728x90
반응형
SMALL

참고자료

 

실전! 스프링 데이터 JPA 강의 | 김영한 - 인프런

김영한 | 스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제

www.inflearn.com

 

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

그래서 스프링 데이터 JPA 공통 인터페이스의 구현체는 어떻게 생겨먹었을까? 그 부분을 파헤쳐보자!

스프링 데이터 JPA가 제공하는 공통 인터페이스인 JpaRepository를 찾아가보면 이렇게 되어 있다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.data.jpa.repository;

import java.util.List;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.ListPagingAndSortingRepository;
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.query.QueryByExampleExecutor;

@NoRepositoryBean
public interface JpaRepository<T, ID> extends ListCrudRepository<T, ID>, ListPagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    void flush();

    <S extends T> S saveAndFlush(S entity);

    <S extends T> List<S> saveAllAndFlush(Iterable<S> entities);

    /** @deprecated */
    @Deprecated
    default void deleteInBatch(Iterable<T> entities) {
        this.deleteAllInBatch(entities);
    }

    void deleteAllInBatch(Iterable<T> entities);

    void deleteAllByIdInBatch(Iterable<ID> ids);

    void deleteAllInBatch();

    /** @deprecated */
    @Deprecated
    T getOne(ID id);

    /** @deprecated */
    @Deprecated
    T getById(ID id);

    T getReferenceById(ID id);

    <S extends T> List<S> findAll(Example<S> example);

    <S extends T> List<S> findAll(Example<S> example, Sort sort);
}
  • 이 녀석은 그냥 인터페이스고 실제 이 인터페이스를 구현한 구현체가 있다. 물론, 스프링 데이터 JPA가 구현체를 미리 만들어서 제공해준다. 
  • 그 구현체의 이름은 SimpleJpaRepository이다.
package org.springframework.data.jpa.repository.support;

import ...

@Repository
@Transactional(
    readOnly = true
)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {...}
  • 클래스가 굉장히 크기 때문에 여기다 그 내용을 다 적을 순 없고 직접 들어가서 확인해 보길 바란다.
  • 확인해보면 정말 별 게 없다. 우리가 다 이전에 순수 JPA로 해봤던 코드들이 그대로 여기에도 적용되어 있다.
  • 이런것을 보면, 잘 만든 라이브러리의 주인이 우리가 될 수도 있다는 가슴 설레는 기분이 든다.
  • 그리고 이렇게 스프링 데이터 JPA가 만들어주는 구현체에 이미 @Repository, @Transactional(readOnly = true)가 들어있기 때문에 JpaRepository를 상속받는 인터페이스를 우리가 만들때 @Repository를 굳이 붙이지 않아도 됐던 것이고, 서비스에 @Transactional을 걸지 않아도 스프링 데이터 JPA를 사용해서 공통 인터페이스를 사용하면 쓰기 작업도 원활히 됐던 것이다.  물론 이 내용은 모두 스프링 데이터 JPA를 사용한다는 가정하에 말이다.

예시로 delete()를 보면 이렇게 생겼다.

@Transactional
public void delete(T entity) {
    Assert.notNull(entity, "Entity must not be null");
    if (!this.entityInformation.isNew(entity)) {
        if (this.entityManager.contains(entity)) {
            this.entityManager.remove(entity);
        } else {
            Class<?> type = ProxyUtils.getUserClass(entity);
            T existing = (T)this.entityManager.find(type, this.entityInformation.getId(entity));
            if (existing != null) {
                this.entityManager.remove(this.entityManager.merge(entity));
            }

        }
    }
}
  • 뭐 딱히 다른게 없다! 결국은 EntityManager.remove() 호출하는 것이다.

 

자자, 그리고 변경 감지를 사용해야 한다는 말을 하면서 데이터를 저장하는게 아니라 변경 시에는 save()를 호출하는 게 아니라고 여러번 얘기했는데 이 save() 코드도 보자. 진짜 뭐가 없다.

@Transactional
public <S extends T> S save(S entity) {
    Assert.notNull(entity, "Entity must not be null");
    if (this.entityInformation.isNew(entity)) {
        this.entityManager.persist(entity);
        return entity;
    } else {
        return (S)this.entityManager.merge(entity);
    }
}
  • 자, 엔티티가 새로운 엔티티라면 persist()를 호출하고 그렇지 않다면(데이터를 변경하는 거라면) merge()를 호출하고 있다. 이렇기에 데이터를 변경 시 save()를 호출하는 게 아니라 변경감지를 사용해야 한다고 말하는 것이다. 변경감지를 사용해야 하는 이유는 크게 3가지가 있다.
  1. 어차피 merge()도 결국엔 영속성 컨텍스트에 올려서 데이터를 변경한 다음 변경감지로 데이터를 바꾼다.
  2. 위 1번에 따르면, merge()를 호출하면 영속성 컨텍스트에 올린다고 했는데 그럼 데이터베이스에서 해당 데이터를 조회하는 과정이 일어나는 것 아닌가? 맞다. 데이터베이스에 이 변경하려는 데이터의 레코드가 있는지 확인하기 위해 조회 쿼리가 나간다. 
  3. 조회한 데이터에 전달한 변경할 데이터를 담은 엔티티의 모든 값으로 덮어씌운다. null이 있었다면? null로 덮어씌운다.

 

데이터를 변경할 때 save() 호출하지 말자. 절대!! 

 

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

바로 위에서 본 save() 메서드에서, 새로운 엔티티라면 persist()를 호출하고, 그게 아니라면 (즉, 데이터베이스에 이미 저장된 데이터라면) merge()를 호출하는 것을 보았다. 그런데 말이다. 새로운 엔티티인지 어떻게 판단할까?

 

이 내용이 꽤나 중요하기 때문에 집중해야 한다. 우선 결론부터 말하면 이렇다.

 

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

  • 식별자(PK)가 객체(Long과 같은)타입일 때 null이라면 새로운 엔티티라고 판단
  • 식별자(PK)가 자바 기본 타입(long)일 때 0이라면 새로운 엔티티라고 판단

이게 무슨말인지 코드를 통해서 조금 더 자세히 알아보자.

 

Item

package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;

@Entity
public class Item {
    
    @Id
    @GeneratedValue
    private Long id;
}
  • 아주 간단한 엔티티 하나를 만든다. 식별자 필드 딱 하나만 있으면 된다. 
  • 주의 깊게 볼 부분은 @GeneratedValueLong 타입이다.
  • @GeneratedValue는 사용하는 데이터베이스에게 PK 생성을 위임하는 방식인데 이렇게 하면 EntityManager.persist()를 호출했을 때는 아직 데이터베이스에 저장하기 전이기 때문에 이 Id값이 없다. 즉 null이다.

 

그리고, 테스트 코드를 작성해서 save()를 호출해보자!

ItemTest

package cwchoiit.datajpa.entity;

import cwchoiit.datajpa.repository.springdatajpa.ItemRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ItemTest {

    @Autowired
    private ItemRepository itemRepository;

    @Test
    void isNew() {
        itemRepository.save(new Item());
    }
}
  • 이렇게 했을 때 아까 위에서 본 SimpleJpaRepositorysave()는 어떻게 동작할까?
  • 한번 디버깅을 해보자.

  • isNew(entity)는 어떤 판단을 할까?

  • 우선 브레이크 포인트에 걸렸을 때는 다음과 같이 당연히 id값은 null이다. 아직 데이터베이스에 저장하기 전이니까 말이다.

  • 그래서 persist()를 호출하는 코드를 그대로 수행하게 된다.
  • 이처럼, 식별자가 객체(Long)일 때는 null인 경우 새로운 엔티티라고 판단한다.
  • 그러면 자바 기본 타입인 long같은 경우 값을 세팅 안하면 0이 기본값이라서 0일때 새로운 엔티티라고 판단한다고 했던것이다.

 

아니, 근데 이건 너무 쉬운데 왜 이 내용이 중요하다고 했을까? 지금의 경우는 식별자를 @GeneratedValue로 설정했을 때 이야기다. 만약, 이 방식을 사용하지 않고 개발자가 직접 아이디를 세팅한다고 한다면 어떻게 될까? 그러니까 엔티티를 다음과 같이 작성하는 것이다.

Item

package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {

    @Id
    private Long id;
}
  • @Id만 사용해서 직접 식별자 값을 할당하는 경우에는 당연히 이미 식별자 값이 있는 상태로 save()를 호출한다. 아래 코드처럼 말이다.
@Test
void isNew() {
    itemRepository.save(new Item(1L));
}
  • 와.. 이러면 객체값인데 null이 아니게 된다. 어떻게 될까? 디버깅 해서 다시 한번 돌려보자.

  • 디버깅해서 직접 확인해보면 알겠지만, merge(entity)로 가버린다. 
  • merge()를 호출한다는 건 일단 무조건 데이터베이스에 해당 값이 있다고 간주한다. 그렇기에 merge()는 무조건 이 값을 가져오기 위해 데이터베이스에 조회를 하게 된다. 실제로 그런지는 쿼리 나가는 것을 보면 알 수 있다.

  • SELECT 쿼리가 보이는가? SELECT를 했는데 해당 값이 없으니까, "아 뭐야? 없잖아?" 하고 새로운 엔티티라고 판단을 여기서나마 해서 INSERT를 하게 된다.
  • 이게 어떤 문제냐? 당연히 너무나 비효율적이다. 이러면 이제 모든 새 엔티티를 만들때마다 이 조회 쿼리가 무의미하게 나가는데 이 조회라는 건 꽤나 성능에 영향을 끼치는 작업이다. 
  • 이건 논외지만 변경을 할때 무조건 변경 감지를 사용하자. 보다시피 merge()는 해당 데이터를 데이터베이스에서 조회하는 작업을 무조건 거치게 된다. 

 

