주요글: 도커 시작하기
반응형

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

  • 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;

    }


}


+ Recent posts