[HOW] JPA Projection (Projection Interface vs DTO Projection)

하비·2025년 3월 16일
4

HOW

목록 보기
6/6

이번 시간에는 JPA Projection에 대해서 알아보는 시간을 가지겠습니다!
제가 처음 스프링 부트 프로젝트를 했을 때, 'Jpa를 사용해서 한번에 DTO 형식이랑 맞게 데이터를 불러올 수는 없을까?' 라는 귀차니즘(?)에 시작해서 검색해서 쓰게 되었습니다.

내 프로젝트 코드

이 아이 입니다.

public interface RoutineMeDetailMapping {
    Long getId();
    Long getRoutineId();
    String getRoutineSubject();
    Integer getCount();
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDateTime getCreateDate();
}
@Repository
public interface MyRoutineReposiotry extends JpaRepository<MyRoutine, Long> {

    List<RoutineMeDetailMapping> findMyRoutineByUserOrderByCreateDateDesc(User user);

    List<RoutineMeDetailMapping> findMyRoutineByUserOrderByCountDesc(User user);

}

결과

{
  "id": 1,
  "routineId": 101,
  "routineSubject": "Morning Run",
  "count": 5,
  "createDate": "2025-03-16"
}

사용자에 해당하는 루틴에 관련된 운동을 조회할 때, 루틴 객체의 특정 속성만 DTO에 넣어 전달하고 싶었습니다. 그래서 찾아보던 중 어디 블로그인지 이렇게 인터페이스로 만들어주면 제가 원하는 DTO 형식처럼 반환을 해주는 것을 보았고, 그것을 보고 일단 '무지성 적용!'하였습니다.

참고: MyRoutine Entity

public class MyRoutine extends BaseTimeEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @ManyToOne
    @JoinColumn(name = "user_id", referencedColumnName = "id")
    private User user;
    @ManyToOne
    @JoinColumn(name = "rout_id", referencedColumnName = "id")
    private Routine routine;
    private Integer count;
    public void increaseCnt(){
        this.count++;
    }
}

너의 정체는? (Projection Interface)

Spring Data JPA의 Projection Interface!!
특정 엔티티의 일부 필드만 조회할 수 있도록 도와주는 기능입니다.
즉, DTO를 따로 만들지 않고 인터페이스를 정의하는 것만으로 필요한 데이터만 가져올 수 있습니다.
제 코드로 분석하며 한번 더 알아보도록 하겠습니다.

Projection의 뜻

  • 데이터베이스에서 전체 데이터를 가져오지 않고, 필요한 부분만 선택하는 것을 의미
  • "데이터의 일부분을 투영해서 가져온다"
  • SQL에서 SELECT 특정 컬럼을 하는 것과 유사함

코드 분석

MyRoutine Entity

@Entity
public class MyRoutine extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id", referencedColumnName = "id")
    private User user;  // 루틴을 소유한 사용자

    @ManyToOne
    @JoinColumn(name = "rout_id", referencedColumnName = "id")
    private Routine routine;  // 연관된 루틴 정보

    private Integer count;  // 루틴 수행 횟수

    public void increaseCnt() {
        this.count++;
    }
}
  • User: 루틴을 가진 사용자 (ManyToOne)
  • Routine: 루틴 자체 정보 (ManyToOne)
  • count: 사용자가 해당 루틴을 수행한 횟수
  • increaseCnt(): 수행 횟수를 증가시키는 메서드

Projection Interface

public interface RoutineMeDetailMapping {
    Long getId();                  // MyRoutine 엔티티의 id
    Long getRoutineId();            // Routine 엔티티의 id
    String getRoutineSubject();     // Routine 엔티티의 subject
    Integer getCount();             // 수행 횟수
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDateTime getCreateDate();  // 생성 날짜
}
Projection 필드매핑되는 엔티티 필드설명
getId()MyRoutine.idMyRoutine의 기본키
getRoutineId()Routine.idMyRoutine이 참조하는 Routine의 ID
getRoutineSubject()Routine.subject루틴의 제목 (Routine 엔티티에서 가져옴)
getCount()MyRoutine.count루틴을 수행한 횟수
getCreateDate()BaseTimeEntity.createDate루틴이 생성된 날짜

실행될 SQL

