🎥 AI 시대 옵저버빌리티 전략 웨비나 | 무료 다시보기 (~4/9)
Top
도입문의
테크
2026-08-21

로그 레벨 종류와 순서, ERROR·WARN 차이와 구조화 로깅

서비스를 만들면 코드 곳곳에 로그를 남깁니다. logger.info("주문 생성 완료")처럼 한 줄 적어 두면 실행 중에 그 코드를 지날 때마다 기록이 파일이나 수집 서버에 쌓입니다. 이때 info, warn, error 중 무엇을 고를지, 곧 로그 레벨을 무엇으로 둘지는 대개 코드를 쓰는 순간에 감으로 정합니다.

감으로 정한 결과는 장애를 조사할 때 드러납니다. ERROR로 걸러 보면 재시도에 성공한 일시적 실패가 잔뜩 섞여 나오고, 찾던 사건은 정작 INFO에 묻혀 있습니다. 로그 레벨은 로그 한 줄이 얼마나 급한 일인지를 단계로 표시해 둔 값입니다. 급한 정도를 미리 적어 두었으니 나중에 "ERROR 이상만" 같은 조건으로 걸러 볼 수 있는데, 레벨 표기가 팀마다 제각각이면 걸러 낸 결과도 믿기 어렵습니다.

로그 레벨 종류와 순서

애플리케이션 로깅 라이브러리에서 쓰는 레벨은 TRACE < DEBUG < INFO < WARN < ERROR < FATAL 순으로 심각해집니다. 로거에 설정해 둔 레벨보다 같거나 더 심각한 것만 기록되므로, 운영 환경을 INFO로 두면 DEBUG와 TRACE는 남지 않습니다.

로그 레벨 종류와 순서 TRACE DEBUG INFO WARN ERROR FATAL
왼쪽은 개발자가 재현할 때 쓰는 기록, 오른쪽은 사람이 바로 확인해야 하는 기록입니다.

그런데 TRACE부터 FATAL까지 여섯 개가 어디서나 통하는 표준은 아닙니다. 레벨 이름은 서로 다른 두 계보에서 나왔고, 그래서 어떤 라이브러리를 쓰느냐에 따라 단계 수도 이름도 달라집니다.

하나는 시스템 로그 표준인 Syslog입니다. RFC 5424 6.2.1절(PRI)의 Table 2가 심각도를 0부터 7까지 여덟 단계로 정의합니다. 0이 Emergency(시스템을 쓸 수 없음)이고 7이 Debug입니다. 숫자가 작을수록 심각합니다. 운영체제와 네트워크 장비가 이 체계를 씁니다.

다른 하나는 애플리케이션 로깅 라이브러리입니다. Log4j2는 여섯 단계, Node.js의 Winston은 기본값이 일곱 단계, SLF4J와 Python logging은 다섯 단계, Go slog는 네 단계를 씁니다.

두 흐름을 잇는 기준이 분산 시스템 관측 데이터의 표준을 정하는 OpenTelemetry의 로그 데이터 모델입니다. 여기서는 SeverityNumber라는 숫자 필드에 1부터 24까지의 값을 두고, 네 개씩 묶어 이름을 붙입니다. Syslog와 달리 숫자가 클수록 심각합니다. 원래 라이브러리가 쓰던 이름은 SeverityText에 그대로 보존합니다.

Syslog는 0에 가까울수록, OpenTelemetry SeverityNumber는 24에 가까울수록 심각해 두 체계의 화살표가 서로 반대를 향하는 그림
숫자만 보고 심각도를 판단하면 두 체계 사이에서 정반대로 읽게 됩니다.

SeverityNumber 이름 Syslog 대응
1 ~ 4 TRACE 아주 세밀한 디버깅 기록. 기본값에서는 대개 꺼 둠 (없음)
5 ~ 8 DEBUG 디버깅용 기록 7 Debug
9 ~ 12 INFO 무슨 일이 일어났다는 알림 6 Informational, 5 Notice
13 ~ 16 WARN 오류는 아니지만 알림보다 중요한 일 4 Warning
17 ~ 20 ERROR 무언가 잘못됨 3 Error, 2 Critical, 1 Alert
21 ~ 24 FATAL 애플리케이션이나 시스템이 멈추는 치명적 오류 0 Emergency

