← Back to list

Next.js에서 QueryClient를 전역변수로 사용해도 될까요?

안녕하세요. 펫프렌즈 프리오더팀 프론트엔드 개발자 박재훈입니다.

Julian in 펫프렌즈 기술블로그 · 2026-01-27 02:05 · 225 claps · 16.4 min read
#펫프렌즈 #frontend
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Next.js에서 QueryClient를 전역변수로 사용해도 될까요?

들어가며

안녕하세요. 펫프렌즈 프리오더팀 프론트엔드 개발자 박재훈입니다.

저희 프론트엔드 챕터에서는 오랜 시간 해결하지 못한 과제가 있었습니다. 바로 Next.js 서버의 메모리 누수 현상인데요. 펫프렌즈 서비스는 상품 상세 페이지를 시작으로 점진적으로 Vue.js에서 Next.js로 전환해왔으며, 현재는 대부분의 화면이 Next.js 기반으로 동작하고 있습니다.

트래픽이 증가하면서 메모리 사용량이 늘어나는 것은 자연스러운 현상입니다. 하지만 저희가 마주한 상황은 달랐습니다. 메모리 사용량이 시간이 지날수록 계속 증가했고, GC가 제대로 동작하지 않았으며, 결국 서버가 OOM(Out of Memory) 직전까지 메모리를 사용하여 서버를 재실행하는 일이 반복되었습니다.

제목을 보시면 어떤 문제였는지 짐작하셨을 겁니다. 먼저 밝히자면 저희는 Next.js 서버 환경에서 export된 전역 변수로 QueryClient를 생성하였습니다. 이는 명백히 잘못된 패턴이었고, 메모리 누수의 직접적인 원인이었습니다. 이 글에서는 저희가 겪은 메모리 누수 현상의 발견부터 원인 분석, 그리고 해결 과정을 공유하고자 합니다.

이 글은 특정 라이브러리나 프레임워크 자체의 결함을 다루지 않습니다. Next.js와 TanStack Query는 모두 훌륭한 도구이며, 문제의 원인은 공식 문서에서 권장하는 사용 패턴을 따르지 않은 저희의 구현 방식에 있습니다. 이 글을 통해 같은 실수를 반복하지 않길 바라며, 올바른 사용 패턴의 중요성을 함께 나누고자 합니다.

메모리 누수 현상

브라우저 환경에서는 탭을 닫으면 메모리가 해제되고, 사용자가 페이지를 새로고침하면 모든 것이 초기화됩니다. 설령 메모리 누수가 있더라도 사용자 개개인의 브라우저에서 일시적으로 발생하는 문제에 그치는 경우가 많습니다.그러나 SSR(Server-Side Rendering) 환경은 완전히 다릅니다.

Next.js 서버는 24시간 365일 동작하며, 수많은 사용자의 요청을 처리합니다. 브라우저처럼 ‘닫으면 끝’이 아닙니다. 한 번 발생한 메모리 누수는 서버가 재시작되기 전까지 계속 누적되며, 이는 모든 사용자에게 영향을 미치는 치명적인 문제로 이어집니다.

펫프렌즈 서비스 Next.js 서버 메모리 사용량

펫프렌즈 서비스 Next.js 서버 메모리 사용량

위 그래프를 보시면 문제가 명확하게 파악되실겁니다. 메모리 사용량이 계단식으로 올라가다가 서버를 재시작하는 패턴이 반복되고 있는데요, 정상적인 서버라면 메모리 사용량이 일정 수준에서 유지되거나, GC가 동작하면서 톱니바퀴 모양을 그려야 하는데 저희 서버는 그렇지 않았습니다.

증상은 다음과 같았습니다:

1. 지속적인 메모리 증가

  • 서버 시작 후 시간이 지날수록 메모리 사용량이 선형적으로 증가
  • Garbage Collection이 실행되어도 메모리가 해제되지 않음

2. 서버 성능 저하

  • 메모리 사용률이 높아질수록 응답 시간 증가
  • CPU 사용률 증가 (GC가 메모리를 회수하려고 과도하게 시도)
  • 간헐적인 서버 응답 지연 발생

3. 주기적인 서버 재시작 필요

  • 메모리 사용률이 임계치(약 60%)에 도달하면 수동으로 서버 재시작
  • 재시작하지 않으면 OOM으로 인한 서버 크래시 위험
  • 모니터링 부담 가중

