개발자로 후회없는 삶 살기
spring PART.스프링 데이터 JPA 본문
서론
※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.
https://www.inflearn.com/roadmaps/373
본론
- 스프링 데이터 JPA 등장 이유
옛날에 java 정파당 무술로 EJB라고 있었습니다. 근데 너무 기술이 복잡했습니다. 그래서 EJB 없이 개발하자고 해서 스프링과 하이버네이트가 나오게 됩니다.
- 스프링 데이터
스프링과 jpa의 조합으로 개발을 해나갑니다. 과거에는 rdb의 세상이었는데 이제 몽고 하이브 등 신흥 세력이 등장합니다. 스프링 데이터라는 기술은 몽고, 하이브 등을 다 생각해보면 결국 데이터를 어딘가에 저장하고 조회하는 것으로 사실 다 비슷한 것으로 내가 이 데이터를 어디에 저장할 것이냐, jpa를 통해서 저장할 것이냐 몽고로 저장할 거냐 이런 차이가 있는 거지 생각해보면 crud는 다 비슷한 것입니다.
근데 개발자가 다 다르게 만들고 있으니 이걸 더 크게 추상화해서 crud를 한 번 비슷한 인터를 만들어보자해서 스프링 데이터가 나오게 됩니다. 스프링 데이터라는 큰 공통 기술에서 등록, 수정, 삭제, 조회 등의 기본적인 인터를 제공하고 몽고, 레디스, 하둡 등 각각의 특성에 맞춰서 조금 더 확장 기능을 제공하는 게 있는데 스프링데이터 jpa, 스프링 데이터 몽고, 스프링 데이터 레디스 이런게 생깁니다. 그러면 스프링 데이터 기반으로 이것들을 사용할 수 있게 됩니다.
-> 단순한 통합 그 이상
스프링 데이터는 단순한 통합 이상으로 crud + 동일 인터페이스 + 페이징 처리 등 부가 기능을 제공하고 이것을 다 레디스, 몽고에 관계없이 공통으로 제공하는 것이 대단한 것입니다.
- 스프링 데이터 jpa
스프링 데이터 common이라고 하는 공통적인 기본 crud 인터가 있고 스프링 데이터 jpa는 jpa에 필요한 확장된 기술이 추가된 것입니다.
- Spring + JPA
순수 jdbc하다가 탬플릿하면서 생산성이 늘었습니다. 스프링 jpa 조합이 생기며 더 편리해졌는데 그래서 스프링 데이터 jpa가 생겼습니다.
이 둘을 같이 사용하면 인터가 있고 jpaRepo를 상속받으면 이 인터가 기본적으로 crud을 다 제공합니다.
인터를 제공한다고 했으니 구현체가 있어야할 텐데 구현체는 이 인터페이스를 기반으로 프록시 구현체를 자동으로 만들어줍니다. mybatis를 사용할 때처럼 인터페이스만 만들면 역할에 맞는 구현 기능을 사용할 수 있습니다.
- 스프링 데이터 jpa 기능
스프링 데이터 jpa가 공통 기능을 제공하는데 그 외에 또 다른 기능을 제공합니다.
1. 메서드 이름으로 쿼리 생성
메서드 이름으로 쿼리를 자동으로 생성합니다. jpql을 자동생성해주고 메서드 이름을 findByEmailAndName라고 하면 이름을 분석해서 jpql select 문을 만들어줍니다. 인터페이스를 스프링 데이터 jpa가 자동으로 구현체를 만들고 등록해준다고 했는데 그 구현체에 jpql, 파라미터 바인딩, rs가 다 자동으로 작성됩니다. 코딩할 것이 인터로 제공되어서 코딩량이 확 줍니다.
2. @Query
또 인터에 직접 쿼리를 작성하고 싶으면 JPA 네이티브(직접 작성 쿼리) 쿼리를 지원해서 그냥 sql을 넣을 수 있고 @Query가 붙은 메서드를 item 레포 구현체에서 호출해서 쓰면 그 sql을 사용할 수 있습니다. 이렇게 해서 기본적인 crud를 할 수 있고 간단한 것은 sql로 인터로 바로 쓸 수 있는 것이 핵심입니다. 컴퓨터가 자동화할 일은 컴퓨터에게 맡기는 것입니다.
- 장점
탬플릿을 쓸 때와 비해서 코딩량이 줄어들고 엔터티를 써서 도메인 클래스를 중요하게 다루고 비즈 로직을 이해하기 쉽습니다. sql을 쓰면 sql을 다 봐야 비즈니스 로직이 이해되는데 엔터티와 코드만 봐도 비즈로직을 이해할 수 있고 sql 작성할 시간에 테스트 케이스 작성에 시간을 쏟을 수 있습니다.
> 또한 도메인 클래스 중심으로 돌아가서 sql 의존에서 벗어나서 자바 컬랙션을 다루듯이 자연스럽게 테스트를 작성하기 더 쉬워집니다.
+ 그런데 스프링 데이터 jpa를 쓰면 그냥 orm 안 쓰고 sql 쓴다고 하시는 분들이 많습니다. 이것은 jpa 자체에 대한 이해가 꼭 필요하기 때문이고 DB 설계에 대한 이해를 잘 알아야하기 때문입니다. 따라서 jpa, DB를 잘 알고 있어야합니다.
- 스프링 데이터 jpa 주요 기능
crud는 스프링 데이터에 공통으로 제공되는데 spring data jpa면 jpa에 특화된 기능을 더 제공하는 것입니다. 레디스면 레디스 특화 기능을 제공합니다.
1. 공통 인터페이스 기능
스프링 데이터가 이런 기본 crud를 제공하고
스프링 데이터 jpa로 가면 findALl 이런게 다 특화되어 제공이 됩니다. 그래서 코드량이 줍니다.
jpa 레포 인터를 열어보면 수많은 기능이 다 제공이 됩니다. 우리가 상상할 수 있는 count 쿼리 등이 전부 다 공통으로 제공됩니다. 또한 기본 crud, 페이징을 인터가 상속받아 제공합니다.
-> 사용법
아이템 레포 인터에서 jpa 레포 인터를 상속받으면 끝납니다. 그러면 스프링 데이터 jpa가 제공하는 공통 기능을 다 받을 수 있다. 제네릭에 jpa가 관리하는 관련 엔터티와 그 엔터티 pk 타입을 넣으면 되고 jpa 레포가 제공하는 기본 crud를 공짜로 쓸 수 있습니다.
이 아이템 레포의 구현체는 동적 프록시로 스프링 데이터 jpa가 만들어줍니다. 만든 구현 클래스를 빈에 등록도 해줍니다.
// mybatis
@Override
public Optional<Item> findById(Long id) {
return itemMapper.findById(id);
}
// 스프링 데이터 jpa
@Override
public Optional<Item> findById(Long id) {
return springDataJpaItemRepository.findById(id);
}
그래서 개발자는 mybatis 때처럼 인터만 만들면 기본 crud를 쓸 수 있습니다. 심지어 상속도 받아서 코드 칠 필요도 없이 그냥 등록된 구현체의 메서드를 쓰면 됩니다.
2. 쿼리 메서드 기능
인터에 메서드만 적어두면 메서드 이름을 분석해서 쿼리를 자동으로 만들어주고 파라미터 바인딩에 객체 반환(rs)도 실행해줍니다. jpa도 역시 sql, 파라미터, rs가 전부입니다.
인터에 메서드만 적어두면 메서드 이름을 분석해서 쿼리를 자동으로 만들어주고 실행해줍니다. 예를들어서 이름을 찾고 이름이 나이 조건도 있는 로직이 있다고 치면 where이 있는 조회니 jpql 작성하고 파라미터 이름기반으로 바인딩하고 결과 rs를 getResultList()로 해야합니다. 바인딩은 setParameter로 탬플릿 때와 비슷하게 키, 벨류로 만듭니다. 순수 jpa를 사용했으면 이것을 다 개발자가 직접 했어야 했습니다.
이 코드를 스프링 데이터 jpa를 쓰면 공통 crud는 제공을 한다고 했는데 지금 작성하려는 로직은 우리 비즈니스에만 있는 것이지 공통으로 제공되는 게 아닙니다. username, age 이런게 우리 프로젝트에만 있는 것입니다. 그래서 이런 것을 메서드 이름만 적으면 이름을 분석해서 필요한 jpql을 만들어서 실행해주고 위에 코드가 다 만들어지는 것입니다.
> 이전에 탬플릿에서 sql만 작성하면 다 해준다고 했는데 사실 그때는 파라미터 바인딩, rs도 만들었어야 했는데 스프링 데이터 jpa를 쓰면 진짜로 메서드 이름만 적어도 다 만들어줍니다. 마지막에 jpql을 sql로 바꿔서 실행해줍니다.
-> 규칙
메서드 이름을 가지고 이렇게 편리하게 해주는 것은 다 규칙이 있습니다. 조회는 findBy로 시작해야하고 by 다음에 조건이 오고 and 조건을 적을 수 있습니다. count, limit 이런 조건도 다 메서드 이름으로 넣을 수 있습니다.
-> jpql 직접 사용
쿼리 메서드로 메서드 이름을 분석할 수도 있고 근데 sql이 복잡한 경우 jpql을 직접 작성할 수도 있습니다.
- 스프링 데이터 jpa 적용
스프링 데이터 jpa를 우리 플젝에 적용해 보겠습니다. 그래들에 라이브러리를 추가합니다.
-> 레포지토리
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);
}
이제 적용을 해봅니다. SpringDataJpaItemRepository 인터를 만들고 jpa레포를 상속받습니다. 얘가 위에서 jpa 레포를 상속받은 item레포이고 얘를 스프링 데이터 jpa가 구현체를 만들어서 빈 등록할 것입니다. 얘가 어떤 것을 관리하는 레포냐면 jpa가 관리한다는 것은 엔터티를 관리하는 것이고 엔터티의 저장소로 사용되는 것입니다. 그래서 관리할 엔터티 타입, 그 엔터티의 pk 타입을 넣어줍니다.
> 이러면 스프링 데이터 jpa를 상속받았으니 스프링 데이터 jpa에서 제공하는 기본적인 crud 기능은 다 쓸 수 있습니다. 이게 구현체도 만들어 주고 빈 등록도 해줄 것이니 이걸 가져도 쓰기만 하면 됩니다.
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);
여기에 쿼리 메서드를 사용합니다. 상품명을 찾는 것을 메서드 이름으로 잡고 이러면 where itemname like이 되는 것입니다. 이름으로만 조회하는 것이고 가격으로만 조회하는 것을 만듭니다. lessThanEqual로 합니다. 보면 매개로 들어오는 것이 파라미터 바인딩 될 것들입니다. 당연히 레포의 매개로 들어오는 것들은 다 파라미터였습니다.
> 그리고 상품명과 가격으로 조회하는 것을 쿼리 메서드로 만듭니다. 상품명과 가격이 순서대로 매개로 들어갑니다. 근데 이게 너무 깁니다.
> 이럴 경우에는 jpql을 쿼리를 직접 사용해야합니다. 파라미터를 상품명과 가격을 받고 @Query로 해서 jpql을 적고 where에 파라미터 바인딩 될 곳에 이름기반 ':'로 합니다. 직접 사용할 때는 꼭 @Param을 Mybatis처럼 넣어야합니다. 직접 쿼리를 작성할 때 객체 관점의 jpql이라서 잘 못 쓰면 컴파일 오류가 발생합니다. 이처럼 type safe한 부분이 jpql에는 있습니다.
+ 지금 3개를 만들었고 가격조건, 이름 조건, 둘 다 사용한 조건입니다. 지금 보면 조건 없이 전부 다 검색하는 것을 안만드는데 jpa가 공통으로 제공해서 안 만들어도 됩니다. 하지만 jpql 직접한 것은 동적쿼리로 적어야했습니다. 근데 스프링 데이터 jpa도 동적 쿼리가 약해서 querydsl로 합니다.
-> 일반 jpa와 비교
// 순수 jpa
@Override
public Optional<Item> findById(Long id) {
Item item = em.find(Item.class, id);
return Optional.ofNullable(item);
}
// 순수 jpa가 자동화 해주는 것
@Override
public Optional<Item> findById(Long id) {
String jpql = "select i from Item i where i.id = :id";
return Optional.ofNullable(em.createQuery(jpql, Item.class)
.setParameter("id", id)
.getSingleResult());
}
완성한 것을 보면 jpa만 사용했을 때도 편리했는데 더 편리해졌습니다. jpa를 직접 사용할 때는 jpa 레포 클래스 구현체를 item 레포 인터페이스를 구현하고 em.으로 메서드를 사용했는데 이제 이런 것을 다 제공하고 스프링 데이터 jpa를 상속받아 인터페이스를 만들고 필요한 것을 쿼리 메서드와 jpql로 만들면 그 인터페이스의 구현체를 빈 등록해줘서 jpa 레포 item레포 구현 클래스에서 가져와 쓰면 되는 것입니다.
필자는 SpringDataJpaItemRepository를 jpa레포를 상속받고 바로 구현을 하려고 alt + insert를 눌렀습니다. 하지만 이것부터 사실 안해도 되는 것입니다. 기본적인 기능은 다 제공을 하고 이제 필요한 것만 쿼리 메서드나 jpql로 작성하면 되는 것입니다.
- JPA 적용
지금까지 인터를 만든 것이고 이제 이것을 사용해보겠습니다.
@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {
private final ItemRepository itemRepository;
private final SpringDataJpaItemRepository springDataJpaItemRepository;
@Override
public Item save(Item item) {
return springDataJpaItemRepository.save(item);
}
기능이 다 있으니 서비스에서 "그냥 스프링 데이터 jpa 인터 구현체를 주입해서 사용하면 되는 것 아닌가?"라고 생각할 수 있습니다. 근데 그렇게 못하는 이유가 있습니다. 얘는 item 레포를 구현한게 아니고 jpa 레포를 받아서 만든 것이라서 쓸 수 없습니다. 또한 OCP를 생각했을 때 item 서비스의 코드를 다 고쳐야합니다.
따라서 item 레포 구현체인 jpa 레포V2 구현체를 만들어서 프로젝트 계층을 맞추는 게 맞습니다.
@Slf4j
@Repository
@Transactional
@RequiredArgsConstructor
public class JpaRepositoryV2 implements ItemRepository {
private final SpringDataJpaItemRepository repository;
그리고 이 구현체에서 스프링 데이터 jpa 구현 레포를 주입받아서 씁니다. 그리고 레포 v2에서 스프링 데이터 jpa에 만든 메서드와 스프링 데이터 jpa가 제공하는 메서드를 가져다 씁니다. jpa는 무조건 트랜잭션 안에서 일어난다고 했습니다. @트랜잭션을 붙입니다.
-> 구현
@Override
public Item save(Item item) {
return repository.save(item);
}
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
Item item = repository.findById(itemId).orElseThrow();
item.setItemName(updateParam.getItemName());
item.setPrice(updateParam.getPrice());
item.setQuantity(updateParam.getQuantity());
}
1) save
한줄로 끝납니다. 이건 스프링 데이터 jpa가 제공하는 메서드입니다. 스프링 데이터 jpa에 제네릭을 넣었으므로 저장하는 타입을 다 알 수 있습니다. 제네렉 넣은 것이 다 스프링 데이터 jpa 상속 받은 부모에서 사용됩니다.
2) update
레포에서 id로 찾고 이게 옵셔널이라서 없으면 예외를 던지고 jpa에서 데이터 변경은 컬랙션처럼 set하면 끝납니다.
3) findById
// 스프링 데이터 jpa
@Override
public Optional<Item> findById(Long id) {
return repository.findById(id);
}
// 순서 jpa
@Override
public Optional<Item> findById(Long id) {
Item item = em.find(Item.class, id);
return Optional.ofNullable(item);
}
findById가 제공되어서 이미 있습니다. 이게 또 반환타입이 기가막히게 옵셔널입니다. 그래서 강사님께서 큰그림으로 item 레포 인터페이스에 옵셔널을 넣어 추상 메서드들을 정의한 것이고 원래도 반환타입이 하나면 옵셔널이 맞습니다. (null이 가능하면 옵셔널) 순수 jpa와 비교해보면 jpa 메서드만 호출하면 rs까지 해서 반환했었던 것이 동일합니다.
4) findAll
@Override
public List<Item> findAll(ItemSearchCond cond) {
Integer maxPrice = cond.getMaxPrice();
String itemName = cond.getItemName();
if (StringUtils.hasText(itemName) && maxPrice != null) {
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();
}
}
조건 때문에 지저분해집니다. String에 StringUtils라는게 있습니다. 상품명에 조건이 있고 가격이 null이 아니면 상품명과 가격 조건이 있는 것으로 두가지 조건이 있는 경우 스프링 데이터 jpa에 만든 메서드를 쓰는데 jpql로 만든 메서드를 씁니다. 조건이 하나씩만 있는 경우와 없는 경우도 다 스프링 데이터 jpa 메서드로 합니다.
@Override
public List<Item> findAll(ItemSearchCond cond) {
String jpql = "select i from Item i";
Integer maxPrice = cond.getMaxPrice();
String itemName = cond.getItemName();
if (StringUtils.hasText(itemName) || maxPrice != null) {
jpql += " where";
}
boolean andFlag = false;
if (StringUtils.hasText(itemName)) {
jpql += " i.itemName like concat('%',:itemName,'%')";
andFlag = true;
}
if (maxPrice != null) {
if (andFlag) {
jpql += " and";
}
jpql += " i.price <= :maxPrice";
}
log.info("jpql={}", jpql);
TypedQuery<Item> query = em.createQuery(jpql, Item.class);
if (StringUtils.hasText(itemName)) {
query.setParameter("itemName", itemName);
}
if (maxPrice != null) {
query.setParameter("maxPrice", maxPrice);
}
return query.getResultList();
}
이전에 순수 jpa로 할 때는 이 메서드들이 제공이 안되니 지금 if else-if에 있는 메서드에 있는 것을 직접 V1 레포의 findAll에 직접 작성을 한 것인데 이제 잘 제공이 되니 이렇게 가져다 쓰면 됩니다. 스프링 데이터 jpa의 메서드를 사용할 경우 rs까지 해버려서 if - else에서 바로 return을 합니다. 순수 jpa의 경우 jpql을 조건에 따라 완성하고 호출은 getResultList()를 하면 한 번만 됩니다. 실무에서는 이럴 때 무조건 동적쿼리로 진행합니다.
- 의존 관계와 구조
서비스가 item 레포에 의존하기 때문에 서비스에서 스프링 데이터 jpa 상품 레포를 사용할 수 없습니다. OCP가 안되고 코드의 변경이 일어납니다.
-> 그림
따라서 서비스가 item 레포에 의존하니 item 레포 구현체를 만들고 구현체에서 스프링 데이터 jpa 상품 레포를 사용하게 했습니다.
런타임에는 item 레포 구현체가 스프링 데이터 jpa item 레포 동적 프록시 객체를 주입해서 사용합니다.
1) save
이건 jpa가 기본으로 제공하는 메서드로 타고 들어가보면 결과적으로 순수 jpa의 persist가 호출됩니다.
2) update
트랜잭션 커밋 시점에 반영됩니다.
3) findById
이것도 타고 들어가보면 결국 em.find를 사용합니다.
4) findAll
조건이 2개여도 길어집니다. 그래서 jpql을 직접 쓰고 동적쿼리는 querydsl을 사용해야합니다.
- 실행
@Configuration
@RequiredArgsConstructor
public class SpringDataJpaConfig {
private final SpringDataJpaItemRepository repository;
@Bean
public ItemService itemService() {
return new ItemServiceV1(itemRepository());
}
@Bean
public ItemRepository itemRepository() {
return new JpaRepositoryV2(repository);
}
}
config를 바꾸고 실행해봅니다. 이제 em이 필요가 없고 spring데제item 레포가 필요합니다. 얘는 스프링 데이터 jpa가 구현체를 빈으로 등록해주기에 생성자 주입을 받아야합니다. app의 import도 바꿉니다.
@Override
public List<Item> findAll(ItemSearchCond cond) {
Integer maxPrice = cond.getMaxPrice();
String itemName = cond.getItemName();
if (StringUtils.hasText(itemName) && maxPrice != null) {
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();
}
}
테스트를 다 돌려보면 findItem에서 실패합니다. like를 사용하려면 %를 넣어야합니다. 이래서 테스트 케이스를 잘 작성해야하는 것입니다. 이렇게 조금의 string만 넣었는데 문법 오류가 난 것을 보면 정말 sql을 직접 다 작성하면 버그가 많았을 것입니다. 이 부분을 type safe하게 querydsl이 해결해줍니다.
'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글
spring PART.Querydsl (0) | 2023.05.18 |
---|---|
spring PART.중간점검 4 (0) | 2023.05.17 |
spring PART.JPA (0) | 2023.05.16 |
spring PART.MyBatis (0) | 2023.05.15 |
spring PART.데이터 접근 기술 테스트 (0) | 2023.05.14 |