본문으로 건너뛰기

Fiber 시작하기

이 챕터에서 다루는 것

Fiber v3.4를 쓴다. v2가 아니다. v3는 파괴적 변경이 들어간 메이저 릴리스라 인터넷에 널려 있는 v2 스니펫이 그대로 컴파일되지 않는다. 이 챕터의 코드는 전부 v3다.

Gin과 다른 점 셋 — 핸들러가 error를 반환한다는 것, *fiber.Apphttp.Handler가 아니라는 것, 컨텍스트가 취소되지 않는다는 것 — 을 확인한다.

설치

go get github.com/gofiber/fiber/v3@v3.4.0

v3.4의 go.modgo 1.25.0을 선언한다. Go 1.25 이상이 필수다.

fasthttp 기반이라는 것

Gin은 net/http 위에 얹혀 있다. Fiber는 valyala/fasthttp 위에 얹혀 있다. fasthttp는 net/http와 호환되지 않는 별도의 HTTP 구현이다. 요청·응답 객체를 풀링하고 문자열 할당을 피해 성능을 얻는다.

그 대가를 이 챕터에서 하나씩 확인한다. 먼저 결론부터 보면 이렇다.

--- net/http 호환성 ---
http.HandlerFunc가 http.Handler인가: true
*fiber.App이 http.Handler인가: false

