Skip to content

[FEAT] 환불 멱등성 키 + 중복 환불 방지#341

Merged
unifolio0 merged 6 commits intodevelopfrom
feat/#337
Mar 3, 2026
Merged

[FEAT] 환불 멱등성 키 + 중복 환불 방지#341
unifolio0 merged 6 commits intodevelopfrom
feat/#337

Conversation

@unifolio0
Copy link
Copy Markdown
Contributor

closed #337

작업 내용

스크린샷

참고 사항

@unifolio0 unifolio0 self-assigned this Mar 3, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 3, 2026

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 결제 취소 시 멱등성 키 적용으로 재시도/중복 방지 강화
  • 개선 사항

    • 분산 잠금 기반으로 동시 결제 취소 처리 신뢰성 향상
    • 결제 상태 목록 정리 및 상태별 설명 보강
  • 테스트

    • 변경된 취소 흐름을 반영한 단위 테스트 및 오류 시나리오 보강

Walkthrough

결제 환불에 멱등성 키(idempotencyKey)를 도입하고 취소 경로에 분산 락(@DistributedLock)을 적용했으며, 사용되지 않는 WAITING_FOR_DEPOSIT enum 상수를 제거했습니다. Tosspayments 클라이언트와 관련 단위 테스트 시그니처가 3-인자 형태로 갱신되었습니다.

Changes

Cohort / File(s) Summary
Import 정렬
src/main/java/com/samhap/kokomen/interview/service/ResumeBasedInterviewService.java
DistributedLock 임포트 위치 재정렬 (기능 변경 없음).
Tosspayments 상태 정의
src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java
WAITING_FOR_DEPOSIT 상수 제거 및 나머지 상태에 한글 주석 추가 (public enum 멤버 변경).
멱등성 키 지원 - 클라이언트
src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java
cancelPayment 시그니처에 String idempotencyKey 추가하고, 요청에 Idempotency-Key HTTP 헤더를 설정하도록 변경.
멱등성 키·분산락 적용 - 서비스
src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java
cancelPayment@DistributedLock 추가, 이름 기반 UUID로 idempotencyKey 생성("cancel:" + paymentKey), 재시도 로직에서 idempotencyKey 전달 및 재시도 로깅 추가.
테스트 업데이트
src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java
tosspaymentsClient.cancelPayment 모킹/검증을 새 3-인자 시그니처로 갱신하고 관련 에러 시나리오 목 설정 업데이트.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant PaymentFacade as PaymentFacadeService\n(`@DistributedLock`)
    participant TosspayClient as TosspaymentsClient
    participant TossAPI as ExternalTossAPI

    Client->>PaymentFacade: refundRequest(CancelRequest)
    Note right of PaymentFacade: acquire distributed lock
    PaymentFacade->>PaymentFacade: generate idempotencyKey ("cancel:" + paymentKey → UUID)
    PaymentFacade->>TosspayClient: cancelPayment(paymentKey, cancelReq, idempotencyKey)
    TosspayClient->>TossAPI: HTTP POST /payments/{paymentKey}/cancel\nHeader: Idempotency-Key: <idempotencyKey>
    TossAPI-->>TosspayClient: 200 OK / error
    TosspayClient-->>PaymentFacade: TosspaymentsPaymentResponse / error
    PaymentFacade-->>Client: refundResult
    Note left of PaymentFacade: release distributed lock
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • nak-honest
  • kargowild

Poem