무엇보다 사용자가 많아질수록 문제가 더 빨리, 더 심각하게 발생한다는 점이 가장 큰 문제였습니다. 저희는 이 문제를 더 이상 미룰 수 없었고, 근본 원인을 찾아 해결하기로 결심했습니다.

Node.js 메모리 구조

메모리 누수 현상에는 다양한 원인이 있을 수 있습니다. 디버깅을 하기 위해서는 어디서부터 시작해야 할까요?

먼저 Node.js의 메모리 구조를 간단히 살펴볼 필요가 있습니다. Node.js 프로세스의 메모리는 크게 다음과 같이 구성됩니다:

대부분의 JavaScript 객체와 데이터는 Heap Memory에 저장됩니다.

왜 Heap Memory부터 확인하는가?

세 가지 이유가 있습니다:

  1. 애플리케이션 코드가 직접 관리하는 영역 Stack Memory는 함수가 종료되면 자동으로 해제되고, Native Memory는 Node.js 내부나 네이티브 모듈이 관리합니다. 하지만 Heap은 다릅니다. 저희가 작성한 코드가 객체를 생성하고, 참조를 유지하고, 해제하는 모든 과정이 Heap에서 일어납니다. 메모리 누수의 원인도 대부분 여기에 있습니다.
  2. 가시성과 디버깅 용이성 V8 엔진은 Heap Memory를 모니터링하고 분석할 수 있는 강력한 도구들을 제공합니다. Chrome DevTools, Heap Snapshot, 메모리 프로파일러 등을 통해 “어떤 객체가 얼마나 많은 메모리를 차지하고 있는지”, “왜 해제되지 않는지”를 눈으로 확인할 수 있습니다.
  3. SSR 환경에서의 주요 메모리 사용처 Next.js SSR 환경에서 Heap Memory의 대부분은 다음과 같이 사용됩니다:
  • 요청마다 생성되는 React 컴포넌트 트리
  • getServerSideProps에서 fetch한 데이터
  • TanStack Query 캐시
  • 전역 변수와 전역 함수

결국 문제의 원인을 찾으려면 “Heap에 무엇이 쌓이고 있는지”를 확인해야 했습니다.

Heap Snapshot으로 원인 찾기

메모리 누수 원인을 찾기 위한 방법은 다양합니다. process.memoryUsage()로 메모리 사용량을 로깅할 수도 있고, clinic.js 같은 프로파일링 도구를 사용할 수도 있습니다. 하지만 저희는 "무엇이 메모리를 차지하고 있는지"를 직접 눈으로 확인하고 싶었습니다.

그래서 선택한 것이 Chrome DevTools의 Heap Snapshot입니다.

Next.js 공식 문서에서 메모리 누수 디버깅 방법을 제공하고 있었고, 이를 참고하여 로컬 환경에서 Heap Snapshot을 분석할 수 있었습니다.

로컬 환경에서 힙 스냅샷 수집하기

이제 본격적으로 메모리 내부를 분석할 차례입니다. Chrome DevTools를 사용하면 Next.js 서버의 힙 메모리를 실시간으로 확인할 수 있습니다.

1. 개발 서버를 inspect 모드로 실행 먼저 Next.js 서버를 디버그 모드로 실행합니다:

// package.json
"script" : {
  "build": "next build",
  "start": "cross-env NODE_OPTIONS='--inspect' next dev",
}

2. Chrome DevTools 연결 Chrome 브라우저에서 chrome://inspect/#devices에 접속하면 실행 중인 Node.js 프로세스 목록이 표시됩니다. 여기서 Next.js 서버를 찾아 "inspect"를 클릭하면 익숙한 Chrome DevTools 창이 열립니다.

3. 힙 스냅샷 촬영 Memory 탭으로 이동해서 “힙 스냅샷 촬영” 버튼을 클릭하면 현재 시점의 힙 메모리 상태가 스냅샷으로 저장됩니다.

재현 시나리오

메모리 누수가 어떤 상황에 발생하는지 명확해야 했기에 재현할 수 있는 시나리오가 필요했습니다. 저희는 다음과 같이 테스트했습니다:

