JWK 라이브러리를 만들어 보자! #5

김성현·2021년 12월 31일
0
post-thumbnail

5편 : 라이브러리 설계, 그리고 API 정의

나는 언제나 뭔가를 만들때 사용하기 편하고, 복잡한 것들은 선택적으로 둘 수 있게 하는 것을 좋아한다.

그렇기에 내 라이브러리를 만들 때에도 이 2가지에 집중하기로 하였다.

  • 쓰기 편할 것.
  • 복잡한 것은 선택적인 옵션으로 둘 것.

이제 이에 기초하여 라이브러리를 작은 모듈들로 대강 설계해 보았고 해당 설계는 아래와 같다.

  • context.go
    각 함수들에서 사용되는 옵션값들을 정의하고 이에 관련된 편의성 함수들이 저장되는 곳
  • jwk.go
    RFC에 정의된 JWK 구조에 따른 Go 언어 구조체들이 선언되고 구현되는 곳
  • decode.go
    JWK를 입력으로 부터 얻어내는 함수와 프로시져들이 존재하는 곳
  • encode.go
    JWK를 JSON 형태로 변환하는 함수들이 존재하는 곳
  • fetch/fetcher.go
    대개 JWK는 HTTP 웹 상에 존재하므로 URL을 통해 키나 셋을 얻어오는 기능이 존재하는 곳
  • golang-jwt.go
    처음부터 이 라이브러리는 golang-jwt에 호환되는 jwk 라이브러리 제작이 목표였으니 해당 모듈과 호환을 위한 함수들이 존재하는 곳
  • errors.go
    이 라이브러리에서 디코딩, 인코딩, 펫칭중 생기는 오류를 명료하게 정의하는데 도움을 주는 상수나 함수가 존재하는 곳
  • 그 외의 것들은 공통적인 유틸 함수나 enum 정의에 사용된다.

    최종적으로 연관관계를 대략 표현하자면 위와 같다.
    보라색은 외부 라이브러리와 호환을 위한 인터페이스, 파란색은 주로 구조체나 상수들이 선언되는 모듈, 초록색은 특정 작업을 위한 모듈이다.

이제 대략적인 모듈 구성은 설계했고 본격적으로 각 모듈들에 대해 자세히 설계해 보겠다.

context.go

Go는 1.7버전부터 고루틴간의 실행 제어를 위해 컨텍스트라는 것을 도입했다.

그런데 컨텍스트에는 단순히 실행 제어 뿐만이 아닌 특정 컨텍스트에서 사용 가능한 KV 형태의 데이터도 저장 가능하다.

컨텍스트는 Go에서 주로 고루틴 제어, 혹은 timeout 기능을 위해 많이 사용된다.

그런데 이번에 내가 구현하는 라이브러리는 fetch/fetcher.go 라는 웹에서 https를 통해 JWK를 얻어오는 기능이 존재한다.

이때 컨텍스트를 제공하면 시간 제한 기능을 구현하기 편리하기에 Context를 decode.go, encode.go, fetch/fetcher.go에 도입하기로 결정하였다.

내가 컨텍스트를 사용하는 목적은 두가지이다.

첫번째는 위에서 기술한 대로 시간 제한이 걸려있는 동작을 구현하기 위해서이고, 두번째는 각 작업 도중 설정할 필요가 있는 옵션을 제공하기 위한 목적이다.

예를 들면 JWK의 Set은 각 키들간에 kid가 중복이 되지 않게 만드는 것이 권장되지만, 만약 키 중복이 있다면 kidkty모두가 동일한 경우가 아니면 일단 규칙에 위반되지 않는다는 사항이 있다.

만약 이런 경우 디코더에서는 kid가 동일한 것이 있는 Set도 허용할 것이다.

하지만 컨텍스트에 DisallowDuplicatedKID라는 bool 필드가 있고 이 값에 따라 kty이 다르더라도 kid가 같은 게 있다면 오류를 내뿜게 해주는, 일종의 세분화된 검증 설정이 가능하다면 편리할 것이라고 생각되었다.

