스프링 DB 2편(데이터 접근 활용 기술)

Ch06. SpringDataJPA - 스프링 데이터 JPA 적용

webmaster 2022. 7. 1. 10:34
728x90

build.gradle 추가

dependencies {
	...
    
	//JPA, 스프링 데이터 JPA 추가
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	
    ...
}

이미 앞에서 JPA를 설정하면서 spring-boot-starter-data-jpa 라이브러리를 넣어주었다.

여기에는 JPA , 하이버네이트, 스프링 데이터 JPA( spring-data-jpa ), 그리고 스프링 JDBC 관련 기능도 모두 포함되어 있다.

따라서 스프링 데이터 JPA가 이미 추가되어있으므로 별도의 라이브러리 설정은 하지 않아도 된다.

SpringDataJpaItemRepository 전체 코드

public interface SpringDataJpaItemRepository extends JpaRepository<Item, Long> {

    List<Item> findByItemNameLike(String itemName);

    List<Item> findByPriceLessThanEqual(Integer price);

    //쿼리 메서드
    List<Item> findByItemNameLikeAndPriceLessThanEqual(String itemName, Integer price);

    //쿼리 직접 실행
    @Query("select i from Item i where i.itemName like :itemName and i.price <= :price")
    List<Item> findItems(@Param("itemName") String itemName,@Param("price") Integer price);
}
  • 스프링 데이터 JPA가 제공하는 JpaRepository 인터페이스를 인터페이스 상속받으면 기본적인 CRUD 기능을 사용할 수 있다.
  • 그런데 이름으로 검색하거나, 가격으로 검색하는 기능은 공통으로 제공할 수 있는 기능이 아니다. 따라서 쿼리 메서드 기능을 사용하거나 @Query를 사용해서 직접 쿼리를 실행하면 된다.
  • 동적 쿼리를 사용하면 좋겠지만 SpringDataJpa는 동적 쿼리에 약하다(이후에 QueryDsl로 해결해보자)
    • SpringDataJPA도 Example이라는 기능으로 약간의 동적 쿼리를 지원하지만, 실무에서 사용하기는 기능이 빈약하다. 실무에서 JPQL 동적 쿼리는 Querydsl을 사용하는 것이 좋다.

findAll()

실행되는 JPQL

select i from Item i
  • 코드에는 보이지 않지만 JpaRepository 공통 인터페이스가 제공하는 기능이다.
  • 모든 Item을 조회한다.

findByItemNameLike()

List<Item> findByItemNameLike(String itemName);

실행되는 JPQL

select i from Item i where i.name like ?
  • 이름 조건만 검색했을 때 사용하는 쿼리 메서드이다.

findByPriceLessThanEqual()

 List<Item> findByPriceLessThanEqual(Integer price);

실행되는 JPQL

select i from Item i where i.price <= ?
  • 가격 조건만 검색했을 때 사용하는 쿼리 메서드이다. 

findByItemNameLikeAndPriceLessThanEqual()

//쿼리 메서드
List<Item> findByItemNameLikeAndPriceLessThanEqual(String itemName, Integer price);

실행되는 JPQL

select i from Item  i.itemName like ? where and i.price <= ?
  • 가격 조건만 검색했을 때 사용하는 쿼리 메서드이다.

findItems()

//쿼리 직접 실행
@Query("select i from Item i where i.itemName like :itemName and i.price <= :price")
List<Item> findItems(@Param("itemName") String itemName,@Param("price") Integer price);

실행되는 JPQL

select i from Item  i.itemName like ? where and i.price <= ?
  • 메서드 이름으로 쿼리를 실행하는 기능은 다음과 같은 단점이 있다.
    1. 조건이 많으면 메서드 이름이 너무 길어진다.
    2. 조인 같은 복잡한 조건을 사용할 수 없다.
  • 메서드 이름으로 쿼리를 실행하는 기능은 간단한 경우에는 매우 유용하지만, 복잡해지면 직접 JPQL 쿼리를 작성하는 것이 좋다.
  • 쿼리를 직접 실행하려면 @Query 애노테이션을 사용하면 된다.
  • 메서드 이름으로 쿼리를 실행할 때는 파라미터를 순서대로 입력하면 되지만, 쿼리를 직접 실행할 때는 파라미터를 명시적으로 바인딩해야 한다.
  • 파라미터 바인딩은 @Param("itemName") 애노테이션을 사용하고, 애노테이션의 값에 파라미터 이름을 주면 된다.

적용

JpaItemRepositoryV2 전체 코드

@Repository
@Transactional
@RequiredArgsConstructor
public class JpaItemRepositoryV2 implements ItemRepository {

    private final SpringDataJpaItemRepository repository;

    @Override
    public Item save(Item item) {
        return repository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        Item findItem = repository.findById(itemId).orElseThrow();
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

    @Override
    public Optional<Item> findById(Long id) {
        return repository.findById(id);
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();
        if (StringUtils.hasText(itemName) && maxPrice != null) {
            //return repository.findByItemNameLikeAndPriceLessThanEqual("%" + itemName + "%", maxPrice);
            return repository.findItems("%" + itemName + "%", maxPrice);
        } else if (StringUtils.hasText(itemName)) {
            return repository.findByItemNameLike("%" +itemName + "%");
        } else if (maxPrice != null) {
            return repository.findByPriceLessThanEqual(maxPrice);
        } else {
            return repository.findAll();
        }
    }
}

의존관계와 구조

