UMC 8th Web 워크북

🍎 회원가입 관리

minnote29 2025. 4. 13. 21:42

이번 회원가입 폼은 저번에 한 공용 훅 useForm을 잘 만들어 놨지만, 리액트 훅 폼 같은 것을 활용해서 처리할 것이다. (라이브러리) 그리고 타입 안전성을 더 고려할 수 있게 처리하도록 할 것이다. 타입스크립트 계열에서 유효성 검사할 때 많이 쓰는 zod라는 라이브러리를 쓸 것이다. yup도 있지만, zod를 써볼 것이다. 토큰 불러오는 과정도 처리해줄 것이다.

$ pnpm i react-hook-form
$ pnpm i @hookform/resolvers 
$ pnpm i zod

설치를 해주고,

 

스키마 파일을 만들어준다. signup page 에 정의해도 좋을 것 같아서 내부에 스키마를 정의해준다. 스키마는 zod에서 가져오는 것.

그 다음,  useForm을 제공을 해주는데, 이 useForm은 저번에 커스터한 useForm이 아니라 리액트 훅 폼에서 주는 것을 써보는 것이다.

const {
        register, 
        handleSubmit, 
        formState: {errors, isSubmitting}, 
    } = useForm<FormFields>({
        defaultValues: {
            name: "",
            email: "",
            password: "",
        },
        // 스키마를 정의한 것에 대해서 위반하면 에러 메시지를 띄워주기 위해서 resolver를 사용한다.
        resolver: zodResolver(schema),
    });

이런 식으로 스키마에 대해서 처리하는 식을 만들어주고, 화면 구성은 로그인에서 한 것을 가져와서 넣어줬다. 그 다음, register를 활용해서 넘길 수 있도록 수정해준다. touched는 안 쓰니까 지워준다.

<input
                    {...register('email')}
                    name="email"
                    className={`border border-[#333} w-[300px] p-[10px] focus:border-[#807bff] rounded-sm
                        ${errors?.email ? "border-red-500 bg-red-200" : "border-gray-300"}`}
                    type={'email'}
                    placeholder={"이메일"}  
                />
                <input 
                    {...register("password")}
                    name="password"
                    className={`border border-[#333} w-[300px] p-[10px] focus:border-[#807bff] rounded-sm
                        ${errors?.password ? "border-red-500 bg-red-200" : "border-gray-300"}`}
                    type={'password'}
                    placeholder={"비밀번호"}  
                />
                <input 
                    {...register("name")}
                    name="name"
                    className={`border border-[#333} w-[300px] p-[10px] focus:border-[#807bff] rounded-sm
                        ${errors?.name ? "border-red-500 bg-red-200" : "border-gray-300"}`}
                    type={'name'}
                    placeholder={"이름"}  
                />

그 다음은 에러 처리를 해줘야 한다. 

그리고 회원가입 같은 경우는 onSubmit을 통해서 레지스터에 연결된 값들을 받아와서 처리를 해줄 수 있다. 

또한, 로그인 같은 경우는 touched로 onBlur를 처리해줬는데 회원가입에서는 쓰면 안 된다. 근데 리액트 훅 폼 같은 경우는 mode라는 것이 있다. 여기에 onBlur 속성이 있다. 

disabled 처리를 알아서 다 해준다. 

비밀번호를 치다보면 안 보이니까 잘못 칠 경우가 있는데 그걸 처리하기 위해서 비밀번호 체크라는 게 하나가 더 있다. 이 부분을 처리해주기 위해서 필드를 하나 더 만들어주고 처리한다. -> 체크랑 그냥 비밀번호랑 일치하는지를 refine을 통해서 한번 더 체크를 해줄 수 있다. 

 

추가적으로, 이렇게 해주면 passwordCheck를 제외한 나머지 데이터만 띄워지도록 할 수 있다. 

const onSubmit:SubmitHandler<FormFields> = (data) => {
        const {passwordCheck, ...rest } = data;
        console.log(rest);
    }

 isSubmitting은 데이터를 요청하고 있을 때, 버튼의 로딩 처리 같은 것을 할 때 쓰는 것이다. 버튼에 disabled하고 추가해주면 된다. 

