TechFeedTechFeed
Programming Languages

Go 언어 REST API 서버 튜토리얼 — 환경 설정부터 DB 연동, Docker 배포까지

Go 1.22 기준 REST API 서버를 처음부터 만드는 단계별 튜토리얼. Chi 라우터, PostgreSQL 연동, JWT 미들웨어, 멀티스테이지 Docker 빌드까지 실제 동작하는 코드로 정리.

한 줄 요약: Go(Golang)로 프로덕션 수준의 REST API 서버를 처음부터 만드는 단계별 튜토리얼. 환경 설정부터 라우팅, DB 연동, 미들웨어, Docker 배포까지 실제 코드 중심으로 다룬다.

이 글이 필요한 사람
  • Python/Node.js 백엔드 개발자로 Go를 처음 도입하려는 분
  • Go 문법은 알지만 실제 서버 구조를 모르는 분
  • 프로덕션 수준 Go 서버의 레이어 구성을 배우고 싶은 분

※ Go 1.22 기준. 2026-04-05 작성.

Go 개발 환경 설정 — 설치부터 모듈 시스템까지

Go는 공식 사이트에서 바이너리를 내려받거나 패키지 매니저로 설치한다. macOS는 Homebrew, Linux는 공식 tar.gz, Windows는 MSI 설치 파일을 쓴다. Go 1.22 기준 최신 버전을 확인하고 설치한다.

설치 후 go version으로 확인하고, 프로젝트는 반드시 go mod init으로 모듈을 초기화한다. GOPATH는 Go 1.16 이후로 모듈 기반 워크플로우에서 거의 신경 쓸 필요가 없다.

Go 설치 및 프로젝트 초기화
# macOS brew install go # Linux (공식 tar.gz) wget https://go.dev/dl/go1.22.2.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.22.2.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin # 버전 확인 go version # go version go1.22.2 linux/amd64 # 프로젝트 생성 mkdir go-rest-api && cd go-rest-api go mod init github.com/yourname/go-rest-api # 디렉토리 구조 mkdir -p cmd/server internal/{handler,middleware,model,repository,service} pkg/database

프로젝트 구조는 Go 커뮤니티에서 통용되는 Standard Go Project Layout을 따른다. cmd/에 진입점, internal/에 내부 패키지(외부 import 불가), pkg/에 재사용 가능한 코드를 배치한다. 모노레포가 아닌 단일 서비스라면 이 구조로 충분하다.

Go 프로젝트 디렉토리 구조 다이어그램
Standard Go Project Layout — cmd, internal, pkg 레이어 구조

첫 HTTP 서버 — net/http와 Chi 라우터

Go 표준 라이브러리의 net/http는 프로덕션에서도 쓸 수 있을 만큼 완성도가 높다. 다만 라우팅 패턴 매칭과 미들웨어 체이닝이 불편해서 대부분의 실무 프로젝트는 Chi 또는 Gin을 사용한다.

Chi는 net/http를 그대로 사용하면서 라우팅만 추가하는 경량 라우터다. 표준 핸들러 인터페이스(http.Handler)를 그대로 사용하므로 나중에 다른 라이브러리로 교체하기 쉽다. 이 튜토리얼은 Chi를 기준으로 진행한다.

