본문 바로가기

프론트엔드 애플리케이션 개발

React·Vite에서 회원가입, 로그인, 공개 프로필 조회와 로그아웃을 연결하고 외부 응답과 세션 상태를 확인합니다.

앞 절의 서버는 사용자 번호·이메일·이름만 클라이언트에 돌려줍니다. 이번에는 회원가입한 뒤 로그인하고, 받은 토큰으로 내 정보를 화면에 표시해 보겠습니다. 서버 구현과 인증 연결의 파일을 먼저 준비해 주세요.

이 절에서 작성할 화면은 회원가입·로그인·프로필 세 개입니다. 관리자 화면과 전체 사용자 CRUD는 연결하지 않습니다. 일반 회원의 정보를 가져올 때는 관리자 전용 /users가 아니라 /auth/profile을 사용합니다.

클라이언트와 서버의 주소 맞추기

packages/client에서 React·TypeScript 프로젝트를 만듭니다. 이미 프로젝트가 있다면 생성 명령을 다시 실행하지 말고 아래 파일을 기존 프로젝트에 추가해 주세요.

# packages/client에서 실행
npm create vite@latest . -- --template react-ts
npm install
npm install axios zustand react-router

Axios와 Zustand는 자체 타입을 제공합니다. 별도의 @types/axios나 immer는 이 예제에 필요하지 않습니다. 라우터는 React Router의 선언형 설치 안내에 맞춰 react-router에서 가져옵니다. 이미 다른 주요 버전을 쓰는 프로젝트라면 설치된 버전의 안내와 import를 함께 맞춰 주세요.

앞 절 서버의 CORS는 http://localhost:3000을 허용합니다. Vite의 기본 포트는 5173이므로 클라이언트 설정을 명시적으로 바꾸겠습니다.

packages/client/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { port: 3000, strictPort: true },
});

strictPort는 3000번을 사용할 수 없을 때 다른 포트로 넘어가지 않고 종료하게 합니다. 포트를 바꾸려면 서버 main.ts의 CORS origin도 실제 클라이언트 주소와 같게 바꿔 주세요. Vite 서버 옵션

packages/client/.env
VITE_API_BASE_URL=http://localhost:4100

서버는 PORT를 따로 지정하지 않으면 4100번을 사용합니다. 다른 PORT를 설정했다면 .env의 API 주소도 실제 서버 주소에 맞춰 주세요. 현재 서버에는 /api 접두사를 추가하지 않았습니다. 다음 세 경로 앞에 /api를 붙이면 다른 주소로 요청하게 됩니다.

화면의 행동요청기대할 응답
회원가입POST /auth/register201, id·email·name
로그인POST /auth/login200, { accessToken }
내 정보 조회GET /auth/profile, Bearer 토큰200, id·email·name

VITE_ 변수는 브라우저에 공개되는 설정입니다. API 주소만 넣고 JWT 서명 키나 DB 비밀번호는 넣지 않습니다. .env를 바꾸었다면 Vite 개발 서버를 다시 시작해 주세요. Vite 환경 변수

공유 타입을 실제 파일로 내보내기

모노레포 구조의 packages/shared에 아래 두 파일을 만듭니다. 서버의 DB 엔터티를 그대로 공유하지 않고, 요청과 공개 응답에 필요한 모양만 정의합니다.

packages/shared/src/interfaces/user.ts
export interface PublicUserDto {
  id: number;
  email: string;
  name: string;
}

export interface CreateUserDto {
  email: string;
  password: string;
  name: string;
}

export type LoginCredentials = Pick<CreateUserDto, 'email' | 'password'>;

export interface LoginResponseDto {
  accessToken: string;
}
packages/shared/src/index.ts
export type {
  PublicUserDto,
  CreateUserDto,
  LoginCredentials,
  LoginResponseDto,
} from './interfaces/user';

클라이언트의 앱 코드에 적용되는 tsconfig.app.json의 compilerOptions에 다음 설정을 추가합니다. 생성된 다른 옵션과 tsconfig.json의 app/node 참조는 유지해 주세요. 하나의 tsconfig만 사용하는 프로젝트라면 앱 코드에 적용되는 그 파일에 넣습니다.

packages/client/tsconfig.app.json에 추가할 compilerOptions
{
  "baseUrl": ".",
  "paths": {
    "@shared/*": ["../shared/src/*"]
  }
}

이제 import type { PublicUserDto } from '@shared/index'가 실제 export를 찾습니다. import type은 JavaScript 출력에서 사라지므로 이번 공유 타입에는 Vite의 런타임 alias가 필요하지 않습니다. paths 자체가 실행 중 import를 바꾸거나 workspace 경계를 강제하는 것은 아닙니다. 공유 패키지에서 실제 함수를 가져오는 확장은 별도 package·빌드 설정과 함께 다룹니다.

