[리팩토링] Next.js 쇼핑몰 프로젝트 - 로그인 컴포넌트에서 비지니스 로직 분리하기

YouGyoung·2024년 3월 29일
0

기존 코드의 문제점

회원가입 컴포넌트에서와 마찬가지로 로그인 관련 비지니스 로직이 전부 로그인 Form 컴포넌트 내에 포함된 상태라 가독성이 나쁜 상태입니다.

회원가입 컴포넌트 리팩토링 진행 시 로그인과 스토어 초기화 커스텀 훅도 작성을 했었기 때문에 비교적 빠르게 리팩토링이 완료할 수 있을 거 같습니다.

src/app/account/login/components/Form.tsx

'use client';
import React, { useState } from 'react';
import { signIn } from 'next-auth/react';
import { signInSchema } from '../../../../schema/userValidationSchema';
import { useRouter } from 'next/navigation';
import { SafeParseReturnType, ZodIssue } from 'zod';

const Form: React.FC = () => {
  const [email, setEmail] = useState<string>('');
  const [password, setPassword] = useState<string>('');
  const [isSignIng, setIsSignIng] = useState<boolean>(false);
  const [error, setError] = useState<string>('');

  const rotuer = useRouter();

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    setIsSignIng(true);
    setError('');

    const validatedFields = signInSchema.safeParse({
      email: email,
      password: password,
    });

    if (!validatedFields.success) {
      const errorMessages = validatedFields.error.issues.map(
        (issue: ZodIssue) => issue.message
      );
      const combinedMessage = errorMessages.join('\n');
      setError(combinedMessage);
      setIsSignIng(false);
      return;
    }

    const result = await signIn('credentials', {
      redirect: false,
      email: validatedFields.data.email,
      password: validatedFields.data.password,
    });
    if (result?.status === 200) {
      rotuer.push('/');
    } else {
      setError(result?.error || '');
    }

    setIsSignIng(false);
  };

  return (
    <>
      <div className="text-red-600  mb-4 errorMessage text-center">{error}</div>
      <form
        onSubmit={handleSubmit}
        className="flex flex-col items-center w-full"
      >
        <div className="flex flex-col items-center w-full">
          <input
            name="email"
            type="email"
            value={email}
            placeholder="이메일"
            autoComplete="username"
            onChange={(e) => setEmail(e.target.value)}
            className="px-4   h-14 bg-gray-50  dark:text-black border-gray-200 border mb-6 outline-none w-11/12 sm:w-4/5 md:w-1/2 lg:w-2/5 xl:w-1/3 "
          />
          <input
            name="password"
            type="password"
            value={password}
            placeholder="패스워드"
            autoComplete="current-password"
            onChange={(e) => setPassword(e.target.value)}
            className="px-4  h-14 bg-gray-50  dark:text-black border-gray-200 border outline-none w-11/12 sm:w-4/5 md:w-1/2 lg:w-2/5 xl:w-1/3 "
          />
        </div>
        <div className="flex flex-col items-center w-full my-8">
          <button
            aria-label="로그인하기"
            type="submit"
            disabled={isSignIng}
            className="h-12  bg-zinc-900 dark:bg-white dark:text-black dark:hover:bg-zinc-300  text-white transition duration-200 ease-in-out  w-11/12 sm:w-4/5 md:w-1/2 lg:w-2/5 xl:w-1/3"
          >
            {isSignIng ? '로그인 중' : '로그인하기'}
          </button>
        </div>
      </form>
    </>
  );
};

export default Form;

분리 후 코드

다음과 같이 로그인 관련 파일 트리를 구성했습니다.

src/
├── _utils/
│   ├── getUserDataFireStore.ts
│   ├── initializeUserDataFireStore.ts
├── app/
│   ├── account/
│   │    └── login/
│   │        └── components/
│   │           └── Form.tsx
│ 	│		 └── page.tsx	
│   ├── api/
│   │    └── auth/
│   │        └── [...nextauth]/
│   │           └── route.ts
├── hooks/
│   ├── useSignInUser.ts
│   └── useStore.ts
├── schema/
│   └── userValidationSchema.ts
└── utils/
    ├── getUserCartItems.ts
    └── getUserWishlistItems.ts

route.ts 파일의 경우 NextAuth 도입할 때 생성한 파일이지만 로그인 및 회원가입 관련 리팩토링을 하면서 수정이 여러 번 발생하고 있습니다.

