Skip to content

[FIX] 결제 멱등성 보장#357

Open
unifolio0 wants to merge 1 commit into
developfrom
fix/error
Open

[FIX] 결제 멱등성 보장#357
unifolio0 wants to merge 1 commit into
developfrom
fix/error

Conversation

@unifolio0
Copy link
Copy Markdown
Contributor

closed #

작업 내용

스크린샷

참고 사항

@unifolio0 unifolio0 self-assigned this May 25, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

Review Change Stack

Summary by CodeRabbit

릴리스 노트

  • Bug Fixes
    • 결제 확인 시 동일한 요청이 재호출되는 경우를 안전하게 처리합니다. 이미 승인 또는 완료된 결제는 외부 API를 다시 호출하지 않고 저장된 데이터로 응답하여 멱등성을 보장합니다.
    • 중복 결제 요청 시 발생하는 데이터 무결성 오류를 적절히 처리합니다.

Walkthrough

이 PR은 결제 확인 API의 멱등성을 구현합니다. 동일한 paymentKey로 재요청 시 발생하는 중복 데이터 제약 조건 위반 예외를 감지하고, 기존 결제 상태를 확인하여 적절한 응답을 반환합니다.

Changes

결제 멱등성 구현

Layer / File(s) Summary
결제 상태 판별 유틸
src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java, src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java
PaymentState 열거형과 TosspaymentsPayment 도메인에 isApprovedOrCompleted()isClientBadRequest() 메서드를 추가하여 상태 판별을 간편히 합니다.
결제 결과 서비스 접근
src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java
Optional을 반환하는 findByTosspaymentsPaymentId 공개 메서드를 추가하여 TosspaymentsPaymentResult를 조회합니다.
PaymentResponse 팩토리 메서드
src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java
기존 결제와 결과 정보에서 PaymentResponse를 구성하는 fromExisting 정적 팩토리를 추가합니다.
결제 확인 멱등성 처리
src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java
confirmPayment 메서드의 저장 단계에 DataIntegrityViolationException 처리를 추가하고, 기존 결제 상태를 판별하여 멱등 응답을 반환하거나 적절한 예외를 발생시키는 헬퍼 메서드들을 구현합니다.
결제 확인 멱등성 테스트
src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java
confirmPayment의 재호출 시나리오를 검증하는 테스트들을 추가하여 상태별 응답 및 외부 호출 억제를 확인합니다.
토큰 구매 멱등성 테스트
src/test/java/com/samhap/kokomen/token/service/TokenFacadeServiceTest.java
동일 paymentKey로 purchaseTokens를 2회 호출 시 멱등성이 보장되는지 검증하는 테스트를 추가합니다.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested reviewers

  • nak-honest
  • kargowild

Poem

🐰 중복된 요청도 이제 문제없지,
멱등성의 마법으로 안전하게!
같은 열쇠로 몇 번 불러도,
결제는 한 번만 이루어지고,
토큰도 차곡차곡 쌓여간다네. ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR 설명은 완전히 비어있고 작업 내용, 스크린샷, 참고 사항 등이 모두 누락되어 있어 변경 사항에 대한 의미 있는 정보를 제공하지 않습니다. PR 설명에 변경의 목적, 멱등성 보장 방식, 관련된 이슈 번호, 그리고 테스트 검증 내용 등의 구체적인 정보를 작성하여 검토자의 이해를 돕도록 합니다.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 "[FIX] 결제 멱등성 보장"으로 변경 내용의 핵심을 명확하게 반영하고 있으며, 결제 멱등성 구현에 관한 여러 파일의 수정사항과 일치합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/error

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

본 PR은 결제 승인 과정에서 발생할 수 있는 중복 요청 문제를 해결하기 위해 멱등성을 보장하는 로직을 도입했습니다. 데이터 무결성 예외를 활용하여 이미 처리된 결제에 대한 재요청을 감지하고, 기존 결제 상태에 따라 적절한 응답을 반환하거나 예외를 던짐으로써 시스템의 안정성을 높였습니다.

