Joyuno's Dev Blog

2026-09-03 (목) 개발일지

dev-blog

오늘의 회고: 데모앱 장시간 실행 열화, 마스킹 구조 검증, 그리고 로그프레소 이중화 구축

오늘은 세 가지 축으로 작업이 흘러갔다 — 데모앱에서 오래 알려진 “장시간 실행 시 스냅샷 정지”의 진짜 원인을 규명하고, Omnitrace 전 계층 마스킹 구조를 크로스 검증했으며, 로그프레소 소나 5.0 이중화를 실제 구축하며 문서로 남겼다.


데모앱 스냅샷 정지 — maxSnapshotsPerSession 카운터가 세션 회전에 리셋되지 않던 버그

몇 달째 “장시간 실행 시 리플레이 스냅샷이 멈춘다”는 이슈가 있었다. 처음엔 리소스 누수일 거라 추정했고, 서버 스펙 부족이나 봇 클릭 패턴을 의심하기도 했다. 하지만 오늘 결정적인 실험 결과지가 만들어졌다 — 수정판으로 띄운 세션 s_656129d1이 1.3시간을 돌다가 꺼지면서, 초기 2분에 35스냅샷이 찍힌 시각 분포 데이터가 나온 것이다.

스냅샷의 시각 분포를 보니 “언제 멈췄는지”가 그대로 드러났다. 특정 지점에서 급격히 끊긴 패턴이었고, 점진적인 성능 저하가 아니었다. 이건 리소스 누수나 서버 스펙 문제와는 다른 시그널이다.

코드를 다시 봤다. ReplaySnapshotCapturer.kt에서 maxSnapshotsPerSession = 1800으로 하드캡이 걸려 있었고, 카운터는 AtomicInteger(0)으로 선언되어 있었다(:414). 증가는 maxSnapshotsExceeded() 호출부(:2819)에서 일어나는데, 세션 회전 시 이 카운터를 리셋하는 코드가 전혀 없었다. 즉 AtomicInteger(0)은 per-process 카운터였고, 세션이 바뀌어도 계속 누적됐다. 결국 1800 스냅샷이 찍히면 그 뒤로는 영원히 maxSnapshotsExceeded가 true를 반환해, 아무리 세션을 회전해도 스냅샷이 생성되지 않았다.

해결: 세션 회전 지점(SessionManager의 rotateSession())에 capturedCount.set(0)을 추가했다.

// Before: 카운터가 per-process로 누적
// After: 세션 회전 시 리셋
fun rotateSession() {
    sessionId = generateSessionId()
    capturedCount.set(0) // 추가
    // ...
}

같은 결함이 ReplaySnapshotCapturer.swift에도 있었다(:414). iOS도 동일한 AtomicInt(0) 선언에 리셋 누락. 함께 수정했다.

검증: 수정 후 세션 회전 로그에서 capturedCount가 0으로 리셋되는 걸 확인. 장시간 실행 테스트에서 1800 스냅샷 이후에도 신규 세션에서 정상 캡처가 재개됐다.


Omnitrace 마스킹 구조 — 5계층 전체를 크로스 검증한 사흘

최근 작업에서 “마스킹이 어느 계층에서 적용되는가”에 대한 주장들이 분분했다. SDK 캡처단, OTel 어댑터, 서버 egress, 프론트 렌더단 — 각 계층의 에이전트가 낸 주장들을 회의적인 검증자 입장에서 반박하려고 코드를 읽었다.

처음엔 “SDK 캡처단에서 모든 마스킹이 처리된다”는 가설부터 시작했다. 하지만 ReplaySnapshotCapturer의 캡처 루틴을 grep해보니 마스킹 관련 훅은 rasterWindowGated, cropIcon 같은 이미지 전처리뿐이었다. 실제 텍스트·데이터 마스킹은 여기서 안 한다.

