[JPA] 10.Spring Data JPA

재우·2025년 11월 27일

JPA

목록 보기
10/11

예제 도메인 모델과 동작확인

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {
    private Long id;
    private String username;
    private int age;
}
  1. @NoArgsConstructor(access = AccessLevel.PROTECTED)
    ==> 기본 생성자(파라미터 없는 생성자)를 자동으로 만들어주되 접근 제어자를 protected로 설정하라는 의미이다.
    즉, 위 코드를 컴파일하면,
    protected Member() {
    }
    와 같은 생성자를 만들어준다. 참고로 엔티티에서, 기본생성자가 아닌 생성자를 별도로 만들게 되면 기본생성자를 무조건 선언해줘야한다.

  2. @ToString(of = {"id", "username", "age"})
    ==> 객체 자체를 System.out.println으로 출력하면 내부적으로 객체의 toString()이 호출되는데,
    일반적인 객체 자체를 System.out.println으로 출력하면 해당 객체의 toString()이 따로 오버라이딩 되어있지 않기 때문에 주소값이 출력된다.
    @ToString(of = {"id", "username", "age"})를 해주면, Member객체를 출력할때 id, username, age 필드만 포함해서 출력하겠다는 의미이다.
    즉, 내부적으로

    @Override
    public String toString() {
        return "Member(id=" + this.id + ", username=" + this.username + ", age=" + this.age + ")";
    }

    와 같이 오버라이딩 해준다.
    참고로 of를 사용하지않고 @ToString만 기본으로 쓰면 모든 필드가 출력된다.



기타 - Optional

  1. Optional.of(매개값) : 매개값이 들어있는 Optional 객체를 생성한다. 매개값이 null인경우 예외(NullPointerException)가 발생한다.
  2. Optional.empty() : 비어있는 Optional 객체를 생성한다. null값이 들어있는게 아니라 비어있는 Optional 객체이다.
  3. Optional.ofNullable(매개값) : 매개값이 null이 아니면 Optional.of(매개값), 매개값이 null이면 Optional.empty()
  4. Optional<Member> member = Optional.of(member1); 에서 Member를 꺼내는 방법
    4.1 member.get(); => 값이 있으면 Member객체를 반환하고, 값이 없으면(비어있는 Optional객체이면) 예외 발생.
    4.2 member.orElse(null); ==> 값이 있으면 Member객체를 반환하고, 값이 없으면(비어있는 Optioanl객체이면) null을 반환.
    4.3 member.orElse(new Member()); ==> 값이 있으면 Member 객체를 반환하고, 값이 없으면(비어있는 Optional 객체이면) 새로운 Member를 생성해서 반환.
  5. public Optional<Member> findByLoginId(String loginId) {
        List<Member> all = findAll();
        return all.stream()
            .filter(m -> m.getLoginId().equals(loginId))
            .findFirst();
    }
    => stream의 filter()는 조건을 만족하면 다음 단계인 findFirst()를 실행하고 만족하지 않으면 버려지고 다음 루프를 돈다.
    => findFirst()는 먼저 조건을 만족하는 요소를 가지고, Optional객체를 생성해서 리턴한다. filter에서 조건을 만족하는 요소가 없으면 비어있는 Optional 객체를 생성해서 리턴한다.
  6. public Member login(String loginId, String password){
        return memberRepository.findByLoginId(loginId)
        .filter(m -> m.getPassword().equals(password)
        .orElse(null);
    }
    => Optional의 filter()는 Optional 객체 안에 저장된 값을 조건에 따라 필터링 한다. 조건을 만족하는 값이 있으면 해당 값(Optional<Member> 객체)을 그대로 반환하며, 그렇지 않으면 비어있는 Optional객체를 반환한다. 이때, 비어있는 Optional객체를 반환하지않고 다른값을 반환하고 싶으면 orElse()를 사용하면 된다.
    참고로, memberRepository.findByLoginId()가 비어있는 Optional객체를 리턴하게 되면, filter()의 조건을 만족하지 못하기 때문에 비어있는 Optional객체를 그대로 리턴하게 된다. 이때도, 비어있는 Optional객체를 반환하지않고 다른 값을 반환하고 싶으면 orElse()를 사용하면 된다.


순수 JPA 기반 리포지토리 만들기

public long count() {
	return em.createQuery("select count(m) from Member m", Long.class).getSingleResult();
}

=> count는 반환타입이 Long타입으로 나오기 때문에 반환타입을 Long.class로 하면되고, 한개만 리턴되기 때문에 getSingleResult()를 사용한다.



기타

인터페이스는 인터페이스만 가지고 new로 객체를 생성할 수 없다. 인터페이스를 구현한 구현 클래스가 있어야한다. 그리고 객체는 구현 클래스를 가지고 생성한다.
즉,

public interface TestInterface {
}

가 있으면,

public class TestClass implements TestInterface {
}

와 같이 구현 클래스가 있어야하고,
이를 바탕으로
TestInterface t = new TestClass(); 와 같이 구현클래스로 객체를 만든다.



공통 인터페이스 설정

@SpringBootApplication
@EnableJpaRepositories(basePackages = "study.datajpa.repository")
public class DataJpaApplication {
	...
}

// Spring Data Jpa를 사용하기 위해서는 @EnableJpaRepositories를 사용해서 사용하고자하는 위치를 적어줘야한다.
// 하지만 스프링 부트에서는 이를 생략해도, @SpringBootApplication이 선언된 클래스의 package 와 그 하위 패키지 모두에서, Spring Data JPA는 내부적으로 "JpaRepository를 상속한 인터페이스"를 구현한 클래스(프록시클래스)를 만들고, 구현객체(프록시 객체)를 생성해서 스프링빈으로 생성하고 필요한 곳에 주입해준다.



메소드 이름으로 쿼리 생성

  1. find..By.... ==> By뒤에 where문에 들어갈 조건들을 적는다. ex)findByUsernameAndAgeGreaterThan(), findByUsername(), findTestByUsername();
  2. find..By ==> 이렇게 By뒤에 아무것도 없으면 where문이 없는 전체 조회이다. 즉, 모든 member를 다 조회한다. findAll()과 동일한 동작을 한다. ex)findBy();


