[JPA] JPA 정복하기 - 04. 확장 기능(사용자 정의 repository, Auditing, web domain class converter, web paging/sort)
우리는 아래 확장 기능을 알아볼예정이다!
- 사용자 정의 repository
- Auditing
- web domain class converter
- web paging/sort
사용자 정의 repository
만약에 Spring Data JPA repository를 사용하다가 쿼리 메소드 기능 이외에 다른 기능이 필요하다면 JpaRepository interface를 모두 상속받아 구현해야한다.
모두 구현하지 않고 확장 할 수 있는 기능을 Spring Data JPA에서 제공한다.
확장할 코드를 구현할 인터페이스를 다중 상속하는 방식으로 명칭 규칙을 지키면 알아서 Spring Data JPA 가 해당 인터페이스를 상속하고 명칭이 맞는 클래스를 찾아 확장한 메소드를 실행한다.
우선 실제 확장할 코드가 들어갈 클래스명은 [JPA Repository 상속한 interface 이름]+[Impl] 로 생성하는것이 규칙이다.
우리는 아래 MemberRepository Interface를 확장할 것이기에 class 명을 'MemberRepositoryImpl'로 할 것이다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}
'MemberRepositoryImpl' 에서 확장할 기능은 새로운 interface(명칭 상관 없음)를 상속받아 구현해야 하며
MemberRepository는 새로운 interface(명칭 상관 없음)를 상속하게 되면 Spring repository JPA 가 interface를 상속하고 있는 클래스 지정된 명칭인 'MemberRepositoryImpl' 를 찾아 해당 메소드를 실행한다.
이를 테스트 하기 위해 우선 새로운 interface(명칭 상관 없음)를 생성한다. 우리는 findMemberCustom 이라는 메소드 기능을 확장할 것이다.
public interface MemberRepositoryCustom {
List<Member> findMemberCustom();
}
이제 새로운 interface(명칭 상관 없음)를 상속받을 클래스 MemberRepositoryImpl 를 생성하여 확장할 코드를 상속받아 구현한다.
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m").getResultList();
}
}
이제 해당 메소드를 JPA Interface에서 사용하기 위해 새로운 interface(명칭 상관 없음)를 상속 받는다.
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}
이제 테스트 코드를 돌려보자!
@Test
public void callCustomRepository(){
Member member = Member.builder()
.username("member")
.age(10)
.build();
memberRepository.save(member);
memberRepository.findMemberCustom();
}
정상적으로 확장한 코드를 실행하는것을 볼 수 있다.
Auditing
우리는 매번 DB를 생성할때 필수 칼럼인 등록일, 수정일, 등록자, 수정자를 생성한다.
이를 우선 순수 JPA로 구현해보자!
등록일 수정일이 담길 JpaBaseEntity 클래스를 생성하고 @MappedSuperclass 를 붙여서 해당 클래스가 Entity 테이블 자체가 아닌 공통 정의 칼럼임을 정의하며 해당 어노테이션을 붙이지 않으면 JpaBaseEntity를 상속받은 자식 entity에 등록일, 수정일 관련 칼럼이 생성 되지 않는다.
package study.datajpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import java.time.LocalDateTime;
import lombok.Getter;
@Getter
@MappedSuperclass
public class JpaBaseEntity {
// update시에 DB값이 변경되지 않는다.
@Column(updatable = false)
private LocalDateTime createDate;
private LocalDateTime updateDate;
/***
* 저장 하기전 실행
*/
@PrePersist
public void prePersist(){
LocalDateTime now = LocalDateTime.now();
createDate = now;
updateDate = now;
}
/***
* 업데이트 하기전 실행
*/
@PreUpdate
public void preUpdate(){
updateDate = LocalDateTime.now();
}
}
여기서 사용한 어노테이션은 두개 정도지만 아래와 같이 여러 어노테이션이 있어 참고하여 사용하면 된다.
어노테이션명 | 설명 |
PrePersist | 해당 엔티티를 저장하기 이전 |
PostPersist | 해당 엔티티를 저장한 이후 |
PreUpdate | 해당 엔티티를 업데이트 하기 이전 |
PostUpdate | 해당 엔티티를 업데이트 한 이후 |
PreRemove | 해당 엔티티를 삭제하기 이전 |
PostRemove | 해당 엔티티를 삭제한 이후 |
이제 해당 공통 메소드를 상속받는다.
@Setter
@ToString(of = {"id","username","age"}) // 나중에 toString 으로 데이터 확인하기 위함 > 정의한 값만 출력
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Entity
public class Member extends JpaBaseEntity{
@Id @GeneratedValue // 알아서 식별자를 딴다.
@Column(name = "member_id")
private Long id;
private String username;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id") // FK
private Team team;
public Member(String username, int age, Team team){
this.username = username;
this.age = age;
if (team != null){
changeTeam(team);
}
}
public void changeTeam(Team team){
this.team = team;
team.getMembers().add(this);
}
}
이제 테스트를 진행해보자!
@Test
public void JpaBaseEntity(){
Member member = Member.builder()
.username("member")
.age(10)
.build();
memberRepository.save(member); // 실행전 @PrePersist 동작
member.setUsername("member2");
em.flush();
em.clear();
Member findMember = memberRepository.findById(member.getId()).get();
System.out.println("[date]"+findMember.getCreateDate()+"|"+findMember.getUpdateDate());
}
실행 결과는 아래와 같이 정상적으로 create, update 데이터가 나온다.
이를 Spring Data JPA를 통해 구현해보자!
JpaBaseEntity에 사용한 @Prepersist @PreUpdate 대신 @CreatedDate @LastModifiedDate 를 사용하면 알아서 생성일자, 수정일자를 넣어준다! 여기서 이벤트를 감지하는 행위기에 @EntityListeners 를 붙여줘야 auditing 기능이 동작한다.
package study.datajpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createDate;
@LastModifiedDate
private LocalDateTime updateDate;
}
추가로 Auditing 기능 이용을 위해서 @EnableJpaAuditing 을 붙여준다.
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {
...
}
이제 새로 생성한 부모 클래스로 변경해보자!
public class Member extends BaseEntity{
@Id @GeneratedValue // 알아서 식별자를 딴다.
@Column(name = "member_id")
private Long id;
...
}
이제 이전에 생성한 테스트코드를 돌려보면 정상적으로 동작 하는것을 볼 수있다.
만약에 생성자, 업데이트유저도 셋팅하고 싶다면 AuditorAware 인터페이스를 상속받아서 사용하면 된다.
Auditing 관련 설정을 위해 클래스를 생성하고 AuditorAware의 생성자, 업데이트 유저 관련값을 가져오는 메소드 getCurrentAuditor 를 오버라이딩하여 랜덤 유저 아이디가 셋팅 되도록 한다. (앞에서 DataJpaApplication 클래스에 추가한 JpaAuditing 어노테이션을 아래 JpaAuditing 설정 클래스에 넣어 관리하기 쉽게 한다.)
package study.datajpa.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import java.util.Optional;
import java.util.UUID;
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration implements AuditorAware {
@Override
public Optional getCurrentAuditor() {
return Optional.of(UUID.randomUUID().toString());
}
}
이제 BaseEntity에 @CreateBy @LastModifiedBy 어노테이션을 붙여주면 위에서 오버라이딩한 getCurrentAuditor 메소드의 리턴값을 가져와서 셋팅한다.
package study.datajpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createDate;
@LastModifiedDate
private LocalDateTime updateDate;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updateBy;
}
테스트 코드에 출력되도록 프린트를 찍고 테스트 코드를 실행해보자!
@Test
public void JpaBaseEntity(){
Member member = Member.builder()
.username("member")
.age(10)
.build();
memberRepository.save(member); // 실행전 @PrePersist 동작
member.setUsername("member2");
em.flush();
em.clear();
Member findMember = memberRepository.findById(member.getId()).get();
System.out.println("[date]"+findMember.getCreateDate()+"|"+findMember.getUpdateDate());
System.out.println("[date]"+findMember.getCreatedBy()+"|"+findMember.getUpdateBy());
}
정상적으로 uuid 가 출력되는것을 볼 수 있으며 실제 서비스 할때는 실제 유저의 값이 셋팅 될 수 있도록 하면 된다.
web domain class converter
member entity 에서 id 로 username 을 조회한다고 하면 보통 이렇게 id 를 받고 repository 에서 데이터를 조회하는 로직이 필요하다.
@GetMapping("/member/{id}")
public String member(@PathVariable("id") Long id){
return memberRepository.findById(id).get().getUsername();
}
그러나 Spring Data Jpa가 제공해주는 web domain converter 기능을 사용하면 아래와 같이 repository 를 따로 하지 않아도 조회 쿼리가 나가게 되어 간단하게 구현 가능하다.
@GetMapping("/member/entity/{id}")
public String member(@PathVariable("id") Member member){
return member.getUsername();
}
데이터를 초기 셋팅하는 코드를 작성하고 @PostConstruct 를 붙이면 어플리케이션 기동시 한번 실행 된다.
@PostConstruct
public void init(){
for(int i=0 ;i < 50;i++){
memberRepository.save(Member.builder()
.username("member"+i)
.age(10)
.build());
}
}
이제 어플리케이션을 기동하고 API 를 호출해보면 repository 를 통해 정상적으로 조회하는것을 볼 수 있고
repository 를 사용하지 않은 API 도 조회가 정상적으로 된다.
web paging/sort
이제 페이징과 정렬 기능을 알아보자!
아래와 같이 Pageable을 requetParameter 로 사용하면 PageRequest 라는 객체를 생성하여 Pageable에 주입하며 관련 변수는 page(0부터 시작), sort, size 가 있다.
@GetMapping("/members")
public Page<Member> members(Pageable pageable){
return memberRepository.findByMember(pageable);
}
이제 호출해보면 page 기능이 정상적으로 실행 되며 size 는 default 값이 20이므로 20개만 조회해온다.
만약 page 가 1, size 가 3이면 아래와 같이 id가 4인 친구 부터 3개의 데이터가 나온다.
sort 의 경우에는 아래와 같이 정렬 조건 넣을 수 있으며 현재는 id 내림차순, 그다음 정렬은 username 오름차순 정렬이다.
만약 Pageable 기본값, max 값 설정을 전체적으로 변경하고 싶다면 아래와 같이 application yml 에 정의하며
application.yml
spring:
data:
web:
pageable:
default-page-size: 3
max-page-size: 10
size 전달 안해도 3개가 정상적으로 나오고 page 를 max 보다 큰 11개를 요청해도 max 인 10개만 조회해오는것을 볼 수 있다.
만약 각각 API 마다 적용하고 싶다면 @PageableDefault 를 사용하면 된다.
@GetMapping("/members")
public Page<Member> members(@PageableDefault(size = 5) Pageable pageable){
return memberRepository.findByMember(pageable);
}
조회해보면 해당 API 는 5개 조회 기본으로 되는것을 볼 수 있다.
그리고 추가적으로 Member 엔티티 중에 보여줘야 하는 값이 정해져 있다면 관련 DTO 를 생성하여 리턴 하도록 하면 된다.
보여줄 데이터만 TestDto를 생성하며 Member 를 매개변수로 받는 생성자를 생성한다.
package study.datajpa.dto;
import lombok.Getter;
import lombok.Setter;
import study.datajpa.entity.Member;
@Setter
@Getter
public class TestDto {
private Long id;
private String username;
public TestDto(Long id, String username) {
this.id = id;
this.username = username;
}
public TestDto(Member member){
this.id = member.getId();
this.username = member.getUsername();
}
}
이제 map 으로 응답값을 묶어 TestDto 객체를 생성하여 담으면
@GetMapping("/tests")
public Page<TestDto> tests(Pageable pageable){
return memberRepository.findByMember(pageable).map(TestDto::new);
}
원하는 데이터만 담겨서 노출 되고 page 정보도 있으니 쉽게 사용하는 클라이언트에게 페이징, 정렬이 되는 API 를 생성하여 공유 할 수 있다.
끝!