✏️기록하는 즐거움
article thumbnail
반응형

포트폴리오 사이트를 개발할 때 포트폴리오에 들어가는 내용을 마크다운 파일로 가져와 사이트에서 보여주고 싶었다.

기존에는 express 서버로 파일 시스템(fs) 모듈을 가져와서 사용했지만, 이는 로컬에서만 동작하고 있었고

배포를 위해서는 AWS EC2를 사용하는 방법이 존재했다.

하지만 나에게는 마크다운 파일을 가져오는 HTTP GET 메서드만 필요했고 EC2 배포는 부담스러운 선택지였다.

이 때 찾아낸 것이 AWS Lambda와 S3를 연동하여 사용하는 방법이다.

오늘은 프론트엔드 입장에서 처음 AWS를 맞이했을 때 당황하며 해결해낸 과정을 작성해보려 한다.

 

💡 AWS Lambda란?

AWS Lambda란 AWS에서 제공하는 서버리스 컴퓨팅 플랫폼이다. (서버리스 학습 TIL)

별도의 서버 구성 없이 코드로 작성된 Lambda 함수를 호출할 수 있기 때문에 서버 관리에 신경쓰지 않고 코드에만 집중할 수 있다는 큰 장점이 있다.

또한 다른 AWS 서비스와 연동이 용이하기 때문에 이미지를 Amazon S3에서 읽어와 람다 함수로 Resizing 하는 기능도 구현할 수 있다.

 

💠 Lambda의 장단점

장점

  1. 비용절감
    함수가 필요한 상황에서만 호출되기 때문에 항상 서버를 켜두고 있지 않아도 되므로 비용을 절약할 수 있다.
  2. 인프라 운영관리 부담 절감
    성능이나 보안 등 서버 자체의 관리는 AWS가 해주기 때문에 서버를 관리할 필요가 없어 운영에 대한 부담을 줄일 수 있다.

단점

  1. 리소스 제한
    하나의 함수가 한번 호출될 때 AWS에서 최대 10GB의 메모리까지 사용 가능하며, 처리 시간은 최대 15분이기 때문에 비교적 가벼운 동작일 때 사용가능하다.
    메모리 즉, 코드의 용량이 최대 10GB, 최대 실행 시간은 15분으로 제한된다.
  2. Stateless - 상태비저장
    람다는 함수가 호출되면 새로운 컨테이너를 띄우는 방식으로 별도의 상태를 저장하지 않는다.
    이는 Lambda 함수가 이벤트에 의해 트리거 될 때마다 완전히 새로운 환경에서 호출된다는 것을 의미한다.
    따라서 이전 이벤트의 실행 컨텍스트에 대한 액세스 권한이 없다보니, db connection을 유지하는 등의 기능을 수행하지 못한다.
  3. ColdStart
    람다는 리소스를 효율적으로 사용하기 위해 일정 시간동안 사용하지 않을 시 서버가 꺼지게 된다. 따라서 사용자에 요청에 따라 람다 함수가 실행되면 실행 환경을 구성하기 위해 대략 수초 ~ 수십초 정도의 지연 시간이 발생한다.
    이 상태를 Cold Start라고 하며, 상대적으로 느린 응답을 제공받는다. 

 

🤔 EC2 vs Lambda

EC2Lambda중 Lambda를 선택하게 된 가장 큰 계기는 서버 관리비용이었다.

EC2는 서버 설정 및 지속적인 관리가 필요하고, 사용하고 있는 인스턴스의 시간당 또는 초당 비용을 지불하게 된다.

반대로 Lambda는 함수를 한번 만들어 놓으면 이벤트가 발생할 때마다 AWS에서 함수를 호출시켜주는 서버리스 서비스이기 때문에 서버 관리가 필요 없고, 포트폴리오 사이트는 매번 서버가 돌아갈 필요가 없기 때문에 이벤트가 트리거되어 함수가 호출될 때마다 비용이 지불되는 Lambda가 적합하다고 생각했다.

 

