seungriyou / spring-study

자바 스프링 부트를 배워봅시다 🔥
0 stars 0 forks source link

[강의 정리] 06. 실전! 스프링 데이터 JPA #12

Open seungriyou opened 8 months ago

seungriyou commented 8 months ago

실전! 스프링 데이터 JPA

Contents


H2 URL

jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/06-spring_data_jpa/data-jpa/data-jpa


Main Repository Code

https://github.com/seungriyou/spring-study/blob/main/06-spring_data_jpa/data-jpa/src/main/java/study/datajpa/repository/MemberRepository.java

seungriyou commented 8 months ago

1. 프로젝트 환경설정

프로젝트 생성

프로젝트 생성

image


롬복 적용

IntelliJ 설정에서 롬복 사용 설정


라이브러리


스프링 데이터 JPA와 DB 설정, 동작 확인

JPA 기억할 점 📌


쿼리 파라미터 로그 남기기

두 가지 방법이 있는데, 외부 라이브러리를 사용하는 방법을 선택하겠다.

  1. application.yml 설정 추가
```yaml
logging.level:
  org.hibernate.type: trace
```
  1. 외부 라이브러리 사용

    • https://github.com/gavlyukovskiy/spring-boot-data-source-decorator
    • p6spy의 latest release version을 확인하고, build.gradle에 다음과 같이 추가한다. (현재는 1.9.1)

      implementation("com.github.gavlyukovskiy:p6spy-spring-boot-starter:${version}")

      혹은

      implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'


[!caution] 운영에서 사용하려면 로그를 많이 남기기 때문에 성능 테스트를 수행해야 한다.

seungriyou commented 8 months ago

2. 예제 도메인 모델

image

seungriyou commented 8 months ago

3. 공통 인터페이스 기능

[!note] JPA에서 수정은 리포지토리 안에 따로 메서드를 만들 필요 없이, 변경 감지 기능을 사용하면 된다. (dirty checking)

트랜잭션 안에서 엔티티를 조회한 다음 데이터를 변경하면, 트랜잭션 종료 시점에 변경 감지 기능이 작동하여 변경된 엔티티를 감지하고 UPDATE 쿼리를 실행한다.


공통 인터페이스 설정

JavaConfig 설정 (스프링 부트 사용 시 생략)


스프링 데이터 JPA가 구현 클래스를 생성해준다

[!tip] 개발자가 인터페이스만 선언해두면, 스프링 JPA가 구현체를 만들어서 주입해준다.


공통 인터페이스 적용

JpaRepository<entity-class, entity-pk-type>

JPA로 작성한 코드의 테스트 코드를 그대로 복사 붙여넣기 하면 동작한다!


공통 인터페이스 분석


주의할 점


제네릭 타입


주요 메서드

JpaRepository는 대부분의 공통 메서드를 제공한다.

  • save(S): 새로운 엔티티는 저장, 이미 있는 엔티티는 병합
  • delete(T): 엔티티 하나를 삭제

내부에서 em.remove() 호출

  • findById(ID): 엔티티 하나 조회

내부에서 em.find() 호출

  • getOne(ID): 엔티티를 프록시로 조회

내부에서 em.getReference() 호출

  • findAll(...): 모든 엔티티 조회

정렬(Sort)이나 페이징(Pageable) 조건을 파라미터로 제공

seungriyou commented 8 months ago

4. 쿼리 메소드 기능

쿼리 메소드 기능 사용하는 세 가지 방법

쿼리 메소드 기능 세 가지

  1. 메소드 이름으로 쿼리 생성
  2. 메소드 이름으로 JPA NamedQuery 호출
  3. @Query 어노테이션을 사용해서 리포지토리 인터페이스에 쿼리 직접 정의

[1] 메소드 이름으로 쿼리 생성 (✅)

조건 2개까지는 이 방법으로 할만하다!


[!caution] 엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 어플리케이션 로딩 시점에 오류가 발생한다.

