1차 팀 프로젝트 : FilmTalk

영화 예매 웹 플랫폼 : 좌석
SHIN's avatar
Oct 01, 2024
1차 팀 프로젝트 : FilmTalk
 
[Project] 좌석 선택 화면 완성 (seat.mustache)

화면 설계

 
notion image
 

화면 Description

notion image
 
 

엔티티 만들 때 필요한 필드 정리

notion image
notion image
 

seat.mustache

notion image
 
  • 인원 수 선택란에 “일반/청소년/경로/우대” 같은 옵션이 있는데 v1에서는
“일반” 옵션만 넣기로 했다.
 
  • 좌석 렌더링을 어떻게 할 것인지 고민 된다. 2차원 배열로 할 수 있다고 해서 찾아보고 있다.
 
 
 
 
 
[Project] 좌석 페이지 쿼리 작성
select h1.id, h1.started_at, h1.runtime, h1.movie_nm, h1.rating_grade, h1.screen_name, h2.img_uname, h2.img_name, h2.name cinema_name from ( select t1.id, t1.started_at, t1.runtime, t1.movie_nm, t1.rating_grade, t2.cinema_id, t2.name screen_name from ( select st.id, st.screen_id, st.started_at, m.runtime, m.movie_nm, m.rating_grade from showtime_tb st inner join movie_tb m on st.movie_id = m.id where st.id = 1 ) t1 inner join screen_tb t2 on t1.screen_id = t2.id ) h1 inner join cinema_tb h2 on h1.cinema_id = h2.id select u1.id, u1.col_num, u1.row_num, u1.stime, u1.movie_nm, u1.rating_grade, u1.name screen_name, u2.name cinema_name, u2.img_name, u2.img_uname from ( select h1.id, h1.col_num, h1.row_num, h1.showtime_id, h1.stime, h1.movie_nm, h1.rating_grade, h2.name, h2.cinema_id from ( select t1.id, t1.col_num, t1.row_num, t1.showtime_id, t1.stime, t2.movie_nm, t2.rating_grade from ( select s.id, s.col_num, s.row_num, s.showtime_id, st.movie_id mid, st.screen_id, st.started_at stime from seat_tb s inner join showtime_tb st on s.showtime_id = st.id where s.showtime_id = 1 ) t1 inner join movie_tb t2 on t1.mid = t2.id ) h1 inner join screen_tb h2 on h1.showtime_id = h2.id ) u1 inner join cinema_tb u2 on u1.cinema_id = u2.id; select u1.id, u1.col_num, u1.row_num, u1.stime, u1.movie_nm, u1.runtime, u1.rating_grade, u1.name screen_name, u2.name cinema_name, u2.img_name, u2.img_uname from ( select h1.id, h1.col_num, h1.row_num, h1.showtime_id, h1.stime, h1.movie_nm, h1.runtime, h1.rating_grade, h2.name, h2.cinema_id from ( select t1.id, t1.col_num, t1.row_num, t1.showtime_id, t1.stime, t2.movie_nm, t2.runtime, t2.rating_grade from ( select s.id, s.col_num, s.row_num, s.showtime_id, st.movie_id mid, st.screen_id, st.started_at stime from seat_tb s inner join showtime_tb st on s.showtime_id = st.id where s.showtime_id = 1 ) t1 inner join movie_tb t2 on t1.mid = t2.id ) h1 inner join screen_tb h2 on h1.showtime_id = h2.id ) u1 inner join cinema_tb u2 on u1.cinema_id = u2.id; select * from ( select st.id, st.movie_id, st.screen_id, st.started_at, s.col_num, s.row_num from showtime_tb st inner join seat_tb s on s.showtime_id = st.id where s.showtime_id = 1 ) t1 inner join movie_tb t2 on t1.movie_id = t2.id; select h1.id, h1.started_at, h1.runtime, h1.movie_nm, h1.rating_grade, h1.screen_name, h2.img_uname, h2.img_name, h2.name cinema_name from ( select t1.id, t1.started_at, t1.runtime, t1.movie_nm, t1.rating_grade, t2.cinema_id, t2.name screen_name from ( select st.id, st.screen_id, st.started_at, m.runtime, m.movie_nm, m.rating_grade from showtime_tb st inner join movie_tb m on st.movie_id = m.id where st.id = 1 ) t1 inner join screen_tb t2 on t1.screen_id = t2.id ) h1 inner join cinema_tb h2 on h1.cinema_id = h2.id; // 전체 좌석 수 제외하고 필요한 것 select u1.id, u1.col_num, u1.row_num, u1.stime, u1.movie_nm, u1.runtime, u1.rating_grade, u1.name screen_name, u2.name cinema_name, u2.img_name, u2.img_uname from ( select h1.id, h1.col_num, h1.row_num, h1.showtime_id, h1.stime, h1.movie_nm, h1.runtime, h1.rating_grade, h2.name, h2.cinema_id from ( select t1.id, t1.col_num, t1.row_num, t1.showtime_id, t1.stime, t2.movie_nm, t2.runtime, t2.rating_grade from ( select s.id, s.col_num, s.row_num, s.showtime_id, st.movie_id mid, st.screen_id, st.started_at stime from seat_tb s inner join showtime_tb st on s.showtime_id = st.id where s.showtime_id = 1 ) t1 inner join movie_tb t2 on t1.mid = t2.id ) h1 inner join screen_tb h2 on h1.showtime_id = h2.id ) u1 inner join cinema_tb u2 on u1.cinema_id = u2.id; select * from ( select st.id, st.movie_id, st.screen_id, st.started_at, s.col_num, s.row_num from showtime_tb st inner join seat_tb s on s.showtime_id = st.id where s.showtime_id = 1 ) t1 inner join movie_tb t2 on t1.movie_id = t2.id; // 전체 좌석 수 포함 SELECT u1.id, u1.col_num, u1.row_num, u1.stime, u1.movie_nm, u1.runtime, u1.rating_grade, u1.name AS screen_name, u2.name AS cinema_name, u2.img_name, u2.img_uname, -- 전체 좌석 수 (SELECT count(col_num) FROM SEAT_TB WHERE showtime_id = u1.showtime_id) AS total_seat_num, -- 남은 좌석 수 ((SELECT count(col_num) FROM SEAT_TB WHERE showtime_id = u1.showtime_id) - (SELECT count(seat_id) FROM ticket_tb WHERE showtime_id = u1.showtime_id)) AS available_seat_num FROM ( SELECT h1.id, h1.col_num, h1.row_num, h1.showtime_id, h1.stime, h1.movie_nm, h1.runtime, h1.rating_grade, h2.name, h2.cinema_id FROM ( SELECT t1.id, t1.col_num, t1.row_num, t1.showtime_id, t1.stime, t2.movie_nm, t2.runtime, t2.rating_grade FROM ( SELECT s.id, s.col_num, s.row_num, s.showtime_id, st.movie_id AS mid, st.screen_id, st.started_at AS stime FROM seat_tb s INNER JOIN showtime_tb st ON s.showtime_id = st.id WHERE s.showtime_id = 1 ) t1 INNER JOIN movie_tb t2 ON t1.mid = t2.id ) h1 INNER JOIN screen_tb h2 ON h1.showtime_id = h2.id ) u1 INNER JOIN cinema_tb u2 ON u1.cinema_id = u2.id;
 
