코드 에디터와 파일 트리
코드 에디터와 파일·폴더 트리를 구성하고 WebSocket 편집 흐름을 연결합니다.
코드 에디터 및 파일/폴더 트리
이 페이지는 핵심 로직을 담고 있으며, 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;에디터 페이지에서는 코드 입력이 두 방향으로 나뉩니다.
즉시 전파가 필요한 변경은 WebSocket 이벤트로 보내고, 저장은 디바운스된 REST API 요청으로 처리합니다.
프론트엔드 실행 및 테스트
프론트엔드 프로젝트를 실행합니다.
npm run dev브라우저에서 http://localhost:5173 (기본 Vite 포트)에 접속하여 테스트합니다.
회원가입 페이지 (/register)에서 새 계정 생성.
로그인 페이지 (/login)에서 로그인.
프로젝트 목록 페이지 (/)에서 새 프로젝트 생성.
생성된 프로젝트 클릭하여 에디터 페이지로 이동.
에디터 페이지에서 새 파일 생성 (백엔드 API 호출).
파일 선택 후 코드 에디터에서 내용 편집 (자동 저장 확인).
이번 절에서는 React를 사용하여 온라인 코드 에디터 및 실시간 협업 도구의 프론트엔드를 구축하고, NestJS 백엔드 API와 기본적인 HTTP 통신을 통합하는 과정을 살펴보았습니다.
특히, 사용자 인증 및 보호된 라우트 구현, 그리고 Monaco Editor와 Socket.IO 클라이언트의 초기 설정까지 다루었습니다.
프론트엔드 완성 후에는 인증부터 자동 저장까지 하나의 사용자 흐름으로 이어지는지 다음 다이어그램처럼 점검해볼 수 있습니다.
특히 인증 상태, 보호 라우트, 프로젝트 트리, 편집 저장 흐름은 같은 API 계약을 공유하므로 상태 전이를 한 장의 흐름으로 정리해두면 구현 순서가 선명해집니다.
프론트엔드 프로젝트 초기 설정 (React)와 프로젝트 구조 및 컴포넌트 설계 중심으로 정리한 보조 다이어그램입니다.