[REST API] 10편. Post 첨부파일 관리 구현: 업로드, 다운로드, 게시글 연결 구조 설계
10편. Post 첨부파일 관리 구현: 업로드, 다운로드, 게시글 연결 구조 설계
📚 목차
1. 게시글 첨부파일 도메인 구조와 메타데이터 설계
2. Multipart 파일 업로드와 DB 메타데이터 저장 구현
3. 게시글 작성 이후 파일 연결, 목록 조회, 삭제 처리
4. 다운로드 구현과 스트림 응답 처리

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. 게시글 첨부파일 도메인 구조와 메타데이터 설계
게시글 기능을 구현하다 보면 제목, 본문, 작성자 같은 기본 데이터 외에도 첨부파일 기능이 필요해집니다.
처음에는 게시글을 저장할 때 파일도 함께 저장하면 단순해 보입니다.
하지만 실제 구현에서는 게시글 저장과 파일 업로드를 하나의 요청으로 묶는 방식보다, 파일 업로드와 게시글 연결을 분리하는 구조가 더 유연합니다.
특히 게시글 작성 화면에서는 다음과 같은 흐름이 자주 발생합니다.
▸ 사용자가 게시글 작성 중 파일을 먼저 업로드한다.
▸ 게시글 저장 전에 첨부파일을 추가하거나 삭제한다.
▸ 게시글 작성을 취소할 수도 있다.
▸ 게시글 저장 이후 첨부파일 목록을 따로 조회해야 한다.
이런 흐름을 고려하면 파일을 게시글 저장 요청 안에서 한 번에 처리하기보다, 파일을 먼저 업로드하고 나중에 게시글과 연결하는 방식이 더 다루기 쉽습니다.
🔷 이번 구현에서는 다음과 같은 흐름을 선택했습니다.
1. 사용자가 파일을 먼저 업로드한다.
2. 서버는 파일을 저장하고 DB에 파일 메타데이터를 등록한다.
3. 이때 아직 postId는 연결하지 않는다.
4. 게시글 저장이 완료되면 업로드된 fileIds를 postId와 연결한다.
즉, 파일은 먼저 임시 상태로 등록되고, 게시글이 확정된 뒤에 게시글과 연결됩니다.
이 구조의 핵심은 실제 파일 바이너리와 DB 메타데이터를 분리해서 관리하는 것입니다.
실제 파일
→ 서버 디스크 또는 외부 스토리지에 저장
DB
→ fileKey, fileName, contentType, fileSize, postId, userId 등 메타데이터 저장
🔷 이렇게 분리하면 다음과 같은 장점이 있습니다.
▸ DB에 대용량 바이너리를 직접 저장하지 않아도 된다.
▸ 파일 저장소를 로컬 디스크에서 S3 같은 외부 스토리지로 교체하기 쉽다.
▸ 게시글 저장 전에도 파일 업로드를 먼저 처리할 수 있다.
▸ 게시글 작성 취소, 파일 삭제, 재첨부 같은 흐름을 유연하게 다룰 수 있다.
🔷 DTO 역시 이 구조에 맞춰 분리했습니다.
파일 업로드 요청에는 userId가 필요하고, 파일을 게시글에 연결할 때는 userId, postId, fileIds가 필요합니다.
다운로드와 삭제 요청에서는 파일을 식별하기 위해 id와 fileKey를 함께 받습니다.
export const PostFileAttachParamsSchema = Type.Object(
{
userId: Type.Integer(),
postId: Type.Integer(),
},
{ $id: 'PostFileAttachParamsSchema', additionalProperties: false },
);
export const PostFilesBodySchema = Type.Object(
{
fileIds: Type.Array(Type.Integer()),
},
{ $id: 'PostFilesBody', additionalProperties: false },
);
여기서 fileIds 배열을 사용하는 이유는, 파일을 여러 개 업로드한 뒤 한 번에 게시글에 연결하기 위해서입니다.
✔️ 예를 들어 클라이언트는 파일 업로드 API를 통해 다음과 같은 응답을 먼저 받습니다.
{
"success": true,
"data": [
{
"id": 1,
"fileKey": "550e8400-e29b-41d4-a716-446655440000"
}
]
}
이후 게시글 저장이 완료되면 업로드된 파일 ID를 모아 게시글에 연결합니다.
{
"fileIds": [1, 2, 3]
}
정리하면, 이번 첨부파일 구조는 다음과 같은 책임 분리를 기준으로 설계했습니다.
Post
→ 게시글 자체의 제목, 본문, 작성자, 공개 여부 관리
PostFile
→ 파일 메타데이터, 실제 저장 파일 식별자, 게시글 연결 상태 관리
File System
→ 실제 파일 바이너리 저장
이렇게 분리해두면 Post 도메인이 커지더라도 파일 관리 책임을 독립적으로 유지할 수 있습니다.
2. Multipart 파일 업로드와 DB 메타데이터 저장 구현
파일 업로드는 일반 JSON 요청과 다르게 multipart/form-data를 사용합니다.
Fastify에서는 request.files()를 통해 업로드된 파일 스트림을 순회할 수 있습니다.
🔷업로드 흐름은 다음처럼 정리할 수 있습니다.
1. 업로드 디렉토리를 생성한다.
2. UUID 기반 fileKey를 만든다.
3. 파일 스트림을 디스크에 저장한다.
4. 저장하면서 파일 크기를 계산한다.
5. DB에 파일 메타데이터를 등록한다.
6. 중간 실패 시 디스크 파일을 정리한다.
이 구조는 현재 로컬 파일 시스템을 기준으로 구현되어 있습니다.
다만 실제 파일 저장 책임이 Service 계층에 모여 있기 때문에, 추후 S3 같은 외부 스토리지로 교체하더라도 업로드 저장 로직을 중심으로 변경할 수 있습니다.
🔷 Route: multipart 파일 순회
Route에서는 업로드된 파일을 하나씩 Controller로 넘기고, 처리 결과를 배열로 모아 응답합니다.
fastify.post(
'/:userId',
{
schema: {
tags: ['PostFile'],
params: PostFileUserIdSchema,
response: { 200: SuccessResponseSchema(Type.Array(PostFileBaseParamsSchema)) },
},
},
async (request, reply) => {
const files = request.files();
const uploadedFiles = [];
for await (const file of files) {
const uploaded = await controller.uploadFile(file, request.params);
uploadedFiles.push(uploaded);
}
return reply.code(200).send(success(uploadedFiles));
},
);
이 API의 응답은 다음과 같은 형태가 됩니다.
{
"success": true,
"data": [
{
"id": 1,
"fileKey": "550e8400-e29b-41d4-a716-446655440000"
}
]
}
여기서 id는 DB에 저장된 파일 메타데이터의 식별자이고, fileKey는 실제 저장된 파일을 찾기 위한 키입니다.
🔷 Service: UUID 기반 파일명 생성
Service 계층에서는 실제 파일 저장과 DB 메타데이터 등록을 함께 처리합니다.
const fileKey = randomUUID();
const ext = path.extname(file.filename);
const storedFileName = `${fileKey}${ext}`;
const filePath = path.join(env.UPLOAD_DIR, storedFileName);
파일명은 원본 파일명을 그대로 저장하지 않고, UUID + 원본 확장자 형태로 저장합니다.
예를 들어 사용자가 다음 파일을 업로드했다고 가정해 보겠습니다.
원본 파일명: profile.png
생성된 fileKey: 550e8400-e29b-41d4-a716-446655440000
실제 저장 파일명: 550e8400-e29b-41d4-a716-446655440000.png
이 방식은 다음 문제를 방지합니다.
▸ 동일한 파일명 충돌
▸ 한글/특수문자 파일명으로 인한 경로 문제
▸ 사용자가 업로드한 파일명을 통한 경로 조작 위험
🔷 Service: 스트림 기반 저장과 파일 크기 계산
파일 저장은 스트림 기반으로 처리합니다.
let totalSize = 0;
const counter = new Transform({
transform(chunk, enc, cb) {
totalSize += chunk.length;
cb(null, chunk);
},
});
await pipeline(file.file, counter, createWriteStream(filePath));
여기서 중요한 점은 파일 전체를 메모리에 올리지 않는다는 것입니다.
업로드 파일을 스트림으로 읽으면서 바로 디스크에 쓰고, 중간에 Transform 스트림을 끼워 넣어 파일 크기만 누적합니다. 대용량 파일 업로드에서는 이런 방식이 훨씬 안정적입니다
파일이 디스크에 정상적으로 저장되면, DB에는 메타데이터만 등록합니다.
const fileinfo = await this.fileRepository.createFileInfo({
fileKey: fileKey,
fileName: file.filename,
contentType: file.mimetype,
fileSize: BigInt(totalSize),
userId: userId.userId,
});
DB에는 실제 파일 바이너리가 아니라 다음 정보만 저장됩니다.
🔷 실패 처리: 고아 파일 방지
업로드 과정에서 주의해야 할 부분은 실패 처리입니다.
파일 저장은 성공했지만 DB 저장이 실패할 수 있습니다. 이 경우 디스크에 파일만 남고 DB에는 기록이 없는 고아 파일이 생길 수 있습니다.
그래서 Service에서는 오류가 발생하면 생성된 파일을 삭제합니다.
try {
await unlink(filePath);
} catch (unlinkErr) {
// 파일이 생성되기 전에 실패했거나 이미 제거된 경우는 무시
}
이 처리는 파일 시스템과 DB 상태가 최대한 어긋나지 않도록 하기 위한 보정 로직입니다.
🔷 Repository: postId 없이 파일 정보 먼저 등록
Repository에서는 postId 없이 파일 정보를 먼저 저장합니다.
이 시점에는 아직 게시글이 확정되지 않았을 수 있기 때문입니다.
createFileInfo(data: {
fileKey: string;
fileName: string;
contentType: string;
fileSize: bigint;
userId: number;
}) {
return this.prisma.postFile.create({
data: {
fileKey: data.fileKey,
fileName: data.fileName,
contentType: data.contentType,
fileSize: data.fileSize,
userId: data.userId,
},
select: {
id: true,
fileKey: true,
},
});
}
즉, 업로드 단계에서는 파일을 “게시글에 첨부된 파일”로 확정하지 않고, “사용자가 업로드한 파일” 상태로만 등록합니다.
3. 게시글 작성 이후 파일 연결, 목록 조회, 삭제 처리
파일 업로드가 완료되면 아직 게시글과 연결되지 않은 상태입니다.
게시글 작성이 완료된 뒤, 클라이언트는 업로드 응답으로 받은 fileIds를 사용해 파일을 게시글에 연결합니다.
PATCH /postfile/attach/:userId/:postId
요청 body는 다음과 같습니다.
{
"fileIds": [1, 2, 3]
}
🔷 파일 연결 Route
Route에서는 params와 body를 받아 Controller로 넘깁니다.
fastify.patch(
'/attach/:userId/:postId',
{
schema: {
tags: ['PostFile'],
params: PostFileAttachParamsSchema,
body: PostFilesBodySchema,
response: { 200: SuccessResponseSchema(Type.Object({ count: Type.Integer() })) },
},
},
async (request, reply) => {
const result = await controller.attachFiles(request.params, request.body);
return reply.code(200).send(success(result));
},
);
이 API는 파일 자체를 다시 업로드하지 않습니다.
이미 업로드되어 DB에 등록된 파일들의 postId를 채워 넣어 게시글과의 관계를 확정합니다.
🔷 Service: 게시글 존재 여부와 작성자 권한 확인
파일 업로드는 사용자 단위로 발생하지만, 파일 연결은 게시글 권한과 관련이 있습니다.
따라서 Service 계층에서는 먼저 게시글 존재 여부와 작성자 권한을 확인합니다.
const postOne = await this.postRepository.selectOne({
postId: params.postId,
includeDraft: true,
});
if (!postOne) {
throw new BusinessError(ErrorCode.NOT_FOUND, '게시글을 찾을 수 없습니다.', 404);
}
if (postOne.authorId !== params.userId) {
throw new BusinessError(ErrorCode.FORBIDDEN, '게시글에 파일을 첨부할 권한이 없습니다.', 403);
}
이 검증을 통해 다음 상황을 막을 수 있습니다.
▸ 존재하지 않는 게시글에 파일을 연결하는 경우
▸ 다른 사용자의 게시글에 내 파일을 연결하는 경우
▸ 게시글 작성자가 아닌 사용자가 첨부파일을 수정하는 경우
🔷 Repository: 트랜잭션 기반 파일 연결
권한 검증이 끝나면 Repository에서 실제 연결 작업을 수행합니다.
이때 트랜잭션을 사용합니다.
async attachFilesToPost(data: { userId: number; postId: number; fileIds: number[] }) {
return this.prisma.$transaction(async (tx) => {
const count = await tx.postFile.count({
where: {
postId: data.postId,
},
});
if (count + data.fileIds.length > this.fileLimit) {
throw new BusinessError(
ErrorCode.FILE_COUNT_EXCEEDED,
`파일은 최대 ${this.fileLimit}개까지 업로드할 수 있습니다.`,
400,
);
}
const result = await tx.postFile.updateMany({
where: {
id: { in: data.fileIds },
},
data: {
postId: data.postId,
},
});
return result;
});
}
여기서 트랜잭션을 사용하는 이유는 파일 개수 제한 검증과 연결 작업이 하나의 작업 단위로 묶여야 하기 때문입니다.
1. 현재 게시글에 연결된 파일 수를 조회한다.
2. 새로 연결할 파일 수를 더한다.
3. 최대 첨부파일 수를 초과하는지 확인한다.
4. 제한을 넘지 않을 때만 postId를 업데이트한다.
이 구조를 통해 게시글 하나에 연결할 수 있는 파일 개수를 제한할 수 있습니다.
🔷 첨부파일 목록 조회
게시글 상세 화면에서는 해당 게시글에 연결된 첨부파일 목록을 조회해야 합니다.
이를 위해 다음 API를 사용합니다.
GET /postfile/list/:postId
Route에서는 postId를 받아 Controller를 호출하고, 파일 목록 응답 스키마를 사용합니다.
fastify.get(
'/list/:postId',
{
schema: {
tags: ['PostFile'],
params: PostFilePostIdSchema,
response: { 200: SuccessResponseSchema(PostFileListResponseSchema) },
},
},
async (request, reply) => {
const result = await controller.getFileList(request.params);
return reply.code(200).send(success(result));
},
);
목록 조회는 단순히 파일 전체를 반환하는 것이 아니라, 게시글 화면에 필요한 메타데이터만 내려주는 방식이 적절합니다.
▸ 파일 ID
▸ postId
▸ fileKey
▸ 원본 파일명
▸ MIME 타입
▸ 파일 크기
▸ 다운로드 수
▸ 생성일
이렇게 응답을 구성하면 클라이언트는 게시글 상세 화면에서 첨부파일 목록을 보여주고, 각 파일의 다운로드 링크를 만들 수 있습니다.
🔷 삭제 처리: DB 메타데이터와 실제 파일 정리
삭제는 DB 메타데이터 삭제와 실제 파일 삭제를 함께 고려해야 합니다.
DELETE /postfile/:userId?id=1&fileKey=...
Service에서는 먼저 DB 레코드를 삭제합니다.
이때 userId, id, fileKey를 모두 조건으로 사용합니다.
deleteFileInfo(userId: number, data: { id: number; fileKey: string }) {
return this.prisma.postFile.delete({
where: {
id: data.id,
fileKey: data.fileKey,
userId: userId,
},
select: { id: true, fileName: true, fileKey: true },
});
}
DB 삭제가 성공하면 실제 파일 경로를 복원한 뒤 디스크 파일도 삭제합니다.
const ext = path.extname(deletedFile.fileName);
const filePath = path.join(env.UPLOAD_DIR, `${deletedFile.fileKey}${ext}`);
try {
await unlink(filePath);
} catch (err: any) {
// 이미 파일이 없거나 파일 시스템 오류가 나도 DB 삭제 성공 응답은 유지
}
여기서 중요한 설계 판단이 있습니다.
DB 삭제는 성공했지만 파일 삭제가 실패한 경우, 사용자 요청은 성공으로 처리합니다.
그 이유는 사용자의 관점에서는 이미 파일 메타데이터가 삭제되어 더 이상 접근할 수 없기 때문입니다.
실제 디스크에 남은 파일은 별도 배치 작업이나 운영 정책으로 정리할 수 있습니다
4. 파일 다운로드 구현과 스트림 응답 처리
파일 다운로드는 업로드보다 고려할 부분이 많습니다.
단순히 파일을 읽어서 내려주는 것뿐만 아니라, 다음 항목을 함께 처리해야 합니다
▸ 요청한 파일이 DB에 존재하는지 확인
▸ id와 fileKey가 일치하는지 확인
▸ 실제 디스크 파일이 존재하는지 확인
▸ Content-Type 설정
▸ Content-Disposition 설정
▸ Content-Length 설정
▸ 다운로드 횟수 증가
🔷 Route: 파일 스트림 응답
Route에서는 다운로드 요청을 다음처럼 정의합니다.
fastify.get(
'/download/:id',
{
schema: {
tags: ['PostFile'],
params: PostFileDownloadParamsSchema,
querystring: PostFileDownloadQuerySchema,
response: {
200: Type.Any(),
},
},
},
async (request, reply) => {
const { stream, meta } = await controller.downloadFile({
...request.params,
...request.query,
});
reply.header('Content-Type', meta.contentType);
reply.header(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(meta.fileName)}`,
);
reply.header('Content-Length', meta.fileSize);
return reply.send(stream);
},
);
다운로드 응답에서는 JSON이 아니라 파일 스트림을 내려줍니다.
그래서 일반적인 API 응답 스키마와 다르게 Type.Any()로 최소화하고, 실제 응답은 reply.header()와 reply.send(stream)으로 처리합니다.
특히 Content-Disposition 헤더에는 원본 파일명을 인코딩해서 넣습니다.
reply.header(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(meta.fileName)}`,
);
이 처리를 통해 브라우저는 응답을 일반 JSON이 아니라 다운로드 파일로 인식합니다.
🔷 Service: DB 메타데이터 확인
Service에서는 먼저 DB 메타데이터를 조회합니다.
const file = await this.fileRepository.getFileInfoById(data.id);
if (!file || file.fileKey !== data.fileKey) {
throw new BusinessError(ErrorCode.NOT_FOUND, '파일을 찾을 수 없습니다.', 404);
}
여기서 id만 확인하지 않고 fileKey까지 비교합니다.
id가 맞더라도 fileKey가 다르면 잘못된 요청으로 처리한다.
이 방식은 단순한 숫자 ID 추측만으로 파일에 접근하는 것을 방지하는 보조적인 식별 장치로 볼 수 있습니다
🔷 실제 파일 경로 복원과 존재 여부 확인
DB 메타데이터가 확인되면 실제 파일 경로를 복원합니다.
const ext = path.extname(file.fileName);
const filePath = path.join(env.UPLOAD_DIR, `${file.fileKey}${ext}`);
업로드 시점에 UUID + 원본 확장자 형태로 저장했기 때문에, 다운로드 시점에도 같은 규칙으로 파일 경로를 만들 수 있습니다.
그 다음 파일이 실제로 존재하고 읽을 수 있는지 확인합니다.
try {
await access(filePath, constants.R_OK);
} catch {
throw new BusinessError(ErrorCode.NOT_FOUND, '파일이 서버에 존재하지 않습니다.', 404);
}
이 검증이 필요한 이유는 DB에는 메타데이터가 있지만 실제 파일이 유실될 수 있기 때문입니다.
예를 들어 다음과 같은 상황이 있을 수 있습니다.
▸ 서버 파일 디렉토리가 수동으로 삭제된 경우
▸ 배포 과정에서 업로드 디렉토리가 초기화된 경우
▸ 파일 삭제 중 DB와 파일 시스템 상태가 어긋난 경우
정상 파일이면 읽기 스트림을 생성합니다.
const stream = createReadStream(filePath);
그리고 Route 계층에서 이 스트림을 클라이언트로 내려보냅니다.
🔷 다운로드 횟수 증가
파일 다운로드가 정상적으로 준비되면 다운로드 횟수를 증가시킵니다.
this.fileRepository.incrementDownloadCount(data.id).catch((err) => {
console.error(`Failed to increment download count for fileId ${data.id}:`, err);
});
여기서 다운로드 횟수 증가는 비동기로 처리합니다.
다운로드 카운트 증가가 실패하더라도 파일 다운로드 자체를 막을 필요는 없기 때문입니다.
Repository에서는 Prisma의 increment 연산을 사용합니다.
incrementDownloadCount(id: number) {
return this.prisma.postFile.update({
where: { id },
data: {
downloadCount: {
increment: 1,
},
},
select: { downloadCount: true },
});
}
이 방식은 현재 값을 먼저 조회한 뒤 애플리케이션에서 +1 하는 방식보다 안전합니다.
비효율적인 방식
1. 현재 downloadCount 조회
2. 애플리케이션에서 +1
3. 다시 update
현재 방식
1. DB에서 atomic increment 수행
동시에 여러 다운로드 요청이 들어와도 DB 레벨에서 증가 연산이 처리되기 때문에 카운트 누락 가능성을 줄일 수 있습니다.
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.