티스토리 뷰

롯데시네마 클론 코딩 프로젝트에서 사용자의 티켓 정보를 가져오는 기능 개발 중

테이블 간 연관된 정보들이 많아 총 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);
    }
}

Fetch Join X, 비동기 X - 75349ms(기존 구현)

 


🧙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);
}

 

Fetch Join O, 비동기 X - 440ms

 


🧙Refactor2. 각 티켓에 대한 티켓 정보는 비동기적으로 가져오기

사용자가 가진 티켓들은 서로 독립적인 정보이기에, 각 티켓에 대한 정보를 불러오는 작업을 비동기로 처리했습니다.

 

Fetch Join O, 비동기 O - 266ms(현재)

✴️비동기 처리 포인트

  1. ConcurrentHashMap 사용
    • 티켓 정보 저장 시, 여러 스레드에서 안전하게 동시에 접근할 수 있도록 함.
  2. CompletableFuture.runAsync()를 사용하여 각 티켓에 대한 작업을 비동기로 실행
    • 각 티켓마다 실행되는 작업은
      • 티켓 상태 체크 -> 티켓 관련 정보 가져오기
    • 반환값이 필요없는 경우 비동기 작업의 경우 runAsync()를 사용하고, 반환값이 필요한 경우 supplyAsync()를 사용하면 됩니다. 각각 티켓을 가져와서 ConcurrentHashMap에 넣어주었기에 반환값이 필요없는 runAsync()를 사용해주었습니다.
  3. 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);
}

Fetch Join O, 비동기 O - 266ms(현재)

 

 


테스트 코드로 소요 시간 비교하기

테스트 코드를 통해 티켓 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배의 성능 향상을 확인하며 생각보다 성능 향상이 많이 이루어져서 놀랬습니다😯