프로그래밍/JS, Node.js

CloudFlare KV로 앱 점검시간 관리하기

Lou Park 2023. 1. 21. 13:47

왜 Cloudflare Worker?

개발중인 앱 실행시 스플래시 화면에서 진입점(Entrypoint)을 결정하고 간단한 정보들을 내려주는 API가 있다. 포함되는 데이터는 API 서버 URL, 앱 버전 업데이트 정보, 점검시간 정보 등 앱 실행을 위한 필수정보들이다. 이는 CloudFlare Worker 로 구현되어있는데, 아래와 같은 이유로 채택하게 되었다.

  • 앱 API 서버와 별개로 동작할 것: 서비스 점검 등으로 서버가 다운되었을때도 정보를 전달할 수 있다.
  • 빠르고 안정적인 응답을 내려줄 것: Cloudflare 전역 네트워크 위에서 돌아가서 50ms 이내 응답이 가능하다.
  • 수정 및 배포가 용이할 것: JS를 써서 언어적 장벽도 낮고 Wrangler라는 CLI를 제공해서 배포가 편리하다.

 

요구사항: 점검시간을 운영파트에서 수정할 수 있게 해주세요

처음에는 Worker 코드 내에서 하드코딩하여 클라이언트 개발자가 점검 시간을 관리하는 식이었다. 그러나 기억력 이슈로 인해 까먹어 버리거나, 운영파트에서도 본인들이 직접 볼 수 없으니 답답함이 있었다. 그래서 점검시간을 운영툴에서 수정할 수 있도록 해달라는 요구사항이 들어왔다.

const Maintenances: { [region: string]: Maintenance } = {
  KR: {
    start: new Date('2017-02-17T00:00:00+09:00'), // KST
    end: new Date('2017-02-17T00:00:00+09:00'), // KST
    description: '서버 안정화 작업 진행중입니다.',
  },
  US: {
    start: new Date('2017-02-17T00:00:00+00:00'), // GMT
    end: new Date('2017-02-17T00:00:00+00:00'), // GMT
    description: '',
  },
}

새로운 요구사항에 따라 어떠한 형태이든 저장소가 필요했는데, DB를 사용해서 새로운 의존성을 추가하기보다 CloudFlare KV를 이용하기로 했다.

 

CloudFlare KV는 CloudFlare Workers에서 사용할 수 있는 서버리스 Key-Value 저장소다. KV는 몇몇 중앙화된 데이터 센터에서 데이터를 저장하지만, 데이터 액세스 후에는 캐시하기때문에 빠른 응답이 가능하다. 비용은 Paid Plan의 경우 월 1천만건이 무료 제공되고, 1백만건당 $0.5가 부과된다.

 

KV 사용법

Namespace 설정

워커에서 KV를 사용하기 위해서는 먼저 KV Namespace를 설정해야한다. Wrangler CLI에서 컨맨드를 제공한다.

$ wrangler kv:namespace create "<네임스페이스명>"
// generated id: 1ce9b927aa314cf6bc54d13f1c557ec1

성공적으로 설정시 id가 만들어지면서 설정 파일에 붙여넣으라고 한다.

복사해서 wrangler.toml 파일에 코드를 붙여준다. *예시의 Namesapce명은 APP이다.

name = "playio-entrypoint"
main = "src/index.ts"
...
kv_namespaces = [
  { binding = "APP", id = "1ce9b927aa314cf6bc54d13f1c557ec1" },
]

Previews용 Namespace 설정

이대로 테스트용 빌드를하면 오류가 날건데, production이 아닌 환경에서는 preview용 Namespace를 사용해야한다. --preview 플래그를 붙여주어 생성하면된다.

$ wrangler kv:namespace create "<네임스페이스명>" --preview
// generated id: 9d7028e70b5a455399a933857be461af

wrangler.toml은 이렇게 production 환경에서와 테스트 환경에서의 값을 따로 설정해준다. 그러면 테스트용 빌드에도 문제없을 것이다.

name = "playio-entrypoint"
main = "src/index.ts"

kv_namespaces = [
  { binding = "APP", id = "1ce9b927aa314cf6bc54d13f1c557ec1", preview_id = "9d7028e70b5a455399a933857be461af" },
]

[env.production]
// ...
kv_namespaces = [
  { binding = "APP", id = "1ce9b927aa314cf6bc54d13f1c557ec1" },
]

읽기와 쓰기

기본적으로 KV에서 읽기와 쓰기를하는 방법은 다음과 같다.

await NAMESPACE.get(key); // 읽기
await NAMESPACE.put(key, value); // 쓰기

NAMESPACE라고 하는 부분은 방금 만든 Namespace와 일치해야한다.

