frontend/react

[React] React.js 강좌 17. 로그인 폼 설계부터 인증 흐름까지 – React Hook Form × React Query 실전 예제

mirabo01 2025. 11. 10. 08:53

1. 로그인 기능, 왜 제대로 설계해야 할까?

프론트엔드 개발에서 로그인은 단순한 폼 이상의 의미를 가집니다.
단지 아이디와 비밀번호를 입력받는 것 같지만,
실제로는 토큰 관리, 에러 처리, 상태 동기화, 보안까지 함께 고려해야 합니다.

많은 초보 개발자가 다음과 같은 문제를 겪습니다.

  • 로그인 후 새로고침 시 인증 정보가 사라짐
  • 잘못된 비밀번호 입력 시 에러 처리 누락
  • 로그인/로그아웃 시 전역 상태 업데이트 누락

이번 강의에서는
React Hook Form + React Query를 결합해
현실적인 로그인 기능을 구현하는 방법을 단계별로 살펴보겠습니다.


2. 기본 세팅

먼저 필요한 라이브러리를 설치합니다.

npm install react-hook-form @tanstack/react-query axios

그리고 전역에서 React Query의 Provider를 설정합니다.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const client = new QueryClient();

export default function Root() {
  return (
    <QueryClientProvider client={client}>
      <App />
    </QueryClientProvider>
  );
}

3. 로그인 API 설계 예시

일반적으로 로그인 API는 다음과 같은 구조를 가집니다.

요청(Request)

POST /api/login
{
  "email": "user@example.com",
  "password": "123456"
}

응답(Response)

{
  "accessToken": "eyJhbGciOiJIUzI1...",
  "refreshToken": "dGhpcyBpcyByZWZyZXNo...",
  "user": {
    "id": 1,
    "name": "홍길동",
    "email": "user@example.com"
  }
}

받은 토큰은 일반적으로 로컬스토리지(LocalStorage) 혹은 **쿠키(Cookie)**에 저장합니다.


4. 로그인 폼 구현

아래는 React Hook Form과 React Query를 이용한 로그인 폼 예제입니다.

import { useForm } from 'react-hook-form';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const mutation = useMutation({
    mutationFn: async (data) => {
      const res = await axios.post('/api/login', data);
      return res.data;
    },
    onSuccess: (data) => {
      localStorage.setItem('accessToken', data.accessToken);
      alert(`${data.user.name}님, 환영합니다!`);
    },
    onError: (error) => {
      alert(error.response?.data?.message || '로그인에 실패했습니다.');
    },
  });

  const onSubmit = (formData) => mutation.mutate(formData);

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ width: 320, margin: '0 auto' }}>
      <h3>로그인</h3>

      <div style={{ marginBottom: '12px' }}>
        <label>이메일</label>
        <input
          {...register('email', {
            required: '이메일을 입력해주세요.',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: '이메일 형식이 올바르지 않습니다.',
            },
          })}
        />
        {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
      </div>

      <div style={{ marginBottom: '12px' }}>
        <label>비밀번호</label>
        <input
          type="password"
          {...register('password', {
            required: '비밀번호를 입력해주세요.',
            minLength: { value: 6, message: '6자 이상 입력해야 합니다.' },
          })}
        />
        {errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
      </div>

      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? '로그인 중...' : '로그인'}
      </button>

      {mutation.isError && (
        <p style={{ color: 'red' }}>로그인에 실패했습니다. 다시 시도해주세요.</p>
      )}
    </form>
  );
}

export default LoginForm;

5. 인증 상태 전역 관리

로그인 여부를 페이지 전체에서 확인하려면
토큰만 저장해서는 부족합니다.
전역 상태 관리를 도입해야 합니다.

간단한 예로, Zustand를 이용해 로그인 상태를 저장할 수 있습니다.

npm install zustand
// authStore.js
import { create } from 'zustand';

export const useAuthStore = create((set) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
}));

이제 로그인 성공 시 상태를 업데이트합니다.

import { useAuthStore } from './authStore';

const mutation = useMutation({
  mutationFn: (data) => axios.post('/api/login', data),
  onSuccess: (data) => {
    useAuthStore.getState().login(data.user);
    localStorage.setItem('accessToken', data.accessToken);
  },
});

로그아웃은 이렇게 간단히 구현할 수 있습니다.

function LogoutButton() {
  const { logout } = useAuthStore();

  const handleLogout = () => {
    localStorage.removeItem('accessToken');
    logout();
  };

  return <button onClick={handleLogout}>로그아웃</button>;
}

6. 인증 상태 유지 (자동 로그인)

사용자가 새로고침해도 로그인 상태를 유지하려면,
앱 로드 시 로컬스토리지에 저장된 토큰을 읽어서 상태를 복원해야 합니다.

import { useEffect } from 'react';
import { useAuthStore } from './authStore';
import axios from 'axios';

function AuthInitializer() {
  const { login } = useAuthStore();

  useEffect(() => {
    const token = localStorage.getItem('accessToken');
    if (!token) return;

    axios.get('/api/me', { headers: { Authorization: `Bearer ${token}` } })
      .then((res) => login(res.data.user))
      .catch(() => localStorage.removeItem('accessToken'));
  }, [login]);

  return null; // 렌더링 X, 초기화 역할만 수행
}

App.js 최상단에 <AuthInitializer />를 추가하면
사용자가 새로고침해도 로그인 상태가 유지됩니다.


7. 보호된 페이지 (Protected Route)

로그인하지 않은 사용자가 특정 페이지에 접근하지 못하게 하려면
“Protected Route”를 설정해야 합니다.

import { Navigate } from 'react-router-dom';
import { useAuthStore } from './authStore';

function PrivateRoute({ children }) {
  const { user } = useAuthStore();

  if (!user) {
    return <Navigate to="/login" replace />;
  }

  return children;
}

이제 라우터에서 이렇게 사용합니다.

<Route
  path="/dashboard"
  element={
    <PrivateRoute>
      <Dashboard />
    </PrivateRoute>
  }
/>

8. 에러 처리 & UX 개선 팁

  1. 서버 에러 메시지 표시하기
  2. onError: (error) => { alert(error.response?.data?.message || '로그인 실패'); }
  3. 로딩 중 버튼 비활성화 및 스피너 표시
  4. <button disabled={mutation.isLoading}> {mutation.isLoading ? '로딩 중...' : '로그인'} </button>
  5. 자동 포커스 및 Tab 순서 최적화
    React Hook Form의 setFocus()를 이용하면 UX가 향상됩니다.
  6. const { setFocus } = useForm(); useEffect(() => setFocus('email'), [setFocus]);

9. 전체 흐름 요약

1️⃣ React Hook Form → 입력값 및 유효성 검사
2️⃣ React Query → 서버 요청 및 상태 관리
3️⃣ Zustand → 로그인 상태 전역 관리
4️⃣ localStorage → 인증 토큰 저장
5️⃣ PrivateRoute → 보호된 페이지 구성


10. 마무리

이번 강의에서는 로그인 기능을 중심으로
폼 처리 → 서버 요청 → 인증 상태 유지까지 전 과정을 다뤘습니다.

핵심 요약:

  • React Hook Form은 입력과 검증을 간단하게 관리
  • React Query는 서버 통신과 상태를 체계적으로 관리
  • Zustand로 인증 상태를 전역에서 유지
  • localStorage와 PrivateRoute로 로그인 지속성 보장

다음 강의에서는 실제로 로그인한 사용자의 데이터를 기반으로
대시보드 페이지를 구성하고, React Query의 useQuery로
서버 데이터를 효율적으로 불러오는 방법을 다뤄보겠습니다.