본문으로 건너뛰기

안동민 개발노트

본문 시작

프론트엔드 통합 (React, Angular)

React 인증 상태와 라우팅을 구성하고 프로젝트·파일 UI를 REST API 및 WebSocket 실시간 편집 흐름에 연결합니다.

지난 절에서는 온라인 코드 에디터 및 실시간 협업 도구의 NestJS 백엔드 API, 즉 사용자 인증과 프로젝트/파일 관리 기능을 구현했습니다.

이번 절에서는 구현된 백엔드 API를 실제로 활용할 수 있도록 프론트엔드 애플리케이션을 구축하고 통합하는 과정을 다룹니다.

사용자 경험의 핵심은 프론트엔드에 달려 있습니다.

우리는 React를 사용하여 직관적인 UI를 만들고, 백엔드와 HTTP 통신 및 WebSocket을 통한 실시간 통신을 연동할 것입니다.

React와 NestJS 사이의 검증된 클라이언트 서버 경계

ARCHITECTURE · CLIENT SERVER

native fetch의 unknown 응답을 Zod가 화면 상태로 승격한다

React 화면은 URL·헤더·응답 형식을 직접 조립하지 않는다. 주입된 API client가 bearer를 붙이고 서버 결과를 검증한 뒤 typed value만 돌려준다.

React와 NestJS 사이의 검증된 클라이언트 서버 경계 React View가 주입된 API Client를 통해 Nest HTTP 경계와 Workspace Service, DataSource로 요청을 보내고, Nest 응답은 API Client의 Zod schema를 통과한 typed value만 View에 전달하는 구조다. REACT 19 View + reducer four states NATIVE FETCH API Client injected URL · memory token NEST HTTP DTO + Guard principal DOMAIN Workspace ownership TYPEORM DataSource one root RESPONSE SCHEMA Zod validation unknown → typed
  1. React View

    loading·empty·ready·error 상태와 사용자 입력만 관리합니다.

  2. API Client

    기준 URL을 주입받고 bearer 헤더를 한곳에서 붙입니다.

  3. Nest HTTP

    DTO와 JWT guard가 요청을 검증합니다.

  4. Workspace

    검증된 principal로 자원 권한을 확인합니다.

  5. TypeORM DataSource

    one root로 Workspace 영속성 경계를 공유합니다.

  6. Zod 응답

    unknown JSON이 schema를 통과해야 View 상태가 됩니다.

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


프론트엔드 프로젝트 초기 설정 (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;

인증 상태 관리

AuthContext, localStorage, axios 인터셉터가 어떻게 연결되어 인증 상태를 유지/복구하는지 먼저 흐름으로 정리해보겠습니다.

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
// 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도 유사하게 구현합니다.


이어서 보기