문서 읽는 데 75분 · D2

D-2: Next.js 16 — 그 사이를 오간다

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

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

지난 시간 마지막에 이렇게 적어뒀습니다.

다음 시간엔 그 사이를 잇습니다. 이으면 재밌는 일이 생겨요. 오늘 만든 껍데기가 화면을 옮겨도 안 다시 그려진다는 것을 그때 직접 재볼 수 있게 됩니다.

약속을 지킬 시간입니다. 안녕하세요, 홍순구입니다.

지금 우리 앱에는 화면이 셋 있죠. 피드, 탐색, 프로필인데, 머리말의 "홈"과 "탐색"은 눌러도 아무 일이 안 일어나서 주소창에 직접 쳐서 다녀야 해요.

오늘은 그 셋을 잇는 것부터 시작하는데, 잇는 방법이 두 가지고 그 선택 하나가 오늘 배울 나머지 전부를 결정합니다.

💡 오늘 수업의 핵심 — "문서를 버리지 않고 옮기면, 남는 것이 생깁니다"

주소를 옮기는 방법은 두 가지예요.

하나는 브라우저에게 "새 문서를 받아와"라고 하는 것인데, 지금까지 주소창에 직접 쳐서 다닌 방식이라 받아온 문서로 화면을 처음부터 다시 짓습니다.

다른 하나는 문서는 그대로 두고 바뀌는 부분만 갈아끼우는 것인데, 이러면 안 바뀐 것들이 살아남아요. 지난 시간에 만든 껍데기도, 쓰던 값도, 화면에 심어둔 것도요.

살아남는 것이 생기니까 그때부터 새로운 질문이 생깁니다. 지금 어디 있는지는 누가 알까? 없는 주소로 가면 뭘 보여주지? 한쪽이 터지면 나머지도 같이 죽어야 하나?

오늘은 그 질문들을 차례로 풉니다.

🎯 학습 목표

  • <Link>로 화면을 잇고, 평범한 앵커와 무엇이 다른지 직접 세어봅니다.
  • 껍데기가 다시 안 그려진다는 것을 세 가지 방법으로 확인합니다.
  • 누르기도 전에 이미 받아와 있다는 것을 네트워크 탭에서 봅니다.
  • 지금 어느 주소에 있는지 알아내 표시하고, 그 과정에서 'use client'를 처음 만납니다.
  • 갈 곳이 미리 안 정해진 이동을 코드로 만듭니다.
  • 없는 주소와 터진 화면에 무엇을 보여줄지 파일 두 개로 정합니다.
텍스트
 오늘의 여정

 1. 머리말을 잇는다          <Link> · 요청을 세어본다
 2. 옮겨도 안 다시 그려진다   심어둔 표시가 살아남는다
 3. 누르기 전에 받아왔다      요청이 시간축에서 앞으로 간다
 4. 지금 어디 있는지          usePathname · 'use client' 첫 등장
 5. 코드가 화면을 보낸다      useRouter · 검색해서 프로필로
 6. 없는 주소                 not-found.tsx · notFound()
 7. 터졌을 때                 error.tsx · 터진 칸만 갈린다

Step 1: "머리말을 잇는다"

지금은 글자일 뿐입니다

app/layout.tsx를 열어보면 머리말이 이렇게 되어 있습니다.

텍스트
 <span className="font-bold">인스타그램 클론</span>
 <span className="text-black/50">홈</span>
 <span className="text-black/50">탐색</span>

<span>이라 눌리지 않아요. 지난 시간에 일부러 그렇게 뒀습니다.

평범한 앵커로 먼저 해봅니다

바꾸는 방법은 알고 계실 겁니다. HTML을 배울 때 링크는 <a href="...">였죠.

먼저 그걸로 해보면서 무슨 일이 일어나는지 정확히 세어볼 거예요.

개발자 도구를 열고 네트워크 탭으로 갑니다. 기록을 한 번 지우고 "탐색"을 눌러보세요.

텍스트
 <a href="/explore"> 를 눌렀을 때

   문서      1 건        2,524 바이트
   스타일    1 건        2,204
   미리받기  1 건        5,863
   스크립트  4 건      127,537
   ─────────────────────────────
   합계      7 건      138,128 바이트

138 킬로바이트가 다시 왔습니다.

여기서 중요한 것은 우리가 이미 갖고 있던 것들이라는 점이에요. 스크립트 4 건은 방금 전 화면에서 이미 받아서 돌리고 있던 바로 그 코드입니다. 그걸 버리고 똑같은 것을 다시 받았어요.

정말 버렸는지 확인해봅시다

말로만 하면 안 믿기니까 직접 심어보죠. 콘솔에 이렇게 치세요.

JavaScript
window.probe = '살아있음';

그리고 "탐색"을 누른 뒤, 다시 콘솔에서 window.probe를 쳐보세요.

텍스트
 undefined

사라졌습니다. 자바스크립트가 통째로 다시 시작된 거예요. React도, 우리가 만든 컴포넌트도, 그 안에 있던 모든 상태도 처음부터 다시입니다.

<Link>로 바꿉니다

Next에는 이걸 위한 물건이 따로 있습니다. next/link에서 가져옵니다.

tsx
// apps/web-next/app/layout.tsx
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';

export const metadata: Metadata = {
  title: '인스타그램 클론',
  description: 'Next.js App Router 로 다시 짓는 인스타그램',
};

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="ko">
      <body className="min-h-screen bg-white text-black antialiased">
        <header className="border-b border-black/10">
          <nav className="mx-auto flex max-w-3xl gap-4 p-4 text-sm">
            <span className="font-bold">인스타그램 클론</span>
            <Link href="/">홈</Link>
            <Link href="/explore">탐색</Link>
          </nav>
        </header>
        {children}
      </body>
    </html>
  );
}

<a href><Link href>가 됐습니다. 속성 이름도 href 그대로예요.

C-1에서 만난 <Link to="/">를 기억하시죠. 하는 일은 같고 속성 이름만 다릅니다. React Router는 to, Next는 href예요. 헷갈리기 좋은 자리라 한 번 짚고 갑니다.

다시 세어봅니다

같은 자리에서 같은 이동을 다시 해보세요.

텍스트
 <Link href="/explore"> 를 눌렀을 때

   누를 때 새로 나간 요청 ─── 0 건

0 건입니다.

이어서 window.probe를 다시 쳐보세요.

텍스트
 '살아있음'

살아 있습니다. 문서를 안 버렸으니까요.

⚠️ 다만 "요청이 아예 없어졌다"는 아닙니다. 누르기 전에 이미 몇 건 나가 있어요. 그게 Step 3의 주제입니다. 지금은 "누를 때 0 건"까지만 봐두세요.

💡 한 줄 정리

<a>는 문서를 버리고 새로 받아오지만, <Link>는 문서를 그대로 두고 바뀌는 부분만 갈아끼웁니다.

🙋 학생 질문 — "튜터님, 그럼 &lt;a&gt;는 이제 안 쓰나요?"

아니에요. 쓸 자리가 분명히 있습니다.

우리 앱 밖으로 나갈 때<a>가 맞아요. 회사 블로그, 깃허브 저장소, 외부 문서 링크 같은 것들이요. 그건 애초에 우리가 갈아끼울 수 있는 화면이 아니니까요.

새 탭으로 열 때도 그렇고, 파일 내려받기 링크도 그렇습니다.

기준은 하나예요. 우리 앱 안에서 옮기는 것이면 <Link>, 밖으로 나가는 것이면 <a>.

참고로 <Link>에 바깥 주소를 넣어도 터지지는 않습니다. Next가 "이건 우리 주소가 아니네"를 알아채고 평범한 앵커로 그려줘요. 그래도 처음부터 <a>로 쓰는 편이 낫습니다. 읽는 사람에게 "여기서부터는 앱 밖"이라는 게 분명히 보이니까요.


Step 2: "옮겨도 안 다시 그려진다"

지난 시간에 못 보여드린 것

지난 시간 마지막 질문에 이렇게 답했었죠. "이 껍데기는 화면을 옮겨도 안 다시 그려집니다. 그런데 오늘은 보여드릴 수가 없습니다." 라고요.

이유는 이제 분명합니다. 보여주려면 문서를 안 버리고 옮기는 방법이 먼저 있어야 했는데, 그게 없었어요. 주소창에 직접 치면 브라우저가 문서를 통째로 다시 받아오니까 껍데기도 당연히 처음부터 다시 그려집니다.

<Link>가 생긴 지금은 잴 수 있습니다.

세 가지로 확인합니다

말로 "안 다시 그려져요" 하면 안 와닿으니까, 눈에 보이는 것을 심어두고 살아남는지 봅시다.

하나 — 화면에 직접 표시를 심어봅니다.

콘솔에 이렇게 치세요. 머리말 요소에 우리만 아는 꼬리표를 붙이는 겁니다.

JavaScript
document.querySelector('header').dataset.probe = '심었음';

둘 — 자바스크립트에도 심습니다.

JavaScript
window.probe = '살아있음';

셋 — 그리고 "탐색"을 누릅니다.

화면이 바뀐 뒤 콘솔에서 둘 다 다시 꺼내보세요.

JavaScript
document.querySelector('header').dataset.probe   // '심었음'
window.probe                                      // '살아있음'

둘 다 그대로입니다.

이게 무슨 뜻인가

첫 번째가 특히 결정적이에요.

우리가 표시를 심은 건 <header>라는 그 요소 하나입니다. 만약 화면을 옮길 때 머리말을 새로 그렸다면 새로 만들어진 요소에는 그 표시가 없겠지만, 표시가 그대로 있다는 건 애초에 새로 안 만들었다는 뜻입니다.

텍스트
 <a> 로 옮길 때

   문서를 버린다
     └ 껍데기도 버려진다
         └ 새 문서에서 처음부터 다시 그린다
             └ 심어둔 표시 사라짐


 <Link> 로 옮길 때

   문서는 그대로
     └ 껍데기도 그대로    손을 안 댄다
         └ 안쪽 화면만 갈아끼운다
             └ 심어둔 표시 그대로

왜 이게 값어치가 있나

지금은 머리말이 글자 몇 개뿐이라 "그래서 뭐?" 싶을 수 있는데, 껍데기에 무엇이 들어가는지 떠올려보세요.

