관리 메뉴

나구리의 개발공부기록

확장 기능, 사용자 정의 리포지토리 구현, Auditing, Web 확장 - 도메인 클래스 컨버터/페이징과 정렬, 스프링 데이터 JPA 구현체 분석, 새로운 엔터티를 구별하는 방법 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 데이터 JPA

확장 기능, 사용자 정의 리포지토리 구현, Auditing, Web 확장 - 도메인 클래스 컨버터/페이징과 정렬, 스프링 데이터 JPA 구현체 분석, 새로운 엔터티를 구별하는 방법

소소한나구리 2024. 10. 28. 14:29

출처 : 인프런 - 실전! 스프링 데이터 JPA (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


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

1) 용도

  • 특정 기능을 사용해야하거나, 기존의 메소드를 다르게 정의해야할 때 커스텀된 리포지토리를 구현해서 함께 사용할 수 있음
  • 스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하게 되면 수많은 메소드들의 정의되어있기 때문에 실질적으로 구현하기가 어렵기 때문에, 별도의 커스텀인터페이스와 그 인터페이스를 구현하는 클래스를 생성하여 메소드를 정의함
  • 실무에서는 복잡한 쿼리로 인하여 Querydsl을 사용해야할 때 주로 사용하며 Mybatis, 스프링 JDBC Template 처럼 네이티브 쿼리를 직접 입력해야할 때 사용함
  • 순수 JPA를 사용한다던가 데이터베이스 커넥션을 직접 사용하고자 할 때도 가능함

2) 구현 방법

(1) 사용자 정의 인터페이스 생성

  • 원하는 명칭으로 리포지토리 역할을 할 인터페이스를 생성하고 커스텀으로 적용할 메소드들을 선언
public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}

 

(2) 구현 클래스 생성

  • 변경된 규칙을 적용하여 커스텀인터페이스 + Impl로 클래스이름을 작성 후 인터페이스를 구현(MemberRepositoryCustomImpl)
  • 해당 예제에서는 순수 JPA로 인터페이스의 메소드를 구현했지만 실무에서는 스프링 데이터 JPA에 공통으로 정의되어있지 않고 @Query로도 해결하기 어려운 복잡한 쿼리들을 생성해야할 때 이런 커스텀 리포지토리를 많이 사용함

** 참고

  • 원래는 사용자 정의 구현 클래스에 상속받은 리포지토리 인터페이스 이름 + Impl이 구현 클래스 명칭의 규칙이였음
  • 그러나 스프링 데이터 2.x 부터는 사용자 정의 인터페이스명 + Impl로 구현해도 됨
  • 새롭게 적용된 방식이 사용자 정의한 인터페이스와 구현 클래스의 이름이 비슷하기 때문에 더 직관적이고 확장하기에도 더 좋기 때문에 새롭게 변경된 방식으로 사용자 정의 클래스를 구현하는것을 권장함
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    private final EntityManager em;

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

 

(3) 사용자 정의 인터페이스를 상속

  • 최종적으로 이렇게 스프링 데이터 JPA의 인터페이스들을 상속받는 인터페이스와 함께 커스텀 인터페이스를 상속하면됨
  • 해당 예제를 예시로 들면 MemberRepository에서는 스프링 데이터 JPA의 공통 메소드들과 MemberRepositoryCustom에 정의된 메소드를 전부 사용할 수 있음
  • 커스텀 인터페이스의 구현체는 구현 클래스 명명 규칙인 커스텀인터페이스 이름 + Impl으로 작성된 클래스를 스프링 데이터 JPA가 인식해서 스프링 빈으로 등록하여 동작함
public interface MemberRepository extends JpaRepository<Member, Long> , MemberRepositoryCustom {
    // ... 코드 생략
}

 

** 참고

  • 관례를 따르지 않고 XML이나 별도의 Config 설정을 통해 다른 명칭으로 변경하는 방법을 공유는 하지만 절대 권장하지 않음
  • 대부분의 관례를 따르는 이유는 사용자의 편의성도 있지만, 함께 개발하기 때문에 일반적으로 생각하는 범위에서 유지보수를 하게 되는데 이렇게 관례를 바꿔버리면 함께 개발하는 사람들은 한번 더 생각을하거나 어디에 작성해두어야하는 번거로움이 있기 때문임
  • XML로 설정하는 방법
