본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 10: Spring Boot에서 엔티티 그래프

들어가기

JPA에서는 성능을 위한 다양한 기능을 제공하고 있다. 엔티티 그래프도 그 중에 하나이다. 엔티티에서 성능 최적화를 위해서 내부 속성에 대해 즉시 로딩과 지연 로딩을 지원한다. 이런 기능은 컴파일 타임에 설정되어 변경할 수 없다. 엔티티 그래프를 사용할 경우 런타임에 제어할 수 있다. 엔티티 그래프 사용하는 방법을 간단하게 살펴보자.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

엔티티 그래프란?

엔티티 그래프는 JPA 2.1에서 소개되었다. 엔티티 그래프는 로딩 성능 관련해 좀 더 세밀하게 제어하는 기능을 제공한다. 엔티티 내의 관련 영속 속성들을 그룹핑한 템플릿을 정의하고 런타임에 이를 선택적으로 적용할 수 있다. 엔티티 그래프가 n+1 조회 이슈를 피하는 훌륭한 방법 중에 하나이다.

JPA 2.0에서 LAZY와 EAGER에 의해 조회 전략을 선택적으로 적용했었다. 이럴 경우 정적으로 고정되기 때문에 런타임시 변경이 불가능하다. 그래서 엔티티 그래프를 사용해 런타임에 성능을 고려해서 동적으로 환경구성을 변경할 수 있다.

Member 엔티티

엔티티 그래프를 적용할 엔티티를 정의해보자. Member 엔티티를 정의해보자.

@Embedding
data class MemberName (val myName: String)

@Entity
open class Member(
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private var id: Long?,
    @Embedded    
    private var name: MemberName?,
    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    private var posts: MutableList<Post>,
    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private var subscription: Subscription,
    @ManyToOne(fetch = FetchType.LAZY)
    private var hobby: Hobby?,
) {
    //...
}

다음은 Post 엔티티이다.

@Entity
open class Post(
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private var id: Long?,
    private var content: String,
    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    private var comments: MutableList<Comment>,
) {
    //...
}

Member 엔티티와 Post 엔티티 간에 1:N 연관관계가 있다. 그래서 Member 엔티티 로딩할때 속성인 posts에 대한 로딩할 Post 엔티티가 매우 많다면 모든 게시글 조회 쿼리를 수행되서 성능을 떨어뜨린다. 그래서 해당 속성을 지연 로딩(FetchType.LAZY)를 적용해서 직접 액세스할 때 로딩하게 한다. 또한 Subscription 엔티티에도 1:1 연관관계와 지연 로딩이 적용되어 있다. Member 엔티티를 조회하면 멤버 속성에서 posts와 subscription을 제외하고 로딩함으로써 초기 조회 성능을 높인다. 만약 posts와 subscription을 바로 사용하는 경우 Member 엔티티를 조회하고 posts와 subscription을 두번 더 조회하면서 전체적인 성능이 떨어지게 된다. 어떤 곳에서는 posts와 subscription을 사용하지 않는 경우에는 성능에 유리하지만, 사용하는 곳에서는 불리하게 된다.

다음으로 Hobby 엔티티이다. 같은 취미를 가진 멤버를 모아놓는 엔티티이다.

@Entity
open class Hobby(
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private var id: Long?,
    private var name: String,
    @OneToMany(mappedBy = "hobby", fetch = FetchType.LAZY)
    private var members: MutableMap<MemberName, Member>,
) {
    //...
}

Hobby 엔티티와 Member 엔티티 간에 1:N 연관관계가 있고 member 속성은 지연로딩 전략을 설정했다. 또한 MemberName을 키로 가지는 맵 형식으로 정의되어 있다. 여기도 역시 성능 측면에서 members을 조회하지 않는 경우에는 유리하지만 members도 같이 조회할 경우 N+1로 인해 불리하다.

이를 보완할 수 있는 방법을 살펴보자.

엔티티 그래프 정의

