프로젝트
프로젝트·회고결제·정산

BE-commerce: 결제 실패를 정합하게 되돌리는 모듈형 모놀리스

타일을 누르면 글 아래 영역별 글에서 그 영역만 보입니다. 선을 따라 움직이는 점은 주문 한 건이 그 영역에 닿기까지 지나는 길입니다.

BE-commerce는 주문부터 결제, 원장, 정산·대사, 추천까지를 하나의 Spring Modulith 애플리케이션으로 구현한 이커머스 백엔드입니다. 이 시리즈에서 다루는 질문은 “기능을 어떻게 만들었나”보다 실패했을 때 무엇을 확정하고, 무엇을 모르는 상태로 남기며, 어떤 숫자를 측정한 뒤 선택했는가에 가깝습니다.

예를 들어 결제 타임아웃을 실패로 확정하면 이중 결제가 생길 수 있고, 요청 제한을 초당 100건으로 둔다고 해서 서버 용량이 100건/초가 되지는 않습니다. 그래서 각 글은 문제를 정의하고, 가설을 세우고, 실험 결과와 적용 범위를 분리해 적습니다.

프로젝트 소개와 실행 방법은 저장소 README에 있습니다. 아래 지도에서 영역을 고르면 해당 영역의 글만 볼 수 있습니다. 처음 읽는다면 유입·인증에서 인증 비용과 요청 제한을 읽은 뒤, 결제와 정산·대사, 개인화 순서로 이동하면 시스템의 경계가 자연스럽게 이어집니다.

영역별 글

핵심 ERD 펼치기 핵심 ERD 접기

주문·결제·원장·정산에서 글이 다루는 테이블만 골랐습니다. UK는 같은 요청이나 이벤트가 두 번 처리되는 것을 막는 유니크 제약입니다.

BE-commerce 핵심 ERD. 주문, 주문 항목, 멱등 키, 웹훅 수신, 결제, 발행 대기 이벤트, 포인트 잔액, 정산 항목 테이블과 관계, 두 번 처리되면 안 되는 자리의 유니크 제약

그림을 누르면 원본 크기로 열립니다.

영역 8곳, 공개 글 45편

유입·인증

로그인, 사용자별·전역 요청 제한, 대기열로 몰려드는 요청을 먼저 받습니다. 비밀번호 해시 강도를 올릴 때 로그인 한 건의 메모리 비용이 얼마나 커지는지도 여기서 쟀습니다.

저장소 문서: ADR-009 비밀번호 해시와 이관 경로

스토어프론트

홈, 카테고리, 검색, 상품 상세, 장바구니, 주문 화면이 결제와 같은 API 위에 있습니다. 결제 응답이 불확실하면 화면에도 그 상태를 그대로 드러내고 주문 조회로 확정합니다.

저장소 문서: 스토어프론트 설계

검색 엔진을 넣을지 지연으로 먼저 재 봤다: 지연 축은 답했고 품질 축은 남았다

상품 목록 페이지 지연의 82.5%가 패싯 집계였다. 검색 엔진 도입 기준을 측정 전에 정해 두고 쟀더니 300ms를 넘긴 원인은 집계가 아니라 포화였고 패싯을 60초 캐시하자 패싯 p95가 90.9ms에서 5.7ms로 줄어 엔진을 검토할 조건이 사라졌다. 그런데 이커머스가 검색 엔진을 들이는 큰 이유는 지연이 아니라 품질이고 그 축은 아직 재지 않았다.

재고 차감 락을 인메모리 DB로 고르면 틀린다: 실 MySQL에서 낙관적 락이 가장 느렸다

재고 20개에 스레드 30개가 동시에 차감하는 실험에서 세 락 전략 모두 초과판매는 0이었고 소요 시간만 갈렸다. H2 인메모리에서는 낙관적 락이 비관적 락보다 빨랐는데 실 MySQL 8.4에서는 조건부 UPDATE 40ms, 비관적 락 75ms, 낙관적 락 151ms로 순서가 뒤집혔다. 고경합 150스레드에서는 조건부 대 낙관적이 79ms 대 429ms였다.

검색 품질을 쟀더니 MySQL 이 따라오지 못했다: Lucene, Elasticsearch, OpenSearch 중 앱 안 Lucene 을 고른 이유

오타와 어형 변화가 섞인 검색어에서 nDCG@10이 지금 검색(LIKE) 0.071, MySQL FULLTEXT 0.324, Lucene 0.808, Elasticsearch 0.814, OpenSearch 0.798이었다. 측정 전에 정한 기준대로 엔진을 넣었고 세 엔진의 품질 차이가 0.02 안이라 운영 비용으로 앱 안 Lucene을 골랐다. 1차에서는 오타 허용이 blazer를 lazer에 밀리게 했다.

