본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout
TechProjectsBooksAbout
© 2026 Kreath. All rights reserved.
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 11장: 실전 프로젝트 - Go 백엔드 API 서버 구축
2026년 9월 2일·프로그래밍·

11장: 실전 프로젝트 - Go 백엔드 API 서버 구축

시리즈의 모든 내용을 종합하여 Chi 기반 REST API 서버, PostgreSQL(pgx), JWT 인증, 미들웨어 스택, 구조화된 로깅, 테스트, Docker 빌드, CI/CD 파이프라인을 갖춘 완전한 백엔드를 구축합니다.

16분1,800자13개 섹션
concurrencyperformancedesign-patternstestinginfrastructure
공유
go-backend11 / 11
1234567891011
이전10장: gRPC와 마이크로서비스 통신

학습 목표

  • 클린 아키텍처에 기반한 프로젝트 구조를 설계할 수 있다
  • Chi, pgx, JWT를 조합한 실전 API 서버를 구축할 수 있다
  • Docker 멀티스테이지 빌드로 프로덕션 이미지를 만들 수 있다
  • GitHub Actions CI/CD 파이프라인을 구성할 수 있다

프로젝트 개요

이 장에서는 **할 일 관리 API 서버(Task Manager API)**를 처음부터 끝까지 구축합니다. 시리즈에서 다룬 모든 주제를 하나의 프로젝트로 통합합니다.

프로젝트 기능 요약
text
- 사용자 인증 (회원가입, 로그인, JWT)
- 할 일 CRUD (생성, 조회, 수정, 삭제)
- 프로젝트별 할 일 그룹핑
- 구조화된 로깅 (slog)
- 미들웨어 스택 (인증, 로깅, 복구, CORS)
- 테스트 (단위, 통합)
- Docker 배포
- CI/CD

프로젝트 구조

클린 아키텍처(Clean Architecture) 원칙에 따라 계층을 분리합니다. 도메인 계층은 외부 의존성을 모르며, 의존성은 항상 안쪽(도메인) 방향으로 흐릅니다.

디렉토리 구조
text
task-manager/
  cmd/
    server/
      main.go              # 진입점
  internal/
    config/
      config.go            # 환경 설정
    domain/
      user.go              # 도메인 모델
      task.go
      project.go
      errors.go            # 도메인 에러
    port/
      repository.go        # 리포지토리 인터페이스
      service.go           # 서비스 인터페이스
    service/
      user_service.go      # 비즈니스 로직
      task_service.go
      auth_service.go
    adapter/
      postgres/
        user_repo.go       # PostgreSQL 구현
        task_repo.go
        migrations/        # SQL 마이그레이션
      http/
        router.go          # Chi 라우터 설정
        middleware.go       # 미들웨어
        handler/
          user_handler.go  # HTTP 핸들러
          task_handler.go
          auth_handler.go
  pkg/
    jwt/
      jwt.go               # JWT 유틸리티
    response/
      response.go          # HTTP 응답 헬퍼
  Dockerfile
  docker-compose.yml
  Makefile
  go.mod

도메인 모델

internal/domain/task.go
go
package domain
 
import "time"
 
type TaskStatus string
 
const (
    TaskStatusTodo       TaskStatus = "todo"
    TaskStatusInProgress TaskStatus = "in_progress"
    TaskStatusDone       TaskStatus = "done"
)
 
type TaskPriority string
 
const (
    TaskPriorityLow    TaskPriority = "low"
    TaskPriorityMedium TaskPriority = "medium"
    TaskPriorityHigh   TaskPriority = "high"
)
 
type Task struct {
    ID          int64
    Title       string
    Description string
    Status      TaskStatus
    Priority    TaskPriority
    ProjectID   *int64
    UserID      int64
    DueDate     *time.Time
    CreatedAt   time.Time
    UpdatedAt   time.Time
}
 
// 도메인 로직은 모델에 포함
func (t *Task) MarkDone() {
    t.Status = TaskStatusDone
    t.UpdatedAt = time.Now()
}
 
func (t *Task) IsOverdue() bool {
    if t.DueDate == nil {
        return false
    }
    return time.Now().After(*t.DueDate) && t.Status != TaskStatusDone
}
internal/domain/errors.go
go
package domain
 
import "errors"
 
