프론트엔드 애플리케이션 개발
React와 Vite로 회원가입, 로그인, 프로필 화면을 만들어 앞 절의 API에 연결하고, 서버의 응답을 unknown으로 받아 확인하며, 저장된 토큰을 서버가 확인해 줄 때까지 기다리는 세션 상태와 늦게 도착한 응답을 버리는 방법을 구현합니다.
앞 절의 서버는 회원가입과 로그인을 받고, 토큰을 가진 사용자에게 사용자 번호와 이메일, 이름만 돌려줍니다. 이 절에서는 그 API에 연결되는 클라이언트를 만들어, 회원가입한 뒤 로그인하고 받은 토큰으로 자기 정보를 보는 세 화면을 구현합니다. API 서버 구현과 인증·인가와 서버 실행의 서버가 준비되어 있어야 합니다.
관리자 화면과 사용자 관리 기능은 만들지 않습니다. 일반 사용자의 정보는 관리자 전용인 /users가 아니라 /auth/profile에서 가져옵니다.
프로젝트와 주소 맞추기
저장소의 루트에서 첫 줄로 packages/client 폴더를 만들어 들어간 뒤, Vite의 React TypeScript 템플릿으로 프로젝트를 만들고, 요청과 상태, 라우팅에 쓸 패키지를 정확한 버전으로 설치합니다. 둘째 줄의 .은 새 폴더를 만들지 않고 지금 폴더에 프로젝트 파일을 만들라는 뜻이며, 셋째 줄이 템플릿의 기본 의존성을, 넷째 줄이 이 장에서 더하는 세 패키지를 설치합니다. create-vite가 린터와 지금 바로 설치하고 실행할지를 물으면, 린터는 어느 쪽이든 고르고 설치와 실행 질문에는 No를 골라 셋째와 넷째 줄을 이어서 실행합니다.
mkdir -p packages/client && cd packages/client
npm create vite@latest . -- --template react-ts
npm install
npm install --save-exact [email protected] [email protected] [email protected]템플릿은 만들 때의 최신 React와 TypeScript를 설치하며, 2026년 10월 현재 TypeScript 7.0입니다. 클라이언트와 서버는 각자 설치하는 독립된 프로젝트이므로, 서버가 고정한 TypeScript 5.9.3과 버전이 달라도 문제가 없습니다. Axios와 Zustand, React Router는 자체 타입을 포함하므로 @types 패키지를 따로 설치하지 않으며, 라우터는 React Router의 선언형 사용 안내대로 react-router에서 가져옵니다.
앞 절의 서버는 CORS로 http://localhost:3000만 허용하는데, Vite 개발 서버의 기본 포트는 5173입니다. 그래서 클라이언트의 포트를 3000으로 정합니다.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 3000, strictPort: true },
});VITE_API_BASE_URL=http://localhost:4100strictPort는 3000번을 쓸 수 없을 때 다른 포트로 넘어가지 않고 멈추게 해서, CORS가 허용하지 않는 주소로 조용히 바뀌는 일을 막습니다. .env의 VITE_로 시작하는 변수는 빌드할 때 코드에 들어가 브라우저에 그대로 공개되므로, API 주소만 넣고 서명 키나 데이터베이스 비밀번호는 넣지 않습니다. .env를 바꾼 뒤에는 개발 서버를 다시 시작해야 반영됩니다.
이 화면들이 쓰는 요청과 기대하는 응답은 다음 세 가지입니다. 서버에 /api 같은 접두사를 두지 않았으므로 경로 앞에 붙이지 않습니다.
| 화면의 동작 | 요청 | 기대하는 응답 |
|---|---|---|
| 회원가입 | POST /auth/register | 201, id, email, name |
| 로그인 | POST /auth/login | 200, accessToken |
| 내 정보 조회 | GET /auth/profile, Bearer 토큰 | 200, id, email, name |
공유 타입 만들기
프로젝트 구조 설계에서 정한 대로, packages/shared에 클라이언트와 서버가 주고받는 데이터의 타입을 둡니다. 서버의 엔티티를 그대로 공유하지 않고, 요청과 공개 응답에 필요한 모양만 정의합니다.
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;
}export type { CreateUserDto, LoginCredentials, LoginResponseDto, PublicUserDto } from "./interfaces/user";클라이언트의 앱 코드에 적용되는 tsconfig.app.json의 compilerOptions에 "paths": { "@shared/*": ["../shared/src/*"] }를 더합니다. 그러면 import type { PublicUserDto } from "@shared/index"가 shared의 파일을 찾습니다. paths의 경로는 설정 파일의 위치를 기준으로 하므로, TypeScript 6.0에서 폐기 예정이 되고 7.0에서 없어진 baseUrl은 적지 않습니다.
import type은 컴파일하면 사라지므로, 실행 중에는 shared에서 아무것도 가져오지 않고 Vite에 경로를 따로 알려 줄 필요도 없습니다. 거꾸로 말하면 paths는 타입 검사에서만 쓰이므로, 나중에 shared에서 함수 같은 값을 가져오려면 Vite의 resolve.alias나 workspaces로 실행 중의 경로도 연결해야 합니다.
응답을 확인하는 서비스
src/services에 Axios 인스턴스를 만듭니다. 설정이 잘못되었을 때 요청마다 실패하게 두지 않고, 앱이 시작할 때 주소를 확인합니다.
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,
});new URL은 주소의 형식이 틀리면 예외를 던지고, 그 뒤의 검사가 http와 https만 받습니다. timeout은 응답이 5초 안에 오지 않으면 요청을 실패로 처리합니다. 다음 서비스는 응답을 unknown으로 받아 필요한 필드를 확인한 뒤에만 앱의 타입으로 넘깁니다.
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);
},
};apiClient.post<User>처럼 Axios의 타입 인수에 기대하는 타입을 적어도 응답을 검사하지는 않으므로, 여기서는 unknown을 적고 parsePublicUser와 parseLogin이 직접 확인합니다. parsePublicUser는 응답에 다른 필드가 섞여 있어도 세 필드만 골라 새 객체를 만들고, requestErrorMessage는 서버의 오류 메시지가 문자열이든 ValidationPipe가 만든 배열이든 화면에 보여 줄 문장으로 바꿉니다.
register는 관리자용 /users가 아니라 공개된 /auth/register를 부르며, 그 응답에는 토큰이 없으므로 회원가입만으로는 로그인 상태가 되지 않습니다. getProfile은 확인할 토큰을 인수로 받아 그 요청의 헤더에만 넣으며, 모든 요청에 끼어드는 Axios 인터셉터로 401이 오면 페이지를 강제로 옮기는 식의 숨은 동작은 두지 않았습니다.
FRONTEND RUNTIME BOUNDARY
브라우저의 외부 값은 검증 뒤에만 앱 상태가 된다
API 주소는 시작할 때 URL 형식을 확인합니다. auth 응답은 unknown으로 받은 뒤 필요한 필드를 검사하고, 사용자 객체에서는 id·email·name만 골라 state와 UI로 전달합니다. 타입 선언만으로 실제 JSON을 믿지 않습니다.
- 두 입력: 공개 설정·auth 응답
VITE_API_BASE_URL은 공개 설정입니다. 시작할 때 http/https URL인지 확인합니다. auth JSON 응답은 각 요청 뒤 unknown으로 확인합니다. 이 둘은 연속 요청 단계가 아닙니다.
- 각 경계의 URL·필드 검사
parseLogin은 문자열 accessToken을, parsePublicUser는 양의 안전한 정수 id와 문자열 email·name을 확인합니다. Axios generic은 이를 대신하지 않습니다.
- 검증 성공 → Typed state
사용자는 공개 세 필드로 새 객체를 만듭니다. 로그인은 profile 확인 뒤 저장하고, 이전 요청의 늦은 응답은 요청 번호로 무시합니다.
- React 출력 경계
프로필은 세 필드를 일반 React 텍스트로 표시합니다. raw HTML이나 사용자 URL은 이 화면에 없으며, 별도 확장에서 추가한다면 출력에 맞는 검사가 필요합니다.
- 핵심 경계
- 진행·선택 경로
URL 형식과 공개 응답 필드를 확인하는 작은 경계입니다. 모든 JSON·URL 보안 정책을 구현한 것은 아닙니다. 클라이언트의 parser와 route guard는 서버의 입력 검증·인증·인가를 대신하지 않습니다.
그림처럼 브라우저 바깥에서 들어오는 값은 두 가지입니다. 공개 설정인 API 주소는 앱이 시작할 때 URL의 형식을, 서버의 JSON 응답은 요청마다 필요한 필드를 확인하며, 둘은 차례로 일어나는 단계가 아니라 따로 확인하는 입구입니다. 확인을 거친 공개 사용자의 세 필드만 상태에 들어가 React의 텍스트로 그려지며, React는 텍스트를 이스케이프해서 그리므로 이름에 HTML이 들어 있어도 태그로 해석되지 않습니다. 이 작은 검사들은 서버의 입력 검증과 인증, 인가를 대신하지 않습니다.
더 깊이: import.meta.env의 값이 정해지는 때와 그 타입
Vite의 환경 변수 안내에 따르면, import.meta.env.VITE_API_BASE_URL처럼 VITE_로 시작하는 변수의 값은 빌드할 때 소스 코드에 그대로 들어가 번들에 포함됩니다. 그래서 한 번 빌드한 번들의 API 주소를 바꾸려면 다시 빌드해야 합니다. 값을 정하는 우선순위도 있어서, .env.production처럼 모드에 맞는 파일이 .env보다 앞서고, Vite를 실행하기 전에 셸에 이미 있던 환경 변수는 어느 .env 파일로도 덮이지 않습니다. 또 Vite는 이 값들을 모두 문자열로 내놓으므로, 숫자나 불리언으로 쓰려면 코드에서 직접 바꾸어야 합니다.
변수의 타입은 src/vite-env.d.ts에서 interface ImportMetaEnv를 넓혀 readonly VITE_API_BASE_URL: string처럼 적으며, 안내의 예에는 ImportMetaEnv를 더 엄격하게 만드는 strictImportMetaEnv 설정도 나옵니다. 안내는 이 확장이 동작하지 않으면 vite-env.d.ts에 import 문이 있는지 확인하라고 하는데, 8장에서 본 것처럼 최상위 import가 있으면 그 파일이 모듈이 되어 전역 인터페이스와 합쳐지지 않기 때문입니다. 다만 이렇게 적은 타입은 값이 정말 들어 있다는 약속이 아니므로, 이 절의 코드처럼 앱이 시작할 때 주소의 형식을 직접 확인하는 일은 그대로 필요합니다.
토큰과 확인된 세션 나누기
브라우저에 토큰이 남아 있다고 서버가 지금 그 토큰을 받아 준다는 보장은 없습니다. 토큰이 만료되었거나 계정이 지워졌을 수 있으므로, 새로고침하면 먼저 프로필을 요청해 확인하고, 확인이 끝날 때까지 보호된 화면을 열지 않습니다. 세션의 상태는 네 가지로 나눕니다.
| 상태 | 화면에서 할 일 |
|---|---|
checking | 프로필의 확인을 기다립니다. |
anonymous | 로그인 화면으로 보냅니다. |
authenticated | 확인된 공개 사용자를 보여 줍니다. |
error | 연결이나 응답의 오류를 보여 주고, 다시 확인하거나 로그아웃할 수 있게 합니다. |
src/store/authStore.ts에 이 상태와 동작을 Zustand 스토어로 둡니다.
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 });
},
}));로그인은 토큰을 받은 뒤 그 토큰으로 프로필까지 확인하고 나서야 토큰과 사용자를 저장합니다. 이미 저장된 토큰을 확인하다가 401을 받으면 서버가 그 인증을 더는 받아들이지 않는다는 뜻이므로 토큰과 사용자를 지우지만, 연결이 실패한 것은 토큰이 틀렸다는 뜻이 아니므로 토큰을 남긴 채 error 상태로 두어 다시 확인할 수 있게 합니다. 관리자 경로의 403은 그 작업의 권한이 없다는 뜻일 뿐이므로, 그것을 이유로 세션 전체를 지우지 않습니다.
requestVersion은 Zustand를 활용한 상태 관리에서 본 요청 번호입니다. 새 요청을 시작하거나 로그아웃할 때 번호를 바꾸므로, 그 전에 시작한 요청의 결과가 늦게 도착해도 상태를 덮지 못합니다. 그래서 로그아웃한 직후에 이전의 프로필 응답이 와도 사용자가 다시 로그인되지 않고, 개발 모드의 StrictMode가 처음의 확인을 두 번 실행해도 마지막 요청의 결과만 반영됩니다.
서버 확인 결과에 따라 세션과 화면을 바꾼다 — 상태 추적
저장 토큰과 검증된 session은 다르며 200/401/네트워크 오류/로그아웃 뒤 늦은 응답의 UI 반영 여부가 바뀐다. request ID 기반 무시와 실제 취소를 혼동하지 않는다.
SESSION STATE MACHINE
서버 확인 결과에 따라 세션과 화면을 바꾼다
저장된 토큰은 먼저 profile로 확인합니다. 확인 중에는 보호 화면을 기다리고, 200 응답의 공개 필드를 확인한 뒤 사용자를 표시합니다. 401이면 인증을 지우고, 연결·응답 형태 오류라면 토큰을 남겨 재시도합니다.
단계별 조건과 보충 설명
- 시작 → checking 또는 anonymous
저장 토큰이 있으면 profile을 확인하며 기다립니다. 없으면 로그인 화면으로 이동합니다.
- 200 · 공개 필드 확인 → authenticated
id·email·name을 확인한 뒤 보호 화면을 엽니다. 새 로그인도 토큰 발급만으로 끝내지 않고 profile을 조회합니다.
- 401 → anonymous
프로필의 인증 거절은 토큰과 사용자를 지우고 다시 로그인하게 합니다. 만료 시각을 자동 감지하지 않고 요청의 응답을 확인합니다.
- 연결·응답 형태 오류 → error
저장 토큰의 확인 실패가 곧 만료는 아닙니다. 같은 토큰으로 재시도하거나 로그아웃합니다. 로그인 도중의 실패는 로그인 폼에 표시합니다.
- 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 error | 200은 공개 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 또는 anonymous
저장 토큰이 있으면 profile을 확인하며 기다립니다. 없으면 로그인 화면으로 이동합니다.
- 200 · 공개 필드 확인 → authenticated
id·email·name을 확인한 뒤 보호 화면을 엽니다. 새 로그인도 토큰 발급만으로 끝내지 않고 profile을 조회합니다.
- 401 → anonymous
프로필의 인증 거절은 토큰과 사용자를 지우고 다시 로그인하게 합니다. 만료 시각을 자동 감지하지 않고 요청의 응답을 확인합니다.
- 연결·응답 형태 오류 → error
저장 토큰의 확인 실패가 곧 만료는 아닙니다. 같은 토큰으로 재시도하거나 로그아웃합니다. 로그인 도중의 실패는 로그인 폼에 표시합니다.
- 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 error | 200은 공개 profile, 401은 anonymous, network 오류는 재시도 상태다 | 일시적 오류를 무조건 logout으로 해석하지 않는다 |
| logout 뒤 완료 | session: anonymous / 늦은 응답: 반영 없음 | logout이 request ID를 바꾸면 늦은 profile 응답은 무시한다 | ID 무효화는 실제 HTTP 취소와 다르다 |
그림은 이 네 상태가 바뀌는 길입니다. 저장된 토큰이 있으면 checking에서 프로필을 확인해, 200과 올바른 필드면 authenticated로, 401이면 토큰을 지우고 anonymous로, 연결이나 응답 모양의 오류면 토큰을 남긴 error로 갑니다. 어느 상태에서든 로그아웃하면 토큰을 지우고 요청 번호를 바꿉니다.
이 예제는 새로고침한 뒤에도 세션을 확인하려고 토큰을 localStorage에 저장합니다. localStorage는 같은 출처의 자바스크립트가 모두 읽을 수 있으므로, 페이지에 악성 스크립트가 들어오면 토큰도 읽힐 수 있습니다. 갱신 토큰, 서버의 토큰 폐기, 여러 탭 사이의 로그아웃 동기화는 제공하지 않으며, 관리 권한의 판단은 클라이언트의 상태가 아니라 서버가 요청마다 합니다.
세 화면
src/pages에 회원가입 화면부터 만듭니다. 요청 중인지와 결과는 이 화면의 상태로 관리하고, 서버와 맞춰 비밀번호를 8자 이상 입력하게 합니다.
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 (err: unknown) {
setError(requestErrorMessage(err));
} finally {
setBusy(false);
}
}
return (
<main>
<h1>회원가입</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="register-name">이름</label>
<input id="register-name" value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" required />
<label htmlFor="register-email">이메일</label>
<input id="register-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" required />
<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 />
<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>
);
}로그인 화면은 스토어의 상태를 씁니다. 성공했다는 문구를 띄우는 대신, 확인된 사용자가 생기면 프로필 경로로 옮겨 갑니다.
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}>
<label htmlFor="login-email">이메일</label>
<input id="login-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" required />
<label htmlFor="login-password">비밀번호</label>
<input id="login-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
<button type="submit" disabled={status === "checking"}>{status === "checking" ? "확인 중…" : "로그인"}</button>
</form>
{error && <p role="alert">{error}</p>}
<p><Link to="/register">회원가입으로 이동</Link></p>
</main>
);
}프로필 화면에는 공개 응답의 세 필드만 보여 줍니다. 관리자 여부는 서버 안에만 있는 값이어서 공개 프로필에 없으므로 여기서 읽지 않습니다.
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>
);
}if (!user) return null;은 사용자 정보가 아직 없을 때 아무것도 그리지 않는 방어 코드입니다. 이 화면이 확인된 상태에서만 열리게 하는 일은 다음 절의 PrivateRoute가 합니다.
라우터에서 세션의 확인 기다리기
템플릿이 만든 App.tsx를 다음 코드로 바꿉니다. PrivateRoute는 토큰이 있다는 것만으로 화면을 열지 않고, 확인된 상태일 때만 자식을 보여 줍니다.
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>
);
}App은 처음 마운트될 때 fetchUser로 저장된 토큰을 확인하고, BrowserRouter를 직접 감싸므로 템플릿의 main.tsx는 그대로 App을 그리게 둡니다. 화면의 꾸밈은 세 경로가 연결된 뒤에 더해도 되며, 위 파일들은 따로 스타일 라이브러리나 공통 컴포넌트를 요구하지 않습니다.
따라가며 확인하기
서버는 packages/server에서 npm run start:dev로, 클라이언트는 packages/client에서 npm run dev로 시작하고, 브라우저에서 http://localhost:3000/register를 엽니다. 개발자 도구의 Network 탭을 열어 두고 다음 순서로 확인합니다.
먼저 등록하지 않은 이메일과 이름, 8자 이상의 비밀번호로 회원가입하면 /auth/register가 201과 세 필드를 돌려주고 안내 문구가 보입니다. 같은 이메일과 비밀번호로 로그인하면 /auth/login의 200 뒤에 /auth/profile의 200이 이어지고 프로필 화면에 사용자 번호와 이메일, 이름이 보이며, 응답에 비밀번호나 해시, 관리자 여부가 섞이지 않습니다. 프로필 화면을 새로고침하면 잠깐 확인 중이라는 문구가 보인 뒤 같은 정보가 다시 보입니다.
로그아웃하면 로그인 화면으로 돌아가고, 주소창에 /profile을 넣어도 프로필이 열리지 않습니다. 틀린 비밀번호로 로그인하면 401이 폼의 오류 문구로 보이고 프로필로 옮겨 가지 않으며, 다시 올바른 비밀번호로 로그인할 수 있습니다.
프로필 대신 연결 오류가 보이면, Network 탭에서 요청한 주소가 http://localhost:4100/auth/profile인지, 서버가 켜져 있는지, 클라이언트가 3000번 포트를 쓰는지를 차례로 봅니다. 브라우저의 주소를 http://127.0.0.1:3000으로 열면 http://localhost:3000과 출처가 달라 CORS가 막으므로, 서버가 허용한 주소와 같게 엽니다.
정리
- 클라이언트는 서버와 따로 설치하는 프로젝트이며, CORS가 허용한 3000번 포트를
strictPort로 고정하고 API 주소는 공개되는VITE_변수로 둡니다. shared의 타입은import type과paths로 가져오며,paths는 타입 검사에만 쓰이고baseUrl없이 설정 파일의 위치를 기준으로 합니다.- 서버의 응답은
unknown으로 받아 필요한 필드를 확인한 뒤 공개 필드만 골라 상태에 넣으며, Axios의 타입 인수는 그 확인을 대신하지 않습니다. - 세션은
checking,anonymous,authenticated,error로 나누어, 저장된 토큰을 서버가 확인해 줄 때까지 보호된 화면을 열지 않습니다. - 401은 인증을 지우고 연결 오류는 토큰을 남겨 다시 확인하게 하며, 요청 번호로 늦게 도착한 응답이 상태를 덮지 못하게 합니다.
- 클라이언트의 검사와 경로 보호는 사용자 경험을 위한 것이며, 입력 검증과 인증, 인가는 서버가 요청마다 합니다.
연습 문제
1. 연결 오류와 401
저장된 토큰으로 프로필을 확인하는데 서버가 꺼져 있어 연결이 실패한 경우와, 서버가 401을 돌려준 경우에 세션 상태와 localStorage의 토큰은 각각 어떻게 되나요?
해설 보기
연결이 실패하면 isUnauthorized가 거짓이므로 상태는 error가 되고 토큰은 남아, 서버를 켠 뒤 다시 확인을 누르면 같은 토큰으로 로그인 상태를 되찾을 수 있습니다. 401이면 서버가 그 토큰을 받아들이지 않는다는 뜻이므로 토큰을 지우고 상태는 anonymous가 되어 로그인 화면으로 갑니다.
2. 로그아웃 뒤의 늦은 응답
로그인 버튼을 누른 직후 프로필 요청이 끝나기 전에, 다른 컴포넌트에서 스토어의 logout()이 불렸습니다. 이 절의 화면에는 그때 누를 로그아웃 버튼이 없으므로, 나중에 헤더 같은 곳에 로그아웃 버튼을 더한 경우로 생각합니다. 그 뒤 프로필 응답이 200으로 도착하면 화면은 어떻게 되나요? requestVersion 검사가 없다면 어떻게 될까요?
해설 보기
로그아웃이 requestVersion을 바꾸었으므로, 로그인 요청의 version과 달라 늦게 온 응답은 무시되고 화면은 로그인 화면에 머뭅니다. 검사가 없다면 늦은 응답이 토큰을 다시 저장하고 상태를 authenticated로 바꾸어, 사용자가 로그아웃했는데도 다시 로그인된 상태가 됩니다.
3. 응답에 섞인 필드
서버의 실수로 /auth/profile의 응답에 isAdmin: true가 섞여 왔습니다. 클라이언트의 스토어에 들어가는 사용자 객체에는 무엇이 있나요? 이것으로 서버의 문제가 해결된 것인가요?
해설 보기
parsePublicUser는 세 필드만 골라 새 객체를 만들므로, 스토어의 사용자에는 id, email, name만 있고 isAdmin은 없습니다. 하지만 응답은 이미 네트워크로 브라우저까지 왔으므로 개발자 도구로 누구나 볼 수 있으며, 공개하면 안 되는 필드는 서버의 toPublicUser가 응답에서 빼야 합니다.
다음 절 CI/CD 파이프라인 구축에서는 이 클라이언트와 서버를 GitHub Actions로 검사하고 빌드해, 배포할 수 있는 결과물을 만드는 과정을 자동화합니다.