검색어와 필터를 함께 걸고 앱 안 Lucene이 몇 건까지 버티는지 쟀다

검색어가 있으면 카테고리·가격·재고 필터가 모두 무시되던 검색에 필터와 패싯을 붙였다. 엔진 안에서 걸면 결과 수가 정답과 171개 쿼리 모두 같았고 상위 500개를 받아 DB에서 거르면 95.5%를 잃었다. 복제 카탈로그로 규모를 늘리자 앱 안 Lucene은 300만 건에서 p95 232ms로 지연 예산을 넘었다.

결제

승인과 취소, 원장, 지갑과 포인트, 에스크로를 처리합니다. PG 응답이 오지 않은 결제는 성공이나 실패로 확정하지 않고 UNKNOWN으로 남긴 뒤 조회로 복구합니다. 이 저장소의 코어입니다.

저장소 문서: 결제 도메인 핵심 개념

가상계좌에서 결제 완료를 최종 상태로 믿으면 안 되는 이유

토스 문서에 따르면 가상계좌는 입금 기한이 지나도 웹훅이 오지 않고 일부 은행은 입금 완료를 보낸 뒤 최대 2분 뒤에 되돌립니다. BE-commerce는 그 두 자리를 상태머신과 만료 배치로 막았지만 입금 웹훅을 그 상태머신에 잇는 연결은 아직 없습니다. 만든 것과 이어지지 않은 것을 코드 기준으로 나눴습니다.

쌓인 미확정 결제는 언제 풀리는가: 복구 시간의 하한과 상한, 그리고 알림을 나이에 거는 이유

PG 타임아웃으로 쌓인 미확정 결제 887건이 전부 확정되기까지의 시간은 복구 배치의 속도가 아니라 코드 상수 1분이 정했다. 청크를 줄이자 그때부터는 청크 ÷ 주기가 정했다. 두 식으로 기본 설정에서 1,351건은 약 3.7분, 1만 건은 약 20분이 걸린다고 미리 계산된다. 같은 이유로 적체 알림은 건수가 아니라 가장 오래된 건의 나이에 건다.

재전송을 켰더니 느린 PG에서 결제 실패가 늘었다

PG 응답이 끊긴 승인을 같은 멱등키로 한 번 더 보내게 했다. 응답 일부를 잃는 PG에서는 확정 실패가 14.29%에서 1.90%로 줄었지만 동시 호출 상한이 찬 느린 PG에서는 31.56%에서 55.23%로 늘었다. 상한에 빈 자리가 4개 이상일 때만 다시 보내게 하자 느린 PG에서는 31.40%로 돌아왔고 손실에서는 2.82%로 이득이 남았다. 그 결정이 운영 기본값에 빠져 있던 것은 뒤늦게 찾아 고쳤다.

떠난 고객의 결제를 빼도 PG 호출은 8.4%만 줄었다

PG가 느리면 고객이 이미 화면을 떠난 결제도 PG 동시 호출 상한의 자리를 끝까지 차지한다. 요청에 남은 시간을 싣고 다 된 결제는 PG를 부르지 않게 했다. 요청의 30%를 떠난 결제로 보내면 PG 호출도 30% 줄 거라 예측했지만 8.4%만 줄었다. 비운 자리를 남은 결제가 바로 채웠고 상한 거절이 609건에서 169건으로 줄었다. 처음에는 클라이언트 시계의 절대 시각을 받았다가 남은 시간으로 바꿨다. 결제 화면과 똑같이 움직이는 고객으로 다시 재자 뺀 결제는 0건이었다. 기다림을 멈춘 고객도 조회로 결과를 기다리고 있어 화면이 보내는 남은 시간을 27초로 고쳤다.

짧은 PG 타임아웃은 우리 쪽 대기만 줄였다

PG 읽기 타임아웃 5초는 토스 공식 권장 60초보다 12배 짧았다. 가짜 PG가 끊긴 호출도 끝까지 처리하게 고친 뒤 두 값을 쟀다. 평소 느린 꼬리에서 5초는 결제의 10.2%를 결과 모름으로 만들었고 확정까지 p95 92.7초가 걸렸다. PG가 전부 느린 브라운아웃에서 5초는 1.5배 더 팔았지만 모두 결과 모름을 거쳤고 PG 승인 호출은 16배였다. 측정 전에 적은 기준을 넘어 60초로 바꾸고 결제 화면이 그 시간까지 결과를 기다리게 했다.

정산·대사