→ 스프링 데이터 JPA의 큰 장점!


[2] 메소드 이름으로 JPA NamedQuery 호출

실무에서 거의 사용하지 X

어플리케이션 로딩 시점에 NamedQuery들은 한 번 파싱이 되기 때문에, 일반 문자열로 작성했던 JPQL과는 다르게 오류가 있으면 해당 시점에 알 수 있다는 장점이 있다.

하지만 해당 장점은 그대로 가지고 있으면서도, 리포지토리 메서드에 바로 쿼리를 작성할 수 있는 다음 방식이 훨씬 편리하므로 실무에서 거의 사용할 일이 없다.


[!note] 명확하게 @Param()을 적는 경우?

→ 명확하게 JPQL이 있고, 내부에서 :username과 같이 파라미터를 사용할 때! (이름으로 매칭시켜 준다.)

→ 메서드 이름으로 쿼리 생성할 때는 쓰지 않아도 된다!


[3] @Query: 리포지토리 메서드에 쿼리 정의하기 (✅)

// <3> @Query: 리포지토리 메서드에 쿼리 정의하기
// JPQL을 인터페이스 메서드에 작성할 수 있다. (실무에서 많이 쓴다!)
@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);


[!tip] 간단한 쿼리는 <1>번 방법으로, 조금 복잡한 쿼리는 <3>번 방법으로 간단한 메서드 이름을 부여해서 사용한다.

동적 쿼리는 QueryDSL을 사용하자.





반환 타입

전체 반환 타입: https://docs.spring.io/spring-data/jpa/reference/repositories/query-return-types-reference.html#appendix.query.return.types

스프링 데이터 JPA에서는 같은 동작에 대해서 반환 타입을 다음 세 가지 중 아무거나 적어도 된다.

List<Member> findByUsername(String name); // 컬렉션
Member findByUsername(String name); // 단건
Optional<Member> findByUsername(String name); // 단건 Optional


조회 결과가 많거나 없다면?



페이징과 정렬

[1] 순수 JPA

// 페이징 및 정렬
public List<Member> findByPage(int age, int offset, int limit) {
    return em.createQuery("select m from Member m where m.age = :age order by m.username desc")
            .setParameter("age", age)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

public long totalCount(int age) {
    return em.createQuery("select count(m) from Member m where m.age = :age", Long.class)
            .setParameter("age", age)
            .getSingleResult();
}


[2] 스프링 데이터 JPA


페이징과 정렬은 다음과 같이 4가지 예제로 살펴볼 수 있다.

  1. Page<Member> findByUsername(String name, Pageable pageable)

    → count 쿼리 사용

    @Test
    public void paging() {
        // given
        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;
        // username 기준 desc 정렬
        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Direction.DESC, "username"));
    
        // when
        Page<Member> page = memberRepository.findByAge(age, pageRequest);
    
        // then
        List<Member> content = page.getContent();
    
        assertThat(content.size()).isEqualTo(3);
        assertThat(page.getTotalElements()).isEqualTo(5);
        assertThat(page.getNumber()).isEqualTo(0);
        assertThat(page.getTotalPages()).isEqualTo(2);
        assertThat(page.isFirst()).isTrue();
        assertThat(page.hasNext()).isTrue();
    }
    • Pageable인터페이스이며, 실제로 사용할 때는 주로 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다.
    • PageRequest 생성자의 파라미터

      페이지는 0부터 시작한다!!

      • 첫 번째 파라미터 pageNumber: 현재 페이지
      • 두 번째 파라미터 pageSize: 조회할 데이터 수
      • 추가 파라미터: 정렬 정보
    • count 쿼리가 사용된다.
  2. Slice<Member> findByUsername(String name, Pageable pageable)

    → count 쿼리 사용 X

    // when
    Slice<Member> page = memberRepository.findByAge(age, pageRequest);
    
    // then
    List<Member> content = page.getContent();
    
    assertThat(content.size()).isEqualTo(3);
    assertThat(page.getNumber()).isEqualTo(0);
    assertThat(page.isFirst()).isTrue();
    assertThat(page.hasNext()).isTrue();
    • limit에 1개를 더 요청해온다.

      • pageSize=3으로 설정했으나, 4 rows를 가져온다.
      • limit보다 하나 더 있으면 더보기 버튼으로 넘기는 식으로 활용한다.
      select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username from member m1_0 where m1_0.age=10 order by m1_0.username desc offset 0 rows fetch first 4 rows only;
    • count 쿼리가 나가지 않는다.

  3. List<Member> findByUsername(String name, Pageable pageable)

    → count 쿼리 사용 X

    • pageSize 만큼의 결과를 일반 list로 반환받을 수도 있다.
    • count 쿼리가 나가지 않는다.
    • 여러 기능들이 제공되지 않는다.
  4. List<Member> findByUsername(String name, Sort sort)