var (
    ErrNotFound       = errors.New("리소스를 찾을 수 없습니다")
    ErrAlreadyExists  = errors.New("리소스가 이미 존재합니다")
    ErrUnauthorized   = errors.New("인증이 필요합니다")
    ErrForbidden      = errors.New("접근 권한이 없습니다")
    ErrInvalidInput   = errors.New("유효하지 않은 입력입니다")
)

포트 계층 -- 인터페이스 정의

internal/port/repository.go
go
package port
 
import (
    "context"
    "github.com/myapp/task-manager/internal/domain"
)
 
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*domain.User, error)
    FindByEmail(ctx context.Context, email string) (*domain.User, error)
    Create(ctx context.Context, user *domain.User) error
}
 
type TaskRepository interface {
    FindByID(ctx context.Context, id int64) (*domain.Task, error)
    FindByUserID(ctx context.Context, userID int64, filter TaskFilter) ([]*domain.Task, error)
    Create(ctx context.Context, task *domain.Task) error
    Update(ctx context.Context, task *domain.Task) error
    Delete(ctx context.Context, id int64) error
}
 
type TaskFilter struct {
    Status    *domain.TaskStatus
    Priority  *domain.TaskPriority
    ProjectID *int64
    Limit     int
    Offset    int
}

PostgreSQL 리포지토리

internal/adapter/postgres/task_repo.go
go
package postgres
 
import (
    "context"
    "errors"
    "fmt"
 
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/myapp/task-manager/internal/domain"
    "github.com/myapp/task-manager/internal/port"
)
 
type TaskRepo struct {
    pool *pgxpool.Pool
}
 
func NewTaskRepo(pool *pgxpool.Pool) *TaskRepo {
    return &TaskRepo{pool: pool}
}
 
func (r *TaskRepo) FindByID(ctx context.Context, id int64) (*domain.Task, error) {
    var task domain.Task
    err := r.pool.QueryRow(ctx, `
        SELECT id, title, description, status, priority,
               project_id, user_id, due_date, created_at, updated_at
        FROM tasks WHERE id = $1
    `, id).Scan(
        &task.ID, &task.Title, &task.Description, &task.Status,
        &task.Priority, &task.ProjectID, &task.UserID,
        &task.DueDate, &task.CreatedAt, &task.UpdatedAt,
    )
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, domain.ErrNotFound
        }
        return nil, fmt.Errorf("TaskRepo.FindByID(%d): %w", id, err)
    }
    return &task, nil
}
 
func (r *TaskRepo) FindByUserID(
    ctx context.Context, userID int64, filter port.TaskFilter,
) ([]*domain.Task, error) {
    query := `
        SELECT id, title, description, status, priority,
               project_id, user_id, due_date, created_at, updated_at
        FROM tasks WHERE user_id = $1
    `
    args := []any{userID}
    argIdx := 2
 
    if filter.Status != nil {
        query += fmt.Sprintf(" AND status = $%d", argIdx)
        args = append(args, *filter.Status)
        argIdx++
    }
 
    if filter.Priority != nil {
        query += fmt.Sprintf(" AND priority = $%d", argIdx)
        args = append(args, *filter.Priority)
        argIdx++
    }
 
    query += " ORDER BY created_at DESC"
 
    if filter.Limit > 0 {
        query += fmt.Sprintf(" LIMIT $%d", argIdx)
        args = append(args, filter.Limit)
        argIdx++
    }
 
    rows, err := r.pool.Query(ctx, query, args...)
    if err != nil {
        return nil, fmt.Errorf("TaskRepo.FindByUserID: %w", err)
    }
    defer rows.Close()
 
    var tasks []*domain.Task
    for rows.Next() {
        var t domain.Task
        if err := rows.Scan(
            &t.ID, &t.Title, &t.Description, &t.Status,
            &t.Priority, &t.ProjectID, &t.UserID,
            &t.DueDate, &t.CreatedAt, &t.UpdatedAt,
        ); err != nil {
            return nil, fmt.Errorf("TaskRepo.FindByUserID scan: %w", err)
        }
        tasks = append(tasks, &t)
    }
 
    return tasks, rows.Err()
}
 
func (r *TaskRepo) Create(ctx context.Context, task *domain.Task) error {
    err := r.pool.QueryRow(ctx, `
        INSERT INTO tasks (title, description, status, priority, project_id, user_id, due_date)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
        RETURNING id, created_at, updated_at
    `, task.Title, task.Description, task.Status, task.Priority,
        task.ProjectID, task.UserID, task.DueDate,
    ).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt)
 
    if err != nil {
        return fmt.Errorf("TaskRepo.Create: %w", err)
    }
    return nil
}