앱 조립 — Config가 중심이다

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
// New는 앱을 조립한다. 반환 타입이 http.Handler가 아니라 *fiber.App이다.
// 이 한 줄이 Fiber를 고를 때 감수하는 모든 것의 출발점이다.
func New(logs io.Writer) *fiber.App {
var counter atomic.Int64

app := fiber.New(fiber.Config{
ErrorHandler: errorHandler,
// v3의 기본 인코더는 encoding/json이다. 명시하면 의도가 분명해진다.
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
// 본문 상한. net/http에서 MaxBytesReader로 하던 일이 설정 한 줄이다.
BodyLimit: 64 << 10,
})

BodyLimit은 Fiber가 Gin보다 나은 지점이다. Gin에는 대응하는 설정이 없어 http.MaxBytesReader를 매번 끼워야 했다.

fiber.Config에는 ReadTimeout, WriteTimeout, IdleTimeout도 있다. http.Server의 필드를 못 쓰는 대신 여기에 있다.

핸들러는 error를 반환한다

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
app.Get("/ping", func(c fiber.Ctx) error {
return c.SendString("pong")
})

시그니처가 func(fiber.Ctx) error다. 이게 Fiber의 설계 중심이다.

:::warning v2와 v3의 가장 큰 차이 v2는 func(c *fiber.Ctx) error, v3는 func(c fiber.Ctx) error다. v3에서 Ctx는 인터페이스이고 포인터가 아니다. 인터넷의 v2 코드를 붙여 넣으면 invalid operation: cannot indirect c류의 에러가 난다. :::

에러를 반환하는 것이 곧 응답을 쓰는 것이다

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
// APIError는 핸들러가 반환하는 에러다.
// Fiber에서 핸들러는 error를 반환하고, 그 error를 ErrorHandler가 응답으로 바꾼다.
// 이 구조가 Gin과 가장 크게 다른 점이다.
type APIError struct {
Status int
Code string
Message string
}

func (e APIError) Error() string { return e.Code + ": " + e.Message }

핸들러는 이렇게 쓴다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
func getBook(c fiber.Ctx) error {
// fiber.Params[int](c, "id")라는 제네릭 헬퍼도 있다. 짧지만
// 파싱 실패와 "0이 들어왔다"를 구분하지 못한다 — 둘 다 0을 준다.
// 9-5에서 본 "제로값과 미지정의 구분" 문제가 여기서 다시 나온다.
raw := c.Params("id")
id, err := strconv.Atoi(raw)
if err != nil || id <= 0 {
return APIError{fiber.StatusBadRequest, "bad_path", fmt.Sprintf("id %q가 올바르지 않다", raw)}
}
for _, b := range books {
if b.ID == id {
return c.JSON(b)
}
}
return APIError{fiber.StatusNotFound, "not_found", "없는 책이다"}
}

Gin의 c.AbortWithStatusJSON(...) + returnreturn APIError{...} 하나가 됐다. Go의 에러 반환 관례와 잘 맞는다 — 이건 Fiber가 Gin보다 나은 지점이다.

응답으로 바꾸는 곳은 한 군데다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
// errorHandler는 모든 에러를 하나의 JSON 형식으로 바꾼다.
//
// 프레임워크가 스스로 만드는 *fiber.Error(404, 405, 413 등)까지 여기로 온다.
// 그걸 처리하지 않으면 라우터의 404가 500으로 뭉개진다.
func errorHandler(c fiber.Ctx, err error) error {
ae := APIError{Status: fiber.StatusInternalServerError, Code: "internal", Message: "내부 오류"}

var mine APIError
var fe *fiber.Error
switch {
case errors.As(err, &mine):
ae = mine
case errors.As(err, &fe):
ae = APIError{Status: fe.Code, Code: codeFor(fe.Code), Message: fe.Message}
}

c.Set(fiber.HeaderContentType, "application/json; charset=utf-8")
return c.Status(ae.Status).JSON(fiber.Map{"code": ae.Code, "message": ae.Message})
}

:::danger 커스텀 ErrorHandler를 붙일 때 가장 먼저 밟는 함정 *fiber.Error 분기를 빼면 라우터가 낸 404와 405가 전부 500이 된다. 프레임워크가 스스로 만드는 에러도 같은 통로로 오기 때문이다. 이 강의를 쓰는 동안 실제로 그렇게 됐고, 테스트가 status = 500, want 405로 잡았다. :::

미들웨어

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
// 미들웨어도 그냥 핸들러다. c.Next()로 다음을 부른다.
// Gin과 달리 error를 반환하므로 "다음이 낸 에러"가 여기로 돌아온다.
app.Use(func(c fiber.Ctx) error {
id := fmt.Sprint(counter.Add(1))
c.Locals("request_id", id)
c.Set("X-Request-ID", id)

err := c.Next()

// 여기서 읽는 상태 코드는 "핸들러가 직접 쓴" 값이다.
// 핸들러가 error를 반환한 경우 최종 상태는 ErrorHandler가 정하는데,
// ErrorHandler는 이 미들웨어가 끝난 뒤에 돈다. 그래서 404가 나갈
// 요청도 여기서는 200으로 보인다. 최종 상태를 로그에 남기려면
// err를 함께 봐야 한다 — Gin의 c.Writer.Status()와 다른 지점이다.
fmt.Fprintf(logs, "%s %s %d err=%v id=%s\n",
c.Method(), c.Path(), c.Response().StatusCode(), err, id)
return err
})

c.Next()가 에러를 반환한다는 점은 Gin보다 낫다 — 미들웨어가 하위 에러를 직접 볼 수 있다.

그런데 상태 코드는 못 본다. ErrorHandler가 미들웨어 체인이 다 풀린 뒤에 돌기 때문이다. 테스트로 못 박아 뒀다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp_test.go
// TestMiddlewareSeesStatusBeforeErrorHandler는 Fiber의 미들웨어가
// 최종 상태 코드를 볼 수 없다는 사실을 못 박는다.
//
// 404가 나갈 요청인데도 미들웨어는 200을 본다. ErrorHandler가
// 미들웨어 체인이 다 풀린 뒤에 돌기 때문이다. 접근 로그를 제대로 남기려면
// 반환된 error를 함께 봐야 한다.
func TestMiddlewareSeesStatusBeforeErrorHandler(t *testing.T) {
app, logs := newApp(t)

do(t, app, "GET", "/ping", "")
do(t, app, "GET", "/nope", "")

want := "GET /ping 200 err=<nil> id=1\n" +
"GET /nope 200 err=Not Found id=2\n"
if logs.String() != want {
t.Errorf("로그 =\n%s\nwant\n%s", logs.String(), want)
}
}

접근 로그에 최종 상태 코드를 남기려면 fiber/v3/middleware/logger를 쓰거나, ErrorHandler 안에서 로깅해야 한다. 미들웨어 하나로 끝나지 않는다.

라우팅

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
v1 := app.Group("/v1")
v1.Get("/books", listBooks)
v1.Get("/books/:id", getBook)
v1.Post("/books", createBook)
v1.Get("/search", search)

// +는 "한 조각 이상", *는 "0개 이상"이다.
// 앞에 슬래시가 붙지 않는다 — Gin의 *path와 다르다.
app.Get("/files/+", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"path": c.Params("+")})
})
ServeMux (9-7)GinFiber v3
파라미터{id}:id:id
나머지 경로{path...}a/b.txt*path/a/b.txt+a/b.txt
선택 파라미터없음없음:id?
후행 슬래시307 리다이렉트301 리다이렉트같은 라우트로 매칭