findMyRoutineByUserOrderByCreateDateDesc(user) 실행 시 SQL 문은 다음과 같습니다.

SELECT 
    mr.id AS id, 
    r.id AS routineId, 
    r.subject AS routineSubject, 
    mr.count AS count, 
    mr.create_date AS createDate
FROM my_routine mr
JOIN routine r ON mr.rout_id = r.id
WHERE mr.user_id = ?
ORDER BY mr.create_date DESC;

이렇게 Projection을 사용하면 전체 엔티티를 가져오지 않고 필요한 필드만 조회하므로 성능 최적화가 가능합니다!

결과

[
  {
    "id": 10,
    "routineId": 101,
    "routineSubject": "Morning Run",
    "count": 5,
    "createDate": "2025-03-16"
  },
  {
    "id": 11,
    "routineId": 102,
    "routineSubject": "Yoga",
    "count": 3,
    "createDate": "2025-03-15"
  }
]

원리는?

이제 SQL이 어떻게 호출되었는지는 알 것 같습니다. 근데 그러면 어떻게 interface를 그대로 했는데 객체가 반환되는 것일까요? 원리가 뭘까요?

인터페이스만 정의해도 동작하는 이유는 JPA와 Spring이 내부적으로 프록시 객체를 생성하여 결과를 매핑해주기 때문입니다.

동작 과정

  1. JPQL 변환 : JPA가 Repository 메서드를 분석하여 필요한 필드만 포함하는 JPQL을 생성
  2. SQL 실행 : 변환된 JPQL을 기반으로 필요한 필드만 조회하는 SQL 실행
  3. Proxy 객체 생성 : 조회한 데이터를 Projection Interface의 메서드와 매칭하여 프록시 객체를 자동으로 생성
  4. 결과 반환 : 프록시 객체가 Projection Interface를 구현하고 있음 → getXxx() 호출 시 값이 반환됨

JPA가 내부적으로 프록시 객체를 생성

Spring Data JPA가 SQL 실행 후 인터페이스를 구현한 익명 프록시 객체를 생성하여 값을 주입합니다.

JPA 내부에서 생성되는 익명 클래스 (실제 코드 X, 개념 설명용)

class RoutineMeDetailMappingProxy implements RoutineMeDetailMapping {
    private Long id;
    private Long routineId;
    private String routineSubject;
    private Integer count;
    private LocalDateTime createDate;
    public RoutineMeDetailMappingProxy(Long id, Long routineId, String routineSubject, Integer count, LocalDateTime createDate) {
        this.id = id;
        this.routineId = routineId;
        this.routineSubject = routineSubject;
        this.count = count;
        this.createDate = createDate;
    }
    @Override
    public Long getId() { return id; }
    @Override
    public Long getRoutineId() { return routineId; }
    @Override
    public String getRoutineSubject() { return routineSubject; }
    @Override
    public Integer getCount() { return count; }
    @Override
    public LocalDateTime getCreateDate() { return createDate; }
}

JPA가 SQL 결과를 기반으로 익명 클래스를 자동 생성하며, RoutineMeDetailMapping을 구현하는 프록시 객체가 자동으로 생성됩니다.
만약에,

List<RoutineMeDetailMapping> result = myRoutineRepository.findMyRoutineByUserOrderByCreateDateDesc(user);
for (RoutineMeDetailMapping routine : result) {
    System.out.println(routine.getRoutineSubject());  // 프록시 객체에서 subject 값 반환
}

이렇게 인터페이스에 있는 get 함수를 호출할 경우, 프록시 객체가 내부적으로 값을 반환해줍니다.


이 익명 객체는 어디에 저장되는 걸까?

JPA의 엔티티 관리 컨텍스트(EntityManager 내부 캐시)에 저장되지 않고, 단순히 메모리(힙 영역)에 생성됩니다. 왜냐하면, Projection Interface는 엔티티가 아니라 특정 조회 결과를 매핑하기 위한 가벼운 DTO 역할을 하기 때문입니다.

Spring Data JPA에서 Projection Interface를 사용하면, 조회된 데이터는 프록시 객체로 변환되어 메모리에 저장됩니다.

