Golang, Gin, GORM 실전 API 개발 4편 - 최첨단 Full-Stack 완성편 Vite, TanStack, Zustand, shadcn/ui

지난 튜토리얼에서 우리는 React를 사용하여 백엔드 API와 연동되는 프론트엔드를 구축하고, 이를 Docker로 통합하는 과정을 마쳤습니다.

하지만 웹 기술의 발전 속도는 눈부시게 빠르며, 개발자 경험과 애플리케이션 성능을 극대화하는 새로운 도구들이 끊임없이 등장하고 있습니다.

이번 4편은 단순한 기능 구현을 넘어, '어떻게 하면 더 효율적이고 전문적으로 프론트엔드를 개발할 수 있는가'에 대한 해답을 제시하는 '심화 재구축' 과정입니다.

우리는 이전의 프론트엔드 코드를 과감히 버리고, 2024년 현재 가장 주목받는 최첨단 기술 스택으로 완전히 새롭게 무장할 것입니다.

'Vite'의 빛처럼 빠른 개발 서버와 빌드 속도, 'TanStack(Router & Query)'이 제공하는 완벽한 타입-세이프 라우팅과 선언적 데이터 관리, 'Zustand'의 놀랍도록 간결한 전역 상태 관리, 그리고 'shadcn/ui'와 'Tailwind CSS'를 통한 미려하고 일관성 있는 UI 시스템까지.

이 모든 것을 유기적으로 결합하여, 단순히 동작하는 애플리케이션이 아닌, 유지보수성이 뛰어나고 성능이 최적화된 프로덕션급 웹 애플리케이션을 만드는 전 과정을 경험하게 될 것입니다.

1. 최신 기술 스택으로 개발 환경 재구성

먼저, 프로젝트의 기반을 완전히 새롭게 다지겠습니다.

'Vite를 이용한 TypeScript 기반 React 프로젝트 생성'

프로젝트 루트 디렉터리에서 다음 명령어로 Vite 기반의 React + TypeScript 프로젝트를 생성합니다.

npx create-vite@latest frontend -- --template react-ts

'핵심 라이브러리 설치'

새로 생성된 frontend 디렉터리로 이동하여 필요한 모든 라이브러리를 설치합니다.

cd frontend
# UI 및 스타일링
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

# 상태 관리 및 라우팅, 데이터 페칭
npm install zustand @tanstack/react-router @tanstack/react-query axios better-react-auth

# shadcn/ui 초기 설정
npx shadcn-ui@latest init

shadcn/ui init 과정에서 TypeScript, tailwind.config.js 경로 등 몇 가지 질문에 답하면, UI 컴포넌트를 위한 기본 설정이 자동으로 완료됩니다.

2. 전역 상태 및 인증 시스템 구축 (Zustand & better-react-auth)

인증 상태는 앱 전반에서 사용되므로, 간결한 전역 상태 관리 라이브러리인 Zustand와 인증 라이브러리를 결합하여 관리합니다.

'Zustand 스토어 생성 (frontend/src/store/authStore.js)'

로그인 상태와 토큰 정보를 담을 간단한 스토어를 만듭니다.

import { create } from 'zustand';

const useAuthStore = create((set) => ({
  token: localStorage.getItem('token') || null,
  setToken: (token) => {
    if (token) {
      localStorage.setItem('token', token);
    } else {
      localStorage.removeItem('token');
    }
    set({ token });
  },
}));

export default useAuthStore;

'인증 컨텍스트 제공 (frontend/src/App.tsx)'

better-react-authAuthProvider를 사용하여 앱 전체에 인증 관련 함수를 제공합니다.

// ... imports
import { AuthProvider } from 'better-react-auth';
import useAuthStore from './store/authStore';
import apiClient from './api/client';

function App() {
  const { setToken } = useAuthStore();

  const login = async (credentials) => {
    const { data } = await apiClient.post('/login', credentials);
    setToken(data.token);
  };

  const logout = () => {
    setToken(null);
  };
  
  // ...
  return (
    <AuthProvider authClient={{ login, logout, register: ... }}>
      {/* 라우터 컴포넌트가 위치할 곳 */}
    </AuthProvider>
  );
}

3. 타입-세이프 라우팅 (TanStack Router)

TanStack Router는 파일 기반이 아닌 코드 기반의 완전한 타입-세이프 라우팅을 제공하여, 경로와 파라미터를 안전하게 관리할 수 있게 해줍니다.

'라우트 트리 정의 (frontend/src/routeTree.gen.ts는 자동생성)'

src/routes 디렉터리에 각 라우트 파일을 정의하면, TanStack Router가 이를 감지하여 타입-세이프한 라우트 트리를 자동으로 생성합니다.

'루트 라우트 설정 (frontend/src/routes/__root.tsx)'

모든 라우트의 기반이 되는 최상위 레이아웃을 정의합니다.

import { createRootRoute, Outlet } from '@tanstack/react-router';
import { useAuth } from 'better-react-auth';

export const Route = createRootRoute({
  component: () => {
    const { user } = useAuth(); // 인증 상태 확인
    return (
      <>
        {/* 네비게이션 바 등 공통 UI */}
        <hr />
        <Outlet />
      </>
    );
  },
});

