문서 읽는 데 83분 · C6

C-6: 서버 상태 ① — Axios·useQuery로 가져온다

목차 71
전체 59강 중 17강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

지난 시간 마지막에 이렇게 약속했습니다.

다음 시간에는 그 배열을 걷어내고 진짜 서버에서 가져옵니다. 오전에 만드신 백엔드 API를 오후에 화면에 띄우는 시간이에요.

안녕하세요, 홍순구입니다. 오늘 그 약속을 지킵니다.

우리 앱의 게시물은 지금 data/feed.ts라는 파일 안에 배열로 적혀 있는데, 처음 React를 배울 때 화면에 뭐라도 띄우려고 손으로 적어둔 것이 여기까지 왔어요. 오늘 그것을 걷어내고 localhost:8090에 물어보는데, 도구부터 들이지는 않을 거예요.

먼저 손으로 짜봅니다. B-4에서 "데이터 가져오기는 effect로 풀 문제가 아니다"라고 예고만 하고 넘어갔었죠. 오늘 그 방식으로 직접 만들어 보고, 무엇이 힘든지 숫자로 확인한 다음에 도구를 들입니다. 그래야 그 도구가 무엇을 대신해주는지 알 수 있어요.

💡 오늘 수업의 핵심 — "서버가 준 값은 우리 것이 아니라 잠깐 빌려온 사본이다"

지금까지 다룬 값은 전부 우리가 주인이었습니다. useState로 담은 값은 우리가 바꾸기 전에는 절대 안 변했고, 주소에 적은 값도 우리가 옮기기 전에는 그대로였어요.

서버 값은 달라서, 우리가 아무것도 안 해도 틀려집니다. 다른 사람이 좋아요를 누르는 순간 우리 화면의 숫자는 낡은 값이 돼요.

오늘은 그 성질이 코드를 어떻게 바꾸는지 봅니다.

🎯 학습 목표

  • 코드 안에 적힌 배열을 걷어내고 실제 HTTP 요청으로 피드를 가져옵니다.
  • fetch와 Axios가 실패를 다루는 방식이 어떻게 갈리는지 직접 확인합니다.
  • 주소·응답 껍데기·인증 토큰을 화면에서 걷어내 한 곳에 모읍니다.
  • 만료된 토큰을 화면이 모르는 사이에 갱신해 요청을 다시 보냅니다.
  • useQuery로 옮긴 뒤 요청 횟수가 어떻게 달라지는지 같은 지표로 다시 잽니다.
  • 캐시의 이름표를 만들고, 받아둔 값을 얼마나 믿을지 시간으로 정합니다.
텍스트
 오늘의 여정

 1. 배열을 걷어낸다            손으로 짜보고 아픈 곳을 센다
        
 2. fetch 는 404 에 안 던진다   그래서 Axios 를 쓴다
        
 3. 주소와 껍데기를 한 곳에      단일 인스턴스와 인터셉터
        
 4. 토큰도 인터셉터가            401 이면 갱신하고 다시 보낸다
        
 5. 서버 값은 낡는다            지금까지 다룬 값과 성질이 다르다
        
 6. 세 갈래가 한 줄로           useQuery
        
 7. 키가 캐시의 이름표          무엇을 달라고 했는지가 들어간다
        
 8. 두 시간                    얼마나 믿을지 · 얼마나 남길지

Step 1: "코드 안에 적힌 게시물을 걷어낸다"

먼저 서버를 켭시다

오늘부터 화면이 localhost:8090에 물어봅니다. 백엔드 과목을 함께 듣고 계시면 그쪽 Spring Boot를 8090 포트로 띄우시면 돼요. 아직 백엔드가 없다면 저장소에 연습용 서버를 하나 넣어뒀습니다.

Bash
node apps/api-stub/server.mjs

설치할 것은 없고, 이 서버가 내려주는 응답은 백엔드 과목에서 만드는 것과 같은 껍데기를 쓰고 있어요.

텍스트
 GET /api/posts

 {
   "success": true,
   "data": [ { "id": 1, "username": "jaehoon", ... }, ... ],
   "message": null
 }

success · data · message 세 칸. 백엔드 과목에서 ApiResponse<T>라는 이름으로 만든 그 껍데기입니다. 성공하면 data에 알맹이가 들어오고 message는 비어 있어요. 실패하면 반대가 됩니다.

손으로 짜봅니다

HomePage를 열어보면 지금은 이렇게 시작합니다.

tsx
// apps/web-spa/src/routes/HomePage.tsx (고치기 전)
import { feedPosts } from '../data/feed';

