티스토리 뷰
롯데시네마 클론 코딩 프로젝트에서 사용자의 티켓 정보를 가져오는 기능 개발 중
테이블 간 연관된 정보들이 많아 총 8개의 테이블을 건드려야 하는 상황이 발생했습니다
“과연 쿼리문을 8개 사용하는 것이 적합한가?”라는 의문을 가지게 되었습니다🫤
기능 개발 후, 리팩토링을 통해 성능을 향상시켜보았습니다🧙
Refactor1.
fetch join을 통해 연관관계 엔티티 함께 조회하기
Refactor2.
각 티켓에 대한 티켓 정보는 비동기적으로 가져오기
(각 티켓은 서로에게 영향을 미치지 않는 독립적인 데이터이므로)
Fetch Join X, 비동기 X - 75349ms(기존 구현)
우선 기존에 구현된 코드는 아래와 같습니다!
각각의 테이블들에 대해 데이터를 따로 가져오고 있는 것을 확인할 수 있습니다.
- ticket, member, schedule, movie, screen, theater, payment, seat 무려 8개😅
@Service
@RequiredArgsConstructor
public class TicketService {
// ...
@Transactional
public List<TicketInfoResponseDto> getAllTicketsInfoByMemberId(Long memberId) {
List<TicketInfoResponseDto> ticketInfoResponseDtos = new ArrayList<>();
List<Ticket> tickets = ticketRepository.findAllByMemberId(memberId);
tickets.forEach(ticket -> {
ticket.changeStatusCorrectly();
addTicketInfoResponseDtosByTicket(ticket, ticketInfoResponseDtos);
});
return ticketInfoResponseDtos;
}
private void addTicketInfoResponseDtosByTicket(Ticket ticket,
List<TicketInfoResponseDto> ticketInfoResponseDtos) {
Schedule relatedSchedule = ticketRepository.findScheduleByTicketId(ticket.getId())
.orElseThrow(() -> new RuntimeException("연결된 상영 일정 없음"));
Movie relatedMovie = scheduleRepository.findMovieByScheduleId(relatedSchedule.getId())
.orElseThrow(() -> new RuntimeException("연결된 영화 없음"));
Screen relatedScreen = scheduleRepository.findScreenByScheduleId(relatedSchedule.getId())
.orElseThrow(() -> new RuntimeException("연결된 상영관 없음"));
Theater relatedTheater = theaterRepository.findByScreenId(relatedScreen.getId())
.orElseThrow(() -> new RuntimeException("연결된 영화관 없음"));
List<Seat> relatedSeats = ticket.getSeats();
Payment relatedPayment = ticketRepository.findPaymentByTicketId(ticket.getId())
.orElseGet(Payment::new);
TicketInfoResponseDto ticketInfoResponseDto = TicketInfoResponseDto.create(ticket, relatedMovie,
relatedTheater, relatedSchedule, relatedScreen, relatedPayment,
relatedSeats.stream().map(Seat::getName).toList());
ticketInfoResponseDtos.add(ticketInfoResponseDto);
}
}
🧙Refactor1. fetch join을 통해 연관관계 엔티티 함께 조회하기
일반 Join vs Fetch Join
1. 일반 Join
A 테이블에 B를 Join 하는 경우, 실제 조회하는 컬럼은 B가 아닌 A에 대한 컬럼만 조회하게 됩니다.
2. Fetch Join
A 테이블에 B를 fetch Join 하는 경우, 실제 조회하는 컬럼은 A의 컬럼 뿐만 아니라 B의 컬럼도 함께 조회하게 됩니다.
👉 간단한 예시로 살펴보면
User 엔티티와 Post 엔티티가 있고, User와 Post가 1:1 연관관계를 가지는 경우
1. Post에 User를 일반 Join하여 데이터 가져오기
- 연관 관계의 엔티티 데이터를 사용해야 할 경우 별도의 조회 쿼리문을 실행해야 하므로 N번의 쿼리가 더 발생
- join대상에 대해서는 영속성 컨텍스트에 담지 않는다.
- FetchType.Eager에서 N+1 문제가 발생
- 모든 Post에 대해서 User를 조회하는 경우에는, FetchType Lazy에서도 N+1 문제 발생
2. Post에 User를 Fetch Join하여 데이터 가져오기
- 조회 주체가 되는 Post와 연관 관계의 User까지 모두 조회하여 영속화하므로, 2개의 엔티티 모두 영속성 컨텍스트로 관리되어져 쿼리 1개로 모두 조회 가능
- Fetch Join은 연관관계의 Entity 모두 영속화하기 때문에 FetchType이 LAZY인 Entity에 참조하더라도 따로 쿼리가 실행되지 않아 N+1 문제가 발생하지 않음.
➕N+1문제: 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생
N+1 문제로 불필요하게 발생하는 쿼리문을 제거하기 위해, Fetch Join을 적용해보았습니다!
Fetch Join O, 비동기 X - 440ms
JPQL을 통해 fetch join을 적용하여 쿼리 하나로 연관관계의 엔티티를 함께 조회했습니다.
레포지토리에서 데이터를 가져오도록 처리함으로 서비스 코드 또한 간단해졌으며, 소요시간 또한 75349ms ➡️ 440ms 로 눈에 띄게 줄어든 것을 확인할 수 있었습니다😮
@Query(value = "SELECT t FROM Ticket t " +
"JOIN FETCH t.member m " +
"JOIN FETCH t.schedule s " +
"JOIN FETCH s.movie " +
"JOIN FETCH s.screen sc " +
"JOIN FETCH sc.theater " +
"LEFT JOIN FETCH t.payment " +
"JOIN FETCH t.seats " +
"WHERE t.member.id = :memberId")
List<Ticket> findAllByMemberIdWithFetchJoin(Long memberId);
@Transactional
public List<TicketInfoResponseDto> getAllTicketsInfoByMemberIdWithFetchJoin(Long memberId) {
List<TicketInfoResponseDto> ticketInfoResponseDtos = new ArrayList<>();
List<Ticket> tickets = ticketRepository.findAllByMemberIdWithFetchJoin(memberId);
tickets.forEach(ticket -> {
ticket.changeStatusCorrectly();
addTicketInfoResponseDtosByTicket(ticket, ticketInfoResponseDtos);
});
return ticketInfoResponseDtos;
}
private void addTicketInfoResponseDtosByTicket(Ticket ticket,
List<TicketInfoResponseDto> ticketInfoResponseDtos) {
Theater theater = ticket.getSchedule().getScreen().getTheater();
List<Seat> seats = ticket.getSeats();
Payment payment = ticket.getPayment() == null ? new Payment() : ticket.getPayment();
TicketInfoResponseDto ticketInfoResponseDto = TicketInfoResponseDto.create(
ticket, ticket.getSchedule().getMovie(), theater,
ticket.getSchedule(), ticket.getSchedule().getScreen(),
payment,
seats.stream().map(Seat::getName).toList());
ticketInfoResponseDtos.add(ticketInfoResponseDto);
}
🧙Refactor2. 각 티켓에 대한 티켓 정보는 비동기적으로 가져오기
사용자가 가진 티켓들은 서로 독립적인 정보이기에, 각 티켓에 대한 정보를 불러오는 작업을 비동기로 처리했습니다.
Fetch Join O, 비동기 O - 266ms(현재)
✴️비동기 처리 포인트
- ConcurrentHashMap 사용
- 티켓 정보 저장 시, 여러 스레드에서 안전하게 동시에 접근할 수 있도록 함.
- CompletableFuture.runAsync()를 사용하여 각 티켓에 대한 작업을 비동기로 실행
- 각 티켓마다 실행되는 작업은
- 티켓 상태 체크 -> 티켓 관련 정보 가져오기
- 반환값이 필요없는 경우 비동기 작업의 경우 runAsync()를 사용하고, 반환값이 필요한 경우 supplyAsync()를 사용하면 됩니다. 각각 티켓을 가져와서 ConcurrentHashMap에 넣어주었기에 반환값이 필요없는 runAsync()를 사용해주었습니다.
- 각 티켓마다 실행되는 작업은
- CompletableFuture.allOf().join()을 통해 모든 작업이 완료될 때까지 기다리기
비동기로 처리함으로 소요시간이 440ms ➡️ 266ms 로 줄어든 것을 확인했습니다.
@Query(value = "SELECT t FROM Ticket t " +
"JOIN FETCH t.member m " +
"JOIN FETCH t.schedule s " +
"JOIN FETCH s.movie " +
"JOIN FETCH s.screen sc " +
"JOIN FETCH sc.theater " +
"LEFT JOIN FETCH t.payment " +
"JOIN FETCH t.seats " +
"WHERE t.member.id = :memberId")
List<Ticket> findAllByMemberIdWithFetchJoin(Long memberId);
@Transactional
public List<TicketInfoResponseDto> getAllTicketsInfoByMemberIdWithFetchJoinAndAsync(
Long memberId) {
Map<Long, TicketInfoResponseDto> ticketInfoResponseDtoMap = new ConcurrentHashMap<>();
List<CompletableFuture<Void>> ticketFutureList = ticketRepository.findAllByMemberIdWithFetchJoin(
memberId)
.stream()
// 각 티켓에 대해 비동기적으로 작업을 실행
// 각 티켓마다 실행되는 작업: 티켓 상태 체크 -> 티켓 관련 정보 가져오기
.map(ticket -> CompletableFuture.runAsync(() -> {
ticket.changeStatusCorrectly();
addTicketInfoResponseDtoMapByTicket(ticket, ticketInfoResponseDtoMap);
}))
.toList();
// 모든 비동기 작업이 완료될 때까지 대기
CompletableFuture.allOf(
ticketFutureList.toArray(new CompletableFuture[ticketFutureList.size()])
).join();
return new ArrayList<>(ticketInfoResponseDtoMap.values());
}
private void addTicketInfoResponseDtoMapByTicket(Ticket ticket,
Map<Long, TicketInfoResponseDto> ticketInfoResponseDtos) {
Theater theater = ticket.getSchedule().getScreen().getTheater();
List<Seat> seats = ticket.getSeats();
Payment payment = ticket.getPayment() == null ? new Payment() : ticket.getPayment();
TicketInfoResponseDto ticketInfoResponseDto = TicketInfoResponseDto.create(
ticket, ticket.getSchedule().getMovie(), theater,
ticket.getSchedule(), ticket.getSchedule().getScreen(),
payment,
seats.stream().map(Seat::getName).toList());
ticketInfoResponseDtos.put(ticket.getId(), ticketInfoResponseDto);
}
테스트 코드로 소요 시간 비교하기
테스트 코드를 통해 티켓 1000개에 대해 정보를 가져오는데 걸리는 소요시간을 측정 후 비교해보았습니다.
@Test
@DisplayName("특정 멤버의 모든 티켓 관련 정보 가져올 수 있다. - Fetch Join X, 비동기 X")
void success_get_all_tickets_Info_by_member_id() {
// given
// when
long beforeTime = System.currentTimeMillis();
List<TicketInfoResponseDto> ticketInfoResponseDtos = ticketService.getAllTicketsInfoByMemberId(
member.getId());
long afterTime = System.currentTimeMillis();
long timeTaken = afterTime - beforeTime;
System.out.println(timeTaken + "ms");
// then
assertThat(ticketInfoResponseDtos).hasSize(ticketCnt);
}
@Test
@DisplayName("특정 멤버의 모든 티켓 관련 정보 가져올 수 있다. - Fetch Join O, 비동기 X")
void success_get_all_tickets_Info_by_member_id_given_fetch_join() {
// given
// when
long beforeTime = System.currentTimeMillis();
List<TicketInfoResponseDto> ticketInfoResponseDtos = ticketService.getAllTicketsInfoByMemberIdWithFetchJoin(
member.getId());
long afterTime = System.currentTimeMillis();
long timeTaken = afterTime - beforeTime;
System.out.println(timeTaken + "ms");
// then
assertThat(ticketInfoResponseDtos).hasSize(ticketCnt);
}
@Test
@DisplayName("특정 멤버의 모든 티켓 관련 정보 가져올 수 있다. - Fetch Join O, 비동기 O")
void success_get_all_tickets_Info_by_member_id_given_fetch_join_and_async() {
// given
// when
long beforeTime = System.currentTimeMillis();
List<TicketInfoResponseDto> ticketInfoResponseDtos = ticketService.getAllTicketsInfoByMemberIdWithFetchJoinAndAsync(
member.getId());
long afterTime = System.currentTimeMillis();
long timeTaken = afterTime - beforeTime;
System.out.println(timeTaken + "ms");
// then
assertThat(ticketInfoResponseDtos).hasSize(ticketCnt);
}
👀 티켓 1000개에 대해, 각 티켓의 정보를 가져오는데 소요되는 시간 비교
소요 시간 | |
Fetch Join X, 비동기 X | 75349ms |
Fetch Join O, 비동기 X | 440ms |
Fetch Join O, 비동기 O | 266ms |
Fetch Join과 비동기 처리를 통해 75349ms ➡️ 266ms 약 283배 소요 시간을 절약할 수 있었습니다.
- Fetch Join으로 75349ms ➡️ 440ms 약 171배 절약
- 비동기 처리로 440ms ➡️ 266ms 약 1.65배 절약
성능 최적화의 중요성🌟
리팩토링을 통해 fetch join을 적용하여 하나의 쿼리문으로 데이터를 가져오고, 각 티켓에 대한 작업을 비동기적으로 처리하여 성능을 향상해보았습니다. 테스트 코드를 통해 소요 시간을 수치화하여 확인해보니, 약 283배의 성능 향상을 확인하며 생각보다 성능 향상이 많이 이루어져서 놀랬습니다😯
'백엔드 > SpringBoot' 카테고리의 다른 글
커서를 사용하여 무한 스크롤을 구현해보자. (0) | 2024.02.23 |
---|---|
Spring에서 AOP를 구현하는 방법(JDK/CGLib Proxy) (2) | 2024.01.21 |
LazyInitializationException 왜 발생하고, 어떻게 해결할까🧐 (0) | 2024.01.06 |
@Transactional 어노테이션을 뜯어보자. (+ 테스트 시 주의할 점) (0) | 2023.12.11 |
[SpringBoot] @Repository와 같은 빈이 생성되는 클래스에서 @Value로 주입받은 변수를 생성자에서 써도 될까? (1) | 2023.10.19 |