Book Review

자바 ORM 표준 JPA 프로그래밍 - JPQL

빈코 2023. 1. 17. 14:10

이전 포스팅에서는 값 타입에 대해 알아보았다. 이번 포스팅은 객체지향 쿼리 언어 중에 JPA의 기본인 JPQL에 대한 포스팅을 진행하려 한다😊

 

JPQL

개요

JPQL은 엔티티 객체를 대상으로 쿼리 하는 객체지향 쿼리 언어다. SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는 특징을 가지고 있다. 

도메인 모델 예제

예제로 사용할 도메인 모델을 살펴보면 회원이 상품을 주문하는 다대다 관계임을 알 수 있다. 또한 Address임베디드 타입인데 이것은 값 타입으로 UML에서 스테레오 타입을 사용해 <<Value>>로 정의했다. ERD에서는 ORDERS 테이블에 포함되어 있다.

 

기본 문법과 쿼리 API📙

JPQL도 SQL과 비슷하게 SELECT, UPDATE, DELETE 문을 사용할 수 있다. 엔티티를 저장하는 INSERT문은 EntityManager.persist() 메서드로 대신한다. 

SELECT m.username FROM Member m

첫 번째 SELECT문은 엔티티와 속성에 대한 대소문자를 구분한다. 위 예시에서 SELECT, FROM 같은 JPQL 키워드는 대소문자를 구분하지 않지만, Member와 username은 대소문자를 구분한다.

 

또한 위의 Member는 클래스 명이 아니라 엔티티 명이다. 이후에 엔티티를 직접 선언하는 예제도 나오겠지만, 엔티티 클래스에는 @Entity(name="XXX") 어노테이션이 필수로 들어가게 되며, name 속성을 지정하지 않으면 클래스명을 기본값으로 사용한다.

 

또한, 별칭은 필수로 사용해야 한다. Member m 구문을 보면 as문이 빠져있는걸 볼 수 있는데 JPQL에서 AS는 생략할 수 있으며, m.username처럼 별칭을 꼭 붙여줘야 한다.

 

TypeQuery, Query

작성한 JPQL을 실행하려면 쿼리 객체를 만들어야 한다. 쿼리 객체는 TypeQuery와 Query가 있는데 반환할 타입을 명확하게 지정할 수 있으면 TypeQuery 객체를 사용하고, 반환 타입을 명확하게 지정할 수 없으면 Query 객체를 사용하면 된다.

TypedQuery<Member> query = em.createQuery("SELECT m FROM MEMBER m", Member.class);
List<Member> resultList = query.getResultList();

Query query = em.createQuery("SELECT m.username, m.age FROM Member m");
List resultList = query.getResultList(); // 오브젝트 반환

em.createQuery()의 두 번째 파라미터에 반환할 타입을 지정하면 TypeQuery를 반환하고 지정하지 않으면 Query를 반환하는 것을 확인할 수 있다. 첫 번째 예시는 조회 대상이 Member 엔티티이므로 대상 타입이 명확하지만, 두 번째 예시는 String 타입인 회원 이름과 Integer 타입인 나이이므로 대상 타입이 명확하지 않다.

 

이처럼 SELECT 절에서 여러 엔티티나 칼럼을 선택할 때는 반환할 타입이 명확하지 않으므로 Query 객체를 사용해야 한다. 사실 실무에서는 이렇게 사용하지 않고, 해당 기능에 필요한 DTO를 생성해서 반환받는다.

 

query.getResultList()는 결과를 예제로 반환해 준다. 만약 결과가 없으면 빈 컬렉션을 반환한다. query.getSingleResult() 메서드도 있는데, 이 메서드는 결과가 정확히 하나일 때만 사용한다. 결과가 없거나 1개보다 많으면 예외가 발생한다.

 

파라미터 바인딩📘

JPQL은 파라미터 바인딩으로 이름 기준 파라미터와 위치 기준 파라미터를 제공하는데, 위치 기준 파라미터는 칼럼이 추가될 경우 숫자로 표현하기에 어려움이 있어 대게 이름 기준으로 사용한다.

String usernameParam = "User1";
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m where 
						m.username = :username", Member.class);
query.setParameter("username", usernameParam);
List<Member> resultList = query.getResultList();

