Technology/Spring

Dirty check(변경 감지) 가 발생하지 않는 이유는...?

ikjo 2024. 1. 30. 01:53

발생 이슈

입사한지 2달 정도 됐을 당시 주어진 Task 를 진행하는 중에 기존 코드 상 트랜잭션 애노테이션이 붙어있음에도 불구하고 JPA 영속성 컨텍스트에서 제공하는 Dirty check 기능이 활성화되지 않고 있었던 이슈가 있었다. 이로 인해 update 쿼리가 나가야할 때 나가지 않는 문제가 있었다. 코드의 상황은 대략적으로 아래와 같았다.

 

@Slf4j
public class TokenManager {

  @Transactional
  public void updateToken() {
    try {
      Token token = externalApiTokenRepository.find().orElseThrow();
      token.update();
    } catch (Exception e) {
      log.error(".... e : {}, msg : {}", e.getClass(), e.getMessage());
      throw e;
    }
  } 
}

 

 

Dirty check 의 발생 조건은?

당시 코드를 얼핏 봤을 때에는 트랜잭션 애노테이션도 붙어 있는데, Dirty check 가 왜 발생하지 않을까 싶었다. 그렇다면 왜 Dirty Check 가 발생하지 않았던 걸까? 일단, Dirty Check 의 발생 조건부터 되짚어 보기로 했다. 

 

JPA 는 엔티티의 최초 상태를 영속성 컨텍스트에 복사해서 저장(스냅샷)한다. 그리고 스프링 트랜잭션이 커밋되는 순간 엔티티 매니저는 영속성 컨텍스트를 플러시한다. 이때, 엔티티와 스냅샷을 비교하여 변경된 엔티티를 찾는데, 변경된 엔티티가 있다면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 전송하고, 쓰기 지연 SQL 저장소에 모인 쿼리를 데이터베이스에 전송한다. 최종적으로 데이터베이스 트랜잭션을 커밋하여 데이터베이스에 반영이 된다.

 

코드 상 스프링 트랜잭션 애노테이션이 붙어있으니 스프링 트랜잭션이 커밋되는 순간 위와 같은 일련의 작업들이 이루어질거라 기대할 수 있었지만 그러지 않았다. 이때, "스프링 트랜잭션이 제대로 적용안된 거 아니야?"라는 의문을 가질 수 있었다. 왜냐하면 Dirty check 는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용되는데, 스프링 컨테이너의 기본 전략은 트랜잭션 범위와 영속성 컨텍스트의 생존 범위가 같기 때문이다. 즉, 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료하기 때문이다.

 

 

스프링 트랜잭션의 적용 조건

마찬가지로, 스프링 트랜잭션의 적용 조건부터 다시 한 번 되짚어 보기로 했다.

 

스프링에서 트랜잭션을 사용하는 방식으로는 2가지가 있다. 첫 번째는 위 코드에서 처럼 @Transactional 을 사용하여 선언적으로 트랜잭션을 사용 및 관리하는 방법이 있고, 두 번째로는 트랜잭션 매니저 등을 직접 사용해서 트랜잭션 관련 코드를 직접 작성하는 프로그래밍 방식으로 트랜잭션을 사용 및 관리하는 방법이 있다. 어떤 방법이든 스프링 트랜잭션은 트랜잭션 매니저를 통해 동작하며, 스프링 부트는 사용되는 데이터 접근 기술에 따라 적절한 트랜잭션 매니저를 선택해서 스프링 빈으로 등록해준다. 

 

우선, 해당 코드에서는 선언적으로 사용했는데, 이 경우 트랜잭션은 기본적으로 프록시 방식의 스프링 AOP 가 적용되어 동작한다. 즉, 스프링의 트랜잭션 AOP 는 @Transactional 을 인식해서 트랜잭션을 처리하는 별도의 프록시를 생성하게 되는 것이다. 이때, 해당 프록시가 트랜잭션 매니저를 직접 호출하여 트랜잭션을 관리해주게 되는 것이다.

 

