문서 읽는 데 80분 · D7

D-7: Server Actions & Route Handlers ① — 요청을 받기 시작한다

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

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

질문 하나로 시작할게요. 지금까지 우리가 만든 화면 중에서, 서버가 들고 있는 것을 바꿔본 화면이 하나라도 있을까요?

안녕하세요, 홍순구입니다. 좋아요 버튼이 있죠. 눌러보면 숫자가 오르는데, 새로고침을 한 번 하면 원래 숫자로 돌아와요. 서버는 우리가 그 버튼을 눌렀다는 사실을 모릅니다.

세어보면 정확히 나오는데, 우리 앱이 지금까지 API 서버에 보낸 요청 중 가져오는 것이 아닌 요청은 0건이에요. 여덟 회차 동안 우리는 화면을 그리는 일만 해왔습니다.

오늘 그게 바뀌는데, 요청을 받을 수 있게 되는 순간 지난 시간에 미뤄둔 두 가지가 한꺼번에 풀립니다. 붙여만 두고 못 불렀던 이름표와, 되찾지 못한 404예요.

💡 오늘 수업의 핵심 — "브라우저에서 부르는데 몸통은 서버에서 돕니다"

지금까지 서버와 브라우저 사이에는 한 방향 통로만 있었습니다. 서버가 그려서 보내고, 브라우저는 받아서 띄우고요.

오늘 반대 방향이 열리는데, 열리는 방식이 좀 낯설어요. API 주소를 하나도 안 만들고 열립니다. 함수를 하나 쓰고 그 함수를 폼에 건네면, 그 함수가 곧 주소가 됩니다.

그리고 오늘은 요청을 받는 자리를 셋 배웁니다. 화면이 부르는 자리, 아무나 부를 수 있는 자리, 그리고 화면이 그려지기도 전에 끼어드는 자리요. 셋이 왜 따로 있어야 하는지가 오늘의 줄기입니다.

🎯 학습 목표

  • 'use server'로 서버에서만 도는 함수를 만들고, <form action={fn}>으로 API 주소 없이 부릅니다.
  • useActionState로 액션의 결과·오류·기다리는 중을 화면에 연결합니다.
  • updateTag로 지난 시간에 붙여둔 이름표를 처음 부르고, revalidateTag와 무엇이 다른지 재봅니다.
  • Route Handler로 화면이 아닌 응답을 만들고, proxy.ts로 잃었던 404를 되찾습니다.
텍스트
 오늘의 여정

 1. 아무것도 안 바뀝니다     우리가 보낸 요청을 세어본다
 2. 함수가 주소가 됩니다      'use server' -- 자바스크립트 없이도 동작한다
 3. 팔로우를 보냅니다         useActionState -- 결과와 오류를 받는다
 4. 굳은 것을 갈아 끼웁니다   updateTag -- 지난 시간의 이름표를 부른다
 5. 얼마나 굵게 부를까        이름표 하나 차이로 남의 화면까지 바뀐다
 6. 화면이 아닌 응답          Route Handler -- 그리고 두 함수의 진짜 차이
 7. 404 를 되찾습니다         proxy -- 그리기 전에 끼어드는 자리
 8. 그 404 의 값             2ms 가 405ms 가 된다

Step 1: "아무것도 안 바뀝니다"

좋아요 버튼은 거짓말을 하고 있었습니다

D-3에서 만든 좋아요 버튼을 다시 봅시다.

tsx
// apps/web-next/app/components/LikeButton.tsx
'use client';

import { useState } from 'react';

export function LikeButton({ likeCount, liked }: { likeCount: number; liked: boolean }) {
  const [pressed, setPressed] = useState(liked);
  const [count, setCount] = useState(likeCount);

  function toggle() {
    setPressed(!pressed);
    setCount(pressed ? count - 1 : count + 1);
  }

  return (
    <button
      type="button"
      aria-pressed={pressed}
      onClick={toggle}
      className="mt-2 text-sm text-black/60"
    >
      {pressed ? '♥' : '♡'} 좋아요 {count}
    </button>
  );
}

useState 두 줄이 전부니까, fetch도 없고 서버로 가는 길도 없어요. 브라우저 안에서 숫자만 바뀝니다.

당연히 새로고침하면 되돌아오는데, 이걸 "미완성"이라고만 넘기면 안 되는 이유가 있어요. 지금 우리 앱에는 서버에 무언가를 보내는 방법 자체가 없습니다.

요청을 세어봅시다

연습용 API 서버에 요청 로그를 켜고 띄웁니다.

Bash
API_STUB_LOG=1 node apps/api-stub/server.mjs

그리고 화면 넷을 훑어봅니다. 홈, 탐색, 그리고 프로필 둘이요.

Bash
curl -s -o /dev/null http://localhost:3000/
curl -s -o /dev/null http://localhost:3000/explore
curl -s -o /dev/null http://localhost:3000/jaehoon
curl -s -o /dev/null http://localhost:3000/minji

API 서버 쪽 로그를 메서드별로 세면 이렇게 나옵니다.

텍스트
 GET       9 건
 그 외      0 건

아홉 번 물어봤고, 바꿔달라고 한 적은 한 번도 없습니다.

바꾸려면 무엇이 필요한가

보통 여기서 백엔드에 요청을 넣습니다. "팔로우 API 하나만 만들어주세요" 하고요. 그다음 프런트에서 그 주소로 fetch를 보내는데, C-7에서 우리가 한 게 정확히 그거였습니다.

Next는 다른 길을 하나 더 주는데, 주소를 안 만들고 서버 코드를 부르는 길이요. 다음 Step에서 그 길을 열어봅니다.

💡 한 줄 정리

우리 앱은 여덟 회차 동안 가져오기만 했습니다. 좋아요 숫자가 새로고침에 되돌아오는 것은 버그가 아니라, 아직 보낼 길이 없다는 뜻입니다.

🙋 학생 질문 — "그럼 좋아요 버튼은 오늘 고치나요?"

오늘은 안 고칩니다. 팔로우 버튼을 새로 만들어서 그걸로 배우고, 좋아요는 그다음 시간에 손댈 거예요.

이유가 있습니다. 좋아요는 누르는 순간 바로 반응해야 하는 버튼이에요. 서버가 답할 때까지 하트가 안 채워지면 답답하죠. 그 문제를 제대로 다루려면 도구가 하나 더 필요한데, 그건 다음 시간 주제입니다.

팔로우는 조금 기다려도 견딜 만한 버튼이라 오늘 배울 것들만으로 충분히 만들 수 있어요. 쉬운 것부터 제대로 하고 어려운 것으로 갑니다.


Step 2: "함수가 주소가 됩니다"

가장 작은 것부터

팔로우를 만들기 전에 하나 걸리는 게 있습니다. 누가 팔로우하는지를 알아야 하는데, 우리 앱에는 로그인이 없습니다.

진짜 로그인은 나중에 제대로 다룹니다. 오늘은 "지금 누구로 보고 있나"만 정할 수 있으면 되니까, 쿠키에 이름 하나 적는 것으로 대신할게요. 그리고 그 쿠키를 적는 일이 마침 가장 작은 서버 함수입니다. 네트워크도 안 타고 하는 일이 한 줄이에요.

TypeScript
// apps/web-next/app/actions/session.ts
'use server';

import { cookies } from 'next/headers';

// 이 파일의 함수들은 브라우저가 부르지만 몸통은 서버에서만 돈다.
// 폼이 제출되면 Next 가 여기까지 값을 실어 나른다.
export async function signIn(formData: FormData) {
  const username = formData.get('username');

  if (typeof username !== 'string' || username === '') {
    return;
  }

  // httpOnly 라 브라우저 자바스크립트는 이 값을 못 읽는다. 서버만 본다.
  (await cookies()).set('me', username, {
    httpOnly: true,
    path: '/',
    maxAge: 60 * 60 * 24,
  });
}

export async function signOut() {
  (await cookies()).delete('me');
}

파일 맨 위의 'use server'가 오늘의 새 지시어입니다. D-3에서 배운 'use client'와 짝이지만 하는 일이 정반대예요.

'use client'는 "이 파일부터는 브라우저로 보내라" 였지만, 'use server'"이 함수들은 브라우저로 보내지 마라. 대신 부를 수 있는 통로만 열어둬라"입니다. 몸통은 서버에만 남고, 브라우저에는 "그 함수를 부르는 방법"만 갑니다.

폼에 함수를 건넵니다

이 함수를 화면에 붙입니다.

tsx
// apps/web-next/app/components/SignInForm.tsx
import { cookies } from 'next/headers';
import { signIn, signOut } from '@/app/actions/session';

// 지시어가 없다. 서버 컴포넌트다 — 그런데 폼이 있고 버튼이 동작한다.
// 쿠키를 읽으니 이 조각은 요청 때 채워진다.
export async function SignInForm() {
  const me = (await cookies()).get('me')?.value;

  if (me !== undefined) {
    return (
      <form action={signOut} className="ml-2 flex items-center gap-2">
        <span className="text-black/60">@{me}</span>
        <button type="submit" className="rounded border border-black/15 px-2 py-1">
          나가기
        </button>
      </form>
    );
  }

  // 어느 버튼을 눌렀는지는 name/value 로 함께 실려 간다.
  return (
    <form action={signIn} className="ml-2 flex items-center gap-2">
      <button
        type="submit"
        name="username"
        value="jaehoon"
        className="rounded border border-black/15 px-2 py-1"
      >
        재훈으로 보기
      </button>
      <button
        type="submit"
        name="username"
        value="minji"
        className="rounded border border-black/15 px-2 py-1"
      >
        민지로 보기
      </button>
    </form>
  );
}

여기서 두 가지를 눈여겨보세요.

첫째, 이 파일에는 'use client'가 없는데, 서버 컴포넌트예요. 그런데 버튼이 눌리고 동작합니다. 지금까지 우리는 "누르면 뭔가 일어나는 것"은 전부 클라이언트 컴포넌트여야 한다고 배웠는데, 폼은 예외입니다.

둘째, <form action={...}>문자열이 아니라 함수를 넣었습니다. 원래 HTML에서 action은 "이 주소로 보내라"는 뜻이었는데, 여기서는 "이 함수를 불러라"가 됐어요.

머리말에 끼워 넣습니다

이 조각은 쿠키를 읽으니까 지난 시간 규칙대로 다뤄야 하는데, 머리말은 클라이언트 컴포넌트라 서버 조각을 그 안에 직접 넣을 수 없어요. D-4에서 배운 방법을 씁니다.

