C-2: React Router 8 — 주소가 데이터를 데려온다
목차 63
친구가 링크를 하나 보냈습니다.
https://www.instagram.com/p/3021847265
눌러서 들어가면 게시물 하나가 화면에 뜹니다. 앱을 처음 켜는 것도 아니고, 피드를 훑다가 찾은 것도 아니에요. 주소 하나만 들고 곧장 그 게시물로 들어갔습니다.
이 짧은 동작 안에 오늘 배울 것이 전부 들어 있습니다. 주소에 적힌 3021847265를 누가 읽었을까요, 그 번호로 게시물을 어디서 가져왔을까요, 가져오는 동안 화면에는 무엇이 떠 있었을까요, 없는 번호였다면 무엇이 떴을까요.
안녕하세요, 홍순구입니다. 지난 시간에 우리는 화면을 주소로 갈랐죠. /와 /signup이 생겼고, 링크로 오갈 수 있게 됐고, 머리말은 한 번만 그려집니다.
지난 시간에 만든 주소는 둘 다 미리 정해진 글자였는데, 오늘부터는 주소가 값을 나르면서 그 값이 데이터를 데려오기 시작합니다.
💡 오늘 수업의 핵심 — "주소는 이름표이면서 동시에 지시문이다"
지난 시간의 주소는 이름표였습니다. 이 화면의 이름은 /signup 이다, 그게 전부였죠.
오늘부터 주소는 지시문이 됩니다. /p/7은 "게시물 화면을 띄워라"가 아니라 "7번 게시물을 가져와서 띄워라"입니다. 명령에 목적어가 붙은 거예요.
목적어가 붙으면 새로운 문제가 따라옵니다. 가져오는 데 시간이 걸리고, 없는 번호일 수도 있고, 가져오다 실패할 수도 있어요. 오늘은 그 셋을 차례로 해결합니다.
🎯 학습 목표
- 주소의 일부를 값으로 받아내고(
:postId·useParams), 그 값의 타입이 왜 약한지 이해합니다. - 코드가 화면을 옮기고(
useNavigate), 브라우저 기록에 무엇을 남길지 고릅니다(replace). - URL이 화면 상태를 담는 그릇이 될 수 있다는 것을
useSearchParams로 확인합니다. - 데이터를 화면이 뜨기 전에 부르는 방법(
loader·useLoaderData)을 만들고, 화면이 뜬 뒤에 부르던 것과 나란히 비교합니다. - 실패를 화면 대신 받아내고(
ErrorBoundary·useRouteError), 두 종류의 404를 갈라 처리합니다. - 모달이 주소를 가져야 하는지 직접 결정하고 그렇게 만듭니다.
오늘의 여정
1. 주소의 일부를 값으로 /p/:postId 와 useParams
↓
2. 코드가 화면을 보낸다 useNavigate 와 replace
↓
3. URL 도 상태의 한 갈래 useSearchParams 로 태그 거르기
↓
4. 화면이 먼저, 데이터가 나중 지금 방식의 고통을 직접 본다
↓
5. 뜨기 전에 부른다 loader 와 useLoaderData
↓
6. 던진 것을 좁혀 읽는다 ErrorBoundary 와 useRouteError
↓
7. 404 는 두 종류다 별표 라우트와 던지는 404
↓
8. 모달은 주소를 가져야 할까 미뤄둔 질문에 답한다
Step 1: "주소의 일부를 값으로 받는다"
지난 시간 마지막에 이런 주소를 봤죠.
/p/3021847265 게시물 하나
/jaehoon 누군가의 프로필
그리고 질문을 하나 남겨뒀습니다. 게시물이 백만 개면 주소 표에 백만 줄을 적어야 할까요.
당연히 아닙니다. 그런데 "당연히 아니다"를 코드로 어떻게 옮길지가 관건인데, 우리가 지금까지 적어온 주소는 전부 고정된 글자였어요.
{ path: 'signup', Component: SignUpPage }
signup이라는 다섯 글자가 정확히 맞아야 이 화면이 뜹니다. signu도 안 되고 signups도 안 돼요.
콜론을 붙이면 글자가 아니라 빈칸이 됩니다
React Router는 주소 조각 앞에 콜론(:)을 붙이면 그 칸을 아무 글자나 받는 빈칸으로 봅니다. 이렇게 콜론으로 표시한 조각을 정식 용어로 동적 세그먼트(dynamic segment)라고 부르는데, 이 교안에서는 계속 "빈칸"이라고 편하게 부를게요.
path: 'p/:postId'
/p/1 매칭 ✅ postId = "1"
/p/3021847265 매칭 ✅ postId = "3021847265"
/p/jaehoon 매칭 ✅ postId = "jaehoon"
/p 매칭 ❌ 빈칸이 비어 있으면 안 된다
/p/1/comments 매칭 ❌ 조각이 하나 더 붙었다
빈칸이라 뭐든 받습니다. 숫자만 받는 게 아니에요. 이건 나중에 Step 6에서 문제가 됩니다.
주소 표에 한 줄을 더합니다.
// apps/web-spa/src/routes/routes.ts
export const routes: RouteObject[] = [
{
path: '/',
Component: Layout,
children: [
{ index: true, Component: HomePage },
{ path: 'signup', Component: SignUpPage },
// 콜론이 붙은 칸은 고정된 글자가 아니라 자리다.
// 게시물이 백만 개여도 이 표는 한 줄이면 된다.
{ path: 'p/:postId', Component: PostDetailPage },
],
},
];
지난 시간에 배운 그대로 자식 주소에는 앞 빗금을 안 붙였습니다. Layout의 자식이니까 /에 이어 붙어서 /p/:postId가 돼요.
받아내는 쪽
빈칸에 들어온 값은 useParams로 꺼냅니다.
// apps/web-spa/src/routes/PostDetailPage.tsx
import { useParams } from 'react-router';
import { feedPosts } from '../data/feed';
export function PostDetailPage() {
// 주소 표에는 :postId 라고 적어뒀지만 타입은 그 표를 읽지 못한다.
// 그래서 여기서 받는 값은 언제나 string | undefined 다.
const { postId } = useParams();
// id 는 number, postId 는 string — 바꾸지 않으면 영원히 안 맞는다.
const post = feedPosts.find((item) => item.id === Number(postId));
if (!post) {
return (
<Section title="게시물">
<p className="text-sm text-faint">게시물을 찾을 수 없습니다.</p>
</Section>
);
}
// ... 카드로 게시물 한 장을 그린다
}
돌려받는 것은 객체입니다. 키 이름은 우리가 주소 표에 적은 그대로예요. :postId라고 적었으니 postId로 나옵니다. :id라고 적었으면 id로 나오고요.
⚠️ 받은 값의 타입이 두 번 배신합니다
여기가 오늘 첫 번째 함정입니다. postId의 타입을 편집기에서 찍어보면 이렇게 나옵니다.
const { postId } = useParams();
postId : string | undefined
~~~~~~ ~~~~~~~~~
숫자가 주소 표에 :postId 가 있는데
아니다 왜 없을 수도 있다고 할까
첫째, 숫자가 아니라 문자열입니다. 주소는 글자로 된 것이라 당연한데, 우리 Post의 id는 숫자라서 이렇게 쓰면 영원히 못 찾습니다.
// 절대 안 맞는 비교 — 왼쪽은 숫자, 오른쪽은 문자열
feedPosts.find((item) => item.id === postId)
1 === "1"은 거짓이니까요. Number(postId)로 바꿔야 맞습니다.
둘째, undefined 일 수도 있다고 나옵니다. 우리는 주소 표에 :postId를 분명히 적었는데요.
이유는 간단합니다. 타입은 우리 주소 표를 읽지 못합니다. useParams는 어떤 컴포넌트에서든 불릴 수 있는 함수예요. 주소 표에 :postId가 있는지 없는지 TypeScript는 알 길이 없습니다. 그래서 "있을 수도 있고 없을 수도 있다"로 가장 안전하게 말해주는 거예요.
🙋 학생 질문 — "튜터님, 이거 안 바꾸면 화면이 터지나요?"
타입 검사가 먼저 잡아줍니다. 실행까지 못 갑니다.
Number를 빼고 저장하면 이 메시지가 나와요.
error TS2367: This comparison appears to be unintentional
because the types 'number' and 'string | undefined' have no overlap.
"겹치는 부분이 없다"는 말이 정확합니다. 숫자와 문자열은 아무리 비교해도 같아질 수 없으니, 이 비교는 항상 거짓이에요. 그걸 컴파일러가 알아채고 막아준 겁니다.
만약 검사를 무시하고 실행하면 어떻게 될까요. find가 아무것도 못 찾아서 "게시물을 찾을 수 없습니다"가 뜹니다. 화면은 멀쩡하고 콘솔도 조용해요. 모든 게시물이 없는 것처럼 보일 뿐입니다.
이 자리는 도구가 잘 막아준 경우라서, 오늘 뒤에서 도구가 안 막아주는 경우도 하나 만나게 될 거예요. 그때 이 메시지를 다시 떠올려 보세요.
🙋 학생 질문 — "왜 /post/:id가 아니라 /p/:postId 인가요?"
p는 진짜 인스타그램의 주소를 그대로 따라간 겁니다. 실제로 instagram.com/p/{게시물코드}예요. 주소는 짧을수록 좋고, 링크로 주고받는 것이라 글자 수가 실제로 의미가 있습니다.
:postId라는 이름은 우리가 정한 거예요. :id로 해도 동작은 똑같습니다. 다만 화면이 늘어나면 useParams()에서 id 하나만 꺼냈을 때 무엇의 id인지 안 보여요. 나중에 /p/:postId/c/:commentId 같은 주소가 생기면 둘 다 id 일 수는 없고요.
주소 표에 적은 이름이 그대로 꺼내는 키가 되니까, 이름을 정할 때는 "이 값을 꺼내 쓰는 쪽에서 읽었을 때 뜻이 통하는가"를 기준으로 잡으면 됩니다.
💡 한 줄 정리
콜론이 붙은 조각은 빈칸이고, 그 빈칸에 들어온 값은 useParams로 꺼내며, 꺼낸 값은 언제나 문자열이고 없을 수도 있는 것으로 다뤄야 합니다.
Step 2: "손으로 안 누르고 코드가 보낸다"
지난 시간에 화면을 옮기는 방법을 하나 배웠죠. <Link>입니다.
<Link to="/signup">회원가입</Link>
이건 사용자가 누르는 것입니다. 화면에 링크가 보이고, 사용자가 그걸 눌러야 옮겨가요.
사용자가 누를 것이 없는데도 화면을 옮겨야 할 때가 있는데, 회원가입이 그래요. 가입 버튼을 눌러서 성공하면, 사용자는 아무것도 더 안 눌렀는데 홈으로 가 있어야 합니다.
지난 과목에서 폼을 다룰 때 이런 이야기를 했었죠. 로그인 성공은 폼이 비워지는 것이 아니라 다른 화면으로 넘어가는 것이라고요. 그때는 화면이 하나뿐이라 넘어갈 곳이 없었습니다. 이제 생겼어요.
useNavigate가 돌려주는 것은 함수입니다
// apps/web-spa/src/routes/SignUpPage.tsx
import { useNavigate } from 'react-router';
export function SignUpPage() {
// 훅이 돌려주는 것은 "지금 가라" 가 아니라 보내는 함수다.
// 그래서 렌더 도중에 부르면 안 되고, 무슨 일이 끝난 뒤에 부른다.
const navigate = useNavigate();
return (
<Section title="회원가입">
<SignUpForm
onSubmit={(values) => {
console.log('가입 요청', values.username);
// replace 를 빼면 가입 화면이 기록에 남는다.
// 그러면 뒤로 가기 한 번에 방금 끝낸 폼으로 되돌아간다.
navigate('/', { replace: true });
}}
/>
</Section>
);
}
여기서 놓치기 쉬운 것이 하나 있습니다. useNavigate()를 부른다고 화면이 옮겨가지 않아요. 옮기는 함수를 돌려받을 뿐입니다.
그래서 이렇게 쓰면 안 됩니다.
export function SignUpPage() {
const navigate = useNavigate();
// ❌ 그리는 도중에 부르면 안 된다
navigate('/');
return <Section title="회원가입">...</Section>;
}
컴포넌트 본문은 화면을 그리는 동안 실행되는 코드예요. 그 안에서 화면을 옮기라고 시키면 그리는 중에 그리는 대상이 바뀝니다. navigate는 무슨 일이 끝난 뒤에 부르는 함수입니다. 버튼을 눌렀을 때, 제출이 성공했을 때, 응답이 도착했을 때요.
⚠️ replace를 빼면 뒤로 가기가 이상해집니다
이 옵션이 이 Step의 핵심입니다. 브라우저가 기록을 어떻게 쌓는지 보면 이유가 바로 보여요.
replace 없이 (기본) replace: true
홈 → 회원가입 → 홈 홈 → 회원가입
↓ 갈아끼움
기록: [ 홈, 회원가입, 홈 ] 기록: [ 홈, 홈 ]
뒤로 가기 → 회원가입 폼 뒤로 가기 → 홈
↑ 방금 끝낸 가입이 ↑ 자연스럽다
다시 나온다
기본값은 기록을 쌓아서, 가입을 마치고 뒤로 가기를 누르면 방금 채워서 보낸 폼이 다시 나와요. 사용자 입장에서는 "가입이 안 된 건가?" 싶습니다.
replace: true를 주면 쌓는 대신 지금 칸을 갈아끼웁니다. 회원가입 화면이 기록에서 사라지니까 뒤로 가기를 눌러도 폼으로 안 돌아가요.
기준은 간단합니다. 끝난 화면은 기록에 남기지 않습니다. 가입 완료, 결제 완료, 로그인 처리 화면이 전부 여기에 해당해요. 반대로 사용자가 다시 보고 싶어 할 화면은 그냥 쌓아둡니다.
뒤로 가기도 코드가 시킬 수 있습니다
게시물 상세 화면에 뒤로 버튼을 하나 답니다.
// apps/web-spa/src/routes/PostDetailPage.tsx
{/* 주소를 적어 보내는 게 아니라 "한 칸 뒤로" 다.
어디서 왔는지는 우리가 모르고, 알 필요도 없다. */}
<Button variant="ghost" size="sm" className="mb-3" onClick={() => navigate(-1)}>
뒤로
</Button>
숫자를 주면 기록 안에서 그만큼 움직입니다. -1은 한 칸 뒤로, -2는 두 칸 뒤로예요.
이게 navigate('/')와 다른 점이 중요합니다. navigate('/')는 홈으로 보내는 것이고, navigate(-1)은 왔던 곳으로 돌려보내는 것입니다. 탐색에서 들어왔으면 탐색으로, 피드에서 들어왔으면 피드로 돌아가요. 어디서 왔는지 우리가 알 필요가 없습니다.
🙋 학생 질문 — "튜터님, 뒤로 버튼을 로 만들면 안 되나요?"
만들 수는 있는데 동작이 달라집니다.
<Link to="/">는 홈으로 가는 링크예요. 탐색에서 게시물을 눌러 들어왔더라도 홈으로 보냅니다. 사용자는 "뒤로"를 눌렀는데 원래 있던 탐색이 아니라 홈에 도착하죠.
그리고 기록도 달라집니다. <Link>는 새 칸을 쌓아요. 그래서 뒤로 버튼을 눌러 홈에 간 다음 브라우저 뒤로 가기를 누르면 다시 게시물로 돌아옵니다. navigate(-1)은 기록을 되감는 것이라 그런 일이 없고요.
정리하면, 갈 곳이 정해져 있으면 <Link>, 왔던 곳으로 돌아가는 것이면 navigate(-1)입니다.
한 가지 덧붙이면 navigate(-1)에도 약점이 있어요. 주소를 직접 치고 들어온 사용자는 되감을 기록이 없습니다. 그럴 때 어떻게 할지는 오늘 과제로 남겨둘게요.
💡 한 줄 정리
<Link>는 사용자가 누르고 navigate()는 코드가 보내며, 끝난 화면은 replace: true로 기록에서 지웁니다.
Step 3: "URL도 상태의 한 갈래다"
지금 우리 앱에서 게시물을 볼 수 있는 곳은 홈 피드뿐입니다. 진짜 인스타그램에는 탐색 화면이 있죠. 사진이 격자로 깔리고, 관심사로 걸러볼 수 있는 그 화면이요.
오늘 그걸 만드는데, 이 화면이 오늘 배울 것 중 하나를 아주 선명하게 보여줍니다.
게시물도 열 장으로 늘렸어요. 두 장으로는 "걸렀다"는 느낌이 안 나니까요.
태그로 거르기 — 그 상태를 어디에 둘까요
"카페 태그가 붙은 것만 보여줘"는 화면의 상태입니다. 지금까지 배운 대로라면 useState를 쓰겠죠.
// 이렇게 짤 수도 있다
const [tag, setTag] = useState<string | null>(null);
동작은 합니다. 태그를 누르면 목록이 걸러져요. 그런데 이 판에는 잃는 것이 셋 있습니다.
카페 태그를 골라둔 상태에서...
새로고침한다 → 전체로 돌아간다. 고른 것이 사라졌다
링크를 보낸다 → 상대는 전체를 본다. 내가 본 화면이 아니다
뒤로 가기를 누른다 → 탐색을 통째로 떠난다. 태그만 되돌릴 수 없다
셋 다 같은 원인입니다. 고른 값이 화면 안에만 있어서 그렇습니다. 화면이 다시 그려지면 사라지고, 주소에는 흔적이 없으니 남에게 보낼 수도 없어요.
값을 주소에 담으면 셋이 한꺼번에 해결됩니다.
useSearchParams는 useState와 모양이 같습니다
// apps/web-spa/src/routes/ExplorePage.tsx
import { Link, useSearchParams } from 'react-router';
import { allPosts } from '../data/feed';
// 태그 목록은 데이터에서 뽑는다. 손으로 적어두면 게시물이 늘 때마다 어긋난다.
const allTags = [...new Set(allPosts.flatMap((post) => post.hashtagNames))];
export function ExplorePage() {
// 모양은 useState 와 똑같다 — 값 하나와 그 값을 바꾸는 함수 하나.
// 다른 것은 담기는 자리다. 이 값은 컴포넌트가 아니라 주소에 적힌다.
const [searchParams, setSearchParams] = useSearchParams();
// 없으면 null 이다. undefined 가 아니라 null 이라는 것을 기억해 두자.
const tag = searchParams.get('tag');
const shown = tag === null ? allPosts : allPosts.filter((post) => post.hashtagNames.includes(tag));
// ... 태그 버튼들과 격자 목록을 그린다
}
돌려받는 모양이 useState와 똑같습니다. 값 하나, 바꾸는 함수 하나. 지난 과목부터 계속 써온 그 형태예요.
다른 것은 담기는 곳입니다. useState의 값은 컴포넌트 안에 있고, useSearchParams의 값은 주소창에 있습니다.
/explore tag = null 전부 보인다
/explore?tag=카페 tag = "카페" 카페만 4장
/explore?tag=러닝 tag = "러닝" 러닝만 3장
/explore?tag=없는것 tag = "없는것" 0장
물음표 뒤에 붙는 것을 쿼리 스트링이라고 부릅니다. 주소의 일부지만 어느 화면인지는 안 바꿔요. /explore는 그대로 탐색 화면이고, 그 화면이 무엇을 보여줄지만 달라집니다.
Step 1의 :postId와 비교하면 차이가 보입니다.
/p/7 경로에 박힌 값. 없으면 화면 자체가 성립 안 한다
/explore?tag=카페 물음표 뒤의 값. 없어도 화면은 멀쩡하다 (전체를 보여준다)
있어야만 하는 값은 경로에, 있으면 좋은 값은 물음표 뒤에. 이게 나누는 기준입니다.
⚠️ 없을 때 undefined가 아니라 null입니다
Step 1에서 useParams는 없을 때 undefined를 준다고 했는데, searchParams.get()은 null을 줍니다.
useParams().postId → string | undefined
searchParams.get('tag') → string | null
같은 라이브러리인데 다릅니다. 이유는 searchParams가 React Router가 만든 것이 아니라 브라우저에 원래 있던 URLSearchParams 이기 때문이에요. 그 표준이 null을 돌려주도록 정해져 있습니다.
실무에서 이게 왜 성가시냐면, ?? 로 기본값을 주는 코드는 둘 다 똑같이 동작하는데 === undefined로 검사하는 코드는 한쪽에서만 동작하기 때문입니다. 그래서 tag === null처럼 무엇과 비교하는지 눈에 보이게 적어두는 편이 안전합니다.
태그 목록을 손으로 적지 않는 이유
태그 버튼을 만들려면 태그가 몇 종류인지 알아야 합니다. 지금은 여덟 종류예요. 손으로 적어도 됩니다.
지난 과목 과제에서 이 이야기를 한 적이 있죠. 게시물이 서버에서 오기 시작하면 손으로 적은 목록은 곧바로 틀린 값이 된다고요. 지금이 그 상황인데, 게시물이 두 장에서 열 장으로 늘었고 태그도 넷에서 여덟로 늘어서 데이터에서 뽑습니다.
const allTags = [...new Set(allPosts.flatMap((post) => post.hashtagNames))];
flatMap으로 모든 게시물의 태그를 한 줄로 펼치고, Set에 넣어 중복을 없앤 뒤, 다시 배열로 펼칩니다. Set은 같은 값을 두 번 담지 않는 자료구조예요. 게시물이 백 장이 되어도 이 한 줄은 안 바뀝니다.
🙋 학생 질문 — "튜터님, 태그 버튼을 로 만들어도 되나요?"
됩니다. 그리고 어떤 면에서는 더 좋아요.
링크로 만들면 사용자가 우클릭해서 새 탭으로 열 수 있고, 주소를 복사할 수도 있습니다. 버튼은 그게 안 돼요. 검색 엔진도 링크는 따라가지만 버튼은 못 따라갑니다.
오늘 버튼으로 만든 이유는 배우는 대상이 setSearchParams 라서예요. 값을 주소에 쓰는 함수를 직접 불러봐야 "이 값이 어디에 담기는지"가 몸에 남습니다.
실무 기준을 말씀드리면 이렇습니다. 화면을 옮기거나 주소를 바꾸는 것은 링크로, 화면 안에서 무언가를 실행하는 것은 버튼으로. 그 기준으로 보면 태그 필터는 주소를 바꾸는 일이니 링크가 더 맞습니다.
오늘 과제에 이걸 링크로 바꿔보는 항목을 넣어뒀어요. 바꿔보면 무엇이 달라지고 무엇이 그대로인지 직접 보실 수 있습니다.
상태가 놓일 곳이 셋이 됐습니다
오늘 하나가 늘었다는 것을 짚고 넘어가겠습니다.
화면 안 상태 useState 이 컴포넌트만 아는 값 (캡션 펼침 여부)
주소 안 상태 useSearchParams 링크로 보낼 수 있어야 하는 값 (고른 태그)
서버가 가진 것 아직 안 배움 게시물·댓글처럼 남이 들고 있는 값
세 번째 칸이 오늘 Step 4부터 다룰 것이고, 이 세 갈래를 표로 정리하는 것은 C-4입니다. 그 앞의 C-3에서 값을 나르는 문제부터 먼저 잡고요.
💡 한 줄 정리
useSearchParams는 useState와 모양이 같지만 값을 주소에 담아서, 새로고침·공유·뒤로 가기가 전부 살아납니다.
Step 4: "화면이 먼저 뜨고 데이터가 늦게 온다"
지금까지 우리 앱에서 데이터는 항상 이미 있었습니다.
import { feedPosts } from '../data/feed';
이 한 줄이면 끝이었어요. 파일에 적혀 있었으니까요. 기다릴 것도 없고 실패할 것도 없었습니다.
진짜 서버는 그렇지 않습니다. 요청을 보내고, 네트워크를 건너가고, 서버가 데이터베이스를 뒤지고, 다시 돌아옵니다. 빨라야 수십 밀리초, 느리면 몇 초예요.
그 상황을 만들어봅시다. 진짜 서버에 붙는 것은 다음 모듈(C-6)에서 할 일이니, 지금은 기다리는 것만 흉내 냅니다.
// apps/web-spa/src/data/feed.ts
// 지금까지 데이터는 import 하는 순간 이미 있었다. 진짜 서버라면 그렇지 않다.
// 여기서는 기다리는 것만 흉내 낸다 — 진짜 요청으로 바뀌는 것은 C-6 다.
const NETWORK_DELAY_MS = 400;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function fetchPost(id: number): Promise<Post | undefined> {
await delay(NETWORK_DELAY_MS);
return allPosts.find((post) => post.id === id);
}
게시물 하나를 얻으려면 이제 기다려야 하는데, 400밀리초면 눈에 확실히 보이는 시간이에요.
배운 대로 짜보면
지금까지 배운 도구로 이걸 처리하려면 방법은 하나뿐입니다. 화면이 그려진 뒤에 부르는 거예요.
// apps/web-spa/src/routes/PostDetailPage.tsx
const [loaded, setLoaded] = useState<Loaded | null>(null);
useEffect(() => {
// 늦게 온 응답이 이미 떠난 화면을 덮어쓰지 않게 표시를 남긴다
let cancelled = false;
fetchPost(Number(postId)).then((found) => {
if (!cancelled) {
setLoaded({ postId, post: found ?? null });
}
});
return () => {
cancelled = true;
};
}, [postId]);
// 기다리는 중인지는 따로 기억하지 않는다 — 담긴 것이 지금 주소의 답인지만 보면 된다.
const loading = loaded === null || loaded.postId !== postId;
돌려보면 이렇게 됩니다.
/p/1 로 들어간 순간
0ms 껍데기가 뜬다 "게시물" 제목 + 뒤로 버튼 + "불러오는 중..."
↓
400ms 알맹이가 도착한다 카드가 그려진다
→ 사용자는 반쯤 그려진 화면을 400ms 동안 본다
화면이 먼저 뜨고 데이터가 나중에 옵니다. 순서가 이렇게 될 수밖에 없어요. useEffect는 화면이 그려진 다음에 실행되는 것이니까요.
없는 게시물도 마찬가지입니다. /p/999로 들어가면 400ms 동안 "불러오는 중..."을 보여준 다음에야 "찾을 수 없습니다"로 바뀝니다. 없다는 것조차 기다려야 알 수 있어요.
⚠️ 그리고 이 코드는 처음에 린터가 막았습니다
처음 쓴 판은 기다리는 중인지를 따로 상태로 들고 있었습니다. 그러려면 effect 안에서 setLoading(true)를 불러야 했는데, 빌드가 거기서 멈췄어요.
react-hooks/set-state-in-effect
> 24 | setLoading(true);
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
Calling setState synchronously within an effect body causes cascading
renders that can hurt performance, and is not recommended.
(https://react.dev/learn/you-might-not-need-an-effect)
메시지 끝에 붙은 주소가 인상적이죠. "effect가 필요 없을지도 모릅니다." 도구가 이 방향 자체를 말리고 있는 겁니다.
그래서 상태 하나를 없애고 유도해서 계산하도록 고쳤습니다. 위에 붙인 코드가 그 고친 판이에요. 린트는 통과합니다.
그런데 화면 순서는 하나도 안 바뀌었습니다. 여전히 껍데기가 먼저 뜨고 400ms 뒤에 알맹이가 옵니다.
이게 오늘 Step 4의 결론입니다. 문제는 effect를 잘못 쓴 것이 아니라 부르는 시점이었어요. 그리기 시작한 뒤에 부르니까 화면이 먼저 뜨는 것이고, 그건 effect를 아무리 잘 써도 안 바뀝니다.
B-4에서 effect를 배울 때 데이터 가져오기를 일부러 건너뛰었던 것 기억하시나요. "이건 effect로 풀 문제가 아니라서 오늘은 안 한다" 고 했었죠. 그 말의 뜻이 지금 눈에 보입니다.
🙋 학생 질문 — "튜터님, cancelled는 왜 필요한가요?"
늦게 온 응답이 엉뚱한 화면을 덮어쓰는 것을 막습니다.
이런 순서를 생각해 보세요. /p/1을 열어서 요청이 나갔습니다. 아직 안 왔는데 사용자가 뒤로 가서 /p/5를 열면 요청이 둘 다 떠 있어요.
만약 1번 응답이 5번보다 늦게 도착하면, 5번 화면에 1번 데이터가 그려집니다. 주소는 /p/5인데 내용은 1번인 거죠.
cancelled는 그걸 막는 표시입니다. 화면이 바뀔 때 정리 함수가 cancelled = true로 바꾸고, 뒤늦게 온 응답은 그 표시를 보고 아무것도 안 합니다.
이런 처리를 직접 해야 한다는 것 자체가 오늘의 논점이에요. 이 코드를 우리가 쓰고, 우리가 관리하고, 우리가 빠뜨리면 버그가 됩니다. 다음 Step에서 이 열 몇 줄이 통째로 사라집니다.
💡 한 줄 정리
화면이 그려진 다음에 데이터를 부르면 반쯤 그려진 화면이 반드시 생기고, 그건 effect를 잘 쓰는 것으로는 해결되지 않습니다.
Step 5: "뜨기 전에 부른다"
문제가 부르는 시점이라면 답도 거기 있습니다. 그리기 전에 부르면 됩니다.
그런데 컴포넌트 안에서는 그럴 수가 없어요. 컴포넌트 본문은 이미 "그리는 중" 이니까요. 그리기 전에 무언가를 하려면 컴포넌트 바깥이어야 합니다.
컴포넌트 바깥에 있으면서 이 화면이 필요하다는 것을 아는 곳이 어디일까요. 주소 표입니다.
loader는 주소 표에 삽니다
// apps/web-spa/src/routes/postLoader.ts
import type { LoaderFunctionArgs } from 'react-router';
import { fetchPost } from '../data/feed';
// 컴포넌트 바깥이다. 화면을 그리기 전에 라우터가 이 함수를 먼저 부른다.
// 훅이 아니라 그냥 함수라서, 주소에서 뽑은 값도 인자로 받는다.
export async function postLoader({ params }: LoaderFunctionArgs) {
const post = await fetchPost(Number(params.postId));
return { post };
}
훅이 아닙니다. 그냥 async 함수예요. 컴포넌트도 아니고 JSX도 없어서 useParams를 못 씁니다. 훅은 컴포넌트 안에서만 부를 수 있으니까요. 대신 인자로 받습니다. 라우터가 주소를 매칭한 결과를 params에 담아 넘겨줘요.
이 함수를 주소 표의 그 줄에 나란히 답니다.
// apps/web-spa/src/routes/routes.ts
// loader 는 Component 와 나란히 선다. 라우터가 loader 를 먼저 부르고,
// 그것이 끝난 뒤에야 Component 를 그린다.
{ path: 'p/:postId', loader: postLoader, Component: PostDetailPage },
한 줄에 셋이 나란히 있습니다. 어느 주소인지, 무엇을 먼저 가져올지, 무엇을 그릴지.
받는 쪽
// apps/web-spa/src/routes/PostDetailPage.tsx
import { useLoaderData, useNavigate } from 'react-router';
import type { postLoader } from './postLoader';
export function PostDetailPage() {
// 꺾쇠 안을 비우면 any 가 돌아온다. 타입 검사가 통과해버리니 아무도 안 알려준다.
// loader 가 무엇을 돌려주는지 알려주면 여기서부터 타입이 산다.
const { post } = useLoaderData<typeof postLoader>();
const navigate = useNavigate();
// 기다리는 중이라는 갈래가 통째로 없어졌다.
// 이 화면이 그려지고 있다는 건 데이터가 이미 왔다는 뜻이라서다.
return (
// ... 카드 하나를 그린다
);
}
useState가 없습니다. useEffect도 없고요. cancelled 표시도, 정리 함수도, 기다리는 중인지 판단하는 줄도 전부 사라졌습니다.
이 화면이 그려지고 있다는 것 자체가 데이터가 왔다는 뜻이라서 그렇습니다. 아직 안 왔으면 이 컴포넌트는 애초에 안 불려요.
순서가 이렇게 갈립니다
Step 4 — 그린 다음에 부른다
0ms 껍데기가 뜬다 반쯤 그려진 화면이 보인다
400ms 알맹이가 도착 완성
Step 5 — 부른 다음에 그린다
0ms 아직 아무것도 없다 앞 화면이 그대로 남아 있다
400ms 껍데기와 알맹이가 함께 완성된 화면이 한 번에 나타난다
탐색 화면에서 게시물을 눌러보면 차이가 더 분명합니다. 눌렀는데 화면이 안 비어요. 탐색 격자가 그대로 남아 있다가 다 불러오면 통째로 게시물 화면으로 갈립니다.
지난 시간에 <a> 대신 <Link>를 쓰는 이유로 "화면이 하얗게 깜빡이지 않는다"를 꼽았죠. loader는 그걸 데이터에까지 확장한 것입니다.
⚠️ 꺾쇠 안을 비우면 조용히 any가 됩니다
useLoaderData의 시그니처를 열어보면 이렇습니다.
declare function useLoaderData<T = any>(): SerializeFrom<T>;
// ~~~~~~~ 기본값이 any
A 카테고리 여섯 모듈 내내 any를 피하라고 배웠는데, 이 훅은 아무것도 안 알려주면 any를 줍니다.
무엇이 문제인지 눈으로 보겠습니다. 같은 파일에 두 컴포넌트를 놓고, 둘 다 Post에 없는 필드를 부르게 했습니다.
// 알려준 쪽
const { post } = useLoaderData<typeof postLoader>();
return <p>{post?.nickname}</p>;
// 안 알려준 쪽
const { post } = useLoaderData();
return <p>{post?.nickname}</p>;
타입 검사를 돌리면 걸리는 것은 한 줄뿐입니다.
8행 (알려준 쪽) error TS2339: Property 'nickname' does not exist on type 'Post'.
14행 (안 알려준 쪽) (아무 말 없음)
같은 코드, 같은 없는 필드인데 한쪽만 잡힙니다. 안 알려준 쪽은 post가 any라서 무슨 필드를 부르든 통과해요. 배포한 다음 화면에 undefined가 뜨고 나서야 알게 됩니다.
<typeof postLoader>라고 적으면 됩니다. loader가 무엇을 돌려주는지 이미 코드에 적혀 있으니, 그걸 가리키기만 하면 타입이 따라옵니다. 같은 것을 두 곳에 적을 필요가 없어요.
🙋 학생 질문 — "튜터님, loader가 400ms 도는 동안 아무 표시도 안 하면 답답하지 않나요?"
좋은 지적입니다. 그리고 답은 "상황에 따라 다르다"예요.
지금처럼 짧을 때는 오히려 낫습니다. 껍데기가 번쩍 떴다가 내용이 채워지는 것보다, 앞 화면이 잠깐 남아 있다가 통째로 갈리는 쪽이 덜 어수선해요. 사람 눈에는 깜빡임이 더 거슬립니다.
문제는 길어질 때입니다. 2초쯤 되면 사용자는 "눌렸나?" 하고 다시 누르기 시작해서, React Router는 지금 이동 중인지를 알려주는 방법을 따로 갖고 있습니다. 그걸로 진행 막대를 띄우거나 화면을 살짝 흐리게 하는 식으로 처리해요.
오늘은 거기까지 안 갑니다. 오늘의 핵심은 부르는 시점을 옮기면 반쯤 그려진 화면이 사라진다는 것이고, 기다림을 어떻게 보여줄지는 그다음 이야기라서요.
💡 한 줄 정리
loader는 컴포넌트 바깥에서 화면보다 먼저 돌고, 그 덕에 화면에서 기다림을 다루는 코드가 통째로 사라집니다.
Step 6: "던진 것을 받아 좁혀 읽는다"
Step 1에서 넘어간 이야기가 하나 있었죠. 콜론이 붙은 칸은 뭐든 받는다고요.
/p/abc 매칭된다. postId = "abc"
숫자가 아니어도 매칭됩니다. 그러면 Number('abc')는 NaN이 되고, 우리 loader는 있지도 않은 NaN 번 게시물을 찾으러 갑니다.
이건 "못 찾았다"와 다른 문제예요. 애초에 물어볼 수 없는 것을 물어본 겁니다. 그럴 때는 던집니다.
// apps/web-spa/src/routes/postLoader.ts
export async function postLoader({ params }: LoaderFunctionArgs) {
const id = Number(params.postId);
// 주소는 사용자가 손으로 칠 수 있는 자리다. 무엇이든 들어온다.
// 여기서 던지면 화면 대신 ErrorBoundary 가 뜬다.
if (Number.isNaN(id)) {
throw new Error(`게시물 번호가 아닙니다: ${params.postId}`);
}
const post = await fetchPost(id);
return { post };
}
loader에서 던지면 그 화면은 아예 안 그려집니다. 대신 우리가 지정한 오류 화면이 뜹니다.
오류 화면도 주소 표에 답니다
// apps/web-spa/src/routes/routes.ts
{
path: '/',
Component: Layout,
// Component 와 짝을 이룬다. 아래에서 무엇이 던져지든 여기로 올라온다.
ErrorBoundary: RootErrorBoundary,
children: [ /* ... */ ],
}
Component와 ErrorBoundary가 나란히 있습니다. 평소에는 Component 를 그리고, 아래에서 무언가 던져지면 ErrorBoundary 를 그린다. 이 줄 하나가 그 뜻이에요.
우리는 이걸 맨 위 라우트에 달아서, 자식 어디에서 던지든 여기까지 올라옵니다.
⚠️ 받은 것은 unknown입니다
// apps/web-spa/src/routes/RootErrorBoundary.tsx
import { Link, isRouteErrorResponse, useRouteError } from 'react-router';
export function RootErrorBoundary() {
// 무엇이 던져질지 라우터도 모른다. 그래서 돌려주는 타입이 unknown 이다.
// unknown 은 좁히기 전에는 아무것도 못 꺼낸다 — 점 하나도 못 찍는다.
const error = useRouteError();
let detail: string;
if (isRouteErrorResponse(error)) {
// 우리가 Response 를 던진 경우. 상태 번호가 함께 온다.
detail = `${error.status} ${error.statusText}`;
} else if (error instanceof Error) {
// 코드가 터지거나 우리가 Error 를 던진 경우
detail = error.message;
} else {
// 문자열이든 뭐든 던질 수 있다. 여기까지 오면 우리가 아는 게 없다.
detail = '알 수 없는 오류입니다';
}
// ... 제목과 detail 과 홈으로 가는 링크를 그린다
}
useRouteError()가 돌려주는 타입은 unknown입니다.
왜 그럴까요. JavaScript에서는 아무거나 던질 수 있기 때문입니다. Error 객체를 던져도 되고, 문자열을 던져도 되고, 숫자를 던져도 돼요. 라이브러리 입장에서는 무엇이 올지 알 방법이 없습니다.
그래서 unknown을 줍니다. 그리고 unknown은 좁히기 전에는 아무것도 못 꺼내요.
const error = useRouteError();
return <p>{error.message}</p>;
// ~~~~~~~~~~~~~
// error TS18046: 'error' is of type 'unknown'.
점 하나도 못 찍습니다. A-2에서 배운 좁히기를 여기서 그대로 씁니다. isRouteErrorResponse는 그때 만들어본 사용자 정의 타입 가드와 같은 종류예요. 참이면 그 안에서 error를 Response로 다룰 수 있게 해줍니다.
🌟 Step 5와 정반대라는 것이 재밌습니다
같은 라이브러리, 같은 버전인데 두 훅의 기본값이 갈립니다.
| 훅 | 기본 타입 | 잘못 쓰면 |
|---|---|---|
useLoaderData() |
any |
통과시킨다. 배포하고 나서 안다 |
useRouteError() |
unknown |
곧바로 막는다. 고치기 전엔 못 넘어간다 |
any는 "검사를 그만둬"이고 unknown은 "확인하기 전엔 못 쓴다"입니다. 두 글자 차이인데 결과가 정반대예요.
라이브러리를 들일 때 이런 것을 한 번은 확인해 두면 좋습니다. 이 도구가 나를 막아주는 쪽인지, 통과시키는 쪽인지요. 통과시키는 쪽이면 내가 대신 챙겨야 합니다.
⚠️ 검색하면 나오는 errorElement는 뭔가요
이 기능을 검색하면 errorElement라고 적힌 자료가 많이 나옵니다. 우리가 쓴 ErrorBoundary와 이름이 다르죠.
정확히 말하면 이렇습니다. errorElement는 없어진 것이 아닙니다. 지금 버전의 타입 정의에도 그대로 있어요.
Component 컴포넌트를 준다 ErrorBoundary 컴포넌트를 준다
element 엘리먼트를 준다 errorElement 엘리먼트를 준다
두 쌍이 똑같은 구조입니다. 짝이 맞아요. 우리는 Component를 쓰고 있으니 짝을 맞춰 ErrorBoundary를 씁니다.
공식 문서가 errorElement를 더 이상 안 다루는 것은 맞지만, 그건 문서에서 내렸다는 뜻이지 사라졌다는 뜻이 아니에요. 이 둘을 구분하는 습관이 중요합니다. "없어졌다" 고 적힌 글을 믿고 코드를 지웠다가 멀쩡히 도는 남의 코드를 망가뜨리는 일이 실제로 생기거든요.
💡 한 줄 정리
loader가 던지면 화면 대신 ErrorBoundary가 뜨고, 받은 것은 unknown이라 좁혀야만 읽을 수 있습니다.
Step 7: "404는 두 종류다"
이제 남은 구멍을 메웁니다. 두 가지 "못 찾았다"가 있는데, 지금 우리 앱은 하나도 제대로 처리하지 않고 있어요.
/이런주소는없다 주소 표에 아예 없는 주소
/p/999 주소 표에는 맞는데 그런 게시물이 없다
둘 다 사용자에게는 "없다" 지만 기제가 완전히 다릅니다. 앞쪽은 라우터가 표를 훑다가 실패한 것이고, 뒤쪽은 표에서는 찾았는데 데이터가 없는 것이에요.
없는 주소 — 표의 맨 끝에 세워두기
주소 표에 별표를 하나 답니다.
// apps/web-spa/src/routes/routes.ts
{ path: 'p/:postId', loader: postLoader, Component: PostDetailPage },
// 별표는 아무도 안 맡은 주소를 받는다. 맨 끝에 둬야 위의 주소들을 안 가로챈다.
{ path: '*', Component: NotFoundPage },
별표는 무엇이든 받습니다. 그러니 맨 끝에 두는 게 당연해 보이죠. 저 주석도 그렇게 적혀 있고요.
⚠️ 그런데 "맨 끝에 둬야 한다"를 직접 재봤더니
지난 시간에 배운 습관이 있죠. 널리 퍼진 조언은 재봐야 한다고요. 이번에도 재봤습니다.
같은 다섯 주소를 놓고, 표의 순서만 정반대로 뒤집은 두 판을 만들었습니다.
A 판 (통설상 최악) [ *, :username, p/:postId, explore, index ]
B 판 (통설상 권장) [ index, explore, p/:postId, :username, * ]
| 들어간 주소 | A 판이 고른 것 | B 판이 고른 것 |
|---|---|---|
/ |
index | index |
/explore |
explore |
explore |
/p/7 |
p/:postId |
p/:postId |
/jaehoon |
:username |
:username |
/a/b/c |
* |
* |
다섯 줄 전부 같습니다. 순서가 한 건도 결과를 안 바꿨어요.
이유는 React Router가 표를 위에서부터 훑어 먼저 맞는 것을 고르지 않기 때문입니다. 후보를 전부 모은 다음 가장 구체적인 것을 고릅니다.
구체적일수록 이긴다
explore 정해진 글자 가장 셈
>
p/:postId 정해진 글자 + 빈칸
>
:username 빈칸 하나
>
* 뭐든 다 가장 약함
저 조언은 왜 그렇게 퍼져 있을까요. 예전에는 참이었기 때문입니다. React Router v5의 <Switch>는 정말로 위에서부터 훑어 먼저 맞는 것을 골랐어요. 그때는 별표를 위에 두면 모든 주소를 삼켰습니다. 버전이 바뀌면서 규칙이 바뀌었는데 조언만 남은 거예요.
그래도 저는 별표를 맨 끝에 둡니다. 동작 때문이 아니라 읽는 사람 때문이에요. 구체적인 것부터 적어두면 표를 위에서 아래로 읽으며 "이 앱에 어떤 화면이 있는지"를 파악할 수 있습니다. 별표가 맨 위에 있으면 그 흐름이 깨져요.
이 차이가 중요합니다. "안 그러면 망가진다"와 "그렇게 적는 편이 읽기 좋다"는 전혀 다른 이유입니다. 앞엣것은 규칙이고 뒤엣것은 취향이에요. 둘을 구분해두면 팀에서 규칙을 정할 때 무엇을 강제하고 무엇을 권장할지 갈라집니다.
이 화면은 오류가 아닙니다. 우리가 맡기로 한 화면이에요. 그래서 평범한 컴포넌트로 만들고 머리말도 그대로 남습니다.
// apps/web-spa/src/routes/NotFoundPage.tsx
import { Link, useLocation } from 'react-router';
// 표의 맨 끝에 서서 아무도 안 맡은 주소를 받는다.
// 오류가 아니라 우리가 맡기로 한 화면이라, 머리말이 그대로 남는다.
export function NotFoundPage() {
const { pathname } = useLocation();
return (
<Section title="없는 주소">
<h2 className="mb-2 text-xl font-bold">없는 주소예요</h2>
<p className="mb-4 text-sm text-faint">
<code>{pathname}</code> 은 우리 주소 표에 없습니다.
</p>
<Link className="text-sm underline underline-offset-4" to="/explore">
탐색으로 가기
</Link>
</Section>
);
}
useLocation으로 지금 주소를 꺼내서 보여줍니다. 사용자가 오타를 냈다면 무엇을 잘못 쳤는지 바로 보이니까요.
없는 게시물 — 번호를 붙여 던지기
이쪽은 loader가 알아냅니다. 데이터를 가져와 봐야 없다는 것을 알 수 있으니까요.
// apps/web-spa/src/routes/postLoader.ts
const post = await fetchPost(id);
// 주소는 표에 있는데 그 데이터가 없는 경우다. 이건 오류라기보다 상태라서
// 번호를 붙여 던진다 — 받는 쪽이 404 인지 아닌지 구별할 수 있게.
//
// 사람이 읽을 말은 본문에 담는다. statusText 는 HTTP reason phrase 라
// Latin-1 만 받아서, 한글을 넣으면 만드는 순간 TypeError 로 터진다.
if (post === undefined) {
throw new Response('게시물을 찾을 수 없습니다', { status: 404 });
}
// 여기까지 왔으면 post 는 반드시 있다. 화면이 없음을 신경 쓸 일이 없어졌다.
return { post };
Error 대신 Response를 던졌는데, 이건 코드가 터진 것이 아니라 상태이기 때문이에요. 404라는 번호가 그 상태를 정확히 가리킵니다.
번호를 붙여 던지면 받는 쪽에서 갈래를 나눌 수 있습니다.
// apps/web-spa/src/routes/RootErrorBoundary.tsx
if (isRouteErrorResponse(error)) {
// 우리가 Response 를 던진 경우. 번호가 함께 오니 갈래를 더 나눌 수 있다.
// 사람이 읽을 말은 statusText 가 아니라 본문(data)에 담겨 온다.
title = error.status === 404 ? '없는 페이지예요' : '문제가 생겼어요';
detail = `${error.status} ${error.data}`;
} else if (error instanceof Error) {
detail = error.message;
}
Step 6에서 만든 세 갈래가 그대로 살아 있고, 첫 번째 갈래 안에서 404만 따로 대접합니다. /p/abc는 Error를 던지므로 같은 화면에 잡혀도 404가 아니에요.
⚠️ statusText에 한글을 넣으면 터집니다
처음에는 이렇게 쓰려고 했습니다. 훨씬 자연스러워 보이니까요.
throw new Response(null, { status: 404, statusText: '게시물을 찾을 수 없습니다' });
이 줄은 만드는 순간 터집니다.
TypeError: Cannot convert argument to a ByteString because the character
at index 0 has a value of 44172 which is greater than 255.
44172는 '게'의 코드 번호입니다. 255를 넘죠.
이유는 statusText가 우리 것이 아니라 HTTP 규격의 일부이기 때문입니다. 서버가 HTTP/1.1 404 Not Found라고 응답할 때 그 Not Found 자리예요. 규격이 한 글자를 1바이트로 정해놨으니 한글은 애초에 들어갈 수 없습니다.
그래서 사람이 읽을 말은 본문에 담습니다. 받는 쪽에서는 error.data로 꺼내요.
이건 React Router의 규칙이 아니라 웹 자체의 규칙입니다. 그래서 다른 곳에서도 똑같이 만납니다.
두 화면이 다르게 생긴 이유
만들어놓고 보면 두 "없다" 화면이 다르게 생겼습니다.
/이런주소는없다 머리말 있음 ✅ Layout 안에서 그려진다
/p/999 머리말 없음 ❌ Layout 을 대신해서 그려진다
우연이 아니라 구조 때문입니다. 별표 라우트는 Layout의 자식이라 Outlet 자리에 들어가고, ErrorBoundary는 Layout이 있던 그 자리를 대신해서 머리말이 통째로 사라져요.
이게 맞는 걸까요? 상황에 따라 다릅니다. 오류 화면에서 머리말이 사라지면 사용자가 다른 데로 갈 방법이 없어서, 우리는 "홈으로" 링크를 직접 넣어뒀습니다.
ErrorBoundary를 더 아래쪽 라우트에 달면 머리말을 살릴 수도 있습니다. 그 경우 무엇이 좋아지고 무엇이 나빠지는지는 오늘 과제로 남겨둘게요.
💡 한 줄 정리
라우터가 못 찾은 것은 별표 라우트가 받고, 데이터가 없는 것은 loader가 번호를 붙여 던지며, 둘은 다른 화면으로 이어집니다.
Step 8: "모달은 주소를 가져야 할까"
미뤄둔 질문이 하나 있습니다. 두 번 미뤘어요.
비주얼을 마감하던 시간에 이렇게 남겼습니다. "지금 우리 모달은 주소가 안 바뀌어요. 열어두고 새로고침하면 사라집니다. 그게 맞는 걸까요, 아닐까요. 다음 시간에 같이 정합니다."
그런데 지난 시간에는 주소를 나누는 데까지만 갔죠. 오늘 갚습니다.
지금 무엇이 안 되는지
우리 모달은 "댓글 32개 모두 보기"를 누르면 열립니다. 열고 나서 해보세요.
모달을 연 상태에서...
주소창을 본다 → / 그대로다. 아무 변화가 없다
새로고침한다 → 모달이 사라진다. 피드로 돌아간다
링크를 보낸다 → 상대는 그냥 피드를 본다
뒤로 가기를 누른다 → 앱을 떠난다. 모달만 닫히지 않는다
Step 3에서 본 목록과 똑같죠. 원인도 같습니다. 열렸다는 사실이 화면 안에만 있어서예요.
그런데 정말 주소를 줘야 할까요
여기서 바로 만들지 말고 한 번 멈춥시다. 우리에게는 이미 /p/1이라는 전체 화면이 있어요. 같은 게시물을 보는 방법이 이미 있는데 모달에도 주소를 줄 이유가 있을까요.
진짜 인스타그램이 어떻게 하는지 보면 답이 나옵니다.
피드에서 게시물을 누른다 → 피드가 깔린 채로 상자가 뜬다 + 주소가 바뀐다
그 주소를 복사해 새로 연다 → 상자가 아니라 전체 화면이 뜬다
같은 주소인데 어디서 왔느냐에 따라 다르게 보여줍니다. 왜 이렇게 만들었을까요.
피드를 훑던 사람에게는 모달이 낫습니다. 스크롤 위치를 잃지 않고, 닫으면 보던 곳으로 돌아오니까요. 반면 링크를 받고 들어온 사람에게는 깔아둘 피드가 없습니다. 전체 화면이 맞아요.
그러니 답은 "준다"인데, 주소는 같은 것을 가리키되 보여주는 방식은 진입 경로에 따라 갈립니다.
오늘 우리는 그 구조를 간단한 형태로 만듭니다. 피드에서 여는 상자는 ?post=1을 붙이고, /p/1은 지금처럼 전체 화면으로 둡니다.
스스로 열던 상자를 시키는 대로 열게 바꾸기
지금 우리 모달은 열림 여부를 스스로 들고 있습니다. 이런 것을 비제어라고 불러요. 여는 버튼과 상자가 한 덩어리라 밖에서는 관여하지 않습니다.
주소가 열림 여부를 정하게 하려면 상자가 스스로 판단하면 안 됩니다. 밖에서 값을 받아야 해요.
// apps/web-spa/src/components/PostModal.tsx
interface PostModalProps {
username: string;
// ...
// 아래 둘은 주소와 이어 붙일 때만 준다.
// 안 주면 상자가 예전처럼 혼자 열고 닫는다.
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function PostModal({ username, /* ... */ open, onOpenChange }: PostModalProps) {
return (
// open 이 undefined 면 상자가 스스로 상태를 들고 있는다.
// 값을 주는 순간부터는 우리가 시키는 대로만 열리고 닫힌다.
<Dialog open={open} onOpenChange={onOpenChange}>
{/* ... */}
);
}
물음표를 붙여 선택형으로 뒀습니다. 안 주면 예전 그대로 동작해요. 이러면 이 상자를 쓰던 다른 곳을 하나도 안 고쳐도 됩니다.
⚠️ onOpenChange가 받는 인자가 둘입니다
검색해서 나오는 예제 대부분은 이 함수의 인자가 하나입니다. 우리가 쓰는 꾸러미는 둘을 줘요. 실제로 눌러보면 이렇습니다.
| 무엇을 했나 | 1번 인자 | 2번 인자의 reason |
|---|---|---|
| 여는 버튼을 눌렀다 | true |
trigger-press |
| 닫기 X를 눌렀다 | false |
close-press |
| Esc를 눌렀다 | false |
escape-key |
두 번째 인자에 왜 닫혔는지가 담겨 옵니다. Esc로 닫은 것과 X로 닫은 것을 구별해야 할 때 쓸 수 있어요.
우리는 첫 번째만 쓰기 때문에 (open: boolean) => void로 받아도 문제가 없습니다. 인자를 덜 받는 함수는 더 주는 곳에 넣을 수 있거든요. 남는 인자는 그냥 무시됩니다.
값을 주소에 잇기
이제 홈 화면이 주소를 읽어 누가 열려 있는지 정합니다.
// apps/web-spa/src/routes/HomePage.tsx
// 어느 게시물이 열려 있는지를 화면이 아니라 주소가 들고 있다.
// 그래서 새로고침해도 살아남고, 링크로 보내면 상대도 그 상자를 본다.
const [searchParams, setSearchParams] = useSearchParams();
const openPost = searchParams.get('post');
const openPostId = openPost === null ? null : Number(openPost);
Step 3에서 배운 것 그대로입니다. 태그를 담던 자리에 게시물 번호를 담을 뿐이고, 그 값을 피드에 넘깁니다.
<Feed
posts={posts}
onToggleLike={toggleLike}
openPostId={openPostId}
onOpenPost={(id) => setSearchParams(id === null ? {} : { post: String(id) })}
/>
처음 목록이 이제 전부 뒤집힙니다.
모달을 연 상태에서...
주소창을 본다 → /?post=1 로 바뀌어 있다
새로고침한다 → 상자가 열린 채로 다시 뜬다
링크를 보낸다 → 상대도 그 상자를 본다
뒤로 가기를 누른다 → 상자만 닫힌다. 앱을 안 떠난다
마지막 줄이 특히 좋습니다. 모바일에서 뒤로 가기로 상자를 닫는 것은 사용자가 이미 몸에 익힌 동작이에요. 주소에 담았더니 그게 따로 만들지 않아도 그냥 됩니다.
⚠️ 그런데 값 하나가 네 단계를 내려갔습니다
방금 만든 것을 다시 보면 마음에 걸리는 부분이 있습니다.
HomePage → Feed → PostCard → PostBody → PostModal
~~~~ ~~~~~~~~ ~~~~~~~~
가운데 셋은 이 값을 쓰지도 않는다. 받아서 넘기기만 한다
Feed는 어느 상자가 열렸는지 관심이 없습니다. PostCard도, PostBody도요. 그런데 셋 다 이 값을 받아서 아래로 넘기는 코드를 갖게 됐어요.
불편하게 느껴지신다면 정확히 보신 겁니다. 이걸 props drilling이라고 부릅니다. 값이 필요한 곳과 값이 있는 곳 사이에 관심 없는 컴포넌트들이 끼어 있는 상황이에요.
지금은 네 단계라 견딜 만하지만, 화면이 커지면 여덟 단계가 되고 값이 하나가 아니라 다섯이 되면 넘기는 코드만 수십 줄이 돼요.
이 문제를 정면으로 다루는 것이 다음 두 시간입니다. 값을 나르는 방법을 먼저 배우고, 그다음에 무엇을 어디에 담을지 정합니다.
💡 한 줄 정리
모달의 열림 여부를 주소에 담으면 새로고침·공유·뒤로 가기가 전부 따라오고, 그 값을 내려보내는 길이 다음 시간부터의 숙제가 됩니다.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 주소가 이름표에서 지시문이 됐습니다.
시작할 때 우리 주소는 /와 /signup 둘뿐이었고, 둘 다 미리 정해진 글자였어요. 지금은 주소가 게시물 번호를 나르고, 고른 태그를 나르고, 열린 상자를 나르면서 그 값들이 데이터를 데려옵니다.
오늘 배운 핵심 세 가지
💡 하나 — 데이터를 부르는 시점을 옮기면 화면에서 코드가 사라집니다. Step 4의 상세 화면에는 갈래가 셋이었어요. 기다리는 중, 못 찾음, 게시물. Step 7이 끝난 지금은 하나도 없습니다. 늦게 온 응답을 막던 표시도, 정리 함수도 없어졌고요. 화면이 그려지고 있다는 것 자체가 데이터가 왔다는 뜻이 됐기 때문입니다.
💡 둘 — 라우터가 주는 값은 넷 다 타입이 약한데 방향이 다릅니다. useParams는 string | undefined, searchParams.get()은 string | null, useLoaderData는 안 알려주면 any, useRouteError는 unknown이에요. 앞의 셋은 조심하면 되지만 any는 조심할 기회조차 안 줍니다. 같은 라이브러리 안에서도 막아주는 도구와 통과시키는 도구가 섞여 있다는 것을 기억해 두세요.
💡 셋 — 화면 안에 담을지 주소에 담을지가 실제로 기능을 가릅니다. 태그 필터도 모달도, 같은 값을 어디에 담느냐만 바꿨는데 새로고침·공유·뒤로 가기가 통째로 따라왔습니다. 상태를 어디에 둘지는 취향 문제가 아니라 무엇이 되고 안 되는지의 문제였어요.
시작할 때 남아 있던 것
지난 시간과 그 앞에서 남겨둔 약속들이 어떻게 닫혔는지 봅시다.
| 약속했던 것 | 닫힌 곳 |
|---|---|
| 주소의 일부를 값으로 받기 | Step 1 — :postId와 useParams |
| 그때 필요한 데이터는 누가 언제 | Step 4 · 5 — 화면보다 먼저, loader가 |
| 모달은 주소를 가져야 하나 | Step 8 — 준다. ?post=N으로 |
| 상태를 어디에 둘지 누가 정하나 | Step 3 · 8 — 아직 절반. 다음 두 시간에 |
마지막 줄이 남았습니다. 오늘 두 갈래(화면 안 · 주소)를 봤는데, 아직 한 갈래가 더 있어요.
다음 시간 예고
오늘 마지막에 값 하나가 네 단계를 내려가는 것을 봤죠. Feed도 PostCard도 PostBody도 그 값에 관심이 없는데 전부 거쳐 갔습니다.
이 문제는 두 시간에 걸쳐 다룹니다. 해결책이 하나가 아니기 때문이에요. Context라는 것이 React에 원래 들어 있고, Zustand라는 꾸러미도 있고, 오늘 배운 주소도 여전히 선택지입니다.
다음 시간에는 그중 Context 부터 봅니다. 그런데 시작은 오늘 본 모달이 아니라 훨씬 작은 값이에요. 같은 값을 두 곳에서 읽으려 하면 무슨 일이 생기는지부터 확인하고, 값을 위에 두고 아래에서 꺼내는 길을 만듭니다.
그러고 나서 그다음 시간에 결정 기준을 세웁니다. 상태를 세 갈래로 나눠 보고, 어떤 값이 어디에 살아야 하는지를 정리해요. 오늘 만든 태그 필터와 모달이 그 표에서 어디에 놓이는지도 그때 확인합니다. 위 네 단계 배달도 거기서 걷어냅니다.
오늘 계속 미뤄둔 것이 하나 더 있는데, 우리 fetchPost는 400밀리초를 기다리는 척만 합니다. 진짜 서버는 아직 안 붙었어요. 그건 상태 정리가 다 끝난 뒤에 시작합니다.
과제
[구현] 프로필 주소 만들기
/:username 주소를 만들고 그 사람의 게시물만 모아 보여주세요.
routes/ProfilePage.tsx를 만들고 주소 표에 추가합니다./jaehoon으로 들어가면 jaehoon의 게시물만 나와야 해요.- loader를 붙여서 화면이 뜨기 전에 데이터를 가져오세요.
data/feed.ts에fetchPostsByUsername같은 함수를 하나 더 만들면 됩니다. - 없는 사용자(
/nobody)로 들어가면 어떻게 할지 정하고 그렇게 만드세요. 404를 던질까요, 빈 목록을 보여줄까요? 정답이 하나가 아닙니다. 고른 이유를 한 줄로 적어 주세요. - 여기가 핵심입니다:
/:username은 빈칸이라/explore도/signup도 받아버릴 것처럼 보입니다. 정말 그런지 확인해 보세요./:username을explore위에 놓았을 때와 아래에 놓았을 때/explore로 들어가 각각 무엇이 뜨는지 적어 주세요. 결과를 Step 7에서 배운 것으로 설명할 수 있나요?
[구현] 태그 버튼을 링크로 바꾸기
Step 3의 🙋 에서 이야기한 것을 직접 해보세요.
- 탐색 화면의 태그 버튼을
<Link>로 바꿉니다.to="?tag=카페"처럼 물음표부터 적으면 지금 경로는 그대로 두고 물음표 뒤만 바뀝니다. - 바꾼 뒤에 우클릭해서 새 탭으로 열어 보세요. 버튼일 때와 무엇이 다른가요.
- 지금 고른 태그를 표시하던
aria-pressed는 링크에 그대로 쓸 수 있을까요? 안 된다면 무엇으로 바꿔야 할지 찾아보고 이유와 함께 적어 주세요. - 마지막으로 판단해 주세요. 이 화면에서는 버튼과 링크 중 무엇이 맞을까요.
[탐구] 재보고 숫자를 적기
각 항목의 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.
postLoader에서Number(params.postId)의Number를 지우고/p/1에 들어가 보세요. 무슨 화면이 뜨나요. 타입 검사는 뭐라고 하나요.useLoaderData<typeof postLoader>()에서 꺾쇠 부분만 지우고, 그 아래에서post.존재하지않는필드를 써 보세요. 타입 검사가 잡나요? 화면은 어떻게 되나요?- 어떤 주소가 별표까지 내려가는지 찾아보세요.
/p/1/comments처럼 조각을 하나 더 붙이면 어디로 갈까요./p/처럼 빈칸을 비워두면요? 각각 무엇이 떴는지 적어 주세요. navigate('/', { replace: true })에서replace옵션만 지우고 가입을 끝낸 뒤 뒤로 가기를 눌러 보세요. 무엇이 나오나요./p/1에서/p/5로 이동할 때 loader가 다시 도는지 확인해 보세요. 네트워크 탭을 열어봐도 아무것도 안 잡힐 텐데, 왜 그런지 먼저 생각해 보고 그럼 무엇으로 확인할 수 있을지 방법을 찾아 적어 주세요.
[설계] ErrorBoundary를 아래로 내려보기
Step 7 마지막에 남긴 질문입니다.
- 지금
ErrorBoundary는 맨 위 라우트에 달려 있어서 오류가 나면 머리말까지 사라집니다. 이걸p/:postId라우트에 직접 달아 보세요. /p/999로 들어갔을 때 화면이 어떻게 달라지나요.- 두 방식의 장단점을 각각 두 줄씩 적어 주세요. 어느 쪽을 쓸지 정하고 이유도 함께요.
- 만약 둘 다 두고 싶다면 어떻게 될까요. 위아래 양쪽에 달면 어느 쪽이 잡을까요? 예상을 먼저 적고 그다음 확인해 보세요.
생각해볼 주제
1. 주소에 담을 것과 담지 말아야 할 것
오늘 우리는 고른 태그와 열린 모달을 주소에 담았습니다. 담았더니 새로고침·공유·뒤로 가기가 전부 따라왔죠. 좋은 것만 있는 것처럼 보입니다.
그렇다면 모든 화면 상태를 주소에 담으면 어떨까요. 캡션을 펼쳤는지, 어떤 댓글에 마우스를 올렸는지, 스크롤이 어디까지 갔는지까지요.
어디까지가 담을 만한 값이고 어디부터가 아닐까요. 판단 기준을 한 문장으로 만들어 보고, 그 기준으로 봤을 때 애매한 값을 하나 떠올려 그 경우에는 무엇을 더 따져봐야 할지 생각해 보세요.
2. 같은 데이터, 다른 그릇
오늘 게시물 하나를 보는 방법이 둘이 됐습니다. /p/1은 전체 화면이고 /?post=1은 피드 위에 뜨는 상자예요. 같은 데이터를 두 방식으로 보여줍니다.
진짜 인스타그램은 한 발 더 나가서, 같은 주소인데 어디서 왔느냐에 따라 다르게 보여줍니다. 피드에서 눌렀으면 상자, 링크로 들어왔으면 전체 화면이죠.
이걸 구현한다고 상상해 보세요. "어디서 왔는지"를 앱이 어떻게 알 수 있을까요. 그리고 이렇게까지 만들 가치가 있을까요? 얻는 것과 늘어나는 복잡함을 견주어 보고, 어떤 서비스라면 할 만하고 어떤 서비스라면 과한지 생각해 보세요.
3. 도구가 나를 막아주는가
오늘 같은 라이브러리 안에서 정반대인 두 훅을 봤습니다. useRouteError는 좁히기 전에는 아무것도 못 하게 막았고, useLoaderData는 아무것도 안 알려줘도 통과시켰어요.
막아주는 쪽이 무조건 좋아 보이지만, 막는 도구는 쓰기 번거롭고 배우는 사람에게는 벽처럼 느껴지기도 합니다. 실제로 많은 라이브러리가 처음에는 느슨하게 시작해서 나중에 엄격해져요.
여러분이 팀에서 쓸 도구를 고른다면 이 축을 얼마나 중요하게 볼지 생각해 보고, 통과시키는 쪽 도구를 이미 쓰고 있다면 팀에서 그 구멍을 어떻게 메울 수 있을지 구체적인 방법을 하나 떠올려 보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 프로필 주소 만들기
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
| 동적 세그먼트 | :username으로 빈칸을 만들었는가 |
15 |
| loader 분리 | 데이터를 컴포넌트 밖에서 가져오는가 (useEffect를 안 썼는가) |
20 |
| 타입 연결 | useLoaderData<typeof ...>()로 제네릭을 채웠는가 |
15 |
| 없는 사용자 | 처리 방식을 정하고 이유를 적었는가 (정답 하나 아님) | 20 |
| 순서 실험 | 위·아래 두 경우를 다 확인하고 결과를 적었는가 | 30 |
풀이 예시
먼저 데이터를 가져오는 함수를 하나 더 만듭니다.
// apps/web-spa/src/data/feed.ts
export async function fetchPostsByUsername(username: string): Promise<Post[]> {
await delay(NETWORK_DELAY_MS);
return allPosts.filter((post) => post.username === username);
}
loader는 이렇게 됩니다. 게시물 상세와 달리 빈 배열도 정상이라고 보기로 했습니다.
// apps/web-spa/src/routes/profileLoader.ts
export async function profileLoader({ params }: LoaderFunctionArgs) {
const username = params.username ?? '';
const posts = await fetchPostsByUsername(username);
return { username, posts };
}
없는 사용자를 어떻게 다룰지가 이 과제의 판단 지점입니다. 두 선택지 모두 근거가 있어요.
| 선택 | 근거 | 약점 |
|---|---|---|
| 404 를 던진다 | 없는 사람의 프로필은 존재하지 않는 화면이다 | 게시물이 0개인 실재하는 사용자와 구별이 안 된다 |
| 빈 목록을 보여준다 | 가입은 했는데 아직 안 올린 사람이 있다 | 오타로 들어온 사용자에게 "없다" 를 못 알려준다 |
우리 데이터에는 사용자 목록이 따로 없고 게시물만 있어서, "게시물이 0개"와 "그런 사람 없음"을 구별할 방법이 지금은 없어요. 구별할 수 없는 것을 구별하는 척하면 안 되니, 빈 목록을 보여주고 "아직 게시물이 없습니다"로 안내하는 쪽을 골랐습니다.
사용자 목록이 생기면 그때 404로 바꾸는 게 맞습니다. 판단이 데이터에 달려 있다는 것을 적어두면 좋은 답안입니다.
🌟 순서 실험 — 여기가 이 과제의 핵심입니다
/:username은 빈칸이라 /explore도 삼킬 것처럼 보입니다. 그래서 대부분 이렇게 예상해요. "explore 아래에 둬야 안전하겠다."
직접 두 판을 만들어 /explore로 들어가 봤습니다.
| 표에 놓은 순서 | /explore로 들어가면 |
/jaehoon으로 들어가면 |
|---|---|---|
:username을 explore 위에 |
탐색 화면 | 프로필 화면 |
:username을 explore 아래에 |
탐색 화면 | 프로필 화면 |
두 줄이 완전히 같습니다. 순서를 바꿔도 아무 일도 안 일어나요.
Step 7에서 본 그대로입니다. 라우터는 표를 위에서부터 훑지 않고 후보를 다 모은 다음 가장 구체적인 것을 고릅니다. explore는 정해진 글자라 빈칸인 :username을 이깁니다.
"안전한 순서"를 찾으려 했다면 애초에 없는 문제를 풀고 있었던 거예요. 그래도 저는 :username을 아래에 둡니다. 동작이 아니라 읽는 순서 때문입니다.
자주 나오는 실수
params.username 을 그대로 믿고 쓰는 것. 이 값도 string | undefined입니다. ?? '' 나 검사 없이 fetchPostsByUsername(params.username)을 부르면 타입 검사에서 막혀요.
loader 를 만들어놓고 useEffect를 같이 쓰는 것. 둘 다 있으면 데이터를 두 번 가져옵니다. loader를 붙였으면 화면에서는 useLoaderData만 씁니다.
useLoaderData() 의 꺾쇠를 비워두는 것. 이 과제에서 특히 티가 납니다. posts가 any가 되면 posts.map 안에서 post.usrname 같은 오타를 아무도 안 잡아줘요.
/:username을 p/:postId와 같은 층으로 착각하는 것. /jaehoon은 조각이 하나고 /p/7은 둘입니다. 서로 안 겹쳐요.
🎯 [과제 2 예시답안] 태그 버튼을 링크로 바꾸기
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
| 상대 주소 | to="?tag=카페"처럼 물음표부터 적었는가 |
20 |
| 우클릭 확인 | 새 탭으로 열어보고 버튼과의 차이를 적었는가 | 20 |
aria-pressed |
링크에는 쓸 수 없다는 것과 대체 속성을 찾았는가 | 35 |
| 최종 판단 | 어느 쪽이 맞는지 정하고 이유를 적었는가 | 25 |
풀이 예시
물음표부터 적으면 지금 경로는 그대로 두고 물음표 뒤만 바뀝니다.
// apps/web-spa/src/routes/ExplorePage.tsx
<Link to={tag === name ? '?' : `?tag=${name}`}>{name}</Link>
한 번 더 눌렀을 때 꺼지게 하려면 이미 고른 태그인지 보고 ?만 주면 됩니다.
우클릭해서 새 탭으로 열어보면 차이가 바로 보여요.
| 버튼 | 링크 | |
|---|---|---|
| 우클릭 → 새 탭 | ❌ 메뉴에 안 나옴 | ✅ 열린다 |
| 주소 복사 | ❌ 안 됨 | ✅ 된다 |
| 가운데 클릭 | ❌ 아무 일 없음 | ✅ 새 탭 |
| 검색 엔진이 따라감 | ❌ | ✅ |
aria-pressed는 링크에 못 씁니다
이 속성은 눌린 채로 남아 있는 버튼을 위한 것입니다. 굵게·기울임 버튼처럼인데, role="button"인 요소에만 붙일 수 있고 <a href>는 역할이 링크라 이 속성이 무시됩니다. 낭독기가 아무것도 안 읽어줘요.
링크에서 "지금 이것"을 알리는 속성은 따로 있습니다. aria-current입니다.
<Link to={`?tag=${name}`} aria-current={tag === name ? 'true' : undefined}>
{name}
</Link>
지난 시간에 이미 만난 속성이에요. NavLink가 켜진 링크에 aria-current="page"를 알아서 붙여줬죠. 그때는 도구가 대신 해줬고 지금은 우리가 직접 붙입니다.
값이 두 종류인 이유도 여기서 갈립니다. 화면 자체가 바뀌면 page를 쓰고, 목록 안에서 고른 항목이면 true를 씁니다. 태그 필터는 화면이 안 바뀌니 true가 맞아요.
한 가지 주의할 것은 꺼진 상태를 "false"로 두면 안 된다는 점입니다. aria-pressed는 꺼짐을 "false"로 표현하지만, aria-current는 아닐 때 속성 자체를 없애야 하기 때문에 위 코드가 undefined를 줘요.
최종 판단
링크가 맞습니다. 기준은 단순해요. 이 동작이 주소를 바꾸나요? 바꾸면 링크입니다.
버튼은 화면 안에서 무언가를 실행할 때 씁니다. 좋아요 누르기, 모달 열기, 폼 제출 같은 것이요. 태그를 고르는 것은 실행이 아니라 다른 주소로 가는 일입니다.
교안에서 버튼으로 만든 것은 setSearchParams를 직접 불러봐야 값이 어디에 담기는지 보이기 때문이었어요. 배우고 나면 링크로 바꾸는 게 맞습니다.
자주 나오는 실수
to="/explore?tag=카페"로 경로까지 적는 것. 동작은 하는데 화면 이름이 코드에 박힙니다. 나중에 이 컴포넌트를 다른 주소에서 재사용하면 엉뚱한 곳으로 보내요.
aria-pressed를 그냥 링크에 옮겨 붙이는 것. 브라우저가 오류를 안 내서 지나치기 쉽습니다. 눈으로는 멀쩡한데 낭독기 사용자에게만 정보가 안 가요. 우리 프로젝트에는 접근성 검사 도구가 아직 없어서 아무도 안 알려줍니다.
전체 버튼까지 링크로 바꾸면서 to="?" 대신 to="/explore"로 적는 것. 결과는 같지만 위와 같은 이유로 상대 주소가 낫습니다.
🎯 [과제 3 예시답안] 재보고 숫자를 적기
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
Number 제거 |
타입 검사가 막는다는 것을 확인했는가 | 20 |
| 제네릭 제거 | 반대로 안 막는다는 것을 확인하고 대비했는가 | 25 |
| 별표 도달 | 어떤 주소가 별표까지 가는지 실제로 찾았는가 | 20 |
replace 제거 |
뒤로 가기 결과를 직접 눌러 확인했는가 | 15 |
| loader 재실행 | 네트워크 탭이 비는 이유를 설명하고 다른 방법을 찾았는가 | 20 |
풀이 예시
① postLoader에서 Number를 지우면
error TS2345: Argument of type 'string | undefined' is not assignable
to parameter of type 'number'.
Type 'undefined' is not assignable to type 'number'.
실행까지 못 갑니다. fetchPost가 숫자를 받겠다고 적어뒀는데 문자열을 주려 했으니까요.
Step 1의 find 쪽에서 지우면 메시지가 다르게 나옵니다.
error TS2367: This comparison appears to be unintentional
because the types 'number' and 'string | undefined' have no overlap.
한쪽은 "인자가 안 맞는다", 다른 쪽은 "겹치는 부분이 없는 비교다"예요. 둘 다 잡아준다는 것이 중요합니다.
② useLoaderData의 꺾쇠를 비우면
const { post } = useLoaderData();
return <p>{post.존재하지않는필드}</p>;
→ 타입 검사: 아무 말 없음
→ 화면: 그 자리가 빈다 (아무것도 안 그려진다)
→ 콘솔: 조용함
①과 정반대입니다. 같은 종류의 실수인데 한쪽은 막히고 한쪽은 통과해요. 차이는 딱 하나, any 냐 아니냐입니다.
이 둘을 나란히 적어두는 것이 이 과제의 핵심입니다. 도구가 늘 막아준다고 믿으면 안 되고, 어디가 뚫려 있는지 알아야 한다는 뜻이니까요.
③ 별표까지 내려가는 주소
| 주소 | 어디로 갔나 | 받은 값 |
|---|---|---|
/p/1/comments |
* |
{ "*": "p/1/comments" } |
/p/ |
* |
{ "*": "p/" } |
/p |
* |
{ "*": "p" } |
/explore/ |
explore |
{} |
/EXPLORE |
explore |
{} |
앞의 셋은 p/:postId에 안 걸립니다. /p/1/comments는 조각이 하나 많고, /p/와 /p는 빈칸이 비어 있어요. 빈칸은 뭐든 받지만 비어 있는 것은 안 받습니다.
별표가 받은 값도 재밌습니다. params의 "*" 키에 남은 경로가 통째로 들어와요. 이걸로 "혹시 이런 주소를 찾으셨나요?" 같은 안내를 만들 수도 있습니다.
뒤의 둘은 덤으로 알게 된 것입니다. 끝에 빗금이 붙어도, 대문자로 써도 같은 화면이 뜹니다. 대소문자를 구분하게 하려면 그 라우트에 따로 설정을 켜야 해요.
④ replace를 지우면
replace 있을 때 가입 완료 → 뒤로 가기 → 홈
replace 없을 때 가입 완료 → 뒤로 가기 → 방금 채운 회원가입 폼
내부적으로 무엇이 갈리는지도 볼 수 있습니다. 기록에 무슨 일이 있었는지가 PUSH와 REPLACE로 남아요. 기본은 PUSH(쌓기)고, 옵션을 주면 REPLACE(갈아끼우기)입니다.
⑤ loader가 다시 도는지 확인하기
네트워크 탭에는 아무것도 안 잡힙니다. 우리 fetchPost가 진짜 요청이 아니기 때문이에요. setTimeout으로 기다리는 척만 하고 데이터는 이미 브라우저 안에 있어서, 다른 방법을 씁니다.
- loader 안에
console.log(params.postId)를 넣고 이동하면 두 번 찍힙니다. 가장 확실해요. - 눈으로도 알 수 있습니다.
/p/1에서/p/5를 누르면 400밀리초 동안 화면이 안 바뀌다가 갈립니다. 그 멈칫함이 loader가 도는 시간이에요.
이 항목의 진짜 답은 "우리 앱에는 아직 네트워크가 없다"를 알아채는 것입니다. 진짜 요청이 생기면 그때는 네트워크 탭에 잡히고, 그건 다음 모듈에서 만듭니다.
자주 나오는 실수
②에서 화면이 안 터지니까 문제없다고 넘어가는 것. 빈 자리가 뜨는 것은 사용자에게는 오류와 같습니다. 다만 우리에게만 안 보일 뿐이에요.
③에서 /p/가 p/:postId에 걸릴 거라고 예상하고 확인을 안 하는 것. 빈칸은 비워둘 수 없다는 규칙은 직접 넣어봐야 압니다.
⑤에서 "네트워크 탭에 안 나오니까 loader가 안 도나 보다"로 결론짓는 것. 도구에 안 보이는 것과 안 일어나는 것은 다릅니다.
🎯 [과제 4 예시답안] ErrorBoundary를 아래로 내려보기
채점 포인트
| 항목 | 확인할 것 | 배점 |
|---|---|---|
| 위치 이동 | p/:postId 라우트에 직접 달았는가 |
20 |
| 화면 차이 | 머리말이 남는다는 것을 확인했는가 | 25 |
| 장단점 | 두 방식을 각각 두 줄씩 근거를 들어 적었는가 | 30 |
| 양쪽 실험 | 예상을 먼저 적고 확인했는가 | 25 |
풀이 예시
라우트 한 줄에 ErrorBoundary를 함께 답니다.
{
path: 'p/:postId',
loader: postLoader,
Component: PostDetailPage,
ErrorBoundary: RootErrorBoundary,
}
/p/999로 들어가면 차이가 바로 보입니다.
맨 위에 달았을 때 오류 화면만 남는다. 머리말이 사라진다
이 줄에 달았을 때 머리말이 그대로 있고 그 아래만 오류 화면
이유는 무엇을 대신하느냐입니다. ErrorBoundary는 자기가 붙은 라우트의 Component 자리를 대신해요. 맨 위 라우트에 붙으면 Layout을 대신하니 머리말까지 통째로 갈리고, 자식 라우트에 붙으면 Outlet 안쪽만 갈립니다.
| 맨 위에 하나 | 화면마다 따로 | |
|---|---|---|
| 좋은 점 | 어디서 터져도 반드시 잡힌다 | 머리말이 남아 사용자가 다른 데로 갈 수 있다 |
| 오류 화면을 한 곳에서 관리한다 | 화면 성격에 맞는 안내를 줄 수 있다 | |
| 나쁜 점 | 머리말이 사라져 길을 잃는다 | 안 붙인 화면이 생기면 구멍이 난다 |
| 무슨 화면에서 터졌는지 안 보인다 | 같은 코드가 여러 곳에 흩어진다 |
저는 둘 다 씁니다. 맨 위에 하나를 그물처럼 깔아 아무것도 새지 않게 하고, 자주 실패하는 화면에는 따로 붙여 머리말을 지킵니다.
양쪽에 달면 어느 쪽이 잡을까요
예상부터 적어봅시다. 두 갈래가 다 그럴듯해요.
- 위쪽이 잡는다 — 오류는 위로 올라가는 것이니까
- 아래쪽이 잡는다 — 더 가까우니까
확인해 보면 가까운 쪽, 즉 아래쪽이 잡습니다. 그리고 머리말도 그대로 남아요.
try/catch를 떠올리면 자연스럽습니다. 중첩된 try 안에서 던지면 가장 안쪽 catch가 먼저 받죠. 여기서도 던진 지점에서 가장 가까운 경계가 받습니다.
이게 실무에서 유용한 이유가 있어요. 맨 위 그물을 깔아둔 상태에서 특정 화면만 다르게 처리하고 싶을 때, 위쪽을 건드리지 않고 그 화면에만 하나 더 붙이면 됩니다. 위쪽은 나머지를 계속 지키고요.
자주 나오는 실수
예상을 안 적고 확인부터 하는 것. 예상이 틀렸을 때 배우는 게 가장 많습니다. 맞았으면 확신이 생기고요. 결과만 보면 둘 다 못 얻어요.
아래에 달았으니 위엣것은 지워도 된다고 판단하는 것. 지우면 Layout 자체가 터졌을 때 잡을 곳이 없어집니다. 흰 화면만 남아요.
/p/abc로만 확인하는 것. 그건 Error를 던지는 경로입니다. /p/999(404를 던지는 경로)로도 확인해야 두 갈래가 다 지나가는지 알 수 있어요.
🤔 [생각해볼 주제 1] 주소에 담을 것과 담지 말아야 할 것
문제 상황 요약
값을 주소에 담으면 새로고침·공유·뒤로 가기가 따라옵니다. 좋은 것뿐인 듯하니 전부 담으면 될까요. 담을 값과 안 담을 값을 가르는 기준을 만드는 문제입니다.
튜터의 가이드 및 해설
전부 담으면 어떻게 되는지부터 상상해 봅시다.
/explore?tag=카페&caption3=open&hover=7&scroll=1240&focus=tag-2
주소가 이렇게 됩니다. 링크로 보내면 상대의 화면에서 3번 캡션이 펼쳐지고 7번에 마우스가 올라간 상태가 재현되는데, 상대가 그걸 원했을까요.
기준을 한 문장으로 만들면 이렇습니다. "이 링크를 받은 사람이 나와 같은 것을 보고 싶어 할까?"
이 질문으로 오늘 다룬 값들을 통과시켜 봅시다.
- 고른 태그 — 그렇습니다. "카페 사진 모아둔 거 봐봐"는 실제로 하는 말이에요
- 열린 게시물 — 그렇습니다. "이 게시물 봐봐"가 링크의 본래 용도죠
- 펼친 캡션 — 아닙니다. "내가 이 캡션 펼친 상태로 봐줘"라고 말하는 사람은 없어요
- 스크롤 위치 — 아닙니다. 그리고 화면 크기가 다르면 재현되지도 않습니다
기준이 하나 더 필요한 경우도 있습니다. 되돌리기가 필요한가예요. 주소에 담으면 뒤로 가기로 취소할 수 있습니다. 모달이 여기 해당해요. 공유 가치가 없더라도 뒤로 가기로 닫히는 게 자연스러우면 담을 이유가 됩니다.
애매한 값을 하나 들면 정렬 순서입니다. "최신순 / 인기순" 같은 것이요.
공유 가치로 보면 애매합니다. 링크를 받은 사람이 내 정렬을 그대로 봐야 할 이유는 없지만, 되돌리기로 보면 담을 만합니다. 정렬을 바꿨다가 뒤로 가기로 되돌리는 것은 자연스럽거든요.
이럴 때는 세 번째 기준을 봅니다. 그 값이 없으면 화면이 이상해지는가. 정렬은 기본값이 있으니 없어도 멀쩡해요. 그래서 담아도 되고 안 담아도 되는데, 담기로 했다면 기본값일 때는 주소에서 빼는 처리를 같이 해야 합니다. ?sort=latest가 늘 붙어 있으면 주소가 지저분해지니까요.
정리하면 순서는 이렇습니다. 공유할 만한가 → 되돌릴 만한가 → 없어도 화면이 성립하는가. 앞의 둘 중 하나라도 그렇다면 담고, 담았다면 기본값은 생략합니다.
🎯 면접관을 홀리는 핵심 멘트
"URL에 담을지는 '이 링크를 받은 사람이 나와 같은 화면을 보고 싶어 할까'로 먼저 거릅니다. 여기서 애매하면 '뒤로 가기로 되돌리는 게 자연스러운가'를 봐요. 모달이 그런 경우입니다. 공유 가치는 낮지만 뒤로 가기로 닫히는 게 사용자 기대라서 담습니다. 대신 담은 값은 기본값일 때 주소에서 빼서 주소가 지저분해지지 않게 합니다."
🤔 [생각해볼 주제 2] 같은 데이터, 다른 그릇
문제 상황 요약
같은 게시물을 전체 화면으로도, 피드 위 상자로도 보여줄 수 있습니다. 진짜 인스타그램은 한 발 더 나가 같은 주소인데 어디서 왔느냐에 따라 다르게 보여줘요. 이걸 구현할 가치가 있는지 따져보는 문제입니다.
튜터의 가이드 및 해설
먼저 "어디서 왔는지"를 어떻게 아는지부터 봅시다.
브라우저는 그걸 알려주지 않습니다. 주소는 /p/7 하나뿐이고, 피드에서 눌러 온 것인지 링크를 받아 온 것인지 구별할 정보가 주소에 없어서, 우리가 직접 표시를 남깁니다. 화면을 옮길 때 주소와 함께 작은 메모를 딸려 보낼 수 있어요. 피드에서 게시물을 누를 때 "나는 피드에서 왔다"는 메모를 붙이면 상세 화면이 그 메모를 보고 판단할 수 있어요.
메모가 있으면 상자로, 없으면 전체 화면으로. 링크를 받아 들어온 사람에게는 메모가 없으니 자동으로 전체 화면이 됩니다.
여기까지는 깔끔한데, 실제로 만들어보면 따라오는 것이 많습니다.
- 상자로 띄우려면 뒤에 깔 피드가 이미 그려져 있어야 합니다. 그 상태를 유지하는 코드가 필요해요
- 상자를 닫으면 어디로 갈지 정해야 합니다. 뒤로 가기인가, 피드 주소로 보내기인가
- 새로고침하면 메모가 사라집니다. 상자로 보던 화면이 갑자기 전체 화면이 돼요
- 같은 화면 컴포넌트를 두 그릇에 넣어야 하니, 그릇에 따라 배치가 달라지는 것도 처리해야 합니다
들이는 노력에 비해 얻는 것은 "피드를 훑던 흐름이 안 끊긴다" 하나입니다.
그래서 판단은 이렇게 갈립니다. 훑는 것이 핵심인 서비스면 할 만합니다. 인스타그램, 핀터레스트처럼 사용자가 계속 스크롤하며 머무는 곳이요. 한 번 흐름이 끊기면 그 세션이 끝나버리기 때문에 이 하나가 지표에 직접 잡힙니다.
찾아 들어가는 것이 핵심인 서비스면 과합니다. 검색해서 상세를 보고 나가는 쇼핑몰이나 문서 사이트에서는 전체 화면 하나로 충분해요. 사용자가 목록으로 돌아가는 빈도 자체가 낮습니다.
한 가지 덧붙이면, 이런 기능은 처음부터 만들 것이 아닙니다. 전체 화면을 먼저 만들어 두고, 목록으로 돌아가는 사용자가 실제로 많다는 것을 확인한 다음에 얹는 순서가 맞아요. 순서를 거꾸로 하면 아무도 안 쓰는 복잡함만 남습니다.
🎯 면접관을 홀리는 핵심 멘트
"같은 주소를 진입 경로에 따라 다르게 보여주는 것은 브라우저가 안 알려주니 우리가 이동할 때 표시를 남겨야 합니다. 그런데 새로고침하면 그 표시가 사라지니 두 그릇을 모두 완성해두는 게 전제예요. 얻는 것은 '훑던 흐름이 안 끊긴다' 하나라, 스크롤 체류가 핵심 지표인 서비스에서만 값을 합니다. 저라면 전체 화면부터 만들고 목록 복귀율을 본 뒤에 얹겠습니다."
🤔 [생각해볼 주제 3] 도구가 나를 막아주는가
문제 상황 요약
같은 라이브러리 안에서 useRouteError는 좁히기 전에 아무것도 못 하게 막았고, useLoaderData는 아무것도 안 알려줘도 통과시켰습니다. 도구를 고를 때 이 축을 얼마나 중요하게 볼지, 그리고 이미 뚫린 도구를 쓰고 있다면 어떻게 메울지 생각하는 문제입니다.
튜터의 가이드 및 해설
막아주는 쪽이 좋다는 답은 너무 쉽습니다. 대가를 같이 봐야 해요.
막는 도구는 처음 배우는 사람에게 벽입니다. 오류 메시지가 길고, 무엇을 고쳐야 할지 바로 안 보이고, 검색해도 자기 상황과 딱 맞는 답이 잘 안 나옵니다. 팀에 신입이 들어올 때마다 같은 곳에서 막히고, 막는 도구는 탈출구가 필요합니다. 정말 예외적인 상황에서 규칙을 어겨야 할 때 방법이 없으면 사람들이 도구 전체를 우회하기 시작하는데, 그러면 안전장치가 통째로 사라집니다.
그래서 저는 이 축을 단독으로는 크게 안 봅니다. 대신 이렇게 봅니다. 뚫린 구멍이 어디인지 문서에 적혀 있는가.
useLoaderData는 기본값이 any라는 것이 타입 정의에 그대로 보입니다. 숨기지 않았어요. 이런 도구는 괜찮습니다. 우리가 알고 대비할 수 있으니까요. 나쁜 것은 뚫려 있는데 뚫린 줄 모르게 만든 도구입니다.
이미 뚫린 도구를 쓰고 있다면 메우는 방법은 세 가지가 있습니다. 힘이 약한 순서로요.
첫째, 규칙으로 정하기. "useLoaderData는 반드시 제네릭을 채운다"를 팀 문서에 적습니다. 가장 쉽지만 가장 약해요. 새로 온 사람은 문서를 안 읽고, 급할 때는 다들 잊습니다.
둘째, 감싸서 좁은 문만 남기기. 원래 훅을 직접 못 쓰게 하고 우리가 만든 것을 쓰게 합니다.
// 제네릭을 빼면 any 가 아니라 unknown 이 되도록 감싼 판
export function useTypedLoaderData<T extends (...args: never[]) => unknown>(): Awaited<
ReturnType<T>
> {
return useLoaderData() as Awaited<ReturnType<T>>;
}
핵심은 제약(extends)입니다. 이게 있으면 제네릭을 빠뜨렸을 때 T가 제약 그대로 잡히고, 결과가 unknown이 돼요.
useTypedLoaderData<typeof postLoader>() → { post: Post } 정상
useTypedLoaderData() → unknown 쓰는 순간 막힌다
any를 unknown으로 바꾼 것뿐인데 결과가 정반대가 됩니다. 빠뜨려도 조용히 넘어가지 않고 오늘 본 useRouteError처럼 걸려요.
감싼 것이 늘어나면 새로 온 사람이 원본 문서를 봐도 우리 코드와 안 맞아 헷갈리기 때문에, 감쌀 때는 이름을 원본과 비슷하게 두고 왜 감쌌는지 한 줄 남겨두는 편이 낫습니다.
셋째, 자동으로 잡게 하기. 규칙을 검사 도구에 넣습니다. 가장 강하지만 만드는 데 품이 들고, 예외 처리를 잘못하면 사람들이 검사를 통째로 꺼버립니다.
고르는 기준은 그 실수가 얼마나 자주 나고 얼마나 늦게 발견되는가입니다. 자주 나고 늦게 발견되면 셋째까지 갑니다. 드물게 나거나 금방 들키면 첫째로 충분해요.
useLoaderData는 어디에 해당할까요. 자주 납니다. 그리고 화면에 빈 자리가 뜨는 것으로만 드러나니 늦게 발견돼요. 저라면 둘째까지는 하겠습니다.
🎯 면접관을 홀리는 핵심 멘트
"라이브러리를 고를 때 '엄격한가'보다 '뚫린 곳이 문서에 드러나 있는가'를 봅니다. 알려진 구멍은 대비할 수 있지만 숨은 구멍은 못 하니까요. 그리고 구멍을 메우는 방법은 규칙·래퍼·자동검사 순으로 강해지는데, 고르는 기준은 그 실수의 빈도 × 발견까지 걸리는 시간입니다. 자주 나는데 배포하고 나서야 드러나는 것은 래퍼로 아예 못 쓰게 막습니다."