ecsimsw
이벤트 파이프라인 개선, 캐시 도입으로 처리량 높이기 본문
이벤트 파이프라인
우리 회사 기기는 삼성의 Smartthings, LG의 ThinQ, KT, Genie, Kakao, Clova 등 여러 IoT 플랫폼에서 연동되어 사용된다. 서버는 기기의 이벤트를 외부 서버에 전달하여 해당 플랫폼에서 상태 변화나 알림을 반영될 수 있도록 한다. 이벤트 파이프라인은 기기의 연동 정보를 확인하여 이벤트를 전달하거나, 필터링하는 역할을 한다.
만약 이벤트에 지연이 생기거나 유실이 발생한다면, 단순히 기기의 상태 변화가 앱에 반영되지 않는 것을 넘어, 혼자 있는 집에서의 도어락 강제 문 열림 감지, 영업을 종료한 가게에서 발생한 동작 감지, 아기 방에서의 비정상 온도 경보 등, 사용자의 안전과 직결된 문제로 이어질 수 있다. 그래서 기기 이벤트 지연과 유실은 팀에서 가장 유심하게 쳐다보는 문제이다. 처리량 개선하여 문제를 사전에 막고, 문제가 발생했을 때 최대한 빠른 대응 방법을 찾는 게 내 일이다.
처리량 개선이 필요하다.
회사가 성장하며 이벤트가 크게 늘었다. 이를 테면 8개월 전까지만 해도 초당 1500건이었던 기기 이벤트가, 이제는 3500건을 넘는다. 한 달 동안의 이벤트 수 변화량만 보더라도, 그 수위가 점점 높아지는 것을 볼 수 있다. 기존 인프라와 로직으로는 늘어난 유입량을 못 따라가고, 곧 지연과 유실로 이어진다.
이벤트 컨슘부터 Ack까지의 흐름 안에는 다양한 로직들이 있다. 그래서 '어떤 구간이 문제다!'라기보다는, 주변 영향을 최소화하면서도 효과적인 개선 방향을 고민한다. 물론 어떤 경우에는 단순히 인프라만 늘리면 해결되는 경우도 많다. 프로세스를 더 띄우고, 스레드 수를 늘리는 게 제일 빠를 수도 있겠지만, 최대한 주어진 자원 안에서 개선하는 방법을 고민하는 게 요즘 우리 팀의 방향이다.
그래서 캐시 적용을 이번 개선 대상으로 선택하게 되었다. 로직 개선과 DB 인덱스 최적화는 이전에 여러 번 진행하여 캐싱이 더 큰 효과가 있을 것이라 생각했고, DB 레플리카를 늘리거나 WAS 스케일 아웃하는 것보다 저렴한 인프라 비용으로 개선하고자 했다.
1. Look Aside, Evict Only
우선 글로벌 캐시가 필요했다. 이벤트 파이프라인에서 그 캐싱된 내용이 다른 BE 서비스에서 변경이 생길 수 있기 때문이었다. 이를 테면 { 기기 1 : 플랫폼 A, 플랫폼 B, 플랫폼 C }로 캐싱되어 있는 상태에서, 플랫폼 D를 다루는 서비스에서 새로운 기기 연동이 발생하면 이를 캐시에 반영해야 했다.
조회 쪽에서만 Cache put을 하고, DB에 변경이 생기는 BE 서비스들에선 해당 키의 Cache를 단순히 Evict 한다. 이벤트 파이프라인에서는 기기가 엮여있는 모든 플랫폼 정보들이 필요한데, DB에 연동 정보를 변경하는 BE 서비스에선 본인이 처리하는 플랫폼 연동 정보만이 다뤄져서이다. 만약 각 BE 서비스에서 바로 반영하고자 했다면, 캐시 조회 후 put이 필요하거나, 자신이 담당하는 플랫폼 외에 다른 플랫폼 정보까지 DB 조회 후, 이를 반영해야 하는 또 다른 비효율이 발생할 것이다.
2. Fall back, Dirties
레디스 액세스가 안 되는 상황에선 DB를 사용한다. 처리에 지연이 생기겠지만 파이프라인 전체 마비보다는 낫다. 물론 연결이 돌아오면 다시 레디스를 사용한다. 레디스 연결 실패 예외를 확인하는데 걸리는 시간도 중요하다. 만약 확인 자체에 시간이 소요된다면, 큰 지연으로 이어질 것이다. 테스트 결과 약 1~2ms면 예외가 발생하기 때문에, 커넥션 확인을 위한 지연 없이 안전하게 흐름을 이어갈 수 있다.
// 조회단
try {
var valueFromCache = readFromCache(deviceId);
if(valueFromCache.isPresent()) {
return valueFromCache;
}
var valueFromDb = readFromDb(deviceId);
putCache(deviceId, valueFromDb);
return valueFromDb;
} catch (RedisSystemException | RedisConnectionException e) {
log.error("Redis connection error : {}", e);
return readFromDb(deviceId);
}
DB를 직접 액세스 하는 동안, 캐시에 CUD가 반영되지 않는 경우가 생길 것이고, 복구된 이후에 정합성이 안 맞는 레디스를 사용하게 되는 문제가 있다. 이를 대비하기 위해 레디스 예외가 되었던 시점에서 DB 반영이 일어난 키를 기록한다. 그리고 스케줄링으로 일정 시간 간격으로 이 DIRTIES 키 목록에 포함된 Key Evict을 시도한다.
DIRTIES를 무한히 받아선 안된다. 이 역시 메모리 사이즈가 점점 늘어나는 꼴이 될 것이다. 만약 DIRTIES가 계획한 수를 모두 차면, 스케줄러가 돌아갔을 때 모든 캐시 내용을 제거하도록 설계했다. 큐 사이즈를 20000으로 한 이유는 위 로직의 레디스 초당 요청이 2000개여서 그렇다. 대략 10초 이상 레디스 연결이 실패했다면, 그땐 모든 레디스 값을 지우고 다시 사용하겠다는 정책이다.
// 쓰기단
try {
saveToDb(deviceId, platform);
evictCache(deviceId);
} catch (RedisSystemException | RedisConnectionException e) {
if(DIRTIES.size() < 20000) {
DIRTIES.add(deviceId);
}
}
3. Transaction
DB Update 후 이를 캐시를 반영했으나 트랜잭션에 의해 DB가 롤백되면, DB와 캐시 정합성이 깨지는 문제가 발생한다. 이런 정합성 불일치를 방지하기 위해 TransactionalEventListener을 사용하여 트랜잭션이 Commit 된 이후에 캐시가 반영될 수 있도록 보장한다.
TransactionalEventListener 기본 fallbackExecution 설정은 false로, 트랜잭션이 없다면 Listener는 실행되지 않음을 주의한다. 아래 리스너의 경우엔, 앞선 Dirties 제거 스케줄러처럼 DB 트랜잭션 외에도 사용될 수 있도록 fallbackExecution 설정을 true로 지정했다.
4. 모니터링
Look aside 정책에선 Hit율 계산이 레디스만으로는 가능하지 않다. 애플리케이션 단에서 캐시 히트율을 기록하고, 일정 주기로 초기화하길 반복한다. Spring boot에서는 'micrometer-registry-prometheus' 의존성과 'MeterRegistry' 클래스를 이용하여 프로메테우스에서 scrap 할 Api에 히트율을 매트릭으로 추가한다.
Redis 매트릭을 수집하기 위해 redis-exporter라는 오픈소스 Agent를 사용했다. 레디스의 commandstats 정보와 리소스 사용량 등의 정보를 프로메테우스에서 사용할 수 있는 Api로 감싸 수집할 수 있도록 한다. 메모리 사용량, 액세스 횟수, 요청별 평균 처리 시간, 전체 Entity 개수나 시간대별 Put, Evict 개수를 모니터링할 수 있다.
개선 결과 1. 처리량 개선
아래는 한 프로세스에서 캐시를 적용하기 전의 초당 이벤트 처리 수에 대한 로그이다. 초당 약 1200개의 이벤트를 처리할 수 있었다.
캐시 적용 후의 히트율은 85% ~ 90% 정도에서 안정적으로 유지되고 있다. 같은 조건에서 초당 이벤트 처리 수 로그를 확인하면 평균 6500개가 처리되는 것을 볼 수 있다. 단순 개선률로 따지면 441.67%으로, 약 4.4배의 큰 개선을 만들 수 있었다. 다중 프로세스로 이벤트를 처리하고 있었던 이전에서, 이제는 HA를 고려하지 않는다면 단일 프로세스로 이벤트로 처리해도 충분히 초당 3500개의 이벤트 유입량을 처리할 수 있겠다.
캐시 히트율이 낮으면 캐시가 없는 것보다 비효율적이다. 아래는 의도적으로 캐시를 비워, 히트율이 약 60% 정도일 때, 캐시를 적용하기 이전의 처리율을 갖음을 확인했던 로그이다. 이를 통해 반대로 히트율을 60% 이상으로 유지해야, 캐시 적용이 처리량 개선에 의미가 있다는 것을 확인할 수 있다.
개선 결과 2. 이벤트 전달 로직 개선
캐시로 DB 액세스 시간이 크게 개선되어 기존에는 비효율적이었던 로직을 개선할 수도 있었다. DB 조회를 바탕으로 이벤트를 필터링하는 로직이 있는데, 기존에는 DB 조회 시간이 커, 처리량 문제로 필터링 처리를 최소화할 수 밖에 없었다. 이벤트를 전달받는 BE 서비스가 대부분의 부하를 감당하는 꼴이었어서, 항상 크 서비스는 불필요하게 과한 리소스와 불안정한 메모리 변화를 보였다.
캐시를 통해 조회 시간이 개선되어 처리량 문제없이 메시지를 선-필터링할 수 있었다. 기존 초당 1800개의 메시지가 필터링되지 못하고 다음 BE 서비스로 전달되었는데, 필터링 후 전달하는 메시지 수가 초당 100개로 크게 줄여 서비스의 부하를 줄이고, 보다 안정적인 리소스 운영이 가능해졌다.
아래는 앞서 소개한 필터링 강화로 부하가 크게 줄었던 서비스의 메모리 변화, 요청 수 변화를 볼 수 있는 매트릭 대시보드이다. 캐시 처리 개선 후 눈에 띄게 요청 수가 줄고, 그에 따른 메모리 변화가 안정적으로 변화된 것을 볼 수 있다.
정리
작업 : 이벤트 파이프라인 캐시 도입
처리 :
- 캐시 정책
- 레디스 Fallback과 정합성 문제
- 트랜잭션 처리
- 히트율 모니터링
결과 :
- 초당 이벤트 처리량 : 1200 -> 6500, 4.4배 개선
- 이벤트 전달 필터링 강화 : 초당 1800개의 메시지가 전달 -> 초당 100개의 메시지 전달, BE 서비스 부하 개선
'Architecture > Application' 카테고리의 다른 글
이벤트 발행과 DB 트랜잭션 원자성 유지, Transaction outbox pattern (0) | 2024.06.01 |
---|---|
레디스로 분산 환경에서 스케줄러 단일 실행 보장 (0) | 2024.02.23 |
두 번의 갱신 분실 문제와 락 (12) | 2024.01.23 |
RepeatableRead 에서 발생할 수 있는 동시성 문제와 락 (8) | 2024.01.10 |
Future 를 활용한 비동기 이미지 비동기 업로드 흐름과 시연 (0) | 2023.11.28 |