특징 AWS Lambda Amazon EC2
비용 사용한 컴퓨팅 시간에 대해서만 비용이 지불된다. 코드가 실행되지 않을 때는 요금 부과 X 예약한 인스턴스에 대해 비용을 지불한다.
스케일링 자동 스케일링 수동 스케일링
서버 관리 X 서버 설정 및 관리가 필요하다.
예시 실시간 파일 처리(크기 조정, 필터 적용 등), 데이터 변환 등 웹 서버, 애플리케이션 호스팅 등 일관된 트래픽이 발생하는 경우

표 출처

 

🪣 Amazon S3 버킷 생성하기

Lambda 함수를 작성하기 전에 먼저 마크다운 파일을 저장할 S3 버킷을 생성한다.

Amazon Simple Sotrage Service(Amazon S3)는 AWS에서 제공하는 데이터를 버킷 내의 객체로 저장하는 객체 스토리지 서비스이다.

객체는 모든 메타 데이터이고, 버킷은 객체에 대한 컨테이너이다.

Amazon S3는 생성된 버킷에 데이터를 업로드하는 방식으로 동작한다. 각 객체는 객체에 대한 고유한 식별자(키)를 가지고 있다.

1. S3 버킷 생성하기

S3 서비스에 접속하여 버킷 만들기를 클릭한다.

 

버킷의 이름은 원하는 원하는 이름으로 설정하고, AWS 리전은 아시아 서울로 설정한다.

 

만약, 다른 AWS 사용자들이 해당 버킷에 자유롭게 접근하기를 원한다면 경우에 따라 버킷의 퍼블릭 액세스 차단을 해제할 수 있지만 나의 경우 그럴 필요가 없기 때문에 굳이 차단을 해제하지 않았다.

버킷 버전 관리의 경우 동일한 파일을 업로드 했을 때 업데이트 이전의 내용을 복원할 수 있게 해준다.

이 기능 또한 이전 버전을 기억할 필요는 없다고 생각하여 비활성화로 설정했다.

나머지 설정들도 특별한 경우가 아닌 이상 기본 값으로 설정하면 된다.

 

2. 파일 업로드

버킷을 생성하게 되면 버킷 목록에 생성한 버킷이 보이게 되고, 이름을 클릭하면 객체(데이터)를 업로드 할 수 있다.

업로드는 파일 혹은 폴더 형태로 직접 선택할 수도 있고, 드래그 앤 드롭으로도 업로드 가능하다.

 

🔼 아주아주 중요한 파일.txt 파일만 업로드 했을 경우
🔼 중요폴더/아주아주 중요한 파일.txt를 폴더 채로 업로드 했을 경우

 

⚙️ AWS Lambda 함수와 연결하기

버킷을 생성했다면 Lambda 함수를 생성해준다.

1. Lambda 함수 생성하기

Lambda 서비스에 접속하여 함수 생성을 클릭한다.

 

함수의 기본 정보를 입력하고 기존 역할을 사용할지, Lambda 권한을 가진 새 역할을 생성할지 선택해준다.

함수 이름 외에 정보들은 기본 값으로 설정해주었다.

 

2. Lambda 함수 작성하기

import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";

const REGION = 'ap-northeast-2';
const s3Client = new S3Client({ region: REGION });