API 응답을 확인하는 서비스

src/services 폴더를 만들고 Axios 인스턴스를 준비합니다. 설정이 잘못되었을 때 API 요청을 반복하기보다 시작 지점에서 주소를 확인하게 하겠습니다.

packages/client/src/services/apiClient.ts
import axios from 'axios';

const configuredUrl = import.meta.env.VITE_API_BASE_URL;
if (typeof configuredUrl !== 'string' || configuredUrl.trim() === '') {
  throw new Error('.env의 VITE_API_BASE_URL을 확인해 주세요.');
}

const apiUrl = new URL(configuredUrl.trim());
if (apiUrl.protocol !== 'http:' && apiUrl.protocol !== 'https:') {
  throw new Error('API 주소는 http 또는 https URL이어야 합니다.');
}

export const apiClient = axios.create({
  baseURL: apiUrl.href.replace(/\/$/, ''),
  timeout: 5000,
});

TypeScript의 응답 타입은 서버가 실제로 그 모양을 보냈다는 증거가 아닙니다. 응답은 unknown으로 받은 뒤 확인하고, 사용자 객체에서는 공개 세 필드만 골라 새 객체를 만듭니다.

packages/client/src/services/authService.ts
import axios from 'axios';
import type {
  CreateUserDto,
  LoginCredentials,
  LoginResponseDto,
  PublicUserDto,
} from '@shared/index';
import { apiClient } from './apiClient';

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function parsePublicUser(value: unknown): PublicUserDto {
  if (
    !isRecord(value) ||
    typeof value.id !== 'number' ||
    !Number.isSafeInteger(value.id) || value.id <= 0 ||
    typeof value.email !== 'string' || typeof value.name !== 'string'
  ) {
    throw new Error('사용자 응답의 id, email, name을 확인해 주세요.');
  }
  return { id: value.id, email: value.email, name: value.name };
}

function parseLogin(value: unknown): LoginResponseDto {
  if (!isRecord(value) || typeof value.accessToken !== 'string' ||
      value.accessToken.trim() === '') {
    throw new Error('로그인 응답의 accessToken을 확인해 주세요.');
  }
  return { accessToken: value.accessToken };
}

export function isUnauthorized(error: unknown): boolean {
  return axios.isAxiosError(error) && error.response?.status === 401;
}

export function requestErrorMessage(error: unknown): string {
  if (axios.isAxiosError(error)) {
    if (!error.response) {
      return '서버 연결을 확인해 주세요. API 주소, 서버 상태, CORS를 먼저 확인합니다.';
    }
    const data: unknown = error.response.data;
    if (isRecord(data)) {
      if (typeof data.message === 'string') return data.message;
      if (Array.isArray(data.message)) {
        const messages = data.message.filter(
          (value): value is string => typeof value === 'string',
        );
        if (messages.length > 0) return messages.join(' ');
      }
    }
    return `요청이 거절되었습니다. HTTP ${error.response.status} 응답을 확인해 주세요.`;
  }
  return error instanceof Error ? error.message : '응답을 확인하지 못했습니다.';
}

export const authService = {
  async register(payload: CreateUserDto): Promise<PublicUserDto> {
    const response = await apiClient.post<unknown>('/auth/register', payload);
    return parsePublicUser(response.data);
  },

  async login(credentials: LoginCredentials): Promise<string> {
    const response = await apiClient.post<unknown>('/auth/login', credentials);
    return parseLogin(response.data).accessToken;
  },

  async getProfile(token: string): Promise<PublicUserDto> {
    const response = await apiClient.get<unknown>('/auth/profile', {
      headers: { Authorization: `Bearer ${token}` },
    });
    return parsePublicUser(response.data);
  },
};

register는 관리자 /users가 아니라 공개 /auth/register를 호출합니다. 등록 응답에 토큰이 없으므로 회원가입 성공만으로 로그인 상태를 만들지 않습니다. 프로필 요청에는 그 요청에서 확인할 토큰을 직접 전달합니다. 전역 Axios interceptor가 로그인 실패를 보고 강제로 페이지를 이동시키지 않게 했습니다.

FRONTEND RUNTIME BOUNDARY

브라우저의 외부 값은 검증 뒤에만 앱 상태가 된다

API 주소는 시작할 때 URL 형식을 확인합니다. auth 응답은 unknown으로 받은 뒤 필요한 필드를 검사하고, 사용자 객체에서는 id·email·name만 골라 state와 UI로 전달합니다. 타입 선언만으로 실제 JSON을 믿지 않습니다.

