일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- vercel
- 카카오
- 생활코딩
- 프로토타입
- superstarjypnation
- CSS
- redux
- 백준
- REST_API
- React
- 스택
- Til
- html
- UX
- 큐
- 30daysdowoonchallenge
- 프로그래머스
- 코드스테이츠
- 자바스크립트
- Next.js
- mysemester
- level1
- web
- 회고
- 운영체제
- javascript
- 자료구조
- 해시테이블
- UI
- useState
- Today
- Total
데굴데굴
[Next.js] Next.js 13 배포 시 serverless function 용량 초과 문제 pre-build scripts로 해결하기 본문
[Next.js] Next.js 13 배포 시 serverless function 용량 초과 문제 pre-build scripts로 해결하기
aemaaeng 2023. 10. 6. 12:51이전 글과 이어지는 내용입니다.
문제 상황
vercel 배포 시 이미지 경로를 읽지 못하는 문제가 생겼다.
환경 변수로 작업 환경에 따라 이미지를 다른 방식으로 불러오도록 하여 오류가 해결된 줄 알았지만 또 다른 문제가 생겼다.
배포 환경에서는 이미지 주소를 이용해 fetch()
로 불러왔는데 이 때문인지 이미지가 포함된 채팅에 접속하게 되면 로딩 속도가 현저하게 증가했고, 위처럼 서버리스 함수가 실행 시간을 초과하기도 했다.
방법 떠올리기
이미지 경로 문제를 처음 봤을 때 생각한 해결 방법은 1. 절대 경로 이용
, 2. 실행 환경에 따라 다른 방식으로 이미지 불러오기
이 두 가지였다.
1번 방법에서는 서버리스 함수 용량 초과 문제가 발생해 어쩔 수 없이 2번을 택했는데 시간 초과라는 또 다른 문제가 생겨버렸다.
반드시 1번 방법을 활용해서 해결해야 하는 상황에 놓였고, 그러려면 이 때 발생하던 서버리스 함수 용량 초과 문제를 해결해야 했다.
서버리스 함수 용량 초과 문제와 `outputFileTracingExcludes`
const buffer = await fs.readFile(path.join(process.cwd(), "public", src));
process.cwd()
를 이용해 절대 경로로 파일을 불러오게 되면 배포할 때 위처럼 public 폴더에 있는 모든 파일을 읽어와 서버리스 함수의 용량을 초과했다는 에러가 뜨며 배포에 실패했다.
이와 관련해서 열심히 구글링을 하다가 비슷한 이슈를 발견했다.
배포 중에 public 폴더가 포함되는 것은 Next.js가 정적 파일을 추적하는 방식이기 때문에 이를 제외하고 싶다면 public 폴더에 하위 폴더를 만들어 경로를 세분화하거나 next.config.js에서 outputFileTracingIgnores 옵션을 이용해 폴더를 제외하는 것을 제안하고 있었다.
outputFileTracingIgnores
옵션은 현재 experimental.outputFileTracingExcludes
로 바뀌었다.
공식 문서를 참고해 config 파일을 아래처럼 수정해보았지만 어째서인지 배포마다 `public/media` 폴더는 빠지지 않고 등장했다.
// next.config.mjs
import withPlaiceholder from "@plaiceholder/next";
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
outputFileTracingExcludes: {
"*": ["./public/media/**/*"],
},
},
images: {
formats: ["image/avif", "image/webp"],
},
swcMinify: true,
};
export default withPlaiceholder(nextConfig);
경로를 잘못 작성한건가 싶어 여러 방법을 시도해보았지만 결과는 똑같았다.
outputFileTracingExcludes
가 왜 적용되지 않는지 찾아보다가 이 discussion을 보게 되었다.
여기서 서버리스 함수 용량 초과와 outputFileTracingExcludes
에 관해 아주 많은 이야기가 오가고 있었다.
슥 훑어보다가 config 파일에 outputFileTracingExcludes
를 적용했음에도 여전히 public 폴더가 배포에 포함되는 문제를 겪은 분이 있어 자세히 읽어보았다.
이 분의 문제에 달린 답변은 아래와 같았다.
This is happening because somewhere in your code, you are including or using files from
public/static/work
, therefore the folder becomes a dependency.For me, I was using fs.readFileSync() to check the public folder for the existence of certain files, so the entire public folder was included in my serverless function.
코드 내부에서 `public` 폴더를 직접적으로 참조하는 부분이 있어 폴더 자체가 dependency가 된다는 답변이었다. 그래서 config 파일에 제외 옵션을 아무리 적용해봐도 계속 포함되는 것으로 이해했다.
Yep, you were right. I was using fs.readFile to generate placeholder images using plaiceholder. I moved this step to a pre-build script and it solved my issue.
마침 이 분도 placeholder 이미지 생성을 위해 plaiceholder
라이브러리를 이용하고 있었고, plaiceholder
가 쓰이는 부분을 pre-build scripts로 옮겨 해결했다고 되어 있어 이 분의 코드를 참고해 해결하기로 했다. 이걸 발견 못했다면 아마 지금까지도 헤매고 있지 않았을까 생각한다.
pre-build scripts
pre-build scripts는 빌드 전에 추가 작업을 실행하는 것으로, 빌드 프로세스를 커스터마이징 할 수 있는 방법이다.
이 방법의 핵심은 plaiceholder
라이브러리를 런타임에 실행하는 게 아니라 빌드 전에 미리 실행해놓고 그 결과를 json 파일로 만들어둔 후 런타임에서는 그 json 파일에서 데이터를 가져오는 것이다.
스크립트 파일 작성하기
public/media
폴더에 있는 모든 이미지 파일에 plaiceholder
라이브러리를 적용하고 그 결과를 json 파일로 저장해주는 스크립트가 필요했다.
이런 스크립트는 처음 작성해봤기에 아래처럼 흐름을 적어놓고 작업을 시작했다.
media 폴더에 있는 모든 이미지를 읽어와서
base64URL을 생성해 "파일명": {정보} 형태의 json 파일로 저장한다.
build 전에 미리 스크립트를 실행해서 생성된 json 파일을 output 폴더나 어딘가에 넣어둔다
이미지를 읽어올 때 이 json에 key로 접근해서 바로 읽어오면 시간도 O(1)이기 때문에 로딩 속도도 훨씬 빨라질 것 같다.
%% 의사 코드 %%
1. media 폴더에 접근한다.
2. 그 곳에 있는 모든 이미지 파일명을 불러와 배열로 저장한다.
3. 이미지 배열을 순회하면서 plaiceholder 라이브러리로 base64url을 생성한다.
4. 생성 결과를 json 객체에 "파일명": {정보} 형태로 저장한다.
5. scripts/output 폴더에 최종 json 파일을 생성한다.
우선 media
폴더에 접근해 모든 이미지 파일을 불러와 plaiceholder
라이브러리를 적용하는 generateAllBase64()
함수를 작성했다.
async function generateAllBase64() {
// 이미지가 저장된 경로를 변수에 할당해둔다.
const imageDirectory = path.join(process.cwd(), "public", "media");
// 최종 결과가 저장될 객체
let result = {};
// 해당 디렉토리에서 이미지 파일만 골라낸다. ex) ['1234.png', 'abcd.jpeg']
let files = await readdir(path.join(imageDirectory));
files = files.filter((file) => {
return (
file.endsWith(".jpg") || file.endsWith(".jpeg") || file.endsWith(".png")
);
});
const imageData = await Promise.all(
files.map(async (filename) => {
// 이미지 파일을 읽어와 buffer를 생성한다.
const imageBuffer = await readFile(
path.join("public", "media", filename)
);
// plaiceholder 라이브러리로 base64 주소를 얻어낸다.
const {
metadata: { height, width },
base64,
} = await getPlaiceholder(imageBuffer, { size: 10 });
// result 객체에 저장한다.
result[filename] = {
base64,
img: { src: `/media/${filename}`, height, width },
};
})
);
return result;
}
위 과정을 거쳐 생성된 result
객체는 아래와 같은 형태이다.
{
"240504.jpeg": {
"base64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAKCAIAAAAGpYjXAAAACXBIWXMAAAsTAAALEwEAmpwYAAABBUlEQVR4nAH6AAX/AP735/jw3vDn1vHn1ebbyeXby+fezt/YywD7+Oft5dTv59Xr4tH17Nv58eH379/v6tgA6ejjgoeWz8u+8uzb//7t/fblysnCiY2YAOrl3QUVLW5xeOLf0fbz5djUyj1HXV9pggCurqYuNUKpr7rQ0Mrp5tzc3d50fZIPExwAd3t8d316foOHi5CTr7KyrbK0o6Wko6enAEhMUUJHSQABFTxDT1JZaCw1RXJ2d2pvdABBQkBiYl13dmyyrpysppRxcWxiY2FOUlYAoJqHubKfvLKawbif0catyL2om5SFcnJpAFpTOlRPNFxWPGdfRm5nT4V+Z5eReZ+ZgkFbkyPmw7A9AAAAAElFTkSuQmCC",
"img": { "src": "/media/240504.jpeg", "height": 512, "width": 384 }
},
"240505.jpeg": {
"base64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAKCAIAAAAGpYjXAAAACXBIWXMAAAsTAAALEwEAmpwYAAABBUlEQVR4nAH6AAX/APvu1+vdx+PXv9/Su9rNttjMtN3RvN7QvAD26NTn28Pi1r/k2MDd0Lvc0Lvk2ML36tQA9ujU6t/L5NvF4tfB1Miz0MWy19LLzcfBAP/87PTr2ufezc/HtLSwo7K2v3V+l3R7lAD/++vt5NTPybnAv7vg5fBmcYokMU3c2NcA7+fX3NjS3d/l5un2q7C3hIyeO0Rc2c7BAPTz88nN2V1jfLu+xcvO2kxVaCouPP705ADMzdQfJz8jKkLCwsetrbFyc3h/gIO/vr4AiYGDAAAifH6GjIqGZWVif35+iYmJn6CjAOXXwV1cY3l4dnBtZmhnZG5ubH+Af52fn8aho9gL9oxrAAAAAElFTkSuQmCC",
"img": { "src": "/media/240505.jpeg", "height": 512, "width": 384 }
}
}
다음으로 result
객체를 json 파일로 생성해주는 함수 writeJSON()
함수를 작성했다.src/scripts/output
디렉토리가 있는지 확인한 후 json 파일을 생성하는 방식이다.
async function writeJSON(obj) {
const exists = (
await readdir(path.join(process.cwd(), "src", "scripts"))
).includes("output");
if (!exists) {
await mkdir(path.join(process.cwd(), "src", "scripts", "output"));
}
await writeFile(
path.join(process.cwd(), "src", "scripts", "output", "base64.json"),
JSON.stringify(obj)
);
}
전체 코드는 아래와 같다. 모듈 파일이기 때문에 .mjs
확장자로 지정해줘야 했다.
// scripts/generateBase64.mjs
import { readdir, readFile, writeFile, mkdir } from "fs/promises";
import path from "node:path";
import { getPlaiceholder } from "plaiceholder";
async function generateAllBase64() {
const imageDirectory = path.join(process.cwd(), "public", "media");
let result = {};
let files = await readdir(path.join(imageDirectory));
files = files.filter((file) => {
return (
file.endsWith(".jpg") || file.endsWith(".jpeg") || file.endsWith(".png")
);
});
const imageData = await Promise.all(
files.map(async (filename) => {
const imageBuffer = await readFile(
path.join("public", "media", filename)
);
const {
metadata: { height, width },
base64,
} = await getPlaiceholder(imageBuffer, { size: 10 });
result[filename] = {
base64,
img: { src: `/media/${filename}`, height, width },
};
})
);
return result;
}
async function writeJSON(obj) {
const exists = (
await readdir(path.join(process.cwd(), "src", "scripts"))
).includes("output");
if (!exists) {
await mkdir(path.join(process.cwd(), "src", "scripts", "output"));
}
await writeFile(
path.join(process.cwd(), "src", "scripts", "output", "base64.json"),
JSON.stringify(obj)
);
}
generateAllBase64().then((res) => writeJSON(res));
이 파일을 실행하면 이렇게 src/scripts/output
디렉토리에 파일이 생긴다.
기존에 plaiceholder
라이브러리를 이용하던 getBase64()
함수는 json 파일에서 데이터를 가져오도록 수정했다.
// utils/getBase64.ts
export default async function getBase64(src: string) {
const trimmedSrc = src.split("/")[2];
const base64JSON = require("../scripts/output/base64.json");
return base64JSON[trimmedSrc];
}
package.json
수정하기
"scripts": {
"dev": "next dev",
"build": "npm run generate && next build",
"start": "next start",
"lint": "next lint",
"generate": "node src/scripts/generateBase64.mjs"
}
package.json
에 generateBase64.mjs
파일을 실행해주는 명령어 generate
를 추가한다.
build
명령어를 npm run generate && next build
로 작성하면 앞 명령어를 실행한 후에 빌드를 시작하게 된다.
전후 비교
채팅에 포함된 이미지 파일이 많으면 많을수록 로딩 시간이 더 길어졌고 일정 시간을 초과하면 서버리스 함수는 그대로 다운되어버렸다.
pre-build scripts를 적용한 후 프리뷰 도메인에 배포를 해보았다.
문제 해결 전후를 제대로 비교하기 위해 이미지가 가장 많이 포함된 '2022-06-29'의 채팅으로 테스트해보았다.
이 날 채팅에 포함된 이미지는 총 10장이다.
해결 전
글 자체를 불러오지 못하고 서버리스 함수가 실행 시간(10초)을 초과하여 강제로 실행 중단된다.
내가 사용자라면 바로 사이트를 꺼버렸을 것이다.
10장의 이미지를 전부 fetch()
로 불러오고 라이브러리까지 적용하고 있었기에 Content Download에서 모든 시간을 잡아먹고 있는 것을 볼 수 있다.
해결 후
pre-build scripts를 적용하고 배포 프리뷰 도메인에서 테스트한 화면이다.
훨씬 빠른 속도로 페이지가 표시되고 있다.
전에는 실시간으로 이미지를 가져와 라이브러리를 거친 후에야 이미지가 렌더링됐는데 빌드 전에 라이브러리를 다 적용해두었기 때문에 로딩 속도가 굉장히 빨라진 것을 볼 수 있다.
느낀 점
vercel 배포 후 `fs.readFile()`로 파일을 읽어오는 코드에서 `ENOENT: no such file or directory` 오류가 발생했다.
작업 환경에 따라 파일 접근 방식을 구분해 배포해보았지만 `fetch()` 때문에 로딩이 너무 오래 걸려 페이지가 다운되어버리는 경우가 생겼다.
문제 해결을 위해 절대 경로를 적용해 다시 배포했더니 `public/media` 폴더의 파일을 전부 읽어와 서버리스 함수의 용량이 초과되어 배포에 실패하는 상황을 경험했다.
코드 내부에서 해당 경로의 파일을 직접적으로 참조하고 있었기 때문에 `outputFileTracingExcludes` 옵션을 적용해도 문제는 해결되지 않았다.
문제 해결의 실마리는 파일을 참조하는 코드를 서버리스 함수와 완전히 분리해버리는 것이었다.
파일을 참조하는 코드를 독립적인 모듈로 빼고 pre-build scripts로 빌드 전에 모듈을 실행해 결과를 JSON 파일에 저장해두는 방식이다.
이렇게 하니 `fs.readFile()`로 파일을 읽어오는 부분은 서버리스 함수에 포함되지 않아 더 이상 용량 초과 오류가 발생하지 않았고 라이브러리의 실행 결과는 모듈로 생성한 JSON 파일에서 참조하면 되니 콘텐츠를 불러오는 속도도 대폭 향상시킬 수 있었다.
모듈을 실행해야 하기 때문에 배포 과정에서 시간이 더 걸리지만 기존에 실시간으로 이미지를 불러와 placeholder를 생성하던 비용을 빌드 시점에 미리 가져가는 것으로 보면 괜찮은 방법이라고 생각한다.
프로젝트를 배포해놓고도 찝찝하게 만들었던 이미지 로딩 이슈를 마침내 해결하게 되어 뿌듯하다.
해결까지 꽤 많은 난관(?)을 겪었던 오류였다.
파일 참조 부분을 모듈로 분리해 그 결과를 JSON 파일로 만들어두는 것은 전혀 생각지도 못한 해결 방법이었어서 새로웠다.
빌드 전에 특정 스크립트를 실행하도록 직접 작성해줄 수 있다는 것도 처음 알게 되었는데 분명 내가 모르는 다양한 쓰임새가 있을 것 같다.
앞으로 또 다른 오류를 만나게 됐을 땐 아래를 명심하자!
- 공식 문서를 꼼꼼히 읽어볼 것
- 아리까리할 땐 github 전체 검색으로 코드 용례를 잘 찾아볼 것
'Programming' 카테고리의 다른 글
[Next.js] Next.js 13 vercel 배포 시 이미지 로딩 실패 문제 (ENOENT: no such file or directory) (0) | 2023.10.06 |
---|---|
[Next.js] Next.js 13 <Image> plaiceholder 라이브러리 적용기 (1) | 2023.09.21 |
<Deploy> 배포 과정 자동화하기 (with Github actions) (0) | 2022.12.07 |
<WEB> Lighthouse로 웹사이트 성능 분석하기 (0) | 2022.12.05 |
번들링과 웹팩 (0) | 2022.11.23 |