Go의 Gin 웹 프레임워크를 예로 들어, HTTP의 단순한 문제 영역에 비해 Gin이 얼마나 과도하게 크고 복잡하며 API·문서·의존성 면에서 나쁜 라이브러리인지를 분석하고, 좋은 소프트웨어 의존성을 고르는 기준을 제안하는 글
Efron Licht가 쓴 소프트웨어 아티클.
2025년 12월
내 경험상 Go는 백엔드 개발을 위한 범용 프로그래밍 언어 중 가장 뛰어난 언어이고, 그중 상당 부분은 잘 설계된 표준 라이브러리에서 온다. 약간만 인내심을 가지고, 문서를 읽고, 관용구에 익숙해질 시간을 들일 수 있다면, 멀리 돌아다니지 않아도 필요한 것은 다 있다.
대부분의 프로그래머는 이 약간의 인내심을 가지려 하지 않는다. 그냥 ‘go web framework’를 구글에 치고, 첫 번째 검색 결과를 쓴다. 대부분의 경우, 이것은 Gin이다. 소프트웨어 라이브러리인 척하는 일종의 악성 곰팡이다.
많은 곰팡이와 마찬가지로,
Gin만이 유일한 나쁜 라이브러리는 아니다. 사실, 흔히 쓰이는 라이브러리 중 최악인 축에도 못 낀다. 하지만, 일상적으로 나를 제일 열 받게 만드는 라이브러리고, 더 넓게는 소프트웨어 라이브러리 설계 전반의 가장 큰 결함들을 상징적으로 잘 보여준다고 생각한다.
2025-12-09 자동 생성됨
3-http-is-not-that-complicated-a-brief-review
7-gin-s-api-has-the-surface-area-of-an-industrial-heat-sink-and-sucks-nearly-as-much
시작하기 전에:
이 글은 대부분 분노의 발산(rant)에 가깝다. 미리 사과해 둔다.
Gin은 Go 생태계 기준으로 굉장히 오래된 라이브러리이고, 그보다 더 오래된 go-martini를 기반으로 한다. 가장 심각한 실수들 상당수는 당시 시대적 산물이고, 실수처럼 보이는 것들 중 일부는 표준 라이브러리에 그 기능이 생기기 전에 만들어졌기 때문이다.
이 말을 해야 한다는 게 싫지만, Gin을 쓰거나 쓴 적이 있거나 Gin에 기여한다고 해서 당신이 나쁜 사람인 것은 아니다. 이 글을 들고 다니면서 “유행 지난” 코드를 쓴다고 사람들을 집단 공격하는 도구로 쓰지 말아 달라. ‘클라우트(명성)’로 소프트웨어를 고르는 문화 때문에 우리가 이런 상황에 빠진 부분도 있다.
업데이트: 나는 백엔드 개발에서 아예 라이브러리를 쓰지 말아야 한다거나, net/http 말고 다른 것을 쓰는 것이 “틀렸다”고 말하려는 게 전혀 아니다. 나는 gorilla/mux와 그 주변 친구들을 기꺼이 써 왔고, 예를 들어 chi를 쓰는 것에도 아무 문제를 못 느낀다. 표준 라이브러리를 비교 대상으로 쓰는 이유는,
내가 말하고 싶은 건, Gin이 나쁜 라이브러리라는 것, 그리고 Gin과 같은 결함을 공유하는 라이브러리라면 의심부터 하고 봐야 한다는 것이다.
net/http vs gin이제 본론으로 들어가자. 겉보기만 보면, 기본적인 HTTP 작업은 net/http와 Gin 사이에서 큰 차이가 없어 보인다.
net/httpgo1func main() { 2 // METHOD / PATH를 핸들러에 라우팅: 여기서는 GET /ping 3 mux := http.NewServeMux() 4 mux.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) { 5 w.WriteHeader(200) 6 json.NewEncoder(w).Encode(map[string]string { 7 "message": "pong", 8 }) 9 }) 10 // HTTP 서버를 만들고... 11 srv := http.Server { 12 Handler: mux, // 우리가 만든 라우터를 쓰고... 13 Addr: ":8080", // 8080 포트에서 대기 14 } 15 srv.ListenAndServe() 16}
go1func main() { 2 // 기본 gin router / server / engine 생성 3 r := gin.Default() 4 // METHOD / PATH를 핸들러에 라우팅: 여기서는 GET /ping. 5 r.GET("/ping", func(c *gin.Context) { 6 c.JSON(http.StatusOK, gin.H{ 7 "message": "pong", 8 }) 9 }) 10 r.Run() 11}
겉보기에만 보면, gin이 더 쉬워 보일 수도 있다. 코드 줄이 조금 적고, 설정이 더 적어 보인다.
하지만 이건 전부 피상적인 이야기다.
지도를 평가하는 올바른 기준은 그 지도가 어떤 지형을 얼마나 잘 덮고 있는가이다. 다시 말해, 어떤 소프트웨어를 고르기 전에, 그걸로 풀려고 하는 문제를 먼저 알아야 한다. 그러니 Gin을 까기 전에, 우리가 다루는 지형, 즉 HTTP를 먼저 훑어보자.
다행히도 HTTP는 그리 복잡하지 않아서, 칠판 몇 장과 90초만 있으면 기본을 훑을 수 있다.
HyperText Transport Protocol에서 클라이언트는 HTTP Request를 보내고, 서버는 HTTP Response로 응답한다.
클라이언트는 HTTP Request를 서버로 보낸다. 서버는 요청을 파싱하고, 클라이언트가 원하는 것을 파악한 뒤, HTTP Response를 돌려준다.
이 설명은 매우 대충이다. HTTP 구조에 대해 좀 더 상세히 알고 싶다면, 내 연재물 ‘Backend from the Beginning’을 참고하라. 거기서는 HTTP 라이브러리를 처음부터 직접 만들면서 이 모든 부분을 자세히 다룬다.
HTTP Request는 기본적으로 줄바꿈으로 구분된 네 부분으로 구성된다.
즉, 대략 이런 모양이다.


HTTP Response 역시 비슷한 구조를 가지며, 줄바꿈으로 구분된 네 부분으로 되어 있다.


근본적으로, 해결책의 구조(HTTP 라이브러리)는 문제의 구조를 반영해야 한다. 만약 해결책이 문제보다 현저하게 크다면, 다음 중 하나 이상이 사실이다.
Go 표준 라이브러리의 net/http는 HTTP 전체(서버, 클라이언트, TLS, 프록시 등)를 순수 Go 코드 35개 파일, 25,597줄로 다룬다.
Gin과 그 의존성 체인은 오직 서버 측 처리만 담당하면서, 2,148개의 파일과 1,032,635줄의 코드가 필요하다. 이 안에는 플랫폼 특정적인 GNU 스타일 어셈블리 코드 80,084줄도 포함되어 있다.
이건 _미친 짓_이다. 155mm 포탄으로 계란을 깰 수도 있다. 로켓 부스터랑 유도 레이저까지 달았다고 해서, 그게 좋은 생각이 되는 건 아니다.
어떤 사람들은 _코드 무게_는 상관없고, 우리가 진짜로 신경 써야 할 것은 API, 즉 머릿속에 담아야 하는 인터페이스라고 주장할 것이다. 아마 성급한 최적화 어쩌고 하는 인용구도 슬쩍 끼워 넣겠지. 좋다, 문제 없다.
다음 다이어그램은 net/http와 Gin을 이해하기 위한 ‘최소한의’ API들을 보여준다.
net/http 서버의 최소 인터페이스gin 서버의 “최소” 인터페이스와 카프카적 악몽믿기 힘들겠지만, 이 그래프는 수많은 세부사항들을 생략한 것이다.
이 글을 읽고 있다면, 아마 당신은 프로그래머일 것이다. 지금 참여하고 있는 프로젝트(들)의 의존성이 어떻게 선택되었는지 잠시 생각해 보라. 그리고 자신에게, 아니면 더 좋게는 팀 동료에게 다음 질문을 던져 보라. 주요 의존성들에 관해서 말이다.
당신의 주요 의존성들은 _무엇_인가?
누가, 언제, 왜 그것들을 프로젝트에 추가하기로 결정했나?
몇 개의 대안을 검토했나?
그 대안 중 하나가 ‘우리가 직접 쓴다’였나?
다음 범주들에서, 인식하는 강점과 약점은 무엇인가?
친숙함
성능(어떤 종류의 성능인가?)
API 표면적
문서화
테스트 커버리지
코드 비대화
보안(누가 검증했는가? 코드를 읽어 봤는가? 읽을 수는 있는가? 불투명한 바이너리나 플랫폼 특정 어셈블리에 의존하는가?)
기능이 많을수록 좋은가 나쁜가? 왜? 항상 그런가?
만약 이 결정이 잘못되었다면, 갈아타는 데 얼마나 힘들까?
이 마지막 항목이 바로 Gin의 저주다. Gin은 믿을 수 없을 정도로 제거하기 어렵고, 나는 이것이 Gin의 성공의 뿌리라고 생각한다. 마지막 섹션에서 다시 돌아오겠다.
대부분의 프로젝트에서는, 이 질문들에 대한 어떠한 답도 존재하지 않는다. 왜냐면 애초에 아무도 이런 질문을 해 보지 않았기 때문이다. 그냥 구글이나 chatgpt에 가서 “best golang web framework reddit” 같은 걸 치고, 그걸로 끝낸다. 나는 이것을 반쯤은 되는 회사에서, 스무 번은 넘게 실제로 보는 바람에 이걸 안다. 소프트웨어 개발이 바쁘고 스트레스가 많은 일이라는 점에서, 이런 태도는 이해할 수는 있다. 하지만 용납할 수는 없다. 이건 점심 메뉴 고를 때나 써먹는 수준의 논리이지, 수백만·수십억짜리 프로젝트의 핵심 소프트웨어 의존성을 고를 때 쓸 만한 논리가 아니다.
어떤 것이든, 더할 것이 없을 때가 아니라 뺄 것이 더 이상 남지 않았을 때 비로소 완벽에 이른다. ~Antoine De Saint-Exupéry
Gin은 너무 크다. Gin은 터무니없이, 믿기 힘들 정도로 크다. 그 의존성 트리는 55MiB가 넘는다. Gin과 그 의존성들의 코드 줄 수만(주석과 문서 제외) 세어도 877,615줄이다. 이건 엄청나게, 거대하게, 코끼리 수준으로 큰 비용으로, 모든 프로젝트가 git clone이나 go build를 할 때마다 매번 치러야만 하는 비용이다. 그리고 그 비용 일부는 컴파일된 바이너리에도 새어 나온다.
놀랍게도, Gin 안에는 농담이 아니라 4개~~~~5개 최소 _6개_의 서로 다른 JSON 라이브러리가 들어 있다. 표준 라이브러리에 내장된 것까지 치면 그 이상이다(이 부분은 뒤에서 자세히 이야기하겠다).
포함된 것들로는 다음이 있다.
goccy/go-json (1204K)bytedance/sonic (무려 13 MiB!!!!)quic-go/quic-go/qlogwriter/jsontext (12 KiB - 이 정도는 넘어가 주자)ugorji/go/codec (3MiB!!!)./github.com/quic-go/quic-go/qlogwriter/jsontextgabriel-vasile/mimetype/internal/jsonjson-iterator/go (348K)원래는 네 개라고 생각했는데, 찾아볼수록 계속 더 나왔다.
아래 표는 Gin의 코드 비대함을 몇 가지 인기 있거나 역사적으로 중요한 프로그램/글들과 비교한 것이다.
| Program or Library | Description | Files | Code Lines | %target | Size | %size |
|---|---|---|---|---|---|---|
| github.com/gin-gonic/gin | 인기 있는 Go 웹 프레임워크이자 OSHA 위반 사례 | 2189 | 877615 | 100.000% | 55.461 MiB | 100.00% |
| lua | Python이나 Javascript 같은 범용 스크립팅 언어 | 105 | 36685 | 4.180% | 14.926 MiB | 26.91% |
| chi | 최소주의 Go HTTP 프레임워크 | 85 | 7781 | 0.887% | 4.746 MiB | 8.56% |
| Command and Conquer: Red Alert | 자체 GUI, 네트워킹 코드, 커스텀 게임 엔진 등등을 갖춘 베스트셀러 실시간 전략 게임(1996) | 1893 | 368288 | 41.965% | 39.957 MiB | 72.05% |
| DOOM | 네트워크 플레이를 포함한 id Software의 혁신적인 FPS | 152 | 39250 | 4.472% | 2.375 MiB | 4.28% |
| gorilla/mux | 인기 있는 Go HTTP 라우터 | 19 | 6214 | 0.708% | 1.059 MiB | 1.91% |
| labstack/echo | 인기 있는 Go 웹 프레임워크 | 600 | 326000 | 37.146% | 23.855 MiB | 43.01% |
| golang/go/src | Go 프로그래밍 언어, 런타임, 툴링, 컴파일러 | 9591 | 2244096 | 255.704% | 143.129 MiB | 258.07% |
| MechCommander2-Source/ | 2001년 실시간 전략 게임 | 1875 | 858811 | 97.857% | 1.771 GiB | 3269.17% |
| MS-DOS/v1.25/ | 완전한 운영체제, Microsoft Windows의 전신 | 20 | 12001 | 1.367% | 504.000 KiB | 0.89% |
| MS-DOS/v2.0/ | “ | 116 | 41417 | 4.719% | 2.527 MiB | 4.56% |
| MS-DOS/v4.0 | 진정한 멀티태스킹 지원을 갖춘 최종 MS-DOS 릴리스 | 1065 | 332117 | 37.843% | 23.203 MiB | 41.84% |
| original-bsd/ | 원조 Berkeley Systems Distribution 운영체제와 수백 개의 프로그램, 라이브러리, 게임 | 9562 | 1526953 | 173.989% | 185.387 MiB | 334.27% |
| Quake | 3D 그래픽 엔진, GUI, 네트워킹 코드 등등을 포함한 id Software의 FPS | 516 | 170211 | 19.395% | 15.266 MiB | 27.53% |
| Research-Unix-v10/v10 | BSD 등으로 갈라지기 전의 오리지널 ‘연구용’ 유닉스. 네트워킹, 생산성 소프트웨어, 게임 포함 | 8755 | 1671269 | 190.433% | 137.430 MiB | 247.80% |
| zig/src/ | 수십 개 타겟용 C 컴파일러까지 포함한 시스템 프로그래밍 언어와 툴링 | 175 | 473612 | 53.966% | 24.094 MiB | 43.44% |
| musl | 리눅스 및 기타 OS에서 쓰이는 핵심 C 라이브러리 구현 | 1922 | 64837 | 7.388% | 9.199 MiB | 0.16586 |
| Bible (King James Version) | 유대교·기독교 핵심 종교 텍스트의 인기 번역본 | 31104 | — | — | 4.436 Mib | — |
| War and Peace | 나폴레옹 전쟁을 다루는 톨스토이의 매우 긴 소설 | 23637 | — | — | 3.212 MiB — |
솔직히 말해, 이건 완전히 용납 불가능하다. 만약 chi 같은 정상적인 프레임워크를 골랐다면(사실 프레임워크가 꼭 필요하진 않지만, 논의를 위해), DOOM을 통째로 번들하고, 그걸 빌드할 C 컴파일러(Zig라고 치자)를 하나 더 넣고, 돌아갈 운영체제로 MS-DOS 4.0을 하나 더 넣고, 덤으로 전쟁과 평화와 킹제임스 성경 전체까지 던져 넣어도, 여전히 Gin과 그 소스 트리보다 덩치가 작다.
이 비대함은 컴파일된 바이너리에도 그대로 반영된다.
Go 컴파일러는 사용되지 않는 코드를 제거하는 데 꽤 능숙하지만, Gin은 import 시점에 가능한 한 많은 라이브러리를 건드려서, 컴파일러가 그렇게 하지 못하도록 최선을 다한다.
이를 보여 주기 위해, 예제를 최대한 더 줄여서, 가능한 가장 단순한 Gin 프로그램과 동등한 HTTP 서버를 만들어 보고, 나온 바이너리의 크기를 비교해 보자.
go1// simplegin.go 2func main() { 3 e := gin.Default() 4 e.ANY("/", func(c *gin.Context) { 5 c.Writer.WriteHeader(200) 6 }) 7 e.Run() 8}
go1// simplehttp/main.go 2func main() { 3 http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 4 w.WriteHeader(200) 5 })) 6}
컴파일된 결과를 보자.
bash1#!/usr/bin/env bash 2du -H simplehttp simplegin 3 419640K simplegin 57864K simplehttp
디버그 심벌 때문일까? strip을 한 번 해 보자.
bash1#!/usr/bin/env bash 2strip simplehttp 3strip simplegin 4du -H simplehttp simplegin 513572K simplegin 65444K simplehttp
이 모든 비대함은 어디에서 오는 걸까? 어차피 Gin의 기능 대부분은 쓰지도 않는데… GODEBUG=inittrace=1을 켜서 어떤 패키지들이 초기화되는지 살펴보면, 대략 감이 잡힐 것이다.
bash1GODEBUG=inittrace=1 ./simplegin
text1init internal/bytealg @0.005 ms, 0 ms clock, 0 bytes, 0 allocs 2init runtime @0.078 ms, 0.10 ms clock, 0 bytes, 0 allocs 3init crypto/internal/fips140deps/cpu @0.63 ms, 0.003 ms clock, 0 bytes, 0 allocs 4init math @0.67 ms, 0 ms clock, 0 bytes, 0 allocs 5... many, many lines omitted
잡음이 너무 많으니, 하이라이트만 요약해 보겠다.
실제로 쓰든 말든, toml, gob, yaml, protobuf, xml, 그리고 최소 두 개의 JSON 라이브러리에 대한 비용을 치른다.
HTTP/3 (QUIC)를 쓰지 않아도 그 비용을 치른다.
이 비용은, Gin의 끔찍한 ‘있는 것 없는 것 다 때려 넣기’식 API 설계의 직접적인 결과다. 이건 뒤에서 더 다룬다.
go build -tags nomsgpack알고 보니,
gin팀도 이 끔찍한 바이너리 비대함을 줄이려고 나름 노력은 하고 있다.
nomsgpack이라는 빌드 태그를 추가하면 msgpack 의존성을 제거할 수 있고, 그러면 10메가바이트 정도를 깎을 수 있다. 이게 원래 _기본값_이어야 했겠지만, 어쨌든 잘했다.
점점 더 많은 사람들이 복잡함을 세련됨으로 착각하는 것 같다. 정말 이해할 수 없는 일이다 – 이해 불가능한 것은 경외의 대상이 아니라 의심의 대상이어야 한다.
파스칼의 창시자, Niklaus Wirth
Gin의 API로 뛰어들기 전에, 먼저 유닉스(UNIX)에 대해 잠깐 짚고 넘어가자.
UNIX는 아직도 살아 있는 가장 오래된 소프트웨어 전통 중 하나다. 이 전통에서 좋은 API는 작은 표면을 가지면서 깊은 기능성을 제공한다. 고전적인 예가 UNIX의 파일시스템 API인데, OPEN, CLOSE, READ, WRITE, SEEK, FCTNL이라는 여섯 개 동사만으로 디스크 드라이브, 공유 네트워크 파일시스템, 터미널, 프린터 등을 모두 다룰 수 있었다.
물론 이게 더 이상 옳은 파일시스템 API냐는 것은 다른 문제다. FCTNL은 사실상 치팅이고, 논블로킹이나 동시 I/O도 잘 처리하지 못한다. 이 주제에 대해서는 Benno Rice의 훌륭한 강연 What Unix Cost Us을 참고하라.
UNIX 프로그래밍에 대해 더 알고 싶다면, 내 글 Starting Systems Programming 시리즈를 보라.
Go는 이런 전통 안에 튼튼히 서 있다. 그래서 표준 라이브러리는 가능하면 API 표면적을 줄이려고 한다. Go 표준 라이브러리의 거의 모든 인터페이스는 메서드가 세 개 이하, 보통은 하나뿐이다. Go에서 가장 큰 인터페이스인 net.Conn조차 메서드가 8개에 불과하다. Gin은… 그렇지 않다.
reflect.Type은 예외다. 애초에 외부 라이브러리가 구현하도록 설계된 게 아니고, 구현체는 전부 내부 코드 생성에 의해 만들어지며, 리플렉션은 언제나 모든 규칙의 예외다. 이걸로 싸우자는 DM은 사양한다.
이 철학이 실제로 어떻게 구현되어 있는지 보려면, 먼저 net/http가 어떻게 설계되어 있는지 보자.
net/http는 아름다운 API다Go에서 서버 측 HTTP는 네 가지 타입과 한 문장으로 요약할 수 있다. http.Server가 패킷을 http.Request 구조체로 파싱해서 http.Handler에 넘기고, 핸들러는 http.ResponseWriter를 통해 응답을 쓴다.
보통 그 핸들러는
http.ServeMux같은 라우터로, 요청을 서브 핸들러들로 디스패치한다. 하지만 꼭 그래야 하는 것은 아니다.
간단한 예를 들어 보자. 다음은 표준 라이브러리만으로 만든, POST /greet에 응답하는 최소 HTTP 서버다.
여기서는 여러 타입을 쓰지만, 이 코드를 이해하는 데 필요한 인터페이스는 소수다. http.Handler, http.ResponseWriter, 그리고 JSON 인코더·디코더에서 사용하는 io.Reader, io.Writer 정도다.
go1// 주석을 제외하면 인터페이스 표면적이 43단어 2type Handler interface { 3 ServeHTTP(w ResponseWriter, r *Request) 4} 5type ResponseWriter interface { 6 WriteHeader(statusCode int) 7 Header() Header 8 Write([]byte) (int, error) 9} 10type Reader interface { 11 Read(p []byte) (n int, err error) 12} 13type Writer interface { 14 Write(p []byte) (n int, err error) 15}
비슷하게 요약해 보자면, Gin의 API는 대략 이렇다. gin.Engine이 HTTP 요청을 받고, 임베드된 gin.RouterGroup을 사용해서 라우팅한다. 이렇게 해서 만들어진 *gin.Context 안에는 *http.Request와 gin.ResponseWriter가 들어 있고, 이 컨텍스트를 하나 이상의 gin.HandlerFunc에 넘기고, 핸들러는 *gin.Context를 수정한다.
이 설명만 보면 별로 나빠 보이지 않는다. 사실 거의 비슷하게 들린다. 그럼 gin.Engine부터 이 타입들의 메서드를 실제로 훑어보자.
*gin.Enginego1 2 func (engine *Engine) Delims(left, right string) *Engine 3 func (engine *Engine) HandleContext(c *Context) 4 func (engine *Engine) Handler() http.Handler 5 func (engine *Engine) LoadHTMLFS(fs http.FileSystem, patterns ...string) 6 func (engine *Engine) LoadHTMLFiles(files ...string) 7 func (engine *Engine) LoadHTMLGlob(pattern string) 8 func (engine *Engine) NoMethod(handlers ...HandlerFunc) 9 func (engine *Engine) NoRoute(handlers ...HandlerFunc) 10 func (engine *Engine) Routes() (routes RoutesInfo) 11 func (engine *Engine) Run(addr ...string) (err error) 12 func (engine *Engine) RunFd(fd int) (err error) 13 func (engine *Engine) RunListener(listener net.Listener) (err error) 14 func (engine *Engine) RunQUIC(addr, certFile, keyFile string) (err error) 15 func (engine *Engine) RunTLS(addr, certFile, keyFile string) (err error) 16 func (engine *Engine) RunUnix(file string) (err error) 17 func (engine *Engine) SecureJsonPrefix(prefix string) *Engine 18 func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) 19 func (engine *Engine) SetFuncMap(funcMap template.FuncMap) 20 func (engine *Engine) SetHTMLTemplate(templ *template.Template) 21 func (engine *Engine) SetTrustedProxies(trustedProxies []string) error 22 func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes 23 func (engine *Engine) With(opts ...OptionFunc) *Engine
완전한 _난장판_이다. 이 안에는 대략
Delims, NoMethod, NoRoute, Use, Routes()) 같은 라우팅·미들웨어 관련 기능과,SetTrustedProxies, RunTLS, RunQUIC, With) 같은 서버 설정,SetHTMLTemplate, SetFuncMap, LoadHTMLGlob, LoadHTMLFS, LoadHTMLFiles) 같은 HTML 템플릿 처리 기능이 들어 있다. 즉, http.Server, http.ServeMux, html/template(https://pkg.go.dev/html/template)의 관심사들을 한데 뭉개놓은 데다, QUIC처럼 완전히 다른 HTTP 프로토콜까지 같이 우겨 넣었다.그런데, 이 수많은 설정 옵션들 중 어느 것도 어떤
http.Server를 쓸지 고르는 기능은 없다. 연결 타임아웃을 설정하거나 커넥션·패킷 레벨에서 뭔가를 설정하고 싶으면? Gin은 기본 HTTP 서버에 하드코딩되어 있다. 아마.Handler()를 호출해서 나온 걸*http.Server에 넘기면 가능하긴 한 것 같은데, 확신은 없고, 문서엔 나와 있지도 않다. 혹시With안에 있나?
하지만 이게 끝이 아니다. 앞에서 언급했듯, gin.Engine은 RouterGroup을 임베드한다. 즉, 위 메서드들에 더해, 다음 메서드들도 그대로 노출한다.
*gin.RouterGroupgo1 func (group *RouterGroup) Any(relativePath string, handlers ...HandlerFunc) IRoutes 2 func (group *RouterGroup) BasePath() string 3 func (group *RouterGroup) DELETE(relativePath string, handlers ...HandlerFunc) IRoutes 4 func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes 5 func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup 6 func (group *RouterGroup) HEAD(relativePath string, handlers ...HandlerFunc) IRoutes 7 func (group *RouterGroup) Handle(httpMethod, relativePath string, handlers ...HandlerFunc) IRoutes 8 func (group *RouterGroup) Match(methods []string, relativePath string, handlers ...HandlerFunc) IRoutes 9 func (group *RouterGroup) OPTIONS(relativePath string, handlers ...HandlerFunc) IRoutes 10 func (group *RouterGroup) PATCH(relativePath string, handlers ...HandlerFunc) IRoutes 11 func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes 12 func (group *RouterGroup) PUT(relativePath string, handlers ...HandlerFunc) IRoutes 13 func (group *RouterGroup) Static(relativePath, root string) IRoutes 14 func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) IRoutes 15 func (group *RouterGroup) StaticFile(relativePath, filepath string) IRoutes 16 func (group *RouterGroup) StaticFileFS(relativePath, filepath string, fs http.FileSystem) IRoutes 17 func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes
이 메서드들은 라우팅을 담당한다. 이상하게도 HTTP 메서드마다 각각 별도 메서드를 만든 데다, 정적 파일 서빙도 네 가지 방식으로 제공한다. Group을 빼면 전부 IRoutes 인터페이스를 반환하고, 거의 모든 메서드는 HandlerFunc를 인자로 받는다.
gin.HandlerFunc와 gin.Context좋다, 그럼 HandlerFunc는 뭔가?
go1type HandlerFunc func(c *gin.Context)
드디어 작은 타입이 나왔다. 혹시 이게 http.ResponseWriter와 비슷한 역할일까? 그러면 *gin.Context의 공개 필드와 메서드 목록을 보자.
go1type Context struct { 2 Request *http.Request 3 Writer ResponseWriter // gin.ResponseWriter이지 http.ResponseWriter가 아니다. 4 Params Params 5 Keys map[any]any 6 Errors errorMsgs 7 Accepted []string 8 // contains filtered or unexported fields 9}
즉, http.Request와 gin.ResponseWriter를 담고 있다. 그럼 gin.ResponseWriter는 무엇인가?
go1type ResponseWriter interface { 2 http.ResponseWriter 3 http.Hijacker 4 http.Flusher 5 http.CloseNotifier 6 Status() int 7 Size() int 8 WriteString(string) (int, error) 9 Written() bool 10 WriteHeaderNow() 11 Pusher() http.Pusher 12}
*gin.Context에는 Jerry Seinfeld의 차보다 많은 메서드가 있다Rob Pike, “Go Proverbs”
여기까지만 봐도, 이것만으로 이미 net/http 인터페이스 전체의 API 표면을 포함한다. Gin은 그 위에 복잡성만 쌓았다. 그리고 여기에다 다섯 개의 추가 공개 필드와, 그 필드들에 대한 열 개의 추가 메서드까지 더했다.
벌써 이 정도면 좋지 않지만, 진짜 공포는 이제부터다. gin.Context의 메서드 목록이다.
깊게 숨을 들이쉬길 권한다. 아직 준비가 안 되었을 것이다.
go1// 133개의 함수. 전부 외웠는가? 그렇기를 바란다. https://pkg.go.dev/github.com/gin-gonic/gin#Context 2 func (c *Context) Abort() 3 func (c *Context) AbortWithError(code int, err error) *Error 4 func (c *Context) AbortWithStatus(code int) 5 func (c *Context) AbortWithStatusJSON(code int, jsonObj any) 6 func (c *Context) AbortWithStatusPureJSON(code int, jsonObj any) 7 func (c *Context) AddParam(key, value string) 8 func (c *Context) AsciiJSON(code int, obj any) 9 func (c *Context) Bind(obj any) error 10 func (c *Context) BindHeader(obj any) error 11 func (c *Context) BindJSON(obj any) error 12 func (c *Context) BindPlain(obj any) error 13 func (c *Context) BindQuery(obj any) error 14 func (c *Context) BindTOML(obj any) error 15 func (c *Context) BindUri(obj any) error 16 func (c *Context) BindWith(obj any, b binding.Binding) errordeprecated 17 func (c *Context) BindXML(obj any) error 18 func (c *Context) BindYAML(obj any) error 19 func (c *Context) ClientIP() string 20 func (c *Context) ContentType() string 21 func (c *Context) Cookie(name string) (string, error) 22 func (c *Context) Copy() *Context 23 func (c *Context) Data(code int, contentType string, data []byte) 24 func (c *Context) DataFromReader(code int, contentLength int64, contentType string, reader io.Reader, ...) 25 func (c *Context) Deadline() (deadline time.Time, ok bool) 26 func (c *Context) DefaultPostForm(key, defaultValue string) string 27 func (c *Context) DefaultQuery(key, defaultValue string) string 28 func (c *Context) Done() <-chan struct{} 29 func (c *Context) Err() error 30 func (c *Context) Error(err error) *Error 31 func (c *Context) File(filepath string) 32 func (c *Context) FileAttachment(filepath, filename string) 33 func (c *Context) FileFromFS(filepath string, fs http.FileSystem) 34 func (c *Context) FormFile(name string) (*multipart.FileHeader, error) 35 func (c *Context) FullPath() string 36 func (c *Context) Get(key any) (value any, exists bool) 37 func (c *Context) GetBool(key any) (b bool) 38 func (c *Context) GetDuration(key any) (d time.Duration) 39 func (c *Context) GetFloat32(key any) (f32 float32) 40 func (c *Context) GetFloat32Slice(key any) (f32s []float32) 41 func (c *Context) GetFloat64(key any) (f64 float64) 42 func (c *Context) GetFloat64Slice(key any) (f64s []float64) 43 func (c *Context) GetHeader(key string) string 44 func (c *Context) GetInt(key any) (i int) 45 func (c *Context) GetInt16(key any) (i16 int16) 46 func (c *Context) GetInt16Slice(key any) (i16s []int16) 47 func (c *Context) GetInt32(key any) (i32 int32) 48 func (c *Context) GetInt32Slice(key any) (i32s []int32) 49 func (c *Context) GetInt64(key any) (i64 int64) 50 func (c *Context) GetInt64Slice(key any) (i64s []int64) 51 func (c *Context) GetInt8(key any) (i8 int8) 52 func (c *Context) GetInt8Slice(key any) (i8s []int8) 53 func (c *Context) GetIntSlice(key any) (is []int) 54 func (c *Context) GetPostForm(key string) (string, bool) 55 func (c *Context) GetPostFormArray(key string) (values []string, ok bool) 56 func (c *Context) GetPostFormMap(key string) (map[string]string, bool) 57 func (c *Context) GetQuery(key string) (string, bool) 58 func (c *Context) GetQueryArray(key string) (values []string, ok bool) 59 func (c *Context) GetQueryMap(key string) (map[string]string, bool) 60 func (c *Context) GetRawData() ([]byte, error) 61 func (c *Context) GetString(key any) (s string) 62 func (c *Context) GetStringMap(key any) (sm map[string]any) 63 func (c *Context) GetStringMapString(key any) (sms map[string]string) 64 func (c *Context) GetStringMapStringSlice(key any) (smss map[string][]string) 65 func (c *Context) GetStringSlice(key any) (ss []string) 66 func (c *Context) GetTime(key any) (t time.Time) 67 func (c *Context) GetUint(key any) (ui uint) 68 func (c *Context) GetUint16(key any) (ui16 uint16) 69 func (c *Context) GetUint16Slice(key any) (ui16s []uint16) 70 func (c *Context) GetUint32(key any) (ui32 uint32) 71 func (c *Context) GetUint32Slice(key any) (ui32s []uint32) 72 func (c *Context) GetUint64(key any) (ui64 uint64) 73 func (c *Context) GetUint64Slice(key any) (ui64s []uint64) 74 func (c *Context) GetUint8(key any) (ui8 uint8) 75 func (c *Context) GetUint8Slice(key any) (ui8s []uint8) 76 func (c *Context) GetUintSlice(key any) (uis []uint) 77 func (c *Context) HTML(code int, name string, obj any) 78 func (c *Context) Handler() HandlerFunc 79 func (c *Context) HandlerName() string 80 func (c *Context) HandlerNames() []string 81 func (c *Context) Header(key, value string) 82 func (c *Context) IndentedJSON(code int, obj any) 83 func (c *Context) IsAborted() bool 84 func (c *Context) IsWebsocket() bool 85 func (c *Context) JSON(code int, obj any) 86 func (c *Context) JSONP(code int, obj any) 87 func (c *Context) MultipartForm() (*multipart.Form, error) 88 func (c *Context) MustBindWith(obj any, b binding.Binding) error 89 func (c *Context) MustGet(key any) any 90 func (c *Context) Negotiate(code int, config Negotiate) 91 func (c *Context) NegotiateFormat(offered ...string) string 92 func (c *Context) Next() 93 func (c *Context) Param(key string) string 94 func (c *Context) PostForm(key string) (value string) 95 func (c *Context) PostFormArray(key string) (values []string) 96 func (c *Context) PostFormMap(key string) (dicts map[string]string) 97 func (c *Context) ProtoBuf(code int, obj any) 98 func (c *Context) PureJSON(code int, obj any) 99 func (c *Context) Query(key string) (value string) 100 func (c *Context) QueryArray(key string) (values []string) 101 func (c *Context) QueryMap(key string) (dicts map[string]string) 102 func (c *Context) Redirect(code int, location string) 103 func (c *Context) RemoteIP() string 104 func (c *Context) Render(code int, r render.Render) 105 func (c *Context) SSEvent(name string, message any) 106 func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string, perm ...fs.FileMode) error 107 func (c *Context) SecureJSON(code int, obj any) 108 func (c *Context) Set(key any, value any) 109 func (c *Context) SetAccepted(formats ...string) 110 func (c *Context) SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool) 111 func (c *Context) SetCookieData(cookie *http.Cookie) 112 func (c *Context) SetSameSite(samesite http.SameSite) 113 func (c *Context) ShouldBind(obj any) error 114 func (c *Context) ShouldBindBodyWith(obj any, bb binding.BindingBody) (err error) 115 func (c *Context) ShouldBindBodyWithJSON(obj any) error 116 func (c *Context) ShouldBindBodyWithPlain(obj any) error 117 func (c *Context) ShouldBindBodyWithTOML(obj any) error 118 func (c *Context) ShouldBindBodyWithXML(obj any) error 119 func (c *Context) ShouldBindBodyWithYAML(obj any) error 120 func (c *Context) ShouldBindHeader(obj any) error 121 func (c *Context) ShouldBindJSON(obj any) error 122 func (c *Context) ShouldBindPlain(obj any) error 123 func (c *Context) ShouldBindQuery(obj any) error 124 func (c *Context) ShouldBindTOML(obj any) error 125 func (c *Context) ShouldBindUri(obj any) error 126 func (c *Context) ShouldBindWith(obj any, b binding.Binding) error 127 func (c *Context) ShouldBindXML(obj any) error 128 func (c *Context) ShouldBindYAML(obj any) error 129 func (c *Context) Status(code int) 130 func (c *Context) Stream(step func(w io.Writer) bool) bool 131 func (c *Context) String(code int, format string, values ...any) 132 func (c *Context) TOML(code int, obj any) 133 func (c *Context) Value(key any) any 134 func (c *Context) XML(code int, obj any) 135 func (c *Context) YAML(code int, obj any)
이건 악몽이다. net/http 위에서 요청·응답을 JSON으로 보내기만 하는 “간단한” Gin 서버조차도, 이 거대한 복잡성과 필연적으로 얽힌다.
설령 “그냥” JSON을 주고받고 싶을 뿐이더라도, gin.Context에는 서로 다른 방식으로 동작하는 JSON 관련 메서드가 11개 있다. 빌드 태그에 따라 행동이 달라지고, 여러 계층의 구조체 검증을 마법처럼 호출하며, 일부는 gin.Engine 설정(핸들러 함수의 시그니처에서는 전혀 보이지 않는)에 의존한다. 거기에 .Writer.WriteString()과 .Writer.Write()까지 있다.
go1 func (c *Context) AbortWithStatusJSON(code int, jsonObj any) 2 func (c *Context) AbortWithStatusPureJSON(code int, jsonObj any) 3 func (c *Context) AsciiJSON(code int, obj any) 4 func (c *Context) BindJSON(obj any) error 5 func (c *Context) IndentedJSON(code int, obj any) 6 func (c *Context) JSON(code int, obj any) 7 func (c *Context) JSONP(code int, obj any) 8 func (c *Context) PureJSON(code int, obj any) 9 func (c *Context) SecureJSON(code int, obj any) 10 func (c *Context) ShouldBindBodyWithJSON(obj any) error 11 func (c *Context) ShouldBindJSON(obj any) error
예를 하나만 집어 보자. 런타임에 SecureJSON의 동작을 정확히 알기 위해서는, 적어도 다음을 알아야 한다.
gin.Engine이 - HandlerFunc 시그니처에서는 전혀 보이지 않는 - gin.SecureJSONPrefix를 설정했는가?Status Header는 더 복잡하다. .Context와 그 필드들에 걸쳐 응답 헤더를 설정하는 방법이 24가지나 있다. 예를 들면 다음과 같다.
Context.Status() (status header를 쓴다)Context.Writer.Status() (이미 써진 status header를 읽는다 – 가끔만)Context.Writer.WriteHeader() (status header를 쓴다. 하지만 .Writer.Status()로 항상 다시 읽을 수 있는 것은 아니다. 실제로 이 문제에 부딪힌 적 있고, 지금도 화가 난다.)이 거대한 메서드 목록은 위협적으로 보이지만, 사실 대부분은 같은 핵심 기능의 래퍼일 뿐이다. 더 정확히는, net/http.ResponseWriter와 동일한 기능의 래퍼다. 평범한 .JSON 메서드가 내려가며 어떤 일이 벌어지는지 따라가 보자.
.JSON() 메서드는 export된 함수 WriteJSON을 호출하고, 이 함수는 c.Render()를 호출한다. 여기에서 .Status()를 통해 Status를 쓰는데, 이건 그냥 http.ResponseWriter.WriteHeader를 감싼 것일 뿐이다.
이 메서드는 render.Render 인터페이스를 받고, 거기서 WriteContentType이라는 마법 메서드를 호출한다. 그리고 빌드 태그에 따라 컴파일되는 빈 구조체 codec/json.jsonapi 타입의 전역 export 변수 codec/json.API(타입은 json.Core)에서 render.Render()를 호출한다. 그리고 최종적으로는 마샬된 바이트를 http.ResponseWriter에 쓴다.
이 마법 전역 변수는 빌드 태그에 따라 달라진다. 보통은 표준 라이브러리의
encoding/json이다.
요컨대, 이건 결국 다음과 같다.
go1b, _ := json.Marshal(obj) 2w.Write(b)
단지 중간에 온갖 추가 단계를 집어넣었을 뿐이다.
Content-Type 헤더를 쓰는 것도 마찬가지로 우회적이다.
JSON()은 render.Render.WriteContentType()을 호출하고, 이건 vtable lookup으로 render.JSON.WriteContentType()을 찾아 호출한다. 그 안에서는 평범한 함수 writeContentType()을 호출하고, 다시 vtable lookup을 통해 .Header()를 찾아서, 그제야 헤더를 설정한다.
말로만 하면 너무 추상적일 수 있으니(실제로도 그렇다), 이를 정리한 도표를 하나 준비했다.
‘gin’이라고 적힌 박스 안에서 일어나는 일은 하나도 유용하지 않다.
그리고 다시 말하지만, 이건 Gin에서 JSON 응답을 보내는 열한 가지 방법 중 하나일 뿐이다. 대부분 비슷한 곡예를 거친다. 모두 저마다의 struct를 가지고 있다. 아직 요청 처리에 대해서는 얘기도 안 했다!(원래 이 글에 넣으려고 했지만, 이미 여러 작업일을 다 잡아먹었다.)
이 접근법은 끔찍하다. 런타임 lookup(추가 간접 호출과 함수 호출)을 쓰는 _최악_과, 조건부 컴파일의 _최악_을 동시에 가져온다. 당신도, 컴파일러도, 실제 런타임에 무슨 일이 벌어지는지 알아내기 위해 여러 층의 간접 참조를 통과해야 한다. 그런데 아무 이득도 없다. 이 추가 계층들은 바이너리를 비대하게 만들고, 프로그래머를 혼란스럽게 만들 뿐이다.
기본 설정 – Gin 사용자 중 99.5%가 쓸 것으로 추정되는 – 에서는, 표준 라이브러리와 완전히 같은 일을 하면서, 책임을 서너 개의 추가 인터페이스와 타입들, 수백 줄의 코드에 나눠두었다.
만약 다른 JSON 라이브러리를 쓰고 싶으면, 그냥… 그 라이브러리를 직접 쓰면 된다!
Gin이 하는 일은 제어 흐름을 감추는 것뿐이다. 덕분에 프로그래머는 무력감을 느끼고, 런타임에는 캐시 미스가 발생한다. 이 모든 것이 어떤 실질적인 이득도 없이 벌어진다.
render에 대한 자잘한 트집들render는 io.Writer가 아니라 http.ResponseWriter를 받는가? Body 쓰기 말고 머릿속에 떠오르는 다른 사용처라도 있는가?(헤더를 바꾸는 것처럼?)WriteContentType은 굳이 http.ResponseWriter 전체를 받는가? Body를 바꾸기라도 하나? 차라리 *http.Header를 받았어야 한다! 아니면 조금 더 말이 되는 interface { ContentType() string } 정도로 만들었어야 하지 않을까? 아니면, 차라리 아예 존재하지 않는 편이 더 낫다!이 섹션은 짧게 가자. Gin의 문서는 기껏해야 빈약한 수준이다. 좋은 예가 gin.RouterGroup인데, 막대한 API를 갖고 있음에도 불구하고 문서는 gin.RouterGroup과 gin.RouterGroup.Handle에 걸쳐 있는 짧은 몇 문장뿐이다.
RouterGroup은 라우터를 설정하기 위해 내부적으로 사용된다. RouterGroup은 prefix와 핸들러(미들웨어) 배열과 연결된다.
…
Handle은 지정된 path와 method로 새로운 request 핸들러와 미들웨어를 등록한다. 마지막 핸들러가 실제 핸들러가 되어야 하며, 나머지는 여러 라우트 간에 공유할 수 있는 미들웨어여야 한다. 예제 코드는 GitHub를 보라. (주의: 링크는 제공되지 않는다!)
For GET, POST, PUT, PATCH and DELETE requests the respective shortcut functions can be used.
이 함수는 대량 로딩과, 잘 쓰이지 않거나, 비표준이거나, 커스텀 메서드(e.g. 프록시와의 내부 통신용)를 사용할 수 있게 하기 위한 것이다.
net/http.ServeMux: (좋은) 문서 예시반면 http.ServeMux의 문서는, 문서 안의 예제 코드들을 제외하고도 거의 1,000단어에 달하며, Patterns, Precedence, Trailing-slash redirection, Request sanitizing, Compatibility 다섯 섹션으로 나뉘어 있다. 위 두 링크를 실제로 클릭해서 직접 비교해 보길 권한다.
그럼에도, 이 모든 것은 Gin의 최악의 문제는 아니다. 진짜 최악은 이것이다. http.Handler에서 Gin 핸들러로 가는 건 아주 쉽다. 표준 라이브러리에서 Gin으로 가는 어댑터는 단 한 줄로 구현할 수 있다.
go1func adaptHandler(h http.Handler) func(c *gin.Context) { return func(c *gin.Context) {return h.ServeHTTP(c.ResponseWriter, c.Request)}}
반대로, Gin 핸들러에서 평범한 http.Handler로 가는 건 사실상 불가능하다. 실질적으로 가능한 유일한 방법은, 코드를 파헤쳐서 실제로 무슨 일을 하는지 알아낸 뒤, 모든 간접 참조를 직접 걷어내는 것이다.
만약 아직 프로젝트 초반이라면, 그나마 현실적인 시도일 수 있다. 하지만 수개월·수년 된 레거시 코드베이스라면, 가능성은 없다.
팀에 단 한 사람이라도 Gin을 쓰자는 “번뜩이는” 아이디어를 낸 순간, 사실상 꼼짝없이 묶인다. 그 뒤로는 그 주변을 우회해서 일할 수는 있지만, 서버의 바닥 어딘가에는 Gin이 늘 웅크리고 있게 된다. 그리고 거대한 의존성 체인이 되어, 사실상 영원히 제거할 수 없는 상태가 된다.
나는 이것이 Gin의 성공 비결이라고 생각한다. Gin은 트렌드를 따르는 사람들과 순진한 사람들을 끌어들일 만큼 충분히 매력적이고 유명하다. 그리고 그들이 충분히 오래 붙들고 있을 만큼 그럭저럭 참을 만하다. 그리고 식당과 마찬가지로, 대부분의 사람들은 다른 사람들이 이미 쓰고 있기 때문에 소프트웨어를 쓴다. 더 나쁜 것은, Gin에서 벗어나는 게 너무 어렵고 고통스럽다 보니, Gin 사용자들은 “다른 라이브러리가 어려워서 그렇다”라는 이상한 결론에 도달해, 결국 자신들을 가둔 간수를 찬양하게 된다는 점이다. 어쩌면 진짜 거미줄에 걸린 파리들도 똑같이 생각하는지 모른다.
Gin은 나쁜 소프트웨어 라이브러리이며, 우리는 개발자로서 이런 류의 것을 쓰는 것을 멈춰야 한다. 사실 이 글의 목적은 정말로 Gin에 대해서 말하려는 게 아니다. 좋은 라이브러리의 반대, 즉 소프트웨어 라이브러리에서 무엇이 나쁜지를 설명하기 위한, 편리한 사례로 Gin을 쓰는 것이다.
라이브러리를 쓸지 말지, 쓴다면 무엇을 쓸지는 단순한 취향의 문제가 아니라, 엔지니어링 결정이다. 이 결정은 코드 작성 과정과, 결과물인 프로그램에 구체적인 영향을 미친다. 취향은 결정의 _일부_일 수는 있지만, 주된 요소가 되어서는 안 된다. Gin과 같은 라이브러리는 당신의 소프트웨어를 더 나쁘게 만든다. 쓰지 말아라.
마지막으로 의존성을 고를 때의 조언을 몇 가지 남기겠다.
아직 깊게 들어가지 않았다면, 지금이라도 뽑아 버려라. 이미 코드베이스 깊숙이 퍼져 있다면, 현실적으로 할 수 있는 최선은 봉쇄(containment)일 것이다.
net/http 핸들러를 사용하라.