BackEnd/JPA

[JPA] 확장 기능

샤아이인 2022. 5. 6.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

1. 사용자 정의 리포지토리 구현

이번 시간에는 실무에서 매우 중요한 내용이다.

 

Spring Data JPA Repository는 인터페이스만 정의하고 구현체는 스프링이 자동 생성하게 된다.

문제는! Spring Data JPA가 제공하는 인터페이스에서 일부를 수정하려면 나머지 인터페이스 들도 모두 직접 구현해야 하기 때문에 현실적이지 못하다.

 

또한 다른 기술들, 예를 들어 MyBatis, QueryDsl 과 같은 기능도 함께 사용하고 싶다면?

다양한 이유로 인터페이스의 메서드를 직접 구현하고 싶다면 어떻게 해야할까?

 

1. 사용자 정의 Interface 만들기

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

 

2. 이를 구현하는 구현체 만들기.

@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberCustomRepository{

    private final EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m").getResultList();
    }
}

구현체를 만들때 원하는 기능을 사용해도 된다.

EntityManager를 위 코드처럼 직접 사용해도 되고, MyBatis를 써도 되고, QueryDsl을 써도 된다.

 

3. 사용자 정의 인터페이스 상속하기

기존의 사용중인 MemberRepository에서 MemberCustomRepository 또한 extends 해야한다.

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

인터페이스 끼리 상속한것 이다.

 

4. 사용자 정의 메서드 호출하기

다음과 같이 간단한 테스트 코드를 작성해 보았다.

@Test
public void customRepositoryTest() {
    List<Member> memberCustom = memberRepository.findMemberCustom();
}

위에서 만든 사용자 정의 메서드를 사용하고 있다! 정상적으로 동작한다.

 

실무에서는 주로 QueryDSL이나 SpringJdbcTemplate을 함께 사용할 때 사용자 정의 리포지토리 기능이 자주 사용된다.

 

5. 주의사항

구현 repository의 끝에는 Impl이라는 단어가 꼭 붙어야 한다! (규칙: 리포지토리 인터페이스 이름 + Impl)

따라서 우리의 리포지토리 이름인 MemberRepository 끝에 Impl을 추가해줘야 한다.

스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

 

추가로 스프링 데이터 1.9.x 버전까지는 MemberRepositoryImpl처럼 이름을 작성해야만 했다.

하지만 최근에는 MemberCustomRepositoryImpl 처럼 구현 대상 interface의 이름 끝에 Impl을 추가해줘도 된다.

이 방식이 조금더 직관적이다.

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.custom-implementations

따라서 위 예제의 MemberRepositoryImpl 대신에 MemberCustomRepositoryImpl 같이 구현해도 된다.

 

2. Auditing

Entity를 생성할 때 생성일, 변경일, 등록자, 수정자 등에 대한 정보를 추가하려면 어떻게 해야할까?

 

우선 순수 JPA를 사용해서 등록일, 수정일 문제를 해결해 보자.

 

1. 순수 JPA를 사용한 등록일, 수정일 추가하기

우선 공통으로 사용될 JpaBaseEntity를 만들었다.

@MappedSuperclass
public class JpaBaseEntity {

    @Column(updatable = false)
    private LocalDateTime createdDate; // 값 변경 불가
    private LocalDateTime updatedDate;

    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        this.createdDate = now;
        this.updatedDate = now;
    }

    @PreUpdate
    public void preUpdate() {
        this.updatedDate = LocalDateTime.now();
    }
}

Spring은 @PrePersist, @PostPersist @PreUpdate, @PostUpdate 와 같은 이벤트 에노테이션을 지원해준다.

 

사용하는 쪽 에서는 다음과 같이 상속하면 된다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username"
)
public class Member extends JpaBaseEntity { // 상속!!

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;
    
    // 생략...
}

생성된 SQL문은 다음과 같다.

create table member (
    member_id bigint not null,
    created_date timestamp,
    updated_date timestamp,
    age integer not null,
    username varchar(255),
    team_id bigint,
    primary key (member_id)
)

