[Project] 결제 부분 Error

김호정's avatar
Oct 09, 2024
[Project] 결제 부분 Error
 
Query did not return a unique result: 2 results were returned (해결)
💡
  • 단일 결과를 기대하는 쿼리에서 다중 결과가 반환될 때 발생하는 에러
  • 현 상황에서는 Reservation에 연관된 여러 티켓이 존재하고, 그것이 한 번에 다중 결과로 반환되면서 발생하는 것으로 추측
 

1. 현재 상태 파악

목표
💡
  • 단일 예약(reservationId)과 연관된 데이터를 조회, 해당 예약과 관련된 각각의 좌석 정보를 개별적으로 화면에 출력을 원함
  • 현재 예매된 예약번호(reservationId)와 관련된 여러 티켓이 있을 때, 티켓 정보를 하나씩 출력하여 결제 페이지에 보여주는 것이 목표
 
로직 구상
💡
  1. 예매(Reservation)와 관련된 모든 데이터(영화, 상영관, 좌석 등)를 조회
  1. 예약 ID(reservationId)를 기준으로, 그와 연관된 티켓 정보를 조회.
  1. 각 티켓은 좌석 정보(Seat)와 상영 시간(Showtime)에 연관되며, 각 티켓의 정보를 화면에 표시
  1. 상품 금액, 할인 금액, 결제 금액 등의 계산 후 결제 페이지에서 출력
 
문제 발생 상황
💡
  1. 서버를 실행하고 예약 ID로 데이터를 조회하려고 했을 때, 단일 결과를 기대했지만 2개의 결과가 반환되었다는 에러가 발생함.
  1. 이 문제는 보통 JPA에서 JOIN FETCH를 사용했을 때 연관된 데이터가 중복으로 조회되어 발생하는 상황과 유사함
  1. 구상한 로직은 단일 예약과 여러 티켓이 연관되는 구조를 가지며, 각 티켓을 개별적으로 출력하려고 했음에도 불구하고, 중복된 결과로 인해 에러가 발생한 것으로 보임
 

2. 에러 발생 이유 & 해결 방법

에러 원인
💡
  • JOIN FETCH를 사용할 때, 다대일(N:1) 또는 일대다(1:N) 관계에서 중복된 데이터가 반환되기 쉬움
  • 예약 하나에 여러 티켓이 존재하기 때문에, 연관된 테이블을 JOIN하면서 중복된 Reservation 객체가 반환됨
  • 결과적으로 Reservation에 대해 단일 결과를 기대하고 쿼리를 작성했지만, 여러 개의 Ticket과 Join된 Reservation이 중복되어 반환되는 문제가 발생
구체적 문제
💡
  • Reservation은 단일 엔티티지만, 그 안에 포함된 티켓이 여러 개 있을 때 이를 가져오는 쿼리가 중복된 예약을 반환하는 문제가 있음
  • 이 때, Distinct를 사용했더라도 메모리 상에서는 여전히 중복된 예약 객체가 있을 수 있음. 이는 단순히 중복 제거가 쿼리 수준에서만 적용되기 때문에, Collection이 포함된 엔티티의 경우 중복된 데이터가 반환되기 때문
 
해결 방법 → 해봤지만 어림도 없음
💡
  • JOIN FETCH로 인한 중복 문제 해결:
    • HibernateJOIN FETCH에서 일대다 관계를 처리할 때, 중복된 부모 엔티티를 가져오는 경향이 있음
    • 이를 방지하기 위해, Hibernate에서 중복된 엔티티를 제거하는 방법을 사용해야 함
  • 다중 결과를 방지하는 Test 시나리오:
    • 쿼리 자체를 단일 결과로 반환하도록 조정해야 함. Ex) 첫 번째 티켓만 가져오거나 필요한 경우 티켓을 순차적으로 조회할 수 있도록 함
 
  1. JOIN FETCH 사용 시 중복 문제 해결
      • 쿼리에서 DISTINCT를 사용하고 JOIN FETCH 로 인한 중복 데이터 제거
      @Query("select distinct r from Reservation r join fetch r.tickets t join fetch t.showtime s join fetch s.movie m where r.id = :reservationId") Optional<Reservation> findById(@Param("reservationId") Long reservationId);
      *하지만 쿼리에서 DISTINCT를 사용해도 중복된 부모 엔티티가 반환되는 경우가 많기 때문에, Hibernate의 중복 엔티티 제거 기능을 활용하는 방법
       
  1. 티켓 개별 출력 방식으로 변경 (X → 티켓은 리스트로 받기)
      • 만약, 티켓이 여러 개인 경우에도 개별 티켓을 순차적으로 처리하여 중복된 결과를 방지해야 한다면, 각 티켓을 하나씩 순차적으로 처리하도록 로직을 구성 → 단일 티켓을 처리하는 식으로 코드 수정
      수정 전 코드
      // 첫 번째 티켓을 사용해 상영시간, 영화, 상영관 등의 정보를 가져오기 Showtime showtime = tickets.get(0).getShowtime(); Movie movie = showtime.getMovie(); Screen screen = showtime.getScreen(); Cinema cinema = screen.getCinema(); // 좌석 정보를 개별적으로 처리 (티켓 개별 처리) String seat = tickets.get(0).getSeat().getSeatNumber(); // 첫 번째 티켓 좌석번호 // 인원수 (티켓 개수) int people = tickets.size(); // 총 금액 계산 (인원 * 티켓 가격) Double totalPrice = people * showtime.getPrice();
      수정 후 코드
      Ticket ticket = reservation.getTickets().get(0); // 추가 Showtime showtime = ticket.getShowtime(); Movie movie = showtime.getMovie(); Screen screen = showtime.getScreen(); Cinema cinema = screen.getCinema(); // 단일 좌석 정보만 가져오기 String seat = ticket.getSeat().getSeatNumber(); int people = 1; // 티켓이 1개일 경우 Double totalPrice = showtime.getPrice();
 

