본문 바로가기

JPA 실전

[JPA] JPA 실전 - 확장 기능

 

 

 

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

  • 스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동 생성
  • 스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많음
  • 다양한 이유로 인터페이스의 메서드를 직접 구현하고 싶다면?
    • JPA 직접 사용( `EntityManager` )
    • 스프링 JDBC Template 사용
    • MyBatis 사용
    • 데이터베이스 커넥션 직접 사용 등등...
    • Querydsl 사용

 

1. 사용자 정의 리포지토리 인터페이스 정의

먼저, 사용자 정의 리포지토리 인터페이스를 정의합니다. 이 인터페이스는 추가하고자 하는 커스텀 메서드를 선언합니다.

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

 

2. 사용자 정의 리포지토리 구현 클래스 정의

사용자 정의 리포지토리 인터페이스를 구현하는 클래스를 정의합니다. 이 클래스는 MemberRepositoryCustom 인터페이스를 구현하고, 원하는 커스텀 로직을 작성합니다.

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public List<Member> findCustomMembers() {
        String jpql = "select m from Member m where m.age > :age";
        return entityManager.createQuery(jpql, Member.class)
                            .setParameter("age", 20)
                            .getResultList();
    }
}

 

3. 기본 리포지토리와 사용자 정의 리포지토리 인터페이스 통합

마지막으로, 기본 리포지토리 인터페이스에 사용자 정의 리포지토리 인터페이스를 상속받도록 합니다.

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

 

이제 MemberRepository는 JpaRepository에서 제공하는 기본 기능뿐만 아니라 MemberRepositoryCustom에서 정의한 커스텀 메서드도 사용할 수 있습니다.

 

**사용자 정의 구현 클래스**
- 규칙: 리포지토리 인터페이스 이름 + `Impl`
- 스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

 

**Impl 대신 다른 이름으로 변경하고 싶으면?**

 

 XML 설정

<repositories base-package="study.datajpa.repository"
               repository-impl-postfix="Impl" />

 

JavaConfig 설정

 @EnableJpaRepositories(basePackages = "study.datajpa.repository",
                        repositoryImplementationPostfix = "Impl")

 

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

 

참고: 항상 사용자 정의 리포지토리가 필요한 것은 아니다. 그냥 임의의 리포지토리를 만들어도 된다. 예를들어 MemberQueryRepository를 인터페이스가 아닌 클래스로 만들고 스프링 빈으로 등록해서 그냥 직접 사용해도 된다. 물론 이 경우 스프링 데이터 JPA와는 아무런 관계 없이 별도로 동작한다.

 

 

**최신 사용자 정의 인터페이스 구현 클래스 예제**

 @RequiredArgsConstructor
 public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
     private final EntityManager em;
     @Override
     public List<Member> findMemberCustom() {
         return em.createQuery("select m from Member m")
                 .getResultList();
} }

기존 방식보다 이 방식이 사용자 정의 인터페이스 이름과 구현 클래스 이름이 비슷하므로 더 직관적이다. 추가로 여러 인터페이스를 분리해서 구현하는 것도 가능하기 때문에 새롭게 변경된 이 방식을 사용하는 것을 더 권장한다.

 

 

Auditing

엔티티를 생성, 변경할 때 변경한 사람과 시간을 추적하고 싶으면?

  • 등록일
  • 수정일
  • 등록자
  • 수정자

스프링 데이터 JPA 사용

 

JPA Auditing을 사용하면 엔티티의 생성 및 수정 정보를 자동으로 기록할 수 있습니다. 다음은 스프링 데이터 JPA에서 Auditing을 설정하고 사용하는 방법에 대한 단계별 설명입니다.

1. 설정

1.1 @EnableJpaAuditing 설정

스프링 부트 설정 클래스에 @EnableJpaAuditing 애너테이션을 추가합니다.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.domain.AuditorAware;

import java.util.Optional;
import java.util.UUID;

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

 

2. BaseEntity 클래스 작성

2.1 등록일, 수정일 자동 기록

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

    public LocalDateTime getCreatedDate() {
        return createdDate;
    }

    public LocalDateTime getLastModifiedDate() {
        return lastModifiedDate;
    }
}

 

2.2 등록자, 수정자 자동 기록

import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;

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

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

    @LastModifiedBy
    private String lastModifiedBy;

    public String getCreatedBy() {
        return createdBy;
    }

    public String getLastModifiedBy() {
        return lastModifiedBy;
    }
}

 

3. 엔티티 클래스에 BaseEntity 상속

3.1 엔티티에 BaseEntity 적용

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Member extends BaseEntityWithUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    // other fields, getters, setters, constructors
}

 

4. AuditorAware 인터페이스 구현

AuditorAware 인터페이스를 구현하여 현재 사용자 정보를 제공하는 빈을 등록합니다. 실무에서는 세션 정보나 스프링 시큐리티의 로그인 정보를 사용하여 사용자 ID를 반환합니다.

4.1 기본적인 UUID를 사용하는 예제

위에서 설명한 DataJpaApplication 클래스에서 UUID를 사용한 예제를 다시 봅니다:

@Bean
public AuditorAware<String> auditorProvider() {
    return () -> Optional.of(UUID.randomUUID().toString());
}

 

4.2 스프링 시큐리티를 사용한 예제

스프링 시큐리티를 사용하여 현재 로그인한 사용자의 이름을 반환하는 예제입니다.

@Bean
public AuditorAware<String> auditorProvider() {
    return () -> Optional.ofNullable(SecurityContextHolder.getContext())
                         .map(SecurityContext::getAuthentication)
                         .map(Authentication::getName);
}

 

