상세 컨텐츠

본문 제목

Spring에서 memcached 사용하기

Spring

by husks 2015. 10. 21. 17:42

본문

반응형

memcached는

메모리 기반의 분산 캐시 서버이다. 방대한 양의 데이터를 조회하는 포털과 같은 웹어플리케이션의 경우 일반적으로 캐쉬를 사용하여 성능의 향상을 도모하게 된다. 보통 캐시를 적용할 때 고려하는 사항 중 하나는 어플리케이션과 동일한 JVM에 캐시 데이터를 저장할 것인가와 분산 캐시 서버를 두고 별도의 메모리에 캐시 데이터를 저장할 것인가이다. JVM에 캐시 데이터를 유지하는 경우 어플리케이션에서 캐시 데이터를 사용하는데 있어서 상당히 빠른 반면, 분산 캐시 서버를 통해 캐시 데이터를 사용하게 되면 객체를 직렬화하는 비용과 네트워크 통신 비용이 발생한다. (즉, memcached에 저장하는 캐시 데이터-객체-는 Serializable한 객체여야 한다.) 반면 분산 캐시 서버를 두면 어플리케이션 서버와 캐시 서버를 물리적 또는 논리적으로 분리할 수 있다.


memcached 사용 방법
memcached의 사용 방법은 매우 간단하다. 여기서는 Spring 에서 memcached를 사용하는 방법에 대해서만 다룰 것이기 때문에 memcached의 사용 방법을 언급하지는 않는다. memcached의 사용 방법을 알고 싶으면 아래의 링크를 참조할 것을 권장한다.


Spring DI를 사용한 memcached 클라이언트 등록
memcached 클라이언트를 사용하기 위해서는 SockIOPool을 초기화해야 한다. memcached client for Java가 제공하는 SockIOPool은 싱글턴 인스턴스를 구하는 getInstance() 메소드를 제공하고 있다. memcached 클라이언트인 MemCachedClient 클래스는 내부적으로 SockIOPool의 싱글턴 인스턴스를 얻어 이를 통해 memcached 서버와 통신하도록 되어 있다.
MemCachedClient가 SockIOPool의 인스턴스를 생성자의 파라미터 또는 setter 메소드를 통해 얻지 않고 내부적으로 싱글턴 인스턴스를 얻어 사용하기 때문에 MemCachedClient를 사용하기 전에 SockIOPool은 반드시 초기화되어 있어야 한다. (이러한 이유로 spring에 bean 등록시 객체의 의존관계가 드러나지 않기 때문에 개인적으로 맘에 안드는 부분이기도 하다.) 

이제 Spring이 관리하는 bean에 MemCachedClient와 SockIOPool을 등록하도록 하자. memcached client for Java의 jar 파일을 클래스패스에 추가한 뒤, Spring 설정 파일에 아래와 같은 내용을 추가하면 된다.
<bean id="memCachedClient"
        class="com.danga.MemCached.MemCachedClient"
        depends-on="sockIOPool">
        <property name="compressEnable" value="true" />
        <property name="compressThreshold" value="65536" /><!-- 64K -->
</bean>
<bean id="sockIOPool" class="com.danga.MemCached.SockIOPool"
        factory-method="getInstance" init-method="initialize"
        destroy-method="shutDown">
        <property name="servers">
            <list>
                <value>localhost:11211</value>
            </list>
        </property>
        <property name="weights">
            <list>
                <value>1</value>
            </list>
        </property>
        <property name="initConn" value="5" />
        <property name="minConn" value="5" />
        <property name="maxConn" value="10" />
        <property name="maxIdle" value="21600000" /><!-- 6 hours: 1000 * 60 * 60 * 6 -->
        <property name="maintSleep" value="30" />
        <property name="nagle" value="false" />
        <property name="socketTO" value="3000" />
        <property name="socketConnectTO" value="0" />
