Backend/JPA

Spring Data Jpa

Zayson 2022. 8. 22. 16:24
  • EntityManager를 이용해 CRUD를 작성해보면 도메인에 상관없이 중복되는 로직이 발생한다.
public Team save(Team team) {
    em.persist(team);
        return team;
}

public Optional<Team> findById(Long id) {
        Team team = em.find(Team.class, id);
    return Optional.ofNullable(team);
}

public Member save(Member member) {
    em.persist(member);
        return member;
}

public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
    return Optional.ofNullable(member);
}

 

  • JPA가 제공하는 공통적인 기능을 인터페이스로 추출한 라이브러리가 Spring Data Jpa이다.
  • 도메인 레포지토리를 인터페이스로 선언하고 공통 인터페이스인 JpaRepository를 상속받는다.
  • public interface MemberRepository extends JpaRepository<Type, Id>
  • @Repository 생략이 가능하다.

인터페이스로 선언하고 별다른 구현체가 없어도 사용이 되는 이유는 Spring이 인터페이스를 보고 구현 클래스를 프록시 객체로 만들어서 주입하기 때문이다.

 

  • 도메인에 특화된 메서드를 사용하는 경우 레포지토리를 구현해서 사용하고자 한다면, 생성한 레포지토리 뿐만 아니라 상위의 JpaRepository, PagingAndSortingRepository, CrudRepsitory 등 든 상속받은 인터페이스를 구현해야하기 때문에 사실상 구현해 사용하는 방법은 불가능하다.

 

주요 메서드

  • save(S) : 엔티티를 저장하거나 병합
  • delete(T) : 엔티티 삭제, EntityManager.remove() 호출
  • findById(ID) : 특정 아이디와 매핑된 엔티티를 조회 EntityManager.find(); 호출
  • getOne(ID) : 엔티티를 프록시로 조회, EnttiyManager.getReference(); 호출
  • findAll(…) : 모든 엔티티 조회, Sort, Pageable 조건 파라미터 추가 가능

 

쿼리 메서드 기능

메서드 이름으로 쿼리를 생성

  • 메서드 이름을 관례에 맞게 설정한다.
    • 사용자의 이름과 나이가 15살보다 많은 사용자를 구한다.
//순수 JPA
public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
        return em.createQuery("select m from Member m where m.username = :username and m.age > :age", Member.class)
            .setParameter("username", username)
            .setParameter("age", age)
            .getResultList();
}

// Spring Data Jpa
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);

 

  • 정확한 프로퍼티명을 작성해줘야한다. 프로퍼티명이 잘못된 경우 예외가 발생한다.
  • 파라미터가 증가하거나 조건이 변경되게 되면 메서드를 새로 생성해야하고, 메서드 명이 길어지는 단점이 있다.
  • 파라미터를 많이 사용하고 메서드 명이 길어지는 경우 다른 방법을 사용하는 것을 권장한다.
  • 엔티티 필드명 변경 시 인터페이스에 정의한 메서드 이름 반드시 수정해야한다.
    • 수정을 안한 경우 애플리케이션 실행 시 프로퍼티를 찾을 수 없는 오류가 발생하기 때문에 큰 장애로 번지는 것을 막아준다.

Spring Reference Docs : https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation

 

메서드 이름 필터 조건 및 기능

  • 조회 : find(XXX)By, read(XXX)By, query(xxx)By, get(xxx)By
  • 카운트 : count(xxx)By → return long
  • Exists : exists(xxx)By → return boolean
  • 삭제 : delete(xxx)By, remove(xxx)By → return long
  • Distinct : findDistinct, find(xxx)DistinctBy
  • Limit : findFirst, findTop

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

 

JPA 네임드 쿼리

  • @NamedQuery를 엔티티에 작성하고, Repository에 @Query를 이용해 네임드 쿼리를 매핑한다.
  • JPQL에 :파라미터가 명확하게 전달되는 경우 @Param을 이용해 파라미터를 매핑한다.
@NamedQuery(
        name="Member.findUsername",
        query="select m from Member m where m.username = :username"
)
public class Member { ... }

