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

김성현·2022년 1월 4일
0
post-thumbnail

7편 : 유연한 error를 위하여

프로그래머는 누구나 실수한다. 하지만 결과물에 실수가 있어서는 안된다.
따라서 우리는 실수를 고치기 위해 노력해야 한다.
그렇다면 어떻게 실수를 고칠 수 있을까?

실수라고 하는 건 프로그래밍에서 너무나 흔하고, 사실 불가피한 녀석이다. 그래서 똑똑한 사람들이 많은 토의를 거쳐 에러를 정의하는 몇가지 방법을 정리했는데 Go에서는 이러한 에러를 어떻게 처리해야 하는지에 대해 errors 라는 패키지로 정의하고 있다.

errors 패키지는 매우 작은 패키지로 고작 4개의 함수로 구성되어 있다.(물론 익명 인터페이스가 3개 더 있다.)

이 함수와 인터페이스는 각각 아래와 같다.

// 아래 3개 인터페이스는 원래 익명 인터페이스이다.
// 다만 그러면 설명이 힘드니 임의로 이름을 지어줬다.
type (
    Unwrap interface{
        Unwrap() error
    }
    type As interface{
        As(interface{}) bool
    }
    type Is interface{
        Is(error) bool
    }
)
func New(string) error {...}
func Is(error, error) bool {...}
func As(error, output interface{}) bool {...}
func Unwrap(error) error {...}

해당 패키지는 사실 미완성이지만 Go에서 어떻게 에러를 처리할지에 대한 방향성은 확실히 나타낸다고 생각한다.

Go 에러는 일종의 트리 형태로 표현이 가능하며 트리의 부모를 찾는 함수는 errors.Unwrap이고, 특정 노드와 그 노드의 부모나 조상 중에 특정 Error가 있는지 확인하는 메서드가 errors.Is, errors.Is와 동일하지만 타입을 기반으로 존재여부를 검색할 때에는 errors.As를 사용한다.

해당 패키지는 미완성이라고 했는데 사실 이 패키지가 약간 더 완성에 가까워진 형태가 github.com/pkg/errors에 있다.
내가 알기로는 이 패키지가 go 표준 errors의 원형인걸로 알고 있는데, 해당 패키지는 표준 errors에 있는 모든 내용과 함께 추가적으로 몇몇 편의성 함수가 있다.

다만 이 패키지도 완성이라고는 하기 어렵고 몇년 전부터 개발 진척이 되지 않는 상황이다.
그 이유는 Go 2에서 error에 대해서 대대적인 언어적 변경을 예고한 상황이라, 해당 패키지 기여자들은 해당 패키지를 발전시키기 보다는 Go 언어 자체에 기여하기로 결정한 걸로 알고있다.

이 패키지는 사실상 인터페이스만 구현해 놓은 패키지라 실제 구현체는 각각 만들어야 한다.

그래서 지금부터 내가 errors에 호환되는 라이브러리를 만들면서 있었던 과정을 이야기하고자 한다.

에러를 구현하는 방법들

첫번째 구현법, WrapMessage

제일 첫번째로 생각한 구현은 아래와 같다.(정확히 같지는 않고 대략 이런 느낌이다.)

type wrappedError struct{
    parent Error
    message string
}
func newError(cause Err, format string, args...interface{}) *wrappedError

이렇게 만든 이유는 에러의 종류를 구분하는 것을 구현하기 위해서다.
예를 들어 Key를 디코딩하면서 생길수 있는 에러는 IOError도 있을테고, JSONError도 있을 것이다,혹은 JWK 스펙 미준수 에러일 수도 있다.

이 경우, 아래처럼 확인이 가능하다면 편할 것이다.

// IO에서 문제가 생겼을 때
if errors.Is(err, ErrIOError){...}
// 입력값이 JSON 표준을 미준수할때
if errors.Is(err, ErrJSONError){...}
// JWK 표준을 준수하지 않는 경우에
if errors.Is(err, ErrJWKError){...}

실제로도 wrapedErrorerrors.Unwrap을 지원하기에 위와 같은 형태를 지원한다. 즉 이를 코드로 표현하면 아래 줄과 같다.

err := newError(ErrJWKError, "my jwk error")

if !errors.Is(err, ErrJWKError){
    panic(err)
}
if errors.Is(err, ErrJSONrror){
    panic(err)
}

newError에서 첫번째 패러미터 값으로 주어진 부모 에러값이 일치한다면 errors.Is가 true를 리턴한다.

wrappedError의 장단점

일단 해당 구현은 내가 생각한 구현 중 가장 단순했다.

