문서 읽는 데 72분 · H7

H-7: 국제화 ① — 언어를 주소로 옮깁니다

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

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

안녕하세요, 홍순구 튜터입니다.

시작하기 전에 숫자 하나 보여드릴게요. 우리 앱 코드에는 버튼 이름, 안내 문구, 오류 메시지 같은 자리에 한국어 글자가 101개 들어 있는데, 그중 30개는 번역하면 안 되는 것들입니다.

왜 안 되는지는 잠시 뒤에 보기로 하고, 오늘은 이 앱을 여러 나라 말로 내보내는 일부터 시작합니다.

지난 시간에 번들 이야기를 닫으면서 "글자만 바꾸면 될 것 같지만 그렇지 않다"는 예고를 드렸는데, 오늘 그 말의 앞쪽 절반을 확인하게 되실 거예요. 글자를 바꾸는 일보다 언어를 어디서 알아낼지 정하는 일이 훨씬 크고, 그 결정 하나가 우리 앱이 미리 그려질 수 있는지 없는지를 바꿔놓습니다.

한 가지 미리 말씀드릴 게 있어요. 오늘은 Next.js 앱으로 돌아가는데, 최근 몇 시간 동안 Vite로 만든 앱에서 오류와 속도와 번들을 다뤘던 것과 달리 오늘 쓸 도구는 서버에서 번역을 읽어오는 것이 핵심이라 서버 컴포넌트가 있는 쪽이라야 이야기가 됩니다.

텍스트
 오늘의 여정

 1. 한국어가 박혀 있다     도구를 붙이고 첫 글자를 옮긴다
 2. 쿠키에서 알아낸다면     빌드가 다섯 군데에서 멈춘다
 3. 주소로 옮긴다          언어별로 한 벌씩 미리 그려진다
 4. 타입이 목록을 준다      고칠 곳 아홉 군데 — 그리고 못 잡는 한 군데
 5. 빌드는 통과했는데       모든 화면이 도달 불가
 6. 탭 제목도 번역한다      Step 1 이 남긴 빚

💡 오늘 수업의 핵심 — "언어를 어디서 알아내느냐가, 그 화면을 미리 그릴 수 있느냐를 정합니다"


🎯 학습 목표

  • 화면에 나갈 글자를 코드에서 분리하고, 번역해야 할 것과 하면 안 되는 것을 가릅니다.
  • 언어를 쿠키에서 읽을 때와 주소에서 읽을 때가 어떻게 다른지 빌드 결과로 확인합니다.
  • 주소 구조를 바꿀 때 타입 검사가 무엇을 잡아주고 무엇을 못 잡는지 구분합니다.

Step 1: "우리 앱은 한국어로 박혀 있습니다"

먼저 세어봅니다

고칠 것을 정하기 전에 얼마나 있는지부터 봅시다. 코드에서 한국어가 들어간 글자를 세어봤어요.

처음에는 이렇게 나왔습니다.

텍스트
 한국어가 들어간 줄   144줄 / 31파일

이 숫자는 틀렸는데, 우리 코드베이스는 주석이 전부 한국어라서 그렇습니다. 주석은 화면에 안 나가니, 세는 방법이 답을 정해버린 경우인 셈이에요.

주석을 걷어내고 실제로 화면에 나갈 수 있는 것만 — 문자열과 화면에 직접 쓴 글자만 — 다시 셌습니다.

텍스트
 화면에 나갈 수 있는 한국어   101개 / 24파일
   그중 lib/posts.ts          30개

lib/posts.ts에 뭐가 들었는지 열어보면 이렇습니다.

텍스트
 '오늘 한강 노을이 미쳤다'
 '제주도 3박 4일 기록'
 '한강'  '노을'  '여행'

게시물 본문과 해시태그예요. 사용자가 쓴 글이라, 재훈이가 한국어로 올린 글을 영어 화면이라고 영어로 바꿔서 보여주면 그건 번역이 아니라 위조가 됩니다. 그래서 이 30개는 대상이 아니에요.

텍스트
 UI 글자      71개    오늘부터 옮겨갈 것
 사용자 글    30개    절대 건드리면 안 되는 것

이 구분이 국제화 작업의 첫 갈림입니다. 도구를 붙이기 전에 이걸 먼저 정해야 해요.

도구를 붙입니다

Next.js의 App Router에서 가장 널리 쓰이는 도구를 붙입니다.

Bash
npm install next-intl

먼저 번역문을 담을 파일을 만듭니다. 언어마다 하나씩이에요.

JSON
// apps/web-next/messages/ko.json
{
  "Nav": {
    "brand": "인스타그램 클론",
    "home": "홈",
    "explore": "탐색"
  }
}
JSON
// apps/web-next/messages/en.json
{
  "Nav": {
    "brand": "Instagram Clone",
    "home": "Home",
    "explore": "Explore"
  }
}

일본어도 함께 만들어둡니다. 나중에 재미있는 것을 보여줄 예정이라서요.

JSON
// apps/web-next/messages/ja.json
{
  "Nav": {
    "brand": "インスタグラムクローン",
    "home": "ホーム",
    "explore": "見つける"
  }
}

그다음, 이 앱이 무슨 언어로 그려질지 정하는 파일을 만듭니다.

TypeScript
// apps/web-next/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async () => {
  const locale = 'ko';

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

지금은 한국어로 고정해뒀습니다. 어디서 알아낼지는 아직 안 정했어요. 그게 오늘의 본론이라 일부러 미뤄둡니다.

마지막으로 빌드가 이 파일을 알아보게 연결합니다.

TypeScript
// apps/web-next/next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';

// i18n/request.ts 를 빌드에 물려준다. 이걸 안 하면 번역을 찾을 곳을 모른다.
const withNextIntl = createNextIntlPlugin();

// ... 기존 설정 그대로 ...

export default withNextIntl(nextConfig);

첫 글자를 옮깁니다

머리말의 메뉴 이름부터 옮겨봅시다. 지금은 이렇게 되어 있어요.

tsx
// apps/web-next/app/components/HeaderNav.tsx
const items = [
  { href: '/', label: '홈' },
  { href: '/explore', label: '탐색' },
] as const;

글자를 여기서 빼고, 대신 어느 칸을 꺼낼지 이름만 남깁니다.

tsx
const items = [
  { href: '/', key: 'home' },
  { href: '/explore', key: 'explore' },
] as const;

컴포넌트 안에서는 번역문을 꺼내 씁니다.

