프론트엔드 과제 회고 - 복잡한 폼 만들기

김병훈·2024년 7월 10일
0

assignment

목록 보기
2/2
post-thumbnail

과제 내용

평가 기준

  • 기능 구현

개요

  • 메시지 발송 플랫폼 구현

요구사항

필수 사용 언어 및 라이브러리

  • TypeScript, React
  • yarn
  • Emotion

디자인

  • 제공한 피그마 파일을 참고하여 레이아웃을 구성합니다.

기능 구현

  • 페이지별 요구사항

    1. 로그인
      • 사용자의 입력 값에 대해 유효성 검증을 진행합니다.
      • 입력 값이 유효하지 않은 경우, 아래 작업을 수행하도록 구현합니다.
        • 유효하지 않은 Input 테두리가 빨간색으로 변합니다.
        • 유효하지 않은 Input으로 Focus 됩니다.
      • 로그인에 성공한 경우, 서비스 페이지로 이동합니다.
    2. 회원가입
      • 사용자의 입력 값에 대해 유효성 검증을 진행합니다.
      • 입력 값이 유효하지 않은 경우, 아래 작업을 수행하도록 구현합니다.
        • 유효하지 않은 Input 테두리가 빨간색으로 변합니다.
        • 유효하지 않은 Input으로 Focus 됩니다.
      • 회원가입에 성공한 경우, 로그인 페이지로 이동합니다.
    3. 서비스(메시지 발송)
      • 메시지 발송 폼을 구현합니다.
  • 별도의 요구사항이 없는 것은 지원자가 판단하여 개발합니다.


구현 내용

프로젝트 구조

/src
	/api (backend API 관리)
	/components (공용 컴포넌트)
	/contexts (공용 컨텍스트)
	/hooks (공용 훅)
	/icons
	/pages (라우트 기본 단위를 페이지로 설정)
	/schemas (zod 스키마)
	/styles
	/types
	/utils
	main.tsx

라우팅 (w. react-router)

구현해야 하는 페이지는 총 3개

  • 회원가입 /auth/signup
  • 로그인 /auth/login
  • 서비스 /

최근에는 주로 Nextjs를 사용했었기 때문에, 페이지 라우팅을 위해 react-router를 사용하는 것은 익숙하진 않았었어요.

공식 문서를 살펴보면서, 라우팅을 위한 작업을 진행했습니다.

  • 프로젝트 구조
    NextJS의 페이지 기반 라우팅 방식이 익숙했기에, 이 방식을 살려서 구조를 설계하고자 했습니다.
	디렉토리
    /src
    	/pages
    		page.tsx
    		/messages
    			page.tsx
    		/auth
    			/login
    				page.tsx
    			/register
    				page.tsx
  • createBrowserRouter
    공식 문서 상, 웹 서비스의 라우팅을 위해 권장하는 방식인 createBrowserRouter를 사용했습니다.

    // main.ts
    
    const router = createBrowserRouter([
      {
        path: "/",
        element: <RootLayout />,
        children: [
          {
            path: "/",
            element: <RootPage />,
          },
          {
            path: "/messages",
            element: <MessagesPage />,
          },
        ],
      },
      {
        path: "/auth/login",
        element: <LoginPage />,
      },
      {
        path: "/auth/register",
        element: <RegisterPage />,
      },
    ]);
  • layout
    RootPage와 MessagesPage에서 공통으로 사용하는 레이아웃 및 로직이 존재했기에, 이를 위해 RootLayout을 구성했습니다.

    • 공통 로직
      로그인 여부를 체크하고, 로그인이 되어있지 않다면 로그인 페이지로 리다이렉트합니다.

      // layout.tsx
      
      const RootLayout = () => {
      	// 로그인 체크
        return (
          <LayoutWrapper>
            <Sidebar />
            <Outlet />
          </LayoutWrapper>
        );
      };

공용 컴포넌트 (FormField)

로그인, 회원가입, 메시지 발송 등 여러 폼을 구현해야 했기 때문에, 폼의 구성요소를 컴포넌트화하여, 재사용할 수 있도록 했습니다.

공용 컴포넌트는 shadcn/ui 를 참고하여 구조화했습니다.

  • 사용 예제
    <form>
    	<FormField>
    		<FormFieldLabel>라벨</FormFieldLabel>
    		<Input
    			ref={inputRef}
    			value={value}
    			onChange={onChange}
    			onBlur={onBlur}
    			disabled={disabled}
    			name={name}
    		/>
    		<FormFieldError>{error.message}</FormFieldError>
    	</FormField>
    </form>
  • 구현 상세 내용
    • FormField
      label과 input을 연결할 수 있는 htmlfor 값을 Context API를 통해 관리합니다.
    • Input
      react-hook-form 을 활용했을 때, 해당 컴포넌트에 ref를 전달하기 때문에 이를 위해 forwardRef를 사용하여 컴포넌트를 정의했습니다.
    • FormFieldError
      에러 메시지를 렌더링합니다.

복잡한 폼 관리

폼을 관리하기 위해 react-hook-form 라이브러리를 사용했습니다.

