# 스프링 데이터 JPA 실전 정리

# 프로젝트 환경설정

스프링 부트 3.x 기준 핵심 설정만 정리한다.

스프링 부트 3.x 필수 체크

  • Java 17 이상 사용
  • javax.* 패키지를 모두 jakarta.*로 변경 (예: jakarta.persistence.Entity)
  • H2 데이터베이스 2.1.214 버전 이상
  • 스프링 부트 3.2부터는 빌드를 IntelliJ가 아닌 Gradle로 선택

application.yml 핵심 설정:

spring:
    datasource:
        url: jdbc:h2:tcp://localhost/~/datajpa
        username: sa
        password:
        driver-class-name: org.h2.Driver
    jpa:
        hibernate:
            ddl-auto: create
        properties:
            hibernate:
                format_sql: true
logging.level:
    org.hibernate.SQL: debug

쿼리 파라미터 로그

운영에서는 성능 영향이 있으니 주의. 스프링 부트 3.0 이상은 버전 1.9.0 이상 사용.

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'

# 예제 도메인 모델

Member(N) : Team(1) 양방향 연관관계. Member.team이 연관관계의 주인이며 외래키를 관리한다. Team.members는 읽기 전용.

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }

    public void changeTeam(Team team) {  // 연관관계 편의 메소드
        this.team = team;
        team.getMembers().add(this);
    }
}

엔티티 설계 실무 포인트

  • @Setter는 가급적 사용하지 않는다
  • @NoArgsConstructor(access = PROTECTED): 기본 생성자는 JPA 스펙상 열어두되 외부 생성은 막는다
  • @ToString은 연관관계 없는 내부 필드만 (무한 루프 방지)
  • @ManyToOne은 반드시 LAZY로 설정

# Spring Data JPA 기능 - 공통 인터페이스

순수 JPA 리포지토리를 스프링 데이터 JPA로 대체한다. 인터페이스만 선언하면 구현체는 스프링이 자동 생성(Proxy)한다.

public interface MemberRepository extends JpaRepository<Member, Long> {
}

제네릭은 <엔티티 타입, 식별자 타입>. @Repository 애노테이션 생략 가능하며, JPA 예외를 스프링 예외로 자동 변환한다.

주요 메서드:

save(S)        // 새 엔티티면 persist, 있으면 merge
findById(ID)   // 내부에서 em.find() 호출, Optional 반환
getReferenceById(ID)  // 프록시 조회 (구 getOne, deprecated)
delete(T)      // 내부에서 em.remove() 호출
findAll(...)   // Sort, Pageable 조건 가능
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor   // 롬복: final 필드 생성자 자동 생성
public class MemberService {

    private final MemberRepository memberRepository;  // 인터페이스 타입으로 주입

    @Transactional
    public Long join(String username) {
        Member member = new Member(username);
        memberRepository.save(member);   // 구현 안 했는데 동작 (자동 생성된 메서드)
        return member.getId();
    }

위와 같이 JpaRepository를 인터페이스로 주입받아 서비스 계층에서 사용이 가능하다.

Repository                      (스프링 데이터 - 마커 인터페이스)
   ↑ 상속
CrudRepository                  (스프링 데이터 - 기본 CRUD)
   ↑ 상속
PagingAndSortingRepository      (스프링 데이터 - 정렬 + 페이징)
   ↑ 상속
JpaRepository                   (스프링 데이터 JPA - JPA 특화 기능)
   ↑ 상속 (사용자가 직접)
MemberRepository                (내가 선언하는 인터페이스)

리포지토리 상속관계는 위와 같다.

# 쿼리 메소드 기능

스프링 데이터 JPA의 핵심 기능. 실무에서는 메소드 이름 쿼리 + @Query를 주로 사용한다.

메소드 이름으로 쿼리 생성:

public interface MemberRepository extends JpaRepository<Member, Long > {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}

메소드 이름 쿼리

필드명이 바뀌면 메서드 이름도 함께 바꿔야 한다. 안 바꾸면 애플리케이션 로딩 시점에 에러 발생 → 이게 큰 장점.
파라미터가 많아지면 이름이 지저분해지므로 그때는 @Query를 사용한다.

@Query로 직접 정의 (실무에서 가장 많이 사용):

@Query("select m from Member m where m.username = :username and m.age = :age")
List<Member> findUser(@Param("username") String username, @Param("age") int age);

DTO 직접 조회 (new 명령어 필수):

@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
       "from Member m join m.team t")
