본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 13: Spring Boot에서 최적화

들어가기

최적화 관련해서 N+1 문제, 읽기 전용, 페이징 처리에 대한 추가내용을 다룰려고 한다. 이미 앞에서 성능 관련해서 1차와 2차 캐시에 대해서 어느정도 다루었고 데이터 조회 성능 관련해서도 살펴보았다. 이번에서는 최적화 관련해서 잡스러운 부분을 조금더 다룰려고 한다.

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

N+1 문제

N+1문제는 조회 쿼리를 실행할 때 연관 데이터가 N가 있는 경우 모든 데이터를 가져오기위해 N+1 개만큼 조회하는 상황으로 대표적인 성능 이슈이다. 이런 상황은 연관관계에 의해서 한 엔티티가 연관된 여러 엔티티 객체를 로딩하는 경우로 자신 엔티티를 로딩하고 연관관계 속성을 확인하고 연관된 엔티티 들을 로딩하게 된다. 더욱이 목록형태로 되어 있는 경우 가져올 데이터 개수가 더 커질 수 있다. 이로 인해 엔티티 로딩할 때 시간이 많이 걸린다. 이를 해결하기 위한 몇가지 방법이 있다.

  • 중요하지 않고 불필요한 연관관계를 만들지 않는다.
    • 모든 엔티티를 하나로 연결하기 보다는 결합도가 높은 도메인끼리 분리한다.
    • 모델이 잘 설계되지 못하면 빈번하게 추가 조회 연산이 발생할 수 있다.
  • LAZY 같은 지연 로딩을 사용한다.
    • 필요한 엔티티만 로딩하는 방법으로 나머지 엔티티 로딩할 시간을 늦춘다.
    • 이는 시간을 늦출 뿐이지 전체 로딩 시간에는 변함이 없다. 그렇기에 전체 로딩할 경우에는 적합하지 않다.
  • 페치 조인을 사용하는 방법이다.
    • 이는 가져올 데이터를 한번에 조인해서 가져온다.
    • 이는 일부 데이터만 필요할 경우 불필요한 데이터를 로딩하는 추가 시간이 소요될 수 있다.
  • 엔티티 그래프를 사용하는 방법
    • 기본 전략은 LAZY 같은 지연로딩을 사용하지만 특정한 경우 엔티티 그래프를 사용해 한꺼번에 가져오게 한다.
    • 추가적인 번거로운 작업이 있지만 상황에 따라 선택적으로 사용할 수 있다.
  • @BatchSize을 사용해서 한번에 가져올 연관 데이터 개수를 지정한다.
    • 목록형태인 연관 관계 엔티티에서 한번 조회에 가져올 데이터 개수를 지정하여 N 개수를 줄인다.
    • @Entity public class Member ( @OneToMany(mappedBy = "owner") @BatchSize(size = 10) private var posts: MutableList<Post>?, //... ) { //... }
    • size는 보통 10-50 범위 값을 사용하며 자신 환경에 맞게 최적 크기를 찾아서 적용해야 한다.

읽기 전용

엔티티를 로딩하게 되면 영속 컨텍스트에 의해 관리되면서 1차 캐시 역할을 하게 된다. 이런 경우 엔티티가 빈번하게 사용할면 유리하지만, 1회성으로 사용하는 경우 메모리를 계속 유지하는게 문제일 수가 있다. 이를 해결할 수 있는 방법이 엔티티 로딩할 때 영속 컨텍스트에 저장하지 않고 1회성으로 소비되는 읽기 전용 엔티티를 조회하면 된다. 어떻게 하면 읽기 전용 형태로 조회할 수 있을까?

가장 손쉬운 방법은 엔티티를 사용하지 않고 필드을 직접 조회하면 된다.

SELECT m.id, m.name FROM Member m

이 방법은 엔티티를 사용하지 않기 때문에 유지 보수에 있어서 권장하지 않는다. 다른 방법으로는 엔티티 매니저에서 쿼리 힌트를 사용한다.

val q = em.createQuery(...)
q.setHint("org.hibernate.readOnly", true)

다음으로 트랜잭션에서 readonly을 사용할 수 있다.

@Transaction(readonly=true)

readonly로 트랜잭션을 설정하면 영속 컨텍스트가 플러시하지 않게 된다. 플러시로 인한 추가 작업이 줄어든다. 쿼리 힌트와 트랜잭션을 같이 쓰는게 효과적이다.

페이징

페이징 처리는 대규모 데이터를 처리함에 있어서 많이 사용하는 방식 중에 하나이다. 많은 데이터를 한번에 처리하는게 아니라 현재 자원에 맞게 나눠서 처리한다. 즉, 여러 덩어리로 나눠서 처리하는 방식이다. 이미 이전에 Pageable을 사용한 페이징 처리한 방식과 동일하다. 여기서는 Pageable과 다른 페이징 처리 방식을 살펴볼려고 한다. Pageable은 간단하고 직관적인 방식으로 페이지 번호와 크기를 지정해서 원하는 데이터들을 획득할 수 다. 그러나 매우 많은 데이터가 있을 경우 성능 저하가 올 수 있다. 이에 대한 대안으로 몇가지 다른 방식에 대해서 살펴볼려고 한다.

엔티티 매니저 API

엔티티 매니저의 API를 사용한 간단한 페이징 처리 예를 보자. Pageable과 비슷하지만 해당 객체를 사용하지 않고 직접 가져올 데이터의 시작 인덱스 위치와 데이터 크기를 지정할 수 있다.

val data = em.createQuery(..)
             .setFirstResult(i * pageSize)
             .setMaxResults(pageSize)
             .resultList

setFirstResult()로 읽기 시작할 레코드 위치와 setMaxResults()로 한꺼번에 가져올 데이터 크기를 지정한다. 결국, 시작 위치에서 정해진 데이터 크기만큼 조회한다.

하이버네이트 scroll

하이버네이트는 JDBC의 커서 기능을 scroll을 통해서 지원한다. 커서는 데이터베이스에서 지원하는 기능으로 데이터를 순차적으로 액세스할 때 사용한다. JPA에서는 지원하지 않아서 직접 구현해야 한다. 이를 위해 무상태 세션으로 영속 컨텍스트및 2차 캐시도 사용하지 않게 한다. 그렇기에 변경된 엔티티를 저장하기 위해서는 직접 처리해야 한다.

val session = em.unwrap(Session::class.java)
em.transaction.begin()
val scroll = session.createQuery("...")
    .setCacheMode(CacheMode.IGNORE)
    .scroll(ScrollMode.FORWARD_ONLY)
while(scroll.next()) {
    val obj = scroll[0] as Member
    //...
}
scroll.close()
em.transaction.commit()
session.close()

unwarp()을 통해 세션을 획득한다. 그리고 트랜잭션을 시작하고 쿼리를 생성해서 scroll()로 ScrollableResults 객체를 획득한다. 이때 2차 캐시를 필요 없기 때문에 setCacheMode()로 무시한다. 반복문을 통해 next() 호출해 순차적으로 데이터를 처리한다. 마지막으로 사용이 끝나면 ScrollableResults 객체를 종료하고 트랜잭션을 끝내고 세션을 종료한다.

직접 세션을 획득해서 처리하기 때문에 모든 작업을 직접 처리해야하는 단점이 있지만 리소스 사용을 최소화하고 성능을 최적화할 수 있는 장점이 있다.

Scroll API

Spring Data의 Scroll API는 많은 데이터에 대해 순회하는 기능을 제공한다. 안정적인 정렬, 스크롤 타입(Offset-, Keyset-), 결과 제한등 지원한다. 속성 이름을 사용해서 간단한 정렬 표현도 할 수 있고, 쿼리 메소드에서 Top과 First 키워드로 정적 결과 제한을 정의할 수 있다.

interface MemberRepository : Repository<Member, Long> {
    fun findFirst5ByNameNotNull(pos: ScrollPosition): Window<Member>
}

MemberRepository 인터페이스에서 ScrollPosition인자와 Window 리턴형을 가지는findFirst5ByNameNotNull() 메소드를 선언하고 있다. 쿼리 결과는 Window 타입으로 처리되며 이는 전체 쿼리 결과를 가져올 때까지 각 요소의 스크롤 위치가 다음 Window을 반복적으로 가져오면서 처리된다. 이는 자바의 Iterator와 비슷하다. 간단한 예를 보자.

var members = memberRepo.findFirst5ByNameNotNull(ScrollPosition.offset())
do {
  for( each in members) {
    //...
  }
  members = memberRepo.findFirst5ByNameNotNull(members.positionAt(members.size()-1))
} while(!members.isEmpty() && members.hasNext())

positionAt()으로 다음에 가져올 ScrollPosition을 획득하고 메소드 호출 인자로 사용한다. 이를 정의하기 위해서는 JpaRepository가 아닌 Repository을 사용한 쿼리 메소드를 사용해야 한다. JpaRepository에서는 다른 형태로 선언되어 있다. 실제 사용할 때에는 findFirst5ByNameNotNull()에 의해서 Window 객체를 획득하고 이를 통해서 데이터를 접근한다. 그리고 다시 positionAt()을 통해 다음 위치인 ScrollPosition 객체를 획득해서 조회한다. 그래스 isEmpty()와 hasNext()을 통해서 가져올 데이터가 있는지 확인해서 반복한다. 시작 위치는 offset()이나 keyset()으로 스크롤 시작 위치를 지정한다. 한가지 주의할 점은 ScrollPosition.offset()과 ScrollPosition.offset(0)과 차이가 있다. 전차는 처음 스크롤 위치에서 시작하지만 후자는 오프셋이 1로 간주에서 처음 스크롤 위치 다음에서 시작된다.

아래는 Window 인터페이스 정의로 메소드를 살펴보면 어떤 기능을 제공하는지 쉽게 알 수 있다.

interface Window<T> extends Streamable<T> {
  int size();
  boolean isEmpty();
  List<T> getContent();
  default boolean isLast() { return !this.hasNext(); }
  default boolean hasPosition(int index) {
      try {
        return this.positionAt(index) != null;
    } catch(IllegalStateException e) {
        return false;
    }
  }
  default ScollPosition positionAt(T object) {
    int index = this.getContent().indexOf(object);
    if (index == -1) {
      throw new NoSuchElementException();
    } else {
      return this.positionAt(index);
    }
  }
  //...
}

Window 객체를 WindowIterator을 통해 사용하면 Window를 좀더 간단하게 순회할 수 있다. 간단한 예를 보자.

val members = WindowIterator.of{memberRepo.findFirst5ByNameNotNull(it)}.startingAt(ScrollPosition.offset())
while(members.hasNext()) {
  val member = members.next()
  //...
}

WindowIterator 객체를 사용해서 더 간단하게 데이터를 순회할 수 있게 된다.

Offset Scrolling

오프셋(Offset) 스크롤은 페이징과 비슷하지만 조금 다르다. 결과에서 일정 개수를 건너뛰어서 시작할때 사용한다. 이는 큰 데이터를 한꺼번에 보내지 않게 하지만, 리턴하기 전에 데이터베이스에서는 전체를 구체화해야 한다. 오프셋 스크롤를 사용하기 위해 메소드 인자도 OffsetScrollPosition 타입으로 변경해야 한다.

fun findFirst5ByNameNotNull(pos: OffsetScrollPosition): Window<Member>

다음 처럼 사용된다.

val members = WindowIterator.of{memberRepo.findFirst5ByNameNotNull(it)}.startingAt(OffsetScrollPosition.initial())

Keyset-Filtering Scrolling

키셋-필터링(Keyset-Filtering)은 페이징 구현하는 방법으로 데이터베이스의 내장된 기능을 사용해 연산과 I/O를 줄이는 목적으로 접근한다. 이는 오프셋을 사용하지 않고 키집합을 관리해서 쿼리에 키들을 전달하여 페이징처리한다. 핵심은 안정된 정렬 순서를 사용하는데에서 시작한다. 다음 페이지를 가져오기 위해 Window를 통해 최근 결과의 마지막 위치 키셋을 캡처한 ScrollPositon를 획득한다. 데이터베이스가 완전한 큰 데이터셋을 구할 필요 없이 매우 작은 데이터셋을 구성하여 특정 오프셋까지 결과를 건너뛸 수 있다. 결국 매우 큰 데이터에 대해 빠른 페이징 처리와 데이터 추가/삭제가 되도 일관된 결과를 얻을 수 있다. 이를 위해 메소드에 인자를 KeysetScrollPosition 타입을 사용한다.

fun findFirst5ByNameNotNull(pos: KeysetScrollPosition): Window<Member>

실제 순회하는 방식은 다음 처럼 사용하면 된다.

val members = WindowIterator.of{memberRepo.findFirst5ByNameNotNull(it)}.startingAt(ScrollPosition.keyset())

키셋-필터링은 정렬 필드와 일치되는 인덱스가 포함된 경우 최적이다. 이를 적용한 쿼리는 리턴에 사용되기 위해 속성이 순서 정렬에 사용되어야 한다. 그렇기 때문에 데이터베이스의 특정 컬럼은 유니크하고 연속적이어야 하고 인덱스가 잘 구성되어야 한다. 이해를 돕기 위해서 이를 SQL로 작성된다면 다음과 같은 형태가 될 수 있다.

SELECT *
FROM member
WHERE id > {offset}
ORDER BY id ASC
LIMIT 5

쓰기 지연

쓰기 지연(write-behind)은 영속 컨텍스트를 즉시 데이베이스와 동기화하지 않고 트랜잭션 커밋 시점에 데이터베이스에 반영하도록 쓰기를 지연시키는 기능이다. 이를 통해 한번에 변경 작업이 처리되므로 좀더 성능을 최적화할 수 있다. 이는 SQL에서도 동일하다. JDBC인 경우는 SQL 배치 처리 기능이 있다. JPA에서는 @Transactional을 사용해 함수를 트랜잭션 범위를 지정해 리턴시 플러시할 수 있다. 이와 관련해서 쓰기 지연 설정을 할 수 있다. “hibernate.flushMode”로 쓰기 작업을 언제 데이터베이스에 반영될지 설정할 수 있다. 설정 가능한 값은 “AUTO”, “COMMIT”, “ALWAYS”, “MANUAL”이다. “AUTO”는 쿼리 실행전이나 커밋 전에 동작하고 “COMMIT”은 커밋 전에 실행된다. “ALWAYS”은 쿼리 실행전에 항상 동작하고, “MANUAL”은 명시적으로 flush() 호출할때만 실행한다. 이 설정은 별다른 이유가 없다면 특별히 변경할 필요는 없다.

spring.jpa.properties.hibernate.flushMode = COMMIT

다음은 배치 크기 설정으로 한번에 전달되서 처리될 SQL 쿼리 작업 개수를 설정할 수 있다. 데이터베이스 호출회수가 줄어들어 성능을 최적화할 수 있고 롤백시 좀더 유리하다. 보통 10-50개 정도 사용되지만 환경에 따라 최적 값을 선택한다.

spring.jpa.properties.hibernate.jdbc.batch_size = 10

주의할 부분은 JDBC 드라이버 지원 여부에 따라 배치 처리가 무시 될 수 있다.

추가로 INSERT 문을 실행할 때 최적화 순서로 정렬해서 실행할 수 있는 설정이 있다. “hibernate.order_inserts” 설정으로 “true”인 경우 동일 엔티티의 INSERT 문을 모아서 한꺼번에 처리하여 성능을 향상한다. 한번에 INSERT 처리하기에 데이터 추가 효율성, 인덱스 갱신, 페이지 스플릿(Page Split) 부하를 줄일 수 있는 장점이 있다.

spring.jpa.properties.hibernate.order_inserts = true

마무리

JPA 최적화에 대해서 간단하게 살펴보았다. 최적화는 튜닝 작업에 있어서 중요한 부분으로 이외에 대 다양한 방법이 있을 수 있다. 물론 최적화 전에 해당 부분에 문제가 인지되고 어떤 원인으로 발생했는지 확인해서 필요한 방법을 적절하게 적용해야 한다. JPA에 복잡하고 학습곡선이 가파르기 때문에 JPA가 제대로 사용하기 더 힘들어지는 이유이기도 하다.

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

참고

[1] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015
[2] Hibernate ORM User Guide, https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html

[3] Suraj Mishra, Scroll API in Spring Data JPA, https://www.baeldung.com/spring-data-jpa-scroll-api, 2023.09.05

[4] Oliver Gierke 외 5인, JPA Query Methods, https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html

반응형