팀 프로젝트 진행중 N+1 문제에 대해서 물어보는 팀원이 있어서, 단순히 N+1문제는 성능저하를 일으키고 그 대안으로 Fetch join, batch size 등을 알려줬지만 하나하나 상세하게 설명하지는 못하였다. 팀원들과 같이 문제를 해결하기 위해서 N+1 문제에 대해 알아보려고 한다.
N+1 문제란?
연관관계가 설정된 엔티티를 조회할 때, 쿼리문이 1회 발생되어야 하지만 연관관계에 따라서 N개 의 쿼리를 추가로 조회해서 총 N+1만큼 쿼리가 발생되는 현상. 객체는 연관관계를 통해 레퍼런스를 가지고 있으면 언제든지 메모리 내에서 연관 객체에 접근할 수 있지만, RDB의 경우 Select 쿼리를 통해서만 조회할 수 있기 때문이다.
House : Room = 1 : N
@Getter
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class House {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "house_id")
private Long id;
private String name;
@OneToMany(mappedBy = "house", cascade = ALL, fetch = FetchType.EAGER)
private List<Room> rooms = new ArrayList<>();
}
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Entity
public class Room {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "room_id")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "house_id")
private House house;
@Builder
public Room(String name, House house) {
this.name = name;
setHouse(house);
}
private void setHouse(House house) {
this.house = house;
house.getRooms().add(this);
}
}
@SpringBootTest
public class HouseRoomTest {
@Autowired
HouseRepository houseRepository;
@Autowired
RoomRepository roomRepository;
@Test
public void test() {
for (int i = 0; i < 5; i++) {
House house = House.builder().name("house" + i).build();
houseRepository.save(house);
roomRepository.save(Room.builder().name("room" + i).house(house).build());
}
System.out.println("-----------------------------------------------");
houseRepository.findAll();
}
}
테스트 코드를 실행하면 바로 위 사진과 같이 N+1 문제가 발생되는 것을 알 수 있다. N+1 문제가 발생하면 쿼리가 N번 만큼 증가하기 때문에 서버에 부담을 주고 사용자의 요청은 적지 않은 시간동안 지연된다.
해결 방법에 들어가기 전 fetchType Lazy설정은 해결 방법이 아니라는 것을 알고 가야한다. 지연로딩은 연관관계 데이터를 프록시 객체로 바인딩했을 뿐이며, 지연 로딩 역시 하위 엔티티로 작업을 하게 되면 그 순간 추가로 N개의 쿼리가 발생된다.
첫 번째는 Fetch Join이다. FetchJoin은 JPQL에서 제공 하는 기능으로 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법이다.
public interface HouseRepository extends JpaRepository<House, Long> {
@Query("select h from House h join fetch h.rooms")
List<House> findAllWithRoom();
}
@SpringBootTest
public class HouseRoomTest {
...
@Test
public void test() {
...
System.out.println("-----------------------------------------------");
houseRepository.findAllWithRoom();
}
}
쿼리문이 1개로 줄은 것을 확인할 수 있다. Fetch Join의 단점으로는 하나의 쿼리문에 2번 이상 적용 불가능이다. 또한 페이징이 포함된 검색 쿼리에서 사용하면 큰 문제점이 하나있는데 아래와 같은 경고 문구를 만나게 된다.
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Join하는 엔티티를 limit로 갖고 오는 것이 아닌, 전부를 가지고와 서버에서 개수를 조절하는 작업을 하게 된다. 이는 out of memory 현상을 발생시킬수 있어서 매우 조심해야한다.
@EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다.
public interface HouseRepository extends JpaRepository<House, Long> {
@EntityGraph(attributePaths = {"rooms"})
@Query("select h from House h")
List<House> findAllWithRoomGraph();
}
@SpringBootTest
public class HouseRoomTest {
...
@Test
public void test() {
...
System.out.println("-----------------------------------------------");
houseRepository.findAllWithRoomGraph();
}
}
Fetch조인은 inner join이며 @Entity Graph는 outer join이다. 그리고 공통적으로 카티션 프로덕트(Cartesian Product)가 발생하여 데이터가 중복 발생할 수 있다. 이에 대응하여 쿼리문에 Distinct 옵션을 주거나, 일대다 필드 타입을 Set으로 하는 것이다.
hibernate가 제공하는 옵션중 default_batch_fetch_size이 있는데 이 옵션은 N+1의 완벽한 해결 방안보다는 차선책의 느낌이다. 이 방법을 사용하면 기존 쿼리문에서 where 조건에 in으로 batch size만큼의 id값을 넣는다. 즉 연관된 엔티티에 1만개의 데이터가 있을 때 batch size가 1000이라면 기존 10000/1000, 10번의 쿼리가 나간다. 최소한의 쿼리를 보내 효율적으로 지연로딩을 하는 것이다.
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
1:1 연관관계는 최대한 fetch join을 활용하고 컬렉션 연관관계는 Batch size를 활용하는 것이 좋고, 많은 컬럼 중 특정 컬럼만 조회해야 할 경우 처음부터 DTO로 조회를 하는 것이 좋다. 마지막으로 JPA를 사용할 때는 항상 N+1의 문제를 염두에 두어야 하며, 항상 로그를 통해서 쿼리가 내가 원하는 대로 나가는지 확인해야 한다.