icon
16장 : 실전 프로젝트

프론트엔드 애플리케이션 개발


이전 절에서는 NestJS를 사용하여 견고한 타입스크립트 기반의 API 서버를 구현했습니다. 이제는 사용자와 직접 상호작용하는 부분인 프론트엔드 애플리케이션을 개발할 차례입니다. 프론트엔드는 백엔드 API로부터 데이터를 가져와 사용자에게 시각적으로 보여주고, 사용자의 입력을 받아 백엔드로 전송하는 역할을 담당합니다.

이 절에서는 React와 타입스크립트를 사용하여 프론트엔드 애플리케이션을 구축하는 과정을 다룹니다. 특히, 백엔드와의 효율적인 통신, 상태 관리, 그리고 사용자 인터페이스를 구성하는 방법을 집중적으로 설명합니다.


프로젝트 초기 설정 (React + Vite)

이전 장에서 모노레포 구조를 설계했다면, packages/client 폴더 내에서 React 프로젝트를 시작합니다. Vite는 빠르고 가벼운 개발 서버와 빌드 도구를 제공하여 React 애플리케이션 개발에 많이 사용됩니다.

  1. Vite 프로젝트 생성

    # packages/client 폴더에서 실행
    npm create vite@latest . -- --template react-ts

    이 명령어를 실행하면 현재 디렉토리(packages/client)에 React 및 타입스크립트 기반의 Vite 프로젝트가 생성됩니다.

  2. 필수 의존성 설치: 기본 생성된 프로젝트에는 React와 타입스크립트 관련 의존성이 이미 포함되어 있습니다. 백엔드와 통신하기 위한 HTTP 클라이언트 라이브러리인 Axios를 설치합니다.

    npm install axios
    npm install --save-dev @types/axios
  3. vite.config.tstsconfig.json 확인: Vite는 기본적으로 타입스크립트를 지원하며, vite.config.tstsconfig.json 파일이 프로젝트 루트에 생성됩니다. 모노레포 환경에서는 루트 tsconfig.json과 워크스페이스 내의 tsconfig.json 간의 설정을 잘 조율해야 합니다. 특히, shared 패키지의 타입을 임포트할 수 있도록 경로 별칭(paths)을 설정하는 것이 유용합니다.

    // packages/client/tsconfig.json
    {
      "compilerOptions": {
        // ...
        "baseUrl": ".",
        "paths": {
          "@shared/*": ["../shared/src/*"] // shared 패키지 경로 별칭 설정
        },
        // ...
      },
      "include": ["src", "vite.config.ts"],
      "references": [{ "path": "../shared" }] // shared 패키지를 참조
    }

    이제 import { IUser } from '@shared/interfaces/user'; 와 같이 shared 패키지의 타입을 가져올 수 있습니다.

  4. 환경 변수 설정: 백엔드 API의 URL과 같이 환경별로 달라지는 값은 .env 파일을 통해 관리합니다. Vite는 .env 파일에 VITE_ 접두사가 붙은 변수를 자동으로 인식하고 클라이언트 코드에서 import.meta.env.VITE_YOUR_VAR 형태로 접근할 수 있도록 지원합니다.

    # packages/client/.env
    VITE_API_BASE_URL=http://localhost:4000/api

    백엔드에서 /api 프리픽스를 사용하는 경우 위와 같이 설정하고, 아니라면 http://localhost:4000으로 설정할 수 있습니다. (NestJS @Controller() 데코레이터에 /api를 붙이지 않았다면)


컴포넌트 기반 UI 개발

React는 컴포넌트 기반 아키텍처를 따릅니다. UI를 재사용 가능하고 독립적인 작은 단위로 분리하여 개발합니다.

공통 UI 컴포넌트

버튼, 입력 필드, 모달 등 애플리케이션 전반에 걸쳐 사용될 기본적인 UI 컴포넌트를 정의합니다.

// packages/client/src/components/Button.tsx
import React from 'react';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  children: React.ReactNode;
}

const Button: React.FC<ButtonProps> = ({ variant = 'primary', children, ...props }) => {
  const baseStyle = 'px-4 py-2 rounded font-semibold transition-colors duration-200';
  const variantStyles = {
    primary: 'bg-blue-500 text-white hover:bg-blue-600',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
    danger: 'bg-red-500 text-white hover:bg-red-600',
  };

  return (
    <button className={`${baseStyle} ${variantStyles[variant]}`} {...props}>
      {children}
    </button>
  );
};