HTTP 핸들러와 라우터

internal/adapter/http/router.go
go
package http
 
import (
    "time"
 
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/myapp/task-manager/internal/adapter/http/handler"
    "github.com/myapp/task-manager/internal/config"
    "github.com/myapp/task-manager/pkg/jwt"
)
 
func NewRouter(cfg *config.Config, h *handler.Handlers, jwtSvc *jwt.Service) chi.Router {
    r := chi.NewRouter()
 
    // 글로벌 미들웨어
    r.Use(RequestIDMiddleware)
    r.Use(LoggingMiddleware)
    r.Use(middleware.Recoverer)
    r.Use(CORSMiddleware(cfg.AllowedOrigins))
    r.Use(middleware.Timeout(30 * time.Second))
 
    // 헬스 체크
    r.Get("/health", h.Health.Check)
 
    // API v1
    r.Route("/api/v1", func(r chi.Router) {
        // 공개 라우트
        r.Post("/auth/register", h.Auth.Register)
        r.Post("/auth/login", h.Auth.Login)
        r.Post("/auth/refresh", h.Auth.RefreshToken)
 
        // 인증 필요 라우트
        r.Group(func(r chi.Router) {
            r.Use(AuthMiddleware(jwtSvc))
 
            r.Route("/tasks", func(r chi.Router) {
                r.Get("/", h.Task.List)
                r.Post("/", h.Task.Create)
                r.Route("/{taskID}", func(r chi.Router) {
                    r.Get("/", h.Task.Get)
                    r.Put("/", h.Task.Update)
                    r.Delete("/", h.Task.Delete)
                })
            })
 
            r.Route("/projects", func(r chi.Router) {
                r.Get("/", h.Project.List)
                r.Post("/", h.Project.Create)
            })
 
            r.Route("/users/me", func(r chi.Router) {
                r.Get("/", h.User.GetMe)
                r.Put("/", h.User.UpdateMe)
            })
        })
    })
 
    return r
}
internal/adapter/http/handler/task_handler.go
go
package handler
 
import (
    "encoding/json"
    "net/http"
    "strconv"
 
    "github.com/go-chi/chi/v5"
    "github.com/myapp/task-manager/internal/domain"
    "github.com/myapp/task-manager/internal/port"
    "github.com/myapp/task-manager/pkg/response"
)
 
type TaskHandler struct {
    taskSvc port.TaskService
}
 
func NewTaskHandler(taskSvc port.TaskService) *TaskHandler {
    return &TaskHandler{taskSvc: taskSvc}
}
 
type CreateTaskRequest struct {
    Title       string  `json:"title" validate:"required,min=1,max=200"`
    Description string  `json:"description" validate:"max=2000"`
    Priority    string  `json:"priority" validate:"required,oneof=low medium high"`
    ProjectID   *int64  `json:"project_id"`
    DueDate     *string `json:"due_date"`
}
 
func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req CreateTaskRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        response.Error(w, http.StatusBadRequest, "요청 본문을 파싱할 수 없습니다")
        return
    }
 
    if err := validate.Struct(&req); err != nil {
        response.ValidationError(w, err)
        return
    }
 
    claims := GetUserClaims(r.Context())
    task, err := h.taskSvc.Create(r.Context(), port.CreateTaskInput{
        Title:       req.Title,
        Description: req.Description,
        Priority:    domain.TaskPriority(req.Priority),
        ProjectID:   req.ProjectID,
        UserID:      claims.UserID,
    })
    if err != nil {
        response.HandleServiceError(w, err)
        return
    }
 
    response.JSON(w, http.StatusCreated, task)
}
 
func (h *TaskHandler) Get(w http.ResponseWriter, r *http.Request) {
    taskID, err := strconv.ParseInt(chi.URLParam(r, "taskID"), 10, 64)
    if err != nil {
        response.Error(w, http.StatusBadRequest, "유효하지 않은 할 일 ID입니다")
        return
    }
 
    claims := GetUserClaims(r.Context())
    task, err := h.taskSvc.GetByID(r.Context(), taskID, claims.UserID)
    if err != nil {
        response.HandleServiceError(w, err)
        return
    }
 
    response.JSON(w, http.StatusOK, task)
}

서비스 진입점

