Home 학습용 JPA 프로젝트 개요 - 애플리케이션 (+ 변경감지와 병합)
Post
Cancel

학습용 JPA 프로젝트 개요 - 애플리케이션 (+ 변경감지와 병합)

이전 포스트에서 엔티티에 대한 설계에 대해 보았고, 이번에는 애플리케이션 단에 대한 부분을 본다.

기능

  • 회원 기능
    • 회원 등록
    • 회원 조회
  • 상품 기능
    • 상품 등록
    • 상품 수정
    • 상품 조회
  • 주문 기능
    • 상품 주문
    • 주문 내역 조회
    • 주문 취소

예외사항

학습용 예제이기 때문에 아래 기능들은 일단 구현하지 않는다.

  • 로그인과 권한 관리
  • 파라미터 검증 및 예외 처리
  • 카테고리 및 배송 정보 사용

애플리케이션 아키텍처

  • Controller, web : 웹 계층
  • Service : 비즈니스 로직, 트랜잭션 처리
  • Repository : JPA를 직접 사용하는 계층, 엔티티 매니저 사용
  • Domain : 엔티티가 모여있는 계층, 모든 계층에서 사용한다.

패키지 구조

  • jpabook.jpashop
    • domain
    • exception
    • repository
    • service
    • web

개발 순서 : (리포지토리 -> 서비스) 계층을 개발 -> 테스트 케이스로 검증 -> 웹 계층 적용

코드 : 깃허브 링크

정리

  • @PersistenceContext
    • 스프링이 생성한 엔티티 매니저를 주입받아 쓸 수 있도록 해준다.
    • 이 애노테이션을 사용하지 않고 생성자 주입을 통해 사용할 수도 있다.
      • private final EntityManager em;
      • @RequiredArgsCounstructor
  • @Transactional(readOnly = true)
    • 읽기만 필요한 메서드에는 readOnly = true 조건을 달아 리소스 낭비를 줄이는 등의 효과를 얻을 수 있다.
    • 효과는 DB별로 다르다.
  • OrderService에서 order만 save해주는 이유
    • Cascade 옵션을 ALL로 해두었기 때문에 Order에 있는 컬렉션인 OrderItem들과 Delivery도 함께 persist() 되기 때문이다.
  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 생성메서드를 만들어주었는데 기본 생성자가 public으로 되어있으면 후에 유지보수가 어려울 수 있다.
    • 기본 생성자를 protected로 알아서 만들어주는 애노테이션이다.

OrderService.cancelOrder(Long orderId)에서 cancel()만 해줬을 뿐인데 DB도 변경되는 이유

JPA의 장점이 여기서 드러난다.

코드를 타고 들어가면 결국 Item.addStock(int quantity)가 호출되고 Item의 재고 수량이 늘어나게 된다.

여기서 단순히 자바 코드로 Item의 재고 수량을 늘리고 update쿼리를 따로 날리거나 save하지 않는데 어떻게 데이터가 의도한대로 변경되느냐라는 의문이 들 수 있다.

  • orderRepository.findOne()을 통해 주문을 가져왔다.
    • JPA를 통해 가져온 내용인 것.
  • 이렇게 가져온 order는 영속성으로 관리하는 객체이다.
  • 영속성으로 관리하는 객체는 트랜잭션 안에서 데이터를 변경하기만 하면 데이터베이스에 값을 업데이트 해준다.
    • 참고로 준영속 객체를 영속으로 관리하기 위해 사용하는 방법이 merge() 이다.
      • 아래에서 자세히 설명.

만약 JPA를 사용하지 않았다면 손수 update 쿼리를 작성해 날려주어야 했을 것이다.

JPA 사용에는 장단이 있으니 상황에 맞추어 더 나은 방법을 선택하자.

주문 검색 기능 - JPA에서의 동적 쿼리 문제

1
2
3
4
5
6
7
em.createQuery("select o from Order o join o.member m" +  
	" where o.status = :status" +  
	" and m.name like :name", Order.class)  
	.setParameter("status", orderSearch.getOrderStatus())  
	.setParameter("name", orderSearch.getMemberName())  
	.setMaxResults(1000)  
	.getResultList();

status의 값과 name의 값이 존재한다면 위와 같이 조건을 달면 된다.

하지만 주문 검색을 할 때 status와 name 모두 검색을 한다는 것은 100% 보장되지 않는다. 어떤 사람은 status로만 검색하고, 어떤 사람은 두 값 모두 입력하지 않고 전체를 검색할 수도 있는 것이다.

이러한 동적쿼리 문제가 발생한다.

  • JPQL로 if문으로 하나하나 조건을 걸어서 where을 붙이는 등의 작업으로 해결.
  • JPA Criteria로 처리
    • 이 또한 매우 복잡하다.
  • QueryDSL 사용.
    • 이 방법을 권장한다.
    • QueryDSL에 관하여 추후 포스팅.

