Go로 웹 API 만들기: Gin 라우팅, GORM, JWT 인증, 미들웨어, 동시성과 배포

이 글의 핵심

Go 웹 개발 자료는 프레임워크 소개나 문법 설명 한쪽에 치우친 경우가 많습니다. 이 글은 설치부터 인증이 붙은 CRUD API를 컨테이너로 배포하기까지 한 프로젝트 흐름으로 이어 보여주고, 구현 체크리스트와 Go를 백엔드로 고를 때 자주 받는 학습 곡선과 프로덕션 사용 질문에 답합니다.

이 글의 핵심

Go로 웹 API를 만드는 흐름을 정리한 글입니다. Gin 프레임워크, GORM, JWT 인증, Middleware, 동시성, 배포를 예제로 다룹니다.

실무에서 마주치는 문제들

CPU 집약적 작업이 느려요

Node.js는 JavaScript를 하나의 이벤트 루프 스레드에서 실행합니다. I/O 대기는 비동기로 잘 처리하지만, 이미지 리사이즈나 대용량 JSON 가공처럼 CPU를 오래 쓰는 작업이 들어오면 그동안 다른 요청이 모두 멈춥니다. 워커 스레드를 쓰면 해결되지만 코드가 복잡해집니다. Go는 요청마다 고루틴이 할당되고, 런타임이 고루틴을 여러 CPU 코어의 OS 스레드에 나눠 실행하므로 별도 설정 없이 모든 코어를 씁니다.

메모리를 너무 많이 써요

Go 고루틴은 수 KB의 작은 스택으로 시작해 필요할 때만 늘어나서, 동시 연결 수만 개를 고루틴 하나씩으로 처리해도 메모리 부담이 크지 않습니다. 컴파일된 바이너리는 런타임을 포함한 단일 실행 파일이라 컨테이너 이미지도 작게 만들 수 있습니다.

타입 안전성이 부족해요

정적 타입 언어라 필드 이름 오타나 잘못된 타입 전달은 컴파일 단계에서 잡힙니다. TypeScript와 달리 타입 정보가 런타임까지 유지되므로, JSON을 구조체로 바인딩할 때 타입이 맞지 않으면 에러로 거부됩니다.

대신 Go에는 Node.js 생태계만큼 “다 해 주는” 프레임워크가 적고, 에러를 if err != nil로 매번 직접 처리해야 해서 코드가 길어집니다. 제네릭도 1.18에야 들어와 라이브러리들이 아직 적극적으로 쓰지 않습니다. 팀이 이 명시적인 스타일에 익숙해지는 데 시간이 든다는 점도 선택 기준에 넣어야 합니다.


Go란?

핵심 특징

Go (Golang)는 Google이 만든 컴파일 언어로, 네트워크 서버와 인프라 도구에 많이 쓰입니다. Docker, Kubernetes, Terraform이 Go로 작성되었습니다. 주요 장점:

  • 빠른 성능: 네이티브 코드로 컴파일, 가비지 컬렉터 포함
  • 동시성: 고루틴과 채널
  • 간단한 문법: 키워드가 적고 코드 스타일이 gofmt로 통일됨
  • 빠른 컴파일: 대규모 프로젝트도 빠름
  • 단일 바이너리: 배포 간편

초당 처리량 비교는 벤치마크 조건(핸들러가 하는 일, DB 호출 여부, 하드웨어)에 따라 몇 배씩 달라져서 숫자 하나로 말하기 어렵습니다. 실제 API 서버는 대부분 DB와 외부 API 대기가 응답 시간을 좌우하므로, 언어 자체의 속도보다는 커넥션 풀 설정, 쿼리, 캐시가 체감 성능을 결정합니다. Go의 실질적인 이점은 CPU 작업이 섞여도 요청 처리가 막히지 않는 동시성 모델과, 부하가 올라가도 메모리 사용량과 지연 시간이 비교적 예측 가능하다는 점에 있습니다.