export const handler = async (event) => {
  try {
    // API 쿼리 스트링 name 파라미터를 변수로 저장
    const dirname = event.queryStringParameters?.name;

    if(!dirname){
        return {
          statusCode: 400,
          body: JSON.stringify({ error: "디렉토리 경로가 제공되지 않았습니다." }),
        }
    }

    const BUCKET = 'nor-test-bucket'; // s3 버킷 이름
    const PREFIX = `${dirname}/`; // 접두사 필터

    // S3 버킷 및 경로 설정
    const commandListObjects = new ListObjectsV2Command({
      Bucket: BUCKET,
      Prefix: PREFIX
    });

    let isTruncated = true;
    let contentsKey = [];

    // S3 버킷에서 객체 목록 가져오기
    while (isTruncated) {
      const { Contents, IsTruncated, NextContinuationToken } = await s3Client.send(commandListObjects);

      if(!Contents || Contents.length === 0){
        return {
            statusCode: 500,
            body: JSON.stringify({ error: "가져올 파일이 존재하지 않습니다." })
        }
      }

      const contentsList = Contents.reduce((acc, c) => c.Key.includes('.md') ? [...acc, c.Key.split(PREFIX)[1]] : acc, []);

      contentsKey.push(...contentsList);
      isTruncated = IsTruncated;
      commandListObjects.input.ContinuationToken = NextContinuationToken;
    }

    const contentsData = [];

    const streamToString = (stream) =>
      new Promise((resolve, reject) => {
        const chunks = [];
        stream.on("data", (chunk) => chunks.push(chunk));
        stream.on("error", reject);
        stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    });

    // 폴더명의 각 파일을 찾아 배열에 저장
    for(const key of contentsKey){
      const commandGetObject = new GetObjectCommand({
        Bucket: BUCKET,
        Key: `${dirname}/${key}`
      });

      const { Body } = await s3Client.send(commandGetObject);

      if(!Body){
        return {
            statusCode: 500,
            body: JSON.stringify({ error: "가져올 파일이 존재하지 않습니다." })
        }
      }

      const content = await streamToString(Body);

      contentsData.push(content);
    };

     return {
      statusCode: 200,
      headers: {
      "Access-Control-Allow-Origin": "*", // Required for CORS support to work
      "Access-Control-Allow-Credentials": true, // Required for cookies, authorization headers with HTTPS
      },
      body: JSON.stringify({ data: contentsData }),
    };

  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({ error: error.message }),
    };
  }
};

위 코드는 S3 버킷의 구조가 folder1, folder2, folder3 안에 마크다운 파일들이 들어가 있을 때 쿼리 파라미터에 폴더명을 입력받고, 해당 폴더에서 마크다운 파일을 찾아 파일 내용을 data 키 값에 넣어 가져와주는 코드이다.

Lambda의 Node.js 런타임에는 javascript용 aws sdk가 포함되어 있는데, Node.js 16까지는 js용 aws sdk v2가 포함이 되어있지만, Node.js 18부터는 v3가 포함되어 있기 때문에 아래와 같이 v3를 import 해주어야 한다.

import { S3Client, ... } from "@aws-sdk/client-s3";

 

streamToString함수는 data.Body가 ReadableStream 타입으로 배열에 저장할 수 없어 String 타입으로 변경하기 위해 정의되었다. (자세한 트러블 슈팅)

 

✔️ ReadableStream이란?

여기서 ReadableStream은 브라우저 환경에서 제공되는 스트림 인터페이스로, 비동기적으로 데이터를 읽을 수 있는 스트림을 의미한다.

 

fetch('https://api.example.com/data')
  .then(response => response.body)
  .then(body => {
    const reader = body.getReader();

    function read() {
      return reader.read().then(({ value, done }) => {
        if (done) {
          console.log('Stream reading complete');
          return;
        }
        console.log('Read chunk:', value);
        // 여기에서 데이터를 처리하거나 저장할 수 있다.
        return read();
      });
    }

    return read();
  })
  .catch(error => console.error('Error:', error));

간단히 말하자면, fetch 함수를 통해 데이터를 가져올 때, 응답 데이터의 body를 ReadableStream으로 얻어 스트림 리더를 통해 비동기적으로 데이터를 읽을 수 있다. Stream은 데이터들을 효율적으로 처리하고 전송하기 위한 방법을 제공하며, Node.js에서느 stream 모듈을 통해 스트림을 다룰 수 있다.

 

++) AWS-SDK v3.357.0 이후에는 Body.transformToString('utf-8')로 간단하게 String 타입으로 변환할 수 있다.

 

 

3. 함수 배포하기