단순하다는 것이 프로그래밍에서 얼마나 중요한지를 생각하면 이것만으로도 채택하기에는 충분하다.

또 실제로 이러한 형태의 구현은 github.com/pkg/errors에도 동일하게 있고, 실제로도 널리 쓰이는 형태의 error이다.

하지만 이를 만든 뒤 사용하려 하다 보니 불편한 점이 생겨났다.

우선 아래와 같은 코드를 생각해 보자.


err := newError(ErrJWKError, 
    "field '%s' not satisfied cause %v", 
    fieldname, errorcause
)

// 특정 <field>의 에러를 감지하고 싶을 때.
if <여기를 어떻게 채워넣어야 할까?> {...}

만약 위와 같은 사용사례에서 특정 필드 에러 탐지를 위해서는 어떻게 해야 할까?

글세... 딱히 문자열을 분석해서 파싱하는 방법 이외에는 어떻게 해야할지 방법이 떠오르지 않는다.

하지만 그런 방식은 좋지 않다고 생각한다. 만약 에러 메시지가 조금이라도 변경되는 날에는 대재앙이 일어날 수도 있으니까.

또 내 JWK 라이브러리는 상당히 유연한 구조에 많은 옵션들을 제공하기에, 더욱이 이 문제가 심하다. 그렇기에 나는 에러가 조금 더 정보를 많이 담고 있기를 원했다.

물론 메시지 형태의 에러 메시지를 사람은 이해할 수 있겠지만, 컴퓨터도 이해할 수 있는 형태로 에러를 만들기를 원했다. 그렇기에 나는 다른 방식을 고안했다.

두번째 구현법 (msg|field|index|wrap)Error

에러를 더 유연하게 다룰 수 있게 만들기 위해서 나는 위의 3가지 구조체를 고안했다.
이는 아래와 같다.

type wrapError struct{
    parent error
    current error
}
type msgError struct{
    parent error
    msg error
}

type fieldError struct{
    parent error
    msg error
}

type indexError struct{
    parent error
    msg error
}

func newWrapError(parent error, current error) *wrapError
func newMsgError(parent error, format string, args ... interface{}) *msgError
func newFieldError(parent error, index int) *fieldError
func newIndexError(parent error, field string) *indexError

func IsFieldError(err error, field string) bool
func IsIndexError(err error, field string) bool

이렇게 구현하면 이를 사용하는 방법은 다음과 같다.


err := newMsgError(
    newFieldError(ErrJWKError, "<field>")
    "cause by %v", <cause>
)

// 특정 <field>의 에러를 감지하고 싶을 때.
if IsFieldError(err, "<field>") {...}

이렇게 함으로서, 이제 특정 필드에서 에러가 일어난지 알 수 있게 되었다.

두번째 구현의 장단점

일단 이 구현은 일단 문제는 없었다. 하지만 이 구현은 사용하기가 불편했다.

만약 아래와 같은 에러를 사용해야 한다고 생각해 보자.

err := newWrapError(
    newIndexError(newFieldError(ErrJWKSetError, "<field>"), "<index>"),
    newMsgError(
        ErrJWKKeyError
        "cause by %v", <cause>,
    ),
)

이는 보기가 난해한데 간단히 이 에러를 설명하면 다음과 같은 상황에서 일어난다.

  • Set을 디코딩 중에,
  • <field>라는 이름을 가진 json array object의 필드 에서,
  • array index 번째 요소가,
  • "cause by %v", <cause>, 한 이유로 에러가 났다.

직관적인 형태가 아니라 이해하기 힘들었지만, 위와같이 사실은 매우 선형적인 형태의 에러이다.

이 에러가 쓰이는 곳은 JWK의 Set인데, 자세한 예시를 들면 Set을 디코딩 할 때 Set의 keys필드에 있는 JWK Key 배열을 디코딩 중에 특정 키에서 에러가 나는 경우 field의 index에서 에러가 나는 경우가 위와 같다.

즉 의사코드로 표현하면 대략 이런 형태다.

def DecodeSetBy(source):
    rawjson = json.decode(source)
    if "keys" in rawjson and rawjson["keys"] is List[object]:
    	decKeys = rawjson["keys"].map(lambda rawkey : DecodeKeyBy(rawkey))
        for decKey in decKeys:
        	if decKey is error:
            	return <에러 어쩌고 저쩌고>

여기까지는 간단하지만, 실제 이 에러를 만드려면 좀 직관적이지 못하다.
실제 이 에러를 만드는 코드는 아래와 같다.

err := newWrapError(
    newIndexError(newFieldError(ErrJWKSetError, "keys"), i),
    ifError(DecodeKeyBy(rawjson["keys"][i])),
)