다시 주제로 돌아와서, 이 경우에 그러면 어떻게 하면 될까? Persistable 인터페이스를 구현해서 판단 로직을 변경할 수 있는 기능을 제공한다.

Persistable 구현

package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import org.springframework.data.domain.Persistable;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<Long> {

    @Id
    private Long id;
    
    @Override
    public Long getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return false;
    }
}
  • Persistable<PK의 타입>을 구현하면, 다음 두가지 메서드를 구현해야 한다. getId(), isNew().
  • 여기서 isNew를 직접 구현해서 새로운 엔티티에 대한 판단 로직을 작성하면 되는데 어떻게 하면 좋을까?
  • 아래와 같은 기가막힌 방법이 있다.
package cwchoiit.datajpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import org.springframework.data.domain.Persistable;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item extends BaseEntity implements Persistable<Long>{

    @Id
    private Long id;

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

    @Override
    public boolean isNew() {
        return getCreatedDate() == null;
    }
}
  • Auditing에 대해 공부할 때 BaseEntity를 만들고 그 안에 createdDate 필드를 직접 만들어 넣는게 아니라 스프링 데이터 JPAAuditing 기능을 사용해서 자동으로 세팅하게 설정했다.
  • 결국 새로운 엔티티인 경우엔 이 createdDate값이 null이고 새로운 엔티티가 아니라면 이 값은 무조건 null이 아니게 된다.
  • 이 필드의 값을 사용해서 @GeneratedValue를 사용하지 않았을 때도 정상적으로 새 엔티티를 판단해서 merge()를 호출하는 무식한 짓을 하지 않아도 된다.
  • 테스트 코드로 이제 다시 테스트 해보자.

  • 이제는 persist(entity)를 호출하는 라인으로 넘어가는 것을 볼 수 있다.

 

728x90
반응형
LIST

'Spring Data JPA' 카테고리의 다른 글

Spring Data JPA (2)  (0) 2024.12.12
Spring Data JPA (1)  (0) 2024.12.12
728x90
반응형
SMALL

참고자료

 

실전! 스프링 데이터 JPA 강의 | 김영한 - 인프런

김영한 | 스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제

www.inflearn.com

 

이전 포스팅에서, 그냥 메서드 시그니처만 만들어도 자동으로 스프링 데이터 JPA가 쿼리를 만들어주는 놀라운 기능을 제공한다고 했었다! 이게 사실일까?!

 

쿼리 메서드

이 기능을 쿼리 메서드라고 표현한다. 순수 JPA를 사용해서 만약 유저를 찾는데, 특정 유저이름 조건과 나이 조건을 부합하는 유저를 찾는 쿼리를 순수 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();
}
  • 그럼 이런식으로 직접 JPQL을 작성해야 한다. 물론 어려운게 아니다. 귀찮은 거지.

 

그런데, 저 코드를 그냥 메서드 시그니처 하나로 알아서 만들어준다면? 개발자들의 개발자 경험은 기가막히게 올라갈 것이다. 

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • 이렇게 메서드 시그니처 하나만 만들어주면 끝이다. 물론, 규칙이 있는데 엔티티 필드명을 완벽하게 그대로 작성해야 하고, findBy..., GreaterThan 등 스프링 데이터 JPA가 정한 규칙을 따라 만들어야 한다.
  • 실제로 테스트 코드를 돌려보면 정상적으로 수행된다.
@Test
void findByUsernameAndAgeGreaterThan() {
    Member member1 = Member.builder()
            .username("memberA")
            .age(20)
            .build();

    memberRepository.save(member1);

    List<Member> result = memberRepository.findByUsernameAndAgeGreaterThan("memberA", 10);

    assertThat(result.size()).isEqualTo(1);
}

실행 결과

 

그럼 시그니처 어떻게 작성해야 하나?에 대한 대답은 당연히 공식문서를 참고하면 된다. 딱히 어렵지도 않다. 

 

JPA Query Methods :: Spring Data JPA

By default, Spring Data JPA uses position-based parameter binding, as described in all the preceding examples. This makes query methods a little error-prone when refactoring regarding the parameter position. To solve this issue, you can use @Param annotati

docs.spring.io

 

그런데, 이 방식의 가장 큰 문제점 중 하나는 여기서 조건이 한 두개 정도만 더 추가되도 메서드 이름이 너무 길어진다는 것이다. 생각해보자. 만약 나이 조건과 이름 조건 말고 팀 조건도 있고 등급 조건도 있으면 아마 이렇게 생겨먹을 것이다.

List<Member> findByUsernameAndAgeGreaterThanAndFindByTeamEqualsAndFindByGradeEquals(String username, int age, Team team, Grade grade);
  • 으악이다. 이걸 누가 보고 좋다 할 수 있겠나? 
  • 대신, 조건 2개 정도안에서는 정말 정말 좋은 기능이다. 
  • 그럼 이런 경우엔 어떻게 해결하면 될까? 직접 JPQL을 사용할 수 있게도 지원해준다.

 

JPA NamedQuery

이 방법은 위의 문제를 해결해주지만 결론부터 말하면 이 기능은 거의 사용하지 않으니 그냥 이런게 NamedQuery구나? 하고 넘어가면 된다. 바로 아래 코드를 보자.

 

Member 엔티티

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
@NamedQuery(
        name = "Member.findByUsername",
        query = "SELECT m FROM Member m WHERE m.username = :username"
)
public class Member {...}
  • 엔티티를 정의한 클래스에 @NamedQuery 라는 애노테이션을 달고, 거기에 쿼리에 대한 명칭과 쿼리를 직접 작성해줄 수 있다.
  • 쿼리에 명칭을 부여한다고 해서 NamedQuery이다.
  • 그리고 이걸 어떻게 사용하면 되냐? 바로 다음 코드를 보자.

 

우선, 순수 JPA만을 사용했을 때 방법이다. 순수 JPA 레포지토리에서 다음과 같이 작성할 수 있다.

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;

  	...

    public List<Member> findByUsername(String username) {
        return em.createNamedQuery("Member.findByUsername", Member.class)
                .setParameter("username", username)
                .getResultList();
    }
}
  • em.createNamedQuery() 라는 메서드가 있는데 이게 바로 아까 만든 NamedQuery의 이름을 가져다가 사용하는 방법이다.
  • 그런데 생각해보면, 굳이 이걸 왜써? 라는 생각이 드는게 그냥 JPQL을 바로 직접 사용하면 되는것 아닌가? 순수 JPA는 그게 맞다. 그런데 스프링 데이터 JPA는 좀 편리하게 사용할 수가 있다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query(name = "Member.findByUsername")
    Optional<Member> findByUsername(@Param("username") String username);

    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • 이게 스프링 데이터 JPANamedQuery를 사용하는 방식이다. @Query(name = "...") 이렇게 내가 지정한 이름으로 name 값을 지정해주면 끝난다.
  • 그리고, NamedQuery를 사용할 때 파라미터가 필요한 경우에는 @Param(...) 애노테이션을 달아주면 된다.
  • 그런데, 여기서 한 발 더 나아갈 수가 있다!
package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    // @Query(name = "Member.findByUsername")
    Optional<Member> findByUsername(@Param("username") String username);

    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • @Query(name = "...")이 없어도 동작한다! 왜 그럴까? 스프링 데이터 JPA가 규칙이 있는데 이게 NamedQuery인지를 먼저 확인하기 위해 "엔티티.메서드명"으로 된 NamedQuery를 찾는게 첫번째 하는 작업이다.
  • 그래서 Member.findByUsername 으로 된 NamedQuery를 찾는데 아까 내가 만들었으니 그걸 그대로 가져다가 적용하는 것이다.
  • 만약, 찾았는데 없다면 위에서 배운 쿼리 메서드를 만드려고 시도한다.

 

그러나, 이 방법은 깊이 있게 알 필요가 없다. 왜냐하면 이 NamedQuery는 너무 불편해서 거의 사용하지 않는다. 왜 불편하냐면 이 다음에 말할 최고의 막강한 기능이 있다. 그걸 다 사용하기 때문에 이건 거의 사용하지 않는다. 그래도 이 NamedQuery의 좋은 점도 있다. 좋은 점은 쿼리에 문제가 있으면 컴파일 오류를 뱉어준다는 점이다. 다음 코드를 보자.

 

