JPA 상속 관계 매핑에서 발생하는 N+1 해결하기

Glen·2024년 8월 24일
0

배운것

목록 보기
37/37
post-thumbnail

서론

객체지향 언어의 특징 중 하나는 상속을 통한 다형성의 지원이다.

다형성을 이용하면 객체가 가진 구체적인 정보를 알지 않고 추상적인 행동으로 의존성과 결합도를 낮추며 비즈니스 로직을 처리할 수 있다.

하지만 다형성을 모두 적용하기엔 이상에 가깝고, 실제 비즈니스 로직에서는 다형성을 완벽하게 적용하기는 불가능하다.

특히 ORM 프레임워크인 JPA를 사용하면 데이터베이스에 존재하지 않는 상속을 구현할 수 있게 해주는데, Command 같은 CUD 작업에서는 다형성의 이점을 누릴 수 있으나, Query 같은 Read 작업에서는 객체가 가진 구체적인 정보들이 필요하므로 다형성의 이점을 누리기 힘들다.

또한 ORM을 사용하면 발생할 수 있는 문제에는 N+1이 있는데, 이는 성능을 매우 떨어트리는 문제 중 하나이다.

JPA를 사용할 때 대부분의 N+1 문제는 FetchType과 Fetch Join을 사용하여 해결할 수 있으나, 특별한 비즈니스의 요구사항에 따른 엔티티의 설계에 따라 쉽게 해결할 수 없는 경우가 있다.

이 중 하나는 상속 관계 매핑을 했을 때의 경우인데, 상속 관계 매핑 시 N+1 문제가 어떻게 발생하고 어떻게 해결할 수 있는지 알아보자.

본론

기존에 진행했던 프로젝트인 "페스타고"에서 다양한 사용자들의 니즈를 충족시키기 위해 티켓팅 로직을 객체지향적으로 구성했다고 가정하자.

구현해야 할 티켓팅의 기능에는 축제에 여러 공연이 있을 때, 공연에 대해 입장 시간이 여러 개인 공연 티켓팅 기능과 축제에 대한 티켓팅이 아닌 입장 시간이 하나인 간단한 행사에 대한 행사 티켓팅에 대한 기능이 있다.

우선 공연 티켓팅에 대한 정보를 엔티티로 설계하면 다음과 같다.

@Entity
public class StageTicket {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int amount; // 재고

    @ManyToOne(fetch = FetchType.LAZY)
    private Stage stage;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "stageTicket")
    private List<StageTicketEntryTime> entrytimes = new ArrayList<>();

    ...

    public ReserveTicket reserve(Long memberId) {
        ...
    }

    public boolean canReserve(LocalDateTime currentTime) {
        ...
    }
}

@Entity
public class StageTicketEntryTime {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private StageTicket stageTicket;

    private LocalDateTime entryTime;

    private int amount;

    ...
}

그리고 공연 티켓팅에 대한 예매 로직은 간단하게 다음과 같이 작성할 수 있다.

@Service
@RequiredArgsConstructor
public class StageTicketingService {

    private final StageTicketRepository stageTicketRepository;
    private final ReserveTicketRepository reserveTicketRepository;

    @Transactional
    public Long ticketing(Long ticketId, Long memberId) {
        StageTicket ticket = stageTicketRepository.findById(ticketId)
            .orElseThrow(() -> new NotFoundException(...));
        if (!ticket.canReserve(LocalDateTime.now())) {
            throw new BadRequestException(...);
        }
        ReserveTicket reserveTicket = ticket.reserve(memberId);
        reserveTicketRepository.save(reserveTicket);
        return reserveTicket.getId();
    }
}

그리고 행사 티켓팅에 대한 정보를 엔티티로 설계하면 다음과 같다.

EventTicket과 EventTicketEntryTime은 OneToOne 양방향 관계를 가지는데, OneToOne 양방향 관계에서 주인이 아닌 엔티티를 조회할 때 FetchType을 Lazy로 설정하여도 Eager로 설정된다.
이러한 문제를 고려하면 EventTicket을 양방향 연관 관계의 주인으로 설정하거나 @Embedded를 사용하는 게 더 적절하지만, 예시를 위해 이렇게 설정했다.