export function HomePage() {
  const { posts, likedCount, ... } = useFeed(feedPosts);

import 한 줄로 게시물이 이미 손에 있었지만, 서버에서 가져오면 이게 안 되죠. 요청을 보내고, 기다리고, 받아야 합니다.

그 전에 화면을 둘로 나눕니다

가져오는 일과 그린 다음의 일은 성격이 달라서, 지금 HomePage가 하던 일 중에 게시물을 이미 손에 쥔 다음의 일을 통째로 FeedSection으로 옮기겠습니다.

tsx
// apps/web-spa/src/components/FeedSection.tsx
interface FeedSectionProps {
  posts: Post[];
}

// 게시물을 이미 손에 쥔 다음부터의 일만 맡는다.
// 가져오는 일은 이 컴포넌트를 그리는 쪽이 한다.
export function FeedSection({ posts: initialPosts }: FeedSectionProps) {
  const { posts, likedCount, toast, toggleLike, reachBottom, dismissToast } = useFeed(initialPosts);
  ...
}

내용은 지금 HomePage에 있던 것 그대로예요. 좋아요 개수 문단, Section, Feed, 알림까지 전부요.

⚠️ 왜 굳이 나눴는지는 이 Step 끝에서 확인합니다. 나누지 않으면 화면이 영영 비어 있는 문제가 생겨요.

이제 가져오기

tsx
// apps/web-spa/src/routes/HomePage.tsx
const FEED_URL = 'http://localhost:8090/api/posts';

export function HomePage() {
  // 세 갈래를 손으로 들고 있어야 한다. 데이터·기다림·실패.
  const [posts, setPosts] = useState<Post[] | null>(null);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  useEffect(() => {
    // 늦게 온 응답이 이미 떠난 화면을 덮어쓰지 않게 표시를 남긴다
    let cancelled = false;

    fetch(FEED_URL)
      .then((response) => response.json())
      .then((body) => {
        if (cancelled) {
          return;
        }

        // fetch 는 404 든 500 이든 성공으로 친다. 봉투를 열어봐야 안다.
        if (body.success === false) {
          setErrorMessage(body.message);
          return;
        }

        setPosts(body.data);
      })
      .catch(() => {
        if (cancelled) {
          return;
        }

        setErrorMessage('피드를 불러오지 못했어요');
      });

    return () => {
      cancelled = true;
    };
  }, []);

그리고 화면은 세 갈래로 갈립니다.

tsx
// apps/web-spa/src/routes/HomePage.tsx
  if (errorMessage !== null) {
    return <p className="text-sm text-danger-strong">{errorMessage}</p>;
  }

  // 게시물이 도착한 뒤에야 피드를 그린다.
  if (posts === null) {
    return <p className="text-sm text-faint">피드를 불러오는 중이에요…</p>;
  }

  return <FeedSection posts={posts} />;
}

브라우저를 열어보세요. 잠깐 "피드를 불러오는 중이에요…"가 떴다가 게시물 열 장이 뜹니다. 서버를 끄고 새로고침하면 "피드를 불러오지 못했어요"가 뜨고요.

됐습니다. 배열은 걷어냈고 진짜 서버에서 가져오지만, 이 판에는 문제가 넷 있어요. 하나씩 세어봅시다.

하나 — 세 갈래를 손으로 들고 있습니다

postserrorMessage 두 개를 우리가 관리하고, 그 조합으로 화면을 갈랐습니다. 지금은 괜찮아 보이지만 조합이 늘어나면 헷갈립니다. 데이터도 있고 오류도 있는 상태는 뭘까요? 다시 불러오는 중인데 앞의 데이터는 남아 있는 상태는요?

둘 — 한 화면을 여는데 요청이 두 번 나갑니다

개발자 도구 네트워크 탭을 열고 새로고침해 보세요. posts 요청이 두 번 갑니다.

B-4에서 만난 그것입니다. 개발 모드의 <StrictMode>가 컴포넌트를 두 번 마운트해서 effect도 두 번 돌아요. 그때는 "리스너를 두 번 붙였다 뗀다" 정도였는데, 이번엔 서버에 요청이 두 번 갑니다. 성격이 다르죠.

셋 — 같은 데이터를 두 곳에서 쓰면 요청도 두 번입니다

머리말에도 게시물 개수를 띄우고 싶다고 해봅시다. 그 컴포넌트도 자기 effect로 불러야 해요. 같은 데이터인데 요청이 두 번 나가고, 두 응답이 서로 다를 수도 있습니다.

넷 — 늦게 온 데이터가 안 들어갑니다

이게 오늘 Step 1에서 제일 중요합니다. 아까 FeedSection을 왜 나눴는지 여기서 답이 나와요.

이렇게 쓰고 싶었을 겁니다.

tsx
// ⚠️ 이렇게 하면 화면이 영영 비어 있습니다
export function HomePage() {
  const [posts, setPosts] = useState<Post[]>([]);
  useEffect(() => { /* 가져와서 setPosts */ }, []);

  const { posts: shown, likedCount, ... } = useFeed(posts);
  ...
}

훨씬 자연스러워 보이지만 안 됩니다. useFeed 안을 보면 이렇게 생겼어요.

TypeScript
// apps/web-spa/src/hooks/useFeed.ts
export function useFeed(initialPosts: Post[]) {
  const [state, dispatch] = useReducer(feedReducer, createFeedState(initialPosts));

useReducer의 초기값입니다. 초기값은 첫 렌더에서 한 번만 읽혀요. A-5에서 useState를 배울 때 짚었던 그 성질이 여기서 발목을 잡습니다.

첫 렌더에서 posts는 빈 배열이고, 그 빈 배열이 초기값으로 들어갑니다. 400ms 뒤에 진짜 게시물이 도착해서 setPosts를 불러도 useFeed는 처음 받은 빈 배열을 계속 들고 있어서, 화면은 영영 비어 있습니다.

화면이 영영 비지 않도록 데이터가 도착한 뒤에 마운트되는 자식으로 내렸습니다. posts === null이면 안내 문구를 그리고, 도착한 다음에야 <FeedSection posts={posts} />를 그리니까 FeedSection은 태어나는 순간 이미 게시물을 손에 쥐고 있어요.

⚠️ 이건 트릭이 아니라 실무에서 자주 쓰는 방식입니다. "데이터가 없으면 다른 것을 그린다"는 조건부 렌더는 B-1에서 배운 그대로고요.

💡 한 줄 정리

배열은 걷어냈지만 대신 네 가지를 떠안았습니다 — 세 갈래를 손으로 들기, 요청 두 번, 공유 안 됨, 늦게 온 값이 안 들어감.

🙋 학생 질문 — "튜터님, cancelled 표시는 왜 필요한가요? 없으면 뭐가 잘못되나요?"

응답이 오기 전에 사용자가 다른 화면으로 넘어가는 경우를 생각해 보세요.

요청은 이미 날아갔고, 400ms 뒤에 응답이 도착하는데, 그때 HomePage는 이미 화면에서 사라진 뒤입니다. 그 상태에서 setPosts가 불리면 없는 화면의 상태를 바꾸는 셈이 됩니다.

cancelled는 "이 요청의 결과는 이제 필요 없다"는 표시예요. 정리 함수(return () => { cancelled = true; })가 화면을 떠날 때 그것을 켜고, 늦게 도착한 응답은 표시를 보고 조용히 물러납니다.

더 흔한 경우는 빠르게 왔다 갔다 할 때입니다. 태그 A를 눌렀다가 곧바로 B를 누르면 요청이 둘 다 날아가는데, 응답이 오는 순서는 보장되지 않아요. A의 응답이 늦게 오면 B를 보고 있는데 A의 결과가 화면을 덮습니다. 이걸 경쟁 조건이라고 부르고, 손으로 짜면 매번 이 표시를 챙겨야 합니다.

Step 6에서 이 코드가 통째로 사라집니다.


Step 2: "fetch는 404를 실패로 안 본다"

Step 1 코드에 이런 줄이 있었죠.

tsx
// fetch 는 404 든 500 이든 성공으로 친다. 봉투를 열어봐야 안다.
if (body.success === false) {

이 한 줄을 짚고 넘어가겠습니다. 오늘 도구를 바꾸는 첫 번째 이유거든요.

없는 게시물을 불러봅시다

서버에 /api/posts/999를 물어보면 이렇게 답합니다.

텍스트
 상태 번호: 404
 본문: { "success": false, "data": null, "message": "게시물을 찾을 수 없습니다" }

없는 것을 달라고 했으니 404를 주는 게 맞아요. 그럼 이 코드는 어떻게 될까요?

TypeScript
try {
  await fetch('http://localhost:8090/api/posts/999');
  console.log('여기까지 옵니다');
} catch {
  console.log('여기는 안 옵니다');
}

catch가 안 불립니다. fetch는 이 응답을 성공으로 봐요.

처음 만나면 버그 같지만 의도된 동작입니다. fetch에게 "실패"란 서버에 말을 못 붙인 것이에요. 응답이 왔다면 그 내용이 무엇이든 통신은 성공한 것이고, 404는 서버가 정상적으로 답한 결과라서 갈리는 값을 우리가 직접 봐야 합니다.

TypeScript
const response = await fetch('http://localhost:8090/api/posts/999');

response.ok;      // false
response.status;  // 404

const body = await response.json();   // await 이 한 번 더 필요합니다

⚠️ 이걸 안 하면 어떻게 될까요? body.datanull인 채로 화면까지 흘러가서, 화면 어딘가에서 post.username을 읽는 순간 터져요. 진짜 원인은 세 단계 전인데 에러는 엉뚱한 곳에서 납니다.

Axios는 같은 응답에 던집니다

TypeScript
import axios from 'axios';

try {
  await axios.get('http://localhost:8090/api/posts/999');
} catch (error) {
  // 여기로 옵니다
  error.response.status;        // 404
  error.response.data.message;  // '게시물을 찾을 수 없습니다'
}

던져진 것 안에 상태 번호도 있고 서버가 보낸 껍데기도 통째로 들어 있고, 성공한 응답은 본문을 이미 풀어서 줍니다. await이 한 번이에요.

TypeScript
const response = await axios.get('http://localhost:8090/api/posts');
response.data.success;  // true — .json() 을 부를 필요가 없습니다

무엇을 실패로 볼지는 정할 수 있습니다

Axios가 던지는 기준은 고정된 게 아닙니다.

TypeScript
axios.defaults.validateStatus;
// 200 → true · 299 → true · 300 → false · 404 → false · 500 → false

200 이상 300 미만만 성공으로 본다는 뜻이에요. 필요하면 요청마다 바꿀 수 있습니다.

TypeScript
// 404 도 성공으로 받고 싶을 때
await axios.get(url, { validateStatus: (status) => status < 500 });

🌟 그래서 무엇을 쓰나요

fetch는 브라우저에 이미 있어서 설치할 게 없고, Axios는 한 줄 설치가 필요합니다. 대신 Axios는 실패를 실패로 다루고, 본문을 풀어주고, 다음 Step에서 볼 인터셉터를 줍니다.

우리는 Axios로 갑니다. 실무에서도 이 세 가지 때문에 고르는 경우가 많아요.

Bash
npm install axios

💡 한 줄 정리

fetch에게 실패란 연결이 안 된 것뿐이라 404·500을 우리가 직접 확인해야 하고, Axios는 그것을 던져서 알려줍니다.

🙋 학생 질문 — "튜터님, 그럼 fetch는 잘못 만든 건가요? 왜 이렇게 설계했을까요?"

잘못이라기보다 다루는 층이 다릅니다.

fetch는 HTTP라는 프로토콜을 그대로 옮겨놓은 도구예요. HTTP에서 404는 "실패한 요청"이 아니라 정상적으로 처리된 응답입니다. 서버가 질문을 이해했고, 답을 만들었고, 그 답이 "그런 건 없습니다"인 거죠. 통신 자체는 완벽하게 성공했습니다.

반면 Axios는 그 위에 한 겹을 더 얹은 도구입니다. "우리가 코드를 쓸 때 보통 4xx·5xx 는 실패로 다루고 싶어 하니까 기본값을 그렇게 두자"는 판단이 들어가 있는데, 그 판단이 마음에 안 들면 validateStatus로 바꿀 수 있게 열어뒀습니다.

한 가지는 짚고 갈게요. fetch를 쓰더라도 response.ok만 챙기면 똑같이 안전합니다. Axios를 고르는 진짜 이유는 실패 처리보다 다음 Step에 나오는 인터셉터예요.


Step 3: "주소와 껍데기를 한 곳에 모은다"

Step 1 코드를 다시 보면 화면 안에 이런 것들이 들어 있습니다.

텍스트
 HomePage 가 알고 있는 것

 · 서버 주소가 localhost:8090 이라는 것
 · 응답에 success · data · message 껍데기가 씌워져 있다는 것
 · 실패했을 때 message 를 꺼내 보여줘야 한다는 것

셋 다 화면이 알 필요가 없는 것들이에요. 게시물 상세 화면, 프로필 화면, 댓글 목록도 전부 같은 것을 알아야 한다면 같은 코드를 세 번 쓰게 됩니다. 주소가 바뀌면 세 곳을 고쳐야 하고요.

한 곳에 모읍시다.

인스턴스를 만듭니다

TypeScript
// apps/web-spa/src/api/client.ts
import axios, { AxiosError } from 'axios';

// 주소를 한 곳에만 적는다. 화면은 '/posts' 처럼 뒷부분만 안다.
export const API_BASE_URL = 'http://localhost:8090/api';

export const api = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10_000,
});

axios.create설정을 기억하는 axios 한 벌을 만들어 주는데, api.get('/posts')라고 쓰면 앞부분은 알아서 붙어요. timeout을 준 것도 여기 한 번이면 모든 요청에 적용됩니다.

껍데기를 벗기는 자리

인터셉터는 요청이 나가기 직전과 응답이 들어온 직후에 끼어드는 함수입니다. 응답 쪽에 하나 달아볼게요.

TypeScript
// apps/web-spa/src/api/client.ts
api.interceptors.response.use(
  (response) => {
    const envelope = response.data as ApiEnvelope<unknown>;

    // 200 으로 오면서 봉투만 실패인 경우도 있다. 그것도 실패로 만든다.
    if (envelope.success === false) {
      throw new ApiError(envelope.message ?? CONNECTION_FAILED, response.status);
    }

    // 여기서 봉투를 벗긴다. 이 뒤로는 아무도 .data.data 를 안 쓴다.
    response.data = envelope.data;

    return response;
  },
  ...
);

이 함수는 응답이 들어올 때마다 불리는데, 껍데기를 열어서 success를 확인하고 알맹이만 남겨서 돌려줘요.

⚠️ success: false인데 상태 번호는 200인 경우도 처리합니다. 백엔드가 "요청은 잘 받았고 처리 결과가 실패"라는 뜻으로 200을 주는 설계도 있거든요. 그런 응답은 Axios가 안 던지므로 우리가 던져야 합니다.

실패를 한 종류로 모읍니다

실패는 두 갈래로 들어옵니다. 서버가 4xx·5xx 를 준 경우와 서버까지 못 간 경우요. 화면 입장에서는 둘 다 "안 됐다" 이므로 한 종류로 만들어 줍니다.

TypeScript
// apps/web-spa/src/api/client.ts
/** 서버가 "안 됐다" 고 말했을 때 던지는 것 */
export class ApiError extends Error {
  readonly status: number | null;

  constructor(message: string, status: number | null) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
  }
}

그리고 오류 쪽 인터셉터가 그것을 만듭니다.

TypeScript
// apps/web-spa/src/api/client.ts
  (error: unknown) => {
    if (error instanceof AxiosError) {
      // 응답이 아예 없으면 서버까지 못 간 것이다.
      // 이때 Axios 가 주는 말은 'Network Error' — 영어라서 화면에 그대로 못 쓴다.
      if (error.response === undefined) {
        throw new ApiError(CONNECTION_FAILED, null);
      }

      // 4xx·5xx 에도 서버는 사유를 봉투에 담아 보낸다. 그 말을 그대로 쓴다.
      const envelope = error.response.data as ApiEnvelope<unknown> | undefined;

      throw new ApiError(envelope?.message ?? CONNECTION_FAILED, error.response.status);
    }

    throw new ApiError(CONNECTION_FAILED, null);
  },