마지막 줄이 셋 다 다르다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp_test.go
// TestTrailingSlashIsIgnoredByDefault는 Fiber의 기본 라우팅 동작을 못 박는다.
// StrictRouting이 false(기본)면 /v1/books와 /v1/books/가 같은 라우트다.
// ServeMux는 307 리다이렉트, Gin은 301 리다이렉트였다 — 셋 다 다르다.
func TestTrailingSlashIsIgnoredByDefault(t *testing.T) {
app, _ := newApp(t)

resp, _ := do(t, app, "GET", "/v1/books/", "")
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200 (리다이렉트 없이 그대로 매칭)", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "" {
t.Errorf("Location = %q, want 빈 값", loc)
}
}

fiber.Config{StrictRouting: true}로 끄면 별개 라우트가 된다.

405는 Fiber가 스스로 만든다. Allow 헤더까지 붙는다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp_test.go
// TestMethodNotAllowed는 Fiber가 405와 Allow를 스스로 만든다는 것을 확인한다.
func TestMethodNotAllowed(t *testing.T) {
app, _ := newApp(t)

resp, body := do(t, app, "DELETE", "/v1/books/1", "")
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", resp.StatusCode)
}
if got := resp.Header.Get("Allow"); got == "" {
t.Error("Allow 헤더가 없다")
}
if want := `{"code":"method_not_allowed","message":"Method Not Allowed"}`; body != want {
t.Errorf("body = %s, want %s", body, want)
}
}

Gin은 HandleMethodNotAllowed = true를 켜야 했다. Fiber는 기본이다.

바인딩

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
func createBook(c fiber.Ctx) error {
var req CreateBook
// c.Bind()는 소스별 메서드를 들고 있다: JSON, Query, URI, Form, Header...
// Gin의 ShouldBindJSON에 해당하지만, 검증 태그는 기본으로 붙어 있지 않다.
if err := c.Bind().JSON(&req); err != nil {
return APIError{fiber.StatusBadRequest, "bad_json", "본문을 읽을 수 없다"}
}
if strings.TrimSpace(req.Title) == "" {
return APIError{fiber.StatusUnprocessableEntity, "validation", "title은 필수다"}
}
return c.Status(fiber.StatusCreated).JSON(Book{
ID: len(books) + 1, Title: strings.TrimSpace(req.Title), Author: req.Author,
})
}

태그 이름도 다르다 — Gin의 form:"..."이 Fiber에서는 query:"..."다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp.go
// SearchQuery는 쿼리 스트링을 구조체로 받는다.
type SearchQuery struct {
Q string `query:"q"`
Limit int `query:"limit"`
}

검증은 기본으로 없다. Fiber는 StructValidator 인터페이스를 열어 두고 구현체는 주지 않는다. go-playground/validator를 쓰려면 어댑터를 직접 붙인다. Gin이 binding:"required"를 바로 주는 것과 다르다.

대가 1 — httptest.NewRecorder를 못 쓴다

