프레임워크가 필요한 순간
이 챕터에서 다루는 것
9-8에서 외부 의존성 없이 REST API를 만들었다. 잘 돌아갔다. 그런데 엔드포인트를 스무 개로 늘리면 무슨 일이 생기는가.
같은 엔드포인트 하나를 표준 라이브러리와 Gin으로 각각 만들어 응답이 같은지 확인하고, 줄어든 것과 늘어난 것을 센다.
문제 — 반복되는 다섯 단계
POST /signup 하나를 표준 라이브러리로 쓰면 이렇게 된다.
func signup(w http.ResponseWriter, r *http.Request) {
// (1) Content-Type 확인
if base, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";"); strings.TrimSpace(base) != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, "Content-Type은 application/json이어야 한다", nil)
return
}
// (2) 본문 크기 제한
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
// (3) 디코딩
var req SignupRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "본문을 읽을 수 없다", nil)
return
}
// (4) 검증 — 필드마다 if문
fields := map[string]string{}
if !strings.Contains(req.Email, "@") {
fields["email"] = "이메일 형식이 아니다"
}
if utf8.RuneCountInString(req.Password) < 8 {
fields["password"] = "8자 이상이어야 한다"
}
if n := utf8.RuneCountInString(strings.TrimSpace(req.Nickname)); n == 0 || n > 20 {
fields["nickname"] = "1자 이상 20자 이하여야 한다"
}
if req.Age < 14 || req.Age > 120 {
fields["age"] = "14 이상 120 이하여야 한다"
}
if len(fields) > 0 {
writeError(w, http.StatusUnprocessableEntity, "입력이 올바르지 않다", fields)
return
}
// (5) 응답 직렬화
writeJSON(w, http.StatusCreated, SignupResponse{
Email: req.Email,
Nickname: strings.TrimSpace(req.Nickname),
})
}
(1)(2)(3)(5)는 엔드포인트가 몇 개든 똑같다. 9-8에서 decodeBody와 writeJSON으로
묶어 낸 것이 바로 이 부분이다. 묶고 나면 각 핸들러에는 if !a.decodeBody(...) { return }
한 줄만 남는다. 여기까지는 표준 라이브러리만으로도 충분히 정리된다.
진짜로 남는 것은 (4)다. 필드가 넷이면 if문이 넷, 열이면 열이다. 그리고 이 if문들은
전부 "타입에는 쓸 수 없지만 값에는 걸려 있는 제약"을 서술한다. Email string이라는
타입 선언만으로는 "@가 들어 있어야 한다"를 표현할 수 없으니, 그 정보가 코드로 흘러넘친다.
프레임워크의 답 — 제약을 타입 옆에 적는다
Gin으로 같은 것을 쓴다.
// SignupRequest는 검증 규칙까지 태그에 적는다.
// rawway의 (4) 검증 블록 전체가 이 네 줄로 옮겨 왔다.
type SignupRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Nickname string `json:"nickname" binding:"required,min=1,max=20"`
Age int `json:"age" binding:"required,gte=14,lte=120"`
}
핸들러는 이렇게 줄어든다.
func signup(c *gin.Context) {
var req SignupRequest
// (1)(2)(3)(4)가 이 한 줄이다.
// Content-Type 확인, 디코딩, 검증까지 ShouldBindJSON이 한다.
// 본문 크기 제한은 여전히 직접 걸어야 한다 — Gin이 해 주지 않는다.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
if err := c.ShouldBindJSON(&req); err != nil {
var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, ErrorBody{
Message: "입력이 올바르지 않다",
Fields: translate(verrs),
})
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorBody{Message: "본문을 읽을 수 없다"})
return
}
c.JSON(http.StatusCreated, SignupResponse{
Email: req.Email,
Nickname: strings.TrimSpace(req.Nickname),
})
}
세어 보면 signup 함수 본문이 44줄에서 25줄이 됐다. 그런데 줄어든 것만 보면 안 된다.
대신 늘어난 것
// translate는 검증 실패를 사람이 읽을 메시지로 바꾼다.
// 이 함수는 프레임워크가 대신 써 주지 않는다. 규칙 선언은 짧아졌지만
// 메시지는 여전히 손으로 쓴다 — 이 거래를 10-3에서 자세히 본다.
func translate(verrs validator.ValidationErrors) map[string]string {
out := make(map[string]string, len(verrs))
for _, fe := range verrs {
key := strings.ToLower(fe.Field())
switch key {
case "email":
out[key] = "이메일 형식이 아니다"
case "password":
out[key] = "8자 이상이어야 한다"
case "nickname":
out[key] = "1자 이상 20자 이하여야 한다"
case "age":
out[key] = "14 이상 120 이하여야 한다"
default:
out[key] = "값이 올바르지 않다"
}
}
return out
}
19줄이다. 줄어든 19줄이 여기로 옮겨 왔다. 파일 전체로 보면 102줄에서 93줄, 9줄 줄었을 뿐이다.
다만 translate는 엔드포인트마다 다시 쓰지 않는다. 규칙 태그별로 한 번만 쓰면
나머지 엔드포인트는 태그만 붙이면 된다. 이것이 실제 이득의 정체다 — 첫 번째
엔드포인트에서는 손해이고, 열 번째 엔드포인트에서 이익이 난다.
응답이 정말 같은지 확인한다
주장은 검증되어야 한다. 두 구현에 같은 본문을 넣고 응답을 비교한다.
go run ./01-why-framework
요청: {"email":"a@example.com","password":"hunter2!!","nickname":"진","age":30}
net/http → 201 {"email":"a@example.com","nickname":"진"}
gin → 201 {"email":"a@example.com","nickname":"진"}
요청: {"email":"not-an-email","password":"short","nickname":"","age":9}
net/http → 422 {"message":"입력이 올바르지 않다","fields":{"age":"14 이상 120 이하여야 한다","email":"이메일 형식이 아니다","nickname":"1자 이상 20자 이하여야 한다","password":"8자 이상이어야 한다"}}
gin → 422 {"message":"입력이 올바르지 않다","fields":{"age":"14 이상 120 이하여야 한다","email":"이메일 형식이 아니다","nickname":"1자 이상 20자 이하여야 한다","password":"8자 이상이어야 한다"}}
요청: {"email":"a@example.com","password":"hunter2!!","nickname":"진","age":30,"admin":true}
net/http → 400 {"message":"본문을 읽을 수 없다"}
gin → 400 {"message":"본문을 읽을 수 없다"}
바이트 단위로 같다. 테스트가 그걸 못 박는다.
// TestTwoImplementationsMatch는 두 구현이 같은 응답을 내는지 확인한다.
// 1-1의 주장("프레임워크는 같은 일을 짧게 쓴다")이 실제로 성립하는지의 근거다.
func TestTwoImplementationsMatch(t *testing.T) {
tests := []struct {
name string
body string
want int
}{
{"정상", `{"email":"a@example.com","password":"hunter2!!","nickname":"진","age":30}`, http.StatusCreated},
{"검증 실패", `{"email":"nope","password":"x","nickname":"","age":9}`, http.StatusUnprocessableEntity},
{"모르는 필드", `{"email":"a@example.com","password":"hunter2!!","nickname":"진","age":30,"admin":true}`, http.StatusBadRequest},
{"깨진 JSON", `{"email":`, http.StatusBadRequest},
}
raw := rawway.Handler()
gh := ginway.Handler()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rs, rb := post(t, raw, tc.body)
gs, gb := post(t, gh, tc.body)
if rs != tc.want {
t.Errorf("net/http status = %d, want %d", rs, tc.want)
}
if rs != gs {
t.Errorf("status 불일치: net/http %d, gin %d", rs, gs)
}
if rb != gb {
t.Errorf("본문 불일치:\n net/http %s\n gin %s", rb, gb)
}
})
}
}
프레임워크가 실제로 주는 것
정직하게 목록을 만들면 넷이다.
| 주는 것 | 표준 라이브러리에서는 |
|---|---|
| 선언적 검증 | 필드마다 if문 |
| 바인딩 — JSON·쿼리·경로·헤더를 구조체 하나로 | 소스마다 다른 코드 |
| 라우터 그룹 — 접두사와 미들웨어를 묶기 | 미들웨어를 경로마다 조립 |
| 미들웨어 생태계 — 인증·CORS·레이트리밋·로깅 | 직접 만들거나 골라 붙이기 |
라우팅 자체는 이 목록에 없다. ServeMux의 메서드·와일드카드 패턴(9-7)이 들어온 뒤로
"라우터가 필요해서 프레임워크를 쓴다"는 이유는 힘을 많이 잃었다. /tasks/{id}와
/tasks/:id는 표기만 다르다.
:::note 미들웨어 생태계라는 말의 의미
func(http.Handler) http.Handler는 사실상 Go의 표준 미들웨어 규약이다. Gin은
http.Handler를 구현하므로 이 규약과 양방향으로 호환된다 — Gin 엔진을 표준
미들웨어로 감쌀 수 있다. Fiber는 fasthttp 기반이라 그렇지 않다.
10-6에서 이 차이의 대가를 실제로 측정한다.
:::
프레임워크가 주지 않는 것
이쪽도 정직하게 세어야 한다.
- 본문 크기 제한. 위 코드에서 봤듯
MaxBytesReader는 여전히 직접 끼운다. Fiber는 설정으로 있고, Gin은 없다. - 에러 응답 형식. 검증 실패를 어떤 JSON으로 낼지는 전부 내 몫이다.
- 400과 422의 구분.
ShouldBindJSON은 "못 읽었다"와 "값이 틀렸다"를 같은error로 준다. 에러 타입으로 갈라야 한다. - 사용자에게 보일 메시지. validator가 주는 것은
Key: 'SignupRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag같은 디버그 문자열이다. - 타임아웃, graceful shutdown, 관측성. 전부
http.Server층의 일이고 그대로 남는다.
선택 기준
| 상황 | 답 |
|---|---|
| 엔드포인트 5개 미만, 검증이 단순 | 표준 라이브러리 |
| 요청 구조체가 크고 검증 규칙이 많다 | Gin |
| 라이브러리를 만들고 있다 | 표준 라이브러리 — http.Handler만 노출한다 |
| 팀이 이미 특정 프레임워크에 익숙하다 | 그것 |
| 극한의 처리량이 필요하다 | 먼저 측정한다(10-6) |
잠금(lock-in) 비용을 구체적으로 계산한다
"나중에 갈아타면 되지"가 실제로 얼마나 드는지 보려면, 코드에서 프레임워크 타입이 몇 군데에 나타나는지 세면 된다.
- 핸들러 시그니처.
func(c *gin.Context)는http.HandlerFunc가 아니다. 핸들러 전부가 프레임워크 타입에 묶인다. - 컨텍스트 값.
c.Set/c.Get으로 넣은 값은*gin.Context가 있어야 꺼낸다. 하위 계층까지*gin.Context를 넘기기 시작하면 서비스 계층 전체가 잠긴다. - 검증 태그.
binding:"..."은 validator의 문법이다. - 미들웨어. 인증·CORS·로깅이 전부 프레임워크 시그니처를 따른다.
두 번째 항목만 지키면 나머지는 국지적이다. 핸들러 안에서 값을 꺼내
context.Context에 담아 하위 계층에 넘기면, 서비스와 저장소는 프레임워크를 모른다.
10-4에서 그 패턴을 만들고,
10-8에서 실제로 그렇게 짠다.
// describe는 Gin을 전혀 모르는 하위 계층 흉내다.
// 받는 것은 context.Context 하나뿐이다 — *gin.Context가 아니다.
func describe(ctx context.Context) string {
if id := RequestIDFrom(ctx); id != "" {
return "request_id=" + id
}
return "request_id 없음"
}
흔한 실수
1. 라우터가 필요해서 프레임워크를 쓴다
Go 1.22 이후 ServeMux는 메서드와 와일드카드를 지원한다. 라우팅만 필요하면
표준 라이브러리로 충분하다.
2. *gin.Context를 서비스 계층까지 넘긴다
프레임워크 교체가 전면 재작성이 된다. 핸들러 경계에서 context.Context로 바꾼다.
3. 프레임워크가 보안까지 해 준다고 생각한다
본문 크기 제한, CSRF, 레이트리밋은 기본으로 켜져 있지 않다. 9-7에서 본
CrossOriginProtection에 해당하는 것이 Gin에는 없다.
4. 검증 태그를 붙였으니 검증이 끝났다고 생각한다
binding 태그는 형식만 본다. "이 이메일이 이미 가입되어 있는가"는 여전히 코드다.
형식 검증(422)과 비즈니스 규칙 위반(409)은 상태 코드도 달라야 한다.
5. 첫 엔드포인트에서 이득을 기대한다
앞에서 세어 봤듯 첫 번째는 오히려 손해다. 판단 기준은 "지금 쓰는 코드"가 아니라 "스무 번째 엔드포인트를 쓸 때의 코드"다.
정리
- 엔드포인트마다 반복되는 다섯 단계 중 (1)(2)(3)(5)는 표준 라이브러리로도 함수 하나에 묶인다. 프레임워크의 진짜 이득은 (4) 검증이다.
- 검증 규칙 선언은 짧아지지만, 사용자에게 보일 메시지는 여전히 손으로 쓴다. 그 코드는 엔드포인트마다가 아니라 태그마다 한 번이므로, 엔드포인트가 늘수록 이익이다.
- 본문 크기 제한, 에러 형식, 400/422 구분, 타임아웃은 프레임워크가 주지 않는다.
- 잠금 비용의 핵심은 하나다 —
*gin.Context가 핸들러 밖으로 나가지 않게 하면 나머지는 국지적인 교체다. - 라우팅만 필요하다면
ServeMux로 충분하다.
연습문제
-
rawway에 엔드포인트를 두 개 더 추가해 보자(POST /login,PATCH /profile). 추가된 코드 중 몇 줄이 (1)(2)(3)(5)의 복사이고 몇 줄이 (4)인가? 같은 것을ginway에도 해 보면 두 파일의 줄 수 차이는 어떻게 벌어지는가? -
ginway.Handler()가http.Handler를 반환한다는 사실을 이용해, 9-7에서 만든func(http.Handler) http.Handler미들웨어(요청 ID나 패닉 복구)를 Gin 엔진 바깥에 씌워 보자. 그 미들웨어가 심은context값을 Gin 핸들러에서 꺼내려면 어떻게 해야 하는가? -
translate가 필드 이름으로 분기하고 있다. 필드가 아니라 태그(required,email,min)로 분기하도록 고쳐 보자. 어느 쪽이 엔드포인트가 늘었을 때 덜 고쳐지는가?fe.Param()은 무엇을 담고 있는가?