이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › JPA › 쿼리
스프링 데이터 JPA
구현 클래스가 없는데 리포지토리는 어떻게 동작하나
스프링 데이터 JPA는 JPA를 더 쉽게 쓰게 해 주는 스프링의 모듈이고, 리포지토리는 엔티티를 저장하고 조회하는 메서드를 모아 둔 인터페이스입니다. 스프링 데이터 JPA에서는 리포지토리를 인터페이스로만 선언합니다. 구현은 애플리케이션이 뜰 때 스프링 데이터가 만들어 주고, findByName 같은 메서드는 이름을 읽어 쿼리로 바꿉니다. 메서드 이름이 쿼리로 바뀌는 과정과 함께, 페이지를 요청할 때와 save()를 부를 때 실제로 어떤 SQL이 나가는지도 봅니다.
실행으로 대조 · 스프링 부트 4.1.1 자동 설정으로 스프링 데이터 JPA 4.1.1, Hibernate 7.4.5, H2를 띄워 열아홉 가지 장면의 기동 결과와 나간 SQL을 대조
이 실습에 나오는 개념
- JPA, Hibernate, Spring Data JPA
- JPA는 자바의 ORM 표준 명세로, 어떤 기능을 어떻게 사용할지 정한 규칙(인터페이스)이다. Hibernate는 그 명세를 실제로 구현한 라이브러리다. Spring Data JPA는 JPA를 더 쉽게 쓰도록 리포지토리 인터페이스를 제공하는 스프링의 모듈이며 내부적으로 JPA(Hibernate)를 사용한다.
- 쿼리 메서드
- 스프링 데이터 리포지토리 인터페이스에 선언한 메서드의 이름을 읽어 쿼리를 만들어 주는 기능이다. findByName처럼 find…By 뒤에 엔티티의 프로퍼티 이름을 쓰고, GreaterThan이나 Containing 같은 키워드를 프로퍼티 이름 뒤에 붙여 조건의 종류를 정하며, 여러 조건은 And나 Or로 잇는다. 이름을 쿼리로 바꾸는 일은 애플리케이션이 뜰 때 하므로, 엔티티에 없는 프로퍼티 이름을 쓰면 PropertyReferenceException이 나고 애플리케이션이 뜨지 않는다. 조건이 많아지면 이름이 지나치게 길어지므로 그때는 @Query에 JPQL을 직접 적는다.
- Pageable과 Page
- Pageable은 몇 번째 페이지를 몇 건씩 어떤 순서로 달라는 요청을 담는 객체이고, 보통 PageRequest.of(페이지 번호, 크기)로 만든다. 페이지 번호는 0부터 센다. 반환 타입을 Page로 하면 스프링 데이터가 내용을 읽는 쿼리를 보내고 보통 전체 건수를 세는 count 쿼리가 한 번 더 나가며, 이 페이지의 내용과 전체 건수, 전체 페이지 수를 함께 돌려준다. 반환 타입을 Slice로 하면 count 쿼리 없이 한 건을 더 읽어 다음 페이지가 있는지만 알려 준다. 데이터가 많은 테이블에서는 count 쿼리가 느릴 수 있으므로 전체 건수가 꼭 필요한지 먼저 따져 본다.
- save()와 merge
- 스프링 데이터 JPA의 save()는 넘겨받은 엔티티가 새것이면 em.persist를, 아니면 em.merge를 부른다. 새것인지는 기본적으로 id가 null인지로 판단하며, @Version 필드가 있고 그 타입이 Long 같은 객체 타입이면 그 값이 null인지로 판단한다(long 같은 기본형이면 id로 판단한다). merge는 같은 id의 행을 SELECT로 먼저 조회한 뒤 넘겨받은 객체의 값을 관리되는 객체에 덮어쓰고, 그 관리되는 객체를 돌려준다. 그래서 id를 코드에서 직접 정하는 엔티티는 처음 저장할 때도 SELECT가 한 번 더 나간다. 이를 피하려면 엔티티가 Persistable 인터페이스를 구현해 새것인지를 직접 알려 준다.
- 엔티티
- 데이터베이스 테이블의 행에 대응하는 자바 객체다. @Entity를 붙인 클래스로 정의하며 식별자(@Id)를 가진다. JPA는 엔티티의 상태 변화를 추적해 그에 맞는 SQL을 실행한다.
이렇게 바꿔 보세요
- 처음 설정으로 — 본문이 없는 메서드인데 이름만으로 조건 두 개짜리 쿼리가 나갑니다.
- 엔티티에 없는 이름을 쓰면 — 메서드를 부르기도 전에, 애플리케이션이 뜨다가 실패합니다.
- 키워드를 잘못 쓰면 — GreaterThen은 키워드가 아니라서 프로퍼티 이름으로 읽힙니다.
- 페이지 요청: 쿼리 두 번 — 내용을 읽는 쿼리와 전체 건수를 세는 쿼리가 따로 나갑니다.
- 마지막 페이지에서는 — 전체 건수를 계산할 수 있어서 개수 쿼리를 보내지 않습니다.
- Slice로 받으면 — 개수 쿼리가 없는 대신 한 건을 더 읽어 다음이 있는지 봅니다.
- save: 새 엔티티인데 SELECT가 먼저 — id를 직접 정하는 엔티티는 INSERT 앞에 SELECT가 한 번 더 나갑니다.
처음 설정으로 실행하면
- 애플리케이션 시작: 스프링 데이터 JPA가 JpaRepository를 상속한 인터페이스 MemberRepository를 찾습니다. 구현 클래스를 만든 적이 없지만, 스프링 데이터가 이 인터페이스의 구현 객체(프록시)를 대신 만들어 빈으로 등록합니다.
- 메서드 이름 해석: findByNameAndAgeGreaterThan 메서드에는 사람이 쓴 본문이 없습니다. 스프링 데이터가 이름을 잘라 읽어 쿼리를 만듭니다. 맨 앞의 find…By는 "조건에 맞는 것을 조회한다"는 뜻이고, By 뒤가 조건입니다.
- Name: 키워드가 붙지 않았으므로 "같다"는 조건입니다. Member 엔티티에 이 이름(name)의 프로퍼티가 있는지 확인합니다. 있으므로 쿼리의 조건은 "name = ?"입니다. 물음표는 호출할 때 넘기는 값이 들어갈 자리입니다.
- And AgeGreaterThan: And는 앞의 조건과 and로 잇는다는 뜻입니다. 끝에 붙은 키워드(GreaterThan)는 "보다 크다"는 뜻입니다. Member 엔티티에 이 이름(age)의 프로퍼티가 있는지 확인합니다. 있으므로 쿼리의 조건은 "age > ?"입니다. 물음표는 호출할 때 넘기는 값이 들어갈 자리입니다.
- 애플리케이션 기동 완료: 이름 해석이 끝나 쿼리가 준비됐습니다. 이 해석과 프로퍼티 검사는 메서드를 부를 때가 아니라 애플리케이션이 뜰 때 미리 합니다.
- memberRepository.findByNameAndAgeGreaterThan("김철수", 20) 호출: 구현 객체가 준비해 둔 쿼리의 물음표 자리에 넘긴 값을 넣어 SELECT를 한 번 보냅니다.
- 결과: 조건에 맞는 회원 1건을 Member 객체의 목록으로 돌려줍니다.
실행 결과
- 본문이 없는 메서드를 불렀는데 "select … where name = ? and age > ?" 쿼리가 한 번 나갔습니다. 메서드 이름이 곧 쿼리입니다.
- 규칙: find…By나 count…By 뒤에 엔티티의 프로퍼티 이름을 쓰고, 조건의 종류(GreaterThan, Containing 등)는 프로퍼티 이름 뒤에 붙이며, 여러 조건은 And나 Or로 잇습니다. 매개변수는 이름이 아니라 순서대로 조건에 들어갑니다.
- 조건이 서너 개를 넘으면 이름이 너무 길어집니다. 그때는 @Query에 JPQL을 직접 적는 편이 읽기 쉽습니다.
코드
@Entity
class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String name;
int age;
// 생성자와 getter는 줄였다
}
// 구현 클래스는 만들지 않는다. 인터페이스만 선언한다
interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByNameAndAgeGreaterThan(String name, int age);
}
// 서비스
List<Member> result = memberRepository.findByNameAndAgeGreaterThan("김철수", 20);
이 모형의 전제
모든 장면(메서드 이름 여섯 가지, 페이지 요청 여덟 가지, save 다섯 가지)을 이 PC에서 스프링 부트 4.1.1의 자동 설정으로 스프링 데이터 JPA 4.1.1, Hibernate 7.4.5, H2 데이터베이스를 실제로 띄워 실행하고, 기동 성공 여부와 예외 이름과 메시지, DB로 나간 SQL의 수와 종류, where와 order by의 모양, 건너뛴 건수와 요청한 건수, 돌려받은 결과를 화면과 대조했습니다. 리포지토리의 구현이 프록시이고 그 뒤에 SimpleJpaRepository가 있다는 것도 실행으로 확인했습니다. 화면의 SQL은 읽기 쉽게 줄인 것입니다. 실제 SQL에는 테이블 별칭이 붙고 like 조건에는 escape 절이 덧붙으며, 문장의 모양은 Hibernate 버전과 DB에 따라 다릅니다. 잘못된 메서드 이름 두 가지는 그 메서드만 가진 리포지토리를 따로 띄워 확인했습니다. 결과 요약에서 말한 Or 같은 다른 키워드는 스프링 데이터 JPA 문서의 키워드 표를 따른 것이고 실행해 보지는 않았습니다. save의 "새 엔티티인가" 판단은 스프링 데이터 JPA 문서의 Entity State-detection Strategies 절을 따랐고, @Version 필드가 있는 엔티티는 id가 아니라 그 필드가 null인지로 판단합니다. id 999의 예에서 SELECT가 두 번 나가고 예외가 나는 것은 Hibernate 7.4.5에서 본 결과이며 버전에 따라 다릅니다. id를 직접 정하는 엔티티가 Persistable을 구현해 새 엔티티라고 알려 주면 SELECT 없이 INSERT 한 번만 나가는 것도 실행으로 확인했습니다(@Version 필드를 두는 방법은 실행해 보지 않았습니다). save는 바깥에 트랜잭션이 없는 상태에서 불렀으므로 save 자신의 트랜잭션이 끝날 때 UPDATE와 INSERT가 나갑니다. 바깥에 @Transactional이 있으면 그 트랜잭션이 커밋될 때 나갑니다.
앞 실습: 벌크 연산 · 다음 실습: 동적 쿼리와 Querydsl