🐰 환불은 한 번만 쫑 — 키를 달고 가네,
락으로 문단속, 동시성은 잠깨나,
헤더에 속삭여 중복을 멈추고,
테스트도 깡충 맞춰 뛰었지,
당근 한 입, 배포는 환한 웃음 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

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.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주요 변경사항인 환불 멱등성 키 추가 및 중복 환불 방지를 명확하게 설명합니다.
Description check ✅ Passed PR 설명이 관련 이슈 #337을 참조하고 있으며 변경사항과 연관되어 있습니다.
Linked Issues check ✅ Passed 코드 변경사항이 환불 멱등성 키 추가 및 중복 환불 방지라는 목표를 충족합니다. cancelPayment에 idempotencyKey 파라미터 추가, DistributedLock 적용, UUID 기반 키 생성 구현 [#337]
Out of Scope Changes check ✅ Passed ResumeBasedInterviewService의 import 정렬 변경은 스코프 범위 내 정리이며, TosspaymentsStatus의 WAITING_FOR_DEPOSIT 제거는 환불 기능 개선의 일부로 적절합니다.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#337

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은 토스페이먼츠 환불 처리의 안정성을 강화하기 위해 멱등성(Idempotency)을 구현하고 중복 환불을 방지하는 기능을 추가합니다. 이를 통해 결제 취소 요청이 여러 번 전송되더라도 한 번만 처리되도록 보장하여 데이터 일관성을 유지하고 잠재적인 오류를 줄입니다.

Highlights

  • 토스페이먼츠 취소 API 멱등성 구현: 토스페이먼츠 결제 취소 API 호출 시 멱등성 키(Idempotency-Key)를 HTTP 헤더에 추가하여 중복 요청으로 인한 문제를 방지했습니다.
  • 중복 환불 방지를 위한 분산 락 적용: PaymentFacadeServicecancelPayment 메서드에 @DistributedLock 어노테이션을 적용하여 동일한 결제에 대한 동시 환불 요청을 제어하고 중복 환불을 방지했습니다.
  • 결제 상태 Enum 업데이트: TosspaymentsStatus enum에서 사용되지 않는 WAITING_FOR_DEPOSIT 상태를 제거하고, 각 상태에 대한 한국어 주석을 추가하여 가독성을 높였습니다.
  • 테스트 코드 업데이트: 변경된 cancelPayment 메서드 시그니처에 맞춰 PaymentFacadeServiceTesttosspaymentsClient.cancelPayment 모킹 로직을 수정했습니다.

🧠 New Feature in Public Preview: You can now enable Memory 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.

Changelog
  • src/main/java/com/samhap/kokomen/interview/service/ResumeBasedInterviewService.java
    • DistributedLock import 문 위치를 변경했습니다.
  • src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java
    • WAITING_FOR_DEPOSIT 상태를 제거하고 각 상태에 대한 한국어 주석을 추가했습니다.
  • src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java
    • cancelPayment 메서드에 idempotencyKey 파라미터를 추가하고, 해당 키를 HTTP 요청 헤더에 포함하도록 수정했습니다.
  • src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java
    • DistributedLock import 문 위치를 변경했습니다.
    • cancelPayment 메서드에 @DistributedLock 어노테이션을 적용하여 분산 락을 구현했습니다.
    • cancelPayment 호출 시 UUID.randomUUID().toString()을 사용하여 멱등성 키를 생성하고 전달하도록 수정했습니다.
  • src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java
    • tosspaymentsClient.cancelPayment 모킹 시그니처를 any(), any(), any()로 변경하여 idempotencyKey 파라미터 추가에 대응했습니다.
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 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 counter productive. 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.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.


멱등성 키, 환불은 한 번만, 안전한 거래.

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

github-actions bot commented Mar 3, 2026

Test Results

 47 files   47 suites   1m 25s ⏱️
269 tests 268 ✅ 1 💤 0 ❌
271 runs  270 ✅ 1 💤 0 ❌

Results for commit 6964c9b.

♻️ This comment has been updated with latest results.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java (1)

303-361: 🧹 Nitpick | 🔵 Trivial

3번째 인자에 any()를 사용하면 멱등키 누락 회귀를 잡지 못합니다.

cancelPayment(String paymentKey, TosspaymentsPaymentCancelRequest request, String idempotencyKey) 메서드의 3번째 파라미터인 idempotencyKey는 HTTP 헤더로 필수 전달되는 항목입니다. any()는 null도 매칭하므로, 서비스 로직이 변경되어 idempotencyKey를 누락하거나 null을 전달하는 경우를 테스트에서 감지할 수 없습니다. anyString()을 사용하여 null이 아닌 String이 전달되는지 확인해 주세요.

🔧 제안 수정안
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
@@
-        when(tosspaymentsClient.cancelPayment(any(), any(), any())).thenReturn(cancelResponse);
+        when(tosspaymentsClient.cancelPayment(any(), any(), anyString())).thenReturn(cancelResponse);
@@
-        when(tosspaymentsClient.cancelPayment(any(), any(), any())).thenThrow(clientError);
+        when(tosspaymentsClient.cancelPayment(any(), any(), anyString())).thenThrow(clientError);
@@
-        when(tosspaymentsClient.cancelPayment(any(), any(), any())).thenThrow(clientError);
+        when(tosspaymentsClient.cancelPayment(any(), any(), anyString())).thenThrow(clientError);
@@
-        when(tosspaymentsClient.cancelPayment(any(), any(), any()))
+        when(tosspaymentsClient.cancelPayment(any(), any(), anyString()))
                 .thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR));