이와 같은 형태의 에러는 만든 나도 쓰기가 불편해서 구현을 하면서 계속 신경 쓰였다.

만약 이 코드를 이런 형태로 쓸 수 있으면 좋지 않을까?

err := makeError(
    ErrJWKSetError, 
    FieldError("keys"), 
    IndexError(i),
    ifError(DecodeKeyBy(rawjson["keys"][i]))
)

내 생각에는 이게 훨씬 더 좋아보인다. 그래서 이렇게 만들기로 했다.

마지막 구현 linkedError

사실 위의 사용사례들을 보다보면 결국 errors.Unwrap이란 결국 linked listnext와 별반 다를게 없다는 점에서 착안해 코드를 다시 짜기로 결심했다.

그리하여 최종적으로 만든 코드는 아래와 같다.

type(
    linkedError struct {
        parent  error
        current error
    }
    fieldError struct {
        field string
    }
    indexError struct {
        index int
    }
)
func makeError(err ... errors)
func IndexError(index int) error
func FieldError(field string) error

보다시피 fieldErrorindexErrorlinked list의 요소로 쓰일 뿐 이제 노드로 사용하지 못하게 만들었다.

그러니까, 의사소통을 위해 의사코드 느낌으로 코드를 작성하면 아래 느낌의 코드이다.

struct LinkedError<P : LinkedError<error, error> | error | nil>{
    parent : P
    wraped : error
}

linkedError의 부모 노드는

  • nil : 현재 노드가 루트 노드인 경우
  • linkedError 가 아닌 error인터페이스 : 다음 노드가 루트 노드인 경우
  • linkedError : 노드가 중간 부터 마지막 사이의 경우
    이렇게 사례가 나눠지게 된다.

사실 parent에 값을 넣는 것은 append보다는 extend에 더 가깝다.
GA = makeError(Aerr, Berr, Cerr), GB = makeError(GA, Derr, Eerr) 형식으로 에러를 만들면 GB의 에러 발생 순서는 Eerr -> Derr -> GA 가 아닌, Eerr -> Derr -> Cerr -> Berr -> Aerr순으로 되게 된다. 이는 parent를 검색하는 방법이 errors.Unwrap을 사용하기 때문으로, 일부로 내가 노린 부분이다.

이를 이용해 에러 처리를 하는 시나리오는 다음과 같다.


err := makeError(
    ErrJWKError, 
    FieldError("<field>"), 
    fmt.Errorf("cause by %v", <cause>)
)

// 특정 <field>의 에러를 감지하고 싶을 때.
if errors.Is(err, FieldError("<field>")) {...}

와우!, 이제는 자체구현한 IsFieldError 함수같은 불명확한 것이 아닌 errors 패키지의 errors.Is를 이용해 분석을 할 수 있게 되었다.

특히 에러를 만들때 이런 시나리오에서도 잘 작동한다.

func A() error{
    err := makeError(Aerr, ADetailErr)
    return err
}
func B() error{
    err := makeError(Berr, FieldError("b"), A())
    return err
}

위 코드에서 B 의 특정 b라는 필드가 에러의 원인인 경우 Berr -> FieldError("b") -> A() 순으로 에러를 조합시키면 나중에 확인이 필요할 때는 아래처럼 확인이 가능하다.

err := B()

// err이 Berr때문에 일어난 경우
if errors.Is(err, Berr) {...}
// err이 FieldError("b")때문에 일어난 경우
if errors.Is(err, FieldError("b")) {...}
// err이 Aerr때문에 일어난 경우
if errors.Is(err, Aerr) {...}
// err이 ADetailError때문에 일어난 경우
if errors.Is(err, ADetailError) {...}

에러를 이렇게 만든 경우의 장점

에러를 유연하고 강력하게 만들기 위해 한 이 고민들로서 얻어낼 수 있는 장점은 무엇일까?

일단 내가 생각하는 가장 큰 장점은 특정 조건의 경우 다른 분기를 통해 파싱을 지속해야 하는 경우에 큰 도움이될 것이라고 생각한다.

이러한 예시를 한번 생각해 보면, 만약 JWKSetError이면서 KeynameDuplicated에러인 경우 키 대신 인덱스를 이용하도록 파싱옵션을 바꾸어 다시 디코딩한다고 생각해 보자.

이런 고민 없이 구현했다면 이런 경우에 고생고생하면서 에러 메시지를 분석하며 조건을 검토해야 했겠지만 내가 생각한 구현을 따르면 아래와 같은 코드만으로 위 조건을 만족하는 코드를 짤 수 있다.