브라우저의 외부 값은 검증 뒤에만 앱 상태가 된다 공개 VITE API 주소와 unknown 인증 응답을 각각 검사하고 공개 사용자 세 필드만 상태에 넣어 React의 일반 텍스트로 표시하는 클라이언트 데이터 경계. Public configVITE API URL공개 설정Auth 응답unknown JSON경계별 검사URL · 필드 확인Typed stateid · email · nameReacttext기본 escape세 필드 표시
data-flow 핵심 계약
  1. 두 입력: 공개 설정·auth 응답

    VITE_API_BASE_URL은 공개 설정입니다. 시작할 때 http/https URL인지 확인합니다. auth JSON 응답은 각 요청 뒤 unknown으로 확인합니다. 이 둘은 연속 요청 단계가 아닙니다.

  2. 각 경계의 URL·필드 검사

    parseLogin은 문자열 accessToken을, parsePublicUser는 양의 안전한 정수 id와 문자열 email·name을 확인합니다. Axios generic은 이를 대신하지 않습니다.

  3. 검증 성공 → Typed state

    사용자는 공개 세 필드로 새 객체를 만듭니다. 로그인은 profile 확인 뒤 저장하고, 이전 요청의 늦은 응답은 요청 번호로 무시합니다.

  4. React 출력 경계

    프로필은 세 필드를 일반 React 텍스트로 표시합니다. raw HTML이나 사용자 URL은 이 화면에 없으며, 별도 확장에서 추가한다면 출력에 맞는 검사가 필요합니다.

  • 핵심 경계
  • 진행·선택 경로

URL 형식과 공개 응답 필드를 확인하는 작은 경계입니다. 모든 JSON·URL 보안 정책을 구현한 것은 아닙니다. 클라이언트의 parser와 route guard는 서버의 입력 검증·인증·인가를 대신하지 않습니다.

그림의 입력은 두 종류입니다. API 주소는 초기 설정에서 확인하고, JSON 응답은 각 요청 뒤에 확인합니다. 이 작은 parser는 공개 사용자와 로그인 응답의 필드를 검사합니다. 서버의 입력 검증·인증·권한 판단을 대신하지 않습니다.

토큰 문자열과 확인된 세션 구분하기

브라우저에 토큰이 남아 있어도 서버가 지금 받아들인다는 보장은 없습니다. 새로고침하면 먼저 프로필을 조회하고, 확인이 끝날 때까지 보호 화면을 기다리게 하겠습니다.

상태화면에서 할 일
checking프로필 확인을 기다림
anonymous로그인으로 이동
authenticated확인된 공개 사용자 표시
error연결·응답 오류를 표시하고 재시도 또는 로그아웃 제공

src/store/authStore.ts에 상태와 동작을 함께 둡니다. 이름이 requestVersion인 번호는 어느 요청의 결과를 반영할지 구분합니다. 새 요청이나 로그아웃 때 번호를 바꾸면, 이전 요청의 성공·실패가 나중에 도착해도 현재 상태를 덮어쓰지 않습니다.

packages/client/src/store/authStore.ts
import { create } from 'zustand';
import type { LoginCredentials, PublicUserDto } from '@shared/index';
import { authService, isUnauthorized, requestErrorMessage } from '../services/authService';

type SessionStatus = 'checking' | 'anonymous' | 'authenticated' | 'error';

interface AuthState {
  user: PublicUserDto | null;
  token: string | null;
  status: SessionStatus;
  error: string | null;
  login: (credentials: LoginCredentials) => Promise<boolean>;
  fetchUser: () => Promise<void>;
  logout: () => void;
}

const savedToken = localStorage.getItem('token');
let requestVersion = 0;

export const useAuthStore = create<AuthState>((set, get) => ({
  user: null,
  token: savedToken,
  status: savedToken ? 'checking' : 'anonymous',
  error: null,

  login: async (credentials) => {
    const version = ++requestVersion;
    localStorage.removeItem('token');
    set({ token: null, user: null, status: 'checking', error: null });
    let receivedToken = false;
    try {
      const token = await authService.login(credentials);
      receivedToken = true;
      const user = await authService.getProfile(token);
      if (version !== requestVersion) return false;
      localStorage.setItem('token', token);
      set({ token, user, status: 'authenticated', error: null });
      return true;
    } catch (error: unknown) {
      if (version !== requestVersion) return false;
      const message = isUnauthorized(error)
        ? receivedToken
          ? '발급받은 토큰을 서버가 받아들이지 않았습니다. 서버의 인증 설정을 확인해 주세요.'
          : '이메일 또는 비밀번호를 확인해 주세요.'
        : requestErrorMessage(error);
      set({ token: null, user: null, status: 'anonymous', error: message });
      return false;
    }
  },

  fetchUser: async () => {
    const token = get().token;
    const version = ++requestVersion;
    if (!token) {
      set({ user: null, status: 'anonymous', error: null });
      return;
    }
    set({ user: null, status: 'checking', error: null });
    try {
      const user = await authService.getProfile(token);
      if (version !== requestVersion) return;
      set({ user, status: 'authenticated', error: null });
    } catch (error: unknown) {
      if (version !== requestVersion) return;
      if (isUnauthorized(error)) {
        localStorage.removeItem('token');
        set({ token: null, user: null, status: 'anonymous', error: '다시 로그인해 주세요.' });
      } else {
        set({ user: null, status: 'error', error: requestErrorMessage(error) });
      }
    }
  },

  logout: () => {
    ++requestVersion;
    localStorage.removeItem('token');
    set({ token: null, user: null, status: 'anonymous', error: null });
  },
}));

