Joyuno's Dev Blog

2026-07-23 (목) 개발일지

dev-blog

오늘의 트러블슈팅: 텔레메트리 조회 착각과 부하생성기 중복

오늘은 사주 프로젝트의 모니터링 파이프라인과 부하 테스트 환경에서 두 가지 이슈를 동시에 처리했다. 하나는 텔레메트리 span이 OpenObserve에 보이지 않는 문제, 다른 하나는 예상치 못한 트래픽 소스가 데이터를 오염시키고 있던 상황이다. 두 문제 모두 원인을 잘못 짚고 한참 헤맸다.


사주 백엔드 span이 보이지 않는다 — otel_traces가 아니라 otel_rum_events를 봐야 했다

모바일 앱 dongbaek-ios에서 screen.view span을 전송했는데 OpenObserve 대시보드에서 0건으로 조회됐다. “텔레메트리 수집이 아예 안 되고 있다”는 인상을 받았다. 영향 범위는 전체 saju-backend 서비스의 span이었다.

첫 가설: export 파이프라인 문제

가장 먼저 otel-collector 설정 파일을 열어봤다. exporters 섹션에 otlphttp endpoint가 제대로 적혀 있는지, processors에서 드롭이 걸리지 않았는지 확인했다. 모두 정상이었다. 다음으로 백엔드 FastAPI의 OpenTelemetry SDK 로그를 켜서 SpanExporter가 실제로 export()를 호출하는지 확인했다 — 정상적으로 호출하고 있었다.

curl -X POST -d '{"resourceSpans":[{"resource":{},"scopeSpans":[{"scope":{},"spans":[{"traceId":"...","spanId":"...","name":"test"}]}]}]}' 로 직접 collector에 span을 쏴봤더니 HTTP 200이 떨어졌다. 수집기까지는 문제없이 도달하고 있었다.

가설 수정: OpenObserve 인덱스 라우팅 문제

collector는 살아있는데 조회가 안 된다면 OpenObserve 쪽 문제였다. 먼저 otel_rum_events 테이블을 조회해보니 — 놀랍게도 해당 span이 거기에 있었다. 다시 otel_traces를 조회하니 진짜 0건이었다. 여기서 전환점이 생겼다.

문서를 뒤져보니 모바일 SDK(otel-swift)는 기본적으로 otel_rum_events 스트림으로 span을 전송하도록 설정되어 있었다. 내가 본 otel_traces는 서버 사이드 SDK 전용 테이블이었고, 모바일 span은 애초에 다른 곳에 라우팅된 것이다. 한참 동안 잘못된 테이블만 보고 있던 셈이다.

해결: 조회 쿼리 통일과 서비스명 분리

구분 before after
조회 테이블 otel_traces otel_rum_events + otel_traces (서비스별 분기)
서비스명 식별 모든 span이 saju-backend로 통합 main.dart serviceName을 saju-ios/saju-android로 분리

근본적으로는 OpenObserve에서 서비스별로 다른 테이블에 span이 쌓이는 구조를 정확히 이해하지 못한 게 원인이었다. 모바일 SDK가 otel_rum_events를 쓰는 건 OpenTelemetry Collector의 signalfx 파이프라인 기본 설정이었고, 우리 collector는 그걻 그대로 통과시키고 있었다. otel_traces만 조회하는 대시보드는 당연히 빈 화면이었다.

main.dart에서 서비스명을 saju-ios / saju-android로 분리한 것은 같은 테이블 안에서도 클라이언트 유형을 구분하기 위함이다. 앞으로는 두 테이블을 모두 조회하거나, 리버스 프록시 레벨에서 RUM/서버 레이블을 강제로 붙이는 방식으로 통일할 예정이다.

검증

수정 후 otel_rum_events에서 service.name = "saju-ios" 필터로 조회하니 screen.view span이 정상 조회됐다. otel_traces에도 백엔드 span(saju-backend)이 보였다. 문제는 공백이 아니라 조회 눈을 잘못 뜨고 있었던 것이다. 남은 과제는 대시보드가 두 테이블을 동시에 바라볼 수 있게 퀴리 로직을 개선하는 일이다.


백엔드 부하생성기 간섭 — 예상 밖 40k span의 출처

텔레메트리 문제를 진단하는 동안 OpenObserve에서 saju-backend의 span 건수가 갑자기 40,000건으로 폭등하는 걸 발견했다. 분명 부하생성기(omnitrace_loadgen.py)는 꺼둔 상태였다. “모바일 시뮬레이터가 혼자 돌면서 텔레메트리를 쏘고 있나?” 라고 의심했다.

단서: otel_logs 롤오버와 서버 내 다른 프로세스

로그 탭을 보니 otel_logs 스트림에서 롤오버가 발생한 시점이 정확히 40k span 유입 시점과 일치했다. 롤오버는 로그 볼륨이 임계치를 넘었을 때만 발생하므로, 단순 모바일 시뮬의 소량 텔레메트리로는 설명이 안 됐다.

서버에 SSH로 들어가 ps aux | grep loadgen을 실행했다. 예상대로 omnitrace_loadgen.py가 PID 47902로 실행 중이었다. 얼핏 생각하기에 “내가 분명히 껐는데” 라고 착각했지만, 서버에는 두 개의 부하생성기가 존재했다.

소스 설명 PID 상태
sim_user_loop.sh 모바일 시뮬레이터를 조작하는 스크립트 종료됨 내가 직접 제어
omnitrace_loadgen.py 백엔드 전용 부하생성기 (컨테이너 내 자동 실행) 47902 (살아있음) 별도 컨테이너에서 동작

원인 추적 — “로드젠 컨테이너”의 존재

사용자가 “로드젠 컨테이너”라고 부르는 녀석이 서버의 다른 네임스페이스에서 돌고 있었다. 이 컨테이너는 모바일 클라이언트 없이 백엔드 엔드포인트에 직접 부하를 생성하고, 그 텔레메트리를 같은 collector로 내보내고 있었다. 때문에 otel_logs 롤오버가 유발되고, span 카운트가 실제 부하보다 더 높게 계측된 것이다.

시행착오: 처음엔 sim_user_loop.sh가 여전히 돌고 있다고 생각해 clickkill로 프로세스를 찾았지만, 그 스크립트는 이미 종료된 상태였다. 오히려 백엔드 전용 부하생성기가 눈에 띄지 않는 곳에서 계속 돌고 있었던 게 진짜 원인이었다.

해결: 부하생성기 종료와 실행 권역 분리

kill 47902로 해당 프로세스를 종료했다. span 카운트가 즉시 정상 수준으로 떨어졌다. 이후로는 백엔드 전용 부하생성기와 모바일 시뮬 부하생성기의 실행 권역을 분리하기로 했다. 전자는 서버의 docker-compose 프로파일로만 실행되게 하고, 후자는 로컬 sim_user_loop.sh 전용으로 제한한다. 각각의 측정 데이터에도 loadgen.type 속성을 추가해서 OpenObserve에서 필터링할 수 있게 할 예정이다.

검증

종료 후 10분간 span 건수를 모니터링했다. 40k → 120건 이하로 안정화됐고, otel_logs 롤오버도 발생하지 않았다. 남은 과제는 부하생성기 컨테이너가 실수로 재시작되지 않도록 systemd 유닛을 disable 처리하는 일이다.


오늘의 한줄

틀린 테이블을 보는 것과 틀린 프로세스에 속는 것 — 모니터링은 ‘보는 방식’과 ‘보는 대상을 함께 의심해야 한다’