Skip to content

hyoguoo

17 posts by hyoguoo

AI를 활용한 백엔드 개발 4단계 워크플로우

AI가 코드도 작성해주는 시대가 온 지금, AI가 작성한 코드를 얼마나 잘 검사하고 통제하느냐에서 결정된다고 생각한다. 이를 위해서 명령 프롬프트나 여러 기술들을 잘 사용하는 것도 중요하지만, 프로젝트 전체의 주도권을 유지할 수 있도록 워크플로우를 설계하는 것이 가장 중요하다고 생각한다.

AI는 매우 빠르게 코드를 작성하고 작업을 수행하지만, 그만큼 주변 시스템을 빠르게 망가뜨리게 되고, 어느 지점부터 문제가 생겼는지 추적하기 어려워질 수 있다.

AI에게 구현을 시키기 전, 작업을 하고자 하는 해당 프로젝트의 코드베이스를 학습시키는 단계다.

  • 프롬프트 키워드: ‘깊이’, ‘매우 상세히’ 등의 키워드를 사용하여 코드베이스를 실제로 깊게 분석하도록 유도
  • 파일 작성: 레이어 구조, 도메인 용어, 기존 컴포넌트 명칭 등을 CLAUDE.md 혹은 별도의 분석 문서에 기록(AI가 세션이 바뀌어도 컨텍스트를 유지할 수 있도록)

2단계. 새로 개발할 내용에 대한 스펙

Section titled “2단계. 새로 개발할 내용에 대한 스펙”

구현 전, AI가 새로 개발할 기능에 대한 스펙을 작성하는 단계다.

  • 범위 정의: 어떤 기능을 만들지를 명확히 정의하여, 방향성 및 구현 범위를 좁혀 AI가 임의로 범위를 확장하는 것을 방지
  • 환각 방지: 요구사항이 모호하면 AI는 환각(Hallucination)으로 빈틈을 채울 수 있기 때문에 무엇을 만들지를 명확히 정의하며 계속해서 AI와 질의하며 목표 고도화

AI가 목표 달성을 위해 어떤 단계를 거쳐야 하는지 작성하는 단계다.

  • 마일스톤과 세부 작업으로 쪼개어 작성: 큰 목표를 달성하기 위해 필요한 단계들을 마일스톤과 세부 작업으로 쪼개어 작성
  • 검토 및 피드백: AI가 작성한 계획을 검토하며, AI가 놓칠 수 있는 기술적 디테일이나 백엔드 시스템의 특성을 보완하여 계획을 고도화
  • 파일 기록: 계획을 별도의 파일로 기록하여, AI가 세션이 바뀌거나 컨텍스트가 초기화되어도 계획을 참조하여 중간에 작업을 이어갈 수 있도록 함

AI에게 단계별로 구현을 시킨 뒤 감독하는 단계다.

  • 명령: Agent, Skills 혹은 상세한 프롬프트를 활용하여 구현 지시
  • 롤백: 방향이 틀어지면 점진적 수정 대신 — git reset이나 revert로 되돌린 후 범위 재설정 및 다시 구현 지시

결제 시스템의 새로운 기능을 개발하면서, AI를 활용하여 트랜잭션 설계와 구현을 진행했다. 구현된 기술적 내용은 보상 트랜잭션 실패 상황 극복 가능한 결제 플로우 설계에서 자세히 다룬다.

SPEC 작성에 앞서, 프로젝트의 기본 정보를 CLAUDE.md 파일에 정리했다.

  1. 자체 분석: AI에게 프로젝트의 레이어 구조, 도메인 용어, 기존 컴포넌트 이름 등을 분석하게 하고, 그 결과를 CLAUDE.md에 작성하도록 지시
  2. 추가 정보 제공: AI가 분석한 내용을 확인한 뒤, 추가로 알고 있는 세부 사항이나 기술적 디테일을 CLAUDE.md에 보완하여 작성
  3. 행동 지침 설정: 코드 작성 시 지켜야 할 제약사항이나 작업 진행 방식에 대한 지침을 CLAUDE.md에 명시하여, 불필요한 변경이나 AI가 자체 판단으로 구현을 시작하는 것을 방지

작업 진행 방식은 아래와 같이 작업 진행 방식, 참조해야 할 파일, 구현 시 지켜야 할 제약사항 등을 명시했다.

CLAUDE.md
// ... (프로젝트 분석 내용 생략) ...
## Development Workflow
Follow this required workflow when implementing tasks:
1. Review Specifications: Always refer to `TECHSPEC.md` to understand the technical requirements before starting
implementation.
2. Follow the Plan: Use `PLAN.md` as the authoritative source for the implementation sequence.
* After completing a task, mark it as checked in `PLAN.md`.
* Always begin work on the next unchecked task listed in `PLAN.md`.
3. Test-First Development:
* Write tests (unit or integration) *before* writing the implementation code.
* Implement the minimum amount of code necessary to pass the newly written tests. Avoid making overly large or
unrelated changes.
4. Update Documentation:
* Upon task completion, if the changes necessitate updates to guidance or specification files (e.g., CLAUDE.md,
TECHSPEC.md), update those files accordingly.
// ... (추가 분석 내용 생략) ...

AI에게 구현을 시키기 전에, 새로 개발할 기능에 대한 스펙을 TECHSPEC.md 파일에 작성했다.

  1. 초안 작성: AI에게 기능의 목적과 요구사항을 설명하여 초안을 작성하게 함
  2. 환각 방지: 초안이 모호하거나 불충분한 경우, AI와 계속해서 질의를 하면서 스펙을 고도화하여, AI가 환각으로 빈틈을 채우는 것을 방지
  3. 최종 스펙: 기능의 목적, 핵심 변경 사항, 상세 구현 내역 등을 포함하는 최종 스펙 문서 작성
TECHSPEC.md
### 1. 배경 및 목적
현재 결제 프로세스는 '재고 차감'과 '외부 PG 결제'가 순차적으로 진행됩니다. 만약 1단계(재고 차감) 성공 후 2단계(PG 결제)를 처리하는 도중 서버가 다운되면, ...
### 2. 핵심 변경 사항: 3단계 프로세스 도입
사용자 결제 요청의 전체 흐름을 3단계로 분리하여 구현합니다.
- 1단계 (Tx 1): 재고 차감 + 작업 생성 (단일 트랜잭션)
- 2단계 (Non-Tx): 외부 PG사 결제 요청 (장애 위험 구간)
- 3단계 (Tx 2): 결제 결과 반영 + 작업 상태 완료 (단일 트랜잭션)
### 3. 상세 구현 내역
### 3-1. [신규] '결제 프로세스 테이블' 스키마 정의
...(세부 스키마 정의 생략)...
### 3-2. [수정] 기존 결제 서비스 로직 변경
기존의 단일 결제 로직을 아래와 같이 3단계로 명확히 분리하고, 각 단계별 트랜잭션을 적용합니다.
1단계: 작업 생성 및 재고 차감 (Tx 1 - @Transactional)
- @Transactional 어노테이션 등으로 단일 트랜잭션을 보장합니다.
- 로직 1: `payment_process` 테이블에 해당 `[주문_FK]`와 함께 'PROCESSING' 상태로 신규 작업을 INSERT 합니다.
- 로직 2: 기존 로직대로 `[재고_테이블]`의 실제 재고를 차감하는 UPDATE를 실행합니다.
- 이 트랜잭션이 성공적으로 커밋되어야만 다음 단계로 진행합니다. 실패 시(재고 부족 등) 모든 변경 사항이 롤백됩니다.
2단계: 외부 PG사 결제 처리 (Non-Tx)
- 1단계 트랜잭션이 성공적으로 커밋된 후, 별도의 트랜잭션 없이 외부 PG사 결제 API를 호출합니다.
- 이 단계에서 서버가 다운될 경우, `payment_process` 테이블에는 'PROCESSING' 상태의 작업이 남게 되며, 이것이 복구 대상이 됩니다.
3단계: 작업 완료 및 주문 상태 변경 (Tx 2 - @Transactional)
- PG사로부터 받은 결제 결과를 바탕으로 @Transactional이 적용된 별도의 메서드를 호출합니다.
- [결제 성공 시]
- 로직 1: `payment_process` 테이블에서 해당 작업의 상태를 'COMPLETED'로 UPDATE 합니다.
- 로직 2: 기존 로직대로 `[주문_테이블]`의 상태를 '결제 완료'로 변경합니다.
- [결제 실패 시]
- 로직 1: `payment_process` 테이블에서 해당 작업의 상태를 'FAILED'로 UPDATE 합니다.
- 로직 2 (보상 트랜잭션): 1단계에서 차감했던 재고를 다시 복구(증가)하는 UPDATE를 `[재고_테이블]`에 실행합니다.
- 로직 3: `[주문_테이블]`의 상태를 '결제 실패' 등으로 변경합니다.
... (세부 구현 내역 생략) ...

플랜 단계에서 마일스톤을 쪼개고, AI에게 구현 가이드를 작성하도록 지시한 뒤, AI가 놓칠 수 있는 백엔드의 세부 사항을 보완했다.

PLAN-BEFORE.md
## Milestone 3: 장애 복구 로직 구현 (다음 작업)
목표: 1단계와 3단계 사이, 즉 2단계(외부 결제) 도중 장애로 인해 'PROCESSING' 상태로 남겨진 작업을 찾아내어 상태를 동기화합니다.
### 구현 가이드
- [ ] Subtask 3.1: Toss API 결제 상태 조회 기능 확인 및 구현
- 목적: PROCESSING 상태 작업의 실제 결제 결과를 확인하기 위함
- 구현 위치: `TossOperator` 또는 새로운 UseCase
- 필요 기능:
- 메서드명 예시: `getPaymentStatus(String orderId)` 또는 `retrievePaymentInfo(String orderId)`
- 반환 타입: Toss API 응답을 매핑한 DTO (예: `TossPaymentStatus`)
- 필요한 정보: 결제 상태 (DONE, CANCELED, ABORTED 등), approvedAt, 실패 사유
- 참고: 기존 `TossOperator`에 해당 메서드가 있는지 먼저 확인
- Toss API
문서: [https://docs.tosspayments.com/reference#결제-조회](https://docs.tosspayments.com/reference#%EA%B2%B0%EC%A0%9C-%EC%A1%B0%ED%9A%8C)
- [ ] Subtask 3.2: 복구 전용 UseCase 구현
- 파일 위치: `payment/application/usecase/PaymentRecoveryUseCase.java`
- 의존성:
- `PaymentProcessUseCase`: PROCESSING 작업 조회
- `PaymentLoadUseCase`: PaymentEvent 로드
- `PaymentTransactionCoordinator`: 복구 트랜잭션 실행
- `TossOperator` (또는 Toss 조회 UseCase): 실제 결제 상태 조회
- 핵심 메서드: `recoverStuckPayments()`
... (세부 구현 가이드 생략) ...

그리고 작업이 완료되면, AI는 구현이 완료된 작업을 PLAN.md에 체크 표시하며, 다음 작업으로 넘어갈 수 있도록 했다.

PLAN-COMPLETED.md
## Milestone 3: 장애 복구 로직 구현 ✅ (완료)
목표: 1단계와 3단계 사이, 즉 2단계(외부 결제) 도중 장애로 인해 'PROCESSING' 상태로 남겨진 작업을 찾아내어 상태를 동기화합니다.
### 완료된 작업 요약
- [x] Subtask 3.1: Toss API 결제 상태 조회 기능 확인 ✅
- 기존 `TossOperator.findPaymentInfoByOrderId(String orderId)` 메서드 확인
- `TossPaymentInfo` 반환: 결제 상태, approvedAt, 모든 필요한 정보 포함
- 추가 구현 불필요
- [x] Subtask 3.2: `PaymentRecoveryUseCase` 구현 ✅
- 파일 위치: `payment/application/usecase/PaymentRecoveryUseCase.java:65-116`
- 핵심 메서드:
- `recoverStuckPayments()`: PROCESSING 작업들을 순회하며 복구
- `recoverSinglePayment()`: 개별 결제 복구 로직
- 구현 특징:
- 에러 격리: 한 작업 실패가 다른 작업에 영향 없음
- Toss 상태별 분기: DONE → success completion, 기타 → failure compensation
- 구조화된 로깅: `EventType` 상수 활용
- [x] Subtask 3.3: 스케줄러 통합 ✅
- `PaymentRecoverService` 인터페이스에 `recoverStuckPayments()` 추가
- `PaymentRecoverServiceImpl` 구현 완료
- `PaymentScheduler` 스케줄링 메서드 추가:
- `@Scheduled(fixedDelayString = "${scheduler.payment-recovery.interval-ms:60000}")`
- 60초마다 자동 실행

PLAN은 고정된 문서로 하는 것이 아니라, 작업이 완료될 때마다 검토하고 피드백을 반영하여 업데이트하는 방식으로 진행했다.

  • 최소 하나의 서브 테스크 - 최대 하나의 마일스톤만 작업을 진행하도록 하고, 작업 범위를 특정하여 AI가 임의로 작업 범위를 확장하는 것을 방지
  • 컨텍스트 윈도우가 가득 차거나 세션이 바뀌어도, PLAN 파일을 참조하여 작업의 진행 상황과 다음 단계에 대한 정보를 유지할 수 있도록 함
  • 중도에 PLAN 파일에 추가적인 메모나 피드백을 직접 작성하여, AI가 이를 반영하여 작업을 수행하도록 지시

또한, 이 파일을 작성함으로써, AI 뿐만 아니라 개발자 자신도 전체 작업의 흐름과 세부 사항을 명확히 파악할 수 있게 되어, 기술적 디테일을 보완하는 데 큰 도움이 되었다.

이번 작업은 4단계 워크플로우를 실제 결제 시스템 개발에 적용했고, 다음과 같은 성과를 얻을 수 있었다.

  • 방향 유지: AI가 임의로 작업 범위를 확장할 때 PLAN을 기준으로 즉시 재설정 가능
  • 세션 연속성: 컨텍스트 윈도우가 초기화되어도 PLAN 파일로 작업 흐름 유지
  • 요구 사항 고도화: PLAN 검토 과정에서 요구사항이 모호하거나 불충분한 부분을 발견하여, AI와 질의하며 고도화할 수 있는 기회 확보

다만 돌아보면 아쉬운 지점도 있다.

이번 프로젝트에서 마일스톤 구현 지시, 코드 검토 요청, PLAN 업데이트, 테스트 실행 확인 등 반복되는 패턴이 매 마일스톤마다 거의 동일한 순서로 반복됐다.

스킬 파일은 자주 하는 작업의 명령어, 순서, 판단 기준, 실수 목록을 하나의 파일로 정의해두는 방식이다.

  • 명령어
  • 작업 순서
  • 판단 기준
  • 자주 하는 실수 목록

반복 입력이 줄어드는 것 외에도 다음 이점을 얻을 수 있다.

  • 실수 방지: 판단 기준과 실수 목록을 담아두면 AI가 같은 실수를 반복할 가능성 감소
  • 품질 향상: 확인 방법을 함께 제공하여 결과물 품질 향상(“구현해라”보다 “구현하고 이 방식으로 검증해라”가 나은 결과 생성)

이번 프로젝트는 결제, 재고, 주문 등 도메인별로 패키지가 명확히 나뉜 구조였다. 그럼에도 프로젝트의 모든 컨텍스트를 CLAUDE.md 하나에 담아 제공했는데, 이는 결제 도메인 작업을 할 때 재고나 주문 관련 정보까지 함께 로드되는 셈이었다.

AI에게 보여주는 코드 범위가 넓어질수록 다음 문제가 발생한다.

  • 컨텍스트 집중도 저하: AI가 현재 작업과 관련 없는 정보까지 함께 로드하면서, 실제로 필요한 정보에 대한 집중도가 떨어짐
  • 패턴 일관성 저하: AI가 다양한 도메인의 정보를 함께 참조하면서, 기존 패턴과 어긋난 코드가 나올 가능성이 높아짐

더 나은 방식은 도메인마다 별도의 안내 파일을 두어, 작업 시 해당 파일만 참조하도록 지시하는 방식이다.

  • payment/PAYMENT.md, stock/STOCK.md처럼 도메인 패키지 안에 별도 파일 구성
  • 해당 파일에 도메인 구조, 주요 클래스, 비즈니스 규칙, 주의사항 정리
  • 작업 시 해당 파일만 참조하도록 지시

볼 코드가 작아질수록 요구되는 컨텍스트가 줄어들어 AI의 품질이 향상으로 이어지게 된다.

전략 패턴을 통한 PG 독립성 확보 및 확장 가능한 결제 시스템 설계

실행 환경: Java 21, Spring Boot 3.3.3

결제 시스템은 다양한 PG(Payment Gateway) 업체와 연동되어야 하며, 비즈니스 요구사항에 따라 PG를 유연하게 전환할 수 있어야 한다. 그러나 초기 구현에서는 특정 PG(Toss Payments)에 강하게 결합된 구조로 인해 다음과 같은 근본적인 문제들이 존재했다.

  • 도메인/애플리케이션 레이어에 Toss-specific 타입 직접 사용 (TossPaymentInfo, TossPaymentDetails)
  • PG 변경 시 핵심 비즈니스 로직 수정 필요로 인한 높은 결합도
  • 여러 PG 동시 지원 불가능으로 비즈니스 확장성 제약

이러한 문제를 해결하기 위해 전략 패턴(Strategy Pattern)과 포트-어댑터 패턴(Port-Adapter Pattern)을 결합하여 PG 독립적인 아키텍처를 구축했다.
핵심은 추상화를 통한 의존성 역전으로, 도메인 레이어는 구체적인 PG 구현체가 아닌 추상화된 인터페이스에만 의존하도록 설계했다.

  • 도메인 모델 PG 독립화: PG 변경 시 핵심 비즈니스 로직 무수정
  • 런타임 전략 교체 가능(설정 기반): 코드 재배포 없이 PG 전환
  • 새로운 PG 추가 시 기존 코드 무영향(OCP 준수): 확장에는 열려있고 수정에는 닫힌 구조

위 목표를 달성하기 위해 다음 디자인 패턴을 적용했다.

  • 전략 패턴: PG별 결제 처리 로직을 독립적인 전략으로 캡슐화
  • 포트-어댑터 패턴: 외부 시스템(PG API)과의 경계를 명확히 분리
  • 팩토리 패턴: 설정 기반 전략 객체 생성 및 선택 자동화

애플리케이션 레이어는 구체적인 구현이 아닌 Port 인터페이스에만 의존하며, 실제 PG 통신 로직은 Infrastructure 레이어의 Strategy 구현체에서 처리된다.

graph TB
subgraph "Application Layer"
Service[PaymentConfirmServiceImpl]
UseCase[PaymentProcessorUseCase]
Port[PaymentGatewayPort<br/>Interface]
end
subgraph "Infrastructure Layer"
Adapter[PaymentGatewayAdapter<br/>Port 구현체]
Factory[PaymentGatewayFactory<br/>전략 선택]
Strategy[PaymentGatewayStrategy<br/>Interface]
subgraph "Strategy Implementations"
Toss[TossPaymentGatewayStrategy]
Future[Other PG Strategy<br/>... 확장 가능]
end
end
subgraph "External Systems"
TossAPI[Toss Payments API]
end
Service -->|사용| UseCase
UseCase -->|의존| Port
Port -.->|구현| Adapter
Adapter -->|위임| Factory
Factory -->|선택| Strategy
Strategy -.->|구현| Toss
Strategy -.->|확장 가능| Future
Toss -->|호출| TossAPI
style Port fill: #e1f5ff
style Strategy fill: #e1f5ff
style Adapter fill: #fff4e1
style Factory fill: #fff4e1
style Toss fill: #e8f5e9
style Future fill: #f5f5f5, stroke-dasharray: 5 5

1. PaymentGatewayPort(포트 인터페이스)

Section titled “1. PaymentGatewayPort(포트 인터페이스)”

애플리케이션 레이어가 외부 PG와 통신하기 위한 추상 인터페이스로, 구체적인 PG 구현체가 이를 구현하는 의존성 역전 구조를 형성한다.

public interface PaymentGatewayPort {
PaymentStatusResult getStatus(String paymentKey);
PaymentStatusResult getStatusByOrderId(String orderId);
PaymentConfirmResult confirm(PaymentConfirmRequest request);
PaymentCancelResult cancel(PaymentCancelRequest request);
}
  • PG 독립적인 DTO 모델 사용 (PaymentStatusResult, PaymentConfirmRequest)하여 특정 PG에 종속되지 않도록 설계
  • 모든 메서드는 특정 PG사 종속적이지 않은 DTO 사용
  • PG별 데이터 변환은 Infrastructure 레이어에서 처리

2. InternalPaymentGatewayAdapter(어댑터 구현)

Section titled “2. InternalPaymentGatewayAdapter(어댑터 구현)”

Port를 구현하고 전략 패턴으로 위임하는 역할을 한다. 이 어댑터는 애플리케이션 레이어와 인프라스트럭처 레이어 사이의 중재자 역할을 수행하며, 실제 PG 통신 로직은 Strategy 구현체에 위임한다.

@Component
@RequiredArgsConstructor
public class InternalPaymentGatewayAdapter implements PaymentGatewayPort {
private final PaymentGatewayFactory factory;
private final PaymentGatewayProperties properties;
@Override
public PaymentConfirmResult confirm(PaymentConfirmRequest request) {
PaymentGatewayStrategy strategy = factory.getStrategy(properties.getType());
return strategy.confirm(request);
}
// 나머지 메서드도 동일한 패턴
}

3. PaymentGatewayStrategy(전략 인터페이스)

Section titled “3. PaymentGatewayStrategy(전략 인터페이스)”

PG별 구현체가 구현해야 하는 공통 인터페이스를 정의하여, 모든 PG가 제공해야 하는 표준 작업(결제 승인, 취소, 조회)을 명시한다.

public interface PaymentGatewayStrategy {
boolean supports(PaymentGatewayType type);
PaymentConfirmResult confirm(PaymentConfirmRequest request);
PaymentCancelResult cancel(PaymentCancelRequest request);
PaymentStatusResult getStatus(String paymentKey);
PaymentStatusResult getStatusByOrderId(String orderId);
}

4. PaymentGatewayFactory(전략 선택 팩토리)

Section titled “4. PaymentGatewayFactory(전략 선택 팩토리)”

설정 기반으로 적절한 전략을 선택하고 반환하는 역할로, Spring의 의존성 주입을 활용하여 모든 Strategy 구현체를 자동으로 수집하고, 런타임에 설정값에 따라 적절한 구현체를 선택한다.

@Component
@RequiredArgsConstructor
public class PaymentGatewayFactory {
private final List<PaymentGatewayStrategy> strategies;
public PaymentGatewayStrategy getStrategy(PaymentGatewayType type) {
return strategies.stream()
.filter(strategy -> strategy.supports(type))
.findFirst()
.orElseThrow(() -> UnsupportedPaymentGatewayException.of(type));
}
}
  • Spring 자동 주입: 모든 PaymentGatewayStrategy 구현체가 자동으로 List에 주입
  • 예외 처리: 지원하지 않는 PG 타입이 설정되면 명확한 예외(UnsupportedPaymentGatewayException)를 발생

5. TossPaymentGatewayStrategy(구체적 전략 구현)

Section titled “5. TossPaymentGatewayStrategy(구체적 전략 구현)”

Toss Payments API와의 실제 통신 로직을 수행하며, 구현체는 특정 PG사에 종속적인 세부 사항을 캡슐화한다.

@Component
@RequiredArgsConstructor
public class TossPaymentGatewayStrategy implements PaymentGatewayStrategy {
private final PaymentGatewayInternalReceiver paymentGatewayInternalReceiver;
@Override
public boolean supports(PaymentGatewayType type) {
return type == PaymentGatewayType.TOSS;
}
@Override
public PaymentConfirmResult confirm(PaymentConfirmRequest request) {
// 1. 도메인 모델 → Toss-specific 모델 변환
TossConfirmGatewayCommand tossCommand = TossConfirmGatewayCommand.builder()
.orderId(request.orderId())
.paymentKey(request.paymentKey())
.amount(request.amount())
.idempotencyKey(generateIdempotencyKey(request.orderId()))
.build();
// 2. Toss API 호출
PaymentGatewayInfo info = PaymentInfrastructureMapper.toPaymentGatewayInfo(
paymentGatewayInternalReceiver.confirmPayment(
PaymentInfrastructureMapper.toTossConfirmRequest(tossCommand)
)
);
// 3. Toss 응답 → 도메인 모델 변환
return convertToPaymentConfirmResult(info, request);
}
private PaymentConfirmResult convertToPaymentConfirmResult(
PaymentGatewayInfo info, PaymentConfirmRequest request) {
// Toss 상태 코드 → 도메인 상태 매핑
PaymentConfirmResultStatus status = determineConfirmResultStatus(info, failure);
return new PaymentConfirmResult(status, info.getPaymentKey(), ...);
}
}

해당 메서드는 계층 간 명확한 책임 분리를 위해 다음 3단계로 구성된다.

  1. 도메인 모델 변환: PaymentConfirmRequest (PG-독립 모델) → TossConfirmGatewayCommand (Toss-specific 모델)
    • 멱등성 키(Idempotency Key) 생성을 통한 중복 결제 방지
    • Toss API 요구사항에 맞는 데이터 형식 변환
  2. PG API 호출: PaymentGatewayInternalReceiver를 통한 실제 HTTP 통신
    • 네트워크 통신 및 인증 처리
    • 타임아웃, 재시도 등의 복원력 패턴 적용
  3. 응답 변환 및 매핑: Toss 응답 → 도메인 모델 (PaymentConfirmResult)
    • Toss의 상태 코드를 도메인 상태(PaymentConfirmResultStatus)로 정규화
    • PG-specific한 에러 정보를 도메인 예외로 변환

이번 아키텍처 리팩토링을 통해 기술적 개선과 함께 비즈니스 유연성을 확보할 수 있었다.

  1. PG 독립성 확보: 도메인/애플리케이션 레이어에서 PG-specific 타입 완전 제거
  2. 확장 가능한 구조: 기존 코드 수정 없이 새로운 PG 추가 가능

이러한 아키텍처는 결제 시스템의 핵심인 안정성확장성을 동시에 달성하며, 향후 비즈니스 요구사항 변경에 따른 다양한 요구사항에 유연하게 대응할 수 있는 기반을 제공할 수 있다.

보상 트랜잭션 실패 상황 극복 가능한 결제 플로우 설계

실행 환경: Java 21, Spring Boot 3.3.3

결제 로직은 재고 차감(DB)‘과 외부 PG 결제(API)라는 두 개의 분리된 작업을 수행하고 있다. 이 과정에서 1) 재고 차감은 성공했지만, 2) PG 결제가 실패하는 경우가 발생할 수 있는데, 차감했던 재고를 다시 되돌리는 보상 트랜잭션(Compensation Transaction)을 통해 해결하고 있었다.

