오늘은 자바(Java)에서 데이터 캐시(cache)를 직접 구현하는 방법을 정리해보고자 한다. 많이들 사용하는 Ehcache 같은 라이브러리도 있고 별도의 기계로 캐시 서버를 두는 방법도 있지만 캐시의 개념은 그리 어려운 게 아니며 특히 읽기 전용 캐시는 애플리케이션에서 간단하게 구현할 수 있다. “캐시! 어렵지 않아요~”

캐시의 개념

우리나라의 기업용 웹애플리케이션은 대부분 관계형 데이터베이스(RDBMS)를 백엔드에 두고 데이터 트랜잭션 위주로 운영되는 경우가 많다. 규모가 큰 사이트는 성능을 위해 애플리케이션 서버나 데이터베이스 서버를 이중화하는데 사용자가 많이 몰리면 결국 병목(bottleneck)이 되는 곳은 데이터베이스인 경우가 많다.

데이터베이스의 성능을 향상하는 방법이 여러가지가 있는데 가장 근본적인 방법은 데이터베이스 질의를 줄이는 것이라고 할 수 있다. 애플리케이션 입장에서 캐시는 데이터베이스의 데이터를 정적인 메모리에 보관해뒀다가 필요할 때 데이터베이스가 아니라 메모리에서 바로 꺼내 쓰면 되는 것이다.

그래서 캐시의 기본적인 형태는 아래 그림과 같이 데이터를 쌓아두고 원하는 데이터를 바로 찾을 수 있도록 키(key)를 부여하여 호출 측에서 해당 키를 캐시에 넘기면 그에 해당하는 데이터가 반환되게 하는 것이다. 키/값 형태의 데이터 구조는 대표적으로 해시테이블을 사용하면 바로 구현이 가능하다.

기본적인 키/값 형태의 캐시
기본적인 키/값 형태의 캐시

캐시에 들어갈 데이터는 자주 사용하면서도 변경은 자주 일어나지 않고(읽기는 많지만 쓰기는 적은 것) 또 그 데이터 양이 너무 크지 않은 것을 선택하는 것이 적합하다. 자주 사용하는 것을 캐싱할 수록 성능 향상에 도움이 될 것인데 양이 크다면 그 만큼 애플리케이션이 사용할 메모리를 소모하므로 메모리 성능에 악영향이 있을 수 있기 때문이다. 또 변경이 자주 일어나면 캐시나 데이터베이스를 그에 맞게 갱신해줘야 하므로 캐시와 데이터베이스간의 트랜잭션이 자주 발생하게 될 것이다.

그래서 캐싱에 적합한 데이터의 예를 들자면 기준 정보성 데이터가 있다(게시판이라면 게시판 설정, 쇼핑몰이라면 상품 카테고리, 또 그 밖에도 메뉴라든가 다양한 업무 코드들). 기준 정보성 데이터는 한번 셋업되면 자주 바뀌지 않고 또 그 개수가 많아야 몇 백개인 경우가 대부분이므로 메모리 부담이 크지 않으면서도 데이터베이스 질의 회수를 줄이는 데 상당한 도움이 될 수 있다.

자바에서의 캐시 구현

키/값 형태의 캐시를 구현하려면 기본적으로 다음과 같은 동작이 필요할 것이다.

이제 캐시 클래스를 설계하는 과정을 밟아보자. 이 글은 실용적으로 사용할 클래스를 위한 방법을 제시하려는 것이지만 과정을 다 보이고자 한다.

여기서는 예를 들어 상품 카테고리를 구현한다고 해보자. 이 상품 카테고리는 쇼핑몰에 한 세트만 있는 게 아니고 여러 세트가 있다고 한다. 쇼핑몰 메인 메뉴를 통해 보이는 기본적인 상품 카테고리도 있지만 판매자별로 서브몰이 있어서 이러한 서브몰마다 별도의 상품 카테고리가 관리된다. 방문자가 쇼핑몰의 거의 모든 웹페이지에 들어갈 때마다 상품 카테고리를 메뉴나 화면에 표시해야 할 텐데 이런 데이터를 매번 데이터베이스에서 읽어온다면 부담이 상당할 것이다. (실제로는 데이터베이스 자체에도 캐시 기능이 있는 등 무조건 데이터베이스에 100% 부담이 가는 것은 아니지만…)

아무튼 이런 상황을 가정했을 때 대략 다음과 같이 상품 카테고리에 대한 업무 로직을 처리하는 서비스 클래스의 인터페이스를 생각해볼 수 있다. 스프링 기반으로 개발하는 경우가 많아 그러한 방식으로 클래스를 생각해봤으나 기본 개념이 간단하므로 다른 곳에 적용하는 데 별 문제는 없을 것이다.

public interface CategoryService {
// 질의 메서드
public List<Category> findCategoryGroup(String categoryGroupKey);

// 적재 메서드
public void loadCategryGroups();

// 그 밖의 업무 로직 메서드들
...
}

이렇게 인터페이스를 생각해보니 문제가 하나 생긴다. "적재 메서드"는 언제 호출해야 하는가? 캐시를 사용하기 전에 불러두긴 해야 하는데 호출하는 쪽에서 데이터 적재가 됐는지 안됐는지 신경쓰면서 호출할 수 있을까? 물론 답은 No다. 호출 받는 이 캐시 서비스 클래스 측에서 데이터 적재가 됐는지를 판단하는 게 쉬울 것이다. 그리고 동시성도 고려해야 한다. 적재 메서드를 통해 데이터를 데이터베이스에서 가져오는 작업은 상대적으로 상당한 시간이 걸리는 작업이다. 따라서 웹 환경과 같이 동시 사용자가 많은 환경에서는 이러한 초기화 작업이 동시에 다수 발생할 가능성을 염두에 둬야 한다. 이러한 문제들을 고려했을 때 위 인터페이스는 아래와 같이 바뀌어야 하며,

public interface CategoryService {
// 질의 메서드
public List<Category> findCategoryGroup(String categoryGroupKey);

// 초기화 메서드
public void invalidateCategoryGroups();

// 그 밖의 업무 로직 메서드들
...
}

구현 클래스는 다음과 같이 만들 수 있다.

@Service
public class CategoryServiceImpl implements CategoryService {

@Autowired
private CategoryDao categoryDao;

private final Map<String, List<Category>> categoryGroups = new HashMap<String, Listlt;Category>>();

// 질의 메서드
@Override
public List<Category> findCategoryGroup(String categoryGroupKey) {
// 데이터가 적재되지 않았으면 데이터 저장소(DB)에서 데이터 가져오기
if (categoryGroups.isEmpty()) {
synchronized (categoryGroups) {
if (categoryGroups.isEmpty()) {
Map<String, List<Category>> map = new HashMap<String, List<Category>>();
List> list = categoryDao.findAllCategories();
for (Category c : list)
map.put(c.getCategoryGroup(), c);

categoryGroups.clear();
categoryGroups.putAll(map);
}
}
}

return categoryGroups.get(categoryGroupKey);
}

// 초기화 메서드
@Override
public void invalidateCategoryGroups() {
categoryGroups.clear();
}

// 그 밖의 업무 로직 메서드들
...
}

원본 데이터가 데이터베이스에서 수정됐으면 캐시는 데이터를 다시 적재해야 할 것이다. 그런데 그 시점이 캐시 데이터를 사용하는 시점과 반드시 일치하는 것은 아니다. 캐시 데이터가 필요한 시점은 사용하는 쪽에서 결정되는 것이다. 그래서 위와 같이 캐시를 다시 로딩하는 메서드가 아니라 캐시 데이터가 유효하지 않게 되었다는 메서드를 정의하게 됐다. 그래서 호출하는 쪽에서 초기화의 책임을 지기 힘들므로 앞서 생각했던 loadCategoryGroups() 메서드의 역할은 질의 메서드에서 수행하는 것이 맞으며 이 과정에서 동시성도 고려하여 구현했다. "초기화"와 "데이터 적재"의 동작은 구분되는 게 맞다는 판단이 생기며 위와 같이 구성한 것이다.

이제 웹 애플리케이션에서 상품 카테고리 데이터가 수정될 경우 위의 invalidateCategoryGroups() 메서드를 호출하고 상품 카테고리를 가져다 쓰는 쪽에서는 findCategoryGroup() 메서드를 호출해 사용하면 된다. 웹 애플리케이션에 있어서 캐시의 유스케이스는 이것으로 끝이다. 그러나… 만약 웹애플리케이션을 거치지 않고 데이터베이스 데이터가 수정되는 경우는 어떻게 해야 하는가? 데이터베이스 클라이언트 프로그램으로 SQL을 직접 실행하여 데이터가 수정되는 것 같은 경우 말이다.

이를 위해서는 시간에 따른 캐시 초기화도 필요하게 된다. 이를 고려해 위 메서드는 다시 다음과 같이 바뀌게 된다. 캐시 유효 시간을 600초(10분)로 설정한 예시다.

@Service
public class CategoryServiceImpl implements CategoryService {

@Autowired
private CategoryDao categoryDao;

private final Map<String, List<Category>> categoryGroups = new HashMap<String, Listlt;Category>>();

private long categoryGroupsLoadTime;

private final long categroyGroupsCacheDuration = 600 * 1000L;

// 질의 메서드
@Override
public List<Category> findCategoryGroup(String categoryGroupKey) {
long now = System.currentTimeMills();

// 데이터가 적재되지 않았으면 데이터 저장소(DB)에서 데이터 가져오기
if (categoryGroups.isEmpty() || now - categoryGroupsLoadTime > categroyGroupsCacheDuration) {
synchronized (categoryGroups) {
if (categoryGroups.isEmpty() || now - categoryGroupsLoadTime > categroyGroupsCacheDuration) {
Map<String, List<Category>> map = new HashMap<String, List<Category>>();
List> list = categoryDao.findAllCategories();
for (Category c : list)
map.put(c.getCategoryGroup(), c);

categoryGroups.clear();
categoryGroups.putAll(map);
categoryGroupsLoadTime = now;
}
}
}

return categoryGroups.get(categoryGroupKey);
}

// 초기화 메서드
@Override
public void invalidateCategoryGroups() {
categoryGroups.clear();
}

// 그 밖의 업무 로직 메서드들
...
}

마치며

위의 사례는 특정한 데이터에 대한 한정적인 사례지만 쉽게 다양한 사례에 적용할 수 있을 것이고 범용 캐시 구현에 대한 밑그림도 이해할 수 있을 것이라 생각된다.

엔터프라이즈급 웹애플리케이션에서 성능은 상당히 중요한 이슈다. 그 한 가지 사례로 데이터 캐시에 대해 여기서 언급했지만 중요한 것은 실제로 성능이 가장 저하되는 병목이 어딘지를 파악하고 그에 맞게 처방하는 것일 것이다. 매일매일이 문제 해결의 과정인 개발자에게 성능은 또다른 복병이다.