들어가면서
최근에 Spring Data JPA를 배우고 적용해보았는데
N+1 문제에 대해 헷갈리는 부분이 많습니다.
구글링을 해도 뭔가 묘하게 비슷한 듯 다른 설명인 것 같고..
궁금점이 풀리지 않은 부분이 있어 개인적으로 코드를 돌려가면서 테스트하고 답을 얻어보려 합니다.
여러 글들을 읽은 바탕으로 제가 생각하는 내용을 덧붙여 틀린 부분이 있을 수 있습니다.
(특히 "고찰"이라고 적은 부분)
일반적인 쉬운 예시가 아니라 제가 지금 겪는 문제를 트러블 슈팅한거라 내용이 복잡할 수도 있습니다.
틀린 내용은 댓글 달아주시면 감사하겠습니다 😊
Spring Data JPA 이용 시
엔티티 간 연관 관계에서 발생하는 문제로
1개의 엔티티 데이터를 조회하는데 추가로 조회된 엔티티의 개수 N번 만큼 쿼리가 추가적으로 실행되는 현상을 말한다.
엔티티 조회 쿼리(1 번) + 조회된 엔티티의 개수(N 개)만큼 연관된 엔티티를 조회하기 위한 추가 쿼리 (N 번)
개인적인 생각으로는 1+N 문제라고 부르는 게 이해가 더 쉽다.
중요한 포인트는 추가 쿼리가 발생한다는 것이다.
스프린트 미션에서 채팅 서비스를 구현하고 있는데 마침 좋은 예시가 있어 들고 왔다.
유저를 의미하는 User 엔티티와 서버에 업로드되는 파일을 표현하는 BinaryContent가 있다. 그리고 유저의 접속 상태를 표현하는 UserStatus가 있다.
이때 한 명의 유저는 하나의 프로필 사진과 하나의 접속 상태 정보를 가질 수 있다. 따라서 User와 BinaryContent, User와 UserStatus의 관계 모두 1:1이다.
DB 스키마

JPA 연관관계 정리

이 관계는 User 엔티티에서 다음과 같이 코드로 나타낼 수 있다. BinaryContent와 UserStatus 클래스는 여기서는 안 중요하므로 다루지 않는다.
@Entity
@Table(name = "users")
@NoArgsConstructor
@Getter
public class User extends BaseUpdatableEntity {
private String username;
private String email;
@JsonIgnore // 비밀번호 노출 방지
private String password
@OneToOne
@JoinColumn(name = "profile_id")
private BinaryContent profile;
@OneToOne(mappedBy = "user")
@JoinColumn(name = "status_id")
private UserStatus status;
...
}
UserRepository extends JpaRepository에서 findAll()로 모든 유저를 조회했을 때 실행되는 쿼리는 아래 쿼리 1개면 된다.
select * from users
그러나 총 9개의 쿼리가 실행된다!
Hibernate: select u1_0.id,u1_0.created_at,u1_0.email,u1_0.password,u1_0.profile_id,u1_0.updated_at,u1_0.username from users u1_0
Hibernate: select bc1_0.id,bc1_0.content_type,bc1_0.created_at,bc1_0.file_name,bc1_0.size from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select us1_0.id,us1_0.created_at,us1_0.last_active_at,us1_0.updated_at,u1_0.id,u1_0.created_at,u1_0.email,u1_0.password,p1_0.id,p1_0.content_type,p1_0.created_at,p1_0.file_name,p1_0.size,u1_0.updated_at,u1_0.username from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
Hibernate: select bc1_0.id,bc1_0.content_type,bc1_0.created_at,bc1_0.file_name,bc1_0.size from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select us1_0.id,us1_0.created_at,us1_0.last_active_at,us1_0.updated_at,u1_0.id,u1_0.created_at,u1_0.email,u1_0.password,p1_0.id,p1_0.content_type,p1_0.created_at,p1_0.file_name,p1_0.size,u1_0.updated_at,u1_0.username from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
Hibernate: select bc1_0.id,bc1_0.content_type,bc1_0.created_at,bc1_0.file_name,bc1_0.size from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select us1_0.id,us1_0.created_at,us1_0.last_active_at,us1_0.updated_at,u1_0.id,u1_0.created_at,u1_0.email,u1_0.password,p1_0.id,p1_0.content_type,p1_0.created_at,p1_0.file_name,p1_0.size,u1_0.updated_at,u1_0.username from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
Hibernate: select bc1_0.id,bc1_0.content_type,bc1_0.created_at,bc1_0.file_name,bc1_0.size from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select us1_0.id,us1_0.created_at,us1_0.last_active_at,us1_0.updated_at,u1_0.id,u1_0.created_at,u1_0.email,u1_0.password,p1_0.id,p1_0.content_type,p1_0.created_at,p1_0.file_name,p1_0.size,u1_0.updated_at,u1_0.username from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
쿼리 속 테이블명을 보면 조회 순서를 알 수 있다.
즉, 전체 유저 조회하는 것 자체는 User 테이블에서 쿼리 1회 실행으로 가능하지만
각 유저와 관련된 UserStatus와 BinaryContent 정보까지 가져와야 하면서
추가로 쿼리가 발생하는 것이다.

