프론트엔드 통합 (React, Angular)
React 인증 상태·라우팅·프로젝트 화면을 REST API에 연결하고, 파일 편집·자동 저장의 기본 흐름과 협업 확장 준비를 구분합니다.
앞 절에서는 온라인 코드 에디터의 인증 API와 프로젝트·파일 REST API를 만들었습니다. 이번 절에서는 이 API를 쓰는 프런트엔드를 만들어, 로그인부터 프로젝트 선택, 파일 편집, REST 저장까지의 흐름을 화면에 연결합니다. 예제는 React로 작성합니다. Angular를 쓴다면 HttpClient와 HTTP interceptor로 같은 토큰 전달과 오류 처리 흐름을 구성할 수 있습니다.
기본 흐름은 REST만으로 동작합니다. 앞 절의 선택 실습인 같은 파일의 두 세션 연결하기에서 Gateway를 추가했다면, 에디터 글에서 Socket.IO 클라이언트로 협업 모드를 켤 수 있습니다. React의 state, effect를 이용한 데이터 요청, 라우팅과 Promise 기반 API 호출이 익숙하지 않다면 먼저 복습해 주세요. 백엔드는 모듈 연결과 실행에서 API 호출을 확인한 상태에서 시작합니다.
Nest · Authentication state boundaries
React 인증 상태와 HTTP 책임
AuthContext·localStorage·axios·PrivateRoute를 원문 코드와 비교합니다. 초기 인증 확인과 로그인 직후 프로필 조회의 실패 처리는 다르며, UI 보호 라우트는 서버 권한 검사를 대신하지 않습니다.
| 구성 | 원문에서 하는 일 | 경계 |
|---|---|---|
| 토큰 저장 | login은 accessToken을 localStorage에 저장하고 logout은 지웁니다. | 메모리 전용 저장 구현이 아닙니다. |
| HTTP 요청 | 공유 axios 인스턴스가 localStorage 토큰을 읽어 Bearer 헤더를 붙입니다. | baseURL은 localhost로 고정돼 있고 응답 Zod 검증은 없습니다. |
| 초기 인증 확인 | 토큰이 있으면 /auth/profile을 조회하여 user·isAuthenticated를 설정합니다. | 조회 실패는 저장 토큰과 인증 상태를 지웁니다. |
| 로그인 이후 | 토큰 저장 후 인증 상태를 true로 바꾸고 프로필을 비동기로 조회합니다. | login 자체 catch는 콘솔만 기록합니다. 401은 공통 interceptor가 토큰 삭제·이동하며, 다른 오류는 인증 true가 유지될 수 있습니다. |
| 보호 라우트·401 | PrivateRoute는 loading·isAuthenticated로 화면을 고릅니다. HTTP 401은 토큰 삭제 후 /login으로 이동합니다. | 화면 분기는 서버의 API 권한 검사를 대신하지 않습니다. |
- 구성: 토큰 저장
- 원문에서 하는 일
- login은 accessToken을 localStorage에 저장하고 logout은 지웁니다.
- 경계
- 메모리 전용 저장 구현이 아닙니다.
- 구성: HTTP 요청
- 원문에서 하는 일
- 공유 axios 인스턴스가 localStorage 토큰을 읽어 Bearer 헤더를 붙입니다.
- 경계
- baseURL은 localhost로 고정돼 있고 응답 Zod 검증은 없습니다.
- 구성: 초기 인증 확인
- 원문에서 하는 일
- 토큰이 있으면 /auth/profile을 조회하여 user·isAuthenticated를 설정합니다.
- 경계
- 조회 실패는 저장 토큰과 인증 상태를 지웁니다.
- 구성: 로그인 이후
- 원문에서 하는 일
- 토큰 저장 후 인증 상태를 true로 바꾸고 프로필을 비동기로 조회합니다.
- 경계
- login 자체 catch는 콘솔만 기록합니다. 401은 공통 interceptor가 토큰 삭제·이동하며, 다른 오류는 인증 true가 유지될 수 있습니다.
- 구성: 보호 라우트·401
- 원문에서 하는 일
- PrivateRoute는 loading·isAuthenticated로 화면을 고릅니다. HTTP 401은 토큰 삭제 후 /login으로 이동합니다.
- 경계
- 화면 분기는 서버의 API 권한 검사를 대신하지 않습니다.
코드의 AuthContext·axios·라우팅 책임을 비교한 것이다. 응답 데이터의 런타임 스키마 검증은 별도로 구현해야 한다.
위 그림은 인증에 관여하는 다섯 구성 요소가 각각 무엇을 하고 어디까지 하지 않는지를 표로 정리합니다. 토큰은 localStorage에 저장되므로 메모리에만 두는 방식이 아니고, 공유 axios 인스턴스는 그 토큰을 Bearer 헤더에 붙일 뿐 응답 형식을 검증하지는 않습니다. 그림에는 baseURL이 localhost로 고정되어 있다고 적혀 있지만, 아래 코드는 VITE_API_URL이 있으면 그 값을 쓰고 없을 때만 http://localhost:3000을 씁니다. 앱을 처음 열 때는 토큰이 있으면 프로필을 조회하고, 조회에 실패하면 토큰과 인증 상태를 지웁니다.
그림의 로그인 이후 줄은 토큰을 저장하자마자 인증 상태를 참으로 바꾸고 프로필을 나중에 조회하는 방식의 문제를 보여 줍니다. 이렇게 하면 프로필 조회가 실패해도 인증된 상태가 남을 수 있습니다. 아래 코드는 프로필 조회가 성공한 뒤에만 사용자를 설정해 이 문제를 피합니다. 마지막 줄처럼 보호 라우트는 화면을 고를 뿐이며, API의 권한 검사는 언제나 서버가 합니다.
프로젝트 만들기
Vite로 React와 TypeScript 프로젝트를 만들고 필요한 라이브러리를 설치합니다. CLI가 바로 의존성 설치와 실행까지 진행할지 물으면 아니요를 고르고, 아래 명령으로 직접 설치합니다.
npm create vite@latest collaborative-code-editor-frontend -- --template react-ts
cd collaborative-code-editor-frontend
npm install
npm install axios react-router socket.io-client monaco-editor @monaco-editor/reactaxios: Promise 기반 HTTP 클라이언트로, 요청과 응답에 interceptor를 붙일 수 있습니다.react-router: 화면 라우팅을 담당합니다. 버전 7부터react-router-dom의 기능이 이 패키지로 합쳐졌으며, 버전 8에서는react-router만 설치합니다.socket.io-client: 백엔드의 Socket.IO Gateway에 연결하는 클라이언트입니다. 에디터의 협업 모드에서만 씁니다.monaco-editor,@monaco-editor/react: Visual Studio Code의 에디터 엔진과 그 React 컴포넌트입니다.
Vite의 React 템플릿은 TypeScript 7을 씁니다. TypeScript 6부터 strict가 기본으로 켜지므로 tsconfig에 따로 적지 않아도 엄격한 타입 검사를 하며, 템플릿이 켜 둔 verbatimModuleSyntax는 타입만 가져올 때 import type이나 type 표시를 요구합니다. 아래 코드는 이 설정에서 컴파일되도록 타입을 명시합니다.
파일 구성
이 절에서 만드는 파일은 다음과 같습니다. src/main.tsx는 템플릿의 파일을 그대로 씁니다.
API 클라이언트
모든 화면이 같은 axios 인스턴스로 백엔드를 호출하도록 한 파일에 모읍니다. 아래 파일은 axios.create()로 만든 인스턴스 api, 요청이 나가기 전에 실행되는 요청 interceptor, 응답이 오류일 때 실행되는 응답 interceptor로 나뉩니다. 화면 코드는 이 api만 가져다 쓰므로, 토큰을 붙이는 코드와 401 처리 코드를 화면마다 반복해 적지 않습니다.
import axios from 'axios';
// 배포할 때는 .env 파일의 VITE_API_URL로 백엔드 주소를 바꿉니다.
export const API_URL: string = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
const api = axios.create({ baseURL: API_URL });
// 요청마다 localStorage의 토큰을 Authorization 헤더에 붙입니다.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 로그인 요청이 아닌 요청이 401을 받으면 토큰을 지우고 로그인 화면으로 보냅니다.
api.interceptors.response.use(
(response) => response,
(error: unknown) => {
if (
axios.isAxiosError(error) &&
error.response?.status === 401 &&
error.config?.url !== '/auth/login'
) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
return Promise.reject(error);
},
);
// Nest의 오류 응답은 message가 문자열이거나, DTO 검증 오류일 때 문자열 배열입니다.
export function getErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError<{ message?: string | string[] }>(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (message) return message;
}
return fallback;
}
export default api;API_URL은 Vite의 환경 변수 VITE_API_URL이 있으면 그 값을, 없으면 개발용 주소를 씁니다. Vite는 VITE_로 시작하는 변수만 브라우저 코드에 노출하므로, 비밀 값은 이런 변수에 넣지 않습니다. 에디터 글의 Socket.IO 연결도 이 주소를 함께 씁니다.
응답 interceptor는 토큰이 만료되었거나 잘못된 경우를 처리합니다. 다만 로그인 요청은 비밀번호가 틀려도 401을 받으므로 제외합니다. 이 조건이 없으면 로그인에 실패할 때마다 페이지가 /login으로 새로 열려, 화면에 오류 메시지를 보여 주기 전에 사라집니다. 앞 절에서 다른 사용자의 리소스에 404를 돌려주도록 한 것도 이 interceptor와 관련이 있습니다. 권한이 없다는 이유로 401을 받으면 로그인한 사용자도 로그아웃되기 때문입니다.
getErrorMessage()는 Nest가 돌려준 오류 본문에서 사람이 읽을 메시지를 꺼냅니다. ValidationPipe의 검증 오류는 message가 배열이고, ConflictException 같은 예외는 문자열이므로 두 경우를 모두 처리합니다.
인증 상태
로그인한 사용자 정보를 React context로 앱 전체에 제공합니다. context로 내보내는 값은 AuthContextValue에 정의한 다섯 가지입니다. 코드에서는 앱이 처음 열릴 때 useEffect가 저장된 토큰으로 프로필을 확인하는 부분과, login()이 토큰을 저장한 뒤 프로필 조회까지 성공해야 사용자를 설정하는 부분을 봅니다. 맨 아래의 useAuth()는 AuthProvider 바깥에서 부르면 오류를 던지므로, provider로 감싸는 일을 빠뜨리면 첫 렌더링에서 바로 알 수 있습니다.
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import api from '../api/axiosInstance';
export interface UserProfile {
id: string;
email: string;
nickname: string | null;
createdAt: string;
updatedAt: string;
}
interface AuthContextValue {
user: UserProfile | null;
isAuthenticated: boolean;
loading: boolean;
login: (token: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserProfile | null>(null);
// 저장된 토큰이 있으면 프로필을 확인할 때까지 로딩 상태로 시작합니다.
const [loading, setLoading] = useState(() => localStorage.getItem('accessToken') !== null);
useEffect(() => {
if (!localStorage.getItem('accessToken')) return;
api
.get<UserProfile>('/auth/profile')
.then(({ data }) => setUser(data))
.catch(() => {
localStorage.removeItem('accessToken');
setUser(null);
})
.finally(() => setLoading(false));
}, []);
const login = async (token: string) => {
localStorage.setItem('accessToken', token);
try {
// 프로필 조회가 성공한 뒤에만 로그인 상태가 됩니다.
const { data } = await api.get<UserProfile>('/auth/profile');
setUser(data);
} catch (error) {
localStorage.removeItem('accessToken');
throw error;
}
};
const logout = () => {
localStorage.removeItem('accessToken');
setUser(null);
};
return (
<AuthContext value={{ user, isAuthenticated: user !== null, loading, login, logout }}>
{children}
</AuthContext>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}UserProfile은 백엔드의 프로필 API가 password를 뺀 뒤 돌려주는 필드와 같습니다. 날짜는 JSON으로 오므로 Date가 아니라 문자열입니다. 인증 여부는 따로 상태를 두지 않고 user !== null로 계산해, 사용자 정보와 인증 여부가 서로 어긋날 수 없게 했습니다.
loading의 초기값은 저장된 토큰이 있는지로 정합니다. 토큰이 있으면 프로필 확인이 끝날 때까지 보호된 화면을 보여 주지 않고, 토큰이 없으면 확인할 것이 없으므로 바로 false로 시작합니다. React 19에서는 <AuthContext.Provider> 대신 <AuthContext value={...}>처럼 context를 바로 provider로 쓸 수 있습니다.
개발 모드에서는 템플릿의 StrictMode가 effect를 한 번 더 실행하므로, Network 탭에 프로필 요청이 두 번 보일 수 있습니다. 이 요청은 조회만 하므로 두 번 실행되어도 결과가 같습니다.
라우팅
import type { ReactNode } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router';
import { AuthProvider, useAuth } from './context/AuthContext';
import EditorPage from './pages/EditorPage';
import LoginPage from './pages/LoginPage';
import ProjectsPage from './pages/ProjectsPage';
import RegisterPage from './pages/RegisterPage';
// 로그인하지 않았으면 로그인 화면으로 보냅니다.
function PrivateRoute({ children }: { children: ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) return <p>Loading...</p>;
return isAuthenticated ? children : <Navigate to="/login" replace />;
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<PrivateRoute><ProjectsPage /></PrivateRoute>} />
<Route
path="/project/:projectId"
element={<PrivateRoute><EditorPage /></PrivateRoute>}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}PrivateRoute는 프로필을 확인하는 동안 로딩 문구를 보여 주고, 확인이 끝난 뒤 로그인하지 않았으면 <Navigate replace>로 로그인 화면으로 보냅니다. replace를 주면 방문 기록에 보호된 주소가 남지 않아, 뒤로 가기를 눌러도 같은 이동이 반복되지 않습니다. ProjectsPage와 EditorPage는 다음 두 글에서 만듭니다.
로그인과 회원 가입 화면
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import api, { getErrorMessage } from '../api/axiosInstance';
import { useAuth } from '../context/AuthContext';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const submit = async () => {
setSubmitting(true);
setError('');
try {
const { data } = await api.post<{ accessToken: string }>('/auth/login', { email, password });
await login(data.accessToken);
await navigate('/');
} catch (err) {
setError(getErrorMessage(err, 'Login failed'));
} finally {
setSubmitting(false);
}
};
return (
<main>
<h2>Login</h2>
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<label>
Email
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<button type="submit" disabled={submitting}>Login</button>
</form>
{error && <p role="alert">{error}</p>}
<p>
No account yet? <Link to="/register">Register here</Link>
</p>
</main>
);
}import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import api, { getErrorMessage } from '../api/axiosInstance';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [nickname, setNickname] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();
const submit = async () => {
setSubmitting(true);
setError('');
try {
// 닉네임은 선택 값이므로 비워 두면 보내지 않습니다.
await api.post('/auth/register', { email, password, ...(nickname ? { nickname } : {}) });
await navigate('/login');
} catch (err) {
setError(getErrorMessage(err, 'Registration failed'));
} finally {
setSubmitting(false);
}
};
return (
<main>
<h2>Register</h2>
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<label>
Email
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={6}
required
/>
</label>
<label>
Nickname
<input value={nickname} onChange={(event) => setNickname(event.target.value)} />
</label>
<button type="submit" disabled={submitting}>Register</button>
</form>
{error && <p role="alert">{error}</p>}
<p>
Already registered? <Link to="/login">Log in</Link>
</p>
</main>
);
}두 화면은 같은 구조입니다. 제출하면 버튼을 잠그고 요청을 보내며, 실패하면 서버의 메시지를 보여 주고, 끝나면 버튼을 다시 엽니다. 버튼을 잠그면 응답을 기다리는 동안 같은 요청을 두 번 보내지 않습니다. 이벤트 처리기를 JSX 안에 직접 쓰면 event의 타입을 TypeScript가 추론하므로, 이벤트 타입 이름을 따로 가져오지 않아도 됩니다.
입력의 type="email", minLength={6}, required는 사용자가 실수를 빨리 알아차리게 하는 브라우저 검사일 뿐입니다. 개발자 도구로 요청을 직접 보내면 우회할 수 있으므로, 실제 규칙은 서버의 RegisterUserDto가 검사합니다. 같은 이메일로 다시 가입하면 서버의 409 Conflict 메시지인 Email already registered가 화면에 표시됩니다.
정리
- 공유 axios 인스턴스가 요청마다
localStorage의 토큰을 Bearer 헤더로 붙이고, 로그인 요청이 아닌 요청의401에서 토큰을 지우고 로그인 화면으로 보냅니다. - 로그인 요청의
401은 비밀번호 오류이므로 interceptor에서 제외해야 화면에 오류 메시지를 보여 줄 수 있습니다. AuthProvider는 프로필 조회가 성공한 뒤에만 사용자를 설정하고, 인증 여부는 사용자 정보에서 계산합니다.PrivateRoute는 프로필 확인이 끝날 때까지 기다린 뒤 화면을 고르며, API 권한 검사는 서버가 맡습니다.- React Router 8은
react-router패키지 하나로BrowserRouter,Routes,Navigate,Link를 제공합니다. - 브라우저의 입력 검사는 사용자 편의를 위한 것이며, 실제 규칙은 서버 DTO가 검사합니다.
연습 문제
1. 틀린 비밀번호로 로그인
틀린 비밀번호로 로그인하면 서버는 401과 Invalid credentials를 돌려줍니다. 지금 코드의 화면에는 무엇이 보일까요? interceptor에서 error.config?.url !== '/auth/login' 조건을 지우면 어떻게 될까요?
해설 보기
interceptor가 로그인 요청을 제외하므로 오류가 submit()의 catch로 전달되고, 화면에 Invalid credentials가 표시됩니다.
조건을 지우면 interceptor가 먼저 window.location.href = '/login'을 실행합니다. 페이지가 새로 열리면서 React 상태가 모두 초기화되므로, 사용자는 오류 메시지 없이 비어 있는 로그인 화면만 보게 됩니다.
2. 사용 중에 만료된 토큰
프로젝트 목록을 보는 동안 토큰의 유효 시간 1시간이 지났습니다. 이 상태에서 새 프로젝트를 만들면 어떤 순서로 무엇이 일어날까요?
해설 보기
POST /projects 요청에 만료된 토큰이 붙어 가므로 서버의 JWT guard가 401을 돌려줍니다. 로그인 요청이 아니므로 응답 interceptor가 토큰을 지우고 /login으로 페이지를 새로 엽니다. 프로젝트는 만들어지지 않습니다.
새로 열린 페이지에서는 토큰이 없으므로 AuthProvider가 프로필을 조회하지 않고 바로 로그인 화면을 보여 줍니다. 입력해 둔 프로젝트 이름은 페이지를 새로 열면서 사라지므로, 입력을 보존하려면 이동하기 전에 저장하는 처리를 따로 넣어야 합니다.
3. 직접 넣은 가짜 토큰
개발자 도구에서 localStorage.setItem('accessToken', 'abc')를 실행한 뒤 /를 새로 열었습니다. 보호된 프로젝트 화면이 나타날까요? 이 결과는 API 보안과 어떤 관계가 있을까요?
해설 보기
나타나지 않습니다. 토큰이 있으므로 로딩 상태로 시작해 프로필을 조회하고, 서버가 abc를 거부해 401을 돌려줍니다. interceptor가 토큰을 지우고 로그인 화면으로 보냅니다.
설령 화면 코드를 고쳐 PrivateRoute를 통과하게 만들어도, 그 화면이 보내는 API 요청은 모두 서버에서 거부됩니다. 보호 라우트는 보여 줄 화면을 고르는 장치일 뿐이고, 데이터 보호는 서버의 guard와 소유 확인이 맡습니다.
다음 글 프로젝트 목록 UI에서는 로그인한 사용자의 프로젝트를 조회하고 만들고 지우는 화면을 구현합니다.