[!tip] 실무에서 count 쿼리의 성능 관련 조언

totalCount를 세는 count 쿼리는 조인을 할 필요가 없는 경우(ex. 모두 left outer join인 경우)가 있어 최적화가 가능할 수 있다. 이러한 상황을 위해 count 쿼리를 countQuery로 분리할 수도 있다.

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


[!tip] Sort 조건이 너무 복잡해지면 그냥 @Query로 빼서 쓰자.


[!warning] API에서 반환하기 전에 엔티티를 DTO로 변환해야 한다. 이때, Page를 유지하면서 엔티티를 DTO로 변환하는 방법은?

map을 사용하자!

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

// entity -> dto 변환
// dto로 변환 후에는 Page로 그대로 반환해도 ok
Page<MemberDto> toMap = page.map(m -> new MemberDto(m.getId(), m.getUsername(), null));



[!note] Top, First 사용은 다음을 참고한다.

https://docs.spring.io/spring-data/jpa/reference/repositories/query-methods-details.html#repositories.limit-query-result


벌크성 수정 쿼리

ex) 모든 직원들의 연봉을 10% 인상

[1] JPA

public int bulkAgePlust(int age) {
    int resultCount = em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
            .setParameter("age", age)
            .executeUpdate();
    return resultCount;
}


[2] 스프링 데이터 JPA

// 벌크성 수정 쿼리
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);


[!warning] 같은 트랜잭션 안이면 같은 엔티티 매니저가 동작한다.


[!caution] 스프링 데이터 JPA와 JDBC 템플릿, 순수한 JDBC, MyBatis 같은 것을 섞었을 때도 영속성 컨텍스트와 DB가 동기화되지 않으므로 flush 및 clear 작업이 필요하다.



@EntityGraph

연관된 엔티티들을 SQL 한 번에 조회하는 방법이다. (→ 페치 조인)

N+1 문제

memberteam은 지연로딩 관계이므로, team의 데이터를 조회할 때마다 쿼리가 실행되게 된다.

  • LAZY 로딩으로 설정된 필드 team에 대해서는 프록시 객체로 가져온다.
  • 그리고 해당 필드 team의 데이터에 접근할 때, 실제로 다시 쿼리를 날려서 가져온다. (프록시 초기화)
  • JPA에서는 이를 페치 조인으로 해결했다.


스프링 데이터 JPA에서 페치 조인 사용하기

  1. 스프링 데이터 JPA에서도 @Query에 작성하는 JPQL에 join fetch를 사용하면 페치 조인을 사용할 수 있다.

    쿼리 한 번에 전부 다 끌고 온다! team 엔티티까지 생성해서..!

    @Query("select m from Member m left join fetch m.team")
    List<Member> findMemberFetchJoin();
    select
        m1_0.member_id,
        m1_0.age,
        t1_0.team_id,
        t1_0.name,
        m1_0.username 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.team_id=m1_0.team_id
  2. 스프링 데이터 JPA에서 1번과 같은 JPQL 없이 페치 조인(객체 그래프) 을 사용하려면? @EntityGraph를 사용할 수 있다!

    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();

    이렇게 해도 동일하게 내부적으로 페치 조인을 사용하는 것을 확인할 수 있다.

    select
        m1_0.member_id,
        m1_0.age,
        t1_0.team_id,
        t1_0.name,
        m1_0.username 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.team_id=m1_0.team_id


