서비스 프론트엔드 성능 최적화 탐구: Nuxt.js 3와 캐싱 전략의 현실

커버 이미지

Nuxt.js 3와 Vue.js 3 기반의 서비스에 다양한 성능 최적화 기법을 검토하고 시도한 과정을 정리합니다.

단순히 기술을 도입하는 것을 넘어, 실제 서비스의 특성과 인프라 상황을 면밀히 분석하며 어떤 전략이 최적일지 판단한 과정과, 그 과정에서 마주한 현실적인 한계를 다룹니다.

여정의 시작: 막연한 목표에서 구체적인 질문으로

초기에는 "성능을 높이자!"라는 막연한 목표로 시작했습니다. 다양한 캐싱 전략(SWR, Redis, CloudFront 등)을 검토하고, In-Memory 캐싱을 구현하고, Nuxt.js의 기능들을 활용하려 했습니다.

하지만 실제 인프라 지표를 분석하면서 중요한 발견을 하게 됩니다. CPU 사용률은 평균 30~40%로 여유 구간에 머물러 있었고, 메모리는 할당된 2GB 중 200MB만 사용하여 약 10% 수준이었습니다. 트래픽도 하루 수 GB, 월 수십~백 GB 수준으로 안정적인 상태였습니다.

현재 저희 서비스의 인프라 사용량은 충분히 여유가 있었습니다. 이는 캐싱 전략만으로 비용을 획기적으로 절감하기에는 한계가 있다는 것을 의미했습니다. 오히려 UI/UX 변경 및 검색 엔진 최적화(SEO)에 미칠 영향 등 고려해야 할 부수적인 요소들이 많았습니다.

하지만 이 과정에서 Nuxt.js의 강력한 기능들과 웹 성능 최적화에 대한 실무에 적용 가능한 판단 기준을 확보했습니다.

웹 성능 최적화 개념 탐구

이번 여정에서 깊이 있게 연구했던 주요 개념들을 정리합니다.

Nuxt.js 3 Route Rules와 SWR

Nuxt.js 3는 nuxt.config.ts 파일에서 routeRules를 통해 특정 라우트 경로에 대한 서버 측 동작을 세밀하게 정의할 수 있도록 해줍니다. SSR(Server-Side Rendering), SSG(Static Site Generation), SWR(Stale-While-Revalidate) 캐싱, 리디렉션 등 다양한 렌더링 및 캐싱 전략을 유연하게 제어할 수 있습니다.

특히, SWR(Stale-While-Revalidate) 전략은 사용자 경험과 데이터 신선도 사이의 균형을 맞추는 데 매력적인 방식입니다. 캐시된 데이터를 먼저 사용자에게 보여주어 빠른 초기 응답을 제공하고, 동시에 백그라운드에서 최신 데이터를 다시 검증하여 캐시를 업데이트하는 방식입니다.

이론적으로는 완벽해 보였습니다. 하지만 여기서 첫 번째 현실적인 한계를 마주합니다.

발견한 한계: 다중 컨테이너 환경의 함정

저희 서비스는 AWS ECS Fargate에서 6개의 태스크(컨테이너)가 동시에 운영되고 있습니다. Nitro의 기본 SWR 캐시는 각 컨테이너의 메모리 내부에만 존재하기 때문에:

컨테이너 A로 들어온 /items/detail?id=123 요청
→ A에서 DB 조회 + SSR 렌더링 후 A의 메모리에만 캐시

같은 URL이 컨테이너 B로 들어오면
→ B 입장에서는 "첫 요청"이므로 다시 DB/SSR 수행

결과: 컨테이너 수만큼 "첫 요청"이 중복 발생

Redis 같은 공유 캐시 저장소가 있다면 모든 컨테이너가 동일한 캐시를 공유하여 글로벌 캐시 히트율이 크게 개선됩니다. 하지만 Redis 도입에 따른 추가 비용과 운영 복잡도가 발생하고, 현재 CPU와 메모리 여유를 고려할 때 즉각적인 비용 절감 효과는 제한적이었습니다. 결국 투자 대비 효과(ROI)에 대한 의문이 생길 수밖에 없었습니다.

In-Memory Cache: 빠르지만 휘발적인 캐싱