// JPQL에 :파라미터가 명확하게 전달된 경우 @Param을 반드시 이용한다.
@Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);

 

  • @Query를 생략해도 동작하는 이유는 우선순위가 NamedQuery → 메서드 이름 쿼리 생성 순서이기 때문이다.
  • 네임드 쿼리는 애플리케이션 로딩 시점에 파싱하기 때문에 잘못된 프로퍼티인 경우 오류를 발생시킨다.
  • 실무에서 거의 사용하지 않는다.

 

🔥 @Query를 이용해 레포지토리 메서드에 쿼리 정의

  • @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);

 

  • 메서드 명이 길어지는 경우 직접 JPQL을 작성해 메서드 명을 짧게 지정할 수 있다.
  • 복잡한 쿼리의 경우 JPQL로 바로 정의하는 것이 가능하다.
  • @Query 내에 정의한 쿼리는 이름이 없는 네임드 쿼리로 정적 쿼리이기 때문에 애플리케이션 로딩 시점에 파싱해서 사용한다. 즉, 애플리케이션 로딩 시점에 프로퍼티가 없는 경우 에러를 발생시킨다는 장점이 있다.
  • 실무에서 가장 많이 사용하는 기능이다.

 

@Query, 값, DTO 조회

  • 특정한 값 혹은 Embedded 타입을 조회하는 경우 @Query에 쿼리를 작성한다.
// 실제 값으로 가져오기 -> username은 String 값(특정 값)
@Query("select m.username from Member m")
List<String> findUsernameList(); 

 

  • Dto를 조회하는 경우 반드시 Dto에 @AllArgsConstructor를 선언하거나 모든 필드를 가진 생성자를 반드시 만든다.
  • @Query 내에서 new 키워드를 통해 조회하고, Dto는 풀 패키지명까지 붙여 사용한다.
// DTO로 조회하기 (new 사용) -> 생성자 선언하는 것 처럼 사용
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();

 

파라미터 바인딩

  • 파라미터 바인딩은 위치 기반, 이름 기반을 지원하지만 이름 기반을 사용하도록 한다.
  • @Param을 이용해 @Query내 :파라미터에 바인딩 한다.
  • 컬렉션 타입을 IN절에 파라미터 바인딩 할 수 있다.
// IN 절에 컬렉션 바인딩 가능
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);

 

반환 타입

  • 유연한 반환 타입을 지원한다. (컬렉션, 단건(엔티티), Optional 등)
List<Member> findListByUsername(String username);   // 리스트
Member findMemberByUsername(String username); // 단일
Optional<Member> findOptionalByUsername(String username); // Optional

 

  • 컬렉션 반환 타입의 경우 데이터가 없다면 Empty Collection을 반환한다.
    • List는 Not Null을 보장한다.
  • 단건 조회의 경우 데이터가 없다면 null을 반환한다.
    • 순수 JPA의 경우 NoResultException이 발생
    • Spring Data JPA에서는 null로 반환
  • 따라서 단일 데이터는 Optional로 반환받는 것이 바람직하다.
    • null인 경우 Optional.empty를 반환
  • 단일 데이터 조회이지만 데이터가 한개 이상 조회되는 경우 예외가 발생한다.
    • JPA 예외가 발생하고 이를 Spring이 예외를 변환해서 발생시킨다.

 

🔥 페이징 및 정렬

  • 순수 JPA만 이용하는 경우 setFirstResult, setMaxResults를 이용해 페이징 처리를 한다.
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", Member.class)
               .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();
}

 

  • DB Dialect가 변경되더라도 DB 벤더에 맞는 쿼리가 발생한다.
  • Spring Data Jpa는 org.springframework.data.domain.Sort, Pageable페이징 공통화
  • 리턴 타입에 따라 TotalCount 쿼리의 호출 유무를 판단한다.
    • Page : TotalCount 쿼리를 추가로 호출한다.
    • Slice : TotalCount 쿼리를 호출하지 않는다. 무한 스크롤, 더보기 같은 페이징 처리에 사용한다.
    • List : TotalCount 쿼리를 호출하지 않는다.
  • 파라미터로 Pageable을 넘겨준다.
