본문 바로가기
Spring

[Spring Data JPA] 실전! 스프링 데이터 JPA 정리

by 도전하는 린치핀 2024. 7. 18.

해당 포스팅은 인프런에서 김영한님의 "실전! 스프링 데이터 JPA" 강의를 수강 후 핵심 개념을 정리한 내용입니다.

 

실전! 스프링 데이터 JPA 강의 | 김영한 - 인프런

김영한 | 스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제

www.inflearn.com

 

1.  공통 인터페이스 적용

  • 컴포넌트 스캔을 스프링 데이터 JPA가 자동으로 처리하기 때문에 @Repository 애노테이션 생략이 가능하다.
  • JpaRepository 인터페이스를 통해 기본적인 CRUD를 제공한다.
  • 생성하는 인터페이스는 "엔티티명" + Repository로 네이밍 해야 한다.
  • 인터페이스를 생성 후 JpaRepository<T, ID> 인터페이스를 상속 받는다.
  • T : 생성하는 레포지토리 타켓 엔티티
  • ID : 타켓 엔티티의 PK의 자료형

주요 메서드

  • save(S) : 새로운 엔티티를 저장하고 이미 있는 엔티티라면 merge(병합)이 일어난다.
  • delete(T) : 엔티티 하나를 삭제하며 내부에서는 EntityManager.remove()를 호출한다.
  • findById(ID) : 엔티티 하나를 조회하며 내부에서는 EntityManager.find() 호출한다.
  • getOne(ID) : 엔티티를 프록시로 조회하며 내부에서는 EntityManager.getReference() 호출한다.
  • findAll() : 모든 엔티티를 조회하며 정렬이나 페이징 조건을 파라미터를 제공할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
	...
}

 

2.  메소드 이름으로 쿼리 생성

스프링 데이터 JPA가 제공하는 쿼리 메소드 기능에 대해 알아보자.

  • 스프링 데이터 JPA는 메소드 이름을 분석해서 JPQL을 생성하고 실행한다.
  • 조회 기능 : find...By... | read...By... | query...By.... | get...By...
  • Count 기능(반환 타입 - long) : count...By...
  • EXISTS 기능(반환 타입 - boolean) : exists...By...
  • 삭제 기능(반환 타입 - long) : delete...By... | remove...By...
  • DISTINCT 기능 : findDistinct | findMemberDistinctBy...
  • LIMIT 기능 : findFirst3 | findFirst | findTop | findTop3
public interface MemberRepository extends JpaRepository<Member, Long> {
	
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
    
    ...
}

 

해당 기능의 경우 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 변경해야 동작한다.

인터페이스에 정의한 메서드 이름과 엔티티의 필드명이 다르면 애플리케이션 시작 시점에서 오류가 발생한다.

이것은 애플리케이션 로딩 시점에 오류를 인지할 수 있게 하기 때문에 큰 장점이다.

 

3. JPA NamedQuery

  • 보통, 스프링 데이터 JPA를 사용하면 NamedQuery를 직접 등록해서 사용하는 일은 적다.
  • NamedQuery를 사용하는 대신 뒤에 나올 @Query를 사용해서 레포지토리 내 메서드에 직접 쿼리를 정의한다.
@Entity
@NamedQuery(
	name = "Member.findByUsername",
	query = "select m from Member m where m.username = :username")
public class Member {
	...
}

 

  • 스프링 데이터 JPA는 선언한 "도메인 클래스 + . + 메서드 이름"으로 NamedQuery를 찾아 실행한다.
  • 만약, 실행할 Named Query가 없으면 메서드 이름으로 쿼리 생성 전략을 사용한다.
  • 필요하면 전략을 변경할 수 있지만 엔티티 자체에 NamedQuery를 등록해서 사용하는 것은 권장되지 않는다.

4. @Query (레포지토리 내 메소드에 직접 쿼리 정의하기)

  • 실제로 스프링 데이터 JPA를 사용할 때, 메소드 이름으로 쿼리 생성 기능은 파라미터가 증가함에 따라 메서드 이름이 길어지기 때문에 지저분해진다.
  • 이를 해결하기 위해 @Query 기능을 사용하면 메서드를 간결하고 가독성 있게 만들 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
	
    @Query("select m from Member m where m.username = :username and m.age = :age")
    public List<Member> findUser(@Param("username") String username, @Param("age") int age);
    
    ...
}

 

  • @org.springframework.data.jpa.repository.Query 애노테이션을 사용한다.
  • 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름이 없는 NamedQuery라고 생각할 수 있다.
  • JPA NamedQuery처럼 애플리케이션 시점에 문법 오류를 발견할 수 있다는 장점이 있다.

 

