이미지 로딩 중...
AI Generated
2025. 11. 13. · 2 Views
파일 업로드 엔드포인트 테스트 완벽 가이드
파일 업로드 API를 안전하고 효과적으로 테스트하는 방법을 배웁니다. Multer를 사용한 엔드포인트 구현부터 Jest와 Supertest를 활용한 통합 테스트까지, 실무에서 바로 적용할 수 있는 완벽한 가이드입니다.
목차
- Multer 기본 설정
- 파일 업로드 엔드포인트 구현
- Supertest를 활용한 파일 업로드 테스트
- 파일 크기 제한 테스트
- 파일 타입 검증 테스트
- 멀티파트 폼 데이터 테스트
- 파일 저장 경로 검증
- Mock 파일 시스템 사용
1. Multer 기본 설정
시작하며
여러분이 사용자 프로필 이미지 업로드 기능을 개발한다고 상상해보세요. 프론트엔드 개발자가 파일 선택 UI를 완성했고, 이제 여러분은 백엔드에서 이 파일을 안전하게 받아서 저장해야 합니다.
하지만 Express.js는 기본적으로 multipart/form-data를 처리하지 못합니다. 이 문제를 해결하지 않으면 사용자가 업로드한 파일은 서버에 도달하지 못하고, 에러만 발생하게 됩니다.
또한 파일 크기 제한, 저장 위치 설정, 파일명 중복 처리 등 고려해야 할 사항들이 많습니다. 바로 이럴 때 필요한 것이 Multer입니다.
Multer는 Node.js의 가장 인기 있는 파일 업로드 미들웨어로, 복잡한 파일 처리를 간단하게 만들어줍니다.
개요
간단히 말해서, Multer는 multipart/form-data를 처리하는 Node.js 미들웨어입니다. Express.js 애플리케이션에서 파일 업로드를 쉽게 구현할 수 있도록 도와줍니다.
실무에서 파일 업로드는 매우 흔한 요구사항입니다. 사용자 프로필 사진, 문서 첨부, 이미지 갤러리 등 다양한 기능에서 필요합니다.
Multer를 사용하면 이러한 기능을 몇 줄의 코드만으로 구현할 수 있습니다. 기존에는 raw body를 파싱하고 boundary를 찾아서 직접 파일을 분리해야 했다면, 이제는 Multer가 모든 것을 자동으로 처리해줍니다.
개발자는 비즈니스 로직에만 집중할 수 있습니다. Multer의 핵심 특징은 다음과 같습니다: 1) 디스크 또는 메모리 저장 옵션 선택 가능, 2) 파일 크기와 개수 제한 설정, 3) 파일 필터링으로 특정 타입만 허용, 4) 커스텀 파일명 생성 가능.
이러한 특징들이 보안과 성능 모두를 고려한 파일 업로드 시스템을 만들 수 있게 해줍니다.
코드 예제
const multer = require('multer');
const path = require('path');
// 디스크 저장소 설정
const storage = multer.diskStorage({
// 저장 경로 지정
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
// 파일명 생성 로직
filename: function (req, file, cb) {
// 타임스탬프를 추가하여 중복 방지
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
// Multer 인스턴스 생성
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 } // 5MB 제한
});
설명
이것이 하는 일: 이 코드는 Multer를 설정하여 업로드된 파일을 디스크에 저장하고, 파일명 중복을 방지하며, 크기를 제한하는 완전한 파일 업로드 시스템을 구축합니다. 첫 번째로, diskStorage를 사용하여 저장소 엔진을 구성합니다.
destination 함수는 파일이 저장될 디렉토리를 지정하는데, 여기서는 'uploads/' 폴더를 사용합니다. 이 폴더는 미리 생성되어 있어야 하며, 쓰기 권한이 있어야 합니다.
콜백 함수 cb(null, 'uploads/')에서 첫 번째 인자 null은 에러가 없음을 의미합니다. 두 번째로, filename 함수가 실행되면서 고유한 파일명을 생성합니다.
Date.now()로 현재 타임스탬프를 얻고, Math.random()으로 추가 난수를 생성하여 파일명 충돌 가능성을 극도로 낮춥니다. path.extname()을 사용하면 원본 파일의 확장자를 유지할 수 있어, 파일 타입을 보존할 수 있습니다.
세 번째로, multer() 함수로 최종 인스턴스를 생성할 때 storage 옵션과 limits 옵션을 전달합니다. limits.fileSize를 5MB로 설정하여 너무 큰 파일이 서버 자원을 낭비하는 것을 방지합니다.
이 설정을 초과하는 파일이 업로드되면 자동으로 에러가 발생합니다. 여러분이 이 코드를 사용하면 안전하고 확장 가능한 파일 업로드 시스템을 구축할 수 있습니다.
파일명 중복 걱정 없이 수천 개의 파일을 처리할 수 있고, 서버 디스크 공간을 보호하며, 파일 타입별로 다른 처리를 할 수 있는 기반이 마련됩니다.
실전 팁
💡 uploads 폴더는 애플리케이션 시작 시 자동으로 생성하세요. fs.mkdirSync('uploads', { recursive: true })를 사용하면 폴더가 없을 때만 생성합니다.
💡 프로덕션 환경에서는 파일을 로컬 디스크가 아닌 AWS S3, Google Cloud Storage 같은 클라우드 스토리지에 저장하는 것이 좋습니다. multer-s3 같은 패키지를 사용하세요.
💡 fileSize 제한은 요구사항에 맞게 조정하세요. 이미지는 5MB, PDF는 10MB, 동영상은 100MB처럼 파일 타입별로 다른 제한을 적용할 수 있습니다.
💡 보안을 위해 파일 확장자만 믿지 말고, file-type 라이브러리로 실제 MIME 타입을 검증하세요. 악의적인 사용자가 확장자를 속일 수 있습니다.
💡 업로드된 파일 정보는 데이터베이스에 저장하여 추적하세요. 파일명, 크기, 업로드 시간, 사용자 ID를 기록하면 나중에 관리가 쉬워집니다.
2. 파일 업로드 엔드포인트 구현
시작하며
여러분이 Multer 설정을 완료했다면, 이제 실제로 파일을 받을 수 있는 API 엔드포인트가 필요합니다. 프론트엔드에서 FormData로 파일을 전송할 때, 백엔드는 어떤 필드명으로 파일이 오는지 알고 있어야 하고, 업로드 후 적절한 응답을 반환해야 합니다.
많은 개발자들이 이 단계에서 실수를 합니다. 미들웨어를 잘못된 위치에 배치하거나, 에러 처리를 빠뜨리거나, 응답 형식을 일관되게 만들지 않는 경우가 많습니다.
이런 문제들은 디버깅을 어렵게 만들고 사용자 경험을 해칩니다. 바로 이럴 때 필요한 것이 체계적인 엔드포인트 구현입니다.
Express 라우터에 Multer 미들웨어를 올바르게 통합하고, 성공과 실패 케이스를 모두 처리하는 방법을 알아야 합니다.
개요
간단히 말해서, 파일 업로드 엔드포인트는 Multer 미들웨어를 라우트 핸들러에 적용하여 multipart 요청을 처리하는 API입니다. 클라이언트가 보낸 파일을 받아서 저장하고 결과를 반환합니다.
실무에서 API 설계는 매우 중요합니다. RESTful 원칙을 따르고, 명확한 응답 형식을 제공하며, 에러를 적절히 처리해야 합니다.
특히 파일 업로드는 네트워크 타임아웃, 용량 초과, 권한 문제 등 다양한 에러가 발생할 수 있어 더욱 신경 써야 합니다. 기존에는 파일 업로드와 일반 데이터 업로드를 별도로 처리했다면, 이제는 단일 엔드포인트에서 파일과 텍스트 필드를 함께 받을 수 있습니다.
프론트엔드 개발자는 하나의 요청으로 모든 데이터를 전송할 수 있어 편리합니다. 엔드포인트의 핵심 구성 요소는 다음과 같습니다: 1) POST 메서드 사용 (파일 업로드는 리소스 생성), 2) Multer 미들�어를 라우트 핸들러 체인에 추가, 3) req.file 또는 req.files로 업로드된 파일 접근, 4) 적절한 HTTP 상태 코드와 응답 데이터 반환.
이러한 구조가 클라이언트가 쉽게 통합할 수 있는 예측 가능한 API를 만들어줍니다.
코드 예제
const express = require('express');
const router = express.Router();
// 단일 파일 업로드 엔드포인트
router.post('/upload', upload.single('avatar'), (req, res) => {
// 파일이 업로드되지 않은 경우 처리
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// 업로드 성공 응답
res.status(201).json({
message: 'File uploaded successfully',
file: {
filename: req.file.filename,
originalname: req.file.originalname,
size: req.file.size,
path: req.file.path
}
});
});
module.exports = router;
설명
이것이 하는 일: 이 코드는 '/upload' 경로로 POST 요청이 오면 'avatar'라는 필드명으로 전송된 단일 파일을 받아서 저장하고, 파일 정보를 JSON 응답으로 반환하는 완전한 RESTful 엔드포인트를 만듭니다. 첫 번째로, router.post()로 POST 메서드의 엔드포인트를 정의합니다.
두 번째 인자로 upload.single('avatar')를 전달하는데, 이것이 핵심입니다. single() 메서드는 하나의 파일만 받겠다는 의미이고, 'avatar'는 클라이언트에서 FormData.append('avatar', file)로 보낼 때 사용하는 필드명과 정확히 일치해야 합니다.
두 번째로, 미들웨어가 성공적으로 실행되면 req.file 객체가 생성됩니다. 이 객체에는 filename(저장된 파일명), originalname(원본 파일명), size(바이트 단위 크기), path(저장 경로), mimetype(MIME 타입) 등 유용한 정보가 담겨 있습니다.
if (!req.file) 체크는 필수입니다. 사용자가 파일을 선택하지 않고 요청을 보낼 수 있기 때문입니다.
세 번째로, 성공 시 201 Created 상태 코드와 함께 파일 정보를 반환합니다. 클라이언트는 이 응답을 받아서 업로드가 성공했음을 사용자에게 알리고, 필요하다면 filename을 데이터베이스에 저장할 수 있습니다.
path 정보를 반환하면 나중에 파일을 다운로드하거나 표시할 때 사용할 수 있습니다. 여러분이 이 코드를 사용하면 프론트엔드와 백엔드 간의 명확한 계약이 생깁니다.
프론트엔드 개발자는 정확히 어떤 필드명을 사용해야 하고, 어떤 응답을 받을지 알 수 있습니다. 에러 처리도 명확하여 사용자에게 적절한 피드백을 줄 수 있습니다.
실전 팁
💡 여러 파일을 받으려면 upload.array('photos', 5)를 사용하세요. 두 번째 인자는 최대 파일 개수입니다. req.files 배열로 접근할 수 있습니다.
💡 다른 필드명으로 여러 파일을 받으려면 upload.fields([{ name: 'avatar', maxCount: 1 }, { name: 'gallery', maxCount: 8 }])를 사용하세요.
💡 파일과 함께 전송된 텍스트 필드는 req.body로 접근할 수 있습니다. 예를 들어 FormData.append('description', '프로필 사진')으로 보낸 데이터는 req.body.description으로 읽을 수 있습니다.
💡 Multer 에러는 try-catch로 잡을 수 없습니다. Express 에러 핸들러 미들웨어를 만들어서 처리하세요: app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { ... } })
💡 업로드 완료 후 바이러스 스캔, 이미지 리사이징, 썸네일 생성 등의 후처리 작업을 비동기로 수행하세요. 사용자를 기다리게 하지 않도록 큐 시스템(Bull, BeeQueue)을 사용하는 것이 좋습니다.
3. Supertest를 활용한 파일 업로드 테스트
시작하며
여러분이 파일 업로드 엔드포인트를 완성했다면, 이제 이것이 제대로 작동하는지 확인해야 합니다. 수동으로 Postman이나 cURL로 테스트할 수도 있지만, 코드가 변경될 때마다 반복적으로 테스트하는 것은 비효율적입니다.
또한 팀원들이 코드를 수정했을 때 파일 업로드 기능이 망가졌는지 즉시 알 수 있어야 합니다. 많은 개발자들이 파일 업로드 테스트를 어려워합니다.
실제 파일을 어떻게 만들어야 하는지, FormData를 어떻게 구성하는지, 멀티파트 요청을 어떻게 시뮬레이션하는지 혼란스러울 수 있습니다. 테스트를 제대로 작성하지 않으면 버그를 놓치고 프로덕션에서 문제가 발생합니다.
바로 이럴 때 필요한 것이 Supertest입니다. Supertest는 Express 애플리케이션을 테스트하는 가장 인기 있는 라이브러리로, 파일 업로드를 포함한 모든 HTTP 요청을 쉽게 테스트할 수 있게 해줍니다.
개요
간단히 말해서, Supertest는 HTTP 요청을 시뮬레이션하여 API 엔드포인트를 테스트하는 Node.js 라이브러리입니다. Jest나 Mocha 같은 테스트 프레임워크와 함께 사용하여 통합 테스트를 작성할 수 있습니다.
실무에서 자동화된 테스트는 필수입니다. CI/CD 파이프라인에서 코드가 푸시될 때마다 자동으로 테스트가 실행되어, 문제를 조기에 발견할 수 있습니다.
특히 파일 업로드처럼 복잡한 기능은 엣지 케이스가 많아서 자동화 테스트의 가치가 더욱 큽니다. 기존에는 실제 서버를 띄우고 HTTP 클라이언트로 요청을 보내야 했다면, 이제는 Supertest가 Express 앱을 메모리에서 실행하여 훨씬 빠르게 테스트할 수 있습니다.
데이터베이스나 외부 서비스를 모킹하면 순수한 단위 테스트처럼 빠르게 실행됩니다. Supertest의 핵심 특징은 다음과 같습니다: 1) 체이닝 가능한 API로 읽기 쉬운 테스트 작성, 2) attach() 메서드로 파일 첨부 간단히 구현, 3) expect()로 응답 검증, 4) Promise 기반으로 async/await 사용 가능.
이러한 특징들이 복잡한 시나리오도 명확하게 테스트할 수 있게 해줍니다.
코드 예제
const request = require('supertest');
const app = require('../app'); // Express 앱
const path = require('path');
const fs = require('fs');
describe('POST /upload', () => {
it('should upload a file successfully', async () => {
// 테스트용 파일 경로
const testFilePath = path.join(__dirname, 'fixtures', 'test-image.jpg');
const response = await request(app)
.post('/upload')
.attach('avatar', testFilePath) // 파일 첨부
.expect(201) // 상태 코드 검증
.expect('Content-Type', /json/); // 응답 타입 검증
// 응답 데이터 검증
expect(response.body).toHaveProperty('message');
expect(response.body.file).toHaveProperty('filename');
expect(response.body.file.originalname).toBe('test-image.jpg');
});
});
설명
이것이 하는 일: 이 테스트 코드는 실제 이미지 파일을 '/upload' 엔드포인트로 전송하고, 서버가 201 상태 코드를 반환하는지, 응답에 파일 정보가 올바르게 포함되어 있는지 자동으로 검증합니다. 첫 번째로, describe() 블록으로 테스트 스위트를 정의하고, it() 블록으로 개별 테스트 케이스를 작성합니다.
async/await를 사용하면 비동기 요청을 동기 코드처럼 읽기 쉽게 작성할 수 있습니다. testFilePath는 프로젝트 내에 미리 준비해둔 테스트 파일의 경로입니다.
실제 파일이 있어야 테스트가 작동하므로 test/fixtures/ 폴더에 작은 이미지 파일을 커밋해두는 것이 좋습니다. 두 번째로, request(app)으로 Supertest 체인을 시작합니다.
post('/upload')로 POST 메서드와 경로를 지정하고, attach('avatar', testFilePath)로 파일을 첨부합니다. 첫 번째 인자 'avatar'는 엔드포인트에서 기대하는 필드명과 일치해야 합니다.
Supertest는 내부적으로 multipart/form-data 요청을 자동으로 구성해줍니다. 세 번째로, expect() 메서드를 체이닝하여 여러 조건을 검증합니다.
expect(201)은 상태 코드가 201인지 확인하고, expect('Content-Type', /json/)은 응답이 JSON 형식인지 정규식으로 확인합니다. 이 검증들이 실패하면 테스트가 즉시 중단되고 에러를 보고합니다.
네 번째로, response.body에 접근하여 응답 데이터의 구조를 상세히 검증합니다. toHaveProperty()로 필수 필드의 존재를 확인하고, toBe()로 정확한 값을 비교합니다.
이렇게 하면 API 응답 형식이 변경되었을 때 즉시 테스트가 실패하여 문제를 발견할 수 있습니다. 여러분이 이 코드를 사용하면 파일 업로드 기능이 항상 정상 작동함을 보장할 수 있습니다.
코드 변경 후 npm test를 실행하면 몇 초 만에 모든 시나리오가 검증되어, 자신감 있게 배포할 수 있습니다. CI/CD 파이프라인에 통합하면 팀 전체의 코드 품질이 향상됩니다.
실전 팁
💡 테스트 파일은 작은 크기(10KB 이하)로 준비하세요. 테스트 속도가 빨라지고 Git 리포지토리 크기도 줄어듭니다.
💡 테스트가 실제 파일을 uploads/ 폴더에 저장한다면, afterEach() 훅에서 생성된 파일을 삭제하세요. 테스트가 반복 실행될 때 폴더가 파일로 가득 차는 것을 방지합니다.
💡 Buffer.from()으로 메모리에서 가짜 파일을 생성할 수도 있습니다: .attach('avatar', Buffer.from('fake file content'), 'test.txt'). 디스크 I/O 없이 더 빠른 테스트가 가능합니다.
💡 field() 메서드로 텍스트 필드도 함께 전송할 수 있습니다: .field('description', 'My avatar').attach('avatar', ...). 실제 사용 시나리오를 정확히 재현하세요.
💡 timeout 설정을 늘리세요. 파일 업로드 테스트는 일반 API 테스트보다 시간이 더 걸릴 수 있습니다. jest.setTimeout(10000)으로 10초 타임아웃을 설정하면 안정적입니다.
4. 파일 크기 제한 테스트
시작하며
여러분의 서버는 무한정 큰 파일을 받을 수 없습니다. 사용자가 실수로 또는 악의적으로 수 기가바이트짜리 파일을 업로드하면 서버 메모리가 고갈되고, 다른 사용자들에게도 영향을 미칩니다.
디스크 공간도 빠르게 소진되어 서비스 장애로 이어질 수 있습니다. 많은 개발자들이 파일 크기 제한을 설정하지만, 이것이 제대로 작동하는지 테스트하지 않습니다.
Multer 설정에서 limits.fileSize를 지정했다고 해서 끝이 아닙니다. 실제로 제한을 초과하는 파일을 업로드했을 때 적절한 에러 응답이 반환되는지, 에러 메시지가 명확한지 검증해야 합니다.
바로 이럴 때 필요한 것이 에러 케이스 테스트입니다. 성공 케이스만큼 실패 케이스도 중요합니다.
사용자가 파일 크기 제한을 초과했을 때 "500 Internal Server Error" 대신 "413 Payload Too Large"와 명확한 메시지를 받아야 좋은 사용자 경험을 제공할 수 있습니다.
개요
간단히 말해서, 파일 크기 제한 테스트는 Multer에 설정한 fileSize 제한이 올바르게 작동하는지 검증하는 테스트입니다. 제한을 초과하는 파일을 업로드하여 적절한 에러가 발생하는지 확인합니다.
실무에서 에러 처리는 성공 로직만큼 중요합니다. 특히 파일 업로드는 네트워크 상태, 파일 크기, 디스크 공간 등 다양한 이유로 실패할 수 있어, 각 실패 시나리오마다 적절한 응답을 제공해야 합니다.
사용자가 왜 실패했는지 이해할 수 있어야 문제를 해결할 수 있습니다. 기존에는 모든 에러를 동일하게 처리했다면, 이제는 MulterError의 종류에 따라 다른 상태 코드와 메시지를 반환할 수 있습니다.
LIMIT_FILE_SIZE, LIMIT_FILE_COUNT, LIMIT_UNEXPECTED_FILE 등 각각에 맞는 응답을 제공하면 프론트엔드에서 사용자에게 정확한 피드백을 줄 수 있습니다. 에러 테스트의 핵심 요소는 다음과 같습니다: 1) 제한을 초과하는 테스트 데이터 생성, 2) 예상되는 HTTP 상태 코드 검증 (보통 413 또는 400), 3) 에러 메시지의 명확성 확인, 4) 파일이 저장되지 않았는지 검증.
이러한 검증들이 시스템의 안정성과 보안을 보장해줍니다.
코드 예제
describe('POST /upload - file size limit', () => {
it('should reject files larger than 5MB', async () => {
// 5MB보다 큰 파일 생성 (6MB)
const largeBuffer = Buffer.alloc(6 * 1024 * 1024);
const response = await request(app)
.post('/upload')
.attach('avatar', largeBuffer, 'large-file.jpg')
.expect(413); // Payload Too Large
// 에러 메시지 검증
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('File too large');
// 파일이 저장되지 않았는지 확인
const uploadedFiles = fs.readdirSync('uploads/');
expect(uploadedFiles).not.toContain('large-file.jpg');
});
});
설명
이것이 하는 일: 이 테스트는 6MB 크기의 가짜 파일을 생성하여 5MB 제한이 있는 엔드포인트로 업로드하고, 서버가 413 상태 코드를 반환하는지, 적절한 에러 메시지를 제공하는지, 실제로 파일이 저장되지 않았는지 종합적으로 검증합니다. 첫 번째로, Buffer.alloc()을 사용하여 메모리에 6MB 크기의 버퍼를 생성합니다.
실제 파일을 만들지 않고도 큰 데이터를 시뮬레이션할 수 있어 테스트가 빠릅니다. 6 * 1024 * 1024 계산으로 정확히 6MB를 할당하는데, 이는 Multer 설정의 5MB 제한을 확실히 초과하는 크기입니다.
두 번째로, attach() 메서드의 세 번째 인자로 파일명을 지정합니다. Buffer를 전달할 때는 파일명을 명시적으로 제공해야 합니다.
expect(413)으로 "Payload Too Large" 상태 코드를 기대하는데, 이는 HTTP 표준에서 요청 본문이 너무 클 때 사용하는 코드입니다. 어떤 개발자는 400이나 422를 사용하기도 하지만, 413이 더 의미론적으로 정확합니다.
세 번째로, response.body.error를 검증하여 에러 메시지가 존재하고 유용한 정보를 담고 있는지 확인합니다. toContain('File too large')는 부분 문자열 매칭으로, 정확한 메시지 형식이 변경되어도 테스트가 깨지지 않습니다.
프론트엔드는 이 메시지를 사용자에게 보여줄 수 있습니다. 네 번째로, fs.readdirSync()로 uploads 폴더의 파일 목록을 읽어서 큰 파일이 저장되지 않았는지 확인합니다.
이것은 매우 중요한 검증입니다. 에러가 발생했는데도 파일이 저장된다면 디스크 공간이 낭비되고, 나중에 정리하기 어려워집니다.
Multer는 제한을 초과하면 자동으로 파일 저장을 중단하지만, 이를 명시적으로 테스트하는 것이 안전합니다. 여러분이 이 테스트를 작성하면 파일 크기 제한이 제대로 작동한다는 확신을 가질 수 있습니다.
악의적인 사용자나 실수로 인한 대용량 파일 업로드로부터 서버를 보호할 수 있고, 사용자에게 명확한 피드백을 제공할 수 있습니다.
실전 팁
💡 에러 핸들러 미들웨어를 반드시 구현하세요: app.use((err, req, res, next) => { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: 'File too large' }); } })
💡 에러 메시지에 최대 허용 크기를 포함하세요. "File too large. Maximum size is 5MB"처럼 구체적인 정보를 제공하면 사용자가 적절한 크기로 다시 시도할 수 있습니다.
💡 여러 크기 경계를 테스트하세요. 정확히 5MB, 4.9MB, 5.1MB 파일로 테스트하여 경계 조건을 확인하세요. off-by-one 에러를 잡을 수 있습니다.
💡 클라이언트 측에서도 파일 크기를 검증하세요. JavaScript로 file.size를 확인하면 불필요한 네트워크 전송을 줄일 수 있고 사용자 경험이 개선됩니다.
💡 프로덕션 환경에서는 로그를 남기세요. 크기 제한 초과 시도가 반복되면 악의적인 공격일 수 있으니 IP 주소와 함께 기록하여 모니터링하세요.
5. 파일 타입 검증 테스트
시작하며
여러분의 애플리케이션이 프로필 이미지만 받아야 하는데, 사용자가 실행 파일이나 스크립트를 업로드하면 어떻게 될까요? 최악의 경우 서버에서 이 파일이 실행되어 보안 사고로 이어질 수 있습니다.
또한 이미지 처리 로직이 PDF나 동영상 파일을 받으면 에러가 발생하여 서비스가 중단될 수 있습니다. 많은 개발자들이 파일 확장자만 확인하고 안심합니다.
하지만 악의적인 사용자는 virus.exe를 virus.jpg로 이름만 바꿔서 업로드할 수 있습니다. 진짜 파일 타입을 검증하려면 MIME 타입을 확인하고, 더 나아가 파일의 매직 넘버를 검사해야 합니다.
바로 이럴 때 필요한 것이 fileFilter 옵션입니다. Multer는 파일을 저장하기 전에 fileFilter 함수를 실행하여, 원하는 타입의 파일만 허용할 수 있습니다.
이 로직이 제대로 작동하는지 테스트하는 것은 보안과 안정성 측면에서 매우 중요합니다.
개요
간단히 말해서, 파일 타입 검증은 업로드되는 파일의 MIME 타입을 확인하여 허용된 타입만 받아들이는 보안 메커니즘입니다. Multer의 fileFilter 함수에서 구현하고 테스트로 검증합니다.
실무에서 파일 타입 제한은 필수입니다. 이미지 업로드 기능에서 스크립트 파일을 받으면 XSS 공격에 노출될 수 있고, PDF만 받아야 하는 문서 관리 시스템에서 다른 파일을 허용하면 사용자 혼란과 데이터 손상을 초래할 수 있습니다.
타입 검증은 첫 번째 방어선입니다. 기존에는 파일을 저장한 후 확장자를 확인했다면, 이제는 저장하기 전에 fileFilter에서 거부할 수 있습니다.
불필요한 디스크 I/O를 줄이고, 악의적인 파일이 서버에 저장되는 것 자체를 방지하여 보안이 강화됩니다. fileFilter의 핵심 구조는 다음과 같습니다: 1) file.mimetype으로 MIME 타입 접근, 2) 허용 목록(whitelist) 방식으로 안전한 타입만 허용, 3) 거부 시 false 반환 또는 에러 전달, 4) 콜백 함수로 결과 전달.
이러한 구조가 선언적이고 유지보수하기 쉬운 검증 로직을 만들어줍니다.
코드 예제
// fileFilter 함수 구현
const imageFilter = (req, file, cb) => {
// 허용된 MIME 타입 목록
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
if (allowedMimes.includes(file.mimetype)) {
// 파일 허용
cb(null, true);
} else {
// 파일 거부 - 에러 전달
cb(new Error('Invalid file type. Only JPEG, PNG and GIF allowed'));
}
};
// Multer에 적용
const upload = multer({
storage: storage,
fileFilter: imageFilter
});
// 테스트
it('should reject non-image files', async () => {
const response = await request(app)
.post('/upload')
.attach('avatar', Buffer.from('test'), 'document.pdf')
.expect(400);
expect(response.body.error).toContain('Invalid file type');
});
설명
이것이 하는 일: 이 코드는 업로드되는 모든 파일의 MIME 타입을 검사하여 JPEG, PNG, GIF만 허용하고, 다른 타입은 에러와 함께 거부하는 강력한 파일 타입 검증 시스템을 구축합니다. 테스트는 PDF 파일로 이 검증이 작동하는지 확인합니다.
첫 번째로, imageFilter 함수는 세 개의 매개변수를 받습니다. req는 Express 요청 객체, file은 업로드되는 파일 정보, cb는 결과를 전달하는 콜백 함수입니다.
allowedMimes 배열에 허용할 MIME 타입을 명시적으로 나열하는데, 이것이 화이트리스트 방식입니다. 블랙리스트 방식(금지할 타입 나열)보다 안전합니다.
두 번째로, includes() 메서드로 file.mimetype이 허용 목록에 있는지 확인합니다. file.mimetype은 브라우저가 전송한 Content-Type 헤더에서 추출됩니다.
예를 들어 JPEG 이미지는 'image/jpeg', PNG는 'image/png'입니다. 매치되면 cb(null, true)를 호출하여 "에러 없음, 파일 허용"을 의미합니다.
세 번째로, 타입이 매치되지 않으면 cb(new Error(...))로 에러를 전달합니다. 이 에러는 Multer에 의해 캐치되고, Express 에러 핸들러로 전달됩니다.
에러 메시지는 구체적으로 작성하여 사용자가 어떤 파일 타입을 업로드할 수 있는지 알 수 있게 합니다. "Invalid file type"만 쓰는 것보다 "Only JPEG, PNG and GIF allowed"가 훨씬 유용합니다.
네 번째로, 테스트에서는 'document.pdf'라는 파일명으로 데이터를 전송합니다. attach()의 세 번째 인자로 파일명을 지정하면 Supertest가 자동으로 적절한 MIME 타입을 추론합니다.
PDF는 'application/pdf' MIME 타입을 가지므로 allowedMimes에 없어서 거부됩니다. expect(400)으로 Bad Request 응답을 기대하고, 에러 메시지를 검증합니다.
여러분이 이 코드를 사용하면 원치 않는 파일 타입으로부터 애플리케이션을 보호할 수 있습니다. 이미지 처리 라이브러리가 안전하게 작동하고, 사용자가 명확한 피드백을 받으며, 보안 취약점이 크게 줄어듭니다.
각 기능에 맞는 파일 타입만 받아서 시스템의 일관성을 유지할 수 있습니다.
실전 팁
💡 MIME 타입만 믿지 말고 file-type 라이브러리로 파일의 매직 넘버를 검증하세요. 진짜 파일 타입을 확인하여 확장자 스푸핑을 방지할 수 있습니다.
💡 SVG 파일은 주의하세요. image/svg+xml은 이미지지만 JavaScript 코드를 포함할 수 있어 XSS 위험이 있습니다. SVG를 허용한다면 sanitize-svg로 정화하세요.
💡 카테고리별로 여러 fileFilter를 만드세요. imageFilter, documentFilter, videoFilter처럼 분리하면 각 엔드포인트에 맞는 검증을 적용할 수 있습니다.
💡 에러 메시지를 국제화하세요. 다국어 서비스라면 i18n 라이브러리로 사용자 언어에 맞는 메시지를 제공하세요.
💡 허용된 확장자 목록도 프론트엔드와 공유하세요. input accept 속성에 사용하면 사용자가 파일 선택 다이얼로그에서 올바른 타입만 볼 수 있어 UX가 개선됩니다.
6. 멀티파트 폼 데이터 테스트
시작하며
여러분이 게시글 작성 기능을 만든다고 생각해보세요. 사용자는 제목, 내용, 그리고 첨부 이미지를 함께 제출합니다.
파일만 업로드하는 것이 아니라, 텍스트 필드와 파일을 동시에 전송하는 것이 일반적인 실무 시나리오입니다. 이메일 작성, 상품 등록, 프로필 수정 등 대부분의 기능이 이런 형태입니다.
많은 개발자들이 파일 업로드와 텍스트 데이터를 별도의 요청으로 처리하려고 합니다. 먼저 텍스트를 JSON으로 보내고, 그 다음 파일을 업로드하는 식입니다.
하지만 이는 비효율적이고 복잡하며, 둘 중 하나가 실패했을 때 롤백이 어렵습니다. 단일 요청으로 모든 데이터를 처리하는 것이 더 나은 설계입니다.
바로 이럴 때 필요한 것이 multipart/form-data입니다. 이 인코딩 타입은 파일과 텍스트 필드를 하나의 요청에 담을 수 있게 해줍니다.
Multer는 자동으로 파일은 req.file로, 텍스트는 req.body로 분리하여 쉽게 접근할 수 있게 해줍니다.
개요
간단히 말해서, 멀티파트 폼 데이터는 파일과 일반 텍스트 필드를 하나의 HTTP 요청에 포함하는 인코딩 방식입니다. Multer가 파싱하면 각 데이터를 적절한 객체로 분리하여 접근할 수 있습니다.
실무에서 대부분의 폼은 복합적입니다. 단순히 파일만 업로드하는 경우는 드물고, 파일에 대한 메타데이터나 관련 정보를 함께 전송합니다.
예를 들어 이미지 업로드 시 제목, 설명, 태그를 함께 보내고, 이력서 업로드 시 이름, 이메일, 지원 직무를 함께 전송합니다. 기존에는 application/json으로는 파일을 보낼 수 없어서 두 번의 요청이 필요했다면, 이제는 multipart/form-data로 한 번에 처리할 수 있습니다.
네트워크 왕복을 줄이고, 데이터 일관성을 보장하며, 코드가 단순해집니다. 멀티파트 테스트의 핵심은 다음과 같습니다: 1) field() 메서드로 텍스트 필드 추가, 2) attach()로 파일 추가, 3) req.body와 req.file 모두 검증, 4) 서버에서 두 데이터를 함께 처리하는 로직 확인.
이러한 테스트가 실제 사용자 시나리오를 정확히 재현합니다.
코드 예제
describe('POST /upload - with form fields', () => {
it('should handle file and text fields together', async () => {
const testFile = path.join(__dirname, 'fixtures', 'avatar.jpg');
const response = await request(app)
.post('/upload')
.field('username', 'john_doe') // 텍스트 필드 추가
.field('description', 'My profile photo') // 또 다른 필드
.attach('avatar', testFile) // 파일 첨부
.expect(201);
// 응답에 모든 데이터가 포함되었는지 검증
expect(response.body.data.username).toBe('john_doe');
expect(response.body.data.description).toBe('My profile photo');
expect(response.body.file.filename).toBeDefined();
// 서버에서 파일과 텍스트를 함께 처리했는지 확인
expect(response.body.message).toContain('uploaded successfully');
});
});
설명
이것이 하는 일: 이 테스트는 실제 사용자가 프로필 사진과 함께 사용자명, 설명을 제출하는 시나리오를 재현합니다. 서버가 텍스트와 파일을 모두 올바르게 받아서 처리하는지, 응답에 모든 정보가 포함되는지 종합적으로 검증합니다.
첫 번째로, field() 메서드를 두 번 호출하여 'username'과 'description' 필드를 추가합니다. 순서는 중요하지 않지만, 일반적으로 field를 먼저 쓰고 attach를 나중에 쓰는 것이 읽기 좋습니다.
각 field() 호출은 FormData.append('key', 'value')와 동일한 동작을 합니다. 서버에서는 req.body.username, req.body.description으로 접근할 수 있습니다.
두 번째로, attach()로 파일을 추가합니다. 이전 예제들과 동일하지만, 이번에는 텍스트 필드와 함께 전송된다는 점이 다릅니다.
Supertest는 내부적으로 multipart/form-data 경계를 생성하고, 각 필드와 파일을 적절히 인코딩하여 하나의 요청 본문으로 만듭니다. 개발자는 이런 복잡한 처리를 신경 쓰지 않아도 됩니다.
세 번째로, 응답 검증에서 response.body.data.username과 response.body.file.filename을 모두 확인합니다. 이는 서버 코드가 텍스트 필드를 읽고 파일을 저장한 후, 두 정보를 통합하여 응답했음을 의미합니다.
실제 서버 코드에서는 req.body와 req.file을 함께 사용하여 데이터베이스에 저장하는 로직이 있을 것입니다. 네 번째로, message 필드를 확인하여 전체 프로세스가 성공했는지 확인합니다.
실무에서는 파일 업로드 후 데이터베이스에 레코드를 생성하고, 생성된 ID를 응답에 포함하는 경우가 많습니다. 이 테스트 패턴은 그런 복잡한 플로우도 검증할 수 있습니다.
여러분이 이 테스트를 작성하면 실제 사용자 경험을 정확히 재현할 수 있습니다. 프론트엔드에서 FormData를 구성하는 방법과 백엔드에서 파싱하는 방법을 명확히 이해하게 되고, 통합 과정에서 발생할 수 있는 문제를 조기에 발견할 수 있습니다.
API 문서화할 때도 이 테스트가 좋은 예제가 됩니다.
실전 팁
💡 field()는 문자열만 받습니다. 숫자나 불린을 보내려면 String()으로 변환하세요: .field('age', String(25)). 서버에서 Number(req.body.age)로 다시 변환합니다.
💡 배열을 보내려면 같은 key로 여러 번 field()를 호출하세요: .field('tags', 'javascript').field('tags', 'nodejs'). 서버에서 req.body.tags는 배열이 됩니다.
💡 JSON 객체를 보내야 한다면 JSON.stringify()로 직렬화하세요: .field('metadata', JSON.stringify({ key: 'value' })). 서버에서 JSON.parse(req.body.metadata)로 파싱합니다.
💡 여러 파일을 다른 필드명으로 보내려면 attach()를 여러 번 호출하세요: .attach('avatar', file1).attach('cover', file2). 서버에서 upload.fields()를 사용해야 합니다.
💡 실제 프론트엔드 코드와 테스트를 일치시키세요. React에서 FormData를 구성하는 코드를 작성했다면, 테스트도 동일한 필드명과 순서를 사용하여 일관성을 유지하세요.
7. 파일 저장 경로 검증
시작하며
여러분이 파일 업로드 기능을 구현했고, 테스트도 통과했다면 안심해도 될까요? API가 201 상태 코드를 반환했다고 해서 파일이 실제로 디스크에 저장되었다는 보장은 없습니다.
권한 문제, 디스크 공간 부족, 경로 오류 등으로 파일 쓰기가 실패할 수 있지만, 코드는 에러를 제대로 처리하지 못하고 성공 응답을 반환할 수도 있습니다. 많은 개발자들이 API 응답만 테스트하고 실제 파일 시스템 상태를 확인하지 않습니다.
하지만 진정한 통합 테스트는 시스템의 최종 상태를 검증해야 합니다. 파일 업로드의 목적은 파일을 저장하는 것이므로, 파일이 올바른 위치에 올바른 내용으로 저장되었는지 확인해야 합니다.
바로 이럴 때 필요한 것이 파일 시스템 검증입니다. Node.js의 fs 모듈을 사용하여 업로드 후 파일이 존재하는지, 크기가 맞는지, 내용이 올바른지 확인하는 테스트를 작성해야 합니다.
이것이 진정한 end-to-end 테스트입니다.
개요
간단히 말해서, 파일 저장 경로 검증은 업로드 후 실제 파일 시스템을 확인하여 파일이 올바르게 저장되었는지 검증하는 테스트 기법입니다. fs.existsSync(), fs.statSync(), fs.readFileSync() 등을 사용합니다.
실무에서 이런 검증은 프로덕션 버그를 예방합니다. 권한 설정이 잘못되어 있거나, 저장 경로가 상대 경로로 되어 있어서 예상치 못한 위치에 파일이 저장되거나, 파일명 생성 로직에 버그가 있어서 파일이 덮어써지는 등의 문제를 조기에 발견할 수 있습니다.
기존에는 API 레벨에서만 테스트했다면, 이제는 인프라 레벨까지 검증할 수 있습니다. 파일이 올바른 디렉토리에 있는지, 파일 이름이 예상한 패턴을 따르는지, 파일 내용이 손상되지 않았는지 모두 확인하여 완전한 신뢰성을 확보합니다.
파일 시스템 검증의 핵심 요소는 다음과 같습니다: 1) 응답에서 파일 경로 추출, 2) fs.existsSync()로 파일 존재 확인, 3) fs.statSync()로 메타데이터 검증, 4) 테스트 후 파일 정리. 이러한 단계들이 실제 저장이 성공했음을 보장해줍니다.
코드 예제
describe('POST /upload - file system verification', () => {
let uploadedFilePath;
it('should save file to disk correctly', async () => {
const testFile = path.join(__dirname, 'fixtures', 'test.jpg');
const originalSize = fs.statSync(testFile).size;
const response = await request(app)
.post('/upload')
.attach('avatar', testFile)
.expect(201);
// 응답에서 파일 경로 추출
uploadedFilePath = response.body.file.path;
// 파일이 실제로 존재하는지 확인
expect(fs.existsSync(uploadedFilePath)).toBe(true);
// 파일 크기가 원본과 동일한지 확인
const uploadedSize = fs.statSync(uploadedFilePath).size;
expect(uploadedSize).toBe(originalSize);
});
// 테스트 후 정리
afterEach(() => {
if (uploadedFilePath && fs.existsSync(uploadedFilePath)) {
fs.unlinkSync(uploadedFilePath);
}
});
});
설명
이것이 하는 일: 이 테스트는 API 응답뿐만 아니라 실제 파일 시스템을 검사하여 파일이 올바른 위치에 올바른 크기로 저장되었는지 확인하고, 테스트 후 자동으로 정리하여 다음 테스트에 영향을 주지 않도록 합니다. 첫 번째로, 테스트 시작 시 원본 파일의 크기를 fs.statSync()로 읽어둡니다.
size 속성은 바이트 단위 파일 크기를 반환합니다. 이 값을 나중에 업로드된 파일과 비교하여 데이터 손실이 없었는지 확인합니다.
uploadedFilePath 변수를 테스트 스코프 밖에 선언하여 afterEach 훅에서도 접근할 수 있게 합니다. 두 번째로, 파일 업로드 후 response.body.file.path에서 저장된 파일의 경로를 추출합니다.
이 경로는 Multer가 파일을 저장할 때 결정한 실제 경로입니다. filename과는 다르게 path는 'uploads/avatar-1234567890.jpg'처럼 디렉토리를 포함한 전체 경로입니다.
세 번째로, fs.existsSync()로 해당 경로에 파일이 실제로 존재하는지 확인합니다. 이 함수는 불린을 반환하므로 toBe(true)로 검증합니다.
만약 false라면 파일이 저장되지 않았거나 경로가 잘못되었다는 의미입니다. 그 다음 fs.statSync()로 저장된 파일의 메타데이터를 읽어서 크기를 비교합니다.
크기가 다르다면 업로드 과정에서 데이터 손상이 발생한 것입니다. 네 번째로, afterEach 훅에서 업로드된 파일을 삭제합니다.
fs.unlinkSync()는 파일을 동기적으로 삭제하는 함수입니다. 이 정리 단계가 없으면 테스트를 실행할 때마다 uploads 폴더에 파일이 쌓여서 디스크 공간을 낭비하고, 다른 테스트에 영향을 줄 수 있습니다.
existsSync()로 한 번 더 확인하여 파일이 없을 때 에러가 발생하지 않도록 합니다. 여러분이 이 테스트를 작성하면 파일 업로드의 전체 플로우를 완벽하게 검증할 수 있습니다.
API 레벨의 성공 응답만이 아니라 실제 저장까지 확인하여, 프로덕션에서 "업로드는 성공했는데 파일이 없다"는 버그를 예방할 수 있습니다. 파일 권한, 디스크 공간, 경로 설정 등의 인프라 문제도 조기에 발견할 수 있습니다.
실전 팁
💡 원본 파일과 업로드된 파일의 내용을 비교하려면 fs.readFileSync()로 Buffer를 읽어서 Buffer.compare()로 비교하세요. 완전히 동일한지 확인할 수 있습니다.
💡 beforeAll 훅에서 uploads 폴더를 비우고 시작하세요. 이전 테스트 실패로 남은 파일들이 테스트 결과에 영향을 주지 않도록 깨끗한 상태를 보장합니다.
💡 CI/CD 환경에서 파일 권한 문제가 발생할 수 있습니다. Docker 컨테이너에서는 uploads 폴더에 쓰기 권한이 있는지 확인하세요. chmod 777 uploads 또는 적절한 소유권 설정이 필요할 수 있습니다.
💡 대용량 파일 테스트 시 타임아웃을 늘리세요. 수십 MB 파일은 업로드와 검증에 시간이 걸리므로 jest.setTimeout(30000)으로 충분한 시간을 확보하세요.
💡 프로덕션 환경에서는 클라우드 스토리지를 사용하는 경우, 테스트 환경에서만 로컬 디스크를 사용하도록 설정을 분리하세요. NODE_ENV로 구분하여 테스트 속도를 높일 수 있습니다.
8. Mock 파일 시스템 사용
시작하며
여러분이 지금까지 작성한 테스트들은 실제 파일 시스템을 사용합니다. uploads 폴더에 진짜 파일을 저장하고, 읽고, 삭제합니다.
이것은 완전한 end-to-end 테스트로서 가치가 있지만, 몇 가지 문제점이 있습니다. 첫째, 디스크 I/O는 느립니다.
테스트가 많아지면 실행 시간이 급격히 늘어납니다. 둘째, 병렬 테스트 실행이 어렵습니다.
여러 테스트가 동시에 같은 폴더를 사용하면 충돌이 발생할 수 있습니다. 많은 팀들이 테스트가 느려지면 실행을 건너뛰게 되고, 결국 테스트의 가치가 떨어집니다.
"빠른 테스트 = 자주 실행하는 테스트 = 버그를 조기에 발견"이라는 공식이 성립합니다. 따라서 테스트 속도를 개선하는 것은 매우 중요합니다.
바로 이럴 때 필요한 것이 Mock 파일 시스템입니다. mock-fs 같은 라이브러리를 사용하면 실제 디스크 대신 메모리에 가상 파일 시스템을 만들 수 있습니다.
테스트는 훨씬 빠르게 실행되고, 격리도 완벽하게 유지되며, 정리 작업도 자동으로 처리됩니다.
개요
간단히 말해서, Mock 파일 시스템은 Node.js의 fs 모듈을 가로채서 메모리 기반 가상 파일 시스템으로 대체하는 테스트 기법입니다. memfs나 mock-fs 라이브러리를 사용하여 구현합니다.
실무에서 단위 테스트와 통합 테스트의 균형이 중요합니다. 모든 테스트가 실제 디스크를 사용할 필요는 없습니다.
핵심 플로우는 실제 파일 시스템으로 테스트하고, 세부 로직은 Mock으로 빠르게 테스트하는 것이 효율적입니다. 이렇게 하면 수백 개의 테스트도 몇 초 만에 실행할 수 있습니다.
기존에는 테스트마다 파일을 생성하고 삭제하는 오버헤드가 있었다면, 이제는 메모리에서 즉시 처리할 수 있습니다. 디스크 I/O를 제거하여 테스트 속도가 10배 이상 빨라질 수 있고, 테스트 간 격리가 완벽하여 플레이키 테스트(간헐적 실패)를 줄일 수 있습니다.
Mock 파일 시스템의 핵심 장점은 다음과 같습니다: 1) 디스크 I/O 제거로 속도 향상, 2) 테스트 격리 완벽 보장, 3) 정리 작업 자동화, 4) 디스크 공간 절약. 이러한 장점들이 대규모 테스트 스위트에서 빛을 발합니다.
코드 예제
const mockFs = require('mock-fs');
const request = require('supertest');
const app = require('../app');
describe('POST /upload - with mock filesystem', () => {
// 테스트 시작 전 Mock 파일 시스템 설정
beforeEach(() => {
mockFs({
'uploads': {}, // 빈 디렉토리 생성
'test': {
'fixtures': {
'test.jpg': Buffer.from([0xFF, 0xD8, 0xFF]) // 가짜 JPEG 헤더
}
}
});
});
// 테스트 후 실제 파일 시스템 복원
afterEach(() => {
mockFs.restore();
});
it('should upload file to mock filesystem', async () => {
const response = await request(app)
.post('/upload')
.attach('avatar', 'test/fixtures/test.jpg')
.expect(201);
// Mock 파일 시스템에서 파일 검증
const files = fs.readdirSync('uploads/');
expect(files.length).toBe(1);
});
});
설명
이것이 하는 일: 이 테스트는 실제 디스크 대신 메모리에 가상 파일 시스템을 만들어 파일 업로드를 시뮬레이션합니다. 테스트는 훨씬 빠르게 실행되고, 각 테스트가 완전히 격리되어 부작용이 없으며, 테스트 완료 후 자동으로 정리됩니다.
첫 번째로, beforeEach 훅에서 mockFs()를 호출하여 가상 파일 시스템을 초기화합니다. 전달하는 객체는 파일 시스템 구조를 정의합니다.
'uploads': {}는 빈 uploads 디렉토리를 만들고, 'test/fixtures/test.jpg'는 가짜 JPEG 파일을 생성합니다. Buffer.from([0xFF, 0xD8, 0xFF])는 JPEG 파일의 매직 넘버(시그니처)입니다.
실제 이미지 데이터가 아니어도 파일 타입 검증 테스트에는 충분합니다. 두 번째로, 이 설정 후 모든 fs 모듈 함수는 실제 디스크 대신 메모리를 사용합니다.
fs.writeFileSync(), fs.readFileSync(), fs.readdirSync() 등 모든 동기/비동기 함수가 가상 파일 시스템에서 작동합니다. Multer도 내부적으로 fs를 사용하므로 자동으로 메모리에 파일을 저장합니다.
세 번째로, attach('avatar', 'test/fixtures/test.jpg')로 가상 파일 시스템의 파일을 업로드합니다. 이 경로는 mockFs()에서 정의한 구조와 일치해야 합니다.
업로드가 성공하면 Multer는 uploads/ 디렉토리(역시 가상)에 파일을 저장합니다. fs.readdirSync('uploads/')로 저장된 파일을 확인할 수 있는데, 이 역시 메모리에서 실행되어 매우 빠릅니다.
네 번째로, afterEach 훅에서 mockFs.restore()를 호출하여 원래의 파일 시스템을 복원합니다. 이것을 빠뜨리면 다음 테스트들이 실제 파일 시스템에 접근할 수 없어서 실패합니다.
restore() 후에는 모든 것이 정상으로 돌아오고, 메모리의 가상 파일 시스템은 자동으로 정리됩니다. 여러분이 이 기법을 사용하면 테스트 스위트가 극적으로 빨라집니다.
100개의 파일 업로드 테스트가 10초에서 1초로 줄어들 수 있습니다. CI/CD 파이프라인에서 빠른 피드백을 받을 수 있고, 개발 중에도 자주 테스트를 실행할 수 있어 생산성이 향상됩니다.
단, 실제 파일 시스템과의 통합을 검증하는 몇 개의 E2E 테스트는 유지하는 것이 좋습니다.
실전 팁
💡 Mock과 실제 파일 시스템 테스트를 혼합하세요. 90%는 Mock으로 빠르게, 10%는 실제 디스크로 완전하게 테스트하는 것이 이상적입니다.
💡 mockFs()에서 node_modules를 포함하세요. 일부 라이브러리는 내부 파일을 읽는데, Mock으로 가려지면 에러가 발생합니다. 'node_modules': mockFs.load('node_modules')로 실제 파일을 로드할 수 있습니다.
💡 memfs는 더 현대적인 대안입니다. mock-fs는 오래되어 일부 Node.js 버전에서 호환성 문제가 있습니다. memfs는 actively maintained되고 더 안정적입니다.
💡 디버깅이 어렵다면 일시적으로 restore()를 주석 처리하고 console.log(mockFs.getMockRoot())로 가상 파일 시스템 구조를 확인하세요.
💡 watch 모드에서 조심하세요. Jest의 --watch 모드에서 mockFs를 사용하면 파일 변경 감지가 작동하지 않을 수 있습니다. 필요하다면 특정 테스트 파일만 watch에서 제외하세요.