필요한 데이터를 가지고 오기 위해 네이티브 쿼리를 먼저 작성해 보았다.
 
 
notion image
 
여러번 조인해서 좌석 페이지에 필요한 데이터를 가지고 왔다.
 
 
notion image
 
전체 쿼리는 이렇게 생겼다.
 
[Error] 하이버네이트 @OneToMany 에러 -1
 
Join할 때 테이블에 OneToMany가 2개 이상일 경우
 
MultipleBagFetchException 가 발생한다.
 
 
notion image
 
notion image
 
 
데이터의 중복 문제 때문에 MultipleBagFetchException 발생하는 것으로 보인다.
 
 
notion image
 
OneToMany 양방향 매핑을 걸어준 필드의 타입은 List 인데
List → Set 으로 변경하니 위처럼 에러없이 잘 나온다.
 
하지만 List를 Set으로 변경해서 사용하는 것 보다 좋은 해결책이 있을 것이라는 생각이 들었다.
 
Set으로 가져오면 일단 정렬이 안된다는 단점도 있다.
 
 
위 블로그의 내용을 보면
 
2개 이상의 OneToMany 자식 테이블에 Fetch Join을 선언했을 때 발생하는 MultipleBagFetchException을 해결하기 위해서
 
  1. Fetch Join을 1번만 하고 나머지는 다 Lazy Loading으로 !
  1. 모든 자식 테이블을 다 Lazy Loading으로 !
 
가져오는 이 2가지 방법을 많이 쓴다고 한다.
 
 
결론 : OneToMany 가 2개 이상 걸려있으면 여러번 Join 해서 가져오지 X
 
레이지 로딩으로 가져와야 하는데
 
  1. Join + 레이지로딩
  1. join + inQuery 로 조회(select)
 
하는 방법 2개를 사용해 보았다
 
 
 
(To be continued…)
 
 
 
 
 
[Error] 하이버네이트 @OneToMany 에러 - 2 (해결) (feat. inquery)
 
Join을 1번만 하고 나머지는 lazy loading으로 가져오는 방법과
Inquery를 사용해서 데이터를 가져오는 방법 두 개를 사용해 보았다.
 