설치

운영체제별 패키지 매니저로 설치한 뒤 go version으로 버전이 출력되는지 확인합니다.

# Windows (Chocolatey)
choco install golang
# macOS
brew install go
# 확인
go version

Gin 프레임워크

설치

go mod init myapp
go get -u github.com/gin-gonic/gin

go mod init myapp은 모듈 이름을 myapp으로 정하고 go.mod를 만듭니다. 이 이름이 이후 import "myapp/models"처럼 내부 패키지를 가져올 때의 경로 접두사가 됩니다. 공개 저장소에 올릴 모듈이라면 github.com/사용자/myapp처럼 실제 경로로 짓는 것이 관례입니다. go get의 -u는 해당 패키지뿐 아니라 의존성까지 최신 마이너 버전으로 올리므로, 기존 프로젝트에서 무심코 쓰면 다른 라이브러리 버전이 함께 바뀝니다. 새 패키지를 추가할 때는 -u 없이 쓰는 편이 안전합니다.

Go 표준 라이브러리 net/http만으로도 웹 서버를 만들 수 있고, Go 1.22부터는 http.HandleFunc("GET /users/{id}", ...)처럼 메서드와 경로 매개변수를 지원해 간단한 API에는 프레임워크가 필요 없어졌습니다. Gin은 여기에 라우트 그룹, 미들웨어 체인, JSON 바인딩과 검증, 에러 복구를 더해 반복 코드를 줄여 줍니다.

기본 서버

// main.go
package main
import (
	"github.com/gin-gonic/gin"
	"net/http"
)
func main() {
	r := gin.Default()
	r.GET("/", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": "Hello World",
		})
	})
	r.GET("/users/:id", func(c *gin.Context) {
		id := c.Param("id")
		c.JSON(http.StatusOK, gin.H{
			"user_id": id,
		})
	})
	r.Run(":8080")
}

gin.Default()는 요청 로그를 남기는 Logger와, 핸들러에서 panic이 나도 서버가 죽지 않고 500을 돌려주는 Recovery 미들웨어가 기본으로 붙은 엔진을 만듭니다. gin.H는 map[string]any의 별칭으로 간단한 JSON 응답을 만들 때 씁니다. c.Param("id")는 경로의 :id 부분을 문자열로 돌려주므로, 숫자로 쓰려면 strconv.Atoi로 변환하고 에러를 처리해야 합니다.

실행하면 콘솔에 [WARNING] Running in "debug" mode. Switch to "release" mode in production. 경고가 나옵니다. 디버그 모드는 등록된 라우트를 모두 출력하는 등 개발용 동작이 켜져 있으므로, 운영 환경에서는 GIN_MODE=release 환경 변수를 설정합니다. r.Run()은 내부적으로 http.ListenAndServe를 호출하는데 이 방식에는 읽기·쓰기 타임아웃이 없어서, 느린 클라이언트가 연결을 붙잡고 있으면 고루틴과 연결이 계속 쌓입니다. 운영 서버라면 http.Server{Addr: ":8080", Handler: r, ReadTimeout: ..., WriteTimeout: ...}를 직접 만들고, Shutdown(ctx)로 진행 중인 요청을 마무리한 뒤 종료하는 graceful shutdown을 구현하는 것이 좋습니다.


GORM (ORM)

설치

go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres

모델 정의

// models/user.go
// 패키지 선언
package models
import "gorm.io/gorm"
type User struct {
	gorm.Model
	Name  string `json:"name"`
	Email string `json:"email" gorm:"uniqueIndex"`
	Age   int    `json:"age"`
}