엔티티 그래프의 사용법을 살펴보자. 먼저 사용할 엔티티 그래프를 정의해야 한다. 보통 엔티티 그래프는 해당 엔티티 클래스 상단에 어노테이션 형태로 추가한다.

@NamedEntityGraph 어노테이션

엔티티 그래프 정의는 @NamedEntityGraph 어노테이션을 사용해서 그래프 루트를 정의한다. 이 어노테이션은 아래와 같은 속성을 제공한다.

  • name: 엔티티 그래프 이름
  • attributeNodes: @NamedAttributeNode 어노테이션을 사용해 조회되는 속성을 지정

엔티티 그래프에 의해서 엔티티 페칭(fetching)을 지정할 수 있다. 먼저 Member 엔티티에 엔티티 그래프를 정의해 보자.

@NamedEntityGraph(
    name = "member-with-posts-and-subscription",
    attributeNodes = [
      NamedAttributeNode("posts"),
      NamedAttributeNode("subscription")
    ]
)
@Entity
open class Member {
  //...
}

attributeNodes 속성에 @NamedAttributeNode 어노테이션을 사용해 posts와 subscription 속성 노드 목록을 추가했다. 이는 Member 엔티티 로딩할 때에 posts와 subscription 속성과 연관된 엔티티도 같이 로딩하는 엔티티 그래프이다.

SubGraph

@NamedAttributeNode가 현재 엔티티의 속성에 대한 페칭 전략을 지정할 수 있지만, 연관 엔티티의 속성에 대한 페칭 전략을 지정할 수 없다. @NamedAttributeNode가 연관 엔티티를 참조한 경우, 서브그래프를 사용해 연관 엔티티의 하위 속성에 대한 페칭 전략도 지정할 수 있다.

서브그래프 정의는 @NamedAttributeNode 어노테이션에 의해 정의할 수 있으며 아래와 같은 서브그래프 관련 속성을 제공한다.

  • subgraph: 속성이 자신 AttributeNodes를 가진 관리 타입(ex. 엔티티)에 대한 참조인 경우 NamedSubgraph 정의를 사용한다. 만약 상속된 클래스인 경우 여러 서브그래프을 지정할 수 있다. 추가 서브그래프는 각 서브클래스 특정 속성들을 추가할 수 있다. 슈퍼클래스의 서브그래프는 서브클래스 서브그래프에 합쳐진다. NamedSubgraph의 이름을 사용한다.
  • keySubgraph: 속성이 맵 형식을 참조하는 경우 키 속성이 엔티티인 경우 키의 서브그래프를 지정할 수 있다. Map 속성이 아니면 사용할 수 없다. 마찬가지로 상속된 클래스가 가 있다면 서브그래프를 지정할 수 있고, 추가 서브 그래프는 서브클래스 특정 속성을 추가 할 수 있다. 슈퍼클래스의 서브그래프는 서브클래스 서브그래프에 합쳐진다. NamedSubgraph의 이름을 사용한다.

그리고 @NamedEntityGraph 어노테이션의 subgraphs에서 @NamedSubgraph 어노테이션을 사용해 서브그래프 목록을 정의한다.

서브그래프를 사용하여 좀더 복잡한 엔티티 그래프를 정의해보자.

@NamedEntityGraph(
    name = "post-with-posts-subscription",
    attributeNodes = [
      NamedAttributeNode("posts"),
      NamedAttributeNode("subscription", subgraph = "subgraph-subscription"),
    ],
    subgraphs = [
      NamedSubgraph(
        name="subgraph-subscription",
        attributeNodes = [
          NamedAttributeNode("member"),
        ]
      ),
    ]
)
@Entity
open class HObby {
  //...
}

NamedAttributeNode에서 subgraph 속성에 subgraph-subscription이라는 서브그래프을 설정하여 사용할 서브그래프를 선택한다. 그리고, NamedEntityGraph의 subgraphs 속성에 @NamedSubgraph 어노테이션을 사용해 @NamedSubgraph으로 subgraph-subscription 서브그래프를 정의한다. @NamedEntityGraph에서 동일하게 @NamedAttributeNode을 사용해서 엔티티 속성을 지정한다.