</bean>
위 내용은 memcached client for Java의 예제 코드 설정 내용을 그대로 적용한 것이다. 각 항목에 대한 자세한 내용은 memcached client for Java를 참조하면 된다. SockIOPool은 싱글턴 인스턴스를 얻기 위한 factory 메소드인 getInstance(), 초기화 메소드인 initialize(), 리소스 해제를 위한 메소드인 shutDown()을 제공하고 있으므로 Spring bean으로 등록할 때 이 내용을 적어주도록 하자. 
그리고 명시적으로 depends-on 속성값에 SockIOPool 빈을 지정하여 SockIOPool이 초기화된 이후에 MemCachedClient 객체를 생성하도록 하자. 이렇게 함으로써 명시적으로 객체의 의존 관계를 표현할 수 있다.

이제 memCachedClient 라는 이름으로 스프링에 MemCachedClient가 등록되었다. 이를 사용하여 캐시 서버를 이용할 수 있다.


Spring AOP를 사용한 memcached 클라이언트 사용
memcached 클라이언트를 등록했으면 이를 통해 캐시 기능을 이용할 수 있게 된다. 하지만 캐시를 사용함에 있어서 일일이 코드에 캐시가 있는지를 검사하고 없으면 DB에서 데이터를 조회하고 캐시에 넣고 하는 일련의 작업을 구현해야 한다면 이는 매우 번거로운 작업이 될 것이다. 따라서 이러한 귀찮은 작업들을 피하기 위해 Spring AOP를 사용해 캐시를 적용해보도록 하자.

AOP를 적용하기 전에 먼저 알아두어야 할 것은 Spring AOP는 내부적으로 Proxy를 사용해 AOP를 구현하고 있으므로 메소드 단위로만 AOP를 적용할 수 있다는 점이다. 따라서 Spring AOP를 사용해 캐시를 적용할 수 있는 대상은 메소드 호출시 리턴되는 객체로 한정되게 된다. (사실 이것만으로도 충분하다.)

그럼 첫번째로 Aspect를 작성하자. 작성할 Aspect는 메소드 호출 시 캐시에 데이터가 있으면 캐시 데이터를 리턴하고 없으면 메소드가 수행되고 리턴하는 객체를 캐시에 저장한 후 이를 리턴하는 Aspect이다. 자질구레한 설명따윈 필요없다. 바로 코드를 보자.
package justfun.spring.aspect;

import java.util.Date;
import java.util.Map;

import org.apache.log4j.Logger;
import org.aspectj.lang.ProceedingJoinPoint;

import com.danga.MemCached.MemCachedClient;

public class MemCached {
    private static Logger LOG = Logger.getLogger(MemCached.class);

    private MemCachedClient mcc;

    public void setMemCachedClient(MemCachedClient mcc) {
        this.mcc = mcc;
    }

    public Object methodCache(ProceedingJoinPoint joinPoint) throws Throwable {
        //printStats();
        
        LOG.debug("-----------------------------------------------");

        StringBuffer key = new StringBuffer();
        key.append(joinPoint.getTarget().getClass().getName());
        key.append("$").append(joinPoint.toShortString());
        key.append("[");
        Object[] args = joinPoint.getArgs();
        if (args != null) {
            for (Object arg : args) {
                if (arg != null)
                    key.append(arg);
            }
        }
        key.append("]");
        LOG.debug(key.toString());

        Object o = null;
        if (mcc.keyExists(key.toString())) {
            Date s = new Date();
            o = mcc.get(key.toString());
            Date e = new Date();
            long ret = e.getTime() - s.getTime();
            LOG.debug("cache hit [" + ret + "ms]");
        }
        else {
            Date s = new Date();
            o = joinPoint.proceed();
            if (o != null)
                mcc.add(key.toString(), o);
            Date e = new Date();
            long ret = e.getTime() - s.getTime();
            LOG.debug("method execution [" + ret + "ms]");
        }

        LOG.debug("-----------------------------------------------");
        return o;
    }

    @SuppressWarnings("unchecked")
    public void printStats() {
        Map stats = mcc.stats();
        System.out.println("[STATS]");
        System.out.println(stats);
        System.out.println();
    }
}

