fromis_9/backend/src/services/image.js
caadiq 7b227a6c56 refactor(backend): 로거 통일
- utils/logger.js 생성 (createLogger)
- 서비스 레이어: logger 유틸리티 사용
- 라우트 레이어: fastify.log 사용
- console.error/log → 구조화된 로깅으로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:20:32 +09:00

206 lines
6.1 KiB
JavaScript

import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp';
import config from '../config/index.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('S3');
// S3 클라이언트 생성
const s3Client = new S3Client({
endpoint: config.s3.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: config.s3.accessKey,
secretAccessKey: config.s3.secretKey,
},
forcePathStyle: true,
});
const BUCKET = config.s3.bucket;
const PUBLIC_URL = config.s3.publicUrl;
// 이미지 처리 설정
const { medium, thumb } = config.image;
/**
* 이미지를 3가지 해상도로 변환
*/
async function processImage(buffer) {
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
sharp(buffer).webp({ lossless: true }).toBuffer(),
sharp(buffer)
.resize(medium.width, null, { withoutEnlargement: true })
.webp({ quality: medium.quality })
.toBuffer(),
sharp(buffer)
.resize(thumb.width, null, { withoutEnlargement: true })
.webp({ quality: thumb.quality })
.toBuffer(),
]);
return { originalBuffer, mediumBuffer, thumbBuffer };
}
/**
* S3에 이미지 업로드
*/
async function uploadToS3(key, buffer, contentType = 'image/webp') {
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
}));
return `${PUBLIC_URL}/${BUCKET}/${key}`;
}
/**
* S3에서 이미지 삭제
*/
async function deleteFromS3(key) {
try {
await s3Client.send(new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
}));
} catch (err) {
logger.error(`삭제 오류 (${key}): ${err.message}`);
}
}
/**
* 멤버 프로필 이미지 업로드
* @param {string} name - 멤버 이름
* @param {Buffer} buffer - 이미지 버퍼
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
*/
export async function uploadMemberImage(name, buffer) {
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
const basePath = `member/${name}`;
const filename = `${name}.webp`;
// 병렬 업로드
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
]);
return { originalUrl, mediumUrl, thumbUrl };
}
/**
* 멤버 프로필 이미지 삭제
* @param {string} name - 멤버 이름
*/
export async function deleteMemberImage(name) {
const basePath = `member/${name}`;
const filename = `${name}.webp`;
const sizes = ['original', 'medium_800', 'thumb_400'];
await Promise.all(
sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`))
);
}
/**
* 앨범 커버 이미지 업로드
* @param {string} folderName - 앨범 폴더명
* @param {Buffer} buffer - 이미지 버퍼
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
*/
export async function uploadAlbumCover(folderName, buffer) {
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
const basePath = `album/${folderName}/cover`;
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
uploadToS3(`${basePath}/original/cover.webp`, originalBuffer),
uploadToS3(`${basePath}/medium_800/cover.webp`, mediumBuffer),
uploadToS3(`${basePath}/thumb_400/cover.webp`, thumbBuffer),
]);
return { originalUrl, mediumUrl, thumbUrl };
}
/**
* 앨범 커버 이미지 삭제
* @param {string} folderName - 앨범 폴더명
*/
export async function deleteAlbumCover(folderName) {
const basePath = `album/${folderName}/cover`;
const sizes = ['original', 'medium_800', 'thumb_400'];
await Promise.all(
sizes.map(size => deleteFromS3(`${basePath}/${size}/cover.webp`))
);
}
/**
* 앨범 사진 업로드 (컨셉포토 또는 티저)
* @param {string} folderName - 앨범 폴더명
* @param {string} subFolder - 'photo' 또는 'teaser'
* @param {string} filename - 파일명 (예: '01.webp')
* @param {Buffer} buffer - 이미지 버퍼
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string, metadata: object}>}
*/
export async function uploadAlbumPhoto(folderName, subFolder, filename, buffer) {
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
const metadata = await sharp(originalBuffer).metadata();
const basePath = `album/${folderName}/${subFolder}`;
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
]);
return {
originalUrl,
mediumUrl,
thumbUrl,
metadata: {
width: metadata.width,
height: metadata.height,
size: originalBuffer.length,
},
};
}
/**
* 앨범 사진 삭제
* @param {string} folderName - 앨범 폴더명
* @param {string} subFolder - 'photo' 또는 'teaser'
* @param {string} filename - 파일명
*/
export async function deleteAlbumPhoto(folderName, subFolder, filename) {
const basePath = `album/${folderName}/${subFolder}`;
const sizes = ['original', 'medium_800', 'thumb_400'];
await Promise.all(
sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`))
);
}
/**
* 앨범 비디오 업로드 (티저 전용)
* @param {string} folderName - 앨범 폴더명
* @param {string} filename - 파일명 (예: '01.mp4')
* @param {Buffer} buffer - 비디오 버퍼
* @returns {Promise<string>} - 비디오 URL
*/
export async function uploadAlbumVideo(folderName, filename, buffer) {
const key = `album/${folderName}/teaser/video/${filename}`;
return await uploadToS3(key, buffer, 'video/mp4');
}
/**
* 앨범 비디오 삭제
* @param {string} folderName - 앨범 폴더명
* @param {string} filename - 파일명
*/
export async function deleteAlbumVideo(folderName, filename) {
await deleteFromS3(`album/${folderName}/teaser/video/${filename}`);
}