본문으로 건너뛰기
ff1451 logo ff1451

Next.js SSR이 swap을 채운 이유 — V8 조기 승격

Sentry rage click에서 출발해 EC2 swap 절반 사용을 원인으로 특정하고, ISR 전환·V8 힙 제한·vm.swappiness 조정을 복합 적용해 응답 지연을 완화한 과정을 정리한다.

6 min read

Sentry 대시보드에서 rage click과 dead click 경보가 올라왔다. 직접 확인해보니 링크를 누른 뒤 페이지 이동까지 몇 초씩 걸리는 상황이었다. 프론트엔드 코드는 한동안 변경이 없었으니 서버 쪽을 먼저 봐야 했다.

1. 서버 상태 확인

EC2에 접속해 watch -n 10 free -m으로 메모리를 확인했다.

              total  used  free  shared  buff/cache  available
Mem:            914   722   187       5         167        192
Swap:          2047  1062   985

전체 RAM 914MB 중 722MB를 사용 중이었고, 여기서 더 눈에 띈 건 Swap이었다. 2047MB 중 1062MB, 절반 이상이 차 있었다. Node.js 프로세스가 디스크로 밀려난 메모리를 읽으면서 응답이 느려진 것이다. rage click의 직접 원인은 swap I/O 지연이었다.

2. 왜 swap까지 찼나

단순 누수라면 힙이 무한정 커지다 OOM으로 프로세스가 죽었을 텐데, 관찰된 건 “서서히 차오르는 RSS와 swap 진입”이었다. V8의 세대별 GC 구조를 생각해보니 이유가 보였다.

RSS 팽창은 누수가 아닐 수 있다

Platformatic의 Node.js 성능 분석 글은 이 상황을 이렇게 설명한다.

“High RSS in a Node.js application does not automatically signify a memory leak in the conventional sense. V8 employs sophisticated memory management strategies focused on performance optimization.”

V8은 GC 이후에도 메모리 세그먼트를 OS에 바로 반납하지 않는다. 다음 할당 요청을 예상해서 확보한 채로 두는 것이다. RSS가 높더라도 힙이 무한정 증가하지 않는다면 누수보다 이쪽을 먼저 의심해야 한다.

조기 승격(premature promotion)

V8은 신규 객체를 Young Generation(New Space)에 먼저 올리고, Scavenge 사이클에서 살아남은 객체만 Old Space로 승격한다. 같은 글은 조기 승격이 발생하는 조건을 이렇게 설명한다.

“If allocations outpace collections significantly, objects might still be present during one or two Scavenge cycles simply because the collector didn’t run frequently enough.”

SSR 환경에서는 요청마다 React 트리를 렌더링하면서 대량의 단기 객체가 쏟아진다. 트래픽이 일정 수준을 넘으면 Young Generation이 너무 빨리 차서, 본래 요청이 끝나면 사라졌어야 할 객체들이 Scavenge 사이클을 거쳐 Old Space까지 올라간다. 이 조기 승격이 반복되면 Old Space의 Major GC 빈도가 늘고, GC 후에도 allocator가 메모리를 즉시 돌려주지 않아 RSS가 줄지 않는다.

같은 글에서 소개하는 --max-semi-space-size 플래그는 Young Generation의 semi-space 크기를 명시적으로 늘려 이 문제를 완화한다. 벤치마크 결과로는 128MB 설정 시 P99 지연시간 5% 감소, 처리량 7% 향상이 관측됐다. 이번 서버(914MB RAM)에는 별도로 적용하지 않았지만, 메모리 여유가 더 있는 환경이라면 같이 검토할 수 있다.

vm.swappiness와 prefetch 폭발

여기에 vm.swappiness 기본값 60이 겹쳤다. 이 값이 높을수록 Linux는 RAM 여유가 남아 있어도 page out을 적극적으로 시도한다. available 192MB가 있는 상태에서도 cold page로 분류된 Node.js 힙이 디스크로 밀려난 건 그 결과였다.

홈 화면 진입 시 Next.js <Link>의 기본 prefetch 동작도 문제를 키우고 있었다. 기사 상세, 헤더/푸터 보조 링크까지 포함해 22개 페이지의 /_next/data 요청이 한꺼번에 나가면서, 홈 로드 직후 SSR 렌더링 요청이 폭발했다.

