[Go Fiber Contribution] Cloudflare KV 스토리지 드라이버 추가하기

오형근·2024년 4월 20일
3

Study

목록 보기
8/10
post-thumbnail
post-custom-banner

최근에 Golang의 백엔드 프레임워크 중 하나인 fiber를 활용해서 개인적인 일을 진행하던 도중, Cloudflare의 KV Store를 세션 저장 용도로 사용하고자 하였습니다.

그러나 아직 fiber에는 Cloudflare KV 관련 스토리지 드라이버가 존재하지 않았기에, 이를 직접 제작하고 추가하기까지의 과정을 담았습니다.

https://github.com/gofiber/storage

실제 PR: #1298


계기

앞서 언급한 것처럼 fiber를 이용해 백엔드를 구축하던 도중 세션 저장 용도로 가볍게 사용하고자 했으나, 아직 드라이버가 없음을 확인했다고 했습니다.

관련하여 fiber 스토리지 드라이버의 사양(Specification)을 확인하였고, 아래와 같은 표준 메서드를 담고 있는 구조체이면 됨을 파악했습니다.

type Storage interface {
	// Get gets the value for the given key.
	// `nil, nil` is returned when the key does not exist
	Get(key string) ([]byte, error)

	// Set stores the given value for the given key along
	// with an expiration value, 0 means no expiration.
	// Empty key or value will be ignored without an error.
	Set(key string, val []byte, exp time.Duration) error

	// Delete deletes the value for the given key.
	// It returns no error if the storage does not contain the key,
	Delete(key string) error

	// Reset resets the storage and delete all keys.
	Reset() error

	// Close closes the storage and will stop any running garbage
	// collectors and open connections.
	Close() error
}

이때 KV 스토리지는 이름처럼 key-value store였기에, 쿼리가 다각화되어있거나 방법이 다양하지 않아 CRUD를 구현하기 비교적 단순한 형태였습니다. 그래서 스토리지 드라이버를 직접 구현하고 기여하자는 생각을 하게 되었고, 작업에 들어갔습니다.

스토리지 드라이버 구현

스토리지 드라이버는 기본적으로 다음과 같은 역할을 담당합니다.

특정 프레임워크에서 여러 스토리지의 기능들을 (비교적)동일한 형태로 사용할 수 있도록 인터페이스 제공

해외여행 시 110V를 맞추기 위해 돼지코를 구비하는 것처럼, 특정 규격을 기준으로 기능을 맞춰 프레임워크 사용자로 하여금 어렵지 않게 스토리지를 사용하도록 돕는 것입니다.

마치 네트워크 소켓이 전송 계층의 기능을 함축적으로 담아 제공하는 것처럼!!

이를 위해서는, 핵심 구현체가 되는 cloudflare/cloudflare-go 패키지의 사용 방법을 온전히 익혀야합니다. 해당 구현체에서 어떤 기능을 끌어와서 구현할지 파악하기 위함입니다.

https://github.com/cloudflare/cloudflare-go

기본적인 개념을 먼저 짚고 넘어가면, 기존에 http call을 기반으로 데이터를 읽고 쓰는 작업을 진행하는 cloudflare API를 이용하여, 드라이버 내부적으로 설정된 계정의 KV 네임스페이스에 api call을 날리는 방식으로 구현하였습니다.

다행인 점은, DynamoDB나 S3와 같이 그나마 형태가 비슷한 비-관계형 데이터베이스 이 존재했기에 이를 참고하여 코드를 작성할 수 있었습니다.

처음에는 아래와 같이 스토리지 구조체를 선언해줍니다.

  • 추후 설명합니다.*
// APIInterface는 이후 테스트 모듈을 위해 
// 선언된 특수 타입입니다.
type Storage struct {
	// 핵심 메서드가 담긴 api모듈
	api         APIInterface
    // api call을 위해 사용하는 기본적인 정보
	email       string
	accountID   string
	namespaceID string
}

이후, New 로 드라이버를 초기화해주고, Get, Set, Close, Reset 을 차례대로 구현해주었습니다.

func New(config ...Config) *Storage {
	cfg := configDefault(config...)
	if cfg.Key == "test" {
		api := &TestModule{
			baseUrl: "http://localhost:8787",
		}

		storage := &Storage{
			api:         api,
			email:       "example@cloudflare.org",
			accountID:   "dummy-ID",
			namespaceID: "dummy-ID",
		}

		return storage
	}

	api, err := cloudflare.NewWithAPIToken(cfg.Key)
	if err != nil {
		log.Panicf("error with cloudflare api initialization: %v", err)
	}

	storage := &Storage{
		api:         api,
		email:       cfg.Email,
		accountID:   cfg.AccountID,
		namespaceID: cfg.NamespaceID,
	}

	return storage
}

Get

func (s *Storage) Get(key string) ([]byte, error) {
	resp, err := s.api.GetWorkersKV(context.Background(), cloudflare.AccountIdentifier(s.accountID), cloudflare.GetWorkersKVParams{NamespaceID: s.namespaceID, Key: key})

	if err != nil {
		log.Printf("Error occur in GetWorkersKV: %v", err)
		return nil, err
	}

	return resp, nil
}

Set

func (s *Storage) Set(key string, val []byte, exp time.Duration) error {
	_, err := s.api.WriteWorkersKVEntry(context.Background(), cloudflare.AccountIdentifier(s.accountID), cloudflare.WriteWorkersKVEntryParams{
		NamespaceID: s.namespaceID,
		Key:         key,
		Value:       val,
	})

	if err != nil {
		log.Printf("Error occur in WriteWorkersKVEntry: %v", err)
		return err
	}

	return nil
}

기본적인 메서드인 Get과 Set만 첨부하였습니다. 전체 코드는 이곳에서 확인하실 수 있습니다.

Reset의 경우 Pagination이 적용되어있기에, Cursor를 기준으로 for 루프를 돌려 얻은 key값을 배열에 담아 한 번에 Delete하는 방식으로 진행했습니다.

테스트 코드 제작

이제 이 드라이버 코드가 잘 작동하는지 여부를 판단하기 위한 테스트 코드를 작성해야합니다.

테스트 시에, 초기 Configuration에서 API Key값을 test로 설정해주면 실제 api call url이 아닌 테스트용 cloudflare KV 엔진으로 연결될 수 있도록 설정해주었습니다.

처음 Storage를 전역적으로 선언하고 진행하면 추후 메모리 해제를 직접 해주어야하는 이슈가 발생하여, 테스트 내부에서 스토리지를 초기화하고 이를 t.Parallel()을 이용해 병렬적으로 테스팅하도록 하였습니다.

TestMain (테스트 초기화)

func TestMain(m *testing.M) {

	var testStore *Storage

	testStore = New(Config{
		Key: "test",
	})

	code := m.Run()

	_ = testStore.Close()
	os.Exit(code)
}

Get 테스트

func Test_CloudflareKV_Get(t *testing.T) {
	t.Parallel()

	var testStore *Storage

	testStore = New(Config{
		Key: "test",
	})

	var (
		key = "john"
		val = []byte("doe")
	)

	_ = testStore.Set(key, val, 0)

	result, err := testStore.Get(key)

	for i := 0; i < 2; i++ {
		result, err = testStore.Get(key)
		if bytes.NewBuffer(result).String() == "" {
			_ = testStore.Set(key, val, 0)
		} else {
			break
		}
	}

	require.NoError(t, err)
	require.Equal(t, bytes.NewBuffer(val).String(), bytes.NewBuffer(result).String())

	_ = testStore.Close()
}

Set 테스트

func Test_CloudflareKV_Set(t *testing.T) {
	t.Parallel()

	var testStore *Storage

	testStore = New(Config{
		Key: "test",
	})

	var (
		key = "john"
		val = []byte("doe")
	)

	err := testStore.Set(key, val, 0)

	require.NoError(t, err)

	_ = testStore.Close()
}

마찬가지로 Get과 Set 테스트만 첨부하였습니다.

테스트 모듈 제작

이렇게 구현 코드와 테스트 코드까지 모두 작성했지만, 이를 테스팅하기 위한 별도의 환경이 존재하지 않았습니다.

다른 데이터베이스의 경우 Docker 이미지가 존재하여 컨테이너 내에서 테스트 인스턴스를 생성하고 테스트를 진행했지만, Cloudflare KV의 경우에는 그렇지 못했습니다.

그래서 관련하여 테스트용 엔진을 찾기 위해 cloudflare/cloudflare-go 에 질문을 올리기도 하고, miniflare 등 다양한 대책을 찾아본 결과 wrangler를 이용하여 Cloudflare KV엔진을 로컬에서 실행할 수 있음을 확인하였습니다.

이제 이를 위해 Wrangler 초기화용 코드를 작성하고, 해당 Wrangler 인스턴스를 띄우는 스크립트를 작성해야 했습니다.

  1. .github/scripts/initialize-wrangler.sh 에서 wrangler 설정 용 wrangler.toml작성. 이후 해당 인스턴스가 8787 포트를 바라보도록 설정.
  2. cloudflare KV 네임스페이스 대신 localhost:8787 포트를 가리키는 테스트 모듈을 작성. 해당 테스트 모듈이 기존의 cloudaflare/cloudflare-go API를 온전히 모방하도록 구현.

최종적인 테스트 과정을 정리하면 아래와 같습니다.

wrangler code 작성 -> 8787 port initialization -> mock cloudflare-go package -> storage driver test code -> storage driver

.github/scripts/initialize-wrangler.sh