하지만 이 보상 트랜잭션이 실패할 수 있다는 문제가 남아있다.

  • 상황: PG사 결제 실패
  • 시스템: 재고를 롤백하기 위한 보상 트랜잭션 실행
  • 장애: 이 보상 트랜잭션이 DB 데드락, 커넥션 풀 고갈, 서버 다운 등으로 실패

이 경우, 시스템은 다음과 같은 심각한 데이터 불일치 상태에 빠진다.

  • 결제 상태: 실패
  • 재고 상태: 차감됨 (재고 롤백 실패)
  • 최종 결과: 고객은 구매에 실패했지만, 재고는 판매된 것으로 처리되어 유령 재고 발생

이 실패 처리의 실패(Double Fault) 문제를 해결하기 위해, 다음과 같은 설계 원칙을 수립했다.

  1. 상태 추적성 (Traceability): 모든 결제 과정을 ‘작업’으로 정의하고 상태를 별도 테이블에 기록하여, 장애 발생 시에도 중단된 지점을 명확히 식별
  2. 최종적 일관성 (Eventual Consistency): 장애가 발생하더라도, 시스템 스스로 데이터 정합성 회복

이 문제를 해결하기 위해 여러 해결 방법을 고려할 수 있었다.

  1. Two-Phase Commit (2PC)
    • 모든 리소스(재고 DB, PG사)가 커밋에 동의해야 하는 강력한 일관성 모델
    • 한계: 외부 PG사가 2PC를 지원하지 않으며, 전체 시스템의 성능 저하와 블로킹(Blocking)을 유발
  2. 메시지 큐와 Saga Pattern
    • 재고 차감, 결제 요청, 결과 처리를 별도의 트랜잭션으로 분리하고 메시지 큐(Kafka, RabbitMQ)로 연결하는 방식
    • 한계: 시스템 간 결합도는 낮출 수 있으나, 별도의 메시징 인프라를 도입하는 것은 피하고 싶었음
  3. 선택: 작업 테이블 + Scheduler
    • ‘작업 테이블(payment_process)‘을 DB 내의 ‘메시지 큐(Outbox)‘처럼 활용하는 방식
    • 장점:
      • 단순성: 외부 인프라 의존성 없이, Spring 스케줄러와 DB만으로 ‘최종적 일관성’을 구현 가능
      • 트랜잭션 보장: 데이터 처리를 DB 트랜잭션 내에서 처리하여, 복잡한 트랜잭션 관리 불필요

아키텍처 - 3단계 트랜잭션 분리와 작업 테이블

Section titled “아키텍처 - 3단계 트랜잭션 분리와 작업 테이블”

이 문제를 해결하기 위해 기존의 결제 로직을 3개의 명확한 단계로 분리하고, 모든 과정을 PaymentProcess (작업 테이블)에 기록하도록 설계했다.

작업 테이블 (payment_process) 스키마

Section titled “작업 테이블 (payment_process) 스키마”
컬럼명타입제약조건설명
idBigIntPK, Auto-Increment작업 고유 ID
order_idStringNot Null주문 고유 ID
statusEnum / VarcharNot Null작업 상태 (PROCESSING, COMPLETED, FAILED)
created_atTimestampNot Null생성 시각
updated_atTimestampNot Null수정 시각

이 테이블을 기반으로 결제 흐름은 다음과 같이 분리된다.

  • 1단계 (Tx 1): 재고 차감 + 작업 생성 (PROCESSING)
  • 2단계 (Non-Tx): 외부 PG사 결제 API 호출
  • 3단계 (Tx 2): 결제 결과 반영 + 작업 상태 변경 (COMPLETED / FAILED)

이 구조의 핵심은 장애 발생 시 PROCESSING 상태가 DB에 남는다는 것이다.

graph TD
subgraph sg1 ["1단계: 작업 생성 및 재고 차감 (Transaction 1)"]
A["요청 시작"] --> B{"Tx START"};
B --> C["1. payment_process 테이블에<br/>'PROCESSING' 상태로 작업(Job) INSERT"];
C --> D["2. stock 테이블 재고 차감"];
D --> E{"Tx COMMIT"};
end
E -- 성공 --> F["2단계: 외부 PG사 결제 처리"];
F --> G{결제 성공?};
subgraph sg3 ["3단계: 작업 완료 (Transaction 2)"]
G -- Yes --> H_Success["Tx START"];
H_Success --> I_Success["payment_process 상태 'COMPLETED'로 UPDATE"];
I_Success --> J_Success["orders 테이블 상태 '결제 완료'로 변경"];
J_Success --> K_Success["Tx COMMIT"];
K_Success --> L_End["종료"];
G -- No --> H_Fail["Tx START"];
H_Fail --> I_Fail["payment_process 상태 'FAILED'로 UPDATE"];
I_Fail --> J_Fail["[보상 트랜잭션]<br/>차감했던 재고 복구"];
J_Fail --> K_Fail["Tx COMMIT"];
K_Fail --> L_End;
end
subgraph sg_recovery ["장애 복구 로직"]
Z["복구 로직 시작"] --> Y["payment_process 테이블에서<br/>'PROCESSING' 상태인 작업 조회"];
Y --> X{"PG사에 실제 결제 성공 여부 조회 or 승인 요청"};
%% 복구 로직이 중단되었던 3단계 로직을 다시 실행
X -- " 결제 성공 상태 " --> H_Success;
X -- " 결제 실패 상태 " --> H_Fail;
end
%% Style definitions using IDs
style sg1 fill: #f9f9f9, stroke: #333, stroke-width: 2px
style F fill: #fff, stroke: #333, stroke-width: 2px, stroke-dasharray: 5 5
style sg3 fill: #f9f9f9, stroke: #333, stroke-width: 2px
style sg_recovery fill: #e6f7ff, stroke: #0056b3, stroke-width: 2px, stroke-dasharray: 2 2

보상 트랜잭션 실패 흐름 시나리오

Section titled “보상 트랜잭션 실패 흐름 시나리오”
sequenceDiagram
participant Client as 사용자
participant Service as PaymentCoordinator
participant DB as 데이터베이스 (JPA)
participant Toss as Toss PG API
Client ->> Service: 1. 결제 승인 요청
%% --- 1단계 (Tx 1) ---
Service ->> DB: 2. [Tx 1 시작]
Service ->> DB: 3. 재고(Stock) 차감
Service ->> DB: 4. 작업(PaymentProcess) 생성 (상태: PROCESSING)
Service ->> DB: 5. [Tx 1 커밋]
%% --- 2단계 (Non-Tx) ---
Service ->> Toss: 6. 외부 결제 승인 API 호출
Toss -->> Service: 7. 결제 실패 응답 (예: 한도 초과)
%% --- 3단계 (Tx 2) - 보상 트랜잭션 ---
Service ->> DB: 8. [Tx 2 (보상) 시작]
Service ->> DB: 9. 재고(Stock) 복구 시도
Service ->> DB: 10. 작업(PaymentProcess) 상태 'FAILED' 변경 시도
critical DB 장애 발생
Service ->> DB: 11. [Tx 2 (보상) 롤백] (예: DB 데드락)
end
Service -->> Client: 12. 결제 실패 응답
Note right of DB: [데이터 불일치 상태!]<br/>- 재고: 차감됨 (롤백 실패)<br/>- 작업 상태: PROCESSING (롤백됨)

11번 단계가 핵심인데, 보상 트랜잭션이 롤백되면서, 재고는 차감되었지만 작업 상태는 PROCESSING으로 남는 데이터 불일치 상태가 된다.

스케줄러를 통한 최종적 일관성 확보

Section titled “스케줄러를 통한 최종적 일관성 확보”

이 문제를 해결하기 위해 PaymentRecoveryUseCase라는 스케줄러를 구현했다.

  • 동작: PROCESSING 상태로 일정 시간 이상 방치된 작업을 조회
  • 로직: 조회된 작업의 orderId로 Toss API에 실제 결제 상태를 다시 조회
sequenceDiagram
participant Scheduler as PaymentRecoveryUseCase
participant DB as 데이터베이스 (JPA)
participant Toss as Toss PG API
participant Service as PaymentCoordinator
Scheduler ->> Scheduler: 1. 스케줄러 실행
Scheduler ->> DB: 2. 'PROCESSING' 상태 작업 조회
DB -->> Scheduler: 3. [Job (orderId-123)] 반환
Scheduler ->> Toss: 4. Toss API로 결제 상태 재확인 (orderId-123)
Toss -->> Scheduler: 5. 최종 상태 응답: "CANCELED" (실패)
Scheduler ->> Service: 6. '결제 실패 보상 로직' 재실행
%% --- 3단계 (Tx 2) - 보상 트랜잭션 "재시도" ---
Service ->> DB: 7. [Tx 2 (보상) 시작]
Service ->> DB: 8. 재고(Stock) 복구
Service ->> DB: 9. 작업(PaymentProcess) 상태 'FAILED' 변경
Service ->> DB: 10. [Tx 2 (보상) 커밋]
Note right of DB: [데이터 정합성 복구 완료!]<br/>- 재고: 정상 복구<br/>- 작업 상태: FAILED

이 프로젝트를 통해 다음과 같은 성과를 달성할 수 있었다.

  • 실패 처리의 실패(Double Fault) 엣지 케이스 해결: 결제 시스템에서 발생할 수 있는 데이터 불일치 시나리오에 대한 자동 복구 메커니즘 구축
  • 최종적 일관성 확보: 스케줄러와 작업 테이블 상태 관리를 통해, 장애가 발생하더라도 시스템은 스스로 데이터 정합성을 맞춰 회복 가능

결제 이력 추적 및 핵심 지표 모니터링 시스템 구현

실행 환경: Java 21, Spring Boot 3.3.3

결제 복구 시스템을 구축하면서 결제 상태가 복잡해지면서 다양한 상태와 재시도 메커니즘이 추가되면서 상태의 추적이 필요해졌다.
이러한 상태 전환을 단순 로그로만 남길 경우 다음과 같은 한계가 존재했다.

  • 구조화되지 않은 로그 데이터로 인해 집계 및 분석이 어려움
  • 트랜잭션 단위로 상태 변경의 정합성을 보장할 수 없음
  • 수동 로그 해석에 따른 운영 효율성 저하
  • 이상 데이터 탐지 및 모니터링 한계

따라서, 구조화된 상태 변경 이력을 별도 테이블에 저장하고, 결제 이벤트를 시간순으로 추적할 수 있는 시스템을 구축할 필요가 있었다.
또한, 단순 이력 추적을 넘어 시스템 투명성 확보가 중요하다고 판단했고, 외부 API 호출 시간, 혹은 특정 상태에 비정상적으로 머무는 결제 건수 등을 파악할 수 있는 실시간 모니터링 체계를 추가하고자 했다.

이번 기능 개발에 있어, 아래의 항목들을 충족시키는 것이 핵심 목표로 설정하였다.

  • 완전성: 모든 상태 전환이 누락 없이 기록
  • 추적 가능성: 단일 결제 건의 모든 상태 전환을 시간순으로 연결·조회 가능
  • 관심사 분리: 비즈니스 로직에 침투하지 않고 선언적으로 이력 추적 및 모니터링 기능 추가
  • 운영 투명성: 외부 API 호출 성능 / 비정상 상태(5회 이상 재시도) 등 운영 지표 실시간 모니터링 기능 제공

두 가지 핵심 목표(이력 추적, 헬스 모니터링)를 달성하기 위해 다양한 기술적 대안을 검토했다.

결제 이력 추적(AOP + Spring Event 기반 설계)

Section titled “결제 이력 추적(AOP + Spring Event 기반 설계)”

결제 이력 추적 기능 구현을 위해 AOP + Spring Event을 사용하고, 커밋 직전(BEFORE_COMMIT)에 이력 저장을 수행하는 방안을 채택했다.

  • AOP (Aspect-Oriented Programming)

    • 선정 이유: 상태 변경 기록은 전형적인 횡단 관심사 -> 서비스 코드 변경 최소화 가능
    • 장점: 어노테이션 기반 선언적 프로그래밍으로 적용 누락 최소화
      • Repository 직접 호출 대비 결합도 낮음
  • Spring ApplicationEvent

    • 선정 이유: 프레임워크 내장 이벤트 시스템 활용 -> 외부 라이브러리 불필요
    • 장점: 이벤트 발행(서비스)과 처리(저장) 분리 -> 결합도 낮춤
      • 메시지 큐 대비 인프라 비용 추가 없이 구현 가능
  • TransactionPhase.BEFORE_COMMIT

    • 선정 이유: 커밋 직전에 실행하여 비즈니스 상태와 이력 간 정합성 확보
    • 장점: 로직이 성공한 경우에만 기록
      • AFTER_COMMIT은 커밋 후 저장 실패하더라도, 비즈니스 로직이 이미 커밋된 상태가 되어 불일치 발생하는 문제 발생
      • 히스토리 테이블의 변경 이력 == 실제 비즈니스 상태 변경을 보장
  • AOP
    • 선정 이유: 기존 이력 추적 AOP와 동일한 메커니즘 활용 가능
    • 장점: 메서드 실행 시간(API 호출 시간) 측정 및 결제 상태 변경 감지 용이
  • Scheduler DB 스캔
    • 선정 이유: 5회 이상 재시도 상태 등 장기 체류 결제 건 탐지 용이
    • 장점: 주기적으로 스캔하고, 이를 메트릭으로 노출하여 Grafana 대시보드에서 실시간 모니터링 가능

구현 세부 사항 1 - 결제 이력 추적

Section titled “구현 세부 사항 1 - 결제 이력 추적”
Service Method (@PublishPaymentHistory)
AOP Aspect
ApplicationEvent 발행
@TransactionalEventListener (BEFORE_COMMIT)
PaymentHistory 저장
Payment 상태 변경

비즈니스 서비스 메서드에 @PublishPaymentHistory 어노테이션을 선언하여, 해당 메서드 실행 시 자동으로 상태 변경 이력을 발행하도록 설정했다.

@Transactional
@PublishPaymentHistory(action = "changed")
public PaymentEvent markPaymentAsFail(
PaymentEvent paymentEvent,
@Reason String failureReason // 이력에 기록할 사유
) {
// 순수한 비즈니스 로직 수행
paymentEvent.fail(failureReason);
return paymentEventRepository.saveOrUpdate(paymentEvent);
}
  • @PublishPaymentHistory 어노테이션으로 이력 추적 대상 메서드 지정 및 action 속성으로 상태 변경 유형 전달
  • @Reason 어노테이션으로 이력 기록에 포함할 변경 사유 전달

AOP Aspect에서는 메서드 실행 전의 상태를 저장하고, 변경 이력을 담은 이벤트를 발행한다.

@Around("@annotation(publishHistory)")
public Object publishHistoryEvent(ProceedingJoinPoint joinPoint, PublishPaymentHistory publishHistory)
throws Throwable {
// 1. 실행 전 상태 캡처
Optional<PaymentEvent> beforeEventOpt = findPaymentEvent(joinPoint.getArgs());
PaymentEventStatus beforeStatus = beforeEventOpt.map(PaymentEvent::getStatus).orElse(null);
// 2. 이력에 기록할 사유 추출
String reason = findReasonParameter(joinPoint);
try {
// 3. 비즈니스 로직 실행
Object result = joinPoint.proceed();
// 4. 실행 결과 기반으로 상태 변경 이벤트 발행
processResultAndPublishEvent(beforeStatus, result, reason, publishHistory);
return result;
} catch (Exception e) {
log.error("Error occurred while processing payment: {}", e.getMessage(), e);
throw e;
}
}
  • findPaymentEvent 메서드로 현재 상태를 조회하여 이전 상태 저장
  • findReasonParameter로 이력에 포함할 변경 사유 추출
  • 비즈니스 로직 실행 후 action에 따라 적절한 이벤트 발행

3. 이벤트 리스너 - BEFORE_COMMIT 전략

Section titled “3. 이벤트 리스너 - BEFORE_COMMIT 전략”

Spring의 @TransactionalEventListener를 이용해, 트랜잭션 커밋 직전인 TransactionPhase.BEFORE_COMMIT 단계에서 이력 저장 로직을 실행한다.

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handlePaymentHistoryEvent(PaymentHistoryEvent event) {
paymentHistoryService.recordPaymentHistory(event);
}
  • BEFORE_COMMIT 단계는 트랜잭션이 성공적으로 커밋되기 직전에 실행되어, 비즈니스 상태 변경과 이력 기록이 동일 트랜잭션 내에서 처리되도록 보장
  • 상태 변경과 이력 저장 간 불일치 문제를 방지하며, 데이터 정합성과 원자성을 확보

외부 PG사인 Toss API 호출 시, AOP를 활용하여 호출 시간 및 성공/실패 여부를 메트릭으로 기록하도록 구현했다.

@Around("@annotation(tossApiMetric)")
public Object recordTossApiMetric(
ProceedingJoinPoint joinPoint,
TossApiMetric tossApiMetric
) throws Throwable {
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
// 시간 측정
long duration = System.currentTimeMillis() - startTime;
// 성공 메트릭 기록
handleSuccess(joinPoint, tossApiMetric, duration);
return result;
} catch (Exception e) {
// ...
}
}
private void handleSuccess(ProceedingJoinPoint joinPoint, TossApiMetric tossApiMetric, long duration) {
switch (tossApiMetric.value()) {
case SUCCESS:
// 측정된 시간과 함께 메트릭 기록
tossApiMetrics.recordTossApiCall(tossApiMetric.operation(), duration, true);
break;
// ...
}
}

2. 스케줄러 기반 이상 징후 감지 (Gauge)

Section titled “2. 스케줄러 기반 이상 징후 감지 (Gauge)”

상태가 오랫동안 변경되지 않은 결제 건수, 알 수 없는 상태 건수, 최대 재시도 횟수에 도달한 건수 등은 주기적으로 DB에서 조회하여 Gauge 메트릭으로 기록하는 스케줄러를 구현했다.

@Scheduled(fixedDelayString = "${metrics.payment.health.polling-interval-seconds:10}000")
public void updateHealthGauges() {
LocalDateTime now = localDateTimeProvider.now();
// Stuck in progress
LocalDateTime stuckThreshold = now.minusMinutes(stuckInProgressMinutes);
long stuckInProgress = paymentEventRepository
.countByStatusAndExecutedAtBefore(PaymentEventStatus.IN_PROGRESS, stuckThreshold);
healthGauges.get("stuck_in_progress").set(stuckInProgress);
// Unknown status
long unknownStatus = paymentEventRepository.countByStatus()
.getOrDefault(PaymentEventStatus.UNKNOWN, 0L);
healthGauges.get("unknown_status").set(unknownStatus);
// Max retry reached
long maxRetryReached = paymentEventRepository
.countByRetryCountGreaterThanEqual(maxRetryCount);
healthGauges.get("max_retry_reached").set(maxRetryReached);
log.debug("Health gauges updated - stuckInProgress={}, unknownStatus={}, maxRetryReached={}",
stuckInProgress, unknownStatus, maxRetryReached);
}

헬스 체크를 위한 주기적인 DB 스캔을 사용하여 간단하게 구현할 수 있었으나, 운영 환경에서 다음과 같은 잠재적 위험이 존재한다.

  • 트래픽이 많은 운영 환경에서 잦은 SELECT는 DB 부하 발생
  • 헬스 체크 쿼리가 복잡해지거나 인덱스를 제대로 활용하지 못할 경우, 메인 비즈니스 로직의 트랜잭션 성능까지 영향 받을 수 있음

때문에 시스템 확장 시에는 다음과 같은 해결 방안을 고려해볼 수 있을 것으로 보인다.

  1. 읽기 전용 복제(Read Replica) DB 활용: 모니터링 및 헬스 체크 쿼리를 읽기 전용 DB(Replica)로 분리하여, 다른 비즈니스 트랜잭션에 미치는 영향 최소화
  2. 쿼리 최적화: 복합 인덱스를 적용하여 Full Table Scan 방지
  3. 이벤트 기반 헬스 체크: DB 스캔 대신, 지연 큐(e.g., Redis, Kafka Streams)를 활용해 ‘5분 뒤 헬스 체크’ 이벤트를 발행하는 방식으로 DB 의존성 제거
graph TD
subgraph sg1 [API Request Flow]
A[API 요청 수신] --> B{Trace ID 할당};
end
subgraph sg2 [비즈니스 로직]
B --> C[결제 상태 변경 등<br/>비즈니스 로직 수행];
end
subgraph sg3 [AOP: 이력 저장 관심사 분리]
C -- TransactionalEventListener --> D[PaymentStateChanged 이벤트 발생];
D --> E{결제 상태 변경 이력 생성};
E --> F[DB 트랜잭션 커밋 직전 이력 저장];
end
subgraph sg4 [AOP: 실시간 성능 측정 Timer]
C -- AOP Pointcut --> M[외부 API 호출<br/>e.g. TossPayments 시간 측정];
M --> N[수집];
end
subgraph sg5 [구조화된 로그]
C --> L2[Trace ID + Order ID 기반<br/>로그 기록];
end
subgraph sg6 [데이터 저장 DB]
F --> G[(DB: PaymentEvent,<br/>PaymentHistory)];
end
subgraph sg7 [헬스 모니터링 Scheduled Flow]
H[Scheduled] -- 주기적 DB 스캔 --> G;
H --> I{이상 징후 감지<br/>e.g., 5번 이상 시도한 결제};
I --> J[수집];
end

이번 작업을 통해 AOP, Spring Event, 스케줄러를 조합하여 추적 가능하고 투명한 결제 시스템의 기반을 구축하여 다음과 같은 성과를 달성할 수 있었다.

  • 복잡한 결제 상태 변경의 체계적 관리: 다양한 결제 상태와 재시도 로직이 혼재하는 환경에서도 모든 상태 변경 내역을 누락 없이 기록하여 장애 분석 및 추적 가능
  • 비즈니스 로직의 순수성 및 유지보수성 향상: 이력 추적 기능이 AOP를 통한 선언적 방식으로 분리되어, 기존 비즈니스 서비스 코드에 침투하지 않고 기능 추가
  • 실시간 운영 투명성 확보: Toss API 호출 시간을 실시간 측정하고, 스케줄러로 핵심 결제 상태 지표를 추가하여, 시스템의 현재 상태를 실시간으로 모니터링 가능