export default Button;

(tailwind-css 등을 사용하여 스타일링하는 예시)

페이지 컴포넌트

라우팅을 통해 접근되는 각 페이지를 구성하는 컴포넌트입니다. 여러 공통 컴포넌트를 조합하고, 데이터 페칭 및 상태 관리 로직과 연결됩니다.

// packages/client/src/pages/HomePage.tsx
import React from 'react';
import Button from '../components/Button'; // 공통 버튼 컴포넌트 임포트

const HomePage: React.FC = () => {
  const handleLogout = () => {
    // 로그아웃 로직 처리
    console.log('Logout clicked');
  };

  return (
    <div className="home-page p-8 text-center">
      <h1 className="text-3xl font-bold mb-4">Welcome to My Fullstack App!</h1>
      <p className="text-lg mb-8">This is the homepage of our application.</p>
      <Button onClick={handleLogout} variant="secondary">
        Logout
      </Button>
    </div>
  );
};

export default HomePage;

백엔드 API 통신

Axios와 타입스크립트를 사용하여 백엔드 API와 통신하는 서비스를 구현합니다. shared 패키지의 인터페이스를 활용하여 통신의 타입 안전성을 확보합니다.

// packages/client/src/services/apiClient.ts
import axios from 'axios';

const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // .env에서 API 기본 URL 가져오기
  headers: {
    'Content-Type': 'application/json',
  },
});

