일기 slecs

결제 도메인 완성하고 퇴사한 달의 기록

목차

278개. 2월 커밋 수다. 재직 기간 내내 단월 최고치였다.

slecs 275개, claude-monitor 3개. claude-monitor는 Claude API 비용을 실시간으로 보고 싶어서 메인 작업 틈새에 짧게 만든 사이드 프로젝트다. 코드 자체는 별거 없다. 사용량 엔드포인트를 폴링해서 누적 토큰과 예상 비용을 터미널에 찍어주는 수준. 그런데 만들고 나서 매일 열어보게 됐다. 숫자가 눈에 보이면 달라지는 게 있다. 비싼 모델 쓰기 전에 한 번 더 생각하게 되고, 프롬프트 길이도 신경 쓰게 된다. 비용 가시화가 사용 습관을 바꾼다는 걸 직접 체감했다.

그리고 이달이 이전 회사의 마지막 달이었다. 마지막 달에 역대 최고 커밋을 찍은 건 의도한 게 아닌데, 퇴사 날짜가 정해지고 나서 오히려 집중이 잘 됐다. 남은 것과 남은 시간의 윤곽이 보이니까 흐트러질 이유가 없었다. 기한이 있다는 게 이렇게 작동하는구나 싶었다.

결제 도메인을 한 달 만에 완성하면서

slecs의 결제 도메인은 2월에 대부분 완성됐다. PG 결제 라우터, 결제대행사 에러 핸들링, PENDING 충전 관리, 잔액 동기화, 포인트 관리. 목록만 봐도 꽤 된다. 어떻게 한 달 만에 이걸 다 했나 싶은데, 돌아보면 몰입 상태가 꽤 길게 이어졌다. 회의가 없고, 컨텍스트 스위칭도 없고, 할 일이 명확했다. 그냥 됐다.

결제 라우터부터 보면, 여러 PG 중 어디로 요청을 보낼지 결정하는 레이어다. 결제 수단, 금액 구간, 카드사, fallback 순서가 조합된다. 처음엔 조건문으로 시작했다가 경우의 수가 늘면서 전략 패턴으로 정리했다.

class PGRouter:
    def __init__(self, strategies: list[PGStrategy]):
        self._strategies = strategies

    def resolve(self, context: PaymentContext) -> PGStrategy:
        for strategy in self._strategies:
            if strategy.can_handle(context):
                return strategy
        raise NoPGAvailableError(context)

각 전략이 can_handle로 자기 조건을 판단하니까 라우터 자체는 분기 조건을 모른다. 새 PG를 붙일 때 기존 코드를 건드리지 않아도 된다. 패턴 자체는 교과서에 나오는 건데, 실제로 새 결제 수단을 추가하는 순간 "이 구조가 맞았구나"를 몸으로 느꼈다. 설계가 옳다는 신호는 대개 그 설계를 쓰는 다음 순간에 온다.

에러 핸들링은 손이 더 많이 갔다. PG마다 에러 코드 체계가 다르다. 같은 "한도 초과"인데 어떤 곳은 숫자 코드, 어떤 곳은 문자열, 어떤 곳은 HTTP 상태 코드로만 내려온다. 이걸 도메인 에러 타입으로 통일하는 어댑터를 PG마다 따로 만들었다. 단기적으론 귀찮지만, 상위 레이어가 어떤 PG를 쓰는지 몰라도 되니까 PG 교체나 추가 시 영향 범위가 줄어든다. 결제 시스템에서 외부 의존성을 얼마나 잘 격리하느냐가 나중에 유지보수 난이도를 갈라놓는다는 걸 이달에 확실히 체감했다.

PENDING 충전 관리가 까다로운 이유

이달에서 제일 신경 쓴 건 PENDING 충전 관리였다.

결제대행사에 충전 요청을 보낸 뒤, "확정됐다"는 콜백이 오기까지 시간 차이가 있다. 그 사이에 유저가 취소하면? 네트워크 장애로 콜백을 못 받으면? 확정 콜백이 중복으로 두 번 오면? 이 케이스들을 하나씩 막지 않으면 잔액이 틀어지거나 이중 충전이 생긴다. 돈이 얽힌 버그는 재현도 어렵고 수습도 어렵다. 특히 이중 충전은 유저 신뢰 문제로 바로 이어지니까 진지하게 봤다.

