안동민 개발노트

본문 시작

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

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

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

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

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

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

원문 인증 상태와 HTTP 요청의 경계

원문 인증 상태와 HTTP 요청의 경계

원문 인증 상태와 HTTP 요청의 경계
구성원문에서 하는 일경계
토큰 저장login은 accessToken을 localStorage에 저장하고 logout은 지웁니다.메모리 전용 저장 구현이 아닙니다.
HTTP 요청공유 axios 인스턴스가 localStorage 토큰을 읽어 Bearer 헤더를 붙입니다.baseURL은 localhost로 고정돼 있고 응답 Zod 검증은 없습니다.
초기 인증 확인토큰이 있으면 /auth/profile을 조회하여 user·isAuthenticated를 설정합니다.조회 실패는 저장 토큰과 인증 상태를 지웁니다.
로그인 이후토큰 저장 후 인증 상태를 true로 바꾸고 프로필을 비동기로 조회합니다.이 login 경로의 프로필 실패는 콘솔 기록만 하므로 초기 확인 경로와 다릅니다.
보호 라우트·401PrivateRoute는 loading·isAuthenticated로 화면을 고릅니다. HTTP 401은 토큰 삭제 후 /login으로 이동합니다.화면 분기는 서버의 API 권한 검사를 대신하지 않습니다.
구성: 토큰 저장
원문에서 하는 일: login은 accessToken을 localStorage에 저장하고 logout은 지웁니다.
경계: 메모리 전용 저장 구현이 아닙니다.
구성: HTTP 요청
원문에서 하는 일: 공유 axios 인스턴스가 localStorage 토큰을 읽어 Bearer 헤더를 붙입니다.
경계: baseURL은 localhost로 고정돼 있고 응답 Zod 검증은 없습니다.
구성: 초기 인증 확인
원문에서 하는 일: 토큰이 있으면 /auth/profile을 조회하여 user·isAuthenticated를 설정합니다.
경계: 조회 실패는 저장 토큰과 인증 상태를 지웁니다.
구성: 로그인 이후
원문에서 하는 일: 토큰 저장 후 인증 상태를 true로 바꾸고 프로필을 비동기로 조회합니다.
경계: 이 login 경로의 프로필 실패는 콘솔 기록만 하므로 초기 확인 경로와 다릅니다.
구성: 보호 라우트·401
원문에서 하는 일: PrivateRoute는 loading·isAuthenticated로 화면을 고릅니다. HTTP 401은 토큰 삭제 후 /login으로 이동합니다.
경계: 화면 분기는 서버의 API 권한 검사를 대신하지 않습니다.

코드의 AuthContext·axios·라우팅 책임을 비교한 것이다. 응답 데이터의 런타임 스키마 검증은 별도로 구현해야 한다.


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


이어서 보기