<repositories base-package="study.datajpa.repository"
               repository-impl-postfix="Impl" />
  • Config 클래스에 설정 방법
 @EnableJpaRepositories(basePackages = "study.datajpa.repository",
                        repositoryImplementationPostfix = "Impl")

3) 정리

  • 사용자 정의 리포지토리는 실무에서 자주 사용하는데 사용자 정의 리포지토리는 무조건 구현하고 보는것이 아님
  • 스프링 데이터 JPA를 활용하여 공통 인터페이스와 쿼리메소드기능 및 @Query기능을 모두 사용해도 더 복잡하거나 네이티브 쿼리를 사용해야할 때, 즉 Querydsl 이나 JdbcTemplate을 함께 사용해야할 때 사용하는 것임
  • 그리고, 이런 커스텀 기능이 있다고 복잡한 쿼리를 한곳에다 모아두는 것이 아니라 JPA 강의에서 계속 반복되듯이 대부분의 복잡한 쿼리는 화면이나 API와 관련된 쿼리들이지 엔터티(비즈니스 로직)과 관련되어있지 않음
  • 그렇기 때문에 엔터티와 DTO를 통해 쿼리들을 한곳에 두는것이 아니라 별도의 MemberQueryRepository처럼 별도의 클래스를 생성한 뒤 @Repository로 입력하여 각 용도에 맞는 리포지토리를 분리하도록 아키텍처구조로 설계해야 애플리케이션의 복잡도를 낮출 수 있음(이와 관련된 내용은 JPA 활용2편 강의를 참고)

2. Auditing

1) 용도

  • 엔터티를 생성하거나 변경되었을 때 변경된 내역을 추적하고 싶을 때 편리하게 사용하도록 해줌
  • 예를 들어 회원 엔터티가 수정 되었는데 등록일, 수정일과 같은 필요한 정보를 추적할 수 없으면 애플리케이션 운영하기가 어려워짐
  • 그래서 기본적으로 모든 엔터티에는 등록일, 수정일, 등록인, 수정인 관련정보는 기본으로 깔고 가는 것이 좋음

2) 순수 JPA에서의 적용

(1) 공통 필드 클래스 생성

  • 전체 엔터티에 적용할 값들을 정의한 클래스를 생성 후 @MappedSuperclass애노테이션을 적용하면 진짜 상속관계는 아니지만 상속관계처럼 동작함
  • @PrePersist, @PostPersist, @PreUpdate, @PostUpdate 애노테이션을 활용하여 persist전/후 update전/후에 동작할 기능들을 작성
  • 생성일의 경우 수정할 필요가 없다보니 @Column의 updatable 속성을 false로 변경하여 실수로 수정하지 못하도록 막는것이 좋음
  • 예제에서는 영속 전에 생성일과 수정일을 동시에 현재시간으로 저장하고 수정이 발생되면 DB에 반영되기 전에 수정일을 저장하도록 메서드를 정의
  • 엔터티가 저장될 때 수정일도 현재시간으로 저장해두는 이유는 실제 update를 할 때 쿼리가 지저분해지기 때문에 값을 채워두도록 하는것이 좋음
package study.data_jpa.entity;

@MappedSuperclass
@Getter
public class JpaBaseEntity {

    @Column(updatable = false)
    private LocalDateTime createDate;   // 등록일은 수정 불가
    private LocalDateTime updateDate;

    // persist하기 전에 이벤트 발생
    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        createDate = now;
        updateDate = now;
    }

    // Update하기 전에 이벤트 발생
    @PreUpdate
    public void preUpdate() {
        updateDate = LocalDateTime.now();
    }
}

 

(2) 적용할 엔터티 클래스에 상속

  • 엔터티에 상속하면 해당 필드의 값들이 적용되어 테이블이 생성되고 update, persist가 발생될 때마다 정의한 기능들이 동작하여 반영됨
