JAVA

[JPA] JPA 정복하기 - 04. 확장 기능(사용자 정의 repository, Auditing, web domain class converter, web paging/sort)

코린이s 2024. 1. 21. 00:53
728x90

우리는 아래 확장 기능을 알아볼예정이다!

  • 사용자 정의 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 를 생성하여 공유 할 수 있다. 

끝!

728x90