로그인은 토큰을 받고 프로필까지 확인한 뒤에만 토큰과 사용자를 저장합니다. 로그인 도중 연결이 실패하면 로그인 폼에서 다시 제출합니다. 반면 이미 저장한 토큰을 확인하다 연결이 실패하면 error 상태에 토큰을 남겨 재시도할 수 있게 합니다. 그 실패를 토큰 만료라고 단정하지 않습니다.

프로필의 401은 현재 인증을 받아들일 수 없다는 응답이므로 토큰과 사용자 상태를 지웁니다. 토큰 만료 시각을 브라우저가 자동 감지하는 코드는 없으며, 다음 프로필 요청에서 401을 관찰합니다. 선택적인 관리 API의 403은 그 작업의 권한 거절이므로 인증된 세션 전체를 자동으로 지우는 근거가 아닙니다.

로그아웃은 요청 번호도 바꿉니다. 전송 중인 요청 자체를 취소하지는 않지만, 뒤늦게 온 프로필이 사용자를 다시 로그인시킬 수 없습니다. 이 규칙은 개발 중 StrictMode에서 초기 확인이 반복되어도 마지막 요청의 결과만 반영합니다.

서버 확인 결과에 따라 세션과 화면을 바꾼다 — 상태 추적

저장 토큰과 검증된 session은 다르며 200/401/네트워크 오류/로그아웃 뒤 늦은 응답의 UI 반영 여부가 바뀐다. request ID 기반 무시와 실제 취소를 혼동하지 않는다.

SESSION STATE MACHINE

서버 확인 결과에 따라 세션과 화면을 바꾼다

저장된 토큰은 먼저 profile로 확인합니다. 확인 중에는 보호 화면을 기다리고, 200 응답의 공개 필드를 확인한 뒤 사용자를 표시합니다. 401이면 인증을 지우고, 연결·응답 형태 오류라면 토큰을 남겨 재시도합니다.

서버 확인 결과에 따라 세션과 화면을 바꾼다 저장된 토큰의 프로필 확인은 checking에서 authenticated, anonymous 또는 재시도 가능한 error로 바뀌며 로그아웃은 토큰과 사용자를 지우고 이전 요청의 결과를 무시합니다. 토큰 있음 토큰 없음 200 · 필드 확인 401 profile 연결·형태 재시도 logout 저장 토큰 시작 시 확인 확인 중 checking 인증됨 authenticated 로그인 필요 anonymous 확인 오류 error · 재시도
세션 확인 성공·401·네트워크 오류를 구분하고 logout은 token 삭제와 요청 세대 변경으로 늦은 응답을 무효화한다.start→checking: 저장 token은 profile로 확인。start→anon: 로그인 화면。checking→auth: 공개 프로필 확인 성공。checking→error: token을 자동 삭제하지 않음。checking→anon: 인증 실패는 자격 증명 제거。error→retry: 일시적 실패에서 재시도。retry→checking: 새 요청으로 확인。auth→logout: 인증 상태에서 로그아웃。logout→anon: 요청 세대가 다르면 상태 반영하지 않음있음200장애시작: 저장 token?시작: 저장 token?로그인 성공도 profile확인checkingcheckingprofile 요청인증됨인증됨200응답형식확인errorerror네트워크· 형식실패익명 상태익명상태token없음401이면삭제재시도재시도token보존다시확인모든 상태에서 logout모든 상태에서 logouttoken 삭제 · requestId증가
단계별 조건과 보충 설명
state핵심 계약
  1. 시작 → checking 또는 anonymous

    저장 토큰이 있으면 profile을 확인하며 기다립니다. 없으면 로그인 화면으로 이동합니다.

  2. 200 · 공개 필드 확인 → authenticated

    id·email·name을 확인한 뒤 보호 화면을 엽니다. 새 로그인도 토큰 발급만으로 끝내지 않고 profile을 조회합니다.

  3. 401 → anonymous

    프로필의 인증 거절은 토큰과 사용자를 지우고 다시 로그인하게 합니다. 만료 시각을 자동 감지하지 않고 요청의 응답을 확인합니다.

  4. 연결·응답 형태 오류 → error

    저장 토큰의 확인 실패가 곧 만료는 아닙니다. 같은 토큰으로 재시도하거나 로그아웃합니다. 로그인 도중의 실패는 로그인 폼에 표시합니다.

  5. logout → anonymous

    토큰·사용자를 지우고 요청 번호를 바꿉니다. 이전 요청의 늦은 성공과 실패는 현재 세션에 반영하지 않습니다.

  • 프로필 확인 완료
  • 확인 결과·사용자 행동