examples/10-web-frameworks/05-fiber-basics/fapp/fapp_test.go
// do는 요청 하나를 보낸다.
//
// httptest.NewRecorder를 쓸 수 없다. *fiber.App은 http.Handler가 아니라
// ServeHTTP가 없기 때문이다. 대신 app.Test가 요청을 실제 HTTP 바이트로
// 직렬화해 fasthttp에 먹이고 *http.Response를 돌려준다.
func do(t *testing.T, app *fiber.App, method, target, body string) (*http.Response, string) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, "http://example.test"+target, r)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.RequestURI = ""

resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
t.Cleanup(func() {
if err := resp.Body.Close(); err != nil {
t.Errorf("본문 닫기: %v", err)
}
})
out, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("본문 읽기: %v", err)
}
return resp, strings.TrimSpace(string(out))
}

app.Test가 있어서 테스트는 된다. 다만 하는 일이 다르다 — httptest.NewRecorder는 핸들러를 직접 호출하지만, app.Test는 요청을 실제 HTTP 바이트로 직렬화해서 fasthttp 파서에 먹인다. 10-6의 벤치마크에서 이 차이가 큰 왜곡을 만든다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp_test.go
// TestAppIsNotAnHTTPHandler는 컴파일 타임 사실을 문서화한다.
//
// var _ http.Handler = fapp.New(io.Discard) // 컴파일 에러
//
// *fiber.App에는 ServeHTTP가 없다. 그래서 9장에서 쓰던 httptest.NewServer,
// http.Handler 미들웨어, http.Server의 타임아웃 필드가 전부 못 쓰게 된다.
// 대신 app.Listener(net.Listener)로 직접 리스너를 넘긴다.
func TestAppIsNotAnHTTPHandler(t *testing.T) {
app, _ := newApp(t)

if _, ok := any(app).(http.Handler); ok {
t.Error("*fiber.App이 http.Handler를 구현하고 있다 — 이 챕터의 전제가 바뀌었다")
}
}

대가 2 — 컨텍스트가 취소되지 않는다

fiber.Ctxcontext.Context를 구현한다. 그런데 go doc이 이렇게 말한다.

// Due to current limitations in how fasthttp works, Deadline operates as a nop.
// Due to current limitations in how fasthttp works, Done operates as a nop.
// Due to current limitations in how fasthttp works, Err operates as a nop.

실제로 확인하면 이렇다.

examples/10-web-frameworks/05-fiber-basics/fapp/fapp_test.go
// TestContextIsNotCancellable은 fasthttp의 문서화된 한계를 못 박는다.
//
// fiber.Ctx는 context.Context를 구현하지만 Deadline/Done/Err은 전부 no-op이다.
// net/http에서는 클라이언트가 끊으면 r.Context()가 취소되어 하위 작업이
// 함께 멈췄다(9-6, 9-7). Fiber에서는 그 신호가 오지 않는다.
func TestContextIsNotCancellable(t *testing.T) {
app, _ := newApp(t)

_, body := do(t, app, "GET", "/ctx", "")
want := `{"deadline_set":false,"done_is_nil":true,"err_is_nil":true}`
if body != want {
t.Errorf("body = %s, want %s", body, want)
}
}

이건 성능 특성이 아니라 기능 차이다. 9-6에서 배운 "클라이언트가 끊으면 컨텍스트가 취소되어 DB 쿼리와 외부 호출도 함께 멈춘다"가 Fiber에서는 성립하지 않는다. 느린 클라이언트가 연결을 끊어도 서버는 하던 일을 끝까지 한다.

c.SetContext(ctx)로 직접 만든 컨텍스트를 심을 수는 있지만, 그건 취소를 내가 관리한다는 뜻이지 연결 종료가 자동으로 전파된다는 뜻이 아니다.

대가 3 — 서버 조립이 다르다

httptest.NewServer도, http.Server도 쓸 수 없다.

