이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


Go로 웹 애플리케이션을 만들 때, 가장 많이 비교되는 두 선택지가 ChiGin입니다. GitHub 스타 기준으로 Gin이 압도적인 인기를 누리고 있지만(Gin ~80k vs Chi ~19k), 실무에서의 선택은 단순한 인기도만으로 결정할 문제가 아닙니다.

이 글에서는 두 라이브러리의 설계 철학부터 시작해서, 실제 코드 레벨에서의 차이를 비교합니다.


설계 철학: 라우터 vs. 프레임워크#

Chi와 Gin의 가장 근본적인 차이는 자기 자신을 어떻게 정의하는가에 있습니다.

Chi: “net/http와 함께 쓰는 라우터”#

Chi는 스스로를 “lightweight, idiomatic, and composable router"라고 소개합니다. 핵심 철학은 명확합니다:

  • net/http 100% 호환: http.Handlerhttp.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는 RouteMount를 통해 라우트 그룹과 서브 라우터를 구성합니다:

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는 그 위에 꼭 필요한 것만 얹어주는 역할을 하기 때문입니다.


References#