F-2: 이미지 & 리소스 최적화 — 찾아낸 약점을 실제로 고쳐 점수를 끌어올린다
목차 46
안녕하세요, 홍순구 튜터입니다. 지난 시간(F-1)에 우리는 "빠르다"라는 막연한 느낌을 숫자로 바꿨어요. Lighthouse로 피드의 성능 점수를 받고, LCP·INP·CLS 세 지표로 어디가 약한지 읽었죠. 그러고 나서 제가 마무리에 할 일 목록을 적어 드렸어요. 측정의 진짜 목적은 점수 자랑이 아니라 "어디를 고칠지"를 알아내는 거라고요.
오늘은 그 목록을 들고 직접 손을 대요. 측정이 아니라 수술이에요. 지난 시간에 받은 점수를 기억해 두세요. 오늘 다 고친 뒤에 똑같이 다시 재서, 숫자가 정말 올라가는지 두 눈으로 확인할 거예요.
F-1 에서 받은 "할 일 목록" 오늘 F-2 에서 하는 일
┌──────────────────────────────┐ ┌─────────────────────────────┐
│ ① 첫 사진이 늦게 뜬다 │ │ → lazy 떼고 먼저 받기 │
│ (LCP 후보에 lazy 붙음) │ ───▶ │ (fetchpriority·preload) │
│ ② 이미지가 너무 무겁다 │ │ → 더 가벼운 포맷(WebP) │
│ ③ 화면 밖 사진을 똑똑하게 │ │ → 네이티브 lazy + 직접 제어 │
│ ④ 코드 묶음이 크다 │ │ → 코드 쪼개기(스플리팅) │
└──────────────────────────────┘ └─────────────────────────────┘
💡 오늘 수업의 핵심 — "무거운 이미지를 가볍게(WebP), 주인공 사진은 먼저(우선 로드), 나머지는 천천히(lazy), 큰 코드 묶음은 잘게(스플리팅) — 네 가지 손질로 지난 시간 받은 점수를 실제로 끌어올린다." 🎯
🎯 학습 목표
- 웹페이지에서 가장 무거운 게 이미지라는 걸 알고, 이미지를 다루는 네 가지 규율(포맷·우선순위·지연·예약)을 이해합니다.
- 같은 사진을 더 가벼운 WebP 포맷으로 내려보내되, 못 받는 브라우저를 위해
<picture>로 되돌아갈 사다리를 놓습니다. - 가장 큰 첫 사진(LCP)에서
loading="lazy"를 떼고fetchpriority="high"·preload로 먼저 받아오게 합니다. - 화면 밖 사진은
loading="lazy"로 미루고, Intersection Observer로 등장 연출을 직접 더하며, 두 방법의 장단점을 압니다. - 코드 스플리팅으로 첫 화면 코드 묶음을 가볍게 만들고, 고치기 전·후를 다시 측정해 증명합니다.
Step 1: 무게부터 재보자 — 웹페이지에서 제일 무거운 건 이미지
성능을 고치러 왔으니, 가장 먼저 "무엇이 제일 무거운가" 부터 알아야 해요. 다이어트를 하려면 어디에 살이 붙었는지부터 봐야 하잖아요. 웹페이지도 똑같아요.
페이지 용량의 대부분은 사진이다
보통의 웹페이지를 열어 받아오는 파일들을 무게순으로 쌓아 보면, 대개 이런 그림이 나와요.
웹페이지 하나를 받을 때 오가는 데이터 (전형적인 비율)
이미지 ██████████████████████████ 약 50% 이상
동영상 ████████
폰트 ████
CSS ██
HTML █
JS ██████ ← 우리가 지난 시간 줄인 부분
→ 글자(HTML·CSS·JS)를 아무리 줄여도, 사진을 안 건드리면 큰 덩어리가 그대로 남아요
지난 시간에 Vite로 JS 묶음을 줄였죠. 그건 분명 잘한 일이에요. 그런데 위 그림을 보면, 진짜 큰 덩어리는 이미지예요. 우리 인스타그램은 사진이 주인공인 서비스라 더 그래요. 그러니 오늘 손질의 절반 이상은 이미지에 쏟아요.
이미지를 다루는 네 가지 규율
그렇다고 사진을 빼 버릴 순 없어요. 사용자가 보려는 게 바로 그 사진이니까요. 그래서 "빼지 말고 똑똑하게 다루는" 네 가지 규율을 오늘 하나씩 익혀요.
① 포맷 — 같은 사진을 더 가벼운 파일 형식으로 (WebP/AVIF) → Step 2·3
② 우선순위 — 주인공 사진은 먼저, 단역은 나중에 (fetchpriority) → Step 4·5
③ 지연 — 화면 밖 사진은 보일 때까지 미루기 (lazy / Observer) → Step 6·7
④ 예약 — 들어올 자리를 미리 잡아 화면 흔들림 막기 (width·height) → 이미 함!
네 번째 "예약"은 이미 우리가 하고 있어요. 모든 <img>에 width·height를 적어 둬서, 사진이 늦게 떠도 자리가 밀리지 않아요(지난 시간에 CLS가 좋았던 이유죠). 그러니 오늘은 앞의 세 가지에 집중해요. 자, 첫 번째 규율인 포맷부터 들어가요.
Step 2: 같은 사진, 절반 용량 — 이미지 포맷 삼형제
사진 파일에도 "형식(포맷)"이 있어요. 같은 풍경 사진이라도 어떤 형식으로 저장하느냐에 따라 용량이 크게 달라져요. 우리가 흔히 보는 .jpg(제이펙) 말고도, 더 똑똑하게 압축하는 새 형식들이 있어요.
압축이란 "비슷한 건 대충, 중요한 건 정확히"
사진 포맷이 용량을 줄이는 원리는 손실 압축이에요. 사람 눈이 잘 구별 못 하는 미세한 차이는 과감히 버리고, 눈에 띄는 부분만 정확히 남기는 거예요. 하늘처럼 비슷한 파란색이 넓게 펼쳐진 곳은 "여기 다 비슷한 파랑"이라고 뭉뚱그려 적으면, 픽셀 하나하나 적는 것보다 훨씬 가벼워지죠. 새 포맷일수록 이 "버리는 기술"이 더 똑똑해서, 같은 화질을 더 작은 용량으로 담아요.
같은 600×600 사진을 형식만 바꿨을 때 (대략적인 용량 비교)
JPG ████████████████████ 100 (기준)
WebP ██████████████ 약 70 (25~35% 더 가벼움)
AVIF ██████████ 약 50 (40~50% 더 가벼움)
→ 화질은 거의 그대로인데 용량만 절반 가까이 줄어요
WebP(웹피) 는 JPG보다 대략 25~35% 가벼워요. AVIF(에이브이아이에프) 는 더 똑똑해서 절반 가까이 줄여요. 사진을 바꾸지도, 화질을 떨어뜨리지도 않고 그냥 "담는 그릇"만 바꿨는데 용량이 줄어드는 거예요.
문제는 "모두가 새 그릇을 못 읽는다"는 것
이렇게 좋은데 왜 다들 안 쓸까요? 새 포맷은 오래된 브라우저가 못 읽을 수 있기 때문이에요. 2026년 지금은 상황이 꽤 좋아졌어요.
| 포맷 | 대략적인 지원 | 비고 |
|---|---|---|
| JPG | 100% | 누구나 읽음. 가장 안전한 기본값 |
| WebP | 약 96% | 거의 모든 브라우저 |
| AVIF | 약 94% | 최신 브라우저. 가장 가벼움 |
WebP·AVIF가 94~96%까지 올라왔지만, 나머지 몇 %를 위한 대비는 여전히 필요해요. 그래서 우리는 "더 가벼운 걸 먼저 권하되, 못 읽으면 안전한 JPG로 되돌아가게" 사다리를 놓을 거예요. 그게 다음 Step의 <picture>예요.
🙋 학생 질문 — "튜터님, 용량은 DevTools 어디서 봐요?"
크롬 개발자 도구의 Network(네트워크) 탭에서 봐요. 페이지를 새로고침하면 받아온 파일이 줄줄이 뜨는데, 이미지 한 줄을 누르면 Size(용량)와 Type(형식)이 보여요. 같은 사진을 .jpg로 받을 때와 .webp로 받을 때 이 숫자가 어떻게 다른지, 다음 Step에서 직접 비교해 볼 거예요.
Step 3: 더 가벼운 포맷을 권하되, 못 받으면 되돌아가게 — <picture>
이제 첫 코드를 손대요. 우리 피드의 게시물 사진을 WebP로 받되, 못 읽는 브라우저는 JPG로 되돌아가게 만들 거예요. 이때 쓰는 게 <picture> 태그예요. 반응형(B-6)에서 화면 크기별로 다른 이미지를 줄 때 한 번 만났던 그 태그가 맞아요. 이번엔 "화면 크기"가 아니라 "포맷"을 고르는 데 써요.
<picture>는 후보를 위에서부터 시험한다
<picture> 안에는 여러 후보를 넣어요. 브라우저는 맨 위 후보부터 "이거 읽을 수 있어?"를 시험해서, 읽을 수 있는 첫 후보를 골라요. 못 읽으면 아래로 내려가고, 마지막엔 누구나 읽는 <img>가 받쳐 줘요.
브라우저가 <picture> 를 읽는 순서
<source webp> ──"webp 읽을 수 있어?"──┐
│ 예 → 이걸로 끝! (가벼운 webp)
│
└ 아니오 ↓
<img jpg> ────────────────────── 누구나 읽는 안전한 JPG (마지막 보루)
우리 게시물 사진을 그리는 곳은 post-card.js의 카드 템플릿이에요. 사진 한 줄을 <picture>로 감쌌어요.
// instagram-clone-frontend/js/post-card.js
<figure class="post-photo">
<picture>
<source type="image/webp" srcset="${post.image}.webp">
<img src="${post.image}.jpg" alt="${post.alt}" width="600" height="600" loading="lazy">
</picture>
</figure>
<source type="image/webp">가 첫 후보예요. type으로 "이건 webp야"라고 알려주면, webp를 읽을 수 있는 브라우저만 이 줄을 골라요. 못 읽는 브라우저는 무시하고 아래 <img>의 .jpg로 내려가요. width·height·alt는 항상 안쪽 <img>에 적어요(자리 예약과 대체 텍스트는 <img>의 몫이에요).
💡 우리 이미지 주소(
post.image)는https://picsum.photos/seed/insta1/600/600처럼 확장자가 없어요. 여기에.webp·.jpg를 붙이면 같은 사진을 그 형식으로 받을 수 있어요. 우리가 쓰는 이미지 서버가 두 형식을 모두 내려주거든요.
AVIF는 왜 안 넣었을까
눈치챈 분도 있을 거예요. "삼형제라며 AVIF는 왜 빠졌어요?" 이상적으로는 AVIF를 맨 위에 한 줄 더 얹어요.
이상적인 3단 사다리 (자체 이미지 서버가 있다면)
<source type="image/avif" srcset="...avif"> ← 가장 가벼움, 먼저 시험
<source type="image/webp" srcset="...webp"> ← 그다음
<img src="...jpg"> ← 마지막 보루
다만 우리가 쓰는 연습용 이미지 서버는 AVIF를 안 내려줘요. 그래서 우리 실습은 WebP→JPG 2단으로 가요. 나중에 여러분이 직접 만든 서비스에서 AVIF 파일을 가지고 있다면, 위처럼 <source avif> 한 줄만 맨 위에 더 얹으면 돼요. 사다리는 똑같고 칸만 하나 늘어나는 거예요.
<picture>도 카드 너비를 꽉 채우게
<picture>는 그냥 <img>를 감싸는 상자예요. 예전엔 <img>가 직접 카드 너비를 채웠는데, 이제 그 사이에 상자가 하나 끼었으니 상자도 블록으로 자리를 차지하게 한 줄 더해 줬어요.
/* instagram-clone-frontend/css/components.css */
article figure picture {
display: block;
}
이제 개발자 도구 Network 탭을 열고 새로고침해 보면, 게시물 사진이 webp 형식으로 받아져요. 같은 사진인데 Type이 webp로 찍히고 용량이 JPG보다 작은 걸 직접 확인할 수 있어요. 같은 화면, 더 가벼운 무게예요.
Step 4: 주인공은 맨 앞으로 — LCP 이미지 구출 작전
이번 Step이 오늘의 심장이에요. 지난 시간에 찾아낸 가장 큰 약점, 바로 첫 게시물 사진이 늦게 뜨는 문제를 고쳐요.
우리가 주인공을 미루고 있었다
지난 시간에 LCP(가장 큰 콘텐츠가 뜨는 시간)가 약하다고 나왔죠. 범인은 우리 자신이었어요. 모든 게시물 사진에 loading="lazy"(천천히 받기)가 붙어 있었거든요. 화면 아래 사진엔 좋은 일이지만, 화면 맨 위 첫 사진에까지 "천천히 받아"라고 한 게 문제였어요. 주인공한테 늦게 오라고 한 셈이죠.
Before — 첫 사진도 lazy After — 첫 사진은 먼저
┌──────────────────────────┐ ┌────────────────────────────┐
│ HTML·CSS·JS 다 받고 │ │ 첫 사진을 곧장 받기 시작 │
│ → 그제서야 첫 사진 요청 │ │ → 화면 뜨자마자 주인공 등장│
│ → LCP 늦음 │ │ → LCP 빨라짐 🌟 │
└──────────────────────────┘ └────────────────────────────┘
첫 사진만 "먼저 받아"라고 표시하기
그래서 PostCard에 "이 카드가 주인공이냐"를 알려주는 표시(priority)를 하나 달았어요. 주인공이면 loading="lazy"를 빼고, 대신 fetchpriority="high"("이거 급해, 먼저 받아")를 붙여요.
// instagram-clone-frontend/js/post-card.js
export class PostCard {
constructor(post, options = {}) {
this.post = post; // 게시물 데이터(객체)를 인스턴스에 담아 둬요
// priority: true 면 "가장 큰 첫 사진(LCP)" — 미루지 말고 먼저 받아 와요
this.priority = options.priority ?? false;
}
그리고 사진을 그리는 줄에서, 주인공이냐 아니냐에 따라 붙는 속성을 갈라요.
// instagram-clone-frontend/js/post-card.js
<img src="${post.image}.jpg" alt="${post.alt}" width="600" height="600"
${this.priority ? 'fetchpriority="high"' : 'loading="lazy"'}>
주인공이면 fetchpriority="high", 아니면 loading="lazy". 한 줄로 둘을 가른 거예요.
누가 주인공인지는 피드가 정한다
그럼 "누가 주인공이냐"는 누가 정할까요? 게시물을 그려 내려보내는 feed.js예요. 가장 첫 페이지의 가장 첫 게시물 — 그게 화면 맨 위 가장 큰 사진, 즉 LCP 후보예요.
// instagram-clone-frontend/js/feed.js
result.data.forEach((post, index) => {
// 첫 페이지의 첫 게시물 = 화면 맨 위 가장 큰 사진(LCP 후보)이에요.
// 이 한 장은 미루지 말고 "먼저 받아 와"라고 일러둬요(priority).
const isLcp = currentPage === 1 && index === 0;
const card = index === 2
? new AdPostCard(post)
: new PostCard(post, { priority: isLcp });
currentPage === 1 && index === 0 — 첫 페이지의 0번 게시물일 때만 priority가 켜져요. 딱 한 장이에요. 주인공은 한 명이어야 의미가 있어요. 모두를 "먼저 받아"라고 하면 아무도 먼저가 아니게 되니까요.
⚠️
fetchpriority="high"는 딱 한 장, 진짜 주인공에게만 줘요. 모든 사진에 "급해"라고 붙이면 브라우저가 우선순위를 못 가려서, 안 붙인 것과 똑같아져요.
이제 첫 사진은 화면이 뜨자마자 곧장 받기 시작해요. 나머지 사진은 여전히 loading="lazy"로 화면에 가까워질 때 받고요. 주인공은 먼저, 단역은 나중에 — 규율 ②번 "우선순위"가 적용된 거예요.
Step 5: 먼저 손잡고, 먼저 받아오기 — preconnect & preload
첫 사진을 "먼저 받아"라고 표시는 했는데, 그 사진이 멀리 있는 다른 서버에서 온다는 게 또 하나의 걸림돌이에요. 우리 사진은 전부 외부 이미지 서버(picsum)에서 와요. 처음 보는 서버와는 인사부터 나눠야 사진을 받을 수 있어요.
외부 서버와 인사하는 데도 시간이 든다
브라우저가 처음 보는 서버에서 파일을 받으려면, 본론(파일 받기) 전에 준비 단계를 거쳐요. 주소를 찾고(DNS), 연결을 트고(TCP), 안전한 통로를 만들고(TLS) — 이게 다 시간이에요.
처음 보는 서버에서 사진 받기
[주소 찾기] → [연결 트기] → [안전 통로] → [사진 받기]
DNS TCP TLS ← 진짜 본론
└────────── 준비만 하는 시간 ──────────┘
이 준비를 "미리" 해 두면 본론이 그만큼 빨라져요
preconnect(미리 연결)는 이 준비 단계를 페이지 읽기 시작과 동시에 미리 해 두라는 신호예요. 사진을 실제로 요청할 때쯤이면 이미 손을 잡아 둔 상태라, 곧장 받기 시작해요.
첫 사진은 아예 미리 받기 시작 — preload
preconnect가 "손 미리 잡기"라면, preload(미리 받기)는 한 걸음 더 나아가 "이 파일, 화면 뼈대 다 읽기 전이라도 지금 당장 받기 시작해"예요. 우리 주인공 사진(LCP)에 딱 맞는 신호죠. feed.html의 <head>에 이렇게 넣었어요.
<!-- instagram-clone-frontend/feed.html -->
<!-- 사진이 오는 이미지 서버에 미리 손을 잡아둬요(연결 준비) -->
<link rel="preconnect" href="https://picsum.photos">
<link rel="dns-prefetch" href="https://picsum.photos">
<!-- 가장 큰 첫 사진(LCP)은 화면 뼈대보다 먼저 받기 시작해요. picture 가 고를 webp 를 똑같이 가리켜요 -->
<link rel="preload" as="image" imagesrcset="https://picsum.photos/seed/insta1/600/600.webp" type="image/webp" fetchpriority="high">
preconnect로 이미지 서버와 손을 미리 잡고, dns-prefetch는 아주 옛 브라우저를 위한 더 간단한 예비 신호예요. 그리고 preload로 첫 사진을 미리 받기 시작해요. 한 가지 주의할 점 — preload가 가리키는 주소는 <picture>가 실제로 고를 주소와 똑같아야 해요. 안 그러면 같은 사진을 두 번 받게 되거든요. 그래서 imagesrcset에 .webp 주소를 똑같이 적고, type="image/webp"로 "webp 못 읽는 브라우저는 이 미리 받기를 무시해"라고 일러 둬요.
preload 와 prefetch 는 다르다
이름이 비슷한 prefetch와 헷갈리기 쉬워요. 둘은 "언제 쓸 것이냐"가 달라요.
| 신호 | 뜻 | 언제 |
|---|---|---|
preload |
지금 이 페이지에 곧 필요한 걸 먼저 받기 | LCP 사진처럼 당장 쓸 것 |
prefetch |
다음에 갈 법한 페이지의 걸 한가할 때 미리 | 프로필로 넘어갈 것 같을 때 |
오늘 우리가 쓴 건 preload예요. 당장 이 화면의 주인공이 필요하니까요. prefetch는 "사용자가 다음에 누를 것 같은 페이지"를 미리 받아둘 때 쓰는데, 우리는 개념만 알아 둬요.
Step 6: 나머지는 천천히 — loading="lazy"의 진짜 일과 한계
주인공을 먼저 받게 했으니, 이번엔 반대로 단역들을 똑똑하게 미루는 이야기예요. 사실 우리는 이미 하고 있었어요. 비-LCP 사진엔 전부 loading="lazy"가 붙어 있죠. 그게 정확히 무슨 일을 하는지 짚고 갈게요.
lazy 는 "보일 때쯤 받아라"
loading="lazy"가 붙은 이미지는, 페이지를 열자마자 받지 않아요. 사용자가 스크롤해서 그 사진이 화면에 가까워질 때서야 받기 시작해요. 화면 맨 아래, 한참 스크롤해야 보이는 사진을 처음부터 다 받아 두는 건 낭비잖아요. 안 볼 수도 있는데 말이죠.
화면(viewport)
┌────────────────────┐
│ 지금 보이는 사진 │ ← 즉시 받음
│ [사진 1] 받음 ✓ │
├────────────────────┤ ← 화면 경계
│ [사진 2] 곧 보임 │ ← 가까워지면 받기 시작 (lazy)
│ │
│ [사진 3] 한참 밑 │ ← 아직 안 받음 (스크롤하면 그때)
└────────────────────┘
좋은 점은 공짜라는 거예요. <img>에 loading="lazy" 한 단어만 적으면 브라우저가 알아서 다 해 줘요. JavaScript 한 줄 안 짜도 되고, 거의 모든 브라우저가 이해해요. 그래서 화면 밖 이미지를 미루는 첫 번째 선택지는 언제나 네이티브 loading="lazy" 예요.
그런데 lazy 로는 못 하는 게 있다
다만 네이티브 lazy는 "받는 시점"만 정해 줘요. 그 이상은 못 해요. 예를 들면 이런 것들이요.
- 사진이 스르륵 나타나는 연출(페이드인)을 넣고 싶다 — 못 해요.
- "화면에 닿기 정확히 300px 전에 받아라" 처럼 시점을 세밀하게 조절하고 싶다 — 못 해요.
- 사진이 보일 때 다른 동작(예: 통계 기록)을 같이 하고 싶다 — 못 해요.
이런 세밀한 제어가 필요하면, 우리가 직접 "이 요소가 화면에 보이나?"를 감시하는 도구를 써야 해요. 바로 다음 Step의 Intersection Observer예요. 사실 우리는 이 도구를 이미 한 번 써 봤어요. 어디였을까요?
Step 7: 직접 만들어보는 등장 연출 — Intersection Observer
답은 D-4 무한 스크롤이에요. 목록 맨 아래 "감시병"이 화면에 보이면 다음 페이지를 불러왔죠. 그때 "감시병이 보이나?"를 대신 지켜본 도구가 IntersectionObserver(인터섹션 옵저버, 교차 관찰자) 예요. 스크롤 위치를 우리가 계산하지 않고, 브라우저에게 "이 요소가 화면에 들어오면 알려줘"라고 맡기는 도구예요. 오늘은 같은 도구로, 사진이 화면에 들어올 때 스르륵 나타나는 연출을 더해요.
보이면 또렷해지는 관찰자
새 파일 lazy-image.js를 만들었어요. 관찰자 하나를 만들어 두고, 사진이 화면에 들어오면 또렷하게(is-revealed) 바꿔 줘요.
// instagram-clone-frontend/js/lazy-image.js
const revealObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue; // 아직 화면 밖이면 그대로 둬요
entry.target.classList.add("is-revealed"); // 보이는 순간 또렷하게(페이드인)
revealObserver.unobserve(entry.target); // 한 번 나타나면 더는 안 봐요(낭비 방지)
}
},
{
// 화면에 닿기 200px 전에 미리 켜서, 스크롤이 도착했을 땐 이미 또렷해지도록 해요.
rootMargin: "200px",
}
);
entry.isIntersecting이 "지금 화면에 들어왔니?"예요. 들어왔으면 is-revealed 클래스를 달아 또렷하게 만들고, unobserve로 더는 감시하지 않아요. 한 번 나타난 사진을 계속 지켜볼 이유는 없으니까요. rootMargin: "200px"는 "화면에 닿기 200px 전에 미리 시작해"라는 뜻이에요 — 네이티브 lazy로는 못 하던 그 "세밀한 시점 조절"이죠.
JS 가 멈춰도 사진은 보이게 — 점진적 향상
연출을 위해 사진을 흐릿하게(opacity: 0) 시작했다가 또렷하게 만들 거예요. 그런데 여기 함정이 하나 있어요. 만약 처음부터 CSS로 모든 사진을 opacity: 0으로 깔아 두면, JavaScript가 어떤 이유로 멈췄을 때 사진이 영영 안 보여요. 그래서 흐릿하게 만드는 표시(lazy-photo)는 JS가 도는 지금 이 순간에만 붙여요.
// instagram-clone-frontend/js/lazy-image.js
export function revealOnScroll(figure) {
figure.classList.add("lazy-photo");
revealObserver.observe(figure);
}
revealOnScroll이 불려야 비로소 lazy-photo(흐릿 시작)가 붙어요. JS가 안 돌면 이 클래스도 안 붙으니, 사진은 그냥 또렷하게 보여요. "JS가 되면 더 멋지게, 안 되면 최소한 보이게" — 이런 안전한 설계를 점진적 향상(progressive enhancement) 이라고 해요.
흐릿함과 또렷함은 CSS가 맡아요.
/* instagram-clone-frontend/css/components.css */
.post-photo.lazy-photo {
opacity: 0;
transition: opacity 0.5s ease;
}
.post-photo.lazy-photo.is-revealed {
opacity: 1;
}
opacity: 0(흐릿)에서 is-revealed가 붙으면 opacity: 1(또렷)로, transition 덕에 0.5초에 걸쳐 스르륵 바뀌어요. B-7에서 배운 그 transition이에요.
주인공 빼고 나머지에만 연출을
이 연출은 주인공(LCP)을 뺀 사진에만 걸어요. 주인공은 즉시 또렷하게 떠야 하니까요. feed.js에서 카드를 만든 뒤, 주인공이 아니면 사진에 연출을 걸어 줘요.
// instagram-clone-frontend/js/feed.js
const node = card.render();
// LCP(첫 사진)는 즉시 또렷이, 나머지는 화면에 들어올 때 부드럽게 나타나게 해요.
if (!isLcp) {
revealOnScroll(node.querySelector(".post-photo"));
}
feedMain.insertBefore(node, sentinel); // 감시병 위에 차례로
네이티브 vs 직접 — 무엇을 언제 쓸까
여기서 한 가지 정리하고 갈게요. 우리 코드에서 사진을 실제로 미뤄 받는 일은 여전히 네이티브 loading="lazy"가 해요. Intersection Observer는 그 위에 등장 연출을 더한 거고요. 그럼 Observer로 받는 시점까지 직접 제어할 수도 있을까요? 네, 가능해요. 고전적인 방법은 src 대신 data-src에 주소를 숨겨 뒀다가, 화면에 들어올 때 JS가 옮겨 끼우는 거예요.
// (개념 설명용 — 우리 파일엔 안 넣어요. 우리는 네이티브 lazy 를 써요)
// <img data-src="진짜주소.jpg"> ← src 가 비어 있어 처음엔 안 받음
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 화면에 들어올 때 비로소 주소를 끼워 받기 시작
}
옛날엔 네이티브 lazy가 없어서 이렇게 직접 만들어 썼어요. 지금도 아주 세밀한 제어가 필요하면 쓰고요. 다만 이 방식엔 약점이 있어요 — src가 비어 있으니 JS가 멈추면 사진이 아예 안 받아져요. 네이티브 lazy는 그런 위험이 없죠. 그래서 둘을 이렇게 나눠 기억하세요.
네이티브 loading="lazy" |
Intersection Observer | |
|---|---|---|
| 코드 | 한 단어 | 직접 짜야 함 |
| JS 꺼지면 | 사진 정상 | (data-src 방식은) 사진 안 뜸 |
| 시점 조절·연출 | 못 함 | 자유롭게 |
| 첫 선택 | 이게 기본 🌟 | 세밀한 제어가 필요할 때 |
규율 ③번 "지연"을 두 갈래로 익혔어요. 기본은 네이티브, 욕심나면 직접. 우리 코드는 둘을 영리하게 섞었어요(받는 건 네이티브, 연출은 직접).
Step 8: 첫 화면을 더 가볍게 — 코드 쪼개기
마지막 규율은 사진이 아니라 코드예요. 지난 시간 마무리에서 "맛만 봤던" 코드 스플리팅을 본격적으로 써요.
번들 — 흩어진 코드를 한 묶음으로
지난 시간 Vite는 우리가 나눠 짠 여러 JS 파일을 하나로 묶어(번들) 줬어요. 파일이 흩어져 있으면 브라우저가 여러 번 받아와야 해서 느리거든요. 그런데 너무 다 묶으면 반대 문제가 생겨요. 첫 화면엔 당장 필요 없는 코드까지 한 덩어리에 들어가서, 그 큰 덩어리를 다 받아야 화면이 시작돼요.
다 묶기 쪼개기(코드 스플리팅)
┌──────────────────────┐ ┌────────────┐ ┌──────────────┐
│ 피드 + 이모지 + │ │ 피드(지금) │ │ 이모지(나중) │
│ 통신 + ... 한 덩어리 │ ───▶ │ 먼저 받음 │ │ 필요할 때 │
│ → 다 받아야 시작 │ └────────────┘ └──────────────┘
└──────────────────────┘ 첫 화면이 가벼워짐 🌟
필요할 때만 받기 — 우리는 이미 하고 있다
사실 우리는 한 군데서 이미 쪼개고 있어요. 이모지 고르개예요. 이모지 버튼을 누르는 그 순간에야 모듈을 받아오죠(import()). 첫 화면엔 이모지 코드가 빠져 있다가, 필요할 때 따로 받아와요. 이렇게 동적으로 받는 코드는 Vite가 알아서 별도 묶음으로 떼어 줘요.
// instagram-clone-frontend/js/feed.js (지난 시간에 만든 부분)
const emojiBtn = event.target.closest(".comment-emoji");
if (emojiBtn) {
// 누르는 그 순간에야 이모지 고르개 모듈을 받아와요(동적 import).
const { openEmojiPicker } = await import("./emoji-picker.js");
openEmojiPicker(emojiBtn, input);
}
잘 안 바뀌는 코드는 따로 묶어 캐시 재사용
한 걸음 더 나아가, "자주 바뀌는 코드"와 "잘 안 바뀌는 코드"를 일부러 갈라 두면 좋아요. 화면을 그리는 코드는 우리가 자주 손대지만, 서버와 통신하는 코드(api.js)는 한 번 만들면 거의 안 바뀌거든요. 이 둘을 갈라 두면, 화면 코드를 고쳐도 통신 묶음은 그대로라 사용자가 받아둔 걸 다시 안 받아요(캐시 재사용). Vite 설정에 통신 계층을 core라는 별도 묶음으로 떼라고 적었어요.
// instagram-clone-frontend/vite.config.js
output: {
// 자주 바뀌는 화면 코드와, 잘 안 바뀌는 통신 코드(api·api-error)를 갈라둬요.
// 이렇게 묶어 두면 화면을 고쳐도 통신 덩어리는 그대로라, 받아둔 걸 다시 안 받아요(캐시 재사용).
manualChunks(id) {
if (id.includes('/js/api.js') || id.includes('/js/api-error.js')) {
return 'core';
}
},
},
npm run build로 빌드해 보면, 이제 통신 계층이 core라는 별도 파일로 떨어져 나와요. 피드 묶음은 그만큼 가벼워지고, 통신 묶음은 독립적으로 캐시돼요. 이 시점에 빌드 결과를 보면 core 묶음이 따로 생기고 피드 묶음의 크기가 줄어든 걸 직접 확인할 수 있어요.
💡 코드 스플리팅은 만능이 아니에요. 너무 잘게 쪼개면 받아올 파일 개수가 늘어 오히려 느려질 수 있어요. "당장 안 쓰는 큰 덩어리"와 "거의 안 바뀌는 공용 코드" 정도만 떼는 게 적당해요.
Step 9: 다시 재보자 — Before / After 로 증명하기
네 가지 규율을 다 손봤어요. 이제 가장 중요한 순간이에요. 정말 빨라졌을까요? 숫자로 증명해요. 지난 시간과 똑같은 방법으로 다시 재요.
고치기 전·후를 나란히
지난 시간에 적어 둔 점수를 꺼내세요. 그리고 지금 다시 Lighthouse를 돌려, 특히 LCP가 어떻게 바뀌었는지 봐요.
같은 페이지, 고치기 전 → 후 (예시)
지표 Before After
LCP 3.1초 ──▶ 1.8초 🌟 주인공을 먼저 받게 한 효과
이미지 용량 큼 ──▶ 작음 🌟 webp 로 바꾼 효과
Performance 낮음 ──▶ 높음
진짜 우리 첫 사진이 LCP 인지 확인
Lighthouse 결과에서 LCP 항목을 펼치면, 어느 요소가 LCP로 잡혔는지 나와요. 우리 첫 게시물 사진이 맞는지 확인하세요. 그리고 Network 탭에서 그 사진이 webp로, 다른 사진들보다 먼저 받아지는지 봐요. preload 덕에 다른 파일보다 일찍 출발선을 끊었을 거예요.
측정의 출렁임에 속지 않기
한 가지 습관을 강조할게요. Lighthouse는 돌릴 때마다 점수가 조금씩 달라져요. 우리 이미지가 외부 서버에서 오는 탓도 크고요. 그러니 한 번 재고 "좋아졌다!"라고 단정하지 마세요. 고치기 전 3번, 고친 후 3번 재서 평균으로 비교해야 출렁임에 속지 않아요. 한 번의 좋은 점수는 운일 수 있지만, 여러 번의 평균은 거짓말을 안 해요.
💡 점수가 전부는 아니에요. 우리 목표는 100점이 아니라 "사용자가 첫 사진을 빨리 보는 것"이에요. 숫자는 그걸 확인하는 도구일 뿐, 숫자 자체가 목적이 되면 본말이 뒤집혀요.
마무리
오늘 우리는 지난 시간 받아 든 할 일 목록을 실제로 처리했어요. 측정에서 그치지 않고 손을 댄 거예요.
- 이미지 네 규율: 포맷(WebP) · 우선순위(LCP 먼저) · 지연(lazy) · 예약(width·height, 이미 함).
<picture>: 더 가벼운 WebP를 권하되, 못 읽으면 JPG로 되돌아가는 사다리. AVIF는 칸 하나 더 얹으면 끝.- LCP 구출: 첫 사진에서
loading="lazy"를 떼고fetchpriority="high"+preload로 먼저 받기. - preconnect / preload: 외부 서버와 미리 손잡고, 주인공 사진은 뼈대보다 먼저 받기 시작.
- lazy 두 갈래: 기본은 네이티브
loading="lazy", 세밀한 제어·연출은 Intersection Observer. - 코드 스플리팅: 당장 안 쓰는 코드는 따로(이모지), 잘 안 바뀌는 공용 코드도 따로(
core). - 증명: 고치기 전·후를 여러 번 재 평균으로 비교. 숫자는 목적이 아니라 도구.
다음 시간 예고
오늘 내내 우리는 <img>에 alt(대체 텍스트)를 적었어요. "노을이 지는 협재 해변" 처럼요. 이 글자가 대체 누구를 위한 걸까요? 눈으로 사진을 못 보는 분들이, 화면을 소리로 읽어 주는 프로그램으로 우리 사이트를 쓸 때, alt가 사진을 대신 읽어 줘요. 다음 시간(G-1)부터는 웹 접근성 — 눈으로 못 보거나, 마우스를 못 쓰는 분들도 우리 인스타그램을 쓸 수 있게 만드는 법을 배워요. 우리가 그동안 alt를 잘 챙겼는지, 키보드만으로 좋아요·댓글을 누를 수 있는지 점검하는 것부터 시작해요.
과제
오늘 고친 코드를 직접 확인하고, 한 걸음 더 실험해 보는 과제예요. npm run mock과 npm run dev를 켜 둔 채로 진행하세요.
[구현] WebP 로 받아지는지 두 눈으로 확인하기
개발자 도구 Network 탭을 열고 feed.html을 새로고침하세요.
- 게시물 사진 한 줄을 찾아,
Type이webp로 찍히는지 확인하세요. - 그 사진의
Size(용량)를 적어 두세요. 주소 끝의.webp를.jpg로 바꿔 같은 사진을 받아 보고(주소창에 직접 입력해도 돼요), 용량이 얼마나 차이 나는지 비교해 적으세요. - "같은 사진인데 형식만 바꿔서 얼마를 아꼈는가"를 한 문장으로 정리하세요.
[구현] 첫 사진이 정말 먼저 출발하는지 확인하기
같은 Network 탭에서, 이번엔 받아오는 순서를 보세요.
- 첫 게시물 사진(첫 페이지 0번)이 다른 게시물 사진들보다 먼저 받아지기 시작하는지 확인하세요.
- 그 사진 줄의
Priority(우선순위) 칸이High로 찍히는지 보세요. 다른 게시물 사진은 어떤가요? feed.js에서isLcp조건을 잠깐index === 1로 바꿔 저장해 보고, 어느 사진의 우선순위가 바뀌는지 관찰한 뒤 반드시 원래대로 되돌리세요.
[탐구] AVIF 사다리를 상상해 보기
우리 실습은 WebP→JPG 2단이었어요. 만약 여러분이 자체 이미지 서버에서 AVIF 파일까지 가지고 있다면, post-card.js의 <picture>를 어떻게 바꿀까요?
- AVIF 후보를 어디에(맨 위? 맨 아래?) 넣어야 하는지, 그 이유를
<picture>가 후보를 고르는 순서로 설명하세요. - AVIF를 못 읽는 브라우저는 그다음 무엇을 고르게 되는지, 사다리를 따라 적어 보세요.
생각해볼 주제
정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어 보는 질문들이에요. 스스로 답을 만들어 본 뒤, 예시답안과 비교해 보세요.
1. 모든 사진을 preload 하면 더 빨라질까?
preload로 첫 사진을 먼저 받게 했더니 빨라졌어요. 그럼 욕심을 내서 화면의 모든 사진을 preload하면 더 빨라질까요? fetchpriority="high"도 전부 붙이면요? 한정된 인터넷 속도라는 자원을 떠올려 보세요. 모두가 "내가 먼저"라고 외치면 무슨 일이 벌어질까요? "먼저 받기"가 효과를 보려면 무엇이 전제되어야 하는지 생각해 보세요.
2. 왜 LCP 이미지에는 lazy 를 걸면 안 될까?
loading="lazy"는 분명 성능에 좋은 기능이에요. 화면 밖 사진을 안 받으니까요. 그런데 우리는 첫 사진에서 일부러 그걸 뗐어요. 좋은 기능을 왜 주인공에게는 빼야 했을까요? lazy가 "보일 때쯤 받는다"는 뜻이라는 걸 떠올려, 화면 맨 위에 있는 사진에 lazy를 걸면 그 사진이 언제 받아지기 시작하는지 따라가 보세요. "좋은 기능도 자리를 잘못 만나면 독이 된다"는 게 무슨 뜻일지 정리해 보세요.
3. 코드를 잘게 쪼갤수록 좋을까?
코드 스플리팅으로 첫 화면이 가벼워졌어요. 그럼 코드를 잘게, 더 잘게 쪼개서 파일을 100개로 만들면 가장 빠를까요? 브라우저가 파일 하나를 받을 때마다 드는 비용(연결·요청)을 떠올려 보세요. "한 덩어리로 다 묶기"와 "잘게 다 쪼개기" 사이 어디쯤에 적당한 지점이 있을 텐데, 그 지점을 어떻게 판단하면 좋을지 생각해 보세요. 우리가 core로는 묶고 이모지로는 쪼갠 기준이 힌트예요.
✅ 예시 답안정답 보기
오늘 과제는 "고친 코드가 정말 동작하는지 두 눈으로 확인하고, 한 걸음 더 상상해 보는" 거예요. 정답을 외우기보다, Network 탭에서 직접 보고 자기 말로 정리하는 게 핵심이에요. npm run mock과 npm run dev를 켜 둔 채로 진행하세요.
과제 예시답안
🎯 [과제 1 예시답안] WebP 로 받아지는지 두 눈으로 확인하기
핵심 접근
같은 사진을 형식만 바꿔 받아 보고, 용량 차이를 숫자로 적는 게 목표예요. "더 가벼워졌다"를 느낌이 아니라 KB 단위로 확인하는 연습이에요.
확인 절차
feed.html을 열고 개발자 도구(F12) → Network 탭 → 새로고침.- 위쪽 필터에서
Img(이미지)를 눌러 사진만 추려요. - 게시물 사진 한 줄을 눌러
Type이webp인지,Size가 얼마인지 적어요. - 주소창에 그 사진 주소를 붙이되 끝을
.jpg로 바꿔 받아 보고, 그 용량과 비교해요.
같은 600×600 사진, 형식만 다르게 (예시 — 값은 환경마다 다름)
.webp 약 25 KB ←─┐
.jpg 약 38 KB │ 같은 사진인데 약 13 KB(35%) 더 가벼움
──┘
예상 결과와 해석
WebP가 JPG보다 대략 25~35% 가벼워요. 화질 차이는 눈으로 거의 못 느끼는데 용량만 줄었죠. 게시물이 수십 장이면 이 차이가 쌓여 첫 화면 무게를 크게 좌우해요. "사진을 빼지 않고, 담는 그릇만 바꿔 아꼈다"가 한 문장 정리예요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 형식 확인 | Network Type에서 webp로 받아진 걸 직접 확인했는가 |
상 |
| 용량 비교 | .webp와 .jpg의 Size를 숫자로 적어 비교했는가 |
상 |
| 같은 사진 | 형식만 다르고 같은 사진(같은 seed)인지 통제했는가 | 중 |
| 해석 | 화질은 그대로인데 용량만 줄었다는 손실 압축 개념을 짚었는가 | 중 |
흔한 실수
- 캐시 때문에 0 B로 보임 — 이미 받아 둔 사진은
Size가 작게(혹은memory cache로) 떠요. Network 탭에서Disable cache를 켜고 새로고침해야 진짜 용량이 보여요. - 다른 사진끼리 비교 —
seed가 다르면 다른 사진이라 비교가 무의미해요. 같은 주소에서 확장자만 바꿔야 해요. - gzip과 헷갈림 — 이미지는 이미 압축된 파일이라 서버가 따로 더 압축하지 않아요. 포맷 자체의 차이를 보는 거예요.
실무 개선 포인트 (심화)
- 실무에선 이미지를 올리는 순간 서버가 자동으로 WebP·AVIF 여러 형식을 만들어 두고, 요청한 브라우저가 읽을 수 있는 가장 가벼운 걸 골라 내려줘요. 우리는
<picture>로 브라우저가 고르게 했지만, 더 큰 서비스는 서버 단에서 형식을 결정하기도 해요. - 포맷만큼 중요한 게 크기예요. 600×600 자리에 3000×3000 원본을 내려보내면 포맷을 바꿔도 무거워요. 화면에 보일 크기에 맞춰 줄여서 내보내는 게 먼저예요.
🎯 [과제 2 예시답안] 첫 사진이 정말 먼저 출발하는지 확인하기
핵심 접근
우리가 첫 사진에만 준 fetchpriority="high"가 실제로 받아오는 순서와 우선순위에 반영되는지 확인하는 과제예요. 코드가 의도대로 동작하는지 눈으로 검증하는 거예요.
관찰 절차
- Network 탭에서
Img필터를 켜고 새로고침. - 받아온 시간 순서(Waterfall)에서 첫 게시물 사진이 다른 게시물 사진보다 먼저 출발하는지 봐요.
- 표 헤더를 우클릭해
Priority(우선순위) 칸을 켜고, 첫 사진이High인지, 나머지 사진은 무엇인지 비교해요.
Network Waterfall (예시)
첫 게시물 .webp ████ High ← preload·fetchpriority 로 가장 먼저
두번째 .webp ░░░ Low ← loading="lazy", 화면 가까워질 때
세번째 .webp ░░░ Low
실험과 되돌리기
feed.js에서 const isLcp = currentPage === 1 && index === 0;를 잠깐 index === 1로 바꿔 저장하면, 두 번째 게시물이 High로 바뀌고 첫 게시물은 평범해져요. "priority 표시가 어느 사진에 붙느냐"를 우리가 정한다는 걸 확인하는 실험이에요. 확인했으면 반드시 index === 0으로 되돌리세요 — 진짜 주인공은 첫 사진이니까요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 우선순위 확인 | 첫 사진의 Priority가 High로 찍힌 걸 확인했는가 |
상 |
| 순서 비교 | 첫 사진이 나머지보다 먼저 출발하는 걸 Waterfall로 봤는가 | 상 |
| 대조 | 비-LCP 사진은 lazy라 늦게/낮은 우선순위로 받아짐을 짚었는가 |
중 |
| 되돌리기 | 실험 후 index === 0으로 원복했는가 |
중 |
흔한 실수
- lazy 사진이 안 떠서 당황 — 비-LCP 사진은 스크롤로 화면에 가까워져야 받아져요. Network에 안 보이면 고장이 아니라 정상이에요. 스크롤해 보세요.
- preload와 fetchpriority를 같은 걸로 착각 —
preload는 "언제 받기 시작할지",fetchpriority는 "받을 때 얼마나 급한지"예요. 둘이 함께 첫 사진을 앞당겨요. - 실험 코드 안 되돌림 —
index === 1로 둔 채 잊으면 진짜 주인공이 늦어져요. 꼭 원복.
실무 개선 포인트 (심화)
- 실무에선 "첫 화면에서 가장 큰 사진이 무엇인가"가 페이지마다 달라요. 목록 페이지의 첫 카드, 상세 페이지의 대표 이미지처럼요. 그래서 LCP 후보를 자동으로 찾아 우선순위를 주는 건 까다로워, 보통 페이지를 잘 아는 개발자가 직접 지정해요.
fetchpriority는 이미지뿐 아니라 중요한 스크립트·요청에도 쓸 수 있어요. 다만 "정말 급한 하나"에만 줘야 의미가 있다는 원칙은 똑같아요.
🎯 [과제 3 예시답안] AVIF 사다리를 상상해 보기
핵심 접근
<picture>가 후보를 위에서부터 시험한다는 규칙을 이해했는지 확인하는 탐구 과제예요. 코드를 외우는 게 아니라, "왜 그 순서인가"를 설명하는 게 핵심이에요.
답안
AVIF는 가장 가볍지만 가장 적게(약 94%) 지원돼요. <picture>는 맨 위 후보부터 "읽을 수 있어?"를 물으니, 가장 가볍고 가장 까다로운 AVIF를 맨 위에 둬요. 못 읽으면 그다음 WebP, 그래도 못 읽으면 누구나 읽는 JPG로 내려가요.
<!-- 자체 AVIF 자산이 있을 때의 3단 사다리 -->
<picture>
<source type="image/avif" srcset="${post.image}.avif"> <!-- 가장 가벼움, 먼저 시험 -->
<source type="image/webp" srcset="${post.image}.webp"> <!-- 그다음 -->
<img src="${post.image}.jpg" alt="${post.alt}" width="600" height="600" loading="lazy">
</picture>
순서가 핵심이에요. JPG를 맨 위에 두면, AVIF를 읽을 수 있는 최신 브라우저도 무거운 JPG를 골라 버려요(맨 위 후보를 읽을 수 있으면 거기서 멈추니까요). 가벼운 것일수록 위로, 안전한 것일수록 아래로. 우리 실습은 AVIF 칸이 없는 2단이었지만, 사다리 원리는 똑같아요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 위치 | AVIF를 맨 위에 둬야 한다고 답했는가 | 상 |
| 이유 | <picture>가 위에서부터 고르는 순서로 설명했는가 |
상 |
| 폴백 흐름 | 못 읽을 때 WebP→JPG로 내려가는 걸 따라갔는가 | 중 |
| 안전망 | 맨 아래 <img>(JPG)가 마지막 보루임을 짚었는가 |
중 |
흔한 실수
- 순서를 거꾸로 — JPG를 맨 위에 두면 새 포맷이 무용지물이 돼요. "안전한 걸 위에"가 아니라 "가벼운 걸 위에"예요.
<source>에alt·width를 적음 — 이것들은 항상 안쪽<img>의 몫이에요.<source>는 후보 주소와 형식만 알려줘요.<img>를 빼먹음 —<source>만 있고<img>가 없으면 아무것도 안 떠요.<img>는 마지막 보루이자 필수예요.
실무 개선 포인트 (심화)
- 실무에선
<picture>로 형식을 고르는 것과srcset으로 화면 크기에 맞는 해상도를 고르는 것을 함께 써요(B-6에서 본srcset이 그거예요). 형식 사다리와 크기 사다리를 동시에 놓는 거죠. - AVIF는 가벼운 대신 만드는 데(인코딩) 시간이 더 걸려요. 그래서 사진을 올리는 순간 미리 만들어 두지, 사용자가 요청할 때마다 만들지는 않아요.
생각해볼 주제 예시답안
1. 모든 사진을 preload 하면 더 빨라질까?
[문제 상황 요약]
preload로 첫 사진을 먼저 받게 했더니 빨라졌어요. 그럼 화면의 모든 사진을 preload하고 fetchpriority="high"도 전부 붙이면 더 빨라질까요?
[튜터의 가이드 및 해설]
안 빨라져요. 오히려 느려질 수 있어요. 핵심은 인터넷 속도가 한정된 자원이라는 거예요.
preload와 fetchpriority="high"는 "이걸 먼저"라는 신호예요. 그런데 모두에게 "너 먼저"라고 하면, 한정된 통로를 다 같이 비집고 들어가려 해서 아무도 먼저가 못 돼요. 줄을 안 세우고 다 같이 문으로 몰리는 것과 같아요. 결국 진짜 급한 첫 사진까지 다른 사진들과 자원을 나눠 쓰게 돼, 우선순위를 준 효과가 사라져요.
"먼저 받기"가 효과를 보려면 나머지가 양보해야 해요. 첫 사진 하나만 High로 올리고 나머지는 lazy로 미루니까, 통로가 첫 사진에게 열린 거예요. 우선순위란 본질적으로 "무엇을 뒤로 미룰지"를 정하는 일이에요. 다 1등이면 1등이 없는 것처럼요.
🎯 면접관을 홀리는 핵심 멘트
"네트워크 대역폭은 한정된 자원이라, 우선순위는 상대적으로만 작동해요. 모든 이미지를 preload하고 high로 올리면 서로 경쟁해서 결국 아무것도 빨라지지 않아요. 그래서 저는 LCP 후보 딱 하나에만 preload·fetchpriority=high를 주고, 나머지는 lazy로 양보시켜요. '먼저 받기'는 '나머지를 미루기'와 한 쌍입니다."
2. 왜 LCP 이미지에는 lazy 를 걸면 안 될까?
[문제 상황 요약]
loading="lazy"는 화면 밖 사진을 안 받으니 분명 성능에 좋은 기능이에요. 그런데 우리는 첫 사진에서 일부러 그걸 뗐어요. 좋은 기능을 왜 주인공에게는 빼야 했을까요?
[튜터의 가이드 및 해설]
lazy의 뜻을 따라가 보면 답이 나와요. lazy는 "이 사진이 화면에 가까워질 때까지 받지 마"예요. 그런데 첫 사진은 이미 화면 맨 위, 즉 처음부터 화면 안에 있어요.
문제는 브라우저가 lazy 사진을 판단하는 시점이에요. 브라우저는 화면 뼈대(HTML·CSS)를 어느 정도 그린 뒤에야 "이 사진이 지금 화면 안인가?"를 따져요. 그래서 첫 사진에 lazy가 붙어 있으면, 처음부터 받을 수 있었는데도 한 박자 늦게 출발해요. 가장 큰 사진이 LCP를 좌우하는데, 그 주인공을 스스로 늦춘 셈이죠.
이게 "좋은 기능도 자리를 잘못 만나면 독이 된다"는 뜻이에요. lazy는 화면 밖 단역에겐 약이지만, 화면 맨 위 주인공에겐 독이에요. 그래서 우리는 첫 사진에서 lazy를 떼고 오히려 fetchpriority="high"로 "먼저 받아"라고 정반대 신호를 줬어요. 기능 자체가 좋고 나쁜 게 아니라, 어디에 쓰느냐가 좋고 나쁨을 가르는 거예요.
🎯 면접관을 홀리는 핵심 멘트
"lazy는 '화면에 가까워질 때 받는다'는 뜻인데, LCP 이미지는 이미 화면 맨 위에 있어요. 거기에 lazy를 걸면 브라우저가 레이아웃을 그린 뒤에야 로드를 시작해서 가장 중요한 사진이 늦게 떠요. 그래서 화면 밖 이미지엔 lazy를, LCP 이미지엔 fetchpriority=high를 줘요. 같은 기능도 above-the-fold냐 below-the-fold냐에 따라 약이 되기도 독이 되기도 합니다."
3. 코드를 잘게 쪼갤수록 좋을까?
[문제 상황 요약]
코드 스플리팅으로 첫 화면이 가벼워졌어요. 그럼 코드를 잘게, 더 잘게 쪼개서 파일을 100개로 만들면 가장 빠를까요?
[튜터의 가이드 및 해설]
아니에요. 너무 잘게 쪼개면 오히려 느려져요. 여기엔 반대 방향으로 당기는 두 힘이 있어요.
한쪽 힘은 "쪼개면 좋다"예요. 첫 화면에 당장 필요 없는 코드(이모지 고르개 같은)를 떼 두면, 첫 화면이 가벼워져 빨리 떠요. 다른 한쪽 힘은 "묶으면 좋다"예요. 브라우저가 파일 하나를 받을 때마다 연결하고 요청하는 비용이 들어요. 파일이 100개면 이 자잘한 비용이 100번 쌓여, 받는 양은 같아도 더 느려져요.
그래서 정답은 양 극단이 아니라 그 사이예요. 판단 기준은 두 가지예요. 첫째, "당장 안 쓰는 큰 덩어리"는 떼요 — 이모지처럼 특정 행동을 해야 쓰는 큰 코드요. 둘째, "거의 안 바뀌는 공용 코드"는 따로 묶어요 — 우리 core(통신 계층)처럼요. 화면 코드를 고쳐도 core는 그대로라, 사용자가 받아둔 걸 다시 안 받거든요(캐시 재사용). 우리가 이모지는 쪼개고 통신은 묶은 게 정확히 이 두 기준이에요. "쪼갤 이유가 분명한 것만 쪼갠다"가 핵심이에요.
🎯 면접관을 홀리는 핵심 멘트
"코드 스플리팅엔 트레이드오프가 있어요. 쪼개면 첫 화면이 가벼워지지만, 너무 잘게 쪼개면 요청 수가 늘어 오버헤드가 커져요. 그래서 저는 두 기준으로만 쪼개요 — 당장 안 쓰는 무거운 기능은 동적 import로 떼고, 자주 바뀌는 코드와 거의 안 바뀌는 공용 코드(vendor·통신 계층)는 갈라 캐시 효율을 높여요. '쪼갤 수 있다'가 아니라 '쪼갤 이유가 있다'를 기준으로 삼습니다."