@EntityGraph 사용 예시

다음과 같이 JPQL과 함께 사용할 수도 있고, 메서드 이름을 이용한 쿼리 메서드에서도 사용할 수 있다.

// (1) 공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

// (2) JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

// (3) 메서드 이름으로 쿼리할 때에 특히 편리함
@EntityGraph(attributePaths = {"team"})
List<Member> findEntityGraphByUsername(@Param("username") String username);


@EntityGraph 정리


@NamedEntityGraph 사용 방법

다음과 같이 이름을 부여해서 사용할 수도 있다.

  • 엔티티
```java
@Entity
...
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode(("team")))
public class Member {
```


[!tip] 강사님 조언

  1. 간단할 때는 @EntityGraph(attributePaths = {})를 사용한다.
  2. 복잡해지면 JPQL에서 페치 조인을 사용한다.



JPA Hint & Lock

JPA Hint

JPA 쿼리 힌트 (SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)

JPA에서는 엔티티를 조회한 후 데이터를 수정하면 변경 감지(dirty checking) 를 통해 update 쿼리를 실행한다.

즉, JPA에서는 엔티티를 조회하면 항상 변경 감지를 위해 원본(스냅샷)을 따로 확보하고, 추후 원본과 엔티티를 비교하는 등 비용이 발생한다.

하지만 만약, 데이터를 변경할 목적이 아니고 단순히 조회만 하려 한다면 최적화가 가능하다!

바로, @QueryHints를 사용하는 것이다! (이는 JPA 표준에서는 제공하지 않지만 하이버네이트에서 제공한다.)

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);

이를 사용하면, 아예 변경 감지 체크를 하지 않는다. 따라서 조회한 엔티티의 값을 수정해도 update 쿼리가 나가지 않는다.

Member findMember = memberRepository.findReadOnlyByUsername("member1");
findMember.setUsername("member2");

em.flush(); // 변경 감지(dirty checking) X -> update query X


Page에 사용하는 예시는 다음과 같다. 이때, forCounting은 추가로 호출되는 페이징을 위한 count 쿼리도 쿼리 힌트를 적용할지 지정하는 값이다. (default: true)

@QueryHints(value = {@QueryHint(name = "org.hibernate.readOnly",
                                  value = "true")},
             forCounting = true)
 Page<Member> findByUsername(String name, Pageable pageable);


[!note] 일일이 공수를 들여서 넣을 필요는 없다. 정말 트래픽이 많은 api 몇 개만 넣는 것이다! 성능 테스트를 해보고 얻는 이점이 있는지를 따져봐야한다.

또한, 조회 성능이 부족하면 캐시/레디스를 도입해야 한다.

처음부터 이러한 튜닝을 전부 도입하는 것은 항상 좋지는 않다!


Lock

[!note] 트랜잭션, 락 → 낙관적 락/비관적 락….

DB에서 select 시 lock을 거는 것 (select for update)

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=? for update

끝에 update가 나가게 된다! (?)


[!tip] 실시간 트래픽이 많은 서비스에서는 가급적이면 lock을 걸면 안 된다! 걸려면 비관적 락 말고 낙관적 락으로 풀거나, 혹은 락 없이 다른 방법을 찾아본다.

하지만 실시간 트래픽이 그렇게 많지 않고, 그것보다는 정합성이 더 중요한 경우는 비관적 락, select for update 등을 사용하는 것을 권장한다.

seungriyou commented 8 months ago

5. 확장 기능

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

스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많다!

다음과 같은 다양한 이유로 인터페이스의 메서드를 직접 구현하여 확장하고 싶다면?

실무에서 굉장히 많이 사용하는 방법이다!



