팀 프로젝트2026.09 — 2026.10프로그래머스

Kidly

키즈 패션 이커머스 예치금 결제

토스로 예치금을 충전하고, 그 예치금으로 주문을 결제 · 환불 · 정산하는 결제 도메인을 혼자 맡았다. 외부 결제 결과를 알 수 없는 순간에도 돈이 한 번만 움직이도록 상태 · 락 · 원장 세 겹으로 막았다.

GitHub ↗서비스 보기 ↗

Kidly 키 비주얼 — 로고와 홈 화면(이번 시즌 사이즈 120 추천), 주문 상세 화면(예치금 25,000원 결제 완료)

한눈에 보기

기간
2026.09 — 2026.10
팀
프로그래머스 데브코스 백엔드 단기심화 8기 · 5인
내 역할
결제 도메인 전담 — payment 패키지 커밋 39건 모두 작성
기술
Java 25 · Spring Boot 4.1 · PostgreSQL 18 · QueryDSL · Flyway · Toss Payments

직접 만든 기능

  • 예치금 충전 — 토스 결제창 승인 · 실패 · 만료
  • 충전 취소, 토스에서 직접 취소한 충전 맞추기(웹훅)
  • 주문 결제와 주문 취소 환불
  • 판매자 정산 지급
  • 입출금 원장 · 행동 로그 · 관리자 결제 조회

화면

배포된 시연 환경(dev.kidly.kro.kr)에서 시연 역할(보호자 · 판매자 · 관리자)별로 찍은 화면

  • 보호자 홈

  • 판매자 센터

  • 관리자 대시보드

  • 시연 콘솔

보호자 홈1 / 4

Kidly 보호자 홈 — 자녀 키로 계산한 이번 시즌 추천 사이즈 120과 Fit Passport

흐름

예치금 충전 상태 흐름

  1. PENDING결제창 대기
  2. CONFIRMING승인 중
  3. DONE입금 완료
  • PENDING → FAILED · EXPIRED결제창 실패 · 30분 안에 승인 요청이 없으면 만료
  • CONFIRMING → FAILED토스가 승인을 거절
  • CONFIRMING → CONFIRMING결과 모름 — 1분마다 토스를 다시 조회해 확정
  • DONE → CANCELLING → CANCELLED충전 취소 — 토스가 거절하면 DONE으로 되돌림
  • DONE → CANCELLED토스에서 직접 취소 — 웹훅을 받고 토스를 다시 조회해 반영

상태를 바꾸는 모든 곳은 충전을 비관적 락으로 읽는다. 늦게 온 쪽은 바뀐 상태를 보고 멈춘다.

문제 해결 과정

결과를 모르는 토스 승인 — 실패로 단정하지 않기

  1. 문제

    토스 승인 호출이 시간 초과나 5xx로 끝나면, 고객 돈이 빠져나갔는지 알 수 없다

  2. 원인 — 어떻게 찾았나

    실패로 처리하면 이미 결제된 돈이 입금되지 않고, 다시 승인하면 두 번 입금될 수 있다. 승인 호출을 DB 트랜잭션 안에 두면 토스를 기다리는 동안 커넥션을 붙잡고, 롤백되면 승인을 시도했다는 기록마저 사라진다

  3. 해결 — 무엇을 바꿨나

    승인을 세 단계로 나눴다. 금액 대조 · 만료 확인 뒤 '승인 중'을 먼저 커밋하고, 트랜잭션 밖에서 토스 주문번호를 멱등 키로 승인한 다음 결과를 반영한다. 결과를 모르면 승인 중으로 두고 202로 응답하고, 스케줄러가 1분마다 5분 넘게 승인 중인 충전을 토스에 다시 조회해 승인은 입금, 거절은 실패, 모름은 다음 실행으로 넘긴다

  4. 결과

    결과를 모르는 승인도 토스 조회로 확정될 때 한 번만 입금된다. 승인 금액이 충전 금액과 다르면 반영하지 않고 ERROR로 남긴다

