[번역] Batch Fetching - 객체 그래프 로딩을 최적화 하기 (JPA/ECLIPSELINK)

2017-11-21

자주 들여다 보는 문서라 아예 번역해 봤습니다.

객체 지향적인 객체 모델과, 관계 DB 관계 모델간의 가장 큰 임피던스(impedance) 차이를 보이는 부분은 데이터에 접근하는 방식이다.

관계 모델에서는, 특정 유스케이스나 서비스 요청에 적합한 데이터를 모든 데이터를 조인 걸어서 가져 오는 단일 한방 쿼리를 작성한다. 가져와야 하는 데이터가 복잡할수록 쿼리도 복잡해진다. 최적화는 불필요한 조인을 제거하고, 중복 데이터를 가져오는 것을 제거하는 것으로 수행된다.

객체 모델에서는 객체 하나 또는 몇개의 세트를 얻어 온후, 객체의 관계를 추적(traverse)하여 데이터를 수집한다.

객체-관계-매퍼(ORM/JPA)를 사용하면 일반적으로 여러번의 쿼리가 실행되게 되며, 이것은 "N queries"라고 알려진 문제로 발전하게 된다. 또는 결과 세트(Result set)안에 포함된 객체 마다 별개의 쿼리를 실행하게 된다.

예제로써 EMPLOYEE, ADDRESS, PHONE 테이블을 가지고 생각해자.

EMPLOYEE는 ADDRESS ADDRESS_ID로 향하는 외부키 ADDR_ID를 가진다. PHONE은 EMPLOYEE EMP_ID로 향하는 외부키 EMP_ID를 가진다.

파트타임 직원 목록을 주소와 전화번호를 포함하여 표시하고자 할때 다음과 같은 SQL을 작성할것이다.

Big database query(한방쿼리)

SELECT E.*, A.*, P.* FROM EMPLOYEE E, ADDRESS A, PHONE P WHERE E.ADDR_ID = A.ADDRESS_ID AND E.EMP_ID = P.OWNER_ID AND E.STATUS = 'Part-time'

이렇게 나온 데이터는 각 직원 마다 전화 번호를 출력하깅 위해 그루핑한후 포메팅해야 한다.

ID Name Address Phone

6578

Bob Jones

17 Mountainview Dr., Ottawa

519-456-1111, 613-798-2222

7890

Jill Betty

606 Hurdman Ave., Ottawa

613-711-4566, 613-223-5678

위 예에 대응하는 객체 모델은 Employy, Address, Phone 클래스를 정의한다.

JPA에서 Employee는 Address와 OneToOne 관계이고, Phone과 OneToMany 관계이다. Phone은 Employee와 ManyToOne 관계이다.

파트타임 직원 목록을 주소와 전화번호를 포함하여 표시하고자 할때 다음과 같은 JPQL을 작성할것이다.

Simple JPQL

Select e from Employee e where e.status = 'Part-time'

직원 정보를 출력하기 위해서는 Employee에서 Address를 가져오고, 마찬가지로 모든 Phone을 가져오면된다. 출력된 결과는 SQL 버전과 동일하지만, JPA를 통해 생성된 SQL은 매우 다르다.

ID Name Address Phone

6578

Bob Jones

17 Mountainview Dr., Ottawa

519-456-1111, 613-798-2222

7890

Jill Betty

606 Hurdman Ave., Ottawa

613-711-4566, 613-223-5678

N+1 queries problem

SELECT E.* FROM EMPLOYEE E WHERE E.STATUS = 'Part-time'

이 실행된 다음 N개(선택된 employee의 수)만큼의 Address를 select한다.

SELECT A.* FROM ADDRESS A WHERE A.ADDRESS_ID = 123
SELECT A.* FROM ADDRESS A WHERE A.ADDRESS_ID = 456

이 실행된 다음 N개(선택된 employee의 수)만큼의 Phone를 select한다.

SELECT P.* FROM PHONE P WHERE P.OWNER_ID = 789
SELECT P.* FROM PHONE P WHERE P.OWNER_ID = 135

이것은 상당히 끔찍한 성능을 보여줄 것이다.(모든 객체가 메모리에 이미 캐쉬 되어 있지 않는한). JPA에서 이 문제를 해결하는 방법이 여럿 있다. 가장 흔하게 사용되는 방식은 Join fetching 이다. join fetch는 객체, 그 객체와 연관된 객체를 단일 쿼리를 통해 fetch하는 것이다. JPQL에서 이것은 꽤나 쉽게 할수 있고, Join을 정의 하는 것과 유사하다.