JPA NamedQuery

@Entity
@NamedQuery(name = "Member.findByUsername", query = "select m from Member m where m.username = :username")
public class Member {

}

@Query(name = "Member.findByUsername") // 선언된 NamedQuery를 사용하는방법
List<Member> findByUsername(@Param("username") String username); // NamedQuery의 JPQL에 파라미터로 값을 넘겨주기 위해서는 @Param을 무조건 적어줘야한다.


Query, 리포지토리 메소드에 쿼리 정의하기

@Query("select m from Member m where m.username = :username and m.age = :age")
List<Member> findUser(@Param("username") String username, @Param("age") int age);
  • Query에 선언된 JPQL에 파라미터로 값을 넘겨주기 위해서는 @Param을 무조건 적어줘야한다.

  • 참고로, @Query를 사용하면 메서드 이름으로 쿼리를 만들지 않고 @Query 내용을 그대로 사용하여 JPQL을 실행한다.



반환타입

스프링 데이터 JPA는 유연한 반환 타입을 지원한다.
반환 타입은 단건(Optional, 객체)이 될 수 도 있고, 다건(List)이 될 수 도 있다. 메서드를 선언할 때 실제 나가는 쿼리 결과를 예상해서 개발자가 적절한 반환타입을 선언해주면 된다.

  • List<Member> findByUsername(String name); // 다건 => 결과가 있으면 List<Member> 반환. 결과가 없으면 빈 컬렉션 반환.
  • Member findByUsername(String name); // 단건 => 결과가 있으면 Member반환. 결과가 없으면 null 반환, 결과가 2개 이상이면 예외 발생
  • Optional<Member> findByUsername(String name); // 단건 => 결과가 있으면 Optional<Member> 반환. 결과가 없으면 비어있는 Optional객체 반환, 결과가 2개 이상이면 예외 발생.


스프링 데이터 JPA 페이징과 정렬

SELECT 
* 
FROM table
LIMIT 3 OFFSET 3;