Payment Custom Metric

Payment Event 히스토리 리스트 조회

Payment Event 상세 조회

System.out.println()의 동작 원리와 성능 이슈

PrintStream 클래스는 OutputStream을 상속받아 출력 스트림을 구현하며, 다양한 타입의 데이터를 출력할 수 있는 메서드를 제공한다.

public class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}

System.outPrintStream 타입의 static 객체이며, println()을 포함한 다양한 출력 메서드를 제공하여 간편하게 콘솔에 데이터를 출력할 수 있다.

PrintStream을 import하거나 인스턴스를 생성 없이 System.out.println()을 바로 사용할 수 있는 데, 그 이유는 다음과 같다.

  • System 클래스는 java.lang 패키지에 포함되어 있으며, 기본적으로 import되는 패키지이기 때문에 별도 import 없이 사용 가능
  • System 클래스 내부에 PrintStream 타입의 static 필드 out이 정의되어 있음
public final class System {
/* Register the natives via the static initializer.
*
* The VM will invoke the initPhase1 method to complete the initialization
* of this class separate from <clinit>.
*/
private static native void registerNatives();
static {
registerNatives();
}
private System() {
}
public static final PrintStream out = null;
// ...
}

out은 처음에는 null로 선언되어 있지만, JVM이 초기화 과정에서 실제 PrintStream 객체로 할당하게 된다.

  1. System.out은 Java 코드 상으로는 null로 선언되어 있으나, 이는 컴파일 시점 값
  2. registerNatives()라는 native 메서드가 System 클래스 초기화 블록에서 호출
  3. JVM의 initPhase1()에서 입출력 스트림을 직접 설정

이 작업은 Java 코드가 아닌 JVM 내부 native 코드에서 수행되며, 실제로는 메모리 상의 System.out 필드에 객체가 강제로 할당된다.

System.out.println() 내부 구현 코드 분석

Section titled “System.out.println() 내부 구현 코드 분석”

일반적으로 사용하는 System.out.println() 호출은 PrintStream 클래스 구현을 그대로 사용하는 경우가 대부분이며, 이 경우 다음과 같은 흐름으로 동작한다.

public void println(Object x) {
String s = String.valueOf(x);
if (getClass() == PrintStream.class) {
// need to apply String.valueOf again since first invocation
// might return null
writeln(String.valueOf(s));
} else {
// 하위 클래스 확장 시의 예외적 경로
synchronized (this) {
print(s);
newLine();
}
}
}
  • System.out은 JVM이 직접 생성한 PrintStream 인스턴스이므로, 일반적으로는 writeln() 경로로 실행
  • writeln()은 내부적으로 출력 버퍼에 문자열을 쓰고, 줄바꿈 후 flush까지 수행하는 방식으로 구현

writeln(String s)의 상세 구현을 살펴보면 다음과 같다.

private void writeln(String s) {
try {
synchronized (this) {
ensureOpen();
textOut.write(s);
textOut.newLine();
textOut.flushBuffer();
charOut.flushBuffer();
if (autoFlush)
out.flush();
}
} catch (InterruptedIOException x) {
Thread.currentThread().interrupt();
} catch (IOException x) {
trouble = true;
}
}
  1. ensureOpen()
    • 스트림이 닫히지 않았는지 확인
    • 닫힌 경우 IOException 발생시켜 출력 중단
  2. textOut.write(s) / textOut.newLine()
    • 문자열 및 줄바꿈 문자를 내부 문자 버퍼(StreamEncoder)에 쓰기 수행
    • 개행은 플랫폼에 맞는 \n, \r\n 등 줄바꿈 문자로 추가
    • 실제 출력은 하지 않고, 버퍼에만 저장
  3. textOut.flushBuffer()
    • 문자 버퍼에 저장된 내용을 지정된 Charset으로 인코딩하여 바이트 배열로 변환
    • 인코딩된 바이트 배열 데이터를 StreamEncoder 내부의 OutputStream에 저장
  4. charOut.flushBuffer()
    • StreamEncoder 내부 OutputStream에 저장된 바이트 데이터를 실제 출력 스트림으로 전달
    • 최종적으로 native 메서드를 호출하게 되며, 실제 OS 단에서 출력이 이루어짐
  5. if (autoFlush) out.flush()
    • 기본 true로 설정되어 있어 자동으로 flush() 수행
    • 일반적으로 위 과정으로 이미 flush된 상태이기 때문에 추가 동작은 없음
    • 명시적으로 flush 호출을 통해 출력 스트림을 강제로 비우는 역할

모든 과정이 synchronized 블록 안에서 수행되기 때문에, 여러 쓰레드가 System.out.println()을 호출해도 출력 순서를 보장한다.
하지만 내부적으로 동기화와 IO 작업을 수반하기 때문에 성능 저하를 유발할 수 있다.

  • println() 호출 시, 내부적으로 write()flush()가 함께 수행
  • 출력 스트림은 기본적으로 블로킹 IO이기 때문에, 호출 시점마다 시스템 콜을 발생시키고 쓰레드는 출력 완료까지 대기
  • 특히 반복문 내에서 출력이 빈번하게 발생하는 경우, 다음과 같은 문제 발생
    • 출력 버퍼가 자주 flush되어 성능 저하
    • synchronized/lock 경쟁으로 인한 쓰레드 병목 현상 발생
    • 콘솔 IO 속도는 CPU 연산보다 훨씬 느림

Logger 성능 저하 방지와 구조화된 로깅 설계

로그를 단순히 println()으로 찍는 건 성능 이슈와 레벨을 구분할 수 없기 때문에, 개발 초기 테스트 외엔 적절하지 않다.
로그는 문제를 추적하고 흐름을 분석하기 위해 많은 양의 데이터를 남기기 때문에, 구조화된 출력과 불필요한 연산 제어가 반드시 필요하다.

  • 로그 레벨 구분이 없음 (debug, info, warn 등 불가능)
  • 시간, 클래스, 스레드 정보가 출력되지 않음
  • 로그 파일 관리 및 저장 불가
  • 멀티스레드 환경에서 병목 발생 (System.out은 synchronized I/O)

로그를 남길 때는 "Hello" + "World"와 같은 문자열 결합 방식이 아닌, "Hello {}"와 같은 포맷팅 방식을 권장하는데, 이는 성능에 영향을 미치기 때문이다.

private static void concatLog(Order order) {
logger.trace("OrderId=" + order);
}

이 방식은 로그 레벨이 trace가 아니라면 출력도 안 되지만, order.toString() 호출과 문자열 결합은 그대로 발생하여, 출력은 안 되지만 연산은 수행되는 비효율이 생긴다.
이를 피하기 위해 두 가지 방식을 사용할 수 있다.

  1. 로그 레벨을 직접 체크하는 방식
  2. 파라미터 포맷팅 방식 ({} 사용)
// 1. 레벨을 직접 확인하는 방식
private static void checkConditionLog(Order order) {
if (logger.isTraceEnabled()) {
logger.trace("OrderId= " + order);
}
}
// 2. 파라미터 포맷팅 방식
private static void paramLog(Order order) {
logger.trace("OrderId={}", order);
}

1번 방식은 조건을 직접 해야하는 조건문을 작성해야 하므로 코드가 복잡해지고 가독성이 떨어져, 보통 2번 방식인 파라미터 포맷팅 방식을 사용한다.

로그를 남기는 방식에 따라 성능 차이가 얼마나 나는지 테스트를 진행해보기 위해, 다음과 같이 멀티스레드 환경에서 로그를 남기는 테스트를 작성했다.(info 레벨 기준)

class LoggerTest extends IntegrationTest {
private static final Logger logger = LoggerFactory.getLogger(LoggerTest.class);
private static final int THREAD_COUNT = 10;
private static final int TASK_COUNT = 100000;
private void runTest(ExecutorService executor, String label, Runnable task) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
long start = System.currentTimeMillis();
for (int i = 0; i < THREAD_COUNT; i++) {
executor.submit(() -> {
for (int j = 0; j < TASK_COUNT; j++) {
task.run();
}
latch.countDown();
});
}
latch.await();
long end = System.currentTimeMillis();
System.out.println(label + ": " + (end - start) + "ms");
}
static class TestData {
private final List<String> data;
public TestData(int size) {
this.data = new ArrayList<>();
for (int i = 0; i < size; i++) {
data.add(UUID.randomUUID().toString());
}
}
public String getTestDataString() {
return data.stream().sorted().collect(Collectors.joining(","));
}
@Override
public String toString() {
return this.getTestDataString();
}
}
}

방식은 3가지로 나누어 테스트를 진행했다.

  1. 문자열 결합 방식 (+ 사용)
  2. String.format() 방식
  3. 파라미터 포맷팅 방식 ({} 사용)
class LoggerTest extends IntegrationTest {
private static void logWithConcat(TestData testData) {
logger.trace("[TEST] | TEST | testData=" + testData);
}
private static void logWithFormat(TestData testData) {
logger.trace(String.format("[TEST] | TEST | testData=%s", testData));
}
private static void logWithParam(TestData testData) {
logger.trace("[TEST] | TEST | orderId={}", testData);
}
@Test
void testLogSpeed() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
TestData testData = new TestData(100);
// 948ms
runTest(executor, "String concatenation (+)", () -> logWithConcat(testData));
// 886ms
runTest(executor, "String.format()", () -> logWithFormat(testData));
// 19ms
runTest(executor, "Parameterized logging ({})", () -> logWithParam(testData));
executor.shutdown();
}
}
방식소요 시간
문자열 결합 (+)948ms
String.format() 사용886ms
파라미터 포맷팅 ({})19ms
  • +, String.format() 연산자 방식은 로그 레벨에 관계없이 toString()이 호출되고 문자열 결합이 발생
  • 파라미터 포맷팅({}) 방식은 로그 레벨이 활성화된 경우에만 포맷팅이 수행되어, toString() 호출이 발생하지 않음

결과적으로 파라미터 포맷팅 방식이 가장 빠르며, 문자열 결합/포맷은 로그 레벨이 활성화되어 있지 않아도 toString() 호출과 문자열 결합이 발생하여 성능이 저하되는 것을 확인할 수 있었다.

{} 포맷팅을 사용하면 문자열 결합 비용은 줄일 수 있지만, 메서드 호출 자체는 여전히 발생한다는 점에서 성능 이슈는 완전히 사라지지 않는다.

private static String getExpensiveDetail(Order order) {
logger.trace("OrderId={}, detail={}", order.getId(), order.getExpensiveDetail());
}

위 코드처럼 로그 레벨과 무관하게 getExpensiveDetail()이 호출된다면, 불필요한 연산이 수행되어 성능 저하가 발생할 수 있다.

private static void logWithSupplier(Order order) {
logger.trace("OrderId={}, detail={}", () -> order.getId(), () -> order.getExpensiveDetail());
}

Log4j2는 이를 해결하기 위해 Supplier 기반 지연 평가를 지원하지만, Slf4j는 해당 기능을 제공하지 않기 때문에 isTraceEnabled() 같은 조건문을 명시적으로 사용하여 방지할 수 있다.

메서드 호출 성능 테스트(SlF4J 환경)

Section titled “메서드 호출 성능 테스트(SlF4J 환경)”

위와 동일한 테스트 환경에서 메서드 호출을 포함한 로그 성능을 테스트해보았다.

class LoggerTest extends IntegrationTest {
private static void logConcatWithMethodCall(TestData testData) {
logger.trace("[TEST] | TEST | testData=" + testData.getTestDataString());
}
private static void logFormatWithMethodCall(TestData testData) {
logger.trace(String.format("[TEST] | TEST | testData=%s", testData.getTestDataString()));
}
private static void logParamWithMethodCall(TestData testData) {
logger.trace("[TEST] | TEST | orderId={}", testData.getTestDataString());
}
private static void logParamWithCheck(TestData testData) {
if (logger.isTraceEnabled()) {
logger.trace("[TEST] | TEST | testData={}", testData.getTestDataString());
}
}
@Test
void testLogSpeedWithMethodCall() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
TestData testData = new TestData(100);
// 1042ms
runTest(executor, "String concat + method call",
() -> logConcatWithMethodCall(testData));
// 1092ms
runTest(executor, "String.format + method call",
() -> logFormatWithMethodCall(testData));
// 947ms
runTest(executor, "Param-style logging + method call",
() -> logParamWithMethodCall(testData));
// 14ms
runTest(executor, "Level check logging + method call",
() -> logParamWithCheck(testData));
executor.shutdown();
}
}
방식소요 시간
문자열 결합 + 메서드 호출1042ms
String.format + 메서드 호출1092ms
파라미터 포맷팅 + 메서드 호출947ms
로그 레벨 체크 후 포맷팅 + 메서드 호출14ms
  • 파라미터 포맷팅 방식은 로그 레벨과 관계없이 메서드 호출이 발생하여 성능 저하가 발생
  • isTraceEnabled() 조건을 사용한 경우, 로그 레벨이 비활성화되어 있으면 해당 연산 자체가 수행되지 않아 성능 저하가 거의 없음

실전 대응: 구조화된 포맷 + 연산 최소화

Section titled “실전 대응: 구조화된 포맷 + 연산 최소화”

로그 포맷을 강제하고 싶었고, 동시에 로그 레벨이 꺼져 있을 때 불필요한 연산이 발생하지 않도록 막고 싶어, 직접 LogFmt 유틸 클래스를 만들어 사용했다.

  • 로그의 도메인과 이벤트명을 명확하게 구분
  • 로그 메시지는 Supplier<String>으로 감싸, 로그 레벨이 비활성화되었을 땐 메시지 연산 자체가 발생하지 않도록 처리
  • Slf4j 환경이므로 isInfoEnabled() 등의 레벨 체크 사용
public void example() {
LogFmt.info(
logger,
LogDomain.PAYMENT,
EventType.PAYMENT_STATUS_TO_IN_PROGRESS,
() -> String.format("orderId=%s", orderId)
);
}
2025-08-03 12:56:56.129 ... - [3ce5fab6] [PAYMENT] | PAYMENT_STATUS_TO_IN_PROGRESS | orderId=55996af6-e5b5-47e5-ac3c-44508ee6fd6b
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LogFmt {
private static final String INFO_LOG_FORMAT = "[{}] | {} | {}";
private static final String INFO_LOG_FORMAT_NO_MESSAGE = "[{}] | {}";
// ...
public static void info(Logger logger, LogDomain logDomain, EventType event) {
if (logger.isInfoEnabled()) {
logger.info(INFO_LOG_FORMAT_NO_MESSAGE, logDomain.name(), event.name());
}
}
public static void info(Logger logger, LogDomain logDomain, EventType event, Supplier<String> messageSupplier) {
if (logger.isInfoEnabled()) {
logger.info(INFO_LOG_FORMAT, logDomain.name(), event.name(), messageSupplier.get());
}
}
// ...
}

이렇게 구성된 로깅 유틸은 성능을 해치지 않으면서도 흐름을 추적할 수 있는 구조화된 로그 설계로 이어졌다.
불필요한 연산을 막고, 도메인과 이벤트 타입을 명확히 하여 로그를 쉽게 파악할 수 있게 되었다.

로그

결제 상태 전환 관리와 재시도 로직을 통한 결제 복구 시스템 구축

실행 환경: Java 21, Spring Boot 3.3.3

최초 구상했던 결제 로직은 결제 정보 검증을 통해 안전한 결제 연동 시스템을 목표로 하였지만, 예상치 못한 에러에 대한 처리 로직이 미흡했다.
그로 인해 특수한 상황에서 사용자가 신뢰성을 느끼기 어려울 것으로 판단되어 결제 시스템에 대한 설계 및 구현을 다시 진행하게 되었다.

문제를 해결하기 위한 방법을 찾던 중 가상 면접 사례로 배우는 대규모 시스템 설계 기초 중 결제 시스템 파트를 통해 결제 시스템에 대한 도메인 지식과 안전한 결제 처리를 위한 해결 방법을 찾을 수 있었고, 이를 바탕으로 더 나은 결제 시스템을 설계하고자 했다.

책에서는 결제 서비스/결제 실행 서비스/원장 서비스/지갑 서비스로 분리된 구조를 제시했는데, 현 프로젝트에서는 결제 서비스와 결제 실행 서비스를 하나로 통합하여 구현하였다.
또한, 이번 개선 작업에서는 결제 서비스와 그 안에서 발생하는 문제들에 대해서 다뤘으며, 원장 서비스와 지갑 서비스에 대한 부분과 시스템에 주는 이점은 다음 단계에서 다루고자 한다.

기존 결제 시스템 구현에서 발생할 수 있던 문제점은 다음과 같았다.

  1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재
    • 기존 코드에선 모든 에러에 대해 결제 실패로 처리하고 있었음
    • 그 중 재시도를 통해 해결될 수 있는 에러도 실패로 간주
  2. API 지연으로 인한 결제 처리 오류
    • 토스 측에 결제 승인 요청을 보낸 후 응답이 지연되어 타임아웃 발생 가능성 존재
    • 서버 측에서는 지연으로 인해 실패 처리되었으나, 실제로는 토스 측에서 승인 완료하여 금액만 빠져나갈 수 있음
  3. 결제 승인 요청 중 서버 중단
    • 결제 승인 요청이 토스 결제 서비스까지 전달되어 실제 결제가 완료되었으나, 서버가 중단되면서 결과를 반환하지 못함
    • 이로 인해 결제는 이미 이루어져 금액이 빠져나갔음에도 불구하고, 시스템에서는 결제가 완료되지 않은 상태로 남아있을 수 있음

이러한 문제들을 해결하기 위해 크게 아래 네 가지 방법들을 도입하였다.

  1. 재시도 가능/불가능 에러 구분
  2. 재시도 로직 구현
  3. 결제 상태 전환 관리
  4. 멱등키 사용

1. 재시도 가능/불가능 에러 구분

Section titled “1. 재시도 가능/불가능 에러 구분”

기존 코드에서는 어떤 에러가 발생하든지 동일하게 실패 처리했지만, 에러를 크게 두 가지 유형으로 구분하였다.

  • 재시도 가능한 에러: 일시적인 네트워크 문제 / 외부 서비스 장애 / API 타임아웃과 같이 일정 시간 후 재 시도를 통해 해결될 수도 있는 에러
  • 재시도 불가능한 에러: 잘못된 요청, 상품 재고 부족 등으로 인해 재시도가 불가능한 근본적인 문제

위에서 구분 된 재시도 가능한 에러에 대해, 일정 횟수까지만큼 재시도 처리하는 스케줄러를 구현하여 결제 성공으로 이어질 수 있도록 하였다.

  • 재시도 가능한 특정 결제 상태인 결제 요청에 대해 결제 재시도 요청
  • 재시도가 너무 많아져 시스템에 부하가 걸리는 것을 방지하기 위해 재시도 횟수 제한

기존 프로젝트에서는 시작 -> 진행 -> 완료/실패 -> 결제 취소로 순차적으로만 관리되었으나, 재시도 처리를 위한 디테일한 상태 관리가 필요했다.

결제 상태 다이어그램

  • READY: checkout 으로 인해 최초 생성된 결제 상태
  • IN_PROGRESS: 결제 승인 요청 처리 시작한 상태
  • DONE: 결제가 성공적으로 완료된 상태
  • FAILED: 결제 실패한 상태
  • CANCEL: 결제가 취소된 상태
  • UNKNOWN: 에러가 발생하였으나, 재시도를 통해 해결 가능성이 있는 상태

개선된 상태 다이어그램에서는 UNKNOWN 상태를 도입하여, 모든 에러를 바로 실패 처리하지 않고 재시도를 통해 결제 성공으로 이어질 수 있도록 하였다.

이미 성공한 결제일 수 있지만, 알 수 없는 상태에 대한 결제 재시도로 인해 동일한 결제가 여러 번 이루어질 수 있다.
특히 재시도 로직이 도입되는 만큼, 멱등키를 이용하여 중복 결제를 방지하여 정확히 한 번만 결제 금액이 빠져나가도록 해야 했다.

  • 멱등키 생성: 각 결제 요청 시 고유한 멱등키를 DB에 저장하고, 같은 결제에 대해 다시 요청하면 해당 키로 중복 결제 방지
  • 재시도 시 동일 결과 보장: 이미 토스 측에 성공한 결제더라도, 같은 키를 통해 중복 결제 방지

결과적으로, 이러한 네 가지 해결 방안을 통해 기존 프로젝트에서 발생하던 문제들을 다음과 같이 해결할 수 있었다.

1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재

Section titled “1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재”

모든 에러를 동일하게 처리하여 결제가 실패로 처리하던 부분을 재시도 가능한 에러와 불가능한 에러로 명확하게 구분하여 각각에 맞는 처리를 적용할 수 있게 되었다.

  • 재시도 가능 에러: 재시도 로직을 통해 성공으로 처리
  • 재시도 불가능한 에러: 근본적인 문제로 인한 에러는 즉시 결제 실패로 처리하여, 사용자에게 정확한 정보 전달

API 응답 지연 시에는 결제를 UNKNOWN 상태로 변경하여, 재시도를 통해 최종적으로 결제 성공으로 이어질 수 있도록 처리할 수 있게 되었다.

  • 응답 지연 시 재시도 처리: 토스 측 응답 지연으로 결제가 실패 처리되는 경우, 즉시 실패로 간주하지 않고 재시도할 수 있도록 함
  • 중복 결제 방지: 이미 성공했으나 토스측에 결과만 못 받은 경우에도, 멱등키를 사용하여 중복해서 결제되지 않도록 함

3. 결제 승인 요청 중 서버가 중단되는 경우

Section titled “3. 결제 승인 요청 중 서버가 중단되는 경우”

서버 중단으로 인해 결제가 제대로 처리되지 못하거나 중복 결제가 발생할 수 있었으나, 재시도를 통해 올바른 결제 처리를 보장할 수 있게 되었다.

  • 서버 중단 시 복구: IN_PROGRESS 상태로 남아있는 결제들에 대해 결제 요청을 다시 시도
  • 중복 결제 방지: (토스 측에 요청을 보냈으나 반환되기 전에 서버가 중단 된 경우)성공한 결제에 대해 중복 결제 방지를 위해 멱등키 사용

앞서 설명한 문제들을 해결하기 위해 도입한 네 가지 개선 방안에 대한 실제 구체적인 구현 방법과 코드를 살펴보자.

1. 재시도 가능/불가능 에러 구분 + 3. 결제 상태 전환 관리

Section titled “1. 재시도 가능/불가능 에러 구분 + 3. 결제 상태 전환 관리”

결제 승인 과정에서 발생하는 다양한 에러에 따라 시스템은 다음과 같이 처리하도록 구현하였다.

결제 승인 처리 흐름과 에러에 따른 상태 처리

이러한 에러 상태를 구분하기 위해, 먼저 토스 측에서 발생하는 에러 코드를 명확히 정의할 필요가 있었다.

  • 토스 측에서 발생하는 모든 에러 코드에 대해 Enum으로 정의
  • 해당 에러 코드에 대해 성공 / 재시도 가능 여부 판단 메서드 구현

그 외에 추가적으로 타임아웃 관련 에러 코드를 추가해 재시도 가능한 에러로 처리하였다.