@Entity
public class EventTicket {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int amount; // 재고

    @ManyToOne(fetch = FetchType.LAZY)
    private School school; // 행사는 반드시 학교에서만 생성된다고 가정한다.

    // OneToOne 양방향 관계에서 주인이 아닌 곳은 FetchType을 Lazy로 설정해도 Eager로 조회된다.
    @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "eventTicket", fetch = FetchType.LAZY)
    private EventTicketEntryTime entryTime;

    ...

    public ReserveTicket reserve(Long memberId) {
        ...
    }

    public boolean canReserve(LocalDateTime currentTime) {
        ...
    }
}

@Entity
public class EventTicketEntryTime {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    private EventTicket eventTicket;

    ...
}

그리고 행사 티켓팅에 대한 예매 로직은 다음과 같이 작성할 수 있다.

@Service
@RequiredArgsConstructor
public class EventTicketingService {

    private final EventTicketRepository eventTicketRepository;
    private final ReserveTicketRepository reserveTicketRepository;

    @Transactional
    public Long ticketing(Long ticketId, Long memberId) {
        EventTicket ticket = eventTicketRepository.findById(ticketId)
            .orElseThrow(() -> new NotFoundException(...));
        if (!ticket.canReserve(LocalDateTime.now())) {
            throw new BadRequestException(...);
        }
        ticket.validateSoldout();
        ReserveTicket reserveTicket = ticket.reserve(memberId);
        reserveTicketRepository.save(reserveTicket);
        return reserveTicket.getId();
    }
}

상속 관계 매핑

두 티켓팅 로직을 보면 알겠지만, 티켓의 예매는 구체적인 티켓의 종류만 다를 뿐, ReserveTicket 이라는 예매된 티켓의 정보를 반환하는 로직은 똑같다.

하지만 코드를 봤을 때 티켓팅 메서드가 어떤 티켓을 예매할지 결정하려면 구체적인 티켓의 종류를 알아야 한다. (티켓의 종류에 따라 중복된 식별자를 가질 수 있기에 클라이언트에서 해당 로직을 호출하기 전 분기 로직이 필요하다)

또한 다른 티켓팅 요구사항이 생길 경우 위와 같은 예매 로직이 추가로 생기게 될 것이다.

이 경우에는 다형성을 활용하여 비즈니스 로직을 풀어내면 위 문제를 모두 해결할 수 있다.

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "type")
public abstract class Ticket {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected Long id;

    protected int amount;

    @Enumerated(EnumType.STRING)  
    @Column(insertable = false, updatable = false, columnDefinition = "varchar")  
    protected TicketType type;

    public abstract ReserveTicket reserve(Long memberId);
    
    public abstract boolean canReserve(LocalDateTime currentTime);
}

@Entity
@DiscriminatorValue("STAGE")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StageTicket extends Ticket {

    @ManyToOne(fetch = FetchType.LAZY)
    private Stage stage;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "stageTicket")
    private List<StageTicketEntryTime> entrytimes = new ArrayList<>();

    @Override
    public ReserveTicket reserve(Long memberId) {
        ...
    }

    @Override
    public boolean canReserve(LocalDateTime currentTime) {
        ...
    }
}

@Entity
@DiscriminatorValue("EVENT")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class EventTicket extends Ticket {

    @ManyToOne(fetch = FetchType.LAZY)
    private School school;

    @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "eventTicket", fetch = FetchType.LAZY)
    private EventTicketEntryTime entryTime;

    @Override
    public ReserveTicket reserve(Long memberId) {
        ...
    }

    @Override
    public boolean canReserve(LocalDateTime currentTime) {
        ...
    }
}

JPA는 객체의 상속 관계를 관계형 데이터베이스로 구현할 수 있게 해주는 기능을 제공한다.

이를 통해 중복되고 필수적인 필드는 부모 클래스인 Ticket에 정의하고, 나머지 타입에 따라 필요한 부가적이고 구체적인 필드는 자식 클래스에 정의한다.