// 페이징
Page<Member> findByAge(int age, Pageable pageable);
Slice<Member> findByAge(int age, Pageable pageable);
List<Member> findByAge(int age, Pageable pageable);

 

  • Pageable은 Pageable의 구현체인 PageRequest.of()를 이용해 구현해 넘겨준다.
    • PageRequest.of(페이지 번호, 사이즈, [option] 정렬)
    • 페이지의 시작은 0페이지 부터 시작한다.
// 사용자 이름 내림차순으로 정렬
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

 

  • Page 리턴타입의 경우 페이징 쿼리와 TotalCount 쿼리가 함께 호출된다.
// 0 페이지에서 3개 가져오기
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

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

// then
List<Member> content = page.getContent();
long totalElements = page.getTotalElements();

assertThat(content.size()).isEqualTo(3);    // 현재 페이지 데이터 개수
assertThat(page.getTotalElements()).isEqualTo(5);   // totalCount
assertThat(page.getNumber()).isEqualTo(0);  // 가져온 페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2);  // 전체 페이지 개수
assertThat(page.isFirst()).isTrue();    // 첫번째 페이지인지?
assertThat(page.hasNext()).isTrue();    // 다음 페이지가 있는지?
assertThat(page.isLast()).isFalse();    // 마지막 페이지인지?

 

  • Slice 리턴 타입은 페이징 쿼리만 호출된다. (Page 리턴 타입과 동일한 쿼리 호출)

 

  • Slice는 Page의 상위 클래스이다.
  • 쿼리 호출 시에 size + 1 값을 limit에 넣어 가져온다.

 

  • TotalCount 쿼리가 실무에서는 데이터가 많으니 성능이 나오지 않는 경우가 있다.
  • 테이블을 조인해 페이징을 하게되는 경우 Page 리턴 타입의 경우 totalCount 쿼리 또한 함께 join되어 사용되기 때문에 성능적으로 문제가 생길 수 있다.
  • @Query의 countQuery 프로퍼티를 이용해 데이터를 조회하는 쿼리와 카운트 쿼리를 구분한다.
@Query(value = "select m from Member m left join m.team t", countQuery = "select count(m.username) from Member m")
Page<Member> findByAge(int age, Pageable pageable);

 

  • map()을 이용해서 가져온 엔티티 데이터를 Dto로 변환하는 것이 가하다.

 

벌크성 수정 쿼리

  • 한 번에 데이터를 수정하는 작업을 SQL에서는 한 번에 가능하지만 JPA는 객체 지향적이기 때문에 모든 객체에 대해 변경 감지가 필요하다.
  • 이를 SQL과 비슷하게 한번에 수정하는 연산을 벌크 연산이라고 한다.
// 벌크 연산 [순수 JPA] 
public int bulkAgePlus(int age) {
        return em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
            .setParameter("age", age)
          .executeUpdate();
}

 

  • Spring Data Jpa에서는 @Modifying을 이용해 executeUpdate() 메서드를 대체할 수 있다.
@Modifying(clearAutomatically = true) // JPA의 executeUpdate를 호출한다.
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

 

  • 벌크 연산은 DB에 바로 update연산을 하는 것이기 때문에 영속성 컨텍스트에 있는 데이터와 불일치가 발생할 수 있다.
  • 벌크 연산 이후에는 영속성 컨텍스트를 초기화 하는 것이 중요하다.
// 벌크 연산
int resultCount = memberRepository.bulkAgePlus(20);

// 영속성 컨텍스트에 반영 안된 작업 반영 및 초기화
em.flush();
em.clear();

 

  • 영속성 컨텍스트 초기화를 @Modifying의 clearAutomatically = true로 해주는 것으로 대체할 수 있다.

벌크 연산이외에도 MyBatis, JDBC Template 등과 같이 영속성 컨텍스트가 감지할 수 없는 쿼리를 날리는 로직 이전에는 기본적으로 영속성 컨텍스트를 flush하고 clear해주는 작업이 필요하다.

 

