1편에서 이어서 진행하겠습니다. 제가 어쩌다가 SEO를 맡게 되었는지 궁금하시다면 1편을 확인해주세요!
https://insengnewbie.tistory.com/653
센디 SEO 3배 개선기(1) - 어쩌다 SEO를 맡게 되었나
어느 날, CTO님이 말했습니다"요즘 페이지가 좀 느린 것 같지 않아요?" 슬랙으로 날아온 이 한 문장이 시작이었습니다. 그땐 이게 9개월 뒤 트래픽을 3배로 만드는 여정의 입구일 줄은 몰랐습니다.
insengnewbie.tistory.com
느낌이 아니라 숫자로
성능 개선에서 가장 먼저 배운 교훈은 "체감으로 고치지 말 것"이었습니다. "느린 것 같다"는 말은 출발점일 뿐, 무엇을 고칠지는 알려주지 않습니다. 그래서 저는 두 개의 지표를 기준점으로 삼았습니다. FCP(First Contentful Paint)와 LCP(Largest Contentful Paint)입니다.
쉽게 말하면 FCP는 "화면에 뭐라도 처음 그려지는 시점"이고, LCP는 "그 페이지에서 가장 큰 핵심 콘텐츠(보통 히어로 이미지나 큰 텍스트 블록)가 다 그려지는 시점"입니다. 사용자 입장에서 "이 페이지 빠르네/느리네"를 결정짓는 건 사실상 이 두 순간이죠. 그리고 앞 편에서 말했듯, 이 둘은 Google이 보는 Core Web Vitals의 일부이기도 합니다. 성능과 SEO가 만나는 지점이 정확히 여기입니다.
분석 방법은 단순했습니다. DevTools Performance 탭에서 페이지 로드를 녹화한 뒤, 타임라인을 펼쳐 "무엇이 LCP와 FCP를 뒤로 미루고 있는가"를 하나씩 짚어내는 것이었습니다. 거창한 도구는 필요 없었습니다. 범인을 특정하고, 하나 고치고, 다시 측정하고. 이 지루한 루프의 반복이 전부였습니다.
개선 1: 보내는 양을 줄이다 (번들 다이어트)
측정을 해보니 가장 먼저 눈에 띈 범인은 자바스크립트 번들이었습니다. 브라우저가 받아야 할 JS가 많을수록, 그걸 내려받고 파싱하고 실행하는 데 시간이 걸리고, 그만큼 화면이 그려지는 시점(FCP·LCP)도 뒤로 밀립니다. 다이어트의 원칙은 단순합니다. 브라우저에게 보내는 양 자체를 줄이는 것.
여기서 Next.js의 렌더링 전략이 큰 무기가 됐습니다. 핵심은 "이 페이지를 굳이 클라이언트에서 그릴 필요가 있나?"를 페이지마다 따져보는 것이었죠.
- 내용이 자주 바뀌지 않는 페이지(서비스 소개, 정책 페이지 등)는 SSG/ISR로 빌드 시점 또는 주기적으로 미리 렌더링 처리하였습니다. 사용자가 들어오면 완성된 HTML이 바로 날아가니, 클라이언트가 처음부터 그릴 때보다 훨씬 빨라집니다.
- 매 요청마다 최신 데이터가 필요한 페이지는 SSR로 서버에서 그려 내려줬습니다.
어느 쪽이든 공통된 효과는 "클라이언트에서 처리할 일을 서버사이드로 옮긴" 것이었습니다. 서버가 미리 HTML을 만들어 주니, 브라우저는 무거운 JS 묶음을 다 실행할 때까지 기다리지 않고도 콘텐츠를 먼저 보여줄 수 있게 됐죠. 결과적으로 FCP가 눈에 띄게 당겨졌습니다.
여기까지가 "어디서 그릴까"의 이야기였다면, 그다음은 "그래도 보내야 하는 JS를 어떻게 더 줄일까"의 문제였습니다. 렌더링 전략을 바꿔도 클라이언트로 가는 번들은 여전히 남아 있었거든요. 그래서 번들 자체를 깎는 작업을 병행했습니다.
- 코드 스플리팅 & dynamic import: 모든 코드를 한 덩어리로 묶어 내려보내면, 당장 첫 화면에 필요 없는 코드까지 사용자가 기다려야 합니다. 그래서 초기 렌더링에 꼭 필요하지 않은 무거운 컴포넌트(모달, 첫 뷰포트 아래쪽에서야 등장하는 위젯 등)는
next/dynamic으로 잘라내, 실제로 필요해질 때 따로 불러오도록 했습니다. 첫 화면이 짊어지는 JS의 무게가 그만큼 가벼워졌죠.
// 첫 화면에 필요 없는 무거운 컴포넌트는 분리해서 나중에 로드
const HeavyModal = dynamic(() => import('./HeavyModal'), {
ssr: false, // 클라이언트에서만, 필요할 때 로드
});
- 불필요한 라이브러리 걷어내기: 번들을 들여다보니, 고작 함수 하나 쓰자고 통째로 들고 온 무거운 라이브러리들이 보였습니다. 이런 것들은 가벼운 대안으로 교체하거나, 직접 몇 줄로 구현해 의존성 자체를 들어냈습니다. "이 패키지가 정말 그 용량값을 하는가"를 하나씩 따져본 셈이죠.
- 불필요한 코드 걷어내기: knip 라이브러리를 활용하여 불필요하게 존재하는 레거시 함수, 컴포넌트, 파일, 라이브러리를 들어냈습니다.
JS만 무거운 게 아니었습니다. CSS와 폰트에도 같은 다이어트를 적용했습니다.
CSS에서는 Tailwind 설정 하나가 발목을 잡고 있었습니다. Tailwind는 빌드할 때 실제로 안 쓰는 클래스를 걷어내(purge) 파일을 얇게 유지하는데, 우리에겐 그 purge를 거스르는 safelist가 있었거든요. safelist는 코드에서 문자열로 조합해 만드는 클래스명처럼, Tailwind가 "안 쓴다"고 착각해 지워버릴 클래스를 따로 지켜주는 목록입니다. 그런데 이게 너무 헐거웠습니다.
safelist: [
{ pattern: /bg-(.*)/, variants: ['md', 'lg', 'xl'] },
{ pattern: /text-(.*)/, variants: ['md', 'lg', 'xl'] },
{ pattern: /accent-(.*)/, variants: ['md', 'lg', 'xl'] },
// ...
]
bg-(.*)는 "bg로 시작하는 클래스는 전부 남겨라"라는 뜻입니다. 여기에 md·lg·xl 반응형 변형까지 곱해지니, 쓰지도 않는 유틸리티 클래스가 최종 CSS에 무더기로 박제됐죠. purge를 켜둔 의미가 사라진 겁니다. 그래서 .* 같은 막연한 패턴을 실제로 동적 생성하는 값만 콕 집어 좁혔더니, CSS 파일이 453KB에서 26KB로 줄었습니다. 안 쓰는 스타일이 그만큼 실려 있었다는 뜻이죠.
폰트도 사정이 비슷했습니다. 우리가 쓰던 건 굵기 100부터 700까지를 한 파일에 다 담은 Variable 폰트였습니다. 굵기를 자유롭게 조절할 수 있어 편하지만, 정작 화면에서 쓰는 굵기는 몇 개뿐인데 안 쓰는 굵기까지 통째로 내려받고 있었죠. 그래서 Variable 파일을 버리고, 실제로 쓰는 굵기만 담은 Static 파일 몇 개로 쪼갰습니다. 거기에 서브셋(subset)도 적용했습니다. 웹에서 자주 쓰는 한글·영문·기본 기호·통화 기호 정도만 남기고 나머지 글자를 들어낸 변형이죠.
서브셋은 공짜가 아닙니다. 체감 로딩이 빨라지는 대신, 남겨두지 않은 글자(중국어·일본어나 특수 기호 등)가 들어오면 깨질 수 있습니다. 그래서 그 폰트가 어디에 쓰이는지 사용 범위가 확실한 곳에만 적용했고, 다국어가 필요한 곳에는 전체 woff2를 그대로 뒀습니다. 결과는 분명했습니다. 단일 폰트 파일 2.1MB가, 굵기별로 쪼갠 4개 파일 총합 1.1MB로 줄었습니다. 거의 절반입니다.
어떤 번들이 얼마나 무거운지는 감으로 알 수 없습니다. 여기서도 원칙은 똑같았습니다. 번들 애널라이저로 무엇이 용량을 차지하는지 눈으로 확인하고, 무거운 것부터 손댔습니다.
개선 2: 이미지를 똑똑하게 불러오기
JS 다음으로 무거운 짐은 이미지였습니다. 특히 랜딩 페이지처럼 이미지가 많은 화면에서는, 이미지를 어떤 순서로 불러오느냐가 LCP를 크게 좌우합니다.
여기서 핵심 개념이 하나 있습니다. 첫 화면에 보이는 이미지와 보이지 않는 이미지를 다르게 취급해야 한다는 것입니다.
페이지를 열었을 때 사용자 눈에 가장 먼저, 가장 크게 들어오는 이미지. 보통 이게 LCP(Largest Contentful Paint)의 Lagest Content입니다. 이건 무조건 빨리 보여줘야 합니다. 반대로 가장 큰 이미지가 아닌 작은 이미지들은 굳이 빨리 보여줘야 할 필요가 없습니다.
그래서 한 가지 원칙을 세웠습니다.
첫 뷰포트에 나타나는 가장 큰 이미지 하나만 우선 로딩하고, 나머지는 전부 지연시킨다.
Next.js 에서는 화면 밖 이미지들에는 지연 로딩을 걸어, 사용자가 스크롤로 가까이 갔을 때 비로소 불러오도록 처리하고 있습니다. 그러나 첫 화면에 보이는 이미지들은 우선 로딩 대상입니다. 그래서 첫 화면에 보이는 가장 큰 이미지가 아닌 이미지들을 의도적으로 지연시켰습니다. 덕분에 브라우저의 한정된 네트워크 대역폭이 "지금 당장 중요한 이미지"에 먼저 쓰이게 됐고, LCP를 당길 수 있었습니다.
개선 3: picture 태그 삽질담
이 편에서 가장 기억에 남는 삽질을 소개할 차례입니다. 분명 최적화를 하려고 한 일이 오히려 페이지를 느리게 만든, 그런 이야기입니다.
발단은 랜딩 페이지의 이미지였습니다. 모바일과 PC에서 서로 다른 이미지 파일을 쓰고 있었거든요. 모바일엔 세로로 긴 이미지, PC엔 가로로 넓은 이미지, 이런 식이었죠. 그래서 저는 "지금 접속한 게 모바일인지 PC인지 판단해서, 거기 맞는 이미지를 내려주면 되겠다"고 생각했습니다. 그리고 그 판단을 Next.js 서버 컴포넌트에서 처리했습니다. 요청 헤더를 보고 모바일 여부 플래그를 계산해, 알맞은 이미지를 골라 렌더링하는 방식이었죠.
논리적으로는 깔끔해 보였습니다. 그런데 측정을 해보니 오히려 로드 속도가 떨어져 있었습니다. 한참을 들여다보고 나서야 원인을 찾았습니다.
문제는 "요청 헤더를 본다"는 그 행위 자체였습니다. 서버 컴포넌트에서 매 요청의 헤더를 읽어 분기하는 순간, 이 페이지는 더 이상 "모두에게 똑같은" 페이지가 아니게 됩니다. Next.js 입장에서는 빌드 시점에 정적으로 미리 만들어 둘 수가 없는, 요청마다 매번 새로 그려야 하는 동적 페이지가 되어버린 겁니다. 미리 만들어 캐싱해 둘 수 있었던 페이지가, 요청이 올 때마다 서버에서 새로 렌더링되는 페이지로 강등된 셈이죠. 이미지 하나 똑똑하게 고르려다, 페이지 전체를 동적으로 만들어버린 것이었습니다.
깨달음은 단순했습니다. "어떤 이미지를 보여줄지"는 서버가 헤더를 보고 결정할 게 아니라, 브라우저가 알아서 고르게 두면 된다. 마침 이걸 위해 만들어진 표준이 있었습니다. HTML의 <picture> 태그입니다.
<picture>
<!-- 뷰포트가 768px 이하면 모바일용 이미지 -->
<source media="(max-width: 768px)" srcset="/hero-mobile.webp" />
<!-- 그 외에는 PC용 이미지 -->
<img src="/hero-pc.webp" alt="센디 메인 비주얼" />
</picture>
<picture> 태그는 여러 이미지 후보를 던져주면, 브라우저가 현재 뷰포트에 맞는 소스를 알아서 골라 받아옵니다. 서버는 어떤 기기인지 알 필요도, 분기할 필요도 없습니다. 그저 모두에게 똑같은 HTML을 내려주면 되죠. 페이지는 다시 정적으로 캐싱될 수 있는 상태로 돌아왔고, 모바일·PC 이미지 분기도 그대로 유지됐습니다. 두 마리 토끼를 다 잡은 겁니다.
이 삽질에서 얻은 교훈은 지금도 종종 떠올립니다. 클라이언트가 알아서 처리할 수 있는 일을 굳이 서버로 끌고 오면, 종종 더 비싼 대가를 치른다.
개선 4: 인프라 레이어로 내려가기
여기까지가 애플리케이션 코드 안에서 할 수 있는 일이었습니다. 번들을 줄이고, 이미지를 똑똑하게 불러오고. 그런데 측정을 거듭하다 보니, 코드만으로는 더 이상 깎이지 않는 영역이 보이기 시작했습니다. 그래서 한 레이어 더 아래, 인프라로 내려갔습니다.
기존 배포 구조는 단순했습니다. next build로 빌드한 결과물을 그대로 Docker 컨테이너에 올려 Next.js 서버가 모든 요청을 처리하는 방식이었죠. 동작에는 문제가 없었습니다. 다만 한 가지 아쉬운 점이 있었습니다. 이미지, 폰트, JS·CSS 같은 정적 파일까지 전부 Next.js 서버가 직접 응답하고 있었다는 것입니다. 자주 바뀌지도 않는 파일들을 매번 애플리케이션 서버가 처리하는 건, 잘하는 일을 엉뚱한 사람에게 시키는 셈이었습니다.
그래서 다음과 같이 구조를 바꿨습니다.
- Next.js standalone 모드:
output: 'standalone'옵션을 켜면, 실행에 꼭 필요한 파일만 추려 가벼운 산출물을 만들어 줍니다. 컨테이너 이미지가 한결 가벼워졌죠. - 앞단에 nginx를 세우기: 정적 파일 요청은 nginx가 직접 받아 캐싱해서 빠르게 응답하고, 실제 렌더링이 필요한 요청만 뒤의 Next.js 서버로 넘기도록 했습니다.
# 정적 파일은 nginx가 직접 응답하고 길게 캐싱
location /_next/static/ {
proxy_pass http://nextjs_upstream;
proxy_cache_valid 200 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
핵심은 역할을 나눈 것이었습니다. 정적 파일은 그 일을 잘하는 nginx에게, 동적 렌더링은 Next.js에게. 각자 잘하는 일에 집중시키니, 정적 자원이 캐시에서 빠르게 날아가면서 전체 응답이 가벼워졌습니다.
마치며
돌아보면 성능 개선에서 한 일은 레이어만 달랐지 본질은 하나였습니다. 측정해서 범인을 찾고, 하나 고치고, 다시 측정한다. 번들이든 이미지든 인프라든, 이 지루한 루프를 레이어를 바꿔가며 반복했을 뿐입니다. 화려한 한 방은 없었습니다. 그리고 picture 태그를 작업하며 배웠듯, 좋은 의도가 늘 좋은 결과로 이어지는 것도 아니었죠. 그래서 더더욱, 감이 아니라 숫자로 확인하는 습관이 중요했습니다.
그렇게 당겨진 FCP·LCP는 사용자 경험을 좋게 했을 뿐 아니라, Google이 보는 Core Web Vitals 신호로서 SEO의 토대가 되어주었습니다. 하지만 페이지가 빨라졌다고 검색엔진이 알아서 우리 사이트를 잘 이해해 주는 건 아니었습니다. 빠른 페이지를 "검색엔진이 제대로 읽고, 예쁘게 노출하고, 클릭하고 싶게" 만드는 건 또 다른 작업이었거든요. 그 이야기는 다음 편, 테크니컬 SEO에서 이어가겠습니다.
'커리어 > Sendy' 카테고리의 다른 글
| 센디 SEO 3배 개선기(1) - 어쩌다 SEO를 맡게 되었나 (1) | 2026.06.23 |
|---|---|
| 프론트엔드 개발자가 노션 하나로 개발 효율 3배 높인 후기 (11) | 2025.12.22 |
| SEO 폭증을 위한 센디 용달후기 페이지 개발기 (3) | 2025.11.15 |
| 2025년 센디 스탠다드 그로스 해커톤 후기 (13) | 2025.08.31 |
| [센디] 3개월차 근황 (2) | 2024.09.28 |