1차 팀 프로젝트 : FilmTalk

영화 예매 웹 플랫폼 : 결제
SHIN's avatar
Oct 01, 2024
1차 팀 프로젝트 : FilmTalk
 
[payment/view] 결제 메인 페이지
결제 시나리오
노트 필기 정리
notion image
로직 구성 순서(with PortOne)

1. 라이브러리 및 의존성 추가

plugins { id 'java' id 'org.springframework.boot' version '3.3.3' id 'io.spring.dependency-management' version '1.1.6' } group = 'shop.mtcoding' version = '0.0.1-SNAPSHOT' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() // 아임포트 관련 maven {url 'https://jitpack.io'} } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-mustache' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-validation' runtimeOnly 'com.mysql:mysql-connector-j' // 아임포트 관련 // // https://mvnrepository.com/artifact/com.github.iamport/iamport-rest-client-java implementation group: 'com.github.iamport', name: 'iamport-rest-client-java', version: '0.2.22' // https://mvnrepository.com/artifact/com.squareup.retrofit2/adapter-rxjava2 implementation group: 'com.squareup.retrofit2', name: 'adapter-rxjava2', version: '2.9.0' // https://mvnrepository.com/artifact/com.google.code.gson/gson implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.5' // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.9.3' // https://mvnrepository.com/artifact/com.squareup.retrofit2/converter-gson implementation group: 'com.squareup.retrofit2', name: 'converter-gson', version: '2.3.0' } tasks.named('test') { useJUnitPlatform() }
 

2. Controller에서 request 임시 데이터 - view 영역

