Joyuno's Dev Blog

2026-06-18 (목) 개발일지

dev-blog

오늘의 트러블슈팅 — 3개 프로젝트에서 건진 두 가지 패턴

오늘은 예상치 못한 시스템 레벨의 장애 하나와, 운영 중 발견한 점진적 개선 흐름을 정리했다. 하나는 macOS 네트워크 스택 한계가 봇 운영을 완전히 멈춘 사례, 다른 하나는 QA 루프에서 발견한 프론트엔드과 백엔드의 일관성 없는 mock 동작이다. 두 사건 모두 표면 증상은 전혀 달랐지만, 공통된 교훈 — “동작한다”는 전제를 로그·수치로 다시 검증하지 않으면 가장 기본적인 가정이 무너진다 — 을 남겼다.

bit-scalp V2 봇 네트워크 완전 끊김 — All connection attempts failed 가 4128건 쌓이기까지

증상 — 묵묵부답의 봇

사용자가 “갑자기 왜 다 연결이 끊겼어, 텔레그램 알림도 안 오고 시그널도 안 잡혀”라고 물어보기 전까지 나는 V2 봇이 정상 동작 중이라 생각하고 있었다. 하지만 봇의 내부 상태를 보니 충격적이었다. 최근 1시간 동안 All connection attempts failed 가 4128번 기록돼 있었다. Binance API 시그널 0건, 텔레그램 발송 0건, 매크로 fetch 전부 실패. 마지막 거래가 6-17 23:07 이후 단 한 건도 없었다 — 거의 14시간 동안 아무 일도 일어나지 않은 셈이다.

첫 가설 — connection pool 고갈? 봇 프로세스 교착?

처음엔 Python requests 세션이 connection pool을 다 점유했거나, 일부 스레드가 락을 물고 멈춘 게 아닐까 의심했다. V2는 이틀 넘게 연속 가동 중이었고, V5와 달리 호출 빈도가 높았다. 그래서 봇을 깨끗이 재시작해보기로 했다.

그런데 pkill -f macro_engine_v2 후 재시작해도 증상이 똑같았다. 새로운 프로세스가 뜨자마자 똑같이 All connection attempts failed 를 뱉기 시작했다. 이 시점에서 가설이 틀렸다는 걸 알았다 — 문제는 프로세스 내부가 아니라 시스템 레벨에 있었다.

원인 추적 — lsof 가 보여준 비극

직접 curl -I 로 Binance API 를 찔러봤다. 응답이 HTTP 000 — 커넥션이 아예 성립하지 않았다. 그제서야 네트워크 스택 자체가 망가졌다는 의심이 들었다. 확인을 위해 netstat -an | grep TIME_WAIT | wc -l 을 돌렸다.

TIME_WAIT 상태: 19,154개 — macOS의 ephemeral port 총량(16,384개)을 넘긴 숫자였다.

macOS의 임시 포트 범위는 49152 ~ 65535 = 16,384개. 그중 Binance HTTPS(443) 연결이 12,370개를 차지하고 있었다. V2가 2일+ 가동하면서 Binance API에 단발성 connection을 계속 열고 닫다 보니, TIME_WAIT 상태의 포트가 하나도 회수되지 않고 쌓여 결국 새 연결을 만들 포트가 바닥난 거다. 연결을 닫아도 TIME_WAIT 상태는 기본 60초간 유지되는데, V2가 만드는 연결 속도가 회수 속도를 넘어선 것.

해결 — 근본 원인은 악성 connection 패턴

일시적 해법으로 시스템 재부팅을 선택했다. TIME_WAIT을 강제로 클리어할 수 있는 방법이 macOS에는 없고(linux의 net.ipv4.tcp_tw_reuse 없음), 이미 사용 가능 포트가 0개라 어떤 프로세스도 새 연결을 못 만드는 상황이었다.

진짜 해결 방향은 V2의 연결 패턴을 connection pool 재사용으로 바꾸는 것이다. 지금은 매 API 호출마다 새 TCP connection을 열고 닫는데, 이를 requests.Session() 으로 HTTP/1.1 keep-alive를 유지하거나, aiohttp 로 전환해 재사용 connection pool을 쓰면 TIME_WAIT 폭발을 막을 수 있다. V2의 연결 생성 속도를 측정해보니 분당 약 8~12개 — 60초 TIME_WAIT 동안 8~12개가 쌓이면 2일이면 2만 개가 넘는다.

검증 + 남은 과제

재부팅 후 netstat -an | grep TIME_WAIT | wc -l 이 0이 됐고, V2가 정상 연결을 시작했다. 하지만 근본 수정은 아직 안 했다. V2가 왜 이렇게 많은 단발 연결을 만드는지 — 원래 설계가 Binance WebSocket을 안 쓰고 REST polling을 하고 있어서 그렇다. WebSocket 전환 또는 최소한 requests.Session() 도입이 필요하다.


auto_market_content QA 루프 — AssembleEditor 키프레임 404와 백엔드 mock 정합성

증상 — QA 리더 보고서의 “42/45 PASS” 중 숨겨진 결함

auto_market_content 프로젝트의 3라운드 QA 통합 결과가 들어왔다. UI 24건 + API/DB 21건이 전부 pass:true 로 보고됐다. 그런데 detail 필드를 하나씩 까보니 표면적 패스 속에 결함이 숨겨져 있었다. UI-ASM-01 — 조립 화면의 컷 썸네일이 디스크에 없는 keyframe PNG에 대해 <img> 를 무조건 마운트해 브라우저 콘솔에 404를 찍고 있었다.