cmd/server/main.go
go
package main
 
import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
 
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/myapp/task-manager/internal/config"
    "github.com/myapp/task-manager/internal/adapter/postgres"
    apphttp "github.com/myapp/task-manager/internal/adapter/http"
    "github.com/myapp/task-manager/internal/adapter/http/handler"
    "github.com/myapp/task-manager/internal/service"
    "github.com/myapp/task-manager/pkg/jwt"
)
 
func main() {
    // 설정 로드
    cfg := config.MustLoad()
    setupLogger(cfg.Env)
 
    // 데이터베이스 연결
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
    if err != nil {
        slog.Error("데이터베이스 연결 실패", "error", err)
        os.Exit(1)
    }
    defer pool.Close()
 
    if err := pool.Ping(ctx); err != nil {
        slog.Error("데이터베이스 핑 실패", "error", err)
        os.Exit(1)
    }
    slog.Info("데이터베이스 연결 성공")
 
    // 의존성 조립
    jwtSvc := jwt.NewService(cfg.JWTSecret)
    userRepo := postgres.NewUserRepo(pool)
    taskRepo := postgres.NewTaskRepo(pool)
    userSvc := service.NewUserService(userRepo)
    taskSvc := service.NewTaskService(taskRepo)
    authSvc := service.NewAuthService(userRepo, jwtSvc)
 
    handlers := handler.NewHandlers(userSvc, taskSvc, authSvc)
    router := apphttp.NewRouter(cfg, handlers, jwtSvc)
 
    // 서버 시작
    server := &http.Server{
        Addr:         ":" + cfg.Port,
        Handler:      router,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }
 
    // Graceful Shutdown
    go func() {
        slog.Info("서버 시작", "port", cfg.Port, "env", cfg.Env)
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("서버 실행 실패", "error", err)
            os.Exit(1)
        }
    }()
 
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
 
    slog.Info("서버 종료 시작")
    shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()
 
    if err := server.Shutdown(shutdownCtx); err != nil {
        slog.Error("서버 강제 종료", "error", err)
    }
    slog.Info("서버 종료 완료")
}
Info

Graceful Shutdown은 프로덕션 서버의 필수 요소입니다. SIGINT/SIGTERM 신호를 수신하면 새로운 요청은 거부하고, 처리 중인 요청이 완료될 때까지 대기한 후 서버를 종료합니다. 이를 통해 배포 중 요청 손실을 방지합니다.


Docker 빌드

멀티스테이지 Dockerfile

Dockerfile
dockerfile
# 빌드 스테이지
FROM golang:1.23-alpine AS builder
 
RUN apk add --no-cache git ca-certificates
 
WORKDIR /app
 
COPY go.mod go.sum ./
RUN go mod download
 
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
 
# 실행 스테이지
FROM gcr.io/distroless/static-debian12
 
COPY --from=builder /server /server
COPY --from=builder /app/internal/adapter/postgres/migrations /migrations
 
EXPOSE 8080
 
ENTRYPOINT ["/server"]
docker-compose.yml
yaml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=postgres://app:secret@db:5432/taskmanager?sslmode=disable
      - JWT_SECRET=your-secret-key
      - PORT=8080
      - ENV=production
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: taskmanager
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d taskmanager"]
      interval: 5s
      timeout: 5s
      retries: 5
 
volumes:
  pgdata:
Tip

distroless 이미지를 사용하면 셸도 없는 초소형 이미지를 만들 수 있습니다. Go 바이너리가 정적 링크되므로 별도의 C 라이브러리가 필요 없으며, 최종 이미지 크기는 15-25MB 수준으로 줄어듭니다. 보안 측면에서도 공격 표면이 최소화됩니다.


CI/CD 파이프라인

.github/workflows/ci.yml
yaml
name: CI/CD
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
 
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
 
      - name: 의존성 설치
        run: go mod download
 
      - name: 린트
        uses: golangci/golangci-lint-action@v6
        with:
          version: latest
 
      - name: 단위 테스트
        run: go test -short -coverprofile=coverage.out ./...
 
      - name: 통합 테스트
        run: go test -v ./...
        env:
          DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable
 
  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Docker 이미지 빌드
        run: docker build -t task-manager:${{ github.sha }} .
 
      - name: Docker 이미지 푸시
        run: |
          echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
          docker tag task-manager:${{ github.sha }} myregistry/task-manager:latest
          docker push myregistry/task-manager:latest

