클래스 기반의 객체는 필드와 메소드를 가지며 상속, 다형성 캡슐화 등의 객체지향적인 개념을 활용할 수 있지만 RDB의 경우 테이블 기반으로 데이터가 저장된다. 객체는 쉽게 복잡한 상속 관계를 다룰 수 있다. RDB의 경우 복잡한 상속 관계를 구현하려면 여러 테이블을 엮어야 하는데 이는 매우 복잡한 작업이며 결국 객체와 테이블을 매핑해야하는 관점에서 특정한 데이터를 꺼내기 위해 여러번의 JOIN을 걸쳐야 하는 등의 어려움이 있다.
JPA는 이러한 개념을 극복시켜주는 자바 진영의 표준 ORM기술이며 완성된 기술이다.(이 부분은 다른 언어 진영과 달리 기술에 대한 업데이트가 큰 변화를 나타내지 않는 점에서 유추할 수 있다.)
JPA는 DB를 Collection처럼 사용하도록 하는 편리성을 지향한다. 그러므로 자바 언어 규칙에 익숙하다면 이 기술에 익숙해졌을 때 높은 생산성으로 이어질 수 있다. 뒤에 나오는 스프링 데이터 JPA와 Querydsl은 JPA를 더욱 쉽게 사용할 수 있도록 하는 보조 기술이다.
@Data
@Entity
@Table(name = "Item") // 객체명과 같이 쓴다면 생략가능
public class Item {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "item_name")
private String itemName;
@Column(name = "price") // 필드명이랑 같을 경우 생략가능
private Integer price;
@Column(name = "quantity")
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
@Entity는 JPA가 사용하는 객체(엔티티)라는 뜻(JPA가 이 애노테이션으로 인식한다)이다. @Id 테이블의 PK와 해당 필드를 매핑한다. @GeneratedValue(strategy = GenerationType.IDENTITY) PK 생성 값을 DB에서 생성하는 IDENTITY 방식을 사용하는 것을 명시한다. @Column으로 객체의 필드 이름과 테이블의 컬럼명으로 하여금 매핑할 수 있다. 만약 Column 애노테이션을 생략한다면 item_name(DB) <-> itemName(Java)로 자동 매핑된다.
JPA Entity는 기본 생성자가 필수이므로 꼭 생성해서 넣어주어야 한다.(JPA Spec)
@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());
//update 저장
}
@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 = "select i from Item i"; // 엔티티를 대상으로 하는 문법(JPQL)
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();
}
}
EntityManager : JPA의 모든 동작은 엔티티 매니저로 하여금 일어난다. 엔티티 매니저는 내부에 데이터 소스를 가지고 데이터베이스에 접근할 수 있다.
JPA의 모든 변경은 트랜잭션안에서 이루어져야하므로 트랜잭션이 꼭 걸려야한다.(굳이 따지자면 Service계층에서 거는 것이 맞지만 일단은 레포지토리에서 걸어 무조건 걸리게끔 실습했다.)
save 로직의 경우 em.persist(item)하나로 모든 것이 해결되었다.
JPA가 객체와 DB간의 괴리를 해결해준다는 것은 update로직을 보면 알 수 있다.
@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());
}
em.find로 하여금 ITEM 테이블에서 해당 item_id(PK)에 해당하는 아이템을 추출한다. 그리고 그 아이템의 setter를 호출해서 객체에 값을 저장만 하면 업데이트가 성공하는 것이다.
보통 자바 컬렉션의 리스트에 Item이 담겨있다면 참조형으로 하여금 리스트 안에있는 Item을 조회해서 Item의 필드를 바꿀 수 있는데, 이와 같은 논리가 그대로 적용되는 것이다. 무려 DB에서 꺼내온 객체인데도 말이다.
findAll을 보니 다시 동적쿼리에 대한 공포가 엄습했다. JPA는 DB에 대한 쿼리인 sql이 아니라 객체에 대한 쿼리인 jpql을 사용하는데(이는 충분히 좋은 기술이다 - DB관점의 언어인 sql을 객체 관점으로 돌려 객체에만 원하는 행동을 적용하는 방식으로 더욱 자바스럽게 개발할 수 있으니까) 동적으로 jpql을 구성하는 것이 JDBC때 처럼 문제가 되고 있다.
이러한 동적 쿼리 문제를 JPA의 보조 기술인 Querydsl로 해결할 수 있다.
스프링 데이터 JPA는 JpaRepository 인터페이스를 제공한다. 이를 통해 기본적인 어느정도 공통화가 가능한 CRUD 기능을 제공한다. 파이썬 진영의 Django의 ModelViewSet이나 GenericViewSet과 매우 닮아보였다.
스프링 데이터 JPA는 인터페이스에 메서드만 적어두면 메서드 이름을 분석해서 쿼리를 자동으로 만들고 실행해주는 기능을 제공한다.(CrudRepository, PagingAndSortingRepository, 또는 JpaRepository를 상속 받아야 한다.)
public interface SpringDataJpaItemRepository extends JpaRepository<Item, Long> {
List<Item> findByItemNameLike(String itemName);
List<Item> findByPriceLessThanEqual(Integer price);
findByItemNameLike : "select i from Item i where i.itemName like :itemName"
findByPriceLessThanEqual : "select i from Item i where i.price
<= :price"
쿼리 메서드 기능 대신에 직접 JPQL을 사용하기 위해서는 메서드에 @Query애노테이션을 붙여 사용할 수 있다.
// 쿼리 직접 실행
@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);
이 역시 구현체 없는 인터페이스를 이용하는 것이기 때문에 실제로는 프록시 구현체를 만들어서 사용하게 된다.
위에서 findAll 메서드 로직이 동적 JPQL 문제로 코드가 더러워지는 것을 볼 수 있었다. 복잡한 절차의 query를 DB에 전달해야할 경우 순수한 JPA만으로 다루기는 어려운 측면이 있기에 querydsl을 이용하여 해결하는 것이 보통적이다.
@Repository
@Transactional
public class JpaItemRepositoryV3 implements ItemRepository {
private final EntityManager em;
private final JPAQueryFactory query;
public JpaItemRepositoryV3(EntityManager em) {
this.em = em;
this.query = new JPAQueryFactory(em);
}
...
@Override
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 maxPrice(Integer maxPrice) {
if (maxPrice != null) {
return item.price.loe(maxPrice);
}
return null;
}
// null 리턴되면 무시한다.
private BooleanExpression likeItemName(String itemName) {
if (StringUtils.hasText(itemName)) {
return item.itemName.like("%" + itemName + "%");
}
return null;
}
Querydsl을 사용하기 위해 JPAQueryFactory를 주입받는다. 주입 받은 query로 하여금 체인 형식으로 굉장히 직관적인 코드를 작성할 수 있다.
동적 쿼리로 문제가 되는 부분은 where이었다. 따로 BooleanExpression을 where절에 조건적으로 주어 동적 쿼리 문제를 if문 없이 처리할 수 있게 되었다.
실제로 JPA 기술은 매우 효과적이며 생산성이 높은 기술이지만 학습에 난이도가 있으므로 이번 챕터에서는 JPA를 왜 써야하는 지와 기본 기능들, 발생하는 문제에 대한 보조기술을 활용한 간단한 해결을 학습할 수 있었다.