[JPA] 영속성 컨텍스트와 변경 감지(dirty checking) vs. 병합(merge)
EntityManager
EntityManagerFactory는 여러 스레드에서 동시에 접근해도 안전하지만, 생성 비용이 크다.
따라서 EntityManagerFactory에서는 "요청이 올 때마다" 생성 비용이 거의 없는 EntityManager를 생성한다.
EntityManager는 Thread Safe하지 않아, 여러 스레드가 동시에 접근하면 동시성 문제가 발생한다.
따라서 요청(스레드)별로 한 개 씩 할당해준다.
이때 만들어진 EntityManager는 내부적으로 DatabaseConnection을 사용해서 DB를 사용한다.
영속성 컨텍스트
- 엔티티를 영구 저장하는 환경
- 영속성 컨텍스트는 애플리케이션과 데이터베이스 사이에서 객체를 보관하는 논리적 개념
- EntityManager를 통해서 영속성 컨텍스트에 접근
- EntityManager가 생성되면 논리적 개념인 영속성 컨텍스트(PersistenceContext)가 1:1로 생성됨
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@Service
public class MyService {
@PersistenceContext
private EntityManager em;
// ...
}
@PersistenceContext 어노테이션을 통해 JPA에서 엔티티 매니저를 주입받을 수 있다. (@Autowired처럼)
즉, em 이라는 변수에 EntityManger의 인스턴스를 주입받을 수 있다는 것이다.
한 가지 생각해볼만한 것은, 해당 EntityManger가 어떻게 관리되느냐 하는 것이다.
하나의 트랜잭션 범위 내에서는 같은 em 인스턴스가 유지된다. 이는 같은 트랜잭션 내에서 여러 번 엔티티 매니저를 주입받아도 동일한 인스턴스가 사용된다는 것을 의미한다. 따라서 동일한 트랜잭션 내에서 여러 개의 서비스 메서드나 DAO 메서드가 호출될 때 같은 em 인스턴스가 사용된다.
하지만 트랜잭션이 다르면, 즉 다른 트랜잭션 범위 내에서는 새로운 em 인스턴스가 주입된다. 각각의 트랜잭션은 독립적으로 자신의 엔티티 매니저 인스턴스를 가지게 된다. 이는 서로 다른 트랜잭션 범위에서의 데이터베이스 작업을 완전히 분리할 수 있게 해준다. (동시성 이슈)
따라서 JPA 엔티티 매니저는 트랜잭션 스코프에 따라 다르게 동작하며, 트랜잭션 범위 내에서는 같은 em 인스턴스가 사용되고, 다른 트랜잭션 범위에서는 새로운 em 인스턴스가 주입된다.
엔티티의 생명 주기
New (비 영속 객체)
영속성 컨텍스트와 전혀 관계가 없는 새로운 상태를 말한다.
Entity 객체가 DB에 반영되지 않았고, Managed 상태가 아닌 상태를 말한다.
이 상태는 new 키워드를 사용해 생성한 Entity 객체를 말하고 영속화되지 않는다.
Member member = new Member("memberA");
Managed (영속 객체)
영속성 컨텍스트에 관리되는 상태를 말한다.
Entity 객체가 영속 객체가 되는 상황은 크게 2가지가 있다.
- New (비 영속 객체) 상태에서 persist 메소드를 이용해 저장한 경우
- DB 테이블에 저장돼 있는 데이터를 find 메소드 또는 query를 사용해 조회한 경우다.
이 상태는 Persistence Context가 관리하는 상태이며, 해당 객체를 수정했는지(자동 변경 감지 - dirty checking) 알아낼 수 있다.
Case 1. persist()
Member member = new Member("memberB");
EntityManager em = entityManagerFactory.createEntityManager();
em.persist(member); //객체가 DB에 저장된다
Case 2. find(Entity.class, id)
//ItemController
@GetMapping("/items/{itemId}/edit")
public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {
Book item = (Book) itemService.findOne(itemId); //find를 통해 찾은 item 영속 상태
BookForm form = new BookForm(); //BookForm이라는 DTO를 이용
form.setId(item.getId());
form.setName(item.getName());
form.setPrice(item.getPrice());
form.setStockQuantity(item.getStockQuantity());
form.setAuthor(item.getAuthor());
form.setIsbn(item.getIsbn());
model.addAttribute("form", form);
return "items/updateItemForm";
}
//ItemService
public Item findOne(Long itemId) {
return itemRepository.findOne(itemId);
}
//ItemRepository
public Item findOne(Long id) {
return em.find(Item.class, id); //find() Returns : the found entity instance or null if the entity does not exist
}
위의 예시는 Item.class에서 id를 pk로 가지는 엔티티를 DB에서 찾아서 영속성 컨텍스트에 올려두고
BookForm이라는 DTO에 해당 엔티티의 값을 넣은 다음, 모델을 통해 뷰템플릿에 전달하는 코드이다.
Detached (준영속 객체)
영속성 컨텍스트에 저장되었다가 분리된 상태를 말한다.
트랜잭션이 commit되었거나, clear, flush 메소드가 실행된 경우 Managed (영속 객체) 상태의 객체는 모두 Detached (준 영속 상태) 상태가 된다. 이 상태는 더 이상 DB와 동기화를 보장하지 않는다. 다시 Managed (영속 객체) 상태로 만들기 위한 merge 메소드가 존재한다.
JPA가 더는 관리하지 않는 엔티티
- DB에 한 번 갔다온 엔티티
- DB에 한 번 저장되어 식별자가 존재하는 객체
- JPA가 식별할 수 있는 ID를 가지고 있는 객체
Member member = new Member("memberB");
EntityManager em = entityManagerFactory.createEntityManager();
em.detach(member);
@PostMapping("items/{itemId}/edit")
public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) { //@ModelAttribute : 들어오는 @RequestParam 파라미터 -> BookForm에 set
Book book = new Book(); //식별자가 DB에 있는 객체 - 준영속 엔티티
book.setId(form.getId()); //JPA가 식별할 수 있는 id가 세팅되어 있음 == JPA로 DB에 한 번 들어갔다 나온 것
book.setName(form.getName());
book.setPrice(form.getPrice());
book.setStockQuantity(form.getStockQuantity());
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
return "redirect:/items";
}
Removed (삭제 객체)
실제 DB에서 삭제된 상태를 말한다.
Managed 상태인 객체를 remove 메소드를 이용해 삭제한 경우에 Removed (삭제 객체) 상태에 해당한다. 작업 단위가 종료되는 시점에 실제로 DB 테이블에 삭제가 동기화 된다. 이 상태에 객체는 작업 단위가 종료되는 동시에 DB에서 삭제되므로 재사용하면 안된다.
Member member = new Member("memberB");
EntityManager em = entityManagerFactory.createEntityManager();
em.remove(member);
변경 감지(dirty checking)
public void cancel() {
if (delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송 완료된 상품은 취소가 불가능합니다.");
}
this.setStatus(OrderStatus.CANCEL); //em.update() 나 em.merge() 안 해줬으나 flush할 때 dirty checking해서 db 쿼리 날림
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
준영속 엔티티
JPA가 더는 관리하지 않는 엔티티
- DB에 한 번 갔다온 엔티티
- DB에 한 번 저장되어 식별자가 존재하는 객체
- JPA가 식별할 수 있는 ID를 가지고 있는 객체
준영속 엔티티는 JPA가 더이상 관리를 하지 않기 때문에 객체를 수정해도 commit-flush가 일어날 때 더티 체킹으로 DB에 업데이트가 일어나지 않는다.
@GetMapping("/items/{itemId}/edit")
public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {
Book item = (Book) itemService.findOne(itemId);
// find() Returns : the found entity instance or null if the entity does not exist
// item 영속 상태
BookForm form = new BookForm();
form.setId(item.getId());
form.setName(item.getName());
form.setPrice(item.getPrice());
form.setStockQuantity(item.getStockQuantity());
form.setAuthor(item.getAuthor());
form.setIsbn(item.getIsbn());
model.addAttribute("form", form);
return "items/updateItemForm";
}
@PostMapping("items/{itemId}/edit")
public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) { //@ModelAttribute : 들어오는 @RequestParam 파라미터 -> BookForm에 set
Book book = new Book(); //식별자가 DB에 있는 객체 - 준영속 엔티티
book.setId(form.getId()); //JPA가 식별할 수 있는 id가 세팅되어 있음 == JPA로 DB에 한 번 들어갔다 나온 것
book.setName(form.getName());
book.setPrice(form.getPrice());
book.setStockQuantity(form.getStockQuantity());
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
itemService.saveItem(book);
return "redirect:/items";
}
book객체는 이미 영속엔티티였던 form의 id값을 book.setId(form.getId()) 하였으므로
JPA가 식별할 수 있는 식별자를 가지고 있기 때문에 준영속 엔티티인것이다.
임의로 만들어낸 엔티티라도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있다.
준영속 엔티티 수정 방법 (2)
1. 변경 감지 기능
//ItemController
@GetMapping("/items/{itemId}/edit")
public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {//Book의 id값 전달받음
Book item = (Book) itemService.findOne(itemId);
//find() Returns : the found entity instance or null if the entity does not exist
//item 영속 상태
BookForm form = new BookForm();
form.setId(item.getId()); //영속 상태의 id값을 Bookform DTO에 넘겨서 보냄
//find()로 가져온 Book엔티티의 필드들을 form에 set
model.addAttribute("form", form);
return "items/updateItemForm";
}
<form th:object="${form}" method="post">
<!-- id -->
<input type="hidden" th:field="*{id}" />
<!-- 생략 -->
<button type="submit" class="btn btn-primary">Submit</button>
</form>
//ItemController
@PostMapping("items/{itemId}/edit")
public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) { //@ModelAttribute : 들어오는 @RequestParam 파라미터 -> BookForm에 set
//id를 명확하게 보내야 Service의 Transaction 안에서 엔티티 조회가 되고 영속상태가 된다 -> 값 변경해야 변경 감지 일어남
itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
return "redirect:/items";
}
//ItemService
@Transactional
public void updateItem(Long itemId, String name, int price, int stockQuantity) { //id를 명확하게 받아야 Transaction 안에서 엔티티 조회가 되고 영속상태가 된다 -> 값 변경해야 변경 감지 일어남
Item findItem = itemRepository.findOne(itemId); //findItem : 영속상태
findItem.setPrice(price);
findItem.setName(name);
findItem.setStockQuantity(stockQuantity);
//itemRepository.save(findItem); // 호출할 필요 X
//@Transactional -> 트랜잭션 커밋됨 -> flush() -> 영속성 컨텍스트에 있는 엔티티 중 변경사항 찾음
//-> findItem 변경된 것 쿼리 날림
//merge보다 내가 변경할 값들만 골라서 set 날려주는(변경 감지 방식) 게 더 안전한 방식임!
//merge는 없는 필드는 null로 갈아버리므로
}
2. 병합 (merge) 사용
//ItemController
@PostMapping("items/{itemId}/edit")
public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) { //@ModelAttribute : 들어오는 @RequestParam 파라미터 -> BookForm에 set
Book book = new Book(); //식별자가 DB에 있는 객체 - 준영속 엔티티
book.setId(form.getId()); //JPA가 식별할 수 있는 id가 세팅되어 있음 == JPA로 DB에 한 번 들어갔다 나온 것
book.setName(form.getName());
book.setPrice(form.getPrice());
book.setStockQuantity(form.getStockQuantity());
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
itemService.saveItem(book);
return "redirect:/items";
}
//ItemService
@Transactional
public void saveItem(Item item) {
itemRepository.save(item);
}
//ItemRepository
public void save(Item item) {
if (item.getId() == null) { // item을 JPA에 저장하기 전까진 id값이 없음 -> 즉 새로 생성한 객체라는 뜻
em.persist(item); // 신규 등록
} else { // 준영속 상태인 것 - 이미 DB에 등록된 걸 가져온 것
em.merge(item); // 이미 JPA를 통해 DB에 한 번 들어갔구나 생각하고 update 같은 것
}
}
준영속 엔티티 수정하는 방법 비교
병합 vs. 변경 감지 기능
변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있다.
그러나 병합을 사용하면 모든 속성이 변경된다. 즉, 병합은 모든 필드를 교체한다.
따라서 병합 때엔 값이 없으면 null로 업데이트 할 위험도 있다.
둘 다 변경하기 전에 영속성 컨텍스트에 객체를 올리는 것은 동일하지만, 내부에서 수정하는 방식이 다른 것이다.
따라서 권장되는 엔티티 변경 방법은, 항상 변경 감지를 하는 게 좋다.
1. 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터만 명확하게 전달 (parameter 또는DTO 이용)
2. 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회
3. 해당 엔티티의 데이터를 직접 변경
4. 트랜잭션 커밋 시점에 변경 감지 실행
//ItemController
@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";
}
//ItemService
@Transactional
public void updateItem(Long itemId, String name, int price, int stockQuantity) { //id를 명확하게 받아야 Transaction 안에서 엔티티 조회가 되고 영속상태가 된다 -> 값 변경해야 변경 감지 일어남
Item findItem = itemRepository.findOne(itemId); //findItem : 영속상태
findItem.setPrice(price);
findItem.setName(name);
findItem.setStockQuantity(stockQuantity);
// dirty checking
}
@Transactional
스프링 환경에서는 @Transactional을 이용하여 메서드, 클래스, 인터페이스의 트랜잭션 처리가 가능한데 이러한 방식을 선언적 트랜잭션이라 부른다. 내부적으로 좀 뜯어보면 트랜잭션 기능이 들어가 있는 프록시 객체가 생성되어 자동적으로 커밋이나 롤백을 해준다고 생각하면 된다.