판매자별 정산금을 집계하고 PG 정산 파일과 내부 기록을 대조합니다. 규칙으로 가르지 못한 불일치는 사람이 확인하는 큐로 보내고, 큐가 방치되면 알림을 보냅니다.

저장소 문서: 지급 대사 엔진과 외부 reference

대사 결과가 맞아도 확정하면 안 되는 경우: Pending과 Posted의 경계

Modern Treasury는 ACH 입금을 pending 단계에서 잠정 대사해 하루를 앞당기고 posted가 오면 다시 대사합니다. BE-commerce 지급 대사는 금액이 맞아도 posted가 아니면 MATCHED로 올리지 않고, 그 결과가 지급 확정의 유일한 입구입니다. 코드로 강제한 것과 합성 report로만 확인한 것을 나눠 적었습니다.

정산을 떼어 낼 근거 하나를 기각했다: 그 실험이 대신 포인트 적립의 동시성 결함을 찾았다

"정산 배치가 결제를 느리게 한다"는 가설은 배치를 7번 연달아 돌려도 결제 p95가 변하지 않아 기각했다. 커넥션 풀 20개 중 최대 15개를 썼고 대기는 0이었다. 대신 그 실험의 1차 실행에서 결제 승인 70%가 500으로 무너졌고 원인은 포인트 적립의 낙관적 락 충돌이었다. 적립을 DB 원자 증가로 바꿔 같은 최악 조건의 승인 성공률을 39.6%에서 100%로 올렸다.

개인화

클릭과 조회, 구매 로그를 모아 홈 추천을 구성합니다. 추론이 늦으면 인기 상품으로 물러섭니다. 생성 모델 GenPage는 혼자서는 순위 모델에 졌습니다. 순위 모델 점수와 합친 줄 구성은 오프라인과 가상 사용자 짝 A/B에서 규칙을 넘었고 홈 2쪽 줄 구성의 기본값이 됐습니다.

저장소 문서: 개인화 아키텍처

추천 모델을 학습해 보고 넣지 않았다: 넘을 선을 먼저 정했고 생성 중 차단도 재 보고 껐다

학습한 ALS 모델의 최고 점수(MAP@12 0.0076)는 측정 전에 정한 선(마지막 구매 재추천 0.0234)의 3분의 1이었고 인기 상품보다도 낮았다. 품절 상품을 생성 중에 막는 방식은 막으려던 문제가 실제 카탈로그에 없었고 서빙 중앙값을 62ms에서 369ms로 늘려 개인화를 못 받는 요청을 만들었다. 둘 다 코드는 남기고 켜지 않았다.

CDC가 139배 느리게 나왔다: 느렸던 것은 적지 않은 기본값이었다

사용자 활동 로그를 Debezium CDC로 나르게 바꾸고 재 보니 lag p95가 아웃박스의 139배(1,944ms 대 14ms)였다. 그대로 적었으면 CDC를 기각했을 것이다. 커넥터의 지연 설정 셋을 명시하자 13ms로 같아졌다. 그 전에 반영률 0%·오류 로그 0건인 측정이 있었고 원인은 컨슈머 빈이 아예 없던 것이었다.

캐시 값을 몇 바이트부터 압축할까: 교차점은 쟀고, 임계값은 거기에 두지 않았다

Redis 캐시 값을 압축하면 187B에서는 오히려 7% 커지고 1KB에서는 66% 줄었다. 손익이 갈리는 교차점은 187~279B였지만 임계값은 1KB로 정했다. 코덱은 통념과 달리 SNAPPY가 LZ4보다 CPU를 덜 썼고 이유는 확인하지 못했다. 그리고 값은 정했지만 켜지 않았다. 적용할 캐시가 없었다.

과부하에서 무엇을 포기하나: 정책은 지연을 정하고 개인화 비율은 모델 용량이 정한다

추천 모델이 감당하는 양보다 요청이 많을 때 넘친 요청을 어떻게 포기할지 세 정책을 비교했다. 개인화를 받은 요청 비율(coverage)은 세 정책이 ±0.3%p 안에서 같았고 갈린 것은 p95(160ms·320ms·454ms)였다. 아무것도 거절하지 않는 정책은 4배 부하에서 coverage가 31.4%로 더 좋아 보였는데 요청을 받지도 못해 분모에서 빠진 생존자 편향이었다.

늦게 도착한 이벤트를 버리고 있었다: 순서를 사는 대신 병합을 순서에 무관하게 만들었다

