Spring Boot

Spring Boot에서 Spring Data JPA 시작하기: Repository 인터페이스(CrudRepository/JpaRepository)와 쿼리 메서드, 페이징 기본

IT Lab 2026. 3. 12. 10:00

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

Spring Data JPA Pageable로 페이징/정렬이 적용되는 흐름

페이징은 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,desc
  • GET /members?status=ACTIVE&page=0&size=10
  • GET /members?q=example&page=0&size=10
  • GET /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 엔티티 설계 원칙(연관관계 포함)]