[JPA] 자바 ORM 표준 JPA 프로그래밍(기본편) 8 - JPQL 중급 문법

유승선 ·2022년 11월 21일
1
post-thumbnail

사실 이 강의는 끝낸지 오래된 강의다. JPQL과 과련된 지식을 얻기위해 프로그래머스에서 SQL 문제들을 풀고 JOIN, SELECT, 서브쿼리 등등에 기본 지식을 가지고 강의를 재차 들었다.

강의 초반에는 사실 프로젝트를 진행하면서 어느정도 얻을 수 있는 지식들이 대부분이었다. 예를 들면 영속성 컨텍스트 안에 데이터가 저장이 되고 더티 체킹을 통한 변경감지 등, 기본적이지만 중요한 로직을 잘 이해 했지만 유독 마지막 강의를 갈 수록 집중력도 많이 떨어졌고 갈팡질팡 하다가 이번에 3회차 강의를 보면서 드디어 마지막 챕터를 정리해본다.

경로 표현식

기본적이지만 용어가 헷갈릴수 있기 때문에 정리하면서 시작해본다.

상태필드란? -> m.username, team.name 과 같이 기본적인 정보를 찾는 필드다.

연관필드: 연관관계를 위한 필드

  • 단일 값 연관 필드 : @ManyToOne, @OneToOne, 대상이 엔티티인 경우 (ex: m.team)

  • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany, 대상이 컬렉션인 경우(ex: m.orders)

경로 표현식의 특징을 나열했다. 원래 강의에서 보면 훨씬 더 이해가 빠를 수 있는데 최대한 설명하겠다.

상태필드:

SELECT m.username FROM Member m

정말로 단순하다. 상태필드에 적힌 쿼리는 데이터 베이스에도 그대로 보내져서 우리가 생각하는 SQL 쿼리가 날라간다.


단일 값 연관 경로

JPQL:
SELECT o.member from Order o
SQL:
SELECT M.* FROM Orders o inner join Member m on o.member_id = m.id

단일 값 연관 경로 탐색 쿼리지만 무엇인가 이상하다. 분명히 JPQL 에서는 m.team 만 검색했는데 지금 보면은 실제 SQL 쿼리에서는 INNER JOIN이 날라가는것을 확인할 수 있다.

왜 이런걸까? 우리는 이것을 묵시적 내부 조인(inner join) 이라고 한다.

묵시적 내부조인이란, JPQL에서는 단순 엔티티의 참조값을 부른거지만 SQL 에서는 테이블 형식으로 되어있기 때문에 어쩔 수 없이 내부조인을 발생해서 정보를 찾을 수 밖에 없다.

그렇지만 이런 묵시적 조인은 좋은 설계가 아니다. 왜? JPQL 과 SQL은 최대한 비슷하게 운영되어야 한다. 안그러면 나중에 오류가 있었을때 내부적 조인이 발생하는 곳을 찾기 정말 힘든 상황이 온다. 그렇기 때문에 JPQL을 아래와 같이 바꾸자.

JPQL:
SELECT m FROM member m join m.team t

join 키워드를 직접 사용하며 최대한 SQL 쿼리와 맞춰줬다.
추가적으로, 단일 값 연관 경로 탐색은 대상이 엔티티이기 때문에 계속해서 탐색을 할 수 있다. 예: SELECT m.team.userId FROM Member join m.team t


컬렉션 값 연관 경로

JPQL:
SELECT t.members FROM Team t

@OneToMany 의 관계 같이 컬렉션으로 값을 가져오는 경우에 위와 같이 JPQL 쿼리를 운영할 수 있다. 그리고 여기서도 묵시적인 join인 inner join이 발생하고 컬렉션 조회이기 때문에 전에 있었던 예시와 같이 join을 명시 안해주었다.

다만 여기서 중요하게 생각할 점은, 앞서 엔티티를 대상으로 탐색을 계속 할 수 있었던 단일 값 연관 경로와는 달리 컬렉션 값 연관 경로는 불가능 하다.

즉, SELECT t.members.username..같은 형식이 불가능하다는 것이다. 이유로는 컬렉션 값 안에는 많은 members 엔티티가 들어있는데 이거를 하나 콕 집어서 출력하기에는 한계가 있기 때문이다. 그렇기 때문에 조심해야 한다.

정말로 탐색을 그래도 하고 싶다면은,

SELECT m.username FROM Team t join t.members m

이런식으로 t.members 를 바로 가져오는게 아닌 팀 자체를 t.members와 조인 시켜서 별칭을 얻고 그것을 기준으로 탐색하는 방법이 존재한다.

실무조언으로 마무리 하겠다.


페치 조인(fetch join)

JPQL 을 사용하는데 있어서 가장 중요한 핵심 컨셉이다. 사실 강의를 3번 이상 보려고 했던 이유도 이 페치 조인을 이해하고 싶었고 2회차에 이해한 느낌이 있었지만 3회차에 누군가 물어봐도 잘 대답할 수준을 만들었다.