그림은 저장 토큰의 확인 흐름입니다. 새 로그인의 실패는 anonymous 상태에서 폼 오류로 표시하며, 성공은 profile까지 확인한 뒤 저장합니다. 모든 logout은 늦은 결과를 무효화합니다. 별도 관리 API의 403은 해당 작업의 권한 거절이며 이 최소 라우터에는 관리 화면이 없습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명저장된 토큰은 먼저 profile로 확인합니다. 확인 중에는 보호 화면의 표시를 보류하고, 200 응답의 공개 필드를 확인한 뒤 사용자를 표시합니다. 401이면 인증을 지우고, 연결·응답 형태 오류라면 토큰을 남겨 재시도합니다.
전체 관계·조건 설명저장된 토큰의 프로필 확인 결과에 따라 상태가 checking에서 authenticated, anonymous 또는 재시도 가능한 error로 바뀌며 로그아웃은 토큰과 사용자를 지우고 이전 요청의 결과를 무시합니다.
원도표 조건·주석시작 → checking 또는 anonymous: 저장 토큰이 있으면 profile을 확인하며 기다립니다. 없으면 로그인 화면으로 이동합니다.
원도표 조건·주석200 · 공개 필드 확인 → authenticated: id·email·name을 확인한 뒤 보호 화면을 엽니다. 새 로그인도 토큰 발급만으로 끝내지 않고 profile을 조회합니다.
원도표 조건·주석401 → anonymous: 프로필의 인증 거절은 토큰과 사용자를 지우고 다시 로그인하게 합니다. 만료 시각을 자동 감지하지 않고 요청의 응답을 확인합니다.
원도표 조건·주석연결·응답 형태 오류 → error: 저장 토큰의 확인 실패가 곧 만료는 아닙니다. 같은 토큰으로 재시도하거나 로그아웃합니다. 로그인 도중의 실패는 로그인 폼에 표시합니다.
원도표 조건·주석logout → anonymous: 토큰·사용자를 지우고 요청 번호를 바꿉니다. 이전 요청의 늦은 성공과 실패는 현재 세션에 반영하지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
토큰 확인 중token: 있음 / session: checking저장된 token만으로 authenticated 상태를 확정하지 않는다credential 보유와 검증된 session은 다르다
검증 결과200: authenticated / 401/network: anonymous/retriable error200은 공개 profile, 401은 anonymous, network 오류는 재시도 상태다일시적 오류를 무조건 logout으로 해석하지 않는다
logout 뒤 완료session: anonymous / 늦은 응답: 반영 없음logout이 request ID를 바꾸면 늦은 profile 응답은 무시한다ID 무효화는 실제 HTTP 취소와 다르다

서버 확인 결과에 따라 세션과 화면을 바꾼다 — 상태 추적

저장 토큰과 검증된 session은 다르며 200/401/네트워크 오류/로그아웃 뒤 늦은 응답의 UI 반영 여부가 바뀐다. request ID 기반 무시와 실제 취소를 혼동하지 않는다.

SESSION STATE MACHINE

서버 확인 결과에 따라 세션과 화면을 바꾼다

저장된 토큰은 먼저 profile로 확인합니다. 확인 중에는 보호 화면을 기다리고, 200 응답의 공개 필드를 확인한 뒤 사용자를 표시합니다. 401이면 인증을 지우고, 연결·응답 형태 오류라면 토큰을 남겨 재시도합니다.

