모든 카테고리
personal gwanmun

gwanmun

6개의 글

전문·TCP와 JSON·REST 사이에 통역기를 세운 아홉 단계, 관문(gwanmun) 총정리

은행 계정계는 고정길이 전문(電文)과 TCP로만, 모바일 앱은 JSON과 HTTP REST로만 말합니다. 둘 다 못 고치니 가운데에 통역기를 세웁니다. 전문과 JSON을 변환하는 연계층, 그리고 그 통로를 지키는 API 게이트웨이층입니다. 이 글은 그 통역기를 목업 계정계까지 세워 직접 만든 9단계의 총정리입니다. 계정계를 실제로 죽여 서킷이 OPEN으로 열리고 503으로 격리하는 과정, RuntimeException 하나(풀 고갈)가 서킷을 열고 원장 4건을 증발시킨 감사 결함과 그 수정(503과 원장 완결), 부하가 서킷 stale 레이스를 staleResultsTotal=197로 실증한 기록, 한계 약 10~12k req/s와 게이트웨이 오버헤드 약 0.21ms, 죽은 백엔드에서 서킷 off 351 vs on 9,425 req/s, 같은 멱등키를 두 번 보내도 계정계 호출 1회에 원장 1행으로 이중거래가 0이었던 것까지 담았습니다. 전부 VERIFICATION에 명령과 출력이 남은 실측이고, 안 만든 것은 왜 안 만들었는지 적었습니다.

재전송의 이중 거래를 멱등키로 막고, 대조된 적 없던 원장을 EOD 대사로 맞추다

4편까지 원장은 거래를 3값(SUCCESS/FAILED/UNKNOWN)으로 적고 UNKNOWN을 수동으로 해소할 수 있었지만, 두 구멍이 남아 있었습니다. 하나, 게이트웨이는 호출자의 재전송을 구분하지 못합니다. 타임아웃(UNKNOWN)을 받은 호출자가 같은 요청을 다시 보내면 그건 새 거래고, 계정계에서 두 번 실행돼 이중 거래가 됩니다. 둘, 원장은 한 번도 계정계와 대조된 적이 없는 진실입니다. 5편은 둘을 채웁니다. 멱등키는 (키+메서드+경로)를 DB 유니크 제약으로 원자적 선점합니다. 동시 재요청은 앱 락으로 못 묶고 DB가 하나로 만듭니다. 처리 중 재요청은 409, 완료된 요청 재수신은 저장된 원응답을 재실행 없이 재반환합니다. 같은 키로 잔액조회를 두 번 보내니 계정계 로그에 요청 수신은 1회, 원장에 그 거래는 1행으로 남아 이중 거래 0을 psql로 확인했습니다. EOD 대사는 계정계 당일 처리내역(가변 전문)을 원장 전량과 거래고유번호로 대조해 양쪽일치 / 금액상이 / 우리만있음 / 저쪽만있음의 4유형으로 가립니다. UNKNOWN은 대조 전에 상태조회·망취소로 자동 해소합니다. 통제된 5건으로 {MATCH:2, MISMATCH:1, LEDGER_ONLY:1, CORE_ONLY:1, UNKNOWN_RESOLVED:1}을 실측했고, 여기서 순서 함정을 하나 발견했습니다. 자동 해소의 망취소가 계정계 기록도 바꾸므로, 계정계 스냅샷은 해소 이후에 떠야 합니다. 마지막으로 원장 PG를 DBTower 관제 대상으로 등록 가능하게 준비했습니다.

감사가 찾은 풀 고갈 3중 오작동과 부하가 드러낸 서킷 stale 레이스를 k6 실측으로 잡다

1부는 방향을 뒤집어 전체 코드 감사를 돌립니다. 최대 발견은 풀 고갈의 3중 오작동입니다. PoolExhaustedException이 RuntimeException으로 모든 처리 경로를 관통해서, 계정계가 멀쩡한데 내부 풀 고갈 3연속이면 서킷이 열리고(오보), 고갈 요청은 500으로 터지며 원장에서 통째로 증발하고, 재시도 경로도 안 탑니다. 동일 시나리오(동시 8건 슬로우 계좌)를 수정 전과 후로 실측했습니다. 수정 전에는 500이 4건에 원장 5행(4건 증발), 서킷 OPEN, 직후 멀쩡한 계좌까지 503 거절이었고, 수정 후에는 503 4건('포화 상태' + 거래ID), 원장 9행 완결, 서킷 CLOSED, 직후 멀쩡한 계좌는 200이었습니다. 유휴 커넥션 TTL, 채번기 자정 재시드, EUC-KR 무음 '?' 치환의 fail-closed 전환, API 키 기동 로그 노출 제거 등 확정 결함을 소탕하고 회귀 테스트 18건으로 고정했습니다(129→147). 2부는 감사가 '난이도 대비 실익이 낮다'며 미룬 A3(서킷 stale 결과 귀속)를 부하가 실증한 이야기입니다. 서킷이 열렸다 닫혔다 하는 15초 부하 창에서 stale 결과 보고가 197번 발생했고(staleResultsTotal=197), acquire()가 상태 세대를 담은 permit 토큰을 발급해 세대가 일치할 때만 상태에 반영하게 고쳤습니다. 그리고 k6로 세 가지를 실측했습니다. 무릎 약 10~12k req/s(6k까지 p95<1ms, 실패율 전 구간 0%), 게이트웨이 경유 오버헤드 약 0.21ms/req(병목은 TCP 풀이 아니라 웹 계층으로, 커넥터 40k vs 전체 경로 12k req/s), 죽은 백엔드에서 서킷 off 351 req/s(p50 8.11s) vs on 9,425 req/s(p50 0.68ms)입니다. CI(GitHub Actions), MIT LICENSE, Spring Boot 3.5.4 업그레이드(150건 그린)까지 함께 소진했습니다.