[!tip] 실무에서는 간단한 것은 스프링 데이터 JPA가 제공하는 기본 기능으로 쓰고, 복잡한 쿼리는 QueryDSL이나 SpringJdbcTemplate을 사용하는데, 이때 을 이렇게 확장하여 사용한다!

이때, 간단한 것이란 (1) 메서드 이름으로 해결이 되거나, (2) @Query로 해결이 되는 경우이다.


[!caution] 항상 사용자 정의 리포지토리가 필요한 것은 아니다. 그냥 임의의 리포지토리를 클래스로 따로 만들어도 된다.

핵심 비즈니스 로직이 있는 리포지토리화면에 맞춘 DTO들에 대한 리포지토리(ex. MemberQueryRepository)는 분리하는 편을 권장한다!

그리고 필요한 곳에서 해당 리포지토리를 주입 받아서 사용하면 된다.

@Repository
@RequiredArgsConstructor
public class MemberQueryRepository {

    private final EntityManager em;

    List<Member> findAllMembers() {
        return em.createQuery("select m from Member m")
                .getResultList();
    }
}

이렇게 사용자 정의 리포지토리를 구현하게 되면, 이는 결국 멤버 리포지토리가 이 기능을 가지고 있는 것이다. 다음과 같은 이유로 인해 분리하는 것을 권장한다.

  • 화면에 맞춘 복잡한 쿼리들은 그거 자체만으로도 이해하기 굉장히 힘들다.
  • 수정의 라이프 사이클이 핵심 비즈니스 로직과 화면용 쿼리가 다르다.

사용자 정의 리포지토리에 모든 것을 다 몰아 넣는 것은 XXX


[!note] 아키텍처적인 고민 (어플리케이션이 커질수록)

  • 커맨드와 쿼리 분리
  • 핵심 비즈니스 로직과 복잡한 화면용 쿼리 분리
  • 라이프 사이클


Auditing

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

[!tip] 강사님은 ✅ 한 요소들은 모든 테이블에 다 적용한다고 한다.


[1] 순수 JPA 사용

@MappedSuperclass & 상속을 활용한다.

JPA 주요 이벤트 어노테이션

  • @PrePersist, @PostPersist
  • @PreUpdate, @PostUpdate
@MappedSuperclass
@Getter
public class JpaBaseEntity {

    @Column(updatable = false)  // 실수로 수정하더라도 update X
    private LocalDateTime createdDate;
    private LocalDateTime updatedDate;

    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        createdDate = now;  // 쿼리할 때 null이 있으면 굉장히 귀찮아진다!
        updatedDate = now;
    }

    @PreUpdate
    public void preUpdate() {
        updatedDate = LocalDateTime.now();
    }
}
public class Member extends JpaBaseEntity { }
@Test
public void JpaEventBaseEntity() throws InterruptedException {
    // given
    Member member = new Member("member1");
    memberRepository.save(member);  // @PrePersist

    Thread.sleep(100);  // 원래 테스트에서는 sleep 거는 것은 좋지 않다!
    member.setUsername("member2");

    em.flush(); // @PreUpdate
    em.clear();

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

    // then
    System.out.println("findMember.getCreatedDate() = " + findMember.getCreatedDate());
    System.out.println("findMember.getUpdatedDate() = " + findMember.getUpdatedDate());
}


[2] 스프링 데이터 JPA 사용


[!tip] 실무에서는 대부분의 엔티티는 등록시간, 수정시간이 필요하지만, 등록자, 수정자는 필요 없을 수도 있다. 이런 경우에는 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속하도록 하자.

public class BaseTimeEntity {
     @CreatedDate
     @Column(updatable = false)
     private LocalDateTime createdDate;
     @LastModifiedDate
     private LocalDateTime lastModifiedDate;     
}

public class BaseEntity extends BaseTimeEntity {
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    @LastModifiedBy
    private String lastModifiedBy;
}