gorm.Model을 임베드하면 ID, CreatedAt, UpdatedAt, DeletedAt 네 필드가 자동으로 들어갑니다. 백틱 안의 구조체 태그는 라이브러리에 전달하는 메타데이터로, json:"name"은 JSON 필드 이름을, gorm:"uniqueIndex"는 이 컬럼에 유니크 인덱스를 만들라는 지시입니다. gorm.Model의 필드에는 json 태그가 없어서 응답 JSON에 "ID", "CreatedAt"처럼 대문자 이름으로 나오고, DeletedAt까지 노출됩니다. API 응답 모양을 제어하려면 모델을 그대로 내보내지 말고 응답용 구조체로 옮겨 담는 것이 좋습니다.

DeletedAt이 있으면 GORM은 소프트 삭제를 합니다. Delete를 호출해도 행이 지워지지 않고 deleted_at에 시각만 기록되며, 이후 조회에서 자동으로 제외됩니다. 실수로 지운 데이터를 복구할 수 있다는 장점이 있지만, 삭제된 사용자의 이메일이 유니크 인덱스에 남아 있어서 같은 이메일로 재가입하면 duplicate key value violates unique constraint 에러가 나는 문제가 흔합니다. 이 경우 (email, deleted_at) 복합 인덱스나 부분 인덱스를 고려해야 합니다.

DB 연결

// db/db.go
package db
import (
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"myapp/models"
)
var DB *gorm.DB
func Connect() {
	dsn := "host=localhost user=postgres password=secret dbname=mydb port=5432"
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
	if err != nil {
		panic("Failed to connect to database")
	}
	db.AutoMigrate(&models.User{})
	DB = db
}

gorm.Open은 내부적으로 database/sql의 커넥션 풀을 만들고, 이 *gorm.DB는 여러 고루틴이 동시에 써도 안전하므로 앱 전체에서 하나를 공유합니다. 요청마다 새로 Open하면 커넥션이 계속 늘어나 DB의 too many connections 에러로 이어집니다. 기본 풀 설정은 최대 연결 수 제한이 없으므로, sqlDB, _ := db.DB() 후 sqlDB.SetMaxOpenConns(25), SetConnMaxLifetime(...)처럼 DB 서버가 감당할 수 있는 범위로 제한하는 것이 운영의 기본입니다.

AutoMigrate는 구조체를 보고 테이블과 컬럼, 인덱스를 추가만 합니다. 필드를 지우거나 타입을 바꿔도 기존 컬럼은 삭제·변경되지 않고, 어떤 변경이 적용됐는지 기록도 남지 않습니다. 개발 초기에는 편하지만, 운영 DB라면 golang-migrate 같은 도구로 버전 관리되는 SQL 마이그레이션을 쓰는 편이 안전합니다. DSN에 비밀번호를 하드코딩한 부분은 예제용이며, 실제로는 os.Getenv("DATABASE_URL")로 환경 변수에서 읽어야 합니다. panic으로 종료하는 대신 log.Fatalf("db connect: %v", err)로 원인이 되는 에러를 함께 남기면 연결 실패 원인을 찾기 쉽습니다.


CRUD API

// main.go
package main
import (
	"myapp/db"
	"myapp/models"
	"net/http"
	"github.com/gin-gonic/gin"
)
func main() {
	db.Connect()
	r := gin.Default()
	// 모든 사용자 조회
	r.GET("/users", func(c *gin.Context) {
		var users []models.User
		db.DB.Find(&users)
		c.JSON(http.StatusOK, users)
	})
	// 단일 사용자 조회
	r.GET("/users/:id", func(c *gin.Context) {
		var user models.User
		if err := db.DB.First(&user, c.Param("id")).Error; err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
			return
		}
		c.JSON(http.StatusOK, user)
	})
	// 사용자 생성
	r.POST("/users", func(c *gin.Context) {
		var user models.User
		if err := c.ShouldBindJSON(&user); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		db.DB.Create(&user)
		c.JSON(http.StatusCreated, user)
	})
	// 사용자 업데이트
	r.PUT("/users/:id", func(c *gin.Context) {
		var user models.User
		if err := db.DB.First(&user, c.Param("id")).Error; err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
			return
		}
		if err := c.ShouldBindJSON(&user); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		db.DB.Save(&user)
		c.JSON(http.StatusOK, user)
	})
	// 사용자 삭제
	r.DELETE("/users/:id", func(c *gin.Context) {
		if err := db.DB.Delete(&models.User{}, c.Param("id")).Error; err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
	})
	r.Run(":8080")
}

