[ReactJS] Express + CKeditor5 이미지 업로드 구현해보기 (4) (serverless 환경 최종 구현)

오진서·2022년 7월 24일
2

변경해야 할 사항

이전 포스팅
저번 포스팅에서 presignedURL을 발급받아 S3 객체에 접근하는 데까지 해보았습니다.

이번 시간에는 저번 로컬 서버에서 동작했던 업로드 방식을 새로 구성된 serverless환경에 적용해보는 시간을 가져보겠습니다.

일단 그 전에, 구현해야 될 변경 사항부터 간단히 짚고 넘어가겠습니다.

  1. 로컬 서버로 업로드 -> Lambda 함수로 부터 presignedURL을 발급받아 바로 S3로 업로드

  2. CKeditor 컴포넌트 언마운트 시 로컬에 저장된 임시 폴더 삭제 -> Lambda 함수 호출하여 S3 임시 폴더 삭제
  • 처음 생각했을 당시에는 S3 버킷 임시 폴더에 수명 주기 정책을 둬서 일정 주기로 삭제시키는 방향으로 생각했습니다. 하지만 좀 더 생각해보니 이전에 CKEditor를 사용하는 과정에서 글 작성 도중 바로 이미지가 업로드되는 제약 조건이 있었습니다. 만약 이 상황에서 특정 시간을 주기로 임시 폴더가 비워진다면 그 시간대에는 글 작성이 불가능해질 것입니다. 이러한 까닭에 기존의 방법대로 적용하게 되었습니다.
  1. 게시글 작성 시 임시 폴더에서 영구 폴더로 이동 후, 에디터 내용 이미지 소스 변경 -> Lambda 함수 호출하여 S3 임시 폴더에서 영구 폴더로 이동 후, 글 등록 API를 호출하여 에디터 내용 이미지 소스 변경

#1 클라이언트에서 presignedURL로 업로드 구현

업로드 API를 호출하는 CKEditor 컴포넌트의 어댑터 정의 함수부터 살펴보겠습니다.
만약 함수가 낯서신 분들은 초기에 설계한 (로컬 서버 업로드) 글에서 Editor.jsx 부분을 참고하시면 좋을 것 같습니다.

Editor.jsx

const customUploadAdapter = (loader) => {
        return {
            upload() {
                return new Promise((resolve, reject) => {
                    loader
                        .file
                        .then(async (file) => {
                            const filename = v4(); // (1)
                            const type = mime.extension(file.type); // (2)

                            // AWS-React CORS : 서로 Resource를 공유할 수 있게끔 설정
                            const bodyData = { // (3)
                                "objectKey": `temp/${userId}/${filename}.${type}`,
                                "s3Action": "putObject",
                                "contentType": file.type
                            }

                            const signedURL = await axios // (4)
                                .post(
                                    process.env.REACT_APP_GET_SIGNEDURL,
                                    bodyData
                                )
                                .then(body => {
                                    return body.data
                                });

                            await fetch(signedURL, { // (5)
                                method: "PUT",
                                body: file,
                                headers: {
                                    'Content-Type': file.type,
                                }
                            });

                            resolve({ // (5)
       default: `${process.env.REACT_APP_IMAGE_URL}/temp/${userId}/${filename}.${type}`
                            });

                        })
                })
            }
        }
    }

(1) 파일 이름을 랜덤하게 짓기위해(중복을 피하기 위해) uuid라는 라이브러리를 사용했습니다. 이전에는 nodeJS에서 파일 관련 로직을 작성했다면, 이제는 서버를 거치지 않고 바로 S3로 업로드하기 위해 클라이언트에 모든 로직을 작성해주었습니다.

(2) mime-types 모듈을 사용해서 파일 타입을 받습니다.

(3) presignedURL을 발급 받기 위한 bodydata를 정의해줍니다. 저희는 이미지 업로드를 해야되므로 s3Action으로 putObject를 지정해줍니다. objectKey에는 /가 붙으면 폴더로 인식됩니다. 임시 폴더는 처음 설계했던 것과 마찬가지로 사용자 ID를 기준으로 분류해줍니다.

(4) post 요청으로 '업로드를 위한' presignedURL 요청을 보냅니다. 요청이 성공하면 응답된 URL을 변수에 담도록 합니다. 요청 주소는 서버리스 배포 때 출력된 주소를 사용하시면 됩니다.