'보호된 라우트 구현 (frontend/src/routes/memos.tsx)'

beforeLoad 옵션을 사용하여, 인증되지 않은 사용자가 이 경로에 접근하려고 하면 로그인 페이지로 리디렉션시킵니다.

import { createFileRoute, redirect } from '@tanstack/react-router';

export const Route = createFileRoute('/memos')({
  beforeLoad: ({ context, location }) => {
    // context.auth는 AuthProvider에서 주입된 상태입니다.
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          redirect: location.href,
        },
      });
    }
  },
  component: MemosPage,
});

function MemosPage() {
  // 메모 페이지 컴포넌트
  return <div>메모 페이지</div>;
}

4. 선언적 데이터 관리 및 UI 구현 (TanStack Query & shadcn/ui)

로딩, 에러, 상태 관리를 직접 하던 방식에서 벗어나, TanStack Query를 통해 데이터를 선언적으로 관리합니다.

'메모 페이지 완성 (frontend/src/routes/memos.tsx)'

useQuery로 데이터를 가져오고, useMutation으로 데이터를 변경하며, shadcn/ui로 멋진 UI를 구성합니다.

// ... MemosPage 컴포넌트 내부
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';

// API 호출 함수
const fetchMemos = async () => (await apiClient.get('/memos')).data;
const addMemo = async (newMemo) => (await apiClient.post('/memos', newMemo)).data;
const deleteMemo = async (id) => (await apiClient.delete(`/memos/${id}`)).data;

function MemosPage() {
  const queryClient = useQueryClient();

  // 데이터 조회
  const { data: memos, isLoading } = useQuery({ 
    queryKey: ['memos'], 
    queryFn: fetchMemos 
  });

  // 데이터 추가 (Mutation)
  const mutation = useMutation({
    mutationFn: addMemo,
    onSuccess: () => {
      // 성공 시 'memos' 쿼리를 무효화하여 자동으로 데이터를 다시 불러옵니다.
      queryClient.invalidateQueries({ queryKey: ['memos'] });
    },
  });

  // ... (삭제, 수정 Mutation도 유사하게 구현)

  if (isLoading) return <div>로딩 중...</div>;

  return (
    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>내 메모장</CardTitle>
      </CardHeader>
      <CardContent>
        <form onSubmit={...}>
          <Input placeholder="새 메모..." />
          <Button type="submit">추가</Button>
        </form>
        {/* memos.map(...)을 통해 메모 목록 렌더링 */}
      </CardContent>
    </Card>
  );
}

5. 최종 통합 배포 (Backend + Frontend in one Docker)

백엔드와 최신 프론트엔드 스택을 하나의 Docker 이미지로 통합합니다.

Vite의 빌드 결과물 디렉터리는 build가 아닌 dist라는 점에 유의해야 합니다.

'백엔드 수정 (main.go)'

정적 파일을 서빙하는 경로를 build에서 dist로 변경합니다.

// ... main.go
    // React 정적 파일 서빙 경로를 'dist'로 변경합니다.
    router.Static("/assets", "./frontend/dist/assets") // Vite는 보통 assets 폴더를 사용합니다.
    router.StaticFile("/", "./frontend/dist/index.html")
    router.StaticFile("/favicon.ico", "./frontend/dist/favicon.ico")

    // 클라이언트 사이드 라우팅을 위한 대체 경로 설정
    router.NoRoute(func(c *gin.Context) {
        c.File("./frontend/dist/index.html")
    })
// ...

'최종 Dockerfile 작성'

프론트엔드 빌드 스테이지가 Vite 환경에 맞게 수정됩니다.

# Dockerfile

# --- 1단계: 백엔드 빌드 환경 ---
FROM golang:1.21-alpine AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o /go/bin/memo-api .

# --- 2단계: 프론트엔드 빌드 환경 (Vite) ---
FROM node:20-alpine AS fe-builder
WORKDIR /app
# frontend 디렉터리의 package.json, package-lock.json 복사
COPY frontend/package*.json ./
# 의존성 설치 (npm ci는 package-lock.json을 사용해 더 빠르고 일관된 설치를 보장)
RUN npm ci
# frontend 소스 코드 전체 복사
COPY frontend/ ./
# Vite를 사용하여 React 앱 빌드
RUN npm run build

# --- 3단계: 최종 실행 환경 ---
FROM alpine:latest
WORKDIR /app

# Go 빌더에서 컴파일된 바이너리 복사
COPY --from=go-builder /go/bin/memo-api .

# React 빌더에서 빌드된 정적 파일들을 복사 (Vite의 결과물은 'dist' 폴더)
COPY --from=fe-builder /app/dist ./frontend/dist

# 애플리케이션이 8080 포트를 사용함을 명시
EXPOSE 8080

# 컨테이너 시작 시 Go 서버 실행
CMD ["./memo-api"]

마무리

ㄴ 이번 4편에서는 기존의 프론트엔드 코드를 완전히 버리고, Vite, TanStack, Zustand, shadcn/ui 등 현존하는 가장 현대적이고 강력한 도구들을 사용하여 애플리케이션을 재탄생시켰습니다.