// 요청 인터셉터: JWT 토큰을 모든 요청 헤더에 추가 (인증 구현 시)
apiClient.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 시 로그인 페이지로 리다이렉트)
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response && error.response.status === 401) {
      console.error('Unauthorized. Redirecting to login...');
      // TODO: 로그인 페이지로 리다이렉트 로직 구현
      // window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default apiClient;
// packages/client/src/services/userService.ts
import apiClient from './apiClient';
import { IUser, CreateUserDto, UpdateUserDto } from '@shared/interfaces/user'; // shared 패키지에서 타입 임포트

export const userService = {
  // 모든 사용자 조회
  getAllUsers: async (): Promise<IUser[]> => {
    const response = await apiClient.get<IUser[]>('/users');
    return response.data;
  },

  // 특정 사용자 조회
  getUserById: async (id: number): Promise<IUser> => {
    const response = await apiClient.get<IUser>(`/users/${id}`);
    return response.data;
  },

  // 사용자 생성
  createUser: async (userData: CreateUserDto): Promise<IUser> => {
    const response = await apiClient.post<IUser>('/users', userData);
    return response.data;
  },

  // 사용자 업데이트
  updateUser: async (id: number, userData: UpdateUserDto): Promise<IUser> => {
    const response = await apiClient.put<IUser>(`/users/${id}`, userData);
    return response.data;
  },

  // 사용자 삭제
  deleteUser: async (id: number): Promise<void> => {
    await apiClient.delete(`/users/${id}`);
  },
};

상태 관리

React 애플리케이션의 복잡성이 커지면, 컴포넌트 간에 상태를 공유하고 관리하는 문제가 중요해집니다. 전역 상태 관리 라이브러리를 사용하면 이를 효율적으로 처리할 수 있습니다. 여기서는 가볍고 사용하기 쉬운 Zustand를 예시로 들어보겠습니다. (Redux, Recoil, Jotai 등도 좋은 대안입니다.)

  1. Zustand 설치

    npm install zustand
  2. 스토어 정의 (packages/client/src/store/authStore.ts)

    // packages/client/src/store/authStore.ts
    import { create } from 'zustand';
    import { immer } from 'zustand/middleware/immer'; // 불변성 관리를 위해 immer 미들웨어 사용 (npm install zustand-middleware-immer)
    import { userService } from '../services/userService';
    import apiClient from '../services/apiClient';
    import { CreateUserDto } from '@shared/interfaces/user'; // DTO 타입 임포트
    
    // 사용자 정보 타입 (로그인 후 저장될 정보)
    interface AuthUser {
      id: number;
      email: string;
      name: string;
      isAdmin: boolean;
    }
    
    interface AuthState {
      user: AuthUser | null;
      accessToken: string | null;
      isLoading: boolean;
      error: string | null;
      
      // 액션
      login: (credentials: Pick<CreateUserDto, 'email' | 'password'>) => Promise<void>;
      register: (userData: CreateUserDto) => Promise<void>;
      logout: () => void;
      // 사용자 정보를 불러오는 액션 (토큰 유효성 검사용 등)
      fetchUser: () => Promise<void>;
    }
    
    export const useAuthStore = create<AuthState>()(
      immer((set, get) => ({
        user: null,
        accessToken: localStorage.getItem('accessToken'), // 초기 로드 시 토큰 확인
        isLoading: false,
        error: null,
    
        login: async (credentials) => {
          set((state) => {
            state.isLoading = true;
            state.error = null;
          });
          try {
            const response = await apiClient.post<{ accessToken: string }>('/auth/login', credentials);
            localStorage.setItem('accessToken', response.data.accessToken);
            set((state) => {
              state.accessToken = response.data.accessToken;
              state.isLoading = false;
            });
            await get().fetchUser(); // 로그인 후 사용자 정보 바로 가져오기
          } catch (err: any) {
            set((state) => {
              state.error = err.response?.data?.message || '로그인 실패';
              state.isLoading = false;
              state.user = null;
              state.accessToken = null;
            });
            localStorage.removeItem('accessToken');
          }
        },
    
        register: async (userData) => {
          set((state) => {
            state.isLoading = true;
            state.error = null;
          });
          try {
            await userService.createUser(userData);
            set((state) => {
              state.isLoading = false;
            });
            // 회원가입 성공 후 자동으로 로그인 처리할 수도 있습니다.
          } catch (err: any) {
            set((state) => {
              state.error = err.response?.data?.message || '회원가입 실패';
              state.isLoading = false;
            });
          }
        },
    
        logout: () => {
          localStorage.removeItem('accessToken');
          set((state) => {
            state.user = null;
            state.accessToken = null;
            state.isLoading = false;
            state.error = null;
          });
        },
    
        fetchUser: async () => {
          if (!get().accessToken) {
            set((state) => { state.user = null; });
            return;
          }
          set((state) => { state.isLoading = true; });
          try {
            // 실제 사용자 정보를 가져오는 API (백엔드에서 JWT 토큰을 통해 사용자 정보를 반환하는 엔드포인트)
            const response = await apiClient.get<AuthUser>('/auth/profile');
            set((state) => {
              state.user = response.data;
              state.isLoading = false;
            });
          } catch (err) {
            console.error('Failed to fetch user profile:', err);
            // 토큰이 유효하지 않거나 만료된 경우 로그아웃 처리
            get().logout();
            set((state) => {
              state.error = '세션이 만료되었습니다. 다시 로그인해주세요.';
              state.isLoading = false;
            });
          }
        },
      }))
    );
  3. 컴포넌트에서 스토어 사용

    // packages/client/src/pages/LoginPage.tsx
    import React, { useState } from 'react';
    import { useAuthStore } from '../store/authStore';
    import Button from '../components/Button';
    
    const LoginPage: React.FC = () => {
      const [email, setEmail] = useState('');
      const [password, setPassword] = useState('');
      const login = useAuthStore((state) => state.login);
      const error = useAuthStore((state) => state.error);
      const isLoading = useAuthStore((state) => state.isLoading);
      const user = useAuthStore((state) => state.user); // 로그인 성공 여부 확인
    
      const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        await login({ email, password });
      };
    
      // 로그인 성공 시 홈 페이지 등으로 리다이렉트
      if (user) {
        // return <Navigate to="/" />; // react-router-dom 사용 시
        return <p className="text-green-500 text-center mt-4">Login successful! Redirecting...</p>
      }
    
      return (
        <div className="login-page p-8 max-w-sm mx-auto mt-20 border rounded-lg shadow-lg">
          <h2 className="text-2xl font-bold mb-6 text-center">Login</h2>
          <form onSubmit={handleSubmit}>
            <div className="mb-4">
              <label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">Email:</label>
              <input
                id="email"
                type="email"
                className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                required
              />
            </div>
            <div className="mb-6">
              <label htmlFor="password" className="block text-gray-700 text-sm font-bold mb-2">Password:</label>
              <input
                id="password"
                type="password"
                className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                required
              />
            </div>
            {error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
            <div className="flex items-center justify-between">
              <Button type="submit" disabled={isLoading}>
                {isLoading ? 'Logging In...' : 'Login'}
              </Button>
            </div>
          </form>
        </div>
      );
    };
    
    export default LoginPage;

라우팅 설정

싱글 페이지 애플리케이션(SPA)에서는 React Router DOM과 같은 라이브러리를 사용하여 URL에 따라 다른 컴포넌트를 렌더링하고 탐색 기능을 구현합니다.

  1. React Router DOM 설치

    npm install react-router-dom
    npm install --save-dev @types/react-router-dom
  2. 라우터 설정 (packages/client/src/App.tsx)

    // packages/client/src/App.tsx
    import React, { useEffect } from 'react';
    import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
    import HomePage from './pages/HomePage';
    import LoginPage from './pages/LoginPage';
    import RegisterPage from './pages/RegisterPage';
    import UserProfilePage from './pages/UserProfilePage'; // 예시
    import AdminDashboardPage from './pages/AdminDashboardPage'; // 예시
    import { useAuthStore } from './store/authStore';
    
    // 인증된 사용자만 접근할 수 있는 라우트 보호 컴포넌트
    const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
      const accessToken = useAuthStore((state) => state.accessToken);
      return accessToken ? <>{children}</> : <Navigate to="/login" replace />;
    };
    
    // 관리자만 접근할 수 있는 라우트 보호 컴포넌트 (선택 사항)
    const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
      const { user, isLoading } = useAuthStore();
      if (isLoading) return <p>Loading user data...</p>; // 로딩 중일 때 표시
      return user && user.isAdmin ? <>{children}</> : <Navigate to="/" replace />;
    };
    
    const App: React.FC = () => {
      const fetchUser = useAuthStore((state) => state.fetchUser);
    
      useEffect(() => {
        // 앱 초기 로드 시 기존 토큰으로 사용자 정보 페칭 시도
        fetchUser();
      }, [fetchUser]);
    
      return (
        <Router>
          <Routes>
            {/* 공개 라우트 */}
            <Route path="/login" element={<LoginPage />} />
            <Route path="/register" element={<RegisterPage />} />
    
            {/* 인증된 사용자만 접근 가능한 라우트 */}
            <Route path="/" element={
              <PrivateRoute>
                <HomePage />
              </PrivateRoute>
            } />
            <Route path="/profile" element={
              <PrivateRoute>
                <UserProfilePage />
              </PrivateRoute>
            } />
    
            {/* 관리자만 접근 가능한 라우트 */}
            <Route path="/admin" element={
              <AdminRoute>
                <AdminDashboardPage />
              </AdminRoute>
            } />
    
            {/* 일치하는 라우트가 없을 때 */}
            <Route path="*" element={
                <div className="text-center mt-20">
                    <h1 className="text-4xl font-bold">404 - Page Not Found</h1>
                    <p className="mt-4">The page you are looking for does not exist.</p>
                </div>
            } />
          </Routes>
        </Router>
      );
    };
    
    export default App;