Asepct 내용은 매우 단순하다. 먼저 메소드 호출에 대한 Unique한 키를 생성하고 캐시에서 데이터를 찾은 후 있으면 그것을 리턴한다. 캐시에서 데이터가 없으면 target 메소드를 실행(joinPoint.proceed())한 후 결과를 캐시에 저장하고 리턴한다.

Asepct를 작성했으니 이제 Aspect를 적용해보자. 작성한 Aspect를 적용하기 위해서 Spring 설정 파일에 아래와 같은 내용을 추가하면 된다.
<bean id="memcached" class="justfun.spring.aspect.MemCached">
        <property name="memCachedClient" ref="memCachedClient" />
</bean>
<aop:config>
        <aop:aspect id="memcachedAspect" ref="memcached">
            <aop:pointcut id="service"
                expression="execution(* justfun.spring.domain.service..*.*(..))" />
            <aop:around pointcut-ref="service" method="methodCache" />
        </aop:aspect>
</aop:config>
위 설정은 memcached Aspect를 justfun.spring.domain.service 패키지에 존재하는 모든 클래스의 모든 메소드에 적용하는 설정이다. AOP 표현식에 대한 내용은 Spring AOP의 내용을 참조하면 된다.

※ 참고: aop 네임스페이스를 사용하기 위해서는 beans 엘리먼트에 다음의 내용을 추가해야 한다.
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans-2.0.xsd  
        http://www.springframework.org/schema/aop
        http://www.springframework.org/schema/aop/spring-aop-2.0.xsd">

이제 모든 서비스(justfun.spring.domain.service.*) 메소드를 호출시 캐시 데이터를 사용할 수 있게 되었다. 이렇게 Spring AOP를 사용함으로써 모든 서비스 클래스에 대해서 메소드 구현 시 일일이 캐시 사용 코드를 작성하지 않아도 된다.

하지만 이렇게 메소드 호출에 대해서 캐시를 적용하게 되면 캐시 업데이트 정책을 어떻게 수립하고 적용할 것인가가 큰 이슈가 된다. 바꿔 말하면 데이터의 추가/수정/삭제 등으로 데이터에 변동이 발생하게 되면 캐시도 refresh되어야 하는데 위와 같은 방법으로는 이를 반영하지 못한다. 따라서 데이터의 변동이 발생할 때마다 캐시를 업데이트 하는 코드를 별도로 작성해야 하는 문제점이 있다.


iBatis의 CacheController를 사용한 memcached 클라이언트 사용
Spring AOP를 사용하여 캐시를 메소드 단위로 적용할 때 발생하는 문제점인 캐시 업데이트 문제를 해결하기 위해서 iBatis의 CacheController를 사용할 수 있다. iBatis는 자체적으로 Cache Model을 지원하고 있으며 간단한 sqlmap 설정을 통해 캐시가 업데이트 되는 시점을 결정할 수 있다. 

iBatis의 캐시 모델에 memcached를 사용하는 CacheController를 적용해보도록 하자. memcached를 사용하는 캐시 컨트롤러를 구현하기 위해서 iBatis의 CacheController를 구현(implements)해야 한다.
package justfun.ibatis;

import java.util.Properties;

import org.apache.log4j.Logger;

import com.danga.MemCached.MemCachedClient;
import com.ibatis.sqlmap.engine.cache.CacheController;
import com.ibatis.sqlmap.engine.cache.CacheModel;

public class MemCachedController implements CacheController {
    private static Logger LOG = Logger.getLogger(MemCachedController.class);
    
    private MemCachedClient mcc;

    public MemCachedController() {
        mcc = new MemCachedClient();
    }

    @Override
    public void flush(CacheModel cacheModel) {
        LOG.debug("flush");
        mcc.flushAll();
    }

    @Override
    public Object getObject(CacheModel cacheModel, Object key) {
        Object o = mcc.get(key.toString());
        LOG.debug("getObject: " + key);
        LOG.debug("\t" + o);
        return o;
    }

    @Override
    public void putObject(CacheModel cacheModel, Object key, Object object) {
        LOG.debug("putObject: " + key);
        mcc.add(key.toString(), object);
    }