지금 이 데이터베이스에는 4명의 유저가 있다. -> N = 4
유저를 조회하기 위한 쿼리 1회 + 4번의 BinaryContent 조회 쿼리 + 4번의 UserStatus 조회 쿼리가 발생했다.
가져올 User 데이터가 4개 뿐이니 지금 실행되는 쿼리는 9개 뿐이다.
하지만 평범한 서비스에서는 사용자가 몇 백, 천, 만 단위까지 올라갈 수 있다.
그때마다 N번의 추가 쿼리가 발생하면 DB 조회 성능이 아주 부담될 것이다.
N+1 문제가 발생하는 이유로 지연 로딩을 많이 언급한다.
지연 로딩이란, 연관된 엔티티의 데이터는 실제로 사용될 때까지 로딩을 지연시키는 방법이다.
User 엔티티와 연관된 하위 엔티티는 실제로 그 엔티티를 사용하기 전까지는 DB에서 로딩되지 않는다.
그런데 단순히 지연 로딩으로 N+1 문제가 발생한다는 게 바로 이해되지 않았고 지금 내 코드가 지연 로딩이라는 확신도 안 들었다.
처음에는 데이터를 실제로 사용한다는 걸 코드에서 해당 엔티티를 사용하는, 즉 개발자가 직접적으로 호출한다고 받아들였다.
그리고 내가 명시하지 않은 경우 지연 로딩이 디폴트라고 생각했다.
public List<UserDto> findAll() {
List<User> users = userRepository.findAll();
return users.stream()
.sorted(Comparator.comparing(user -> user.getCreatedAt()))
.map(userMapper::toDto)
.toList();
}
지금 이 서비스 코드에서는 Mapper를 통해 DTO로 변환하며 User 엔티티 속 BinaryContent와 UserStatus 데이터를 사용한다.
(Mapper 코드까지는 필요 없을 것 같아 첨부하지는 않는다.)
그럼 만약 findAll()로 User 엔티티를 가져오기만 하고 아무것도 하지 않는다면 연관된 엔티티를 사용하지 않으니 N+1 문제가 안 발생하는 걸까?
서비스 코드를 아래처럼 해보았다. 올바른 응답은 아니지만 아무튼 연관된 엔티티가 필요없을 것이라 생각했다.
public List<UserDto> findAll() {
List<User> users = userRepository.findAll();
return new ArrayList<>();
}
(원래는 모든 필드가 뜨는데 임의로 * 표시하였다.)
Hibernate: select * from users u1_0
Hibernate: select * bc1_0 where bc1_0.id=?
Hibernate: select * from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
Hibernate: select * from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select * from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
Hibernate: select * from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select * from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
Hibernate: select * from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select * from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id left join binary_contents p1_0 on p1_0.id=u1_0.profile_id where us1_0.user_id=?
그런데 여전히 N+1 문제가 발생했다.
그 이유는 @OneToOne 애노테이션을 쓰는 경우 지연 로딩이 아니라 즉시 로딩 전략이 디폴트이기 때문이다.
연관관계 디폴트 fetch 전략
@ManyToOne: EAGER
@OneToOne: EAGER
@ManyToMany: LAZY
@OneToMany: LAZY
User 엔티티를 조회하면 @OneToOne 관계인 UserStatus와 BinaryContent 객체들도 즉시 함께 조회해야 하기 때문에 추가 쿼리가 발생한다.
📌 보통 지연 로딩 때문에 N+1 문제가 발생한다고 많이 표현하던데 즉시 로딩이어도 N+1 문제가 발생할 수 있다.
앞서 말했듯이 난 디폴트는 다 지연 로딩일 것이라 생각했다.
왜냐면 즉시 모든 연관 데이터를 가져와 불필요한 데이터도 가져오는 즉시 로딩보다는 지연 로딩이 성능면에서 낫기 때문이다.
그런데 왜 @OneToOne에서는 즉시 로딩이 디폴트일까?
나는 이것을 Spring Data JPA의 구현체 Hibernate가 User 엔티티를 맵핑하는 과정으로 이해했다.
User 객체를 DB에서 불러오기 위해 JpaRepository에서 findAll() 메서드로 select * from users 쿼리를 실행한다.
select * from users 쿼리로 users 테이블에서는 다음과 같은 데이터를 가져왔다.