@Entity
public class Member extends JpaBaseEntity {
    // ... 코드 생략
}

 

(3) MemberTest

  • 코드들을 적용하고 테스트를 실행해보면 처음 저장되었을때 생성일/수정일이 저장이 되고, 수정이 일어나면 update 쿼리가 발생되어 다시 날짜를 수정일에 저장하는 것을 확인할 수 있음
  • DB에서 확인해봐도 MEMBER 테이블에 수정일, 생성일 속성이 생성되어있고 값들이 저장되어있음
  • 만약 수많은 공통 관심사가 있어도 이렇게 개발자가 따로 손대지 않아도 상속만하면 자동으로 적용될 수 있도록 할 수 있음
// Auditing
@Test
public void JpaEventBaseEntity() throws Exception {
    Member member = new Member("member1");
    memberRepository.save(member);

    Thread.sleep(1000);
    member.setUsername("member2");

    em.flush();
    em.clear();

    Member findMember = memberRepository.findById(member.getId()).get();

    System.out.println("findMember.getCreateDate() = " + findMember.getCreateDate());
    System.out.println("findMember.getUpdateDate() = " + findMember.getUpdateDate());
}

/*
실행 로그 일부
insert into member (age,create_date,team_id,update_date,username,member_id) values (0,'2024-10-27T21:04:13.297+0900',NULL,'2024-10-27T21:04:13.297+0900','member1',1);
update member set age=0,team_id=NULL,update_date='2024-10-27T21:04:14.317+0900',username='member2' where member_id=1;
findMember.getCreateDate() = 2024-10-27T21:04:13.297772
findMember.getUpdateDate() = 2024-10-27T21:04:14.317514
*/

3) 스프링 데이터 JPA 사용

  • 스프링데이터 JPA는 더 깔끔하게 해결할 수 있음

(1) 설정

  • @EnableJpaAuditing -> 스프링 부트 설정 클래스에 적용
  • @EntityListeners(AuditingEntityListener.class) -> 엔터티에 적용
  • 위 설정을 하지 않으면 동작하지 않으니 주의해야함
@EnableJpaAuditing
public class DataJpaApplication {
    // main 메서드 ...
}

 

(2) 공동 필드 클래스 생성

  • 순수 JPA에서 적용하는 것과  큰 차이가 없는 것 같지만.. @CreatedDate, @LastModifiedDate 와같이 애노테이션으로 명시적으로 적용하기에 구분하기가 쉬움
  • 해당 클래스에 @EntityListeners(AuditingEntityListener.class)를 입력해야함
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {

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

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

 

(3) 적용할 엔터티 클래스에 상속

  • 순수 JPA에서 적용할 때와 마찬가지로 생성한 Entity를 상속하면 동일하게 적용되고 테스트해보면 동일하게 동작함

(4) 생성인, 수정인 필드 추가 및 설정 적용

  • @CreatedBy, @LastModifiedBy를 활용해서 해당 엔터티를 생성한 사람과 수정한 사람 속성도 추가할 수 있음
  • 스프링부트 설정 클래스에 @Bean을 등록해서 AuditorAware<String> 타입으로 반환하는 메서드를 생성하고 람다식이나 익명클래스로 구현하여 해당 필드의 값을 입력할 대상을 지정하면됨
  • 예제에서는 UUID를 랜덤하게 생성하여 입력되도록 했지만 실무에서는 세션 정보나, 스프링 시큐리티 로그인 정보에서 ID를 등을 입력하도록 작성함
public class BaseEntity {
    // ... 기존 코드 생략
    
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String lastModifiedBy;
}


@EnableJpaAuditing
public class DataJpaApplication {
    // ... main메서드
    
    // Bean등록
	@Bean
	public AuditorAware<String> auditorProvider() {
		return () -> Optional.of(UUID.randomUUID().toString());
	}
}

 

(5) 테스트 수행 결과

  • 동일한 테스트를 메서드만 추가하여 수행해보면 생성일, 수정일, 생성인, 수정인의 값이 로그에 찍히는 것을 확인할 수 있으며 DB에서도 데이터가 반영되어 있음
/* 테스트 실행 결과 일부
findMember.getCreatedDate() = 2024-10-27T21:25:27.355213
findMember.getLastModifiedDate() = 2024-10-27T21:25:28.371347
findMember.getCreatedBy() = b62c73cd-e997-4e7e-8d3b-953bab305561
findMember.getLastModifiedBy() = 39e7eb0b-67cf-4bfa-90f5-f2bc03adcd80
*/

4) 추천 실무 적용 방식

  • 실무에서는 대부분의 엔터티는 등록시간, 수정시간은 필요하지만 등록인과 수정인 정보는 없을 수 있기 때문에 보통 각각 별도의 클래스로 분리해서 원하는 타입을 선택해서 상속하여 적용할 수 있도록 설계하는 것을 권장함