@EntityGraph

  • 모든 연관관계에 있는 엔티티는 지연 로딩을 기본으로 하는 것이 중요하다.
  • 지연 로딩 시 N + 1 문제가 발생할 수 있다.
  • fetch join을 이용해 N + 1 문제를 해결하는 것이 가능하다.
  • join이 많아지고 복잡한 쿼리를 사용해야할 땐 @Query에 JPQL로 fetch join을 작성해주면 되지만, 메서드 이름으로 쿼리를 생성하거나, 간단한 쿼리의 경우 JPQL을 추가적으로 작성하는 것은 비효율적이다.
  • @EntityGraph를 이용해 fetch join을 지원한다. @EntityGraph에 attributePaths를 통해 한 번에 조회할 엔티티를 지정한다.
// 페치조인 (연관된 엔티티 바로가져옴)
@Query("select m from Member m left join fetch m.team")
List<Member> findMEmberFetchJoin();

// 메서드 이름으로 쿼리 생성 경우
@Override
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

// fetch join까지 쿼리를 작성하지 않고 fetch join 하는 부분을 @EntityGraph로 구분
@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List<Member> findMemberEntityGraph();

 

  • @Named 쿼리와 비슷하게 @NamedEntityGraph를 엔티티에 선언하고 @EntityGraph에서 호출하는 것이 가능하다.
// NamedEntityGraph
@NamedEntityGraph(name = "MEmber.all", attributeNodes = @NamedAttributeNode("team"))
public class Member { ... }

// NamedEntityGraph 호출
@EntityGraph("Member.all")
List<Member> findEntityGraphByUsername(@Param("username") String username);

 

Hint와 Lock

  • SQL 힌트가 아닌 JPQ 구현체에게 제공하는 힌트
  • 영속성 컨텍스트 내 엔티티가 변경되는 경우 JPA가 flush 하는 시점에 감지해 update 쿼리를 날린다.
    • 변경 감지가 이뤄지기 위해선 원본 데이터에 대한 스냅샷이 필요하다.
    • 스냅샷으로 인한 메모리 비용이 추가로 발생한다.
  • 데이터 변경 목적의 조회가 아닌 100% 조회 용도 쿼리인 경우 성능 최적화를 위해 힌트를 사용한다.
  • 성능 테스트를 해보고 필요한 경우에만 사용한다.
// 힌트 사용 - 스냅샷을 만들지 않기 때문에 변경하더라도 변경감지가 발생하지 않는다. 
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);

 

  • @Lock을 통해 DB의 락 기능을 제공한다.
  • select for update 형태의 쿼리가 발생한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);

  • 나중에 공부 필요…!

 

확장 기능

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

  • JDBC, MyBatis 등 JPA 이외의 기능을 사용하고자 하는 경우 사용자 정의 리포지토리를 구현해서 사용한다.
  • QueryDSL을 사용할 때 많이 사용한다.
  • JpaRepository를 상속한 리포지토리 인터페이스를 직접 구현하는 것은 모든 메서드를 오버라이딩해서 구현해야하기 때문에 불가능하다.
  • 사용 방법
    1. Custom한 리포지토리 인터페이스를 만든다.
    2. Custom 리포지토리 인터페이스를 구현하는 구현 클래스를 만들고 메서드를 오버라이딩다.
    3. 실제 인터페이스로 사용되고 있는 리포지토리에서 Custom 리포지토리 인터페이스를 상속받는다.
// 사용자 정의 리포지토리 인터페이스 상속
public interface MemberRepository extends JpaRepository<Member, Long> , MemberRepositoryCustom { ... }

 

  • Custom 리포지토리를 구현한 클래스는 리포지토리 인터페이스 이름에 Impl을 붙여서 사용한다.
MemberRepository -> 실제 Spring Data Jpa를 이용하는 리포지토리 인터페이스
MemberCustomRepositor -> 사용자가 정의한 리포지토리 인터페이스
MemberRepositoryImpl -> 사용자가 정의한 리포지토리를 구현한 클래스 [MemberRepository + Impl]

 

  • Spring Data 2.X 부터 Custom 리포지토리의 인터페이스 명에 Impl을 붙히는 방식도 지원한다.