3. 놓친 부분 및 주의점

3-1. 중복된 결과 처리
💡
  • JOIN FETCH는 여러 관계를 조인할 때 중복된 결과를 반환할 수 있으므로, 이를 처리하는 방법에 주의
  • 쿼리에서 DISTINCT를 적용했음에도 문제가 해결되지 않았다면, Hibernate의 엔티티 중복 제거 방법 사용
3-2. 구체적 중복 원인 분석
💡
  • ReservationTicket의 관계가 일대다이므로 Reservation을 조회할 때 각 티켓마다 동일한 예약 객체가 중복되어 반환될 수 있음
  • 티켓을 리스트로 출력하지 않고 개별적으로 처리해야 한다는 조건이 있기 때문에, 단일 티켓을 기준으로 출력을 구현해야 함
 
3-3. 회의 부분
💡
  • 중복된 데이터 처리 방법 논의 → JOIN FETCH 으로 인한 중복 문제, 티켓 개별 처리 방식 차이점 등
  • 좌석 선택 시 개별 티켓 처리를 어떻게 할지, 데이터 중복되지 않도록 처리

H2 조회

  1. Reservation ↔ Ticket 간 Join Query = 정상 출력
SELECT r.id AS reservation_id, r.user_id, r.created_at, t.id AS ticket_id, t.seat_id, t.showtime_id, t.created_at AS ticket_created_at FROM reservation_tb r LEFT JOIN ticket_tb t ON r.id = t.reservation_id WHERE r.id = 1;
notion image
 
  1. Ticket ↔ Showtime ↔ Seat 간 Join Query = 정상 출력
SELECT t.id AS ticket_id, t.seat_id, t.showtime_id, s.seat_number, s.row_num, s.col_num, st.started_at, st.movie_id FROM ticket_tb t LEFT JOIN seat_tb s ON t.seat_id = s.id LEFT JOIN showtime_tb st ON t.showtime_id = st.id WHERE t.reservation_id = 1;
notion image
 
  1. Movie ↔ Showtime 간 Join Query = 정상 출력
SELECT m.movie_nm, st.started_at, c.name AS cinema_name, scr.name AS screen_name FROM showtime_tb st LEFT JOIN movie_tb m ON st.movie_id = m.id LEFT JOIN screen_tb scr ON st.screen_id = scr.id LEFT JOIN cinema_tb c ON scr.cinema_id = c.id WHERE st.id IN (SELECT showtime_id FROM ticket_tb WHERE reservation_id = 1);
notion image
  • 쿼리는 정상적으로 출력되고 단일 결과가 반환됨 → 즉 중복처리 또는 fetch join 과 관련된 문제 가능성
  • @BatchSize, @OneToMany(fetch = FetchType.LAZY), @Transactional 등의 전략을 확인

해결

 
 
결제요청 시 “이미 결제가 이루어진 거래건입니다” (해결)

1. 현재 상태 파악

💡
  • merchant_uid 중복으로 인한 중복 결제건 발생 → imp_uid와 merchant_uid 값이 제대로 처리되지 않음. 즉 사용자가 동일한 결제 요청을 여러번 시도할 경우 발생(결제가 이미 성공했음에도 중복 결제를 시도) → 추후 결제 취소 기능도 구현
  • UUID를 생성해서 해당 중복을 피하는 것이 목표 → 생성하는 코드를 작성했으나 생성되지 않았고 default 값이 사용됨 (생성 로직에서 제대로 된 고유값이 생성되었는지 확인)
  • 추가로 imp_uid = null
    • Iamport API로 결제 요청을 보낼 때 merchant_uid가 잘못되었거나, 결제가 실패한 상태에서 imp_uid가 생성되지 않아서 null로 반환됐을 수 있음
 

2. 문제 해결 방안

