MyBatis에도 Hibernate처럼 1차/2차 캐시 기능이 있다는 걸 알게 됐다.
캐시를 사용할 땐 주의를 요해야 하는데, 실무에서 이 기능을 모른 채 작업하다가 당황스러운 상황을 맞이하여 정리한다.
테스트 환경은 아래와 같다.
MyBatis의 내부 동작을 먼저 알아야 캐시 기능을 이해하기가 쉽다. Mapper Interface를 호출했다고 가정했을 때 아래의 동작을 수행한다.
MapperProxy
다.SqlSessionTemplate
는 실행될 때 SqlSesisonFactory
를 통해 새로운 SqlSession
을 생성한다. TransactionSynchronizationManager
를 통해 해당 transaction에 SqlSession
가 이미 생성되어 있다면 재사용한다.CachingExecutor
는 2nd 캐시가 있는지 확인한다.BaseExecutor
는 1st 캐시가 있는지 확인한다.로컬 캐시는 항상 활성화되어 있다. 비활성화할 수 있는 방법을 제공하지 않지만 후술할 방법으로 대체할 수 있다.
로컬 캐시는 BaseExecutor
에 존재한다. Executor
의 구현체는 SqlSession
생성자의 인자로 사용될 때 생성된다. 따라서 로컬 캐시의 최대 생애주기는 SqlSession
이 종료될 때까지다.
List<E> list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
/* ... */
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
로컬 캐시는 mybatis.configuration.local-cache-scope
로 캐시 범위를 조절할 수 있다. 기본값은 SESSION
, 즉 transaction이 종료될 때까지다. STATEMENT
로 설정됐다면, 쿼리 결과를 받은 후 즉시 캐시를 폐기한다.
로컬 캐시 기능은 2차 캐시와 달리 비활성화할 수 있는 방법을 제공하지 않지만, 이 옵션으로 갈음할 수 있다.
CacheKey cacheKey = new CacheKey();
cacheKey.update(mappedStatement.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
for (ParameterMapping parameterMapping : parameterMappings) {
if (parameterMapping.getMode() != ParameterMode.OUT) {
/* ... */
cacheKey.update(value);
}
}
if (configuration.getEnvironment() != null) {
// issue #176
cacheKey.update(configuration.getEnvironment().getId());
}
캐시의 키로 여러 값이 사용되지만 일반적으로 주의해야 할 값은 아래와 같다.
같은 Mapper에서 같은 SQL이라도 메서드가 다르다면, 동일한 캐싱 대상이 아니다. 클래스 이름, 메서드 이름, SQL이 같더라도 Mapper 간 패키지가 다르면, 이도 캐싱 대상이 아니다. 따라서 캐싱을 적극적으로 활용하고자 한다면, 어떤 모델을 조회하는 Mapper는 하나만 정의하는 게 좋다. (insert/update/delete는 당연히 캐싱되지 않으므로, CQRS로 구성해도 문제 없다)
파라미터의 타입이 custom class라면 반드시 equals/hashcode를 재정의해야, 의도한 대로 캐싱이 동작한다.
BaseExecutor
는 insert/update/delete가 호출되면 먼저 로컬 캐시 전부를 폐기한다.
동일 transaction이라면 여러 Mapper가 같은 SqlSession
을 공유한다. Executor
는 SqlSession
와 생애주기가 같다. 따라서 동일 transaction에서 Mapper나 메서드와 상관없이 insert/update/delete가 호출되면, 해당 transaction의 모든 로컬 캐시가 폐기된다.
@Transactional
public Foo findFoo(Long id) {
// Gets a foo from database first.
Foo foo = fooMapper.selectFoo(id);
// Gets the foo from local cache.
Foo anotherFoo = fooMapper.selectFoo(id);
assert foo == anotherFoo;
barMapper.insertBar(new Bar(foo));
// Gets the foo from database again.
Foo otherFoo = fooMapper.selectFoo(id);
assert foo != otherFoo;
}
FooMapper
가 아닌 BarMapper
에서 insert가 호출되었지만 같은 transaction에 엮여 있으므로, FooMapper.selectFoo
의 캐시는 폐기된다. 물론 FooMapper.insertFoo
를 호출해도 같은 결과를 볼 수 있다.
TransactionSynchronizationManager
가 ThreadLocal
에 SqlSession
을 저장)REQUIRES_NEW
또는 NESTED
인 경우일반적으로 위의 상황일 때 문제가 발생할 수 있다. 구체적인 코드를 보자.
@Service
@RequiredArgsConstructor
public class FooService {
private final FooMapper fooMapper;
private final BarService barService;
@Transactional
public Foo findFoo(Long id) {
// Gets a foo from database first.
Foo foo = fooMapper.selectFoo(id);
// Gets the foo from local cache.
Foo anotherFoo = fooMapper.selectFoo(id);
assert foo == anotherFoo;
barService.createBar(foo);
// Still gets the foo from local cache.
Foo otherFoo = fooMapper.selectFoo(id);
assert foo == otherFoo;
}
}
@Service
@RequiredArgsConstructor
public class BarService {
private final BarMapper barMapper;
private final FooMapper fooMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Bar createBar(Foo foo) {
Bar bar = new Bar(foo)
barMapper.insertBar(bar);
fooMapper.updateFoo(foo);
return bar;
}
}
BarService.createBar
는 propagation이 REQUIRES_NEW
라서 호출될 때마다 새로운 transaction에서 실행된다. 다른 transaction에서 실행된다는 건 다른 Executor
다. BarService
에서 insert/update가 호출되었지만 FooService
에서의 로컬 캐시에는 영향을 끼치지 못하기 때문에, 여전히 로컬 캐시에서 값을 가져온다.
foo가 update되어 dirty하게 됐지만 FooService
는 이를 알 수 없기에 예전 값을 반환한다. 별도의 transaction에 동작하는 코드는 신중히 생각하여 작성해야 한다.
이 글을 쓰게 된 이유가 이것이다. JPA는 영속된 엔터티의 값을 변경하면 dirty checking이 들어간다는 걸 알고 있기에, update를 의도하여 값을 변경한다. 반면에 MyBatis는 반환된 값을 수정하여 요리조리 사용하는 경우가 많다. (물론 side effect가 발생할 수 있어서 좋지 않지만 별론으로 하자) 데이터베이스에 저장된 원래의 값을 얻기 위해 다시 조회하는 경우가 종종 있다.
public void doSomething() {
Foo foo = fooMapper.selectFoo(1);
foo.setId(2);
// Gets the foo whose id increased from local cache.
Foo otherFoo = fooMapper.selectFoo(1);
assert foo.getId() == otherFoo.getId();
}
데이터베이스에서 조회한 foo는 id가 1이었다. 이걸 2로 변경하고 같은 값으로 다시 조회하면 id가 2인 foo가 반환된다. 위의 예시가 현실적이지 않다고 생각하는가? 많은 동료와 많은 코드 속에서 협업하다보면 자신이 제어할 수 없는 범위에서 위와 같은 일이 발생할 수 있다. 이 경우 원인이 되는 코드를 찾기는 더욱 어려워진다.
이에 대해 공식 사이트에서도 주의하라고 언급하고 있다.
MyBatis returns references to the same objects which are stored in the local cache. Any modification of the returned objects (lists etc.) influences the local cache contents and subsequently the values which are returned from the cache in the lifetime of the session. Therefore, as best practice, do not to modify the objects returned by MyBatis.
이 문제를 아래와 같이 해결했다.
@Service
@RequiredArgsConstructor
public class FooService {
private final FooMapper fooMapper;
public List<Foo> findAllFooList() {
return fooMapper.selectAllFooList().stream().map(Foo::new).toList();
}
}
public class Foo {
private Long id;
private String name;
// For MyBatis.
protected Foo() {
}
public Foo(Foo other) {
this.id = other.id;
this.name = other.name;
}
}
copy constructor를 사용하여 FooService
를 사용하는 곳에서 로컬 캐시를 참조하지 못하게 했다.
이 캐시는 로컬 캐시와 달리 애플리케이션 전역에 적용된다.
XML Mapper를 사용하는 경우, 각 Mapper에 아래의 코드만 삽입하면 캐시가 적용된다. 다시 말해 2차 캐시는 Mapper별로 저장된다.
<cache />
Java Annotation으로 Mapper를 사용하는 경우, 아래의 annotation을 붙이면 된다.
@Mapper
@CacheNamespace
public interface FooMapper {
}
만약 XML Mapper와 함께 사용한다면 캐시를 공유하기 위해 아래의 annotation을 쓴다.
@Mapper
@CacheNamespaceRef(FooMapper.class)
public interface FooMapper {
}
자세한 옵션은 공식 사이트에서 확인할 수 있다.
org.apache.ibatis.cache.Cache
인터페이스를 구현하기만 하면 third-party 캐시 구현체도 활용할 수 있다. 2차 캐시는 mybatis.configuration.cache-enabled
으로 기능을 끌 수 있다. 기본값은 true
이며 false
로 설정할 경우, CachingExecutor
를 사용하지 않는다.
로컬 캐시와는 달리 transaction이 종료돼도 캐시가 사라지지 않는다. 오로지 eviction
과 flushInterval
옵션 값에 따라 캐시 생애주기가 결정된다. flushInterval
을 설정하지 않으면 SQL을 실행할 때마다 폐기할 캐시가 있는지 확인한다.
로컬 캐시와 동일하다.
흥미로운 점은 CachingExecutor
가 쿼리 결과를 캐시 구현체에 즉시 전달하지 않는다는 것이다. TransactionalCache
를 사용하여 해당 transaction이 commit될 때까지 캐싱될 데이터를 보관한다. 마치 버퍼처럼 commit이 되어야 캐시 구현체에 flush한다.
CachingExecutor
는 2차 캐시가 있는지 먼저 찾아보고 없을 경우에만 로컬 캐시를 조회한다.
로컬 캐시를 먼저 조회하지 않으므로 로컬 캐시와 병행하려고 한다면 주의해서 사용해야 한다.