c.ShouldBindJSON(&user)은 요청 본문을 구조체로 디코딩하고, 구조체에 binding:"required,email" 같은 태그가 있으면 검증까지 합니다. Should가 붙은 메서드는 에러를 반환만 하고, BindJSON은 실패 시 400을 자동으로 쓰고 이후 응답 설정이 경고를 냅니다. 응답 형식을 직접 제어하려면 이 예제처럼 Should 계열을 씁니다. 현재 User 모델에는 binding 태그가 없어서 이름이 비어 있거나 이메일 형식이 틀려도 그대로 저장됩니다.

이 CRUD 코드는 흐름을 보여 주기 위한 것이라 실무에 옮기기 전에 고칠 곳이 여럿입니다.

  • 에러 무시: db.DB.Find(&users)와 db.DB.Create(&user)의 .Error를 확인하지 않습니다. 이메일 중복으로 Create가 실패해도 201과 함께 ID가 0인 사용자가 응답됩니다.
  • 대량 할당: PUT에서 기존 레코드에 요청 본문을 그대로 바인딩하므로, 클라이언트가 JSON에 "ID": 5를 넣으면 Save가 다른 사용자의 행을 덮어씁니다. 요청용 구조체(UpdateUserInput)를 따로 두고 허용된 필드만 복사해야 합니다.
  • 삭제 확인: GORM의 Delete는 대상 행이 없어도 에러를 반환하지 않으므로, 없는 ID를 지워도 “User deleted”가 응답됩니다. result.RowsAffected == 0으로 404를 판단해야 합니다.
  • Save의 동작: Save는 모든 필드를 업데이트하므로, 본문에서 빠진 필드는 0값으로 덮어써질 수 있습니다. 일부 필드만 바꾸려면 Updates를 쓰되, Updates는 구조체의 0값 필드(빈 문자열, 0)를 무시한다는 점도 알아 둬야 합니다.
  • 목록 크기: Find로 전체 테이블을 한 번에 가져오면 사용자가 늘수록 응답이 커지므로 Limit/Offset 페이지네이션이 필요합니다.

First(&user, c.Param("id"))처럼 문자열 ID를 넘기는 방식은 GORM이 기본 키 조건으로 해석하지만, 숫자가 아닌 입력이 SQL 조건으로 해석될 여지를 줄이려면 먼저 strconv.ParseUint로 변환해 두는 편이 명확합니다. 또 First는 레코드가 없을 때 gorm.ErrRecordNotFound를 돌려주고 DB 연결 오류도 같은 err로 오므로, errors.Is(err, gorm.ErrRecordNotFound)로 구분해 404와 500을 나눠야 장애가 “사용자 없음”으로 가려지지 않습니다.


JWT 인증

설치

go get -u github.com/golang-jwt/jwt/v5

JWT 생성

// auth/jwt.go
package auth
import (
	"time"
	"github.com/golang-jwt/jwt/v5"
)
var jwtSecret = []byte("your-secret-key")
type Claims struct {
	UserID uint   `json:"user_id"`
	Email  string `json:"email"`
	jwt.RegisteredClaims
}
func GenerateToken(userID uint, email string) (string, error) {
	claims := Claims{
		UserID: userID,
		Email:  email,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}
func ValidateToken(tokenString string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		return jwtSecret, nil
	})
	if err != nil {
		return nil, err
	}
	if claims, ok := token.Claims.(*Claims); ok && token.Valid {
		return claims, nil
	}
	return nil, err
}