export default { async fetch(Request, env) {

    const namespace = env.TEST_NAMESPACE1;

    if (Request.url === "http://localhost:8787/health") {
      return new Response("Success");
    }

    if (Request.url === "http://localhost:8787/writeworkerskvkeyvaluepair") {
      const res = await Request.json();
      const { key, val } = res;
      WriteWorkersKVKeyValuePair(namespace, key, val);
      return new Response("Success");
    }

    else if (Request.url === "http://localhost:8787/listworkerskvkeys") {
      const resp = await Request.json();
      const { limit, prefix, cursor } = resp;
      const list = await ListWorkersKVKeys(namespace, limit, prefix, cursor);
      return new Response(list);
    }

    else if (Request.url === "http://localhost:8787/deleteworkerskvpairbykey") {
      const res = await Request.json();
      const { key } = res;
      await DeleteWorkersKVPairByKey(namespace, key);

      return new Response(key)
    }

    else if (Request.url === "http://localhost:8787/getworkerskvvaluebykey") {
      const key = (await Request.json()).key;
      const res = await GetWorkersKVValueByKey(namespace, key);

      return new Response(res);
    }

    else if (Request.url === "http://localhost:8787/deleteworkerskventries") {
      const res = await Request.json();
      const { keys } = res;
      const newKeys = keys.filter(x => x.length > 0);
      await DeleteWorkersKVEntries(namespace, newKeys);

      return new Response("Success")
    }
  }
}

const GetWorkersKVValueByKey = async (NAMESPACE, key) => {
  const val = await NAMESPACE.get(key);

  return val;
}

const WriteWorkersKVKeyValuePair = async (NAMESPACE, key, val) => {
  await NAMESPACE.put(key, val);

  return "Wrote Successfully"
}

const DeleteWorkersKVPairByKey = async (NAMESPACE, key) => {
  await NAMESPACE.delete(key);

  return "Delete Successfully"
}

const ListWorkersKVKeys = async (NAMESPACE, limit, prefix, cursor) => {
  const resp = await NAMESPACE.list({ limit, prefix, cursor });

  return JSON.stringify(resp.keys);
}

const DeleteWorkersKVEntries = async (NAMESPACE, keys) => {
  for (let key of keys) {
    await NAMESPACE.delete(key);
  }

  return "Delete Successfully"
}

위처럼 실제 Typescript로 작성되어 cloudflare-go 패키지를 모방할 수 있는 스크립트를 작성해주었습니다.

이후, 해당 스크립트를 기반으로 하여 설정이 추가된 wrangler.toml 파일을 만들어주도록 하였습니다.

.github/scripts/initialize-wrangler.sh

main = "index.ts"

kv_namespaces = [
  { binding = "TEST_NAMESPACE1", id = "hello", preview_id = "world" },
]

compatibility_date = "2024-03-20"

[dev]
port = 8787
local_protocol = "http"

이후, npx wrangler dev & 를 실행하여 해당 스크립트를 기반으로 생성된 wrangler 인스턴스를 백그라운드에서 실행해주었습니다.

CI 확인

드디어 모든 테스트가 통과되었습니다..

놀랐던 점은 CI 과정에서 정말 다양한 테스트를 진행하고, 이를 기반으로 단단한 오픈소스 환경을 만들어나간다는 점이었습니다.

이제 모든 기여가 끝났습니다! 내부 스토리지 엔진도 추가했고, 테스트 모듈도 구현했고, 테스트 코드도 작성했고, 벤치마크 테스트도 진행했고, 스토리지 드라이버 코드도 작성했습니다!


다 구현하고 나서 보니까,,,

지금이야 다 구현하고 기여를 마친 뒤이기 때문에 괜찮아 보이지만, 처음에는 단순히 주어진 사양만 구현하면 될 줄 알았습니다.

그러니까, 위에서 언급한 과정의 정확히 역순으로 구현을 진행했습니다. 구현체를 만들고, 테스트코드를 작성하고, pr을 올리고 나서 보니 테스팅할 엔진이 없어서 직접 제작하는 과정을 거쳤습니다.

마냥 단순하게 생각한 부분이 있었는데, 오픈소스는 그보다 훨씬 까다롭게 적용되어야하고, 그만큼 많은 이들이 사용하는 것을 고려해야함을 다시금 깨닫게 되었습니다.

이렇게 큰 규모로 오픈소스 기여를 진행하는 경험이 앞으로 얼마나 될지 모르지만, 오픈소스를 만들면서 고려해야하는 부분과 방법에 대해 확실하게 깨닫게 되는 것들이 많아 좋은 경험이었습니다. 그래서 더욱 기록으로 남기고 싶었고, 이렇게 작성하게 된 것 같습니다.

안타까운 점은 곧 cloudflare/cloudflare-go의 2.0.0 버전이 출시될 예정이라서, wrangler 관련 코드를 전부 갈아엎어야할지도 모른다는,,,

어차피 5월에 Cloudflare R2 도 추가하려고 생각은 하고 있었지만,,,조금 더 할 일이 늘어난 것 같습니다.

그래도 pr 한 번에 얼굴이 맨 위에 박힌 건,,,열심히 코드를 작성했다는 의미라고 생각하고 있습니다.

꿀벌 아니고 호박벌입니다

감사합니다 :)

profile
eng) https://medium.com/@a01091634257
post-custom-banner

0개의 댓글