당니의 개발자 스토리
변경 감지와 병합(merge) 본문
변경 감지와 병합(merge)
자 이번 시간에는 변경감지와 병합에 대해서 설명을 해드리겠습니다.

정말 너무 중요한 내용이거든요. 여러분 JPA를 쓰시면 변경감지와 병합의 차이를 모르면 정말 많은 시간을 그냥 날리실 수가 있어요. 이 두 개는 진짜 100% 이해를 하셔야 됩니다.

우선 말씀드리기 전에 준영속 엔티티라는 것에 대해서 이해를 해야 되는데요. 이게 뭐냐면, 일단 엔티티가 영속상태로 관리가 돼요. 그러면 엔티티에 있는 값만 바꾸면 JPA가 트랜잭션 해서 commit 시점에 변경된 내용을 알아가지고 DB에 반영해 주는 거 아시죠?
이렇듯 트랜잭션은 로직을 수행하고 모든 로직이 성공적으로 수행되었을 경우에는 모든 결과를 DB에 일괄적으로 commit 하고, 하나라도 실패한다면 모든 작업을 원상 복구(rollback) 시킵니다.
일단 예로 보여드리기 위해서 테스트를 짜볼게요.

이렇게 만든 다음에, JPA에서 기본적으로 값을 변경할 때는

엔티티 매니저를 가지고 이렇게 한단 말이에요.

예를 들어서, 데이터베이스에 1L 식별자를 가진 book을 가지고 온다고 칠게요.

이게 트랜잭션 안에서는 그냥 book.setName 해가지고 이름을 그냥 바꿔치기 한 다음에 트랜잭션이 Commit이 되어버리면, JPA가 이걸 어떻게 하냐면 JPA가 이 변경된 부분에 대해서 찾아가지고 업데이트 쿼리를 자동으로 생성해서 데이터베이스에 반영을 해요.

그걸 뭐라고 하냐면 Dirty Checking이라고 그러거든요. 이게 바로 변경 감지에요. 이 메커니즘으로 기본적으로 JPA의 엔티티를 바꿀 수가 있어요. 내가 원하는 걸로 데이터를 업데이트 칠 수 있단 말이에요.
이건 이전에도 봤었죠? 언제? Order에 가보시면,

cancel() 기억나시죠. cancel만 호출했어요. 데이터를 여기에서만 바꿨거든요. 근데 Order의 상태를 OrderStatus.CANCEL로 바꾸고 내가 db 업데이트를 따로 날려주는 명령어가 없었어요.

핵심 비즈니스 로직 개발할 때 기억을 떠올려 보시면, 이 값만 바꾸고 제가 따로 JPA한테 '너 이거 Order의 상태를 OrderStatus.CANCEL로 바꿔' 라고 따로 em.update를 하거나 em.merge를 하거나 그런 게 전혀 없었어요. 그런데 cancel로만 이 엔티티의 값을 바꿨더니 그냥 JPA가 알아서 뭐가 바뀐지 확인하고 트랜잭션 Commit 시점에 바뀐 데를 찾아가지고, db 업데이트를 날리고 트랜잭션 Commit이 일어나거든요. 그리고 나서, flush 할 때 Dirty Checking 이라는 게 일어나요. 이게 JPA에서 데이터를 변경할 때의 기본 메커니즘인데,
문제는 뭐냐면 준영속 엔티티일 때예요.

준영속 엔티티는 뭐냐면 JPA의 영속성 컨텍스트가 더 이상 관리하지 않는 엔티티를 말해요. 자 어떤 애냐면,

ItemController에 보면 updateItem에서 막 데이터를 입력을 해가지고

Submit을 딱 하잖아요.

그러면 여기로 로직이 날라오면서 어떻게 됩니까?

이 BookForm을 통해서 데이터가 넘어와요. 그럼 이제 Book을 생성하는데 재밌는 게 지금 객체는 내가 생성한 새로운 객체인데,