⚠️ 가운데 갈래를 눈여겨보세요. 연결이 안 됐을 때 Axios가 주는 말은 'Network Error'입니다. 영어예요. error.message를 그대로 화면에 내보내면 한국어 화면에 저 문구가 뜹니다. 서버가 보낸 사유가 있을 때만 그 말을 쓰고, 없으면 우리가 쓴 문장으로 갈아치웁니다.

부르는 쪽

TypeScript
// apps/web-spa/src/api/posts.ts
import type { Post } from '../types/instagram';
import { api } from './client';

// 봉투는 인스턴스가 이미 벗겨준다. 그래서 제네릭에 적는 것은
// 봉투가 아니라 안에 들어 있던 것이다.
export async function fetchFeed(): Promise<Post[]> {
  const response = await api.get<Post[]>('/posts');

  return response.data;
}

export async function fetchPostById(id: number): Promise<Post> {
  const response = await api.get<Post>(`/posts/${id}`);

  return response.data;
}

api.get<Post[]>의 제네릭을 보세요. 인터셉터가 껍데기를 벗겨줬기 때문에 response.data가 진짜로 Post[]입니다. 만약 안 벗겼다면 여기에 ApiResponse<Post[]>라고 적어야 했고, 부르는 쪽마다 .data.data를 써야 했을 거예요.

A-6에서 배운 제네릭이 여기서 사실을 말하고 있는지가 중요해집니다. 타입은 실제로 손에 들어오는 것과 같아야 해요.

화면이 가벼워집니다

tsx
// apps/web-spa/src/routes/HomePage.tsx
    fetchFeed()
      .then((loaded) => {
        if (cancelled) {
          return;
        }

        setPosts(loaded);
      })
      .catch((error: unknown) => {
        if (cancelled) {
          return;
        }

        // 봉투를 여는 일은 인스턴스가 끝냈다. 화면은 사유만 받는다.
        setErrorMessage(error instanceof ApiError ? error.message : '피드를 불러오지 못했어요');
      });

success.json()도 주소도 화면에서 사라졌습니다. 남은 것은 "가져와서 담고, 실패하면 사유를 보여준다" 뿐이에요.

💡 한 줄 정리

인스턴스는 주소를, 인터셉터는 껍데기와 실패 처리를 맡고, 화면은 알맹이만 받습니다.

🙋 학생 질문 — "튜터님, 인터셉터에서 response.data를 직접 바꿔도 되나요? 원본을 건드리는 것 같아 불안한데요."

좋은 감각이지만 여기서는 안전해요.

response는 그 요청 하나를 위해 Axios가 방금 만든 객체라서, 다른 곳에서 공유하는 값이 아니에요. 우리가 손대지 않으면 그대로 await 한 쪽으로 가고, 그러고 나면 버려집니다.

B-2에서 배운 불변성 규칙이 걸리는 건 React 가 보고 있는 상태입니다. 상태를 직접 바꾸면 React가 바뀐 줄 몰라서 화면이 안 갱신되죠. 이건 그 경우가 아닙니다.

굳이 새 객체를 만들어 돌려줄 수도 있어요. return { ...response, data: envelope.data }처럼요. 결과는 같지만, Axios의 응답 객체에는 config·request 같은 것도 들어 있어서 복사하다 빠뜨리면 그게 더 위험합니다.


Step 4: "토큰도 인터셉터가 붙인다"

피드는 로그인 없이도 볼 수 있지만, 좋아요를 누르거나 댓글을 지우는 일은 내가 누구인지 서버가 알아야 해요.

확인해 봅시다. 토큰 없이 좋아요를 눌러보면 서버가 이렇게 답합니다.

텍스트
 상태 번호: 401
 본문: { "success": false, "data": null, "message": "로그인이 필요합니다" }

토큰을 어디에 둘까요

로그인하면 서버가 토큰 두 개를 줍니다. 짧게 사는 것과 길게 사는 것이에요.

텍스트
 accessToken   요청마다 들고 가는 출입증. 금방 만료된다.
 refreshToken  출입증을 새로 받아올 때만 쓰는 것. 오래 산다.

⚠️ 왜 둘로 나눌까요? 출입증이 새어 나가도 금방 못 쓰게 만들려는 겁니다. 오래 사는 것은 갱신할 때만 쓰니까 노출될 기회가 적고요.

저장은 한 곳에서만 알게 합니다.

TypeScript
// apps/web-spa/src/lib/tokens.ts
const ACCESS_KEY = 'instagram.accessToken';
const REFRESH_KEY = 'instagram.refreshToken';

export const tokenStore = {
  access(): string | null {
    return window.localStorage.getItem(ACCESS_KEY);
  },

  refresh(): string | null {
    return window.localStorage.getItem(REFRESH_KEY);
  },

  save({ accessToken, refreshToken }: TokenPair): void {
    window.localStorage.setItem(ACCESS_KEY, accessToken);
    window.localStorage.setItem(REFRESH_KEY, refreshToken);
  },

  ...
};

나중에 저장 위치를 바꿀 일이 생겨도 이 파일만 고치면 됩니다.

보내는 쪽 인터셉터

TypeScript
// apps/web-spa/src/api/client.ts
// 보내는 쪽 — 토큰이 있으면 모든 요청에 붙는다.
// 화면 어디에도 Authorization 을 적는 곳이 없다.
api.interceptors.request.use((config) => {
  const accessToken = tokenStore.access();

  if (accessToken !== null) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }

  return config;
});

이게 전부라서, api로 나가는 모든 요청에 출입증이 붙어요. 새 API 함수를 만들 때 깜빡할 일이 없습니다.

만료되면 어떻게 하나요

출입증은 금방 만료되는데, 만료된 채로 요청하면 401이 돌아와요.

이때 화면에 "다시 로그인하세요"를 띄우면 사용자는 몇 분마다 로그인해야 하므로, 조용히 갱신하고 그 요청을 다시 보냅니다.

TypeScript
// apps/web-spa/src/api/client.ts
    // 액세스 토큰이 만료된 것뿐이라면, 갱신하고 그 요청을 다시 보낸다.
    // 화면은 실패한 줄도 모른다.
    if (error.response.status === 401 && config !== undefined && config.retried !== true) {
      config.retried = true;

      try {
        await startRefresh();
      } catch {
        tokenStore.clear();
        throw new ApiError(LOGIN_AGAIN, 401);
      }

      return api.request(config);
    }

네트워크 탭으로 보면 이렇게 됩니다.

텍스트
 좋아요 한 번을 눌렀을 때

 POST /api/posts/1/like      401  (출입증이 만료됐다)
 POST /api/auth/refresh      200  (새 출입증을 받는다)
 POST /api/posts/1/like      200  (다시 보낸다)

요청은 세 번 나갔는데 화면 쪽 코드는 한 번 불렀을 뿐이고, 결과는 성공입니다.

갱신하는 함수에서 조심할 것 둘

TypeScript
// apps/web-spa/src/api/client.ts
async function refreshAccessToken(): Promise<string> {
  const refreshToken = tokenStore.refresh();

  if (refreshToken === null) {
    throw new ApiError(LOGIN_AGAIN, 401);
  }

  // 갱신 요청은 인터셉터가 없는 맨 axios 로 보낸다.
  // api 로 보내면 이 응답의 401 이 다시 여기로 돌아온다.
  const response = await axios.post<ApiEnvelope<{ accessToken: string }>>(
    `${API_BASE_URL}/auth/refresh`,
    { refreshToken },
  );

  ...
  tokenStore.saveAccess(next);

  return next;
}

⚠️ 첫째, 갱신 요청은 api로 보내면 안 됩니다. 갱신도 실패할 수 있는데, 그 401이 같은 인터셉터로 들어오면 또 갱신을 시도합니다. 끝이 없어요.

⚠️ 둘째, config.retried 표시가 없으면 무한 반복이 됩니다. 갱신은 성공했는데 새 출입증도 거절당하는 상황을 생각해 보세요. 다시 보낸 요청이 401 → 갱신 → 다시 보냄 → 401이 영원히 돕니다. 저는 이 표시를 일부러 빼고 돌려봤는데, 브라우저 탭이 멈추는 게 아니라 실행하던 프로세스가 통째로 죽었습니다. 무한 루프는 이렇게 조용히 시작해서 요란하게 끝나요.

동시에 여러 개가 만료를 만나면

화면 하나에서 요청 다섯 개가 동시에 나가는 일은 흔합니다. 출입증이 만료된 상태라면 다섯 개가 전부 401을 받아요.

각자 갱신하면 갱신 요청이 다섯 번 나갑니다. 서버 입장에서도 낭비고, 마지막에 발급된 것만 유효한 서버라면 앞의 넷은 쓸모없는 출입증을 받게 되므로, 갱신은 한 번에 하나만 돌게 묶습니다.

TypeScript
// apps/web-spa/src/api/client.ts
// 갱신은 한 번에 하나만 돈다. 동시에 401 을 받은 요청들이 같은 약속을 기다린다.
let refreshing: Promise<string> | null = null;

function startRefresh(): Promise<string> {
  refreshing ??= refreshAccessToken().finally(() => {
    refreshing = null;
  });

  return refreshing;
}

??=는 "왼쪽이 비어 있을 때만 넣어라"입니다. 첫 번째 요청이 갱신을 시작하면 그 약속이 refreshing에 담기고, 뒤따라온 넷은 새로 시작하지 않고 같은 약속을 기다립니다. 끝나면 finally가 자리를 비워서 다음 만료 때 다시 한 번 돕니다.

이 다섯 줄이 있고 없고를 재봤습니다. 없으면 갱신 요청이 다섯 번, 있으면 한 번입니다.

💡 한 줄 정리

출입증을 붙이는 일과 만료됐을 때 갱신하는 일을 인터셉터가 맡으면, 화면은 인증이라는 것이 있는 줄도 모르고 지냅니다.

🙋 학생 질문 — "튜터님, 토큰을 localStorage에 두는 게 안전한가요? 위험하다는 글을 봤어요."

정확히 보셨습니다. localStorage는 그 사이트에서 도는 자바스크립트라면 누구나 읽을 수 있어서, 남의 스크립트가 우리 페이지에서 도는 상황이 되면 토큰이 통째로 새어 나갑니다.

더 안전한 방법은 서버가 HttpOnly 쿠키로 토큰을 주는 겁니다. 그 쿠키는 자바스크립트가 아예 못 읽어요. 대신 요청마다 브라우저가 알아서 실어 보냅니다.

