From 16a565c773c9f055e8486bd6b6c1489a34b6a5dc Mon Sep 17 00:00:00 2001 From: wooh Date: Sat, 23 May 2026 04:18:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Fix]=20=EA=B2=B0=EC=A0=9C=20=EB=B0=8F=20?= =?UTF-8?q?=ED=81=AC=EB=A0=88=EB=94=A7=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/PaymentPrepareResponse.java | 3 + .../dto/toss/TossPaymentConfirmRequest.java | 6 + .../dto/toss/TossPaymentConfirmResponse.java | 2 +- .../domain/payment/entity/Payment.java | 4 +- .../payment/repository/PaymentRepository.java | 8 ++ .../domain/payment/service/CreditService.java | 18 ++- .../payment/service/PaymentService.java | 11 +- .../payment/service/TossPaymentClient.java | 34 ++++- .../user/repository/UserRepository.java | 8 ++ .../analysis/service/AnalysisServiceTest.java | 136 +++++++++++++++++- .../payment/service/PaymentServiceTest.java | 91 +++++++++++- src/test/resources/application-test.yaml | 6 + 12 files changed, 307 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java index 1d55905..af47f1d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java @@ -2,6 +2,8 @@ import com.jobdri.jobdri_api.domain.payment.entity.Payment; +import java.util.Objects; + public record PaymentPrepareResponse( Long paymentId, String orderId, @@ -12,6 +14,7 @@ public record PaymentPrepareResponse( String customerEmail ) { public static PaymentPrepareResponse of(Payment payment, String clientKey) { + Objects.requireNonNull(payment.getUser(), "Payment.user must not be null"); return new PaymentPrepareResponse( payment.getId(), payment.getOrderId(), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java index b445a0c..ae15c03 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java @@ -1,8 +1,14 @@ package com.jobdri.jobdri_api.domain.payment.dto.toss; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + public record TossPaymentConfirmRequest( + @NotBlank String paymentKey, + @NotBlank String orderId, + @Positive int amount ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java index 20665cd..66438e6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java @@ -5,7 +5,7 @@ public record TossPaymentConfirmResponse( String orderId, String orderName, String status, - Integer totalAmount, + int totalAmount, String method ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index 2797182..8c339dc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -25,13 +25,13 @@ public class Payment { @Column(nullable = false) private String content; - @Column(unique = true) + @Column(nullable = false, unique = true) private String orderId; @Column(unique = true) private String paymentKey; - @Column + @Column(nullable = false) private String planCode; @Column(nullable = false) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java index e4c5faf..205a211 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java @@ -2,7 +2,11 @@ import com.jobdri.jobdri_api.domain.payment.entity.Payment; import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -11,4 +15,8 @@ public interface PaymentRepository extends JpaRepository { List findAllByUserId(Long userId); List findAllByStatus(PaymentStatus status); Optional findByOrderId(String orderId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from Payment p where p.orderId = :orderId") + Optional findByOrderIdForUpdate(@Param("orderId") String orderId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java index 5040911..c5443f4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java @@ -9,7 +9,6 @@ import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -19,16 +18,18 @@ public class CreditService { private final UserRepository userRepository; private final CreditTransactionRepository creditTransactionRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public int charge(User user, int amount, String description, String referenceId) { + validatePositiveAmount(amount); User managedUser = getManagedUser(user); managedUser.increaseCredit(amount); saveTransaction(managedUser, CreditTransactionType.CHARGE, amount, description, referenceId); return managedUser.getCredit(); } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public int use(User user, int amount, String description, String referenceId) { + validatePositiveAmount(amount); User managedUser = getManagedUser(user); try { managedUser.decreaseCredit(amount); @@ -39,14 +40,21 @@ public int use(User user, int amount, String description, String referenceId) { return managedUser.getCredit(); } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public int refund(User user, int amount, String description, String referenceId) { + validatePositiveAmount(amount); User managedUser = getManagedUser(user); managedUser.increaseCredit(amount); saveTransaction(managedUser, CreditTransactionType.REFUND, amount, description, referenceId); return managedUser.getCredit(); } + private void validatePositiveAmount(int amount) { + if (amount <= 0) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "amount는 1 이상이어야 합니다."); + } + } + private void saveTransaction( User user, CreditTransactionType type, @@ -68,7 +76,7 @@ private User getManagedUser(User user) { if (user == null || user.getId() == null) { throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO, "인증 정보가 누락되었습니다."); } - return userRepository.findById(user.getId()) + return userRepository.findByIdForUpdate(user.getId()) .orElseThrow(() -> new GeneralException( GeneralErrorCode.USER_NOT_FOUND, "해당 유저를 찾을 수 없습니다. userId=" + user.getId() diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index c5033e8..8717820 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -14,6 +14,7 @@ import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -37,6 +38,13 @@ public class PaymentService { @Value("${payment.toss.client-key:}") private String tossClientKey; + @PostConstruct + void validateConfig() { + if (tossClientKey == null || tossClientKey.isBlank()) { + throw new IllegalStateException("payment.toss.client-key must be configured"); + } + } + public List getPlans() { return Arrays.stream(CreditPlan.values()) .map(CreditPlanResponse::from) @@ -63,7 +71,7 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) @Transactional public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) { User validatedUser = userService.validateUser(user); - Payment payment = paymentRepository.findByOrderId(request.orderId()) + Payment payment = paymentRepository.findByOrderIdForUpdate(request.orderId()) .orElseThrow(() -> new GeneralException( GeneralErrorCode.PAYMENT_NOT_FOUND, "결제 정보를 찾을 수 없습니다. orderId=" + request.orderId() @@ -117,7 +125,6 @@ private void validateTossResponse(PaymentConfirmRequest request, TossPaymentConf if (response == null || !request.orderId().equals(response.orderId()) || !request.paymentKey().equals(response.paymentKey()) - || response.totalAmount() == null || response.totalAmount() != request.amount()) { throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "결제 승인 응답 검증에 실패했습니다."); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java index 7e9ab47..e720712 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -4,15 +4,19 @@ import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Base64; @Component @@ -20,18 +24,33 @@ public class TossPaymentClient { private final RestClient.Builder restClientBuilder; + private RestClient restClient; - @Value("${payment.toss.secret-key:}") + @Value("${payment.toss.secret-key}") private String secretKey; @Value("${payment.toss.base-url:https://api.tosspayments.com}") private String baseUrl; + @PostConstruct + void init() { + if (secretKey == null || secretKey.isBlank()) { + throw new IllegalStateException("payment.toss.secret-key must be configured"); + } + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(5)); + requestFactory.setReadTimeout(Duration.ofSeconds(10)); + + this.restClient = restClientBuilder + .baseUrl(baseUrl) + .requestFactory(requestFactory) + .build(); + } + public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int amount) { try { - return restClientBuilder - .baseUrl(baseUrl) - .build() + return restClient .post() .uri("/v1/payments/confirm") .header(HttpHeaders.AUTHORIZATION, authorizationHeader()) @@ -40,10 +59,15 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int .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() ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/user/repository/UserRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/user/repository/UserRepository.java index 1a10ae4..0610582 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/user/repository/UserRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/user/repository/UserRepository.java @@ -1,11 +1,19 @@ package com.jobdri.jobdri_api.domain.user.repository; import com.jobdri.jobdri_api.domain.user.entity.User; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java index 86a8b17..e89137b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -21,6 +21,8 @@ import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; @@ -33,6 +35,9 @@ import org.springframework.test.context.ActiveProfiles; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -73,6 +78,9 @@ class AnalysisServiceTest { @Autowired private UserRepository userRepository; + @Autowired + private CreditTransactionRepository creditTransactionRepository; + @MockBean private AnalysisAiClient analysisAiClient; @@ -82,6 +90,7 @@ void analyzeSavesAnalysis() { User user = saveUser("analysis-save@example.com"); MockApply mockApply = saveMockApply(user); Question question = saveQuestion(mockApply, "지원 직무 경험을 작성해주세요.", "Spring Boot API를 개발했습니다."); + int initialCredit = userRepository.findById(user.getId()).orElseThrow().getCredit(); when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( 120, 82, @@ -115,6 +124,29 @@ void analyzeSavesAnalysis() { assertThat(mockApplyRepository.findById(mockApply.getId()).orElseThrow().getStatus()) .isEqualTo(MockApplyStatus.COMPLETED); assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isPresent(); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(initialCredit - 1); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.USE + )).hasSize(1); + } + + @Test + @DisplayName("LLM 분석 실패 시 크레딧 차감과 분석 저장을 롤백한다") + void analyzeRollsBackCreditWhenLlmFails() { + User user = saveUser("analysis-credit-rollback@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 직무 경험을 작성해주세요.", "Spring Boot API를 개발했습니다."); + int initialCredit = userRepository.findById(user.getId()).orElseThrow().getCredit(); + when(analysisAiClient.analyze(any(), any())).thenThrow(new RuntimeException("LLM timeout")); + + assertThatThrownBy(() -> analysisService.analyze(user, mockApply.getId())) + .isInstanceOf(RuntimeException.class) + .hasMessage("LLM timeout"); + + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(initialCredit); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isEmpty(); + assertThat(creditTransactionRepository.findAllByUserIdOrderByCreatedAtDescIdDesc(user.getId())).isEmpty(); } @Test @@ -252,9 +284,67 @@ void getAnalysis() { assertThat(response.questions().get(0).analyses()).hasSize(1); } + @Test + @DisplayName("크레딧 1개인 사용자가 동시에 분석을 요청해도 하나만 성공한다") + void analyzeConcurrentlyUsesCreditOnlyOnce() throws Exception { + User user = saveUserWithCredit("analysis-concurrent-credit@example.com", 1); + MockApply firstMockApply = saveMockApply(user); + MockApply secondMockApply = saveMockApply(user); + Question firstQuestion = saveQuestion(firstMockApply, "지원 직무 경험을 작성해주세요.", "Spring Boot API를 개발했습니다."); + Question secondQuestion = saveQuestion(secondMockApply, "문제 해결 경험을 작성해주세요.", "장애 로그를 분석했습니다."); + when(analysisAiClient.analyze(any(), any())) + .thenReturn(new AnalysisLlmResponse( + 80, + 81, + 82, + 83, + "첫 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + firstQuestion.getId(), + "Spring Boot API를 개발했습니다.", + "mentioned", + "성과 지표가 부족합니다.", + "Spring Boot API를 개발해 응답 시간을 개선했습니다." + )) + )) + .thenReturn(new AnalysisLlmResponse( + 70, + 71, + 72, + 73, + "두 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + secondQuestion.getId(), + "장애 로그를 분석했습니다.", + "mentioned", + "결과가 부족합니다.", + "장애 로그를 분석해 복구 시간을 단축했습니다." + )) + )); + + List results = runConcurrently( + List.of( + () -> analyzeSafely(user, firstMockApply.getId()), + () -> analyzeSafely(user, secondMockApply.getId()) + ) + ); + + assertThat(results).filteredOn(Result::success).hasSize(1); + assertThat(results).filteredOn(result -> !result.success()).hasSize(1); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isZero(); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.USE + )).hasSize(1); + } + private User saveUser(String email) { + return saveUserWithCredit(email, 11); + } + + private User saveUserWithCredit(String email, int credit) { User user = User.signup("테스트 사용자", email, "encoded-password"); - user.increaseCredit(10); + user.increaseCredit(credit - 1); return userRepository.save(user); } @@ -287,4 +377,48 @@ private DetailClassification saveDetailClassification() { classificationRepository.save(classification); return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); } + + private Result analyzeSafely(User user, Long mockApplyId) { + try { + analysisService.analyze(user, mockApplyId); + return Result.ok(); + } catch (Exception e) { + return Result.failure(e); + } + } + + private List runConcurrently(List> tasks) throws Exception { + var ready = new CountDownLatch(tasks.size()); + var start = new CountDownLatch(1); + var executor = Executors.newFixedThreadPool(tasks.size()); + try { + var futures = tasks.stream() + .map(task -> executor.submit(() -> { + ready.countDown(); + start.await(); + return task.call(); + })) + .toList(); + ready.await(); + start.countDown(); + + List results = new java.util.ArrayList<>(); + for (var future : futures) { + results.add(future.get()); + } + return results; + } finally { + executor.shutdownNow(); + } + } + + private record Result(boolean success, Exception exception) { + static Result ok() { + return new Result(true, null); + } + + static Result failure(Exception exception) { + return new Result(false, exception); + } + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index bcc2c65..3ff86d4 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -7,6 +7,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.entity.Payment; import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; @@ -21,8 +22,15 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; @SpringBootTest @@ -70,7 +78,8 @@ void prepare() { assertThat(response.orderName()).isEqualTo("JobDri 크레딧 5회권"); assertThat(response.amount()).isEqualTo(11500); assertThat(response.creditAmount()).isEqualTo(5); - assertThat(paymentRepository.findByOrderId(response.orderId())).isPresent(); + Payment payment = paymentRepository.findByOrderId(response.orderId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); } @Test @@ -78,9 +87,10 @@ void prepare() { void confirm() { User user = saveUser("payment-confirm@example.com"); PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); - when(tossPaymentClient.confirm("payment-key", prepared.orderId(), 2500)) + String paymentKey = "payment-key-" + prepared.orderId(); + when(tossPaymentClient.confirm(paymentKey, prepared.orderId(), 2500)) .thenReturn(new TossPaymentConfirmResponse( - "payment-key", + paymentKey, prepared.orderId(), prepared.orderName(), "DONE", @@ -90,7 +100,7 @@ void confirm() { PaymentConfirmResponse response = paymentService.confirm( user, - new PaymentConfirmRequest("payment-key", prepared.orderId(), 2500) + new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500) ); assertThat(response.status()).isEqualTo(PaymentStatus.COMPLETED); @@ -117,7 +127,80 @@ void confirmThrowsWhenAmountMismatch() { .isEqualTo(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH); } + @Test + @DisplayName("동일 결제 승인 요청이 동시에 들어와도 한 번만 크레딧을 충전한다") + void confirmConcurrentlyChargesOnlyOnce() throws Exception { + User user = saveUser("payment-concurrent-confirm@example.com"); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + String paymentKey = "payment-key-" + prepared.orderId(); + when(tossPaymentClient.confirm(anyString(), anyString(), anyInt())) + .thenReturn(new TossPaymentConfirmResponse( + paymentKey, + prepared.orderId(), + prepared.orderName(), + "DONE", + 2500, + "CARD" + )); + PaymentConfirmRequest request = new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500); + + List results = runConcurrently(2, () -> { + try { + paymentService.confirm(user, request); + return Result.ok(); + } catch (Exception e) { + return Result.failure(e); + } + }); + + assertThat(results).filteredOn(Result::success).hasSize(1); + assertThat(results).filteredOn(result -> !result.success()).hasSize(1); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + private User saveUser(String email) { return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); } + + private List runConcurrently(int threadCount, Callable task) throws Exception { + var ready = new CountDownLatch(threadCount); + var start = new CountDownLatch(1); + var executor = Executors.newFixedThreadPool(threadCount); + try { + List> tasks = java.util.stream.IntStream.range(0, threadCount) + .mapToObj(i -> (Callable) () -> { + ready.countDown(); + start.await(); + return task.call(); + }) + .toList(); + var futures = tasks.stream() + .map(executor::submit) + .toList(); + ready.await(); + start.countDown(); + + List results = new java.util.ArrayList<>(); + for (var future : futures) { + results.add(future.get()); + } + return results; + } finally { + executor.shutdownNow(); + } + } + + private record Result(boolean success, Exception exception) { + static Result ok() { + return new Result(true, null); + } + + static Result failure(Exception exception) { + return new Result(false, exception); + } + } } diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index ea6145c..223740f 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -76,3 +76,9 @@ jwt: job-posting: image-upload: max-size-bytes: 5242880 + +payment: + toss: + client-key: test-toss-client-key + secret-key: test-toss-secret-key + base-url: https://api.tosspayments.com From 95dcf68df2801e3ea2ee85fe4e71b3e1ee492b55 Mon Sep 17 00:00:00 2001 From: wooh Date: Sat, 23 May 2026 04:29:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[Fix]=20=ED=86=A0=EC=8A=A4=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20=EC=9D=91=EB=8B=B5=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=85=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/TossPaymentClient.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java index e720712..3ce5a28 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -6,6 +6,7 @@ import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -21,8 +22,11 @@ @Component @RequiredArgsConstructor +@Slf4j public class TossPaymentClient { + private static final int LOG_MESSAGE_MAX_LENGTH = 500; + private final RestClient.Builder restClientBuilder; private RestClient restClient; @@ -60,14 +64,21 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int .retrieve() .body(TossPaymentConfirmResponse.class); } catch (HttpStatusCodeException e) { + log.warn( + "Toss payment confirm failed. status={}, response={}", + e.getStatusCode(), + truncate(e.getResponseBodyAsString()), + e + ); throw new GeneralException( GeneralErrorCode.PAYMENT_CONFIRM_FAILED, - "토스페이먼츠 결제 승인 실패: " + e.getStatusCode() + " - " + e.getResponseBodyAsString() + "토스페이먼츠 결제 승인 실패" ); } catch (RestClientException e) { + log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage()), e); throw new GeneralException( GeneralErrorCode.PAYMENT_CONFIRM_FAILED, - "토스페이먼츠 결제 승인 중 오류 발생: " + e.getMessage() + "토스페이먼츠 결제 승인 중 오류 발생" ); } } @@ -76,4 +87,11 @@ private String authorizationHeader() { String credential = secretKey + ":"; return "Basic " + Base64.getEncoder().encodeToString(credential.getBytes(StandardCharsets.UTF_8)); } + + private String truncate(String value) { + if (value == null || value.length() <= LOG_MESSAGE_MAX_LENGTH) { + return value; + } + return value.substring(0, LOG_MESSAGE_MAX_LENGTH) + "..."; + } } From d02df8cfe082dadc642683feaf3deae1b966fd70 Mon Sep 17 00:00:00 2001 From: wooh Date: Sat, 23 May 2026 04:43:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[Fix]=20=ED=86=A0=EC=8A=A4=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/service/TossPaymentClient.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java index 3ce5a28..2e9c1a8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -67,15 +67,16 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int log.warn( "Toss payment confirm failed. status={}, response={}", e.getStatusCode(), - truncate(e.getResponseBodyAsString()), - e + truncate(e.getResponseBodyAsString()) ); + log.warn("Toss payment confirm exception", e); throw new GeneralException( GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "토스페이먼츠 결제 승인 실패" ); } catch (RestClientException e) { - log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage()), e); + log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage())); + log.warn("Toss payment confirm request exception", e); throw new GeneralException( GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "토스페이먼츠 결제 승인 중 오류 발생"