Makefile

개발 편의를 위한 Makefile을 정의합니다.

Makefile
makefile
.PHONY: dev build test lint migrate docker-build
 
dev:
	go run ./cmd/server
 
build:
	CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/server ./cmd/server
 
test:
	go test -v -coverprofile=coverage.out ./...
 
test-short:
	go test -short -v ./...
 
lint:
	golangci-lint run ./...
 
migrate-up:
	migrate -path internal/adapter/postgres/migrations \
	        -database "$(DATABASE_URL)" up
 
migrate-down:
	migrate -path internal/adapter/postgres/migrations \
	        -database "$(DATABASE_URL)" down 1
 
docker-build:
	docker build -t task-manager .
 
docker-up:
	docker compose up -d
 
docker-down:
	docker compose down

시리즈 마무리

11장에 걸쳐 Go 언어로 실전 백엔드 시스템을 구축하는 과정을 살펴보았습니다. 각 장에서 다룬 핵심 내용을 정리합니다.

장핵심 주제실전 적용
1장Go 철학, 언어 비교기술 선택의 근거
2장타입 시스템, 인터페이스도메인 모델, 의존성 역전
3장고루틴, 채널동시 요청 처리
4장동시성 패턴context, errgroup, 워커 풀
5장에러 처리도메인 에러, API 에러 응답
6장웹 프레임워크Chi 라우터 선택
7장데이터베이스pgx, 마이그레이션, 트랜잭션
8장미들웨어, 인증JWT, slog, 미들웨어 스택
9장테스트단위/통합 테스트, testcontainers
10장gRPC마이크로서비스 통신
11장실전 프로젝트전체 통합, Docker, CI/CD
Tip

이 시리즈에서 구축한 프로젝트를 기반으로, 다음 단계로 Redis 캐싱, 메시지 큐(NATS, Kafka) 연동, OpenTelemetry 분산 추적, Kubernetes 배포 등을 추가해보세요. 실전 프로젝트에 하나씩 기능을 추가하면서 학습하는 것이 가장 효과적인 방법입니다.


정리

이번 장에서 구축한 프로젝트의 핵심 설계 결정을 정리합니다.

  • 클린 아키텍처로 계층을 분리하여 테스트와 유지보수가 용이한 구조를 만들었습니다
  • Chi + pgx + JWT 조합으로 표준 라이브러리 호환성과 성능을 모두 확보했습니다
  • Graceful Shutdown으로 배포 시 요청 손실을 방지합니다
  • Docker 멀티스테이지 빌드로 15-25MB의 초경량 프로덕션 이미지를 생성합니다
  • GitHub Actions로 테스트, 린트, 빌드, 배포를 자동화했습니다

이 글이 도움이 되셨나요?

관련 글

프로그래밍

10장: gRPC와 마이크로서비스 통신

Protocol Buffers 정의부터 Go에서의 gRPC 서버/클라이언트 구현, 유니어리/스트리밍 통신, 인터셉터, gRPC-Gateway를 통한 REST 변환, 헬스 체킹과 서비스 디스커버리를 다룹니다.

2026년 8월 30일·12분
프로그래밍

9장: 테스트 전략 - 단위, 통합, 벤치마크

Go의 testing 패키지를 중심으로 테이블 기반 테스트, testify, httptest, 목(Mock) 생성, testcontainers-go 통합 테스트, 벤치마크, 퍼징 테스트 전략을 다룹니다.

2026년 8월 28일·14분
프로그래밍

8장: 미들웨어, 인증, API 설계

Go 백엔드의 미들웨어 패턴, JWT 인증 구현, CORS 설정, 레이트 리미팅, 요청 유효성 검사, OpenAPI/Swagger 문서 생성, log/slog를 활용한 구조화된 로깅을 다룹니다.

2026년 8월 25일·11분
이전 글10장: gRPC와 마이크로서비스 통신

댓글

목차

약 16분 남음
  • 학습 목표
  • 프로젝트 개요
  • 프로젝트 구조
  • 도메인 모델
  • 포트 계층 -- 인터페이스 정의
  • PostgreSQL 리포지토리
  • HTTP 핸들러와 라우터
  • 서비스 진입점
  • Docker 빌드
    • 멀티스테이지 Dockerfile
  • CI/CD 파이프라인
  • Makefile
  • 시리즈 마무리
  • 정리