Go 웹 프레임워크 비교: Chi vs. Gin
이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Go로 웹 애플리케이션을 만들 때, 가장 많이 비교되는 두 선택지가 Chi와 Gin입니다. GitHub 스타 기준으로 Gin이 압도적인 인기를 누리고 있지만(Gin ~80k vs Chi ~19k), 실무에서의 선택은 단순한 인기도만으로 결정할 문제가 아닙니다.
이 글에서는 두 라이브러리의 설계 철학부터 시작해서, 실제 코드 레벨에서의 차이를 비교합니다.
설계 철학: 라우터 vs. 프레임워크#
Chi와 Gin의 가장 근본적인 차이는 자기 자신을 어떻게 정의하는가에 있습니다.
Chi: “net/http와 함께 쓰는 라우터”#
Chi는 스스로를 “lightweight, idiomatic, and composable router"라고 소개합니다. 핵심 철학은 명확합니다:
net/http100% 호환:http.Handler와http.HandlerFunc인터페이스를 그대로 사용- 최소주의: 코어 라우터가 1,000줄 미만
- 외부 의존성 제로: Go 표준 라이브러리 외에 아무것도 필요 없음
// Chi의 핸들러 — 표준 net/http 시그니처 그대로
func GetArticle(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "articleID")
w.Write([]byte("Article: " + id))
}
Gin: “배터리 포함 웹 프레임워크”#
Gin은 “high-performance web framework"를 표방합니다. 목표가 다릅니다:
- 자체 Context 객체: 요청/응답 처리를 위한 풍부한 헬퍼 메서드 제공
- 내장 기능: JSON 바인딩, 유효성 검증, 렌더링 등을 프레임워크 차원에서 지원
- martini-like API: 직관적이고 간결한 API 설계
// Gin의 핸들러 — Gin 전용 Context 사용
func GetArticle(c *gin.Context) {
id := c.Param("articleID")
c.JSON(200, gin.H{"article": id})
}
이 차이는 단순한 API 스타일의 문제가 아닙니다. 프로젝트의 의존성 구조와 테스트 전략에 직접적인 영향을 미칩니다.
라우팅#
Chi의 라우팅#
Chi는 Route와 Mount를 통해 라우트 그룹과 서브 라우터를 구성합니다:
r := chi.NewRouter()
r.Route("/articles", func(r chi.Router) {
r.Get("/", listArticles)
r.Post("/", createArticle)
r.Route("/{articleID}", func(r chi.Router) {
r.Use(ArticleCtx) // 이 그룹에만 적용되는 미들웨어
r.Get("/", getArticle)
r.Put("/", updateArticle)
r.Delete("/", deleteArticle)
})
})
// 별도 라우터를 마운트하는 방식도 가능
apiRouter := chi.NewRouter()
apiRouter.Get("/health", healthCheck)
r.Mount("/api", apiRouter)
정규식 URL 파라미터도 지원합니다:
r.Get("/{slug:[a-z-]+}", getBySlug)
Gin의 라우팅#
Gin은 Group으로 라우트를 구성합니다:
router := gin.Default()
articles := router.Group("/articles")
{
articles.GET("/", listArticles)
articles.POST("/", createArticle)
article := articles.Group("/:articleID")
article.Use(ArticleCtx())
{
article.GET("/", getArticle)
article.PUT("/", updateArticle)
article.DELETE("/", deleteArticle)
}
}
비교#
| 항목 | Chi | Gin |
|---|---|---|
| 그룹핑 | Route(), Mount() |
Group() |
| URL 파라미터 | {param} 형식 |
:param 형식 |
| 정규식 파라미터 | 지원 ({slug:[a-z-]+}) |
미지원 |
| 서브 라우터 마운트 | Mount()로 독립 라우터 결합 |
직접 지원 없음 |
Chi의 Mount()는 마이크로서비스 아키텍처에서 각 도메인별 라우터를 독립적으로 개발하고 합치는 패턴에 유용합니다.
미들웨어#
미들웨어 설계는 두 라이브러리의 철학이 가장 극명하게 드러나는 부분입니다.
Chi: 표준 미들웨어 시그니처#
Chi의 미들웨어는 표준 net/http 패턴을 따릅니다:
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "Unauthorized", 401)
return
}
next.ServeHTTP(w, r)
})
}
r.Use(AuthMiddleware)
Go 생태계의 모든 net/http 호환 미들웨어를 그대로 사용할 수 있습니다. 별도의 어댑터가 필요 없습니다.
Chi는 자주 쓰이는 미들웨어도 내장하고 있습니다:
r.Use(middleware.RequestID) // 요청 ID 부여
r.Use(middleware.RealIP) // 프록시 뒤의 실제 IP 파싱
r.Use(middleware.Logger) // 요청 로깅
r.Use(middleware.Recoverer) // 패닉 복구
r.Use(middleware.CleanPath) // 중복 슬래시 정리
r.Use(middleware.Timeout(60 * time.Second)) // 타임아웃
Gin: 전용 미들웨어 시그니처#
Gin의 미들웨어는 gin.HandlerFunc를 사용합니다:
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.AbortWithStatusJSON(401, gin.H{"error": "Unauthorized"})
return
}
c.Next()
}
}
router.Use(AuthMiddleware())
gin.Context가 제공하는 c.Abort(), c.Next(), c.Set(), c.Get() 등의 메서드로 미들웨어 흐름을 제어할 수 있어 편리합니다. 다만, net/http 미들웨어를 직접 사용하려면 래핑이 필요합니다.
비교#
| 항목 | Chi | Gin |
|---|---|---|
| 시그니처 | func(http.Handler) http.Handler |
gin.HandlerFunc |
| net/http 호환 | 100% 호환, 그대로 사용 | 래핑 필요 |
| 내장 미들웨어 | Logger, Recoverer, Timeout 등 | Logger, Recovery |
| 미들웨어 체인 제어 | next.ServeHTTP() 호출 여부로 |
c.Next(), c.Abort() |
데이터 바인딩과 유효성 검증#
이 영역에서는 Gin이 확실한 우위를 가집니다.
Chi: 직접 구현#
Chi는 데이터 바인딩 기능을 제공하지 않습니다. encoding/json이나 서드파티 라이브러리로 직접 처리해야 합니다:
func CreateArticle(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", 400)
return
}
// 유효성 검증도 직접 구현
if req.Title == "" {
http.Error(w, "Title is required", 400)
return
}
// 응답도 직접 마샬링
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}
Gin: 내장 바인딩 + 검증#
Gin은 go-playground/validator를 내장하여 선언적 바인딩과 검증을 지원합니다:
type CreateArticleReq struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Content string `json:"content" binding:"required"`
}
func CreateArticle(c *gin.Context) {
var req CreateArticleReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(201, gin.H{"status": "created"})
}
JSON, XML, YAML, Form, Multipart 등 다양한 포맷의 바인딩을 구조체 태그 하나로 처리할 수 있습니다.
성능#
벤치마크#
Gin은 httprouter 기반의 라딕스 트리(radix tree) 알고리즘으로 라우팅하며, “zero allocation router"를 표방합니다. Chi도 라딕스 트리를 사용하지만, net/http 호환성을 유지하면서 약간의 오버헤드가 있습니다.
그러나 실무에서 두 라이브러리의 성능 차이는 거의 무의미합니다. 웹 애플리케이션의 병목은 라우팅이 아니라 데이터베이스 쿼리, 외부 API 호출, 비즈니스 로직에 있기 때문입니다.
메모리 할당#
| 항목 | Chi | Gin |
|---|---|---|
| 라우팅 알고리즘 | Radix Tree | Radix Tree (httprouter 기반) |
| 라우팅 시 할당 | 최소 | Zero allocation |
| Context 오버헤드 | 없음 (표준 context 사용) | gin.Context 풀링으로 최소화 |
Gin이 마이크로 벤치마크에서는 더 빠르지만, 실제 애플리케이션에서 체감할 수준의 차이는 아닙니다.
테스트#
Chi: 표준 테스트 도구 그대로#
Chi 핸들러는 표준 net/http 시그니처이므로, httptest 패키지로 바로 테스트할 수 있습니다:
func TestGetArticle(t *testing.T) {
req := httptest.NewRequest("GET", "/articles/123", nil)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Get("/articles/{articleID}", GetArticle)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Errorf("expected 200, got %d", w.Code)
}
}
Gin: 테스트 모드 설정 필요#
Gin도 httptest를 사용할 수 있지만, gin.Context를 설정하는 과정이 필요합니다:
func TestGetArticle(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, r := gin.CreateTestContext(w)
r.GET("/articles/:articleID", GetArticle)
c.Request = httptest.NewRequest("GET", "/articles/123", nil)
r.ServeHTTP(w, c.Request)
if w.Code != 200 {
t.Errorf("expected 200, got %d", w.Code)
}
}
Chi의 핸들러는 http.HandlerFunc 자체이므로, 라우터 없이도 단위 테스트가 가능합니다. Gin의 핸들러는 gin.Context에 의존하므로 테스트 시 항상 Gin 런타임이 필요합니다.
언제 무엇을 선택할까?#
Chi를 선택해야 할 때#
- 표준 라이브러리 친화적인 코드를 선호하는 팀
net/http호환 미들웨어 생태계를 적극 활용하려는 경우- 핸들러를 프레임워크에 종속시키고 싶지 않은 경우
- 마이크로서비스에서 도메인별 라우터를 독립적으로 개발하고 합치는 구조
- Go의 관용적(idiomatic) 패턴을 중시하는 프로젝트
Gin을 선택해야 할 때#
- 빠른 프로토타이핑과 생산성이 중요한 프로젝트
- JSON API 서버를 빠르게 구축해야 하는 경우
- 데이터 바인딩과 유효성 검증을 선언적으로 처리하고 싶은 경우
- 풍부한 커뮤니티 리소스와 레퍼런스가 필요한 경우
- Rails/Express.js 같은 프레임워크 경험이 있는 팀
판단 기준 정리#
| 기준 | Chi | Gin |
|---|---|---|
| 철학 | 최소주의, 표준 호환 | 배터리 포함, 편의성 |
| 학습 곡선 | net/http 이해 필요 | 프레임워크 API 학습 |
| 프레임워크 종속성 | 없음 | 있음 (gin.Context) |
| 데이터 바인딩 | 직접 구현 | 내장 |
| 미들웨어 호환 | net/http 생태계 전체 | Gin 전용 |
| 커뮤니티 크기 | 중간 | 매우 큼 |
| 적합한 규모 | 중~대형 프로젝트 | 소~중형 프로젝트 |
마무리#
Chi와 Gin은 “어떤 것이 더 좋은가"의 문제가 아니라 “어떤 철학으로 웹 서버를 만들 것인가” 의 문제입니다.
Go의 표준 라이브러리를 최대한 활용하면서 프레임워크 종속성을 피하고 싶다면 Chi가 적합합니다. 반면, 프레임워크가 제공하는 편의 기능을 적극 활용하여 빠르게 개발하고 싶다면 Gin이 좋은 선택입니다.
개인적으로는 Go 1.22 이후 net/http의 라우팅 기능이 강화되면서, Chi의 “표준 호환” 철학의 가치가 더욱 부각되고 있다고 생각합니다. 표준 라이브러리만으로도 상당 부분을 커버할 수 있게 되었고, Chi는 그 위에 꼭 필요한 것만 얹어주는 역할을 하기 때문입니다.