서버 확인 결과에 따라 세션과 화면을 바꾼다 저장된 토큰의 프로필 확인은 checking에서 authenticated, anonymous 또는 재시도 가능한 error로 바뀌며 로그아웃은 토큰과 사용자를 지우고 이전 요청의 결과를 무시합니다. 토큰 있음 토큰 없음 200 · 필드 확인 401 profile 연결·형태 재시도 logout 저장 토큰 시작 시 확인 확인 중 checking 인증됨 authenticated 로그인 필요 anonymous 확인 오류 error · 재시도
세션 확인 성공·401·네트워크 오류를 구분하고 logout은 token 삭제와 요청 세대 변경으로 늦은 응답을 무효화한다.start→checking: 저장 token은 profile로 확인。start→anon: 로그인 화면。checking→auth: 공개 프로필 확인 성공。checking→error: token을 자동 삭제하지 않음。checking→anon: 인증 실패는 자격 증명 제거。error→retry: 일시적 실패에서 재시도。retry→checking: 새 요청으로 확인。auth→logout: 인증 상태에서 로그아웃。logout→anon: 요청 세대가 다르면 상태 반영하지 않음있음200장애시작: 저장 token?시작: 저장 token?로그인 성공도 profile확인checkingcheckingprofile 요청인증됨인증됨200응답형식확인errorerror네트워크· 형식실패익명 상태익명상태token없음401이면삭제재시도재시도token보존다시확인모든 상태에서 logout모든 상태에서 logouttoken 삭제 · requestId증가
단계별 조건과 보충 설명
state핵심 계약
  1. 시작 → checking 또는 anonymous

    저장 토큰이 있으면 profile을 확인하며 기다립니다. 없으면 로그인 화면으로 이동합니다.

  2. 200 · 공개 필드 확인 → authenticated

    id·email·name을 확인한 뒤 보호 화면을 엽니다. 새 로그인도 토큰 발급만으로 끝내지 않고 profile을 조회합니다.

  3. 401 → anonymous

    프로필의 인증 거절은 토큰과 사용자를 지우고 다시 로그인하게 합니다. 만료 시각을 자동 감지하지 않고 요청의 응답을 확인합니다.

  4. 연결·응답 형태 오류 → error

    저장 토큰의 확인 실패가 곧 만료는 아닙니다. 같은 토큰으로 재시도하거나 로그아웃합니다. 로그인 도중의 실패는 로그인 폼에 표시합니다.

  5. logout → anonymous

    토큰·사용자를 지우고 요청 번호를 바꿉니다. 이전 요청의 늦은 성공과 실패는 현재 세션에 반영하지 않습니다.

  • 프로필 확인 완료
  • 확인 결과·사용자 행동

그림은 저장 토큰의 확인 흐름입니다. 새 로그인의 실패는 anonymous 상태에서 폼 오류로 표시하며, 성공은 profile까지 확인한 뒤 저장합니다. 모든 logout은 늦은 결과를 무효화합니다. 별도 관리 API의 403은 해당 작업의 권한 거절이며 이 최소 라우터에는 관리 화면이 없습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명저장된 토큰은 먼저 profile로 확인합니다. 확인 중에는 보호 화면의 표시를 보류하고, 200 응답의 공개 필드를 확인한 뒤 사용자를 표시합니다. 401이면 인증을 지우고, 연결·응답 형태 오류라면 토큰을 남겨 재시도합니다.
전체 관계·조건 설명저장된 토큰의 프로필 확인 결과에 따라 상태가 checking에서 authenticated, anonymous 또는 재시도 가능한 error로 바뀌며 로그아웃은 토큰과 사용자를 지우고 이전 요청의 결과를 무시합니다.
원도표 조건·주석시작 → checking 또는 anonymous: 저장 토큰이 있으면 profile을 확인하며 기다립니다. 없으면 로그인 화면으로 이동합니다.
원도표 조건·주석200 · 공개 필드 확인 → authenticated: id·email·name을 확인한 뒤 보호 화면을 엽니다. 새 로그인도 토큰 발급만으로 끝내지 않고 profile을 조회합니다.
원도표 조건·주석401 → anonymous: 프로필의 인증 거절은 토큰과 사용자를 지우고 다시 로그인하게 합니다. 만료 시각을 자동 감지하지 않고 요청의 응답을 확인합니다.
원도표 조건·주석연결·응답 형태 오류 → error: 저장 토큰의 확인 실패가 곧 만료는 아닙니다. 같은 토큰으로 재시도하거나 로그아웃합니다. 로그인 도중의 실패는 로그인 폼에 표시합니다.
원도표 조건·주석logout → anonymous: 토큰·사용자를 지우고 요청 번호를 바꿉니다. 이전 요청의 늦은 성공과 실패는 현재 세션에 반영하지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
토큰 확인 중token: 있음 / session: checking저장된 token만으로 authenticated 상태를 확정하지 않는다credential 보유와 검증된 session은 다르다
검증 결과200: authenticated / 401/network: anonymous/retriable error200은 공개 profile, 401은 anonymous, network 오류는 재시도 상태다일시적 오류를 무조건 logout으로 해석하지 않는다
logout 뒤 완료session: anonymous / 늦은 응답: 반영 없음logout이 request ID를 바꾸면 늦은 profile 응답은 무시한다ID 무효화는 실제 HTTP 취소와 다르다

