이번 섹션부터 실무에서 자주 쓰이는 기능을 학습한다.
1. 확장 기능
사용자 정의 리포지토리 구현
스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고, 구현체는 스프링이 자동으로 생성한다. 만약 인터페이스까지 직접 구현해야 하면 개발자가 할 일이 너무 많아진다. 그래서 인터페이스의 메서드를 직접 구현하고 싶다면 아래 방법들을 사용하면 된다.
- JPA 직접 사용(EntityManager)
- 스프링 JDBC Template 사용
- MyBatis 사용
- DB 커넥션 직접 사용
- Querydsl 사용
참고
실무에서는 주로 QueryDSL이나 SpringJdbcTemplate를 함께 사용할 때 사용자 정의 리포지토리 기능을 자주 사용한다.
사용자 정의 인터페이스를 구현하고, 구현 클래스를 만들어 보자.
- 먼저, 사용자 정의 인터페이스를 아래와 같이 구현해 둔다.
// 사용자 정의 인터페이스
public interface MemebrRepositoryCustom {
List<Member> findMemberCustom();
}
- 스프링 부트 2 이상부터는 사용자 정의 구현 클래스에 리포지토리 인터페이스 이름 + Impl을 적용하는 대신, 사용자 정의 인터페이스 이름 + Impl 방식도 지원한다.
- 예를 들어서 아래 코드의 MemberRepositoryImpl 대신 MemberRepositoryCustomImpl과 같이 구현해도 된다.
- 사용자 정의 인터페이스 이름과 구현 클래스 이름이 비슷하므로 더 직관적이라는 장점이 있다. 추가로 여러 인터페이스를 분리해서 구현하는 것도 가능하다.
- 두 방식을 사용하면, 스프링 데이터 JPA가 인식해서 자동으로 스프링 빈으로 등록해 준다.
- 예를 들어서 아래 코드의 MemberRepositoryImpl 대신 MemberRepositoryCustomImpl과 같이 구현해도 된다.
// 기존 방식
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m")
.getResultList();
}
}
///
// 새로운 방식
@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 {
...
}
///
@Test
public void callCustom() {
// 사용자 정의 메서드 호출
List<Member> result = memberRepository.findMemberCustom();
}
Impl 대신 다른 이름으로 변경하고 싶다면 아래 2가지 방법을 사용하면 된다. 억지로 바꾸는 걸 권장하진 않는다.
- XML 설정
<repositories base-package="study.datajpa.repository"
repository-impl-postfix="Impl" />
- JavaConfig 설정
@EnableJpaRepositories(basePackages = "study.datajpa.repository",
repositoryImplementationPostfix = "Impl")
public class JavaConfig {
}
참고
항상 사용자 정의 리포지토리가 필요한 것은 아니다. 그냥 임의의 리포지토리를 만들어도 된다. 예를 들어 MemberQueryRepository를 인터페이스가 아닌 클래스로 만들고, 스프링 빈으로 등록해서 직접 사용해도 된다. 물론 이 경우엔 스프링 데이터 JPA와는 아무런 관계없이 별도로 동작한다.
Auditing
엔티티를 생성하거나 변경할 때 변경한 사람과 시간을 추적할 수 있다. 등록일과 수정일은 운영할 때 엔티티에 필수로 남겨야 하는 정보이기 때문에 꼭 추적해야 한다.
ex. 등록일, 수정일, 등록자, 수정자
a. 순수 JPA 사용
JPA의 주요 이벤트 애노테이션은 아래와 같다.
- @PrePersist, @PostPersist
- 저장(persist) 전과 후에 각각 실행하는 메서드를 표시할 때 사용한다.
- @PreUpdate, @PostUpdate
- 수정(update) 전과 후에 각각 실행하는 메서드를 표시할 때 사용한다.
등록일과 수정일부터 적용해 본다.
- JpaBaseEntity를 만들고, 모든 엔티티가(ex. Member)가 JpaBaseEntity를 상속받도록 하면 된다.
@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 prePersist() {
updatedDate = LocalDateTime.now();
}
}
///
public class Member extends JpaBaseEntity {
}
- 확인 코드
@Test
public void jpaEventBaseEntity() throws Exception {
// given
Member member = new Member("member1");
memberRepository.save(member) // @PrePersist
Thread.sleep(100);
member.setUsername("member2");
em.flush(); // @PreUpdate
em.clear();
// when
Member findMember = memberRepository.findById(member.getId()).get();
// then
System.out.println("findMember.createdDate = " + findMember.getCreatedDate());
System.out.println("findMember.updatedDate = " + findMember.getUpdatedDate());
}
b. 스프링 데이터 JPA 사용
스프링 데이터 JPA를 사용하면 기존 방식보다 더 깔끔하게 해결할 수 있다. 먼저, 아래 두 애노테이션을 설정해야 한다.
- 스프링 부트 설정 클래스에 적용
- @EnableJpaAuditing
- 엔티티에 적용
- @EntityListeners(AuditingEntityListener.class)
그 외에 사용하는 애노테이션은 아래와 같다.
- @CreatedDate = 등록일
- @LastModifiedDate = 수정일
- @CreatedBy = 등록자
- @LastModifiedBy = 수정자
스프링 데이터 Auditing을 적용하고 코드로 확인해 보자.
- BaseEntity에 등록일과 수정일, 등록자, 수정자 적용
@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;
}
이후 AuditorAware를 스프링 빈으로 등록해야 등록자와 수정자가 정상적으로 저장된다.
- @EnableJpaAuditing도 함께 등록해야 한다.
- 실무에서는 UUID 대신 세션 정보나 Spring Security의 로그인 정보(SecurityContextHolder)에서 ID를 받아와서 등록자나 수정자에 저장해야 한다.
- 저장 시점에 등록일과 등록자는 물론이고, 수정일과 수정자도 같은 데이터가 저장된다. 데이터가 중복으로 저장되는 것 같지만, 이렇게 해두면 변경 컬럼만 확인해도 마지막으로 수정한 유저를 확인할 수 있기 때문에 유지 보수 관점에서 편리하다.
- 이렇게 하지 않으면 변경 컬럼이 null인 경우, 등록 컬럼을 찾아 확인해야 한다.
- 저장 시점에 저장 데이터만 입력하고 싶다면, @EnableJpaAuditing(modifyOnCreate = false) 옵션을 사용하면 된다.
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
// AuditorAware 빈 추가 적용
@Bean
public AuditorAware<String> auditorProvider() {
// return () -> Optional.of(UUID.randomUUID().toString()); // 아래 코드와 동일
return new AuditorAware<String>() {
@Override
public Optional<String> getCurrentAuditor() {
return Optional.of(UUID.randomUUID().toString());
}
}
}
}
참고
실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만, 등록자와 수정자는 없을 수도 있다. 그래서 아래와 같이 Base 타입을 2개로 분리하고, 원하는 타입을 선택해서 상속한다.
// 등록일, 수정일
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;
}
c. 전체 적용
@EntityListeners(AuditingEntityListener.class)를 생략하고 스프링 데이터 JPA가 제공하는 이벤트를 전체에 적용하려면, META-INF/orm.xml 경로에 다음과 같이 등록하면 된다.
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd"
version="2.2">
<persistence-unit-metadata>
<persistence-unit-defaults>
<entity-listeners>
<entity-listener
class="org.springframework.data.jpa.domain.support.AuditingEntityListener"/>
</entity-listeners>
</persistence-unit-defaults>
</persistence-unit-metadata>
</entity-mappings>
Web 확장 - 도메인 클래스 컨버터
도메인 클래스 컨버터는 HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩하는 역할을 한다.
- 도메인 클래스 컨버터를 사용하기 전엔 HTTP 요청으로 회원 id를 받고, 리포지토리에서 해당하는 회원 객체를 찾은 뒤 이름을 반환해야 했다.
@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();
}
}
- 도메인 클래스 컨버터를 사용하면 HTTP 요청은 회원 id를 받지만, 도메인 클래스 컨버터가 중간에 동작해서 회원 엔티티 객체로 반환해 준다.
- 코드로 안 보일 뿐이지 도메인 클래스 컨버터도 리포지토리를 사용해 엔티티를 찾는다.
- 트랜잭션이 없는 범위에서 엔티티를 조회했으므로, 영속성 컨텍스트가 동작하지 않아 엔티티를 변경해도 DB에 반영되지 않는다. 따라서 도메인 클래스 컨버터를 사용해 엔티티를 파라미터로 바로 받는다면, 이 엔티티는 단순 조회용으로만 사용해야 한다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Member member) {
return member.getUsername();
}
}
참고
엔티티의 식별자가 간단할 때만 사용할 수 있다. 또는 조회용으로만 사용하는 게 낫다.
Web 확장 - 페이징과 정렬
스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.
a. 페이징과 정렬
예제 코드로 확인해 보자.
- 파라미터로 Pageable을 받을 수 있다.
- Pageable은 인터페이스고, 실제로는 org.springframework.data.domain.PageRequest 객체를 생성한다.
@RestController
@RequiredArgsConstructor
public class MemberController {
...
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
return page;
}
}
- 요청 파라미터 예시도 살펴보자.
- ex. /members?page=0&size=3&sort=id,desc&sort=username,desc
- page = 현재 페이지 (0부터 시작; 기본값 0)
- size = 한 페이지에 노출할 데이터 건수 (기본값 20)
- sort = 정렬 조건을 정의 (기본값 ASC; 생략 가능)
- ex. 정렬 속성, 정렬 속성...(ASC | DESC)
- ex. /members?page=0&size=3&sort=id,desc&sort=username,desc
요청 파라미터에 기본값을 설정할 수 있다.
- 글로벌 설정
spring:
data:
web:
pageable:
default-page-size=20 # 기본 페이지 사이즈
max-page-size=2000 # 최대 페이지 사이즈
- 개별 설정 - @PageableDefault 애노테이션 사용
@RequestMapping(value = "/members_page", method = RequestMethod.GET)
public String list(@PageableDefault(size = 12, sort = "username",
direction = Sort.Direction.DESC) Pageable pageable) {
}
페이징 정보가 둘 이상일 땐 접두사로 구분해야 한다.
- @Qualifier 애노테이션에 접두사명을 추가하면 된다.
- 형식: "{접두사명}_xxx"
- ex. /members?member_page=0&order_page=1
public String list(
@Qualifier("member") Pageable memberPageable,
@Qualifier("order" Pageable orderPageable, ...)
b. Page 내용을 DTO로 변환하기
엔티티를 API로 노출하면 다양한 문제가 발생하기 때문에 엔티티를 꼭 DTO로 변환해서 반환해야 한다. Page에서 지원하는 map() 메서드를 사용하면 내부 데이터를 다른 것으로 변경할 수 있다.
- MemberDto
@Data
public class MemberDto {
private Long id;
private String username;
public MemberDto(Member m) {
this.id = m.getId();
this.username = m.getUsername();
}
}
- Page.map() 사용 및 코드 최적화
// 사용
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
Page<MemberDto> pageDto = page.map(MemberDto::new);
return pageDto;
}
///
// 코드 최적화
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
return memberRepository.findAll(pageable).map(MemberDto::new);
}
c. Page를 1부터 시작하기
스프링 데이터는 Page를 0부터 시작한다. 1부터 시작하려면 아래 2가지 방법을 사용하면 된다.
- Pageable, Page를 파라미터와 응답 값으로 사용하지 말고, 직접 클래스를 만들어서 처리한다.
- 그리고 직접 PageRequest(Pageable 구현체)를 생성해서 리포지토리에 넘긴다. 물론 응답값도 Page 대신에 직접 만들어서 제공해야 한다.
- spring.data.web.pageable.one-indexed-parameters를 true로 설정한다.
- 이 방법은 web에서 page 파라미터를 -1 처리할 뿐이다. 따라서 응답값이 Page에 모두 0 페이지 인덱스를 사용하는 한계가 있다.
{
"content" : [
...
],
"pageable" : {
"offset" : 0,
"pageSize" : 10,
"pageNumber" : 0 // 0 인덱스
},
"number" : 0, // 0 인덱스
"empty" : false
}
참고
직접 클래스를 만들거나 그냥 0부터 시작하는 기본값을 사용하자.
2. 스프링 데이터 JPA 분석
스프링 데이터 JPA 구현체 분석
스프링 데이터 JPA가 제공하는 공통 인터페이스의 구현체를 분석해 보자.
- org.springframework.data.jpa.repository.support.SimpleJpaRepository
- @Repository 적용
- 스프링 빈의 컴포넌트 스캔 대상이 된다.
- JPA 예외를 스프링이 추상화한 예외로 변환한다.
- @Transactional 트랜잭션 적용
- JPA의 모든 변경은 트랜잭션 안에서 동작한다.
- 스프링 데이터 JPA는 변경(등록, 수정, 삭제) 메서드를 트랜잭션 안에서 처리해야 한다.
- 이때, 서비스 계층에서 트랜잭션을
- 시작하지 않으면, 리포지토리에서 트랜잭션을 시작한다.
- 시작하면, 리포지토리는 해당 트랜잭션을 전파받아서 사용한다.
- 트랜잭션이 리포지토리 계층에 걸려있기 때문에 스프링 데이터 JPA를 사용할 때, 트랜잭션이 없어도 데이터 등록 및 변경이 가능했던 것이다.
- 이때, 서비스 계층에서 트랜잭션을
- @Transactional(readOnly = true)
- 데이터를 단순히 조회만 하고 변경하지 않는 트랜잭션에서 readOnly = true 옵션을 사용하면, em.flush()를 생략해서 조회 성능을 약간 향상할 수 있다.
- save() 메서드
- 새로운 엔티티면 저장(persist)하고, 아니면 병합(merge)한다.
- merge는 데이터를 전부 변경할 때 사용해야 한다. 안 그러면 입력하지 않은 값엔 null이 들어갈 수 있다. 엔티티 값을 수정할 땐 변경 감지(Dirty Checking) 기능을 사용하자.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID>
implements JpaRepositoryImplementation<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);
}
}
...
}
새로운 엔티티를 구별하는 방법
새로운 엔티티를 판단하는 기본 전략은 아래와 같다.
- 식별자가 객체일 때 null로 판단한다.
- 식별자가 자바 기본 타입일 때 0으로 판단한다.
- Persistable 인터페이스를 구현해서 판단 로직을 변경할 수 있다.
public interface Persistable<ID> {
ID getId();
boolean isNew();
}
JPA 식별자 생성 전략이 @GeneratedValue면 save() 호출 시점에 식별자가 없으므로 새로운 엔티티로 인식해서 정상적으로 동작한다. 그런데 JPA 식별자 생성 전략이 @Id만 사용해서 직접 할당하는 것이라면 이미 식별자 값이 있는 상태로 save()를 호출하기 때문에 merge()가 호출된다.
- merge()는 우선 DB를 호출해서 값을 확인하고, DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율적이다. 따라서 Persistable 인터페이스를 사용해서 새로운 엔티티 확인 여부를 직접 구현하는 게 효과적이다.
- 여기에 등록일(@CreatedDate)을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다.
- @CreatedDate에 값이 없으면 새로운 엔티티로 판단한다.
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {
@Id
private String id;
@CreatedDate
public LocalDateTime createdDate;
public Item(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
@Override
public boolean isNew() {
return createdDate == null;
}
}
3. 나머지 기능
네이티브 쿼리와 Projections를 제외하고는 복잡도에 비해 실무에서 크게 사용할 일이 없는 기능들이다. 참고용으로만 보는 게 좋다.
Specifications (명세)
책 <도메인 주도 설계(Domain Driven Design)>에서 SPECIFICATION(명세)라는 개념을 소개한다. 스프링 데이터 JPA는 JPA Critetia를 활용해서 이 개념을 사용할 수 있도록 지원한다.
참고
실무에서는 JPA Criteria를 거의 안 쓴다. 대신 QueryDSL을 사용하자.
a. 술어(predicate)
검색 조건을 참 또는 거짓으로 평가한다.
- AND나 OR 같은 연산자를 조합해서 다양한 검색 조건을 쉽게 생성할 수 있다. 이걸 컴포지트 패턴이라고 부른다.
- 스프링 데이터 JPA는 org.springframework.data.jpa.domain.Specification 클래스로 정의해 뒀다.
- 명세 기능을 사용하려면 아래처럼 JpaSpecificationExecutor 인터페이스를 상속받아야 한다.
public interface MemberRepository
extends JpaRepository<Member, Long>, JpaSpecificationExecutor<Member> {
...
}
b. JpaSpecificationExecutor 인터페이스
public interface JpaSpecificationExecutor<T> {
Optional<T> findOne(@Nullable Specification<T> spec);
List<T> findAll(Specification<T> spec);
Page<T> findAll(Specification<T> spec, Pageable pageable);
List<T> findAll(Specification<T> spec, Sort sort);
long count(Specification<T> spec);
}
c. 명세 기능 사용 방법
Specification을 파라미터로 받아서 검색 조건으로 사용하면 된다.
- Specification을 구현하면 명세들을 조립할 수 있다.
- where(), and(), or(), not() 메서드를 제공한다.
- findAll(spec)을 보면 회원 이름 명세(username)와 팀 이름 명세(teamName)를 and()로 조합해서 검색 조건으로 사용한다.
@Test
public void specBasic() throws Exception {
// given
Team teamA = new team("teamA");
em.persist(teamA);
Member m1 = new Member("m1", 0, teamA);
Member m2 = new Member("m2", 0, teamA);
em.persist(m1);
em.persist(m2);
em.flush();
em.clear();
// when
Specification<Member> spec = MemberSpec
.username("m1").and(MemberSpec.teamName("teamA"));
List<Member> result = memberRepository.findAll(spec);
// then
Assertions.assertThat(result.size()).isEqualTo(1);
}
명세를 정의하려면 Specification 인터페이스를 구현해야 한다.
- 명세를 정의할 때는 toPredicate(...) 메서드만 구현하면 된다. 이때, JPA Criteria의 Root, CriteriaQuery, CriteriaBuilder 클래스를 파라미터로 제공하면 된다.
public class MemberSpec {
public static Specification<Member> teamName(final String teamName) {
return (Specification<Member>) (root, query, builder) -> {
if (StringUtils.isEmpty(teamName)) {
return null;
}
Join<Member, Team> t = root.join("team", JoinType.INNER); // 회원과 조인
return builder.equal(t.get("name"), teamName); // 술어
};
}
public static Specification<Member> username(final String username) {
return (Specification<Member>) (root, query, builder) ->
builder.equal(root.get("username"), username);
}
}
}
Query By Example
Query By Example은 위의 명세 기능과 비슷하게 Example을 사용해서 쿼리를 날리는 기능이다. 자세한 설명은 해당 링크를 확인해 보자.
- Probe
- 필드에 데이터가 있는 실제 도메인 객체 = 검색 조건
- ExampleMatcher
- 특정 필드를 일치시키는 상세한 정보 제공하며, 재사용 가능
- Example
- Probe와 ExampleMatcher로 구성되며, 쿼리를 생성하는 데 사용
@SpringBootTest
@Transactional
public class OueryByExampleTest {
@Autowired MemberRepository memberRepository;
@Autowired EntityManager em;
@Test
public void basic() throws Exception {
// given
Team teamA = new Team("teamA");
em.persist(teamA);
em.persist(new Member("m1", 0, teamA));
em.persist(new Member("m2", 0, teamA));
em.flush();
// when
// 1. Probe 생성
Member member = new Member("m1");
Team team = new Team("teamA"); // 내부 조인으로 teamA 가능
member.setTeam(team);
// 2. ExampleMatcher 생성; age 프로퍼티는 무시함
ExampleMatcher matcher = ExampleMatcher
.matching().withIgnorePaths("age");
// 3. Example 생성
Example<Member> example = Example.of(member, matcher);
List<Member> result = memberRepository.findAll(example);
// then
assertThat(result.size()).isEqualTo(1);
}
}
a. 장점
- 동적 쿼리를 편리하게 처리할 수 있다.
- 도메인 객체를 그대로 사용한다.
- 데이터 저장소를 RDB에서 NoSQL로 변경해도, 코드 변경이 없게 추상화가 돼 있다.
- 스프링 데이터 JPA의 JpaRepository 인터페이스에 이미 포함돼 있다.
b. 단점
- 조인은 가능하지만, 내부 조인(INNER JOIN)만 가능하다.
- 아래와 같은 중첩 제약조건을 사용할 수 없다.
- ex. firstname = ?0 or (firstname = ?1 and lastname = ?2)
- 매칭 조건이 매우 단순하다.
- 문자는 starts / contains / ends / regex만 지원한다.
- 다른 속성은 정확한 매칭(=)만 지원한다.
참고
실무에서는 QueryDSL을 사용하자.
Projections
Projections는 엔티티 대신 DTO를 편리하게 조회할 때 사용한다. 쿼리에서 SELECT 절에 들어가는 데이터라고 보면 된다. 자세한 내용은 해당 링크를 참고해 보자.
- 예를 들어, 전체 엔티티가 아니라 회원 이름만 조회하고 싶다면, 조회할 엔티티의 필드를 getter 형식으로 지정해 해당 필드만 선택해서 조회(Projection)할 수 있다.
- 이때, 반환 타입으로 인지하기 때문에 메서드 이름은 자유롭게 지어도 된다.
public interface UsernameOnly {
String getUsername();
}
///
public interface MemberRepository... {
List<UsernameOnly> findProjectionsByUsername(String username);
}
- 실행된 SQL에서도 SELECT 절에서 username만 조회(Projection)하는 것을 확인할 수 있다.
SELECT M.USERNAME
FROM MEMBER M
WHERE M.USERNAME='m1'
a. 인터페이스 기반 Projections
프로퍼티 형식(getter)의 인터페이스를 제공하면, 구현체는 스프링 데이터 JPA가 제공한다.
- Closed Projections
public interface UsernameOnly {
String getUsername();
}
다음과 같이 스프링 SpEL 문법도 지원한다. 단, 이렇게 SpEL 문법을 사용하면, DB에서 엔티티 필드를 다 조회해 온 다음에 계산하기 때문에 JPQL SELECT 절을 최적화할 수 없다.
- Open Projections
public interface UsernameOnly {
@Value("#{target.username + ' ' + target.age + ' ' + target.team.name}")
String getUsername();
}
b. 클래스 기반 Projections
다음과 같이 인터페이스가 아닌 구체적인 DTO 형식도 가능하다.
- 생성자의 파라미터 이름으로 매칭해서 조회(Projection)한다.
public class UsernameOnlyDto {
private final String username;
public UsernameOnlyDto(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
c. 동적 Projections
다음과 같이 제네릭 타입을 주면, 동적으로 Projection 데이터를 변경할 수 있다.
<T> List<T> findProjectionsByUsername(String username, Class<T> type);
- 아래 코드처럼 사용하면 된다.
List<UsernameOnly> result = memberRepository
.findProjectionsByUsername("m1", UsernameOnly.class);
d. 중첩 구조 처리
예를 들어, 회원뿐만 아니라 연관된 팀까지 가져와서 원하는 정보만 조회(Projection)할 수 있다.
public interface NestedClosedProjection {
String getUsername();
TeamInfo getTeam();
interface TeamInfo {
String getName();
}
}
- 실행된 SQL을 확인해 보면, 회원(root 엔티티)은 최적화(username만 꺼냄)해서 가져오고 팀은 엔티티 자체를 가져온 것을 확인할 수 있다. 또, LEFT OUTER JOIN이 적용된 것도 알 수 있다.
SELECT
M.USERNAME AS COL_0_0_,
T.TEAMID AS COL_1_0_1,
T.TEAMID AS TEAMID1_2_,
T.NAME AS NAME2_2_
FROM
MEMBER M
LEFT OUTER JOIN
TEAM T ON M.TEAMID = T.TEAMID
WHERE
M.USERNAME=?
e. 정리
프로젝션 대상이 root 엔티티면 JPQL SELECT 절을 최적화할 수 있기 때문에 유용하다.
- 프로젝션 대상이 root가 아니면 JPQL SELECT 절 최적화가 안 된다.
- LEFT OUTER JOIN 처리 후 모든 필드를 SELECT 해서 엔티티로 조회한 다음에 계산하기 때문이다.
- 실무에서의 복잡한 쿼리를 해결하기에는 한계가 있다.
- 단순할 때만 사용하고, 복잡한 경우엔 QueryDSL을 사용하자.
네이티브 쿼리
SQL을 그대로 쓰는 네이티브 쿼리는 사용하지 않는 게 좋다. 정말 어쩔 수 없을 때만 사용하자.
최근에는 스프링 데이터 Projections를 활용해 함께 사용한다.
a. 스프링 데이터 JPA 기반 네이티브 쿼리
지원하는 기능과 제약은 아래와 같다.
- 페이징 지원
- 반환 타입
- Object[]
- Tuple
- DTO(스프링 데이터 인터페이스 Projections 지원)
- 제약
- Sort 파라미터를 통한 정렬이 정상적으로 동작하지 않을 수 있다. 따라서 직접 처리하는 게 좋다.
- JPQL처럼 애플리케이션 로딩 시점에 문법을 확인할 수 없다.
- 동적 쿼리를 만들 수 없다.
b. JPA 네이티브 SQL 지원
JPQL은 위치 기반 파라미터를 1부터 시작하지만, 네이티브 SQL은 0부터 시작한다. 네이티브 SQL을 엔티티가 아닌 DTO로 변환하는 방법은 아래와 같다. (해당 링크 참고)
- DTO 대신 JPA TUPLE로 조회하기
- DTO 대신 MAP으로 조회하기
- 아래 두 방법은 너무 복잡해서 추천하지 않는다.
- @SqlResultSetMapping 애노테이션 사용하기
- Hibernate Result Transformer 사용하기
- 되도록이면 이 방법을 사용하자.
- JdbcTemplate나 myBatis를 사용해 DTO로 조회하기
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query(value = "select * from member where username = ?",
nativeQuery = true)
Member findByNativeQuery(String username);
}
c. Projections 활용
아래 코드처럼 스프링 데이터 JPA 네이티브 쿼리와 인터페이스 기반 Projections를 함께 활용할 수 있다.
@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);
d. 동적 네이티브 쿼리
하이버네이트를 직접 활용하거나 SpringJdbcTemplate, myBatis, jooq 같은 외부 라이브러리를 사용해야 한다.
- ex. 하이버네이트 기능 사용
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();