Tab 컴포넌트 추상화하기 + Emotion 코드 정리

[리팩토링 #2] 로그인 페이지를 리팩토링해보자


Table Of Contents


코드를 깔끔하게 작성하자!


이전 글에 이어서 기존 페이지에 form을 붙이려고 파일을 열었는데 엄청난 코드가 나왔다🤯

무슨 생각으로 만든거지? 아무튼 오놀은 기능 개선을 하면서 코드 스타일도 정리해보자.

prev login page

1. 스타일링 코드 분리


😭 문제 상황

지금 코드에서 가장 큰 문제는 아래 2개인 것 같다.

  1. 스타일링 방법이 통일되지 않음
    • Emotion 라이브러리를 사용하고 있는데, Styled Components를 이용하는 방법과 css Prop을 이용하는 방법이 혼용되고 있었다. css prop의 경우에는 위쪽에 정의해놓은 객체도 있고, JSX 중간에 정의 없이 인라인으로 사용한 경우도 섞여있었다. 거의이중인격인 수준🤔
  2. JSX 코드와 스타일링 코드가 분리되지 않음
    • 파일이 너무 길다. 스타일링 코드만 따로 빼면 300줄 -> 150줄 + 150줄정도로 나눠질 것 같다.

💡 해결하기

Emotion라이브러리는 그대로 사용하는데, 스타일링 방식을 통일한다. 그 전에 Emotion 라이브리리 사용법을 좀 보고 가야겠다.

Emotion

  • Emotion은 JavaScript로 css 스타일링 코드를 작성하기 위한 라이브러리이다. (공식 소개)
  • Emotion 라이브러리를 사용하는 방식은 크게 2가지로 나눌 수 있다.

1. 프레임워크에 구애받지 않는 방법(Framework Agnostic)

  • 특정 프레임워크에 의존하지 않는다. (이후에 나오는 방법은 React에서 사용 가능하다)
  • @emotion/css 패키지를 사용한다. (docs →)
  • 추가적인 설정, babel 플러그인 등이 필요하지 않다(SSR을 위해서는 추가 설정이 필요하다)
  • css 함수를 통해서 class name들을 만들 수 있고, cx 함수를 통해서 이들을 조합할 수 있다. (cx 함수 docs →)

css 함수의 사용법만 간단히 살펴보자. css 함수 명세 →

css 함수는 template literal, object, object 리스트 형태로 스타일 코드를 받아서 class name을 반환한다.

  1. template literal
    • 아래 코드처럼 @emotion/css에서 css함수를 import하고, css 코드를 백틱(`)으로 감싸 템플릿 리터럴(template literal)로 만든 다음, css 함수를 호출한다.
    • 이렇게 템플릿 리터럴을 함수가 파싱하도록 하는 방법을 Tagged template이라고 부른다.
    import { css } from '@emotion/css' const app = document.getElementById('root') const myStyle = css` color: rebeccapurple; ` app.classList.add(myStyle)
  2. object
    • css에 object를 그대로 넘겨줄 수도 있다.
    import { css } from '@emotion/css' const color = 'darkgreen' render( <div className={css({ backgroundColor: 'hotpink', '&:hover': { color } })} > This has a hotpink background. </div> )
  3. object 리스트
    • css 함수의 인자로 object의 배열을 넘겨줄 수 있다.
    • className={css([style1, style2])}

2.React와 함께 쓰는 방법

  • @emotion/react 패키지를 사용한다.
  • 기존의 style prop과 비슷하게, css prop을 이용해서 스타일링할 수 있다.
  • SSR을 위해서 추가 설정이 필요 없다.
  • @emotion/styled 패키지를 설치하여 컴포넌트를 생성할 때 styled API를 사용할 수 있다. (Styled Components docs →)

가장 기본적인 방법은 css props을 통해 스타일을 정의하는 방법이다.

css props을 사용하기 위해서도 2가지 설정법이 있는데, 하나는 Babel Preset을 이용하는 것이고, 다른 하나는 JSX Pragma를 이용하는 것이다.

이 중에서 Babel Preset은 Create React App 등 커스텀 Babel 설정을 허용하지 않는 환경에서는 사용하지 못한다. 나는 Create Next App으로 프로젝트를 생성했는데, 여기에서는 Babel 설정을 커스텀할 수 있다. 하지만 Next.js 13 이상 버전에 대한 지원이 불완전한 탓인지 작동하지 않았다. 따라서 나는 JSX Pragma를 설정해줬다.

파일 맨 위에 /** @jsxImportSource @emotion/react */ 처럼 써주고,

<div css={{ backgroundColor: 'hotpink', '&:hover': { color: 'lightgreen' } }} > This has a hotpink background. </div>

처럼 css prop을 넘겨주면 된다. Template literal, object, object 리스트 모두 사용 가능하다.


두 방식 모두 공급업체 접두사(Vendor Prefix), 선택자 중첩, 미디어 쿼리를 지원한다.

3. 어떻게 쓸 것인가

  • Next.js를 쓰는 만큼, @emotion/css보다는 SSR 설정이 기본으로 되어 있는 @emotion/react 패키지를 사용한다.
  • 기존에 styled component를 사용하던 부분은 전부 css prop으로 교체한다. styled component는 여러 스타일을 중복으로 적용하기 어렵다는 점이 아쉬워서 css prop만 쓰겠다. css prop은 object 또는 object 리스트를 사용한다.
  • 스타일을 정의한 object는 파일을 분리한다.

2. Tab 컴포넌트 만들기


로그인 페이지에서는 이렇게 로그인과 회원가입 탭을 선택할 수 있어야 한다.

Tab demo

😭 문제 상황

현재는 Tab 구현이 추상화되지 않았다. Tab 관련 코드를 분리해서 가독성과 재사용성을 높혀보자.

현재 코드를 보면, 로그인 상태인지 아닌지를 저장하고, 해당 변수에 따라서 내용을 렌더링한다. 선택된 탭으로 이동하는 코드나, 로그인, 회원가입 관련 코드가 모두 한 파일에 있다.

// /login/page.tsx const [isSignIn, setIsSignIn] = useState(true); { isSignIn && <>로그인 관련 코드...</>; } { !isSignIn && <>회원가입 관련 코드들...</>; }

💡 해결하기

이제 Tabs, Tab 컴포넌트를 만들어 현재 탭 상태 관리와 조건부 렌더링 기능을 넘겨주자. SignInFormSignUpForm도 컴포넌트로 분리해준다. 구조는 MUI를 참고했다.

// /login/page.tsx <Tabs> <Tab label="로그인"> <SignInForm /> </Tab> <Tab label="회원가입"> <SignUpForm /> </Tab> </Tabs>

Tab 컴포넌트

헤더에 표시되는 이름은 각자 Tab에서 관리해야 할 것 같아서 여기에 넣어줬다. 이외에는 다른 기능이 필요하지 않아서 그냥 children을 그대로 반환하도록 했다.

Tab 컴포넌트에서 label을 참조하는 일이 없는데, label을 여기에 둬도 괜찮은지 잘 모르겠다.

import { ReactElement } from "react"; export default function Tab({ label, children, }: { label: string; children: ReactElement; }) { return children; }

Tabs 컴포넌트

탭 헤더를 만드는 부분과 내용을 보여주는 부분으로 나뉜다.

  • children으로 Tab 컴포넌트 리스트가 들어오기 때문에 헤더는 children을 순회하면서 label 속성을 렌더링해주면 된다.
  • 현재 선택된 탭 아이템은 useState에 인덱스로 저장한다. 해당 인덱스로 몇 번째 children Tab을 렌더링할 지 결정할 수 있다.
export default function Tabs({ children }: { children: ReactElement[] }) { const [selectedIndex, setSelectedIndex] = useState(0); return ( <div> <div css={tabHeader}> {children.map((tab, index) => ( <button css={[tabItem, index === selectedIndex && selectedTabItem]} onClick={() => { setSelectedIndex(index); }} > {tab.props.label} </button> ))} </div> {children[selectedIndex]} </div> ); }

3. 경고 문구 띄우기


회원가입 시에는 양식에 맞는 비밀번호를 입력하도록 경고를 해줘야 한다!

warning

<기존 서비스의 경고 문구>

😭 문제 상황

현재는 모든 조건을 하드코딩해서 렌더링해주고 있다😇

<div> {checkPasswordLength({ password: inputData.password, }) ? ( <CheckIcon size={size.icon.small} color={color.valid} /> ) : ( <XIcon size={size.icon.small} color={color.invalid} /> )} 6자리 이상 </div> <div> {checkConfirmPassword... ? ... : ...} 비밀번호 일치함 </div>

앞으로 규칙 리스트가 바뀔 수 있고, 조건 체크 함수와 메세지 사이에 거리가 있어 둘을 한번에 파악하기 힘들다.

💡 해결하기

조건 체크 함수와 경고 메세지를 묶어서 객체로 만들어줬다. MIN_PASSWORD_LENGTH도 상수로 빼줬다.

const passwordRules = [ { message: `비밀번호는 ${MIN_PASSWORD_LENGTH}자리 이상이어야 합니다.`, check: () => checkPasswordLength(passwordValue), }, { message: "비밀번호가 일치하지 않습니다.", check: () => checkConfirmPassword(passwordValue, confirmPasswordValue), }, ];

이제 check 함수를 통과하지 못하는 메세지만 묶어서 warningMessageList에 저장하고, warningMessageList를 출력해주면 된다.

setWarningMessageList( passwordRules.filter((rule) => !rule.check()).map((rule) => rule.message) );

password check remake

잘 작동한다.

❓ 이전에는 조건을 모두 보여주고 만족한 조건은 초록색으로 색만 바꿔주었는데, 지금은 불만족한 메세지만 보여주기 때문에 입력에 따라서 버튼 레이아웃이 밀렸다 돌아왔다 한다. 예전처럼 돌리는게 낫나?

4. 로그인/회원가입 폼 추상화


😭 문제 상황

로그인/회원가입 폼을 별도의 컴포넌트로 구성했는데, type, placeholder, autoComplete 등의 속성을 가지는 text input이나 formAction이 달린 submit button처럼 구조가 비슷한 걸 알 수 있다. Form 컴포넌트에서 공통된 로직을 처리하고, SignInForm, SignUpForm에서는 Form을 import해서 적절한 prop을 넘기도록만 하자.

💡 해결하기

추려보니 data(input field 리스트), onSubmit 함수, 버튼 텍스트만 넘겨주면 로그인 폼은 완벽하게 작동한다.

// /login/forms/Form.tsx export default function Form({ data, onSubmit, buttonText, }: { data: Field[]; onSubmit: (formData: FormData) => Promise<void>; buttonText: string; }) { return ( <form> <div> {data.map((field: Field, index: number) => ( <div key={index}> <p>{field.label}</p> <input name={field.field} type={field?.type} placeholder={field.placeholder} autoComplete={field?.autoComplete} required /> </div> ))} </div> <button formAction={onSubmit}>{buttonText}</button> </form> ); }

로그인 폼은 데이터 및 서버 액션을 받아와서 그대로 prop으로 내려주면 된다.

// /login/forms/SignInForm.tsx import { signin } from "../actions"; import { signInFieldList } from "./fieldData"; import Form from "./Form"; export default function SignInForm() { return <Form data={signInFieldList} onSubmit={signin} buttonText="로그인" />; }

참고로 field 데이터는 이런 식으로 정의되어 있다.

export const signInFieldList: Field[] = [ { label: "이메일", field: "email", placeholder: "이메일", type: "email", autoComplete: "email", }, { label: "비밀번호", field: "password", placeholder: "6자리 이상", type: "password", autoComplete: "current-password", }, ];

회원가입 폼은 이것보다는 복잡하다.

  • 비밀번호 양식이 틀린 경우 등에 경고 메세지를 보여줘야 함 → prop으로 warningMessageList를 넘겨줘야 함
  • 비밀번호/비밀번호 확인 필드에서 일어나는 모든 입력마다 check 함수들을 돌려줘야 함 → onChange 함수도 넘겨줘야 함
  • 유저의 입력값을 알기 위해서 formData를 뜯어봐야함 → form에 ref를 달아야 함 → ref도 넘겨줘야 함

순식간에 새로 넘겨줘야 할 prop들이 3개나 생겼다. 어쩔 수 없지... 다 넘겨주고, Form에도 새로운 prop들 타입을 선언해준다.

// /login/forms/Form.tsx export default function Form({ data, formRef, onChange, warningMessageList = [], onSubmit, buttonText, }: { data: Field[]; formRef?: RefObject<HTMLFormElement>; onChange?: () => void; warningMessageList?: string[]; onSubmit: (formData: FormData) => Promise<void>; buttonText: string; }) { ... }
// /login/forms/SignUpForm.tsx import { useRef, useState } from "react"; import { signup } from "../actions"; import { MIN_PASSWORD_LENGTH, checkConfirmPassword, checkPasswordLength, } from "@/utils/singUpValidation"; import Form from "./Form"; import { signUpFieldList } from "./fieldData"; export default function SignUpForm() { const formRef = useRef<HTMLFormElement>(null); const [warningMessageList, setWarningMessageList] = useState<string[]>([]); const handleChange = () => { const passwordValue = formRef.current!.elements["password"].value; const confirmPasswordValue = formRef.current!.elements["confirmPassword"].value; const passwordRules = [ { message: `비밀번호는 ${MIN_PASSWORD_LENGTH}자리 이상이어야 합니다.`, check: () => checkPasswordLength(passwordValue), }, { message: "비밀번호가 일치하지 않습니다.", check: () => checkConfirmPassword(passwordValue, confirmPasswordValue), }, ]; setWarningMessageList( passwordRules.filter((rule) => !rule.check()).map((rule) => rule.message) ); }; return ( <Form formRef={formRef} onChange={handleChange} warningMessageList={warningMessageList} data={signUpFieldList} onSubmit={signup} buttonText="회원가입" /> ); }

5. form validation


그런데 아무 폼이나 제출해서는 안 된다. 예를 들어서, 이메일이 [email protected] 형식으로 되어 있는지, 비밀번호가 6자리 이상인지 등을 체크해서 올바른 형식인지 확인해야 한다. 보통은 클라이언트에서 한 번, 서버에서 한 번 확인하나... Server Action에서 form validation 하기는 아직 지원이 잘 안 되는 것 같다.

그래서 클라이언트에서만 validation해주겠다.Client-side validation을 하려면 두 가지 방법이 있다.

  • HTML form을 이용하기.
    • html input 태그를 사용할 때, required, minlength, pattern등을 이용해서 특정 형식의 데이터만 들어가게 할 수 있다.
  • JavaScript를 이용하기.
    • JS를 이용해서도 validation이 가능하다. onSubmit 함수 실행 전에 특정 로직을 수행한다던가, HTML의 element에 접근해서 invalid시에 뜨는 메세지를 수정한다던가...

HTML form의 속성들은 일부 사용중이라 minlength 속성만 추가해줬다. 이걸로 웬만한 입력은 방지할 수 있지만, 비밀번호-비밀번호 확인 필드의 불일치같은 항목은 잡아낼 수 없다. 이 부분은 JS를 이용해서 체크 함수를 돌리고, 만약 통과하지 못한다면 button에 disabled 속성을 넣어주겠다.

이미 오류 리스트가 warningMessageList라는 이름으로 넘어오고 있으므로 그대로 써준다.

// /login/forms/Form.tsx <button css={submitButton} formAction={onSubmit} disabled={warningMessageList.length > 0} > {buttonText} </button>;

6. 에러 처리


Form을 제출만 한다고 끝나는게 아니다. 로그인이나 회원가입에 실패한다면 유저에게 실패했다고 말해줘야 하는데, 이걸 /login에서 처리하면 정말 좋겠지만... 아직 Server Action에서 client로 응답을 다시 보내는 기능은 없는거같다...🫠 이전 글에서 참고했던 Supabase 튜토리얼에서도 로그인 오류 시 /error 페이지로 리다이렉트 시키고 있었다.

그래서 저도 리다이렉트했습니다. 더 좋은 방법이 있는지 모르겠어요😇

// /login/actions.ts if (error) {   redirect(`/error?message=${error.message}`); }

에러 메세지는 쿼리 스트링으로 넘겨준다.

error page

올바른 로그인 정보가 아닌 경우. Supabase의 에러 메세지를 그대로 가져온다.

마무리


로그인 및 회원가입 기능 개선은 일단 여기까지... 왜냐면 갑자기 pdf 빌드가 안됨😭

참고