타임아웃을 실패로 단정하지 않는 3값 원장, 그리고 손으로 짠 서킷브레이커와 망취소

1부는 아무것도 기억하지 못하던 게이트웨이에 기억을 붙입니다. 모든 거래에 거래고유번호를 채번하고(스레드 안전 + 재기동 안전), 결과를 3값 상태(SUCCESS/FAILED/UNKNOWN)로 원장에 적재합니다. 핵심 규칙은 타임아웃을 임의로 실패 처리하지 않는 것입니다. 응답을 못 받은 거래는 계정계에서 처리됐을 수 있어 FAILED가 아니라 UNKNOWN으로 적습니다. 목업 계정계에 응답 지연 모드를 넣어 진짜 read 타임아웃(3.06초 → 504)을 일으키고 원장에 UNKNOWN이 남는 것을 실측했습니다. 적재는 비동기라 거래를 막지 않고, 계좌는 저장 직전 마스킹되며, correlation ID가 로그와 원장을 한 줄로 꿰고, 토큰버킷·커넥션 풀·TCP 왕복이 Prometheus 커스텀 메트릭으로 노출됩니다. 2부는 남은 폭탄 둘을 처리합니다. 계정계 프로세스를 실제로 죽여 손으로 짠 서킷브레이커(CLOSED→OPEN→HALF_OPEN)가 502 → 503×4 즉시 거절로 장애를 격리하고 재기동 후 탐침으로 닫히는 전 과정을 확인했고, UNKNOWN 해소도 완성했습니다. 원거래 전문에 거래고유번호를 실어(30→52byte) 거래상태조회로 처리 여부를 확인해, 처리됐으면 망취소로 CANCELED, 미처리면 그제야 FAILED로 확정합니다. 조회성 거래만 지수 백오프로 제한 재시도하고(변경성 재시도는 이중 거래라 코드가 0회를 강제), 거래 단위 데드라인이 마지막 재시도의 read 제한까지 깎습니다. ddl-auto:update가 체크 제약을 갱신하지 않아 CANCELED UPDATE가 거부된 함정과, 그 실패 순간 이미 나간 망취소를 멱등성이 구해 준 이야기도 정직하게 적었습니다.

완제품 없이 세운 문지기, 인증·라우팅·유량제어 필터 체인부터 가변 프레이밍과 커넥션 풀까지

전문↔JSON 왕복 통로는 열렸지만 아무나 드나들 수 있었습니다. 1부에서 /api/gateway/** 앞에 문지기를 손으로 세웁니다. 인증(X-API-Key, 없으면 401·잘못되면 403), 라우팅(모르는 경로 404), 유량제어(클라이언트별 토큰버킷, 용량 5 초과 시 6번째 429 + Retry-After)입니다. 완제품 프레임워크 없이 GatewayFilter 인터페이스와 체인 실행기, 서블릿 브릿지로 직접 짰고, 직접 짜는 유량제어의 두 함정(벽시계로 인한 시간 역행, 같은 버킷을 치는 스레드 경쟁)을 단조 시계와 버킷 단위 동기화로 막았습니다. 겸사겸사 코드베이스를 Spring Modulith 모듈러 모놀리스로 재정렬해 모듈 경계를 verify()가 강제하게 했습니다. 2부는 통로에 남은 두 구멍을 메웁니다. 거래내역 조회 응답처럼 레코드가 건수만큼 붙어 길이가 매번 다른 전문은 4byte 길이 헤더의 2단계 프레이밍으로 자르고(가변 309B 왕복, 비정상 길이는 fail-closed 거절), 요청마다 소켓을 새로 열던 것은 최대 크기·유휴 반납·검증·고갈 거절을 갖춘 스레드 안전 커넥션 풀로 바꿨습니다. 순차 6회 조회에 소켓 1개 재사용, 동시 10건 폭주에도 created=4(==max)·reused=12를 진짜 실행으로 남겼습니다.

고정길이 전문 파서와 TCP 프레이밍을 손으로 짜며 배운 것, 바이트가 진실이다

은행 계정계의 고정길이 전문(電文)을 모바일 앱이 알아듣는 JSON으로, 그 반대로도 바꾸는 변환 엔진을 만들고(1부), 그 파서를 실제 TCP 소켓 앞에 세웠습니다(2부). 1부의 핵심은 하나입니다. 한글이 섞인 전문을 String.substring으로 자르면 깨진다는 것입니다. EUC-KR에서 한글 한 글자는 2byte라, 자르기도 패딩 제거도 전부 byte[] 위에서 해야 합니다. 필드 레이아웃을 어노테이션 스펙으로 선언하고 오프셋을 자동 계산해 byte[]↔DTO↔JSON을 양방향 변환하는 과정을, 왕복 무손실 테스트와 hex 덤프로 검증했습니다. 2부의 함정은 TCP가 바이트 스트림이라는 사실입니다. '고정 61byte 전문'이라도 소켓 read 한 번이 그걸 온전히 준다는 보장이 없습니다. 반쪽만 왔다가 나머지가 뒤에 오고(partial read), 두 전문이 붙어 오기도 합니다(뭉침). 필요한 바이트가 다 모일 때까지 버퍼에 누적한 뒤에야 한 전문으로 넘기는 프레이밍을 순수 java.net 소켓으로 직접 짜고, 목업 계정계 TCP 서버를 세워 REST→전문→TCP→전문→JSON 왕복을 실제 두 프로세스로 붙였습니다. 반쪽 도착 재조립을 테스트로 강제하고, 소켓을 타고 오간 진짜 hex를 화면과 curl로 남겼습니다.