Chi 라우터로 기본 서버 구성 (cmd/server/main.go)
package main import ( "log" "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/yourname/go-rest-api/internal/handler" ) func main() { r := chi.NewRouter() // 기본 미들웨어 r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.RequestID) // 라우트 등록 r.Route("/api/v1", func(r chi.Router) { r.Route("/users", func(r chi.Router) { r.Get("/", handler.ListUsers) r.Post("/", handler.CreateUser) r.Get("/{id}", handler.GetUser) r.Put("/{id}", handler.UpdateUser) r.Delete("/{id}", handler.DeleteUser) }) }) // 헬스체크 r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) log.Println("서버 시작: :8080") log.Fatal(http.ListenAndServe(":8080", r)) } // go 패키지 설치 // go get github.com/go-chi/chi/v5
Gin vs Chi 선택 기준
Gin은 성능이 중요하고 JSON 바인딩을 많이 쓰는 API 서버에 유리하다. Chi는 net/http 호환성을 유지하면서 미들웨어 생태계를 그대로 활용할 때 유리하다. 대부분의 실무 프로젝트에서 성능 차이는 무의미하므로, 팀이 더 익숙한 쪽을 선택하면 된다.

JSON 요청·응답 처리와 모델 정의

Go에서 JSON 처리는 encoding/json 표준 패키지로 충분하다. 구조체 태그(json:"field_name")로 직렬화 이름을 지정하고, omitempty로 빈 값 생략, -로 필드 제외를 제어한다.

요청 바디는 json.NewDecoder(r.Body).Decode(&input)로 파싱하고, 응답은 json.NewEncoder(w).Encode(data)로 내보낸다. 반복 패턴이므로 헬퍼 함수로 추출하면 코드가 깔끔해진다.

모델 정의 및 JSON 핸들러 (internal/handler/user.go)
package handler import ( "encoding/json" "net/http" "strconv" "github.com/go-chi/chi/v5" ) // 모델 정의 type User struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` // 응답에서 패스워드 제외 Password string `json:"-"` } type CreateUserRequest struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` } type APIResponse struct { Data any `json:"data,omitempty"` Error string `json:"error,omitempty"` Message string `json:"message,omitempty"` } // JSON 응답 헬퍼 func respond(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func respondError(w http.ResponseWriter, status int, msg string) { respond(w, status, APIResponse{Error: msg}) } // 핸들러 func CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "잘못된 요청 형식입니다") return } if req.Name == "" || req.Email == "" { respondError(w, http.StatusUnprocessableEntity, "name과 email은 필수입니다") return } // 실제 서비스 레이어 호출 (다음 섹션에서 구현) user := User{ID: 1, Name: req.Name, Email: req.Email} respond(w, http.StatusCreated, APIResponse{Data: user}) } func GetUser(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { respondError(w, http.StatusBadRequest, "유효하지 않은 ID입니다") return } // 서비스 레이어 호출 _ = id user := User{ID: id, Name: "홍길동", Email: "hong@example.com"} respond(w, http.StatusOK, APIResponse{Data: user}) } func ListUsers(w http.ResponseWriter, r *http.Request) { users := []User{ {ID: 1, Name: "홍길동", Email: "hong@example.com"}, } respond(w, http.StatusOK, APIResponse{Data: users}) } func UpdateUser(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotImplemented, "미구현") } func DeleteUser(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotImplemented, "미구현") }
Go REST API 레이어드 아키텍처 구조도
Handler → Service → Repository 레이어 분리 패턴

데이터베이스 연동 — database/sql + pgx

Go의 DB 연동은 표준 인터페이스 database/sql을 통해 드라이버를 교체할 수 있다. PostgreSQL은 pgx 드라이버가 사실상 표준이다. ORM보다 sqlx(표준 sql 확장)나 pgx 직접 사용을 선호하는 팀이 많다.

연결 풀은 sql.DB가 자동으로 관리한다. SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime 세 값만 조정하면 대부분의 프로덕션 환경에서 충분하다.

PostgreSQL 연결 풀 설정 (pkg/database/postgres.go)
package database import ( "database/sql" "fmt" "os" "time" _ "github.com/jackc/pgx/v5/stdlib" // pgx를 database/sql 드라이버로 등록 ) type Config struct { Host string Port string User string Password string DBName string SSLMode string } func NewPostgres(cfg Config) (*sql.DB, error) { dsn := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, ) db, err := sql.Open("pgx", dsn) if err != nil { return nil, fmt.Errorf("DB 연결 실패: %w", err) } // 연결 풀 설정 db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(5 * time.Minute) if err := db.Ping(); err != nil { return nil, fmt.Errorf("DB ping 실패: %w", err) } return db, nil } func ConfigFromEnv() Config { return Config{ Host: getEnv("DB_HOST", "localhost"), Port: getEnv("DB_PORT", "5432"), User: getEnv("DB_USER", "postgres"), Password: os.Getenv("DB_PASSWORD"), DBName: getEnv("DB_NAME", "goapi"), SSLMode: getEnv("DB_SSL_MODE", "disable"), } } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // go get github.com/jackc/pgx/v5
유저 Repository 구현 (internal/repository/user.go)
package repository import ( "context" "database/sql" "fmt" ) type User struct { ID int Name string Email string } type UserRepository struct { db *sql.DB } func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) { u := &User{} err := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id, ).Scan(&u.ID, &u.Name, &u.Email) if err == sql.ErrNoRows { return nil, fmt.Errorf("유저 없음: id=%d", id) } if err != nil { return nil, fmt.Errorf("유저 조회 실패: %w", err) } return u, nil } func (r *UserRepository) Create(ctx context.Context, name, email, hashedPw string) (*User, error) { u := &User{} err := r.db.QueryRowContext(ctx, "INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING id, name, email", name, email, hashedPw, ).Scan(&u.ID, &u.Name, &u.Email) if err != nil { return nil, fmt.Errorf("유저 생성 실패: %w", err) } return u, nil } func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]User, error) { rows, err := r.db.QueryContext(ctx, "SELECT id, name, email FROM users ORDER BY id DESC LIMIT $1 OFFSET $2", limit, offset, ) if err != nil { return nil, fmt.Errorf("유저 목록 조회 실패: %w", err) } defer rows.Close() var users []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil { return nil, err } users = append(users, u) } return users, rows.Err() }

