이번 시간에는 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++; } }
Spring Data JPA의 Projection Interface!!
특정 엔티티의 일부 필드만 조회할 수 있도록 도와주는 기능입니다.
즉, DTO를 따로 만들지 않고 인터페이스를 정의하는 것만으로 필요한 데이터만 가져올 수 있습니다.
제 코드로 분석하며 한번 더 알아보도록 하겠습니다.
Projection의 뜻
- 데이터베이스에서 전체 데이터를 가져오지 않고, 필요한 부분만 선택하는 것을 의미
- "데이터의 일부분을 투영해서 가져온다"
- SQL에서 SELECT 특정 컬럼을 하는 것과 유사함
@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++;
}
}
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.id | MyRoutine의 기본키 |
getRoutineId() | Routine.id | MyRoutine이 참조하는 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이 내부적으로 프록시 객체를 생성하여 결과를 매핑해주기 때문입니다.
동작 과정
- JPQL 변환 : JPA가 Repository 메서드를 분석하여 필요한 필드만 포함하는 JPQL을 생성
- SQL 실행 : 변환된 JPQL을 기반으로 필요한 필드만 조회하는 SQL 실행
- Proxy 객체 생성 : 조회한 데이터를 Projection Interface의 메서드와 매칭하여 프록시 객체를 자동으로 생성
- 결과 반환 : 프록시 객체가 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 대상이 된다.
흐름
- SQL 실행 후 결과(ResultSet)가 JDBC 드라이버를 통해 애플리케이션으로 전달된다.
- JPA가 해당 결과를 기반으로 Projection Interface를 구현하는 익명 클래스를 생성한다.
- 생성된 익명 클래스의 인스턴스가 JVM 힙 메모리에 저장된다.
- List 형태로 반환되며, 필요한 필드만 조회되기 때문에 메모리 사용량이 적다.
특징 | Projection Interface (프록시 객체) | JPA 엔티티 (영속 객체) |
---|---|---|
저장 위치 | JVM 힙 메모리 | JPA 1차 캐시 (Persistence Context) |
변경 감지 | 변경 감지 안 됨 | 변경 감지 가능 |
SQL 실행 방식 | 필요한 필드만 SELECT | 전체 엔티티를 SELECT |
영속성 관리 | JPA가 관리하지 않음 | JPA가 관리 |
목적 | 조회 성능 최적화 (DTO 역할) | CRUD 및 영속성 유지 |
즉, Projection Interface는 데이터를 일시적으로 보관하는 가벼운 객체이고, 엔티티처럼 관리되지 않습니다. 조회 성능을 높이는 데 최적화된 방식입니다.
인터페이스의 메서드에서 필드 값을 조작할 수도 있습니다.
public interface RoutineSummary {
String getRoutineSubject();
@Value("#{target.routineSubject + ' (' + target.count + '회)'}")
String getRoutineInfo();
}
@Value("#{target.필드}")를 사용하면 SpEL을 활용해 동적인 값을 생성할 수 있습니다.
{
"routineSubject": "Morning Run",
"routineInfo": "Morning Run (5회)"
}
RoutineMeDetailMapping처럼 필드만 정의하면, 해당 필드만 조회하는 SQL이 실행됩니다.
인터페이스 대신 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 객체를 직접 생성해 반환합니다.
특징 | Projection Interface | DTO Projection |
---|---|---|
코드 간결성 | 인터페이스 정의만 하면 됨 | DTO 클래스 + JPQL 필요 |
성능 최적화 | 필요한 필드만 조회 (불필요한 쿼리 X) | SELECT new 사용 시 전체 조회 가능성 |
필드 조작 가능 여부 | Open Projection으로 조작 가능 | DTO 생성자에서 가공 가능 |
연관 엔티티 조회 | 기본적으로 불가능 (Join 시 DTO 권장) | JPQL로 해결 가능 |
단순한 조회만 한다면, Projection Interface
복잡한 로직 포함된 데이터 조회를 하고 싶다면, DTO 방식
을 쓰는 게 좋습니다.
이번 멍립선언 프로젝트에서 팀원 분이 DTO projection을 사용하는 것을 보고, 제 예전 무지성 코드 작성에서 썼던 것이 생각나 '난 이렇게 썼었는데 이거랑 다른건가? 뭐가 좋지?' 라는 궁금증에서 찾아보다가 이제서야 체증이 풀리는 것 같네요.