s, err := DecodeSet(source)
if err != nil{
	if !(errors.Is(err, JWKSetError) && errors.Is(err, KeynameDuplicated)){
		return err
	}
	s, err = DecodeSet(source, WithKeynameMode(IndexOnly))
}

이렇게 에러를 만들고 사용하면 에러를 만들기도 쉽고, 분석하기도 편하며, 사람이 볼 때에도 충분한 정보를 제공하게 된다.

따라서 나는 이 형태의 에러가 가장 이상적이다 판단했고 내가 지금까지 짠 모든 코드를 리팩토링(... 힘들었다.)해서 에러의 구현을 모두 변경했다.

심지어 실제로는 처음에 1차 구현을 따라 만들었다가 2차 구현으로 바꾸던 도중 3차 구현법으로 전환해서 최종적으로 구현했기에 더더욱 힘들었다.


나는 프로그래밍에서 에러가 제일 중요하다고 생각한다.

프로그래밍에서 에러 처리의 중요성은 두말할 필요도 없는 당연한 말이기는 하지만, 나는 개인적으로 이를 더 중요시하게 된 계기가 있다.

과거에 나는 openGL 프로그래밍을 하면서, 텍스처 관련 오류를 경험한 적이 있었다.

좀 더 자세히 말하자면 아래 코드처럼 opengl을 사용하는 경우 발생하는 오류였다.

// 이미지를 파일에서 읽어 rgb 3채널, 각 채널당 1바이트(=8비트) 단위로 묶어
// 바이트 형태로 전환
byte[] img = imread("이미지 파일", "rgb8")
int texture;
// 그래픽 드라이버의 메모리 포인터 획득
genTexture(&texture, 1);
// 텍스처의 특정 attribute를 설정
setTextureAttribute(text, glWrap, glWrapNo)
// 텍스처에 실제 이미지 데이터를 전송함
sendTexture(texture, img)

위 코드를 노트북에서 짤 때는 잘 돌아갔는데 데스크톱에서는 에러가 나는 증상을 발견했다.

이는 당시 내 PC 환경과 관련이 있는데, 당시 내 데스크탑은 nvidia의 그래픽카드를 이용했고 랩탑은 Intel Iris 그래픽을 이용했다.

문제는 여기서 시작되었다. nvidia 그래픽카드는 반드시 gen이후 실제 생성된 메모리 주소에 texture를 초기화 해야지만 Attribute 값을 변경할 수 있다는 제약조건이 있었다. 그런데 Intel Iris에서는 Gen 이후 attribute에 바로 접근이 가능한, 같은 구현에 세부적인 차이가 있는 부분이 있었다.

setTextureAttribute하고 sendTexture의 순서가 반드시 sendTexture가 먼저 와야만 nvidia의 그래픽 카드가 정상적으로 동작하는 문제였다.

그런데 지금은 해결이 된 상태니 이렇게 쉽게 말이 나오지만 그때는 정말 뒤로 뛰고 앞으로 뛰고 열받아서 난리도 아니였다.

그 당시 에러는 Debug 옵션을 켜도 Fetal error, memory violation 정도의 메시지만 띄워줬는데 이 메시지만 보고는 노트북에서 잘 돌아가던 코드가 데스크탑에서는 안돌아가는 이유를 알 수 없었다.

만약 그때 에러 메시지가 Fetal error, texture must initialize first before using attribute정도로 나왔다면 나는 순식간에 에러를 해결할 수 있었을 것이다.

하지만 그때 그 에러는 그러지 않았고 나는 그 에러를 해결하기 위해 3일 정도 화도 내고 벽에 머리도 박고, 하여간 내 능력의 부족과 증오스런 에러 메시지 사이에서 고통받았다.

그 고통스러웠던 경험 이후, 나는 에러 메시지가 명확한 것을 매우 중요하게 생각하게 되었고 이번에 내 라이브러리는 이정도로 필요할까? 싶을 정도로 에러를 자세하게 기술하는 것을 목표로 잡았다.

이 고민이 라이브러리의 완성도를 높여줄 수 있었을까?

잘 모르겠지만, 의미가 있었다면 기쁠 것 같다.

opengl은 저수준 그래픽 api 이고 성능을 중시하는 그래픽스 영역의 라이브러리기에 에러 메시지 조차도 성능에 방해되지 않게 만들기 위해 쳐낸 것은 납득하지만... 그래도 에러 메시지는 길고 자세한게 좋다고 생각한다.
아니면 Debug 옵션일때만 ifdef같은 거로 자세하게 나오게 만들어 주던가...

그때만 생각하면 지금도 치가 떨린다

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

0개의 댓글