In-Memory Cache는 애플리케이션이 실행되는 동안 메인 메모리(RAM)에 데이터를 저장하는 방식입니다. 디스크나 외부 네트워크를 통해 데이터를 가져오는 것보다 훨씬 빠르게 접근할 수 있어 이론적으로는 매우 매력적입니다.

저희는 '계정 상태 정보' 데이터와 같이 여러 곳에서 반복적으로 조회되지만, 특정 액션(구독/구독 해제)이 있기 전까지는 잘 변하지 않는 데이터에 이 방식을 구현해보기로 했습니다.

구현 고민: 클라이언트 vs 서버 사이드 캐싱

클라이언트 사이드 In-Memory 캐싱은 상대적으로 구현이 간단합니다. 브라우저 메모리에 데이터를 저장하여 동일 세션 내에서 빠르게 데이터에 접근할 수 있지만, 페이지를 새로고침하면 캐시가 초기화된다는 제약이 있습니다.

하지만 서버 사이드 캐싱은 더 복잡한 고려사항이 필요했습니다. 6개 컨테이너의 분산 캐시 문제로 인해 Redis 없이는 캐시 히트율이 저하될 수밖에 없었고, Redis를 도입하면 추가 비용과 운영 복잡도가 증가하는 상황이었습니다.

Client-Only Component: SSR과 CSR의 균형

SSR(Server-Side Rendering)은 초기 로딩 성능과 SEO에 유리하지만, window 객체나 특정 브라우저 API에 의존적인 컴포넌트, 또는 사용자별로 맞춤화된 동적 콘텐츠가 Hydration Mismatch 오류를 일으킬 수 있습니다.

Nuxt.js의 <client-only> 컴포넌트는 이러한 문제를 해결할 수 있는 좋은 도구입니다. 하지만 여기서 또 다른 트레이드오프가 발생합니다:

Client-only 사용 증가 → SSR 범위 축소 → SEO 악영향 가능성

Skeleton Loading: 부드러운 로딩 경험

데이터 로딩 중 사용자에게 빈 화면이나 갑작스러운 레이아웃 시프트를 보여주는 것은 좋지 않은 UX입니다. 스켈레톤 로딩은 데이터가 로딩되는 동안 실제 콘텐츠의 구조와 레이아웃을 미리 보여주는 플레이스홀더 UI 기법입니다.

이는 실제로 효과적인 UX 개선 방법이었지만, 추가 개발 공수가 필요했습니다. 각 컴포넌트별로 스켈레톤 디자인을 만들어야 했고, 이를 위해 디자이너와의 협업이 필수적이었습니다. 또한 유지보수해야 할 포인트가 증가한다는 점도 고려해야 했습니다.

실제 적용해본 것들

위 개념들을 바탕으로, 실제로 몇 가지 최적화 기법을 프로젝트에 시험 적용해보았습니다.

1. In-Memory API 캐싱 프로토타입

'계정 상태 정보'는 사용자 관련 상태를 나타내는 정보로, 서비스 전반에서 매우 빈번하게 조회되는 데이터였습니다. 초기에는 매번 API를 호출하여 이 정보를 가져왔기 때문에 불필요한 네트워크 부하가 발생했습니다.

Before (캐싱 없음)

// src/api/user-status-api.ts (캐싱 적용 전)
export async function fetchUserStatusInfo(
  payload: {
    ids: Array<string>; // 문자열 배열만 지원
    type: string;
  },
  nuxtApp?: ReturnType<typeof useNuxtApp>
) {
  if (!payload.ids || payload.ids.length === 0) return [];
  if (!nuxtApp) nuxtApp = useNuxtApp();
 
  try {
    // 매번 모든 ID를 API로 요청
    const { data } = await nuxtApp.$api.get(
      `/api/v1/users/${payload.ids}/status-info`
    );
    return data.statusInfoList;
  } catch (error) {
    const appStore = useApplicationStore();
    appStore.handleError({
      error,
      api: "fetchUserStatusInfo",
      isAlert: false,
    });
    return [];
  }
}

실험적 구현 (In-Memory 캐싱)

// src/api/user-status-api.ts (In-Memory 캐싱 프로토타입)
import {
  splitStatusInfoByCache,
  setCachedStatusInfo,
  updateCachedStatus,
} from "~/utils/status-cache";
 