인스타그램이라면 머리말에 검색창이 있고, 알림 아이콘이 있고, 프로필 메뉴가 있습니다. 왼쪽에는 스크롤을 한참 내린 목록이 있을 수도 있고요.

화면을 옮길 때마다 그게 전부 초기화된다면 어떨까요. 검색창에 쓰던 글자가 날아가고, 스크롤이 맨 위로 튀고, 열어둔 메뉴가 닫힙니다.

안 다시 그려진다는 건 그 모든 게 살아 있다는 뜻입니다. Step 4에서 머리말에 진짜 입력칸을 만들 건데, 그때 이걸 한 번 더 눈으로 보실 거예요.

💡 한 줄 정리

<Link>로 옮기면 껍데기 요소 자체를 새로 만들지 않기 때문에, 거기 들어 있던 것들이 그대로 살아남습니다.

🙋 학생 질문 — "프로필 껍데기는요? /jaehoon에서 /minji로 가면요?"

좋은 질문이에요. 나눠서 봐야 합니다.

바깥 껍데기(app/layout.tsx)는 두 주소 모두가 물려받는 것이라 손을 안 댑니다. 그대로예요.

프로필 껍데기(app/[username]/layout.tsx)는 다릅니다. 그 껍데기 안에 @jaehoon게시물 3이 그려져 있잖아요. minji로 가면 그 글자가 @minji로 바뀌어야 하니까 다시 그릴 수밖에 없습니다.

정리하면 이렇습니다. 바뀔 이유가 없는 것은 그대로 두고, 바뀌어야 하는 것만 다시 그린다. 껍데기라고 무조건 안 그리는 게 아니라, 그 껍데기가 지금 이동에서 달라지는지를 보고 정하는 거예요.

/jaehoon에서 /explore로 갈 때는 프로필 껍데기가 아예 안 쓰이니까 통째로 걷어냅니다.


Step 3: "누르기 전에 이미 받아왔다"

Step 1의 "0 건"을 다시 봅니다

아까 <Link>를 눌렀을 때 요청이 0 건이라고 했죠. 조금 이상하지 않나요?

/explore 화면을 그리려면 그 화면에 뭘 그릴지는 알아야 하잖아요. 아무것도 안 받아왔는데 어떻게 그릴까요.

받아온 게 맞지만, 누르기 전에 받아왔어요.

직접 봅시다

/를 열고 네트워크 탭을 켜둔 채로, 아무것도 누르지 말고 몇 초 기다려보세요.

텍스트
 /?_rsc=5CB68i4pnAekjehf                200 바이트
 /explore?_rsc=5CB68i4pnAekjehf         212
 /?_rsc=8nTjltbQWvg6NcbE              1,693
 /explore?_rsc=y5uIUfDQA6olE3D1       1,786
 ────────────────────────────────────────────
                                합계  3,891 바이트

/explore가 있죠. 우리는 아직 안 눌렀는데요.

Next는 <Link>화면에 보이면 그 주소를 미리 받아둡니다. 사용자가 누를지 안 누를지는 모르지만 누를 수도 있으니까 미리 준비해두는 거라서, 눌렀을 때는 이미 손에 있어서 새 요청이 필요 없습니다.

⚠️ 잴 때 조건이 두 개 있습니다. 안 지키면 0 건이 나와서 "미리 받는 거 없는데요?"라는 잘못된 결론이 나요.

  • 몇 초 기다리세요. 화면이 뜬 직후에 바로 재면 아무것도 없습니다. 브라우저가 급한 일부터 끝내고 한가해졌을 때 나가거든요.
  • 그 탭을 앞에 두세요. 미리 받을지 말지는 "그 링크가 화면에 보이는가"로 정해집니다. 탭이 뒤에 가 있으면 아무것도 안 보이는 상태라 판단 자체가 안 돌아요. 다른 탭을 보다가 돌아와서 숫자를 읽으면 안 됩니다.

바이트 숫자는 여러분 화면에서 조금 다를 수 있는데, 앱이 자라면 페이로드도 달라지기 때문입니다. 중요한 건 정확한 숫자가 아니라 자릿수입니다. 138,128과 3,891은 자릿수가 다르죠.

주소 뒤에 붙은 것

?_rsc=라는 게 붙어 있죠. 이건 "문서 말고 화면 조각만 달라"는 표시입니다.

같은 주소인데 붙이는 것에 따라 받는 게 달라져요.

텍스트
 /explore                완전한 HTML 문서
                          <html> 부터 </html> 까지 전부

 /explore?_rsc=...       화면 조각만
                          갈아끼울 부분에 대한 설명

두 번째가 훨씬 작습니다. 껍데기·스타일·스크립트 얘기가 다 빠져 있으니까요.

그래서 진짜 비교는 이렇습니다

Step 1의 표를 정직하게 다시 씁시다.

<a href> <Link href>
누를 때 새 요청 7 건 0 건
누르기 전에 미리 받은 것 0 건 4 건
오간 총 바이트 138,128 3,891
자바스크립트 다시 시작 그대로
껍데기 다시 그림 그대로

요청이 사라진 게 아니라 두 가지가 달라진 겁니다.

하나는 시점이에요. 누를 때가 아니라 그 전에 미리 나갔기 때문에 누르는 순간에는 기다릴 게 없습니다.

또 하나는 . 138 킬로바이트가 3.9 킬로바이트가 됐어요. 35 분의 1입니다. 이미 갖고 있는 것을 다시 안 받으니까요.

공짜는 아닙니다

미리 받는다는 건 안 누를 수도 있는 것을 받는다는 뜻이기도 합니다.

머리말에 링크가 20 개 있고 사용자가 그중 하나만 누른다면, 19 개분은 헛수고예요. 사용자의 데이터 요금이고 서버의 일거리입니다.

Next는 화면에 보이는 것만 미리 받는 식으로 이 낭비를 줄여둡니다. 스크롤을 안 내리면 아래쪽 링크는 안 받아요. 그래도 완전히 없앨 수는 없습니다. 빠르게 느껴지는 대가로 조금 더 받아두는 교환이고, 대부분의 경우 남는 장사라 기본값으로 켜져 있는 거예요.

💡 한 줄 정리

<Link>는 요청을 없앤 게 아니라 누르기 전으로 옮기고 양을 줄인 것이고, 그 대가로 안 누를 수도 있는 것을 미리 받습니다.

🙋 학생 질문 — "미리 받는 걸 끄고 싶으면요?"

<Link>prefetch={false}를 주면 됩니다.

텍스트
 <Link href="/explore" prefetch={false}>탐색</Link>

이러면 누르기 전에는 아무것도 안 받고, 누른 다음에 받습니다. 그만큼 반응이 조금 느려지겠죠.

끌 만한 자리가 있어요. 링크가 아주 많이 늘어서는 목록이라든가, 사용자 대부분이 안 누르는 것이 확실한 링크라든가요.

처음부터 끄고 시작하기보다는, 먼저 켜두고 문제가 측정되면 그때 끄는 순서가 맞습니다. C-8에서 성능을 다룰 때 세운 원칙 그대로예요. 재보지도 않고 최적화라며 끄는 것이 가장 흔한 실수입니다.


Step 4: "지금 어디 있는지"

머리말이 어디 있는지 모릅니다

지금 /explore에 있어도 머리말의 "탐색"은 회색 그대로예요. 지금 보고 있는 화면이 어느 것인지 표시가 안 됩니다.

C-1에서 같은 문제를 풀어봤죠. 거기서는 NavLink라는 물건이 있었습니다. 자기가 가리키는 주소에 와 있으면 isActive로 알려주고, aria-current="page"까지 알아서 붙여줬어요.

Next에는 그런 게 없습니다.

없다고 안 되는 건 아니에요. 재료를 주니까 우리가 조립하면 되는데, 재료는 "지금 주소가 무엇인가" 하나예요.

그런데 그걸 누가 알까요

여기서 잠깐 멈춰야 합니다.

지금까지 우리가 만든 화면들은 서버에서 그려졌습니다. 지난 시간에 소스 보기를 열어서 확인했고, 서버가 HTML을 다 만들어서 보냈지만, "지금 사용자가 어느 주소에 있는지"는 서버가 알 수 있는 게 아닙니다.

사용자가 <Link>를 눌러 돌아다니는 동안 서버는 그 사실을 모르는데, 브라우저 안에서 일어나는 일이니까 이 컴포넌트는 브라우저에서 돌아야 합니다. 그렇게 하려면 파일 맨 위에 한 줄을 씁니다.

텍스트
 'use client'

⚠️ 이 한 줄이 무엇을 가르는지는 다음 시간의 주제 전체입니다. 오늘은 깊이 들어가지 않지만, 조용히 쓰고 넘어가면 안 되는 것이라 짚고 갑니다.

지금 알아둘 것은 딱 이만큼입니다. 훅을 쓰려면 브라우저에서 돌아야 하고, 브라우저에서 돌게 하려면 이 한 줄이 필요합니다. usePathname도 훅이고, useState도 훅이죠. 다음 시간에 "그럼 무엇을 서버에 두고 무엇을 브라우저에 둘 것인가"를 제대로 다룹니다.

머리말을 따로 뺍니다

app/layout.tsx 전체에 'use client'를 붙이면 어떻게 될까요. 그 아래 모든 것이 브라우저로 끌려갑니다. 그건 손해예요.

그래서 머리말만 따로 뺍니다.

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

import Link from 'next/link';
import { usePathname } from 'next/navigation';

const items = [
  { href: '/', label: '홈' },
  { href: '/explore', label: '탐색' },
] as const;

// 지금 어느 주소에 와 있는지는 브라우저만 안다.
// 그래서 이 컴포넌트는 클라이언트에서 돈다.
export function HeaderNav() {
  const pathname = usePathname();

  return (
    <nav className="mx-auto flex max-w-3xl items-center gap-4 p-4 text-sm">
      <span className="font-bold">인스타그램 클론</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'}
          >
            {item.label}
          </Link>
        );
      })}
    </nav>
  );
}

usePathname()이 돌려주는 건 지금 주소의 경로예요. /explore에 있으면 '/explore'입니다.

그걸 각 링크의 href와 비교해서 같으면 켜진 것으로 봅니다. C-1의 isActive를 우리 손으로 만든 셈이에요.

aria-current도 우리가 직접 붙였습니다. C-1에서 NavLink가 자동으로 해주던 것이죠. 그때 이런 이야기를 했었습니다.

