본문 바로가기
프로젝트/KokoaHairshop

N + 1 문제 해결 (Fetch Join, DISTINCT)

by 걸어가는 신사 2022. 7. 17.

1. 문제 발생

  • designer 조회 시에 designer select 문 이외에 reservationTime 조회 쿼리가 추가적으로 발생하였습니다.

Entity

[DESIGNER]

@Entity
@Getter
@Table(name = "designer")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Designer {

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

    @Column(name = "name", nullable = false, columnDefinition = "varchar(20)")
    private String name;

    @Column(name = "profile_img", nullable = false, columnDefinition = "varchar(200)")
    private String image;

    @Column(name = "introduction", nullable = false, columnDefinition = "varchar(300)")
    private String introduction;

    @Enumerated(EnumType.STRING)
    @Column(name = "position", nullable = false, columnDefinition = "varchar(20)")
    private Position position;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "hairshop_id", referencedColumnName = "id")
    private Hairshop hairshop;

    @OneToMany(mappedBy = "designer")
    private List<Reservation> reservations = new ArrayList<>();

    @OneToMany(mappedBy = "designer")
    private List<ReservationTime> reservationTimes = new ArrayList<>();

    @Builder
    public Designer(Long id, String name, String image, String introduction,
        Position position, Hairshop hairshop) {
        this.id = id;
        this.name = name;
        this.image = image;
        this.introduction = introduction;
        this.position = position;
        this.hairshop = hairshop;
    }

    public void addReservation(Reservation reservation) {
        this.reservations.add(reservation);
    }

    public void addReservationTimes(ReservationTime reservationTime) {
        this.reservationTimes.add(reservationTime);
    }

}

[RESERVATION_TIME]

@Entity
@Table(name = "reservation_time")
@Getter
@NoArgsConstructor
public class ReservationTime {

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

    @Column(name = "date", nullable = false)
    private LocalDate date;

    @Column(name = "time", nullable = false, columnDefinition = "char(5)")
    private String time;

    @Column(name = "reserved", nullable = false)
    private Boolean reserved;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "designer_id", referencedColumnName = "id")
    Designer designer;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "hairshop_id", referencedColumnName = "id")
    Hairshop hairshop;

    @Builder
    public ReservationTime(Long id, LocalDate date, String time, boolean reserved,
        Designer designer, Hairshop hairshop) {
        this.id = id;
        this.date = date;
        this.time = time;
        this.reserved = reserved;
        this.designer = designer;
        this.hairshop = hairshop;
    }

    public void setDesigner(Designer designer) {
        if (this.designer != null) {
            this.designer.getReservationTimes().remove(this);
        }
        this.designer = designer;
        designer.getReservationTimes().add(this);
    }

    public void changeReserved() {
        this.reserved = true;
    }
}
  • ReservationTime과 Designer의 연관관계에서 @ManyToOne 어노테이션에 fetch = FetchType.LAZY 선언을 해줌으로서 지연 로딩을 사용하였습니다.

Repository

public interface DesignerRepository extends JpaRepository<Designer, Long> {
    @Query("select d from Designer d where d.hairshop.id = :hairshopId")
    List<Designer> findDesignerByHairshopId(@Param("hairshopId") Long hairshopId);
}

Service

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ReservationService {
	public List<ReservationTimeResponseDto> findReservationTimes(Long hairshopId,
	    ReservationTimeRequestDtoStatic requestDto) {
	    return designerRepository.findDesignerByHairshopId(hairshopId)
	        .stream().map(designer -> toReservationTimeResponseDtoStatic(designer))
	        .collect(Collectors.toList());
	}

	public static ReservationTimeResponseDto toReservationTimeResponseDtoStatic(Designer designer) {
        return ReservationTimeResponseDto.builder()
            .designerId(designer.getId())
            .designerPosition(designer.getPosition())
            .designerName(designer.getName())
            .designerImage(designer.getImage())
            .designerInstruction(designer.getIntroduction())
            .reservationTimes(designer.getReservationTimes()
                .stream().map(reservationTime -> reservationTime.getTime())
                .collect(Collectors.toList()))
            .build();
    }
}
  • Service findReservationTimes() 메서드 통해 해당 헤어샵의 모든 designer를 가져옵니다.
  • 가져온 designer들의 reservationTime List를 Dto로 변환시켜서 반환해 줍니다.

Test Code

