프론트엔드 통합 (React, Angular)
지난 절에서는 온라인 코드 에디터 및 실시간 협업 도구의 NestJS 백엔드 API, 즉 사용자 인증과 프로젝트/파일 관리 기능을 구현했습니다. 이번 절에서는 구현된 백엔드 API를 실제로 활용할 수 있도록 프론트엔드 애플리케이션을 구축하고 통합하는 과정을 다룹니다.
사용자 경험의 핵심은 프론트엔드에 달려 있습니다. 우리는 React를 사용하여 직관적인 UI를 만들고, 백엔드와 HTTP 통신 및 WebSocket을 통한 실시간 통신을 연동할 것입니다.
프론트엔드 프로젝트 초기 설정 (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도 유사하게 구현합니다.
프로젝트 및 파일 관리 UI 구현
이제 로그인 후 접근할 수 있는 프로젝트 목록 페이지와 에디터 페이지를 구현합니다.
프로젝트 목록
import React, { useEffect, useState } from 'react';
import api from '../api/axiosInstance';
import { useAuth } from '../context/AuthContext';
import { Link, useNavigate } from 'react-router-dom';
interface Project {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
const ProjectsPage: React.FC = () => {
const { user, logout } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [newProjectName, setNewProjectName] = useState('');
const navigate = useNavigate();
useEffect(() => {
const fetchProjects = async () => {
try {
const response = await api.get('/projects');
setProjects(response.data);
} catch (error) {
console.error('Failed to fetch projects:', error);
}
};
fetchProjects();
}, []);
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault();
if (!newProjectName.trim()) return;
try {
const response = await api.post('/projects', { name: newProjectName });
setProjects([...projects, response.data]);
setNewProjectName('');
navigate(`/project/${response.data.id}`); // 생성 후 바로 에디터 페이지로 이동
} catch (error) {
console.error('Failed to create project:', error);
}
};
const handleDeleteProject = async (projectId: string) => {
if (!window.confirm('Are you sure you want to delete this project?')) return;
try {
await api.delete(`/projects/${projectId}`);
setProjects(projects.filter(p => p.id !== projectId));
} catch (error) {
console.error('Failed to delete project:', error);
}
};
return (
<div>
<h2>Welcome, {user?.nickname || user?.email}!</h2>
<button onClick={logout}>Logout</button>
<h3>Create New Project</h3>
<form onSubmit={handleCreateProject}>
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project Name"
required
/>
<button type="submit">Create</button>
</form>
<h3>Your Projects</h3>
{projects.length === 0 ? (
<p>No projects yet. Create one!</p>
) : (
<ul>
{projects.map((project) => (
<li key={project.id}>
<Link to={`/project/${project.id}`}>{project.name}</Link> ({new Date(project.updatedAt).toLocaleDateString()})
<button onClick={() => handleDeleteProject(project.id)} style={{ marginLeft: '10px' }}>Delete</button>
</li>
))}
</ul>
)}
</div>
);
};
export default ProjectsPage;코드 에디터 및 파일/폴더 트리
이 페이지는 핵심 로직을 담고 있으며, Monaco Editor 통합과 WebSocket 통신을 담당합니다.
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import Editor from '@monaco-editor/react'; // Monaco Editor 컴포넌트
import api from '../api/axiosInstance';
import { useAuth } from '../context/AuthContext';
// WebSocket 연결 (기본 실시간 동기화 + 저장 실패 복구 로직 포함)
import { io, Socket } from 'socket.io-client';
interface ProjectDetails {
id: string;
name: string;
files: Array<{ id: string; name: string; path: string; content: string; type: string }>;
folders: Array<{ id: string; name: string; path: string }>;
}
const EditorPage: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const { user } = useAuth();
const [project, setProject] = useState<ProjectDetails | null>(null);
const [selectedFile, setSelectedFile] = useState<any | null>(null); // 현재 선택된 파일
const [fileContent, setFileContent] = useState<string>(''); // 에디터에 표시될 파일 내용
const editorRef = useRef(null); // Monaco Editor 인스턴스 참조
// WebSocket 관련 상태
const socketRef = useRef<Socket | null>(null);
const saveTimerRef = useRef<number | null>(null);
useEffect(() => {
// 프로젝트 상세 정보 불러오기
const fetchProjectDetails = async () => {
try {
const response = await api.get(`/projects/${projectId}`);
setProject(response.data);
// 기본적으로 첫 번째 파일을 선택
if (response.data.files && response.data.files.length > 0) {
setSelectedFile(response.data.files[0]);
setFileContent(response.data.files[0].content);
}
} catch (error) {
console.error('Failed to fetch project details:', error);
// 오류 처리: 프로젝트가 없거나 권한이 없는 경우 리다이렉트
}
};
fetchProjectDetails();
// WebSocket 연결 설정 (페이지 로드 시)
const socket = io('http://localhost:3000', {
auth: { token: localStorage.getItem('accessToken') }, // JWT 토큰을 인증에 사용
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('WebSocket connected:', socket.id);
// 연결 후 특정 파일의 협업 세션에 조인
if (selectedFile) { // 선택된 파일이 있을 경우 (초기 로드 시 바로 조인)
socket.emit('join_file', { fileId: selectedFile.id, userId: user?.id });
}
});
socket.on('disconnect', () => {
console.log('WebSocket disconnected');
});
// 서버로부터의 실시간 코드 변경 이벤트 수신 (다음 절에서 자세히 구현)
socket.on('code_update', (data) => {
console.log('Received code_update:', data);
// 여기에 실제 에디터에 변경 사항을 적용하는 로직 추가
// (예: Monaco Editor의 applyEdits 사용)
});
return () => {
if (socketRef.current) {
socketRef.current.disconnect(); // 컴포넌트 언마운트 시 WebSocket 연결 해제
}
};
}, [projectId, selectedFile?.id, user?.id]); // selectedFile.id가 변경될 때마다 join_file 다시 호출
// Monaco Editor 로드 완료 시
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor;
};
// 코드 변경 시 (debounced 저장 또는 실시간 동기화)
const handleEditorChange = useCallback((value: string | undefined) => {
if (value === undefined) return;
setFileContent(value); // UI 상태 업데이트
if (!selectedFile) return;
// 1) 실시간 협업 이벤트 전송: 서버 ACK 기반으로 실패를 감지
if (socketRef.current?.connected) {
socketRef.current.emit(
'code_change',
{
fileId: selectedFile.id,
userId: user?.id,
changes: value, // 실무에서는 전체 텍스트 대신 OT/CRDT delta 권장
},
(ack: { ok: boolean; message?: string }) => {
if (!ack?.ok) {
console.warn('Realtime sync rejected by server:', ack?.message);
}
},
);
}
// 2) 자동 저장: 과도한 API 호출 방지를 위해 디바운스 적용
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(async () => {
try {
await api.patch(`/projects/${projectId}/files/${selectedFile.id}/content`, { content: value });
} catch (error) {
// 저장 실패 시 사용자에게 재시도 가능 상태를 알려야 함 (토스트/배지 등)
console.error('Failed to save file content:', error);
}
}, 800);
}, [projectId, selectedFile, user?.id]);
// 파일 클릭 시 내용 로드
const handleFileSelect = (file: any) => {
setSelectedFile(file);
setFileContent(file.content); // 백엔드에서 받은 초기 내용으로 설정
// 파일 변경 시 WebSocket에서 이전 파일 세션 leave, 새 파일 세션 join 필요
if (socketRef.current) {
socketRef.current.emit('leave_file', { fileId: selectedFile?.id, userId: user?.id }); // 이전 파일 이탈
socketRef.current.emit('join_file', { fileId: file.id, userId: user?.id }); // 새 파일 조인
}
};
// 파일/폴더 트리 관리 (핵심 흐름)
const refreshProjectTree = async () => {
const response = await api.get(`/projects/${projectId}`);
setProject(response.data);
};
const handleCreateFile = async (path: string, name: string) => {
try {
await api.post(`/projects/${projectId}/files`, { path, name, type: 'typescript' });
await refreshProjectTree();
} catch (error) {
console.error('Failed to create file:', error); // 생성 실패 시 이름 충돌/권한 오류를 UI에 노출
}
};
const handleDeleteNode = async (nodeType: 'file' | 'folder', nodeId: string) => {
try {
const endpoint = nodeType === 'file'
? `/projects/${projectId}/files/${nodeId}`
: `/projects/${projectId}/folders/${nodeId}`;
await api.delete(endpoint);
await refreshProjectTree();
} catch (error) {
console.error('Failed to delete node:', error); // 서버 에러 시 트리 재조회로 상태 불일치 복구
}
};
if (!project) {
return <div>Loading project...</div>;
}
return (
<div>
<h2>Project: {project.name}</h2>
<div style={{ display: 'flex', height: '80vh' }}>
{/* 파일/폴더 트리 영역 */}
<div style={{ width: '20%', borderRight: '1px solid #ccc', padding: '10px' }}>
<h3>Files & Folders</h3>
{/* 간단한 파일 목록 (실제는 트리 형태로 구현) */}
<ul>
{project.files.map(file => (
<li key={file.id} onClick={() => handleFileSelect(file)} style={{ cursor: 'pointer', fontWeight: selectedFile?.id === file.id ? 'bold' : 'normal' }}>
📄 {file.name}
</li>
))}
</ul>
{/* 새 파일/폴더 추가 버튼 등 */}
<button onClick={() => alert('New File/Folder functionality')}>New File/Folder</button>
</div>
{/* 코드 에디터 영역 */}
<div style={{ flex: 1 }}>
{selectedFile ? (
<div>
<h3>Editing: {selectedFile.path}/{selectedFile.name}</h3>
<Editor
height="70vh"
language={selectedFile.type || 'plaintext'} // 파일 타입에 따라 언어 설정
theme="vs-dark" // 에디터 테마
value={fileContent}
onMount={handleEditorDidMount}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
fontSize: 14,
}}
/>
</div>
) : (
<p>Select a file to start editing.</p>
)}
</div>
</div>
</div>
);
};
export default EditorPage;프론트엔드 실행 및 테스트
프론트엔드 프로젝트를 실행합니다.
npm run dev브라우저에서 http://localhost:5173 (기본 Vite 포트)에 접속하여 테스트합니다.
회원가입 페이지 (/register)에서 새 계정 생성.
로그인 페이지 (/login)에서 로그인.
프로젝트 목록 페이지 (/)에서 새 프로젝트 생성.
생성된 프로젝트 클릭하여 에디터 페이지로 이동.
에디터 페이지에서 새 파일 생성 (백엔드 API 호출).
파일 선택 후 코드 에디터에서 내용 편집 (자동 저장 확인).
이번 절에서는 React를 사용하여 온라인 코드 에디터 및 실시간 협업 도구의 프론트엔드를 구축하고, NestJS 백엔드 API와 기본적인 HTTP 통신을 통합하는 과정을 살펴보았습니다. 특히, 사용자 인증 및 보호된 라우트 구현, 그리고 Monaco Editor와 Socket.IO 클라이언트의 초기 설정까지 다루었습니다.