ecsimsw
레디스 주요 옵션과 사용 전략 본문
캐시로 조회 성능 개선
Pic-up 은 앨범 기반 사진 스토리지이다. Picup 은 페이지네이션이 적용되어 있고, 서비스 특성상 사용자는 앨범을 조회 시 항상 첫 페이지의 사진들부터 확인한다. 전체 앨범 조회에도 마찬가지다. 사용자의 앨범 목록을 조회할 때도 항상 첫 페이지의 사진들부터 조회한다.
그리고 그 앨범, 사진들은 수정될 여지가 적다. 일반 게시물과는 다르게 사진을 앨범으로 한번 만들어두면 그 이후로는 사진을 삭제하거나 수정하는 요청보다는 단순 조회가 다수일 것이다. 그래서 조회 성능을 개선하고 DB에 접근하는 네트워크 비용을 아끼고자 캐시를 도입하게 되었다.
이 글에선 어떤 전략으로 캐시를 사용했고, Spring 에서의 설정 방법을 소개한다.
어떤 캐시를 사용할까 1 : 지역 캐시와 전역 캐시
우선 지역 캐시와 전역 캐시 중 골라야 한다. Picup 의 경우에는 WAS가 여러 개이고 사용자는 랜덤 하게 선택된 WAS 에서 요청이 처리된다.
"By default, kubernetes uses iptables mode to route traffic between the pods. The pod that is serving request is chosen randomly"
이번 캐시 사용의 가장 큰 목적인 "첫 페이지 캐시"의 경우에는 각 WAS가 캐시를 공유해야한다. 로컬 캐시를 사용하는 경우 WAS 1 에서 삭제 요청이 일어났는데 WAS 2 에서 읽기가 되어 삭제된 리소스가 그대로 응답되면 안 될 테니 말이다. 그래서 전역 캐시를 사용했다.
분산 환경에서의 지역 캐시는 어떻게 사용될까
사실 여기서 이미 전역 캐시로 결정했지만 분산 환경에서 로컬 캐시는 어떻게 사용해야 할지 고민해보았다. WAS가 여러 개인 환경이면 다 전역 캐시를 써야 하는 게 아닐까.
로컬 캐시보다 전역 캐시가 더 비용이 큰 것은 당연하다. 네트워크 비용도 당연할테고, 그보다도 여러 WAS의 데이터를 캐시해야 하니 관리와 부하를 고민해야할 것이다. 그래서 지금보다 더 큰 규모의 서버에선 여러 레이어로 캐시를 두면 좋을 것 같다. 그렇게 되면 전역 캐시의 부하를 로컬 캐시로 좀 분담할 수 있게 되고, 당장 WAS와 캐시의 거리도 가까워져 네트워크 비용도 아낄 수 있을 것이다.
예를 들어 인스타그램의 좋아요 수 기능을 설계하라고 하면 나는 분산 환경에서도 전역 캐시보다 지역 캐시를 먼저 도입할 것 같다. 좋아요를 당장 DB에 저장하는 것이 아니라, 각 WAS 별로 특정 기간 동안의 좋아요를 모으고 기간을 벗어날 때마다 이를 DB에서 모으든, 전역 캐시에서 모으든 여러 WAS의 캐시 내용을 집계하는 것이다.
이게 가능한 것은 좋아요 수가 예민하게 실시간일 필요는 없다는 점, 혹 한 WAS 또는 그 WAS의 캐시가 문제 되어도 그렇게 큰 서비스 재난은 아니라는 특성 덕이다.
어떤 캐시를 사용할까 2 : Redis 와 Memcached
전역 캐시로 고민한 옵션은 Redis 와 Memcache 두 가지였다. 자주 들어보고 자료가 많은 것도 어떤 기술을 사용하는데 정말 큰 근거가 된다고 생각했다. 특히 나처럼 개발 어린이에게는 더더욱 말이다. 게다가 둘 중에서도 레디스를 더 많이 들어봤고 더 많은 자료를 봤었고, 사용해 본 경험이 있었다. 우선 둘을 비교하되 Redis 에 대비해 Memcached가 꼭 사용해야 할 이유가 있는 것이 아니라면 가능한 Redis 를 사용하겠다는 생각이었고 결국 전역 캐시로 Redis 를 사용하기로 결정했다.
비교했던 내용은 시간, 공간적인 비용, 관리 포인트, 참고할 수 있는 자료 수, 사용 난이도 였고 이 아티클이 성능 비교에 큰 도움이 되었다. 특히 와닿았던 지표는 처리 시간이랑 메모리 사용이었는데 Memcached는 멀티 스레딩, Redis는 단일 스레딩으로 확실히 데이터 양이 많아질 때 성능이 앞섰다. 사실 "키를 기반으로 한" 읽기에는 그렇게까지 차이가 나지 않았는데, 쓰기 성능에서 큰 차이를 보였다.
다만 그만큼 많은 메모리를 많이 사용했고 picup 처럼 작은 프로젝트, 저렴이 배포 환경에서 그게 메모리 비용이 더 크게 다가왔다.
그래서 Redis로 캐시를 하기로 했다.
(그나저나 H2도 인메모리로 성능 지표에 있는데 Spring CacheManager에 H2를 등록해 캐시로 H2를 사용할 수 있는 라이브러리를 만들어도 참 재밌겠다. 특히 Dev 환경에서 지금은 inmemory mockRedis를 넣어주는데 이 라이브러리를 쓰면 다른 설정 없이 Spring 기본 제공 H2로 캐시를 사용하면 편할 것 같다는 생각이다.)
꼭 알아야 하는 Redis 옵션들
Redis 캐시 사용을 준비하면서 얻었던 옵션과 키워드를 정리한다.
1. in - Memory / --max-memory
Redis 는 인메모리 캐시이다. 데이터를 과적하여 할당 메모리보다 더 많은 데이터를 적재해야 한다면 Swapping이 일어난다.
따라서 데이터를 과적하는 경우에는 인메모리의 성능 이점을 못 얻고 오히려 오버헤드를 야기한다. Redis의 Max memory 옵션으로 최대 메모리 양을 지정할 수 있다. 레디스는 이 값 이상의 데이터를 저장하지 않고 그래야 할 경우 이미 저장된 데이터를 삭제하는 eviction 처리를 통해 메모리를 관리하게 된다.
(Swapping은 커널단에서 자주 사용하지 않는 메모리를 디스크에 저장하고 사용되어야 하는 경우 다시 디스크에서 메모리로 로드하는 것을 말하고 각각 swap out, swap in 이라고 부른다.)
2. Eviction / --maxmemory-policy
최대 메모리 이상을 적재해야 하는 경우 이미 저장된 데이터를 희생된다고 했다. 어떤 데이터를 삭제해야할까. 그 삭제 전략을 정하는 옵션이 max memory policy 이다. Redis에는 크게 세 가지 종류의 교체 알고리즘을 지원한다. LRU, LFU, Randomly. LRU는 least recently used, LFU는 least frequently used, Randomly 는 랜덤 하게 선별된 자원을 제거하고 새 자원으로 교체한다. 이 세 가지 안에서 더 세부적으로 여러 교체 전략을 가지니 확인해 보시길 바란다.
3. 데이터 백업 방식 / AOF, RDB
레디스도 인메모리이지만 데이터 백업 방식을 지원한다. 이 백업 방식을 사용하면 파일로 캐시 상태 / 처리 내용을 기록하여 레디스 서버 자체에 문제가 생겼을 때 복구에 사용할 수 있다. RDB는 Snap shot 방식으로 레디스를 마치 이미지로 만들 듯 데이터 전체를 파일로 만든다. AOF는 Append only file 방식으로 Access log를 사용 기록을 파일로 만들어 백업한다.
마치 MySQL의 Row based log, Statement Based log를 비교하는 것 같다는 생각이 든다. 데이터 특성에 따라 RDB를 일정 주기로 백업하고, AOF를 설정하여 RDB가 백업하지 못한 시간 동안을 대비하되 RDB 백업 주기 이후의 기록들은 삭제하거나 압축하여 관리할 수 있을 것 같다.
Redis는 싱글 스레드라고 했는데 백업에 데이터 처리가 막히는 것 아닌가 걱정할 수 있다. Redis는 fork로 자식 프로세스를 만들어 백업 파일을 만드는 데 사용한다.
4. 구성 / HA, Replication
Redis 여러 방식의 HA와 Replication 옵션을 제공한다. 먼저 데이터 샤딩과 HA가 필요한 경우 Cluster 로 구성한다. 또 데이터 샤딩은 필요 없지만 HA를 원한다면 Sentinel 방식으로 구성할 수 도 있다. Sentinel 방식은 Master - Slave1, Slave2 처럼 레디스 노드를 홀수개로 구성하고, 노드 중 과반수가 Master 노드의 다운을 감지하면 Master를 내리고 Slave 중 하나를 승격시키는 선별 방식이다.
또 Replication을 지원한다. 만약 노드를 홀수개로 만드는 것이 부담스럽다, HA까지는 불필요하고 데이터 백업만을 원한다면 Replication 방식으로 구성할 수 있다.
HA와 복제가 불필요하다면 단일 노드로 구성하면 될 것이다.
5. TTL
데이터 캐시 수명이다. 캐시 수명이 클수록 hit 율이 높아지겠지만 불필요한 메모리 사용이 늘어날 것이다.
반대로 TTL이 작으면 메모리 효율은 좋겠지만 hit 율이 낮아진다.
Picup 의 캐시 사용 전략
1. Redis server 구성
redis-server --save "" \
--appendonly no \
--maxmemory 256mb \
--maxmemory-policy allkeys-lru
Picup에선 데이터 백업이 당장은 불필요하다. 캐시가 날아가면 다음 조회에서 miss가 나는 정도, 서비스 적으로 문제가 되는 상황이 없다. 위 커멘드로 각각 RDB off, AOF off, 최대 메모리 값 256mb, 교체 알고리즘은 LRU로 설정한다.
2. 읽기 전략
Cache 읽기 전략은 Look aside, Read through 가 대표적이다. Look aside는 서버가 캐시를 질의하고 miss가 나는 경우 다시 서버에서 DB로 쿼리 하여 데이터를 가져오고, 이를 캐시에 넣는 식이다. Read through는 서버가 캐시 또는 DB 에 한번 질의하는 것으로 캐시를 처리하는 방식을 말한다. 예를 들어 서버는 캐시에만 질의하고 캐시는 miss 가 나는 경우 DB에 요청하여 캐시를 채운다.
Picup 에선 Look aside 방식을 사용했다. Spring 에서 제공하는 Cacheable로 캐시를 다루는 것이 설정에서 훨씬 깔끔할 것이라고 생각했어서이다. Spring 에서 제공하는 CacheManager 에 Redis를 등록하고 간단히 어노테이션을 추가하는 것으로 설정이 끝난다.
@Cacheable(
key = "{#userId, #albumId}",
value = "userPictureFirstPageDefaultSize",
condition = "{ #cursor.isEmpty() && #limit == DEFAULT_PAGE_SIZE }"
)
public List<Response> fetch(Long userId, Long albumId, int limit, Optional<Cursor> cursor) {
// DB를 읽고 반환
}
Spring 에서는 @Cacheable 어노테이션을 사용할 수 있다. fetch() 메서드가 호출되었을 때 Cursor 정보가 없고, limit 가 기본 페이지 크기인 경우, 즉 커서 기반 페이지네이션에서 커서 정보가 없는 첫 페이지 호출일 경우 userPictureFirstPageDefaultSize라는 이름의 캐시에서 유저 정보와 앨범 정보를 기반으로 캐시를 조회하게 된다.
만약 해당 유저, 해당 앨범에 해당하는 첫 페이지 캐시가 CacheManager에 존재하지 않을 경우 알아서 fetch 메서드의 내용을 읽고 처리하는데 메서드는 DB 엑세스가 있어 반환하게 되고, 이를 {유저 정보, 앨범 정보}를 키로 캐시에 추가하게 된다.
3. 쓰기 전략
대표적인 쓰기 전략은 Write through, Write back, Write around 가 있다. Write through 는 쓰기 내용을 캐시를 거쳐 캐시에 업데이트하고 캐시에 DB로 반영하게 된다. 즉 서버는 캐시에만 업데이트하는 것과 같다.
Write back 은 쓰기 내용을 캐시에 특정 기간 모았다가 기간이 끝나거나, miss가 발생하여 해당 키에 해당하는 캐시가 비워져야 할 때 그제야 DB에 반영하게 된다. DB에 가장 적게 액세스 한다는 장점이 있고, 불필요한 업데이트를 만들지 않을 수 있지만 miss 가 발생하기 전까지 DB와 캐시의 싱크가 안 맞고, 그때 캐시가 다운되면 DB에 update 할 내용을 잃게 되는 등 위험할 수 있으니 데이터 특성과 캐시 옵션을 꼼꼼히 확인해야 한다.
Write around 는 쓰기를 캐시에 업데이트하지 않고 DB에 직접 반영한다. 쓰기 내용을 캐시에 당장 반영하지 때문에 곧바로 조회되지 않는다면 캐시 메모리를 아낄 수 있다. 만약 A 데이터를 생성하고 바로 캐시와 DB에 싱크를 맞췄는데 A 데이터가 조회되지 않는다면 캐시 메모리를 비효율적으로 사용하게 된 것이니 말이다. 단 캐시에 반영하지 않아서 생기는 효율만큼 캐시와 DB 싱크가 안 맞아선 안 되는 데이터의 경우 이를 대비해야 한다.
@CacheEvict(key = "{#userId, #albumId}", value = "userPictureFirstPageDefaultSize")
public void delete(Long userId, Long albumId, Long pictureId) {
// DB 데이터 삭제
}
Picup에선 Write around 방식을 사용했다. 쓰기가 된 데이터를 꼭 바로 사용해야 하는 경우가 아니기에 캐시가 되는 기준을 "조회"로 하는 게 정책적으로나 설정 부분에서 가장 깔끔한 방식이라는 생각이었다. 그래서 조회 시 miss 가 발생하면 데이터를 캐시 하고, 쓰기 시에는 바로 관련 키에 해당하는 데이터를 삭제 처리한다. 그리고 다시 조회가 발생하면 그때 miss가 발생할 테니 그제야 캐시 되는 것이다.
Tips
Tip 1. 쓰기 전략으로 Write back vs Write through 을 고민한다면
쓰기 전략에서 Write back과 (Write through / Write around) 중 고민할 때 사용한 내가 만든 기준이다.
1. 저장했다가 바로 삭제 or 업데이트하는 경우가 있는가 -> 경우가 많을수록 Write back
2. 캐시 서버 문제로 캐시가 비워질 경우 얼마나 큰 서비스 문제를 이어지는가 -> 문제가 클수록 Write through
3. 순위 계산이나, 카운팅처럼 단순한 계산이나 그 내용을 WAS 간 데이터 공유를 위한 캐시인가 -> 그럴수록 Write back
Tip 2. 쓰기 전략으로 Write through vs Write around 을 고민한다면
쓰기 전략에서 Write through / Write around 중 고민할 때 사용한 내가 만든 기준이다.
1. 한번 저장하고 다시 조회하는 상황이 많은가 -> 많을수록 Write around
2. DB와 캐시의 실시간 싱크가 필요한가 -> Write through
3. 데이터 사이즈가 크거나 캐시 용량이 적어 캐시 내 불필요한 데이터가 있어선 안되는가 -> Write around
4. 캐시를 설정하는 것보다 서버를 설정하는 것에 더 집중하고 싶은가 -> 복잡한 캐시 서버 설정을 피하고 싶을수록 Write around
Tip 3. JPA 1차 캐시와 함께 사용을 주의한다.
용관이가 공유해준 문제다. 아래와 같은 꼴을 수행했는데 picture가 더티 체킹으로 업데이트되지 않는다는 문제를 말해줬다.
@Transactional
@CachePut(key = "{#userId, #albumId}", value = "userPictureFirstPageDefaultSize")
public void upate(Long userId, Long albumId, Long pictureId) {
Picture picture = pictureReadService.findById(pictureId);
picture.updateInfo();
}
위 pictureReadService 의 조회에서 CacheManager의 캐시를 사용했다면 이는 JPA를 타지 않아 영속성 컨텍스트의 1차 캐시로 등록되지 않을 것이다. 그리고 그래서 더티 체킹으로 업데이트 쿼리가 발생하지 않는다.
캐시를 여러 레이어에서 사용할 때 영속성 컨텍스트의 1차 캐시와 함께 사용하면 이런 문제가 있을 수 있겠다.
JPA 1차 캐시 없이 직접 save 를 호출하는 것으로 해결했다.
'Architecture > Application' 카테고리의 다른 글
DataSource 헬스 체크와 동적 라우팅으로 DB 서버 다운 대비하기 (2) | 2023.12.23 |
---|---|
Future 를 활용한 비동기 이미지 비동기 업로드 흐름과 시연 (0) | 2023.11.28 |
도메인 이벤트를 이용하여 의존성 분리 연습 (4) | 2022.01.12 |
Scheduler 적용 배경 / 스레드 풀과 비동기 처리 (2) | 2021.10.03 |
대부분 못 지키고 있는 REST 제약조건 (4) | 2021.07.22 |