도구가 챙겨주는 것과 우리가 챙겨야 하는 것을 구분해 두는 게 중요합니다.

바로 이 자리입니다. 도구가 바뀌면 챙겨주는 목록도 바뀌어요. NavLink가 없어졌으니 낭독기를 위한 표시는 이제 우리 몫입니다. 안 붙여도 화면은 멀쩡해 보이니까 빠뜨리기 쉬운데, 화면 낭독기를 쓰는 사람에게는 굵은 글씨가 전해지지 않는다는 걸 기억하세요.

껍데기에 끼웁니다

tsx
// apps/web-next/app/layout.tsx
export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="ko">
      <body className="min-h-screen bg-white text-black antialiased">
        <header className="border-b border-black/10">
          <HeaderNav />
        </header>
        {children}
      </body>
    </html>
  );
}

이제 눌러보면 켜진 링크가 따라 움직입니다.

폴더가 만든 타입, 여기까지 옵니다

지난 시간에 폴더가 타입을 만든다는 걸 봤죠. PagePropsLayoutProps요. 그런데 <Link href="/explore">의 그 주소는 검사를 받고 있을까요?

기본값으로는 안 받습니다. 켜야 받아요.

TypeScript
// apps/web-next/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  typedRoutes: true,
};

export default nextConfig;

켜고 나서 일부러 틀려봅시다.

텍스트
 <Link href="/explore/foo">
    Type '"/explore/foo"' is not assignable...
     Did you mean '"/explore"'?

잡습니다. 게다가 고칠 주소까지 알려줘요.

그런데 이건 어떨까요.

텍스트
 <Link href="/expolre">      e 와 o 가 바뀐 오타
    통과합니다

통과합니다. 타입이 부실해서가 아니에요.

우리 앱에 app/[username]/이 있으니까 /expolre는 진짜로 있는 주소입니다. expolre라는 이름의 사람 프로필이요.

지난 시간 과제에서 page.tsxPage.tsx로 바꿨더니 /explore@explore 프로필을 그렸던 것 기억하시죠. 같은 이야기입니다. 그때는 화면에서 만났고, 지금은 타입에서 만난 거예요.

⚠️ 한 칸짜리 주소는 우리 앱에서 전부 유효합니다. 타입도 못 잡고 화면도 안 알려주니, 이런 앱을 만들 때 알고 있어야 하는 함정이에요.

💡 한 줄 정리

usePathname으로 지금 주소를 받아 표시를 직접 만들고, 훅을 쓰려면 그 컴포넌트를 브라우저 쪽으로 보내는 한 줄이 필요합니다.

🙋 학생 질문 — "머리말을 브라우저 쪽으로 보냈는데, Step 2에서 확인한 건 아직 맞나요?"

맞습니다. 오히려 이제 더 잘 보여요.

Step 5에서 머리말에 입력칸을 만들 겁니다. 거기에 글자를 좀 쳐 넣고, 제출하지 말고 그대로 "탐색"을 눌러보세요.

텍스트
 입력칸에 '쓰던 값' 을 쳐둔 채로 화면을 옮기면
    옮긴 뒤에도 '쓰던 값' 이 그대로 있습니다

Step 2에서는 우리가 콘솔로 표시를 심어서 확인했죠. 이건 앱이 원래 갖고 있는 상태가 살아남는 겁니다. 지난 시간 마지막에 "스크롤 위치나 입력하던 값도 안 날아갑니다"라고 말했던 게 이거예요.

<a>로 같은 이동을 하면 입력칸이 비워집니다. 직접 해보시면 차이가 확 느껴질 거예요.


Step 5: "코드가 화면을 보낸다"

<Link>로는 안 되는 것

지금까지의 이동은 전부 갈 곳이 미리 정해져 있었습니다. "홈"은 /로 가고 "탐색"은 /explore로 가죠. 코드를 쓸 때 이미 알고 있는 주소예요.

그런데 이런 건 어떨까요. 머리말에 사람 찾는 칸을 만들어서, 이름을 치면 그 사람 프로필로 가게 하는 것.

갈 곳을 미리 못 씁니다. 사용자가 뭘 칠지 모르니까요. <Link href="???">라고 쓸 수가 없어요.

이럴 때 쓰는 것이 있습니다.

useRouter

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

import { useRouter } from 'next/navigation';
import { useState } from 'react';

// 갈 곳이 미리 정해져 있지 않다 — 사용자가 친 글자를 읽어야 정해진다.
// 그래서 <Link> 가 아니라 코드가 보낸다.
export function UserSearch() {
  const router = useRouter();
  const [name, setName] = useState('');

  return (
    <form
      className="ml-auto flex gap-2"
      onSubmit={(event) => {
        event.preventDefault();
        const trimmed = name.trim();
        if (trimmed === '') return;
        router.push(`/${trimmed}`);
        setName('');
      }}
    >
      <input
        aria-label="사람 찾기"
        value={name}
        onChange={(event) => setName(event.target.value)}
        placeholder="사람 찾기"
        className="w-28 rounded border border-black/15 px-2 py-1"
      />
      <button type="submit" className="rounded border border-black/15 px-2 py-1">
        이동
      </button>
    </form>
  );
}

useRouter()를 부르면 옮기는 도구를 받습니다. 그 도구의 push()에 주소를 주면 그리로 갑니다.

C-2에서 만난 useNavigate와 짝이 맞아요. 거기서도 이런 이야기를 했었죠.

<Link>는 사용자가 누르고 navigate()는 코드가 보내며, 끝난 화면은 replace: true로 기록에서 지웁니다.

Next도 똑같습니다. 이름과 모양만 조금 달라요.

하는 일 C-2 (React Router) 지금 (Next)
도구 받기 const navigate = useNavigate() const router = useRouter()
옮기기 navigate('/') router.push('/')
기록 안 남기고 옮기기 navigate('/', { replace: true }) router.replace('/')
왔던 곳으로 navigate(-1) router.back()

C-2에서는 옵션으로 갈랐던 것을 Next는 메서드 이름으로 가릅니다. push는 기록에 새 칸을 쌓고, replace는 지금 칸을 갈아끼워요. 회원가입을 끝낸 뒤처럼 돌아가면 안 되는 화면에서 replace를 쓰는 이유는 C-2에서 이야기한 그대로입니다.

useRouter()를 부른다고 안 옮겨집니다

C-2에서 짚었던 함정이 여기서도 똑같이 적용돼요.

텍스트
 const router = useRouter();    도구를 받는 것뿐. 아무 일도 안 일어난다
 router.push('/explore');       이걸 불러야 옮겨진다

useRouter()는 옮기는 함수를 돌려줄 뿐입니다. 부르는 건 우리 몫이에요.

⚠️ 검색해서 나오는 useRouter는 다른 물건일 수 있습니다

여기가 오늘 가장 조심해야 할 자리입니다.

Next에는 예전 방식의 라우터가 있었습니다. pages/ 폴더에 파일을 두던 시절이요. 지금 우리가 쓰는 app/ 방식이 나오기 전의 것이고, 그때도 useRouter라는 훅이 있었어요.

이름이 똑같습니다. 갈리는 곳은 가져오는 주소 한 칸뿐이에요.

가져오는 주소 지금 우리 앱에서
옛 방식 next/router 안 됩니다
지금 방식 next/navigation 이걸 씁니다

다른 API 들은 이름 자체가 사라져서 쓰면 바로 티가 나는데, 이건 눈으로 보면 맞아 보여서 위험합니다. 검색해서 나온 옛 글의 코드를 붙여 넣으면 useRouter라는 글자는 똑같으니까 의심을 안 하게 되죠.

증상도 헷갈립니다. 옛 주소에서 가져온 것을 app/ 안에서 쓰면 화면이 터지는데, 에러 메시지가 "이건 옛날 거예요"라고 친절히 말해주지 않아요.

⚠️ import 줄부터 보세요. 옛 방식과 지금 방식은 돌려주는 것도 다릅니다. 옛것에는 router.pathnamerouter.query가 붙어 있었는데, 지금은 그 둘이 각각 usePathname()useSearchParams()라는 별도의 훅으로 갈라졌어요. 그러니까 검색 결과에서 router.pathname 같은 게 보이면 그건 옛 글입니다.

이걸 알아보는 게 왜 중요하냐면, 인터넷에 있는 Next 자료의 상당수가 아직 옛 방식으로 쓰여 있기 때문이에요. C-1에서 end 옵션 조언이 예전엔 맞았지만 지금은 아니었던 것처럼, 글이 쓰인 시점을 확인하는 습관이 필요합니다.

눌러봅시다

머리말에 이름을 치고 "이동"을 눌러보세요. jaehoon을 치면 /jaehoon으로 가는데, 이번엔 아무 이름이나 쳐보세요. zzzzz 같은 거요.

텍스트
 @zzzzz
 게시물 0

화면이 뜹니다. 그런 사람은 없는데요.

이게 다음 순서의 주제입니다.

💡 한 줄 정리

갈 곳이 미리 정해져 있으면 <Link>, 코드가 읽어봐야 정해지면 useRouterpush를 씁니다.

🙋 학생 질문 — "그냥 location.href = '/jaehoon' 하면 안 되나요?"

동작은 하지만 그건 Step 1의 <a>와 똑같아요.

브라우저에게 "이 주소로 새 문서를 받아와"라고 말하는 것이거든요. 문서를 버리고, 자바스크립트를 다시 시작하고, 껍데기를 다시 그립니다. 오늘 오전 내내 피한 그 방식이죠.

router.push()는 다릅니다. 문서를 그대로 두고 바뀌는 부분만 갈아끼워요. Step 2에서 심어둔 표시로 확인해보시면 바로 갈립니다.

텍스트
 location.href = '/jaehoon'     심어둔 표시 사라짐
 router.push('/jaehoon')        심어둔 표시 그대로

같은 곳으로 가지만 가는 방법이 다르고, 그 차이가 사용자에게는 깜빡임으로 보입니다.


Step 6: "없는 주소"

두 가지 다른 상황

방금 zzzzz를 쳤을 때 "@zzzzz 게시물 0"이 떴죠. 없는 사람인데 있는 것처럼 보이니 곤란한데, 없는 주소에도 종류가 있어요.

텍스트
 1. 아무 폴더도 안 맡은 주소
    /settings/alarm  ·  /a/b/c
     애초에 그런 화면이 없다

 2. 폴더는 맡았는데 데이터가 없는 주소
    /zzzzz  ·  /p/999
     화면은 있는데 보여줄 게 없다

C-2에서 똑같은 갈림을 만났습니다. 거기서는 "404는 두 종류다"라고 정리했었죠. 주소 표에 아무도 안 맡은 것은 별표 라우트가 받고, 데이터가 없는 것은 loader가 번호를 붙여 던졌습니다.

Next도 둘로 가르는데, 갈라놓은 방식이 조금 다릅니다.

파일 하나가 둘 다 받습니다

tsx
// apps/web-next/app/not-found.tsx
import Link from 'next/link';

// 이 파일 하나가 두 가지를 받는다.
// 1. 아무 폴더도 안 맡은 주소   2. notFound() 를 부른 화면
export default function NotFound() {
  return (
    <main className="mx-auto max-w-xl p-6">
      <h1 className="mb-2 text-xl font-bold">없는 주소예요</h1>
      <p className="mb-4 text-black/60">주소를 다시 확인해주세요.</p>
      <Link href="/" className="underline">
        피드로 돌아가기
      </Link>
    </main>
  );
}

이 파일을 만드는 순간 첫 번째 상황이 해결됩니다. /a/b/c를 열어보세요. 우리가 만든 화면이 뜹니다.

지난 시간 빌드 표에서 우리가 만든 적 없는 /_not-found가 하나 있었죠. Next가 기본으로 준비해둔 자리였는데, 이제 그게 우리 것으로 바뀐 겁니다.

두 번째는 우리가 불러야 합니다

/zzzzz는 이 파일만으로 안 고쳐집니다. Next 입장에서 그 주소는 있는 주소거든요. [username] 폴더가 맡고 있으니까요.

"이 주소는 있지만 보여줄 게 없다"는 건 우리만 압니다. 그러니 우리가 말해줘야 해요.

먼저 아는 사람인지 판단할 방법을 만듭니다.

TypeScript
// apps/web-next/lib/posts.ts
/** 우리가 아는 사람의 목록 — 게시물이 0장인 것과 아예 없는 사람은 다르다 */
const knownUsernames = new Set(allPosts.map((it) => it.username));

export function isKnownUser(username: string): boolean {
  return knownUsernames.has(username);
}

주석에 적은 구분이 중요합니다. 게시물이 0 장인 사람과 아예 없는 사람은 다른 이야기예요. 가입은 했는데 아직 아무것도 안 올린 사람은 프로필이 떠야 맞습니다.

이어서 프로필 껍데기에서 멈춥니다.

tsx
// apps/web-next/app/[username]/layout.tsx
import { notFound } from 'next/navigation';
import { isKnownUser, postsByUsername } from '@/lib/posts';

export default async function ProfileLayout({ children, params }: LayoutProps<'/[username]'>) {
  const { username } = await params;

  // 모르는 사람이면 여기서 멈춘다 — 게시물 0장으로 그리면 안 된다.
  if (!isKnownUser(username)) {
    notFound();
  }

  const posts = postsByUsername(username);

  return (
    <main className="mx-auto max-w-3xl p-6">
      <div className="mb-6 flex items-center gap-4">
        <div className="size-16 rounded-full bg-black/10" />
        <div>
          <h1 className="text-xl font-bold">@{username}</h1>
          <p className="mt-1 text-sm text-black/60">게시물 {posts.length}</p>
        </div>
      </div>
      {children}
    </main>
  );
}

notFound()를 부르면 그 아래는 안 그려지고 not-found.tsx가 대신 뜹니다.

껍데기에서 부른 이유가 있어요. 가짜 정보를 만들어내던 게 바로 이 껍데기거든요. @zzzzz게시물 0을 그리던 곳이요. 그러니 여기서 막아야 합니다.

진짜로 달라졌는지 확인합니다

화면이 바뀐 건 눈으로 보이죠. 그런데 더 중요한 게 있습니다.

주소창에 /zzzzz를 치고 개발자 도구 네트워크 탭에서 그 요청의 상태 코드를 보세요.

텍스트
 /              200
 /explore       200
 /jaehoon       200
 /zzzzz         404       바뀐 곳
 /a/b/c         404

지난 시간 과제에서 /p/999가 200 이었던 것 기억하시죠. "없는 게시물"이라고 화면에는 떴지만 서버는 "정상"이라고 말하고 있어서, 검색 엔진이 그 화면을 목록에 넣는다고 했었고요.

지금은 404라서, 없다고 제대로 말합니다.

덤으로 하나 더 붙어요. 응답의 HTML을 보면 이런 줄이 들어가 있습니다.

텍스트
 <meta name="robots" content="noindex">

"이 화면은 검색 목록에 넣지 마세요"라는 표시인데, Next가 알아서 붙여줘요. 404로 만들었더니 검색 쪽 처리까지 따라온 겁니다.

머리말은 남습니다

C-2와 결정적으로 갈리는 곳이 하나 있어요.

C-2에서 ErrorBoundary가 떴을 때 무슨 일이 있었죠. 머리말이 통째로 사라졌습니다. 그 화면이 Layout이 있던 자리를 대신 차지했기 때문이었어요.

지금은 어떤가요. /zzzzz를 열어보세요.

텍스트
 인스타그램 클론   홈   탐색           [사람 찾기] [이동]
 ─────────────────────────────────────────────────────
 없는 주소예요
 주소를 다시 확인해주세요.
 피드로 돌아가기

머리말이 그대로 있습니다. 검색칸도 살아 있어서 바로 다른 사람을 찾아볼 수 있어요.

not-found.tsx는 바깥 껍데기 안에서 그려지기 때문입니다. 껍데기를 대신하는 게 아니라 껍데기가 감싸고 있는 자식 자리에 들어가요. 그래서 사용자가 막다른 길에 갇히지 않습니다.

💡 한 줄 정리

아무도 안 맡은 주소는 not-found.tsx를 만들어두면 알아서 받고, 있는 주소인데 데이터가 없는 것은 notFound()를 직접 불러 404로 만듭니다.

🙋 학생 질문 — "404 화면에서 소스 보기를 했더니 글자가 하나도 없어요"

잘 보셨어요. 그리고 이상하게 느끼는 게 당연합니다. 지난 시간에 "서버가 이미 글자를 보냈다"를 그렇게 강조했는데 여기서는 안 그러니까요.

두 가지 404를 나란히 놓고 보면 이렇습니다.

텍스트
 /a/b/c    아무도 안 맡은 주소
            소스 보기에 글자가 있습니다

 /zzzzz    notFound() 를 불러서 만든 404
            소스 보기가 비어 있고, 브라우저가 그립니다

첫 번째는 Next가 주소를 훑는 단계에서 이미 알아채요. 그려서 보낼 여유가 있습니다.

두 번째는 화면을 그리다가 도중에 "아, 이건 없는 거네" 하고 멈춘 경우예요. 그리던 것을 중간에 접었으니 완성된 HTML이 없습니다. 그래서 브라우저에게 넘겨서 그리게 해요.

상태 코드와 화면은 둘 다 정상입니다. 404도 제대로 나가고 검색 목록 제외 표시도 붙어요. 검색 엔진이 404를 보면 내용을 읽기 전에 이미 "이건 없는 주소"로 처리하니까 실무에서 문제가 되지도 않지만, "서버가 그려서 보낸다"는 말이 모든 경우에 그렇다는 뜻은 아니라는 것은 알아두시면 좋겠습니다. 언제 서버가 그리고 언제 브라우저가 그리는지가 다음 시간의 주제예요.


Step 7: "터졌을 때"

없는 것과 터진 것은 다릅니다

Step 6에서 다룬 건 "없다" 였어요. 우리가 알고 있고, 예상한 상황입니다.

그런데 예상 못 한 것도 있죠. 데이터를 가져오다 서버가 응답을 안 한다든가, 계산하다 뭔가 잘못된다든가요.

그럴 때 지금은 어떻게 되냐면, 화면 전체가 죽습니다. 하얀 화면이 뜨거나 알 수 없는 에러 화면이 떠요. 머리말도 같이 사라지고요. 사용자는 할 수 있는 게 새로고침밖에 없습니다.

일부러 터뜨려봅니다

확인하려면 터지는 게 있어야 하니 하나 만들어둡시다. 프로필에 팔로워 수를 붙일 건데, 그걸 세어 오는 곳이 가끔 실패하게 해두는 거예요.

TypeScript
// apps/web-next/lib/follower-stats.ts

// 팔로워 수를 세어 오는 곳이다. 진짜 서버는 나중에 붙이고,
// 지금은 "가끔 실패하는 바깥 서버" 를 흉내만 낸다 — 세 번에 한 번만 성공한다.
let attempts = 0;

export function loadFollowerCount(username: string): number {
  attempts += 1;

  if (attempts % 3 !== 0) {
    throw new Error(`팔로워 집계 서버가 응답하지 않습니다 (${username})`);
  }

  return username.length * 137;
}

진짜 서버가 아니라 흉내입니다. 두 시간 뒤에 진짜로 가져올 거예요. 지금은 실패라는 상황을 만들어내는 것이 목적이라 이렇게 둡니다.

프로필 화면에서 씁니다.

tsx
// apps/web-next/app/[username]/page.tsx
import { loadFollowerCount } from '@/lib/follower-stats';
import { postsByUsername } from '@/lib/posts';

export default async function ProfilePage({ params }: PageProps<'/[username]'>) {
  const { username } = await params;
  const posts = postsByUsername(username);
  const followerCount = loadFollowerCount(username);

  return (
    <>
      <p className="mb-4 text-sm text-black/60">팔로워 {followerCount}</p>
      <ul className="grid grid-cols-3 gap-2">
        {posts.map((post) => (
          <li key={post.id} className="aspect-square rounded bg-black/5 p-3 text-sm">
            <p>{post.content}</p>
            <p className="mt-1 text-black/60">좋아요 {post.likeCount}</p>
          </li>
        ))}
      </ul>
    </>
  );
}

/jaehoon을 몇 번 새로고침해보세요. 세 번에 한 번만 제대로 뜨고 나머지는 터집니다.

터진 곳을 받아낼 파일

tsx
// apps/web-next/app/[username]/error.tsx
'use client';

// 이 파일은 자기 구간의 page 와 그 아래를 감싼다.
// 같은 칸의 layout.tsx 는 안 감싸므로 프로필 머리 부분은 그대로 남는다.
export default function ProfileError({
  error,
  retry,
}: {
  error: Error & { digest?: string };
  retry: () => void;
}) {
  return (
    <div className="rounded border border-black/10 p-6">
      <h2 className="mb-2 font-semibold">이 부분을 불러오지 못했어요</h2>
      <p className="mb-4 text-sm text-black/60">
        잠시 뒤 다시 시도해주세요.
        {error.digest ? ` (기록 번호 ${error.digest})` : ''}
      </p>
      <button
        type="button"
        onClick={() => retry()}
        className="rounded border border-black/15 px-3 py-1 text-sm"
      >
        다시 시도
      </button>
    </div>
  );
}

⚠️ 'use client'가 또 나왔죠. 이 파일은 선택의 여지가 없습니다. 터진 것을 붙잡는 일은 브라우저에서만 할 수 있고, "다시 시도" 버튼도 눌려야 하니까요. error.tsx는 언제나 이 줄로 시작합니다.

터진 칸만 갈립니다

이제 프로필을 열어서 실패했을 때 화면을 보세요.

텍스트
 인스타그램 클론   홈   탐색           [사람 찾기] [이동]
 ─────────────────────────────────────────────────────

   (프로필 사진)   @jaehoon
                   게시물 3

   ┌───────────────────────────────┐
   │  이 부분을 불러오지 못했어요  │
   │  잠시 뒤 다시 시도해주세요.   │
   │  [다시 시도]                  │
   └───────────────────────────────┘

바깥 머리말이 살아 있고, 프로필 머리 부분도 살아 있습니다. @jaehoon게시물 3이 그대로예요. 갈린 건 게시물 격자가 있던 자리뿐입니다.

왜 그럴까요. error.tsx자기 구간의 화면과 그 아래를 감싸는데, 같은 칸의 껍데기는 안 감쌉니다.

텍스트
 app/layout.tsx                  머리말         안 감싼다
   └ app/[username]/layout.tsx   프로필 머리     안 감싼다
       └ app/[username]/error.tsx              여기부터 감싼다
           └ app/[username]/page.tsx  게시물 격자

C-2와 정반대죠. 거기서는 에러 화면이 Layout 자리를 대신 차지해서 머리말이 통째로 사라졌습니다. 여기서는 터진 부분만 도려내고 나머지는 멀쩡히 둡니다.

사용자 입장에서 차이가 큽니다. 팔로워 수 하나 못 가져왔다고 프로필 전체가 사라지는 것과, 그 칸만 "못 불러왔어요"로 바뀌는 것은 완전히 다른 경험이에요.

"다시 시도"를 눌러봅시다

에러 화면에 기록 번호가 하나 적혀 있죠. 그 번호를 적어두고 "다시 시도"를 눌러보세요.

텍스트
 처음 화면      기록 번호 1983395149
 1 번 누름      기록 번호 3314094339      번호가 바뀌었다
 2 번 누름      회복 — 팔로워 959, 게시물 3 장

번호가 바뀌었다는 게 핵심입니다. 서버에 다시 물어봤다는 뜻이거든요. 새로 물어봤더니 새 에러가 났고, 그래서 번호도 새것입니다. 두 번째에는 성공해서 화면이 돌아왔고요.

⚠️ 검색하면 나오는 reset은 이제 다른 물건입니다

여기가 오늘 두 번째로 조심할 자리예요.

error.tsx를 검색하면 거의 모든 글이 reset이라는 것을 씁니다. 이렇게요.

텍스트
 export default function Error({ error, reset }) {
   ...
   <button onClick={() => reset()}>다시 시도</button>
 }

reset은 지금도 있습니다. 없어지지 않았어요. 그런데 하는 일이 다릅니다.

우리 코드에서 retryreset으로 바꾸고 똑같이 눌러봤습니다.

텍스트
 처음 화면      기록 번호 1983395149
 1 번 누름      1983395149
 2 번 누름      1983395149
 3 번 누름      1983395149
 4 번 누름      1983395149
 5 번 누름      1983395149

번호가 한 번도 안 바뀌고, 다섯 번을 눌러도 영영 회복이 안 됩니다.

왜 그럴까요. 둘이 하는 일을 나란히 놓으면 이렇습니다.

텍스트
 reset()   에러 표시만 지운다
             └ 서버에는 안 물어본다
                 └ 아까 받은 그 응답으로 다시 그린다
                     └ 그 응답이 에러였으니 또 터진다

 retry()   서버에 다시 물어본다
             └ 새 응답을 받는다
                 └ 그다음에 에러 표시를 지운다
                     └ 이번엔 성공했으면 화면이 돌아온다

서버가 그리다 터진 화면에서 reset()은 아무것도 못 고칩니다. 고칠 재료를 안 가져오니까요. 사용자는 버튼을 눌러도 아무 반응이 없다고 느낍니다.

retry는 최근에 정식으로 자리 잡은 것이라 옛 글에는 안 나오니까, 검색 결과를 그대로 따라 쓰면 눌러도 안 낫는 버튼을 만들게 돼요.

🌟 새 코드에서는 retry를 쓰세요. 기록 번호가 바뀌는지 보면 제대로 동작하는지 바로 확인됩니다.

기록 번호는 왜 있을까

에러 화면에 원래 메시지 대신 번호가 뜬 것도 눈여겨보세요. 우리가 던진 메시지는 "팔로워 집계 서버가 응답하지 않습니다" 였는데 화면에는 안 나옵니다.

일부러 그렇습니다. 서버에서 난 에러 메시지에는 파일 경로나 내부 사정이 담기기 쉬운데, 그걸 그대로 사용자 화면에 보내면 공격자에게 정보를 주는 셈이거든요.

그래서 브라우저에는 번호만 보내고, 진짜 메시지는 서버 기록에 남깁니다. 사용자가 "1983395149 번 에러가 났어요"라고 알려주면 개발자가 그 번호로 서버 기록에서 찾아볼 수 있어요.

💡 한 줄 정리

error.tsx는 자기 구간만 도려내서 나머지 화면을 살려두고, 회복하려면 서버에 다시 물어보는 retry를 써야 합니다.

🙋 학생 질문 — "머리말이 터지면요? 그건 누가 잡나요?"

못 잡습니다. 방금 본 그림 때문이에요.

텍스트
 app/layout.tsx                   여기가 터지면
   └ app/[username]/error.tsx        이 파일은 이미 그 안에 있다

error.tsx는 자기보다 바깥에 있는 껍데기를 못 감쌉니다. 그러니 가장 바깥 껍데기가 터지면 잡아줄 게 없어요.

그걸 위한 파일이 따로 있습니다. app/global-error.tsx예요. 이건 껍데기까지 통째로 대신하는 것이라 <html><body>를 직접 써야 합니다. 지난 시간에 가장 바깥 껍데기에서 그 둘을 직접 썼던 것과 같은 이유죠.

다만 실무에서 이 파일이 뜰 일은 드뭅니다. 가장 바깥 껍데기에는 보통 복잡한 로직을 안 두거든요. 터질 만한 것은 안쪽에 있고, 안쪽은 각자의 error.tsx가 받습니다.

터질 만한 곳 가까이에 받아낼 파일을 두는 것이 요령이에요. 그래야 도려내는 범위가 작아집니다.


마무리

오늘 화면 셋을 이었는데, 이은 것보다 잇는 방법이 더 중요했어요.

텍스트
 문서를 버리고 옮기면            문서를 두고 옮기면
 ──────────────────────────────────────────────────────
 138,128 바이트가 다시 온다      3,891 바이트로 끝난다
 자바스크립트가 다시 시작된다     그대로 돈다
 껍데기를 다시 그린다            손을 안 댄다
 쓰던 값이 날아간다              살아 있다
 지금 어디인지 물을 필요 없다     물어야 한다 — usePathname
 터지면 화면 전체가 죽는다        터진 칸만 갈린다 — error.tsx

오른쪽 줄들이 전부 하나에서 나왔습니다. 문서를 안 버렸다는 것.

살아남는 것이 생기니까 챙길 것도 생겼어요. 지금 어디 있는지 표시해야 하고, 없는 주소와 터진 화면에 무엇을 보여줄지 정해야 합니다. 오늘 그 셋을 파일 두 개와 훅 두 개로 처리했습니다.

오늘 배운 핵심 세 가지

💡 하나 — <Link>는 요청을 없애는 게 아니라 앞당기고 줄입니다. 누를 때 0 건인 이유는 이미 받아뒀기 때문이고, 그 대가로 안 누를 수도 있는 것을 미리 받습니다.

💡 둘 — 껍데기가 살아남으니 상태도 살아남습니다. 심어둔 표시도, 입력하던 값도 그대로예요. 지난 시간에 만든 구조의 값어치가 여기서 회수됐습니다.

💡 셋 — 없는 것과 터진 것은 다르게 다룹니다. 없는 것은 notFound()로 404를 만들고, 터진 것은 error.tsx로 그 칸만 도려냅니다. 둘 다 나머지 화면은 살려둡니다.

다음 시간 예고

오늘 'use client'를 두 번 썼습니다. 머리말에 한 번, 에러 화면에 한 번요. 그때마다 "이게 무엇을 가르는지는 다음에"라고 미뤄뒀죠.

다음 시간에 그걸 정면으로 다룹니다.

지금까지 우리가 만든 컴포넌트들은 사실 기본적으로 서버에서 돌고 있었습니다. 순수 자바스크립트만 하다 React로 넘어온 분들에게 이건 꽤 큰 재배선이에요. "모든 것이 브라우저에서 돈다"는 전제가 깨지거든요.

다음 시간에는 무엇이 어디서 도는지를 가르는 데까지 갑니다. 'use client' 한 줄이 정확히 어디에 선을 긋는지 보고, 좋아요 버튼만 브라우저로 내려보내서 나머지 화면에는 자바스크립트가 아예 안 간다는 것을 숫자로 확인할 거예요.

진짜로 가져오는 건 그다음 시간입니다. 서버에서 도는 컴포넌트에는 지금까지 못 하던 일이 하나 생기거든요. 데이터를 가져오려고 훅을 쓰고 상태를 만들고 할 필요 없이, 컴포넌트 안에서 곧바로 기다렸다 받을 수 있어요. 오늘 흉내만 내둔 팔로워 집계도 그때 진짜로 바꿉니다.


과제

