본문으로 건너뛰기

Gin vs Fiber vs 표준 라이브러리

이 챕터에서 다루는 것

9-8의 할 일 API를 net/http, Gin, Fiber 세 가지로 구현했다. 여섯 엔드포인트, 같은 검증 규칙, 같은 에러 형식, 같은 400/422 규약.

응답이 실제로 같은지 테스트로 강제하고, 그 위에서 코드량·경계 동작·성능을 비교한다. 비교가 의미를 가지려면 세 구현이 정말 같은 일을 해야 하기 때문이다.

공정한 비교를 위한 장치

프레임워크 비교 글이 대부분 쓸모없는 이유는 비교 대상이 같은 일을 하지 않기 때문이다. 한쪽만 검증을 하고, 한쪽만 에러 처리를 하고, 한쪽만 요청 ID를 붙인다.

그래서 프레임워크와 무관한 부분을 전부 공유 패키지로 뺐다.

06-comparison/
├── taskcore/ 도메인 타입, 저장소, 에러 형식, 검증 메시지 (세 구현이 공유)
├── stdlibapi/ net/http 구현
├── ginapi/ Gin 구현
├── fiberapi/ Fiber 구현
├── compare/ 대본 실행기 + 일치 테스트 + 벤치마크
└── main.go 세 구현에 같은 대본을 흘리는 실행기
examples/10-web-frameworks/06-comparison/taskcore/taskcore.go
// Package taskcore는 9-8의 할 일 API에서 프레임워크와 무관한 부분만 떼어낸 것이다.
// 도메인 타입, 저장소, 에러 응답 형식, 검증 메시지가 여기 있다.
//
// 세 구현(net/http, Gin, Fiber)이 이 패키지를 공유한다. 그래야 10-6의 비교가
// "프레임워크 차이"만 남는다.
package taskcore

검증 메시지까지 상수로 고정했다. 문장 하나만 달라도 비교가 무너진다.

examples/10-web-frameworks/06-comparison/taskcore/taskcore.go
// 검증 실패 메시지. 세 구현이 같은 문자열을 내보내야 하므로 상수로 고정한다.
const (
MsgTitleRequired = "필수 항목이다"
MsgStatusEnum = "todo, doing, done 중 하나여야 한다"
MsgLimitPositive = "양의 정수여야 한다"
)

같은 요청을 세 곳에 흘린다

examples/10-web-frameworks/06-comparison/compare/compare.go
// Target은 "요청을 하나 받아 결과를 돌려주는 것"이다.
// 세 구현의 실행 방식이 다르므로 이 함수 타입으로 감싼다.
type Target struct {
Name string
Do func(*http.Request) (*http.Response, error)
Stop func()
}

net/http와 Gin은 http.Handler이므로 같은 방식으로 감싼다.

examples/10-web-frameworks/06-comparison/compare/compare.go
// NewStdlib은 net/http 구현을 Target으로 감싼다.
// http.Handler이므로 httptest.NewRecorder만으로 돌릴 수 있다.
func NewStdlib() Target {
h := stdlibapi.New(taskcore.NewStore(FixedClock())).Handler()
return Target{Name: "net/http", Do: handlerDo(h), Stop: func() {}}
}

// NewGin은 Gin 구현을 Target으로 감싼다. 역시 http.Handler다.
func NewGin() Target {
h := ginapi.New(taskcore.NewStore(FixedClock())).Handler()
return Target{Name: "gin", Do: handlerDo(h), Stop: func() {}}
}

// NewFiber는 Fiber 구현을 Target으로 감싼다.
// http.Handler가 아니므로 httptest.NewRecorder를 쓸 수 없고, 앱이 제공하는
// app.Test를 써야 한다. 이것이 fasthttp 기반의 첫 번째 구체적 대가다.
func NewFiber() Target {
app := fiberapi.New(taskcore.NewStore(FixedClock())).App()
return Target{
Name: "fiber",
Do: func(r *http.Request) (*http.Response, error) { return app.Test(r) },
Stop: func() { _ = app.Shutdown() },
}
}

NewFiber만 다르게 생겼다. 이 세 함수가 이 챕터 전체를 요약한다.

일치 여부는 테스트가 강제한다.