useUserSignIn 커스텀 훅의 경우, 초기 생성 후 로그인 Form에 적용에서 조금 혼동이 왔던 부분이 있었습니다.
NextAuth의 로그인 기능과, FireBase의 로그인 기능이 각각 따로따로 있는 상태에서 한 번 더 useSignIn으로 감싸다보니 에러가 발생할 때 어디에서 발생했는지 찾기가 힘들었습니다.
또한, 에러가 UI는 표시 되지 않고 console에서만 확인되는 문제도 있었습니다.

그래서 errorMessageForm에서 관리를 했다가 useUserSignIn에서 관리하게 되었습니다.
(회원가입 리팩토링 포스팅에서도 이 부분을 적용시켰습니다. errorMessage를 useUserSignUp으로 옮긴 코드로 수정했습니다.)

initializeUserStore()goHome()은 커스텀 훅 내의 함수로, 로그인이 성공적이면 실행 됩니다.

initializeUserStore()는 사용자의 email정보를 사용해 FireStore DB에 접근하여 wishlistItemscartItems를 가져오는 역할을 합니다.

goHome()은 로그인 완료 시 메인 화면으로 클라이언트 측 라우팅을 수행합니다.

처음에는 Form에서 userSignIn()이 성공적인지 확인한 다음 실행시키도록 Form에 포함되어 있었으나 로그인이라는 비지니스 로직에서 필수인 부분으로 생각되어 userSignIn() 내에서 NextAuth signIn()이 성공적이면 실행하도록 변경하게 되었습니다.

src/app/account/login/components/Form.tsx


'use client';
import React, { useState } from 'react';
import useSignInUser from '@/hooks/useSignInUser';

const Form: React.FC = () => {
  const [email, setEmail] = useState<string>('');
  const [password, setPassword] = useState<string>('');
  const { status: signInStatus, signInUser, errorMessage } = useSignInUser();

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    signInUser(email, password);
  };

  return (
    <>
      <div className="text-red-600  mb-4 errorMessage text-center">
        {errorMessage}
      </div>
      <form
        onSubmit={handleSubmit}
        className="flex flex-col items-center w-full"
      >
        <div className="flex flex-col items-center w-full">
          <input
            name="email"
            type="email"
            value={email}
            placeholder="이메일"
            autoComplete="username"
            onChange={(e) => setEmail(e.target.value)}
            className="px-4   h-14 bg-gray-50  dark:text-black border-gray-200 border mb-6 outline-none w-11/12 sm:w-4/5 md:w-1/2 lg:w-2/5 xl:w-1/3 "
          />
          <input
            name="password"
            type="password"
            value={password}
            placeholder="패스워드"
            autoComplete="current-password"
            onChange={(e) => setPassword(e.target.value)}
            className="px-4  h-14 bg-gray-50  dark:text-black border-gray-200 border outline-none w-11/12 sm:w-4/5 md:w-1/2 lg:w-2/5 xl:w-1/3 "
          />
        </div>
        <div className="flex flex-col items-center w-full my-8">
          <button
            aria-label="로그인하기"
            type="submit"
            disabled={signInStatus === 'loading'}
            className="h-12  bg-zinc-900 dark:bg-white dark:text-black dark:hover:bg-zinc-300  text-white transition duration-200 ease-in-out  w-11/12 sm:w-4/5 md:w-1/2 lg:w-2/5 xl:w-1/3"
          >
            {signInStatus === 'loading' ? '로그인 중' : '로그인하기'}
          </button>
        </div>
      </form>
    </>
  );
};

export default Form;

src/hooks/useSignInUser.ts

import { signInSchema } from '@/schema/userValidationSchema';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import useRouterPush from './useRouterPush';
import useStore from './useStore';

const useSignInUser = () => {
  const [status, setStatus] = useState<string>('idle');
  const [errorMessage, setErrorMessage] = useState<string>('');
  const { initializeUserStore } = useStore();
  const { goHome } = useRouterPush();

  const signInUser = async (email: string, password: string) => {
    setStatus('loading');

    const validatedFields = signInSchema.safeParse({
      email: email,
      password: password,
    });

    if (!validatedFields.success) {
      const errorMessages = validatedFields.error.issues.map(
        (issue) => issue.message
      );
      const combinedMessage = errorMessages.join('\n');
      setStatus('error');
      setErrorMessage(combinedMessage);
      return;
    }

    const result = await signIn('credentials', {
      redirect: false,
      email,
      password,
    });

    if (result?.ok) {
      setStatus('success');
      initializeUserStore(email);
      goHome();
    } else {
      setStatus('error');
      if (result?.error) setErrorMessage(result?.error);
    }
  };

  return { status, signInUser, errorMessage };
};
export default useSignInUser;

src/app/api/auth/[...nextauth]/route.ts