@Getter
@RequiredArgsConstructor
public enum TossPaymentErrorCode {
ALREADY_PROCESSED_PAYMENT(400, "이미 처리된 결제 입니다."),
// ...
UNKNOWN_PAYMENT_ERROR(500, "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요."),
UNKNOWN(500, "알 수 없는 에러입니다."),
NETWORK_ERROR(500, "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); // 타임아웃 처리를 위한 별도로 추가한 에러
//...
public boolean isSuccess() {
return this == ALREADY_PROCESSED_PAYMENT;
}
public boolean isRetryableError() {
return switch (this) {
case PROVIDER_ERROR, FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING,
FAILED_INTERNAL_SYSTEM_PROCESSING, UNKNOWN_PAYMENT_ERROR,
UNKNOWN, NETWORK_ERROR -> true;
default -> false;
};
}
public boolean isFailure() {
return !isSuccess() && !isRetryableError();
}
}

위 클래스를 통해 토스 결제 결과가 성공인지, 재시도 가능한 에러인지, 실패인지를 판단할 수 있도록 구현하였고, 호출한 곳에서 해당 결과에 따라 성공 객체 혹은 Checked Exception을 던지도록 하였다.

@Service
@RequiredArgsConstructor
public class PaymentProcessorUseCase {
// ...
public TossPaymentInfo confirmPaymentWithGateway(PaymentConfirmCommand paymentConfirmCommand)
throws PaymentTossRetryableException, PaymentTossNonRetryableException {
// ... 토스 측에 결제 승인 요청 및 반환
PaymentConfirmResultStatus paymentConfirmResultStatus = tossPaymentInfo.getPaymentConfirmResultStatus();
// 상태에 따라 처리
return switch (paymentConfirmResultStatus) {
// 1. 성공
case PaymentConfirmResultStatus.SUCCESS -> tossPaymentInfo;
// 2. 재시도 가능한 에러 - Checked Exception
case PaymentConfirmResultStatus.RETRYABLE_FAILURE ->
throw PaymentTossRetryableException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
// 3. 재시도 불가능한 에러 - Checked Exception
case PaymentConfirmResultStatus.NON_RETRYABLE_FAILURE ->
throw PaymentTossNonRetryableException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
};
}
// ...
}

호출한 메서드에서 해당 에러를 처리하도록 하였고(Checked Exception으로 이를 강제), 에러 핸들링은 다음과 같은 유형으로 나누어 처리하였다.

예외 상황재시도 가능결제 상태 처리재고 처리
1. 재고 차감 중 오류X실패 처리복구 불필요
2. 이미 처리된 결제에 대한 중복 요청X실패 처리재고 복구
3. 외부 결제사의 일시적인 오류O알 수 없음 상태로 변경재고 유지
4. 외부 결제사의 복구 불가능 오류X실패 처리재고 복구
5. 결제 검증 또는 기타 시스템 오류X실패 처리재고 복구
@Service
@RequiredArgsConstructor
public class PaymentConfirmServiceImpl implements PaymentConfirmService {
// ...
@Override
public PaymentConfirmResult confirm(PaymentConfirmCommand paymentConfirmCommand) {
// 1. 결제 이벤트 조회
PaymentEvent paymentEvent = paymentLoadUseCase.getPaymentEventByOrderId(
paymentConfirmCommand.getOrderId()
);
try {
// 2. 재고 차감 - 재고 부족 에러 발생 가능
orderedProductUseCase.decreaseStockForOrders(paymentEvent.getPaymentOrderList());
} catch (PaymentOrderedProductStockException e) { // 에러 1: 재고 부족 에러 발생
handleStockFailure(paymentEvent); // FAIL 처리(재고 중 일어난 에러로, 감소가 일어나지 않았으므로 복구 필요 없음)
throw PaymentTossConfirmException.of(PaymentErrorCode.ORDERED_PRODUCT_STOCK_NOT_ENOUGH);
}
try {
// 3. 결제 시작 처리 - 결제 시작 상태 검증 중 에러 발생 가능
paymentProcessorUseCase.executePayment(paymentEvent, paymentConfirmCommand.getPaymentKey());
// 4. 결제 검증 - 결제 검증 중 에러 발생 가능
// 5. 토스 결제 승인 요청 - 토스 측 재시도 가능/불가능한 에러 발생 가능
// 6. 결제 완료 처리
PaymentEvent completedPayment = processPayment(paymentEvent, paymentConfirmCommand);
// 7. 결제 완료 결과 반환
// ...
} catch (PaymentStatusException e) { // 에러 2: 이미 요청이 진행 된 건에 대한 요청
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(paymentEvent); // UNKNOWN 상태로 변경 + 재고 그대로 유지
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
} catch (PaymentTossNonRetryableException e) { // 에러 4: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
} catch (Exception e) { // 에러5: 결제 검증 중 에러 등 기타 에러 발생
handleUnknownException(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
}
}
// ...
}

위 구현 사항으로 처리 된 재시도 가능으로 구분 된 에러에 대해, 재시도 로직을 구현하여 두 가지 상태에 대해 결제 성공으로 이어질 수 있도록 구현하였다.

  1. UNKNOWN: 토스 측 재시도 가능 에러 혹은 타임아웃으로 인해 결제 결과를 받지 못한 상태
  2. IN_PROGRESS: 결제 승인 시작 처리는 했지만 아직 결과를 받지 못한 상태

이 두 가지 상태를 기반으로 재시도 가능한 결제를 주기적으로 조회하여 재시도 처리를 수행하여, 결제를 성공으로 마무리하거나, 실패로 처리하도록 구현하였다.

@Service
@RequiredArgsConstructor
public class PaymentRecoverServiceImpl implements PaymentRecoverService {
// 1. 재시도 가능한 결제 이벤트 조회 후 아래 로직 수행
private void processRetryablePaymentEvent(PaymentEvent retryablePaymentEvent) {
try {
// 2. 재시도 가능 여부 횟수 검증 - 재시도 불가능 에러 발생 가능
if (!retryablePaymentEvent.isRetryable(localDateTimeProvider.now())) {
throw PaymentRetryableValidateException.of(PaymentErrorCode.RETRYABLE_VALIDATION_ERROR);
}
// 3. 재시도 횟수 증가
paymentProcessorUseCase.increaseRetryCount(retryablePaymentEvent);
// ...
// 4. 토스 결제 승인 요청 재시도 후 완료 처리 - 토스 측 재시도 가능/불가능한 에러 발생 가능
TossPaymentInfo tossPaymentInfo = paymentProcessorUseCase.confirmPaymentWithGateway(
paymentConfirmCommand
);
paymentProcessorUseCase.markPaymentAsDone(
retryablePaymentEvent,
tossPaymentInfo.getPaymentDetails().getApprovedAt()
);
} catch (PaymentRetryableValidateException // 에러 1: 재시도 불가능한 상태 에러 발생
| PaymentTossNonRetryableException e) { // 에러 2: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(retryablePaymentEvent); // FAIL 처리 + 재고 복구
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(retryablePaymentEvent); // UNKNOWN 상태로 변경
}
}
// ...
}

재시도 검증 후, 기존 결제 승인 요청과 같이 토스 측에 재시도 요청을 보내도록 처리하였으며, 다음과 같이 구분하여 처리하였다.

결제 재시도 다이어그램

  • 성공: 재시도 성공 후 결제 완료 처리(이미 재고가 차감되어 있으므로, 재고 감소나 복구는 필요 없음)
  • 토스 측 재시도 불가능한 에러: 해당 결제에 대해 실패 처리 + 재고 복구
  • 토스 측 재시도 가능 에러: UNKNOWN 상태로 변경
  • 재시도 횟수 초과: 해당 결제에 대해 실패 처리 + 재고 복구

재시도 횟수 초과 에러로, 계속 해서 UNKNOWN 상태로 유지되더라도, 일정 횟수 이상 재시도를 했을 때 결제 실패로 처리하여 무한히 재시도하는 것을 방지하였다.

결제 처리 중 서버의 장애나 네트워크 문제로 동일한 결제 요청이 중복될 가능성이 존재하고, 해당 결제들에 대한 복구 로직이 실행되기 때문에 중복 결제를 방지하기 위해 멱등키를 도입하였다.

  • 이번 프로젝트에서는 결제 이벤트 생성 시 만들어지는 고유한 orderId를 멱등키로 사용
  • UUID를 사용하여 고유한 키임을 보장

이렇게 생성 된 멱등키는 토스 결제 서비스로 요청을 보낼 때 HTTP 헤더에 포함되어 전송하여, 동일한 결제 요청에도 중복 결제를 막고 성공적인 결제 처리를 보장할 수 있었다.

이번 결제 시스템 개선을 통해 기존 구현에서 발생했던 주요 문제점들을 해결할 수 있었다.

항목기존 시스템 문제점개선된 시스템 해결 방안
에러 처리모든 에러를 동일하게 결제 실패로 처리재시도 가능/불가능 에러로 구분하여 각각에 맞는 처리 도입
API 응답 지연응답 지연 시 결제 실패로 처리UNKNOWN 상태로 관리하고 재시도를 통해 최종적으로 성공 처리
결제 도중 서버 중단서버 중단으로 결제 완료 여부를 알 수 없고, 중복 결제 발생 가능성 존재멱등키를 사용하여 중복 결제 방지 및 재시도 로직을 통한 결제 완료 처리

이번 결제 시스템 개선을 통해 재시도 가능한 에러와 불가능한 에러를 구분하고, 멱등키를 통해 중복 결제를 방지하며, 재시도 로직을 도입하여 결제의 안정성과 유연성을 크게 향상시킬 수 있었다.
이를 통해 결제를 복구할 수 있는 시스템을 구현하였으며, 결제 처리의 신뢰성을 높일 수 있었다.

그러나 정확한 상태 변경 이력 추적과 원장 및 지갑 관리 시스템의 부재로 인해 결제 내역의 투명성 및 결제 상태 추적이 어려운 한계점이 존재한다.
이를 보완하기 위한 추가적인 개선이 이루어진다면, 결제 시스템의 완성도를 더욱 높일 수 있을 것으로 기대된다.

결제 상태가 변경될 때마다 상태 변경 이력을 추적하고, 상태 변경에 따른 추가적인 로그를 남기지 않아 추후 에러에 대한 추적이 어려울 수 있음

책에서 제시한 것처럼 원장/지갑 관리 기능이 포함되지 않아 결제 내역과 잔액 간의 정확한 일치 여부를 추적할 수 없음

외부 의존성 제어를 통한 결제 프로세스 다양한 시나리오 검증

실행 환경: Java 21, Spring Boot 3.3.3, JUnit 5

결제 시스템은 서비스 신뢰성과 직결되기 때문에 안정적이고 오류 없는 시스템을 구축하기 위해서는 다양한 시나리오를 검증하는 테스트 코드 작성이 필수적이다.
결제 승인 과정에서 발생할 수 있는 다양한 시나리오에 대응할 수 있고, 신뢰성 높은 결제 프로세스를 보장하기 위해 여러 테스트 전략을 적용해보았다.

크게 단위 테스트와 통합 테스트로 구분지어 테스트 코드를 작성했으며, 각 테스트 코드의 목표는 다음과 같이 설정하였다.

  • 단위 테스트: 개별 도메인 및 서비스 로직을 대상으로 세부적인 로직 검증
  • 통합 테스트: 실제 결제 흐름을 따르고 다양한 예외 상황에 대해 대응하는 테스트를 작성

특히 토스 결제 시스템의 의존성을 제어하는 것이 가장 주요한 과제였는데, 이를 위해 여러 전략을 활용하여 결제 시나리오를 테스트하는 방법을 적용했다.

** 참고: 토스 실제 결제 승인은 클라이언트 SDK를 통해 결제 요청을 먼저 수행해야 하며, 그 후에 결제 승인을 진행해야 한다.

위 두 가지를 기반으로 테스트를 통해 보장하고자 했던 주요 목표는 다음과 같다.

  1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보
  2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성
  3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증

각 목표를 달성하기 위해 취한 전략 및 작성한 테스트 코드의 예시는 다음과 같다.

1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보

Section titled “1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보”

결제 승인 과정은 다양한 예외 상황과 복잡한 절차적 흐름을 포함하고 있기 때문에, 이를 신뢰성 있게 처리하기 위해서는 다양한 시나리오를 고려한 테스트 코드 작성이 필수적이다.
단일 케이스를 넘어 여러 파라미터를 조합한 테스트를 통해 다양한 시나리오를 검증하고, 서비스 로직에서 발생 가능한 모든 에러에 대한 처리를 확인함으로써 신뢰성을 높일 수 있었다.

PaymentEvent 관련된 로직에서는 결제 승인 및 검증 과정에서 발생할 수 있는 다양한 시나리오를 파라미터화된 테스트로 작성하여, 단순 성공 케이스 뿐만 아니라, 예외 상황이나 여러 상태 값에 대한 다양한 케이스를 고려하여 테스트 코드를 작성했다.

class PaymentEventTest {
@ParameterizedTest
@CsvSource({
"1, validPaymentKey, 15000, order123, INVALID_TOTAL_AMOUNT",
"2, invalidPaymentKey, 15000, order123, INVALID_PAYMENT_KEY",
"1, validPaymentKey, 14000, wrongOrderId, INVALID_ORDER_ID"
})
@DisplayName("다양한 조건에서 검증 실패 시 PaymentValidException 예외가 발생한다.")
void validate_InvalidCases(
Long userId,
String paymentKey,
int amount,
String orderId
) {
PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy(
() -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo))
.isInstanceOf(PaymentValidException.class);
}
@ParameterizedTest
@EnumSource(value = TossPaymentStatus.class, names = {
"CANCELED", "EXPIRED", "PARTIAL_CANCELED", "ABORTED"
})
@DisplayName("결제 상태가 유효하지 않은 경우 PaymentStatusException 예외가 발생한다.")
void validate_InvalidPaymentStatus(TossPaymentStatus tossPaymentStatus) {
PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy(
() -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo))
.isInstanceOf(PaymentStatusException.class);
}
}

위 코드에서는 다양한 조건과 상황에 따라 결제 승인 검증 과정에서 발생할 수 있는 다양한 케이스를 다루며, 각 상황에 적합한 예외 처리를 확인했다.

또한, 서비스 로직에서 Toss API와의 통신 중 발생할 수 있는 모든 에러 코드를 검수하고, 이를 적절하게 처리할 수 있도록 테스트 할 수 있었다.

class PaymentGatewayServiceImplErrorCaseTest extends IntegrationTest {
// ...
@ParameterizedTest(name = "{index}: Test with TossPaymentErrorCode={0}")
@EnumSource(TossPaymentErrorCode.class)
@DisplayName("TossPaymentErrorCode에 따라 Header를 설정하고 결제 확인 결과를 검증한다.")
void confirmPayment_withTossPaymentErrorCode(TossPaymentErrorCode errorCode) {
// given
String uuid = new SystemUUIDProvider().generateUUID();
ReflectionTestUtils.invokeMethod(httpOperator, "addHeader", "TossPayments-Test-Code", errorCode.name());
// ...
// when
TossPaymentInfo tossPaymentInfo = paymentGatewayService.confirmPayment(tossConfirmCommand, uuid);
PaymentConfirmResultStatus paymentConfirmResultStatus = tossPaymentInfo.getPaymentConfirmResultStatus();
// then
Assertions.assertThat(paymentConfirmResultStatus)
.isEqualTo(getExpectedResultStatus(errorCode));
}
// ...
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new AdditionalHeaderHttpOperator();
}
}
}

실제 요청을 보내되, 헤더에 특정한 에러 코드를 추가하면 토스 측에서 에러를 반환해주는 테스트 API를 이용해 에러 상황을 시뮬레이션하고 검증했다.
이를 통해 결제 승인 과정에서 발생할 수 있는 모든 예외 상황에 대한 적절한 처리를 확인하고, 각 에러에 대해 재요청 가능/불가능 여부를 검증할 수 있었다.

2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성

Section titled “2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성”

토스 API의 결제 승인 요청은 클라이언트 SDK를 통해 요청을 먼저 수행해야 하며, 그 후에 결제 승인을 진행해야 하는 선행 작업을 만족해야하는 어려움이 있었다.
이를 해결하기 위해 페이크 객체를 활용하여 외부 의존성을 제거하여 테스트 환경을 구성했다.

public class FakeTossHttpOperator implements HttpOperator {
// ...
// Reflection을 통해 호출되는 메서드
@SuppressWarnings("unused")
public void setDelayRange(int minDelayMillis, int maxDelayMillis) {
this.minDelayMillis = minDelayMillis;
this.maxDelayMillis = maxDelayMillis;
}
@SuppressWarnings("unused")
public void addErrorInPostRequest(String code, String message) {
this.code = code;
this.message = message;
this.isErrorInPostRequest = true;
}
@SuppressWarnings("unused")
public void clearErrorInPostRequest() {
this.isErrorInPostRequest = false;
}
@SuppressWarnings("java:S2925")
private void simulateNetworkDelay() {
long delay = minDelayMillis + (long) (Math.random() * (maxDelayMillis - minDelayMillis));
try {
TimeUnit.MILLISECONDS.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void throwError() {
// ...
}
// ...
@Override
public <T, E> E requestPost(String url, Map<String, String> httpHeaderMap, T body, Class<E> responseType) {
// 네트워크 지연 시뮬레이션
simulateNetworkDelay();
// 에러 발생 시뮬레이션
if (isErrorInPostRequest) {
throwError();
}
// ...
// 실제 API 호출 없이 응답 반환
return responseType.cast(tossPaymentApiResponse);
}
}

위 구현체는 기존 실제 요청을 보내는 구현체의 인터페이스인 HttpOperator를 구현했지만 실제 네트워크 요청 없이 결제 승인을 테스트할 수 있게 해준다.

  1. 실제 API 호출 X: 외부 API 호출 없이 승인 결과를 반환
  2. 네트워크 지연 시뮬레이션: 실제 환경과 유사하게 네트워크 지연 시간 설정 가능
  3. 에러 발생 시뮬레이션: 특정한 오류 코드와 메시지를 지정한 에러 발생 가능

이렇게 구성 된 페이크 객체는 @TestConfiguration을 통해 테스트 환경에서만 사용되도록 설정한 뒤, Reflection을 통해 테스트 코드에서 호출하여 사용할 수 있었다.

class PaymentControllerTest extends IntegrationTest {
@Test
@DisplayName("Payment Confirm 요청이 성공하면 결제가 승인되고 DONE / SUCCESS 상태로 변경되면서 재고가 감소한다.")
void confirmPayment_Success() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "clearErrorInPostRequest"); // 에러 없음 => 성공
// ...
}
@Test
@DisplayName("Payment Confirm 요청 중 재시도 가능 오류가 발생하면 결제는 실패하고 UNKNOWN / UNKNOWN 상태로 변경되면서 재고는 감소된 상태로 유지된다.")
void confirmPayment_Failure_RetryableError() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest",
TossPaymentErrorCode.PROVIDER_ERROR.name(), // 재시도 가능한 오류 코드
TossPaymentErrorCode.PROVIDER_ERROR.getDescription()
);
// ...
}
@Test
@DisplayName("Payment Confirm 요청 중 재시도 불가능 오류가 발생하면 결제는 실패하고 FAILED / FAIL 상태로 변경되면서 재고는 다시 복구된다.")
void confirmPayment_Failure_NonRetryableError() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest",
TossPaymentErrorCode.INVALID_STOPPED_CARD.name(), // 재시도 불가능한 오류 코드
TossPaymentErrorCode.INVALID_STOPPED_CARD.getDescription()
);
// ...
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

페이크 객체를 활용함으로써 외부 API 의존성 및 절차적 테스트의 어려움을 해소했을 뿐만 아니라, 결제 승인 중 발생할 수 있는 다양한 시나리오에 대한 테스트를 보다 쉽게 작성할 수 있었다.

3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증

Section titled “3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증”

결제 시스템에서 다수의 사용자가 동시에 결제를 시도할 때 발생할 수 있는 동시성 문제는 중요한 검증 대상이다.
특히, 여러 사용자가 동일한 상품을 동시에 주문하거나 외부 API 타임아웃이 발생할 수 있기 때문에 이러한 상황에 대한 검증이 필요하다.

동시성 문제는 여러 스레드가 동시에 동일한 리소스를 수정할 때 발생하며, 이를 해결하지 못할 경우 결제가 중복되거나 재고 관리에 오류가 발생할 수 있다.
본 프로젝트에서는 비관적 락을 사용하여 동시 결제 시 재고 수량의 일관성을 보장했으며, 멀티 스레드 테스트를 통해 상황을 시뮬레이션하고, 주문 수량과 재고가 정확히 관리되는지 확인했다.