그렇다면 왜 우리는 localStorage로 하고 있을까요. 이 방식은 서버가 쿠키를 내려주도록 만들어져 있어야 쓸 수 있고, 프런트만으로는 못 정해요. 지금은 인증 자체가 오늘의 주제가 아니라 인터셉터가 무엇을 대신해주는지 보는 것이 목적이기도 하고요.

인증을 정면으로 다루는 것은 G-1입니다. 거기서 이 선택을 다시 꺼내서, 무엇을 기준으로 고르는지 제대로 이야기해요. 그리고 G-3에서 백엔드와 신원을 맞출 때, 이 파일이 tokens.ts 하나만 고치면 되게 되어 있다는 것도 확인하게 됩니다.


Step 5: "서버 값은 낡는다"

여기서 잠깐 코드를 멈추고 성질 이야기를 하겠습니다. 오늘 도구를 바꾸는 진짜 이유가 여기 있어요.

지금까지 다룬 값

텍스트
 useState 로 담은 값       우리가 setState 를 부르기 전에는 안 변한다
 주소에 적힌 값            우리가 옮기기 전에는 안 변한다
 store 에 담은 값          우리가 set 을 부르기 전에는 안 변한다

공통점이 보이시나요 — 전부 우리가 주인이라서 "다시 읽어와야 하나"를 걱정할 일이 없었어요.

서버 값

피드를 받아 화면에 그렸는데, 첫 게시물의 좋아요가 1240이라고 떠 있어요.

그사이 다른 사람이 좋아요를 눌러서, 서버의 숫자는 1241이 됐어요.

우리 화면은요? 여전히 1240인데, 우리가 물어보지 않았기 때문이에요.

직접 재보니, 화면을 그린 뒤에 서버 값을 1241로 바꾸고 60초를 흘려보냈습니다. 요청은 한 번도 더 안 나갔고 화면은 계속 1240 이었어요. 당연한 결과인데, 아무도 "언제 다시 물어볼지"를 정해두지 않았거든요.

여기서 화면 하나를 더 열면 이런 일이 생깁니다.

텍스트
 먼저 연 화면      좋아요 1240개
 나중에 연 화면    좋아요 1241개

 같은 게시물인데 두 화면의 숫자가 다르다

둘 다 틀린 게 아니에요. 각자 물어본 시점의 답을 정직하게 들고 있을 뿐입니다.

그래서 무엇이 필요한가요

useState로 담은 서버 값은 이런 질문에 답을 못 합니다.

텍스트
 · 이 값을 받아온 지 얼마나 됐나
 · 지금 다시 물어봐야 하나, 아직 믿어도 되나
 · 다른 화면이 이미 받아둔 것이 있나
 · 다시 물어보는 동안 무엇을 보여줘야 하나

useState는 이런 걸 알 이유가 없는 도구예요. 우리가 주인인 값을 담으라고 만들어진 것이니까 서버 값은 따로 다룹니다. 지난 시간에 만든 표의 마지막 칸이 오늘 채워지는 이유입니다.

텍스트
 값의 종류        어디에 두나              누가 주인인가
 ─────────────────────────────────────────────────────
 화면 안 값       useState                우리
 여러 화면 공용   store (Zustand)         우리
 주소에 담을 값   useSearchParams         우리
 서버가 가진 값    오늘부터              서버

⚠️ 대조를 하나만 더 짚을게요. 캡션을 펼쳐둔 상태는 서버가 뭘 하든 그대로입니다. 서버는 그런 게 있는 줄도 몰라요. 반대로 좋아요를 눌러 화면에서 1241로 만들어도, 새로고침하면 1240으로 돌아옵니다. 그건 우리 값이 아니었으니까요.

좋아요를 진짜로 서버에 보내는 것은 다음 시간입니다. 오늘은 가져오는 쪽만 끝냅니다.

💡 한 줄 정리

서버 값은 우리가 안 만졌는데도 틀려집니다. 그래서 "언제 다시 물어볼지"를 정해주는 도구가 필요합니다.

🙋 학생 질문 — "튜터님, 그럼 실시간으로 계속 물어보면 되는 거 아닌가요? 1초마다 새로 받으면 안 틀리잖아요."

그렇게 하는 화면도 실제로 있습니다. 다만 값이 나갑니다.

게시물 열 장을 1초마다 다시 받으면 사용자가 화면을 10분만 켜둬도 요청이 600번이에요. 사용자 만 명이면 서버는 초당 만 번을 받습니다. 대부분은 아무것도 안 바뀐 응답이고, 화면 쪽에도 문제가 생깁니다. 새 데이터가 도착할 때마다 목록이 다시 그려지면, 사용자가 캡션을 읽던 중에 스크롤이 튀거나 누르려던 버튼이 움직일 수 있어서, 실무에서는 이렇게 갈라 씁니다. 채팅이나 알림처럼 밀려와야 하는 것은 서버가 먼저 알려주는 방식(C-9에서 다룹니다)을 쓰고, 피드처럼 볼 때 맞으면 되는 것은 "언제 다시 물어볼지"를 정해둡니다.

그 시간을 정하는 것이 오늘 Step 8입니다.


Step 6: "세 갈래가 한 줄로"

이제 도구를 들입니다.

Bash
npm install @tanstack/react-query

TanStack Query는 서버에서 받아온 값을 대신 관리해주는 도구입니다. 이름에 Query가 들어간 이유는 "물어보기"를 다루기 때문이에요.

캐시를 만들고 앱에 얹습니다

TypeScript
// apps/web-spa/src/queries/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

// 캐시를 들고 있는 사람. React 바깥에서 한 번 만든다.
// 컴포넌트 안에서 만들면 다시 그려질 때마다 캐시가 새로 생긴다
// (main.tsx 의 라우터와 같은 이유다).
export const queryClient = new QueryClient();

QueryClient는 받아온 값들을 들고 있는 창고입니다. C-1에서 라우터를 React 바깥에서 만든 것과 같은 이유로 여기도 바깥이라서, 앱에 얹습니다.

tsx
// apps/web-spa/src/AppProviders.tsx
  return (
    // 캐시도 라우터보다 바깥이다. 화면을 옮겨 다녀도 받아둔 것이 살아 있어야 한다.
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <ThemeColorMeta />
        {children}
        {/* 확인 상자는 감쌀 것이 없다. 앱에 한 번 그려두기만 하면 된다. */}
        <ConfirmDialog />
      </ThemeProvider>
    </QueryClientProvider>
  );

⚠️ 위치가 중요합니다. 라우터보다 안쪽에 두면 화면을 옮길 때 캐시가 사라져요. C-3에서 밝기를, 지난 시간에 확인 상자를 라우터 바깥으로 올린 것과 같은 판단입니다.

훅을 만듭니다

TypeScript
// apps/web-spa/src/queries/posts.ts
import { useQuery } from '@tanstack/react-query';
import { fetchFeed } from '../api/posts';

// 키는 이 데이터의 이름이다. 캐시에서 이 이름으로 찾는다.
// 문자열이 아니라 배열인 이유는 Step 7 에서 파라미터가 붙기 때문이다.
export const feedKey = ['posts'] as const;

export function useFeedQuery() {
  return useQuery({
    queryKey: feedKey,
    queryFn: fetchFeed,
  });
}

넘기는 것은 둘입니다. 이 데이터의 이름(queryKey)과 가져오는 방법(queryFn)이에요. 가져오는 함수는 Step 3에서 만든 것을 그대로 씁니다.

화면

tsx
// apps/web-spa/src/routes/HomePage.tsx
export function HomePage() {
  // 세 갈래를 손으로 들고 있던 자리가 한 줄로 줄었다.
  const { data: posts, isPending, error } = useFeedQuery();

  if (error !== null) {
    return (
      <p className="text-sm text-danger-strong">
        {error instanceof ApiError ? error.message : '피드를 불러오지 못했어요'}
      </p>
    );
  }

  if (isPending) {
    return <p className="text-sm text-faint">피드를 불러오는 중이에요…</p>;
  }

  return <FeedSection posts={posts} />;
}

화면이 갈리는 세 갈래는 그대로입니다. 달라진 것은 그 세 갈래를 우리가 안 만든다는 거예요. useStateuseEffectcancelled 표시도 전부 사라졌습니다.

Step 1에서 아팠던 것을 같은 지표로 다시 잽니다

말로 "좋아졌다" 고 하면 안 되죠. 개발자 도구 네트워크 탭을 열고 세어봅시다.

텍스트
 재본 것                        Step 1        Step 6
 ─────────────────────────────────────────────────────
 화면 하나를 열 때 요청 수       2             1
 같은 피드를 두 곳에서 그릴 때   2             1
 늦게 온 데이터가 화면에         안 들어감      들어감
 떠났다 돌아왔을 때             처음부터       기다림 없음

첫 줄부터 보겠습니다. <StrictMode>는 그대로인데 요청이 하나입니다. 같은 이름으로 이미 물어보러 간 요청이 있으면 그것을 기다리기 때문이에요.

둘째 줄도 같은 원리입니다. 두 컴포넌트가 같은 이름을 부르면 요청은 한 번 나가고 응답을 나눠 가집니다. Step 1에서 "같은 데이터인데 두 번 나간다" 던 문제가 이름 하나로 해결돼요.

넷째 줄이 캐시입니다. 피드를 보다가 다른 화면에 갔다 돌아오면, "불러오는 중"이 아예 안 뜨고 곧바로 게시물이 있습니다. 받아둔 것을 창고에서 꺼내 먼저 보여주거든요.

💡 한 줄 정리

데이터의 이름과 가져오는 방법만 알려주면, 기다림·실패·중복 요청·캐시는 도구가 맡습니다.

🙋 학생 질문 — "튜터님, isPendingisLoading은 뭐가 다른가요? 둘 다 보이던데요."

이름이 비슷해서 헷갈리는 자리입니다.

isPending아직 데이터가 없다는 뜻이에요. 한 번도 못 받았거나, 캐시에서 지워진 뒤 처음 받는 중이거나요. 우리가 화면을 가를 때 쓰는 것이 이것인데, 데이터가 없으면 목록을 못 그리니까요.

isFetching지금 요청이 나가 있다는 뜻입니다. 이미 받아둔 데이터가 있는데 다시 물어보는 중이면 isPendingfalse인데 isFetchingtrue예요. 목록은 그대로 보여주면서 위쪽에 작은 표시만 띄우고 싶을 때 이걸 씁니다.

isLoading은 그 둘을 합친 것입니다 — 데이터도 없고 요청도 나가 있는 상태예요.