그리고 자식 클래스는 자기가 가지고 있는 정보로 검증 로직과 예매 로직을 재정의하면 클라이언트는 티켓에 대한 구체적인 정보를 알 필요 없이 비즈니스 로직을 처리할 수 있다.

@Service
@RequiredArgsConstructor
public class TicketingService {

    private final TicketRepository ticketRepository;
    private final ReserveTicketRepository reserveTicketRepository;

    @Transactional
    public Long ticketing(Long ticketId, Long memberId) {
        Ticket ticket = ticketRepository.findById(ticketId)
            .orElseThrow(() -> new NotFoundException(...));
        if (!ticket.canReserve(LocalDateTime.now())) {
            throw new BadRequestException(...);
        }
        ReserveTicket reserveTicket = ticket.reserve(memberId);
        reserveTicketRepository.save(reserveTicket);
        return reserveTicket.getId();
    }
}

비즈니스 로직을 작성했으니, 실제 동작을 보장하기 위해 해당 메서드를 실행했을 때 결과를 확인해보자

우선 StageTicket 테스트이다.

@Test  
void StageTicket_티켓팅() {  
    // given  
    Long memberId = 1L;  
    Stage stage = stageRepository.save(new Stage());
    
    StageTicket stageTicket = new StageTicket(stage);  
    stageTicket.addEntryTime(LocalDateTime.parse("2077-06-30T16:00:00"));  
    stageTicket.addEntryTime(LocalDateTime.parse("2077-06-30T17:00:00"));  
    stageTicket.addEntryTime(LocalDateTime.parse("2077-06-30T18:00:00"));  
    ticketRepository.save(stageTicket);  
  
    entityManager.clear();  
  
    // when  
    ticketingService.ticketing(stageTicket.getId(), memberId);  
  
    // then  
    ...
}

해당 테스트를 실행하면 다음과 같이 두 개의 SELECT 쿼리가 발생한다.

# 1
select
    t1_0.id,
    t1_0.type,
    t1_0.type,
    t1_1.id,
    t1_1.school_id,
    t1_2.stage_id 
from
    ticket t1_0 
left join
    event_ticket t1_1 
        on t1_0.id=t1_1.id 
left join
    stage_ticket t1_2 
        on t1_0.id=t1_2.id 
where
    t1_0.id=?

# 2
select
    et1_0.stage_ticket_id,
    et1_0.id,
    et1_0.entry_time 
from
    stage_ticket_entry_time et1_0 
where
    et1_0.stage_ticket_id=?

첫 번째 쿼리는 상속 관계 매핑이 JOIN 전략으로 되어 있으므로, StageTicket뿐 아닌 다른 상속 관계를 가진 엔티티까지 조인을 해서 조회하는 쿼리이다.

두 번째 쿼리는 Ticket의 canReserve() 메서드를 호출할 때 발생하는 N+1 쿼리이다.

다음은 EventTicket 테스트이다.

@Test  
void EventTicket_티켓팅() {  
    // given  
    Long memberId = 1L;  
    School school = schoolRepository.save(new School());  
    
    EventTicket eventTicket = new EventTicket(school, LocalDateTime.parse("2077-06-30T18:00:00"));  
    ticketRepository.save(eventTicket);  
    
    entityManager.clear();  
    
    // when  
    ticketingService.ticketing(eventTicket.getId(), memberId);  
    
    // then  
    ...
}
# 1
select
    t1_0.id,
    t1_0.type,
    t1_0.type,
    t1_1.id,
    t1_1.school_id,
    t1_2.stage_id 
from
    ticket t1_0 
left join
    event_ticket t1_1 
        on t1_0.id=t1_1.id 
left join
    stage_ticket t1_2 
        on t1_0.id=t1_2.id 
where
    t1_0.id=?

# 2 
select
    etet1_0.id,
    etet1_0.entry_time,
    etet1_0.event_ticket_id 
from
    event_ticket_entry_time etet1_0 
where
    etet1_0.event_ticket_id=?

StageTicket과 마찬가지로 첫 번째 쿼리는 상속 관계 매핑 때문에 해당 엔티티가 아닌 다른 엔티티를 포함해 조인해서 조회한다.

