본문 바로가기

프로그래밍/Spring

Spring Framework에서의 독특한 ehcache 사용기... (2)

지난 글에서 Spring에서 ehcache를 일반적인 방법과 다르게 사용하는 이유에 대해 얘기하고 내가 만들었던 소스코드를 올렸다. 이번에는 이 소스코드에 대한 설명을 해보고자 한다

 

Cache 작업에 대한 공통 인터페이스 소스를 다시 올리면 다음과 같다

 

public interface CacheService {
	
	/**
	 * 캐시에 저장할 값을 리턴하는 객체의 함수를 대신 실행하여 캐시에 고유의 캐시키를 주어 저장한다
	 * @param obj			캐시에 저장할 값을 리턴하는 함수가 있는 객체		
	 * @param methodname	캐시에 저장할 값을 리턴하는 함수의 함수명
	 * @param cachekey		캐시키
	 * @param interval		캐시 갱신 간격(0일 경우 캐시 갱신 간격을 체크하지 말고 바로 캐시에서 가져온다)
	 * @param objparam		캐시에 저장할 값을 리턴하는 함수에서 사용하는 파라미터값들
	 * @return				캐시에 저장된 내용
	 * @throws Exception
	 */
	public Object getCache(Object obj, String methodname, String cachekey, long interval, Object ... objparam) throws Exception;
	
	/**
	 * 캐시키를 주어 해당 캐시키에 저장되어 있는 캐시내용을 리턴한다
	 * @param cachekey		캐시키
	 * @param objparam		캐시키를 만들때 사용하는 파라미터 값들
	 * @return				캐시에 저장된 내용
	 * @throws Exception
	 */
	public Object getCache(String cachekey, Object ... objparam) throws Exception;
	
	/**
	 * 현재 사용중인 캐쉬의 크기를 리턴한다
	 * @return				사용중인 캐쉬의 크기
	 * @throws Exception
	 */
	public long getCacheSize() throws Exception;
}
 

JavaDoc 주석을 보고 그 기능을 알 수 있겠지만 앞의 글에서 프로젝트의 요구사항을 디테일하게 적지를 않아서 이렇게 만든 이유에 대해 아리송할수 있을 듯 하여 좀더 디테일하게 설명하고자 한다. 캐시 데이터를 다루는데 있어서는 캐시 데이터를 주기적으로 갱신하여 셋팅하는 갱신 간격의 개념이 존재한다. 캐시로 사용될 데이터가 처음 시스템에 올라간 후에 계속 캐시된 데이터만 줄구장창 내려보낼순 없다. 왜냐면 캐시 데이터 조차도 필요에 따라 바뀔수 있기 때문에 정해진 갱신 주기를 가지고 다시 데이터를 읽어서 새로운 데이터를 가져와야 하기 때문이다. 그러나 프로젝트의 요구사항은 이러한 것을 능동적으로 하기를 바랬다. 요구조건을 조금 정리해보면 다음과 같다

 

1) 캐시 데이터의 갱신주기에 따라 새로이 캐시할 내용을 조회해서 이를 캐시해야 한다(이것은 원래 본연의 기능이다)

2) 캐시 데이터의 갱신주기가 이미 지났지만 필요에 따라 그냥 캐시에 저장되어 있는 데이터를 조회한다(즉 갱신주기를 무시하여 캐시 데이터를 갱신하지 말고 기존에 저장되어 있는 캐시 데이터를 조회한다)

3) 캐시 데이터의 갱신주기와는 상관없이 캐시 데이터에 수정 사항이 발생할 경우 이를 캐시에 바로 올려 사용할수 있어야 한다

 

기존의 Spring과 ehcache의 연동 설정으로는 1)번 외에는 할 수 있는 방법이 없었다. 즉 어플리케이션 레벨에서 캐시에 있는 데이터를 조회하고 또 캐시에 데이터를 등록해야 한다. 그리고 캐시 서비스에서 캐시를 조회할때 캐시 데이터와 갱신 주기만 받아들일수는 없었다. 갱신주기에 따라서 캐시 데이터를 가져와야 할 수도 있고 또 새로이 데이터를 갱신해서 이를 캐시에 넣은뒤에 새로이 갱신된 값을 가져와야 할수도 있고 아예 갱신주기를 무시하고 그냥 현재 캐시에 있는 데이터를 가져와야 할 수도 있기 때문이다. 이러한 판단을 단순히 캐시할 데이터와 갱신주기만 받아서 할 수는 없었다. 그래서 생각했던 개념이 캐시데이터를 가져오는 함수(여기서는 getCache 함수가 되겠다)에서 캐시할 데이터를 조회하는 함수를 대신 실행하는 것으로 개념을 잡았다. 이렇게 하면 다음과 같이 정리가 되기 때문이다

 

