문제 상황
사용자가 직접 취소한경우 위 포스팅에서 확인할 수 있듯이 /api/payments/toss/fail-cancel의 경로 API로 리다이렉트 요청을 보내게 됩니다.
@Transactional
public void tossPaymentUserCancel(String code, String message, AuthUserInfo authUserInfo) {
Payment payment = paymentRepository.findByUserId(authUserInfo.getId())
.orElseThrow(() -> new GlowGlowException(GlowGlowError.PAYMENT_NO_EXIST));
paymentDomainService.cancelPayment(payment, message);
paymentRepository.save(payment);
}
그러면 컨트롤러를 거쳐서 위 코드의 tossPaymentUserCancel메서드가 호출되는데, 이 때 결제 정보를 조회하기 위해서 Repository에서 로그인한 사용자의 ID를 가지고 검증을 합니다.
Optional<Payment> findByUserId(Long userId);
findByUserId 메서드는 주어진 사용자 ID에 해당하는 단일 Payment 엔티티를 찾으려고 시도합니다. 그러나 다음과 같은 에러가 발생했습니다.
문제 원인
에러 로그
org.hibernate.NonUniqueResultException: Query did not return a unique result: 4 results were returned
at org.hibernate.query.spi.AbstractSelectionQuery.uniqueElement(AbstractSelectionQuery.java:578) ~[hibernate-core-6.5.3.Final.jar:6.5.3.Final]
at org.hibernate.query.spi.AbstractSelectionQuery.getSingleResult(AbstractSelectionQuery.java:561) ~[hibernate-core-6.5.3.Final.jar:6.5.3.Final]
에러 로그를 보면 "Query did not return a unique result: 4 results were returned" 이는 해당 userId에 대해 4개의 Payment 레코드가 존재한다는 것을 의미합니다.
데이터베이스를 확인해보면, user_id가 1인 사용자가 4개 존재하는 것을 확인할 수 있습니다.
문제는 사용자가 결제를 취소했을 때, 개발이 완료되지 않은 상태에서 결제가 이루어져 결제 상태가 ['REQUESTED', 'COMPLETED', 'CANCEL', 'REFUND', 'FAILED'] 중에서 'COMPLETED'로 저장되지 않았다는 것입니다.
이로 인해 결제 상태가 'REQUESTED'인 테스트 결제 정보가 남아 있게 되었으며, 사용자가 결제 취소한 데이터가 4개로 남아 있게 되었습니다.
문제 해결
물론 코드가 완성되고나면 문제의 원인이었던 결제 상태가 'REQUESTED'로 남아있지는 않고 ['COMPLETED', 'CANCEL', 'REFUND', 'FAILED'] 중에서 하나로 저장되기 때문에 상관없지만 완성도를 높이기 위해서는 적절한 처리가 필요합니다.
이 문제를 해결하기 위해 다음과 같은 방법을 고려해볼 수 있습니다.
- 가장 최근의 Payment만 필요한 경우 (userId)
- 특정 상태의 Payment만 필요한 경우 (userId + status)
동일한 아이디를 가진 사용자는 동시에 접근할 수 없기 때문에 동시성 문제는 발생하지 않습니다. 따라서 사용자가 결제를 취소할 경우, 해당 userId의 가장 최신 데이터가 사용자의 취소 데이터가 됩니다. 이러한 이유로 첫 번째 방법으로 결정하였습니다.
이를 위해 tossPaymentUserCancel 메서드는 사용자의 결제 정보를 조회할 때, 가장 최근의 결제 정보를 가져오는 findLatestByUserId 메서드를 호출합니다.
이 메서드는 아래와 같이 정의되어 있으며, 사용자의 ID를 기반으로 결제 정보를 최신 순서로 정렬하여 가장 최근의 결제 기록을 반환합니다.
@Transactional
public void tossPaymentUserCancel(String code, String message, AuthUserInfo authUserInfo) {
Payment payment = paymentRepository.findLatestByUserId(authUserInfo.getId())
.orElseThrow(() -> new GlowGlowException(GlowGlowError.PAYMENT_NO_EXIST));
paymentDomainService.cancelPayment(payment, message);
paymentRepository.save(payment);
}
@Query(value = "SELECT * FROM payment WHERE user_id = :userId ORDER BY created_at DESC LIMIT 1", nativeQuery = true)
Optional<Payment> findLatestByUserId(@Param("userId") Long userId);
@Query 어노테이션을 사용하여 좀 더 유연한 쿼리문을 작성함으로써, 결제 취소 요청 시 항상 사용자의 최신 결제 정보에 접근할 수 있습니다.
이렇게 작성된 쿼리는 사용자의 ID를 기준으로 정렬하여 가장 최근의 결제 데이터를 반환하므로, 동시성 문제를 고려할 필요 없이 안전하게 결제 정보를 처리할 수 있게 됩니다.
쿼리 로그 확인
로그를 보면 위와 같이 DESC로 정렬하여 가장 최신의 데이터를 조회하는 것을 확인할 수 있습니다.
정상적으로 단일 결과를 반환하며 정상적으로 결제 상태가 변경된 것을 확인할 수 있다.
'Trouble Shooting' 카테고리의 다른 글
[트러블 슈팅] 할인 계산 로직 개선: 쿠폰 할인 적용 오류 수정 (0) | 2024.10.19 |
---|---|
[트러블 슈팅] BeanCreationException 빈 충돌 해결을 위한 @ConditionalOnProperty 활용 (0) | 2024.10.11 |
[트러블 슈팅] 사용자 직접 결제 취소 시 서버 리다이렉트 문제 해결기 (0) | 2024.10.10 |
[트러블 슈팅] 토스페이먼츠 결제 트러블 슈팅 (0) | 2024.10.09 |
[트러블 슈팅] @ModelAttribute의 자동 변환 에러 해결 (0) | 2024.10.07 |