미들웨어 구현 — 로깅, JWT 인증, CORS

Chi 미들웨어는 func(http.Handler) http.Handler 시그니처를 따른다. 표준 인터페이스이므로 직접 만든 미들웨어와 서드파티 미들웨어를 자유롭게 조합할 수 있다.

JWT 인증은 golang-jwt/jwt 패키지를 사용한다. 토큰 검증 미들웨어를 만들고, 보호된 라우트 그룹에만 적용하면 공개 엔드포인트와 인증 필요 엔드포인트를 명확히 분리할 수 있다.

JWT 인증 미들웨어 (internal/middleware/auth.go)
package middleware import ( "context" "net/http" "strings" "github.com/golang-jwt/jwt/v5" ) type contextKey string const UserIDKey contextKey = "userID" var jwtSecret = []byte(mustGetEnv("JWT_SECRET")) func mustGetEnv(key string) string { v := os.Getenv(key) if v == "" { panic("필수 환경변수 누락: " + key) } return v } // JWT 인증 미들웨어 func Auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { http.Error(w, "인증 토큰이 없습니다", http.StatusUnauthorized) return } tokenStr := strings.TrimPrefix(authHeader, "Bearer ") token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return jwtSecret, nil }) if err != nil || !token.Valid { http.Error(w, "유효하지 않은 토큰", http.StatusUnauthorized) return } claims, ok := token.Claims.(jwt.MapClaims) if !ok { http.Error(w, "토큰 파싱 실패", http.StatusUnauthorized) return } userID := int(claims["user_id"].(float64)) ctx := context.WithValue(r.Context(), UserIDKey, userID) next.ServeHTTP(w, r.WithContext(ctx)) }) } // 라우터에서 인증 그룹 적용 // r.Route("/api/v1/protected", func(r chi.Router) { // r.Use(middleware.Auth) // r.Get("/me", handler.GetMe) // }) // go get github.com/golang-jwt/jwt/v5
CORS 설정 주의
Chi의 cors 미들웨어(github.com/go-chi/cors)를 사용할 때, 개발 환경에서 AllowedOrigins: []string{"*"}로 열어두는 경우가 많다. 프로덕션에서는 반드시 실제 프론트엔드 도메인만 명시할 것. AllowCredentials: trueAllowedOrigins: []string{"*"}는 함께 사용할 수 없다 — CORS 스펙 위반.
Go HTTP 미들웨어 체인 요청 흐름 다이어그램
Chi 미들웨어 체인 — Logger → Recoverer → Auth → Handler 순서