Step 8에서 "화면은 이미 채워졌는데 뒤에서 다시 물어보는" 상황을 만들 겁니다. 그때 이 셋이 서로 다른 답을 하는 것을 보게 돼요.


Step 7: "키가 캐시의 이름표다"

queryKey를 배열로 준 이유를 이제 봅니다.

탐색 화면을 기억하시나요. 지난 시간에 태그를 주소에 담았죠. ?tag=카페가 붙어 있으면 카페 게시물만 보이는 화면이요.

그 화면은 아직도 코드 안 배열을 걸러내고 있습니다. 서버에게 시켜봅시다.

서버에 조건을 실어 보냅니다

TypeScript
// apps/web-spa/src/api/posts.ts
export async function fetchFeed(tag?: string): Promise<Post[]> {
  // params 를 주면 Axios 가 물음표 뒤를 만들어 붙인다.
  // 한글 태그도 여기서 알아서 인코딩된다.
  const response = await api.get<Post[]>('/posts', {
    params: tag === undefined ? undefined : { tag },
  });

  return response.data;
}

⚠️ params로 넘기는 것이 중요합니다. 주소 문자열에 직접 이어 붙이면 한글 태그가 깨져요. 카페는 실제로 %EC%B9%B4%ED%8E%98로 바뀌어 나가야 하는데, 그 변환을 Axios가 대신해 줍니다.

키에 조건을 넣습니다

TypeScript
// apps/web-spa/src/queries/posts.ts
// 키는 이 데이터의 이름이다. 캐시에서 이 이름으로 찾는다.
//
// 무엇을 달라고 했는지가 키에 함께 들어가야 한다.
// tag 를 빼놓으면 태그를 바꿔도 캐시는 "같은 것" 이라고 판단한다.
export function feedKey(tag?: string) {
  return tag === undefined ? (['posts'] as const) : (['posts', { tag }] as const);
}

export function useFeedQuery(tag?: string) {
  return useQuery({
    queryKey: feedKey(tag),
    queryFn: () => fetchFeed(tag),
  });
}
텍스트
 feedKey()            ['posts']
 feedKey('카페')       ['posts', { tag: '카페' }]
 feedKey('한강')       ['posts', { tag: '한강' }]

 서로 다른 이름 = 창고에서 서로 다른 칸

화면

tsx
// apps/web-spa/src/routes/ExplorePage.tsx
  // 주소에 적힌 값이 그대로 키의 일부가 된다.
  // 태그가 바뀌면 키가 바뀌고, 키가 바뀌면 새로 물어본다.
  const { data: shown, isPending, error } = useFeedQuery(tag ?? undefined);
  const { data: tags = [] } = useTagsQuery();

태그 목록도 서버에서 받습니다. 예전에는 게시물 배열에서 뽑아 썼는데, 이제 서버가 걸러서 주니까 걸러진 목록에서는 전체 태그를 알 수 없거든요.

allPosts import가 이 파일에서 사라져서, 이제 탐색 화면은 게시물이 어디서 오는지 모릅니다.

눌러보면서 세어봅시다

네트워크 탭을 켜고 태그를 하나씩 눌러보세요.

텍스트
 전체를 열었다        GET /api/posts
 카페를 눌렀다        GET /api/posts?tag=카페
 한강을 눌렀다        GET /api/posts?tag=한강

키가 바뀔 때마다 새로 물어보는데, 이미 본 카페로 되돌아가면 어떻게 될까요?

화면은 기다림 없이 곧바로 채워지는데, "불러오는 중"이 안 뜨는 이유는 받아둔 것이 창고에 있기 때문이에요.

⚠️ 그런데 네트워크 탭을 보면 요청은 다시 나갑니다. 저도 처음엔 안 나갈 거라고 생각하고 재봤는데 나가더군요.

기본 설정이 "받아둔 것은 이미 낡았다" 이기 때문에, 창고에 있는 것을 먼저 보여주고 동시에 조용히 다시 물어봅니다. 사용자는 기다리지 않고, 값은 최신으로 맞춰지죠.

이 동작을 바꾸는 것이 다음 Step입니다.

키에서 조건을 빼면 어떻게 되나요

직접 만들어 보면 이 규칙이 왜 중요한지 알 수 있습니다.

tsx
// ⚠️ 반례 — tag 가 바뀌어도 키는 그대로다
  const { data = [], isPending } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetchFeed(tag ?? undefined),
  });

전체를 한 번 본 다음 카페 태그로 들어가면, 카페 게시물은 네 장인데 열 장이 그대로 떠 있습니다.

캐시 입장에서는 같은 이름을 물어본 거라서, ['posts']라고 했으니 ['posts']를 꺼내준 겁니다. 잘못한 게 없고, 이름을 대충 지은 우리가 문제죠.

🌟 그래서 규칙은 하나입니다. 가져오는 함수에 넘기는 값은 전부 키에 들어가야 합니다.

💡 한 줄 정리

키는 캐시에서 이 데이터를 찾는 이름표이고, 무엇을 달라고 했는지가 그 이름에 들어가야 합니다.

🙋 학생 질문 — "튜터님, 키를 ['posts', tag]로 안 하고 왜 ['posts', { tag }]처럼 객체로 감쌌나요?"

지금은 조건이 하나뿐이라 둘 다 똑같이 동작하는데, 객체로 감싼 것은 조건이 늘어날 때를 위해서예요.

정렬 순서와 페이지 번호가 추가된다고 해봅시다.

텍스트
 배열로 나열   ['posts', tag, sort, page]
 객체로 묶기   ['posts', { tag, sort, page }]

나열하는 방식은 순서를 지켜야 하고, 중간 것이 없을 때 자리를 비워둬야 합니다. ['posts', undefined, 'latest', 2] 같은 게 되죠. 객체는 이름으로 찾으니까 순서도 상관없고 빠진 것도 자연스럽습니다. 이 도구는 키를 비교할 때 객체 안까지 값으로 비교하기 때문에 { tag: '카페' }를 매번 새로 만들어도 같은 키로 취급해요. C-4에서 selector가 새 객체를 돌려주면 화면이 멈췄던 것과 반대입니다. 그쪽은 참조로 비교했고 이쪽은 값으로 비교하거든요.

같은 "객체를 새로 만든다"가 도구에 따라 다른 결과를 냅니다. 규칙을 외우기보다 무엇으로 비교하는지를 확인하는 습관이 필요한 자리예요.


Step 8: "두 시간"

Step 7 마지막에 남은 질문을 이어갑니다. 받아둔 값을 얼마나 믿을 것인가요.

두 값이 있습니다

텍스트
 staleTime   받아둔 값을 얼마 동안 "쓸 만하다" 고 볼지
             이 시간 안에는 다시 안 물어본다

 gcTime      아무도 안 보는 값을 얼마 동안 창고에 남겨둘지
             이 시간이 지나면 지운다

이름이 비슷해서 헷갈리는데, 하는 일이 아예 다릅니다.

텍스트
 화면이 보고 있는 동안         staleTime 이 "다시 물어볼까" 를 정한다
 화면이 떠난 뒤               gcTime 이 "언제 버릴까" 를 정한다

값을 정합니다

TypeScript
// apps/web-spa/src/queries/queryClient.ts
// 받아둔 값을 얼마 동안 "쓸 만하다" 고 볼지.
// 이 시간 안에는 다시 물어보지 않는다.
const STALE_TIME_MS = 30_000;

// 아무도 안 보는 값을 얼마 동안 캐시에 남겨둘지.
// 이 시간이 지나면 캐시에서 지워진다 — 다시 열면 처음부터다.
const GC_TIME_MS = 5 * 60_000;

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: STALE_TIME_MS,
      gcTime: GC_TIME_MS,
    },
  },
});

무엇이 달라졌는지 재봅니다

Step 7에서 태그를 오가며 세어봤던 그 판을 다시 해보세요.

텍스트
 전체  카페  전체  카페  (30초 안에)

 staleTime 0 (기본값)      요청 2번   화면은 즉시 채워지고 뒤에서 다시 받는다
 staleTime 30초           요청 0번   30초 안이니 물어볼 이유가 없다

30초가 지난 뒤에 같은 걸 하면 다시 물어보는데, 시간이 지났으니 이제 못 믿는 값이 된 거예요.

두 시간은 서로를 안 봅니다

여기가 헷갈리는 자리입니다. "낡았으면 지워지는 것 아닌가요?"라는 질문을 많이 받아요.

아닙니다. 재봤습니다.

텍스트
 staleTime 0 · gcTime 5분 으로 두고
 화면을 떠난 뒤 60초를 흘려보냈다

  값은 창고에 그대로 있다
  다시 열면 그 낡은 값을 먼저 보여주고, 동시에 새로 물어본다

낡았다는 것은 "다시 물어볼 때가 됐다" 이지 "쓸 수 없다"가 아니에요. 이 구분이 사용자 경험을 만듭니다. 낡은 값이라도 먼저 보여주면 화면이 비지 않으니까요.

반대로 gcTime이 지나면 진짜로 지워집니다. 그러면 다시 열 때 "불러오는 중"부터 시작해요.

얼마로 정할까요

정답은 없고 그 데이터가 얼마나 자주 바뀌는지로 정합니다.

텍스트
 거의 안 바뀌는 것        길게
 (태그 목록, 프로필 기본 정보)

 자주 바뀌는 것           짧게
 (좋아요 수, 댓글 수, 알림 개수)

 지금 이 순간이 중요한 것  0
 (재고 수량, 남은 좌석)

우리 피드는 30초로 뒀습니다. 30초 안에 화면을 몇 번 오가도 요청은 안 나가고, 30초가 지나 다시 오면 그때 새로 받아요.

🌟 값을 정할 때 기준은 하나입니다. 틀린 값을 보여줬을 때 얼마나 곤란한가. 좋아요 개수가 몇 초 낡아도 큰일이 안 나지만, 남은 좌석 수가 낡으면 예매가 실패합니다.

💡 한 줄 정리

staleTime은 다시 물어볼 때를 정하고 gcTime은 버릴 때를 정합니다. 낡은 값도 지워지기 전까지는 먼저 보여줄 수 있습니다.

🙋 학생 질문 — "튜터님, 화면을 다른 탭에 갔다 오면 다시 불러오던데 그것도 이 시간과 관계가 있나요?"

네, 같은 staleTime이 그것도 정합니다.

