본문 바로가기
Back-end/nest.js

Nest.js : AWS S3 Presigned url 사용하기 - 1

by hsloth 2023. 12. 19.

 

Nest.js에서 AWS S3의 presigned url을 발급받아 사용해보려고 한다.

 

Presigned url 이란?

말 그대로 미리 서명된 url이다.

서버에서 AWS S3에 요청하여 presigned url을 발급받을 수 있다.

이렇게 발급받은 presigned url을 이용하여 클라이언트에서 직접 S3로 파일을 업로드할 수 있다.

 

Presigned url을 사용하는 이유

사용자가 프로필 이미지를 업로드하는 일반적인 로직을 생각해보자.

1. 사용자가 업로드할 이미지를 선택한다.

2. 클라이언트 -> 서버로 이미지 업로드 요청을 한다.

3. 서버 -> S3로 이미지를 업로드한다.

4. DB의 사용자 프로필 url을 업로드한 이미지의 url로 update한다.

 

이렇게 생각할 수 있다.

하지만, 이러한 방식은 중간에 서버를 매개로 파일을 전송하므로 서버에 부담이 갈 수밖에 없다.

그래서 서버의 부담을 줄이고자, 서버에서는 s3 측에 presigned url을 발급을 받고 클라이언트로 해당 presigned url을 전달만 해주면 된다.

 

 

그렇다면 Presigned url을 사용한 업로드 로직은 어떻게 될까?

 

간단하게 그림을 그려봤다. (퍼가실 분이 있으시다면 퍼가셔도 좋습니다.)

직접 그렸습니다. 퍼가실 분들은 퍼가세요.

 

1. 먼저 클라이언트에서 서버측에 presigned url을 달라고 요청한다.

2. 서버가 S3측에 presigned url 발급을 요청한다.

3. S3가 서버로 presigned url을 발급/전달 해준다.

4. 서버에서 클라이언트로 presigned url을 리턴(전달)한다.

5. 클라이언트는 발급받은 presigned url을 이용해 S3에 파일을 직접 업로드한다. (이 때, PUT 메소드를 사용해야 한다)

6. 업로드 완료 후, 클라이언트가 서버에게 업로드 완료를 알린다.

 

 

AWS S3 버킷 생성 및 정책 발급

은 알아서 해보자.

1. AWS S3에 들어가서 버킷을 생성

2. 버킷에 대한 정책을 발급받는다. (이 때, Actions에 GetObject와 PutObject를 둘 다 선택하도록 하자. 확실하지는 않지만, 우리는 PutObject를 사용할 예정이라. 만약 PutObject가 선택되어있지 않으면 에러가 발생할 수도 있다)

3. Access Key ID와 Secret Access Key를 발급받자.

 

이 정도만 해주면 된다.

구글링 해보자.


Nest.js에서 Presigned url 사용


여기서는 유저의 프로필 이미지를 업로드한다고 가정하고 코드를 작성할 예정이다.

먼저, 패키지를 설치하자.

패키지를 설치할 때, 두 가지 선택을 할 수 있다.

  • aws-sdk 패키지 사용
  • @aws-sdk 패키지 사용

aws-sdk 패키지의 경우 aws 자체에서 제공하는 패키지이다.

해당 패키지를 설치할 경우, AWS에 대한 모든 패키지를 다운받는다.

 

@aws-sdk 패키지의 경우 aws-sdk를 조금 더 세분화 해서 다운받을 수 있다.

// aws-sdk의 경우
npm i aws-sdk

// @aws-sdk의 경우
npm i @aws-sdk/client-s3

 

위와 같이 @aws-sdk를 사용하면 s3과 관련된 패키지만 가져올 수 있다.

 

나는 @aws-sdk를 사용해서 코드를 작성해보려고 한다.

따라서 다음과 같이 패키지를 설치해주자.

npm i @aws-sdk/client-s3
npm i @aws-sdk/s3-request-presigner

 

 

 

 

이제, Presigned url을 발급받는 코드를 작성해보자.

나는 무엇이든 모듈화하는 넣는 것을 좋아한다.

AwsS3Module을 만들고, 그 안에 AwsS3Service를 주입해서 사용해보자.

// aws-s3.module.ts
import { S3Client } from '@aws-sdk/client-s3';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AwsS3Service } from './aws-s3.service';