(1) BaseTimeEntity, BaseEntity 분리 적용

  • 등록일, 수정일 정보만 있는 클래스와 생성인, 수정인 정보가 있는 클래스를 분리
  • 등록일, 수정일 정보는 대부분의 엔터티에 필요하기 때문에 일반적인 엔터티는 BaseTimeEntity를 상속받게하고, 수정인, 생성인 정보가 필요한 엔터티들은 BaseEntity를 상속받도록하면 깔끔하게 적용할 수 있음
@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 createdBy;

    @LastModifiedBy
    private String lastModifiedBy;

}


** 참고

  • 등록일, 등록인, 수정일, 수정인이 있는 BaseEntity의 경우 저장시점에 모든 필드에 값들이 저장되도록 동작하며 이렇게 동작하는 것이 유지보수관점에서도 편리함
  • 스프링 부트 설정에 @EnableJpaAuditing(modifyOnCreate = false) 옵션을 적용하면 저장 시점에 저장데이터만 입력하고 수정 컬럼에는 null이 입력되지만 항상 null은 update할 때 번거롭기 때문에 굳이 이런 설정을 하는것은 권장하지 않음

** 전체 엔터티에 적용하는 방법

  • @EntityListeners(AuditingEntityListener.class) 애노테이션 적용없이 모든 엔터티에 Auditing을 적용하고 싶다면 ~/resources/META-INF 경로에 orm.xml파일을 만들어서 아래의 내용을 입력하면 됨
  • JPA version 정보를 3.1로 적용하였으며(스프링 부트 3.x 버전 사용시) 강의에서는 2.2 버전의 xml이 제공됨
  • 그러니 이러한 방식은 추천 방식처럼 분리해서 공통관심사를 각 엔터티에 적용할 수 없기 때문에 전체적으로 많은 엔터티에 동일한 공통 관심사를 적용할 때만 사용하면 됨
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm
                 https://jakarta.ee/xml/ns/persistence/orm/orm_3_1.xsd"
                 version="3.1">
    <persistence-unit-metadata>
        <persistence-unit-defaults>
            <entity-listeners>
                <entity-listener class="org.springframework.data.jpa.domain.support.AuditingEntityListener"/>
            </entity-listeners>
        </persistence-unit-defaults>
    </persistence-unit-metadata>
</entity-mappings>

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

1) 도메인 클래스 컨버터 적용 전/후

(1) 일반적인 컨트롤러

  • 보통 Rest스타일의 컨트롤러 id로 조회할 때의 컨트롤러는 이러한 형태의 모습을 띄고있음
  • init메서드는 예제를 위한 초기화 메서드
package study.data_jpa.controller;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    // 일반적인 id(pk)로 조회하는 컨트롤러
    @GetMapping("/members/{id}")
    public String findMember(@PathVariable("id") Long id) {
        Member member = memberRepository.findById(id).get();
        return member.getUsername();
    }

    // 예제를 위한 데이터 입력해두기
    @PostConstruct  // Bean 생성 직후에 실행되도록 초기화 메서드를 지정
    public void init() {
        memberRepository.save(new Member("userA"));
    }
}

 

