캐싱 적용으로, API 처리속도 3600% 향상 😎

유알·2024년 1월 17일
0

상황

현재 저는 여행 서비스 서버를 만들고 있습니다.

주된 기능은 여행 계획을 저장하고, 추천하고, 최적화 해주는게 주요 기능인 서비스 인데, 그렇다 보니, 모든 서비스에서 Place에 대한 정보를 자주 쿼리하는 특징이 있었습니다.

  • 추천 경로를 만들기 위해 여행지별 위치 정보 쿼리
  • 여행 계획 띄우기 위해 클라이언트가 정보 호출
  • 최적화 하기위해...
  • ...

그래서 Place 서비스의 쿼리 성능이 전체 서비스의 성능에 매우 큰 영향을 미친다고 판단했습니다.

현재 저의 서비스에서는 외부 Place API를 활용하고 있었는데, 이것이 네트워크를 통하기 때문에 매우 비효율 적이라고 생각했습니다.

그래서 캐싱을 적용하고자 하였습니다. 캐싱이 적합하다고 생각한 근거는 다음과 같았습니다.

  • 사용자 행동 특성상, 인기있는 여행지에 호출이 집중됨
  • 같은 여행지를 네트워크를 통해 반복 fetching하는 것은 비효율적
  • place 정보는 자주 바뀌지 않음
  • 쿼리 성능이 매우 중요함

캐싱 적용

그래서 저는 mongoDb를 활용해서 캐싱을 하기로 하였습니다.
일단 여행지 정보는 매우 많기 때문에 확장성과 용량당 저렴한 비용이 필요했고, json의 스키마 변경에도 유연하게 대응할 수 있을 거라고 생각했습니다.
또한 수정이 적은 반복 쿼리에 mongodb의 빠른 쿼리 특성도 적합할 거라고 생각했습니다.

일단 저는 여행지를 표현하는 DTO를 통일하고, PlaceView라는 이름으로 명명했습니다.(read only라는 것을 강조하고 싶어서)
그리고 캐싱 인터페이스를 정의했습니다.

public interface PlaceCacheService {
    Optional<PlaceView> get(String placeId);

    /**
     * 주어진 placeIds 에 해당하는 장소를 조회합니다.
     * 전부 있지 않은 경우, 있는 것만 반환합니다.
     * @param placeIds 장소 id 목록
     * @return 조회된 장소 목록, 없는 경우, Empty List
     */
    List<PlaceView> getByIdIn(String[] placeIds);
    void put(PlaceView placeView);
    void putAll(List<PlaceView> placeViews);
}

이것에 대한 mongodb 구현체를 만들었습니다.

캐싱 로직 구현

저는 AOP를 활용해서, Place 조회로직과 캐싱 로직을 독립적으로 분리했습니다.

@Slf4j
@Component
@Aspect
@RequiredArgsConstructor
public class PlaceRepositoryCacheAspect {
    private final PlaceCacheService placeCacheService;

    @SuppressWarnings("unchecked")
    @Around("execution(java.util.Optional<click.porito.travel_core.place.dto.PlaceView> click.porito.travel_core.place.dao.PlaceRepository.getPlace(String)) && args(placeId)")
    public Object aroundGetPlace(ProceedingJoinPoint joinPoint, String placeId) throws Throwable {
        // check cache
        Optional<PlaceView> placeView = placeCacheService.get(placeId);
        if (placeView.isPresent()) {
            log.debug("Cache Hit - Get Place From Cache : {}", placeId);
            return placeView;
        }

        // cache miss
        log.debug("Cache Miss - Get Place From : {}", joinPoint.getTarget().getClass().getSimpleName());
        Optional<PlaceView> result = (Optional<PlaceView>) joinPoint.proceed();

        // load to cache
        if (result.isPresent()) {
            // cache
            log.debug("Cache Put - Put Place To Cache : {}", placeId);
            placeCacheService.put(result.get());
        }

        return result;
    }

성능

물론 상황에 따라 달라질 수 있지만, 속도 향상이 있었습니다. (1164 -> 32)
물론 저의 로컬환경이 와이파이를 사용하기 때문에 더 크게 낫을 수도 있지만, 어쨌든 상당히 성능 향상폭이 큰것을 볼 수 있습니다.

이 장소 쿼리의 경우 서비스 전반에서 상당부분 의존하고 있는 만큼, 단순히 이 API의 개선이라기 보다는 전체 서비스의 성능에도 큰 영향을 미칠것으로 예상됩니다.

이후 개선가능 방향

  • 현재 저의 서비스는 place가 읽기 전용이고 수정이나 삽입이 불가능하므로, evict에 대한 구현이 없지만, 추후 이러한 구현이 추가될 수 있습니다.
  • 2단계 캐싱도 가능합니다. 캐시 미스가 나면, redis와 mongodb에 동시에 put을하고, 다음번에 redis -> mongodb 순으로 캐시 조회를 하도록 하면 더 효율적일 것 같습니다. 이렇게 되면, redis쪽에서는 ttl을 더 낮게 잡아 비용도 효율적으로 관리하면서, 매우 빈번하게 조회되는 여행지에 대해 훨신 향상된 성능을 보여줄 것으로 기대됩니다.
profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글