  • ItemService는 ItemRepository에 의존하기 때문에 ItemService에서 SpringDataJpaItemRepository를 그대로 사용할 수 없다.
  • 물론 ItemService 가 SpringDataJpaItemRepository 를 직접 사용하도록 코드를 고치면 되겠지만, 우리는 ItemService 코드의 변경 없이 ItemService 가 ItemRepository에 대한 의존을 유지하면서 DI를 통해 구현 기술을 변경하고 싶다
  • 여기서는 JpaItemRepositoryV2 가 MemberRepository 와 SpringDataJpaItemRepository 사이를 맞추기 위한 어댑터처럼 사용된다

클래스 의존 관계

클래스 의존관계도

  • JpaItemRepositoryV2는 ItemRepository를 구현한다. 그리고 SpringDataJpaItemRepository를 사용한다.

런타임 객체 의존 관계

런타임 객체 의존 관계

  • 런타임의 객체 의존관계는 다음과 같이 동작한다.
  • itemService -> jpaItemRepositoryV2 -> springDataJpaItemRepository(프록시 객체)
  • 중간에서 JpaItemRepository 가 어댑터 역할을 해준 덕분에 MemberService 가 사용하는 MemberRepository 인터페이스를 그대로 유지할 수 있고 클라이언트인 MemberService의 코드를 변경하지 않아도 되는 장점이 있다.

save()

@Override
public Item save(Item item) {
    return repository.save(item);
}
  • 스프링 데이터 JPA가 제공하는 save() 를 호출한다

update()

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    Item findItem = repository.findById(itemId).orElseThrow();
    findItem.setItemName(updateParam.getItemName());
    findItem.setPrice(updateParam.getPrice());
    findItem.setQuantity(updateParam.getQuantity());
}
  • 스프링 데이터 JPA가 제공하는 findById() 메서드를 사용해서 엔티티를 찾는다.
  • 그리고 데이터를 수정한다.
    • 이후 트랜잭션이 커밋될 때 변경 내용이 데이터베이스에 반영된다. (JPA가 제공하는 기능이다.)

findById()

@Override
public Optional<Item> findById(Long id) {
    return repository.findById(id);
}
  • 스프링 데이터 JPA가 제공하는 findById() 메서드를 사용해서 엔티티를 찾는다.

findAll()

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();
    if (StringUtils.hasText(itemName) && maxPrice != null) {
        //return repository.findByItemNameLikeAndPriceLessThanEqual("%" + itemName + "%", maxPrice);
        return repository.findItems("%" + itemName + "%", maxPrice);
    } else if (StringUtils.hasText(itemName)) {
        return repository.findByItemNameLike("%" +itemName + "%");
    } else if (maxPrice != null) {
        return repository.findByPriceLessThanEqual(maxPrice);
    } else {
        return repository.findAll();
    }
}
  • 데이터를 조건에 따라 4가지로 분류해서 검색한다.
    • 모든 데이터 조회
    • 이름 조회
    • 가격 조회
    • 이름 + 가격 조회
  • 모든 조건에 부합할 때는 findByItemNameLikeAndPriceLessThanEqual() 를 사용해도 되고, repository.findItems()를 사용해도 된다. 그런데 보는 것처럼 조건이 2개만 되어도 이름이 너무 길어지는 단점이 있다. 따라서 스프링 데이터 JPA가 제공하는 메서드 이름으로 쿼리를 자동으로 만들어주는 기능과 @Query로 직접 쿼리를 작성하는 기능 중에 적절한 선택이 필요하다.
  • 코드를 잘 보면 동적 쿼리가 아니라 상황에 따라 각각 스프링 데이터 JPA의 메서드를 호출해서 상당히 비효율 적인 코드인 것을 알 수 있다. 앞서 이야기했듯이 스프링 데이터 JPA는 동적 쿼리 기능에 대한 지원이 매우 약하다. 이 부분은 이후에 Querydsl을 사용해서 개선해보자

SpringDataJpaConfig

@Configuration
@RequiredArgsConstructor
public class SpringDataJpaConfig {

    private final SpringDataJpaItemRepository springDataJpaItemRepository;

    @Bean
    public ItemService itemService(){
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository(){
        return new JpaItemRepositoryV2(springDataJpaItemRepository);
    }

}
  • SpringDataJpaItemRepository 는 스프링 데이터 JPA가 프록시 기술로 만들어주고 스프링 빈으로도 등록해준다.

ItemServiceApplication

@Slf4j
@Import(SpringDataJpaConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {
	...
}
  • SpringDataJpaConfig 를 사용하도록 변경했다.

예외 변환

스프링 데이터 JPA도 스프링 예외 추상화를 지원한다. 스프링 데이터 JPA가 만들어주는 프록시에서 이미 예외 변환을 처리하기 때문에, @Repository와 관계없이 예외가 변환된다.

주의! - 하이버네이트 버그

하이버네이트 5.6.6 ~ 5.6.7 을 사용하면 Like 문장을 사용할 때 다음 예외가 발생한다. 스프링 부트 2.6.5 버전은 문제가 되는 하이버네이트 5.6.7을 사용한다.

예외 : java.lang.IllegalArgumentException: Parameter value [\] did not match expected type [java.lang.String (n/a)

 

해결 : 

https://github.com/spring-projects/spring-data-jpa/issues/2472

 

Issue with spring-data "startingWith" and hibernate 5.6.7: Parameter value [\] did not match expected type [java.lang.String (n/

Hello, I am trying to fetch some entity using a “find all by property starting with” query which amount to a CriteriaQuery using javax.persistence.criteria.CriteriaBuilder.like(Expression, String, ...

github.com

build.gradle에 다음을 추가해서 하이버네이트 버전을 문제가 없는 5.6.5.Final로 맞추자.

ext["hibernate.version"] = "5.6.5.Final"
728x90