특징

  • JPA의 1차 캐시(EntityManager 내부 캐시)에는 저장되지 않는다.
  • 단순히 조회 결과를 담고 있을 뿐, 엔티티처럼 영속성 컨텍스트에서 관리되지 않는다. 따라서 @Transactional 내부에서도 변경 감지가 적용되지 않는다.
  • 해당 Projection 객체를 참조하는 변수가 없어지면 GC 대상이 된다.

흐름

  1. SQL 실행 후 결과(ResultSet)가 JDBC 드라이버를 통해 애플리케이션으로 전달된다.
  2. JPA가 해당 결과를 기반으로 Projection Interface를 구현하는 익명 클래스를 생성한다.
  3. 생성된 익명 클래스의 인스턴스가 JVM 힙 메모리에 저장된다.
  4. List 형태로 반환되며, 필요한 필드만 조회되기 때문에 메모리 사용량이 적다.

Projection Interface vs 엔티티의 저장 방식 비교

특징Projection Interface (프록시 객체)JPA 엔티티 (영속 객체)
저장 위치JVM 힙 메모리JPA 1차 캐시 (Persistence Context)
변경 감지변경 감지 안 됨변경 감지 가능
SQL 실행 방식필요한 필드만 SELECT전체 엔티티를 SELECT
영속성 관리JPA가 관리하지 않음JPA가 관리
목적조회 성능 최적화 (DTO 역할)CRUD 및 영속성 유지

즉, Projection Interface는 데이터를 일시적으로 보관하는 가벼운 객체이고, 엔티티처럼 관리되지 않습니다. 조회 성능을 높이는 데 최적화된 방식입니다.


다양한 Projection 방식

1. Open Projection (SpEL 지원)

인터페이스의 메서드에서 필드 값을 조작할 수도 있습니다.

public interface RoutineSummary {
    String getRoutineSubject();
    
    @Value("#{target.routineSubject + ' (' + target.count + '회)'}")
    String getRoutineInfo();
}

@Value("#{target.필드}")를 사용하면 SpEL을 활용해 동적인 값을 생성할 수 있습니다.

  • JSON 결과
{
  "routineSubject": "Morning Run",
  "routineInfo": "Morning Run (5회)"
}

2. Close Projection (Entity 그대로 매핑)

RoutineMeDetailMapping처럼 필드만 정의하면, 해당 필드만 조회하는 SQL이 실행됩니다.

  • 장점: 불필요한 데이터를 조회하지 않으므로 성능이 좋음
  • 단점: 연관 엔티티를 조회할 수 없음 (target.someMethod() 같은 로직 불가능)

3. DTO Projection (클래스 기반)

인터페이스 대신 DTO 클래스를 활용하는 방법도 있습니다.

public class RoutineDTO {
    private Long id;
    private String routineSubject;
    
    public RoutineDTO(Long id, String routineSubject) {
        this.id = id;
        this.routineSubject = routineSubject;
    }
}
public interface RoutineRepository extends JpaRepository<Routine, Long> {
    @Query("SELECT new com.example.RoutineDTO(r.id, r.routineSubject) FROM Routine r WHERE r.routineSubject = :subject")
    List<RoutineDTO> findRoutineDTO(@Param("subject") String subject);
}

SELECT new를 사용하여 DTO 객체를 직접 생성해 반환합니다.

  • 단점: JPQL을 직접 작성해야 합니다.

Projection Interface vs DTO Projection

특징Projection InterfaceDTO Projection
코드 간결성인터페이스 정의만 하면 됨DTO 클래스 + JPQL 필요
성능 최적화필요한 필드만 조회 (불필요한 쿼리 X)SELECT new 사용 시 전체 조회 가능성
필드 조작 가능 여부Open Projection으로 조작 가능DTO 생성자에서 가공 가능
연관 엔티티 조회기본적으로 불가능 (Join 시 DTO 권장)JPQL로 해결 가능

단순한 조회만 한다면, Projection Interface
복잡한 로직 포함된 데이터 조회를 하고 싶다면, DTO 방식
을 쓰는 게 좋습니다.


이번 멍립선언 프로젝트에서 팀원 분이 DTO projection을 사용하는 것을 보고, 제 예전 무지성 코드 작성에서 썼던 것이 생각나 '난 이렇게 썼었는데 이거랑 다른건가? 뭐가 좋지?' 라는 궁금증에서 찾아보다가 이제서야 체증이 풀리는 것 같네요.

profile
멋진 개발자가 될테야

0개의 댓글