위의 예제를 보면 :username이라는 이름 기준 파라미터를 정의하고 query.setParameter()에서 username이라는 이름으로 파라미터를 바인딩하였다.

 

📌 파라미터 바인딩 방식이 아닌 직접 문자를 더해 만들어 넣으면 악의적인 사용자에 의해 SQL 인젝션 공격을 당할 수 있기 때문에 파라미터 바인딩 방식은 선택이 아닌 필수다.

 

프로젝션

SELECT 절에 조회할 대상을 지정하는 것을 프로젝션(projection)이라 하고 [ SELECT ] [ 프로젝션 대상 ] [ FROM ]으로 대상을 선택한다. 프로젝션 대상은 엔티티, 임베디드 타입, 스칼라 타입이 있다. 스칼라 타입은 숫자, 문자 등 기본 데이터 타입을 뜻한다.

 

SELECT m FROM Member m // 회원
SELECT m.team FROM Member m // 팀

 

처음으로 회원을 조회했고 두 번째는 회원과 연관된 팀을 조회했는데 둘 다 엔티티를 프로젝션 대상으로 사용했다. 쉽게 생각하면 원하는 객체를 바로 조회한 것인데 칼럼을 하나하나 나열해서 조회해야 하는 SQL과는 차이가 있다. 참고로 이렇게 조회한 엔티티는 영속성 콘텍스트에서 관리된다.

 

String query = "SELECT o.address FROM Order o";
List<Address> adresses = em.createQuery(query, Address.class).getResultList();

위 예제처럼 엔티티를 통해서 임베디드 타입을 조회할 수 있다. 하지만 임베디드 타입은 값 타입이기 때문에 영속성 컨텍스트에서 관리되지 않는다.

 

List<String> username = em.createQuery("SELECT username FROM Member m", 
			String.class).getResultList();

숫자, 문자, 날짜와 같은 기본 데이터 타입인 스칼라 타입의 조회 방법이다. 만약 중복 데이터를 제거하고 싶다면 SELECT 뒤에 DISTINCT를 사용하면 된다.

 

만약에 여러 값을 한 번에 조회하려면 어떻게 해야 할까? 실무에서는 이런 경우가 흔히 발생하기 때문에, 전용 DTO를 만들고 new 연산자를 사용하여 값을 꺼내올 때가 많다.

 

public class UserDTO {
    private String username;
    private int age;
    
