티스토리 뷰

728x90
반응형

목차

     

     

    지난 글까진 AWS와 이것저것을 이용한 CI/CD를 구현했다.

     

    이번 글에선 AWS에 손을 댄 김에, 이미지를 받아 리사이징 해서 썸네일을 만들거나,

     

    이미지를 AWS S3 버킷에 올리는 부분을 구현하겠다.

     

    시작하기 전에, S3에 버킷이나 폴더를 만드는 부분은 생략하도록 하겠다.

     

    지난 글에 보면 자세히 나와있기도 하고.

     

    참고로 나는 아래와 같은 구조로 버킷과 폴더를 만들었다.

     

    또한 이번 구현을 마치면 구성될 프로젝트 구조는 아래와 같다.

     

     

    Install Packages

     

    먼저 늘 그렇듯 패키지를 설치하자. 이미지 업로드 및 리사이징에 사용되는 패키지는 각각 'multer', 'sharp'를,

     

    그리고 AWS S3에 이미지를 업로드하기 위해 'aws-sdk'를 설치할 것이다.

    npm install multer sharp aws-sdk @aws-sdk/client-s3 uuid

    그리고 타입을 내장하고 있지 않은 multer를 위해 추가 타입 정의를 설치한다.

    npm install multer sharp aws-sdk @aws-sdk/client-s3 uuid

    여기서 aws-sdk와 @aws-sdk/client-s3를 별개로 설치한 이유는,

     

    aws-sdk가 곧 공식 지원이 끊기기는 하지만 multer와 같은 라이브러리에서 내부적으로 aws-sdk를 사용하고 있기 때문이다.

     

    만약 리사이징 등의 작업을 직접 구현할 수 있다면 aws-sdk를 설치할 필요는 없다.

     

    계속해서 구현 및 수정된 코드를 살펴보자.

     

    /src/models

     

    /Feed.ts

     

    가장 먼저 피드 모델에 이미지 업로드를 반영할 필드를 추가한다.

     

    이 글에서는 리사이징을 통해 본 파일과 썸네일 파일을 별개로 만들어 저장할 생각이기 때문에,

     

    총 두 개의 필드가 추가로 필요하다. 먼저 전체 코드를 보자.

    import mongoose, { Schema, Document } from 'mongoose';
    import { autoIncrement } from '../middleware/autoIncrement';
    
    interface IFeed extends Document {
      user: Schema.Types.ObjectId;
      userSeq: number;
      feedSeq: number;
      title: string;
      content: string;
      comments: Schema.Types.ObjectId[];
      likesCount: number;
      imageUrl: string | null;
      thumbnailUrl: string | null;
    }
    
    const feedSchema: Schema<IFeed> = new Schema(
      {
        user: {
          type: Schema.Types.ObjectId,
          ref: 'User',
        },
        userSeq: { type: Number, required: true },
        feedSeq: { type: Number, unique: true, required: true, default: 1 },
        title: { type: String, required: true },
        content: { type: String, required: true },
        comments: [
          {
            type: Schema.Types.ObjectId,
            ref: 'Comment',
          },
        ],
        likesCount: { type: Number, default: 0 },
        imageUrl: { type: String, default: null },
        thumbnailUrl: { type: String, default: null },
      },
      {
        timestamps: true,
      }
    );
    
    feedSchema.pre('save', autoIncrement('feedSeq'));
    
    const Feed = mongoose.model<IFeed>('Feed', feedSchema);
    
    export default Feed;

    바뀐 부분은 대략 아래와 같다. 먼저 인터페이스에서

      imageUrl: string | null;
      thumbnailUrl: string | null;

    그리고 스키마에서

        imageUrl: { type: String, default: null },
        thumbnailUrl: { type: String, default: null },

    를 추가하면 된다. 기본값과 타입에 null을 허용하는 이유는, 기존 이미지 삭제 시 값을 비우기 위함이다.

     

    /src/utils

     

    먼저 이미지 업로드 로직을 유틸에 구현해야 하는지 아니면 미들웨어에 구현해야 하는지 고민이 조금 됐다.

     

    해서 고민한 김에 유틸과 미들웨어에 대해서도 리마인드 할 겸, 정리하고 넘어간다.

     

    • 공통점

    유틸과 미들웨어는 둘 다 로직을 모듈화 해서 재사용성을 높이기 위해 사용된다.

     

    이는 코드의 중복을 줄기면서 유지보수를 용이하게 만들기 때문에, 크게 보면 그다지 구분되지 않는다.

     

    • 차이점 및 장단점
      유틸리티 미들웨어
    호출 위치 앱의 모든 부분에서 호출 가능 요청-응답 주기에서 요청과 응답 사이에 호출
    장점 개발자가 함수를 호출하는 시점에 로직 실행한다.
    이는 로직 실행의 유연성을 제공하며 재사용성을 높인다.
    자동 실행되기 때문에 명시적 호출이 필요 없다.
    체인으로 연결해 여러 미들웨어를 조정할 수 있다.
    단점 모든 라우트, 컨트롤러에서 명시적으로 호출해야 한다.
    자체적인 라이프사이클이 없어 자동실행/체이닝이 없다.
    특정 컨트롤러 / 메서드에 사용하려면 추가 로직이 필요하다.
    실행 시점의 제어 유연성이 떨어진다.

    코드 전체를 보는 눈이 좋다면 미들웨어로 구현하는 것도 나쁘지 않을 것 같지만,

     

    초보자인 데다 자바에서 넘어온 나에겐 일단 유틸로 구현해서 맛을 보는 게 맞겠다고 판단했다.

     

    그래서 이 글의 이미지 업로드 로직은 유틸에 구현!

     

    /imageUtils.ts

     

    코드가 대략 130줄 정도로 길기 때문에 우선 토막으로 모든 부분을 살펴보고, 제일 마지막에 전체 코드를 올리도록 하겠다.

    import multer from 'multer';
    import sharp from 'sharp';
    import { S3Client } from '@aws-sdk/client-s3';
    import { PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
    import { v4 as uuidv4 } from 'uuid';

    먼저 필요한 의존성을 가져온다.

    • multer: 노드에서 사용되는 미들웨어로서 multipart/form-data로 전송된 데이터를 처리하는 데 사용된다.
    • sharp: 리사이징, 회전, 잘라내기 등 이미지 처리를 위한 라이브러리
    • @aws-sdk/client-s3:S3 버킷과 상호작용하기 위한 AWS SDK의 일부
    • PutObjectCommand, DeleteObjectCommand: S3에 객체를 업로드하거나 삭제할 때 쓰이는 명령어 모델
    • uuidv4: 랜덤한 uuid를 생성하기 위한 함수
    const s3 = new S3Client({ region: process.env.AWS_REGION });
    
    const multerStorage = multer.memoryStorage();

    다음으로 Region을 이용해 S3Client 인스턴스를 새로 생성한다.

     

    이때 AWS-Region과 액세스 키, 시크릿 키는 .env 파일에 아래와 같이 설정할 수 있으며,

     

    위 형식에 맞춰 작성하면 액세스 키와 시크릿 키는 라이브러리에서 알아서 읽어온다.

     

    계속해서 다음 줄엔 multer의 메모리 스토리지 엔진을 생성하는 코드인데,

     

    이를 통해 파일 정보가 로컬 디스크가 아닌 메모리에 저장되고, 작업도 메모리에서 이루어지기 때문에 처리속도가 빠르다.

    const multerFilter = (_req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
      if (file.mimetype.startsWith('image/jpeg') || file.mimetype.startsWith('image/png')) {
        cb(null, true);
      } else {
        cb(new Error('jpg, jpeg, png 형식의 이미지 파일이 아닙니다.'));
      }
    };

    다음으로는 multer의 필터링 함수(multerFilter)를 이용해 이미지 파일인지 여부를 판단한다.

     

    적혀있듯이 jpg, jpeg, png가 아니면 업로드를 하지 못하도록 막아두었다.

     

    _req, file, cb라는 세 개의 매개변수를 가지며, 여기서 _req는 사용하지 않으나 함수 시그니처를 위해 추가되었음을 뜻한다.

     

    계속해서 file은 당연히 파일의 정보를, cb는 콜백 함수로써 해당 함수의 필터링 결과를 multer에 전달한다.

    export const upload = multer({
      storage: multerStorage,
      fileFilter: multerFilter,
    });

    이 부분에선 multer를 설정 및 초기화하고 upload라는 이름으로 내보내는 작업을 한다.

     

    storage는 파일 저장 위치를 결정하기 때문에 위에서 정의한 multerstorage를 사용하고,

     

    fileFilter는 파일 형식의 허용여부를 필터링하는 함수를 제공한다. 마찬가지로 multerFilter를 사용하도록 한다.

    // 최대 1024x1024로 이미지를 리사이징하되, 가로 세로 비율은 유지
    const resizeImage = async (file: Express.Multer.File): Promise<Buffer> => {
      const { width, height } = await sharp(file.buffer).metadata();
    
      if (!width || !height) {
        throw new Error('이미지 데이터를 가져올 수 없습니다.');
      }
    
      // 이미지의 가로와 세로 길이가 500 미만인 경우 에러 발생.
      if (width < 500 && height < 500) {
        throw new Error('이미지의 크기가 너무 작습니다. 크기가 500x500 이상인 이미지를 업로드해 주세요.');
      }
    
      // 이미지의 가로 또는 세로 길이가 1024 이상인 경우에만 크기를 조정.
      if (width > 1024 || height > 1024) {
        const resizedBuffer = await sharp(file.buffer)
          .resize(1024, 1024, {
            fit: sharp.fit.inside,
            withoutEnlargement: true,
          })
          .toBuffer();
    
        return resizedBuffer;
      }
    
      // 그 외의 경우 (가로 또는 세로 길이가 500 이상 1024 이하인 경우) 원본 이미지를 그대로 반환.
      return file.buffer;
    };

    이 메서드는 sharp를 이용해 특정 크기의 이미지가 들어왔을 때, 리사이징 하거나 요청을 거절하는 로직을 담고 있다.

     

    매개변수로는 multer에서 넘겨준 File 객체를 받으며,

     

    메타데이터를 가져올 수 없거나 가로세로 길이가 둘 다 500보다 작으면 에러를 발생시킨다.

     

    계속해서 가로 혹은 세로의 길이가 1024보다 큰 경우, 둘 중 긴 길이를 1024에 맞춰 리사이즈하도록 한다.

     

    마지막으로 어떤 조건에도 걸리지 않는다면 원본 이미지 버퍼를 그대로 반환한다.

    const extractThumbnail = async (buffer: Buffer): Promise<Buffer> => {
      const { width, height } = await sharp(buffer).metadata();
    
      if (!width || !height) {
        throw new Error('이미지 데이터를 가져올 수 없습니다.');
      }
    
      // 이미지의 가로 또는 세로 길이가 500 미만인 경우 에러 발생.
      if (width < 500 || height < 500) {
        throw new Error('이미지의 크기가 너무 작습니다. 크기가 500x500 이상인 이미지를 업로드해 주세요.');
      }
    
      const smallestDimension = Math.min(width, height);
      const left = (width - smallestDimension) / 2;
      const top = (height - smallestDimension) / 2;
    
      return await sharp(buffer)
        .extract({ left, top, width: smallestDimension, height: smallestDimension })
        .resize(500, 500)
        .toBuffer();
    };

    이어서 썸네일을 만드는 로직이다.

     

    위와 비슷하지만 조금 다른 형태로 로직이 진행된다.

     

    먼저 너무 작은 이미지거나 이미지가 아닌 경우 에러를 던지고,

     

    가로세로 길이 중 작은 쪽을 기준으로 정사각형을 만들어, 해당 부분을 500x500의 썸네일로 추출한다.

     

    마지막으로 생성한 이미지를 버퍼 형태로 반환한다.

    const uploadToS3 = async (buffer: Buffer, fileName: string, folder: string): Promise<string> => {
      const params = {
        Bucket: process.env.AWS_BUCKET_NAME,
        Key: `${folder}/${fileName}.png`,
        Body: buffer,
        ContentType: 'image/png',
      };
    
      try {
        const command = new PutObjectCommand(params);
        await s3.send(command);
        return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${folder}/${fileName}.png`;
      } catch (error) {
        console.error(error);
        throw new Error('S3에 업로드하는 데 실패했습니다.');
      }
    };

    다음은 주어진 이미지를 S3에 업로드하는 로직이다.

     

    매개변수로 이미지 데이터, 파일 이름, 폴더 이름을 받으며, 해당 값을 기준으로 업로드 한 뒤 해당 이미지의 URL을 반환한다.

     

    params 객체에서는 S3에 업로드할 파일의 정보를 설정하는데,

     

    여기선 버킷, Key(파일의 경로+이름), Body(파일의 내용, 여기서는 버퍼 형태의 이미지 데이터), contentType으로 구성된다.

     

    이어서 PutObjectCommand 객체를 생성해 s3.send()를 이용해 파일을 S3에 업로드한다.

     

    이 과정에서 발생하는 에러를 처리하기 위해 트라이-캐치 문으로 감싼다.

    export const deleteFromS3 = async (filePath: string): Promise<void> => {
      const fileName = filePath.split('/').pop();
    
      if (!fileName) {
        throw new Error('S3에서 삭제할 파일 이름을 추출하는데 실패하였습니다.');
      }
    
      const folder = filePath.includes('thumbnails') ? 'thumbnails' : 'feed-images';
    
      const params = {
        Bucket: `${process.env.AWS_BUCKET_NAME}/${folder}`,
        Key: fileName,
      };
    
      try {
        const command = new DeleteObjectCommand(params);
        await s3.send(command);
      } catch (error) {
        throw new Error('S3에서 삭제하는 데 실패했습니다.');
      }
    };

    거의 다 왔다. 이번엔 S3에서 이미지를 삭제하는 로직이다.

     

    먼저 '/'를 기준으로 파일 경로를 분리해, pop()으로 마지막 요소를 파일 이름을 추출한다.

     

    이어서 파일이 썸네일 이미지인지 확인하고,

     

    S3에서 파일을 삭제하기 위한 params 객체를 생성한다. 여기엔 버킷과 파일 이름만 담긴다.

     

    DeleteObjectCommand 객체를 생성하고, s3.send()를 통해 S3에서 파일을 삭제한다.

     

    각 과정에서 실패할 경우엔 걸맞은 에러를 던지도록 구성했다.

    export const resizeAndUploadToS3 = async (file: Express.Multer.File) => {
      const fileName = `feed-${uuidv4()}`;
    
      const resizedBuffer = await resizeImage(file);
      const resizedImageUrl = await uploadToS3(resizedBuffer, fileName, 'feed-images');
    
      const thumbnailBuffer = await extractThumbnail(resizedBuffer);
      const thumbnailImageUrl = await uploadToS3(thumbnailBuffer, `${fileName}-thumbnail`, 'thumbnails');
    
      return {
        resizedImageUrl,
        thumbnailImageUrl,
      };
    };

    마지막으로 여태 구현한 것들을 모두 모아 메서드로 만든 뒤 내보낸다.

     

    이 메서드는 보다시피 multer를 이용한 이미지를 처리, sharp를 이용한 이미지 리사이징 및

     

    S3 업로드를 통한 URL 반환까지를 간결하게 표시하고 있으며,

     

    기본 파일과 썸네일 파일에 대해 각각 로직을 진행한 뒤 두 개의 URL을 함께 보내고 있다.

     

    이미지 업로드 유틸은 이걸로 끝이다. 마지막으로 전체 코드를 공유한다.

     

    더보기
    import multer from 'multer';
    import sharp from 'sharp';
    import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
    import { v4 as uuidv4 } from 'uuid';
    
    const s3 = new S3Client({ region: process.env.AWS_REGION });
    
    const multerStorage = multer.memoryStorage();
    
    // 이미지 파일인지 체크
    // eslint-disable-next-line @typescript-eslint/no-explicit-any,
    const multerFilter = (_req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
      if (file.mimetype.startsWith('image/jpeg') || file.mimetype.startsWith('image/png')) {
        cb(null, true);
      } else {
        cb(new Error('jpg, jpeg, png 형식의 이미지 파일이 아닙니다.'));
      }
    };
    
    export const upload = multer({
      storage: multerStorage,
      fileFilter: multerFilter,
    });
    
    // 최대 1024x1024로 이미지를 리사이징하되, 가로 세로 비율은 유지
    const resizeImage = async (file: Express.Multer.File): Promise<Buffer> => {
      const { width, height } = await sharp(file.buffer).metadata();
    
      if (!width || !height) {
        throw new Error('이미지 데이터를 가져올 수 없습니다.');
      }
    
      // 이미지의 가로와 세로 길이가 500 미만인 경우 에러 발생.
      if (width < 500 && height < 500) {
        throw new Error('이미지의 크기가 너무 작습니다. 크기가 500x500 이상인 이미지를 업로드해 주세요.');
      }
    
      // 이미지의 가로 또는 세로 길이가 1024 이상인 경우에만 크기를 조정.
      if (width > 1024 || height > 1024) {
        const resizedBuffer = await sharp(file.buffer)
          .resize(1024, 1024, {
            fit: sharp.fit.inside,
            withoutEnlargement: true,
          })
          .toBuffer();
    
        return resizedBuffer;
      }
    
      // 그 외의 경우 (가로 또는 세로 길이가 500 이상 1024 이하인 경우) 원본 이미지를 그대로 반환.
      return file.buffer;
    };
    
    // 가장 큰 정사각형을 찾아서 500x500으로 리사이징
    const extractThumbnail = async (buffer: Buffer): Promise<Buffer> => {
      const { width, height } = await sharp(buffer).metadata();
    
      if (!width || !height) {
        throw new Error('이미지 데이터를 가져올 수 없습니다.');
      }
    
      // 이미지의 가로 또는 세로 길이가 500 미만인 경우 에러 발생.
      if (width < 500 || height < 500) {
        throw new Error('이미지의 크기가 너무 작습니다. 크기가 500x500 이상인 이미지를 업로드해 주세요.');
      }
    
      const smallestDimension = Math.min(width, height);
      const left = (width - smallestDimension) / 2;
      const top = (height - smallestDimension) / 2;
    
      return await sharp(buffer)
        .extract({ left, top, width: smallestDimension, height: smallestDimension })
        .resize(500, 500)
        .toBuffer();
    };
    
    // 이미지를 S3에 업로드
    const uploadToS3 = async (buffer: Buffer, fileName: string, folder: string): Promise<string> => {
      const params = {
        Bucket: process.env.AWS_BUCKET_NAME,
        Key: `${folder}/${fileName}.png`,
        Body: buffer,
        ContentType: 'image/png',
      };
    
      try {
        const command = new PutObjectCommand(params);
        await s3.send(command);
        return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${folder}/${fileName}.png`;
      } catch (error) {
        console.error(error);
        throw new Error('S3에 업로드하는 데 실패했습니다.');
      }
    };
    
    // 이미지를 S3에서 삭제
    export const deleteFromS3 = async (filePath: string): Promise<void> => {
      const fileName = filePath.split('/').pop();
    
      if (!fileName) {
        throw new Error('S3에서 삭제할 파일 이름을 추출하는데 실패하였습니다.');
      }
    
      const folder = filePath.includes('thumbnails') ? 'thumbnails' : 'feed-images';
    
      const params = {
        Bucket: `${process.env.AWS_BUCKET_NAME}/${folder}`,
        Key: fileName,
      };
    
      try {
        const command = new DeleteObjectCommand(params);
        await s3.send(command);
      } catch (error) {
        throw new Error('S3에서 삭제하는 데 실패했습니다.');
      }
    };
    
    export const resizeAndUploadToS3 = async (file: Express.Multer.File) => {
      const fileName = `feed-${uuidv4()}`;
    
      const resizedBuffer = await resizeImage(file);
      const resizedImageUrl = await uploadToS3(resizedBuffer, fileName, 'feed-images');
    
      const thumbnailBuffer = await extractThumbnail(resizedBuffer);
      const thumbnailImageUrl = await uploadToS3(thumbnailBuffer, `${fileName}-thumbnail`, 'thumbnails');
    
      return {
        resizedImageUrl,
        thumbnailImageUrl,
      };
    };

     

    /src/routes

     

    /feedRoutes.ts

     

    위에서 정의한 유틸을 사용하기 위해 라우트 모듈을 살짝 수정해 준다.

    import express from 'express';
    import { createFeed, getFeedByFeedSeq, getAllFeeds, updateFeed, deleteFeed } from '../controllers/feedController';
    import clickLikeContent from '../controllers/likeController';
    import { authMiddleware } from '../middleware/authentication';
    import { paginationMiddleware } from '../middleware/pagenation';
    import commentRouter from './commentRoutes';
    import { upload } from '../utils/imageUtils';
    
    const router = express.Router();
    
    router.post('/post', authMiddleware, upload.single('image'), createFeed);
    router.get('/:feedSeq', getFeedByFeedSeq);
    router.get('/', paginationMiddleware, getAllFeeds);
    router.patch('/:feedSeq/edit', authMiddleware, upload.single('image'), updateFeed);
    router.delete('/:feedSeq/delete', authMiddleware, deleteFeed);
    
    router.use('/:feedSeq/comments', commentRouter);
    
    router.patch('/:likeObjectSeq/like', authMiddleware, clickLikeContent);
    
    export default router;

    POST와 PATCH 요청 라우터에 upload.single('image')이 포함된 것을 확인할 수 있다.

     

    해당 함수는 multer 미들웨어를 이용해 전송된 파일을 위에 구현한 대로 처리하겠다는 것을 나타낸다.

     

    /src/controller

     

    /feedContoller.ts

     

    피드 컨트롤러 역시 당연하게도 변경이 필요하다.

     

    이 글에서는 피드 별로 사진을 하나씩만 올릴 수 있고, 사진을 수정하면 이전 사진은 S3에서 삭제되며

     

    피드를 삭제할 때도 마찬가지로 관련된 사진이 삭제되는, 아주 간단한 로직만 구현했다.

     

    변경된 부분을 토막씩 보자.

    import { Request, Response } from 'express';
    import asyncHandler from 'express-async-handler';
    import User from '../models/User';
    import Feed from '../models/Feed';
    import Comment from '../models/Comment';
    import sendErrorResponse from '../utils/sendErrorResponse';
    import { PaginatedRequest } from '../interface/PagenatedRequest';
    import { resizeAndUploadToS3, deleteFromS3 } from '../utils/imageUtils';

    마지막 줄에 위에서 구현한 메서드를 가져오는 것을 확인할 수 있다.

    const createFeed = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
      const user = await User.findOne({ userSeq: userSeq });
    
      if (req.file) {
        try {
          const { resizedImageUrl, thumbnailImageUrl } = await resizeAndUploadToS3(req.file);
    
          const newFeed = new Feed({
            ...req.body,
            user: user!._id,
            userSeq: userSeq,
            imageUrl: resizedImageUrl,
            thumbnailUrl: thumbnailImageUrl,
          });
          const savedFeed = await newFeed.save();
    
          res.status(201).json(savedFeed);
        } catch (error) {
          sendErrorResponse(res, 500, '이미지 업로드가 실패했습니다.');
          return;
        }
      } else {
        const newFeed = new Feed({ ...req.body, user: user!._id, userSeq: userSeq });
        const savedFeed = await newFeed.save();
    
        res.status(201).json(savedFeed);
      }
    });

    이어서 피드 생성 로직이다. if문과 try-catch 문을 사용해 이미지가 있는 경우 업로드 성공/실패 시 처리를 구현했다.

     

    여기서 if (req.file)이 바로 앞에서 본 upload.single('image')를 이용해 파일이 첨부되었는지 확인하는 부분이다.

     

    참고로 여기서 'image'라고 설정해 줬기 때문에, 이후의 입력에도 키값을 image라고 주어야 한다.

     

    위와 같이 구현한 뒤 포스트맨에서 사진과 게시글을 올리려면 아래와 같이 요청을 보내면 된다.

     

    계속해서 이번엔 피드 수정 로직을 확인하자.

    const updateFeed = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
    
      const feedSeq = Number(req.params.feedSeq);
      const feed = await Feed.findOne({ feedSeq: feedSeq });
    
      if (!feed) {
        sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
        return;
      }
    
      if (feed.userSeq !== userSeq) {
        sendErrorResponse(res, 401, 'Unauthorized');
        return;
      }
    
      if (req.file) {
        try {
          const { resizedImageUrl, thumbnailImageUrl } = await resizeAndUploadToS3(req.file);
    
          if (feed.imageUrl) {
            await deleteFromS3(feed.imageUrl);
          }
          if (feed.thumbnailUrl) {
            await deleteFromS3(feed.thumbnailUrl);
          }
    
          feed.imageUrl = resizedImageUrl;
          feed.thumbnailUrl = thumbnailImageUrl;
        } catch (error) {
          sendErrorResponse(res, 500, '이미지 업로드가 실패했습니다.');
          return;
        }
      } else if (req.body.removeImage === 'true') {
        // If there's a 'removeImage' property in the body of the request, the user wants to remove the image.
        try {
          if (feed.imageUrl) {
            await deleteFromS3(feed.imageUrl);
            feed.imageUrl = null;
          }
          if (feed.thumbnailUrl) {
            await deleteFromS3(feed.thumbnailUrl);
            feed.thumbnailUrl = null;
          }
        } catch (error) {
          sendErrorResponse(res, 500, '이미지 삭제가 실패했습니다.');
          return;
        }
      }
    
      feed.title = req.body.title;
      feed.content = req.body.content;
    
      const updatedFeed = await feed.save();
      res.status(200).json(updatedFeed);
    });

    코드가 길지만 추가된 부분은 피드 등록 부분과 비슷하다.

     

    다만 안전한 로직을 위해 이미지 업로드가 완료된 뒤 이전 사진을 지우도록 구현했다.

     

    만약 이미지 업로드가 실패하면 이전 사진이라도 남도록 하기 위해서이다.

     

    또한 필드를 하나 추가해 기존 이미지를 교체하는 것이 아니라 삭제할 수 있도록 구현했으며,

     

    이미지 파일이 없거나 req.body.removeImage === 'false'인 경우엔

     

    기존 제목과 내용만 변경할 수 있도록 했다.

     

    여기서 req.body.removeImage === 'false'처럼 문자열로 직접 입력하는 이유는

     

    multipart/form-data 형식에선 모든 필드가 기본적으로 문자열로 처리되기 때문인데,

     

    사진만 multipart/form-data로 보내고 나머지는 JSON으로 처리하는 방법도 존재한다.

    const deleteFeed = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
    
      const feedSeq = Number(req.params.feedSeq);
      const feed = await Feed.findOne({ feedSeq: feedSeq });
    
      if (!feed) {
        sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
        return;
      }
    
      if (feed.userSeq !== userSeq) {
        sendErrorResponse(res, 401, 'Unauthorized');
        return;
      }
    
      if (feed.imageUrl) {
        try {
          await deleteFromS3(feed.imageUrl);
        } catch (error) {
          sendErrorResponse(res, 500, '이미지 삭제에 실패했습니다.');
          return;
        }
      }
    
      if (feed.thumbnailUrl) {
        try {
          await deleteFromS3(feed.thumbnailUrl);
        } catch (error) {
          sendErrorResponse(res, 500, '썸네일 삭제에 실패했습니다.');
          return;
        }
      }
    
      await Comment.deleteMany({ feed: feed._id });
      await Feed.deleteOne({ feedSeq: feedSeq });
      res.status(200).json({ message: '삭제 완료' });
    });

    마지막으로 피드 삭제 로직이다.

     

    직관적으로 피드 삭제 시 해당 사진도 삭제되도록 구현했다.

     


    이렇게 해서 기존 로직에 S3를 사용한 이미지 업로드 로직을 구현해 보았다.

     

    어느새 그럴듯한 게시판이 만들어져 있어서 조금 뿌듯하지만 아직 구현해보고 싶은 기능이 많이 있다.

     

    계속 조금씩 구현하면서 노드에 대한 연습을 놓지 말아야겠다.

     

    One more thing

     

    위에서 잠깐 언급했듯이, 사진과 별개로 요청 바디는 JSON으로 보내는 방법이 존재한다.

     

    별로 귀찮은 방법은 아니고, 처음에 구현을 위와 같이 해서 고치는 게 귀찮은 정도였는데,

     

    그래도 boolean 값을 문자열로 보내는 건 아무래도 아닌 것 같아서 내용을 추가한다.

     

    실은 내용이라고 할 것도 없는 것이, 해당 부분의 입력을 JSON으로 파싱 하면 된다.

     

    무슨 말인지는 코드를 보면 명료하다.

    const createFeed = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
      const user = await User.findOne({ userSeq: userSeq });
     
      // JSON 파싱
      const data = JSON.parse(req.body.data);
    
      if (req.file) {
        try {
          const { resizedImageUrl, thumbnailImageUrl } = await resizeAndUploadToS3(req.file);
    
          const newFeed = new Feed({
            ...req.body,
            user: user!._id,
            userSeq: userSeq,
            imageUrl: resizedImageUrl,
            thumbnailUrl: thumbnailImageUrl,
          });
          const savedFeed = await newFeed.save();
    
          res.status(201).json(savedFeed);
        } catch (error) {
          sendErrorResponse(res, 500, '이미지 업로드가 실패했습니다.');
          return;
        }
      } else {
        const newFeed = new Feed({ ...req.body, user: user!._id, userSeq: userSeq });
        const savedFeed = await newFeed.save();
    
        res.status(201).json(savedFeed);
      }
    });

    주석으로 달아놓은 처리가 끝이다.

     

    위와 같이 파싱을 시켜놓으면

     

    이렇게 요청을 보낼 수 있게 된다.

     

    마지막으로 피드 수정 부분도 적용하면 아래와 같이 된다.

    const updateFeed = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
    
      const feedSeq = Number(req.params.feedSeq);
      const feed = await Feed.findOne({ feedSeq: feedSeq });
    
      if (!feed) {
        sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
        return;
      }
    
      if (feed.userSeq !== userSeq) {
        sendErrorResponse(res, 401, 'Unauthorized');
        return;
      }
    
      const data = JSON.parse(req.body.data);
    
      if (req.file) {
        try {
          const { resizedImageUrl, thumbnailImageUrl } = await resizeAndUploadToS3(req.file);
    
          if (feed.imageUrl) {
            await deleteFromS3(feed.imageUrl);
          }
          if (feed.thumbnailUrl) {
            await deleteFromS3(feed.thumbnailUrl);
          }
    
          feed.imageUrl = resizedImageUrl;
          feed.thumbnailUrl = thumbnailImageUrl;
        } catch (error) {
          sendErrorResponse(res, 500, '이미지 업로드가 실패했습니다.');
          return;
        }
      } else if (data.removeImage) {
        // If there's a 'removeImage' property in the body of the request, the user wants to remove the image.
        try {
          if (feed.imageUrl) {
            await deleteFromS3(feed.imageUrl);
            feed.imageUrl = null;
          }
          if (feed.thumbnailUrl) {
            await deleteFromS3(feed.thumbnailUrl);
            feed.thumbnailUrl = null;
          }
        } catch (error) {
          sendErrorResponse(res, 500, '이미지 삭제가 실패했습니다.');
          return;
        }
      }
    
      feed.title = data.title;
      feed.content = data.content;
    
      const updatedFeed = await feed.save();
      res.status(200).json(updatedFeed);
    });

    removeImage를 제대로 된 boolean 값으로 넣을 수 있게 된다.

     

    진짜 끝!

    반응형
    댓글
    공지사항
    최근에 올라온 글
    최근에 달린 댓글
    Total
    Today
    Yesterday
    링크
    «   2024/06   »
    1
    2 3 4 5 6 7 8
    9 10 11 12 13 14 15
    16 17 18 19 20 21 22
    23 24 25 26 27 28 29
    30
    글 보관함