그제서야 OmnitraceMobile.swift(:281)와 OmnitraceMobile.kt(:302)의 OTel 어댑터로 시선을 돌렸다. 여기서 SpanExporter 인터페이스 구현체를 보니, export() 직전에 sanitizeAttributes()라는 프라이빗 메서드가 돌고 있었다 — 키-값 맵에서 password, token, ssn 같은 패턴을 정규식으로 찾아 [REDACTED]로 치환한다. OTel 계층에서 한 번 걸러내는 구조였다.

서버 egress(StoreService)도 확인했다. demo-app-longrun-degrade.md에 기록된 대로, display-list reflector가 또 한 번 마스킹을 건다 — 이건 서버 입장에서 들어온 데이터를 신뢰하지 않고, 화면에 그려질 요소(TextView의 text, ImageView의 contentDescription)만 골라내서 스테이지에 다시 쏜다. 여기서도 민감 패턴을 한 번 더 검사했다.

결정적 발견: 프론트 렌더단(ScreenStage.tsx)에서 라벨이 1글자로 잘리는 "충전" → "충." 현상의 원인은 SDK가 아니라, 스테이지가 190px로 고정되어 있고 폰트 하한이 6px여서 ellipsis로 떨어진 것이다. 이건 마스킹 문제가 아니라 레이아웃 문제다 — 스테이지 확대가 유일한 해법.

최종 판단: 마스킹은 3계층에서 중복 적용된다 — (1) SDK OTel 어댑터의 sanitizeAttributes() → (2) 서버 display-list reflector의 재검증 → (3) 프론트 렌더러의 ellipsis fallback(레이아웃 마스킹 성격). 단, 캡처단(raw 노드)에서는 하지 않는다 — 데이터를 있는 그대로 보내고, 내보내는 순간에만 필터한다.


로그프레소 소나 5.0 이중화 — 실제 구축하며 문서로 만든 이틀

분석 노드 2대, 전달 노드 2대 — 총 4대의 이중화 구축이었다. 공식 가이드대로 따라갔는데, 웹 인스톨러가 ld.so.conf를 엉뚱한 경로로 덮어쓰는 문제를 만났다(E-004). install server 명령이 libjli.so를 찾을 수 없게 만들어서, JLI=$(find /usr/lib/jvm -name libjli.so | sort -V | tail -1)로 경로를 찾아 직접 ldconfig 설정을 다시 해야 했다.

인증서 교체도 한 번 꼬였다. 노드가 FORWARDER로 올바르게 인식됐는데 콘솔에서 회색(미연결) 상태, 버전·호스트명이 비어 있었다. 센트리 로그를 보니:

ERROR: failed to connect (peer not authenticated)
INFO : trying to replace certificates using https://<INTERNAL_IP>:44300
INFO : installed CA certificate / sentry certificate

초기 인증 실패 후 인증서를 자동 교체하고 붙었다 — peer not authenticated가 원인이었고, 자동 교체 루틴이 살아서 살았다. 하지만 이걸 몰랐다면 “왜 연결이 안 되나” 한참 헤맸을 것이다.

파이프라인 앞단이 살아 있는지도 확인했다:

[센트리 .18] logger [sonar_logger_00002] pipe to base [launcher] → is connected
[전달 f1a]   sentry [dongbaek-poc-01] logger [sonar_logger_00002] is started, interval 60000ms

문서화: 06-이중화-구축-가이드.md를 만들며 화면 캡처는 전부 실제 화면, 공식 가이드와 다른 지점은 ⚠️ 표시, 근거는 03-설치-에러-로그.md의 E-번호로 연결했다. 이건 다음에 누군가 같은 구축을 할 때 시간을 절반으로 줄여줄 문서다.


오늘의 한줄

“오래된 이슈일수록 리소스 누수·스펙 탓으로 돌리기 쉽지만, 코드 레벨의 단순한 버그(리셋 누락) 하나가 수개월의 추정을 무효화한다.”