따라서 위의 기능들을 구현하기 위한 구조체나 인터페이스들을 해당 모듈에 정의하기로 하였다.

추가로 해당 모듈에는 Encode 함수나 Decode, Fetch 과정에서 선택적으로 설정 가능한 값들에 대한 선언 역시도 존재한다.

이런 함수들에 선택적인 옵션들을 제공하는 방법은 아래 코드와 같다.

// 시간 제한이 없는 키 다운로드 요청 함수
key, err := jwk.FetchKey(src)

// 1초의 시간 제한이 있는 키 다운로드 요청 함수
key, err := jwk.FetchKey(src, jwk.WithTimeout(1 * time.Second))

이런 형태의 API를 위한 With* 형태의 함수들이 해당 모듈에 존재한다.

errors.go

여기에는 에러 메시지를 좀 더 읽기 쉽게 만드는 함수나 구조체가 정의되어있다.

하지만 이렇게만 이야기하면 뭔 소리인지 알기 힘드니 예시를 들어보자.
만약 특정 JSON을 파싱할때 object의 n 필드에는 string타입의 base64 std로 인코딩된 값이 와야 한다고 치자.
이때 에러를 아래와 같은 식으로 낸다고 쳐 보자.

...
if n is not string{
    return nil, fmt.Errorf("'n' field not satisfied `string` type")
}
if n is not base64.stdenc{
    return nil, fmt.Errorf("'n' field not satisfied `base64 std`")
}

이 경우 에러 타입은 분석이 코드상에서 매우 함들어진다.
예를 들어 해당 함수를 쓰면서 에러가 났을때 만약 base64 url safe인코딩이 아니여서 난 에러면 다른 입력값을 사용하도록 코드를 통해 다시 실행해야 한다고 생각해 보자.

과연 그게 가능하겠는가?

물론 Error()함수를 쓰고, 문자열을 regex로 분석하면 되긴 하겠지만 별로 우아한 해법은 아닐 것이다.

이런 기능을 위해 Go는 errors라는 패키지를 구현해 뒀다.
이 경우를 예로 들면 위 설명을 코드로 구현하면

_, err := FetchKey(src0)
if errors.Is(err, ErrorAt("n", ErrInvalidBase64)){
	k, err = FetchKey(src1)
}

이런 식으로 코드가 작성 가능해 진다. 만약 errors.Is() 없이 구현한다면

_, err := FetchKey(src0)
if strings.Contain(err.Error(), "'n' field not satisfied `base64 std`"){
	k, err = FetchKey(src1)
}

같은 형태로 구현해야 할텐데 위 코드같은 형태는 개인적으로 아주아주아주 나쁘다고 생각하기에 errors 패키지에 호환되도록 에러 모듈을 설계하였다.

어느정도 짜고 보니 에러 모듈은 좀 불안정한 느낌이 있어 재설계에 들어갈 예정이다.
컨셉은 그대로지만 함수명이나 사용법을 수정할 필요가 있어보인다.

jwk.go

해당 모듈은 RFC7517(JWK), RFC7518(JWA)에 따른 키, 셋들을 Go 구조체, 혹은 인터페이스의 형태로 선언한 내용들이 존재하는 모듈이다.

우선 간단히 목표로 하는 형태는 다음과 같다.

type Set struct{
    Keys []Key
}
type Key interface{
    ... JWK 관련 필드들 ...
}
type RSAPrivateKey struct {
    Key // Key 인터페이스 구현
    ... RSA 개인키 관련 필드들 ...
}
... RSAPublicKey, EC 개인 공개키 등등 ...

위에서 기술한것과 같이 JWK Set을 만족시키는 구조체와 JWK Key를 만족시키는 다양한 구조체들과 인터페이스를 정의한 곳이다.

또한 Set에서 kid 기반으로 검색을 수행하는 메서드라던가 같은 기능들도 해당 모듈에 정의되어 있다.

Encode, Decode, Fetch/Fetcher 모듈

