Open seungriyou opened 8 months ago
IntelliJ 설정에서 롬복 사용 설정
엔티티에 기본 생성자가 필요하며, 이는 public
과 protected
까지 가능하다.
혹은 Lombok에서 제공하는 기능을 사용하자.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
엔티티 필드를 설정하는 것은 생성자에서 주입하거나 의미있는 메서드를 따로 만드는 것이 낫다. setter는 지양하자.
테스트 코드 작성 시, 스프링 빈을 주입받으려면 @SpringBootTest
를 달아주자.
테스트 코드 작성 시에도, JPA의 모든 데이터 변경은 트랜잭션 안에서 일어나야 하므로 @Transactional
을 달아주자.
@Transactional
을 사용하면 테스트 완료 시 롤백해준다. 따라서 콘솔에서 SQL이 출력되지 않는다. 눈으로 확인해보려면 @Rollback(false)
를 추가로 달아준다.두 가지 방법이 있는데, 외부 라이브러리를 사용하는 방법을 선택하겠다.
application.yml
설정 추가
```yaml
logging.level:
org.hibernate.type: trace
```
외부 라이브러리 사용
p6spy
의 latest release version을 확인하고, build.gradle
에 다음과 같이 추가한다. (현재는 1.9.1
)
implementation("com.github.gavlyukovskiy:p6spy-spring-boot-starter:${version}")
혹은
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'
[!caution] 운영에서 사용하려면 로그를 많이 남기기 때문에 성능 테스트를 수행해야 한다.
[!note] JPA에서 수정은 리포지토리 안에 따로 메서드를 만들 필요 없이, 변경 감지 기능을 사용하면 된다. (dirty checking)
→ 트랜잭션 안에서 엔티티를 조회한 다음 데이터를 변경하면, 트랜잭션 종료 시점에 변경 감지 기능이 작동하여 변경된 엔티티를 감지하고 UPDATE 쿼리를 실행한다.
@SpringBootApplication
위치를 지정한다. (해당 패키지 + 하위 패키지)@EnableJpaRepositories
필요[!tip] 개발자가 인터페이스만 선언해두면, 스프링 JPA가 구현체를 만들어서 주입해준다.
JpaRepository
를 상속 받아 선언한 인터페이스가 (구현체를 구현하지 않았음에도) 실제로 동작한다.
@Repository
어노테이션을 생략할 수 있다.
JpaRepository<entity-class, entity-pk-type>
JPA로 작성한 코드의 테스트 코드를 그대로 복사 붙여넣기 하면 동작한다!
JpaRepository
인터페이스는 공통 CRUD를 제공한다.<엔티티 타입, 식별자 타입>
을 설정한다.T findOne(ID)
→ Optional<T> findById(ID)
로 변경되었다.boolean exists(ID)
→ boolean existsById(ID)
로 변경되었다.T
: 엔티티ID
: 엔티티의 식별자 타입S
: 엔티티와 그 자식 타입
JpaRepository
는 대부분의 공통 메서드를 제공한다.
save(S)
: 새로운 엔티티는 저장, 이미 있는 엔티티는 병합delete(T)
: 엔티티 하나를 삭제내부에서
em.remove()
호출
findById(ID)
: 엔티티 하나 조회내부에서
em.find()
호출
getOne(ID)
: 엔티티를 프록시로 조회내부에서
em.getReference()
호출
findAll(...)
: 모든 엔티티 조회정렬(
Sort
)이나 페이징(Pageable
) 조건을 파라미터로 제공
쿼리 메소드 기능 세 가지
- 메소드 이름으로 쿼리 생성
- 메소드 이름으로 JPA NamedQuery 호출
@Query
어노테이션을 사용해서 리포지토리 인터페이스에 쿼리 직접 정의
조회: find...By
, read...By
, query...By
, get...By
By
이후에는 조건이 들어가게 된다....
에 식별하기 위한 내용(설명)이 들어가도 된다. (ex. findHelloBy
)COUNT
: count...By
→ 반환 타입 long
EXISTS
: exists...By
→ 반환 타입 boolean
삭제: delete...By
, remove...By
→ 반환 타입 long
DISTINCT
: findDistinct
, findMemberDistinctBy
LIMIT
: findFirst3
, findFist
, findTop
, findTop3
조건 2개까지는 이 방법으로 할만하다!
[!caution] 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 어플리케이션 로딩 시점에 오류가 발생한다.
→ 스프링 데이터 JPA의 큰 장점!
실무에서 거의 사용하지 X
어플리케이션 로딩 시점에 NamedQuery들은 한 번 파싱이 되기 때문에, 일반 문자열로 작성했던 JPQL과는 다르게 오류가 있으면 해당 시점에 알 수 있다는 장점이 있다.
하지만 해당 장점은 그대로 가지고 있으면서도, 리포지토리 메서드에 바로 쿼리를 작성할 수 있는 다음 방식이 훨씬 편리하므로 실무에서 거의 사용할 일이 없다.
[!note] 명확하게
@Param()
을 적는 경우?→ 명확하게 JPQL이 있고, 내부에서
:username
과 같이 파라미터를 사용할 때! (이름으로 매칭시켜 준다.)→ 메서드 이름으로 쿼리 생성할 때는 쓰지 않아도 된다!
@Query
: 리포지토리 메서드에 쿼리 정의하기 (✅)// <3> @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);
[!tip] 간단한 쿼리는 <1>번 방법으로, 조금 복잡한 쿼리는 <3>번 방법으로 간단한 메서드 이름을 부여해서 사용한다.
동적 쿼리는 QueryDSL을 사용하자.
@Query
, 값, DTO 조회하기
실무에서 많이 사용한다!
- 단순히 값 하나를 조회
JPA 값 타입(
@Embedded
)도 이 방식으로 조회할 수 있다.
```java
@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();
파라미터 바인딩
이름 기반 (→ 가독성 & 유지보수를 위해 권장 ✅)
@Param(...)
을 사용한다.
컬렉션 파라미터 바인딩도 다음과 같이 가능하다.
Collection
타입으로 IN
절을 지원한다.
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
스프링 데이터 JPA에서는 같은 동작에 대해서 반환 타입을 다음 세 가지 중 아무거나 적어도 된다.
List<Member> findByUsername(String name); // 컬렉션
Member findByUsername(String name); // 단건
Optional<Member> findByUsername(String name); // 단건 Optional
조회 결과가 많거나 없다면?
컬렉션
null
인지 확인할 필요 없이 그냥 받으면 된다!
단건 조회
결과 없음: null
반환 (예외 발생 X)
내부적으로 JPQL의
.getSingleResult()
메서드가 호출되는데, 조회 결과가 없으면javax.persistence.NoResultException
예외가 발생한다. 스프링 데이터 JPA는 이 예외가 발생하면 예외를 무시하고null
을 반환하는 것이다.
결과가 2건 이상: javax.persistence.NonUniqueResultException
예외 발생
스프링이 제공하는 예외로 변환되어 반환된다.
// 페이징 및 정렬
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")
.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();
}
페이징과 정렬 파라미터
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 쿼리 없이 결과만 반환
페이징과 정렬은 다음과 같이 4가지 예제로 살펴볼 수 있다.
Page<Member> findByUsername(String name, Pageable pageable)
→ count 쿼리 사용
@Test
public void paging() {
// 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;
// username 기준 desc 정렬
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Direction.DESC, "username"));
// when
Page<Member> page = memberRepository.findByAge(age, 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();
}
Pageable
은 인터페이스이며, 실제로 사용할 때는 주로 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest
객체를 사용한다.PageRequest
생성자의 파라미터
페이지는 0부터 시작한다!!
pageNumber
: 현재 페이지pageSize
: 조회할 데이터 수Slice<Member> findByUsername(String name, Pageable pageable)
→ count 쿼리 사용 X
// when
Slice<Member> page = memberRepository.findByAge(age, pageRequest);
// then
List<Member> content = page.getContent();
assertThat(content.size()).isEqualTo(3);
assertThat(page.getNumber()).isEqualTo(0);
assertThat(page.isFirst()).isTrue();
assertThat(page.hasNext()).isTrue();
limit에 1개를 더 요청해온다.
pageSize
=3으로 설정했으나, 4 rows를 가져온다.select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username from member m1_0 where m1_0.age=10 order by m1_0.username desc offset 0 rows fetch first 4 rows only;
count 쿼리가 나가지 않는다.
List<Member> findByUsername(String name, Pageable pageable)
→ count 쿼리 사용 X
pageSize
만큼의 결과를 일반 list로 반환받을 수도 있다.- count 쿼리가 나가지 않는다.
- 여러 기능들이 제공되지 않는다.
List<Member> findByUsername(String name, Sort sort)
[!tip] 실무에서 count 쿼리의 성능 관련 조언
totalCount
를 세는 count 쿼리는 조인을 할 필요가 없는 경우(ex. 모두 left outer join인 경우)가 있어 최적화가 가능할 수 있다. 이러한 상황을 위해 count 쿼리를countQuery
로 분리할 수도 있다.@Query(value = "select m from Member m left join fetch m.team t", countQuery = "select count(m.username) from Member m") Page<Member> findByAge(int age, Pageable pageable);
[!tip]
Sort
조건이 너무 복잡해지면 그냥@Query
로 빼서 쓰자.
[!warning] API에서 반환하기 전에 엔티티를 DTO로 변환해야 한다. 이때,
Page
를 유지하면서 엔티티를 DTO로 변환하는 방법은?→
map
을 사용하자!Page<Member> page = memberRepository.findByAge(age, pageRequest); // entity -> dto 변환 // dto로 변환 후에는 Page로 그대로 반환해도 ok Page<MemberDto> toMap = page.map(m -> new MemberDto(m.getId(), m.getUsername(), null));
[!note]
Top
,First
사용은 다음을 참고한다.
ex) 모든 직원들의 연봉을 10% 인상
public int bulkAgePlust(int age) {
int resultCount = em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
.setParameter("age", age)
.executeUpdate();
return resultCount;
}
// 벌크성 수정 쿼리
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
@Modifying
은 .executeUpdate()
와 같은 역할을 하므로 빼면 에러가 발생한다.
벌크 연산을 사용하면 영속성 컨텍스트를 무시하고 바로 DB에 반영한다. 따라서 DB의 값은 변경되지만, 영속성 컨텍스트는 이를 모른다.
다음과 같은 테스트 코드를 실행시켜보자.
@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", 21));
memberRepository.save(new Member("member5", 40));
// when
int resultCount = memberRepository.bulkAgePlus(20);
List<Member> result = memberRepository.findByUsername("member5");
Member member5 = result.get(0);
System.out.println("member5 = " + member5);
// then
assertThat(resultCount).isEqualTo(3);
}
위의 결과로 출력되는 것은 벌크 연산 적용 전의 값이다!
member5 = Member(id=5, username=member5, age=40)
이처럼 벌크 연산은 영속성 컨텍스트를 무시하고 실행하므로, 권장하는 방안은 다음과 같다.
영속성 컨텍스트를 초기화 하는 방법에는 두 가지가 있다.
벌크 연산 후에는 영속성 컨텍스트를 초기화(em.clear()
) 한다.
save()
를 호출한 후bulkAgePlus()
를 호출하면,bulkAgePlus()
내부에서 실행되는 JPQL이**flush()
를 발생시키고, 벌크 연산 후 영속성 컨텍스트의 상태가 데이터베이스와 동기화되지 않으므로clear()
를 호출**하는 것이 적절하다.
// when
int resultCount = memberRepository.bulkAgePlus(20);
em.clear(); // 영속성 컨텍스트 초기화를 해주어야 1차 캐시에 남아있는 내용이 아닌 DB에 반영된 내용을 찾아올 수 있다.
@Modifying(clearAutomatically = true)
옵션을 사용한다.
JPQL이 나가고 난 후
clear()
를 자동으로 호출해준다.
@Modifying(clearAutomatically = true) // .executeUpdate()
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
[!warning] 같은 트랜잭션 안이면 같은 엔티티 매니저가 동작한다.
[!caution] 스프링 데이터 JPA와 JDBC 템플릿, 순수한 JDBC, MyBatis 같은 것을 섞었을 때도 영속성 컨텍스트와 DB가 동기화되지 않으므로 flush 및 clear 작업이 필요하다.
연관된 엔티티들을 SQL 한 번에 조회하는 방법이다. (→ 페치 조인)
member
→team
은 지연로딩 관계이므로,team
의 데이터를 조회할 때마다 쿼리가 실행되게 된다.
- LAZY 로딩으로 설정된 필드
team
에 대해서는 프록시 객체로 가져온다.- 그리고 해당 필드
team
의 데이터에 접근할 때, 실제로 다시 쿼리를 날려서 가져온다. (프록시 초기화)- JPA에서는 이를 페치 조인으로 해결했다.
스프링 데이터 JPA에서도 @Query
에 작성하는 JPQL에 join fetch
를 사용하면 페치 조인을 사용할 수 있다.
쿼리 한 번에 전부 다 끌고 온다!
team
엔티티까지 생성해서..!
@Query("select m from Member m left join fetch m.team")
List<Member> findMemberFetchJoin();
select
m1_0.member_id,
m1_0.age,
t1_0.team_id,
t1_0.name,
m1_0.username
from
member m1_0
left join
team t1_0
on t1_0.team_id=m1_0.team_id
스프링 데이터 JPA에서 1번과 같은 JPQL 없이 페치 조인(객체 그래프) 을 사용하려면? @EntityGraph
를 사용할 수 있다!
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
이렇게 해도 동일하게 내부적으로 페치 조인을 사용하는 것을 확인할 수 있다.
select
m1_0.member_id,
m1_0.age,
t1_0.team_id,
t1_0.name,
m1_0.username
from
member m1_0
left join
team t1_0
on t1_0.team_id=m1_0.team_id
@EntityGraph
사용 예시다음과 같이 JPQL과 함께 사용할 수도 있고, 메서드 이름을 이용한 쿼리 메서드에서도 사용할 수 있다.
// (1) 공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
// (2) JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
// (3) 메서드 이름으로 쿼리할 때에 특히 편리함
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(@Param("username") String username);
@EntityGraph
정리LEFT OUTER JOIN
사용@NamedEntityGraph
사용 방법다음과 같이 이름을 부여해서 사용할 수도 있다.
- 엔티티
```java
@Entity
...
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode(("team")))
public class Member {
```
리포지토리
@EntityGraph("Member.all")
List<Member> findEntityGraphByUsername(@Param("username") String username);
[!tip] 강사님 조언
- 간단할 때는
@EntityGraph(attributePaths = {})
를 사용한다.- 복잡해지면 JPQL에서 페치 조인을 사용한다.
JPA 쿼리 힌트 (SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)
JPA에서는 엔티티를 조회한 후 데이터를 수정하면 변경 감지(dirty checking) 를 통해 update 쿼리를 실행한다.
즉, JPA에서는 엔티티를 조회하면 항상 변경 감지를 위해 원본(스냅샷)을 따로 확보하고, 추후 원본과 엔티티를 비교하는 등 비용이 발생한다.
하지만 만약, 데이터를 변경할 목적이 아니고 단순히 조회만 하려 한다면 최적화가 가능하다!
바로, @QueryHints
를 사용하는 것이다! (이는 JPA 표준에서는 제공하지 않지만 하이버네이트에서 제공한다.)
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
이를 사용하면, 아예 변경 감지 체크를 하지 않는다. 따라서 조회한 엔티티의 값을 수정해도 update 쿼리가 나가지 않는다.
Member findMember = memberRepository.findReadOnlyByUsername("member1");
findMember.setUsername("member2");
em.flush(); // 변경 감지(dirty checking) X -> update query X
Page
에 사용하는 예시는 다음과 같다. 이때, forCounting
은 추가로 호출되는 페이징을 위한 count 쿼리도 쿼리 힌트를 적용할지 지정하는 값이다. (default: true)
@QueryHints(value = {@QueryHint(name = "org.hibernate.readOnly",
value = "true")},
forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);
[!note] 일일이 공수를 들여서 넣을 필요는 없다. 정말 트래픽이 많은 api 몇 개만 넣는 것이다! 성능 테스트를 해보고 얻는 이점이 있는지를 따져봐야한다.
또한, 조회 성능이 부족하면 캐시/레디스를 도입해야 한다.
처음부터 이러한 튜닝을 전부 도입하는 것은 항상 좋지는 않다!
[!note] 트랜잭션, 락 → 낙관적 락/비관적 락….
DB에서 select 시 lock을 거는 것 (select for update)
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
select
m1_0.member_id,
m1_0.age,
m1_0.team_id,
m1_0.username
from
member m1_0
where
m1_0.username=? for update
끝에 update
가 나가게 된다! (?)
[!tip] 실시간 트래픽이 많은 서비스에서는 가급적이면 lock을 걸면 안 된다! 걸려면 비관적 락 말고 낙관적 락으로 풀거나, 혹은 락 없이 다른 방법을 찾아본다.
하지만 실시간 트래픽이 그렇게 많지 않고, 그것보다는 정합성이 더 중요한 경우는 비관적 락, select for update 등을 사용하는 것을 권장한다.
스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많다!
다음과 같은 다양한 이유로 인터페이스의 메서드를 직접 구현하여 확장하고 싶다면?
EntityManager
)실무에서 굉장히 많이 사용하는 방법이다!
사용자 정의 인터페이스 (이름 상관 X)
public interface MemberRepositoryCustom {
List<Member> findMemberCustom();
}
사용자 정의 인터페이스 구현 클래스
이때, 구현 클래스의 이름은 둘 중 하나로 설정해야 한다.
- 리포지토리 인터페이스 이름 +
Impl
- 사용자 정의 인터페이스 이름 +
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();
}
}
사용자 정의 인터페이스 상속 (스프링 데이터 JPA의 JpaRepository
상속받는 곳)
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
테스트 코드
@Test
public void callCustom() {
List<Member> result = memberRepository.findMemberCustom();
}
[!tip] 실무에서는 간단한 것은 스프링 데이터 JPA가 제공하는 기본 기능으로 쓰고, 복잡한 쿼리는 QueryDSL이나 SpringJdbcTemplate을 사용하는데, 이때 을 이렇게 확장하여 사용한다!
이때, 간단한 것이란 (1) 메서드 이름으로 해결이 되거나, (2)
@Query
로 해결이 되는 경우이다.
[!caution] 항상 사용자 정의 리포지토리가 필요한 것은 아니다. 그냥 임의의 리포지토리를 클래스로 따로 만들어도 된다.
핵심 비즈니스 로직이 있는 리포지토리와 화면에 맞춘 DTO들에 대한 리포지토리(ex.
MemberQueryRepository
)는 분리하는 편을 권장한다!그리고 필요한 곳에서 해당 리포지토리를 주입 받아서 사용하면 된다.
@Repository @RequiredArgsConstructor public class MemberQueryRepository { private final EntityManager em; List<Member> findAllMembers() { return em.createQuery("select m from Member m") .getResultList(); } }
이렇게 사용자 정의 리포지토리를 구현하게 되면, 이는 결국 멤버 리포지토리가 이 기능을 가지고 있는 것이다. 다음과 같은 이유로 인해 분리하는 것을 권장한다.
- 화면에 맞춘 복잡한 쿼리들은 그거 자체만으로도 이해하기 굉장히 힘들다.
- 수정의 라이프 사이클이 핵심 비즈니스 로직과 화면용 쿼리가 다르다.
사용자 정의 리포지토리에 모든 것을 다 몰아 넣는 것은 XXX
[!note] 아키텍처적인 고민 (어플리케이션이 커질수록)
- 커맨드와 쿼리 분리
- 핵심 비즈니스 로직과 복잡한 화면용 쿼리 분리
- 라이프 사이클
엔티티를 생성, 변경할 때 변경한 사람과 시간을 추적하고 싶다면?
[!tip] 강사님은 ✅ 한 요소들은 모든 테이블에 다 적용한다고 한다.
@MappedSuperclass
& 상속을 활용한다.
JPA 주요 이벤트 어노테이션
@PrePersist
,@PostPersist
@PreUpdate
,@PostUpdate
@MappedSuperclass
@Getter
public class JpaBaseEntity {
@Column(updatable = false) // 실수로 수정하더라도 update X
private LocalDateTime createdDate;
private LocalDateTime updatedDate;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
createdDate = now; // 쿼리할 때 null이 있으면 굉장히 귀찮아진다!
updatedDate = now;
}
@PreUpdate
public void preUpdate() {
updatedDate = LocalDateTime.now();
}
}
public class Member extends JpaBaseEntity { }
@Test
public void JpaEventBaseEntity() throws InterruptedException {
// given
Member member = new Member("member1");
memberRepository.save(member); // @PrePersist
Thread.sleep(100); // 원래 테스트에서는 sleep 거는 것은 좋지 않다!
member.setUsername("member2");
em.flush(); // @PreUpdate
em.clear();
// when
Member findMember = memberRepository.findById(member.getId()).get();
// then
System.out.println("findMember.getCreatedDate() = " + findMember.getCreatedDate());
System.out.println("findMember.getUpdatedDate() = " + findMember.getUpdatedDate());
}
설정
@EnableJpaAuditing
: @SpringBootApplication
에 적용해야 한다.
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {
@EntityListeners(AuditingEntityListener.class)
: 엔티티에 적용해야 한다.
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String lastModifiedBy;
}
사용 어노테이션
@CreatedDate
@LastModifiedDate
@CreatedBy
@LastModifiedBy
등록자 & 수정자의 경우, 처리해주기 위한 AuditorAware
스프링 빈을 등록해야 한다.
실무에서는 세션 정보나, 스프링 시큐리티 로그인 정보에서 ID를 받아야 한다.
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
@Bean
public AuditorAware<String> auditorProvider() {
/*
return new AuditorAware<String>() {
@Override
public Optional<String> getCurrentAuditor() {
return Optional.of(UUID.randomUUID().toString());
}
};
*/
return () -> Optional.of(UUID.randomUUID().toString());
}
}
테스트 코드
@Test
public void JpaEventBaseEntity() throws InterruptedException {
// given
Member member = new Member("member1");
memberRepository.save(member); // @PrePersist
Thread.sleep(100); // 원래 테스트에서는 sleep 거는 것은 좋지 않다!
member.setUsername("member2");
em.flush(); // @PreUpdate
em.clear();
// when
Member findMember = memberRepository.findById(member.getId()).get();
// then
System.out.println("findMember.getCreatedDate() = " + findMember.getCreatedDate());
System.out.println("findMember.getUpdatedDate() = " + findMember.getLastModifiedDate());
System.out.println("findMember.getCreatedBy() = " + findMember.getCreatedBy());
System.out.println("findMember.getLastModifiedBy() = " + findMember.getLastModifiedBy());
}
}
findMember.getCreatedDate() = 2024-01-25T11:24:06.476327
findMember.getUpdatedDate() = 2024-01-25T11:24:06.590381
findMember.getCreatedBy() = 7c221430-c4d9-43ac-9bdb-594d983b1a09
findMember.getLastModifiedBy() = 15182c43-ad5d-4be3-be20-adfebcc7c791
[!tip] 실무에서는 대부분의 엔티티는 등록시간, 수정시간이 필요하지만, 등록자, 수정자는 필요 없을 수도 있다. 이런 경우에는 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속하도록 하자.
public class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
}
public class BaseEntity extends BaseTimeEntity {
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String lastModifiedBy;
}
[!note] 현재는 저장 시점에 수정 관련 데이터도 저장된다. 이는 중복 저장 같아 보이지만, 이렇게 해두면 변경 컬럼만 확인해도 마지막 업데이트 유저를 찾을 수 있으므로 유지보수 관점에서 편리하다. 또한,
null
처리를 하지 않아도 된다.권장하는 방법은 아니지만, 저장 시점에 저장 데이터만 입력하고 싶다면
@EnableJpaAuditing(modifyOnCreate = false)
옵션을 사용한다.
전체에 적용하고 싶다면
META-INF/orm.xml
을 작성하면 된다.
HTTP 파라미터로 넘어온 엔티티의 id로 엔티티 객체를 찾아서 바인딩한다.
- 도메인 클래스 컨버터 사용 전
```java
@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();
}
HTTP 요청은 회원 id
를 받지만, 도메인 클래스 컨버터가 동작하여 회원 엔티티 객체를 반환한다.
도메인 클래스 컨버터도 리포지토리를 사용해서 엔티티를 찾는다.
이런 경우에는, 해당 엔티티는 단순 조회용으로만 사용해야 한다! (트랜잭션이 없는 범위이므로)
사용을 권장하지는 않는다.
스프링 데이터 JPA가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.
- 컨트롤러
- 파라미터로
Pageable
을 받을 수 있다.Pageable
은 인터페이스, 실제로는PageRequest
객체를 생성한다.
```java
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
// 받아온 엔티티를 절대로 그냥 반환하면 안 된다!
// DTO로 변환해야 한다.
Page<MemberDto> map = page.map(MemberDto::new);
return map;
}
```
요청 파라미터
/members?page=0&size=3&sort=id,desc&sort=username,desc
정렬 속성,정렬 속성...(ASC|DESC)
의 형태로 작성한다.설정 방법
글로벌 설정 (application.yml
)
spring.data.web.pageable.default-page-size=20 /# 기본 페이지 사이즈/
spring.data.web.pageable.max-page-size=2000 /# 최대 페이지 사이즈/
개별 설정
@GetMapping("/members")
public Page<MemberDto> list(
@PageableDefault(size = 3, sort = "username", direction = Direction.DESC) Pageable pageable
) {
Page<Member> page = memberRepository.findAll(pageable);
// 받아온 엔티티를 절대로 그냥 반환하면 안 된다!
// DTO로 변환해야 한다.
Page<MemberDto> map = page.map(MemberDto::new);
return map;
}
접두사
@Qualifier
로 접두사를 추가할 수 있다.Page 내용을 DTO로 변환하기
Page
는 map()
을 지원해서 내부 데이터를 다른 것으로 변경할 수 있다.
MemberDto
의 생성자
DTO는 엔티티를 봐도 된다.
따라서 MemberDto 생성자가 Member 엔티티를 바로 받아도 된다.
// DTO는 엔티티를 봐도 된다.
public MemberDto(Member member) {
this.id = member.getId();
this.username = member.getUsername();
}
컨트롤러
@GetMapping("/members")
public Page<MemberDto> list(
@PageableDefault(size = 3, sort = "username", direction = Direction.DESC) Pageable pageable
) {
Page<Member> page = memberRepository.findAll(pageable);
// 받아온 엔티티를 절대로 그냥 반환하면 안 된다!
// DTO로 변환해야 한다.
Page<MemberDto> map = page.map(MemberDto::new);
return map;
}
최적화 하면 다음과 같다.
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
return memberRepository.findAll(pageable).map(MemberDto::new);
}
Page를 1부터 시작하려면?
Pageable
, Page
를 파라미터와 응답 값으로 사용하지 않고, 직접 클래스를 만들어서 처리한다. 직접 PageRequest
를 생성해서 리포지토리에 넘긴다. 응답값도 직접 만들어서 제공해야 한다.spring.data.web.pageable.one-indexed-parameters
를 true
로 설정한다. 하지만 이는 page
파라미터는 -1
처리할 뿐이다. 따라서 응답 값인 Page
에 모두 0 페이지 인덱스를 사용하는 데에는 한계가 있다. (권장 X)
content
부분을 제외하고는 0 인덱스로 유지된다.
스프링 데이터 JPA가 제공하는 공통 인터페이스의 구현체는
SimpleJpaRepository
@Repository
JPA 예외를 스프링이 추상화한 예외로 변환한다.
@Transactional
트랜잭션 적용**@Transactional
이 적용되어 있어 변경 메서드를 트랜잭션 처리 한다.
따라서 스프링 데이터 JPA를 사용할 때 트랜잭션 없이도 데이터 등록/변경이 가능했다.
사실은 리포지토리 계층에 트랜잭션이 걸려있는 것이다!
@Transactional(readOnly = true)
인데, 이를 통해 데이터를 단순 조회만 하는 트랜잭션에서 플러시를 생략하여 약간의 성능 향상을 얻을 수 있다.save()
메서드**@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}
}
새로운 엔티티면 저장(persist
), 새로운 엔티티가 아니면 병합(merge
) 한다.
이때, merge
의 단점은 “DB select를 한 번 호출하여 값을 확인하고, 없으면 새로운 엔티티로 인지하여 persist
한다” 는 것이다. 그리고 데이터 전체를 교체하게 된다. (→ 비효율적)
업데이트는 가급적이면 merge
로 하면 안되고, 변경 감지를 사용해야 한다.
merge
는 영속 상태가 아니게 된(detached) 객체를 영속상태로 변경할 때 사용해야 한다.
merge
는 사용할 일이 거의 없다!
객체의 @GeneratedValue
id 값은 JPA에서 persist
를 하면 생기게 된다.
따라서 새로운 Item
객체를 생성하여 save()
하는 코드를 디버깅하면 다음과 같이 확인할 수 있다.
entityManager.persist(entity);
코드 이후에는 id
값이 생성됨을 확인할 수 있다.
JPA 식별자 생성 전략이 @GeneratedValue
이면,
식별자가 객체일 때: null
로 판단
ex.
Long
타입
식별자가 자바 기본 타입일 때: 0
으로 판단
ex.
long
타입
JPA 식별자 생성 전략이 @Id
만 사용해서 직접 할당이면, (= 이미 식별자 값 존재)
Persistable
인터페이스를 구현해서 판단 로직 변경 가능
ex. id가
@GeneratedValue
가 아니어서 새로 생성한 엔티티에도 이미 id 값이 들어가있는 상황이라면? 원래는merge()
가 실행될 것이다.
다음과 같이 엔티티를 수정하고, getId()
와 isNew()
를 오버라이딩한다.
💡
createdDate
는 웬만해서는 필요하기 때문에,isNew()
에서 주로createdDate
가null
인지 여부로 새로운 객체인지를 판단하면 편리하다!
@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;
}
}
다음과 같은 테스트 코드를 돌리게 되면, save()
메서드 내에서 isNew()
반환 결과에 따라, 이미 id 값이 존재하더라도 persist()
가 수행될 수 있다.
@Test
public void save() {
Item item = new Item("A");
itemRepository.save(item);
}
merge()
는 우선 DB를 호출해서 값을 확인하고, DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율적이기 때문에 이런 방법을 사용한다!
…
…
select 절에 찍어서 가져올 필드라고 생각하면 된다.
ex) 전체 엔티티가 아니라 만약 회원 이름만 조회하고 싶다면?
- 엔티티 대신에 DTO를 편리하게 조회할 때 사용한다.
- 메서드 이름을 이용한 쿼리 메소드와도 함께 사용할 수 있다.
조회할 엔티티의 필드를 프로퍼티 형식(getter 형식) 으로 지정하면 해당 필드만 선택해서 조회된다.
public interface UsernameOnly {
String getUsername();
}
메서드 이름은 자유이며, 반환 타입으로 인지한다.
List<UsernameOnly> findProjectionsByUsername(@Param("username") String username);
출력된 SQL문을 살펴보면 select
절에서 username
만 조회(projection)하는 것을 확인할 수 있다. (→ 쿼리 최적화 O)
select m.username from member m
where m.username=‘m1’;
SpEL 문법도 지원한다.
public interface UsernameOnly {
@Value("#{target.username + ' ' + target.age}") -> open projections
String getUsername();
}
단, select
절에서 엔티티 필드를 모두 조회해온 후에 연산하므로, 쿼리 최적화는 X
인터페이스가 아닌 구체적인 DTO 형식도 가능하다.
생성자의 파라미터 이름으로 매칭된다.
public class UsernameOnlyDto {
private final String username;
// 파라미터 명이 중요하다!
public UsernameOnlyDto(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
디버깅 해보면, 인터페이스 기반 프로젝션과는 다르게 프록시 X 구체 클래스 O
다음과 같이 generic type을 주게 되면, 동적으로 프로젝션 데이터를 변경할 수 있다.
<T> List<T> findProjectionsByUsername(@Param("username") String username, Class<T> type);
다음과 같이 사용한다.
List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1", UsernameOnly.class);
public interface NestedClosedProjections {
String getUsername();
TeamInfo getTeam();
interface TeamInfo {
String getName();
}
}
select
절 최적화 Oleft outer join
처리select
해서 엔티티 전체를 조회한 다음 계산 (→ 최적화 X)select
최적화가 안 된다.JPA를 사용한다면 가급적 사용하지 않는 것을 권장한다.
ex) JDBC template, MyBatis 등으로 SQL을 직접 작성하는 것
[!tip] 네이티브 SQL을 DTO로 조회할 때는 커스텀 리포지토리나 별도의 리포지토리를 만들어서 JdbcTemplate이나 MyBatis를 엮어서 사용하는 것을 권장한다.
사실 여기까지 안 가고 99%의 경우는 모두 JPQL, QueryDSL 정도에서 해결한다.
Object[]
(불편해서 안씀)Tuple
(불편해서 안씀)정적 쿼리를 네이티브로 짜야할 때는, 프로젝션을 통해 해결할 수 있다. (이름을 딱 매칭해주면 된다!)
DTO로 가져올 수 있고, 페이징도 가능하다. 하지만 동적 쿼리는 불가능하다.
public interface MemberProjection {
Long getId();
String getUsername();
String getTeamName();
}
@Query(value = "select m.member_id as id, m.username, t.name as teamName" +
" from member m left join team t on m.team_id = t.team_id",
countQuery = "select count(*) from member", // 네이티브 쿼리이므로 countQuery 필요
nativeQuery = true)
Page<MemberProjection> findByNativeProjection(Pageable pageable); // paging 가능
[!note] 강사님은 이런 경우에는 스프링 JDBC Template을 사용하신다고 한다. 하지만 프로젝션 기능도 편리할 것으로 보인다.
Contents
H2 URL
tcp://localhost/
제외하고 입력 (파일 모드)tcp://localhost/
넣어서 입력 (네트워크 모드) & Generic H2 (Server)Main Repository Code
https://github.com/seungriyou/spring-study/blob/main/06-spring_data_jpa/data-jpa/src/main/java/study/datajpa/repository/MemberRepository.java