create_date, updated_date 필드가 추가된것을 확인할 수 있다!

 

테스트코드를 통해서 확인해 보자!

@Test
public void JpaEventBaseEntity() throws InterruptedException {
    // given
    Member member = new Member("member1");
    memberRepository.save(member); // @PrePersist 발생

    // when
    Thread.sleep(100);
    member.setUsername("member2");
    em.flush();
    em.clear();

    // then
    Member findMember = memberRepository.findById(member.getId()).get();
    System.out.println("findMember.getCreatedDate = " + findMember.getCreatedDate());
    System.out.println("findMember.getUpdatedDate = " + findMember.getUpdatedDate());
}

출력되는 결과는 다음과 같다.

DB에도 다음과 같이 잘 저장된것을 확인할 수 있다.

Team Entity에도 추가하고 싶다면 JpaBaseEntity를 상속받으면 된다!


이번에는 Spring Data JPA를 활용하여 위 기능을 사용해보자!

 

@EnableJpaAuditing 을 SpringBoot 설정 클레스에 적용시켜야 한다.

@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {

	public static void main(String[] args) {
		SpringApplication.run(DataJpaApplication.class, args);
	}

}

 

이제 공통으로 사용할 BaseEntity를 다음과 같이 만들어보자!

@EntityListeners(AuditingEntityListener.class)
@Getter
@MappedSuperclass
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

좀 불편하기는 하지만 이벤트 기반으로 동작함을 명시하기 위해 @EntityListeners(AuditingEntityListener.class) 를 추가해줘야 한다.

 

이후 Member가 BaseEntity를 상속하도록 변경해보자!

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username"
)
public class Member extends BaseEntity { // BaseEntity로 변경!

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;
    
    // 생략...
}

이전과 동일하게 테스트 코드를 실행해보면 똑같이 작동하는것을 확인할 수 있다.

 

이번에는 등록자와 수정자 정보를 추가해보자!

@EntityListeners(AuditingEntityListener.class)
@Getter
@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; // 수정자 추가
}

등록자와 수정자 필드를 추가하였다.

 

하지만 한가지 의문이 들 수 있다?

생성, 변경 시간이야 LocalDateTime.now()를 해주면 되겠지만, 등록자와 수정자는 어떤값을 추가해줘야 할까?

 

등록자, 수정자를 처리해주는 AuditorAware를 Spring Bean으로 등록해야 한다.

@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {

	public static void main(String[] args) {
		SpringApplication.run(DataJpaApplication.class, args);
	}

      // 등록자의 수정자가 config 클래스의 Bean에서 주입된다.
	@Bean
	public AuditorAware<String> auditorProvider() {
		return () -> Optional.ofNullable(UUID.randomUUID().toString());
	}

}

 

실무에서는 UUID가 아닌, 세션 정보나 스프링 시큐리티 로그인 정보에서 ID를 받게된다.

 

(다음은 강의에는 없는 코드로 직접 작성해본 코드이다)

public class AuditorAwareImpl implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String userId = "";
        if(authentication != null) {
            userId = authentication.getName();
        }
        return Optional.of(userId);
    }
}

이렇게 AuditorAwareImpl을 만들고, 이를 Bean으로 등록해줘도 된다.

 

테스트 코드를 실행해보자!

@Test
public void JpaEventBaseEntity() throws InterruptedException {
    // given
    Member member = new Member("member1");
    memberRepository.save(member); // @PrePersist 발생

    // when
    Thread.sleep(100);
    member.setUsername("member2");
    em.flush();
    em.clear();

    // then
    Member findMember = memberRepository.findById(member.getId()).get();
    System.out.println("findMember.getCreatedDate = " + findMember.getCreatedDate());
    System.out.println("findMember.getUpdatedDate = " + findMember.getLastModifiedDate());
    System.out.println("findMember.getCreatedBy = " + findMember.getCreatedBy());
    System.out.println("findMember.getLastModifiedBy = " + findMember.getLastModifiedBy());
}

실행 결과는 다음과 같다.

