[Flutter] S3 Presigned URL 발급받기 | AWS Lambda & API Gateway

플러터에서 Amazon S3 버킷에 이미지 업로드하는 기능 구현하기

현재 진행 중인 개인 프로젝트에서 BaaS로 슈퍼베이스를 사용하고 있는데 Storage의 경우에는 무료로 제공하는 용량이 너무 적었다. 그래서 요금이 비교적 저렴한 Amazon S3에 이미지를 업로드하고, 이미지 URL을 얻어서 슈퍼베이스의 데이터베이스에 저장하는 방법을 선택했다.

 

처음에는 Amazon S3 관련 플러터 패키지를 사용할 계획이었는데 관련 패키지들은 업데이트가 몇 년 전으로 너무 오래돼서 지금도 정상적으로 작동할지 걱정이 되었다.

 

그래서 검색하다가 알게 된 게 AWS Amplify였다. AWS Amplify도 파이어베이스나 슈퍼베이스 같이 Auth, Database, Storage 등의 풀스택 개발을 제공하는 서비스이다. 처음에는 아마존에서 공식으로 제공하는 amplify_flutter, amplify_storage_s3와 같은 플러터 패키지를 사용하는 방향으로 개발을 진행했다.

 

하지만 결과적으로 이 방법에는 실패했다. 가장 큰 이유는 Storage 서비스만 이용하려는데도 자꾸 Auth 관련 설정을 요구했기 때문이다. 그리고 Auth 이외에도 AWS Amplify 권한 설정, 백엔드 배포 등 CLI로 꽤나 복잡한 설정이 요구된다.

 

다른 방법을 찾아보다가 Presigned URL을 사용해서 Amazon S3 버킷에 이미지를 업로드할 수 있다는 것을 알게 됐다. 플러터에서 Presigned URL을 얻기 위해서는 AWS Lambda API Gateway를 사용해야 한다.

 

 

Lambda 함수 생성

함수 생성 및 코드 소스 배포

Lambda에서 함수를 생성한다. 기본 설정에 함수 이름만 지정해주면 된다.

 

함수 목록에서 새로 생성한 함수를 클릭해서 들어가면 코드 탭에서 코드 소스를 수정할 수 있다. 코드 소스에 수정사항이 생기면 Deploy 버튼이 활성화되는데, 배포까지 해야 원하는 Lambda 함수를 사용할 수 있다.

 

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

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

export const handler = async (event) => {
  const bucketName = 'YOUR BUCKET NAME'; // ✅수정
  const key = event.queryStringParameters.filename;
  const contentType = event.queryStringParameters.contentType;
  const expiresIn = 60; // URL expiration time in seconds
  
  if (!key || !contentType) {
    return {
      "statusCode": 400,
      "body": JSON.stringify({ error: 'Missing query parameter' }),
    };
  }

  const command = new PutObjectCommand({
    Bucket: bucketName,
    Key: key,
    ContentType: contentType, // ex. 'image/jpeg'
  });

  try {
    const url = await getSignedUrl(s3Client, command, { expiresIn });
    return {
      statusCode: 200,
      headers: {
        'Access-Control-Allow-Origin': '*', // Enable CORS
        'Access-Control-Allow-Credentials': true,
      },
      body: JSON.stringify({ url }),
    };
  } catch (err) {
    return {
      statusCode: 500,
      body: JSON.stringify(err),
    };
  }
};

코드 소스를 입력한다.

 

Cannot find package 'aws-sdk' imported from /var/task/index.mjs

처음에는 `import { S3 } from "@aws-sdk/client-lambda";`로 import 했었는데 `aws-sdk`를 찾을 수 없다는 에레메세지가 발생했다. AWS 공식문서를 참고한 결과 AWS SDK for JavaScript v3에서 import 형식이 변경된 거 같다. `import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';`로 수정 후 정상적으로 작동했다.

 

Invalid mapping expression parameter specified: method.request.querystring.filename

API를 호출할 때 쿼리 파라미터를 전달하는데 이 파라미터를 매핑할 수 없다는 에러가 계속 발생했다. 이건 API Gateway 부분에서 자세히 설명하겠지만 API Gateway에서 쿼리 파라미터를 따로 등록해야 한다.

 

 

API Gateway 생성

REST API 생성 (리소스 및 메서드 생성)

Amazon API Gateway에서 REST API를 생성한다. API 이름 항목만 입력하면 된다.

 

REST API에서 먼저 리소스를 생성한다.

 

리소스 이름만 작성해도 리소스 이름과 동일한 리소스 경로가 생성된다.

 

리소스 생성 후, 메서드 생성 버튼을 클릭한다.

 