두 번째 쿼리 또한 Ticket의 canReserve() 메서드를 호출할 때 발생하는 N+1 쿼리이다.

여기서 서로 다른 Ticket에 대해 발생하는 쿼리의 공통적인 문제점은 한 번의 쿼리가 아닌 연관된 엔티티의 조회를 위해 두 번의 쿼리가 발생한다는 점이고, 상속 관계 전략에 따라 상속된 자식 클래스의 개수만큼 조인이 발생한다는 점이다.

하지만 이렇게 발생하는 N+1 문제는 사실 큰 문제라고 볼 수 없는 것이, 단건을 조회하기 때문에 1번의 쿼리만 추가로 발생할 뿐이기에 성능에 그다지 영향을 미치지는 않는다.

테이블에 ROW가 많다면 상속 관계 매핑의 JOIN 전략 때문에 더 큰 성능 문제가 발생할 것이다.
이 문제의 해결 방법도 밑에서 설명한다.

상속 관계 매핑에서 발생하는 N+1 문제는 이러한 Command 같은 CUD 작업에선 성능에 큰 영향을 미치지 않는다.

하지만 Query 같은 R 작업에서는 N+1 문제가 심각한 성능 저하를 유발할 수 있다.

Query에서 N+1 문제가 어떻게 발생하는지 예시로 바로 확인하자.

Query에서 발생하는 N+1 문제

티켓을 예매하기 전에 사용자는 어떠한 티켓이 있는지 알아야 하므로 티켓 목록을 조회해야 한다.

또한 티켓 목록에 있는 티켓은 티켓이 가지고 있는 구체적인 정보들이 모두 필요하다.

따라서 응답으로 반환하는 티켓 DTO 또한 인터페이스 또는 추상 클래스로 정의하여야 한다.

public interface TicketResponse {  
  
    record StageTicketResponse(  
        Long ticketId,  
        TicketType ticketType,  
        Long stageId,  
        List<TicketEntryTimeResponse> entryTimes  
    ) implements TicketResponse {  
  
    }  
    
    record EventTicketResponse(  
        Long ticketId,  
        TicketType ticketType,  
        Long schoolId,  
        TicketEntryTimeResponse entryTime  
    ) implements TicketResponse {  
  
    }  
    
    record TicketEntryTimeResponse(  
        LocalDateTime entryTime  
    ) {  
  
    }
}

그리고 티켓 목록을 조회하는 로직을 작성하면 다음과 같다.

예시이기 때문에 페이징 적용은 하지 않았다.

@Service  
@RequiredArgsConstructor  
public class TicketingService {
    ...

    @Transactional(readOnly = true)  
    public List<TicketResponse> findAll() {  
        return ticketRepository.findAll()  
            .stream()  
            .map(this::mapToResponse)  
            .toList();  
    }  
      
    private TicketResponse mapToResponse(Ticket ticket) {  
        TicketType ticketType = ticket.getType();  
        return switch (ticketType) {  
            case STAGE -> {  
                StageTicket stageTicket = (StageTicket) ticket;  
                yield new StageTicketResponse(  
                    ticket.getId(),  
                    ticketType,  
                    stageTicket.getStage().getId(),  
                    stageTicket.getEntryTimes().stream()  
                        .map(it -> new TicketEntryTimeResponse(it.getEntryTime()))  
                        .toList()  
                );  
            }  
            case EVENT -> {  
                EventTicket eventTicket = (EventTicket) ticket;  
                yield new EventTicketResponse(  
                    ticket.getId(),  
                    ticketType,  
                    eventTicket.getSchool().getId(),  
                    new TicketEntryTimeResponse(eventTicket.getEventTicketEntryTime().getEntryTime())  
                );  
            }  
        };  
    }
}

그리고 작성한 코드를 테스트 했을 때 결과로 나오는 쿼리는 다음과 같다.

5개의 StageTicket, 5개의 EventTicket이 있다고 가정한다.

@Test  
void 티켓_조회() throws Exception {  
    List<TicketResponse> response = ticketingService.findAll();  
  
    for (TicketResponse ticketResponse : response) {  
        System.out.println(objectMapper.writeValueAsString(ticketResponse));  
    }  
}
# 1
select
    t1_0.id,
    t1_0.type,
    t1_0.type,
    t1_1.id,
    t1_1.school_id,
    t1_2.stage_id 
