개요
인기 거래소, 경매장 조회를 어떻게 구성할지 고민이 된다.
일반적인 캐시 방식으로 설계할 경우 설계에 따라 사용자가 보게 되는 수량이나 가격 정보가 실제 데이터와 달라질 수 있다.
특히 인기품목은 거래가 활발히 이루어지기 때문에, 캐시를 사용하면 이미 품절된 상품이 인기품목으로 계속 노출되는 문제가 발생할 수 있다.
그렇다고 DB에서 직접 조회하기에는 부담이 있다. 현재 기준으로도 거래소는 621ms, 경매장은 322ms로 성능이 썩 만족스럽진 않으며,
실제 운영 환경에서는 삽입 및 수정 요청까지 동시에 발생할 가능성이 높아서 락 경합이 발생할 위험이 있다.
이 문제를 어떻게 해결할 수 있을까?
고려 중인 전략
1. 캐시 + 스케줄러 도입을 통한 성능 개선
상대적으로 변동이 적은 인기 품목은 역시 캐시 스케줄러 도입을 우선적으로 생각하게 된다.
스케줄 주기를 5~10초로 설정해 주기적으로 캐시를 갱신하고, 캐시 미스 시에는 DB에서 데이터를 조회해 채우는 Lazy Loading 방식을 적용한다. 이를 통해 응답 속도 개선과 함께 DB 부하도 줄일 수 있다.
다만, 거래가 빈번한 인기 품목의 경우 5-10초면 부정확한 데이터가 노출될 수 있어 캐시 활용에 한계가 생길 수 있다.
예를 들어 새벽 시간(거래가 활발하지 않은) 시간대에는 5초여도 충분할 수 있지만, 피크타임에는 정합성이 초단위로 깨질 것이다.
따라서 두 번째 방법을 생각하게 되었다.
2. 데이터 일관성 유지를 위한 전략
앞서 이야기했던 문제로, 스케줄링만으로는 거래 완료된 품목이 계속 노출되는 문제를 해결하기 어렵다.
이를 보완하기 위해 메시지 큐로 거래 이벤트를 수집한 뒤 비동기적으로 캐시를 최신 상태로 유지할 수 있다. 이 전략으로 캐시의 정확도를 높이고 DB를 실시간으로 조회하지 않아도 데이터 일관성을 유지할 수 있다.
1. 거래 완료, 입찰 발생 등 이벤트 발생
2. 이벤트 enqueue
3. 큐 길이가 일정 수 이상 되면 캐시 갱신 이벤트 발행
4. 비동기 worker가 db를 조회하고 redis 캐시 갱신
5. 큐 비움
이벤트 발생은 aop 어노테이션으로 관리하도록 하자.
이미 redis를 사용중이니 별도의 메세지 큐 라이브러리 도입은 미루고, redis streams로 구현해보자
구현
우선 어노테이션을 만들었다. 거래소에서 거래가 발생할 때, 아이템이 등록될 때 이벤트가 발행되도록 했고, 경매장은 입찰이 생겼을 때만 이벤트가 발행되도록 했다.(거래소는 아이템 단위로 품목이 묶이기 때문이다)
@Aspect
@Component
@RequiredArgsConstructor
public class PopularUpdateAspect {
private final StringRedisTemplate redisTemplate;
private static final String STREAM_PREFIX = "stream:popular:update:";
@Around("@annotation(triggerPopularUpdate)")
public Object triggerPopularUpdate(ProceedingJoinPoint joinPoint, TriggerPopularUpdate triggerPopularUpdate) throws Throwable {
Object result = joinPoint.proceed();
String target = triggerPopularUpdate.target().name();
String streamKey = STREAM_PREFIX + target.toLowerCase();
redisTemplate.opsForStream().add(streamKey, Map.of("event", target));
return result;
}
}
해당 어노테이션이 붙은 메서드가 실행되고 나서 redis streams에 이벤트가 push된다.
@Slf4j
@Component
@RequiredArgsConstructor
public class PopularUpdateStreamConsumer {
private final StringRedisTemplate redisTemplate;
private final PopularUpdateAsync popularUpdateAsync;
private static final String STREAM_PREFIX = "stream:popular:update:";
@Scheduled(fixedDelay = 1000)
public void pollMarketStream() {
pollStreamForTarget("MARKET", popularUpdateAsync::updateMarketPopulars);
}
@Scheduled(fixedDelay = 1000)
public void pollAuctionStream() {
pollStreamForTarget("AUCTION", popularUpdateAsync::updateAuctionPopulars);
}
private void pollStreamForTarget(String target, Runnable onUpdate) {
String streamKey = STREAM_PREFIX + target.toLowerCase();
List<MapRecord<String, Object, Object>> records =
redisTemplate.opsForStream().read(StreamOffset.fromStart(streamKey));
if (records == null || records.isEmpty()) return;
long count = records.size();
if (count >= 3) {
onUpdate.run();
redisTemplate.delete(streamKey);
}
}
}
이벤트를 소비하는 곳에선, 1초마다 polling하고 누적된 이벤트 개수를 계산한다.
market,auction이 각각 처리되고, 3개 이상이면 비동기적으로 쿼리를 실행하고 쌓인 해당 streams를 모두 삭제하도록 처리했다.
그래서 접속자가 많은 시간대에는 1초마다 한번 데이터가 비동기적으로 redis에 동기화되고,
접속자가 적은 시간대에는 streams가 지정한 개수만큼 쌓이지 않으면 데이터를 동기화하지 않도록 했다.
결과적으로 필요한 시점에만 캐시를 갱신하니 DB 부하를 줄일 수 있다.
@Component
@RequiredArgsConstructor
public class PopularUpdateAsync {
private static final String MARKET_CACHE_KEY = "popular:market:items";
private static final int POPULAR_LIMIT = 200;
@Async
@EventListener(ApplicationReadyEvent.class)
public void updateMarketPopulars() {
Pageable pageable = PageRequest.of(0, POPULAR_LIMIT);
Page<MarketPopularResponseDto> result = marketRepository.findPopularMarketItems(getStartDate(), pageable);
redisTemplate.opsForValue().set(
MARKET_CACHE_KEY,
serialize(result.getContent())
);
}
}
비동기 코드는 일단 간단하게 Async로 처리했다. 이 작업 자체가 한번에 많이 실행되는 구조는 아니다 보니까 별도의 스레드풀 설정은 하지 않았다.
@EventListener로 어플리케이션이 완전 준비상태가 되면 프리로드 해서 캐시미스를 방지했다.
service계층에서도 캐시가 없을 경우에는 쿼리를 실행하도록 했다
결과! 이제 인기 품목을 조회할 때 db에 영향이 가지 않으면서, 조회 속도도 빨라졌다.
현재 문제점 + 회고
1. Hot key
인기 품목이 저장된 Redis key에 읽기 요청이 집중될 경우에 hot key문제가 발생할 수 있다. key를 여러 개로 분산하거나 alias를 도입할 수 있다
2. 분산 환경 이슈
다중 인스턴스에서 동시에 쿼리가 실행될 경우에 race condition이 발생할 수 있다. consumer group 설정해서 해결 예정..
3. redis를 너무 많이 씀
redis 사용량을 좀 줄여봐야한다. 이벤트 발행 조건이 너무 단순해서 수정해야한다.. 지금은 물건 하나만 구매해도 이벤트가 발행되니까 매크로에 취약한 구조다
일단 개선은 됐고 졸리니까 내일 수정해야겠다. 새벽5시다
1. 인기 거래소 조회: 8.51s -> 22ms
1. PagableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + 고정값으로 변경: 8.51s -> 4.46s
2. Covering index 적용(Serve Query 분리): 4.46s -> 2.25s
3. Serve Query를 한번 더 분리하여 join 최적화: 2.25s -> 621ms
4. 이벤트 기반 실시간 캐시 조회로 변경: 621ms -> 22ms
2. 전체 거래소 조회: 5.79s -> 151ms
1. PageableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + countQuery로 변경: 5.79s -> 4.98s
2. TotalCount에 들어가는 countQuery를 최적화: 4.98s -> 3.03s
3. Covering index 단순 적용: 3.03s -> 1.20s
4. Covering index용 Serve Query와 Main Query 분리: 1.20s -> 334ms
5. Count Query에 캐싱 적용: 334ms -> 151ms
3. 인기 경매장 조회: 22.28s -> 22ms
1. PagableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + 고정값으로 변경: 22.28s -> 9.23s
2. Covering index 적용(Serve Query 분리): 9.23s -> 322ms
3. 이벤트 기반 실시간 캐시 조회로 변경: 322ms -> 22ms
4. 전체 경매장 조회: 24.48s -> 112ms
1. PageableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + countQuery로 변경: 24.48s -> 10.47s
2. Covering index 적용(Serve Query 분리): 10.47s -> 702ms
3. Count Query를 병렬실행: 702ms -> 582ms
4. Count Query에 캐싱 적용: 582ms -> 112ms
'팀 프로젝트 > 플러스 프로젝트' 카테고리의 다른 글
조회 성능 향상시키기(5) Semaphore로 비동기 쿼리 동시 실행 제어하기 (0) | 2025.03.26 |
---|---|
조회 성능 향상시키기(3) Pagination Count Query 최적화 (0) | 2025.03.23 |
조회 성능 향상시키기(2) 커버링 인덱스 with Querydsl (0) | 2025.03.22 |