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

YouGyoung·2024년 3월 28일
0

기존 코드의 문제점

현재, 회원가입 페이지의 Form 컴포넌트 안에 회원가입 관련 모든 비즈니스 로직이 포함되어 있어 가독성이 좋지 않은 상태입니다.

따라서 회원가입 처리 로직을 별도의 파일로 분리하여 코드의 모듈성과 재사용성을 높이고, 전체 프로젝트의 유지보수성을 향상시키는 방향으로 리팩토링을 진행할 계획입니다.

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

'use client';
import React, { useState } from 'react';
import { auth, db } from '@/app/firebaseConfig';
import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { AppDispatch } from '@/types/reduxTypes';
import { setUserInfo } from '@/slices/userSlice';
import { useRouter } from 'next/navigation';
import { addDoc, collection, doc, setDoc } from 'firebase/firestore';
import { setCartItems, setWishlist } from '@/slices/productSlict';

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

  const dispatch: AppDispatch = useAppDispatch();
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsRegstering(true);
    if (email === '' || password === '' || name === '') {
      setError('이메일과 패스워드 그리고 이름 모두 입력해 주세요.');
      setIsRegstering(false);
      return;
    }
    if (name.length > 6) {
      setError('이름은 6글자 이하로 입력해 주세요');
      setIsRegstering(false);
      return;
    }

    setError('');
    try {
      await createUserWithEmailAndPassword(auth, email, password)
        .then((userCredential) => {
          const user = userCredential.user;
          updateProfile(user, {
            displayName: name,
          });
          dispatch(setUserInfo(user));
          setDoc(doc(db, 'users', email), {
            wishlist: {},
            cartItems: {},
            //  purchaseList: {},
          });

          dispatch(setWishlist({}));
          dispatch(setCartItems({}));
          router.push('/');
        })
        .catch((error) => {
          setIsRegstering(false);
          const errorCode = error.code;
          switch (errorCode) {
            case 'auth/email-already-in-use':
              setError('이미 사용 중인 이메일이에요.');
              return;
            case 'auth/weak-password':
              setError('비밀번호는 6글자 이상으로 입력해 주세요.');
              return;
            case 'auth/network-request-failed':
              setError(
                '네트워크 연결에 실패했어요. 잠시 후에 다시 시도해 주세요.'
              );
              return;
            case 'auth/invalid-email':
              setError('이메일 형식을 올바르게 입력해 주세요.');
              return;
            case 'auth/internal-error':
              setError('잘못된 요청이에요.');
              return;
            default:
              setError('회원가입에 실패했어요.' + errorCode);
              return;
          }
        });
    } catch (error) {
      console.error('회원가입 에러:', error);
    }
  };

  return (
    <>
      <div className="text-red-600 mb-4">{error}</div>
      <form
        onSubmit={handleSubmit}
        className="flex flex-col items-center w-full"
      >
        <div className="flex flex-col items-center w-full">
          <input
            name="name"
            type="name"
            value={name}
            placeholder="이름 (6글자 이하)"
            autoComplete="username"
            onChange={(e) => setName(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="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="패스워드 (6글자 이상)"
            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
            type="submit"
            className="dark:bg-white dark:text-black dark:hover:bg-zinc-300 h-12 bg-zinc-900   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"
            disabled={isRegistering}
            aria-label="회원가입하기"
          >
            {isRegistering ? '회원가입 중...' : '회원가입하기'}
          </button>
        </div>
      </form>
    </>
  );
};

export default Form;

분리 후 코드

다음과 같이 회원가입 관련 파일 트리를 구성했습니다.

- src
  - app
    - account
      - register
        - components
          - Form.tsx
  - _utils
    - signUpFirebase.ts
    - initializeUserDataFireStore.ts
    - updateDisplayNameFirebase.ts
  - hooks
    - useSignUpUser.ts
    - useSignInUser.ts
    - useStore.ts
  - schema
    - userValidationSchema.ts

_utils 폴더에는 특정 비즈니스 로직이나 애플리케이션의 특정 기능을 지원하기 위한 유틸리티 함수들을 포함하려 합니다. 여기에서는 해당 애플리케이션의 로직을 처리하는 데 특화된 함수들을 찾을 수 있으며, 이 함수들은 애플리케이션의 특정 부분에서만 사용됩니다.

utils 폴더도 따로 있습니다.
이 폴더는 프로젝트 전반에 걸쳐 재사용 가능한 범용 유틸리티 함수를 모아두는 곳입니다. 해당 유틸리티 함수들은 특정 비즈니스 로직이나 애플리케이션의 도메인 로직에 종속되지 않고, 다양한 컨텍스트에서 범용적으로 사용될 수 있는 코드입니다. 예를 들어, 날짜 형식을 변환하거나, 배열을 조작하는 등의 기능을 포함할 수 있겠습니다.

즉, utils 폴더는 애플리케이션 전반에 걸쳐 재사용 가능한 일반적인 유틸리티를 포함하는 반면, _utils 폴더는 특정 비즈니스 로직을 처리하기 위한 유틸리티 함수들을 모아두는 용도로 사용하게 되었습니다.

사실, 이번에 커스텀 훅을 처음 만들어 봤는데요.

커스텀 훅을 효율적으로 사용하면 코드의 가독성과 유지보수성을 크게 높일 수 있다는 것을 깨달았습니다. 이전 코드에서는 회원가입 로직을 수행하며 로딩 상태를 나타내는 isRegistering 상태 변수를 사용했었습니다. 하지만 리팩토링 후에는 signUpStatus를 사용해 회원가입 과정의 모든 상태를 한 곳에서 관리할 수 있게 되었습니다.

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

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

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

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const result: boolean = await signUpUser(email, password, displayName);
    if (result) {
      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="displayName"
            type="displayName"
            value={displayName}
            placeholder="이름 (6글자 이하)"
            autoComplete="username"
            onChange={(e) => setDisplayName(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="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="패스워드 (6글자 이상)"
            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
            type="submit"
            className="dark:bg-white dark:text-black dark:hover:bg-zinc-300 h-12 bg-zinc-900   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"
            disabled={signUpStatus === 'loading'}
            aria-label="회원가입하기"
          >
            {signUpStatus === 'loading' ? '회원가입 중...' : '회원가입하기'}
          </button>
        </div>
      </form>
    </>
  );
};

export default Form;

src/hooks/useSignUpUser.ts

import signUpFirebase from '@/_utils/signUpFirebase';
import { signUpSchema } from '@/schema/userValidationSchema';
import { useState } from 'react';
import { ZodIssue } from 'zod';

const useSignUpUser = () => {
  const [status, setStatus] = useState('idle');
  const [errorMessage, setErrorMessage] = useState<string>('');

  const signUpUser = async (
    email: string,
    password: string,
    displayName: string
  ) => {
    setStatus('loading');
    const validatedFields = signUpSchema.safeParse({
      email: email,
      password: password,
      displayName: displayName,
    });

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

    try {
      await signUpFirebase({ email, password, displayName });
      setStatus('success');
      return true;
    } catch (error) {
      const err = error as Error;
      setStatus('error');
      setErrorMessage(err.message);
      return false;
    }
  };

  return { status, signUpUser, errorMessage };
};
export default useSignUpUser;

src/_utils/signUpFirebase.ts

import { auth } from '@/app/firebaseConfig';
import { registerUserProps } from '@/types/globalTypes';
import { createUserWithEmailAndPassword } from 'firebase/auth';
import initializeUserDataFireStore from './initializeUserDataFireStore';
import updateDisplayNameFirebase from './updateDisplayNameFirebase';
import { FirebaseError } from 'firebase/app';
import { FirebaseAuthError } from '@/error/firebaseAuthError';

const signUpFirebase = async ({
  email,
  password,
  displayName,
}: registerUserProps): Promise<string> => {
  try {
    const userCredential = await createUserWithEmailAndPassword(
      auth,
      email,
      password
    );
    const user = userCredential.user;
    if (displayName !== '') await updateDisplayNameFirebase(user, displayName);
    await initializeUserDataFireStore({ email });
    return 'success';
  } catch (error) {
    if (error instanceof Error) {
      const firebaseError = error as FirebaseError;
      const code = firebaseError.code;
      const message = firebaseError.code;
      throw new FirebaseAuthError(code, message);
    } else {
      console.error('signUpFirebase Error:', error);
      throw new Error('알 수 없는 오류가 발생했습니다.');
    }
  }
};

export default signUpFirebase;

src/_utils/initializeUserDataFireStore.ts

import { db } from '@/app/firebaseConfig';
import { doc, setDoc } from 'firebase/firestore';
const initializeUserDataFireStore = async ({ email }: { email: string }) => {
  try {
    setDoc(doc(db, 'users', email), {
      wishlist: {},
      cartItems: {},
    });
    return 'success';
  } catch (error) {
    return 'error';
  }
};

export default initializeUserDataFireStore;

src/_utils/updateDisplayNameFirebase.ts

import { User, updateProfile } from 'firebase/auth';

const updateDisplayNameFirebase = async (
  user: User,
  displayName: string
): Promise<Error | string> => {
  try {
    await updateProfile(user, {
      displayName: displayName,
    });
    return 'success';
  } catch (error) {
    console.error('updateDisplayNameFirebase Error: ' + error);
    throw new Error('error');
  }
};
export default updateDisplayNameFirebase;

import { signIn } from 'next-auth/react';
const signInUser = async (email: string, password: string) => {
  const result = await signIn('credentials', {
    redirect: false,
    email: email,
    password: password,
  });
  if (result?.ok) {
    return result?.ok;
  } else {
    throw result?.error;
  }
};
export default signInUser;

src/hooks/useSignInUser.ts

import signUpFirebase from '@/_utils/signUpFirebase';
import { signUpSchema } from '@/schema/userValidationSchema';
import { useState } from 'react';
import { ZodIssue } from 'zod';

const useSignUpUser = () => {
  const [status, setStatus] = useState('idle');
  const [errorMessage, setErrorMessage] = useState<string>('');

  const signUpUser = async (
    email: string,
    password: string,
    displayName: string
  ) => {
    setStatus('loading');
    const validatedFields = signUpSchema.safeParse({
      email: email,
      password: password,
      displayName: displayName,
    });

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

    try {
      await signUpFirebase({ email, password, displayName });
      setStatus('success');
      return true;
    } catch (error) {
      const err = error as Error;
      setStatus('error');
      setErrorMessage(err.message);
      return false;
    }
  };

  return { status, signUpUser, errorMessage };
};
export default useSignUpUser;

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/schema/userValidationSchema.ts

export const signUpSchema = z.object({
  email: z.string().email({ message: '유효하지 않은 이메일 형식입니다.' }),
  password: z
    .string()
    .min(6, {
      message: '패스워드는 최소 6글자 이상이어야 합니다.',
    })
    .regex(/[a-z]/, {
      message: '패스워드에는 최소 1개 이상의 소문자가 포함되어 있어야 합니다.',
    })
    .regex(/[A-Z]/, {
      message: '패스워드에는 최소 1개 이상의 대문자가 포함되어 있어야 합니다.',
    })
    .regex(/[0-9]/, {
      message: '패스워드에는 최소 1개 이상의 숫자가 포함되어 있어야 합니다.',
    })
    .regex(/[^A-Za-z0-9]/, {
      message:
        '패스워드에는 최소 1개 이상의 특수문자가 포함되어 있어야 합니다.',
    }),
  displayName: z
    .string()
    .max(6, { message: '이름은 6글자 이하만 입력할 수 있습니다.' }),
});
profile
프론트엔드 개발자

0개의 댓글

관련 채용 정보