List<MemberDto> findMemberDto();

컬렉션 파라미터 (in절):

@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);

쿼리 파라미터가 여러 개이면 @Param을 여러 개 나열하면 된다.

파라미터 바인딩

위치 기반이 아닌 **이름 기반(:name)**을 사용한다. 위치 기반은 순서 실수 위험.

반환 타입과 결과 없음 처리:

List<Member> findByUsername(String name);      // 컬렉션: 결과 없으면 빈 컬렉션
Member findByUsername(String name);            // 단건: 결과 없으면 null
Optional<Member> findByUsername(String name);  // 단건 Optional

단건 조회 주의

컬렉션은 절대 null이 아니므로 if (result != null) 체크는 의미 없다. 결과가 2건 이상이면 NonUniqueResultException 발생.

# NamedQuery

@Entity
@NamedQuery(
    name = "Member.findByUsername",                                  // 쿼리 이름
    query = "select m from Member m where m.username = :username")   // 실제 JPQL
public class Member {
    ...
}

public interface MemberRepository extends JpaRepository<Member, Long> {
    // @Query 생략 가능 - 메서드 이름으로 NamedQuery를 자동으로 찾음
    List<Member> findByUsername(@Param("username") String username);
}

쿼리에 이름을 붙여 미리 정의해둔 뒤, 그 이름으로 쿼리를 호출하여 사용하는 기능이다. 리포지토리와 떨어져 있어 일반적으로는 @Query만으로도 충분하다.

# 페이징과 정렬

Pageable(내부에 Sort 포함)을 파라미터로, Page/Slice/List를 반환 타입으로 사용한다.

Page<Member> findByAge(int age, Pageable pageable);   // count 쿼리 O
Slice<Member> findByAge(int age, Pageable pageable);  // count 쿼리 X, limit+1 조회
List<Member> findByAge(int age, Pageable pageable);   // count 쿼리 X, 결과만

사용 코드:

PageRequest pageRequest =
    PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(10, pageRequest);

page.getContent();          // 조회 데이터
page.getTotalElements();    // 전체 데이터 수
page.getTotalPages();       // 전체 페이지 수
page.hasNext();             // 다음 페이지 여부

PageRequest는 페이징 요청 정보(몇 번째 페이지를, 몇 개씩, 어떻게 정렬해서)를 담는 객체이다. Pageable 인터페이스의 구현체이다.

Sort.by()는 정렬 조건을 만드는 정적 팩토리 메서드이다. "어떤 필드를, 오름차순/내림차순 중 어떻게 정렬할지"를 담은 Sort 객체를 생성한다. 두 번째 파라미터에 전달한 문자열은 DB 컬럼명이 아닌 엔티티 필드명이다.

Sort.by(
    Sort.Order.desc("age"),       // age 내림차순
    Sort.Order.asc("username")    // username 오름차순
);

위와 같이 여러 정렬기준도 나열할 수 있다. 명시하지 않으면 기본적으로 ASC로 동작한다.

페이지 인덱스

페이지는 0부터 시작한다 (1부터 아님).

count 쿼리 분리 (실무 매우 중요)

Page를 반환형으로 지정한 뒤 쿼리를 수행하면 전체 데이터에 대한 count 쿼리도 수행하게 된다.