한가지 주의할 부분은 엔티티그래프가 너무 깊게 정의하면 좋지 않다. 이는 모든 참조가 조인을 통해서 가져오기 때문에 범위가 너무 넓고 결과가 많다면 쿼리 성능이 반대로 좋지 않게 된다.

연관 속성이 맵 형식인 경우 키 객체가 기본 타입을 사용한다면 문제 없지만 엔티티를 사용한다면 키에 대한 서브그래프로 keySubgraph을 사용해야한다. 이를 위해 Hobby의 맵 형식인 members 속성을 위주로 사용법을 살펴보자.

@NamedEntityGraph(
    name = "hobby-with-members",
    attributeNodes = [
      NamedAttributeNode("members", subgraph = "subgraph-members", keySubgraph = "key-member-name"),
    ],
    subgraphs = [
      NamedSubgraph(
        name="subgraph-members",
        attributeNodes = [
          NamedAttributeNode("hobby"),
          NamedAttributeNode("name", subgraph="key-member-name"),
        ]
      ),
      NamedSubgraph(
        name="key-member-name",
        attributeNodes = [
          NamedAttributeNode("myName"),
        ]
      ),
    ]
)
@Entity
open class Hobby {
  //...
}

엔티티그래프에 의해 members 속성에 대해 subgraph-members라는 서브그래프를 사용한다. 또한 키 서브그래프로 key-member-name이라는 서브그래프를 사용한다. 각 서브그래프는 해당 엔티티의 속성에 대한 패치 전략을 변경하게 된다.

위의 예제에서는 myName 속성에 대한 패치전략 변경은 크게 의미가 없지만 단순히 사용하는 관점에서 참고하기 바란다.

엔티티 그래프 사용

앞에서 정의한 엔티티 그래프를 사용해보자. 엔티티 그래프는 정의만 되어있을 뿐 사용하지 않기에 별다른 변화가 없다. 이를 사용하도록 설정해줘야 페치전략이 적용된다.

@EntityGraph 사용

리포지토리에서 @EntityGraph 어노테이션을 이용해 사용할 엔티티 그래프를 지정해보자.

interface MemberRepository : JpaRepository<Member, Long> {
  @EntityGraph("member-with-posts-and-subscription")
  override fun findById(id: Long): Optional<Member>
}

@EntityGraph에 사용할 엔티티 그래프 이름을 지정하면 된다. 또한 type 속성이 있는데 EntityGraphType 열거형으로 지정하는데 기본값이 EntityGraphType.FETCH로 지정되어 있는데 이는 지정된 속성에는 FetchType.EAGER 지정하고 나머지 속성은 FetchType.LAZY로 지정해서 실행한다. EntityGraphType.LOAD도 지정가능하다. 이는 지정된 속성은 FetchType.EAGER로 지정하지만, 나머지 속성은 이미 설정된 값 또는 기본 값으로 실행하다. @EntityGraph 어노테이션을 사용하는 방법은 컴파일 타임에 정해진다. 이를 런타임에 설정하는 방법을 살펴보자.

Hint 사용

Hint을 사용하는 방식으로 엔티티 매니저의 인터페이스를 통해 원하는 엔티티그래프를 획득해서 Hint로 획득한 엔티티그래프를 적용하는 방식이다.

override fun findById(id: Long, entityGraph: String): Member? {
    val eg = em.getEntityGraph(entityGraph)
    val hints = mutableMapOf<String,Any>()
    hints["jakarta.persistence.fetchgraph"] = eg
    return em.find(Member::class.java, id, hints)
}

엔티티 메니저의 getEntityGraph()을 사용해 엔티티 그래프를 획득한다. 그리고 힌트용 맵 객체를 만들어서 "jakarta.persistence.fetchgraph"에 획득한 엔티티 그래프를 저장한다. find() 실행에 사용할 힌트 객체를 입력해서 실행하면 해당 엔티티 그래프로 실행된다.

JPQL에도 적용할 수 있다.

