Spring Boot 3.x에서 Spring Data JPA Repository 인터페이스를 선택하고(CrudRepository/JpaRepository), 쿼리 메서드와 Pageable로 페이징을 빠르게 적용하는 실전 시작 가이드입니다.
1) 도입 (문제 상황)
JPA로 CRUD를 만들려고 보면, “EntityManager로 직접 다 짜야 하나요?” 같은 고민이 먼저 생깁니다. 또 목록 API를 만들 때마다 페이징/정렬을 매번 수동으로 처리하다 보면 코드가 금방 지저분해져요. 이럴 때 Spring Data JPA의 Repository 인터페이스를 제대로 잡아두면 시작 속도가 확 달라집니다.
2) 핵심 개념 — Spring Data JPA Repository가 중요한 이유
Spring Data JPA의 핵심은 “반복되는 데이터 접근 코드를 인터페이스 선언만으로 표준화”하는 데 있어요. 구현체를 직접 만들지 않아도, 런타임에 프록시가 생성되어 CRUD, 페이징, 정렬, 쿼리 메서드(메서드 이름 기반 쿼리)까지 제공합니다.
CrudRepository vs JpaRepository, 무엇을 선택할까?
- CrudRepository: 정말 최소 CRUD에 집중한 인터페이스입니다. 저장/조회/삭제 같은 기본 기능만 필요하면 충분합니다.
- JpaRepository: CrudRepository를 포함하고, JPA에 특화된 기능(예:
flush(), 배치 삭제,getReferenceById()등)을 더 제공합니다. 실무에서는 대부분JpaRepository를 기본으로 선택하는 편입니다.
아래 표처럼 “기능 범위” 관점으로 보면 선택이 쉬워요.
| 구분 | CrudRepository | PagingAndSortingRepository | JpaRepository |
|---|---|---|---|
| 기본 CRUD | O | O | O |
| 페이징/정렬 | X | O | O |
| JPA 특화 기능(flush 등) | X | X | O |
| 실무 기본 선택 | 제한적 | 제한적 | 가장 흔함 |
쿼리 메서드(Query Method): “메서드 이름이 곧 쿼리”
findByEmail(...), findByStatusAndCreatedAtAfter(...)처럼 메서드 이름을 규칙에 맞게 만들면, Spring Data가 JPQL을 생성합니다.
비유하자면 “SQL을 직접 쓰기 전에, 규칙 기반 검색 UI(필터)를 먼저 제공받는 느낌”이에요. 빠르게 시작할 수 있고, 단순 조회는 코드가 매우 깔끔해집니다.
다만 이름이 너무 길어지거나 복잡한 조인이 필요해지면 유지보수가 어려워지니, 그때는 @Query(JPQL) 또는 Querydsl 같은 대안을 검토하는 흐름이 자연스럽습니다.
페이징 기본: Page vs Slice