MemberCustomRepositor -> 사용자가 정의한 리포지토리 인터페이스
MemberCustomRepositoryImpl -> 사용자가 정의한 리포지토리를 구현한 클래스 [MemberRepository + Impl]
  • 핵심 비즈니스 로직 쿼리와 화면에 조회되는 쿼리를 구분하는 것이 중요하다.
  • 항상 사용자 정의 리포지토리가 필요한 것은 아니다. 임의의 리포지토리 클래스를 만들고 빈으로 등록해 사용하는 것도 가능하다.

 

🔥 Auditing

  • 모든 테이블을 생성할 때 등록일, 수정일, 등록자, 수정자를 기본적으로 등록해서 사용하고 싶을 때 자동화 하는 방식이다.
  • 순수 JPA를 이용하는 경우 BaseEntity를 생성한 후 @MappedSuperClass를 이용해 엔티티에서 속성만 상속받도록 구현한다.
  • @PrePersist @PreUpdate, @PostPersist, @PostUpdate
@Getter
@MappedSuperclass // 속성을 상속해주는 느낌
public class JpaBaseEntity {
    @Column(updatable = false)
    private LocalDateTime createdDate;
    private LocalDateTime updatedDate;

    // Persist 이전에 이벤트가 발생
    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        this.createdDate = now;
        this.updatedDate = now;
    }

        // update 이전에 이벤트가 발생
    @PreUpdate
    public void preUpdate() {
        updatedDate = LocalDateTime.now();
    }
}

 

  • @EnableJpaAuditing을 Application에 지정한다.
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication { ... }

 

  • 메서드를 통해 생성 시간, 수정 시간을 주입하던 부분을 @CreatedDate, @LastModifiedDate를 통해 해결할 수 있다.
  • 생성자, 수정자를 등록하는 @CreatedBy, @LastModifedBy를 지원한다.
  • 이벤트가 발생했을 때 감지하기 위해서 @EntityListener를 사용한다.
// 이벤트가 발생했을 때 사용되도록 하기 위해서 지정
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity{
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

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

    @LastModifiedBy
    private String lastModifiedBy;
}

생성 시간, 수정 시간은 일반적으로 모두 사용하지만, 생성자, 수정자는 테이블마다 케이스 바이 케이스이기 때문에 BaseTimeEntity를 생성하고, BaseEntity에는 등록자, 수정자만 두어 상황에 따라 상속받아 사용한다.

  • 등록자, 수정자는 String 타입임에도 자동으로 등록하는 것이 가능한데, AuditorAware 인터페이스를 구현해 기본으로 지정될 아이디를 등록하는 것이 가능하다. -> 실무에선 UUID아닌 개인의 세션 아이디로 사용될 듯..?
@Bean
public AuditorAware<String> auditorProvider() {
        // UUID를 등록자, 수정자로 주입되게 하는 코드
        return () -> Optional.of(UUID.randomUUID().toString());
}

 

도메인 클래스 컨버터

// 두개 모두 동일결과 반환
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Long id) {
        Member member = memberRepository.findById(id).get();
    return member.getUsername();
}

@GetMapping("/members2/{id}")
public String findMember2(@PathVariable("id") Member member) {
        return member.getUsername();
}
  • 요청은 id를 받지만 매핑되는 회원 엔티티로 자동으로 변경되어 사용할 수 있다.
  • 파라미터로 엔티티를 받는 경우 트랜잭션 내에서 관리되는 엔티티가 아니다 → 영속성 컨텍스트에서 관리되는 엔티티가 아니다.
  • 단순 조회용으로 사용한다. 별로 권장되지 않는다.

 

🔥 Web 계층 페이징 및 정렬

  • 페이징 및 정렬을 하기위해 쿼리파라미터를 직접 지정하는 것이 아닌 Pageable 인터페이스로 받는 것이 가능하다.
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
        Page<Member> page = memberRepository.findAll(pageable);
    return page;
}