JPQL with join fetch

Select e from Employee e join fetch e.address, join fetch e.phones where e.status = 'Part-time'

이 JPQL은 SQL 버전과 동일한 SQL을 생성해 낸다.

SQL for JPQL with join fetch

SELECT E.*, A.*, P.* FROM EMPLOYEE E, ADDRESS A, PHONE P WHERE E.ADDR_ID = A.ADDRESS_ID AND E.EMP_ID = P.OWNER_ID AND E.STATUS = 'Part-time'

이 코드는 동일한 결과를 보여 줄수 있으면서, 객체는 더욱 효율적으로 읽어오게 된다.

JPA는 join fetch를 JPQL을 통해서만 지정할수 있으나, Eclipselink는 @JoinFetch 어노테이션을 이용해 특정 관계에 대해 항상 Join Fetch를 수행하게 할수 있다. 다른 JPA 구현체들 중에는 EAGER 관계의 객체를 항상 join fetch하는 것도 있는데, 이것은 일견 좋은 아이디어 인거 같지만, 실제로는 매우 나쁜 아이디어이다. EARGER는 관계를 읽어 와야 하는 가를 정의하지, 데이터베이스에 어떻게 접근하느냐를 정의하는 것이 아니다.

어떤 사용자가 객체의 모든 관계를 읽어 오게 하려고 할때, 만약 모든 관계를 join fetch를 이용해 가져온다면, 모든 ToMany 관계에 대한 거대한 조인(Outer 조인이 사용된다)을 걸게 되고, 거대한 중복 데이터를 읽어고게 된다. 또한 parent, owner, manager 같은 ManyToOne 관계는 원래 공유 객체여야 하는데(보통은 cache된), join fech는 모든 child에 대해 중복된 부모 객체를 생성하게 되며, 이런 동작은 별개의 독리된 쿼리를 던지는 것(또는 캐쉬된)보다 나쁜 성능을 낼수 있다.

JPA는 join fetch에 대한 aliasing을 지원하지 않기때문에, 추가적으로 가져오고자 하는 관계에 대해 두번 조인해 주어야 한다. JPA 내부적으로 ToOne 관계에 대해서는 단일 조인으로 최적화되다. ToMany에 대해서는 진짜로 두번째 Join이 필요하며 이 조인은 연관된 객체를 필터링하기 위해 사용된다. 어떤 JPA 구현체는 join fetch 에 대한 alias를 지원하지만, JPA 표준에서는 허용하지 않고, eclipselink도 아직 지원하지 않는다.

중첩된(Nested) join fetch는 JPQL에서 직접 지원하지 않는다. eclipselink는 중첩된 join fetch를 지원하는데, 쿼리 힌트인 "eclipselink.join-fetch"와 "eclipselink.left-join-fetch" 를 이용하면 된다.

Nested join fetch query hint

query.setHint("eclipselink.join-fetch", "e.projects.milestones");

Join Fetch는 여러 경우에 최고의 솔류션이지만, 대단히 관계 DB 중심의 접근이기도 하다. 더 창의적이고 객체 지향적은 솔류션은 Batch Fetching 이다. Batch Fetching은 전통적인 관계중심의 사고방식으로는 더 어렵게 느껴지겠지만, 일단 이해하고 나면 훨씬 강력하다.

JPA는 join fetch만 정의하고 있다. batch fetching 을 사용하기 위해서는 eclipselink의 쿼리힌트 "eclipselink.batch"를 사용한다.

Batch fetch query hint

query.setHint("eclipselink.batch", "e.address");
query.setHint("eclipselink.batch", "e.phones");

batch fetch 를 실행해도 원래 쿼리가 실행된다. (단순히 employee만 가져오는). 차이점은 연관된 객체를 가져오는 방식에 있다. 일단 employee의 목록을 얻어온후 어느 한 employee의 address를 사용하기 위해 접근할때, 오직 앞서 SELECTED 된 모든 employee들의 모든 address를 가져온다. batch fetching에는 여러 유형이 있으며, JOIN 타입의 batch fetch의 SQL은 다음과 같이 생성된다.

SQL for batch fetch (JOIN)

SELECT E.* FROM EMPLOYEE E WHERE E.STATUS = 'Part-time'
SELECT A.* FROM EMPLOYEE E, ADDRESS A WHERE E.ADDR_ID = A.ADDRESS_ID AND E.STATUS = 'Part-time'
SELECT P.* FROM EMPLOYEE E, PHONE P WHERE E.EMP_ID = P.OWNER_ID AND E.STATUS = 'Part-time'

