RefreshableSqlSessionFactoryBean으로 수정한 쿼리 바로 반영하기

DDEO._.NU·2025년 6월 27일

Spring

목록 보기
2/5
post-thumbnail

Spring + Mybatis를 사용하는 프로젝트를 진행하면서 쿼리를 작성하고 매번 서버를 재기동하는 과정이 너무 불편해서 알아보던 중
RefreshableSqlSessionFactoryBean를 적용하면 Mybatis에서 사용하는 Mapper파일이 수정될 때마다 서버를 재시작할 필요 없이 바로 반영할 수 있게 도와준다는 것을 발견!

RefreshableSqlSessionFactoryBean.java

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를 바라보고 작업을 재개하게 되는 것입니다.

0개의 댓글