이 예제는 새로고침 뒤 세션 확인을 설명하기 위해 토큰을 localStorage에 저장합니다. 같은 origin의 JavaScript가 읽을 수 있는 저장소이며 갱신 토큰·서버 토큰 폐기·여러 탭의 로그아웃 동기화는 제공하지 않습니다. 사용자의 관리 권한은 클라이언트 상태가 아니라 서버가 매 요청에서 판단합니다.

세 화면 작성하기

src/pages 폴더를 만들고 회원가입부터 작성합니다. 요청 중 표시와 결과는 이 화면에서 관리합니다. 서버의 비밀번호 입력 조건과 맞춰 최소 8자를 입력하게 합니다.

packages/client/src/pages/RegisterPage.tsx
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { authService, requestErrorMessage } from '../services/authService';

export default function RegisterPage() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState('');
  const [error, setError] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setBusy(true);
    setMessage('');
    setError('');
    try {
      const user = await authService.register({ name, email, password });
      setPassword('');
      setMessage(`${user.email} 계정을 만들었습니다. 이제 로그인해 주세요.`);
    } catch (error: unknown) {
      setError(requestErrorMessage(error));
    } finally {
      setBusy(false);
    }
  }

  return (
    <main>
      <h1>회원가입</h1>
      <form onSubmit={handleSubmit}>
        <p>
          <label htmlFor="register-name">이름</label>
          <input id="register-name" value={name} onChange={(e) => setName(e.target.value)}
            autoComplete="name" required />
        </p>
        <p>
          <label htmlFor="register-email">이메일</label>
          <input id="register-email" type="email" value={email}
            onChange={(e) => setEmail(e.target.value)} autoComplete="email" required />
        </p>
        <p>
          <label htmlFor="register-password">비밀번호</label>
          <input id="register-password" type="password" value={password}
            onChange={(e) => setPassword(e.target.value)} autoComplete="new-password"
            minLength={8} required />
        </p>
        <button type="submit" disabled={busy}>{busy ? '가입 중…' : '회원가입'}</button>
      </form>
      {message && <p role="status">{message}</p>}
      {error && <p role="alert">{error}</p>}
      <p><Link to="/login">로그인으로 이동</Link></p>
    </main>
  );
}

로그인 화면은 store의 상태를 사용합니다. 성공했다는 문구만 띄우는 대신, 확인된 사용자가 생기면 실제 프로필 경로로 이동합니다.

packages/client/src/pages/LoginPage.tsx
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Link, Navigate } from 'react-router';
import { useAuthStore } from '../store/authStore';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const login = useAuthStore((state) => state.login);
  const status = useAuthStore((state) => state.status);
  const error = useAuthStore((state) => state.error);

  if (status === 'authenticated') return <Navigate to="/profile" replace />;

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    await login({ email, password });
  }

  return (
    <main>
      <h1>로그인</h1>
      <form onSubmit={handleSubmit}>
        <p>
          <label htmlFor="login-email">이메일</label>
          <input id="login-email" type="email" value={email}
            onChange={(e) => setEmail(e.target.value)} autoComplete="email" required />
        </p>
        <p>
          <label htmlFor="login-password">비밀번호</label>
          <input id="login-password" type="password" value={password}
            onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
        </p>
        <button type="submit" disabled={status === 'checking'}>
          {status === 'checking' ? '확인 중…' : '로그인'}
        </button>
      </form>
      {error && <p role="alert">{error}</p>}
      <p><Link to="/register">회원가입으로 이동</Link></p>
    </main>
  );
}

프로필에는 공개 응답의 세 필드만 표시합니다. isAdmin은 서버 내부 사용자에 있는 값이며 공개 프로필에 없으므로 여기서 읽지 않습니다.

packages/client/src/pages/UserProfilePage.tsx
import { useAuthStore } from '../store/authStore';

export default function UserProfilePage() {
  const user = useAuthStore((state) => state.user);
  const logout = useAuthStore((state) => state.logout);
  if (!user) return null;

  return (
    <main>
      <h1>내 프로필</h1>
      <dl>
        <dt>사용자 번호</dt><dd>{user.id}</dd>
        <dt>이메일</dt><dd>{user.email}</dd>
        <dt>이름</dt><dd>{user.name}</dd>
      </dl>
      <button onClick={logout}>로그아웃</button>
    </main>
  );
}

라우터에서 세션 확인 기다리기

생성된 App.tsx를 다음 코드로 바꿉니다. PrivateRoute는 토큰 문자열만으로 화면을 열지 않고 확인된 상태를 사용합니다. 연결 오류라면 같은 토큰으로 재시도하거나 로그아웃할 수 있습니다.

