JPA를 사용할 때 기본적인 CRUD는 반복적인 작업이다. 이를 위해 개발자는 손수 코드를 작성해야 한다.
엔티티가 100개라면 기본 코드를 위해 100번 반복을 해야 하는 것이다.
@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 Member findById(Long id) {
return em.find(Member.class, id);
}
}
X 100번 한다고 생각하니 아찔하다.
그럼 실제 스프링 데이터 JPA가 제공하는 공용 인터페이스를 사용해보자.
데이터 JPA가 제공하는 공용 인터페이스를 사용하기 위해 별도의 준비가 필요하다.
@SpringBootApplication
@EnableJpaRepositories(basePackages = "study.datajpa.repository")
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
}
스프링을 사용한다면 Jpa Repository의 위치를 알려주는 별도의 설정이 필요하다.
하지만 스프링 부트를 사용하면 main 위치부터 하위 패키지까지 스캔하기 때문에 필요가 없다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}
이제 다음과 같이 인터페이스로 Repository를 만들고 JpaRepository를 상속하면 간단히 스프링 데이터 JPA를 사용하기 위
한 준비가 끝난다.
JpaRepository 내부에도 많은 인터페이스를 가지고 있는 것을 확인할 수 있다.
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T>
데이터베이스마다 공용으로 사용하는 시그니처와 jpa에서 사용하는 기능을 분리하는 것을 볼 수 도 있다.
스프링 데이터 JPA는 해당 인터페이스를 보고 구현체를 만들어 주입시켜준다.
그렇다면 우리는 도메인에 특화된 데이터가 필요하다면 어떻게 해야 할까?
쿼리 메소드 기능을 알아보자.
JPA를 사용할 때 다음과 같은 데이터가 필요하다면 우리는 직접 jpql을 사용해야만 했다.
회원의 이름은 x이고 나이는 y보다 회원들만 필요한 경우 다음과 같은 메서드를 만들어야 했다..
public List<Member> findByUserNameAndAgeGreaterThen(String username, int age) {
return em.createQuery("select m from Member m where m.username = :username and m.age > :age", Member.class)
.setParameter("username", username)
.setParameter("age", age)
.getResultList();
}
스프링 데이터 JPA는 이제 메서드 이름을 통해 쿼리를 만들어 줄 수 있다.
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
JPA 인터페이스안에 다음과 같은 규칙에 따라 메서드 이름을 지정하면 된다.
@Test
public void findByUserNameAndAgeGreaterThen() throws Exception {
//given
Member member = Member.builder()
.username("username")
.age(20)
.build();
//when
memberRepository.save(member);
List<Member> findMember = memberRepository.findByUsernameAndAgeGreaterThan("username", 10);
//then
Assertions.assertThat(findMember.get(0).getAge()).isEqualTo(20);
Assertions.assertThat(findMember.get(0).getUsername()).isEqualTo("username");
}
테스트도 통과하는 것을 확인할 수 있다.
이를 구현하는 과정에서 한번 오류 상황을 겪었는데 userName이라고 했다가 userName property를 찾지 못했다고 오류가 발생했다.
이는 서버를 띄울 때 발생했다. 하지만 이러한 철자 오류는 컴파일 시점에 잡지 못하는 것을 확인했다.
또한 파라미터가 두 개이상 넘어가게 되면 메서드 이름이 매우 길어져 클라이언트 입장에서 가독성이 난잡해질 수 있다.
따라서 두 개이상인 경우에는 다른 방법으로 해결한다.
JPA NamedQuery
순수 JPA는 @NameQuery라는 기능을 제공한다. 이는 쿼리에 이름을 부여하고 호출하는 것이다.
@Entity @NoArgsConstructor(access = AccessLevel.PROTECTED)
@NamedQuery(
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
)
public class Member { ... }
public List<Member> findByUsername(String username) {
return em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", username)
.getResultList();
}
아니 이럴바엔.. 그냥 안 쓰고 만다. 반복될 수 있는 상황에는 아주 조금 효과가 있어 보인다. 그렇지만 동일 쿼리에 대해 반복하는 경우가 그렇게 많은 상황일까 고민하게 된다.
이때 스프링 데이터 JPA는 좀 더 편하게 사용할 수 있도록 다음과 같이 도와줄 수 있다.
@Query(name = "Member.findByUsername")
List<Member> anyName(@Param("username") String username);
// @Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);
재밌는 건 주석처리해도 테스트를 통과한다.
스프링 데이터 JPA는 우선 네임드 쿼리를 찾도록 되어있다. 없다면 메서드 이름으로 쿼리를 생성한다.
네임드 쿼리를 찾게 하기 위해선 Domain.NameQuery.name으로 찾기 때문에 맞춰줘야 한다.
다만 이 기능을 실무에서 잘 사용하지 않는다. 왜냐 @Query라는 기능이 있기 때문이다.
@Query
나도 이 기능을 많이 사용한다.
@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);
이유는 쿼리 메소드는 이름이 너무 길어지고, 네임드 쿼리는 분리하는 것도 싫고 엔티티에 있는 것도 싫다(물론 분리가 되지만)
이 기능은 엄청난 장점도 존재한다.
1. 애플리케이션 로딩 시점에 오류가 발생한다.
그 이유는 @Query는 이름이 없는 네임드 쿼리라고 생각하면 된다. 그렇기 때문에 로딩 시점에 미리 파싱을 해버린다.
즉 SQL을 미리 만들어 준다는 것이다. 그래서 오류가 발생하는 경우 로딩 시점에 에러를 발생시킬 수 있다.
2. 단순 값이나 DTO를 조회하는 방법도 존재한다.
단순 값 하나를 조회
@Query("select m.username from Member m")
List<String> findByUsernames();
DTO 조회
public class MemberDto {
private Long id;
private String username;
private String teamName;
}
만약 다음과 같이 조인해서 가져와야 할 데이터가 있다고 하자. 이 경우는 약간 지저분하지만 다음과 같은 방법으로 쉽게 가져올 수 있다.
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();
이 경우는 바로 DTO로 반환을 할 수가 있다.
파라미터 바인딩
파라미터는 대부분 이름 기반으로 바인딩하며 위치 기반은 사용하지 않는다.
그리고 컬렉션을 파라미터로 바인딩하는 방법을 보자. 이 기능은 매우 유용할 수 있다.
@Query("select m from Member m where m.username in :names")
List<MemberDto> findByNames(@Param("names") List<String> names);
상당히 유용할 것 같다! 바로 실무에 적용해야겠다, 난 여러 개를 파라미터로 받았는데..
반환 타입
그리고 보니 반환 타입에 대해 의문을 가진 적이 없었다.
Optional도 가능하고, 컬렉션, 단건, 기본 타입 등 전부 가능했던 것 같다.
이때 컬렉션을 반환 타입으로 사용하는 경우 매우 좋은 것은 empty 컬렉션을 반환한다는 것이다.
따라서 별도의 null처리를 할 필요가 없다.!
그런데 단건인 경우 결과가 없으면 null이다.
JPA는 안건을 조회했을 때 결과가 없으면 NoResultException이 발생한다. 그런데 스프링 데이터 JPA는 try/catch 한 이후 null을 반환해준다.
사실 이는 Optional을 사용하면 되는 문제라 개발자 입장에서 정해야 한다. 없는 경우가 있고 이를 클라이언트에서 처리할 것인지 판단하여 적용하면 될 것 같다.
그런데 단 건을 기대했지만 여러 건이 나와서 반환 타입이 맞지 않는 경우는 IncorrectResultSizeDataAccessException을 발생시킨다. 이 예외는 스프링 예외다.
재밌는 건 ComplateableFuture로 받을 수 있다. @Async 어노테이션으로 실행하면 비동기로 실행할 수 있다.
RxJava를 위한 타입들도 지원을 한다.
페이징
JPA에서 제공해주는 페이징 기능은 놀라울 정도로 편리하다.
페이지 계산 공식도 필요 없고 데이터베이스 방언마다 다른 페이징 쿼리에 맞춰주기 때문이다.
나도 처음 사용했을 때 페이지 계산 값을 넣었다가 오류를 경험한 적이 있다..
다음과 같이 조건 + 페이징, totalCount를 가져올 수 있다.
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 count(int age) {
return em.createQuery("select count(m) from Member m where m.age = :age, Long.class)
.setParameter("age", age)
.getSingleResult();
}
페이징을 위해선 우리는 두 가지 쿼리를 작성한다.
조건에 맞고 몇개의 데이터를 몇 번째 페이지에서 가져올 것인지 결과를 가져오는 쿼리
클라이언트에서 필요한 총개수를 반환하는 쿼리
그럼 스프링 데이터 JPA는 어떻게 페이징과 정렬을 지원해줄까?
더욱이 기대되는 부분이다.
1. data.domain.Sort : 정렬 기능
2. data.domain.Paeable : 페이징 기능 (내부 Sort 포함)
페이징을 인터페이스로 공통화 시켰다.
또한 커서 기반 페이징과 오프셋 기반 페이징을 위한 특별한 반환 타입도 제공해 준다.
토털 카운트가 필요한 오프셋 기반인 경우 Page 타입을 사용하면 매우 쉽게 구현이 가능하고
모바일에서 많이 사용되는 더보기, 위치 기반으로 다음 데이터를 가져오는 상황에 사용할 수 있는 Slice 타입을 사용하면 별도의 토털 쿼리를 날리지 않고 쉽게 구현이 가능하다.
커서 기반은 별도 구현을 해야할 것 같다.
아마 커서 기반으로 사용할 때에도 slice를 사용하면 더 편하게 구현이 가능할 것이다.
1. totalCount 를 사용하는 경우 다음과 같이 사용할 수 있다.
Page<Member> findByAge(int age, Pageable pageable);
이렇게 되면 클라이언트 입장에서는 Pageable 타입에 대한 구현체가 필요하다.
public static PageRequest of(int page, int size) {
return of(page, size, Sort.unsorted());
}
public static PageRequest of(int page, int size, Sort sort) {
return new PageRequest(page, size, sort);
}
public static PageRequest of(int page, int size, Direction direction, String... properties) {
return of(page, size, Sort.by(direction, properties));
}
PageRequest.of라는 정적 생성자 메서드를 통해 Pageable의 구현체인 PageRequest를 생성할 수 있다.
PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "username"));
memberRepository.findByAge(20, pageRequest);
정적 쿼리에 대해 간단한 페이징을 만들어 주는 기능은 너무 획기적이다.
그리고 우리는 페이지에 필요한 토탈 카운트 쿼리를 별도로 날릴 필요도 없다. 왜냐하면 반환 타입이 Page라면 스프링 데이터 JPA가 자동적으로 최적화된 토털 카운트 쿼리가 나간다.
Page<Member> page = memberRepository.findByAge(20, pageRequest);
List<Member> members = page.getContent();
int totalCount = page.getTotalPages();
아 근데 정적 쿼리에 사용하기 편하고 동적 쿼리는..
2. Slice 타입은?
스프링 데이터 JPA는 Slice 타입일 경우 limit에 +1을 더해서 요청해본다.
Slice<Member> findByAge(int age, Pageable pageable);
그 이유는 다음 페이지가 있는 지 없는지 확인하기 위해서이다.
이로 인해 totalCount가 너무 많아 성능상 이슈가 발생했을 때 Slice로 변경하고 싶을 때 반환 타입만 바꾸면 된다.
성능상 이슈에 영향을 주는 부분이 바로 count를 가져오는 부분이다. 데이터가 많을 수록 성능 최적화가 되게 어렵다.
left join인 경우 주 테이블의 결과만 필요하기 때문에 join을 할 이유가 없다.
또한 다중 테이블 조인에서 중간 테이블의 값으로 필터링 한다면 해당 테이블까지만 조인해도 토털 카운트를 가져오는데 무리가 없다.
스프링 데이터 JPA는 이를 위해 카운트 쿼리를 분리할 수있도록 도와준다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query(value = "select m from Member m left join m.team t where m.age = :age",
countQuery = "select count(m) from Member m")
Page<Member> findByAge(int age, Pageable pageable);
}
이를 통해 카운트 쿼리를 분리함으로써 의도치 않은 성능감소를 피할 수 있다.
이렇게 얻어온 데이터를 DTO로 변환하려면 어떻게 해야할까?
List<MemberDto> collect = page.getContent()
.stream()
.map(content -> new MemberDto(content.getId(), content.getUsername(), content.getUsername()))
.collect(Collectors.toList());
아니 이렇게 사용하면 된다.
List<MemberDto> collect = page
.map(content -> new MemberDto(content.getId(),content.getUsername(),content.getUsername()))
.getContent();
혹은
Page<MemberDto> map = page
.map(content -> new MemberDto(content.getId(), content.getUsername(), content.getUsername()));
Page타입을 반환하면 JSON으로 변환 시 다음과 같은 정보도 추가된다.
/**
* Returns the number of total pages.
*
* @return the number of total pages
*/
int getTotalPages();
/**
* Returns the total amount of elements.
*
* @return the total amount of elements
*/
long getTotalElements();
'Spring|Spring-boot > Spring-Data-JPA' 카테고리의 다른 글
Custom Repository (0) | 2022.11.30 |
---|---|
JPA Hint & Lock (0) | 2022.11.27 |
@EntityGraph (0) | 2022.11.27 |
벌크성 수정 쿼리 (0) | 2022.11.27 |
Spring-Data-JPA 소개 (0) | 2022.10.20 |
댓글