Loading...
본문 바로가기
👥
총 방문자
📖
0개 이상
총 포스팅
🧑
오늘 방문자 수
📅
0일째
블로그 운영

여러분의 방문을 환영해요! 🎉

다양한 개발 지식을 쉽고 재미있게 알려드리는 블로그가 될게요. 함께 성장해요! 😊

코딩 정보/NextJs

[NextJs] 개발 환경 효율적인 로깅 시스템 도입하기

by 꽁이꽁설꽁돌 2025. 9. 26.
728x90
반응형
     
목차

     

     

    도입부

    먼저 들어가기 전에 nextjs의 랜더링이 진행되는 원리에 대해 알 필요가 있다.

     

    랜더링 작업은 개별 경로 세그먼트와 suspense boundary에 따라 청크로 분할된다.

     

    각 청크는 아래의 두 단계로 랜더링 된다.

     

    1. React는 Server Components를 React Server Component Payload라는 특별한 데이터 형식으로 랜더링한다.

    2. Nextjs는 RSC payload와 클라이언트 컴포넌트 js 지침을 사용하여 서버에서 HTML을 렌더링한다.

     

    그 후 클라이언트에서는 다음과 같이 동작한다.

     

    1. HTML을 사용하여 경로의 빠른 비인터랙티브(동작이 되지 않는) 미리보기를 즉시 표시한다.

    2. React Server Component Payload를 사용하여 Client와 Server Component 트리를 조정하고 DOM을 업데이트 한다.

    3. JS 지침을 사용하여 Client Components를 하이드레이션하고 애플리케이션을 인터렉티브하게 만든다.

     

     

    RSC Payload란?

    랜더링된 REact Server Component 트리의 간결한 이진 표현입니다. 

    이는 클라이언트에서 React가 브라우저의 DOM을 업데이트하는 데 사용된다.

     

    1. Server Component의 랜더링된 결과

    2. Client Component가 랜더링될 위치와 해당 js 파일에 대한 참조

    3. server Component에서 Client Component로 전달될 모든 props

     

     

    그래서 무엇이 문제인데?

    클라이언트 컴포넌트 같은 경우 데이터 요청을 브라우저에서 진행하기 때문에 네트워크 요청과 응답의 경우 json을 통해 쉽게 추적이 가능하다. 하지만 서버 컴포넌트의 경우 rsc payload를 통해 응답이 오기 때문에 네트워크 탭에서 응답값에 대한 추적이 어렵다는 문제점이 있었다. 

     

     

     

     

    그러면 콘솔 로그 쓰면 되는거 아니야?

    나는 콘솔 로그를 쓰면서 다음과 같은 불편함을 발견했다.

     

    1. 통일되지 않은 콘솔 로그 형태 및 이전 콘솔과의 구별을 위해 문구 지정의 불편함

    그 전의 콘솔과 비교를 위해 수식어 지정

     

    2. 콘솔 로그를 올바른 위치에 찍기 위한 번거로움

    매번 원하는 위치를 파일에서 찾아야 함

     

    3. 콘솔로그의 비일관된 형식

    콘솔마다 형식이 다르기 때문에 일관적으로 보기 힘들 뿐더러 데이터 외에 다른 정보(http 메소드, url등등)는 네트워크 탭을 통해 열어 확인해야 한다는 불편한 점이 있었다.

     

     

    해결 방안

    그래서 저는 nextjs에서 제공하는 instrument라는 것을 활용하였다.

     

    정의

    Instrumentation은 모니터링 및 로깅 도구를 애플리케이션에 통합하기 위해 코드를 사용하는 프로세스이다. 이를 통해 애플리케이션의 성능 및 동작을 추적하고, 프로덕션에서 발생하는 문제를 디버깅할 수 있다.

     

    Instrumentation을 설정하려면 프로젝트 루트 디렉토리(또는 src 폴더를 사용하는 경우 해당 폴더)에 instrumentation.ts|js 파일을 생성하세요.

    그런 다음, 파일에서 register 함수를 내보냅니다. 이 함수는 새로운 Next.js 서버 인스턴스가 시작될 때 한 번 호출된다.

     

    더 자세히 보기

    https://nextjs-ko.org/docs/app/building-your-application/optimizing/instrumentation

     

    Instrumentation – Nextjs 한글 문서

    Learn how to use instrumentation to run code at server startup in your Next.js app

    nextjs-ko.org

     

     

    그 후 로깅 파일을 만들어 작동시켜주었다.

     

    각 환경에 따라 분기 처리해 준 모습

    import * as Sentry from '@sentry/nextjs';
    import nextLogger from '@/shared/lib/nextLogger';
    
    export async function register() {
      if (process.env.NEXT_RUNTIME === 'nodejs') {
        await import('../sentry.server.config');
        nextLogger();
      }
    
      if (process.env.NEXT_RUNTIME === 'edge') {
        await import('../sentry.edge.config');
      }
    }
    
    export const onRequestError = Sentry.captureRequestError;

     

    그전에 알아두자!

     

    request body의 특징

    • body는 ReadableStream.
    • 소비(consumed) 되면 disturbed 상태가 되고, 다시 읽을 수 없다.
    • json(), text(), arrayBuffer(), formData(), blob() 같은 메서드를 호출하면 소비된다.

     

    https://fetch.spec.whatwg.org/#body-mixin

     

    Fetch Standard

     

    fetch.spec.whatwg.org

     

     

    초기에 한번만 실행되는 instrumentation을 통해

     global.fetch를 사용해 기존에 fetch에 wrap 해주는 작업으로 모든 요청에 대해 콘솔을 찍을 수 있게 만들었다.

    이때 여기서 읽어주게 되면 본래 요청의 엔드포인트에서 읽을 수 없기 때문에 clone을 통해 요청을 복제해 주었다.

    import logger from '@/shared/lib/logger';
    import util from 'util';
    
    const originalFetch = global.fetch;
    
    export default function nextLogger() {
      global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
        const start = Date.now();
        try {
          const response = await originalFetch(input, init);
          const duration = Date.now() - start;
    
          let url: string;
          if (typeof input === 'string' || input instanceof URL) {
            url = input.toString();
          } else if (input instanceof Request) {
            url = input.url;
          } else {
            url = String(input);
          }
    
          let method = 'GET';
          if (typeof input === 'string' || input instanceof URL) {
            method = init?.method ?? 'GET';
          } else if (input instanceof Request) {
            method = input.method;
          }
    
          const cloned = response.clone();
          let body;
          try {
            const buffer = await cloned.arrayBuffer();
            let text = new TextDecoder('utf-8').decode(buffer);
            if (/�/.test(text)) {
              text = new TextDecoder('euc-kr').decode(buffer);
            }
    
            body = JSON.parse(text);
    
            const MAX_LEN = 200;
            const bodyStr = JSON.stringify(body);
            if (bodyStr.length > MAX_LEN) {
              body = JSON.parse(
                JSON.stringify({
                  preview: `${bodyStr.slice(0, MAX_LEN)}…`,
                }),
              );
            }
          } catch {
            body = '[unreadable body]';
          }
    
          logger.info({
            url,
            method,
            status: response.status,
            duration: `${duration}ms`,
          });
          logger.debug(
            util.inspect(body, {
              depth: Infinity,
              colors: true,
              maxArrayLength: 5,
            }),
          );
    
          return response;
        } catch (err) {
          logger.error({ input, err });
          throw err;
        }
      };
    }

     

     

     

    개발 환경과 배포 환경의 분리

    메모리 누수란?

    메모리 누수는 부주의 또는 일부 프로그램 오류로 인해 더 사용되지 않는 메모리를 해제하지 못하는 것이다. 간단히, 어떤 변수가 100M의 메모리를 점유한다고 할 때, 이 변수가 사용되지 않더라도 수동 또는 자동으로 해제되지 않아 계속 메모리를 점유하는 것을 말한다.

     

     

    콘솔로그가 메모리 누수에 영향을 주나?

     

    다음 코드를 예로 들어보자

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <button>btn</button>
    <script>
        document.querySelector('button').addEventListener('click', function() {
            let obj = new Array(1000000)
    
            console.log(obj);
        })
    </script>
    
    </body>
    </html>

     

     

    콘솔로그가 있을 경우

     

    메모리가 정리되지 않은 모습

     

    콘솔로그가 없을 경우

     

    메모리가 정리된 모습

     

     

    console.log가 제거된 코드에서는 매시간 obj가 생성되고 나서 즉시 사라지는 것을 볼 수 있다. 결과적으로 가비지 컬렉터가 실행될 때 새로운 메모리 라인은 기존 높이와 같은 높이를 유지하게 된다. 이것으로 메모리 누수가 없다는 것을 알 수 있다.

     

     

    그래서 나는 개발 환경에서만 작동하도록 다음과 같이 env로 분기처리를 해주었다.

    import * as Sentry from '@sentry/nextjs';
    import nextLogger from '@/shared/lib/nextLogger';
    
    export async function register() {
      if (process.env.NEXT_RUNTIME === 'nodejs') {
        await import('../sentry.server.config');
        if (process.env.NODE_ENV === 'development') {
          nextLogger();
        }
      }
    
      if (process.env.NEXT_RUNTIME === 'edge') {
        await import('../sentry.edge.config');
      }
    }
    
    export const onRequestError = Sentry.captureRequestError;

     

     

    한계점 및 개선사항

    1. HMR로 다시 리로드 시 fetch가 원래대로 초기화 되는 문제

    원인: dev 환경(HMR/React Refresh 중) 어딘가에서 Next.js가 자체적으로 global.fetch를 다시 교체를 진행한다.

    이후부터는 내가 한 패치가 아니라 Next.js 래퍼가 fetch로 쓰이게 되어 초기화 된다.

     

    2. 캐싱될 경우 요청이 로깅되지 않는 문제

    원인: 한번했던 요청은 nextjs 서버에서 하지 않고 캐시를 반환하기에 보여지지 않는다.

    따라서 이전 요청을 확인해야 한다.

     

     

    해결방안

    따라서 나는 hmr이 진행되기 전에 로깅을 해놓은 것을 한꺼번에 보는 것이 더 나을 것이라고 판단하였다.

    그래서 로깅 파일을 작성하는 식으로 변경하였다.

     

    import fs from 'fs-extra';
    import path from 'path';
    import util from 'util';
    import createLogJson from '../util/createLogJson';
    
    const LOG_DIR = path.join(process.cwd(), 'logs');
    const LOG_FILE = path.join(LOG_DIR, 'fetch.log');
    
    export default function nextLogger() {
      fs.ensureDirSync(LOG_DIR);
      fs.writeFileSync(LOG_FILE, '');
    
      const originalFetch = global.fetch;
    
      const patchedFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
        const start = Date.now();
        const response = await originalFetch(input, init);
        const duration = Date.now() - start;
    
        try {
          const cloned = response?.clone();
          const logJson = await createLogJson(input, init, cloned);
    
          const summary = {
            time: new Date().toISOString(),
            url: logJson.url,
            method: logJson.method,
            status: response.status,
            duration: `${duration}ms`,
          };
    
          const debugData: Record<string, unknown> = {
            response: logJson.response,
          };
          if (logJson.requestPayload !== undefined) {
            debugData.request = logJson.requestPayload;
          }
    
          const formatted = [
            `\n[${summary.time}] ${summary.method} ${summary.url}`,
            `status: ${summary.status} | duration: ${summary.duration}`,
            util.inspect(debugData, { colors: false, depth: 5 }),
            '-------------------------------------------\n',
          ].join('\n');
    
          fs.appendFileSync(LOG_FILE, formatted);
        } catch (err) {
          console.error('Fetch logging failed:', err);
        }
    
        return response;
      };
    
      (patchedFetch as any).__NEXT_DEV_GLOBAL_OVERWRITE__ = true;
      global.fetch = patchedFetch;
    }

     

     

    dev를 실행할 때마다 로깅 파일이 초기화 되고 로깅된다.

     

     

    2025.11.06 업데이트

    위에 사진을 보면 json을 길이만큼 자르기 때문에 필드값이 사라지는 경우가 있었다.

    그래서 응답값을 순회하여 필드별로 응답 길이가 너무 길면 그 필드의 응답만 자르는 형태로 변경하였다.

    또한 배열 길이가 너무 길 경우를 위해서 특정 객체의 반복을 5번이하가 되도록 하였다.

     

    여전히 남은 문제점

    next dev --turbopack의 환경이 next dev와 다르기 때문에 requestBody를 로깅할 수 없다.

    따라서 이 부분은 직접 콘솔 로깅해야 할 것 같다.

    또한 global.fetch, 즉 nodejs의 환경의 서버 액션을 통한 fetch만 로깅이 가능하기 때문에 client단에서의 fetch는 로깅이 어렵다.

     

    출처

    https://ui.toast.com/posts/ko_20210611

     

    당신이 모르는 자바스크립트의 메모리 누수의 비밀

    크롬 개발자도구로 하는 디버깅과 해결책을 찾아서!

    ui.toast.com

     

     

    반응형