안드로이드 프로그래밍을 하다 보니까, 파일로 캐시를 구현해야 하는 경우가 발생했다. 다음은 파일 캐시가 필요한 경우의 예이다.
- 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 이다. 이 클래스가 가장 길고 복잡하므로 부분 부분 나눠서 구현 코드를 살펴보도록 하겠다.
생성 과정에서는 다음의 작업을 수행한다.
- 필드 초기화 (생성자)
- cacheDir: 캐시 경로를 보관하는 File
- maxBytesSize: 캐시 보관소의 최대 크기
- cacheFileMap: 캐시 파일 정보를 담을 Map
- 읽기/쓰기에 사용될 Lock 생성. 캐시 읽기와 쓰기가 동시에 진행되는 것을 방지하기 위한 Lock.
- currentBytesSize: 캐시 디렉토리에 보관된 전체 파일의 크기
- createCacheDirIfNotExists(): 캐시 디렉토리가 존재하지 않으면 생성한다.
- initializing(): 별도 쓰레도로 Initializer를 실행한다.
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을 리턴한다.
- createCacheDirIfNotExists()를 호출해서 캐시 디렉토리가 없으면 생성한다. 앱의 캐시 디렉토리를 삭제할 수 있기 때문에, 디렉토리 존재 여부를 확인해야 한다.
- createFile()로 캐시 파일 정보를 생성한다.
- copyProviderToFile()로 ByteProvider가 제공하는 내용을 파일에 쓴다. ByteProvider.writeTo() 메서드는 파라미터로 제공받은 OutputStream에 데이터를 쓴다.
- putToCacheMapAndCheckMaxThresold()를 실행해서 cacheFileMap에 캐시 파일 정보를 추가하고, 최대 크기를 초과했는지 확인한다.
- putFileToCacheMap() 메서드로 cacheFileMap에 캐시 파일 정보를 추가하고 현재 스토리지 크기(currentBytesSize) 값을 증가한다.
- checkMaxThresoldAndDeleteOldestWhenOverflow() 메서드를 호출해서, 캐시 디렉토리에 보관된 전체 파일의 크기가 지정한 최대 크기를 초과하면 오래된 파일을 삭제해서 전체 파일 크기를 유지한다.
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;
}
}