메서드 유형은 GET을 선택하고, 통합 유형은 Lambda 함수를 선택한다. Lambda 함수는 돋보기 아이콘이 있는 인풋을 클릭해서 미리 생성했던 Lambda 함수를 선택하면 된다. (일단은 이렇게 3개의 항목만 입력하면 된다.)

 

URL 쿼리 문자열 파라미터 (queryStringParameters) 추가

// Lambda 함수에서 쿼리 파라미터 사용하기
const key = event.queryStringParameters.filename;
const contentType = event.queryStringParameters.contentType;

Lambda 함수에서 `filename`과 `contentType`을 쿼리 파라미터로 받기 때문에 위에서 생성한 API에 이 파라미터들을 등록해주어야 한다.

 

`/getPresignedUrl` 메서드에서 메서드 요청 탭의 편집 버튼을 클릭한다.

 

편집 페이지에서 URL 쿼리 문자열 파라미터에 사용하는 파라미터를 추가하고 저장한다.

 

이번에는 통합 요청 탭에서 편집 버튼을 클릭해서 URL 쿼리 문자열 파라미터를 추가한다.

 

다음에서 매핑됨은 `method.request.querystring.contentType`와 `method.request.querystring.filename`을 입력한다.

 

그리고 통합 요청 편집 페이지에서 입력해야 되는 항목이 하나 더 있다. 맨 밑에 매핑 템플릿이 있는데 콘텐츠 유형은 `application/json`으로, 템플릿 본문은 아래의 코드를 입력한다.

 

{
  "queryStringParameters": {
    "filename": "$input.params('filename')",
    "contentType": "$input.params('contentType')"
  }
}

 

만약에 쿼리 스트링 파라미터와 매핑 템플릿을 추가하지 않는다면 HTTP 요청 시 다음과 같은 에러 메세지가 발생한다.

Invalid mapping expression specified: Validation Result: warnings : [], errors : [Invalid mapping expression parameter specified: method.request.querystring.filename]

 

API 배포

최종적으로 API 배포까지 해줘야 업데이트 사항이 API에 반영된다. API에 수정사항이 있을 때마다 새로 배포를 해야 한다.

 

스테이지 이름은 임의로 작성하면 된다.

 

API 배포까지 하면 HTTP 요청할 때 사용할 URL을 얻을 수 있다.

 

 

플러터 애플리케이션에서 HTTP 요청

Future<void> _getPresignedUrl() async {
  try {
    // Generate a unique filename by appending a UUID
    final String uniqueFilename =
        '${const Uuid().v4()}.${_contentType.split('/').last}';
    final String filename = Uri.encodeComponent(uniqueFilename);

    final url = Uri.parse(
        'https://qznvjmzymb.execute-api.ap-northeast-2.amazonaws.com/dev/getPresignedUrl?filename=$filename&contentType=$_contentType');
    final response = await http.get(url); // AWS API에 Presigned URL 만드는 요청

    // decode the top-level JSON response
    final Map<String, dynamic> responseData = json.decode(response.body);

    if (responseData['statusCode'] == 200) {
      // decode the nested 'body' field which is also a JSON string
      final Map<String, dynamic> bodyData = json.decode(responseData['body']);
      final String presignedUrl = bodyData['url'];

      _objectKey = filename; // 이미지 URL 얻을 때 필요한 filename

      await _uploadImageToS3(presignedUrl);
      print('Succeeded to get presigned URL: $presignedUrl');
    } else {
      print('Failed to get presigned URL: ${response.body}');
    }
  } catch (e) {
    print('Failed to get presigned URL: $e');
  }
}

코드 구조를 간단하게 보여주기 위해 각종 예외 처리는 제외했다. API Gateway에서 생성된 엔드포인트도 `env` 파일에 저장했지만 예시 코드에서는 직관적으로 테스트로 생성한 URL을 그대로 작성했다. http 패키지를 사용해서 API 엔드포인트에 get 요청을 보낸다.

 

HTTP 요청할 때 쿼리 파라미터로 전달하는 `filename`은 UUID를 이용해서 생성했다. 원래는 사용자가 업로드한 이미지 파일 이름을 그대로 사용했었는데 만약에 각자의 사용자가 동일한 파일 이름을 가진 이미지를 업로드할 경우, 나중에 업로드한 파일이 먼저 업로드된 기존의 파일을 그대로 덮어써버리는 현상이 발생한다. 그래서 이미지 파일 이름이 겹치는 현상을 방지하기 위해 uuid 패키지를 사용해서 UUID를 생성했다.

 

Presigned URL 생성에 성공할 경우 status code `200`을 얻을 수 있다. 이제 이 Presigned URL을 사용해서 S3 버킷에 이미지를 업로드할 수 있다. 이 부분은 추가로 글을 작성했다.

[Flutter] Presigned URL 이용해서 S3 버킷에 이미지 업로드하기

 

 

참고 문서