순수 JPA로 만든 레포지토리를 보면, 이 JPQL은 결국 다 문자열이다.

  • 이건 다 문자열이기 때문에 저 문자열로 만들어진 JPQL에 문법적 오류가 있어도 컴파일 시점에 그것을 확인할 수가 없다. 그래서 컴파일 시 문제가 안 생기는데 만약, 유저가 이 쿼리를 호출해야 하는 어떤 작업을 하는 순간 빵! 하고 런타임 에러가 발생하겠지.
  • 그건 좋지 않다. 

그런데, 이 NamedQuery는 문자열이긴 한데 컴파일 시점에 문법 오류를 잡아준다! 다음 코드를 보자!

  • 저기 보면, `m.us123123ername`으로 오타가 발생했다. 이 상태에서 컴파일 하면 컴파일 오류가 난다!

 

NamedQuery는 이런 장점이 있기도 하다. 그런데 결국 안쓴다. 왜냐? 다음에 배울 녀석은 이 장점을 그대로 가짐과 동시에 훨씬 더 편하게 사용할 수 있다!

 

@Query, 레포지토리 메소드에 쿼리 직접 정의

이 방법이 최고의 방법이다. 바로 코드로 보자.

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    Optional<Member> findByUsername(@Param("username") String username);

    @Query("SELECT m " +
            "FROM Member m " +
            "WHERE m.username = :username AND m.age > :age")
    List<Member> findByUsernameAndAgeGreaterThan(@Param("username") String username, @Param("age") int age);
}
  • 이렇게 @Query로 바로 JPQL을 작성할 수 있다. 얼마나 편리한가?
  • 그리고 이 기능 역시 컴파일 시 문법 오류를 잡아준다.

  • 보다시피, `m.user123123name` 이라고 잘못 입력했을 때 이걸 컴파일 하면 바로 아래와 같이 컴파일 오류를 뱉어낸다.

그래서 쿼리 메서드를 사용하는데 조건이 여러개가 늘어나는 경우에는 이 기능을 잘 사용하기를 권장한다!

 

그리고, 이 @Query를 사용하면서, 단순히 값 하나만 조회하거나 DTO로 변환하여 조회할 수도 있다.

 

단순히 값 하나만을 조회

@Query("SELECT m.username FROM Member m")
List<String> findUsernames();
  • m.username처럼 원하는 값 하나를 SELECT절을 채우면 끝이다.

DTO로 조회

우선, DTO로 조회하려면 DTO가 당연히 있어야 하겠지. DTO를 아래와 같이 만들었다.

 

MemberDto

package cwchoiit.datajpa.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemberDto {

    private String username;
    private String teamName;
    private int age;

}

 

아래와 같이 DTO로 조회하면 된다. 순수 JPA에서 했던 방식이랑 동일하다. `new` 키워드를 사용해서 패키지명까지 다 작성하는 방식.

@Query("SELECT new cwchoiit.datajpa.dto.MemberDto(m.username, t.name, m.age) FROM Member m JOIN m.team t")
List<MemberDto> findMembersWithDto();

 

테스트 코드로 테스트 해보자.

@Test
void findMemberWithTeam() {

    Team teamA = Team.builder()
            .name("teamA")
            .build();

    teamRepository.save(teamA);

    Member member1 = Member.builder()
            .username("memberA")
            .age(20)
            .team(teamA)
            .build();

    memberRepository.save(member1);

    List<MemberDto> membersWithDto = memberRepository.findMembersWithDto();
    for (MemberDto memberDto : membersWithDto) {
        System.out.println("memberDto = " + memberDto);
    }
}
  • 테스트 코드로 테스트를 해보면, 다음과 같이 잘 나오는 것을 확인할 수 있다.

실행 결과

 

 

파라미터 바인딩

파라미터 바인딩은 위에서 이미 본 내용이다. 근데 위치 기반과 이름 기반이 있는데 위치 기반같은 싯 코드는 작성하지 말자! 위에서 작성한대로 이름기반으로 파라미터를 바인딩하면 된다. 그런데 컬렉션 파라미터를 바인딩할 수도 있다!

컬렉션 파라미터 바인딩

@Query("SELECT m FROM Member m WHERE m.username IN :usernames")
List<Member> findByUsernames(@Param("usernames") List<String> usernames);
  • 이렇게 IN절을 사용할 때 리스트를 넘길수가 있다. 그럼 리스트를 괄호로 예쁘게 아주 잘 바꿔서 알아서 다 해준다.

테스트 코드로 확인해보자!

@Test
void findByUsernames() {
    Member member1 = Member.builder()
            .username("memberA")
            .age(20)
            .build();

    Member member2 = Member.builder()
            .username("memberB")
            .age(20)
            .build();

    memberRepository.save(member1);
    memberRepository.save(member2);

    List<String> usernames = new ArrayList<>(List.of("memberA", "memberB"));

    List<Member> byUsernames = memberRepository.findByUsernames(usernames);

    for (Member byUsername : byUsernames) {
        System.out.println("byUsername = " + byUsername);
    }
}

실행 결과

byUsername = Member(id=1, username=memberA, age=20)
byUsername = Member(id=2, username=memberB, age=20)

 

 

반환 타입

스프링 데이터 JPA는 유연한 반환 타입을 지원한다.

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

 

이렇게 여러 반환 타입을 지원하는데 이때 고민해볼 거리가 있다.

 

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

  • 컬렉션 반환 타입
    • 결과 없는 경우 → 빈 컬렉션 반환
  • 단건 조회
    • 결과 없는 경우 → null 반환
    • 결과가 2건 이상인 경우 → NonUniqueResultException 발생

근데 이게 순수 JPA를 사용하는 경우 단건 조회 시 결과가 없으면 NoResultException을 발생시킨다. 그런데 스프링 데이터 JPA는 결과가 없다고 에러를 발생시키는 게 맞아? 하면서 이 경우 자기들이 내부적으로 try - catch로 이 예외를 잡아서 null을 반환해버리게 만들어줬다. 그래서 이것에 대해 갑론을박이 있다. 내 개인적인 생각으로도 예외보다는 null이 맞다고 보는데 이게 자바8 이전에 있던 갑론을박이고 자바8 이후로 뭐가 나왔냐?! Optional 이라는 아주 기특한 녀석이 나왔기 때문에 단건 조회는 그냥 무조건 Optional을 사용하는 게 좋다.

 

그리고, 단건 반환 타입인데 결과가 2건 이상인 경우 스프링 데이터 JPA고 순수 JPA고 그냥 무조건 NonUniqueResultException이 발생한다. 

참고로, 이 뿐만 아니라 여러 반환 타입이 있다. 이는 공식 문서를 참고해보면 되는데 가장 많이 사용되는 타입이 저 세가지라고 보면 된다.

 

순수 JPA 페이징과 정렬

JPA에서는 페이징을 어떻게 할까? JPA는 페이징 처리를 굉장히 편하게 할 수가 있는데, 우선 순수 JPA로 페이징하는 방법을 보고 스프링 데이터 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", 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();
}
  • JPAsetFirstResult(), setMaxResults() 메서드를 지원한다. 이게 MySQL로 치면 offset, limit에 해당하는 부분이다.
  • 그래서 굉장히 간단하게 페이징 처리를 할 수 있다.
  • 그리고, 전체 개수를 가져오는 쿼리도 만들어야 한다. 그래서 totalCount()라는 메서드를 만들고 전체 개수를 가져오는 JPQL을 구현했다.

테스트 코드로 잘 동작하는지 확인해보자.

@Test
void paging() {
    memberJpaRepository.save(new Member("memberA", 10));
    memberJpaRepository.save(new Member("memberB", 10));
    memberJpaRepository.save(new Member("memberC", 10));
    memberJpaRepository.save(new Member("memberD", 10));
    memberJpaRepository.save(new Member("memberE", 10));

    List<Member> members = memberJpaRepository.findByPage(10, 0, 3);
    long totalCount = memberJpaRepository.totalCount(10);

    //페이지 계산 공식 적용...
    // totalPage = totalCount / size ... 
    // 마지막 페이지 ...
    // 최초 페이지 ...

    assertThat(members.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
}
  • 나이가 10살인 회원을 조회한다. 그리고 그 쿼리에 대한 페이징 처리를 했다. offset은 0, limit은 3이다.
  • 순수 JPA를 사용하면, 데이터와 전체 개수를 가져오면 페이징 계산을 따로 해줘야하는 번거로움이 있다. 그런데 이 부분을 스프링 데이터 JPA가 아주 쉽게 처리해준다. 이후에 봐보자!
  • 테스트 실행 결과는 아주 잘 동작한다.

실행 결과

 

 

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

스프링 데이터 JPA를 사용하면 페이징 처리가 아주 기가막히다. 그런데 이건, 스프링 데이터 JPA가 아니라 Spring Data가 표준으로 제공하는 것이라 구현한 어떤 데이터 접근 기술이든 공통 사항이다. 

 

  • 정렬 기능 → org.springframework.data.domain.Sort
  • 페이징 기능 (내부에 Sort 포함) → org.springframework.data.domain.Pageable

패키지를 보면 알겠지만, data.jpa가 아니라 data에서 끝난다. 스프링 데이터 JPA뿐 아니라, Redis, MongoDB 등 어떤걸 사용해도 동일한 인터페이스라는 이야기다. 정말 잘 만들었다는 생각이 든다.

 

그리고 추가적으로 특별한 반환 타입이 있다.

  • org.springframework.data.domain.Page → count 쿼리 결과를 포함하는 페이징
  • org.springframework.data.domain.Slice → count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1로 조회)

