[Java/JPA] JPQL - 프로젝션, 페이징 API

daheenamic·2025년 12월 1일

Java

목록 보기
44/48

프로젝션

프로젝션(Projection)은 SELECT 절에 조회할 대상을 지정하는 것이다. 쉽게 말해 뭘 가져올 건지 정하는 것이다.

프로젝션 대상은 크게 세가지다.

  1. 엔티티 프로젝션: 엔티티 객체 자체를 조회
  2. 임베디드 타입 프로젝션: 값 타입(Value Type)을 조회
  3. 스칼라 타입 프로젝션: 숫자, 문자 같은 기본 데이터 타입을 조회

엔티티 프로젝션

기본 엔티티 조회

// Member 엔티티 전체 조회
SELECT m FROM Member m

조회된 엔티티는 영속성 컨텍스트에서 관리된다. 그래서 값을 변경하면 자동으로 UPDATE 쿼리가 나간다.

List<Member> result = em.createQuery("select m from Member m", Member.class)
                        .getResultList();

Member findMember = result.get(0);
findMember.setAge(20);  // 트랜잭션 커밋 시점에 UPDATE 쿼리 자동 실행

연관 엔티티 조회 시 주의사항

// Member와 연관된 Team 조회
SELECT m.team FROM Member m

이렇게 작성하면 내부적으로 JOIN 쿼리가 실행된다. 문제는 이게 한눈에 보이지 않는다는 점이다.

실무에서는 명시적으로 JOIN을 작성하는 게 좋다

// 명시적 JOIN (권장)
select t from Member m join m.team t

이렇게 쓰면 어디서 JOIN이 발생하는지를 바로 알 수 있다. SQL 쿼리도 예측 가능하다.

select m.team from Member m 같은 방식을 묵시적 조인이라고 한다. JOIN이 숨어있어서 성능 이슈를 파악 하기 어렵다. JOIN은 성능에 큰 영향을 주고 튜닝 포인트도 많기 때문에, 쿼리에서 명확하게 드러나야 한다

묵시적 조인 vs 명시적 조인
묵시적 조인은 쿼리 문자열에서 JOIN이 보이지 않지만 내부적으로 JOIN이 실행된다. 명시적 조인은 join 키워드를 직접 써서 JOIN을 명확히 드러낸다. 실무에서는 항상 명시적 조인을 사용해야 쿼리 성능을 파악하고 튜닝할 수 있다.


임베디드 타입 프로젝션

임베디드 타입(값 타입)은 항상 어딘가에 소속되어 있다. 그래서 단독으로 조회할 수 없다.

// 불가능
SELECT address FROM Address

// 가능 - 소속된 엔티티를 명시
SELECT o.address FROM Order o

이게 값 타입의 한계이다.

값 타입은 엔티티에 종속되어 있기 때문에 반드시 엔티티를 통해서만 접근할 수 있다.


스칼라 타입 프로젝션 - 여러 값 조회

여러 컬럼을 동시에 조회해야 할 때가 있다.

SELECT m.username, m.age FROM Member m

이런 경우 세 가지 방법으로 결과를 받을 수 있다.

1. Query 타입으로 조회

List resultList = em.createQuery("select m.username, m.age from Member m")
                    .getResultList();

Object o = resultList.get(0);
Object[] result = (Object[]) o;
System.out.println("username = " + result[0]);
System.out.println("age = " + result[1]);

타입을 명시하지 못해서 Object 배열로 받아야 한다. 타입 안정성도 없고 가독성도 떨어진다.

2. Object[] 타입으로 조회

List<Object[]> resultList = em.createQuery("select m.username, m.age from Member m")
                              .getResultList();

Object[] result = resultList.get(0);
System.out.println("username = " + result[0]);
System.out.println("age = " + result[1]);

제네릭을 쓸 수 있어서 1번보다는 낫지만, 여전히 배열 인덱스로 접근해야 해서 불편하다.

3. new 명령어로 DTO 조회 (권장)

List<MemberDTO> resultList = em.createQuery(
    "select new jpql.MemberDTO(m.username, m.age) from Member m", 
    MemberDTO.class
).getResultList();