    @Override
    public Object removeObject(CacheModel cacheModel, Object key) {
        LOG.debug("removeObject: " + key);
        Object o = mcc.get(key.toString());
        mcc.delete(key.toString());
        return o;
    }

    @Override
    public void setProperties(Properties props) {

    }

}
iBatis의 CacheController 인터페이스는 5개의 메소드를 구현하도록 되어 있으며 이 메소드들을 통해 memcached를 사용하도록 MemCachedController를 구현하였다.

이제 구현한 캐시 컨트롤러를 사용해 iBatis에서 캐시 모델을 적용하기만 하면 된다.
sqlmap 설정 파일에 다음의 내용을 추가하자.
<cacheModel id="memcached-cache" type="justfun.ibatis.MemCachedController">
        <flushInterval hours="24"/>
        <flushOnExecute statement="insert"/>
        <flushOnExecute statement="update"/>
        <flushOnExecute statement="delete"/>
</cacheModel>
이제 iBatis에서 제공하는 flush 옵션들을 통해 캐시를 쉽게 업데이트 할 수 있게 되었다. 위 설정에서는 insert, update, delete 라는 id로 정의된 statememt가 수행될 때 캐시의 flush를 수행하도록 되어 있다. 언제 flush를 할 것인가는 각자의 요구사항에 맞게 설정하면 된다.

※ 이 예제에서는 memcached의 모든 항목을 flush 하도록 캐시 컨트롤러를 구현했지만 이는 비효율적일 것이므로 특정 캐시 데이터만 flush 하도록 수정하여 사용할 것을 권장한다. (차후에 특정 캐시 데이터만 flush 하도록 캐시 컨트롤러 코드를 업데이트 할 예정이다.)



* depends-on 속성을 사용한 객체 의존성 추가 (2008.11.08)


캐시 만료(Expire)를 고려한 MemCachedAspect 작성하기
앞서 memcached를 사용하여 메소드 호출 시 캐시를 사용하도록 하는 Aspect를 작성했었다. 이전에 구현한 Aspect는 데이터에 변동이 있을 때 캐시를 지우지 못하는 문제가 있었는데, 이러한 이유(캐시 만료)로 iBatis의 CacheController를 구현한 클래스를 작성했으나 이 클래스는 iBatis에 의존적이므로 iBatis를 사용하지 않는 어플리케이션에서는 적용할 수 없다. 따라서 여기서는 캐시의 만료 정책을 고려한 보다 향상된 Aspect를 작성하고자 한다. 
(구현 과정에서 클래스 및 몇몇 메소드 명이 변경되었으니 이 점 유념하시길....)

기본적인 캐시 만료 정책은 매우 간단하다. 자바의 정규식으로 표현된 특정 패턴의 캐시 key와 그 키에 해당될 때 만료시켜야 할 캐시 key의 패턴을 정의하여 이를 이용해 캐시 데이터를 만료시키는 것이다. 이를 위해서는 자바 정규식을 입력받아 관리할 수 있어야 하며, 캐시에 저장된 key들의 목록을 관리할 수 있어야 한다.

여기서도 자질구레한 설명은 필요 없다. 바로 코드를 보자.

package justfun.spring.aspect;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.log4j.Logger;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;

import com.danga.MemCached.MemCachedClient;

/**
 * 
 * @author jhcho
 * 
 */
public class MemCachedAspect {
    private static Logger LOG = Logger.getLogger(MemCachedAspect.class);

    private MemCachedClient mcc;
    private ExpireCond[] expireCond;
    private CacheKeyPool keyPool;

    public MemCachedAspect() {
        keyPool = CacheKeyPool.getInstance();
    }

    public void setMemCachedClient(MemCachedClient mcc) {
        this.mcc = mcc;
    }

    public void setExpireCond(ExpireCond[] expireCond) {
        this.expireCond = expireCond;
    }

