Post

부딪히며 배우는 Golang 백엔드 개발 - ➂ Gin에서 로깅하는 법

세번째 주제는 Gin 프레임워크에서 로깅을 하는 방법이다.
백엔드에서 로깅을 위해 어떤 정보가 필요한지, 어떻게 로그를 잘 남길 수 있는지 알아보자.

문제 상황

Go의 Gin 프레임워크를 이용해 API 서버를 구현하고 있다. 운영 장애에 잘 대응하기 위해 혹은 데이터 분석 용도로 사용하기 위해 로그를 남겨야한다. 애플리케이션에서 발생할 수 있는 다양한 로그들에 충분한 정보가 담겼으면 좋겠고, 로깅을 효율적으로 하고 싶다.

어떻게 하면 내 애플리케이션에 최적화된 로그를 잘 남기고 관리할 수 있을까?

로그는 왜 남길까?

기본적인 질문부터 해보자. 우리는 왜 서버 로그를 남길까?

기본적으로는 개발이나 운영에서 발생하는 문제를 디버깅하기 위한 용도로 로그를 사용할 것이다. 시스템의 현재 상태를 파악할수도 있고, 에러 발생시 스택 트레이스를 남겨서 원인을 빠르게 파악하는데 도움을 주기도한다.
또한, 사용 패턴 등을 집계할 때나 호출량에 따라 과금을 할 때 등 통계 데이터로도 로그가 활용된다.

다만 로그를 남길 때 적절한 정보, 적절한 로그 수준을 사용하지 않으면 불필요하게 로그를 남기는 오버헤드가 커져서 성능에도 영향을 줄 수 있다. 로그 파일의 크기는 쉽게 방대해질 수 있고, I/O도 많이 소비한다. 그래서 로그를 남길 때는 우리 백엔드에 필수적으로 남겨야하는 기록이 무엇인지 파악하는 것에서 시작하는 것이 가장 중요한 것 같다.

기본 Gin Logger

처음에 팀에서 만들고 있던 백엔드 서버를 로컬에서 돌려봤을 때 분명 애플리케이션 코드에는 없는 로그들이 나와서 어디서 발생한건지 찾아본 기억이 있다.

1
2
3
[GIN] 2024/07/24 - 23:07:15 | 200 |    15.026605s |             ::1 | GET      "/ping"
Error #01: error1
Error #02: error2

알고보니 Gin에는 기본적으로 로깅을 할 수 있는 로거가 제공되었다. gin.default() 로 Gin 서버를 만들게 되면 자동으로 이 기본 로거가 미들웨어로 등록된다.

1
2
3
4
5
6
// gin.default를 사용하면 자동으로 미들웨어로 Logger 가 등록된다.
r := gin.default() 

// gin.new를 사용하면 직접 기본 제공되는 gin 로거를 등록해줄 수 있다.
r := gin.new()
r.Use(gin.Logger()) // 혹은 config를 포함하고 싶으면 gin.LoggerWithConfig()를 사용할 수 있다.

gin에서 기본적으로 제공해주는 로거는 gin.DefaultWriter = os.Stdout로 설정되어 있어서 콘솔에 출력된다.

기본 로거를 사용하면 어떻게 출력되는지는 Gin 로거 코드를 보면 알 수 있다. 기본적으로 타임스탬프, latency, 클라이언트 ip, http 요청 정보, http 응답 코드, url 경로, 응답 본문 크기, 그리고 Context에 남은 에러 정보들이 로그로 남는다.

문제점

그럼 이런 기본 로거를 그냥 가져다 쓰면 안될까? 실제로는 기본 로거만 사용하기에는 다양한 문제점들이 있다.

  • 로그 레벨을 제어할 수 없다.
  • 로그 형식이나 출력 위치 등 로그 커스터마이징이 어렵다. 예를 들어 로그를 JSON 형식으로 남기고 싶을 수 있는데, 이러한 커스터마이징이 어렵다.
  • 성능 최적화가 부족하다. 로거가 병목이 될 수 있다.
  • 로그 회전 및 보관 기능이 없다.

그래서, 이러한 문제점을 보완한 Zap이나 Zerolog 같은 로그 라이브러리들이 주로 사용된다.

효율적인 로그 관리 및 커스터마이징

위 문제점들을 로그 라이브러리를 사용하면 어떻게 해결할 수 있는지 하나씩 살펴보자.

로그 레벨

로그에는 보통 레벨(DEBUG, INFO, WARN, ERROR, FATAL)을 설정해서 해당 로그를 얼마나 빠르게 대응해야할지에 대한 정보를 넣는다.
기본 로거에는 이런 기능이 없어서, 보통 로그 라이브러리로 다음과 같이 레벨을 지정해준다.

1
2
3
4
// Zap 코드 예시
logger, _ := zap.NewProduction()
defer logger.Sync()
logger.Info("Hello world", zap.String("key", "value")) // Info 레벨로 설정해주고 있다.

보통 ERROR 레벨 로그는 운영에서 조치를 필요로 하는 경우에만 사용한다.

예를 들어 사용자가 특정 파라미터를 잘못 입력한 4xx 에러 같은 경우는 발생해도 운영에서 처리해줄 것이 없기 떄문에 보통 WARN 레벨로 로그를 남긴다. 하지만 서킷 브레이커가 켜지는 상황은 해당 시스템에 심각한 문제를 일으킬 수 있어서, 빠른 대응이 필요하기 때문에 보통 ERROR 레벨로 로그를 남긴다.

