Spring 개발일지

[팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 서비스 코드 리팩토링

김둘리 2026. 5. 25. 09:06

코드 리팩토링을 통해 내부 API 응답 구조 개선, 환경별 URL 분리, 환불 멱등성 처리를 적용했다.


1. 내부 호출 APIResponse 제거

기존에는 내부 서비스 간 통신에도 ApiResponse로 감싸서 응답했다.

// 기존
@PostMapping
public ResponseEntity<ApiResponse<PaymentResponse>> createPayment(
    @RequestBody @Valid PaymentCreateRequest request) {
    return ApiResponse.success(PaymentSuccessCode.PAYMENT_CREATED, response);
}

Booking 서비스가 Feign Client로 호출할 때 ApiResponse를 벗겨서 써야 하는 번거로움이 있었다.

내부 API는 바로 데이터만 반환하도록 변경했다.

// 변경 후
@PostMapping
public ResponseEntity<PaymentResponse> createPayment(
    @RequestBody @Valid PaymentCreateRequest request) {
    PaymentResponse response = PaymentResponse.from(
        paymentCommandService.createPayment(request.toCommand())
    );
    return ResponseEntity.ok(response);
}

외부 API는 ApiResponse를 유지하고 내부 API만 제거했다.


2. 결제 성공/실패 URL 환경별 분리

토스 결제창의 successUrl, failUrl이 하드코딩되어 있었다.

// 기존 - 하드코딩
successUrl: "http://localhost:8084/api/v1/payments/confirm-redirect",
failUrl: "http://localhost:8084/fail"

로컬, 운영 환경마다 주소가 달라서 배포할 때마다 코드를 수정해야 했다.

config-repo에서 환경별로 관리하도록 변경했다.

# payment-service-local.yml
payment:
  success-url: http://localhost:8084/api/v1/payments/confirm-redirect
  fail-url: http://localhost:8084/fail

# payment-service-prod.yml
payment:
  success-url: ${PAYMENT_SUCCESS_URL}
  fail-url: ${PAYMENT_FAIL_URL}
@Value("${payment.success-url}")
private String successUrl;

@Value("${payment.fail-url}")
private String failUrl;

운영 환경에서는 환경변수로 주입하니까 코드 변경 없이 배포할 수 있다.


3. 재결제 시간 하드코딩 제거

결제 실패 시 재시도 가능 시간이 하드코딩되어 있었다.

// 기존
payment.fail(300); // 하드코딩

config-repo 공통 파일에서 관리하도록 변경했다.

# payment-service.yml (공통)
payment:
  retry-ttl-seconds: 300  # 추후 Booking 담당자 협의 후 조정 예정
@Value("${payment.retry-ttl-seconds}")
private int retryTtlSeconds;

payment.fail(retryTtlSeconds);

추후 좌석 선점 시간과 맞춰서 값만 바꾸면 되니까 코드 수정이 필요없다.


4. 환불 멱등성 처리

booking.payment.refund 이벤트가 중복 수신될 경우 환불이 두 번 처리될 수 있었다.

별도 인박스 테이블을 만드는 대신 Payment 상태로 간단하게 처리했다.

// 2. 본인 확인 (먼저 수행)
if (!payment.getUserId().equals(command.userId())) {
    throw new PaymentException(PaymentErrorCode.PAYMENT_FORBIDDEN);
}

// 3. 이미 환불된 결제면 그냥 반환 (멱등성)
if (payment.getStatus() == PaymentStatus.REFUNDED) {
    log.info("이미 환불된 결제 - paymentId: {}", command.paymentId());
    return PaymentResult.from(payment);
}

코드래빗이 중요한 순서 문제를 지적했다.

처음에 멱등성 체크를 본인 확인보다 앞에 뒀는데 다른 사용자의 paymentId를 알면 PAYMENT_FORBIDDEN 없이 결제 정보를 받을 수 있는 보안 취약점이 있었다.

본인 확인을 먼저 하고 그 다음에 멱등성 체크하도록 순서를 바꿨다.


마무리

리팩토링의 핵심은 작은 것들을 제대로 고치는 것이다.

  • 하드코딩 → 설정값으로
  • 내부/외부 API 응답 구조 분리
  • 보안 순서 고려

코드래빗 리뷰 덕분에 보안 취약점을 잡을 수 있었다.