@Query("select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);

위와 같은 쿼리를 수행할 때 내부적으로 카운트 쿼리도 자동으로 생성한다.

-- 자동 생성된 count 쿼리 (조인이 그대로 따라옴)
select count(m)
from member m
left join team t on m.team_id = t.team_id;

Member <- Team으로 LEFT JOIN을 하는 경우 Member 기준으로 총 카운트 수에는 변화가 없다. 이 경우 전체 count 쿼리는 매우 무겁기 때문에, 복잡한 join 쿼리에서는 count 쿼리를 별도로 분리한다 (데이터는 left join, count는 left join 불필요).

@Query(value = "select m from Member m",
       countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);

위와 같이 쿼리를 분리하면 Page 객체 생성을 위한 쿼리가 value에 이어 countQuery에 담긴 JPQL들을 SQL로 변환하여 수행하게 된다.

하이버네이트 6에서는 LEFT JOIN을 최적화하여 SELECT / WHERE에서 조인된 테이블의 데이터를 사용하지 않으면 LEFT JOIN을 없애준다.

엔티티를 DTO로 변환 후 반환 (API에 엔티티 직접 노출 금지):

Page<MemberDto> dtoPage = page.map(m -> new MemberDto(m));

# 벌크성 수정 쿼리

@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

벌크 연산 주의

  • @Modifying 없으면 QueryExecutionRequestException 발생
  • 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 실행 → 영속성 컨텍스트 값과 DB 값이 달라짐
  • 해결: @Modifying(clearAutomatically = true)로 실행 후 영속성 컨텍스트 자동 초기화
  • 권장: 벌크 연산은 영속성 컨텍스트가 비어있을 때 먼저 실행하거나, 실행 직후 초기화

# @EntityGraph (N+1 해결)

지연 로딩 관계에서 연관 엔티티를 조회할 때마다 쿼리가 나가는 N+1 문제를 페치 조인으로 해결한다. EntityGraph는 페치 조인(LEFT OUTER JOIN)의 간편 버전이다.

LAZY fetch시 대상 엔티티 조회를 하지 않으면 불필요한 N+1을 방지할 수 있지만, 가져온 리스트 각각을 순회하며 실제로 대상 엔티티 조회가 이루어지게 되는 경우 결국 N+1 문제가 발생하게 된다.

N+1 문제 발생 시 네트워킹 비용 증가, DB 부하 증가, 데이터량에 따른 선형 조회 수 증가, 커넥션 풀 고갈 등의 문제가 발생한다.

# JPQL JOIN FETCH로 N+1 직접 해결

@EntityGraph의 간편 버전 대신 JPQL에서 JOIN FETCH를 직접 쓰는 방법도 있다.

// LAZY 로딩 — ua.getUser() 호출 시마다 SELECT 쿼리 추가 발생 (N+1)
SELECT ua FROM UserAuth ua

// JOIN FETCH — UserAuth + User를 한 번의 쿼리로 같이 로드
SELECT ua FROM UserAuth ua JOIN FETCH ua.user

JOIN FETCH를 쓰면 처음 조회 시 JOIN해서 User 데이터를 함께 가져오므로, 이후 ua.getUser()를 호출해도 추가 쿼리가 발생하지 않는다.

JOIN FETCH 문법 제약

JOIN FETCH로 로드한 연관 엔티티는 SELECT 결과로 직접 꺼낼 수 없다. SELECT 절에는 반드시 소유자가 있어야 한다.

// 가능 — 소유자(ua)를 SELECT하면서 user를 함께 로드
SELECT ua FROM UserAuth ua JOIN FETCH ua.user

// 불가능 — Hibernate SemanticException 발생
SELECT ua.user FROM UserAuth ua JOIN FETCH ua.user

연관 엔티티(ua.user)를 직접 SELECT 결과로 원하면 일반 JOIN을 쓴다. JPQL은 @ManyToOne 등 연관관계 정보를 이미 알고 있어서 ON 조건 없이 JOIN ua.user만으로 동작한다.

# JOIN vs JOIN FETCH N+1 비교

쿼리 SELECT 결과 getUser() 호출 시
SELECT ua.user ... JOIN ua.user User 직접 반환 추가 쿼리 없음
SELECT ua ... JOIN ua.user UserAuth 반환, user는 LAZY 상태 추가 SELECT 발생 (N+1)
SELECT ua ... JOIN FETCH ua.user UserAuth 반환, user도 함께 로드 추가 쿼리 없음

핵심은 연관 엔티티를 영속성 컨텍스트에 로드했느냐다. JOIN만 쓰면 조인은 하지만 연관 엔티티는 여전히 LAZY 상태로 남는다. JOIN FETCH는 소유자를 SELECT하면서 연관 엔티티도 함께 영속성 컨텍스트에 올려두기 때문에 이후 접근해도 추가 쿼리가 없다.

// 공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

// JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

// 메서드 이름 쿼리에서 특히 편리
@EntityGraph(attributePaths = {"team"}) // 엔티티 프로퍼티명
List<Member> findByUsername(String username);

EntityGraph를 쓰면 지정한 attribute 이름을 기준으로 조인을 수행하여 한 번에 데이터를 가져온다.

select * from member;                       -- 1: member 전체
select * from team where team_id = 1;       -- member1의 team
select * from team where team_id = 2;       -- member2의 team
... member 수만큼 반복                          -- N번

select m.*, t.*
from member m
left outer join team t on m.team_id = t.team_id;   -- 1번으로 끝

LAZY 페치 조인 전략보다 우선순위가 높다.

# JPA Hint & Lock

// 읽기 전용 힌트 - 스냅샷 저장 안 함, 변경 감지 X (성능 최적화)
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);

