TechFeedTechFeed
Programming Languages

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

한 줄 요약: Go(Golang)로 프로덕션 수준의 REST API 서버를 처음부터 만드는 단계별 튜토리얼. 환경 설정부터 라우팅, DB 연동, 미들웨어, Docker 배포까지 실제 코드 중심으로 다룬다. Go는 공식 사이트에서 바이너리를 내려받거나 패키지 매니저로 설치한다. macOS는 Homebrew, Linux는 공식 tar.gz, Windows는 MSI 설치 파일을 쓴다.

by

한 줄 요약: 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

관련 도구

함께 보면 좋은 문제 해결

관련 포스트