경로 표현식은 점(.)을 찍어서 객체 그래프를 탐색하는 것이다.
select m.username // 상태 필드
from Member m
join m.team t // 단일 값 연관 필드
join m.orders o // 컬렉션 값 연관 필드
where t.name = '팀A'
객체 입장에서는 단순히 .으로 접근하는 것처럼 보이지만, 내부적으로는 전혀 다르게 동작한다. 이 차이를 이해하지 못하면 성능 문제가 발생할 수 있다.
단순히 값을 저장하기 위한 필드다.
m.username // String
m.age // Integer
t.name // String
엔티티의 기본 속성들이 여기에 해당한다.
@ManyToOne, @OneToOne으로 연결된 엔티티다. 대상이 단일 엔티티다.
m.team // Member -> Team (ManyToOne)
o.member // Order -> Member (ManyToOne)
@OneToMany, @ManyToMany로 연결된 컬렉션이다. 대상이 컬렉션이다.
t.members // Team -> List<Member> (OneToMany)
m.orders // Member -> List<Order> (OneToMany)
각 경로 표현식은 전혀 다르게 동작한다.
// JPQL
select m.username, m.age from Member m
// 실행되는 SQL
select m.username, m.age from Member m
상태 필드는 경로 탐색의 끝이다. 더 이상 .을 찍어서 탐색할 수 없다.
m.username.length() // 불가능! username은 상태 필드라 끝
JPQL과 SQL이 거의 동일하게 나간다. 조인도 발생하지 않는다.
// JPQL
select o.member from Order o
// 실행되는 SQL
select m.*
from Orders o
inner join Member m on o.member_id = m.id
문제는 여기서 시작된다. 코드에는 join이라는 단어가 없지만, 내부적으로 INNER JOIN이 발생한다.
더 위험한 건 계속 탐색이 가능하다는 점이다.
// JPQL
select o.member.team from Order o
// 실행되는 SQL
select t.*
from Orders o
inner join Member m on o.member_id = m.id
inner join Team t on m.team_id = t.id
.을 하나 더 찍었을 뿐인데 조인이 2번 발생한다. o.member.team.department.company처럼 계속 이어지면 조인이 계속 늘어난다.
// JPQL - 컬렉션 자체는 가져올 수 있음
select t.members from Team t
// 실행되는 SQL
select m.*
from Team t
inner join Member m on t.id = m.team_id
컬렉션도 묵시적 조인이 발생한다. 하지만 더 이상 탐색할 수 없다.
// 불가능!
select t.members.username from Team t // 에러 발생
컬렉션에서는 .username 같은 탐색이 안 된다. 컬렉션은 여러 개의 엔티티를 담고 있기 때문이다.
대신 .size는 가능하다.
// 팀별 회원 수 조회
select t.name, t.members.size from Team t
컬렉션 값 연관 필드는 경로 탐색의 끝이다. 더 탐색하려면 명시적 조인으로 별칭을 얻어야 한다.
// 실패 - 컬렉션은 탐색 불가
select t.members.username from Team t
// 성공 - 명시적 조인으로 별칭 생성
select m.username from Team t join t.members m
join t.members m으로 별칭 m을 얻으면, 이제 m.username처럼 탐색할 수 있다.
join 키워드를 직접 사용하는 방식이다.
select m from Member m join m.team t
코드에서 join이 명확히 보인다.
경로 표현식에 의해 자동으로 조인이 발생하는 방식이다.
select m.team from Member m // join 키워드 없지만 조인 발생
코드에는 join이 없지만 내부적으로 INNER JOIN이 실행된다.
실제로 어떻게 동작하는지 예제를 보자.
// 예제 1: 성공 - 단일 값 연관 경로
select o.member.team from Order o
// JOIN이 2번 발생 (Order -> Member -> Team)
// 예제 2: 성공 - 컬렉션 크기
select t.members.size from Team t
// JOIN 1번 발생, 크기만 계산
// 예제 3: 실패 - 컬렉션은 탐색 불가
select t.members.username from Team t
// 에러! 컬렉션에서는 .username 불가능
// 예제 4: 성공 - 명시적 조인으로 해결
select m.username from Team t join t.members m
// 명시적 조인으로 별칭 m 획득 후 탐색
// 코드만 보면 단순 조회 같지만
String jpql = "select o.member.team.name from Order o";
// 실제로는 조인이 2번 발생
select t.name
from Orders o
inner join Member m on o.member_id = m.id
inner join Team t on m.team_id = t.id
쿼리가 복잡해질수록 어디서 몇 번 조인이 발생하는지 파악하기 어렵다.
묵시적 조인은 무조건 INNER JOIN이다. LEFT JOIN 같은 외부 조인이 필요하면 명시적 조인을 써야 한다.
// 묵시적 조인 - 항상 INNER JOIN
select m.team from Member m
// 외부 조인이 필요하면 명시적으로
select m from Member m left join m.team t
// 이 쿼리에 조인이 몇 번 발생할까?
String jpql = "select o.member.team.name, " +
"o.product.category.name, " +
"o.member.address.city " +
"from Order o";
코드만 봐서는 알기 어렵다. 조인은 SQL 튜닝의 핵심 포인트인데, 묵시적 조인은 이를 한눈에 파악하기 어렵게 만든다.
// 나쁜 예 - 묵시적 조인
select o.member.team from Order o
// 좋은 예 - 명시적 조인
select t from Order o
join o.member m
join m.team t
명시적 조인을 쓰면:
// 나쁜 예
select t.members from Team t // 묵시적 조인, 더 탐색 불가
// 좋은 예
select m from Team t join t.members m // 명시적 조인, 탐색 가능
// 나쁜 예
select t.members from Team t // 묵시적 조인, 더 탐색 불가
// 좋은 예
select m from Team t join t.members m // 명시적 조인, 탐색 가능
후자가 코드는 길지만, 조인 관계가 명확하고 튜닝하기 쉽다.
코드 리뷰 시 다음을 확인하자:
QueryDSL을 배우게 되면 이 모든 내용을 타입 안전하게, 더 깔끔하게 작성하는 방법을 익힐 수 있다. QueryDSL을 쓰면 묵시적 조인 같은 실수를 원천적으로 방지할 수 있다고 한다.