개인 프로젝트 진행 중 공연장에 등록된 hall을 리스트로 조회하는 API를 구현하면서 각 hall에 설정된 좌석수를 간단히 보여주고 싶었다.
이 때 모든 hall 리스트가 보여지더라도, 모든 hall에 대한 좌석 세부 정보를 확인하지 않을 거라 생각하기 때문에
좌석 정보를 조회하는 부분과 hall을 조회하는 부분을 나누어 필요하지 않은 데이터까지 로딩하지 않는 목표를 세웠다.
공연장과 공연장에 존재하는 홀, 홀에 존재하는 좌석 이렇게 세개의 테이블은 서로 연관관계를 가지는 테이블로 구성되어있고
홀에 존재하는 좌석 수를 구하는 방법 중 4가지 방법을 고안하고 고민했었다.
고민했던 4가지 방안
1. 공연홀을 조회하고, 각 좌석의 리스트 개수를 조회하기
2. 공연홀을 조회한 후 각 공연 홀에 대한 좌석 수 카운팅 하기
3. @Formula를 사용하여 공연홀 조회와 동시에 좌석 수 카운팅 하기
4. JPQL 사용해서 좌석 수 조회 하기
1. 공연홀을 조회하고, 각 좌석의 리스트 개수를 조회하기
/* 공연 홀 엔티티 내부에 선언되어있는 좌석 엔티티
@OneToMany(mappedBy = "theaterRoom", cascade = CascadeType.ALL)
private List<TheaterSeat> seats;
*/
public List<TheaterRoomResponseDto> getTheaterRoomList(Long userId, Long theaterId, int pageNo, String criteria) {
Pageable pageable = PageRequest.of(pageNo, PAGE_SIZE, Sort.by(Sort.Direction.DESC, criteria));
Page<TheaterRoom> theaterRoomsPage = theaterRoomRepository.findTheaterRoomsByTheaterId(theaterId, pageable);
if (theaterRoomsPage.isEmpty()) {
throw new DataNotFoundException("공연에 등록된 Hall이 없습니다.");
}
theaterRoomsPage.getContent().forEach(room -> room.setSeatCount(room.seats.size()));
return responseMapper.toTheaterRoomResponseDto(theaterRoomsPage);
}
이 방법은 고안한 방법 중 성능측면에서 가장 비효율적인 방법이다.
@OneToMany의 경우 따로 fetch type을 지정해주지 않는다면 LAZY 이며 LAZY 인 경우, 호출되는 순간 데이터가 로드되는 지연 방식이다.
내가 목표한 바는 필요없는 정보 즉, 아직 정보 조회를 요청하지 않는 정보까지 로드하지 말자는 것이었다.
하지만 seats 리스트의 사이즈를 얻는 순간 seats의 데이터가 리스트에 채워지게 되므로 결국 모든 영속되어있는 데이터를 다 조회하는 꼴이 되어버려 성능 비효율이 생긴다.
뿐만 아니라 모든 공연 홀 리스트를 순회하면서 좌석의 수를 세팅해주어야 하기 때문에 이 또한 hall이 많아질수록 비효율이 발생할 것이라 판단했다.
따라서 이 방법은 탈락이다.
2. 공연홀을 조회한 후 각 공연 홀에 대한 좌석을 조회하기
public List<TheaterRoomResponseDto> getTheaterRoomList(Long userId, Long theaterId, int pageNo, String criteria) {
Pageable pageable = PageRequest.of(pageNo, PAGE_SIZE, Sort.by(Sort.Direction.DESC, criteria));
Page<TheaterRoom> theaterRoomsPage = theaterRoomRepository.findTheaterRoomsByTheaterId(theaterId, pageable);
if (theaterRoomsPage.isEmpty()) {
throw new DataNotFoundException("공연에 등록된 Hall이 없습니다.");
}
theaterRoomsPage.getContent().forEach(room -> room.setSeatCount(theaterSeatRepository.findSeatCountByTheaterRoomId(room.getId())));
return responseMapper.toTheaterRoomResponseDto(theaterRoomsPage);
}
이 방법은 TheaterRoomList 조회에 theaterRoomRepository만 관여하게 하고싶은데 theaterSeatRepository가 관여되는 것이 통일성 측면에서 깨진다고 생각했다.
또한 1번의 방법과 동일하게 모든 공연 홀 리스트를 순회하면서 좌석의 수를 세팅해주어야 하기 때문에 이 또한 hall이 많아질수록 비효율이 발생할 것이라 판단했다.
3. @Formula를 사용하여 공연홀 조회와 동시에 좌석 수 카운팅 하기
public class TheaterRoom {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "theater_id")
private Theater theater;
@Column
private String name;
@OneToMany(mappedBy = "theaterRoom", cascade = CascadeType.ALL)
private List<TheaterSeat> seats;
@Formula("(SELECT COUNT(s.id) FROM seat s WHERE s.theater_room_id = id)")
private int seatCount;
// 하단 생략
@Formula를 사용해서 공연 홀을 조회할 때 seatCount까지 조회 하는 방법이다.
이 방법은 실제 실행해보면 조회 쿼리에 서브쿼리로 들어가게 되는 방식이다. 실제로 수행되는 sql을 출력해 보면 다음과 같다.
org.hibernate.SQL: select tr1_0.id,tr1_0.name,(SELECT COUNT(s.id) FROM seat s WHERE s.theater_room_id = tr1_0.id),tr1_0.theater_id from theater_room tr1_0 left join theater t1_0 on t1_0.id=tr1_0.theater_id where t1_0.id=?
공연홀을 조회하면서 좌석 개수를 한꺼번에 구할 수 있고, 불필요한 데이터를 로드하지 않아 목표에 부합하여 이 방식을 채택하게 되었다.
@Formula 사용시 쿼리가 서브쿼리로 수행되기 때문에 "(쿼리)" 형태로 작성해주어야 한다.
4. JPQL 사용해서 좌석 수 조회 쿼리 만들기
@Repository
public interface TheaterRoomRepository extends JpaRepository<TheaterRoom, Long> {
@Query("SELECT COUNT(seat) FROM TheaterSeat seat WHERE seat.theaterRoom.id = :roomId")
Long countSeatsByTheaterRoomId(Long roomId);
}
이 방식은 2번과 다르게 내가 통일하고 싶던 구조를 해치지 않으면서 수행할 수 있다.
하지만 여전히 list를 모두 돌면서 좌석 수 값을 세팅해주어야 한다는 문제는 동일하다.
따라서 2번과 동일한 이유로 보류하였다.
따라서 나는 최종적으로 3번 방식을 통해 좌석 수를 구하는 방식을 채택하여 테스트를 진행하게 되었다.
근데 테스트를 하면서 큰 문제가 발생했다.
내가 @Formula를 통해 구한 좌석 수 값을 검증하기 위해 작성한 테스트 코드는 다음과 같다.
@DataJpaTest
@Import({ManageTheaterServiceImpl.class, ResponseMapperImpl.class})
@DisplayName("공연장 관리 서비스 테스트")
class TheaterManageServiceImplTest {
@Autowired
ManageTheaterService manageTheaterService;
@Autowired
TheaterRepository theaterRepository;
TheaterDto theaterDto;
final int insertTheaterRoom = 3;
final int insertSeat = 5;
final Long userId = 1L;
@BeforeEach
void insert() {
this.theaterDto = createTestTheater("테스트 공연장");
Long userId = 1L;
manageTheaterService.saveTheater(theaterDto, userId);
}
@Test
@DisplayName("홀이 등록되어 있는 공연장 홀 리스트 조회 시 성공")
void givenRegisterTheaterRoomByTheaterId_whenGetTheaterRoomList_thenGetTheaterRoomListSuccessfully() {
//given
Long userId = this.userId;
List<TheaterResponseDto> theaterResponseDtoList = manageTheaterService.getTheaterList(userId, 0, "name");
Long theaterId = theaterResponseDtoList.get(0).getId();
System.out.println(theaterId);
//when
List<TheaterRoomResponseDto> theaterRoomList = manageTheaterService.getTheaterRoomList(userId, theaterId, 0, "name");
//then
Assertions.assertThat(theaterRoomList).hasSize(insertTheaterRoom);
Assertions.assertThat(theaterRoomList.get(0).getName()).isEqualTo("test hall2"); // name으로 페이지네이션 했으므로 가장먼저 높은 숫자 출력
Assertions.assertThat(theaterRoomList.get(0).getSeatCount()).isEqualTo(insertSeat);
}
private List<TheaterRoomDto> createTestTheaterRooms() {
List<TheaterRoomDto> roomDtoList = new ArrayList<>();
for (int i = 0; i < 3; i++) {
roomDtoList.add(TheaterRoomDto.builder().roomName("test hall" + i).seatData(createTestTheaterSeats()).build());
}
return roomDtoList;
}
private List<TheaterSeatDto> createTestTheaterSeats() {
List<TheaterSeatDto> seatDtoList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
seatDtoList.add(TheaterSeatDto.builder().seatFloor(i).seatRow(5).seatNumber(1).build());
}
return seatDtoList;
}
}
위의 테스트 코드에서 theaterRoom 데이터는 잘 출력되나, seatCount 가 계속 0으로 나오는 문제가 발생했다.다.Assertions.assertThat(theaterRoomList.get(0).getSeatCount()).isEqualTo(insertSeat);
도대체 뭐가 문제인가..
로그 레벨을 낮춰 sql 수행 쿼리를 모두 찍어봐도 다음과 같이 쿼리가 잘 들어가있다.
심지어는 브레이크 포인트 잡아서 디버깅도 해 보고 로그도 직접 찍어서 확인해보아도 쿼리는 수행되는데 0으로 찍히는 것이다.
그럼 h2 데이터베이스를 사용하지 않고 실제 데이터베이스에 데이터를 저장하고 해당 쿼리를 직접 실행해보자.
insert한 데이터도 잘 들어가있고, 쿼리 수행 시에도 서브쿼리에 해당되는 내 @Formula 데이터도 너무나 잘 조회된다.
위 문제의 원인은 @DataJpaTest에 숨어있다.
@DataJpaTest는 @Transactional이 포함되어있다. 그리고 내 테스트 코드에는 @BeforeEach를 통해 데이터를 직접 insert하고, insert된 데이터를 통해 검증을 시도한다.
하지만 JPA의 @Transactional은 insert, update와 같은 dml 쿼리는 성능을 위해 commit이 되기 전까지 영속성 컨텍스트내에만 존재하며 실제 데이터베이스에 작업을 하지 않는다.
그래서 이것이 왜 문제가 되는가??
단순 조회시에는 영속성 컨텍스트에 저장된 데이터를 통해 조회를 잘 해오지만, @Formula 데이터는 insert할 때 필요한 데이터가 아니기 때문에 insert할 때 데이터로 넣어준 적이 없으며 따라서 값을 얻기 위해 데이터베이스에 직접 접근해서 쿼리를 수행하기 때문에 값이 없는것이다.
그럼 JPA @Transactional에 의해 실제 데이터베이스에는 값이 저장되지 않았는데 seatCount값은 필요로 하기 때문에 조회를 데이터베이스에 하려고 하니 값이 0으로 조회되는 것이다..
@Formula는 자료도 많이 없고 chatgpt도 왜 안되는지 답을 알려주지 않아서 원인 파악에 꽤나 많은 시간을 소요했다..
위 테스트에서 문제를 해결하려면 @DataJpaTest를 사용하지 않고, @Transactional를 사용하지 않고 @SpringBootTest를 사용하면 성공한다.
근데 나는 이 테스트 안에서 값이 잘 들어가는지에 대한 테스트도 수행해야 하기 때문에 @Transactional을 사용해야만 한다.
그럼 또 다른 방법은 @BeforeEach로 수행하는것이 아닌 데이터 insert 작업이 필요한 곳에 메서드를 추가해서 작업을 하고 필요한 테스트에만 @Transactional을 수행하면 된다.
하지만 결론적으로는!
내가 조회하려는 좌석 수는 변동이 잦지 않은 데이터이기 때문에 항상 count를 조회하는 쿼리를 통해 조회하기 된다면 데이터의 성격에 따라 count() 쿼리 자체가 성능적으로 부하가 많을 수 있어 차라리 컬럼으로 넣어 같이 관리하기로 했다..!
@Formula사용 시 아래와 같은 사항을 잘 고려해서 사용해야한다.
1. 성능 문제
@Formula 어노테이션으로 지정된 필드는 해당 엔티티가 로드될 때마다 SQL 표현식이 실행되므로 추가적인 데이터베이스 쿼리를 발생시킬 수 있으며, 복잡한 SQL 표현식이나 대량의 데이터를 처리할 경우 성능 저하를 일으킬 수 있다
2. 휴대성 문제
@Formula에 사용되는 SQL 표현식은 데이터베이스 종류에 종속적일 수 있어 데이터베이스로 전환할 때 SQL 표현식이 호환되지 않을 수 있기 때문에, 데이터베이스 휴대성을 저하시킬 수 있다.
3. 유지보수성
@Formula를 사용하는 SQL 표현식은 자바 코드 내에 문자열로 포함되어 있어, IDE의 도움을 받지 못하고, SQL 쿼리의 오류를 컴파일 타임에 발견하기 어렵다.
4. 보안 고려사항
SQL 인젝션 공격과 같은 보안 취약점에 노출될 위험이 있으므로, @Formula 내에 사용자 입력 값을 직접 포함시키는 것은 피해야 한다.
5. 트랜잭션 및 데이터 일관성
@Formula로 계산된 필드는 읽기 전용이라 이 필드를 통해 계산된 값을 데이터베이스에 저장하거나 업데이트할 수 없다.
특수한 상황에서는 아주 유용하게도 사용될 수 있는 기능인 것 같다.
결국 사용하지 않기로 했으나 자료가 없어서 직접 여러가지 찾으면서 분석할 수 있는 기회가 되어 좋은 공부가 되었다!
'Programming > Spring' 카테고리의 다른 글
[Spring] DELETE API 사용하기 (0) | 2022.12.06 |
---|---|
[Spring] PUT API 사용하기 (1) | 2022.12.06 |
[Spring] POST API 사용하기 (0) | 2022.12.06 |
[Spring] GET API 사용하기 (Annotation 정리) (0) | 2022.12.06 |