5. DTO 조회하기

public interface MemberRepository extends JpaRepository<Member, Long> {
	
    @Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
	List<MemberDto> findMemberDto();
    
    ...
}

 

사용 시 주의점 

  • DTO로 직접 조회하려면 JPA의 new 명령어를 사용해야 한다.
  • 그리고 다음과 같이 생성자가 맞는 DTO가 필요하다. (JPA와 사용방식이 동일하다.)

 

6. 파라미터 바인딩

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    //@Query("select m from Member m where m.username = ?0) 	// 위치 기반
    @Query("select m from Member m where m.username = :name")  // 이름 기반
    Member findMembers(@Param("name") String username);
}
  • 가독성과 유지 보수를 위해 이름 기반의 파라미터 바인딩을 사용하는 것을 권장한다.

 

7. 반환 타입

  • 스프링 데이터 JPA는 유연한 반환 타입을 지원한다.
  • 조회 결과에 따라 다양한 결과가 반환된다.
    • 컬렉션 (여러 개 값 조회) : 결과 없을 때 빈 컬렉션을 반환한다.
    • 단건 조회 (하나의 값 조회) : 결과가 없을 시 null을 반환하고, 결과가 2개 이상일 때 NonUniqueResultException 예외를 발생시킨다.

 

8. 스프링 데이터 JPA 페이징과 정렬

public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용
    Slice<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함
    List<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함
    List<Member> findByUsername(String name, Sort sort);
}
  • 두번째 파라미터로 받은 Pageable은 인터페이스로 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다.
  • PageRequest 생성자의 첫번째 파라미터에는 현재 페이지, 두번째 파라미터는 조회할 데이터 수를 입력한다.
  • 또한, 정렬 정보도 파라미터로 사용할 수 있다. 추가적으로 페이지는 0부터 시작한다.

페이징과 정렬 파라미터

  • org.springframework.data.domain.Sort : 정렬 기능을 제공
  • org.springframework.data.domain.Pageable : 페이징 기능 (내부적으로 Sort를 포함한다.)

특별한 반환 타입

  • org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하여 페이징 처리를 진행하여 반환한다.
  • org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능하다(내부적으로 limit + 1로 조회한다, 보통 무한 스크롤 페이징을 구현할 때 사용한다고 한다.)
  • List(자바 컬렉션 반환) : 추가 count 쿼리 없이 해당 리스트 결과만 반환한다.
추가적인 내용이지만, 데이터의 개수가 많아질 때 count 쿼리를 함께 반환하는 Page를 반환하게 되면 시간이 오래 걸리는 문제가 발생한 경우가 있어 List 형태로 반환한 적이 있다.

 

PageRequest 를 통해 파라미터 바인딩

PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(10, pageRequest);

 

Count 쿼리를 분리한 Page

@Query(value = “select m from Member m”,
countQuery = “select count(m.username) from Member m”)
Page<Member> findMemberAllCountBy(Pageable pageable);

 

페이지를 유지하면서 엔티티를 DTO로 변환

Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());

 

9. 벌크성 수정 쿼리