@@
-        when(tosspaymentsClient.cancelPayment(any(), any(), any()))
+        when(tosspaymentsClient.cancelPayment(any(), any(), anyString()))
                 .thenThrow(new ResourceAccessException("네트워크 오류"));
@@
-        when(tosspaymentsClient.cancelPayment(any(), any(), any()))
+        when(tosspaymentsClient.cancelPayment(any(), any(), anyString()))
                 .thenThrow(new RuntimeException("예상치 못한 오류"));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java`
around lines 303 - 361, The test uses a loose matcher for the third parameter of
tosspaymentsClient.cancelPayment(...) which is the required idempotencyKey;
replace the third matcher any() with anyString() so the mocks verify a non-null
String is passed. Update every mock stubbing and when(...) call in
PaymentFacadeServiceTest that calls tosspaymentsClient.cancelPayment(any(),
any(), any()) to tosspaymentsClient.cancelPayment(any(), any(), anyString())
(and similarly for thenThrow/thenReturn cases) so the test will catch
regressions that drop or pass null for the idempotencyKey.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java`:
- Around line 4-10: TosspaymentsStatus enum is missing the WAITING_FOR_DEPOSIT
value which causes deserialization failures for virtual account responses; add a
new enum constant WAITING_FOR_DEPOSIT (with an appropriate comment like "가상계좌 입금
대기") to the TosspaymentsStatus enum so incoming API/webhook payloads that
contain that state deserialize correctly, and run/update unit/integration tests
that deserialize Toss Payments responses to verify no exceptions occur.

In `@src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java`:
- Around line 142-149: The cancelPayment method in PaymentFacadeService
currently creates a new random idempotencyKey each call (UUID.randomUUID()),
which breaks retry stability; replace this with a deterministic UUID derived
from the payment identifier so retries produce the same key (e.g., generate
idempotencyKey via UUID.nameUUIDFromBytes over request.paymentKey() plus a
static salt like "cancel" using StandardCharsets.UTF_8). Update the
idempotencyKey assignment in cancelPayment, add any necessary imports
(StandardCharsets), and ensure idempotencyKey is passed unchanged to
tosspaymentsClient.cancelPayment so identical cancel requests reuse the same
key.

---

Outside diff comments:
In
`@src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java`:
- Around line 303-361: The test uses a loose matcher for the third parameter of
tosspaymentsClient.cancelPayment(...) which is the required idempotencyKey;
replace the third matcher any() with anyString() so the mocks verify a non-null
String is passed. Update every mock stubbing and when(...) call in
PaymentFacadeServiceTest that calls tosspaymentsClient.cancelPayment(any(),
any(), any()) to tosspaymentsClient.cancelPayment(any(), any(), anyString())
(and similarly for thenThrow/thenReturn cases) so the test will catch
regressions that drop or pass null for the idempotencyKey.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 99b1fdf and bf55562.

📒 Files selected for processing (5)
  • src/main/java/com/samhap/kokomen/interview/service/ResumeBasedInterviewService.java
  • src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java
  • src/main/java/com/samhap/kokomen/payment/external/TosspaymentsClient.java
  • src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java
  • src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java

Comment on lines +4 to +10
READY, // 결제 준비
IN_PROGRESS, // 결제 진행 중
DONE, // 승인 성공
CANCELED, // 결제 취소
PARTIAL_CANCELED, // 결제 부분 취소
ABORTED, // 승인 실패
EXPIRED // 유효 시간 만료
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 3, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Toss Payments API payment status values WAITING_FOR_DEPOSIT official documentation

💡 Result:

In the Toss Payments Core API Payment object, the status field can be one of these values:

  • READY
  • IN_PROGRESS
  • WAITING_FOR_DEPOSIT (virtual account only; issued account but customer hasn’t deposited yet)
  • DONE
  • CANCELED
  • PARTIAL_CANCELED
  • ABORTED
  • EXPIRED (after 30 minutes if not confirmed from IN_PROGRESS) [1]

WAITING_FOR_DEPOSIT is officially documented as: a status that exists only in the virtual account payment flow, when the issued virtual account has not yet received the customer’s deposit. [1]

Sources

  • [1] Toss Payments Developer Center — Core API Reference (Payment object status enum): (docs.tosspayments.com)

Citations:


🏁 Script executed:

# 파일 위치 확인 및 TosspaymentsStatus enum 전체 내용 조회
find . -name "TosspaymentsStatus.java" -type f

Repository: samhap-soft/kokomen-backend

Length of output: 144


🏁 Script executed:

# TosspaymentsStatus enum의 전체 코드와 enum 상수 확인
cat -n "src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java"

Repository: samhap-soft/kokomen-backend

Length of output: 396


🏁 Script executed:

# TosspaymentsStatus enum이 사용되는 모든 위치 찾기 (역직렬화, 매핑 등)
rg "TosspaymentsStatus" --type java -B 2 -A 2

Repository: samhap-soft/kokomen-backend

Length of output: 24524


🏁 Script executed:

# Toss Payments 관련 설정, API 모델, 응답 처리 확인
fd -t f -e java . -path "*toss*" -o -path "*payment*" | head -20

Repository: samhap-soft/kokomen-backend

Length of output: 239


가상계좌 결제 시 WAITING_FOR_DEPOSIT 상태 반환으로 역직렬화 실패 가능

Toss Payments API는 공식 문서에 WAITING_FOR_DEPOSIT를 정당한 결제 상태로 정의하고 있습니다(가상계좌 결제에서 계좌 발급 후 입금 대기 상태). 현재 enum에서 이 상태가 누락되어 있어, 해당 결제 방식 응답이 역직렬화되지 않습니다. 특히 웹훅 및 결제 조회 API 응답 처리 시 예외가 발생합니다.

🔧 수정 방안
 public enum TosspaymentsStatus {
     READY, // 결제 준비
     IN_PROGRESS, // 결제 진행 중
+    WAITING_FOR_DEPOSIT, // 입금 대기 (가상계좌 등)
     DONE, // 승인 성공
     CANCELED, // 결제 취소
     PARTIAL_CANCELED, // 결제 부분 취소
     ABORTED, // 승인 실패
     EXPIRED // 유효 시간 만료
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java`
around lines 4 - 10, TosspaymentsStatus enum is missing the WAITING_FOR_DEPOSIT
value which causes deserialization failures for virtual account responses; add a
new enum constant WAITING_FOR_DEPOSIT (with an appropriate comment like "가상계좌 입금
대기") to the TosspaymentsStatus enum so incoming API/webhook payloads that
contain that state deserialize correctly, and run/update unit/integration tests
that deserialize Toss Payments responses to verify no exceptions occur.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

저희 서비스는 가상계좌 결제 기능을 제공하지 않기로 결정했습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@unifolio0, 알겠습니다! 가상계좌 결제 기능을 제공하지 않는다면 WAITING_FOR_DEPOSIT 상태를 enum에서 제거하는 것이 맞습니다. 비즈니스 요구사항을 반영한 적절한 결정입니다.


✏️ Learnings added
Learnt from: unifolio0
Repo: samhap-soft/kokomen-backend PR: 341
File: src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsStatus.java:4-10
Timestamp: 2026-03-03T06:01:32.009Z
Learning: kokomen-backend 프로젝트는 가상계좌 결제 기능을 제공하지 않으므로, TosspaymentsStatus enum에 WAITING_FOR_DEPOSIT 상태가 필요하지 않습니다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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

이 PR은 환불 요청에 멱등성 키를 도입하여 중복 환불을 방지하고, PaymentFacadeService의 cancelPayment 메서드에 분산 락을 적용하는 중요한 개선 사항을 포함하고 있습니다. TosspaymentsStatus 열거형에 한국어 주석을 추가하여 가독성을 높였으며, TosspaymentsClient 및 관련 테스트 코드도 새로운 idempotencyKey 파라미터에 맞춰 성공적으로 업데이트되었습니다. 전반적으로 결제 시스템의 안정성과 신뢰성을 향상시키는 좋은 변경입니다.

public enum TosspaymentsStatus {
READY,
IN_PROGRESS,
WAITING_FOR_DEPOSIT,
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

WAITING_FOR_DEPOSIT 상태가 제거되었습니다. 이 상태가 더 이상 사용되지 않거나 다른 방식으로 처리되는지 확인이 필요합니다. 만약 이 상태를 사용하는 기존 로직이 있다면, 예기치 않은 동작이나 데이터 불일치가 발생할 수 있습니다. 제거된 이유와 시스템 전반에 미치는 영향에 대한 설명이 추가되면 좋을 것 같습니다.

public void cancelPayment(CancelRequest request) {
TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(
request.cancelReason());
String idempotencyKey = UUID.randomUUID().toString();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

cancelPayment 메서드에서 idempotencyKey를 UUID.randomUUID().toString()으로 생성하고 있습니다. 이는 각 취소 요청마다 새로운 멱등성 키를 생성하게 됩니다. 만약 네트워크 오류 등으로 인해 동일한 논리적 취소 요청을 재시도해야 하는 경우, 매번 새로운 멱등성 키가 생성되어 토스페이먼츠에서 이를 별개의 요청으로 처리할 수 있습니다. 멱등성 키의 목적이 동일한 요청을 여러 번 보내도 한 번만 처리되도록 하는 것이라면, 특정 paymentKey에 대한 동일한 취소 시도에 대해서는 동일한 멱등성 키를 재사용하는 것이 더 적절할 수 있습니다. 예를 들어, paymentKey와 함께 취소 시도에 대한 고유한 식별자를 조합하여 멱등성 키를 생성하거나, 첫 요청 시 생성된 멱등성 키를 저장하고 재시도 시 재사용하는 방식을 고려해볼 수 있습니다.

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.

♻️ Duplicate comments (1)
src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java (1)

146-156: ⚠️ Potential issue | 🟠 Major

환불 멱등성 키를 랜덤 생성하면 재시도 시 중복 취소 위험이 남습니다.

Line 146의 UUID.randomUUID()는 동일 환불 재요청마다 다른 키를 생성합니다. 이 경우 외부 결제사에서 “새 취소 요청”으로 처리될 수 있어, PR 목표(중복 환불 방지)와 충돌합니다. 동일 취소 작업에는 결정적 멱등키를 사용해 주세요.

🔧 제안 수정안
+import java.nio.charset.StandardCharsets;
 import java.util.UUID;
@@
-        String idempotencyKey = UUID.randomUUID().toString();
+        String idempotencyKey = UUID.nameUUIDFromBytes(
+                ("cancel:" + request.paymentKey()).getBytes(StandardCharsets.UTF_8)
+        ).toString();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java`
around lines 146 - 156, Replace the random idempotency key generation
(UUID.randomUUID()) with a deterministic key derived from immutable request
fields so retries produce the same key: compute idempotencyKey from
request.paymentKey() plus a stable refund identifier (e.g., request.refundId()
or paymentKey+amount+timestamp-initial) hashed/encoded, initialize that
idempotencyKey before calling tosspaymentsConfirmRetryTemplate.execute, and pass
it into tosspaymentsClient.cancelPayment(...) so cancelPayment and the retry
logic use the same idempotencyKey across attempts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java`:
- Around line 146-156: Replace the random idempotency key generation
(UUID.randomUUID()) with a deterministic key derived from immutable request
fields so retries produce the same key: compute idempotencyKey from
request.paymentKey() plus a stable refund identifier (e.g., request.refundId()
or paymentKey+amount+timestamp-initial) hashed/encoded, initialize that
idempotencyKey before calling tosspaymentsConfirmRetryTemplate.execute, and pass
it into tosspaymentsClient.cancelPayment(...) so cancelPayment and the retry
logic use the same idempotencyKey across attempts.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf55562 and 6f1d5b7.

📒 Files selected for processing (1)
  • src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java

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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java (1)

278-368: 🧹 Nitpick | 🔵 Trivial

결제 취소 재시도 테스트가 누락되었습니다.

cancelPaymentRetryTemplate이 도입되었으나, 결제 승인(confirmPayment)처럼 재시도 시나리오에 대한 테스트가 없습니다. 다음 케이스에 대한 테스트 추가를 권장합니다:

  • 5xx 에러 후 재시도 성공
  • 네트워크 에러 후 재시도 성공
  • 409 (IDEMPOTENT_REQUEST_PROCESSING) 후 재시도 성공
  • 재시도 소진 후 예외 발생
💡 테스트 예시
`@Test`
void 결제_취소_시_5xx_에러_후_재시도에_성공한다() {
    // given
    TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder()
            .paymentKey("payment_key")
            .build();
    payment.updateState(PaymentState.COMPLETED);
    tosspaymentsPaymentRepository.save(payment);

    TosspaymentsPaymentResult paymentResult = TosspaymentsPaymentResultFixtureBuilder.builder()
            .tosspaymentsPayment(payment)
            .build();
    tosspaymentsPaymentResultRepository.save(paymentResult);

    HttpServerErrorException serverError = mock(HttpServerErrorException.class);
    when(tosspaymentsClient.cancelPayment(any(), any(), any()))
            .thenThrow(serverError)
            .thenReturn(createCancelSuccessResponse());

    // when
    paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"));

    // then
    verify(tosspaymentsClient, times(2)).cancelPayment(any(), any(), any());
    TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow();
    assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java`
around lines 278 - 368, Add missing retry tests for cancelPayment to cover retry
behavior introduced by RetryTemplate: create tests in PaymentFacadeServiceTest
that use
TosspaymentsPaymentFixtureBuilder/TosspaymentsPaymentResultFixtureBuilder to
persist a COMPLETED payment, then stub tosspaymentsClient.cancelPayment to first
throw (HttpServerErrorException or ResourceAccessException or
HttpClientErrorException with status 409 and IDEMPOTENT_REQUEST_PROCESSING) and
then return a successful TosspaymentsPaymentResponse; assert
paymentFacadeService.cancelPayment succeeds, verify
tosspaymentsClient.cancelPayment was called twice (verify(..., times(2))). Also
add tests that stub repeated failures to exhaust retries and assert the
appropriate InternalServerErrorException is thrown with the expected
PaymentServiceErrorMessage. Ensure tests reference
tosspaymentsPaymentRepository/tosspaymentsPaymentResultRepository to validate
state and result fields after success.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java`:
- Around line 151-159: The cancel call is reusing
tosspaymentsConfirmRetryTemplate which ties confirm and cancel retry behavior
together; if cancel needs a different retry policy, create a separate
RetryTemplate (e.g., tosspaymentsCancelRetryTemplate) and use it for the cancel
invocation instead of tosspaymentsConfirmRetryTemplate in the block that calls
tosspaymentsClient.cancelPayment(request.paymentKey(),
tosspaymentsPaymentCancelRequest, idempotencyKey); otherwise, add a comment near
tosspaymentsConfirmRetryTemplate explaining intentional reuse so future
maintainers know the choice.

---

Outside diff comments:
In
`@src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java`:
- Around line 278-368: Add missing retry tests for cancelPayment to cover retry
behavior introduced by RetryTemplate: create tests in PaymentFacadeServiceTest
that use
TosspaymentsPaymentFixtureBuilder/TosspaymentsPaymentResultFixtureBuilder to
persist a COMPLETED payment, then stub tosspaymentsClient.cancelPayment to first
throw (HttpServerErrorException or ResourceAccessException or
HttpClientErrorException with status 409 and IDEMPOTENT_REQUEST_PROCESSING) and
then return a successful TosspaymentsPaymentResponse; assert
paymentFacadeService.cancelPayment succeeds, verify
tosspaymentsClient.cancelPayment was called twice (verify(..., times(2))). Also
add tests that stub repeated failures to exhaust retries and assert the
appropriate InternalServerErrorException is thrown with the expected
PaymentServiceErrorMessage. Ensure tests reference
tosspaymentsPaymentRepository/tosspaymentsPaymentResultRepository to validate
state and result fields after success.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6f1d5b7 and 6964c9b.

📒 Files selected for processing (2)
  • src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java
  • src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java

Comment on lines +151 to +159
TosspaymentsPaymentResponse response = tosspaymentsConfirmRetryTemplate.execute(
context -> {
if (context.getRetryCount() > 0) {
log.warn("토스페이먼츠 환불 승인 재시도 {}회차, paymentKey = {}",
context.getRetryCount(), request.paymentKey());
}
return tosspaymentsClient.cancelPayment(request.paymentKey(),
tosspaymentsPaymentCancelRequest, idempotencyKey);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

RetryTemplate 재사용은 적절하나, 네이밍 불일치에 주의하세요.

tosspaymentsConfirmRetryTemplate을 취소 로직에도 재사용하고 있습니다. 재시도 정책이 confirm과 cancel에 동일하게 적용되어야 한다면 문제없지만, 향후 두 작업의 재시도 정책을 분리해야 할 경우 별도 템플릿이 필요할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java`
around lines 151 - 159, The cancel call is reusing
tosspaymentsConfirmRetryTemplate which ties confirm and cancel retry behavior
together; if cancel needs a different retry policy, create a separate
RetryTemplate (e.g., tosspaymentsCancelRetryTemplate) and use it for the cancel
invocation instead of tosspaymentsConfirmRetryTemplate in the block that calls
tosspaymentsClient.cancelPayment(request.paymentKey(),
tosspaymentsPaymentCancelRequest, idempotencyKey); otherwise, add a comment near
tosspaymentsConfirmRetryTemplate explaining intentional reuse so future
maintainers know the choice.

@unifolio0 unifolio0 merged commit 1663649 into develop Mar 3, 2026
4 checks passed
@unifolio0 unifolio0 deleted the feat/#337 branch March 3, 2026 06:34
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.

[FEAT] 환불 멱등성 키 + 중복 환불 방지

1 participant