페이징은 Pageable 하나로 정리됩니다.
- Page: 전체 개수(count 쿼리)가 필요할 때 사용합니다.
getTotalElements(),getTotalPages()등을 제공합니다. (보통 count 쿼리가 추가로 나갑니다) - Slice: “다음 페이지가 있는지” 정도만 필요할 때 사용합니다. count 쿼리를 줄여 성능에 유리할 수 있어요.
요청 파라미터로 ?page=0&size=20&sort=createdAt,desc 같은 형태를 그대로 받을 수 있어서, 목록 API 표준화에 특히 좋습니다.
flowchart LR
C["Client"] --> R["Controller: Pageable 파라미터 바인딩"]
R --> S["Service: 비즈니스 규칙"]
S --> J["Repository: JpaRepository"]
J --> D["DB"]
D --> J --> S --> R --> C
위 다이어그램은 Spring Data JPA에서 페이징 요청이 흘러가는 전형적인 경로를 보여줍니다.
3) 코드 예제 — 복붙해서 실행 가능한 Spring Boot 3.x + JPA Repository + 쿼리 메서드 + 페이징
아래 예제는 “회원 목록을 상태/이메일로 검색하고 페이징한다”는 가장 흔한 패턴입니다. H2 인메모리 DB로 바로 실행 가능하게 구성했습니다.
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
logging:
level:
org.hibernate.SQL: debug
# 운영에서는 바인딩 로그가 민감할 수 있어요. 개발 환경에서만 제한적으로 사용하세요.
org.hibernate.orm.jdbc.bind: trace
도메인(Entity)
package com.example.demo.member;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "members", indexes = {
@Index(name = "idx_members_email", columnList = "email"),
@Index(name = "idx_members_status_created", columnList = "status,createdAt")
})
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private MemberStatus status = MemberStatus.ACTIVE;
@Column(nullable = false, updatable = false)
private Instant createdAt = Instant.now();
protected Member() { }
public Member(String email, MemberStatus status) {
this.email = email;
this.status = status;
}
public Long getId() { return id; }
public String getEmail() { return email; }
public MemberStatus getStatus() { return status; }
public Instant getCreatedAt() { return createdAt; }
}
package com.example.demo.member;
public enum MemberStatus {
ACTIVE, INACTIVE
}
Repository: JpaRepository + 쿼리 메서드 + 페이징
package com.example.demo.member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// 쿼리 메서드: 상태로 페이징 조회
Page<Member> findByStatus(MemberStatus status, Pageable pageable);
// 쿼리 메서드: 이메일 부분 검색 + 페이징
Page<Member> findByEmailContainingIgnoreCase(String emailKeyword, Pageable pageable);
// 조건 조합도 가능 (복잡해지면 @Query/Querydsl 고려)
Page<Member> findByStatusAndEmailContainingIgnoreCase(MemberStatus status, String emailKeyword, Pageable pageable);
}
Service
package com.example.demo.member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional(readOnly = true)
public Page<Member> search(MemberStatus status, String q, Pageable pageable) {
if (status != null && q != null && !q.isBlank()) {
return memberRepository.findByStatusAndEmailContainingIgnoreCase(status, q.trim(), pageable);
}
if (status != null) {
return memberRepository.findByStatus(status, pageable);
}
if (q != null && !q.isBlank()) {
return memberRepository.findByEmailContainingIgnoreCase(q.trim(), pageable);
}
return memberRepository.findAll(pageable);
}
@Transactional
public void seed() {
if (memberRepository.count() > 0) return;
memberRepository.save(new Member("alice@example.com", MemberStatus.ACTIVE));
memberRepository.save(new Member("bob@example.com", MemberStatus.INACTIVE));
memberRepository.save(new Member("carol@example.com", MemberStatus.ACTIVE));
memberRepository.save(new Member("dave@example.com", MemberStatus.ACTIVE));
memberRepository.save(new Member("erin@example.com", MemberStatus.INACTIVE));
}
}
Controller: Pageable 자동 바인딩으로 페이징 API 만들기
package com.example.demo.member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
// 예: /members?page=0&size=2&sort=createdAt,desc&status=ACTIVE&q=ex
@GetMapping
public Page<MemberResponse> list(
@RequestParam(required = false) MemberStatus status,
@RequestParam(required = false) String q,
Pageable pageable
) {
Page<Member> result = memberService.search(status, q, pageable);
return result.map(MemberResponse::from);
}
}
package com.example.demo.member;
import java.time.Instant;
public record MemberResponse(Long id, String email, MemberStatus status, Instant createdAt) {
public static MemberResponse from(Member m) {
return new MemberResponse(m.getId(), m.getEmail(), m.getStatus(), m.getCreatedAt());
}
}
애플리케이션 시작 시 더미 데이터 넣기
package com.example.demo;
import com.example.demo.member.MemberService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
CommandLineRunner init(MemberService memberService) {
return args -> memberService.seed();
}
}
실행 후 아래처럼 호출해 보세요.
GET /members?page=0&size=2&sort=createdAt,descGET /members?status=ACTIVE&page=0&size=10GET /members?q=example&page=0&size=10GET /members?status=ACTIVE&q=ex&page=0&size=10&sort=email,asc
4) 실무 팁
💡 실무에서는
쿼리 메서드는 “짧고 단순한 검색”까지만 쓰는 게 유지보수에 유리합니다.findByStatusAndEmailContainingIgnoreCaseAndCreatedAtAfterAnd...처럼 길어지기 시작하면, 요구사항 변경 때 메서드 폭발이 생겨요. 이 시점부터는@Query(JPQL)로 명시하거나, 동적 조건이 많다면 Querydsl을 검토해 보세요.
💡 실무에서는
Page는 count 쿼리 비용을 꼭 의식해야 합니다. 목록 화면에서 “전체 페이지 수/총 건수”가 꼭 필요하지 않다면Slice로 바꾸는 것만으로도 트래픽이 큰 서비스에서 DB 부담이 줄어듭니다. 특히 조인이 많은 조회는 count가 더 비쌀 수 있어요.
핵심 요약: JpaRepository는 CRUD+페이징+JPA 특화 기능까지 제공해 실무 기본 선택으로 적합합니다.
핵심 요약: 쿼리 메서드는 빠른 시작에 좋지만, 길어지면 @Query/Querydsl로 전환하는 기준을 잡아두세요.
핵심 요약: 페이징은 Pageable로 표준화하고, total count가 필요 없으면 Slice로 비용을 줄일 수 있습니다.
다음 글: [JPA 엔티티 설계 원칙(연관관계 포함)]