같은 활동 로그로 만든 온라인 컨텍스트와 오프라인 재계산이 순서가 뒤집힌 조건에서 20명 전원 갈라졌다(일치율 0%). 병합 규칙이 늦게 온 이벤트를 버리고 있었다. 사용자별 순서 보장을 사는 대신 병합을 순서에 무관하게 바꿔 100%로 맞췄다. 목록 길이로 세던 창 집계는 60%만 맞았고 재는 도구도 같은 잘림을 물려받고 있었다.

홈을 여러 쪽으로 나눠 만들었다: 커서가 보여 준 것을 들고 다니고, 다음 쪽 순서는 방금 본 상품이 정한다

GenPage처럼 홈을 몇 행씩 나눠 만들고 다음 쪽이 앞 쪽과 그 사이의 세션 활동을 입력으로 받게 했다. 쪽 상태는 서버에 두지 않고 커서에 실었다. 사용자 30명 실측에서 쪽 사이 중복은 0(커서가 없으면 2쪽 평균 8.6개)이었고 세션 반영은 96.7~100%였다. 1차에서는 인기 표에 아동복이 0개라 세션 신호가 조용히 버려지고 있었다.

GenPage를 실제 홈에 붙여 보니 모델보다 입력과 과부하 계약이 먼저 흔들렸다

사용자 이력으로 홈 2쪽을 생성하는 작은 GenPage 모델을 만들고 실제 서빙 경로까지 연결했다. 모델 품질은 MAP@12 0.0209로 기준선 0.0234를 넘지 못했지만, 더 큰 문제는 빈 HTTP 본문·학습과 다른 입력·게이트 밖 2쪽 호출이었다. 네 실험을 한 흐름으로 묶어 무엇을 확인하고 기본값을 켜지 않았는지 정리한다.

GenPage는 순위 모델에 다섯 번 지고 비교 규칙을 맞춘 뒤 순위 점수와 합쳐 이기고 기본값이 됐다

Netflix GenPage 구성을 H&M 구매 데이터로 옮겨 업계 표준(후보 생성 + LightGBM 순위 모델)과 겨뤘다. 상품 고르기, 줄 구성, Taobao 노출 데이터, 통계 곁채널, 행 점수 결합까지 다섯 번 모두 졌다. 비교 규칙을 두 쪽에 같게 걸고 순위 모델 점수 위에서 GenPage가 줄만 고르게 하자 홀드아웃 68,984명에서 규칙 줄 구성을 페이지 nDCG +2.0%로 이겼다. 그 방식을 모델 서버와 앱 홈 2쪽 기능 플래그로 붙였고 지연은 지금 방식보다 짧았다. 표본이 작아 판정을 내리지 못한 가상 사용자 A/B를 같은 사용자 68,984명 짝 설계로 다시 재자 구간이 16분의 1로 줄며 이겼다. 앱이 실제로 보여 주는 구성으로 재도 이겨서 홈 2쪽 줄 구성의 기본값을 결합 방식으로 켰다.

수요를 가정으로 세우자 쌓아 둔 실측이 용량 모델로 읽혔다

커머스 백엔드의 공급 실측(모델 서버 처리량, DB 읽기·쓰기 무릎, 앱 홈 경로)은 문서마다 쌓여 있었지만 수요 모델이 없어 몇 배 모자란지 말할 수 없었다. MAU 10만 · 30만 · 100만 가정에서 peak RPS를 유도해 대조하자 첫 병목이 모델 서버로 드러났다. 1대 9.34/s는 MAU 약 7.5만에서 홈 수요가 넘어선다. 잠금 직렬 상한과 Little's Law 가 실측과 2% 안에서 맞는지 확인한 뒤 수평 확장 예측을 측정 전에 커밋했다. 2대 실측 15.15/s는 예측 범위 밖이었다. 법칙은 맞았고 서비스 시간을 상수로 둔 가정이 틀렸다. AWS 서울 온디맨드 환산 월 비용은 $903에서 $1,792이고 성장할수록 모델 서버 대수가 비용을 정한다.

검증·CI

PR마다 실제 MySQL·Redis로 통합 테스트를 돌리고, 건너뛴 테스트는 통과로 치지 않습니다. 측정용 테스트는 매일 한 번 돌리고, PR은 바뀐 모듈의 테스트만 고릅니다.

저장소 문서: CI 워크플로

리스크

승인 전에 이상거래 점수를 매기고, 분쟁 대응 기한을 관리하고, 정산금을 내보내기 전에 제재 명단과 대조합니다.

저장소 문서: FDS 규칙별 오탐

AI 운영

장애 분석, 대사 원인 후보, 운영 타임라인 서술에 모델을 붙였습니다. 모델보다 검증기를 먼저 만들고, 검증기가 허락하지 않는 자리에서는 모델을 껐습니다.

저장소 문서: AI 운영 자동화 검토

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.