참고로 위 쿼리에서,
OFFSET 3 // 조회된 데이터에서 앞에서 3개 건너뛰고,
LIMIT 3 // 그 다음 3개를 가져와라.
즉, 4번째(OFFSET 3에서 1더한것)부터 3개를 가져오는것.
아래 쿼리와 동일한 결과이다.

SELECT 
* 
FROM table
LIMIT 3,3;
  1. PageRequest객체는, Pageable인터페이스의 구현체이다.

  2. PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username");
    // 첫번째 파라미터에는 현재페이지, 두번째 파라미터에는 조회할 데이터 수를 입력한다. 세번째 파라미터에 정렬조건도 넣어줄 수 있다.
    // 이렇게 사용하면, 첫번째 페이지를 조회하고, 해당 페이지에 3개의 데이터를 보여준다는것이다.
    // 즉, 1페이지(0번 페이지)를 조회하고, 그 페이지에는 3개의 데이터(limit 3)를 포함하며 username기준으로 DESC정렬한것이다.

  public interface MemberRepository extends JpaRepository<Member, Long> {
      Page<Member> findByAge(int age, Pageable pageable);
  }

  @Test
      public void paging() {
          memberRepository.save(new Member("member1", 10));
          memberRepository.save(new Member("member2", 10));
          memberRepository.save(new Member("member3", 10));
          memberRepository.save(new Member("member4", 10));
          memberRepository.save(new Member("member5", 10));

          int age = 10;
          PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

          Page<Member> page = memberRepository.findByAge(age, pageRequest);
      }

3-1. MemberRepository에서 메서드를 선언할때, 메서드의 반환타입에 따라서 count쿼리를 날릴지 안날릴지 결정된다.

  • Page -> 추가 count 쿼리를 포함하는 페이징.
  • Slice -> 추가 count 쿼리를 포함하지않는 페이징.(limit+1로 다음 페이지 여부 확인 가능)
    -> ex) 예를들어 모바일 화면에서 보면, 페이지 번호 없이 [더보기] 버튼을 통해 다음 데이터를 보여준다거나, 스크롤을 했을때 자동으로 다음 데이터를 보여준다거나 할때 사용.
    -> ex) 예를들어 데이터를 11개를 가져온다음에 10개만 보여주고나서, 1개가 더 있으면 [더보기] 버튼을 통해 보여준다거나 할때 사용.
    -> 참고로, db에서 쿼리를 조회할때는 limit + 1로 해서 데이터를 4개를 가져오지만, 내부적으로는 원래 조회하려고 했던 데이터 수만큼만 조회가 되고, 나머지 한개는 hasNext판단용으로만 사용된다.
    -> 즉, db에서 쿼리를 조회할때는 limit + 1로 해서 원래 조회하려고 했던 데이터의 개수보다 하나 더 많은 데이터를 DB에서 가져오고, 가져온 데이터 중 원래 조회하려고 했던 데이터의 개수만큼만 page.getContent() 리스트에 포함시킨다. 나머지 한개는 다음 페이지여부 판단용으로만 사용되고 page.getContent()에는 들어가지않는다.
  • List -> 추가 count 쿼리 없이 결과만 반환 (limit/offset만 적용된 단순 조회)

3-2.

  • Page<Member> findByUsername(String name, Pageable pageable); //반환타입을 Page로 하면 count쿼리(페이지당 개수가 아니라 전체 데이터의 개수를 구하는 쿼리)가 같이 나간다.
  • Slice<Member> findByUsername(String name, Pageable pageable); //반환타입을 Slice로 하면 count쿼리(페이지당 개수가 아니라 전체 데이터의 개수를 구하는 쿼리)는 나가지않는다. limit + 1로 조회된다. 데이터를 한개 더 가져온다.
  • List<Member> findByUsername(String name, Pageable pageable); //반환타입을 List로 하면 count쿼리는 나가지않는다.
[Page]
	select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    where
        member0_.age=? 
    order by
        member0_.username desc limit 3


	select
        count(member0_.member_id) as col_0_0_ 
    from
        member member0_ 
    where
        member0_.age=10