1) 캐시 데이터를 조회하는 함수에 실제 캐시 테이터를 DB에서 조회하는 Spring bean 객체와 함수명, 캐시 갱신기간, 함수를 실행할때 사용되는 파라미터 값들을 넣는다

2) 캐시 갱신기간이 지나지 않았을 경우 캐시 데이터를 조회하는 함수는 인자값으로 받은 Spring bean 객체의 함수를 실행하지 말고 현재 캐시에 저장되어 있는 값을 리턴한다. 만약 값 자체가 없을 경우엔 아직 캐시에 적재되어 있지 않은 것이라 간주하고 Spring bean 객체의 함수를 실행하여 그 값을 캐시에 넣고 리턴한다

3) 캐시 갱신기간이 지났을 경우 캐시 데이터를 조회하는 함수는 인자값으로 받은 Spring bean 객체의 함수를 실행하여 조회된 데이터를 캐시에 넣고 이를 리턴한다

 

이렇게 정리하고 자바의 Reflection을 활용하여 이 부분을 구현하였다. 캐시 서비스 함수가 사용자 함수를 대신 실행하여 캐시할 데이터를 조회하고 이 데이터를 캐시에 넣고 리턴한다고 보면 될것이다.

 

이제 위에서 만든 인터페이스를 구현한 소스를 가지고 설명할 차례이다. 하지만 그 전에 설명해야 할 개념이 하나 있어서 이를 소개한뒤에 설명하고자 한다. ehcache가 데이터를 관리하는 구조를 먼저 설명해야 소스에 대한 설명이 이해가 쉬울 듯 하여 이 부분을 먼저 설명하고자 한다. 다음의 xml는 내가 설정한 ehcache.xml의 일부이다.

 

<cache name="selectCache" 
      eternal="false" 
      maxelementsinmemory="100" 
      overflowtodisk="false" 
      diskpersistent="false" 
      timetoidleseconds="0" 
      timetoliveseconds="0" 
      memorystoreevictionpolicy="LRU"
>
</cache>

 

ehcache의 옵션에 대한 설명을 여기서는 하지 않겠다. 구글링 해보면 자세히 나온다. 여기서는 2개의 옵션에 대해서만 설명하고자 한다. 바로 timetoidlesecondstimetoliveseconds 옵션이다. timetoidleseconds 옵션에는 second 단위의 시간을 기록하도록 되어 있는데 이 옵션에 기록된 시간동안 캐시에 있는 데이터가 이용되지 않을 경우 캐시에 있는 데이터가 삭제된다. 이 값을 0으로 할 경우엔 삭제하지 않는다. timetoliveseconds 옵션에는 second 단위의 시간을 기록하도록 되어 있는데 이 옵션에 기록된 시간동안 캐시에 데이터가 존재하며 그 시간이 지나면 캐시에서 데이터는 삭제된다. 이 값을 0으로 할 경우엔 삭제하지 않는다. 이 두가지 옵션을 모두 0으로 한 것은 캐시에서 데이터를 삭제하는 주체가 ehcache가 아닌 어플리케이션에서 하기 때문에 이 두가지 옵션을 모두 0으로 했다.

 

ehcache에서 데이터를 가져오고 저장할때 어떤 방법으로 할까? ehcache은 내부적으로 Key, Value 형태의 Map 구조로 데이터를 관리한다. 여기서 말하는 key는 캐시 이름(위의 xml 설정으로 보자면 selectCache)를 말하는 것이 아니다. Spring에서의 ehcache를 사용하는 일반적인 방법에서 볼때는 @Cache 어노테이션에 cacheName 값을 주면 알아서 동작한다(@Cache(cacheName="selectCache")). 하지만 어떻게 하여 이렇게 동작할까? 완벽한 분석은 아니지만 ehcache의 API 함수를 보면 ehcache는 Map<String, Element> 구조 형태의 Collection 객체에 값을 저장하여 관리한다. 즉 @Cache(cacheName="selectCache")에서 하는 기능은 ehcache에서 selectCache라는 이름을 가진 Map<String, Element>를 구현한 Collection 객체에 데이터를 저장하는 것이다. Map에서 사용되는 key는 ehcache에서 자체적으로 만들며 Element 클래스는 ehcache에서 정의된 클래스로 여기에는 사용자 데이터뿐만 아니라 이 데이터가 Map에 들어간 시간 등의 정보성 데이터도 같이 저장된다. 정리하자면 ehcache는 ehcache.xml에 설정한 캐시를 찾아 이를 Map<String, Element> 인터페이스를 구현한 객체에 사용자 데이터를 넣는다

 