(users 테이블에서 status는 가지고 있지 않으므로 일단 profile_id 컬럼만 살펴보자.)
프로필 사진 즉 BinaryContent 엔티티에 대한 정보는 DB에서는 binaryContents 테이블의 PK를 FK로만 가지고 있다.
하지만 OneToOne 관계이므로 User 객체에는 당연히 BinaryContent 객체가 있다고 생각하는 게 자연스럽다.
따라서 User 엔티티를 사용할 때는 당연히 BinaryContent 정보도 있을 것이라 믿게 되고, 따라서 BinaryContent의 PK가 아니라 BinaryContent profile 객체가 필요하다.
이 객체는 select * from users 쿼리로 얻은 profile_id를 바탕으로 binaryContents 테이블에서 조회하여 얻을 수 있다.
@Entity
@Table(name = "users")
@NoArgsConstructor
@Getter
public class User extends BaseUpdatableEntity {
(생략)
@OneToOne
@JoinColumn(name = "profile_id")
private BinaryContent profile;
...
}
즉 Hibernate는 User 엔티티를 완전하게 맵핑하기 위해
OneToOne 관계에서는 즉시 로딩 전략을 취하는 것이다.
select * from users 쿼리로 얻은 FK 값을 바탕으로
필요한 다른 테이블(binaryContents)에서도 select 문을 실행하여
BinaryContent profile 객체를 맵핑하는 과정을 거치는 것이다.
따라서 추가 쿼리가 발생한다.
이 과정을 정리하자면 다음과 같다.
엔티티 A 조회를 위해 JPA에서 DB 테이블을 객체로 맵핑할 때,
A가 가진 연관 엔티티 B가 @OneToOne, @ManyToOne 관계라면
연관 엔티티 B를 포함한 완전한 A 엔티티로 맵핑하기 위해
기본적으로는 A를 로딩할 때 B도 즉시 로딩한다.
User:BinaryContent와 User:UserStatus는 둘 다 1:1 관계지만 단방향, 양방향 관계로 조금 다르다.
이번에 그 관계에 따른 차이가 있는지 살펴보려고 한다.
만약 User 엔티티에서 연관 엔티티 모두를 지연 로딩으로 명시하고 데이터를 가져오면 어떻게 될까?
@Entity
@Table(name = "users")
@NoArgsConstructor
@Getter
public class User extends BaseUpdatableEntity {
(생략)
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private BinaryContent profile;
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
@JoinColumn(name = "status_id")
private UserStatus status;
...
}
UserService
public List<UserDto> findAll() {
List<User> users = userRepository.findAll();
return users.stream()
.sorted(Comparator.comparing(user -> user.getCreatedAt()))
.map(userMapper::toDto)
.toList();
}
그 결과 여전히 N+1 문제는 발생하는데 처음과 쿼리 실행 순서가 다르다.
UserStatus를 먼저 조회한 후, BinaryContent를 조회하고 있다.
똑같이 Lazy Loading인데 조회된다면 findAll()을 할 때는 추가 쿼리가 실행 안 되고, DTO 변환 과정에서 UserStatus와 BinaryContent 번갈아가면서 쿼리가 날아가야 하는 거 아닐까?
답은 '아니다'이다.
내가 세운 가설은 "두 테이블 조회의 목적이 다르기 때문"이다.
Hibernate: select (생략) from users u1_0
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select (생략) from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select (생략) from binary_contents bc1_0 where bc1_0.id=?
Hibernate: select (생략) from binary_contents bc1_0 where bc1_0.id=?
🎯 UserStatus 테이블을 조회한 이유
User 엔티티에서 UserStatus는 mappedBy="user"로 FK가 UserStatus 테이블에 존재한다.
그렇다면 select * from users를 했을 때 UserStatus에 대한 정보는 하나도 알 수 없다.
하지만 앞서 말했듯이 1:1 관계이므로 User 엔티티에 당연히 UserStatus 객체가 있어야 한다. 때문에 Hibernate가 User 엔티티를 맵핑할 때 UserStatus를 지금 사용하지는 않더라도 일단 연관 객체가 존재하는지 확인하려 한다.
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where
따라서 이 쿼리의 실행 목적은 UserStatus 데이터를 가져온다기 보다는
Hibernate가 User 엔티티를 맵핑하기 위해서 userRepository.findAll()을 수행할 때 연관된 데이터 UserStatus가 존재하는지 확인하기 위해서라고 추론했다.
가설을 확인하기 위해 엔티티 코드에서 BinaryContent와 UserStatus 객체를 모두 Lazy로 하고, 서비스에서는 User 엔티티만 조회할 뿐 두 객체 모두 사용하지 않고 UserRepository에서 findAl()만 했을 때 쿼리를 보자.
Hibernate: select (생략) from users u1_0
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
이처럼 UserStatus 객체를 사용하지 않음에도 User 엔티티 조회 시 UserStatus를 함께 조회하고 있다.
즉 Lazy Loading으로 설정했음에도 mappedBy로 연결된 양방향 관계에서는, Hibernate가 실제로 연관 데이터를 사용하지 않아도 내부적으로 연관관계의 주인 데이터의 존재를 확인하기 위해 추가 쿼리를 실행할 수 있다.
이 경우 Lazy Loading으로 설정한 보람이 별로 없다고 볼 수 있다고 생각한다.
🎯 BinaryContent 테이블을 조회한 이유
반면 BinaryContent는 profile_id 컬럼으로 그 존재를 확인했기에 Hibernate가 다시 추가 쿼리로 확인할 필요가 없다.
지연 로딩이기 때문에 BinaryContent는 그 객체가 사용될 때 호출된다.
userRepository.findAll()에서는 binaryContents 테이블을 조회하지 않는다.
return 문에서 DTO로 변환하며 BinaryContent 객체를 사용하기 때문에(데이터 사용 시점) User와 UserStatus를 조회하는 쿼리보다 나중에 실행된다.
만약 서비스 코드에서 BinaryContent 객체를 쓰지 않으면
public List<UserDto> findAll() {
List<User> users = userRepository.findAll();
return new ArrayList<>();
}
Hibernate: select (생략)e from users u1_0
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
Hibernate: select (생략) from user_statuses us1_0 left join users u1_0 on u1_0.id=us1_0.user_id where us1_0.user_id=?
위에서 언급한 UserStatus만 조회하고 BinaryContent는 조회하지 않는다.
하지만 앞서 봤듯이 BinaryContent를 코드에서 호출하는 순간 조회 쿼리가 실행된다.
일반적인 서비스에서 유저 정보를 볼 때 프로플 사진도 늘 함께 보듯,
1:1 관계라면 대부분 연관 데이터까지 쓰지 않을까?
그렇다면 지연 로딩이어도 어차피 추가 쿼리는 발생할 수 밖에 없다.
📌 이렇게 연관 관계에 따라서 다르게 동작할 수 있고, 지연 로딩 시 N+1 문제가 발생하는 것을 이해했다.
그런데 즉시 로딩이라면 User 엔티티를 가져올 때 join해서 가져온다면
하나의 쿼리로 가져올 수 있을 텐데 왜 join이 아니라 추가 select 쿼리가 나가는걸까?
지금까지 우리가 예시로 본 User 엔티티는 두 개의 @OneToOne 연관 관계를 가지고 있었다. 여기서 필드 하나를 지워보고 조회하면 어떻게 될까?
@Entity
@Table(name = "users")
@NoArgsConstructor
@Getter
public class User extends BaseUpdatableEntity {
private String username;
private String email;
@JsonIgnore // 비밀번호 노출 방지
private String password
@OneToOne
@JoinColumn(name = "profile_id")
private BinaryContent profile;
...
}
쿼리가 1개만 실행된다! N+1 문제가 발생하지 않는다.
User 테이블을 조회할 때 LEFT JOIN으로 BinaryContents를 함께 조회하고 있다.
사실 엔티티가 연관 관계의 주인일 때는 기본적으로 Join을 하여 하나의 쿼리만 실행하는 것이다.
User 엔티티는 BinaryContent와의 연관 관계의 주인이고,
@OneToOne 연관 관계가 하나이므로 한 번의 Join만 쓰면 되니 JPA는 한 번에 무사히 데이터를 가져오고 추가 쿼리는 발생하지 않는다.
select
u1_0.id,
u1_0.created_at,
u1_0.email,
u1_0.password,
p1_0.id,
p1_0.content_type,
p1_0.created_at,
p1_0.file_name,
p1_0.size,
u1_0.updated_at,
u1_0.username
from users u1_0
left join binary_contents p1_0
on p1_0.id=u1_0.profile_id
그러나 @OneToOne 관계가 여러 개 있는 경우 또는 연관 관계의 주인이 아닌 경우 JPA는 기본적으로 테이블을 JOIN 하지 않고 추가적인 SELECT 쿼리를 실행한다.
JOIN이 많아지면 복잡하고 잘못된 쿼리를 실행할 가능성이 생기기 때문이다.
📌 @OneToOne 관계가 여러 개 있다면, 그리고 연관 관계의 주인이 아니라면
join이 아니라 추가 select로 즉시 가져온다.
이러한 즉시 로딩은 필요한 연관된 엔티티를 모두 가져온다는 장점이 있지만
의도치 않게 join을 할 수 있어서
실무에서 엔티티 간의 관계가 복잡해질수록 조인으로 인한 성능 저하를 피할 수 없기에
지연 로딩을 기본으로 사용할 것을 권장하고 있다.
즉시 로딩을 언급하는 글도 많지만
많은 글에서 '지연 로딩 시 N+1 문제가 발생할 수 있다'고 한다.
즉시 로딩에서도 N+1 문제가 발생하는데 왜 사람들이 지연 로딩만 언급할까 궁금했다.
내가 생각한 이유는 즉시 로딩에서는 N+1 문제가 발생하지 않을 수도 있기 때문이다.
이전 고찰에서 @OneToOne 연관관계 하나인 경우 추가 쿼리가 발생하지 않았다.
이처럼 즉시 로딩에서 항상 N+1 문제가 발생하지 않는다.
그리고 지연 로딩을 기본적으로 사용할 것을 권장하기에
다른 사람들이 지연 로딩을 더 강조해 설명한다고 생각한다.
부모 엔티티를 조회할 때 연관된 자식 엔티티를 추가로 조회하는 쿼리를 실행하기 때문이라고 하는 게 가장 정확한 답변이라고 생각한다.
한쪽 테이블을 조회하고 연결된 다른 테이블을 따로 조회하기 때문에 N+1 문제가 발생한다면,
미리 두 테이블을 Join하여 모든 데이터를 가져오면 된다.
레포지토리에서 JPQL로 직접 Fetch Join을 작성해주자.
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile JOIN FETCH u.status")
List<User> findAllFetchJoin();
로그에서 쿼리가 1개만 실행된 것을 확인할 수 있다.
profile은 LEFT JOIN을 한 이유는 '유저는 프로필 사진을 가지지 않을 수도 있다'는 요구사항 때문에 프로필 사진이 없는 유저까지 가져오기 위해서다.
Hibernate: select u1_0.id,u1_0.created_at,u1_0.email,u1_0.password,p1_0.id,p1_0.content_type,p1_0.created_at,p1_0.file_name,p1_0.size,s1_0.id,s1_0.created_at,s1_0.last_active_at,s1_0.updated_at,u1_0.updated_at,u1_0.username from users u1_0 left join binary_contents p1_0 on p1_0.id=u1_0.profile_id join user_statuses s1_0 on u1_0.id=s1_0.user_id
Join과 Fetch Join은 뭐가 다를까?
@Query("SELECT u FROM User u LEFT JOIN u.profile JOIN u.status")를 실행한다면 User에 맞는 profile과 status 데이터를 찾기만 했지, 그 엔티티를 즉시 가져오지는 않는다. 여전히 로딩이 지연된 상태이다.Fetch Join의 단점
@EntityGraph의 attributePaths에 쿼리 수행 시 바로 가져올 필드명을 지정하면 Eager Loading 방식으로 가져와 한 번의 쿼리로 함께 가져올 수 있다.
User Entity
@OneToOne
@JoinColumn(name = "profile_id")
private BinaryContent profile;
@OneToOne(mappedBy = "user")
@JoinColumn(name = "status_id")
private UserStatus status;
엔티티 클래스에서 쓴 필드명을 그대로 attributePaths에 명시한다.
UserRepository
@EntityGraph(attributePaths = {"profile", "status"})
List<User> findAll();
쿼리가 한 번만 발생한다.
Hibernate: select u1_0.id,u1_0.created_at,u1_0.email,u1_0.password,p1_0.id,p1_0.content_type,p1_0.created_at,p1_0.file_name,p1_0.size,s1_0.id,s1_0.created_at,s1_0.last_active_at,s1_0.updated_at,u1_0.updated_at,u1_0.username from users u1_0 left join binary_contents p1_0 on p1_0.id=u1_0.profile_id left join user_statuses s1_0 on u1_0.id=s1_0.user_id
일반적으로 쿼리 반환 결과가 적은 inner join이 성능이 더 좋고
Fetch Join에서도 필요하면 outer join을 명시할 수 있어
@EntityGraph보다는 fetch join을 더 많이 쓰는 듯 하다.
@ManyToOne이나 @OneToMany 관계라면
여러 엔티티를 한 번에 묶어 조회하는 용도로 이 애노테이션을 사용할 수 있다.
@Entity
public class User {
@OneToMany(fetch = FetchType.LAZY)
@BatchSize(size = 10)
private List<Post> posts;
}
@BatchSize(size = 10)는 User 엔티티에서 Post 엔티티를 10개씩 묶어서 조회하겠다는 의미이다.
Post 엔티티가 많을 경우 여러 번의 쿼리로 처리되지만, 한 번에 최대 10개씩 묶어서 조회하므로 성능이 개선된다.
그러나 쿼리 수를 줄이는 데 도움이 되지만
가져올 데이터 수가 size보다 크다면 여전히 여러 번의 쿼리가 실행된다는 점에서
N+1 문제의 완전한 해결법이라고 보기 어렵다.
하지만 @BatchSize는 지연 로딩을 유지할 수 있으면서 추가적인 조인을 하는 게 아니라 단순히 여러 엔티티를 묶어서 한 번에 조회하는 방식이라 더 간단한 쿼리가 실행된다고 한다.
상황에 맞게 적절한 방법을 택하면 된다.
고찰: 즉시 로딩인데 왜 join이 아닐까?
에서 만약 UserStatus가 아니라 BinaryContent를 지운다면?
@OneToOne 관계가 여러 개인 게 중요한 게 아니라, 연관 관계의 주인 여부가 중요할 수도