Cowork AI는 AI를 활용하여 다양한 제품을 개발하고 있습니다. 많은 관심 부탁드립니다! 둘러보기

Go API 서버에서 Panic을 핸들링 해보자

Go API 서버에서 Panic을 핸들링하는 방법을 알아봅니다.

아쉬운 일이지만, Go 언어로 API 서버를 만들다 보면 어쩔 수 없이 Panic이 발생하는 경우가 있다.

예를 들어 대표적으로는 다음과 같다.

  1. nil 포인터의 리시버 메서드 사용
  2. 배열의 존재하지 않는 인덱스 접근
  3. 타입 어설션 실패
  4. map의 동시 쓰기
  5. 닫힌 채널에 쓰기

이런 경우에는 표준 라이브러리 net/http로 구성된 경우 서버가 Panic을 핸들링한다.
예를 들어, 다음과 같이 코드를 작성하면 서버는 Panic을 기본적으로 Recover하고 빈 응답을 반환한다.

func SomeHandler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		slog.InfoContext(r.Context(), "Some logic 1 executed")

		slog.InfoContext(r.Context(), "Some logic 2 executed")

		slog.InfoContext(r.Context(), "Panic occurred")

		panic("Something went wrong")

		w.Write([]byte("Hello, World!"))
	})
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("GET /", SomeHandler())

	port := ":8080"
	slog.Info("Starting server", "port", port)
	if err := http.ListenAndServe(port, mux); err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}
2025/03/19 12:10:49 INFO Some logic 1 executed
2025/03/19 12:10:49 INFO Some logic 2 executed
2025/03/19 12:10:49 INFO Panic occurred
2025/03/19 12:10:49 http: panic serving [::1]:58424: Something went wrong
goroutine 78 [running]:
net/http.(*conn).serve.func1()
	/opt/homebrew/opt/go/libexec/src/net/http/server.go:1947 +0xb0
panic({0x102508980?, 0x10256dd90?})
	/opt/homebrew/opt/go/libexec/src/runtime/panic.go:792 +0x124
main.main.SomeHandler.func1({0x14000108ae8?, 0x1022756a0?}, 0x1400017af00)
	/Users/giraffewithcode/dev/personal/awesomeProject7/main.go:44 +0x9c
net/http.HandlerFunc.ServeHTTP(0x1400013a0c0?, {0x1025720b8?, 0x1400016cc40?}, 0x102417484?)
	/opt/homebrew/opt/go/libexec/src/net/http/server.go:2294 +0x38
net/http.(*ServeMux).ServeHTTP(0x0?, {0x1025720b8, 0x1400016cc40}, 0x1400017af00)
	/opt/homebrew/opt/go/libexec/src/net/http/server.go:2822 +0x1b4
net/http.serverHandler.ServeHTTP({0x140001f4420?}, {0x1025720b8?, 0x1400016cc40?}, 0x1?)
	/opt/homebrew/opt/go/libexec/src/net/http/server.go:3301 +0xbc
net/http.(*conn).serve(0x14000147290, {0x1025724c8, 0x14000117080})
	/opt/homebrew/opt/go/libexec/src/net/http/server.go:2102 +0x52c
created by net/http.(*Server).Serve in goroutine 1
	/opt/homebrew/opt/go/libexec/src/net/http/server.go:3454 +0x3d8

다른 언어와 프레임워크는 어떤가?

다른 언어도 마찬가지이다. 예를 들어 null pointer exception과 같은 오류가 발생하면, 적절한 예외 처리가 없을 경우 프로그램은 어쩔 수 없이 종료되지만, Spring과 같은 많은 현대 프레임워크의 경우에는 이러한 에러들을 프레임워크 단에서 처리하여 서버가 종료되지 않도록 한다.

이대로 두어도 괜찮은가?

기본 동작도 훌륭한 편이지만, 프론트엔드 개발자나 사용자에게 단순히 빈 응답을 보내는 것은 꽤 무례한 대응으로 볼 수 있다. 에러가 발생했다면, 어떤 에러가 발생했는지 명시하고 그에 맞는 에러 응답을 보내는 것이 일반적이고 상식적인 방법이다.

예를 들어, 500 INTERNAL_SERVER_ERROR의 경우 다음과 같은 응답을 보내는 것이다.

{
  "error": "Internal Server Error",
  "message": "Something went wrong"
}

이런 응답을 하지 않은 표준 라이브러리인 net/http가 문제라고 할 수도 있겠지만, Server의 사용 환경이 매우 다양하기에 가장 일반적이고 보수적인 방법을 선택한 것이라고 생각한다.

