나는 언제나 뭔가를 만들때 사용하기 편하고, 복잡한 것들은 선택적으로 둘 수 있게 하는 것을 좋아한다.
그렇기에 내 라이브러리를 만들 때에도 이 2가지에 집중하기로 하였다.
이제 이에 기초하여 라이브러리를 작은 모듈들로 대강 설계해 보았고 해당 설계는 아래와 같다.
context.go
jwk.go
decode.go
encode.go
fetch/fetcher.go
golang-jwt.go
golang-jwt
에 호환되는 jwk 라이브러리 제작이 목표였으니 해당 모듈과 호환을 위한 함수들이 존재하는 곳errors.go
최종적으로 연관관계를 대략 표현하자면 위와 같다.
보라색은 외부 라이브러리와 호환을 위한 인터페이스, 파란색은 주로 구조체나 상수들이 선언되는 모듈, 초록색은 특정 작업을 위한 모듈이다.
이제 대략적인 모듈 구성은 설계했고 본격적으로 각 모듈들에 대해 자세히 설계해 보겠다.
Go는 1.7버전부터 고루틴간의 실행 제어를 위해 컨텍스트라는 것을 도입했다.
그런데 컨텍스트에는 단순히 실행 제어 뿐만이 아닌 특정 컨텍스트에서 사용 가능한 KV 형태의 데이터도 저장 가능하다.
컨텍스트는 Go에서 주로 고루틴 제어, 혹은 timeout 기능을 위해 많이 사용된다.
그런데 이번에 내가 구현하는 라이브러리는 fetch/fetcher.go
라는 웹에서 https를 통해 JWK를 얻어오는 기능이 존재한다.
이때 컨텍스트를 제공하면 시간 제한 기능을 구현하기 편리하기에 Context를 decode.go
, encode.go
, fetch/fetcher.go
에 도입하기로 결정하였다.
내가 컨텍스트를 사용하는 목적은 두가지이다.
첫번째는 위에서 기술한 대로 시간 제한이 걸려있는 동작을 구현하기 위해서이고, 두번째는 각 작업 도중 설정할 필요가 있는 옵션을 제공하기 위한 목적이다.
예를 들면 JWK의 Set은 각 키들간에
kid
가 중복이 되지 않게 만드는 것이 권장되지만, 만약 키 중복이 있다면kid
와kty
모두가 동일한 경우가 아니면 일단 규칙에 위반되지 않는다는 사항이 있다.만약 이런 경우 디코더에서는
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*
형태의 함수들이 해당 모듈에 존재한다.
여기에는 에러 메시지를 좀 더 읽기 쉽게 만드는 함수나 구조체가 정의되어있다.
하지만 이렇게만 이야기하면 뭔 소리인지 알기 힘드니 예시를 들어보자.
만약 특정 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
패키지에 호환되도록 에러 모듈을 설계하였다.
어느정도 짜고 보니 에러 모듈은 좀 불안정한 느낌이 있어 재설계에 들어갈 예정이다.
컨셉은 그대로지만 함수명이나 사용법을 수정할 필요가 있어보인다.
해당 모듈은 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
기반으로 검색을 수행하는 메서드라던가 같은 기능들도 해당 모듈에 정의되어 있다.
해당 모듈들은 사실 정의한 것들이 별로 많지 않다.
하지만 해당 모듈들은 실제 대부분의 복잡한 동작에 대한 것들이 들어가서 매우 길다.
다만 지금은 세부 동작을 설계하는 것이 아닌 대략적인 형태만 잡는 것이므로 어느정도 중복되는 내용이 많은 해당 모듈들은 한번에 설계하려 한다.
우선 함수 정의만 구성하자.
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 parameter
와interface
를 통한 함수 옵션을 지정하는 형태가 생긴 것 같다.
사실 위와 같이 함수를 설계하면 라이브러리 작성에 손이 많이가서 귀찮다...
하지만 명확한 설계로 특정 필드에 들어갈 수 있는 값들을 한정시킴으로서 라이브러리 이용자의 실수를 줄일 수 있다는 큰 장점이 있으므로, 분명 이런 형태의 코드가 가치가 있다고 생각한다.
또한 각 함수는 *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
라이브러리와 완전호환되는 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
를 구현해주는 형태로 만들 수 있었을텐데, 상당히 아쉽다.
하지만 이렇게 만든다면 라이브러리간의 의존성이 느슨해질 수 있을 것 같기도 하고... 사실 이 부분은 뭐라고 말하기가 애매해서 장단이 있는 것 같다.
이렇게 대강의 설계의 스케치가 끝난 것 같다.
이제 다음 글부터는 구현과 구현 중 마주친 문제점들과 그 문제점들을 어떻게 해결했는가에 대해 서술할 생각이다.
사실 만약 중요한 프로젝트라면 이정도 설계로 코드 작성에 들어가는 것은 어림도 없지만 이번에 하는 작업들은 일단 취미생활이니 너무 지루하지 않도록 설계는 이정도로 마치고 가장 재미있는 코딩으로 넘어가도록 하자.