findMember.getCreatedDate = 2022-05-05T21:51:08.015346
findMember.getUpdatedDate = 2022-05-05T21:51:08.137883
findMember.getCreatedBy = 1cf0e10d-bde0-41b1-ae40-3cdae49f0f77
findMember.getLastModifiedBy = 22eb8c7b-f1f1-4796-871a-c5651eadbc9d

 

만약 (등록일과 수정일), (등록자와 수정자) 를 분리해서 사용하고 싶다면?

시간은 거의 모든 Entity에 사용하는 필드이다. 이를 BaseTimeEntity로 만들자.

@EntityListeners(AuditingEntityListener.class)
@Getter
@MappedSuperclass
public class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

이후 BaseEntity에서 BaseTimeEntity를 상속하도록 변경해보자!

@EntityListeners(AuditingEntityListener.class)
@Getter
@MappedSuperclass
public class BaseEntity extends BaseTimeEntity {

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String lastModifiedBy;
}

 

3. Web 확장 - 도메인 클래스 컨버터

이번 시간에는 HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩 시켜보자!

 

기존에는 다음과 같이 pathVariable 로 넘겨받은 값을 통해서 member를 찾게 된다.

@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(pk)를 받게되는 경우 중간에 도메인 클래스 컨버터가 동작하여 해당 엔티티 객체를 반환해준다.

(도메인 클래스 컨버터도 내부적으로는 repository를 사용해서 엔티티를 찾는다.)

 

도메인 클래스 컨버터를 적용한 코드는 다음과 같다.

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;
      
    @GetMapping("/members/{id}")
    public String findMember(@PathVariable("id") Member member) {
        return member.getUsername();
    }
}

 

▶ 도메인 클래스 컨버터 의 주의사항!

도메인 클래스 컨버터로 엔티티를 파라미터로 받으면, 이 엔티티는 단순 조회용으로만 사용해야 한다.

(트랜잭션이 없는 범위에서 엔티티를 조회했으므로, 엔티티를 변경해도 DB에 반영되지 않는다.)

 

4. Web 확장 - 페이징과 정렬

Spring Data 가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.

 

다음과 같은 컨트롤러가 있다고 해보자.

@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
    return memberRepository.findAll(pageable);
}

list 메서드의 파라미터로 Pageable 을 받고 있다.

Http 파라미터들이 컨트롤러에 바인딩 될때, Pageable이 있으면 PageRequest 라는 객체를 생성해서 메서드의 인자로 전달해준다.

즉, Pageable 은 인터페이스고, 실제는 org.springframework.data.domain.PageRequest 객체를 생성한다.

 

위와 같은 상태에서 다음과 같이 요청을 보내보자.

http://localhost:8080/members

이러면 등록한 모든 member의 정보가 출력되는 것 을 확인할 수 있다.

 

이번에는 쿼리파라미터에 page값을 추가하여 다음과 같이 전송해보자!

http://localhost:8080/members?page=0

0 page에 해당하는 20명의 member 정보가 출력되는 것 을 확인할수 있다.

page=1 을 주면 21번 부터 나오고, page=2 를 주면 41번 부터 나오게 된다.

 

이번에는 size를 추가하여 전달해 보자!

http://localhost:8080/members?page=0&size=2

결과는 다음과 같다.

size값을 2로 줬기 때문에 content 2건이 나오게 된다.

 

이번에는 sort 를 추가해주자!

http://localhost:8080/members?page=0&size=2&sort=id,desc

결과는 다음과 같다.

sord=id,desc 를 추가로 전달하였더니 정렬이 내림차순으로 되었다!

 

추가로 반환 타입이 Page 여서 totalCount 값을 구하는 쿼리가 자동으로 출력된다.

select count(member0_.member_id) as col_0_0_ from member member0_

 

▶ 요청 파라미터

예) /members?page=0&size=3&sort=id,desc&sort=username,desc

- page: 현재 페이지, 0부터 시작한다.

- size: 한 페이지에 노출할 데이터 건수

- sort: 정렬 조건을 정의한다. ) 정렬 속성,정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면 sort 파라미터 추가 ( asc 생략 가능)

 