(2) 도메인 클래스 컨버터 적용

  • 그러나 보통 id는 pk로 구성되기 때문에 도메인 도메인 클래스 컨버터라는 기능을 사용할 수 있는데, 파라미터로 id가 아닌 엔터티 자체를 집어넣고 리포지토리를 통해 꺼내는 코드의 작성이 없이 원하는 값만 return을 해도 정상적으로 동작함
  • 내부적으로 리포지토리를 통해서 엔터티를 찾도록 동작하며 HTTP요청이 회원 id를 받지만 중간에 동작한 도메인 클래스 컨버터가 회원 엔터티 객체를 반환하여 엔터티의 값들을 꺼내기만하면됨
// 도메인 클래스 컨버터 사용 컨트롤러
@GetMapping("/members2/{id}")
public String findMember2(@PathVariable("id") Member member) {
    return member.getUsername();
}

2) 주의점

  • 그러나 실무에서 사용하는 것을 권장하진않는데, 매우 간단한 경우에는 사용할 수 있긴하지만 일반적으로 실무에서는 이렇게 단순한 경우가 없을뿐더라 코드가 깔끔하긴 하지만 일반적으로 작성하는 컨트롤러의 구조보다 명확성이 떨어짐
  • 또한, 도메인 클래스 컨버터로 엔터티를 파라미터로 받으면 해당 엔터티는 트랜잭션이 없는 범위에서 엔터티를 조회했기 때문에 엔터티를 변경해도 DB에 반영되지 않으므로 단순조회용으로 사용해야함
  • JPA에 관해 깊이있게 내용을 알고있다면 예외 상황을 고려하여 데이터를 변경할 수 있겠지만 잘 모르는 상황에서는 오히려 혼란만 야기할 수 있음

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

1) 페이징과 정렬 사용하기

  • 스프링 데이터가 제공하는 페이징과 정렬기능을 스프링 MVC에서 편리하게 사용할 수 있음

(1) 컨트롤러 적용

  • 파라미터로 Pageable 인터페이스로하여 페이징 관련 정보를 요청파라미터로 입력하면 메소드에 페이징 정보를 넘겨서 적용시킬 수 있으며 구현체로는 PageRequest가 동작하며 파라미터 정보에따라 알아서 동작함
  • 반환타입을 Page로 설정하여 페이징이 적용된 값을 반환할 수 있음
// 페이징이 적용된 컨트롤러
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
    return memberRepository.findAll(pageable); // findAll 메서드에 페이징 정보를 넘길 수 있음
}

 

(2) 파라미터 적용

  • 요청 파라미터로 page, size, sort 정보를 입력하면 페이징이 적용된 값이 반환되며 &로 여러값을 입력할 수있음
  • 만약 요청 파라미터 정보를 생략하면 기본값으로 페이징 정보들이 입력됨

http://localhost:8080/members?page=15&size=5&sort=age,desc&sort=username

  • page: 현재페이지, 0부터 시작하며 기본값은 0
  • size: 한페이지에 노출할 데이터 건수, 기본값은 20
  • sort: 정렬 조건 정의, desc를 적용하면 역순 정렬이 되고 기본값은 asc

(3) 접두사 사용

  • 만약 페이징 정보가 둘 이상이면 접두사로 구분할 수 있으며 @Qualifier에 입력한 접두사_size 처럼 요청 파라미터를 입력할 수 있음
  • ex) /members?member_page=0&order_page=1 
  • 그러나 복잡성과 유지보수 문제로 권장하지 않음
public Page<Member> list2(@Qualifier("member") Pageable memberPageable,
                          @Qualifier("team") Pageable teamPageable) { /* 생략 */ }

2) 기본값 설정하기

(1) 글로벌 설정

  • 설정파일에 아래처럼 입력하면 글로벌 설정으로 페이징 기본값들을 변경할 수 있음
  • 자세한 내용은 구글 검색
  data: # 탭1번 (스페이스바 2번)
    web:
      pageable:
        default-page-size: 5  # 기본 페이지 사이즈
        max-page-size: 15     # 최대 페이지 사이즈

 