처음 볼때 join fetch를 사용한 1개의 sql 대신 3개의 sql이 실행된것을 볼수 있다. 3개의 쿼리가 실행 되었으니 더 느릴 것이라고 생각하기 쉽지만, 실제로는 대부분의 경우에 더 빠르다. 1번 과 3번의 select의 차이는 거의 없다 (Pretty minimal). 최적화 되지 않은 예전 경우의 가장 큰 이슈가 N 번의 쿼리가 실행되는 것이였고, 그때는 차이가 100초가 1000초가 될수도 있었다.

batch fetching의 주요한 장점은 오직 필요한 정보만 select 된다는 것이다. join fetch의 경우, EMPLOYEE와 ADDRESS 의 데이터가 PHONE의 결과마다 중복 된다는 것이였다. (역자주: 별도의 group by 나 distinct 를 주어서 해결할수도 있지만, 쿼리가 느려진다. ) ToMany 관계에서 이런 현상이 발생하며, 만약 여러개의 또는 중첩된 ToMany 관계를 읽어오려고 하면 얼어붙은것 처럼 될것이다. 예를 들어 직원의 프로젝트 목록을 join fetch하고, 각 프로젝트의 milestone을 join fetch할때, 직원당 5개의 프로젝트가 있고 프로젝트당 10개의 마일스톤이 있다고 하면 직원정보는 50배 중복된다(프로젝트 정보는 10배 중복된다) 복잡한 객체 모델이라면 이런 현상은 큰 이슈가 된다.

join fetching은 직원이 주소나 전화번호가 없는 경우를 처리 하기 위해 일반적으로 outer 조인을 사용해야 하다. outer 조인은 DB에서 일반적으로 비효율적으로 처리되며, 결과에 null인 row를 추가한다.

batch fetching은 만약 직원이 주소나 전화번호가 없다면, 그냥 batch fetch 결과에 없을 뿐이고 훨씬 적은 데이터를 읽어 오게 된다. Batch fetching을 이용하면 ManyToOne 관계에서 distinct를 허용한다. 예를 들어 직원의 관리자가 batch fetch 된다면, distinct 는 유니크한 관리자가 select 되게 하고, 중복된 데이터를 읽어 오지 않게 한다.

JOIN batch fetch의 단점은 원래 쿼리가 여러번 실행 된다는 것이다. 만약 원래 쿼리가 비용이 비싼 쿼리라면 join fetch가 더 효율적일수 있다. 또다른 경우인, 단 하나의 결과만 select 한다면, batch fetch는 아무런 이익을 제공해주지 못하지만 join fetch는 쿼리를 단 한번만 실행하기 때문에 실행할 쿼리를 줄여줄수 있다.

Batch Fetching은 여러 형태가 있는데 Eclipselink 2.1에서는 JOIN, EXISTS,IN 3가지 타입을 지원한다. (BatchFetchType enum에 정의 되어 있다.) batch fetch 타입의 지정은 쿼리 힌트 "eclipselink.batch.type"을 통해 지정할수 있다. 관계에 대해 상상 batch fetching을 사용하고자 한다면 @BatchFetch 어노테이션을 붙여 주면 된다.

Batch fetch query hints and annotations

query.setHint("eclipselink.batch.type", "EXISTS");
@BatchFetch(type=BatchFetchType.EXISTS)

EXISTS 옵션은 JOIN 옵션관 비슷하지만, Join 대신 exists와 sub-select 를 사용한다. 이 옵션의 장점은 lob이나 복잡한 쿼리를 에서 distinct 를 사용할 필요가 없다는 것이다.

SQL for batch fetch (EXISTS)

SELECT E.* FROM EMPLOYEE E WHERE E.STATUS = 'Part-time'
SELECT A.* FROM ADDRESS A WHERE EXISTS (SELECT E.EMP_ID FROM EMPLOYEE E WHERE E.ADDR_ID = A.ADDRESS_ID AND E.STATUS = 'Part-time')
SELECT P.* FROM PHONE P WHERE EXISTS (SELECT E.EMP_ID FROM EMPLOYEE E, WHERE E.EMP_ID = P.OWNER_ID AND E.STATUS = 'Part-time')