// 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);

readOnly 힌트

readOnly 힌트를 추가하면 엔티티 스냅샷을 만들지 않아 영속성 컨텍스트의 dirty checking 부담을 없애준다. 실무 전체 적용은 부담. 트래픽 많고 성능 최적화가 꼭 필요한 핵심 API에만 선택적으로 적용한다.

**비관적 락(Pessimistic Lock)**은 "충돌이 일어날 것이라 가정하고, 미리 DB 레벨에서 잠가버리는" 방식이다. 위 PESSIMISTIC_WRITE가 그 예로, 해당 행을 select ... for update로 잠가서 다른 트랜잭션이 그 데이터를 수정하지 못하게 막는다. 한 트랜잭션이 작업을 끝낼 때까지 다른 트랜잭션은 대기한다. 충돌이 빈번하고 데이터 정합성이 매우 중요한 경우(예: 재고 차감, 잔액 변경)에 쓴다.

**낙관적 락(Optimistic Lock)**은 "충돌은 드물 것이라 가정하고, 실제 충돌이 났을 때만 잡아내는" 방식이다. 보통 엔티티에 @Version 필드를 두고, 수정 시 버전이 그새 바뀌었으면 예외를 던진다. 실제로 잠그지 않으니 성능 부담이 적다.

# 사용자 정의 리포지토리

스프링 데이터 JPA로 해결 안 되는 복잡한 쿼리(주로 QueryDSL, JdbcTemplate)를 직접 구현할 때 사용한다.

// 1. 사용자 정의 인터페이스
public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}

// 2. 구현 클래스 (이름 규칙: 인터페이스명 + Impl)
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m").getResultList();
    }
}

// 3. JpaRepository와 함께 상속
public interface MemberRepository
    extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}

자바 코드상으로는 MemberRepositoryCustom 인터페이스만 상속하는 MemberRepository와, MemberRepositoryCustom을 구현한 Impl 클래스는 전혀 관계가 없다. 하지만 스프링 데이터 JPA가 네이밍 규칙(인터페이스명 + Impl)을 기반으로 구현체를 찾아 findMemberCustom 메서드를 연결해준다.

이 연결은 애플리케이션 로딩 시점에 모두 완성된다. 스프링 데이터 JPA는 MemberRepository의 메서드들을 분석하면서, findMemberCustomJpaRepository가 제공하는 기본 메서드(CRUD)도 아니고 메서드 이름으로 쿼리를 생성할 수 있는 것도 아님을 인지하고, Impl 구현체가 처리하도록 프록시에 미리 매핑해둔다.