@Tag("TooLongIntegrationTest")
class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest
@CsvSource({
"1000, 1000, 1000, 0, 0", // 재고와 주문 수량이 일치
"1000, 999, 999, 0, 1", // 주문 수량이 재고보다 적음
"1000, 1001, 1000, 1, 0", // 주문 수량이 재고보다 많음
"1000, 1050, 1000, 50, 0", // 재고 초과 주문 (50개 초과 주문 실패)
"1200, 1000, 1000, 0, 200", // 재고가 1200개, 주문 수량은 1000개 (200개 남음)
})
@DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.")
void concurrentConfirmPayment_withStock(
int stock,
int orderCount,
int expectedSuccess,
int expectedFail,
int expectedStock
) {
// ...
executeConcurrentActions(orderIndex -> {
try {
// ...
MvcResult mvcResult = mockMvc.perform(
post("/api/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(confirmRequest))
).andReturn();
if (mvcResult.getResponse().getStatus() == 200) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}, orderCount);
// then
Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain();
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

다섯 번의 시나리오에서 동시 다발적인 결제 요청을 시뮬레이션하고, 결제가 성공적으로 처리되었는지, 재고가 정확하게 차감되었는지를 검증한다.
FakeTossHttpOperator를 활용해 실제 API 호출 없이 외부 의존성을 가지지 않는 환경에서 테스트를 진행할 수 있었다.
(실제 테스트 키로 부하 테스트를 수행하면 토스 측에서 일정 시간 동안 요청을 제한한다.)

동시성 문제 외에도 외부 네트워크 지연으로 인한 타임아웃 문제는 실제 결제 시스템에서 발생할 수 있다.
이를 검증하기 위해 결제 승인 요청에 대해 일정 지연을 설정하여 해당 상황에서도 모든 결제 요청이 적절하게 처리되는지 검증하는 테스트를 작성했다.

@Tag("TooLongIntegrationTest")
class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest
@CsvSource({
"500, 1000",
"1000, 3000",
"3000, 7000",
})
@DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.")
void confirmPayment_withTimeout(
int minDelayMills,
int maxDelayMills
) {
// ...
// 네트워크 지연 설정
ReflectionTestUtils.invokeMethod(httpOperator, "setDelayRange", minDelayMills, maxDelayMills);
executeConcurrentActions(orderIndex -> {
try {
// ...
MvcResult mvcResult = mockMvc.perform(
post("/api/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(confirmRequest))
).andReturn();
if (mvcResult.getResponse().getStatus() == 200) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}, orderCount);
// then
Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain();
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

jacoco test coverage

결제 시스템은 안정성이 최우선이기 때문에, 발생 가능한 다양한 시나리오와 문제를 테스트하는 것이 다른 도메인보다 더 중요하다고 생각한다.
본 테스트에서는 단위 테스트와 통합 테스트를 통해 각각의 로직과 전체 흐름을 검증하고, 멀티 스레드 테스트를 통해 동시성 및 타임아웃 같은 문제도 처리할 수 있는지 확인했다.

이번 테스트 과정을 통해 다음과 같은 성과를 얻을 수 있었다.

  1. 결제 승인 과정 검증: 다양한 시나리오를 고려한 테스트를 통해 결제 승인 과정의 여러 상황에 대한 검증을 수행할 수 있었다.
  2. 효율적인 테스트 환경 구성: 페이크 객체(FakeTossHttpOperator)를 활용하여 외부 API 호출을 대체함으로써 유연한 테스트 환경을 구축할 수 있었다.
  3. 동시성 이슈와 타임아웃 처리: 멀티 스레드 테스트를 통해 결제 시스템의 동시성 이슈와 타임아웃 처리 능력을 검증함으로써 실서비스 환경에서 발생할 수 있는 문제를 미리 방지할 수 있었다.

특히, 외부 API 서버에 요청을 보내는 대신 페이크 객체를 사용해 실제 요청 없이 테스트를 수행한 점이 테스트의 유연성을 높이는 데 큰 도움이 되었다.

다만, 이번 테스트는 동시성 이슈와 타임아웃 문제를 기본적인 수준에서 검증했을 뿐, 실제 부하가 걸리는 환경에서의 완벽한 검증을 수행했다고 보기는 어렵다.
따라서, 더 정밀한 부하 테스트는 전용 툴을 사용해 추가적으로 진행해야 하며, 이를 통해 결제 시스템의 성능 지표와 처리량을 더욱 정확하게 파악할 수 있을 것이다.
이러한 추가적인 검증 작업을 통해 결제 시스템의 안정성을 한층 더 강화할 수 있을 것이다.

MySQL COUNT() 함수의 동작 원리와 성능

실행 환경: MySQL 8.0.33, InnoDB Storage Engine

MySQL에서 COUNT()함수 인자로 아래 두 가지 방식을 사용할 수 있다.

  • COUNT(*)(= COUNT(1)): 테이블의 전체 레코드 수를 반환
  • COUNT(column_name): 특정 컬럼의 값이 NULL이 아닌 레코드 수를 반환

COUNT(*)은 테이블의 전체 레코드 수를 반환하기 때문에, PK인 id 컬럼을 사용할 것 처럼 보이지만, 실제로 옵티마이저는 다른 방식을 사용하여 처리하게 된다.

COUNT(*) 실행 계획과 옵티마이저 동작

Section titled “COUNT(*) 실행 계획과 옵티마이저 동작”

SELECT COUNT(*)SELECT *처럼 전체 컬럼 혹은 PK를 의미하는 것이 아닌, 옵티마이저가 최적화하여 가장 빠르게 처리할 수 있는 컬럼을 의미한다.
여기서 가장 빠르게 처리할 수 있는 컬럼이란 사용 가능한 가장 작은 세컨더리 인덱스인데, 실제로 MySQL 공식 문서에서도 아래와 같이 설명하고 있다.

InnoDB processes SELECT COUNT() statements by traversing the smallest available secondary index unless an index or optimizer hint directs the optimizer to use a different index. If a secondary index is not present, InnoDB processes SELECT COUNT() statements by scanning the clustered index.
인덱스 또는 옵티마이저 힌트가 옵티마이저에 다른 인덱스를 사용하도록 지시하지 않는 한, InnoDB는 사용 가능한 가장 작은 보조 인덱스를 탐색하여 SELECT COUNT() 문을 처리합니다. 보조 인덱스가 없는 경우, InnoDB는 클러스터된 인덱스를 스캔하여 SELECT COUNT() 문을 처리합니다.

실제로 그런지 확인하기 위해 employees 테이블에 COUNT(*) 쿼리를 실행하고, 실행 계획을 확인해보자.
테스트에는 MySQL 샘플 데이터의 employees 테이블을 사용하였다.

-- 데이터 개수 300,024개
create table employees
(
emp_no int not null primary key,
birth_date date not null,
first_name varchar(14) not null,
last_name varchar(16) not null,
gender enum ('M', 'F') not null,
hire_date date not null
);
EXPLAIN
SELECT COUNT(*)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:--------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | PRIMARY | 4 | null | 298841 | 100 | Using index |

실행 계획을 확인해보면, PRIMARY 키를 사용하여 인덱스 스캔을 수행하는 것을 확인할 수 있는데, 여기서 인덱스를 추가하면 달라지는 것을 확인할 수 있다.

CREATE INDEX idx_first_name
ON employees (first_name);
EXPLAIN
SELECT COUNT(*)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:-----------------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | idx\_first\_name | 58 | null | 298841 | 100 | Using index |

key 컬럼이 PRIMARY에서 idx_first_name으로 변경되었는데, 여기서 또 다른 인덱스를 추가하면 어떻게 처리되는지 확인해보자.

CREATE INDEX idx_gender
ON employees (gender);
CREATE INDEX idx_birth_date
ON employees (birth_date);
EXPLAIN
SELECT COUNT(*)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:------------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | idx\_gender | 1 | null | 298841 | 100 | Using index |

idx_gender로 변경되었는데, 해당 컬럼은 enum 데이터 타입을 사용하고 있다.
enum 데이터 타입은 1-2바이트만 사용하는 가장 작은 크기의 인덱스이기 때문에 옵티마이저가 해당 인덱스를 사용하여 COUNT(gender) 쿼리로 처리한 것이다.

EXPLAIN
SELECT COUNT(emp_no)
FROM employees;
-- | id | select\_type | table | partitions | type | possible\_keys | key | key\_len | ref | rows | filtered | Extra |
-- |:---|:-------------|:----------|:-----------|:------|:---------------|:------------|:---------|:-----|:-------|:---------|:------------|
-- | 1 | SIMPLE | employees | null | index | null | idx\_gender | 1 | null | 298841 | 100 | Using index |

추가적으로 COUNT(emp_no)처럼 PK 컬럼으로 명시하더라도 옵티마이저에서 PK가 아닌 가장 작은 크기의 인덱스를 사용하여 처리하는 것을 확인할 수 있었다.

가장 작은 크기의 세컨더리 인덱스가 더 빠른 이유

Section titled “가장 작은 크기의 세컨더리 인덱스가 더 빠른 이유”

COUNT(*) 쿼리는 가장 작은 크기의 세컨더리 인덱스를 사용하여 처리하는 것이 더 빠른 이유는 InnoDB 엔진의 세컨더리 인덱스 구조 때문이다.

InnoDB Non-Clustered Index

InnoDB의 세컨더리 인덱스는 Non-Clustered Index로, 해당 인덱스에는 해당 컬럼 값과 PK 값만 저장되어 있는데,
때문에 COUNT(*) 쿼리에선 아래의 이유로 더 빠르게 처리할 수 있게 된다.

  1. 레코드 수 집계의 특성: COUNT 쿼리에서 필요한 것은 실제 데이터가 아닌 레코드 수이기 때문에 논 클러스터 인덱스더라도 실제 데이터에 대한 I/O가 필요 없음
  2. 인덱스 크기와 스캔 속도: 세컨더리 인덱스는 해당 인덱스와 PK만 저장하므로 작은 데이터 크기를 가져, 하나의 페이지에 많은 레코드가 들어가 디스크 읽기 작업이 줄어듦

** 페이지: 디스크에 데이터를 저장하는 기본 단위로, 디스크의 모든 읽기 및 쓰기 작업의 최소 작업 단위

COUNT(column_name) 인자의 컬럼 데이터 크기가 미치는 영향

Section titled “COUNT(column_name) 인자의 컬럼 데이터 크기가 미치는 영향”

아래 예시는 세컨더리 인덱스를 사용하여 처리하는 것은 아니지만, 컬럼의 데이터 크기와 양에 따라 처리 속도가 크게 달라질 수 있음을 확인할 수 있다.

-- TEXT 타입 컬럼 추가
alter table employees
add text_column text null;
-- 컬럼에 대량의 데이터 추가
UPDATE employees
SET test_column = '...'; -- 약 2^16 길이의 영문자
SELECT COUNT(text_column)
FROM employees;
-- 17025ms
SELECT COUNT(last_name)
FROM employees;
-- 50-100ms

이처럼 데이터의 크기는 COUNT() 쿼리에 큰 영향을 미치는 요인 중 하나이며, 특히 TEXT와 같이 큰 데이터 타입을 사용할 때는 더욱 주의하는 것이 좋다.

MyISAM과 InnoDB 스토리지 엔진에서 테이블의 레코드 전체 개수를 조회하는 쿼리인 SELECT COUNT(*) FROM table_name는 아래와 같이 처리된다.

  • MyISAM: 메타 데이터를 통해 별도 연산 없이 조회
  • InnoDB: 데이터나 인덱스 스캔을 통해 레코드 건수 조회

InnoDB가 MyISAM과 같이 메타 데이터를 통해 조회하지 않는 이유는 InnoDB의 트랜잭션 지원을 위해 MVCC를 사용하기 때문이다.

InnoDB does not keep an internal count of rows in a table because concurrent transactions might “see” different numbers of rows at the same time. Consequently, SELECT COUNT() statements only count rows visible to the current transaction.
동시 트랜잭션이 동시에 다른 수의 행을 ‘볼’ 수 있기 때문에 InnoDB는 테이블의 행 내부 개수를 유지하지 않습니다. 따라서 SELECT COUNT(
) 문은 현재 트랜잭션에 표시되는 행만 카운트합니다.

WHERE 조건 / GROUP BY 조건 없는 COUNT(*) 쿼리

Section titled “WHERE 조건 / GROUP BY 조건 없는 COUNT(*) 쿼리”

이전에 커서 기반 페이징 성능 개선 을 진행하면서 WHERE 조건이 없는 COUNT(*) 쿼리가 빠르게 처리되는 것을 확인할 수 있었는데, 그 때 상황을 재현하면 아래와 같다.

-- 데이터 개수 3,000,000개
CREATE TABLE bulkinsert
(
num INT PRIMARY KEY
);
-- 1. 테이블 레코드 전체 개수 조회
SELECT COUNT(*)
FROM bulkinsert;
-- 80-100ms
-- 2. 테이블 레코드의 전체가 걸리는 조건으로 조회
SELECT COUNT(*)
FROM bulkinsert
WHERE num >= 0;
-- 350-400ms
-- 3. 테이블 레코드의 대부분을 조회
SELECT COUNT(*)
FROM bulkinsert
WHERE num < 2950000;
-- 350-400ms
-- 4. 테이블 레코드의 소수만 조회
SELECT COUNT(*)
FROM bulkinsert
WHERE num < 10;
-- 30-50ms
쿼리 유형조건실행 시간
1. 테이블 레코드 전체 개수 조회없음80-100ms
2. 테이블 레코드의 전체가 걸리는 조건으로 조회num >= 0350-400ms
3. 테이블 레코드의 대부분을 조회num < 2950000350-400ms
4. 테이블 레코드의 소수만 조회num < 1030-50ms

테이블 레코드 전체 개수 조회가 비슷한 양의 데이터를 조회하는 2,3번 쿼리보다 훨씬 빠르게 처리되는 것을 확인할 수 있는데,
이전에는 메타 데이터를 통해 조회하기 때문에 더 빠르다고 생각했지만, 아래의 이유로 이는 틀린 사실임을 알 수 있다.

  • InnoDB에서는 MVCC 지원을 위해 실제 스캔을 통해 레코드 건수를 조회
  • 메타 데이터를 통해 조회하는 것이라면, 소수만 조회하는 4번 쿼리보다 빠르게 처리되어야 함

전체 레코드를 조회하게 되는 1, 2번 두 쿼리의 EXPLAIN ANALYZE 결과를 확인해보면 아래와 같다.

-- 1. 테이블 레코드 전체 개수 조회
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM bulkinsert;
-- -> Count rows in bulkinsert (actual time=58.3..58.3 rows=1 loops=1)
-- 2. 테이블 레코드의 전체가 걸리는 조건으로 조회
EXPLAIN ANALYZE
SELECT COUNT(*)
FROM bulkinsert
WHERE num >= 0;
-- -> Aggregate: count(0) (cost=408276 rows=1) (actual time=627..627 rows=1 loops=1)
-- -> Filter: (bulkinsert.num >= 0) (cost=272398 rows=1.36e+6) (actual time=0.0403..546 rows=3e+6 loops=1)
-- -> Covering index range scan on bulkinsert using PRIMARY over (0 <= num) (cost=272398 rows=1.36e+6) (actual time=0.0377..412 rows=3e+6 loops=1)
  • 1번 쿼리: 전체 행을 카운트하는데, 2번보다 비교적 빠르게 처리(58.3ms vs 627ms)
  • 2번 쿼리: num >= 0 지점을 찾고 필터링을 수행하는 부분 뿐만 아니라, 집계하는 부분에서도 많은 시간이 소요됨

필터링하는 부분과 집계하는 부분에서 예상보다 많은 시간이 소요되는 것을 확인할 수 있었는데, 아래의 이유들로 인해 두 쿼리의 처리 속도 차이가 발생한 것으로 추측해보았다.

  • 오라클 진영의 Single/Multi Block IO 개념과 같이, 1번 쿼리는 MBR(Multi-Block-Read)의 방식처럼 데이터 블록을 빠르게 읽어옴
  • 전체 레코드를 조회할 때는 다른 COUNT 쿼리와 다른 읽기 방식 사용(Count rows in ..., Aggregate: count(0))
  • 2번 쿼리는 WHERE 조건 필터링 과정을 거치지만(Filter: (bulkinsert.num >= 0)), 1번 쿼리는 이러한 과정이 생략됨

결국 옵티마이저가 최적의 방법을 찾아 수행하는 것이기 때문에, 정확한 처리 방법과 그 차이는 알 수 없었지만,
이러한 결과가 나온 이유를 설명할 수 있는 부분을 공식 문서에서 찾을 수 있었다.(실제 개선 내용은 SELECT COUNT(*) 성능 향상에서 확인 가능)

As of MySQL 8.0.13, SELECT COUNT() FROM tbl_name query performance for InnoDB tables is optimized for single-threaded workloads if there are no extra clauses such as WHERE or GROUP BY.
MySQL 8.0.13부터 InnoDB 테이블에 대한 tbl_name의 SELECT COUNT(
) FROM 쿼리 성능은 WHERE 또는 GROUP BY와 같은 추가 절이 없는 경우 단일 스레드 워크로드에 최적화되어 있습니다.

COUNT() 함수는 단일 row를 반환하기 때문에 단순하고 빠르게 처리하는 것으로 보일 수 있지만, 예상치 못한 성능 이슈가 발생할 수 있으니, 유의해서 사용하는 것이 좋다.

  1. 인덱스 활용 최적화: COUNT(*) 쿼리는 옵티마이저에서 가능한 가장 작은 세컨더리 인덱스를 사용하여 처리되므로, 적절한 인덱스를 추가하여 성능을 향상시킬 수 있음
  2. 데이터 타입: COUNT(column_name)을 사용할 때는 데이터 타입을 고려해야 하며, 특히, TEXTBLOB과 같은 대용량 데이터 타입은 주의 필요
  3. 조건이 없는 COUNT 최적화: 전체 레코드를 조회할 땐 조건이 없는 COUNT(*) 쿼리를 사용하여 빠르게 처리할 수 있음
  4. 캐싱과 추정치 사용: COUNT 쿼리는 많은 비용이 발생하기 때문에, 정확한 행 수를 계산이 필요한 요구사항이 아니라면, 캐싱이나 추정치를 계산하는 방법을 고려할 수 있음

트랜잭션 범위 최소화를 통한 성능 및 안정성 향상

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33
!! 해당 내용은 복구 로직 적용 전의 내용을 담고 있으며, 현재 적용된 내용은 마지막 부분에 기술되어 있습니다.

프로젝트 진행 중 MySQL 관련 공부를 하던 중, 트랜잭션 범위의 중요성에 대해 학습하게 되었다.
특히, 트랜잭션 범위가 넓을 경우 발생하는 성능 문제를 인식하게 되었고, 이를 개선하기 위해 트랜잭션 범위 최소화 작업을 진행하였다.

트랜잭션 범위가 넓어지면 다음과 같은 문제점이 발생할 수 있다.

  • 커넥션 풀 부족
    • 커넥션 풀은 데이터베이스와 연결된 커넥션을 미리 생성해두고, 요청이 들어올 때마다 커넥션을 빌려주고 반납받는 방식으로 동작한다.
    • 커넥션 풀에는 최대 커넥션 수가 정해져 있으며, 이를 초과하게 되면 대기열에 들어가게 된다.
    • 트랜잭션 범위가 넓어지면 커넥션을 오래 사용하게 되고, 이로 인해 커넥션 풀의 커넥션 수가 부족해지게 되어 대기열에 들어가게 된다.
    • 이로 인해 다른 요청들이 대기하게 되고, 이는 서비스의 응답 시간을 느리게 하게 된다.
  • 잠금 대기
    • 만약 트랜잭션에서 레코드의 잠금을 거는 경우, 넓어진 트랜잭션 범위만큼 점유하는 시간이 길어지게 된다.
    • 해당 레코드를 다른 트랜잭션에서 사용하려고 할 때, 다른 트랜잭션들이 락을 대기하게 되고, 이는 데드락이 발생할 확률을 높이게 된다.

위와 같은 이유로 많은 책이나 컬럼에서는 트랜잭션 범위를 최소화하라고 권장하고 있다.
특히, 내가 최근에 읽었던 Real MySQL 8.0 서적에서도 트랜잭션 범위를 최소화하라고 권장하고 있으며, 특히 네트워크 작업이 있는 요청은 반드시 트랜잭션에서 배제해야 한다고 말하고 있다.

이전에 개발한 결제 연동 시스템에서 하나의 트랜잭션에서 네트워크 요청을 포함한 많은 작업을 수행하고 있었다.
때문에 트랜잭션 범위가 넓어지게 되었는데, 대략적인 코드와 로직은 아래와 같다.
(자세한 로직 및 전체적인 플로우는 링크 참고)

public class OrderService {
// ...
@Transactional // 해당 메서드 전체에 걸친 트랜잭션 범위
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(X-LOCK)
OrderInfo orderInfo =
this.getOrderInfoByOrderPessimisticLock(orderConfirmRequest.getOrderId());
// 2. 재고 감소
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
// 3. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

전체를 감싸고 있는 트랜잭션 범위

해당 로직을 그림으로 표현하면 위와 같으며, 문제점은 다음과 같다.

  1. 하나의 트랜잭션 범위에 포함된 모든 로직
    • 실질적으로 데이터베이스의 트랜잭션이 필요한 부분은 초록색으로 표시된 부분 뿐이지만 전체 로직을 트랜잭션 범위로 감싸고 있음
  2. 불필요한 테이블의 레코드까지 X-LOCK을 통해 조회
    • 재고 감소 동시성 처리를 위해 필요한 테이블은 Product 레코드 뿐이지만, OrderInfoUser의 레코드까지 잠금이 걸리게 됨
    • 여기서 얻은 LOCK을 로직이 끝나는 시점까지 유지하게 되어 다른 트랜잭션들이 대기하게 되어 데드락 발생 확률이 높아짐
  3. 외부 API 요청을 트랜잭션 범위 내에서 수행
    • 외부 API 호출은 네트워크 작업을 수반하여 시간적 범위가 길어질 수 있음
    • 그 시간만큼 트랜잭션을 유지하는 시간이 길어지게 되어 다른 트랜잭션들의 대기 시간 증가하여 전체 서비스 응답 시간 증가
    • 최악의 경우 외부 API에서 응답이 늦게 오거나 Time Out 되면 기하급수적으로 서비스 응답 시간 증가하게 됨

외부 API 응답이 늦은 경우 발생하는 문제

Section titled “외부 API 응답이 늦은 경우 발생하는 문제”

가장 문제가 될 수 있는 부분은 외부 API 응답이 늦게 오거나 Time Out 되는 경우인데, 결제 승인 요청 API 응답이 늦게 오는 경우를 시뮬레이션하여 확인해보았다.

// Mock Server의 승인 요청에 대한 응답 수신 시 3초 대기하도록 설정
@RestController
@RequiredArgsConstructor
public class MockController {
// ...
@PostMapping("/confirm")
public TossPaymentResponse confirmPayment(@RequestBody TossConfirmRequest tossConfirmRequest) {
// time out
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// ...
}
}

그리고 동시에 10개의 주문 승인 요청을 보내는 동시성 테스트를 수행해보니 다음과 같은 결과를 얻을 수 있었다.

Lock 해제를 대기하는 나머지 트랜잭션들

Lock을 가진 트랜잭션이 네트워크 지연을 기다리는 동안 다른 스레드들도 Lock 해제를 대기하는 것을 확인할 수 있었고,

동시에 10개의 주문 승인 요청 테스트 결과

테스트 수행 시간은 3초씩 대기하게 되어 10개 밖에 안 되는 요청이 30초가 넘게 걸리게 되었으며,
10개보다 조금 더 많은 개수인 20개로만 설정했을 땐 Time Out이 발생하여 마지막 몇 개의 요청은 실패하게 되었다.

트랜잭션 범위 내에 있는 외부 API 요청 배제

Section titled “트랜잭션 범위 내에 있는 외부 API 요청 배제”

우선 위의 문제를 해결하기 위해 네트워크에 영향을 받는 외부 API 요청을 트랜잭션 범위 밖으로 배제하고,
Database에 대한 작업만 트랜잭션 범위 내에서 수행하도록 변경하였다.

public class OrderService {
// ...
// @Transactional 어노테이션 제거하여 트랜잭션 X
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 3. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo); // @Transactional이 없으므로 명시적으로 저장
// WARNING!! 2번 이후에 에러 발생 시, 2번에서 발생한 재고 감소 건이 롤백되지 않음
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

위와 같이 변경을 한 덕분에, 목표로 한 외부 API 요청을 트랜잭션 범위 밖으로 배제할 수 있게 되었다.

외부 API 요청을 트랜잭션 범위 밖으로 배제

하지만 큰 문제가 있는데, 2번에서 재고 감소 이후 에러가 발생하게 되면 재고 감소 건이 롤백되지 않게 되어 결국 재고만 감소된 상태로 남게 된다.
이렇게 되면 하나의 요청이 원자성을 보장하지 못하게 되어, 결국 데이터의 불일치가 발생하게 된다.

로직 순서 변경을 통한 위험 범위 최소화

Section titled “로직 순서 변경을 통한 위험 범위 최소화”

다음으론 위와 같은 문제점을 최소화 위해 로직 순서를 변경하여, 에러 발생 위험 범위를 줄일 수 있게 변경하였다.

public class OrderService {
// ...
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 3. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 4. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo);
// WARNING!! 4번 이후에 에러 발생 시, 2번에서 발생한 재고 감소 건이 롤백되지 않음
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

TossPayment 측 결제 정보 조회결제 정보 검증재고 감소 및 Database Commit 수행 전으로 변경하였는데,
이와 같이 변경할 수 있는 이유는 아래와 같다.

  • TossPayment 측 결제 정보 조회: 주문 승인 전 검증을 위한 외부 API 요청으로, 재고 감소 및 데이터베이스 커밋 이전에 수행해도 무방
  • 결제 정보 검증: 마찬가지로 주문 승인 전 검증을 위한 로직이므로, 재고 감소 및 데이터베이스 커밋 이전에 수행해도 무방

두 개의 로직을 분리함으로써 에러 발생 범위를 최소화할 수 있게 되었다.

에러 발생 범위 최소화

하지만 여전히 에러 발생 시 재고 감소 건이 롤백되지 않는 근본적인 문제점은 해결되지 않았다.

보상 트랜잭션 명시적 추가를 통한 롤백

Section titled “보상 트랜잭션 명시적 추가를 통한 롤백”

근본적인 문제를 해결하기 위해 재고를 다시 증가시키는 보상 트랜잭션을 명시적으로 추가하였다.
여기서 결제 승인이 아닌 재고 감소를 먼저 수행하고, 보상 트랜잭션의 대상으로 재고 증가를 선정한 이유는 다음과 같다.

  • 재고 증감 작업은 서버에서 직접 제어가 가능하므로, 핸들링하는데 있어 비교적 더 안전함
  • 결제 승인 요청을 먼저 수행하는 경우, 승인에 대한 보상 트랜잭션으로 결제 취소라는 외부 API 요청을 수행해야 함
    • 결국 외부 API를 통한 작업이기 때문에, 네트워크 의존성이 더 커지면서 위험 요소가 증가함
    • 돈이 실제로 빠져나가고(승인 API) 다시 채워지게 되어(취소 API) 사용자 경험 측면에서 좋지 않음
public class OrderService {
// ...
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 3. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 4. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 5. TossPayment 측 결제 승인(외부 API) + 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = this.confirmPaymentAndOrderInfoWithStockRollback(
orderConfirmRequest,
orderInfo
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// 이후 로직 하나의 메서드로 추출
private OrderInfo confirmPaymentAndOrderInfoWithStockRollback(
OrderConfirmRequest orderConfirmRequest,
OrderInfo orderInfo
) {
try {
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo);
return confirmedOrderInfo;
} catch (Exception e) { // 에러 발생 시 롤백 수행
// 재고 증가 및 Database Commit
productService.increaseStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
throw e;
}
}
// ...
}

4번 재고 감소 이후 에러가 발생할 수 있는 나머지 로직을 하나의 메서드로 추출하고,
해당 메서드 내에서 에러 발생 시 재고 증가 및 데이터베이스 커밋을 명시적으로 수행하도록 변경하였다.

보상 트랜잭션을 통한 롤백 수행

결과적으로 주문 승인 처리 흐름은 위와 같이 변경되었으며, 트랜잭션 범위도 최소화되었고, 에러 발생 시 원자성을 보장할 수 있게 되었다.

주문 승인 처리의 큰 로직 수정 없이 단순 순서 변경과 추가된 보상 트랜잭션을 통해 트랜잭션 범위 최소화하여 아래의 결과를 얻을 수 있었다.

  • 네트워크 지연 설정 X, 트랜잭션 범위 최소화 전/후 수행시간 비교(11s -> 6s)

트랜잭션 범위 최소화 전(네트워크 지연 설정 X) - 11s 트랜잭션 범위 최소화 후(네트워크 지연 설정 X) - 6s

트랜잭션 범위 최소화를 통해 레코드에 잠금이 걸리는 범위를 줄여 성능 향상을 가져올 수 있었으며,

  • 네트워크 지연 설정 3,000ms, 트랜잭션 범위 최소화 후 1,000개 요청 성공

트랜잭션 범위 최소화 후(네트워크 지연 설정 3,000ms) - 1m 40s

외부 API의 지연으로 잠금 대기 시간이 무한정 늘어나는 장애를 방지해 안정성을 향상 시켜, 1,000개의 요청을 모두 성공시킬 수 있었다.
이전에 20개의 요청을 보냈을 때 Time Out이 발생했던 것과는 달리, 네트워크 지연이 발생하더라도 이전보다 더 안정적으로 서비스를 기대할 수 있게 되었다.

에러 처리 및 복구 로직 적용에 따른 트랜잭션 범위 변화

Section titled “에러 처리 및 복구 로직 적용에 따른 트랜잭션 범위 변화”

결제 상태의 추가와 결제 복구 로직 추가로 인해 트랜잭션 범위 정책과 복구 로직도 함께 변경되었다.

에러 종류에 맞는 복구 로직 수행

결제 시작 처리를 먼저 수행하고, 재고 감소를 수행하며, 결제 검증 및 외부 API 요청을 수행하는 로직은 동일하게 유지하였다.
시작 처리를 먼저 데이터베이스에 반영하기 때문에 그 이후에 발생하는 예외 상황들에 대해 맞는 복구 로직을 각각 작성할 필요가 있었다.

  • 재시도 가능과 불가능한 에러로 구분
  • 재시도 가능한 에러는 UNKNOWN 상태로 변경하고, 재고는 그대로 유지
  • 재시도 불가능한 에러는 FAIL 상태로 변경하고, 재고를 복구
@Service
@RequiredArgsConstructor
public class PaymentConfirmServiceImpl implements PaymentConfirmService {
// ...
@Override
public PaymentConfirmResult confirm(PaymentConfirmCommand paymentConfirmCommand) {
// 1. 결제 이벤트 조회
PaymentEvent paymentEvent = paymentLoadUseCase.getPaymentEventByOrderId(
paymentConfirmCommand.getOrderId()
);
try {
// 2. 재고 차감 - 재고 부족 에러 발생 가능
orderedProductUseCase.decreaseStockForOrders(paymentEvent.getPaymentOrderList());
} catch (PaymentOrderedProductStockException e) { // 에러 1: 재고 부족 에러 발생
handleStockFailure(paymentEvent); // FAIL 처리(재고 중 일어난 에러로, 감소가 일어나지 않았으므로 복구 필요 없음)
throw PaymentTossConfirmException.of(PaymentErrorCode.ORDERED_PRODUCT_STOCK_NOT_ENOUGH);
}
try {
// 3. 결제 시작 처리 - 결제 시작 상태 검증 중 에러 발생 가능
paymentProcessorUseCase.executePayment(paymentEvent, paymentConfirmCommand.getPaymentKey());
// 4. 결제 검증 - 결제 검증 중 에러 발생 가능
// 5. 토스 결제 승인 요청 - 토스 측 재시도 가능/불가능한 에러 발생 가능
// 6. 결제 완료 처리
PaymentEvent completedPayment = processPayment(paymentEvent, paymentConfirmCommand);
// 7. 결제 완료 결과 반환
// ...
} catch (PaymentStatusException e) { // 에러 2: 이미 요청이 진행 된 건에 대한 요청
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(paymentEvent); // UNKNOWN 상태로 변경 + 재고 그대로 유지
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
} catch (PaymentTossNonRetryableException e) { // 에러 4: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
} catch (Exception e) { // 에러5: 결제 검증 중 에러 등 기타 에러 발생
handleUnknownException(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
}
}
// ...
}

멀티 스레드 테스트에서 발생하는 @Transactional가 주는 문제

실행 환경: Java 17, Spring Boot 3.1.5, JUnit 5.9.3

상품 재고 기능 개발 중, 동시성 테스트를 하기 위해 멀티 스레드를 이용하여 테스트를 진행했다.
테스트를 수행하기 전에 저장 될 데이터를 미리 저장해두고, 당시 평소와 같이 테스트 수행 후 롤백되도록 @Transactional을 추가하는 방식으로 테스트를 진행했다.

@SpringBootTest
class OrderConcurrentTest {
// 의존성 주입 ...
private User user;
private Product product;
private List<OrderInfo> savedOrderList;
@Transactional // 독립적인 테스트를 위해 테스트 메서드 실행 후 롤백되도록 @Transactional 어노테이션 추가
@CsvSource({
"300, 300, 300, 0, 0",
"300, 299, 299, 0, 1",
"300, 301, 300, 1, 0",
"300, 350, 300, 50, 0",
})
@ParameterizedTest
@DisplayName("동시에 승인 요청을 보내면 재고만큼 승인되고 나머지는 실패한다.")
void approveOrderWithMultipleRequests(
int stock,
int orderCount,
int expectedSuccess,
int expectedFail,
int expectedStock
) {
// 테스트 수행 전 데이터 저장
product = productRepository.save(
generateProductWithPriceAndStock(BigDecimal.valueOf(1000), stock)
);
user = userRepository.save(generateUser());
savedOrderList = getSavedOrderList(orderCount); // orderCount만큼 주문 데이터 저장 및 리스트에 저장
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// 테스트 수행
executeConcurrentActions(orderIndex -> {
try {
// ...
// 리스트에 저장된 orderIndex번째 주문 승인 요청
OrderConfirmRequest orderConfirmRequest = generateOrderConfirmRequest(
savedOrderList.get(orderIndex)
);
orderController.confirmOrder(orderConfirmRequest); // 실제 테스트 대상 메서드
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
}
}, orderCount, 32);
// 테스트 결과 검증
Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
}
// ...
private void executeConcurrentActions(
Consumer<Integer> action,
int repeatCount,
int threadSize
) {
AtomicInteger atomicInteger = new AtomicInteger();
CountDownLatch countDownLatch = new CountDownLatch(repeatCount);
ExecutorService executorService = Executors.newFixedThreadPool(threadSize);
for (int i = 1; i <= repeatCount; i++) {
executorService.execute(() -> {
int index = atomicInteger.incrementAndGet() - 1;
action.accept(index);
countDownLatch.countDown();
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}

코드는 위와 같으며, 불필요한 부분은 최대한 생략했으나 간략하게 정리하면 다음과 같다.

  1. 테스트 수행 전 데이터 저장
    • 주문할 상품 / 주문자 데이터 저장
    • savedOrderList에 저장된 주문 수만큼 주문 데이터 저장
  2. 멀티스레드 테스트 수행
    • savedOrderList에 저장된 order에 대해 승인 요청을 보냄
    • 메서드 내부에 수행 전 저장 한 데이터를 조회하는 로직이 존재(비관적 락을 통해 조회)
    • 승인 요청 결과에 따라 성공 / 실패 카운트 증가
  3. 테스트 결과 검증

기존 멀티 스레드를 적용하기 전의 테스트 코드는 정상적으로 수행되었으나, 멀티 스레드를 적용하면서 테스트가 실패하였다.
테스트 수행 중 저장된 데이터를 조회하는 로직이 존재하는데, 저장된 데이터를 조회하는 시점에 데이터가 존재하지 않아 수행 중인 테스트가 실패한 것이다.

테스트 수행 중 저장된 데이터를 조회하는 시점에 데이터가 존재하지 않아 테스트가 실패한 것인데, @Transactional 어노테이션에 원인이 있다.
알다시피 @Transactional 어노테이션을 적용하면 메서드에 하나의 트랜잭션에 묶이게 되는데, 결국 메서드가 끝나기 전까지는 트랜잭션이 커밋되지 않는 말과 같다.
결과적으로 @Transactional 어노테이션을 적용한 위의 테스트는 아래와 같이 수행된다.

  1. 트랜잭션 시작(트랜잭션 A)
  2. 테스트 수행 전 데이터 저장(트랜잭션 A)
    • 커밋은 되지 않은 상태로, 트랜잭션 A에서만 조회 가능한 상태
    • 실제 데이터베이스에는 저장되지 않은 상태
  3. 멀티 스레드 테스트 수행
    • 새로운 스레드를 생성하면서 실행하기 때문에 별도의 트랜잭션에서 수행 됨(트랜잭션 B, C, …)
    • 트랜잭션 A가 아닌 다른 트랜잭션에서는 2번에서 저장한 데이터를 조회할 수 없음
    • 데이터 조회 에러 발생
    • 그 이후 로직이 수행되지 않고 메서드 종료
  4. 테스트 실패
  5. 트랜잭션 종료(트랜잭션 A)
@SpringBootTest
class OrderConcurrentTest {
// ...
@Transactional
void approveOrderWithMultipleRequests(
// ...
) {
// ...
System.out.println(entityManager.getDelegate()); // 테스트 수행 전 데이터 저장 시점의 트랜젝션
// ...
// 테스트 수행
executeConcurrentActions(orderIndex -> {
try {
// ...
System.out.println(entityManager.getDelegate()); // 테스트 수행 중 데이터 조회 시점의 트랜젝션
// ...
} catch (Exception e) {
// ...
}
}, orderCount, 32);
// ...
}
// ...
}

System.out.println(entityManager.getDelegate());를 통해 세션 정보와 트랜잭션 정보를 출력해보면,
테스트 수행 전 데이터 저장 시점의 트랜젝션은 커밋 되지 않은 open 상태이고, 테스트 수행 중 데이터 조회 시점엔 모두 다른 트랜잭션에서 수행됐음을 알 수 있다.

SessionImpl(161593456<open>) # 테스트 수행 전 데이터 저장 시점의 트랜젝션 -> open 상태
SessionImpl(734517556<closed>)
SessionImpl(1193267507<closed>)
SessionImpl(600492612<closed>)
SessionImpl(916520713<closed>)
SessionImpl(508597066<closed>)
SessionImpl(195662923<closed>)
SessionImpl(48837314<closed>)
SessionImpl(776960045<closed>)
SessionImpl(924763232<closed>)
SessionImpl(1204832111<closed>)
SessionImpl(399468152<closed>)
SessionImpl(1035630793<closed>)
SessionImpl(1949013162<closed>)
SessionImpl(175375417<closed>)
SessionImpl(258355164<closed>)
SessionImpl(1957971112<closed>)
SessionImpl(380388185<closed>)
...

우선 고려해볼 수 있지만 실제로 사용할 수 없거나 적합하지 않은 방법들은 다음과 같다.

  • Propagation.MANDATORY 사용

    • Propagation.MANDATORY: 이미 존재하는 부모 트랜잭션이 있으면 부모 트랜잭션을 합류시키고, 존재하지 않으면 예외를 발생시키는 전파 방식
    • 멀티 스레드 방식인 새로운 스레드를 생성하면서 테스트를 수행하고 있기 때문에 해당 스레드엔 부모 트랜잭션이 존재하지 않기 때문에 예외 발생
  • Isolation.READ_UNCOMMITTED 사용

    • Isolation.READ_UNCOMMITTED: 트랜잭션에 처리 중인 혹은 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 것을 허용하는 격리 수준
    • 테스트를 수행하는 스레드가 데이터 저장 시점의 트랜잭션을 읽을 수 있도록 하면 데이터 조회 에러는 해결할 수는 있지만, 실제로 사용하기에 적합하지 않다.

실제로 해결할 수 있는 방법으론 아래 두 가지 방법을 생각해 보았다.

  • sql.init.mode 옵션을 사용하여 테스트 수행 전 데이터베이스에 직접 저장
  • @Transactional 어노테이션을 제거하여 테스트 수행 전 저장된 데이터를 커밋하여 데이터베이스에 반영

이번 포스팅에서는 @Transactional 어노테이션 제거 방법을 사용했으며, @BeforeEach, @AfterEach을 통해 테스트 독립성을 확보했다.

@SpringBootTest
class OrderConcurrentTest {
// ...
@BeforeEach
void setUp() {
orderRepository.deleteAll();
productRepository.deleteAll();
userRepository.deleteAll();
}
@AfterEach
void tearDown() {
orderRepository.deleteAll();
productRepository.deleteAll();
userRepository.deleteAll();
}
// ...
}

@Transactional 어노테이션은 하나의 트랜잭션으로 묶어주어 편리하게 사용할 수 있게 도와주지만 멀티 스레드 환경에서는 예기치 못한 문제가 발생할 수 있다.
스프링 프레임 워크에서는 많은 편리한 기능을 제공하지만 그만큼 내부 동작 방식을 잘 알고 사용해야 한다는 것을 다시 한 번 느낄 수 있었다.
현재까지는 @Transactional을 제거하여 테스트 전/후 데이터를 컨트롤하는 방법이 적합하다고 생각하지만, 더 나은 해결책에 대한 고민이 필요한 것 같다.

`@Transactional`을 통한 선언적 트랜잭션 관리 방식에서 Self Invocation 문제가 발생하는 이유

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33

Spring에서는 @Transactional을 통해 트랜잭션을 관리할 수 있으며, 이를 통해 트랜잭션을 쉽게 사용할 수 있다. @Transactional을 통한 선언적 트랜잭션 관리 방식을 사용하면 프록시 방식의 AOP가 적용되는데 그로 인해 아래와 같은 특징을 가지게 된다.

  • @Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어 스프링 컨테이너에 등록
  • 실제 객체 대신에 프록시를 스프링 빈에 등록한 뒤 프록시는 실제 객체를 참조
  • 만약 다른 곳에서 해당 객체를 의존관계 주입을 요청하게 되면 스프링 컨테이너에서 실제 객체 대신에 등록되어 있던 프록시 객체를 주입
  • 최종적으로 다른 객체 -> 실제 객체 -> 프록시 객체 순으로 의존관계가 주입되게 됨

결국 요청을 받게되면 아래와 같이 트랜잭션이 동작하게 된다.

  1. 프록시 객체가 요청을 먼저 받음
  2. 프록시 객체에서 트랜잭션 시작
  3. 프록시 객체에서 실제 객체의 메서드 호출
  4. 실제 객체 로직 수행
  5. 프록시 객체에서 트랜잭션 커밋 또는 롤백

@Transactional 선언 방식은 프록시 객체를 통해 수행되는데, 만약 프록시 객체를 거치지 않고 대상 객체를 직접 호출하게 되면 트랜잭션이 적용되지 않는다. 보통의 경우는 프록시 객체를 거치기 때문에 문제가 되지 않지만, 대상 객체 내부에서 메서드 호출을 하게 되면 프록시를 거치지 않게 되어 위의 문제가 발생할 수 있다.

@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
public void externalCreatePost(PostCreateRequest postCreateRequest) {
internalCreatePost(postCreateRequest);
}
@Transactional
public void internalCreatePost(PostCreateRequest postCreateRequest) {
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
System.out.println(entityManager.isJoinedToTransaction()); // false
if (true) {
throw new RuntimeException();
}
}
}

internalCreatePost 메서드에 트랜잭션이 선언 되어 있기 때문에 트랜잭션 적용을 기대할 수 있지만, externalCreatePost 메서드를 통해 호출하게 되어 적용되지 않고 다음과 같이 동작하게 된다.

  1. 클라이언트에서 프록시 호출
  2. 프록시에서 external 메서드에 트랜잭션이 적용되어 있지 않기 때문에 트랜잭션 없이 메서드 호출
  3. 실제 externalCreatePost 메서드 실행
  4. externalCreatePost 메서드 내부에서 internalCreatePost 메서드 호출
  5. 실행 된 internalCreatePost 메서드는 실제 객체에서 실행되기 때문에 트랜잭션이 적용되지 않음

실제로 entityManager.isJoinedToTransaction()을 통해 트랜잭션이 적용되었는지 확인해보면 적용되지 않았고, DB에도 롤백 없이 데이터가 저장됐다. 단순히 생각했을 때 이 방법을 해결하기 위해서 externalCreatePost 메서드에도 @Transactional을 추가하여 해결해 볼 수 있을 것이다.

@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
@Transactional // 추가
public void externalCreatePost(PostCreateRequest postCreateRequest) {
internalCreatePost(postCreateRequest);
}
@Transactional
public void internalCreatePost(PostCreateRequest postCreateRequest) {
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
System.out.println(entityManager.isJoinedToTransaction()); // true
if (true) {
throw new RuntimeException();
}
}
}

실제로 위와 같이 externalCreatePost 메서드에도 @Transactional을 추가하면 트랜잭션이 적용되었고, internalCreatePost 메서드에서 예외가 발생하면 롤백이 되는 것을 확인할 수 있었다.

propagation 속성을 통해 트랜잭션 전파 방식을 설정할 수 있는데, 그 중 REQUIRES_NEW 방식을 사용하면 항상 새로운 트랜잭션을 생성하여 실행하게 된다. 위의 internalCreatePost 메서드에 propagation = REQUIRES_NEW을 추가했을 때 새로운 트랜잭션을 생셩하여 실행되는지 확인해보자.

@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1985594165<open>)
internalCreatePost(postCreateRequest);
// 내부 메서드 종료 후 예외 발생
if (true) {
throw new RuntimeException();
}
}
@Transactional(propagation = REQUIRES_NEW) // 새로운 트랜잭션 생성
public void internalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1985594165<open>)
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
// 정상적으로 메서드 종료
}
}

위와 같이 internalCreatePost 메서드에 propagation = REQUIRES_NEW을 추가했지만 하나의 트랜잭션으로 실행되고 있었다. 실제 entityManager.getDelegate()를 통해 세션을 확인해보면 동일한 세션을 사용하고 있었으며, 모두 롤백 처리 되어 DB에 데이터가 저장되지 않았다.

  • @Transactional 선언 방식은 프록시 객체를 통해 트랜잭션을 관리하기 때문에 프록시 객체를 거치지 않고 직접 호출하게 되면 트랜잭션이 적용되지 않음
  • 때문에 내부에서 호출한 메서드에 @Transactional을 추가하더라도 프록시 객체를 거치지 않아 새로운 트랜잭션 정책이 적용되지 않음
  • 결국internalCreatePost 메서드에 적용된 REQUIRES_NEW 정책 뿐만 아니라 @Transactional 자체가 무시됨

** entityManager.getDelegate(): 현재 사용되고 있는 세션을 확인하는 메서드(같은 세션 == 같은 영속성 컨텍스트 == 같은 트랜잭션)

@Transactional 선언 방식을 사용하면서 위와 같은 문제를 해결하고 싶다면, 자기 호출을 하지 않도록 다른 클래스를 통해 호출하는 것이 가장 좋은 방법이다. (AopContext나 Self Injection을 통해 자기 호출을 할 수 있지만, 이는 권장되지 않는 방법이다.)

PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
private final PostServiceInternal postServiceInternal;
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1431135451<open>)
this.postServiceInternal.internalCreatePost(postCreateRequest);
if (true) {
throw new RuntimeException();
}
}
}
// PostServiceInternal.java
@Service
@RequiredArgsConstructor
public class PostServiceInternal {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
@Transactional(propagation = REQUIRES_NEW)
public void internalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(460044237<open>)
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
}
}

PostServiceInternal을 주입받아 internalCreatePost 메서드를 호출하면 정상적으로 새로운 트랜잭션을 생성하는 것을 확인할 수 있다. PostService에서는 예외가 발생하였지만, internalCreatePost 메서드는 정상적으로 종료됐기 때문에 DB에 정상적으로 데이터가 저장되었다. 또한 entityManager.getDelegate()를 통해 세션을 확인해보면 서로 다른 세션을 사용하고 있음을 확인할 수 있었다.

자기 호출에 대한 문제를 인지하고 있었다면 다른 클래스를 통해 호출하면 되지만, 내부 호출을 인지하지 못한다면 의도한대로 트랜잭션이 적용되지 않을 수 있다. 때문에 의도치 않은 자기 호출을 막기 위해서 하위 메서드는 항상 private으로 선언하여 외부에서 호출할 수 없도록 하는 것이 좋다.

private에 Transactional을 적용한 경우

위 상황에서 조금 다른 아래와 같은 상황을 생각해보자.

  • internalCreatePost 메서드에서 데이터 저장 수행
  • externalCreatePost 메서드에서도 데이터 저장 수행
  • internalCreatePost 메서드에서 예외 발생
PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
private final PostServiceInternal postServiceInternal;
// 먼저 외부에서 호출되는 메서드
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1431135451<open>)
this.postRepository.save(
postCreateRequest.toEntity(userService.getUserById(postCreateRequest.getUserId()))
);
this.postServiceInternal.internalCreatePost(postCreateRequest);
}
}
// PostServiceInternal.java
@Service
@RequiredArgsConstructor
public class PostServiceInternal {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
// PostService의 externalCreatePost 메서드에서 호출되는 메서드
@Transactional(propagation = REQUIRES_NEW)
public void internalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(
"get delegate: " + entityManager.getDelegate() // SessionImpl(460044237<open>)
);
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
if (true) {
throw new RuntimeException();
}
}
}

