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

thumbnail

AWS S3 사용할까 고민하다가…

전에 만들던 서비스에서는 보통 AWS의 S3 저장소를 사용했었다.
근데 myme.link를 만들면서 노마드코더의 클라우드플레어 소개 영상을 보면서 R2에 대해 알게 되었다.

R2는 일단 S3에 비해 합리적인 가격을 제공한다.
아래에서 간단히 비교를 하게 되면…

  • 아마존 S3

    • 1G 용량 한달 저장 비용 : 0.02$
    • 저장된 데이터를 송신하는 비용(유저가 다운받는 것) 1G 기준 : 0.09$

  • CloudFlare R2

    • 1G 용량 한달 저장 비용 : 0.015$
    • 저장된 데이터를 송신하는 비용(유저가 다운받는 것) 1G 기준 : 거의 무료(초당 10회 이상은 비용 발생)

나 같은 경우 서비스를 오픈할 때 잘 되기 전까진 무료 플랜과 저렴한 비용으로 최대한의 효율을 뽑자 마인드이다.
그래서 이번엔 S3보다는 R2를 적용해봤다.

근데 R2 자료가 거의 없었다.
대부분 S3 -> R2 마이그레이션만 있지, 이것을 S3처럼 사용하는 예제는 없었다.

그래서 좀 삽질하고 배운 내용을 정리하는 용도로 이번 포스팅을 진행한다.
원래 깃허브에 레포 하나 파서 예제를 올리려 했는데…
이거 준비하는 것도 일이더라…

그래서 이건 나중에 시간이 남아돌면 그 때 하는 것으로 하고…
이번 포스팅에서는 최대한 친절한 가이드 형식으로 하려 한다. (사실 내가 나중에 또 만들 때 보려는 목적이 더 크다 -_-;; )

내가 진행한 환경은 Nest.Js로 했으며, Typescript로 작성되었다.
이와 관련된 예제는 CloudFlare R2 사용해보기 Part 2 (With Nest.Js) 포스팅을 참고하면 된다.

일부 로직은 대강 작성했기 때문에 좀 난해할 수 있음을 미리 고지한다.(서비스 코드는 공개할 수 없기에…)
이것을 적용햐려 할 때 힘든 점이 있다면 아래 댓글로 문의하면, 답변 드리도록 하겠다.

포스팅의 전반적인 설명 흐름은 아래 도표대로 되어 있다.

img00

사용법

1. 클라우드플레어에 계정 생성을 해준다.

그리고 R2 메뉴로 들어간다.

img01


2. R2 가서 구입 결제를 진행한다. (프리플랜 가능)

img02

요금제 구매 버튼을 클릭한다.

img03

무료로 사용이 가능하며, 일단 해외 결제 카드가 필요하다.

img04

카드 정보를 다 입력하고 진행하면 아래와 같이 완료가 된다.

img05


3. 대시보드에서 R2 메뉴로 이동한다.

img006


4. 버킷을 생성해준다.

버킷 생성은 웹에서 해도 되고, Wrangler를 이용하여 콘솔에서도 생성이 가능하다.
Wrangler는 CloudFlare의 Workers서비스를 사용하기 위한 일종의 CLI 툴이다.

이에 대해 자세한 것요금은 링크에서 확인이 가능하다.

이 포스팅에서는 Wrangler CLI를 활용하여 생성해 보는 것으로 하겠다.
물론 대시보드에서 생성해도 똑같다.


4-1. Wrangler 설치

yarn global add wrangler

물론 나처럼 특정 프로젝트에서만 설치할 거면 global은 빼면 된다.

그럼 아래와 같이 설치가 된다.

나는 global이 아닌 local으로 설치해서 wrangler 명령어 수행 시 마다 yarn wrangler 형태로 사용한다.
이 부분은 각자 원하는 방식으로 설치하고, 사용하도록 하자

>  yarn add wrangler
yarn add v1.22.19
[1/4] 🔍  Resolving packages...
warning wrangler > @esbuild-plugins/node-modules-polyfill > rollup-plugin-node-polyfills > rollup-plugin-inject@3.0.2: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
warning " > ts-loader@9.4.1" has unmet peer dependency "webpack@^5.0.0".
[4/4] 🔨  Building fresh packages...
....
✨  Done in 19.39s.

설치가 잘 되면 다음과 같이 테스트 해본다.

 > yarn wrangler
yarn run v1.22.19
$ x/node_modules/.bin/wrangler
wrangler