override fun findById(id: Long, entityGraph: String): Member? {
    val eg = em.getEntityGraph(entityGraph)
    return em.createQuery("SELECT m FROM Member m WHERE m.id = :id", Member::class.java)
        .setParameter("id", id)
        .setHint("jakarta.persistence.fetchgraph", eg)
        .singleResult
}

JPQL에서도 쿼리 객체에 setHint()으로 "jakarta.persistence.fetchgraph"에 획득한 엔티티 그래프를 설정한다. 이렇게 하면 현재 쿼리 실행에 사용할 엔티티 그래프가 지정된다.

Criteria Query에서도 JPQL도 동일하게 쿼리 객체의 setHint()을 사용해서 엔티티 그래프를 지정할 수 있다.

그외 엔티티 그래프 생성

API 사용

엔티티 그래프를 생성하는 방법으로 엔티티 매니저를 사용할 수 있다.

val entityGraph = em.createEntityGraph(Post::class.java)
entityGraph.addAttributeNodes("posts")
entityGraph.addSubgraph("subscription").addAttributeNodes("foo")

createEntityGraph()을 사용해 엔티티 그래프를 생성한다. 그리고 addAttributeNodes()와 addSubgraph()을 사용해 엔티티 그래프를 설정한다. 또한, 이렇게 생성된 엔티티 그래프를 앞에서 설명한 힌트을 통해 설정할 수 있다.

@EntityGraph 사용

쿼리 메소드에서 @EntityGraph 어노테이션을 사용해 엔티티 그래프를 생성할 수 있다. 이전에 @EntityGraph에서는 사용할 엔티티 그래프 이름을 지정했다면 여기서는 attributePath 속성을 사용해 임시 엔티티 그래프를 정의할 수 있다.

interface MemberRepository : JpaRepository<Member, Long> {
    @EntityGraph(attributePaths = ["posts", "subscription"])
    fun findById(id: Long): Optional<Member>
}

findById() 쿼리 메소드 상단에 @EntityGraph 어노테이션을 사용해 Member 엔티티에서 posts와 subscription 속성에 대한 페치 전략을 설정하고 있다. 이 방식은 별도로 엔티티 그래프를 로딩해 힌트에 지정할 필요 없이 바로 사용 가능하다.

마무리

엔티티 그래프에 대해서 살펴보았다. 알고 보면 별거 아닌데 먼가 복잡해 보인다. 특히 엔티티 그래프와 서브 그래프 간에 관계와 복잡한 엔티티 관계에 있어서 엔티티 그래프가 복잡해지면서 더 난해해보여서 그런게 아닌가 생각든다. 엔티티 그래프에서 너무 복잡한 엔티티 그래프를 정의하는 것을 지양한다. 물론 엔티티 자체가 너무 복잡한게 문제 일 수도 있다. 지연 로딩이 성능 이슈에 대한 회피 전략이라면 엔티티 그래프는 지연전략과 반대로서 N+1을 해결하여 성능을 최적화하기 위한 방법이다. 둘 간에 목적이 서로 반대이기 때문에 적절하게 선택적으로 잘 적용해야한다.

부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩생활되세요. ospace.

참고

[1] 최범균, JPA 프로그래밍 입문, 가메출판사, 2017

[2] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015

[3] baeldung, JPA Enity Graph, https://www.baeldung.com/jpa-entity-graph, 2024.03.11

https://jpa-buddy.com/blog/dynamic-entity-graphs-in-spring-data-jpa/

[4] baeldung, Spring Data JPA and Named Entity Graphs, 2024.3.11, https://www.baeldung.com/spring-data-jpa-named-entity-graphs

[5] Thorben Janssen, Hibernate Tip: Create an EntityGraph with multiple SubGraphs, https://thorben-janssen.com/hibernate-tip-entitygraph-multiple-subgraphs/, 2024.06.10

[6] Annotation Interface EntityGraph, https://docs.spring.io/spring-data/data-jpa/docs/current/api/org/springframework/data/jpa/repository/EntityGraph.html

반응형