-
[JPA] Spring Data JPA의 핵심 개념Data 2026. 1. 24. 21:16

이전 글에서 보았듯 Spring Data JPA의 핵심은 Repository<T, ID>를 상속받아서 application.yml이나 application.properties 등의 설정과 Entity, Entity의 ID 타입을 바탕으로 데이터베이스와 상호작용 하는 것입니다. Repository는 이러한 상호작용에 있어서 가장 핵심 요소입니다. 이번 글에서는 Repository를 바탕으로 Spring Data JPA의 핵심 개념을 알아보겠습니다.
https://msj9965.tistory.com/64
[JPA] 기본적인 Spring Data Jpa 활용
이번 글에서는 Spring Data JPA를 활용하는 기본적인 방법을 코드를 통해서 알아보겠습니다. Entity, Repository, 데이터 삽입 부분을 직접 작성하고 각 코드의 특성을 기술합니다.Person.javapackage jpabasic.jpa
msj9965.tistory.com
1. Repository
Spring Data 리포지토리 추상화의 핵심은 Repository 인터페이스이다.
이 인터페이스는 관리 대상인 도메인 클래스와 그 도메인의 식별자(identifier) 타입을 타입 인자로 받으며, 실제 기능을 제공하기보다는 마커 인터페이스(marker interface)로서의 역할을 수행한다. 즉, Spring Data가 어떤 도메인 타입과 식별자를 기준으로 동작해야 하는지를 인식하게 하고, 이를 확장한 리포지토리 인터페이스들을 활용하거나 만드는 데에 활용된다.
앞으로 설명할 때, Entity, Domain type, aggregate 등의 개념들이 나올 것입니다. 그래서 여기서 한번 정리하고 가겠습니다.
Spring Data에서 말하는 도메인 타입은 단순한 엔티티 개념뿐만 아니라, DDD(Domain-Driven Design) 관점의 애그리거트(aggregate)를 의미한다. 따라서 문서 전반에서 사용되는 entity, domain type, aggregate라는 용어는 동일한 의미로 서로 바꿔 사용될 수 있다.
DDD 관점에서 도메인 객체는 반드시 식별자(identifier)를 가진다. 식별자가 없다면 이는 값 객체(Value Object)에 해당한다. Spring Data는 데이터 접근 패턴(특히 Repository와 Query Method)을 설계할 때 이 식별자를 핵심 기준으로 삼으며, 이후 리포지토리와 쿼리 메서드를 설명할수록 식별자의 역할은 더욱 중요해진다.
2. CrudRepository
역할: CRUD의 표준화
이름에서 알 수 있듯이 Create, Read, Update, Delete라는 데이터베이스의 가장 기본적인 4가지 작업을 표준화한 인터페이스입니다.
- extends Repository<T, ID>: 앞서 보신 마커 인터페이스인 Repository를 상속받아 기능을 확장했습니다.
- 제네릭 <T, ID>: 특정 엔티티(T)와 그 식별자 타입(ID)만 지정하면, 어떤 도메인 객체든 똑같은 방식으로 데이터를 다룰 수 있게 해줍니다.
주요 어노테이션: @NoRepositoryBean
클래스 상단에 붙은 이 어노테이션은 매우 중요합니다.
- 의미: 스프링이 해당 인터페이스를 빈(Bean)으로 만들지 않도록 함.
- 이유: CrudRepository는 직접 사용하는 것이 아니라, 우리가 만드는 리포지토리(예: PersonRepository)가 상속받아서 사용하는 부모 역할이기 때문입니다. 실제 구현체는 자식 인터페이스를 만들 때 생성됩니다.
메서드 제공
코드에 정의된 메서드들은 크게 세 종류로 나뉩니다.
A. 저장 및 수정 (Create & Update)
save(S entity): 저장과 관련된 메소드입니다.
- 기능: 새로운 엔티티면 insert를, 이미 있는 엔티티면 update를 알아서 수행합니다.
- 반환값: 저장된 엔티티를 반환합니다. (내부에서 값이 변경되었을 수 있으므로, 반환된 객체를 사용하는 것이 안전합니다.)
- saveAll(Iterable entities): 여러 건을 한 번에 저장합니다. (Batch 처리에 유리)
B. 조회 (Read)
- findById(ID id): ID로 단건 조회. 반환 타입이 Optional<T>여서 null 처리를 강제합니다.
- existsById(ID id): 데이터 존재 여부만 가볍게 확인 (boolean).
- findAll(): 테이블의 모든 데이터를 가져옵니다. (데이터가 많을 땐 주의!)
- count(): 전체 데이터 개수 반환 (long).
C. 삭제 (Delete)
- deleteById(ID id): ID만으로 삭제합니다.
- delete(T entity): 엔티티 객체를 넘겨서 삭제합니다.
- 참고: 주석을 보면 "모듈이 삭제 전 엔티티를 로딩할 수도 있다"고 되어 있습니다. 이는 JPA의 라이프사이클 이벤트(@PreRemove 등)를 실행하기 위함입니다.
낙관적 락 (Optimistic Locking)
@throws OptimisticLockingFailureException 부분이 중요합니다.
- 단순한 CRUD 같지만, 내부적으로 동시성 제어를 지원합니다.
- 누군가 데이터를 수정하는 사이에 다른 사람이 같은 데이터를 수정하려 하면, 버전 충돌을 감지하고 이 예외를 던져 데이터 덮어쓰기를 방지합니다.
3. ListCrudRepository
핵심: "Iterable 대신 List를 반환한다"
기능 Iterable (반복자) List (리스트) 순회 (Loop) 가능 (iterator()) 가능 특정 값 조회 불가능 (순서대로 꺼내봐야 함) 가능 (.get(0), .get(index)) 데이터 개수 불가능 (다 세어봐야 알 수 있음) 가능 (.size()) 출력 주소값 등이 출력됨 (불친절) [a, b, c] 형태로 출력됨 (친절) 스트림 변환 복잡함 (StreamSupport 사용) 간편함 (.stream()) 이 인터페이스의 존재 이유는 다음과 같습니다.
"Extension to CrudRepository returning List instead of Iterable where applicable."
기존 CrudRepository의 메서드들(findAll, saveAll 등)은 반환 타입이 Iterable<T>였습니다.
- 불편함: Iterable은 자바에서 가장 상위 인터페이스라 기능이 거의 없습니다. .get(0)으로 인덱스 접근도 안 되고, .stream()을 바로 쓸 수도 없죠.
- 개선: 개발자들은 매번 (List<Person>) repository.findAll() 처럼 형변환을 하거나, StreamSupport를 써서 리스트로 바꾸는 번거로운 작업을 해야 했습니다. ListCrudRepository는 이를 해결해 줍니다.
오버라이딩된 주요 메서드
ListCrudRepository는 대부분의 메소드가 CrudRepository와 동일합니다. 하지만 다음과 같은 몇몇 메소드들에서 차이가 있습니다.
코드를 보시면 @Override를 통해 반환 타입만 List로 구체화한 것을 볼 수 있습니다.
메서드 CrudRepository 반환타입 ListCrudRepository 반환타입 saveAll() Iterable<S> List<S> findAll() Iterable<T> List<T> findAllById() Iterable<T> List<T> 이제 repository.findAll().get(0)이나 repository.findAll().stream()을 바로 호출할 수 있게 되었습니다.
4. PagingAndSortingRepository
가장 기본이 되는 인터페이스로, 대량의 데이터를 한꺼번에 가져와서 처리해줍니다. 이를 통해 서버의 부담을 줄여줍니다.
핵심 기능 2가지
- 정렬 (Sorting): findAll(Sort sort)
- 기능: 조건(Sort)에 맞춰 정렬된 데이터를 가져옵니다.
- 반환: Iterable<T> (순서대로 꺼낼 수 있는 형태)
- 예시: "이름 가나다순으로 전체 다 가져와."
- 페이징 (Pagination): findAll(Pageable pageable)
- 기능: 페이지 번호와 크기(Pageable)를 주면 딱 그만큼의 데이터만 잘라서 가져옵니다.
- 반환: Page<T>
- 단순 리스트가 아니라, "전체 페이지 수", "전체 데이터 개수(Total Elements)", "현재 페이지 번호" 등의 메타데이터를 포함한 객체입니다.
- 프론트엔드에서 페이지네이션 UI(1, 2, 3...)를 그릴 때 필수적인 정보입니다.
5. ListPagingAndSortingRepository
ListCrudRepository와 같은 이유로 탄생한 편의성을 높인 버전입니다. (Spring Data 3.0+부터 도입)
차이점
기존 PagingAndSortingRepository는 정렬 조회 시 terable을 반환해서 불편했습니다. 이를 List로 개선했습니다.
- findAll(Sort sort)
- 기존: Iterable<T> 반환 → 리스트 기능을 쓰려면 변환 필요.
- 개선: List<T> 반환 → .get(0), .stream() 등 리스트 기능 즉시 사용 가능.
주의: findAll(Pageable)은 오버라이딩되지 않았습니다. 페이징 결과는 List가 아니라, 페이징 정보를 담은 Page<T> 객체 자체가 중요하기 때문입니다. (물론 Page 내부의 내용물은 getContent()를 통해 List로 꺼낼 수 있습니다.)
인터페이스 findAll(Sort) 반환값 findAll(Pageable) 반환값 추천 상황 PagingAndSortingRepository Iterable<T> (불편) Page<T> 레거시 코드 호환성 필요 시 ListPagingAndSortingRepository List<T> (편리) Page<T> 최신 스프링 부트 개발 시 6. JpaRepository
JpaRepository는 지금까지 나온 많은 기능들을 포함하면서 JPA에 특화된 기능들을 제공합니다.
상속 구조: "모든 기능을 하나로 통합"