Commands:
  wrangler docs [command]              📚 Open wrangler's docs in your browser
  wrangler init [name]                 📥 Initialize a basic Worker project, including a wrangler.toml file
  wrangler generate [name] [template]  ✨ Generate a new Worker project from an existing Worker template. See https://github.com/cloudflare/templates
  wrangler dev [script]                👂 Start a local server for developing your worker
  wrangler publish [script]            🆙 Publish your Worker to Cloudflare.
  wrangler delete [script]             🗑  Delete your Worker from Cloudflare.
  wrangler tail [worker]               🦚 Starts a log tailing session for a published Worker.
  wrangler secret                      🤫 Generate a secret that can be referenced in a Worker
  wrangler secret:bulk <json>          🗄️  Bulk upload secrets for a Worker
  wrangler kv:namespace                🗂️  Interact with your Workers KV Namespaces
  wrangler kv:key                      🔑 Individually manage Workers KV key-value pairs
  wrangler kv:bulk                     💪 Interact with multiple Workers KV key-value pairs at once
  wrangler pages                       ⚡️ Configure Cloudflare Pages
  wrangler queues                      🇶 Configure Workers Queues
  wrangler r2                          📦 Interact with an R2 store
  wrangler dispatch-namespace          📦 Interact with a dispatch namespace
  wrangler d1                          🗄  Interact with a D1 database
  wrangler pubsub                      📮 Interact and manage Pub/Sub Brokers
  wrangler login                       🔓 Login to Cloudflare
  wrangler logout                      🚪 Logout from Cloudflare
  wrangler whoami                      🕵️  Retrieve your user info and test your auth config
  wrangler types                       📝 Generate types from bindings & module rules in config
  wrangler deployments                 🚢 Displays the 10 most recent deployments for a worker

Flags:
  -c, --config   Path to .toml configuration file  [string]
  -e, --env      Environment to use for operations and .env files  [string]
  -h, --help     Show help  [boolean]
  -v, --version  Show version number  [boolean]
✨  Done in 1.23s.

4-2. Wrangler 콘솔 로그인

다음은 콘솔에서 로그인 해준다. (y 입력해주면 된다.)

img07

그럼 브라우저 열리고 Allow 해준다.

img08

다 해주면 브라우저가 이제 준비되었다고 알려준다.

img09

인증이 되면 아래와 같이 사용자에게 질의를 한다.

img10

질문 내용이 사용자 메트릭(?)을 클라우드 플레어에 제공할거냐 묻는데 난 거절했다.(아마 사용자 피드백이나 유저 데이터 수집용일것으로 사료됨)


4-3. 버킷 생성

다음의 명령어를 쳐서 버킷을 생성해준다.

> yarn wrangler r2 bucket create [bucket name]

> yarn wrangler r2 bucket create lucky-num-dev
yarn run v1.22.19
$ x/node_modules/.bin/wrangler r2 bucket create lucky-num-dev
 ⛅️ wrangler 2.8.1
-------------------
Creating bucket lucky-num-dev.
Created bucket lucky-num-dev.
✨  Done in 6.90s.

img11

나는 lucky-num-dev 이라는 이름으로 만들어줬다.

그리고 다음의 명령어를 통해 잘 생성되었는지 확인해준다.

> yarn wrangler r2 bucket list

> yarn wrangler r2 bucket list
yarn run v1.22.19
$ x/node_modules/.bin/wrangler r2 bucket list
[
  {
    "name": "lucky-num-dev",
    "creation_date": "2023-01-27T13:29:58.769Z"
  }
]
✨  Done in 1.74s.

img12


5. Workers 생성

CloudFlare의 대시보드로 이동해서 사이드 메뉴의 Workers를 선택한다.

img13

Workers도 무료/유료 요금제로 나눠져있는데 무료로 써도 충분하다. (물론 서비스 커지면 유료로…)

하위도메인이란 것을 입력해야 하는데 쉽게 url 접근할 때 이름이다.
이미지의 하단에 보면 my-worker 해서 설명 보면 주소가 예시로 표시된다.

img14


일단 무료 요금제를 선택한다.

img15


생성이 되면 아래의 계정 ID를 잘 확인해둔다.
근데 어짜피 CLI 툴에서 또 확인할 수 있다.

img16


이제 서비스 생성을 해야 하는데 여기도 대시보드에서 가능하지만, 난 Wrangler CLI로 콘솔에서 생성하였다.
취향차이인데 편한대로 해보자.