internalCreatePost 메서드를 REQUIRES_NEW으로 설정했기 때문에, 두 서비스의 트랜잭션이 독립적으로 실행되길 기대할 것이다. 때문에 internalCreatePost에서 예외가 발생하더라도 externalCreatePost에서 데이터가 저장될 것으로 예상할 수 있지만 실제론 그렇지 않다.

externalCreatePostinternalCreatePost
트랜잭션 시작(id 1)
데이터 저장 수행(id 1)
internalCreatePost 메서드 호출
새로운 트랜잭션 생성(id 2)
데이터 저장 수행(id 2)
예외 발생
예외로 인한 트랜잭션 롤백(id 2)
트랜잭션 종료(id 2)
internalCreatePost 예외 전달 받음
예외로 인한 트랜잭션 롤백(id 1)
트랜잭션 종료(id 1)

원인은 아주 간단하게 전파 받은 예외를 처리하지 않았기 때문인데, 전파 받은 예외를 처리하도록 수정하면 해결할 수 있다.

PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
private final PostServiceInternal postServiceInternal;
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1431135451<open>)
this.postRepository.save(
postCreateRequest.toEntity(userService.getUserById(postCreateRequest.getUserId()))
);
try {
this.postServiceInternal.internalCreatePost(postCreateRequest);
} catch (Exception e) { // 예외 처리 추가
System.out.println("catch exception");
}
// 예외를 처리하게 되면서 트랜잭션 커밋 수행
}
}

Self Invocation 문제는 @Transactional 선언 방식을 사용할 때 발생할 수 있는 문제로, 내부 호출을 통해 발생할 수 있다. 다양한 해결 방법이 있겠지만, 객체의 책임을 분리하고 외부에서 호출하도록 설계하는 것이 가장 좋은 방법인 것 같다. 또한, REQUIRES_NEW는 새로운 스레드를 생성하는 것이 아닌 새로운 트랜잭션을 생성하는 것을 명심해야 한다.

Spring Data JPA Cursor 기반 페이징 성능 개선기

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33

Offset이 아닌 Cursor 기반으로 페이징 처리를 하면 성능이 향상된다는 이야기는 들었으나, 실제로 적용해볼 기회가 없어 이번 기회에 적용해보았다.
기존 단순 Offset 기반으로 페이징 처리된 코드와 Cursor 기반으로 페이징 처리된 코드와 성능을 비교해보고자 했는데,
그 과정에서 의도대로 동작하지 않는 부분이 있어 이에 대한 원인과 해결 방법을 포스팅하게 되었다.

만약 한 번에 100만개의 데이터를 가져온다고 가정하면, 서버는 모든 100만개의 데이터를 메모리에 올리게 되고, 이를 클라이언트에게 전달하게 된다.
이렇게 되면 통신 비용이 많이 들고, 서버의 메모리를 많이 사용하게 되어 성능 저하나 메모리로 인한 서버 다운 등의 문제가 발생할 수 있다.

때문에 특정 개수만큼 가져오는 페이지네이션이라는 기법을 사용하고 있으며, 이를 구현하는 방법은 크게 두 가지가 있다.

  • Offset 기반: MySQL의 LIMITOFFSET을 사용하여 구현
  • Cursor 기반: MySQL의 LIMITWHERE을 사용하여 구현

이 방식들의 자세한 설명과 장단점은 아래와 같이 정리할 수 있다.

SQL의 LIMITOFFSET을 사용하여 구현하는 방식으로, 간단하고 쉽게 구현할 수 있으며 Cursor에 비해 여러 기능을 구현하기 쉽다.

SELECT *
FROM table
LIMIT 10 OFFSET 10; -- 11번째부터 10개의 데이터를 가져온다.

구현하기 쉽다는 장점이 있지만, 단점도 존재한다.

  1. 조회한 데이터가 많고, 건너 뛰는(OFFSET) 데이터가 많을 경우 성능이 저하된다.
  2. 데이터가 삭제되거나 추가되면 페이지네이션의 결과가 달라질 수 있다.(2번 페이지에서 확인한 데이터가 다음 페이지에서 다시 노출될 수 있음)

SQL의 LIMITWHERE을 사용하여 구현하는 방식으로, LIMITOFFSET 방식의 단점을 보완할 수 있다.

SELECT *
FROM table
WHERE id > 10 -- id가 10보다 큰 데이터를 가져온다.(OFFSET 기능을 수행)
LIMIT 10; -- 10개의 데이터를 가져온다.

