풀 고갈 사고의 후속 처리
법원경매 서비스에서 좀비 커넥션으로 DB 풀이 고갈되는 사고가 있었다.
연결을 빌려간 쿼리가 끝나지도, 반납하지도 않으면서 풀 전체를 점유하는 문제였다.
이번엔 그 후속으로 방어를 2단으로 구축했다.
앱 계층에는 쿼리 타임아웃 래퍼를 두었다.
메인 쿼리용 withQueryTimeout은 15초 상한으로 초과 시 에러를 던져 “다시 시도”로 유도하고 보조 섹션용 bestEffortQuery는 8초 상한으로 초과 시 빈 배열 폴백으로 강등해서 페이지 본문은 렌더하게 했다.
폴백 타입이 곧 UI 강등 전략이라는 게 설계 포인트다.
타임아웃 종류가 곧 장애 시 화면의 모양을 결정한다.
서버 계층에는 ALTER ROLE ... SET statement_timeout = '15s'로 역할 수준 방어를 걸었다.
15초를 초과하는 쿼리는 서버가 직접 처형하고 연결을 즉시 반납한다.
발화 테스트로 15,035ms에 SQLSTATE 57014로 죽는 걸 실측으로 확인했다.
여기서 재밌는 발견이 두 개 있었다.
첫째, postgres.js가 statement_timeout 옵션을 지원하지만 트랜잭션 풀러(supavisor)를 경유하면 드라이버의 시작 파라미터가 묵살된다.
역할 수준 기본값만이 풀러를 타지 않는 확실한 경로였다.
둘째, SET ROLE로는 역할 기본값 검증이 안 된다.
역할 기본값은 세션 시작, 그러니까 실제 로그인 시에만 적용되기 때문이다.
슈퍼유저 세션에서 SET ROLE 후 확인하면 0이 떠서 처음에 헷갈렸다.
결론은 불가능한 건 좀비 ‘회수’뿐이고, 좀비가 ‘만들어지는 조건’을 크게 줄이는 것까지 가능하다는 것이었다.
ClientRead 상태, 그러니까 서버는 답을 다 보냈는데 클라이언트가 안 읽는 좀비는 타임아웃도 못 죽인다.
쿼리가 이미 끝났기 때문이다.
이건 연결 수명 회전이나 프로세스 재시작으로만 회복된다.
사고의 씨앗까지 거슬러 올라가기
같은 연쇄에서 사고의 근본 원인도 역추적했다.
dev 환경이 프로덕션 DB에 붙는 혼연 사고가 있었는데 원인은 의외로 단순했다.
환경 변수를 조립하는 셸 스크립트가 bash 3.2에서 mapfile을 만나 즉사한 것.
그래서 이전 세션이 수동으로 조립해둔 .env가 그대로 남아 좀비 사고의 씨앗이 됐다.
while read와 case문으로 bash 3.2 호환 복구를 마쳤다.
사건 단위 API와 물건 단위 DB
법원 API 데이터 정합성 작업도 마무리했다.
법원 API는 사건 단위로 응답하는데 DB는 물건 단위로 upsert한다.
이 미스매치가 매각기일 중복, 타 물건 낙찰가 오기입, 사건번호 충돌 3개 버그의 공통 근원이었다.
한 사건에 물건이 여러 개면 API 응답에 그 사건 전체 기일이 섞여 들어오는데 매퍼가 이걸 그대로 전체 매핑하면서 물건1의 회차에 물건2, 3의 기일이 전부 들어갔던 것이다.
매칭 실패 시 응답의 첫 물건으로 폴백하는 로직도 타 물건의 낙찰가를 오기입하는 원인이었다.
사건 그룹폴링과 원자적 확정 트랜잭션으로 재설계해서 프로덕션에 적용했다.
부수 효과로 요청 수도 줄었다.
사건 전체를 반환하는 API에 물건 단위로 조회하니 4,722회 중 약 1,100회가 같은 사건의 재요청이었다.
그룹핑하니 30물건이 26사건 요청으로 준 것을 실측했다.
전날 새벽에 폴링이 두 번 도는 이유를 “잡 내 두 지점 폴링”으로 추론했었는데 이것도 코드를 정독하고 정정했다.
실제로는 광주지방법원 WAF에 차단된 크롤러가 30분 냉각 후 자가회복 루프로 재시작되면서 폴링도 처음부터 다시 돈 것이었다.
추론이 로그 배너 타임라인과 맞지 않으면 코드를 읽어야 한다.
DataTable 이관, 그리고 철회한 반대
UI 쪽에서는 앱의 표 10개를 TanStack Table과 HeroUI를 결합한 DataTable로 이관했다.
여기서 내 편견이 하나 깨졌다.
처음에 이 조합에 반대했는데 이유는 “use client가 SSR을 상실한다”였다.
그런데 사용자가 “SSR을 사용할 수 있지 않나?”라고 정확히 물었고 Next.js 공식 문서를 확인해보니 사용자가 맞았다.
"use client"는 서버 렌더링 끄기가 아니라 상호작용이 필요하다는 경계 표지판이다.
Client Component도 빌드 시 서버가 초기 상태로 HTML에 프리렌더한다.
크롤러는 초기 응답 HTML에서 표의 모든 행을 본다.
실측 비용도 table-core 26KB gz뿐이었다.
그래서 반대 사유를 철회하고 이관을 진행했다.
TanStack은 정렬·페이지네이션을 계산만 하는 headless 상태 기계고 HeroUI는 렌더링과 접근성을 담당하는 렌더링 계층이다.
접점은 정렬 상태 변환기와 헤더, 바디 브리지 네 개뿐이라 기존 컴파운드 문법은 그대로 유지됐다.
접근성 쪽에서는 스킵 링크 유지를 결정했다.
Tab 키 도달만으로는 부족하고 포커스 가시성은 WCAG 2.4.7 AA 필수 요건이다.
항상 숨겨두면 첫 Tab이 죽은 키를 누르는 격이 되니 반사실 실험으로 입증하고 유지했다.
오늘의 교훈
방어는 여러 층으로 쌓되 각 층이 무엇을 못 막는지 아는 게 핵심이다.
앱 상한은 UX 장치이지 리소스 정리 장치가 아니다.
그리고 확신에 찬 반대 의견일수록 공식 문서 원문으로 검증해야 한다.
내가 SSR 상실이라고 단정했던 부분은 문서 한 페이지면 확인되는 내용이었다.
다음 작업은 웹 역할 전환 시크릿 교체와, 기일경과 물건 갱신 배치 구현이다.