    public UserDTO(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

TypedQuery<UserDTO> query = em.createQuery("SELECT 
                  new jpabook.domain.UserDTO(m.username, m.age) 
                  FROM Member m", UserDTO.class);
List<UserDTO> resultList = query.getResultList();

이처럼 SELECT 다음에 NEW 명령어를 사용하면 반환받을 클래스를 지정할 수 있는데 이 클래스의 생성자에 JPQL 조회 결과를 넘겨줄 수 있다. 그리고 NEW 명령어를 사용한 클래스로 TypeQuery 사용이 가능하여 지루한 객체 변환 작업을 줄일 수 있다.

 

페이징 API📒

TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m ORDER BY m.username DESC",
							Member.class);
query.setFirstResult(10);
query.setMaxResults(20);
query.getResultList();

데이터베이스마다 페이징을 처리하는 SQL 문법이 다르지만, JPQL은 데이터베이스마다 다른 페이징 처리를 같은 API로 처리할 수 있다(데이터베이스 방언)

 

setFirstResult(int startPosition)는 조회 시작 위치를 말하고, setMaxResults(int maxResult)는 조회할 데이터 수를 말한다. 위의 예제는 11번째부터 20건의 데이터를 조회하므로, 11~30번 데이터를 조회한다.

 

JPQL 조인📗

JPQ도 조인을 지원하는데 SQL 조인과 기능은 같고 문법만 약간 다르다.

String teamName = "teamA";
String query = "SELECT m FROM Member m INNER JOIN m.team t WHERE t.name = :teamName";

List<Member> members = em.createQuery(query, Member.class)
    .setParameter("teamName", teamName)
    .getResultList();

JPQL 조인의 가장 큰 특징은 연관 필드를 사용한다는 것이다. 여기서 m.team이 연관 필드인데 연관 필드는 다른 엔티티와 연관관계를 가지기 위해 사용하는 필드를 말한다.

 

FROM Member m JOIN Team t; // 오류 예시

혹시라도 위 예시처럼 JPQL 조인을 SQL 조인처럼 사용하면 문법 오류가 발생한다. JPQL은 JOIN 명령어 다음에 조인할 객체의 연관 필드를 사용하기 때문이다. 

 

SELECT m FROM Member LEFT [OUTER] JOIN m.team t;

외부 조인은 기능상 SQL의 외부 조인과 같다. OUTER는 생략 가능해서 보통 LEFT JOIN으로 사용한다. 

 

// JPQL
select m,t from Member m left join m.team t on t.name = 'A'

// SQL
SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.TEAM_ID = t.id and t.name = 'A'

JPQ 2.1부터 조인할 때 ON 절을 지원한다. ON 절을 사용하면 조인 대상을 필터링하고 조인할 수 있다. 참고로 내부 조인의 ON 절은 WHERE 절을 사용할 때와 결과가 같으므로 보통 ON 절은 외부 조인에서만 사용한다.

 

select m from Member m join fetch m.team

페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회할 수 있다. 예제를 보면 join 다음에 fetch라 적혀있는데, 이렇게 하면 연관된 엔티티나 컬렉션을 함께 조회할 수 있다. 여기서는 회원과 팀을 함께 조회한다.

 

참고로 일반적인 JPQL 조인과는 다르게 m.team 다음에 별칭이 없는데, 페치 조인은 별칭을 사용할 수 없다.

 

만약 위 예시를 사용하고 회원과 팀이 지연 로딩으로 설정되었다면, 회원을 조회할 때 페치 조인을 사용해서 팀도 함께 조회했으므로 연관 팀 엔티티는 프록시가 아닌 실제 엔티티다. 따라서 연관된 팀을 사용해도 지연 로딩이 일어나지 않는다. 그리고 프록시가 아닌 실제 엔티티이므로 회원 엔티티가 영속성 콘텍스트에서 분리되어 준영속 상태가 되어도 연관된 팀을 조회할 수 있다.

 

select from Team t join t.members m where t.name = "teamA'

만약 위 예시처럼 페치 조인을 사용하지 않고 일반 조인만 사용한다면, 팀만 조회하고 회원은 전혀 조회하지 않게 될 것이다. JPQL은 결과를 반환할 때 연관관계까지 고려하지 않기 때문이다. 단지 SELECT 절에 지정한 엔티티만 조회된다.

 

📌 join 옆에 fetch를 사용하면 회원도 함께 조회할 수 있다.

 

@OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략

최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다. 물론 일부는 빠를 수 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 오히려 성능에 악영향을 미칠 수 있다. 따라서 글로벌 로딩 전략은 될 수 있으면 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.

 

📌 지연 로딩(LAZY), 즉시 로딩(EAGER)을 모른다면 이곳을 클릭해서 선수학습 하는 것이 좋다😊

 

또한, 위에서 언급했듯이 페치 조인 대상에는 별칭을 줄 수 없기 때문에 SELECT, WHERE 절, 서브 쿼리에 페치 조인 대상을 사용할 수 없다.

 

마지막으로 컬렉션을 페치 조인하면 페이징 API(setFristResult, setMaxResults)를 사용할 수 없다. 하지만 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.

 

서브쿼리📚

JPQL도 SQL처럼 서브 쿼리를 지원한다. 여기에는 몇 가지 제약이 있는데, 서브쿼리를 WHERE, HAVING 절에서만 사용할 수 있고 SELECT, FROM 절에서는 사용할 수 없다.

 

다음은 예시로 한 건이라도 주문한 고객을 찾는 코드다.

select m from Member m
where (select count(o) from Order o where m = o.member) > 0  // 서브쿼리 사용

select m from Member m
where m.orders is not empty  // 서브쿼리 미사용

 

서브쿼리는 다음 함수들과 같이 사용할 수 있다.

select m from Member m
where exists (select t from m.team t where t.name = 'teamA') 
// exits 사용 - 팀A 소속인 회원

select o from Order o
where o.orderAmount > ALL (select p.stockAmount from Product p)
// ALL 사용 - 전체 상품 각각의 재고보다 주문량이 많은 주문들

select t from Team t
where t IN (select t2 From Team t2 JOIN t2.members m2 where m2.age >= 20)
// IN 사용 - 20세 이상을 보유한 팀

 

다형성 쿼리📔

JPQL로 부모 엔티티를 조회하면 그 자식 엔티티도 함께 조회한다. 다음 예제는 Item의 자식으로 Book, Album, Movie가 있다.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {...}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
    ...
    private String name;
}
// Album, Movie 생략

 

다음과 같이 조회하면 Item의 자식도 함께 조회한다.

 

List resultList = em.createQuery("select i from Item i").getResultList();

 

또한, 특정 자식 타입으로 한정하고 싶을 때 TYPE을 주로 사용한다.

select i from Item i where type(i) IN (Book, Movie)

 

마지막으로 JPA 2.1에 추가된 기능으로 TREAT를 사용하여 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용 가능한 기술이 있다.

select i from Item i where treat(i as Book).author = 'Kim'

JPQL을 보면 treat를 사용해서 부모 타입인 Item을 자식 타입인 Book으로 다뤄서 Book 엔티티에만 있는 author 필드에 접근할 수 있다.

 

🔆Named 쿼리: 정적쿼리

지금까지 모두 em.createQuery()처럼 JPQL을 문자로 완성해서 직접 넘기는 동적 쿼리를 진행했는데, 정적 쿼리는 미리 정의한 쿼리에 이름을 부여해서 필요할 때 사용할 수 있는 쿼리를 뜻하며 이것을 Named 쿼리라 한다. Named 쿼리는 한 번 정의하면 변경할 수 없는 정적인 쿼리다.

@Entity
@NamedQuery(
    name = "Member.findByUsername",
    query = "select m from Member m where m.username = :username")
)
public class Member {...}  // 선언