사용자 승인 · 스케줄러 · 웹훅이 겹칠 때 — 한 번만 입금

  1. 문제

    같은 충전을 사용자의 승인 요청, 정리 스케줄러, 토스 웹훅이 동시에 건드릴 수 있다

  2. 원인 — 어떻게 찾았나

    셋이 각자 '아직 승인 중'을 보고 입금하면 같은 충전이 두 번 입금된다

  3. 해결 — 무엇을 바꿨나

    상태를 바꾸는 모든 곳(승인 시작 · 반영 · 실패 · 만료 · 취소)에서 충전을 비관적 락으로 읽어 늦은 쪽이 바뀐 상태를 보고 멈추게 했다. 락은 항상 충전 → 지갑 순서로 잡고, 스케줄러가 집는 기준(5분)은 토스 읽기 시간 제한(30초)보다 길게 둬 진행 중인 승인과 겹치지 않게 했다. 마지막으로 원장의 (type, ref_id) 유니크 키가 같은 원인의 입금을 한 번만 허용한다

  4. 결과

    세 경로가 겹쳐도 원장에는 한 줄만 남는다. 잔액의 기준은 한 번 쓰면 바꾸지 않는 원장이다

결제와 주문 취소가 동시에 올 때 — 커밋 뒤 이벤트

  1. 문제

    결제 제한 시간 만료로 주문이 취소되는 순간 결제 요청이 함께 오면, 취소된 주문에서 돈이 빠지거나 환불이 누락될 수 있다

  2. 원인 — 어떻게 찾았나

    주문과 결제는 다른 도메인이라 서로의 트랜잭션 안을 볼 수 없다

  3. 해결 — 무엇을 바꿨나

    도메인 사이는 발행한 쪽이 커밋된 뒤에만 전달되는 이벤트(order.payment-requested → payment.completed · payment.failed)로만 연결했다. 결제와 취소는 같은 회원 지갑 락을 먼저 잡고 상대의 기록을 보게 해, 취소가 먼저면 차감하지 않고 결제가 먼저면 남은 금액을 환불한다. 주문당 결제는 payment.order_id 유니크 키로 한 번만 허용하고, 취소 처리 실패는 재처리 테이블과 1분 주기 스케줄러로 메운다

  4. 결과

    같은 취소가 여러 번 와도 환불은 한 번만 반영돼, 실패한 처리를 몇 번이고 다시 돌려도 안전하다

설계 결정

  • 결제 키 암호화

    토스 paymentKey는 AES-256-GCM으로 암호화해 저장한다. IV는 암호화할 때마다 새로 만들고, 암호화 키 환경 변수가 없으면 앱이 뜨지 않는다.

  • 웹훅은 믿지 않고 다시 조회

    토스 웹훅에는 서명이 없어 본문을 믿지 않는다. 주문번호로 토스를 다시 조회해 전액 취소일 때만 반영하고, 조회 결과를 모르면 503을 돌려 토스가 다시 보내게 한다.

  • 취소 중에는 돈부터 묶기

    충전 취소는 토스를 부르기 전에 충전액을 먼저 빼 둔다. 토스가 응답하는 동안 그 돈으로 결제하지 못하게 하려는 것이고, 거절되면 다시 넣고 원장에 되돌림을 남긴다.

  • 추적용 행동 로그

    상태가 바뀐 일만 action.payment 로거에 한 줄로 남긴다. 사람은 한글 설명으로, 검색은 key=value로 읽고, 롤백된 일은 남기지 않는다.

검증

  • 결제 테스트 클래스 41개

    충전 승인 · 재조회 · 취소 · 웹훅 · 주문 결제 · 환불 · 정산 지급을 단위 · 통합 테스트로 확인한다.

  • 가짜 결제 게이트웨이

    통합 테스트는 토스 대신 paymentKey로 승인 · 거절 · 결과 모름을 고르는 가짜 게이트웨이를 써서, 결과를 모르는 경우까지 재현한다.

  • ArchUnit 계층 규칙

    팀 공통 계층 규칙 9개(도메인 독립, in · app · out 의존 방향, 도메인 이벤트 위치)를 ArchUnit 테스트로 작성해 고정했다.

← 프로젝트 목록으로