이런 문제를 해결하기 위해 Go에서는 미들웨어를 사용할 수 있다.

Middleware란?

이전에 다루었던 Go API 서버에서 Request ID를 로깅하는 방법에서 간단히 언급했듯이, 미들웨어는 요청과 응답 사이에서 동작하는 코드를 의미한다.

일반적으로 로깅, 인증, 예외 처리와 같이 API에서 공통적으로 수행해야 하는 부분을 미들웨어로 구현하여 사용한다.
미들웨어가 다른 미들웨어를 반환하는 방식으로 구성하면, 미들웨어 간 체인을 형성하여 API는 비즈니스 로직에만 집중할 수 있다.

이를 다이어그램으로 나타내면 다음과 같다.

flowchart LR
  subgraph Middleware1
    subgraph Middleware2
      subgraph Middleware...N
        API
      end
    end
  end
  
  
  client1[Client] -->|Request| Middleware1
  Middleware1 -->|Response| client2[Client]

구현

Go에서 일반적인 미들웨어는 다음과 같이 구현할 수 있다.

func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Do something before
        next.ServeHTTP(w, r)
        // Do something after
    })
}

여기서 next 파라미터는 다음 미들웨어나 실제 API 로직을 담고 있는 Handler를 가리킨다.

실제 호출 시에는 다음과 같이 사용할 수 있다.

func main() {
    http.Handle("/", Middleware(http.HandlerFunc(Handler)))
    http.ListenAndServe(":8080", nil)
}

미들웨어를 여러 개 묶는다면 다음과 같이 사용할 수 있다.

http.Handle("/", Middleware1(Middleware2(http.HandlerFunc(Handler))))

Panic Recover Middleware

이제 Panic을 Recover하는 미들웨어를 구현해보자.

요구 사항에 따라 다르겠지만, 필자의 경우에는 slog 패키지를 이용해 로그를 남기고 500 Internal Server Error를 반환하도록 간단하게 구현하였다.

func PanicMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if r := recover(); r != nil {
				if r == http.ErrAbortHandler {
					// http.ErrAbortHandler는 Recover하지 않는다.
					// 이 경우 클라이언트에 응답을 보내지 않으며 로그에도 기록하지 않는다.
					panic(r)
				}
				slog.Error("HTTP handler panicked", "err", r, "stacktrace", string(debug.Stack()))

				http.Error(
					w,
					http.StatusText(http.StatusInternalServerError),
					http.StatusInternalServerError,
				)

				slog.Info("Panic Recovered")
				return
			}
		}()

		next.ServeHTTP(w, r)
	})
}

r := recover()를 통해 Panic이 발생하면, r에 Panic의 내용이 담기게 된다.
이를 slog 패키지를 이용해 로그로 남기고, 500 Internal Server Error를 반환하도록 처리하였다.

특히, http.ErrAbortHandler는 Recover하지 않도록 하였다.
이는 http.Error 등에서 사용되는 특수한 에러로, 이 에러가 발생하면 클라이언트에 응답을 보내지 않고 스택 트레이스 로그도 남기지 않는다.
해당 에러에 대한 설명은 아래와 같이 적혀 있다.

// ErrAbortHandler is a sentinel panic value to abort a handler.
// While any panic from ServeHTTP aborts the response to the client,
// panicking with ErrAbortHandler also suppresses logging of a stack
// trace to the server's error log.
var ErrAbortHandler = errors.New("net/http: abort Handler")

결과

이제 미들웨어를 테스트해보자.

func SomeHandler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		slog.InfoContext(r.Context(), "Some logic 1 executed")

		slog.InfoContext(r.Context(), "Some logic 2 executed")

		slog.InfoContext(r.Context(), "Panic occurred")

		panic("Something went wrong")

		w.Write([]byte("Hello, World!"))
	})
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("GET /", PanicMiddleware(SomeHandler()))

	port := ":8080"
	slog.Info("Starting server", "port", port)
	if err := http.ListenAndServe(port, mux); err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

핸들러와 메인 코드를 위와 같이 작성하고 http://localhost:8080으로 접속하면 다음과 같은 결과를 볼 수 있다.