[구현] 프로필로 가는 길을 <Link>

피드와 탐색의 @jaehoon 같은 이름을 눌러서 프로필로 가게 만들어보세요. 지난 시간 과제에서 평범한 앵커로 만들어둔 것이 있다면 그걸 바꾸시면 됩니다.

  • 네트워크 탭에서 요청 수를 세어 지난 시간에 적어둔 숫자와 비교해보세요.
  • 누르기 전에 미리 나가는 요청도 함께 세어보세요. 피드에는 링크가 몇 개인가요?
  • 콘솔에 window.probe = '살아있음'을 심어두고 눌러서, 살아남는지 확인하세요.
  • 탐색 화면은 게시물이 열 장이라 링크가 더 많습니다. 미리 받는 요청 수가 피드와 어떻게 다른가요?

[구현] 되돌아가는 버튼

프로필 화면 위쪽에 "뒤로" 버튼을 만들어보세요.

  • router.back()을 씁니다.
  • ⚠️ 주소를 직접 치고 들어온 사용자에게는 돌아갈 곳이 없습니다. 그때 무슨 일이 일어나는지 직접 확인하고, 어떻게 처리할지 정해보세요.
  • C-2에서 같은 문제를 만났습니다. 그때 어떻게 했는지 떠올려보고, 지금 도구로는 어떻게 할 수 있을지 비교해보세요.

[탐구] 두 가지 404를 갈라보기

/a/b/c/zzzzz는 둘 다 404 지만 Next가 처리하는 과정이 다릅니다.

  • 두 주소의 응답 HTML을 각각 저장해서 비교해보세요. 무엇이 다른가요?
  • 한쪽에는 머리말 글자가 HTML 안에 있고 다른 쪽에는 없습니다. 왜 그럴까요?
  • app/[username]/not-found.tsx를 새로 만들면 /zzzzz는 어느 것을 쓸까요? 만들어보고 확인하세요.
  • 확인이 끝나면 지우고 원래대로 돌려두세요.

[탐구] resetretry를 직접 갈라보기

Step 7에서 본 차이를 여러분 손으로 재현해보세요.

  • error.tsxretryreset으로 바꾸고 다섯 번 눌러보세요. 기록 번호가 어떻게 되나요?
  • retry로 되돌리고 다시 눌러보세요. 몇 번 만에 회복되나요? 매번 같은 횟수인가요?
  • loadFollowerCountattempts % 3% 2로 바꾸면 회복까지 걸리는 횟수가 어떻게 달라질까요? 먼저 예상하고 확인해보세요.
  • 확인이 끝나면 retry% 3으로 돌려두세요.

생각해볼 주제

1. 미리 받는 것이 손해가 되는 자리

Step 3에서 Next가 화면에 보이는 링크를 미리 받아둔다는 것을 봤습니다. 눌렀을 때 기다림이 없어지니까 좋아 보이죠.

그런데 이게 손해인 상황도 분명히 있습니다. 어떤 화면, 어떤 사용자, 어떤 환경에서 그럴까요?

우리 인스타그램 안에서 미리 받는 것을 꺼야 할 자리를 하나 찾아보고, 그 판단을 어떤 숫자로 뒷받침할 수 있을지 생각해보세요. "느낌상 많을 것 같아서"가 아니라 무엇을 재면 결정할 수 있을까요?

2. 없는 사람과 게시물 없는 사람

Step 6에서 isKnownUser를 만들면서 "게시물이 0 장인 사람"과 "아예 없는 사람"을 갈랐습니다. 지금은 게시물 목록에서 이름을 모아 판단하고 있는데, 이 방식에는 구멍이 있습니다. 가입만 하고 아직 아무것도 안 올린 사람은 어떻게 될까요?

지금 코드에서 그 사람은 어떤 취급을 받는지 짚어보고, 진짜 서비스라면 이 판단을 무엇으로 해야 하는지 정리한 뒤, 그 판단을 잘못했을 때 사용자에게 어떤 일이 벌어지는지 두 방향(있는데 없다고 하는 경우 · 없는데 있다고 하는 경우)으로 나눠 생각해보세요.

3. 어디까지 도려낼 것인가

Step 7에서 error.tsx를 프로필 구간에 뒀더니 게시물 격자만 갈리고 프로필 머리는 살아남았습니다. 더 안쪽에 두면 더 작게 도려낼 수 있고, 더 바깥에 두면 크게 도려내겠죠.

작게 도려내는 것이 항상 좋을까요?

화면의 일부만 에러로 바뀌었을 때 사용자가 오히려 혼란스러운 경우를 생각해보세요. 반대로 통째로 죽는 편이 나은 상황도 있을까요? 우리 인스타그램에서 각각 예를 하나씩 찾아보고, 어디에 파일을 둘지 정하는 기준을 한 문장으로 만들어보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 프로필로 가는 길을 &lt;Link&gt;로 (25점)

채점 포인트

항목 배점 설명
이름이 링크가 되고 올바른 주소로 간다 6 /jaehoon처럼 이름이 주소 칸에 들어간다
누를 때 요청 수를 지난 값과 비교했다 6 7 건에서 0 건이 된 것을 확인
미리 받는 요청을 세어봤다 7 숫자와 함께 "무엇을 미리 받았는지" 를 적었다
피드와 탐색의 차이를 설명했다 6 앵커 수와 주소 수가 다르다는 것을 짚었다

풀이 예시

이름을 <Link>로 감싸면 됩니다. 파일 맨 위에 가져오는 것을 잊지 마세요.

tsx
// apps/web-next/app/page.tsx
import Link from 'next/link';
import { feedPosts } from '@/lib/posts';
tsx
<p className="font-semibold">
  <Link href={`/${post.username}`}>@{post.username}</Link>
</p>

탐색 화면도 같은 방식으로 바꿉니다.

세어본 것

피드에서 이름을 눌러보면 요청이 0 건입니다. 지난 시간에 세어둔 7 건과 비교하면 차이가 분명하니까, 이어서 누르기 전도 봅니다. 화면을 열어두고 몇 초 기다린 뒤 네트워크 탭을 보세요.

앵커 수 미리 받은 요청 바이트 미리 받은 주소
/ (피드) 4 8 건 5,100 4 개
/explore (탐색) 12 12 건 7,056 6 개

탐색에서 숫자가 안 맞는 이유

탐색 화면의 앵커는 12 개인데 미리 받은 주소는 6 개뿐입니다. 반으로 줄었죠.

게시물 열 장에 이름이 몇 명이나 나오는지 세어보세요.

텍스트
 jaehoon    3 장  (1 · 5 · 9)
 minji      3 장  (2 · 6 · 10)
 seungwoo   2 장  (3 · 7)
 dahye      2 장  (4 · 8)
 ─────────────────────────
 게시물 10 장 · 사람은 4 명

같은 사람이 여러 번 나와도 주소는 하나입니다. 이미 받아둔 것을 또 받을 이유가 없으니 한 번만 받아요. 거기에 머리말의 //explore를 더해 6 개가 됩니다.

요청 건수가 주소 수의 두 배인 것도 규칙적입니다. 주소 하나당 두 번에 나눠 오거든요. 작은 것 하나와 본체 하나요.

텍스트
 피드    4 주소 × 2 =  8 건
 탐색    6 주소 × 2 = 12 건

자주 나오는 실수

  • 빗금을 안 붙임href={`${post.username}`} 라고 쓰면 지금 주소 뒤에 이어 붙습니다. 탐색에서 누르면 /explore/jaehoon으로 가요.
  • 가져오기를 빼먹음Linkimport 안 하면 빌드가 막습니다. 이건 다행히 바로 알려줘요.
  • 탭을 뒤에 두고 잼 — 다른 탭을 보다가 돌아와서 숫자를 읽으면 미리 받은 요청이 0 건으로 보입니다. 앱이 아니라 재는 방법이 틀린 거예요.
  • 0 건을 "요청이 없어졌다"로 읽음 — 없어진 게 아니라 앞으로 옮겨간 겁니다. 총량은 줄었지만 0은 아니에요.

💡 튜터의 한마디 — 숫자를 그냥 받아 적지 말고 왜 그 숫자인지 계산해보는 게 중요합니다. 탐색의 12 건을 보고 "앵커가 12 개니까 12 건"이라고 넘어갔다면 우연히 맞은 거예요. 실제로는 6 주소 × 2 였고, 앵커 수와 같아진 건 우연입니다. 피드에서는 앵커 4 개에 8 건이라 안 맞죠. 맞는 것 같을 때 한 번 더 세어보는 습관이 실무에서 사람을 살립니다.

🎯 [과제 2 예시답안] 되돌아가는 버튼 (25점)

채점 포인트

항목 배점 설명
router.back()으로 동작한다 6 왔던 곳으로 돌아간다
'use client'가 필요한 이유를 안다 5 훅을 쓰는 컴포넌트다
돌아갈 곳이 없을 때를 확인했다 8 직접 해보고 무슨 일이 나는지 적었다
처리 방법을 정하고 근거를 댔다 6 C-2와 비교하거나 대안을 제시

풀이 예시

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

import { useRouter } from 'next/navigation';

export function BackButton() {
  const router = useRouter();

  return (
    <button
      type="button"
      onClick={() => router.back()}
      className="mb-3 rounded border border-black/15 px-2 py-1 text-sm"
    >
      뒤로
    </button>
  );
}

프로필 껍데기 위쪽에 끼우면 됩니다.

'use client'가 필요한 이유는 Step 4에서 본 것과 같아요. useRouter는 훅이고, 훅은 브라우저에서 도는 컴포넌트에서만 쓸 수 있습니다.

돌아갈 곳이 없으면

새 탭을 열고 주소창에 localhost:3000/jaehoon을 직접 쳐서 들어간 뒤 "뒤로"를 누르면요.

텍스트
 아무 일도 안 일어납니다

에러도 안 나고, 화면도 안 바뀌고, 조용합니다. 브라우저 기록에 되감을 칸이 없으니까요.

이게 고약한 점입니다. 버튼은 멀쩡히 보이는데 안 눌리는 것처럼 느껴져요. 사용자는 자기가 잘못 눌렀나 싶어서 몇 번 더 누릅니다.

C-2에서 같은 문제를 만났고, 그때는 과제로 남겨뒀었죠. 이제 답을 정해봅시다.

처리 방법 세 가지