만약 여기서 default값을 변경하고 싶다면 어떻게 해야할까?

예를 들어 이전에 0 page 요청이 20명의 데이터가 나왔다. 이 값을 10개로 바꾸고 싶다면?

 

- Global 설정

application.yml을 다음과 같이 수정하면 Global 설정 된다!

spring:
  data:
    web:
      pageable:
        default-page-size: 10
        max-page-size: 50

 

- 개별 설정

@GetMapping("/members")
public Page<Member> list(
    @PageableDefault(size = 3, sort = "username", direction = Sort.Direction.DESC) Pageable pageable) {
    return memberRepository.findAll(pageable);
}

글로벌 설정보다 개별 설정이 우선권을 갖게된다.

 

▶ 접두사

- 둘 이상의 페이징 정보를 출력해야 하는 상황이라면 접두사로 구분할 수 있다.

- @Qualifier 에 접두사명 추가 "{접두사명}_xxx”

- ex: /members?member_page=0&order_page=1

public String list(
      @Qualifier("member") Pageable memberPageable,
      @Qualifier("order") Pageable orderPageable, ...
  )

 

▶ DTO로 반환하기

항상 Entity를 직접 반환하는 것은 좋지 못하다. 다음과 같이 map을 통해 MemberDto로 변환하여 반환해 보자!

@GetMapping("/members")
public Page<MemberDto> list(@PageableDefault(size = 4, sort = "username", direction = Sort.Direction.DESC) Pageable pageable) {
    Page<Member> page = memberRepository.findAll(pageable);
    Page<MemberDto> map = page.map(m -> new MemberDto(m.getId(), m.getUsername(), null));
    return map;
}

 

▶ Page 1부터 시작하기

Spring Data JPA는 페이지가 0부터 시작한다. 이를 1로 바꾸는 방법에는 2가지가 있다.

 

1) Pageable, Page를 파리미터와 응답 값으로 사용히지 않고, 직접 클래스를 만들어서 처리한다.

그리고 직접 PageRequest(Pageable 구현체)를 생성해서 리포지토리에 넘긴다.

물론 응답값도 Page 대신에 직접 만들어서 제공해야 한다.

 

2) spring.data.web.pageable.one-indexed-parameters true 로 설정한다.

그런데 이 방법은 web에서 page 파라미터를 -1 처리 할 뿐이다.

따라서 응답값인 Page 에 모두 0 페이지 인덱스를 사용하는 한계가 있다.

 

다음 예시를 보자 page=1로 요청을 보내고 있다.

http://localhost:8080/members?page=1

출력된 json의 결과는 다음과 같다.

{
    "content": [
        {
            "id": 100,
            "username": "user99",
            "teamName": null
        },
        {
            "id": 99,
            "username": "user98",
            "teamName": null
        },
        {
            "id": 98,
            "username": "user97",
            "teamName": null
        },
        {
            "id": 97,
            "username": "user96",
            "teamName": null
        }
    ],
    "pageable": {
        "sort": {
            "empty": false,
            "sorted": true,
            "unsorted": false
        },
        "offset": 0,
        "pageNumber": 0, // 이부분이 사실 1이 되야함
        "pageSize": 4,
        "paged": true,
        "unpaged": false
    },
    "last": false,
    "totalPages": 25,
    "totalElements": 100,
    "first": true,
    "size": 4,
    "number": 0, // 이부분이 사실 1이 되야함
    "sort": {
        "empty": false,
        "sorted": true,
        "unsorted": false
    },
    "numberOfElements": 4,
    "empty": false
}

pageNumber와 number는 사실 1이 되야 한다. 

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 나머지 기능들  (0) 2022.05.07
[JPA] 스프링 데이터 JPA 분석  (0) 2022.05.06
[JPA] 쿼리 메소드 기능 - 4  (0) 2022.05.04
[JPA] 쿼리 메소드 기능 - 3  (0) 2022.05.04
[JPA] 쿼리 메소드 기능 - 2  (0) 2022.05.04

댓글