Slice라는 건 뭐냐면, 전체 개수는 없는데 그 모바일에서 보면, 화면을 쭉 내리다보면 보이는 [더보기] 버튼 같은 페이징 처리에 사용되는 것이다. 그래서 사용자가 더보기 버튼을 누르면 (누르지 않아도 되는 경우도 있고) 추가적으로 데이터를 가져오는 그런 방식이다. 그래서 limit + 1을 조회해서 limit까지 데이터를 보여주고 +1 했을때 데이터가 있는 경우 [더보기] 버튼을 보여지게 하는 그런 방식이다.

 

Page 사용 

우선, Page를 먼저 사용해보자. 다음과 같이 딱 한 줄을 추가한다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    ...
    
    Page<Member> findByAge(int age, Pageable pageable);
}
  • 타입을 Page로 받고, 파라미터로 Pageable만 추가하면 끝이다! 그리고 스프링 데이터 JPA는 쿼리 메서드 기능이 있어서 findByAge하면 넘겨받은 age 파라미터랑 같은 age를 가지는 데이터를 가져온다.
  • 물론, 당연히 원하는 JPQL을 작성하는 @Query()를 사용해도 된다.
@Test
void paging() {
    memberRepository.save(new Member("memberA", 10));
    memberRepository.save(new Member("memberB", 10));
    memberRepository.save(new Member("memberC", 10));
    memberRepository.save(new Member("memberD", 10));
    memberRepository.save(new Member("memberE", 10));

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

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

    // Contents
    List<Member> content = members.getContent();

    // Total Count
    long totalCount = members.getTotalElements();

    // Page Number (현재 페이지 번호)
    int pageNumber = members.getNumber();

    // Total Pages (전체 페이지 수)
    int totalPages = members.getTotalPages();

    // 첫번째 페이지인지
    boolean isFirst = members.isFirst();

    // 다음 페이지가 있는지
    boolean hasNext = members.hasNext();

    assertThat(content.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
    assertThat(pageNumber).isEqualTo(0);
    assertThat(totalPages).isEqualTo(2);
    assertThat(isFirst).isTrue();
    assertThat(hasNext).isTrue();
}
  • Pageable에 들어갈 파라미터를 만들기 위해 PageRequest.of(...)PageRequest 객체 하나를 만든다.
  • PageRequestPageable을 구현했기 때문에 아무 문제없이 저 자리에 들어갈 수 있다.
  • 그리고, PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username")) 이라고 하면, 현재 페이지 0, 조회할 데이터 수 3, `username`을 기준으로 내림차순으로 정렬한다는 뜻이다. 참고로 가져온 데이터를 기준으로 내림차순으로 정렬하는게 아니라, 내림차순으로 정렬된 데이터를 페이지 0번에서 데이터 3개를 가져온다. 
  • 이렇게 해서 데이터 가져오면 어떤것들을 할 수 있냐? 데이터, 전체 개수, 현재 페이지 번호, 전체 페이지 수, 첫번째 페이지인지 여부, 다음 페이지가 있는지에 대한 여부를 모두 알 수 있다!
  • 참고로, 첫번째 페이지는 1부터 시작이 아니라 0부터 시작이다.

 

Slice 사용

이번에는 Slice를 사용해보자!

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Page<Member> findByAge(int age, Pageable pageable);

    @Query("SELECT m FROM Member m WHERE m.age = :age")
    Slice<Member> findByAgeWithSlice(@Param("age") int age, Pageable pageable);
}
  • 메서드 이름을 바꿔야 했어서 @Query()를 사용했다. 아까 위에서 말했듯, 쿼리 메서드뿐 아니라 @Query로 직접 JPQL을 작성할 수도 있다! 

테스트 코드로 테스트 해보자!

@Test
void paging_slice() {
    memberRepository.save(new Member("memberA", 10));
    memberRepository.save(new Member("memberB", 10));
    memberRepository.save(new Member("memberC", 10));
    memberRepository.save(new Member("memberD", 10));
    memberRepository.save(new Member("memberE", 10));

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

    Slice<Member> members = memberRepository.findByAgeWithSlice(age, pageRequest);

    // Contents
    List<Member> content = members.getContent();

    // Page Number (현재 페이지 번호)
    int pageNumber = members.getNumber();

    // 첫번째 페이지인지
    boolean isFirst = members.isFirst();

    // 다음 페이지가 있는지
    boolean hasNext = members.hasNext();

    assertThat(content.size()).isEqualTo(3);
    assertThat(pageNumber).isEqualTo(0);
    assertThat(isFirst).isTrue();
    assertThat(hasNext).isTrue();
}
  • Slice는 전체 개수나, 전체 페이지 수를 가져오지 않는다!  그냥 이 방식은 다음 페이지가 있는지 없는지만 판단하고 있으면 [더보기] 버튼 만들어서 사용자에게 보여주면 된다.
  • 이게 스프링 데이터가 아니라 다른 기술이었다면 이 방식으로 변경하기 위해 코드를 아예 다시 작성했어야 했을텐데 스프링 데이터의 도움을 받아서 그냥 타입만 Page → Slice로 변경하면 끝이다. 놀랍지 않은가?

 

전체 Count 쿼리는 고민할 거리가 많다.

위에서 Page를 사용한 경우를 보았다. 얘는, 전체 카운트 개수도 뽑아준다. 근데 같은 쿼리로 내보내서 카운트를 가져오는데 이게 생각보다 간단치 않은 문제다. 왜냐하면, 데이터가 정말 많은 경우에 전체 카운트 개수를 가져오는 건 꽤나 무거운 행위이다. 예를 들어, 데이터가 100만건이 있다고 생각했을 때 이 전체 개수를 가져오는 쿼리는 굉장히 무겁다. 반면, 페이징 해서 데이터를 짤라서 가져오는 건 아무 문제가 되지 않지만 말이다. 

 

그럼 만약, 이런 쿼리가 있다고 해보자. 

SELECT m FROM Member m LEFT JOIN m.team t
  • 이건 지금 멤버와 팀을 조인하는 쿼리이다. 이대로 Page 타입의 쿼리를 날리면 전체 개수를 가져오는 것도 이 쿼리로 나간다.
  • 그런데 생각해보면, 지금 멤버를 기준으로 LEFT JOIN 할 때 다른 WHERE조건도 없고 딱 이 쿼리 그대로라면 전체 카운트는 저 쿼리 대신 아래와 같은 쿼리를 날려도 완전히 동일한 개수를 가져온다.
SELECT COUNT(m.username) FROM Member m
  • 곰곰히 생각해보면 이렇게 날려도 동일한 전체 개수를 가져온다는 것을 알 것이다. 그럼 카운트 쿼리는 이게 더 좋지 않을까? 조인을 하면 그만큼 낭비를 하는 것인데 말이다.

 

그래서! 스프링 데이터 JPA에서는 쿼리와 카운트 쿼리를 분리할 수가 있다!

@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);
  • 이렇게 카운트 쿼리를 최적화할 수도 있는 것이다. 이건 굉장히 중요하고 최적화 아이템 중 하나다.

 

Sort 조건이 복잡한 경우, 쿼리로 풀자!

PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
  • 아까 작성해본 PageRequest이고, 여기에 세번째 파라미터로 Sort.by(...)해서 정렬 기준을 작성해서 사용했다. 
  • 그런데, 이 정렬 조건이 꽤나 복잡해 지는 경우가 있을 수가 있다. 그런 경우에는 이게 이걸로 풀리지가 않는다. 그래서 그냥 JPQL로 풀어버리면 된다. 아래와 같이 말이다.
@Query("SELECT m FROM Member m WHERE m.age = :age ORDER BY m.username DESC")
Slice<Member> findByAgeWithSlice(@Param("age") int age, Pageable pageable);

 

 

페이징 처리를 해도 엔티티를 DTO로 변환하는 것은 필수!

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

Page<Member> members = memberRepository.findByAge(age, pageRequest);
  • REST API의 경우, 이 members 그대로 내보내면 큰일난다! 엔티티는 절대 외부로 노출하지 말자고 여러번 말했다.
  • 그리고, 이 엔티티를 아주 간단하게 DTO로 변경할 수가 있다.
Page<Member> members = memberRepository.findByAge(age, pageRequest);
Page<MemberDto> membersDto = members.map(m -> new MemberDto(m.getUsername(), m.getTeam().getName(), m.getAge()));
  • 이렇게 간단하게 map()을 사용해서 DTO로 변환하면 그대로 Page<MemberDto>로 변경할 수 있다.

 

벌크성 수정 쿼리

여러건의 데이터를 한번에 수정하는 쿼리를 날리는 것 역시 지원한다. 순수 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();
}
  • 별거 없다. 그냥 executeUpdate()를 호출하면 끝이다.

 