export async function fetchUserStatusInfo(
  payload: {
    ids: Array<string> | string; // 문자열/배열 모두 지원
    type: string;
  },
  nuxtApp?: ReturnType<typeof useNuxtApp>
) {
  if (!payload.ids || payload.ids.length === 0) return [];
  if (!nuxtApp) nuxtApp = useNuxtApp();
 
  // 1. 유연한 입력 처리
  const idArray = Array.isArray(payload.ids)
    ? payload.ids
    : payload.ids.split(",").map((id) => id.trim());
 
  // 2. 캐시 상태에 따라 ID 분리
  const { cached, missing } = splitStatusInfoByCache(idArray);
 
  // 3. 모두 캐시되어 있으면 API 호출 생략
  if (missing.length === 0) {
    return cached;
  }
 
  // 4. 캐시되지 않은 ID만 API 요청
  try {
    const { data } = await nuxtApp.$api.get(
      `/api/v1/users/${missing.join(",")}/status-info`
    );
 
    const freshStatusInfoList = data.statusInfoList;
    setCachedStatusInfo(freshStatusInfoList); // 새 데이터 캐시 저장
 
    // 5. 캐시된 데이터와 새 데이터 병합
    return [...cached, ...freshStatusInfoList];
  } catch (error) {
    const appStore = useApplicationStore();
    appStore.handleError({
      error,
      api: "fetchUserStatusInfo",
      isAlert: false,
    });
 
    // 6. 에러 시에도 캐시된 데이터 반환 (Graceful Degradation)
    return cached;
  }
}

개발 환경에서 실제로 프로토타입을 돌려보니, 숫자로 확인했을 때 확실히 인상적이긴 했습니다.

지표캐싱 미적용In-Memory 캐싱 적용개선율
API 응답 시간 (캐시 미스)약 180~220ms약 180~220ms (동일)-
API 응답 시간 (캐시 히트)-약 3~8ms약 95~97% 단축
클라이언트 캐시 히트율 (세션 내)-약 60~75%-
API 호출 횟수 (10회 탐색 기준)10회약 3~4회약 60~70% 감소

캐시가 히트하면 네트워크 요청 자체를 건너뛰니 응답이 체감상 즉시 오는 수준이었습니다. 다만 이건 어디까지나 같은 세션 내에서 동일 데이터를 반복 조회하는 "이상적인" 시나리오에서의 수치입니다.

솔직히 말하면, 프로덕션에 바로 올리기에는 찝찝한 부분이 많았습니다. 새로고침 한 번이면 캐시가 날아가서 히트율이 0%로 떨어지고, 서버 사이드로 확장하자니 Redis가 필요한데, Redis 없이 6개 컨테이너 각각에서 독립 캐싱을 돌리면 히트율이 15~20% 수준에 머물 것으로 예상되었습니다. 무엇보다 현재 트래픽에서 이게 실제 비용 절감으로 이어질지가 불투명했습니다.

2. <client-only>와 스켈레톤 로딩 실험

저희 서비스의 헤더는 사용자 로그인 상태에 따라 검색 영역, 로그인 버튼, 장바구니, 아바타 등 다양한 UI가 동적으로 변경됩니다. 이러한 동적인 요소들을 SSR 환경에서 처리하면 Hydration Mismatch 오류 가능성이 있었습니다.

실험적 구현

<!-- src/components/shared/AppHeader.vue (스켈레톤 실험) -->
<template>
  <header class="app-header">
    <div class="action-group">
      <client-only>
        <template #fallback>
          <!-- 클라이언트 컴포넌트 로딩 전 보여줄 스켈레톤 UI -->
          <div class="action-group-skeleton">
            <content-placeholders>
              <content-placeholders-img class="skeleton-icon" />
              <content-placeholders-img class="skeleton-button" />
            </content-placeholders>
          </div>
        </template>
        <!-- 실제 검색 및 로그인 관련 UI 요소들 -->
        <div class="search-area" @click="emit('searchOpen', true)">
          <p class="b3 light sl">검색어를 입력해주세요.</p>
          <IconSearch />
        </div>
        <div v-if="!isUserLoggedIn">
          <AuthButton @click="openLoginModal" />
        </div>
        <template v-else-if="isUserLoggedIn">
          <button class="cart-button" @click="router.push('/checkout')">
            <IconCart :activated="true" :size="24" />
          </button>
          <button class="profile-button">
            <Popover>
              <template #anchor>
                <Avatar :imageUrl="currentUser.profileImage" :size="32" />
              </template>
            </Popover>
          </button>
        </template>
      </client-only>
    </div>
  </header>