핵심은 상태 전이를 단방향으로 강제하는 것이었다.

상태 의미 가능한 다음 상태
REQUESTED PG에 요청 전송 완료 PENDING, FAILED
PENDING PG 처리 중 CONFIRMED, CANCELLED
CONFIRMED 충전 완료 (종료)
CANCELLED 취소 또는 만료 (종료)
FAILED 오류 발생 (종료)

CONFIRMED, CANCELLED, FAILED는 종료 상태라 다른 상태로의 전이가 없다. 이걸 애플리케이션 레이어에서만 막으면 동시 요청 앞에 약하다. DB 레벨에서 상태 변경 쿼리에 "현재 상태가 X일 때만 Y로 바꿔라" 조건을 걸고, 이중 확정은 충전 ID와 이벤트 타입 조합에 유니크 인덱스를 박았다. 콜백이 두 번 오면 두 번째는 DB가 튕긴다. 애플리케이션에서 중복 체크 로직을 따로 짤 필요가 없어진다.

잔액 동기화는 충전 로직과 분리했다. CONFIRMED 이벤트를 구독하는 방식으로 잔액을 갱신한다. 같은 트랜잭션에 묶으면 충전 확정이 느려질 때 잔액 업데이트도 같이 잡히고, 반대로 잔액 쪽이 느려지면 충전 트랜잭션 전체가 늘어진다. 분리하면 확정과 잔액 반영 사이에 짧은 지연이 생기지만, 현재 스케일에서 그 지연은 문제가 되지 않는다고 봤다. 이벤트 구독은 MQ 없이 DB 폴링으로 구현했는데, 지금 당장 필요 없는 복잡도를 들이지 않겠다는 판단이었다. 트래픽이 크게 늘면 그때 메시지 브로커로 교체하면 된다. 현재 스케일에 맞는 가장 단순한 구조가 옳다.

포인트는 잔액과 별도 원장으로 뒀다. 같은 테이블에 두면 조회 쿼리가 단순해지지만 감사 추적이 어렵다. 충전/차감 이력을 이벤트 로그처럼 쌓아두면 특정 시점 잔액을 역산할 수 있어서, "이 날 왜 이 금액이었냐"는 문의에 답할 수 있다. 결제 시스템에서 돈의 흐름을 설명할 수 있느냐는 기술의 문제이기 전에 신뢰의 문제다.

월별 흐름을 정리하면 이렇다.

시기 주요 작업
상반부 인수인계 마무리, 결제 라우터
중반 결제대행사 통합, 에러 핸들링 완성
하반부 PENDING 충전 관리, 잔액 동기화
월말 포인트 원장, claude-monitor, 퇴사

마지막 날

퇴사 당일, 담당 모듈 인수인계 완료를 확인하고 나왔다. 후련함 70, 아쉬움 30.

더 잘할 수 있었던 건 분명히 있다. 테스트 커버리지를 더 높였으면 좋았을 거고, 어떤 설계는 지금 봐도 바꾸고 싶다. 그런데 그런 감정은 어느 시점, 어느 프로젝트에서나 드는 거라 퇴사 아쉬움의 이유로 쓰기엔 좀 다른 얘기다. 인수인계받는 쪽이 모르는 게 없어야 한다는 게 마지막 한 달의 기준이었고, 그건 됐다.

회사 계정 비밀번호를 반납하면서 묘한 감정이 들었다. 2년 넘게 매일 로그인했던 곳. 결제 설계 얘기하던 화이트보드, 버그 트래킹 화면, 매일 보던 Slack 채널. 그게 그냥 끝났다. 그 화이트보드 사진을 저장해뒀는지 지금도 기억이 안 난다.

다음 주부터는 새로운 곳에서 다른 코드를 만진다. 두려움보다 기대가 컸다는 게 솔직한 소감이었다.

댓글 0

첫 댓글 달아줘.