일단 페치 조인이란, SQL의 조인 종류가 아니다.
정말 순수하게 성능 최적화 를 위해 제공되는 기능이다.
이후에 더 추가적으로 설명할 것이지만 연관된 엔티티나 컬렉션을 SQL 한번에 함께 조회한다고 생각하면 좋다.

정말 대략적인 예시다. 이제 여기에서 더 깊게 이해하려면은 표를 참고하고 코드를 하나씩 봐야한다. 그렇지만 위에 코드만 보면은 바로 떠올릴 수 있는 부분이 하나 있는데 바로 연관관계 매핑때 Fetch 종류를 Eager 로 설정한것과 똑같은 효과를 보이고 있다.

inner join 형식으로 내가 멤버 테이블과 팀 테이블을 합친다고 생각해보자. 참고로 Members 와 Team 은 @ManyToOne의 관계이다.

우측 상단에 보이는 새롭게 생성된 테이블이 DB 입장에서 본 테이블이다. 회원1은 Team_id 1을 가지고 있기 때문에 Team테이블의 1번 ID와 조인이 됐고 회원4는 매칭이 되는 팀 아이디가 없기때문에 삭제 되었다.

하단에 있는 표는 영속성 컨텍스트 안에 있는 모습을 보여준거다. 당연히 알겠지만, 팀A가 DB테이블에서 연속된다 해서 영속성 컨텍스트에서도 연속된건 아니고 하나로 통일 되어있다.

평범해보이는 코드지만 fetch join과 프록시의 이해가 부족하다면은 위에 코드가 fetch join 없이 얼마나 위험한지 알 수 있을것이다.

예전에 적어놨던 JPA 기초에서 알 수 있지만 XtoOne 시리즈로 연결관계가 되어있는 관계는 모두 지연로딩으로 설정했다 (Lazy) 그 이유는 만약에 회원 한명만 조회 해야하는 상황에도 팀 객체를 전부 inner join 해서 가져왔기 때문에 성능적으로 잃는게 많았다. 여기서 우리가 배웠던건 N+1 문제였는데 지연로딩으로 설정해두면 이 문제가 고쳐진다는것은 기본으로 알고 있다.

하지만! 객체 탐색을 해야하는 경우에는 말이 다르다. 만약에 멤버를 JPQL을 통해 찾게되면 당연히 연관관게인 Team 은 지연로딩으로 프록시로 검색이 되기 때문에 쿼리가 안나간다.

그러나, 위에 코드와 같이 여러명의 멤버를 List 안에 저장해두고 멤버를 탐색하면서 각 맴버가 가진 팀을 탐색한다고 생각해보자.

Team은 프록시로 되어있기 때문에 멤버만 가지고 오는 쿼리를 날렸을때 쿼리가 안날라가고 프록시에 있는 메서드를 부를때 쿼리가 날라간다.

그리고 여러명의 멤버가 가진 메서드를 한번에 부르고 그 맴버들이 가진 팀 메서드를 부르게 되면, 영속성 컨텍스트에 저장이 안된 모든 팀들을 향한 쿼리가 지속해서 나가게 된다 (왜? 팀은 프록시니깐!) 이런 이유 때문에 우리는 분명 멤버를 가지고 오는 JPQL 한번만 날렸지만, 그래프 탐색을 하면서 팀도 같이 불러오기 때문에 N 번 만큼 따라오게 된다.

이것을 N+1 문제 라고 부른다.

이런 상황을 대비하고자, 내가 원하는 딱 하나의 쿼리만 날라가게 하려면 이렇게 프록시로 설정되어 있는 팀 객체를 모두 영속성 컨텍스트 안에 한번에 넣을 수 있는 fetch join이 필요하다.

컬렉션 페치 조인

이번에는 반대인 컬렉션 페치 조인을 살펴보자. 팀과 멤버는 일대다 관계이다.

팀과 멤버를 조인해야 하는 상황이다. 팀A는 멤버 ID 1하고 2한테 속해 있기 때문에 연속적으로 조인이 된다. DB 테이블에서는 우리가 알듯이 컬렉션을 저장할 수 있는 별도의 공간이 제공 안되기 때문에 이렇게 중복적으로 조인이 된다면 여러가지 row 로 표현이 된다.

가장 하단부는 영속성 컨텍스트 안에 있는 모습이다. 팀A가 테이블에서는 중복되지만 영속성 컨텍스트 안에 있는 객체는 하나다. 참고로 영상에서는 이렇게 같은 이름 (팀A) 가 연속적으로 DB테이블에 나오는것을 데이터 뻥튀기라고 불렀다.

컬렉션 페치 조인도 일반 페치조인과 마찬가지로 연관된 엔티티를 모두 불러오기 때문에 그래프 탐색으로 멤버를 조회해도 추가적인 쿼리가 안나간다는것을 확인 가능하다.

DISTINCT