아까 맨 위에서 wrangler 실행하면 나온 명령어 중 아래와 같은 것이 있다.

wrangler init [name] 📥 Initialize a basic Worker project, including a wrangler.toml file

아래와 같이 실행해준다.

img17

여기서 물어보는것이 서비스 자체에 package.json을 두고 관리할 것인지를 묻는건데 난 프로젝트 내에서 같이 사용할 것이라서 N을 선택했다.
만약 외부에서 따로 관리하거나 할 경우 y를 선택해서 사용하자.

즉 쉽게 worker 서비스는 위 링크에 들어가서 확인한 것처럼 서버리스를 위한 서비스이고, 그냥 수행할 코드 조각이다.
그래서 이것을 worker에 배포하는 작업을 하는 프로젝트라 보면 된다.
근데 하는 역할이 그냥 코드 조각 수정이나 배포밖에 없고, 또 프로젝트랑 같이 사용하는것이라서 나는 프로젝트 내에 넣어서 사용했다.

궁금하면 따로 만들어서 해보고 하면 이해가 될 것이다.
그래도 이해가 안되면 따로 찾아보거나 댓글로…

무튼 다시 이어나가자면…

위 명령어까지 수행하면 아래와 같이 폴더가 하나 프로젝트 디렉토리에 생성되며 wrangler.toml 파일이 추가된다.

img18

여기까지 한 다음에 계정의 정보를 알아야 하는데 대시보드에서도 볼 수 있지만 아래의 명령어를 통해서 볼 수 있다.

> yarn wrangler whoami
yarn run v1.22.19
$ x/node_modules/.bin/wrangler whoami
 ⛅️ wrangler 2.8.1
-------------------
Getting User settings...
👋 You are logged in with an OAuth Token, associated with the email test@test.com!
┌─────────────────────────────────┬──────────────────────────────────┐
│ Account Name                    │ Account ID                       │
├─────────────────────────────────┼──────────────────────────────────┤
│ test@test.com's Account         │ 12345677880                      │
└─────────────────────────────────┴──────────────────────────────────┘
🔓 Token Permissions: If scopes are missing, you may need to logout and re-login.
Scope (Access)
- account (read)
- user (read)
- workers (write)
- workers_kv (write)
- workers_routes (write)
- workers_scripts (write)
- workers_tail (read)
- d1 (write)
- pages (write)
- zone (read)
- offline_access
✨  Done in 2.42s.

img19

이제 위 정보를 토대로 wrangler.toml 파일을 수정해야 한다.

파일을 열면 아래의 정보만 기입되어 있을 것이다.

name = "[아까 wrangler init 에서 입력한 이름]"
compatibility_date = "2023-01-28"

여기를 아래처럼 수정해준다.

name = "luckynum_dev"
main = "index.ts"
compatibility_date = "2023-01-28"

account_id = "12345677880"

workers_dev = true

[vars]
AUTH_KEY_SECRET = "key-key"

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "lucky-num-dev"

그리고 해당 toml이 있는 디렉토리에 index.ts 파일을 하나 생성해준다.
이 파일의 경로는 위 toml파일의 main항목과 연계된다.
경로 또는 파일명을 바꾸고 싶다면 저 toml과 함께 수정 처리하면 된다.

내가 작성한 코드는 아래와 같다.

export interface Env {
  DEV_BUCKET: R2Bucket
}

const hasValidHeader = (request, env) => {
  return request.headers.get('X-Auth-Key') === env.AUTH_KEY_SECRET
}

function authorizeRequest(request, env, key) {
  switch (request.method) {
    case 'PUT':
    case 'DELETE':
    case 'GET':
      return hasValidHeader(request, env)
  }
}

function parseRange(
  encoded: string | null
): undefined | { offset: number; length: number } {
  if (encoded === null) {
    return
  }

  const parts = encoded.split('bytes=')[1]?.split('-') ?? []
  if (parts.length !== 2) {
    throw new Error(
      'Not supported to skip specifying the beginning/ending byte at this time'
    )
  }

  return {
    offset: Number(parts[0]),
    length: Number(parts[1]) + 1 - Number(parts[0]),
  }
}

function objectNotFound(objectName: string): Response {
  return new Response(
    `<html><body>R2 object "<b>${objectName}</b>" not found</body></html>`,
    {
      status: 404,
      headers: {
        'content-type': 'text/html; charset=UTF-8',
      },
    }
  )
}

