김영한님의 인프런 강의 '실전! 스프링 데이터 JPA'을 참고했습니다.
스프링 데이터 JPA는 스프링 프레임워크에서 JPA를 편리하게 사용할 수 있도록 지원하는 프로젝트이다. 개발자는 데이터 접근 계층을 개발할 때 구현 클래스 없이 인터페이스만 작성해도 개발을 완료할 수 있다.
@Configuration
@EnableJpaRepositories(basePackages = "study.datajpa.repository")
public class AppConfig {}
원래는 위 코드처럼 위치를 지정해 줘야 하지만 스프링 부트 사용 시 생략 가능하다. @SpringBootApplication
가 해당 패키지와 하위 패키지를 위치로 자동 인식한다.
순수 JPA로 구현한 MemberJpaRepository
대신에 스프링 데이터 JPA가 제공하는 공통 인터페이스를 사용해 보자.
📌 순수 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);
}
...
}
📌 스프링 데이터 JPA
public interface MemberRepository extends JpaRepository<Member, Long>{
}
구현체 없이 인터페이스로만 동작하는 이유 : 스프링 데이터 JPA가 구현 클래스(Proxy)를 대신 생성해 준다.
Repository
애노테이션 생략 가능한 이유 : 컴포넌트 스캔을 스프링 데이터 JPA가 자동으로 처리해 준다.
스프링 데이터 JPA는 간단한 CRUD 기능을 공통으로 처리하는 JpaRepository 인터페이스를 제공한다.
스프링 데이터 JPA 이외에도 스프링 데이터 MongoDB, 스프링 데이터 Redis등이 존재
제네릭 타입
T
: 엔티티ID
: 엔티티의 식별자 타입주요 메서드
save(S)
: 새로운 엔티티는 저장하고 이미 있는 엔티티는 병합delete(T)
: 엔티티 하나를 삭제한다. 내부에서 EntityManager.remove() 호출findById(ID)
: 엔티티 하나를 조회한다. 내부에서 EntityManager.find() 호출getOne(ID)
: 엔티티를 프록시로 조회한다. 내부에서 EntityManager.getReference() 호출findAll(…)
: 모든 엔티티를 조회한다. 정렬( Sort )이나 페이징( Pageable ) 조건을 파라미터로 제공 가능스프링 데이터 JPA는 메서드 이름을 분석해서 JPQL을 생성하고 실행한다.
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
📌 쿼리 메서드 필터 조건
스프링 데이터 JPA 공식 문서 참고
📌 스프링 데이터 JPA가 제공하는 쿼리 메소드 기능
이 기능은 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다. 이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다!!
스프링 데이터 JPA를 활용하여 앞서 다룬 JPA의 Named 쿼리를 호출하는 방법이다. 실무에서는 이 방식을 거의 사용하지 않는다. 대신 아래의 @Query
를 사용한다.
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);
}
//단순히 값 한개를 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
//DTO로 직접 조회
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
"from Member m join m.team t")
List<MemberDto> findMemberDto();
1. 공통 인터페이스 : 일반적인 CRUD를 해결
2. 메서드 이름으로 쿼리 생성 : 실무에서 많이 씀(간단한 경우)
3. JPA NamedQuery : 실무에서 사용 안함
4. @Query
: 실무에서 많이 씀(복잡한 경우)
추가로 단순 값이나 DTO 조회시 @Query
사용
파라미터 바인딩에는 위치 기반과 이름 기반이 있다.
select m from Member m where m.username = ?0 //위치 기반
select m from Member m where m.username = :name //이름 기반
코드 가독성과 유지 보수를 위해 이름 기반 파라미터 바인딩을 사용하자!
ex
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);
}
List<Member> findListByUsername(String name); //컬렉션
Member findMemberByUsername(String name); //단건
Optional<Member> findOptionalByUsername(String name); //단건 Optional
조회 결과가 많거나 없으면?
null
반환javax.persistence.NonUniqueResultException
예외 발생반환 타입
Page : 추가 count 쿼리 결과를 포함하는 페이징
public interface Page<T> extends Slice<T> {
int getTotalPages(); //전체 페이지 수
long getTotalElements(); //전체 데이터 수
<U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}
Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으로 limit + 1조회)
public interface Slice<T> extends Streamable<T> {
int getNumber(); //현재 페이지
int getSize(); //페이지 크기
int getNumberOfElements(); //현재 페이지에 나올 데이터 수
List<T> getContent(); //조회된 데이터
boolean hasContent(); //조회된 데이터 존재 여부
Sort getSort(); //정렬 정보
boolean isFirst(); //현재 페이지가 첫 페이지 인지 여부
boolean isLast(); //현재 페이지가 마지막 페이지 인지 여부
boolean hasNext(); //다음 페이지 여부
boolean hasPrevious(); //이전 페이지 여부
Pageable getPageable(); //페이지 요청 정보
Pageable nextPageable(); //다음 페이지 객체
Pageable previousPageable();//이전 페이지 객체
<U> Slice<U> map(Function<? super T, ? extends U> converter); //변환기
}
```
List : 추가 count 쿼리 없이 결과만 반환
페이징과 정렬 사용 예제
검색 조건 : 나이가 10살
정렬 조건 : 이름으로 내림차순
페이징 조건 : 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
public interface MemberRepository extends Repository<Member, Long> {
Page<Member> findByAge(int age, Pageable pageable);
}
//페이징 조건과 정렬 조건 설정
@Test
public void page() 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));
//when
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC,"username"));
Page<Member> page = memberRepository.findByAge(10, pageRequest); //pagealbe은 인터페이스, pageRequest는 그 구현체
//then
List<Member> content = page.getContent(); //조회된 데이터
assertThat(content.size()).isEqualTo(3); //조회된 데이터 수
assertThat(page.getTotalElements()).isEqualTo(5); //전체 데이터 수
assertThat(page.getNumber()).isEqualTo(0); //페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2); //전체 페이지 번호
assertThat(page.isFirst()).isTrue(); //첫번째 항목인가?
assertThat(page.hasNext()).isTrue(); //다음 페이지가 있는가?
}
count 쿼리 분리
count 쿼리를 다음과 같이 분리할 수 있다. 성능 최적화를 위하여 복잡한 sql을 사용할 때 count 쿼리를 분리하자. 실무에서 매우 중요하다!!
@Query(value = “select m from Member m”,
countQuery = “select count(m.username) from Member m”)
Page<Member> findMemberAllCountBy(Pageable pageable);
DTO 변환
페이지를 유지하면서 엔티티를 DTO로 변환할 수 있다.
Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());
벌크성 수정, 삭제 쿼리는 @Modifying
어노테이션을 사용해야 한다. 추가로 벌크성 쿼리를 실행하고 나서 영속성 컨텍스트를 초기화해야 한다. 왜냐하면 벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에 영속성 컨텍스트에 있는 엔티티의 상태와 DB의 엔티티 상태가 달라질 수 있기 때문이다. @Modifying(clearAutomatically = true)
를 통해 초기화하자. 이 옵션의 기본값은 false다.
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
연관된 엔티티를 한번에 조회하는 방법에는 페치 조인 외에도 EntityGraph가 있다. NamedEntityGraph도 있지만 잘 사용하지 않는다.
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
//공통 메서드 오버라이드
@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 Hint : 변경 감지는 원본과 수정본(snapshot) 두개를 동시에 관리한다. 따라서 만약 변경하지 않고 조회만 하고 싶을 때는 변경 감지가 작동하는 것이 비효율적이다. 따라서 만약 조회만을 목적으로 할 때 이를 최적화할 수 있는 방법이 바로 Hint다.
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value ="true"))
Member findReadOnlyByUsername(String username);
성능 최적화 효과가 그렇게 크지 않으므로 일일이 다 적용하기 보다는 성능테스트를 해보고 필요할때 사용하자.
Lock
자세한 내용은 JPA책 참고하자.
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);
스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동으로 생성해 준다. 만약 메서드 이름 방식이나 @Query
방식으로 해결이 안 되어 JPA 직접 사용(EntityManager), 스프링 JDBC Template, Querydsl 방식을 통해 인터페이스의 메서드를 직접 구현하고 싶을 때는 사용자 정의 리포지토리를 구현하면 된다. 사용법은 아래와 같다.
사용자 정의 인터페이스
public interface MemberRepositoryCustom {
List<Member> findMemberCustom();
}
사용자 정의 인터페이스 구현 클래스
규칙 : 사용자 정의 인터페이스 명 + Impl or 리포지토리 인터페이스 이름 + impl
보통 전자를 선호한다.
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m")
.getResultList();
}
}
사용자 정의 인터페이스 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}
실무에서는 주로 QueryDSL이나 스프링 JdbcTemplate를 함께 사용할 때 사용자 정의 리포지토리 기능을 사용한다. 하지만 무작정 사용자 리포지토리가 필요한 것은 아니다 그냥 임의의 리포지토리를 만들어도 된다. 만약 사용자 정의 리포지토리를 무작정으로 사용하면 기존 리포지토리의 규모가 커지고 복잡해질 수 있다.
엔티티를 생성, 변경할 때 변경한 사람과 시간을 추적하고 싶을 때 사용한다.
순수 JPA
@MappedSuperclass
@Getter
public class JpaBaseEntity {
@Column(updatable = false)
private LocalDateTime createdDate;
private LocalDateTime updatedDate;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
createdDate = now;
updatedDate = now;
}
@PreUpdate
public void preUpdate() {
updatedDate = LocalDateTime.now();
}
}
적용하고자 하는 엔티티에서 JpaBaseEntity를 상속하면 된다.
public class Member extends JpaBaseEntity {
...
}
스프링 데이터 JPA
먼저 스프링 부트 설정 클래스에 @EnableJpaAuditing
를 적용한다. 실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만 등록자, 수정자는 없을 수도 있다. 그래서 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속한다.
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@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;
만약 등록자, 수정자가 필요하다면 이들을 처리해 주는 AuditorAware를 스프링 빈으로 등록해야 한다. 아래에서는 편의상 UUID를 사용하지만 실무에서는 세션 정보나 스프링 시큐리티 로그인 정보에서 ID를 받는다.
@SpringBootApplication
@EnableJpaAuditing
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.of(UUID.randomUUID().toString());
}
}
HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩한다.
도메인 클래스 컨버터 사용 전
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Long id) {
Member member = memberRepository.findById(id).get();
return member.getUsername();
}
}
도메인 클래스 컨버터 사용 후
단순 조회용으로만 사용해야 한다는 단점이 있다. 권장하지 않음.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Member member) {
return member.getUsername();
}
}
앞서 리포지토리에서 페이징과 정렬 기능을 사용하는 방법을 공부했다. MVC에서도 마찬가지로 스프링 데이터가 편리한 방법을 제공한다.
페이징과 정렬 예제
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
return page;
}
요청 파라미터
스프링 부트가 파라미터들을 PageRequest 객체로 생성해서 주입해 준다.
예) /members?page=0&size=3&sort=id,desc&sort=username,desc
page : 현재 페이지, 0부터 시작한다.
size : 한 페이지에 노출할 데이터 건수
sort : 정렬 조건을 정의한다. 예) 정렬 속성,정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면 sort 파라미터 추가 ( asc 생략 가능)
기본값
기본 페이지 사이즈는 20, 최대 페이지 사이즈는 2000으로 설정되어 있다. 스프링 부트의 글로벌 설정이나 @PageableDefault
어노테이션을 사용해 바꿀 수 있다.
접두사
@Qualifier
에 접두사명 추가 "{접두사명}_xxx”public String list(
@Qualifier("member") Pageable memberPageable,
@Qualifier("order") Pageable orderPageable, ...
Page 내용을 DTO로 변환하기
엔티티를 API로 노출하면 다양한 문제가 발생한다. 그래서 엔티티를 꼭 DTO로 변환해서 반환해야 한다. Page는 map() 을 지원해서 내부 데이터를 다른 것으로 변경할 수 있다.
Member DTO
@Data
public class MemberDto {
private Long id;
private String username;
public MemberDto(Member m) {
this.id = m.getId();
this.username = m.getUsername();
}
}
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
Page<MemberDto> pageDto = page.map(MemberDto::new);
return pageDto;
}
Page를 1부터 시작하기
스프링 데이터는 Page를 0부터 시작한다. 만약 1부터 시작하려면 2가지 방법이 있는데 자세한 내용은 강의나 책 참고하자.
스프링 데이터 JPA의 공통 인터페이스 JpaRepository는 구현체로 SimpleJpaRepository를 가진다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> ...{
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
...
}
@Repository
1. JPA 예외를 스프링이 추상화한 예외로 변환, 하부 기술을 Jdbc에서 Jpa로 바꿔도 예외를 처리하는 매커니즘은 동일
2. 스프링 빈 컴포넌트 스캔 대상
@Transactional
JPA의 모든 데이터 변경은 트랜잭션 안에서 일어나야 한다. 서비스 계층의 트랜잭션 유무에 따라 두 가지 경우가 있다.
@Transactional
을 가지고 있어서 리포지토리에서 트랜잭션을 시작함따라서 스프링 데이터 JPA를 사용할 때 트랜잭션이 없어도 데이터 등록, 변경을 자유롭게 할 수 있다.
save() 메서드
save() 메서드는 새로운 엔티티면 persist, 새로운 엔티티가 아니면 merge를 한다. entityInformation가 새로운 엔티티를 판단하는 기본 전력은 다음과 같다.
JPA 식별자 생성 전략이 @GenerateValue
면 save() 호출 시점에 식별자가 없으므로 새로운 엔티티로 인식해서 정상 동작한다. 하지만 JPA 식별자 생성 전략이 @Id
만 사용한 직접 할당이면 이미 식별자 값이 있는 상태로 save()를 호출한다. 따라서 이 경우 merge() 가 호출된다. merge()는 매우 비효율적이므로 사용하지 않는 것이 좋다. 이 경우에는Persistable
를 사용해서 새로운 엔티티 확인 여부를 직접 구현하게는 효과적이다. 참고로 등록시간@CreatedDate
을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다. 여기서는 @CreatedDate
에 값이 없으면 새로운 엔티티로 판단한다.
Persistable 구현
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {
@Id
private String id;
@CreatedDate
private LocalDateTime createdDate;
public Item(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
@Override
public boolean isNew() {
return createdDate == null;
}
}
스프링 데이터 JPA는 JPA Criteria를 활용해서 이 개념을 사용할 수 있도록 지원한다. 하지만 실무에서는 JPA Criteria를 안쓴다. 대신에 QueryDSL을 사용하자.
실무에서는 Query By Example 안쓴다. 대신 QueryDSL을 사용하자.
Projections는 DB에서 엔티티 대신 원하는 DTO를 조회할 때 사용한다. 예를 들어 회원의 이름만 조회하고 싶을 때 사용하면 좋다. 실무에서는 단순할 때만 사용하고, 조금만 복잡해지면 QueryDSL을 사용하자.
인터페이스 기반 closed Projections
public interface MemberRepository extends JpaRepository<Member, Long> {
List<UsernameOnly> findProjectionsByUsername(String username);
}
프로퍼티 형식(getter)의 인터페이스를 제공하면, 구현체는 스프링 데이터 JPA가 제공해 준다.
public interface UsernameOnly {
String getUsername();
}
인터페이스 기반 Open Projections
스프링의 SpEL 문법을 사용한다. JPQL SELECT절 최적화가 안된다는 단점이 있다.
public interface UsernameOnly {
@Value("#{target.username + ' ' + target.age + ' ' + target.team.name}")
String getUsername();
}
클래스 기반 Projection
다음과 같이 인터페이스가 아닌 구체적인 DTO 형식도 가능하다.
생성자의 파라미터 이름으로 매칭한다.
public class UsernameOnlyDto {
private final String username;
public UsernameOnlyDto(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
동적 Projections
다음과 같이 Generic type을 주면, 동적으로 프로젝션 데이터 번경 가능하다.
<T> List<T> findProjectionsByUsername(String username, Class<T> type);
List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1", UsernameOnly.class);
JPA는 SQL을 직접 사용할 수 있는 기능을 제공하는데 이를 네이티브 쿼리라 한다. 실무에서는 거의 안쓰고 대신 QueryDSL, JdbcTemplate, myBatis 등으로 해결한다. 최근에 스프링 데이터 Projections 활용 기술이 나왔는데 그냥 이런 기술들이 있구나 정도로 넘어가자.