from
    ticket t1_0 
left join
    event_ticket t1_1 
        on t1_0.id=t1_1.id 
left join
    stage_ticket t1_2 
        on t1_0.id=t1_2.id

# 2
select
    etet1_0.id,
    etet1_0.entry_time,
    etet1_0.event_ticket_id 
from
    event_ticket_entry_time etet1_0 
where
    etet1_0.event_ticket_id=?

...
# 위와 동일 쿼리 4번, 총 5번 발생

# 3
select
    et1_0.stage_ticket_id,
    et1_0.id,
    et1_0.entry_time 
from
    stage_ticket_entry_time et1_0 
where
    et1_0.stage_ticket_id=?

...
# 위와 동일 쿼리 4번, 총 5번 발생

총 11번의 쿼리가 발생했다. (첫 번째 쿼리 1번, 두 번째 쿼리 5번, 세 번째 쿼리 5번)

즉 N+1 문제가 발생한 것이다.

페이징을 적용했을 때, 페이지 수만큼 쿼리가 추가로 날아가게 되므로 사실상 페이징을 하는 의미가 사라진다.

N+1 문제를 해결하려면 Fetch Join을 사용해야 하지만 조회하는 대상이 정확히 어떤 구현체인지 쿼리를 날려봐야 알 수 있으므로 사용할 수 없다.

다만 한 가지 방법이 있는 것이 @OneToMany 연관 관계에는 @BatchSize를 사용할 수 있으므로 N+1 문제로 발생하는 쿼리를 최소한으로 줄일 수 있다.

페이징을 사용할 때 @OneToMany 연관 관계를 Fetch Join하면 페이징을 적용할 수 없기에, @BatchSize를 사용할 수밖에 없다.

@Entity
public class StageTicket {
    ...
    @BatchSize(size = 10)
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "stageTicket")  
    private List<StageTicketEntryTime> entryTimes = new ArrayList<>();
    ...
}
# 1
select
    t1_0.id,
    t1_0.type,
    t1_0.type,
    t1_1.id,
    t1_1.school_id,
    t1_2.stage_id 
from
    ticket t1_0 
left join
    event_ticket t1_1 
        on t1_0.id=t1_1.id 
left join
    stage_ticket t1_2 
        on t1_0.id=t1_2.id

# 2
select
    etet1_0.id,
    etet1_0.entry_time,
    etet1_0.event_ticket_id 
from
    event_ticket_entry_time etet1_0 
where
    etet1_0.event_ticket_id=?

...
# 위와 동일 쿼리 4번, 총 5번 발생

# 3
select
    et1_0.stage_ticket_id,
    et1_0.id,
    et1_0.entry_time 
from
    stage_ticket_entry_time et1_0 
where
    et1_0.stage_ticket_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

StageTicket의 경우 @BatchSize를 적용하여 N+1 문제를 한 번의 추가 쿼리가 발생하는 것으로 막았지만, @OneToOne 연관 관계에는 @BatchSize를 적용할 수 없기 때문에 N+1 문제를 해결했다고 말할 수 없다.

따라서 @OneToOne 매핑에서 N+1 문제를 막으려면 Fetch Join을 사용해야 한다.

하지만 구체적인 타입을 알아내려면 쿼리를 실행해야 하고, 실행된 쿼리로 구체적인 타입을 알아냈지만 이미 엔티티를 조회했기에 Fetch Join을 할 수 없다.

게다가 조회한 엔티티는 양방향 OneToOne 관계의 주인이 아니기에 즉시 로딩이 되므로, 쿼리가 실행된 순간 N+1 문제가 발생한다.

해결 방법

그렇다면 어떻게 상속 관계 매핑에서 Fetch Join을 적용할 수 있을까?

사실 단순히 생각하면 된다.

엔티티를 바로 조회하지 말고 식별자와 타입 컬럼을 조회한 뒤, 각 타입별로 별도로 조회한 뒤 합치는 것이다.

