로그 한 줄에도 규약이 필요하다
console.log에 익숙한 팀이 구조화 로깅으로 옮겨갈 때, 로그 라이브러리만 바꿔서는 절반도 성공하지 못한다. 로그 레벨을 어떤 기준으로 나눌지, 필드 명명과 요청 식별자를 어떻게 통일할지, 민감정보를 어느 계층에서 마스킹할지, 그리고 로그 볼륨이 늘어났을 때 비용을 누가 어디서 감당할지가 코드보다 먼저 합의되어야 한다. 합의 없이 찍힌 로그는 검색도 어렵고 보안 사고의 실마리도 된다. 전환 전에 정해야 할 결정들을 하나씩 점검한다.
DevInsight에서 발행해요
구조화 로깅을 도입하는 팀의 절반은 같은 경로로 무너진다. 로그 라이브러리를 pino나 slog 같은 것으로 교체하고, 첫 JSON 라인이 찍힌 순간을 '성공 시점'으로 기억한다. 한 달 뒤 모니터링 대시보드를 만들려고 로그를 쿼리하는 순간 그 기억이 틀렸다는 게 드러난다. JSON은 구조를 만들 뿐이고, 그 구조 안에 무엇을 담을지는 전적으로 사람이 정한다.
라이브러리 교체는 하루면 끝난다. 레벨 기준과 필드 규약은 두 달이 지나도 합의가 안 된다. 전환에서 가장 오래 붙잡는 지점은 네 군데다. 레벨, 필드, 마스킹, 비용. 각각이 독립적으로 보이지만 전부 연결되어 있다.
error와 warn 사이에는 경계선이 없다
레벨 논쟁의 대부분은 'error인데 복구 가능한 경우'에서 터진다. 결제 재시도가 한 번 실패하고 자동으로 재시도에 들어가면, status 500이 찍혀도 error가 아니다. 반대로 200이지만 응답이 3초 늦은 경로는 warn일 만한 사건이다. 상태 코드와 레벨은 정비례하지 않는다. 이걸 전제로 깔지 않으면 error는 곧잘 '조금 심한 warn'으로 수렴한다.
레벨의 기준을 '문제의 크기'가 아니라 '인간의 행동 필요 여부'로 잡아두면 상당 부분이 해소된다. info는 읽어도 할 일이 없는 라인, warn은 시스템이 스스로 복구하지만 관심을 둬야 하는 라인, error는 사람이 손을 대야 하는 라인. 이 기준으로 서로의 로그를 검토하는 자리를 한 번 열어보면, error로 분류된 라인의 절반쯤이 warn으로 내려간다. 서면 규약보다 소수의 실제 사례가 기준을 만든다.
그래도 애매한 경우는 기계적인 임계값을 임시로 쓰는 방법이 있다. warn = 상태 코드 400+ 또는 P99 지연 초과 같은 식이다. 나쁜 규칙이 없는 것보다는 낫다. 한 달 뒤 실제 소음을 보고 수정하면 된다. 반대로 놓치기 쉬운 지점은 debug의 통제다. 라이브러리 내부 로그는 전부 debug로 보내고, 운영 환경의 기본 레벨을 info로 두면 debug에 무엇이 들어가든 비용 면에서 안전하다.
요청 ID는 어디까지 흘러야 하나
필드 설계의 첫 작업은 공통 필드 세트를 정하는 일이다. timestamp는 ISO 8601, UTC로 고정하고, service와 level과 msg는 필수로 둔다. 여기에 environment와 version까지 넣는 팀이 많다. 스키마보다 먼저 무너지는 곳은 명명이다. 한 서비스 안에서 user_id와 userId가 공존하게 되는 순간, 로그 검색은 두 갈래로 갈라진다. 필드 사전 하나를 만들고, 새 필드는 사전에 등록할 때만 추가하는 절차가 있어야 한다. msg 역시 '동작: 세부사항'처럼 동사로 시작하는 규칙을 정해두면 검색 문법이 단순해진다.
요청 ID는 필드 규약 중에서도 전파가 까다롭다. HTTP 미들웨어에서 만드는 부분은 어렵지 않다. 진짜 문제는 그 뒤다. 비동기 핸들러, 메시지 큐, 배치 작업으로 작업이 넘어갈 때 ID가 끊긴다. 큐에 메시지를 넣는 시점에 요청 ID를 메시지에 실어야 하고, 배치 작업은 배치 ID를 별도로 부여하는 편이 깔끔하다. ID가 끊긴 로그는 수집률이 99.9%여도 상관관계 추적이 불가능하다. 이 항목은 설계 단계에서 최우선으로 둬야 한다.
마스킹은 로거가 아니라 데이터 입구에서
민감 정보는 로그에 실리기 전에 제거하는 게 원칙이다. 로거에 마스킹 옵션을 거는 건 두 번째 방어선일 뿐이다. 호출 지점이 하나가 아니기 때문이다. 예외 메시지 안에 쿼리 문자열이 통째로 들어가면 로거 필터가 제거하지 못하고, 외부 라이브러리가 debug 레벨에서 요청 본문을 찍는 일도 막지 못한다.
가장 자주 터지는 구멍은 URL이다. access token이 쿼리 파라미터로 붙는 사례인데, 이건 로그 필터로 해결할 문제가 아니다. 쿼리 파라미터를 로그에 기록하지 않는다는 규칙을 세우고, 토큰이 쿼리에 남는 패턴은 인시던트 매뉴얼에 적어둬야 한다. 그리고 마스킹 검증은 자동화해야 한다. 테스트 환경에서 실제 비밀번호 문자열을 발생시켜 로그에 남는지 확인하는 스크립트 하나면 마스킹이 '있다고 믿는 것'과 '실제로 동작하는 것' 사이의 간격을 줄여준다.
마스킹을 어느 계층에서 수행할지도 정해야 한다. 로그를 만드는 계층에서 하는 쪽을 권한다. 로그 출력 계층에 맡기면 필터 목록이 두 군데로 나뉘어 관리된다. 그리고 마스킹 규칙에 '검증 주체'를 지정해야 한다. 아무도 주인이 아닌 규칙은 갱신되지 않는다.
JSON은 텍스트보다 비싸다
전환 직후 가장 먼저 체감되는 건 로그의 무게다. 필드 20개짜리 JSON 라인 하나가 500바이트 안팎이고, 초당 수천 건의 요청이면 하루 수십 GB가 쌓인다. 텍스트 로그보다 몇 배는 무겁고, 수집·저장·검색 어느 단계에서든 비용이 붙는다.
비용의 절반은 '찍지 않아도 되는 로그'에서 나온다. 재시도 루프가 하나라도 info 레벨로 로그를 남기면 500ms마다 같은 라인이 반복된다. 헬스 체크와 keep-alive 요청은 기본적으로 로그에서 제외하는 게 정석이고, 볼륨이 큰 경로에는 샘플링을 적용하는 판단도 필요하다. 한 줄에 하나의 이벤트라는 규칙도 미리 정해둬야 한다. 스택 트레이스를 그대로 JSON 값에 넣으면 파서가 라인을 잘못 자르고, 수집 파이프라인에 병합 로직이 추가된다. 이쯤 되면 로그 포맷은 개발팀의 문제가 아니라 인프라의 문제다.
저장 계층의 설계도 합의 항목이다. 최근 7~30일은 고속 검색 대상으로 두고, 그 뒤는 저비용 스토리지로 옮기는 구조가 일반적이다. 무제한 보존은 자산이 아니라 부채다. 서비스별 로그 수와 레벨별 분포를 대시보드에 올려두면, 어느 서비스가 갑자기 10배로 뛰었을 때 코드 분석보다 먼저 원인을 짚을 수 있다. 버그 추적을 위해 잠시 debug로 올렸다가 내려놓지 않아 비용이 치솟는 사례도 반복되는데, 레벨 변경을 코드 리뷰 대상에 넣으면 그중 상당수는 걸러낸다.
로그는 코드와 함께 리뷰된다
전환 검증은 배포 두세 번으로 끝나지 않는다. 핵심 트랜잭션 하나를 정해, 그 트랜잭션이 요청 ID 하나로 전 구간에서 이어지는지 직접 따라가 보는 작업이 필요하다. 검색 도구에서 필드 자동완성이 뜨고, 시간 범위 필터가 의도대로 동작하는지까지 확인해야 한다. 마이그레이션 성공 판정은 'JSON이 찍힘'이 아니라 '쿼리가 원하는 답을 줌'이어야 한다.
마지막 남은 합의 항목은 사람에 관한 것이다. 로그 라인은 PR에서 지나치는 항목이 되기 쉽다. 로그 추가가 코드 리뷰에서 무시되는 팀은 6개월 안에 필드 규약이 흩어진다. 이건 도구의 문제가 아니라 절차의 문제다.
레벨 기준이 애매하면 error가 늘고, error가 늘면 알림이 묻히고, 안 읽히는 로그가 보안 사고의 실마리가 되는 순서다. 라이브러리를 설치하기 전에 레벨 기준과 공통 필드와 마스킹 계층과 보존 기간을 한 장의 문서로 만들어두는 것부터 시작하라. 로그 라이브러리는 그 문서를 실행하는 도구에 불과하다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
이미지를 얇게 만들수록 빌드가 비대해지는 역설
Next.js/Node.js 컨테이너를 multi-stage 빌드와 standalone 출력으로 최적화하다 보면 용량은 80% 줄었는데 빌드 시간은 되레 늘고, 캐시 무효화로 매 배포마다 의존성을 새로 내려받는 경우가 생긴다. 이 글은 무엇을 줄여야 하고 무엇은 절대 건드리면 안 되는지를 가르는 기준을 결정 메모 형식으로 정리하고, 최적화가 역효과를 내는 지점을 짚는다.
삽질 없이 CI를 줄이는 캐시 3종 세트
GitHub Actions에서 의존성 캐시와 빌드 캐시를 도입했는데도 정작 빌드가 느리거나, 캐시가 오히려 잘못된 결과를 재사용하며 깨지는 경험을 해봤다면 이 글이 답이다. cache와 setup-*의 동작 차이, 매트릭스 분할 전략, 캐시 무효화 판단 기준을 함정과 함께 정리해 실패 없이 CI 시간을 단축하는 법을 다룬다.
INFO, WARN, ERROR만으로는 부족하다
console.log에서 JSON 로그로 가는 건 시작일 뿐이다. 로그 레벨을 모호하게 정의하면 알람이 무의미해지고, 스키마 없이 쌓은 로그는 검색조차 불가능하다. 마스킹을 미루면 개인정보가 로그 플랫폼에 그대로 노출된다. 급증하는 로그 비용도 간과할 수 없다. 이 글은 로그 레벨 기준, 공통 필드, 마스킹, 비용 거버넌스 등 구조화 로깅 도입 전에 반드시 정해야 할 결정들을 기록한다.
이전 글
node_modules를 두 번 지운 뒤에야 패키지 매니저를 다시 봤다
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.