EntityManager를 이용해 CRUD를 작성해보면 도메인에 상관없이 중복되는 로직이 발생한다.
public Team save(Team team) {
em.persist(team);
return team;
}
public Optional<Team> findById(Long id) {
Team team = em.find(Team.class, id);
return Optional.ofNullable(team);
}
public Member save(Member member) {
em.persist(member);
return member;
}
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
JPA가 제공하는 공통적인 기능을 인터페이스로 추출한 라이브러리가 Spring Data Jpa이다.
도메인 레포지토리를 인터페이스로 선언하고 공통 인터페이스인 JpaRepository를 상속받는다.
public interface MemberRepository extends JpaRepository<Type, Id>
@Repository 생략이 가능하다.
인터페이스로 선언하고 별다른 구현체가 없어도 사용이 되는 이유는 Spring이 인터페이스를 보고 구현 클래스를 프록시 객체로 만들어서 주입하기 때문이다.
도메인에 특화된 메서드를 사용하는 경우 레포지토리를 구현해서 사용하고자 한다면, 생성한 레포지토리 뿐만 아니라 상위의 JpaRepository, PagingAndSortingRepository, CrudRepsitory 등 모든 상속받은 인터페이스를 구현해야하기 때문에 사실상 구현해 사용하는 방법은 불가능하다.
주요 메서드
save(S) : 엔티티를 저장하거나 병합
delete(T) : 엔티티 삭제, EntityManager.remove() 호출
findById(ID) : 특정 아이디와 매핑된 엔티티를 조회 EntityManager.find(); 호출
findAll(…) : 모든 엔티티 조회, Sort, Pageable 조건 파라미터 추가 가능
쿼리 메서드 기능
메서드 이름으로 쿼리를 생성
메서드 이름을 관례에 맞게 설정한다.
사용자의 이름과 나이가 15살보다 많은 사용자를 구한다.
//순수 JPA
public List<Member> findByUsernameAndAgeGreaterThan(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();
}
// Spring Data Jpa
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
정확한 프로퍼티명을 작성해줘야한다. 프로퍼티명이 잘못된 경우 예외가 발생한다.
파라미터가 증가하거나 조건이 변경되게 되면 메서드를 새로 생성해야하고, 메서드 명이 길어지는 단점이 있다.
파라미터를 많이 사용하고 메서드 명이 길어지는 경우 다른 방법을 사용하는 것을 권장한다.
엔티티 필드명 변경 시 인터페이스에 정의한 메서드 이름 반드시 수정해야한다.
수정을 안한 경우 애플리케이션 실행 시 프로퍼티를 찾을 수 없는 오류가 발생하기 때문에 큰 장애로 번지는 것을 막아준다.
@NamedQuery를 엔티티에 작성하고, Repository에 @Query를 이용해 네임드 쿼리를 매핑한다.
JPQL에 :파라미터가 명확하게 전달되는 경우 @Param을 이용해 파라미터를 매핑한다.
@NamedQuery(
name="Member.findUsername",
query="select m from Member m where m.username = :username"
)
public class Member { ... }
// JPQL에 :파라미터가 명확하게 전달된 경우 @Param을 반드시 이용한다.
@Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);
@Query를 생략해도 동작하는 이유는 우선순위가 NamedQuery → 메서드 이름 쿼리 생성 순서이기 때문이다.
네임드 쿼리는 애플리케이션 로딩 시점에 파싱하기 때문에 잘못된 프로퍼티인 경우 오류를 발생시킨다.
실무에서 거의 사용하지 않는다.
🔥 @Query를 이용해 레포지토리 메서드에 쿼리 정의
@Query를 이용해 메서드 명 위에 직접 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);
메서드 명이 길어지는 경우 직접 JPQL을 작성해 메서드 명을 짧게 지정할 수 있다.
복잡한 쿼리의 경우 JPQL로 바로 정의하는 것이 가능하다.
@Query 내에 정의한 쿼리는 이름이 없는 네임드 쿼리로 정적 쿼리이기 때문에 애플리케이션 로딩 시점에 파싱해서 사용한다. 즉, 애플리케이션 로딩 시점에 프로퍼티가 없는 경우 에러를 발생시킨다는 장점이 있다.
실무에서 가장 많이 사용하는 기능이다.
@Query, 값, DTO 조회
특정한 값 혹은 Embedded 타입을 조회하는 경우 @Query에 쿼리를 작성한다.
// 실제 값으로 가져오기 -> username은 String 값(특정 값)
@Query("select m.username from Member m")
List<String> findUsernameList();
Dto를 조회하는 경우 반드시 Dto에 @AllArgsConstructor를 선언하거나 모든 필드를 가진 생성자를 반드시 만든다.
@Query 내에서 new 키워드를 통해 조회하고, Dto는 풀 패키지명까지 붙여 사용한다.
// DTO로 조회하기 (new 사용) -> 생성자 선언하는 것 처럼 사용
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();
파라미터 바인딩
파라미터 바인딩은 위치 기반, 이름 기반을 지원하지만 이름 기반을 사용하도록 한다.
@Param을 이용해 @Query내 :파라미터에 바인딩 한다.
컬렉션 타입을 IN절에 파라미터 바인딩 할 수 있다.
// IN 절에 컬렉션 바인딩 가능
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
반환 타입
유연한 반환 타입을 지원한다. (컬렉션, 단건(엔티티), Optional 등)
List<Member> findListByUsername(String username); // 리스트
Member findMemberByUsername(String username); // 단일
Optional<Member> findOptionalByUsername(String username); // Optional
컬렉션 반환 타입의 경우 데이터가 없다면 Empty Collection을 반환한다.
List는 Not Null을 보장한다.
단건 조회의 경우 데이터가 없다면 null을 반환한다.
순수 JPA의 경우 NoResultException이 발생
Spring Data JPA에서는 null로 반환
따라서 단일 데이터는 Optional로 반환받는 것이 바람직하다.
null인 경우 Optional.empty를 반환
단일 데이터 조회이지만 데이터가 한개 이상 조회되는 경우 예외가 발생한다.
JPA 예외가 발생하고 이를 Spring이 예외를 변환해서 발생시킨다.
🔥 페이징 및 정렬
순수 JPA만 이용하는 경우 setFirstResult, setMaxResults를 이용해 페이징 처리를 한다.
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();
}
DB Dialect가 변경되더라도 DB 벤더에 맞는 쿼리가 발생한다.
Spring Data Jpa는 org.springframework.data.domain.Sort, Pageable로 페이징 공통화
리턴 타입에 따라TotalCount 쿼리의 호출 유무를 판단한다.
Page : TotalCount 쿼리를 추가로 호출한다.
Slice : TotalCount 쿼리를 호출하지 않는다.무한 스크롤, 더보기 같은 페이징 처리에 사용한다.
Pageable은 Pageable의 구현체인 PageRequest.of()를 이용해 구현해 넘겨준다.
PageRequest.of(페이지 번호, 사이즈, [option] 정렬)
페이지의 시작은 0페이지 부터 시작한다.
// 사용자 이름 내림차순으로 정렬
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page 리턴타입의 경우 페이징 쿼리와 TotalCount 쿼리가 함께 호출된다.
// 0 페이지에서 3개 가져오기
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
//when
Page<Member> page = memberRepository.findByAge(age, pageRequest);
// then
List<Member> content = page.getContent();
long totalElements = page.getTotalElements();
assertThat(content.size()).isEqualTo(3); // 현재 페이지 데이터 개수
assertThat(page.getTotalElements()).isEqualTo(5); // totalCount
assertThat(page.getNumber()).isEqualTo(0); // 가져온 페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2); // 전체 페이지 개수
assertThat(page.isFirst()).isTrue(); // 첫번째 페이지인지?
assertThat(page.hasNext()).isTrue(); // 다음 페이지가 있는지?
assertThat(page.isLast()).isFalse(); // 마지막 페이지인지?
테이블을 조인해 페이징을 하게되는 경우 Page 리턴 타입의 경우 totalCount 쿼리 또한 함께 join되어 사용되기 때문에 성능적으로 문제가 생길 수 있다.
@Query의 countQuery 프로퍼티를 이용해 데이터를 조회하는 쿼리와 카운트 쿼리를 구분한다.
@Query(value = "select m from Member m left join m.team t", countQuery = "select count(m.username) from Member m")
Page<Member> findByAge(int age, Pageable pageable);
map()을 이용해서 가져온 엔티티 데이터를 Dto로 변환하는 것이 가능하다.
벌크성 수정 쿼리
한 번에 데이터를 수정하는 작업을 SQL에서는 한 번에 가능하지만 JPA는 객체 지향적이기 때문에 모든 객체에 대해 변경 감지가 필요하다.
이를 SQL과 비슷하게 한번에 수정하는 연산을 벌크 연산이라고 한다.
// 벌크 연산 [순수 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을 이용해 executeUpdate() 메서드를 대체할 수 있다.
@Modifying(clearAutomatically = true) // JPA의 executeUpdate를 호출한다.
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
벌크 연산은DB에 바로 update연산을 하는 것이기 때문에 영속성 컨텍스트에 있는 데이터와 불일치가 발생할 수 있다.
벌크 연산 이후에는 영속성 컨텍스트를 초기화 하는 것이 중요하다.
// 벌크 연산
int resultCount = memberRepository.bulkAgePlus(20);
// 영속성 컨텍스트에 반영 안된 작업 반영 및 초기화
em.flush();
em.clear();
영속성 컨텍스트 초기화를 @Modifying의 clearAutomatically = true로 해주는 것으로 대체할 수 있다.
벌크 연산이외에도 MyBatis, JDBC Template 등과 같이 영속성 컨텍스트가 감지할 수 없는 쿼리를 날리는 로직 이전에는 기본적으로 영속성 컨텍스트를 flush하고 clear해주는 작업이 필요하다.
@EntityGraph
모든 연관관계에 있는 엔티티는 지연 로딩을 기본으로 하는 것이 중요하다.
지연 로딩 시 N + 1 문제가 발생할 수 있다.
fetch join을 이용해 N + 1 문제를 해결하는 것이 가능하다.
join이 많아지고 복잡한 쿼리를 사용해야할 땐 @Query에 JPQL로 fetch join을 작성해주면 되지만, 메서드 이름으로 쿼리를 생성하거나, 간단한 쿼리의 경우 JPQL을 추가적으로 작성하는 것은 비효율적이다.
@EntityGraph를 이용해 fetch join을 지원한다. @EntityGraph에 attributePaths를 통해 한 번에 조회할 엔티티를 지정한다.
// 페치조인 (연관된 엔티티 바로가져옴)
@Query("select m from Member m left join fetch m.team")
List<Member> findMEmberFetchJoin();
// 메서드 이름으로 쿼리 생성 경우
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
// fetch join까지 쿼리를 작성하지 않고 fetch join 하는 부분을 @EntityGraph로 구분
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
@Named 쿼리와 비슷하게 @NamedEntityGraph를 엔티티에 선언하고 @EntityGraph에서 호출하는 것이 가능하다.
// NamedEntityGraph
@NamedEntityGraph(name = "MEmber.all", attributeNodes = @NamedAttributeNode("team"))
public class Member { ... }
// NamedEntityGraph 호출
@EntityGraph("Member.all")
List<Member> findEntityGraphByUsername(@Param("username") String username);
Hint와 Lock
SQL 힌트가 아닌 JPQ 구현체에게 제공하는 힌트
영속성 컨텍스트 내 엔티티가 변경되는 경우 JPA가 flush 하는 시점에 감지해 update 쿼리를 날린다.
변경 감지가 이뤄지기 위해선 원본 데이터에 대한 스냅샷이 필요하다.
스냅샷으로 인한 메모리 비용이 추가로 발생한다.
데이터 변경 목적의 조회가 아닌 100% 조회 용도 쿼리인 경우 성능 최적화를 위해 힌트를 사용한다.
성능 테스트를 해보고 필요한 경우에만 사용한다.
// 힌트 사용 - 스냅샷을 만들지 않기 때문에 변경하더라도 변경감지가 발생하지 않는다.
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
JDBC, MyBatis 등 JPA 이외의 기능을 사용하고자 하는 경우 사용자 정의 리포지토리를 구현해서 사용한다.
QueryDSL을 사용할 때 많이 사용한다.
JpaRepository를 상속한 리포지토리 인터페이스를 직접 구현하는 것은 모든 메서드를 오버라이딩해서 구현해야하기 때문에 불가능하다.
사용 방법
Custom한 리포지토리 인터페이스를 만든다.
Custom 리포지토리 인터페이스를 구현하는 구현 클래스를 만들고 메서드를 오버라이딩한다.
실제 인터페이스로 사용되고 있는 리포지토리에서 Custom 리포지토리 인터페이스를 상속받는다.
// 사용자 정의 리포지토리 인터페이스 상속
public interface MemberRepository extends JpaRepository<Member, Long> , MemberRepositoryCustom { ... }
Custom 리포지토리를 구현한 클래스는 리포지토리 인터페이스 이름에 Impl을 붙여서 사용한다.
MemberRepository -> 실제 Spring Data Jpa를 이용하는 리포지토리 인터페이스
MemberCustomRepositor -> 사용자가 정의한 리포지토리 인터페이스
MemberRepositoryImpl -> 사용자가 정의한 리포지토리를 구현한 클래스 [MemberRepository + Impl]
Spring Data 2.X 부터 Custom 리포지토리의 인터페이스 명에 Impl을 붙히는 방식도 지원한다.
MemberCustomRepositor -> 사용자가 정의한 리포지토리 인터페이스
MemberCustomRepositoryImpl -> 사용자가 정의한 리포지토리를 구현한 클래스 [MemberRepository + Impl]
핵심 비즈니스 로직 쿼리와 화면에 조회되는 쿼리를 구분하는 것이 중요하다.
항상 사용자 정의 리포지토리가 필요한 것은 아니다. 임의의 리포지토리 클래스를 만들고 빈으로 등록해 사용하는 것도 가능하다.
🔥 Auditing
모든 테이블을 생성할 때 등록일, 수정일, 등록자, 수정자를 기본적으로 등록해서 사용하고 싶을 때 자동화 하는 방식이다.
순수 JPA를 이용하는 경우 BaseEntity를 생성한 후 @MappedSuperClass를 이용해 엔티티에서 속성만 상속받도록 구현한다.
@PrePersist @PreUpdate, @PostPersist, @PostUpdate
@Getter
@MappedSuperclass // 속성을 상속해주는 느낌
public class JpaBaseEntity {
@Column(updatable = false)
private LocalDateTime createdDate;
private LocalDateTime updatedDate;
// Persist 이전에 이벤트가 발생
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
this.createdDate = now;
this.updatedDate = now;
}
// update 이전에 이벤트가 발생
@PreUpdate
public void preUpdate() {
updatedDate = LocalDateTime.now();
}
}
@EnableJpaAuditing을 Application에 지정한다.
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication { ... }
메서드를 통해 생성 시간, 수정 시간을 주입하던 부분을 @CreatedDate, @LastModifiedDate를 통해 해결할 수 있다.
생성자, 수정자를 등록하는@CreatedBy, @LastModifedBy를 지원한다.
이벤트가 발생했을 때 감지하기 위해서 @EntityListener를 사용한다.
// 이벤트가 발생했을 때 사용되도록 하기 위해서 지정
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity{
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String lastModifiedBy;
}
생성 시간, 수정 시간은 일반적으로 모두 사용하지만, 생성자, 수정자는 테이블마다 케이스 바이 케이스이기 때문에 BaseTimeEntity를 생성하고, BaseEntity에는 등록자, 수정자만 두어 상황에 따라 상속받아 사용한다.
등록자, 수정자는 String 타입임에도 자동으로 등록하는 것이 가능한데, AuditorAware 인터페이스를 구현해 기본으로 지정될 아이디를 등록하는 것이 가능하다. -> 실무에선 UUID아닌 개인의 세션 아이디로 사용될 듯..?
@Bean
public AuditorAware<String> auditorProvider() {
// UUID를 등록자, 수정자로 주입되게 하는 코드
return () -> Optional.of(UUID.randomUUID().toString());
}
도메인 클래스 컨버터
// 두개 모두 동일결과 반환
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Long id) {
Member member = memberRepository.findById(id).get();
return member.getUsername();
}
@GetMapping("/members2/{id}")
public String findMember2(@PathVariable("id") Member member) {
return member.getUsername();
}
요청은 id를 받지만 매핑되는 회원 엔티티로 자동으로 변경되어 사용할 수 있다.
파라미터로 엔티티를 받는 경우 트랜잭션 내에서 관리되는 엔티티가 아니다 → 영속성 컨텍스트에서 관리되는 엔티티가 아니다.
단순 조회용으로 사용한다. 별로 권장되지 않는다.
🔥 Web 계층 페이징 및 정렬
페이징 및 정렬을 하기위해 쿼리파라미터를 직접 지정하는 것이 아닌 Pageable 인터페이스로 받는 것이 가능하다.
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
return page;
}
// 요청예제
localhost:8080/members?page=0 // 첫번째 페이지(zero indexing) default 20건
localhost:8080/members?page=1&size=3 // 두번째 페이지 데이터 3건 조회
localhost:8080/members?page=1&size=3&sort=username,desc // 두번째 페이지 데이터 3건 이름 역순
localhost:8080/members?page=1&size=3&sort=id // 두번째 페이지 데이터 3건 PK기준 오름차순
application.yml에서 페이징 관련 default를 수정할 수 있다. (Global 설정)
@PageableDefault를 이용해 특정 API에만 페이징 관련 설정정보를 적용하는 것이 가능하다.
public class UsernameOnlyDto {
private final String username;
// 생성자 파라미터 이름으로 매칭해 프로젝션
public UsernameOnlyDto(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
복잡한 쿼리를 사용하는 경우 QueryDSL을 사용한다. 실무의 경우 단순한 경우에만 프로젝션을 사용한다.
Native Query
네이티브 쿼리는 가급적이면 사용하지 않는 것이 좋다. 왠만하면 QueryDSL로 풀 수 있다.
페이징을 지원한다.
리턴 타입으로 Dto를 사용하는 것을 권장한다.
네이티브 쿼리와 Projection을 함께 이용하는 것이 가능하다.
@Query(value = "select m.member_id as id, m.username, t.name as teamName " +
"from member m left join team t",
countQuery = "select count(*) from member",
nativeQuery = true)
Page<MemberProjection> findByNativeProjection(Pageable pageable);
동적 네이티브 쿼리를 이용하는 경우 Spring JdbcTemplate, Mybatis 등 외부 라이브러리를 사용한다.