IN 옵션은 JOIN이나 EXISTS옵션과 많이 다르다. IN 옵션은 원래 select 쿼리를 사용하지 않고, 대신 객체의 ID를 IN 구절을 이용해 사용한다. IN 옵션의 장점은 원래 쿼리를 다시 실행할 필요가 없다는 것이고, 이것은 원래 쿼리가 복잡할수록 큰 이득을 준다. IN 옵션은 페이징 기능도 지원하며, 다른 옵션이 무조건 전체 객체를 읽어 와야 하는 것에 비해 커서의 사용도 잘 지원한다. Eclipselink에서 IN 옵션은 캐쉬도 지원하기 때문에 캐쉬에 없는 것만 IN에 넣어 가져 오기 때문에 더 적은 분량만 읽어 오게 된다.

IN 옵션의 이슈는 이미 읽어온 세트가 너무 많으면 IN 구절이 너무 커져서 DB에서 처리하는데 비효율적이 될수 있다는 것이다. 그리고 복합키가 문제가 될수 있다. Eclipselink는 in 구절에서 복합키를 지원하하며 , Oracle같은 DB는 SQL 의 in 구절에서 복합키를 지원하지만 다른 DB에서는 IN 구절에서 복합키를 지원하지 않는 경우도 있다. IN 옵션은 또한 SQL의 IN 파트가 동적으로 생성되는것이 지원되는 DB여야 한다.

SQL for batch fetch (IN)

SELECT E.* FROM EMPLOYEE E WHERE E.STATUS = 'Part-time'
SELECT A.* FROM ADDRESS A WHERE A.ADDRESS_ID IN (1, 2, 5, ...)
SELECT P.* FROM PHONE P WHERE P.OWNER_ID IN (12, 10, 30, ...)

Batch fetching은 쿼리 힌트에 점(.) 표기를 이용해 중첩시킬수 있다.

Nested batch fetch query hint

query.setHint("eclipselink.batch", "e.projects.milestones");

Batch fetching의 기능중 join fetch에서 제공되지 않는 것중에 하나가 바로 최적화된 트리 구조 읽기이다. 만약 트리구조에서 자식 관계에 @BatchFetch를 설정하면, 단일 쿼리가 각 레벨을 위해 실행된다.

자, 여태까지 설명한 내용이 무엇을 의미할까? 흠, 모든 환경과 유스케이스는 서로 다르며, 그렇기 때문에 모든 경우에 적합한 완전한 해결책은 없다는 것이다. 서로 다른 쿼리 최적화는 서로 다른 상황에 적합하다. 다음에 설명한 벤치마크 결과는 앞서 설명한 방식들의 잠재적인 성능 향상을 보여줄 것이다.

다음 벤치마크 결과는 로컬 네트워크 상의 저렴한 하드웨어에서 오라클을 java se 환경에서 단일 쓰래드로 구동시켜 나온 결과이다. 각 테스트는 60초간 실행 되었고, 실행된 횟수를 기록했다. 각 테스트는 5번 반복 했다. 최소/최대 값은 제외 했다. 숫자 자체는 별 의미가 없으며 각 방식에 대한 % 차이를 살펴 보기 바란다.

벤치마크에서는 최적화되지 않은 쿼리, join fetching, batch fetching을 적용했다.

Simple run (fetch address, phoneNumbers)

Query Average (queries/minute) %STD %DIF (of standard)

standard

5897

0.5%

0

join fetch

14024

1.1%

+137%

batch fetch (JOIN)

11190

4.5%

+89%

batch fetch (EXISTS)

13764

0.4%

+133%

batch fetch (IN)

14341

0.6%

+143%

첫번쨰 결과만 보면 join fetch가 상당히 좋아 보인다. 첫번째 테스트에서는 2개의 관계만 읽어 왔다. 더 많은 관계를 읽어 오면 어떻게 될까?

Complex run (fetch address, phoneNumbers, projects, manager, managedEmployees, emailAddresses, responsibilities, jobTitle, degrees)

Query Average (queries/minute) %STD %DIF (of standard)

standard

1438

0.7%

0%

join fetch

1121

0.4%

-22%

batch fetch (JOIN)

3395

3.8%

+136%

batch fetch (EXISTS)

3768

2.6%

+162%

batch fetch (IN)

3893

0.5%

+170%

두번째 테스트를 보면 join fetch의 문제점이 들어 난다. join fetch는 실제로는 최적화 되지 않는 쿼리보다 느려진다. (-22%). 이건 본질적으로 여러 테이블을 조인하면 더 많은 데이터를 가져와서 처리해야 하기 때문이다. 반면에 batch fetch는 여전히 우월한 성능을 보여준다.

Tags: jpa   eclipselink   fetch   batch fetch