091
[KINO][JAVA/Spring Boot] 좌석 선정 및 예약: WebSocket 응용(3) 본문
[KINO][JAVA/Spring Boot] 좌석 선정 및 예약: WebSocket 응용(3)
공구일 2026. 3. 29. 01:58*이 글은 프로젝트 KINO CINEMA의 개발 내용을 정리해놓은 글입니다.*
3. SeatCommandService(with SeatService) & ReservationCommandService
- SeatService: CQRS에서 읽기(Query)에 해당하는 로직이 들어가 있는 비지니스 로직입니다.
package com.cinema.kino.service;
//import문 생략
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class SeatService {
private final ScreeningSeatRepository screeningSeatRepository;
private final ScreeningRepository screeningRepository;
private final TicketPriceRepository ticketPriceRepository;
//좌석 상태 목록 반환
public SeatBookingResponseDTO getSeatStatus(Long screeningId) {
//공통 상영 정보 원본(Entity) 조회
Screening screening = screeningRepository.findById(screeningId)
.orElseThrow(() -> new IllegalArgumentException("상영 정보를 찾을 수 없습니다."));
//가격 맵 계산
Map<String, Integer> prices = getPricesForScreening(screeningId);
//해당 상영의 좌석 리스트 조회
List<ScreeningSeat> screeningSeats = screeningSeatRepository.findByScreeningId(screeningId);
return SeatBookingResponseDTO.of(screening, screeningSeats, prices);
}
//가격 정보 목록 반환
public Map<String, Integer> getPricesForScreening(Long screeningId) {
Screening screening = screeningRepository.findById(screeningId)
.orElseThrow(() -> new IllegalArgumentException("상영 정보를 찾을 수 없습니다."));
ScreenType screenType = screening.getScreen().getScreenType();
int startHour = screening.getStartTime().getHour();
ScreeningType screeningType;
if (startHour < 10) {
screeningType = ScreeningType.MORNING;
} else if (startHour >= 21) {
screeningType = ScreeningType.NIGHT;
} else {
screeningType = ScreeningType.NORMAL;
}
List<TicketPrice> ticketPrices = ticketPriceRepository.findByScreenTypeAndScreeningType(screenType, screeningType);
if (ticketPrices.isEmpty()) {
throw new IllegalStateException("해당 상영에 대한 티켓 가격 정책이 DB에 등록되어 있지 않습니다.");
}
return ticketPrices.stream()
.collect(Collectors.toMap(
tp -> tp.getPriceType().name(),
TicketPrice::getPrice
));
}
}
-> @Service: 비지니스 로직 계층으로 Spring Bean으로 등록되는 @Component의 특수 어노테이션입니다.
-> @Transactional(readOnly = true): 읽기 최적화 트랜잭션으로, JPA(Hibernate)는 기본적으로 트랙잭션이 끝날 때 데이터 변경을 확인하는 더티 체킹(Dirty Checking)이라는 무거운 작업을 실행합니다. 이때 (readOnly = true) 키워드를 통해 더티 체킹을 하지 않아도 된다는 것을 명시해줬기 때문에 조회 전용 서비스에 작성됩니다.
package com.cinema.kino.service;
//import문 생략
@Service
@Transactional
@RequiredArgsConstructor
public class SeatCommandService {
private final ScreeningSeatRepository screeningSeatRepository;
//좌석 선점 로직
public void holdSeats(SeatSelectRequestDTO request) {
//티켓 리스트에서 seatId만 뽑기
List<Long> requestedSeatIds = request.getTickets().stream()
.map(SeatSelectRequestDTO.TicketRequest::getSeatId)
.collect(Collectors.toList());
//추출한 ID 리스트로 DB 조회
List<ScreeningSeat> seats = screeningSeatRepository.findAllByScreeningIdAndSeatIdsWithLock(
request.getScreeningId(),
requestedSeatIds
);
//검증 로직도 추출한 리스트 사이즈와 비교하도록 수정
if (seats.size() != requestedSeatIds.size()) {
throw new IllegalArgumentException("요청한 좌석 중 일부를 찾을 수 없거나 이미 선택된 좌석입니다.");
}
seats.forEach(ss -> ss.hold(request.getMemberId(), request.getGuestId()));
}
}
-> @Transactional: 쓰기 트랜잭션(CUD)으로, 하나라도 실패 시 전체 롤백을 하는 DB 정합성을 보장합니다.
-> holdSeats(...): holdSeats 로직은 넘어온 요청 데이터에서, seatId를 뽑아서 ScreeningSeat Entity에 있는 도메인 메서드 hold()를 해서 좌석을 HELD 상태로 바꿔줍니다.
• requestSeatIds: 이 변수를 얻기 위해 사용된 Stream API는 자바8 이상에서 사용되는 추상화된 파이프라인으로, 소스->중간연산->최종 연산의 흐름을 가지고 있습니다. 이때 코드에서 사용된 중간연산인 map은 요소를 다른 값으로 변환 매핑해주며, 이렇게 매핑된 요소들을 최종 연산인 collect로 새로운 컬렉션에 수집하여 리턴합니다.
+) 추가적인 Stream API에 대한 학습을 원하시면 옆 링크를 참조해주세요=> https://in-ouput91.tistory.com/155
[JAVA] Stream API
01. Stream API(Application Programming Interface) - Stream API : 컬렉션(List, Set, Map 등) 또는 배열의 데이터를 함수형 스타일로 처리할 수 있도록 도와주는 API*API : 다른 코드/시스템과 통신하거나 기능을 사용
in-ouput91.tistory.com
• Repository 코드: screeningSeatRepository에 있는 findAllByScreeningIdAndSeatIdsWithLock을 사용하여, 특정 좌석들을 조회하면서 동시에 DB에 Lock해버리는 역할입니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT ss FROM ScreeningSeat ss " +
"WHERE ss.screening.id = :screeningId " +
"AND ss.seat.id IN :seatIds")
List<ScreeningSeat> findAllByScreeningIdAndSeatIdsWithLock(
@Param("screeningId") Long screeningId,
@Param("seatIds") List<Long> seatIds
);
-> @Lock(LockModeType.PESSIMISTIC_WRITE): 비관적 락으로, 충돌이 발생할 거라고 가정하고 미리 잠구는 모드를 말합니다. 이 위의 코드를 SQL로 변환하면, 마지막 AND 조건 뒤로 FOR UPDATE라는 키워드가 붙습니다. 이 쿼리가 실행된 순간 해당 좌석에는 배타적 락이 걸리고, 이 행과 관련된 다른 트랙잭션이 대기상태로 들어가게됩니다. 즉, 행을 잠금해버립니다.
-> @Query(...): JPQL로 직접 작성한 것으로, DB에서 가져올 데이터 조건을 정의해줍니다.
-> @Param(...): JPQL의 :screeningId와 :seatIds에 값을 바인딩 해줍니다.
Q. 비관적 락과 낙관적 락의 차이점?
A. 비관적 락은 무조건 충돌이 발생할 상황을 고려해야 미리 락을 걸며, 조회 시에 DB 락을 걸려줍니다. 비관적 락은 충돌 가능성이 거의 없으며 나중에 검사해도 되는 경우기 때문에 미리 락을 걸지 않고 업데이트 시에 락이 걸립니다.낙관적 락은 @Version을 사용합니다.
• seats.forEach(...): seats의 내부 요소를 직접 순회하면서 람다식을 실행하여, ScreeningSeat 내부에 있는 hold()라는 도메인 메서드를 사용하였습니다. 이 메서드 내부에서 status에 대한 판단을 진행하기 때문에 Fail-Fast 정합성 판단이나 레포지토리에는 이 관련 내용이 들어가있지 않아도 괜찮은 것입니다.
public void hold(Long memberId, Long guestId) {
if (this.status != SeatStatus.AVAILABLE) {
throw new IllegalStateException("이미 선택된 좌석입니다.");
}
this.status = SeatStatus.HELD;
this.holdExpiresAt = LocalDateTime.now().plusMinutes(10);
if (memberId != null) {
this.heldByMember = new Member(memberId); // 프록시
this.heldByGuest = null;
} else {
this.heldByGuest = new Guest(guestId);
this.heldByMember = null;
}
}
-> DB 조회 없이 연관관계만 설정하려고 ID만 가진 객체를 넣는 것으로, 프록시 객체는 완전한 객체가 아니지만 사용되는 이유는 일방적으로 하는 방식을 활용하다보면, 쓸데없이 조회가 발생하기 때문에 FK 설정만 해주는 것으로 변경하는 것입니다.
Member member = memberRepository.findById(memberId); // DB 조회 발생->성능 낭비
this.heldByMember = member;
this.heldByMember = new Member(memberId); //프록시
=> 이 서비스문 어디에도 직접 UPDATE 하는 쿼리를 찾아볼 수 없습니다. 바로 이게 JPA의 영속성 컨텍스트가 조회된 seats 객체를 감시하고 있기 때문에 변화가 일어나면, 트랜잭션이 끝나는 시점에 DB에 UPDATE 쿼리를 일괄적으로 날립니다.
- ReservationCommandService
package com.cinema.kino.service;
//import문 생략
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ReservationCommandService {
private final SeatCommandService seatCommandService;
private final ReservationRepository reservationRepository;
private final ReservationTicketRepository reservationTicketRepository;
private final GuestRepository guestRepository;
private final MemberRepository memberRepository;
private final ScreeningRepository screeningRepository;
private final SimpMessagingTemplate messagingTemplate;
private final SeatService seatService;
@Transactional
public ReservationResponseDTO createPendingReservation(SeatSelectRequestDTO request) {
// 좌석 선정 및 비관적 락 실행
seatCommandService.holdSeats(request);
// 기초 정보 조회
Member member = null;
Guest guest = null;
if (request.getMemberId() != null) {
member = memberRepository.findById(request.getMemberId())
.orElseThrow(() -> new IllegalArgumentException("회원 정보를 찾을 수 없습니다."));
} else if (request.getGuestId() != null) {
guest = guestRepository.findById(request.getGuestId())
.orElseThrow(() -> new IllegalArgumentException("비회원 정보를 찾을 수 없습니다."));
} else {
throw new IllegalArgumentException("회원 또는 비회원 식별 정보가 필요합니다.");
}
Screening screening = screeningRepository.findById(request.getScreeningId())
.orElseThrow(() -> new IllegalArgumentException("상영 정보를 찾을 수 없습니다."));
// 가격 정책 로직 호출
Map<String, Integer> priceMap = seatService.getPricesForScreening(request.getScreeningId());
//총 금액 계산
int totalPrice = request.getTickets().stream()
.mapToInt(t -> priceMap.getOrDefault(t.getPriceType().name(), 15000))
.sum();
//Reservation 생성 및 저장
Reservation reservation = Reservation.builder()
.member(member) // 회원이면 객체 들어감, 비회원이면 null 들어감
.guest(guest) // 비회원이면 객체 들어감, 회원이면 null 들어감
.screening(screening)
.status(ReservationStatus.PENDING)
.totalNum(request.getTickets().size())
.totalPrice(totalPrice)
.orderId(UUID.randomUUID().toString())
.createdAt(LocalDateTime.now())
.build();
reservationRepository.save(reservation);
//ReservationTicket 생성 및 저장
List<ReservationTicket> tickets = request.getTickets().stream()
.map(t -> ReservationTicket.builder()
.reservation(reservation)
.seatId(t.getSeatId())
.priceType(PriceType.valueOf(t.getPriceType().name()))
.ticketCode(UUID.randomUUID().toString()) // 겹치지 않는 난수 발급!
.isIssued(false) // 처음엔 무조건 발급안함
.build())
.collect(Collectors.toList());
reservationTicketRepository.saveAll(tickets);
// [WebSocket] 다른 유저들에게 브로드캐스팅: 어떤 좌석들이 선점되었는지 리스트만 뽑아서 전송
List<Long> holdSeatIds = request.getTickets().stream()
.map(SeatSelectRequestDTO.TicketRequest::getSeatId)
.collect(Collectors.toList());
messagingTemplate.convertAndSend("/topic/screening/" + request.getScreeningId(), holdSeatIds);
log.info("예약 생성 완료 - ID: {}, 예약자: {}, 총 금액: {}",
reservation.getId(),
member != null ? "회원(" + member.getId() + ")" : "비회원(" + guest.getId() + ")",
totalPrice);
return new ReservationResponseDTO(reservation.getId());
}
}
'Programming Language > Java' 카테고리의 다른 글
| [Java] Lombok (0) | 2026.05.05 |
|---|---|
| [JAVA] JPA, Spring Data JPA (0) | 2026.04.29 |
| [KINO][JAVA/Spring Boot] 좌석 선정 및 예약: WebSocket 응용(2) (0) | 2026.03.28 |
| [KINO][JAVA/Spring Boot] 좌석 선정 및 예약: WebSocket 응용(1) (0) | 2026.03.24 |
| [JAVA/Spring Boot] STOMP: WebSocket 개념 (0) | 2026.03.23 |