Dirty Checking과 Merge에 대한 제대로 된 이해를 위해 블로그를 작성하려고 한다. JPA를 사용할 때 엔티티를 어떻게 업데이트할까?에 대한 내용인데 결론부터 말하면 Merge를 사용하면 난 안된다고 생각한다. Merge를 사용하는건 변경감지가 아니라 Merge를 사용하는 확실한 이유가 있어야 한다고 본다.
우선, 다음 코드를 보자. 예시 코드를 위해 최대한 간단하게 작성했다.
@PostMapping("/items/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {
Book book = Book.builder()
.id(form.getId())
.name(form.getName())
.price(form.getPrice())
.stockQuantity(form.getStockQuantity())
.author(form.getAuthor())
.isbn(form.getIsbn())
.build();
itemService.saveItem(book);
return "redirect:/";
}
- form으로 입력한 데이터를 전송받아, 엔티티 객체를 만들고 저장하는 메서드를 호출한다.
- 업데이트한다는 것은 이미 그 데이터가 데이터베이스에 있다는 소리고 그 말은 ID값을 이미 가지고 있기 때문에 form에서 해당 ID값을 전달받을 수도 있다는 말이다.
그리고 호출하는 ItemService.saveItem() 코드를 보자.
ItemService의 일부분
@Transactional
public void saveItem(Item item) {
itemRepository.save(item);
}
ItemRepository의 일부분
public void save(Item item) {
if (item.getId() == null) {
entityManager.persist(item);
} else {
entityManager.merge(item);
}
}
- ItemService는 그저 ItemRepository를 호출하는 위임 클래스 역할을 할 뿐이고, ItemRepository에서 ID값이 있는지 확인해서 ID값이 없다면 persist()를, 있다면 merge()를 호출한다.
이렇게 코드를 작성하면 문제없이 코드가 잘 동작할 것이다. 지금 이 코드는 병합을 통해 엔티티를 업데이트 한 것이다. 근데 이게 왜 문제가 될 수 있고 잘못된 것인지 알아야 한다.
자, 병합은 사실 이런 행위를 한다.
merge(Item item) {
Item findItem = itemRepository.findById(item.getId());
findItem.setName(item.getName());
findItem.setPrice(item.getPrice());
findItem.setStockQuantity(item.getStockQuantity());
그 외 모든 필드들 값 세팅...
}
- 우선, 넘겨받은 엔티티의 기본키를 통해 데이터베이스에서 해당 기본키를 가지고 있는 엔티티를 찾는다.
- 그 찾아온 엔티티의 정말 모든 필드를 넘겨받은 엔티티의 값으로 교체한다.
- 그리고 Dirty Checking을 통해 데이터를 업데이트한다.
이 코드의 문제가 뭘까? 실무에서의 업데이트는 전체 데이터를 모두 업데이트하는 경우보다 일부분을 업데이트하는 경우가 대부분일 것이다. 일단 실세계에서 생각해봐도 나만 해도 그렇다. 어떤 계정 정보를 업데이트할 때 계정 정보를 전체 다 업데이트한 적은 없다. 딱 필요한 몇 부분만 수정하곤 하지. 그 말은, 넘긴 데이터도 수정할 데이터만 일부 채워졌을 확률이 있다는 얘기고 나머지 필드에는 값이 안 채워져 있을 수도 있다는 얘기다. 근데 그 값을 그대로 병합해버리면? 기존에 있는 값을 null로 변경해버릴 것이다. 그러니까 이 말을 코드로 보면 아래와 같은 상황도 있을 수 있단 얘기다.
@PostMapping("/items/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {
Book book = Book.builder()
.id(form.getId())
.name(form.getName())
//.price(form.getPrice())
//.stockQuantity(form.getStockQuantity())
//.author(form.getAuthor())
//.isbn(form.getIsbn())
.build();
itemService.saveItem(book);
return "redirect:/";
}
- 내가 수정하고자 하는 값은 책의 이름뿐이었기에 다른 값은 채우지 않았다. 그리고 업데이트한다.
- 이렇게 되면 저 Book이라는 객체의 나머지 필드들은 다 null이다.
- 병합을 하는 순간 기존에 있는 멀쩡한 데이터에 null이 채워질 것이다.
물론, 간단하고 제약이 잘 갖춰져 있고 딱 수정하고자 하는 값만 수정한다면 병합 시 문제를 해결할 수 있을지도 모른다. 근데 굳이 리스크를 스스로 끌어 안을 필요가 있을까? 그럼 Dirty Checking으로 어떻게 데이터를 변경하면 될까?
Dirty Checking을 통한 업데이트
가장 핵심은 이 변경감지는 영속성 컨텍스트가 관리하고 있는 엔티티여야 한다. 즉, 영속 상태의 엔티티만 변경 감지가 제대로 수행된다.
여기서 많은 실수가 일어나는데, 다음 코드를 보자.
예시를 위해 최대한 단순히 만들었다.
@PostMapping("/items/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {
Book book = new Book();
book.setId(form.getId());
book.setName(form.getName());
return "redirect:/";
}
- Book은 엔티티이다. 이 코드가 의도한건 엔티티의 일부 값을 변경했으니 변경감지가 일어나겠지?! 이다.
- 그러나 아무런 일도 일어나지 않을 것이다.
왜냐하면, 지금 이 Book은 영속성 컨텍스트가 관리하고 있는 대상이 아니기 때문이다. 그래서 준영속 엔티티나 영속되지 않은 엔티티에 변경감지를 기대하고 실수하는 경우가 자주 있다. 그럼 어떻게 해야하지?
1. 먼저 영속성 컨텍스트가 해당 엔티티를 영속한다.
@PostMapping("/items/{itemId}/edit")
public String updateItem(@PathVariable("itemId") Long itemId, @ModelAttribute("form") BookForm form) {
itemService.updateItem(itemId, form);
return "redirect:/";
}
- 컨트롤러에서 애매하게 엔티티를 만들고 지지고 볶는게 아니라, 서비스 레이어에 데이터를 넘겨준다.
@Transactional
public void updateItem(Long itemId, BookForm form) {
Item findItem = itemRepository.findById(itemId);
findItem.change(form);
}
- 그리고 엔티티의 기본키를 통해 엔티티를 찾아와 영속시키면 된다. 영속 시킨 후 데이터를 변경하면 된다.
- 여기서는 의미있는 이름의 메서드를 만들어 추적이 쉽게 만들어주자. 위에서는 예제를 단순히 하기 위해 세터를 사용했지만 세터도 사용하지 않는다. 좋아하지도 않고.
2. 값을 변경한다.
public void change(BookForm form) {
name = form.getName();
price = form.getPrice();
stockQuantity = form.getStockQuantity();
}
- 그리고 이 변경 메서드에서 변경하고자 하는 값만 넣어주면 된다.
- 아이템에서 변경 가능한 데이터는 Name, Price, StockQuantity 이 세개뿐이라고 비즈니스 규칙으로 정해놓으면 이 코드에 문제는 아무것도 없다. 어차피 여기서는 form안에 데이터는 null일수도, null이 아니어도 상관없다. 이 form은 기존에 데이터베이스에 저장된 해당 값을 불러서 화면에 뿌려주고 사용자가 입력한 데이터를 그대로 가져온 것이기 때문이다.
- 그리고 이렇게 값을 변경해주고 save() 같은 메서드는 호출하지 않아도 된다.
- 왜냐? 영속성 컨텍스트가 관리하는 엔티티의 값을 변경했고, 트랜잭션이 끝나는 순간 변경감지를 통해 데이터가 바뀔테니까. 즉, updateItem 메서드가 끝나는 순간, 데이터베이스에 업데이트 쿼리가 나갈것이다.
병합이 아닌 변경감지를 사용하면 어떤점에서 좋은가?
- 예상하지 못한 오류를 방지할 수 있다. (사람은 언제나 실수를 한다. 실수 안하기를 기대하는 코드를 작성하는 사람보다 실수조차 하지 못하게 제약을 두는 코드가 더 좋은 코드라고 생각한다.)
- save() 메서드는 결국 엔티티를 넘겨야 한다. 엔티티를 넘긴다는건 수정한 엔티티를 넘겨야 하고, 엔티티를 수정하기 위해 Setter를 사용하던 뭘 하던 해야 할텐데, 데이터베이스에서 값을 가져와서 Setter를 통해 값을 변경해 save()를 호출하나, 엔티티를 직접 만들고 ID를 데이터베이스에 기존에 있는 동일한 기본키로 적용한 후 값을 수정해 save()를 호출하나 일을 두번하는 것이다. 그냥, 의미 있는 메서드를 하나 만들어두면 이 메서드만 추적하면 값을 어디서 변경하는지 바로 캐치할 수 있고, 세터와 같은 나쁜놈들을 사용하지 않아도 된다.
결론은,
값을 변경할땐 변경감지를 사용하자!
'JPA(Java Persistence API)' 카테고리의 다른 글
[우아하게 JPA 사용] Part.1 지연로딩과 조회 성능 최적화 (0) | 2024.11.11 |
---|---|
[JPA] 엔티티와 인덱스 (0) | 2024.11.10 |
[JPA] OSIV (Open Session In View) (0) | 2023.11.16 |
[JPA] Part 18. 벌크 연산 (0) | 2023.10.30 |
[JPA] Part 17. Named Query (2) | 2023.10.30 |