CloudFlare R2 사용해보기 Part 2 (With Nest.Js)

Posted by , February 03, 2023
NestJsCloudFlareR2
Series ofNestJs

thumbnail

저번 포스팅에 이어...

저번 포스팅(CloudFlare R2 사용해보기 Part 1)에 이어 이번에는 R2에 파일 업로드와 다운로드, 삭제 등을 Nest.Js로 구현하는 것을 해보려 한다.
일단 프로젝트를 따로 파서 깃허브에 올려서 하려 했으나...

생각해보니 각 설정들 부터 해서 Wrangler의 설정 및 배포 등 생각할 것과 직접 구현해야 할 부분이 많아지는 관계로...
일단 코드 조각으로 예시를 만들어보려 한다.

그렇다고 생략을 엄청 하는 것은 아니고, 환경이 구성되어 있다면 해당 코드 예제가 도움될 것이라 생각이 든다.

설명에 앞서 구성, 그리고 기타 사항은 다음과 같이 되어 있다.

  • Nest.JS 사용
  • Typescript 사용
  • CloudFlare R2 사용해보기 Part 1 포스팅까지 진행 완료된 CloudFlare 계정
  • Wrangler 프로젝트가 현재 진행하는(또는 따로 만든) 곳 내부에 존재하는 것을 예시로 진행
  • 먼저 files라는 도메인에서 진행한다. (아래 사진은 참고용)

img01

  • 레이어드 아키텍처 기반으로 코드가 구성되어 있다. (원래 포트 앤 어뎁터로 하려 했는데 아직 배우는 단계라...)

1. Worker 처리기 배포

CloudFlare R2 사용해보기 Part 1 까지 한 뒤에 작성했던 index.ts에 대해서 CloudFlare에 배포를 해줘야 한다.
이 부분은 자주 쓸 수 있기에 package.jsonscripts에 등록을 해두고 쓰자.

{
    "scripts" : {
        ...
        "r2:build:dev": "wrangler publish -c ./my_worker/wrangler.toml",
    }

}

여기서 my_worker의 경우 서비스 생성할 때 이름을 적어주면 된다.
각자 프로젝트 디렉토리에 보면 자신이 정한 디렉토리명이 있는데 이를 넣어주면 된다.
그리고 실행 명령어도 입맛에 바꿔도 된다.

이렇게 하고 아래 명령어처럼 배포를 한다.

> yarn r2:build:dev

그럼 콘솔에 배포가 잘 되고, CloudFlare 사이트의 Workers에서 등록한 서비스를 들어가서 배포 항목을 보면 확인이 가능하다.

img02


2. 프로젝트 구성

먼저 Nest.JS환경이 구성되어 있어야 한다.(아마 진행자라면 다들 구성되어 있을 것이다.)
위에서 설명한 것과 같이 레이어드 아키텍쳐 기반으로 구성하고 설명한다.
각 구성은 다음과 같다.

  • FileController : URL로 직접 접근하는 부분
  • FileR2Service : R2 관련 로직을 핸들링하는 서비스 로직
  • multerR2Options : Multer 처리 옵션 로직

기타 설치해야 할 패키지는 아래와 같다.(같은 역할을 하는 다른 패키지를 써도 무방하다)

yarn add @types/multer --dev
yarn add @nestjs/axios
yarn add axios
yarn add rxjs

각 구성 영역에 대해 설명한다.


2-1 FileController

컨트롤러 영역은 별거 없다.
물론 파일 업로드 및 다운로드, 삭제 등의 작업 시 권한이나 그런 로직은 제외한다.
순수하게 기능에만 집중한 예제이기에 해당 코드를 바로 사용하기 보다는 약간 로직을 추가해서 쓰는 것이 좋다.

그리고 아래 예제는 내가 사용하던 코드인데 일부 코드는 공개가 불가능해서 조작하였다.
약간 억지같은 부분도 있지만 기능 구현이라는 관점만 봐주셨으면 한다.

import {
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Req,
  Res,
  UploadedFile,
  UseInterceptors,
} from "@nestjs/common"
import { FileInterceptor } from "@nestjs/platform-express"
import { Request, Response } from "express"
import {
  FilesSuccessCode,
  FilesSuccessResponse,
} from "./files.response.success"
import { multerR2Options } from "./multer.options"
import { FilesService } from "./services/files.service"

@Controller("files")
export class FilesController {
  constructor(private readonly fileService: FilesService) {}

  @Post("upload")
  @UseInterceptors(FileInterceptor("file", multerR2Options))
  async fileUpload(
    @UploadedFile() file: Express.Multer.File,
    @Req() req: Request
  ) {
    const result = await this.fileService.upload(req, file)
    return FilesSuccessResponse.returnRes(FilesSuccessCode.uploadFile4R2(), {})
  }

  @Get("get/:file_id")
  async downloaFiles(@Res() response: Response, @Param() param) {
    const result = await this.fileService.fileDownload(param.file_id)
    response.set({
      "Content-disposition": `attachment; filename=${result.fileName}`,
    })
    response.send((await result.axiosResponse).data)
  }

