저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

스프링은 캐시 구현으로 레디스를 지원한다. 스프링 부트를 사용하면 다음의 간단한 설정만 추가하면 된다.


<dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-data-redis</artifactId>

</dependency>

<dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-cache</artifactId>

</dependency>


이 설정을 추가하면 @EnableCaching 만으로 쉽게 @Cacheable과 관련 애노테이션을 사용해서 레디스를 캐시로 사용할 수 있다.

그런데, 스프링 부트가 제공하는 설정만으로 레디스를 캐시로 사용하면 캐시별로 유효 시간을 줄 수 없다. Ehcache를 캐시로 사용하면 설정 파일을 이용해서 유효 시간을 줄 수 있는데 레디스의 경우 설정 파일도 없다.(http://docs.spring.io/spring-boot/docs/1.4.3.RELEASE/reference/html/common-application-properties.html 참고)


진행중인 프로젝트에 캐시별 유효시간 설정 기능이 필요해서 스프링 부트로 다음과 같은 프로퍼티를 사용해서 캐시별로 유효 시간을 설정할 수 있도록 구현해봤다.


# application.properties

spring.cache.redis.defaultExpireTime=0

spring.cache.redis.expireTime.billDetailData=3600

spring.cache.redis.expireTime.billSummaryInfos=3600


이 설정에서 "spring.cache.redis"는 접두어이다. defaultExpireTime은 전체 캐시에 기본으로 적용할 유효시간을 설정한다. "expirTime.캐시이름"은 캐시 이름별로 캐시 시간을 설정한다. 유효 시간은 초 단위이다.


이 설정을 담기 위한 @ConfigurationProperties 클래스를 프로퍼티 클래스를 다음과 같이 작성했다.


import java.util.HashMap;

import java.util.Map;

import java.util.Map.Entry;


import org.springframework.boot.context.properties.ConfigurationProperties;


@ConfigurationProperties(prefix = "spring.cache.redis")

public class CacheRedisProperties {


    private long defaultExpireTime = 0L;

    private Map<String, Long> expireTime = new HashMap<>();


    private CacheTimeParser parser = new CacheTimeParser();


    public long getDefaultExpireTime() {

        return defaultExpireTime;

    }


    public void setDefaultExpireTime(long defaultExpireTime) {

        this.defaultExpireTime = defaultExpireTime;

    }


    public Map<String, Long> getExpireTime() {

        return expireTime;

    }


    public void setExpireTime(Map<String, Long> expireTime) {

        this.expireTime = expireTime;

    }

}


다음으로 RedisCacheManager에 유효 시간 설정하면 된다. 이를 위한 코드는 다음과 같다.


import java.util.Map.Entry;


import org.slf4j.Logger;

import org.slf4j.LoggerFactory;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.boot.autoconfigure.cache.CacheManagerCustomizer;

import org.springframework.boot.context.properties.EnableConfigurationProperties;

import org.springframework.cache.annotation.EnableCaching;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.context.annotation.Profile;

import org.springframework.data.redis.cache.RedisCacheManager;


/**

 * org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 클래스 참고

 */

@Configuration

@EnableCaching

@EnableConfigurationProperties(CacheRedisProperties.class)

public class CustomRedisCacheConfiguration {

    private Logger logger = LoggerFactory.getLogger(getClass());


    @Autowired

    private CacheRedisProperties cacheRedisProperties;


    @Bean

    public CacheManagerCustomizer<RedisCacheManager> cacheManagerCustomizer() {

        return new CacheManagerCustomizer<RedisCacheManager>() {

            @Override

            public void customize(RedisCacheManager cacheManager) {

                cacheManager.setDefaultExpiration(cacheRedisProperties.getDefaultExpireTime());

                cacheManager.setExpires(cacheRedisProperties.getExpireTime());

            }

        };

    }

}


스프링부트가 제공하는 CacheManagerCustomizer를 이용하면 부트가 생성한 CacheManager를 커스터마이징할 수 있다. 이 기능을 사용해서 RedisCacheManager에 캐시 유효 시간을 설정하면 된다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. ㅇㅇ 2017.01.29 20:55 신고  댓글주소  수정/삭제  댓글쓰기

    최범균님이 집필하신 책으로 공부하고 있습니다.

    저에게 많은 도움이 되는 것 같아 감사의 의미로 댓글 남깁니다.

    새해 복 많이 받으시고 건강하세요. ^^

  2. 조승규 2018.02.07 10:44 신고  댓글주소  수정/삭제  댓글쓰기

    많은 도움 되었습니다.
    감사합니다.

특정 URL 이미지를 목록에서 보여주어야 할 때, 동일 URL 이미지를 매번 다운로드 받아 출력하면 비효율적일 뿐만 아니라 사용자에게 보여지는 응답도 느려지게 된다. 사용자에게 응답을 빠르게 보여주기 위해서는 URL 이미지를 메모리나 로컬 파일에 캐싱하도록 구현해야 한다. 이미지를 캐싱함으로써, 동일 URL 이미지를 보여주어야 할 때 다운로드 없이 빠르게 이미지를 사용자에게 보여줄 수 있게 된다.


본 글에서는 이미지를 위한 캐시를 만들어보도록 하겠다. 실제 URL로부터 이미지를 읽어와 캐시에 담고 ImageView에 다운로드 받은 이미지를 보여주는 코드는 '안드로이드에서 URL 이미지를 ImageView에 보여주기' 글을 참고하기 바란다.


이미지 캐시 기능


제공할 기능은 다음과 같다.

  • 이미지 메모리 캐시를 제공한다.
    • 메모리에 지정 개수 만큼의 이미지를 보관한다.
  • 이미지 메모리/파일의 2레벨 캐시를 제공한다. 
    • 캐시에 이미지를 보관하면 메모리와 파일에 동시에 보관된다.
    • 메모리 캐시는 보관할 수 있는 개수에 제한이 있다.
    • 파일 캐시는 보관할 수 있는 전체 크기에 제한이 있다. ('안드로이드에서 파일 캐시 구현하기' 글에서 만든 파일 캐시를 사용해서 구현한다.)
    • 메모리 캐시에 없으면, 파일 캐시로부터 이미지를 읽어온다.

2레벨 캐시를 사용할 경우 자주 사용되는 이미지는 메모리에 담고 일정 크기만큼의 이미지는 파일로도 보관한다. 이를 통해 메모리 용량의 사용을 일정 수준으로 유지하면서 동시에 네트워크 사용을 최소화해서 사용자에게 이미지를 빠르게 보여줄 수 있다.


이미지 캐시  클래스 구성


구현할 이미지 캐시의 클래스 구성은 아래와 같다.



구성요소

설명 

ImageCacheFactory

ImageCache의 생성 및 검색 기능을 제공한다. 

ImageCache

이미지 캐시를 위한 인터페이스를 제공한다. 

MemoryImageCache

메모리 기반의 이미지 캐시를 구현한다. 

FileImageCache 

파일 기반의 이미지 캐시를 구현한다.

ChainedImageCache 

캐시 체인 기능을 제공한다.


ImageCache 인터페이스


public interface ImageCache {


public void addBitmap(String key, Bitmap bitmap);


public void addBitmap(String key, File bitmapFile);


public Bitmap getBitmap(String key);


public void clear();


}


MemoryImageCache 클래스


MemoryImageCache는 내부적으로 LruCache를 사용해서 구현하였다.


package com.toonburi.app.infra.imagecache;


import java.io.File;


import android.graphics.Bitmap;

import android.graphics.BitmapFactory;

import android.support.v4.util.LruCache;


public class MemoryImageCache implements ImageCache {


private LruCache<String, Bitmap> lruCache;


public MemoryImageCache(int maxCount) {

lruCache = new LruCache<String, Bitmap>(maxCount);

}


@Override

public void addBitmap(String key, Bitmap bitmap) {

if (bitmap == null)

return;

lruCache.put(key, bitmap);

}


@Override

public void addBitmap(String key, File bitmapFile) {

if (bitmapFile == null)

return;

if (!bitmapFile.exists())

return;


Bitmap bitmap = BitmapFactory.decodeFile(bitmapFile.getAbsolutePath());

lruCache.put(key, bitmap);

}


@Override

public Bitmap getBitmap(String key) {

return lruCache.get(key);

}


@Override

public void clear() {

lruCache.evictAll();

}


}


FileImageCache 클래스


FileImageCache는 앞서 '안드로이드에서 파일 캐시 구현하기'에서 만든 파일 캐시를 이용해서 구현하였다. 코드는 다음과 같다.


public class FileImageCache implements ImageCache {

private static final String TAG = "FileImageCache";


private FileCache fileCache;


public FileImageCache(String cacheName) {

fileCache = FileCacheFactory.getInstance().get(cacheName);

}


@Override

public void addBitmap(String key, final Bitmap bitmap) {

try {

fileCache.put(key, new ByteProvider() {

@Override

public void writeTo(OutputStream os) {

bitmap.compress(CompressFormat.PNG, 100, os);

}

});

} catch (IOException e) {

Log.e(TAG, "fail to bitmap to fileCache", e);

}

}


@Override

public void addBitmap(String key, File bitmapFile) {

try {

fileCache.put(key, bitmapFile, true);

} catch (IOException e) {

Log.e(TAG, String.format("fail to bitmap file[%s] to fileCache",

bitmapFile.getAbsolutePath()), e);

}

}


@Override

public Bitmap getBitmap(String key) {

FileEntry cachedFile = fileCache.get(key);

if (cachedFile == null) {

return null;

}

return BitmapFactory.decodeFile(cachedFile.getFile().getAbsolutePath());

}


@Override

public void clear() {

fileCache.clear();

}


}


위 코드에서 유의할 점은 FileImageCache 객체를 생성할 때, 파라미터로 전달받은 cacheName을 이용해서 FileCache를 구한다는 점이다. 즉, FileImageCache의 이름과 동일한 이름을 갖는 FileCache가 존재해야 정상적으로 동작한다. 따라서, FileImageCache를 사용하기 전에 다음과 같이 이미지 캐시와 동일한 이름을 갖는 FileCache를 생성해 주어야 한다.


// onCreate 등에서 파일을 이용하는 이미지 캐시 생성 전에 초기화

FileCacheFactory.getInstance().create(cacheName, cacheSize);


ChainedImageCache 클래스


이미지 캐시와 파일 캐시를 1차/2차 캐시로 사용하기 위해 ChainedImageCache 클래스를 만들었다.


public class ChainedImageCache implements ImageCache {


private List<ImageCache> chain;


public ChainedImageCache(List<ImageCache> chain) {

this.chain = chain;

}


@Override

public void addBitmap(String key, Bitmap bitmap) {

for (ImageCache cache : chain) {

cache.addBitmap(key, bitmap);

}

}


@Override

public void addBitmap(String key, File bitmapFile) {

for (ImageCache cache : chain) {

cache.addBitmap(key, bitmapFile);

}

}


@Override

public final Bitmap getBitmap(String key) {

Bitmap bitmap = null;

List<ImageCache> previousCaches = new ArrayList<ImageCache>();

for (ImageCache cache : chain) {

bitmap = cache.getBitmap(key);

if (bitmap != null) {

break;

}

previousCaches.add(cache);

}

if (bitmap == null)

return null;


if (!previousCaches.isEmpty()) {

for (ImageCache cache : previousCaches) {

cache.addBitmap(key, bitmap);

}

}

return bitmap;

}


@Override

public final void clear() {

for (ImageCache cache : chain) {

cache.clear();

}

}


}


ChainedImageCache는 chain에 등록되어 있는 모든 ImageCache를 차례대로 실행한다. getBitmap()은 약간 복잡하다. getBitmap()은 체인을 따라 Bitmap이 존재할 때까지 탐색한다. Bitmap이 발견되면 해당 캐시 이전에 위치한 캐시들(previousCaches에 보관됨)에 Bitmap 정보를 추가해서, 이후 동일 키로 요청이 오면 체인의 앞에서 발견되도록 한다.


ImageCacheFactory 클래스


ImageCacheFactory는 캐시 생성 기능을 제공한다.


public class ImageCacheFactory {


private static ImageCacheFactory instance = new ImageCacheFactory();


public static ImageCacheFactory getInstance() {

return instance;

}


private HashMap<String, ImageCache> cacheMap = new HashMap<String, ImageCache>();


private ImageCacheFactory() {

}


public ImageCache createMemoryCache(String cacheName, int imageMaxCounts) {

synchronized (cacheMap) {

checkAleadyExists(cacheName);

ImageCache cache = new MemoryImageCache(imageMaxCounts);

cacheMap.put(cacheName, cache);

return cache;

}

}


private void checkAleadyExists(String cacheName) {

ImageCache cache = cacheMap.get(cacheName);

if (cache != null) {

throw new ImageCacheAleadyExistException(String.format(

"ImageCache[%s] aleady exists", cacheName));

}

}


public ImageCache createTwoLevelCache(String cacheName, int imageMaxCounts) {

synchronized (cacheMap) {

checkAleadyExists(cacheName);

List<ImageCache> chain = new ArrayList<ImageCache>();

chain.add(new MemoryImageCache(imageMaxCounts));

chain.add(new FileImageCache(cacheName));

ChainedImageCache cache = new ChainedImageCache(chain);

cacheMap.put(cacheName, cache);

return cache;

}

}


public ImageCache get(String cacheName) {

ImageCache cache = cacheMap.get(cacheName);

if (cache == null) {

throw new ImageCacheNotFoundException(

String.format("ImageCache[%s] not founds"));

}

return cache;

}

}


createMemoryCache() 메서드는 메모리만 사용하는 ImageCache를 생성한다. createTwoLevelCache() 메서드는 1차 메모리/2차 파일 기반의 2레벨 캐시를 생성한다.


이미지 캐시 사용하기


다음은 이미지 캐시의 사용 예시이다.


-- onCreate 등 초기화 부분


// 2레벨 캐시(이미지 파일 캐시)를 사용하려면 동일 이름의 파일 캐시를 생성해 주어야 한다.

FileCacheFactory.getInstance().create(cacheName, cacheSize);


// 이미지 캐시 초기화

ImageCacheFactory.getInstance().createTwoLevelCache(cacheName, memoryImageMaxCounts);



-- 이미지 캐시 사용 부분

ImageCache imageCache = ImageCacheFactory.getInstance().getCache(cacheName);

Bitmap bitmap = imageCache.getBitmap(key);

if (bitmap != null) {

imageView.set.....

}


-- 이미지 캐시 추가 부분

imageCache.putBitmap(key, someBitmap);


실제 ImageCache를 사용하는 예제 코드는 '안드로이드에서 URL 이미지를 ImageView에 보여주기'에 있으니 이 글을 참고하면 된다.


관련자료



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. bluepoet 2013.01.31 17:35 신고  댓글주소  수정/삭제  댓글쓰기

    저도 이번 스타앱 리뉴얼때, 프로게이머 프로필 이미지쪽을 구현할 예정이었는데

    이번 포스팅이 많이 참고가 되겠네요.

    특히나, 체인을 이용한 캐시 구현방법은 참 신선하네요.

    근데, previousCaches에 정보를 넣고 활용하는 쪽은 좀 어렵네요.

    동일 키로 요청이 오면 어떻게 체인의 앞에서 발견되도록 하는 건지 궁금합니다.

  2. andu 2013.05.16 13:28 신고  댓글주소  수정/삭제  댓글쓰기

    FileImageCache 클래스에서 fileCache.clear()가 사용되는데,

    FileCache 클래스에는 clear()가 없네요. 새로 추가된 메소드인가요?

  3. truelifer 2013.10.10 17:13 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 포스팅 감사합니다.
    저도 한가지 의문이 있는건, bluepoet 님이 문의하신 것 처럼 동일 키로 요청이 오면 어떻게 체인의 앞에서 발견되도록 하는건지 궁금합니다.
    코드를 보니 previousCaches 를 따로 저장하지 않고 getBitmap() 함수가 끝나면 previousCaches 가 소멸될 것 같은데..

    • 최범균 madvirus 2013.10.11 09:09 신고  댓글주소  수정/삭제

      previousCaches 라는 로컬 변수는 사라지지만, previousCache에 담은 객체는 chain 필드에 보관되어 있는 ImageCache 입니다.
      getBitmap() 메서드를 보시면, chain에 있는 ImageCache를 차례대로 탐색하면서 해당 ImageCache가 key에 해당하는 비트맵을 갖고 있는 지 확인을 합니다. 갖고 있으면, for 루프를 나오고, 아니면 (로컬 변수인) previousCaches에 ImageCache를 추가합니다.

      발견된 Bitmap이 없으면 그냥 null을 리턴하고, 발결된 Bitmap이 있으면, previousCaches에 담아 두었던 ImageCache들의 addBitmap을 호출해서 비트맵을 추가해줍니다. 여기서 previousCaches에는 chain에서 비트맵이 발견되기 전까지의 ImageCache 목록을 갖고 있게 되죠. 따라서, chain의 특정 ImageCache에서 비트맵이 발견되면, 그 ImageCache 이전에 있던 ImageCache들에 비트맵을 추가하게 됩니다.

  4. winterCha 2013.10.17 16:38 신고  댓글주소  수정/삭제  댓글쓰기

    정말 감사합니다 아주 투통이 사라지는 캐쉬 로직이네요
    태클아닌 태클 하나.....

    checkAlreadyExists 를 표현 하고 싶으신것 같은데 r이 빠졌네요
    쏘리욤

    madvirus fan 입니다 최범균님 책으로 참 많은 것을 공부 했었던 기억이

    존경합니다.

  5. cs만두 2014.05.19 22:22 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요 안드로이드에 관심이 많은 대학생 개발자입니다!
    몇달전 프로젝트를 진행하면서 bitmap OOM관련 이슈때문에 고민을 하다가 AUIL 라이브러리를 사용해서 해결했습니다. 그러면서 꼭 시간나면 이미지 로더 관련해서 공부해봐야겠다 생각했는데 이렇게 좋은 글을 만나서 너무 좋습니다.
    좋은 글 감사합니다! 열심히 공부하겠습니다!!!!!!

  6. 이승화 2014.05.23 16:59 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다. 덕분에 좀더 쉽게 코딩할수 있게 되었습니다.
    책도 많이 쓰셨던데 짱~ 이십니다ㅋ

  7. 최형식 2015.03.25 14:07 신고  댓글주소  수정/삭제  댓글쓰기

    지도 앱을 만들고 있습니다. svg파일로 하든 png로 하든 비트맵으로 변경해서 보여주려고 합니다. 사이즈가 크다 보니 타일처럼 짤라서 화면밖의 타일들은 캐시로, 이미지가 움직이면 캐시에서 불러오고 이런식으로 하고싶은데 어떤 방법이 있나요?

  8. 최수혁 2015.05.22 11:03 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요?
    이 글 보고 안드로이드 이미지 캐시를 완벽하게 구현하였습니다.
    그런데 문제가 하나 있습니다.

    A라는 이름으로 캐시를 하나 창조해서 다운로드 해서 이미지를 모두 넣었습니다.
    그 다음 액티비티에서 B라는 이름으로 캐시를 하나 또 창조했는데 다운로드되지 않고 있습니다.
    캐시창조할때에는 모두 오류가 없는데 이상하게 onResult에는 안들어오네요..

    B와 A의 순서를 바꾸어도 역시 2번째는 작동을 안합니다.

    왜그럴가요?

    도저히 원인을 못찾겠네요.

    • 최범균 madvirus 2015.05.23 00:47 신고  댓글주소  수정/삭제

      코드를 바꾸지 않으셨다면, 코드에 버그가 있는 거겠죠. 지금은 졸려서 코드가 눈에 잘 안 들어오네요. 좀 맨 정신에 코드 보고 답글 달아보렵니다.

안드로이드 프로그래밍을 하다 보니까, 파일로 캐시를 구현해야 하는 경우가 발생했다. 다음은 파일 캐시가 필요한 경우의 예이다.

  • REST API를 호출한 결과를 파일로 기록해 두고, 다음에는 REST API 호출 없이 파일에서 읽어와 빠른 응답 제공
  • 목록에서 사용되는 웹 이미지를 다운받아 파일로 기록해 두고, 이후 동일 이미지를 빠르게 출력

두 가지 경우 모두 로컬에 데이터를 파일 형태로 캐싱하는 기능을 필요로 한다.


구현할 파일 캐시 기능


여기서 구현해 볼 파일 캐시는 다음의 특징을 갖는다.

  • 두 개 이상의 파일 캐시를 제공한다.
  • 캐시에 보관될 최대 파일 크기를 제한할 수 있다.
  • 시스템의 캐시 폴더에 파일을 기록하기 때문에, 공간이 필요할 경우 안드로이드가 자동으로 오래된 캐시 파일을 삭제한다.


클래스 구성


다음 클래스 다이어그램은 파일 캐시와 관련된 구성 요소의 관계를 표현한 것이다.



[파일 캐시 클래스 다이어그램]


주요 구성 요소는 다음과 같다.


구성 요소 

설명 

FileCacheFactory

FileCache를 생성하고 구할 때 사용되는 Factory 클래스.

FileCache

파일 캐시 기능을 정의한 인터페이스.

FileCacheImpl

CacheStorage를 이용한 FileCache 구현체. 클라이언트의 요청을 

CacheStorage

캐시 파일 저장을 처리하는 클래스. 지정한 최대 크기를 넘길 경우 캐시된 파일 중 오래된 파일을 삭제한다.

CacheStorage.Initializer

CacheStorage를 초기화. 기존에 캐싱된 파일 목록을 등록한다. 

ByteProvider 

FileCache에 데이터를 제공할 때 사용된다. 

ByteProvider 인터페이스 및 보조 클래스


FileCache는 데이터를 파일로 저장하기 때문에 바이트로 데이터를 제공해 주어야 한다. 이를 명시적으로 하기 위해 ByteProvider를 정의하였다. ByteProvider 인터페이스는  다음과 같이 정의되어 있다.


public interface ByteProvider {


void writeTo(OutputStream os) throws IOException;


}


ByteProvider의 몇 가지 구현체를 제공하는 보조 클래스를 만들면 좀 더 편리하게 사용할 수 있다. 다음은 구현 예이다.


public abstract class ByteProviderUtil {


public static ByteProvider create(final InputStream is) {

return new ByteProvider() {

@Override

public void writeTo(OutputStream os) throws IOException {

IOUtils.copy(is, os);

}

};

}


public static ByteProvider create(final File file) {

return new ByteProvider() {

@Override

public void writeTo(OutputStream os) throws IOException {

IOUtils.copy(file, os);

}

};

}


public static ByteProvider create(final String str) {

return new ByteProvider() {

@Override

public void writeTo(OutputStream os) throws IOException {

IOUtils.copy(str, os);

}

};

}

}




CacheStorage 클래스의 구현


핵심 클래스는 CacheStorage 이다. 이 클래스가 가장 길고 복잡하므로 부분 부분 나눠서 구현 코드를 살펴보도록 하겠다.


CacheStorage의 초기화 부분

다음은 CacheStorage의 초기화 부분의 코드이다.

// CacheStorage.java

public class CacheStorage {
private static final String TAG = "CacheStorage";

private File cacheDir;
private Map<String, CacheFile> cacheFileMap;

private long maxBytesSize;
private AtomicLong currentBytesSize = new AtomicLong();;

private ReadWriteLock rwl = new ReentrantReadWriteLock();
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();

public CacheStorage(File cacheDir, long maxBytesSize) {
this.cacheDir = cacheDir;
this.maxBytesSize = maxBytesSize;
this.cacheFileMap = Collections
.synchronizedMap(new LinkedHashMap<String, CacheFile>(1024));

createCacheDirIfNotExists();
initializing();
}

private void createCacheDirIfNotExists() {
if (cacheDir.exists())
return;
cacheDir.mkdirs();
}

private void initializing() {
new Thread(new Initializer()).start();
}

생성 과정에서는 다음의 작업을 수행한다.

  • 필드 초기화 (생성자)
    • cacheDir: 캐시 경로를 보관하는 File
    • maxBytesSize: 캐시 보관소의 최대 크기
    • cacheFileMap: 캐시 파일 정보를 담을 Map
    • 읽기/쓰기에 사용될 Lock 생성. 캐시 읽기와 쓰기가 동시에 진행되는 것을 방지하기 위한 Lock.
    • currentBytesSize: 캐시 디렉토리에 보관된 전체 파일의 크기
  • createCacheDirIfNotExists(): 캐시 디렉토리가 존재하지 않으면 생성한다.
  • initializing(): 별도 쓰레도로 Initializer를 실행한다.
Initializer는 캐시 디렉토리에 보관된 파일 정보를 cacheFileMap에 로딩하는 기능을 제공한다. Initializer는 CacheStorage의 내부 클래스로서 다음과 같다.

// CacheStorage의 내부 클래스

private class Initializer implements Runnable {

@Override
public void run() {
writeLock.lock();
try {
File[] cachedFiles = cacheDir.listFiles();
for (File file : cachedFiles) {
putFileToCacheMap(file);
}
} catch (Exception ex) {
Log.e(TAG, "CacheStorage.Initializer: fail to initialize - "
+ ex.getMessage(), ex);
} finally {
writeLock.unlock();
}
}
}


Initializer는 캐시 디렉토리에 존재하는 파일 목록을 읽어와 각 파일에 대해 putFileToCacheMap()에 전달한다. putFileToCacheMap() 메서드는 파일 정보를 추가한다. 이 메서드에 대한 내용은 뒤에서 다시 설명한다.


캐시 파일 정보를 구하는 get() 메서드


캐시 스토리지로부터 캐시 파일을 구해주는 get() 메서드는 다음과 같이 구현하였다.


// CacheStorage.java


public File get(String filename) {

readLock.lock();

try {

CacheFile cachdFile = cacheFileMap.get(filename);

if (cachdFile == null) {

return null;

}

if (cachdFile.file.exists()) {

moveHitEntryToFirst(filename, cachdFile);

return cachdFile.file;

}

removeCacheFileFromMap(filename, cachdFile);

return null;

} finally {

readLock.unlock();

}

}


private void moveHitEntryToFirst(String filename, CacheFile cachedFile) {

cacheFileMap.remove(filename);

cacheFileMap.put(filename, cachedFile);

}


private void removeCacheFileFromMap(String filename, CacheFile cachedFile) {

currentBytesSize.addAndGet(-cachedFile.size);

cacheFileMap.remove(filename);

}


다음은 get() 메서드의 실행순서를 정리한 것이다.

  • cacheFileMap 으로부터 해당 이름의 CacheFile이 존재하는지 확인한다.
    • 존재하지 않으면 null을 리턴한다.
  • CacheFile의 file이 실제로 존재하는지 확인한다.
    • 존재하면, moveHitEntryToFirst()를 실행한 뒤에 해당 File을 리턴한다.
    • 존재하지 않으면, removeCacheFileFromMap()을 실행해서 메모리에서 정보를 제거하고 null을 리턴한다.
cacheFileMap은 LinkedHashMap을 사용해서 생성했는데, LinkedHashMap은 등록된 순서 정보를 보관하는 Map이다. 이 Map 구현을 사용한 이유는 전체 파일 크기가 제한된 크기를 넘어선 경우, 최근 등록된 파일이 아닌 오래된 파일을 선택해서 삭제하기 위함이다.

get() 메서드는 cacheFileMap에 데이터가 존재할 경우, File을 리턴하기 전에 moveHitEntryToFirst() 메서드를 호출하는데 이 메서드는 최근에 사용된 파일을 제일 앞으로 보내서 용량 초과시 대상이 되지 않도록 만들어준다.

cacheFileMap에는 존재하지만 실제 대상 파일이 존재하지 않을 수도 있다. (그 이유는 생성한 파일이 캐시 디렉토리에 있기 때문이다.) 이 경우 removeCacheFileFromMap()을 호출해서 전체 파일 크기를 줄이고 cacheFileMap에서 제거한다.

스토리지에 파일 추가하는 write() 메서드

다음은 write() 메서드의 구현이다.

// CacheStorage.java

public void write(String filename, ByteProvider provider) throws IOException {
writeLock.lock();
try {
createCacheDirIfNotExists();
File file = createFile(filename);
copyProviderToFile(provider, file);
putToCachMapAndCheckMaxThresold(file);
} finally {
writeLock.unlock();
}
}

private File createFile(String filename) {
return new File(cacheDir, filename);
}

private void copyProviderToFile(ByteProvider provider, File file)
throws FileNotFoundException, IOException {
BufferedOutputStream os = null;
try {
os = new BufferedOutputStream(new FileOutputStream(file));
provider.writeTo(os);
} finally {
IOUtils.close(os);
}
}

private void putToCachMapAndCheckMaxThresold(File file) {
putFileToCacheMap(file);
checkMaxThresoldAndDeleteOldestWhenOverflow();
}

private void putFileToCacheMap(File file) {
cacheFileMap.put(file.getName(), new CacheFile(file));
currentBytesSize.addAndGet(file.length());
}

write() 메서드는 다음의 순서에 따라 파일 쓰기를 처리한다.
  • createCacheDirIfNotExists()를 호출해서 캐시 디렉토리가 없으면 생성한다. 앱의 캐시 디렉토리를 삭제할 수 있기 때문에, 디렉토리 존재 여부를 확인해야 한다.
  • createFile()로 캐시 파일 정보를 생성한다.
  • copyProviderToFile()로 ByteProvider가 제공하는 내용을 파일에 쓴다. ByteProvider.writeTo() 메서드는 파라미터로 제공받은 OutputStream에 데이터를 쓴다.
  • putToCacheMapAndCheckMaxThresold()를 실행해서 cacheFileMap에 캐시 파일 정보를 추가하고, 최대 크기를 초과했는지 확인한다.
putToCachMapAndCheckMaxThresold() 메서드는 다음의 두 작업을 실행한다.
  • putFileToCacheMap() 메서드로 cacheFileMap에 캐시 파일 정보를 추가하고 현재 스토리지 크기(currentBytesSize) 값을 증가한다.
  • checkMaxThresoldAndDeleteOldestWhenOverflow() 메서드를 호출해서, 캐시 디렉토리에 보관된 전체 파일의 크기가 지정한 최대 크기를 초과하면 오래된 파일을 삭제해서 전체 파일 크기를 유지한다.

최대 저장 크기 확인 처리 부분

checkMaxThresoldAndDeleteOldestWhenOverflow() 메서드의 구현 코드는 다음과 같다.

// CacheStorage.java

private void checkMaxThresoldAndDeleteOldestWhenOverflow() {
if (isOverflow()) {
List<Entry<String, CacheFile>> deletingCandidates = getDeletingCandidates();
for (Entry<String, CacheFile> entry : deletingCandidates) {
delete(entry.getKey());
}
}
}

private boolean isOverflow() {
if (maxBytesSize <= 0) {
return false;
}
return currentBytesSize.get() > maxBytesSize;
}

private List<Entry<String, CacheFile>> getDeletingCandidates() {
List<Entry<String, CacheFile>> deletingCandidates = 
new ArrayList<Entry<String, CacheFile>>();
long cadidateFileSizes = 0;
for (Entry<String, CacheFile> entry : cacheFileMap.entrySet()) {
deletingCandidates.add(entry);
cadidateFileSizes += entry.getValue().file.length();
if (currentBytesSize.get() - cadidateFileSizes < maxBytesSize) {
break;
}
}
return deletingCandidates;
}

checkMaxThresoldAndDeleteOldestWhenOverflow() 메서드는 isOverflow()를 이용해서 캐시 디렉토리에 보관된 파일들의 전체 크기가 지정한 최대 크기를 초과했는지 검사한다. 초과한 경우 getDeletingCandidates()를 이용해서 삭제 대상을 구한 뒤에, delete()로 삭제 처리를 한다.

getDeletingCandidates() 메서드는 캐시 파일 중 오래된 파일을 삭제 대상에 추가한다. 최대 크기를 초과하지 않을 때 까지 캐시 파일들을 차례대로 삭제 대상에 추가한다.

스토리지에 파일을 이동시키는 move() 메서드

move() 메서드는 특정 파일을 스토리지로 이동시켜 보관한다.

public void move(String filename, File sourceFile) {
writeLock.lock();
try {
createCacheDirIfNotExists();
File file = createFile(filename);
sourceFile.renameTo(file);
putToCachMapAndCheckMaxThresold(file);
} finally {
writeLock.unlock();
}
}

캐시 파일 삭제 위한 delete() 메서드 및 deleteAll() 메서드

delete() 파일은 간단하다. cacheFileMap에서 캐시파일 정보를 읽어온 뒤, removeCacheFileFromMap() 메서드를 이용해서 메모리에서 정보를 삭제하고 그 다음 파일을 삭제한다.

public void delete(String filename) {
writeLock.lock();
try {
CacheFile cacheFile = cacheFileMap.get(filename);
if (cacheFile == null)
return;

removeCacheFileFromMap(filename, cacheFile);
cacheFile.file.delete();
} finally {
writeLock.unlock();
}
}

public void deleteAll() {
writeLock.lock();
try {
List<String> keys = new ArrayList<String>(cacheFileMap.keySet());
for (String key : keys) {
delete(key);
}
} finally {
writeLock.unlock();
}
}

CacheFile 코드

CacheFile 클래스는 CacheStorage 클래스에 정의된 중첩 클래스로서 다음과 같다.

public class CacheStorage {

...

private static class CacheFile {
public File file;
public long size;

public CacheFile(File file) {
super();
this.file = file;
this.size = file.length();
}
}
}

FileCache 및 FileCacheImpl 구현

FileCache 인터페이스

FileCache 인터페이스는 캐시 목적의 기능을 정의한다.

public interface FileCache {

public FileEntry get(String key);

public void put(String key, ByteProvider provider) throws IOException;

public void put(String key, InputStream is) throws IOException;

public void put(String key, File sourceFile, boolean move) throws IOException;

public void remove(String key);

public void clear();
}

FileCacheImpl 클래스의 구현


파일 캐시를 필요로 하는 코드는 FileCache 타입을 사용하는데, 이 타입의 구현 클래스가 FileCacheImpl이다. FileCacheImpl 클래스는 CacheStorage를 생성하고, get/put/remove 메서드는 cacheStorage에 요청을 전달한다.


public class FileCacheImpl implements FileCache {


private CacheStorage cacheStorage;


public FileCacheImpl(File cacheDir, int maxKBSizes) {

long maxBytesSize = maxKBSizes <= 0 ? 0 : maxKBSizes * 1024;

cacheStorage = new CacheStorage(cacheDir, maxBytesSize);

}


@Override

public FileEntry get(String key) {

File file = cacheStorage.get(keyToFilename(key));

if (file == null) {

return null;

}

if (file.exists()) {

return new FileEntry(key, file);

}

return null;

}


@Override

public void put(String key, ByteProvider provider) throws IOException {

cacheStorage.write(keyToFilename(key), provider);

}


@Override

public void put(String key, InputStream is) throws IOException {

put(key, ByteProviderUtil.create(is));

}


@Override

public void put(String key, File sourceFile, boolean move)

throws IOException {

if (move) {

cacheStorage.move(keyToFilename(key), sourceFile);

} else {

put(key, ByteProviderUtil.create(sourceFile));

}

}


@Override

public void remove(String key) {

cacheStorage.delete(keyToFilename(key));

}


private String keyToFilename(String key) {

String filename = key.replace(":", "_");

filename = filename.replace("/", "_s_");

filename = filename.replace("\\", "_bs_");

filename = filename.replace("&", "_bs_");

filename = filename.replace("*", "_start_");

filename = filename.replace("?", "_q_");

filename = filename.replace("|", "_or_");

filename = filename.replace(">", "_gt_");

filename = filename.replace("<", "_lt_");

return filename;

}


@Override

public void clear() {

cacheStorage.deleteAll();

}


}


캐시 키를 그대로 파일 이름으로 사용할 수 없기 때문에, get/put/remove/move 메서드는 keyToFilename()를 이용해서 파일명으로 사용될 수 없는 문자를 알맞게 치환한다.


FileCacheFactory 클래스의 구현


FileCacheFactory 클래스는 FileCache를 생성하고 제공하는 기능을 제공한다. 안드로이드의 캐시 디렉토리를 먼저 구해야 하기 때문에, 초기화(initialize)를 수행해야 getInstance()를 구하도록 제약을 두었다.


public class FileCacheFactory {


private static boolean initialized = false;

private static FileCacheFactory instance = new FileCacheFactory();


public static void initialize(Context context) {

if (!initialized) {

synchronized (instance) {

if (!initialized) {

instance.init(context);

initialized = true;

}

}

}

}


public static FileCacheFactory getInstance() {

if (!initialized) {

throw new IllegalStateException(

"Not initialized. You must call FileCacheFactory.initialize() before getInstance()");

}

return instance;

}


private HashMap<String, FileCache> cacheMap = new HashMap<String, FileCache>();

private File cacheBaseDir;


private FileCacheFactory() {

}


private void init(Context context) {

cacheBaseDir = context.getCacheDir();

}


public FileCache create(String cacheName, int maxKbSizes) {

synchronized (cacheMap) {

FileCache cache = cacheMap.get(cacheName);

if (cache != null) {

throw new FileCacheAleadyExistException(String.format(

"FileCache[%s] Aleady exists", cacheName));

}

File cacheDir = new File(cacheBaseDir, cacheName);

cache = new FileCacheImpl(cacheDir, maxKbSizes);

cacheMap.put(cacheName, cache);

return cache;

}

}


public FileCache get(String cacheName) {

synchronized (cacheMap) {

FileCache cache = cacheMap.get(cacheName);

if (cache == null) {

throw new FileCacheNotFoundException(String.format(

"FileCache[%s] not founds.", cacheName));

}

return cache;

}

}


public boolean has(String cacheName) {

return cacheMap.containsKey(cacheName);

}

}


파일 캐시 사용법


파일 캐시를 사용하는 방법은 간단하다. 먼저 FileCacheFactory.initialize()로 초기화를 하고, create() 메서드로 캐시를 생성한다. 그 다음 캐시를 필요로 하는 곳에서 get() 메서드를 이용해서 FileCache를 구해서 사용하면 된다.


다음은 FileCache의 사용예를 간단하게 정리한 것이다.


public class SomeLoadActivity extends Activity {


private FileCache fileCache;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

FileCacheFactory.initialize(this);

if (! FileCacheFactory.getInstance().has(cacheName)) {

FileCacheFactory.getInstance().create(cacheName, cacheSize);

}

fileCache = FileCacheFactory.getInstance().get(cacheName);

...

}


public void load() {

...

FileEntry fileEntry = fileCache.get(key);

if (fileEntry != null) {

// 캐시에서 읽어온 데이터로 처리

String data = loadDataFromFile(fileEntry.getFile());

processing(data);

return;

}

// 실제 데이터 로딩 (실제로는 웹에서 비동기로 읽어오는 등의 코드)

String data = loadingDataRealSource();

// 캐시에 보관

fileCache.put(key, ByteProviderUtil.create(dataFile));

// 처리

processing(data);

}


기타 클래스: IOUtils, FileEntry


IOUtils 클래스는 입출력 관련 처리를 위한 보조 클래스로서 본 글에서 사용하는 코드는 다음과 같다.


public abstract class IOUtils {


public static String read(InputStream is) throws IOException {

InputStreamReader reader = null;

try {

reader = new InputStreamReader(is);

StringBuilder builder = new StringBuilder();

char[] readDate = new char[1024];

int len = -1;

while ((len = reader.read(readDate)) != -1) {

builder.append(readDate, 0, len);

}

return builder.toString();

} finally {

close(reader);

}

}


public static void copy(InputStream is, OutputStream out)

throws IOException {

byte[] buff = new byte[4096];

int len = -1;

while ((len = is.read(buff)) != -1) {

out.write(buff, 0, len);

}

}


public static void copy(File source, OutputStream os) throws IOException {

BufferedInputStream is = null;

try {

is = new BufferedInputStream(new FileInputStream(source));

IOUtils.copy(is, os);

} finally {

IOUtils.close(is);

}

}


public static void copy(InputStream is, File target) throws IOException {

OutputStream os = null;

try {

os = new BufferedOutputStream(new FileOutputStream(target));

IOUtils.copy(is, os);

} finally {

IOUtils.close(os);

}

}


public static void copy(String str, OutputStream os) throws IOException {

os.write(str.getBytes());

}


public static void close(Closeable stream) {

if (stream != null) {

try {

stream.close();

} catch (IOException e) {

}

}

}


}


다음은 FileEntry 클래스의 소스 코드이다.


public class FileEntry {


    private String key;

    private File file;


    public FileEntry(String key, File file) {

        this.key = key;

        this.file = file;

    }


    public InputStream getInputStream() throws IOException {

        return new BufferedInputStream(new FileInputStream(file));

    }


    public String getKey() {

        return key;

    }


    public File getFile() {

        return file;

    }


}


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. andu 2013.05.15 17:52 신고  댓글주소  수정/삭제  댓글쓰기

    FileEntry 클래스가 없네요...

  2. andu 2013.05.16 14:05 신고  댓글주소  수정/삭제  댓글쓰기

    FileCacheFactory의 initialize() 정의는 initialize(Context context)인 반면,
    사용법에서는 FileCacheFactory.initialize()로 호출을 하고 있는데,
    이것은 어떤 차이가..

    • 최범균 madvirus 2013.05.19 18:13 신고  댓글주소  수정/삭제

      오류네요... 원본 사용 코드를 그대로 넣지 못해서 복사하고 정리했는데, 그 과정에서 잘못 복사되었어요. FileCacheFactory.initialize(this); 로 수정했습니다.

  3. hong 2013.05.20 22:12 신고  댓글주소  수정/삭제  댓글쓰기

    FileCacheAleadyExistException
    해당 예외처리는 어찌해주셧어요 ??

    다이어그램을 찾아보고 소스부를 봐도 예외처리해준부분은 따로 없어서요 ;;

    익셉션 상속받아 해당 이름으로 예외 클래스 만들어줘서 일단 처리는 해놨지만

    예외처리 클래스 코드가 어떤지 알고싶어서요 부탁드리겠습니다.

    • 최범균 madvirus 2013.05.21 21:51 신고  댓글주소  수정/삭제

      익셉션 직접 만들고 try-catch로 처리해 주심 됩니다.
      FileCacheAleadyExistException 익셉션이 발생한다는 건
      이미 해당 캐시가 존재한다는 것이므로,
      catch 부분에 로그 정도를 남겨주시면 어떨까요?
      전 warn 레벨로 남겼어요.

조회수, 좋아요. 이 두 값은 전형적인 카운트이다. 글을 읽거나 좋아요 버튼을 누를 때 마다 값이 1씩 증가하는 특징을 갖는다. 예를 들어, 게시글의 경우 다음과 같이 '조회' 수와 '좋아요' 수를 갖는데, 이들 값은 사용자가 글을 조회하거나 '좋아요'를 할 때 마다 1씩 증가하게 된다.


public class Article {

    ...

    private int viewCount;

    private int likeCount;

    ...

}


카운트와 캐시


이제 서비스에 있어서 캐시는 기본이 된 듯 하다. 급증하는 트래픽을 장비로만 막는데는 한계가 있기 때문에, 캐시는 반드시 고려해야 하는 대상이다. 이 캐시를 적용할 때 개발자를 짜증나게 만드는 것이 있는데, 그것은 바로 카운트 방식의 값이다.


예를 들어, 게시판에서 하나의 게시글을 Article 이란 모델로 표현했다고 할 경우, 게시글은 읽기만 해도 증가하기 때문에 Article 및 Article의 List를 담고 있는 캐시는 글의 조회수가 증가될 때마다 캐시에서 제거되어야 한다. (또는 백엔드에서 다시 값을 읽어와 캐시에 넣어야 한다.) 조회수 증가가 크지 않은 사이트는 이렇게 하더라도 전혀 문제가 되지 않겠지만, 캐시를 도입하는 이유는 트래픽이 많아졌기 때문이다. 그런데, 트래픽이 많아졌다는 것은 특정 게시글에 대한 조회 요청이 많다는 것을 의미하고, 이는 조회수의 증가가 발생한다는 것을 의미한다. 조회수의 증가는 캐시에 보관된 게시글 정보가 더 이상 유효하지 않다는 것을 의미하므로 캐시에서 게시글 정보 또는 관련 게시글 목록 정보가 캐시에서 제거된다는 것을 뜻한다. (조회수에 민감하지 않다면 캐시에 보관된 데이터를 일정 시간 동안 사용할 수 있겠지만, 실제로 고객들은 조회수, 좋아요 등의 숫자에 매우 민감하기 때문에 제대로 반영해 주어야한다.) 캐시에서 제거되었으므로 결국 DB에서 다시 읽어와 캐시에 담게 된다. 하지만, 조회수가 증가하기 때문에 결국 다시 캐시에서 제거되고 DB에서 읽어와 캐시에 담는 과정이 반복될 것이다.


트래픽이 증가해서 캐시를 적용했는데, 카운트 증가 때문에 캐시 효과가 없어지는 어처구니 없는 상황이 발생하는 것이다. 사실, 게시글의 내용과 카운트 값은 변경의 빈번도가 완전히 다르다. 제목과 같은 내용은 변화 빈도가 낮은 반면에 카운트 값은 특정 기간 동안에 빈번도가 굉장히 높다.


카운트의 분리


따라서, 특정 컨텐츠에 대한 캐시 효과를 극대화하려면 카운트 류의 값을 별도로 분리해주어야 한다고 생각한다. 예를 들어, 게시글의 경우 다음과 같이 두 개의 모델로 분리한다.



즉, 내용을 담고 있는 Article과 해당 Article의 카운트 값을 갖는 ArticleCount로 구분할 수 있을 것이다. ArticleCount의 id는 Article과 동일한 id를 가질 것이다.


Article과 ArticleCount가 분리되었으니, 이 두 객체를 다루는 서비스도 분리된다.


public interface ArticleService {

    public Article getArticle(Long id);

}


public interface ArticleCountService {

    public ArticleCount getArticleCount(Long id);

    public void increaseViewCount(Long id);

}


조립하기


완전한 게시글 읽기 기능은 Article과 ArticleCount를 필요로 하므로, 다음과 같이 두 서비스를 사용해서 구현한다.


public class ReadArticleService {

    public ArticleDto readArticle(Long id) {

        Article article = articleService.getArticle(id);

        articleCountService.increaseViewCount(article.getId());

        ArticleCount count = articleCountService.getArticleCount(article.getId());

        return new ArticleDto(article, count);

    }

    ...

}


public class ArticleDto {

    private Article article;

    private ArticleCount count;


    public ArticleDto(Article article, ArticleCount count) {

        this.article = article;

        this.count = count;

    }


    public Long getTitle() {

        return article.getTitle();

    }

    public int getViewCount() {

        return count.getViewCount();

    }

    ...

}


AritcleDto는 Article과 ArticleCount로부터 값을 읽어오는 getTitle(), getViewCount() 등의 메서드를 제공한다.


캐시 분리 적용


이제 ArticleService의 getArticle()에 캐시 기능을 적용하자. 


public class ReadArticleService {

    public ArticleDto readArticle(Long id) {

        Article article = articleService.getArticle(id); // 캐시에서 읽어 옴

        articleCountService.increaseViewCount(article.getId()); // DB에서 반영

        ArticleCount count = articleCountService.getArticleCount(article.getId()); // DB에서 읽어 옴

        return new ArticleDto(article, count);

    }


ArticleService.getArticle()은 이제 캐시에서 Article 객체를 가져온다. 반면 ArticleCount는 항상 DB에서 가져온다. 이제 조회수와 같은 카운트 값이 빈번하게 변경하더라도 Article 객체가 캐시에서 제거되지 않으므로, Article에 대한 캐시 히트율이 높아질 것이다.


지금까지는 그냥 생각일 뿐, 실제로 조회수가 급격하게 증가하는 상황에서 성능이 어떻게 될지는 아직 테스트 해 보지 못했다. 이건 나중에 기회가 되면 한 번 해 보기로 하자.

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. bluepoet 2012.11.29 16:12 신고  댓글주소  수정/삭제  댓글쓰기

    색다른 아이디어의 글 잘 읽었습니다.

    카운트에 대한 객체를 따로 빼내고, 글 정보 자체를 캐시한다음

    해당 글의 카운트 정보만 업데이트하고 그 정보만 DB에서 읽어온다면

    DB 인덱스 설계에 따라 달라지긴 하겠지만, Article에 대한 글정보를 업데이트하고

    가져오는 것보단 비용이 덜하지 않을까 생각됩니다.

    다만, 리스트를 뿌릴 때 Article 하나에서 가져오는 것과

    Article과 ArticleCount를 조인해서 가져오는 것과의 비용은 잘 따져서

    선택해야 될 것 같습니다.

    스트레스 테스트 하시면 결과도 공유해주시면 좋을 것 같네요

EHCache를 이용한 기본적인 캐시 구현 방법 및 분산 캐시 구현 방법을 살펴본다.

EHCache의 주요 특징 및 기본 사용법

게시판이나 블로그 등 웹 기반의 어플리케이션은 최근에 사용된 데이터가 또 다시 사용되는 경향을 갖고 있다. 80:20 법칙에 따라 20%의 데이터가 전체 조회 건수의 80%를 차지할 경우 캐시를 사용함으로써 성능을 대폭적으로 향상시킬 수 있을 것이다.

본 글에서는 캐시 엔진 중의 하나인 EHCache의 사용방법을 살펴보고, Gaia 시스템에서 EHCache를 어떻게 사용했는 지 살펴보도록 하겠다.

EHCache의 주요 특징

EHCache의 주요 특징은 다음과 같다.

  • 경량의 빠른 캐시 엔진
  • 확장(scable) - 메모리 & 디스크 저장 지원, 멀티 CPU의 동시 접근에 튜닝
  • 분산 지원 - 동기/비동기 복사, 피어(peer) 자동 발견
  • 높은 품질 - Hibernate, Confluence, Spring 등에서 사용되고 있으며, Gaia 컴포넌트에서도 EHCache를 사용하여 캐시를 구현하였다.
기본 사용법

EHCache를 사용하기 위해서는 다음과 같은 작업이 필요하다.

  1. EHCache 설치
  2. 캐시 설정 파일 작성
  3. CacheManager 생성
  4. CacheManager로부터 구한 Cache를 이용한 CRUD 작업 수행
  5. CacheManager의 종료
EHCache 설치

EHCache 배포판은 http://ehcache.sourceforge.net/ 사이트에 다운로드 받을 수 있다. 배포판의 압축을 푼 뒤, ehcache-1.2.x.jar 파일이 생성되는 데, 이 파일을 클래스패스에 추가해준다. 또한, EHCache는 자카르타의 commons-logging API를 사용하므로, commons-logging과 관련된 jar 파일을 클래스패스에 추가해주어야 한다.

ehcache.xml 파일

EHCache는 기본적으로 클래스패스에 존재하는 ehcache.xml 파일로부터 설정 파일을 로딩한다. 가장 간단한 ehcache.xml 파일은 다음과 같이 작성할 수 있다.

<ehcache>
    <diskStore path="java.io.tmpdir"/>

    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            overflowToDisk="true"
            maxElementsOnDisk="10000000"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"
            memoryStoreEvictionPolicy="LRU"
            />
    
    <cache name="simpleBeanCache"
            maxElementsInMemory="10"
            eternal="false"
            overflowToDisk="false"
            timeToIdleSeconds="300"
            timeToLiveSeconds="600"
            memoryStoreEvictionPolicy="LRU" />

</ehcache>

위 코드에서 <defaultCache> 태그는 반드시 존재해야 하는 태그로서, 코드에서 캐시를 직접 생성할 때 사용되는 캐시의 기본 설정값을 저장한다. <cache> 태그는 하나의 캐시를 지정할 때 사용된다. name 속성은 캐시의 이름을 지정하며, 코드에서는 이 캐시의 이름을 사용하여 사용할 Cache 인스턴스를 구한다.

설정 파일에 대한 자세한 내용은 뒤에서 살펴보기로 하자.

CacheManager 생성

ehcache.xml 파일을 작성했다면 그 다음으로 할 작업은 net.sf.ehcache.CacheManager 객체를 생성하는 것이다. CacheManager 객체는 다음의 두 가지 방법 중 한가지 방식을 사용하여 생성할 수 있다.

  • CacheManager.create() : 싱글톤 인스턴스 사용
  • new CacheManager() : 새로운 CacheManager 인스턴스 생성
CacheManager.create() 메소드는 싱글톤 인스턴스를 생성하기 때문에 최초에 한번 호출될 때에만 CacheManager의 초기화 작업이 수행되며, 이후에는 동일한 CacheManager 인스턴스를 리턴하게 된다. 아래는 CacheManager.create() 메소드의 사용 예이다.

CacheManager cacheManager = CacheManager.create();

싱글톤 인스턴스가 아닌 직접 CacheManager 객체를 조작하려면 다음과 같이 new를 사용하여 CacheManager 인스턴스를 생성해주면 된다.

CacheManager cacheManager = new CacheManager();

두 방식 모두 클래스패스에 위치한 ehcache.xml 파일로부터 캐시 설정 정보를 로딩한다.

만약 클래스패스에 위치한 ehcache.xml 파일이 아닌 다른 설정 파일을 사용하고 싶다면 다음과 같이 URL, InputStream, 또는 String(경로) 객체를 사용하여 설정 파일의 위치를 지정할 수 있다.

URL configFile = this.getClass().getResource("/ehcache_config_replicate.xml")
CacheManager cacheManager = new CacheManager(configFile);

Cache에 CRUD 수행

CacheManager 인스턴스를 생성한 다음에는 CacheManager 인스턴스로부터 Cache 인스턴스를 구하고, Cache 인스턴스를 사용하여 객체에 대한 캐시 작업을 수행할 수 있게 된다.

Cache 구하기
net.sf.ehcache.Cache 인스턴스는 CacheManager.getCache() 메소드를 사용하여 구할 수 있다.

CacheManager cacheManager = new CacheManager(configFileURL);
Cache cache = cacheManager.getCache("simpleBeanCache");

CacheManager.getCache() 메소드에 전달되는 파라미터는 ehcache.xml 설정 파일에서 <cache> 태그의 name 속성에 명시한 캐시의 이름을 의미한다. 지정한 이름의 Cache 인스턴스가 존재하지 않을 경우 CacheManager.getCache() 메소드는 null을 리턴한다.

Create/Update 작업 수행
Cache 인스턴스를 구한 다음에는 Cache.put() 메소드를 사용하여 캐시에 객체를 저장할 수 있다. 아래 코드는 Cache.put() 메소드의 사용예이다.

Cache cache = cacheManager.getCache("simpleBeanCache");

SimpleBean newBean = new SimpleBean(id, name);
Element newElement = new Element(newBean.getId(), newBean);
cache.put(newElement);

Cache.put() 메소드는 net.sf.ehcache.Element 객체를 전달받는다. Element 클래스는 캐시에 저장될 원소를 나타내며, 키와 값을 사용하여 원소를 표현한다. Element 객체를 생성할 때 첫번째 파라미터는 원소의 키를 의미하며, 두번째 파라미터는 원소의 값을 의미한다.

EHCache는 캐시에 저장될 각각의 객체들을 키를 사용하여 구분하기 때문에, Element 객체를 생성할 때 (의미상) 서로 다른 객체는 서로 다른 키를 사용해야 한다.

Map과 마찬가지로 EHCache가 제공하는 Cache는 삽입을 하거나 기존의 값을 수정할 때 모두 Cache.put() 메소드를 사용한다. 기존에 캐시에 저장된 객체를 수정하길 원한다면 다음과 같이 동일한 키를 사용하는 Element 객체를 Cache.put() 메소드에 전달해주면 된다.

Element newElement = new Element(id, someBean);
cache.put(newElement);
...
Element updatedElement = new Element(id, updatedBean);
cache.put(updatedElement);

Read 작업 수행
Cache에 보관된 객체를 사용하려면 Cache.get() 메소드를 사용하면 된다. Cache.get() 메소드는 키를 파라미터로 전달받으며, 키에 해당하는 Element 객체를 리턴하며 관련 Element과 존재하지 않을 경우 null을 리턴한다. 아래 코드는 Cache.get() 메소드의 사용예이다.

Element element = cache.get(key);
SimpleBean bean = (SimpleBean) element.getValue();

Element.getValue() 메소드는 캐시에 저장된 객체를 리턴한다. 만약 Serializable 하지 않은 객체를 값으로 저장했다면 다음과 같이 Element.getObejectValue() 메소드를 사용하여 값을 구해야 한다.

Element element = cache.get(key);
NonSerializableBean bean = (NonSerializableBean) element.getObjectValue();

Delete 작업 수행
Cache에 보관된 객체를 삭제하려면 Cache.remove() 메소드를 사용하면 된다. 아래 코드는 Cache.remove() 메소드의 사용예이다.

boolean deleted = cache.remove(key);

Cache.remove() 메소드는 키에 해당하는 객체가 존재하여 삭제한 경우 true를 리턴하고, 존재하지 않은 경우 false를 리턴한다.

CacheManager의 종료

사용이 종료된 CacheManager는 다음과 같이 shutdown() 메소드를 호출하여 CacheManager를 종료해야 한다.

cacheManager.shutdown();

Cache 값 객체 사용시 주의사항

캐시에 저장되는 객체는 레퍼런스가 저장된다. 따라서, 동일한 키에 대해 Cache.put()에 전달한 Element의 값과Cache.get()으로 구한 Element의 값은 동일한 객체를 참조하게 된다.

SimpleBean bean = ...;
Element element = new Element(key, bean);
cache.put(element);

Element elementFromCache = cache.get(key);
SimpleBean beanFromCache = (SimpleBean)elementFromCache.getValue();

(bean == beanFromCache); // true
(element == elementFromCache); // false

위 코드에서 Cache.put()에 전달된 element 객체와 Cache.get()으로 구한 elementFromCache 객체는 서로 다른 객체이다. 하지만, 두 Element 객체가 갖고 있는 값은 동일한 객체를 참조하고 있다. 따라서, 캐시에 값으로 저장된 객체를 변경하게 되면 캐시에 저장된 내용도 변경되므로, 캐시 사용시 이 점에 유의해야 한다.

캐시 설정

캐시 설정 파일에 <cache> 태그를 이용하여 캐시를 설정했었다. 캐시 설정과 관련하여 <cache> 태그는 다양한 속성을 제공하고 있는데, 이들 속성에는 다음과 같은 것들이 존재한다.

name 캐시의 이름 필수
maxElementsInMemory 메모리에 저장될 수 있는 객체의 최대 개수 필수
eternal 이 값이 true이면 timeout 관련 설정은 무시되고, Element가 캐시에서 삭제되지 않는다. 필수
overflowToDisk 메모리에 저장된 객체 개수가 maxElementsInMemory에서 지정한 값에 다다를 경우 디스크에 오버플로우 되는 객체는 저장할 지의 여부를 지정한다. 필수
timeToIdleSeconds Element가 지정한 시간 동안 사용(조회)되지 않으면 캐시에서 제거된다. 이 값이 0인 경우 조회 관련 만료 시간을 지정하지 않는다. 기본값은 0이다. 선택
timeToLiveSeconds Element가 존재하는 시간. 이 시간이 지나면 캐시에서 제거된다. 이 시간이 0이면 만료 시간을 지정하지 않는다. 기본값은 0이다. 선택
diskPersistent VM이 재 가동할 때 디스크 저장소에 캐싱된 객체를 저장할지의 여부를 지정한다. 기본값은 false이다. 선택
diskExpiryThreadIntervalSeconds Disk Expiry 쓰레드의 수행 시간 간격을 초 단위로 지정한다. 기본값은 120 이다. 선택
memoryStoreEvictionPolicy 객체의 개수가 maxElementsInMemory에 도달했을 때,모메리에서 객체를 어떻게 제거할 지에 대한 정책을 지정한다. 기본값은 LRU이다. FIFO와 LFU도 지정할 수 있다. 선택

아래 코드는 몇 가지 설정 예이다.

<!--
sampleCache1 캐시. 최대 10000개의 객체를 저장할 수 있으며, 
5분 이상 사용되지 않거나 또는 10분 이상 캐시에 저장되어 있을 경우 
캐시에서 제거된다. 저장되는 객체가 10000개를 넘길 경우, 
디스크 캐시에 저장한다.
-->
<cache name="sampleCache1"
       maxElementsInMemory="10000"
       maxElementsOnDisk="1000"
       eternal="false"
       overflowToDisk="true"
       timeToIdleSeconds="300"
       timeToLiveSeconds="600"
       memoryStoreEvictionPolicy="LFU"
       />

<!--
sampleCache2 캐시. 최대 1000개의 객체를 저장한다. 
오버플로우 된 객체를 디스크에 저장하지 않기 때문에 
캐시에 최대 개수는 1000개이다. eternal이 true 이므로, 
timeToLiveSeconds와 timeToIdleSeconds 값은 무시된다.
-->
<cache name="sampleCache2"
       maxElementsInMemory="1000"
       eternal="true"
       overflowToDisk="false"
       memoryStoreEvictionPolicy="FIFO"
       />

<!--
sampleCache3 캐시. 오버플로우 되는 객체를 디스크에 저장한다.
디스크에 저장된 객체는 VM이 재가동할 때 다시 캐시로 로딩된다.
디스크 유효성 검사 쓰레드는 10분 간격으로 수행된다.
-->
<cache name="sampleCache3"
       maxElementsInMemory="500"
       eternal="false"
       overflowToDisk="true"
       timeToIdleSeconds="300"
       timeToLiveSeconds="600"
       diskPersistent="true"
       diskExpiryThreadIntervalSeconds="600"
       memoryStoreEvictionPolicy="LFU"
       />

분산 캐시

EHCache는 분산 캐시를 지원한다. EHCache는 피어(peer) 자동 발견 및 RMI를 이용한 클러스터간 데이터 전송의 신뢰성 등 분산 캐시를 위한 완전한 기능을 제공하고 있다. 또한, 다양한 옵션을 통해 분산 상황에 맞게 설정할 수 있도록 하고 있다.

참고로, EHCache는 RMI를 이용하여 분산 캐시를 구현하고 있기 때문에, Serializable 한 객체만 분산 캐시에서 사용 가능하다. 키 역시 Serializable 해야 한다.

분산 캐시 구현 방식

EHCache는 한 노드의 캐시에 변화가 생기면 나머지 노드에 그 변경 내용을 전달하는 방식을 사용한다. 즉, 클러스터에 있는 캐시 인스턴스가 n개인 경우, 한번의 변경에 대해 n-1개의 변경 통지가 발생한다.

각 노드의 캐시간 데이터 전송은 RMI를 통해서 이루어진다. EHCache가 데이터 전송 기술로서 RMI를 사용하는 이유는 다음과 같다.

  • 자바에서 기본적으로 제공하는 원격 메커니즘
  • 안정화된 기술
  • TCP 소켓 옵션을 튜닝할 수 있음
  • Serializable 한 객체를 지원하기 때문에, 데이터 전송을 위해 XML과 같은 별도의 포맷으로 변경할 필요가 없음
노드 발견

EHCache는 클러스터에 새로운 노드가 추가돌 경우 해당 노드를 자동적으로 발견하는 방식과, 지정된 노드 목록에 대해서만 클러스터의 노드로 사용하는 방식을 지원하고 있다.

멀티캐스트 방식

멀티캐스트 모드를 사용한 경우, 지정한 멀티캐스트 IP(224.0.0.1~239.255.255.255)와 포트에 참여하는 노드를 자동으로 발견하게 된다. 지정한 IP와 포트에 참여한 노드는 자기 자신을 다른 노드에 통지한다. 이 방식을 사용하면 클러스터에 동적으로 노드를 추가하거나 제거할 수 있다.

노드 목록 지정 방식

클러스터에 포함되는 노드 목록을 지정한다. 동적으로 새로운 노드를 추가하거나 기존 노드를 제거할 수 없다.

분산 캐시 설정

분산 캐시를 사용하기 위해서는 다음과 같은 세 개의 정보를 지정해주어야 한다.

  • CacheManagerPeerProvider - 피어 발견 관련 설정
  • CacheManagerPeerListener - 메시지 수신 관련 설정
  • 캐시별 CacheReplicator - 메시지 생성 규칙 설정
CacheManagerPeerProvider 설정

CacheManagerPeerProvider는 새롭게 추가된 노드를 발견하는 방식을 지정한다.

노드를 자동으로 발견하는 멀티캐스트 방식을 사용하려면 다음과 같이 설정한다.

<cacheManagerPeerProviderFactory
    class="net.sf.ehcache.distribution.RMICacheManagerPeerProviderFactory"
    properties="peerDiscovery=automatic, 
                    multicastGroupAddress=230.0.0.100, multicastGroupPort=1234" />

위 코드에서 properties 속성의 값에 사용된 프로퍼티는 다음과 같다.

peerDiscovery automatic으로 지정하면 멀티캐스트 방식을 사용한다.
multicaseGroupAddress 멀티캐스트 IP
multicaseGroupPort 포트 번호

하나의 클러스터에 포함될 노드들은 동일한 멀티캐스트 IP와 포트 번호를 사용해야 한다.

클러스터에 참여할 노드 목록을 지정하는 IP 방식을 사용하려면 다음과 같이 설정한다.

<cacheManagerPeerProviderFactory
    class="net.sf.ehcache.distribution.RMICacheManagerPeerProviderFactory"
    properties="peerDiscovery=manual, 
                    rmiUrls=//server2:12345/cache1|//server2:12345/cache2" />

위 코드에서 properties 속성의 값에 사용된 프로퍼티는 다음과 같다.

peerDiscovery manual로 지정한 IP 지정 방식이다.
rmiUrls 분산 노드에 참여할 서버 및 캐시 목록을 지정한다. 현재 노드의 정보는 포함시켜서는 안 된다.

이 경우, rmiUrls에 명시된 포트 번호는 뒤에 살펴볼 CacheManagerPeerListener가 사용할 포트 번호를 지정해주어야 한다.

CacheManagerPeerListener 설정

노드를 발견하는 방식을 지정했다면, 다음으로 할 작업은 클러스터에 있는 다른 노드에서 발생한 변경 정보를 수신할 때 사용할 포트 번호를 지정하는 것이다. 다음과 같은 코드를 이용하여 수신과 관련된 포트 번호를 설정할 수 있다.

<cacheManagerPeerListenerFactory
    class="net.sf.ehcache.distribution.RMICacheManagerPeerListenerFactory"
    properties="port=12345, socketTimeoutMillis=120000" />

위 코드에서 properties 속성의 값에 사용된 프로퍼티는 다음과 같다.

port 메시지를 수신할 때 사용되는 포트
socketTimeoutMillis 이 노드에 메시지를 보냈을 때 메시지 전송을 기다리는 시간. 기본값은 2000ms.

캐시별 CacheReplicator 설정

분산 환경에 적용되어야 하는 캐시는 캐시의 내용이 변경되었을 때 다른 노드에 있는 캐시에 변경 내역을 알려주어야 한다. <cacheEventListenerFactory> 태그를 사용하면, 언제 어떻게 캐시의 변경 내역을 통지할지의 여부를 지정할 수 있다. 아래 코드는 설정의 예이다.

<cache name="simpleBean"
      maxElementsInMemory="100"
      eternal="false"
      overflowToDisk="false"
      timeToIdleSeconds="300"
      timeToLiveSeconds="600"
      memoryStoreEvictionPolicy="LRU">
       <cacheEventListenerFactory 
           class="net.sf.ehcache.distribution.RMICacheReplicatorFactory" 
           properties="replicateUpdatesViaCopy=true,replicateUpdates=true" />
</cache>

위 코드와 같이 <cacheEventListenerFactory>의 구현 클래스로 RMICacheReplicatorFactory를 지정하면 캐시에 변경이 생길 때 마다 해당 변경 내역을 클러스터에 참여하고 있는 노드의 캐시에 통지하게 된다. properties 속성에 프로퍼티를 지정하면, 캐시 요소의 추가, 변경, 삭제 등에 대해 통지 방식을 적용할 수 있다. 설정할 수 있는 프로퍼티는 다음과 같다.

replicatePuts 캐시에 새로운 요소가 추가됐을 때 다른 노드에 복사할지의 여부
replicateUpdates 캐시 요소의 값이 변경되었을 때 다른 노드에 값을 복사할지의 여부
replicateRemovals 캐시 요소가 삭제되었을 때 다른 노드에 반영할지의 여부
replicateAsynchronously 비동기로 값을 복사할지의 여부
replicateUpdatesViaCopy 새로운 요소를 다른 노드에 복사할 지 아니면 삭제 메시지를 보낼지의 여부
asynchronousReplicationIntervalMillis 비동기 방식을 사용할 때 변경 내역을 다른 노드에 통지하는 주기. 기본값은 1000.

위 속성의 기본값은 모두 true이다. 따라서, 기본 설정값을 사용하려면 다음과 같이 properties 속성을 사용하지 않아도 된다.

<cache name="simpleBean" ...
      memoryStoreEvictionPolicy="LRU">
       <cacheEventListenerFactory 
           class="net.sf.ehcache.distribution.RMICacheReplicatorFactory" />
</cache>

어플리케이션 구동시 캐시 데이터 로딩하기

CacheManager가 초기화 될 때, 클러스터에 있는 다른 캐시로부터 데이터를 로딩할 수 있다. 이는 초기 구동이 완료된 후 곧 바로 서비스를 제공할 수 있음을 의미한다. 초기 구동시 다른 노드로부터 캐시 데이터를 로딩하려면 다음과 같이 <bootstrapCacheLoaderFactory> 태그의 구현 클래스를 RMIBootstrapCacheLoaderFactory로 지정해주면 된다.

<cache name="simpleBean" ...
      memoryStoreEvictionPolicy="LRU">
       <bootstrapCacheLoaderFactory
           class="net.sf.ehcache.distribution.RMIBootstrapCacheLoaderFactory"
           properties="bootstrapAsynchronously=true,
                       maximumChunkSizeBytes=5000000" />

       <cacheEventListenerFactory 
           class="net.sf.ehcache.distribution.RMICacheReplicatorFactory" />
</cache>

RMIBootstrapCacheLoaderFactory에 전달 가능한 프로퍼티 목록은 다음과 같다.

bootstrapAsynchronously 비동기적으로 수행할지의 여부를 지정
maximumChunkSizeBytes 클러스터의 다른 노드로부터 로딩 가능한 데이터의 최대 크기

RMIBoostrapCacheLoaderFactory를 설정하면 캐시를 초기화 할 때, 원격지 노드의 캐시에 저장된 데이터를 로딩하여 로컬 캐시에 저장한다.

분산 캐시 고려사항

분산 캐시를 사용할 때에는 다음과 같은 내용을 고려해야 한다.

  • 노드 증가에 따라 네트워크 트래픽 증가:
    많은 양의 네트워크 트래픽이 발생할 수 있다. 특히 동기 모드인 경우 성능에 영향을 받을 수 있다. 비동기 모드인 경우 버퍼에 변경 내역을 저장하였다가 일정한 주기로 버퍼에 쌓인 내역을 다른 노드에 통지하기 때문에 이 문제를 다소 완하시킬 수 있다.
  • 데이터 불일치 발생 가능성:
    두 노드에서 동시에 동일한 캐시의 동일한 데이터에 대한 변경을 수행할 경우, 두 노드 사이에 데이터 불일치가 발생할 수 있다. 캐시 데이터의 불일치가 매우 심각한 문제가 될 경우, 동기 모드(replicateAsynchronously=false)와 복사 메시지 대신 삭제 메시지를 전송(replicateUpdatesViaCopy=false)함으로써 이 문제를 해결할 수 있다.
관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 레온이 2009.06.14 12:19 신고  댓글주소  수정/삭제  댓글쓰기

    항상 좋은글 잘 읽고갑니다 ^ ^

  2. 2010.07.07 13:26  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  3. 나뿐남자 2011.03.24 10:15 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 감사합니다.
    제블로그에 퍼갈께요.

  4. 허니몬 2013.08.27 14:01 신고  댓글주소  수정/삭제  댓글쓰기

    마침 EHCache에 대한 설명글을 찾고 있었는데,
    깔끔히 정리된 글 잘보고갑니다. ^^

  5. dial 2014.02.20 11:57 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 감사하고,
    제 블로그에 펌좀 해가겠습니다 ㅎㅎ
    문제시 삭제하겠습니다.

  6. 한발 2015.04.09 11:19 신고  댓글주소  수정/삭제  댓글쓰기

    우연히 검색해서 들어왔는데...학생시절 봤던 ajax, jsp 서적의 저자시군요~
    좋은글 감사합니다. 저도 블로그에 펌좀 해가겠습니다. 문제될 경우 삭제하겠습니다.

  7. 해외감자 2017.07.20 13:41 신고  댓글주소  수정/삭제  댓글쓰기

    정리가 정말 잘 되어 있어서 많은 도움이 되었습니다
    정말 감사합니다 ^-^

  8. 자바맨 2018.10.12 23:17 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 잘 읽고 갑니다

객체를 캐싱함으로써 객체 생성과 관련된 성능을 향상시킬 수 있다.

캐싱(Caching)

성능을 향상시키기 위해서 많이 사용되는 방법을 말해보라고 하면 대부분 객체 풀링을 말한다. 특히 데이터베이스 커넥션 풀이나 쓰레드 풀과 같은 것들이 일반 개발자들에게 보편화되면서 객체 풀링은 성능 향상을 위해서 반드시 사용해야 하는 기술로서 여겨지기도 한다. 하지만, 객체 풀링 못지 않게 성능을 향상시킬 수 있는 방법이 있다. 바로 객체 캐싱이다. 객체 캐싱은 기존에 사용된 정보를 메모리에 저장해두었다가 다시 참조될 때 사용하는 기술로서 메모리 캐싱 기술과 그 근본 원리가 같다. 이번 글에서는 객체 캐싱을 구현하는 것에 대해 알아보며, 실제 테스트를 통해서 캐시를 사용했을 때와 사용하지 않았을 때의 성능상의 차이점을 알아볼 것이다. 또한, 객체 캐싱과 객체 풀링의 차이점에 대해서도 간단하게 살펴보도록 하겠다.

객체 캐싱

객체를 캐싱하는 기본 목적은 생성하는 데 (컴퓨터의 입장에서) 많은 시간이 소비되는 객체를 사용하고 난 뒤에 메모리에서 삭제하는 것이 아니라 메모리에 저장해두었다가 다시 그 객체가 사용될 때 메모리에서 바로 읽어옴으로써 객체를 생성하는 데 소비되는 시간을 줄이는 것에 있다.

예를 들어, 카탈로그에 있는 제품 정보를 보여주는 어플리케이션이 있다고 해 보자. 카탈로그에 있는 제품 정보를 보여주기 위해서는 데이터베이스에 연결해야 하며, SQL 문장을 실행해야 하고, SQL 문장의 실행 결과로부터 정보를 추출해서 알맞은 객체를 생성해야 한다. 즉, 누군가가 A 라는 제품 정보를 보길 원한다고 할 경우 '데이터베이스연결->SQL문장실행->정보추출->객체생성' 이라는 과정을 거쳐야 하며 또 누군가가 A 라는 제품 정보를 보길 원할 경우 다시 한번 이 과정을 거쳐야 한다. 이때, 일단 생성된 객체를 메모리에 저장한다고 해 보자. 이 경우 누군가가 메모리에 이미 저장되어 있는 제품 정보를 조회하고자 할 경우에는 '데이터베이스연결->SQL문장실행->정보추출->객체생성'이라는 과정을 거칠 필요 없이 메모리에 이미 저장되어 있는 객체를 사용한다면 엄청난 시간이 절약될 것이다. 이것이 바로 객체 캐싱의 기본 개념이다.

그림을 통해서 좀더 구체적으로 살펴보자. 그림1은 객체가 캐싱되는 과정을 그림으로 표현한 것이다. 여기서 [Content Manager] 객체는 요청한 컨텐츠 정보를 저장하고 있는 객체를 리턴해주는 역할을 맡고 있다고 가정한다.

그림1 - 객체의 캐싱 과정

위 그림에서 1단계와 2단계를 살펴보자. [Content Manager]는 객체 생성 요청을 받을 경우(1) 먼저 캐시에 요청한 객체가 존재하는 지 확인한다.(2) 캐시에 요청한 객체가 존재하지 않는다면 객체 생성을 위해 필요한 정보를 추출한 후(3) 객체를 생성한다.(4) 그 다음에 생성한 객체를 캐시에 저장하고(5) 마지막으로 [Content Manager]는 생성한 객체를 리턴한다.(6)

일단 캐싱된 객체를 누군가가 요구할 경우, [Content Manager]는 그림1의 3단계 과정에서 볼 수 있듯이 캐시를 조회한 후 바로 캐시에 저장되어 있는 객체를 리턴해준다. 즉, 객체를 생성하기 위해 필요한 정보를 추출해내는 과정과 객체를 생성하는 과정이 생략되는 것을 알 수 있다. 이는 앞에서도 말했듯이 객체를 생성하는 데 소비되는 시간을 줄여주는 효과가 있다.

캐시 관리(Cache Management)

캐시와 관련해서 제한 사항이 있다면 그것은 바로 메모리의 크기가 무한하지 않다는 점에 있다. 모든 객체를 메모리에 저장하고 싶겠지만 메모리의 크기는 제한되어 있고 따라서 모든 객체를 메모리에 저장할 수는 없다. 또한 캐시로 사용하는 메모리의 크기가 너무 커서 실제 어플리케이션이 메모리를 사용하는 데 제한을 받으면 안 된다. 즉, 캐시로 사용할 메모리의 크기는 어플리케이션을 실행하는 데 영향을 주지 않을 정도로 작아야 한다.

객체를 캐싱하는 데 사용할 수 있는 메모리의 크기가 제한되어 있기 때문에 필요한 것이 바로 캐시에 저장할 객체를 선택하는 기준이다. 예를 들어, 고객들 대부분이 최신의 제품 정보를 보길 원한다고 해 보자. 이 경우 한 달 전에 캐시에 저장되어 있는 객체는 거의 사용되지 않을 것이며, 최근에 생성한 객체가 주로 사용될 것이다. 여기서 문제가 발생한다. 거의 사용되지 않는 객체가 캐시에 저장되어 있어서 최근 정보를 저장하고 있는 객체들이 더 이상 캐시에 저장될 수 없는 상황이 발생하는 것이다. 이는 모두 캐시로 사용될 메모리의 크기 제한에서 비롯된다.

그렇다면 이에 대한 해결방안은 무엇일까? 그것은 바로 잘 사용되지 않는 객체들은 캐시 메모리에서 삭제하고 주로 사용되는 객체를 캐시 메모리에 넣는 것이다. 이처럼 캐시에 참조될 가능성이 많은 객체들이 들어가도록 관리하는 것을 캐시 관리(Cache Management)라고 한다.

캐시를 관리하는 방법에는 여러 가지가 있으나, 일반적으로 많이 사용되는 방법은 LRU(Least Recently Used)이다. LRU는 가장 최근에 사용된 것을 캐시로 사용하는 메모리의 가장 앞쪽에 위치시키는 방법이다. 이를 알아보기 위해 다음 그림을 살펴보자.

그림2 - LRU의 캐시 관리

위 그림은 캐시에 있는 A라는 객체를 누군가가 요청했을 때를 나타내는 것으로 그림1의 Step3의 과정8을 나타낸다. 누군가가 캐시에 이미 존재하고 있는 A 라는 객체를 요청할 경우 LRU 방식의 캐시에서는 위 그림과 같이 참조된 객체를 메모리의 가장 앞으로 보내며, 나머지 객체들은 뒤로 한칸씩 이동하게 된다. 캐시에 새로운 객체를 캐싱할 때는 메모리의 가장 앞에 넣는다. 이러한 과정에서 지속적으로 사용되는 객체는 항상 메모리의 앞쪽에 위치하게 되고, 그렇지 않은 객체는 자연적으로 메모리의 뒤쪽으로 이동하게 되며 결국에는 캐시에 삭제된다.

이렇게 자주 사용되는 객체를 앞쪽에 위치시키고 그렇지 않은 객체를 뒤쪽에 위치시키는 것은 캐시의 효율을 향상시키기 위한 것이다. 예를 들어, 게시판 데이터를 저장하고 있는 캐싱을 생각해보자. 사람들은 보통 게시판의 첫 페이지에 있는 글을 주로 읽으며 오래된 글은 좀처럼 읽지 않는다. 따라서 최신의 게시판 글을 저장하고 있는 객체를 캐시에 넣어둘 경우 그 만큼 시간에 대한 성능 향상이 발생하는 것이다. 실제로 가장 최근에 참조된 객체를 캐시에 지속적으로 저장하는 방식이 얼마나 효과적인지에 대해서는 테스트를 통해서 수치적으로 살펴볼 것이다.

객체 캐싱의 구현

객체 캐싱을 구현하기 위해서는 캐시의 역할을 할 클래스가 필요하다. 이 클래스는 내부적으로 객체를 저장할 공간을 갖고 있어야 하며 또한 LRU와 같은 방법을 사용하여 알맞게 캐시를 관리해야 한다. 여기서는 실제 예를 통해서 객체 캐싱을 구현해보자. 이 글에서 사용할 예는 컨텐츠와 관련된 것이며, 다음은 예제에서 사용되는 각 클래스들의 관계를 UML로 표시한 것이다.

그림3 - 예제에서 사용되는 클래스간의 관계

그림3에서 클라이언트는 ContentManager 클래스를 통해서 Content 객체에 접근하게 된다. ContentCache 클래스는 Content 클래스의 객체를 캐싱하는 역할을 맡고 있으며 ContentFetcher는 DB로부터 정보를 읽어와 Content 객체를 생성하는 역할을 한다. Content 객체를 캐싱하는 과정은 다음 그림과 같다.

그림4 - 캐싱 과정

위 그림에서 클라이언트(임의의 모든 객체가 클라이언트가 될 수 있다)는 ContentManager의 getContent() 메소드를 호출하여 Content 객체를 구하게 된다. 이때 getContent() 메소드에 파라미터로 전달되는 Integer 객체는 각각의 Content를 구분할 때 사용되는 키(key)값이다. ContentManager의 getContent() 메소드는 ContentCache.fetchContent() 메소드를 사용하여 필요한 객체가 캐싱되어 있는 지 확인한다. ContentCache.fetchContent() 메소드는 요청한 객체가 캐싱되어 있지 않을 경우 null을 리턴하고, 캐싱되어 있을 경우 캐싱되어 있는 객체를 리턴한다. ContentCache.getchContent() 메소드가 null을 리턴할 경우 ContentManager는 ContentFetcher.fetchContent()를 사용하여 필요한 객체를 읽어오고 그 객체를 ContentCache.cacheContent()를 사용하여 캐싱한 후 클라이언트에 리턴해준다.

실제로 예제에서 사용되는 ContentManager의 getContent() 메소드는 다음과 같다.

   public Content getContent(Integer contentKey) throws ContentFetcherException {
      if (useCache) {
         Content content = cache.fetchContent(contentKey); // 캐시 검사
         if (content == null) {
            // 캐시에 존재하지 않을 경우 ContentFetcher로부터
            // Content 객체 읽어온 후에 캐싱한다.
            content = fetcher.fetchContent(contentKey);
            cache.cacheContent(content);         }
         return content;
      } else {
         return fetcher.fetchContent(contentKey);
      }
   }

여기서 useCache는 캐시를 사용할지의 여부를 나타내는 값이다.

ContentCache 클래스의 소스 코드

객체 캐싱에서 가장 핵심이 되는 부분은 역시 ContentCache 클래스이다. 먼저 ContentCache 클래스의 완전한 소스 코드부터 살펴보도록 하자.

  package com.javacan.content;
  
  import java.util.HashMap;
  import java.util.ArrayList;
  
  public class ContentCache {
     
     public static final int DEFAULT_CACHE_SIZE = 30;
     
     private HashMap keyMap;
     private ArrayList cacheList;
     private int cacheSize;
     
     private int totalCount; // 전체 fetchContent 호출 회수
     private int hitCount; // hit 회수
     
     public ContentCache(int cacheSize) {
         keyMap = new HashMap();
         cacheList = new ArrayList(cacheSize);
         this.cacheSize = cacheSize;
     }
     
     public Content fetchContent(Integer contentKey) {
        totalCount ++;
        // 지정한 컨텐츠가 없을 경우 null을 리턴
        Content content = (Content)keyMap.get(contentKey);
        if (content != null) {
           // LRU에 따라 가장 앞으로 이동
           hitCount ++;
           cacheList.remove(content);
           cacheList.add(0, content);
        }
        return content;
     }
     
     public void cacheContent(Content content) {
        // 새로운 Content와 같은 키값을 갖는 content가 존재하는 지 확인
        Content oldContent = (Content)keyMap.get(content.getKey());
        if (oldContent != null) {
           // 존재한다면 기존 캐시 리스트 에서 content를 삭제한다.
           cacheList.remove(oldContent);
        } else {
           // 존재하지 않는다면 현재 캐시의 크기가 지정한 크기와
           // 같은 지 검사한다. 그래서 같다면 가장 cacheList의 가장
           // 마지막에 있는 걸 삭제한다.
           if (cacheSize == cacheList.size()) {
              Content lastContent = (Content)cacheList.remove(cacheSize-1);
              keyMap.remove(lastContent.getKey());
           }
        }
        // 가장 처음에 삽입한다.
        keyMap.put(content.getKey(), content);
        cacheList.add(0, content);
     }
     
     public double getHitRate() {
        return (double)hitCount / (double)totalCount;
     }
     public int getCacheSize() {
        return cacheSize;
     }
  }

위 코드에서 실제 캐싱을 위한 메모리 역할을 하는 것은 ArrayList인 cacheList이다. fetchContent() 메소드와 cacheContent() 메소드를 보면 cacheList에 저장된 객체의 순서를 LRU에 따라 알맞게 변경하는 것을 알 수 있다. 또한 히트율(hit rate)을 구하기 위해 fetchContent() 메소드가 호출된 회수와 히트 회수를 각각 totalCount 필드와 hitCount 필드에 기록한다. HashMap인 keyMap을 사용한 이유는 캐시에 객체가 저장되어 있는지의 여부를 좀더 빠르게 검색하기 위해서이다. 코드 자체는 크게 복잡하지 않으므로 자세한 설명은 하지 않겠다.

ContentCache 이외의 다른 코드는 관련 링크의 소스 코드를 참고하기 바란다. ContentFetcher 클래스는 실제로 데이터베이스와 연동되기 때문에 여러분이 직접 테스트하기 위해서는 알맞게 변경해야 한다.

캐싱의 성능 테스트

이 글에서는 먼저 테스트를 위해 다음과 같은 테이블을 생성하였으며, ContentFetcher.fetchContent() 메소드는 이 테이블로부터 정보를 읽어오도록 구현하였다.

테이블명: CONTENT
컬럼 Oracle 타입
SNUMBER NUMBER(38)
REGDATE DATE
TITLE VARCHAR2(100)
CONTENT VARCHAR2(2000)

테스트를 위한 데이터를 100개 삽입하였으며 이 100개의 데이터의 SNUMBER는 1부터 100이 되도록 하였다. 여기서는 다음과 같은 조건으로 테스트를 하였다.

  1. 캐시를 사용하는 상태에서 데이터를 1000번 조회하는 데 걸리는 시간을 기록한다.
  2. 캐시를 사용하지 않는 상태에서 데이터를 1000번 조회하는 데 걸리는 시간을 기록한다.
  3. 1과 2를 각각 10번씩 반복한다. (즉, 전체적으로 10000번 조회하는 결과가 된다.)
  4. 캐시 크기를 20, 30, 40, 50 으로 변경하면서 1-3을 테스트한다.
  5. 데이터를 조회할 때에는 인접한 번호를 선택하도록 한다.
여기서 5번은 전에 조회한 번호와 비교하여 +/- 10 이내에 있는 내용을 조회한다는 것을 의미한다. 즉, 현재 조회한 번호가 17일 경우 다음에는 7부터 27 사이에 있는 데이터를 조회한다는 것을 의미한다. 이렇게 함으로써 인접한 데이터를 선택하도록 하였고 이를 통해 LRU를 적용한 캐시가 얼마나 효율적인지 테스트해보았다. 테스트 코드는 소스 코드에 함께 첨부되어 있으니 살펴보기 바란다.

가장 먼저 살펴볼 테스트 결과는 데이터를 1000번 조회하는 데 걸린 시간이다. 1000번씩 연속적으로 10번을 조회하는데 걸린 시간의 변화추이를 그림5에 표시하였다.

그림5 - 캐시 크기에 따른 시간 비교

그림5의 결과에서 첫번째 특징은 캐시의 크기가 커질수록 1000번을 조회하는 데 적은 시간이 걸린다는 점이다. 특이한 점은 모든 경우에 있어서 처음 천번을 조회할 때 걸리는 시간이 상대적으로 길다는 점이다. 이는 데이터베이스에서 SQL 쿼리 결과를 캐싱하는 것과도 관련이 있는 것으로 보인다. 즉, 첫번째 이후부터는 데이터베이스에 캐싱되어 있는 결과를 읽어올 가능성이 커지기 때문에 전체적으로 첫번째 1000번에 비해서 상대적으로 적은 시간이 걸리는 것이다.

위 결과에서 또 다른 특징은 캐시를 사용하지 않는 경우 2번째부터 10번째까지의 처리 시간이 매우 일정한 반면에 캐시를 사용하는 경우 걸리는 시간이 일정 범위 내에서 가변적인 것을 알 수 있다. 또한, 어떤 경우에는 캐시 크기가 작을 때 오히려 시간이 더 적게 걸리는 경우도 있는 것을 알 수 있다. 이처럼 캐시를 사용할 때 처리하는 시간이 일정하지 않은 것은 랜덤하게 데이터를 읽어왔기 때문이다. 비록 인접한 번호를 읽어온다고 하지만 랜덤한 번호를 읽어오는 것이기 때문에 캐시에 저장되는 객체의 참조 가능성도 그만큼 랜덤해지는 것이다.

위의 테스트 결과에서 첫 번째 1000번을 조회하는 데 걸린 시간을 제외한 나머지 2-10의 시간의 평균을 내보면 다음 그림6과 같다.

그림6 - 평균적인 처리 시간

그림6을 보면 확실하게 캐시의 크기가 커질수록 처리 시간이 작아진다는 것을 알 수 있다. 이는 곧 인접한 데이터를 조회할 경우, 캐시의 크기가 커질수록 캐시에 있는 데이터를 읽어올 가능성이 커진다는 것을 의미한다. 이는 실제로 캐시의 크기가 커질수록 히트율이 높아진다는 것을 의미하는데 그림7은 캐시의 크기와 히트율이 어떤 상관관계를 갖는지 보여주고 있다.

그림7 - 캐시 크기에 따른 전체 걸린 시간과 히트율

그림7을 보면 캐시의 크기가 커질수록 히트율이 증가하는 것을 알 수 있다. 그림7에서 눈여겨 볼 점이 있다면 캐시 크기를 40에서 50으로 늘릴 경우 히트율의 증가 추세가 캐시 크기를 20에서 30으로 또는 30에서 40으로 늘릴 경우에 비해 작다는 점이다. 이는 캐시 크기의 크기에 비례하여 히트율이 증가하지는 않는다는 것을 보여주는 것이다. 실제로 알맞은 캐시 크기는 어플리케이션에 따라서 달라지며 적정 수준에서 캐시 크기를 지정하는 것이 좋다.

결론

이번 아티클에서는 객체 캐싱에 대한 내용과 객체 캐싱의 구현에 대해서 살펴보았으며 또한 객체 캐싱이 실제로 어느 정도의 성능 향상을 일으키는 지에 대해서도 살펴보았다. 테스트 결과를 통해서 여러분은 객체를 캐싱하는 것이 얼마나 큰 성능 향상 효과를 주는 지 알 수 있었을 것이다.

객체 캐싱 방법에는 LRU 기법이 아닌 일정 시간 동안 객체를 메모리에 캐싱하는 방법도 있는데 이 방법 역시 그 근본 원리는 LRU와 비슷하다 할 수 있다. 일정 시간 동안 객체를 캐싱하는 것에 대한 내용은 최근에 자바월드에 실린 'Develop a generic caching Service to improve performance'를 참고하기 바란다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 2017.05.04 16:22  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다