Spring Data JPA란?
Spring Data JPA란, JPA 기반의 데이터 접근 계층을 쉽게 구현하도록 돕는 프레임워크이다. Spring Data JPA는 단순히 JPA 스펙을 이용할 때 직접 엔티티 매니저를 획득하고 쿼리를 작성 및 실행하는 등의 로직을 반복 구현해주어야 하던 귀찮음을 대폭 감소시켜주었다.
그러나 Spring Data JPA가 가져다주는 편리함의 이면에는 수많은 추상화가 숨어 있다. 이들은 반복 로직을 생략하여 코드를 간소화하는 것을 돕지만, 제대로 된 이해 없이 사용하면 의도하지 않은 동작을 야기할 수 있다.
이번 글에서는 Spring Data JPA를 사용할 때 발생할 수 있는 4가지의 Side-Effect를 살펴보고 이들을 예방하기 위해 알아야 할 지식들에 대해 정리해보고자 한다.
Spring Data JPA를 사용할 때 주의할 점
1. N+1
N+1은 Spring Data JPA를 사용할 때 발생할 수 있는 가장 대표적인 문제로, N개의 데이터를 1개의 쿼리로 처리하려고 했으나 데이터의 개수만큼의 추가 쿼리가 발생해 총 N+1개의 쿼리가 발생하는 문제를 말한다.
N+1 문제가 발생하는 원인은 JPA에서 객체가 연관 관계의 객체를 탐색하는 방법에 있다. 이를 이해하기 위해, 아래와 같은 일대다 관계의 두 엔티티를 조회하는 과정을 살펴보자.
하나의 A 레코드와 A의 PK를 FK로 갖는 B 레코드들을 조회하기 위해, 데이터 중심의 쿼리는 아래와 같이 실행된다.
SELECT a.*, b.*
FROM a
JOIN b ON a.id = b.a_id
WHERE ...
그러나 객체 중심의 쿼리는 아래와 같이 실행된다. A와 연관 관계인 B의 데이터 수만큼 추가 쿼리가 발생하는 것이다.
-- 먼저 A를 조회
SELECT *
FROM a
WHERE ...
-- 조회해야 하는 B들을 각각 조회
SELECT *
FROM b
WHERE id = 1;
SELECT *
FROM b
WHERE id = 2;
...
그 이유는 데이터 중심 패러다임에만 존재하는 JOIN을 객체 중심으로 풀어내기 위해 모든 B 데이터를 조회하기 위한 객체의 참조 기반의 객체 그래프 탐색을 이용하기 때문이다. 하나의 연관 객체를 참조할 때마다 하나의 조회 쿼리를 실행시키니, 총 N개의 추가 쿼리가 발생하게 되는 것이다.
그렇다면 이러한 N+1 문제는 어떻게 해결해야 할까? 이 문제의 해결법은 해당 문제가 발생한 조회 쿼리의 특성에 따라 다르다.
연관 관계 데이터를 조회할 필요가 없는 경우라면 Lazy-loading을 통해 간단하게 해결할 수 있고, 단일 join이며 paging이 필요 없는 데이터 조회라면 Fetch join을 통해 간단하게 해결할 수 있다.
만약 위의 두 경우에 해당하지 않는다면(연관 데이터를 조회해야 하고, join이 여러 개이거나 paging이 필요한 경우) Entity graph를 통해 특정 상황에 특정 쿼리가 실행되도록 직접 지정해주거나, Querydsl JPA 같은 프레임워크의 도입을 통해 해결할 수도 있다.
나는 개인적으로 Fetch join이나 Entity graph를 사용하기보다는 Querydsl JPA를 도입하여 N+1 문제를 회피하는 편인데, 그 이유는 아래와 같다.
- Fetch join을 사용하지 않는 이유 - 추후 join 대상이 추가될 수도 있고, 데이터 수가 많아져 paging이 도입될 수도 있기 때문
- Entity graph를 사용하지 않는 이유 - 쿼리를 직접 적어줘야 해서 type-safe가 깨지게 되고, 코드 가독성 면에서도 좋지 않다고 느낌
2. Dirty checking에 의한 의도하지 않은 업데이트
Dirty checking은 영속성 컨텍스트의 관리를 받는 엔티티의 변경 사항을 커밋 시점에 자동 반영해주는 기능이다. 명시적으로 업데이트를 해주지 않아도 자동으로 변경이 반영된다는 것이 마냥 편리해보일 수 있지만 아래의 상황에 유의해야 한다.
- 엔티티를 인자로 넘기는 경우 의도하지 않은 변경에 유의
엔티티를 넘겨 받은 곳에서 의도하지 않은 변경을 발생시킬 수 있다. 이 경우 어디서 변경했는지에 대한 추적이 어려워질 수도 있다. 따라서, 다른 메서드에 엔티티의 데이터를 전달하고자 할 때에는 read-only임이 명확한 경우가 아니라면 엔티티 대신 별도 값 객체를 만들어 전달하도록 하는 편이 좋다. - 여러 개의 업데이트 쿼리가 발생할 수 있음에 유의
리스트에 담긴 엔티티의 값을 일괄 변경하며 여러 개의 업데이트 쿼리가 발생할 수 있다. 이 경우에는 문제라기보다는 최적화 포인트라고 볼 수 있겠다. 하나의 업데이트 쿼리로 줄일 수 있는 경우 줄이면 좋다.
3. JPQL을 통한 Bulk update시 영속성 컨텍스트의 데이터는 갱신되지 않음에 유의
bulk update의 경우, 실행 시점에 바로 쿼리를 실행해 변경을 반영한다. 따라서, 이미 영속성 컨텍스트에 저장되어 있는 엔티티와 매핑되는 레코드를 업데이트하는 경우 컨텍스트 내 엔티티 정보는 업데이트되지 않음에 유의해야 한다.
즉, 정리하자면 아래와 같이 동작한다.
- Person(name=”park”, age=30) 이라는 레코드를 조회해 영속성 컨텍스트에 저장한다.
- bulk update 과정에서 park의 age가 31로 업데이트된다.
- 2번의 업데이트는 즉시 DB에 쿼리를 실행하는 방식으로 이루어지므로, 1번에서 저장한 컨텍스트의 age 값은 여전히 30이다.
- 영속성 컨텍스트에서 park의 데이터를 조회해 사용할 경우, age가 30인(DB의 상태와 다른) 데이터를 조회해 사용하게 된다.
JPQL 기반의 bulk update의 동작 과정을 잘 모르는 개발자라면 4번 과정에서 업데이트된 엔티티를 조회해왔다고 가정하고 비즈니스 로직을 작성할 수 있다. 이 경우 4번에서 조회한 엔티티를 기반으로 dirty checking이 이루어지게 되면 bulk update에 의해 업데이트된 정보가 손실될 수 있음에 유의해야 한다.
4. 영속성 컨텍스트의 Scope 관리에 유의 (OSIV)
영속성 컨텍스트의 default 생명주기는 무엇일까? 바로, Request Scope이다. 별도 설정을 하지 않으면 영속성 컨텍스트는 HTTP 요청을 수신할 때 생성되고, HTTP 응답을 반환할 때 제거된다. 이 동작을 Open Session In View(OSIV) 패턴이라고 부르며, Spring Boot 기준으로 아래 환경변수를 기준으로 on-off 설정을 할 수 있다.
spring:
jpa:
open-in-view: true/false
기본적으로 이 값이 true이기 때문에 영속성 컨텍스트는 HTTP 생명주기를 같이하며, 이 값을 false로 설정하면 트랜잭션과 생명주기를 같이 한다.
그런데 영속성 컨텍스트는 트랜잭션 범위에서만 유지되면 될 것 같은데, 왜 기본적으로 이렇게 동작할까? 이는 view 계층에서 엔티티의 연관 데이터를 조회하는 과정에서 Lazy-loading을 이용할 수 있도록 하기 위함이다. 반대로 말하자면, 컨트롤러 계층에서 Lazy-loading을 이용할 것이 아니라면 불필요하게 이 옵션을 true로 유지할 필요가 없어진다.
OSIV를 사용하면 아래와 같은 side-effect의 발생에 유의해야 한다.
- DB 커넥션을 점유하고 있는 시간이 증가
트래픽이 크지 않은 서비스에서는 문제가 되지 않을 수 있지만, 트래픽이 많은 경우 커넥션 점유 시간이 불필요하게 길어지며 DB 커넥션이 부족해지는 문제가 발생할 수 있다. DB 커넥션은 비싼 자원이기 때문에 최대한 효율적으로 사용하는 것이 좋다는 면에서, 커넥션 풀 사이즈를 늘리는 것보다는 해당 옵션을 비활성화하는 것이 더 좋은 해결책이 되지 않을지 고민해보자. - View 계층에서 N+1 문제 발생 가능
View 계층에서 Lazy-loading을 사용하면서 불필요한 조회 쿼리가 발생하지 않는지 잘 확인해야 한다.
이 점에 유의하여 해당 옵션을 활성화할지 여부를 잘 결정해야 한다.
마치며
본 글에서는 실무에서 JPA를 사용하며 겪었던 문제들을 추적하는 과정에서 깨닫게 된 사실들을 기반으로 JPA를 사용할 때 주의할 점들을 정리해보았다.
본 글에서 언급한 문제들은 JPA가 지향하는 패러다임 자체가 내포하는 문제이거나, Hibernate/Spring Data JPA의 구현 방식에 따라 발생하는 문제이다. 이러한 문제들을 적절히 제어하고 애플리케이션 특성에 맞는 데이터 접근 계층을 구현하기 위해서는 JPA, Hibernate, Spring Data JPA 각각이 제공하는 기능들에 대한 이해를 갖추는 것이 좋을 듯하다.