에러 처리 표준화 — sentinel error와 wrapping

Go에서 에러는 값이다. error 인터페이스를 구현한 어떤 타입도 에러가 될 수 있다. 실무에서 중요한 패턴은 두 가지다: sentinel error(정해진 에러 값 비교)와 error wrapping(fmt.Errorf("...: %w", err)로 컨텍스트 추가).

레이어별로 에러를 wrapping하면서 올려보내고, HTTP 핸들러에서 에러 타입을 판별해 상태 코드를 결정한다. 이 방식은 서비스 레이어와 HTTP 레이어를 명확히 분리해 준다.

커스텀 에러 타입과 핸들러 에러 처리
package apierr import "net/http" // 도메인 에러 타입 type AppError struct { Code int Message string } func (e *AppError) Error() string { return e.Message } // 미리 정의된 에러 var ( ErrNotFound = &AppError{Code: http.StatusNotFound, Message: "리소스를 찾을 수 없습니다"} ErrBadRequest = &AppError{Code: http.StatusBadRequest, Message: "잘못된 요청입니다"} ErrConflict = &AppError{Code: http.StatusConflict, Message: "이미 존재하는 리소스입니다"} ErrInternal = &AppError{Code: http.StatusInternalServerError, Message: "서버 오류가 발생했습니다"} ) // 핸들러에서 에러 처리 func HandleError(w http.ResponseWriter, err error) { var appErr *AppError if errors.As(err, &appErr) { respond(w, appErr.Code, APIResponse{Error: appErr.Message}) return } // 예상치 못한 에러 — 내부 로그는 남기되 클라이언트에는 일반 메시지 log.Printf("내부 오류: %v", err) respond(w, http.StatusInternalServerError, APIResponse{Error: "서버 오류가 발생했습니다"}) } // 서비스 레이어에서 사용 func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) { u, err := s.repo.FindByID(ctx, id) if err != nil { // err가 "유저 없음"이면 NotFound 반환 if strings.Contains(err.Error(), "유저 없음") { return nil, apierr.ErrNotFound } return nil, fmt.Errorf("GetUser: %w", err) } return u, nil }

테스트 작성 — 단위 테스트와 통합 테스트

Go는 테스트 파일을 같은 패키지 안에 _test.go로 두는 것이 관례다. 표준 라이브러리 testing만으로 충분히 테스트를 작성할 수 있고, testify를 추가하면 assert/require 헬퍼로 코드가 줄어든다.

HTTP 핸들러 테스트는 net/http/httptest로 실제 HTTP 없이 테스트할 수 있다. DB가 필요한 통합 테스트는 testcontainers-go로 테스트용 PostgreSQL 컨테이너를 띄우거나, Docker Compose로 관리한다.

핸들러 단위 테스트 (internal/handler/user_test.go)
package handler_test import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateUser_Success(t *testing.T) { body := map[string]string{ "name": "홍길동", "email": "hong@example.com", } bodyBytes, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() CreateUser(w, req) resp := w.Result() assert.Equal(t, http.StatusCreated, resp.StatusCode) var result APIResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) assert.Empty(t, result.Error) } func TestCreateUser_MissingEmail(t *testing.T) { body := map[string]string{"name": "홍길동"} bodyBytes, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() CreateUser(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) } // 테스트 실행 // go test ./internal/handler/... -v // go test ./... -race -count=1 (race condition 감지) // go test ./... -coverprofile=coverage.out // go tool cover -html=coverage.out (HTML 커버리지 리포트) // go get github.com/stretchr/testify

Docker 멀티스테이지 빌드와 배포