코드를 보시면 extends 키워드 뒤에 우리가 배운 것들이 모두 모여 있습니다.
public interface JpaRepository<T, ID> extends ListCrudRepository<T, ID>, // List 반환하는 CRUD 기능 ListPagingAndSortingRepository<T, ID>, // List 반환하는 페이징/정렬 QueryByExampleExecutor<T> // Example을 이용한 동적 쿼리- 의미: JpaRepository 하나만 상속받으면, 저장, 조회, 수정, 삭제, 페이징, 정렬 기능을 모두 List 기반으로 편리하게 사용할 수 있습니다.
JPA 전용 기능 1: 영속성 컨텍스트 제어 (Flush)
JPA는 원래 트랜잭션이 끝날 때까지 SQL을 날리지 않고 모아두는 '쓰기 지연'을 합니다. 하지만 가끔은 강제로 DB에 반영해야 할 때가 있죠.
- flush(): "지금 당장 영속성 컨텍스트에 있는 변경 사항을 DB에 반영해!" (쿼리 전송)
- saveAndFlush(S entity): save() 하자마자 바로 flush()를 실행합니다.
- 언제 쓰나요? 테스트 코드에서 바로 DB 반영 결과를 확인하고 싶거나, 하나의 트랜잭션 안에서 JPA 작업 후 바로 Native SQL 등을 실행해야 할 때 사용합니다.
JPA 전용 기능 2: 배치 삭제 (Batch Delete)
이 부분이 CrudRepository의 delete와 가장 큰 차이점이자, 성능 최적화의 핵심입니다.
- deleteAllInBatch(Iterable<ID> ids):
- 일반 delete: 엔티티를 하나씩 조회해서 하나씩 지웁니다. (데이터가 100개면 쿼리 100방)
- 배치 delete: **"단 한 방의 쿼리"**로 지웁니다. (DELETE FROM person WHERE id IN (...))
- 주의사항:
- JPA 생명주기(Lifecycle Events)를 무시합니다. (예: @PreRemove 같은 게 작동 안 함)
- 영속성 컨텍스트(1차 캐시)를 무시하고 DB에 바로 요청하기 때문에, 메모리에 있는 객체와 DB 상태가 달라질 수 있습니다.
JPA 전용 기능 3: 프록시 조회 (Lazy Loading)
- getReferenceById(ID id):
- 기능: DB에서 SELECT 쿼리를 날리지 않고, 가짜 객체(프록시)만 덜렁 던져줍니다.
- 언제 쓰나요?
- 게시글을 저장할 때 Member 정보가 필요하다고 가정해 봅시다.
- Member의 ID만 알고 있으면 굳이 DB를 조회할 필요 없이, 프록시만 받아서 게시글에 넣어주면 불필요한 SELECT 쿼리를 아낄 수 있습니다.
해당 기능은 핵심적이므로 예시를 통해 더 자세히 알아보겠습니다.상황 설정: 게시글(Post)과 작성자(Member)
우리가 만들려는 시스템의 DB 구조는 다음과 같습니다.
- Member 테이블: id, name, email
- Post 테이블: id, title, content, member_id (FK)
[요구사항]
"ID가 10번인 회원이 쓴 '안녕하세요'라는 게시글을 저장해 주세요."
이때 서버는 클라이언트로부터 memberId: 10이라는 숫자 하나만 받았습니다.
일반적인 방법 (findById)의 비효율성
보통은 이렇게 코드를 짭니다.
// 1. DB에서 회원을 조회합니다. (쿼리 발생!) Member member = memberRepository.findById(10L).orElseThrow(); // SQL: SELECT * FROM member WHERE id = 10; <-- 불필요한 조회 // 2. 게시글 객체를 만들고 조회한 회원을 넣습니다. Post post = new Post(); post.setTitle("안녕하세요"); post.setMember(member); // 연관관계 설정 // 3. 게시글을 저장합니다. (쿼리 발생!) postRepository.save(post); // SQL: INSERT INTO post (title, member_id, ...) VALUES ('안녕하세요', 10, ...);[문제점]
- 우리는 게시글 테이블의 member_id 컬럼에 숫자 10만 넣으면 됩니다.
- 그런데 굳이 1번 단계에서 Member의 이름, 이메일, 가입일 등 모든 정보를 DB에서 꺼내왔습니다.
- 이것이 바로 낭비입니다.
최적화된 방법 (getReferenceById)
이제 getReferenceById를 써보겠습니다.
// 1. DB 조회를 안 합니다! 가짜(프록시) 객체만 만듭니다. Member proxyMember = memberRepository.getReferenceById(10L); // SQL: (없음) // 2. 게시글 객체에 프록시를 넣습니다. Post post = new Post(); post.setTitle("안녕하세요"); post.setMember(proxyMember); // 가짜지만 ID(10)는 가지고 있습니다. // 3. 게시글을 저장합니다. postRepository.save(post); // SQL: INSERT INTO post (title, member_id, ...) VALUES ('안녕하세요', 10, ...);[결과]
- SELECT 쿼리가 아예 사라졌습니다.
- INSERT 쿼리 딱 하나만 나갑니다.
도대체 어떻게 가능한 걸까?
이것의 핵심은 외래 키(Foreign Key)의 본질에 있습니다.
- 프록시의 정체:
- getReferenceById(10L)을 호출하면 JPA는 텅 빈 껍데기 객체(Proxy)를 만듭니다.
- 이 껍데기 안에는 딱 하나, 식별자 값 id=10만 들어있습니다. 저장 시점 (save):
- postRepository.save(post)가 호출되면 JPA는 INSERT 쿼리를 만듭니다.
- 이때 post.getMember()를 확인하는데, 그 자리에 프록시 객체가 들어있습니다.
- JPA는 생각합니다. "어차피 DB의 POST 테이블 MEMBER_ID 컬럼에 넣을 숫자 값(FK)만 있으면 되잖아?"
- 프록시 객체 안에 이미 id=10이 있다는 것을 확인하고, DB를 조회하지 않고 그 ID 값만 꺼내서 쿼리를 완성합니다.
핵심 요약: INSERT 쿼리를 날릴 때 필요한 건 오직 참조하는 대상의 ID(PK) 뿐입니다. 그 외의 정보(이름, 이메일 등)는 필요 없으므로, ID만 가지고 있는 프록시 객체로도 충분히 저장이 가능한 것입니다.
주의할 점
이 기능은 강력하지만, 잘못 쓰면 LazyInitializationException을 만날 수 있습니다.
- 성공 케이스:
- 프록시를 받아서 -> 다른 엔티티에 끼워 넣고 -> save 하는 경우 (위의 예시)
- 실패 케이스:
- getName()을 호출하는 순간, JPA는 "어? 이름은 모르는데?" 하고 그제서야 DB에 SELECT 쿼리를 날려 진짜 데이터를 가져오려고 시도합니다.
- 만약 이때 트랜잭션이 이미 끝난 상태라면, DB 연결이 끊겨 있으므로 초기화를 못 하고 예외가 터집니다.
Member proxy = memberRepository.getReferenceById(10L); // 여기서 문제가 터집니다!(트랜잭션이 끝난 경우) //트랜잭션이 안끝났을 경우에는 데이터 조회를 시도함 System.out.println("회원 이름: " + proxy.getName());
getReferenceById는 "단순히 연관관계를 맺어주기 위해(FK 설정을 위해) 객체가 필요할 때" 사용하는 최고의 성능 최적화 도구입니다. 데이터 조회 목적이 아니라 '연결' 목적으로만 쓸 때 가장 빛을 발합니다.
'Data' 카테고리의 다른 글
[JPA] 기본적인 Spring Data Jpa 활용 (0) 2026.01.24 [Data] JPA - 나오게 된 배경, 사용 이유 (0) 2026.01.03 PL/SQL 실습 (2) 2025.05.01 PL/SQL이란? 오라클의 절차형 SQL 언어 정리 (0) 2025.05.01 데이터베이스 종류 (3) 2025.03.10