이 도구는 몇 가지 순간에 "다시 물어볼까?"를 검토해요. 창에 다시 초점이 들어왔을 때, 컴포넌트가 새로 마운트됐을 때, 인터넷이 끊겼다 돌아왔을 때요. 그리고 그때마다 staleTime을 봅니다. 아직 안 지났으면 그냥 넘어가고, 지났으면 새로 받습니다.

기본값이 0이라서 아무 설정 없이 쓰면 탭을 옮길 때마다 요청이 나갑니다. "왜 이렇게 요청이 많지?" 하는 대부분의 경우가 이것입니다.

각각을 따로 끌 수도 있어요. refetchOnWindowFocus: false처럼요. 다만 그것부터 끄기 전에 staleTime을 먼저 정해보시길 권합니다. 끄는 것은 "다시 안 받겠다"이고, 시간을 주는 것은 "이만큼은 믿겠다"라서 뜻이 다르거든요.


마무리

오늘 한 일을 한 줄로 줄이면 이렇습니다. 화면이 서버에 물어보게 만들고, 그 값을 다루는 도구를 따로 뒀습니다.

data/feed.ts의 배열은 이제 화면에서 안 쓰이고, 홈도 탐색도 localhost:8090에 물어봐요.

오늘 배운 핵심 세 가지

💡 하나 — 서버 값은 우리 것이 아닙니다. 우리가 안 만졌는데도 틀려지고, 화면 두 개가 서로 다른 숫자를 들고 있을 수 있어요. 그래서 "언제 다시 물어볼지"를 정해주는 도구가 따로 필요합니다.

💡 둘 — 화면이 몰라도 되는 것은 화면에서 걷어냅니다. 주소·응답 껍데기·출입증은 전부 인스턴스와 인터셉터가 맡았습니다. 새 API를 만들 때 그것들을 다시 챙길 일이 없어요.

💡 셋 — 키가 캐시의 이름표입니다. 무엇을 달라고 했는지가 키에 들어가야 하고, 안 들어가면 다른 것을 달라고 해도 앞의 결과가 그대로 나옵니다.

오늘 닫은 것과 안 닫은 것

지난 시간에 넘긴 것 닫힌 곳
코드 안 배열을 걷어내기 ✅ Step 1·7 — 홈과 탐색 둘 다
effect로 직접 짜보고 고통 확인 ✅ Step 1 — 네 가지를 숫자로 셌다
서버 값은 낡는다 ✅ Step 5
네 갈래 표의 서버 칸 ✅ Step 5
좋아요를 서버에 보내기 ❌ 안 닫음 — 다음 시간에
삭제가 실패하면 되돌리기 ❌ 안 닫음 — 다음 시간에

다음 시간 예고

오늘 우리는 가져오기만 했습니다. 좋아요 버튼을 눌러보세요. 숫자는 올라가는데 새로고침하면 원래대로 돌아옵니다. 우리 화면에서만 바꿨을 뿐 서버는 모르거든요.

다음 시간에는 서버에 씁니다. 그런데 쓰는 일에는 읽기에 없던 문제가 둘 있어요.

첫째, 쓰고 나면 읽어둔 것이 낡습니다. 좋아요를 눌러 서버 숫자가 1241이 됐는데, 창고에는 1240이 든 피드가 그대로 있어요. 그걸 어떻게 맞출지 정해야 합니다.

둘째, 기다리는 동안 화면을 어떻게 할지 정해야 합니다. 하트를 눌렀는데 0.4초 뒤에야 빨개지면 앱이 굼떠 보여요. 그래서 서버에 물어보기 전에 먼저 빨갛게 만드는 방법을 씁니다. 대신 서버가 거절하면 되돌려야 하고요.

되돌리는 이야기에서 지난 시간에 만든 확인 상자가 다시 나옵니다. 댓글을 지울지 물어보고, 확인했는데, 서버가 "그건 남의 댓글이라 못 지웁니다"라고 답하는 경우요. 실제로 우리 서버가 그렇게 답합니다.

그리고 오늘 상세 화면(/p/1)은 손대지 않았어요. 그 화면은 아직 지난 시간에 만든 흉내 함수를 쓰고 있습니다. 화면이 뜨기 전에 데이터를 준비하는 그 방식과 오늘 배운 캐시가 만나면 어떻게 되는지, 그 이야기도 다음 시간에 함께 합니다.


과제

[구현] 게시물 상세도 서버에서 가져오기

/p/1로 들어가면 아직 코드 안 배열에서 게시물을 찾습니다. Step 3에서 만들어둔 fetchPostById를 쓰도록 바꿔보세요.

  • queries/posts.tsusePostQuery(id)를 추가합니다. 키는 오늘 배운 규칙대로 지으세요.
  • 없는 번호(/p/999)로 들어가면 서버가 404와 함께 사유를 보냅니다. 그 사유를 화면에 띄우세요.
  • 상세 화면에 다녀온 뒤 다시 들어가면 "불러오는 중"이 뜨는지 안 뜨는지 확인하고, 왜 그런지 한 줄로 적어주세요.

⚠️ 이 화면에는 지금 loader가 붙어 있습니다. 그것과 어떻게 어울릴지가 다음 시간 주제라, 이번 과제에서는 loader를 잠시 떼고 화면에서 직접 불러도 괜찮습니다.

[구현] 다시 불러오기 버튼

피드 위에 "새로고침" 버튼을 하나 달아보세요. 누르면 서버에 다시 물어봅니다.

  • useFeedQuery()가 돌려주는 것 중에 다시 물어보는 함수가 있습니다. 찾아서 연결하세요.
  • 다시 물어보는 동안 버튼 글자를 "불러오는 중…"으로 바꾸고 못 누르게 막으세요. Step 6의 🙋 에서 이야기한 값 중 어느 것을 써야 할까요?
  • 중요한 것 하나 — 다시 물어보는 동안에도 게시물 목록은 그대로 보여야 합니다. 목록이 사라졌다 다시 나타나면 안 돼요.

[탐구] 시간을 바꿔가며 재보기

staleTime을 세 가지 값으로 바꿔가며 네트워크 탭에서 요청 수를 세어보세요.

같은 동작을 하세요 — 탐색 화면에서 전체 → 카페 → 전체 → 카페 를 5초 안에 누릅니다.

staleTime 요청 수 화면에 "불러오는 중" 이 떴나
0
3_000
60_000

그리고 gcTime1_000으로 두고 다음을 해보세요. 탐색 → 홈 → (2초 기다림) → 탐색. 무엇이 달라지나요?

[탐구] 인터셉터를 하나씩 꺼보기

client.ts의 인터셉터를 하나씩 주석 처리하고 무엇이 깨지는지 확인하세요.

  1. 응답 인터셉터를 통째로 끄면 화면에 무엇이 뜨나요? (fetchFeed의 반환 타입은 여전히 Post[]인데 실제로는 무엇이 들어올까요?)
  2. 요청 인터셉터만 끄고 좋아요를 눌러보세요. 어떤 상태 번호가 돌아오나요?
  3. config.retried 표시를 지우고 만료된 상태로 요청해 보세요. 개발자 도구를 열어둔 채로 해보시고, 무슨 일이 일어나는지 적어주세요.

⚠️ 3번은 브라우저가 느려질 수 있습니다. 탭을 닫을 준비를 하고 해보세요.

[설계]이 값들의 staleTime을 정하세요

아래 다섯 가지에 staleTime을 정하고 이유를 한 문장씩 적으세요.

  1. 인스타그램 피드 게시물 목록
  2. 로그인한 사용자의 프로필 사진 주소
  3. 게시물 하나의 좋아요 개수
  4. 해시태그 자동완성 목록
  5. 실시간 경매의 현재 최고 입찰가

⚠️ 다섯 중에 "이건 이 도구로 풀 문제가 아니다"라고 답해야 하는 것이 하나 있습니다. 어느 것이고 그럼 무엇을 써야 할까요?


생각해볼 주제

1. 서버 상태를 전역 상태 도구에 담으면 안 되나요

지난 시간에 만든 store에 피드를 담을 수도 있습니다. usePostStore를 만들고 거기에 게시물 배열을 넣는 거죠. 실제로 그렇게 만든 코드베이스가 많습니다.

그렇게 하면 무엇을 직접 만들어야 할까요? 오늘 도구가 대신해준 것들을 하나씩 떠올려 보세요.

그리고 반대 방향도 생각해 보세요. 서버 상태 도구가 있으면 Zustand는 필요 없어질까요?

2. 낡은 값을 먼저 보여주는 것이 항상 옳을까요

오늘 우리는 창고에 있는 값을 먼저 보여주고 뒤에서 새로 받는 방식을 택했습니다. 화면이 비지 않아서 빨라 보이죠.

그런데 사용자가 그 낡은 값을 보고 행동하면 어떻게 될까요. 재고가 1개 남았다고 떠 있어서 주문 버튼을 눌렀는데 사실은 0 이었다면요.

어떤 화면에서 이 방식이 위험한지, 그리고 위험한 화면에서는 무엇을 어떻게 바꿔야 할지 생각해 보세요.

3. 인터셉터에 로직을 얼마나 넣어도 될까요

오늘 인터셉터에 네 가지 일을 시켰습니다. 토큰 붙이기, 껍데기 벗기기, 실패를 한 종류로 모으기, 401 갱신하기요.

여기에 더 넣고 싶은 것들이 생깁니다. 요청 로그 남기기, 실패하면 알림 띄우기, 특정 오류면 로그인 화면으로 보내기 같은 것들요.

전부 넣으면 무엇이 좋고 무엇이 나빠질까요. 넣어도 되는 것과 넣으면 안 되는 것을 가르는 기준은 무엇일까요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 게시물 상세도 서버에서 가져오기

채점 포인트

항목 배점 확인할 것
키에 게시물 번호가 들어갔는가 30 번호가 빠지면 다른 게시물을 열어도 앞의 것이 나온다
훅을 queries/에 두고 화면은 그것만 불렀는가 20 화면이 fetchPostById를 직접 부르면 층이 섞인다
404 에 서버가 보낸 사유를 띄웠는가 25 우리가 지어낸 문구가 아니라 message를 써야 한다
다시 들어갔을 때를 확인하고 이유를 적었는가 25 캐시에 있으니 기다리는 화면이 안 뜬다

풀이 예시

TypeScript
// apps/web-spa/src/queries/posts.ts
export function postKey(id: number) {
  return ['posts', 'detail', { id }] as const;
}

export function usePostQuery(id: number) {
  return useQuery({
    queryKey: postKey(id),
    queryFn: () => fetchPostById(id),
  });
}

