chore: 로그 출력을 비동기 appender 로 분리 - #154
Merged
Merged
Conversation
Logback의 ConsoleAppender는 OutputStreamAppender를 상속하고 write가 동기화돼 있다. 톰캣 스레드 100개가 한 스트림에 쓰면 그 락에서 직렬화되고, 로그 한 줄의 비용이 그대로 응답시간에 실린다. #151에서 dev의 SQL 콘솔 출력을 끄자 납부자 목록 조회 p95가 452ms에서 240ms로 줄었다. 출력량을 줄여 절반을 회수했지만 구조는 그대로였다. LoggingFilter가 요청마다 남기는 한 줄이 여전히 같은 경로로 나간다. 출력을 더 줄이는 대신 요청 스레드에서 I/O를 떼어낸다. AsyncAppender 기본값 두 개는 의도와 맞지 않아 명시적으로 바꿨다. - discardingThreshold: 기본값 20은 큐가 80% 차면 INFO 이하를 조용히 버린다. LoggingFilter의 요청 로그가 INFO라 부하 상황에서 소리 없이 사라진다. 0으로 두어 레벨과 무관하게 큐에 자리가 있는 한 남긴다. - neverBlock: 기본값 false는 큐가 가득 차면 요청 스레드를 블로킹한다. 비동기로 만든 이유가 사라지므로 true로 둔다. 응답시간을 지키는 게 목적이라 로그 유실보다 블로킹을 더 나쁘게 봤다. 커스텀 설정 파일이 있으면 Spring Boot 기본 로깅 설정이 통째로 대체되므로, 로그 포맷과 CONSOLE appender 정의는 defaults.xml과 console-appender.xml을 include해 그대로 쓴다. 목적지는 콘솔 그대로 두었다. CapRover가 stdout을 수집하는 구조라 파일 appender는 필요하지 않다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
로그를 쓰는 비용이 응답시간에 그대로 실리고 있습니다.
이 레포에는
logback-spring.xml이 없어 Spring Boot 기본CONSOLEappender 로 동작합니다. Logback 의ConsoleAppender는OutputStreamAppender를 상속하고 write 가 동기화돼 있습니다. 톰캣 스레드 100개가 한 스트림에 쓰면 그 락에서 직렬화됩니다.#151 에서 dev 의 SQL 콘솔 출력을 껐더니
GET /admin/members/payers의 p95 가 452ms → 240ms 로 줄었습니다 (100 VU / 5분,payer2천 건). 쿼리 자체는 1ms 미만인데 응답시간의 절반이 stdout 쓰기였다는 뜻입니다.출력량을 줄여 절반을 회수했지만 구조는 그대로입니다.
LoggingFilter가 요청마다 남기는 한 줄이 여전히 같은 경로로 나갑니다.❓ 왜 해결해야 하나요?
출력을 더 줄이는 건 한계가 있습니다.
LoggingFilter의 요청 로그는 운영에 필요하고, 이걸 지우면 응답시간은 좋아져도 관측성을 잃습니다.로그를 줄이는 대신 요청 경로에서 빼는 게 맞습니다. 그러면 로그를 유지하면서 응답시간에서 I/O 를 분리할 수 있습니다.
⭐ 어떻게 해결했나요?
src/main/resources/logback-spring.xml을 추가해CONSOLE을AsyncAppender로 감쌌습니다. 요청 스레드는 큐에 넣기만 하고 실제 출력은 워커 스레드가 맡습니다.기본값 두 개는 의도와 맞지 않아 명시적으로 바꿨습니다. 이 부분이 이 PR 의 핵심입니다.
discardingThreshold20LoggingFilter의 요청 로그가 INFO 라 정작 부하가 걸린 순간의 로그가 사라진다0— 레벨과 무관하게 큐에 자리가 있는 한 남긴다neverBlockfalsetrue— 넘치면 버린다includeCallerDatafalsetrue면 이벤트마다 스택 트레이스를 떠서 동기보다 느려질 수 있다discardingThreshold: 0+neverBlock: true조합은 "큐에 자리가 있는 한 다 남기고, 완전히 차면 블로킹 대신 버린다" 는 뜻입니다.커스텀 설정 파일이 있으면 Spring Boot 기본 로깅 설정이 통째로 대체되므로, 로그 포맷과
CONSOLEappender 정의는defaults.xml/console-appender.xml을 include 해 그대로 씁니다. 기존 로그 포맷은 바뀌지 않습니다.🧩 이 PR의 한계 & 트레이드오프
개선 효과를 아직 측정하지 않았습니다. 배포 후 같은 조건(100 VU / 5분)으로 재측정이 필요합니다. #151 에서 SQL 출력(요청당 2~3줄)을 없애 절반을 회수했으니, 남은 1줄을 비동기로 빼는 효과는 그보다 작을 것으로 예상합니다. 240ms 가 전부 로깅 때문은 아닐 것이고, 남는 부분은 별도로 봐야 합니다.
로그가 유실될 수 있습니다.
neverBlock: true라 큐(4096)가 가득 차면 이벤트를 버립니다. 응답시간을 지키는 게 목적이라 블로킹보다 유실을 택했습니다. 감사 로그처럼 유실되면 안 되는 게 생기면 그건 별도 동기 appender 로 빼야 합니다.비정상 종료 시 큐에 남은 로그가 사라집니다. 정상 종료(
server.shutdown: graceful+ Spring Boot 의 로깅 종료 훅)에서는maxFlushTime(기본 1초) 동안 비웁니다. SIGKILL 은 해당하지 않습니다.로그 순서가 미세하게 달라질 수 있습니다. 로깅 프레임워크를 통한 출력끼리는 순서가 보장되지만,
println같은 직접 출력과 섞이면 인터리빙이 달라질 수 있습니다.⛓️ 기존 기능에 미치는 영향
애플리케이션 동작에는 영향이 없습니다.
로그 포맷·목적지는 그대로입니다. Spring Boot 기본 정의를 include 해서 씁니다.
logging.level.*설정은 계속 동작합니다. Spring Boot 의LogbackLoggingSystem이 설정 파일을 읽은 뒤logging.level.*프로퍼티를 덧씌우는 순서라, #151 에서 추가한org.hibernate.SQL레벨 조정도 그대로 먹습니다.모든 프로파일에 적용됩니다.
local포함입니다 (아래 대안 참고).🔀 Edge Case & 실패 시나리오
discardingThreshold: 0이라 계속 다 받는다 (기본값이면 INFO 유실)neverBlock: true라 이벤트를 버리고 요청 스레드는 진행 (기본값이면 블로킹)maxFlushTime기본 1초 동안 큐를 비운다includeCallerData: false라 추가 비용 없음. 큐가 차면 유실📋 검토한 대안과 선택 이유
LoggingFilter를 없애거나 로그를 더 줄이는 방법 — 응답시간은 확실히 좋아지지만 요청 단위 관측성을 잃습니다. 문제는 로그의 존재가 아니라 로그가 요청 스레드를 붙잡는 구조라 봤습니다.파일 appender 로 목적지를 바꾸는 방법 — 콘솔보다 빠르지만 여전히 동기이고, CapRover(Docker) 가 stdout 을 수집하는 구조라 로그 수집 파이프라인을 새로 만들어야 합니다. 얻는 것에 비해 변경이 큽니다.
local은 동기로 두고 dev/prod 만 비동기로 하는 방법 —<springProfile>로 나눌 수 있습니다. 로컬 디버깅 시 로그가 즉시·순서대로 보이는 이점이 있습니다. 다만spring.profiles.active: ${profile}이 환경변수라, 값이 예상 밖이면 어느<springProfile>도 매칭되지 않아 root 에 appender 가 하나도 안 붙어 로그가 통째로 사라지는 경로가 생깁니다. 그 위험이 로컬 편의보다 크다고 보고 전 프로파일 공통으로 두었습니다.queueSize를 더 크게 잡는 방법 — 유실 가능성은 줄지만, 지속적으로 큐가 차는 상황이면 크기를 늘려도 결국 버립니다(소비 속도가 문제). 버스트 흡수용으로 4096 이면 충분하다고 봤습니다. 이벤트당 약 1KB 로 가득 차도 4MB 남짓입니다.💬 리뷰 포인트
[r]neverBlock: true로 로그 유실을 허용한 판단을 봐주세요. 응답시간 > 로그 완전성으로 정했는데, 이 서비스에서 유실되면 곤란한 로그가 있다면 알려주세요.[c]queueSize: 4096이 적절한지 봐주세요. 인스턴스 메모리가 빠듯하면 줄이겠습니다.[c]local까지 비동기로 둔 게 불편하면<springProfile>로 나누되, 매칭 실패 시 폴백을 두는 형태로 바꾸겠습니다.[a]지금은 콘솔만 있습니다. 나중에 파일이나 외부 수집기를 붙일 때도 이AsyncAppender안쪽에appender-ref를 추가하는 형태로 확장됩니다.🧪 테스트
설정 검증
xmllint --noout)spring-boot-3.4.1.jar안에defaults.xml,console-appender.xml존재console-appender.xml이 정의하는 appender 이름이CONSOLE인지 확인AsyncAppender클래스 존재 확인 —logback-classic-1.5.12.jar하지 않은 것