GenerateToken은 사용자 ID와 이메일, 만료 시각(ExpiresAt)을 담은 클레임을 HS256으로 서명합니다. JWT의 내용은 암호화가 아니라 Base64 인코딩일 뿐이라 누구나 디코딩해 읽을 수 있으므로, 비밀번호나 민감한 정보는 넣지 않아야 합니다. 서명은 “이 토큰을 서버가 발급했고 변조되지 않았다”는 것만 보증합니다.

ValidateToken에는 보안상 중요한 확인이 빠져 있습니다. 키 함수에서 토큰의 서명 알고리즘을 검사하지 않고 비밀키를 돌려주고 있는데, JWT 라이브러리 관련 취약점 사례 상당수가 헤더의 alg 값을 믿어서 생겼습니다. jwt.ParseWithClaims(tokenString, &Claims{}, keyFunc, jwt.WithValidMethods([]string{"HS256"}))처럼 허용할 알고리즘을 명시하는 것이 권장됩니다. jwt/v5는 exp 만료를 자동으로 검사하므로 만료된 토큰은 token has invalid claims: token is expired 에러로 거부됩니다. 마지막 줄의 return nil, err는 err가 nil인 상태에서 토큰이 유효하지 않은 경우 nil, nil을 반환할 수 있어, 호출하는 쪽이 claims가 nil인데 에러도 없는 상황을 만날 수 있습니다. 명시적인 에러를 반환하는 편이 안전합니다.

jwtSecret을 코드에 하드코딩하면 저장소에 접근할 수 있는 누구나 토큰을 위조할 수 있습니다. 환경 변수나 비밀 관리 서비스에서 충분히 긴 무작위 값을 읽어 오고, 시작할 때 값이 비어 있거나 너무 짧으면 서버가 뜨지 않게 막아야 합니다. 환경 변수 설정을 빠뜨린 채 배포되어 추측하기 쉬운 키로 운영되는 사고를 막기 위해서입니다.

Middleware

// middleware/auth.go
package middleware
import (
	"myapp/auth"
	"net/http"
	"strings"
	"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
			c.Abort()
			return
		}
		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
		claims, err := auth.ValidateToken(tokenString)
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
			c.Abort()
			return
		}
		c.Set("user_id", claims.UserID)
		c.Set("email", claims.Email)
		c.Next()
	}
}

사용

r.GET("/protected", middleware.AuthMiddleware(), func(c *gin.Context) {
	userID := c.GetUint("user_id")
	c.JSON(http.StatusOK, gin.H{
		"message": "Protected route",
		"user_id": userID,
	})
})

Gin 미들웨어는 gin.HandlerFunc를 반환하는 함수입니다. 검증에 실패하면 응답을 쓴 뒤 c.Abort()로 이후 핸들러가 실행되지 않게 막고, 성공하면 c.Set으로 사용자 정보를 컨텍스트에 넣고 c.Next()로 다음 단계로 넘깁니다. c.Abort()를 빠뜨리고 return만 하면 이 함수는 끝나지만 체인의 다음 핸들러는 그대로 실행되어, 인증 실패 응답 뒤에 보호된 데이터까지 이어서 쓰는 심각한 버그가 됩니다. Gin에서 인증을 처음 구현할 때 가장 조심해야 할 부분입니다.

strings.TrimPrefix(authHeader, "Bearer ")는 접두사가 없으면 원래 문자열을 그대로 돌려주므로, Bearer 없이 토큰만 보낸 요청도 통과합니다. 형식을 엄격히 하려면 strings.HasPrefix로 먼저 확인합니다. c.GetUint("user_id")는 저장된 값이 uint가 아니면 조용히 0을 돌려주므로, c.Set 할 때의 타입과 꺼낼 때의 타입이 일치해야 합니다. 보호할 라우트가 많다면 api := r.Group("/api", middleware.AuthMiddleware())처럼 그룹에 미들웨어를 걸어 두면 라우트마다 적는 누락을 막을 수 있습니다.