examples/10-web-frameworks/05-fiber-basics/main.go
if serve {
// httptest.NewServer도 http.Server도 쓸 수 없다.
// 리스너를 만들어 app.Listener에 넘기는 것이 Fiber의 방식이다.
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("리스닝: %w", err)
}
fmt.Printf("listening on http://%s (Ctrl+C로 종료)\n", ln.Addr())
return app.Listener(ln, fiber.ListenConfig{DisableStartupMessage: true})
}

타임아웃은 fiber.Config에, graceful shutdown은 app.ShutdownWithContext에 있다. 같은 기능이 다 있지만 9장에서 익힌 것을 다시 배워야 한다.

실행

go run ./05-fiber-basics
--- 응답 ---
GET /ping → 200 pong
GET /v1/books?q=action → 200 {"count":1,"items":[{"id":2,"title":"Go in Action","author":"Kennedy"}]}
GET /v1/books/1 → 200 {"id":1,"title":"The Go Programming Language","author":"Donovan"}
GET /v1/books/99 → 404 {"code":"not_found","message":"없는 책이다"}
GET /v1/books/abc → 400 {"code":"bad_path","message":"id \"abc\"가 올바르지 않다"}
GET /v1/books/ → 200 {"count":3,"items":[{"id":1,"title":"The Go Programming Language","author":"Donovan"},{"id":2,"title":"Go in Action","author":"Kennedy"},{"id":3,"title":"Learning Go","author":"Bodner"}]}
POST /v1/books → 201 {"id":4,"title":"새 책","author":"나"}
POST /v1/books → 422 {"code":"validation","message":"title은 필수다"}
GET /v1/search?q=go&limit=3 → 200 {"limit":3,"q":"go"}
GET /files/img/logo.png → 200 {"path":"img/logo.png"}
GET /ctx → 200 {"deadline_set":false,"done_is_nil":true,"err_is_nil":true}
GET /nope → 404 {"code":"not_found","message":"Not Found"}
DELETE /v1/books/1 → 405 Allow=GET, HEAD {"code":"method_not_allowed","message":"Method Not Allowed"}

--- 미들웨어 로그 (상태 코드가 최종값이 아님에 주의) ---
GET /ping 200 err=<nil> id=1
GET /v1/books 200 err=<nil> id=2
GET /v1/books/1 200 err=<nil> id=3
GET /v1/books/99 200 err=not_found: 없는 책이다 id=4
GET /v1/books/abc 200 err=bad_path: id "abc"가 올바르지 않다 id=5
GET /v1/books/ 200 err=<nil> id=6
POST /v1/books 201 err=<nil> id=7
POST /v1/books 200 err=validation: title은 필수다 id=8
GET /v1/search 200 err=<nil> id=9
GET /files/img/logo.png 200 err=<nil> id=10
GET /ctx 200 err=<nil> id=11
GET /nope 200 err=Not Found id=12
DELETE /v1/books/1 200 err=Method Not Allowed id=13

--- net/http 호환성 ---
http.HandlerFunc가 http.Handler인가: true
*fiber.App이 http.Handler인가: false

로그의 상태 코드가 전부 200/201인 것을 보라. 실제 응답은 404, 400, 422, 405였다. 미들웨어에서 본 값과 클라이언트가 받은 값이 다르다.

/v1/books/가 리다이렉트 없이 목록을 준 것, Allow=GET, HEAD에 자동 HEAD가 들어 있는 것도 확인할 수 있다.

v2에서 v3로 바뀐 것 (읽기용)

기존 코드나 블로그 글을 읽을 때 알아볼 수 있어야 한다.

v2v3
func(c *fiber.Ctx) errorfunc(c fiber.Ctx) error — 인터페이스다
c.BodyParser(&v)c.Bind().JSON(&v)
c.QueryParser(&v)c.Bind().Query(&v)
c.ParamsParser(&v)c.Bind().URI(&v)
c.Context()*fasthttp.RequestCtxc.RequestCtx(). c.Context()context.Context
app.Listen(addr)같음. 설정은 ListenConfig

