김영한 님의 실전! 스프링 데이터 JPA 강의를 보고 작성한 내용입니다.
Spring Data JPA 는 인터페이스만 정의하면 구현체는 스프링이 자동으로 생성해줍니다. 인터페이스를 직접 구현하면 개발자가 구현해야 하는 기능이 너무 많습니다.
인터페이스 메서드를 직접 구현하기 위해 JPA 를 직접 사용하거나 MyBatis 를 사용할 수 있도록 사용자 정의 리포지토리라는 기능을 제공합니다.
이 기능은 인터페이스만으로 해결되지 않을 때, 예를 들어 NamedQuery 나 @Query
로 해결할 수 있는 경우가 아니고 복잡한 동적 쿼리를 작성해야 할 때 사용합니다. 대표적으로 QueryDSL 을 사용할 때 사용한다고 합니다.
public interface MemberRepositoryCustom {
List<Member> findMemberCustom();
}
먼저 인터페이스를 생성하고, 사용할 메서드를 정의합니다.
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m", Member.class).getResultList();
}
}
1번에서 구현한 인터페이스를 상속 받아, JPA 를 사용하여 실행할 메서드를 구현합니다.
여기서 중요한 점은 1번의 인터페이스 이름은 아무거나 지정해도 되지만, 구현체의 이름은 JpaRepository 를 상속 받는 인터페이스명 + Impl
형태로 작성해야 합니다.
Spring Data 2.x 부터는 1번에서 구현한 인터페이스명 + Impl
( MemberRepositoryCustomImpl ) 형태도 가능합니다. 강사님은 새롭게 변경된 이 방식이 더 직관적이기 때문에 권장한다고 하십니다.
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
...
}
Spring Data JPA 인터페이스에 1번에서 만든 인터페이스를 상속 받도록 합니다.
결과적으로 MemberRepository 에서 findMemberCustom()
을 호출하면 2번에서 구현한 메서드가 실행되는데 이 기능은 자바에서 되는건 아니고 Spring Data JPA 가 이렇게 동작하도록 엮어주는 것입니다.
MemberRepositoryCustom 을 인터페이스가 아닌 클래스로 만들고 스프링 빈으로 등록해서 그냥 직접 사용해도 된다고 합니다. 물론 이 경우 스프링 데이터 JPA와는 아무런 관계 없이 별도로 동작한다고 합니다.
엔티티를 생성, 변경할 때 등록일, 등록시간, 수정일, 수정시간과 같이 변경한 사람와 시간을 추적하고 싶을 때 사용합니다.
@MappedSuperclass
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();
}
}
생성된 시간에는 updatable = false
라는 옵션을 주었습니다. 이렇게 하면 실수로 생성시간을 변경해도 값이 업데이트 되지 않습니다.
@PrePersist
는 persist 하기 전에 이벤트가 발생하는 것이고, @PreUpdate
는 업데이트 하기 전에 이벤트가 발생하는 것입니다.
JPA 에서 진짜 상속 관계가 있고, 속성만 받는 상속 관계가 존재하는데 지금 같은 경우에는 속성만 받는 상속관계이며, 이런 경우에는 @MappedSuperclass
를 사용합니다.
해당 속성들을 사용할 엔티티에서 extends JpaBaseEntity
를 작성하면 테이블이 실행될 때 createdDate, updateDate 컬럼이 함께 생성됩니다.
create table member (
age integer not null,
member_id bigint not null,
team_id bigint,
username varchar(255),
primary key (member_id)
)
만약 JpaBaseEntity 에 @MappedSuperclass
어노테이션이 없다면 위처럼 등록날짜, 수정날짜가 빠진 채로 테이블이 생성됩니다.
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
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;
}
이벤트를 기반으로 동작한다는 것을 넣어주어야 하기 때문에 @EntityListeners(AuditingEntityListener.class)
를 추가합니다.
해당 어노테이션을 추가하지 않고 동작하도록 할 수 있는데 이것은 강의 자료를 참고하시길 바랍니다.
순수 JPA 에서 추가된 점은 등록자, 수정자를 넣은 것인데 @CreatedBy
, @LastModifiedBy
를 사용하며, 그냥 두면 값이 들어가지 않고 이에 대한 처리가 필요합니다.
@EnableJpaAuditing
@SpringBootApplication
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());
}
}
Spring Data JPA 에서 등록시, 수정시를 넣으려면 xxxApplication
에 @EnableJpaAuditing
어노테이션을 넣어야 합니다.
해당 어노테이션을 붙이면 createdDate, lastModifiedDate 에 값이 채워지게 됩니다. 만약 등록할 때 수정시간을 넣고 싶지 않다면 modifyOnCreate = false
옵션을 넣으면 됩니다.
또 등록자, 수정자에 이름을 넣으려면 AuditorAware
를 스프링 빈으로 등록하고, createdBy, lastModifiedBy 에 채워넣을 값을 반환하면 됩니다.
위처럼 세팅해놓으면 데이터가 등록되거나 수정될 때마다 위에서 생성한 빈을 호출하고 반환하는 값을 꺼내서 createdBy, lastModifiedBy 에 값이 채워지게 됩니다.
예시에서는 간단하게 UUID 를 사용했는데, 저렇게 구현하지 않고 SecurityContextHolder 에서 인증 객체를 가져와 사용해야 한다고 하셨습니다.
public class MemberController {
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Long memberId) {
Member member = memberRepository.findById(memberId).get();
return member.getUsername();
}
}
기존에는 id 값을 받아서 memberRepository 를 통해 조회하는 방식을 거쳐 Member 엔티티를 가져왔습니다. 하지만 이 과정을 도메인 클래스 컨버터를 통해 간편하게 줄일 수 있습니다.
public class MemberController {
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Member member) {
return member.getUsername();
}
}
HTTP 요청은 회원 id 를 받지만 도메인 클래스 컨버터가 중간에 리파지토리를 사용해서 엔티티를 찾고, 회원 엔티티 객체를 반환합니다.
만약 id 값이 존재하지 않는다면 MissingPathVariableException
예외가 발생하게 됩니다.
도메인 클래스 컨버터로 엔티티를 파라미터로 받으면, 이 엔티티는 단순 조회용으로만 사용해야 합니다. 트랜잭션이 없는 범위에서 엔티티를 조회했으므로, 엔티티를 변경해도 DB에 반영되지 않습니다.
public class MemberController {
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
return memberRepository.findAll(pageable);
}
}
findAll()
에 pageable 을 넘기게 되면 PagingAndSortingRepository 의 findAll()
메서드가 실행되며, 이 메서드는 pageable 을 파라미터로 받습니다.
findAll(pageable)
은 파라미터로 받은 pageable
객체가 제공한 페이징 제한을 충족하는 엔티티들의 page
를 반환합니다.
Pageable 을 파라미터로 설정하면 쿼리 파라미터로 page, size, sort 와 같은 key 값들을 받을 수 있습니다.
ex> /members?page=0&size=3&sort=id,desc&sort=username,desc
쿼리 파라미터들이 Controller 에서 바인딩될 때 pageable 이 있으면 PageRequest 라는 객체를 생성해서 값을 채워넣고 인젝션해줍니다. Pageable 은 인터페이스이고, PageRequest 는 구현체입니다
만약 쿼리 파라미터로 page 나 size 와 같은 것들을 지정하지 않았다면 기본 디폴트값들이 들어가게 되는데 이 디폴드 값들을 변경할 수 있습니다.
# 글로벌 설정
spring:
data:
web:
pageable:
default-page-size: 10 # 기본 페이지 사이즈
max-page-size: 1000 # 최대 페이지 사이즈
// 개별 설정
public class MemberController {
@GetMapping("/members")
public Page<Member> list(@PageableDefault(size = 12, sort = "username"
direction = Sort.Direction.DESC) Pageable pageable) {
return memberRepository.findAll(pageable);
}
}
@PageableDefault
어노테이션을 사용해서 개별적으로 설정할 수 있습니다.
public class MemberController {
@GetMapping("/members")
public Page<Member> list(@Qualifier("member") Pageable memberPageable,
@Qualifier("order") Pageable orderPageable) {
return memberRepository.findAll(pageable);
}
}
페이징 정보가 둘 이상인 경우 @Qualifier
에 접두사를 추가하여 접두사_xxx
를 통해 구분할 수 있습니다.
ex> /members?member_page=0&order_page=1
JpaRepository 를 보면 QueryByExampleExecutor
를 상속 받는 것을 알 수 있는데 이 인터페이스가 Example 을 파라미터로 받아 쿼리를 수행할 수 있도록 해줍니다.
Query By Example 을 사용하기 위해서 아래 3가지를 알아야 합니다.
Probe : 필드에 데이터가 있는 실제 도메인 객체
ExampleMatcher : 특정 필드를 일치시키는 상세한 정보 제공, 재사용 가능
Example : Probe와 ExampleMatcher로 구성, 쿼리를 생성하는데 사용
@Test
void test() {
Member member = new Member("m1");
Example<Member> example = Example.of(member);
List<Member> result = memberRepository.findAll(example);
assertThat(result.get(0).getUsername()).isEqualTo("m1");
}
Query By Example 은 도메인 자체가 검색 조건이 됩니다. 도메인으로 Example 객체를 생성한 후 이를 repository 에 넘겨주면 도메인 객체를 가지고 검색 조건을 만들게 됩니다.
select ...
from member m1_0
where m1_0.username='m1' and m1_0.age=0;
근데 위에서 분명 username 만 사용했는데 where 조건을 보면 age = 0
이라는 조건이 들어있는 것을 볼 수 있습니다.
생성한 member 의 PK 는 null 이기 때문에 무시되는데, age 는 기본 타입이기 때문에 0 이 들어갔기 때문에 무시하지 않은 것입니다.
ExampleMatcher
를 사용하여 age 조건을 사용하지 않도록 할 수 있습니다.
@Test
void test() {
Member member = new Member("m1");
ExampleMatcher matcher = ExampleMatcher.matching().withIgnorePaths("age");
Example<Member> example = Example.of(member, matcher);
List<Member> result = memberRepository.findAll(example);
assertThat(result.get(0).getUsername()).isEqualTo("m1");
}
select ...
from member m1_0
where m1_0.username='m1';
위의 코드는 age 라는 속성을 무시한다는 의미이고, Example 객체를 생성할 때 도메인 객체와 함께 넘겨줍니다. 이때 수행되는 쿼리를 보면 age 가 조건에 없는 것을 확인할 수 있습니다.
@Test
void test() {
// 조인 사용을 위해 연관관계 설정
Member member = new Member("m1");
Team team = new Team("teamA");
member.setTeam(team);
ExampleMatcher matcher = ExampleMatcher.matching().withIgnorePaths("age");
Example<Member> example = Example.of(member, matcher);
List<Member> result = memberRepository.findAll(example);
assertThat(result.get(0).getUsername()).isEqualTo("m1");
}
select ...
from member m1_0
join team t1_0 on t1_0.team_id=m1_0.team_id
where t1_0.name='teamA' and m1_0.username='m1';
위처럼 연관관계를 설정하고 쿼리를 수행하면 쿼리를 수행할 때 member 와 team 의 조인이 이루어지고, team 의 이름이 검색조건으로 함께 들어가게 됩니다.
단, 위와 같은 내부 조인( INNER JOIN) 만 가능하고 외부 조인( LEFT JOIN ) 은 불가능합니다. 또 AND 나 OR 를 사용한 중접 제약조건이 불가하며 문자를 제외하면 =
비교밖에 안됩니다.
Spring Data JPA Projections( 프로젝션 )은 JPA 엔터티의 일부 속성만을 선택적으로 조회하고, 이를 DTO 나 인터페이스 등의 특정 타입으로 매핑하는 기능을 말합니다. Projections 을 사용하면 필요한 데이터만을 가져와서 성능을 최적화하거나 데이터 전송 양을 최소화할 수 있습니다.
Spring Data JPA Projections은 인터페이스 기반의 프로젝션과 클래스 기반의 프로젝션 두 가지 유형이 있습니다.
인터페이스를 정의하고, 해당 인터페이스의 메소드들을 이용하여 필요한 속성을 지정합니다. 인터페이스의 메소드 이름은 엔티티의 필드나 그에 상응하는 getter 메소드와 일치해야 합니다.
특정 클래스를 정의하고, 해당 클래스의 생성자를 통해 필요한 속성을 전달합니다. 이 경우, 생성자 매개변수의 이름은 엔티티의 필드와 일치해야 합니다.
public interface UserNameOnly {
String getUsername(); // getter 형식
}
조회할 엔티티의 필드를 getter 형식으로 지정하면 해당 필드만 선택해서 조회합니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
List<UserNameOnly> findProjectionsByUsername(@Param("username") String username);
}
반환타입에 1번에서 생성한 인터페이스를 지정합니다. 이렇게 하면 UserNameOnly 인터페이스에 프록시 객체가 담겨서 반환됩니다.
@Test
void projections() {
List<UserNameOnly> result = memberRepository.findProjectionsByUsername("m1");
for (UserNameOnly userNameOnly : result) {
System.out.println("userNameOnly = " + userNameOnly);
}
}
select
m1_0.username
from
member m1_0
where
m1_0.username=?
위의 코드를 실행했을 때 동작하는 쿼리를 보면 위와 같습니다. select 절을 보면 딱 username 만 가져온 것을 볼 수 있습니다. 또 출력된 결과를 보면 아래와 같습니다.
userNameOnly = org.springframework.data.jpa.repository.query.AbstractJpaQuery$TupleConverter$TupleBackedMap@4936dca9
인터페이스만 구현하면 구현 클래스는 Spring Data JPA 가 프록시 기술을 가지고 가짜 객체를 만들고, gerUsername() 을 확인한 후 구현체에 데이터를 담아서 반환해줍니다.
public interface UserNameOnly {
@Value("#{target.username + ' ' + target.age + ' ' + target.team.name}")
String getUsername(); // getter 형식
}
인터페이스에 정의할 때 @Value
에 스프링의 SpEL 문법을 사용해서 지정할 수 있습니다. 이렇게 하면 지정한 형식에 맞게 반환됩니다.
이렇게 되면 이전처럼 username 만 select 를 하는 것이 아니라 DB에서 엔티티 필드를 다 조회해온 다음에 위의 형식에 맞게 데이터를 집어넣고 반환하게 됩니다. 따라서 JPQL select 절 최적화가 이루어지지 않습니다.
public class UsernameOnlyDTO {
private final String username;
public UsernameOnlyDTO(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
인터페이스 말고 클래스 기반으로도 프로젝션이 가능한데 생성자의 파라미터 이름으로 매칭해서 해당 하는 컬럼만을 프로젝션합니다.
인터페이스와는 다르게 구체적인 클래스를 명시하기 때문에 프록시가 아닌 구체적인 클래스의 객체의 생성자에 값을 넣어서 반환합니다.
userNameOnly = study.datajpa.example.UsernameOnlyDTO@22f3b213
public interface MemberRepository extends JpaRepository<Member, Long> {
<T> List<T> findProjectionsByUsername(String username, Class<T> type);
}
제네릭 타입을 주게 되면 조금 더 동적으로 데이터를 가져올 수 있습니다. 예를 들어, 어떤 경우에는 유저의 이름을 가져온다던지, 어떤 경우에는 나이를 가져온다던지 할 때 사용하면 편리합니다.
List<UsernameOnlyDTO> result =
memberRepository.findProjectionsByUsername("m1", UsernameOnlyDTO.class);
public interface NestedClosedProjections {
String getUsername();
TeamInfo getTeam();
interface TeamInfo {
String getName();
}
}
List<NestedClosedProjections> result =
memberRepository.findProjectionsByUsername("m1", NestedClosedProjections.class);
select
m1_0.username,
t1_0.team_id,
t1_0.name
from
member m1_0
left join team t1_0
on t1_0.team_id=m1_0.team_id
where
m1_0.username=?
쿼리 로그를 보면 member 는 username 만 가져왔는데 team 은 모든 컬럼들을 조회한 것을 볼 수 있습니다.
프로젝션 대상이 root 엔티티면, JPQL SELECT 절 최적화 가능하지만 root 가 아니면 left outer join 을 처리하고, 모든 필드를 select 해서 엔티티로 조회한 다음에 계산하게 됩니다.
즉, 하나의 엔티티를 넘어가는 순간( 프로젝션 대상이 root 엔티티를 넘어가면 ) JPQL select 최적화가 불가능합니다.
네이티브 쿼리는 JPA 가 제공하는 기능입니다. 하지만 가급적 네이티브 쿼리를 사용하지 않는게 좋다고 합니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query(value = "select * from member where username = ?", nativeQuery = true)
Member findByNativeQuery(String username);
}
네이티브 쿼리는 반환형이 굉장히 애매합니다. 예를 들어 username 을 가져온다고 했을 때 이는 Member 타입이 아닙니다.
스프링 데이터 JPA 기반 네이티브 쿼리에서 제공하는 반환형은 Object[]
, Tuple
, DTO( Projections 포함 )
세 가지 입니다.
또 Sort 파라미터를 통한 정렬이 정상 동작하지 않을 수 있으며, 동적 쿼리도 불가하고, @Query
에 작성함에도 불구하고 애플리케이션 로딩 시점에 문법 확인이 불가능합니다.
@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",
nativeQuery = true)
Page<MemberProjection> findByNativeProjection(Pageable pageable);
6번의 Projections 를 사용할 수 있으며, 페이징 처리도 가능합니다. 페이징 처리 시에는 네이티브 쿼리이기 때문에 countQuery 를 직접 작성해주어야 합니다.
과거에는 Object[] 배열로 받았어야 했는데 Projections 기능이 등장하며 반환형으로 인한 문제가 해결되었습니다.
String sql = "select m.username as username from member m";
List<MemberDto> result = em.createNativeQuery(sql)
.setFirstResult(0)
.setMaxResults(10)
.unwrap(NativeQuery.class)
.addScalar("username")
.setResultTransformer(Transformers.aliasToBean(MemberDto.class))
.getResultList();
하이버네이트를 직접 활용해서 동적 네이티브 쿼리를 사용할 수 있습니다.