@Module({
  imports: [],
  controllers: [],
  providers: [
    {
      provide: 'S3_CLIENT',
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        return new S3Client({
          region: configService.get('AWS_REGION'),
          credentials: {
            accessKeyId: configService.get('AWS_ACCESS_KEY_ID')!,
            secretAccessKey: configService.get('AWS_SECRET_ACCESS_KEY')!,
          },
        });
      },
    },
    AwsS3Service,
  ],
  exports: [AwsS3Service],
})
export class AwsS3Module {}

 

S3_CLIENT를 provide를 통해 Inject하는 건, S3Client에 대한 인스턴스를 하나만 생성하기 위함이다.

만약 이렇게 코드를 작성하지 않거나 이해가 되지 않는다면, AwsS3Service에서 직접 S3Client를 생성해서 사용하도록 하자.

해당 코드는 아래쪽에 작성해두겠다.

 

그리고 .env 파일에 환경 변수를 준비하자.

AWS_ACCESS_KEY_ID=발급받은 ACCESS KEY
AWS_SECRET_ACCESS_KEY=발급받은 SECRET ACCESS KEY
AWS_REGION=S3가 있는 Region. ex) ap-northeast-2
AWS_BUCKET=사용할 버킷이름

 

 

그리고 AwsS3Service를 작성하자.

// aws-s3.service.ts
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GetPresignedUrlDto } from 'src/dtos/user/update-user-profile-image.dto';

@Injectable()
export class AwsS3Service {
  constructor(
    @Inject('S3_CLIENT')
    private readonly s3Client: S3Client,

    private readonly configService: ConfigService,
  ) {}

  // presignedUrl을 발급받는 함수
  // Dto에 담겨야할 것들 - fileName, contentType -> 선택사항이다. 솔직히 필요없다.
  async getSignedUrlForUserProfileImage(
    getPresignedUrlDto: GetPresignedUrlDto,
  ): Promise<string> {
    // 저장될 파일 이름 - 해당 presigned url로 업로드한 파일은 모두 해당 파일명으로 저장된다.
    // 같은 presigned url로 여러번 업로드해도 마지막에 등록한 파일 1개만 업로드된다.
    // fileName = `profile/image/${fileName}`; 과 같이 등록하면 버킷의 profile폴더의 image폴더 안에 파일이 저장된다.
    // 폴더가 없으면 만들어준다.
    const fileName = `${Date.now()}${getPresignedUrlDto.fileName}`;

    // Put. 즉, s3에 데이터를 집어넣는 작업에 대한 url 생성
    const command = new PutObjectCommand({
      Bucket: this.configService.get('AWS_BUCKET'),
      Key: fileName, // 저장될 파일 이름을 Key 속성으로 결정한다.
      // 이 밖에 contentType, Expires 등 속성이 있는데,
      // 나는 Expires 속성을 사용하니 SignatureDoesNotMatch 에러가 떴다. 이거 쓰지 말자.
    });

    // Expires 속성 대신, 여기다가 expiresIn 속성을 사용해주자.
    const signedUrl = await getSignedUrl(this.s3Client, command, {
      expiresIn: 60, // seconds
    });

    return signedUrl;
  }
}

 

Expires 속성을 잘못 건드리면, SignatureDoesNotMatch가 발생한다. 내가 이거때문에 3시간 날렸다... (Expires 속성 쓰지말자.)

 

 

먼저, presigned url을 만들기 위해서는 client와 command가 필요하다.

getSignedUrl(client, command, {option});

 

 

client는 AwsS3Module에서 이미 만들어서 Inject데코레이터를 통해 주입해주었다.

그러면 command만 만들면 된다.

우리는 이미지 업로드에 대한 presigned url이 필요하므로, PutObjectCommand 클래스를 사용한다.

const command = new PutObjectCommand({
  Bucket: this.configService.get('AWS_BUCKET'),
  Key: fileName,
});

Bucket과 Key만 지정해주면 된다. 나머지는 잘 모르면 건들지 말자.

Bucket은 사용할 버킷 이름을 넣어주면 되고, Key는 저장될 파일명을 넣어주면 된다.

이 때 발급된 presigned url은 여러번 업로드할 수 있으나, 마지막으로 업로드한 파일 1개만 덮어씌워서 저장된다.

 

GetPresignedUrlDto는 다음과 같다.

import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class GetPresignedUrlDto {
  @ApiProperty({
    example: 'abc.png',
    description: '파일명',
    required: true,
  })
  @IsString()
  @IsNotEmpty()
  fileName: string;
}

 

 

 

자, 이제 UserController와 UserService를 작성해보자.

먼저, UserModule에 AwsS3Module에 대한 의존성을 주입해주고