import { zodResolver } from "@hookform/resolvers/zod";
import { SubmitHandler, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";

const schema = z.object({
    email: z.string().email({message: "올바른 이메일 형식이 아닙니다."}), // 여기에 직접 에러 메시지를 바로 띄울 수 있다.
    password: z
        .string()
        .min(8, {
            message: "비밀번호는 8자 이상이어야 합니다.",
        })
        .max(20, {
            message: "비밀번호는 20자 이하여야 합니다."
        }),
    passwordCheck: z
        .string()
        .min(8, {
            message: "비밀번호는 8자 이상이어야 합니다.",
        })
        .max(20, {
            message: "비밀번호는 20자 이하여야 합니다."
        }),
    name: z
        .string()
        .min(1, {message: "이름을 입력해주세요."})
})
.refine((data) => data.password === data.passwordCheck, {
    message: "비밀번호가 일치하지 않습니다.",
    path: ["passwordCheck"],
});

type FormFields = z.infer<typeof schema> // 스키마의 타입들을 넣어주면 필드가 유추가 된다. 

const SignupPage = () => {
    const navigate = useNavigate();

    const {
        register, 
        handleSubmit, 
        formState: {errors, isSubmitting}, 
    } = useForm<FormFields>({
        defaultValues: {
            name: "",
            email: "",
            password: "",
            passwordCheck: "",
        },
        // 스키마를 정의한 것에 대해서 위반하면 에러 메시지를 띄워주기 위해서 resolver를 사용한다.
        resolver: zodResolver(schema),
        mode: "onBlur",
    });

    const onSubmit:SubmitHandler<FormFields> = (data) => {
        const {passwordCheck, ...rest } = data;
        console.log(rest);
    }

  return (
    <div className="flex flex-col items-center justify-center h-full gap-8">
            <div className="relative w-[300px] h-10 mb-2">
                <button
                    onClick={() => navigate("/")}
                    className="absolute left-0 top-1/2 -translate-y-1/2 text-white text-2xl"
                >
                    &lt;
                </button>
                <h2 className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-xl font-semibold">
                    회원가입
                </h2>
            </div>

                <button className="w-[300px] flex items-center justify-center gap-2 border border-white rounded-sm py-2 hover:bg-white hover:text-black transition-colors">
                    <img
                        src="https://upload.wikimedia.org/wikipedia/commons/5/53/Google_%22G%22_Logo.svg"
                        alt="google"
                        className="w-5 h-5"
                    />
                    구글 로그인
                </button>
                <div className="flex items-center w-[300px] gap-5">
                    <div className="flex-1 h-px bg-white" />
                    <span className="text-gray-400 text-lg text-white">OR</span>
                    <div className="flex-1 h-px bg-white" />
                </div>

            <div className="flex flex-col gap-3">
                <input
                    {...register('email')}
                    name="email"
                    className={`border border-[#333} w-[300px] p-[10px] focus:border-[#807bff] rounded-sm
                        ${errors?.email ? "border-red-500 bg-red-200" : "border-gray-300"}`}
                    type={'email'}
                    placeholder={"이메일"}  
                />
                {errors.email && (
                    <div className={'text-red-500 text-sm'}>{errors.email.message}</div>
                )}

                <input 
                    {...register("password")}
                    name="password"
                    className={`border border-[#333} w-[300px] p-[10px] focus:border-[#807bff] rounded-sm
                        ${errors?.password ? "border-red-500 bg-red-200" : "border-gray-300"}`}
                    type={'password'}
                    placeholder={"비밀번호"}  
                />
                {errors.password && (
                    <div className={'text-red-500 text-sm'}>{errors.password.message}</div>
                )}

                <input 
                    {...register("passwordCheck")}
                    name="passwordCheck"
                    className={`border border-[#333} w-[300px] p-[10px] focus:border-[#807bff] rounded-sm
                        ${errors?.passwordCheck ? "border-red-500 bg-red-200" : "border-gray-300"}`}
                    type={'password'}
                    placeholder={"비밀번호 확인"}  
                />
                {errors.password && (
                    <div className={'text-red-500 text-sm'}>{errors.password.message}</div>
                )}

                <input 
                    {...register("name")}
                    name="name"
                    className={`border border-[#333} w-[300px] p-[10px] focus:border-[#807bff] rounded-sm
                        ${errors?.name ? "border-red-500 bg-red-200" : "border-gray-300"}`}
                    type={'name'}
                    placeholder={"이름"}  
                />
                {errors.name && (
                    <div className={'text-red-500 text-sm'}>{errors.name.message}</div>
                )}

                <button
                    disabled={isSubmitting} 
                    type="button"
                    onClick={handleSubmit(onSubmit)} 
                    className="w-full bg-blue-600 text-white py-3 rounded-md text-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer disabled:bg-gray-300"
                >
                    회원가입
                </button>
            </div>
        </div>
  )
}

export default SignupPage

 

 

이제, api 요청을 위해 types 폴더를 하나 만들어준다. 

스웨거를 보면 name, email, password가 필수 타입이었다.

export type RequestUser = {
    name: string;
    email: string;
    big?: string;
    avatar?: string;
    password: string;
}

export type ResponseSignupDto = CommonResponse<{
    id: number;
    name: string;
    email: string;
    bio: string | null;
    avatar: string | null;
    createdAt: Date;
    updatedAt: Date;
}>

겹치는 부분이 있어서 공용 타입도 하나 만들어준다.

export type CommonResponse<T> = {
    status: boolean;
    statusCode: number;
    message: string;
    data: T
}

로그인, 내 정보 관련 타입도 만들어준다. 

// 로그인
export type RequestSigninDto = {
    email: string;
    password: string;
};

export type ResponseSigninDto = CommonResponse<{
    id: number;
    name: string;
    accessToken: string;
    refreshToken: string;
}>;

// 내 정보 조회
export type ResponseMyInfoDto = CommonResponse<{
    id: number;
    name: string;
    email: string;
    bio: string | null;
    avatar: string | null;
    createdAt: Date;
    updatedAt: Date;
}>;

그 다음, api 파일을 만들어서 api 요청 관련 코드를 구현해야 한다. 여기서 response됐을 때(포스트 요청을 성공했을 때) 예상되는 반환 타입은 Promise 타입이고, 비동기 처리를 해준다. 로그인, 회원가입도 마찬가지.

그리고 서버 url은 계속 겹치고, aws 서버가 배포를 했다면, 그에 맞춰서 바꿔야 되지만 이는 환경 변수로 따로 관리를 한다. 서버 url 유출이 안 되는게 좋기 때문에 따로 지정을 하고 vite-env.d.ts에 설정을 해준다.

/// <reference types="vite/client" />
interface ImportMetaEnv {
    readonly VITE_SERVER_API_URL: string;
}

interface ImportMeta {
    readonly env: ImportMetaEnv;
}

 그리고 url을 공통적으로 처리하면 편하니까 axios.ts 파일을 만들어서 공통으로 쓰이도록 바꾼다.

import axios from "axios";

const axiosInstance = axios.create({
    baseURL: import.meta.env.VITE_SERVER_API_URL,
})

export default axiosInstance;

그리고 auth.ts api 파일을 바꿔준다.

import { RequestSigninDto, RequestSignupDto, ResponseMyInfoDto, ResponseSigninDto, ResponseSignupDto } from "../types/auth"
import axiosInstance from "./axios";

export const postSignup = async (body: RequestSignupDto):Promise<ResponseSignupDto> => {
    const { data } = await axiosInstance.post(
        '/v1/auth/signup', 
        body,
    );

    return data;
};

export const postSignin = async (body: RequestSigninDto):Promise<ResponseSigninDto> => {
    const { data } = await axiosInstance.post(
        '/v1/auth/signin', 
        body,
    );

    return data;
};

export const getMyInfo = async (): Promise<ResponseMyInfoDto> => {
    const { data } = await axiosInstance.get('/v1/users/me');

    return data;
};

이렇게 구현이 됐으면 이제, 페이지 각각에 대입을 한다. 

signup은 아래처럼 적어주면 된다.

const onSubmit:SubmitHandler<FormFields> = async(data) => {
        const {passwordCheck, ...rest } = data;
        
        const response = await postSignup(rest);

        console.log(response);
    }

login은 아래처럼.

const handleSubmit = async () => {
        console.log(values);
        const response = await postSignin(values);

        console.log(response);
    };

없는 아이디일 경우 401에러가 뜰 것이다. 올바르게 입력하면 정상적으로 뜰 것이다. 

로그인이 정상적으로 됐으면 서버에 토큰을 전달을 해주게 된다. 토큰을 통해 검증을 하는데 보통 어디에 보관을 하냐면, 가장 쉽게는 로컬 스토리지에 보통 이런 것을 저장을 많이 한다.

localStorage.setItem("accessToken", response.data.accessToken);

이렇게 저장을 했으면 내 정보를 조회하는 페이지에서 잘 동작하는지 테스트해본다.

{path: 'my', element: <MyPage /> },
import { useEffect } from "react";
import { getMyInfo } from "../apis/auth";

const MyPage = () => {
    useEffect(() => {
        const getData = async () => {
            const response = await getMyInfo();
            console.log(response);
        };

        getData();
    }, [])

  return (
    <div>MyPage</div>
  )
}

export default MyPage;

아마 이런 식으로 하면 401 에러가 뜰 것이다. 헤더에 아직 저장이 안 되있어서 이렇게 에러가 뜨는 것이다. 그럼 어떻게 헤더를 넣냐?

export const getMyInfo = async (): Promise<ResponseMyInfoDto> => {
    const { data } = await axiosInstance.get('/v1/users/me', {
        headers: {
            Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
        }
    });

    return data;
};

이런 식으로 작성해주면 정상적으로 데이터가 불러와 질 것이다. 

대부분의 요청들이 사실상, 헤더가 공통적으로 들어가기 때문에 이것도 axiosInstance에 함께 넣어서 처리할 수 있다.

import axios from "axios";

const axiosInstance = axios.create({
    baseURL: import.meta.env.VITE_SERVER_API_URL,
    headers: {
        Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
    }
})

export default axiosInstance;

 

 

로컬 스토리지 관련된 걸 되게 많이 사용할 것이다. 그래서 로컬 스토리지 관련 훅도 따로 만들어 볼 것이다.

export const useLocalStorage = (key: string) => {
    const setItem = (value: unknown) => {
        try {
            window.localStorage.setItem(key, JSON.stringify(value)); // JSON.stringify를 해서 넣어주는 것이 가장 좋다. 
        } catch (error) {
            console.log(error)
        }
    };

    const getItem = () => {
        try {
            const item = window.localStorage.getItem(key);

            return item ? JSON.parse(item) : null
        } catch (e){
            console.log(e);
        }
    };

    const removeItem = () => {
        try {
            window.localStorage.removeItem(key);
        } catch(error) {
            console.log(error);
        }
    };

    return { setItem, getItem, removeItem };
};

이런 식으로 하고, 이제 적용을 하면 된다. 

로그인 페이지에서 기존에 했던 localStorage.setItem처럼 안 써도 된다. 

Bearer 토큰에 이상한 값이 들어가면 에러를 생성할 것이다. 그렇기 때문에 상쇄화를 시키는 것이 중요하다. key.ts를 만들어준다. 

export const LOCAL_STORAGE_KEY = {
    accessToken: "accessToken",
}

이렇게 만들어주면, 상쇄화한 걸 사용할 수 있다.

import axios from "axios";
import { LOCAL_STORAGE_KEY } from "../constants/key";

export const axiosInstance = axios.create({
    baseURL: import.meta.env.VITE_SERVER_API_URL,
    headers: {
        Authorization: `Bearer ${localStorage.getItem(LOCAL_STORAGE_KEY.accessToken)}`,
    }
})

interceptors를 통해서 훅을 불러올 수도 있는데 이 방법은 나중에 더 자세히..~