Spring Data JPA
스프링 데이터 JPA(Spring Data JPA)는 모든 어플리케이션에서 공통적으로 사용되는 JPA 리포지토리 메소드를 인터페이스에 정의해두고, 엔티티 정보에 따라 자동으로 JPA 리포지토리 구현체를 생성해주는 라이브러리이다.
아래의 MemberJpaRepository와 MemberRepository를 살펴보자.
MemberJpaRepository는 순수 JPA 리포지토리이다. 그리고 MemberRepository는 스프링 데이터 JPA 리포지토리이다.
@Repository
public class MemberJpaRepository {
@PersistenceContext // 생성자 주입의 역할을 한다
private EntityManager em;
public Member save(Member member) {
em.persist(member);
return member;
}
public void delete(Member member) {
em.remove(member);
}
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
public long count() {
return em.createQuery("select count(m) from Member m", Long.class)
.getSingleResult();
}
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
...
}
public interface MemberRepository extends JpaRepository<Member, Long> {
}
MemberJpaRepository는 클래스이며 메소드를 모두 구현해두었지만, MemberRepository는 인터페이스이며 아무런 메소드도 정의되어있지 않다.
그러나 MemberRepository는 MemberJpaRepository와 동일하게 정상적으로 동작한다.
인터페이스만 정의되어있고, 해당하는 구현체는 구현하지 않았는데 어떻게 작동하는 것일까?
MemberRepository가 상속하고 있는 JpaRepositorty<Member, Long>에 기본적인 리포지토리 메소드가 정의되어있으며, JpaRepository<Entity, pkType>을 참고하여 스프링 데이터 JPA가 구현체를 대신 생성해 빈으로 등록해주기 때문이다.
JpaRepository<Entity, pkType>을 스프링 데이터 JPA 공통 인터페이스라고 한다.
스프링 데이터 JPA 공통 인터페이스
JpaRepository<Entity, pkType>는 스프링 데이터 JPA 공통 인터페이스이며, 스프링 데이터 인터페이스인 Repository, CrudRepository, PagingAndSortingRepository를 상속하고 있다.
그리고 실제 리포지토리로 사용될 인터페이스는 JpaRepository<Entity, pkType>을 상속하여 스프링 데이터 JPA 리포지토리로 사용될 수 있다.
JpaRepository<Entity, pkType>의 제네릭 타입을 참고하여 공통 인터페이스에 정의된 메소드들이 구현된 구현체가 생성되고 빈으로 등록된다.
쿼리 메소드 기능
모든 어플리케이션에서 사용되는 기본적인 리포지토리 메소드들은 이미 공통 인터페이스에 정의되어있고, 스프링 데이터 JPA가 대신 구현해주기 때문에 별도의 작업 없이 사용할 수 있다.
그러나 특정 어플리케이션에 종속적인 리포지토리 메소드(쿼리)는 어떻게 생성하고 사용할 수 있을까?
스프링 데이터 JPA는 쿼리 메소드 기능을 통해 공통 인터페이스에 정의되지 않은 리포지토리 메소드(쿼리)를 생성하고 사용할 수 있게 해준다.
메소드 이름을 통한 쿼리 생성
스프링 데이터 JPA 리포지토리의 메소드 이름으로, 해당 메소드를 통해 실행될 JPQL을 정의할 수 있다.
아래의 MemberRepository에는 공통 인터페이스에 정의되지 않은 findByUsernameAndAgeGreaterThan(), findTop3By() 메소드가 정의되어 있다.
스프링 데이터 JPA가 리포지토리 메소드명을 분석하여, JPQL을 생성하고 메소드를 실행할 때 해당 JPQL이 실행된다.
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
List<Member> findTop3By();
...
}
이러한 기능을 사용하기 위해서는 스프링 데이터 JPA가 분석할 수 있도록 올바르게 메소드명을 작성해야한다.
작성 방법은 https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation을 참고하자.
@Query
조건이 많은 쿼리를 메소드 이름을 통해 정의하려면 메소드 이름이 상당히 길어지게 된다.
그리고 메소드 이름으로 정의하기 까다로운 쿼리도 있다.
이런 경우 @Query("JPQL")을 리포지토리 메소드에 적용하여 해당 메소드가 호출될 때 실행될 JPQL을 정의할 수 있다.
JPQL 작성 방법만 알아두면 되고, JPQL 파라미터 바인딩을 위해 @Param("파라미터")을 사용한다는 것만 주의하자.
아래의 MemberRepository에 정의된 메소드들을 참고하자.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
// 메소드명 간략화, 리포지토리 메소드에 바로 JPQL 작성 가능
// 쿼리에 문법 오류가 있을 경우, 컴파일 예외 발생
@Query("select m from Member m where m.username = :username and m.age = :age")
List<Member> findUser(@Param("username") String username, @Param("age") int age);
// String 타입으로 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
// DTO 타입으로 조회
// DTO에 해당하는 생성자가 필요하다. MemberDto(m.id, m.username, t.name)
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();
// Collection을 통한 in절 처리
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
...
}
페이징 및 정렬
스프링 데이터 JPA는 페이징 및 정렬을 위한 매우 편리한 기능을 제공한다.
페이징과 정렬을 위한 아래와 같은 파라미터를 제공한다.
- org.springframework.data.domain.Sort: 정렬 기능
- org.springframework.data.domain.Pageable: 페이징 기능(내부에 Sort 포함)
Pageable 파라미터를 리포지토리 메소드에 적용하면 아래와 같은 타입이 반환된다.
- Page<Entity>: count 쿼리가 별도로 사용된다.
- Slice<Entity>: 별도의 count 쿼리가 사용되지 않는다(내부적으로 limit+1 조회).
- List<Entity>: 별도의 count 쿼리가 사용되지 않는다.
Sort 파라미터를 적용할 경우, List<Entity>로 반환된다.
페이징 및 정렬 예시 코드
MemberRepository에는 Pageable을 파라미터로 가진 findPageByAge(), findSliceByAge()가 정의되어있다.
그리고 join이 필요한 페이징 리포지토리 메소드에 대한 성능 개선 방법을 적용한 findPageCountByAge()가 정의되어있다.
MemberRepositoryTest의 테스트 코드를 통해 두 메소드가 어떻게 사용되지는지 알아보자.
paingPage() 내부를 살펴보면, PageRequest 타입의 객체를 Pageable 파라미터의 인자로 사용하는 것을 볼 수 있다.
Pageable은 인터페이스이고, PageRequest는 구현체이다.
PageRequest.of(page, size, Sort.by(Direction, properties))를 통해 페이징 및 정렬 설정을 해줄 수 있다.
- page: 몇 번째 페이지의 데이터를 가져올지 설정
- size: 한 페이지에 몇 개의 데이터를 가져올지 설정
- Sort.by(Direction, properties): 정렬 방법(내림차순, 오름차순), 정렬 기준(컬럼)을 설정한다.
Page<Entity> 타입으로 반환된 findPageByAge()의 결과를 어떻게 사용하는지는 예시 코드를 참고하자.
이제 pagingSlice() 내부를 살펴보자.
Slice<Entity> 타입으로 결과를 반환하는 findSliceByAge()을 사용했다는 것 말고는 다른 점이 없다.
Slice<Entity> 타입으로 반환된 결과는 별도의 count 쿼리 결과를 포함하고 있지 않기 때문에 총 페이지 수, 총 페이지의 데이터 수의 값은 갖고 있지 않다는 것을 주의하자.
마지막으로 MemberRepository의 findPageCountByAge()를 살펴보자.
join이 필요한 페이징 메소드에 대해서는 count 쿼리를 분리해주는 것이 좋다. 그렇지 않을 경우, count 쿼리에 대해서도 불필요한 조인이 실행되기 때문이다.
@Query(value = "...", countQuery = "...")를 살펴보자.
- value에는 실제 가져올 데이터에 대한 쿼리를 작성한다. 페이징 및 정렬과 관련된 쿼리는 작성할 필요가 없다. Pageable 파라미터를 통해 자동으로 쿼리가 작성되기 때문이다.
- countQuery에는 실행될 join이 없는 count 쿼리를 작성하면된다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
Page<Member> findPageByAge(int age, Pageable pageable);
Slice<Member> findSliceByAge(int age, Pageable pageable);
// join이 필요한 paging 시, count 쿼리 분리(분리하지 않을 경우, count 쿼리에 대해서도 불필요한 조인이 실행된다)
// paing, sorting과 관련된 쿼리는 작성할 필요가 없다. Pageable 파라미터를 통해 해결된다.
// sorting 로직이 복잡해질 경우, Pageable에서 Sorting 값을 제거한 뒤 직접 쿼리에 작성해주면 된다.
@Query(value = "select m from Member m left join m.team t",
countQuery = "select count(m.username) from Member m")
Page<Member> findPageCountByAge(int age, Pageable pageable);
...
}
@SpringBootTest
@Transactional
class MemberRepositoryTest {
...
/// paging
@Test
public void pagingPage() {
// given
for (int i = 0; i < 5; i++) {
memberRepository.save(new Member("member" + i, 10));
}
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Direction.DESC, "username"));
// when
Page<Member> page = memberRepository.findPageByAge(age, pageRequest);
Page<MemberDto> toMap = page.map(
m -> new MemberDto(m.getId(), m.getUsername(), null));
//then
assertThat(toMap.getContent().size()).isEqualTo(3); // 현재 페이지의 데이터는 몇개인가?
assertThat(toMap.getTotalElements()).isEqualTo(5); // 총 페이지의 데이터는 몇개인가?
assertThat(toMap.getNumber()).isEqualTo(0); // 현재 페이지는 몇번째 페이지인가?
assertThat(toMap.getTotalPages()).isEqualTo(2); // 총 페이지는 몇개인가?
assertThat(toMap.isFirst()).isTrue(); // 현재 페이지는 첫번째 페이지인가?
assertThat(toMap.hasNext()).isTrue(); // 다음 페이지가 있는가?
}
@Test
public void pagingSlice() {
// given
for (int i = 0; i < 5; i++) {
memberRepository.save(new Member("member" + i, 10));
}
int age = 10;
// Slice를 사용할 경우, size + 1만큼 요청한다. count 쿼리도 나가지 않는다(필요가 없다).
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Direction.DESC, "username"));
// when
Slice<Member> slice = memberRepository.findSliceByAge(age, pageRequest);
Slice<MemberDto> toMap = slice.map(
m -> new MemberDto(m.getId(), m.getUsername(), null));
//then
assertThat(toMap.getContent().size()).isEqualTo(3); // 현재 페이지의 데이터는 몇개인가?
assertThat(toMap.getNumber()).isEqualTo(0); // 현재 페이지는 몇번째 페이지인가?
assertThat(toMap.isFirst()).isTrue(); // 현재 페이지는 첫번째 페이지인가?
assertThat(toMap.hasNext()).isTrue(); // 다음 페이지가 있는가?
}
...
}
벌크 연산
스프링 데이터 JPA에서 벌크성 수정 삭제 쿼리는 @Modifying을 통해 실시할 수 있다.
@Modifying은 적용된 리포지토리 메소드가 jpa의 excuteUpdate()로 실행되게 해준다.
벌크 연산을 실시할 경우, 벌크 연산이 영속성 컨텍스트를 거치지 않고 바로 DB에 적용된다는 것을 주의하자.
벌크 연산 실행 후, 벌크 연산이 적용된 엔티티 객체에 대해 작업하고 싶다면 영속성 컨텍스트를 깨끗하게 정리해준 다음 사용할 데이터를 DB에서 다시 불러오는 것이 좋다.
@Modifying(clearAutomatically = true)를 통해 벌크 연산 실행 후, 영속성 컨텍스트를 자동으로 정리하도록 설정할 수 있다.
아래의 예시 코드를 참고하자.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
@Modifying // jpa의 executeUpdate() 실행
// @Modifying(clearAutomatically = true) // jpa의 executeUpdate() 실행 + 벌크 연산 후 영속성 컨텍스트 clear()
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
...
}
@SpringBootTest
@Transactional
class MemberRepositoryTest {
...
@Test
public void bulkUpdate() {
for (int i = 0; i < 10; i++) {
memberRepository.save(new Member("member" + i, 10 + i));
}
//when
int resultCount = memberRepository.bulkAgePlus(15);
// 벌크 연산은 영속성 컨텍스트를 거치지 않고 바로 DB에 적용된다. 영속성 컨텍스트에 있는 영속성 엔티티에는 반영이 안된다.
Member preMember5 = memberRepository.findMemberByUsername("member5");
System.out.println("preMember5.getAge() = " + preMember5.getAge()); // 15
// 벌크 연산 후에는 영속성 컨텍스트를 정리하고 다시 불러오는 것이 좋다. 특히 벌크 연산 뒤에 추가 로직이 있는 경우..
em.flush();
em.clear(); // @Modifying의 옵션으로 해결 가능!
Member postMember5 = memberRepository.findMemberByUsername("member5");
System.out.println("member15.getAge() = " + postMember5.getAge()); // 16
//given
assertThat(resultCount).isEqualTo(5);
}
...
}
@EntityGraph
@EntityGraph는 스프링 데이터 JPA에서 fetch join을 보다 간편하게 사용할 수 있도록 도와주는 어노테이션이다.
스프링 데이터 JPA에서 fetch join을 사용하는 방법은 아래와 같다.
- @Query("JPQL")을 통해 직접 fetch join 쿼리 작성
- @EntityGraph(attributePaths = {"value"})를 통해 fetch join 적용
MemberRepository의 fetch join 메소드들을 살펴보자.
- findMemberFetchJoin()은 직접 fetch join 쿼리를 작성하여 team의 데이터를 함께 조회한다.
- findMemberEntityGraph()는 fetch join과 관련된 쿼리를 @EntityGraph(attributePaths = {"team"})을 통해 처리한다.
- 두 메소드는 동일한 쿼리로 동작한다.
findAll()을 보면 알 수 있듯이, 공통 인터페이스에 이미 정의된 메소드도 오버라이딩하여 @EntityGraph를 적용할 수 있다.
findEntityGraphUsername() 처럼 메소드명을 통해 쿼리가 생성될 때에도 @EntityGraph를 적용할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
...
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
@Query("select m from Member m")
@EntityGraph(attributePaths = {"team"})
List<Member> findMemberEntityGraph();
@Override
@EntityGraph(attributePaths = {"team"}) // findAll()이 fetchJoin할 수 있도록 오버라이딩
List<Member> findAll();
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(String username);
...
}
아래는 위의 fetch join 메소드들에 대한 테스트 코드이다.
@SpringBootTest
@Transactional
class MemberRepositoryTest {
...
@Test
public void findMemberFetchJoinAndEntityGraph() {
//given
//member1 -> teamA
//member2 -> teamB
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
teamRepository.save(teamA);
teamRepository.save(teamB);
Member member1 = new Member("member1", 10, teamA);
Member member2 = new Member("member2", 10, teamB);
memberRepository.save(member1);
memberRepository.save(member2);
em.flush();
em.clear();
//when
// JPQL을 통해 직접 fetch join 쿼리 작성
//select Member, Team 쿼리 실행 1
List<Member> memberFetchJoin = memberRepository.findMemberFetchJoin();
for (Member member : memberFetchJoin) {
System.out.println("member.getUsername() = " + member.getUsername());
// 실제 Team 객체
System.out.println("member.getTeam().getClass() = " + member.getTeam().getClass());
System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
}
// -> N + 1 문제 해결
// JPQL에 fetch join 쿼리를 작성하지 않고, @EntityGraph를 적용하여 fetch join 할 수 있다.
//select Member, Team 쿼리 실행 1
em.flush();
em.clear();
List<Member> memberEntityGraph = memberRepository.findMemberEntityGraph();
for (Member member : memberEntityGraph) {
System.out.println("member.getUsername() = " + member.getUsername());
// 실제 Team 객체
System.out.println("member.getTeam().getClass() = " + member.getTeam().getClass());
System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
}
// -> N + 1 문제 해결
//메소드명 쿼리 메소드에도 @EntityGraph 적용 가능
em.flush();
em.clear();
memberRepository.findEntityGraphByUsername("member1");
//공통 인터페이스에 정의된 메소드도 오버라이딩하여 @EntityGraph 적용 가능
em.flush();
em.clear();
memberRepository.findAll();
}
...
}
사용자 정의 리포지토리 구현
스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고 구현체는 스프링 데이터 JPA가 자동으로 생성한다.
메소드를 직접 구현하지 않는 것이다.
만약 스프링 데이터 JPA 리포지토리의 메소드를 직접 구현하고 싶다면 어떻게 해야할까?
사용자 정의 인터페이스를 정의하고, 그에 대한 구현체를 구현하면된다.
아래의 코드를 살펴보자.
MemberRepositoryCustom은 사용자 정의 인터페이스이다.
내부에 구현체에서 구현될 리포지 메소드 findMemberCustom()을 정의해두었다.
MemberRepositoryImpl은 사용자 정의 인터페이스의 구현체이다.
findMemberCustom()의 실제 로직이 오버라이딩 되어있다.
MemberRepository는 기존의 스프링 데이터 JPA 리포지토리이다.
MemberRepositoryCustom에서 정의된 메소드를 사용하고 싶으면, MemberRepositoryCustom을 구현하면된다.
이럴 경우, 스프링 데이터 JPA는 스프링 데이터 JPA 인터페이스 + Impl 또는 사용자 정의 리포지토리 + Impl의 클래스를 인식하여 스프링 빈으로 등록한다.
///////////////////////////////////////////////////////////////////////////////////////////////
public interface MemberRepositoryCustom {
List<Member> findMemberCustom();
}
///////////////////////////////////////////////////////////////////////////////////////////////
// 커스텀 리포지토리 인터페이스의 구현체는, 해당 구현체를 상속할 스프링 데이터 JPA 리포지토리의 이름+Impl로 맞춰야한다.
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
...
}
Auditing
스프링 데이터 JPA는 Auditing을 통해 엔티티의 수정, 변경을 추적할 수 있는 유용한 기능을 제공한다.
아래의 코드를 살펴보자.
스프링 데이터 JPA의 Auditing을 사용하기 위해서는 스프링 부트 설정 클래스에 @EnableJpaAuditing을 적용해야한다.
그리고 Auditing을 사용할 클래스에 @EntityListeners(AuditingEntityListener.class)를 적용해야한다.
스프링 데이터 JPA에서는 아래와 같은 어노테이션을 사용할 수 있다.
- @CreatedDate: 엔티티 생성 일시 자동 등록
- @LastModifiedDate: 엔티티 마지막 수정 일시 자동 등록
- @CreatedBy: 엔티티 생성자 자동 등록
- @LastModifiedBy: 엔티티 마지막 수정자 자동 등록
해당 어노테이션을 사용하고자하는 속성에 적용하고, 해당 속성들이 속한 클래스에 @EntityListeners(AuditingEntityListener.class)를 적용하면된다.
그리고 한 가지 염두할 점은 @CreatedBy, @LastModifiedBy의 값이다.
기본적으로는 Spring Security의 Authentication를 통해 값을 입력한다.
직접 커스텀할 수도 있다([JPA] Auditing 사용하기 | CodeNexus (umanking.github.io)).
예시 코드에서는 스프링 부트 설정 클래스 내부에 AuditorAware<String> 타입의 빈을 등록해두었다.
AudititorAware<String> 타입의 빈이 @CreatedBy, @LastModifiedBy의 값으로 사용되는 것이다.
///////////////////////////////////////////////////////////////////////////////////////////////
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
// @CreatedBy, LastModifiedBy의 값
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.of(UUID.randomUUID().toString());
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
}
///////////////////////////////////////////////////////////////////////////////////////////////
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity {
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String lastModifiedBy;
}
///////////////////////////////////////////////////////////////////////////////////////////////
@Entity
@Getter @Setter // 엔티티에 Setter는 가급적 사용하지 말자, 생성자 또는 빌드패턴을 사용하자
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"}) // 연관 필드(team)을 ToString하면 무한루프 발생.
public class Member extends BaseEntity {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
public Member(String username) {
this.username = username;
}
public Member(String username, int age) {
this.username = username;
this.age = age;
}
public Member(String username, int age, Team team) {
this.username = username;
this.age = age;
if (team != null) {
changeTeam(team);
}
}
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
}
출처: 실전! 스프링 데이터 JPA - 인프런 | 강의 (inflearn.com)