이렇게 되면 건너 뛰는 데이터를 WHERE 절에서 처리하기 때문에 성능이 저하되지 않지만, 이 방식에도 단점이 존재한다.

  1. WHERE 절에 사용되는 컬럼은 중복이 없고(UNIQUE), 인덱스가 존재해야 한다.(적용할 순 있으나 성능이 저하될 수 있음)
  2. 1페이지에서 5페이지 데이터를 바로 조회하는 기능(정확한 데이터 개수만큼 건너 뛰어서 조회하는 기능)을 구현하기 어렵다.

때문에 Cursor 기반으로 페이징 처리가 가능한 기능이면 Cursor 기반으로 페이징 처리를 하고, 그 외의 경우에는 다른 방법(커버링 인덱스 등)을 고려해보는 것이 좋다.

각 방법의 성능 차이는 건너 뛰는 개수가 커질 수록 성능 차이가 커지는 것을 확인할 수 있었다.(데이터 3,260,000개 기준, 5회 측정)
우선 Offset 기반으로 페이징 처리를 한 경우의 성능은 아래와 같다.

SELECT *
FROM posts
LIMIT 0, 20; # 40~80ms
SELECT *
FROM posts
LIMIT 1000000, 20; # 40~80ms
SELECT *
FROM posts
LIMIT 2000000, 20; # 500ms~600ms
SELECT *
FROM posts
LIMIT 3000000, 20; # 800ms~900ms

Offset 기반은 건너 뛰는 개수에 따라 쿼리 속도가 선형적으로 증가하는 것을 확인할 수 있다.
반면 Cursor 기반으로 페이징 처리를 한 경우의 성능은 아래와 같다.

SELECT *
FROM posts
WHERE id > 0 # 50~70ms
LIMIT 20;
SELECT *
FROM posts
WHERE id > 1000000 # 50~70ms
LIMIT 20;
SELECT *
FROM posts
WHERE id > 2000000 # 50~70ms
LIMIT 20;
SELECT *
FROM posts
WHERE id > 3000000 # 50~70ms
LIMIT 20;

Cursor 기반은 건너 뛰는 개수에 따라 쿼리 속도가 일정하고 최대 약 20배 정도 빠른 것을 확인할 수 있다.

Spring Data JPA에서 Cursor 기반 페이징 처리

Section titled “Spring Data JPA에서 Cursor 기반 페이징 처리”

MySQL에서 유의미한 차이를 확인한 뒤 Spring Data JPA에서 Cursor 기반으로 페이징 처리를 하기 위해 아래와 같이 코드를 작성했다.

public interface PostRepository extends JpaRepository<Post, Long> {
// Offset 기반 페이징 처리
@Query("select p from Post p join fetch p.user")
Page<Post> findAllWithUser(Pageable pageable);
// Cursor 기반 페이징 처리(새롭게 추가한 코드)
@Query("select p from Post p join fetch p.user where p.id > :cursorId")
Page<Post> findAllWithUserByCursor(Long cursorId, Pageable pageable);
}

그 후 Postman을 통해 테스트를 진행했으나 의도와는 다르게 동작하는 것을 확인할 수 있었다.
우선 의도대로 동작한 케이스를 확인해보자.

  • 의도대로 동작한 Cursor 기반 조회

Cursor 기반 조회 - 80ms

  • 의도대로 동작한 Offset 기반 조회

Offset 기반 조회 - 2560ms

건너 뛰는 것이 많은 케이스일 땐(page 150000, 약 3,000,000개) 우리가 원하는, 그리고 위에서 확인한 성능 차이가 나는 것을 확인할 수 있었다.
하지만 건너 뛰는 것이 적은 케이스일 땐(page 59, 약 1,200개) 성능 차이가 거의 없는 것을 확인할 수 있었다.

  • 의도하지 않은 결과 Cursor 기반 조회

Cursor 기반 조회 - 1739ms

  • 의도하지 않은 결과 Offset 기반 조회

Offset 기반 조회 - 1745ms

결론부터 말하자면 위 구현 코드에서 Page 타입으로 반환하고 있기 때문이다.(Page 타입 반환은 내용 뿐만 아니라 개수에 대한 정보도 같이 반환한다.)

{
"message": "성공적으로 모든 게시글이 조회되었습니다.",
"data": {
"content": [
...
],
"pageable": {
...
},
"totalPages": 6300,
"totalElements": 126000,
...
}
}

이런 정보를 반환하기 위해 COUNT 쿼리를 실행하고 있기 때문인데, 결국에 요청하는 쿼리는 아래와 같이 된다.

  • Offset 기반 조회: WHERE 조건 없이 전체 데이터에 대한 OFFSET + 데이터 전체 건 수 COUNT
  • Cursor 기반 조회: WHERE 조건에 맞는 데이터에 대한 OFFSET + WHERE 조건에 맞는 데이터 전체 건 수 COUNT

그럼 COUNT 쿼리가 성능에 어떤 영향을 주었길래 이렇게 성능 차이가 발생했을까?
이유엔 크게 아래 세 가지 요인이 있다.

  • offset 특성 상 건너 뛰는 데이터가 적을 땐 성능 저하가 적음
  • 조건 없는 COUNT 쿼리 요청 시 많은 건을 조회하는 WHERE 보다 빠르게 조회됨 (참고)
  • 조건에 잡힌 데이터가 많을 수록 COUNT 쿼리의 속도 저하가 커짐
-- 전체 데이터에 대한 COUNT 쿼리
SELECT COUNT(*)
FROM posts;
-- 300ms
-- 조건에 잡힌 데이터가 적은 경우 COUNT 쿼리
SELECT COUNT(*)
FROM posts
WHERE id > 3000000;
-- 50~100ms
-- 조건에 잡힌 데이터가 많은 경우 COUNT 쿼리
SELECT COUNT(*)
FROM posts
WHERE id > 50000;
-- 500~700ms

때문에 결과적으로 아래와 같은 결과가 나오게 된다.

  • Offset 기반 쿼리: 건너 뛰는 데이터가 적음(성능 저하가 적음) + WHERE 조건 없이 COUNT 쿼리 요청(성능 저하가 적음)
  • Cursor 기반 쿼리: 건너 뛰는 데이터가 많음(성능 동일) + WHERE 조건에 잡힌 데이터가 많은 COUNT 쿼리 요청(성능 저하가 큼)

결국 Offset 기반 쿼리는 성능 저하가 적은 쿼리가 실행되고, Cursor 기반 쿼리는 성능 저하가 큰 쿼리가 실행되어 성능이 서로 거의 비슷해지는 상황이 발생하게 된 것이다.

Cursor 기반 페이징의 경우 전체 데이터 건 수를 꼭 가져올 필요가 없기 때문에 Count 쿼리를 실행하지 않도록 수정하면 된다.
Page 타입이 아닌 Slice 타입으로 반환하도록 수정하면 Count 쿼리가 실행되지 않게 된다.

// Page.java, Page는 Slice를 상속받으면서 개수에 대한 정보만 추가로 가지고 있다.
public interface Page<T> extends Slice<T> {
static <T> Page<T> empty() {
return empty(Pageable.unpaged());
}
static <T> Page<T> empty(Pageable pageable) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
int getTotalPages();
long getTotalElements();
<U> Page<U> map(Function<? super T, ? extends U> converter);
}
public interface PostRepository extends JpaRepository<Post, Long> {
// Page -> Slice로 수정
@Query("select p from Post p join fetch p.user where p.id > :cursorId")
Slice<Post> findAllWithUserByCursor(Long cursorId, Pageable pageable);
}

이렇게 Slice 타입으로 반환하면 Count 쿼리가 실행되지 않기 때문에 기존에 구현했던 Cursor 기반 쿼리의 성능이 향상되면서 일정한 성능을 유지할 수 있게 된다.

Slice 반환 Cursor 조회 - 37ms

Slice 반환 Cursor 조회 - 36ms

결제 정보 검증을 통한 안전한 결제 연동 시스템 구현 - 토스 페이먼츠

실행 환경: Java 17, Spring Boot 3.1.5
서버 위주의 포스팅이기 때문에 클라이언트 코드는 간략하게 작성
프론트엔드는 토스페이먼츠에서 제공해준 샘플 프로젝트 리액트 일부 수정 사용
!! 해당 문서는 2023년 11월 기준의 코드로 작성되었으며, 구현 코드는 많은 부분이 변경되었습니다.

KG이니시스, 다날 페이먼트, 토스 페이먼츠 등의 결제 서비스를 제공하는 업체들이 존재하는데, 그 중 토스페이먼츠를 이용하여 결제 시스템을 구현해보았다.
토스페이먼츠에서는 이미 클라이언트 코드를 제공해주어 쉽게 구현할 수 있다.
하지만 클라이언트에서만 처리하는 것은 보안에 취약하기 때문에 중간에 서버를 두어 검증 하는 단계를 추가하여 결제 시스템을 구현해보고자 한다.

문서가 잘 되어 있어 해당 문서를 참고하는 것이 가장 좋으나 핵심 용어를 간단하게 요약하면 아래와 같다.

  • 결제 위젯: 토스페이먼츠에서 제공해주는 결제 위젯 SDK로, 결제 요청을 위한 정보를 받아 결제 요청을 보내준다.
  • Client Key: 결제 위젯을 사용하기 위해 필요한 키로, 토스 페이먼츠에서 제공해준다.
  • Secret Key: 결제 승인 및 조회를 위해 필요한 키로, 토스 페이먼츠에서 제공해준다.(공개하면 안되는 비밀키이므로 해당 프로젝트에서는 서버에서 사용하도록 하였다.)
  • 결제 후 리다이렉트: 결제가 완료되면 결제 정보와 함께 리다이렉트를 해주는데, 이때 결제 정보는 URL 파라미터로 전달된다.
    • 결제 인증만 완료된 상태로, 완전히 결제가 완료된 것은 아니다.
  • 결제 승인: 인증된 결제를 최종 승인하는 것으로, 결제 승인이 완료되면 결제가 완전히 완료된 것이다.

출처(https://docs.tosspayments.com/guides/learn/payment-flow)

결제 흐름 페이지에도 나와있듯이 결제 흐름은 세 단계로 나눌 수 있다.

  1. 요청
  2. 인증
  3. 승인

문서가 잘 되어 있기 때문에 전체적인 기본 플로우나 각 단계에 대한 설명은 토스 제공 공식 문서를 참고하는 것이 좋은 것 같다.
기본적인 결제 흐름은 위와 같으며 안전한 결제를 위하여 결제 정보를 검증하기 위해 서버를 두어 검증자의 역할을 수행하도록 하였다.

서버를 둔 토스 결제 흐름

  1. 주문 번호 생성 요청: 클라이언트에서 결제 요청 전 주문 번호를 서버에 요청
  2. 구매 요청 검증 및 DB 저장: 서버에서는 주문 번호를 생성하고 받은 정보와 주문 번호를 DB에 저장
  3. 주문 번호 반환: 서버에서 생성한 주문 번호를 클라이언트에게 반환
  4. 결제 요청: 받은 주문 정보로 결제 요청
  5. 결제 인증: 결제 요청을 통해 결제 위젯이 뜨면 결제 인증 진행
  6. 성공 리다이렉트: 결제 인증이 완료되면 결제 정보와 함께 성공 페이지로 리다이렉트
  7. 결제 승인 요청: 성공 페이지로 리다이렉트 되면 서버에 결제 승인 요청
  8. 결제 정보 조회: 서버에서 결제 인증 단계에서 토스페이먼츠에 저장된 결제 정보 조회
  9. 결제/주문 정보 양방향 검증: 서버에서 결제 정보와 클라이언트에서 받은 결제 정보, DB에 저장된 주문 정보 검증
  10. 결제 승인: 결제 정보에 이상이 없다면 토스에 결제 승인 요청
  11. DB 업데이트: 결제 완료로 DB 업데이트
  12. 성공내역 반환: 클라이언트에게 성공 내역 반환
  13. 결제 완료: 클라이언트에서 결제 완료 페이지로 이동

각 과정에 대한 설명을 코드와 함께 아래에서 자세히 설명하도록 하겠다.

1. 주문 번호 생성 요청 - (클라이언트)

Section titled “1. 주문 번호 생성 요청 - (클라이언트)”

실제 결제를 하기 전에 주문 번호를 생성해야 하는데, 토스에서 제공해준 기존 클라이언트 코드에서는 직접 주문 번호를 생성하고 있었다.
이러한 정보는 클라이언트에서 생성하는 것 보단 서버에서 생성하는 것이 더 안전하다고 생각하여 서버에서 주문 번호를 생성하도록 하였다.

const requestData = {
userId: USER_ID, // 인증 처리 생략으로 인해 userId를 직접 넘겨줌
amount: PRICE,
orderProduct: { // 구매하는 상품 정보와 수량
productId: PRODUCT_ID,
quantity: QUANTITY,
},
}
// 주문 번호 생성 요청
const response = await fetch("http://localhost:8080/api/v1/orders/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
})

아래 화면에서 결제하기 버튼을 누르면 실제 결제 요청을 시작하기 전에 서버에 주문 번호를 생성하는 요청을 보내도록 구현하였다.

결제 선택 클라이언트 화면

2. 구매 요청 검증 및 DB 저장 + 3. 주문 번호 반환 - (서버)

Section titled “2. 구매 요청 검증 및 DB 저장 + 3. 주문 번호 반환 - (서버)”

클라이언트에서 주문 번호를 요청하면 서버에서는 주문 번호 반환 뿐만 아니라 구매 상품에 대한 검증과 DB에 저장하는 작업을 수행하게 된다.
수행 작업 및 순서는 주석의 번호를 따라가며 확인할 수 있다.

OrderService.java
public class OrderService {
// ...
@Transactional
public OrderCreateResponse createOrder(OrderCreateRequest orderCreateRequest) {
OrderProduct orderProduct = orderCreateRequest.getOrderProduct();
OrderInfo createdOrder = orderInfoRepository.save(
// 1. toEntity()로 builder()를 호출하여 생성자를 호출
orderCreateRequest.toEntity(
// userId로 사용자 정보 조회
userService.getById(orderCreateRequest.getUserId()),
// productId로 구매 상품 정보 조회
productService.getById(orderProduct.getProductId())
));
return new OrderCreateResponse(createdOrder); // 4. Order ID를 포함한 생성된 주문 정보 반환
}
// ...
}
// OrderInfo.java
@Getter
@Entity
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderInfo extends BaseTime {
// ... 필드 생략
// 2. 생성자 호출
@Builder
protected OrderInfo(/* ... */) {
// ...
this.validateProductInfo(totalAmount, quantity); // 3. 생성 완료 전 상품 정보 검증
}
private void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
this.product.validateStock(quantity); // 상품 재고 검증
// 상품 가격 * 수량 == 결제 금액 검증
BigDecimal totalPrice = this.product.getPrice().multiply(BigDecimal.valueOf(quantity));
if (totalAmount.compareTo(totalPrice) != 0) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_TOTAL_AMOUNT);
}
}
// ...
}

우선 상품 금액과 결제 금액이 일치하는지 검증하고, DB에 저장한다.(여기서 저장한 정보는 이후 승인 요청에서 검증을 위해 사용된다.)
그 후 클라이언트에게 주문 번호를 반환하여 클라이언트가 결제 요청을 할 수 있도록 한다.

4. 결제 요청 + 5. 결제 인증 + 6. 성공 리다이렉트 - (클라이언트)

Section titled “4. 결제 요청 + 5. 결제 인증 + 6. 성공 리다이렉트 - (클라이언트)”
// 위의 클라이언트 로직 이후...
// 반환 된 주문 번호 저장
const orderId = json.orderId;
try {
// 결제 요청 시작
await paymentWidget?.requestPayment({
orderId: orderId,
orderName: ORDER_NAME,
customerName: CUSTOMER_NAME,
customerEmail: CUSTOMER_EMAIL,
successUrl: `${window.location.origin}/success`,
failUrl: `${window.location.origin}/fail`,
});
} catch (error) {
// 에러 처리
console.error(error);
}

주문 번호를 성공적으로 받게 되면 클라이언트에서 결제 정보를 통해 요청을 하게 되고 아래 화면에서 결제 완료 버튼을 누르면 결제 인증이 진행된다.

결제 승인 보내기 전 클라이언트 화면

결제 인증까지 완료된다면 성공 페이지로 리다이렉트 되는데, 이 때 리다이렉트 정보에는 paymentKey, orderId, amount 정보가 포함되어 있다.
각 정보는 결제에 있어 중요한 정보이기 때문에 서버에서 검증 과정을 거치도록 한다.

7. 결제 승인 요청 - (클라이언트)

Section titled “7. 결제 승인 요청 - (클라이언트)”

토스에서 제공해준 클라이언트 코드에서는 성공 페이지로 리다이렉트 되면 결제 승인 요청을 클라이언트에서 직접하도록 되어있다.
하지만 결제 승인 요청을 클라이언트에서 직접 하게 되면 결제 정보를 조작하여 요청을 할 수 있기 때문에 서버에 요청을 보내 검증 과정을 거치도록 하였다.

// 리다이렉트로 받은 결제 정보 데이터를 서버에게 전달하기 위해 데이터 생성
const requestData = {
userId: USER_ID,
orderId: searchParams.get("orderId"),
amount: searchParams.get("amount"),
paymentKey: searchParams.get("paymentKey"),
};
async function confirm() {
// 토스가 아닌 우리가 구축한 서버에 결제 승인 요청
const response = await fetch("http://localhost:8080/api/v1/orders/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
})
const json = await response.json();
// 실패 시 에러 페이지로 이동
if (!response.ok) {
console.log(json);
navigate(`/fail?message=${json.message}`)
return;
}
console.log(json);
}
confirm();

8. 결제 정보 조회 + 9. 결제/주문 정보 검증 + 10. 결제 승인 + 11. DB 업데이트 + 12. 성공내역 반환 - (서버)

Section titled “8. 결제 정보 조회 + 9. 결제/주문 정보 검증 + 10. 결제 승인 + 11. DB 업데이트 + 12. 성공내역 반환 - (서버)”

이제 서버에서는 결제 승인 요청을 받게 되는데, 클라이언트에서 받은 승인 요청 정보와 결제 요청 및 승인을 통해 저장된 토스페이먼츠 결제 정보를 검증하게 된다.
우선 결제 정보 조회 및 승인 요청 코드는 아래와 같다.

@Service
public class PaymentService {
@Value("${spring.myapp.toss-payments.secret-key}")
private String secretKey; // 토스 페이먼츠 인증에 사용되는 시크릿키
@Value("${spring.myapp.toss-payments.api-url}")
private String tossApiUrl; // 토스 페이먼츠 API URL
public TossPaymentResponse getPaymentInfoByOrderId(String orderId) {
return findPaymentInfoByOrderId(orderId)
.orElseThrow(() -> PaymentException.of(PaymentErrorMessage.NOT_FOUND));
}
// 결제 정보 조회
public Optional<TossPaymentResponse> findPaymentInfoByOrderId(String orderId) {
return HttpUtils.requestGetWithBasicAuthorization(
tossApiUrl + "/orders/" + orderId,
EncodeUtils.encodeBase64(secretKey + ":"),
TossPaymentResponse.class);
}
// 결제 승인 요청
public TossPaymentResponse confirmPayment(TossConfirmRequest tossConfirmRequest) {
return HttpUtils.requestPostWithBasicAuthorization(
tossApiUrl + "/confirm",
EncodeUtils.encodeBase64(secretKey + ":"),
tossConfirmRequest,
TossPaymentResponse.class);
}
// ...
}

노출되면 안되는 값들은 application.yaml로 관리하였고, RestTemplate을 사용하여 토스측에 결제 정보 조회 및 승인 요청을 보내도록 하였다.
다음으로 자세한 수행 작업 및 순서는 주석의 번호를 따라가며 확인할 수 있다.

OrderService.java
public class OrderService {
// ...
@Transactional
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. Order ID 저장 때 생성된 주문 정보 조회(+ Pesimistic Lock으로 재고 차감 동시성 제어)
OrderInfo orderInfo = this.getOrderInfoByOrderPessimisticLock(
orderConfirmRequest.getOrderId()
);
// 2. 재고 충분한지 확인 + 재고 차감
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
// 3. 결제 정보 조회
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 저장된 정보 + 클라이언트 요청 정보 + 토스에 저장된 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. 결제 승인
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. OrderInfo 업데이트
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
// 7. 성공내역 반환
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

OrderInfo와 재고를 차감할 Product, 그리고 User에 대해 Lock을 걸어 동시에 재고 차감 및 수정되는 것을 방지하였다.
검증 및 데이터 변경은 OrderInfo 엔티티에서 수행하도록 하였고, 검증하는 정보는 주석에 작성해두었다.

OrderInfo.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderInfo extends BaseTime {
// ...
// (위 코드의 4번에서 호출)저장된 정보 + 클라이언트 요청 정보 + 토스에 저장된 결제 정보 검증
public void validateInProgressOrder(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 주문 상태가 IN_PROGRESS가 아니라면 결제 승인 요청을 할 수 없음
if (!paymentInfo.getStatus().equals(OrderStatus.IN_PROGRESS.getStatusName())) {
throw OrderInfoException.of(OrderInfoErrorMessage.NOT_IN_PROGRESS_ORDER);
}
this.validateOrderInfo(paymentInfo, orderConfirmRequest);
}
// (위 코드의 6번에서 호출)OrderInfo 업데이트
public OrderInfo confirmOrder(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 승인 요청 후 결제 정보가 DONE이 아니라면 결제 승인이 완료되지 않음
if (!paymentInfo.getStatus().equals(OrderStatus.DONE.getStatusName())) {
throw OrderInfoException.of(OrderInfoErrorMessage.NOT_DONE_PAYMENT);
}
this.validateOrderInfo(paymentInfo, orderConfirmRequest);
updateOrderPaymentInfo(paymentInfo); // 결제 정보 업데이트
return this;
}
// 검증 로직, validateInProgressOrder/confirmOrder에서 호출
private void validateOrderInfo(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 저장된 order id == 클라이언트 요청 order id
if (!this.orderId.equals(orderConfirmRequest.getOrderId())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_ORDER_ID);
}
// 저장된 user id == 클라이언트 요청 user id
if (!this.user.getId().equals(orderConfirmRequest.getUserId())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_USER_ID);
}
// 클라이언트 요청 payment key == 토스에 저장된 payment key
if (!paymentInfo.getPaymentKey().equals(orderConfirmRequest.getPaymentKey())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_PAYMENT_KEY);
}
// 클라이언트 요청 amount == 토스에 저장된 total amount == 상품 가격 * 수량
if (!compareAmounts(paymentInfo, orderConfirmRequest)) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_TOTAL_AMOUNT);
}
}
private boolean compareAmounts(
TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest
) {
BigDecimal paymentInfoTotalAmount = BigDecimal.valueOf(paymentInfo.getTotalAmount());
BigDecimal orderConfirmRequestAmount = orderConfirmRequest.getAmount();
BigDecimal orderInfoAmount = this.product.getPrice()
.multiply(BigDecimal.valueOf(this.quantity));
return orderInfoAmount.compareTo(paymentInfoTotalAmount) == 0 &&
orderInfoAmount.compareTo(orderConfirmRequestAmount) == 0 &&
orderConfirmRequestAmount.compareTo(paymentInfoTotalAmount) == 0;
}
// ...
}

코드를 살펴보면 validateOrderInfo가 결국 결제 승인 요청 전/후로 두 번 호출 되는 것을 확인할 수 있다.

  • 요청 전: 아직 검증되지 않은 결제 정보가 불필요하게 결제 승인 요청 되는 것을 방지하기 위해 검증을 수행
  • 요청 후: 승인 요청 후 올바르게 결제 정보를 승인하였는지 다시 한 번 검증

검증 자체는 큰 비용이 아니기 때문에 불필요하게 결제 승인 요청을 보내는 것 보다는 검증 로직을 두 번 호출하는 것이 더 안전하다고 판단하였다.
비슷한 맥락으로 승인 요청 전 결제 정보 조회 API 요청 전에 재고 검증을 먼저 수행하도록 하였다.

결제가 무사히 완료되면 결제 완료 페이지로 이동하게 되면서 결과를 확인할 수 있다.

결제 성공 클라이언트 화면

또한 토스 페이먼츠의 테스트 결제내역에서도 동일한 주문번호가 남아있어 정상적으로 결제가 완료되었음을 확인할 수 있다.

토스 결제 내역 화면

외부 API를 연동하여 통신을 하게 되는 부분에서 여러 문제점들이 발생할 수 있다고 생각하였다.
추후 이러한 문제점들을 개선 방향으로 잡으면 좋을 것 같다.