💡
  1. merchant_uid 생성 검토
    1. 결제 요청 시 merchant_uid가 중복되지 않도록 UUID 생성 확인
  1. Iamport 결제 요청이 제대로 이루어졌는지 확인
    1. API 요청시 전달되는 데이터(merchant_uid, price 등)가 정확히 전달되었는지 먼저 로그를 남겨 확인
 
코드 수정
💡
  1. PaymentController에서 merchant_uid 생성 유무 확인
    1. @PostMapping("/payment") public ResponseEntity<?> validationPayment(@RequestBody PaymentRequest.SaveDTO saveDTO) { // 예약 ID와 결제 요청 전 고유한 merchant_uid 생성 String merchantUid = paymentService.generateMerchantUid(saveDTO.getReservationId()); // 결제 정보 저장 saveDTO.setMerchantUid(merchantUid); // 고유한 merchant_uid를 저장 // 결제 서비스 호출 paymentService.save(saveDTO); return new ResponseEntity<>(Resp.ok(null), HttpStatus.OK); }
       
  1. PaymentService 에서 menrchant_uid 부분에 대한 고유한 merchant_uid 생성 메소드(UUID) 작성
    1. // UUID를 사용하여 고유 ID 생성 : 기존 작성 코드 public String generateMerchantUid(Long reservationId) { return "merchant_" + reservationId + "_" + UUID.randomUUID().toString(); // UUID를 사용하여 고유 ID 생성 } public ResponseEntity<?> validationPayment(@RequestBody PaymentRequest.SaveDTO saveDTO) { // 고유한 merchant_uid 생성 String merchantUid = paymentService.generateMerchantUid(saveDTO.getReservationId()); // merchantUid와 impUid 값을 출력하여 로그 확인 System.out.println("Generated merchant_uid: " + merchantUid); saveDTO.setMerchantUid(merchantUid); // 고유한 merchant_uid 저장 // 결제 서비스 호출 paymentService.save(saveDTO); return new ResponseEntity<>(Resp.ok(null), HttpStatus.OK); }
       
  1. payment/view.mustache 에서 결제요청 부분의 JS
    1. function requestPay() { // merchant_uid는 반드시 UUID와 같은 고유한 값으로 생성해야 함 var merchantUid = 'merchant_' + new Date().getTime(); // 고유한 merchant_uid 생성 var orderUid = '{{paymentData.reservationId}}'; // reservationId는 예매번호로 사용 var itemName = '{{paymentData.movieTitle}}'; var paymentPrice = '{{paymentData.totalPrice}}'; var buyerName = '{{paymentData.username}}'; var buyerEmail = '{{paymentData.email}}'; var phone = '{{paymentData.phone}}'; console.log(merchantUid, orderUid, itemName, paymentPrice, buyerName, buyerEmail, phone); IMP.request_pay({ pg : 'html5_inicis.INIpayTest', pay_method : 'card', merchant_uid: merchantUid, // 고유한 결제 트랜잭션 ID name : '{{paymentData.movieTitle}}', // 영화 제목 amount : paymentPrice, // 결제 금액 buyer_email : buyerEmail, // 유저 이메일 buyer_name : buyerName, // 유저 이름 buyer_tel : phone // 유저 연락처 }, function(rsp) { if (rsp.success) { console.log('결제 성공! imp_uid: ' + rsp.imp_uid + ', merchant_uid: ' + rsp.merchant_uid); // 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우 // jQuery로 HTTP 요청 jQuery.ajax({ url: "/payment", method: "POST", headers: {"Content-Type": "application/json"}, data: JSON.stringify({ "impUid": rsp.imp_uid, // 아임포트 결제 고유번호 "merchantUid": rsp.merchant_uid, // 결제 트랜잭션 ID "reservationId": orderUid, // 예매 ID "price": paymentPrice // 결제 금액 }) }).done(function (response) { console.log(response); alert('결제가 완료되었습니다.'); window.location.href = "/api/payment/success?reservationId=" + orderUid; }); } else { console.error("결제 실패: " + rsp.error_msg); alert('결제 실패. 좌석 페이지로 이동합니다.'); window.location.href = "/seat"; } }); }
      var merchantUid = 'merchant_' + new Date().getTime(); // 고유한 merchant_uid 생성
      → merchant_uid가 고유하게 생성되도록 하고, 결제 요청마다 새로운 ID가 부여되어 중복 결제가 발생하지 않도록 수정
       

3. 놓친 부분 및 주의점

💡
  • mustachedml JS 로직이 중복 결제 에러를 발생시킨다는 점을 간과하고, 처음에는 서버 측 로직에서만 문제를 찾으려 했던 점
  • merchant_uid가 고유해야 한다는 점을 강조, 이 값이 모든 결제 건에서 유일하게 생성되도록 주의
  • imp_uid가 null로 반환되는 경우는 주로 결제 과정 중 실패한 경우이므로 결제 실패 시에도 클라이언트에게 정확한 피드백을 제공하도록 개선 필요
 
 
Share article

keepgoing