Skip to content

Chapter 12. HTTP 레이어 격리와 프론트엔드 에러 바운더리 ​

"11장까지 작성된 코드 곳곳에 흩어져 있는 axios.get을 보라. 컴포넌트가 네트워크 주소와 HTTP 상태 코드를 직접 아는 죄악을 멈추고, Repository 계층과 에러 바운더리로 격리하라."


12.1 컴포넌트에서 Axios를 완전히 몰아내자 ​

1장부터 11장까지 우리는 편의상 컴포넌트와 스토어 내부에서 axios.get('/api/posts')를 직접 호출해 왔습니다.

만약 백엔드 팀에서 다음과 같은 변경 사항을 통보해 온다면 어떻게 될까요?

  • "인증 토큰이 만료되면 401이 떨어지니 Refresh Token으로 자동 재발급받아 재요청하세요."
  • "모든 API 기본 주소 앞에 /api/v2가 붙습니다."

지금 구조에서는 수십 군데의 axios 호출 코드를 열어 일일이 고쳐야 합니다.
백엔드에서 Controller와 DB 사이에 Service와 Repository를 두듯이, 프론트엔드도 HTTP 클라이언트 인프라와 비즈니스 Repository를 명확히 분리해야 합니다.


12.2 [1단계] 중앙 집중식 httpClient.ts와 인터셉터 AOP ​

토큰 자동 주입과 만료 시 재발급(Refresh Token Rotation)을 전담하는 싱글톤 HTTP 클라이언트를 만듭니다.

[코드 12-1] src/api/httpClient.ts ​

typescript
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';

export const httpClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '',
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' }
});

// 1. 요청 인터셉터: 모든 발신 요청에 JWT 토큰 자동 첨부
httpClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = localStorage.getItem('ACCESS_TOKEN');
  if (token && config.headers) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 2. 응답 인터셉터: 401 에러 시 백그라운드 토큰 자동 갱신 AOP
httpClient.interceptors.response.use(
  (res) => res,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      try {
        const refreshRes = await axios.post('/api/auth/refresh', {
          refreshToken: localStorage.getItem('REFRESH_TOKEN')
        });
        const newToken = refreshRes.data.accessToken;
        localStorage.setItem('ACCESS_TOKEN', newToken);

        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return httpClient(originalRequest); // 이전 실패 요청 재전송!
      } catch (refreshErr) {
        localStorage.clear();
        window.location.href = '/login?expired=true';
        return Promise.reject(refreshErr);
      }
    }
    return Promise.reject(error);
  }
);

12.3 [2단계] 비즈니스 postRepository.ts 추상화 ​

이제 컴포넌트와 Pinia 스토어는 Axios나 HTTP URL을 전혀 알지 못합니다. 오직 Repository의 메서드만 호출합니다.

[코드 12-2] src/api/postRepository.ts ​

typescript
import { httpClient } from './httpClient';
import type { Post, CreatePostRequest, PageResult } from '@/types/post';

export const postRepository = {
  async getPosts(params: { page: number; size: number; category?: string; keyword?: string }) {
    const res = await httpClient.get<PageResult<Post>>('/api/posts', { params });
    return res.data;
  },

  async getPostById(id: number) {
    const res = await httpClient.get<Post>(`/api/posts/${id}`);
    return res.data;
  },

  async createPost(payload: CreatePostRequest) {
    const res = await httpClient.post<Post>('/api/posts', payload);
    return res.data;
  },

  async deletePost(id: number) {
    await httpClient.delete(`/api/posts/${id}`);
  },

  async toggleLike(id: number) {
    const res = await httpClient.post<Post>(`/api/posts/${id}/like`);
    return res.data;
  }
};

이로써 usePostStore.ts의 axios.get(...)은 모두 postRepository.getPosts(...)로 깔끔하게 교체되었습니다.


12.4 프론트엔드 에러 바운더리: onErrorCaptured ​

서버가 예기치 않게 500 에러를 뿜거나 렌더링 중 오류가 터졌을 때, 화면 전체가 하얗게 죽어버리는 현상(White-out)을 방지하기 위해 에러 바운더리로 감쌉니다.

[코드 12-3] src/components/common/ErrorBoundary.vue ​

html
<template>
  <div v-if="hasError" class="error-fallback-box">
    <h3>⚠️ 일시적인 시스템 오류가 발생했습니다</h3>
    <p>{{ errorMessage }}</p>
    <button @click="resetError">다시 시도하기</button>
  </div>
  <slot v-else></slot>
</template>

<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue';

const hasError = ref(false);
const errorMessage = ref('');

onErrorCaptured((err, instance, info) => {
  hasError.value = true;
  errorMessage.value = err.message || '알 수 없는 오류가 발생했습니다.';
  console.error(`💥 [Error Boundary 포착] 위치: ${info}`, err);
  
  // 에러가 상위 부모로 계속 전파되어 앱 전체가 크래시되는 것을 방지!
  return false;
});

function resetError() {
  hasError.value = false;
  errorMessage.value = '';
}

12.5 다음 챕터로 이어지는 질문 ​

이제 게시판 시스템은 완벽한 인프라 분리와 에러 안전망을 갖추었습니다.

하지만 실제 사내 운영에 배포하자마자 생각지 못한 병목이 터졌습니다:
사내 인기 기술 아티클에 사우들의 댓글이 10,000개 넘게 달리자, 상세 페이지를 열 때마다 브라우저가 5초 동안 굳어버리고 스크롤이 버벅이는 현상이 발생한 것입니다!

"화면에 10,000개의 댓글이 존재하더라도, 60fps로 매끄럽게 스크롤되도록 만들 수는 없을까?"

이어지는 Chapter 13에서는 shallowRef를 통한 반응형 오버헤드 삭감과, **60줄로 직접 만드는 초경량 가상 스크롤(Virtual Scroll)**을 우리 게시판에 장착해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub