본문으로 건너뛰기

안동민 개발노트

본문 시작

코드 에디터와 파일 트리

코드 에디터와 파일·폴더 트리를 구성하고 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 요청으로 처리합니다.

baseRevision과 ACK로 직렬화하는 편집 동기화

SEQUENCE · EDITOR SYNC

join → change → atomic update → ACK가 한 쓰기 순서를 만든다

재연결은 최신 snapshot을 다시 받고, 같은 baseRevision의 동시 명령은 하나만 저장된다. 충돌한 클라이언트는 서버 snapshot을 기준으로 사용자에게 병합을 요청한다.

baseRevision과 ACK로 직렬화하는 편집 동기화 소켓 join, 멤버십 확인, 최신 snapshot, baseRevision change, 데이터베이스 원자 갱신, ACK와 broadcast가 이어지고 충돌은 최신 snapshot으로 되돌아간다. 1 · CONNECT join knownRevision 2 · AUTHORIZE Membership principal 3 · RESYNC Snapshot content · revision 4 · COMMAND change baseRevision 5 · COMMIT Atomic update revision + 1 6 · RESULT ACK + broadcast accepted CONFLICT Latest snapshot retry by user
  1. join 또는 reconnect

    projectId, entryId, knownRevision만 보냅니다.

  2. 권한 확인

    서버가 handshake principal과 project membership을 결합합니다.

  3. 최신 snapshot

    현재 content와 revision을 room 참가자에게 줍니다.

  4. change

    클라이언트는 현재 baseRevision과 새 content를 보냅니다.

  5. 원자 갱신

    revision이 일치하는 명령 하나만 저장합니다.

  6. ACK 또는 conflict

    성공은 새 revision, 충돌은 서버 최신본을 반환합니다.

화살표는 신뢰 또는 상태가 다음 경계로 이동하는 방향을 뜻합니다.


프론트엔드 실행 및 테스트

프론트엔드 프로젝트를 실행합니다.

npm run dev

브라우저에서 http://localhost:5173 (기본 Vite 포트)에 접속하여 테스트합니다.

테스트 시나리오

회원가입 페이지 (/register)에서 새 계정 생성.

로그인 페이지 (/login)에서 로그인.

프로젝트 목록 페이지 (/)에서 새 프로젝트 생성.

생성된 프로젝트 클릭하여 에디터 페이지로 이동.

에디터 페이지에서 새 파일 생성 (백엔드 API 호출).

파일 선택 후 코드 에디터에서 내용 편집 (자동 저장 확인).


이번 절에서는 React를 사용하여 온라인 코드 에디터 및 실시간 협업 도구의 프론트엔드를 구축하고, NestJS 백엔드 API와 기본적인 HTTP 통신을 통합하는 과정을 살펴보았습니다.

특히, 사용자 인증 및 보호된 라우트 구현, 그리고 Monaco Editor와 Socket.IO 클라이언트의 초기 설정까지 다루었습니다.

프론트엔드 완성 후에는 인증부터 자동 저장까지 하나의 사용자 흐름으로 이어지는지 다음 다이어그램처럼 점검해볼 수 있습니다.

특히 인증 상태, 보호 라우트, 프로젝트 트리, 편집 저장 흐름은 같은 API 계약을 공유하므로 상태 전이를 한 장의 흐름으로 정리해두면 구현 순서가 선명해집니다.

프론트엔드 프로젝트 초기 설정 (React)와 프로젝트 구조 및 컴포넌트 설계 중심으로 정리한 보조 다이어그램입니다.