Skip to content

[Feat] 토스페이먼츠 기반 크레딧 결제 구현#70

Merged
whc9999 merged 1 commit into
mainfrom
feat/#62-payment
May 22, 2026
Merged

[Feat] 토스페이먼츠 기반 크레딧 결제 구현#70
whc9999 merged 1 commit into
mainfrom
feat/#62-payment

Conversation

@whc9999
Copy link
Copy Markdown
Collaborator

@whc9999 whc9999 commented May 22, 2026

✨ 어떤 이유로 PR를 하셨나요?

  • feature 병합
  • 버그 수정(아래에 issue #를 남겨주세요)
  • 코드 개선
  • 코드 수정
  • 배포
  • 기타(아래에 자세한 내용 기입해주세요)

📋 세부 내용 - 왜 해당 PR이 필요한지 작업 내용을 자세하게 설명해주세요

  • 크레딧 가격 플랜 조회 API 추가
  • 토스 결제 준비 및 승인 API 추가
  • 결제 완료 시 크레딧 충전 처리
  • 크레딧 잔액 및 거래 내역 조회 API 추가
  • 자소서 분석 시작 시 크레딧 선차감 적용
  • 분석 실패 시 크레딧 자동 환불 처리
  • 결제/크레딧 관련 엔티티 및 테스트 추가

📸 작업 화면 스크린샷

⚠️ PR하기 전에 확인해주세요

  • 로컬테스트를 진행하셨나요?
  • 머지할 브랜치를 확인하셨나요?
  • 관련 label을 선택하셨나요?

🚨 관련 이슈 번호 [#62]

Summary by CodeRabbit

Release Notes

  • New Features
    • Introduced credit-based system for analysis services with multiple purchasable plans
    • Credit transactions are automatically tracked with refunds on operation failures
    • Users can view current credit balance and filter transaction history by type
    • Added payment provider integration for secure credit purchases

Review Change Stack

- 크레딧 가격 플랜 조회 API 추가
- 토스 결제 준비 및 승인 API 추가
- 결제 완료 시 크레딧 충전 처리
- 크레딧 잔액 및 거래 내역 조회 API 추가
- 자소서 분석 시작 시 크레딧 선차감 적용
- 분석 실패 시 크레딧 자동 환불 처리
- 결제/크레딧 관련 엔티티 및 테스트 추가
@whc9999 whc9999 self-assigned this May 22, 2026
@whc9999 whc9999 added the ✨ feat New feature or request label May 22, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete payment and credit system for JobDri, integrating TOSS Payments gateway for credit purchase and implementing credit-based deduction for analysis features. The change includes domain models, transactional credit services, payment orchestration, REST APIs, configuration, and test coverage, with credit management wired into the existing analysis service flow.

Changes

Payment & Credit System

Layer / File(s) Summary
Credit & Payment Domain Models
src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java, CreditPlan.java, CreditTransaction.java, Payment.java
Introduces CreditTransactionType enum (CHARGE, USE, REFUND, COUPON), CreditPlan enum with purchase tiers, CreditTransaction JPA entity for audit trail, and extends Payment with orderId, paymentKey, planCode, creditAmount, and approvedAt fields. createPending and complete methods updated to populate new fields.
Credit Management Service
src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java
Transactional service providing charge, use, and refund operations, each using REQUIRES_NEW isolation. User validation and credit transaction recording via getManagedUser and saveTransaction helpers; use maps insufficient balance to INSUFFICIENT_CREDIT exception.
Payment Repositories
src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java, PaymentRepository.java
Spring Data JPA repositories for credit transactions (with optional CreditTransactionType filtering, ordered by creation and id descending) and findByOrderId lookup for payment confirmation.
Payment Processing Service
src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java
Orchestrates payment lifecycle: lists available plans, generates order id and persists pending payment on prepare, validates and confirms via TOSS client, charges user credit on success, and supports balance/transaction query APIs with optional type filtering.
TOSS Payment Client
src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java
HTTP client invoking TOSS /v1/payments/confirm endpoint with Basic auth, idempotency key, and request/response serialization; maps RestClientException to PAYMENT_CONFIRM_FAILED.
Payment REST API & DTOs
src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java, src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/*.java, src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/*.java, src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/*.java
Five REST endpoints (/api/payments/plans, /prepare, /confirm, /balance, /transactions) with validated request DTOs and mapped response records for plan listing, payment preparation, confirmation with credit balance, and transaction history queries.
Configuration & Error Codes
.env.example, .env.production.example, src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java, src/main/resources/application-dev.yaml, application-prod.yaml
TOSS payment credentials (client-key, secret-key, base-url) in environment and YAML configs; five new error codes for payment/credit failures (NOT_FOUND, AMOUNT_MISMATCH, ALREADY_PROCESSED, CONFIRM_FAILED, INSUFFICIENT_CREDIT).
Analysis Service Credit Integration
src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java
Injects CreditService and wraps analysis execution: decrements user credit before LLM invocation, persists analysis/questions, and refunds on any RuntimeException with rethrow; uses mockApplyId as transaction reference.
Test Coverage
src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java, src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java
PaymentServiceTest validates plan listing (three tiers), payment preparation (order generation), confirmation (status/balance/transaction persistence), and amount mismatch error; AnalysisServiceTest removes transactional test class decoration, validates persisted status via repository reload, and pre-charges user credit for analysis test.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • JobDri-Developer/BackEnd#66: Modifies the AnalysisService.analyze method with related logic flow changes affecting how analysis results are persisted and handled.
  • JobDri-Developer/BackEnd#61: Implements the initial AnalysisService whose analysis execution flow is now extended with credit deduction and refund operations in this PR.

Poem

🐰 Credits flow like carrots in a row,
Payment gateways help the system grow,
TOSS confirms what users choose to pay,
Then analysis feasts on credits today—
Refund on failure, charge on success,
Each transaction eases the developer's stress!

🚥 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 The title clearly and accurately summarizes the main feature: implementing Toss Payments-based credit payment system.
Description check ✅ Passed The PR description follows the template structure, marks feature merge, includes detailed implementation details, confirms testing/branch/label checks, and references issue #62.
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 feat/#62-payment

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

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

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/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java (1)

79-118: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

The new credit side effects added to analyze(...) are still untested.

This suite now seeds users with credits, but none of the tests verify that a successful analysis decrements the balance or that an LLM/persistence failure restores the deducted credit. Those are the core behavior changes in this PR, so I'd add one success-path balance assertion and one failure-path refund test here.

🤖 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/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java`
around lines 79 - 118, Add tests that assert credit side effects: after calling
analysisService.analyze(user, mockApply.getId()) in the success test (using the
existing saveUser seed), assert the user's credit balance decreased by the
expected amount by reloading User via userRepository.findById(...) or the
existing saveUser return value; and add a new failure-path test that mocks
analysisAiClient.analyze(...) (or make analysisRepository.save(...) throw) to
simulate an LLM/persistence error, then assert the originally deducted credit
was refunded (balance unchanged) and that no Analysis persisted. Use the
existing symbols analyze (AnalysisService.analyze), analysisAiClient.analyze,
analysisRepository.save and userRepository.findById / saveUser to locate and
implement the assertions and failure test.
🧹 Nitpick comments (3)
src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java (1)

62-74: ⚡ Quick win

Actually assert the persisted payment is PENDING.

Right now this only proves that a row was created. If prepare(...) saved the wrong initial status, the test would still pass.

✅ Small assertion addition
-        assertThat(paymentRepository.findByOrderId(response.orderId())).isPresent();
+        var payment = paymentRepository.findByOrderId(response.orderId()).orElseThrow();
+        assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING);
+        assertThat(payment.getAmount()).isEqualTo(11500);
🤖 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/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java`
around lines 62 - 74, Test currently only asserts a row was created; fetch the
persisted Payment via paymentRepository.findByOrderId(response.orderId()) in
PaymentServiceTest.prepare() and assert its status is PaymentStatus.PENDING (or
the enum/constant used by your domain) so the test verifies the initial saved
status from paymentService.prepare(user, new
PaymentPrepareRequest("FIVE_TIMES")) is PENDING.
src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java (1)

102-113: 🏗️ Heavy lift

Transaction history retrieval should be paginated.

Current implementation always loads all rows for a user, which will degrade latency and memory as history grows.

🤖 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/jobdri/jobdri_api/domain/payment/service/PaymentService.java`
around lines 102 - 113, getTransactions in PaymentService currently fetches all
rows for a user; change it to return a paginated result by accepting pagination
inputs (e.g., Pageable or page/size params) and using the repository pageable
variants (replace findAllByUserIdOrderByCreatedAtDescIdDesc and
findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc with repository methods that
accept a Pageable). Update PaymentService.getTransactions(User user, ...) to
validate the user, build a PageRequest (or use the passed Pageable), call
creditTransactionRepository.findAllByUserId(..., pageable) or
findAllByUserIdAndType(..., pageable), and map the Page content to
CreditTransactionResponse (returning a Page<CreditTransactionResponse> or a DTO
containing content + paging metadata). Ensure repository signatures and the
controller/service callers are updated accordingly.
src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java (1)

8-8: 💤 Low value

Use primitive int for TossPaymentConfirmResponse.totalAmount
TOSS Payments’ payment confirmation response payload includes totalAmount, and your code already treats it as required (PaymentService.validateTossResponse throws when response.totalAmount() == null). Switching totalAmount to int (and optionally simplifying the null check) improves consistency with TossPaymentConfirmRequest.amount and reduces nullable handling.

🤖 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/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java`
at line 8, The DTO field TossPaymentConfirmResponse.totalAmount is declared as
Integer but is treated as required; change its type to primitive int in
TossPaymentConfirmResponse and update any constructors/builders to accept int so
nullability is removed, then simplify the null-check in
PaymentService.validateTossResponse (or remove response.totalAmount() == null
checks) to rely on the primitive, keeping consistency with
TossPaymentConfirmRequest.amount and avoiding unnecessary nullable handling.
🤖 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/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java`:
- Around line 61-65: The code currently calls creditService.use(...) before the
external LLM call (analysisAiClient.analyze), which can permanently consume user
credits if the process dies; change this to a durable reserve/finalize flow:
call a reservation method (e.g., creditService.reserve(user, 1, referenceId) or
create a CreditReservation) before calling
AnalysisService.analyze/analysisAiClient.analyze, then after a successful
llmResponse call finalize/commit the reservation (e.g.,
creditService.finalizeReservation(reservation) or
creditService.useReserved(...)); on any failure or exception cancel the
reservation (e.g., creditService.cancelReservation(reservation)). Also apply the
same reservation/finalize change to the other similar deduction sites mentioned
(lines around the second occurrence of creditService.use). Ensure method names
(reserve, finalizeReservation/cancelReservation) exist or add them to
CreditService to implement durable reconciliation.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java`:
- Line 22: The expression payment.getUser().getEmail() in PaymentPrepareResponse
may NPE if Payment.user is null; update the constructor/factory in
PaymentPrepareResponse (where payment is accessed) to defensively handle a null
user by either using a null-safe access (e.g., userEmail = payment.getUser() !=
null ? payment.getUser().getEmail() : null) or by calling
Objects.requireNonNull(payment.getUser(), "Payment.user must not be null") if
the domain invariant requires a non-null user; alternatively, enforce the
invariant at the entity level by adding `@NotNull` to the Payment.user association
and documenting that Payment always contains a user.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java`:
- Around line 3-7: TossPaymentConfirmRequest lacks Bean Validation on its
components; annotate the record components paymentKey and orderId with `@NotBlank`
and annotate amount with `@Positive` (importing the appropriate
javax/jakarta.validation annotations consistent with the project) so validation
occurs before sending to the external TOSS API; update TossPaymentConfirmRequest
to declare these annotations on the record components and ensure any DTO
mapping/validation pipeline picks them up.

In `@src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java`:
- Around line 28-35: Payment entity allows nulls for lifecycle-required fields;
mark orderId and planCode as non-nullable in the JPA mapping. Update the Payment
class by changing the `@Column` annotations for orderId and planCode to include
nullable = false (and optionally add javax.validation.constraints.NotNull on
those fields) so the database schema and JPA enforce non-null constraints for
orderId and planCode while leaving paymentKey as-is.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java`:
- Around line 22-43: The current `@Transactional`(propagation =
Propagation.REQUIRES_NEW) on CreditService methods (charge, use, refund) causes
credit changes to commit even if an outer payment transaction rolls back; change
these methods to use default/REQUIRED propagation so credit updates participate
in the caller transaction (remove Propagation.REQUIRES_NEW from charge, use,
refund and rely on getManagedUser/saveTransaction within the same tx). If truly
isolated commits are needed for specific call paths, move those isolated
operations into separate methods in a different bean and annotate only those
methods with `@Transactional`(propagation = REQUIRES_NEW) so Spring proxy
semantics apply (do not keep REQUIRES_NEW on the existing charge/use/refund
methods).
- Around line 23-47: All three public methods charge, use, and refund must
validate that amount is a positive integer at the service boundary and reject
zero or negative values before performing any user retrieval/mutation; add an
initial guard in CreditService. For invalid amounts throw the appropriate
GeneralException (use an existing error code or add one like INVALID_AMOUNT)
with a clear message, and only call getManagedUser(), increaseCredit(),
decreaseCredit(), and saveTransaction after the check passes; ensure use still
handles insufficient credit via its existing try/catch.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java`:
- Around line 37-38: The PaymentService currently injects tossClientKey with a
default empty string which can hide missing configuration; update PaymentService
to fail fast by removing the empty default and/or adding a validation that
throws an exception when tossClientKey is null or blank (e.g. in the constructor
or a `@PostConstruct` method) so startup fails with a clear message; reference
tossClientKey and the PaymentService class to locate where to validate and throw
an IllegalStateException if the key is missing.
- Around line 64-87: The confirm method is vulnerable to double-processing under
concurrency; make confirm execute within a transaction and fetch the Payment
with a row-level lock before checking status and completing it (e.g., replace
paymentRepository.findByOrderId(...) in PaymentService.confirm with a locked
read such as paymentRepository.findByOrderIdForUpdate(...) or a repository
method annotated with `@Lock`(PESSIMISTIC_WRITE)/SELECT ... FOR UPDATE), then
perform the status check, toss validation, payment.complete(...) and
creditService.charge(...) while the transaction/lock is held to prevent
concurrent requests from both passing the PENDING check.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java`:
- Around line 24-25: The `@Value` injection in TossPaymentClient currently uses a
permissive empty default for payment.toss.secret-key which lets the app start
without a key; remove the empty default from the `@Value` annotation on the
secretKey field so Spring fails to start when the property is missing (i.e.,
change the `@Value`("${payment.toss.secret-key:}") usage to require the property),
and add a clear check in the TossPaymentClient constructor or an `@PostConstruct`
that throws an explicit IllegalStateException if secretKey is null/blank to
ensure fail-fast behavior with a clear error message.
- Around line 30-49: The confirm method in TossPaymentClient currently builds a
REST client without any timeouts; update the restClientBuilder usage in confirm
to configure sensible connect and read (and optionally write) timeouts on the
client before calling .build() so the external TOSS call cannot block
indefinitely (e.g., set connectTimeout and response/read timeout on the
underlying HTTP client used by restClientBuilder). Ensure the configured
timeouts apply to the client built in TossPaymentClient.confirm and that timeout
exceptions still propagate as RestClientException (so the existing catch and
GeneralException mapping remains valid).
- Around line 43-48: The catch block in TossPaymentClient currently swallows
RestClientException details; change error handling to first catch
HttpStatusCodeException (to access e.getStatusCode() and
e.getResponseBodyAsString()) and include those details in the thrown
GeneralException (or its message/metadata) so TOSS HTTP status and response body
are preserved for debugging, then keep a fallback catch for generic
RestClientException that still preserves e.getMessage() or sets e as the cause;
also optionally log the extracted status and response body before rethrowing.
- Around line 32-34: The confirm() method in TossPaymentClient currently builds
a new RestClient via restClientBuilder.baseUrl(baseUrl).build() on every call;
instead, initialize and reuse a single RestClient instance (e.g., a private
final or volatile RestClient field) by building it once in the constructor or a
`@PostConstruct` method and then have confirm() use that cached RestClient; update
TossPaymentClient to add the RestClient field, move the
restClientBuilder.baseUrl(baseUrl).build() call into initialization, and remove
per-call builds to avoid repeated allocation.

---

Outside diff comments:
In
`@src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java`:
- Around line 79-118: Add tests that assert credit side effects: after calling
analysisService.analyze(user, mockApply.getId()) in the success test (using the
existing saveUser seed), assert the user's credit balance decreased by the
expected amount by reloading User via userRepository.findById(...) or the
existing saveUser return value; and add a new failure-path test that mocks
analysisAiClient.analyze(...) (or make analysisRepository.save(...) throw) to
simulate an LLM/persistence error, then assert the originally deducted credit
was refunded (balance unchanged) and that no Analysis persisted. Use the
existing symbols analyze (AnalysisService.analyze), analysisAiClient.analyze,
analysisRepository.save and userRepository.findById / saveUser to locate and
implement the assertions and failure test.

---

Nitpick comments:
In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java`:
- Line 8: The DTO field TossPaymentConfirmResponse.totalAmount is declared as
Integer but is treated as required; change its type to primitive int in
TossPaymentConfirmResponse and update any constructors/builders to accept int so
nullability is removed, then simplify the null-check in
PaymentService.validateTossResponse (or remove response.totalAmount() == null
checks) to rely on the primitive, keeping consistency with
TossPaymentConfirmRequest.amount and avoiding unnecessary nullable handling.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java`:
- Around line 102-113: getTransactions in PaymentService currently fetches all
rows for a user; change it to return a paginated result by accepting pagination
inputs (e.g., Pageable or page/size params) and using the repository pageable
variants (replace findAllByUserIdOrderByCreatedAtDescIdDesc and
findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc with repository methods that
accept a Pageable). Update PaymentService.getTransactions(User user, ...) to
validate the user, build a PageRequest (or use the passed Pageable), call
creditTransactionRepository.findAllByUserId(..., pageable) or
findAllByUserIdAndType(..., pageable), and map the Page content to
CreditTransactionResponse (returning a Page<CreditTransactionResponse> or a DTO
containing content + paging metadata). Ensure repository signatures and the
controller/service callers are updated accordingly.

In
`@src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java`:
- Around line 62-74: Test currently only asserts a row was created; fetch the
persisted Payment via paymentRepository.findByOrderId(response.orderId()) in
PaymentServiceTest.prepare() and assert its status is PaymentStatus.PENDING (or
the enum/constant used by your domain) so the test verifies the initial saved
status from paymentService.prepare(user, new
PaymentPrepareRequest("FIVE_TIMES")) is PENDING.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 92fede2c-ede1-409e-9565-d06ec79211f4

📥 Commits

Reviewing files that changed from the base of the PR and between a30b030 and 8e00ad5.

📒 Files selected for processing (27)
  • .env.example
  • .env.production.example
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentConfirmRequest.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/PaymentPrepareRequest.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditBalanceResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java
  • src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java
  • src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java
  • src/main/resources/application-dev.yaml
  • src/main/resources/application-prod.yaml
  • src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java
  • src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java

Comment on lines +61 to +65
String referenceId = "mockApplyId=" + mockApply.getId();
creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId);

Analysis analysis = analysisRepository.save(Analysis.create(
mockApply,
clampScore(llmResponse.score()),
clampScore(llmResponse.jobFit()),
clampScore(llmResponse.impact()),
clampScore(llmResponse.completeness()),
normalizeFeedback(llmResponse.feedback())
));
try {
AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions);
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 | 🔴 Critical | 🏗️ Heavy lift

Pre-committing the credit deduction here can permanently strand user credits.

creditService.use(...) is committed before the external LLM call, and the refund is only best-effort inside this thread's catch. If the process dies, the request times out, or the node is terminated between those two steps, the user keeps the deducted credit with no analysis result and no refund. For this flow, the charge needs to stay in the outer transaction or be modeled as a reservation/finalize flow with durable reconciliation.

Also applies to: 82-84

🤖 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/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java`
around lines 61 - 65, The code currently calls creditService.use(...) before the
external LLM call (analysisAiClient.analyze), which can permanently consume user
credits if the process dies; change this to a durable reserve/finalize flow:
call a reservation method (e.g., creditService.reserve(user, 1, referenceId) or
create a CreditReservation) before calling
AnalysisService.analyze/analysisAiClient.analyze, then after a successful
llmResponse call finalize/commit the reservation (e.g.,
creditService.finalizeReservation(reservation) or
creditService.useReserved(...)); on any failure or exception cancel the
reservation (e.g., creditService.cancelReservation(reservation)). Also apply the
same reservation/finalize change to the other similar deduction sites mentioned
(lines around the second occurrence of creditService.use). Ensure method names
(reserve, finalizeReservation/cancelReservation) exist or add them to
CreditService to implement durable reconciliation.

payment.getPrice(),
payment.getCreditAmount(),
clientKey,
payment.getUser().getEmail()
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

Potential NPE on user access.

payment.getUser().getEmail() will throw a NullPointerException if the user association is null. Verify that the Payment entity guarantees a non-null user, or add defensive null handling.

🛡️ Proposed defensive fix
-                payment.getUser().getEmail()
+                payment.getUser() != null ? payment.getUser().getEmail() : null

Alternatively, if user is always required, ensure the Payment entity enforces this with @NotNull on the association and document this invariant.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
payment.getUser().getEmail()
payment.getUser() != null ? payment.getUser().getEmail() : null
🤖 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/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java`
at line 22, The expression payment.getUser().getEmail() in
PaymentPrepareResponse may NPE if Payment.user is null; update the
constructor/factory in PaymentPrepareResponse (where payment is accessed) to
defensively handle a null user by either using a null-safe access (e.g.,
userEmail = payment.getUser() != null ? payment.getUser().getEmail() : null) or
by calling Objects.requireNonNull(payment.getUser(), "Payment.user must not be
null") if the domain invariant requires a non-null user; alternatively, enforce
the invariant at the entity level by adding `@NotNull` to the Payment.user
association and documenting that Payment always contains a user.

Comment on lines +3 to +7
public record TossPaymentConfirmRequest(
String paymentKey,
String orderId,
int amount
) {
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

Add validation annotations for external API request.

This DTO is sent to the TOSS Payments API but lacks validation. Consider adding Bean Validation annotations to prevent invalid data:

  • @NotBlank on paymentKey and orderId
  • @Positive on amount to reject negative values
🔒 Proposed validation
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Positive;
+
 public record TossPaymentConfirmRequest(
+        `@NotBlank`
         String paymentKey,
+        `@NotBlank`
         String orderId,
+        `@Positive`
         int amount
 ) {
 }
🤖 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/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java`
around lines 3 - 7, TossPaymentConfirmRequest lacks Bean Validation on its
components; annotate the record components paymentKey and orderId with `@NotBlank`
and annotate amount with `@Positive` (importing the appropriate
javax/jakarta.validation annotations consistent with the project) so validation
occurs before sending to the external TOSS API; update TossPaymentConfirmRequest
to declare these annotations on the record components and ensure any DTO
mapping/validation pipeline picks them up.

Comment on lines +28 to +35
@Column(unique = true)
private String orderId;

@Column(unique = true)
private String paymentKey;

@Column
private String planCode;
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 | 🟡 Minor | ⚡ Quick win

Enforce non-null constraints for mandatory payment fields.

orderId and planCode are lifecycle-required, but DB columns currently allow nulls. That weakens persistence invariants and can create unresolvable payment rows.

Suggested fix
-    `@Column`(unique = true)
+    `@Column`(nullable = false, unique = true)
     private String orderId;

-    `@Column`
+    `@Column`(nullable = false)
     private String planCode;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Column(unique = true)
private String orderId;
@Column(unique = true)
private String paymentKey;
@Column
private String planCode;
`@Column`(nullable = false, unique = true)
private String orderId;
`@Column`(unique = true)
private String paymentKey;
`@Column`(nullable = false)
private String planCode;
🤖 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/jobdri/jobdri_api/domain/payment/entity/Payment.java`
around lines 28 - 35, Payment entity allows nulls for lifecycle-required fields;
mark orderId and planCode as non-nullable in the JPA mapping. Update the Payment
class by changing the `@Column` annotations for orderId and planCode to include
nullable = false (and optionally add javax.validation.constraints.NotNull on
those fields) so the database schema and JPA enforce non-null constraints for
orderId and planCode while leaving paymentKey as-is.

Comment on lines +22 to +43
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int charge(User user, int amount, String description, String referenceId) {
User managedUser = getManagedUser(user);
managedUser.increaseCredit(amount);
saveTransaction(managedUser, CreditTransactionType.CHARGE, amount, description, referenceId);
return managedUser.getCredit();
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public int use(User user, int amount, String description, String referenceId) {
User managedUser = getManagedUser(user);
try {
managedUser.decreaseCredit(amount);
} catch (IllegalArgumentException e) {
throw new GeneralException(GeneralErrorCode.INSUFFICIENT_CREDIT, "크레딧이 부족합니다.");
}
saveTransaction(managedUser, CreditTransactionType.USE, -amount, description, referenceId);
return managedUser.getCredit();
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public int refund(User user, int amount, String description, String referenceId) {
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 | 🏗️ Heavy lift

REQUIRES_NEW here can commit credits even when outer payment transaction rolls back.

Because credit updates run in independent transactions, caller rollback can leave partial state (e.g., credit charged but payment status not completed).

Suggested direction
-    `@Transactional`(propagation = Propagation.REQUIRES_NEW)
+    `@Transactional`
     public int charge(User user, int amount, String description, String referenceId) { ... }

-    `@Transactional`(propagation = Propagation.REQUIRES_NEW)
+    `@Transactional`
     public int use(User user, int amount, String description, String referenceId) { ... }

-    `@Transactional`(propagation = Propagation.REQUIRES_NEW)
+    `@Transactional`
     public int refund(User user, int amount, String description, String referenceId) { ... }

If independent transaction semantics are required for specific call paths, split those into dedicated methods instead of making all credit mutations REQUIRES_NEW.

🤖 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/jobdri/jobdri_api/domain/payment/service/CreditService.java`
around lines 22 - 43, The current `@Transactional`(propagation =
Propagation.REQUIRES_NEW) on CreditService methods (charge, use, refund) causes
credit changes to commit even if an outer payment transaction rolls back; change
these methods to use default/REQUIRED propagation so credit updates participate
in the caller transaction (remove Propagation.REQUIRES_NEW from charge, use,
refund and rely on getManagedUser/saveTransaction within the same tx). If truly
isolated commits are needed for specific call paths, move those isolated
operations into separate methods in a different bean and annotate only those
methods with `@Transactional`(propagation = REQUIRES_NEW) so Spring proxy
semantics apply (do not keep REQUIRES_NEW on the existing charge/use/refund
methods).

Comment on lines +64 to +87
public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) {
User validatedUser = userService.validateUser(user);
Payment payment = paymentRepository.findByOrderId(request.orderId())
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.PAYMENT_NOT_FOUND,
"결제 정보를 찾을 수 없습니다. orderId=" + request.orderId()
));

if (!payment.getUser().getId().equals(validatedUser.getId())) {
throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다.");
}
if (payment.getStatus() != PaymentStatus.PENDING) {
throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
}
if (payment.getPrice() != request.amount()) {
throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다.");
}

TossPaymentConfirmResponse tossResponse =
tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount());
validateTossResponse(request, tossResponse);

payment.complete(request.paymentKey());
int creditBalance = creditService.charge(
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 | 🔴 Critical | ⚡ Quick win

confirm is vulnerable to double-processing under concurrent requests.

Two requests can both pass the PENDING check before either commits, leading to duplicate credit charge for one order.

Suggested fix (row lock during confirm)
// PaymentRepository.java
+import org.springframework.data.jpa.repository.Lock;
+import jakarta.persistence.LockModeType;
@@
+    `@Lock`(LockModeType.PESSIMISTIC_WRITE)
+    Optional<Payment> findByOrderId(String orderId);
// PaymentService.java
-        Payment payment = paymentRepository.findByOrderId(request.orderId())
+        Payment payment = paymentRepository.findByOrderId(request.orderId())
                 .orElseThrow(() -> new GeneralException(
                         GeneralErrorCode.PAYMENT_NOT_FOUND,
                         "결제 정보를 찾을 수 없습니다. orderId=" + request.orderId()
                 ));

(If this repository method is reused elsewhere, consider adding a dedicated findByOrderIdForUpdate method instead.)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) {
User validatedUser = userService.validateUser(user);
Payment payment = paymentRepository.findByOrderId(request.orderId())
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.PAYMENT_NOT_FOUND,
"결제 정보를 찾을 수 없습니다. orderId=" + request.orderId()
));
if (!payment.getUser().getId().equals(validatedUser.getId())) {
throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다.");
}
if (payment.getStatus() != PaymentStatus.PENDING) {
throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
}
if (payment.getPrice() != request.amount()) {
throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다.");
}
TossPaymentConfirmResponse tossResponse =
tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount());
validateTossResponse(request, tossResponse);
payment.complete(request.paymentKey());
int creditBalance = creditService.charge(
public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) {
User validatedUser = userService.validateUser(user);
Payment payment = paymentRepository.findByOrderId(request.orderId())
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.PAYMENT_NOT_FOUND,
"결제 정보를 찾을 수 없습니다. orderId=" + request.orderId()
));
if (!payment.getUser().getId().equals(validatedUser.getId())) {
throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다.");
}
if (payment.getStatus() != PaymentStatus.PENDING) {
throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
}
if (payment.getPrice() != request.amount()) {
throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다.");
}
TossPaymentConfirmResponse tossResponse =
tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount());
validateTossResponse(request, tossResponse);
payment.complete(request.paymentKey());
int creditBalance = creditService.charge(
🤖 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/jobdri/jobdri_api/domain/payment/service/PaymentService.java`
around lines 64 - 87, The confirm method is vulnerable to double-processing
under concurrency; make confirm execute within a transaction and fetch the
Payment with a row-level lock before checking status and completing it (e.g.,
replace paymentRepository.findByOrderId(...) in PaymentService.confirm with a
locked read such as paymentRepository.findByOrderIdForUpdate(...) or a
repository method annotated with `@Lock`(PESSIMISTIC_WRITE)/SELECT ... FOR
UPDATE), then perform the status check, toss validation, payment.complete(...)
and creditService.charge(...) while the transaction/lock is held to prevent
concurrent requests from both passing the PENDING check.

Comment on lines +24 to +25
@Value("${payment.toss.secret-key:}")
private String secretKey;
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 | 🟡 Minor | ⚡ Quick win

Remove empty default for secret-key to fail fast.

An empty string default for payment.toss.secret-key allows the application to start with invalid configuration and fail at runtime during payment attempts. It's better to fail fast at startup if the secret key is missing.

🛡️ Proposed fix to remove unsafe default
-    `@Value`("${payment.toss.secret-key:}")
+    `@Value`("${payment.toss.secret-key}")
     private String secretKey;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Value("${payment.toss.secret-key:}")
private String secretKey;
`@Value`("${payment.toss.secret-key}")
private String secretKey;
🤖 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/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java`
around lines 24 - 25, The `@Value` injection in TossPaymentClient currently uses a
permissive empty default for payment.toss.secret-key which lets the app start
without a key; remove the empty default from the `@Value` annotation on the
secretKey field so Spring fails to start when the property is missing (i.e.,
change the `@Value`("${payment.toss.secret-key:}") usage to require the property),
and add a clear check in the TossPaymentClient constructor or an `@PostConstruct`
that throws an explicit IllegalStateException if secretKey is null/blank to
ensure fail-fast behavior with a clear error message.

Comment on lines +30 to +49
public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int amount) {
try {
return restClientBuilder
.baseUrl(baseUrl)
.build()
.post()
.uri("/v1/payments/confirm")
.header(HttpHeaders.AUTHORIZATION, authorizationHeader())
.header("Idempotency-Key", orderId)
.contentType(MediaType.APPLICATION_JSON)
.body(new TossPaymentConfirmRequest(paymentKey, orderId, amount))
.retrieve()
.body(TossPaymentConfirmResponse.class);
} catch (RestClientException e) {
throw new GeneralException(
GeneralErrorCode.PAYMENT_CONFIRM_FAILED,
"토스페이먼츠 결제 승인에 실패했습니다."
);
}
}
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

Add timeout configuration to prevent indefinite blocking.

The external call to TOSS Payments API has no timeout configuration, which can block request threads indefinitely if the TOSS service is slow or unresponsive. This is a reliability risk that can cause cascading failures.

⏱️ Proposed fix to add timeout configuration
+import java.time.Duration;
+
     `@PostConstruct`
     public void init() {
         this.restClient = restClientBuilder
                 .baseUrl(baseUrl)
+                .defaultRequest(request -> {
+                    request.httpRequest(req -> {
+                        req.setConnectTimeout(Duration.ofSeconds(5));
+                        req.setReadTimeout(Duration.ofSeconds(10));
+                    });
+                })
                 .build();
     }
🤖 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/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java`
around lines 30 - 49, The confirm method in TossPaymentClient currently builds a
REST client without any timeouts; update the restClientBuilder usage in confirm
to configure sensible connect and read (and optionally write) timeouts on the
client before calling .build() so the external TOSS call cannot block
indefinitely (e.g., set connectTimeout and response/read timeout on the
underlying HTTP client used by restClientBuilder). Ensure the configured
timeouts apply to the client built in TossPaymentClient.confirm and that timeout
exceptions still propagate as RestClientException (so the existing catch and
GeneralException mapping remains valid).

Comment on lines +32 to +34
return restClientBuilder
.baseUrl(baseUrl)
.build()
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

Reuse RestClient instance instead of building per request.

Building a new RestClient on every confirm() call is inefficient and creates unnecessary object allocation overhead. The client should be built once (e.g., in a @PostConstruct method or constructor) and reused across all requests.

♻️ Proposed fix to reuse RestClient
 `@Component`
 `@RequiredArgsConstructor`
 public class TossPaymentClient {
 
-    private final RestClient.Builder restClientBuilder;
+    private RestClient restClient;
+    private final RestClient.Builder restClientBuilder;
 
     `@Value`("${payment.toss.secret-key:}")
     private String secretKey;
 
     `@Value`("${payment.toss.base-url:https://api.tosspayments.com}")
     private String baseUrl;
+
+    `@PostConstruct`
+    public void init() {
+        this.restClient = restClientBuilder
+                .baseUrl(baseUrl)
+                .build();
+    }
 
     public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int amount) {
         try {
-            return restClientBuilder
-                    .baseUrl(baseUrl)
-                    .build()
+            return restClient
                     .post()
🤖 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/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java`
around lines 32 - 34, The confirm() method in TossPaymentClient currently builds
a new RestClient via restClientBuilder.baseUrl(baseUrl).build() on every call;
instead, initialize and reuse a single RestClient instance (e.g., a private
final or volatile RestClient field) by building it once in the constructor or a
`@PostConstruct` method and then have confirm() use that cached RestClient; update
TossPaymentClient to add the RestClient field, move the
restClientBuilder.baseUrl(baseUrl).build() call into initialization, and remove
per-call builds to avoid repeated allocation.

Comment on lines +43 to +48
} catch (RestClientException e) {
throw new GeneralException(
GeneralErrorCode.PAYMENT_CONFIRM_FAILED,
"토스페이먼츠 결제 승인에 실패했습니다."
);
}
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 | 🏗️ Heavy lift

Preserve TOSS API error details for debugging.

The current error handling catches all RestClientException instances and throws a generic error, losing critical details from the TOSS API response (HTTP status codes, error codes, error messages). Payment failures are difficult to debug without this context.

🔍 Proposed fix to capture TOSS error details
+import org.springframework.web.client.HttpStatusCodeException;
+
     public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int amount) {
         try {
             return restClient
                     .post()
                     .uri("/v1/payments/confirm")
                     .header(HttpHeaders.AUTHORIZATION, authorizationHeader())
                     .header("Idempotency-Key", orderId)
                     .contentType(MediaType.APPLICATION_JSON)
                     .body(new TossPaymentConfirmRequest(paymentKey, orderId, amount))
                     .retrieve()
                     .body(TossPaymentConfirmResponse.class);
+        } catch (HttpStatusCodeException e) {
+            throw new GeneralException(
+                    GeneralErrorCode.PAYMENT_CONFIRM_FAILED,
+                    "토스페이먼츠 결제 승인 실패: " + e.getStatusCode() + " - " + e.getResponseBodyAsString()
+            );
         } catch (RestClientException e) {
             throw new GeneralException(
                     GeneralErrorCode.PAYMENT_CONFIRM_FAILED,
-                    "토스페이먼츠 결제 승인에 실패했습니다."
+                    "토스페이먼츠 결제 승인 중 오류 발생: " + e.getMessage()
             );
         }
     }
🤖 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/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java`
around lines 43 - 48, The catch block in TossPaymentClient currently swallows
RestClientException details; change error handling to first catch
HttpStatusCodeException (to access e.getStatusCode() and
e.getResponseBodyAsString()) and include those details in the thrown
GeneralException (or its message/metadata) so TOSS HTTP status and response body
are preserved for debugging, then keep a fallback catch for generic
RestClientException that still preserves e.getMessage() or sets e as the cause;
also optionally log the extracted status and response body before rethrowing.

@whc9999 whc9999 merged commit 34475f1 into main May 22, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feat New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant