🤔
readFile vs readFileSync (feat: Artillery)
blog
2025-04-04
MDXIMage 구현
MDX 파일에서 이미지를 받아오는 부분은 다음과 같이 구현되어 있었다.
1import fs from "fs";2import sizeOf from "image-size";3import Image from "next/image";4import path from "path";5import { useMemo } from "react";67interface MDXImageProps {8 src: string;9 alt: string;10}1112interface ImageDimensions {13 width: number;14 height: number;15}1617export default function SyncImage({ src, alt }: MDXImageProps) {18 const dimensions: ImageDimensions = useMemo(() => {19 // 클라이언트 측에서는 실행하지 않음20 if (typeof window !== "undefined") {21 return { width: 700, height: 475 }; // 기본값 설정22 }2324 try {25 // public 폴더 내의 이미지 경로 구성26 const imagePath = path.join(process.cwd(), "public", src);2728 // 파일이 존재하는지 확인29 if (!fs.existsSync(imagePath)) {30 console.warn(`Image not found: ${imagePath}`);31 return { width: 700, height: 475 }; // 이미지를 찾지 못한 경우 기본값 반환32 }3334 // 파일을 바이너리로 읽기35 const buffer = fs.readFileSync(imagePath);3637 // image-size를 사용하여 이미지 크기 계산38 const dimensions = sizeOf(buffer);3940 return {41 width: dimensions.width || 700,42 height: dimensions.height || 475,43 };44 } catch (error) {45 console.error("Error getting image dimensions:", error);46 return { width: 700, height: 475 }; // 오류 발생 시 기본값 반환47 }48 }, [src]);4950 return (51 <Image52 src={src}53 alt={alt || ""}54 width={dimensions.width}55 height={dimensions.height}56 style={{57 maxWidth: "100%",58 height: "auto",59 }}60 className="rounded-[10px]"61 />62 );63}
가장 핵심적인 부분은 다음과 같다.
1// 파일을 바이너리로 읽기2const buffer = fs.readFileSync(imagePath);34// image-size를 사용하여 이미지 크기 계산5const dimensions = sizeOf(buffer);67return {8 width: dimensions.width || 700,9 height: dimensions.height || 475,10};
readFileSync를 통해 이미지를 불러오고, sizeOf를 이용해 불러온 이미지의 사이즈를 구해서 return 한다.
이렇게만 보면, 아무런 생각이 들지 않는다.
괜찮은 코드 아닌가?
그러나 공식 문서에서는 다음과 같이 말하고 있다.
1Reading from a file Syncronously (not recommended)2v1.x of this library had a sync API, that internally used sync file reads.3This isn't recommended because this blocks the node.js main thread, which reduces the performance, and prevents this library from being used concurrently.4However if you still need to use this package syncronously, you can read the file syncronously into a buffer, and then pass the buffer to this library.
동기적으로 파일을 읽어오면 node.js의 메인 쓰레드를 멈추게 하고, 이는 곧 성능 저하로 이어진다고 한다. 다음 답변에서는 ‘절대’ 쓰지 말라고 하기도 한다.
그러면 어떻게 작성하는게 좋을까?
해당 코드가 반드시 정답은 아니다.
1 const buffer = await fs.readFile(imagePath);23 const { metadata, base64 } = await getPlaiceholder(buffer);45 return {6 width: metadata.width,7 height: metadata.height,8 blurDataURL: base64,9 };
단순히 readFileSync를 readFile로 바꿔줬다. (짜잔~) 그리고 plaiceholder 라이브러리를 이용해 이미지가 덜 로딩 되었을 때, 블러 처리를 해주었다. 과연 이 둘의 성능 차이는 얼마나 날까?
그래서 성능 차이가 있어?
결론부터 말하자면, 차이가 아주 미미하다. 그래서 나는 어떨 때 차이가 발생하는지 궁금해서 Artillery를 이용해 부하 테스트를 진행했다.
Artillery의 시나리오는 다음과 같다. 비동기, 동기 둘 다 똑같은 시나리오와 부하를 갖는다. 단지 url이 ‘/test-async’인지 ‘/test-sync’인지에 대한 차이만 있다.
1config:2 target: "http://localhost:3000"3 phases:4 - name: "초기 준비 단계"5 duration: 106 arrivalRate: 100 # 첫 10초 동안 초당 50명7 - name: "중간 부하 단계"8 duration: 109 arrivalRate: 500 # 다음 10초 동안 초당 100명10 - name: "고부하 단계"11 duration: 2012 arrivalRate: 1000 # 마지막 20초 동안 초당 200명13scenarios:14 - name: "테스트 엔드포인트"15 flow:16 - get:17 url: "/test-async"
테스트 결과
왼쪽이 비동기, 오른쪽이 동기이다.



사실 둘의 차이는 거의 없다. (하하하) 이렇게 결론을 맺으면 재미가 없으니까! 데이터를 좀만 더 분석해보자.
우선, 트래픽이 적은 상황에서는 동기와 비동기 방식 모두 평균 응답 시간이 거의 0ms로 비슷하게 나타났다. 그러나 부하가 증가할수록 두 방식의 특성이 점차 뚜렷해진다.
동기 방식은 마치 한 개 차선만 있는 도로와 같다. 차량(요청)이 적을 때는 빠르게 통과하지만, 동시 요청이 몰리면 차선이 금세 포화되어 입구에서부터 차량이 막힌다. 실제로 클라이언트 측 포트가 고갈(EADDRINUSE)되면서 새 연결 생성이 지연돼 전체 성공률이 낮아지고, P95, P99 구간의 응답 시간도 크게 증가했다.
반면, 비동기 방식은 다차선 고속도로와 유사하다. 중부하까지는 여러 요청을 동시에 처리해 안정적인 흐름을 유지하지만, 서버의 I/O 버퍼 자원이 임계점에 다다르면 결국 모든 차선이 포화된다. 이때 다량의 연결 거부(ECONNREFUSED) 오류가 발생하며 응답 지연이 급격히 확대된다.
결론적으로, 저부하에서 중부하 구간까지는 비동기 방식이 더 높은 처리량과 짧은 응답 지연을 보였으나, 극심한 고부하 환경에서는 두 방식 모두 한계에 달해 실패율과 지연이 동시에 급증한다.
만약 현업 환경이었다면?
만약 실제 서버에서 이정도의 부하가 발생했다면, 무조건 터진다. 따라서 비동기 방식의 이점을 살리되, 사전에 이미지 처리(리사이징, placeholder 생성)를 해두고 캐시를 활용하며, 서비스 인프라를 증설하여 단일 서버에 과부하가 걸리지 않도록 운영하는 것을 생각해볼 수 있다.
삽질(Feat: 도커)
원래는, 빌드파일을 도커에 띄워서 도커에서 cpu및 메모리 자원을 조절해가면서 부하테스트를 해보려 했었다. 도커를 거의 처음 쓰다보니 엄청 헤맸지만 어찌저찌 도커 이미지를 빌드하여 컨테이너에 올려서 실행까지 시켰다.
1docker run --cpus="12" --memory="1024m" -p 3000:3000 test
위 커맨드로 실행을 시키고 부하테스트를 해보았는데, 비동기 테스트인데도 다 실패를 하는 거였다. 그래서 부하를 거의 없다시피 설정하고 테스트를 해보았다. 그런데도 모두 실패했다. . .그래서 도커에 띄워서 하지말고 그냥 빌드파일을 next start로 띄운 다음에 부하를 많이 걸어보는 걸로 바꿨다.
1phases:2 - name: "초기 준비 단계"3 duration: 104 arrivalRate: 50 # 첫 10초 동안 초당 50명5 - name: "중간 부하 단계"6 duration: 107 arrivalRate: 100 # 다음 10초 동안 초당 100명8 - name: "고부하 단계"9 duration: 2010 arrivalRate: 200 # 마지막 20초 동안 초당 200명
1phases:2 - name: "초기 준비 단계"3 duration: 104 arrivalRate: 100 # 첫 10초 동안 초당 100명5 - name: "중간 부하 단계"6 duration: 107 arrivalRate: 500 # 다음 10초 동안 초당 500명8 - name: "고부하 단계"9 duration: 2010 arrivalRate: 1000 # 마지막 20초 동안 초당 1000명
그 결과 다행히 결과가 나왔다. (모두 실패가 아닌거에 감사함..ㅠㅠㅠ)
왜 도커로 띄우면 안되는 건지 아직도 잘 파악하지 못했다. 그런데 컴퓨터를 껐다 키면 딱 최초 1회는 괜찮게 나온다. 그 이후로는 아무리 약한 부하를 걸어도 다 실패를 한다. 이 부분은 좀 더 공부해서 나중에 다시 시도해봐야겠다.