728x90
반응형
SMALL

Dirty CheckingMerge에 대한 제대로 된 이해를 위해 블로그를 작성하려고 한다. 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()를 호출하나 일을 두번하는 것이다. 그냥, 의미 있는 메서드를 하나 만들어두면 이 메서드만 추적하면 값을 어디서 변경하는지 바로 캐치할 수 있고, 세터와 같은 나쁜놈들을 사용하지 않아도 된다. 

 

결론은,

값을 변경할땐 변경감지를 사용하자! 

728x90
반응형
LIST

+ Recent posts