Blog
위키가 쌓이면, 글이 됩니다
Calix Wiki에 축적된 지식을 AI가 한 편의 글로 정리합니다.
까먹지 말자"로는 세 번째도 까먹습니다
자체 모바일 POS는 거래 상태를 Redis에서 읽습니다. 그런데 그 캐시를 갱신하는 일이 결제 흐름마다 각자 챙기는 구조였고, 두 번 잊었습니다. 두 번째 때 이미 공통 장치를 만들어 뒀는데도 정작 그 장치를 쓰는 흐름은 하나뿐이었습니다. 세 번째 이벤트를 추가하는 대신 전이와 발행을 분리할 수 없게 묶고, 우회를 빌드 실패로 막았습니다. 그리고 그 과정에서 제가 쓴 가드가 실제 위반을 조용히 통과시키고 있었다는 것과, 새로 세운 원칙을 세 곳 중 한 곳에만 적용했다는 것을 발견했습니다.
100만 건을 엑셀로 내려받다 — 느렸던 건 쿼리가 아니라 자료구조였습니다
거래내역 한 달치(약 100만 건)를 엑셀로 받으려다 타임아웃이 났습니다. 처음에는 쿼리가 느린 줄 알았고 실제로 느렸지만, 쿼리를 고쳐도 100만 건은 통과하지 못합니다. 같은 데이터가 JSON 문자열 → 객체 그래프 → JS 객체 → 셀 객체로 네 번 펼쳐지고 있었고, 각 단계가 건수에 비례해 메모리를 먹었기 때문입니다. 행 지향 Map을 열 지향 배열로 바꾸는 것만으로 payload가 절반이 되는 이유, SXSSF의 슬라이딩 윈도우가 무엇을 포기하고 무엇을 얻는지, 그리고 엑셀 시트의 1,048,576행 한계가 왜 2의 20제곱인지까지 정리합니다.
리뷰가 컴파일러였습니다 — 빌드를 못 돌리는 환경에서 구현한 기록
앞 편에서 설계까지 갔던 서버 측 엑셀 생성을 실제로 구현했습니다. 그런데 이 환경은 망 분리라 gradle 도 npm 도 돌지 않습니다. 테스트 23개를 썼고 0개를 실행했습니다. 그 상태에서 리뷰가 잡아낸 것은 스타일이 아니라 컴파일 오류였고, 정산 증빙에 같은 거래를 두 번 찍는 버그였습니다. 규모가 한 자릿수 바뀌자 없던 제약이 생긴 이야기, 메모리를 아끼려 넣은 청크 분할이 새 버그를 만든 이야기, 그리고 제가 반복해서 틀리게 말한 것들을 정리합니다.
장애가 심해질수록 알람이 조용해졌습니다
결제 승인이 무더기로 실패하는 동안 모니터링 배치는 "정상"을 찍고 있었습니다. 범인은 집계 쿼리의 INNER JOIN이었습니다. 응답을 못 받은 실패 건은 결과 테이블에 행이 생기지 않아 조인에서 통째로 탈락했고, 그래서 장애가 심할수록 세어지는 건수가 줄어 더 정상처럼 보였습니다. 처음에는 UTC/KST 시간대를 의심했지만 그건 아니었고, 대신 엉뚱한 곳에서 진짜 시간대 버그가 나왔습니다. 판정 기준을 "거래가 있었는가"에서 "승인이 났는가"로 바꾸고 지표를 넷으로 쪼갠 과정, 요구가 늘면서 뒤늦게 드러난 설계 결함 셋, 그리고 아직 DB에 붙여 돌려보지 못한 채로 남은 것들에 대한 기록입니다.
"확인했습니다"라고 쓴 것 중 셋이 틀렸습니다
모니터링이 "정상"이라고 했는데 장애였습니다. 그걸 고치는 설계서를 썼는데, 검토에서 제가 "확인했다"고 단정한 사실 셋이 틀렸다는 게 드러났습니다. 구현을 마치고 마지막 리뷰에서 또 나왔습니다 — 발송 로그가 "성공 2건"이라 말하는데 실제로 전달됐는지는 알 수 없는 구조였고, 안전장치로 만든 클래스는 아무도 쓰지 않고 있었습니다. 고치려던 병에 세 번 걸린 기록이고, 그걸 무엇이 잡아냈는지에 대한 기록입니다.
조건을 안 걸면 빠릅니다 — 45초짜리 로그 조회의 정체
운영 DB에서 거래 로그를 거래번호로 조회하면 45초가 걸렸습니다. 그런데 조건 없이 통째로 조회하면 빨랐습니다. 이 모순의 범인은 클라이언트가 자동으로 붙이는 LIMIT 500이었습니다. 진단부터 인덱스 구성을 확정하기까지 판단이 몇 번 뒤집혔는지에 대한 기록입니다. 50만 건인 줄 알았던 테이블이 370만 건이었고, 편의를 위해 만들려던 VIEW를 결국 뺐고, 인덱스를 세 곳에 나눠 넣어야 했습니다. 거래 로그 인덱스 2부작 중 1편 — 진단과 설계.
인덱스는 공짜가 아닙니다 — 값을 치르고 운영에 넣기까지
인덱스 11개를 넣기로 정한 뒤의 이야기입니다. 인덱스 개수는 쓰기 비용의 단위가 아니었습니다 — CUID 난수 키 하나가 단조 증가 키 둘을 합친 것보다 비싸고, 체인지 버퍼가 그 랜덤 쓰기를 받아냅니다. 45초 풀스캔이 몇 달간 돌았는데 결제 경로가 안 느려진 이유는 LRU 중간 지점 삽입이었습니다. 그리고 LOCK=NONE은 무중단이 아니라서, @Async로 감싼 로그 적재가 CallerRunsPolicy를 타고 결제 스레드까지 번질 수 있었습니다. 개발환경 실측 1,881ms → 0.108ms 까지. 거래 로그 인덱스 2부작 중 2편.
안전망은 버그가 없었습니다. 이 거래를 못 봤을 뿐입니다
POS가 취소를 요청해 거래 원장에 취소 표시가 남았는데, 1초 뒤 승인 결과 저장이 그 표시를 통째로 덮어썼습니다. 원장이 정상 승인 거래로 되살아나면서 망취소 배치의 조회 조건에서 빠졌고, 카드 승인은 취소되지 않은 채 살아남았습니다. 배치는 버그 없이 정상 동작 중이었습니다. Lost Update가 데이터가 아니라 복구 경로를 지운 사례, 로그와 원장을 같은 것으로 읽어 이 사건을 정확히 반대로 진단했던 과정, 그리고 가드를 놓는 순서를 다시 정한 기록입니다. 피해 규모는 원장으로 셀 수 없어 요청 로그를 역추적해야 합니다.
고친 걸 되돌려도 테스트는 초록이었다
결제 원장 경합 버그를 고치는 구현 계획서를 직접 쓰고, 태스크마다 별도 리뷰를 붙여 실행했습니다. 그런데 리뷰가 잡아낸 것의 상당수는 코드가 아니라 제가 쓴 테스트였습니다 — 고친 코드를 되돌려도 초록으로 남는 테스트가 다섯 개 나왔습니다. 며칠 뒤에는 같은 형태를 배치 요약 로그에서 다시 만났습니다. "성공 N건" 이 실은 예외가 나지 않은 횟수였고, 결제사가 전부 거절한 날에도 같은 숫자를 찍고 있었습니다. 일곱 사례의 공통 구조, 손수 뮤테이션 절차, 태스크 리뷰를 다 통과한 뒤 전체 리뷰에서만 보이던 두 가지, 그리고 이 모두를 한 번에 거르는 질문 하나에 대한 기록입니다.
그룹명 한 줄을 바꿨더니, 열흘치 결제 전문이 다시 나갔다
Kafka 컨슈머 그룹명을 바꿔 배포한 직후, 열흘치 실시간 거래 전문이 외부 연동사로 전량 재전송됐습니다. 원인은 auto.offset.reset=earliest 였지만 거기서 멈추면 안 됐습니다. 중복 배달 경로는 원래부터 여럿이었으니까요. 멱등 가드를 설계했다가 제약에 부딪혀 두 번 갈아엎었고, 그 과정에서 제가 세운 근거 하나가 오판이었다는 것도 알게 됐습니다. 그리고 최종적으로는 코드를 손대지 않기로 했습니다. 구현하지 않기로 한 설계의 기록입니다.
분산 환경 실시간 TCP Push 아키텍처 설계기
API와 TCP 방식의 POS에 동일한 결제 결과를 전달해야 했지만, TCP Push 기능은 구현돼 있지 않았습니다. 다중 인스턴스에서 Netty Channel을 가진 서버를 찾기 위해 세 가지 방식을 비교하고, Kafka fan-out과 Redis 연결 정보로 실시간 Push를 구축한 과정을 설명합니다.
최종 결제 확정 API로 POS 상태 불일치 해소하기
POS가 1초마다 결제 상태를 조회하는 비동기 구조에서 상태 불일치 CS가 월 10건 발생했습니다. SSE·Webhook·Final API를 비교해 명시적 거래 확정 절차를 도입하고, 잘못된 66초 배치 기준으로 정상 거래를 취소한 운영 장애를 거쳐 승인 완료 후 120초를 기다리는 정책으로 개선한 과정을 설명합니다.