packages/client/src/App.tsx
import { useEffect } from 'react';
import type { ReactNode } from 'react';
import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import UserProfilePage from './pages/UserProfilePage';
import { useAuthStore } from './store/authStore';

function PrivateRoute({ children }: { children: ReactNode }) {
  const status = useAuthStore((state) => state.status);
  const user = useAuthStore((state) => state.user);
  const error = useAuthStore((state) => state.error);
  const fetchUser = useAuthStore((state) => state.fetchUser);
  const logout = useAuthStore((state) => state.logout);

  if (status === 'checking') return <p role="status">세션을 확인하고 있습니다.</p>;
  if (status === 'error') {
    return (
      <main>
        <h1>프로필을 확인하지 못했습니다</h1>
        <p role="alert">{error}</p>
        <button onClick={() => void fetchUser()}>다시 확인</button>
        <button onClick={logout}>로그아웃</button>
      </main>
    );
  }
  if (status !== 'authenticated' || !user) return <Navigate to="/login" replace />;
  return <>{children}</>;
}

export default function App() {
  const fetchUser = useAuthStore((state) => state.fetchUser);
  useEffect(() => { void fetchUser(); }, [fetchUser]);

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Navigate to="/profile" replace />} />
        <Route path="/register" element={<RegisterPage />} />
        <Route path="/login" element={<LoginPage />} />
        <Route path="/profile" element={
          <PrivateRoute><UserProfilePage /></PrivateRoute>
        } />
        <Route path="*" element={<main><h1>페이지를 찾을 수 없습니다</h1>
          <Link to="/profile">프로필로 이동</Link></main>} />
      </Routes>
    </BrowserRouter>
  );
}

Vite가 만든 main.tsx는 그대로 App을 렌더하게 둡니다. 이 예제의 BrowserRouter는 App에 있으므로 main.tsx에 하나 더 감싸지 않습니다. 화면 꾸미기는 세 경로가 연결된 뒤 추가해도 됩니다. 위 파일은 Tailwind나 별도의 공통 Button 컴포넌트를 요구하지 않습니다.

직접 따라가며 확인할 순서

앞 두 절의 서버 파일과 실습용 DB 설정을 준비한 다음, 서버는 packages/server에서 npm run start:dev, 클라이언트는 packages/client에서 npm run dev로 시작합니다. 브라우저는 http://localhost:3000/register로 엽니다.

  1. 아직 등록하지 않은 이메일과 이름, 8자 이상의 비밀번호로 회원가입합니다. 개발자 도구 Network에서 /auth/register의 201과 공개 세 필드를 확인하고 로그인으로 이동합니다. 등록 응답에는 토큰이 없습니다.
  2. 같은 이메일·비밀번호로 로그인합니다. /auth/login의 200 뒤 /auth/profile의 200이 이어져야 하며, 프로필 화면에 사용자 번호·이메일·이름이 표시됩니다. 비밀번호·해시·관리자 여부가 응답에 섞이지 않는지도 확인합니다.
  3. 프로필 화면을 새로고침합니다. 저장된 토큰을 확인한 뒤 같은 사용자 정보가 표시되어야 합니다. 확인하는 동안에는 보호 화면 대신 대기 문구가 나옵니다.
  4. 로그아웃합니다. 로그인 화면으로 돌아가고 저장 토큰이 지워져야 합니다. 다시 /profile로 들어가도 프로필은 열리지 않아야 합니다.
  5. 로그인 화면에서 잘못된 비밀번호를 제출합니다. 401이 폼 오류로 표시되고 프로필로 이동하지 않아야 합니다. 다시 올바른 비밀번호로 로그인할 수 있어야 합니다.

예시: 프로필 대신 연결 오류가 보이면 Network의 실제 URL이 http://localhost:4100/auth/profile인지 먼저 봅니다. 서버가 켜져 있는지, client가 3000번을 쓰는지, 서버 CORS가 같은 origin을 허용하는지 순서대로 확인해 주세요. http://127.0.0.1:3000은 http://localhost:3000과 origin이 다릅니다.

프로필 확인의 401은 다시 로그인할 경우이고, 서버 연결 실패는 재시도할 경우입니다. 200인데 응답 모양 오류가 보이면 해당 응답의 id·email·name과 API 주소를 대조합니다. 화면에 토큰만 있는 것으로 세션 성공을 판단하지 않습니다.

이 경로에서 사용한 shared export, 요청 서비스, 세션 상태, 세 페이지는 서로 연결됩니다. 이메일 중복 처리·초기 관리자 준비·갱신 토큰·관리 UI·운영 배포는 별도 확장입니다. 다음 빌드와 배포 절을 읽을 때도 클라이언트 빌드와 운영 준비의 범위를 구분해 주세요.