spring/스프링 DB 2편

5. 스프링 데이터 JPA(2) - 적용하기

sh1mj1 2023. 2. 22. 16:38
이 글은 배민 기술이사 김영한 이사님의 인프런 강의 "스프링 DB 2편 - 데이터 접근 활용 기술" 을 기반으로 작성되었습니다. 문제 시 삭제 조치하겠습니다.

 

이전 글 스프링 데이터 JPA(1) ( https://sh1mj1-log.tistory.com/106 )에 이어지는 내용입니다. 실제로 프로젝트에 적용해보겠습니다.

 

4. 스프링 데이터 JPA 적용 1

먼저 스프링 데이터 JPA 을 적용하기 전에 설정부터 해줍니다.

설정

build.gradle 추가

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

그런데 이미 이전 글에서 JPA를 설정하면서 spring-boot-starter-data-jpa 라이브러리를 넣어주었습니다. 여기에는 JPA , 하이버네이트, 스프링 데이터 JPA( spring-data-jpa ), 그리고 스프링 JDBC 관련 기능도 모두 포함되어 있습니다.

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

 

스프링 데이터 JPA 적용

SpringDataJpaItemRepository

import hello.itemservice.domain.Item;
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;

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를 사용해서 직접 쿼리를 실행하면 됩니다.

 

여기서는 데이터를 조건에 따라 4가지로 분류해서 검색합니다.

  • 모든 데이터 조회
  • 이름 조회
  • 가격 조회
  • 이름 + 가격 조회

 

동적 쿼리를 사용하면 좋겠지만, 스프링 데이터 JPA는 동적 쿼리에 약하므로 이번에는 직접 4가지 상황을 스프링 데이터 JPA로 구현하였습니다.

 

참고 - 스프링 데이터 JPA도 Example이라는 기능으로 약간의 동적 쿼리를 지원하지만, 실무에서 사용하기는 기능이 빈약합니다. 실무에서 JPQL 동적 쿼리는 Querydsl을 사용하는 것이 좋습니다.

 

findAll()

코드에서는 보이지 않지만 JpaRepository 공통 인터페이스가 제공하는 기능입니다. 모든 Item을 조회 다음과 같은 JPQL이 실행됩니다.

select i from Item i

 

findByItemNameLike()

이름 조건만 검색했을 때 사용하는 쿼리 메서드입니다. 다음과 같은 JPQL이 실행됩니다.

select i from Item i where i.name like ?

 

findByPriceLessThanEqual()

가격 조건만 검색했을 때 사용하는 쿼리 메서드입니다. 다음과 같은 JPQL이 실행됩니다.

select i from Item i where i.price <= ?

 

findByItemNameLikeAndPriceLessThanEqual()

상품 이름과 가격 조건을 검색했을 때 사용하는 쿼리 메서드입니다. 다음과 같은 JPQL이 실행됩니다.

select i from Item i where i.itemName like ? and i.price <= ?

 

findItems()

@Query 애노테이션을 사용해서 직접 JPQL 을 작성한 메서드입니다.

메서드 이름으로 쿼리를 실행하는 기능은 다음과 같은 단점이 있습니다.

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

 

JpaItemRepositoryV2

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

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

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

 

의존관계와 구조

ItemServiceItemRepository에 의존하기 때문에 ItemService에서 SpringDataJpaItemRepository를 그대로 사용할 수 없습니다.

 

물론 ItemServiceSpringDataJpaItemRepository를 직접 사용하도록 코드를 고치면 되겠지만,

우리는 ItemService 코드의 변경없이 ItemServiceItemRepository에 대한 의존을 유지하면서 DI를 통해 구현 기술을 변경하고 싶습니다. 그것이 객체 지향적인 설계이기도 하고요.

 

조금 복잡하지만, 새로운 리포지토리를 만들어서 이 문제를 해결합시다.

여기서는 JpaItemRepositoryV2MemberRepositorySpringDataJpaItemRepository 사이를 맞추기 위한 어댑터 처럼 사용됩니다.

 

클래스 의존 관계

https://user-images.githubusercontent.com/52024566/200841496-05801f83-098a-43aa-8f71-161dd2427c2c.png

JpaItemRepositoryV2ItemRepository를 구현합니다.

그리고 SpringDataJpaItemRepository를 사용합니다.

 

런타임 객체 의존 관계

런타임의 객체 의존관계는 다음과 같이 동작합니다.

itemServicejpaItemRepositoryV2springDataJpaItemRepository(프록시 객체)

 

이렇게 중간에서 JpaItemRepositoryV2가 어댑터 역할을 해준 덕분에 MemberService가 사용하는 MemberRepository 인터페이스를 그대로 유지할 수 있고 클라이언트인 MemberService의 코드를 변경하지 않아도 되는 장점이 있습니다.

 

 

save()

repository.save(item)

스프링 데이터 JPA가 제공하는 save()를 호출합니다.

 

update()

스프링 데이터 JPA가 제공하는 findById() 메서드를 사용해서 엔티티를 찾습니다.

그리고 데이터를 수정한 이후 트랜잭션이 커밋될 때 변경 내용이 데이터베이스에 반영되도록 합니다. (JPA가 제공하는 기능)

 

findById()

repository.findById(itemId) 

스프링 데이터 JPA가 제공하는 findById() 메서드를 사용해서 엔티티를 찾음

 

findAll() 데이터를 조건에 따라 4가지로 분류해서 검색합니다.

  • 모든 데이터 조회
  • 이름 조회
  • 가격 조회
  • 이름 + 가격 조회

모든 조건에 부합할 때는 findByItemNameLikeAndPriceLessThanEqual()를 사용해도 되고, repository.findItems()를 사용해도 됩니다.

 

그런데 보는 것 처럼 조건이 2개만 되어도 이름이 너무 길어지는 단점이 있습니다. 따라서 스프링 데이터 JPA가 제공하는 메서드 이름으로 쿼리를 자동으로 만들어주는 기능과 @Query로 직접 쿼리를 작성하는 기능 중에 적절한 선택이 필요합니다.

 

추가로 코드를 잘 보면 동적 쿼리가 아니라 상황에 따라 각각 스프링 데이터 JPA의 메서드를 호출해서 상당히 비효율적인 코드인 것을 알 수 있습니다.

앞서 이야기했듯이 스프링 데이터 JPA는 동적 쿼리 기능에 대한 지원이 매우 약합니다.

 

 

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

스프링 데이터 JPA가 SpringDataJpaItemRepository를 프록시 기술로 만들어주고 스프링 빈으로도 등록해줍니다.

 

ItemServiceApplication - 변경

//@Import(JpaConfig.class)
@Import(SpringDataJpaConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {}

SpringDataJpaConfig를 사용하도록 변경합니다.

 

예외 변환

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

 

주의! - 하이버네이트 버그
하이버네이트 5.6.6 ~ 5.6.7을 사용하면 Like 문장을 사용할 때 다음 예외가 발생합니다.
java.lang.IllegalArgumentException: Parameter value [\] did not match expected type [java.lang.String (n/a)]

만약 문제가 있는 하이버네이트 버전을 사용한다면 build.gradle에 다음을 추가해서 하이버네이트 버전을 문제가 없는 5.6.5.Final로 변경해서 사용합시다.
ext["hibernate.version"] = "5.6.5.Final"​

 

이렇게 스프링 데이터 JPA 기술에 대해서 알아보았습니다.

간단한 쿼리문에서는 단지 네이밍을 해줌으로써 데이터베이스에 CRUD 기능을 구현할 수 있었습니다.

하지만 동적 쿼리에 대해서는 약점을 보였죠. 

 

다음 글에서는 Querydsl 기술에 대해서 알아보겠습니다.