JPA를 더욱 효율적으로 사용하기 위해 Spring Data JPA가 무엇인지 알아보자.
a) MemberJpaRepository
@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 Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
public long count() {
return em.createQuery("select count(m) from Member m", Long.class)
.getSingleResult();
}
public Member find(Long id) {
return em.find(Member.class, id);
}
}
b) TeamJpaRepository
@Repository
public class TeamJpaRepository {
@PersistenceContext
private EntityManager em;
public Team save(Team team) {
em.persist(team);
return team;
}
public void delete(Team team) {
em.remove(team);
}
public List<Team> findAll() {
return em.createQuery("select t from Team t", Team.class)
.getResultList();
}
public Optional<Team> findById(Long id) {
Team team = em.find(Team.class, id);
return Optional.ofNullable(team);
}
public long count() {
return em.createQuery("select count(t) from Team t", Long.class)
.getSingleResult();
}
}
MemberJpaRepository와 TeamJpaRepository에서 상당히 비슷한 CRUD 메서드를 확인할 수 있다.
💡 Spring Data JPA는 위와 같이 반복 작성되는 메서드를 자동화하여 기본적인 CRUD 메서드를 제공하는 라이브러리다.
public interface MemberRepository extends JpaRepository<Type, Id> {}
Member Entity를 관리하는 Repository라면, Type에 Member, Id에 Member의 pk 자료형인 Long 타입을 기입한다.
public interface MemberRepository extends JpaRepository<Member, Long> {}
테스트를 실행해 기능이 작동하는지 확인해보자.
@Autowired MemberRepository memberRepository;
@Test
public void testMember() {
Member member = new Member("memberA");
Member saveMember = memberRepository.save(member);
Member findMember = memberRepository.findById(member.getId()).get();
assertThat(findMember.getId()).isEqualTo(member.getId());
assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
assertThat(findMember).isEqualTo(member);
}
❗ Spring Data JPA는 JpaRepository를 상속받은 인터페이스의 구현체를 대신 생성한다.
System.out.println("memberRepository.getClass() = " + memberRepository.getClass());
메소드 네이밍은 공식 문서를 참고하자.
@NamedQuery
어노테이션을 작성하여 NamedQuery 정의// Member.class
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
public class Member {...}
a. 순수 JPA기반 Repository에서 사용
// MemberJpaRepository.class
public List<Member> findByUsername(String username) {
return em.createNamedQuery("Member.findByUsername",Member.class)
.setParameter("username", username)
.getResultList();
}
// MemberJpaRepositoryTest.class
@Test
public void testNamedQuery() {
Member memberA = new Member("userA", 10);
Member memberB = new Member("userA", 20);
memberJpaRepository.save(memberA);
memberJpaRepository.save(memberB);
List<Member> members = memberJpaRepository.findByUsername("userA");
assertThat(members.size()).isEqualTo(2);
}
em.createNamedQuery
의 첫 파라미터에 Entity에서 @NamedQuery
로 정의한 쿼리의 name을 기입하여 쿼리를 생성한다.
b. Spring Data JPA Repository에서 사용
// MemberRepository
//@Query("Member.findByUsername")
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsername(@Param("username") String username);
}
// MemberRepositoryTest.class
@Test
public void testNamedQuery() {
Member memberA = new Member("userA", 10);
Member memberB = new Member("userB", 20);
memberRepository.save(memberA);
memberRepository.save(memberB);
List<Member> members = memberRepository.findByUsername("userA");
assertThat(members.size()).isEqualTo(1);
}
JpaRepository를 상속받은 Repository에서 @Query 어노테이션을 사용해 메서드에 직접 jpql을 적용할 수 있다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@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);
}
❗ 메소드 이름으로 쿼리를 생성하는 전략은 파라미터 개수가 증가하면 메소드 이름도 매우 길어진다. 파라미터가 3개 이상이라면 되도록 @Query 기능을 사용하자 !!
a) 단순히 값 하나를 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
b) DTO로 직접 조회
// MemberDto.class
@Data
public class MemberDto {
private Long id;
private String username;
private String teamName;
public MemberDto(Long id, String username, String teamName) {
this.id = id;
this.username = username;
this.teamName = teamName;
}
}
// MemberRepository
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();
// MemberRepositoryTest.class
@Test
public void findMemberDto() {
Member memberA = new Member("userA", 10);
Member saveMember = memberRepository.save(memberA);
Team team = new Team("teamA");
Team saveTeam = teamRepository.save(team);
saveMember.changeTeam(saveTeam);
List<MemberDto> memberDto = memberRepository.findMemberDto();
for (MemberDto dto : memberDto) {
System.out.println("dto = " + dto);
}
}
//
Spring Data JPA는 위치 기반 파라미터 바인딩과 이름 기반 파라미터 바인딩을 모두 지원한다.
select m from Member m where m.username = ?0 // 위치 기반
select m from Member m where m.username = :username // 이름 기반
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username = :name")
Member findMembers(@Param("name") String username);
}
❗ 컬렉션 파라미터 바인딩도 지원한다.
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);
Spring Data JPA는 유연한 반환 타입을 지원한다.
List<Member> findByUsername(String username); // 컬렉션
Member findMemberByUsername(String name); // 단건
Optional<Member> findOptionalByUsername(String name); // 단건 Optional
❗ 단건을 기대하고 반환 타입을 지정했는데 결과가 2건 이상인 경우 NonUniqueResultException 예외가 발생한다.
Spring Data JPA는 페이징과 정렬을 위한 강력한 기능을 제공한다. 이를 알아보기 위해 우선 순수 JPA 페이징 코드를 살펴보자.
💡 순수 JPA 페이징과 정렬
JPA 페이징 코드
public List<Member> findByPage(int age, int offset, int limit) {
return em.createQuery("select m from Member m where m.age = :age order by m.username desc", Member.class)
.setParameter("age", age)
.setFirstResult(offset) // 시작
.setMaxResults(limit) // 끝
.getResultList();
}
public long totalCount(int age) {
return em.createQuery("select count(m) from Member m where m.age=:age", Long.class)
.setParameter("age",age)
.getSingleResult();
}
❗ 단점
totalCount
를 구하는 코드도 직접 작성해야한다.page
와 totalCount
를 이용해 현재 페이지, 첫 페이지 여부 등 직접 로직을 구현해야한다.위와 같은 단점들을 개선하기 위해 Spring Data JPA는 강력한 페이징, 정렬 기능을 제공한다.
💡 Spring Data JPA 페이징과 정렬
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 쿼리 없이 결과만 반환
Page 사용 예제
// MemberRepository
public interface MemberRepository extends JpaRepository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable);
}
// Test
@Test
public void paging() throws Exception{
//given
memberRepository.save(new Member("member1",10));
memberRepository.save(new Member("member2",10));
memberRepository.save(new Member("member3",10));
memberRepository.save(new Member("member4",10));
memberRepository.save(new Member("member5",10));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
//when
Page<Member> page = memberRepository.findByAge(age, pageRequest);
Page<MemberDto> dtoPage = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
//then
List<Member> content = page.getContent();
List<MemberDto> MapContent = dtoPage.getContent();
long totalElements = page.getTotalElements();
// 검증
assertThat(MapContent.size()).isEqualTo(3);
assertThat(totalElements).isEqualTo(5);
assertThat(page.getNumber()).isEqualTo(0);
assertThat(page.getTotalPages()).isEqualTo(2);
assertThat(page.isFirst()).isTrue();
assertThat(page.hasNext()).isTrue();
}
Pageable
은 인터페이스다. 실제 사용할 때는 인터페이스의 구현체인 org.springframework.data.domain.PageRequest
객체를 사용한다.PageRequest
생성자의 첫 번째 파라미터에는 현재 페이지, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 추가로 정렬 정보도 파라미터로 사용할 수 있다.getContent()
메소드는 Pageable
조건으로 조회한 데이터를 반환한다.❗ 주의
Page<Member> page = memberRepository.findByAge(age, pageRequest);
Page<MemberDto> dtoPage = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
Page 인터페이스
public interface Page<T> extends Slice<T> {
int getTotalPages(); //전체 페이지 수
long getTotalElements(); //전체 데이터 수
<U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}
📖 참고
count 쿼리를 날릴 때 불필요한 join이 발생할 수 있다. 이는 성능 저하를 일으킬 수 있다.
@Query(value = "select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);
위 코드에서 count 쿼리를 날리면 m.team과 left join이 발생한다. 하지만, 실질적으로 count 값을 얻기 위해서는 join을 할 필요가 없다. 이러한 경우 다음과 같이 count 쿼리를 따로 분리할 수 있다.
@Query(value = "select m from Member m left join m.team t",
countQuery = "select count(m) from Member m"))
Page<Member> findByAge(int age, Pageable pageable);
벌크성 수정 쿼리란?
벌크성 수정 쿼리는 대량의 데이터를 여러개의 데이터를 한 번에 추가/수정/삭제하는 쿼리이다.
순수 JPA 벌크성 수정 쿼리
public int bulkAgePlus(int age) {
return em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
.setParameter("age", age)
.executeUpdate();
}
Spring Data JPA 벌크성 수정 쿼리
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
@Test
public void bulkUpdate() {
// given
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 19));
memberRepository.save(new Member("member3", 20));
memberRepository.save(new Member("member4", 30));
memberRepository.save(new Member("member5", 40));
// when
int resultCount = memberRepository.bulkAgePlus(20);
//em.flush();
//em.clear();
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("member = " + member);
}
// then
assertThat(resultCount).isEqualTo(3);
}
@Modifying
어노테이션을 사용해야한다.org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations
예외가 발생한다.@Modifying(clearAutomatically = true
옵션을 넣어준다.❗ 참고
- 벌크 연산은 엔티티를 수정하는 것이 아닌 jpql을 통해 DB의 값을 직접 수정하는 것이기 때문에 영속성 컨텍스트의 1차 캐시에 저장되어 있는 엔티티의 상태와 DB의 엔티티의 상태가 달라질 수 있다. 따라서, 벌크 연산 후 조회 기능을 사용하는 경우에는 영속성 컨텍스트를 초기화하고 사용해야 한다.
- jpql은 실행되기 전에 영속성 컨텍스트에 flush를 날리고 실행된다.
연관된 엔티티들을 SQL 한번에 조회하는 방법이다. -> fetch join을 사용해서 한번에 조회
//공통 메서드 오버라이드
@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)
@EntityGraph
의 attributePaths
옵션에 지연 로딩 설정이 되어있는 객체를 등록한다면 fetch join을 통해 한번에 조회한다.
JPA Hint
JPA 쿼리 힌트 (SQL 힌트가 아니라 JPA 구현체에 제공하는 힌트)
// MemberRepository
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value =
"true"))
Member findReadOnlyByUsername(String username);
// Test
@Test
public void queryHint() throws Exception {
//given
memberRepository.save(new Member("member1", 10));
em.flush();
em.clear();
//when
Member member = memberRepository.findReadOnlyByUsername("member1");
member.setUsername("member2");
em.flush(); //Update Query 실행X
}
@QueryHints
어노테이션을 사용해 readOnly
힌트를 설정한다면 메모리 사용을 최소화하기 위해 영속성 컨텍스트에 스냅샷을 남기지 않도록 최적화한다. 따라서, 영속성 컨텍스트에서 변경 감지를 하지 못하고 update 쿼리도 날아가지 않는다.Lock
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);
org.springframework.data.jpa.repository.Lock
어노테이션을 사용