이러한 내용을 먼저 알고 이제부터 위에서 만든 인터페이스를 구현한 소스를 보면 이해하기 쉬우리라 생각한다.

 

@Service
public class CacheServiceImpl implements CacheService {

	private final Log logger = LogFactory.getLog(this.getClass());
	
	@Autowired
	CacheManager cachemanager;
	
	@Override
	public Object getCache(Object obj, String methodname, String cachekey, long interval, Object... objparam) throws Exception{
		// TODO Auto-generated method stub
		Object result = null;
		Cache cache = cachemanager.getCache("selectCache");
		StringBuffer sbCachekey = new StringBuffer(cachekey);
		
		// 고유한 캐쉬키를 만들기 위해 그 당시 사용했던 파라미터값을 같이 조합하여 캐쉬키를 유니크하게 만든다(파라미터 값을 -(하이픈)으로 연결하여 캐쉬키를 만든다)
		if((objparam != null) && (objparam.length > 0)){
			for(Object param : objparam){
				sbCachekey.append("-");
				sbCachekey.append(param);
			}
		}
		
		String useCachekey = sbCachekey.toString();
		logger.debug("사용 캐쉬키  : " + useCachekey);
		
		Element element = cache.get(useCachekey);
		
		if(element == null){
			logger.debug("캐쉬에 없어서 새로 넣는다");
			logger.debug("생성때 currentTimeMillis : " + System.currentTimeMillis());
			result = getData(obj, methodname, objparam);
			cache.put(new Element(useCachekey, result));
		}else{
			logger.debug("캐쉬꺼 꺼내오기");
			logger.debug("getCreationTime : " + element.getCreationTime());
			logger.debug("검색때 currentTimeMillis : " + System.currentTimeMillis());
			
			if(interval == 0){				// interval이 0일때는 갱신간격 체크를 하지 말고 바로 캐쉬에서 가져와서 리턴한다
				result = element.getObjectValue();
			}else if(interval == -1){		// interval이 -1일때는 무조건 조회한뒤에 캐쉬에 넣고 리턴
				try{
					result = getData(obj, methodname, objparam);
					cache.put(new Element(useCachekey, result));
				}catch(Exception e){
					result = element.getObjectValue();
				}
			}else{							// interval이 0이 아닐때는 갱신간격 체크를 해서 DB에서 조회해서 캐시에 넣거나 또는 바로 캐쉬에서 가져와서 리턴한다
				long timeoutgap = System.currentTimeMillis() - element.getCreationTime();
				logger.debug("timeoutgap : " + timeoutgap);
				if(timeoutgap >= interval){
					try{
						result = getData(obj, methodname, objparam);
						cache.put(new Element(useCachekey, result));
					}catch(Exception e){
						result = element.getObjectValue();
					}
				}else{
					result = element.getObjectValue();
				}
			}
			
		}
		
		return result;
	}
	
	@Override
	public Object getCache(String cachekey, Object... objparam)
			throws Exception {
		// TODO Auto-generated method stub
		Object result = null;
		Cache cache = cachemanager.getCache("selectCache");
		StringBuffer sbCachekey = new StringBuffer(cachekey);
		
		// 고유한 캐쉬키를 만들기 위해 그 당시 사용했던 파라미터값을 같이 조합하여 캐쉬키를 유니크하게 만든다(파라미터 값을 -(하이픈)으로 연결하여 캐쉬키를 만든다)
		if((objparam != null) && (objparam.length > 0)){
			for(Object param : objparam){
				sbCachekey.append("-");
				sbCachekey.append(param);
			}
		}
		
		String useCachekey = sbCachekey.toString();
		
		Element element = cache.get(useCachekey);
		
		// 캐쉬에 저장되어 있을 경우
		if(element != null){
			result = element.getObjectValue();
		}
		
		return result;
		
	}