동시성

고루틴

func processUsers(users []User) {
	for _, user := range users {
		go func(u User) {
			// 비동기 처리
			sendEmail(u.Email)
		}(user)
	}
}

go 키워드 하나로 함수를 새 고루틴에서 실행합니다. 루프 변수를 매개변수 u로 넘긴 것은 Go 1.21까지의 함정 때문입니다. 예전에는 루프 변수가 반복 전체에서 하나만 존재해서, 클로저가 user를 직접 캡처하면 대부분의 고루틴이 마지막 사용자에게 메일을 보냈습니다. Go 1.22부터는 반복마다 새 변수가 만들어져 이 문제가 사라졌지만, go.mod의 go 버전이 1.22 미만이면 예전 동작이 유지되므로 매개변수로 넘기는 습관은 여전히 유효합니다.

이 코드는 “보내고 잊는” 방식이라 운영 코드로는 부족합니다. 사용자가 1만 명이면 고루틴 1만 개가 동시에 메일 서버에 연결해 상대 서버의 속도 제한에 걸리고, 함수는 발송 완료를 기다리지 않고 바로 반환하므로 실패를 알 수도 없습니다. HTTP 핸들러에서 이렇게 띄운 고루틴은 요청이 끝난 뒤에도 계속 돌고, 서버가 종료되면 도중에 끊깁니다. sync.WaitGroup으로 완료를 기다리고, 다음 예제처럼 워커 수를 제한하거나 golang.org/x/sync/errgroup의 SetLimit으로 동시 실행 수를 묶는 것이 기본입니다. 발송 자체가 중요하다면 고루틴보다 메시지 큐에 작업을 넣고 별도 워커가 재시도하게 하는 편이 안전합니다.

채널

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		results <- j * 2
	}
}
func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)
	// 3개의 워커 시작
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}
	// 작업 전송
	for j := 1; j <= 9; j++ {
		jobs <- j
	}
	close(jobs)
	// 결과 수신
	for a := 1; a <= 9; a++ {
		<-results
	}
}

워커 풀 패턴입니다. 워커 3개가 같은 jobs 채널에서 작업을 꺼내 가므로 동시에 실행되는 작업은 최대 3개로 제한됩니다. 매개변수 타입의 <-chan int는 받기 전용, chan<- int는 보내기 전용 채널로, 워커가 실수로 jobs에 값을 보내는 코드를 컴파일 단계에서 막아 줍니다. close(jobs)는 “더 보낼 작업이 없다”는 신호로, 이것이 있어야 워커의 for j := range jobs 루프가 끝납니다. 닫지 않으면 워커는 영원히 다음 작업을 기다리며 고루틴이 누수됩니다.

결과를 받는 루프가 정확히 9번 도는 것도 중요합니다. 결과를 덜 받으면 버퍼가 없는 채널에서는 워커가 보내기에서 막히고, 더 받으려 하면 main이 영원히 기다리다 fatal error: all goroutines are asleep - deadlock!으로 종료됩니다. 채널을 처음 다룰 때 가장 자주 만나는 에러가 이 데드락입니다. 결과 개수를 미리 알 수 없다면 WaitGroup으로 워커 종료를 기다린 뒤 별도 고루틴에서 close(results)를 호출하고, for r := range results로 받는 패턴을 씁니다. id 매개변수는 로그용으로 받았지만 쓰지 않았는데, Go는 사용하지 않는 지역 변수는 컴파일 에러로 만들지만 사용하지 않는 함수 매개변수는 허용합니다.


배포

Docker

# Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o main .
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]
docker build -t myapp .
docker run -p 8080:8080 myapp