@Repository  
@RequiredArgsConstructor  
public class TicketRepository {  
  
    private final JPAQueryFactory jpaQueryFactory;  
  
    @Transactional(readOnly = true)  
    public Optional<Ticket> findById(Long ticketId) {  
        TicketType ticketType = jpaQueryFactory.select(ticket.type)  
            .from(ticket)  
            .where(ticket.id.eq(ticketId))  
            .fetchOne();  
        if (ticketType == null) {  
            return Optional.empty();  
        }  
        Ticket ticket = switch (ticketType) {  
            case STAGE -> jpaQueryFactory.selectFrom(stageTicket)  
                .leftJoin(stageTicket.entryTimes, stageTicketEntryTime).fetchJoin()  
                .where(stageTicket.id.eq(ticketId))  
                .fetchOne();  
            case EVENT -> jpaQueryFactory.selectFrom(eventTicket)  
                .innerJoin(eventTicket.entryTime, eventTicketEntryTime).fetchJoin()  
                .where(eventTicket.id.eq(ticketId))  
                .fetchOne();  
        };  
        return Optional.ofNullable(ticket);  
    }  

    @Transactional(readOnly = true)  
    public List<Ticket> findAll() {  
        List<Pair<Long, TicketType>> ticketIdAndTypes = jpaQueryFactory.select(QPair.create(ticket.id, ticket.type)) // QPair 클래스는 QueryDSL에서 제공한다.
            .from(ticket)  
            .fetch();  
  
        Map<TicketType, List<Long>> TicketTypeToTicketIds = ticketIdAndTypes.stream()  
            .collect(groupingBy(Pair::getSecond, mapping(Pair::getFirst, toList())));  
            
        Map<Long, Ticket> ticketIdToTicket = TicketTypeToTicketIds.entrySet().stream()  
            .map(entry -> fetchTickets(entry.getKey(), entry.getValue()))  
            .flatMap(Collection::stream)  
            .collect(toMap(Ticket::getId, Function.identity()));  
            
        return ticketIdAndTypes.stream()  
            .map(Pair::getFirst)  
            .map(ticketIdToTicket::get)  
            .toList();  
    }  
  
    private List<? extends Ticket> fetchTickets(TicketType ticketType, List<Long> ticketIds) {  
        return switch (ticketType) {  
            case STAGE -> jpaQueryFactory.selectFrom(stageTicket)  
                .leftJoin(stageTicket.entryTimes, stageTicketEntryTime).fetchJoin()  
                .where(stageTicket.id.in(ticketIds))  
                .fetch();  
            case EVENT -> jpaQueryFactory.selectFrom(eventTicket)  
                .innerJoin(eventTicket.entryTime, eventTicketEntryTime).fetchJoin()  
                .where(eventTicket.id.in(ticketIds))  
                .fetch();  
        };  
    }  
}

그리고 조회 테스트를 다시 실행해보면 발생하는 쿼리는 다음과 같다.

# 1
select
    t1_0.id,
    t1_0.type 
from
    ticket t1_0

# 2
select
    et1_0.id,
    et1_1.type,
    et2_0.id,
    et2_0.entry_time,
    et1_0.school_id 
from
    event_ticket et1_0 
join
    ticket et1_1 
        on et1_0.id=et1_1.id 
join
    event_ticket_entry_time et2_0 
        on et1_0.id=et2_0.event_ticket_id 
where
    et1_0.id in (?, ?, ?, ?, ?)

# 3
select
    st1_0.id,
    st1_1.type,
    et1_0.stage_ticket_id,
    et1_0.id,
    et1_0.entry_time,
    st1_0.stage_id 
from
    stage_ticket st1_0 
join
    ticket st1_1 
        on st1_0.id=st1_1.id 
left join
    stage_ticket_entry_time et1_0 
        on st1_0.id=et1_0.stage_ticket_id 
where
    st1_0.id in (?, ?, ?, ?, ?)

이제 조회 시 중복이 제거된 TicketType만큼의 쿼리는 발생하긴 하지만 N+1 문제는 더 이상 발생하지 않는다.

