자작 logger의 최소 요건과 결합 테스트 체크리스트
· 업데이트: · Go Komura · Windows 개발, Logging, 결합 테스트, 테스트 설계, 신뢰성
수정 이력(7건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하십시오.
- 검증 스크립트의 BOM 검사가 동작하지 않았습니다. `Get-Content -Encoding utf8`은 앞쪽 BOM을 건너뛴 뒤 행을 반환하므로, BOM이 있는 파일이라도 `ConvertFrom-Json`은 그대로 통과하고 검사가 무력해집니다. 디코드하기 전에 앞 3바이트를 raw로 읽어 `EF BB BF`를 보도록 바꿨습니다.
- session ID를 시각과 PID만으로 만들던 것을 고쳤습니다. 크래시 루프로 같은 초 안에 다시 기동한 경우나, 로컬 시각이 되돌아간 경우에, Windows가 재사용한 PID와 맞물려 이전과 같은 ID가 될 수 있습니다. `JsonLinesLogger`는 `FileMode.Append`로 열기 때문에, 서로 다른 기동의 레코드가 한 파일에 섞이고, 「1기동 = 1세션」이라는 전제가 조용히 무너집니다. GUID를 더해 충돌하지 않는 형태로 만들었습니다.
- 필수 항목 검사가 프로퍼티 이름 유무만 보고, level 값이 null인 레코드까지 통과시키던 문제를 고쳤습니다. 표에서는 「null 혼입」을 찾아낼 수 있다고 적어 두었으므로, 값과 타입까지 검사하도록 바꿨습니다.
- 서두에 대상 독자와 전제 표, 그리고 「먼저 자작하지 않는 선택지를 확인한다」 표를 추가했습니다. JSON Lines의 실제 레코드 예와 사양상의 제약, v1 쓰기 부분의 C# 구현과 호출 쪽, `sessionId`의 정의와 부여 방법, 깨짐을 검출하는 3가지 관점과 이를 돌리는 PowerShell 스크립트를 추가하고, 참고 자료 절을 신설했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635232)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「자작 logger의 최소 요건과 결합 테스트 체크리스트」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635232 https://comcomponent.com/ko/blog/custom-logger-minimum-requirements-and-integration-test-checklist/
- DOI(최신 버전)
- 10.5281/zenodo.21635232
- DOI(이 버전)
- 10.5281/zenodo.21635233
이미 있는 logging framework를 쓸 수 있다면 그쪽이 안전합니다. 그래도 애플리케이션 쪽 제약이나 운영상의 사정으로 자작 logger를 피할 수 없는 경우가 있습니다. 그때 처음에 고민하기 쉬운 지점은, 어디까지 구현해야 「너무 허술하지도, 너무 무겁지도 않은」 설계가 되는가입니다.
이 글에서는 대상을 장애 조사용 애플리케이션 로그로 좁힙니다. 감사 증적, 분산 트레이싱, 메트릭 기반, 클라우드 집약까지 한 번에 떠안지 않고, 우선 현장에서 도움이 되는 최소 구성을 정의한 뒤, 그 구성을 실제로 신뢰할 수 있게 만들기 위한 결합 테스트 관점을 정리합니다.
대상 독자와, 이 글의 전제
| 항목 | 내용 |
|---|---|
| 대상 독자 | 업무 앱이나 도구에 자체 진단 로그를 넣는 개발자. 로그 기반 전임자가 없는 작은 팀을 가정합니다 |
| 설계의 적용 범위 | 언어에 의존하지 않습니다. 파일에 이어 쓸 수 있는 환경이면, C#이든 C++이든 판단은 같습니다 |
| 코드 예 | C# 12 / .NET 8과 PowerShell 7로 보입니다. 다른 언어라도, 같은 순서로 같은 판단을 하면 바꿔 쓸 수 있습니다 |
| 대상으로 하는 로그 | 애플리케이션 장애의 원인 분리에 쓰는 진단 로그 |
| 대상 밖 | 감사 증적, 분산 트레이싱, 메트릭 기반, 클라우드 집약 |
이 글에서 쓰는 용어
설계 이야기에 들어가기 전에, 이후 장에서 설명 없이 나오는 말을 모아 둡니다.
| 용어 | 의미 |
|---|---|
JSON Lines(.jsonl) |
한 행에 JSON 값 하나를 쓰고, 개행 (\n)으로 나누는 텍스트 형식. 문자 코드는 UTF-8이며, BOM을 붙이면 안 된다고 정해져 있습니다1 |
구조화 fields |
message 문장과는 별도로, 검색하고 싶은 값을 키와 값의 쌍으로 담는 그릇. {"file":"orders.csv","row":128} 같은 형태입니다 |
single writer |
파일에 실제로 쓰는 곳은 한 곳(스레드 하나)만으로 두는 설계. 호출 쪽이 몇 스레드여도, 쓰기 입구는 하나로 좁힙니다 |
bounded queue |
상한이 있는 큐. 호출 쪽은 큐에 넣기만 하고 돌아오고, 쓰기는 single writer 쪽이 합니다. 상한이 있으므로, 넘쳤을 때의 방침을 정해야 합니다 |
drain |
종료 시, 큐에 남은 로그를 끝까지 다 쓰는 일. 「넣었는데 빠져서 사라진」 일을 막는 처리입니다 |
flush |
메모리 위 버퍼를 실제 파일로 밀어 내는 일. 여기를 거치지 않은 로그는 비정상 종료에서 사라집니다 |
| 로테이션 | 파일이 커지거나 날짜가 바뀌었을 때, 새 파일로 전환하는 일 |
| 리텐션 | 오래된 로그 파일을 몇 개·며칠까지 남길지의 상한 |
먼저 「자작하지 않는」 선택지를 확인한다
서두에 적은 대로, 이미 있는 logging framework를 쓸 수 있다면 그쪽이 안전합니다. 판단할 수 있도록 구체 이름을 들어 둡니다. 여기서 충분하다면, 이 글의 나머지는 읽을 필요가 없습니다.
| 환경 | 선택지 | 처음부터 따라오는 것 |
|---|---|---|
| .NET | Microsoft.Extensions.Logging |
.NET 표준의 ILogger API. 로그 레벨(Trace부터 Critical), 카테고리, 출력 위치를 갈아 끼우는 provider 구조. 많은 .NET SDK에 암시적 참조로 들어 있습니다2 |
| .NET | Serilog | 구조화 이벤트를 전제로 한 진단 로그. 메시지 템플릿의 매개변수에 이름을 붙이고, 그 값을 이벤트의 프로퍼티로 유지합니다3 |
| .NET | NLog | 구조화 로그와 전통형 로그를 모두 지원. 출력 형식에 JSON layout이 있고, 파일 출력은 자동 명명과 archive를 갖습니다4 |
| C++ | spdlog | C++11 이후에서 쓰는 로그 라이브러리. 크기로 전환하는 rotating과, 날짜로 전환하는 daily 파일 출력이 있습니다5 |
이들을 쓸 수 없는 사정(의존을 늘릴 수 없음, 실행 환경이 한정됨, 기존 코드의 제약 등)이 있을 때, 이후의 최소 요건이 효과가 있습니다.
먼저 결론
첫 버전에서 잡고 싶은 요점은 다음과 같습니다.
- 형식은
UTF-8의JSON Lines로 둔다 - 1레코드 1행을 깨지 않는다
- 필수 항목은
시각,레벨,카테고리,메시지,구조화 fields,sessionId,processId - 기본은
1프로세스 1파일 - 부하가 낮으면 동기 쓰기, 많이 나오면
single writer + bounded queue Error/Critical과 세션 시작·종료는 동기 flush한다- 로테이션과 리텐션은 v1부터 넣는다
- 저장 위치를 쓸 수 없을 때, 조용히 다른 곳으로 보내지 않는다
이 정도까지 좁히면, 구현과 운영 양쪽에서 무너지기 어려워집니다.
이 글의 지식 맵
이 글은 장애 조사용 커스텀 로거에 대해, UTF-8 JSON Lines를 형식으로 하고, logSessionId와 processId를 포함한 필수 항목·single writer와 bounded queue에 의한 쓰기 설계·flush 조건·로테이션과 보존·저장 실패 시의 명시적 알림까지를 최소 요건으로 정리합니다. 기성 Microsoft.Extensions.Logging·Serilog·NLog·spdlog를 쓸 수 있으면 그쪽이 우선이며, 자체 구현은 의존성을 늘릴 수 없는 등의 제약이 있을 때의 선택지입니다. logSessionId를 시작 시각과 프로세스 ID만으로 만들면 OS의 PID 재사용으로 충돌해 로그가 섞이므로, GUID를 더하는 설계가 필요합니다. 마지막으로, 실제 파일·실제 스레드·실제 프로세스에서의 통합 테스트와 줄 단위 기계적 검증으로 커스텀 로거의 신뢰성을 확인하는 방법을 제시합니다.
flowchart LR
accTitle: 커스텀 로거의 지식 맵
accDescr: 커스텀 로거가 JSON Lines·logSessionId·single writer·bounded queue·flush·로테이션과 보존을 어떻게 조합하는지, 기성 프레임워크(ILogger·Serilog·NLog·spdlog)와의 역할 구분, 통합 테스트에서의 검증 관점의 관계를 보여주는 그림
custom_logger["자체 로거"]
logger_integration_test["로거의 통합 테스트"]
json_lines["JSON Lines"]
log_session_id["로그의 sessionId"]
process_id_log_field["processId(로그 항목)"]
single_writer["single writer(단일 기록 지점)"]
bounded_queue["bounded queue(상한 있는 큐)"]
log_flush["flush(로그 동기 쓰기)"]
log_rotation["로그 순환(로테이션)"]
log_retention["로그 보존(리텐션)"]
one_process_one_file["1프로세스 1파일"]
multi_process_shared_log_file["여러 프로세스의 동일 로그 파일 추가 기록"]
sessionid_collision["sessionId 충돌"]
log_record_mixing["서로 다른 기동의 로그 레코드 혼재"]
log_record_validation["로그 레코드의 기계적 검증"]
dotnet_ilogger["Microsoft.Extensions.Logging(ILogger)"]
diagnostic_app_log["장애 조사용 진단 로그"]
serilog["Serilog"]
nlog["NLog"]
spdlog["spdlog"]
log_storage_failure_handling["저장 실패 시 명시적 실패 알림"]
custom_logger -.->|"이용한다"| json_lines
custom_logger -->|"전제로 한다"| log_session_id
custom_logger -->|"전제로 한다"| process_id_log_field
custom_logger -.->|"이용한다"| single_writer
bounded_queue -->|"전제로 한다"| single_writer
custom_logger -.->|"이용한다"| bounded_queue
custom_logger -.->|"이용한다"| log_flush
custom_logger -.->|"이용한다"| log_rotation
custom_logger -.->|"이용한다"| log_retention
one_process_one_file -->|"권장되는 대응"| custom_logger
multi_process_shared_log_file -->|"사용은 비권장"| custom_logger
log_session_id -.->|"방지한다"| sessionid_collision
sessionid_collision -.->|"원인이 될 수 있다"| log_record_mixing
custom_logger -->|"에서 확인할 수 있다"| logger_integration_test
logger_integration_test -->|"이용한다"| log_record_validation
dotnet_ilogger -->|"권장되는 대응"| diagnostic_app_log
serilog -->|"권장되는 대응"| diagnostic_app_log
nlog -->|"권장되는 대응"| diagnostic_app_log
spdlog -->|"권장되는 대응"| diagnostic_app_log
custom_logger -.->|"권장되는 대응"| diagnostic_app_log
log_storage_failure_handling -->|"권장되는 대응"| custom_logger
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
우선 대상 범위를 좁힌다
자작 logger가 어려워지기 쉬운 이유는, 처음부터 무엇이든 다루려고 하기 때문입니다. 진단 로그, 감사 로그, 성능 측정, 분산 트레이스, 사용자 행동 분석을 하나의 구조로 묶으려 하면 요건이 한꺼번에 늘어납니다.
이번 대상은 애플리케이션 장애의 원인 분리에 쓰는 진단 로그입니다. 즉, 「언제」「어느 처리에서」「무엇이 일어났고」「그때 어떤 문맥이었는지」를 나중에 따라갈 수 있는 일을 우선합니다. 여기로 좁히는 것만으로도 첫 설계 판단은 꽤 편해집니다.
최소한으로 필요한 요건
1. 형식은 UTF-8 JSON Lines
평문 텍스트를 이어 붙여도 로그는 남길 수 있지만, 나중에 기계적으로 다루기 어려워집니다. 반대로 처음부터 무거운 독자 바이너리 형식으로 가면, 운영 시 observability가 떨어집니다.
그 중간으로 다루기 편한 것이 UTF-8의 JSON Lines입니다. 한 행이 한 레코드이면 텍스트로도 읽기 쉽고, 나중에 스크립트나 도구로 파싱하기도 쉬워집니다. 중간에 쓰기가 끊겨도, 깨진 것이 어느 행인지 가려내기 쉬운 점도 실무에 맞습니다.
형식으로서 정해져 있는 것은 다음 세 가지뿐입니다.1
- 한 행이 유효한 JSON 값 하나일 것(빈 행은 넣지 않음)
- 행 구분자는
\n - 문자 코드는
UTF-8.BOM(U+FEFF)은 붙이면 안 된다
확장자는 .jsonl이 관례입니다. BOM 금지는 놓치기 쉬운 데 비해 영향이 큽니다. BOM을 붙여 쓰면 첫 행만 다른 도구에서 파싱에 실패하고, 「첫 1행만 깨져 있다」는 알아보기 어려운 형태로 드러납니다.
2. 필수 항목을 처음에 고정한다
최소한 갖춰 두고 싶은 항목은 다음 7개입니다.
timestamplevelcategorymessagefieldssessionIdprocessId
실제 레코드 하나는 예를 들어 이렇게 됩니다(표시를 위해 줄바꿈했지만, 실제 파일에서는 개행 없는 1행입니다).
{"ts":"2026-04-02T01:15:03.4821567Z","level":"Error","category":"import","message":"가져오기에 실패했습니다","fields":{"file":"orders.csv","row":128,"reason":"date parse failed"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
같은 세션의 앞뒤를 나란히 두면, 이렇게 보입니다.
{"ts":"2026-04-02T01:15:00.1002233Z","level":"Info","category":"startup","message":"애플리케이션을 시작했습니다","fields":{"version":"1.4.2"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
{"ts":"2026-04-02T01:15:03.4821567Z","level":"Error","category":"import","message":"가져오기에 실패했습니다","fields":{"file":"orders.csv","row":128,"reason":"date parse failed"},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
{"ts":"2026-04-02T01:15:03.9900011Z","level":"Info","category":"shutdown","message":"애플리케이션을 종료합니다","fields":{"exitCode":1},"sessionId":"20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d","pid":8412}
키 이름은 짧아도 되지만, 한 번 정하면 바꾸지 않는 쪽이 더 중요합니다. 중간에 ts와 timestamp가 섞이면, 나중에 쓰는 파싱 스크립트가 한꺼번에 번거로워집니다.
message만의 문자열 로그로 두면, 나중에 검색 조건이 늘었을 때 곤란합니다. 반대로 항목을 너무 늘리면 호출 쪽 부담이 급격히 올라갑니다. 처음에는 이 정도로 고정하고, 추가는 정말로 필요해진 뒤에 검토하는 것이 안전합니다.
sessionId에 무엇을 넣을까
필수 항목에 넣은 이상, sessionId가 어떤 단위인지를 정해 둘 필요가 있습니다. 이 글에서는 프로세스의 한 번 기동을 1세션으로 둡니다. 사용자의 로그온 세션도, 업무상의 「거래」도 아닙니다.
이 정의로 두면 다음을 할 수 있습니다.
- 한 번 기동에서 나온 로그만 모아 꺼낼 수 있다
- 로테이션으로 파일이 나뉘어도, 같은 기동의 로그를 나중에 이어 붙일 수 있다
- 같은 단말에서 아침과 저녁에 난 장애 두 건을, 섞지 않고 가려낼 수 있다
sessionId는 프로세스 기동 때 한 번만 정하고, 그 프로세스가 끝날 때까지 그대로 씁니다. 방법은 다음 중 하나로 충분합니다.
| 방법 | 예 | 맞는 상황 |
|---|---|---|
기동 시각 + 프로세스 ID + GUID |
20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d |
기본은 이것. 앞쪽은 사람이 읽고, 뒤쪽에서 충돌하지 않는다 |
UUID(GUID)만 |
9f0a1c72-3b58-4f2a-9a2e-6e7c1f0d55b1 |
여러 단말의 로그를 나중에 한곳으로 모은다. 눈으로 읽을 필요는 없다 |
기동 시각 + 프로세스 ID만으로 끝내지 마십시오. processId는 OS가 재사용합니다. 크래시 루프로 같은 초 안에 다시 기동한 경우나, 로컬 시각이 되돌아간 경우에, 이전과 완전히 같은 sessionId가 될 수 있습니다. 파일 이름에도 같은 값을 쓰는 설계라면, append 모드로 연 순간에 서로 다른 기동의 로그가 한 파일에 섞이고, 「1기동 = 1세션」이라는 전제가 조용히 무너집니다. 게다가 깨지는 방식이 조용해서, 나중에 알아채지 못합니다.
processId를 별도 항목으로도 두는 것은, sessionId를 「어느 기동인가」, processId를 「그때 OS 위의 실체」로 나눠 쓰기 위해서입니다.
3. 1프로세스 1파일을 기본으로 한다
여러 프로세스에서 같은 파일에 이어 쓰게 하는 설계는, 겉보기 이상으로 사고 요인이 많아집니다. exclusive lock, 부분 쓰기, 로테이션 타이밍, 비정상 종료 시 처리가 한꺼번에 어려워지기 때문입니다.
우선은 1프로세스 1파일을 기본으로 하십시오. 여러 프로세스를 모으고 싶다면, 후단에서 모으거나 전용 수집 프로세스를 명시적으로 세우는 편이 안전합니다.
4. 쓰기 전략은 부하로 나눈다
로그 양이 적은 단계에서는 동기 쓰기가 알기 쉽고, 장애 조사도 편합니다. 억지로 비동기화하면 종료 직전 로그를 잃거나, 예외 시 flush 조건이 모호해집니다.
한편 로그 양이 많아져 동기 I/O가 병목이 되면, single writer + bounded queue를 씁니다. 즉, 호출 쪽은 상한 있는 큐에 넣기만 하고 돌아오고, 파일에 쓰는 곳은 한 곳만으로 둔다는 형태입니다. 이 생각 자체는 드문 것이 아니며, .NET의 로그 설계 지침에서도, 느린 저장 위치에 직접 쓰지 않고, 메모리 위 큐에 동기로 넣은 뒤 백그라운드 처리로 내보내는 형태가 안내되어 있습니다.2
이때 중요한 것은, 큐가 넘쳤을 때의 방침을 먼저 정하는 일입니다. 오래된 로그를 버릴지, 새 로그를 버릴지, 경고를 낼지를 모호하게 두지 마십시오.
5. flush 조건을 정한다
Error와 Critical, 그리고 세션 시작·종료 로그는 동기 flush해 두면 장애 조사에 도움이 됩니다. 평소의 Info까지 전부 flush하면 느려지므로, 전부를 같은 취급으로 두지 않는 것이 현실적입니다.
6. 로테이션과 리텐션은 v1부터 넣는다
로테이션은 「나중에 넣으면 된다」고 생각되기 쉽지만, 운영에 들어가면 갑자기 곤란해지는 기능입니다. 크기, 일 단위, 기동마다 등 방식은 무엇이든 좋으니, 적어도 「무한히 늘어나지 않을 것」과 「몇 개를 남길지」가 정해진 상태로 두어야 합니다.
7. 저장 실패 시에 멋대로 대체 저장을 하지 않는다
로그 저장 위치를 쓸 수 없을 때, 조용히 다른 곳에 쓰는 설계는 나중에 조사를 어렵게 합니다. 운영 담당이 「있어야 할 곳」에 로그만 없어도, 장애 대응의 초동이 늦어집니다.
저장할 수 없다면, 앱 쪽 알림, 이벤트 로그, 표준 오류 등 명시적으로 알 수 있는 수단으로 실패를 겉으로 내십시오. 적어도 「어디로 갔는지 모르는」 상태는 피해야 합니다.
v1의 최소 구성 이미지
첫 버전에서는 다음 정도로 충분한 경우가 많습니다.
UTF-8 JSON Lines- 1프로세스 1파일
- 세션 단위의 파일 이름
- 크기 기반 또는 기동 단위의 로테이션
- 리텐션 개수의 상한
Error/Critical의 동기 flush- 구조화
fields를 받을 수 있는 API
이 이상의 기능은, 실제 운영에서 「정말로 곤란했던 일」이 보인 뒤에 붙이는 편이, 결과적으로 유지보수하기 쉬워집니다.
v1의 쓰기 부분을 C#으로 쓰면 어떻게 되는가
위의 요건 가운데 형식·필수 항목·single writer·flush 조건만 구현하면, 이 정도의 분량입니다(C# 12 / .NET 8). 로테이션과 리텐션은 다음 단계에서 붙일 전제로, 일부러 넣지 않았습니다.
using System.Text;
using System.Text.Json;
public sealed class JsonLinesLogger : IDisposable
{
// 한국어를 그대로 출력한다. 기본 인코더는 non-ASCII를 모두 \uXXXX로 변환한다
private static readonly JsonSerializerOptions JsonOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private static readonly IReadOnlyDictionary<string, object?> NoFields =
new Dictionary<string, object?>();
private readonly object _gate = new(); // single writer를 이 lock으로 지킨다
private readonly StreamWriter _writer;
private readonly string _sessionId;
private readonly int _processId = Environment.ProcessId;
public JsonLinesLogger(string path, string sessionId)
{
_sessionId = sessionId;
// JSON Lines는 BOM 금지하므로, BOM을 쓰지 않는 UTF-8을 명시한다
_writer = new StreamWriter(
new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
public void Write(
string level,
string category,
string message,
IReadOnlyDictionary<string, object?>? fields = null)
{
var record = new Dictionary<string, object?>
{
["ts"] = DateTimeOffset.UtcNow.ToString("o"),
["level"] = level,
["category"] = category,
["message"] = message,
["fields"] = fields ?? NoFields, // null로 두지 않고, 항상 7항목을 맞춘다
["sessionId"] = _sessionId,
["pid"] = _processId,
};
// 개행이 들어 있는 message가 와도 1행이 깨지지 않도록, JSON화한 뒤에 쓴다
var line = JsonSerializer.Serialize(record, JsonOptions);
lock (_gate)
{
_writer.WriteLine(line);
if (level is "Error" or "Critical")
{
_writer.Flush(); // 중대한 로그만 동기 flush한다
}
}
}
public void Dispose()
{
lock (_gate)
{
_writer.Flush(); // 정상 종료 시 반드시 다 쓴다
_writer.Dispose();
}
}
}
호출 쪽은 이렇게 됩니다. sessionId는 기동 때 한 번만 정하고, 파일 이름에도 같은 값을 씁니다.
// 앞에서 든 JsonLinesLogger를 씁니다
var startedAt = DateTimeOffset.Now;
// 시각과 PID만으로는 부족합니다. 크래시 루프로 같은 초 안에 다시 기동한 경우나,
// 로컬 시각이 되돌아간 경우에, Windows가 재사용한 PID와 맞물려
// 이전과 같은 ID가 될 수 있습니다. JsonLinesLogger는 FileMode.Append로 여므로,
// 그때 서로 다른 기동의 레코드가 한 파일에 섞이고,
// 「1기동 = 1세션」이라는 전제가 조용히 무너집니다
var sessionId = $"{startedAt:yyyyMMdd-HHmmss}-{Environment.ProcessId}-{Guid.NewGuid():N}";
var logDir = @"C:\ProgramData\MyApp\logs";
Directory.CreateDirectory(logDir);
using var logger = new JsonLinesLogger(Path.Combine(logDir, $"app-{sessionId}.jsonl"), sessionId);
logger.Write("Info", "startup", "애플리케이션을 시작했습니다",
new Dictionary<string, object?> { ["version"] = "1.4.2" });
logger.Write("Error", "import", "가져오기에 실패했습니다",
new Dictionary<string, object?> { ["file"] = "orders.csv", ["row"] = 128 });
짧은 코드이지만, 정한 일은 전부 들어가 있습니다.
| 정한 일 | 어디서 효과가 있는가 |
|---|---|
BOM 없는 UTF-8 |
UTF8Encoding(encoderShouldEmitUTF8Identifier: false) |
한국어를 \uXXXX로 만들지 않는다 |
JavaScriptEncoder.UnsafeRelaxedJsonEscaping6 |
| 1레코드 1행 | message를 직접 이어 붙이지 않고, JsonSerializer.Serialize 결과를 WriteLine한다 |
| 필수 7항목을 매번 넣는다 | fields가 미지정이어도 NoFields를 넣어 빠지지 않게 한다 |
single writer |
lock (_gate) 안에서만 _writer를 건드린다 |
flush 조건 |
Error / Critical일 때만 즉시 Flush(), 종료 시는 Dispose()에서 반드시 Flush() |
UnsafeRelaxedJsonEscaping은 < > & '를 이스케이프하지 않으므로, 이 출력을 그대로 HTML 페이지나 script 요소에 넣으면 안 됩니다.6 로그 파일로 읽는 용도에 한정해 쓰십시오.
흔한 NG
피하고 싶은 전형 예도 들어 둡니다.
message문자열에 전부를 밀어 넣는다- 여러 프로세스에서 같은 파일을 공유한다
- flush 조건을 정하지 않고 전면 비동기화한다
- 로테이션과 리텐션을 뒤로 미룬다
- 저장 실패 시 조용히 다른 폴더로 보낸다
- 네트워크 전송이나 로컬 DB 저장까지 v1에 넣는다
어느 것이나 겉보기에는 편리해 보이지만, 원인 분리나 운영을 무겁게 만들기 쉬운 항목입니다.
결합 테스트는 실제 파일·실제 스레드·실제 프로세스로 생각한다
logger는 유닛 테스트만으로는 안심하기 어려운 부품입니다. 문자열 정형이나 JSON화만 검증해도, 실제 운영에서 문제가 되는 것은 I/O, 동시성, 로테이션, 종료 시 flush, 권한 오류 쪽이기 때문입니다.
그래서 결합 테스트에서는 실제 파일, 실제 스레드, 필요하면 실제 프로세스를 써서 확인하게 됩니다. 적어도 「평소에는 통과하지만, 장애 시에는 신뢰할 수 없다」는 상태는 피하고 싶습니다.
통과시켜 두고 싶은 결합 테스트 항목
단일 쓰기의 건전성
- 한 행이 JSON 레코드 하나로 되어 있는가
UTF-8로 다시 읽을 수 있는가- 필수 항목이 매번 들어가 있는가
- 개행 혼입으로 여러 행으로 깨져 있지 않은가
같은 프로세스 안의 동시 실행
- 여러 스레드에서 동시에 써도 레코드가 깨지지 않는가
- 레코드 수의 과부족이 없는가
- queue를 쓸 때 순서나 누락 방침이 사양대로인가
flush와 종료 시 동작
Error/Critical이 즉시 반영되는가- 정상 종료 시 queue 안이 비는가
- 예외 종료에 가까운 경로에서도 필요한 종료 로그가 남는가
로테이션과 리텐션
- 로테이션 조건을 충족하면 새 파일로 전환되는가
- 리텐션 상한을 넘은 오래된 파일이 사양대로 삭제되는가
- 로테이션 직전·직후에도 JSON 행이 깨지지 않는가
이상 경로
- 저장 위치 디렉터리가 없을 때의 처리
- 쓰기 권한이 없을 때의 처리
- 디스크가 가득 찬 경우에 준하는 실패 시의 알림이나 반환값
- queue가 넘쳤을 때의 동작
여러 프로세스의 처리
사양이 1프로세스 1파일이라면, 다른 프로세스가 같은 파일에 들어가려 하지 않는 것 자체를 확인 대상으로 삼을 수 있습니다. 반대로 수집 프로세스 방식이라면, 그쪽으로의 전달 실패까지 포함해 확인이 필요합니다.
「깨짐」을 어떻게 검출하는가
관점만 늘어놓아서는 테스트를 쓸 수 없습니다. 위 항목 가운데 구현에서 헤매기 쉬운 것은 「레코드가 깨져 있지 않음」을 어떻게 판정하는가입니다. 육안으로는 절대 부족하므로, 다음 세 가지를 기계적으로 확인합니다.
| 보는 것 | 판정 방법 | 이것으로 찾아내는 깨짐 |
|---|---|---|
| 행 수 | 쓴 건수와 파일의 행 수가 일치하는가 | 누락, 이중 쓰기, drain 누락 |
| 각 행 | 모든 행이 단독으로 JSON으로 파싱되는가 | 개행 혼입, 쓰기 끼어들기, BOM 혼입 |
| 각 레코드 | 필수 7항목이 매번 갖춰져 있는가 | 항목 누락, null 혼입 |
「앞 10행을 보고 괜찮아 보인다」는 동시 쓰기 테스트에서는 거의 도움이 되지 않습니다. 깨지는 것은 반드시 가운데 어디엔가 한 행이기 때문입니다. 전 행을 통과시키는 데 의미가 있습니다.
PowerShell 7로 쓰면, 이 세 가지는 모아서 이렇게 확인할 수 있습니다. 테스트 후처리에서 호출하는 전제입니다.
# 출력된 로그를 전 행 검증한다. 한 곳이라도 깨져 있으면 throw해서 떨어뜨린다
$path = 'C:\ProgramData\MyApp\logs\app-20260402-101500-8412-9f3c1d2a5b7e4f689a0c3d5e7f1b2c4d.jsonl'
$expected = 10000 # 테스트 쪽이 Write를 호출한 횟수
# 「이름이 있는 것」만으로는 부족합니다. `"level": null` 같은 레코드라도
# 프로퍼티 이름은 존재하므로, 이름 유무만의 검사는 통과해 버립니다.
# null 혼입을 잡으려면, 값과 타입까지 봐야 합니다
$required = [ordered]@{
'ts' = { param($v) $v -is [string] -and $v -ne '' }
'level' = { param($v) $v -is [string] -and $v -ne '' }
'category' = { param($v) $v -is [string] -and $v -ne '' }
'message' = { param($v) $v -is [string] } # 빈 문자열은 허용하되 null은 허용하지 않는다
'fields' = { param($v) $v -is [pscustomobject] }
'sessionId' = { param($v) $v -is [string] -and $v -ne '' }
'pid' = { param($v) ($v -is [int] -or $v -is [long]) -and $v -gt 0 }
}
# BOM 검사는, 디코드하기 전에 raw 바이트로 합니다.
# Get-Content -Encoding utf8은 앞쪽 BOM을 건너뛴 뒤 행을 반환하므로,
# 디코드 후 문자열을 아무리 봐도 BOM 혼입은 잡히지 않습니다
# (5.1이라면 -AsByteStream 대신 -Encoding Byte)
$head = @(Get-Content -LiteralPath $path -AsByteStream -TotalCount 3)
if ($head.Count -ge 3 -and $head[0] -eq 0xEF -and $head[1] -eq 0xBB -and $head[2] -eq 0xBF) {
throw '파일 앞에 UTF-8 BOM이 있습니다. JSON Lines로서 올바르지 않습니다'
}
$lines = @(Get-Content -LiteralPath $path -Encoding utf8)
if ($lines.Count -ne $expected) {
throw "행 수가 일치하지 않습니다. 기대 $expected 행 / 실제 $($lines.Count) 행"
}
$lineNo = 0
foreach ($line in $lines) {
$lineNo++
try {
$record = $line | ConvertFrom-Json
}
catch {
throw "$lineNo 행이 JSON으로 읽히지 않습니다: $line"
}
$names = $record.PSObject.Properties.Name
foreach ($name in $required.Keys) {
if ($names -notcontains $name) {
throw "$lineNo 행에 필수 항목이 없습니다: $name"
}
if (-not (& $required[$name] $record.$name)) {
$shown = if ($null -eq $record.$name) { '(null)' } else { "'$($record.$name)'" }
throw "$lineNo 행의 $name 값이 올바르지 않습니다: $shown"
}
}
}
"OK: $($lines.Count) 행 모두가 1행 1레코드로 읽혔습니다"
이 형태로 두면, 그대로 다음 테스트에도 재사용할 수 있습니다.
- 동시 쓰기: 여러 스레드에서
n회 쓰고,$expected를n으로 두고 통과시킨다 - 로테이션과 리텐션: 로테이션 뒤에 남은 전 파일에 같은 검증을 걸고, 행 수는 합계로 대조한다
- 종료 시
drain: 종료 처리를 끼운 뒤에 검증하고, 넣은 건수와 일치하는지를 본다
이상 경로는 이 형태로는 잴 수 없으므로, 따로 봅니다. 저장 위치 디렉터리가 없다, 쓰기 권한이 없다, 같은 조건을 먼저 만든 뒤에 logger를 초기화하고, 예외나 반환값이나 알림 중 하나로 실패가 겉으로 나오는 것을 확인합니다. 여기서 「아무 일도 없이 삼켜지는」 구현이면, 운영에서 가장 곤란한 깨짐이 됩니다.
v1에서 최소한 통과시키고 싶은 개수
처음에 전부 하려고 하면 테스트가 너무 무거워집니다. v1에서 최소한 통과시키고 싶은 것은, 다음 6개 정도입니다.
- 단일 스레드에서의 정상 쓰기
- 여러 스레드 동시 쓰기
Error/Critical의 flush- 로테이션과 리텐션
- 저장 위치 이상 시의 실패 알림
- 정상 종료 시의 drain과 최종 flush
이 6개가 통과되어 있는 것만으로도, 「문자열은 나왔지만 운영에서 신뢰할 수 없는 logger」에서는 꽤 멀어집니다.
정리
자작 logger의 첫 목표는 고기능화가 아니라 「장애 시에 믿을 수 있는 것」입니다. 그러려면 형식을 UTF-8 JSON Lines로 고정하고, 필수 항목을 좁히고, 1프로세스 1파일을 기본으로 하며, flush·로테이션·리텐션·실패 시 동작을 일찍 정해 두는 것이 유효합니다.
그리고 그 설계가 정말로 동작하는지는, 실제 파일·실제 스레드·실제 프로세스를 쓰는 결합 테스트에서 확인할 필요가 있습니다. 구현을 키우기 전에, 우선 최소 구성과 최소한의 테스트 세트를 먼저 굳혀 두면, 나중에 무리 없이 키우기 쉬워집니다.
참고 자료
-
JSON Lines, JSON Lines ↩ ↩2
-
Microsoft Learn, Logging in C# - .NET ↩ ↩2
-
gabime, spdlog - Fast C++ logging library ↩
-
Microsoft Learn, How to customize character encoding with System.Text.Json ↩ ↩2
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
유닛 테스트와 결합 테스트의 경계를 어디에 둘 것인가
유닛 테스트와 결합 테스트의 경계를 순수 로직, 포맷, 배선, 환경 차이, 시간 의존의 관점에서 정리하고, 실무에서 덜 헤매는 판단표로 모읍니다.
예기치 않은 예외가 났을 때 종료할지 계속할지 판단표
예상하지 못한 예외가 났을 때 앱을 종료할지 계속할지를, 상태 파괴·외부 부작용·스레드·네이티브 경계의 관점에서 정리합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리
네이티브 코드에서 CreateThread를 마구 늘리고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 설명합니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
절전에서 재개하면 깨지는 앱 ── 전원 이벤트의 구조와 재개에 강한 업무 앱을 만드는 방법
노트북을 열었더니 업무 앱의 통신이 끊어져 있었다――원인은 절전을 전제하지 않은 설계입니다. WM_POWERBROADCAST에 의한 알림의 흐름, Modern Standby의 동작, 끊김·재연결 설계, 절전 억제와 조사 명령까지를 1차 정보로 설...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
Windows 도구나 업무 앱에서 로그 설계·구현·운영을 현장 요건에 맞춰 정리하는 주제와 잘 맞기 때문입니다.
기술 상담 & 설계 리뷰
로그 형식, 로테이션, 이상 시 동작, 결합 테스트 범위를 구현 전에 정리하는 일 자체가 기술 상담의 소재가 되기 쉽기 때문입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 자작 logger의 로그 형식은 무엇으로 해야 하나요?
- UTF-8 JSON Lines로, 1레코드 1행을 깨지 않는 형식을 권합니다. 평문 텍스트를 이어 붙이면 나중에 기계적으로 다루기 어렵고, 독자 바이너리 형식은 운영 시 observability가 떨어집니다. JSON Lines는 텍스트로 읽기 쉽고, 스크립트나 도구로 파싱하기 쉬우며, 중간에 쓰기가 끊겨도 깨진 행을 가려내기 쉬운 점이 실무에 맞습니다. 필수 항목은 timestamp, level, category, message, 구조화 fields, sessionId, processId의 7개로 고정합니다.
- 로그 쓰기는 동기와 비동기 중 어느 쪽으로 해야 하나요?
- 부하로 나눕니다. 로그 양이 적은 단계에서는 동기 쓰기가 알기 쉽고 장애 조사도 편합니다. 억지로 비동기화하면 종료 직전 로그를 잃거나, 예외 시 flush 조건이 모호해집니다. 로그 양이 많아 동기 I/O가 병목이 되면 single writer + bounded queue를 쓰고, 큐가 넘쳤을 때 오래된 로그를 버릴지 새 로그를 버릴지의 방침을 먼저 정합니다. Error/Critical과 세션 시작·종료 로그는 동기 flush해 두면 장애 조사에 도움이 됩니다.
- 여러 프로세스에서 같은 로그 파일에 써도 되나요?
- 피해야 합니다. 여러 프로세스에서 같은 파일에 이어 쓰게 하는 설계는 exclusive lock, 부분 쓰기, 로테이션 타이밍, 비정상 종료 시 처리가 한꺼번에 어려워지고, 겉보기 이상으로 사고 요인이 많아집니다. 기본은 1프로세스 1파일로 두고, 여러 프로세스를 모으고 싶다면 후단에서 모으거나 전용 수집 프로세스를 명시적으로 세우는 편이 안전합니다.
- 자작 logger의 결합 테스트에서는 무엇을 확인해야 하나요?
- 실제 파일·실제 스레드·실제 프로세스로 확인합니다. 문자열 정형이나 JSON화의 유닛 테스트만으로는, 실제 운영에서 문제가 되는 I/O, 동시성, 로테이션, 종료 시 flush, 권한 오류를 잡지 못합니다. v1에서 최소한 통과시키고 싶은 것은 6개로, 단일 스레드 정상 쓰기, 여러 스레드 동시 쓰기, Error/Critical의 flush, 로테이션과 리텐션, 저장 위치 이상 시 실패 알림, 정상 종료 시 drain과 최종 flush입니다. 이 6개가 통과되면, 운영에서 신뢰할 수 없는 logger에서는 꽤 멀어집니다.