</template>

이 방식은 Hydration Mismatch 오류를 방지하고 레이아웃 시프트를 완화하여 부드러운 사용자 경험을 제공했습니다.

하지만 트레이드오프도 존재했습니다. 헤더의 SSR을 비활성화하면 초기 HTML에서 헤더 내용이 제외됩니다. 헤더는 주요 콘텐츠가 아니므로 SEO에 미치는 영향이 미미하지만, 이 방식을 페이지 전체로 확대 적용하면 SEO 문제가 발생할 수 있었습니다.

3. Nuxt.js Route Rules 설정 실험

nuxt.config.ts 파일에 Route Rules를 설정하여 페이지별로 최적화된 렌더링 및 캐싱 전략을 테스트해보았습니다.

// nuxt.config.ts (실험적 설정)
export default defineNuxtConfig({
  routeRules: {
    // 홈 페이지는 5분 SWR 캐시
    "/": { swr: 60 * 5 },
    // 상품 및 사용자 상세 페이지도 5분 SWR 캐시
    "/items/**": { swr: 60 * 5 },
    "/users/**": { swr: 60 * 5 },
    // 관리자 페이지는 SSR 비활성화
    "/admin/**": { ssr: false },
    // 약관, 개인정보처리방침 등은 정적 생성
    "/about": { prerender: true },
    "/terms": { prerender: true },
    "/privacy": { prerender: true },
    // 특정 페이지는 클라이언트 전용 렌더링
    "/pages/items/*.vue": { ssr: false },
    "/pages/dashboard.vue": { ssr: false },
    "/pages/user/profile/favorites/list.vue": { ssr: false },
  },
});

이론적으로는 페이지별로 최적화된 렌더링 전략을 적용하여 서버 부하를 감소시키고 응답 속도를 개선할 수 있었습니다.

하지만 실제로 발견한 문제점들이 있었습니다. 첫째, 6개 컨테이너 환경에서는 SWR 효율이 저하되었습니다. 각 컨테이너가 독립적인 캐시를 가지면서 중복 렌더링이 발생했고, Redis 없이는 클러스터 전체의 캐시 히트율을 개선하기 어려웠습니다.

둘째, 개인화 UI를 분리하는 작업이 예상보다 복잡했습니다. 페이지 캐싱을 확대하려면 개인화 영역을 분리해야 했고, 이는 SSR 영역과 CSR 영역을 명확히 나누는 대규모 리팩토링을 의미했습니다. 여기에 Skeleton UI를 추가로 구현해야 하는 공수도 만만치 않았습니다.

셋째, SEO 관점의 트레이드오프가 있었습니다. CSR 비중이 증가하면 초기 HTML에 담기는 정보가 감소하고, 검색 엔진 크롤러가 핵심 정보를 인식하기 어려워집니다. 특히 상품 상세 페이지의 구매 버튼이나 가격 같은 중요한 요소가 CSR로 전환되면 SEO에 불리하게 작용할 수 있었습니다.

마주한 현실: 이론과 실제의 간극

1. 비용 절감 효과의 한계

초기 목표는 캐싱을 통한 비용 절감이었습니다. 하지만 실제 인프라 지표를 분석하면서 주목할 만한 사실을 발견했습니다. 현재 서비스의 CPU 사용률은 30~40%로 여유가 60~70%나 남아 있었고, 메모리는 할당된 2GB 중 200MB만 사용하여 겨우 10% 수준이었습니다. 트래픽도 월 수십~백 GB로, CloudFront 무료 구간인 월 1TB와 비교하면 충분히 여유 있는 상태였습니다.

이런 상황에서 SWR이나 Redis로 CPU와 데이터베이스 부하를 줄여도 당장 리소스를 축소할 만큼 극적인 변화는 아니었습니다. CloudFront를 도입해도 현재 트래픽 규모에서는 비용 절감 효과가 제한적일 수밖에 없었습니다. 결국 캐싱 도입에 필요한 투자 대비 실제 절감되는 비용이 크지 않다는 결론에 도달했습니다.