[JPQL과 Criteria는 깃허브 링크에서 코드로 확인할 수 있다.]

웹 계층(Controller, Thymeleaf)

  • MemberForm을 만드는 이유
    • Form 화면에서 받아오는 데이터와 Member 엔티티의 속성이 정확히 일치하지 않는다.
    • 따라서 엔티티를 그대로 쓰기보단 화면에 맞게 새로운 객체를 만들어 사용한 것.
  • 타임리프에서 ?를 사용하면 null을 무시한다.
    • member.address?.city

변경 감지, 병합(merge)

  • 준영속 엔티티
    • 영속성 컨텍스트가 관리하지 않는 엔티티
    • itemService.saveItem(book) 이라는 코드가 상품 수정을 하는 코드에 존재한다.
      • 이 코드는 준영속 엔티티이다.
      • DB에 id값과 함께 저장이 되어있는 데이터를 객체로 만든 것인데 이렇게 id같은 식별자를 가지고 있으면 임의로 만들어낸 엔티티라도 준영속 엔티티라고 볼 수 있다.

이러한 준영속 엔티티를 수정하는 방법은 2가지가 있다.

  • 변경 감지
    • em.find()와 같은 메서드로 엔티티를 조회하면 그 엔티티는 영속성 컨텍스트에서 엔티티가 조회된 것이다.
    • 이렇게 조회된 엔티티는 트랜잭션 안에서 엔티티가 다시 조회된 것이고
    • 이렇게 조회된 엔티티의 값을 변경하면 트랜잭션 커밋 시점에 변경을 감지한다
    • 변경이 감지되면 데이터베이스에 update SQL이 실행되는 것이다.
  • 병합(merge)
1
2
3
4
5
6
7
public void save(Item item) {  
	if (item.getId() == null) {  
		em.persist(item);  
	} else {  
		em.merge(item);  
	}  
}

ItemRepository에는 위와 같은 코드가 존재한다.

merge 동작 방식

  • merge() 실행
  • 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.
    • 만약 1차 캐시에 엔티티가 없다면 DB에서 엔티티를 조회하고 1차 캐시에 저장한다.
  • 조회한 영속 엔티티(mergeMember)에 member 엔티티 값을 채워 넣는다.
  • 영속 상태인 mergeMember를 반환한다.

준영속 엔티티의 식별자 값으로 영속 엔티티를 조회하고 그 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체하는 것이다.

그러면 트랜잭션 커밋 시점에 변경 감지 기능이 동작해 데이터베이스에 update SQL이 실행된다.

즉 merge()도 변경 감지 동작을 이용하는 것이다.

주의 사항

하지만 merge()의 사용은 권장하지 않는다.

변경 감지 기능을 사용하면 원하는 속성만 선택해 변경이 가능하지만, 병합(merge)을 이용하면 모든 속성이 변경된다.

모든 속성이 변경되면 값이 없을 때 null로 업데이트될 위험이 존재한다.

  • 엔티티를 변경할 때 변경 감지를 사용하자
    • 컨트롤러에서 어설프게 엔티티를 생성하지 말자. (new Book())
    • 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달하자.
      • parameter or dto
    • 트랜잭션이 있는 서비스 계층에서 영속상태의 엔티티를 조회하고 엔티티의 데이터를 직접 변경하자.
    • 트랜잭션 커밋 시점에 변경 감지가 실행된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@PostMapping("/items/{itemId}/edit")  
public String updateItem(@ModelAttribute("form") BookForm form) {  
  
  //merge
	Book book = new Book();  
	book.setId(form.getId());  
	book.setName(form.getName());  
	book.setPrice(form.getPrice());  
	book.setStockQuantity(form.getStockQuantity());  
	book.setAuthor(form.getAuthor());  
	book.setIsbn(form.getIsbn());  
	  
	itemService.saveItem(book); 
 //merge	 
	return "redirect:/items";  
}

위와 같은 코드보다는

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional  
public void updateItem(Long itemId, String name, int price, int stockQuantity) {  
	Item item = itemRepository.findOne(itemId);  
	item.setName(name);  
	item.setPrice(price);  
	item.setStockQuantity(stockQuantity);  
}
//ItemService에 위와 같은 메서드를 추가. -> findOne()을 통해 영속 엔티티 사용

@PostMapping("/items/{itemId}/edit")  
public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) {  
  
	itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());  
	return "redirect:/items";  
}
//Controller에서 해당 메서드를 이용해 변경 감지를 사용하여 수정한다.

이렇게 수정하는 것이 바람직하다.

참고: updateItem과 같은 메서드에 파라미터가 너무 많다면 DTO를 사용하는 등의 방법으로 개선할 수 있다.

그리고 setter의 사용보다는 item.change(name, price, stockQuantity) 같은 메서드를 통해 값을 변경하자.

This post is licensed under CC BY 4.0 by the author.