(2) 개별 설정

  • 메소드의 파라미터에 @PageableDefault를 입력하여 개별 메소드마다 기본값 설정을 변경할 수 있음
  • value, page, sort, size 등을 설정할 수 있음
public Page<Member> list(@PageableDefault(size = 5, sort = "username") Pageable pageable) {
    // ... 코드 생략
}

3) 페이징의 타입을 DTO로 반환

  • 위에서는 Page의 반환타입을 엔터티로 직접했지만 실무에서는 각 화면과 API의 스펙에 맞춰서 DTO를 정의하여 해당 DTO타입으로 반환해야함 - 중요하므로 계속 반복적으로 나옴

(1) 반환타입을 DTO로 설정한 컨트롤러

  • 리포지토리에서 조회한 엔터티를 .map으로 MemberDto로 변환하여 값들을 세팅하고 반환
  • 결과를 확인해보면 기존에 엔터티의 모든 정보가 반환되었던 것과는 달리 딱 지정한 값만 반환됨
// 페이징의 반환타입이 DTO - 실무에서는 용도에 맞게 DTO로 반환해야함
@GetMapping("/membersDto")
public Page<MemberDto> list3(Pageable pageable) {
    return memberRepository.findAll(pageable)
        .map(member -> new MemberDto(member.getId(), member.getUsername(), member.getTeam().getName()));
}

 

(2) 코드 최적화

  • Entity는 같은 패키지에 있는 DTO가 아닌이상 DTO를 가급적 의존하면 안되지만 DTO는 Entity를 의존해도 되기 때문에 DTO클래스에 의존해도 상관없음
  • 그래서 DTO클래스의 생성자로 Entity를 주입받아서 필드의 값들을 초기화하는 코드를 만들어 두면 컨트롤러에서 map메소드의 람다식을 메서드 참조로 리펙토링 할 수 있음
package study.data_jpa.dto;

@Data
public class MemberDto {

    private Long id;
    private String username;
    private String teamName;

    public MemberDto(Member member) {
        this.id = member.getId();
        this.username = member.getUsername();
        this.teamName = member.getTeam().getName();
    }
}

// 람다식을 메서드 참조로 변환한 컨트롤러
public Page<MemberDto> list3(Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::new);
}

 

** 참고 - Page를 1부터 시작하기

  • 스프링 데이터는 기본적으로 Page를 0부터 시작하는데 1부터 시작하도록 변경하려면 Pageable, Page를 파라미터와 응답 값으로 바로 사용하지 않고 구현체인 PageRequest와 응답 타입클래스를 직접 정의해서 반환하면 됨
  • 다른 방법은 설정파일에 spring.data.web.pageable.one-indexed-parameters: true로 설정해도 되지만 한계가 있음
  • web에서 page 파라미터를 -1 처리를 할 뿐이기 때문에 응답값인 Page의 정보는 원래의 상태인 0부터시작하는 값으로 반영되기 때문에 서로 일치하지 않아 가끔 문제가 발생할 수 있음
  • 그래서 가급적이면 그냥 인덱스를 0으로 페이징정보를 사용하는 것을 권장하고 커스텀 해야해야 한다면 직접 정의해서 구현하는 것을 권장함

5. 스프링 데이터 JPA 분석

1) 스프링 데이터 JPA 구현체 분석

(1) SimpleJpaRepository

  • 스프링 데이터 JPA가 제공하는 공통 인터페이스의 구현체이며 스프링 데이터 JPA의 공통 인터페이스들이 어떻게 구현되어있는지 확인해볼 수 있음
  • @Repository가 적용되어있어 예외가 발생하면 원인예외를 감싼 스프링 예외로 추상화하여 반환함
  • @Transactional이 readOnly로 적용되어있기때문에 Service계층에 @Transactional이 없어도 기본적으로 트랜잭션으로 동작하며, sava와 같이 저장, 삭제하는 메서드에는 @Transactional이 메소드에 오버라이드 되어있음
  • JPA의 모든 변경은 트랜잭션안에서 동작해야하기때문에 이와같이 정의되어있어서 트랜잭션의 적용없이도 JPA에서 메서드들이 동작할 수 있었던 것이며 Service계층에 보통 트랜잭션 애노테이션을 적용할텐데, 해당 리포지토리가 트랜잭션을 전파 받아서 사용함
  • @Transactional(readOnly = true)처럼 읽기 전용으로하면 JPA가 플러시를 생략하기 때문에 약간의 성능 향상을 얻을 수 있음
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
    // ... 
    
	@Override
	@Transactional
	public <S extends T> S save(S entity) {/* ... */ }
    
    
	@Override
	@Transactional
	@SuppressWarnings("unchecked")
	public void delete(T entity) { /* ... */ }
    
    // ...
}

