Query did not return a unique result: 2 results were returned
(해결)
- 단일 결과를 기대하는 쿼리에서 다중 결과가 반환될 때 발생하는 에러
- 현 상황에서는 Reservation에 연관된 여러 티켓이 존재하고, 그것이 한 번에 다중 결과로 반환되면서 발생하는 것으로 추측
1. 현재 상태 파악
목표
- 단일 예약(reservationId)과 연관된 데이터를 조회, 해당 예약과 관련된 각각의 좌석 정보를 개별적으로 화면에 출력을 원함
- 현재 예매된 예약번호(reservationId)와 관련된 여러 티켓이 있을 때, 티켓 정보를 하나씩 출력하여 결제 페이지에 보여주는 것이 목표
로직 구상
- 예매(Reservation)와 관련된 모든 데이터(영화, 상영관, 좌석 등)를 조회
- 예약 ID(reservationId)를 기준으로, 그와 연관된 티켓 정보를 조회.
- 각 티켓은 좌석 정보(Seat)와 상영 시간(Showtime)에 연관되며, 각 티켓의 정보를 화면에 표시
- 상품 금액, 할인 금액, 결제 금액 등의 계산 후 결제 페이지에서 출력
문제 발생 상황
- 서버를 실행하고 예약 ID로 데이터를 조회하려고 했을 때, 단일 결과를 기대했지만 2개의 결과가 반환되었다는 에러가 발생함.
- 이 문제는 보통 JPA에서
JOIN FETCH
를 사용했을 때 연관된 데이터가 중복으로 조회되어 발생하는 상황과 유사함
- 구상한 로직은 단일 예약과 여러 티켓이 연관되는 구조를 가지며, 각 티켓을 개별적으로 출력하려고 했음에도 불구하고, 중복된 결과로 인해 에러가 발생한 것으로 보임
2. 에러 발생 이유 & 해결 방법
에러 원인
JOIN FETCH
를 사용할 때, 다대일(N:1) 또는 일대다(1:N) 관계에서 중복된 데이터가 반환되기 쉬움
- 예약 하나에 여러 티켓이 존재하기 때문에, 연관된 테이블을 JOIN하면서 중복된
Reservation
객체가 반환됨
- 결과적으로
Reservation
에 대해 단일 결과를 기대하고 쿼리를 작성했지만, 여러 개의Ticket
과 Join된Reservation
이 중복되어 반환되는 문제가 발생
구체적 문제
- Reservation은 단일 엔티티지만, 그 안에 포함된 티켓이 여러 개 있을 때 이를 가져오는 쿼리가 중복된 예약을 반환하는 문제가 있음
- 이 때, Distinct를 사용했더라도 메모리 상에서는 여전히 중복된 예약 객체가 있을 수 있음. 이는 단순히 중복 제거가 쿼리 수준에서만 적용되기 때문에, Collection이 포함된 엔티티의 경우 중복된 데이터가 반환되기 때문
해결 방법 → 해봤지만 어림도 없음
JOIN FETCH
로 인한 중복 문제 해결:- Hibernate는
JOIN FETCH
에서 일대다 관계를 처리할 때, 중복된 부모 엔티티를 가져오는 경향이 있음 - 이를 방지하기 위해, Hibernate에서 중복된 엔티티를 제거하는 방법을 사용해야 함
- 다중 결과를 방지하는 Test 시나리오:
- 쿼리 자체를 단일 결과로 반환하도록 조정해야 함. Ex) 첫 번째 티켓만 가져오거나 필요한 경우 티켓을 순차적으로 조회할 수 있도록 함
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의 중복 엔티티 제거 기능을 활용하는 방법티켓 개별 출력 방식으로 변경(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. 구체적 중복 원인 분석
- Reservation과 Ticket의 관계가 일대다이므로
Reservation
을 조회할 때 각 티켓마다 동일한 예약 객체가 중복되어 반환될 수 있음
- 티켓을 리스트로 출력하지 않고 개별적으로 처리해야 한다는 조건이 있기 때문에, 단일 티켓을 기준으로 출력을 구현해야 함
3-3. 회의 부분
- 중복된 데이터 처리 방법 논의 →
JOIN FETCH
으로 인한 중복 문제, 티켓 개별 처리 방식 차이점 등
- 좌석 선택 시 개별 티켓 처리를 어떻게 할지, 데이터 중복되지 않도록 처리
H2 조회
- 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;

- 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;

- 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);

- 쿼리는 정상적으로 출력되고 단일 결과가 반환됨 → 즉 중복처리 또는
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. 문제 해결 방안
merchant_uid
생성 검토- 결제 요청 시
merchant_uid
가 중복되지 않도록 UUID 생성 확인
Iamport
결제 요청이 제대로 이루어졌는지 확인- API 요청시 전달되는 데이터(
merchant_uid
,price
등)가 정확히 전달되었는지 먼저 로그를 남겨 확인
코드 수정
PaymentController
에서merchant_uid
생성 유무 확인
@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);
}
PaymentService
에서menrchant_uid
부분에 대한 고유한merchant_uid
생성 메소드(UUID) 작성
// 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);
}
- payment/view.mustache 에서 결제요청 부분의 JS
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