[!note] 현재는 저장 시점에 수정 관련 데이터도 저장된다. 이는 중복 저장 같아 보이지만, 이렇게 해두면 변경 컬럼만 확인해도 마지막 업데이트 유저를 찾을 수 있으므로 유지보수 관점에서 편리하다. 또한, null 처리를 하지 않아도 된다.

권장하는 방법은 아니지만, 저장 시점에 저장 데이터만 입력하고 싶다면 @EnableJpaAuditing(modifyOnCreate = false) 옵션을 사용한다.


전체에 적용하고 싶다면 META-INF/orm.xml을 작성하면 된다.


Web 확장

도메인 클래스 컨버터

HTTP 파라미터로 넘어온 엔티티의 id로 엔티티 객체를 찾아서 바인딩한다.

  • 도메인 클래스 컨버터 사용 전
```java
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Long id) {
    Member member = memberRepository.findById(id).get();
    return member.getUsername();
}
```

사용을 권장하지는 않는다.


페이징과 정렬

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

  • 컨트롤러
  • 파라미터로 Pageable을 받을 수 있다.
  • Pageable은 인터페이스, 실제로는 PageRequest 객체를 생성한다.
```java
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
    Page<Member> page = memberRepository.findAll(pageable);

    // 받아온 엔티티를 절대로 그냥 반환하면 안 된다!
    // DTO로 변환해야 한다.
    Page<MemberDto> map = page.map(MemberDto::new);

    return map;
}
```
seungriyou commented 8 months ago

6. 스프링 데이터 JPA 분석

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

스프링 데이터 JPA가 제공하는 공통 인터페이스의 구현체는 SimpleJpaRepository

@Repository

JPA 예외를 스프링이 추상화한 예외로 변환한다.


@Transactional 트랜잭션 적용**


save() 메서드**

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

새로운 엔티티면 저장(persist), 새로운 엔티티가 아니면 병합(merge) 한다.

이때, merge의 단점은 “DB select를 한 번 호출하여 값을 확인하고, 없으면 새로운 엔티티로 인지하여 persist 한다” 는 것이다. 그리고 데이터 전체를 교체하게 된다. (→ 비효율적)

업데이트는 가급적이면 merge로 하면 안되고, 변경 감지를 사용해야 한다.

merge는 영속 상태가 아니게 된(detached) 객체를 영속상태로 변경할 때 사용해야 한다.

merge는 사용할 일이 거의 없다!


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

객체의 @GeneratedValue id 값은 JPA에서 persist를 하면 생기게 된다.

따라서 새로운 Item 객체를 생성하여 save() 하는 코드를 디버깅하면 다음과 같이 확인할 수 있다.

entityManager.persist(entity); 코드 이후에는 id 값이 생성됨을 확인할 수 있다.


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

  1. JPA 식별자 생성 전략이 @GeneratedValue이면,

    • 식별자가 객체일 때: null로 판단

      ex. Long 타입

    • 식별자가 자바 기본 타입일 때: 0으로 판단

      ex. long 타입

  2. JPA 식별자 생성 전략이 @Id 만 사용해서 직접 할당이면, (= 이미 식별자 값 존재)

    • Persistable 인터페이스를 구현해서 판단 로직 변경 가능

      ex. id가 @GeneratedValue가 아니어서 새로 생성한 엔티티에도 이미 id 값이 들어가있는 상황이라면? 원래는 merge()가 실행될 것이다.

      다음과 같이 엔티티를 수정하고, getId()isNew()를 오버라이딩한다.

      💡 createdDate는 웬만해서는 필요하기 때문에, isNew()에서 주로 createdDatenull인지 여부로 새로운 객체인지를 판단하면 편리하다!

      @Entity
      @EntityListeners(AuditingEntityListener.class)
      @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 String getId() {
              return id;
          }
      
          @Override
          public boolean isNew() {
              return createdDate == null;
          }
      }

      다음과 같은 테스트 코드를 돌리게 되면, save() 메서드 내에서 isNew() 반환 결과에 따라, 이미 id 값이 존재하더라도 persist()가 수행될 수 있다.

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


      merge()는 우선 DB를 호출해서 값을 확인하고, DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율적이기 때문에 이런 방법을 사용한다!