참고: 실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만, 등록자, 수정자는 없을 수도 있다. 그래서 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속한다.

 

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

    public LocalDateTime getCreatedDate() {
        return createdDate;
    }

    public LocalDateTime getLastModifiedDate() {
        return lastModifiedDate;
    }
}

 

 

참고: 저장시점에 등록일, 등록자는 물론이고, 수정일, 수정자도 같은 데이터가 저장된다. 데이터가 중복 저장되는 것 같지만, 이렇게 해두면 변경 컬럼만 확인해도 마지막에 업데이트한 유저를 확인 할 수 있으므로 유지보수 관점 에서 편리하다. 이렇게 하지 않으면 변경 컬럼이 `null` 일때 등록 컬럼을 또 찾아야 한다.
참고로 저장시점에 저장데이터만 입력하고 싶으면 `@EnableJpaAuditing(modifyOnCreate = false)` 옵션을 사용하면 된다.

 

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

도메인 클래스 컨버터를 사용하면 HTTP 요청의 파라미터로 넘어온 엔티티 ID를 기반으로 엔티티 객체를 자동으로 바인딩할 수 있습니다. 이는 코드의 간결성을 높이고, 엔티티 조회 로직을 컨트롤러에서 분리하여 클린 코드 작성에 도움을 줍니다.

도메인 클래스 컨버터 사용 전

다음은 도메인 클래스 컨버터를 사용하기 전의 코드 예제입니다. 여기서는 id를 통해 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();
    }
}

 

도메인 클래스 컨버터 사용 후

도메인 클래스 컨버터를 사용하면, @PathVariable을 통해 id 대신 Member 엔티티를 직접 받을 수 있습니다.

@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberRepository memberRepository;

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

이 경우, HTTP 요청은 여전히 회원 id를 받지만, 도메인 클래스 컨버터가 중간에서 동작하여 Member 엔티티 객체로 변환해 줍니다.

동작 원리

도메인 클래스 컨버터는 스프링 데이터 JPA가 제공하는 기능으로, @PathVariable이나 @RequestParam에 엔티티 타입을 사용하면, 스프링이 자동으로 엔티티를 찾아 바인딩해 줍니다. 내부적으로 리포지토리를 사용하여 엔티티를 조회합니다.

주의 사항

  • 도메인 클래스 컨버터로 엔티티를 파라미터로 받을 때, 이 엔티티는 단순 조회용으로만 사용해야 합니다.
  • 트랜잭션이 없는 범위에서 엔티티를 조회했기 때문에, 엔티티를 변경해도 DB에 반영되지 않습니다.

 

스프링 데이터 JPA의 페이징과 정렬 기능은 매우 편리하게 사용할 수 있으며, 이를 스프링 MVC와 함께 사용하면 클라이언트 요청에 따라 동적으로 데이터를 페이징하고 정렬할 수 있습니다.

 

1. 페이징과 정렬 예제

컨트롤러에서 Pageable 사용

@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;
    }
}

여기서 Pageable을 파라미터로 받으면 클라이언트에서 페이지 번호, 페이지 크기, 정렬 조건 등을 요청 파라미터로 지정할 수 있습니다.

2. 요청 파라미터

  • page: 현재 페이지 (0부터 시작)
  • size: 한 페이지에 노출할 데이터 건수
  • sort: 정렬 조건

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

 

3. 기본값 설정

글로벌 설정 (application.yml)

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 2000
 

개별 설정 (@PageableDefault)

@GetMapping("/members_page")
public String list(@PageableDefault(size = 12, sort = "username", direction = Sort.Direction.DESC) Pageable pageable) {
    // implementation
}

 

4. 접두사 사용

페이징 정보가 둘 이상이면 접두사로 구분할 수 있습니다.

@GetMapping("/members")
public String list(
    @Qualifier("member") Pageable memberPageable,
    @Qualifier("order") Pageable orderPageable) {
    // implementation
}

 

요청 예시: /members?member_page=0&order_page=1

5. Page 내용을 DTO로 변환하기

엔티티를 직접 노출하지 않고 DTO로 변환하여 반환하는 것이 좋습니다.

 

DTO 클래스

@Data
public class MemberDto {
    private Long id;
    private String username;

    public MemberDto(Member m) {
        this.id = m.getId();
        this.username = m.getUsername();
    }
}

 

Page 변환

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

 

6. Page를 1부터 시작하기

스프링 데이터 JPA는 기본적으로 페이지 번호를 0부터 시작합니다. 이를 1부터 시작하게 하려면 다음과 같이 설정할 수 있습니다.

방법 1: 직접 구현

Pageable과 Page를 직접 구현하고 PageRequest를 사용합니다.

방법 2: application.yml 설정

spring:
  data:
    web:
      pageable:
        one-indexed-parameters: true

 

이 설정을 적용하면 클라이언트의 요청에서 페이지 번호를 1부터 시작하게 할 수 있습니다. 하지만 응답값은 여전히 0부터 시작합니다.

 

{
    "content": [
        // ...
    ],
    "pageable": {
        "offset": 0,
        "pageSize": 10,
        "pageNumber": 0 // 여전히 0부터 시작
    },
    "number": 0, // 여전히 0부터 시작
    "empty": false
}

 

이 설정은 클라이언트 요청에서만 페이지 번호를 1부터 시작하게 합니다. 응답값의 페이지 인덱스는 여전히 0부터 시작합니다. 이를 완전히 해결하려면 커스텀 페이지 응답을 만들어야 합니다.