시나리오 1: 같은 페이지 반복 새로고침

  • 서버 사이드 렌더링 방식인 상품 상세 페이지에서 새로고침 반복
  • 결과: 최초 약 93MB에서 시작해 106MB까지 증가 후 더 이상 올라가지 않음

시나리오 2: 여러 페이지 탐색

  • 다른 ID의 상품 상세 페이지 이동
  • 검색 페이지 진입
  • 장바구니, 마이페이지 등 여러 페이지 이동
  • 결과: 메모리가 증가 하지만 서버가 일을 했으므로 메모리 사용량이 늘어나는것은 정상

시나리오 3: 대기 후 재측정

  • 약 10분간 아무 작업도 하지 않고 대기
  • 결과: 메모리가 감소하지 않음

스냅샷 16이 10분 대기 후 측정한 스냅샷입니다

스냅샷 16이 10분 대기 후 측정한 스냅샷입니다

발견한 패턴

여기서 중요한 패턴을 발견했습니다:

  • 같은 페이지 반복: 메모리가 일정 수준에서 유지됨 (정상)
  • ⚠️ 다른 페이지 이동: 메모리가 계속 증가 (정상 이지만 시간이 지나면 감소해야 함)
  • GC 후에도 미해제: 10분 대기 후에도 메모리 감소 없음 (메모리 누수 확정)

문제는 명확했습니다. 페이지를 이동할 때마다 무언가가 메모리에 계속 쌓이고 있었고, GC가 이를 회수하지 못하고 있었습니다.

그렇다면 도대체 무엇이 쌓이고 있는 걸까요? 힙 스냅샷을 자세히 들여다보았습니다.

스냅샷 분석

힙 스냅샷을 여러 개 수집한 후, 본격적으로 비교 분석을 시작했습니다. 가장 먼저 스냅샷 15와 16을 비교해보았습니다. 두 스냅샷 사이에는 약 10분의 시간 간격이 있었고, 이 시간 동안 아무런 사용자 액션도 없었습니다.

스냅샷 15

스냅샷 15

스냅샷 16

스냅샷 16

Compiled Code는 소폭 감소했습니다. 사용되지 않는 함수 코드가 GC에 의해 정리된 것으로, 이는 정상적인 동작입니다.

하지만 String과 Object는 오히려 증가했습니다. 10분 동안 아무 작업도 하지 않았는데 String이 391개나 늘어났다는 것은, 무언가가 계속해서 메모리에 쌓이고 있다는 의미입니다.

더 중요한 점은 이 객체들이 GC의 대상이 되지 않았다는 것입니다. 정상적인 상황이라면 참조가 끊긴 객체들은 GC가 회수했어야 합니다. 이 객체들이 여전히 힙에 남아있다는 것은 어딘가에서 여전히 참조를 유지하고 있다는 뜻입니다.

증가한 String의 정체

JavaScript 힙에 String 타입으로 저장되는 것들은 생각보다 다양합니다:

  • API 응답 데이터: 상품명, 설명, 카테고리 등 모든 문자열 필드
  • Query Key: TanStack Query가 캐시를 식별하는 키 (['product', '12345'])
  • URL 및 경로: API 엔드포인트, 페이지 경로
  • 메타데이터: 쿼리 상태('success', 'loading'), 타임스탬프 등

String 객체의 내부를 상세히 분석한 결과, 저희 서비스의 상품 상세 페이지 API 응답 데이터와 Query Key가 GC 대상이 되지 않고 힙에 계속 유지되고 있는 것을 확인했습니다.

더 심각한 사실은 다른 클라이언트의 요청 데이터도 함께 남아있었다는 점입니다. 동료 개발자가 다른 PC에서 제 로컬 서버에 접속해 조회한 데이터까지도 힙 메모리에 그대로 유지되고 있었습니다.

이는 SSR 환경에서 발생할 수 있는 전형적인 문제였습니다. 서버는 여러 클라이언트의 요청을 처리하는데, 각 요청의 데이터가 요청별로 격리되지 않고 하나의 공유된 메모리 공간에 축적되고 있었던 것입니다.

QueryClient 인스턴스 관리 방식이 원인

저희 코드를 살펴보니 다음과 같이 QueryClient를 생성하고 있었습니다:

// queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      retry: false,
      suspense: true,
    },
  },
});