Go 바이너리는 정적 링크 단일 파일로 빌드되므로 Docker 이미지를 극단적으로 작게 만들 수 있다. 멀티스테이지 빌드로 빌더 이미지(golang:1.22-alpine)에서 컴파일하고, 최종 이미지는 scratch(빈 이미지) 또는 gcr.io/distroless/static을 사용하면 50MB 이하 이미지가 가능하다.

환경 변수는 .env 파일과 godotenv 패키지로 로컬 개발에 로드하고, 프로덕션에서는 컨테이너 런타임의 시크릿 관리(AWS Secrets Manager, Vault 등)를 사용한다.

Dockerfile 멀티스테이지 빌드 + docker-compose.yml
# Dockerfile FROM golang:1.22-alpine AS builder 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 # 최종 이미지 — distroless (CA 인증서 포함) FROM gcr.io/distroless/static:nonroot COPY --from=builder /app/server /server EXPOSE 8080 ENTRYPOINT ["/server"] --- # docker-compose.yml (로컬 개발) version: "3.9" services: api: build: . ports: - "8080:8080" environment: - DB_HOST=postgres - DB_PORT=5432 - DB_USER=postgres - DB_PASSWORD=secret - DB_NAME=goapi - DB_SSL_MODE=disable - JWT_SECRET=local-dev-secret-change-in-prod depends_on: postgres: condition: service_healthy postgres: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: secret POSTGRES_DB: goapi healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata: # 빌드 및 실행 # docker compose up --build # docker compose down -v (볼륨 포함 정리)
자주 막히는 케이스 3가지

1. CGO_ENABLED=0 없이 scratch 이미지 사용
libc 의존성 때문에 컨테이너가 뜨지 않는다. Go 바이너리를 scratch에 올리려면 반드시 CGO_ENABLED=0으로 정적 빌드해야 한다.

2. sql.ErrNoRows를 처리하지 않은 경우
단일 행 조회 시 레코드가 없으면 sql.ErrNoRows가 반환된다. 이를 처리하지 않으면 500 에러가 된다. errors.Is(err, sql.ErrNoRows)로 분기할 것.

3. context 미전파
핸들러에서 받은 r.Context()를 서비스→레포지토리까지 반드시 전달해야 한다. context 없이 DB 쿼리를 호출하면 요청 취소/타임아웃이 DB 레벨까지 전파되지 않는다.

정리 및 다음 단계

이 튜토리얼에서 다룬 내용을 정리하면 다음과 같다.

  • 환경 설정: Go 1.22 설치, go mod init, 프로젝트 레이아웃
  • 라우팅: Chi 라우터로 RESTful 엔드포인트 정의
  • JSON 처리: 구조체 태그, encoding/json, 응답 헬퍼
  • DB 연동: pgx + database/sql, 연결 풀, Repository 패턴
  • 미들웨어: JWT 인증, CORS, 로깅
  • 에러 처리: sentinel error, wrapping, HTTP 상태 코드 매핑
  • 테스트: httptest, testify, 커버리지
  • 배포: 멀티스테이지 Docker 빌드, docker-compose

다음 단계로는 DB 마이그레이션(golang-migrate), Swagger 자동화(swaggo/swag), 구조화된 로깅(slog 표준 라이브러리, zap), 메트릭 수집(prometheus/client_golang) 등을 추가하면 프로덕션 서버의 완성도가 높아진다.

GoGolangREST APIChiPostgreSQLJWTDocker백엔드 튜토리얼Go 입문net/http

관련 도구

관련 포스트

Kotlin + Spring Boot 3 실전 튜토리얼 — Coroutines, JWT 인증, PostgreSQL, Docker 배포2026-04-26Axum 실전 튜토리얼 — Rust 비동기 웹 서버, JWT 인증, PostgreSQL 연동, Docker 배포2026-04-19Rust vs Go — 2026년 백엔드 실무 선택 가이드2026-03-20Rust vs Go 2026 비교 — 성능, 생산성, 생태계 총정리2026-03-22