tsx
export function HeaderNav({ children }: { children?: ReactNode }) {
  const pathname = usePathname();
  const { toggle } = useTextScale();
  // 번역 파일의 "Nav" 칸을 연다. 아래에서는 그 안의 이름만 부르면 된다.
  const t = useTranslations('Nav');

  return (
    <nav className="mx-auto flex max-w-3xl items-center gap-4 p-4 text-sm">
      <span className="font-bold">{t('brand')}</span>
      {items.map((item) => {
        const active = pathname === item.href;
        return (
          <Link
            key={item.href}
            href={item.href}
            aria-current={active ? 'page' : undefined}
            className={active ? 'font-semibold text-black' : 'text-black/50'}
          >
            {t(item.key)}
          </Link>
        );
      })}

번역문이 브라우저에서 도는 조각에도 닿으려면 위쪽에서 한 번 감싸줘야 합니다.

tsx
// apps/web-next/app/layout.tsx
{/* 번역문을 아래 조각들에게 흘려보낸다. 브라우저에서 도는 조각도 이걸 통해 읽는다. */}
<NextIntlClientProvider>
  <Providers>
    {/* ... 머리말과 children ... */}
  </Providers>
</NextIntlClientProvider>

빌드해보면 아무 일도 안 일어납니다

빌드하고 주소 표를 봅니다.

텍스트
 Route (app)
 ┌ ◐ /
 ├ ◐ /_not-found
 ├ ◐ /[username]
 ├ ◐ /explore
 └ ◐ /signup

지난 시간 표와 글자 하나 안 다릅니다. 도구를 하나 들였는데 미리 그려지는 구조는 그대로예요. 이게 중요합니다. 오늘 뒤에서 이 표가 크게 바뀌는 순간이 오는데, 그게 도구 때문이 아니라는 걸 여기서 확인해두는 거죠.

정말 연결된 게 맞을까요

화면에 한국어가 뜨는 건 원래도 그랬으니, 이것만으로는 번역이 도는지 알 수 없습니다. 확인하려면 일부러 바꿔봐야 해요. 고정값을 'ja'로 바꾸고 다시 빌드했습니다.

텍스트
             바꾸기 전    바꾼 뒤
 홈              1건        0건
 탐색            1건        0건
 ホーム          0건        1건
 見つける        0건        1건

번역 파일에서 읽어오는 게 맞습니다.

⚠️ 그런데 안 바뀐 게 있습니다

같은 일본어 화면에서 인스타그램 클론이라는 글자가 3건 남아 있었습니다. 어디인지 찾아보니 이랬어요.

텍스트
 <title>인스타그램 클론</title>
 <meta property="og:title" content="인스타그램 클론">
 <meta name="twitter:title" content="인스타그램 클론">

브라우저 탭에 뜨는 제목과, 메신저에 링크를 붙였을 때 나오는 미리보기 제목입니다. 화면 안쪽 글자는 다 바뀌었는데 이 셋만 한국어로 남았어요.

눈에 잘 안 띄는 곳이라 더 위험합니다. 화면을 아무리 들여다봐도 안 보이거든요. 왜 안 바뀌었는지와 어떻게 고치는지는 Step 6에서 다룹니다. 오늘 끝까지 안 잊고 갚을게요.

💡 한 줄 정리

번역할 것과 하면 안 되는 것을 먼저 가르고, 글자를 코드에서 파일로 옮겼습니다. 도구를 붙이는 것만으로는 미리 그려지는 구조가 바뀌지 않습니다.

🙋 학생 질문 — "튜터님, 번역 파일을 언어마다 따로 두면 나중에 안 맞아지지 않나요?"

정확한 걱정이에요. 한국어 파일에 칸을 하나 더 만들고 영어 파일에는 깜빡하면, 영어 화면에서 그 자리가 비게 됩니다.

next-intl은 이걸 타입으로 막을 수 있게 해뒀어요. 기준이 되는 언어 하나를 정해두면, 다른 언어 파일에 없는 칸을 부를 때 타입 검사가 걸어줍니다. 다만 그건 파일이 어느 정도 자란 뒤에 붙이는 게 좋은데, 지금은 칸이 셋뿐이라 눈으로 보는 게 더 빠르거든요.

실무에서는 번역 파일을 개발자가 직접 안 고치는 경우도 많습니다. 번역가가 쓰는 별도 도구에서 관리하고, 결과물만 이 파일로 내려받는 방식인데, 그때도 "코드에는 이름만, 글자는 파일에"라는 오늘의 구조가 그대로 쓰입니다.


Step 2: "언어를 쿠키에서 알아내 봅니다"

가장 먼저 떠오르는 방법

언어를 정할 차례인데, 사용자가 언어를 고르면 그 선택을 어딘가에 저장해뒀다가 다음에도 그 언어로 보여주고 싶죠.

가장 먼저 떠오르는 건 쿠키인데, 우리는 이미 글자 크기 설정을 쿠키에 담아본 적이 있어요.

공식 문서도 이 방법을 안내합니다. 그대로 따라 써봅시다.

TypeScript
// apps/web-next/i18n/request.ts
import { cookies } from 'next/headers';
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async () => {
  // 사람이 고른 언어를 쿠키에서 꺼내 온다.
  const store = await cookies();
  const locale = store.get('locale')?.value === 'en' ? 'en' : 'ko';

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

바꾼 건 이 파일 하나뿐입니다. 나머지는 손대지 않았어요. 빌드해봅시다.

다섯 군데에서 멈춥니다

텍스트
 Error: Route "/": Next.js encountered uncached or runtime data during prerendering.

 `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()`
 accessed outside of `<Suspense>` prevents the route from being prerendered.

 Ways to fix this:
   - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
   - [cache]  For uncached data: cache the access with "use cache"
   - [block]  Set `export const instant = false` to allow a blocking route

이 오류가 다섯 번 납니다.

텍스트
 /            /_not-found     /[username]     /explore     /signup

앱의 모든 화면입니다. 한 줄 바꿨는데 전부 멈췄어요.

이 세 갈래 안내는 처음 보는 게 아닌데, 캐시를 다루던 시간에 이미 똑같은 모양을 만났었죠. 그때 배운 규칙이 그대로 적용되는데, 미리 그리는 동안에는 요청이 없으니까 요청이 와야 아는 값을 읽으면 미리 그릴 수가 없어요.

오류가 가리키는 곳을 봅니다

어디서 났는지 자세히 보면 이렇습니다.

텍스트
 at <anonymous> (i18n/request.ts:7:30)       await cookies()
 at RootLayout (app/layout.tsx:38:9)         <NextIntlClientProvider>

i18n/request.ts 7번 줄은 우리가 방금 쓴 쿠키 읽기인데, 그 위에 있는 게 더 흥미롭습니다. layout.tsx 38번 줄, 번역문을 나눠주는 곳입니다.

여기서 한 번 멈춰서 생각해볼 만해요. 번역을 실제로 쓰는 건 머리말인데, 머리말은 이미 <Suspense> 안에 들어 있습니다. 그런데도 막혔어요.

텍스트
 <NextIntlClientProvider>           여기가 쿠키를 읽는다
   <Providers>
     <Suspense>                      경계는 이 아래에 있다
       <HeaderNav />                 번역을 쓰는 곳

번역문을 나눠주는 곳이 경계보다 위에 있는데, 나눠주려면 먼저 무슨 언어인지 알아야 하기 때문입니다. 그래서 문제는 번역을 쓰는 곳이 아니라 주는 곳이에요.

세 갈래를 하나씩 따져봅니다

안내가 준 세 가지 중에 쓸 만한 게 있는지 봅시다.

[block]은 쓸 수 없습니다. 이건 "이 주소는 미리 그리기 검사를 건너뛰어라"라는 선언이에요. 우리는 캐시를 다루던 시간에 이 줄을 코드베이스에서 전부 걷어냈습니다. 다시 넣으면 그때 얻은 걸 도로 반납하는 셈이죠.

[cache]도 안 됩니다. 캐시로 감싼 함수 안에서는 쿠키를 못 읽습니다. 이것도 그때 확인했어요. 캐시는 여러 사람이 나눠 쓰는 것이고 쿠키는 그 사람만의 것이니, 같이 둘 수 없는 게 당연합니다.

[stream]은 됩니다. 나눠주는 곳을 <Suspense>로 감싸면 빌드가 통과해요. 실제로 해봤습니다.

텍스트
 빌드         exit 0
 주소 표      전부 ◐  — 바뀐 것 없음

통과하긴 하는데, 이걸로 끝일까요?

되는데, 값을 치릅니다

미리 그려진 화면 파일을 열어서 비교해봤습니다.

텍스트
                        미리 그려진 HTML    머리말이 그 안에 있나
 A. 언어 고정              3,052 B                있음
 B. 쿠키 + Suspense        2,003 B                없음

1,049 바이트가 사라졌고, 머리말이 미리 그려진 화면에서 통째로 빠졌습니다.

당연한 결과예요. 머리말을 그리려면 번역문이 필요하고, 번역문을 정하려면 쿠키를 읽어야 하고, 쿠키는 요청이 와야 아니까요. 그러니 머리말은 미리 못 그리고 나중에 흘려보내야 합니다.

여기서 질문이 생깁니다. 언어 하나 정하자고 머리말을 통째로 포기할 값어치가 있을까요?

머리말은 모든 화면에 있고 가장 먼저 보이는 것이라서, 그걸 미리 못 그린다는 건 언어가 몇 개든 상관없이 매번 사용자가 빈 자리를 잠깐 보게 된다는 뜻이에요.

그래서 다음 Step에서 방향을 바꿉니다. 쿠키가 문제라면, 요청이 오기 전에도 알 수 있는 곳에 언어를 두면 됩니다.

💡 한 줄 정리

쿠키는 요청이 와야 아는 값이라, 언어를 거기서 읽으면 앱을 미리 그릴 수 없습니다. <Suspense>로 피할 수는 있지만 머리말을 미리 그리는 걸 포기해야 합니다.

🙋 학생 질문 — "튜터님, 그럼 쿠키로 언어를 정하는 사이트는 다 느린 건가요?"

아니에요. 두 가지를 갈라서 봐야 합니다.

먼저 미리 그리기를 아예 안 하는 앱이라면 이 문제가 없습니다. 어차피 요청마다 그리는 앱이면 쿠키를 읽는 게 아무 부담이 아니에요. 우리가 쓰는 Vite 앱도 그렇고요.

미리 그리기를 하더라도 감수할 만한 경우도 있습니다. 예를 들어 로그인해야만 들어올 수 있는 관리자 화면이라면, 어차피 사람마다 다른 내용을 보여줘야 해서 미리 그릴 게 별로 없으니 언어를 쿠키에 둬도 잃을 게 없죠.

우리 앱은 로그인 안 해도 볼 수 있는 화면이 있고 그 화면들이 지금 미리 그려지고 있어서 아까운 겁니다. 같은 선택이 앱마다 다른 값을 갖는다는 게 요점이에요.


Step 3: "언어를 주소로 옮깁니다"

주소는 빌드할 때도 아는 값입니다

쿠키가 안 되는 이유는 하나였습니다. 요청이 와야 안다는 것.

요청이 오기 전에도 알 수 있는 곳은 어딜까요? 주소입니다. /ko/explore라는 주소가 있다면 그 주소의 언어는 ko예요. 아무도 방문하지 않아도 그렇습니다.

여러 나라 말을 지원하는 사이트가 /ko/..., /en/...처럼 주소에 언어를 넣는 걸 보신 적 있을 거예요. 흔히 검색 노출 때문이라고 설명하는데, 그것도 맞지만 더 실용적인 이유가 있습니다. 주소에 넣으면 미리 그릴 수 있습니다.

지원할 언어를 선언합니다

TypeScript
// apps/web-next/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';

// 우리가 지원하기로 한 언어와 기본값.
// 이 목록이 곧 주소 첫 칸에 올 수 있는 값 전부다.
export const routing = defineRouting({
  locales: ['ko', 'en', 'ja'],
  defaultLocale: 'ko',
});

언어는 쿠키가 아니라 주소에서 받게 바꿉니다.

TypeScript
// apps/web-next/i18n/request.ts
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';

export default getRequestConfig(async ({ requestLocale }) => {
  // 주소의 첫 칸에서 온다. 쿠키와 달리 미리 그릴 때도 아는 값이다.
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

hasLocale은 들어온 값이 우리가 아는 언어인지 확인합니다. 아무 글자나 첫 칸에 넣고 들어올 수 있으니 걸러야 해요.

폴더를 옮깁니다

주소 첫 칸이 언어가 되려면, 지금 있는 화면들이 전부 그 아래로 들어가야 합니다.

텍스트
 옮기기 전                     옮긴 뒤
 app/                          app/
   page.tsx                      [locale]/
   layout.tsx                      page.tsx
   explore/page.tsx                layout.tsx
   signup/page.tsx                 explore/page.tsx
   [username]/                     signup/page.tsx
   not-found.tsx                   [username]/
   components/                   not-found.tsx       여기는 안 옮긴다
   api/                          components/
                                 api/

not-found.tsx를 왜 안 옮기는지는 Step 4에서 자연스럽게 드러납니다.

껍데기가 언어를 받습니다

옮긴 껍데기 파일에 세 가지를 추가합니다.

tsx
// apps/web-next/app/[locale]/layout.tsx

// 어떤 언어들을 미리 그려둘지 알려준다. 이게 있어야 빌드가 언어별로 한 벌씩 만든다.
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }: LayoutProps<'/[locale]'>) {
  // 언어가 주소 첫 칸으로 들어온다.
  const { locale } = await params;

  // 우리가 모르는 값이 첫 칸에 오면 그건 언어가 아니다.
  if (!hasLocale(routing.locales, locale)) {
    notFound();
  }

  // 이 요청이 무슨 언어인지 아래 조각들에게 알려둔다.
  // 이 줄이 있어야 미리 그리는 동안에도 번역을 찾을 수 있다.
  setRequestLocale(locale);

  return (
    <html lang={locale} className={notoSansKr.className}>

세 가지를 하나씩 짚어볼게요.

generateStaticParams는 "미리 그려둘 언어 목록"입니다. 이게 없으면 빌드는 /[locale]이라는 틀만 알 뿐, 거기에 무엇이 들어올지 몰라서 미리 그릴 수가 없어요.

notFound()는 걸러내는 자리입니다. 누가 /zzzz/explore로 들어오면 zzzz는 언어가 아니니까요.

setRequestLocale은 아래 조각들에게 알려주는 일입니다. 이게 빠지면 미리 그리는 동안 번역을 못 찾습니다.

<html lang>이 바뀐 것도 눈여겨보세요. 지금까지 "ko"로 박혀 있던 자리인데, 이제는 주소를 따라갑니다.

빌드하면 언어별로 한 벌씩

텍스트
 Route (app)
 ┌ ○ /_not-found
 ├   /[locale]
 │ ├ ◐ /[locale]
 │ ├ ◐ /ko
 │ ├ ◐ /en
 │ └ ◐ /ja
 ├   /[locale]/[username]
 │ ├ ◐ /ko/[username]   ├ ◐ /en/[username]   └ ◐ /ja/[username]
 ├   /[locale]/explore
 │ ├ ◐ /ko/explore      ├ ◐ /en/explore      └ ◐ /ja/explore
 └   /[locale]/signup
   ├ ◐ /ko/signup       ├ ◐ /en/signup       └ ◐ /ja/signup

주소마다 언어 수만큼 줄이 생겼습니다. 미리 그려진 화면이 여덟 개에서 스무 개로 늘었어요.

쿠키 방식과 견줘보면 차이가 분명합니다.

텍스트
 쿠키    화면 하나 · 요청이 와야 언어를 안다 · 미리 못 그린다
 주소    언어마다 한 벌 · 빌드 때 언어를 안다 · 전부 미리 그린다

번역 파일이 늘어난 만큼 미리 그릴 화면도 늘어나서, 언어를 열 개 지원하면 열 벌이 됩니다. 공짜는 아니지만, 그 값은 빌드 시간과 저장 공간으로 치르는 것이지 사용자가 기다리는 시간으로 치르지는 않습니다.

💡 한 줄 정리

언어를 주소에 넣으면 빌드할 때 알 수 있는 값이 되고, 그래서 언어마다 한 벌씩 미리 그려둘 수 있습니다. 여러 나라 말 사이트가 주소에 언어를 넣는 실용적인 이유가 이것입니다.

🙋 학생 질문 — "튜터님, 그럼 사용자가 고른 언어를 기억하는 건 어떻게 하나요?"

좋은 질문이에요. 쿠키를 아예 안 쓴다는 뜻은 아닙니다.

갈라서 생각해보세요. 화면을 그릴 때 쓰는 언어는 주소에서 옵니다. 그건 미리 그려야 하니까요. 반면 처음 들어온 사람을 어느 언어로 보낼지는 주소에 없어요. /로 들어온 사람에게 /ko를 줄지 /en을 줄지는 정해야 하죠.

그 판단은 화면을 그리기 전에 하는 것이고, 그건 문지기가 합니다. 문지기는 요청을 받는 곳이라 쿠키를 읽어도 아무 문제가 없는데, 미리 그리는 것과 무관하기 때문이에요.

쿠키를 읽는 곳을 화면 밖으로 옮긴 것이지 없앤 게 아닙니다. Step 5에서 그 문지기를 손보게 됩니다.


Step 4: "타입이 고칠 목록을 만들어줍니다"

폴더를 옮겼으니 빌드해봅니다

방금 폴더를 통째로 옮겼죠. 빌드하면 어떻게 될까요?

텍스트
 app/[locale]/[username]/layout.tsx(8,65):  error TS2344
   Type '"/[username]"' does not satisfy the constraint 'LayoutRoutes'.
 app/[locale]/[username]/layout.tsx(9,11):  error TS2339
   Property 'username' does not exist on type 'unknown'.
 app/[locale]/[username]/page.tsx(10,62):   error TS2344
 app/[locale]/[username]/page.tsx(11,11):   error TS2339
 app/[locale]/[username]/page.tsx(28,65):   error TS2344
 app/[locale]/[username]/page.tsx(29,11):   error TS2339
 app/[locale]/[username]/layout.tsx(23,73): error TS2344
 app/components/HeaderNav.tsx(37,13):       error TS2322
   Type '"/" | "/explore"' is not assignable to type 'RouteImpl<"/" | "/explore">'.
 app/not-found.tsx(11,13):                  error TS2322
   Type '"/"' is not assignable to type 'RouteImpl<"/">'.

아홉 건인데, 이건 나쁜 소식이 아니라 좋은 소식이에요. 세 종류로 나뉘고, 각각이 우리에게 할 일을 알려줍니다.

텍스트
 TS2344  네 건   없어진 주소를 가리키고 있다
 TS2339  세 건   그래서 params 가 무엇인지 모른다
 TS2322  두 건   손으로 적어둔 주소가 이제 없는 주소다

첫째 — 없어진 주소

PageProps<'/[username]'>라고 적혀 있던 자리입니다. 그 주소는 이제 없어요. /[locale]/[username]이 됐죠.

tsx
// 고치기 전
export default async function ProfilePage({ params }: PageProps<'/[username]'>) {

// 고친 뒤
export default async function ProfilePage({ params }: PageProps<'/[locale]/[username]'>) {

둘째 — 그래서 params를 모른다

첫째가 틀리면 둘째가 따라옵니다. 없는 주소를 가리키니 그 주소에 무슨 칸이 있는지도 모르고, paramsunknown이 돼요. username을 꺼내려는 순간 막힙니다.

이건 Next.js를 처음 배울 때 봤던 것과 같은 모양입니다. 폴더 구조가 곧 타입이 되기 때문에, 폴더를 옮기면 타입이 따라 움직입니다.

셋째 — 손으로 적어둔 주소

이게 가장 재미있습니다.

tsx
const items = [
  { href: '/', key: 'home' },
  { href: '/explore', key: 'explore' },
] as const;

'/'라는 주소가 이제 존재하지 않는데, 우리 앱에 있는 주소는 /ko, /en, /ja뿐이라 타입 검사가 걸어줍니다.

우리 앱은 주소를 타입으로 확인하는 설정을 켜둔 상태였죠. 그 설정이 여기서 제값을 합니다. 폴더를 옮기면서 어긋난 곳을 빠짐없이 찾아줬어요.

고치는 방법은 언어를 아는 링크로 바꿔 끼우는 겁니다.

TypeScript
// apps/web-next/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';

// next/link 와 next/navigation 을 «언어를 아는» 것으로 바꿔 끼운다.
// 이걸 쓰면 주소에 언어 칸을 손으로 붙이지 않아도 되고,
// usePathname 도 언어 칸을 뗀 주소를 돌려준다.
export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);
tsx
// apps/web-next/app/components/HeaderNav.tsx
import { Link, usePathname } from '@/i18n/navigation';

이제 href="/explore"라고 쓰면 이 링크가 알아서 지금 언어를 붙여줍니다. 한국어 화면에서는 /ko/explore, 영어 화면에서는 /en/explore로요.

⚠️ 그런데 타입이 못 잡은 게 있습니다

방금 바꾼 import 줄을 다시 보면, Link만 바꾼 게 아니라 usePathname도 같이 바꿨는데, 타입 검사는 usePathname에 대해서는 아무 말도 안 했어요.

무슨 일이 일어나는지 보시면 이렇습니다.

텍스트
 사용자가 있는 곳       /ko/explore
 usePathname 이 주는 값
   next/navigation      "/ko/explore"
   i18n/navigation      "/explore"
 우리가 비교하는 값      "/explore"

앞엣것을 쓰면 영영 안 맞는데, 무슨 일이 벌어질까요? 현재 위치 표시가 안 붙어서, 지금 보고 있는 메뉴가 굵게 표시되지 않습니다.

일부러 되돌려서 재봤습니다.

텍스트
                          타입   린트   빌드   현재 위치 표시
 i18n/navigation            0     0      0        1건
 next/navigation            0     0      0        0건

셋 다 통과합니다. 링크도 멀쩡하고 글자도 멀쩡하고 화면도 뜹니다. 표시 하나만 조용히 사라져요.

같은 파일 안에 두 가지가 나란히 있는 셈인데, 하나는 시끄럽게 깨지고 하나는 조용히 깨집니다. 조용히 깨지는 쪽이 훨씬 오래 남고요.

not-found는 언어를 모릅니다

아까 이 파일만 안 옮겼죠. 이유가 여기서 나옵니다.

이 화면은 아무 폴더도 안 맡은 주소를 받습니다. 언어 칸이 없는 주소까지 여기로 와요. 그러니 이 화면은 지금이 무슨 언어인지 알 수가 없습니다.

언어를 아는 링크를 쓰려면 /ko인지 /en 인지를 이 화면에서 정해버려야 하는데, 그건 틀린 결정이 될 수 있습니다. 그래서 평범한 앵커로 되돌립니다.

tsx
// apps/web-next/app/not-found.tsx
{/* 린터는 여기에 Link 를 쓰라고 한다. 그런데 이 화면은 언어를 모른다 —
    Link 를 쓰려면 /ko 인지 /en 인지를 여기서 정해버려야 한다.
    "/" 로 통째로 이동하면 그 판단을 문지기에게 넘길 수 있다. */}
<a href="/" className="underline">
  피드로 돌아가기
</a>

린터가 이걸 막습니다. "앵커 말고 Link를 쓰세요" 라고요. 보통은 맞는 조언이지만 여기서는 아닌데, 린터는 우리 앱에 언어 칸이 생겼다는 걸 모르기 때문이에요. 이유를 주석으로 적고 그 줄만 예외로 뒀더니, 이제 빌드가 통과합니다.

💡 한 줄 정리

주소 구조를 바꾸면 타입 검사가 고칠 곳 목록을 만들어줍니다. 다만 값을 비교하는 코드는 못 잡아주니, 목록을 다 지웠다고 끝난 게 아닙니다.

🙋 학생 질문 — "튜터님, 타입이 못 잡는 걸 어떻게 미리 알 수 있나요?"

완전히 알 수는 없어요. 대신 기준 하나를 갖고 있으면 도움이 됩니다.

타입은 "이 값이 무엇인가"를 봅니다. href에 들어갈 수 있는 주소 목록은 타입이 알고 있으니 틀리면 잡아줘요.

타입은 "이 값이 무엇과 같은가"는 못 봅니다. usePathname() === '/explore'는 문자열끼리 비교하는 거라 둘 다 문자열이면 통과입니다. 실제로 같아지는지는 돌려봐야 알아요.

그래서 구조를 바꾼 뒤에는 비교하는 코드를 따로 찾아보는 습관이 도움이 됩니다. ===로 주소를 견주는 곳, 주소를 잘라 쓰는 곳 같은 데요. 오늘 Step 5에서 만날 문제도 정확히 그 종류입니다.


Step 5: "빌드는 통과했는데 앱이 죽어 있습니다"

띄워봅니다

빌드가 통과했고 타입도 린트도 깨끗합니다. 화면도 미리 그려졌어요. 그럼 됐겠죠?

서버를 띄우고 들어가봤습니다.

텍스트
 /              404
 /ko            307    /?next=%2Fko
 /en            307    /?next=%2Fen
 /ja            307    /?next=%2Fja
 /ko/explore    307    /?next=%2Fko%2Fexplore
 /ko/signup     307    /?next=%2Fko%2Fsignup
 /ko/jaehoon    307    /?next=%2Fko%2Fjaehoon

하나도 안 열립니다.

게다가 더 고약해요. 전부 /로 보내는데, 그 /가 바로 위에서 404입니다. 막다른 길로 몰아넣고 있는 거죠.

서버 쪽도 확인해봤습니다. 데이터를 주는 서버가 그동안 받은 요청은 0건 이었어요. 아무도 거기까지 못 갔다는 뜻입니다.

아무도 안 알려줍니다

정리해보면 이런 상태입니다.

텍스트
 빌드    통과
 타입    통과
 린트    통과
 화면    미리 그려짐 (스무 개)
 앱      전부 도달 불가

앞에서 보안 헤더를 다룰 때도 비슷한 걸 만났습니다. 빌드가 멀쩡한데 앱이 안 도는 상황이요. 이런 종류는 재보지 않으면 모릅니다. 도구들이 보는 것과 사용자가 겪는 것이 다르거든요.

문지기가 옛날 주소를 들고 있었습니다

범인은 문지기입니다. 이 파일이 우리 앱에 들어오는 모든 요청을 먼저 받거든요.

문지기가 들고 있는 목록을 보면 이렇습니다.

TypeScript
// apps/web-next/proxy.ts
const PUBLIC_PATHS = ['/', '/explore', '/signup'];

로그인 없이 들어와도 되는 주소 목록이에요. 그런데 지금 우리 앱의 실제 주소는 /ko, /ko/explore, /ko/signup입니다. 목록에 하나도 안 걸립니다.

안 걸리면 어떻게 되냐면, 문지기는 그걸 보호해야 할 주소로 봅니다. 로그인 안 했으니 로그인하라고 돌려보내죠. 모든 주소를요.

사람 이름을 꺼내는 곳도 같은 문제를 갖고 있습니다.

TypeScript
const username = pathname.slice(1);

/jaehoon에서 앞의 빗금 하나를 떼면 jaehoon이 나왔는데, 이제 주소가 /ko/jaehoon이라서 떼고 나면 ko/jaehoon이 됩니다. 그런 사람은 없어요.

이 목록과 이 계산은 언어 칸이 없던 시절에 쓰인 겁니다. 그때는 맞았어요. 지금은 틀렸고요.

언어를 떼고 나서 판단하게 합니다

고치는 방향은 둘 중 하나입니다. 목록을 전부 언어별로 늘리거나, 판단하기 전에 언어를 떼거나.

당연히 뒤쪽입니다. 언어가 늘어날 때마다 목록을 고쳐야 한다면 언젠가 빠뜨릴 테니까요.

TypeScript
// apps/web-next/proxy.ts
import createIntlMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

// 언어 라우팅을 맡는 조각. 주소에 언어 칸이 없으면 붙여서 되돌려보낸다.
const handleI18nRouting = createIntlMiddleware(routing);

// 주소에서 언어 칸을 떼어낸다.
// 아래 검사들은 전부 언어를 «모르던 시절» 에 쓰인 주소로 판단하기 때문에,
// 떼지 않으면 /ko/explore 가 공개 목록의 /explore 와 안 맞는다.
function stripLocale(pathname: string) {
  const segments = pathname.split('/');
  const first = segments[1];
  const isLocale = (routing.locales as readonly string[]).includes(first);

  return {
    locale: isLocale ? first : routing.defaultLocale,
    // 언어 칸을 뗀 나머지. 떼고 나서 빈 문자열이면 그건 홈이다.
    rest: isLocale ? `/${segments.slice(2).join('/')}`.replace(/\/$/, '') || '/' : pathname,
  };
}

문지기 본문이 이 결과를 쓰게 합니다.

TypeScript
  // 통과시킬 때는 우리가 응답을 만들지 않고 언어 라우팅에게 만들게 한다.
  // 그래야 /ko 를 내부 라우트로 다시 쓰고, 언어 칸이 없으면 붙여서 되돌려보낸다.
  const pass = () => withCsp(handleI18nRouting(request));

  // 언어 칸을 떼고 나서 판단한다. 아래 목록과 검사는 언어를 모르는 주소로 적혀 있다.
  const { locale, rest } = stripLocale(request.nextUrl.pathname);

  if (PUBLIC_PATHS.includes(rest)) {
    return pass();
  }

  if (getSessionCookie(request) === null) {
    // 되돌려보낼 곳도 그 사람이 보던 언어여야 한다.
    const signInUrl = new URL(`/${locale}`, request.url);
    signInUrl.searchParams.set('next', rest);
    return withCsp(NextResponse.redirect(signInUrl));
  }

  const username = rest.slice(1);

바뀐 곳이 넷입니다. 통과시키는 방법, 판단할 주소, 되돌려보낼 주소, 사람 이름을 꺼내는 곳.

특히 pass()를 눈여겨보세요. 예전에는 우리가 직접 "통과" 응답을 만들었는데, 이제는 언어 라우팅에게 만들게 합니다. 그래야 /로 들어온 사람을 /ko로 보내주는 일까지 대신 해주거든요.

되살아납니다

텍스트
 로그아웃 상태                        로그인 상태
 /             307  /ko              /ko            200
 /ko           200                    /ko/jaehoon    200
 /en           200                    /en/jaehoon    200
 /ja           200                    /ja/jaehoon    200
 /ko/explore   200                    /ko/zzzzz      404
 /ja/explore   200                    /en/zzzzz      404
 /ko/signup    200
 /ko/jaehoon   307  /ko?next=%2Fjaehoon

전부 살아났는데, 눈여겨볼 게 몇 개 있어요.

/로 들어오면 /ko로 보내줍니다. 언어 칸 없는 주소도 처리된다는 뜻이죠.

로그인이 필요한 화면으로 돌려보낼 때 /ko?next=/jaehoon이 됩니다. 되돌아갈 언어를 기억합니다. 영어로 보던 사람은 /en?next=...로 가고요.

없는 사람 주소가 404인데, 예전에 이 404를 얻으려고 꽤 고생했었죠. 언어를 셋으로 늘려도 그대로 살아 있습니다.

앞에서 얻은 것들이 살아 있는지 확인합니다

큰 구조 변경을 했으니 예전에 값을 치르고 얻은 것들이 무사한지 봐야 합니다.

텍스트
 보안 헤더의 번호표    /ko /en /ja 세 화면 모두 정상 · 비율도 예전과 같음
 없는 사람 404         /ko/zzzzz · /en/zzzzz 둘 다 404
 로그인 문지기         로그아웃이면 프로필이 307
 화면의 언어 표시      ko / en / ja 로 갈린다

전부 무사합니다. 마지막 줄은 원래 "ko"로 박혀 있던 것이 이제 진짜로 갈리는 거고요.

💡 한 줄 정리

주소 구조가 바뀌면 주소를 문자열로 다루던 코드가 전부 어긋납니다. 빌드도 타입도 이걸 못 잡아주니 직접 띄워서 재봐야 합니다.

🙋 학생 질문 — "튜터님, 문지기가 언어 칸을 떼는 것 말고 다른 방법은 없나요?"

있어요. 크게 둘입니다.

하나는 목록 자체를 언어별로 늘리는 것입니다. ['/ko', '/en', '/ja', '/ko/explore', ...]처럼요. 지금은 세 언어에 주소 셋이라 아홉 줄이면 되긴 합니다. 그런데 언어나 주소가 늘 때마다 곱하기로 늘어나고, 하나 빠뜨리면 그 주소만 조용히 보호가 풀립니다.

다른 하나는 언어 라우팅을 먼저 태우고 그 결과를 보는 것입니다. 언어 라우팅이 주소를 다시 쓴 다음에 판단하는 방식이죠. 이것도 됩니다. 다만 다시 쓴 결과를 꺼내 보는 게 한 겹 더 들어가서, 우리처럼 검사가 여러 개면 오히려 읽기 어려워져요.

우리가 고른 방법의 장점은 검사 코드를 한 줄도 안 고쳤다는 것입니다. 목록도 그대로고 조건문도 그대로예요. 바뀐 건 그것들이 보는 값 하나뿐입니다.


Step 6: "탭 제목도 번역합니다"

Step 1이 남긴 빚

기억하시죠. 화면 안쪽 글자는 다 바뀌었는데 셋이 안 바뀌었습니다.

텍스트
 <title>인스타그램 클론</title>
 <meta property="og:title" content="인스타그램 클론">
 <meta name="twitter:title" content="인스타그램 클론">

그 이유를 이제 볼 수 있는데, 값들이 어디서 오는지 보면 이렇습니다.

tsx
// apps/web-next/app/[locale]/layout.tsx
export const metadata: Metadata = {
  title: '인스타그램 클론',
  description: 'Next.js App Router 로 다시 짓는 인스타그램',
};

이건 컴포넌트가 아니라 그냥 값이라서, useTranslations를 못 씁니다. 훅은 화면을 그리는 도중에만 부를 수 있는데, 이 값은 그리기 전에 읽히거든요.

값 대신 함수로 바꿉니다

값으로 두면 못 고치니 함수로 바꿉니다. 함수면 언어를 받을 수 있어요.

tsx
// apps/web-next/app/[locale]/layout.tsx

// metadata 는 컴포넌트가 아니다. 그래서 useTranslations 를 못 쓴다 —
// 훅은 그리는 도중에만 부를 수 있는데 이건 그리기 전에 불린다.
// 대신 기다렸다 받는 getTranslations 를 쓰고, 함수로 바꿔 params 를 받는다.
export async function generateMetadata({ params }: LayoutProps<'/[locale]'>): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'Meta' });

  return {
    title: t('title'),
    description: t('description'),
    // 아래 화면이 자기 것을 안 주면 이게 쓰인다 — 링크가 맨몸으로 나가는 일은 없다.
    openGraph: {
      title: t('title'),
      description: t('description'),
      type: 'website',
    },
  };
}

두 가지가 달라졌습니다.

useTranslations가 아니라 getTranslations입니다. 앞엣것은 훅이라 컴포넌트 안에서만 살고, 뒤엣것은 그냥 함수라 어디서든 부를 수 있어요. 대신 기다렸다 받아야 합니다.

언어를 인자로 직접 넘깁니다. 훅은 화면을 그리는 중이라 지금 언어가 뭔지 알고 있지만, 이 함수는 그 바깥에 있어서 알려줘야 하거든요.

번역 파일에 칸도 만들어줍니다.

JSON
// apps/web-next/messages/en.json
{
  "Meta": {
    "title": "Instagram Clone",
    "description": "Rebuilding Instagram with the Next.js App Router",
    "profileTitle": "@{username} · Instagram Clone",
    "followers": "{count} followers"
  }
}

{username}처럼 중괄호로 감싼 자리는 나중에 값을 끼워 넣는 곳입니다. 프로필 화면 제목이 사람마다 달라야 하니까요.

프로필 화면도 같은 방식으로

tsx
// apps/web-next/app/[locale]/[username]/page.tsx
export async function generateMetadata({ params }: PageProps<'/[locale]/[username]'>): Promise<Metadata> {
  const { locale, username } = await params;
  const profile = await fetchProfile(username);
  const t = await getTranslations({ locale, namespace: 'Meta' });

  return {
    title: t('profileTitle', { username }),
    description: t('followers', { count: profile.followerCount }),

t('profileTitle', { username })처럼 둘째 인자로 값을 넘기면 중괄호 자리에 들어갑니다.

확인합니다

텍스트
 /ko          <title>인스타그램 클론</title>
 /en          <title>Instagram Clone</title>
 /ja          <title>インスタグラムクローン</title>

 /ko/jaehoon  @jaehoon · 인스타그램 클론      팔로워 1240명
 /en/jaehoon  @jaehoon · Instagram Clone     1240 followers
 /ja/jaehoon  @jaehoon・インスタグラムクローン   フォロワー1240人

빚을 갚았습니다. 탭 제목도 링크 미리보기도 언어를 따라갑니다.

⚠️ 영어 줄을 다시 보세요

1240 followers라고 나왔죠. 영어권에서 이 숫자를 쓴다면 보통 1,240이라고 씁니다. 세 자리마다 쉼표를 찍으니까요.

우리가 넘긴 값은 숫자 1240이 맞습니다. 그런데 중괄호에 그냥 넣으면 값을 그대로 글자로 바꿔서 넣어요. 나라마다 숫자를 어떻게 쓰는지는 안 봅니다.

숫자를 숫자답게 쓰려면 따로 알려줘야 하는데, 그건 숫자만의 문제가 아니에요. 날짜도 그렇고 금액도 그런데, 이게 다음 시간의 첫 주제입니다.

💡 한 줄 정리

화면을 그리기 전에 정해지는 값은 훅으로 못 바꿉니다. 기다렸다 받는 함수를 쓰고 언어를 직접 넘겨야 합니다.

🙋 학생 질문 — "튜터님, 훅이랑 그냥 함수를 왜 이렇게 갈라놨나요?"

훅은 "지금 그리고 있는 화면"이라는 맥락에 붙어 있습니다. 그 안에서만 지금 언어가 뭔지, 어떤 값이 바뀌면 다시 그려야 하는지를 알 수 있어요.

metadata는 화면이 아닙니다. 화면 옆에 붙는 정보고, 그리기 전에 확정돼야 해요. 브라우저가 탭 제목을 화면 다 그린 다음에 정하면 곤란하니까요.

그래서 두 세계가 갈립니다. 화면 안이면 훅, 화면 밖이면 함수인데, 화면 밖에서는 맥락이 없으니 언어를 손으로 넘겨줘야 하는 거고요.

이 구분은 앞으로도 계속 만나실 겁니다. 서버 액션 안에서 오류 메시지를 번역해야 할 때도 같은 이유로 함수 쪽을 씁니다.


마무리

오늘 배운 핵심 세 가지

💡 하나. 번역할 것과 하면 안 되는 것을 먼저 가릅니다. 우리 앱의 한국어 101개 중 30개는 사용자가 쓴 글이었습니다. 그걸 같이 옮겼으면 남의 글을 고치는 도구를 만들 뻔했어요.

💡 둘. 언어를 어디서 알아내느냐가 미리 그릴 수 있느냐를 정합니다. 쿠키는 요청이 와야 아는 값이라 앱 전체가 미리 그리기에서 빠집니다. 주소는 빌드할 때 아는 값이라 언어마다 한 벌씩 만들어둘 수 있어요. 여러 나라 말 사이트가 주소에 언어를 넣는 실용적인 이유입니다.

💡 셋. 구조를 바꾸면 도구가 잡아주는 것과 못 잡는 것이 갈립니다. 타입 검사는 없어진 주소를 아홉 군데 찾아줬지만, 주소를 문자열로 비교하던 곳은 못 봤습니다. 문지기가 통째로 멈춰 있었는데도 빌드는 통과했고요.

다음 시간 예고

오늘 우리는 구조를 옮겼습니다. 언어가 주소에 들어갔고, 앱이 세 언어로 그려지고, 문지기도 그걸 알아봅니다.

지금 옮긴 글자는 UI 71개 중 열 개 남짓뿐이고, 나머지는 아직 한국어로 박혀 있어요. 다음 시간에는 그것들을 옮기면서, 제목에도 있었던 그 말의 뒤쪽 절반을 보게 됩니다. 글자만 바꾸면 되는 게 아니라는 것요.

세 가지가 기다리고 있습니다. 첫째로 오늘 마지막에 본 12401,240의 차이 — 날짜와 숫자와 금액은 글자가 아니라서 나라마다 쓰는 방식이 다릅니다. 둘째로 같은 뜻인데 영어가 훨씬 길어지면서 화면이 밀리는 문제. 셋째로 일본어에는 띄어쓰기가 없어서 줄이 다른 데서 끊긴다는 것.

마지막에는 오른쪽에서 왼쪽으로 쓰는 언어를 하나 들여보는데, 그때 우리가 지금까지 쓴 여백 클래스들이 어디서 어긋나는지 보게 될 거예요.


과제

[탐구] 남은 글자가 어디에 얼마나 있는지 세어보세요

오늘 우리는 UI 글자 71개 중 열 개 남짓만 옮겼습니다. 나머지가 어디 있는지 직접 찾아보세요.

  • 파일별로 몇 개씩 남아 있나요? 가장 많은 파일 둘은 어디인가요?
  • 그중에 오늘 배운 방법으로는 못 옮기는 것이 있나요? 힌트로 app/actions/ 폴더를 열어보세요. 거기 있는 오류 메시지들은 화면 안일까요 밖일까요?
  • 옮길 순서를 정한다면 무엇부터 하시겠어요? 그 기준은 뭔가요?

[구현] 없는 언어로 들어가면 어떻게 되는지 확인하고 고치세요

주소 첫 칸에 zzzz 같은 값을 넣어보세요. /zzzz/zzzz/explore 둘 다요.

⚠️ 먼저 로그인부터 하세요. 로그아웃 상태면 문지기가 다른 이유로 먼저 돌려보내서 이 실험이 안 됩니다.

  • 어떤 화면이 뜨나요? 상태 코드는 몇인가요?
  • 그 화면에 머리말이 있나요, 없나요? 왜 그럴까요?
  • 데이터 서버 쪽 기록도 함께 보세요. 그 서버가 무엇을 물어봤나요? 우리가 zzzz를 언어로 넣었는데, 앱은 그걸 무엇으로 알아들었나요?
  • [locale]/layout.tsxnotFound()를 잠깐 빼면 무엇이 달라지나요? 반드시 되돌리세요.

[구현] 언어를 하나 더 늘려보세요

지금 세 언어를 지원합니다. 하나를 더 늘려보세요. 어떤 언어든 괜찮습니다.

  • 고쳐야 하는 파일이 몇 개인가요? 목록을 적어보세요.
  • 빌드 후 미리 그려진 화면이 몇 개가 되나요? 늘기 전과 견줘보세요.
  • 번역 파일에서 칸 하나를 일부러 빼고 빌드해보세요. 빌드가 막아주나요? 그 화면에서는 무엇이 보이나요?
  • 확인이 끝나면 되돌려도 좋고, 마음에 들면 두셔도 됩니다.

[탐구] 문지기를 안 고쳤다면 어디까지 망가졌을까요

Step 5에서 문지기를 고치지 않은 상태를 재현해보세요. stripLocale을 쓰는 세 자리를 원래대로 되돌리면 됩니다.

  • /ko가 몇 번으로 답하나요? 어디로 보내나요?
  • 그 보내는 곳으로 직접 들어가면 어떻게 되나요?
  • 이 상태에서 빌드와 타입 검사는 통과하나요? 통과한다면, 그게 왜 문제일까요?
  • 반드시 되돌리고 다시 확인하세요.

생각해볼 주제

1. 두 번 잰 숫자

Step 1에서 저는 한국어 글자를 두 번 셌습니다. 처음에는 144개, 다시 세니 101개였어요. 처음 것이 주석까지 세고 있었거든요.

만약 제가 다시 안 세고 그냥 갔다면 어떻게 됐을까요. 숫자 자체는 그럴듯했고, 아무도 이상하다고 안 했을 거예요. 그 숫자를 근거로 "작업량이 이만큼이다"라고 계획을 세웠겠죠.

이런 일은 개발에서 아주 자주 일어납니다. 재는 방법이 답을 미리 정해버리는 경우요. 우리는 언제 "다시 세봐야겠다" 고 생각할 수 있을까요? 그 신호를 어디서 찾을 수 있을까요?

2. 조용히 깨지는 것

오늘 두 번 만났습니다. usePathname이 안 맞아서 현재 위치 표시가 사라진 것, 그리고 문지기가 멈춰서 앱 전체가 안 열린 것요. 둘 다 빌드와 타입 검사를 통과했습니다.

시끄럽게 깨지는 것은 사실 다행입니다. 고치지 않으면 진도가 안 나가니까요. 반면 조용히 깨지는 것은 몇 달이고 남아 있을 수 있습니다.

그렇다면 조용히 깨지는 것을 시끄럽게 만들 방법이 있을까요? 오늘 우리가 만난 두 가지 각각에 대해, 어떤 장치를 미리 만들어두면 다음 사람이 같은 곳에서 안 넘어질까요?

3. 늘어나는 것의 값

언어를 주소에 넣으면서 미리 그려지는 화면이 여덟 개에서 스무 개가 됐습니다. 언어가 열 개면 더 늘어나겠죠.

이건 분명히 비용입니다. 빌드가 오래 걸리고 저장 공간을 더 씁니다. 그런데 우리는 그걸 감수하기로 했어요. 사용자가 기다리는 시간으로 치르는 것보다 낫다고 봤으니까요.

이 판단이 항상 옳을까요? 어느 지점부터는 뒤집힐 것 같은데, 그 지점을 무엇으로 알아볼 수 있을까요? 뒤집혔다는 걸 알아차렸을 때 우리에게 어떤 선택지가 남아 있을까요?

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안입니다. 직접 해보신 뒤에 펼쳐서 맞춰보세요.


🎯 [과제 1 예시답안] 남은 글자가 어디에 얼마나 있는지 세어보세요

채점 포인트

항목 배점 확인 내용
주석을 걷어내고 셌는가 6 안 걷으면 두 배 넘게 나온다
파일별 분포를 뽑고 상위를 지목 5 로그인 칸과 가입 처리가 가장 많다
오늘 방법으로 못 옮기는 것을 찾아냄 8 app/actions/는 화면 밖이다
옮길 순서와 그 기준을 제시 6 눈에 많이 띄는 것 · 고치기 쉬운 것 중 무엇을 먼저
합계 25

풀이 예시

Step 1에서 쓴 방법을 그대로 쓰되, 이번엔 파일별로 나눠서 셉니다. 주석을 먼저 걷어내는 것이 핵심이에요.

텍스트
   7  app/components/SignInFields.tsx
   6  app/actions/signup.ts
   4  lib/api.ts
   4  app/components/SignUpFields.tsx
   4  app/components/CommentForm.tsx
   4  app/actions/comment.ts
   ...
  합계 61개 / 22파일

Step 1에서 71개였으니 열 개가 줄었습니다. 오늘 옮긴 만큼이죠.

가장 많은 둘은 로그인 칸과 가입 처리입니다. 폼이 있는 곳에 안내 문구와 오류 메시지가 몰려 있는데, 사용자에게 말을 거는 곳이 폼이라 당연한 결과예요.

오늘 방법으로는 못 옮기는 것

app/actions/ 폴더를 열어보면 이런 것들이 있습니다.

텍스트
 '로그인이 필요해요'
 '댓글을 입력해주세요'
 '백엔드에 신원을 확인받지 못했어요'

이 파일들은 전부 맨 위에 'use server'가 붙어 있어요. 그래서 서버에서 도는 함수이지 화면이 아니에요.

그러니 useTranslations를 못 쓰는데, 이건 Step 6에서 탭 제목을 고칠 때 만난 것과 정확히 같은 상황이에요. 훅은 화면을 그리는 도중에만 살아 있는데, 이 함수들은 그 바깥에서 돕니다.

답은 같습니다. getTranslations를 쓰고 언어를 직접 넘겨야 하는데, 여기서 한 가지가 더 걸립니다. 이 함수들은 언어를 어디서 받을까요? 화면은 주소에서 받았지만, 서버 함수는 주소를 통해 불리는 게 아니거든요.

그 답까지 여기서 낼 필요는 없습니다. "옮길 수는 있는데 언어를 전달할 방법을 따로 정해야 한다"까지 짚었으면 만점이에요.

옮길 순서

정답이 하나는 아닙니다. 다만 기준을 갖고 있는 것이 중요해요. 두 가지를 제안합니다.

눈에 많이 띄는 것부터. 로그인 칸과 가입 칸은 처음 들어온 사람이 반드시 보는 화면입니다. 여기가 한국어로 남아 있으면 영어 사용자는 시작도 못 해요.

한 갈래씩 통째로. 화면 안쪽(components/)을 다 끝내고 나서 서버 함수(actions/)로 넘어가는 편이 낫습니다. 방법이 다르니까요. 섞어서 하면 두 방법을 왔다 갔다 하게 됩니다.

자주 나오는 실수

주석을 안 걷고 세면 144개가 나오는데, 우리 코드베이스는 설명이 전부 한국어라 그렇습니다. 세기 전에 "이 숫자가 재려는 것이 맞나"를 한 번 물어보세요.

lib/posts.ts의 30개를 목록에 넣는 것도 흔한 실수입니다. 그건 게시물 본문이라 번역 대상이 아니에요.

💡 튜터의 한마디 — 국제화 작업의 진짜 크기는 글자 개수가 아니라 글자가 있는 자리의 종류로 정해집니다. 61개가 전부 컴포넌트 안에 있었다면 반나절 일이지만, 서버 함수와 설정 파일에 흩어져 있으면 방법을 몇 가지 준비해야 합니다.


🎯 [과제 2 예시답안] 없는 언어로 들어가면 어떻게 되는지 확인하고 고치세요

채점 포인트

항목 배점 확인 내용
두 주소의 상태 코드를 확인 5 둘 다 404
머리말이 없는 이유를 설명 6 [locale] 밖의 화면이라
데이터 서버 기록에서 진짜 원인을 찾음 8 언어가 아니라 사람 이름으로 알아들었다
notFound()를 빼도 안 바뀌는 것을 확인하고 설명 6 문지기가 먼저 잡아서 도달 불가
합계 25

풀이 예시

로그인한 상태로 들어가봅니다.

텍스트
  /zzzz          404 · 머리말 없음 · "없는 주소예요"
  /zzzz/explore  404 · 머리말 없음 · "없는 주소예요"

404가 나오는 건 예상대로예요. 그런데 머리말이 없습니다. 우리 앱은 모든 화면에 머리말이 있는데도요.

이유는 이 화면이 어디 있는지를 보면 되는데, app/not-found.tsx[locale] 폴더 밖에 있습니다. 머리말은 [locale]/layout.tsx가 그리니, 그 껍데기 밖이면 머리말이 없는 게 맞아요.

데이터 서버가 뭘 물어봤나

여기서부터가 진짜입니다. 데이터 서버 기록을 보면 이렇습니다.

텍스트
 [api-stub] GET /api/users/zzzz
 [api-stub] GET /api/users/zzzz%2Fexplore

우리 앱은 zzzz를 언어로 안 봤습니다. 사람 이름으로 봤어요.

문지기의 stripLocale을 다시 떠올려보세요. 주소 첫 칸이 우리가 아는 언어 목록에 있으면 떼고, 없으면 안 뗍니다. zzzz는 목록에 없으니 안 떼서, 남은 주소가 그대로 /zzzz이고 문지기는 그걸 사람 이름으로 읽습니다.

둘째 줄은 더 재미있죠. zzzz/explore를 통째로 사람 이름으로 보고 물어봤습니다.

결과적으로 404는 맞게 나왔는데, 그런 사람이 없기 때문이죠. 답은 맞지만 이유가 우리가 생각한 것과 다릅니다.

notFound()를 빼보면

껍데기에 이런 걸 넣어뒀었죠.

tsx
if (!hasLocale(routing.locales, locale)) {
  notFound();
}

이걸 주석 처리하고 다시 빌드해서 들어가봤습니다.

텍스트
  /zzzz          404
  /zzzz/explore  404

아무것도 안 달라집니다.

문지기가 먼저 잡기 때문에, 껍데기까지 도달을 못 해 저 줄이 실행될 기회가 없습니다. 지금 우리 앱에서 저 코드는 닿지 않는 코드예요.

그렇다고 지우면 될까요? 저는 두는 쪽을 권합니다. 문지기가 모든 주소를 보고 있는 건 지금 우리 앱 사정이고, 나중에 공개 주소가 늘어나면 문지기를 건너뛰는 길이 생깁니다. 그때 저 줄이 마지막 방어선이 돼요.

자주 나오는 실수

로그아웃 상태로 실험하면 307이 나옵니다. 문지기가 "로그인 안 했네"로 먼저 걸러버려서, 언어 이야기까지 가지도 못해요. 실험 전에 로그인부터 하세요.

💡 튜터의 한마디 — 상태 코드가 기대한 값이라고 안심하면 안 됩니다. 오늘 404는 맞았지만 이유가 달랐는데, 결과만 보면 두 원인이 구별이 안 됩니다. 그래서 서버 기록을 함께 보는 습관이 필요합니다.


🎯 [과제 3 예시답안] 언어를 하나 더 늘려보세요

채점 포인트

항목 배점 확인 내용
고칠 파일을 정확히 셈 5 두 개뿐이다
미리 그린 화면 수의 변화를 확인 6 20 → 24
칸을 빼도 빌드가 안 막는 것을 확인 8 통과한다
그때 화면에 무엇이 뜨는지 확인 6 칸 이름이 그대로 보인다
합계 25

풀이 예시

스페인어를 넣어봤는데, 고친 파일은 두 개뿐이에요.

JSON
// apps/web-next/messages/es.json  (새로 만듦)
{
  "Nav": {
    "brand": "Clon de Instagram",
    "home": "Inicio",
    "explore": "Explorar"
  },
  "Meta": {
    "title": "Clon de Instagram",
    "description": "Instagram reconstruido con Next.js App Router",
    "profileTitle": "@{username} · Clon de Instagram",
    "followers": "{count} seguidores"
  }
}
TypeScript
// apps/web-next/i18n/routing.ts
export const routing = defineRouting({
  locales: ['ko', 'en', 'ja', 'es'],
  defaultLocale: 'ko',
});

이게 전부인데, 화면 코드는 한 줄도 안 건드렸어요. 글자를 코드에서 파일로 옮겨둔 것의 값이 여기서 나옵니다.

빌드하면 미리 그려진 화면이 이렇게 바뀝니다.

텍스트
 언어 3개    20개
 언어 4개    24개

주소마다 한 벌씩 늘어나니 정확히 넷이 늘었습니다.

칸 하나를 빼면

es.json에서 Nav.explore를 지우고 빌드해봤습니다.

텍스트
 빌드    exit 0      막아주지 않는다

통과합니다. 빌드는 번역 파일들을 서로 견줘보지 않아요.

대신 빌드 기록에 이런 줄이 남습니다.

텍스트
 MISSING_MESSAGE: Nav.explore (es)

스페인어 화면을 열어보면 이렇게 나옵니다.

텍스트
 Inicio   Nav.explore

칸 이름이 그대로 화면에 뜨는데, 사용자가 Nav.explore라는 글자를 보게 되는 거예요.

이게 좋은 걸까요 나쁜 걸까요. 저는 나쁘지 않다고 봅니다. 빈칸으로 두거나 다른 언어로 대신 보여주면 아무도 못 알아채니까요. 그런데 칸 이름이 뜨면 최소한 누군가는 이상하다고 신고하죠.

사용자가 그걸 보기 전에 우리가 먼저 알아야 하는데, 방법은 두 가지입니다. 빌드 기록에서 저 줄을 찾아 실패로 처리하거나, 번역 파일 사이의 칸을 서로 견주는 타입을 붙이거나요.

자주 나오는 실수

routing.ts만 고치고 번역 파일을 안 만들면 그 언어 화면 전체가 칸 이름으로 채워집니다. 빌드는 여전히 통과하고요.

💡 튜터의 한마디 — 언어를 늘리는 데 파일 두 개면 된다는 건 오늘 한 일이 잘 됐다는 뜻이지만, 늘리기 쉬운 만큼 빠뜨리기도 쉽습니다. 도구가 안 막아주는 자리에는 우리가 장치를 두는 게 맞는데, 지난 시간에 번들 상한을 직접 만들었던 것과 같은 이유입니다.


🎯 [과제 4 예시답안] 문지기를 안 고쳤다면 어디까지 망가졌을까요

채점 포인트

항목 배점 확인 내용
모든 주소가 307 이 되는 것을 확인 6 하나도 안 열린다
보내는 곳이 404 라는 것까지 확인 7 막다른 길이다
빌드와 타입이 통과하는 것을 확인 6 아무도 안 알려준다
왜 도구가 못 잡는지 설명 6 주소를 문자열로 다뤄서
합계 25

풀이 예시

stripLocale을 쓰는 세 자리를 원래대로 되돌리고 띄워봤습니다.

텍스트
 /              404
 /ko            307    /?next=%2Fko
 /en            307    /?next=%2Fen
 /ko/explore    307    /?next=%2Fko%2Fexplore
 /ko/signup     307    /?next=%2Fko%2Fsignup
 /ko/jaehoon    307    /?next=%2Fko%2Fjaehoon

하나도 안 열립니다.

전부 /로 보내는데, 맨 위에서 보시다시피 그 /가 404예요. 로그인하라고 보낸 곳에 로그인 화면이 없으니, 막다른 길인 셈입니다.

데이터 서버 기록도 확인해보세요. 요청이 0건인데, 아무도 화면까지 도달을 못 했다는 뜻이에요.

도구는 뭐라고 하나

텍스트
 빌드         통과
 타입 검사     통과
 린트         통과
 미리 그리기   스무 개 정상

전부 통과합니다. 앱이 완전히 죽었는데 아무 도구도 문제를 안 알려줍니다.

왜 못 잡을까

문지기 코드를 다시 보면 이런 것들이었죠.

TypeScript
const PUBLIC_PATHS = ['/', '/explore', '/signup'];
// ...
if (PUBLIC_PATHS.includes(pathname)) {
const username = pathname.slice(1);

전부 문자열을 다루는 코드입니다. 타입 검사가 볼 수 있는 건 "이게 문자열인가"까지라서, '/ko/explore''/explore'는 둘 다 문자열이고 하나가 다른 하나를 포함하지 않는다는 건 돌려봐야 압니다.

Step 4에서 타입이 <Link href="/">를 잡아준 것과 대조해보면, 그건 주소 타입이었습니다. 어떤 주소가 있는지 목록을 알고 있었던 반면, 문지기의 저 문자열들은 그냥 문자열이라 타입이 볼 근거가 없습니다.

같은 앱 안에서 어떤 주소는 타입이 지켜주고 어떤 주소는 안 지켜주는데, 그 갈림은 값의 중요도가 아니라 그 값이 타입으로 표현되어 있느냐에 달려 있어요.

자주 나오는 실수

되돌린 뒤에 새로 빌드하지 않고 서버만 다시 띄우면 옛 결과가 그대로 나옵니다. 그리고 그걸 보고 "고쳤는데 안 바뀌네"로 오해하게 돼요. 반드시 다시 빌드하세요.

옛 서버가 안 죽고 남아 있는 경우도 흔합니다. 새 서버가 포트를 못 잡고 조용히 죽는데, 브라우저에는 옛 서버가 답하거든요. 포트를 쓰는 프로세스가 정말 없는지 확인하고 띄우세요.

💡 튜터의 한마디 — 오늘 배울 것 중 가장 오래 남았으면 하는 게 이겁니다. 초록불 네 개가 앱이 돈다는 뜻은 아닙니다. 빌드·타입·린트·미리 그리기는 각자 자기가 볼 수 있는 것만 보니, 구조를 바꾼 날에는 반드시 띄워서 눌러보세요.


🤔 [생각해볼 주제 1] 두 번 잰 숫자

문제 상황 요약

한국어 글자를 세는데 처음엔 144개, 다시 세니 101개였습니다. 처음 것이 주석까지 세고 있었던 거예요. 문제는 144라는 숫자가 충분히 그럴듯해서 아무도 이상하다고 안 했을 거라는 점입니다.

튜터의 가이드 및 해설

이런 일이 왜 자주 일어나는지부터 봅시다. 우리는 보통 답이 대충 어느 정도일지 모르는 상태에서 재는데, 알면 잴 이유가 없기 때문이죠. 그러니 나온 숫자가 맞는지 틀린지 견줄 기준이 없습니다.

숫자 자체를 의심하는 건 잘 안 됩니다. 그래서 재는 방법을 의심하는 것이 실용적이에요. 세 가지를 권합니다.

첫째, 재려는 것을 한 문장으로 적어보세요. 저는 "화면에 나갈 수 있는 한국어"를 재려고 했는데, 실제로 짠 코드는 "한국어가 있는 줄"을 셌어요. 문장과 코드가 안 맞으니, 적어두면 이 어긋남이 보입니다.

둘째, 극단을 하나 넣어보세요. 한국어 주석만 잔뜩 있고 화면 글자는 하나도 없는 파일을 만들어서 세보면 되는데, 0이 나와야 하는데 큰 숫자가 나오면 방법이 틀린 거예요. 이건 테스트를 쓰는 것과 같은 발상입니다.

셋째, 다른 방법으로 한 번 더 재보세요. 저는 파일별로 나눠서 다시 셌는데, 그때 lib/posts.ts 하나가 30개로 튀는 게 보였습니다. 총계만 봤으면 못 봤을 텐데, 나눠서 재면 튀는 것이 보이는 거죠.

이건 세는 일에만 해당하지 않습니다. 속도를 잴 때도, 크기를 잴 때도 같아요. 재는 도구가 대상을 바꿔놓는 경우도 있고, 재는 조건이 답을 정해버리는 경우도 있습니다.

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

"숫자가 틀렸는지는 대개 알 수 없습니다. 그래서 저는 숫자 대신 재는 방법을 검토합니다. 재려는 것을 한 문장으로 적어보고 코드와 맞는지 보고, 답을 아는 극단값을 하나 넣어보고, 총계 말고 나눠서 봅니다. 나눠서 봤을 때 유독 튀는 항목이 있으면 거기서 방법의 결함이 드러나는 경우가 많았습니다."


🤔 [생각해볼 주제 2] 조용히 깨지는 것

문제 상황 요약

오늘 두 번 만났습니다. usePathname이 안 맞아 현재 위치 표시가 사라진 것, 그리고 문지기가 멈춰 앱 전체가 안 열린 것요. 둘 다 빌드·타입·린트를 통과했습니다.

튜터의 가이드 및 해설

먼저 왜 조용한지를 갈라보면, 두 경우가 원인이 다릅니다.

usePathname 쪽은 비교가 실패한 것입니다. 두 문자열이 안 같은 건 오류가 아니라 프로그램 입장에서는 정상 동작이라서, 아무도 안 알려줍니다.

문지기 쪽은 판단이 뒤집힌 것입니다. 공개 주소인데 보호 대상으로 봤는데, 이것도 오류가 아니에요. 문지기는 자기 목록대로 정확히 일했고, 목록이 낡았을 뿐이죠.

공통점은 둘 다 "틀린 값"이 아니라 "틀린 판단"이라는 겁니다. 타입은 값을 보지 판단을 못 봐요.

그럼 어떻게 시끄럽게 만들까요. 각각 다른 장치가 필요합니다.

현재 위치 표시 — 이건 화면을 그려놓고 확인하는 판을 하나 두면 잡히는데, /ko/explore를 그렸을 때 탐색 메뉴에 현재 위치 표시가 붙어 있는지 보는 거예요. 우리는 이미 이런 판을 쓸 줄 압니다. 화면을 그리고 역할과 이름으로 찾아보는 방법요.

문지기 — 이건 화면 하나가 아니라 여러 주소를 훑어야 하는데, 주소 목록을 놓고 각각이 몇 번으로 답하는지 확인하는 표를 만들어두면 됩니다. 오늘 제가 손으로 만든 그 표를 자동으로 돌리는 겁니다.

여기서 한 가지 더. 두 장치의 성격이 다른데, 앞엣것은 화면 하나를 보고 뒤엣것은 주소 여러 개를 봅니다. 서버를 띄워야 해서, 뒤엣것이 더 비싸요.

그러니 전부에 다 붙일 필요는 없습니다. 바꿨을 때 넓게 퍼지는 것에만 붙이면 됩니다. 문지기와 주소 구조가 정확히 그런 것들이에요.

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

"조용히 깨지는 건 대개 틀린 값이 아니라 틀린 판단입니다. 값은 타입이 잡아주지만 판단은 못 잡아요. 두 문자열이 안 같은 건 오류가 아니니까요. 그래서 저는 판단이 걸린 곳 — 주소 매칭이나 권한 검사 같은 곳 — 에는 결과를 확인하는 장치를 따로 둡니다. 다만 비싸기 때문에, 바꿨을 때 영향이 넓게 퍼지는 곳에만 붙입니다."


🤔 [생각해볼 주제 3] 늘어나는 것의 값

문제 상황 요약

언어를 주소에 넣으면서 미리 그려지는 화면이 여덟 개에서 스무 개가 됐습니다. 언어를 하나 더 늘리면 스물넷이 되고요. 빌드 시간과 저장 공간을 쓰는 대신 사용자가 기다리는 시간을 줄인 거래입니다.

튜터의 가이드 및 해설

먼저 이 거래가 왜 대체로 좋은지 짚어보면, 빌드는 한 번 하고 방문은 여러 번 일어납니다. 한 번 더 일해서 만 번의 방문을 빠르게 만드는 거래는 웬만하면 남는 장사예요.

이 계산에는 숨은 조건이 있습니다. 그런데 미리 그려둔 것이 실제로 쓰여야 한다는 것요.

여기서 뒤집히는 지점이 보입니다. 세 가지 신호를 제안합니다.

첫째, 곱해지는 것이 늘어날 때. 지금은 언어만 곱하지만, 나라별 통화, 다크 모드, 로그인 여부 같은 것이 주소에 들어가기 시작하면 곱셈이 됩니다. 언어 10 × 통화 5 면 벌써 50 배라서, 곱셈이 시작되면 다시 봐야 합니다.

둘째, 안 쓰이는 것의 비율이 커질 때. 언어 스무 개를 지원하는데 방문의 95%가 두 언어에 몰려 있다면, 나머지 열여덟 언어를 미리 그려두는 건 거의 다 버려지는 셈인데, 지난 시간에 우리가 "안 쓰는 코드"를 셌던 것과 같은 종류의 질문이에요.

셋째, 내용이 자주 바뀔 때. 미리 그려두면 그 시점 내용으로 굳는데, 내용이 하루에도 여러 번 바뀌는 화면이라면 미리 그린 것이 금방 낡아서 미리 그리는 값어치 자체가 줄어듭니다.

뒤집혔다는 걸 알았을 때 선택지는 세 가지쯤 됩니다.

전부 말고 일부만 미리 그립니다. 방문이 몰리는 언어 두셋만 미리 그리고 나머지는 요청이 올 때 그리는 거예요. 우리가 쓴 목록은 우리가 정하는 것이라 줄일 수 있습니다.

미리 그리는 시점을 옮깁니다. 빌드할 때 다 만드는 대신, 처음 요청이 온 언어를 그때 만들어서 보관하는 방식도 있어요.

주소에서 뺍니다. 정말 곱셈이 감당이 안 되면 일부 조건은 주소가 아니라 다른 곳에서 받아야 하는데, 그러면 오늘 Step 2에서 본 대가를 치르게 되죠. 그때는 그 대가가 지금보다 더 싼 선택일 수 있습니다.

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

"미리 만들어두는 전략은 한 번 만들고 여러 번 쓴다는 전제 위에 서 있습니다. 그래서 저는 그 전제가 깨지는 신호를 봅니다. 조합이 곱셈으로 늘어나는지, 만들어둔 것 중 실제로 쓰이는 비율이 얼마인지, 내용이 얼마나 자주 바뀌는지요. 셋 중 하나라도 나빠지면 전부 미리 만드는 대신 자주 쓰이는 것만 미리 만들고 나머지는 요청 시점으로 미루는 쪽을 검토합니다."

전체 목록 리액트