	private Object getData(Object obj, String methodname, Object... objparam) throws Exception{
		
		Class clazz = obj.getClass();
		Class [] args = new Class[objparam.length];
		for(int i=0; i < args.length; i++){
			
			logger.debug("Class name : " + objparam[i].getClass().getName());
			
			/*
			 * Java Reflection에서 method에 파라미터 사용시 해당 파라미터 타입이 int(primitive 타입)를 사용하든 Integer를 사용하든 똑같이 Integer 클래스로 인식한다
			 * 때문에 캐쉬를 사용하는 함수의 파라미터는 primitive 함수로 설계해야 한다
			 */
			if(objparam[i] instanceof String){
				args[i] = String.class;
			}else if(objparam[i] instanceof Integer){
				args[i] = int.class;
			}else if(objparam[i] instanceof Long){
				args[i] = long.class;
			}else if(objparam[i] instanceof Float){
				args[i] = float.class;
			}else if(objparam[i] instanceof Double){
				args[i] = double.class;
			}else if(objparam[i] instanceof Boolean){
				args[i] = boolean.class;
			}else if(objparam[i] instanceof Byte){
				args[i] = byte.class;
			}else if(objparam[i] instanceof Character){
				args[i] = char.class;
			}else{
				args[i] = objparam[i].getClass();
			}
			
			
		}
		
		Method method = clazz.getMethod(methodname, args);
		Object result = method.invoke(obj, objparam);
		return result;
	}

	@Override
	public long getCacheSize() throws Exception {
		// TODO Auto-generated method stub
		// 캐시 크기가 이상하다고 느껴질경우엔 캐시에 저장되는 클래스들이 Serializable 인터페이스를 구현했는지 확인한다
		Cache cache = cachemanager.getCache("selectCache");
		return cache.calculateInMemorySize();
	}
	
}

 

소스에 주석을 달아서 내용을 조금 훑어보면 이해되겠지만 그래도 좀더 설명하고자 한다. 캐시에 대한 작업을 진행해야 하기 때문에 Spring에서 ehcache에 대한 전반적인 작업을 진행하는 CacheManager 객체를 가져와야 한다. spring 설정파일에서 ehcache 관련 설정을 할때 다음의 bean을 설정한 내용이 있을 것이다

 

<bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean" > <property name="configLocation" value="classpath:com/terry/sample/resource/ehcache/ehcache.xml" /> </bean>

 

이 설정 내용으로 인해 Spring에서는 CacheManager 객체가 생성이 되며 이렇게 생성이 된 CacheManager 객체를 다음과 같이 하여 소스에서 이용할 수 있게 된다

 

@Autowired
CacheManager cachemanager;

 

위의 소스에는 다음의 private 함수가 있다

 

private Object getData(Object obj, String methodname, Object... objparam) throws Exception

 

이 함수가 하는 역할은 사용자가 넘긴 서비스 객체와 함수명과 함수를 실행할때 사용되는 파라미터 값들을 받아 java의 Reflection을 이용하여 이를 대신 실행하는 역할을 한다. 

 

캐시에 있는 데이터를 가져오는 함수로 같은 이름이지만 파라미터 구성이 다른 2개의 함수가 있다

 

public Object getCache(Object obj, String methodname, String cachekey, long interval, Object... objparam) throws Exception
public Object getCache(String cachekey, Object... objparam) throws Exception

 

이 2개의 함수 모두 캐시에 있는 데이터를 가져오는 기능에는 동일하다. 그러나 위의 것은 사용자의 함수를 대신 실행하여 그에 따른 결과를 가지고 캐시를 갱신하는 기능이 포함되어 있으나 아래의 함수는 단순히 캐시 데이터를 조회하기 위한 캐시 key만을 받아 캐시를 조회하고 캐시를 갱신하는 기능은 없다

 

첫번째 함수든 두번째 함수든 공통적으로 들어가 있는 것이 있는데 그것은 ehcache.xml에서 정의한 cache들중 어떤것을 사용할지를 가져오는 부분이다. 그러한 작업을 하는 코드가 다음의 코드이다.

 

Cache cache = cachemanager.getCache("selectCache");

 

이렇게 하면 위에 예로 들었던 ehcache.xml에서 이름이 selectCache인 cache 모델을 가져올수가 있다. 그럼 이제 여기에 캐시 데이터를 넣거나 조회하는 부분에 대해 설명하겠다. Cache 객체에 우리가 다루는 데이터가 직접 조회되는 것은 아니다. ehcache는 내부적으로 Element라고 하는 객체에 사용자 데이터를 한번 wrapping을 한다. 이 객체에는 캐시에 데이터가 들어간 시간등의 정보성 데이터도 같이 저장이 된다. 그래서 캐시에 데이터를 넣을때는 Element 객체로 wrapping 하여 넣고 캐시에서 꺼낼때도 캐시 데이터를 넣을때 사용한 캐시키를 주어 Element 객체를 꺼낸뒤 이 객체에서 다시 사용자 데이터를 꺼내야 한다. 이 내용을 알고 코드를 보면 이해가 쉽다. 그래서 캐시에 데이터를 넣을때는 다음과 같이 한다

 

cache.put(new Element(useCachekey, result));

 