[Slice]
	select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    where
        member0_.age=? 
    order by
        member0_.username desc limit 4

[List]
	select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.username as username3_0_ 
    from
        member member0_ 
    where
        member0_.age=? 
    order by
        member0_.username desc limit 3
  1. 참고로,

    @Query(value = "select m from Member m")
    Page<Member> findByAge(int age, Pageable pageable);
    

    -> 이렇게 하면 페이징 쿼리는 아래와 같이 된다.

        select
            member0_.member_id as member_i1_0_,
            member0_.age as age2_0_,
            member0_.team_id as team_id4_0_,
            member0_.username as username3_0_ 
        from
            member member0_ 
        order by
            member0_.username desc 
        limit 3

    즉, 개발자는 jpql을 직접 짜더라도, 반환타입과 Pageable이 있기 때문에 페이징에 대한 쿼리는 직접 안짜도 된다. 페이징에 대한 쿼리는 자동으로 붙여준다.

  2. 참고로, 전체 count 쿼리는 매우 무겁다. 그래서 만약에 count쿼리의 성능이 좋지않는다면 count쿼리만 따로 지정해줄 수 있다.
    예를들어

    SELECT *
    FROM A
    LEFT JOIN B ON A.id = B.a_id
    LIMIT ?, ?

    와 같이
    페이징 쿼리에서 WHERE조건이 없고, LEFT JOIN만 한다고 했을때, 전체 count쿼리에서는 left join을 하나 안하나 개수가 동일하기 때문에 left join을 안해도 된다.
    예를들어

    SELECT COUNT(*)
    FROM A
    LEFT JOIN B ON A.id = B.a_id

    SELECT COUNT(*)
    FROM A

    는 동일하기 때문이다.
    그래서 count쿼리를 따로 지정해줄 수 있다.

    @Query(value = "select m from Member m left join m.team t")
    Page<Member> findByAge(int age, Pageable pageable);

    -> 이렇게 하면, 페이징쿼리는 아래와 같이되고,

    	select
            member0_.member_id as member_i1_0_,
            member0_.age as age2_0_,
            member0_.team_id as team_id4_0_,
            member0_.username as username3_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id 
        order by
            member0_.username desc 
        limit 3

    전체 카운트 쿼리는 아래와 같이 된다.

    select
            count(member0_.member_id) as col_0_0_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id

    즉, count쿼리에서도 쓸데없이 left join을 한다.

    @Query(value = "select m from Member m left join m.team t", countQuery = "select count(m) from Member m")
    Page<Member> findByAge(int age, Pageable pageable);

    -> 하지만 이렇게 countQuery를 지정해주면,
    전체 count쿼리는 아래와 같이 된다.

    	select
            count(member0_.member_id) as col_0_0_ 
        from
            member member0_
  3. 참고로 @Query 없이 메서드이름만으로 쿼리를 생성할때, 어떤 엔티티에 대해 쿼리를 생성할지는 리포지토리의 제네릭 타입(extends JpaRepository<Member, Long>)을 보고 나서 결정된다.
    그래서, List<Member> findTop3By(); 라고 해도 Member에 대해 조회를하고, List<Member> findMemberTop3By()라고 해도 Member테이블에 대해 조회를 한다.
    Member findMemberByUsername(String name); 이것도 Member findByUsername(String name); 이렇게 해도된다.
    즉, 메서드 이름에 엔티티명을 꼭 넣을 필요는 없고, 제네릭이 기준이다.

  4. 참고로, stream의 map처럼, page.map()은 Page내부의 content(= List<Member> content)에 있는 각각의 데이터(Member)를 MemberDto로 변환해서 새로운 Page<MemberDto>로 반환한다.
    즉, Page내부의 content만 List<Member>에서 List<MemberDto>로 변환하고 기존 Page의 페이징 정보(totalElements, totalPages 등)는 그대로 유지하면서 새로운 Page 객체를 만들어 주는것이다.



벌크성 수정 쿼리