해당 모듈들은 사실 정의한 것들이 별로 많지 않다.
하지만 해당 모듈들은 실제 대부분의 복잡한 동작에 대한 것들이 들어가서 매우 길다.

다만 지금은 세부 동작을 설계하는 것이 아닌 대략적인 형태만 잡는 것이므로 어느정도 중복되는 내용이 많은 해당 모듈들은 한번에 설계하려 한다.

우선 함수 정의만 구성하자.

func DecodeKey(src io.Reader, options ... OptionalDecodeKey) (Key, error)
func DecodeSet(src io.Reader, options ... OptionalDecodeSet) (*Set, error)
func EncodeKey(src io.Reader, options ... OptionalEncodeKey) (Key, error)
func EncodeSet(src io.Reader, options ... OptionalEncodeSet) (*Set, error)
func FetchKey(src io.Reader, options ... OptionalFetchKey) (Key, error)
func FetchSet(src io.Reader, options ... OptionalFetchSet) (*Set, error)

해당 함수들은 편의성을 위해 variadic parameter를 사용하는 함수들이다.
해당 패턴을 사용하면 아래의 ts 처럼 선택적으로 옵션을 주는 함수 호출이 가능해진다.

// 옛날에는 이런 형태의 패턴이 많았는데 요즘은 옵션값들은 아래처럼 정의하는게 트렌드인 것 같다.
function decodeKey(src: Reader, optionA?: boolean, optionB?:boolean)
// 오픈소스 API 들 중 많은 것들이 아래 형태를 따른다.
function decodeKey(src: Reader, options?: { A? : boolean, B? : boolean})

JS, TS 에서는 undefined라는 무척 특이한 개념이 있어서 위와 같은 형태가 유행인 것 같다.
Go에서 유사한 내용을 찾자면 *type같은 포인터 타입을 쓸 수 있지만, 원시 자료형의 포인터는 값을 할당할 때에 var a *int = &1같은 상수값을 포인터 형태로 초기화를 할 수가 없어 매우 불편하다. 따라서 JS, TS 와는 다르게 Go에서는 variadic parameterinterface를 통한 함수 옵션을 지정하는 형태가 생긴 것 같다.

사실 위와 같이 함수를 설계하면 라이브러리 작성에 손이 많이가서 귀찮다...
하지만 명확한 설계로 특정 필드에 들어갈 수 있는 값들을 한정시킴으로서 라이브러리 이용자의 실수를 줄일 수 있다는 큰 장점이 있으므로, 분명 이런 형태의 코드가 가치가 있다고 생각한다.

또한 각 함수는 *By()형태의 기존 함수명에 By라는 전치사가 붙은 형태가 존재하는데 해당 함수들은 context.Context를 바로 입력으로 받는 함수들이다.

해당 함수들은 아래와 같다.

func DecodeKeyBy(ctx context.Context, src io.Reader) (Key, error)
func DecodeSetBy(ctx context.Context, src io.Reader) (*Set, error)
func EncodeKeyBy(ctx context.Context, src io.Reader) (Key, error)
func EncodeSetBy(ctx context.Context, src io.Reader) (*Set, error)
func FetchKeyBy(ctx context.Context, src io.Reader) (Key, error)
func FetchSetBy(ctx context.Context, src io.Reader) (*Set, error)

해당 함수들은 context.Context의 실행중 취소가 지원되며 위의 함수들에서 지정하는 옵션들을 지정하는 것도 가능하다.

사실 위의 함수들은 내부적으로 적절히 컨텍스트를 만들어 아래 함수로 제공하는데 예를 들면 다음과 같다.

func DecodeKey(src io.Reader, options ... OptionalDecodeKey) (Key, error){
    ctx := context.Background()
    for _, option := range options{
    	ctx := option.WithDecodeKey(ctx)
    }
    return DecodeKeyBy(ctx, src)
}
func DecodeKeyBy(ctx context.Context, src io.Reader) (Key, error)

또 많은 With 형태로 다루기에는 옵션을 너무 많이 지정해야 할 때에는 아래처럼 함수 호출도 가능하도록 설계하였다.