@GetMapping("/payment/view") public String paymentView(Model model) { // 임시 더미 model.addAttribute("reservationId", "1"); model.addAttribute("posterImg", "/img/inter.jpg"); model.addAttribute("movieTitle", "인터스텔라"); model.addAttribute("showTime", "2024-09-12 (목) 12:00 ~ 16:30"); model.addAttribute("cinema", "서면롯데시네마 Screen 1"); model.addAttribute("people", "성인 2"); model.addAttribute("seat", "1, 4"); model.addAttribute("price", "100원"); model.addAttribute("discount", "0 원"); model.addAttribute("payPrice", "10원"); model.addAttribute("userName", "신민재"); model.addAttribute("email", "example@example.com"); model.addAttribute("phone", "010-1234-5678"); return "payment/view"; }
 

3. view.mustache (controller 연동)

<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>결제 메인페이지</title> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"/> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="/css/payment.css"> <link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/header.css"> <script src="https://cdn.iamport.kr/v1/iamport.js"></script> </head> <body class="view-page"> <!-- 컨테이너 클래스 추가 --> {{> layout/header}} <!-- 메인 컨텐츠 --> <main id="main"> <div class="inner"> <!-- 예약정보 섹션 --> <div class="section__wrapper"> <div class="section__header">예약정보</div> <div class="section__content"> <div class="movie__info"> <div class="poster__box"> <img src="{{posterImg}}" alt="영화 포스터" class="img-fluid"> </div> <h3 class="movie__title">{{movieTitle}}</h3> <ul class="movie__details"> <li> <strong>일시</strong> <div>{{showTime}}</div> </li> <li> <strong>영화관</strong> <div>{{cinema}}관</div> </li> <li> <strong>인원</strong> <div>{{people}}</div> </li> <hr> <li> <strong>좌석</strong> <div>{{seat}}</div> </li> </ul> </div> </div> </div> <!-- 결제수단 섹션 --> <div class="section__wrapper"> <div class="section__header">결제수단</div> <div class="section__content"> <div class="payment__methods"> <h5>할인/포인트</h5> <!-- 관람권 버튼 --> <button id="viewing-ticket-btn" class="btn btn-outline-secondary">관람권</button> <!-- 할인권 버튼 --> <button id="discount-ticket-btn" class="btn btn-outline-secondary">할인권</button> <!-- 구분선 --> <hr> <div class="final__payment__method">최종 결제수단</div> <div class="payment__options"> <button class="btn">신용카드</button> <button class="btn">간편결제</button> </div> </div> </div> </div> <!-- 결제하기 섹션 --> <div class="section__wrapper"> <div class="section__header">결제하기</div> <div class="section__content"> <div class="payment__summary"> <ul> <li>상품금액 <span>{{price}}</span></li> <li>할인금액 <span>{{discount}}</span></li> <li>결제금액 <span>{{payPrice}}</span></li> </ul> <button class="btn" onclick="requestPay()">결제하기</button> </div> </div> </div> </div> </main> {{> layout/footer}} <script> var IMP = window.IMP; IMP.init("imp28446715"); // 고객사 식별코드 function requestPay() { var orderUid = '{{reservationId}}'; var itemName = '{{movieTitle}}'; var paymentPrice = '{{payPrice}}'; var buyerName = '{{userName}}'; var buyerEmail = '{{email}}'; var phone = '{{phone}}'; IMP.request_pay({ // m_redirect_url: "/payment", pg : 'html5_inicis.INIpayTest', pay_method : 'card', merchant_uid: orderUid, // 예매 번호 name : itemName, // 영화 제목 amount : paymentPrice, // 티켓 가격 buyer_email : buyerEmail, // 유저 이메일 buyer_name : buyerName, // 유저 이름 buyer_tel : phone, // 유저 연락처 buyer_postcode : '', // 임의의 값 }, function(rsp) { if (rsp.success) { alert('call back!!: ' + JSON.stringify(rsp)); // 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우 // jQuery로 HTTP 요청 jQuery.ajax({ url: "/payment", method: "POST", headers: {"Content-Type": "application/json"}, data: JSON.stringify({ "impUid": rsp.imp_uid, // 포트원 결제 고유번호 "reservationId": rsp.merchant_uid // 주문번호 (예매번호) }) }).done(function (response) { console.log(response); alert('결제가 완료되었습니다.'); window.location.href = "/payment/success"; }) } else { // alert("success? "+ rsp.success+ ", 결제에 실패하였습니다. 에러 내용: " + JSON.stringify(rsp)); alert('결제 실패. 좌석 페이지로 이동합니다.'); window.location.href = "/seat"; // TODO: 결제 실패 페이지를 만들지, 좌석 페이지로 redirect 할지 } }); } </script> </body> </html>
 
 

4. 결제창 요청 (Post)

@PostMapping("/payment") // 결제 프로세스 처리 public ResponseEntity<?> validationPayment(@RequestBody PaymentRequest.SaveDTO saveDTO) { paymentService.save(saveDTO); // 서비스로 전달해서 결제 정보 저장 // "http://localhost/payment?imp_uid=imp_359888216361&merchant_uid=255eae01-2b82-4cbf-b40b-49b12d793703&imp_success=true" return new ResponseEntity<>(Resp.ok(null), HttpStatus.OK); }
 

5. PaymentRequest

package shop.mtcoding.filmtalk.payment; import jakarta.persistence.EntityManager; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; public class PaymentRequest { @Data public static class SaveDTO { private Long reservationId; // 예매 ID (결제 고유 번호) = merchant_uid private String impUid; // 가맹점 ID } }
 

7. Service

💡
save() 메소드
  • 결제가 성공적으로 처리됐는지 포트원 API로 확인 후, 결제 미완료시 예약 데이터 삭제 및 예외 발생.
  • 결제 성공시 결제 정보를 DB에 Inser
package shop.mtcoding.filmtalk.payment; import com.siot.IamportRestClient.IamportClient; import com.siot.IamportRestClient.response.IamportResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.filmtalk.core.error.ex.ExceptionApi404; import shop.mtcoding.filmtalk.core.error.ex.ExceptionApi500; import shop.mtcoding.filmtalk.reservation.Reservation; import shop.mtcoding.filmtalk.reservation.ReservationRepository; import java.sql.Timestamp; import java.time.LocalDateTime; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class PaymentService { private final PaymentRepository paymentRepository; private final PaymentQueryRepository paymentQueryRepository; private final IamportClient iamportClient; private final ReservationRepository reservationRepository; @Transactional public void save(PaymentRequest.SaveDTO saveDTO) { try { // 클라이언트가 결제 됐다고 알림 -> SaveDTO로 / 예매ID(reservationId), 가맹점ID(impUid) IamportResponse<com.siot.IamportRestClient.response.Payment> iamportResponse = iamportClient.paymentByImpUid(saveDTO.getImpUid()); // 결제 완료가 아니면 if(!iamportResponse.getResponse().getStatus().equals("paid")) { // 티켓도 2장 삭제 // 주문&결제 삭제 (예매=부모 삭제하면, 티켓도 삭제 되는지 확인) reservationRepository.deleteById(saveDTO.getReservationId()); throw new ExceptionApi500("결제 미완료"); } // Payment Insert Reservation reservationPS = reservationRepository.findById(saveDTO.getReservationId()) .orElseThrow(() -> new ExceptionApi404("예매 내역이 존재하지 않아서 결제할 수 없습니다")); Payment payment = Payment.builder() .price(28000.0) // 임시 더미 금액 -> 티켓 2장 가격 .point(0) .state(2) // 결제 완료 .cnclDate(null) .payDate(Timestamp.valueOf(LocalDateTime.now())) .impUid(saveDTO.getImpUid()) .type("card") .mycoupon(null) .reservation(reservationPS) .build(); paymentRepository.save(payment); } catch (Exception e) { throw new ExceptionApi500(e.getMessage()); } } }
 

 

혼동할 수 있는 uid

💡
  • imp_uid : 포트원 결제 고유 번호, 결제 성공 시 포트원에서 반환하는 값
  • reservation_id : 예매 번호(merchant_uid)
 
 
 
 
예매 - 좌석 - 결제 - 내역 시나리오

좌석선택 페이지에서 결제페이지로 넘기는 데이터들

 

결제 페이지(payment/view)

페이지 데이터
table / column
조회할 데이터
포스터 이미지
movie / posterUrls
영화제목
movie / movieNm
인터스텔라
영화관(극장)
cinema / name
더미 : 서면롯데시네마 Screen 1 (CGV판교)
상영 일시
showtime / started_at (끝나는 시간은 movie_tb의 runtime 자동연산)
2024.9.14(토) 12:00
상영관
screen / name
Screen 1 (1관)
인원
reservation / (?)
(일반 2명)
좌석번호
seat / seatNumber
E9, E10
일반
showtime / price
20,000 원 * 2
총 금액
40,000 원
 

결제 내역 페이지(mypage/paymentDetail)

페이지 데이터
table / column
조회할 데이터
결제(예매)일시
payment / createdAt
2024.09.12(목)
영화제목
movie / movieNm
인터스텔라
총 결제 금액
payment / totalPrice
28,000
예매번호
payment / bookingNumber
12345678
포스터 이미지
movie / posterUrls
상영 일시
showtime / startedAt
2024.09.14(토) 12:00
상영관
screen / name
더미 : 서면롯데시네마 Screen 1 (CGV판교)
관람 인원
people
일반 2명
좌석 번호
seat / seatNumber
E9, E10
주문 금액
payment / price
40,000
할인금액 / 관람권 or 할인권
payment / mycoupon , point
금액 (추후 추가 예정)
총 금액
40,000 원
 

결제 취소내역 페이지(mypage/paymentCncl)

페이지 데이터
table / column
조회할 데이터
결제(예매)일시
payment / createdAt
2024.09.12(목)
영화제목
movie / movieNm
인터스텔라
총 결제 금액
payment / price
28,000
예매번호
reservation / id ..?
12345678
취소일자
payment / cnclDate
{{2024.09.12}} 취소완료
상영일시
showtime / startedAt
2024.09.14(토) 12:00
상영관
screen / name
서면롯데시네마 Screen 1
관람인원
reservation / (?)
일반 2명
좌석 번호
seat / seatNumber
E9, E10
주문 금액
payment / price
40,000
 

 

추가 정리

💡
  1. 영화 정보 조회(movie_tb)
      • 영화 이름(movieNm), 포스터 이미지(posterUrls)
      • 이 정보는 상영 시간(showtime_tb)을 참조, 영화 상세 정보가 필요할 때 조회
  1. 상영 시간 정보 조회(showtime_tb)
      • 상영 시간(startedAt), 상영관(screen), 상영금액(price) 조회
      • 상영 시간은 영화(movie_id)와 상영관(screen_id)을 참조하고 있어서 영화 선택 후 해당 영화 상영 시간을 조회해야 함
  1. 좌석 정보 조회 (seat_tb)
      • seat_tb에서는 좌석 번호(seatNumber)및 행, 열 정보를 조회
      • 선택한 상영 시간에 해당하는 좌석 정보를 통해 예약할 수 있는 좌석을 선택, 그 번호를 결제 페이지로 넘김
  1. 예약 정보 조회(reservation_tb)
      • reservation_tb에서는 사용자(user_id)와 예매한 티켓 정보(ticket_id)를 참조
      • 결제 내역에 있는 예약 시간(createdAt)과 관련된 데이터 확인 후 결제 정보와 연결
  1. 티켓 정보 조회 (ticket_tb)
      • ticket_tb에서 좌석(seat_id) 및 상영 시간(showtime_id)을 참조하여 선택한 좌석과 상영 시간에 대한 티켓 정보를 조회
      • 예약된 티켓의 정보를 조회하고 결제 단계로 넘어갈 수 있도록 해야 함
  1. 사용자 정보 조회 (user_tb)
      • user_tb에서는 사용자의 ID, 이름, 이메일, 전화번호 등의 정보가 필요할 때 조회
      • 결제 진행 중 사용자 정보를 참조할 수 있어야 하며, 결제 완료 후 해당 사용자에게 결제 내역을 저장
 
 

참조 테이블

  • reservation_tb (예약)
  • ticket_tb (티켓)
  • seat_tb (좌석)
  • showtime_tb (상영시간)
  • movie_tb (영화)
  • user_tb (사용자)
  • screen_tb (상영관)
  • cinema_tb (영화관)
 

 
 
PortOne REST API -V1
결제 상세내역 조회 API
notion image
결제내역 단건조회 API
notion image
결제취소 API
notion image
notion image
notion image
notion image
notion image
Swagger JSON (with jsonviewer)
→ jsonviewer 로 확인
 
결제 테스트1 (임시 더미 테스트)
notion image
 
notion image
 
notion image
 
 
notion image
 
notion image
 

정리

💡
  1. 매번 결제 테스트를 진행할 때마다 reservationId가 카운트 돼서, 해당 idUUID로 설정
  1. DTO에서 Long 타입의 idString 타입으로 수정
  1. reservationId가 문자열이지만 Long 또는 다른 타입으로 변환 가능하면, 변환 후 deleteById() 를 호출 가능
3번 추가 설명
  • reservationId가 숫자로 변환 가능한 경우 (Long 타입으로 변환)
Long reservationId = Long.parseLong(saveDTO.getReservationId()); reservationRepository.deleteById(reservationId);
→ 이 경우 reservationId가 숫자 형식(”123”)인 문자열이어야 하고, db에서 reservationIdLong 타입으로 정의되어 있어야 함
  • 이 방법 외에도 UUID.fromString()으로 변환 후 deleteById() 호출하는 방법
    • reservationId 필드를 UUID로 변환
    • UUID reservationId = UUID.fromString(saveDTO.getReservationId());
    • deleteById() 호출 대신, UUID에 맞는 조회/삭제 메소드를 사용하도록 repository 수정
    • reservationRepository.deleteById(reservationId);
 
 
결제 테스트2
24.09.20
TODO List
PaymentService에 stream API 사용한 거 수정
success.mustache 수정 → {{}} 에 paymentDetails 추가
view.mustache 확인 필요
 
추가 항목
  • Reservation 엔티티
    • @OneToMany(mappedBy = "reservation", cascade = CascadeType.ALL, orphanRemoval = true) private List<Ticket> tickets;
       
  • Ticket 엔티티
    • // 33라인 @JoinColumn(name = "reservation_id")
       
  • service에 PaymentRespons로 티켓 받음 (상영시간, 상영관 이름, 영화관 이름) → 추후 상영관과 영화관 합칠지 고려
 

 
주말 (20, 21, 22) TODO List
db 테이블 정리 (특히 참조키) → 어떻게 조인할지 쿼리문 구성
좌석에서 받는 거 (totalPrice, reservationId)
paymentRepository에서 전체 join하는게 아닌, 조회할 테이블의 해당 repository에서 쿼리 작성 후 paymentService에서 final 걸기
 
 

24.09.25
payment/view & payment/success : 인원 부분에 좌석넘버 (seatNumber) 표기
좌석 넘버 표기 정리

1. mustache 변경

<li> <strong>좌석</strong> <div>{{#paymentData.seatNumbers}}{{.}} {{/paymentData.seatNumbers}}</div> </li>
  • {{#paymentData.seatNumbers}}...{{/paymentData.seatNumbers}} 부분은 Mustache의 반복 블록을 사용하여 좌석 목록을 출력
  • 좌석 번호paymentData.seatNumbers에서 가져와서 각 좌석을 출력
 

2. JS 수정

function requestPay() { var merchantUid = 'merchant_' + new Date().getTime(); var orderUid = '{{paymentData.reservationId}}'; var seatNumbers = '{{#paymentData.seatNumbers}}{{.}} {{/paymentData.seatNumbers}}'; // 좌석 넘버 추가 var itemName = '{{paymentData.movieTitle}}'; var paymentPrice = '{{paymentData.totalPrice}}'; var buyerName = '{{paymentData.username}}'; var buyerEmail = '{{paymentData.email}}'; var phone = '{{paymentData.phone}}'; console.log(merchantUid, orderUid, seatNumbers, itemName, paymentPrice, buyerName, buyerEmail, phone); IMP.request_pay({ pg: 'html5_inicis.INIpayTest', pay_method: 'card', merchant_uid: merchantUid, name: itemName, amount: paymentPrice, buyer_email: buyerEmail, buyer_name: buyerName, buyer_tel: phone, buyer_postcode: '', }, function(rsp) { if (rsp.success) { console.log('결제 성공! imp_uid: ' + rsp.imp_uid + ', merchant_uid: ' + rsp.merchant_uid); jQuery.ajax({ url: "/payment", method: "POST", headers: { "Content-Type": "application/json" }, data: JSON.stringify({ impUid: rsp.imp_uid, merchantUId: rsp.merchant_uid, reservationId: orderUid, price: paymentPrice, seatNumbers: seatNumbers // 좌석 넘버 전달 }) }).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"; } }); }
  • seatNumbers 변수를 추가하여 좌석 정보를 결제 요청과 함께 서버에 전달할 수 있도록 함 → 결제 완료 후에도 좌석 정보가 유지되며 결제 완료 페이지에서도 좌석 정보 확인 가능
 
날짜 포맷팅해서 HH:mm 으로 맞추기
상영종료 시간 계산 및 날짜 포맷팅

1. PaymentService 수정

1-1. 상영종료 시간 계산, 상영시작 시간과 함께 결제 정보를 전달하는 코드 추가
public PaymentResponse.PaymentViewDTO getPaymentViewData(Long reservationId) { Reservation reservation = reservationRepository.findById(reservationId) .orElseThrow(() -> new ExceptionApi404("예약 정보를 찾을 수 없습니다.")); Showtime showtime = reservation.getTickets().getFirst().getShowtime(); int runtimeMinutes = showtime.getMovie().getRuntime(); // 영화 런타임 (분 단위) Timestamp startedAt = Timestamp.valueOf(showtime.getStartedAt().toLocalDateTime()); // 상영 시작 시간 String startTime = convertTimeStampToString(startedAt, "yyyy.MM.dd HH:mm"); // 시작 시간 포맷팅 String endTime = calculateEndTime(startedAt, runtimeMinutes); // 종료 시간 계산 return new PaymentResponse.PaymentViewDTO( reservationId, reservation.getUser().getUsername(), reservation.getUser().getEmail(), reservation.getUser().getPhone(), showtime.getMovie().getPosterUrls().get(0).getUrl(), showtime.getMovie().getMovieNm(), startTime, // 시작 시간 endTime, // 종료 시간 showtime.getScreen().getCinema().getName(), showtime.getScreen().getName(), reservation.getTickets().size(), reservation.getTickets().stream() .map(ticket -> ticket.getSeat().getSeatNumber()) .collect(Collectors.toList()), reservation.getTickets().size() * showtime.getPrice(), reservation.getTickets().size() * showtime.getPrice() ); }
1-2. calculateEndTime 메소드를 사용해 시작 시간 + runtime으로 종료시간 계산
public static String calculateEndTime(Timestamp startedAt, int runtimeMinutes) { LocalDateTime startDateTime = startedAt.toLocalDateTime(); LocalDateTime endDateTime = startDateTime.plus(runtimeMinutes, ChronoUnit.MINUTES); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); return endDateTime.format(formatter); }
1-3. 시작 시간은 convertTimeStampToString 메소드를 사용해 지정된 형식으로 포맷팅
public static String convertTimeStampToString(Timestamp timestamp, String format) { if (timestamp == null) return ""; try { Date date = new Date(); date.setTime(timestamp.getTime()); SimpleDateFormat sdf = new SimpleDateFormat(format, Locale.KOREAN); return sdf.format(date); } catch (Exception e) { throw new RuntimeException("날짜 포맷팅 중 오류가 발생했습니다."); } }
 

2. mustache (결과 화면)

<div class="info_row"> <span class="info_label">상영 일시</span> <span>{{paymentData.showtime}} ~ {{paymentData.endtime}}</span> <!-- 상영 시작 시간과 종료 시간 표시 --> </div>
  • 시작 시간(paymentData.showtime)과 종료 시간(paymentData.endtime)을 함께 출력하여, 클라이언트가 영화를 보는 시작 시간끝나는 시간을 알 수 있게 함
 
[payment/seccess] 결제 완료 페이지
 
notion image
 
notion image
💡
결제 완료 시 payment_tb에 데이터 Insert 후 payment/success로 redirect

 

필요(출력) 데이터

페이지 데이터
table / column
조회할 데이터
포스터 이미지
movie / posterUrls
moviePosterUrl
예매번호
payment / bookingNumber
bookingNumber
상영 일시(시작/종료시간)
showtime / startedAt
showtime / endtime
상영관 (영화관&상영관 이름)
screen / name
cinemaName / screenName
관람 인원 (예매한 인원 수)
x
people
좌석 번호
seat / seatNumber
seatNumbers
결제 금액
x
totalPrice
할인금액 / 관람권 or 할인권
payment / mycoupon , point
discount (현재는 0원 고정)
총 결제 금액 (결제 금액 - 할인 금액)
payment / price
payPrice
 

관련 레이어 및 코드

 

1. PaymentController

  • 결제 성공 후 payment/success 페이지로 이동
@GetMapping("/api/payment/success") public String paymentSuccess(HttpSession session, Model model) { // 세션에서 예약 ID와 총 결제 금액 가져오기 Long sessionReservationId = (Long) session.getAttribute("sessionReservationId"); Double sessionTotalPrice = (Double) session.getAttribute("sessionTotalPrice"); if (sessionReservationId == null || sessionTotalPrice == null) { throw new ExceptionApi404("Reservation or Price data is missing."); } // 예약 정보와 결제 정보를 한 번에 조회 PaymentResponse.PaymentViewDTO paymentData = paymentService.getPaymentViewData(sessionReservationId); // 할인 금액은 0으로 고정 (추후 쿠폰 및 할인 적용 시 수정 가능) int discount = 0; double payPrice = sessionTotalPrice - discount; // 8자리 예매번호 생성 String bookingNumber = paymentService.generateBookingNumber(); // 모델에 paymentData 객체 전체 추가 model.addAttribute("paymentData", paymentData); model.addAttribute("mTotalPrice", sessionTotalPrice); // 총 금액 model.addAttribute("discount", discount); // 할인 금액 model.addAttribute("payPrice", payPrice); // 최종 결제 금액 model.addAttribute("bookingNumber", bookingNumber); // 8자리 예매번호 return "payment/success"; }
  • 세션에서 reservationIdtotalPrice를 가져와 paymentService를 통해 결제 데이터 조회
  • 결제 정보, 할인금액(0원), 최종 결제 금액을 계산하여 model에 담아 payment/success페이지에 전달
 

2. PaymentService

  • PaymentViewDto 객체 생성해서 필요 데이터 조회 및 전달
public PaymentResponse.PaymentViewDTO getPaymentViewData(Long reservationId) { Reservation reservation = reservationRepository.findById(reservationId) .orElseThrow(() -> new ExceptionApi404("예약 정보를 찾을 수 없습니다.")); Showtime showtime = reservation.getTickets().getFirst().getShowtime(); int runtimeMinutes = showtime.getMovie().getRuntime(); // 영화 런타임 (분 단위) Timestamp startedAt = Timestamp.valueOf(showtime.getStartedAt().toLocalDateTime()); // 상영 시작 시간 String startTime = convertTimeStampToString(startedAt, "yyyy.MM.dd HH:mm"); // 시작 시간 포맷팅 String endTime = calculateEndTime(startedAt, runtimeMinutes); // 종료 시간 계산 return new PaymentResponse.PaymentViewDTO( reservationId, reservation.getUser().getUsername(), reservation.getUser().getEmail(), reservation.getUser().getPhone(), showtime.getMovie().getPosterUrls().get(0).getUrl(), showtime.getMovie().getMovieNm(), startTime, // 시작 시간 endTime, // 종료 시간 showtime.getScreen().getCinema().getName(), showtime.getScreen().getName(), reservation.getTickets().size(), reservation.getTickets().stream() .map(ticket -> ticket.getSeat().getSeatNumber()) .collect(Collectors.toList()), reservation.getTickets().size() * showtime.getPrice(), reservation.getTickets().size() * showtime.getPrice() ); }
  • getPaymentViewData 메소드는 reservationId를 기반으로 데이터 조회. 영화 정보, 좌석 정보, 결제 금액등을 포함한 DTO 반환
 

3. payment/success.mustache

  • 받아온 데이터 출력
<div class="info_row"> <span class="info_label">예매번호</span> <span>{{bookingNumber}}</span> </div> <div class="info_row"> <span class="info_label">상영 일시</span> <span>{{paymentData.showtime}} ~ {{paymentData.endtime}}</span> <!-- 시작 시간 ~ 종료 시간 --> </div> <div class="info_row"> <span class="info_label">상영관</span> <span>{{paymentData.cinemaName}} {{paymentData.screenName}}</span> </div> <div class="info_row"> <span class="info_label">관람 인원</span> <span>{{paymentData.people}}</span> </div> <div class="info_row"> <span class="info_label">좌석</span> <span>{{#paymentData.seatNumbers}}{{.}} {{/paymentData.seatNumbers}}</span> </div> <div class="bottom_section"> <span>주문 금액</span> <strong>{{paymentData.price}}원</strong> </div> <div class="bottom_section middle"> <span>할인 금액</span> <strong>{{discount}}원</strong> </div> <div class="bottom_section"> <span>총 결제 금액</span> <strong>{{paymentData.totalPrice}}원</strong> </div>
notion image
 

예약 - 좌석 선택 - 결제 로직 시연영상

 
 
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

SHIN