Env 인터페이스에 KVNamespace 타입으로 Namespace를 추가하여 간단히 사용할 수 있다.

export interface Env {
  APP: KVNamespace
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
        const value = env.APP.get('somekey')
    // ...
  },
}

 

캐싱하여 돈을 아끼자

Entrypoint 호출 건 수는 지난 24시간동안 20만건, 1달 예상 약 6백만건이라 여유있긴하지만 어쨌든 돈을 아끼고 싶은 마음이 들었다.

그래서 점검시간을 요청마다 KV에서 불러와서 쓰지 않고, 1분마다 갱신하도록 구현했다.

 

캐시된 데이터가 있으면 그대로 보내고, 없다면 받아올때까지 await를 한다. 그리고 데이터를 갱신한지 1분이 지났다면 이미 받아온 데이터는 막히지않고 내려보내되, 비동기처리 후 데이터를 업데이트 하도록 구현했다. 이렇게하면 동시에 요청이 들어와도 여러번 데이터를 가져오지 않을 수 있다.

 

사실 KV도 Data Center로부터 값을 가져올때 캐싱을 한다. 하지만 캐싱시간에 대한 내용이 “짧은 시간” 정도 뿐이어서 불확실한 마음에 자체적으로 캐싱을 하기로 했다. 또한 이러한 패턴은 KV를 쓸때 뿐만아니라 다른 API를 호출하여 값을 가져오는 등 여러 군데에서 활용할 수 있기도 하다.

const REFRESH_TIME = 60 * 1000

const cacheData: {
  [key: string]: {
    time: number
    fetcher: Promise<any>
    data: any
  }
} = {}

const postAsync = async (f: () => Promise<any>) => {
  try {
    return await f()
  } catch (e) {
    printError(e)
  }
}

export const fetchDataRegularly = async <T = any>(
  key: string,
  fetchData: () => Promise<T>
): Promise<T> => {
  const now = Date.now()
  const d = cacheData[key] ?? { time: now, data: undefined }
  if (d.data === undefined || now - (d.time ?? 0) > REFRESH_TIME) {
    cacheData[key] = d
    d.time = now
    d.fetcher = fetchData()
    // scheduler에서 별도 처리하도록 함
    postAsync(async () => (d.data = await d.fetcher))
  }
  if (d.data === undefined) {
    d.data = await d.fetcher
  }
  return d.data as T
}

모든것이 괜찮아보였지만 이상하게도 에러 발생률이 높아졌다. 이전에는 0%였는데 0.06%까지 떨어졌다. 하루에 116번 앱이 안켜지고 튕기는건 좋은 수치는 아니었다.

 

오류의 등장

확인해보니 대부분 Script Threw Exception이라고 나왔다.

하지만 worker를 통해 처리되는 모든 요청에 대한 에러 핸들링이 되어있었기 때문에 좀처럼 원인을 찾기어려웠다.

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return await handleRequest({ request, env, ctx }).catch((e) => {
      if (e instanceof UserError) {
        printError(e)
        return new Response(JSON.stringify({ error: e.message }), { status: 200 })
      }
      printError(e)
      return new Response(JSON.stringify({ error: 'system error' }), { status: 500 })
    })
  },
}

에러 로깅을 켜고 몇 시간 기다리자, 오류가 밝혀졌다. The script will never generate a response. 이 포스트에 의하면 CloudFlare Workers는 Javascript event loop와 관련하여 약간 특이하게 동작한다고 한다.

"exceptions": [
  {
    "name": "Error",
    "message": "The script will never generate a response.",
    "timestamp": 1674186925178
  }
],

동일한 Worker 인스턴스에서 A, B 2개의 요청이 왔을때 A에서 Promise를 생성하고 B에서 해당 Promise가 끝나기를 기다릴때 B에서 Event loop가 끊기면서 요청이 중단된다고 한다.

fetchDataRegularly() 함수의 usecase와 같은 상황이었다.

그래서 동시요청이 오더라도 그냥 각각의 Promise를 생성하여 처리하도록 변경했다.

const cacheData: {
  [key: string]: {
    time: number
    data: any
  }
} = {}

export const fetchDataRegularly = async <T = any>(
  key: string,
  fetchData: () => Promise<T>
): Promise<T> => {
  const now = Date.now()
  const d = cacheData[key] ?? { time: now, data: undefined }
  if (d.data === undefined || now - (d.time ?? 0) > REFRESH_TIME) {
    cacheData[key] = d
    d.time = now
    d.data = await fetchData()
  }
  return d.data as T
}

바꾼 코드로 배포 후 에러발생률이 0%로 떨어지고 제대로 작동되는 것이 확인되었다.

CF Worker를 다룰때 조심해야할 점이다.