    @SuppressWarnings("unchecked")
    private String generateKey(JoinPoint joinPoint) {
        Class targetClass = joinPoint.getTarget().getClass();
        Signature targetMethod = joinPoint.getSignature();
        Object[] args = joinPoint.getArgs();

        StringBuffer key = new StringBuffer();
        key.append(targetClass.getName());
        key.append(".").append(targetMethod.getName());
        key.append("(");
        if (args != null) {
            int argCount = args.length;
            for (Object arg : args) {
                if (arg != null)
                    key.append(arg);
                else
                    key.append("null");
                if (arg != args[argCount - 1])
                    key.append(",");
            }
        }
        key.append(")");

        return key.toString();
    }

    private void doFlush(String key) {
        if (expireCond == null)
            return;

        LOG.debug("CHECK EXPIRE ");

        Pattern targetPattern, flushPattern;
        Matcher targetMatcher, flushMatcher;
        String[] keys = keyPool.list();
        List<String> flushKeyList = new ArrayList<String>();

        for (ExpireCond cond : expireCond) {
            targetPattern = Pattern.compile(cond.getTargetKey());
            flushPattern = Pattern.compile(cond.getExpireKey());

            targetMatcher = targetPattern.matcher(key); // for target key searching
        
            if (targetMatcher.find()) {
                for (String cacheKey : keys) {
                    flushMatcher = flushPattern.matcher(cacheKey); // for flush key searching
                    if (flushMatcher.find()) {
                        flushKeyList.add(cacheKey);
                    }
                }
            }
        }
        
        if (flushKeyList.size() > 0) {
            for (String flushKey : flushKeyList) {
                LOG.debug("DO EXPIRE");
                if (mcc.delete(flushKey)) {
                    keyPool.remove(flushKey);
                    LOG.debug("\tKey: " + flushKey + " [SUCCESS]");
                }
                else {
                    LOG.debug("\tKey: " + flushKey + " [FAIL]");
                }
            }
        }
    }

    /**
     * Aspect method. invoke and cache a result object of target method.
     * 
     * @param joinPoint
     * @return a result of target method
     * @throws Throwable
     */
    @SuppressWarnings("unchecked")
    public Object invoke(ProceedingJoinPoint joinPoint) throws Throwable {
        String key = generateKey(joinPoint);

        // flush some caches
        doFlush(key);

        // printStats();
        LOG.debug("-----------------------------------------------");
        LOG.debug("key: " + key);

        Object o = null;
        if (mcc.keyExists(key)) {
            if (!keyPool.contains(key))
                keyPool.add(key);
            Date s = new Date();
            o = mcc.get(key);
            Date e = new Date();
            long ret = e.getTime() - s.getTime();
            LOG.debug("cache hit [" + ret + "ms]");
        }
        else {
            Date s = new Date();
            o = joinPoint.proceed();
            if (o != null) {
                if (mcc.add(key, o))
                    keyPool.add(key);
            }
            Date e = new Date();
            long ret = e.getTime() - s.getTime();
            LOG.debug("method execution [" + ret + "ms]");
        }

        LOG.debug("-----------------------------------------------");
        return o;
    }

    @SuppressWarnings("unchecked")
    public void printStats() {
        Map stats = mcc.stats();
        System.out.println("[STATS]");
        System.out.println(stats);
        System.out.println();
    }
}

※ 클래스명을 MemCached에서 MemCachedAspect로 변경하였고, 메소드 명을 methodCache에서 invoke로 변경하였다.

위 코드에서 눈여겨 볼 부분은 invoke 시 먼저 doFlush 메소드를 호출하여 캐시 만료 조건을 검사하도록 하는 것이다. doFlush는 캐시 만료 조건인 ExpireCond[]를 순차적으로 탐색하며 현재 메소드에 해당하는 키와 일치하는지 검사한 후 이와 일치하면 현재 캐시에 저장되어 있는 Key의 목록을 순차적으로 탐색하며 만료시킬 대상을 찾는다. 
만약 만료 대상이 존재하면 해당 캐시를 지운다. 
이 때 정규표현식은 자바의 정규표현식을 따르며 패턴 매칭은 검사는 java.util.regex.Pattern과 java.util.regex.Matcher를 사용하였다. 그리고 패턴을 검사할 때 find() 메소드를 사용하기 때문에 정확하게 일치하는 패턴을 작성하지 않아도 된다. 


