https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2#reviews
김영한님의 위 강의를 기반으로 작성하였습니다.
데이터 접근을 편리하게 해주는 기술에는 SQL Mapper,ORM 이 있다.
SQL Mapper
EX) jdbcTemplate, myBatis
ORM
EX) JPA
JPA, Spring Data JPA, Query dsl의 장,단점을 파악하고, 적절하게 각 기술들을 활용하는 방법에 대해 알아보자.
JPA
JdbcTemplate이나 MyBatis 같은 SQL 매퍼 기술은 SQL을 개발자가 직접 작성해야 하지만, JPA를 사용하면 SQL도 JPA가 대신 작성하고 처리해준다.
실무에서는 JPA를 더욱 편리하게 사용하기 위해 스프링 데이터 JPA와 Querydsl이라는 기술을 함께 사용한다.
중요한 것은 JPA이다. 스프링 데이터 JPA, Querydsl은 JPA를 편리하게 사용하도록 도와주는 도구라 생각하면 된다.
SQL 중심적 개발의 문제점
-반복 코드 ( 매번 CRUD 무한 반복....)
- 객체와 관계형 DB의 패러다임 불일치
객체 지향 프로그래밍: 추상화, 캡슐화, 정보은닉, 상속, 다형성 등 다양한 장치가 있다.
하지만 RDB와 객체는 패러다임의 불일치로, 위와 같은 기능들이 완전히 제공되지 않는다.
객체를 영구히 저장하기 위해 객체를 SQL로 변환하고 DB에 저장해야한다.
이러한 변환을 대신해주는 것이 SQL 매퍼 (jdbc Template, my batis 등)
ex 상속
테이블엔 상속 관계가 없고, 유사한 슈퍼타입, 서브 타입으로 대체한다.
만약 Table에서 Album을 insert하려면, Album관련 데이터(artist)는 album 테이블에 insert 쿼리를 날리고,
Item 관련 정보(Name) 는 Item에 insert 해야한다.
즉, 객체관점에선 1번만 update해도 될 것 같지만, 실제론 sql이 2번 나가는 것.
이처럼 RDB와 객체 간의 패러다임 불일치로 인해서 차이점이 발생하게 된다.
자바 컬렉션에 album이라는 객체 저장시.
객체 관점
위와 같이 한줄의 코드로 손쉽게 가능.
하지만 만약 SQL로 가게 되면 어렵다.
연관관계
객체: 참조를 사용 (item.getAlbum() )
테이블: FK를 통해 테이블 join 수행.
만약 item객체가, 자신이 가진 FK 인 AlbumId를 가져오려면?
item.getAlbum.getId(). -> 상당히 번잡하다.
객체 모델링을 자바 걸렉션에서 관리하면?
하지만 실제 테이블에선, join을 통해 수행되기에 SQl 수행 시 그때의 시점으로 , 객체 그래프가 제한된다.
ex) member join Order에서 조회 시, Team은 알 수 없다.
위의 코드만 가지고, 결과가 올바르게 출력될지 확신할 수 없다. ( member가 연관된 Team, Order를 join해서 가지고 왔는가? )
즉, 계층형 아키텍처에서 진정한 의미의 계층 분할이 어려워지는 것 (물리적으로는 계층이 분할되어있지만, 논리적으로는 분할되어있지 않다.)
Lazy, eager fetch를 적절하게 사용해서 적절하 필요한 연관 객체만 로딩해서 처리한다.
테이블을 객체답게 모델링할 수록, 매핑 작업만 늘어난다.
객체를 자바 컬렉션에 저장하듯 DB에 저장할 수 없을까? -> ORM의 이유
객체는 객체답게 설계하고, RDB는 RDB답게 설계한 뒤, ORM을 사용해서 매핑하자!
JPA는 애플리케이션, JDBC 사이에서 동작
애플리케이션에서 JPA 사용 ->JPA가 jdbc API 를 사용 -> JDBC API가 SQL 생성하여 DB에 전달
JPA 동작
DAO에서 객체를 JPA에 넘겨준다
JPA가 entity를 분석하고 SQL 생성 후, JDBC API(DB마다 SQL의 차이, 커넥션 연결 코드의 차이 등등을 해결하는 공통 표준)
를 사용해서 DB에 전달.
JPA 사용 장점
-생산성, 유지보수성 증가
-패러다임의 불일치 해결
-SQL 중심 -> 객체 중심의 개발 가능
- 성능 최적화 기능.
성능최적화 기능
- 위에서 SQL은 1번만 수행된다. (만약 트랜잭션 내에서 read 2번 발생 시, 캐시에서 가져올 수 있기에, 중복 쿼리가 나가지 않는다.
- 애플리케이션에서, 실제 DB 보다 한단계 높은 Isolation level로 수행되어질 수 있다.
트랜잭션 Isolation level
- READ UNCOMMITTED : DIRTY READ현상 발생(트랜잭션이 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있게 되는 현상)
- READ COMMITTED : commit된 데이터만 read 가능 (문제: select가 항상 같은 결과를 가져올 수 없다.)
- REPEATABLE READ: phantom read 문제 존재
- SERIALIZABLE: 완전
Write을 commit 시점에 몰아서 하는 것으로 자동으로 성능 최적화가 수행된다. (Delay Wriate)
데이터의 변경사항을 바로 디스크에 쓰는 대신 메모리나 버퍼에 임시로 저장한다.
- 여러 작업을 묶어서 한 번에 디스크에 쓰기 때문에 I/O 작업 횟수가 감소하고, 디스크에 접근하는 시간이 줄어든다.
- 여러 트랜잭션이 동시에 발생할 경우, 쓰기 충돌을 최소화하고 데이터 일관성을 유지할 수 있다.
예를 들어, 동일한 데이터를 동시에 수정하는 두 개의 트랜잭션이 있다면, 쓰기 지연을 사용하면 한 트랜잭션이 완료된 후에 변경사항을 디스크에 기록하므로 데이터 일관성이 유지된다.
- 쓰기 지연을 사용하면 트랜잭션이 롤백되는 경우 변경사항을 디스크에 기록하지 않고 버려서 disk Write 최소화 가능
쓰기 지연을 사용하면 롤백이 발생하면 변경사항을 취소하고 메모리나 버퍼에서 삭제함으로써 오류가 있는 트랜잭션의 영향을 최소화.
데이터 update, delete 등 Write에 관한 lock은 해당 데이터 소스에 접근하는 모든 접근을 막는다.
(read lock은 read끼리는 접근 가능)
하지만, write를 몰아서 수행하는 것으로, write Lock을 commit 시점에 몰아서 잡는 것으로 lock을 잡는 시간을 최적화한다.
(2PL lock 프로토콜을 사용하면 보통 미리 lock을 전부 잡아두고, 마지막에 한번에 놓기는 한다. )
순수 JPA만을 사용한 Repository 영역 코드
@Configuration
public class JpaConfig {
private final EntityManager em;
public JpaConfig(EntityManager em) {
this.em = em;
}
@Bean
public ItemService itemService() {
return new ItemServiceV1(itemRepository());
}
@Bean
public ItemRepository itemRepository() {
return new JpaItemRepository(em);
}
}
@Slf4j
@Repository
@Transactional
public class JpaItemRepository implements ItemRepository {
private final EntityManager em;
public JpaItemRepository(EntityManager em) {
this.em = em;
}
@Override
public Item save(Item item) {
em.persist(item);
return item;
}
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
Item findItem = em.find(Item.class, itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
@Override
public Optional<Item> findById(Long id) {
Item item = em.find(Item.class, id);
return Optional.ofNullable(item);
}
@Override
public List<Item> findAll(ItemSearchCond cond) {
String jpql = "selectxxx i from Item i";
Integer maxPrice = cond.getMaxPrice();
String itemName = cond.getItemName();
if (StringUtils.hasText(itemName) || maxPrice != null) {
jpql += " where";
}
boolean andFlag = false;
List<Object> param = new ArrayList<>();
if (StringUtils.hasText(itemName)) {
jpql += " i.itemName like concat('%',:itemName,'%')";
param.add(itemName);
andFlag = true;
}
if (maxPrice != null) {
if (andFlag) {
jpql += " and";
}
jpql += " i.price <= :maxPrice";
param.add(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();
}
}
private final EntityManager em : 생성자를 보면 스프링을 통해 엔티티 매니저( EntityManager ) 라는 것을 주입받은 것을 확인할 수 있다. JPA의 모든 동작은 엔티티 매니저를 통해서 이루어진다. 엔티티 매니저는 내부에 데이터소스를 가지고 있고, 데이터베이스에 접근할 수 있다.
@Transactional : JPA의 모든 데이터 변경(등록, 수정, 삭제)은 트랜잭션 안에서 이루어져야 한다. 조회는 트랜잭션이 없어도 가능하다. 변경의 경우 일반적으로 서비스 계층에서 트랜잭션을 시작하기 때문에 문제가 없다. 하지만 이번 예제에서는 복잡한 비즈니스 로직이 없어서 서비스 계층에서 트랜잭션을 걸지 않았다. JPA에서는 데이터 변경시 트랜잭션이 필수다. 따라서 리포지토리에 트랜잭션을 걸어주었다. 다시한번 강조하지만 일반적으로는 비즈니스 로직을 시작하는 서비스 계층에 트랜잭션을 걸어주는 것이 맞다.
참고: JPA를 설정하려면 EntityManagerFactory , JPA 트랜잭션 매니저( JpaTransactionManager ), 데이터소스 등등 다양한 설정을 해야 한다. 스프링 부트는 이 과정을 모두 자동화 해준다.
JPA 예외 변환
@Repository
@Transactional
public class JpaItemRepositoryV1 implements ItemRepository {
private final EntityManager em;
@Override
public Item save(Item item) {
em.persist(item);
return item;
}
}
EntityManager 는 순수한 JPA 기술이고, 스프링과는 관계가 없다.
따라서 엔티티 매니저는 예외가 발생하면 JPA 관련 예외를 발생시킨다.
JPA는 PersistenceException 과 그 하위 예외를 발생시킨다.
추가로 JPA는 IllegalStateException , IllegalArgumentException 을 발생시킬 수 있다.
그렇다면 JPA 예외를 스프링 예외 추상화( DataAccessException )로 어떻게 변환할 수 있을까? 비밀은 바로 @Repository 에 있다.
@Repository의 기능
@Repository 가 붙은 클래스는 컴포넌트 스캔의 대상이 된다.
@Repository 가 붙은 클래스는 예외 변환 AOP의 적용 대상이 된다.
스프링과 JPA를 함께 사용하는 경우 스프링은 JPA 예외 변환기 ( PersistenceExceptionTranslator )를 등록한다.
예외 변환 AOP 프록시는 JPA 관련 예외가 발생하면 JPA 예외 변환기를 통해 발생한 예외를 스프링 데이터 접근 예외로 변환한다.
결과적으로 리포지토리에 @Repository 애노테이션만 있으면 스프링이 예외 변환을 처리하는 AOP를 만들어준다.
동적 쿼리 문제
JPA를 사용해도 동적 쿼리 문제가 남아있다. 동적 쿼리는 뒤에서 설명하는 Querydsl이라는 기술을 활용하면 매우 깔끔하게 사용할 수 있다. 실무에서는 동적 쿼리(조건어 따라 다르게 수행되야하는 쿼리) 문제 때문에, JPA 사용할 때 Querydsl도 함께 선택하게 된다.
*JPA에서, 단순히 PK를 기준으로 조회하는 것이 아닌, 여러 데이터를 복잡한 조건으로 사용하려면, JPQL을 직접 사용해야한다.
아래에서 maxPrice 가 null 일 경우, null 이 아닐 경우, 서로 다른 쿼리로 변경되어 나가게된다. ( 동적 쿼리 )
이런 쿼리는 querydsl로 최적화 가능.
public List<Item> findAll(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
String sql = "select id, item_name, price, quantity from item";
//동적 쿼리
if (StringUtils.hasText(itemName) || maxPrice != null) {
sql += " where";
}
boolean andFlag = false;
List<Object> param = new ArrayList<>();
if (StringUtils.hasText(itemName)) {
sql += " item_name like concat('%',?,'%')";
param.add(itemName);
andFlag = true;
}
if (maxPrice != null) {
if (andFlag) {
sql += " and";
}
sql += " price <= ?";
param.add(maxPrice);
}
log.info("sql={}", sql);
return template.query(sql, itemRowMapper(), param.toArray());
}
Spirng Data JPA
-CRUD+ 쿼리
- 동일한 인터페이스
-페이징 처리 통합
-메소드이름으로 쿼리 생성
-스프링 MVC에서 id값만 넘겨도, 도메인 클래스로 바인딩
다양한 데이터베이스에 관계없이 위와 같은 기능을 추상화해서 제공한다.
가장 대표적인 기능은 다음과 같다.
- 공통 인터페이스 기능 (기본 CRUD를 자동 제공 )
- 쿼리 메서드 기능(findby필드명 과 같이 쿼리메소드 제공)
공통 인터페이스
JpaRepository 인터페이스를 통해서 기본적인 CRUD 기능 제공한다.
공통화 가능한 기능이 거의 모두 포함되어 있다.
CrudRepository 에서 fineOne() findById() 로 변경되었다.
public interface ItemRepository extends JpaRepository<Item, Long> {
}
JpaRepository 인터페이스를 인터페이스 상속 받고, 제네릭에 관리할 <엔티티, 엔티티ID> 를 주면 된다. 그러면 JpaRepository 가 제공하는 기본 CRUD 기능을 모두 사용할 수 있다.
JpaRepository 인터페이스만 상속받으면 스프링 데이터 JPA가 프록시 기술을 사용해서 구현 클래스를 만들어준다.
그리고 만든 구현 클래스의 인스턴스를 만들어서 스프링 빈으로 등록한다.
따라서 개발자는 구현 클래스 없이 인터페이스만 만들면 기본 CRUD 기능을 사용할 수 있다.
쿼리 메서드 기능
스프링 데이터 JPA는 인터페이스에 메서드만 적어두면, 메서드 이름을 분석해서 쿼리를 자동으로 만들고 실행해주는 기능을 제공한다.
스프링 데이터 JPA는 메서드 이름을 분석해서 필요한 JPQL을 만들고 실행해준다. 물론 JPQL은 JPA가 SQL로 번역해서 실행한다.
물론 그냥 아무 이름이나 사용하는 것은 아니고 다음과 같은 규칙을 따라야 한다.
스프링 데이터 JPA가 제공하는 쿼리 메소드 기능 조회: find...By , read...By , query...By , get...By
예:)
findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 된다.
COUNT: count...By 반환타입 long
EXISTS: exists...By 반환타입 boolean
삭제: delete...By , remove...By 반환타입 long
DISTINCT: findDistinct , findMemberDistinctBy
LIMIT: findFirst3 , findFirst , findTop , findTop3
그외에 규칙은 docs를 참고하자
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#appendix.query.method.subject
스프링 데이터 JPA가 제공하는 JpaRepository 인터페이스를 인터페이스 상속 받으면 기본적인 CRUD 기능을 사용할 수 있다.
그런데 이름으로 검색하거나, 가격으로 검색하는 기능은 공통으로 제공할 수 있는 기능이 아니다.
따라서 쿼리 메서드 기능을 사용하거나 @Query 를 사용해서 직접 쿼리를 실행하면 된다.
여기서는 데이터를 조건에 따라 4가지로 분류해서 검색한 다. 모든 데이터 조회
이름 조회
가격 조회
이름 + 가격 조회
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()
메서드 이름으로 쿼리를 실행하는 기능은 다음과 같은 단점이 있다.
1. 조건이 많으면 메서드 이름이 너무 길어진다.
2. 조인 같은 복잡한 조건을 사용할 수 없다.
메서드 이름으로 쿼리를 실행하는 기능은 간단한 경우에는 매우 유용하지만, 복잡해지면 직접 JPQL 쿼리를 작성하는 것이 좋다.
쿼리를 직접 실행하려면 @Query 애노테이션을 사용하면 된다.
메서드 이름으로 쿼리를 실행할 때는 파라미터를 순서대로 입력하면 되지만, 쿼리를 직접 실행할 때는 파라미터를 명시적으로 바인딩 해야 한다.
파라미터 바인딩은 @Param("itemName") 애노테이션을 사용하고, 애노테이션의 값에 파라미터 이름을 주면 된다.
의존관계와 구조
ItemService 는 ItemRepository 에 의존하기 때문에 ItemService 에서 SpringDataJpaItemRepository 를 그대로 사용할 수 없다.
물론 ItemService 가 SpringDataJpaItemRepository 를 직접 사용하도록 코드를 고치면 되겠지만, 우리는 ItemService 코드의 변경없이 ItemService 가 ItemRepository 에 대한 의존을 유지하면서 DI를 통해 구현 기술을 변경하고 싶다.
조금 복잡하지만, 새로운 리포지토리를 만들어서 이 문제를 해결해보자
여기서는 JpaItemRepositoryV2 가 ItemRepository 와 SpringDataJpaItemRepository 사이를 맞추기 위한 어댑터 처럼 사용된다.
public interface ItemRepository {
Item save(Item item);
void update(Long itemId, ItemUpdateDto updateParam);
Optional<Item> findById(Long id);
List<Item> findAll(ItemSearchCond cond);
}
@Repository
@Transactional
@RequiredArgsConstructor
public class JpaItemRepositoryV2 implements ItemRepository {
private final SpringDataJpaItemRepository repository;// Spring Data JPA 인터페이스 사용
@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());
}
위와 같이 Spring Data JPA로 인하여, 인터페이스에 맞게 프록시로 생성되는 구현체를 사용하는 Repository 구현 클래스를 만들고,
그에 대한 인터페이스를 만든다.
-> 이제 update 로직을 바꾸고 싶다면, Service에서 바꾸는 것이 아닌
JpaItemRepositoryV2 는 ItemRepository 를 구현한다. 그리고 SpringDataJpaItemRepository 를 사용한다.
런타임의 객체 의존관계는 다음과 같이 동작한다.
itemService -> jpaItemRepositoryV2 -> springDataJpaItemRepository(프록시 객체)
이렇게 중간에서 JpaItemRepository 가 어댑터 역할을 해준 덕분에 ItemService 가 사용하는 ItemRepository 인터페이스를 그대로 유지할 수 있고 클라이언트인 ItemService 의 코드를 변경하지 않아도 되는 장점이 있다.
스프링 데이터 JPA도 스프링 예외 추상화를 지원한다. 스프링 데이터 JPA가 만들어주는 프록시에서 이미 예외 변환을 처리하기 때문에, @Repository 와 관계없이 예외가 변환된다.
pagable 인터페이스, 페이징 구현체도 Spring data JPA가 제공해준다.
하지만 여전히, 동적 쿼리를 적절하게 만들 수 없다....
ex
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();
}
}
위의 예시는 간단한 예시. 분기점이 많아질수록 코드가 길어지고, 복잡해진다.
Querydsl
왜 필요한가?
긴급 요구사항 추가( 검색 조건 추가 등등)
-> 완벽하게 버그가 없을까? 사소한 버그들이 많다. 실제 배포에서 버그 -> 크리티컬 이슈
query는 문자이다. 실행 전까지 작동여부를 확인할 수 없다. (런타임 에러)
만약 SQL이 클래스처럼 타입이있고, 자바코드로 작성 가능하다면? -> type safe.
컴파일 타임에서 에러 감지 가능!
ex)
-> 런타임에러. 해당 쿼리 실행전까지는 에러가 검출 x
querydsl 사용 시, 이런 문제는 컴파일 에러에서 검출 가능.
query dsl
쿼리를 java로 type-safe하게 개발할 수 있게 지원하는 프레임워크
주로 JPA(JPQL) 쿼리에서 사용
JPA의 쿼리사용방법
1) JPQL
장점: sql과 유사하여 금방 익숙해짐
단점: type-safe 가 아님. 동적 쿼리 생성이 어려움
2)Criteria API
자바 코드로 jpql 작성 가능
장점: 동적 쿼리 용이( 복잡해서 의미 없다.)
단점: type-safe x, 매우 복잡하다.
3)MetaModel Criteria API( type-safe)
장점: type-safe, 동적 쿼리 용이( 복잡해서 의미 없다.)
단점: 매우 복잡함.
QueryDSL
장점: type-safe, 단순하고 쉽다.
단점: Q코드 생성을 위한 APT설정이 필요하다.
쿼리에 특화된 프로그램 언어.
다양한 기술들을 querydsl 언어로 추상화 가능. ( JPA 랑 주로 사용함)
type-safe하다.
Querydsl-JPA
작동 방식
지원기능 (JPQL에서 지원하는 거의 모든 기능 제공)
동적쿼리
booleanBuilder 로 동적 쿼리 작성 가능.
Spring Data JPA 사용 프로젝트의 약점은 조회
queryDSL로 복잡한 조회 기능 보완 가능
- 복잡한 쿼리, 동적쿼리
실제 사용
단순 -> Spring Data JPA
복잡 -> Querydsl 사용
하지만 결국 Querydsl은 JPQL을 사용한다.
만약 JPQL로 해결하기 어려운 매우매우 복잡한 쿼리가 있다면, 네이티브 SQL(JDBC Template, MyBatis를 통해서)를 구현해야한다.
공통
Querydsl을 사용하려면 JPAQueryFactory 가 필요하다. JPAQueryFactory 는 JPA 쿼리인 JPQL을 만들기 때문에 EntityManager 가 필요하다.
설정 방식은 JdbcTemplate 을 설정하는 것과 유사하다.
참고로 JPAQueryFactory 를 스프링 빈으로 등록해서 사용해도 된다.
예시
@Repository
@RequiredArgsConstructor
public class MemberChatRoomRepositoryImpl implements MemberChatRoomRepositoryCustom {
private final JPAQueryFactory queryFactory;
public List<MemberChatRoom> getMemberChatRoomWithChatRoomAndClubMemberByClubMember(ClubMember keyClubMember) {
return queryFactory.select(memberChatRoom)
.from(memberChatRoom)
.leftJoin(memberChatRoom.chatRoom, chatRoom).fetchJoin()
.leftJoin(memberChatRoom.clubMember, clubMember).fetchJoin()
.where(memberChatRoom.clubMember.eq(keyClubMember))
.fetch();
}
문제: 아이템 명, price의 상황에 따라 where에 조건 추가하기
ex) 검색어 없으면, 검색어 없이 검색, 검색어 있으면 검색어 가지고 검색.
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();
}
}
public List<Item> findAllOld(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
QItem item = QItem.item;
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(itemName)) {
builder.and(item.itemName.like("%" + itemName + "%"));
}
if (maxPrice != null) {
builder.and(item.price.loe(maxPrice));
}
List<Item> result = query
.select(item)
.from(item)
.where(builder)
.fetch();
return result;
}
더 효율적이게 동적 쿼리 작성하기.
(메소드로 조건을 만들어서 재사용 가능하게, 가독성 올라가게)
public List<Item> findAll(ItemSearchCond cond) {
String itemName = cond.getItemName();
Integer maxPrice = cond.getMaxPrice();
return query
.select(item)
.from(item)
.where(likeItemName(itemName), maxPrice(maxPrice))
.fetch();
}
private BooleanExpression likeItemName(String itemName) {
if (StringUtils.hasText(itemName)) {
return item.itemName.like("%" + itemName + "%");
}
return null; //null은 where 조건에서 무시된다.
}
private BooleanExpression maxPrice(Integer maxPrice) {
if (maxPrice != null) {
return item.price.loe(maxPrice);
}
return null;
}
.where(likeItemName(itemName), maxPrice(maxPrice)) -> where(A,B) == where(A AND B)
*Querydsl은 예외 추상화 제공 x
@Repository를 붙여서 예외 추상화 사용하자!
정리
장점:
- 메소드 추출로 코드 재사용 가능
- 쿼리에 문제가 있어도 컴파일 시점에 오류를 막을 수 있다.
- 동적 쿼리를 깔끔하게 사용가능.
- DTO로 조회하는 기능과 같은 최적의 쿼리를 위한 기능 등을 지원한다.
실제 활용
스프링 데이터 JPA 예제와 트레이드 오프
스프링 데이터 JPA 예제를 다시 한번 돌아보자.
중간에서 JpaItemRepositoryV2 가 어댑터 역할을 해준 덕분에 ItemService 가 사용하는 ItemRepository 인터페이스를 그대로 유지할 수 있고 클라이언트인 ItemService 의 코드를 변경하지 않아도 되는 장점이 있다.
구조를 맞추기 위해서, 중간에 어댑터가 들어가면서 전체 구조가 너무 복잡해지고 사용하는 클래스도
많아지는 단점이 생겼다.
실제 이 코드를 구현해야하는 개발자 입장에서 보면 중간에 어댑터도 만들고, 실제 코드까지 만들어야 하는 불편함이 생긴다.
유지보수 관점에서 ItemService 를 변경하지 않고, ItemRepository 의 구현체를 변경할 수 있는 장점이 있다. 그러니까 DI, OCP 원칙을 지킬 수 있다는 좋은 점이 분명히 있다. 하지만 반대로 구조가 복잡해지면서 어댑터 코드와 실제 코드까지 함께 유지보수 해야 하는 어려움도 발생한다.
다른 방법
temService 코드를 일부 고쳐서 직접 스프링 데이터 JPA를 사용하는 방법이다.
DI, OCP 원칙을 포기하는 대신에, 복잡한 어댑터를 제거하고, 구조를 단순하게 가져갈 수 있는 장점이 있다.
트레이드 오프
- DI, OCP를 지키기 위해 어댑터를 도입하고, 더 많은 코드를 유지한다.
- 어댑터를 제거하고 구조를 단순하게 가져가지만, DI, OCP를 포기하고, ItemService 코드를 직접 변경한다.
결국 여기서 발생하는 트레이드 오프는 구조의 안정성 vs 단순한 구조와 개발의 편리성 사이의 선택이다. 이 둘 중에 하나의 정답만 있을까? 그렇지 않다. 어떤 상황에서는 구조의 안정성이 매우 중요하고, 어떤 상황에서는 단순한 것이 더 나은 선택일 수 있다.
개발을 할 때는 항상 자원이 무한한 것이 아니다. 그리고 어설픈 추상화는 오히려 독이 되는 경우도 많다. 무엇보다 추상화도 비용이 든다. 인터페이스도 비용이 든다. 여기서 말하는 비용은 유지보수 관점에서 비용을 뜻한다. 이 추상화 비용을 넘어설 만큼 효과가 있을 때 추상화를 도입하는 것이 실용적이다.
이런 선택에서 하나의 정답이 있는 것은 아니지만, 프로젝트의 현재 상황에 맞는 더 적절한 선택지가 있다고 생각한다. 그리고 현재 상황에 맞는 선택을 하는 개발자가 좋은 개발자라 생각한다.
실용적인 Repository 구조
스프링 데이터 JPA의 기능은 최대한 살리면서, Querydsl도 편리하게 사용할 수 있는 구조를 만들어보겠다.
이제 단순한 기능은 Spring Data JPA에서 가져오고, 복잡한건 QueryDSL을 사용하면 된다.
Spring Data JPA 인터페이스
public interface ClubMemberRepository extends JpaRepository<ClubMember, Long>, ClubMemberRepositoryCustom {
}
querydsl 에 대한 인터페이스
public interface ClubMemberRepositoryCustom {
ClubMember getClubMemberWithUserTokenById(Long id);
}
querydsl에 대한 구현체
@Repository
@RequiredArgsConstructor
@Log4j2
public class ClubMemberRepositoryImpl implements ClubMemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
public ClubMember getClubMemberWithUserTokenById(Long id) {
return queryFactory.select(clubMember)
.from(clubMember)
.leftJoin(clubMember.userToken, userToken).fetchJoin()
.where(clubMember.id.eq(id))
.fetchOne();
}
이제
ClubMemberRepository.getClubMemberDTOWithStepAndUserTokenByEmail()
로 queryDSL의 쿼리를 사용할 수 도 있고,
ClubMemberRepository.findById()
로 Spring Data JPA를 사용한 쿼리도 사용 가능.
이제 단순한건 Spring Data JPA로 구현하고, 복잡한건 queryDSL로 처리할 수 있다.
'Spring boot' 카테고리의 다른 글
[스프링 데이터 접근 활용 기술] 스프링 트랜잭션 propagation 활용 (0) | 2023.06.01 |
---|---|
[스프링 데이터 접근 활용 기술] 스프링 트랜잭션 (0) | 2023.06.01 |
[스프링 데이터 접근 활용 기술] JPA, Spring Data JPA, QueryDSL의 활용 (0) | 2023.05.30 |
[spring 데이터 접근 핵심 원리 7] Spring 예외 처리 (0) | 2023.05.30 |
[spring 데이터 접근 핵심 원리 6] 자바 예외의 이해 (0) | 2023.05.30 |