v3.4가 새로 넣은 것 중에는 HTTP QUERY 메서드 지원(fiber.MethodQuery, app.Query()), 컨텍스트를 받는 세션 메서드(SaveWithContext()), 통합 ConstraintHandler 인터페이스, PreforkRecoverInterval/PreforkShutdownGracePeriod가 있다.

흔한 실수

1. v2 코드를 붙여 넣는다

*fiber.Ctx, c.BodyParser가 v3에서 컴파일되지 않는다.

2. 커스텀 ErrorHandler에서 *fiber.Error를 처리하지 않는다

라우터의 404·405·413이 전부 500이 된다.

3. 미들웨어에서 c.Response().StatusCode()로 접근 로그를 쓴다

ErrorHandler가 정한 최종 상태가 아니다.

4. c.Context()가 취소된다고 믿는다

Done()nil이다. 클라이언트가 끊어도 알 수 없다.

5. c.Body()c.Query()의 반환값을 핸들러 밖으로 들고 나간다

fasthttp는 버퍼를 재사용한다. 반환된 문자열·슬라이스는 핸들러가 도는 동안만 유효하다. 고루틴에 넘기거나 맵에 저장하려면 복사해야 한다. fiber.Config{Immutable: true}로 항상 복사하게 만들 수 있지만 성능 이득이 사라진다. Gin의 c.Copy()와 같은 계열의 문제다.

6. net/http 미들웨어를 붙이려 한다

func(http.Handler) http.Handler는 쓸 수 없다. Fiber용으로 다시 써야 한다.

7. 검증이 기본으로 있다고 생각한다

binding 태그에 해당하는 것이 없다. validator를 붙이는 것은 내 일이다.

정리

  • Fiber v3의 Ctx는 인터페이스다. v2의 *fiber.Ctx와 다르다.
  • 핸들러는 error를 반환하고, ErrorHandler가 그것을 응답으로 바꾼다. Go의 에러 관례와 잘 맞고, 에러 응답이 한 군데로 모인다. Gin보다 나은 지점이다.
  • ErrorHandler*fiber.Error도 받아야 한다. 아니면 404·405가 500이 된다.
  • BodyLimit, 자동 405 + Allow가 기본으로 있다. Gin에서는 둘 다 손이 갔다.
  • 미들웨어는 최종 상태 코드를 볼 수 없다. ErrorHandler가 나중에 돌기 때문이다.
  • *fiber.Apphttp.Handler가 아니다. httptest.NewRecorder, httptest.NewServer, http.Server, 표준 미들웨어를 전부 못 쓴다.
  • Deadline/Done/Err은 no-op이다. 연결 종료가 하위 작업에 전파되지 않는다. 성능 트레이드오프가 아니라 기능 차이다.
  • 버퍼는 재사용된다. 핸들러 밖으로 들고 나가려면 복사한다.

연습문제

  1. errorHandler에서 *fiber.Error 분기를 지우고 테스트를 돌려 보자. 몇 개가 깨지는가? 깨지는 응답의 상태 코드는 무엇인가? ErrorHandler를 아예 설정하지 않으면(기본 fiber.DefaultErrorHandler) 같은 요청들이 어떤 본문을 내는가?

  2. SearchQuery에 go-playground/validator를 붙여 보자. fiber.ConfigStructValidator 필드에 넣을 어댑터를 만들면 된다 (go doc github.com/gofiber/fiber/v3.StructValidator로 시그니처를 확인한다). Gin의 binding 태그와 비교하면 몇 줄이 더 드는가?

  3. /slow 엔드포인트를 만들어 3초 동안 100밀리초마다 c.Done()을 확인하게 하고, 클라이언트가 1초 만에 끊는 요청을 보내 보자. 서버는 언제 멈추는가? 같은 것을 9-7의 net/http 서버로 만들면 어떻게 다른가? 이 차이가 실제 서비스에서 문제가 되는 상황을 하나 적어 보자.