스프링 데이터 JPA에서는 어떻게 해야할까?

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

	...
    
    @Modifying
    @Query("UPDATE Member m SET m.age = m.age + 1 WHERE m.age >= :age")
    int bulkAgePlus(@Param("age") int age);
}
  • 쿼리 날리는 건 똑같은데, @Modifying 애노테이션을 꼭 붙여줘야 한다. 그래야 스프링 데이터 JPA는 아 이게 업데이트 쿼리를 날리는 구나를 인지할 수 있다. 

테스트 코드로 확인만 해보자!

@Test
void bulkUpdate() {
    memberRepository.save(new Member("memberA", 10));
    memberRepository.save(new Member("memberB", 20));
    memberRepository.save(new Member("memberC", 30));
    memberRepository.save(new Member("memberD", 40));
    memberRepository.save(new Member("memberE", 50));

    int resultCount = memberRepository.bulkAgePlus(20);

    assertThat(resultCount).isEqualTo(4);
}
  • 실행 결과는 정상적으로 수행된다.

벌크성 쿼리를 날릴 때 무조건 조심해야 할 것

근데 이 벌크성 쿼리를 날리는걸 어떻게 하냐가 문제가 아니다! 벌크성 쿼리는 조심해야 할 부분이 하나 있다. 순수 JPA든, 스프링 데이터 JPA든 벌크성 쿼리는 영속성 컨텍스트를 무시하고 바로 데이터베이스에 업데이트 쿼리를 빵! 날려버린다. 근데 이게 상황에 따라 다르겠지만 만약, 벌크성 쿼리 대상이 되는 레코드가 이미 영속성 컨텍스트에 관리되는 상태라면 실제 데이터베이스의 해당 레코드와 영속성 컨텍스트가 관리하는 해당 레코드의 데이터 불일치가 일어난다.

 

생각해보자. 영속성 컨텍스트는 쓰기 지연 기능이 있기 때문에 트랜잭션이 완전히 끝나는 시점에 쓰기 쿼리가 나간다. 그래서 트랜잭션이 살아있는 시점에는 쓰기 쿼리가 안 나가는 상태인데 이 상태 어느 순간에 벌크성 쿼리를 날리면 영속성 컨텍스트에 보관되고 있는 데이터는 변경되지 않고 데이터베이스에 직접적으로 적용되기 때문에 데이터베이스에 있는 데이터와 영속성 컨텍스트에 있는 데이터가 값이 달라질 수가 있다. 그래서 결론적으로는, 벌크성 쿼리를 날리면 무조건 플러시, 클리어를 해줘야한다. 

 

저기서도 플러시가 핵심이 아니라, 클리어가 핵심이다. 플러시는 바로 위에서 말한 쓰기 지연 쿼리가 있는 경우 이 쿼리는 당연히 날려줘야 하니까 플러시를 호출해서 쓰기 지연 쿼리를 날리는 데 의의가 있는것이고, 클리어를 해줘야 영속성 컨텍스트가 관리하고 있는 모든 레코드가 정리되고 깨끗해진다. 

 

근데, 스프링 데이터 JPA의 공통 인터페이스에는 플러시는 있지만 클리어는 없다. 이 경우에 어떻게 하면 되냐면, 방법은 두가지 정도가 있는데 첫번째 방법EntityManager를 주입받으면 된다. 주입 받아서 이렇게 작성하면 된다.

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.entity.Team;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import cwchoiit.datajpa.repository.springdatajpa.TeamRepository;
import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    EntityManager entityManager;

    @Test
    void bulkUpdate() {
        memberRepository.save(new Member("memberA", 10));
        memberRepository.save(new Member("memberB", 20));
        memberRepository.save(new Member("memberC", 30));
        memberRepository.save(new Member("memberD", 40));
        memberRepository.save(new Member("memberE", 50));

        int resultCount = memberRepository.bulkAgePlus(20);

        entityManager.flush();
        entityManager.clear();

        Member member = memberRepository.findByUsername("memberE").orElseThrow();
        System.out.println("member = " + member);

        assertThat(resultCount).isEqualTo(4);
    }
}
  • EntityManager를 주입받았다. 
  • flush(), clear()를 연달아 호출하면 된다.

실행 결과

2024-12-13T12:01:39.214+09:00 DEBUG 50911 --- [    Test worker] org.hibernate.SQL                        : 
    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.username=?
member = Member(id=5, username=memberE, age=51)
  • 51살로 잘 찍힌다. 

 

그런데, 이건 굉장히 비효율적이다. 어떤 방법이 있냐면, 이렇게 해버리면 끝이다.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Member m SET m.age = m.age + 1 WHERE m.age >= :age")
int bulkAgePlus(@Param("age") int age);
  • @Modifying 애노테이션에는 clearAutomatically, flushAutomatically 속성이 있다.

 

@EntityGraph

지연로딩을 해결하기 위해 페치 조인을 사용하는 데 가끔 JPQL을 작성하기가 귀찮을때가 있다.

그럴때, 이 @EntityGraph를 이용하면 조금 더 편하게 페치 조인을 사용하는 것처럼 할 수 있다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
}
  • 예시일뿐이다. 스프링 데이터 JPA가 만들어준 기본 findAll()을 사용하면 페치 조인이 먹히질 않는다. 그리고 연관 객체를 가져올때 N+1 문제가 발생하는데, 그러기가 싫고 페치 조인으로 한번에 다 가져오고 싶은데 JPQL을 작성하기 귀찮다! 하면 이렇게 하면 된다.
  • attributePaths = {...} 에 들어가는 게 연관 객체들이다.

 

근데 이렇게 기존에 있는 메서드를 재정의하는 방식 말고, 내가 새로 만든 메서드를 사용하긴 하는데 이것도 페치 조인을 해야 하긴 하는데 JPQL을 작성하기 귀찮은 경우에 이렇게도 작성할 수 있다.

@Query("SELECT m FROM Member m")
@EntityGraph(attributePaths = {"team"})
List<Member> findMemberEntityGraph();
  • @Query로 가장 간단하게 JPQL을 작성하고 페치 조인을 작성하는 부분은 빼버린 다음에 @EntityGraph 애노테이션을 사용하면 된다.
  • 근데 난 굳이?란 생각은 든다. 그냥 아래와 같이 하면 되지 않나.
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findMemberEntityGraph();

 

근데 이런 경우는 그냥 JPQL 작성으로 한번에 하는게 더 편하고, 위에 경우처럼 스프링 데이터 JPA가 제공하는 공통 메서드를 재정의해서 사용하고 싶은 경우나 아니면 쿼리 메서드를 사용할때는 꽤나 유용할 것 같다. 아래와 같이 말이다.

@EntityGraph(attributePaths = {"team"})
Optional<Member> findByUsername(@Param("username") String username);
  • 이런 쿼리 메서드의 경우, 기본으로는 페치 조인이 안 먹힌 상태이니까 만약 이 쿼리 메서드에 페치 조인이 필요한 경우 @Query 애노테이션으로 JPQL을 다 작성하는 것보다는 이 @EntityGraph가 더 편리해 보이긴 한다.

 

그리고, 이 @EntityGraph는 사실 @NamedEntityGraph라고 JPA에서 제공하는 기능이다. 그래서 그냥 JPA를 사용할 때 이렇게 작성을 할 수가 있다. 

@Entity
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {...}
  • 엔티티 객체에 @NamedEntityGraph로 마치 @NamedQuery 작성하듯 작성할 수가 있다. 근데 잘 사용하지는 않는다.
  • 그리고 이렇게 해 놓으면 스프링 데이터 JPA가 이것을 지원하는데 아래와 같이 사용하면 된다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @EntityGraph("Member.all")
    List<Member> findMemberEntityGraph();
}

 

JPA HintLock

HintLock은 자주 사용하는 것은 아니고 알아두면 될 정도만 이해하고 있으면 된다.

 

Hint

이 힌트라는건 어떤거냐면, 예를 들어 데이터베이스에서 어떤 데이터를 가져오고 그걸 엔티티로 우리가 사용할 때 정말 죽었다가 깨어나도 이 데이터는 변경할 필요도 없고 영속성 컨텍스트에 스냅샷을 가지고 있을 이유가 하나도 없다면 사용해볼 법한 기능이다. 결국 변경 감지든 1차 캐시든 어쨌든간에 영속성 컨텍스트가 그 데이터를 가지고 관리한다는 것은 메모리를 사용한다는 것이고 사용하지 않아도 될 메모리를 사용하는 것보단 사용 안할 수 있다면 안하는게 더 좋으니까 "스냅샷 가지고 있지 말아라"라고 JPA에게 알려주는 거라고 생각하면 된다.

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.QueryHint;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
    Member findReadOnlyMemberByUsername(String username);
}
  • 레포지토리에 @QueryHints 애노테이션을 달아서, 위와 같이 작성해주면 이 메서드로 가져오는 멤버를 영속성 컨텍스트가 관리하지 않는다. 그래서 오로지 정말 READ의 목적만 있는 경우에는 이런 방법을 고려해봐도 좋다. 성능도 메모리를 쓰는것보단 덜 잡아 먹을테니. 
  • 테스트 코드로 테스트를 해보면 어떤 말인지 알 수 있을 것이다.
