본 글은 인프런 김영한님의 JPA 로드맵을 기반으로 정리했습니다.
모든 객체는 서로 참조를 통해 마치 그래프처럼 연결되있다. 자바에서 .(점)을 찍어서 연결된 객체로 이동할 수 있다.
엔티티도 객체기 때문에 마찬가지다. 엔티티들은 연관관계를 통해 객체 그래프를 이룬다. JPQL에서도 경로 표현식을 통해 객체 그래프를 탐색할 수 있다.
경로 표현식은 마치 자바에서 .(점)을 통해 연관된 객체로 이동하듯이 JPQL에서 객체(엔티티) 그래프를 탐색하는 문법이다. 주로 select, where 절에서 사용한다.
경로 표현식에서 .(점)을 통해 이동하는 필드는 3가지로 분류할 수 있다.
JPQL
select m.username from Member m
SQL
SELECT m.username FROM member m
문자열이나 숫자처럼 단순히 값을 저장하는 필드. 단순값이기 때문에 더 이상 탐색이 불가능하다.
JPQL
select m.team from Member m
SQL
SELECT m.*
FROM member m
INNER JOIN team t
ON m.team_id = team.id
@OneToOne
, @ManyToOne
을 통해 연관관계를 맺은 필드. 탐색 결과는 엔티티다. 엔티티기 때문에 추가 탐색이 가능하다. 묵시적 내부 조인이 발생한다.
JPQL
select m.orders from Member m
SQL
SELECT o.*
FROM member m
INNER JOIN orders o
ON m.id = o.member_id
@OneToMany
, @ManyToMany
을 통해 연관관계를 맺은 필드. 탐색 결과는 컬렉션이다. 컬렉션이기 때문에 추가 탐색이 불가능하다. 묵시적 내부 조인이 발생한다.
컬렉션을 from절에서 명시적 조인하고 별칭(alias)을 붙이면 추가 탐색이 가능하긴 하지만 권장하지 않는다.
연관된 엔티티를 경로 탐색하면 단일 엔티티, 컬렉션에 관계 없이 묵시적 내부 조인이 발생한다. 묵시적 내부 조인은 JPQL에는 조인이 명시되있지 않지만 SQL에서는 조인이 생기는 것이다. 연관된 엔티티는 다른 테이블에 저장되어있기 때문에 당연히 조인을 통해 가져와야한다.
묵시적 조인은 항상 내부 조인이 되는 한계가 있다. 더 큰 문제는 JPQL에 명시되어 있지 않은 조인이 SQL에 생기기 때문에 쿼리 튜닝이 힘들어진다는 것이다. 조인은 SQL에서 중요한 튜닝 포인트 중 하나다. JPQL은 변환되는 SQL과 최대한 모양을 비슷하게 맞춰주는 것이 유지보수하기에 좋다.
묵시적 조인 대신 명시적 조인을 사용하자. 명시적 조인을 사용하면 JPQL이 변환되는 SQL과 모양이 비슷해지고 외부 조인 또한 가능하다.
select m.username -> 상태 필드
from Member m
join m.team t -> 단일 값 연관 필드 명시적 조인
join m.orders o -> 컬렉션 값 연관 필드 명시적 조인
where t.name = '팀A'
N+1 문제는 1번의 쿼리 이후 연관된 엔티티 N개의 데이터를 찾기 위해 최대 N번의 추가 쿼리가 나가는 문제다. N+1 문제는 성능에 막대한 불이익을 주기 때문에 반드시 해결해야한다.
N+1 문제가 발생하는 다양한 경우를 살펴보자.
다대일 관계를 즉시 로딩으로 설정한 경우 (기본값)
em.createQuery("select m from Member m")
.getResultList();
JPQL은 SQL로 그대로 번역된다. JPQL은 연관된 엔티티를 고려하지 않는다. 때문에 위의 JPQL은 team 테이블의 데이터를 가져오지 않는다.
문제는 Member에서 Team을 즉시 로딩으로 설정했다는 것이다. 만약 처음 쿼리로 100명의 Member가 조회되면 연관된 Team 프록시를 초기화하기 위해 최대 100번의 추가 쿼리가 발생한다.
참고로 최대 100번인 이유는 만약 중복된 Team 엔티티가 있다면 1차 캐시에서 바로 조회할 수 있기 때문이다.
다대일 관계를 지연 로딩으로 설정한 경우
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
for (Member member : members) {
member.getTeam().getName(); // 프록시 초기화
}
N+1 문제는 즉시 로딩, 지연 로딩 상관 없이 발생한다. 위의 경우 Member에서 Team을 지연 로딩으로 설정했기 때문에 Tean 엔티티는 초기화되지 않은 프록시가 된다. 바로 N번의 추가 쿼리가 나가지는 않지만 Member 엔티티들이 Team의 데이터를 요구하면 프록시를 초기화하기 위해 최대 N번의 추가 쿼리가 나가게 된다.
일대다 관계를 지연 로딩으로 설정한 경우 (기본값)
Team team = em.createQuery("select t from Team t where t.name = 'team A'", Team.class)
.getSingleResult();
for (Member member : team.getMembers()) {
member.getUsername(); // 프록시 초기화
}
N+1 문제는 컬렉션에서도 발생할 수 있다. 일대다 연관관계는 기본값이 지연 로딩이기 때문에 만약 Team에 연관된 Member가 100명이라면 프록시를 초기화하기 위해 100번의 추가 쿼리가 나간다.
페치 조인을 사용하면 연관된 엔티티나 컬렉션을 SQL 한 번으로 조회한다. 연관된 엔티티까지 프록시가 아닌 실제 엔티티로 조회하기 때문에 N+1 문제가 발생하지 않는다. 참고로, 페치 조인은 SQL의 조인 종류가 아니다. 성능 최적화를 위해 사용하는 JPQL의 특별한 기능이다.
예제를 통해 더 자세히 살펴보자.
일반 조인부터 알아보자.
JPQL
select m
from Member m
join m.team
SQL
SELECT m.*
FROM member m
INNER JOIN team t ON m.team_id=t.id
Member에서 일대다 연관관계를 가지는 Team을 일반 조인했다. 번역된 SQL을 보면 연관된 team 테이블의 데이터는 조회하지 않는다.
연관된 하나의 엔티티를 페치 조인하는 경우다.
JPQL
select m
from Member m
join fetch m.team
SQL
SELECT m.*, t.*
FROM member m
INNER JOIN team t ON m.team_id=t.id
Member에서 다대일 연관관계를 가지는 Team을 페치 조인했다. join 키워드 다음에 fetch 키워드를 넣으면 페치 조인이 된다. 번역된 SQL을 보면 연관된 team 테이블의 데이터도 함께 조회한다.
연관된 여러개의 엔티티 컬렉션을 페치 조인하는 경우다.
JPQL
select distinct t
from Team t
join fetch t.members
where t.name = 'teamA'
SQL
SELECT DISTINCT t.*, m*
FROM team t
INNER JOIN member m ON t.id = m.team_id
WHERE t.name = 'teamA'
Team에서 일대다 연관관계를 가지는 Member를 페치 조인했다. 번역된 SQL을 보면 member 테이블의 데이터도 함께 조회한다.
일대다 관계를 조인하면 속된말로 데이터가 뻥튀기된다. 예를 들어, 팀이 10개고 팀의 멤버가 각각 20명이라면 조인 후 데이터의 개수는 10개에서 200개로 늘어난다.
그렇기 때문에, DISTINCT
키워드를 통해 중복을 제거하는것이 좋다.
SQL의 DISTINCT는 조회된 데이터가 완전히 일치하는 경우 중복을 제거하지만, JPQL의 DISTINCT는 여기에 더해 엔티티 중복도 제거해준다. 예를 들어, 데이터베이스에서 조회된 데이터가 위와 같은 경우, SQL의 DISTINCT 만으로는 중복을 제거할 수 없지만, JPQL의 DISTINCT는 엔티티 단위로 중복을 제거해준다.
컬렉션의 경우 N+1 문제를 페치 조인 대신 배치를 통해 1+1로 최적화할 수도 있다. 성능 최적화 부분에서 더 자세히 다루겠지만 미리 간단히 말하자면 데이터베이스의 IN 절을 이용하여 N번의 쿼리를 특정 단위로 묶어서(배치) 데이터베이스에 보내는 방법이다.
쿼리는 애플리케이션의 성능에 막대한 영향을 끼치는 주요 튜닝 포인트다. 특히 N+1 문제는 발생하면 성능에 큰 손실을 줄 수 있기 때문에 최적화가 필요하다. 모든 로딩 전략을 지연 로딩으로 세팅하고 최적화가 필요한 곳에 페치 조인을 적용하면 대부분의 문제를 해결할 수 있다.
페치 조인으로 모든 문제를 해결할 수 있다면 좋겠지만 페치 조인도 한계가 있다.
페치 조인 대상에 별칭을 줄 때 주의해야한다.
둘 이상의 컬렉션을 페치 조인 할 수 없다. 컬렉션을 조인하면 데이터가 뻥튀기 되기 때문에 컬렉션 페치 조인은 최대 한 개 까지 가능하다.
컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 적용할 수 없다. 컬렉션을 조인하면 데이터가 뻥튀기 되기 때문에 페이징을 해도 의도한 결과가 나오지 않는다. 하이버네이트 구현체는 컬렉션을 페치 조인 한 뒤 페이징하면 경고 로그를 남기고 데이터베이스가 아닌 메모리에서 페이징한다. 이는 성능에 매우 큰 손실을 줄 수 있으므로 절대 사용하면 안 된다.
벌크 연산은 이름에서 유추할 수 있듯이 한 번의 쿼리로 여러 엔티티를 변경하는 것이다.
예를 들어보자. 회원 100명의 나이를 모두 증가시켜야한다. JPA의 변경 감지를 통해 처리하면 총 100번의 UPDATE 쿼리가 필요하다. 벌크 연산(executeUpdate)을 이용하면 한 번의 쿼리로 해결할 수 있다.
String qlString = "update Member m " +
"set m.age = m.age + 1";
int resultCount = em.createQuery(qlString).executeUpdate();
벌크 연산의 특징을 알아보자.
executeUpdate는 영향받은 엔티티 수를 반환한다.
JPA 표준에서 UPDATE, DELETE를 지원한다.
하이버네이트에서 INSERT(insert into .. select)를 지원한다.
영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리하기 때문에 벌크 연산을 가장 먼저 실행하거나 수행한 뒤 영속성 컨텍스트를 초기화 해야한다.