[payment/view] 결제 메인 페이지
결제 시나리오
노트 필기 정리

로직 구성 순서(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 |
추가 정리
- 영화 정보 조회(
movie_tb) - 영화 이름(
movieNm), 포스터 이미지(posterUrls) - 이 정보는 상영 시간(
showtime_tb)을 참조, 영화 상세 정보가 필요할 때 조회
- 상영 시간 정보 조회(
showtime_tb) - 상영 시간(startedAt), 상영관(screen), 상영금액(
price) 조회 - 상영 시간은 영화(
movie_id)와 상영관(screen_id)을 참조하고 있어서 영화 선택 후 해당 영화 상영 시간을 조회해야 함
- 좌석 정보 조회 (
seat_tb) seat_tb에서는 좌석 번호(seatNumber)및 행, 열 정보를 조회- 선택한 상영 시간에 해당하는 좌석 정보를 통해 예약할 수 있는 좌석을 선택, 그 번호를 결제 페이지로 넘김
- 예약 정보 조회(
reservation_tb) reservation_tb에서는 사용자(user_id)와 예매한 티켓 정보(ticket_id)를 참조- 결제 내역에 있는 예약 시간(
createdAt)과 관련된 데이터 확인 후 결제 정보와 연결
- 티켓 정보 조회 (
ticket_tb) ticket_tb에서 좌석(seat_id) 및 상영 시간(showtime_id)을 참조하여 선택한 좌석과 상영 시간에 대한 티켓 정보를 조회- 예약된 티켓의 정보를 조회하고 결제 단계로 넘어갈 수 있도록 해야 함
- 사용자 정보 조회 (
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

결제내역 단건조회 API

결제취소 API





Swagger JSON (with jsonviewer)
→ jsonviewer 로 확인
결제 테스트1 (임시 더미 테스트)





정리
- 매번 결제 테스트를 진행할 때마다
reservationId가 카운트 돼서, 해당id를UUID로 설정
DTO에서Long타입의id를String타입으로 수정
reservationId가 문자열이지만Long또는 다른 타입으로 변환 가능하면, 변환 후deleteById()를 호출 가능
3번 추가 설명
reservationId가 숫자로 변환 가능한 경우 (Long타입으로 변환)
Long reservationId = Long.parseLong(saveDTO.getReservationId());
reservationRepository.deleteById(reservationId);→ 이 경우
reservationId가 숫자 형식(”123”)인 문자열이어야 하고, db에서 reservationId가 Long 타입으로 정의되어 있어야 함
- 이 방법 외에도
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] 결제 완료 페이지


결제 완료 시 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";
}- 세션에서
reservationId와totalPrice를 가져와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>
예약 - 좌석 선택 - 결제 로직 시연영상
Error
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 IamportAPI로 결제 요청을 보낼 때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