2) 새로운 엔터티를 구별하는 방법

(1) save()의 구조와 새로운 엔터티를 판단하는 기본 전략

  • 내부 구조를 살펴보면 새로운 엔터티면 persist, 새로운 엔터티가 아니면 merge(병합)하도록 되어있음
  • 엔터티의 식별자가 객체일 때는 null, 자바 기본타입일 때는 0을 새로운 객체로 인식하고 persist가 호출되며, 그 외의 상황에서는 merge()가 호출됨
  • 그래서 엔터티 정의할 때 @GeneratedValue를 사용하면 자동으로 id값이 생성되어 save호출 시점에 식별자가 없어 정상동작하지만, JPA식별자 생성 전략이 @Id만 사용해서 직접 식별자를 할당하게 되었을 때 save()를 호출하면 merge()가 호출되어 주의가 필요함
  • 이렇게 직접 id의 값을 적용해야할 때는 Persistable 인터페이스를 구현해서 판단 로직을 변경하는것이 좋음

** 참고

  • merge는 완전히 객체를 교체하는 것이기 때문에 수정할 때 save()메서드를 통해 merge()가 호출되도록 하는 것이 아니라 JPA의 변경감지기능을 사용해야함
  • merge는 한번 DB에서 데이터를 조회를 한뒤에 DB에 상태를 반영하기 때문에 select 쿼리가 한번 호출된다는 단점이 있음
  • merge()는 준영속상태의 엔터티를 영속화 할 때 사용해야함
  • https://nagul2.tistory.com/328 내용 참고
@Override
@Transactional
public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null");

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

 

(2) @Id만 적용한 엔터티 테스트

  • 엔터티에 @Id만 적용하여 식별자를 직접 커스텀하여 입력하여 엔터티를 생성하고 save()를 호출하게되면 merge()가 호출되어 select쿼리 한 번, insert쿼리 한 번 총2번의 쿼리가 진행된 것을 확인할 수 있음
  • SimpleJpaRepository의 save()메서드 if문에 브레이크포인트를 체크하고 디버그를 돌려본뒤 다음 포인트로 넘어가보면 merge()로 넘어가는 것도 확인할 수 있음
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {

    @Id
    private String id;

    public Item(String id) {
        this.id = id;
    }
}

@SpringBootTest
class ItemRepositoryTest {

    @Autowired ItemRepository itemRepository;

    @Test
    void save() {
        Item item = new Item("A");
        itemRepository.save(item);
    }

}

 

(3) Persistable 인터페이스 구현

  • 적용할 엔터티에 Persistable을 구현하고 제네릭타입으로 id의타입을 입력
  • isNew()메서드를 오버라이드하여 직접 판단하는 로직을 구현하여 테스트를 실행해보면 insert쿼리 한번으로 save()가 동작하고 디버그로 확인해보아도 persist()로 동작하는 것을 확인할 수 있음
  • 보통 김영한님은 Auditing을 활용하여 엔터티가 저장될 때 생성일자, 수정일자 등을 자동으로 입력되도록하기때문에 해당 값이 없으면 신규 객체로 판단하도록 구현한다고 함
package study.data_jpa.entity;

@EntityListeners(AuditingEntityListener.class)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {

    @Id
    private String id;

    @CreatedDate
    private LocalDateTime createdDate;

    public Item(String id) {
        this.id = id;
    }
    
    // 생성일자가 입력이 되어있지 않으면 신규 객체로 판단
    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}