현재 배달의민족 서버를 클론하는 프로젝트를 하고있습니다.
그 과정에서 ENUM을 통해 카테고리를 관리할지, DB에 저장하여 관리할지 고민한 경험을 정리했습니다.
기존에 카테고리는 아래와 같이 enum 클래스로 관리하였습니다.
public enum Category {
KOREAN(1L),
CHINESE(2L),
JAPANESE(3L),
CHICKEN(4L),
PIZZA(5L),
FAST_FOOD(6L);
....
}
enum을 관리할 경우 장점과 단점을 생각해보면
장점으로는
단점으로는
제가 생각하기에 카테고리의 변경은 그렇게 잦지않고, 현재 로컬에서만 개발중인 상황인데 코드를 수정하고 컴파일 하는일이 어렵지 않으므로 enum으로 관리하는 쪽을 택했었습니다.
이러한 문제점은 DB에 카테고리를 저장함으로서 개발자가 코드 수정을 하지않아도 쿼리 한번으로 카테고리 수정이 가능해지는데요,
DB에 저장할 경우 치명적인 단점이 하나 있습니다.
네트워크를 타서 데이터베이스를 조회한다는 단점만 해결한다면 요구사항을 충족할 수 있었는데,
이를 충족할 방법으로 캐시(Cache)
를 떠올렸습니다.
과연 카테고리가 캐싱하기 좋은 데이터인지 다음을 고려해봤습니다.
크게 위의 관점에서 보면 카테고리는 캐싱에 아주 적합한 기술이었습니다.
애플리케이션과 데이터베이스 사이에 캐시를 두고, 한번 조회된 내용은 캐시에 저장하여 데이터베이스를 조회하지 않고도 캐시를 통해 데이터를 가져올 수 있습니다.
만약 캐시서버 또한 네트워크를 탄다 하더라도 데이터베이스 내에서 조회하는 과정이 생략되고 가져오기만 하면 되니, 직접 조회보단 효과적이라고 생각할 수 있었습니다.
물론 ConcurrentHashMap 같은 자료구조를 이용해서 직접 캐시를 구현해도 되지만, Cache는 Eviction Strategy를 설정할 수 있고, 직접 구현하는 경우 TTL(Time-To-Live) 같은 설정이나, 각 캐시마다 제공하는 기능들을 누리지 못하거나 직접 구현해야 하므로 이미 존재하는 캐시들을 사용하는 편이 안전합니다.
캐시는 Local Cache와 Global Cache로 나눠질 수 있습니다.
Local Cache
Global Cache
캐시를 검색하면 Redis가 워낙 장점도 많고 유명해서 널리 사용되고 있음을 알 수 있습니다.
그러나 두 종류의 캐시 모두 장단점이 명확하기 때문에 유명하다고 무턱대고 사용할 순 없었고 저의 상황과 비교할 필요가 있었습니다.
카테고리는
이렇게 단순 데이터를 처리하기 위해 매번 네트워크 트래픽을 발생시키는건 효율이 떨어진다고 판단했습니다.
그래서 저는 로컬캐시 중 카페인을 사용하기로 하였고, 레디스와 속도를 비교해보았습니다.
// 카테고리를 1000번 조회하는 루프를 10번 테스트해본다.
public void applicationRunner() {
for (int i = 0; i < 10; i++) {
long start = System.currentTimeMillis();
for (int j = 0; j < 1000; j++) {
service.getAllCategories(); // cache가 적용된 카테고리 조회 서비스
}
}
}
로컬캐시인 카페인이 압도적인 속도를 보여줬습니다. 실제로 제가 실행했을 때도 카페인은 바로 결과가 나온 반면, 레디스는 타임아웃이 걸린것 처럼 한참이 걸렸습니다.
(스프링에서 PSA로서 캐시 추상화 인터페이스를 제공하기 때문에 간단하게 적용할 수 있습니다.)
Local Cache를 사용할 경우 scale-out시 정합성 문제가 발생할 수 있다고 하였습니다.
정합성 문제는 데이터간의 논리적 모순이 생기는 것입니다. 같은 질문을 했는데 두 서버가 다른 데이터를 가지고 있어서 다른 답을 주는 경우가 생깁니다.
A, B 서버가 있고 카테고리 '치킨' 을 '닭고기'로 변경하는 상황이라고 가정하겠습니다.
요청을 했을 때, A서버가 이 작업을 처리했습니다.
A서버의 Local Cache에는 이 변경사항이 반영되었고, B서버는 아직 이 사실을 모릅니다.
사용자들이 접속하였습니다. 카테고리 메뉴를 보여줘야 합니다.
1번 사용자는 A서버를, 2번 사용자는 B서버를 통해 카테고리 메뉴를 요청합니다.
어떤 서버가 처리하는지는 로드밸런서의 영역이므로 다른 글을 참고해주세요.
A, B 서버 캐싱된 데이터를 활용하는데
문제는 A서버는 변경된 '닭고기', B서버는 변경전인 '치킨' 을 보여줍니다.
이러한 정합성 문제를 해결하기 위해선 동기화 과정이 필요한데, 저는 Eventual Consistency를 만족하는 동기화를 하였습니다.
Eventual Consistency를 간단하게 설명하자면,
모든 서버가 당장 일관되진 않지만 시간이 지나면 결국 동기화가 된다.
는 아이디어로 보시면 되겠습니다.
그 이유는
카테고리 자체가 변경이 잦지 않으며, 그 변경이 다른 기능에 영향을 줄 가능성이 적다.
카테고리가 변경되더라도 전혀 의미가 다른 카테고리로 변경되는 없을 것이라고 본다.
(예를들어 치킨→ 닭고기 정도가 될것. 치킨→한식 이 된다면 그 파급효과가 클것이기 때문에 이런 변경은 하지 않을것이라 봄)
그렇다면 일시적으로 반영이 늦는 것 정돈 괜찮다고 보았습니다.
Caffeine
에서의 Eventual ConsistencyexpireAfterWrtie
이라는 옵션은 캐싱 이후 일정 시간이 지나면 캐시를 삭제하는 옵션입니다.
1분를 주기로 하면 서버간 데이터가 불일치가 생겨도 60초를 넘어가지 않습니다.
새로 지워지고 최신의 데이터를 데이터베이스에서 가져와 캐싱할 것 이기 때문입니다.
아래는 서비스의 코드입니다. 카테고리에 변화가 생길시 작업을 처리하는 서버의 캐시는 @CacheEvict
를 통해 직접 삭제하고 있습니다. (작업을 처리하지 않은 서버들은 위의 설정에 따라 1분 뒤에 삭제가 되겠죠.)
자 이제 확인해보겠습니다.
카테고리를 반복적으로 조회했더니, 이미지에는 다 안나오지만, blue-delivery1~3 모두 데이터베이스에 쿼리를 한번씩 날렸습니다. (이제 캐싱이 된 상태입니다.)
다음과 같이 ID=1에 해당하는 카테고리의 이름을 KOREAN 에서 KOREAN_DELICIOUS 로 변경하겠습니다.
보시다시피 UPDATE 쿼리가 나갔습니다.(blue-delivery1_1이 처리했네요)
그리고 다시 카테고리 목록을 조회했는데, 수정된 데이터 KOREAN_DELICIOUS 가 아닌 KOREAN 이 아직 보입니다.
60초가 지났으니 다시 요청을 보내보겠습니다.
사진이 조금 짤렸지만 모든 서버에서 캐시 재사용이 아닌, 데이터베이스에 쿼리를 보내는 것을 확인할 수 있습니다.
이로써 로컬 캐시의 동기화과 완료되었습니다.