2. 개발 공수의 현실

페이지 캐싱을 적극적으로 활용하려면 생각보다 많은 작업이 필요했습니다. 먼저 로그인 사용자 정보, 찜한 상품, 맞춤 추천 같은 개인화 UI를 CSR로 전환하는 분리 작업이 필수적이었습니다. 이는 기존 컴포넌트를 대대적으로 리팩토링해야 한다는 의미였습니다.

또한 각 컴포넌트별로 로딩 플레이스홀더를 디자인하는 Skeleton UI 구현도 필요했습니다. 이를 위해서는 디자이너와의 협업은 물론 상당한 개발 시간을 투입해야 했습니다. 여기에 개인화 데이터만 제공하는 경량 API 엔드포인트를 새로 개발해야 했고, 이는 백엔드 개발 리소스까지 필요로 했습니다.

결국 이 모든 것을 종합해보니, 단순한 설정 변경이 아니라 별도 프로젝트급 작업이 요구되는 상황이었습니다.

3. SEO와 UX의 딜레마

캐싱 전략을 적용하면서 SEO와 UX 사이의 미묘한 균형을 고민하게 되었습니다. SEO 관점에서 보면, CSR 비중이 증가할수록 초기 HTML에 담기는 정보가 부족해집니다. 검색 엔진 크롤러가 핵심 정보를 제대로 인식하지 못하면 검색 랭킹에 불리하게 작용할 가능성이 있었습니다.

반면 UX 관점에서는 Skeleton UI로 어느 정도 완화할 수 있지만, 초기 로딩 시 "빈 영역"이 늘어나는 느낌을 완전히 없앨 수는 없었습니다. 특히 데이터 로딩이 지연될 경우 사용자가 불편을 느낄 수 있었습니다.

저희 서비스는 네이버 앱 유입이 많은 구조라 SEO 중요도가 상대적으로 낮을 수 있다는 판단도 있었습니다. 하지만 그렇다고 검색 유입을 완전히 무시할 수는 없었습니다.

4. 운영 복잡도 증가

캐싱 솔루션 도입은 운영 복잡도를 크게 높이는 결과를 가져왔습니다. Redis를 도입하면 추가 인프라 비용이 발생하는 것은 물론, 캐시 무효화 전략을 설계하고 운영해야 했습니다. 장애가 발생했을 때 대응 방안도 미리 마련해두어야 했습니다.

CloudFront를 도입하는 경우에도 캐시 정책을 관리하고, Origin과 Edge 간 동기화를 고려해야 했습니다. 여기에 문제가 생겼을 때 디버깅 복잡도까지 증가하면서, 전체적인 시스템 관리 부담이 상당히 커질 수밖에 없었습니다.

판단 근거와 원칙

1. 측정 없이 최적화 없다

막연히 "캐싱하면 좋아질 것"이라는 기대가 아니라, 실제 모니터링 지표를 분석했기에 현실적인 판단을 내릴 수 있었습니다. CPU와 메모리 사용률, 트래픽 규모를 면밀히 살펴보고, 예상 비용 절감액과 개발 투입 비용을 비교하면서 데이터 기반의 의사결정을 할 수 있었습니다.

2. 컨텍스트가 모든 것을 결정한다

6개 컨테이너 환경에서 Nitro의 기본 SWR은 효율이 떨어집니다. 하지만 단일 인스턴스라면 충분히 효과적일 수 있습니다.

"좋은 아키텍처"는 절대적이지 않으며, 현재 시스템 구조와 운영 환경에 따라 달라집니다.

3. 최신 기술이 무조건 최적의 솔루션은 아니다.

SWR, Redis, CloudFront는 모두 훌륭한 기술입니다. 하지만 우리 서비스에 지금 필요한가?라는 질문이 더 중요했습니다. 경우에 따라서는 트래픽이 증가하는 시점에 도입하는 것이 더 효율적입니다.

4. 트레이드오프의 종합적 판단이 중요하다.

캐싱 전략 하나를 도입하더라도 성능 개선 효과, 개발 공수, SEO 영향, 운영 복잡도, 실제 비용 절감액 등 여러 요소를 함께 고려해야 합니다. 이 모든 요소를 종합적으로 판단해야 현명한 의사결정이 가능합니다.

