1. 발단
기존의 알림 서비스는 아래와 같이 구성되어 있었습니다.
기존에도 소셜 알림의 경우 한번에 1000+명의 유저에게 발송이 필요했지만, 큰 이슈 없이 동작했기 때문에 발견되지 않았습니다.
그런데 이벤트 기간동안 몇 십만명에게 푸시를 발송해야하는 요구사항이 있었고, response time 이 7초까지 지연되며 개선의 필요성이 화두에 올랐습니다.
로그와 핀포인트를 통해 확인한 결과, 성능 저하의 원인이 되는 부분은 명확했습니다.
유저의 알림 설정 정보를 가져오는 부분에서 1건씩 조회를 하며 많은 시간을 소요하고 있었습니다.
public List<Long> getNotificationEnabled(List<Long> memberList,
Integer notificationNo) {
...
return memberList.stream()
.map(this::findById) // 한 건씩 조회
.filter(it -> it.doSomething())
.map(Notification::getMemberNo)
.toList();
}
public Optional<Notification> findById(Long memberNo) {
return Optional.of(hashOperations.entries(memberNo))
.filter(Predicate.not(Map::isEmpty))
.map(Notification::of);
}
각 멤버에 대한 Redis 조회가 stream().map()
내부에서 개별적으로 이루어졌기 때문에 findById 메서드가 매번 호출되어 Redis 연결이 생성되고 종료되는 문제가 발생했습니다.
기존처럼 stream 을 돌며 조회를 했을 때의 로그는 아래와 같습니다.
[ main] : [channel=0x36802467] write() writeAndFlush command AsyncCommand [type=HGETALL]
[ueEventLoop-7-1] : [channel=0x36802467] write(ctx, AsyncCommand [type=HGETALL], promise)
[ueEventLoop-7-1] : [channel=0x36802467] writing command AsyncCommand [type=HGETALL]
[ main] : [channel=0x36802467] write() done
[ueEventLoop-7-1] : [channel=0x36802467] Received: 4 bytes, 1 commands in the stack
[ueEventLoop-7-1] : [channel=0x36802467] Stack contains: 1 commands
[ueEventLoop-7-1] : Decode done, empty stack: true
[ueEventLoop-7-1] : [channel=0x36802467] Completing command LatencyMeteredCommand [type=HGETALL]
[ main] : dispatching command AsyncCommand [type=HGETALL]
[ main] : [channel=0x36802467] write() writeAndFlush command AsyncCommand [type=HGETALL]
[ueEventLoop-7-1] : [channel=0x36802467] write(ctx, AsyncCommand [type=HGETALL], promise)
[ueEventLoop-7-1] : [channel=0x36802467] writing command AsyncCommand [type=HGETALL]
[ main] : [channel=0x36802467] write() done
[ueEventLoop-7-1] : [channel=0x36802467] Received: 4 bytes, 1 commands in the stack
[ueEventLoop-7-1] : [channel=0x36802467] Stack contains: 1 commands
[ueEventLoop-7-1] : Decode done, empty stack: true
[ueEventLoop-7-1] : [channel=0x36802467] Completing command LatencyMeteredCommand [type=HGETALL]
write() done
이후 다음 명령이 시작되는 동기식 패턴이 보임일반 StringRedisTemplate
에서는 mget을 통해 쉽게 멀티 키 조회가 가능합니다.
허나 현재 서비스는 hash 형식으로 데이터를 저장하고 있었고, HashOperation
에서는 멀티 키 조회를 지원하지 않고 있습니다.
이는 Lettuce 의 비동기를 가장 잘 활용하는 방법이지만, 리액티브 프로그래밍 경험이 부족한 팀 상황에서 러닝 커브가 높고 유지보수 리스크도 있을 것이라 판단하여 다른 방법을 채택했습니다.
전체 애플리케이션이 리액티브하지 않은 상황에서 부분 적용을 하는 것보다, 이후 방식을 통한 처리가 합리적이었습니다.
ReactiveRedisTemplate<String, String> reactiveTemplate = ...;
return reactiveTemplate.opsForHash()
.entries(key)
.flatMap(...) // non-blocking
.collectList()
.block(); // block (if needed)
Redis 는 기본적으로 Request/Response 프로토콜을 사용하며, 하나의 요청에 대한 응답을 받은 후에 다음 요청을 처리합니다. 이는 Round-Trip Time (RTT)을 증가시켜 전체 응답 시간에 영향을 미칠 수 있습니다.
이를 해결하기 위한 방법으로 Redis 와 Spring Data Redis 공식문서 에서 Pipelining에 대한 내용을 확인할 수 있었습니다.
조회를 하는 부분의 코드를 아래와 같이 pipeline 처리를 했고, 성능 테스트 결과 10000 건 조회에 걸리는 시간이 약 1/100 수준으로 줄어든 것을 확인할 수 있었습니다.
@SuppressWarnings("unchecked")
private Map<Long, Notification> getMemberNotificationMap(List<Long> memberNos) {
return hash.getOperations().executePipelined(new SessionCallback<>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
HashOperations<K, Object, Object> hashOperations = operations.opsForHash();
memberNos.forEach(memberNo -> hashOperations.entries((K) getKey(memberNo)));
return null;
}
})
.stream()
.map(it -> (Map<String, String>) it)
.filter(Predicate.not(Map::isEmpty))
.map(Notification::of)
.collect(Collectors.toMap(Notification::getMemberNo, Function.identity()));
}
아래는 파이프라이닝을 적용했을 때의 로그입니다.
응답을 기다리지 않고 명령을 모두 전송한 후, 응답이 돌아오는대로 받는 것을 확인할 수 있습니다.
[ main] : write() writeAndFlush command AsyncCommand [type=HGETALL]
[ueEventLoop-7-2] : writing command AsyncCommand [type=HGETALL]
[ main] : write() done
[ueEventLoop-7-2] : write(ctx, AsyncCommand [type=HGETALL], promise)
[ main] : command AsyncCommand [type=HGETALL]
[ main] : write() writeAndFlush command AsyncCommand [type=HGETALL]
[ueEventLoop-7-2] : writing command AsyncCommand [type=HGETALL]
[ main] : write() done
[ueEventLoop-7-2] : write(ctx, AsyncCommand [type=HGETALL], promise)
[ main] : command AsyncCommand [type=HGETALL]
...
[ueEventLoop-7-2] : Completing command LatencyMeteredCommand [type=HGETALL]
[ueEventLoop-7-2] : Stack contains: 1015 commands
[ueEventLoop-7-2] : Decode done, empty stack: true
[ueEventLoop-7-2] : Completing command LatencyMeteredCommand [type=HGETALL]
[ueEventLoop-7-2] : Stack contains: 1014 commands
[ueEventLoop-7-2] : Decode done, empty stack: true
현재 알림 서비스의 구조는 아래처럼 prefix 와 조합해 키를 생성하기 때문에 레디스 클러스터 구성에서도 모든 알림 데이터가 동일 슬롯에 저장되고 있습니다.
// pipelining method
...
memberNos.forEach(memberNo -> hashOperations.entries((K) getKey(memberNo)));
...
// getKey()
public String getKey(Long memberNo) {
return "notification:" + memberNo;
}
하지만 이 말은, 키가 여러 슬롯에 분산되는 상황이 된다면 CROSSSLOT Keys
에러가 발생할 수 있다는 말이기도 합니다.
때문에 이 경우는 슬롯별로 그룹화하여 여러 번의 파이프라인 실행이 필요합니다.
현재 단순 일괄 처리이기에 파이프라이닝으로 적용했지만, 추후 복잡한 비동기 처리 필요 시 Lettuce native API 사용 또한 고려해볼 수 있겠습니다.
// 비동기 명령 실행
RedisAsyncCommands<String, String> async = connection.async();
RedisFuture<Map<String, String>> future = async.hgetall(key);
// 결과 처리
LettuceFutures.awaitAll(timeout, TimeUnit.SECONDS, future);
RedisAsyncCommands 를 사용하면 여러 조회 작업을 독립적으로 실행, 관리할 수 있고 중간 결과를 활용할 수 있습니다.
Redis pipelining
Spring Boot with Redis: Pipeline Operations
Spring Data Redis Pipelining
lettuce.io/pipelining_and_command_flushing