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