키를 ['posts', 'detail', { id }]로 지은 이유가 있습니다. 앞의 'posts'를 공유해두면 나중에 "게시물과 관련된 것 전부"를 한 번에 다룰 수 있는데, 그 쓰임은 다음 시간에 나옵니다.

tsx
// apps/web-spa/src/routes/PostDetailPage.tsx
export function PostDetailPage() {
  const { postId } = useParams();
  const id = Number(postId);

  const { data: post, isPending, error } = usePostQuery(id);

  if (error !== null) {
    return <p>{error instanceof ApiError ? error.message : '게시물을 불러오지 못했어요'}</p>;
  }

  if (isPending) {
    return <p>게시물을 불러오는 중이에요…</p>;
  }

  return (
    <article aria-label="게시물">
      <h2>{post.username}</h2>
      <p>{post.content}</p>
      <p>좋아요 {post.likeCount}개</p>
    </article>
  );
}

다시 들어갔을 때 — "불러오는 중"이 안 뜹니다. 첫 방문에서 받은 게시물이 ['posts', 'detail', { id: 1 }]라는 이름으로 창고에 남아 있고, 다시 들어오면 그것을 꺼내 곧바로 그리기 때문이에요.

자주 나오는 실수

키를 ['post'] 하나로 두는 것. 1번 게시물을 보고 나서 2번으로 가면 1번이 그대로 뜹니다. 실제로 그렇게 만들어 확인해 봤는데, 창고 입장에서는 같은 이름을 두 번 물어본 것이라 아무 잘못이 없습니다.

Number(postId)를 안 하는 것. 주소에서 온 값은 항상 문자열인데, postKey('1')postKey(1)은 다른 이름이라 어디선가 숫자로 부르고 어디선가 문자열로 부르면 같은 게시물을 두 번 받아옵니다.

💡 튜터의 한마디 — 이 과제의 진짜 목적은 "키를 짓는 감각"입니다. 키는 캐시에서 이 데이터를 찾는 이름이고, 이름이 같으면 같은 것으로 취급됩니다. 가져오는 함수에 넘긴 값이 전부 이름에 들어갔는지만 확인하면 실수할 일이 없어요.

🎯 [과제 2 예시답안] 다시 불러오기 버튼

채점 포인트

항목 배점 확인할 것
refetch로 다시 물어보는가 25 누를 때마다 요청이 하나씩 나간다
isFetching을 썼는가 30 isPending을 쓰면 목록이 사라진다
목록이 그대로 남아 있는가 30 다시 받는 동안 화면이 비면 안 된다
버튼이 잠기고 글자가 바뀌는가 15 연타를 막는다

풀이 예시

tsx
// apps/web-spa/src/routes/HomePage.tsx
export function HomePage() {
  const { data: posts, isPending, isFetching, error, refetch } = useFeedQuery();

  if (error !== null) {
    return <p>{error instanceof ApiError ? error.message : '피드를 불러오지 못했어요'}</p>;
  }

  if (isPending) {
    return <p>피드를 불러오는 중이에요…</p>;
  }

  return (
    <>
      <Button size="sm" disabled={isFetching} onClick={() => void refetch()}>
        {isFetching ? '불러오는 중…' : '새로고침'}
      </Button>
      <FeedSection posts={posts} />
    </>
  );
}

자주 나오는 실수

isPending으로 버튼을 잠그는 것. 이렇게 하면 버튼이 영영 안 잠기는데, 이유는 데이터가 이미 있어서 isPending이 계속 false이기 때문이에요. 반대로 isPending으로 목록을 가리려고 하면 그것도 안 됩니다.

Step 6의 🙋 에서 정리한 세 값이 여기서 갈립니다.

새로고침 누른 직후
isPending 아직 데이터가 없다 false
isFetching 지금 요청이 나가 있다 true
isLoading 둘 다 false

onClick={refetch}로 바로 넘기는 것도 조심하세요. 버튼의 클릭 이벤트 객체가 refetch의 인자로 들어갑니다. 지금은 무해하지만 옵션을 받는 함수라 나중에 문제가 될 수 있어요. () => void refetch()로 감싸는 편이 안전합니다.

💡 튜터의 한마디 — "다시 받는 동안 목록을 그대로 두라"는 조건이 이 과제의 핵심입니다. 손으로 짰다면 posts를 비우지 않으면서 로딩 표시만 켜는 상태를 따로 만들어야 했어요. 도구는 그 두 상태를 처음부터 나눠서 줍니다.

🎯 [과제 3 예시답안] 시간을 바꿔가며 재보기

채점 포인트

항목 배점 확인할 것
세 값의 요청 수를 정확히 셌는가 40 0 일 때만 요청이 나간다
"불러오는 중" 이 안 뜬 이유를 설명했는가 30 캐시가 먼저 채우기 때문
gcTime 실험 결과가 맞는가 30 지워진 뒤에는 처음부터다

풀이 예시

전체 → 카페 → 전체 → 카페 를 5초 안에 눌렀을 때입니다.

staleTime 요청 수 "불러오는 중" 이 떴나
0 2 안 뜸
3_000 0 안 뜸
60_000 0 안 뜸

세 경우 모두 "불러오는 중"이 안 뜨는 이유는, 이미 받아둔 것이 창고에 있어서 화면이 그것으로 곧바로 채워지기 때문이에요. 갈리는 것은 화면이 아니라 요청이 나가느냐입니다.

staleTime: 0은 "받자마자 낡은 것으로 친다"는 뜻이라, 돌아올 때마다 조용히 다시 물어봅니다. 화면은 멀쩡한데 네트워크만 바쁜 상태예요.

gcTime: 1_000 실험 — 탐색 → 홈 → 2초 기다림 → 탐색 을 하면 이번엔 "불러오는 중"이 뜹니다. 아무도 안 보는 사이에 1초가 지나 창고에서 지워졌기 때문이에요. 지워진 값은 없는 값이라 처음부터 받아야 합니다.

텍스트
 staleTime  다시 물어볼 때를 정한다   (화면이 보고 있는 동안)
 gcTime     버릴 때를 정한다          (아무도 안 보는 동안)

자주 나오는 실수

두 시간을 "짧은 것 / 긴 것"으로 외우는 것. 순서가 정해져 있지 않습니다. staleTime: 0 + gcTime: 5분이면 값은 곧바로 낡지만 5분 동안 창고에 남아 있어요. 실제로 60초를 흘려보내고 확인했는데 그대로 있었습니다. 낡았다는 것은 "다시 물어볼 때가 됐다" 이지 "쓸 수 없다"가 아닙니다.

💡 튜터의 한마디 — 실무에서 "요청이 왜 이렇게 많이 나가지?"의 답은 대부분 staleTime이 기본값 0 이기 때문인데, 이걸 모르면 refetchOnWindowFocus: false 같은 것부터 끄게 됩니다. 그건 "다시 안 받겠다"라서 뜻이 다르니 먼저 시간을 정해보시길 권합니다.

🎯 [과제 4 예시답안] 인터셉터를 하나씩 꺼보기

채점 포인트

항목 배점 확인할 것
응답 인터셉터를 껐을 때 무엇이 오는지 확인 35 껍데기가 그대로 온다
타입은 맞는데 실제가 다르다는 것을 짚었는가 20 제네릭이 거짓말을 한다
요청 인터셉터를 껐을 때 상태 번호 20 401
무한 반복을 재현하고 무슨 일이 났는지 적었는가 25 브라우저가 아니라 프로세스가 죽는다

풀이 예시

1. 응답 인터셉터를 끄면 화면에 게시물이 한 장도 안 뜨고 오류가 납니다.

fetchFeed()가 돌려주는 것이 Post[]가 아니라 껍데기 통째이기 때문이에요.

텍스트
 인터셉터가 있을 때   [ { id: 1, ... }, { id: 2, ... } ]
 껐을 때             { success: true, data: [ ... ], message: null }

⚠️ 여기서 중요한 것은 타입 검사가 이걸 못 잡는다는 점입니다. fetchFeed의 반환 타입은 여전히 Promise<Post[]>라고 적혀 있지만, tsc가 아무 말도 안 하는 이유는 우리가 api.get<Post[]>라고 적어둔 것을 그대로 믿기 때문이에요. A-6에서 배운 그대로 제네릭은 약속이지 검사가 아니어서, 인터셉터가 그 약속을 지키고 있었던 겁니다.

2. 요청 인터셉터를 끄고 좋아요를 누르면 401이 돌아옵니다.

텍스트
 { "success": false, "data": null, "message": "로그인이 필요합니다" }

출입증이 안 붙었으니 서버가 누군지 모릅니다.

3. config.retried 표시를 지우면 요청이 끝없이 반복됩니다.

401 → 갱신 → 다시 보냄 → 401 → 갱신 → … 이 멈추지 않아요. 저는 이걸 실행해 봤는데 브라우저 탭이 느려지는 정도가 아니라 실행하던 프로세스가 통째로 죽었습니다.

무한 반복이 무서운 이유가 여기 있는데, 화면에는 아무 오류도 안 뜨고 그냥 조용히 요청만 계속 나가다가 어느 순간 전부 멈춘다는 거예요. 원인을 찾으려면 네트워크 탭을 열어봐야 하는데 그때쯤이면 개발자 도구도 느려져 있습니다.

자주 나오는 실수

주석 처리한 뒤 새로고침을 안 하는 것. 인터셉터는 모듈을 불러올 때 한 번 등록됩니다. 개발 서버가 파일만 갈아끼우고 등록된 것을 안 지우는 경우가 있어서, 확실히 보려면 새로고침하세요.

💡 튜터의 한마디 — 가드는 만들어 놓고 끝이 아니라 일부러 깨봐야 무엇을 막고 있었는지 알 수 있습니다. 3번을 직접 해보신 분은 앞으로 재시도 로직을 짤 때 "그만둘 조건"부터 쓰게 될 거예요.

🎯 [과제 5 예시답안]이 값들의 staleTime을 정하세요

채점 포인트

항목 배점 확인할 것
다섯 값에 시간을 정하고 이유를 적었는가 50 값마다 바뀌는 빈도가 다르다
이 도구가 아닌 것을 찾았는가 30 경매 최고가
그럼 무엇을 써야 하는지 답했는가 20 서버가 먼저 알려주는 방식

풀이 예시

staleTime 이유
피드 게시물 목록 30초 ~ 1분 새 글이 자주 올라오지만 몇십 초 낡아도 곤란하지 않다
프로필 사진 주소 5분 이상 본인이 바꾸기 전에는 안 바뀐다
게시물 좋아요 개수 10초 안팎 남이 계속 누른다. 다만 몇 개 차이는 문제가 안 된다
해시태그 자동완성 10분 이상 목록 자체가 거의 안 바뀐다
경매 최고 입찰가 이 도구로 풀 문제가 아니다

