ORM과 데이터 정합성 유지하기 - boostcampwm-2022/web17-waglewagle GitHub Wiki

😵‍💫 문제발생 : 객체 상태와 데이터베이스 상태 간의 차이 발생

JPA를 통해 상태변경 쿼리문(JPQL)을 작성하고 해당 쿼리문의 정상 동작 여부를 확인하고자 디버깅을 통해 확인해보았는데, 예상하지 못한 상황에 마주치게 되었다.

@Transactional
public void changeMember(Long memberId) {

	Member member = memberRepository.findById(memberId);
	
	//상태변경 쿼리
	memberRepository.delete(member);
	
	//동작 확인(콘솔 출력, 디버깅 중단점)
	System.out.println(memberRepository.findById(memberId)); //member객체 출력!(기대값: null)
}

삭제 쿼리를 요청하고 정상적으로 수행 되었음에도 자바 코드 레벨에서 대상 객체가 생존 해 있는 상황을 확인하였다. 위의 상황을 분석하고 해결하는 과정에서 JPA ‘엔티티 생명주기’, ‘캐시’ 그리고 ‘트랜잭션’ 까지 넓은 범위의 주제에 대해 학습할 수 있었다.

❗️ 문제 원인 : 1차 캐시

가장 먼저, JPA (Hibernate)의 캐시 구조이다.

Hibernate는 두 종류의 캐시를 사용할 수 있다.

  • 1차 캐시
    • 영속성 컨텍스트 내부에 존재하는 엔티티를 보관하는 캐시
    • *트랜잭션 단위 로 존재하고 공유된다.(”트랜잭션이 시작되고 종료될 때까지 캐시가 유효”)
    • 트랜잭션안에서 commit 혹은 flush가 호출되면 1차 캐시의 내용(엔티티의 변경사항)을 데이터베이스에 동기화 한다.
    • 영속성 컨텍스트 자체가 1차 캐시로, 끄고 킬 수 있는 옵션이 아니다.
    • 엔티티 자체를 보관하고 있어 캐시의 반환값이 조회 대상이 되는 객체와 똑같다.(동일성, ==비교)
  • 2차 캐시
    • 영속성 컨텍스트 범위가 아닌, 애플리케이션 범위의 캐시(트랜잭션의 시작과 종료가 아닌, 애플리케이션이 시작되고 종료될 때까지 캐시가 유지된다.)
    • 끄고 킬 수 있는 옵션으로, 2차 캐시 옵션이 켜져있으면, EntityManager를 통해 데이터를 조회할 경우, ‘1차 캐시 → 2차 캐시 → 데이터베이스’순으로 조회를 진행한다.
    • 가지고 있는 “엔티티를 복사하여” 반환한다.(== 비교에 대해 항상 보장되지는 않음)

위에 서술한 우리가 겪었던 문제는 1차 캐시에 관련된 문제로, 엔티티 메니저를 통하지 않고 쿼리를 통해 직접 데이터베이스의 상태를 변경 하여 1차 캐시에 있는 객체 의 상태와 데이터베이스의 데이터 상태 간의 차이가 생긴 것 이었다. (기본적으로 엔티티 매니저를 통한 상태변경은, 트랜잭션 종료 시점에 엔티티 매니저가 영속화된 객체의 상태 변경을 자동으로 감지하고 반영하는 ‘더티체킹’이라고 불리는 기법을 통해 진행한다.)

🔨 문제 해결 : 엔티티 생명주기

엔티티 매니저가 관리하는 객체, “엔티티”의 생명주기(엔티티 상태)는 아래와 같다.

  1. 비영속(new/transient): 영속성 컨텍스트와 전혀 관계가 없는 상태
  2. 영속(managed): 영속성 컨텍스트에 저장된 상태(영속성 컨텍스트에 의해 관리되는 상태)
  3. 준영속(detached): 영속성 컨텍스트에 저장되었다가 분리된 상태
  4. 삭제(removed): 삭제된 상태

결국, 데이터베이스의 데이터가 아닌, 영속성 컨텍스트 내의 Managed 상태의 엔티티가 먼저 조회되어 발생한 문제이다. 생태변경 쿼리 이후에 추가적인 쿼리의 정상 동작을 위해서는 1차 캐시(영속성 컨텍스트)의 내용을 비워줄 필요가 있다.

@Modyfying(clearAutomatically = true)
void delete(Member member);

@ModyfyingclearAutomatically 속성을 사용하여, 상태변경 쿼리 이후에 1차 캐시를 명시적으로 비워줄 수 있다.

@Transactional
public void changeMember(Long memberId) {

	Member member = memberRepository.findById(memberId);
	
	//상태변경 쿼리
	memberRepository.delete(member);
	
	//동작 확인(콘솔 출력, 디버깅 중단점)
	System.out.println(memberRepository.findById(memberId)); //null
}

1차 캐시를 비워준 이후, 예상대로 동작함을 확인할 수 있었다.