따라서 호출부에서 MemberRepository를 주입받아 findMemberCustom을 호출하면, 로딩 시점에 이미 정해진 경로를 따라 Impl 구현체의 메서드로 위임되어 실행된다. (이름 규칙이 틀리면 호출 전, 애플리케이션 로딩 시점에 에러가 발생한다.)

memberRepository.findMemberCustom() 호출
        │
        ▼
   프록시 객체 (구동 시 매핑 완료된 상태)
        │  "이 메서드는 CustomImpl 담당" ← 이미 알고 있음
        ▼
   MemberRepositoryCustomImpl.findMemberCustom() 실행

구현 클래스 네이밍 (최신 권장)

스프링 데이터 2.x부터 리포지토리명 + Impl 대신 사용자정의인터페이스명 + Impl(예: MemberRepositoryCustomImpl)을 지원한다. 이름이 직관적이고 여러 인터페이스 분리도 가능하므로 이 방식을 권장.

항상 필요한 건 아니다

모든 복잡 쿼리에 사용자 정의 리포지토리가 필요한 건 아니다. 핵심 비즈니스 로직과 특정 화면용 쿼리는 별도 클래스(예: MemberQueryRepository)로 분리하는 것도 좋은 방법이다.

# Auditing (등록일/수정일 자동화)

// 설정 클래스에 @EnableJpaAuditing 추가
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String lastModifiedBy;
}

등록자/수정자 처리를 위한 빈 등록 (실무에서는 시큐리티 로그인 정보 사용):

@Bean
public AuditorAware<String> auditorProvider() {
    return () -> Optional.of(UUID.randomUUID().toString());
}

Base 타입 분리

대부분 엔티티는 등록/수정 시간이 필요하지만 등록자/수정자는 없을 수 있다. BaseTimeEntity(시간만)와 BaseEntity(시간+사람)로 분리해 선택 상속하는 것이 실무 패턴.

아래와 같이 BaseTimeEntity 상속을 명시해줘야 한다.

@MappedSuperclass
public abstract class BaseTimeEntity {
    @CreatedDate @Column(updatable = false)
    private Instant createdAt;
    @LastModifiedDate
    private Instant updatedAt;
}

// extends 한 엔티티 → createdAt, updatedAt 물려받음
@Entity
public class Member extends BaseTimeEntity { ... }   // ✅ 적용됨

# Web 확장 - 페이징과 정렬

컨트롤러에서 Pageable을 바로 파라미터로 받는다.

@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::new);
}

요청 파라미터: /members?page=0&size=3&sort=id,desc&sort=username,desc, 이 양식에 맞춰 요청을 하면 스프링 MVC가 Pageable 인스턴스를 자동으로 만들어준다.

기본값 설정:

spring.data.web.pageable.default-page-size: 20
spring.data.web.pageable.max-page-size: 2000

개별 설정은 @PageableDefault(size = 12, sort = "username", direction = Sort.Direction.DESC).

엔티티 직접 노출 금지

컨트롤러에서 엔티티를 그대로 반환하면 안 된다. 반드시 page.map()으로 DTO 변환해서 반환한다.

도메인 클래스 컨버터

@PathVariable로 id 대신 엔티티를 바로 받을 수 있으나, 단순 조회용으로만 사용한다. 트랜잭션 없는 범위 조회라 변경해도 DB 반영 안 됨. 실무 권장도는 낮음.

# 스프링 데이터 JPA 구현체 분석

공통 인터페이스 구현체는 SimpleJpaRepository다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> {
    @Transactional
    public <S extends T> S save(S entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);   // 새 엔티티
        } else {
            return em.merge(entity);  // 기존 엔티티
        }
    }
}

@Transactional 동작