List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
		.setParameter("username", "Binco")
        .getResultList();  // 사용

 

두 개 이상 사용 시에는 아래 예시처럼 작성하면 된다.

@Entity
@NamedQueries({
    @NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username"),
    @NamedQuery(
        name = "Member.count",
        query = "select count(m) from Member m")
})
public class Member {...}

 

 


👨‍👩‍👦‍👦 오픈채팅방 운영

취업을 준비하는 예비 개발자분들을 위한 질문&답변할 수 있는 공간을 만들었습니다. 취업과 이직을 하기 위해서 어떤 걸 중점적으로 준비해야 하는지부터 포트폴리오&이력서 작성법 등 다양한 질문들을 받고 답변을 드립니다. 참여하셔서 다양한 정보 얻고 가시면 좋을 것 같네요😁

 

참여코드 : 456456

https://open.kakao.com/o/gVHZP8dg

 

비전공 개발자 취업 준비방(질문&답변)

#비전공 #개발자 #취업 #멘토링 #부트캠프 #국비지원 #백엔드 #프론트엔드 #중소기업 #중견기업 #자바 #Java #sql

open.kakao.com

 


👨‍💻 전자책 출간

아울러 제가  🌟비전공자에서 2년만에 보안 전문 중견기업으로 이직 한 방법들을 정리한 전자책을 출간 하게 되었습니다. 어떤 걸 공부해야 하는지, 이직을 위해서 무엇을 준비해야 하는지, 제가 받았던 기술 면접 리스트 등 다양한 목차로 구성되어 있습니다. 또한, 구매 시 1:1 채팅을 이용하여 포트폴리오 첨삭을 도와드리고 있습니다. 🐕전자책으로 얻은 모든 수익은 유기견 센터 '팅*벨 입양센터'에 후원될 예정입니다. 관심 있으신 분들은 아래 링크를 참고해주세요😁

https://kmong.com/gig/480954

 

비전공개발자 2년만에 중견기업 들어간 방법 | 14000원부터 시작 가능한 총 평점 0점의 전자책, 취

0개 총 작업 개수 완료한 총 평점 0점인 Binco의 전자책, 취업·이직 전자책 서비스를 0개의 리뷰와 함께 확인해 보세요. 전자책, 취업·이직 전자책 제공 등 14000원부터 시작 가능한 서비스

kmong.com


 

마치며

지금까지 JPQL을 간단하게 설명하였다. 실무에서도 사용을 해봤지만, 실제 책을 깊게 살펴보니 모르는 것이 정말 많아서 다시금 공부해야겠다는 생각이 든다😅 

반응형