게다가 상속 관계 매핑의 JOIN 전략으로 인해 발생하는 JOIN 문제 또한 해결한 것을 볼 수 있다.

단건 조회의 경우 한 번의 쿼리가 추가로 발생하므로 자식 엔티티의 개수가 적거나 row 수가 적을 경우에는 그냥 JOIN을 해서 가져오는 것이 성능상 이점이 있을 수 있다.

추가 팁

Query 로직이 아닌 Command에서도 티켓의 목록이 필요할 수 있다.

또한 다른 테이블과 JOIN을 해서 WHERE 절을 사용해야 하는 경우도 있기에 복잡한 매핑 코드를 중복으로 정의해야 하는 문제가 발생한다.

이는 템플릿 콜백 패턴을 사용하면 해결할 수 있다.

@Component  
@RequiredArgsConstructor  
public class TicketFetchTemplate {  
  
    private final JPAQueryFactory jpaQueryFactory;  
  
    public List<Ticket> fetch(Function<JPAQuery<Pair<Long, TicketType>>, List<Pair<Long, TicketType>>> callback) {  
        var query = jpaQueryFactory.select(QPair.create(ticket.id, ticket.type));  
        List<Pair<Long, TicketType>> ticketIdAndTicketTypes = callback.apply(query);  
  
        Map<TicketType, List<Long>> TicketTypeToTicketIds = ticketIdAndTicketTypes.stream()  
            .collect(groupingBy(Pair::getSecond, mapping(Pair::getFirst, toList())));  
  
        Map<Long, Ticket> ticketIdToTicket = TicketTypeToTicketIds.entrySet().stream()  
            .map(entry -> fetchTickets(entry.getKey(), entry.getValue()))  
            .flatMap(Collection::stream)  
            .collect(toMap(Ticket::getId, Function.identity()));  
  
        return ticketIdAndTicketTypes.stream()  
            .map(Pair::getFirst)  
            .map(ticketIdToTicket::get)  
            .toList();  
    }  
  
    private List<? extends Ticket> fetchTickets(TicketType ticketType, List<Long> ticketIds) {  
        return switch (ticketType) {  
            case STAGE -> jpaQueryFactory.selectFrom(stageTicket)  
                .leftJoin(stageTicket.entryTimes, stageTicketEntryTime).fetchJoin()  
                .where(stageTicket.id.in(ticketIds))  
                .fetch();  
            case EVENT -> jpaQueryFactory.selectFrom(eventTicket)  
                .innerJoin(eventTicket.entryTime, eventTicketEntryTime).fetchJoin()  
                .where(eventTicket.id.in(ticketIds))  
                .fetch();  
        };  
    }  
}
@Repository  
@RequiredArgsConstructor  
public class TicketDao {  
  
    private final JPAQueryFactory jpaQueryFactory;  
    private final TicketFetchTemplate ticketFetchTemplate;  
  
    ...
  
    @Transactional(readOnly = true)  
    public List<Ticket> findAll() {  
        return ticketFetchTemplate.fetch(query -> query  
            .from(ticket)  
            .fetch()  
        );  
    }  
}

결론

객체지향 언어를 사용하면 다형성을 통해 추상적인 행동으로 의존성과 결합도를 낮추며 다양한 요구사항의 비즈니스 로직을 처리할 수 있다.

하지만 다형성을 모두 적용할 수 있다는 것은 이상에 가깝고, 거의 불가능하다.

ORM 프레임워크인 JPA에서는 상속 관계 매핑을 통해 객체의 다형성을 활용할 수 있지만, 단순히 사용한다면 N+1 문제로 인해 처참한 성능 문제를 맛보게 된다.

따라서 번거롭지만, 사용자가 직접 상속 관계를 가진 엔티티에 대해 약간의 기교를 사용하여 N+1 문제를 해결하는 과정이 필요하다.

이렇게 하더라도 구체 타입의 개수만큼 쿼리가 발생하긴 하지만 가져온 엔티티만큼의 쿼리는 발생하지 않기에 부족함은 없다.

만약 이 방법도 만족하지 못한다면 ORM이 아닌, NoSQL을 사용하여 해결해야 할 것 같다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글