1. In query 를 사용했을 때

CinemaRepositoryTest

@Test public void mFindCinemaById_test(){ Long id = 1L; Cinema cinema = cinemaRepository.mFindCinemaById(id); // 여기에 id List로 넣을 수 있다. List<Screen> screen1 = cinema.getScreens(); List<Showtime> showtimes1 = screen1.get(0).getShowtimes(); //screen1. //System.out.println(showtimes1.size()); // 이거하면 여기서 lazyLoading 발생 // System.out.println(showtimes1.getFirst().getId()); // 1 // System.out.println(showtimes1.getLast().getId()); // 2 System.out.println("영화관 정보:"); System.out.println("Cinema ID: " + cinema.getId()); System.out.println("Cinema Name: " + cinema.getName()); System.out.println(cinema.getScreens().size()); // 3 List<Screen> screens = cinema.getScreens(); List<Long> screenIds = screens.stream().map(screen -> screen.getId()).toList(); // stream -> screen의 id를 배열에 담음 System.out.println("screenIds : " + screenIds); // [1, 2, 3] List<Showtime> showtimes = showtimeRepository.findByScreenIds(screenIds); // 상영관들 for (Screen screen : screens) { System.out.println(" Screen ID: " + screen.getId()); System.out.println(" Screen Name: " + screen.getName()); System.out.println(" ------------"); for (Showtime showtime : showtimes) { System.out.println(" Showtime ID: " + showtime.getId()); System.out.println(" 상영 시작 시간: " + showtime.getStartedAt()); } } }
 

ShowtimeRepository

package shop.mtcoding.filmtalk.showtime; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface ShowtimeRepository extends JpaRepository<Showtime,Long> { @Query("select st from Showtime st join fetch st.movie m left join fetch st.screen sc where st.id=:id") Showtime mFindById(@Param("id") Long id); @Query("select s from Showtime s where s.screen.id in :screenIds") List<Showtime> findByScreenIds(@Param("screenIds") List<Long> screenIds); }
 

CinemaRepository

package shop.mtcoding.filmtalk.cinema; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import shop.mtcoding.filmtalk.screen.Screen; public interface CinemaRepository extends JpaRepository<Cinema, Long> { @Query("select c from Cinema c join fetch c.screens s") List<Cinema> mFindAllWithScreen(); //@Query("SELECT c FROM Cinema c JOIN FETCH c.screens s JOIN FETCH s.showtimes st WHERE c.id = :id") @Query("select Distinct c from Cinema c join fetch c.screens s where c.id=:id") Cinema mFindByIdWithScreen(@Param("id") Long id); @Query("select c from Cinema c join fetch c.screens s where c.id=:id") Cinema mFindCinemaById(@Param("id") Long id); @Query("select c from Cinema c join fetch c.region r where c.id=:id") Cinema mFindCinemaRegionById(@Param("id") Long id); }
notion image
 
notion image
 
→ select가 2번만 실행된다.
 

2. 조인 + 레이지로딩 (Join + LazyLoading) 으로 했을 때

 
→ 역시나 select가 2번 실행된다.
→ select 하는 횟수에 대한 차이는 없음
 
 
→ DB 조회 횟수에 대한 차이는 없지만
 
데이터를 더 깔끔하게볼 수 있는건 In query를 사용했을 때인 것 같다.
 
 
[Project] in query 테스트 코드 작성
 
영화관 & 상영관 & 상영 정보를 모두 조회해 오는 걸 inquery 를 사용해서 작성해 보았다.
 
 
notion image
package shop.mtcoding.filmtalk.cinema; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.test.context.TestPropertySource; import shop.mtcoding.filmtalk.screen.Screen; import shop.mtcoding.filmtalk.screen.ScreenRepository; import shop.mtcoding.filmtalk.showtime.Showtime; import shop.mtcoding.filmtalk.showtime.ShowtimeRepository; import shop.mtcoding.filmtalk.user.UserRepository; import java.util.List; @DataJpaTest public class CinemaRepositoryTest { @Autowired private CinemaRepository cinemaRepository; @Autowired private ScreenRepository screenRepository; @Autowired private ShowtimeRepository showtimeRepository; @Test public void mFindCinemaById_test() { List<Cinema> cinemas = cinemaRepository.findAll(); System.out.println("=========================="); List<Long> cinemaIds = cinemas.stream().map(cinema -> cinema.getId()).toList(); List<Screen> screens = screenRepository.findByCinemaIds(cinemaIds); System.out.println("=========================="); List<Long> screenIds = screens.stream().map(screen -> screen.getId()).toList(); List<Showtime> showtimes = showtimeRepository.findByScreenIds(screenIds); System.out.println("=========================="); for (Screen screen : screens) { System.out.println(" Screen ID: " + screen.getId()); System.out.println(" Screen Name: " + screen.getName()); System.out.println(" ------------"); for (Showtime showtime : showtimes) { System.out.println(" Showtime ID: " + showtime.getId()); System.out.println(" 상영 시작 시간: " + showtime.getStartedAt()); } } } }
notion image
 
 
package shop.mtcoding.filmtalk.cinema; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; public interface CinemaRepository extends JpaRepository<Cinema, Long> { // @Query("SELECT c FROM Cinema c " + // "JOIN FETCH c.screens scr " + // "JOIN FETCH scr.showtimes " + // "WHERE c.id = :cinemaId") // Cinema findCinemaWithScreensAndShowtimes(@Param("cinemaId") Long cinemaId); @Query("select c from Cinema c join fetch c.screens s where c.id=:id") Cinema mFindCinemaById(@Param("id") Long id); @Query("select c from Cinema c join fetch c.screens s where c.id in :cinemaIds") List<Cinema> mFindIdsByIds(@Param("cinemaIds") List<Long> cinemaIds); }
 
 
 
notion image
package shop.mtcoding.filmtalk.screen; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import shop.mtcoding.filmtalk.showtime.Showtime; import java.util.List; public interface ScreenRepository extends JpaRepository<Screen, Long> { @Query("select s from Screen s where s.cinema.id in :cinemaIds") List<Screen> findByCinemaIds(@Param("cinemaIds") List<Long> cinemaIds); }
 
 
 
notion image
package shop.mtcoding.filmtalk.showtime; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface ShowtimeRepository extends JpaRepository<Showtime,Long> { @Query("select st from Showtime st join fetch st.movie m left join fetch st.screen sc where st.id=:id") Showtime mFindById(@Param("id") Long id); @Query("select s from Showtime s where s.screen.id in :screenIds") List<Showtime> findByScreenIds(@Param("screenIds") List<Long> screenIds); }
 
 
notion image
notion image
 
→ 3번 SELECT 해서 모든 정보를 가져 올 수 있다.
 
 
[Project] 좌석 2차원 배열
 
테스트
@Test public void mFindAllByShowtimeId_test(){ //given Long showtimeId = 8L; // 열과 행의 최댓값 (초기화) int maxCols = 0; int maxRows = 0; //when List<Seat> seats = seatRepository.mFindAllByShowtimeId(showtimeId); System.out.println(seats.size()); // 9 // 좌석 정보에서 최대 열과 행을 찾아야 하므로 먼저 탐색 for(Seat seat : seats){ Character colNum = seat.getColNum(); //System.out.println(colNum); // 2 3 4 5 1 2 3 4 5 Character rowNum = seat.getRowNum(); // A B //System.out.println(rowNum); // colNum이 문자여서 숫자로 변환 int colIndex = Character.getNumericValue(colNum); //System.out.println("colIndex: " + colIndex); // 2 3 4 5 1 2 3 4 5 int rowIndex = rowNum - 'A' + 1; // A = 1, B = 2 // 1 1 1 2 2 2 2 2 //System.out.println("rowIndex: " + rowIndex); // 최대 열과 행을 업데이트 if(colIndex > maxCols){ maxCols = colIndex; } if(rowIndex > maxRows){ maxRows = rowIndex; } } System.out.println(maxCols); // 5 System.out.println(maxRows); // 2 // 계산된 최대 열과 행을 사용하여 배열 생성 Seat[][] seatArray = new Seat[maxRows][maxCols]; // seat[2][5] // 다시 좌석 정보를 배열에 넣는 작업 for(Seat seat : seats){ Character colNum = seat.getColNum(); // 1, 2, 3, 4, 5 Character rowNum = seat.getRowNum(); // A, B int colIndex = Character.getNumericValue(colNum) - 1; // 배열 인덱스는 0부터 시작하므로 -1 int rowIndex = rowNum - 'A'; // 배열 인덱스는 0부터 시작하므로 'A'는 0, 'B'는 1 seatArray[rowIndex][colIndex] = seat; } //eye //System.out.println(seatNumbers); System.out.println("이차원배열 길이 : " + seatArray.length); // 좌석 배열 확인용 출력 for (int i = 0; i < seatArray.length; i++) { for (int j = 0; j < seatArray[i].length; j++) { Seat seat = seatArray[i][j]; if (seat != null) { System.out.print("[" + seat.getRowNum() + seat.getColNum() + "] "); } else { System.out.print("[ ] "); // 중간에 빈 좌석 } } System.out.println(); // 행이 끝나면 줄 바꿈 } // 확인용 출력 System.out.println("Max Columns: " + maxCols); System.out.println("Max Rows: " + maxRows); }
 
SeatRepository
@Query("select s from Seat s join fetch s.showtime st where st.id = :showtimeId") List<Seat> mFindAllByShowtimeId(@Param("showtimeId") Long showtimeId);
 
실행 시키면
 
notion image
 
쿼리가 실행되면서
 
 
notion image
 
2차원 배열 안에 좌석 정보가 담기게 된다.
 
 
아쉬운 점 : 테스트 코드랑 서비스 레이어의 비즈니스 로직 부분에서는 이렇게 2차원 배열을 활용하였는데, ResponseDTO 에 담을 때 이차원 배열로 넘어온 걸 다시
행과 열을 따로 분리시켜서 담았다. ( 왜 그랬을까 ..? )
이차원 배열 형태로 DTO에 담아서 return 한 뒤 자바스크립트 코드를 사용해
그대로 렌더링 했으면 코드가 더 깔끔했을 것 같다.
 
 
[Code] 좌석 메인 화면
 

SeatController

@GetMapping("/api/seat") public String seat(HttpServletRequest request) { long showtimeId = (long) session.getAttribute("showtimeId"); SeatResponse.DTO model = seatService.좌석메인화면(showtimeId); request.setAttribute("model", model); return "seat/view"; }
 
좌석메인화면에 필요한 데이터를 조회하기 위해 showtimeId 가 필요한데
 
session으로 전달받아서 사용하였다.
 

SeatService

public SeatResponse.DTO 좌석메인화면(long id) { Showtime showtimePS = showtimeRepository.mFindById(id) .orElseThrow(() -> new Exception404("상영시간정보가 없습니다.")); Long screenId = showtimePS.getScreen().getId(); Screen screenPS = screenRepository.mFindAllById(screenId) .orElseThrow(() -> new Exception404("상영관정보가 없습니다.")); Integer totalSeat = seatRepository.mFindCountOfTotalSeat(id); if(totalSeat == null || totalSeat == 0){ throw new Exception404("좌석 정보가 없습니다."); } Integer reservedSeat = ticketRepository.mFindCountOfReservedSeats(id); if(reservedSeat == null){ reservedSeat = 0; } //System.out.println("예약된 좌석 수 " + reservedSeat); return new SeatResponse.DTO(showtimePS, screenPS, totalSeat, reservedSeat); }
 
null이나 0이 return 되는 경우 GlobalExceptionHandler 로 throw 해서 자바스크립트 alert 창이 뜨도록 처리하였다.
 

SeatRepository

@Query("select st from Showtime st left join fetch st.movie mt where st.id=:id") Optional<Showtime> mFindById(@Param("id") Long id);
 
컨벤션을 지키기 위해 m 을 매서드 이름 앞에 붙였다.
 

SeatResponse

@Data public static class DTO { // showtime private String startedAt; // 시작 시간 private String endedAt; // 끝나는 시간 private String wholeShowTime; // 일자 + 시간 private Integer price; // 영화 가격 ( 1좌석당, 1티켓당 ) // movie private Integer runtime; // 영화 러닝타임 private String movieNm; // 영화 pk private String ratingGrade; // 등급 //poster private String posterUrl; // 포스터 // screen private String screenNm; // 상영관 pk // cinema private String cinemaNm; // 영화관 pk private String cinemaImg; // 영화관 이미지 // seat count private Integer totalSeats; private Integer remainingSeats; public DTO(Showtime showtimePS, Screen screenPS, Integer totalSeat, Integer reservedSeat) { String formatGrande = "yyyy.MM.dd(E)"; String formatPequeno = "HH:mm"; String formatTotal = "yyyy.MM.dd(E) HH:mm"; //showtimePS.getStartedAt(); // 2024-09-12 14:00:00.0 // 시작 시간 형식 변환 this.startedAt = convertTimeStampToString(showtimePS.getStartedAt(), formatGrande); // 종료 시간 계산 ( 시작 시간 + runtime ) this.endedAt = calculateEndTime(showtimePS.getStartedAt(), showtimePS.getMovie().getRuntime()); // 종료 시간 계산 // 전체 시간 형식 변환 this.wholeShowTime = convertTimeStampToString(showtimePS.getStartedAt(), formatTotal); this.price = showtimePS.getPrice(); this.runtime = showtimePS.getMovie().getRuntime(); this.movieNm = showtimePS.getMovie().getMovieNm(); this.posterUrl = showtimePS.getMovie().getPosterUrls().get(0).getUrl(); String grade = showtimePS.getMovie().getRatingGrade(); if(grade == "전체"){ this.ratingGrade = "전체 관람가"; }else{ this.ratingGrade = grade + " 관람가"; } this.screenNm = screenPS.getName(); this.cinemaNm = screenPS.getCinema().getName(); this.cinemaImg = screenPS.getCinema().getImgName(); // 좌석 수 this.totalSeats = totalSeat; this.remainingSeats = totalSeats - reservedSeat; System.out.println(remainingSeats); } // Timestamp를 지정한 format으로 변환하는 매서드 public static String convertTimeStampToString(Timestamp timestamp, String format) { if(timestamp == null) return ""; try { Date date = new Date(); date.setTime(timestamp.getTime()); //return new SimpleDateFormat(format).format(date); // Locale 설정으로 요일을 한국어로 출력 SimpleDateFormat sdf = new SimpleDateFormat(format, Locale.KOREAN); return sdf.format(date); } catch (Exception e) { throw new Exception404("날짜 정보가 뭔가 잘못되었습니다."); } } // 시작 시간에 runtime을 더해 종료 시간을 계산하는 매서드 public static String calculateEndTime(Timestamp startedAt, int runtimeMinutes){ LocalDateTime startDateTime = startedAt.toLocalDateTime(); // TimeStamp를 LocalDateTime으로 변환 LocalDateTime endDateTime = startDateTime.plus(runtimeMinutes, ChronoUnit.MINUTES); // runtime을 분 단위로 더함 // HH :mm 형식으로 변환 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); return endDateTime.format(formatter); } }
 
좌석 페이지에 필요한 정보들을 담았다
 
좌석은 제외.
 
→ 좌석 코드까지 들어가니 코드가 너무 길어져서 좌석을 제외한 정보만 담았다.
→ 좌석은 AJAX 로 요청해서 SeatDTO 에 담아서 return 하도록 코드를 분리하였다.
 

seatView.mustache

<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>title</title> <!-- 좌석 선택 --> <!-- 부트스트랩 CSS 링크 --> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="/css/seat.css"> <link rel="stylesheet" href="/css/header.css"> </head> <body> {{>layout/header}} <main> <div id="hidden-data" data-price="{{model.price}}" data-showtimeid="{{showtimeId}}"></div> <div class="container mt-5"> <!-- 상단 인원 / 좌석 --> <div class="text-center mb-4"> <div class="bg-dark text-white py-2 mb-3">인원 / 좌석</div> </div> <section> <div class="count__seat__section"> <!-- 인원 선택 --> <div class="count__section"> <div class="mb-4"> <div class="text-center"> <label>일반</label> <div class="btn-group btn-group-toggle" data-toggle="buttons"> <button class="btn btn-outline-primary" id="count0" value="0" onclick="resetCount()">0</button><!-- 0 클릭 시 초기화 --> <button class="btn btn-outline-primary" id="count1" value="1" onclick="getCount(this.value)">1</button> <button class="btn btn-outline-primary" id="count2" value="2" onclick="getCount(this.value)">2</button> <button class="btn btn-outline-primary" id="count3" value="3" onclick="getCount(this.value)">3</button> <button class="btn btn-outline-primary" id="count4" value="4" onclick="getCount(this.value)">4</button> </div> </div> </div> </div> <div class="vertical__line"></div> <div class="movie__info__section"> <!-- 상영 정보 --> <div class="text-center mb-4"> <img class="screen__img" src="/img/cinema01.jpg"><!-- model.cinema 이미지로 수정 --> <p class="font-weight-bold">{{model.cinemaNm}} | {{model.screenNm}} | 남은좌석 {{model.remainingSeats}}/{{model.totalSeats}}</p> <p>{{model.wholeShowTime}} ~ {{model.endedAt}}</p><!-- 2024.09.04 (수) 20:15 --> </div> </div> </div> </section> <!-- 좌석 배치 --> <div class="seat-selection text-center mb-4"> <div class="bg-dark text-white py-2 mb-3">SCREEN</div> <div id="seat-container"></div> <!-- 좌석 반복 추가 --> </div> </div> <!-- 하단 검은창 --> <div class="row bg-dark text-white py-2 mb-3"> <div class="col text-left"> <button class="btn btn-secondary">영화선택</button> </div> <div class="blackbox__middle__container"> <div class="blackbox__img__div"> <img class="movie__img" src="{{model.posterUrl}}"> </div> <div class="blackbox__movie__div"> <div>{{model.movieNm}}</div> <div>{{model.ratingGrade}}</div> </div> <div> <table> <tr> <td class="left__td">극장</td> <td class="right__td">{{model.cinemaNm}}</td> </tr> <tr> <td>일시</td> <td>{{model.wholeShowTime}}</td><!-- 2024.9.14(토) 12:00 --> </tr> <tr> <td>상영관</td> <td>{{model.screenNm}}</td> </tr> <tr> <td>인원</td> <td id="peopleNum"></td><!-- js로 --> </tr> </table> </div> <div class="seat__num__box" id="seatNumBox"> <!-- JS 로 동적으로 넣기 --> </div> <div class="seat__payment__box" id="paymentBox"> <!-- JS 로 동적으로 넣기 --> </div> </div> <div class="col text-right"> <form action="/api/seat/reservation" method="post"> <!--<input type="hidden" name="showtimeId" value="">--> <input type="hidden" name="showtimeId" value="{{showtimeId}}"> <input type="hidden" name="selectedSeatsIds" id="selectedSeatsIds"> <input type="hidden" name="totalPrice" id="totalPrice"> <button onclick="return didYouSelectAll()" class="btn btn-secondary" type="submit">결제선택</button> </form> </div> </div> <!-- 부트스트랩 JS 및 jQuery --> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script> <script src="/js/seat2.js"></script> </main> {{>layout/footer}} </body> </html>
 
 
notion image
 
좌석을 제외한 부분의 정보가 잘 들어왔다.
 
[Code] 좌석 렌더링
 

Seat.js

async function getSeats() { try { const hiddenData = document.getElementById('hidden-data'); let showtimeId = hiddenData.getAttribute('data-showtimeid'); // fetch 요청하기 let response = await fetch(`/api/seat/` + showtimeId, { method: "get", }); let responseBody = await response.json(); console.log(responseBody); // 응답 상태 코드 확인 if (responseBody.status === 200) { renderSeats(responseBody.body); // 좌석을 렌더링하는 함수 } else { console.log("응답 상태가 정상적이지 않습니다:", response.status); alert("좌석 정보를 불러 올 수 없습니다."); } } catch (error) { console.error("요청 중 오류 발생:", error); // 에러 발생 시 출력 alert("좌석 정보를 불러오는 중 오류가 발생했습니다."); } } getSeats();
 
Get 요청으로 좌석 정보를 DTO에 담아 들고 온 뒤, renderSeats 함수를 통해 화면에 나타나도록 했다.
 
let selectedSeats = []; // 선택된 좌석을 저장할 배열 let selectedSeatsIds = []; // 선택된 좌석의 pk를 저장할 배열 // 좌석 렌더링 seats.forEach(seatInfo => { // row가 바뀌면 새로운 줄을 만든다 if (seatInfo.row !== currentRow) { // A '' -> A // B A -> B currentRow = seatInfo.row; // currentRow = A rowElement = document.createElement('div'); // row div 만들기 rowElement.className = 'seat-row'; // div class = "seat-row" seatContainer.appendChild(rowElement); // div 생성! } // 예약 여부 // reservedSeats의 row와 넘어온 SeatInfo의 row가 같고, reservedSeats의 col과 seatInfo의 col이 같으면 // 예약된 좌석이니까 // true 가 반환된다. // some은 JavaScript의 배열 메서드 중 하나로, 배열의 요소 중에서 주어진 조건을 만족하는 요소가 하나라도 있는지 확인할 때 사용 const isReserved = reservedSeats.some(rs => rs.row === seatInfo.row && rs.col === seatInfo.col); // 좌석을 표시하는 버튼 생성 let seatButton = document.createElement('button'); // button 선언 seatButton.className = 'seat'; // 버튼의 calss명은은 seat seatButton.textContent = seatInfo.row + seatInfo.col; // A + 1 seatButton.id = seatInfo.id; // seat + pk if (isReserved) { // TRUE seatButton.classList.add('reserved'); seatButton.disabled = true; // 예약된 좌석은 클릭 불가 } else { seatButton.classList.add('available'); // 예약안된 좌석은 Class 이름에 available 추가 let isSelected = false; // 지금은 선택 안되어있으니 false seatButton.addEventListener('click', function () { // 클라이언트가 클릭을 시작..! if(maxSelectableSeats == 0){ alert("인원을 먼저 선택해주세요") } isSelected = seatButton.classList.contains('selected'); // 버튼의 현재 선택 상태 확인 if (isSelected) { seatButton.style.backgroundColor = ''; // 선택 해제 시 원래 색으로 돌아옴 seatButton.classList.remove('selected'); selectedCount--; // 선택 해제 시 선택된 좌석에서 제거 selectedSeats = selectedSeats.filter(seat => seat !== seatButton.textContent); // 선택 해제 시 선택된 좌석의 id를 배열에서 제거 selectedSeatsIds = selectedSeatsIds.filter(seat => seat !== seatButton.id); } else { if (selectedCount < maxSelectableSeats) { // 선택 가능한 좌석 수 체크 // 선택할 때마다 배열을 새로 구성 seatButton.style.backgroundColor = 'red'; // 선택 시 빨간색으로 변경 seatButton.classList.remove('available'); seatButton.classList.add('selected'); selectedCount++; // 선택된 좌석 수 증가 selectedSeats.push(seatButton.textContent); // 선택된 좌석을 배열에 추가 selectedSeatsIds.push(seatButton.id); // 선택된 좌석의 id 를 배열에 추가 console.log(selectedSeats); console.log(selectedSeatsIds); if (selectedCount === maxSelectableSeats) { // // 더 이상 좌석을 선택할 수 없을 때 나머지 좌석을 비활성화 // document.querySelectorAll('.seat.available').forEach(seat => { // if (!seat.classList.contains('selected')) { // seat.disabled = true; // 나머지 좌석 비활성화 // seat.style.backgroundColor = '#ccc'; // 비활성화된 좌석의 배경색 진하게 변경 // } // }); // seatNumbers 함수를 통해 선택된 좌석 번호를 추가 document.getElementById("seatNumBox").innerHTML = ''; // 기존 내용 초기화 // 선택된 좌석들을 HTML로 변환하여 추가 document.getElementById("seatNumBox").innerHTML = ` <table class="seatNumTable"> <tr> <th>좌석번호</th> </tr> ${selectedSeats.map(seat => `<tr><td>${seat}</td></tr>`).join('')} </table>`; document.getElementById("peopleNum").innerHTML = ` 일반 ${selectedCount}명 `; document.getElementById("selectedSeatsIds").value = selectedSeatsIds.join(','); // 배열을 문자열로 변환 const hiddenData = document.getElementById('hidden-data'); const price = hiddenData.getAttribute('data-price'); // 일반 let pureTotalPrice = selectedCount * price; let commaTotalPrice = pureTotalPrice.toLocaleString(); console.log(commaTotalPrice); document.getElementById("totalPrice").value = commaTotalPrice; document.getElementById("paymentBox").innerHTML = ''; document.getElementById("paymentBox").innerHTML = ` <table> <tr> <td>일반</td> <td>${price}원 X ${selectedCount}</td> </tr> <tr> <td>총금액 &nbsp&nbsp</td> <td style="color: red">${commaTotalPrice}원</td><!-- 빨간색 --> </tr> </table> `; } } else { if(selectedCount == 0){ alert(`인원을 먼저 선택해주세요`); }else{ alert(`최대 ${maxSelectableSeats}개의 좌석만 선택할 수 있습니다.`); } } } }); } rowElement.appendChild(seatButton); // 버튼을 현재 줄에 추가 }); }
 
좌석은 버튼으로 생성하였고,
클래스명에 reserved, available, selected 를 넣어서
좌석의 예약여부 와 선택가능 여부를 구분하였다.
 
let selectedSeats = []; // 선택된 좌석을 저장할 배열 let selectedSeatsIds = []; // 선택된 좌석의 pk를 저장할 배열
 
2가지 배열을 사용해
 
자바스크립트를 사용해 동적으로 화면에 렌더링할 좌석번호 (A1, A2 등) 와
form 전송 시 파라미터로 넘길 좌석 pk (1, 2 등) 가
 
동시에 2가지 배열에 담기도록 하였다.
 
아쉬운 점 : 좌석 선택이 끝나면 나머지 좌석을 비활성화 하려고 했는데,
잘 안되었다. 그래서 일단은 주석처리 해두었다.
let selectedCount = 0; // 현재 선택된 좌석 수 let maxSelectableSeats = 0; // 선택할 수 있는 최대 좌석 수 // 인원 선택 시 function getCount(value) { if(value == 0){ maxSelectableSeats = 0; // 최대 좌석 수 초기화 0 }else{ maxSelectableSeats = parseInt(value); // 선택할 수 있는 최대 좌석 수를 저장 1 2 3 4 } selectedCount = 0; // 인원이 변경되면 선택된 좌석수를 초기화 selectedSeats = []; // 인원이 변경되면 선택된 좌석 seatNum을 초기화 selectedSeatsIds = []; // 인원이 변경되면 선택된 좌석 seatIds를 초기화 document.querySelectorAll('.seat.selected').forEach(seat => { seat.classList.remove('selected'); seat.style.backgroundColor = ''; // 선택된 좌석을 해제 }); console.log(`선택 가능한 좌석 수: ${maxSelectableSeats}`); } // 인원수에 맞게 좌석 선택했는지 & 좌석 선택했는지 확인 function didYouSelectAll(){ let choosedCount = document.querySelectorAll('.seat.selected').length; if(maxSelectableSeats != choosedCount){ alert("관람인원과 선택 좌석 수가 동일하지 않습니다."); return false; } if(choosedCount == 0){ alert("관람인원을 선택해 주세요."); return false; } return true; }
 
getCount 함수를 사용해
 
페이지 상단의 인원 선택 버튼을 클릭했을 때,
 
최대 선택가능한 좌석수를 변경하고 이미 선택된 좌석 정보가 초기화 되도록 했다.
 
didYouSelectAll 함수는 선택한 인원과 선택한 좌석 수가 동일한지 검사하는 함수다.
 
 
 
 
 
 
 
 
 
 
Share article

SHIN