Skip to content

Commit 6fd8668

Browse files
authored
Merge pull request #149 from umc-commit/feat/115-s3-upload
[FEAT] 리뷰 이미지 업로드 및 삭제 S3 연동
2 parents 8ad88bd + 6172bb8 commit 6fd8668

File tree

3 files changed

+76
-64
lines changed

3 files changed

+76
-64
lines changed

src/review/controller/review.controller.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { stringifyWithBigInt } from "../../bigintJson.js";
33
import {
44
RequestIdRequiredError,
55
ReviewIdRequiredError,
6-
UserIdRequiredError
6+
UserIdRequiredError,
7+
ImageUploadFailedError
78
} from '../../common/errors/review.errors.js';
89
import {
910
ImageUploadResponseDto,
@@ -21,6 +22,10 @@ class ReviewController {
2122
*/
2223
async uploadImage(req, res, next) {
2324
try {
25+
if (!req.file) {
26+
throw new ImageUploadFailedError({ reason: '파일이 업로드되지 않았습니다' });
27+
}
28+
2429
const result = await reviewService.uploadImage(req.file);
2530

2631
// 응답 데이터를 DTO로 구조화

src/review/service/review.service.js

Lines changed: 50 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from 'path';
2-
import fs from 'fs';
32
import multer from 'multer';
3+
import { uploadToS3, deleteFromS3 } from '../../s3.upload.js';
44

55
// 관련 에러 클래스 import
66
import {
@@ -22,30 +22,14 @@ import { UserNotFoundError } from '../../common/errors/user.errors.js';
2222
// Repository import
2323
import reviewRepository from '../repository/review.repository.js';
2424

25-
// TODO: 추후 프로젝트 완성 시 AWS S3 연동으로 변경 예정
2625
class ReviewService {
2726

2827
/**
29-
* 파일 업로드를 위한 multer 설정
28+
* 파일 업로드를 위한 multer 설정 초기화
3029
*/
3130
constructor() {
32-
// 업로드 디렉토리 설정 (프로젝트 루트/uploads/reviews)
33-
this.uploadDir = path.join(process.cwd(), 'uploads', 'reviews');
34-
this.ensureUploadDir();
35-
36-
// multer 저장소 설정
37-
this.storage = multer.diskStorage({
38-
destination: (req, file, cb) => {
39-
cb(null, this.uploadDir);
40-
},
41-
filename: (req, file, cb) => {
42-
// 파일명 생성: review_현재시간_랜덤값.확장자
43-
const timestamp = Date.now();
44-
const random = Math.round(Math.random() * 1E9);
45-
const ext = path.extname(file.originalname);
46-
cb(null, `review_${timestamp}_${random}${ext}`);
47-
}
48-
});
31+
// 업로드 파일 메모리에 적재
32+
this.storage = multer.memoryStorage();
4933

5034
// 파일 필터 (이미지만 허용)
5135
this.fileFilter = (req, file, cb) => {
@@ -68,17 +52,6 @@ class ReviewService {
6852
});
6953
}
7054

71-
/**
72-
* 업로드 디렉토리가 없으면 생성
73-
* TODO: S3 연동 시 이 메서드는 불필요하므로 삭제 예정
74-
*/
75-
ensureUploadDir() {
76-
if (!fs.existsSync(this.uploadDir)) {
77-
fs.mkdirSync(this.uploadDir, { recursive: true });
78-
console.log(`업로드 디렉토리 생성: ${this.uploadDir}`);
79-
}
80-
}
81-
8255
/**
8356
* 이미지 업로드 처리
8457
*
@@ -87,7 +60,6 @@ class ReviewService {
8760
*
8861
* @example
8962
* const result = await reviewService.uploadImage(req.file);
90-
* // result: { image_url: "http://localhost:3000/uploads/reviews/review_123_456.jpg", file_size: 1024, file_type: "image/jpeg" }
9163
*/
9264
async uploadImage(file) {
9365
try {
@@ -98,52 +70,54 @@ class ReviewService {
9870

9971
// 2. 파일 크기 추가 검증
10072
if (file.size > 5 * 1024 * 1024) {
101-
// 업로드된 파일 삭제
102-
this.deleteFile(file.path);
10373
throw new FileSizeExceededError({ fileSize: file.size });
10474
}
10575

106-
// 3. 파일 URL 생성 (현재: 로컬 환경용)
107-
// TODO: S3 연동 시 S3 URL 생성 로직으로 변경 필요
108-
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
109-
const imageUrl = `${baseUrl}/uploads/reviews/${file.filename}`;
76+
// 3. 파일 확장자 기준 추가 검증
77+
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
78+
if (!['jpeg', 'jpg', 'png'].includes(ext)) {
79+
throw new UnsupportedImageFormatError({ fileType: file.mimetype });
80+
}
81+
82+
// 4. S3 업로드 (리뷰 이미지 전용으로 reviews/ 폴더에 저장)
83+
const imageUrl = await uploadToS3(
84+
file.buffer,
85+
'reviews',
86+
ext
87+
);
11088

111-
// 4. 성공 응답 반환
11289
return {
11390
image_url: imageUrl,
11491
file_size: file.size,
11592
file_type: file.mimetype
11693
};
11794

11895
} catch (error) {
119-
// 오류 발생 시 업로드된 파일 삭제
120-
if (file && file.path) {
121-
this.deleteFile(file.path);
122-
}
12396
throw error;
12497
}
12598
}
12699

127100
/**
128-
* 파일 삭제 헬퍼 메서드
129-
* TODO: S3 연동 시 S3 객체 삭제 로직으로 변경 필요
130-
* @param {string} filePath - 삭제할 파일 경로
101+
* S3 객체 삭제
102+
*
103+
* @param {string} imageUrl - 삭제할 이미지의 퍼블릭 URL
104+
* @returns {Promise<void>}
131105
*/
132-
deleteFile(filePath) {
106+
async deleteS3File(imageUrl) {
133107
try {
134-
if (fs.existsSync(filePath)) {
135-
fs.unlinkSync(filePath);
136-
console.log(`파일 삭제: ${filePath}`);
108+
if (imageUrl && imageUrl.includes('s3.amazonaws.com')) {
109+
await deleteFromS3(imageUrl);
110+
console.log(`S3 파일 삭제 완료: ${imageUrl}`);
137111
}
138112
} catch (error) {
139-
console.error('파일 삭제 실패:', error);
113+
console.error('S3 파일 삭제 실패:', error);
140114
}
141115
}
142116

143117

144118
/**
145119
* 리뷰 작성
146-
*
120+
*
147121
* @param {BigInt} requestId - 커미션 신청 ID
148122
* @param {BigInt} userId - 사용자 ID
149123
* @param {ReviewCreateDto} reviewDto - 검증된 DTO 객체
@@ -265,18 +239,24 @@ class ReviewService {
265239
// 5. 이미지 업데이트 (프론트에서 보낸 최종 이미지 목록으로 교체)
266240
// 프론트 로직: 기존 이미지 로드 > 사용자가 추가/삭제 > 최종 결과만 백으로 전송
267241
// 백엔드 로직: 기존 이미지 목록 전체 삭제 > 새로 받은 이미지들로 교체
268-
if (image_urls && image_urls.length > 0) {
269-
// 기존 이미지들 전체 삭제
270-
await reviewRepository.deleteAllReviewImages(reviewId);
242+
243+
// 5-1. 기존 이미지들 조회 후 S3에서 삭제
244+
const existingImages = await reviewRepository.getImagesByTarget('review', reviewId);
245+
for (const image of existingImages) {
246+
if (image.imageUrl) {
247+
await this.deleteS3File(image.imageUrl);
248+
}
249+
}
250+
251+
// 5-2. DB에서 기존 이미지 정보 삭제
252+
await reviewRepository.deleteAllReviewImages(reviewId);
271253

272-
// 새로운 이미지들 추가 (최대 5개)
254+
// 5-3. 새로운 이미지들 추가 (최대 5개)
255+
if (image_urls && image_urls.length > 0) {
273256
const imagesToSave = image_urls.slice(0, 5);
274257
for (const imageUrl of imagesToSave) {
275258
await reviewRepository.createImage('review', reviewId, imageUrl);
276259
}
277-
} else {
278-
// 이미지 배열이 비어있으면 모든 이미지 삭제 (사용자가 모든 이미지 제거)
279-
await reviewRepository.deleteAllReviewImages(reviewId);
280260
}
281261

282262
// 6. Controller로 반환할 데이터 구성
@@ -311,13 +291,21 @@ class ReviewService {
311291
throw new ReviewPermissionDeniedError({ userId, reviewId });
312292
}
313293

314-
// 3. 관련 이미지들 먼저 삭제
294+
// 3. 관련 이미지들 조회 후 S3에서 삭제
295+
const reviewImages = await reviewRepository.getImagesByTarget('review', reviewId);
296+
for (const image of reviewImages) {
297+
if (image.imageUrl) {
298+
await this.deleteS3File(image.imageUrl);
299+
}
300+
}
301+
302+
// 4. DB에서 이미지 정보 삭제
315303
await reviewRepository.deleteAllReviewImages(reviewId);
316304

317-
// 4. 리뷰 삭제
305+
// 5. 리뷰 삭제
318306
await reviewRepository.deleteReview(reviewId);
319307

320-
// 5. 성공 메시지 반환
308+
// 6. 성공 메시지 반환
321309
return {
322310
message: "리뷰가 성공적으로 삭제되었습니다."
323311
};

src/s3.upload.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
1+
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
22
import { v4 as uuidv4 } from "uuid";
33
import dotenv from 'dotenv';
44

@@ -27,4 +27,23 @@ export const uploadToS3 = async (buffer, folderName = "uploads", extension = "pn
2727
await s3.send(command);
2828

2929
return `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${filename}`;
30+
};
31+
32+
export const deleteFromS3 = async (imageUrl) => {
33+
try {
34+
// URL에서 키 추출
35+
const urlParts = imageUrl.split('/');
36+
const key = urlParts.slice(3).join('/'); // bucket-name.s3.region.amazonaws.com/ 이후 부분
37+
38+
const command = new DeleteObjectCommand({
39+
Bucket: process.env.AWS_S3_BUCKET_NAME,
40+
Key: key,
41+
});
42+
43+
await s3.send(command);
44+
return true;
45+
} catch (error) {
46+
console.error('S3 파일 삭제 실패:', error);
47+
throw error;
48+
}
3049
};

0 commit comments

Comments
 (0)