여기서 result는 사용자가 캐시 데이터를 얻기 위해 캐시 서비스에 보낸 서비스 객체와 함수명과 파라미터를 받아 이를 대신 실행하는 getData 함수가 얻은 조회 결과이다. useCachekey는 result를 캐시에 저장할때 사용하는 캐시키로써 캐시키를 구성할땐 시스템에서 프로퍼티로 정의한 캐시키와 함수명과 함수를 실행할때 필요한 파라미터 값들의 조합으로 만들어 조회된 데이터가 중복되지 않도록 캐시키를 만들었다. 단순히 시스템에서 정의한 프로퍼티로 캐시키를 사용할 수 없었던 것은 데이터의 성격 때문이었다. 내가 이 코드를 만들때의 프로젝트의 데이터는 같은 성격의 데이터지만 조회에 사용하는 파라미터에 따라 조회결과가 다르고 이것들을 각각 캐시에 저장해야 했기 때문에 시스템에 정의한 프로퍼티로 캐시키를 사용할 경우 기존에 저장된 캐시 데이터를 덮어씌우게 되기 때문에 데이터 유지가 되질 않는다. 그래서 캐시키를 만들때 캐시 데이터가 덮어 씌워지지 않게 하기 위해 함수명과 파라미터까지 같이 조합하여 만든것이다.

 

캐시에 저장된 데이터를 가져올때도 캐시에서 Element 객체를 꺼내온 뒤에 이 Element 객체에서 다시 실제 저장된 데이터를 꺼내야한다(Element 객체는 위에서 잠깐 얘기 했듯이 사용자 데이터를 wrapping 한 객체이다) 그래서 다음과 같이 꺼내온다. 조회된 결과는 Object 객체이기 때문에 나중에 이것을 다시 형변환을 해줘야 원하는 결과를 가져올수 있다

 

Element element = cache.get(useCachekey);
result = element.getObjectValue();

 

Cache 객체의 get 함수에 파라미터로 꺼내올 캐시 데이터의 캐시키를 넣어주면 이 데이터가 들어있는 Element 객체가 리턴이 되며 Element 객체의 getObjectValye 함수를 실행하여 Object 클래스 객체인 실제 캐시 데이터를 가져올수 있게 된다

 

캐시 데이터 갱신시간 판단여부는 캐시 데이터가 만들어진 시간과 현재 시간을 비교하여 그 시간을 가지고 비교하게 된다. 위에서 언급했듯이 Element 객체엔 캐시되는 데이터와 함께 캐시 생성 시간등의 정보성 데이터가 같이 들어가게 된다. 이런 Element 객체에서 캐시 생성 시간을 꺼내온뒤 이를 현재 시간에서 빼는 작업을 거쳐 그 차이를 가지고 갱신시간을 경과했는지를 판단한다. 관련 코드는 다음과 같다

 

long timeoutgap = System.currentTimeMillis() - element.getCreationTime();

 

Element 객체의 getCreationTime 함수를 실행하면 캐시가 저장되는 시점의 System.currentTimeMillis() 값을 리턴하게 된다. 이것을 System.currentTimeMillis()에서 빼면 그 차이값이 나오는데 이것을 getCache 함수에서 파라미터로 받은 interval 값과 비교하게 되는 것이다. 만약 interval 값이 timeoutgap보다 크면 아직은 캐시를 갱신할 시간이 아닌것이고 반대로 interval 값이 timeoutgap 보다 작으면 갱신주기를 지났기 때문에 다시 데이터를 조회하여 캐시에 데이터를 넣어야 하는 것이다.

 

나머지 코드들은 내가 수행했던 업무 로직이나 로그성 코드들이라 중요한것은 아니다. 다만 마지막으로 하나 설명할 것이 캐시 데이터가 얼마나 저장되었는지 관리툴 같은데서 확인할 경우가 발생할수 있다. 그럴 경우 getCacheSize 함수를 참조하면 된다. 이것을 만들때 주의해야 할것은 캐시되는 데이터의 클래스가 Serializable 인터페이스를 implement 해야 한다는 것이다. 그리고 캐시의 크기를 구하는 함수는 시스템에 영향을 줄 수도 있고 또 정확하게 구해지지 않는점도 먼저 알고 진행했으면 한다(ehcache API 문서에서 보면 캐시 크기를 구하는 부분에 있어서는 비용이 많이 드는 작업이라고 설명되어 있고 좋지는 않은 내용으로 설명되어 있다. API 문서를 한번 봤음 한다)

 

다음에는 이렇게 만든 코드를 실제로 사용하는 방법이 기술된 코드의 설명과 개선점을 얘기해보도록 하겠다