Joyuno's Dev Blog

2026-07-22 (수) 개발일지

dev-blog

오늘의 회고: OTel export 누락, 봇 사망, 텔레그램 무응답 — 세 가지 다른 병목을 같은 날 추적했다

오늘은 세 개의 독립된 프로젝트에서 각각 다른 성격의 장애를 마주했다. 하나는 모바일 OTel span이 서버에 도달하지 않는 네트워크·ATS 문제, 하나는 프로세스가 5일간 죽어 있었던 인프라 복구, 하나는 단순한 CLI 플래그 누락이었다. 공통점은 “동작하는 것처럼 보이지만 실제로는 안 돌아가는” 상태였고, 각각의 원인을 좁히는 방식이 전혀 달랐다.

OTel span 0건 — no-op 추측이 하드 증거로 뒤집힌 과정

오연(사주) Flutter 앱에 Omnitrace 모바일 SDK를 심은 뒤 otel_traces 테이블을 조회했는데 모바일 span이 단 1건도 없었다. 백엔드 span은 잘 쌓이고 있었고, 서버 콜렉터 파이프라인은 필터 없이 모든 input을 받고 있었다. 문제는 모바일 클라이언트 export 파이프라인으로 좁혀졌다.

처음엔 네이티브 모듈이 아예 컴파일되지 않았다(= no-op 코드가 들어갔다) 고 가설을 세웠다. Flutter plugin 브릿지가 Dart → Swift/iOS까지 이어지는 경로인데, 빌드 로그에서 OpenTelemetryProtocolExporterHttp 모듈이 빠져 있거나 OmnitraceMobile.o가 비어 있을 거라 예상했다.

하지만 nm OmnitraceMobile.o에서 OtlpHttpTraceExporter.init(endpoint:) 심볼이 undefined가 아니라 링크된 상태로 잡혔다. strings로 확인한 "OTelAdapter bootstrapped: endpoint=" 문자열도 빌드 산출물에 박혀 있었다. 모듈은 정상 링크, 파이프라인은 활성이라는 증거였다.

결정적 반전: strings에서 export endpoint가 내부 IP(<INTERNAL_IP>)로 하드코딩된 걸 확인하고, iOS CFNetwork 로그를 찍어봤다. 시뮬레이터에서 실제로 :14318로 OTLP POST가 가고 있었고, response_status=200까지 받고 있었다. span이 서버 도착 못 한 게 아니라, 서버에서 받고 나서 적재가 안 된 건가? 그런데 로그를 보니 ClickHouse에는 실제로 들어오지 않았다.

여기서 갈림길이 하나 더 있었다. 서버 콜렉터가 받았는데 ClickHouse로 쌓지 않았다면 콜렉터 구성 문제일 수도 있었다. 하지만 otel_traces 파이프라인에 필터가 없고, 백엔드 span은 잘 쌓이는 걸 보면 콜렉터는 정상. 그러면 export는 성공했는데 서버 저장소에서 사라진 건가? — 이 가설도 잠시 검토했지만, response_status=200이 return 된 시점과 서버 수신 시점의 차이를 보려면 콜렉터 디버그 로그가 필요했다. 시간이 오래 걸릴 것 같아 잠시 접었다가 다시 모바일 로그로 돌아왔다.