@Test
@DisplayName("designer에서 ReservationTime 조회시 N+1 문제 발생")
void findDesignerByHairshopIdTest() {
    // Given
    for (int i = 0; i < 10; i++) {
        designer = Designer.builder()
            .name("디자이너1")
            .position(Position.DESIGNER)
            .introduction("안녕하세요")
            .image("이미지 URL")
            .hairshop(hairshop)
            .build();
        em.persist(designer);

        ReservationTime reservationTime1 = ReservationTime.builder()
            .date(LocalDate.now())
            .time("11:00")
            .reserved(false)
            .build();

        ReservationTime reservationTime2 = ReservationTime.builder()
            .date(LocalDate.now())
            .time("11:00")
            .reserved(false)
            .build();

        reservationTime1.setDesigner(designer);
        em.persist(reservationTime1);
        reservationTime2.setDesigner(designer);
        em.persist(reservationTime2);
    }

    em.clear();

    // When
    List<ReservationTimeResponseDto> designers = reservationService.findReservationTimes(
        hairshop.getId());

    // Then
		assertThat(designers.size()).isEqualTo(10);
		assertThat(designers.get(0).getReservationTimes().size()).isEqualTo(2);
}
  • 위 테스트 코드를 실행해보면 아래와 같이 헤어샵에 속한 designer를 조회하는 쿼리와 각각의 ReservationTime을 조회하는 쿼리가 10개가 발생한 것을 확인할 수 있습니다.

  • N+1 쿼리 발생
    • 이렇게 하위 엔티티들을 첫 쿼리 실행시 한 번에 가져오지 않고, Lazy Loading으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제가 N+1 쿼리 문제입니다.

 

2. 문제 해결

Join Fetch 사용

@Query("select d from Designer d join fetch d.reservationTimes where d.hairshop.id = :hairshopId")
List<Designer> findDesignerByHairshopId(@Param("hairshopId") Long hairshopId);
  • desginer 조회 시 reservationTimes 필드를 함께 가져오기 위해 Fetch Join을 사용하였습니다.

Test Code 확인

  • designer 조회 쿼리 한 개만 날라 가는 것을 확인할 수 있습니다.
@Test
@DisplayName("designer에서 ReservationTime 조회시 N+1 문제 발생")
void findDesignerByHairshopIdTest() {
    // Given
    for (int i = 0; i < 10; i++) {
        designer = Designer.builder()
            .name("디자이너1")
            .position(Position.DESIGNER)
            .introduction("안녕하세요")
            .image("이미지 URL")
            .hairshop(hairshop)
            .build();
        em.persist(designer);

        ReservationTime reservationTime1 = ReservationTime.builder()
            .date(LocalDate.now())
            .time("11:00")
            .reserved(false)
            .build();

        ReservationTime reservationTime2 = ReservationTime.builder()
            .date(LocalDate.now())
            .time("11:00")
            .reserved(false)
            .build();

        reservationTime1.setDesigner(designer);
        em.persist(reservationTime1);
        reservationTime2.setDesigner(designer);
        em.persist(reservationTime2);
    }

    em.clear();

    // When
    List<ReservationTimeResponseDto> designers = reservationService.findReservationTimes(
        hairshop.getId());

    // Then
assertThat(designers.size()).isEqualTo(10);
assertThat(designers.get(0).getReservationTimes().size()).isEqualTo(2);
}
  • 하지만 Test에는 실패합니다.

Collection Fetch Join의 이해

  • Test에서 10개의 designer를 넣어주었는데 20개의 designer가 생성된 것을 확인 할 수 있습니다.

  • 하나의 designer 당 reservationTime 2건을 조인하는 과정에서 결과가 증가해서 designer의 size 또한 증가한 것을 알 수 있습니다.
  • 일대다 조인은 결과가 증가하는 것을 알 수 있습니다.

Fetch Join과 DISTINCT

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령입니다.
  • JPQL의 DISTINCT 2가지 기능을 제공합니다.
    • SQL에 DISTINCT를 추가합니다.
    • 애플리케이션에서 엔티티 중복을 제거합니다.

동작 과정

  1. SQL에 DISTINCT를 추가하지만 데이터가 다르므로 SQL 결과에서 중복제거를 실패합니다.
  2. 애플리케이션에서 DISTINCT 명령어를 보고 중복된 데이터를 걸러냅니다.
    • 같은 식별자를 가진 designer Entity를 제거합니다.

쿼리 수정

@Query("select distinct d from Designer d join fetch d.reservationTimes where d.hairshop.id = :hairshopId")
List<Designer> findDesignerByHairshopId(@Param("hairshopId") Long hairshopId);
  • distinct를 사용하여 중복을 제거할 수 있습니다.

 

Reference

https://jojoldu.tistory.com/165

 

반응형

댓글