OrderService.java
public class OrderService {
// ...
@Transactional // 외부 API 2회 요청이 있는 트랜잭션 범위
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
OrderInfo orderInfo = this.getOrderInfoByOrderPessimisticLock(
orderConfirmRequest.getOrderId()
);
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 결제 승인
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 요청이 지연 되는 경우..
if (true)
throw new Exception("test"); // 만약 승인 이후 오류 발생하면?
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

결제 승인 이후 오류가 발생한다면 서버의 데이터베이스에는 전부 롤백이 되지만 토스에는 결제 승인된 내역이 그대로 남아있게 된다.
우선은 변동 가능성이 큰 재고를 가진 Product에 대한 Lock을 얻어 내부에서 오류가 발생할 확률은 낮지만, 해당 케이스에 대한 처리가 필요하다.

결제 승인 단계에서 통신 중 응답이 지연되는 경우가 발생할 수 있다.
우리 서버의 타임 아웃이 5초이고, 토스 API의 응답이 지연되어 6초가 걸렸다고 가정하면 우리 서버는 5초 후에 응답을 받지 못하고 타임 아웃이 발생하게 된다.
하지만 토스사에서는 결제 승인이 완료되었기 때문에 결제가 완료된 것으로 처리되지만 우리 서버에서는 결제 승인이 완료되지 않은 것으로 처리될 수 있다.
이번에도 마찬가지로 데이터베이스는 롤백되지만 토스사에는 결제 승인된 내역이 그대로 남아있게 된다.

현재 트랜잭션 범위가 메서드 전체에 걸려있는데, 해당 메서드 안에 외부 API 요청이 2회가 있기 때문에 트랜잭션의 시간적 범위가 넓어지게 된다.
여기서 API 타임 아웃이 발생하게 되면 락을 획득한 상태에서 계속 대기하게 되는데, 이는 다른 사용자의 요청이 많아질 경우 성능 저하를 발생시킬 수 있다.

@Builder 사용의 여러가지 방법과 안전하게 사용하기

실행 환경: Java 17, Spring Boot 3.1.4

빌더 패턴을 사용하면 객체를 생성할 때 많은 이점을 얻을 수 있다. (링크 참조)
하지만 빌더 패턴을 사용하면 많은 코드를 작성해야 하는 단점이 존재하나 Lombok의 @Builder 어노테이션 을 사용하면 이러한 단점을 보완할 수 있다.(성능 저하라는 단점도 있으나 미미한 편)

Builder를 사용하면 모든 필드를 매개변수로 받는 생성자를 필요로 한다.

** @Builder 어노테이션 내부 설명
* If a member is annotated, it must be either a constructor or a method. If a class is annotated,
* then a package-private constructor is generated with all fields as arguments
* (as if {@code @AllArgsConstructor(access = AccessLevel.PACKAGE)} is present
* on the class), and it is as if this constructor has been annotated with {@code @Builder} instead.
* Note that this constructor is only generated if you haven't written any constructors and also haven't
* added any explicit {@code @XArgsConstructor} annotations. In those cases, lombok will assume an all-args
* constructor is present and generate code that uses it; this means you'd get a compiler error if this
* constructor is not present.

생성자가 없는 경우엔 @Builder 어노테이션에서 자동 생성해주지만, 다른 생성자가 있는 경우엔 @AllArgsConstructor로 생성자를 추가하여 사용할 수 있다.

@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor // @Entity는 빈 생성자(기본 생성자)가 필요
@AllArgsConstructor // @NoArgsConstructor가 있기 때문에 명시적으로 @AllArgsConstructor를 추가
public class OrderInfo extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Builder.Default
@Column(name = "status", nullable = false)
private String status = ORDER_CREATE_STATUS;
// ...필드 및 메서드 생략
// 주문 정보 검증
public void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
}

이 Entity 클래스를 생성하고 데이터베이스에 저장하기 위해 다음과 같이 사용할 수 있다.
주문 정보를 생성하고 검증 후 저장하는 간단한 서비스 코드이다.

dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private final Long userId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.build();
}
}
// service.java
public class OrderService {
// ...
public OrderCreateResponse createOrder(OrderCreateRequest orderCreateRequest) {
// 1. 주문 생성
OrderInfo createdOrderInfo = orderCreateRequest.toEntity(
userService.getById(orderCreateRequest.getUserId()),
productService.getById(orderProduct.getProductId())
);
// 2. 주문 상품 정보 검증
createdOrderInfo.validateProductInfo(
orderCreateRequest.getAmount(),
orderProduct.getQuantity()
);
// 3. 주문 정보 저장
OrderInfo createdOrder = orderInfoRepository.save(createdOrderInfo);
return new OrderCreateResponse(createdOrder);
}
// ...
}

서비스 로직을 살펴보면 크게 세 가지 단계로 나눌 수 있다.

  1. 주문 생성
  2. 주문 상품 정보 검증
  3. 주문 정보 저장

위 코드에서는 2번 단계를 성실하게 수행하여 결과적으로 데이터베이스에 검증이 완료 된 주문 정보만 저장할 수 있게 되었다.

하지만 2번 단계를 생략하여 저장하게 되면 올바르지 않은 주문 정보가 데이터베이스에 저장될 수 있다.(다른 메서드에서 생성하거나 기존 코드가 수정되는 등)
때문에 2번 단계를 서비스 코드에서 수행하는 것이 아닌 Entity 클래스에서 생성할 때 검증하는 것이 더 안전하다고 볼 수 있다.

@Builder 어노테이션을 사용하면서 생성 시 검증 로직을 추가하는 방법으로 두 가지를 생각해볼 수 있다.

  1. build() 메서드를 재작성
  2. 생성자에서 검증 로직 수행

@Builder 어노테이션을 사용하면 build() 메서드를 호출하여 인스턴스를 생성하게 되는데, 이 메서드를 재작성할 수 있다.

entity.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor
@AllArgsConstructor
public class OrderInfo extends BaseTime {
// @Builder.Default가 정상적으로 동작하지 않은 것을 제외하고 위의 코드와 동일
public static class OrderInfoBuilder {
private void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
// build() 메서드 재작성
public OrderInfo build(BigDecimal totalAmount, Integer quantity) {
this.validateProductInfo(totalAmount, quantity);
return new OrderInfo(
this.id,
this.user,
this.product,
this.orderId,
this.paymentKey,
this.orderName,
this.method,
this.quantity,
this.totalAmount,
this.status,
this.requestedAt,
this.approvedAt,
this.lastTransactionKey
);
}
}
}
// dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private static final String ORDER_CREATE_STATUS = "READY";
private final Long userId;
private final String orderId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.orderId(this.orderId)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.status(ORDER_CREATE_STATUS) // @Build.Default 동작하지 않아 직접 추가
.build(amount, this.orderProduct.getQuantity()); // build() 호출 시 필요한 매개변수 추가
}
}

build() 메서드를 호출 할 때 필요한 매개변수를 추가하여 검증 로직을 수행할 수 있게 되었지만, 많은 단점이 생겼다.

  1. @Builder.Default가 정상적으로 동작하지 않아 기본 값을 직접 할당해야 함
  2. 해당 클래스의 생성자를 직접 호출하는 경우 검증 로직이 수행되지 않음
  3. 코드의 양이 많아지고 굉장히 복잡해짐

2번의 문제 같은 경우엔 @AllArgsConstructor(access = PROTECTED)를 사용하여 외부에서 생성자를 호출하지 못하도록 제한할 수 있지만,
1, 3번의 문제는 여전히 존재하고, 2번의 문제도 여전히 내부에서 생성자를 호출하는 경우에는 검증 로직이 수행되지 않는다.

위의 문제를 해결하기 위해 더 간단하고 안전한 방법은 생성자를 직접 추가하는 것이다.

entity.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 외부에서의 생성자 호출 방지
// @AllArgsConstructor 제거
public class OrderInfo extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Builder.Default
@Column(name = "status", nullable = false)
private String status = ORDER_CREATE_STATUS;
// ...필드 및 메서드 생략
@SuppressWarnings("java:S107") // 파라미터 개수 8개 이상 경고 무시
protected OrderInfo(Long id, User user, Product product, String orderId, String paymentKey,
String orderName, String method, Integer quantity, BigDecimal totalAmount,
String status, LocalDateTime requestedAt, LocalDateTime approvedAt,
String lastTransactionKey) {
this.id = id;
this.user = user;
this.product = product;
this.orderId = orderId;
this.paymentKey = paymentKey;
this.orderName = orderName;
this.method = method;
this.quantity = quantity;
this.totalAmount = totalAmount;
this.status = status;
this.requestedAt = requestedAt;
this.approvedAt = approvedAt;
this.lastTransactionKey = lastTransactionKey;
this.validateProductInfo(totalAmount, quantity); // 모든 값이 할당된 후 검증 로직 수행
}
public void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
}
// dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private final Long userId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
// 간결했던 가장 처음의 코드로 복귀
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.build();
}
}

이 방법을 사용하면 위애서 언급 된 문제를 해결할 수 있다.

  1. @Builder.Default가 정상적으로 동작
  2. 해당 클래스의 생성자를 직접 호출하는 경우에도 검증 로직이 수행
  3. 생성자 추가로 코드의 양은 비슷하지만, build() 메서드를 재작성하는 것보다는 코드가 간결해짐

이렇게 함으로써 객체가 어느 시점에 생성되든 검증 로직이 수행되도록 할 수 있게 되었다.
추가적으로 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하여 기본 생성자를 외부에서 호출하지 못하도록 제한하였다.

@Builder 어노테이션은 생성자에도 사용할 수 있는데, 이 방식을 사용해 꼭 필요한 매개변수만 받을 수 있도록 할 수 있다.

@Getter
@Entity
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderInfo extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Column(name = "status", nullable = false)
private String status;
// ...필드 및 메서드 생략
@Builder
protected OrderInfo(User user, Product product, Integer quantity, BigDecimal totalAmount) {
this.user = user;
this.product = product;
this.quantity = quantity;
this.totalAmount = totalAmount;
// Builder.Default 대신 직접 할당
this.orderId = generateOrderId();
this.status = OrderStatus.READY.getStatusName();
this.validateProductInfo(totalAmount, quantity);
}
public void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
}

이 방식을 사용하면 불필요한 매개변수까지 포함되는 것을 방지하는 것 뿐만 아니라, 어떤 필드가 생성 시 전달 받고 기본 값으로 할당되는지도 명확하게 알 수 있다.

@Builder 어노테이션을 사용하는 경우 롬복을 사용하고 있기 때문에 무의식적으로 @AllArgsConstructor를 사용할 수 있다.
@AllArgsConstructor를 사용하면 모든 필드를 매개변수로 받는 생성자가 생성 될 뿐만 아니라 생성 시 검증 로직을 수행할 수 없게 된다.
때문에 생성자에 @Builder 어노테이션을 사용하여 꼭 필요한 파라미터만 받을 수 있도록 하고, 생성 시 검증 로직을 수행할 수 있도록 하는 것이 좋다.

BeanCreationException 예외로 알아보는 빈 생명주기

실행 환경: Java 17, Spring Boot 3.1.4

스프링 부트로 커맨드 라인 애플리케이션을 만들던 중, csv 관련 에러 테스트 중 예외 처리가 의도하지 않은 방향으로 흘러가는 것을 발견했다.

우선 아래는 애플리케이션을 실행하고 유지하는 CommandLineRunner 인터페이스를 구현한 CommandLineExecutor 클래스이며,
애플리케이션 실행 및 정책은 다음과 같이 설정하였다.

  • RuntimeException 발생 시: warning 로그를 남기고 실행 상태 유지
  • Exception 발생 시: error 로그를 남기고 실행 종료
@Slf4j
@Component
@RequiredArgsConstructor
public class CommandLineExecutor implements CommandLineRunner {
private final ConsoleIOHandler consoleIOHandler;
private final FunctionHandler functionHandler;
private boolean isRunning = true;
@Override
public void run(String... args) {
while (isRunning) {
progress();
}
}
private void progress() {
try {
consoleIOHandler.printMenuTitle(ConsoleConstants.VOUCHER_PROGRAM_START_MESSAGE);
consoleIOHandler.printEnumString(Function.class);
String command = consoleIOHandler.getInputWithPrint();
Function.fromString(command)
.ifPresentOrElse(
function -> function.execute(functionHandler),
() -> {
throw InputException.of(InputErrorMessage.INVALID_COMMAND);
});
} catch (RuntimeException e) {
log.warn(e.getMessage());
} catch (Exception e) {
isRunning = false;
log.error(Arrays.toString(e.getStackTrace()));
}
}
}

다음으로는 csv 파일을 읽고 쓰는 로직인 CsvFileHandler 클래스이며, 호출 시점 및 에러 처리는 아래와 같이 구현하였다.

// CsvCustomerRepository.java: @PostConstruct와 @PreDestroy를 통해 빈 생성 및 소멸될 때 CsvFileHandler 클래스의 파일 입출력 메서드 호출
@Profile("default")
@Repository
public class CsvCustomerRepository implements CustomerRepository {
private final Map<UUID, Customer> customerDatabase = new ConcurrentHashMap<>();
// ...
@PostConstruct
public void init() {
Function<String[], Customer> parser = line -> { /* ... */ };
List<Customer> customers = csvFileHandler.readListFromCsv(
parser,
CSV_LINE_TEMPLATE
); // CSV 파일 읽기
customers.forEach(customer -> customerDatabase.put(customer.getId(), customer));
}
@PreDestroy
public void destroy() {
List<Customer> customers = customerDatabase.values()
.stream()
.toList();
Function<Customer, String> serializer = customer -> { /* ... */ };
csvFileHandler.writeListToCsv(customers, serializer); // CSV 파일 쓰기
}
}
// CsvFileHandler.java: 파일 입출력 처리 로직, R/W 중 IOException이 발생하면 RuntimeException을 상속 받은 사용자 정의 예외로 변환하여 throw
public class CsvFileHandler {
private static final String CSV_DELIMITER = ",";
private final String filePath;
// ...
public <T> List<T> readListFromCsv(Function<String[], T> parser, String csvLineTemplate) {
List<T> itemList = new ArrayList<>();
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
while (true) {
String line = bufferedReader.readLine();
if (line == null) {
break;
}
String[] parts = line.split(CSV_DELIMITER);
itemList.add(parser.apply(parts));
}
} catch (IOException e) {
throw FileException.of(
FileErrorMessage.IO_EXCEPTION
); // IOException 발생 시 사용자 정의 예외로 변환하여 throw
}
return itemList;
}
public <T> void writeListToCsv(List<T> itemList, Function<T, String> serializer) {
try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(filePath))) {
for (T item : itemList) {
String csvLine = serializer.apply(item);
bufferedWriter.write(csvLine);
bufferedWriter.newLine();
}
} catch (IOException e) {
throw FileException.of(
FileErrorMessage.IO_EXCEPTION
); // IOException 발생 시 사용자 정의 예외로 변환하여 throw
}
}
}

우선 애플리케이션 실행 중에 파일 경로에 파일 명을 수정하여 존재하지 않는 파일을 읽도록 하여 IOException이 발생하도록 했다.
의도한 대로 사용자 정의 에러가 발생하고 CommandLineExecutor에서 예외를 처리하여 정의한 메시지가 warning 로그로 남은 뒤 애플리케이션이 계속 유지됐다.

2023-10-24 23:36:51.326 [main] WARN d.s.commandline.CommandLineExecutor -- An error occurred during file input/output operations.

이번에는 애플리케이션 시작 전 파일명을 잘못 입력하여 애플리케이션 초기화 중에 IOException이 발생하도록 했다.
이번에는 CommandLineExecutor에서 예외를 처리하지 못하고 애플리케이션이 바로 종료되었고, 아래 로그가 출력되었다.

2023-10-24 23:06:39.147 [main] WARN o.s.c.a.AnnotationConfigApplicationContext -- Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'commandLineExecutor' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/CommandLineExecutor.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
2023-10-24 23:06:39.169 [main] ERROR o.s.boot.SpringApplication -- Application run failed
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'commandLineExecutor' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/CommandLineExecutor.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: springbootbasic.exception.FileException: An error occurred during file input/output operations.
...

로그를 살펴보면 직접 정의한 FileException은 가장 마지막 라인에 존재하고, 그 위엔 BeanCreationException가 존재하여 빈 생성 중 발생한 예외로 추측 할 수 있다.
빈 관련 에러라는 것을 확인했으니 빈 생명주기를 살펴보자.

  1. 스프링 컨테이너 생성
  2. 스프링 빈 생성
  3. 의존 관계 주입
  4. 초기화 콜백
  5. 사용(실제 애플리케이션(빈) 동작 단계)
  6. 소멸 전 콜백
  7. 스프링 종료

여기서 파일을 읽어오는 단계는 @PostConstruct는 4번 초기화 콜백에 해당하며, 빈이 생성되고, 빈의 의존관계 주입이 완료된 후 호출된다.
하지만 CommandLineExecutor가 동작하는 순간은 5번 사용 단계에 해당하기 때문에, 애초에 해당 에러를 처리하지 못하는 것이다.
그 흐름을 자세히 살펴보면 아래와 같다.

  1. 애플리케이션 시작 전 파일명 잘못 입력
  2. 빈 초기화 중 @PostConstruct 애노테이션을 통해 CsvCustomerRepositoryinit() 메서드 호출
  3. init() 메서드에서 CsvFileHandlerreadListFromCsv() 메서드 호출
  4. CsvFileHandler 내부에서 IOException 발생
  5. FileException으로 변환하여 throw
  6. @PostContruct 애노테이션에서 발생한 빈 초기화 중 발생한 예외이기 때문에 BeanCreationException으로 감싸져서 throw
  7. 애플리케이션 초기화 중 발생했기 때문에 CommandLineExecutor이 동작하기 전에 예외가 발생

사실 어찌보면 너무나 당연한 지식을 기반한 내용이지만, Spring의 여러 기능을 사용하게 되면서 생각하지 못한(의도하지 않은) 경로로 예외가 흘러가는 것을 확인할 수 있었다.
다시 한 번 빈 생명주기에 대해 공부할 수 있었고, 그 흐름을 이해하는 것이 중요하다는 것을 깨달았다.
만약 BeanCreationException이 발생하면, 빈이 생성되는 과정에서 문제가 있는 것이므로 빈 생명주기를 생각하면서 디버깅을 해보자.

`null`은 오버 로딩된 메서드 중 어떤 메서드를 호출할까?

실행 환경: Java 17

우선 해당 주제를 본격적으로 다루기 전에 아래의 코드를 보자. 아래 코드는 null을 참조하는 변수를 사용했을 때와, 리터럴 null을 사용했을 때 String.valueOf() 메서드의 동작을 보여준다.

class NullTest {
public static void main(String[] args) {
String s = null;
String nullValue = String.valueOf(s);
System.out.println(nullValue); // null, 정상 출력 ---- 1
nullValue = String.valueOf(null); // NullPointerException ---- 2
System.out.println(nullValue);
}
}

1번 코드는 정상적으로 null을 출력하지만, 2번 코드는 NullPointerException이 발생하는 것을 확인할 수 있다. 디버깅 모드를 통해 호출 된 메서드를 추적했을 때, 두 라인은 서로 다른 메서드를 호출하고 있음을 알 수 있었다.

java.lang.String
public final class String {
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
}

String.valueOf(s)String.valueOf(Object obj)를 호출하는데, 이 메서드는 null 체크를 하는 것을 확인할 수 있다. 때문에 넘겨 받은 obj 값이 null이기 때문에 자연스럽게 "null"을 반환하게 된다.

하지만 직접 null을 넘겨 받은 경우에는 String.valueOf(char data[])를 호출하게 된다.

java.lang.String
public final class String {
public String(char value[]) {
this(value, 0, value.length, null); // 3. value.length에서 NullPointerException 발생
// Exception in thread "main" java.lang.NullPointerException: Cannot read the array length because "value" is null
}
// 1. 메서드 호출 받음
public static String valueOf(char data[]) {
return new String(data); // 2. 위의 String(char value[])를 호출
}
}

주석의 순번대로 코드가 실행되는데, 결국 3번에서 null인 값에서 length를 읽으려고 하기 때문에 NullPointerException이 발생하게 된다. 그렇다면 왜 nullchar data[] 타입이 아닌데 해당 메서드가 호출되는 것일까?

호출 메서드는 어떻게 결정되는가?

Section titled “호출 메서드는 어떻게 결정되는가?”

위 상황과 비슷하게 char data[] 타입과 Object o 타입을 파라미터로 갖는 메서드를 호출했을 때 어떤 메서드가 호출되는지 확인해보자.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // char[] Param Method Called
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
}

처음의 예제 코드와 같이 char[] Param Method Called가 출력된다. 그럼 nullchar[]과 특수한 관계가 있는 것일까? 그것도 아니다.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // Object Param Method Called
}
// public static void testMethod(char data[]) {
// System.out.println("char[] Param Method Called");
// }
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
}

char data[] 타입의 메서드를 주석 처리하고 실행해보면 Object Param Method Called가 출력된다. 즉, nullchar[]과 특수한 관계가 있는 것이 아니라, Object 보다는 char[] 타입이 더 높은 우선순위를 가진다고 추측해 볼 수 있다. 다른 타입들도 더 살펴보자.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // no suitable method found for testMethod(<nulltype>)
}
public static void testMethod(int i) {
System.out.println("int Param Method Called");
}
public static void testMethod(long i) {
System.out.println("Integer Param Method Called");
}
// ...
// char, byte, short, int, long, float, double, boolean
}

당연하게도 원시 타입은 null을 가질 수 없기 때문에 일치하는 메서드가 없다는 에러가 발생한다. 그럼 null이 호출할 수 있는 메서드는 참조(주소) 타입 인자의 메서드만 호출할 수 있는 것으로 추측할 수 있다.

class Test {
int x;
int y;
}
class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // reference to testMethod is ambiguous
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Test t) {
System.out.println("Test Param Method Called");
}
public static void testMethod(String s) {
System.out.println("String Param Method Called");
}
public static void testMethod(int... i) {
System.out.println("int... Param Method Called");
}
public static void testMethod(Integer i) {
System.out.println("Integer Param Method Called");
}
}

위의 메서드들은 단일로 존재했을 때 전부 호출 될 수 있는 메서드들인데, 동시에 존재하는 경우 호출할 수 있는 메서드가 많아 모호하다는 에러 메시지가 발생한다.

reference to testMethod is ambiguous
both method testMethod(int...) in MethodCallTest and method testMethod(java.lang.Integer) in MethodCallTest match

그 중 int ...i 타입과 Integer i 두 개의 메서드에 대해 언급하면서 에러 메시지가 발생했는데, 두 타입이 더 null과 관련이 있는 것일까? 아니다, 그 이유는 다시 아래의 코드와 에러 메시지를 보면 알 수 있다.

class Test {
int x;
int y;
}
class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // reference to testMethod is ambiguous
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
public static void testMethod(int... i) {
System.out.println("int... Param Method Called");
}
public static void testMethod(Integer i) {
System.out.println("Integer Param Method Called");
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Test t) {
System.out.println("Test Param Method Called");
}
public static void testMethod(String s) {
System.out.println("String Param Method Called");
}
}
reference to testMethod is ambiguous
both method testMethod(Test) in MethodCallTest and method testMethod(java.lang.String) in MethodCallTest match

에러 메시지를 다시 살펴 보면 Test 타입과 String 타입이 언급되는 것을 볼 수 있다. 결국 특정 타입이 아닌, 완전히 일치하는 타입이 없어 null을 호출 할 수 있는 메서드를 탐색하게되고, 마지막 두 타입에 대해 모호하다는 에러 메시지가 발생한 것으로 추측해 볼 수 있다.

  1. null은 원시 타입을 제외한 모든 타입의 인자로 호출 당할 수 있다.
  2. nullObject 타입으로도 호출 당할 수 있다.
  3. Object가 아닌 참조 타입 메서드가 존재하면 더 높은 우선순위를 가진다.
  4. 만약 Object 타입을 제외한 참조 타입 메서드가 두 개 이상 존재하면 refrence to method is ambiguous 에러가 발생하게 된다.
  5. 에러 메시지는 null을 호출 할 수 있는 메서드를 탐색하다가 마지막 두 가지 타입에 대해 모호하다는 에러 메시지가 발생한 것으로 추측해 볼 수 있다.

다소 애매한 결과라고 볼 수 있지만, 사실 null 타입을 그대로 넣는 일은 거의 없기 때문에 이러한 상황은 잘 발생하지 않을 것이라고 생각한다. 다시 한 번 null을 사용할 때는 주의 해야 한다는 것을 체감할 수 있었고, null 자체를 파라미터로 넘기는 것을 지양하는 것이 좋을 것 같다.