// user.module.ts
@Module({
  imports: [AwsS3Module],
  controllers: [UserController],
  providers: [UserService, UserRepository],
})
export class UserModule {}

 

 

컨트롤러를 설정해주자.

// user.controller.ts
  @Post('profile-image/presigned-url')
  async getPresignedUrl(
    @Body() body: GetPresignedUrlDto,
  ): Promise<string> {
    const signedUrl = await this.userService.getPresignedUrl(body);

    return signedUrl;
  }

필자는 JWT를 사용하기 때문에 원래 로직에는 UseGuards를 이용해주었다. 여기서는 로그인을 생각하지 않고 로직을 작성하고 있다. 로그인 로직이 필요하면, UseGuards를 사용해주도록하자.

 

 

 

그리고 서비스를 작성하자.

// user.service.ts
@Injectable()
export class UserService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly configService: ConfigService,
    private readonly awsS3Service: AwsS3Service,
  ) {}
  
  // ...

  async getPresignedUrl(
    getPresignedUrlDto: GetPresignedUrlDto,
    userId: number,
  ): Promise<string> {
    const signedUrl = await this.awsS3Service.getSignedUrlForUserProfileImage(
      getPresignedUrlDto,
    );

    return signedUrl;
  }
}

 

UserController에서 직접 AwsS3Service를 사용해도 되지만, 나는 외부 로직의 경우 Service에 주입해준다. 이유는... 나중에 분리/구분하기 편하라고!

 


(무시해도됩니다) Inject를 사용하지 않고, 직접 S3Client를 생성해서 사용하는 경우

실제로 테스트 해보진 않고 내 손을 거쳐 직접 고쳤기 때문에 에러가 발생할 수 있다.

하지만, 이정도는 어느정도 해결할 수 있을 거라고 생각합니다. 만약, 모르시겠다면 댓글 남겨주세요.

// aws-s3.module.ts
import { Module } from '@nestjs/common';
import { AwsS3Service } from './aws-s3.service';

@Module({
  imports: [],
  controllers: [],
  providers: [AwsS3Service],
  exports: [AwsS3Service],
})
export class AwsS3Module {}



// aws-s3.service.ts
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GetPresignedUrlDto } from 'src/dtos/user/update-user-profile-image.dto';

@Injectable()
export class AwsS3Service {
  constructor(
    private readonly configService: ConfigService,
  ) {}

  // Dto에 담겨야할 것들 - fileName, contentType
  async getSignedUrlForUserProfileImage(
    getPresignedUrlDto: GetPresignedUrlDto,
  ): Promise<string> {
    const s3Client = new S3Client({
      region: this.configService.get('AWS_REGION'),
      credentials: {
        accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID')!,
        secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY')!,
      },
    });
  
    // 저장될 파일 이름
    const fileName = `${Date.now()}${getPresignedUrlDto.fileName}`;

    // Put. 즉, s3에 데이터를 집어넣는 작업에 대한 url 생성
    const command = new PutObjectCommand({
      Bucket: this.configService.get('AWS_BUCKET'),
      Key: fileName,
    });

    const signedUrl = await getSignedUrl(s3Client, command, {
      expiresIn: 180, // seconds
    });

    return signedUrl;
  }
}

이런식으로 코드를 작성하면, 함수를 실행할 때마다 S3Client 인스턴스가 생성된다. 좋은 방법이 아니라고 생각한다.


 

Presigned url 발급 및 업로드 테스트


 

postman에서 발급 테스트를 해보자.

 

나는 fileName을 abc.jpeg로 설정하고 전달했다.

 

그리고 결과로 나온 url을 이용해서 이미지를 업로드해보자.

다른거 다 필요없이

1. presigned url을 postman 주소창에 입력한다.

2. method를 PUT으로 변경한다.

3. Body의 형식을 binary로 변경하고 파일을 선택한다.

그러면 된다. body의 형식을 binary로 해야한다. form-data로 하면 안된다... 그러면 파일이 깨져서 업로드된다.

 

200 응답이 올 것이다.

 

이제 버킷에 가서 파일이 정상 업로드 되었는지 확인해보자.

정상적으로 업로드 되었다.

혹시 모르니 객채 URL에 접속해서 파일이 깨지진 않았는지 확인해보도록 하자.

 

 

여기까지 Presigned url을 발급받아서 클라이언트에 전달해주고, 클라이언트 측에서 파일을 업로드하는 것까지 해보았다.

 

다음 편에서는 업로드 완료 요청을 보내고 DB에 profileUrl을 저장하는 것 까지 해보도록 할 예정이다.