앞서 말한 데이터 뻥튀기를 생각해보자. 영속성 컨텍스트 안에 있는 팀 객체는 하나이지만 실제 팀 리스트 컬렉션을 확인해보면 3가지의 팀이 들어가있다. 그러나 현실은 같은 팀 값이 들어간것이고 데이터 뻥튀기로 인해 중복된 값을 가진거다.

실제 DB 테이블에서는 팀A가 데이터 뻥튀기로 중복되었다.

DISTINCT를 페치 조인에 함께 쓰게 되면은 위와 같이 같은 식별자를 가졌기에 제거가 된다.

페치 조인과 일반 조인의 차이

페치 조인의 가장 큰 장점은 연결된 엔티티 정보를 전부 가지고 오게되서 그래프 탐색을 하면서 프록시를 건들 일이 있을때 쿼리 단 한번으로 나가게 해주는 장점이 있다.

그러나 일반적인 조인은 그냥 정말 조인이다. 만약 그래프 탐색을 하려고 하면 똑같이 N+1 문제가 생길것이다.

내가 말한 차이점을 요약했다.

아마 이 문장이 페치 조인을 가장 잘 정리한게 아닌가 싶다. 결국 페치 조인은 수동적으로 즉시 로딩을 설정하는것과 같다. 강의에서도 말하길 대부분에 JPA 관련 문제는 페치 조인으로 해결 가능하다.

페치 조인의 특징과 한계

완벽해 보이지만 페치 조인에도 몇가지 한계가 존재한다. 첫번쨰로 별칭을 줄 수 없다.

이게 왜 한계지? 생각해봤지만 페치 조인의 목적을 잘 생각해보면 이해가 가능하다. 패치 조인은 연관된 엔티티를 전부 가져오는 즉시로딩 개념으로 사용되는것이다. 그러나, 만약 별칭을 주고 페치 조인에 "where m.userId = ..." 이런식으로 조건을 넣어서 필터링 한다면 애초에 페치 조인의 목적과 맞지가 않다!

전부 조회하라고 만든 기능인데 별칭을 주면서 필터링을 한다고? 목적과 어긋나있다.

페이징 API를 이해하기 위해서는 위에 데이터 뻥튀기 를 언급한걸 잘 이해 해야한다.

우리는 컬렉션 페치 조인을 할때도 있고 이때 생기는 데이터 뻥튀기란 팀A가 연속적으로 DB 테이블에 나오는것이다 (왜? 팀A를 가진 멤버가 여럿이 있기 때문에 DB테이블에서는 팀A를 여러가지 row 로 만들어야 하니깐) 그런데 이때 내가 페이징을 이용해서 몇번째 row만 가지고 오게되면 팀A가 가지고 있는 다른 멤버들을 놓칠수도 있다.

예:
팀A -> Member1
팀A -> Member2
팀A -> Member3

페이징 (0) -> 페이징(1)

이럴경우 마지막 팀A가 가지고 있는 Member3를 잃게 된다.

물론 페이징이 완전히 불가능한건 아니다. 하지만 하이버네이트는 경고 로그를 남기고 이것을 메모리에서 페이징하기 때문에 매우 위험하다 다만 일대일, 다대일 같은 경우 단일 값 연관 필드들을 페치 조인하기 때문에 데이터 뻥튀기에 위험이 없다. 그럼으로 페이징이 가능하다.

페치 조인을 정리했다.

JPQL 벌크연산

벌크연산의 예다. 만약에 UPDATE를 해야하는 쿼리가 주어질때 숫자가 많으면 많을 수록 반복되는 쿼리가 많아진다. 그렇기 때문에 한번에 모두 처리할 수 있는 벌크 연산이 사용된다.

쿼리는 이렇게 요약할 수 있다.

다만 이 부분은 좀 생각해야 하는데 벌크 연산은 영속성 컨텍스트와 상관없이 DB에 직접 쿼리를 한다. 그렇기 때문에 벌크 연산을 먼저 실행하고 영속성 컨텍스트 안에 있는 객체를 조회하고 꺼내오면 업데이트 되기 전 값을 가진 엔티티를 가지고 온다

그렇기때문에 한번 영속성 컨텍스트를 초기화 하고 아예 새로 업데이트된 엔티티를 불러와서 새롭게 영속성 컨텍스트에 조회하는게 맞다.


마무리

이로서 JPA 관련 기본 포스팅을 모두 끝냈다. 바쁜 와중에도 이렇게 할 수 있는 부지런한 내 자신한테 너무 자랑스럽다. 원래는 대수롭지 않게 넘겼던 부분이 많았고 내가 스스로 이미 잘한다 생각해서 넘겼지만 기본적인것도 모르는게 많았다. 이제 진짜 기본기 탄탄하게 잡힌거 같아서 Spring JPA로 넘어가서 실무에서 사용되는 JPA 기술을 배우는게 좋을거 같다!

profile
성장하는 사람

0개의 댓글