상똥이의 Back-End 공부방

[Nest.js, AWS S3] S3 버켓에 이미지 업로드하기 본문

Nest.JS

[Nest.js, AWS S3] S3 버켓에 이미지 업로드하기

상똥백 2024. 6. 13. 20:18

목표

- 서버에서 이미지를 업로드하면

- 이미지를 AWS S3에 업로드하고 저장한다

- AWS S3에서 이미지를 조회할 수 있도록 한다

 

목차

0. S3 버켓을 사용하는 이유

1. S3 버켓 생성 및 사용자 생성

2. S3를 사용하기 위한 로직 구현

3. 이미지 업로드 로직 구현

4. 이미지 업로드 후 AWS에서 이미지 확인

 


0. S3 버켓을 사용하는 이유

- 데이터베이스의 성능 저하 : 이미지를 데이터베이스에 저장하면, 데이터베이스의 성능이 크게 영향을 받음 / 이미지 파일은 일반적으로 큰 용량을 차지하므로, 데이터베이스가 많은 이미지 데이터를 처리하려면 상당한 디스크 공간과 I/O 성능이 필요 / 이로 인해 데이터베이스의 쿼리 성능이 저하되고, 전체 시스템의 응답 속도가 느려짐
- 보안 취약성 : 서버의 public 디렉토리에 이미지를 저장하면, 해당 이미지에 대한 접근이 공개적으로 열려있을 수 있음 -> 이는 이미지에 대한 무단 접근과 잠재적인 보안 문제를 초래할 수 있으며, 민감한 정보를 포함할 수 있는 이미지의 경우 특히 위험할 수 있음


1. S3 버켓 생성 및 사용자 생성

(1) S3 버켓 생성

- aws 검색란에 S3 검색

- S3 화면으로 들어가 버킷 만들기 클릭

- 일반 구성: 이름 설정

- 객체 소유권: ACL 비활성화

- 퍼블릭 엑세스 차단 설정: 모든 퍼블릭 액세스 차단 비활성화 

- 기본 암호화: Amazon S3 관리형 키(SSE-S3)를 사용한 서버 측 암호화

- 고급 설정: 객체 잠금 비활성화

- 버킷 만들기 클릭

 

(2) 버켓 권한 편집

- 생성한 버켓 선택 후 권한으로 이동

- '버킷 정책'을 아래처럼 설정 (권한에 대한 자세한 내용은 여기 참고)

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",	
            "Principal": "*", //모두
            "Action": "s3:ListBucket", //객체를 볼 수 있음
            "Resource": "arn:aws:s3:::버켓이름"
        },
        {
            "Sid": "Statement2",
            "Effect": "Allow",
            "Principal": "*", //모두
            "Action": "s3:GetObject",  //객체를 다운로드할 수 있음
            "Resource": "arn:aws:s3:::버켓이름/*"
        }
    ]
}

 

(3) 사용자 생성

- aws 검색란에 IAM 검색

- IAM → 사용자 → 사용자 생성 클릭

- 사용자 이름 지정

- 권한 설정 시 '직접 정책 연결' 선택

- 권한 정책에 'AmazonS3FullAccess' 선택

- 사용자 생성 클릭

- 사용자 목록에서 새로 생성한 사용자 클릭 → '보안 자격 증명' 탭으로 이동

- '액세스 키 만들기' 클릭

- 'AWS 외부에서 실행되는 애플리케이션' 선택 후 다음 클릭

- '액세스 키 만들기' 클릭

- '액세스 키'와 '비밀 액세스 키'가 생성됨 (csv다운로드 눌러서 비밀 액세스 키 잃어버리지 않도록 하기)

 

2. S3를 사용하기 위한 로직 구현

(1) .env파일 설정

- 아래처럼 생성

/* .env */
/*S3 버켓 및 사용자 설정*/
AWS_REGION= 버켓 리전 설정
AWS_ACCESS_KEY_ID= 발급받은 액세스 키
AWS_SECRET_ACCESS_KEY= 발급받은 비밀 액세스 키
AWS_S3_BUCKET_NAME= 버켓 이름

 

(2) s3로직 구현

- 필요한 라이브러리를 모두 설치

npm i @types/express @types/multer @nestjs/config @aws-sdk/client-s3 multer-s3 nanoid@^3.0.0

- src 아래에 utils 폴더 생성하고, 그 안에 file.util.ts 생성

//file.util.ts

import { S3Client } from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';
import * as multerS3 from 'multer-s3';
import { nanoid } from 'nanoid';