// 요청예제
localhost:8080/members?page=0 // 첫번째 페이지(zero indexing) default 20건
localhost:8080/members?page=1&size=3  // 두번째 페이지 데이터 3건 조회
localhost:8080/members?page=1&size=3&sort=username,desc // 두번째 페이지 데이터 3건 이름 역순
localhost:8080/members?page=1&size=3&sort=id // 두번째 페이지 데이터 3건 PK기준 오름차순

 

  • application.yml에서 페이징 관련 default를 수정할 수 있다. (Global 설정)

 

  • @PageableDefault를 이용해 특정 API에만 페이징 관련 설정정보를 적용하는 것이 가능하다.
@GetMapping("/members")
public Page<Member> list(@PageableDefault(size = 5) Pageable pageable) {
        Page<Member> page = memberRepository.findAll(pageable);
      return page;

}

 

  • 페이징 정보가 둘 이상인 경우 @Qualifier에 접두사명을 추가해서 사용한다.
// request : localhost:8080/members?member_page=0&order_page=1
// [접두사_xxx] -> member_page -> member가 접두사
public String list(
        @Qualifier("member") Pageable memberPageable, 
        @Qualifier("order") Pageable orderPageable, ...
)

 

  • 페이징 처리에도 Dto를 이용해 반환해야한다.
@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;
}

 

  • Dto는 엔티티를 의존해도된다.
public MemberDto(Member member) {
    this.id = member.getId();
    this.username = member.getUsername();
}

@GetMapping("/members")
public Page<MemberDto> list(@PageableDefault(size = 5) Pageable pageable) {
        // 엔티티를 외부에 노출하면 안된다.
    return memberRepository.findAll(pageable).map(MemberDto::new);
}

 

  • Page를 1인덱싱 하는 방법
    • Pageable, Page를 응답값으로 사용하지 않고 직접 PageRequest를 생성하고, Custom한 Page객체를 만들어 응답한다.
    • spring.data.web.pageable.one-indexed-parameters: true 를 이용한다. 리턴 값의 Page 정보는 모두 0인덱싱 기준으로 리턴하기 때문에 사용하는 경우 이를 감안해야한다.

 

Spring Data JPA 구현체

  • SimpleJpaRepository는 Spring Data Jpa의 구현체이다.
  • @Repository : 스프링 빈으로 등록, JPA 예외를 스프링이 추상화한 예외로 변환
  • @Transactional : JPA의 모든 변경은 트랜잭션 내에서 동작되기 때문에 애노테이션 선언이 필요하다. (등록, 수정 , 삭제)
    • 서비스 계층에서 @Transactional을 지정하지 않는 경우 SimpleJpaRepository에 애노테이션이 선언되어 있으므로 리포지토리에서 트랜잭션을 시작한다.
    • 서비스 계층에서 @Transactional 지정하는 경우 해당 트랜잭션을 전파받아서 사용한다
  • @Transactional(readOnly = true) : 트랜잭션이 종료되면 DB에 반영하는 플러시를 실행시키지만, ReadOnly = true로 지정된 트랜잭션은 플러시를 호출하지 않는다.
  • save()는 기본적으로 등록 및 수정을 지원한다. 하지만 수정의 경우 반드시 변경감지를 이용한다.

 

