프론트엔드 통합 (React, Angular)
React 인증 상태와 라우팅을 구성하고 프로젝트·파일 UI를 REST API 및 WebSocket 실시간 편집 흐름에 연결합니다.
지난 절에서는 온라인 코드 에디터 및 실시간 협업 도구의 NestJS 백엔드 API, 즉 사용자 인증과 프로젝트/파일 관리 기능을 구현했습니다.
이번 절에서는 구현된 백엔드 API를 실제로 활용할 수 있도록 프론트엔드 애플리케이션을 구축하고 통합하는 과정을 다룹니다.
사용자 경험의 핵심은 프론트엔드에 달려 있습니다.
우리는 React를 사용하여 직관적인 UI를 만들고, 백엔드와 HTTP 통신 및 WebSocket을 통한 실시간 통신을 연동할 것입니다.
ARCHITECTURE · CLIENT SERVER
native fetch의 unknown 응답을 Zod가 화면 상태로 승격한다
React 화면은 URL·헤더·응답 형식을 직접 조립하지 않는다. 주입된 API client가 bearer를 붙이고 서버 결과를 검증한 뒤 typed value만 돌려준다.
- React View
loading·empty·ready·error 상태와 사용자 입력만 관리합니다.
- API Client
기준 URL을 주입받고 bearer 헤더를 한곳에서 붙입니다.
- Nest HTTP
DTO와 JWT guard가 요청을 검증합니다.
- Workspace
검증된 principal로 자원 권한을 확인합니다.
- TypeORM DataSource
one root로 Workspace 영속성 경계를 공유합니다.
- Zod 응답
unknown JSON이 schema를 통과해야 View 상태가 됩니다.
화살표는 신뢰 또는 상태가 다음 경계로 이동하는 방향을 뜻합니다.
프론트엔드 프로젝트 초기 설정 (React)
먼저 React 프로젝트를 생성하고 필요한 라이브러리를 설치합니다.
# Vite를 사용하여 React 프로젝트 생성 (빠르고 가볍습니다)
npm create vite collaborative-code-editor-frontend -- --template react-ts
# 프로젝트 디렉토리로 이동
cd collaborative-code-editor-frontend
# 필요한 라이브러리 설치
npm install axios react-router-dom socket.io-client monaco-editor @monaco-editor/reactaxios: HTTP 요청을 보내기 위한 Promise 기반 HTTP 클라이언트입니다.react-router-dom: React 애플리케이션에서 라우팅을 관리합니다.socket.io-client: NestJS 백엔드의 Socket.IO 서버와 통신하기 위한 클라이언트 라이브러리입니다.monaco-editor,@monaco-editor/react: Visual Studio Code의 핵심 엔진으로, 웹 기반 코드 에디터 기능을 제공합니다.
프로젝트 구조 및 컴포넌트 설계
프론트엔드 애플리케이션의 구조는 다음과 같이 구성할 수 있습니다.
인증 및 라우팅 구현
사용자 인증(로그인, 회원가입) 및 보호된 라우트(Protected Routes) 설정은 모든 웹 애플리케이션의 기본입니다.
API 클라이언트 설정
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000', // NestJS 백엔드 주소
headers: {
'Content-Type': 'application/json',
},
});
// 요청 인터셉터: 로컬 스토리지에서 JWT 토큰을 가져와 Authorization 헤더에 추가
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 응답 인터셉터: 401 Unauthorized 응답 시 로그인 페이지로 리다이렉트
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// 토큰 만료 또는 유효하지 않은 토큰일 경우
localStorage.removeItem('accessToken');
window.location.href = '/login'; // 로그인 페이지로 리다이렉트
}
return Promise.reject(error);
}
);
export default api;인증 상태 관리
AuthContext, localStorage, axios 인터셉터가 어떻게 연결되어 인증 상태를 유지/복구하는지 먼저 흐름으로 정리해보겠습니다.
import React, { createContext, useState, useEffect, useContext } from 'react';
import api from '../api/axiosInstance';
interface AuthContextType {
isAuthenticated: boolean;
user: any | null; // 실제 User 타입으로 대체
login: (token: string) => void;
logout: () => void;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [user, setUser] = useState<any | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const checkAuth = async () => {
const token = localStorage.getItem('accessToken');
if (token) {
try {
const response = await api.get('/auth/profile'); // 사용자 프로필 조회 API 호출
setUser(response.data);
setIsAuthenticated(true);
} catch (error) {
console.error('Failed to fetch user profile:', error);
localStorage.removeItem('accessToken');
setIsAuthenticated(false);
setUser(null);
}
}
setLoading(false);
};
checkAuth();
}, []);
const login = (token: string) => {
localStorage.setItem('accessToken', token);
setIsAuthenticated(true);
// 로그인 후 사용자 프로필 다시 로드
api.get('/auth/profile').then(res => setUser(res.data)).catch(console.error);
};
const logout = () => {
localStorage.removeItem('accessToken');
setIsAuthenticated(false);
setUser(null);
};
return (
<AuthContext.Provider value={{ isAuthenticated, user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};라우팅 설정
// src/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import ProjectsPage from './pages/ProjectsPage';
import EditorPage from './pages/EditorPage'; // 에디터 페이지
// 보호된 라우트를 위한 컴포넌트
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div>Loading...</div>; // 로딩 중 표시
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
};
function App() {
return (
<Router>
<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>
</Router>
);
}
export default App;인증 페이지
각 페이지에서 axiosInstance를 사용하여 백엔드 API를 호출하고 useAuth 훅을 사용하여 인증 상태를 업데이트합니다.
import React, { useState } from 'react';
import api from '../api/axiosInstance';
import { useAuth } from '../context/AuthContext';
import { useNavigate, Link } from 'react-router-dom';
const LoginPage: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await api.post('/auth/login', { email, password });
login(response.data.accessToken);
navigate('/'); // 로그인 성공 시 프로젝트 페이지로 이동
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
}
};
return (
<div>
<h2>Login</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div>
<label>Password:</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
<button type="submit">Login</button>
</form>
{error && <p style={{ color: 'red' }}>{error}</p>}
<p>Don't have an account? <Link to="/register">Register here</Link></p>
</div>
);
};
export default LoginPage;RegisterPage.tsx도 유사하게 구현합니다.