import NextAuth, { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { FirebaseError } from 'firebase/app';
import { FirebaseAuthError } from '@/error/firebaseAuthError';
import signInFirebase from '@/_utils/signInFirebase';

const authOptions: NextAuthOptions = {
  jwt: {
    secret: process.env.NEXTAUTH_SECRET,
  },
  providers: [
    CredentialsProvider({
      name: 'showfinnmore',
      credentials: {
        email: { label: 'email', type: 'text' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials || !credentials.email || !credentials.password)
          return null;
        try {
          const result = await signInFirebase(
            credentials.email,
            credentials.password
          );
          if (result) {
            const { user } = result;
            return {
              id: user.uid,
              name: user.displayName,
              email: user.email,
            };
          } else {
            return null;
          }
        } catch (error) {
          if (error instanceof Error) {
            const firebaseError = error as FirebaseError;
            const errorCode = firebaseError.code;
            const message = firebaseError.message;

            throw new FirebaseAuthError(errorCode, message);
          } else {
            throw new Error('알 수 없는 에러가 발생했습니다.');
          }
        }
      },
    }),
  ],
  pages: {
    signIn: '/account/login',
  },
  callbacks: {
    async redirect({ url, baseUrl }) {
      return baseUrl;
    },
  },
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

src/hooks/useStore.ts

import { resetCartItems, setCartItems } from '@/slices/cartSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { CartItems, WishlistItems } from '@/types/globalTypes';
import { resetWishlistItems, setWishlistItems } from '@/slices/wishListSlice';
import getUserCartItems from '@/utils/getUserCartItems';
import getUserWishlistItems from '@/utils/getUserWishlistItems';

const useStore = () => {
  const dispatch = useAppDispatch();

  const resetStore = () => {
    dispatch(resetCartItems());
    dispatch(resetWishlistItems());
  };

  const initializeUserStore = async (email: string) => {
    const cartItems: CartItems = await getUserCartItems(email);
    const wishlistItems: WishlistItems = await getUserWishlistItems(email);
    dispatch(setCartItems(cartItems));
    dispatch(setWishlistItems(wishlistItems));
  };

  return { resetStore, initializeUserStore };
};

export default useStore;

src/utils/getUserCartItems.ts

import getUserDataFireStore from '@/_utils/getUserDataFireStore';
import { CartItems } from '@/types/globalTypes';
import { DocumentData } from 'firebase/firestore';

const getUserCartItems = async (email: string) => {
  try {
    const userSnapShot: DocumentData = await getUserDataFireStore(email);
    const cartItems: CartItems = userSnapShot.cartItems;
    return cartItems;
  } catch (error) {
    console.error('getUserCartItemsFireStore Error: ', error);
    throw new Error(`failed to get user's cart items`);
  }
};
export default getUserCartItems;

src/utils/getUserWishlistItems.ts

import { DocumentData } from 'firebase/firestore';
import getUserDataFireStore from '../_utils/getUserDataFireStore';
import { WishlistItems } from '@/types/globalTypes';

const getUserWishlistItems = async (email: string) => {
  try {
    const userSnapShot: DocumentData = await getUserDataFireStore(email);
    const wishlistItems: WishlistItems = userSnapShot.wishlistItems;
    return wishlistItems;
  } catch (error) {
    console.error('getUserWishlistItemsFireStore Error: ', error);
    throw new Error(`failed to get user's wishlist items`);
  }
};
export default getUserWishlistItems;

src/_utils/getUserDataFireStore.ts

import {
  DocumentReference,
  DocumentSnapshot,
  getDoc,
} from 'firebase/firestore';

import { db } from '@/app/firebaseConfig';
import { doc } from 'firebase/firestore';

const getUserDataFireStore = async (email: string) => {
  try {
    const userDocumentReference: DocumentReference = doc(db, 'users', email);
    if (!userDocumentReference)
      throw new Error('Failed to get user document reference');

    const userSnapShot: DocumentSnapshot = await getDoc(userDocumentReference);

    if (!userSnapShot.exists()) {
      throw new Error(`UserSnapshot isn't exists`);
    }

    return userSnapShot.data();
  } catch (error) {
    console.error('Failed to get user snapshot:', error);
    throw new Error('Failed to get user snapshot');
  }
};
export default getUserDataFireStore;

마치며

이번 리팩토링에서는 오류 핸들링 부분에서 상당히 애를 먹었습니다.
사용된 모든 함수에서 try-catch 블록을 사용하고 있었고, 그 안에서 발생하는 수많은 throw 문 때문에 코드를 추적하는 일이 매우 복잡했습니다.

이번 리팩토링에서 오류 핸들링의 중요함을 느끼게 됐습니다..

profile
프론트엔드 개발자

0개의 댓글

관련 채용 정보