하나 — 갈 곳을 정해둡니다. 가장 간단하고 실무에서 가장 흔합니다.

tsx
<Link href="/" className="...">피드로</Link>

되돌아가기를 포기하고 그냥 홈으로 보내는 거예요. 어디서 왔든 갈 곳이 확실합니다.

둘 — 돌아갈 곳이 있으면 되감고, 없으면 홈으로.

tsx
onClick={() => {
  if (window.history.length > 1) {
    router.back();
  } else {
    router.push('/');
  }
}}

의도대로 동작하지만 history.length는 우리 사이트 밖의 기록까지 셉니다. 다른 사이트를 보다 우리 링크로 들어온 사람은 길이가 2 이상이라 되감기가 실행되고, 그러면 우리 사이트를 떠나버려요. 완벽하지 않습니다.

셋 — 버튼을 아예 안 보여줍니다. 되돌아갈 데가 없으면 버튼을 숨기는 것도 방법입니다. 다만 화면 요소가 있다 없다 하면 그것대로 어수선해요.

🌟 저라면 첫 번째를 고르겠습니다. "왔던 곳으로"는 사용자가 이미 브라우저 뒤로 가기 버튼으로 할 수 있는 일이에요. 앱 안의 버튼은 갈 곳이 확실한 것을 맡는 편이 덜 헷갈립니다.

자주 나오는 실수

  • 'use client'를 빼먹음 — 훅을 서버 컴포넌트에서 쓰면 빌드가 막습니다. 이건 알려주니 다행이에요.
  • router.back()router.push(-1)로 씀 — C-2의 navigate(-1)이 익숙해서 나오는 실수입니다. Next는 메서드 이름으로 갈라요.
  • 안 눌리는 걸 버튼 문제로 오해 — 기록이 없는 것이지 버튼이 고장 난 게 아닙니다. 새 탭에서 직접 들어가 확인해보면 바로 갈립니다.

💡 튜터의 한마디 — "안 되는 경우"를 직접 만들어본 사람과 안 만들어본 사람의 차이가 여기서 갈립니다. 여러분 개발 환경에서는 늘 링크를 눌러 들어가니까 기록이 항상 있어서, 이 문제가 안 보입니다. 주소를 직접 치고 들어가보는 것을 확인 목록에 넣어두세요. 검색으로 들어온 사용자, 링크를 공유받은 사용자가 전부 그 상태입니다.

🎯 [과제 3 예시답안] 두 가지 404를 갈라보기 (25점)

채점 포인트

항목 배점 설명
두 응답의 차이를 찾았다 8 한쪽 HTML에 글자가 없다는 것을 확인
왜 다른지 설명했다 7 그리다 멈춘 것과 처음부터 안 그린 것
구간 전용 파일을 만들어 확인했다 6 어느 것이 뜨는지 직접 봤다
결과가 예상과 다른 것을 짚었다 4 가까운 쪽이 이길 거라 예상했다면

풀이 예시

터미널에서 두 응답을 받아 비교하면 빠릅니다.

Bash
curl -s http://localhost:3000/a/b/c    > case1.html
curl -s http://localhost:3000/zzzzz    > case2.html

무엇이 다른가

파일 맨 앞줄부터 다릅니다.

텍스트
 /a/b/c     <html lang="ko">          우리가 껍데기에 쓴 그것
 /zzzzz     <html id="__next_error__">

그리고 안을 들여다보면 이렇습니다.

/a/b/c /zzzzz
상태 코드 404 404
HTML에 머리말 글자 있음 없음
HTML에 "없는 주소예요" 있음 없음
스타일시트 연결 있음 없음
브라우저에서 본 화면 정상 정상

/zzzzz는 서버가 보낸 HTML이 거의 비어 있는데, 브라우저에서 열면 멀쩡히 나옵니다. 브라우저가 뒤이어 받은 데이터로 그리기 때문이에요.

왜 갈릴까

Next가 그 주소를 처리하는 시점이 다릅니다.

텍스트
 /a/b/c
   주소를 훑는다  맡은 폴더가 없다  없는 주소 화면을 그린다  보낸다
                                      └ 그릴 여유가 있다


 /zzzzz
   주소를 훑는다  [username] 이 맡았다  프로필을 그리기 시작한다
                                           도중에 notFound() 를 만난다
                                           그리던 것을 접는다
                                             └ 완성된 HTML 이 없다

첫 번째는 그리기 전에 알았고, 두 번째는 그리다가 알았습니다. 그리다 접었으니 내보낼 완성품이 없어서 브라우저에게 넘기는 거예요.

지난 시간에 "서버가 이미 글자를 보냈다"를 배웠는데 여기서는 아니죠. 그 말이 모든 화면에 해당하는 건 아니라는 것이 이 과제의 핵심입니다.

실무에서 문제가 되지는 않습니다. 검색 엔진은 404를 보면 내용을 읽기 전에 이미 "없는 주소"로 처리하거든요. 게다가 두 경우 다 검색 목록에서 빼달라는 표시가 붙습니다.

구간 전용 파일을 만들면

app/[username]/not-found.tsx를 만들고 /zzzzz를 열어보세요.

텍스트
 예상   프로필 구간에 있는 것이 더 가까우니까 그게 뜬다
 실제   루트의 app/not-found.tsx 가 뜬다

가까운 쪽이 지는 결과입니다.

이유는 우리가 notFound()를 껍데기에서 불렀기 때문이에요.

텍스트
 app/not-found.tsx                        받는 것은 여기
   └ app/[username]/layout.tsx               notFound() 를 부른 곳
       └ app/[username]/not-found.tsx        이미 그 안에 있다

app/[username]/not-found.tsx는 그 껍데기가 감싸고 있는 자식입니다. 껍데기가 스스로 멈췄는데 그 안쪽에 있는 것이 대신 그려질 수는 없으니, 한 칸 위로 올라갑니다.

notFound()page.tsx로 옮겨서 다시 해보면 이번엔 구간 전용 파일이 뜹니다. 부른 곳이 한 칸 안쪽으로 들어갔으니까요.

규칙은 하나입니다. 부른 곳보다 바깥에 있는 것 중 가장 가까운 파일이 받는다.

자주 나오는 실수

  • 상태 코드를 안 봄 — 화면만 보면 두 경우가 똑같아 보입니다. 다른 것은 서버가 보낸 HTML이에요.
  • 소스 보기가 비어 있어서 "고장 났다" 고 판단 — 상태 코드와 브라우저 화면을 함께 보면 정상입니다.
  • 되돌리기를 잊음 — 만든 app/[username]/not-found.tsx를 지워야 다음 실습이 헷갈리지 않습니다.

💡 튜터의 한마디 — "가까운 것이 이긴다"는 대개 맞는 직관이라 여기서 뒤통수를 맞습니다. 그런데 뒤집어보면 규칙 자체는 아주 단순해요. 받아주는 쪽은 터진 곳을 감쌀 수 있어야 한다. 안쪽에 있는 것은 바깥이 무너질 때 같이 무너지니까 못 받습니다. 다음 순서의 error.tsx도 똑같은 규칙을 씁니다. 두 파일을 따로 외우지 말고 이 한 문장으로 묶어두세요.

🎯 [과제 4 예시답안] resetretry를 직접 갈라보기 (25점)

채점 포인트

항목 배점 설명
reset으로 회복이 안 되는 것을 확인 8 기록 번호가 안 바뀐다는 것까지
retry로 회복되는 것을 확인 5 번호가 매번 바뀐다
회복까지 걸리는 횟수가 일정하지 않음 6 왜 그런지 설명
% 2를 먼저 예상하고 확인했다 6 예상과 결과를 둘 다 적었다

reset으로 바꿔보면

텍스트
 처음 화면      기록 번호 1983395149
 1 번 누름      1983395149
 2 번 누름      1983395149
 3 번 누름      1983395149
 4 번 누름      1983395149
 5 번 누름      1983395149

한 번도 안 바뀌고, 영영 회복되지 않습니다.

번호가 그대로라는 게 결정적인 증거예요. 서버에 다시 물어봤다면 새 에러가 났을 테고 번호도 달라졌을 것이니까요. 안 바뀌었다는 건 아예 안 물어봤다는 뜻입니다.

reset()이 하는 일은 "에러가 났다"는 표시를 지우는 것뿐이에요. 지우고 나면 다시 그리는데, 그릴 재료가 아까 받은 그 에러 응답 그대로입니다. 그러니 또 터지고, 또 같은 번호가 뜹니다.

retry로 되돌리면

텍스트
 처음 화면      1983395149
 1 번 누름      3314094339    번호가 바뀌었다
 2 번 누름      회복

번호가 바뀐다는 건 서버에 다시 다녀왔다는 뜻입니다. 새로 물어봤더니 이번에도 실패했고, 그래서 새 번호가 붙었어요. 두 번째에 성공해서 화면이 돌아왔습니다.

회복까지 걸리는 횟수

매번 같지 않습니다. 여러 번 해보면 한 번에 되기도 하고 두 번, 세 번 걸리기도 해요.

우리가 만든 규칙을 보면 이유가 나옵니다.

텍스트
 attempts % 3 !== 0 이면 실패

 1 실패 · 2 실패 · 3 성공 · 4 실패 · 5 실패 · 6 성공 · ...

성공은 3의 배수 차례에만 옵니다. 그런데 화면에 처음 들어왔을 때 몇 번째 차례였는지는 우리가 모르잖아요. 새로고침도 한 번씩 세고 있고요.

  • 지금이 2 번째 차례였다면 → 한 번만 눌러도 성공
  • 지금이 1 번째 차례였다면 → 두 번 눌러야 성공

그래서 들쭉날쭉합니다. 진짜 서버도 이렇습니다. 언제 회복될지 모르니까 "다시 시도"를 주는 거예요.

% 2로 바꾸면

예상: 세 번에 한 번 성공하던 것이 두 번에 한 번 성공으로 바뀌니까, 회복까지 걸리는 횟수가 줄어든다.

텍스트
 % 3    1 실패 · 2 실패 · 3 성공       최대 2 번 눌러야 함
 % 2    1 실패 · 2 성공               최대 1 번 누르면 됨

확인: 예상대로입니다. 눌렀다 하면 거의 바로 회복돼요.

