본문으로 건너뛰기

안동민 개발노트

본문 시작
13장 : 실전 프로젝트프론트엔드 통합 (React, Angular)

코드 에디터와 파일 트리

코드 에디터와 파일·폴더 트리를 구성하고 WebSocket 편집 흐름을 연결합니다.

코드 에디터 및 파일/폴더 트리

이 페이지는 핵심 로직을 담고 있으며, 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);
  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)와 프로젝트 구조 및 컴포넌트 설계 중심으로 정리한 보조 다이어그램입니다.