DecodeSomething(src, 
    WithOptionA(true or false), 
    WithOptionB(true or false), 
    WithOptionC(true or false), 
    WithOptionD(true or false), 
    WithOptionE(true or false)
)

DecodeSomethingBy(&OptionDecodeSomething{
    A : true or false,
    B : true or false,
    C : true or false,
    D : true or false,
    E : true or false,
}, src)

이는 각 함수에 대응하는 Option* 구조체가 존재하고 각 구조체가 context.Context를 구현하였기에 이런 형태가 가능하다.

실제로도 내부적으로는 컨텍스트에 해당 구조체를 추가하는 방식으로 구현되어 있다.

golang-jwt.go

처음부터 본 프로젝트의 목표는 golang-jwt라이브러리와 완전호환되는 jwk 라이브러리 작성이 목표였다.

하지만 특정 라이브러리와 너무 밀접하게 라이브러리를 제작하면 혹시나 다른 라이브러리와 함께 사용하는 사례에서 불편을 야기할 수 있을 것 같았다.

따라서 처음부터 golang-jwt라이브러리와의 종속관계는 특정 파일에서 모두 담당하고 빌드 플레그를 지정하면 종속성을 완전 제거 가능하도록 만들기 위해 해당 파일을 분리하였다.

현재로서 해당 파일에 구현할 핵심은 바로 Set, Key, 혹은 Fetcher에서 golang-jwt/jwt.Keyfunc에 호환되는 함수를 제공하는 것이다.

이게 말로하면 복잡한데 코드를 보며 설명하면 쉽다. golang-jwt를 예로 들어보자.
해당 라이브러리로 jwt를 jws에 따라 검증하려면 한가지 함수 포인터를 제공해야 하는데 해당 함수의 형태가 jwt.Keyfunc이다.

해당 함수의 정의는 아래와 같다.

type Keyfunc func(*jwt.Token) (interface{}, error)

해당 함수는 특정 토큰이 주어졌을 때 해당 토큰을 검증하기 위한 키를 제공하는 함수에 대한 정의인데 실제 사용 사례는 다음과 같다.

prik, _ := rsa.GenerateKey(rand.Reader, 2048)
pubk := prik.Public()
message := map[string]interface{}{
    "msg" : "Hello, World"
}
compactjws := <대충 message를 개인키를 써서 서명하고 *jwt.Token으로 바꿔주는 함수>(message, prik)
jwttoken, err := jwt.Parse(compactjws, func(token *jwt.Token) (interface{}, error){
	return pubk, nil
})
<대충 에러 체크하는 코드>

대략 이런 느낌인데 여기서 jwt.Parse의 두번째 패러미터가 jwt.Keyfunc타입이다.

우리가 해당 go 파일에서 만들 것은 이 jwt.Keyfunc를 만족하는 함수를 리턴해주는 함수들을 정의하는 것이다.

즉 대략 이런 함수를 만들어야 한다.

func LetKeyfuncFromKey(key Key) jwt.Keyfunc

사실 아쉬운게 jwt.Keyfunc가 만약 인터페이스였다면 Key 자체를 jwt.Keyfunc를 구현해주는 형태로 만들 수 있었을텐데, 상당히 아쉽다.

하지만 이렇게 만든다면 라이브러리간의 의존성이 느슨해질 수 있을 것 같기도 하고... 사실 이 부분은 뭐라고 말하기가 애매해서 장단이 있는 것 같다.


이렇게 대강의 설계의 스케치가 끝난 것 같다.
이제 다음 글부터는 구현과 구현 중 마주친 문제점들과 그 문제점들을 어떻게 해결했는가에 대해 서술할 생각이다.

사실 만약 중요한 프로젝트라면 이정도 설계로 코드 작성에 들어가는 것은 어림도 없지만 이번에 하는 작업들은 일단 취미생활이니 너무 지루하지 않도록 설계는 이정도로 마치고 가장 재미있는 코딩으로 넘어가도록 하자.

profile
수준 높은 기술 포스트를 위해서 노력중...

0개의 댓글