// _app.tsx
import { queryClient } from 'queryClient.ts'

function App({ Component, pageProps }) {
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}

이 코드는 브라우저 환경에서는 문제가 없습니다. 각 사용자는 자신의 브라우저에서 독립적인 JavaScript 런타임을 갖기 때문입니다.

하지만 Next.js SSR 환경에서는 완전히 다릅니다. 하나의 Node.js 서버 인스턴스가 모든 사용자의 요청을 처리하며, 모듈 레벨에서 생성된 queryClient는 서버가 시작될 때 단 한 번만 생성되어 모든 요청에서 공유됩니다

각 사용자의 요청이 동일한 QueryClient 인스턴스를 사용하면서, 모든 사용자의 캐시 데이터가 하나의 인스턴스에 계속 쌓이고 있었습니다.결국 메모리는 계속 증가했고, GC가 이를 회수할 수 없었습니다. 왜냐하면 queryClient가 여전히 모든 데이터를 참조하고 있었기 때문입니다.

해결 방법은 요청마다 새로운 QueryClient 인스턴스를 생성하는 것이었습니다.

사용자별 QueryClient 분리

사실 방법은 간단하고. 한가지 밖에 존재하지 않습니다. TanstackQuery의 공식문서에 소개된 대로 queryClient를 생성하고 queryClient에 접근이 필요한경우 useQueryClient 를 사용하는것입니다.

// pages/_app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

