[Spring Data JPA] 3. 다양한 확장 기능들

HJ·2024년 3월 8일
0

Spring Data JPA

목록 보기
3/4
post-thumbnail
post-custom-banner

김영한 님의 실전! 스프링 데이터 JPA 강의를 보고 작성한 내용입니다.


1. 사용자 정의 인터페이스 구현

Spring Data JPA 는 인터페이스만 정의하면 구현체는 스프링이 자동으로 생성해줍니다. 인터페이스를 직접 구현하면 개발자가 구현해야 하는 기능이 너무 많습니다.

인터페이스 메서드를 직접 구현하기 위해 JPA 를 직접 사용하거나 MyBatis 를 사용할 수 있도록 사용자 정의 리포지토리라는 기능을 제공합니다.

이 기능은 인터페이스만으로 해결되지 않을 때, 예를 들어 NamedQuery 나 @Query 로 해결할 수 있는 경우가 아니고 복잡한 동적 쿼리를 작성해야 할 때 사용합니다. 대표적으로 QueryDSL 을 사용할 때 사용한다고 합니다.


[ 1. 인터페이스 생성 ]

public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}

먼저 인터페이스를 생성하고, 사용할 메서드를 정의합니다.


[ 2. 구현체 생성 ]

@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 ) 형태도 가능합니다. 강사님은 새롭게 변경된 이 방식이 더 직관적이기 때문에 권장한다고 하십니다.


[ 3. 인터페이스 상속 ]

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    ...
}

Spring Data JPA 인터페이스에 1번에서 만든 인터페이스를 상속 받도록 합니다.

결과적으로 MemberRepository 에서 findMemberCustom() 을 호출하면 2번에서 구현한 메서드가 실행되는데 이 기능은 자바에서 되는건 아니고 Spring Data JPA 가 이렇게 동작하도록 엮어주는 것입니다.


[ 참고 ]

MemberRepositoryCustom 을 인터페이스가 아닌 클래스로 만들고 스프링 빈으로 등록해서 그냥 직접 사용해도 된다고 합니다. 물론 이 경우 스프링 데이터 JPA와는 아무런 관계 없이 별도로 동작한다고 합니다.




2. Auditing

엔티티를 생성, 변경할 때 등록일, 등록시간, 수정일, 수정시간과 같이 변경한 사람와 시간을 추적하고 싶을 때 사용합니다.

2-1. 순수 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 어노테이션이 없다면 위처럼 등록날짜, 수정날짜가 빠진 채로 테이블이 생성됩니다.


2-2. Spring Data JPA 사용

[ 1. 엔티티 생성 ]

@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 를 사용하며, 그냥 두면 값이 들어가지 않고 이에 대한 처리가 필요합니다.


[ 2. Application 세팅 ]

@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 에서 인증 객체를 가져와 사용해야 한다고 하셨습니다.




3. 도메인 클래스 컨버터

[ 기존 방식 ]

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에 반영되지 않습니다.




4. 페이징과 정렬

4-1. Pageable

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 는 구현체입니다


4-2. 디폴트 설정

만약 쿼리 파라미터로 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 어노테이션을 사용해서 개별적으로 설정할 수 있습니다.


4-3. 페이징 정보가 둘 이상인 경우

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




5. Query By Example

JpaRepository 를 보면 QueryByExampleExecutor 를 상속 받는 것을 알 수 있는데 이 인터페이스가 Example 을 파라미터로 받아 쿼리를 수행할 수 있도록 해줍니다.

Query By Example 을 사용하기 위해서 아래 3가지를 알아야 합니다.

Probe : 필드에 데이터가 있는 실제 도메인 객체

ExampleMatcher : 특정 필드를 일치시키는 상세한 정보 제공, 재사용 가능

Example : Probe와 ExampleMatcher로 구성, 쿼리를 생성하는데 사용


5-1. Example

[ 사용 예시 ]

@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 이 들어갔기 때문에 무시하지 않은 것입니다.


5-2. ExampleMatcher

[ 사용 예시 및 쿼리 로그 ]

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 가 조건에 없는 것을 확인할 수 있습니다.


5-3. 조인 사용하기

[ 사용 예시 및 쿼리 로그 ]

@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 를 사용한 중접 제약조건이 불가하며 문자를 제외하면 = 비교밖에 안됩니다.




6. Projections