@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

 

  • 벌크성 수정, 삭제 쿼리는 @Modifying 애노테이션을 사용한다.(사용하지 않으면 예외 발생한다.)
  • 벌트성 쿼리를 실행하고 나서 영속성 컨텍스트를 초기화해야 한다.(@Modifying(clearAutomatically = true )
  • 벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 있는 데이터의 상태가 달라진다.
  • 이 옵션 없이 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아 문제가 발생할 수 있다

 권장 방안

  1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 실행한다.
  2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화한다.

 

10. @EntityGraph

  • 연관된 엔티티들을 SQL 한번에 조회하는 방법
  • member -> team은 1 : N 지연로딩 관계이다.
  • 따라서 아래와 같이 team의 데이터를 조회할 때 마다 쿼리가 실행되어 N+1문제가 발생한다.
//공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

//JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

//메서드 이름으로 쿼리에서 특히 편리하다.
@EntityGraph(attributePaths = {"team"})
List<Member> findByUsername(String username)

 

  • 스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 편리하게 사용할 수 있도록 도와준다.
  • 이 기능을 통해 JPQL 없이 패치 조인을 사용할 수 있다.
  • 사실상 패치 조인의 간편한 버전이라고 할 수 있다.

 

11. JPA Hint

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
  • readOnly로 적용하였기 때문에, 해당 쿼리로 조회된 엔티티 객체는 스냅샷을 만들지 않아 변경 감지가 일어나지 않아 변경되지 않는다.

 

12. 사용자 정의 레포지토리 구현

  • 기본적으로 스프링 데이터 JPA 레포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동으로 생성한다.

그렇다면 인터페이스의 메서드를 직접 구현하고 수정하고 싶을 땐 어떻게 해야 할까?

  • JPA 직접 사용 (EntityManager)
  • 스프링 JDBC Template 사용
  • MyBatis 사용
  • 데이터베이스 커넥션을 직접 사용
  • Querydsl 사용

참고 사항

  1. 주로 QueryDSL이나 SpringJdbcTemplate을 함께 사용할 때 사용자 정의 레포지토리 기능을 사용할 수 있다.
  2. 항상 사용자 정의 레포지토리가 필요한 것은 아니고 임의의 레포지토리를 만들 수 있다.

 

13. Auditing

엔티티가 생성, 변경되는 시간과 변경한 사람을 추적하고 싶을 때, 모든 엔티티에 해당 컬럼을 작성해야 할까?

  • @EnableJpaAuditing : 스프링 부트 설정 클래스에 적용
  • @EntityListeners(AuditingEntityListener.class) : 엔티티에 적용

실제 BaseEntity에 사용하는 애노테이션

  • @CreatedDate 
  • @LastModifiedDate 
  • @CreatedBy
  • @LastModifiedBy

참고 사항

  1. 실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만 등록자, 수정자는 반드시 필요한 것은 아니다.
  2. 그래서 Base 타입을 분리하고, 원하는 타입을 선택해서 상속할 수 있다.
  3. 저장 시점에 등록일, 등록자는 물론이고 수정일, 수정자도 같은 데이터가 저장된다.
  4. 데이터가 중복 저장될 것 같지만 이렇게 해두면 변경 컬럼만 확인해도 마지막에 업데이트한 유저를 확인할 수 있어 유지보수 관점에서 편리하다.

 

14. Web 확장 - 도메인 클래스 컨버터

도메인 클래스 컨버터 사용 전

@GetMapping("/members/{id}")
 	public String findMember(@PathVariable("id") Long id) {
 		Member member = memberRepository.findById(id).get();
 		return member.getUsername();
 	}

 

도메인 클래스 컨버터 사용 후

@GetMapping("/members/{id}")
 	public String findMember(@PathVariable("id") Member member) {
 		return member.getUsername();
 	}

 

  • HTTP 요청은 회원 id를 받지만 도메인 클래스 컨버터가 중간에 동작하며 회원 엔티티 객체를 반환한다.
  • 도메인 클래스 컨버터도 레포지토리를 사용해서 엔티티를 찾는다.
  • 도메인 클래스 컨버터로 엔티티를 파라미터로 받을 때, 해당 엔티티는 단순한 조회용으로만 사용해야 한다는 주의점이 있다.
  • 트랜잭션이 없는 범위에서 엔티티를 조회했기 때문에 엔티티를 변경해도 DB에 반영되지 않는다.

 

15. Web 확장 - 페이징과 정렬

@GetMapping("/members")
	public Page<Member> list(Pageable pageable) {
	 	Page<Member> page = memberRepository.findAll(pageable);
	 	return page;
	}
  • 파라미터로 Pageable을 받을 수 있다.
  • Pageable은 인터페이스, 실제는 org.springframework.data.domain.PageRequest 객체 생성

요청 파라미터

  • /member?page=0&size=5&sort=id, desc&sort=username, desc
  • page : 현재 페이지로 0부터 시작한다.
  • size : 한페이지에 노출할 데이터 건수
  • sort : 정렬할 기준을 설정할 수 있다. asc가 default

접두사

  • 페이징 정보가 둘 이상이면 접두사를 통해 구분한다.
  • @Qualifier에 접두사명 추가 "{접두사명}_xxx"
  • 예시 : /members?member_page=0&order_page=1

페이징 관련 글로벌 설정

// application.yml
spring.data.web.pageable.default-page-size=20 /# 기본 페이지 사이즈/
spring.data.web.pageable.max-page-size=2000 /# 최대 페이지 사이즈/

 

페이징 관련 개별 설정 (@PageableDefault 애노테이션 사용)

@GetMapping(value = "/members_page", method = RequestMethod.GET)
public String list(@PageableDefault(size = 12, sort = “username”, direction = Sort.Direction.DESC) Pageable pageable) {
  ...
}

 

보통 설정이 겹치면 개별 설정으로 적용된다.

 

Page를 1부터 시작하기

  • Pageable, Page를 파라미터와 응답 값으로 사용하지 않고 직접 클래스를 만들어 처리
  • 직접 PageRequest를 생성해 레포지토리에 넘기고 응답값도 Page가 아닌 직접 만들어 제공해야 한다.

Page 내용을 DTO로 변환하기

  • 엔티티를 API로 노출하면 다양한 에러가 발생할 수 있기 때문에 DTO로 변환해서 반환해야 한다.
  • Page를 map()을 통해 내부에서 DTO로 변환해야 한다.

 

16. 스프링 데이터 JPA 구현체 분석

스프링 데이터 JPA가 제공하는 공통 인터페이스 구현체 : org.springframework.data.jpa.repository.support.SimpleJpaRepository

 

@Repository 적용 

  • JPA 예외를 스프링이 추상화한 예외로 변환시킨다.

@Transactional

  • JPA의 모든 변경은 트랜잭션 안에서 동작한다.
  • 스프링 데이터 JPA는 변경(등록, 수정, 삭제) 메서드를 모두 트랜잭션으로 처리해야 한다.
  • 서비스 계층에서 트랜잭션을 시작하지 않으면 레포지토리에서 트랜잭션이 시작된다.
  • 서비스 계층에서 트랜잭션을 시작하면 레포지토리는 해당 트랜잭션을 전파받아 사용한다.
  • 따라서 스프링 데이터 JPA를 사용할 때 트랜잭션 없이 데이터 등록, 수정이 가능했다.(실제로는 레포지토리에서 트랜잭션이 시작된 것)

@Transactional(readOnly = true)

  • 단순 데이터 조회만 일어나고 변경이 일어나지 않는 트랜잭션에서 readOnly = true 옵션을 사용하면 영속성컨텍스트 내 플러시를 생략하기 때문에 약간의 성능 향상을 얻을 수 있다.
  • 또한, Shared Lock 상태에서 데이터에 대한 동시 조회가 가능해서 성능 향상을 얻을 수 있다.

 

17.  save() 가 새로운 엔티티를 구별하는 방법

save() 메서드

  • 새로운 엔티티에 대해서는 저장한다(persist)
  • 새로운 엔티티가 아닌 기존 있던 엔티티라면 병합(수정)이 일어난다(merge)

새로운 엔티티를 판단하는 기본 전략

  • 식별자가 객체라면 null 인지 아닌지로 판단
  • 식별자가 자바 기본 타입이라면 0인지 아닌지로 판단
  • Persistable 인터페이스를 구현해서 판단 로직을 변경할 수 있다.
위의 내용이 중요한 이유는 JPA 식별자(보통 생각하는 PK) 생성 전략이 @GenerateValue라면 save() 호출 시점에 식별자가 존재하지 않기 때문에 새로운 엔티티로 인식해서 정상적으로 저장이 일어난다.

하지만 JPA 식별자 생성 전략이 @Id만을 사용해 직접 할당된다면 이미 식별자 값이 존재한다고 판단되어 save()에서 merge()가 일어난다.

merge()의 경우 DB에서 호출된 값을 확인하고 DB에 값이 없는 것을 확인하고 새로운 엔티티라는 것을 인지하기 때문에 비효율적이다.
따라서 Persistable을 사용해서 새로운 엔티티 확인 여부를 직접 구현하는 것이 효과적이라고 한다.

이에 대한 간단한 예시로 앞서 설명한 엔티티 등록시간(@CreatedDate)을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 판단하기 쉬워질 수 있다.