Spring + Mybatis를 사용하는 프로젝트를 진행하면서 쿼리를 작성하고 매번 서버를 재기동하는 과정이 너무 불편해서 알아보던 중
RefreshableSqlSessionFactoryBean를 적용하면 Mybatis에서 사용하는 Mapper파일이 수정될 때마다 서버를 재시작할 필요 없이 바로 반영할 수 있게 도와준다는 것을 발견!
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.core.io.Resource;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.*;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
// SqlSessionFactoryBean을 상속받아 Mybatis의 SqlSessionFactory를 생성하고 관리합니다.
// DisposableBean 인터페이스를 구현하여 Spring 컨테이너 종료 시 자원을 정리할 수 있도록 합니다.
public class RefreshableSqlSessionFactoryBean extends SqlSessionFactoryBean implements DisposableBean {
// 로깅을 위한 Logger 인스턴스 생성
private static final Log log = LogFactory.getLog(RefreshableSqlSessionFactoryBean.class);
// SqlSessionFactory의 실제 인스턴스 대신 사용할 프록시 객체입니다.
// 이 생성된 객체를 통해서 SqlSessionFactory에 접근하게 됩니다.
// 프록시 객체는 XML정보를 가지고 있지 않고 SqlSessionFactory와 연결해주는 매개체 역할
private SqlSessionFactory proxy;
// Mapper 파일 변경 감시 주기(밀리초)
private int interval = 500;
// 파일변경 감지를 위한 타이머
private Timer timer;
// 타이머에 의해 실행될 작업
private TimerTask task;
// 모니터링할 Mapper 파일들의 위치
private Resource[] mapperLocations;
/**
* 파일 감시 쓰레드가 실행중인지 여부.
*/
private boolean running = false;
// 읽기-쓰기 락(ReentrantReadWriteLock)을 사용하여 SqlSessionFactory 객체에 대한 동시 접근 제어
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 읽기 락. SqlSessionFactory 객체를 읽을 때 사용됩니다.
private final Lock r = rwl.readLock();
// 쓰기 락. SqlSessuibFactiry 객체를 새로고침할 때 사용
private final Lock w = rwl.writeLock();
// 모니터링할 Mapper 파일 위치 설정
// SqlSessionFactoryBean의 setMapperLocations를 호출하여 기본설정
// 넘어오는 파라미터는 context-mapper.xml에 정의된 mapperLocations에 정의
public void setMapperLocations(Resource[] mapperLocations) {
super.setMapperLocations(mapperLocations);
if(mapperLocations != null) {
int cnt = mapperLocations.length;
this.mapperLocations = new Resource[cnt];
// 원본 배열에 영향이 가지 않도록 복사
for(int i=0;i<cnt;i++) {
this.mapperLocations[i] = mapperLocations[i];
}
}
}
// 파일 변경 감지 주기를 설정
public void setInterval(int interval) {
this.interval = interval;
}
/**
* SqlSessionFactory를 새로고침하는 메소드
* 파일 변경이 감지되었을 때 호출되어 Mybatis 설정을 다시 로드합니다.
* w.lock()을 사용하여 SqlSessionFactory가 새로고침될 때 접근하지 못하도록 제어합니다.
* @throws Exception
*/
public void refresh() throws Exception {
if (log.isInfoEnabled()) {
log.info("refreshing sqlMapClient.");
}
w.lock(); // 쓰기 락 획득
try {
// 부모 클래스의 afterPropertiesSet을 호출하여 SqlSessionFactory 재생성
super.afterPropertiesSet();
} catch (IOException e) {
log.error("IOException");
} catch (Exception e) {
log.error("Exception");
} finally {
w.unlock(); // 쓰기 락 해제
}
}
/**
* 스프링 빈 초기화 시 호출된는 메소드
* 부모 클래스의 초기화 로직을 수행하고, SqlSessionFactory를 프록시로 래핑하고
* 파일 변경 감지 타이머를 설정합니다.
* 싱글톤 멤버로 SqlMapClient 원본 대신 프록시로 설정하도록 오버라이드.
*/
public void afterPropertiesSet() throws Exception {
super.afterPropertiesSet(); // 부모 클래스의 초기화 로직 수행
setRefreshable(); // 새로고침 가능하도록 설정
}
// SqlSessionFactory 객체를 프록시로 래핑하고, Mapper 파일 변경 감지 타이머를 설정합니다.
// 프록시를 통해 SqlSessionFactory에 접근하면 SqlSessionFactory는 읽기 락에 의해 보호됩니다.
private void setRefreshable() {
// SqlSessionFactory 인터페이스를 구현
proxy = (SqlSessionFactory) Proxy.newProxyInstance(SqlSessionFactory.class.getClassLoader(), new Class[] { SqlSessionFactory.class }, new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 실제 SqlSessionFactory 객체의 메소드 호출
return method.invoke(getParentObject(), args);
}
});
// 파일 변경 감지를 위한 TimerTask 설정
task = new TimerTask() {
// 각 리소스의 마지막 수정 시간을 저장하는 맵
private Map<Resource, Long> map = new HashMap<Resource, Long>();
// 주기적으로 호출되며, 파일 변경 여부를 확인합니다.
public void run() {
// 파일 변경 여부 확인
if (isModified()) {
try {
// SqlSessionFactory 새로고침
refresh();
} catch (Exception e) {
log.error("caught exception", e);
}
}
}
// Mapper 파일 중 수정된 파일이 있는지 확인합니다.
private boolean isModified() {
boolean retVal = false;
if (mapperLocations != null) {
for (int i = 0; i < mapperLocations.length; i++) {
Resource mappingLocation = mapperLocations[i];
// 각 리소스에 대해 수정 여부를 확인합니다.
// 하나라도 존재하는 경우 true가 반환됩니다.
retVal |= findModifiedResource(mappingLocation);
}
}
return retVal;
}
// 특정 리소스 파일의 수정 여부를 확인하고, 수정되었다면 true를 반환합니다.
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) {
// 새로운 수정시간으로 업데이트
map.put(resource, new Long(modified));
// 수정된 파일 목록에 추가
modifiedResources.add(resource.getDescription());
// 수정되었으니 true 반환
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();
}
// 실제 SqlSessionFactory 객체를 반환합니다.
// r.lock()을 사용하여 SqlSessionFactory 객체를 읽는 동안 쓰기 작업이 발생하지 않도록 보호합니다.
private Object getParentObject() throws Exception {
// 읽기 락 획득
r.lock();
try {
// SqlSessionFactory의 getObject()를 호출하여 원본 SqlSessionFactory를 반환합니다.
return super.getObject();
} catch (IOException e) {
throw new IOException("IOException");
} catch (Exception e) {
throw new Exception("Exception");
} finally {
// 읽기 락 해제
r.unlock();
}
}
// 애플리케이션에서는 항상 프록시 객체를 바라봅니다.
public SqlSessionFactory getObject() {
return this.proxy;
}
// 반환될 객체의 타입을 명시합니다.
public Class<? extends SqlSessionFactory> getObjectType() {
return (this.proxy != null ? this.proxy.getClass() : SqlSessionFactory.class);
}
// 이 Bean이 싱글톤임을 나타냅니다.
public boolean isSingleton() {
return true;
}
// 파일 변경 감지 간격을 설정하고, 이미 타이머가 있다면 재설정 합니다.
public void setCheckInterval(int ms) {
interval = ms;
if (timer != null) {
// 간격이 변경되었다면 타이머 재설정
resetInterval();
}
}
// 타이머의 스케줄을 재설정합니다.
private void resetInterval() {
// 타이머가 실행중이라면 작업을 취소하고 상태를 false로 설정합니다.
if (running) {
timer.cancel();
running = false;
}
// 유효한 간격이라면 새로운 간격으로 작업 스케줄을 설정하고 상태를 true로 설정합니다.
if (interval > 0) {
timer.schedule(task, 0, interval);
running = true;
}
}
// 스프링 컨테이너에서 빈이 소멸될 때 호출되는 메소드입니다.
// 타이머를 취소하여 불필요한 스레드가 계속 실행되는 것을 방지합니다.
public void destroy() throws Exception {
timer.cancel();
}
}
📌 생성된 proxy 객체는 XML 정보를 가지고 있지 않고
SqlSessionFactory과 연결해주는 매개체 역할을 합니다.
모든 메소드 호출을 가로채어 접근, 로깅, 캐싱, 보안 등 실제 객체와 분리하여 처리하기 위한 디자인 패턴입니다.
Mybatis는 .xml 파일에 SQL 쿼리를 정의합니다. SqlSessionFactory는 이 .xml 파일들을 읽어서 메모리에 로드하고, 어떤 쿼리를 실행할지 정보를 가지고 있습니다.
SqlSessionFactory는 한 번 빌드되면 그 시점의 .xml 파일 내용을 고정적으로 가지고 있게 됩니다.
따라서 .xml 파일이 수정되었을 때 SqlSessionFactory를 재빌드 하지 않으면 수정된 SQL쿼리가 반영되지 않습니다.
위 코드에서는 TimerTask를 통해 지속적으로 XML파일이 수정되었는지 확인 후 refresh()를 호출하여 SqlSessionFactory가 최신 상태의 xml정보를 가지고 있을 수 있도록 재빌드시킵니다.
재빌드 되고 있는 상태의 SqlSessionFactory를 참고할 경우 예상치 못한 오류를 발생시킬 수 있기 때문에 w.lock()을 통해 애플리케이션의 새로운 쿼리 요청들을 대기시킵니다.
재빌드가 완료되고 w.unlock()이 풀리면, 대기하고 있던 요청들이 r.lock()을 획득하고 getParentObject()를 통해 새롭게 빌드된 SqlSessionFactory 인스턴스를 받아 처리됩니다. 이 새로운 인스턴스에는 최신 버전의 XML 정보가 반영되어 있습니다.
lock이 걸린 짧은 재빌드 시간 동안 애플리케이션은 쿼리를 처리하지 않고 대기합니다. 이 시간 동안에는 이전 XML도, 새로운 XML도 바라보지 않습니다. 재빌드가 완료되어 락이 풀리는 순간, 비로소 새롭게 빌드된, 최신 XML 정보를 포함한 SqlSessionFactory를 바라보고 작업을 재개하게 되는 것입니다.