DTO Projection — 엔티티 대신 딱 필요한 데이터만 조회하는 전략
테이블에 컬럼이 30개인데, API 응답에 필요한 건 딱 3개뿐이라면 — 나머지 27개는 왜 조회하고 있는 걸까?
JPA를 사용하면 기본적으로 엔티티 전체를 조회합니다. findById든 findAll이든, SELECT 절에는 엔티티의 모든 컬럼이 들어갑니다. 데이터가 적을 때는 문제가 안 되지만, 트래픽이 늘고 테이블 컬럼이 많아지면 불필요한 데이터 전송이 성능 병목이 됩니다.
DTO Projection 은 이 문제를 해결하는 전략입니다. 엔티티 대신 필요한 필드만 담은 DTO를 직접 조회하는 방식이죠.
예제에서 사용할 엔티티
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
// ... getter, 생성자 생략
}
@Entity
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String teamName;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
이제 이 엔티티를 기반으로 다양한 Projection 방식을 살펴보겠습니다.
1. Interface-based Projection (Closed Projection)
가장 간단한 방식입니다. 인터페이스를 정의하고, getter 메서드명으로 필드를 매핑합니다.
// 필요한 필드만 getter로 선언
public interface MemberSummary {
String getName();
String getEmail();
}
public interface MemberRepository extends JpaRepository<Member, Long> {
// 반환 타입을 인터페이스로 지정하면 Projection 적용
List<MemberSummary> findByAge(int age);
}
실행되는 SQL을 보면 차이가 확실합니다.
-- 엔티티 조회: 모든 컬럼
SELECT m.id, m.name, m.email, m.age, m.team_id FROM member m WHERE m.age = ?
-- Interface Projection: 필요한 컬럼만
SELECT m.name, m.email FROM member m WHERE m.age = ?
Spring Data가 인터페이스를 보고 프록시 객체를 자동 생성합니다. 별도의 구현체를 만들 필요가 없어서 편리합니다.
Closed Projection은 SELECT 절이 getter 메서드에 의해 결정되기 때문에 쿼리 최적화가 가능합니다. 반대로 Open Projection은 모든 컬럼을 조회한 뒤 가공하므로 성능 이점이 없습니다.
Open Projection
@Value 어노테이션으로 SpEL 표현식을 사용하면 Open Projection이 됩니다.
public interface MemberInfo {
// SpEL 표현식으로 값 가공
@Value("#{target.name + ' (' + target.email + ')'}")
String getDisplayName();
}
편리하지만 내부적으로는 엔티티 전체를 조회합니다. 성능 최적화가 목적이라면 Closed Projection을 사용해야 합니다.
2. Class-based Projection (DTO Projection)
인터페이스 대신 클래스(또는 record)를 사용하는 방식입니다.
// Java record로 불변 DTO 정의
public record MemberDto(String name, String email) {
// 생성자 파라미터명이 엔티티 필드명과 일치해야 함
}
public interface MemberRepository extends JpaRepository<Member, Long> {
// 클래스를 반환 타입으로 사용
List<MemberDto> findByAge(int age);
}
Class Projection은 프록시가 아니라 실제 객체를 생성합니다. 생성자 파라미터명과 엔티티 필드명이 일치해야 매핑이 동작합니다. Java record를 사용하면 불변 객체를 간결하게 만들 수 있어 실무에서 가장 많이 쓰이는 패턴입니다.
Interface vs Class — 어떤 걸 써야 할까?
| 기준 | Interface Projection | Class Projection |
|---|---|---|
| 구현 난이도 | getter만 선언 | 클래스/record 정의 필요 |
| 불변성 | 보장 안 됨 (프록시) | record로 보장 가능 |
| 생성자 로직 | 불가 | 가능 |
| 중첩 Projection | 가능 | 불가 |
| 디버깅 | 프록시라 불편 | 일반 객체라 편리 |
단순 조회라면 Interface, 변환 로직이 필요하거나 불변 객체가 중요하다면 Class를 추천합니다.
3. @Query + new 생성자 표현식
JPQL에서 new 키워드를 사용해 DTO를 직접 생성할 수 있습니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// FQCN(전체 패키지 경로)을 작성해야 함
@Query("SELECT new com.example.dto.MemberDto(m.name, m.email) " +
"FROM Member m WHERE m.age > :age")
List<MemberDto> findMemberDtoByAge(@Param("age") int age);
}
// DTO에 매칭되는 생성자가 반드시 있어야 함
public record MemberDto(String name, String email) {}
이 방식의 장점은 조인한 결과를 자유롭게 조합 할 수 있다는 점입니다.
// 팀 이름까지 포함하는 DTO
public record MemberWithTeamDto(String name, String email, String teamName) {}
@Query("SELECT new com.example.dto.MemberWithTeamDto(m.name, m.email, t.teamName) " +
"FROM Member m JOIN m.team t " +
"WHERE m.age > :age")
List<MemberWithTeamDto> findMemberWithTeam(@Param("age") int age);
패키지 경로가 길어지면 가독성이 떨어지는 게 이 방식의 최대 단점입니다. Hibernate 6부터는 패키지 경로 없이 클래스명만 써도 되도록 개선되었지만, Spring Data JPA 버전에 따라 지원 여부가 다를 수 있으니 확인이 필요합니다.
4. Tuple Projection
JPQL의 Tuple을 사용하면 DTO 클래스 없이도 여러 컬럼을 조회할 수 있습니다.
@Query("SELECT m.name AS name, m.email AS email FROM Member m WHERE m.age > :age")
List<Tuple> findMemberTupleByAge(@Param("age") int age);
// 사용하는 쪽
List<Tuple> results = memberRepository.findMemberTupleByAge(20);
for (Tuple tuple : results) {
String name = tuple.get("name", String.class); // alias로 접근
String email = tuple.get("email", String.class);
}
Tuple은 빠르게 프로토타이핑할 때 유용하지만, 타입 안전성이 없고 alias 문자열에 의존합니다.
Tuple은 컴파일 타임에 타입 체크가 안 되므로, 프로덕션 코드보다는 임시 조회나 복잡한 통계 쿼리에서 제한적으로 사용하는 것을 권장합니다.
5. Dynamic Projection
제네릭 타입 파라미터를 사용하면, 호출 시점에 Projection 타입을 결정할 수 있습니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// 제네릭으로 Projection 타입을 동적으로 결정
<T> List<T> findByAge(int age, Class<T> type);
}
// 상황에 따라 다른 Projection 사용
List<MemberSummary> summaries = memberRepository.findByAge(25, MemberSummary.class);
List<MemberDto> dtos = memberRepository.findByAge(25, MemberDto.class);
List<Member> entities = memberRepository.findByAge(25, Member.class); // 엔티티도 가능
하나의 메서드로 여러 용도를 커버할 수 있어 유연합니다. 다만 남용하면 어떤 Projection이 쓰이는지 추적하기 어려워질 수 있으니, 실제로 여러 Projection이 필요한 경우에만 사용하는 게 좋습니다.
6. Native Query Projection
네이티브 쿼리에서도 Projection을 사용할 수 있습니다. 다만 Interface Projection만 지원 됩니다.
public interface MemberNativeProjection {
String getName();
String getEmail();
}
// 네이티브 쿼리 + Interface Projection
@Query(value = "SELECT m.name AS name, m.email AS email " +
"FROM member m WHERE m.age > :age",
nativeQuery = true)
List<MemberNativeProjection> findByAgeNative(@Param("age") int age);
주의할 점은 컬럼 alias가 인터페이스의 getter 메서드명과 정확히 일치 해야 한다는 것입니다. getName() 메서드가 있으면 SQL에서도 AS name으로 alias를 맞춰야 합니다.
네이티브 쿼리에서 Class Projection(DTO, record)을 직접 사용하는 건 Spring Data JPA가 기본적으로 지원하지 않습니다. 꼭 필요하다면
@SqlResultSetMapping이나Transformers를 사용해야 하는데, 복잡해지므로 가급적 Interface Projection으로 해결하는 게 깔끔합니다.
7. 성능 비교: Entity vs Projection
실제로 얼마나 차이가 날까요? 간단한 시나리오로 비교해보겠습니다.
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private int price;
private String description; // TEXT 타입, 길이가 김
private byte[] thumbnail; // BLOB 타입, 이미지 데이터
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
// ... 나머지 20개 필드 생략
}
목록 화면에서 이름과 가격만 필요한 경우를 비교합니다.
// 방법 1: 엔티티 전체 조회
List<Product> products = productRepository.findAll();
// → SELECT p.id, p.name, p.price, p.description, p.thumbnail, ... FROM product p
// 방법 2: Projection 조회
public record ProductListItem(String name, int price) {}
List<ProductListItem> items = productRepository.findAllBy(ProductListItem.class);
// → SELECT p.name, p.price FROM product p
| 항목 | 엔티티 전체 조회 | Projection |
|---|---|---|
| SELECT 컬럼 수 | 20개+ | 2개 |
| 네트워크 전송량 | BLOB, TEXT 포함 | 최소한의 데이터 |
| 영속성 컨텍스트 | 관리됨 (메모리 사용) | 관리 안 됨 |
| 변경 감지 | 활성화 | 없음 |
| 연관 엔티티 로딩 | 프록시 생성 (N+1 위험) | 없음 |
조회 전용 API에서는 Projection을 쓰는 것만으로도 메모리 사용량과 쿼리 성능이 확연히 개선됩니다. 특히 BLOB이나 TEXT 같은 대용량 컬럼이 있는 테이블에서 효과가 큽니다.
8. Projection과 N+1 문제
N+1 문제의 핵심은 연관 엔티티를 로딩하면서 추가 쿼리가 발생하는 것입니다. Projection은 이 문제를 원천적으로 차단 합니다.
// 엔티티 조회 → team 접근 시 추가 쿼리 발생 (N+1)
List<Member> members = memberRepository.findAll();
for (Member m : members) {
System.out.println(m.getTeam().getTeamName()); // 여기서 추가 쿼리!
}
// Projection 조회 → 필요한 데이터를 한 번에 가져옴
public record MemberWithTeamName(String name, String teamName) {}
@Query("SELECT new com.example.dto.MemberWithTeamName(m.name, t.teamName) " +
"FROM Member m JOIN m.team t")
List<MemberWithTeamName> findAllWithTeamName();
// → 쿼리 딱 1번. N+1 발생할 여지 자체가 없음
Projection은 엔티티가 아니므로 영속성 컨텍스트에 의해 관리되지 않습니다. 지연 로딩 프록시도 없고, 변경 감지도 없습니다. 그래서 연관 엔티티에 접근해서 추가 쿼리가 나가는 일 자체가 일어나지 않습니다.
9. 실전 패턴 정리
패턴 1: 목록 조회는 Projection, 상세 조회는 Entity
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
// 목록: 가벼운 Projection
public List<MemberSummary> getMembers() {
return memberRepository.findAllBy(MemberSummary.class);
}
// 상세: 엔티티 (수정이 필요하니까)
public Member getMember(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("회원을 찾을 수 없습니다"));
}
}
패턴 2: 통계 쿼리에 Tuple 또는 전용 DTO 활용
// 팀별 멤버 수 통계
public record TeamStats(String teamName, long memberCount) {}
@Query("SELECT new com.example.dto.TeamStats(t.teamName, COUNT(m)) " +
"FROM Team t LEFT JOIN t.members m " +
"GROUP BY t.teamName")
List<TeamStats> findTeamStats();
패턴 3: QueryDSL + Projection 조합
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
public List<MemberDto> searchMembers(String keyword) {
return queryFactory
.select(Projections.constructor(MemberDto.class,
member.name,
member.email))
.from(member)
.where(member.name.contains(keyword))
.fetch();
}
}
QueryDSL의 Projections.constructor, Projections.bean, Projections.fields 등을 사용하면 FQCN 문제 없이 타입 안전하게 Projection을 사용할 수 있습니다.
어떤 Projection을 선택할까?
결정이 어렵다면 이 순서로 고려해보세요.
- Spring Data 쿼리 메서드로 충분한가? → Interface 또는 Class Projection
- ** 조인이 필요한가?** →
@Query+new생성자 표현식 - ** 동적 쿼리가 필요한가?** → QueryDSL + Projections
- ** 네이티브 쿼리를 써야 하는가?** → Interface Projection + alias 매핑
- ** 빠른 프로토타이핑?** → Tuple (나중에 DTO로 리팩토링)
그리고 가장 중요한 원칙 하나.
** 조회 전용 로직에서는 엔티티를 반환하지 마세요.** 엔티티를 반환하면 불필요한 컬럼 조회, 영속성 컨텍스트 관리 비용, N+1 위험이 함께 따라옵니다. Projection을 기본으로 쓰고, 수정이 필요한 경우에만 엔티티를 조회하는 습관을 들이면 JPA 성능 문제의 상당 부분을 예방할 수 있습니다.