웹 개발

HTTP 캐쉬 직접 구현 (Angular)

깨비아빠0 2023. 12. 13. 12:32
728x90
반응형

 

 

프론트엔드에서 백엔드로부터 받은 request 결과를 캐싱함으로써 앱 성능을 높일 수 있다. 예를 들어, 이미 조회했던 상품 데이터를 다시 조회할 때, 매번 백엔드에 요청하지 않고 기존에 받은 내용을 보여줌으로써 불필요한 통신을 줄일 수 있다.

이미 웹브라우저 상에서 HTTP 캐싱이 이루어지고 있지만, 원하는 대로 캐쉬 사용이 불가한 등의 이유로 프론트엔드 상에서 (service worker 등을 사용하지 않고) 간단하게 직접 캐쉬 시스템을 구현할 때가 있다.

이러한 캐쉬 시스템은 데이터 종류마다 개별적으로, 즉, 상품 데이터 캐쉬, 사용자 데이터 캐쉬를 따로따로 만드는 것이 보통이다. 하지만, HTTP Request 처리단에 캐쉬 시스템을 구현하게 되면, 모든 요청들에 대해 자동으로 캐쉬를 적용하는 것이 가능하다. (물론 어떤 요청은 캐쉬를 적용 안 한다거나, 캐쉬 만료 시간을 다르게 적용하는 등의 고도화는 필요하다.) 예전에 구현했던 HTTP 요청 캐쉬 기능을 한 번 정리해 보았다.

 

HTTP Request에 캐쉬 시스템을 끼워 넣기 위해서 다음 클래스들을 추가한다.

 

  • CacheInterceptor: HTTP 요청을 가로채기 위해 HttpInterceptor를 상속받아 구현
  • RequestCacheService: HTTP 요청 결과 캐싱을 담당하는 서비스 클래스

 

CacheInterceptor 클래스

HTTP 요청을 가로채기 위해 HttpInterceptor를 상속받아 구현한다.

RequestCacheService를 주입하여 HTTP 요청 캐쉬를 처리한다. 요청 URL에 대해 캐쉬가 존재한다면 해당 스트림(Observable)을 리턴하고, 아니면 새롭게 캐쉬를 등록한다.

import { Injectable } from '@angular/core';
import {
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { RequestCacheService } from '../services/request-cache.service';

@Injectable()
export class CacheInterceptor implements HttpInterceptor {
  constructor(private cacheService: RequestCacheService) {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler,
  ): Observable<HttpEvent<any>> {
    if (!this.isCacheable(req)) {
      return next.handle(req);
    }

    const key = req.urlWithParams;
    const cached$ = this.cacheService.getObservable(key);
    return cached$ || this.cacheService.register(key, next.handle(req));
  }

  isCacheable(req: HttpRequest<any>): boolean {
    // 캐쉬 여부 판단
    // 편의상 판단 로직을 여기에 두었지만, request 헤더에 명시적으로 파라미터를 추가하는 것이 바람직하다.
    return (
      req.method == 'GET' &&
      ['xxx.com', 'yyy.com'].some(host =>
        req.url.includes(host),
      )
    );
  }
}

 

CacheInterceptor가 실제로 작동하기 위해서 @NgModule 클래스의 provider 목록에 추가한다.

@NgModule({
	...
    providers: [
    	...
        { provide: HTTP_INTERCEPTORS, useClass: CacheInterceptor },
        ...
    ],
    ...
})
export class AppSharedModule {}

 

RequestCacheService 클래스

getObservable 함수로 캐쉬된 HTTP 요청 스트림(Observable)을 돌려받는다.

 

  1. 요청 결과를 이미 받은 상태라면, 결과 데이터를 바로 emit하는 Observable 리턴
  2. 아직 요청중이라면 원래의 HTTP 요청 response를 기다리는 Observable 리턴
  3. 캐쉬가 없거나 만료 시간이 지났다면 null 리턴

register 함수로 새로운 캐쉬를 등록한다.

import { Injectable } from '@angular/core';
import { HttpEvent, HttpResponse } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { finalize, share, tap } from 'rxjs/operators';

const DEFAULT_MIN_CACHE_DURATION_SECONDS = 600; // 캐쉬 유효기간 (유효기간이 지나도 아직 요청중이면 캐쉬가 적용됨)

export interface IRequestCache {
  event$: Observable<HttpEvent<any>>;
  response?: HttpResponse<any>;
  expires?: Date;
}

@Injectable()
export class RequestCacheService {
  private caches = new Map<string, IRequestCache>();

  register(
    key: string,
    event$: Observable<HttpEvent<any>>,
    minCacheDuration = DEFAULT_MIN_CACHE_DURATION_SECONDS,
  ): Observable<HttpEvent<any>> {
    let cache: IRequestCache = { event$: null };
    cache.event$ = event$.pipe(
      tap(event => {
        if (event instanceof HttpResponse) {
          cache.response = event;
        }
      }),
      share(),
      finalize(() => (cache.event$ = null)),
    );

    if (minCacheDuration != null) {
      const expires = new Date();
      expires.setSeconds(expires.getSeconds() + minCacheDuration);
      cache.expires = expires;
    }

    this.caches.set(key, cache);
    return cache.event$;
  }

  getObservable(key: string): Observable<HttpEvent<any>> {
    const cache = this.get(key);
    if (!cache) return null;

    return cache.response ? of(cache.response) : cache.event$;
  }

  get(key: string): IRequestCache {
    const cache = this.caches.get(key);
    if (!cache) return null;

    const inFlight = cache.event$ != null;
    if (inFlight) return cache;

    if (cache.expires) {
      const now = new Date();
      const expired = cache.expires.getTime() < now.getTime();
      if (expired) {
        this.caches.delete(key);
        return null;
      }
    }
    return cache;
  }
}

 

반응형