결제 시스템 재설계 (1) - PHP 레거시에서 Java/Spring으로
PHP로 구현된 레거시 결제 시스템을 Java/Spring + SQS 기반 이벤트 드리븐 아키텍처로 전환한 과정을 공유합니다.
배경
우리 서비스의 결제 시스템은 PHP로 구현되어 있었다. 서비스 초기부터 쌓여온 코드라 기능은 동작했지만, 구조적인 한계가 뚜렷했다.
PHP 결제 시스템의 구조
결제 흐름은 이랬다.
결제 유형마다 완전히 다른 PHP 파일이 진입점이었고, 각 파일 안에서 포트원 API 호출부터 DB 저장까지 모든 로직을 직접 구현하고 있었다. 가상계좌 웹훅은 결제 실행 없이 후처리만 담당했지만, 트랜잭션 관리와 DB 조작 코드는 동일하게 반복되고 있었다.
핵심 문제들: 결제 로직이 여러 파일에 분산: 수강권 구매, 수강권 연장, 구독 결제, 가상계좌 웹훅 등 결제 유형별로 완전히 다른 파일에 로직이 흩어져 있었다. 각 파일마다 포트원(Iamport) API 키를 하드코딩으로 넣고 있었다 포트원 API 추상화 부재: 포트원 API 호출 코드가 각 파일에 직접 박혀 있어서, API 버전을 올리려면 모든 파일을 수정해야 했다. 추상화 계층 없이 포트원 V1 API에 강하게 결합된 구조였다 트랜잭션 관리가 수동: 매 결제마다 직접 트랜잭션을 열고, 쿼리를 실행하고, 성공 시 커밋, 실패 시 롤백을 호출했다. 중간에 에러가 나면 정합성이 깨질 수 있는 구간이 있었다 멱등성 미보장: 동일 webhook이 두 번 들어오면 중복 결제가 발생할 수 있었다
결제 유형의 복잡성
단순한 결제 시스템이 아니었다. 지원해야 하는 결제 유형만 해도:
| 유형 | 설명 | 후처리 | |||--| | BILLING | 정기 구독 결제 | 기존 수강권 갱신, 수업 연결 | | FIRST_BILLING | 구독 첫 결제 (구독 생성 포함) | 구독 매핑 생성, 티켓 발급, 다음 결제 예약 | | LUMP_SUM | 일시불 결제 | 전체 기간 티켓 일괄 발급 | | TRIAL | 체험 수업 | 체험 티켓 생성 (스마트톡 1일 / 일반 7일) | | TRIAL_FREE | 무료 체험 | PG 결제 없이 체험 티켓 생성 | | BEHIND | 미납 결제 | BILLING과 동일 로직으로 미납분 처리 |
각 유형마다 후처리가 다르다 - 티켓 생성 방식, 구독 매핑, 다음 결제일 계산, 쿠폰 적용 등이 전부 다르다. PHP에서는 이 분기가 if-else 체인으로 처리되고 있었고, 새로운 결제 유형을 추가할 때마다 여러 파일을 수정해야 했다.
왜 전면 재설계인가
부분 개선이 아닌 전면 재설계를 결정한 이유는 명확했다. 기술 스택 통일: 백엔드가 Java/Spring으로 전환되는 과정에서 결제만 PHP로 남아 있었다. 배포·모니터링·온콜 모두 이중 운영 포트원 API 추상화 부재: PHP 코드에 포트원(Iamport) V1 API 호출이 직접 박혀 있어서, API 버전 업그레이드에 대응하기 어려운 구조였다 품질 문제: 중복 결제, 환불 누락, 구독 갱신 오류 등 결제 관련 CS가 꾸준히 발생
| | PHP 레거시 | Java/Spring (목표) | |||| | 진입점 | 결제 유형별 독립 PHP 파일 | 단일 SQS Consumer | | 포트원 연동 | 파일마다 API 키 하드코딩 | 추상화 계층 분리 | | 트랜잭션 | 수동 BEGIN/COMMIT/ROLLBACK | Spring @Transactional | | 멱등성 | 미보장 | Redis 분산 락 | | 후처리 | 동기 처리 (결제 응답 지연) | SQS 비동기 분리 | | 타입별 분기 | if-else 체인 | Strategy 패턴 |
설계: SQS 기반 이벤트 드리븐
첫 번째 설계 방향은 SQS 기반 이벤트 드리븐 아키텍처였다. 결제 프로세스를 단계별로 나누고, 각 단계를 SQS 메시지로 연결하는 구조다.
왜 SQS인가
결제 시스템에서 가장 중요한 건 "결제는 실행됐는데 후처리가 실패하는" 상황을 막는 것이다. PG사에서 결제 승인이 났는데 우리 시스템에서 티켓 발급이 실패하면? 사용자는 돈은 나갔는데 서비스를 못 받는다.
SQS를 도입하면: 각 단계가 독립적으로 실행/재시도 가능 메시지가 유실되지 않으므로 후처리 누락 방지 단계 간 결합도가 낮아져서 각각 독립적으로 수정 가능
전체 아키텍처
SQS 메시지 설계
결제 흐름을 Action 단위로 분리했다.
| Action | 역할 | |--|| | PAYMENT | 결제 완료 진입점. 결제 검증 + 정보 저장 | | PAYMENT_DATA | 타입별 후처리 (티켓 생성, 구독 매핑, 알림) | | PAYMENT_SCHEDULE | 다음 정기결제 예약 (Portone Schedule API) | | PAYMENT_FAILED | 결제 실패 처리 (재시도 or 구독 해지) |
첫 결제·체험·일시불은 클라이언트에서 결제가 완료되면 BFF(Next.js 서버)를 통해 SQS PAYMENT 메시지를 발행한다. 정기결제는 Portone Schedule API가 예약일에 결제를 실행하고, 그 결과가 웹훅으로 백엔드에 들어오면 마찬가지로 SQS PAYMENT를 발행한다. 이후 흐름은 동일하다 - 결제 검증과 정보 저장을 거친 뒤, PAYMENT_DATA와 PAYMENT_SCHEDULE을 각각 발행한다. 실패하면 PAYMENT_FAILED로 분기한다.
Handler 기반 라우팅
SQS 메시지를 받아서 Action별로 분기하는 구조는 세 개의 클래스로 나뉘어 있었다.
PaymentListener가 SQS 큐를 구독하고, 메시지를 PaymentActionHandler에 전달하면, Handler가 message.getAction() 값에 따라 Gateway의 적절한 메서드를 호출하는 구조였다. Gateway가 결제 검증부터 후처리까지 모든 비즈니스 로직을 담당했고, Handler는 순수하게 라우팅 역할만 했다.
이 구조는 SQS 기반 비동기 흐름을 단순하게 유지하는 데는 좋았지만, Gateway 한 클래스가 모든 결제 유형의 검증·후처리·스케줄링 로직을 담으면서 빠르게 비대해지는 문제가 있었다.
멱등성: 이중 방어
동일한 결제 요청이 중복으로 들어오는 건 실제로 자주 발생한다. Webhook 재전송, 사용자 더블 클릭, 네트워크 재시도 등.
1단계 - SQS 메시지 레벨 멱등성 (AOP)