examples/10-web-frameworks/06-comparison/compare/compare_test.go
// TestThreeImplementationsAgree는 세 구현이 같은 대본에 같은 응답을 내는지 확인한다.
// 이 테스트가 통과해야 10-6의 비교표가 의미를 가진다.
func TestThreeImplementationsAgree(t *testing.T) {
steps := compare.Script()

base := compare.NewStdlib()
t.Cleanup(base.Stop)
want, err := compare.Run(base, steps)
if err != nil {
t.Fatalf("기준선 실행: %v", err)
}

for _, mk := range []func() compare.Target{compare.NewGin, compare.NewFiber} {
target := mk()
t.Run(target.Name, func(t *testing.T) {
t.Cleanup(target.Stop)
got, err := compare.Run(target, steps)
if err != nil {
t.Fatalf("실행: %v", err)
}
if len(got) != len(want) {
t.Fatalf("응답 개수 %d, want %d", len(got), len(want))
}
for i := range want {
s := steps[i]
if got[i] != want[i] {
t.Errorf("%s %s\n got = %+v\nwant = %+v", s.Method, s.Path, got[i], want[i])
}
}
})
}
}

실행 — 19개 요청이 전부 일치한다

go run ./06-comparison
--- 공통 대본: 세 구현의 응답 ---
= GET /healthz → 200 {"status":"ok"}
= POST /tasks → 201 {"id":1,"title":"우유 사기","status":"todo","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}
= POST /tasks → 201 {"id":2,"title":"보고서 쓰기","status":"doing","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}
= POST /tasks → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"title":"필수 항목이다"},"request_id":"4"}
= POST /tasks → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"status":"todo, doing, done 중 하나여야 한다"},"request_id":"5"}
= POST /tasks → 400 {"code":"bad_json","message":"모르는 필드 \"titel\"","request_id":"6"}
= POST /tasks → 400 {"code":"bad_json","message":"본문이 중간에 끊겼다","request_id":"7"}
= POST /tasks → 415 {"code":"bad_content_type","message":"Content-Type은 application/json이어야 한다","request_id":"8"}
= GET /tasks?status=doing → 200 {"count":1,"items":[{"id":2,"title":"보고서 쓰기","status":"doing","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}]}
= GET /tasks?status=nope → 400 {"code":"bad_query","message":"status 값이 올바르지 않다","fields":{"status":"todo, doing, done 중 하나여야 한다"},"request_id":"10"}
= GET /tasks?limit=0 → 400 {"code":"bad_query","message":"limit이 올바르지 않다","fields":{"limit":"양의 정수여야 한다"},"request_id":"11"}
= PATCH /tasks/1 → 200 {"id":1,"title":"우유 사기","status":"done","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}
= PATCH /tasks/1 → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"status":"todo, doing, done 중 하나여야 한다"},"request_id":"13"}
= GET /tasks/1 → 200 {"id":1,"title":"우유 사기","status":"done","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}
= GET /tasks/999 → 404 {"code":"not_found","message":"할 일 999 없음","request_id":"15"}
= GET /tasks/abc → 400 {"code":"bad_path","message":"id \"abc\"가 올바르지 않다","request_id":"16"}
= DELETE /tasks/2 → 204
= DELETE /tasks/2 → 404 {"code":"not_found","message":"할 일 2 없음","request_id":"18"}
= GET /tasks → 200 {"count":1,"items":[{"id":1,"title":"우유 사기","status":"done","created_at":"2026-08-12T09:00:00Z","updated_at":"2026-08-12T09:00:00Z"}]}
--- '=' 는 세 구현의 status/body/Location이 전부 같다는 뜻 ---

--- 갈라지는 지점 ---
net/http GET /nope 404 | PUT /tasks/1 405 Allow=DELETE, GET, HEAD, PATCH | GET /tasks/ 404
gin GET /nope 404 | PUT /tasks/1 405 Allow=GET, PATCH, DELETE | GET /tasks/ 404
fiber GET /nope 404 | PUT /tasks/1 405 Allow=GET, HEAD, DELETE, PATCH | GET /tasks/ 200 (매칭됨)

코드량

파일
taskcore/taskcore.go (공유)228
stdlibapi/ (.go 2개)277
ginapi/ (.go 2개)366
fiberapi/fiberapi.go294

Gin이 가장 길다. 예상과 반대일 것이다. 이유는 셋이다.

  1. validator.ValidationErrors를 필드별 메시지로 번역하는 validators.go가 69줄.
  2. "못 읽었다"(400)와 "값이 틀렸다"(422)를 에러 타입으로 가르는 분기.
  3. HandleMethodNotAllowed, RedirectTrailingSlash, NoRoute, NoMethod를 9-8과 같게 맞추는 설정.

검증 규칙이 다섯 개뿐인 API에서는 태그 방식이 이득이 안 난다. 10-1에서 예고한 대로다. 규칙이 스무 개, 엔드포인트가 열 개가 되면 validators.go는 그대로이고 태그만 늘어난다 — 거기서 뒤집힌다.

갈라지는 세 지점

일치 대본에 넣지 않은 요청 세 개가 있다. 셋 다 세 구현이 다르게 답하기 때문이다.

없는 경로

examples/10-web-frameworks/06-comparison/compare/compare_test.go
// TestUnknownRouteDiffers는 세 구현이 유일하게 갈라지는 지점을 기록한다.
//
// ServeMux는 "/" 캐치올을 등록하는 순간 405가 사라진다(9-7). 405를 지키려면
// 404 본문을 표준 라이브러리 기본값(text/plain)으로 둘 수밖에 없다.
// Gin은 NoRoute와 NoMethod가 따로 있어서 둘 다 가진다.
// Fiber는 라우터가 낸 *fiber.Error를 ErrorHandler에서 받아 JSON으로 바꾼다.
func TestUnknownRouteDiffers(t *testing.T) {
want := map[string]struct {
status int
body string
}{
"net/http": {http.StatusNotFound, "404 page not found"},
"gin": {http.StatusNotFound, `{"code":"not_found","message":"경로 없음","request_id":"1"}`},
"fiber": {http.StatusNotFound, `{"code":"not_found","message":"경로 없음","request_id":"1"}`},
}

net/http만 JSON이 아니다. 9-7에서 확인한 트레이드오프 때문이다 — / 캐치올을 등록하면 JSON 404를 낼 수 있지만, 그 순간 ServeMux의 자동 405가 사라진다. 9-8은 405를 택했고 여기서도 같은 선택을 유지했다.

Gin은 NoRouteNoMethod가 별개 훅이라 둘 다 가진다. 이건 Gin이 명확히 나은 지점이다.

Fiber는 app.Use로 마지막에 catch-all을 걸면 net/http와 똑같은 문제가 생긴다. 그래서 catch-all을 쓰지 않고 ErrorHandler에서 *fiber.Error를 받는 쪽을 택했다.

examples/10-web-frameworks/06-comparison/fiberapi/fiberapi.go
// Fiber에는 Gin의 NoRoute에 해당하는 훅이 없다. 마지막에 app.Use로
// catch-all을 걸면 JSON 404를 낼 수 있지만, 그 순간 405가 사라진다.
// 9-7에서 ServeMux의 "/" 캐치올이 405를 삼킨 것과 같은 거래다.
// 여기서는 405를 택했다.

405와 Allow 헤더

구현Allow
net/httpDELETE, GET, HEAD, PATCH
GinGET, PATCH, DELETE
FiberGET, HEAD, DELETE, PATCH

Gin에만 HEAD가 없다. ServeMux와 Fiber는 GET을 등록하면 HEAD도 자동으로 받아 준다. Gin은 그러지 않는다. 헬스체크나 링크 검사기가 HEAD를 쓰는 경우가 많으므로 알고 있어야 한다.

정렬 순서도 셋 다 다르다. 클라이언트가 Allow 문자열을 통째로 비교하면 안 된다.

후행 슬래시

구현GET /tasks/
net/http404 (등록하지 않았으므로)
Gin404 (RedirectTrailingSlash = false로 껐다)
Fiber200/tasks와 같은 라우트로 매칭

Fiber는 StrictRouting이 기본으로 꺼져 있다. 같은 자원에 URL이 두 개 생기는 것이 싫다면 StrictRouting: true로 켜야 한다.

벤치마크 — 먼저 무엇을 재는지 정한다

여기서부터가 이 챕터의 핵심이다. 아래 숫자들은 프레임워크 선택 근거가 되기에 거의 쓸모가 없다. 왜 그런지를 보이는 것이 목적이다.

examples/10-web-frameworks/06-comparison/compare/bench_test.go
// 아래 벤치마크가 재는 것과 재지 않는 것을 먼저 분명히 해 둔다.
//
// 재는 것: 라우팅 + 컨텍스트 조립 + JSON 직렬화의 in-process 비용.
// 재지 않는 것: 소켓 읽기·쓰기, TLS, keep-alive, DB, 실제 부하 아래의 GC 압력.
//
// Fiber는 여기서 실제 HTTP 파싱(app.Test)을 하고 나머지 둘은 ResponseRecorder를
// 쓴다 — 애초에 같은 층위를 재고 있지도 않다. 이 숫자로 "프레임워크 성능"을
// 결론짓지 말 것. 10-6 본문에서 왜 그런지 설명한다.

1차 측정 — 핸들러 레벨

go test -run '^$' -bench . -benchmem -benchtime 1s -count=1 ./06-comparison/compare/
goos: darwin
goarch: arm64
pkg: example.com/web-frameworks/06-comparison/compare
cpu: Apple M4
BenchmarkGetTask_Stdlib-10 691346 1715 ns/op 7018 B/op 29 allocs/op
BenchmarkGetTask_Gin-10 555386 2081 ns/op 7415 B/op 34 allocs/op
BenchmarkGetTask_Fiber-10 140470 8576 ns/op 11615 B/op 45 allocs/op
BenchmarkCreateTask_Stdlib-10 493380 2556 ns/op 9120 B/op 45 allocs/op
BenchmarkCreateTask_Gin-10 435138 2821 ns/op 8825 B/op 46 allocs/op
BenchmarkCreateTask_Fiber-10 114429 10531 ns/op 15381 B/op 72 allocs/op

읽는 그대로 받아들이면 "가장 빠르다는 Fiber가 5배 느리다" 는 결론이 나온다. 그리고 그 결론은 틀렸다.

층위가 다르기 때문이다.

  • net/http와 Gin은 httptest.NewRecorder()로 핸들러를 직접 호출한다. HTTP 파싱이 없다.
  • Fiber는 app.Test로 요청을 실제 HTTP 바이트로 직렬화해서 fasthttp 파서에 먹인다. 요청 라인, 헤더, 본문을 전부 만들고 다시 파싱한다.

*fiber.Apphttp.Handler가 아니라서 ResponseRecorder를 쓸 수 없었고, 그래서 같은 자로 잴 방법이 애초에 없었다. 측정 도구가 다르면 숫자를 비교할 수 없다.

2차 측정 — 같은 자로 재기

셋 다 진짜 루프백 소켓 뒤에 세우고 같은 http.Client로 때린다.

examples/10-web-frameworks/06-comparison/compare/bench_socket_test.go
// bench_test.go의 숫자는 층위가 달라서 세 구현을 나란히 놓을 수 없다.
// 여기서는 셋 다 진짜 루프백 소켓 뒤에 세우고 같은 http.Client로 때린다.
// 그래야 "HTTP 파싱까지 포함한 한 요청의 비용"을 같은 자로 잰다.
//
// 그래도 이 숫자가 재는 것은 여전히 라우팅 + JSON + 로컬 TCP뿐이다.
// 실제 서비스의 병목인 DB·외부 API·직렬화되는 큰 페이로드는 들어 있지 않다.

Fiber만 서버를 세우는 방법이 다르다.

examples/10-web-frameworks/06-comparison/compare/bench_socket_test.go
// serveFiber는 httptest.NewServer를 쓸 수 없다. fasthttp 서버는 http.Handler를
// 받지 않으므로 리스너를 직접 만들어 app.Listener에 넘긴다.
func serveFiber(b *testing.B) string {
store := taskcore.NewStore(compare.FixedClock())
store.Create(taskcore.Task{Title: "우유 사기"})
app := fiberapi.New(store).App()

ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatalf("리스닝: %v", err)
}
go func() {
// Shutdown 후 반환되는 에러는 정상 종료다.
_ = app.Listener(ln, fiber.ListenConfig{DisableStartupMessage: true})
}()
b.Cleanup(func() {
if err := app.Shutdown(); err != nil {
b.Errorf("shutdown: %v", err)
}
})
return "http://" + ln.Addr().String()
}
BenchmarkSocketGetTask_Stdlib-10 43378 26926 ns/op 6813 B/op 81 allocs/op
BenchmarkSocketGetTask_Gin-10 44461 27174 ns/op 7231 B/op 86 allocs/op
BenchmarkSocketGetTask_Fiber-10 50137 23489 ns/op 4099 B/op 58 allocs/op
BenchmarkSocketCreateTask_Stdlib-10 40143 29621 ns/op 10066 B/op 111 allocs/op
BenchmarkSocketCreateTask_Gin-10 39004 30459 ns/op 10001 B/op 112 allocs/op
BenchmarkSocketCreateTask_Fiber-10 45286 26036 ns/op 7045 B/op 84 allocs/op

이제 Fiber가 빠르다. GET에서 13%, POST에서 12%. 할당은 28% 적다. fasthttp의 이점이 여기서 나온다.

그런데 이 숫자가 의미하는 것

두 측정을 나란히 놓으면 진짜 이야기가 보인다.

핸들러만소켓 포함소켓이 차지하는 비율
net/http GET1,715 ns26,926 ns94%
Gin GET2,081 ns27,174 ns92%

로컬 루프백에서조차 시간의 90% 이상이 소켓에 들어간다. 핸들러 코드는 6-8%다. 그리고 실제 서비스에는 여기에 네트워크 왕복(수 밀리초), DB 쿼리(수 밀리초), 외부 API 호출이 더 붙는다.

한 요청이 5ms 걸리는 평범한 API에서 프레임워크 차이 3.4µs는 0.07% 다.

net/http와 Gin의 차이 366ns도 마찬가지다. 초당 만 요청을 처리한다면 CPU 시간 3.7밀리초 차이다. 그 정도는 로그 한 줄 형식을 바꾸면 사라진다.

:::danger 프레임워크 벤치마크를 읽는 법 인터넷의 "프레임워크 성능 비교"는 대부분 hello-world 라우팅을 잰다. 그 숫자는 다음을 하나도 알려 주지 않는다.

  • 실제 페이로드 크기에서의 직렬화 비용
  • 미들웨어 다섯 개를 통과할 때의 비용
  • 커넥션 수천 개 아래에서의 GC 압력과 꼬리 지연(p99)
  • DB 커넥션 풀을 기다릴 때의 동작

측정 도구가 다르면 숫자를 비교할 수 없고(1차 측정), 같은 도구로 재도 병목이 다른 곳에 있으면 그 숫자는 결론을 뒷받침하지 못한다(2차 측정). 프레임워크를 성능으로 고르려면 내 페이로드와 내 미들웨어 스택으로, p50이 아니라 p99를, 예상 동시성에서 재야 한다. 그렇게 재고 나면 대개 프레임워크가 병목이 아니라는 결론이 나온다. :::

:::note 이 숫자를 인용하기 전에 위 결과는 darwin/arm64, Apple M4, 논리 코어 10개에서 -benchtime 1s -count=1로 한 번 잰 것이다. 8-5에서 배운 대로 한 번 잰 숫자는 비교 근거가 못 된다. 차이가 유의미한지 알려면 -count 10으로 여러 번 재고 benchstat으로 비교해야 한다. 여기서는 "차이가 있다/없다"가 아니라 "자릿수가 다르다"만 주장하고 있으므로 한 번 측정으로도 논지가 성립한다. :::

net/http 호환성이 실제로 비싼 이유

성능이 결론을 내주지 않으니, 남는 판단 기준은 호환성이다. *fiber.Apphttp.Handler가 아니라는 사실 하나에서 다음이 전부 따라온다.

잃는 것대체재대체재의 문제
httptest.NewRecorderapp.Test층위가 달라 벤치마크가 왜곡된다
httptest.NewServernet.Listen + app.Listener정리 코드를 직접 쓴다
http.Server 타임아웃 필드fiber.Config같은 기능, 다시 배워야 함
func(http.Handler) http.Handler 미들웨어 생태계Fiber 전용 미들웨어생태계가 작다
r.Context() 취소 전파없음대체 불가
httputil.ReverseProxy, h2c, 표준 계측 도구각자 별도있는 것도 없는 것도 있다

이 중 대체 불가는 하나뿐이다 — 컨텍스트 취소. 나머지는 "다시 배우면 된다"에 가깝다. 하지만 그 "다시 배우기"가 팀 전체에 곱해진다.

:::note 반대 방향도 있다 Fiber가 Gin보다 나은 점도 분명히 있다.

  • 핸들러가 error를 반환한다. Go 코드답고, 에러 응답이 ErrorHandler 한 곳에 모인다.
  • BodyLimit이 설정에 있다. Gin은 MaxBytesReader를 매번 끼워야 한다.
  • 405와 Allow가 기본이다. Gin은 켜야 하고, 켜도 HEAD가 빠진다.
  • 할당이 실제로 적다. 2차 측정의 28% 차이는 진짜다.

기술적 완성도가 낮아서 안 고르는 것이 아니다. net/http 생태계 밖에 있기 때문이다. :::

선택 가이드

상황이유
라이브러리·SDK를 만든다표준 라이브러리http.Handler만 노출하면 사용자가 무엇을 쓰든 상관없다
엔드포인트 5개 미만표준 라이브러리태그 방식이 이득을 못 낸다 (위 코드량 표)
일반적인 JSON APIGin 또는 표준 라이브러리검증 규칙이 많아지면 Gin
팀이 이미 쓰는 것이 있다그것일관성이 3µs보다 비싸다
표준 계측·프록시·h2c가 필요하다표준 라이브러리 또는 Ginnet/http 생태계 안에 있어야 한다
요청당 처리 시간이 마이크로초 단위측정 후 판단그런 서비스가 실제로 있다면 프레임워크가 병목일 수 있다
Fiber를 이미 안다, 취소 전파가 필요 없다Fiber나쁜 선택이 아니다

흔한 실수

1. 벤치마크 결과를 프레임워크 선택 근거로 쓴다

위에서 본 대로다. 층위가 다르거나, 같은 층위라도 병목이 다른 데 있다.

2. 세 구현을 "대충 같게" 만들고 비교한다

한쪽만 검증하고 한쪽만 요청 ID를 붙이면 비교가 성립하지 않는다. taskcore처럼 공통 부분을 강제로 공유하고, 일치 테스트를 둔다.

3. 9-8 코드를 Gin으로 옮기면서 405를 잃는다

HandleMethodNotAllowed가 기본 false다. 조용히 404가 된다.

4. Gin으로 옮기면서 HEAD를 잃는다

GET을 등록해도 HEAD가 안 붙는다. 헬스체크가 깨진다.

5. Fiber로 옮기면서 후행 슬래시 중복을 만든다

StrictRouting이 꺼져 있어 /tasks/tasks/가 같은 라우트가 된다. 캐시 키와 접근 로그가 갈라진다.

6. 프레임워크 타입을 서비스 계층까지 흘린다

이걸 하면 위 표의 "다시 배우면 된다"가 "전면 재작성"으로 바뀐다.

7. -count를 한 번만 주고 그 숫자를 인용한다

8-5에서 본 대로다. 여러 번 재고 benchstat으로 비교해야 차이가 유의미한지 알 수 있다.

정리

  • 비교하려면 먼저 같은 일을 하게 만들어야 한다. taskcore 공유 + 일치 테스트가 이 챕터를 성립시킨다.
  • 19개 요청에서 세 구현의 응답이 바이트 단위로 같다. 갈라지는 곳은 셋뿐이다 — 없는 경로의 본문, Allow 헤더 내용, 후행 슬래시.
  • 코드량은 Gin이 가장 길다. 검증 규칙이 다섯 개인 API에서는 태그 방식이 손해다. 규칙이 늘면 뒤집힌다.
  • 핸들러만 재면 Fiber가 5배 느리고, 소켓까지 재면 13% 빠르다. 같은 코드, 다른 자. 자가 다르면 숫자를 비교할 수 없다.
  • 소켓이 전체 시간의 90% 이상이다. 실제 서비스에서는 DB와 네트워크가 그 위에 더 붙는다. 프레임워크 차이는 대개 측정 노이즈에 묻힌다.
  • 결론을 내주는 것은 성능이 아니라 net/http 호환성이다. 그중 대체 불가는 컨텍스트 취소 전파 하나뿐이지만, 나머지도 "다시 배우기"가 팀 전체에 곱해진다.
  • Fiber가 나은 점도 분명히 있다error 반환, BodyLimit, 기본 405, 적은 할당.

연습문제

  1. taskcore.Task에 1KB짜리 Description 필드를 넣고 소켓 벤치마크를 다시 돌려 보자. 세 구현의 상대적 차이는 커지는가 작아지는가? JSON 직렬화 비용이 늘면 프레임워크 차이는 어떻게 되는가?

  2. 세 구현에 미들웨어를 다섯 개씩 더 얹고(로깅, 요청 ID, CORS, 인증, 레이트리밋) 다시 재 보자. net/httpfunc(http.Handler) http.Handler 중첩과 Gin의 슬라이스 순회 중 어느 쪽이 미들웨어 개수에 더 민감한가?

  3. ginapi에서 HandleMethodNotAllowed = true를 지우고 일치 테스트를 돌려 보자. TestThreeImplementationsAgree는 통과하는가? 통과한다면 왜인가? 405를 검사하는 케이스를 공통 대본에 넣으려면 무엇을 먼저 해결해야 하는가?

  4. Gin에 HEAD /tasks/:id를 자동으로 붙이는 방법을 찾아보자. 라우트 등록을 감싸는 도우미 함수로 해결할 수 있는가? r.Routes()가 무엇을 돌려주는지 확인하면 힌트가 된다.