최근 쓴 글Critical CSSNextJstailwind v4.0
[블로그 소개] 프론트 엔드 - 성능 최적화
[블로그 소개] 프론트엔드
방문해주셔서 감사합니다. 1번째 글 이후 블로그의 마음에 안드는 부분 수정을 하느라 2번째 글 쓰기까지 많은 시간이 걸린 것 같습니다.
이번 글에서는 저가 어떤 방식들로 블로그를 최적화를 했는지 소개하려고 합니다. 우선 글을 읽기 앞서 함께(?) 축하할 일은 최적화 결과 Lighthouse 4가지 카테고리에서 모두 100점을 달성했다는 것입니다. 하하
이번 글에서 소개할 것은 성능에 영향을 끼치는 원인을 어떻게 찾았는지, 그리고 어떻게 최적화 했는지 소개드릴려고 합니다. 여기에 더해 Lighthouse로 점수로 측정되지는 않지만, [네트워크가 오류날 정도로 느린 상황]이나 [새로고침 상황에서 하얀색 깜박임으로 인한 불편한 상황]을 개선한 과정도 소개드리겠습니다.
우선 아래 사진은 최적화를 완전히 마치고 홈화면을 Lighthouse로 성능, 접근성, 권장사항, 검색엔진 4가지 카테고리의 점수를 측정한 결과들입니다. Lighthouse는 크롬 개발자도구의 Lighthouse탭에서 누구나 성능측정을 쉽게 할 수 있도록 만든 툴입니다.
Lighthouse 점수 측정시 주의할 점은 사용자 네트워크, 컴퓨터, 브라우저 환경에 따라 다른 결과를 보여줄 수 있다는 점입니다. 대표적인 예로 ThirdParty Cookie를 브라우저에서 차단하지 않았거나, 네트워크가 느리면 성능 측정 결과가 달라집니다. 성능 측정할 때 뿐만 아니라 평소에도 브라우저 설정으로 써드파티 Cookie는 차단하시길 추천드립니다.
Home 화면(어드민 로그인)
Home 화면(일반유저 로그인)
Home 화면(비 로그인)
어드민 계정은 프로필 버튼 개수가 달라 리플로우가 발생하여 CLS 0.005의 수치를 갖음.
일반 계정은 의도한 버튼 개수가 출력되어 리플로우 발생없이 CLS 0의 수치를 갖음.
비로그인시 third party 라이브러리로 에러 메시지 출력되어 권장사항 점수가 낮아짐.
성능 저하 원인을 어떻게 파악했고 이를 바탕으로 LCP, FCP, CLS를 어떻게 개선했는지 소개드리겠습니다.
우선 각 용어에 대해 간단 설명과 최적화 전 YoonCarrot 사이트 성능 측정 결과를 보여드리겠습니다.
LCP는 Largest Contentful Paint의 약자로 가장 용량이 큰 컨텐츠 표시 시점.
FCP는 First Contentful Paint의 약자로 최초 화면에 렌더링 시작 할 때까지 시간.
CLS는 Cumulative Layout Shift의 약자로 웹 페이지 출력 후 Layout Shift 발생 정도.
성능 측정 결과
진단된 원인
위 Lighthouse 성능 보고서를 확인하면 FCP, LCP, TBT, CLS같은 지표와 함께, 예상되는 원인들까지 진단해줍니다. 만약 지표의 의미를 안다면 LayoutShift가 일어나고 있거나 렌더링을 Blocking 시간이 존재한다는 것을 금방 알아낼 수 있습니다. 지표의 의미를 몰라도 진단된 원인들과 해결 방법을 확인하고 성능 개선이 가능합니다.
하지만 위 툴 만으로는 자세한 원인 파악하기 힘들 수 있습니다. 그래서 브라우저 성능 분석 툴을 쓰기도 하는데요. 다음 사진은 브라우저 개발자도구에 있는 툴을 이용하여 분석한 사진입니다. 프레임과 시간 단위로 어떠한 일들이 일어났는지 확인할 수 있습니다.
성능 분석 도구로 분석을 할때는 FCP 74ms, DCL106ms, LCP 171ms가 나왔습니다. Lighthouse와 결과가 많이 다르죠?? 네 정상입니다. Lighthouse 결과 사진은 사실 자료를 안 남겨둬 과거의 사진이라 점수가 낮습니다. 그래도 쭉 이어서 가보겠습니다.
성능 분석 도구로 분석된 자료를 간단하게 분석해 보겠습니다.
시각적으로 볼 수 있는 프레임 자료부터 보면 [빈 프레임 2번 => 이미지 렌더링 없는 프레임 2번 => LCP까지 완료된 프레임] 이 과정으로 5번째 프레임만에게 보여줄만한 화면이 나왔습니다. 여기서 확인이 필요해 보이는 부분은 FCP 결과가 빈화면이고, 왜 LCP까지 2번 프레임을 그렸을까 같습니다. (네트워크 상황 등에 따라 빈화면이나 이미지 없는 프레임이 1번으로 바뀔 수도 있습니다.)
그래프를 보면 중간쯤에 분홍색 선이 보입니다. 저 선은 보통 Layout Shift가 여러 번 일어나면 생깁니다. 확인해보니 CookirRunt Font가 적용되고 일부 컴포넌트의 크기가 바뀌는 것을 확인했습니다. 그리고 GoogleLogin 버튼이 생길때도 Layout Shift가 일어나는 것을 확인할 수 있었습니다.
1.1 FCP와 LCP 최적화 (with Critical CSS)
위 두 가지 분석을 통해 저가 내린 결론은 다음과 같습니다.
위 FCP의 빈 프레임은 HTML에 CSS가 씌어지지 않고 렌더링 되었거나, 서버 상태가 안좋아서다.
위와 같은 상황에도 내 블로그는 첫 프레임부터 안정적인 화면을 보여줄 수 있어야 한다.
FCP와 LCP사이의 여러 번의 프레임 생성은 여러 번의 레이아웃 쉬프트의 결과다. (1.2 리플로우 최적화 참고)
위의 결론들 중 1번째와 2번째 결론으로 블로그를 개선하기 위해 Critical CSS를 적용했습니다. CSS 파일을 파일로 다운 받아 적용하면 첫 프레임 화면이 스타일 적용이 안된 채 보여질 것입니다. 그래서 중요(Critical)한 CSS를 Inline으로 HTML에 함께 보내주게 됩니다. 그리고 이를 Critical CSS라고 합니다. NextJs 환경에서 Critical CSS를 적용하기 위해서는 build후 HTML을 파싱하여 CSS를 불러오는 부분을 해당 Inline CSS로 바꾸는 방법을 사용합니다.
위 고슴도치는 Critical CSS 적용하는 라이브러리 중 하나인 Beasties(구 Critters)의 마스코트입니다. 그리고 아래 postbuild에 tailwind 처리 후 만들어진 CSS를 inline CSS로 바꿔주는 코드와 vercel 배포 시 출력 되는 로그입니다.
Postbuild.js
import Critters from "beasties";;
import fs from "fs";
import path from "path";
const critters = new Critters({
path: ".next/static/css",
publicPath: "/_next/static/css/",
inlineFonts: true,
preloadFonts: false,
preload: "media",
});
function fromDir(startPath, filter) {
if (!fs.existsSync(startPath)) {
throw new Error("빌드 폴더 위치 맞나요?")
} else {
const files = fs.readdirSync(startPath);
Promise.all(
files.map(async file => {
const filename = path.join(startPath, file);
const stat = fs.lstatSync(filename);
if (stat?.isDirectory()) {
fromDir(filename, filter);
} else if (filename.endsWith(filter)) {
const inlined = await critters.process(
fs.readFileSync(filename, "utf8"),
);
fs.writeFileSync(filename, inlined);
}
}),
);
}}
const promise = new Promise(() => fromDir(".next/server/app", ".html"));
promise.then(() => console.log("완료"));
현재 이 방법은 Vercel 서버 PageRouter 사용자만 가능합니다. AppRouter 사용자는 Inline CSS 사용을 위해서 Custom Server를 사용해야 합니다.(tailwind 사용 기준. 스크립트는 실행하나 적용은 안됨.)
저 Vercel Server+ App Router 사용 중이라 위 Postbuild.js 스크립트로 Inline CSS를 적용할 수 없었습니다. InlineCSS기능은 현재 experiment 기능으로 존재하고 있으며 정식 기능이 될 때 적용할 예정입니다. 만약 사용하고 싶으시면 아래 next.config.ts 같이 사용하시면 됩니다.
next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
inlineCss: true,
},
}
export default nextConfig
그래서 저는 직접 Layout에서 중요한 스타일들을 추출해서 style태그에 넣어줬습니다. 지금은 이렇게 수동으로 넣어놨지만, 추후 실험기능인 inline css 기능이 정식으로 출시되면 적용할 예정입니다. 아래 Layout에서 html에 바로 style태그를 넣어줄 수 있게 만든 컴포넌트 입니다.
HtmlHeadRegistry.tsx
"use client";
import {useServerInsertedHTML} from "next/navigation";
import {PropsWithChildren} from "react";
export default function HtmlHeadRegistry({children}: PropsWithChildren) {
useServerInsertedHTML(() => {
return (
<>
{`
@base {span, p { font-size: 1rem; line-height: 1.5; }
html { font-size: 16px; @media (width
);
});
return <>{children};
}
1.2 CLS 개선과 프레임 개선
위에서 내린 결론 중에 아직 3번째 결론을 해결하지 못했습니다. 3번째 결론은 리플로우가 어떠한 원인으로 일어나는지 성능 분석 도구로 파악하면 쉽습니다.
우선 리플로우에 대해 설명하자면 브라우저가 변경된 style을 화면에 반영할 때, 요소의 크기, 위치, 구조가 변하는 경우에 리페인트와 함께 일어납니다. 색깔만 바뀌는 경우 리페인트만 일어나는 경우와 다르게 1단계 더 일어나 자원 소모가 더 큽니다.
리플로우는 성능에도 영향을 주지만, 급작스러운 위치 변동으로 사용자 경험에도 악영향을 줍니다. 하지만 이를 해결하기 위한 방법은 간단합니다. 위치 변동이 예상되는 경우 미리 width, height를 적용해 주는 것입니다. image에 width height 정보를 넣어주는 이유에도 해당합니다.
아래 Flex 박스에서 CSS 자동계산으로 Reflow가 일어나는 경우와, 미리 예상하고 width/height를 걸어둔 경우를 비교해 봤습니다. LCP, CLS가 확실이 달라지는 것을 수치로 확인할 수 있습니다.
자동 CSS 계산의존
Static 하게 Height 설정
ㅇ
원인 분석을 하기 위해 CLS를 일으키는 지점 2부분에 ["By 글꼴"], ["자동 CSS 계산"] 이라고 라벨링한 모습을 가장 아래 사진에서 확인할 수 있다.
글꼴에 의한 CLS 증가만 존재한다.
왼쪽은 자동 CSS 계산으로 출력할 때 데이터고 오른쪽은 이를 해결하기 위해 Static하게 Width/height 설정하여 레이아웃 변화가 없게 만들었을 때 데이터 입니다. CLS를 일으키는 지점이 글꼴의 변경 1개로 줄어들었습니다.
이후 글꼴의 변경으로 인한 레이아웃 변화도 해결했습니다. 엄선한 쿠키런 글씨체를 썼는데 놓아줬습니다. CLS없이 쿠키런 글씨체를 쓰려면 글꼴 style을 디테일하게 설정 해야하는데 오래 걸릴 것 같아서 대중적인 구글 폰트로 바꿨습니다.
목록을 보여주는 화면같이 동적일 수 밖에 없는 화면에는 flex를 쓰겠지만, 고정적으로 사용한다면 width, height를 설정해달라고 디자이너에게 요청하는 것이 성능과 사용자 사용성에 더 좋을 수도 있다고 생각합니다.
마지막으로 최종 최적화 성능 결과 보여드리겠습니다.
FCP와 LCP가 거의 항상 동시에 일어납니다. 위 결과에선 114.78ms에 일어납니다. 따라서 첫 프레임에 렌더링하는 화면은 블로그에서 보여줘야하는 화면과 같아졌습니다. 어쩔 수 없는 맨 처음 1개의 빈 프레임 제외하고는 사용자는 항상 완벽한 화면 만을 빠르게 볼 수 있게 되었습니다.
1.3 이미지 포멧 최적화
결론부터 말하면 92%의 퀄리티로 이미지를 webp로 압축 후 원본 파일과 비교하여 더 낮은 용량의 파일을 업로드 하는 방식으로 만들었습니다. 처음에는 이미지 포멧을 항상 webp로 변경했는데, 막상 용량 비교를 해보니 일부 색이 다양한 JPG나 투명한 부분이 많은 PNG는 오히려 webp보다 용량이 작은 경우가 많았기 때문입니다. AVIF가 압축률이 더 좋지만, webp를 사용한 이유는 그만큼 렌더링 하는데 연산량이 많아지고 아직 AVIF는 지원하지 않는 경우가 있어서 사용하지 않았습니다.
1.4 성능 툴로 원인 파악하여 개선한 다른 내용
사실 위에서 소개한 분석 전에도 성능을 개선했는데, 그때 자료를 안 남겨서 개선했던 내용 중 흥미로운 한 가지 요약해서 설명드리겠습니다.
CSP 설정으로 Nonce 사용하여 스크립트를 실행하게 만들었었는데, Script마다 Nonce값 주입이 필요하니 다이나믹 렌더링으로 생성된다는 것을 생각하지 못했습니다. 그래서 엄청 사이트가 느려졌고, 그 원인을 성능 분석을 통해 알게 되어 빠른 CSP를 구현할 다른 방법을 찾고있습니다.
이번에 소개할 것은 느린 네트워크 화면에서도 화면 깨짐을 최소하하며 렌더링 할 수 있도록 개선한 방법입니다.
안정된 환경에서는 CSS까지 받아와서 화면을 그리겠지만, 공공장소에서는 순간적으로 네트워크가 불안정한 경우 잠깐 CSS가 적용 안된 화면이 보이거나 전체 레이아웃이 뒤틀리면서 피로감을 느끼는 경우를 경험한 적이 있으실 겁니다.
이 경험을 최소화하기 위해서는 Critical CSS을 사용하면 됩니다. 사실 1.1 내용과 같은 방법을 적용하면 됩니다.
아래 두 사진은 네트워크가 불안정하여 HTML만 받을 경우를 비교한 사진입니다.
순수 Tailwind 4 + Next15(App Router)
Tailwind 4 + Next15(App Router) + Critical CSS
왼쪽은 CSS파일과 HTML은 따로 받는 방법, 오른쪽은 중요한 CSS는 Inline CSS로 HTML에 함께 받는 방법을 적용한 결과입니다.
왼쪽 화면은 배경이 CSS 적용이 안되어서 맨 위에 플레이어가 나오지만, 오른쪽은 일부 CSS가 바로 적용되어 GNB, SNB, LOGO가 출력되고 있습니다.
왼쪽 화면은 네트워크가 안좋아 잠깐 나타난다면 전체 화면이 리플로우를 일으키며 UX적으로 나쁜 경험을 줄 확률이 높지만 오른쪽은 이미 레이아웃이 어느 정도 잡혀있어 괜찮습니다. 그리고 우측 방법을 사용하면 네트워크가 나쁘더라도 화면이 뜰 경우 최소한 사용할 정도로 만들 수 있습니다. 즉, Critical CSS의 장점으로 inline CSS를 조절하여 최소한 보여줄 화면의 스펙을 결정할 수 있다는 것입니다.
그렇다면 항상 Critical CSS를 사용해야 할까 궁금하여 저도 비교한 자료를 찾아봤습니다.
2.1 Only CSS vs Inline CSS vs Inline Style
HTML + CSS Sheet
HTML+ Inline CSS
HTML + Inline Style
HTML에 바로 Style이 적용되게 하는 방법은 Inline CSS와 Inline Style이 있습니다. 위 가운데와 우측 사진이 그 예시 입니다.
그러면 "어떤 방법이 가장 좋을까?" 라고 물을때, 결론부터 말하면 환경(디바이스, 네트워크, 브라우저 등..)에 따라 다르고, 개발자가 어느 정도 Critical CSS나 Inline Style로 적용할지에 따라 달라집니다.
HTML 사이즈비교
SSR HTML 렌더링 시간
브라우저 렌더링 시간
CSS
이제 로딩시 흰화면 출력을 제거했습니다. 다음으로 새로고침 시 스크롤 위치에 맞는 화면과 배율 출력 해결 방법을 소개드리겠습니다.
3.2 스크롤 위치에 맞는 화면과 맞는 배율 보여주기
위 3.1 방법으로 하얀 화면은 막았지만 새로고침시 여전히 배경이 야경이여야 하는데 아침을 출력하거나, Scroll 위치와 안 맞는 배율의 화면을 출력하는 문제가 발생합니다.
저의 화면 전환 방식은 Scroll 위치에 따라 배율이 계산되고, 특정 위치에서 src가 바뀌는 방식입니다. 이를 해결하기 위해서는 새로고침시 같은 스크롤 위치를 기억하여 사용할 수 있어야 합니다. 그래서 unload 이벤트와 sessionStorage를 활용하여 위치를 기억하고, useLayoutEffect를 사용하여 스크롤 위치가 DOM에 반영된 상태로 렌더링 되도록 의도하였습니다 아래가 그 예시 코드입니다.
ScrollVideo.tsx
'use client'
export const ScrollVideo = () => {
const [videoIndex, setVideoIndex] = useState(0);
useLayoutEffect(() => {
// 첫 렌더링에만 실행되는 Effect
// SessionStorage에 scrollY 저장하는 함수.
function storeScrollY() {
sessionStorage.setItem("last-scroll-y", window.scrollY.toString());
}
// reload했다면, last-scroll-y가 존재함.
const lastScrollY = sessionStorage.getItem("last-scroll-y");
=> 첫 렌더링 시 lastScrollY 존재하면 scale값 계산후 반영.
// scrollY 변화에 따라 video src를 교체
scrollY.on("change", y => {
flushSync(() => (y >= 400 ? setVideoIndex(1) : setVideoIndex(0)));
});
// unload시 scrollY를 Session Storage에 저장.
window.addEventListener('unload',storeScrollY);
return () => {
window.removeEventListener('unload',storeScrollY);
scrollY.destroy();
};
}, []);
return (
);
};
하지만 실제로 위 방식대로 코드를 실행해도 여전히 아주 잠깐 흰 화면이 출력됩니다. 왜냐하면 HTML 파일을 받으면 컴포넌트들은 서버에서 만들어진 HTML placeholder 값대로 출력하고 그 다음 hydration이 일어나기 때문입니다.
그러면 어떻게 반영해야할 까요?? 저는 고민 끝에 React DOM이 생성되는 것 보다 먼저 반영되어야 한다고 결론 내렸습니다. 하지만 저에게 생각나는 방법은 Hydration Rule을 어겨야만 합니다. 그래도 어쩔 수 없다고 생각했습니다. 더 좋은 방법이 있을 것 같지만.. 저로서는 생각이 나지 않았습니다. 그래서 html에 아에 script태그로 스크롤 위치를 저장하는 이벤트를 추가하고, hydration전에 DOM에 바로 src를 바꿔서 껴주는 방법을 선택했습니다. 아래는 그 예시 코드와 실제 scrollPosition이 저장되는 저장소 사진입니다.
RootLayout.tsx
'use client'
export const ScrollVideo = () => {
return (
<>
);
}
이렇게 하면 hydration 전에 video 소스와 포스터도 바꾸고, scale값도 바꿉니다. 첫 화면 그리기도 전에 반영시킵니다. 결론은 엄청 잘 작동했습니다. 하지만 이렇게 해도 충분하지만, 원래 작동하던 시간과, 그때의 화면을 poster로 그려보고 싶어집니다. 그래서 한번 시도해 봤는데요.
따로 코드로는 적지 않고 방법과 결론만 말씀드리겠습니다. unload시에 canvas 객체를 생성해서 마지막 배경화면을 데이터로 sessionStorage에 저장하고, 동영상 재생시간도 저장했습니다. 그리고 새로고침하면 그대로 화면에 보이게 만들었습니다. 잘 작동은 합니다. 다만, 컴퓨터의 한계로 살짝 멈칫 멈칫한 것은 없앨 수 없었습니다. 굳이 이렇게 구현해도 사용자 경험에 달라지는 것이 없으니 리소스 낭비라고 판단했습니다. 그래서 poster와 재생시간까지 저장하여 활용하는 코드는 삭제했습니다.
자세한 설명은 에디터 글에서 했었기 때문에 문제 원인과 해결 방법을 간단히 설명하고 넘어가겠습니다. Viewer로 사용자에게 보여줄 때, TOC를 생성하게 됩니다. 그런데 이미지 로딩이 있으니 처음에 레이아웃 계산이 자꾸 바뀝니다.(이미지 width, height 설정하면 덜합니다) 그래서 TOC 스크롤 위치 계산하는 동안 해당 Element 위치가 바뀌니 부정확한 TOC가 됩니다. 그래서 ResizeObserver와 Debouncing을 통해 100ms 이상의 시간 동안 Size가 바뀌지 않으면 그때 TOC를 출력할 수 있도록 최적화 하며 해결했습니다.
처음에는 Spring과 OAuth 2.0을 사용하여 로그인을 구현했습니다. 이미 레퍼런스가 많아서 구현하기 쉽지만 OAuth 2.0으로 코드를 전달하는 과정에서 리다이렉트가 필수적으로 일어날 수 밖에 없었습니다. 왜냐하면 구글 로그인 후 url path를 통해 리다이렉트로 인증 Code를 백엔드에서 받아서 Authorization 토큰을 발급하여 api로 사용자 정보를 받기 때문입니다.
그래서 리다이렉트가 사용자 경험을 해친다고 생각하여 Redirect없는 로그인을 위해서 OAuth2.0대신 Authorization과 Authentication이 분리되어 프론트단에서 인증만 하고 인증 데이터를 post로 넘겨 받을 수 있는 구글 sdk를 사용했습니다.
프론트엔드 구현은 쉬운데 백엔드는 완전히 커스텀한 로그인이 되어버려 자바 스프링시큐리티의 AbstractAuthenticationProcessingFilter를 써야해서 구현해야할 것들이 너무 많았습니다. 만약 협업으로 로그인을 이렇게 구현한다고 하면 반드시 백엔드에게 양해를 구해야겠다는 생각을 했습니다. 물론, 이 SDK도 OAuth2.0 로그인되게 리다이렉트 가능은 합니다.
아무튼 실제 적용해보니 redirect가 없어서 깜박임 없는 화면이 제공되어 더 편안하게 화면을 볼 수 있었습니다. 다만 깜박임이 없어 로그인 했는지 인지하지 못할 수 있어 로딩 표시 같은 기능을 추가할 예정입니다.
컴포넌트 단위 CSS 파일 적절한 분리
컴포넌트 스트리밍 목록 등 필요한 부분에 적용
디바이스에 따른 이미지 출력 최적화(저장 포함)
SEO를 위한 subscription 길이 요약하여 줄이기
Client단 ReactQuery 데이터 캐싱
Next서버 컴포넌트 스트리밍 데이터 ReactQuery 캐싱
디자인 시스템 정비(Space, Font 등)
스크롤 이벤트 관리 중앙화(Provider 생성)
Font Color와 배경 시인성 개선
2025-03-27