이를 통해 또 한 번 생각해볼 수 있었다. "프록시 방식의 스프링 AOP 가 적용안된 거 아니야?"

 

 

스프링 AOP 의 적용 조건

이번엔, 스프링 AOP 의 기본에 대해 되짚어 보기로 했다.

 

프록시 방식을 사용하는 스프링 AOP 는 컴파일도 다 끝나고 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음 (자바의 main 메서드가 이미 실행된 다음) 프록시를 통해 스프링 빈에 부가 기능을 적용하는 방식이다. 이때, 스프링 AOP 는 컴파일이나 클래스 로딩 시점에 적용되어 바이트 코드를 조작하는 AOP 와는 달리 설정이 어렵지 않아 사용하기에는 간편하지만 메서드 실행 지점에만 AOP 를 적용할 수 있다는 제약이 있다. 이러한 스프링 AOP 의 구현 방식에는 JDK 동적 프록시 방식과 CGLIB 프록시 방식이 있다.

 

스프링 AOP 를 되짚어 보며, 눈에 하나 띈 게 있다. 바로 스프링 AOP 는 프록시를 통해 "스프링 빈"에 부가 기능을 적용하는 것이다. 이때 또 한 번 생각해볼 수 있었다. "애초에 이 메서드를 지닌 객체가 스프링 빈이 맞기는 한 걸까?"

 

 

원인과 결론

해당 코드를 작성하신 분의 의도는 트랜잭션 애노테이션이 적용된 메서드를 지닌 해당 객체를 스프링 빈으로 등록하고자 하셨다. 이를 위해 별도 @Configuration 이 적용된 설정 코드 상에서 해당 객체를 스프링 빈으로 등록하기 위한 코드를 작성하시기도 했다. 코드의 상황은 대략적으로 아래와 같다.

 

@Configuration
public class TokenConfig {

    @Bean
    public TokenService tokenService() {
    	return new TokenService(new TokenManager());
    }
}

 

 

위 코드를 보면, TokenService 객체는 확실히 스프링 빈으로 등록되리라 기대할 수 있다. 하지만 인자로 넣어주고 있는 TokenManager 객체도 같이 스프링 빈으로 등록될까?

 

결론적으로, TokenManager 객체는 스프링 빈으로 등록되지 않는다. TokenManager 객체는 TokenService 라는 스프링 빈이 지닌 객체일뿐, 스프링 컨테이너 관리 범위 밖에 있다. 다만, 싱글톤으로 존재하기는 한다. 

 

TokenManager 객체도 함께 스프링 빈으로 등록하기 위해서는 아래와 같이 코드를 작성해야 한다.

 

@Configuration
public class TokenConfig {

    @Bean
    public TokenService tokenService() {
    	return new TokenService(tokenManager());
    }
    
    @Bean
    public TokenManager tokenManager() {
        return new TokenManager();
    }
}

 

@Bean 이 적용된 tokenManager 메서드는 런타임 단계에서 스프링 AOP 에 의해 생성된 프록시에 의해 호출될 것이고, 반환된 TokenManager 객체는 스프링 컨테이너에 의해 관리되는 객체 즉, 스프링 빈으로 정상적으로 등록될 것이다.

 

실제로, 위와 같이 코드를 변경함으로써, TokenManager 가 스프링 빈으로 등록되었고, 이에 스프링 AOP 가 적용되어 스프링 트랜잭션 역시 적용될 수 있었고, 스프링 트랜잭션이 적용됨으로써 Token 엔티티가 영속성 컨텍스트에 의해 관리될 수 있었고 최종적으로 Dirty check 역시 정상적으로 동작하는 것을 확인할 수 있었다. ☕

 

 

 

참고자료

  • 에이콘 "자바 ORM 표준 JPA 프로그래밍"