경매 최고가가 그것입니다.

이유를 정확히 말하면 이렇습니다. 지금 방식은 우리가 물어봐야만 새 값을 압니다. staleTime을 0으로 두고 1초마다 물어봐도, 물어보는 그 순간과 순간 사이는 여전히 모르는 시간이에요.

경매는 그 사이가 치명적입니다. 마감 3초 전에 남이 더 높게 불렀는데 내 화면에는 옛 최고가가 떠 있으면, 나는 이미 진 금액을 부르게 됩니다.

이런 값은 서버가 먼저 알려줘야 합니다. 우리가 묻는 게 아니라 서버가 "지금 최고가가 바뀌었습니다" 하고 밀어주는 방식이에요. 그것을 C-9에서 다룹니다.

⚠️ 다만 둘은 경쟁 관계가 아닙니다. 실무에서는 처음 화면을 그릴 때 지금 방식으로 받고, 그 뒤의 변화는 서버가 밀어주는 것으로 채웁니다. 서버가 알려준 값을 창고에 넣어두면 두 방식이 한 화면에서 같이 돌아요.

자주 나오는 실수

"실시간이 필요하면 staleTime: 0"이라고 답하는 것. 0은 "돌아올 때마다 다시 물어본다" 이지 "계속 물어본다"가 아닙니다. 화면을 켜두고 가만히 있으면 아무 요청도 안 나가요.

💡 튜터의 한마디 — 이 표를 채울 때 기준은 하나입니다. 틀린 값을 보여줬을 때 사용자가 무엇을 잘못하게 되는가입니다. 좋아요 개수가 몇 초 낡으면 아무 일도 안 생기지만, 재고나 입찰가가 낡으면 사용자가 실패할 행동을 합니다.


🤔 [생각해볼 주제 1] 서버 상태를 전역 상태 도구에 담으면 안 되나요

문제 상황 요약

지난 시간에 만든 store에 게시물 배열을 담을 수도 있습니다. usePostStore를 만들고 fetchFeed() 결과를 넣는 거죠. 실제로 그렇게 만든 코드베이스가 많고, 잘 돌아가기도 합니다.

그럼 오늘 도구를 따로 들인 이유는 무엇일까요.

튜터의 가이드 및 해설

store에 담아도 됩니다. 다만 담는 순간 직접 만들어야 하는 목록이 생깁니다.

오늘 도구가 대신해준 것을 하나씩 세어보면 이렇습니다.

텍스트
 · 같은 데이터를 두 곳에서 부를 때 요청을 하나로 묶기
 · 받아온 시각을 기억했다가 "다시 물어볼 때" 판단하기
 · 아무도 안 보면 언제 버릴지 정하기
 · 화면에 다시 들어왔을 때 창고에 있는 것부터 보여주기
 · 다시 받는 동안 앞의 값을 유지하기
 · 실패했을 때 몇 번 다시 시도할지
 · 조건(태그·번호)마다 따로 저장하기

일곱 가지 전부 store로도 만들 수 있지만, 문제는 이게 우리 도메인 코드가 아니라는 것이에요. 인스타그램을 만들려고 앉았는데 캐시 만료 로직을 짜고 있게 됩니다.

반대 방향도 봅시다. 서버 상태 도구가 있으면 Zustand는 필요 없어질까요?

아닙니다. 지난 시간에 store에 담은 것이 무엇이었는지 떠올려 보세요. 삭제 확인 상자가 열려 있는지 여부였습니다. 그 값은 서버가 모르고, 새로고침하면 사라져야 하고, 낡을 일도 없어요. 서버 상태 도구에는 담을 자리조차 없습니다.

그래서 둘은 대체 관계가 아니라 담는 것이 다른 그릇입니다. 실무 코드베이스를 열어보면 대개 둘 다 있고, store 쪽은 생각보다 훨씬 작아요. 서버 값을 걷어내고 나면 진짜 화면 상태는 얼마 안 되거든요.

🎯 면접관을 홀리는 핵심 멘트

"전역 상태 라이브러리에 서버 응답을 담으면, 그때부터 캐시 무효화와 중복 요청 제거를 직접 구현하게 됩니다. 저는 값의 주인이 누구인가로 그릇을 나눕니다. 주인이 서버면 서버 상태 도구에, 주인이 우리면 클라이언트 상태 도구에 담습니다. 이렇게 나누면 전역 store가 눈에 띄게 작아지고, 남은 것들은 전부 화면의 상태라서 읽기도 쉬워집니다."

🤔 [생각해볼 주제 2] 낡은 값을 먼저 보여주는 것이 항상 옳을까요

문제 상황 요약

오늘 우리는 창고에 있는 값을 먼저 보여주고 뒤에서 새로 받는 방식을 택했습니다. 화면이 비지 않아서 빨라 보이죠.

그런데 사용자가 그 낡은 값을 보고 행동하면 어떻게 될까요.

튜터의 가이드 및 해설

이 방식이 좋은 이유는 분명합니다. 화면이 비었다 채워지는 깜빡임이 사라지고, 사용자는 기다렸다는 느낌을 안 받아요.

위험은 읽는 값과 행동하는 값이 같을 때 생깁니다.

텍스트
 낡아도 괜찮은 것          낡으면 위험한 것
 ────────────────         ─────────────────
 피드 게시물 목록          장바구니 결제 금액
 프로필 소개글             남은 재고 수량
 좋아요 개수               예매 가능 좌석
 댓글 목록                 계좌 잔액

오른쪽 것들의 공통점은 그 숫자를 보고 누르는 버튼이 있다는 겁니다. 재고가 1이라고 떠 있어서 주문했는데 실제로 0이면, 사용자는 성공했다고 믿고 다음 단계로 갔다가 나중에 실패를 알게 됩니다.

해법은 두 갈래입니다.

하나 — 그 화면만 시간을 0으로 둡니다. 전체 기본값은 그대로 두고 위험한 화면에서만 매번 확인하게 하는 거예요. 값은 화면마다 따로 줄 수 있습니다.

둘 — 화면에 보여주는 값과 실제로 처리하는 값을 나눕니다. 이게 더 중요합니다. 재고가 화면에 몇으로 떠 있든, 주문을 확정하는 순간 서버가 다시 확인해야 해요. 프런트의 숫자는 안내를 위한 것이지 판정 근거가 아닙니다.

⚠️ 두 번째가 본질입니다. 프런트에서 아무리 자주 물어봐도 사용자가 버튼을 누르는 그 순간과 서버가 처리하는 순간 사이에는 틈이 있어요. 그 틈은 프런트가 없앨 수 없습니다.

🎯 면접관을 홀리는 핵심 멘트

"캐시된 값을 먼저 보여주는 것은 표시의 문제이고, 그 값으로 판정하는 것은 정합성의 문제입니다. 저는 이 둘을 분리합니다. 재고 수량은 낡은 값으로 보여줘도 되지만, 주문 확정은 서버가 그 순간의 재고로 판정해야 합니다. 프런트에서 아무리 자주 갱신해도 클릭과 처리 사이의 틈은 없앨 수 없기 때문입니다."

🤔 [생각해볼 주제 3] 인터셉터에 로직을 얼마나 넣어도 될까요

문제 상황 요약

오늘 인터셉터에 네 가지 일을 시켰습니다. 토큰 붙이기, 껍데기 벗기기, 실패를 한 종류로 모으기, 401 갱신하기요.

여기에 더 넣고 싶은 것들이 계속 생깁니다. 요청 로그, 실패 알림, 특정 오류면 로그인 화면으로 보내기 같은 것들요.

튜터의 가이드 및 해설

인터셉터에 넣으면 좋은 점은 분명합니다. 모든 요청에 자동으로 적용되고, 부르는 쪽이 그것을 몰라도 됩니다. 오늘 만든 넷은 전부 그 성질을 갖고 있어요. 새 API 함수를 만들 때 토큰을 챙길 필요가 없죠.

문제는 인터셉터가 눈에 안 보이는 곳에서 돈다는 점입니다. fetchFeed() 한 줄만 봐서는 무슨 일이 일어나는지 알 수 없어요. 넣을 것을 고를 때 이 비용을 함께 봐야 합니다.

제가 쓰는 기준은 셋입니다.

하나 — 모든 요청에 예외 없이 적용되는가. "이 요청만 빼고"가 붙기 시작하면 인터셉터가 아니라 부르는 쪽에서 정해야 합니다. 조건문이 늘어난 인터셉터는 읽기가 아주 어려워져요.

둘 — 부르는 쪽이 결과를 몰라도 되는가. 토큰을 붙이는 일은 화면이 몰라도 됩니다. 반면 "실패하면 알림 띄우기"는 화면이 알아야 하는 일이에요. 어떤 화면은 알림 대신 그 자리에 오류 문구를 그리고 싶을 테니까요.

셋 — 화면을 옮기는 일인가. 이게 특히 위험합니다. 401이면 로그인 화면으로 보내는 코드를 인터셉터에 넣으면, 백그라운드에서 조용히 돌던 요청 하나 때문에 사용자가 쓰던 화면에서 튕겨 나갑니다. 작성 중이던 댓글과 함께요.

⚠️ 로그 남기기는 대체로 안전합니다. 결과를 바꾸지 않고 부르는 쪽이 알 필요도 없거든요. 다만 요청 본문까지 통째로 찍으면 비밀번호나 토큰이 로그에 남을 수 있습니다.

정리하면 결과를 바꾸지 않거나, 모든 요청에 똑같이 적용되는 일은 인터셉터에 어울리고, 화면마다 다르게 반응해야 하는 일은 부르는 쪽 몫입니다.

🎯 면접관을 홀리는 핵심 멘트

"인터셉터는 횡단 관심사를 담는 자리라고 봅니다. 인증 헤더나 응답 껍데기처럼 모든 요청에 똑같이 적용되고 부르는 쪽이 몰라도 되는 것들이요. 반대로 화면 이동이나 알림처럼 화면마다 다르게 반응해야 하는 것은 넣지 않습니다. 401에서 로그인 화면으로 보내는 코드를 인터셉터에 넣었다가, 백그라운드 요청 하나 때문에 사용자가 작성 중이던 글과 함께 튕겨 나가는 사고를 본 적이 있습니다."

전체 목록 리액트