JPA JOIN 어떻게 할까?


타임라인 구현

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 엔티티 객체만으로 FollowPost를 가져올 수 있다. 그러나 내부적으로는 다음과 같은 쿼리가 날아가는 것이다.

-- 팔로우 하는 모든 대상 구하기
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가지가 있다.

특징과 문제점에 대해 알아보자.


해결방법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);






해결방법2: EntityGraph

@EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면

Lazy가 아닌 Eager 조회로 가져온다.

EntityGraph 상에 있는 Entity들의 연관관계 속에서 필요한 엔티티와 컬렉션을 함께 조회하려고 할때 사용한다.

@EntityGraph(attributePaths = "post")





해결방법3: FetchMode.SUBSELECT

이 방법은 쿼리 한번으로 해결하는 것은 아니고 두번의 쿼리로 해결하는 방법이다.

연관관계의 데이터를 조회할 때 서브 쿼리로 함께 조회하는 방법이다.

@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "following", fetch = FetchType.EAGER)
private List<Follow> followings = new ArrayList<>();





해결방법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 ?



참고