MemCachedAspect는 ExpireCond[] 와 CacheKeyPool 을 갖는다. ExpireCond는 캐시 만료 조건(패턴)을 저장하고 있는 빈이고 CacheKeyPool은 현재 캐시에 저장된 데이터들의 캐시 Key 목록을 관리하기 위한 클래스이다.

☞ ExpireCond.java

package justfun.spring.aspect;

/**
 * Expire condition.
 * 
 * @author jhcho
 * 
 */
public class ExpireCond {

    private String targetKey;
    private String flushKey;

    public String getTargetKey() {
        return targetKey;
    }

    public void setTargetKey(String targetKey) {
        this.targetKey = targetKey;
    }

    public String getExpireKey() {
        return expireKey;
    }

    public void setExpireKey(String expireKey) {
        this.expireKey = expireKey;
    }

}

☞ CacheKeyPool.java
package justfun.spring.aspect;

import java.util.List;
import java.util.Vector;

public class CacheKeyPool {
    private List<String> pool;

    private CacheKeyPool() {
        pool = new Vector<String>();
    }

    public static CacheKeyPool getInstance() {
        return Holder.instance;
    }

    public boolean contains(String key) {
        return pool.contains(key);
    }

    public void add(String key) {
        pool.add(key);
    }

    public void remove(String key) {
        pool.remove(key);
    }

    public void remove(String[] keys) {
        for (String key : keys) {
            remove(key);
        }
    }

    public String[] list() {
        return pool.toArray(new String[0]);
    }

    private static class Holder {
        static final CacheKeyPool instance = new CacheKeyPool();
    }
}



MemCachedAspect 사용하기 (Spring AOP 설정)
이제 새로 작성한 MemCachedAspect를 사용하여 캐시 만료 정책을 추가해보자. 이전 글과 같이 Spring AOP를 사용할 것이고, memCachedClient와 sockIOPool은 이미 설정되어 있다고 가정한다.

새로운 Aspect를 적용하기 위해 다음과 같이 설정한다.
<bean id="memcached" class="justfun.spring.aspect.MemCachedAspect">
        <property name="memCachedClient" ref="memCachedClient" />
        <property name="expireCond">
            <list>
                <bean class="justfun.spring.aspect.ExpireCond">
                    <!-- if "write", "modify", "delete" method invoked, delete cache data of "list" method -->
                    <property name="targetKey"
                        value="MemoServiceImpl\.(write|modify|delete)" />
                    <property name="expireKey"
                        value="MemoServiceImpl\.(list)" />
                </bean>
            </list>
        </property>
    </bean>

위 설정 내용은 key에 "MemoServiceImpl.write" 또는 "MemoServiceImpl.modify" 또는 "MemoServiceImpl.delete" 문자열이 포함된 경우 "MemoServiceImpl.list" 문자열이 포함된 모든 캐시 key에 해당되는 캐시 데이터들을 만료시키는 것이다. 위 설정이 의도하는 것은 MemoServiceImpl의 write, modify, delete 메소드가 수행될 때, MemoServiceImpl의 list 메소드에 대한 캐시 데이터를 만료시키는 것이다.
(MemCachedAspect에서 캐시 키는 {클래스명}.{메소드명}({파라미터정보}) 와 같이 생성된다. 예를 들어justfun.spring.domain.service.MemoServiceImpl.list() 와 같은 형식으로 키를 생성한다.)

여기서 사용하는 정규표현식에 대한 자세한 내용은 자바의 정규표현식과 관련된 내용을 참조하면 된다.
-> 자바 정규식 패턴: http://java.sun.com/javase/6/docs/api/java/util/regex/Pattern.html


※ Aspect의 메소드 이름이 변경되었으므로 AOP 설정파일도 변경된 메소드 명으로 바꿔주도록 하자.
<aop:pointcut id="service"
                expression="execution(* net.daum.spring.domain.service..*.*(..))" />
<aop:around pointcut-ref="service" method="invoke" />

출처: http://tinywolf.tistory.com/80, http://tinywolf.tistory.com/81

반응형

관련글 더보기

댓글 영역