Spring Data JPA는 인터페이스만 선언하면 구현 객체를 대신 만들어 주는 라이브러리다. JpaRepository를 상속한 인터페이스 한 줄로 save, findById 같은 기본 메서드를 쓸 수 있고, 메서드 이름 규칙이나 @Query로 조회 메서드를 더 선언할 수 있다.
interface BookingRepository : JpaRepository<Booking, Long>
// Booking: 다룰 엔티티, Long: id 타입몸통 없는 메서드가 동작하는 이유
- 부를 수 있는 이유: 부모 인터페이스에 선언이 있기 때문이다.
CrudRepository에save,findById,existsById,count,deleteById가,ListCrudRepository에findAll이 선언돼 있고,JpaRepository는 이것들에 정렬, 페이지 나누기, 일괄 처리를 더한다. - 동작하는 이유: 애플리케이션이 시작할 때 Spring이 이 인터페이스의 구현 객체(프록시)를 만들어 빈으로 등록하고, 기본 메서드 호출을
SimpleJpaRepository의 코드로 연결하기 때문이다.
save
val saved = bookingRepository.save(Booking(memberId = 7L, status = BookingStatus.PENDING))
val bookingId = saved.id!! // 저장 직후에는 id가 채워져 있음Booking(...)는 메모리에 객체를 만들 뿐이고, DB에는 save가 INSERT를 보낼 때 들어간다. SimpleJpaRepository.save의 코드는 아래와 같다(Spring Data JPA 4.1).
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}- 새 엔티티(id가
null)면persist를 부르고 받은 객체를 그대로 돌려준다.IDENTITY방식이면 INSERT 직후 DB가 매긴 id를 Hibernate가 그 객체에 채운다.val id여도 Hibernate는 리플렉션으로 필드에 값을 넣는다. - id가 있는 엔티티면
merge를 부른다.merge는 영속성 컨텍스트가 관리하는 다른 객체에 값을 복사하고 그 객체를 돌려줄 수 있어서, 넘긴 객체와 돌려받은 객체가 다를 수 있다.
그래서 save 뒤에는 항상 반환값을 받아서 쓴다. 새 엔티티일 때만 같은 객체라는 사실에 기대면, 같은 코드가 수정 경로에서 다르게 동작한다.
existsById, count, findAll
bookingRepository.existsById(42L) // 있는지만
bookingRepository.count() // 전체 행 수
bookingRepository.findAll() // 모든 행을 객체로spring.jpa.show-sql=true로 실제 SQL을 보면 차이가 드러난다.
-- existsById: 컬럼을 읽지 않고 개수만 셈
select count(*) from bookings where id = ?
-- findById: 모든 컬럼을 읽어 객체를 만듦
select id, created_at, member_id, status from bookings where id = ?- 내용은 쓰지 않고 존재만 확인할 때는
existsById를 쓴다. 쓸 데이터가 있으면findById를 쓴다. count는 테스트에서 '저장 전보다 정확히 1개 늘었다'를 확인할 때 주로 쓴다.findAll은 행이 많으면 전부 읽어 오느라 느리고 메모리를 많이 쓴다. 운영 코드에서는 조건이 있는 조회나 페이지 단위 조회를 쓴다.
테스트에서 findAll().last()로 '방금 저장한 행'을 꺼내는 코드는 테스트 전용 빈 DB라서 성립한다. 여러 요청이 동시에 저장하는 운영 DB에서는 마지막 행이 내가 넣은 행이라는 보장이 없다. 운영 코드는 save가 돌려준 객체의 id를 쓴다.
메서드 이름으로 쿼리 만들기
부모에 없는 조회는 인터페이스에 메서드 선언만 추가한다. Spring이 시작할 때 이름을 규칙 단어로 쪼개 쿼리를 만든다.
fun findTop5ByMemberIdAndCreatedAtBeforeOrderByCreatedAtDesc(
memberId: Long,
createdAt: LocalDateTime,
): List<Booking>| 조각 | SQL |
|---|---|
find | SELECT |
Top5 | LIMIT 5 |
By | WHERE |
MemberId | member_id = ? |
And | AND |
CreatedAtBefore | created_at < ? |
OrderByCreatedAtDesc | ORDER BY created_at DESC |
- 이름에는 컬럼 이름(
member_id)이 아니라 엔티티 필드 이름(memberId)을 쓴다. - 인자는 조건의
?에 순서대로 들어간다. - 규칙에 없는 단어나 없는 필드 이름을 쓰면 애플리케이션이 시작할 때 에러로 멈춘다.
- 결과가 하나뿐이면 반환 타입을
Booking?로 둔다.findTop1ByMemberIdOrderByCreatedAtDesc(memberId): Booking?
@Query와 JPQL
다른 테이블의 조건을 봐야 하거나 서브쿼리가 필요하면 이름으로 표현할 수 없다. 이때 @Query로 JPQL을 직접 쓴다. JPQL(Java Persistence Query Language)은 테이블 대신 엔티티 클래스와 필드 이름으로 쓰는 SQL 비슷한 문법이고, Hibernate가 DB에 맞는 SQL로 바꿔 보낸다.
@Query(
"""
SELECT o
FROM Booking o
JOIN Payment p ON p.bookingId = o.id
WHERE o.memberId = :memberId
AND p.status = :status
ORDER BY o.createdAt DESC, o.id DESC
LIMIT 5
"""
)
fun findRecentPaid(memberId: Long, status: PaymentStatus): List<Booking>| SQL | JPQL |
|---|---|
bookings | Booking (클래스 이름) |
o.member_id | o.memberId (필드 이름) |
SELECT o.* | SELECT o |
FROM Booking o처럼 별칭을 붙여야SELECT o,o.id가 무엇을 가리키는지 정해진다.:memberId자리에는 같은 이름의 메서드 파라미터 값이 들어간다.@ManyToOne연관관계가 없어도JOIN Payment p ON p.bookingId = o.id처럼 필드 값으로 이을 수 있다(Hibernate 6).@Query를 붙이면 메서드 이름은 해석하지 않는다. 이름의Top5도 무시되므로 개수 제한은 쿼리 안에LIMIT으로 쓴다.ORDER BY에o.id DESC를 두 번째 기준으로 두면, 시각이 같은 행도 순서가 항상 같게 나온다.
쿼리 문자열은 컴파일러가 확인하지 않는다. enum을 com.example.booking.PaymentStatus.CONFIRMED처럼 전체 이름으로 쿼리에 고정하면, 패키지를 옮길 때 문자열은 따라 바뀌지 않는다. 파라미터로 받거나, 리포지토리 테스트로 잡는다.
nativeQuery = true를 주면 JPQL이 아니라 DB의 SQL로 해석된다. FOR UPDATE SKIP LOCKED처럼 JPQL에 없는 문법이 필요할 때 쓴다. 예시는 SELECT FOR UPDATE SKIP LOCKED에 있다. 데이터를 바꾸는 쿼리에 붙이는 @Modifying과 영속성 컨텍스트의 관계는 JPA 쓰기 시점과 영속성 컨텍스트에 정리했다.