icon
13장 : 실전 프로젝트

프론트엔드 통합 (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의 핵심 엔진으로, 강력한 웹 기반 코드 에디터 기능을 제공합니다.

프로젝트 구조 및 컴포넌트 설계

프론트엔드 애플리케이션의 구조는 다음과 같이 구성할 수 있습니다.

App.tsx # 메인 애플리케이션 컴포넌트
main.tsx # 애플리케이션 진입점
tsconfig.json
vite.config.ts

인증 및 라우팅 구현

사용자 인증(로그인, 회원가입) 및 보호된 라우트(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 클라이언트의 초기 설정까지 다루었습니다.