Spring Data Jpa가 새로운 엔티티를 구별하는 방법

  • 식별자가 null인 경우 새로운 엔티티로 판단한다. → GeneratedValue는 persist되기 전까진 값이 엔티티에 들어가지 않는다.
  • 식별자가 프리미티브 타입일때 0인 경우 새로운 엔티티로 판단한다.
  • GeneratedValue를 사용하지 못하는 경우 식별자가 null이 아니다.
    • 이미 존재하는 데이터라고 판단 → DB에 Select 쿼리를 통해 데이터를 조회하고 없는 경우 새로운 엔티티로 판단
    • 쿼리 한번이 더 호출되기 때문에 비효율적이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {
    @Id
    private String id;

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

 

  • Persistable 인터페이스를 구현해서 새로운 엔티티인지 판단하는 로직을 변경한다.
    • getId(), isNew() 구현
  • CreatedDate를 조합해 생성일자가 없는 경우를 새로운 엔티티로 판단하면 편리하다.
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {
    @Id
//    @GeneratedValue
    private String id;

    // Persist전에 호출
    @CreatedDate
    private LocalDateTime createdDate;

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

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

    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}

 

Spring Data Jpa의 나머지 기능

  • Specification : 명세를 조립해서 사용한다. (where, and, or, not을 이용해 조립), 실무에서 사용 금지!QueryDSL 사용

 

Query By Example

  • Probe: 필드에 데이터가 있는 실제 도메인 객체
  • ExampleMatcher: 특정 필드를 일치시키는 상세한 정보 제공, 재사용 가능
  • Example: Probe와 ExampleMatcher로 구성, 쿼리를 생성하는데 사용
  • 장점
    • 동적 쿼리를 작성하기 편리하다.
    • 도메인 객체를 그대로 사용한다.
    • spring.data.common 패키지에 정의되어 있으므로 RDB를 NoSQL로 변경해도 코드 변경할 필요없게 추상화되어있다.
  • 단점
    • Inner Join만 가능하다.
    • equal, starts, contains, ends, regex 정도의 매칭 조건만 지원
  • 결론 : QueryDSL 사용

 

Projection

  • Projections : 엔티티 대신 Dto를 편리하게 조회하는 경우에 사용한다.
  • 인터페이스 기반 Closed Projections
    • 프로퍼티 형식(Getter)으로 인터페이스 내 메서드 선언
public interface UsernameOnly {
    String getUsername();
}

// UsernameOnly (DTO) 타입으로 조회
public interface MemberRepository extends JpaRepository<Member, Long> {
        List<UsernameOnly> findProjectionsByUsername(@Param("username") String username);
}

 

  • 인터페이스 기반 Open Projections
    • SpEl 문법을 사용
    • 엔티티를 조회한 후 원하는 데이터를 가공해서 사용 → Select 절의 최적화 불가능
public interface UsernameOnly {
    @Value("#{target.username + ' ' +  target.age}")
    String getUsername();
}

 

  • 구체 클래스 기반 Projection
    • 구체적인 Dto 클래스를 생성 후 생성자 파라미터 명으로 매칭
public class UsernameOnlyDto {
    private final String username;

    // 생성자 파라미터 이름으로 매칭해 프로젝션
    public UsernameOnlyDto(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }
}

 

  • 동적 Projection
    • 쿼리가 동일한데 다양한 데이터를 상황에 따라 가져오고 싶은 경우, 제너릭을 이용
<T> List<T> findProjectionsByUsername(@Param("username") String username, Class<T> type);

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

 

  • 중첩 구조
    • 프로젝션 대상이 루트 엔티티라면 최적화해서 가져오지만, 대상이 아닌 경우 최적화가 되지 않아 엔티티 자체를 조회해온다.
    • 프로젝션 대상이 루트아 가닌 경우 LEFT OUTER JOIN으로 처리
public interface NestedClosedProjections {
    String getUsername();   // 프로젝션 대상
    TeamInfo getTeam();

        // 프로젝션 대상X
    interface TeamInfo {
        String getName();
    }
}

 

  • 프로젝션 대상이 루트 엔티티인 경우 유용하다.
  • 프로젝션 대상이 루트 엔티티를 넘어가면 SELECT 최적화가 안된다.
  • 잡한 쿼리를 사용하는 경우 QueryDSL을 사용한다. 실무의 경우 단순한 경우에만 프로젝션을 사용한다.

 

Native Query

  • 네이티브 쿼리는 가급적이면 사용하지 않는 것이 좋다. 왠만하면 QueryDSL로 풀 수 있다.
  • 페이징을 지원한다.
  • 리턴 타입으로 Dto를 사용하는 것을 권장한다.
  • 네이티브 쿼리와 Projection을 함께 이용하는 것이 가능하다.
@Query(value = "select m.member_id as id, m.username, t.name as teamName " +
            "from member m left join team t",
            countQuery = "select count(*) from member",
            nativeQuery = true)
Page<MemberProjection> findByNativeProjection(Pageable pageable);
  • 동적 네이티브 쿼리를 이용하는 경우 Spring JdbcTemplate, Mybatis 등 외부 라이브러리를 사용한다.

 

📄 Reference

김영한님의 실전! 스프링 데이터 JPA : https://www.inflearn.com/course/스프링-데이터-JPA-실전/dashboard

반응형