Node.js Express 및 Fastify를 사용한 파일 작업 간소화
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
현대 웹 애플리케이션에서는 파일 업로드 및 다운로드 처리가 일반적인 요구 사항입니다. 사용자가 프로필 사진, 문서를 업로드하거나 관리자가 대규모 데이터셋을 배포하는 경우, 이러한 작업의 효율성은 사용자 경험과 서버 성능에 상당한 영향을 미칩니다. 기존 접근 방식은 종종 전체 파일을 메모리로 로드한 후 처리하는 방식을 사용하는데, 이는 비효율적일 수 있으며 대용량 파일을 처리할 때 메모리 부족 오류로 이어질 수도 있습니다. 여기서 Node.js 스트림의 강력함이 작용합니다. 스트림을 활용하면 파일을 청크 단위로 처리하여 메모리 사용량을 크게 줄이고 응답성을 향상시킬 수 있습니다. 이 글에서는 강력하고 확장 가능한 파일 처리를 위해 Express 및 Fastify와 같은 인기 있는 Node.js 프레임워크 내에서 스트림을 효과적으로 활용하는 방법을 살펴봅니다.
핵심 개념
실제 구현으로 들어가기 전에 이 주제와 관련된 핵심 개념을 명확히 이해해 봅시다.
- 스트림(Streams): Node.js에서 스트림은 스트리밍 데이터를 다루기 위한 추상 인터페이스입니다.
EventEmitter의 인스턴스이며, 모든 데이터를 한 번에 메모리로 로드하는 대신 작고 관리하기 쉬운 청크 단위로 데이터를 처리하는 방법을 제공합니다. 이는 대용량 파일이나 연속 데이터 흐름을 처리하는 데 중요합니다. - Readable Stream: 데이터를 읽을 수 있는 스트림 유형입니다. 파일 읽기를 위한
fs.createReadStream()또는 HTTP 서버의 요청 객체인http.IncomingMessage등이 있습니다. - Writable Stream: 데이터를 쓸 수 있는 스트림 유형입니다. 파일 쓰기를 위한
fs.createWriteStream()또는 HTTP 서버의 응답 객체인http.ServerResponse등이 있습니다. - Duplex Stream: Readable하고 Writable한 스트림입니다. 소켓이 좋은 예시입니다.
- Transform Stream: 데이터를 쓰면서 수정하거나 변환하고 다시 읽을 수 있는 Duplex 스트림 유형입니다. 압축/압축 해제를 위한 zlib 스트림이 예시입니다.
- Piping: Readable Stream의 출력을 Writable Stream의 입력에 연결하는 기본적인 스트림 개념입니다. 이를 통해 데이터를 중간에 전체 데이터를 버퍼링하지 않고 한 스트림에서 다른 스트림으로 직접 효율적으로 흐르게 할 수 있습니다.
source.pipe(destination)이 일반적인 구문입니다. - Bussboy (Fastify): Fastify를 위해 특별히 설계된 고성능 multipart/form-data 파서로, 파일 업로드 처리에 자주 사용됩니다.
- Multer (Express): Express.js를 위한 미들웨어로, 주로 파일 업로드에 사용되는
multipart/form-data를 처리합니다. Multer는 스트림을 처리할 수 있지만, 기본 동작은 종종 파일을 디스크에 전체 버퍼링하는 것이므로 매우 큰 파일의 경우 순전히 스트림 기반 접근 방식보다 덜 효율적일 수 있습니다.
효율적인 파일 업로드
특히 Multer와 같은 미들웨어를 사용하는 기존 파일 업로드 처리 방식은 종종 전체 파일을 임시 디스크 위치에 저장하거나 메모리에 버퍼링합니다. 작은 파일에는 편리하지만, 큰 파일의 경우 병목 현상이 될 수 있습니다. 스트림 기반 업로드를 사용하면 도착하는 대로 파일을 청크 단위로 처리하거나 저장할 수 있습니다.
Express.js에서 스트림을 사용한 업로드
Express의 경우, 사용자 정의 미들웨어와 busboy와 같은 라이브러리(Fastify의 busboy 라이브러리가 아닌 Node.js의 네이티브 multipart/form-data 파서)를 결합하거나 들어오는 요청 스트림을 직접 처리할 수 있습니다. 좀 더 구조화된 접근 방식에 busboy를 사용하는 예제는 다음과 같습니다.
const express = require('express'); const Busboy = require('busboy'); const fs = require('fs'); const path = require('path'); const app = express(); const uploadDir = path.join(__dirname, 'uploads'); // 업로드 디렉토리 생성 확인 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } app.post('/upload', (req, res) => { const busboy = Busboy({ headers: req.headers }); let fileName = ''; busboy.on('file', (fieldname, file, filename, encoding, mimetype) => { fileName = filename; const saveTo = path.join(uploadDir, path.basename(filename)); console.log(`Uploading: ${saveTo}`); file.pipe(fs.createWriteStream(saveTo)); }); busboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { console.log(`Field [${fieldname}]: value: %j`, val); }); busboy.on('finish', () => { console.log('Upload complete'); res.status(200).send(`File '${fileName}' uploaded successfully.`); }); busboy.on('error', (err) => { console.error('Busboy error:', err); res.status(500).send('File upload failed.'); }); req.pipe(busboy); }); app.listen(3000, () => { console.log('Express Upload Server listening on port 3000'); });
이 Express 예제에서 req.pipe(busboy)가 핵심입니다. 들어오는 HTTP 요청(Readable Stream)이 busboy로 직접 파이프됩니다. busboy가 multipart 데이터를 파싱하면서 업로드된 파일에 대한 file 스트림을 제공하는 file 이벤트를 발생시킵니다. 이 file 스트림은 파일 전체를 메모리에 버퍼링하지 않고 청크 단위로 디스크에 저장하는 fs.createWriteStream으로 직접 파이프됩니다.
Fastify에서 스트림을 사용한 업로드
Fastify는 스트림에 대한 훌륭한 네이티브 지원을 갖추고 있으며, 생태계는 성능을 중심으로 번성합니다. fastify-multipart 플러그인은 파일 업로드를 효율적으로 처리하기 위해 내부적으로 busboy(Fastify 전용)를 사용합니다.
const fastify = require('fastify'); const fs = require('fs'); const path = require('path'); const pump = require('pump'); // 오류 처리를 포함한 스트림 파이핑 유틸리티 const app = fastify({ logger: true }); const uploadDir = path.join(__dirname, 'uploads'); // 업로드 디렉토리 생성 확인 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } app.register(require('@fastify/multipart'), { limits: { // 예시로 10MB 제한 fileSize: 10 * 1024 * 1024 } }); app.post('/upload', async (request, reply) => { const data = await request.file(); // 파일 스트림 가져오기 if (!data) { return reply.code(400).send('No file uploaded.'); } const { filename, mimetype, encoding, file } = data; const saveTo = path.join(uploadDir, filename); try { await pump(file, fs.createWriteStream(saveTo)); reply.code(200).send(`File '${filename}' uploaded successfully.`); } catch (err) { request.log.error('File upload error:', err); reply.code(500).send('File upload failed.'); } }); app.listen({ port: 3000 }, (err) => { if (err) { app.log.error(err); process.exit(1); } app.log.info(`Fastify Upload Server listening on ${app.server.address().port}`); });
Fastify 예제에서 request.file()은 파일 데이터를 비동기적으로 검색하며, 이는 그 자체로 Readable file 스트림을 포함합니다. 그런 다음 pump를
이 파일 스트림을 fs.createWriteStream으로 안전하게 파이프하는 데 사용됩니다. pump는 스트림을 닫고 오류를 올바르게 전파하므로 스트림 파이핑을 더 강력하게 만들어주기 때문에 특히 유용합니다.
효율적인 파일 다운로드
대용량 파일을 다운로드용으로 제공하는 것도 스트림을 통해 크게 이점을 얻습니다. 전체 파일을 서버 메모리로 로드한 후 보내는 대신, 파일에서 Readable 스트림을 생성하고 이를 HTTP 응답으로 직접 파이프할 수 있습니다.
Express.js에서 스트림을 사용한 다운로드
const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); const downloadsDir = path.join(__dirname, 'downloads'); const sampleFilePath = path.join(downloadsDir, 'sample-large-file.txt'); // 다운로드 테스트를 위한 더미 대용량 파일 생성 if (!fs.existsSync(downloadsDir)) { fs.mkdirSync(downloadsDir); } if (!fs.existsSync(sampleFilePath)) { const dummyContent = 'This is a sample line for a large file.\n'.repeat(100000); // ~5MB 파일 fs.writeFileSync(sampleFilePath, dummyContent); console.log('Created a sample large file:', sampleFilePath); } app.get('/download/:filename', (req, res) => { const filename = req.params.filename; const filePath = path.join(downloadsDir, filename); if (!fs.existsSync(filePath)) { return res.status(404).send('File not found.'); } // 다운로드를 위한 적절한 헤더 설정 res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); const fileStream = fs.createReadStream(filePath); // 스트림 오류 처리 fileStream.on('error', (err) => { console.error('Error reading file for download:', err); res.status(500).send('Could not retrieve file.'); }); fileStream.pipe(res); // 파일 스트림을 응답으로 직접 파이프 }); app.listen(3000, () => { console.log('Express Download Server listening on port 3000'); });
여기서 fs.createReadStream(filePath)는 파일에서 Readable 스트림을 생성합니다. 그런 다음 이 스트림은 HTTP 응답 객체(Writable Stream)인 res로 직접 파이프됩니다. 즉, 파일의 청크가 디스크에서 읽혀질 때 전체 파일을 서버 메모리에 버퍼링하지 않고 즉시 클라이언트로 전송됩니다. 이는 대용량 파일에 매우 효율적이며 클라이언트 측의 진행률 표시기와 잘 작동합니다.
Fastify에서 스트림을 사용한 다운로드
Fastify의 reply 객체도 Writable 스트림처럼 작동하므로 스트림 기반 다운로드를 쉽게 처리할 수 있습니다.
const fastify = require('fastify'); const fs = require('fs'); const path = require('path'); const pump = require('pump'); const app = fastify({ logger: true }); const downloadsDir = path.join(__dirname, 'downloads'); const sampleFilePath = path.join(downloadsDir, 'sample-large-file.txt'); // 다운로드 테스트를 위한 더미 대용량 파일 생성 if (!fs.existsSync(downloadsDir)) { fs.mkdirSync(downloadsDir); } if (!fs.existsSync(sampleFilePath)) { const dummyContent = 'This is a sample line for a large file.\n'.repeat(100000); // ~5MB 파일 fs.writeFileSync(sampleFilePath, dummyContent); app.log.info('Created a sample large file:', sampleFilePath); } app.get('/download/:filename', (request, reply) => { const filename = request.params.filename; const filePath = path.join(downloadsDir, filename); if (!fs.existsSync(filePath)) { return reply.code(404).send('File not found.'); } // 다운로드를 위한 적절한 헤더 설정 reply.header('Content-Type', 'application/octet-stream'); reply.header('Content-Disposition', `attachment; filename="${filename}"`); const fileStream = fs.createReadStream(filePath); // 견고한 파이핑 및 오류 처리를 위해 pump 사용 pump(fileStream, reply.raw, (err) => { if (err) { request.log.error('Error during file download:', err); // 헤더가 이미 전송된 경우 오류 상태를 보내기에는 너무 늦었을 수 있습니다. // 오류를 기록하고 연결이 닫히도록 두는 것을 고려하세요. } else { request.log.info(`File '${filename}' sent successfully.`); } }); }); app.listen({ port: 3000 }, (err) => { if (err) { app.log.error(err); process.exit(1); } app.log.info(`Fastify Download Server listening on ${app.server.address().port}`); });
Express와 마찬가지로 fs.createReadStream(filePath)는 Readable 스트림을 생성합니다. Fastify의 reply.raw는 Writable 스트림인 기본 Node.js http.ServerResponse 객체에 액세스할 수 있도록 합니다. 그런 다음 pump를 사용하여 파일 스트림을 reply.raw로 파이프하여 효율적인 데이터 전송과 강력한 오류 처리를 보장합니다.
결론
Express 및 Fastify에서 Node.js 스트림을 활용하여 파일 업로드 및 다운로드를 처리하는 것은 특히 대용량 파일을 처리할 때 매우 효율적이고 확장 가능한 솔루션을 제공합니다. 전체 파일을 메모리에 버퍼링하는 대신 청크 단위로 데이터를 처리함으로써 애플리케이션은 메모리 사용량을 크게 줄이고 성능을 향상시키며 사용자 경험을 개선할 수 있습니다. 스트림 기반 접근 방식을 채택하는 것은 Node.js 웹 애플리케이션에서 성능이 뛰어나고 복원력 있는 파일 처리 기능을 구축하기 위한 중요한 단계입니다. 이 우아한 배관은 리소스 효율적인 데이터 흐름을 가능하게 하여 애플리케이션을 더 강력하고 확장 가능하게 만듭니다.