MemberDTO memberDTO = resultList.get(0);
System.out.println(memberDTO.getUsername());
System.out.println(memberDTO.getAge());

가장 깔끔한 방법이다. DTO 클래스로 바로 매핑되기 때문에 타입 안정성도 확보되고 코드도 명확하다.

주의사항

  • 패키지명을 포함한 전체 클래스명을 입력해야 한다. (jpql.MemberDTO)
  • DTO에 순서와 타입이 일치하는 생성자가 필요하다.
public class MemberDTO {
    private String username;
    private int age;
    
    // 생성자 순서와 타입이 JPQL과 일치해야 함
    public MemberDTO(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

이 방법도 패키지 경로가 길어지면 쿼리 문자열이 복잡해진다.

QueryDSL을 사용하면 이런 불편함 없이 더 깔끔하게 작성할 수 있다.


페이징 API

과거 MyBatis + Oracle 조합으로 페이징을 구현하려면 정말 복잡했다. ROWNUM을 쓰고, 서브쿼리를 3중으로 감싸고, ORDER BY 위치도 신경 써야 했다. 코드도 길고 데이터베이스마다 문법이 달라서 유지보수도 어려웠다.

JPA는 페이징을 단 두 개의 메서드로 해결한다

  • setFirstResult(int startPosition): 조회 시작 위치 (0부터 시작)
  • setMaxResults(int maxResult): 조회할 데이터 수

페이징 사용 예시

String jpql = "select m from Member m order by m.name desc";

List<Member> resultList = em.createQuery(jpql, Member.class)
    .setFirstResult(10)   // 11번째 데이터부터 (0-based index)
    .setMaxResults(20)    // 20개 조회
    .getResultList();

이게 전부다. 데이터베이스 방언에 따라 JPA가 자동으로 적절한 SQL을 생성해준다.

페이징에서는 ORDER BY를 꼭 넣어봐야 JPA가 제대로 동작하는지 확인할 수 있다. 정렬 기준이 없으면 데이터 순서가 보장되지 않는다.


데이터 베이스별 페이징 처리

같은 JPQL이 각 데이터베이스에 맞게 자동 변환된다.

MySQL 방언

SELECT
    M.ID AS ID,
    M.AGE AS AGE,
    M.TEAM_ID AS TEAM_ID,
    M.NAME AS NAME
FROM
    MEMBER M
ORDER BY
    M.NAME DESC 
LIMIT ?, ?

MySQL은 LIMIT 절로 간단하게 처리된다.

Oracle 방언

SELECT * FROM
( SELECT ROW_.*, ROWNUM ROWNUM_
  FROM
    ( SELECT
        M.ID AS ID,
        M.AGE AS AGE,
        M.TEAM_ID AS TEAM_ID,
        M.NAME AS NAME
      FROM MEMBER M
      ORDER BY M.NAME
    ) ROW_
  WHERE ROWNUM <= ?
)
WHERE ROWNUM_ > ?

Oracle은 3중 서브쿼리에 ROWNUM을 사용하는 복잡한 쿼리가 생성된다.
개발자가 직접 작성할 필요 없이 JPA가 알아서 데이터베이스에 맞는 쿼리를 만들어준다 이게 JPA 페이징의 가장 큰 장점이다. 데이터베이스를 변경해도 코드 수정이 필요 없다.


정리

프로젝션

  • 엔티티 프로젝션: 조회된 엔티티는 영속성 컨텍스트에서 관리된다
  • 연관 엔티티 조회 시 명시적 JOIN 사용 (묵시적 조인은 성능 파악 어려움)
  • 여러 값 조회는 DTO 방식 권장 (new 명령어 사용)

페이징

  • setFirstResult()setMaxResults() 두 메서드만으로 페이징 완성
  • 데이터베이스 방언에 따라 자동으로 최적화된 SQL 생성
  • ORDER BY를 함께 사용해야 정렬 순서가 보장된다

다음 포스팅에서는 조인, 서브쿼리, JPQL 함수 같은 고급 기능을 배워 보려 한다.

0개의 댓글