Syslog 대응은 Logs Data Model 부록 Appendix B의 예시 매핑입니다. 문서가 스스로 규격이 아닌 예시라고 밝히고 있어 구현마다 다를 수 있습니다.

한 이름에 숫자를 네 개씩 배정한 것은 Syslog의 중간 단계를 살리기 위해서입니다. Syslog에는 Informational 위에 Notice라는 단계가 하나 더 있습니다. 이름이 INFO 하나뿐이라면 둘 다 INFO가 되어 버려서, 어느 쪽이 더 중요한 기록이었는지 알 수 없게 됩니다. 구간마다 숫자가 넷이라 Informational은 9번, Notice는 10번으로 서로 다른 숫자를 받습니다. 이름이 아니라 숫자로 통일해 두면 서로 다른 언어로 만든 서비스의 로그를 한곳에 모아도 조건 하나로 걸러집니다. ERROR 구간이 17번부터 시작하니 "17 이상만"이 곧 오류 이상을 뜻합니다.

Spring Boot와 Logback의 로그 레벨

표준은 숫자로 맞춰 두지만, 코드에서 실제로 쓸 수 있는 이름은 고른 라이브러리가 정합니다. Spring Boot가 기본으로 쓰는 Logback에는 FATAL이 없습니다. 그래서 application.properties에 FATAL이라고 적으면 Spring Boot가 ERROR로 바꿔 Logback에 넘깁니다.

반대로 logback.xml에 직접 level="FATAL"이라고 적으면 Logback이 모르는 이름이라 기본값인 DEBUG로 떨어집니다. 치명적인 것만 남기려던 의도와 정반대로 로그가 쏟아집니다.

레벨은 application.properties에서 패키지마다 다르게 둘 수 있습니다.

이렇게 두면 전체는 INFO로 유지하면서 주문 관련 패키지의 DEBUG 로그만 남길 수 있습니다.

ERROR와 WARN을 나누는 기준

레벨을 고를 때 쓸 만한 질문은 하나입니다. 이 줄을 누가, 언제 봐야 하는가.

자동 회복 여부로 ERROR와 WARN의 차이를 판단하는 흐름
같은 실패라도 서비스가 스스로 처리했는지에 따라 레벨이 달라집니다.

  • ERROR 사람이 조치해야 하는 실패입니다. 처리에 실패했고 자동으로 회복되지 않았습니다. 알림을 걸어도 되는 수준입니다
  • WARN 지금은 굴러가지만 그대로 두면 문제가 되는 상태입니다. 커넥션 풀 사용률이 높다거나, 재시도로 겨우 성공했다거나, 곧 없어질 API를 아직 쓰고 있다는 기록입니다
  • INFO 정상적으로 일어난 일 중 나중에 되짚어 볼 만한 것입니다
  • DEBUG 개발자가 재현할 때 필요한 내부 값입니다. 운영에서는 평소 꺼 둡니다

자주 보는 오용은 세 가지입니다.

재시도에 성공한 실패를 ERROR로 남깁니다. 외부 API 호출이 한 번 실패했다가 두 번째에 성공했다면 서비스는 정상 동작한 것입니다. 이걸 ERROR로 남기면 ERROR 알림이 매일 울리고, 사람은 곧 그 알림을 무시하기 시작합니다. 중간 실패는 WARN, 재시도를 다 쓰고도 실패했을 때만 ERROR가 맞습니다.

사용자 입력 오류를 ERROR로 남깁니다. 필수 항목을 빼고 요청을 보낸 사용자가 400 응답을 받는 것은 설계대로 동작한 결과입니다. 서버는 멀쩡하므로 WARN이나 INFO가 맞습니다. 이런 요청이 갑자기 늘어나는지 보고 싶다면 로그 레벨 대신 응답 코드 메트릭으로 셉니다.

