[JPA] 변경감지(dirty checking)

2023. 5. 18. 13:11JPA 기초

Intro

해당 포스팅에서는 JPA가 제공하는 변경감지 기능을 가볍게 다룹니다.

"JPA의 영속성 컨텍스트는 객체의 동등성 뿐만아니라 동일성도 보장한다" 는 내용이 포스팅의 핵심이므로 해당 내용을 잘 아시는 분은 넘어가셔도 좋을 것 같습니다!(지적 환영)


Problem

팀원분이 JMeter를 이용해 성능 테스트를 진행하던 중, 병목 현상이 발생하는 부분을 발견하였습니다.

https://github.com/prgrms-web-devcourse/Team-DarkNight-Kkini-BE/pull/223

 

[#221] Store내 병목 현상을 유발하는 코드 제거 by JoosungKwon · Pull Request #223 · prgrms-web-devcourse/Team-Dar

🌱 작업 사항 refactor(Store): Store내 병목현상 제거 Store를 Update 하는 과정에서 Point 객체의 값이 미묘하게 변하게 되어(내부적으로 Double을 가지고 있는데 아마, 부동소수점으로 인해 발생했을것으

github.com

 

요 프로젝트는 주변 가게를 대상으로 식사 모임을 생성/참여하여 식사 메이트를 구할 수 있는 플랫폼인데요,
식사 모임을 생성할 때, 특정 가게를 대상으로 생성하는 것이 정책입니다.

따라서 식사 모임을 생성할 때, 모임하려는 가게에 대한 정보 + 생성하려는 모임의 정보(인원, 시간, 모임 이름 등)를 Request로 받고 있습니다.

만약 모임을 생성하려고 하는 가게 정보가 DB에 이미 저장되어 있다면 가게 정보를 Request에 담긴 정보로 업데이트를 하게 됩니다.  아래는 해당 코드입니다.

@Transactional
public IdResponse create(CreateCrewRequest request, Long userId) {

   Store store = storeMapper.toStore(request.createStoreRequest());

   //가게 정보가 DB에 있는 경우 update, 없는 경우 save 실행
   Crew crew = storeRepository.findByPlaceId(request.createStoreRequest().placeId())
      .map(findStore -> {
         //병목 발생 지점
         findStore.updateStore(store);
         return crewMapper.toCrew(request, findStore);
      })
      .orElseGet(() -> {
         storeRepository.save(store);
         return crewMapper.toCrew(request, store);
      });
	
    ...
}

 

위의 코드에서 병목현상이 발생한 지점은 update를 실행하는 부분인 아래 코드입니다.

findStore.updateStore(store);

만약 위의 메소드에서 Store에 변경이 발생한다면, 트랜잭션이 종료되기 전에 변경 감지를 통해 update 쿼리가 발생하게 됩니다.

그리고 update문과 같이 데이터를 변경하는 작업은, 데이터의 일관성과 무결성을 위해 잠금(Lock)을 사용하게 됩니다. 
즉, 트랜잭션 A가 특정 레코드를 update하는 도중에, 트랜잭션 B가 동일한 레코드를 변경하려고 한다면 잠금으로 인해 대기 상태가 됩니다. 

그래 update 중에는 락이 걸리고 여러 트랜잭션이 경합하면 오류가 발생하겠구나. 성능테스트에서 왜 오류가 발생했는지는 이제 알겠습니다. 근데  왜 update가 발생하는지에 대한 궁금증은 여전히 남아있었습니다.

왜냐하면 성능 테스트에서는 같은 가게를 대상으로 모임 생성 요청을 하였기 때문에, Store의 모든 필드값은 그대로 유지되기 때문입니다.  즉 update 될 건덕지가 없었습니다.

findStore.updateStore(store);

따라서 위의 메소드가 실행되어도 가게 정보에 저장되는 필드 값은 이전의 값과 100% 일치하였으므로 변경 감지가 발생한 것이 의외였습니다.


Conclusion

Store 엔티티는 다음과 같은 필드들을 가지고 있습니다.

public class Store extends BaseEntity {

@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

@Column(nullable = false)
private Point location;

@Column(nullable = false)
private String placeId;

@Column(nullable = false)
private String placeName;

...

}

 

결론부터 말씀드리면, Point 타입의 location 때문에 변경감지가 발생했습니다. 같은 가게를 대상으로 요청하기 때문에, Request에 담겨오는 placeName, placeId, location(x좌표, y좌표)는 전부 동일했습니다. 
하지만 updateStore()을 통해 새로 생성된 Point 객체로 변경되었기 때문에, 동일하지 않은 객체를 감지하고 변경감지가 발생한 것입니다.

아래는 새로운 Point가 생성되는 코드입니다. updateStore()에서 이때 생성된 Point 객체로 변경됩니다.

@Named("pointMethod")
default Point mapPoint(CreateStoreRequest request) {

   GeometryFactory gf = new GeometryFactory();

   return gf.createPoint(new Coordinate(
      request.longitude(),
      request.latitude()));

}

 

이토록 헤맸던 이유는 변경감지가 동등성 비교를 통해 이루어진다고 생각했기 때문입니다.
Point type은 equals & hashcode를 오버라이딩하고 있었고, Request로부터 새로 생성된 Point와 기존 Store의 Point가 Objects.equals()결과는 true가 나왔습니다.

하지만 JPA의 영속성 컨텍스트는 객체의 동등성 뿐만아니라 동일성도 보장합니다.

그렇다면 String도 객체 타입이므로 String의 리터럴이 동일하게 변경되더라도 참조값이 달라지므로 변경 감지가 일어나야 하는 것 아닌가? 생각하실 수 있습니다. 

String은 new 연산자로 생성한 것이 아니라면 JVM String Constant Pool에 저장되어 재사용됩니다. 이 때 재사용이라는 말은 리터럴 값이 같으면 같은 참조값을 같게 된다는 뜻입니다. 따라서 String 필드는 리터럴이 동일하게 변경되는 경우에도 참조값이 달라지지 않으므로 변경 감지가 일어나지 않습니다.

글로 배운것은 금방 잊게되는 것 같습니다..