@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bultAgePlus(@Param("age) int age);
  • 벌크연산은 영속성 컨텍스트를 무시하고 실행하기 때문에, 영속성 컨텍스트에 있는 엔티티의 상태와 DB에 있는 엔티티의 상태가 달라질 수 있다.
  • @Modifying(clearAutomatically = true) // 해당 쿼리가 실행되고 나서 em.clear()를 해준다.

참고로 JPA는 JPQL쿼리가 실행되면 flush()가 자동으로 호출되고 나서 JPQL이 실행된다.



@EntityGraph

엔티티그래프는 연관된 엔티티를 SQL 한 번에 가져오기 위해 사용하며, 내부적으로 페치조인과 (거의) 동일한 SQL이 실행된다.

  1. @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll(); //JpaRepository에 있는 findAll()메서드를 오버라이드 
  • 기존에는 모든 Member만 조회하는 JPQL이 실행되는데, @EntityGraph(attributePaths = {"team"})을 사용하면 연관된 엔티티도 SQL한번에 가져온다.
  • attributePaths에는 조회하려는 엔티티가 가진 연관관계 필드 이름을 넣으면된다.
  • 참고로, 실행되는 JPQL은 그대로이다. 하지만 SQL로 변환될때 JPA가 LEFT OUTER JOIN을 사용해서 Member와 Team을 한번에 조회한다.
  1. @EntityGraph(attributePaths = {"team"})
    @Query("select m from Member m")
    List<Member> findMemberEntityGraph();

    이렇게도 가능하다.

  2. @EntityGraph(attributePaths = {"team"})
    List<Member> findEntityGraphByUsername(String username);

    이렇게도 가능하다. 실행하게 되면, 아래와 같은 SQL이 실행된다.

    	select
            member0_.member_id as member_i1_0_0_,
            team1_.team_id as team_id1_1_1_,
            member0_.age as age2_0_0_,
            member0_.team_id as team_id4_0_0_,
            member0_.username as username3_0_0_,
            team1_.name as name2_1_1_ 
        from
            member member0_ 
        left outer join
            team team1_ 
                on member0_.team_id=team1_.team_id 
        where
            member0_.username=?
    


JPA Hint

JPA 쿼리힌트는 JPA 구현체인 Hibernate에게 “이 쿼리를 이렇게 처리하라”라고 지시하는 옵션이다.

  1. @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
    List<Member> findReadOnlyByUsername(String username);
    • Hibernate에게 이 조회는 readOnly이므로 변경감지하지말라는 의미이다. 결과적으로 불필요한 스냅샷 생성을 방지해서 성능이 올라간다.
        @Test
        public void queryHint(){
            Member member1 = new Member("member1", 10);
            memberRepository.save(member1);
            em.flush();
            em.clear();
    
            Member findMember = memberRepository.findReadOnlyByUsername("member1");
            findMember.setUsername("member2");
        }
    • 그래서 조회한 엔티티에 대해서 findMember.setUsername("member2"); 를 하더라도, 변경감지 및 update쿼리가 실행되지않는다.
  2. @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true", forCounting = false))
    Page<Member> findByAge(int age, Pageable pageable);
    • 기본적으로 메서드의 반환타입이 Page이면 추가 count쿼리를 날리는데, @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true", forCounting = flase)) 라고 하면 추가 count쿼리는 날리지않는다.


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

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

@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{

    private final EntityManager em;

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

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

}
  • Spring Data JPA는 인터페이스(JpaRepository)를 기반으로 하는데, 특정 메소드는 직접만든 JPQL이나 복잡한 쿼리를 사용해야될 때가 있다. 그럴때 별도로 Custom Repository 인터페이스를 만들어서 사용한다.

  • 인터페이스 이름은 아무거나 해도 되지만, 구현 클래스 이름은 Repository 인터페이스 이름 + Impl로 해야한다.
    이렇게 하지않으면, 커스텀 레포지토리에서 만든 메서드를 사용할때 오류가 발생한다.
    구현 클래스에 대한 객체 생성 및 스프링빈으로 등록되지않아서 메서드를 사용할 수 없다. 이 규칙대로 해야만 @Repository 또는 @Component 어노테이션이 없어도 Spring Data JPA가 자동으로 인식해서 객체를 생성하고 스프링빈으로 등록한다.
    MemberRepository에서 MemberRepositoryCustom을 상속받았기 때문에 MemberRepositoryCustom의 구현 클래스 이름은 해당 Custom 인터페이스를 상속받은 Repository 인터페이스 이름 + Impl로 해야한다.
    이렇게 하면 MemberRepository를 통해 MemberRepositoryCustom에 있는 메서드를 사용할 수 있다.

  • 즉, MemberRepository를 통해서 MemberRepositoryCustom인터페이스에 있는 메서드를 사용하려면 MemberRepositoryCustom의 구현 클래스에 대한 객체 생성 및 스프링빈으로 등록되어 있어야한다.

  • Spring Data JPA는
public interface MemberRepository extends JpaRepository<Member, Long> {
      Page<Member> findByAge(int age, Pageable pageable);
}

개발자는 메서드 시그니처만 정의하고 구현은 따로 하지않는다.
Spring Data JPA는 Repository 인터페이스에 메서드 시그니처만 정의하면, 메서드가 호출될 때 내부적으로 필요한 작업을 수행하고, 선언된 반환 타입에 맞게 값을 리턴해준다.

  • 참고로, Spring Data JPA는 Page<T> 처럼 반환 타입이 인터페이스인 경우에는, 내부적으로 return new PageImpl<>(content, pageable, total); 처럼 Page인터페이스의 구현체인 PageImpl을 리턴한다.
    • content : 현재 페이지 데이터
    • pageable : 페이지 정보
    • total : 전체 데이터 개수


Auditing

@column(updatable=false)
private String username;

User user = userRepository.findById(1L).get();
user.setUsername("NewName");
  • @column(updatable=false) : setUsername로 값을 변경하면 값은 바뀌고, 변경감지는 일어나지만 UPDATE SQL에 변경된 값을 UPDATE하지않는다. updatable=false인 컬럼은 UPDATE 절에서 제외한다. 한번 저장 후 수정하면 안 되는 정보이므로 UPDATE SQL에서 제외시키고, 개발자의 실수로 값이 변경되지 않도록 막기 위해서 이렇게 사용한다.
  • @PrePersist 메서드는 save()나 persist()를 호출해서, 영속성 컨텍스트에 엔티티를 저장하기 직전에 호출된다.
  • @PreUpdate 메서드는 엔티티를 수정해서, 엔티티의 변경 감지가 일어나고 트랜잭션 커밋 또는 em.flush()시점에 UPDATE쿼리가 실행되는데, 이 UPDATE쿼리가 실행되기 직전에 호출된다.
  • @PostPersist 메서드는 INSERT쿼리가 실행되고 난 후에 호출된다.
  • @PostUpdate 메서드는 UPDATE쿼리가 실행되고 난 후에 호출된다.

Auditing(=스프링데이터 Auditing) : 스프링데이터JPA가 등록날짜/수정날짜/등록자/수정자 값을 자동으로 처리해주는것.

스프링데이터JPA가 등록날짜/수정날짜 값을 자동으로 처리해주는 방법
1. 엔티티에 @EntityListeners(AuditingEntityListener.class)가 있어야한다.
2. 스프링 부트 설정 클래스(@SpringBootApplication)에 @EnableJpaAuditing가 있어야한다.
엔티티의 필드에 @CreatedDate가 있으면, save()나 persist()를 호출해서, 영속성 컨텍스트에 엔티티를 저장하기 직전에 현재시간(LocalDateTime.now())을 @CreatedDate필드에 넣어준다.
엔티티의 필드에 @LastModifiedDate가 있으면, 엔티티를 수정해서, 엔티티의 변경 감지가 일어나고 트랜잭션 커밋 또는 em.flush()시점에 UPDATE쿼리가 실행되는데, 이 UPDATE쿼리가 실행되기 직전에 현재시간(LocalDateTime.now())을 @LastModifiedDate필드에 넣어준다.

스프링데이터JPA가 작성자/수정자 값을 자동으로 처리해주는 방법
1. 엔티티에 @EntityListeners(AuditingEntityListener.class)가 있어야한다.
2. 스프링 부트 설정 클래스(@SpringBootApplication)에 @EnableJpaAuditing가 있어야한다.
3. AuditorAware<String> 구현체가 스프링 빈으로 등록되어 있어야 한다.
엔티티의 필드에 @CreatedBy이 있으면, save()나 persist()를 호출해서, 영속성 컨텍스트에 엔티티를 저장하기 직전에 스프링이 내부적으로 AuditorAware.getCurrentAuditor() 메서드를 실행하고, 반환한 Optional안의 실제 값을 꺼내서 @CreatedBy필드에 넣어준다.
엔티티의 필드에 @LastModifiedBy이 있으면, 엔티티를 수정해서, 엔티티의 변경 감지가 일어나고 트랜잭션 커밋 또는 em.flush()시점에 UPDATE쿼리가 실행되는데,
이 UPDATE쿼리가 실행되기 직전에 스프링이 내부적으로 AuditorAware.getCurrentAuditor() 메서드를 실행하고, 반환한 Optional안의 실제 값을 꺼내서 @LastModifiedBy필드에 넣어준다.



Web 확장 - 페이징과 정렬

@Getmapping("/members")
public Page<Member> list(Pageable pageable) {
	Page<Member> page = memberRepository.findALl(pageable);
    return page;
}
  • 스프링은 파라미터로 Pageable을 해놓으면, 요청파라미터를 보고 자동으로 Pageable구현객체를 생성해준다. 하지만 /members 라고만 해도 기본값인 page=0, size=20으로 Pageable구현객체를 생성한다. 즉, 첫번째 페이지에서 20개를 가져온다.


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

@Transactional
    public <S extends T> S save(S entity) {
        Assert.notNull(entity, "Entity must not be null.");
        if (this.entityInformation.isNew(entity)) {
            this.em.persist(entity);
            return entity;
        } else {
            return this.em.merge(entity);
        }
    }

@Entity
@Getter
public class Item {

    @Id @GeneratedValue
    private Long id;
}

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

Spring data JPA는 save()를 호출 할때 새로운 엔티티라고 판단하면 em.persist()를 호출하고, 그게 아니면 em.merge()를 호출한다.

새로운 엔티티를 판단하는 기본 전략

  • 식별자인 pk가 객체일 때 null로 판단
  • 식별자인 pk가 기본타입일 때 0으로 판단
  • Persistable 인터페이스를 구현해서 판단 로직 변경 가능

하지만 만약에 @GeneratedValue와 같이, 저장할 때 id가 할당되는것이 아니라, @GeneratedValue를 사용하지않고 개발자가 직접 id값을 지정해준 다음에 save()를 하게되면, Spring data JPA는 save()를 할때 새로운 엔티티라고 판단하지않고 em.merge()를 하게된다.
참고로 em.merge()는 해당 엔티티가 이미 DB에 있으면 UPDATE하고, 없으면 em.persist()한다. 그래서 있으면 SELECT 쿼리 호출 후 UPDATE 하고, 없으면 SELECT 쿼리 호출 후 INSERT 한다.
==> 이게 문제이다. 새로운 엔티티라고 판단하지도 않고, SELECT 쿼리를 호출했다가 INSERT 하는것이 문제.
이러한 경우 Persistable 인터페이스를 구현해서 판단 로직을 변경할 수 있다. 새로운 엔티티 판단 기준을 ID가 아닌 isNew()로 제어한다.

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

    @Id @GeneratedValue
    private String id;

    @CreatedDate
    private LocalDateTime createdDate;

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

    @Override
    public String getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return createdDate == null; // createdDate가 null이면 새로운 엔티티
    }
}

Persistable인터페이스를 구현하고, getId()와 isNew()를 재정의한다. getId()의 리턴은 엔티티의 식별자 필드를 반환하면 되고, isNew()의 리턴은 새 엔티티인지 판단기준을 개발자가 직접 주면된다.
이렇게 하게 되면 save()를 호출 할떄 is New()가 먼저 호출되고, isNew()가 true이면 em.persist()를 호출하고, false이면 em.merge()를 하게된다.

0개의 댓글