  @Delete("delete/:file_id")
  async deleteFiles(@Param() param) {
    await this.fileService.updateDeleteStatus(param.file_id)
    return FilesSuccessResponse.returnRes(FilesSuccessCode.deleteFile4R2())
  }
}

여기서 FilesService가 있는데 이 서비스 로직은 디비에 저장 및 로깅, 기타 작업 등을 처리하는 메인 서비스 로직인데 이 부분은 아래 서비스 로직에서 다루겠다.


2-2 FileService & FileR2Service

여기는 실제 기능을 담당하는 구현 로직이다.
FilesService의 경우 일부 로직만 구현되어 있기에 그냥 호출 흐름 정도만 공개한다.

//FilesService

@Injectable()
export class FilesService {
  constructor(private readonly r2Service: FilesR2Service) {}

  /**
   * 파일 업로드 영역
   * @param req
   * @param file
   */
  async upload(req: Request, file: Express.Multer.File): Promise<Files> {
    //R2에 파일 업로드를 처리한다.
    const fileKey: string = getUuid()
    const r2Result: R2UploadResult = await this.r2Service.uploadR2(
      file,
      fileKey
    )
  }

  /**
   * 파일 다운로드 영역
   * @param id
   * @returns
   */
  async fileDownload(id: string): Promise<GetDownloadFile> {
    const entity = await this.filesRepo.findEntityById(id)
    return await this.r2Service.getFileR2(
      entity.r2Key,
      entity.uploadName + "." + entity.extension
    )
  }

  async updateDeleteStatus(fileId: string) {
    await this.r2Service.deleteFileData4R2(fileKey)
  }
}

내가 구성한 코드의 경우 파일 업로드 시 해당 파일명을 uuid로 바꿔서 관리했다.
그래서 실제 R2에 파일 업로드 시 aaa.png 파일명이라면 R2에는 acbv-3342 등의 UUID로 변경되어 저장된다.
이는 향후 다운로드 시 디비에 저장된 값으로 다시 바꿔서 전달하면 되는 부분인데 여기서는 자세하게 설명하지 않는다.

다운로드는 컨트롤러에서 받은 유니크한 UUID 생성 키 값으로 파일 데이터를 찾아서 R2에 저장된 파일명을 가져온다.

삭제의 경우 원래 파일 상태만 바꾸는데 이번 예제에서는 직접 삭제를 진행한다.

다음은 실제 구현 로직인 FileR2Service이다.

import { HttpService } from "@nestjs/axios"
import { Injectable } from "@nestjs/common"
import { AxiosError, AxiosRequestConfig } from "axios"
import { firstValueFrom, lastValueFrom, map } from "rxjs"

@Injectable()
export class FilesR2Service {
  constructor(private readonly http: HttpService) {}

  async uploadR2(
    file: Express.Multer.File,
    fileKey: string
  ): Promise<R2UploadResult> {
    const url: string = this.getUrl4WorkerR2(fileKey)
    const baseHeader: AxiosRequestConfig = this.getR2DefaultAxiosHeader({
      "Content-Type": file.mimetype,
    })
    const data: R2UploadResult = await lastValueFrom(
      await this.http
        .put(url, file.buffer, baseHeader)
        .pipe(map(response => response.data))
    )
    return data

    //데이터가 안올 때 처리 필요
  }

  async getFileR2(r2Key: string, fileName: string): Promise<GetDownloadFile> {
    const url: string = this.getUrl4WorkerR2(r2Key)
    const axiosConfg: AxiosRequestConfig = this.getR2DefaultAxiosHeader()
    axiosConfg.responseType = "arraybuffer" //AxiosRequestConfig 추가
    const fileData = await this.http.get(url, axiosConfg)

    return {
      axiosResponse: firstValueFrom(fileData.pipe(map(response => response))),
      fileName,
    }
  }

  /**
   * [주의] R2에서 물리적 파일 삭제 처리 함수
   * @param fileKey R2에 저장된 파일명
   * @returns
   */
  async deleteFileData4R2(fileKey: string) {
    const url: string = this.getUrl4WorkerR2(fileKey)
    const axiosConfg: AxiosRequestConfig = this.getR2DefaultAxiosHeader()
    const result = await this.http.delete(url, axiosConfg)
    const callback = await firstValueFrom(
      result.pipe(map(response => response.data))
    )
    return callback.status === "success"
  }

  /**
   * R2에 업로드 Url을 생성해주는 메서드
   * @param fileName
   * @returns
   */
  private getUrl4WorkerR2(fileName: string): string {
    //CF_R2_WORKER_URL = "https://myworker.workers.dev/"
    return process.env.CF_R2_WORKER_URL + fileName
  }

  private getR2DefaultAxiosHeader(headerOpt?: {
    [key: string]: string
  }): AxiosRequestConfig {
    const baseHeader = {
      "X-Auth-My-S-Key": process.env.CF_R2_AUTH_KEY,
    }
    return {
      headers:
        headerOpt === undefined ? baseHeader : { ...baseHeader, ...headerOpt },
    }
  }
}

