ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Next.js CPU 사용량 및 메모리 튀는 현상 개선 (feat. Clinic.js)
    😱 삽질 이슈 기록 2023. 1. 26.

    문제 상황

    회사 홈페이지 개편작업을 하면서 프론트엔드 프레임워크로 사용중인 Next.js로 이미지 썸네일이 보여지는 페이지에 요청이 들어올때 마다 CPU 사용량이 거의 100% 메모리도 비정상적으로 튀는 문제가 발생하였다.

    Datadog에서 Next.js 컨테이너를 분석한 결과 특정 페이지에 요청이 들어올때마다 CPU 사용량이 100%를 찍는다...


    CPU 사용량이 튈 경우 서버에 높은 부하(CPU Load)가 걸리고 있다는 신호일 수 있다. 그리고 Node.js의 "이벤트 루프 delay 현상"이 뒤따를 수 있다. 이는 동시에 많은 요청이 발생할 경우 CPU 바운드 작업을 실행하는 동안 다른 요청을 처리하지 못하기 때문에 병목현상이 발생할 수 있다.

    원인 분석

    최근 인프랩 밋업을 통해 알게된 Node.js 프로파일링 도구인 Clinic.js를 사용해 요청이 들어올때, 정확히 어느 부분에서 CPU 사용량이 튀는지 분석을 진행했다.

    Clinic.js에 대한 자세한 설명은 아래 포스트에 정리했습니다.

     

    Clinic.js로 Node.js 서버 프로파일링 하기

    Clinic.js는 Node.js 런타임 분석도구로 Doctor, Flame, Bubbleprof, Heap Profiler의 4가지 분석 도구를 지원해준다. 설치 $ npm install -g clinic 사용하기 1. 프로파일링 실행 아래 명령어로 프로파일링 할 Node.js 애

    neverfadeaway.tistory.com


    Clinic.js를 실행하기 위해서는 CLI에서 clinic doctor -- node index.js 와 같이 entrypoint가 되는 .js파일을 지정해 주어야 하는데 Next.js의 경우, entrypoint가 되는 .js파일 없이 next start 명령으로 서버를 바로 실행하기 때문에 바로 Clinic.js를 사용할 수 없다.

    Clinic.js에서 Next.js 서버를 프로파일링 하기 위해서는 Next.js에 프로파일링 용도로 사용할 별도 entrypoint를 만들어 주어야 한다.

    Next.js에 프로파일링 용도로 사용할 custom entrypoint 만들기

     

    Advanced Features: Custom Server | Next.js

    Start a Next.js app programmatically using a custom server.

    nextjs.org

     

    1. Next.js 루트 디렉토리에 프로파일링 용 빌드 파일을 생성 하기 위해 타입스크립트 빌드 설정 파일을 생성 한다.

    //tsconfig.server.json
    
    {
      "extends": "./tsconfig.json",
      "compilerOptions": {
        "module": "commonjs",
        "outDir": "dist",
        "lib": ["es2019"],
        "target": "es2019",
        "isolatedModules": false,
        "noEmit": false
      },
      "include": ["server.ts"]
    }

    dist 디렉토리에 entrypoint로 사용할 server.js 파일이 생성되도록 하기 위해 outDir 옵션에 "dist" 디렉토리를 지정해 주었다.

    2. Next.js 루트 디렉토리에 커스텀 서버 파일을 작성한다.

    *커스텀 서버의 경우 자동 정적 최적화 기능을 지원해주지 않기 때문에 커스텀 서버는 프로파일 할때만 사용할 것이다.

    //server.ts
    
    import { createServer } from 'http';
    import { parse } from 'url';
    import next from 'next';
    import { NextServer, RequestHandler } from 'next/dist/server/next';
    
    const port: number = parseInt(process.env.PORT || '3000');
    const dev: boolean = process.env.NODE_ENV !== 'production';
    const app: NextServer = next({ dev });
    const handle: RequestHandler = app.getRequestHandler();
    
    app.prepare().then(() => {
      createServer((req, res) => {
        const parsedUrl = parse(req.url!, true);
        handle(req, res, parsedUrl);
      }).listen(port);
    
      console.log(
        `📡 Server listening at http://localhost:${port} as ${
          dev ? 'development' : process.env.NODE_ENV
        }`
      );
    });

     

    3. package.json에 프로파일링용 script 추가

    "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint",
        "profile:build": "next build && tsc --project tsconfig.server.json",
        "profile:doctor": "clinic doctor -- node dist/server.js",
        "profile:doctor-auto": "clinic doctor --autocannon [ localhost:3000/news/pr --method GET ] -- node dist/server.js",
        "profile:flame": "clinic flame -- node dist/server.js",
        "profile:flame-auto": "clinic flame --autocannon [ localhost:3000/news/pr --method GET ] -- node dist/server.js",
      },


    "profile:build": "next build && tsc --project tsconfig.server.json"
    Next.js는 빌드 시 커스텀 서버 파일(server.ts 파일)도 함께 컴파일 해주지 않기 때문에 아래와 같이 별도로 타입스크립트 컴파일러(tsc)를 실행해 server.ts 파일도 컴파일 해주어야 한다.

    "profile:doctor": "clinic doctor -- node dist/server.js"
    profile:build 단계를 마치면 entrypoint인 dist/server.js 파일이 생성되고, 이를 통해 Next.js를 clinic.js에서 프로파일링 할 수 있다.

    Next.js 서버 flame 그래프 분석

    Next.js 관련 라이브러리 외에 sentry와 sharp에서 CPU를 오래동안 점유하고 있는것을 확인 할 수 있었다.

    이미지 변환 라이브러리인 sharp가 실행된다는 것이 의아했다. 백엔드에서 이미지를 업로드 할 때, 썸네일 생성 후 S3에 업로드하는데 Next.js 서버에서 설마 또 이미지 변환을 하고 있는 걸까?

    프론트엔드 개발자분에게 여쭈어보니 Next.js에서 제공하는 Image Optimizer의 경우, 프론트엔드에서 썸네일에 사용되는 영역에 딱 맞게 이미지를 리사이징 할 수 있고, 디폴트 설정으로 이미지 포멧을 웹에 최적화 되어있는 webp로 변환 한 후 제공할 수 있기 때문에 추가적인 이점이 있어 현재 프론트에 적용이 되어있는 상태라고 하셨다. 하지만 이미 백엔드에서 이미지를 업로드 할때 썸네일 이미지를 생성하고, CloudFront를 통해 지역적으로 캐싱도 하기 때문에 Next.js에서 또 한번 썸네일 이미지로 변환한 후 따로 캐싱을 할 필요가 없다고 생각이 들었다.

    해결

    프론트엔드 개발자 분과 이야기를 나누면서 이미 썸네일 이미지를 백엔드에서 생성한 후 CloudFront에서 기본 하루 동안 캐싱해 두고 있는 상태에서 그 이미지를 받아 또 한번 더 Next.js에서 변환 해서 Next.js 서버에 자체적으로 캐싱 할 필요가 있을 지에 대해 이야기를 나누었다. 그리고 Next.js에서 Image Optimazation 하는 부분 해제 하기로 결정했다.

    Next.js 서버의 경우, 특정 리전 내 인스턴스에 떠있는데 그 인스턴스에서 이미지까지 서비스 하면, 글로벌 서비스를 하기 위해 CloudFront를 적용한 것에 의미가 사라진다는 이유도 있었다.

    CloudFront는 글로벌 콘텐츠 서비스를 위해 여러 지역에 엣지라는 분산된 캐시 서버를 제공해 준다.

     

    CloudFront에서 받아오는 썸네일을 또 한번 Next.js에서 Image Optimazation 하는 부분 해제

    <Image
      layout="fill"
      src={url}
      alt="list image"
      className={cx('thumbnail')}
      unoptimized // 썸네일 이미지를 또 한번 변환하지 않도록 설정
    />


    Next.js에서 Image Optimazation 하는 부분을 해제하고 나서 CPU 사용량이 눈에 뛰게 줄어들었다.


    메모리 사용량도 불규칙적으로 뛰던 문제도 해결 되었다.

     

     

    개인적으로 고민한 또 다른 방법

    Next.js Image Optimization 캐시 수명 조정

    위에서 살펴본 바와 같이 Next.js Image Optimization 기능은 CPU 사용량이 높은 작업이기 때문에 너무 자주 Optimazation이 실행되면 안된다. 만약 별도 썸네일 이미지를 생성 하거나 캐시 처리하는 시스템이 없어서 반드시 Next.js에서 이미지를 변환해서 사용 해야하는 경우라면 적절한 캐시 수명을 설정 해야한다.

    Next.js Image Optimization에서 이미지는 첫 요청 시 한번 만 변환이 일어나고 변환된 이미지는 <distDir>/cache/images디렉터리에 저장된다. 그리고 후속 요청에 대해 이 변환 된 이미지 파일이 제공된다.

    Next.js Image Optimization 을 통해 받아온 이미지의 Response Header를 살펴보면 x-nextjs-cache, Cache-Control 헤더를 통해 현재 이미지의 캐시 상태에 대한 정보를 확인 할 수 있다.


    x-nextjs-cache 헤더
    Next.js Image Optimization 을 통해 생성된 이미지의 Response Header를 살펴보면 현재 요청한 이미지가 새로 변환된 이미지 인지, 캐싱되어있던 이미지인지에 대한 캐시 상태 정보가 x-nextjs-cache 헤더에 표시된다.

    • MISS- 이 이미지는 캐싱 되어 있지 않아서 새로 CPU를 써가며 변환 했다
    • STALE- 캐싱된 이미지 가 있지만 유효시간이 지나서 다시 변환 했다
    • HIT- 캐싱된 이미지가 있고, 유효시간도 지나지 않아서 캐시 이미지를 내려줬다

    Cache-Control 헤더
    현재 캐시가 언제까지 유효한지에 대한 정보를 나타낸다.

    Next.js 에서는 Cache-Control 헤더에 들어가는 정보를 정하는데 업스트림 이미지의 Cache-Control 헤더 와 next.config.js에 지정된 minimumCacheTTL 설정값(default = 60초) 이 두가지를 정보를 사용한다.

    CDN과 같은 외부에서 받아온 이미지(업스트림 이미지)에 이미 Cache-Control 헤더가 있다면 Next.js는 minimumCacheTTL 설정값(따로 설정한 값이 없다면 기본 60초)과 Cache-Control 헤더의 max-age 값중에 큰 값을 변환되어 캐싱된 이미지의 최대 수명으로 정한다. 만약 업스트림 이미지의 Cache-Control 헤더에 max-age와 s-maxage 값이 모두 정의 되어있다면 s-maxage값이 우선적으로 선택 된다.

     

     

    next/image | Next.js

    Enable Image Optimization with the built-in Image component.

    nextjs.org


    따라서 업스트림 이미지에도 Cache-Control 헤더가 없고, next.config.js에 minimumCacheTTL도 지정하지 않았다면 위 사진과 같이 변환된 이미지의 캐시 수명이 기본 60초로 정해지게 되고 60초마다 캐시가 만료되면 이미지 변환이 또 일어나게 되는 것이다.

    한편, 위 사진에서는 Next.js에서 변환되어 캐싱된 이미지의 Cache-Control 헤더값이 public, max-age=60, must-revalidate로 정해졌는데 각 헤더의 의미를 해석하면 다음과 같다.

    • public : 브라우저 뿐만 아니라 다른 중개 서버에 캐시를 저장해도 된다는 뜻
    • max-age=60 : 이 캐시는 60초까지만 유효하다.
    • must-revalidate : 캐시를 쓰기 전 마다 서버에 이 캐시 써도 되는지 물어보지 말고 캐시가 만료됬을때만 물어보라는 뜻
    • 참고) no-cache: 캐시를 쓰기 전 마다 서버에 이 캐시 써도 되는지 물어볼 것

     

    CloudFront에서 내려주는 이미지 응답헤더에 Cache-Control 헤더 추가하기

    Next.js에서는 변환된 이미지의 Cache-Control 헤더에 들어가는 정보를 정하는데 업스트림 이미지의 Cache-Control 헤더도 활용한다는 점에 착안하면, 캐시 수명을 컨트롤 하는 관리포인트를 한곳으로 줄일 수 있다.

    CloudFront에서 내려주는 이미지에 Cache-Control 헤더가 원하는 대로 잘 들어가 있으면, Next.js에서 또 한번 캐시 수명을 정할 필요 없이 업스트림 서버의 설정 대로 변환된 이미지의 캐시 수명이 정해지게 할 수 있기 때문이다.

    cloudfront에서 받아온 이미지의 응답 헤더를 살펴보면 Cache-Control 헤더 없이 x-cache와 age헤더를 통해 현재 캐시 상태를 보여주고 있는 것을 확인 할 수 있다.

    각 헤더의 의미를 살펴보면 다음과 같다.

    x-cache : CloudFront에서 캐시 처리한 결과를 보여준다.

    • "Hit from cloudfront" 또는 "RefreshHit from cloudfront" : 요청은 엣지 로케이션의 캐시에서 처리됨을 의미
    • "Miss from cloudfront" : 리소스가 CloudFront에 등록된 오리진에 있기는 하지만, 캐시에서 처리되지 않았음을 의미

    CloudFront 응답 헤더 중 캐싱 관련 된 헤더의 분석의 경우 아래 문서를 참고
    https://aws.amazon.com/ko/premiumsupport/knowledge-center/cloudfront-custom-object-caching/

     

    CloudFront 사용자 지정 객체 캐싱 관련 문제 해결

    CloudFront 배포에서 사용자 지정 객체 캐싱을 설정합니다. 그런데 배포에서 오리진의 캐시 설정이 사용되는 이유가 무엇인가요? 최종 업데이트 날짜: 2022년 7월 13일 Amazon CloudFront 배포에서 객체 캐

    aws.amazon.com


    age: 캐시 유효시간 내에서 현재까지 얼마나 시간이 흘렀는지를 표시함.

    위 사진에서 Cache-Control 헤더가 없는 이유는 CloudFront에 오리진(원본)으로 지정한 S3 리소스에 따로 Cache-Control 정보를 추가하지 않았기 때문이다. 그렇다면, S3 오브젝트마다 일일히 Cache-Control 정보를 추가해 주어야 할까?

    S3 리소스에 따로 일일히 Cache-Control 메타 데이터를 지정할 필요 없이, CloudFront에서 원본에 대한 동작을 지정할 때, 공통 응답 헤더를 추가 할 수 있다.

    1. 응답 헤더를 달아줄 동작을 선택 한 후 편집을 누른다.

     

    2. 하단 응답 헤더 정책 섹션에서 정책 생성을 눌러 새로운 응답 헤더 정책을 생성해준다.

     

    3. 정책 이름을 지정해주고, 아래 사용자 지정 헤더 섹션에 Cache-Control 헤더의 키와 값를 추가해 준다.



    max-age로 설정 할 값을 정할 때, 현재 CloudFront 동작에 지정된 캐시 정책을 참고 했다.


    CloudFront에서 관리형으로 미리 생성 해둔 정책 중, S3 오리진(원본)에 권장 이라고 되어있는 CachingOptimized 정책이 적용 되어있는데 이 정책의 TTL 설정을 살펴보면 기본 TTL이 하루(86400초)로 설정되어 있기 때문에 위에서 추가한 Cache-Control 헤더의 max-age값도 86400초로 지정했다.

    CloudFront는 오리진(원본)으로 지정한 S3 리소스에 따로 Cache-Control이 지정되어 있지 않을 경우 최소 TTL과 기본 TTL중 더 큰 값을 CloudFront의 캐싱 수명으로 지정하기 때문이다.

    https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html



    4. 응답 헤더에 Cache-Control 헤더를 추가 해주는 응답 헤더 정책을 생성 완료 했으면, 다시 동작 편집 메뉴로 돌아와서 생성한 응답 헤더 정책을 설정해주고 변경 사항을 저장 해준다.

     

    5. 해당 동작에 성공적으로 Cache-Control 헤더를 추가 해주는 응답 헤더 정책이 붙은 모습을 확인 할 수 있다.



    CloudFront에서 받아온 이미지 응답 헤더에 Cache-Control 헤더 추가하기 결과

    CloudFront에서 받아온 이미지의 응답 헤더를 다시 살펴보면, 성공적으로 Cache-Control 헤더가 위에서 설정 한 대로 추가 된 것을 확인 할 수 있다.


    이제 next.js 에서 이 Cache-Control 헤더를 읽어 변환된 이미지의 캐시 수명을 정할 지 테스트 해보면

    Next.js에서 변환한 이미지의 캐시 설정이 CloudFront의 Cache-Control 설정 대로 적용된것을 확인 할 수 있다.


    그리고 Next.js Image Optimization 캐시 수명을 60초에서 하루로 늘린 후 다시 성능 평가를 진행 해 보았다.

    max-age=60

    max-age=86400


    테스트를 진행하면서 max-age=60 일때보다 더 많이 캐시 히트를 얻을 수 있었고, flame 그래프를 확인 한 결과 Sharp 라이브러리에서 CPU를 사용하는 부분(hot 으로 잡히는 영역)도 많이 줄어든 것을 확인 할 수 있었다.

    댓글

GitHub: https://github.com/Yesung-Han