원인을 정리하면 세 층이 쌓인 구조였다. SSR 요청이 과도하게 많았고, V8 힙이 메모리 제한 없이 팽창했으며, swap 정책이 이 RSS를 빠르게 디스크로 밀어냈다.

3. 적용한 변경들

SSR 요청 감소: ISR 전환

변경이 드문 공개 페이지들이 매 요청마다 서버에서 렌더링되고 있었다. getServerSidePropsgetStaticProps + revalidate로 전환해 articles/[id], store/[id], room/[id], bus/shuttle 페이지를 ISR로 바꿨다. 페이지 성격에 따라 재생성 주기를 다르게 잡았다.

export const ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS = 60 * 10;   // 10분
export const BUS_SHUTTLE_ISR_REVALIDATE_SECONDS   = 60 * 30;   // 30분
export const ROOM_ISR_REVALIDATE_SECONDS          = 60 * 60;   // 1시간
export const STORE_DETAIL_ISR_REVALIDATE_SECONDS  = 60 * 60;   // 1시간

ISR로 전환하면 첫 요청 이후 revalidate 초 안에는 캐시된 HTML을 내려주기 때문에, 동일 페이지에 대한 반복 서버 렌더링이 사라진다.

빌드·재생성 중 API 서버가 429나 5xx를 반환할 때 페이지 생성이 실패하면서 ISR 갱신이 멈추는 문제도 있었다. 이를 막기 위해 재시도 래퍼를 뒀다.

const STATIC_FETCH_RETRY_ATTEMPTS = 2;
const STATIC_FETCH_RETRY_DELAY_MS = 300;

export async function withStaticFetchRetry<T>(task: () => Promise<T>): Promise<T> {
  for (let attempt = 0; attempt <= STATIC_FETCH_RETRY_ATTEMPTS; attempt += 1) {
    try {
      return await task();
    } catch (error) {
      if (!isRetryableStaticFetchError(error)) throw error;
      if (attempt === STATIC_FETCH_RETRY_ATTEMPTS) break;
      await new Promise(resolve => setTimeout(resolve, STATIC_FETCH_RETRY_DELAY_MS * (attempt + 1)));
    }
  }
  throw lastError;
}

2회, 지수 지연(300ms·600ms)으로 재시도하고, 404처럼 재시도해도 의미 없는 에러는 즉시 throw한다.

SSR 페이지 캐시 헤더: withCacheControl

ISR로 전환하지 못한 SSR 페이지는 withCacheControl HOC로 감쌌다. 기존처럼 getServerSideProps 안에서 res.setHeader를 직접 호출하는 대신, 래퍼가 주입하는 cacheControl 객체를 통해 opt-in하는 방식이다.

export const getServerSideProps = withCacheControl(async (context, cacheControl) => {
  const isLoggedIn = getTokenFromCookie(context.req);

  if (!isLoggedIn) {
    cacheControl.enablePublicCache();  // 비로그인 응답만 공용 캐시 허용
  }

  const data = await fetchData();
  return { props: { data } };
});

래퍼 내부에는 두 가지 가드가 있다. 첫째, enablePublicCache()를 호출하지 않은 응답은 자동으로 private, no-store가 붙는다. 둘째, Set-Cookie 헤더가 있는 응답은 enablePublicCache()를 호출했더라도 private, no-store로 고정된다. 세션 갱신 응답이 공용 캐시에 실리는 상황을 방지하기 위해서다.

const cacheControl = shouldCachePublicResponse && !hasSetCookieHeader
  ? publicCacheControl           // 'public, s-maxage=60, stale-while-revalidate=300'
  : PRIVATE_SSR_CACHE_CONTROL;  // 'private, no-store'
context.res.setHeader('Cache-Control', cacheControl);

공개/인증 사용자 캐시가 섞이지 않도록 상점 리뷰 query key도 publicauth 기준으로 분리했다. ISR 프리패치에서 로그인 사용자 데이터를 내려보내면 익명 사용자가 잘못된 캐시를 볼 수 있기 때문이다.

prefetch 축소

