프로젝트/KokoaHairshop
N + 1 문제 해결 (Fetch Join, DISTINCT)
걸어가는 신사
2022. 7. 17. 18:49
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를 추가합니다.
- 애플리케이션에서 엔티티 중복을 제거합니다.
동작 과정
- SQL에 DISTINCT를 추가하지만 데이터가 다르므로 SQL 결과에서 중복제거를 실패합니다.
- 애플리케이션에서 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
반응형