Go는 공식 사이트에서 바이너리를 내려받거나 패키지 매니저로 설치한다. macOS는 Homebrew, Linux는 공식 tar.gz, Windows는 MSI 설치 파일을 쓴다. Go 1.22 기준 최신 버전을 확인하고 설치한다.
설치 후 go version으로 확인하고, 프로젝트는 반드시 go mod init으로 모듈을 초기화한다. GOPATH는 Go 1.16 이후로 모듈 기반 워크플로우에서 거의 신경 쓸 필요가 없다.
한 줄 요약: Go(Golang)로 프로덕션 수준의 REST API 서버를 처음부터 만드는 단계별 튜토리얼. 환경 설정부터 라우팅, DB 연동, 미들웨어, Docker 배포까지 실제 코드 중심으로 다룬다. Go는 공식 사이트에서 바이너리를 내려받거나 패키지 매니저로 설치한다. macOS는 Homebrew, Linux는 공식 tar.gz, Windows는 MSI 설치 파일을 쓴다.
한 줄 요약: Go(Golang)로 프로덕션 수준의 REST API 서버를 처음부터 만드는 단계별 튜토리얼. 환경 설정부터 라우팅, DB 연동, 미들웨어, Docker 배포까지 실제 코드 중심으로 다룬다.
※ Go 1.22 기준. 2026-04-05 작성.
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 표준 라이브러리의 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
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의 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() }
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 미들웨어(github.com/go-chi/cors)를 사용할 때, 개발 환경에서 AllowedOrigins: []string{"*"}로 열어두는 경우가 많다. 프로덕션에서는 반드시 실제 프론트엔드 도메인만 명시할 것. AllowCredentials: true와 AllowedOrigins: []string{"*"}는 함께 사용할 수 없다 — CORS 스펙 위반.
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
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 (볼륨 포함 정리)
CGO_ENABLED=0으로 정적 빌드해야 한다.sql.ErrNoRows가 반환된다. 이를 처리하지 않으면 500 에러가 된다. errors.Is(err, sql.ErrNoRows)로 분기할 것.r.Context()를 서비스→레포지토리까지 반드시 전달해야 한다. context 없이 DB 쿼리를 호출하면 요청 취소/타임아웃이 DB 레벨까지 전파되지 않는다.이 튜토리얼에서 다룬 내용을 정리하면 다음과 같다.
다음 단계로는 DB 마이그레이션(golang-migrate), Swagger 자동화(swaggo/swag), 구조화된 로깅(slog 표준 라이브러리, zap), 메트릭 수집(prometheus/client_golang) 등을 추가하면 프로덕션 서버의 완성도가 높아진다.