돌아와서 확인한 건 Info.plist의 ATS(Application Transport Security) 설정이었다. 시뮬레이터는 기본적으로 ATS를 덜 적용하지만, 디바이스에서는 cleartext HTTP(http://)를 명시적 예외로 등록하지 않으면 차단한다. 앱의 Info.plist는 localhost만 NSExceptionDomains에 예외로 등록되어 있었고, 내부 IP(<INTERNAL_IP>) 항목은 아예 없었다. 시뮬레이터에서만 요청이 성공하고 실제 디바이스에서는 실패했던 것.

해결은 단순했다. Info.plist에 <INTERNAL_IP>를 ATS 예외로 추가했다. 다만 이 IP는 개발용 내부망에 종속된 값이라, 실제 운영 환경에서는 도메인 기반으로 바꿔야 한다는 과제가 남았다.

검증: 변경 후 시뮬레이터 재빌드 → 동일하게 span 요청 성공. 실제 디바이스로도 E2E 확인하려면 발송 대기 중.

남은 과제: 운영 환경에서의 ATS 정책. 내부망 IP 하드코딩을 없애고, 콜렉터 endpoint를 도메인 + HTTPS로 전환해야 한다. 또한 otel_traces에 span이 도달하는 평균 지연시간을 모니터링할 대시보드가 필요하다.

bit-scalp 스윙봇 5일 사망 — launchd 자동 복구 등록

이틀째 6시간 시장 보고가 텔레그램으로 오지 않았다. 봇 프로세스를 확인해보니 7월 17일 18:30부터 죽어 있었다 — 정확히 5일간 정지. 맥북을 이동하면서 네트워크 오류가 반복되다 프로세스가 사망했고, nohup으로 띄운 상태라 재부팅이나 네트워크 복구 후에도 자동 재시작되지 않았다.

다행히 포지션은 거래소 서버측 SL/TP로 관리되고 있어서 수익에는 문제가 없었다. 다만 5일간 신호가 안 들어와서 거래 기회를 놓친 게 명백했다.

처음엔 cron 재시작 방식을 검토했다. 1분마다 프로세스 생존여부 체크 → 죽었으면 재기동. 하지만 이 방식은 1분의 데드타임이 있고, 시스템 이벤트(절전·네트워크 복구)에 즉시 반응하지 못한다.

launchd로 방향을 틀었다. KeepAlive 옵션을 활성화하면 프로세스가 죽었을 때 60초 이내에 자동 재시작된다. 추가로 RunAtLoad 플래그가 있어 로그인/재부팅 시에도 자동 기동된다. plist(com.bitscalp.swingbot.plist)를 작성하고, 기존 nohup 인스턴스를 종료한 뒤 launchctl load로 등록했다.

검증: launchctl list | grep bitscalp → state = running (PID 53369). 프로세스를 강제 kill해보니 60초 내 자동 재시작되는 걸 확인했다. 또한 맥북을 슬립→깨우기 했을 때도 네트워크 복구 후 봇이 살아있는 걸 확인했다.

남은 과제: launchd의 WatchPaths 옵션으로 설정 파일 변경 시 자동 재시작하는 것까지는 아직 안 했다. 추후 settings.yaml이 바뀌면 launchctl unload/load를 수동으로 해야 하는데, 이 부분을 자동화할 필요가 있다.

텔레그램 봇 무응답 — --channels 플래그 누락

bit-scalp 봇 설정 자체는 allowlist까지 완료되어 있고, 텔레그램에서 봇에 보낸 DM이 해당 Claude CLI 세션으로 전달되지 않았다. 처음엔 봇 토큰 문제인 줄 알았다. 8679678101... 토큰이 정상적으로 설정되어 있고, curl로 Bot API를 찔러보니 정상 응답이 돌아왔다. 토큰 문제는 아니었다.

다음으론 세션 연결 문제를 의심했다. claude 명령어로 기본 실행했을 때, 텔레그램 플러그인이 CLI 세션에 연결되지 않는 것 같았다. claude-plugins-official 저장소의 README를 확인했는데, 텔레그램 서버와 CLI 세션 간 채널을 열기 위해 --channels 플래그가 명시적으로 필요하다는 내용이 있었다.

핵심 발견: claude --channels plugin:telegram@claude-plugins-official — 이 플래그 없이 시작하면 텔레그램 봇이 세션에 붙지 않아서 DM이 무시된다.

해결은 단순히 플래그를 추가하는 것뿐이었다. launchd plist의 ProgramArguments에 --channels plugin:telegram@claude-plugins-official 항목을 추가하고, launchctl unload/load로 재시작했다.

검증: 텔레그램 봇에 “/start” 보내니 “세션에 연결되었습니다” 응답이 돌아왔다. 이후 시장 보고 메시지가 정상적으로 텔레그램 채널로 전송되는 걸 확인했다.

남은 과제: 없음 — 단순 설정 변경으로 완료됨. 다만 launchd 환경에서는 --channels 플래그가 빠지기 쉬우니, 앞으로 plist 생성 시 ProgramArguments 리스트를 빼먹지 않도록 체크리스트에 넣어야겠다.

오늘의 한줄

가장 큰 오해는 “이건 분명 구조적 문제일 거야”라는 가정이었는데, 실제로는 단순한 설정 누락·ATS 예외·프로세스 관리 수준의 문제로 풀린 경우가 많았다 — 하드 증거를 먼저 보지 않고 높은 복잡도를 가정하지 말 것.