@Test
void hint() {
    Member memberA = memberRepository.save(new Member("memberA", 10));

    entityManager.flush();
    entityManager.clear();

    Member findMember = memberRepository.findReadOnlyMemberByUsername("memberA");

    findMember.setUsername("memberB");
}
  • 이렇게 작성하면 변경감지 기능으로 인해 해당 유저의 이름을 변경하는 쿼리를 날려야 한다. 기본적으로는 그게 맞는데 저렇게  @QueryHintreadOnly를 설정하면 영속성 컨텍스트가 관리하지 않게 되어 변경 감지도 하지 않는다.

실행 결과

  • 읽기만 하고 업데이트 쿼리는 없다.

 

Lock

락 같은 경우에는 뭐냐면, SELECT로 데이터를 가져올 때 FOR UPDATE 구문을 사용하는 것이다. 내가 읽은 데이터를 내가 커넥션을 반납하기 전에 (다른 말로 커밋하기 전에) 다른 커넥션에서 해당 데이터를 업데이트 치지 못하도록 하는 방법인데, 거의 보통은 사용하지 않는다. 특히나 트래픽이 많고 유저수가 많고 활발한 서비스는! 그런데 이런게 있다는것 정도만 알아두자.

 

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.dto.MemberDto;
import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<Member> findLockByUsername(String username);
}
  • @Lock 애노테이션으로 PESSIMISTIC_WRITE 옵션을 주면 된다. 그럼 이 메서드로 데이터를 가져온다면 해당 커넥션이 반납되기 전에는 다른 커넥션에서 같은 데이터에 쓰기 작업이 불가능하다. 쿼리를 보면 가장 확실하다.
@Test
void lock() {
    List<Member> memberA = memberRepository.findLockByUsername("memberA");
}

실행 결과

  • SELECT FOR UPDATE 쿼리가 나가는 것을 볼 수 있다.

 

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

스프링 데이터 JPA가 제공하는 인터페이스만으로 개발이 가능하면 아무런 문제가 없다. 그냥 그대로 쭉 사용해도 된다. 그런데 가끔은 정말 복잡한 동적 쿼리나 통계형 쿼리를 짜야하는 경우도 있다. 그런 경우에는 스프링 데이터 JPA의 공통 인터페이스가 제공하는 기능으로는 충분하지 않을 것이다. QueryDsl을 사용해야 할 때도 있고, NativeSQL을 작성해야 할 수도 있다. 

 

이럴땐 두 가지 방법이 있다. 사용자 정의 레포지토리를 구현하거나 아예 통계형(쿼리형) 레포지토리를 직접 만들면 된다. 말보단 코드로!

사용자 정의 레포지토리 구현해보기

MemberRepositoryCustom

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;

import java.util.List;

public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}
  • 별게 아니다. 그냥 나만의 인터페이스를 만들면 된다. 그리고 그 인터페이스에서 정의할 메서드도.

MemberRepositoryCustomImpl

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;

import java.util.List;

@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();
    }
}
  • 그 다음 이걸 구현할 구현체를 만들면 된다. 여기서는 예시이기 때문에 내용은 의미가 없지만 이렇게 하면 된다는 것이다. 보통은 이렇게 할 땐 QueryDsl을 사용해서 QueryDsl용 레포지토리 구현할때 많이 사용한다. 
  • 그래서 저 코드가 QueryDsl용 코드가 되면 된다. 

이렇게 만든 인터페이스와 구현체를 스프링 데이터 JPA가 제공하는 공통 인터페이스가 상속받으면 된다. 다음과 같이 말이다.

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {...}
  • 이렇게 하면 끝이다. 

 

근데 착각하면 안되는 게 있다. 이 과정이 절대 필수가 아니다. 이렇게 해도 된다는 것이지 이렇게 안해도 상관이 전혀 없다. 스프링 데이터 JPA가 제공하는 공통 인터페이스가 제공해주는 기능으로 충분하면 이 과정 필요없다. 그리고 특정 화면에서만 사용되는 복잡한 쿼리를 다룰때는 그냥 레포지토리를 만들면 된다. 그 안에서 QueryDsl을 사용하던 네이티브 쿼리를 작성하던 그건 개발하는 사람의 몫이고 반드시 이렇게 스프링 데이터 JPA가 제공하는 공통 인터페이스가 상속받도록 만들 필요는 없다는 것이다.

 

그러니까 이렇게 만들면 된다는 것이다.

package cwchoiit.datajpa.repository.springdatajpa;

import org.springframework.stereotype.Repository;

@Repository
public class MemberQueryRepository {
    
    // QueryDsl 사용
    
    // nativeSQL 사용
    
    // ...
}
  • 이렇게 나만의 레포지토리를 만들고 @Repository 애노테이션을 붙여서 스프링 빈으로 등록시킨 다음에 사용하는 곳에서 주입받아서 사용해도 아무런 문제가 없다는 말이다.
  • 그리고 이 방식이 더 효율적이고 가시성이 좋을수도 있다. 위에서 말한 스프링 데이터 JPA가 제공하는 공통 인터페이스가 상속받게 만들면 오히려 핵심 쿼리(CRUD)와 쿼리형 레포지토리가 전부 짬뽕이 되서 가시성이 떨어질 수도 있기 때문이다.

 

Auditing

이 내용은 꽤나 중요하고 실무에서도 거의 무조건 사용하는 기능이다. 다른건 아니고 데이터베이스에 레코드 하나를 추가하거나 업데이트할 때, 누가 생성하고 수정했고, 그 시간이 어떻게 되는지?를 자동으로 넣어주는 방법이다. 

 

우선, 이 기능을 사용하려면 다음과 같이 엔트리 클래스에 @EnableJpaAuditing 애노테이션을 넣어줘야한다.

package cwchoiit.datajpa;

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

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

}

 

그리고, 이 등록일, 수정일은 거의 대부분의 엔티티가 반드시 필요한 필드라서 여러 엔티티에서 사용할 것이기 때문에 따로 하나 빼면 된다.

BaseEntity

package cwchoiit.datajpa.entity;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class BaseEntity {

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

    @LastModifiedDate
    private LocalDateTime modifiedDate;

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

    @LastModifiedBy
    private String modifiedBy;
}
  • @EntityListeners(AuditingEntityListeners.class) 애노테이션을 반드시 붙여줘야한다.
  • @MappedSuperclass 애노테이션 역시 반드시 붙여줘야 한다.
  • @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 애노테이션을 각 필드에 붙여주면 된다.
  • 이렇게 해 준 다음에 사용할 엔티티가 이 BaseEntity를 상속받으면 끝이다.
@Entity
public class Member extends BaseEntity {...}
  • 한가지 유념할 부분은, @CreatedBy, @LastModifiedBy는 저렇게 애노테이션만 붙이면 다 되는게 아니라 한가지 작업을 더 해줘야 한다. 아래와 같이 말이다.
@Bean
public AuditorAware<String> auditorProvider() {
    return () -> Optional.of(UUID.randomUUID().toString());
}
  • 우선 빈으로 AuditorAware를 등록해줘야 한다. 그리고 반환값은 이제 이 엔티티를 생성하거나 수정한 사용자의 이름이나 아이디 정도면 적당할 것 같은데 지금은 그런 정보가 없고 실제 서비스라면, Spring Security를 사용하면 현재 로그인 한 사용자 정보를 가져올 수 있고 Http Session을 사용한다면 이 세션으로도 로그인 한 사용자 정보를 가져올 수 있을것이다. 그것을 반환하면 된다. 
  • 테스트 한번 해보자!

 

@Test
void auditing() {
    memberRepository.save(new Member("memberA", 10));

    entityManager.flush();
    entityManager.clear();

    Member findMember = memberRepository.findByUsername("memberA").orElseThrow();

    System.out.println("findMember created: = " + findMember.getCreatedDate());
    System.out.println("findMember updated: = " + findMember.getModifiedDate());
    System.out.println("findMember.getCreatedBy() = " + findMember.getCreatedBy());
    System.out.println("findMember.getModifiedBy() = " + findMember.getModifiedBy());
}

실행 결과

findMember created: = 2024-12-18T19:17:51.345707
findMember updated: = 2024-12-18T19:17:51.345707
findMember.getCreatedBy() = ebb665c7-b5d4-4640-8e1a-870fe6bd52ff
findMember.getModifiedBy() = ebb665c7-b5d4-4640-8e1a-870fe6bd52ff

 

요 기능은 굉장히 유용하고 운영에 있어서 매우 많은 도움을 주는 데이터이다. 그래서 꼭 참고하길 바란다!

 

 

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

결론부터 말하면, 쓰지 말거나 최소한으로 사용할 것을 권장한다. 코드로 바로 보자.

MemberController

package cwchoiit.datajpa.controller;

import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String getMember(@PathVariable Long id) {
        return memberRepository.findById(id)
                .map(Member::getUsername)
                .orElse(null);
    }
}
  • 컨트롤러가 있을 때, PathVariableID값을 받으면 그 값으로 엔티티를 찾아내는 방법은 굉장히 일반적이다.
  • 그래서 저렇게 멤버를 찾아서 멤버의 이름을 반환하거나 못 찾으면 null을 반환하는 아주 간단한 코드가 있다고 쳐보자.

