D-9: 배포 & 최적화 ① — 늦게 도착하는 것들
목차 49
안녕하세요, 홍순구입니다. 지난 시간 마지막에 제가 이런 말을 했었죠, 우리 피드에는 사진이 없다고요.
인스타그램인데요.
지금 브라우저에서 피드를 열어보세요. @jaehoon, "오늘 한강 노을이 미쳤다", 하트, 좋아요 1240인데, 글자만 있고 노을은 어디에도 없습니다.
데이터에는 처음부터 있었는데, Post 타입을 열어보면 imageUrl이 아홉 번째 줄쯤에 얌전히 앉아 있어요. C-6에서 그 타입을 만들 때부터 있었고, 우리가 화면에 안 붙였을 뿐입니다.
오늘 붙이는데, 그냥 붙이면 이상한 일이 생겨서 사진이 한 장씩 도착할 때마다 아래에 있던 글이 아래로 밀리면서 읽고 있던 문장이 손가락 밑에서 도망가요. 그 밀림을 재보고 없애는데, 사진만 늦게 오는 게 아니라 글꼴도 늦게 오고 제목은 아예 안 바뀌는 것까지 하나 더 있어요.
💡 오늘 수업의 핵심 — "늦게 올 것의 자리를 미리 잡아둡니다"
브라우저는 정직해서 사진이 몇 대 몇인지 모르면 자리를 못 비우는데, 안 비우고 있다가 사진이 도착하는 순간 그만큼을 밀어냅니다.
그렇게 밀어내는 걸 막으려면 사진을 빨리 받는 게 아니라 얼마나 클지 미리 알려주는 것이 해법인데, 오늘 배우는 도구들이 전부 이 한 문장 위에 서 있어요. next/image도, next/font도요.
그리고 오늘 한 번 이상한 걸 만나게 됩니다. 개발 서버에서는 요란하게 터지는데 진짜로 빌드해서 띄우면 아무 일도 없는 것처럼 보이는 자리요. 200이라고 다 정상이 아니라는 걸 D-4에서 한 번 봤었는데, 오늘 다른 얼굴로 다시 만납니다.
🎯 학습 목표
- 사진이 늦게 도착할 때 아래 글이 얼마나 밀리는지 직접 재고,
next/image로 0으로 만듭니다. remotePatterns로 사진을 가져올 곳을 허락하고, 서버가 사진을 어떻게 다시 만들어 주는지 크기로 확인합니다.- 어느 사진을 먼저 받을지 정하고, 그 자리에서 폐기된 옛 이름과 현행 이름을 가릅니다.
next/font와 Metadata API로 글꼴과 제목을 각 화면에 맞게 붙입니다.
오늘의 여정
1. 인스타그램인데 사진이 없습니다 그냥 <img> 로 붙인다 -- 4,233px 밀린다
2. 자리를 미리 잡아둡니다 next/image 와 width/height -- 0px
3. 그런데 사진이 안 나옵니다 개발과 프로덕션이 반대로 움직인다
4. 허락한 곳에서만 가져옵니다 remotePatterns -- 74KB 가 27KB 로
5. 어느 사진을 먼저 받을까요 Next 가 직접 알려주는 경고
6. 글꼴도 늦게 옵니다 next/font -- 조각 125개와 대역 배우
7. 어느 화면에 가 있든 제목이 같습니다 generateMetadata
8. 남이 우리 링크를 볼 때 og 태그
Step 1: "인스타그램인데 사진이 없습니다"
먼저 데이터부터 확인하고 가는데, 우리가 안 그린 건지 데이터에 없는 건지부터 봐요.
// apps/web-next/lib/posts.ts
export interface Post {
id: number;
username: string;
profileImageUrl: string;
imageUrl: string;
mediaKind: 'image' | 'carousel' | 'video';
content: string;
hashtagNames: string[];
likeCount: number;
commentCount: number;
liked: boolean;
createdAt: string;
}
imageUrl도 profileImageUrl도 있어서 서버는 계속 사진 주소를 보내주고 있었는데, 우리가 안 쓴 겁니다.
붙여봅시다
FeedList를 열어서 사용자 이름과 본문 사이에 한 줄을 넣습니다.
// apps/web-next/app/components/FeedList.tsx
import { LikeButton } from './LikeButton';
import { fetchPosts } from '@/lib/api';
// 지시어가 없다. 그러니 서버 컴포넌트다.
// 서버에서 도니까 함수 안에서 그냥 기다렸다 받으면 된다 — 훅도, 상태도 없다.
export async function FeedList() {
const posts = await fetchPosts();
return (
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id} className="rounded border border-black/10 p-4">
<p className="font-semibold">@{post.username}</p>
{/* 데이터에는 처음부터 있었다. 화면에 붙이는 건 지금이 처음이다. */}
<img src={post.imageUrl} alt={post.content} />
<p className="mt-1">{post.content}</p>
<LikeButton postId={post.id} likeCount={post.likeCount} liked={post.liked} />
</li>
))}
</ul>
);
}
새로고침하면 사진이 나오는데, 한강 노을도 제주도도요.
새로고침을 한 번 더 해보면, 이번엔 스크롤을 조금 내린 채로 읽으려던 문장이 아래로 도망가요.
얼마나 밀리는지 재봅시다
느낌으로 말하면 안 되니까 숫자로 봅시다. 화면 폭 1853, 게시물 카드 안쪽 폭이 494픽셀인 창에서 잰 값입니다.
| 사진이 오기 전 | 사진이 온 뒤 | |
|---|---|---|
| 첫 사진 자리의 높이 | 24px | 494px |
| 문서 전체 높이 | 1,686px | 6,390px |
| 열 번째 게시물의 위치 | 1,524px | 5,757px |
열 번째 게시물이 4,233픽셀 아래로 밀려납니다. 화면 네 개 반쯤 되는 거리예요.
24픽셀은 사진이 아직 없을 때 그 자리에 있던 것인데, alt 글자 한 줄의 높이라서 브라우저 입장에서는 그게 전부였어요.
왜 밀릴까요
브라우저가 HTML을 읽는 순서를 생각해봅시다.
1) HTML 을 읽는다 <img src="https://picsum.photos/...">
→ 주소만 있다. 크기는 안 적혀 있다
2) 자리를 잡는다 "얼마나 클지 모르겠는데... 일단 0 으로 두자"
→ 아래 글이 위로 바짝 붙는다
3) 사진이 도착한다 640 x 640 이었구나
→ 494px 을 확보해야 한다
→ 아래 있던 것들을 전부 494px 만큼 밀어낸다
문제는 2번과 3번 사이인데, 그 사이에 학생이 글을 읽고 있고 링크를 누르려고 손가락을 대고 있어요.
이게 웹 성능 지표 중 CLS(Cumulative Layout Shift, 누적 레이아웃 이동)로 측정되는 항목인데, 얼마나 빨리 그렸느냐가 아니라 얼마나 안 흔들렸느냐를 재는 지표예요.
사실 도구가 미리 말해줬습니다
터미널에서 린트를 한 번 돌려보세요.
npx eslint
app/components/FeedList.tsx
16:11 warning Using `<img>` could result in slower LCP and higher bandwidth.
Consider using `<Image />` from `next/image` ...
@next/next/no-img-element
✖ 1 problem (0 errors, 1 warning)
error가 아니라 warning인데, 종료 코드는 0이라 빌드도 통과해요. 아무도 우리를 막지 않고, 그냥 알려만 줍니다.
💡 한 줄 정리
폭과 높이를 안 알려준 사진은 도착하는 순간 아래 있는 모든 것을 밀어낸다. 우리 피드에서는 열 번째 게시물이 4,233픽셀 내려갔다.
🙋 학생 질문 — "튜터님, CSS로 높이를 고정하면 되는 거 아닌가요?"
맞는데, height: 494px를 박으면 밀림은 사라져요. 실제로 그렇게 하는 사이트도 많고요.
문제는 사진마다 비율이 다르다는 것인데, 인스타그램에는 정사각형도 있고 세로로 긴 것도 있어요. 높이를 하나로 박으면 어떤 사진은 잘리고 어떤 사진은 빈 공간이 남습니다.
그리고 화면 폭이 바뀌면 높이도 같이 바뀌어야 하는데, 고정값은 안 따라옵니다. 폰에서 494픽셀은 화면을 다 덮어버려요.
우리가 원하는 건 "높이를 고정"이 아니라 "비율을 미리 알려주기"입니다. 폭이 얼마가 되든 그 비율만큼 자리를 잡아달라는 거죠. 다음 Step에서 그걸 합니다.
Step 2: "자리를 미리 잡아둡니다"
Next에는 <img> 대신 쓰는 컴포넌트가 있습니다. next/image의 Image예요.
이름만 보면 그냥 감싼 것 같은데, 하는 일이 꽤 많습니다. 오늘 그 일들을 하나씩 만나게 될 텐데, 첫 번째가 지금 우리 문제입니다.
// apps/web-next/app/components/FeedList.tsx
import Image from 'next/image';
import { LikeButton } from './LikeButton';
import { fetchPosts } from '@/lib/api';
// 지시어가 없다. 그러니 서버 컴포넌트다.
// 서버에서 도니까 함수 안에서 그냥 기다렸다 받으면 된다 — 훅도, 상태도 없다.
export async function FeedList() {
const posts = await fetchPosts();
return (
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id} className="rounded border border-black/10 p-4">
<p className="font-semibold">@{post.username}</p>
{/* 원본이 몇 대 몇인지 알려준다. 그러면 사진이 오기 전에도 그 비율만큼 자리가 비워진다.
화면 폭에 맞춰 줄어드는 건 className 이 맡는다. */}
<Image
src={post.imageUrl}
alt={post.content}
width={640}
height={640}
className="h-auto w-full"
/>
<p className="mt-1">{post.content}</p>
<LikeButton postId={post.id} likeCount={post.likeCount} liked={post.liked} />
</li>
))}
</ul>
);
}
width와 height는 "화면에 이만큼 그려라"가 아닙니다. "원본이 이 비율이다"라고 알려주는 값이에요. 실제 표시 크기는 className의 w-full이 정합니다. 폭은 컨테이너를 꽉 채우고, 높이는 h-auto로 비율을 따라가요.
alt는 필수입니다. 안 적으면 타입이 통과 안 돼요. 화면을 못 보는 사람에게 이 사진이 무엇인지 읽어주는 글자니까, 컴포넌트가 아예 강제합니다.
다시 재봅시다
같은 창, 같은 방법으로 잰 값입니다.
그냥 <img> |
next/image |
|
|---|---|---|
| 사진이 오기 전 첫 사진 자리 | 24px | 494px |
| 사진이 온 뒤 | 494px | 494px |
| 열 번째 게시물이 밀린 거리 | 4,233px | 0px |
사진이 도착하기 전에 이미 494픽셀입니다. 도착해도 자리가 안 바뀌니 밀 것이 없어요.
브라우저가 받은 결과물
Next가 우리 <Image>를 무엇으로 바꿔서 내보냈는지 소스 보기로 확인해봅시다.
<img alt="오늘 한강 노을이 미쳤다" loading="lazy" width="640" height="640"
decoding="async" data-nimg="1" class="h-auto w-full"
style="color:transparent"
srcSet="/_next/image?url=...&w=640&q=75 1x,
/_next/image?url=...&w=1920&q=75 2x"
src="/_next/image?url=...&w=1920&q=75" />
결국 평범한 <img>입니다. 다만 세 가지가 붙었어요.
width="640" height="640"— 이게 자리를 잡습니다. 현대 브라우저는 이 두 값에서 비율을 계산해 미리 공간을 비워둬요.loading="lazy"— 화면에 가까워질 때까지 안 받습니다. 열 장을 한꺼번에 받지 않아요.srcSet과src— 주소가picsum.photos가 아니라/_next/image?url=...로 바뀌었습니다. 우리 서버를 거쳐서 옵니다.
마지막 게 오늘의 다음 이야기예요. 사진을 원본 그대로 안 주고 우리 서버가 손을 대려면 허락이 필요하거든요.
💡 한 줄 정리
width/height는 표시 크기가 아니라 원본 비율이다. 그 비율만 알면 브라우저가 사진이 오기 전에 자리를 비워두고, 밀림은 0이 된다.
🙋 학생 질문 — "튜터님, 그럼 <img> 에 그냥 width height를 적어도 되는 거 아닌가요?"
됩니다. 그게 사실 정답의 절반인데, <img src="..." width="640" height="640">만 해도 밀림은 사라지는 건 브라우저가 그 두 값에서 비율을 계산해주기 때문이에요.
나머지 절반은 next/image를 쓰는 진짜 이유인 srcSet과 /_next/image에 있는데, 화면 폭에 맞는 크기로 다시 만들어 주고 브라우저가 받을 수 있는 형식으로 바꿔주고 화면에 가까워질 때까지 안 받아줘요.
이걸 <img>로 직접 하려면 사진마다 크기별 파일을 미리 만들어두고 srcSet을 손으로 적어야 합니다. 열 장이면 손으로 할 만한데, 사용자가 올리는 사진이 하루 만 장이면 못 합니다.
Step 3: "그런데 사진이 안 나옵니다"
여기서 좀 이상한 일이 생깁니다. 코드는 방금 그대로인데, 개발 서버에서 피드를 열면 이렇게 나옵니다.
피드를 불러오는 중…
그대로 멈춰 있어서, 사진은커녕 글자도 안 나옵니다. 그런데 응답 상태는 200이에요.
터미널을 보면 이유가 적혀 있습니다.
⨯ Error: Invalid src prop (https://picsum.photos/seed/post1/640/640) on `next/image`,
hostname "picsum.photos" is not configured under images in your `next.config.js`
우리 서버가 아무 주소의 사진이나 받아다 다시 만들어주지 않는다는 뜻인데, 이유는 Step 4에서 볼게요. 지금 볼 것은 이 사실이 어디에서 드러나느냐입니다.
빌드는 통과합니다
같은 코드로 프로덕션 빌드를 돌려봅시다.
rm -rf .next
npx next build
✓ Compiled successfully in 1782ms
Finished TypeScript in 955ms ...
✓ Generating static pages using 8 workers (7/7) in 371ms
Route (app)
┌ ◐ /
├ ◐ /_not-found
├ /[username]
│ └ ◐ /[username]
├ ƒ /api/profiles/revalidate
└ ◐ /explore
종료 코드 0이고, 라우트 표도 D-6 때와 똑같아서 아무 일도 없었던 것처럼 보입니다.
띄워보면 더 이상합니다
npx next start
피드가 정상으로 그려집니다. 개발 서버에서는 멈춰 있던 그 화면인데, 소스를 보면 <img> 태그도 멀쩡히 있고 srcSet도 src도 다 있어요.
그런데 화면에는 사진 자리만 비어 있는데, 자리는 494픽셀로 잡혀 있고 그 안이 하얗습니다.
그 src 주소를 직접 불러보면 답이 나옵니다.
curl -i 'http://localhost:3000/_next/image?url=https%3A%2F%2Fpicsum.photos%2Fseed%2Fpost1%2F640%2F640&w=640&q=75'
HTTP/1.1 400 Bad Request
"url" parameter is not allowed
정리하면 이렇습니다.
| 개발 서버 | 프로덕션 | |
|---|---|---|
next build |
— | 종료 코드 0 |
GET / |
200 · 피드가 "불러오는 중…" 에서 멈춤 | 200 · 피드 정상 · <img>도 정상 |
GET /_next/image?... |
400 | 400 |
개발에서는 요란하게 터지고, 프로덕션에서는 조용히 사진만 안 나옵니다.
보통은 반대로 생각하죠, 개발은 관대하고 프로덕션이 엄격하다고요. 여기서는 뒤집혀 있는데, 개발 서버가 렌더 도중에 예외를 던져 화면을 세워버리는 반면 프로덕션은 HTML을 정상으로 다 내보내고 사진 바이트를 요청하는 순간에만 거절해요.
D-4에서 비슷한 걸 한 번 봤습니다. loading.tsx 하나 때문에 없는 사람의 프로필이 404 대신 200으로 나왔었죠. 그때 배운 문장이 오늘도 그대로입니다. 200은 "요청이 도착했다"는 뜻이지 "잘 됐다"는 뜻이 아닙니다.
💡 한 줄 정리
허락 안 된 주소의 사진은 빌드도, 페이지도, HTML도 전부 통과한다. 400이 나는 것은 사진 바이트 하나뿐이라 프로덕션에서는 조용히 빈 자리만 남는다.
🙋 학생 질문 — "튜터님, 그럼 이런 건 어떻게 미리 잡나요? 빌드가 안 잡아주는데요."
세 가지가 겹쳐야 합니다.
첫째, 개발 서버에서 화면을 한 번은 직접 열어보는 것. 이 경우는 개발 서버가 오히려 크게 소리쳐줬어요.
둘째, 빌드한 결과를 띄워보는 것. next build만 돌리고 끝내면 못 봅니다. next start로 띄워서 실제 화면을 봐야 해요.
셋째, 화면을 눈으로만 보지 않는 것. 사진 자리가 비어 있는 걸 "아직 로딩 중인가 보다" 하고 넘기기 쉽습니다. 브라우저 개발자 도구의 네트워크 탭을 열어두면 400이 빨갛게 찍혀요.
이 세 가지는 사실 하나로 묶입니다. 만든 것을 실행해서 확인한다. 다음 시간에 이 앱을 컨테이너에 담을 때 이 원칙이 한 번 더 값을 합니다.
Step 4: "허락한 곳에서만 가져옵니다"
이제 고칩니다. next.config.ts에 목록을 하나 만들어요.
// apps/web-next/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
typedRoutes: true,
// 굳힐 것과 흘려보낼 것을 우리가 직접 고르겠다고 선언하는 스위치.
cacheComponents: true,
images: {
// 여기 적힌 주소에서 온 사진만 다시 만들어 준다. 나머지는 400 으로 거절한다.
remotePatterns: [new URL('https://picsum.photos/seed/**')],
},
};
export default nextConfig;
한 줄만 넣으면 새로고침 후 사진이 나옵니다.
왜 허락이 필요할까요
/_next/image?url=...은 주소를 받아서 그 사진을 가져다 다시 만들어주는 통로입니다. 목록이 없으면 누구든 우리 서버에 아무 주소나 넣어서 시킬 수 있는데, 남의 서버에 있는 100MB 짜리 사진을 우리 CPU로 백 번 변환시키는 것도 가능하고요.
D-8에서 문지기를 세우면서 배운 것과 같은 결입니다. 그때는 "이 사람이 들어와도 되나" 였고, 지금은 "이 주소에서 가져와도 되나"인데, 열려 있는 통로는 반드시 목록으로 좁힙니다.
/seed/**에서 **는 그 아래 몇 겹이든 허용한다는 뜻입니다. picsum.photos의 다른 경로는 안 되고, http://도 안 됩니다. 프로토콜·호스트·경로를 다 봐요.
⚠️ 예전 자료에서
images.domains를 보실 수 있는데, 그건 폐기된 설정입니다. 호스트 이름만 적는 방식이라 경로도 프로토콜도 포트도 못 가렸어요. 지금은remotePatterns만 씁니다.
우리 서버가 사진에 무슨 짓을 하는지
같은 사진 한 장을 여러 조건으로 불러서 크기를 재봤습니다.
| 요청 | 받은 형식 | 크기 |
|---|---|---|
원본을 picsum.photos에서 직접 |
image/jpeg | 74,427 B |
/_next/image?w=640&q=75 (형식 안 알림) |
image/jpeg | 65,557 B |
| 같은 요청 + "webp도 받을 수 있어요" | image/webp | 64,182 B |
/_next/image?w=384&q=75 + webp |
image/webp | 27,102 B |
두 가지가 일어납니다.
형식을 바꿔줍니다. 브라우저가 요청할 때 "나는 이런 형식들을 읽을 수 있다"를 같이 보내는데(Accept 헤더), 서버가 그걸 보고 webp로 줄지 jpeg로 줄지 정해요. 우리가 코드를 나눠 쓸 필요가 없습니다.
필요한 크기로 다시 만들어 줍니다. 폰 화면에 384픽셀로 그려질 사진이라면 640픽셀짜리를 받을 이유가 없죠. 27,102바이트는 원본의 36%입니다.
화질은 잠겨 있습니다
주소의 q=75가 화질입니다. 숫자를 바꿔서 불러보면 이렇게 됩니다.
q=75 → 200
q=50 → 400 "q" parameter (quality) of 50 is not allowed
q=100 → 400 "q" parameter (quality) of 100 is not allowed
75 말고는 전부 거절당하는데, Next 16부터 화질 목록도 허용제로 바뀌었습니다. 안 적으면 75 하나만 열려 있고, 다른 값을 쓰려면 qualities에 적어야 합니다.
이유는 remotePatterns와 같습니다. 아무나 q=1부터 q=100까지 백 번 요청하면 우리 서버가 같은 사진을 백 가지 화질로 백 번 만들거든요.
💡 한 줄 정리
/_next/image는 열려 있는 통로다. remotePatterns로 어디서 가져올지, qualities로 어떤 화질까지 만들지 목록으로 좁힌다. 대신 서버가 형식과 크기를 알아서 맞춰준다.
🙋 학생 질문 — "튜터님, 사진을 미리 여러 크기로 만들어두면 서버가 매번 안 만들어도 되지 않나요?"
맞는데, 그게 실무에서 더 흔한 방식이에요.
/_next/image는 첫 요청 때 만들고 그 결과를 캐시에 두는데, 두 번째부터는 만들지 않아요. 그래도 첫 요청은 누군가 기다려야 하고 서버 한 대가 사진 변환까지 떠안는 건 부담이라서, 규모가 커지면 사진 전용 서비스를 따로 씁니다.
Cloudinary 나 imgix 같은 것들이요. next/image는 loader를 바꿔 끼울 수 있게 열어놨는데, 컴포넌트는 그대로 두고 사진을 만들어주는 쪽만 갈아 끼우는 겁니다.
지금 우리 규모에서는 기본 통로로 충분합니다. 갈아 끼울 수 있게 되어 있다는 것만 알아두세요.
Step 5: "어느 사진을 먼저 받을까요"
이제 사진이 나옵니다. 그런데 피드를 처음 열었을 때 첫 장이 조금 늦게 뜬다는 느낌을 받으셨을 거예요.
터미널을 보면 Next가 직접 말해줍니다.
[browser] Image with src "https://picsum.photos/seed/post1/640/640" was detected as
the Largest Contentful Paint (LCP). Please add the `loading="eager"`
property if this image is above the fold.
LCP(Largest Contentful Paint, 최대 콘텐츠 렌더링 시간)는 "화면에서 가장 큰 것이 언제 그려졌나"를 재는 지표입니다. 학생이 "아 떴다"라고 느끼는 순간과 가장 가까운 숫자예요.
우리 피드에서 가장 큰 것은 첫 사진인데, Step 2에서 본 것처럼 모든 사진에 loading="lazy"가 붙어 있어요. 화면에 이미 보이는 사진까지 "가까워지면 받자" 하고 미루고 있는 겁니다.
첫 장만 곧바로 받게
// apps/web-next/app/components/FeedList.tsx
{posts.map((post, index) => (
<li key={post.id} className="rounded border border-black/10 p-4">
<p className="font-semibold">@{post.username}</p>
{/* 원본이 몇 대 몇인지 알려준다. 그러면 사진이 오기 전에도 그 비율만큼 자리가 비워진다.
화면 폭에 맞춰 줄어드는 건 className 이 맡는다. */}
<Image
src={post.imageUrl}
alt={post.content}
width={640}
height={640}
className="h-auto w-full"
// 첫 장은 화면을 열자마자 보이는 자리다. 미루지 않고 곧바로 받는다.
loading={index === 0 ? 'eager' : 'lazy'}
/>
<p className="mt-1">{post.content}</p>
<LikeButton postId={post.id} likeCount={post.likeCount} liked={post.liked} />
</li>
))}
map의 두 번째 인자로 순번을 받아서 첫 장에만 eager를 줍니다. 나머지는 그대로 lazy예요.
경고가 사라지고, <head>에 이런 줄이 하나 생깁니다.
<link rel="preload" as="image" imageSrcSet="/_next/image?url=...&w=640&q=75 1x, ..." />
브라우저가 HTML을 위에서부터 읽다가 이 줄을 만나면, 아직 <img>까지 못 갔어도 사진을 미리 받기 시작합니다.
⚠️ 여기가 옛 자료와 갈리는 자리입니다
검색해서 나오는 대부분의 글은 이 자리에 priority를 쓰라고 합니다. Next 16에서 폐기된 이름입니다. 공식 문서가 이렇게 적어놨어요.
"Starting with Next.js 16, the
priorityproperty has been deprecated in favor of thepreloadproperty in order to make the behavior clear."
그런데 문서는 한 줄 더 붙이는데, 대부분의 경우 preload보다 loading="eager"를 쓰라는 것이에요. 방금 본 Next의 경고문도 정확히 그 이름을 불렀고요.
셋을 실제로 넣어보고 렌더 결과를 비교했습니다.
| 무엇을 적었나 | <img>의 loading |
<head>의 preload |
|---|---|---|
| 아무것도 안 줌 | lazy |
없음 |
priority (폐기) |
없음 | 있음 |
preload (현행) |
없음 | 있음 |
loading="eager" |
eager |
있음 |
여기가 고약한데, priority를 써도 결과가 같아요. 타입 검사도 통과하고 린트도 조용하고 개발 서버 로그에도 아무 말이 없어서, 아무도 안 알려주는 채로 코드만 낡아갑니다.
C-1에서 라우터 패키지 이름이 바뀌었다는 이야기를 했었죠. 검색 결과가 낡았을 수 있다는 것을요. 프론트엔드는 반년마다 이런 자리가 생깁니다. 공식 문서의 그 API 페이지를 한 번 열어보는 습관이 유일한 방어선이에요.
💡 한 줄 정리
화면 맨 위 사진 한 장만 loading="eager"로 곧바로 받는다. 옛 자료의 priority는 Next 16에서 폐기됐는데 아직 동작해서 아무도 안 알려준다.
🙋 학생 질문 — "튜터님, 그럼 전부 eager로 하면 제일 빠른 거 아닌가요?"
첫 장은 빨라지고 나머지는 느려집니다.
브라우저가 같은 서버에 동시에 열 수 있는 연결 수는 정해져 있는데, 열 장을 한꺼번에 시키면 열 개가 그 자리를 나눠 씁니다. 첫 장이 받을 몫이 10분의 1로 줄어드는데, 학생이 지금 보고 있는 사진이 안 보이는 사진 아홉 장 때문에 늦어지는 겁니다.
게다가 학생이 스크롤을 안 내리면 아홉 장은 아무도 안 보는데 데이터만 쓰고 끝나서, 기준은 하나인데 화면을 열자마자 보이는 것만 eager로 두고 나머지는 브라우저가 알아서 하게 둡니다.
Step 6: "글꼴도 늦게 옵니다"
사진 이야기는 여기까지인데, 늦게 오는 게 사진만이 아니에요.
지금 우리 앱은 글꼴을 지정한 적이 없어서 학생 컴퓨터에 깔려 있는 기본 글꼴로 그려지는데, 맥에서 보는 화면과 윈도우에서 보는 화면이 다르고 자간도 줄 높이도 다르고요.
글꼴을 하나 정해서 우리가 주기로 합시다.
// apps/web-next/app/layout.tsx
import { Suspense } from 'react';
import type { Metadata } from 'next';
import { Noto_Sans_KR } from 'next/font/google';
import { HeaderNav } from './components/HeaderNav';
import { Providers } from './components/Providers';
import { SignInForm } from './components/SignInForm';
import { TextScaleStyle } from './components/TextScaleStyle';
import './globals.css';
// 빌드할 때 폰트 파일을 받아와 우리 서버에서 준다 — 학생 브라우저가 구글을 부르지 않는다.
// subsets 는 "미리 받아둘 조각" 이다. 한글은 여기 못 적는다(뒤에서 이유를 본다).
const notoSansKr = Noto_Sans_KR({ subsets: ['latin'], display: 'swap' });
이 값을 <html>에도 붙입니다.
<html lang="ko" className={notoSansKr.className}>
새로고침하면 글꼴이 바뀝니다.
⚠️ 한글 폰트인데 subsets에 한글이 없습니다
subsets: ['korean']이 자연스러워 보이죠. 적어보면 이렇게 나옵니다.
app/layout.tsx(11,45): error TS2322: Type '"korean"' is not assignable to
type '"cyrillic" | "latin" | "latin-ext" | "vietnamese"'.
한글이 목록에 없는데, 빠뜨린 게 아니라 한글은 그 방식으로 못 나르기 때문입니다.
이유는 글자 수인데, 라틴 알파벳은 대소문자·숫자·기호 다 합쳐 200자쯤이라 한 파일에 담을 수 있습니다. 한글은 완성형만 11,172자라서, 통째로 받으면 몇 메가바이트가 됩니다.
그래서 한글 폰트는 잘게 쪼개서 나릅니다. 만들어진 CSS를 열어보면 이렇게 되어 있어요.
@font-face {
font-family: Noto Sans KR;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../media/5989c81e7a9c7e38-s.2isa_l1o5587j.woff2") format("woff2");
unicode-range: U+D723-D728, U+D72A-D733, U+D735-D748, U+D74A-D74F, ...;
}
@font-face가 125개 있습니다. 각각이 unicode-range로 "이 글자들은 내가 담당한다"를 선언해요. 브라우저는 화면에 실제로 나온 글자를 보고 그 글자가 든 조각만 받습니다. "안녕하세요" 다섯 글자를 그리려고 11,172자를 받지 않아요.
구글을 안 부릅니다
방금 CSS의 src를 다시 보세요. fonts.gstatic.com이 아니라 ../media/...입니다. 우리 서버 주소예요.
next/font/google은 이름만 구글인데, 실제로는 빌드할 때 폰트 파일을 받아와 우리 프로젝트 안에 넣어두고 런타임에는 우리 서버가 줍니다. 학생 브라우저가 구글에 요청을 보내는 일이 한 번도 없어서, 방문자 IP가 구글로 안 갑니다.
글자가 안 밀리는 장치
여기부터가 오늘 주제와 이어지는데, 웹폰트도 늦게 와요. 도착하기 전까지는 기본 글꼴로 그려지다가, 도착하는 순간 갈아 끼워집니다. 두 글꼴의 글자 폭이 다르면 그 순간 줄이 흔들리는데, 사진이 밀어내던 것과 같은 일입니다.
만들어진 CSS에 이런 규칙이 하나 더 있습니다.
@font-face {
font-family: Noto Sans KR Fallback;
src: local(Arial);
ascent-override: 110.73%;
descent-override: 27.49%;
line-gap-override: 0.0%;
size-adjust: 104.76%;
}
"대역 배우"를 하나 만들어둔 겁니다. 이미 컴퓨터에 있는 글꼴(여기서는 Arial)을 데려다가, 줄 높이가 Noto Sans KR과 같아지도록 비율을 조정해서 씁니다. 위로 올라가는 높이(ascent), 아래로 내려가는 높이(descent), 전체 크기(size-adjust)를 전부 맞춰요. 이 숫자들은 Next가 두 글꼴의 실제 치수를 비교해서 계산한 값이라서, 진짜 글꼴이 도착해서 갈아 끼워질 때 줄 높이가 안 바뀌는데, 우리가 width/height로 사진 자리를 미리 잡은 것과 정확히 같은 발상이에요. 늦게 올 것의 크기를 미리 알아내서 그만큼 자리를 잡아둡니다.
display: 'swap'은 그동안 무엇을 보여줄지 정하는 값입니다. swap은 "대역 배우로 먼저 보여주고 진짜가 오면 바꿔라"예요. 글자가 안 보이는 시간이 없습니다.
💡 한 줄 정리
next/font는 폰트를 빌드 때 받아와 우리 서버가 준다. 한글은 유니코드 범위로 125조각이 나고, 대역 배우 글꼴의 비율을 진짜와 맞춰둬서 갈아 끼워질 때 글자가 안 밀린다.
🙋 학생 질문 — "튜터님, subsets: ['latin'] 은 왜 적는 건가요? 우리 앱은 한글인데요."
subsets는 "미리 받아둘 조각"이라는 뜻입니다. 여기 적힌 조각은 <head>에 preload로 걸려서 화면을 그리기 전에 받기 시작해요.
라틴을 적어두는 이유는 우리 화면에 영문과 숫자가 섞여 있기 때문인데, @jaehoon, 1240, 10km 같은 것들이요. 그 글자들은 확실히 나오니까 미리 받아둡니다.
한글 조각은 미리 못 받습니다. 125개 중 어느 것이 필요한지는 화면에 무슨 글자가 나오는지에 달려 있고, 그건 데이터가 정하니까요. 그래서 한글은 브라우저가 글자를 만난 뒤에 받는데, 그 사이를 대역 배우가 메워주고요.
Step 7: "어느 화면에 가 있든 제목이 같습니다"
브라우저 탭을 보세요.
피드에 있어도 "인스타그램 클론", @minji 프로필에 있어도 "인스타그램 클론", 탐색 화면에서도 "인스타그램 클론"인데, 탭을 다섯 개 띄워놓으면 어느 게 어느 화면인지 알 수 없어요.
원인은 D-1에서 만든 이 부분입니다.
// apps/web-next/app/layout.tsx
export const metadata: Metadata = {
title: '인스타그램 클론',
description: 'Next.js App Router 로 다시 짓는 인스타그램',
};
가장 바깥 껍데기에 하나 있고, 아래 화면들이 자기 것을 안 줘서 이게 그대로 쓰이고 있습니다.
화면마다 다르게
metadata는 고정된 값이라 사람 이름을 넣을 수 없는데, 이름은 주소에서 와야 하니까 값 대신 함수를 내보냅니다.
// apps/web-next/app/[username]/page.tsx
import { Suspense } from 'react';
import type { Metadata } from 'next';
import { FollowButton } from '@/app/components/FollowButton';
import { TagFilter } from '@/app/components/TagFilter';
import { fetchPostsByUsername, fetchProfile, fetchTopTags } from '@/lib/api';
// 화면을 그리기 전에 Next 가 이 함수를 먼저 부른다. 돌려준 값이 <head> 로 들어간다.
// 굳혀둔 집계값을 그대로 다시 쓴다 — 제목 하나 때문에 서버를 또 부르지 않는다.
export async function generateMetadata({ params }: PageProps<'/[username]'>): Promise<Metadata> {
const { username } = await params;
const profile = await fetchProfile(username);
return {
title: `@${username} · 인스타그램 클론`,
description: `팔로워 ${profile.followerCount}명`,
};
}
params를 받는 모양이 ProfilePage와 똑같은데, await를 붙이는 것도요. 화면을 그리는 함수와 제목을 만드는 함수가 같은 입력을 받는 거예요.
결과입니다.
| 주소 | <title> |
|---|---|
/ |
인스타그램 클론 |
/explore |
인스타그램 클론 |
/minji |
@minji · 인스타그램 클론 |
/jaehoon |
@jaehoon · 인스타그램 클론 |
description에는 팔로워 수까지 들어갔습니다.
<meta name="description" content="팔로워 8500명"/>
굳혀둔 값을 다시 씁니다
여기서 fetchProfile을 부르는 게 마음에 걸리실 수 있습니다. 화면도 같은 함수를 부르는데, 제목 때문에 서버를 한 번 더 부르는 건 아닐까요.
아닙니다. D-6에서 그 함수에 'use cache'와 cacheLife('hours')를 달아뒀거든요. 이미 굳어 있는 값이라 그대로 꺼내 씁니다. 그때 붙여둔 이름표가 여기서 값을 하네요.
그리고 하나 더. cacheComponents를 켜두면 굳지 않는 것을 <Suspense>로 감싸라는 규칙이 있었죠. 제목은 그 규칙 밖입니다. <head>는 흘려보낼 수 있는 자리가 아니라서 Next가 따로 다룹니다. 빌드 라우트 표도 안 바뀌어요 — /[username]은 여전히 ◐입니다.
💡 한 줄 정리
고정된 제목은 metadata, 주소에 따라 달라지는 제목은 generateMetadata. 화면과 같은 입력을 받고, 화면이 쓰는 굳은 값을 그대로 다시 쓴다.
🙋 학생 질문 — "튜터님, 예전 리액트에서는 react-helmet 같은 걸 썼는데 그건 왜 안 쓰나요?"
C-1에서 만든 SPA를 떠올려보세요. 그 앱은 브라우저에서 자바스크립트가 돌아야 화면이 생깁니다. 제목도 마찬가지예요. react-helmet은 브라우저에서 document.title을 바꾸는 방식이었습니다.
문제는 자바스크립트를 안 돌리는 방문자입니다. 검색 엔진 크롤러, 카카오톡이나 슬랙의 링크 미리보기 봇 같은 것들이요. 그들은 HTML만 받아서 읽습니다. 자바스크립트가 나중에 제목을 바꿔도 못 봐요.
generateMetadata는 서버에서 돌아서 HTML 이 만들어질 때 <head>에 이미 들어갑니다. 소스 보기를 하면 그대로 보여요. C-10 마무리에서 "검색 엔진이 흰 종이를 본다" 고 했던 문제가 여기서 완전히 닫힙니다.
참고로 App Router에서는 <head>를 직접 다루던 Pages Router 시절의 전용 컴포넌트도 안 씁니다. 제목은 전부 이 방식으로 만들어요.
Step 8: "남이 우리 링크를 볼 때"
마지막으로 하나 더 있는데, 지난 시간 마지막에 이렇게 말했었죠, 링크를 메신저에 붙여넣어도 마찬가지라고요.
/minji 주소를 복사해서 카카오톡이나 슬랙에 붙여넣으면 미리보기 카드가 뜹니다. 그 카드에 무엇이 들어갈지는 우리가 정할 수 있어요. og:로 시작하는 태그들입니다.
이것도 generateMetadata 안에서 만듭니다.
// apps/web-next/app/[username]/page.tsx
return {
title: `@${username} · 인스타그램 클론`,
description: `팔로워 ${profile.followerCount}명`,
// 우리 화면이 아니라 남의 화면에 뜨는 카드다. 크기까지 같이 줘야 자리를 잡는다.
openGraph: {
title: `@${username}`,
description: `팔로워 ${profile.followerCount}명`,
images: [{ url: profile.profileImageUrl, width: 64, height: 64 }],
},
};
openGraph라는 키 하나를 더 얹었습니다. 나온 결과는 이렇습니다.
<meta property="og:title" content="@minji"/>
<meta property="og:description" content="팔로워 8500명"/>
<meta property="og:image" content="https://picsum.photos/seed/minji/64/64"/>
<meta property="og:image:width" content="64"/>
<meta property="og:image:height" content="64"/>
title을 따로 준 이유가 있습니다. 탭에 뜨는 제목은 "@minji · 인스타그램 클론"이 좋아요. 어느 사이트인지 알아야 하니까요. 그런데 카드에는 이미 사이트 이름이 따로 표시되니까 @minji 만으로 충분합니다. 읽는 자리가 다르면 쓰는 글도 달라집니다.
width와 height를 같이 준 것도 오늘 주제 그대로예요. 메신저도 사진 크기를 모르면 카드 자리를 못 잡습니다. 우리 화면에서 한 일을 남의 화면에서도 하는 겁니다.
자기 것이 없는 화면은
지금 상태에서 / 나 /explore를 소스 보기로 열면 og: 태그가 하나도 없어서, 링크가 맨몸으로 나가요. 가장 바깥 껍데기에도 하나 깔아둡시다.
// apps/web-next/app/layout.tsx
export const metadata: Metadata = {
title: '인스타그램 클론',
description: 'Next.js App Router 로 다시 짓는 인스타그램',
// 아래 화면이 자기 것을 안 주면 이게 쓰인다 — 링크가 맨몸으로 나가는 일은 없다.
openGraph: {
title: '인스타그램 클론',
description: 'Next.js App Router 로 다시 짓는 인스타그램',
type: 'website',
},
};
결과는 이렇게 갈립니다.
| 주소 | og:title |
|---|---|
/ |
인스타그램 클론 |
/explore |
인스타그램 클론 |
/minji |
@minji |
/explore는 자기 generateMetadata가 없어서 껍데기 것을 물려받았고, /minji는 자기 것으로 덮었습니다. D-1에서 layout이 겹겹이 쌓이는 걸 봤는데, <head>도 안쪽이 이기는 구조입니다.
다만 한 가지가 다른데, 물려받기는 키 하나 단위예요. openGraph를 안 주면 껍데기 것이 통째로 내려오지만, 주는 순간 그 키 전체가 교체됩니다. 안쪽 항목끼리 섞이지 않는다는 것을 과제에서 직접 확인하게 됩니다.
💡 한 줄 정리
openGraph는 우리 화면이 아니라 남의 화면에 뜨는 카드다. 사진에는 크기를 같이 주고, 껍데기에 기본값을 깔아두면 어떤 주소를 복사해도 맨몸으로 안 나간다.
🙋 학생 질문 — "튜터님, 카드 사진이 64픽셀이면 너무 작지 않나요?"
작습니다. 메신저 미리보기 카드는 보통 1200×630 정도를 기대하는데, 64픽셀짜리 프로필 사진을 넣으면 흐릿하게 늘어나거나 아주 작은 아이콘으로 붙습니다.
지금 우리가 가진 게 그것뿐이라 그걸 쓴 거예요. 실무에서는 카드 전용 이미지를 따로 만드는데, 프로필 사진에 이름과 팔로워 수를 얹어서 1200×630 짜리 한 장을 그려주는 식으로요.
Next에는 그걸 서버에서 그려주는 도구도 있습니다(ImageResponse). 다만 그건 오늘 다룰 범위를 넘어가요. 지금은 크기를 같이 알려줘야 카드가 자리를 잡는다는 것과, 읽는 자리에 따라 글이 달라진다는 것만 가져가시면 됩니다.
마무리
오늘 배운 핵심 세 가지
💡 하나 — 늦게 오는 것은 자리를 미리 잡아둡니다. 사진도 글꼴도 나중에 도착하는데, 도착할 때 자리가 없으면 그만큼 아래를 밀어내요. 우리 피드에서는 열 번째 게시물이 4,233픽셀 내려갔는데, 해법은 빨리 받는 게 아니라 얼마나 클지 미리 알려주는 것이었어요. width/height가 하는 일도, 폰트의 size-adjust가 하는 일도 같습니다.
💡 둘 — 열려 있는 통로는 목록으로 좁힙니다. /_next/image는 주소를 받아 사진을 만들어주는 통로인데, 좁혀두지 않으면 누구든 우리 서버에 아무 일이나 시킬 수 있어요. remotePatterns로 어디서 가져올지, qualities로 어떤 화질까지 만들지 정합니다. 지난 시간에 문지기를 세운 것과 같은 결이에요.
💡 셋 — 200이라고 다 정상은 아닙니다. 허락 안 된 주소의 사진은 빌드도, 페이지도, HTML도 전부 통과했는데, 400이 나는 것은 사진 바이트 하나뿐이라 프로덕션에서는 빈 자리만 남아요. 개발 서버가 더 요란하게 알려주는 자리도 있다는 걸 오늘 봤습니다.
다음 시간 예고
오늘까지 화면은 다 만들었는데, 사진도 있고 글꼴도 있고 주소마다 제목도 다르지만, 이 앱은 아직 제 노트북에서만 돕니다.
다음 시간에는 이걸 밖으로 내보냅니다. 필요한 것만 골라내서 한 덩어리로 만들고, 컨테이너 하나에 담아서 어디서든 실행되게 만들어요.
그 과정에서 오늘 배운 문장이 한 번 더 나오는데, 담아서 띄웠는데 화면은 200으로 뜨지만 스타일이 하나도 없고 버튼도 안 눌려서 무엇이 안 담겼는지 찾아야 하고, 컨테이너 안에서만 드러나는 문제도 하나 더 기다리고 있어요. 지금까지 우리가 코드에 그냥 적어둔 값 중에 밖으로 나가는 순간 뜻이 달라지는 것이 있거든요.
과제
[구현] 프로필 격자에도 사진을 붙이세요
/minji 같은 프로필 화면의 게시물 격자는 아직 글자만 보여줍니다. 여기에도 사진을 붙이세요.
- 칸이 정사각형(
aspect-square)으로 고정되어 있어서, 피드처럼width/height로 비율을 알려주는 방식은 여기에 안 맞아요. 공식 문서에서fill을 찾아 읽고 쓰세요. fill을 쓰면 한 가지 조건이 생기는데, 그 조건을 안 맞추면 사진이 엉뚱한 곳에 붙어요. 무엇인지 확인하고 맞추세요.- 사진이 칸을 꽉 채우되 찌그러지지 않게 하세요.
- 다 되면 소스 보기로
srcSet을 확인하세요. 피드의 것과 모양이 다릅니다. 무엇이 다르고 왜 다른지 한 문장으로 적으세요.
[구현] 프로필 사진을 진짜로 바꾸세요
프로필 화면 맨 위의 회색 동그라미를 실제 프로필 사진으로 바꾸세요. Profile 타입에 profileImageUrl이 있습니다.
- 동그라미는 64×64입니다.
- 바꾼 뒤 화면을 새로고침하면서 잘 보세요. 오늘 없앤 문제가 다시 나타납니다. 무엇이 밀리나요.
- 왜 다시 나타났는지 설명하고, 고치세요. 힌트는
<Image>밖에 있습니다.
[탐구] 화질을 열어보세요
Step 4에서 q=50이 400으로 거절당하는 걸 봤습니다. 열어봅시다.
next.config.ts의images에qualities를 추가해서 40과 75를 허용하세요./_next/image를q=40·q=75·q=100세 가지로 직접 불러서 상태 코드와 응답 크기를 표로 적으세요.- 40으로 받은 사진과 75로 받은 사진을 나란히 열어보세요. 눈으로 구별이 되나요?
- 그럼 왜 모두 40을 안 쓸까요. 어떤 사진에서는 차이가 크게 보일지 한 문장으로 적으세요.
[탐구] 링크를 붙여넣어 보세요
만든 미리보기 카드를 실제로 확인합니다.
- 개발 서버 주소는 남의 서버에서 못 보니까, 소스 보기로
og:태그를 직접 읽어 확인하세요./·/explore·/minji세 곳을 비교해 표로 적으세요. /explore에generateMetadata를 추가해서 그 화면만의 제목과 카드를 주세요. 이 화면은params가 없습니다. 함수 모양이 어떻게 달라지나요.- 마지막으로
openGraph.images의 주소를 실제로 404가 나는 주소로 바꿔보세요. Step 4에서 허락한 목록에 없는 호스트로요. 빌드·타입 검사·화면 중 어디에서 걸리나요? 아무 데서도 안 걸린다면, 그 사실이 왜 문제인지 한 문장으로 적으세요.
생각해볼 주제
1. 우리 서버가 사진을 만들어야 할까요
/_next/image는 요청이 올 때마다 사진을 다시 만듭니다. 첫 요청은 만들고, 그 뒤로는 캐시에서 꺼내요.
편한데, 설정 한 줄이면 되고 코드도 안 바뀌어요. 사진 변환은 CPU를 꽤 쓰는 일이라서, 사용자가 늘면 화면을 그리는 일과 사진을 만드는 일이 같은 서버에서 자리를 다투게 돼요.
대안은 사진 전용 서비스에 맡기는 것인데, 대신 돈이 들고 업체에 묶이고, 그 서비스가 죽으면 우리 사진이 다 안 나옵니다.
우리 인스타그램 클론이 하루 10만 장을 서비스한다면 어느 쪽을 고르시겠어요? 판단의 기준이 되는 숫자는 무엇인가요?
2. 폐기됐는데 동작하는 API를 어떻게 발견할까요
오늘 priority를 봤습니다. Next 16에서 폐기됐는데 타입 검사도, 린트도, 개발 서버 로그도 아무 말을 안 했어요. 렌더 결과까지 같았고요.
이런 게 무서운 이유는 코드가 잘못됐다는 신호가 어디에도 없다는 것입니다. 다음 메이저 버전에서 갑자기 사라져야 그때 알게 되는데, 그때는 이미 그 코드가 프로젝트 곳곳에 복사되어 있고요.
팀에서 이런 것을 조기에 발견하려면 무엇을 해야 할까요? 사람의 성실함에 기대지 않는 방법이 있을까요?
3. 밀림을 없애는 것과 빨리 그리는 것 중 무엇이 먼저일까요
오늘 우리는 밀림을 4,233픽셀에서 0으로 만들었지만, 페이지가 빨라진 건 아니에요. 사진은 여전히 같은 시간에 도착하는데, 오히려 자리를 미리 비워두니 처음엔 빈 사각형이 크게 보이죠.
반대로 사진을 아주 작게 만들어서 빨리 받게 할 수도 있는데, 그러면 빨리 그려지는 대신 화질이 나빠져요.
"빠르다"는 하나가 아니라 여러 지표의 묶음이고, 어떤 것은 서로 밀어냅니다. 우리 인스타그램 클론에서 학생이 "이 앱 좋다" 고 느끼게 하려면 어느 지표를 먼저 잡아야 할까요? 그렇게 판단한 근거는 무엇인가요?
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 프로필 격자에도 사진을 붙이세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
fill을 써서 칸을 채웠는가 |
8 | width/height 대신 fill |
부모에 relative를 줬는가 |
6 | 이걸 빼면 사진이 엉뚱한 기준을 잡는다 |
sizes를 줬는가 |
6 | 안 주면 훨씬 큰 파일을 받는다 |
| 찌그러지지 않게 처리했는가 | 3 | object-cover |
srcSet 차이를 설명했는가 |
2 | 1x/2x가 아니라 w 목록으로 바뀐다 |
| 합계 | 25 |
풀이 예시
// apps/web-next/app/[username]/page.tsx
import Image from 'next/image';
<ul className="grid grid-cols-3 gap-2">
{posts.map((post) => (
<li key={post.id} className="relative aspect-square overflow-hidden rounded bg-black/5">
<Image
src={post.imageUrl}
alt={post.content}
fill
sizes="(max-width: 768px) 33vw, 224px"
className="object-cover"
/>
</li>
))}
</ul>
fill을 쓰는 이유. 피드에서는 폭이 컨테이너를 따라가고 높이가 비율대로 정해졌습니다. 여기는 반대인데, 칸 크기가 aspect-square로 이미 정해져 있고 사진이 그 안을 채워야 합니다. fill은 "원본 비율은 신경 쓰지 말고 부모 칸을 꽉 채워라"라는 뜻이라 이 상황에 맞는데, 칸이 이미 자리를 잡고 있으니까 밀림 걱정도 없고요.
relative를 빼면 fill을 쓸 때 사진에 붙는 position: absolute가 렌더된 결과에서 그대로 드러나요.
style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent"
absolute는 위치가 정해진 가장 가까운 조상을 기준으로 자리를 잡습니다. 칸에 relative가 없으면 그 위로 계속 올라가면서 기준을 찾고, 끝까지 없으면 페이지 전체를 기준으로 삼는데, 그러면 사진 한 장이 화면을 통째로 덮습니다. 공식 문서도 부모에 position이 있어야 한다고 못박아 뒀습니다.
object-cover를 안 주면, 정사각형 칸에 정사각형이 아닌 사진이 들어올 때 찌그러집니다. object-cover는 비율을 지키면서 칸을 채우고 넘치는 부분을 잘라내는데, overflow-hidden은 잘린 부분이 칸 밖으로 새지 않게 합니다.
srcSet이 달라집니다. 피드에서는 이랬습니다.
w=640 1x, w=1920 2x
격자에서는 이렇게 나옵니다.
w=256 256w, w=384 384w, w=640 640w, ...
1x/2x는 화면 배율만 보는 방식이고, 256w 같은 목록은 "이 파일은 256픽셀짜리다"를 알려주는 방식인데, sizes로 "이 사진은 화면에서 33vw 쯤 차지한다"를 알려주면 브라우저가 그 크기와 배율을 곱해서 목록 중 알맞은 것을 고를 수 있어요. 피드처럼 폭이 컨테이너를 꽉 채우는 경우는 배율만 알면 되지만, 격자는 화면 크기에 따라 칸이 달라지니 이 방식이 필요합니다.
자주 나오는 실수
sizes를 안 주면 fill을 쓸 때 sizes의 기본값이 100vw가 됩니다. "이 사진은 화면 전체 폭을 차지한다"는 뜻이라 브라우저가 아주 큰 파일을 고릅니다. 실제로 sizes 유무에 따라 후보 목록이 이렇게 갈렸어요.
srcSet 후보 |
|
|---|---|
sizes 있음 |
256w, 384w, 640w, ... |
sizes 없음 |
640w, 750w, 828w, 1080w, 1200w, 1920w, 2048w, 3840w |
같은 사진을 실제로 받아보면 w=256은 18,752바이트, w=640은 101,122바이트인데, 224픽셀짜리 칸 하나에 5배가 넘는 파일을 받게 돼요. 격자가 아홉 칸이면 그 차이가 아홉 배가 됩니다.
fill과 width/height를 같이 쓰는 실수도 흔한데, 둘은 같이 못 써서 타입 검사에서 걸려요.
💡 튜터의 한마디
fill과 width/height를 가르는 기준은 간단합니다. "이 사진의 크기를 누가 정하나"예요.
사진이 정한다면 width/height입니다. 원본 비율을 알려주면 브라우저가 그만큼 자리를 잡아요. 피드가 그렇습니다.
칸이 정한다면 fill입니다. 격자, 배너, 카드 썸네일처럼 자리가 먼저 있고 사진이 나중에 채우는 곳이요.
그리고 fill을 쓰기로 했다면 sizes는 선택이 아니라 짝입니다. 칸 크기를 CSS만 알고 있는 상황이라, 알려주지 않으면 브라우저는 최악을 가정합니다.
🎯 [과제 2 예시답안] 프로필 사진을 진짜로 바꾸세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
findProfile 결과를 재사용했는가 |
6 | 같은 함수를 두 번 부르지 않는다 |
width/height를 64로 줬는가 |
5 | 원본이 64×64 |
| 다시 나타난 밀림을 발견했는가 | 8 | 아바타가 도착할 때 아래가 밀린다 |
원인을 <Image> 밖에서 찾았는가 |
4 | Suspense의 대기 화면 |
| 고쳤는가 | 2 | 대기 화면이 같은 자리를 잡게 |
| 합계 | 25 |
풀이 예시
ProfileHeading은 이미 findProfile을 부르고 있으니까, 결과를 버리지 말고 받아서 씁니다.
// apps/web-next/app/[username]/layout.tsx
import Image from 'next/image';
async function ProfileHeading({ params }: { params: LayoutProps<'/[username]'>['params'] }) {
const { username } = await params;
// 모르는 사람이면 여기서 멈춘다 — 게시물 0장으로 그리면 안 된다.
const profile = await findProfile(username);
if (profile === null) {
notFound();
}
return (
<>
<Image
src={profile.profileImageUrl}
alt={`${username} 프로필 사진`}
width={64}
height={64}
className="size-16 rounded-full object-cover"
/>
<h1 className="text-xl font-bold">@{username}</h1>
</>
);
}
원래 있던 회색 동그라미(<div className="size-16 rounded-full bg-black/10" />)는 지웁니다.
profileImageUrl은 https://picsum.photos/seed/minji/64/64인데, Step 4에서 허락한 https://picsum.photos/seed/** 안에 들어가니까 설정은 안 건드려도 됩니다.
다시 나타난 밀림
새로고침해보면 이름과 아바타가 늦게 나타나면서 아래 내용이 한 번 내려앉는데, 오늘 없앤 그 문제예요.
원인은 <Image>가 아닌데, <Image>는 제 몫을 하고 있고 문제는 그 위에 있습니다.
<Suspense fallback={<div className="h-7 w-40 animate-pulse rounded bg-black/5" />}>
<ProfileHeading params={params} />
</Suspense>
D-4에서 프로필 머리를 <Suspense>로 감싸면서 기다리는 동안 보여줄 것을 하나 만들어뒀습니다. h-7, 그러니까 28픽셀짜리 회색 막대예요. 그때는 그 자리에 글자 한 줄만 있었으니까 맞는 크기였습니다.
그런데 지금 그 안에 64픽셀짜리 동그라미가 새로 들어왔어요. 대기 화면은 28픽셀인데 도착하는 것은 64픽셀이라서 36픽셀이 밀립니다.
고치기
대기 화면이 도착할 것과 같은 자리를 잡게 만듭니다.
<Suspense
fallback={
<>
<div className="size-16 animate-pulse rounded-full bg-black/5" />
<div className="h-7 w-40 animate-pulse rounded bg-black/5" />
</>
}
>
<ProfileHeading params={params} />
</Suspense>
동그라미 자리와 글자 자리를 각각 만들어뒀습니다. 이제 도착해도 자리가 안 바뀝니다.
💡 튜터의 한마디
이 과제의 진짜 내용은 프로필 사진이 아닙니다. 자리를 미리 잡는 일이 <Image> 안에서만 끝나지 않는다는 것이에요.
<Image>는 "사진이 얼마나 클지"를 아는데, 그 사진이 <Suspense> 안에 있으면 사진이 오기 전에는 사진 자체가 화면에 없어서, 그 시간 동안 자리를 지키는 것은 대기 화면입니다.
그래서 규칙이 하나 더 붙습니다. 대기 화면은 도착할 것과 같은 크기여야 하는데, 안 그러면 도구를 아무리 잘 써도 그 위에서 밀려요.
D-4에서 만든 대기 화면이 오늘 틀린 값이 됐다는 것도 눈여겨보세요. 그때는 맞았는데, 화면이 자라면 그때 맞았던 값이 지금 틀린 값이 됩니다. 고칠 곳을 찾아주는 도구는 없어서, 새로고침을 한 번 해보는 사람만 찾습니다.
🎯 [과제 3 예시답안] 화질을 열어보세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
qualities를 올바로 추가했는가 |
6 | images 안에 배열로 |
| 세 가지 응답을 표로 정리했는가 | 8 | 40·75 는 200, 100 은 여전히 400 |
| 눈으로 비교했는가 | 4 | 결론이 무엇이든 근거가 있으면 |
| 어떤 사진에서 차이가 커지는지 답했는가 | 7 | 무엇이 화질에 민감한지 |
| 합계 | 25 |
풀이 예시
// apps/web-next/next.config.ts
images: {
remotePatterns: [new URL('https://picsum.photos/seed/**')],
qualities: [40, 75],
},
직접 불러본 결과입니다. 같은 사진, 같은 폭(640), webp로 받았습니다.
| 요청 | 상태 | 크기 |
|---|---|---|
q=40 |
200 | 43,612 B |
q=75 |
200 | 64,182 B |
q=100 |
400 | — ("q" parameter (quality) of 100 is not allowed) |
100은 여전히 막히는데, 목록에 적은 것만 열리고, 허락 목록이지 상한선이 아닙니다.
40과 75를 나란히 열어보면 이 사진에서는 구별이 잘 안 되는데, 크기는 32% 줄었습니다.
그럼 왜 모두 40을 안 쓸까요
사진 종류에 따라 갈리기 때문입니다.
풍경 사진이나 인물 사진처럼 색이 부드럽게 이어지는 사진은 화질을 낮춰도 티가 잘 안 나는데, 우리가 받은 것도 그런 사진이었어요.
반대로 경계가 뚜렷한 것은 티가 확 나는데, 글자가 들어간 이미지·로고·스크린샷·선으로 그린 그림 같은 것들이 그렇습니다. 그런 데서는 글자 가장자리가 지저분해지고 단색 배경에 얼룩이 생깁니다.
인스타그램에는 두 종류가 다 올라오는데, 여행 사진도 있고 공지 이미지도 있어요. 그래서 하나로 정하기 어렵고, 보통은 안전한 쪽(75)을 기본으로 두고 확실히 사진인 자리만 낮춥니다.
자주 나오는 실수
qualities: [40]만 적으면 우리 코드가 지금 쓰고 있는 q=75가 막혀서, 화면 전체의 사진이 한꺼번에 안 나와요. 기본값이 [75]였다는 걸 잊고 덮어쓰면 생기는 일입니다.
💡 튜터의 한마디
여기서 가져갈 것은 40 이냐 75 냐가 아닙니다. 왜 목록이냐예요.
q를 자유롭게 열어두면 누군가 q=1부터 q=100까지 백 번 요청할 수 있는데, 우리 서버는 같은 사진을 백 가지로 만들고 백 개를 캐시에 저장해요. CPU도 디스크도 백 배로 쓰는데, 우리가 쓰지도 않는 값으로요.
remotePatterns와 정확히 같은 이야기이고, 지난 시간 문지기와도 같은 결인데, 밖에서 값을 넣을 수 있는 자리는 전부 목록으로 좁히는 것입니다. 그게 Next 16이 이 설정을 잠가둔 이유입니다.
🎯 [과제 4 예시답안] 링크를 붙여넣어 보세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
세 주소의 og:를 비교했는가 |
7 | 상속과 덮어쓰기가 드러나게 |
/explore에 제목·카드를 추가했는가 |
8 | params가 없는 형태 |
| 함수 모양의 차이를 설명했는가 | 4 | 인자도 await도 필요 없다 |
| 404 나는 주소를 넣고 결과를 확인했는가 | 3 | 어디에서도 안 걸린다 |
| 왜 문제인지 답했는가 | 3 | 깨진 것을 우리가 못 본다 |
| 합계 | 25 |
풀이 예시
세 주소의 소스 보기에서 og: 태그를 뽑은 결과입니다.
| 주소 | og:title |
og:description |
og:image |
|---|---|---|---|
/ |
인스타그램 클론 | Next.js App Router로 다시 짓는 인스타그램 | 없음 |
/explore |
인스타그램 클론 | Next.js App Router로 다시 짓는 인스타그램 | 없음 |
/minji |
@minji | 팔로워 8500명 | .../seed/minji/64/64 |
/explore는 자기 것이 없어서 껍데기 값을 그대로 물려받았습니다. /minji는 자기 것으로 덮었고요.
/explore에 붙이기
// apps/web-next/app/explore/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '탐색 · 인스타그램 클론',
openGraph: {
title: '탐색',
description: '요즘 많이 보는 게시물',
},
};
함수가 아니라 값으로 충분한데, /explore는 주소에 변수 칸이 없어서 누가 들어와도 같은 화면이라 제목도 하나로 고정되고, 그러면 generateMetadata를 쓸 이유가 없습니다.
generateMetadata가 함수인 이유는 하나뿐인데, 주소마다 값이 달라야 해서요. 그래서 params를 받고, 그 값이 준비될 때까지 await로 기다리는데, 고정된 제목에는 받을 것도 기다릴 것도 없어요.
굳이 함수로 쓴다면 이렇게 됩니다.
export async function generateMetadata(): Promise<Metadata> {
return { title: '탐색 · 인스타그램 클론' };
}
동작은 하지만, 인자도 안 받고 기다리는 것도 없는 async 함수라서 읽는 사람이 "무엇 때문에 함수지" 하고 한 번 멈춥니다. 그래서 값으로 두는 쪽이 낫습니다.
덤으로 발견되는 것
붙이고 나서 /explore의 og:를 다시 뽑아보면 이렇게 됩니다.
| 붙이기 전 | 붙인 뒤 | |
|---|---|---|
og:title |
인스타그램 클론 | 탐색 |
og:description |
Next.js App Router로 다시 짓는 인스타그램 | 요즘 많이 보는 게시물 |
og:type |
website | 사라짐 |
우리는 og:type을 건드린 적이 없는데 없어졌습니다.
물려받기가 키 하나 단위로 일어나기 때문이에요. openGraph를 안 줬을 때는 껍데기 것이 통째로 내려왔는데, 우리가 openGraph를 주는 순간 그 키 전체가 우리 것으로 교체되면서 안쪽 항목끼리는 섞이지 않습니다.
그래서 아래에서 openGraph를 줄 때는 껍데기에 있던 것 중 계속 필요한 것을 같이 적어야 합니다.
openGraph: {
title: '탐색',
description: '요즘 많이 보는 게시물',
type: 'website',
},
404 나는 주소를 넣으면
openGraph: {
images: [{ url: 'https://example.com/no-such-image.png', width: 64, height: 64 }],
},
이 주소는 실제로 404인데, 게다가 example.com은 Step 4에서 허락한 목록에 없는 호스트인데도 아무 데서도 안 걸립니다.
| 확인한 곳 | 결과 |
|---|---|
tsc --noEmit |
통과 |
eslint |
통과 |
next build |
종료 코드 0 |
GET /minji |
200 |
| 개발 서버 로그 | 아무 말 없음 |
소스 보기에는 그 주소가 멀쩡히 적혀 있고요.
<meta property="og:image" content="https://example.com/no-such-image.png"/>
허락 목록도 안 보는데, 당연해요. og:image는 우리가 그리는 사진이 아니라 "남에게 알려주는 주소" 니까요. /_next/image를 안 거치니까 목록과 상관이 없고, 우리 브라우저가 그 주소를 불러볼 이유도 없습니다.
그래서 문제인데, 깨진 것을 확인할 사람이 우리 쪽에 아무도 없어요. 링크를 공유한 사용자가 빈 카드를 보고, 우리는 영영 모릅니다. Step 3에서 본 것과 같은 종류로, 빌드도 화면도 통과하지만 우리가 안 보는 곳에서만 틀린 것이지요.
실무에서는 이걸 사람 눈으로 확인합니다. 각 메신저와 SNS가 제공하는 미리보기 확인 도구에 주소를 넣어보는 거예요. 배포한 뒤 한 번 돌려보는 것을 절차로 만들어 둡니다.
💡 튜터의 한마디
오늘 세 번 같은 모양을 만났습니다.
허락 안 된 사진 주소는 빌드도 화면도 통과했는데, 폐기된 priority도 타입 검사와 린트를 통과했고, 없는 og:image 주소도 전부 통과합니다.
공통점은 우리 쪽 도구가 볼 수 있는 범위 밖에서 결과가 나온다는 것입니다. 사진 바이트는 브라우저가 따로 요청하고, 폐기 여부는 다음 메이저에서 드러나고, 카드는 남의 서버가 그립니다.
그래서 확인 방법도 도구 밖에 있는데, 실제로 띄워서 열어보기, 문서의 그 페이지를 한 번 읽기, 배포한 주소를 미리보기 도구에 넣어보기요. 셋 다 자동화가 어려운데, 절차로 만들어두지 않는다면 아무도 안 합니다.
🤔 [생각해볼 주제 1] 우리 서버가 사진을 만들어야 할까요
문제 상황 요약
/_next/image는 요청이 오면 원본을 받아다 크기와 형식을 바꿔서 돌려줍니다. 설정 한 줄이면 되고 코드도 그대로예요. 대신 그 변환을 우리 서버의 CPU가 하는데, 화면을 그리는 일과 같은 자원을 나눠 쓰게 돼요.
대안은 사진 전용 서비스에 맡기는 것인데, 편해지는 대신 비용이 들고 그 업체에 묶이고, 그쪽이 멈추면 우리 사진이 전부 안 나옵니다.
튜터의 가이드 및 해설
먼저 볼 숫자는 트래픽이 아니라 "서로 다른 사진 × 서로 다른 크기"의 개수입니다.
변환은 한 번만 일어나는데, 첫 요청 때 만들고 그 결과를 캐시에 두기 때문이에요. 하루 10만 장을 서비스한다는 말이 곧 하루 10만 번 변환한다는 뜻은 아닙니다. 인기 게시물 하나를 만 명이 봤다면 변환은 몇 번뿐이에요.
그러니 진짜로 봐야 할 것은 이겁니다.
- 새로 올라오는 사진이 하루 몇 장인가. 이게 변환 횟수의 상한입니다.
- 한 장이 몇 가지 크기로 필요한가.
srcSet후보가 여덟 개면 최악의 경우 여덟 번입니다. - 변환 한 번에 몇 초 걸리는가. 그 시간 동안 그 서버는 다른 요청을 못 받습니다.
이 셋을 곱하면 하루에 변환에 쓰는 시간이 나오는데, 그게 하루 24시간에 비해 우스운 숫자라면 직접 하면 되고, 무시 못 할 비중이라면 옮겨야 합니다.
두 번째로 볼 것은 캐시가 어디에 있느냐입니다.
우리 서버가 만든 결과는 그 서버의 디스크에 저장되는데, 서버를 여러 대로 늘리면 각자 따로 만들고 따로 저장해요. 세 대면 세 번 만드는 셈이고, 서버를 새로 띄우면 그 서버는 처음부터 다시 만들고요.
배포할 때마다 캐시가 날아가는 구조라면, 배포 직후가 가장 느립니다. 사용자가 몰리는 시간에 배포하면 그 순간 변환이 한꺼번에 터져요.
세 번째는 되돌릴 수 있느냐입니다.
next/image는 loader를 바꿔 끼울 수 있게 되어 있는데, 컴포넌트를 그대로 두고 사진을 만들어주는 쪽만 갈아 끼우는 구조예요. 그래서 이 결정은 되돌릴 수 있는 결정입니다.
되돌릴 수 있는 결정은 미리 고민할 이유가 적습니다. 기본값으로 시작해서, 위 숫자들이 실제로 아파질 때 옮기면 돼요. 반대로 되돌리기 어려운 결정이라면 처음부터 신중해야 하고요.
🎯 면접관을 홀리는 핵심 멘트
"저는 먼저 이 결정이 되돌릴 수 있는지부터 봅니다.
next/image는 loader만 갈아 끼우면 되니까 되돌릴 수 있는 결정이었고, 그래서 기본값으로 시작했습니다. 대신 옮길 시점의 기준은 미리 정해뒀어요. 하루 신규 업로드 수 × 크기 후보 수 × 변환 시간이 서버 처리 시간의 일정 비율을 넘으면 옮기기로요. 실제로 부딪힌 건 그 숫자보다 배포 문제였습니다. 배포할 때마다 캐시가 날아가서 직후 응답이 튀었거든요. 그게 옮길 결정적 이유가 됐습니다."
🤔 [생각해볼 주제 2] 폐기됐는데 동작하는 API를 어떻게 발견할까요
문제 상황 요약
priority는 Next 16에서 폐기됐는데, 타입 검사도 린트도 개발 서버 로그도 아무 말을 안 했어요. 렌더 결과까지 같았고요.
신호가 아예 없다는 게 문제인데, 다음 메이저에서 사라져야 알게 되고, 그때는 이미 그 코드가 프로젝트 곳곳에 복사되어 있습니다.
튜터의 가이드 및 해설
사람의 성실함에 기대는 방법은 반드시 실패합니다. "문서를 잘 읽자"는 대책이 아닌데, 바쁠 때 제일 먼저 사라지는 게 그겁니다.
기댈 수 있는 건 세 층입니다.
첫째, 프로젝트가 스스로 거절하게 만듭니다.
우리가 이미 아는 폐기 목록은 검사로 바꿔둘 수 있는데, 린트 규칙을 하나 만들거나, 그게 부담이라면 저장소에 금지 문자열 목록을 두고 검사에서 걸리게 하는 것만으로도 충분해요.
핵심은 알고 나면 반드시 그 자리에 적어두는 것입니다. 오늘 우리가 priority를 알았으니까 오늘 목록에 넣습니다. 안 넣으면 3개월 뒤 다른 사람이 같은 코드를 쓰는데, 사람은 잊지만 목록은 안 잊어요.
둘째, 올릴 때 읽습니다.
버전을 올릴 때가 유일하게 "이번에 뭐가 바뀌었지"를 자연스럽게 묻는 순간입니다. 그때 릴리스 노트와 마이그레이션 문서를 읽는 것을 절차로 만드는데, 프레임워크들은 자동 변환 도구도 같이 내놓는 경우가 많아요.
읽고 나면 반드시 첫째 층으로 옮기는데, 읽기만 하고 끝내면 읽은 사람 머릿속에만 남습니다.
셋째, 새 코드를 쓸 때 그 API 페이지를 엽니다.
오늘 우리가 실제로 그렇게 발견했습니다. next/image를 처음 쓰면서 문서를 열었고, 거기 priority 항목에 폐기 표시가 있었어요. 이건 검색으로는 못 찾는데, 검색 결과 상위는 대부분 옛 글이거든요.
프론트엔드는 이 문제가 특히 심한데, 반년마다 메이저가 바뀌고 블로그 글은 그대로 남고 검색 순위는 오래된 글이 높아서, C-1에서 라우터 패키지 이름이 바뀐 것도 D-2에서 훅을 가져오는 주소가 바뀐 것도 같은 종류였어요.
그중 제일 위험한 것은 이름은 그대로인데 뜻이 달라진 것입니다. 사라진 것은 터지기라도 하는데, 이건 눈으로 맞아 보이는 채로 낡습니다.
🎯 면접관을 홀리는 핵심 멘트
"저희는 '알게 된 것은 그날 검사로 옮긴다'를 규칙으로 뒀습니다. 폐기된 API를 발견하면 문서에 적는 게 아니라 그날 금지 목록에 넣어요. 문서는 안 읽히지만 검사는 안 읽고 못 지나가니까요. 실제로 next/image의 priority가 그렇게 잡혔습니다. 타입도 린트도 통과하고 렌더 결과까지 같아서 아무 신호가 없었는데, 새 컴포넌트를 쓰면서 공식 API 문서를 연 김에 발견했고, 그 자리에서 목록에 넣었습니다."
🤔 [생각해볼 주제 3] 밀림을 없애는 것과 빨리 그리는 것 중 무엇이 먼저일까요
문제 상황 요약
오늘 밀림을 4,233픽셀에서 0으로 만들었지만 페이지가 빨라지진 않았습니다. 사진은 여전히 같은 시간에 도착해요. 오히려 자리를 미리 비워두니 처음엔 빈 사각형이 크게 보입니다.
반대로 사진을 아주 작게 만들면 빨리 그려지지만 화질이 나빠지는데, 지표들이 서로 밀어내요.
튜터의 가이드 및 해설
밀림이 먼저입니다. 이유는 두 가지예요.
하나. 느린 것은 기다리면 되지만 밀리는 것은 손해를 만듭니다.
느린 페이지에서 사용자가 하는 일은 기다리는 것인데, 짜증은 나지만 되돌릴 수 없는 일은 안 일어나요.
밀리는 페이지에서는 다른데, 누르려던 버튼이 순간 이동해서 다른 것이 눌립니다. 읽던 문장을 놓치는데, 결제 화면이라면 잘못 눌린 게 결제일 수도 있어요.
둘. 밀림은 고치면 다시 안 생기고, 속도는 계속 관리해야 합니다.
width/height를 붙이는 건 한 번 하면 끝인데, 반면 속도는 코드가 늘고 라이브러리가 붙을 때마다 다시 나빠져요. 한 번에 끝나는 일을 먼저 하는 게 맞습니다.
"빈 사각형이 크게 보인다"는 것도 다시 볼 필요가 있습니다.
밀림을 없앤 뒤 처음 화면은 빈 네모가 큰데, 그건 아직 안 온 것이 있다는 사실을 정직하게 보여주는 것이에요. 사용자는 "여기에 사진이 올 자리구나"를 알고 넘어갑니다.
밀림이 있던 화면은 반대로 "다 왔다" 고 거짓말을 하다가 갑자기 뒤집습니다. 어느 쪽이 나은지는 분명해요.
속도는 밀림을 없앤 다음, 사용자가 "떴다" 고 느끼는 한 지점만 골라서 잡습니다. 우리 피드에서는 첫 사진인데, 그래서 Step 5에서 그 한 장만 eager로 바꿨어요. 나머지 아홉 장은 늦어도 아무도 모릅니다.
전부를 빠르게 만들려고 하면 첫 장이 느려지는데, 전부를 챙기는 건 아무것도 안 챙기는 것과 같아요.
우리 앱의 순서로 정리하면 이렇습니다.
- 밀림 0 — 한 번 고치면 끝나고, 손해를 막습니다.
- 첫 화면의 가장 큰 것 하나 — 사용자가 "떴다" 고 느끼는 그 순간.
- 나머지 — 안 보이는 것은 늦게 와도 됩니다.
🎯 면접관을 홀리는 핵심 멘트
"저는 '느린 것'과 '흔들리는 것'을 다른 문제로 봅니다. 느린 건 사용자가 기다리면 되지만, 흔들리는 건 누르려던 걸 잘못 누르게 만들거든요. 되돌릴 수 없는 손해가 생기는 쪽을 먼저 잡습니다. 게다가 밀림은 폭과 높이를 알려주는 걸로 한 번에 끝나고 다시 안 생기는 데 반해, 속도는 기능이 늘 때마다 다시 나빠져서 계속 관리해야 합니다. 그래서 밀림을 먼저 0으로 만들고, 속도는 사용자가 '떴다' 고 느끼는 한 지점만 골라서 잡았습니다."