필요한 정보를 전부 DEBUG에 넣고 운영에서 끕니다. 재현이 어려운 문제일수록 그 값이 필요한데, 정작 사고가 난 시점의 로그에는 없습니다. 조사에 반드시 필요한 값이라면 INFO로 올리고, 대신 남기는 항목을 줄입니다.

운영 환경의 기본 레벨은 대개 INFO입니다. 설정을 DEBUG로 바꾸면 로그 양이 크게 늘어납니다. 저장 비용이 함께 오르고 검색도 느려집니다.

구조화 로깅으로 넘어가기

구조화 로깅은 로그를 필드와 값의 쌍으로 남기는 방식입니다. 사람이 읽는 문장 한 줄로 적는 대신 항목마다 이름을 붙여 두고, 형식은 JSON을 가장 많이 씁니다.

일반 텍스트 로그와 JSON 구조화 로그의 검색 처리 차이
텍스트 로그는 값을 꺼내는 단계를 한 번 더 거칩니다.

같은 사건을 두 방식으로 남기면 이렇습니다.

# 문장으로 남긴 로그
2026-08-24T14:32:11.204Z ERROR OrderService - 주문 처리 실패 orderId=10482 userId=771 사유=결제 승인 거부

# 구조화해 남긴 로그
{"timestamp":"2026-08-24T14:32:11.204Z","level":"ERROR","service":"order-api","logger":"OrderService","message":"주문 처리 실패","order_id":10482,"user_id":771,"reason":"payment_declined","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}

문장으로 남긴 쪽도 사람이 읽으면 뜻이 통합니다. 문제는 기계가 읽을 때입니다. orderId=10482에서 주문 번호만 꺼내려면 문자열을 잘라내는 규칙(파서)이 필요하고, 메시지 형식이 조금만 바뀌어도 그 규칙이 깨집니다. 로그 수집 도구를 쓰면서 파서를 계속 손보게 되는 이유입니다.

필드는 두 종류로 나눠 설계하면 관리하기 쉽습니다.

  • 공통 필드 모든 로그에 같은 이름으로 들어갑니다. 시각, 레벨, 서비스 이름, 호스트, trace_id처럼 어느 서비스에서 나온 로그든 동일하게 두는 항목입니다
  • 이벤트 고유 필드 그 사건에만 있는 값입니다. 주문 번호, 결제 사유, 처리 시간 같은 항목입니다

공통 필드에는 trace_id를 함께 넣어 두면 도움이 됩니다. 분산 트레이싱(요청 하나가 여러 서비스를 거치는 경로를 이어서 추적하는 방식)을 쓰고 있다면 트레이스에서 느린 구간을 먼저 찾습니다. 같은 trace_id로 로그를 걸러 보면 그 순간 무슨 값이 오갔는지 바로 확인할 수 있습니다. 로그 레벨 TRACE와 이름만 같고 다른 개념입니다.

대가도 있습니다.

사람이 터미널에서 읽기 불편해집니다. 그래서 로컬 개발 환경은 사람이 읽는 형식으로, 운영 환경은 JSON으로 출력하도록 나눠 설정하는 경우가 많습니다.

용량도 늘어납니다. 필드 이름이 줄마다 반복되기 때문입니다. 저장할 때 압축을 걸면 이 반복이 줄어듭니다. 다만 줄어드는 정도는 로그 내용에 따라 다르므로, 필요 없는 필드를 처음부터 넣지 않는 편이 확실합니다.

값의 종류가 지나치게 많은 필드는 검색 인덱스 대상에서 빼는 편이 안전합니다. 요청마다 값이 달라지는 항목을 인덱스가 걸린 필드로 두면 저장 구조가 빠르게 커집니다. 값의 종류가 늘어날 때 저장 구조가 어떻게 커지는지는 메트릭 카디널리티란, 모니터링 비용이 갑자기 뛰는 이유에서 정리했습니다.

자주 묻는 질문

FATAL과 ERROR는 어떻게 다른가요

FATAL은 프로세스가 더 이상 동작할 수 없어 종료되는 상황에만 씁니다. 설정 파일을 못 읽어 기동에 실패했다거나, 필수 의존 서비스에 붙지 못해 서버를 내려야 하는 경우입니다. ERROR는 요청 하나가 실패했을 뿐 서비스는 계속 돌아갑니다. 라이브러리 지원도 다릅니다. Log4j2에는 FATAL이 있지만 SLF4J 표준 API와 Logback에는 없습니다. Log4j API로 찍은 FATAL을 SLF4J로 넘기는 브리지도 ERROR로 바꿔 전달합니다. 그래서 이 조합에서는 대개 ERROR로 남기고 종료 여부를 메시지에 적습니다.

이미 쌓인 텍스트 로그를 전부 JSON으로 바꿔야 하나요

한 번에 바꾸지 않아도 됩니다. 수집 도구가 파서 규칙으로 기존 텍스트 로그에서 필드를 뽑아낼 수 있어서, 오래된 로그는 파서로 다루고 새로 만드는 서비스부터 구조화해 내보내는 방식이 현실적입니다. 다만 파서 규칙은 메시지 형식이 바뀌면 깨지므로, 형식을 자주 손보는 서비스일수록 먼저 옮기는 편이 낫습니다.

로그 레벨을 서버 재시작 없이 바꿀 수 있나요

주요 로깅 라이브러리는 대부분 실행 중 레벨 변경을 지원합니다. 설정 파일을 주기적으로 다시 읽는 기능을 켜 두거나, 관리용 엔드포인트로 특정 로거의 레벨만 바꾸는 방식을 씁니다. 조사할 때 전체를 DEBUG로 바꾸면 로그가 폭증하므로, 문제가 있는 패키지나 클래스 단위로만 바꾸는 것이 안전합니다.

예외 스택 트레이스는 어느 레벨에 남기나요

그 예외를 사람이 조치해야 하면 ERROR, 코드가 처리하고 넘어갈 수 있으면 WARN입니다. 판단 기준은 처리 결과이고, 예외 종류로 정하지 않습니다. 구조화 로깅에서는 스택 트레이스를 message 안에 문장으로 밀어 넣지 말고 별도 필드에 담습니다. 그래야 예외 종류별 집계가 됩니다.

로그에 개인정보가 섞이면 어떻게 하나요

남기기 전에 거르는 것이 원칙입니다. 구조화 로깅은 이 점에서 유리한데, 개인정보가 들어갈 수 있는 항목이 필드로 분리돼 있어 해당 필드만 골라 마스킹하거나 아예 뺄 수 있습니다. 문장 안에 섞여 있으면 정규식으로 찾아야 하고 놓치는 값이 생깁니다. 구체적인 처리 방법은 LLM 프롬프트 로그 개인정보 마스킹과 비식별화 가이드에서 다뤘습니다.

마치며

레벨은 나중에 로그를 거를 때 쓰는 조건이 됩니다. 구조화해 둔 필드는 거른 다음 값을 꺼내는 데 씁니다. 둘 다 로그를 남기는 시점에 정해지고, 그 시점을 지나면 되돌리기 어렵습니다. ERROR와 WARN의 경계를 팀 안에서 한 문장으로 합의해 두고, 모든 로그에 넣을 공통 필드 목록을 정해 두는 것부터 시작하면 충분합니다.

이미 텍스트로 쌓아 둔 로그에서 필드를 뽑아 검색하고 싶다면 와탭 로그 모니터링에서 파서 규칙을 설정해 비정형 로그의 필드를 추출하고, 추출한 필드로 검색과 집계를 걸 수 있습니다.

더 읽을거리

와탭 모니터링을 무료로 체험해보세요!