(5) 이제 발급 받은 signedURL로 S3에 업로드 해봅니다. 어찌된 영문인지 form-data 형식으로는 업로드가 거부되었고, binary 형식으로만 업로드에 성공했습니다. 원인을 알아보고자 온갖 레퍼런스를 다 뒤져봤지만 쥐털만큼도 안나와.. 할 수 없이 binary로 업로드하는 쪽을 선택하게 되었습니다.

그리고 Axios는 form-data만 패킷된다는 점이 또 한번 제 뒷목을 잡게되어,, fetch를 사용하였습니다.

(5) 마지막으로 이미지 소스 부분이 S3의 객체 URL을 가리키도록 설정해줍니다. 버킷 객체는 외부에서도 접근이 가능하도록 정책 설정을 퍼블릭으로 해주었습니다.

혹시나 CORS 이슈가 발생하신 분들은 바로 이전글을 참고하시면 됩니다.


#2 CKEditor 컴포넌트 언마운트 시 임시 폴더 삭제 구현

사용자가 글 작성 도중 나가거나 다른 페이지로 이동하면 저장된 임시 폴더도 삭제해주어야 됩니다. 해당 프로세스를 AWS 람다 함수를 통해 서버를 거치지 않고 즉시 S3로 요청되도록 구현해보겠습니다.

이전 글에서 작성했던 것과 동일하게 handler.js에 람다 함수를 정의해보도록 하겠습니다.

handler.js (serverless framework 제공)

module.exports.deleteObjects = async (event) => {
  let body = JSON.parse(event.body);
  let userId = body.userId;

  let prefix = `temp/${userId}/`;
  let params = {
    Bucket: config.BUCKET_NAME,
    Prefix: prefix
  };

  try{
    const listObjResponse = await s3.listObjectsV2(params).promise(); // (1)

    const response = await Promise.all( // (2)
      listObjResponse.Contents.map(async (obj) => {
            await s3.deleteObject({
              Bucket: config.BUCKET_NAME,
              Key: obj.Key,
            }).promise();
      })
    );

    return {
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': true,
      },
      body: JSON.stringify(response)
    }

  }catch(err){
    console.log(err);
    return{
      statusCode:500
    }
  }
}

(1) 버킷에 특정 폴더 내의 하위 집합을 불러오기 위해 listObjectsV2 메소드를 사용하였습니다. 여기서 해당 메소드를 수행하기 위해 IAM 정책에 s3:ListBucket 권한을 추가해주어야만 합니다. 매개변수(params)에는 버킷 이름과 키의 접두사(삭제할 임시 폴더)를 지정해줍니다.

(2) Promise.all을 사용하여 임시 폴더의 모든 객체에 접근하여 삭제 테스크들을 병렬적으로 처리해줍니다.

이외의 코드는 이전 글과 중복되어 생략하겠습니다.

Editor.jsx

클라이언트로 돌아와서 Editor 컴포넌트가 언마운트될 시 방금 작성한 람다 함수가 호출되도록 작성해보겠습니다.

  useEffect(() => {
    return async () => {
      console.log(userId);
      await axios.delete(process.env.REACT_APP_DELETE_S3_OBJECTS, {
        data:{
        userId
        }
      });
    }
  }, [])

요청 주소는 나중 서버리스를 배포한 뒤, 화면에 출력될 주소를 사용하시면 됩니다.


#3 게시글 작성 시 영구 폴더로 이동 구현

게시글 작성 시 S3의 임시 폴더 내의 객체들은 영구 폴더로 이동해야되고, 임시 폴더는 삭제되어야 합니다. 그런 다음 에디터의 이미지 태그의 소스는 S3의 영구 폴더를 가리키도록 수정작업 까지 해보도록 하겠습니다.

마찬가지로 객체 복사 후 삭제하는 기능을 람다 함수에 구현해보도록 하겠습니다.

handler.js

module.exports.moveObjects = async (event) => {
  let body = JSON.parse(event.body);
  let userId = body.userId;
  let postId = body.postId;

  let oldPrefix = `temp/${userId}/`;
  let newPrefix = `posts/${postId}/`;

  let params = {
    Bucket: config.BUCKET_NAME,
    Prefix: oldPrefix
  };

  try {
    const listObjResponse = await s3.listObjectsV2(params).promise(); // (1)

    const response = await Promise.all(
      listObjResponse.Contents.map(async (obj) => {
            let newKey = obj.Key.replace(oldPrefix, newPrefix); // (2)

            await s3.copyObject({ // (3)
              Bucket: config.BUCKET_NAME,
              CopySource: `/${config.BUCKET_NAME}/${obj.Key}`,
              Key: newKey
            }).promise();

            await s3.deleteObject({ // (4)
              Bucket: config.BUCKET_NAME,
              Key: obj.Key,
            }).promise();
      })
    );

    return {
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': true,
      },
      body: JSON.stringify(response)
    }

  } catch (error) {
    console.log(error);

    return{
      statueCode:500
    }
  }
  
}

