티스토리 뷰
영속성 컨텍스트에는 변경 감지(Dirty Checking)와 병합(Merge) 기능이 존재합니다. Entity의 필드값을 수정한다는 공통점이 있으나, 주의해야 할 차이점 또한 존재합니다. 이 글에서는 준영속 상태의 Entity에 접근하는 관점에서 설명합니다.
변경 감지(Dirty Checking)
변경 감지란 영속성 컨텍스트가 관리하는 영속 상태의 Entity의 필드 값을 수정합니다. (영속 상태라 함은 Entity가 식별자 값(ID)을 가지고 있음을 말합니다) 영속성 컨텍스트를 Flush하는 시점에 Entity와 SNAPSHOT을 비교하여 변경된 값을 추적합니다. 변경된 필드가 존재한다면 수정 쿼리를 작성하여 쓰기 지연 SQL 저장소에 보관하였다가 Flush 시점에 DB에 업데이트됩니다.
변경 감지는 Entity의 원하는 필드만 수정할 수 있다는 특징을 가지고 있습니다. 즉, Entity가 가진 모든 필드가 업데이트가 되는 것이 아닙니다.
병합(Merge)
준영속 상태의 Entity를 영속 상태로 변경하기 위해서는 병합을 사용하면 됩니다. 준영속 상태 Entity는 식별자 값을 가지고 있기 때문에 식별자 값을 통해 DB로부터 Entity를 조회할 수 있습니다.
병합 과정은 다음과 같습니다.
- merge() 호출
- 매개변수로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 Entity를 조회 (식별자 값을 가지고 있으므로 조회 가능)
- 이때, 1차 캐시에 Entity가 없다면 DB를 조회 (식별자 값을 가지고 있으므로 조회 가능)
- 조회한 Entity에 저장된 필드값을 새로운 Entity에 복사
- 새로운 Entity를 반환
- 이때, 3번에서 반환하는 Entity와 파라미터로 넘오온 Entity는 서로 다르다
- 영속성 엔티티 : 반환된 Entity / 준영속 Entity : 파라미터로 넘어온 Entity
- 이때, 3번에서 반환하는 Entity와 파라미터로 넘오온 Entity는 서로 다르다
병합(Merge)은 모든 필드에 대하여 값을 변경합니다. 즉, 변경을 원하지 않는 필드도 값이 변경됩니다.
차이점
변경 감지와 병합의 차이점은 다음과 같습니다.
- 변경 감지 : 원하는 필드만 수정 가능
- 병합 : 모든 필드 수정 (원하는 필드만 수정 불가능)
모든 필드가 수정된다는 것은 항상 모든 필드의 값을 유지해야 함을 말합니다.
@Entity
@Getter
@Setter
public class Book {
@Id
@GeneratedValue
@Column(name = "item_id")
private Long id;
private String name; // 책 이름
private int price; // 가격
private int stockQuantity; // 재고
private String author; // 저자
private String isbn; // isbn
}
위와 같이 JPA BOOK 책이 있습니다. 가격은 30,000원이고 재고는 5개가 존재합니다.
위와 같이 JPA BOOK의 수량을 수정하려고 합니다. 이때, 가격을 실수로 입력하지 않은 상태로 재고를 2개로 수정했다고 합시다. 변경 감지를 사용했더라면 가격 필드에 접근하지 않고 수량 필드만 접근했을 것이기 때문에 문제가 되지 않습니다.
// 변경 감지(Dirty Checking)
@Service
public class ItemService {
@Transactional
public void updateItem(Long itemId, int stockQuantity) {
Item item = itemRepository.findOne(itemId); // 준영속 -> 영속
item.setStockQuantity(stockQuantity); // 재고 필드에만 접근
}
}
그러나 병합은 모든 필드를 수정하기 때문에 가격 필드에 NULL이 저장되므로 문제가 발생합니다.
기존 수량 2개에서 5개로 변경하였습니다.
// Controller
@Controller
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/items")
public class ItemController {
private final ItemService itemService;
@PostMapping("/{itemId}/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {
Book book = new Book();
book.setId(form.getId());
book.setName(form.getName());
// book.setPrice(form.getPrice()); 가격 필드를 설정하지 않음
book.setStockQuantity(form.getStockQuantity()); // 2개 -> 5개
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
itemService.updateItem(book);
return "redirect:/items";
}
}
JPA BOOK 수량을 변경하기 위해 book.setStockQuantity()를 호출하였습니다. 그러나 가격 필드를 설정하지 않고 서비스 계층의 updateItem() 메서드에 매개변수로 넘기면 어떻게 될까요? 기존의 가격 30,000원이 유지될까요?
// 서비스 계층
@Service
@RequiredArgsConstructor
public class ItemService {
private final EntityManager em;
@Transactional
public void updateItem(Item updateItem) {
Item item = em.merge(updateItem);
}
}
Controller로부터 수량을 변경하기 위한 Book 객체를 전달받았습니다. 병합은 모든 필드를 update한다고 설명하였습니다. 즉 파라미터로 넘겨받은 updateItem 객체의 모든 필드의 값을 새로운 Entity인 item에 복사하게 됩니다. 근데 updateItem 객체에는 가격 필드를 설정하는 코드가 주석처리 되어있었습니다. 따라서 새로운 Entity인 item의 가격 필드에 null값을 복사하게 됩니다.
모든 필드가 update 되는 문제점 때문에 병합을 사용해서는 안 됩니다.(null 저장 위험성)
결론
김영한 님은 다음 방법을 추천하고 있습니다.
- Entity를 변경할 때는 반드시 변경 감지(Dirty Checking)를 사용
- Controller에서 Entity 생성 X
@PostMapping("/{itemId}/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {
Book book = new Book(); // 이런식으로 Entity 생성x
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.updateItem(book);
return "redirect:/items";
}
- Transaction이 수행되는 서비스 계층에 식별자(ID)와 변경할 데이터를 전달 (파라미터 또는 DTO)
- Transaction이 수행되는 서비스 계층에서 영속 상태의 Entity를 조회하고, Entity의 데이터를 직접 변경
// Controller 계층
@Controller
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/items")
public class ItemController {
private final ItemService itemService;
// @PostMapping("/{itemId}/edit")
// public String updateItem(@ModelAttribute("form") BookForm form) {
// 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.updateItem(book);
// return "redirect:/items";
// }
@PostMapping("/{itemId}/edit")
public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) {
UpdateItemDto updateItemDto = new UpdateItemDto(); // Dto 생성
updateItemDto.setStockQuantity(form.getStockQuantity()); // update할 필드 설정
itemService.updateItem(itemId, updateItemDto); // 식별자와 dto를 넘김
return "redirect:/items";
}
}
// Service 계층
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final EntityManager em;
// @Transactional
// public void updateItem(Item updateItem) {
// em.merge(updateItem);
// }
@Transactional
public void updateItem(Long itemId, UpdateItemDto updateItem) {
Item item = itemRepository.findOne(itemId); // 식별자로 Entity 조회
item.setStockQuantity(updateItem.getStockQuantity()); // Dto를 통해 변경 감지
}
}