[태그:] 캐싱

  • Service Worker, 그리고 캐싱 전략

    Service Worker?

    Service Worker는 브라우저 백그라운드 스레드(background thread)로 동작한다.

    즉, 메인 스레드(UI 렌더링)와는 별도로 작동.

    웹페이지가 닫혀 있어도 네트워크 요청을 가로채거나 캐싱 제어를 할 수 있는 권한을 갖는다.

    클라이언트와 네트워크 사이에서 프록시(Proxy) 역할을 수행한다.


    Service Worker의 동작 구조

    다음 3단계 생명주기를 갖는다.

    install → activate → fetch

    • install : 처음 등록될 때, 필요한 리소스를 캐시에 저장
    • activate: 이전 버전의 캐시를 정리하고 새 워커 활성화
    • fetch : 모든 네트워크 요청을 가로채 캐시 또는 네트워크로 응답

    이 구조로 인해 한 번 방문한 페이지는 오프라인에서도 작동할 수 있는 로컬 앱처럼 작동한다.

    등록(Registration)

    먼저 클라이언트 측에서 서비스 워커를 등록해야 한다.

    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker
          .register('/sw.js')
          .then(() => console.log('Service Worker registered'))
          .catch(console.error);
      });
    }
    

    /sw.js 파일은 루트 경로에 위치해야 전체 사이트를 제어할 수 있다.

    (하위 폴더에 있으면 해당 스코프 내에서만 동작)

    install 단계 : 캐시 준비

    Service Worker가 처음 설치될 때, 주요 정적 파일을 캐시에 미리 저장한다.

    self.addEventListener('install', (event) => {
      event.waitUntil(
        caches.open('v1').then((cache) => {
          return cache.addAll([
            '/',
            '/index.html',
            '/styles.css',
            '/main.js',
            '/logo.png',
          ]);
        })
      );
    });
    
    • caches.open() : 캐시 스토리지에 새로운 버전 생성
    • cache.addAll() : 지정한 자원들을 미리 다운로드 후 저장
    • waitUntil() : 설치 완료 전 종료되지 않도록 대기

    이 단계는 앱 설치 시 초기 리소스 다운로드와 같은 과정으로 볼 수 있다.

    activate 단계 : 버전 관리 및 정리

    Service Worker가 새로 설치되면 이전 캐시와 교체해야 한다.

    이때 activate 이벤트 실행,

    self.addEventListener('activate', (event) => {
      const cacheWhitelist = ['v1'];
    
      event.waitUntil(
        caches.keys().then((keys) =>
          Promise.all(
            keys
              .filter((key) => !cacheWhitelist.includes(key))
              .map((key) => caches.delete(key))
          )
        )
      );
    });
    

    이 과정들을 통해 이전 버전 캐시가 정리되고 업데이트 된 파일만 남는다.

    fetch 단계 : 요청 가로채기

    fetch 이벤트는 사용자가 페이지를 요청할 때마다 호출된다.

    Service Worker는 이 요청을 가로채 캐시나 네트워크에서 응답을 선택할 수 있다.

    e.g. Cache First + Network Fallback 전략

    self.addEventListener('fetch', (event) => {
      event.respondWith(
        caches.match(event.request).then((cached) => {
          return (
            cached ||
            fetch(event.request).then((response) => {
              return caches.open('v1').then((cache) => {
                cache.put(event.request, response.clone());
                return response;
              });
            })
          );
        })
      );
    });
    

    캐싱 전략

    PWA의 성능과 오프라인 대응을 좌우하는 건 캐싱 전략이다.

    전략은 상황에 따라 다르게 적용한다.

    Cache First(캐시 우선)

    한 번 저장한 건 그대로 쓴다. 네트워크는 보조, 즉시 로딩이 최우선

    • 동작 흐름
      1. 캐시에 해당 요청이 있는지 확인
      2. 있으면 즉시 반환(네트워크 요청 안 함)
      3. 없으면 네트워크 요청 후 캐시에 저장
    • 장점
      • 가장 빠른 응답 속도(특히 이미지, 폰트, 정적 JS/CSS)
      • 네트워크 불안정 시 강력함
    • 장점
      • 캐시 갱신이 어려움(오래된 리소스가 남을 수 있다)
    • 사용 예시
      • 앱 셸(App Shell) 구조의 JS/CSS
      • 로고, 아이콘 등 변하지 않는 정적 자산

    Network First(네트워크 우선)

    항상 최신 데이터를 원한다. 네트워크가 실패하면, 캐시에서 백업한다.

    • 동작 흐름
      1. 네트워크로 요청 시도
      2. 실패 시 캐시에서 가져온다.
    • 장점
      • 항상 최신 데이터 유지
      • API 요청, 뉴스 피드 등 실시간 콘텐츠에 적합
    • 단점
      • 네트워크 지연이 크면 느려짐
      • 오프라인에서는 첫 로드 실패
    • 예시
      • REST API, GraphQL 요청
      • JSON 데이터, 동적 요청

    Stale-While-Revalidate(캐시 우선 + 백그라운드 갱신)

    사용자에겐 캐시를 즉시 보여주고, 뒤에서는 최신 데이터를 받아 업데이트.

    • 동작 흐름
      1. 캐시에 있으면 즉시 반환
      2. 동시에 네트워크 요청으로 최신 데이터 가져오기
      3. 응답을 캐시에 덮어쓰기
    • 장점
      • 빠른 로딩 + 최신성 확보
      • UX적으로 로딩 없이 즉시 뜨는 화면.
    • 단점
      • 캐시와 최신 데이터 불일치 시 순간적 차이 발생 가능.
    • 사용 예시
      • 블로그, 상품 리스트, 피드
      • 자주 바뀌지만 실시간이 아닌 콘텐츠

    Cache Only(캐시 전용)

    네트워크는 무시하고, 오직 캐시만 본다.

    • 동작 흐름
      1. 캐시에서만 자원을 찾음
      2. 없으면 실패 응답 반환
    • 장점
      • 완전한 오프라인 대응 가능
      • 네트워크 불필요
    • 단점
      • 캐시 미스 시 실패(데이터 불일치 가능성 높음)
    • 사용 예시
      • 완전 오프라인 앱(설치 후 고정 콘텐츠)
      • 내부 도움말, 튜토리얼 페이지)

    Network Only(네트워크 전용)

    항상 실시간, 캐시는 전혀 사용하지 않는다.

    • 동작 흐름
      • 요청을 그대로 네트워크로 보낸다.
      • 캐시 관련 로직 없음
    • 장점
      • 항상 최신 데이터
      • 캐시 동기화 불필요
    • 단점
      • 오프라인/느린 네트워크 환경에 매우 취약
    • 사용 예시
      • 로그인, 결제 인증 요청
      • 민감 데이터(POST 요청, 토큰 등)

    어떤 전략을 언제 쓸까?

    상황추천 전략이유
    정적 파일(JS/CSS/폰트)Cache First변경 거의 없음, 속도 최우선
    API 요청, JSON 데이터Network First최신성 중요
    콘텐츠 페이지(블로그, 피드)Stale-While-Revalidate빠른 UX + 자동 업데이트
    완전 오프라인 앱Cache Only네트워크 불필요
    로그인/결제/POST 요청Network Only항상 실시간 통신 필요

    하나의 전략만 사용하기 어려운 상황에서는?

    실무에서는 하나의 전략만을 사용하지 않는다.

    상황에 따라서, 또는 자원 유형별로 다르게 적용하는 것이 필요하다.

    혼합 전략 사용.

    실무에서는?

    캐시 버전 관리

    캐시 이름에 버전을 붙여야 업데이트가 명확히 구분되고 관리하기 용이하다.

    const CACHE_VERSION = 'v3';
    const CACHE_NAME = `static-${CACHE_VERSION}`;
    

    캐시 청소 주기 설정

    이전 버전 캐시는 activate 이벤트에서 정리한다.

    self.addEventListener('activate', (event) => {
      event.waitUntil(
        caches.keys().then((keys) =>
          Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
        )
      );
    });
    

    Workbox 적극 활용

    Google이 만든 Workbox는 캐싱 전략, 버전 관리, 프리캐싱을 자동화한다.

    importScripts('<https://storage.googleapis.com/workbox-cdn/releases/6.5.3/workbox-sw.js>');
    

  • REST API와 GraphQL

    API란

    API(Application Programming Interface)는

    클라이언트(프론트엔드)와 서버(백엔드)가 데이터를 주고받는 약속이다.

    데이터를 어떻게 요청하고, 어떤 형태로 받을 것인지를 정의하는 규칙이며,

    이 약속의 구조는 REST와 GraphQL로 나뉜다.

    REST API

    REST(Representational State Transfer)는 2000년대 초부터 웹의 표준으로 자리잡았다. 자원(Resource) 중심의 설계된 구조.

    특징

    각 자원은 고유한 URL로 표현된다.

    HTTP 메서드(GET, POST, …)로 동작을 구분하며, 동작은 다음과 같다.

    • 조회(GET)
    • 생성(POST)
    • 수정(PUT/PATCH)
    • 삭제(DELETE)

    REST는 직관적이고 단순하며, URL을 보면 무슨 동작을 하는지 한눈에 파악하기 쉽다.

    GraphQL

    GraphQL은 2015년 Facebook이 만든 쿼리 언어로,

    REST가 엔드포인트 중심이라면- GraphQL은 요청 데이터 중심이다.

    query {
      user(id: 1) {
        name
        email
        posts(limit: 2) {
          title
          likes
        }
      }
    }
    

    이 한 줄의 쿼리로 사용자 정보와 게시글까지 한 번에 가져올 수 있다.

    특징

    • 단일 엔드포인트(/graphql)
    • 필요한 데이터만 요청(over-fetching 방지)
    • 하나의 요청으로 여러 리소스 결합(under-fetching 해결)

    Over-Fetching / Under-Fetching 두 문제를 모두 해결할 수 있는 GraphQL

    기존 REST는 데이터가 과하게 오거나 부족하게 오는 상황이 잦다는 한계점을 갖고 있다. 이를 해결하려면 별도의 API 엔드포인트를 새로 만들어야 했다.

    GraphQL을 통해 필요한 필드만 선택적으로 요청, 따라서 네트워크의 효율을 향상시킬 수 있고 여러 리소스를 한 번의 요청으로 결합할 수 있게 되었다.

    # REST에서는 두 번 요청해야 하는 걸 한 번에 처리
    query {
      user(id: 1) {
        name
        posts {
          title
        }
      }
    }
    

    응답 구조 비교

    GraphQL은 클라이언트가 요청한 필드만 정확히 반환한다.

    REST

    {
      "id": 1,
      "name": "Jihyun",
      "email": "jihyun@example.com",
      "phone": "010-1234-5678"
    }
    

    GraphQL

    {
      "data": {
        "user": {
          "name": "Jihyun"
        }
      }
    }
    

    캐싱과 네트워크 효율성에서

    REST

    • HTTP의 기본 캐싱(ETag, Cache-Control)을 활용하기 쉬움.
    • URL이 명확하므로 브라우저 캐시, CDN 캐시에도 유리.

    GraphQL

    • 요청이 POST 기반이라 캐싱이 어렵다.
    • 캐시는 대부분 클라이언트 단에서 수동 관리 (예: Apollo Client, Relay).
    • 대신 GraphQL은 부분 업데이트(Partial Cache Update)가 가능해, 세밀한 캐시 제어엔 오히려 강점을 가진다.

    에러 처리 및 타입 안정성에서는?

    REST

    • 주로 HTTP 상태 코드(200, 404, 500) 기반.
    • 단순하지만 세밀한 에러 구분이 어려움.

    GraphQL

    • 응답 본문에 errors 필드가 포함되어, 부분 성공/부분 실패를 명확히 표현 가능.
    • 스키마 기반(Type System)이라 컴파일 타임에서 타입 검증 가능.

    언제나 GraphQL 사용이 좋은걸까?

    API 안정성이 중요하거나, 단순 CRUD 환경에에서는 REST가 효율과 편리성을 더 갖춘 선택지가 된다.

    단, 모바일 환경이나 대화형 데이터, 복잡한 UI를 요하는 환경에서는 GraphQL을 사용하는 것이 적합하다.

    항목RESTGraphQL
    설계 철학자원(Resource) 중심데이터(Query) 중심
    엔드포인트여러 개단일 /graphql
    요청 데이터 제어서버 결정클라이언트 결정
    캐싱브라우저/CDN 친화적수동 관리 필요
    요청 효율성Over/Under-fetch 가능성 있음필요한 데이터만 정확히
    복잡한 관계 데이터별도 엔드포인트 필요한 쿼리로 결합 가능
    학습 난이도낮음중간 이상

    즉, 실무적으로는 REST, GraphQL 을 적절한 상황에 맞게 혼용해서 사용한다.

    예를 들어, REST를 기본 API로 사용하되 GraphQL을 고정되지 않은 데이터 요청(대시보드, 검색 결과, 필터)용으로 사용하는 식이다.

    → GraphQL은 빠른 UI 반응성과 유연한 데이터 조합이 필요할 때 사용하면 된다.

  • API 요청 최적화

    성능 병목은 API 호출에서 시작된다

    대부분의 웹 애플리케이션은 API 기반으로 움직인다.

    컴포넌트가 많아지고 상태가 복잡해질수록, 같은 데이터를 여러 컴포넌트가

    동시에 요청하는 일이 발생한다.

    예를 들어,

    A 컴포넌트가 유저 프로필 요청,

    B 컴포넌트가 유저 알림 요청,

    C 컴포넌트가 프로필 하위 정보 요청

    이때 프로필 정보가 서로 다른 위치에서 중복 호출되면

    다음과 같은 문제가 발생한다.

    • 서버 트래픽 낭비
    • 응답 지연
    • 사용자 체감 성능 저하

    그래서 API 요청 최적화는 성능 튜닝의 시작점이 된다.

    중복 요청 방지(Deduplication)

    React의 라이프사이클 특성상, 여러 컴포넌트가 같은 시점에 같은 API를 호출할 수 있다.

    이런 상황을 해결하려면 다음과 같은 방법이 있다.

    • SWR / React Query의 요청 Deduplication

      동일한 키로 요청하면 내부적으로 캐시를 공유, 중복 네트워크 호출을
      차단한다.
      const { data: user } = useSWR('/api/user', fetcher);
      const { data: profile } = useSWR('/api/user', fetcher);
      → 두 컴포넌트가 동시에 호출해도 네트워크는 1회만 발생한다
    • Axios 요청 취소(Cancellation Token)

      요청이 중복되었거나, 컴포넌트가 언마운트된 경우 기존 요청을
      취소한다. 중복 요청 방지는 불필요한 네트워크 소모를 막고 UI적으로 속도 저하를 해소.
      const controller = new AbortController();
      axios.get('/api/data', { signal: controller.signal });
      controller.abort();

    캐싱(Caching)

    캐싱?

    캐시는 한 번 불러온 데이터를 일정 시간 재사용하는 전략,

    API 캐싱에는 서버 캐시, CDN 캐시, 클라이언트 캐시가 있다.

    프론트엔드에서는 보통 클라이언트 캐시를 다룬다.

    캐싱을 통한 전략

    • SWR / React Query의 캐싱

      key 기반으로 데이터 저장 → 같은 요청 시 즉시 반환.

      TTL 설정이 가능하다(staleTime, cacheTitme)


      *SWR(Stale-While-Revalidate)

      캐시된 데이터를 즉시 보여주고, 백그라운드에서 최신화.
      useSWR('/api/posts', fetcher, { staleTime: 10000 });
    • 브라우저 캐시 활용

      Cache-Control, Etag 헤더를 사용하면 서버 응답을 브라우저가
      저장할 수 있음. React-Query는 브라우저 탭 간 캐시 공유도 지원한다.
    • IndexedDB / localStorage 캐시

      오프라인 지원이 필요한 경우, 클라이언트 영구 저장소를 활용.
      SWR + IndexedDB 커스텀 스토리지 패턴으로 자주 사용된다.


    배칭(Batching)

    배칭 요청

    여러 개의 작은 요청을 합쳐 한 번에 전송하면 네트워크 효율이 크게 향상됨.

    예를 들어,

    • GraphQL Batching
      GraphQL에서는 여러 쿼리를 한 번의 POST로 묶을 수 있다.
    [
      { "query": "{ user { name } }" },
      { "query": "{ posts { title } }" }
    ]

    → 서버는 여러쿼리를 한 번의 POST로 묶을 수 있음.

    • Custom Batch API
      REST API라도, 백엔드가 /batch 엔드포인트를 제공하면 다음처럼
      묶을 수 있다.
      {
      "requests": [
      { "url": "/api/user", "method": "GET" },
      { "url": "/api/notifications", "method": "GET" }
      ]
      }

    • 브라우저 요청 지연 처리
      일정 시간동안 요청을 모아서 보낸다.
      e.g. 입력 자동완성, 검색 제안 등 → debounce or throttle로 네트워크 호출 감소.

    병렬 vs 직렬 요청 최적화

    병렬 요청

    서로 의존하지 않는 요청은 Promise.all로 병렬 처리, 렌더링 블로킹 시간을 단축할 수 있다.

    const [user, posts] = await Promise.all([
      fetch('/api/user'),
      fetch('/api/posts')
    ]);

    직렬 요청

    앞 요청 결과가 다음 요청의 파라미터로 필요한 경우 순차 진행

    단, React Query에서는 enabled 옵션을 통해 의존적 요청을 깔끔하게 처리 가능.

    const { data: user } = useQuery('user', fetchUser);
    const { data: posts } = useQuery(['posts', user?.id], fetchPosts, {
      enabled: !!user
    });

    정리하자면

    • 중복 요청 방지: SWR / React Query deduping
    • 최신성 유지: Stale-While-Revalidate, revalidateOnFocus
    • 응답 속도 향상:캐싱, prefetching
    • 서버 부하 완화: 배치 요청, debounce
    • UX 개선: optimistic update, background sync