여기서 한 가지 더 눈여겨볼 것이 있습니다. % 2로 두면 실패 확률이 절반이라 화면에 처음 들어갈 때 성공하는 경우도 절반이라서, 에러 화면을 보려고 새로고침을 몇 번 해야 할 수도 있어요. 실험하기가 오히려 번거로워집니다.

자주 나오는 실수

  • 번호를 안 보고 화면만 봄resetretry도 "안 낫는다"로 보일 때가 있습니다. 운 나쁘게 retry가 연속 실패하면요. 번호를 봐야 갈립니다.
  • 한 번 눌러보고 판단retry도 한 번에 안 나을 수 있어요. 서너 번 눌러보고, 그동안 번호가 바뀌는지를 보세요.
  • 되돌리기를 잊음retry% 3으로 돌려놓으세요.

💡 튜터의 한마디 — 이 과제의 진짜 교훈은 resetretry의 차이가 아니라, "눌러도 안 낫는 버튼"을 만들어놓고 모를 수 있다는 것입니다. 검색으로 나온 코드를 붙여 넣고, 개발할 때는 에러가 안 나니까 눌러볼 일이 없고, 배포하고 나서야 사용자가 헛손질을 합니다. 눈에 보이는 증거(기록 번호)를 하나 찾아두고 그걸로 확인하는 습관이 이런 종류의 버그를 잡아요.


🤔 [생각해볼 주제 1] 미리 받는 것이 손해가 되는 자리

문제 상황 요약

<Link>가 화면에 보이면 Next는 그 주소를 미리 받아둡니다. 누를 때 기다림이 없어지니 좋아 보이지만, 안 누를 수도 있는 것을 받는 것이기도 합니다.

튜터의 가이드 및 해설

손해가 커지는 조건은 세 가지가 겹칠 때입니다.

하나, 링크가 많다. 화면에 링크가 50 개 있으면 50 개를 미리 받습니다. 우리 탐색 화면에서 게시물이 10 장일 때 6 주소를 받았죠. 게시물이 1,000 장이 되면 어떻게 될지 생각해보세요.

둘, 누르는 비율이 낮다. 목록을 훑기만 하고 하나도 안 누르는 사용자가 많다면 받아둔 것이 통째로 낭비입니다.

셋, 받아오는 것이 무겁다. 주소 하나에 3 킬로바이트면 대수롭지 않지만, 화면마다 큰 데이터가 딸려 오면 이야기가 달라집니다.

우리 인스타그램에서 꺼야 할 자리를 하나 고른다면 무한 스크롤로 늘어나는 탐색 화면입니다. 스크롤할수록 링크가 계속 늘고, 사용자는 눈으로 훑다가 마음에 드는 하나만 누르거든요. 조건 셋이 다 맞습니다.

그럼 무엇을 재야 결정할 수 있을까요. "많을 것 같아서"는 근거가 아닙니다. 잴 수 있는 것들이 있어요.

  • 미리 받은 주소 수 대비 실제로 누른 수. 이게 낮을수록 낭비가 큽니다. 100 개 받아서 1 개 누르면 99% 가 헛것이에요.
  • 미리 받기가 차지한 전송량. 전체 대비 몇 퍼센트인지 보면 규모가 잡힙니다.
  • 끄고 났을 때 느려진 정도. 이게 핵심입니다. 껐더니 체감이 확 나빠진다면 그 낭비는 값을 한 거예요.

세 번째를 빼먹으면 안 됩니다. 낭비만 재고 끄면 사용자 경험을 깎아놓고 "최적화했다" 고 말하게 되거든요. 얻은 것과 잃은 것을 같이 재야 결정이 되는데, 모바일 데이터 환경도 따로 봐야 합니다. 와이파이에서는 티도 안 나는 낭비가 데이터 요금제에서는 실제 비용이에요. 사용자가 어디서 쓰는지에 따라 답이 갈립니다.

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

"미리 받기를 끌지 말지는 낭비만 보고 정하면 안 됩니다. 받아둔 것 대비 실제 클릭 비율로 낭비를 재고, 껐을 때 체감이 얼마나 나빠지는지를 함께 재서 비교합니다. 한쪽만 재고 끄면 그건 최적화가 아니라 기능을 깎은 겁니다."

🤔 [생각해볼 주제 2] 없는 사람과 게시물 없는 사람

문제 상황 요약

isKnownUser를 게시물 목록에서 이름을 모아 만들었습니다. 게시물이 있는 사람만 "있는 사람"으로 판정되는 구조입니다.

튜터의 가이드 및 해설

지금 코드에서 가입만 하고 아무것도 안 올린 사람은 404를 받습니다. 분명히 존재하는 사람인데 "없는 주소"라는 말을 듣는 거예요.

이건 데이터를 어디서 가져왔느냐 때문에 생긴 문제입니다. 우리는 게시물 목록에서 사람을 유추했어요. 게시물이 사람의 존재 증거라고 가정한 셈인데, 그 가정이 틀렸습니다.

진짜 서비스라면 판단 근거는 회원 목록이어야 합니다. 게시물이 아니라요. 사람이 있는지는 사람 데이터에 물어보는 게 맞습니다.

텍스트
 지금       게시물 목록에서 이름을 모은다      게시물 0 장이면 없는 사람
 제대로     회원 목록에 그 이름이 있나 본다    게시물 수와 무관

D-4에서 서버 컴포넌트로 데이터를 진짜로 가져오기 시작하면 이 구분이 자연스럽게 잡힙니다. 회원을 조회하는 곳과 게시물을 조회하는 곳이 갈리거든요.

틀렸을 때 무슨 일이 생기는지 두 방향으로 봅시다.

있는데 없다고 하는 경우 — 지금 우리 상태입니다. 갓 가입한 사람이 자기 프로필 링크를 친구에게 보냈는데 친구가 404를 봅니다. 본인이 자기 프로필에 들어가도 404예요. 신규 사용자가 가장 먼저 겪는 경험이 이것이라면 그 서비스는 거기서 사용자를 잃습니다. 게다가 검색 엔진에도 "없음"으로 알려지고, 나중에 게시물을 올려서 정상이 되어도 이미 목록에서 빠진 뒤예요.

없는데 있다고 하는 경우 — D-1 과제에서 본 그 상황입니다. 아무 이름이나 쳐도 "@zzzzz 게시물 0"이 떴죠. 이쪽은 더 조용하게 나쁩니다. 오타를 친 사용자가 "이 사람 아직 아무것도 안 올렸구나"로 오해하고 넘어가요. 없는 프로필 페이지가 검색 목록에 무한히 쌓이는 문제도 생깁니다.

어느 쪽이 더 나쁠까요. 저는 첫 번째라고 봅니다. 두 번째는 잘못된 정보를 보여주는 것이고, 첫 번째는 정당한 사용자를 막는 것이거든요. 다만 둘 다 고쳐야 하는 문제이고, 고치는 방법은 하나입니다. 존재 여부를 존재를 아는 곳에 물어보는 것.

한 가지 더. 없는 사람과 차단·탈퇴한 사람도 갈라야 합니다. 탈퇴한 사람의 프로필은 "없음" 일까요, "탈퇴한 사용자" 일까요. 실제 서비스는 이 상태를 따로 두고 다른 화면을 보여줍니다.

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

"존재 여부는 존재를 아는 데이터에 물어봐야 합니다. 게시물 목록으로 회원 존재를 판정하면 게시물 0 장인 신규 회원이 404를 받습니다. 신규 사용자가 가장 먼저 겪는 화면이 그것이라면 데이터 설계가 사용자를 쫓아내고 있는 겁니다."

🤔 [생각해볼 주제 3] 어디까지 도려낼 것인가

문제 상황 요약

error.tsx를 어느 폴더에 두느냐로 도려내는 범위가 정해집니다. 안쪽에 둘수록 작게 도려내고, 바깥에 둘수록 크게 도려냅니다.

튜터의 가이드 및 해설

작게 도려내는 것이 항상 좋지는 않습니다. 화면이 반쯤 살아 있는 것이 오히려 위험한 경우가 있어요.

작게 도려내는 게 맞는 자리 — 그 부분이 없어도 나머지가 혼자서 말이 되는 곳입니다. 우리가 만든 팔로워 수가 딱 그래요. 팔로워 수를 못 가져와도 게시물은 볼 수 있고, 프로필이 누구 것인지도 알 수 있습니다. 사용자가 원래 하려던 일에 지장이 없어요.

추천 목록, 광고 영역, 관련 게시물, 실시간 알림 개수 — 전부 같은 성격입니다. 없으면 아쉽지만 화면의 목적을 망치지는 않아요.

통째로 죽는 편이 나은 자리 — 남은 부분이 거짓말을 하게 되는 곳입니다.

우리 인스타그램에서 예를 찾자면 프로필의 비공개 여부입니다. 어떤 계정이 비공개인지 아닌지를 못 가져왔다고 칩시다. 그 칸만 에러로 두고 게시물 격자는 그대로 그린다면, 비공개여야 할 게시물이 노출됩니다. 화면은 멀쩡해 보이는데 보면 안 되는 것을 보여주고 있어요.

결제 금액도 마찬가지입니다. 상품 목록은 떴는데 총액 칸만 "불러오지 못했어요" 라면, 사용자는 얼마인지 모르는 채로 결제 버튼을 누르게 되니, 기준은 크기가 아니라 의미입니다.

그 칸이 없어졌을 때 남은 화면이 여전히 참인가. 참이면 작게 도려내고, 거짓이 되면 통째로 죽인다.

크기로 정하면 틀립니다. 작은 칸 하나가 화면 전체의 의미를 결정하는 경우가 있고, 큰 영역이 없어도 아무 문제 없는 경우가 있어요.

하나 덧붙이면, 작게 도려낼 때는 도려낸 자리가 눈에 띄어야 합니다. 회색 상자 하나만 조용히 놓여 있으면 사용자가 못 알아채고 "팔로워가 없나 보다"로 읽어버려요. 우리가 만든 화면에 "불러오지 못했어요"라고 명확히 쓴 이유가 그것입니다. 빈칸과 실패는 다르게 보여야 합니다.

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

"에러 경계는 화면 크기가 아니라 의미로 나눕니다. 그 부분이 사라졌을 때 남은 화면이 여전히 참이면 작게 도려내고, 거짓이 되면 통째로 죽입니다. 비공개 여부를 못 불러온 프로필에서 게시물만 그리면 화면은 멀쩡해 보이지만 보안 사고입니다."

전체 목록 리액트