export function setupMulterS3(configService: ConfigService) {
  // configService를 사용해 .env파일에서 aws 사용자와 s3버켓 정보를 가져온다. 
  const bucketName = configService.get('AWS_S3_BUCKET_NAME');
  const region = configService.get('AWS_REGION');
  const accessKeyId = configService.get('AWS_ACCESS_KEY_ID');
  const secretAccessKey = configService.get('AWS_SECRET_ACCESS_KEY');

  // S3Client 설정: Amazon S3와 상호작용할 수 있도록 함
  // region: S3 버킷이 위치한 AWS 리전, 올바른 위치 설정은 지연 시간을 줄임
  // credentials: 세부 정보
  const s3 = new S3Client({
    region: region,
    credentials: {
      accessKeyId: accessKeyId,
      secretAccessKey: secretAccessKey,
    },
  });

  // multerS3: multer와 통합된 S3저장소 엔진 (multer: Node.js에서 파일 업로드를 처리하는 미들웨어)
  // s3: AWS S3와 상호작용
  // bucket: 파일을 저장할 S3 버킷의 이름을 지정
  // key: 업로드된 파일이 s3에 저장될 때의 파일명을 정의, 파일 업로드 시 호출
  const storage = multerS3({
    s3: s3,
    bucket: bucketName,
    key: function (file, cb) {
      cb(null, `${nanoid()}-${file.originalname}`);
    },
  });

  return {
    storage: storage,
  };
}

- src 아래에 storage라는 폴더를 생성하고, 그 안에  s3.module.ts, s3.service.ts 생성

// s3.module.ts

import { Module } from '@nestjs/common';

import { ConfigModule } from '@nestjs/config';
import { S3Service } from './s3.service';

@Module({
  imports: [ConfigModule],
  providers: [S3Service],
})
export class S3Module {}
// s3.service.ts

import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { nanoid } from 'nanoid';
import { setupMulterS3 } from 'src/common/utils/file.util';

@Injectable()
export class S3Service {
  private s3: S3Client;
  private bucketName: string;
  private readonly fileFieldsInterceptor;

  constructor(private readonly configService: ConfigService) {
    const region = this.configService.get('AWS_REGION');
    const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID');
    const secretAccessKey = this.configService.get('AWS_SECRET_ACCESS_KEY');
    this.s3 = new S3Client({
      region: region,
      credentials: {
        accessKeyId: accessKeyId,
        secretAccessKey: secretAccessKey,
      },
    });

    this.bucketName =
      this.configService.get('AWS_S3_BUCKET_NAME') ?? 'default-bucket-name';

    if (!this.bucketName) {
      throw new Error('AWS S3 Bucket name is undefined.');
    }
  }
  
  // 파일을 s3버켓에 업로드하는 로직
  async uploadFile(
    file: Express.Multer.File | undefined,
  ): Promise<string | undefined> {
    if (!file) return undefined;

    const body = file.buffer;
    const contentType = file.originalname.split('.').pop()?.toLowerCase();
    const key = `${nanoid()}.${contentType}`;  //nanoid 3.0.0 버전이어야 함

    const uploadCommand = new PutObjectCommand({
      Bucket: this.bucketName,
      Key: key,
      Body: body,
      ContentType: contentType,
    });

    const awsRegion = this.configService.get('AWS_REGION');

    await this.s3.send(uploadCommand);

    return `https://${this.bucketName}.s3.${awsRegion}.amazonaws.com/${key}`;
  }
}

 

3. 이미지 업로드 로직 구현

- 두 개의 이미지를 업로드할 계획이다 (홈 이미지, 프로필 이미지)

- 코드는 아래와 같다

// schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Images {
  id           Int    @id @default(autoincrement())
  profileImage String
  homeImage    String
}
// upload-image.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/database/prisma/prisma.service';

@Injectable()
export class UploadImageService {
  constructor(private readonly prismaService: PrismaService) {}

  async getProfile(id: number) {
    const profile = await this.prismaService.images.findUnique({
      where: { id: id },
    });

    if (profile) return profile;
    else throw new NotFoundException('프로필을 찾을 수 없습니다.');
  }

  async createProfile(profileImage?: string | null, homeImage?: string | null) {
    const profile = await this.prismaService.images.create({
      data: {
        homeImage: homeImage,
        profileImage: profileImage,
      },
    });

    return profile;
  }
}
// upload-image.controller.ts

import {
  Controller,
  Get,
  Post,
  UploadedFiles,
  UseInterceptors,
} from '@nestjs/common';
import { UploadImageService } from './upload-image.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { S3Service } from 'src/storage/s3.service';

@Controller('upload-image')
export class UploadImageController {
  constructor(
    private readonly uploadImageService: UploadImageService,
    private readonly s3Service: S3Service,
  ) {}

  @Get()
  async getProfile(id: number) {
    return await this.uploadImageService.getProfile(id);
  }

  @Post()
  @UseInterceptors(
    FileFieldsInterceptor([
      { name: 'profileImage', maxCount: 1 },
      { name: 'homeImage', maxCount: 1 },
    ]),
  )
  async createProfile(
    @UploadedFiles()
    files: {
      profileImage?: Express.Multer.File[];
      homeImage?: Express.Multer.File[];
    },
  ) {
    console.log(files);
    const { profileImage, homeImage } = {
      profileImage: files?.profileImage?.pop(),
      homeImage: files?.homeImage.pop(),
    };

    const [profileImagePath, homeImagePath] = await Promise.all([
      this.s3Service.uploadFile(profileImage),
      this.s3Service.uploadFile(homeImage),
    ]);

    const profile = await this.uploadImageService.createProfile(
      profileImagePath,
      homeImagePath,
    );

    return profile;
  }
}

 

4. 이미지 업로드 후 AWS에서 이미지 확인

- 포스트맨을 통해 테스트

- s3 버켓 확인