본문으로 건너뛰기

안동민 개발노트

본문 시작

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

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

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

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

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

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

NestJS와 프론트엔드 경계

React나 Next 화면은 사용자 경험을 맡고, NestJS는 API 계약과 서버 정책을 맡는다. 둘 사이는 명확한 데이터 계약으로 연결한다.

  1. 프론트 앱
    화면 상태와 사용자 흐름

    프론트 앱 화면 상태와 사용자 흐름 Route 페이지 전환과 렌더링 Fetch API 호출과 캐시

  2. 계약면
    서로 맞춰야 하는 형식

    계약면 서로 맞춰야 하는 형식 Request 헤더, 토큰, 파라미터 Response DTO, 오류 코드, 페이지 크기

  3. NestJS 서버
    검증, 권한, 도메인 처리

    NestJS 서버 검증, 권한, 도메인 처리 Controller 요청 라우팅 Service 업무 규칙 실행

지점프론트 관점Nest 관점
데이터필요한 필드와 로딩 상태를 정한다.DTO와 검증 규칙으로 응답을 고정한다.
인증토큰 저장과 재요청 흐름을 다룬다.Guard와 세션 검증으로 접근을 제한한다.
배포정적 자산 또는 별도 호스팅을 선택한다.API 서버와 정적 파일 제공 범위를 분리한다.

프론트엔드 프로젝트 초기 설정 (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도 유사하게 구현합니다.


이어서 보기