패스트캠퍼스 데브캠프

파이널 프로젝트 - 회원가입 (6) - 로그인

vitamin3000 2025. 4. 4. 12:14

 

이번 포스트에서는 구현한 로그인 기능에 대해 소개하고자 한다.

 

구현된 로그인 화면

사용자로부터 아이디와 비밀번호를 입력받아 일치하는 값이 있는지 확인하고, AccessToken과 RefreshToken을 발급받는다.

 

이때 예전에 설정했던 const api를 기억하는가?

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
});

 

여기에서 쿠키 관련 옵션을 true로 했었다.

그 이유는 대부분의 경우에서 LocalStorage나 SessionStorage에서 토큰을 저장하고, 사용하는데

export const login = async (
  username: string,
  password: string
): Promise<string> => {
  const response = await api.post('/api/v1/auth/login', {
    username,
    password,
  });
  const { accessToken, refreshToken } = response.data;

  api.defaults.headers['Authorization'] = `Bearer ${accessToken}`;

  Cookies.set('accessToken', accessToken, {
    expires: 7,
    secure: true,
    httpOnly: true,
  });

  return accessToken;
};

 

나는 보안을 더 강화하여 프론트단에서 정보 노출이 되는 것을 최대한 막고 싶었다.

따라서 관련값을 쿠키로 저장하고, 전역으로 관리해 어디서든 쉽게 값을 받아오게 하였다.

 

관련 함수는 아래와 같다.

export const getTokens = () => {
  const { accessToken } = useAuthStore.getState();
  return { accessToken };
};

export const setTokens = (accessToken: string) => {
  useAuthStore.getState().setTokens(accessToken);
};

export const resetTokens = () => {
  useAuthStore.getState().clearTokens();
};

 

사용자가 페이지에 오래 머무는 등으로 인해, 토큰이 만료되면 axios의 interceptors를 활용해 값을 다시 받아오도록 하였다.

 

api.interceptors.request.use(
  (config) => {
    const accessToken = useAuthStore.getState().accessToken;
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      const newAccessToken = await refreshAccessToken();

      if (newAccessToken) {
        api.defaults.headers.Authorization = `Bearer ${newAccessToken}`;
        return api(originalRequest);
      }
    }
    return Promise.reject(error);
  }
);

 

이때 페이지를 새로고침하거나 나갔다 다시 들어오는 경우 로그인이 풀리는 경우가 발생했었다.

따라서 쿠키에 저장하고 있는 액세스토큰을 사용하여, 위의 경우(새로고침, 나갔다 들어오는)에 새로 랜더링될 때
쿠키에 액세스 토큰이 있는지를 판별하여, 로그인 상태를 유지하도록 하였다.

 

 

export const initializeAuth = async () => {
  try {
    const response = await api.post('/api/v1/auth/refresh');
    const { accessToken } = response.data;
    useAuthStore.getState().setTokens(accessToken);
  } catch {
    throw new Error(`세션이 유효하지 않습니다.`);
  }
};
__root.tsx

  useEffect(() => {
    async function initAuth() {
      try {
        await initializeAuth();
        const { accessToken } = useAuthStore.getState();

        setAuthState({
          isInitialized: true,
          isAuthenticated: !!accessToken,
        });
      } catch {
        setAuthState({
          isInitialized: true,
          isAuthenticated: false,
        });
      }
    }

    initAuth();
  }, []);

 

토큰값에 따라 protected route 설정은 다음과 같다.

    const { accessToken } = useAuthStore.getState();
    const publicRoutes = [
      '/auth/login',
      '/auth/sign-up/finish',
      '/auth/sign-in',
      '/auth/sign-up/sns',
    ];

    if (!accessToken && !publicRoutes.includes(location.pathname)) {
      navigate({ to: '/auth/login' });
    } else if (
      (location.pathname === '/' || location.pathname === '/auth/login') &&
      accessToken
    ) {
      navigate({ to: '/dashboard' });
    }
  }, [authState.isInitialized, location.pathname, navigate]);

 

비밀번호 보임/숨김 처리

 

React-Icons를 사용하였고, showPassword 값에 따라 아이콘과, 값들이 보여지게 하였다.

    const [showPassword, setShowPassword] = useState(false);
    
    // ...
    <input
        type={
          type === 'password' ? (showPassword ? 'text' : 'password') : 'email'
        }
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange && onChange(e.target.value)}
      />
      {type === 'password' && (
        <S.EyeIconContainer onClick={handleShow}>
          {showPassword ? (
            <FaRegEyeSlash size={20} />
          ) : (
            <IoEyeOutline size={20} />
          )}
        </S.EyeIconContainer>
      )}

 

 

 

이메일에 따른 입력이나 비밀번호 유효성 검사는 이전에 활용했던 아래 코드를 사용하였다.

import { z } from 'zod';

export type LoginSchemaType = {
  email: string;
  password: string;
};
const LoginSchema = z.object({
  email: z.string().email('유효한 이메일 주소를 입력해주세요'),
  password: z
    .string()
    .regex(
      /^(?=.*[A-Za-z])(?=.*\d).{8,}$/,
      '비밀번호를 숫자, 영문 포함 8자리 이상으로 입력해주세요'
    ),
});