프론트엔드 통합 (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/react
주요 라이브러리 설명
axios
: HTTP 요청을 보내기 위한 Promise 기반 HTTP 클라이언트입니다.react-router-dom
: React 애플리케이션에서 라우팅을 관리합니다.socket.io-client
: NestJS 백엔드의 Socket.IO 서버와 통신하기 위한 클라이언트 라이브러리입니다.monaco-editor
,@monaco-editor/react
: Visual Studio Code의 핵심 엔진으로, 강력한 웹 기반 코드 에디터 기능을 제공합니다.
프로젝트 구조 및 컴포넌트 설계
프론트엔드 애플리케이션의 구조는 다음과 같이 구성할 수 있습니다.
인증 및 라우팅 구현
사용자 인증(로그인, 회원가입) 및 보호된 라우트(Protected Routes) 설정은 모든 웹 애플리케이션의 기본입니다.
API 클라이언트 설정
// src/api/axiosInstance.ts
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;
인증 상태 관리
// src/context/AuthContext.tsx
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
훅을 사용하여 인증 상태를 업데이트합니다.
// src/pages/LoginPage.tsx (간략화)
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 구현
이제 로그인 후 접근할 수 있는 프로젝트 목록 페이지와 에디터 페이지를 구현합니다.
프로젝트 목록
// src/pages/ProjectsPage.tsx (간략화)
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 통신을 담당합니다.
// src/pages/EditorPage.tsx (핵심 로직 위주로 간략화)
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);
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 상태 업데이트
// TODO: 실시간 협업 로직 (다음 절에서 구현)
// 현재는 간단히 변경 즉시 백엔드에 저장 (또는 debounced 저장)
if (selectedFile) {
// 이 부분은 실시간 협업보다는 자동 저장에 가깝습니다.
// 실시간 협업을 위해서는 Socket.IO를 통해 변경 델타(delta)를 보내야 합니다.
api.patch(`/projects/${projectId}/files/${selectedFile.id}/content`, { content: value })
.catch(error => console.error('Failed to save file content:', error));
// Socket.IO를 통한 실시간 변경 전송 (임시 로직)
if (socketRef.current) {
socketRef.current.emit('code_change', {
fileId: selectedFile.id,
userId: user?.id,
changes: value // 실제로는 Operational Transformation(OT) delta를 보내야 함
});
}
}
}, [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 }); // 새 파일 조인
}
};
// TODO: 파일/폴더 트리 UI 렌더링, 새 파일/폴더 생성/삭제 로직 추가
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 클라이언트의 초기 설정까지 다루었습니다.