[Spring Data JPA] 3. 확장 기능
사용자 정의 Repository 구현
Spring Data JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많기 때문에 Spring Data JPA 리포지토리를 사용하여 인터페이스만 정의하고 구현체는 스프링이 자동 생성하도록 한다.
만약, 다양한 이유로 인터페이스의 메서드를 직접 구현하고 싶다면(JPA 직접 사용, 스프링 JDBC Template 사용, MyBatis 사용, QueryDSL 사용, DB 커넥션 직접 사용 등) 사용자 정의 인터페이스를 사용한다.
사용 방법
1. 사용자 정의 Interface 생성
public interface MemberRepositoryCustom { // 이름은 자유
List<Member> findMemberCustom();
}
2. 사용자 정의 Interface의 구현체 생성
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m")
.getResultList();
}
}
사용자 정의 Interface 구현체는 이름을 규칙에 맞게 작성해야 한다.
규칙 : JpaRepository extends 받은 interface 이름 + Impl (ex: MemberRepositoryImpl)
Impl을 대신 다른 이름으로 변경할 수 있지만 관례에 따르는 것이 안전하다.
2.1 사용자 정의 리포지토리 구현 최신 방식 (Spring Data 2.x 이상 버전)
스프링 데이터 2.x 부터는 사용자 정의 구현 클래스에 리포지토리 인터페이스 이름 + Impl을 적용하는 대신에 사용자 정의 인터페이스 명 + Impl 방식도 지원한다. (ex: MemberRepositoryCustomImpl)
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom{
/** 위와 동일 */
}
사용자 정의 인터페이스 이름과 구현 클래스 이름이 비슷하므로 더 직관적이며, 여러 인터페이스를 분리해서 구현하는 것도 가능하기 때문에 이 방식을 사용하는 것을 더 권장한다.
3. MemberRepository에 사용자 정의 Interface 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
...
}
Test 및 실행 결과
@Test
public void callCustom() {
List<Member> result = memberRepository.findMemberCustom();
}
언제 사용?
QueryDSL을 사용할 때, Custom해서 많이 사용하기도 하고, 실무에서는 주로 QueryDSL과 SpringJdbcTemplate을 함께 사용할 때 사용한다. 즉, Spring Data JPA로 해결할 수 없는 복잡한 동적 쿼리를 짜야 될 때 주로 사용된다.
참고: 항상 사용자 정의 리포지토리가 필요한 것은 아니다. 그냥 별도의 리포지토리를 interface가 아닌 Class로 만들어서 스프링 빈으로 등록해서 사용해도 된다.
Auditing
엔티티를 생성하거나 변경할 때, 변경한 사람과 시간을 추적할 때 필요하며, 등록일과 수정일은 모든 테이블에 꼭 넣어주어야 한다.
순수 JPA 사용
@Getter
@MappedSuperclass
public class JpaBaseEntity {
@Column(updatable = false) // 변경되면 안됨
private LocalDateTime createDate; // 생성일
private LocalDateTime updatedDate; // 수정일
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
createDate = now;
updatedDate = now;
}
@PreUpdate
public void preUpdate() {
updatedDate = LocalDateTime.now();
}
}
등록일, 수정일 필드를 가지고 있는 JpaBaseEntity를 작성한다.
이 때, @MappedSuperclass를 추가해야 한다. 이는 실제 상속 관계는 아니고, 속성(필드)만 상속 받겠다라는 의미를 가지며 @MappedSuperclass가 붙은 클래스는 엔티티가 아니다. 테이블과 관계가 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할을 하기 때문에 추상 클래스로 만드는 것을 권장한다.
참고: JPA 주요 이벤트 어노테이션
- @PrePersist : Persist 하기 전에 이벤트 발생
- @PostPersist : Persist 한 후에 이벤트 발생
- @PreUpdate : Update하기 전에 이벤트 발생
- @PostUpdate : Update한 후에 이벤트 발생
public class Member extends JpaBaseEntity {
...
}
그리고 Entity에 JpaBaseEntity를 상속받아서 사용한다.
Test 및 실행 결과
@Test
public void JpaEventBaseEntity() throws InterruptedException {
// 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.createDate = " + findMember.getCreateDate());
System.out.println("findMember.updateDate = " + findMember.getUpdatedDate());
}
테스트 코드에 대한 실행 결과를 보면 생성일(create_date) 컬럼과 수정일(updated_date) 컬럼이 추가되었고, @PrePersist와 @PreUpdate도 잘 작동된 것을 확인할 수 있다.
Spring Data JPA 사용
@SpringBootApplication
@EnableJpaAuditing
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
}
Spring Data JPA를 사용하려면 @EnableJpaAuditing을 스프링 부트 설정 클래스에 적용해야 한다.
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate; // 등록일
@LastModifiedDate
private LocalDateTime lastModifiedDate; // 수정일
}
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity {
@CreatedBy
@Column(updatable = false)
private String createBy; // 등록자
@LastModifiedBy
private String lastModifiedBy; // 수정자
}
일반적으로 등록일과 수정일은 기본적으로 모든 테이블에 컬럼으로 들어가고, 등록자와 수정자는 필요한 경우에만 들어간다.
따라서 BaseTimeEntity(등록일, 수정일)를 각 테이블에서 상속받아서 사용하되 등록자나 수정자까지 필요한 테이블의 경우에는 BaseEntity extends BaseTimeEntity를 한 BaseEntity(등록일, 수정일, 등록자, 수정자)를 상속받아서 사용한다.
@SpringBootApplication
@EnableJpaAuditing
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()); // 현재는 랜덤
}
}
그리고 스프링 부트 설정 클래스에 AuditorAware 설정을 추가해줘야 등록, 수정될 때마다 AuditorProvider()를 호출해서 결과물을 꺼내간다. 현재는 랜덤 값으로 지정했지만 실무에서는 세션 정보나, Spring Security 로그인 정보에서 ID를 받는다.
Test 및 실행 결과
@Test
public void JpaEventBaseEntity() throws InterruptedException {
// 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.createDate = " + findMember.getCreateDate());
System.out.println("findMember.updateDate = " + findMember.getLastModifiedDate());
System.out.println("findMember.createBy = " + findMember.getCreateBy());
System.out.println("findMember.updateBy = " + findMember.getLastModifiedBy());
}
테스트 코드를 실행한 결과를 보면 create_by(등록자), create_date(등록일), last_modified_by(수정자), last_modified_date(수정일)이 모두 추가된 것을 확인할 수 있다. 또한 출력된 결과를 보면 예상처럼 수정 시간이 0.1만큼 차이가 나는 것을 확인할 수 있다.
등록자와 수정자는 다르게 나타나는데, 이는 AuditorProvider()에서 랜덤 값을 지정했으므로 값이 다르게 나온다.
Web 확장
도메인 클래스 컨버터
도메인 클래스 컨버터는 HTTP 파라미터로 넘어온 엔티티의 id로 엔티티 객체를 찾아서 바인딩한다.
도메인 클래스 컨버터 사용 전
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("members/{id}")
public String findMember(@PathVariable("id") Long id) { // @PathVariable의 파라미터: id
Member member = memberRepository.findById(id).get();
return member.getUsername();
}
}
도메인 클래스 컨버터 사용 후
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("members2/{id}")
public String findMember2(@PathVariable("id") Member member) { // @PathVariable 파라미터: 엔티티
return member.getUsername();
}
}
HTTP 요청은 회원 id(PK: 기본키)를 받지만, 도메인 클래스 컨버터가 중간에 동작해서 회원 엔티티 객체를 반환한다.
도메인 클래스 컨버터도 리포지토리를 사용해서 엔티티를 찾는다.
주의!!!
도메인 클래스 컨버터로 엔티티를 파라미터로 받으면, 이 엔티티는 단순 조회용으로만 사용해야 한다.
트랜잭션이 없는 범위에서 엔티티를 조회했기 때문에, 엔티티의 값을 변경해도 DB에 반영되지 않는다.
※ 간단한 경우에는 사용해도 되지만, 사용을 추천하지는 않는다.
페이징과 정렬
스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
return page;
}
// 100개의 Member 데이터 생성
@PostConstruct
public void init() {
for (int i = 0; i < 100; i++) {
memberRepository.save(new Member("user" + i, i));
}
}
}
HTTP 요청이 파라미터로 바인딩될 때, Spring Data JPA는 파라미터가 Pageable이면 pageRequest 객체를 생성해서 값을 채워서 Injection을 해준다.
요청 파라미터
- page : 현재 페이지(0부터 시작)
- size : 한 페이지에 노출할 데이터 건수
- sort : 정렬 조건 (ex: 정렬 속성, 정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면, sort 파라미터 추가(ASC는 생략 가능)
예시
/members?page=0&size=3 : 한 페이지에 3개씩, 0번 페이지 가져오기
/members?page=0&sort=id,desc : id를 내림차순으로 정렬했을 때, 0번 페이지 가져오기
/members?page=0&sort=id,desc&sort=username : id를 내림차순으로, username을 오름차순으로 정렬했을 때, 0번 페이지 가져오기
/members?page=0&sort=id,desc 에 대한 결과(size=20 기본값)
{
"content": [
{
"createdDate": "2024-09-05T00:23:44.288926",
"lastModifiedDate": "2024-09-05T00:23:44.288926",
"createBy": "5c44ba3d-decd-444f-a011-0cebe8aa7326",
"lastModifiedBy": "5c44ba3d-decd-444f-a011-0cebe8aa7326",
"id": 100,
"username": "user99",
"age": 99,
"team": null
},
...
{
"createdDate": "2024-09-05T00:23:44.266665",
"lastModifiedDate": "2024-09-05T00:23:44.266665",
"createBy": "ae657d72-b81e-4c04-992c-412b2229c750",
"lastModifiedBy": "ae657d72-b81e-4c04-992c-412b2229c750",
"id": 81,
"username": "user80",
"age": 80,
"team": null
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 20,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"offset": 0,
"paged": true,
"unpaged": false
},
"last": false,
"totalPages": 5,
"totalElements": 100,
"first": true,
"size": 20,
"number": 0,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"numberOfElements": 20,
"empty": false
}
last, totalPages, totalElements 등의 내용들은 반환타입이 Page라서 생긴다.
Page 내용 DTO로 변환
Page<Member>와 같이 엔티티를 외부에 두면 엔티티의 모든 정보들이 외부에 노출되기 때문에 절대 그대로 반환하면 안된다.
엔티티의 스펙을 변경하는 순간 API 스펙도 변경된다.
@GetMapping("/members")
public Page<MemberDto> list(@PageableDefault(size = 5) Pageable pageable) {
Page<Member> page = memberRepository.findAll(pageable);
Page<MemberDto> map = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
return map;
}
위와 같이 /members?page=0&sort=id,desc를 DTO로 반환한 결과를 보면 아래와 같이 지정한 정보들만 공개된다.(size=5로 설정함)
{
"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
},
{
"id": 96,
"username": "user95",
"teamName": null
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 5,
"sort": {
"empty": false,
"unsorted": false,
"sorted": true
},
"offset": 0,
"unpaged": false,
"paged": true
},
"last": false,
"totalPages": 20,
"totalElements": 100,
"first": true,
"size": 5,
"number": 0,
"sort": {
"empty": false,
"unsorted": false,
"sorted": true
},
"numberOfElements": 5,
"empty": false
}
size 기본값
application.yml을 수정하여 global하게 설정할 수 있다.
spring.data.web.pageable.default-page-size=20 /# 기본 페이지 사이즈/
spring.data.web.pageable.max-page-size=2000 /# 최대 페이지 사이즈/
또는 아래와 같이 @PageableDefault를 사용하여 개별 설정을 할 수 있다. 개별 설정이 global 설정보다 우선시된다.
@GetMapping("/members")
public Page<Member> list(@PageableDefault(size=5, sort = "username") Pageable pageable){
return memberRepository.findAll(pageable);
}
[출처]
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA-%EC%8B%A4%EC%A0%84 → 이 글은 김영한님의 "실전! 스프링 데이터 JPA" 강의 중 6장을 듣고 정리한 내용입니다.