자잘자잘한 로직이 많다.
코드를 보면 알겠지만 좀 지저분하고, 로직이 난잡한데 이는 비공개 코드를 지우느라 어색한 부분이 있어서 그렇다.(물론 코드 리팩토링이 아직 제대로 안됨 -_-;)

getUrl4WorkerR2 메서드의 경우 .env 파일에 있는 값을 가져오는데 저기서는 Worker Url이다.
자신이 설정한 URL로 설정해두자.

getR2DefaultAxiosHeader 메서드의 경우 Axios 요청 시 헤더 값이다.
여기서 헤더의 키 값은 wrangler.toml파일과 맞춰줘야 한다.
전 포스팅에서 만들었던 암호키를 넣으면 된다.

그리고 헤더 이름도 index.ts에서 처리한 것과 같게 해줘야 한다. (너무 당연한 이야기...)
각자 구성에 맞춰서 진행해야 에러가 안난다.

403뜨면 이 부분을 잘 확인해보자.

그 외에 부분은 코드를 보면 이해가 될 것이다.
업로드 후 data를 로깅으로 찍어보면 아래와 같은 값이 나온다.

data =  {
  uploaded: '2023-01-31T00:59:59.713Z',
  etag: '8b0e11242eda10911c1d203f33ab841b',
  size: 630046,
  version: '5307c9d4821a4ea903f7015ec19d72ef',
  key: '7c7e5ae2-8260-499e-883c-949a0a687ada'
}

각 설명은 아래를 참고하자

  • uploaded : 업로드 날짜
  • etag : 고유 etag 값
  • size : 파일 사이트
  • version : R2에서 사용하는 값
  • key : 업로드한 파일명

저기 로직에서 axios 통신을 보면 Workers의 HTTP 처리기 로직(index.ts)와 매칭이 된다.
이 부분을 잘 응용하면 좀 더 고도화 된 코드를 구현할 수 있는데, 귀찮으면 일단 여기 로직으로 시작해도 된다.


2-3 multerR2Options

이건 약간 번외 파일인데...
각자 업로드에 맞춰서 사용하면 된다.

아래는 그냥 내가 사용했던 샘플 코드인데...
그냥 어디선가 긁어온 코드를 그냥 사용했었다....

물론 지금 내 서비스 코드는 다르게 되어 있지만...
여러분들도 입맛에 맞춰서 바꿔 사용해보시는 것을 추천한다.

import { HttpException, HttpStatus } from "@nestjs/common"
import { existsSync, mkdirSync } from "fs"
import { diskStorage } from "multer"
import { extname } from "path"

export const multerR2Options = {
  fileFilter: (request, file, callback) => {
    if (file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
      callback(null, true)
    } else {
      callback(
        new HttpException(
          {
            message: 1,
            error: "지원하지 않는 이미지 형식입니다.",
          },
          HttpStatus.BAD_REQUEST
        ),
        false
      )
    }
  },
  limits: {
    fieldNameSize: 200, // 필드명 사이즈 최대값 (기본값 100bytes)
    filedSize: 10 * 1024 * 1024, // 필드 사이즈 값 설정 (기본값 1MB) 10MB
    fields: 2, // 파일 형식이 아닌 필드의 최대 개수 (기본 값 무제한)
    fileSize: 16777216, //multipart 형식 폼에서 최대 파일 사이즈(bytes) "16MB 설정" (기본 값 무제한)
    files: 1, //multipart 형식 폼에서 파일 필드 최대 개수 (기본 값 무제한)
  },
}

정리

좀 성급한 정리의 감이 없지않아 있지만... 위 코드를 참고하면 구현할 수 있을 것이다.

R2와 S3는 사실 각각의 장단점을 가지고 있다.
S3는 접근 제어를 할 수 있지만, R2는 내가 알기론 다 공개형태이다.

URL만 있으면 접근이 가능하다는 뜻이다.
그래서 이 부분이 서비스 구현에 맞다면 R2를 쓰는것이 좋지만...

보안이 좀 필요하거나 하면 S3를 쓰는게 맞는 것 같다.

각자 구성환경이 좀 다르겠지만...
보통 React 또는 Next.JS를 프론트에 두고, 백엔드는 Nest.js 또는 스프링, FastApi 등을 쓰고 할 것이다.

근데 나는 Next.Js + Nest.Js 에 Ngix를 통해서 작업을 했다.
근데 Next.Js에서 미들웨어에서 파일에 대한 ByteArray를 전달할 때 좀 문제가 있었다.

물론 이 부분은 다 구현해서 정리해뒀다.

나중에 시간날 때 저 환경에서 파일 업로드 다운로드를 하는 부분에 대해 포스팅 해볼 예정이다.

예제를 최대한 친절하게 작성해뒀지만...
이해가 안되는 부분이 있다면 댓글로 남겨주시면 확인 후 답변 드릴 예정이다.

나중에 시간이 좀 많이 널널해지면 그땐 깃허브에 예제를 올리고 해당 포스팅 문서도 업데이트 하도록 할 예정이다.