Highlights

  • 결제 멱등성 보장: 결제 승인 요청 시 동일한 paymentKey로 재요청이 들어올 경우, 기존 결제 정보를 반환하여 중복 결제를 방지하도록 로직을 개선했습니다.
  • 예외 처리 강화: 결제 상태에 따라 이미 승인된 결제는 결과를 반환하고, 클라이언트 오류 발생 시 적절한 메시지를 전달하며, 비정상 상태 재요청 시 서버 오류를 발생시키도록 처리했습니다.
  • 테스트 코드 추가: 다양한 결제 상태에서의 재요청 시나리오와 토큰 구매 시의 멱등성 보장을 검증하는 테스트 케이스를 추가했습니다.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.


결제 요청 중복되어, 멱등성으로 막아내니, 데이터는 안전하고, 사용자도 안심하네.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions
Copy link
Copy Markdown

Test Results

 51 files   51 suites   1m 36s ⏱️
298 tests 297 ✅ 1 💤 0 ❌
300 runs  299 ✅ 1 💤 0 ❌

Results for commit 2014c72.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java`:
- Around line 48-50: The catch for DataIntegrityViolationException in
PaymentFacadeService indiscriminately treats all constraint violations as
idempotent "same paymentKey" retries; update the handler to inspect the
exception cause (e.g., exception.getMostSpecificCause() / message or
SQLState/code) to detect whether the unique constraint violated is the
payment_key constraint and only then call
resolveExistingPayment(tosspaymentsPaymentService.readByPaymentKey(request.paymentKey()));
for other constraint violations (like order_id) rethrow or handle separately
(log full exception details and propagate or map to a proper error), and ensure
the log includes the exception info to aid debugging.

In
`@src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java`:
- Around line 325-336: The test
CLIENT_BAD_REQUEST_상태에_failure_정보가_없으면_기본_메시지로_BadRequestException을_던진다()
currently only asserts the thrown BadRequestException message but misses
verifying idempotency by ensuring external client is not invoked; update the
test to also verify that tosspaymentsClient.confirmPayment(...) was never called
(e.g., verify(tosspaymentsClient, times(0)).confirmPayment(...)) after calling
paymentFacadeService.confirmPayment(request), keeping existing setup that sets
payment state to PaymentState.CLIENT_BAD_REQUEST and saves via
tosspaymentsPaymentRepository.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 91d0a883-1724-48e8-a701-7c1a923cd84c

📥 Commits

Reviewing files that changed from the base of the PR and between d777ff7 and 2014c72.

📒 Files selected for processing (7)
  • src/main/java/com/samhap/kokomen/payment/domain/PaymentState.java
  • src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java
  • src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java
  • src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java
  • src/main/java/com/samhap/kokomen/payment/service/dto/PaymentResponse.java
  • src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java
  • src/test/java/com/samhap/kokomen/token/service/TokenFacadeServiceTest.java

Comment on lines +48 to +50
} catch (DataIntegrityViolationException e) {
log.info("동일 paymentKey 결제 재요청 - 멱등 분기 처리, paymentKey: {}", request.paymentKey());
return resolveExistingPayment(tosspaymentsPaymentService.readByPaymentKey(request.paymentKey()));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

DataIntegrityViolationException 전체를 멱등 재요청으로 처리하면 오분류됩니다.

현재 분기는 모든 DataIntegrityViolationException을 동일 paymentKey 재요청으로 가정합니다. 하지만 TosspaymentsPaymentorder_id도 unique라서 다른 제약 위반까지 멱등 분기로 흡수될 수 있습니다(잘못된 상태 조회/응답 위험).

🔧 제안 수정안
 try {
     tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request);
 } catch (DataIntegrityViolationException e) {
-    log.info("동일 paymentKey 결제 재요청 - 멱등 분기 처리, paymentKey: {}", request.paymentKey());
-    return resolveExistingPayment(tosspaymentsPaymentService.readByPaymentKey(request.paymentKey()));
+    TosspaymentsPayment existing = tosspaymentsPaymentService.findByPaymentKey(request.paymentKey())
+            .orElseThrow(() -> {
+                log.error("결제 저장 무결성 위반 - paymentKey로 기존 결제를 찾지 못했습니다. paymentKey: {}", request.paymentKey(), e);
+                return e;
+            });
+    log.info("동일 paymentKey 결제 재요청 - 멱등 분기 처리, paymentKey: {}", request.paymentKey());
+    return resolveExistingPayment(existing);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java`
around lines 48 - 50, The catch for DataIntegrityViolationException in
PaymentFacadeService indiscriminately treats all constraint violations as
idempotent "same paymentKey" retries; update the handler to inspect the
exception cause (e.g., exception.getMostSpecificCause() / message or
SQLState/code) to detect whether the unique constraint violated is the
payment_key constraint and only then call
resolveExistingPayment(tosspaymentsPaymentService.readByPaymentKey(request.paymentKey()));
for other constraint violations (like order_id) rethrow or handle separately
(log full exception details and propagate or map to a proper error), and ensure
the log includes the exception info to aid debugging.

