시리즈의 모든 내용을 종합하여 Chi 기반 REST API 서버, PostgreSQL(pgx), JWT 인증, 미들웨어 스택, 구조화된 로깅, 테스트, Docker 빌드, CI/CD 파이프라인을 갖춘 완전한 백엔드를 구축합니다.
이 장에서는 **할 일 관리 API 서버(Task Manager API)**를 처음부터 끝까지 구축합니다. 시리즈에서 다룬 모든 주제를 하나의 프로젝트로 통합합니다.
- 사용자 인증 (회원가입, 로그인, JWT)
- 할 일 CRUD (생성, 조회, 수정, 삭제)
- 프로젝트별 할 일 그룹핑
- 구조화된 로깅 (slog)
- 미들웨어 스택 (인증, 로깅, 복구, CORS)
- 테스트 (단위, 통합)
- Docker 배포
- CI/CD클린 아키텍처(Clean Architecture) 원칙에 따라 계층을 분리합니다. 도메인 계층은 외부 의존성을 모르며, 의존성은 항상 안쪽(도메인) 방향으로 흐릅니다.
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.modpackage 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
}package domain
import "errors"
var (
ErrNotFound = errors.New("리소스를 찾을 수 없습니다")
ErrAlreadyExists = errors.New("리소스가 이미 존재합니다")
ErrUnauthorized = errors.New("인증이 필요합니다")
ErrForbidden = errors.New("접근 권한이 없습니다")
ErrInvalidInput = errors.New("유효하지 않은 입력입니다")
)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
}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
}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
}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)
}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("서버 종료 완료")
}Graceful Shutdown은 프로덕션 서버의 필수 요소입니다. SIGINT/SIGTERM 신호를 수신하면 새로운 요청은 거부하고, 처리 중인 요청이 완료될 때까지 대기한 후 서버를 종료합니다. 이를 통해 배포 중 요청 손실을 방지합니다.
# 빌드 스테이지
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"]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:distroless 이미지를 사용하면 셸도 없는 초소형 이미지를 만들 수 있습니다. Go 바이너리가 정적 링크되므로 별도의 C 라이브러리가 필요 없으며, 최종 이미지 크기는 15-25MB 수준으로 줄어듭니다. 보안 측면에서도 공격 표면이 최소화됩니다.
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을 정의합니다.
.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 down11장에 걸쳐 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 |
이 시리즈에서 구축한 프로젝트를 기반으로, 다음 단계로 Redis 캐싱, 메시지 큐(NATS, Kafka) 연동, OpenTelemetry 분산 추적, Kubernetes 배포 등을 추가해보세요. 실전 프로젝트에 하나씩 기능을 추가하면서 학습하는 것이 가장 효과적인 방법입니다.
이번 장에서 구축한 프로젝트의 핵심 설계 결정을 정리합니다.
이 글이 도움이 되셨나요?
Protocol Buffers 정의부터 Go에서의 gRPC 서버/클라이언트 구현, 유니어리/스트리밍 통신, 인터셉터, gRPC-Gateway를 통한 REST 변환, 헬스 체킹과 서비스 디스커버리를 다룹니다.
Go의 testing 패키지를 중심으로 테이블 기반 테스트, testify, httptest, 목(Mock) 생성, testcontainers-go 통합 테스트, 벤치마크, 퍼징 테스트 전략을 다룹니다.
Go 백엔드의 미들웨어 패턴, JWT 인증 구현, CORS 설정, 레이트 리미팅, 요청 유효성 검사, OpenAPI/Swagger 문서 생성, log/slog를 활용한 구조화된 로깅을 다룹니다.