function MyApp({ Component, pageProps }) {
  // ✅ 각 사용자별로 독립적인 QueryClient 인스턴스 생성
  // useState의 초기화 함수는 컴포넌트 마운트 시 한 번만 실행됨
  const [queryClient] = useState(
    () => new QueryClient({
      defaultOptions: {
       queries: {
        refetchOnWindowFocus: false,
        retry: false,
        suspense: true,
       },
     },
   })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}

export default MyApp;

// 사용부
import { useQueryClient } from '@tanstack/react-query';

export const useInvalidateSampleQuery = () => {
  // ✅ Context에서 현재 사용자의 QueryClient를 가져옴
  const queryClient = useQueryClient();
  return () => queryClient.invalidateQueries([{ key: '/sample' }]);
};

왜 이 방식이 작동하는가?

이 패턴이 메모리 누수를 해결하는 이유는 React의 컴포넌트 라이프사이클과 Next.js의 렌더링 방식을 활용하기 때문입니다.

React의 컴포넌트 라이프사이클 활용

useState의 초기화 함수(lazy initialization)는 컴포넌트가 최초 마운트될 때 단 한 번만 실행됩니다. Next.js의 _app.tsx는 SSR 환경에서 각 요청마다 새롭게 마운트되기 때문에, 매 요청마다 새로운 QueryClient 인스턴스가 생성됩니다.

각 요청은 독립적인 컴포넌트 인스턴스를 가지며, 따라서 독립적인 QueryClient를 갖게 됩니다. 이로써 요청 간 데이터 격리가 보장됩니다.

메모리 관리 SSR 렌더링이 완료되면 해당 요청의 컴포넌트 트리가 언마운트되고, QueryClient 인스턴스에 대한 참조가 사라집니다. 더 이상 참조되지 않는 객체는 JavaScript의 Garbage Collector 대상이 되어 메모리에서 자동으로 제거됩니다.

기존 패턴에서는 5번 단계에서 참조가 해제되지 않아 6번이 실행되지 않았지만, 이제는 정상적으로 메모리가 회수될것이라 기대해도 좋을것 같습니다.

문제 해결 후 개선 결과

01월 15일 QueryClient 개선 후

01월 15일 QueryClient 개선 후

변화의 핵심

가장 큰 변화는 메모리 사용 패턴이었습니다. 기존에는 시간이 지날수록 계단식으로 증가하던 메모리가, 이제는 일정 범위 내에서 유지되는 패턴을 확인할 수 있었습니다.

메모리가 일정 수준까지 증가하면 GC가 실행되어 사용하지 않는 메모리를 회수하고, 다시 증가하는 사이클이 반복됩니다. 더 이상 수동으로 서버를 재시작할 필요가 없어졌습니다.

정량적 개선 결과

배포 후 1주간 프로덕션 환경을 모니터링한 결과입니다:

메모리 사용량

  • 평균 메모리 사용률: 9–10% (안정적으로 유지)
  • 최대 메모리 사용률: 16% (피크 시간대 기준)
  • 메모리 누수로 인한 서버 재시작: 주 1회 → 0회

기존에는 메모리 사용률이 계속 증가하여 명확한 평균치를 측정할 수 없었지만, 개선 후에는 일정 범위 내에서 안정적으로 유지되고 있습니다.

운영 안정성 확보

  • 메모리 모니터링 및 수동 재시작 작업 제거
  • 안정적인 서버 응답 속도 유지
  • 예측 가능한 인프라 리소스 계획 수립 가능

왜 전역 변수로 사용했을까?

솔직히 말하면, 명확한 이유를 알 수는 없습니다. 다만 저희의 마이그레이션 과정을 돌아보면 어느 정도 추측은 가능합니다. 앞서 언급했듯이 펫프렌즈 서비스는 Vue.js에서 Next.js로 점진적인 마이그레이션을 진행했습니다. 가장 먼저 마이그레이션한 상품 상세 페이지의 경우 초기에는 CSR(Client-Side Rendering) 방식으로 렌더링했습니다. CSR 환경에서는 전역 변수가 문제가 되지 않습니다. 문제는 이후 SSR을 도입하면서 시작되었을 것입니다. 기존 코드베이스를 그대로 유지한 채 점진적으로 SSR을 적용하다 보니, QueryClient가 하나의 인스턴스로 생성되어 있다는 사실을 미처 인지하지 못했던 것 같습니다. 코드 리뷰나 테스트 과정에서도 발견되지 않았고, 트래픽이 적을 때는 문제가 크게 드러나지 않았습니다.

시간이 지나고 서비스가 성장하면서, 그리고 더 많은 페이지가 Next.js로 전환되면서 메모리 누수 현상이 본격적으로 나타났습니다.

마치며

이번 경험을 통해 몇 가지 중요한 교훈을 얻었습니다:

1. 공식 문서의 중요성 TanStack Query와 Next.js의 공식 문서에서는 SSR 환경에서 QueryClient를 어떻게 사용해야 하는지 명확히 안내하고 있습니다.

2. 메모리 누수는 초기에 발견하기 어렵다 로컬 개발 환경에서는 트래픽이 적고 자주 재시작하기 때문에 메모리 누수를 발견하기 어렵습니다. 프로덕션 모니터링과 정기적인 성능 점검이 필수적일것 같습니다.

3. 점진적 마이그레이션의 함정 기존 코드를 최대한 재사용하며 빠르게 마이그레이션하는 것도 중요하지만, 새로운 환경(SSR)에 맞는 패턴으로 재설계하는 것 역시 중요합니다.

덕분에 SSR 환경의 메모리 관리, Garbage Collection의 동작 원리, TanStack Query의 내부 구조에 대한 이해도를 크게 높일 수 있었습니다. 무엇보다 “왜 공식 문서에서 이렇게 권장하는가”에 대한 근본적인 이유를 직접 경험하며 배울 수 있었습니다.

이 글이 Next.js와 TanStack Query를 함께 사용하시는 분들께 조금이나마 도움이 되었으면 좋겠습니다. 혹시 비슷한 메모리 증가 문제를 겪고 계신다면, 저희처럼 QueryClient 생성 방식을 한번 살펴보시는 것도 도움이 될 수 있을 것 같습니다.

긴 글 읽어주셔서 감사합니다.


메타데이터
post_id
74c6d05bc53b
slug
next-js에서-queryclient를-전역변수로-사용해도-될까요-74c6d05bc53b
url
https://techblog.pet-friends.co.kr/next-js%EC%97%90%EC%84%9C-queryclient%EB%A5%BC-%EC%A0%84%EC%97%AD%EB%B3%80%EC%88%98%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EB%90%A0%EA%B9%8C%EC%9A%94-74c6d05bc53b
canonical_url
https://techblog.pet-friends.co.kr/next-js%EC%97%90%EC%84%9C-queryclient%EB%A5%BC-%EC%A0%84%EC%97%AD%EB%B3%80%EC%88%98%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EB%90%A0%EA%B9%8C%EC%9A%94-74c6d05bc53b
author_url
https://medium.com/@jh.park2
status
ok
fetched_at
2026-06-11 12:34:08