Comment on lines +325 to +336
void CLIENT_BAD_REQUEST_상태에_failure_정보가_없으면_기본_메시지로_BadRequestException을_던진다() {
TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder()
.paymentKey("payment_key")
.build();
payment.updateState(PaymentState.CLIENT_BAD_REQUEST);
tosspaymentsPaymentRepository.save(payment);
ConfirmRequest request = createConfirmRequest();

assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request))
.isInstanceOf(BadRequestException.class)
.hasMessage(PaymentServiceErrorMessage.INVALID_REQUEST.getMessage());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

멱등성 핵심 검증(외부 호출 억제) 조건이 빠져 있습니다.

Line 325-336 테스트는 예외 메시지만 확인하고 tosspaymentsClient.confirmPayment 미호출을 확인하지 않아, 멱등성 회귀를 놓칠 수 있습니다. 외부 호출 0회를 함께 검증해 주세요.

제안 수정
     assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request))
             .isInstanceOf(BadRequestException.class)
             .hasMessage(PaymentServiceErrorMessage.INVALID_REQUEST.getMessage());
+    verify(tosspaymentsClient, times(0)).confirmPayment(any(), any());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java`
around lines 325 - 336, The test
CLIENT_BAD_REQUEST_상태에_failure_정보가_없으면_기본_메시지로_BadRequestException을_던진다()
currently only asserts the thrown BadRequestException message but misses
verifying idempotency by ensuring external client is not invoked; update the
test to also verify that tosspaymentsClient.confirmPayment(...) was never called
(e.g., verify(tosspaymentsClient, times(0)).confirmPayment(...)) after calling
paymentFacadeService.confirmPayment(request), keeping existing setup that sets
payment state to PaymentState.CLIENT_BAD_REQUEST and saves via
tosspaymentsPaymentRepository.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements idempotency for the payment confirmation process by handling DataIntegrityViolationException during payment record creation. It introduces logic to resolve existing payments based on their current state, returning cached responses for successful payments or throwing specific exceptions for client-side failures. Feedback from the reviewer suggests strengthening this implementation by validating that the incoming request details (orderId and amount) match the existing record to prevent incorrect idempotent responses. Additionally, the reviewer recommends allowing the confirmation process to proceed if the payment is in a transient state such as NEED_APPROVE or CONNECTION_TIMEOUT.

Comment on lines +46 to +51
try {
tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request);
} catch (DataIntegrityViolationException e) {
log.info("동일 paymentKey 결제 재요청 - 멱등 분기 처리, paymentKey: {}", request.paymentKey());
return resolveExistingPayment(tosspaymentsPaymentService.readByPaymentKey(request.paymentKey()));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

결제 승인 요청 중 DataIntegrityViolationException이 발생했을 때, 단순히 기존 결제 정보를 조회하여 반환하는 것만으로는 멱등성을 완벽하게 보장하기 어렵습니다.

  1. 재시도 허용: 기존 결제 상태가 NEED_APPROVECONNECTION_TIMEOUT인 경우, 이는 아직 토스페이먼츠 API 호출이 성공하지 않았거나 네트워크 오류로 중단된 상태임을 의미합니다. 이 경우 예외를 던지지 않고 승인 프로세스를 계속 진행할 수 있도록 처리해야 합니다.
  2. 요청 정보 검증: 멱등성 처리를 위해 기존 결제를 조회했을 때, 요청된 orderIdamount가 DB에 저장된 정보와 일치하는지 반드시 확인해야 합니다. 정보가 다를 경우 잘못된 멱등 요청으로 간주하고 예외를 발생시켜야 합니다.
        try {
            tosspaymentsPayment = tosspaymentsPaymentService.saveTosspaymentsPayment(request);
        } catch (DataIntegrityViolationException e) {
            log.info("동일 paymentKey 결제 재요청 - 멱등 분기 처리, paymentKey: {}", request.paymentKey());
            tosspaymentsPayment = tosspaymentsPaymentService.readByPaymentKey(request.paymentKey());
            PaymentResponse existingResponse = resolveExistingPayment(request, tosspaymentsPayment);
            if (existingResponse != null) {
                return existingResponse;
            }
        }

Comment on lines +66 to +79
private PaymentResponse resolveExistingPayment(TosspaymentsPayment payment) {
if (payment.isApprovedOrCompleted()) {
log.info("이미 승인된 결제 재요청 - 멱등 응답 반환, paymentKey: {}, state: {}",
payment.getPaymentKey(), payment.getState());
TosspaymentsPaymentResult result = tosspaymentsPaymentResultService.readByTosspaymentsPaymentId(payment.getId());
return PaymentResponse.fromExisting(payment, result);
}
if (payment.isClientBadRequest()) {
log.info("클라이언트 원인 실패 결제 재요청 - paymentKey: {}", payment.getPaymentKey());
throw new BadRequestException(readClientFailureMessage(payment.getId()));
}
log.error("비정상 상태 결제 재요청 - paymentKey: {}, state: {}", payment.getPaymentKey(), payment.getState());
throw new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

resolveExistingPayment 메서드에서 NEED_APPROVECONNECTION_TIMEOUT 상태를 처리하여 승인 프로세스를 재시작할 수 있도록 개선이 필요합니다. 또한, 요청 정보(orderId, amount)에 대한 검증 로직을 추가하여 보안 및 정합성을 강화해야 합니다.

    private PaymentResponse resolveExistingPayment(ConfirmRequest request, TosspaymentsPayment payment) {
        if (!payment.getOrderId().equals(request.orderId()) || !payment.getTotalAmount().equals(request.amount())) {
            log.warn("멱등 요청 정보 불일치 - paymentKey: {}, requestOrderId: {}, dbOrderId: {}", 
                    payment.getPaymentKey(), request.orderId(), payment.getOrderId());
            throw new BadRequestException("이미 존재하는 결제 키이나, 주문 정보가 일치하지 않습니다.");
        }

        if (payment.isApprovedOrCompleted()) {
            log.info("이미 승인된 결제 재요청 - 멱등 응답 반환, paymentKey: {}, state: {}",
                    payment.getPaymentKey(), payment.getState());
            TosspaymentsPaymentResult result = tosspaymentsPaymentResultService.readByTosspaymentsPaymentId(payment.getId());
            return PaymentResponse.fromExisting(payment, result);
        }
        if (payment.isClientBadRequest()) {
            log.info("클라이언트 원인 실패 결제 재요청 - paymentKey: {}", payment.getPaymentKey());
            throw new BadRequestException(readClientFailureMessage(payment.getId()));
        }
        if (payment.getState() == PaymentState.NEED_APPROVE || payment.getState() == PaymentState.CONNECTION_TIMEOUT) {
            log.info("결제 승인 전 단계에서 재요청 - 승인 프로세스 진행, paymentKey: {}, state: {}", 
                    payment.getPaymentKey(), payment.getState());
            return null;
        }
        log.error("비정상 상태 결제 재요청 - paymentKey: {}, state: {}", payment.getPaymentKey(), payment.getState());
        throw new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage());
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant