Repository 인터페이스, 쿼리 메서드, JPQL, 페이징, 동적 쿼리를 학습합니다.
Spring Data JPA란?
JPA를 더 쉽게 사용할 수 있도록 추상화한 Spring 프로젝트입니다. 반복적인 CRUD 코드를 자동 생성하고, 메서드 이름만으로 쿼리를 만들어줍니다.
┌─────────────────────────────────────────────────────────────┐
│ Repository 계층 구조 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Repository<T, ID> ← 최상위 마커 인터페이스 │
│ │ │
│ ▼ │
│ CrudRepository<T, ID> ← 기본 CRUD 메서드 │
│ │ save, findById, delete │
│ ▼ │
│ PagingAndSortingRepository ← 페이징, 정렬 추가 │
│ │ findAll(Pageable) │
│ ▼ │
│ JpaRepository<T, ID> ← JPA 특화 기능 │
│ flush, saveAndFlush │
│ deleteInBatch │
└─────────────────────────────────────────────────────────────┘| 인터페이스 | 주요 메서드 | 사용 시점 |
|---|---|---|
| Repository | 없음 (마커) | 커스텀 메서드만 정의할 때 |
| CrudRepository | save, findById, findAll, delete, count, existsById | 기본 CRUD만 필요할 때 |
| PagingAndSortingRepository | findAll(Sort), findAll(Pageable) | 페이징/정렬이 필요할 때 |
| JpaRepository | flush, saveAndFlush, deleteInBatch, getOne | JPA 사용 시 (권장) |
public interface MemberRepository extends JpaRepository<Member, Long> {
// JpaRepository가 제공하는 메서드들 (자동 구현)
}
// === 저장 ===
Member member = new Member("kim");
memberRepository.save(member); // INSERT or UPDATE (merge)
memberRepository.saveAndFlush(member); // save + 즉시 flush
memberRepository.saveAll(List.of(m1, m2)); // 여러 건 저장
// === 조회 ===
Optional<Member> member = memberRepository.findById(1L);
List<Member> members = memberRepository.findAll();
List<Member> members = memberRepository.findAllById(List.of(1L, 2L, 3L));
boolean exists = memberRepository.existsById(1L);
long count = memberRepository.count();
// === 삭제 ===
memberRepository.delete(member); // 단건 삭제
memberRepository.deleteById(1L); // ID로 삭제
memberRepository.deleteAll(); // 전체 삭제 (N개 쿼리)
memberRepository.deleteAllInBatch(); // 전체 삭제 (1개 쿼리)
memberRepository.deleteAllByIdInBatch(List.of(1L, 2L)); // 배치 삭제
// === JPA 특화 ===
memberRepository.flush(); // 영속성 컨텍스트 flush
Member ref = memberRepository.getReferenceById(1L); // 프록시 반환// SimpleJpaRepository의 save() 구현
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity); // INSERT
return entity;
} else {
return em.merge(entity); // UPDATE (SELECT + UPDATE)
}
}
// isNew() 판단 기준
// 1. @Id가 null이면 새로운 엔티티
// 2. @Id가 primitive 타입(long)이고 0이면 새로운 엔티티
// 3. @Version이 null이면 새로운 엔티티
// 4. Persistable 인터페이스 구현 시 isNew() 메서드로 판단⚠️ merge()의 문제점
merge()는 항상 SELECT 쿼리를 먼저 실행합니다. ID를 직접 할당하는 경우 불필요한 SELECT가 발생할 수 있습니다.
// ID를 직접 할당하는 경우 Persistable 구현
@Entity
public class Item implements Persistable<String> {
@Id
private String id; // 직접 할당
@CreatedDate
private LocalDateTime createdDate;
@Override
public String getId() {
return id;
}
@Override
public boolean isNew() {
// createdDate가 null이면 새로운 엔티티
return createdDate == null;
}
}
// 사용
Item item = new Item("ITEM-001");
itemRepository.save(item); // persist() 호출 (SELECT 없음)┌─────────────────────────────────────────────────────────────┐
│ Spring Data JPA 프록시 생성 과정 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. @EnableJpaRepositories 스캔 │
│ └─ basePackages 하위의 Repository 인터페이스 탐색 │
│ │
│ 2. JpaRepositoryFactoryBean 생성 │
│ └─ 각 Repository 인터페이스마다 FactoryBean 생성 │
│ │
│ 3. 프록시 객체 생성 │
│ └─ JdkDynamicAopProxy 또는 CGLIB 프록시 │
│ └─ SimpleJpaRepository를 타겟으로 설정 │
│ │
│ 4. 쿼리 메서드 분석 │
│ └─ 메서드 이름 파싱 → JPQL 생성 │
│ └─ @Query 어노테이션 → 직접 JPQL 사용 │
│ │
│ 5. 빈 등록 │
│ └─ 프록시 객체를 Spring 컨테이너에 등록 │
│ │
└─────────────────────────────────────────────────────────────┘// 실제 주입되는 객체 확인
@Repository
public class MemberService {
@Autowired
private MemberRepository memberRepository;
public void checkProxy() {
System.out.println(memberRepository.getClass());
// 출력: class jdk.proxy3.$Proxy123
// 또는: class com.sun.proxy.$Proxy123
}
}Section 1 핵심 정리
public interface MemberRepository extends JpaRepository<Member, Long> {
// === 기본 조회 ===
List<Member> findByUsername(String username);
// SELECT * FROM member WHERE username = ?
Optional<Member> findByEmail(String email);
// SELECT * FROM member WHERE email = ?
// === 복합 조건 ===
List<Member> findByUsernameAndAge(String username, int age);
// SELECT * FROM member WHERE username = ? AND age = ?
List<Member> findByUsernameOrEmail(String username, String email);
// SELECT * FROM member WHERE username = ? OR email = ?
// === 비교 연산 ===
List<Member> findByAgeGreaterThan(int age);
// SELECT * FROM member WHERE age > ?
List<Member> findByAgeGreaterThanEqual(int age);
// SELECT * FROM member WHERE age >= ?
List<Member> findByAgeLessThan(int age);
// SELECT * FROM member WHERE age < ?
List<Member> findByAgeBetween(int start, int end);
// SELECT * FROM member WHERE age BETWEEN ? AND ?
}| 키워드 | 예시 | JPQL |
|---|---|---|
| Is, Equals | findByUsername(Is) | where x.username = ? |
| Between | findByAgeBetween | where x.age between ? and ? |
| LessThan | findByAgeLessThan | where x.age < ? |
| LessThanEqual | findByAgeLessThanEqual | where x.age <= ? |
| GreaterThan | findByAgeGreaterThan | where x.age > ? |
| After/Before | findByStartDateAfter | where x.startDate > ? |
| IsNull, Null | findByAgeIsNull | where x.age is null |
| IsNotNull, NotNull | findByAgeNotNull | where x.age is not null |
| Like | findByUsernameLike | where x.username like ? |
| StartingWith | findByUsernameStartingWith | where x.username like ?% |
| EndingWith | findByUsernameEndingWith | where x.username like %? |
| Containing | findByUsernameContaining | where x.username like %?% |
| In | findByAgeIn(Collection) | where x.age in (?) |
| NotIn | findByAgeNotIn(Collection) | where x.age not in (?) |
| True/False | findByActiveTrue | where x.active = true |
| IgnoreCase | findByUsernameIgnoreCase | where UPPER(x.username) = UPPER(?) |
| OrderBy | findByAgeOrderByUsernameDesc | where x.age = ? order by x.username desc |
| Not | findByUsernameNot | where x.username <> ? |
public interface MemberRepository extends JpaRepository<Member, Long> {
// === 단건 조회 ===
Member findByUsername(String username); // null 가능
Optional<Member> findByEmail(String email); // Optional 권장
// === 컬렉션 조회 ===
List<Member> findByAgeGreaterThan(int age); // 빈 리스트 반환
Collection<Member> findByTeamName(String name);
Set<Member> findByStatus(Status status);
// === 페이징 ===
Page<Member> findByAge(int age, Pageable pageable); // 카운트 쿼리 포함
Slice<Member> findByTeam(Team team, Pageable pageable); // 카운트 없음
List<Member> findByUsername(String username, Pageable pageable);
// === 스트림 ===
Stream<Member> findAllByAgeGreaterThan(int age); // try-with-resources 필수
// === 비동기 ===
@Async
Future<Member> findByEmail(String email);
@Async
CompletableFuture<Member> findByUsername(String username);
// === 카운트/존재 여부 ===
long countByAge(int age);
boolean existsByEmail(String email);
// === 삭제 ===
long deleteByUsername(String username); // 삭제된 건수 반환
List<Member> removeByAge(int age); // 삭제된 엔티티 반환
}// 엔티티 구조
@Entity
public class Member {
@ManyToOne
private Team team;
@Embedded
private Address address;
}
@Entity
public class Team {
private String name;
}
@Embeddable
public class Address {
private String city;
private String street;
}
// Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
// 연관 엔티티 프로퍼티 접근 (Team.name)
List<Member> findByTeamName(String teamName);
// SELECT m FROM Member m WHERE m.team.name = ?
// Embedded 프로퍼티 접근
List<Member> findByAddressCity(String city);
// SELECT m FROM Member m WHERE m.address.city = ?
// 깊은 중첩도 가능
List<Member> findByTeamLeaderName(String leaderName);
// SELECT m FROM Member m WHERE m.team.leader.name = ?
}
// ⚠️ 주의: 프로퍼티 이름 충돌
// Member에 teamName 필드가 있으면 team.name이 아닌 teamName으로 해석
// 해결: findByTeam_Name(String name) - 언더스코어로 명시적 구분public interface MemberRepository extends JpaRepository<Member, Long> {
// === First, Top으로 결과 제한 ===
Member findFirstByOrderByAgeDesc(); // 가장 나이 많은 1명
Member findTopByOrderByAgeDesc(); // 동일
List<Member> findFirst3ByOrderByAgeDesc(); // 상위 3명
List<Member> findTop10ByTeamName(String name); // 팀별 상위 10명
// === 정렬 ===
List<Member> findByTeamNameOrderByAgeDesc(String teamName);
List<Member> findByTeamNameOrderByAgeDescUsernameAsc(String teamName);
// === Sort 파라미터 사용 ===
List<Member> findByTeamName(String teamName, Sort sort);
}
// Sort 사용 예시
Sort sort = Sort.by(Sort.Direction.DESC, "age");
List<Member> members = memberRepository.findByTeamName("teamA", sort);
// 복합 정렬
Sort sort = Sort.by(
Sort.Order.desc("age"),
Sort.Order.asc("username")
);
// TypeSafe Sort (권장)
Sort sort = Sort.by(
Sort.Order.desc("age").nullsLast(),
Sort.Order.asc("username").ignoreCase()
);Section 2 핵심 정리
public interface MemberRepository extends JpaRepository<Member, Long> {
// 이름 기반 파라미터 (권장)
@Query("select m from Member m where m.username = :username and m.age = :age")
List<Member> findByUsernameAndAge(
@Param("username") String username,
@Param("age") int age
);
// 컬렉션 파라미터
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
}// DTO 정의
@Getter @AllArgsConstructor
public class MemberDto {
private Long id;
private String username;
private String teamName;
}
// Repository - new 연산자로 DTO 생성 (패키지명 포함 필수)
@Query("select new com.example.dto.MemberDto(m.id, m.username, m.team.name) " +
"from Member m join m.team")
List<MemberDto> findMemberDtos();// 벌크 UPDATE - clearAutomatically로 영속성 컨텍스트 초기화
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
// 벌크 DELETE
@Modifying(clearAutomatically = true)
@Query("delete from Member m where m.age < :age")
int bulkDelete(@Param("age") int age);벌크 연산은 영속성 컨텍스트를 무시합니다. clearAutomatically = true 필수!
핵심 정리
// Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable); // COUNT 쿼리 포함
Slice<Member> findSliceByAge(int age, Pageable pageable); // COUNT 없음
List<Member> findListByAge(int age, Pageable pageable); // 단순 리스트
}
// 사용
PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(20, pageRequest);
// Page 정보 활용
List<Member> content = page.getContent(); // 데이터
int totalPages = page.getTotalPages(); // 전체 페이지 수
long totalElements = page.getTotalElements(); // 전체 데이터 수
boolean hasNext = page.hasNext(); // 다음 페이지 존재 여부Page<T>
Slice<T>
List<T>
// 복잡한 조인이 있는 경우 COUNT 쿼리 분리
@Query(value = "select m from Member m left join m.team t",
countQuery = "select count(m) from Member m") // 조인 없이 카운트
Page<Member> findByAge(int age, Pageable pageable);// Page<Entity>를 Page<DTO>로 변환
Page<Member> page = memberRepository.findByAge(20, pageRequest);
Page<MemberDto> dtoPage = page.map(member -> new MemberDto(
member.getId(),
member.getUsername(),
member.getTeam().getName()
));핵심 정리
// Repository - JpaSpecificationExecutor 상속
public interface MemberRepository extends
JpaRepository<Member, Long>,
JpaSpecificationExecutor<Member> {
}
// Specification 정의
public class MemberSpecs {
public static Specification<Member> hasUsername(String username) {
return (root, query, cb) ->
username == null ? null : cb.equal(root.get("username"), username);
}
public static Specification<Member> hasTeam(String teamName) {
return (root, query, cb) -> {
if (teamName == null) return null;
Join<Member, Team> team = root.join("team", JoinType.LEFT);
return cb.equal(team.get("name"), teamName);
};
}
public static Specification<Member> ageGreaterThan(Integer age) {
return (root, query, cb) ->
age == null ? null : cb.greaterThan(root.get("age"), age);
}
}// 동적 쿼리 조합
List<Member> members = memberRepository.findAll(
Specification.where(MemberSpecs.hasUsername("kim"))
.and(MemberSpecs.hasTeam("teamA"))
.and(MemberSpecs.ageGreaterThan(20))
);
// 서비스에서 조건에 따라 동적 조합
public List<Member> searchMembers(MemberSearchCondition condition) {
Specification<Member> spec = Specification.where(null);
if (condition.getUsername() != null) {
spec = spec.and(MemberSpecs.hasUsername(condition.getUsername()));
}
if (condition.getTeamName() != null) {
spec = spec.and(MemberSpecs.hasTeam(condition.getTeamName()));
}
if (condition.getAgeGoe() != null) {
spec = spec.and(MemberSpecs.ageGreaterThan(condition.getAgeGoe()));
}
return memberRepository.findAll(spec);
}핵심 정리
// 1. 설정 클래스
@Configuration
@EnableJpaAuditing
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
// Spring Security에서 현재 사용자 가져오기
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
// 2. BaseEntity 정의
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
// 3. 엔티티에서 상속
@Entity
public class Member extends BaseEntity {
@Id @GeneratedValue
private Long id;
private String username;
// createdAt, updatedAt, createdBy, updatedBy 자동 관리
}// 시간만 관리하는 BaseTimeEntity
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
// 시간 + 사용자 관리하는 BaseEntity
@MappedSuperclass
@Getter
public abstract class BaseEntity extends BaseTimeEntity {
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updatedBy;
}핵심 정리
QueryDSL이란?
타입 안전한 쿼리를 자바 코드로 작성할 수 있게 해주는 프레임워크입니다. 컴파일 시점에 오류를 발견할 수 있고, IDE 자동완성을 지원합니다.
// build.gradle (Spring Boot 3.x + Gradle)
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}
// Q클래스 생성 위치 설정
def querydslDir = "$buildDir/generated/querydsl"
sourceSets {
main.java.srcDirs += [ querydslDir ]
}
tasks.withType(JavaCompile) {
options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}@Repository
@RequiredArgsConstructor
public class MemberQueryRepository {
private final JPAQueryFactory queryFactory;
// 기본 조회
public List<Member> findAll() {
return queryFactory
.selectFrom(member)
.fetch();
}
// 조건 조회
public List<Member> findByUsername(String username) {
return queryFactory
.selectFrom(member)
.where(member.username.eq(username))
.fetch();
}
// 정렬
public List<Member> findAllOrderByAge() {
return queryFactory
.selectFrom(member)
.orderBy(member.age.desc())
.fetch();
}
// 페이징
public List<Member> findWithPaging(int offset, int limit) {
return queryFactory
.selectFrom(member)
.offset(offset)
.limit(limit)
.fetch();
}
}public List<MemberDto> searchMembers(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberDto(
member.id,
member.username,
member.age,
team.name
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.fetch();
}
// 동적 조건 메서드들
private BooleanExpression usernameEq(String username) {
return hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
// 조건 조합
private BooleanExpression ageBetween(Integer ageGoe, Integer ageLoe) {
return ageGoe(ageGoe).and(ageLoe(ageLoe));
}// ToOne 관계 Fetch Join
public List<Member> findAllWithTeam() {
return queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.fetch();
}
// ToMany 관계 Fetch Join (주의: 페이징 불가)
public List<Team> findAllWithMembers() {
return queryFactory
.selectFrom(team)
.join(team.members, member).fetchJoin()
.distinct()
.fetch();
}
// 동적 Fetch Join
public List<Member> findMembers(boolean fetchTeam) {
JPAQuery<Member> query = queryFactory.selectFrom(member);
if (fetchTeam) {
query.join(member.team, team).fetchJoin();
}
return query.fetch();
}public Page<MemberDto> searchMembersPage(
MemberSearchCondition condition,
Pageable pageable) {
// 데이터 조회
List<MemberDto> content = queryFactory
.select(new QMemberDto(
member.id,
member.username,
member.age,
team.name
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// Count 쿼리 최적화
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName())
);
// 마지막 페이지면 count 쿼리 생략
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}QueryDSL Best Practices