클래스 레벨에 @Transactional(readOnly = true)가 걸려있어 트랜잭션 없이도 등록/변경이 가능했던 것(사실은 리포지토리 계층에 트랜잭션이 걸려있는 것). 서비스 계층에서 트랜잭션을 시작하면 그 트랜잭션을 전파받아 사용한다. (레포지토리 계층에 선언된 트랜잭션 설정은 무시됨)

# 새로운 엔티티 구별 방법 (중요)

스프링은 스프링이 만든 레포지토리 프록시 실제 동작을 SimpleJpaRepository에게 위임한다.

memberRepository.save(member) 호출
        ↓
스프링이 만든 프록시
        ↓
SimpleJpaRepository.save(member)  ← 실제 동작은 여기서 (스프링이 연결해둠)
        ↓
em.persist() 또는 em.merge()

실무에서는 기본적으로 EntityManager의 persist, merge를 직접 호출하지 않고 JpaRepository에서 제공하는 save 메서드를 호출한다.

save()는 새 엔티티면 persist, 아니면 merge를 호출한다. 새 엔티티 판단 기본 전략:

new Item("A")              // ID "A" 직접 할당
   ↓
memberRepository.save(item)  // save() 호출
   ↓
isNew()? → ID가 "A"로 차 있음 → "기존 엔티티" 판단
   ↓
em.merge(item)
   ↓
SELECT * from item where id = 'A'   ← 불필요한 조회 (사실 새 거)
   ↓
없으니까 INSERT
  • 식별자가 객체 타입 → null이면 새 엔티티
  • 식별자가 기본 타입 → 0이면 새 엔티티

스프링은 식별자를 기준으로 필드가 비어있는지 여부를 기준으로 하여 새 엔티티 여부를 판단한다.

merge 주의

@GeneratedValue면 save 시점에 식별자가 없어 정상 동작한다. 하지만 @Id 직접 할당이면 이미 식별자가 있어 merge()가 호출되고, merge는 DB를 먼저 조회(SELECT)한 뒤 없으면 insert → 매우 비효율적.

Merge 시 해당 아이디로 인스턴스가 이미 존재하는 상태면 조용히 데이터를 덮어씀.

해결: Persistable 인터페이스를 구현해 새 엔티티 판단 로직을 직접 정의한다. @CreatedDate를 조합하면 편리하다.

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Item implements Persistable<String> {
    @Id
    private String id;

    @CreatedDate
    private LocalDateTime createdDate;

    @Override
    public boolean isNew() {
        return createdDate == null;  // 등록일 없으면 새 엔티티
    }
}

# 나머지 기능들 (Specifications / QueryByExample / Projections / 네이티브)

공통 결론

Specifications(명세)와 Query By Example은 매칭 조건이 단순하고 LEFT JOIN이 안 되는 등 한계가 명확하다. 실무에서는 QueryDSL을 사용한다.

Projections - 엔티티 대신 필요한 필드만 조회:

// 인터페이스 기반 (Closed)
public interface UsernameOnly {
    String getUsername();
}

// 동적 Projections
<T> List<T> findProjectionsByUsername(String username, Class<T> type);

Projections 한계

프로젝션 대상이 root 엔티티면 SELECT 절 최적화가 되어 유용하다. 하지만 root를 넘어가면(중첩 구조) 최적화가 안 되고 모든 필드를 조회한다. 단순할 때만 쓰고, 복잡하면 QueryDSL.

네이티브 쿼리 - 정말 어쩔 수 없을 때만:

@Query(value = "SELECT m.member_id as id, m.username, t.name as teamName " +
       "FROM member m left join team t ON m.team_id = t.team_id",
       countQuery = "SELECT count(*) from member",
       nativeQuery = true)
Page<MemberProjection> findByNativeProjection(Pageable pageable);

네이티브 쿼리 제약

  • JPQL과 달리 로딩 시점 문법 체크 불가, 동적 쿼리 불가
  • Sort 파라미터 정상 동작 안 할 수 있음
  • DTO 변환이 까다로움 → 네이티브 + DTO는 JdbcTemplate이나 MyBatis 권장