멀티 스테이지 빌드로 Go 컴파일러가 든 golang 이미지에서 빌드하고, 결과 바이너리만 작은 alpine 이미지로 옮깁니다. 최종 이미지에는 Go 도구 체인이 없어 수백 MB가 수십 MB로 줄어듭니다. go.mod와 go.sum을 먼저 복사해 go mod download를 따로 실행하는 이유는 Docker 레이어 캐시 때문입니다. 소스 코드만 바뀌고 의존성이 그대로면 다운로드 단계가 캐시에서 재사용되어 빌드가 빨라집니다.

빌드 단계에서는 CGO_ENABLED=0 go build -ldflags="-s -w" -o main .처럼 cgo를 끄고 빌드하는 것이 일반적입니다. cgo가 켜진 채로 glibc 기반 이미지에서 빌드한 바이너리를 alpine(musl libc)에 옮기면 파일이 분명히 있는데도 exec ./main: no such file or directory라는 헷갈리는 에러로 실행되지 않습니다. 동적 링커를 찾지 못해서 나는 에러라, Go를 Docker로 처음 배포할 때 자주 겪습니다. cgo를 끈 정적 바이너리는 scratch나 distroless 같은 더 작은 이미지에서도 실행되지만, 외부 HTTPS를 호출한다면 CA 인증서(ca-certificates)와 시간대 데이터를 함께 넣어야 x509: certificate signed by unknown authority 에러를 피할 수 있습니다. WORKDIR /root/처럼 root로 실행하는 대신 USER 지시어로 일반 사용자로 실행하는 것도 권장됩니다.


정리 및 체크리스트

핵심 요약

  • Go: 동시성 모델이 강한 컴파일 언어
  • Gin: 라우팅·미들웨어·바인딩을 더한 웹 프레임워크
  • GORM: 소프트 삭제·AutoMigrate를 제공하는 ORM (에러 확인 필수)
  • JWT: 인증 구현
  • 고루틴: 동시성 처리
  • 단일 바이너리: 배포 간편

구현 체크리스트

  • Go 프로젝트 생성
  • Gin 서버 구현
  • GORM으로 DB 연결
  • CRUD API 구현
  • JWT 인증 구현
  • Middleware 작성
  • Docker 배포

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Go vs Node.js, 어떤 게 나은가요?

A. CPU 작업이 섞인 서버나 동시 연결이 많은 서비스, 컨테이너 이미지 크기와 메모리 사용량이 중요한 환경이라면 Go가 유리합니다. 프런트엔드와 코드·타입을 공유하고 싶거나, npm 생태계의 라이브러리를 많이 써야 하거나, 팀이 이미 TypeScript에 익숙하다면 Node.js가 생산성 면에서 나을 수 있습니다. I/O 위주의 일반적인 CRUD API는 둘 다 충분합니다.

Q. 학습 곡선이 가파른가요?

A. 문법 자체는 작아서 며칠이면 읽고 쓸 수 있습니다. 시간이 걸리는 부분은 포인터와 값 복사의 구분, 인터페이스를 암묵적으로 만족시키는 방식, 고루틴 누수와 데드락을 피하는 동시성 설계, 그리고 if err != nil 중심의 에러 처리 스타일입니다.

Q. 프론트엔드 개발도 가능한가요?

A. Go를 WebAssembly로 컴파일해 브라우저에서 실행할 수는 있지만 바이너리가 크고 DOM 연동이 번거로워 일반적인 선택은 아닙니다. 대신 html/template으로 서버 렌더링 HTML을 만들고 htmx 같은 도구를 붙이는 조합은 꽤 쓰입니다. 본격적인 SPA라면 프런트엔드는 JavaScript/TypeScript로 만드는 것이 현실적입니다.

Q. 이 글의 예제를 실제 서비스로 옮길 때 먼저 보완할 것은?

A. 서버 타임아웃과 graceful shutdown, GORM 에러 확인과 입력 구조체 분리, JWT 서명 알고리즘 검증과 비밀키 관리, 고루틴 수 제한을 먼저 챙겨야 합니다.