Spring Data JPA란?

yrok·2023년 10월 15일
1

Spring Data JPA

목록 보기
1/1
post-thumbnail

📌 개요

JPA를 더욱 효율적으로 사용하기 위해 Spring Data JPA가 무엇인지 알아보자.

1. Spring Data JPA란?

  • JPA에서 Repository를 작성할 때 반복적으로 작성되는 CRUD 메서드가 존재한다.
  • JPA만을 사용해서 생성한 MemberJpaRepository, TeamJpaRepository를 살펴보자.

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 메서드를 제공하는 라이브러리다.

2. Spring Data JPA 사용법

public interface MemberRepository extends JpaRepository<Type, Id> {}
  • MemberRepository를 인터페이스로 선언하고 JpaRepository를 상속 받는다.
  • Type : 사용할 Repository의 기준이 되는 Entity의 타입을 기입한다.
  • Id : 기입된 Entity의 Primary Key 자료형을 기입한다.

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);
}

📌 memberRepository는 인터페이스로만 선언되어있고 내부에 정의한 메서드가 없는데 어떻게 동작하는걸까?

Spring Data JPA는 JpaRepository를 상속받은 인터페이스의 구현체를 대신 생성한다.

System.out.println("memberRepository.getClass() = " + memberRepository.getClass());

1) 쿼리 메소드 기능

  • 메소드 이름으로 쿼리 생성
  • 메소드 이름으로 JPA NamedQuery 호출
  • @Query 어노테이션을 사용해 Repository 인터페이스에 쿼리 직접 정의
  • 애플리케이션 실행 시점에 문법 오류를 발견할 수 있다.
    • 기본 jpql은 String으로 인식하여 메소드가 실행될 때 오류를 발견할 수 있다.

메소드 네이밍은 공식 문서를 참고하자.

⛅ JPA Named Query

  • JPA의 NamedQuery를 호출할 수 있다.
  • Entity에 @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);
}
  • @Query 어노테이션을 사용해 @NamedQuery로 정의한 쿼리 name을 입력하여 NamedQuery를 생성할 수 있다.
  • @Query 어노테이션을 생략해도 정상적으로 작동한다.
    • Spring Data JPA의 동작 순서는 다음과 같다.
    1. 설정한 Entity 타입 (Member).메소드명인 NamedQuery를 찾아서 실행한다.
    2. 만약 실행할 NamedQuery가 없으면 메서드 이름으로 쿼리 생성 전략을 사용한다.

⛅ @Query, 레포지토리 메소드에 쿼리 정의하기

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);
}
  • 메서드 파라미터에 @Param을 사용해 jpql에 파라미터 바인딩을 할 수 있다.
  • 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 NamedQuery라 할 수 있다.
  • JPA Named Query처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있다.

메소드 이름으로 쿼리를 생성하는 전략은 파라미터 개수가 증가하면 메소드 이름도 매우 길어진다. 파라미터가 3개 이상이라면 되도록 @Query 기능을 사용하자 !!

⛅ @Query, 값, DTO 조회하기

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);
    }
}
//
  • DTO를 조회할 때 MemberDto가 있는 패키지명까지 모두 입력하고 정의한 필드를 파라미터에 기입하여 작성해야한다. MemberDto 내부의 teamName 필드를 위해 m.team과 Inner Join 하여 값을 가져온다.
  • MemberDto에서 정의한 id, username, teamName 필드가 출력되는 것을 확인할 수 있다.

⛅ 파라미터 바인딩

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
  • 컬렉션을 조회할 경우 데이터가 없을 때 빈 리스트를 반환한다. null 처리를 따로 해줄 필요가 없다.
  • 단건을 조회할 때는 데이터가 없으면 null을 반환한다. 따라서, Optional을 사용해 null 체크를 해줄 필요가 있다.

단건을 기대하고 반환 타입을 지정했는데 결과가 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();
    }

단점

  • 페이징을 위해 jpql을 직접 작성해야하고 offset, limit 등 파라미터를 넘기는 코드를 직접 작성해야한다.
  • totalCount를 구하는 코드도 직접 작성해야한다.
  • pagetotalCount를 이용해 현재 페이지, 첫 페이지 여부 등 직접 로직을 구현해야한다.

위와 같은 단점들을 개선하기 위해 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();
}
  • findByAge의 두번째 파라미터 Pageable은 인터페이스다. 실제 사용할 때는 인터페이스의 구현체인 org.springframework.data.domain.PageRequest 객체를 사용한다.
  • PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 추가로 정렬 정보도 파라미터로 사용할 수 있다.
  • getContent() 메소드는 Pageable 조건으로 조회한 데이터를 반환한다.

주의

  • 페이지 인덱스는 0부터 시작한다.
  • Entity를 응답에 노출하는 것은 좋은 방법이 아니다. DTO로 변환해서 반환해야 한다.
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 옵션을 넣어준다.
    • 벌크성 쿼리 실행 후 조회 기능을 수행한다면 DB의 값과 영속성 컨텍스트의 값이 달라 문제가 될 수 있다.

참고

  1. 벌크 연산은 엔티티를 수정하는 것이 아닌 jpql을 통해 DB의 값을 직접 수정하는 것이기 때문에 영속성 컨텍스트의 1차 캐시에 저장되어 있는 엔티티의 상태와 DB의 엔티티의 상태가 달라질 수 있다. 따라서, 벌크 연산 후 조회 기능을 사용하는 경우에는 영속성 컨텍스트를 초기화하고 사용해야 한다.
  2. jpql은 실행되기 전에 영속성 컨텍스트에 flush를 날리고 실행된다.

⛅ @EntityGraph

연관된 엔티티들을 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)

@EntityGraphattributePaths 옵션에 지연 로딩 설정이 되어있는 객체를 등록한다면 fetch join을 통해 한번에 조회한다.

⛅ JPA Hint & Lock

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
}
  • 영속성 컨텍스트는 Entity의 스냅샷과 비교하여 변경 감지가 일어난다면 update 쿼리를 날려 정보를 갱신한다.
  • @QueryHints 어노테이션을 사용해 readOnly 힌트를 설정한다면 메모리 사용을 최소화하기 위해 영속성 컨텍스트에 스냅샷을 남기지 않도록 최적화한다. 따라서, 영속성 컨텍스트에서 변경 감지를 하지 못하고 update 쿼리도 날아가지 않는다.

Lock

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);
  • org.springframework.data.jpa.repository.Lock 어노테이션을 사용
profile
공부 일기장

0개의 댓글