이 코드를 스프링 데이터 JPA가 컨버터를 적용해주는데 아래와 같이 할 수 있단 뜻이다.

package cwchoiit.datajpa.controller;

import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String getMember(@PathVariable Long id) {
        return memberRepository.findById(id)
                .map(Member::getUsername)
                .orElse(null);
    }

    @GetMapping("/members2/{id}")
    public String getMember(@PathVariable("id") Member member) {
        return member.getUsername();
    }
}
  • 비교를 위해 기존의 코드를 남겨두었다. `/member2/{id}` 를 보면, @PathVariableID를 받을 때 Member 타입으로 받으면 스프링 데이터 JPA는 이 받아온 아이디를 통해 바로 데이터베이스의 해당 ID를 가지고 있는 멤버를 뽑아준다. 
  • 당연히 파라미터 이름이 다르니까 @PathVariable("id") 이렇게 작성해야 한다.

실행 결과

GET http://localhost:8080/members2/1

HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 7
Date: Wed, 18 Dec 2024 10:44:41 GMT

memberA

Response code: 200; Time: 18ms (18 ms); Content length: 7 bytes (7 B)

 

보면 굉장히 획기적인 기능 같은데 쓰지말자. 왜냐하면, 일단 이 코드를 이해하기가 정말 어렵다. 아니 분명 PathVariableID를 받는데 갑자기 멤버로 받아? 싶기도 한데다가 가장 문제는 이 멤버는 절대적으로 조회용으로 밖에 사용할 수 없다. 왜냐하면 컨버터를 통해 멤버를 찾아오는 과정은 스프링 데이터 JPA 내부에서 해주는 작업이기 때문에 트랜잭션이 이 코드안에 없다. 따라서 쓰기 작업이 불가능하다. 즉, 영속성 컨텍스트가 이 컨트롤러 안에 없다는 의미다.

 

Web 확장 - 페이징과 정렬

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

@GetMapping("/members")
public Page<MemberDto> getMembers(Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::new);
}
  • 파라미터로 Pageable을 받으면 된다.
  • Pageableorg.springframework.data.domain.Pageable이다.
  • 당연히 DTO로 변환해서 반환해야 한다.

 

이렇게 만들어 두면, 어떤식으로 요청할 수가 있냐? 바로 이렇게 하면 된다.

GET http://localhost:8080/members?page=1&size=5&sort=id,desc&sort=username,desc
  • page → 현재 페이지를 의미한다. 참고로 0부터 시작한다!
  • size → 한 페이지에 노출할 데이터 건 수
  • sort → 정렬 조건을 정의한다. 

 

기본 설정값 변경 

그런데, 가끔은 기본 페이지 사이즈나 최대 페이즈 사이즈같은 값을 바꾸고 싶을 때가 있다. 이 경우엔, 글로벌 설정과 개별 설정이 있는데 글로벌 설정은 application.yaml 파일에서 수정하면 된다.

 

글로벌 설정 (application.yaml)

spring:
  data:
    web:
      pageable:
        default-page-size: 10
        max-page-size: 1000

 

개별 설정

@GetMapping("/members")
public Page<MemberDto> getMembers(@PageableDefault(size = 5, sort = {"username", "id"}, direction = Sort.Direction.DESC) Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::new);
}

 

페이징 정보가 둘 이상인 경우

어떤 요청에는 여러 데이터를 가져와야 하는데 그 여러 데이터가 다 페이징이 필요한 경우 페이징 정보가 둘 이상이 될 수도 있다. 그럴땐 다음과 같이 @Qualifier를 사용하면 된다.

@GetMapping("/members")
public Page<MemberDto> getMembers(@Qualifier("member") Pageable memberPageable,
                                  @Qualifier("order") Pageable orderPageable) {
    return memberRepository.findAll(memberPageable).map(MemberDto::new);
}

 

 

Page를 1부터 시작하기

이게 살짝 아쉬운 부분인데, 이 스프링 데이터 JPA가 제공하는 Web 확장 기능의 PageablePage의 첫 페이지가 0이다. 그래서 1부터 하고 싶은 경우에 난감하긴 하다. 가장 좋은건 0부터 시작하고 0부터 시작하는 것을 규칙으로 만들면 된다. 

 

그런데 만약, 1부터 무조건 시작해야 한다면 다음 두 가지 방법이 있다.

  • Pageable, Page를 파라미터와 응답값으로 사용하지 않고, 직접 클래스를 만들어서 처리한다. 그리고 직접 PageRequest(Pageable 구현체)를 생성해서 레포지토리에 넘긴다. 물론 응답값도 Page 대신에 직접 만들어서 제공해야 한다.
  • spring.data.web.pageable.one-indexed-parameterstrue로 설정한다. 그런데 이 방법은 web에서 page 파라미터를 -1 처리할 뿐이다. 따라서 응답값인 Page에 모두 0 페이지 인덱스를 사용하는 한계가 있다.

그러니까 다음과 같이 application.yaml을 설정을 했다.

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

 

그런데, 이렇게 하더라도 Page 응답값에서는 적용되지가 않는다. 

GET http://localhost:8080/members?page=1

###
...
"pageable": {
    "pageNumber": 0,
    "pageSize": 10,
    "sort": {
      "sorted": false,
      "empty": true,
      "unsorted": true
    },
    "offset": 0,
    "paged": true,
    "unpaged": false
  },
  "totalPages": 10,
  "totalElements": 100,
  "last": false,
  "number": 0,
  "size": 10,
  "numberOfElements": 10,
  "sort": {
    "sorted": false,
    "empty": true,
    "unsorted": true
  },
  "first": true,
  "empty": false
}
  • 여전히 pageNumber에는 0으로 나온다.

 

728x90
반응형
LIST

'Spring Data JPA' 카테고리의 다른 글

Spring Data JPA (3)  (0) 2024.12.18
Spring Data JPA (1)  (0) 2024.12.12
728x90
반응형
SMALL

참고자료

 

실전! 스프링 데이터 JPA 강의 | 김영한 - 인프런

김영한 | 스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제

www.inflearn.com

 

JPASpring Data JPA랑 뭐가 다른건가요?에 대한 질문에 정확한 답변을 할 수 없다면 JPA와 스프링을 다시 공부해야 한다. JPA를 모르면 Spring Data JPA를 제대로 사용할 수 없다. JPA는 데이터 접근 기술이고 ORM이다. 지금은 스프링을 사용하는 개발자들에게는 거의 필수인 데이터 접근 기술인데 JPA를 사용하던, 다른 데이터 접근 기술을 사용하던 기본적인 CRUD 기능은 거의 대부분이 비슷하다. 심지어 이건 관계형 데이터베이스가 아닌 데이터베이스도 마찬가지로 기본적인 CRUD는 거의 대부분이 동일하다. 이렇게 유사한 기능을 가지고 있는데 기술이 가지각색인 경우 스프링은 항상 뭐다? 추상화를 한다

 

그래서, 스프링 진영에서 데이터 접근 기술들이 이것 저것 많지만 기본적인 CRUD는 모두 동일하니 이거 추상화하자! 해서 만든 표준이 Spring Data라는 표준이고 그 표준을 특정 기술(여기서는 JPA)로 구현한 구현체 중 하나가 Spring Data JPA이다.

 

그럼 도대체 JPA가 있는데 뭐 얼마나 유사하다고 이 표준을 만든걸까? 한번 Spring Data JPA를 사용하지 않고 순수 JPA를 사용해서 레포지토리를 만들고 데이터 접근 기술을 사용해보자.

 

엔티티

Member

package cwchoiit.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;

    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username) {
        this.username = username;
    }

    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

 

Team

package cwchoiit.datajpa.entity;

import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name", "members"})
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    @Builder.Default
    private List<Member> members = new ArrayList<>();
}
  • 간단하게 Member, Team 두 엔티티를 만들었다.
  • 둘은 다대일 양방향 연관관계를 가지고 있고, 그렇기에 연관관계의 주인을 지정해야 한다. JPA 관련 포스팅에서 열심히 말했다. 연관관계의 주인은 항상 테이블 관점에서 외래키를 가지고 있는 쪽이 주인이 되면 된다고 했다. 고로 연관관계의 주인을 Member로 지정했다. 양방향 연관관계에서 연관관계의 주인이 아닌 TeammappedBy 속성으로 "나는 주인인 Member의 필드인 team에 매핑된 필드입니다."라고 알려주어야 한다.
  • 지연로딩은 당연히 모두 다 걸어야 한다.
  • 여기서 @ToString을 유심히 보면, Member에는 Team 필드를 제외한 것을 볼 수 있다. 왜 그러냐면, toString()을 호출할 때 팀까지 넣어버리면 팀을 호출하고, 팀은 멤버 리스트를 가지고 있기 때문에 멤버를 다시 호출한다. 그럼 팀이 멤버를 멤버는 팀을 계속해서 호출하는 문제가 발생해서 스택 오버플로우 에러를 마주하게 된다.

 

순수 JPA 레포지토리

