타임라인 구현
SNS 타임라인은 내가 구독하고 있는 사용자들의 최신 게시글 목록을 의미한다.
이전에 타임라인 페이징을 어떻게 구현할지 아래의 게시글에서 이야기 했었는데,
https://hyerin6.github.io/2021-09-14/timeline/
이번엔 JPA JOIN을 어떻게 작성했는지 자세히 알아보자.
엔티티는 다음과 같다.
User
@Entity
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String userId;
private String name;
private String email;
private String profile;
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
Follow
@Entity
public class Follow {
@Id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "follower_id")
private User follower;
@Id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "following_id")
private User following;
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class PK implements Serializable {
private static final long serialVersionUID = 1L;
@JoinColumn(name = "follower_id")
private User follower;
@JoinColumn(name = "following_id")
private User following;
}
}
Post
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
public void modifyContent(String content) {
this.content = content;
}
}
JPA 연관관계를 사용하면 불필요한 조회가 발생하고
N+1 문제가 발생할 수 있기 때문에 @OneToMany
는 사용하지 않았다.
왜 N+1 문제가 발생하는지, JPA 연관관계를 사용하면 어떤 단점들이 있는지 먼저 알아보자.
구현 방법1: JPA 연관관계 사용하는 경우
만약 JPA 연관관계로 조회하고자 User를 다음과 같이 구현했다고 가정해보자.
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
@OneToMany(mappedBy = "follower")
private List<Follow> followers = new ArrayList<>();
@OneToMany(mappedBy = "following")
private List<Follow> followings = new ArrayList<>();
위와 같이 구현했다면 User 엔티티 객체만으로 Follow
와 Post
를 가져올 수 있다.
그러나 내부적으로는 다음과 같은 쿼리가 날아가는 것이다.
-- 팔로우 하는 모든 대상 구하기
SELECT * FROM follow WHERE follow.follower_user_id = ?
-- 첫 번째 팔로우 유저의 정보, 게시글 가져오기
SELECT * FROM user WHERE user.id = ?
SELECT * FROM post WHERE post.user_id = ?
-- 두 번째 팔로우 유저의 정보, 게시글 가져오기
SELECT * FROM user WHERE user.id = ?
SELECT * FROM post WHERE post.user_id = ?
-- N 번째 팔로우 유저의 정보, 게시글 가져오기
SELECT * FROM user WHERE user.id = ?
SELECT * FROM post WHERE post.user_id = ?
내가 팔로우하고 있는 사용자의 정보와 게시글을 조회하기 때문에
구독하는 유저 * 2
만큼 쿼리가 날아간다.
N+1 문제 발생
쿼리 1번으로 N건을 가져왔는데,
관련 컬럼(Follow, Post)을 얻기 위해 쿼리를 N번 추가 수행하는 N+1 문제가 발생했다.
N+1 문제는 왜 발생하는걸까?
jpaRepository
에 정의한 인터페이스 메서드를 실행하면
JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다.
JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고
엔티티 객체와 필드 이름을 가지고 쿼리를 한다.
그렇기 때문에 JPQL은 findAll()
이란 메소드를 수행하면
해당 엔티티를 조회하는 select * from User
쿼리만 실행하게 되는것이다.
JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회한다.
때문에 연관된 엔티티 데이터가 필요한 경우, FetchType
으로 지정한 시점에 조회를 별도로 호출하게 된다.
N+1 문제 해결 방법
N+1 문제를 해결할 수 있는 방법은 3가지가 있다.
특징과 문제점에 대해 알아보자.
- Fetch join
- @EntityGraph
- BatchSize
해결방법1: Fetch join
Fetch join은 JPQL로 작성해야 한다.
타임라인 구현은 다음과 같은 방식이다.
@Query(value = "SELECT p" +
" FROM Post p" +
" JOIN FETCH p.user u" +
" JOIN FETCH u.followers f" +
" WHERE f.follower.id = :userId AND p.id < :lastPostId")
List<Post> findByFetchJoin(@Param("memberId") Long memberId, @Param("lastPostId") Long lastPostId, Pageable pageable);
- Fetch join은 원하는 조건의 데이터를 한 번에 가져오기 때문에 내가 원하지 않는 추가 쿼리는 발생하지 않는다.
- INNER JOIN으로 호출되는 것이 특징이다.
-
Fetch join은 연관된 테이블끼리만 사용할 수 있기 때문에 JPA 연관관계를 사용해야 하고
Follow
와Post
테이블은 연관관계가 없어User
까지 같이 조인해야 합니다. -
설정해놓은 FetchType을 사용할 수 없다. Fetch Join을 사용하게 되면 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오기 때문에 FetchType을 Lazy로 해놓는것이 무의미하다.
-
Fetch Join
과Pageable
함께 사용하면LIMIT
쿼리가 제대로 적용되지 않는다. 하나의 쿼리로 조회하는 것이기 때문에 페이징 단위로 데이터를 가져오지 않는다. -
DB 에서 Fetch Join 한 결과물을 모두 가져온 후 애플리케이션 메모리에서 직접 골라내기 때문에 데이터 수가 많다면 OutOfMemory 에러가 발생할 가능성이 높다.
해결방법2: EntityGraph
@EntityGraph
의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면
Lazy가 아닌 Eager 조회로 가져온다.
EntityGraph 상에 있는 Entity들의 연관관계 속에서 필요한 엔티티와 컬렉션을 함께 조회하려고 할때 사용한다.
@EntityGraph(attributePaths = "post")
- Fetch join과 동일하게 JPQL을 사용하여 query 문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다.
- Fetch join과 다른 점은 OUTER JOIN으로 실행된다.
해결방법3: FetchMode.SUBSELECT
이 방법은 쿼리 한번으로 해결하는 것은 아니고 두번의 쿼리로 해결하는 방법이다.
연관관계의 데이터를 조회할 때 서브 쿼리로 함께 조회하는 방법이다.
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "following", fetch = FetchType.EAGER)
private List<Follow> followings = new ArrayList<>();
- 즉시로딩으로 설정하면 조회시점에, 지연로딩으로 설정하면 지연로딩된 엔티티를 사용하는 시점에 위의 쿼리가 실행된다.
- 모두 지연로딩으로 설정하고 성능 최적화가 필요한 곳에는 JPQL 페치 조인을 사용하는 것이 추천되는 전략이다.
해결방법4: BatchSize
하이버네이트가 제공하는 org.hibernate.annotations.BatchSize
어노테이션을 이용하면
연관된 엔티티를 조회할 때 지정된 size 만큼 SQL의 IN절을 사용해서 조회한다.
@BatchSize(size=5)
@OneToMany(mappedBy = "following", fetch = FetchType.EAGER)
private List<Follow> followings = new ArrayList<>();
즉시로딩이므로 User
를 조회하는 시점에 Follow
를 같이 조회한다.
@BatchSize
가 있으므로 Follow
의 row 갯수만큼 추가 SQL을 날리지 않고,
조회한 User
의 id들을 모아서 SQL IN 절을 날린다.
성능적으로 많이 개선되었고 Pageable
도 함께 사용할 수 있는 방법이다.
그런데 만약 followings
의 사이즈가 엄청나게 많다면?
성능적인 문제를 해결하기 위해 IN 쿼리를 나눠 호출해야 하는 문제가 발생한다.
N+1 문제와 해결 방법은 JPA 연관관계를 사용해서 발생한 문제들이다.
JPA 연관관계로만 해결하려고 하지 말고 JPQL로 Join, Limit 조건을 직접 작성해보자.
구현 방법2: JPQL JOIN 쿼리 직접 작성하기
@Query(value = "SELECT p"
+ " FROM Post p"
+ " JOIN Follow f ON p.user.id = f.following.id"
+ " WHERE f.follower.id = :userId")
List<Post> findByJoinFollow(@Param("userId") Long userId, Pageable pageable);
@Query(value = "SELECT p"
+ " FROM Post p"
+ " JOIN Follow f ON p.user.id = f.following.id"
+ " WHERE f.follower.id = :userId"
+ " AND p.id < :lastPostId")
List<Post> findByJoinFollowAndLastIdLessThan(@Param("userId") Long userId,
@Param("lastPostId") Long lastPostId, Pageable pageable);
실제 쿼리는 다음과 같다.
select
post0_."id" as id1_4_,
post0_."content" as content2_4_,
post0_."created_at" as created_3_4_,
post0_."updated_at" as updated_4_4_,
post0_."user_id" as user_id5_4_
from
"post" post0_
inner join
"follow" follow1_
on (
post0_."user_id"=follow1_."following_id"
)
where
follow1_."follower_id"=?
order by
post0_."id" desc limit ?
참고
- https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1
- https://jojoldu.tistory.com/165
- https://tech.wheejuni.com/2018/06/16/jpa-cartesian/