[Spring] Mybatis의 RefreshableSqlSessionFactoryBean의 SQL 수정이 반영되지 않을 때
결론부터 말씀드리면 이 MybatisProperties의 Configuration에서 로드된 자원 필드인 loadedResources값을 삭제해주면 됩니다.
하지만 이 필드는 보시는 바와 같이 protected final로 선언되어 있고 set 메소드도 따로 존재하지 않습니다.
따라서 기존의 refresh 메서드에 Spring에서 제공하는 BeanUtils를 사용해 깊은 복사로 새로운 객체를 만들어 해결했습니다.
/**
* @throws Exception
*/
public void refresh() throws Exception {
if (log.isInfoEnabled()) {
log.info("refreshing sqlMapClient.");
}
w.lock();
try {
//새 Configuration 객체 생성
Configuration mybatisConfig = new Configuration();
//loadedResources를 제외한 깊은 복사
BeanUtils.copyProperties(mybatisProperties.getConfiguration(), mybatisConfig, "loadedResources");
//생성한 Configuration 객체를 할당
this.setConfiguration(mybatisConfig);
super.afterPropertiesSet();
} finally {
w.unlock();
}
}
Mybatis를 사용하여 프로젝트를 진행하고 있는데 서버를 재시작하지 않고 프로그램의 SQL을 갱신할 수 있다는 방법이 있다는 것을 알게 되었습니다. RefreshableSqlSessionFactoryBean이 바로 그것인데 나온 지는 꽤 오래 되었네요.
그런데 아무리 해봐도 SQL이 갱신이 안 되더군요. 콘솔 로그에서는 분명 xml의 변경이 감지되어 갱신했다는 내용이 출력되었음에도 불구하고 실제 실행되는 SQL에는 변화가 없었습니다.
원인을 따라가 보니 SqlSessionFactoryBean에서 Mapper의 XML 내용을 파싱하기 전에 Configuration의 loadedResources필드 안에 이미 추가된 자원인지 여부를 판단하는 로직이 있었습니다. 한 번 로드된 자원은 이미 XML 파싱이 끝났다고 간주되어 변경 내용이 반영되지 않고 있었던 것이죠.
아래에 RefreshableSqlSessionFactoryBean 변경된 mapper의 컨텐츠를 출력하도록 하는 코드를 포함한 전체 코드를 작성해두겠습니다.
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.boot.autoconfigure.MybatisProperties;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.core.io.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* mybatis mapper 자동 감지 후 자동으로 서버 재시작이 필요 없이 반영
*
* @author sbcoba
*/
@Slf4j
@RequiredArgsConstructor
public class RefreshableSqlSessionFactoryBean extends SqlSessionFactoryBean implements DisposableBean {
private SqlSessionFactory proxy;
private int interval = 500;
private Timer timer;
private TimerTask task;
private Resource[] mapperLocations;
/**
* 파일 감시 쓰레드가 실행중인지 여부.
*/
private boolean running = false;
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
//Bean 주입
private final MybatisProperties mybatisProperties;
public void setMapperLocations(Resource[] mapperLocations) {
super.setMapperLocations(mapperLocations);
this.mapperLocations = mapperLocations;
}
public void setInterval(int interval) {
this.interval = interval;
}
/**
* @throws Exception
*/
public void refresh() throws Exception {
if (log.isInfoEnabled()) {
log.info("refreshing sqlMapClient.");
}
w.lock();
try {
//새 Configuration 객체 생성
Configuration mybatisConfig = new Configuration();
//loadedResources를 제외한 깊은 복사
BeanUtils.copyProperties(mybatisProperties.getConfiguration(), mybatisConfig, "loadedResources");
//생성한 Configuration 객체를 할당
this.setConfiguration(mybatisConfig);
super.afterPropertiesSet();
} finally {
w.unlock();
}
}
/**
* 싱글톤 멤버로 SqlMapClient 원본 대신 프록시로 설정하도록 오버라이드.
*/
public void afterPropertiesSet() throws Exception {
super.afterPropertiesSet();
setRefreshable();
}
private void setRefreshable() {
proxy = (SqlSessionFactory) Proxy.newProxyInstance(
SqlSessionFactory.class.getClassLoader(),
new Class[]{SqlSessionFactory.class},
new InvocationHandler() {
public Object invoke(Object proxy, Method method,
Object[] args) throws Throwable {
// log.debug("method.getName() : " + method.getName());
return method.invoke(getParentObject(), args);
}
});
task = new TimerTask() {
private Map<Resource, Long> map = new HashMap<Resource, Long>();
public void run() {
if (isModified()) {
try {
refresh();
} catch (Exception e) {
log.error("caught exception", e);
}
}
}
private boolean isModified() {
boolean retVal = false;
if (mapperLocations != null) {
for (int i = 0; i < mapperLocations.length; i++) {
Resource mappingLocation = mapperLocations[i];
retVal |= findModifiedResource(mappingLocation);
}
}
return retVal;
}
private boolean findModifiedResource(Resource resource) {
boolean retVal = false;
List<String> modifiedResources = new ArrayList<String>();
try {
long modified = resource.lastModified();
if (map.containsKey(resource)) {
long lastModified = ((Long) map.get(resource))
.longValue();
if (lastModified != modified) {
//쿼리 변경 확인
// BufferedReader br = new BufferedReader(new InputStreamReader(resource.getInputStream()));
// String line = "";
// while((line = br.readLine()) != null) {
// System.out.println(line);
// }
map.put(resource, new Long(modified));
modifiedResources.add(resource.getDescription());
retVal = true;
}
} else {
map.put(resource, new Long(modified));
}
} catch (IOException e) {
log.error("caught exception", e);
}
if (retVal) {
if (log.isInfoEnabled()) {
log.info("modified files : " + modifiedResources);
}
}
return retVal;
}
};
timer = new Timer(true);
resetInterval();
}
private Object getParentObject() throws Exception {
r.lock();
try {
return super.getObject();
} finally {
r.unlock();
}
}
public SqlSessionFactory getObject() {
return this.proxy;
}
public Class<? extends SqlSessionFactory> getObjectType() {
return (this.proxy != null ? this.proxy.getClass()
: SqlSessionFactory.class);
}
public boolean isSingleton() {
return true;
}
public void setCheckInterval(int ms) {
interval = ms;
if (timer != null) {
resetInterval();
}
}
private void resetInterval() {
if (running) {
timer.cancel();
running = false;
}
if (interval > 0) {
timer.schedule(task, 0, interval);
running = true;
}
}
public void destroy() throws Exception {
timer.cancel();
}
}
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.mybatis.spring.boot.autoconfigure.MybatisProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.context.ApplicationContext;
import javax.sql.DataSource;
@EnableConfigurationProperties
@Configuration
@MapperScan(basePackages = "com.sample", annotationClass = Mapper.class,
sqlSessionFactoryRef = "sqlSessionFactory")
@EnableTransactionManagement
@RequiredArgsConstructor
public class DatabaseConfig {
private final ApplicationContext applicationContext;
@Bean
@ConfigurationProperties(prefix = "mybatis.sample")
MybatisProperties mybatisProperties() {
return new MybatisProperties();
}
@Bean
SqlSessionFactory sqlSessionFactory(DataSource dataSource, MybatisProperties mybatisProperties) throws Exception {
SqlSessionFactoryBean factoryBean = new RefreshableSqlSessionFactoryBean(mybatisProperties);
factoryBean.setDataSource(dataSource);
factoryBean.setConfiguration(mybatisProperties.getConfiguration());
factoryBean.setTypeHandlersPackage(mybatisProperties.getTypeHandlersPackage());
factoryBean.setMapperLocations(applicationContext.getResources(mybatisProperties.getMapperLocations()));
//설정 완료 후 호출 메서드
factoryBean.afterPropertiesSet();
return factoryBean.getObject();
}
}