5. "지금 당장"보다 "적절한 시점"

현재는 인프라 리소스 최적화에 집중하고 있습니다. 메모리를 2GB에서 1GB로 축소하고, Fargate 리소스를 다운사이징하여 직접적인 비용 절감 효과를 얻고 있습니다. 캐싱 전략은 장기적 관점에서 단계적으로 재검토할 계획입니다.

미래에 트래픽이 10배 증가하거나 서버 부하가 한계에 도달할 때, 오늘의 학습과 프로토타입이 빛을 발할 것입니다. 그때는 Redis와 CloudFront 도입을 빠르게 실행할 수 있을 것이라 확신합니다.

Nuxt.js 개발 경험 - 긍정적인 발견들

실제 프로덕션 적용은 하지 않았지만, 탐구 과정에서 Nuxt.js와 Vue 3의 강력한 기능들을 깊이 있게 경험했습니다.

Vue.js Composition APIrefcomputed를 사용하면서 반응형 캐시 데이터 관리가 얼마나 직관적인지 체감했고, 재사용 가능한 composable 로직을 작성하기도 매우 편리했습니다.

useNuxtApp()을 통해 공유 컨텍스트로 $api 헬퍼에 접근할 수 있었고, 플러그인 시스템과도 자연스럽게 통합되는 경험이 인상적이었습니다.

Pinia Store는 전역 상태 관리와 캐싱 로직을 유기적으로 연동할 수 있었고, 타입스크립트 지원도 우수했습니다.

Nitro의 routeRules는 선언적이고 직관적인 설정 방식으로, 코드베이스를 크게 침범하지 않아 실험하기에 좋은 구조였습니다.

특히 Nuxt의 모듈화된 아키텍처 덕분에 캐싱 로직을 실험적으로 구현하고 테스트한 후, 최종적으로 적용하지 않더라도 코드베이스를 크게 훼손하지 않았습니다.

결론

"캐싱을 도입했다"는 화려한 성공담은 쓸 수 없게 되었지만, 대신 왜 지금은 도입하지 않는 것이 현명한가를 명확하게 설명할 수 있게 되었습니다.

현재 전략

우선 인프라 리소스 튜닝에 집중하고 있습니다. Fargate 메모리를 2GB에서 1GB로 다운사이징하고, 태스크 수 조정을 검토하여 직접적이고 즉각적인 비용 절감을 이루고 있습니다.

동시에 선택적 최적화를 적용하고 있습니다. 약관이나 개인정보처리방침 같은 정적 페이지에는 SSG를 적용하고, 관리자 페이지에는 ssr: false 설정을 적용하는 식으로 영향 범위가 작고 리스크가 낮은 부분부터 시작했습니다.

장기적 관점에서는 캐싱 전략 프로토타입 코드를 유지하면서 트래픽 증가 시점에 빠르게 적용할 수 있도록 준비하고 있습니다. Redis와 CloudFront 도입 시나리오도 문서화해두었습니다.

미래를 위한 준비

트래픽이 증가하고 서비스가 확장되는 시점에는 오늘 학습한 SWR, Redis, CloudFront 전략을 활용할 것입니다. 이미 작성된 프로토타입 코드를 기반으로 빠르게 적용할 수 있고, SEO 중요도에 따라 SSR과 CSR의 비율을 유연하게 조정할 수 있습니다.

서비스 특성 고려

저희 서비스는 네이버 앱 유입이 많은 특성을 가지고 있습니다. GA 등으로 실제 유입 경로를 분석한 후 SEO 중요도가 낮다고 판단되면, 과감한 CSR 전환으로 서버 비용을 최적화하는 것도 가능합니다. 물론 이는 장기적 관점에서 신중하게 재검토할 계획입니다.

마무리하며

이번 프로젝트의 핵심은 기술을 적용하는 것보다 적절한 시점을 판단하는 것이었습니다.

실제 비즈니스 임팩트, 개발 공수 대비 효과, SEO나 UX와의 균형을 종합적으로 판단한 결과, 현 시점에서는 캐싱 도입보다 인프라 리소스 튜닝이 더 효과적이라는 결론을 내렸습니다. 프로토타입 코드와 도입 시나리오는 문서화해두었으므로, 트래픽 증가 시점에 빠르게 적용할 수 있는 상태입니다.


참고 자료