각 필드의 유효성을 검증하기 위해서 zod@hookform/resolver 라이브러리를 활용했습니다.

  • 구현 예시
    const formSchema = z.object({
    	header: z.string().max(100, "헤더는 최대 100자까지 가능합니다"),
    	recipents: z.array(z.object({ phoneNumber: z.string() })).min(1, "수신자를 입력해주세요"),
    });
    
    type FormValues = z.infer<typeof formSchema>;
    
    const MessageForm = () => {
    	const form = useForm<FormValues>({
    		resolver: zodResolver(formSchema), // 유효성을 검증합니다.
    		defaultValues: {
    			header: "",
    			recipents: [{ phoneNumber: "" }],
    		},
    	});
    	
    	// ...
    	
    	return (
    		<form>
    			<Controller
    				control={form.control}
    				name="header"
    				render={({ field, fieldState }) => (
    					<FormField>
    						<FormFieldLabel>헤더</FormFieldLabel>
    						<Input {...field} hasError={!!fieldState.error} />
    						<FormFieldError>{fieldState.error?.message}</FormFieldError>
    					</FormField>
    				)}
    			/>
    		</form>
    	);
    };

API 에러 핸들링 (로그인, 회원가입)

백엔드로의 요청에 대한 응답을 성공과 실패 두가지 케이스로 나누어, 구조화하였습니다.
에러가 발생했을 때, 어떤 데이터와 관련이 있는지도 전달하여 해당 필드에 에러를 표현하였습니다.

// auth.ts

type AuthAPIResponse<D = unknown, T = unknown> = 
  | {
      success: true;
      data: D;
    }
  | {
      success: false;
      target: T;
      message: string;
    }

const login = async (values: {
  email: string;
  password: string;
}): Promise<AuthAPIResponse<unknown, Path<LoginInput>>> => {
  const url = `${API_DOMAIN}/api/auth/signin`;
  const res = await fetch(url, {
    method: "POST",
    body: JSON.stringify(values),
    headers: {
      "Content-Type": "application/json",
    },
  });
  
  const isSuccess = res.ok;
  
  if (isSuccess) {
    const data = await res.json();
    return {
      success: true,
      data,
    };
  }
  
  switch (res.status) {
    case 400: {
      const data = await res.json();
      // 백엔드 응답에 따른 분기처리
      const isInValidEmail = data.detail === "Invalid email";
      const isInValidPassword = data.detail === "Invalid password";
      
      if (isInvalidEmail) {
        return {
          success: false,
          target: "email",
          message: "가입된 이메일이 아닙니다.",
        };
      }
      
      if (isInvalidPassword) {
        return {
          success: false,
          target: "password",
          message: "비밀번호가 일치하지 않습니다.",
        };
      }
      
      throw new Error("알 수 없는 오류가 발생했습니다.");
    }
      
    default: {
      throw new Error("알 수 없는 오류가 발생했습니다.");
    }
  }
}

해당 API 메서드를 호출하는 컴포넌트에서는 다음과 같이 에러를 처리할 수 있었습니다.

const form = useForm(...);
                     
const onSubmit = async (values) => {
  try {
  	const res = await login(values);
    
    const isSuccess = res.success;
    
    if (!isSuccess) {
      form.setError(
        res.target,
        {
          type: "",
          message: res.message,
        },
        { shouldFocus: true },
      );
    }
    
    // ...
  } catch (e) {
    if (e instanceof Error) {
      alert(error.message);
    } else {
      console.error(error);
    }
  }
};

추가한 라이브러리

  • react-router
    페이지 라우팅

  • zod
    유효성 검증(form)

  • react-hook-form
    form 관리

  • @hook-form/resolver
    zod를 활용하여, react-hook-form의 유효성 검증을 수행하기 위함

  • react-hot-toast
    토스트


어려웠던 부분

emotion

기존에는 주로 tailwind를 사용하여, 스타일링을 진행했었다 보니 emotion을 통해 스타일링을 하는 것이 익숙치 않아, 고민되는 부분이 있었습니다.

  • css, styled, Global 등 스타일링을 할 수 있는 방법이 다양하게 있어, 어떤 방식으로 스타일링을 진행할 것인가?

    • 코드 가독성을 저해하지 않는 방법은 무엇일까?
    • 스타일링을 위한 코드가 컴포넌트 구조 혹은 로직에 대한 이해를 위한 가독성을 떨어뜨리지 않도록 하려면?
    • 스타일이 선언된 위치와 해당 스타일이 적용되는 요소 간, 간격이 멀지 않도록 하고자 함.
    • 재사용 하지 않는 스타일은 어디에 정의할 것인가?
  • 재사용 가능한 스타일 관련 코드는 어떻게 관리할 것인가?

    • Global 을 사용하여, 해당 컴포넌트의 하위 컴포넌트에 대해 일관된 스타일을 적용하도록 시도해 봤지만, 이 방법은 스타일링 코드와 적용되는 부분 간 거리가 떨어져 있기에 스타일을 수정하기 위해서 찾기 힘들 수 있으며, 해당 코드가 어디에 영향을 미치는지 이해하기 어려울 수 있기에 사용하지 않는 것이 좋겠다고 판단.
profile
재밌는 걸 만드는 것을 좋아하는 메이커

0개의 댓글