MemberJpaRepository

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Member;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {

    private final EntityManager em;

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

    public Member find(Long id) {
        return em.find(Member.class, id);
    }

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

    public long count() {
        return em.createQuery("SELECT COUNT(m) FROM Member m", Long.class)
                .getSingleResult();
    }

    public List<Member> findAll() {
        return em.createQuery("SELECT m FROM Member m", Member.class)
                .getResultList();
    }

    public void delete(Member member) {
        em.remove(member);
    }
}

 

TeamJpaRepository

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Team;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class TeamJpaRepository {

    private final EntityManager em;

    public Team save(Team team) {
        em.persist(team);
        return team;
    }

    public void delete(Team team) {
        em.remove(team);
    }

    public List<Team> findAll() {
        return em.createQuery("SELECT t FROM Team t", Team.class)
                .getResultList();
    }

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

    public long count() {
        return em.createQuery("SELECT COUNT(t) FROM Team t", Long.class)
                .getSingleResult();
    }
}
  • 직접 EntityManager를 주입받아서 순수한 JPA 레포지토리 코드를 작성한 모습이다. 코드만 봐도 알겠지만, TeamMember가 참조하는 객체만 다를뿐 코드가 거의 100% 동일하다. 
  • save(), delete(), findAll(), findById(), count() 모두 둘 다 가지고 있는 메서드이다.
  • @Repository 애노테이션을 달아서 이 클래스를 스프링 컨테이너를 띄울때 컴포넌트 스캔을 할 수 있도록 해줘야 한다. 참고로, @Repository를 달면 스프링은 데이터 접근 예외 추상화 기술도 넣어 프록시를 만든다. 그래서 어떤 데이터베이스를 사용하든지 예외가 발생하더라도 동일한 스프링 데이터 접근 예외가 발생한다.
  • 이후에 Spring Data JPA를 사용하면 이 @Repository 애노테이션은 달 필요가 없다. 왜냐하면 JpaRepository를 상속받는 시점부터 이미 알아서 스프링 데이터 접근 예외 추상화도 해준다. 이건 이후에 직접 사용하면서 다시 보자.

 

순수하게 JPA 레포지토리를 작성하고 보니, 코드가 거의 동일하다. 엔티티가 10개면 10개의 레포지토리가 동일하게 가지는 이 기능들을 매번 구현해줘야 한다. 개발자는 게으르다. 이것을 참을 수 없다. 

 

 

스프링 데이터 JPA 사용

스프링 데이터 JPA를 사용하기 위해선 다음 작업이 필요하다.

package cwchoiit.datajpa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

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

    public static void main(String[] args) {
        SpringApplication.run(DataJpaApplication.class, args);
    }

}
  • 엔트리 지점인 메인 클래스에 @EnableJpaRepositories 애노테이션을 달아서 어떤 패키지에 스프링 데이터 JPA 레포지토리를 가지고 있는지 알려줘야 한다. 그래야 그 지점에 가서 레포지토리들을 컴포넌트 스캔으로 빈 등록하기 때문이다.
  • 그런데, 스프링 부트를 사용하는 우리는 이게 필요없다. 스프링 부트가 자동으로 컴포넌트 스캔을 해주는데 어디서부터 해주냐? 바로 이 클래스(DataJpaApplication)가 존재하는 패키지 하위부터 모든 패키지를 다 뒤져서 찾는다. 

 

그래서 다시 제거한 모습으로 놔두자!

package cwchoiit.datajpa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DataJpaApplication {

    public static void main(String[] args) {
        SpringApplication.run(DataJpaApplication.class, args);
    }

}

 

 

스프링 데이터 JPA 공통 인터페이스

이제, 스프링 데이터 JPA가 제공하는 인터페이스를 상속받는 인터페이스를 만들기만 하면 끝이다.

MemberRepository

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}
  • 이게 끝이다.
  • 네? 구현체는요? → 필요가 없다. 스프링 데이터 JPA가 직접 만들어서 꽂아준다.
  • 프록시로 만들어서 공통으로 사용되는 기본적인 CRUD 기능도 가지고 있고, 스프링 데이터 접근 예외 추상화도 적용해서 만들어준다.

 

실제로 확인해볼 수 있는데, 다음과 같이 테스트 코드를 작성해보자.

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void save() {

        log.info("memberRepository class = {}", memberRepository.getClass());

    }
}
  • 위에서 만든 MemberRepository를 주입받아서 이 클래스 정보를 찍어보면 다음과 같이 나온다.

실행 결과

2024-12-12T14:18:13.905+09:00  INFO 15409 --- [    Test worker] c.d.repository.MemberRepositoryTest      : memberRepository class = class jdk.proxy3.$Proxy125
  • JDK 동적 프록시를 사용해서 프록시로 만든 모습을 확인할 수 있다. 

 

스프링 데이터 JPA 사용하여 테스트하기

MemberRepositoryTest

package cwchoiit.datajpa.repository;

import cwchoiit.datajpa.entity.Member;
import cwchoiit.datajpa.repository.springdatajpa.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void crud() {
        Member member1 = Member.builder()
                .username("member1")
                .build();

        Member member2 = Member.builder()
                .username("member2")
                .build();

        memberRepository.save(member1);
        memberRepository.save(member2);

        Member findMember1 = memberRepository.findById(member1.getId()).orElseThrow();
        Member findMember2 = memberRepository.findById(member2.getId()).orElseThrow();

        assertThat(findMember1).isEqualTo(member1);
        assertThat(findMember2).isEqualTo(member2);

        List<Member> members = memberRepository.findAll();
        assertThat(members.size()).isEqualTo(2);

        long memberCount = memberRepository.count();
        assertThat(memberCount).isEqualTo(2);

        memberRepository.delete(member1);
        memberRepository.delete(member2);

        long afterRemovedCount = memberRepository.count();
        assertThat(afterRemovedCount).isEqualTo(0);
    }
}
  • 이런 코드를 작성했다. 어? 나는 findAll(), count(), delete(), findById(), save() 만든 적이 없는데 잘 작성이 된다. 당연히 되지! 스프링 데이터 JPA가 구현체를 다 만들어 놨으니까!

실행 결과

 

 

스프링 데이터 JPA 공통 인터페이스 분석

그럼 스프링 데이터 JPA 공통 인터페이스인 JpaRepository가 어떻게 생긴걸까?

  • 이런 모양으로 생겼다. 참고로, Deprecated 된 메서드도 있다. getOne()이 대표적이다. 그리고 findOne()findById()로 변경되었다. 메서드 명이나 이런게 중요한게 아니고 여기서 핵심은 이런 구조를 가지고 있다는 점이다.
  • 맨 위에서 스프링이 데이터 접근 기술을 추상화했고 그게 Spring Data라고 했다. 그리고 그 여러 데이터 접근 기술 중 하나인 JPA를 가지고 구현한 구현체가 Spring Data JPA일 뿐이다.
  • 그래서 이거 타고 올라가보면, JpaRepository는 패키지 명이 다음과 같다.

  • 보다시피, org.springframework.data.jpa.repository이다.
  • 이 위로 올라가서 PagingAndSortingRepository 올라가보면 패키지 명이 다음과 같다.

  • 보다시피 org.springframework.data.repository이다.
  • 이 말은, 스프링 데이터 JPA보다 위에있는 것들은 JPA뿐 아니라 Spring Data를 구현한 구현체들은 모두 가지고 있는 기능들이란 뜻이다. 그래서 위에서 말한대로 어떤 데이터베이스를 사용하든지 공통 기능을 추상화했다고 말한것이다.

 

스프링 데이터 JPA가 제공하는 공통 CRUD 말고는요?

그럼, save(), delete(), findAll() 등 공통적으로 어떤 데이터베이스든 필요한 메서드는 편리하게 사용할 수 있다는 것을 알았다. 근데 당연히 비즈니스적으로 프로젝트마다 엔티티도 다를 것이다. 예를 들면 어떤 프로젝트는 Member라는 엔티티가 있는가 하면 User라는 엔티티를 가진 프로젝트도 있을 것이다. 이런것까지 스프링 데이터 JPA가 다 만들어주진 않는다. 그럼 뭐 마냥 좋은게 아니네? 라고 생각이 들 수 있는데 이 스프링 데이터 JPA가 또 마법같은 일을 부린다. 

 

바로, 메서드 시그니처만으로 어떤 쿼리를 사용할지를 유추해서 그냥 해당 메서드 쿼리를 만들어버린다! 예를 들면 다음과 같이 코드를 작성하자. 

package cwchoiit.datajpa.repository.springdatajpa;

import cwchoiit.datajpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByUsername(String username);
}
  • 위에서 만든 스프링 데이터 JPA 공통 인터페이스에 시그니처 하나를 추가했다.
  • 유저이름으로 Member를 찾아내는 메서드이다. 이거 한 줄만 추가하면 스프링 데이터 JPA가 알아서 이 구현체를 만들어낸다!
  • 이 내용은 다음 포스팅에서 본격적으로 다뤄보자!

 

728x90
반응형
LIST

'Spring Data JPA' 카테고리의 다른 글

Spring Data JPA (3)  (0) 2024.12.18
Spring Data JPA (2)  (0) 2024.12.12

+ Recent posts