이렇게 레벨을 설정하면 빠르게 대응해야하는 로그들을 파악하기 쉬울 뿐만 아니라, 로그 시각화 툴에서도 특정 레벨 이상의 로그만 수집하도록해 데이터 수집 오버헤드를 줄일 수 있다. 예를 들어서, 운영 환경에서는 WARN 이상의 로그만 수집하고, 개발 환경에서는 DEBUG 이상의 로그를 수집하도록 설정할 수 있다.

로그 커스터마이징

로그로 남겨야하는 정보 및 출력 위치

애플리케이션별로 로그로 어떤 정보를 남겨야하는지 달라질 수 있다. 로그에 포함되어야하는 정보를 육하원칙으로 생각해 볼 수 있다. 다음의 정보들이 포함되어야 충분한 로그가 된다.

  • 누가: 요청을 발생시킨 주체를 식별하는 정보. userID, sessionID (혹은 transactionID, requestID)
  • 언제: 로그 발생 시간 및 타임존.
  • 어디서: 애플리케이션 코드에서 로그가 발생한 위치 (파일 및 라인번호).
  • 무엇을: 발생한 이벤트나 오류에 대한 설명. (에러 스택 트레이스)
  • 어떻게: 요청 및 처리 방식에 대한 정보.(http 요청 정보, 처리한 이벤트 정보)

위 정보들을 애플리케이션의 특성에 맞게 일관된 포맷의 로그로 기록하면, 문제가 발생했을 때 원인을 빠르게 파악하고 해결할 수 있다.

또한, 로그를 어디에 남길지도 상황에 따라 다를 수 있다.

일반적으로 로그는 표준 출력(콘솔)으로 출력하지만, 로그를 디스크 파일로 저장하거나 네트워크 스트림(로그 수집 서버에 네트워크를 통해 전송)으로도 저장할 수 있다. 통계를 위해 로그를 남기는 경우에는 카프카같은 메세지큐에 로그를 전송해야하거나 DB에 전송하는 경우도 있다.

로그 형식 및 출력 위치 커스터마이징

위에서 설명했듯이 각 로그마다 형식이나 출력 위치 등을 커스터마이징하고 싶은 경우가 많다. 그래서, 각 로그 라이브러리들은 이런 커스터마이징이 가능하도록 config를 바꿀 수 있는 여러 API들을 제공한다.

예를 들어 Zap을 사용하면 다음의 커스터마이징을 해줄 수 있다.

  1. zap.Config을 수정하여 인코딩 형식(Encoding), 로그 메세지 포맷(EncoderConfig), 로그를 출력할 파일 또는 스트림(OutputPaths), 로그 오휴 메세지를 출력할 파일 또는 스트림(ErrorOutputPaths), 로깅 레벨(Level) 설정 등을 변경할 수 있다.
  2. 로그 zapcore.Encoder를 구현해서 로그 메세지에 포함시킬 필드들, 추가적으로 인코딩해줄 값들을 설정할 수 있다.

성능 최적화

Gin의 기본 로거는 최적화가 부족해 로그의 양이 많은 대규모 애플리케이션에서 성능이 문제가 될 수 있다. Zap의 경우에는 슬로건 자체가 Blazing fast, structured, leveled logging in Go로, 빠르다는 것을 강조하고 있다.

Zap이 어떻게 성능 최적화를 했는지는 리드미이슈 등에서 확인할 수 있다.

일단 가장 중요한건 Zap은 Field라는 자체 타입을 사용한 타입 기반 방식이기 때문에 interface{}를 사용하는 것보다 리플렉션을 사용하지 않게 되어 성능이 향상된다. 예를 들어 로그 메시지를 기록할 때 zap.String("key", "value")처럼 필드의 타입을 명확히 지정해줘야한다. 타입 기반의 방식에서는 JSON 인코딩시 동적으로 메모리 할당을 하지 않아도 되어서 메모리 할당이 줄어든다는 것도 장점이다.

그리고, Zap은 로그를 기록할 때 모든 필드를 map[string]interface{}에 모은 후 json.Marshal로 변환하는 대신 각 필드를 개별적으로 인코딩하고 이를 []byte 형태로 처리한다. 이렇게 하면 바이트 배열 버퍼의 재사용이 가능하기 때문에 새로운 메모리 할당을 최소한으로 할 수 있어서 성능이 개선된다.

결국 핵심은 Zap을 사용할 때 개발자가 각 필드의 타입을 명시적으로 정의해줘야한다는 것인데, 이러한 방식이 불편한 사람들을 위해 Zap은 SugaredLogger라는 것도 제공한다. SugaredLogger는 타입을 직접 지정하지 않아도 되는 간편한 API를 제공해서 편의성을 높이되 성능은 약간 떨어지는 로거이다. 성능이 중요한 경우에는 기본 Logger, 편의성이 중요한 경우에는 SuggaredLogger를 사용하면 된다.

참고자료

Stackoverflow - Log information best practice
A Comprehensive Guide to Zap Logging in Go

This post is licensed under CC BY 4.0 by the author.