(1) 삭제 메소드에서와 마찬가지로 임시 폴더 내의 객체들을 불러옵니다.

(2) 객체 키(객체 명)에 접근하여 기존 접두사(임시 폴더)를 새로운 접두사(영구 폴더)로 변경한 키를 정의합니다. 임시 폴더에는 사용자 ID를 기준으로 분류했다면, 영구 폴더에서는 글 ID를 기준으로 분류하였습니다.

(3) 임시 폴더에 있던 객체들을 새로 정의한 영구 폴더의 객체 키로 복사하는 작업을 수행합니다. 이때 CopySource 부분에 철자가 틀리지 않도록 주의하셔야 합니다. (저는 이것 때문에 반나절을 고통받았네요)

(4) 복사된 임시 폴더의 객체는 삭제해줍니다.

serverless.yml

이제 작성된 두 개의 람다 함수를 API Gateway와 연결시켜주기 위해 yml 파일에 정의 해줍니다.

functions:
  hello:
    handler: handler.generatePresignedUrl
    events:
      - http:
          path: presigned
          method: post
          cors: true

  copyObjects:
    handler: handler.moveObjects
    events:
      - http:
          path: copy
          method: put
          cors: true

  deleteObjects:
    handler: handler.deleteObjects
    events:
      - http:
          path: /
          cors: true
          method: delete

작성이 완료되면, sls deploy를 통해 배포 작업을 진행 해줍니다.

성공적으로 방금 작성한 2개의 람다 함수를 통한 API Gateway의 endpoint가 출력되었습니다.

Express 서버

앞에서 언급했듯이, 글 작성 시 서버에서 해줘야 되는 일이 한 가지 있습니다. 글 작성 도중에는 이미지가 S3의 임시 폴더를 가리키고 있는 상황입니다. 만약 글 작성이 완료 되면 S3의 임시 폴더는 존재하지 않고, 영구 폴더의 이미지를 가리키도록 에디터 내의 이미지 태그들을 수정해주어야 합니다.

router.post("/register", async (req, res) => {
  try {
    let newPost = await new Post(req.body).save();
    const {_id:postId, userId, desc} = newPost;

    const newDesc = desc.replaceAll(`temp/${userId}`, `posts/${postId}`);
    newPost = await Post.findOneAndUpdate({_id:postId}, {desc:newDesc});

    res.status(200).json(newPost);
  } catch (error) {
    console.log(error);
    res.status(500).json(error);
  }
});

이전에 작성한 [ReactJS] Express + CKeditor5 이미지 업로드 구현해보기 (2) (문제점 해결)의 Express 서버 구현부분에서 삭제한 내용 밖에 없어 설명은 생략하겠습니다.


마무리

이번에는 초기에 로컬 서버에서 업로드 하던 프로세스를 그대로 S3 서버리스 환경에 적용해보는 시간을 가져보았습니다.

이번 내용만 구현하는 데까지 거진 5일 정도 걸렸는데,, 적고나니 별 내용이 없는 것 같네요. 어찌나 막히는 것이 많던지 서버리스 배포만 100번 넘게 했던 것 같습니다. (특히 CORS 문제가 아님에도 CORS 오류만 나와서 디버깅 과정에서 정말 화병 돋았네요 ㅎㅎ)

제가 생각하기에, 저와 동일한 환경에서 구현하실 분들은 거의 없으실 것같고 단편적인 부분(서버리스 프레임워크, CKEditor)에서 찾고자하는 분들은 꽤 있을 것 같아 최대한 키워드 중심으로 작성하려고 노력했습니다.

글 작성 중 한 가지 아쉬운 점은 저도 배우는 입장이라 뭔가 명쾌하게 설명하고 싶어도 혹여 잘못된 정보를 전달하면 어떡하지하는 생각 때문에 글 작성 중 지우기만을 반복했네요.

이상으로 CKEditor로 이미지 업로드 하기 글 작성은 여기까지 하도록 하겠습니다. 읽어주셔서 감사합니다.~

참고

https://stackoverflow.com/questions/30959251/how-to-copy-move-all-objects-in-amazon-s3-from-one-prefix-to-other-using-the-aws
S3 폴더 이동 부분에서 참고하였습니다.

profile
안녕하세요

0개의 댓글