Lambda 함수를 작성했다면 Deploy 버튼을 눌러 배포를 해주어야 변경 사항이 저장되고, 테스트를 해볼 수 있다.

 

EX. S3 버킷의 폴더 구조가 folder1/test1.md, folder1/test2.md, folder2/test3.md이고, API 쿼리 스트링의 name 파라미터에 folder1을 전달한 경우 응답값
{
  "statusCode": 200,
  "headers": {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Credentials": true
  },
  "body": "{\"data\":[\"test1.md 파일입니다.\", \"test2.md 파일입니다.\"]}
}

위와 같이 응답값이 잘 오는지 테스트를 해볼 수도 있다.

 

🛣️ API Gateway 설정

API 호출로 마크다운 파일 응답을 받기 위해서 API Gateway를 생성하여 Lambda 함수에 트리거 할 수 있다.

1. API Gateway 생성하기

API의 이름을 설정해주고, API 생성을 누르면 위와 같이 화면이 넘어간다.

 

2. 리소스 및 메서드 생성

API 이름은 나중에 API를 호출할 때의 URL을 결정하기 때문에 잘 생각해보고 설정해주면 된다.

위의 같은 경우에는 https://[aws 주소]/[스테이지 이름]/[리소스 이름]?name=[파라미터 값]이 된다.

 

쿼리 스트링 파라미터를 전달하면 마크다운 파일의 내용을 응답값으로 전달해주는 API가 필요하기 때문에,

GET 메서드를 선택하고, 만들어준 Lambda 함수와 연결해주면 된다.

이 때 Lambda 프록시 통합을 꼭 체크해주어야 API Gateway가 파라미터를 포함한 이벤트의 세부 정보를 Lambda에 전달할 수 있다.

나는 이 부분을 지나쳐서 Lambda에 이벤트 객체에 값이 넘어오지 않아 꽤나 많은 시간을 에러로 보냈다ㅠㅠ

 

3. 쿼리 스트링 파라미터 추가

메서드까지 생성하고 나면 위와 같은 화면이 나오게 된다.

S3 버킷의 폴더명으로 사용하기 위해 쿼리 스트링 파라미터를 받으려면 메서드를 선택하고, 메서드 요청 설정을 편집한다.

 

name 파라미터를 추가해주고, 필수 여부나 캐싱 여부에 따라 체크 해준 뒤 저장한다.

 

4. API 배포

모든 설정을 마쳤다면, API를 배포한다.

스테이지 이름을 설정하고 배포하게 되면, https://[암호화 된 리소스명].execute-api.ap-northeast-2.amazonaws.com/test-api(스테이지명)/testResource(리소스명)?name=[파라미터 값] url로 API를 호출할 수 있다.

이후에도 API에 대해 변경 사항이 생기거나 Lambda 함수에 변경 사항이 생기면 무조건 배포를 다시 해주어야 적용된다.

 

🌟 간단한 예시 테스트

  • Lambda 소스 코드
export const handler = async (event) => {
   const dirname = event.queryStringParameters?.name;

  const response = {
    statusCode: 200,
    body: JSON.stringify(dirname + ' from Lambda!'),
  };
  return response;
};
  • API 호출 응답

 

⚠️ 만약, 이후에 CORS 에러가 발생한다면?

리소스 부분에서 CORS를 설정하고 해결이 된다면, API 요청 시 응답 헤더에 Access-Control-Allow-Origin : * 에 대한 정보가 추가되어 응답이 온다.

 

🧹 마무리

AWS를 처음 다뤄보다 보니 어디서부터 어떻게 시작해야할지, 어떤 설정이 잘못 되었는지 찾기가 힘들었다.

여러 시도를 걸쳐 완성해낼 수 있음에 매우 뿌듯했고, 못할 건 없다는 걸 깨달았다.

더 쉬운 길도 있었겠지만 AWS를 사용하여 헤매고 있는 나 같은 사람이 있다면 이 글이 도움이 되었으면 좋겠다😄

반응형
profile

✏️기록하는 즐거움

@nor_coding

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!