유저에게 전가되던 외부 API의 처리 시간(Processing Time)를 분리하여 Latency를 개선한다. 개선 방안 중 사용된 Redis, Kafka의 활용 목적성이 올바른지 검토한다.
이전 포스트의 유스케이스 다이어그램을 보면 서비스를 이해하는 데 도움이 조금 될 것 같다.
지속가능한 서비스로 개선하기✔
유저 시나리오에서 템플릿을 유저 개인의 디자인으로 적용하는 과정
에서 응답 시간이 오래 걸리는 것을 확인할 수 있었다.
프로젝트의 정책 중에 하나는 최대한 유저간의 동일상 S3 오브젝트(이미지)을 사용하는 경우가 발생하지 않도록 하는 것이 있다. 때문에 템플릿에 30개의 이미지를 포함하고 있다면 유저 개인의 디자인으로 변환해주는 과정에서 S3 오브젝트를 복사하는 과정이 진행된다.
하지만 프로젝트의 성격 상 이미지의 개수 제한이 있지도 않고 있어서도 안될뿐더러 외부 API(AWS)를 통해 실행되는 작업이니 만큼 개선이 쉽지 않았다.
현재도 S3 복사를 일정 수준에서 병렬 처리함에도 해당 요청은 7.42s의 시간이 발생하고 있다.
유저에게 제공되는 응답 시간이 정말 늦다.
템플릿을 디자인으로 변환하는 작업이 완료되면 유저 입장에서 많은 데이터가 셋팅됨을 보고 오래걸린 이유를 이해하겠지만 좋지 않은 유저 경험을 제공한다고 생각한다.
내부 요인이 아닌 외부 요인(AWS)에 의존적이다.
S3.CopyObject를 직접적으로 개선할 수 없고 성공 여부를 받기 위해선 동기적으로 실행되어야 한다.
서버 자원의 오랜 점유
외부 작업을 기다림으로서 서버 자원(스레드, 커넥션)을 점유한 상태에서 I/O 작업 완료를 대기하는 상황이 빈번하게 펼쳐 진다. 7.42s 중 약 75% 정도가 외부 작업을 대기 시간이다.
결론부터 말하자면 Redis를 이용하여 템플릿 Warm-Up 이라는 과정을 도입하게 되었다. Template(엔티티) -> Design(엔티티)
직접 변환하는 것이 아닌 Template(엔티티) -> DTO -> Design(엔티티)
과정으로 진행한다. DTO로 변환된 템플릿은 Redis의 캐시로 저장하고 유저는 DTO(Redis) -> Design(엔티티)
로 변환하는 과정만을 처리한다.
Template(엔티티) -> DTO(Redis)
의 과정은 유저에게 영향 없이 서비스 내에서 작업되도록 한다.
S3 오브젝트 복사가 완료된 DTO 정보를 Redis에 List 타입으로 저장한다. 캐시의 사용은 leftPop()을 통해 가져온다.
// Template 데이터 추출
public CopyDesignDto exportTemplateIfNotExceedLimit(Long templateSn, Long companySn) {
Company company = findCompanyService.findBySnOrThrow(companySn);
int countExistDesign = findDesignService.countByCompany(company);
checkPossibleToCreateDesign(countExistDesign);
Template template = findTemplateService.findBySnOrThrow(templateSn);
CopyDesignDto warmUpCache = templateWarmUpComponent.getTemplateWarmUpCache(template.getSn());
if (warmUpCache != null) {
return warmUpCache;
}
return copyDesignService.templateToCopyDesignDtoWithParallel(template);
}
// Redis Cache Get
public CopyDesignDto getTemplateWarmUpCache(Long templateSn) {
ListOperations<String, String> redisOps = redisTemplate.opsForList();
String key = RedisKey.toKeyForm(RedisKey.TEMPLATE_WARM_UP, String.valueOf(templateSn));
String value = redisOps.leftPop(key);
produceTemplateWarmUpTopic(templateSn); // 2단계: 템플릿 데이터 재생성 요청
if (value == null) {
return null;
}
return jsonComponent.toObject(value, CopyDesignDto.class);
}
DTO 정보를 DB에 저장해둘 수도 있지만 Redis에 저장한 이유는 Redis는 싱글스레드로 동작하기 때문이다. 프로젝트의 정책 중 하나가 같은 S3 오브젝트를 사용하지 않는다고 했다. 템플릿 -> 디자인
API는 생성할 데이터가 많아 트랜잭션의 시간이 길다고 볼 수 있다. 동시 요청이 발생하여 트랜잭션이 겹칠 경우 같은 데이터를 사용하는 상황이 발생한다.
Redis는 싱글 스레드와 leftPop() 또는 getOrDel()를 통해 데이터가 한번만 사용될 수 있는 환경을 제공할 수 있었다.
템플릿 데이터와 S3 오브젝트를 복사 후 Redis 저장해두는 과정은 서비스 내에서 비동기로 실행된다. 현재는 유저가 Redis 데이터를 가져올 때마다 Cache의 재생성 요청을 Kafka로 전달하고 있다.
특정 템플릿의 캐시 데이터가 사용되었된 후 설정한 Redis List의 최대 크기만큼 Cache를 채워 넣는다. (Warm-Up)
private static final long TEMPLATE_MAX_WARMING_UP = 4;
/**
* 템플릿 생성을 위한 Redis Cache Warm-Up 컨슘
**/
@KafkaListener(
topics = KafkaConsumeTopicConst.BUILDER_V1_TEMPLATE_CACHE_WARM_UP,
groupId = KafkaConsumeConstants.GROUP_ID
)
public void warmUpTemplateCache(@Payload KafkaTemplateWarmUpDto data) {
kafkaTemplateWarmUpService.warmUpTemplateCache(data.getTemplateSn());
}
public void warmUpTemplateCache(Long templateSn) {
Template template = findTemplateService.findBySnOrThrow(templateSn);
ListOperations<String, String> redisOps = redisTemplate.opsForList();
String key = RedisKey.toKeyForm(RedisKey.TEMPLATE_WARM_UP, String.valueOf(template.getSn()));
while (Objects.requireNonNullElse(redisOps.size(key), TEMPLATE_MAX_WARMING_UP) < TEMPLATE_MAX_WARMING_UP) {
CopyDesignDto data = copyDesignService.templateToCopyDesignDto(template);
String value = jsonComponent.toJson(data);
redisOps.rightPush(key, value);
redisTemplate.expire(key, 30, TimeUnit.DAYS);
}
}
캐시 데이터의 사용 후 @Async를 통해 Warm-Up 과정을 진행할 수도 있었다. 하지만 Kafka를 선택한 이유는 스레드의 점유를 최소화하기 위함이다. Kafka는 Group이 같다면 Topic의 소비는 순차 실행되는 부분을 이용하여 유저의 요청이 많아졌을 때 S3 오브젝트 복사 과정이 동시다발적으로 실행되어 모든 스레드를 점유하는 현상을 방지하였다. 불필요하게 Warm-Up된 Cache가 많아지지 않도록 할 수 있었다.
실제 Production 환경에 배포 후 일주일간의 개선 상황을 지켜봤다. 유저가 대기하는 시간(Latency)에 대해 72%의 개선이 이루어졌다.
역할의 분리를 이루며 개선한 항목이기에 성능 개선의 시선으로 절대적인 수치만으로 비교하는 것이 올바르지 않을 수 있다는 것을 참고하자.