Spring Data JPA Projections( 프로젝션 )은 JPA 엔터티의 일부 속성만을 선택적으로 조회하고, 이를 DTO 나 인터페이스 등의 특정 타입으로 매핑하는 기능을 말합니다. Projections 을 사용하면 필요한 데이터만을 가져와서 성능을 최적화하거나 데이터 전송 양을 최소화할 수 있습니다.

Spring Data JPA Projections은 인터페이스 기반의 프로젝션과 클래스 기반의 프로젝션 두 가지 유형이 있습니다.

[ 1. 인터페이스 기반 프로젝션 ]

인터페이스를 정의하고, 해당 인터페이스의 메소드들을 이용하여 필요한 속성을 지정합니다. 인터페이스의 메소드 이름은 엔티티의 필드나 그에 상응하는 getter 메소드와 일치해야 합니다.

[ 2. 클래스 기반 프로젝션 ]

특정 클래스를 정의하고, 해당 클래스의 생성자를 통해 필요한 속성을 전달합니다. 이 경우, 생성자 매개변수의 이름은 엔티티의 필드와 일치해야 합니다.



6-1. 인터페이스 기반 Closed Projections

[ 1. 반환하고자 하는 컬럼을 가진 인터페이스 생성 ]

public interface UserNameOnly {
    String getUsername();   // getter 형식
}

조회할 엔티티의 필드를 getter 형식으로 지정하면 해당 필드만 선택해서 조회합니다.


[ 2. 인터페이스를 반환하는 메서드 생성 ]

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<UserNameOnly> findProjectionsByUsername(@Param("username") String username);
}

반환타입에 1번에서 생성한 인터페이스를 지정합니다. 이렇게 하면 UserNameOnly 인터페이스에 프록시 객체가 담겨서 반환됩니다.


[ 3. 테스트 코드 ]

@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() 을 확인한 후 구현체에 데이터를 담아서 반환해줍니다.



6-2. 인터페이스 기반 Open Projections

public interface UserNameOnly {
    @Value("#{target.username + ' ' + target.age + ' ' + target.team.name}")
    String getUsername();   // getter 형식
}

인터페이스에 정의할 때 @Value 에 스프링의 SpEL 문법을 사용해서 지정할 수 있습니다. 이렇게 하면 지정한 형식에 맞게 반환됩니다.

이렇게 되면 이전처럼 username 만 select 를 하는 것이 아니라 DB에서 엔티티 필드를 다 조회해온 다음에 위의 형식에 맞게 데이터를 집어넣고 반환하게 됩니다. 따라서 JPQL select 절 최적화가 이루어지지 않습니다.



6-3. 클래스 기반 프로젝션

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



6-4. 동적 Projections

public interface MemberRepository extends JpaRepository<Member, Long> {
    <T> List<T> findProjectionsByUsername(String username, Class<T> type);
}

제네릭 타입을 주게 되면 조금 더 동적으로 데이터를 가져올 수 있습니다. 예를 들어, 어떤 경우에는 유저의 이름을 가져온다던지, 어떤 경우에는 나이를 가져온다던지 할 때 사용하면 편리합니다.


[ 사용 예시 ]

List<UsernameOnlyDTO> result = 
    memberRepository.findProjectionsByUsername("m1", UsernameOnlyDTO.class);



6-5. 중첩 구조

[ 1. 인터페이스 정의 ]

public interface NestedClosedProjections {
    String getUsername();
    TeamInfo getTeam();

    interface TeamInfo {
        String getName();
    }
}

[ 2. 사용 예시 및 쿼리 ]

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 최적화가 불가능합니다.




7. Native Query

네이티브 쿼리는 JPA 가 제공하는 기능입니다. 하지만 가급적 네이티브 쿼리를 사용하지 않는게 좋다고 합니다.

7-1. 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 에 작성함에도 불구하고 애플리케이션 로딩 시점에 문법 확인이 불가능합니다.


7-2. 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);

6번의 Projections 를 사용할 수 있으며, 페이징 처리도 가능합니다. 페이징 처리 시에는 네이티브 쿼리이기 때문에 countQuery 를 직접 작성해주어야 합니다.

과거에는 Object[] 배열로 받았어야 했는데 Projections 기능이 등장하며 반환형으로 인한 문제가 해결되었습니다.


7-3. 동적 네이티브 쿼리

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();

하이버네이트를 직접 활용해서 동적 네이티브 쿼리를 사용할 수 있습니다.

post-custom-banner

0개의 댓글