2025/03/19 11:33:31 INFO Starting server port=:8080
2025/03/19 11:33:39 INFO Some logic 1 executed
2025/03/19 11:33:39 INFO Some logic 2 executed
2025/03/19 11:33:39 INFO Panic occurred
2025/03/19 11:33:39 ERROR HTTP handler panicked err="Something went wrong" stacktrace="goroutine 35 [running]:\nruntime/debug.Stack()\n\t/opt/homebrew/opt/go/libexec/src/runtime/debug/stack.go:26 +0x64\nmain.main.PanicMiddleware.func2.1()\n\t/Users/giraffewithcode/dev/personal/awesomeProject7/main.go:19 +0x70\npanic({0x1004809c0?, 0x1004e5e70?})\n\t/opt/homebrew/opt/go/libexec/src/runtime/panic.go:792 +0x124\nmain.main.SomeHandler.func1({0x1400000e0e0?, 0x1400000e0d4?}, 0x1400007e000)\n\t/Users/giraffewithcode/dev/personal/awesomeProject7/main.go:44 +0x9c\nnet/http.HandlerFunc.ServeHTTP(0x100b1cce8?, {0x1004ea198?, 0x14000198000?}, 0x10020184c?)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2294 +0x38\nmain.main.PanicMiddleware.func2({0x1004ea198?, 0x14000198000?}, 0x14000163b28?)\n\t/Users/giraffewithcode/dev/personal/awesomeProject7/main.go:32 +0x6c\nnet/http.HandlerFunc.ServeHTTP(0x1400013a0c0?, {0x1004ea198?, 0x14000198000?}, 0x10038f5a4?)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2294 +0x38\nnet/http.(*ServeMux).ServeHTTP(0x14000182000?, {0x1004ea198, 0x14000198000}, 0x1400007e000)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2822 +0x1b4\nnet/http.serverHandler.ServeHTTP({0x14000072030?}, {0x1004ea198?, 0x14000198000?}, 0x6?)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:3301 +0xbc\nnet/http.(*conn).serve(0x140001462d0, {0x1004ea5a8, 0x14000115080})\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2102 +0x52c\ncreated by net/http.(*Server).Serve in goroutine 1\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:3454 +0x3d8\n"
2025/03/19 11:33:39 INFO Panic Recovered
2025/03/19 11:33:39 INFO Some logic 1 executed
2025/03/19 11:33:39 INFO Some logic 2 executed
2025/03/19 11:33:39 INFO Panic occurred
2025/03/19 11:33:39 ERROR HTTP handler panicked err="Something went wrong" stacktrace="goroutine 35 [running]:\nruntime/debug.Stack()\n\t/opt/homebrew/opt/go/libexec/src/runtime/debug/stack.go:26 +0x64\nmain.main.PanicMiddleware.func2.1()\n\t/Users/giraffewithcode/dev/personal/awesomeProject7/main.go:19 +0x70\npanic({0x1004809c0?, 0x1004e5e70?})\n\t/opt/homebrew/opt/go/libexec/src/runtime/panic.go:792 +0x124\nmain.main.SomeHandler.func1({0x1400000e1b0?, 0x140000140e4?}, 0x1400007e140)\n\t/Users/giraffewithcode/dev/personal/awesomeProject7/main.go:44 +0x9c\nnet/http.HandlerFunc.ServeHTTP(0x100b1cce8?, {0x1004ea198?, 0x140001980e0?}, 0x10020184c?)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2294 +0x38\nmain.main.PanicMiddleware.func2({0x1004ea198?, 0x140001980e0?}, 0x14000163b28?)\n\t/Users/giraffewithcode/dev/personal/awesomeProject7/main.go:32 +0x6c\nnet/http.HandlerFunc.ServeHTTP(0x1400013a0c0?, {0x1004ea198?, 0x140001980e0?}, 0x10038f5a4?)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2294 +0x38\nnet/http.(*ServeMux).ServeHTTP(0x140001820a0?, {0x1004ea198, 0x140001980e0}, 0x1400007e140)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2822 +0x1b4\nnet/http.serverHandler.ServeHTTP({0x14000072030?}, {0x1004ea198?, 0x140001980e0?}, 0x6?)\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:3301 +0xbc\nnet/http.(*conn).serve(0x140001462d0, {0x1004ea5a8, 0x14000115080})\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:2102 +0x52c\ncreated by net/http.(*Server).Serve in goroutine 1\n\t/opt/homebrew/opt/go/libexec/src/net/http/server.go:3454 +0x3d8\n"
2025/03/19 11:33:39 INFO Panic Recovered

panic 값에 http.ErrAbortHandler를 넣어보면 다음과 같이 동작한다.

panic(http.ErrAbortHandler)
2025/03/19 11:43:17 INFO Some logic 1 executed
2025/03/19 11:43:17 INFO Some logic 2 executed
2025/03/19 11:43:17 INFO Panic occurred

panic을 핸들링하지 않아 스택도 출력되지 않고 로그도 남지 않는다.
다만, net/http에서는 서버가 종료되지 않도록 기본적인 Recover는 수행하는 것을 볼 수 있다.

Reference

Cookies
essential