프론트엔드 애플리케이션 실행

모든 설정과 구현이 완료되었다면, 프론트엔드 애플리케이션을 실행하여 백엔드 API와 연동되는지 확인할 수 있습니다.

packages/client 디렉토리에서 다음 명령어를 실행합니다.

npm run dev

Vite 개발 서버가 시작되고, 보통 http://localhost:5173 (또는 다른 포트)에서 애플리케이션이 실행됩니다. 브라우저에서 이 URL에 접속하여 로그인, 사용자 정보 조회 등의 기능을 테스트해볼 수 있습니다.

주의: 프론트엔드 애플리케이션이 백엔드 API와 통신하려면, 백엔드 서버가 먼저 실행되어 있어야 합니다.


결론

이 절에서는 React와 타입스크립트, Vite를 사용하여 프론트엔드 애플리케이션을 구축하는 핵심 과정을 다루었습니다. 컴포넌트 기반 UI 개발, 백엔드 API 통신(Axios), 효율적인 상태 관리(Zustand), 그리고 라우팅(React Router DOM) 구현을 통해 사용자와 상호작용하는 웹 애플리케이션의 기본적인 골격을 완성했습니다.

타입스크립트는 프론트엔드 개발에서도 강력한 타입 안전성을 제공하여, API 응답 데이터의 형태를 보장하고 런타임 오류를 줄여줍니다. 다음 절에서는 지금까지 구현한 프론트엔드와 백엔드 애플리케이션을 함께 배포하는 방법에 대해 알아보겠습니다.