들어가며
최고의 UX는 최고의 성능이다.
웹 메모 프로젝트의 UX를 개선하며 느낀 핵심 포인트다. 서비스의 속도가 빠르면 사용자에게 '이 사이트 좋다'라는 인식이 들게 만들고, 이는 곧 서비스를 계속 이용하고 싶어지게 만든다. 반대로 속도가 느리다면 사용자는 답답함을 느끼고 이탈할 가능성이 높아진다.
실제로 성능 개선은 비즈니스 지표에도 직접적인 영향을 미친다. Pinterest는 로딩 속도를 40% 개선한 후, 트래픽이 15% 증가하고 회원가입이 15% 증가했다고 한다. 또한 Amazon은 로딩속도가 1초 빨라지면, 매출이 68억달러가 증가한다고 발표했다.
이번 글에서는 다양한 성능 최적화 방법 중 하나인 정적 렌더링과 관련한 개념을 설명하고, 실제 도입을 통해 성능을 개선한 경험을 공유하고자 한다.
개념 이해
Next.js의 정적 렌더링
Next.js는 기본적으로 정적 렌더링 기능(SSG, Static Site Generation)을 제공한다. 정적 렌더링은 빌드 타임에 각 페이지를 HTML 파일로 미리 생성해두고, 사용자가 페이지에 접속하면 사전에 생성된 페이지를 즉시 제공하는 방식이다.
반면 동적 렌더링(SSR, Server Side Rendering)은 사용자가 서버에 페이지를 요청할 때마다 HTML을 새로 생성해서 제공한다. 쿠키나 URL의 search params처럼 사용자마다 다르거나 요청 시점에만 알 수 있는 값을 사용해야 할 때 유용하다.
정적 렌더링에서 동적 렌더링으로 전환되는 경우
Next.js는 렌더링 과정에서 동적 함수나 캐싱되지 않는 데이터 요청이 발견되면 해당 페이지를 자동으로 동적 렌더링으로 전환한다. 이를 이해하고 있어야 의도치 않게 정적 렌더링의 이점을 잃는 상황을 방지할 수 있다.
동적 함수에는 다음과 같은 것들이 있다 : cookies(), headers(), searchParams 등
캐싱되지 않는 데이터 요청은 다음과 같은 경우를 포함한다.
cache: 'no-store'옵션이 설정된 fetch 요청revalidate: 0옵션이 설정된 fetch 요청- POST 메서드를 사용하는 fetch 요청이 라우트 핸들러 내부에 있는 경우
- headers 혹은 cookies에 접근하는 fetch 요청
- 라우트 Segment 설정에서
const dynamic = 'force-dynamic'을 설정한 경우 - 쿠키나 권한 인증이 필요한 fetch 요청
이런 요소들이 포함되면 정적 렌더링의 빠른 제공이라는 이점을 잃게 되므로, 페이지 설계 시 이러한 요소들의 사용을 신중히 고려해야 한다.
성능 부하테스트란?
성능 부하 테스트는 시스템이 특정 부하 상황에서 어떻게 동작하는지를 검증하는 테스트다. 많은 사용자가 동시에 접속하거나 대량의 데이터를 처리할 때 시스템의 성능, 안정성, 확장성을 측정하기 위해 수행한다. 이를 통해 병목 현상을 파악하고 최적화 포인트를 찾을 수 있다.
대표적인 성능 부하 테스트 도구로는 다음과 같은 것들이 있다:
- nGrinder: 네이버에서 만든 도구로 최대 1억 명의 사용자까지 테스트가 가능하다
- k6: Grafana에서 만든 도구로 직관적인 JavaScript API를 제공하고 다양한 프로토콜과 시나리오를 지원한다
핵심 성능 지표
성능 테스트에서 주로 사용하는 지표는 다음과 같다:
TPS (Transactions Per Second) : 초당 처리할 수 있는 트랜잭션의 수를 의미한다. TPS가 높을수록 시스템의 처리 능력이 좋다는 뜻이다.
MTT (Mean Test Time) : 응답까지 걸리는 평균 시간을 의미한다. MTT가 낮을수록 응답 시간이 짧아 사용자 경험이 좋아진다.
VUser (Virtual User) : 성능 테스트에서 실제 사용자의 행동을 시뮬레이션하는 가상 사용자 수를 의미한다. VUser를 통해 실제 서비스 환경과 유사한 부하 상황을 재현할 수 있다.
실전: 정적 렌더링 적용하기
현재 상태 파악하기
가장 먼저 해야 할 일은 현재 서비스에 어떤 페이지들이 있고, 어떤 페이지가 정적으로 렌더링되고 있는지 파악하는 것이다.
빌드 결과를 보면 auth 관련 페이지를 제외한 모든 페이지가 SSG로 표시되어 있다. 그런데 여기서 주의해야 할 점이 있다. SSG로 표시되었다고 해서 페이지 전체가 빌드 타임에 정적으로 생성된 것은 아니다.
내 경우에는 [lng]라는 동적 라우팅을 위해 generateStaticParams 함수를 사용했기 때문에 SSG로 표시된 것이었다.
// src/app/[lng]/layout.tsx
export async function generateStaticParams() {
return SUPPORTED_LANGUAGES.map(lng => ({ lng }));
}
generateStaticParams함수를 제거한 후 빌드했을 때 결과는 다음과 같다.
introduce를 제외한 모든 페이지가 동적으로 만들어짐을 확인할 수 있다.
진짜 정적 렌더링인지 확인하기
페이지 전체가 정적 라우팅이 된 것은 아니고 각 ko와 en에 대한 페이지만 정적으로 만들어진 것이다. 어떻게 페이지 전체가 정적 라우팅이 안되었는지 알 수 있냐면 아래와 같이 .next/server/app 폴더에 html파일이 존재하는지 여부로 파악할 수 있다.
위 폴더를 살펴보면 not-found페이지만 정적으로 만들어진 것을 확인할 수 있다. en/memos와 ko/memos 등의 폴더 내부에는 html파일이 존재하지 않는다.
또 다른 확인 방법은 해당 페이지에서 현재 시간을 html파일에서 출력해볼 수 있다. 만약 페이지가 빌드 타임 때 정적으로 생성이 되었다면 동일한 시간이 적혀있는 html파일을 반환할 것이다.
하지만 위와 같이 새로고침을 할 때마다 다른 시간대를 보여주는 것을 확인할 수 있다. 이는 다시말해 사용자가 페이지에 접속할 때마다 서버에서 페이지를 만들어 클라이언트에 전달하고 있다는 것을 의미한다.
정적 렌더링을 막는 요소 찾기
다시 본론으로 돌아가보자. 아래와 같은 페이지들이 있다.
내 프로젝트의 페이지 구조를 살펴보니 introduce, update 같은 페이지는 정적 렌더링에 적합해 보였다. 이 페이지들은 빌드 타임에 한 번 생성하고 이후에는 변하지 않는 콘텐츠이기 때문이다.
반면 memos 페이지는 서버에서 데이터를 불러와 사용자마다 다른 값을 보여줘야 하므로 동적 렌더링이 적절하다.
그렇다면 introduce와 update 페이지를 정적으로 만드는 것을 막는 요소는 무엇일까?
1. middleware의 cookie접근 로직
// src/middleware.ts
export async function middleware(request: NextRequest) {
// ..
return await updateSession(request); // 쿠키를 확인하여 로그인이 만료되었을 경우 토큰을 재발급한다.
}
쿠키에 접근하는 로직은 updateSession함수에 있다.
그런데 여기서 middleware의 cookie 접근 로직이 정말로 정적 렌더링에 영향을 줄까? Next.js의 Middleware는 사용자가 서버에 페이지를 요청하면 그 때 실행되는, 말그대로 미들웨어이다. 빌드 타임 때 정적으로 만드는 것이 아니라 런타임 때 실행된다는 것을 의미한다.
그렇기에 Middleware는 페이지를 정적으로 만드는데에 영향을 미치지 않는다.
2. 헤더 컴포넌트의 cookie 접근 로직
헤더 컴포넌트는 아래와 같다.
코드로 살펴보면 다음과 같다.
// ../Header/index.tsx
'use server';
export default async function Header() {
return (
<header>
<HeaderLeft />
<HeaderRight />
</header>
);
}
// ..
async function HeaderLeft() { .. }
// ..
async function HeaderRight() {
const supabaseClient = getSupabaseClient();
const user = await new AuthService(supabaseClient).getUser();
// ..
}
Header은 현재 서버 컴포넌트이기에 서버에서만 렌더링이 일어난다. 그런데 이 서버 컴포넌트 내부에서 AuthService(supabaseClient).getUser()와 같이 사용자 쿠키에 접근하여 사용자 정보를 얻어오는 역할을 수행하고 있다. 이는 동적 API에 해당한다.
그렇기에 서버에서 쿠키에 접근하는 로직이 있는 Header가 있는 모든 페이지는 빌드 타임 때 정적으로 만들어질 수가 없다. 즉 헤더 컴포넌트의 동적으로 쿠키에 접근하는 로직을 제거해야한다.
이를 위해서는 아래와 같은 동작을 취할 수 있다.
- 동적 데이터에 접근하는 컴포넌트와 그렇지 않은 컴포넌트를 파일 단위로 나눈다.
- 동적 데이터에 접근하는 컴포넌트를 불러올 때는 동적 import를 수행하여 서버 단에서 렌더링이 되지 않도록 한다.
현재 HeaderLeft는 동적 데이터에 접근하지 않고 모든 사용자에게 동일하게 보여주며, HeaderRight는 동적 데이터인 쿠키에 접근해 사용자마다 다른 화면을 보여준다. 클라이언트 컴포넌트와 서버 컴포넌트는 파일 단위로 구분할 수 있기 때문에 이 둘을 각각의 파일로 분리해야한다.
// ../Header/HeaderLeft.tsx
default function HeaderLeft() { .. }
// ../Header/HeaderRight.tsx
function HeaderRight() {
const supabaseClient = getSupabaseClient();
const user = await new AuthService(supabaseClient).getUser();
// ..
}
이제 동적 데이터에 접근하는 HeaderRight컴포넌트를 사용하는 헤더 컴포넌트에서 동적 import로 불러온다.
// ../Header/index.tsx
'use client';
import HeaderLeft from './HeaderLeft';
const HeaderRight = dynamic(() => import('./HeaderRight'), {
ssr: false,
loading: () => <Loading />,
});
export default async function Header() {
return (
<header>
<HeaderLeft />
<HeaderRight />
</header>
);
}
여기서 중요한 부분은 ssr: false 옵션이다.
현재 상황은 다음과 같다.
- Header, HeaderLeft : RCC & SSR/CSR
- HeaderRight : RCC & CSR
약자 설명
RCC(React Client Component), RSC(React Server Component)
SSR(Server Side Rendering), CSR(Client Side Rendering)
여기서 주의해야할 점은 RCC가 곧 CSR이 아니라는 것이다. RSC는 SSR이 맞다.(의미적으로 동일한 것이 아니라, 실제 동작 면에서 동일하게 서버에서만 동작한다) RSC는 반드시 서버에서만 렌더링되는 리액트 컴포넌트를 말한다. 하지만 RCC는 서버에서 렌더링이 되고 클라이언트에서도 렌더링이 된다. 이것이 우리가 Next.js를 사용하다보면 ‘서버와 클라이언트 간에 html이 불일치합니다’를 에러 메시지를 보는 이유이기도 하다.
HeaderRight는 동적 데이터에 접근하기에, 빌드 타임 때 정적 렌더링을 하기위해서는 반드시 클라이언트에서만 렌더링이 되어야한다. 서버에서 렌더링이 될 경우 해당 컴포넌트는 빌드타임 때 만들어질 수 없기 때문이다. 그래서 HeaderRight를 dynamic으로 동적 import를 하고 ssr옵션을 false로 설정했다.
결과 확인
이제, 정적 렌더링이 된 후의 결과는 어떨까?
- 새로고침 시
새로고침을 해도 시간은 계속 고정된 값을 보여주는 것을 확인할 수 있다.
- 정적으로 생성된 HTML
html 파일 또한 정적으로 만들어짐을 확인할 수 있다. 여기서 generateStaticParams함수에서 반환한 값인 ko와 en 각각 폴더가 만들어지고 내부에 introduce, update 페이지의 html파일이 만들어짐을 확인할 수 있다. 실제로 이 html은 정적으로 만들어진 css파일과 함께 서버 없이 단독으로 실행이 가능하며 자세한 내용은 Untitled 글을 참고 바란다.
성능 부하 테스트 : Nginder
기존 동적 페이지에서 정적 페이지로 바꾸어 보았다. 빌드 타임 때 정적으로 페이지를 만들기에 특히 많은 사용자가 몰렸을 때 큰 효과를 볼 수 있을 것이라 생각했다. 서버에서는 모든 사용자마다 동적으로 페이지를 만드는 것이 아니라 정적으로 만들어놓은 페이지를 사용자에게 제공만 하면 되기 때문이다.
성능 부하 테스트 도구로는 Nginder를 사용했다. 최근에 네이버의 egis-grid를 잘 사용한 덕분에 좋은 기억이 있어 동일한 네이버의 기술을 선택했다.
Nginder 설치 및 실행
설치 및 실행에 대한 가이드는 깃헙에서도 확인할 수 있습니다.
- 설치
가장 먼저 https://github.com/naver/ngrinder/releases에서 최신 버전을 설치한다. .war파일을 설치하면 된다.
- 실행
터미널에서 아래 스크립트를 실행한다.
java -Djava.io.tmpdir=~/lib -jar ${war파일의 경로}
예를 들어 3.5.9버전의 .war 파일을 home경로에 설치했다면 아래와 같이 입력하면 된다.
java -Djava.io.tmpdir=~/lib -jar ~/ngrinder-controller-3.5.9-p1.war
- 서버 접속 및 로그인
"Tomcat started on port(s): 8080" 메시지가 보이면 성공이다. localhost:8080으로 접속하면 로그인 화면이 나온다. ID와 Password 모두 admin이다.
- Agent 생성
3번에서 접속한 사이트(localhost:8080)에서 우측 상단의 버튼을 눌러 Agent를 다운로드한다. 그럼 .tar파일이 다운로드될 것이다. 이 .tar은 파일들의 묶음이라고 보면 된다.
이제 run_agent.sh 혹은 run_agent_bg.sh 파일을 실행한다. .sh는 리눅스나 맥OS에서 실행할 수 있는 셸 스크립트 파일의 확장자이다. 이 스크립트를 실행하면 Agent가 실행되며 이제 부하 테스트를 위한 준비가 완료된다.
Agent Managment에 접속하면 아래와 같이 Agent가 하나 존재함을 확인할 수 있다.
- 부하테스트 실행
이제 모든 준비를 마쳤다. 부하테스트를 실행해보자.
메인 화면에서 부하테스트를 원하는 웹사이트의 url을 입력하면 된다.
Agent와 VUser를 입력한다.
Agent는 위에서 1개를 실행했으니 최대 1개만 가능하고, VUser는 테스트를 원하는 접속자 수를 입력한다.
트러블 슈팅
- Error while saving file to SVN
nGrinder가 실행되지 않는다면 다음 명령어로 권한을 변경한다.
sudo chmod -R 755 ~/.ngrinder
이 명령어는 ~/.ngrinder 디렉토리와 하위 디렉토리의 권한을 755로 변경한다. 755 권한은 소유자에게는 읽기/쓰기/실행 권한을, 그룹과 다른 사용자에게는 읽기/실행 권한을 부여한다.
- run_agent.sh를 실행하면 agent 파일이 즉시 종료된다.
이전에 실행한 Agent가 남아있을 때 발생하는 문제다. Agent 파일이 있는 폴더의 모든 파일을 제거한 후 다시 실행하면 해결된다.
Before
After
테스트 결과 및 분석
| VUser | 구분 | TPS | 에러율 | MTT |
|---|---|---|---|---|
| 32 | Before | 46 | 0% | 609ms |
| 32 | After | 179 | 0% | 179ms |
| 160 | Before | 76 | 3.9% | 1429ms |
| 160 | After | 310 | 0.5% | 360ms |
| 3000 | Before | 352 | 12% | 4950ms |
| 3000 | After | 32 | 14.6% | 4816ms |
성능 테스트 결과를 분석해보자. 적은 수의 동시 접속자(VUser 32, 160)에서는 정적 렌더링 이후 TPS(초당 처리량)가 약 4배 향상되었고, MTT(평균 응답 시간)는 약 4배 감소했다. 특히 VUser 160에서 에러율이 3.9%에서 0.5%로 8배 가까이 줄어든 것을 확인할 수 있다.
하지만 VUser 3000같이 대량의 동시 접속자에서는 상반된 결과를 보였다. TPS는 오히려 11배 가까이 감소하고, 에러율은 2%p 가까이 증가하는 모습을 보였다. 이는 정적 렌더링이 적용된 페이지라 하더라도 서버의 물리적 한계로 인해 발생하는 현상으로 보인다.
이 결과를 보면 정적 렌더링이 일반적인 트래픽에서는 매우 효과적이지만, 부하가 극단적으로 커지면 서버의 물리적 한계에 부딪혀 효과가 제한된다는 걸 알 수 있다. 따라서 대규모 트래픽에 대비하기 위해서는 정적 렌더링과 함께 로드 밸런싱이나 CDN 활용과 같은 추가적인 인프라 최적화 전략을 고려해야 해야한다는 것을 느꼈다.
-
로드 밸런싱 : 애플리케이션을 지원하는 리소스 풀 전체에 네트워크 트래픽을 균등하게 배포하는 방법
-
CDN : 컨텐츠 전송 네트워크(Content Delivery Network)의 약자로, 전 세계에 분산된 서버 네트워크를 통해 사용자와 가까운 위치에서 콘텐츠를 전달함으로써 로딩 속도를 향상시키는 서비스
Lighthouse 측정
이번에는 Lighthouse를 이용하여 페이지의 성능을 측정해보자.
Before & After
가장 눈에 띄는 변화는 성능 점수와 LCP이다. 성능 점수는 CLS, SI, FCP, LCP, TBT를 종합적으로 고려해 100점 만점으로 점수를 매긴 것으로 LCP 점수가 증가하며 성능 점수까지 함께 증가한 것으로 보인다. 그렇다면 LCP점수는 왜 증가한 걸까?
LCP는 화면에서 가장 큰 영역을 차지하는 요소가 렌더링되는데 걸리는 시간을 의미하며 위 introuduce페이지에서 기존에는 메인의 가장 큰 이미지가 LCP를 차지했다. 아래 이미지는 블로그 글 작성 기준에서 설명을 위해 캡처한 것이라 2.490ms라 적혀있으며 아래와 같은 경우 LCP가 2.9s로 측정이 되었을 것이다.
세부 정보를 살펴보면 Load Delay와 Load Time이 총합 1,310ms + 620ms = 약 1.9s를 차지한다. Load Delay는 페이지가 시작되는 시점부터 로드되기 시작하는 시점까지의 시간, Load Time은 요소가 실제로 로드되는데 걸리는 시간을 의미한다.
정적 렌더링으로 개선한 이후의 LCP요소이다.
LCP요소는 메인 이미지로 동일하며 Load Delay, Load Time, Render Delay 모두 속도가 크게 감소한 것을 확인할 수 있다. 그리고 총합 LCP는 680ms가 되었다.
LCP가 크게 감소한 이유는 정적 렌더링의 핵심 특징과 관련이 있다. 빌드 타임에 HTML이 미리 생성되어 있어 서버에서 HTML을 생성하는 과정이 필요 없다. 그렇기에 사용자가 페이지를 요청하면 이미 준비된 HTML을 즉시 전달할 수 있어 초기 로딩 시간이 크게 단축된다. 따라서 서버에서 데이터를 가져와 렌더링하는 과정이 없기 때문에 가장 큰 콘텐츠가 화면에 그려지는 시점이 훨씬 빨라진 것이다. 이렇게 정적 렌더링으로 서버 쪽 지연을 없애고 콘텐츠를 바로 전달하니 LCP를 4배 가까이 줄일 수 있었다.
맺으며
정적 렌더링 하나로 체감 속도가 눈에 띄게 빨라졌다. 정적 렌더링을 적용하기 위해 동적 데이터에 접근하는 컴포넌트를 분리하고, 클라이언트 사이드에서만 렌더링되도록 구조를 개선했다.
일반적인 트래픽 상황(VUser 32~160)에서는 TPS가 4배 향상되고 MTT가 4분의 1로 감소하는 등 기대 이상의 성과를 얻을 수 있었다. 특히 에러율이 8배 가까이 감소한 것은 서비스의 안정성 측면에서도 큰 개선이었다.
가장 인상 깊었던 점은 정적 렌더링이 단순히 서버 부하를 줄이는 것을 넘어 사용자 경험에도 직접적인 영향을 미친다는 것이다. Lighthouse 측정 결과 LCP가 2.9초에서 680ms로 개선되면서, 사용자가 체감하는 로딩 속도가 눈에 띄게 빨라졌다. 수치로만 보던 개선이 '이 사이트 빠르네' 하는 느낌으로 실제 이어졌다.
다만 극단적인 트래픽 상황(VUser 3000)에서는 정적 렌더링만으로는 한계가 있다는 점도 알게 되었다. 이는 앞으로 CDN 활용이나 로드 밸런싱 같은 추가적인 인프라 최적화가 필요함을 시사한다. 정적 렌더링도 서버가 못 버티면 소용없더라.
결국 서비스 특성과 사용자 패턴을 보고 맞는 전략을 고르는 게 중요했다. 모든 페이지를 정적으로 만들 필요는 없다. introduce나 update처럼 콘텐츠가 자주 변하지 않는 페이지에 정적 렌더링을 적용하고, memos처럼 실시간으로 데이터가 변하는 페이지는 동적 렌더링을 유지하는 것이 올바른 접근이다.
글 서두에서 언급했던 '최고의 UX는 최고의 성능'이라는 말을 다시 한 번 되새기며, 앞으로도 사용자 경험 개선을 위한 다양한 성능 최적화 방법들을 계속해서 탐구하고 적용해 나갈 예정이다.