function isNullOrUndefine(x: any) {
  if (x == null) {
    return false
  }
  if (x === null) {
    return false
  }
  if (typeof x === 'undefined') {
    return false
  }
  return true
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)
    const objectName = url.pathname.slice(1)
    if (!authorizeRequest(request, env, objectName)) {
      return new Response('Forbidden', { status: 403 })
    }

    switch (request.method) {
      case 'PUT':
        const uploadObj = await env.DEV_BUCKET.put(objectName, request.body, {
          httpMetadata: request.headers,
        })

        const resultJson = JSON.stringify({
          uploaded: isNullOrUndefine(uploadObj['uploaded'])
            ? uploadObj['uploaded']
            : '-',
          etag: isNullOrUndefine(uploadObj['httpEtag'])
            ? uploadObj['etag']
            : '-',
          size: isNullOrUndefine(uploadObj['size']) ? uploadObj['size'] : '-',
          version: isNullOrUndefine(uploadObj['version'])
            ? uploadObj['version']
            : '-',
          key: isNullOrUndefine(uploadObj['key']) ? uploadObj['key'] : '-',
        })

        return new Response(resultJson, {
          headers: {
            etag: uploadObj.httpEtag,
          },
        })
      case 'GET':
        const object = await env.DEV_BUCKET.get(objectName)
        if (!object) {
          return new Response(
            JSON.stringify({ status: 'fail', message: 'Object Not Found' }),
            { status: 404 }
          )
        }
        return new Response(object.body)
      case 'DELETE':
        await env.DEV_BUCKET.delete(objectName)
        return new Response(JSON.stringify({ status: 'success' }), {
          status: 200,
        })

      default:
        return new Response(
          JSON.stringify({ status: 'fail', message: 'Method Not Allowed' }),
          { status: 405 }
        )
    }
  },
}

몇몇 죽은 코드가 있는데 필요에 맞게 커스텀 해서 쓰면 될듯 하다.


6. 암호키 만들기

이제 worker와 통신할 때 쓸 암호키를 만들어줘야 한다. 콘솔 CLI에서 아래와 같은 명령어를 봤을 것이다.

wrangler secret 🤫 Generate a secret that can be referenced in a Worker

그렇다면 이제 만들어준다.

> yarn wrangler secret put lucky_key -c ./luckynum_dev/wrangler.toml
yarn run v1.22.19
$ X/node_modules/.bin/wrangler secret put lucky_key -c ./luckynum_dev/wrangler.toml
 ⛅️ wrangler 2.8.1
-------------------
✔ Enter a secret value: … ********************
🌀 Creating the secret for the Worker "luckynum_dev"
✨ Success! Uploaded secret lucky_key
✨  Done in 57.18s.

img20


여기서 입력한 암호는 위 toml에서 AUTHKEYSECRET이 값과 같아야 한다.
그리고 이 값은 향후 헤더에 넣어서 보내야 하는 값이다.


(번외) 서비스 대시보드로 생성법

만약 CLI가 좀 어렵다면 대시보드로도 가능하다.
사람마다 다르지만 사실 GUI로 하는게 더 편하고 좋다.

아까 대시보드에서 서비스 생성을 누르면 아래와 같이 뜬다.

img21

서비스 이름을 원하는 것으로 작성한다.
그리고 아래 스타터 선택은 해당 워커로 접근 시 처리할 코드 조각인데 다른거 건들지말고 HTTP 처리기를 선택하고 진행한다.
어짜피 위에서 작성한 index.ts파일로 수행할 것이라서 서비스 생성을 진행한다.

img22

그럼 서비스가 생성되고 아래와 같이 서비스에 대한 요약 내용이 나온다.


정리

여기까지가 준비 과정이었다.
좀 복잡하고 과정이 길었는데 천천히 하다보면 어렵지 않게 할 수 있다.
혹여나 이해가 안되거나 문제가 있고, 해결하기 어려운 경우 댓글을 통해 공유주시면 같이 해결해 볼 수 있도록 노력해보겠다.

다음은 Nest.JS에서 파일 업로드, 다운로드, 삭제 등을 구현해볼 예정이다.

아래의 포스팅은 Nest.Js에서 구현한 부분을 다룬다.

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


참고


Written by@MHLab
로또는 흑우집합소 🎲
와인관리, 시음노트, 셀러관리는 마와셀 🥂

🫥 My Service|  📜 Contact|  💻 GitHub