프론트엔드 통합 (React, Angular)
React 인증 상태와 라우팅을 구성하고 프로젝트·파일 UI를 REST API 및 WebSocket 실시간 편집 흐름에 연결합니다.
지난 절에서는 온라인 코드 에디터 및 실시간 협업 도구의 NestJS 백엔드 API, 즉 사용자 인증과 프로젝트/파일 관리 기능을 구현했습니다.
이번 절에서는 구현된 백엔드 API를 실제로 활용할 수 있도록 프론트엔드 애플리케이션을 구축하고 통합하는 과정을 다룹니다.
사용자 경험의 핵심은 프론트엔드에 달려 있습니다.
우리는 React를 사용하여 직관적인 UI를 만들고, 백엔드와 HTTP 통신 및 WebSocket을 통한 실시간 통신을 연동할 것입니다.
React나 Next 화면은 사용자 경험을 맡고, NestJS는 API 계약과 서버 정책을 맡는다. 둘 사이는 명확한 데이터 계약으로 연결한다.
- 프론트 앱화면 상태와 사용자 흐름
프론트 앱 화면 상태와 사용자 흐름 Route 페이지 전환과 렌더링 Fetch API 호출과 캐시
- 계약면서로 맞춰야 하는 형식
계약면 서로 맞춰야 하는 형식 Request 헤더, 토큰, 파라미터 Response DTO, 오류 코드, 페이지 크기
- NestJS 서버검증, 권한, 도메인 처리
NestJS 서버 검증, 권한, 도메인 처리 Controller 요청 라우팅 Service 업무 규칙 실행
| 지점 | 프론트 관점 | Nest 관점 |
|---|---|---|
| 데이터 | 필요한 필드와 로딩 상태를 정한다. | DTO와 검증 규칙으로 응답을 고정한다. |
| 인증 | 토큰 저장과 재요청 흐름을 다룬다. | Guard와 세션 검증으로 접근을 제한한다. |
| 배포 | 정적 자산 또는 별도 호스팅을 선택한다. | API 서버와 정적 파일 제공 범위를 분리한다. |
프론트엔드 프로젝트 초기 설정 (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도 유사하게 구현합니다.