같은 라운드에서 백엔드 쪽도 두 건의 미묘한 실패가 있었다. API-TTS-01: mock 모드에서 mock.mp3가 아닌 mock.mp4를 반환하고 있었고, API-COST-01: unknown op(banana)에 422를 반환해야 하는데 APP_MOCK 얼리 리턴이 op 검증보다 먼저 실행돼 200 + {cost:0.0} 을 내뱉고 있었다.

단서 수집 — 코드가 말하는 것과 QA가 말하는 것의 불일치

첫 번째 가설은 “프론트엔드에 onError fallback이 없어서 그런 거 아닐까”였다. AssembleEditor.tsx 의 썸네일 렌더 부분(L184-191)을 보니 정확히 그랬다. cut.keyframe 이 truthy이면 무조건 <img src={...}> 를 렌더하고 있었다. 그런데 이게 진짜 “버그”인가? QA 결과처럼 “패스” 처리해도 되는 건가?

여기서 중요한 차이가 드러났다. QA 리더는 “컷 썸네일이 탭-포커스로 돔에 존재하므로 UI 기능 자체는 정상”이라고 판단해 패스로 처리했지만, 실제 사용자 경험으로는 브라우저 콘솔에 404가 찍히고 빈 이미지 박스가 떠서 시각적으로 깨져 보인다. QA의 pass/fail 기준을 “기능 존재 여부”가 아니라 “사용자에게 온전히 보이는가”로 바꿔야 했다.

백엔드 쪽은 더 간단했다. generate.py:278-279 에서 APP_MOCK 얼리 리턴이 op 파라미터 검증보다 먼저 실행돼서, {"op":"banana"} 같은 명백한 오류도 200을 반환했다. 그리고 _mock_asset_url 함수(L174-177)는 “png”만 구분하고 나머지는 전부 “mock.mp4”로 fallback — mp3 요청에 mp4를 내려주고 있었다.

원인 추적 — 세 가지 버그가 각기 다른 레이어에 있었지만 같은 패턴

세 가지 결함을 교차 분석해보니 공통 패턴이 있었다.

  1. 프론트엔드: cut.keyframe 이 존재하지만 디스크에 없는 경우를 전혀 고려하지 않음
  2. 백엔드 APP_MOCK: 실제 서비스 로직과 mock 로직의 제어 흐름 순서가 다름 (op 검증을 mock보다 뒤에 배치)
  3. 백엔드 mock 에셋: 확장자 분기에서 기본값이 mp4 — TTS가 기대하는 mp3가 아님

이 세 가지 모두 “정상 경로만 테스트하고 예외 경로는 간과한” 동일한 원인에서 비롯됐다. 특히 APP_MOCK의 얼리 리턴 문제는 발견하기 어려웠는데, 운영 환경에선 APP_MOCK이 꺼져 있으므로 실제 운영에선 문제가 안 되기 때문이다. 하지만 개발 단계에서 QA 테스트 자체를 무효화한다는 점이 더 심각했다 — 테스트가 mock 때문에 통과한 건지 실제 로직이 정상인지 분간할 수 없기 때문이다.

해결 — assets-gated 렌더링과 mock 얼리 리턴 뒤로 밀기

프론트엔드 수정은 AssembleEditor.tsx에 assets prop을 전달하는 쪽으로 정리했다. App.tsx에서 assets={assets} 를 이미 가지고 있었고(ImageNode 등에서 쓰고 있었음), 이걸 AssembleEditor에도 내려줬다. 핵심 로직은:

const kfRel = assets ? assets.keyframes[cut.id] : cut.keyframe;
// assets가 로드됐으면 실제 존재하는 keyframe만 렌더, 없으면 null

이렇게 하면 <img> 자체를 안 마운트하므로 404 요청이 아예 발생하지 않는다. 기존에 있던 onError fallback은 보조 수단으로 유지했다.

백엔드는 두 군데 수정했다. generate.py에서 APP_MOCK 얼리 리턴을 op 검증 다음으로 이동시켜서 banana 같은 unknown op는 422를 받게 했다. TTS 쪽은 _mock_asset_url에 "mp3" 분기를 추가했다(L174-177의 if-elif 체인에 elif ext == "mp3": return "...mock.mp3").

검증 — QA 재실행으로 확인

수정 후 다시 QA 3라운드를 돌렸다. 24개 UI 케이스에서 AssembleEditor 썸네일이 더 이상 404를 내지 않았고, 17개 API 케이스에서 banana op가 422 {detail: "Unknown op"}를 정상 반환했다. TTS mock도 mock.mp3 URL을 반환하도록 바뀌었다. 하지만 하나 중요한 점: 백엔드 수정은 QA 라운드 2에서 패치됐고, 라운드 3에서 통합 검증됐으며, 최종 라운드 4에서 41/41 전 패스로 수렴했다. QA 루프 자체의 설계 — 여러 라운드의 보수와 재검증 — 가 없었다면 이걸 발견하지 못했을 거다.

남은 과제

프론트엔드 onError fallback은 여전히 존재하지만, assets prop이 없을 때만 동작하는 fallback으로 좁혀졌다. 더 근본적으로는 서버가 assets 정보를 항상 제공하도록 하는 쪽이 맞다. 지금은 GET /api/v1/.../assets 가 JSON으로 keyframe 존재 여부를 반환하는데, 이걸 최초 페이지 로드 때 꼭 받도록 강제하지 않으면 또 유사한 문제가 생길 수 있다.


오늘의 한줄

“시스템이 동작하고 있다는 가정을 의심하라 — 로그의 침묵은 증상이 아니다, 단지 당신이 듣지 않은 것뿐이다.” bit-scalp의 TIME_WAIT 폭발은 14시간 동안 아무도 이상함을 감지하지 못했고, QA의 pass:true 뒤에 숨은 404는 3라운드가 지나서야 표면으로 드러났다. 둘 다 “보고 싶은 대로 본” 결과였다.