id가 세팅이 되어있죠. 지금 getId로 세팅이 되어있잖아요. 이 말은 JPA에 한번 들어갔다 나온 애라는 거죠. 이런 객체를 뭐라 그러냐면, 조금 모호하긴 하지만 어쨌든 이렇게 한번 데이터베이스에 정확하게 들어갔다 온 상태로, 식별자가 정확하게 데이터베이스에 있는 객체를 준영속성 상태의 객체라고 그럽니다. (인프런 질문: https://www.inflearn.com/questions/70393/book-%EA%B0%9D%EC%B2%B4%EA%B0%80-%EC%99%9C-%EC%A4%80%EC%98%81%EC%86%8D%EC%9D%B8%EA%B2%83%EC%9D%B8%EA%B0%80)

왜냐하면 얘는 어쨌든 JPA가 식별할 수 있는 id를 가지고 있거든요. 이미 이 전에 form에 의해서 id가 db에 저장된 적이 있는데, 그 id를 book.setId로 설정하면 id값이 동일한 것 뿐이지 실제로 이 book이 JPA의 영속성 컨텍스트에는 없으므로 준영속 상태라고 이해하시면 됩니다.
그러니까 Book이 과거에 이 식별자를 기반으로 JPA가 관리를 했었죠. 그러니까 영속성 컨텍스트가 더는 관리하지 않는 엔티티인 거에요. 내가 그냥 new로 생성을 하긴 했지만, 이미 jpa에서 이 id 기반으로 생각을 해보면 이미 db에서 한번 저장되고 불러온 애거든요. 그렇기 때문에

얘는 준영속 엔티티라고 보는 게 맞아요. 이 book이 바로 준영속 엔티티인 겁니다.

이렇게 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있습니다.
그래서 이 준영속 엔티티는 문제가 뭐냐면 JPA가 관리를 안 하잖아요. 반면 JPA가 관리하는 영속성 상태의 엔티티는 변경 감지라는 게 일어나요. 뭐가 변경되는지 JPA가 다 눈으로 보고 있단 말이에요. 그래서 트랜잭션 commit 시점에 너 이거 변경될 거라고 딱 바꿔치기를 해요.

그런데 이건 내가 직접 new로 해서 만든 거잖아요. 기본적으로 JPA가 얘를 관리하지 않아요.

그렇기 때문에 제가 아무리 여기에서 값을 바꿔치기를 해도 DB에 업데이트가 안 일어나요.

예를 들어서 이 itemService.saveItem 로직이 없다고 칠게요. 그러면 제가 이거를 그냥 new한 거잖아요. 트랜잭션이 있다고 쳐도 JPA가 관리를 안하기 때문에 JPA도 이걸 업데이트 칠 수 있는 근거 자체가 없는 거예요.

자 그러면 이러한 준영속 상태의 엔티티는 도대체 어떻게 데이터를 변경할 수 있을까?

준영속 엔티티를 수정하는 방법은 두 가지가 있습니다. 첫 번째는 준영속 엔티티지만 변경 감지 기능을 이용하는 방법이 있고, 두 번째는 Merge를 쓰는 방법이 있습니다. 준영속 엔티티라는 것이 나오면서 Merge가 나온 거에요. 그냥 Merge를 호출하면 되는게 아니에요.
첫 번째 Dirty Checking 이라고 하는 변경 감지를 쓰는 방법은

이렇게 하는 겁니다. 코드로 바로 짜볼게요. ItesmService로 가셔서,

updateItem 메서드를 만들어줍니다. 일단 param은 준영속 엔티티죠. 왜냐면 지금 ItemController에서 수정을 할 때 itemService.updateItem를 호출할 텐데, 여기 담기는 param은 기존에 이미한번 id가 저장된 애죠.
그래서 지금 준영속 객체를 변경 감지 시켜보는 거에요.

일단 itemRepository에서 findOne 해가지고 item을 찾을게요. 지금 id를 기반으로 실제 DB에 있는 영속성 상태의 엔티티를 찾아왔죠. 그리고 set 해가지고 price는 param의 getPrice로 꺼내면 돼요.

그리고 쭉쭉쭉 나머지 필드를 다 채웠다고 하고요.

이제 itemRepository.save를 호출할 필요가 있을까요? 아니면 merge를 호출할 필요가 있을까요?
정답은 아무것도 호출할 필요가 없어요.

지금 보시면 이 itemId로 찾아온 findItem은 영속 상태에요. 그러면 값을 세팅을 한 다음에 어떻게 됩니까? parameter로 넘어온 이 book을 가지고 값을 다 set해서 세팅을 했죠?

그 다음 그냥 이 라인에서 끝나면 어떻게 됩니까?

스프링의 @Transactional에 의해서 트랜잭션이 Commit이 됩니다. 그럼 commit이 딱 되면 JPA는 뭘 하죠? flush 라는 거를 날려요. flush를 날리는 건 영속성 컨텍스에 있는 엔티티 중에 변경된 애가 뭔지 다 찾는단 말이에요. 찾아서 옳거니 하고 '얘가 지금 뭔가 바뀌었네' 합니다. 제가 set으로 바꿨죠. 그래서 이 바뀐 값으로 업데이트 쿼리를 DB에 날려서 업데이트를 쳐버립니다.
이게 이제 변경 감지에 의해서 데이터를 변경하는 방법이고 자 이렇게 하셔야 돼요. 이게 더 나은 방법입니다.
그 다음 한 가지 방법이 더 있습니다.

병합이 영어로 Merge 거든요. Merge를 쓰는 겁니다. 병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이거든요.

지금 저희가 만든 ipdateItem에서 book이 동작하는 방식이 바로 Merge 예요. Merge로 동작한 거예요.

saveItem을 호출하면 어떻게 되죠?

itemRepository에서 save 해가지고 이 item을 넘겨버려요. 자 그럼 itemRepository에서 어떻게 되냐?

merge가 호출이 되죠. id 값이 있으면 Merge가 호출되도록 이 코드를 짜놨단 말이에요.
지금부터 Merge의 비밀이 뭔지 설명을 드리겠습니다.

진짜 단순하게 설명을 드리자면 merge는 제가 방금 짠 이 코드랑 똑같은 코드예요. 영속성 컨텍스트에서 itemId로 db를 뒤져가지고 똑같은 식별자를 가진 item을 찾아요.

그 다음에 이 파라미터로 넘어온 거 있죠.

JPA 입장에서도 merge 해서 넘긴 이 item이 있죠. 이 item 값으로

여기있는 모든 데이터를 다 바꿔치기 해버린 거예요.
바꿔치기가 되니까 어떻게 됩니까? 트랜잭션 commit 일 때 반영이 되는 거예요.

그래서 merge는 쉽게 말씀드려서 이 코드, 제가 한땀 한땀 짠 코드를 JPA가 한줄씩 해주는 겁니다.
자 동작 방식을 한번 쭉 정리를 해드릴게요.

merge를 딱 호출하잖아요. 그럼 어떻게 되느냐? 파라미터로 넘어온 준영속성 엔티티의 식별자 있죠. 지금 예를 member로 들었는데, merge 해서 member를 넘기면 여기서 memberd의 id를 가지고 영속성 컨텍스트에서 찾아요. 찾으면 없겠죠. 없으면 DB에서 엔티티를 가지고 옵니다. 그리고 1차 캐시에 저장합니다. 조회한 영속 엔티티를 mergeMember라고 할게요. 조회해온 mergeMember라는 엔티티에다가 member 엔티티의 값을 채워 넣습니다. 이 mergeMember를 다 set, set해가지고 데이터를 다 밀어넣어 버려요. Hibernate가 막 set을 호출하지는 않는데 아무튼 기본적으로 필드를 그냥 다 바꿔치기 해버려요. 쫙 바꿔치기 한 다음에 바꿔치기 된 거를 반환해줍니다.

굳이 따지자면 이렇게 return 해서 findItem을 반환을 해줘요.

자 그러면 주의할 게 이 item이 영속성 컨텍스트로 바뀌는 건 아니예요.

merge로 반환된 걸 꺼내면, 이게 영속성 컨텍스트에서 관리되는 객체인 거고,

기존에 파라미터로 넘어온 이 item은 영속성 상태로 변하지 않아요. 여기서 이 merge랑 item은 다른 애인 거죠. item은 파라미터로 넘어온 거고 merge는 병합이 돼서 영속성 컨텍스트에서 관리하는 애고요.

그래서 혹시 뭔가 더 쓸 일 있으면 merge를 쓰셔야 됩니다.



참고한 책은 자바 ORM 표준 JPA 프로그래밍이구요.
자 그런데 병합은 되게 조심해야할 게 있어요.

뭐가 문제냐면 주의점 보세요. 변경 감지 기능을 사용하면 딱 원하는 속성만 선택을 해서 데이터를 변경할 수가 있어요. 그런데 병합을 쓰잖아요? 파라미터로 넘어온 걸로 모든 속성이 다 갈아져 버려요. 이게 왜 위험하냐면,
실무에서는 병합 시에 필드에 파라미터 값이 없잖아요. 그러면 null로 업데이트를 해버려요. 그래서 merge는 모든 필드를 다 교체하기 때문에 선택을 할 수 있는 개념이 아니에요.
자 무슨 말이냐,

여기서 만약에 ISPN 같은 경우 필드 값이 없다고, NULL 이라고 해봅시다. 그러면 어떻게 됩니까?

book에는 정말 필요한 필드가 많겠죠. 그런데 우리는 가격은 한번 책정되면 못 바꾼다고 세팅을 해볼게요.

그럼 setPrice를 주석처리 하고 이렇게 놔두잖아요?

이대로 Merge를 호출해버리면 지금 book에서 price는 값이 null 이란 말이에요. 값을 세팅 못하기 때문에. 그러면 데이터베이스에 NULL로 업데이트가 돼버려요. 엄청 위험하죠. 그러니까 기존에 있던 10,000원이 유지가 되는 게 아니라, Merge란 건 진짜 다 갈아치기를 해버려서 NULL조차도 다 갈아치기를 해버리는 거예요.

그래서 merge를 쓰시면 안되고, 변경 감지를 해야 돼요. 조금 귀찮더라도 내가 직접 조회해서 업데이트 칠 필드들만 이렇게 set, set, set 하셔가지고 반환하셔야 돼요. 실무는 되게 복잡하거든요. Merge를 가지고 깔끔하게 할 수 있는 경우는 거의 없어요.
'다 갈아치면 되죠!' 그게 가능할까요? 등록할 땐 필드가 10개였는데 수정할 때는 필드 4, 5개만 수정할 수 있고 이렇게 제약이 생기거든요.

이건 아까 말씀드렸구요.

그럼에도 불구하고 이렇게 만들어 놓으면 편하긴 하겠죠?

save할 때 persist, 업데이트, merge를 한 메서드에서 다 해주니까 편하긴 한데 정말 이거는 단순한 경우에만 적용할 수 있는 거고 일반적인 경우에는 위험하기 때문에 그냥 변경 감지를 해주시는 게 낫습니다.

한 가지 더, 업데이트는 사실 이런식으로 단발성으로 업데이트를 하면 안돼요. 제가 예제니까 이렇게 한 거지,

findItem.change 처럼 의미있는 price나 name이나 넣는다거나 아니면 findItem.addStock 처럼 의미 있는 메서드를 만들어야지 이렇게 set을 막 깔면 안돼요.
이렇게 해야 변경 지점이 엔티티로 다 간단 말이에요. 이렇게 set 막 되있으면 사람들이 '아 도대체 어디서 바꾸는 거야' 하고 조금만 복잡해져도 'setPrice를 보고 도대체 어디서 price를 바꾸는 거야?' 할 수 있어요. 또 이거 찾는다고 한참 뒤져야 돼요.

그래서 change 같은 의미 있는 메서드를 이렇게 넣어 두시면 이거를 딱 역추적 할 수 있거든요. 변경할 때도 엔티티 레벨에서 보고 '아 여기서 바뀌는 구나' 다 추적할 수 있게 설계를 하셔야 돼요.
그래서 setter는 웬만하면 쓰지 말자는 것을 다시 한번 더 강조를 드렸구요.

그래서 실무에서는 보통 변경 가능한 데이터만 노출하기 때문에 병합을 사용하는 것이 오히려 번거롭다. 이것도 말씀드렸고요.

결론적으로 가장 좋은 해결방법 엔티티를 변경할 때는 항상 변경감지를 사용하세요. 이게 제가 꼭 권장하는 방법입니다.

그리고 컨트롤러에서 어설프게 엔티티를 생성하지 마세요.
이 말은 무슨 말이냐면,

컨트롤러에서 이거를 만들었잖아요. form을 받아와서 form을 Book으로 바꿨어요. 왜? 지금 전략적으로 form은 웹 계층에서만 쓰자 라고 딱 잡았단 말이에요. 그러면 이 form을 그대로 itemService 같은 서비스 계층으로 넘기면 되게 지저분 하거든요. 그래서 book을 어설프게 만들어서 넘긴 거예요.
그래서 사실 더 나은 설계는

itemService의 updateItem을 이렇게 바꾸고,

이렇게 바꾸면,

어때요. 훨씬 깔끔하죠. 어설프게 엔티티를 파라미터를 안 쓴 거예요. 정확하게 내가 필요한 데이터만 딱딱딱 받은 거예요. 딱 코드가 명확하게 매칭되니까 이게 훨씬 유지 보수상 나은 거죠. 이게 만약에 업데이트할 데이터가 많다고 그러면,

이 서비스 계층에 DTO를 하나 만드세요.

클래스를 따로 뽑아서

이 파라미터를 필드로 만드셔가지고, 여기에다가

itemDto로 대신 값을 세팅해내는 거죠.

그래서 방금 보여드린 것처럼 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달하라는 거구요.(파라미터 or dto)
물론 DTO에 id가 들어가도 돼요.

여기 id가 있으면 이 트랜잭션 안에서 엔티티를 조회해야 이게 영속 상태로 조회가 되고 거기에서 값을 변경을 해야 어떻게 됩니까? 변경 감지가 일어날 수 있거든요. 트랜잭션을 commit될 때 flush가 일어나면서 변경 감지가 쫙 되면서 업데이트 쿼리가 DB에 딱 날아간단 말이에요.

그래서 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하시면 트랜잭션 commit 시점에 변경 감지가 실행됩니다.
여기까지 해서 이제 변경 감지에 대해서 설명을 드렸습니다.

지금 보시면 updateItem에 저자랑 ISBN이 없죠.

저자랑 ISBN은 업데이트 하지 말자 라고 임의로 정했다고 칩시다.
아무튼 이렇게 해서 여러분께 변경감지와 병합에 대해서 설명을 해드렸습니다.
마지막으로 추가로 강조하자면,

setter 없이 엔티티 안에서 바로 추적할 수 있는 메서드를 만드세요.

이런 식으로 메서드를 만들어서 이 안에서 값을 변경하는 게 훨씬 낫습니다. 이렇게 하면 change를 역으로 뒤지면 어디서 변경한지가 다 나오거든요. 이거 하나만 딱 찾으면 된단 말이에요.

반면에 이렇게 stter를 풀어 놓으면 정말 추적하기가 어려워요.
자 그래서 이번 시간에는 변경 감지와 병합에 대해서 말씀드렸고요. 다음 시간에는 상품을 주문하는 코드를 한번 짜보도록 하겠습니다.