[Spring] N+1 Problem 해결

eugene·2024년 9월 8일
0

Trouble Shooting

목록 보기
2/4

N+1 문제는 스프링의 JPA를 사용하여 엔티티에 연결된 도메인에 대한 조회가 자동으로 이루어질 때 발생합니다. 한 가지 엔티티 조회에 N개의 쿼리가 추가로 발생하면서 데이터베이스에서 비효율적인 쿼리가 실행되게 됩니다.

N+1 문제를 방지하기 위해 양방향 매핑을 지양하고, @OneToMany 어노테이션을 사용하지 않는 방식으로 코드를 작성해왔습니다. 하지만 내가 맡은 도메인은 아니었지만, 두 명뿐이었던 백엔드 팀원의 도메인에서 쿼리가 추가로 발생하는 부분을 보고, 함께 해결하면서 개념 정리를 위해 이 포스팅을 작성하게 되었습니다.

결론이자 가장 큰 문제는, @ManyToOne 어노테이션만 사용해 지연 로딩 방식을 택해도 N+1 문제가 발생한다는 것입니다!


지연 로딩 시 N+1 문제

제가 맡았던 User 도메인을 예시로 설명하겠습니다.

@Getter
@Builder
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

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

    @Column(name = "nickname")
    private String nickname;

    @Column(name = "birth", columnDefinition = "date")
    private LocalDate birth;

    @Enumerated(EnumType.STRING)
    @Column(name = "gender")
    private Gender gender;

    @Column(name = "temperature", nullable = false)
    private double temperature;

    @Column(name = "email", nullable = false)
    private String email;

    @Column(name = "provider", nullable = false)
    private String provider;

    @OneToOne(fetch = FetchType.LAZY)
    private Image profileImage;

    @OneToMany(mappedBy = "user")
    @Builder.Default
    private List<ChatParticipant> chatRooms = new ArrayList<>();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "job_id")
    private Tag jobTag;

위의 User 엔티티는 사용자의 직업 태그 값을 외래키로 가집니다. Tag 엔티티가 지연 로딩으로 설정되어 있어, 단순히 User에 대해 조회가 이루어질 때는 Tag 조회에 대한 쿼리가 발생하지 않습니다. 흔히 지연 로딩은 연관된 객체를 프록시 객체로 가지고 있는다고 합니다.

프록시 객체의 속성에 접근하거나, 메서드를 호출하거나, 프록시 객체가 직렬화되는 순간 쿼리가 발생하게 됩니다.

public static UserInfoDto of(User user) {
	Long jobTagId = user.getJobTag() == null ? null : user.getJobTag().getId();
        return new UserInfoDto(
                user.getId(),
                user.getNickname(),
                user.getBirth(),
                user.getGender(),
                user.getTemperature(),
                jobTagId,
                user.getEmail(),
                ImageDto.of(user.getProfileImage()),
                user.getProvider()
       );
}

위의 UserDto로 변환하는 과정에서 user.getJobTag()와 같이 get 메서드로 접근하는 순간, 해당 엔티티에 대한 쿼리가 발생하면서 N+1 문제가 발생합니다. 즉, DTO로 변환하면서 사용했던 get 메서드에서 모두 N+1 문제가 발생했던 것입니다.

같은 트랜잭션 내에서 한번이라도 로드되었던 객체는 영속성 컨텍스트에 저장되어 있어 추가 쿼리가 발생하지 않습니다.


Fetch Join으로 N+1 문제 해결하기

@Query("SELECT u FROM User u LEFT JOIN FETCH u.jobTag LEFT JOIN FETCH u.profileImage WHERE u.id = :id AND u.deletedAt IS NULL")
Optional<User> findByIdWithDetails(@Param("id") Long id);

@Query 어노테이션을 활용해 Fetch join을 적용한 쿼리를 작성할 수 있습니다. 직업 태그와 프로필 이미지는 존재하지 않을 수 있기 때문에 LEFT JOIN FETCH를 활용하면 User는 정상적으로 조회되면서 없는 엔티티들은 null로 설정됩니다.

하지만 Fetch join을 사용할 때, ~ToMany 연관 관계가 있는 경우 Page 객체를 활용한 페이지네이션 조회에서 다시 N+1 문제가 발생할 수 있습니다. User가 여러 개의 ProfileImage를 가지는 경우, 결과 집합이 폭발적으로 증가할 수 있으며, 이는 페이징 처리에 혼란을 줄 수 있습니다.

이를 해결하기 위해 JPA는 내부적으로 Fetch join을 사용하지 않고 두 번의 쿼리를 수행합니다:

첫 번째 쿼리: 페이징 처리된 메인 엔티티의 기본 정보만 조회 (User 엔티티만 조회).
두 번째 쿼리: 연관된 엔티티를 IN 절을 사용하여 별도로 조회 (ProfileImage를 조회).

이 과정에서 다시 연관된 엔티티를 별도로 조회하기 때문에 N+1 문제가 발생할 수 있습니다. 즉, Page<User> 객체를 조회하면서 User 엔티티들을 페이징 처리하고, 그 User 엔티티들에 대해 각각 ProfileImage를 다시 조회하는 쿼리가 실행될 수 있습니다.


또 다른 방식

어차피 Fetch join으로 다시 조회가 발생한다면, 필요한 부분에 EAGER로 설정하면 안 될까라는 생각을 하게 될 수 있습니다. 그렇지만 더 복잡해진 도메인에서는 EAGER 설정으로 인해 어디서 수많은 쿼리가 발생할지 알 수 없기 때문에, 이는 곧 터질 폭탄을 안고 가는 것과 같다고 생각합니다. 따라서 Fetch join을 필요한 경우에 맞게 적절히 적용하는 것이 좋습니다.

적용해보고 싶었지만 러닝 커브로 인해 아직 적용하지 못했던 QueryDSL을 도입해보는 것도 좋을 것 같습니다. 도메인이 점점 복잡해지고 커지면서, 쿼리의 복잡도도 증가하고 있기 때문에, QueryDSL을 활용하면 서브쿼리 등 복잡한 쿼리 작성이 더욱 수월해질 것입니다.

profile
뽀글뽀글 개발공부

0개의 댓글