seungriyou commented 8 months ago

7. 나머지 기능들

Specifications


Query By Example


Projections

select 절에 찍어서 가져올 필드라고 생각하면 된다.

ex) 전체 엔티티가 아니라 만약 회원 이름만 조회하고 싶다면?

  • 엔티티 대신에 DTO를 편리하게 조회할 때 사용한다.
  • 메서드 이름을 이용한 쿼리 메소드와도 함께 사용할 수 있다.


인터페이스 기반 Closed Projections

조회할 엔티티의 필드를 프로퍼티 형식(getter 형식) 으로 지정하면 해당 필드만 선택해서 조회된다.

public interface UsernameOnly {
    String getUsername();
}

메서드 이름은 자유이며, 반환 타입으로 인지한다.

List<UsernameOnly> findProjectionsByUsername(@Param("username") String username);

출력된 SQL문을 살펴보면 select 절에서 username만 조회(projection)하는 것을 확인할 수 있다. (→ 쿼리 최적화 O)

select m.username from member m
  where m.username=‘m1’;


인터페이스 기반 Open Projections

SpEL 문법도 지원한다.

public interface UsernameOnly {

    @Value("#{target.username + ' ' + target.age}") -> open projections
    String getUsername();
}

단, select 절에서 엔티티 필드를 모두 조회해온 후에 연산하므로, 쿼리 최적화는 X


클래스 기반 Projection

인터페이스가 아닌 구체적인 DTO 형식도 가능하다.

생성자의 파라미터 이름으로 매칭된다.

public class UsernameOnlyDto {

    private final String username;

    // 파라미터 명이 중요하다!
    public UsernameOnlyDto(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }
}

디버깅 해보면, 인터페이스 기반 프로젝션과는 다르게 프록시 X 구체 클래스 O


동적 Projection

다음과 같이 generic type을 주게 되면, 동적으로 프로젝션 데이터를 변경할 수 있다.

<T> List<T> findProjectionsByUsername(@Param("username") String username, Class<T> type);

다음과 같이 사용한다.

List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1", UsernameOnly.class);


중첩 구조 처리

public interface NestedClosedProjections {

    String getUsername();

    TeamInfo getTeam();

    interface TeamInfo {
        String getName();
    }
}


네이티브 쿼리

JPA를 사용한다면 가급적 사용하지 않는 것을 권장한다.

ex) JDBC template, MyBatis 등으로 SQL을 직접 작성하는 것


[!tip] 네이티브 SQL을 DTO로 조회할 때는 커스텀 리포지토리별도의 리포지토리를 만들어서 JdbcTemplate이나 MyBatis를 엮어서 사용하는 것을 권장한다.

사실 여기까지 안 가고 99%의 경우는 모두 JPQL, QueryDSL 정도에서 해결한다.


스프링 데이터 JPA 기반 네이티브 쿼리


Projection 이용 (→ DTO)

정적 쿼리를 네이티브로 짜야할 때는, 프로젝션을 통해 해결할 수 있다. (이름을 딱 매칭해주면 된다!)

DTO로 가져올 수 있고, 페이징도 가능하다. 하지만 동적 쿼리는 불가능하다.

public interface MemberProjection {

    Long getId();

    String getUsername();

    String getTeamName();
}
@Query(value = "select m.member_id as id, m.username, t.name as teamName" +
            " from member m left join team t on m.team_id = t.team_id",
            countQuery = "select count(*) from member", // 네이티브 쿼리이므로 countQuery 필요
            nativeQuery = true)
    Page<MemberProjection> findByNativeProjection(Pageable pageable);   // paging 가능


[!note] 강사님은 이런 경우에는 스프링 JDBC Template을 사용하신다고 한다. 하지만 프로젝션 기능도 편리할 것으로 보인다.