tsx
// apps/web-next/app/components/HeaderNav.tsx
// children 으로 받은 것은 이 파일이 import 한 게 아니라 밖에서 만들어져 온다.
// 그래서 이 안에 서버 조각을 끼워 넣을 수 있다 — D-4 에서 본 그 구멍이다.
export function HeaderNav({ children }: { children?: ReactNode }) {
tsx
// apps/web-next/app/layout.tsx
            <Suspense fallback={<div className="mx-auto h-14 max-w-3xl" />}>
              {/* 로그인 칸은 쿠키를 읽는 서버 조각이다. 클라이언트 머리말 안에
                  children 으로 끼워 넣는다 — 경계를 넘어가지 않는다. */}
              <HeaderNav>
                <SignInForm />
              </HeaderNav>
            </Suspense>

띄워서 눌러보면 머리말이 "재훈으로 보기 / 민지로 보기"에서 "@jaehoon / 나가기"로 바뀝니다.

브라우저에 실제로 뭐가 그려졌을까요

여기가 오늘 가장 재미있는 부분입니다. 소스 보기로 그 폼을 찾아보세요.

HTML
<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_ID_40e4cbf2b7df5e2e9f3f7a912205f3ad51cacacedf"/>
  <button type="submit" value="jaehoon" name="username">재훈으로 보기</button>
  <button type="submit" value="minji" name="username">민지로 보기</button>
</form>

평범한 HTML 폼입니다. method="POST"가 붙어 있고, action=""은 "지금 이 주소로 보내라"는 뜻을 담고 있는데, 어느 함수를 부를지는 숨은 입력칸에 긴 문자열로 실려 있습니다.

그러니까 이런 일이 벌어집니다.

텍스트
 브라우저                                    서버
    │                                         │
    │  POST /                                 │
    │  $ACTION_ID_40e4cb...                   │
    │  username=minji         ───────────>    │
    │                                         │   아 이 함수구나
    │                                         │   signIn(formData) 실행
    │                                         │   쿠키를 심는다
    │                                         │   화면을 다시 그린다
    │    <───────────  Set-Cookie + 새 화면   │
    │                                         │

자바스크립트를 안 쓰고도 되는지 확인해봅시다

평범한 폼이라면 자바스크립트 없이도 동작해야 합니다. 브라우저를 안 쓰고 터미널에서 보내볼게요.

⚠️ 그 긴 식별자는 빌드할 때마다 달라집니다. 아래 값을 그대로 복사하면 안 되고, 여러분 화면의 소스 보기에서 직접 읽어와야 해요. 이렇게 뽑으면 편합니다.

Bash
curl -s http://localhost:3000/ | grep -o '\$ACTION_ID_[a-f0-9]*'

그 값을 넣고 보냅니다.

Bash
curl -s -D- -X POST http://localhost:3000/ \
  -F '$ACTION_ID_여기에_여러분_값=' \
  -F 'username=minji' | head -20
텍스트
HTTP/1.1 200 OK
Set-Cookie: me=minji; Path=/; Max-Age=86400; HttpOnly

쿠키가 심겼는데, 그 응답 본문에는 이미 로그인된 화면이 들어 있어요. "나가기" 버튼이 그려진 채로요.

두 가지를 동시에 확인한 겁니다. 하나, 이 버튼은 자바스크립트가 꺼져 있어도 동작합니다. 둘, 서버는 일을 처리하고 새 화면을 그려서 한 번에 보내는데, 요청 한 번에 왕복 한 번이에요.

💡 한 줄 정리

'use server'를 붙인 함수는 브라우저로 안 가고 통로만 열립니다. 폼의 action에 그 함수를 건네면 Next가 평범한 HTML 폼으로 그려주고, 제출되면 함수를 실행한 뒤 새 화면까지 한 응답에 담아 보냅니다.

🙋 학생 질문 — "쿠키를 document.cookie로 심으면 안 되나요? 지난 시간엔 그렇게 했잖아요."

맞습니다. 지난 시간 글자 크기 토글은 브라우저에서 document.cookie로 직접 썼어요. 그건 그래도 되는 값이었습니다. 남이 봐도 상관없고, 위조해도 자기 글자만 커지니까요.

로그인은 다릅니다. 이번 쿠키에는 httpOnly: true가 붙어 있는데, 이걸 붙이면 브라우저 자바스크립트가 그 쿠키를 아예 못 읽어서 서버만 볼 수 있고, document.cookie로는 심을 수가 없습니다. httpOnly 쿠키는 서버가 응답 헤더로만 심을 수 있거든요. 서버에서 도는 함수가 필요한 이유가 이거예요.

왜 이렇게까지 하냐면, 페이지에 끼어든 남의 스크립트가 쿠키를 훔쳐가는 공격이 흔하기 때문입니다. 읽을 수 없으면 훔쳐갈 수도 없어요.


Step 3: "팔로우를 보냅니다"

서버 쪽부터 열어둡니다

이번엔 진짜로 데이터가 바뀌는 일을 해봅니다. 연습용 API 서버에 팔로우를 받는 주소를 만들었어요.

JavaScript
// apps/api-stub/server.mjs
  // 팔로우 — 누른 사람이 누구인지를 헤더로 받는다. 없으면 거절한다.
  // 진짜 서비스라면 세션이나 토큰을 보겠지만, 여기서는 "서버가 직접 확인한다" 는
  // 모양만 같으면 된다. 토큰 발급은 다른 시간에 다룬다.
  {
    method: 'POST',
    match: (path) => /^\/api\/users\/[^/]+\/follow$/.test(path),
    handle: async (req, res, path) => {
      const actor = req.headers['x-actor'];
      if (typeof actor !== 'string' || findUser(actor) === undefined) {
        return fail(res, 401, '로그인이 필요합니다');
      }

      const target = decodeURIComponent(path.split('/').at(-2));
      if (!posts.some((it) => it.username === target)) {
        return fail(res, 404, '그런 사람이 없습니다');
      }
      if (target === actor) {
        return fail(res, 400, '자기 자신은 팔로우할 수 없습니다');
      }
      // ... 팔로우 상태를 뒤집고 팔로워 수를 증감시킨다

      ok(res, { username: target, following: nowFollowing, followerCount: followersOf(target) });
    },
  },

터미널로 먼저 확인해봅시다. 다섯 갈래가 다 다르게 나옵니다.

텍스트
 헤더 없이                  401  로그인이 필요합니다
 X-Actor: nobody           401  로그인이 필요합니다
 X-Actor: minji  -> minji  400  자기 자신은 팔로우할 수 없습니다
 X-Actor: jaehoon -> zzzzz 404  그런 사람이 없습니다
 X-Actor: jaehoon -> minji 200  {"following":true,"followerCount":8501}

액션을 씁니다

TypeScript
// apps/web-next/app/actions/follow.ts
'use server';

import { cookies } from 'next/headers';

const API_BASE = 'http://localhost:8090/api';

export type FollowState = {
  /** 아직 눌러본 적이 없으면 null */
  following: boolean | null;
  message: string | null;
};

/**
 * 폼에서 받을 값이 없어서 formData 인자는 아예 안 적었다.
 * 대신 누구를 팔로우하는지는 호출부에서 미리 묶어 보낸다.
 */
export async function follow(username: string, previous: FollowState): Promise<FollowState> {
  // 누가 눌렀는지는 브라우저가 보내준 값이 아니라 서버가 직접 읽는다.
  const me = (await cookies()).get('me')?.value;

  if (me === undefined) {
    return { ...previous, message: '로그인이 필요해요' };
  }

  const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}/follow`, {
    method: 'POST',
    headers: { 'X-Actor': me },
  });

  const envelope = await response.json();

  if (!response.ok || !envelope.success) {
    // 실패하면 알던 상태는 그대로 두고 사유만 얹는다.
    return { ...previous, message: envelope.message ?? '팔로우하지 못했어요' };
  }

  return { following: envelope.data.following, message: null };
}

주석에 적어둔 두 줄이 중요합니다.

누가 눌렀는지를 서버가 직접 읽습니다. 브라우저가 "저 재훈이에요" 하고 보내주는 값을 믿지 않아요. 쿠키를 서버가 열어봅니다. 이게 왜 중요한지는 다음 시간에 제대로 다룹니다.

실패하면 previous를 펼쳐서 돌려줍니다. 알고 있던 상태는 그대로 두고 사유만 얹는 거예요. 실패했다고 "팔로우 안 한 상태"로 되돌려버리면 화면이 거짓말을 하게 됩니다.

화면에 붙입니다

tsx
// apps/web-next/app/components/FollowButton.tsx
'use client';

import { useActionState } from 'react';
import { follow, type FollowState } from '@/app/actions/follow';

const initial: FollowState = { following: null, message: null };

export function FollowButton({ username }: { username: string }) {
  // 서버 함수에 이름을 미리 묶어두면 나머지 인자는 React 가 채운다.
  const [state, formAction, pending] = useActionState(follow.bind(null, username), initial);

  return (
    <form action={formAction} className="mb-4">
      <button
        type="submit"
        disabled={pending}
        className="rounded border border-black/15 px-3 py-1 text-sm disabled:opacity-40"
      >
        {pending ? '보내는 중…' : state.following === true ? '팔로잉' : '팔로우'}
      </button>
      {state.message !== null && (
        <p aria-live="polite" className="mt-1 text-sm text-black/60">
          {state.message}
        </p>
      )}
    </form>
  );
}

useActionState가 돌려주는 것이 셋입니다.

텍스트
 const [state, formAction, pending] = useActionState(액션, 초기값)
                │       │         └── 지금 실행 중인가 (true/false)
                │       └──────────── 폼의 action 에 넣을 것
                └──────────────────── 액션이 마지막으로 돌려준 값

state는 우리가 만든 FollowState 그대로라서, 버튼 글자도 오류 문구도 전부 액션의 반환값에서 나와요. pending은 React가 알아서 켜고 끕니다.

follow.bind(null, username)은 자바스크립트의 평범한 bind입니다. 첫 인자를 미리 채워두는 거예요. 그러면 남는 인자가 previous 하나가 되고, 그 자리를 useActionState가 채웁니다.

눌러봅시다

로그인을 안 한 채로 먼저 눌러보면 "로그인이 필요해요"가 뜨는데, API 서버 로그를 보면 아무 요청도 안 갔어요. 액션이 쿠키를 확인하고 그 앞에서 멈춘 겁니다.

이제 "재훈으로 보기"를 누르고 민지 프로필에서 팔로우를 눌러봅니다. 버튼이 "팔로잉"으로 바뀝니다. API 서버 로그에도 찍혀요.

텍스트
 [api-stub] POST /api/users/minji/follow

그런데 숫자가 안 바뀝니다

팔로워 수를 보세요. 팔로우를 눌렀는데 그대로입니다.

API 서버에 직접 물어보면 바뀌어 있어요.

텍스트
 액션 실행 전   원본 8500  ·  화면 8500
 액션 실행 후   원본 8501  ·  화면 8500
 다시 봐도             ·  화면 8500
 또 봐도               ·  화면 8500

서버는 8501 인데 화면은 8500에서 안 움직입니다.

당연합니다. 지난 시간에 우리가 그렇게 만들었으니까요. cacheLife('hours')를 걸어뒀죠. 한 시간 동안은 다시 안 물어봅니다.

우리가 방금 바꾼 건데도요.

💡 한 줄 정리

액션이 서버까지 가서 데이터를 바꿨는데, 화면은 지난 시간에 굳혀둔 값을 그대로 보여줍니다. 바꾼 사람이 자기가 바꾼 결과를 못 보는 상태예요.

🙋 학생 질문 — "액션 함수 인자가 왜 두 개예요? formData는 어디 갔죠?"

useActionState는 액션을 부를 때 (이전상태, formData) 두 개를 넘겨줍니다. 그런데 우리 팔로우 함수는 폼에서 받을 값이 하나도 없어요. 입력칸이 없고 버튼만 있으니까요.

자바스크립트는 선언한 것보다 많은 인자를 받아도 아무 문제가 없어서, 안 쓸 인자는 아예 안 적었어요.

처음에는 이렇게 적었었습니다.

TypeScript
export async function follow(username: string, _previous: FollowState, _formData: FormData)

이러면 린트가 "안 쓰는 인자가 둘 있다" 고 경고하는데, 밑줄을 붙여도 마찬가지예요. 안 쓸 거면 지우는 게 맞습니다.

Step 2의 signIn은 반대죠. 어느 버튼을 눌렀는지를 알아야 하니 formData를 받아 씁니다.


Step 4: "굳은 것을 갈아 끼웁니다"

지난 시간에 붙여둔 이름표

D-6에서 프로필 조회 함수에 이름표를 두 개 붙였는데, 그때는 붙여만 두고 부르지 못했어요. 부르는 함수가 서버 액션 안에서만 동작하는데, 그때 우리에겐 서버 액션이 없었으니까요.

TypeScript
// apps/web-next/lib/api.ts
export async function fetchProfile(username: string): Promise<Profile> {
  'use cache';
  cacheLife('hours');
  // 이 칸에 이름표를 붙여둔다. 누군가 이 사람을 팔로우한 순간 이 이름으로 지목해 버릴 수 있게.
  cacheTag('profile', `profile:${username}`);
  return get<Profile>(`/users/${encodeURIComponent(username)}`);
}

주석에 "누군가 이 사람을 팔로우한 순간"이라고 적혀 있죠. 그 순간이 방금 왔습니다.

한 줄 추가합니다

TypeScript
// apps/web-next/app/actions/follow.ts
import { updateTag } from 'next/cache';

// ... 성공한 뒤에

  // 지난 시간에 붙여둔 이름표를 여기서 처음 부른다.
  // 이 이름이 달린 캐시 칸을 지금 당장 버려라 — 다음에 볼 사람은 새로 만든 것을 본다.
  updateTag(`profile:${username}`);

  return { following: envelope.data.following, message: null };

이게 전부입니다. 다시 빌드하고 같은 실험을 해보세요.

텍스트
                    Step 3 (updateTag 없음)      Step 4 (updateTag 있음)
 액션 실행 전         원본 8500 · 화면 8500        원본 8501 · 화면 8501
 액션 실행 후         원본 8501                    원본 8500
 화면 1회                    8500                        8500
 화면 2회                    8500                        8500
 화면 3회                    8500                        8500

Step 4 쪽은 첫 요청부터 새 숫자입니다. 한 시간짜리 수명이 걸려 있는데도요.

cacheLife는 "시간이 지나면 버려라" 였는데, updateTag는 "시간과 상관없이 지금 버려라"예요. 둘은 경쟁하는 게 아니라 서로를 메꿉니다. 대부분은 시간에 맡기고, 우리가 직접 바꾼 것만 이름을 불러 버리는 거죠.

이 흐름의 이름

방금 우리가 한 것에는 이름이 있습니다. 자기가 쓴 것을 자기가 바로 읽는다는 뜻의 규칙이에요.

캐시를 쓰는 시스템에서 이게 왜 어려운지 생각해보면 이해가 쉽습니다. 캐시는 "누가 봐도 같은 답"을 전제로 하는데, 방금 무언가를 바꾼 사람에게는 그 전제가 깨지거든요. 남들에게는 8500이 맞지만 그 사람에게는 8501이 맞습니다.

그래서 바꾸는 코드와 버리는 코드가 같은 자리에 있어야 합니다. 팔로우가 성공한 바로 그 지점에서 이름표를 부르는 것이 그 이유예요.

💡 한 줄 정리

updateTag는 이름표가 붙은 캐시 칸을 즉시 버리는데, 수명이 아무리 길게 걸려 있어도요. 데이터를 바꾼 바로 그 자리에서 불러야, 바꾼 사람이 자기가 바꾼 결과를 봅니다.

🙋 학생 질문 — "팔로우가 실패했을 때는 이름표를 안 부르나요?"

안 부릅니다. 코드를 보면 updateTag가 성공 갈래 안쪽에만 있어요. 실패하면 그 앞의 return에서 빠져나갑니다.

맞는 선택입니다. 실패했다는 건 서버 데이터가 안 바뀌었다는 뜻이니까요. 안 바뀐 것을 버리면 똑같은 값을 다시 만드느라 요청만 한 번 더 나갑니다.

이걸 눈으로 확인할 수도 있는데, 팔로우가 실패하는 상황을 만들어두고 프로필 조회 요청 수를 세보면, 성공할 때는 늘고 실패할 때는 안 늡니다. 과제에서 직접 해보세요.


Step 5: "얼마나 굵게 부를까"

이름표가 두 개였던 이유

지난 시간에 붙인 줄을 다시 봅시다.

TypeScript
cacheTag('profile', `profile:${username}`);

이름표가 둘입니다. 굵은 것 하나와 가는 것 하나요. 그때는 "나중에 쓸 데가 있다" 고만 하고 넘어갔는데, 지금 그 차이를 재볼 수 있습니다.

실험을 설계합니다

이렇게 해봅시다.

텍스트
 1. 민지 프로필과 재훈 프로필을 한 번씩 열어 둘 다 캐시에 담는다
 2. 앱을 안 거치고 API 서버에서 두 사람의 팔로워 수를 바꾼다
 3. 민지 프로필에서만 팔로우 버튼을 누른다
 4. 두 화면이 각각 어떻게 되는지 본다

민지는 우리가 건드렸으니 바뀌는 게 맞습니다. 문제는 재훈이 어떻게 되느냐예요.

가는 이름표로 부르면

지금 코드가 그렇죠. updateTag(profile:${username})입니다.

텍스트
 민지    원본 8501  ·  화면 8501     갱신됐다
 재훈    원본 1241  ·  화면 1240     낡은 채로 남았다

재훈은 안 바뀝니다. 우리가 지목한 이름이 profile:minji 였으니까요. 재훈 칸에는 그 이름표가 안 붙어 있습니다.

굵은 이름표로 부르면

한 글자만 바꿔봅시다.

TypeScript
updateTag('profile');
텍스트
 민지    원본 8500  ·  화면 8500     갱신됐다
 재훈    원본 1240  ·  화면 1240     같이 갱신됐다

재훈 화면이 1241에서 1240으로 따라 바뀌었습니다. 우리는 재훈을 건드린 적이 없는데요.

profile이라는 이름표는 프로필 캐시 전부에 붙어 있습니다. 그걸 부르면 전부 버려져요.

어느 쪽이 맞는가

둘 다 맞을 수 있습니다. 상황에 따라요.

텍스트
 가는 이름표 (profile:minji)
   버리는 것: 민지 칸 하나
   대가:      우리가 지목한 것만 바뀐다. 다른 사람은 낡은 채로 남는다
   쓸 때:     무엇이 바뀌었는지 정확히 알 때

 굵은 이름표 (profile)
   버리는 것: 모든 사람의 프로필 칸
   대가:      멀쩡한 캐시까지 버려서 다시 만드는 비용이 든다
   쓸 때:     프로필 화면 구조 자체가 바뀌었을 때 (배포 직후 같은 경우)

팔로우는 앞쪽입니다. 누구를 팔로우했는지 정확히 아니까요. 실험이 끝났으면 가는 이름표로 되돌려주세요.

한 가지 더 짚어둘 게 있습니다. 방금 재훈 화면이 낡은 채로 남았죠. 그건 버그가 아닙니다. 재훈의 팔로워 수는 우리가 앱 밖에서 몰래 바꾼 것이라 앱이 알 도리가 없어요. 진짜 서비스라면 재훈을 팔로우한 사람의 액션이 그때 자기 몫의 이름표를 부릅니다.

💡 한 줄 정리

이름표는 굵기를 골라 붙일 수 있습니다. 굵게 부르면 편하지만 남의 캐시까지 버려요. 무엇이 바뀌었는지 정확히 안다면 그만큼만 지목하는 게 맞습니다.

🙋 학생 질문 — "이름표를 여러 개 부르고 싶으면요?"

그냥 여러 번 부르면 됩니다.

TypeScript
updateTag(`profile:${username}`);
updateTag(`profile:${me}`);

팔로우는 사실 두 사람의 숫자를 바꿉니다. 상대의 팔로워 수가 오르고, 내 팔로잉 수가 오르죠. 우리 화면에는 팔로잉 수가 없어서 하나만 불렀지만, 있었다면 둘 다 불러야 합니다.

이름표는 한 캐시 칸에 여러 개 붙일 수도 있어요. cacheTag('a', 'b', 'c')처럼요. 어느 이름으로 불려도 그 칸이 버려지는데, 하나의 캐시 칸에 최대 128개까지 붙일 수 있습니다.


Step 6: "화면이 아닌 응답"

화면 말고 답만 주는 주소

지금까지 app/ 아래 만든 파일은 전부 화면이었습니다. page.tsx는 화면이고 layout.tsx는 껍데기죠.

파일 이름을 route.ts로 하면 다릅니다. 화면을 안 그리고 응답만 만듭니다.

왜 이런 게 필요할까요. 우리 팔로우 액션은 화면이 부르는 함수였는데, 화면이 아닌 것이 우리에게 말을 걸어야 할 때가 있어요. 예를 들어 백엔드에서 누군가의 프로필이 바뀌었을 때, 그걸 우리에게 알려줄 방법이 필요합니다.

TypeScript
// apps/web-next/app/api/profiles/revalidate/route.ts
import { revalidateTag } from 'next/cache';

// 파일 이름이 page 가 아니라 route 다. 그래서 이 주소는 화면을 안 그린다.
// 백엔드에서 사람 정보가 바뀌었을 때 우리에게 알려주라고 열어두는 문이다.
export async function POST(request: Request) {
  const username = new URL(request.url).searchParams.get('username');

  if (username === null) {
    return Response.json(
      { success: false, data: null, message: 'username 이 필요합니다' },
      { status: 400 },
    );
  }

  // 여기서는 updateTag 를 못 쓴다. 이 자리는 Server Action 이 아니다.
  revalidateTag(`profile:${username}`, 'max');

  return Response.json({ success: true, data: { username }, message: null });
}

함수 이름이 POST입니다. HTTP 메서드 이름을 그대로 쓰는데, GET을 만들고 싶으면 export async function GET을 쓰면 돼요.

응답 봉투는 우리 백엔드와 같은 모양으로 맞췄습니다. C-6에서 정한 그 규약이요.

빌드 표에 새 줄이 생깁니다

텍스트
 Route (app)
 ┌ ◐ /
 ├ ◐ /_not-found
 ├   /[username]
 │ └ ◐ /[username]
 ├ ƒ /api/profiles/revalidate
 └ ◐ /explore

 ◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
 ƒ  (Dynamic)            server-rendered on demand

ƒ 기호가 돌아왔는데, D-5에서 캐시를 켜면서 이 기호가 표에서 통째로 사라졌었죠. 그때 ƒ는 "요청 때 그리는 화면"이라는 뜻이었는데, 지금은 화면이 아닌 것이라는 뜻으로 붙어 있습니다.

만들지도 않은 응답이 옵니다

우리는 POST만 만들었습니다. 그럼 GET으로 부르면 어떻게 될까요.

Bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/profiles/revalidate?username=minji
텍스트
405

405 Method Not Allowed. 우리가 안 만든 응답인데 Next가 알아서 냅니다. 파일에 어떤 메서드를 export 했는지가 곧 "이 주소가 받는 메서드"가 되기 때문이에요.

username 없이 부르면 우리가 쓴 400이 옵니다.

텍스트
{"success":false,"data":null,"message":"username 이 필요합니다"} [400]

여기서 두 함수의 진짜 차이가 드러납니다

Step 4에서 updateTag를 썼고 여기서는 revalidateTag를 썼습니다. 이름이 다르니 뭔가 다르겠죠. 재봅시다.

앱을 안 거치고 API 서버에서 민지의 팔로워 수만 바꾼 다음, 이 주소를 불러 캐시를 버려보겠습니다.

텍스트
 1. 캐시를 채운다                       화면 8500 · 원본 8500
 2. 앱 밖에서 원본만 바꾼다              화면 8500 · 원본 8501
 3. POST /api/profiles/revalidate 호출   {"success":true,...}
 4. 바로 다시 본다   요청1              화면 8500   <-- 아직 옛 숫자!
                    요청2              화면 8501
                    요청3              화면 8501
                    요청4              화면 8501

버렸는데 한 번은 옛것을 더 줍니다.

이게 revalidateTag에 두 번째 인자로 준 'max'의 뜻입니다. "낡았다고 표시는 해두되, 다음 사람에게는 일단 옛것을 주고 뒤에서 조용히 새로 만들어라"라는 뜻이라 아무도 안 기다리고, 대신 딱 한 명이 옛것을 봅니다.

updateTag는 반대입니다. "지금 당장 버려라. 다음 사람은 기다리더라도 새것을 봐야 한다"예요.

텍스트
 revalidateTag(태그, 'max')      updateTag(태그)
 아무도 안 기다린다                다음 사람이 기다린다
 한 명은 옛것을 본다               아무도 옛것을 안 본다
 남이 바꾼 것을 반영할 때           내가 바꾼 것을 내가 볼 때

팔로우 버튼이 updateTag여야 하는 이유가 이겁니다. 방금 누른 사람이 옛 숫자를 보면 "안 눌렸나?" 하고 또 누르니까요.

반대로 써보면

그럼 Route Handler에서 updateTag를 쓰면 어떻게 될까요. 바꿔서 빌드해봅시다.

텍스트
 next build      exit 0     통과
 tsc --noEmit    0          통과

둘 다 통과하는데, 불러보면요.

텍스트
 500

서버 로그에 이유가 나옵니다.

텍스트
⨯ Error: updateTag can only be called from within a Server Action.
  To invalidate cache tags in Route Handlers or other contexts,
  use revalidateTag instead.

"updateTag는 Server Action 안에서만 부를 수 있습니다. Route Handler 나 다른 곳에서 캐시 태그를 무효화하려면 revalidateTag를 쓰세요."

지난 시간에도 비슷한 걸 만났죠. 빌드가 통과하는데 런타임에서 터지는 경우요. 그때처럼 오류 메시지가 해법을 그대로 말해줍니다.

왜 이런 제한이 있을까요. updateTag는 "다음 사람을 기다리게 해서라도 새것을 보여준다"는 뜻인데, 그게 의미가 있으려면 바꾼 사람이 곧바로 화면을 다시 받는 상황이어야 합니다. 서버 액션은 그렇죠. 일을 처리하고 새 화면을 같이 보내니까요. Route Handler는 화면을 안 그립니다. 여기서 "다음 사람"은 누군지도 모르는 남이에요.

실험이 끝났으면 revalidateTag로 되돌려주세요.

💡 한 줄 정리

route.ts는 화면이 아니라 응답을 만듭니다. 그리고 캐시를 버리는 함수가 둘인 이유가 여기서 드러나요. 내가 바꾼 것을 내가 볼 때는 updateTag, 남이 알려준 것을 반영할 때는 revalidateTag입니다.

🙋 학생 질문 — "그럼 팔로우도 Route Handler로 만들면 안 되나요? 그게 더 익숙한데요."

만들 수 있는데, app/api/follow/route.ts를 만들고 버튼에서 fetch로 부르면 돼요. C-7에서 한 방식 그대로입니다.

차이가 셋 있습니다.

첫째, 코드 양이 늘어요. 주소를 정하고, 그 주소로 보내는 fetch를 쓰고, 응답 봉투를 벗기고, 오류를 처리해야 하는데, 서버 액션은 함수 하나면 끝이었죠.

둘째, 자바스크립트가 필요합니다. Step 2에서 봤듯이 서버 액션 폼은 스크립트 없이도 제출되지만, fetch로 부르는 방식은 스크립트가 죽으면 아무 일도 안 일어납니다.

셋째, 방금 본 것처럼 updateTag를 못 씁니다.

반대로 Route Handler가 나은 경우도 있어요. 우리 화면이 아닌 것이 부를 주소라면 그쪽입니다. 백엔드 서버, 결제사 알림, 다른 회사 서비스처럼요. 그런 상대에게 "우리 폼을 제출해주세요"라고 할 수는 없으니까요.


Step 7: "404를 되찾습니다"

세 회차째 미뤄둔 것

정리해봅시다.

텍스트
 D-4   없는 사람 주소에 404 를 붙였다        401ms 를 내고 샀다
 D-5   캐시를 켰더니 200 으로 되돌아갔다      스위치 하나에 되팔렸다
 D-6   껍데기를 다 넓혔는데도 200 이었다      화면 안에서는 못 되찾는다
 D-7   ?

지난 시간에 공식 문서가 답을 알려줬죠. "그 확인은 proxy에서 하라"고요.

이유는 간단합니다. 상태 코드는 응답의 맨 앞에 나가고 그 뒤에 화면 내용이 따라가는데, 지금 우리 앱은 껍데기를 먼저 내보내기로 되어 있어서 화면을 그리기 시작하는 순간 이미 상태 코드가 나가버립니다.

그러니 그리기 전에 확인해야 합니다.

그리기 전에 끼어드는 자리

proxy.tsapp 폴더 밖, 프로젝트 뿌리에 둡니다. app과 같은 높이예요.

TypeScript
// apps/web-next/proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const API_BASE = 'http://localhost:8090/api';

// 이 파일은 app 폴더 밖, 프로젝트 뿌리에 둔다.
// 요청이 우리 앱에 도착했지만 아직 아무것도 안 그려진 순간에 끼어든다.
// 여기서는 아직 한 글자도 안 나갔으니 상태 코드를 우리가 정할 수 있다.
export async function proxy(request: NextRequest) {
  const username = request.nextUrl.pathname.slice(1);

  const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);

  if (response.status === 404) {
    // 주소는 그대로 두고 404 화면을 그리게 한다. 상태 코드는 우리가 얹는다.
    return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 });
  }

  return NextResponse.next();
}

export const config = {
  // 프로필로 갈 수 있는 주소만 본다. 나머지는 아예 안 거친다.
  matcher: ['/((?!api|_next|favicon.ico|explore).+)'],
};

세 부분으로 나눠 봅시다.

proxy 함수는 요청 하나를 받습니다. 여기서 우리가 할 수 있는 일이 여러 가지인데, 오늘 쓰는 건 둘이에요. 그냥 통과시키거나(NextResponse.next()), 다른 화면을 그리게 하거나(NextResponse.rewrite)입니다.

rewrite의 두 번째 인자가 오늘의 열쇠입니다. { status: 404 }요. 주소창에는 /zzzzz가 그대로 보이면서, 그려지는 화면은 404 화면이고, 상태 코드는 404로 나갑니다.

config.matcher는 "어떤 주소에서 이 함수를 돌릴지"입니다. 안 적으면 이미지·CSS·스크립트까지 전부 거쳐 가지만, 우리는 API 주소, Next 내부 파일, 아이콘, 탐색 화면을 뺐습니다.

확인합니다

텍스트
 /            200
 /explore     200
 /jaehoon     200
 /minji       200
 /zzzzz       404      <-- 되찾았다
 /a/b/c       404

/zzzzz를 열면 화면은 지난 시간과 똑같습니다. "없는 주소예요"가 뜨고 피드로 돌아가는 링크가 있어요. 달라진 건 상태 코드 하나뿐입니다. 그런데 그 하나를 위해 세 회차를 왔죠.

옛 이름이 두 군데 남아 있습니다

빌드 표를 보면 이런 줄이 생깁니다.

텍스트
 ƒ Proxy (Middleware)

파일 이름은 proxy.ts인데 괄호 안은 Middleware입니다. 응답 헤더에도 남아 있어요.

텍스트
 x-middleware-rewrite: /_not-found

이 기능의 원래 이름이 미들웨어였기 때문입니다. Next 16에서 proxy로 이름이 바뀌었는데, 표시와 헤더에는 옛 이름이 아직 남아 있어요.

⚠️ 검색하면 나오는 자료는 대부분 middleware.ts로 되어 있을 겁니다. 파일 이름과 함수 이름만 바꾸면 그대로 동작하는데, 자동으로 바꿔주는 명령도 있습니다.

Bash
npx @next/codemod@canary middleware-to-proxy .

💡 한 줄 정리

proxy.ts는 화면이 그려지기 전에 끼어드는데, 아직 한 글자도 안 나갔으니 상태 코드를 정할 수 있어요. 세 회차에 걸쳐 잃었던 404를 여기서 되찾습니다.

🙋 학생 질문 — "왜 redirect가 아니라 rewrite예요?"

redirect는 브라우저에게 "다른 주소로 다시 가라"고 시키는 거라서, 주소창이 /not-found 같은 걸로 바뀌어요.

그건 우리가 원하는 게 아닙니다. 사용자가 친 주소는 /zzzzz였고, 그 주소가 없다는 걸 알려주고 싶은 거니까요. 주소창이 바뀌면 "내가 뭘 쳤더라"를 못 보게 됩니다.

rewrite는 브라우저를 안 움직입니다. 주소는 /zzzzz로 두고, 서버가 속으로 다른 화면을 그려서 보내요. 검색 엔진 입장에서도 이쪽이 맞습니다. /zzzzz라는 주소에 대해 404를 받아야 "이 주소는 없다" 고 기록하니까요.

그리고 상태 코드도 갈립니다. redirect는 307이나 308이 나가요. 404를 못 보냅니다.


Step 8: "그 404의 값"

공짜가 아니었습니다

첫 바이트가 나가는 데까지 걸리는 시간을 재봅시다. 다섯 번씩 재서 비교했어요.

텍스트
              proxy 없을 때        proxy 있을 때
 /            2 ~ 4 ms             2 ~ 3 ms
 /explore     2 ~ 5 ms             1 ~ 2 ms
 /jaehoon     2 ~ 6 ms             404 ~ 406 ms
 /zzzzz       2 ms (200)           404 ms (404)

/jaehoon을 보세요. 2ms 가 405ms 가 됐는데, 없는 사람이 아니라 있는 사람 프로필인데도요.

당연합니다. proxy가 "이 사람 있나요"를 API 서버에 물어보고, 그 답이 와야 첫 바이트가 나가니까요. 우리 연습용 서버는 일부러 400ms 씩 늦게 답하도록 되어 있죠. 그 400ms 가 통째로 앞에 붙은 겁니다.

//explore는 그대로예요. matcher에서 뺐거나 애초에 프로필 주소가 아니니까요.

이게 404 하나의 값입니다. 없는 주소 하나를 제대로 알려주려고 모든 프로필 방문자가 400ms 를 더 기다립니다.

그런데 더 있습니다

팔로우 버튼을 한 번 눌렀을 때 API 서버가 받는 요청을 순서대로 찍어봤습니다.

텍스트
 GET  /api/users/minji            <-- proxy 의 존재 확인
 POST /api/users/minji/follow     <-- 진짜 할 일
 GET  /api/posts?username=minji   <-- 화면 다시 그리기
 GET  /api/users/minji/tags       <-- 화면 다시 그리기
 GET  /api/users/minji            <-- updateTag 로 버린 것 다시 만들기

맨 위 줄이 이상합니다. 팔로우를 하는데 왜 "이 사람 있나요"를 물어보죠?

공식 문서에 답이 있는데, 서버 액션은 별도의 주소가 아니라 그 화면 주소로 오는 POST 요청으로 처리됩니다. Step 2에서 소스 보기로 확인했죠. 폼의 action=""이 "지금 이 주소" 였잖아요.

팔로우 제출은 결국 POST /minji인데, /minji는 우리 matcher에 걸리고요. proxy는 그게 화면 요청인지 폼 제출인지 구분을 안 합니다.

무는 범위를 좁힙니다

matcher로는 메서드를 못 가릅니다. 주소 패턴만 적을 수 있거든요. 그럼 함수 안에서 가르면 됩니다.

TypeScript
// apps/web-next/proxy.ts
export async function proxy(request: NextRequest) {
  // 화면을 달라는 요청에만 확인이 필요하다.
  // 폼을 제출하면 같은 주소로 POST 가 오는데, 거기까지 물어볼 이유는 없다.
  if (request.method !== 'GET') {
    return NextResponse.next();
  }

  const username = request.nextUrl.pathname.slice(1);
  // ... 나머지는 그대로

다시 재봅시다.

텍스트
                        고치기 전    고친 뒤
 팔로우 한 번에 나가는
 "이 사람 있나요" 요청      3 건       2 건

 /zzzzz                    404        404      그대로
 /a/b/c                    404        404      그대로
 /jaehoon 첫 바이트        406ms      406ms    그대로

낭비 하나가 사라졌고 404는 그대로입니다. /jaehoon이 여전히 406ms 인 건 당연해요. 화면을 달라는 요청은 여전히 확인해야 하니까요. 그게 404의 값이고, 우리는 그걸 내기로 한 겁니다.

남은 두 건은 낭비가 아닙니다. 하나는 화면이 프로필을 그리려고 부르는 것이고, 하나는 updateTag로 버린 캐시를 다시 만드는 거예요.

그래서 이걸 써야 하나

정직하게 짚고 갑시다. 400ms 는 작은 값이 아닙니다.

텍스트
 얻는 것    없는 주소가 정확한 상태 코드로 답한다
            검색 엔진·통계 도구·감시 시스템이 제대로 읽는다

 내는 것    프로필 화면 전부의 첫 바이트가 늦어진다
            우리 경우 약 400ms (API 서버가 답하는 시간)

우리 연습용 서버가 유독 느린 겁니다. 진짜 서비스에서 "이 사람 있나" 정도는 훨씬 빠르게 답하도록 만들 수 있어요. 캐시를 앞에 두거나, 아이디 목록만 따로 가볍게 들고 있거나요.

그래도 0은 안 됩니다. proxy 안에서는 'use cache'를 못 쓰는데, 화면을 그리는 코드가 아니라 그 앞에서 도는 코드라서요.

결정은 이렇게 하는 겁니다. 상태 코드가 우리 서비스에서 얼마나 중요한가. 규정이나 통계 때문에 정확한 404가 꼭 필요하면 값을 치를 만합니다. 그게 아니라면 화면만 맞는 지금 상태로 두는 것도 선택이에요.

💡 한 줄 정리

404를 되찾는 대신 프로필 주소 전부가 400ms 를 더 기다립니다. matcher로 볼 주소를 고르고 메서드로 한 번 더 걸러 낭비는 줄일 수 있지만, 화면 요청의 확인 비용은 안 없어집니다. 그게 값입니다.

🙋 학생 질문 — "proxy에서 부르는 API를 캐시하면 안 되나요?"

'use cache'는 못 쓰는데, 그건 화면을 그리는 동안에만 쓸 수 있는 도구예요. proxy는 화면을 그리기 전에 돌고, 아예 다른 곳에 배포될 수도 있습니다.

방법이 아주 없는 건 아닙니다. proxy 안에서 부르는 fetch에 캐시 옵션을 직접 주거나, 우리가 만든 Route Handler를 부르게 하거나, 아이디 목록을 메모리에 들고 있는 방법이 있어요.

다만 공식 문서가 경고를 하나 답니다. proxy는 앱 본체와 떨어진 곳에서 돌 수 있다고요. 어떤 요청은 기억이 있는 곳으로, 어떤 요청은 없는 곳으로 갈 수 있어서, "이 값은 아까 봤으니까 기억해두자" 같은 걸 믿으면 안 됩니다.

그래서 실무에서는 보통 확인 자체를 빠르게 만드는 쪽으로 갑니다. 존재 확인 전용 주소를 따로 두고 아주 가볍게 답하게 하는 식으로요.


마무리

오늘 배운 핵심 세 가지

💡 하나 — 함수 하나가 주소가 됩니다. 'use server'를 붙이면 그 함수는 브라우저로 안 가고 부를 수 있는 통로만 열리는데, 폼에 건네면 평범한 HTML 폼으로 그려지고 자바스크립트 없이도 제출되며, 서버는 일을 처리한 결과와 새 화면을 한 응답에 담아 보냅니다.

💡 둘 — 바꿨으면 버려야 보입니다. 캐시는 "누가 봐도 같은 답"을 전제로 하는데, 방금 무언가를 바꾼 사람에게는 그 전제가 깨집니다. 그래서 바꾸는 코드 옆에 버리는 코드가 있어야 해요. 내가 바꾼 것을 내가 볼 때는 updateTag, 남이 알려준 것을 반영할 때는 revalidateTag입니다.

💡 셋 — 화면 밖에도 자리가 있습니다. 세 회차 동안 404를 못 되찾은 이유는 답이 화면 안에 없었기 때문입니다. 요청을 받는 자리를 셋 배웠어요. 화면이 부르는 서버 액션, 아무나 부를 수 있는 Route Handler, 그리기 전에 끼어드는 proxy요. 각각 할 수 있는 일과 할 수 없는 일이 다릅니다.

다음 시간 예고

오늘 만든 팔로우 버튼을 다시 눌러보세요. 누르고 나서 화면이 바뀌기까지 시간이 좀 걸리는데, 그동안 버튼에는 "보내는 중…"이 떠 있고요.

팔로우라서 견딜 만한데, 좋아요는요? 하트를 누르고 반 초를 기다려야 빨개진다면 아무도 안 씁니다.

그래서 다음 시간에는 기다리지 않고 먼저 바꾸는 방법을 배웁니다. 일단 화면부터 바꿔놓고, 서버가 거절하면 되돌리는 거예요. C-7에서 브라우저 안에서 한 번 해봤던 그 고민을, 이번엔 서버까지 포함해서 다시 합니다.

오늘 세워둔 문지기에게도 두 번째 일을 주는데, 지금 proxy는 "이 사람 있나"만 보고 있으니 "이 사람이 들어와도 되나"도 보게 할 거예요.

마지막으로 오늘 소스 보기에서 본 것 하나를 다시 꺼냅니다. 팔로우 폼 안에 ["minji",{"following":null,"message":null}]이 그대로 적혀 있었죠. 브라우저가 들고 있다가 그대로 돌려보내는 값이라는 뜻입니다. 그걸 누가 고쳐 보낸다면 어떻게 될까요.


과제

[구현] 팔로우가 실패하는 날

지금까지는 팔로우가 늘 성공했습니다. 서버가 거절하면 어떻게 되는지 확인해봅시다.

연습용 API 서버에 실패를 만들어내는 스위치가 있어요.

Bash
FOLLOW_FAIL_EVERY=2 API_STUB_LOG=1 node apps/api-stub/server.mjs

두 번에 한 번씩 서버가 500으로 거절합니다.

  • 팔로우 버튼을 네 번 눌러보세요. 화면에 무엇이 뜨는지, 팔로워 숫자가 어떻게 되는지 기록하세요.
  • 실패했을 때 팔로워 숫자가 안 흔들리는 이유를 코드에서 찾아 설명하세요. 어느 줄 때문인가요?
  • 이제 follow.ts의 실패 갈래를 return { following: null, message: ... }로 바꿔보세요. ...previous를 안 쓰는 겁니다. 무엇이 달라지나요? 왜 원래대로가 나은가요?
  • 힌트: 이미 "팔로잉" 이던 사람이 팔로우 취소에 실패하면 버튼이 뭐라고 되어야 할까요.

[구현] 문을 넓힙니다

Step 6의 Route Handler는 한 번에 한 사람만 받습니다. 백엔드에서 여러 사람이 한꺼번에 바뀌었다면 여러 번 불러야 하죠.

  • ?username=jaehoon&username=minji처럼 여러 개를 받게 고치세요.
  • 힌트: searchParams.get() 말고 다른 메서드가 있습니다. 이름 그대로예요.
  • 하나도 안 왔을 때는 지금처럼 400을 주되, 몇 개를 버렸는지 응답에 담아주세요.
  • 고친 뒤 두 사람의 캐시가 실제로 버려지는지 Step 5의 실험 방법으로 확인하세요.

[탐구] 문지기가 보는 범위

proxy.tsmatcher에서 explore를 빼보세요.

TypeScript
matcher: ['/((?!api|_next|favicon.ico).+)'],
  • 무슨 일이 벌어질지 먼저 예상하고 확인하세요.
  • 확인했으면 왜 그렇게 되는지 설명하세요. proxy가 무엇을 물어봤고 무슨 답을 받았을까요?
  • /는 왜 멀쩡한가요? matcher 패턴의 어느 부분 때문인지 짚어보세요.
  • 되돌린 뒤, 나중에 /settings 같은 화면을 새로 만든다면 무엇을 잊지 말아야 할지 한 줄로 적어두세요.

[탐구] 폼 안에 실려 있는 것

팔로우 버튼이 있는 화면에서 소스 보기를 열고, 팔로우 폼의 숨은 입력칸을 전부 찾아보세요.

  • 값이 네 개 나올 겁니다. 각각 무엇으로 보이는지 적어보세요.
  • 그중 하나에 ["minji",{"following":null,"message":null}] 같은 것이 들어 있습니다. 이게 무엇일까요? 우리 코드의 어느 부분과 대응되나요?
  • 이 값은 브라우저에 있다가 제출될 때 서버로 돌아갑니다. 만약 누군가 "minji""jaehoon"으로 고쳐서 보낸다면 무슨 일이 벌어질까요?
  • 우리 follow.ts가 그걸 막고 있나요, 아니면 막을 수 있나요? 막고 있다면 어느 줄인지, 아니라면 무엇이 더 필요한지 생각해보세요.

생각해볼 주제

1. 주소가 없는 API

오늘 우리는 팔로우 기능을 만들면서 API 주소를 하나도 안 만들었습니다. 함수를 쓰고 폼에 건넨 게 전부였어요.

편했지만 잃은 것도 있습니다. 주소가 없으니 다른 곳에서 부를 수가 없어요. 모바일 앱이 생기면요? 파트너 회사가 우리 기능을 쓰고 싶다면요?

여러분이 실무에서 새 기능을 만들 때 어느 쪽으로 시작하시겠습니까. "일단 서버 액션으로 빠르게 만들고 필요해지면 주소를 파자"와 "처음부터 주소를 만들어두자" 중에서요. 그 판단을 가르는 기준이 무엇일지, 그리고 나중에 옮기는 비용이 얼마나 될지 함께 생각해보세요.

2. 두 함수의 이름

updateTagrevalidateTag를 배웠습니다. 둘 다 캐시를 버리는 함수인데 동작이 다르고, 부를 수 있는 자리도 다르죠.

오늘 실험에서는 하나 이상한 걸 봤는데, 서버 액션 안에서는 둘을 바꿔 써도 화면 결과가 같아 보였어요. 차이가 드러난 건 Route Handler 에서였습니다.

이런 API를 만나면 어떻게 판단하시겠습니까. "돌려보니 같으니까 아무거나 쓰자"와 "문서가 다르다고 하니 이유가 있겠지" 사이에서요. 그리고 오늘처럼 차이가 특정 상황에서만 드러나는 경우, 그걸 팀 전체가 알게 하려면 무엇을 남겨야 할지도 생각해보세요.

3. 값을 알고 내는 것

404를 되찾는 데 400ms 를 냈습니다. 되찾기 전에는 화면이 정상이고 상태 코드만 틀렸어요. 눈으로는 아무 문제가 없었죠.

이런 결정을 실무에서 만나면 누구와 이야기해야 할까요. "눈에 안 보이는 정확성"에 성능을 내주는 판단은 또 어떤 근거로 하는 게 좋을까요.

반대 방향도 생각해보세요. 만약 팀이 "400ms 는 못 낸다" 고 결정한다면, 그 결정을 어디에 어떻게 남겨둬야 다음 사람이 "이거 버그 아냐?" 하고 다시 파헤치지 않을까요.

✅ 예시 답안정답 보기

과제 넷과 생각해볼 주제 셋의 예시답안입니다. 배점은 과제당 25점, 합계 100점입니다.

먼저 스스로 풀어보고 나서 펼쳐보세요. 특히 [탐구] 과제는 예상을 먼저 적어두고 확인하는 것이 훨씬 남습니다.


🎯 [과제 1 예시답안] 팔로우가 실패하는 날 (25점)

채점 포인트

항목 배점 확인
네 번 눌러 성공·실패가 번갈아 나오는 것을 기록했다 6 실패한 차례에 화면 문구를 적었는가
실패해도 팔로워 숫자가 안 흔들리는 이유를 코드에서 짚었다 8 updateTag가 성공 갈래 안에만 있다는 것
...previous를 뺐을 때 무엇이 달라지는지 확인했다 7 버튼 글자가 잘못 되돌아가는 것
원래대로가 나은 이유를 사용자 관점으로 설명했다 4 화면이 거짓말을 하게 된다

풀이 예시

먼저 실패를 만들어내는 서버로 띄웁니다.

Bash
FOLLOW_FAIL_EVERY=2 API_STUB_LOG=1 node apps/api-stub/server.mjs

네 번 눌러본 결과입니다.

텍스트
 클릭   서버 응답    화면 문구                  팔로워 수
  1     200        (없음)                     8501 로 오름
  2     500        팔로우를 저장하지 못했습니다   8501 그대로
  3     200        (없음)                     8500 으로 내려감
  4     500        팔로우를 저장하지 못했습니다   8500 그대로

왜 숫자가 안 흔들리는가

follow.ts의 순서를 보면 답이 나옵니다.

TypeScript
  if (!response.ok || !envelope.success) {
    // 실패하면 알던 상태는 그대로 두고 사유만 얹는다.
    return { ...previous, message: envelope.message ?? '팔로우하지 못했어요' };
  }

  updateTag(`profile:${username}`);

  return { following: envelope.data.following, message: null };

실패하면 return으로 먼저 빠져나갑니다. updateTag는 그 아래에 있어서 실행되지 않아요. 캐시를 안 버리니 화면은 원래 값을 계속 보여줍니다.

이게 맞는 동작입니다. 서버가 거절했다는 건 데이터가 안 바뀌었다는 뜻이니까요. 안 바뀐 것을 버리면 똑같은 값을 다시 만드느라 요청만 한 번 더 나갑니다.

눈으로도 확인할 수 있습니다. API 서버 로그에서 프로필 조회 요청 수를 세보면 이렇게 갈려요.

텍스트
 성공한 클릭    프로필 조회 요청이 늘어난다   (버린 캐시를 다시 만드는 중)
 실패한 클릭    안 늘어난다                 (버릴 일이 없었으니까)

...previous를 빼면

이렇게 바꿔봅니다.

TypeScript
  if (!response.ok || !envelope.success) {
    return { following: null, message: envelope.message ?? '팔로우하지 못했어요' };
  }

이미 팔로우 중인 상태(버튼이 "팔로잉")에서 취소를 눌렀는데 서버가 거절하면요.

텍스트
 ...previous 있을 때    버튼 "팔로잉" 유지 + 오류 문구      맞다. 아직 팔로우 중이니까
 ...previous 없을 때    버튼 "팔로우" 로 바뀜 + 오류 문구    틀렸다. 취소가 안 됐는데 취소된 것처럼 보인다

following: null은 "모른다"는 뜻이고, 화면은 모를 때 "팔로우"를 보여주도록 되어 있어서, 실패했는데 성공한 것처럼 보이는 상태가 됩니다.

실패는 "아무 일도 안 일어난 것"이어야 합니다. 알고 있던 것을 유지하고 사유만 덧붙이는 게 그 뜻이에요.

자주 나오는 실수

실패 응답을 그냥 던져버리기. throw new Error(...)로 처리하면 오류 경계가 받아서 화면 조각이 통째로 오류 화면으로 바뀝니다. 팔로우 하나 실패했다고 프로필이 사라지면 과하지만, 사용자가 다시 시도할 수 있는 실패는 값으로 돌려주고, 다시 시도해도 소용없는 실패만 던집니다.

response.ok만 보기. 우리 API는 200 인데 success: false인 경우가 있습니다. 그래서 코드가 !response.ok || !envelope.success로 둘 다 봅니다. C-6에서 만든 규약 그대로예요.

💡 튜터의 한마디

실패 처리에서 제일 흔한 사고가 "실패했으니 초기 상태로"입니다. 초기 상태는 아무것도 모르는 상태고, 실패는 아무것도 안 일어난 상태예요. 둘은 다르니까, 실패했을 때 화면이 어떤 모습이어야 하는지는 코드가 아니라 사용자가 방금 무엇을 봤는지로 정하세요.


🎯 [과제 2 예시답안] 문을 넓힙니다 (25점)

채점 포인트

항목 배점 확인
getAll로 여러 값을 받았다 8 get은 첫 번째 하나만 준다는 것을 알았는가
하나도 없을 때 400 을 유지했다 5 length === 0 검사
응답에 처리한 개수를 담았다 5 봉투 모양을 유지했는가
두 사람 캐시가 실제로 버려지는지 확인했다 7 실측으로 확인

풀이 예시

TypeScript
// apps/web-next/app/api/profiles/revalidate/route.ts
import { revalidateTag } from 'next/cache';

export async function POST(request: Request) {
  // get 은 첫 번째 하나만 준다. 같은 이름이 여러 번 오면 getAll 이다.
  const usernames = new URL(request.url).searchParams.getAll('username');

  if (usernames.length === 0) {
    return Response.json(
      { success: false, data: null, message: 'username 이 필요합니다' },
      { status: 400 },
    );
  }

  for (const username of usernames) {
    revalidateTag(`profile:${username}`, 'max');
  }

  return Response.json({
    success: true,
    data: { usernames, count: usernames.length },
    message: null,
  });
}

확인

Bash
curl -s -X POST "http://localhost:3000/api/profiles/revalidate?username=jaehoon&username=minji"
텍스트
{"success":true,"data":{"usernames":["jaehoon","minji"],"count":2},"message":null}

하나도 없이 부르면 그대로 400입니다.

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

캐시가 실제로 버려지는지는 Step 5 방법으로 확인합니다. 두 프로필을 한 번씩 열어 캐시를 채우고, 앱을 안 거치고 두 사람의 팔로워 수를 바꾼 다음 이 주소를 부른 뒤 두 번씩 새로고침해보면 둘 다 새 숫자가 나옵니다.

두 번인 이유가 중요합니다. revalidateTag'max'를 줬으니 첫 요청은 옛것을 보고, 그 뒤부터 새것이 나오는데, Step 6에서 잰 그대로입니다.

자주 나오는 실수

get을 그대로 두고 반복문만 씌우기. searchParams.get('username')은 같은 이름이 여러 번 와도 첫 번째 하나만 돌려줍니다. 그래서 jaehoon만 버려지고 minji는 낡은 채로 남는데, 응답은 성공으로 나가서 아무도 안 알려줍니다.

하나도 안 왔을 때 200을 주기. getAll은 없으면 빈 배열을 주는데, null이 아니라서 예전 if (username === null) 검사가 그냥 통과해버려요. 반복문이 0번 돌고 성공 응답이 나가는데, 검사를 length === 0으로 바꿔야 하는 이유입니다.

💡 튜터의 한마디

이 과제의 진짜 함정은 getAll을 모르는 게 아니라, 틀려도 아무 일도 안 일어난다는 겁니다. 응답은 200이고 오류도 없고 로그도 깨끗하지만, 한 명만 갱신됐다는 사실은 며칠 뒤 "왜 이 사람 프로필만 옛날 거예요?"라는 문의로 돌아옵니다.

여러 개를 받는 주소를 만들 때는 몇 개를 처리했는지 응답에 담는 습관을 들이세요. 부르는 쪽이 두 개를 보냈는데 count: 1이 오면 그 자리에서 잡힙니다.


🎯 [과제 3 예시답안] 문지기가 보는 범위 (25점)

채점 포인트

항목 배점 확인
확인 전에 예상을 적었다 5 예상이 틀려도 감점 없음
/explore가 404 가 되는 것을 확인했다 7 실제로 돌려봤는가
proxy가 무엇을 묻고 무슨 답을 받았는지 설명했다 8 API 서버가 404 를 준 것
/가 멀쩡한 이유를 패턴에서 짚었다 5 .+가 한 글자 이상을 요구한다

풀이 예시

explore를 빼고 돌리면 이렇게 됩니다.

텍스트
 /            200
 /jaehoon     200
 /zzzzz       404
 /explore     404      <-- 탐색 화면이 사라졌다

/explore를 열면 "없는 주소예요"가 뜹니다. 머리말의 "탐색" 링크는 그대로 있는데 눌러도 404가 나와요.

왜 이렇게 되는가

matcher는 "이 함수를 돌릴 주소"를 정합니다. explore를 빼면 /explore도 그 대상에 들어가요.

그러면 proxy가 이렇게 묻습니다.

텍스트
 요청 주소:  /explore
 pathname.slice(1) 로 잘라낸 값:  "explore"

 proxy 가 API 서버에 묻는 것:
   GET /api/users/explore
 API 서버의 답:
   404  그런 사람이 없습니다

 proxy 의 판단:
   404 니까 없는 주소구나  404 화면을 그리게 한다

proxy는 주소의 첫 칸을 무조건 사람 이름으로 보는데, /explore가 우리 앱의 정식 화면이라는 사실을 모르는 거예요. API 서버에 물어보니 그런 사람이 없다고 하니까 없는 주소로 처리한 겁니다.

/는 왜 멀쩡한가

패턴을 뜯어보면 나옵니다.

텍스트
 /((?!api|_next|favicon.ico).+)
  │ └──────────────────────┘└┴── 한 글자 이상
  │            └── 이 말들로 시작하면 제외
  └── 맨 앞의 빗금

마지막 .+가 한 글자 이상을 요구하는데, /는 빗금 뒤에 아무것도 없어서 이 패턴에 안 걸리다 보니 홈은 proxy를 아예 안 거칩니다.

만약 .+ 대신 .*였다면 /도 걸렸을 겁니다. 그러면 proxy가 빈 문자열로 GET /api/users/를 물어봤겠죠.

다음 화면을 만들 때

한 줄로 적어두면 이렇습니다.

새 화면을 app/ 아래에 만들 때마다 proxy.tsmatcher에 그 이름을 빼줘야 한다.

/settings를 만들면 matchersettings를 추가하는 겁니다. 안 그러면 만들자마자 404가 나오는데, 화면 코드에는 아무 문제가 없어서 원인을 찾기 어렵습니다.

자주 나오는 실수

explore를 뺐다가 되돌릴 때 패턴을 잘못 쓰기. 목록은 세로 막대가 아니라 그냥 이어 붙입니다. (?!api|_next|favicon.ico|explore)처럼요. (?!api)(?!explore)로 써도 동작하지만 길어집니다.

💡 튜터의 한마디

이 방식은 화면이 늘어날수록 관리가 어려워져서, 실무에서는 반대로 가는 경우가 많아요. "이것만 본다"로 쓰는 겁니다.

프로필 주소를 /u/jaehoon처럼 접두사가 있는 모양으로 바꿔두면 matcher: '/u/:path*' 한 줄로 끝나요. 우리는 인스타그램을 흉내 내느라 /jaehoon을 썼는데, 이게 편하지만 값이 따라옵니다. 주소 설계가 나중에 이런 데서 청구서를 보내는 거죠.


🎯 [과제 4 예시답안] 폼 안에 실려 있는 것 (25점)

채점 포인트

항목 배점 확인
숨은 입력칸 네 개를 찾아 각각 무엇인지 적었다 8 함수 식별자·묶은 인자를 구분했는가
배열 값이 bind로 묶은 인자임을 알아봤다 7 코드의 어느 부분과 대응되는지
값을 고쳐 보내면 무슨 일이 생길지 추론했다 6 다른 사람을 팔로우하게 된다
지금 코드가 막는 것과 못 막는 것을 갈랐다 4 신원은 막고 대상은 안 막는다

풀이 예시

소스 보기에서 팔로우 폼을 찾으면 숨은 입력칸이 넷 나옵니다.

텍스트
 $ACTION_REF_2   (빈 값)
 $ACTION_2:0     {"id":"6003d10320fc8a030178f8c3383d129e215ce659eb","bound":"$@1"}
 $ACTION_2:1     ["minji",{"following":null,"message":null}]
 $ACTION_KEY     k0367a6623c8e395d82b365f3e91b6869

긴 문자열 둘은 여러분 화면에서 다른 값으로 나오는데, 빌드할 때마다 새로 만들어지거든요. 중요한 건 값이 아니라 모양입니다.

각각 이런 뜻입니다.

텍스트
 $ACTION_REF_2   이 폼이 어떤 액션을 쓰는지 가리키는 번호
 $ACTION_2:0     실행할 함수의 식별자 + 묶인 인자가 있다는 표시
 $ACTION_2:1     묶인 인자의 실제 값
 $ACTION_KEY     useActionState 가 상태를 이어 붙이는 데 쓰는 열쇠

배열의 정체

["minji",{"following":null,"message":null}]이 우리 코드와 정확히 대응됩니다.

TypeScript
useActionState(follow.bind(null, username), initial)
//                          └── "minji"    └── {following:null, message:null}

앞의 "minji"bind로 묶은 첫 인자, 뒤의 객체는 initial입니다. 액션 시그니처가 follow(username, previous) 였으니 순서도 그대로예요.

그러니까 bind로 묶은 값은 서버에 남아 있는 게 아니라 브라우저로 내려갔다가 제출될 때 돌아옵니다.

고쳐 보내면

개발자 도구에서 "minji""dahye"로 고치고 제출하면, 서버는 다혜를 팔로우합니다. 화면에는 민지 프로필이 떠 있는데도요.

이게 이상해 보이지만 사실 당연합니다. 서버 액션은 주소가 없을 뿐이지 공개된 통로예요. 폼을 안 거치고 직접 요청을 만들어 보낼 수도 있는데, Step 2에서 우리가 curl로 그렇게 했잖아요.

우리 코드가 막는 것과 못 막는 것

텍스트
 막는다     누가 눌렀는가
            액션이 쿠키를 서버에서 직접 읽는다. 브라우저가 보낸 값이 아니다.
            그래서 "재훈인 척" 은 못 한다.

 못 막는다   누구를 팔로우하는가
            "minji" 가 브라우저에서 오니까 바꿔 보낼 수 있다.

지금 우리 앱에서는 이게 큰 문제가 아닙니다. 팔로우는 아무나 아무나에게 할 수 있는 일인 데다, API 서버가 없는 사람은 404로 자기 자신은 400으로 막고 있으니까요.

문제가 되는 건 권한이 걸린 값일 때예요. 예를 들어 게시물 삭제 액션에 bind로 게시물 번호를 묶었다면, 그 번호를 남의 것으로 바꿔 보낼 수 있습니다. 그때 서버가 "이 사람이 이 게시물 주인인가"를 확인 안 하면 남의 글이 지워집니다.

자주 나오는 실수

"우리 폼에서만 보내니까 안전하다" 고 생각하기. 폼은 화면에 있는 것이고, 요청은 누구나 만들 수 있는데, 브라우저가 보낸 값은 전부 사용자가 정한 값이라고 봐야 합니다.

숨은 입력칸이니까 안 보인다고 생각하기. type="hidden"은 화면에 안 그린다는 뜻이지 숨긴다는 뜻이 아니니까, 소스 보기 한 번이면 다 보여요.

💡 튜터의 한마디

여기서 얻어갈 규칙은 하나입니다. 신원은 서버가 읽고, 나머지는 전부 서버가 검사한다.

우리 액션의 첫 줄이 그 규칙의 앞쪽입니다. 쿠키를 서버에서 읽는데, 뒤쪽은 아직 안 했습니다. username을 그대로 믿고 API 서버에 넘기고 있어요.

다음 시간에 이 이야기를 제대로 합니다. 그때 "서버 액션은 API 주소가 없어서 안전하다"는 오해를 완전히 걷어낼 거예요.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] 주소가 없는 API

문제 상황 요약

오늘 팔로우 기능을 만들면서 API 주소를 하나도 안 만들었습니다. 함수를 쓰고 폼에 건넨 게 전부였죠. 편했지만 그 기능은 우리 화면에서만 부를 수 있습니다. 모바일 앱이나 파트너 회사가 쓰려면 결국 주소가 필요합니다.

튜터의 가이드 및 해설

먼저 오해 하나를 걷어내는데, 서버 액션에도 사실 주소는 있습니다. 우리가 이름을 안 지었을 뿐이에요. 소스 보기에서 본 $ACTION_ID_...가 그 이름입니다.

그런데 그 이름은 빌드할 때마다 바뀝니다. 코드가 조금만 달라져도 다른 값이 나와서, 밖에서 쓸 수 있는 주소가 아닙니다. 우리 앱이 자기 코드를 부르는 내부 통로예요.

판단 기준은 "부르는 쪽이 우리가 아닌 적이 있는가"입니다.

텍스트
 우리 화면만 부른다            서버 액션
   폼 제출, 버튼 클릭            코드가 짧고 스크립트 없이도 동작한다

 우리가 아닌 것이 부른다        Route Handler
   모바일 앱, 파트너사            주소가 고정되고 문서로 남길 수 있다
   결제사 알림, 백엔드 훅         메서드·상태 코드를 우리가 정한다

"나중에 모바일 앱이 생길지도 모르니까 처음부터 주소를 만들자"는 흔한 실수입니다. 안 생길 수도 있고, 생겨도 그때 필요한 모양은 지금 상상한 것과 다를 가능성이 높아요.

옮기는 비용을 실제로 따져보면 생각보다 작습니다. 우리 follow.ts를 Route Handler로 옮긴다면 몸통은 거의 그대로 복사되고, 인자를 받는 방식과 응답 만드는 부분만 바뀌어요. 반나절이면 됩니다.

대신 반대 방향은 어렵습니다. 이미 주소를 공개했고 누군가 그걸 쓰고 있다면, 없앨 수가 없어요. 되돌리기 쉬운 쪽으로 먼저 가는 것이 이 판단의 핵심입니다.

한 가지 더. 둘을 같이 쓸 수도 있는데, 진짜 일을 하는 함수를 따로 빼두고, 서버 액션과 Route Handler가 둘 다 그 함수를 부르게 하는 거예요. 그러면 화면은 액션으로 편하게 부르고, 밖에서는 주소로 부릅니다. 로직은 한 곳에만 있고요.

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

"서버 액션에도 식별자는 있지만 빌드마다 바뀌어서 외부 계약으로 못 씁니다. 그래서 저는 '부르는 쪽이 우리 화면뿐인가'로 갈라요. 우리뿐이면 액션으로 시작합니다. 나중에 밖에서 부를 일이 생기면 로직을 함수로 빼고 Route Handler를 얹으면 되는데, 그 반대는 공개한 주소를 회수해야 해서 훨씬 비쌉니다. 되돌리기 쉬운 쪽을 기본값으로 두는 판단이에요."


🤔 [생각해볼 주제 2] 두 함수의 이름

문제 상황 요약

updateTagrevalidateTag는 둘 다 캐시를 버리는데, 서버 액션 안에서는 바꿔 써도 결과가 같아 보였고 차이는 Route Handler 에서야 드러났습니다. 문서는 다르다고 하는데 눈으로는 같아 보이는 상황이었죠.

튜터의 가이드 및 해설

먼저 왜 액션 안에서 같아 보였는지부터 정리합시다.

서버 액션은 일을 처리한 뒤 화면을 다시 그려서 같이 보내는데, 그 다시 그리는 과정에서 캐시가 어차피 새로 채워져요. 그러니 "다음 사람이 기다리느냐 마느냐"를 물어볼 틈이 없습니다. 다음 사람이 오기 전에 이미 채워져 있으니까요.

Route Handler는 화면을 안 그리는데, 버리기만 하고 끝나요. 그래서 정말로 "다음 사람"이 생기고 그 사람이 옛것을 받느냐 기다리느냐가 갈리는데, 이건 차이가 없어진 게 아니라 차이가 드러날 조건이 안 됐던 것입니다. 이 구분이 중요해요.

"돌려보니 같으니까 아무거나 쓰자"가 위험한 이유가 여기 있습니다. 지금 우리 액션은 화면을 다시 그리니까 같아 보입니다. 그런데 나중에 액션 끝에 다른 곳으로 보내는 코드가 붙거나, 화면을 안 그리는 형태로 바뀌면 그때부터 갈려요. 그날 누군가 "왜 한 번은 옛날 숫자가 나오지?" 하고 며칠을 쓰는데, 이건 취향 문제가 아닙니다. 실제로 updateTag는 Route Handler에서 부르면 예외를 던져요. 프레임워크가 두 함수를 다른 용도로 못 박아둔 겁니다.

팀에 남기는 방법으로는 셋을 권합니다.

첫째, 코드에 짧은 주석. "여기서 왜 이걸 골랐는지" 한 줄이면 충분합니다.

둘째, 헷갈리는 자리를 아예 없애기. 프로젝트에 헬퍼 함수를 하나 두고 이름을 용도로 짓는 겁니다. invalidateAfterMyChange 같은 식으로요. 그러면 고를 일이 없어집니다.

셋째, 이 차이를 드러내는 확인을 하나 남기기. Step 6에서 우리가 잰 것처럼요. 나중에 누가 함수를 바꾸면 그 확인이 먼저 알려줍니다.

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

"두 함수가 같아 보였던 건 차이가 없어서가 아니라 차이가 드러날 조건이 아니었기 때문입니다. 서버 액션은 화면을 다시 그리면서 캐시를 채워버리니까요. 이런 걸 '돌려보니 같더라'로 넘기면, 나중에 조건이 바뀐 날 원인을 못 찾습니다. 저는 문서가 다르다고 하면 차이가 드러나는 조건을 만들어서 확인해요. 그게 안 되면 왜 안 되는지가 오히려 답인 경우가 많습니다."


🤔 [생각해볼 주제 3] 값을 알고 내는 것

문제 상황 요약

404를 되찾는 데 프로필 화면 전부의 첫 바이트 400ms 를 냈는데, 되찾기 전에도 화면은 멀쩡했어요. 사용자 눈에는 아무 문제가 없었고, 틀린 건 사람이 안 보는 상태 코드 하나였습니다.

튜터의 가이드 및 해설

이런 결정을 혼자 내리면 안 됩니다. 상태 코드가 필요한 이유가 개발 밖에 있기 때문이에요.

물어볼 사람이 셋 있습니다.

먼저 마케팅이나 검색 담당입니다. 검색 엔진 관리 도구에서 "실질적인 404"로 분류된 주소가 몇 개인지 보고 있을 수 있는데, 그 수가 많으면 사이트 품질 지표로 읽힙니다.

다음은 데이터나 운영 쪽입니다. 접속 통계에서 404 비율을 보고 있다면, 200으로 나가는 없는 주소들이 그 숫자를 조용히 망가뜨립니다. 감시 시스템이 404 급증으로 장애를 잡아내는 곳이라면 더 심각하죠.

마지막으로 규정 담당입니다. 업종에 따라 응답 코드가 감사 대상인 곳이 있습니다.

근거를 만드는 방법은 이렇습니다.

400ms 를 "느려짐"이라고만 말하면 아무도 판단을 못 해서, 숫자를 실제 영향으로 옮겨야 합니다. 우리 경우라면 프로필 화면이 하루에 몇 번 열리는지, 첫 바이트가 늦어질 때 이탈이 얼마나 느는지를 보고, 반대편에는 없는 주소가 실제로 하루 몇 건인지도 함께 놓습니다.

그리고 400ms 를 줄일 수 있는 값으로 보는 것도 중요합니다. 우리 연습용 서버가 유독 느린 거예요. 존재 확인만 하는 가벼운 주소를 따로 만들면 훨씬 빨라지는데, "400ms 냐 0 이냐"가 아니라 "얼마까지 줄이면 낼 만한가"로 질문을 바꾸는 게 실무에 가깝습니다.

반대로 안 하기로 결정했다면 그게 더 중요합니다. 안 한 결정은 흔적이 안 남거든요.

코드에 주석 한 줄이 가장 싸고 확실합니다. 없는 주소를 다루는 자리에 "상태 코드는 200으로 둔다. proxy에서 확인하면 프로필 전체가 400ms 느려져서 그렇게 정했다"를 적어두면, 다음 사람이 "이거 버그 아냐?" 하고 파헤치다가 그 주석을 만납니다.

이건 오늘 배운 것 중 코드가 아닌 유일한 교훈일지도 모릅니다. 안 고치기로 한 것도 결정이고, 결정에는 기록이 필요합니다.

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

"눈에 안 보이는 정확성에 성능을 내주는 판단이라 혼자 못 정합니다. 검색·통계·규정 중에 상태 코드를 실제로 읽는 곳이 있는지부터 확인해요. 그리고 400ms 를 고정 비용으로 보지 않고 '얼마까지 줄이면 낼 만한가'로 바꿔서 봅니다. 안 하기로 정했다면 그 이유를 코드 옆에 남깁니다. 안 한 결정은 흔적이 안 남아서, 반년 뒤에 누가 같은 고민을 처음부터 다시 하거든요."

전체 목록 리액트