ecsimsw

Cache 정합성 문제, TransactionAwareCacheManagerProxy 동작 원리 본문

Cache 정합성 문제, TransactionAwareCacheManagerProxy 동작 원리

JinHwan Kim 2024. 6. 17. 20:51

롤백에 의해 정합성이 깨지는 문제

아래 update()에선 id에 해당하는 person의 이름을 수정한다. 

DB에서 person을 조회하고, Transaction이 종료되며 업데이트 쿼리를 수행하고, Cache를 업데이트하게 된다.

 

@Transactional
@CachePut(key = "#id", value = "person")
public Person update(Long id, String newName) {
    var person = personRepository.findById(id).orElseThrow();
    person.setName(newName);
    return person;
}

 

만약 이 update를 감싼 트랜잭션이 실패하게 되면 어떻게 될까.

 

@Transactional
public void updateName(Long id, String newName) {
    personService.update(id, newName);
    throwException();
}

 

DB의 업데이트는 처리되지 않고, 캐시는 트랜잭션 결과에 상관없이 업데이트된다.

결국 아래처럼 DB와 Cache에 데이터 정합성이 깨지는 문제가 발생할 수 있고, 이를 주의해야 한다.

 

DB READ : Person(id=1, name=origin_name)
CACHE READ : Person(id=1, name=new_name)

 

트랜잭션 범위 고민

우선 트랜잭션 범위를 다시 고민해 볼 것 같다. 

만약 트랜잭션 범위가 클 필요가 없다면 범위를 좁혀 상위 메서드에서 발생한 예외에 영향을 받지 않도록 한다.

좁아진 트랜잭션 안에서 DB, Cache 작업이 완료되고, 트랜잭션 밖 예외로부터의 정합성 고민은 없어진다.

 

// @Transactional
public void updateName(Long id, String newName) {
    personService.update(id, newName); // 트랜잭션을 이 메서드 범위로 좁히기
    throwException();
}

 

캐시 처리 위치 고민

어쩔 수 없이 예외가 발생할 수 있는 로직이 포함되어 정합성 문제를 고민해야 한다면,

그다음으로는 캐시 처리 위치가 적절한지 고민할 것 같다.

캐시 선언 위치를 최상단의 트랜잭션과 함께 위치시키면, 예외 발생에도 캐시 처리가 안되고 정합성을 유지할 수 있다.

 

@Transactional
@CachePut(key = "#id", value = "person")
public Person updateName(Long id, String newName) {
    var person = personService.update(id, newName);
    // 예외가 발생할 수 있는 로직
    return person;
}

 

TransactionAwareCacheManagerProxy 사용

'TransactionAwareCacheManagerProxy'를 사용하여 CacheManager를 감싸는 것도 방법이다. 

트랜잭션 성공 여부에 따라 캐시 처리를 여부를 결정하기에, 트랜잭션이 롤백되어도 DB와의 정합성을 유지할 수 있다.

 

@Bean
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
    ...
    return new TransactionAwareCacheManagerProxy(cacheManager);
}

 

TransactionAwareCacheManagerProxy 동작 원리

아래는 TransactionAwareCacheManagerProxy 코드의 일부이다.

TransactionAwareCacheManagerProxy는 CacheManager를 생성자로 받아 등록해 둔다.

 

캐시 이름으로 캐시를 찾는 getCache()가 호출되면 CacheManger로부터 찾은 캐시를 바로 반환하는 것이 아니라, TransactionAwareCacheDecorator로 찾은 캐시를 감싸 반환한다.

 

public class TransactionAwareCacheManagerProxy implements CacheManager, InitializingBean {

    @Nullable
    private CacheManager targetCacheManager;

    public TransactionAwareCacheManagerProxy(CacheManager targetCacheManager) {
        Assert.notNull(targetCacheManager, "Target CacheManager must not be null");
        this.targetCacheManager = targetCacheManager;
    }

    @Override
    @Nullable
    public Cache getCache(String name) {
        Assert.state(this.targetCacheManager != null, "No target CacheManager set");
        Cache targetCache = this.targetCacheManager.getCache(name);
        return (targetCache != null ? new TransactionAwareCacheDecorator(targetCache) : null);
    }
    
    ...
}

 

TransactionAwareCacheDecorator

'TransactionAwareCacheDecorator'는 아래와 같이 동작한다.

 

우선 get()은 그대로 캐시 값을 반환하게 된다. 

TransactionAwareCacheManagerProxy를 사용해도 트랜잭션과 상관없는 캐시 조회가 이뤄진다는 것을 알 수 있다.

 

public class TransactionAwareCacheDecorator implements Cache {

    private final Cache targetCache;
    
    public TransactionAwareCacheDecorator(Cache targetCache) {
        Assert.notNull(targetCache, "Target Cache must not be null");
        this.targetCache = targetCache;
    }

    @Override
    @Nullable
    public ValueWrapper get(Object key) {
        return this.targetCache.get(key);
    }
    ...
}

 

재밌는 것은 캐시 값을 변경하는 put()과 evit()이다.

두 메서드가 호출되면 'TransactionSynchronization' 객체를 정의하게 된다.

 

TransactionSynchronization은 트랜잭션 작업이 커밋되거나 롤백될 때의 동작을 콜백으로 정의할 수 있는데,  TransactionAwareCacheDecorator는 이 콜백 함수를 캐시 처리 로직으로 한다.

 

put, evit의 내용을 TransactionSynchronization의 afterCommit()에 담아 TransactionSynchronizationManager에 등록하는 것으로, 트랜잭션이 커밋된 경우에만 캐시 처리(put, evit)를 수행함을 보장한다.

 

public class TransactionAwareCacheDecorator implements Cache {

    private final Cache targetCache;
    
    @Override
    public void put(final Object key, @Nullable final Object value) {
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCommit() {
                    TransactionAwareCacheDecorator.this.targetCache.put(key, value);
                }
            });
        }
        else {
            this.targetCache.put(key, value);
        }
    }
    
    @Override
    public void evict(final Object key) {
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCommit() {
                    TransactionAwareCacheDecorator.this.targetCache.evict(key);
                }
            });
        }
        else {
            this.targetCache.evict(key);
        }
    }

   ...
}

 

TransactionSynchronizationManager 란?

TransactionSynchronizationManager 는 트랜잭션의 활성 상태, readOnly 여부, 트랜잭션 이름 등 트랜잭션 내에서 처리되는 작업들을 관리하고, 동기화하는 컴포넌트이다. 

 

트랜잭션을 유지하기 위해선 작업마다 동일한 Connection을 사용해야 할 것이다.

Connection 유지를 직접 관리하려면 매번 같은 Connection 객체를 전달하기 위해 고민해야 하고, 코드가 더러워질 것이다.

 

동기화 매니저는 작업 흐름마다 커넥션을 보관하여, 필요한 작업마다 동일한 커넥션을 꺼내 쓸 수 있도록 한다.

커넥션 저장에 ThreadLocal을 사용하여 작업 흐름(스레드)마다 고유한 DB connection을 사용하게 해 트랜잭션을 유지할 수 있도록 한다.

 

// 동기화 시작
TransactionSynchronizeManager.initSynchronization();
Connection conn = DataSourceUtils.getConnection(dataSource);

// 동일한 conn 유지 하에 작업 수행 보장

// 동기화 종료
DataSourceUtils.releaseConnection(conn, dataSource);
TransactionSynchronizeManager.unbindResource(dataSource);
TransactionSynchronizeManager.clearSynchronization();
Comments