방문 가능성이 낮은 기사 상세, 헤더·푸터 보조 링크의 상시 prefetch를 끊었다.

// 변경 전
<Link href={`/articles/${id}`}>...</Link>

// 변경 후
<Link href={`/articles/${id}`} prefetch={false}>...</Link>

prefetch={false}로 설정해도 링크 위에 hover하면 prefetch가 시작되니 이동 직전에 여전히 빠르게 로드된다. 홈 진입 시 불필요한 서버 요청 폭발만 사라지는 것이다.

compress: false와 nginx gzip

Next.js 내장 압축을 비활성화하고 nginx에서 gzip을 처리하도록 이전했다. Next.js의 응답 압축은 Node.js 프로세스 위에서 돌기 때문에 CPU를 소비하는데, nginx의 gzip은 별도 레이어에서 처리되니 Node.js 입장에서는 압축 오버헤드가 사라진다.

// next.config.mjs
const nextConfig = {
  compress: false,
};

V8 힙 제한과 systemd 메모리 정책

메모리 제한을 명시하지 않으면 V8은 물리 메모리 비율로 힙 상한을 결정하는데, RAM 914MB 환경에서는 힙이 RAM 전체를 채울 때까지 확장하고 swap까지 흘렀다. NODE_OPTIONS로 Old Space 크기를 제한하고 systemd 유닛에 메모리 정책을 추가했다.

# /etc/systemd/system/koin-web.service
Environment=NODE_OPTIONS=--max-old-space-size=400
MemoryHigh=480M
MemoryMax=550M

--max-old-space-size=400은 V8 힙이 400MB를 넘기 전에 GC 압력을 가하게 한다. MemoryHigh는 소프트 제한으로 초과 시 커널이 GC를 유도하고, MemoryMax는 하드 제한이다. 수치는 과부하 상태 실측값을 기반으로 잡았기 때문에, 이후 모니터링에서 재시작이 잦게 관측되면 조정할 계획이다.

vm.swappiness 조정

RAM 여유가 있는데도 swap이 차오르는 근본 원인이었다. 기본값 60에서 10으로 낮췄다.

# /etc/sysctl.conf
vm.swappiness=10

0은 swap을 완전히 비활성화하는 게 아니라 “극도로 피하도록” 힌트를 주는 값이고, 10은 물리 메모리가 실제로 부족해질 때까지 swap을 최대한 미루는 값이다. 이 변경만으로도 RAM 여유가 있는 상태에서의 디스크 I/O가 줄었다.

HTTP/2 적용

확인해보니 프로덕션 서버에 HTTP/2가 설정되어 있지 않았다. nginx 설정에 http2를 추가했다. 헤더 압축과 멀티플렉싱으로 동일 연결에서 여러 요청을 처리할 수 있으니, 위에서 줄인 prefetch 요청들이 나갈 때도 연결 오버헤드가 감소한다.

4. 결과와 한계

적용 후 Sentry rage/dead click 빈도가 줄었고 페이지 이동 체감 속도가 회복됐다. 적용 전후 free -m 수치를 비교하면 변화가 보인다.

# 적용 전
              total  used  free  shared  buff/cache  available
Mem:            914   722   187       5         167        192
Swap:          2047  1062   985

# 적용 후
              total  used  free  shared  buff/cache   available
Mem:            911   768    66       7         249        143
Swap:          2047   433  1614

swap 사용량이 1062MB에서 433MB로 줄었다. 가장 직접적인 효과는 vm.swappiness 조정과 V8 힙 제한이었고, ISR 전환과 prefetch 축소가 서버 렌더링 부하를 낮춰 장기적인 메모리 압박을 줄였다.

한계는 두 가지다. systemd 메모리 값은 실측 기반 임의 설정이라, 트래픽 패턴이 바뀌면 재조정이 필요하다. 그리고 이번 작업은 응급처치에 가깝다. SSR이 필요하지 않은 나머지 페이지를 ISR이나 SSG로 계속 전환해야 하고, 힙 메트릭을 지속 모니터링해 다음 임계값을 판단해야 한다. swap이 다시 채워지기 시작한다면, 그때는 인스턴스 스펙 자체를 검토해야 할 것이다.

참고 자료