아래의 User Entity는 Set<> 자료구조가 필드에 존재한다. 그렇다면 User는 제 1 정규형(원자성)을 위반할까?
@Entity
public class User {
@Id
private Long id;
@OneToMany(mappedBy = "user")
private Set<Friend> friends;
}
@Entity
public class Friend {
@Id
private Long id;
@ManyToOne
private User user;
@ManyToOne
private User friend;
}
결과부터 말하자면, DB 테이블 차원에서 위는 1NF를 만족한다.
왜냐하면 JPA에서는 내부적으로 다대일(N:1) 또는 일대다(1:N) 관계를 별도의 테이블(또는 외래 키)을 통해 정규화된 방식으로 저장하기 때문이다.
실제로 DB에는 아래와 같이 저장된다.
user(id)
friend(id, user_id, friend_id, level)
그렇다. User 테이블에는 friends 정보가 직접적으로 저장되어 있지 않기 때문에, 기본적으로는 JPA가 자동으로 친구들을 가져오지 않는다.
위와같은 방식으로 데이터를 로딩하는 것을 지연 로딩(LAZY)이라고 한다.
지연로딩이란, 해당 데이터를 미리 가져오지 않고, 해당 데이터가 필요한 순간에 데이터를 가져오는 것을 말한다.
위의 User, friend를 예시로 설명하자면, User 정보를 가져올때, friend 정보를 같이 가져오는 것이 아니라, friend 정보가 필요할때, user_id를 통해 검색하여 friend 정보를 가져오는 것을 말한다.
지연로딩을 사용하게 되면, 불필요한 데이터를 가져오지 않아도 되니 데이터 로딩 비용이 적게 들고, 속도가 빠르다는 장점이 있다.
그런데 지연 로딩의 치명적인 단점이 있다. 바로 N+1 문제이다.
N+1문제란, 짧게 말하자면 외래키로 연관되어있는 필드를 가져올 때, LAZY로 설정하였을 경우 DB요청 쿼리가 과도하게 발생하는 문제를 말한다.
예를 들어서 아래 코드를 봐보자
user의 friends 정보를 가져오려고한다. 하지만, user에는 friends 정보가 없으므로, 다음과 같이 가져와보자.
user를 가져오고 → friend에서 가져온 user_id로 friends 정보를 가져올 수 있다.
List<User> users = userRepository.findAll(); // 1번 쿼리
for (User user : users) {
System.out.println(user.getFriends()); // N번 쿼리
// (getFriends() 는 SELECT * FROM friend WHERE user_id = ? 쿼리로 동작한다.)
}
# SQL로 표현하면 다음과 같다.
SELECT * FROM USERS;
# 가져온 모든 USERS.ID 에 대해서
SELECT * FROM friend WHERE user_id = ?1
...
SELECT * FROM friend WHERE user_id = ?n
위에서 findAll()의 경우, SELECT * FROM user 쿼리가 1번 발생한다.
하지만 아래의 경우에는 N번의 쿼리가 발생한다.
이를 N+1 문제라고 한다.
(왜 1+N이 아니라 N+1로 불리는지는 모르겠다. 순서상 1+N이 더 타탕한데…)
이를 해결하기 위해서는 User 정보를 가져올때, Friends 정보도 같이 가져오는 쿼리를 사용하면 된다. JPA에서는 이를 Fetch Join으로 구현할 수 있다.
Fetch Join 사용
@Query("SELECT u FROM User u LEFT JOIN FETCH u.friends WHERE u.id = :id")
Optional<User> findByIdWithFriends(@Param("id") Long id);
EntityGraph 사용
@EntityGraph(attributePaths = "friends")
Optional<User> findWithFriendsById(Long id);
별도 Repository로 Friend 조회
List<Friend> findByUser(User user);
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private Set<Friend> friends;
항상 join된 데이터를 가져와야한다면 간편할 수는 있으나, 중복 row, 순환참조 문제 등으로 위험하기 때문에 LAZY과 fetch join을 사용하는 것이 좋다.