이력서를 정리하면서 내가 많은걸 배웠던 버그 리폿팅 & 기능 개발에 대해서도 같이 정리하는 편이다.
그 중 블로그에 올려도 괜찮겠다 싶은 사례가 있어서 가져왔다. 이것말고도 더 많지만 아직 글이 정리가 안되어서 다듬어지면 또 가져와보겠다..ㅎ
3문단으로 이 글을 요약하자면 아래와 같다.
[Situation]
- RPC 통신으로 받은 데이터를 바로 프로젝트 안에서 사용하는 변수에 할당해주는 unpack 함수 개발 필요
(unpack 함수를 지원하던 패키지를 확장성 문제로 사용하지 않게됨)
[Task]
- 기존 사용하던 외부 패키지의 함수는 ABI 규격을 이용해 변수에 알아서 할당을 해주었음. 그러나, gateway 서버를 사용하는 현재 프로젝트 구조상 ABI를 참고할 수 없음
- 현재 모든 서버가 공통적으로 RPC 응답을 byte 배열로 받아 interface로 marshal 해주는데 타입을 지정하지 않으면 go는 숫자를 모두 float64로 인식함 (기존 unpack 함수를 사용할 때는 데이터를 RPC 통신을 통해 받지 않기도 했을 뿐더러, 기존 함수는 엄격한 type saftey를 강제해서 RPC 응답으로 받은 숫자를 big.Int 나 decimal로 올바르게 넣어주지 못했을것이다.)
[Action & Result]
기존 unpack 함수에서 사용하는 set 함수를 아래와 같이 바꿈
- ABI 대신 응답 데이터들의 순서를 바탕으로 변수 구조(배열, 구조체 등)에 데이터 할당
- 타입 형변환이 가능한지 여부를 검사해 변수에 들어갈 수 있으면 형변환 후에 변수에 데이터 할당
- 프로젝트 배경 설명
wemix 3.0 사이트 작업을 하면서 기존에 klaytn 네트워크와 통신하던 동작하던 서비스들이 mainnet 네트워크와도 통신을 할 수 있게 확장하는 프로젝트를 하게 되었습니다. 기존 서버들을 전부 수정함과 동시에 mainnet 네트워크 통신을 위한 새로운 서버들도 개발을 해야했기에 한 사람이 하나 이상의 서버를 맡을 만큼 굉장히 큰 프로젝트였습니다. 저는 기존 wemixplay 서비스의 API 서버를 맡았습니다.
메인넷은 이더리움 기반이고 클레이튼은 이더리움을 fork 해온거나 마찬가지이기 때문에 이름이나 동작이 비슷하거나 동일한 패키지가 많아 충돌이 일어날 수 있었습니다. 따라서 현재 서버들에서 클레이튼 패키지와의 종속성을 없애고 블록체인 네트워크와의 통신은 gateway 서버를 통하도록 변경하기로 했습니다. 그러려면 패키지에서 사용했던 함수들을 공통함수로 다시 만드는 과정이 수반되어야 했는데, 모두 수월하고 교체할 수 있었지만 딱 하나 Unpack 함수가 관건이었습니다.
- Unpack 함수 설명
// Unpack output in v according to the abi specification
func (abi ABI) Unpack(v interface{}, name string, output []byte) (err error) {
if len(output) == 0 {
return fmt.Errorf("abi: unmarshalling empty output")
}
// since there can't be naming collisions with contracts and events,
// we need to decide whether we're calling a method or an event
if method, ok := abi.Methods[name]; ok {
if len(output)%32 != 0 {
return fmt.Errorf("abi: improperly formatted output")
}
return method.Outputs.Unpack(v, output)
} else if event, ok := abi.Events[name]; ok {
return event.Inputs.Unpack(v, output)
}
return fmt.Errorf("abi: could not locate named method or event")
}
Unpack 함수는 rpc 통신으로 받은 response 데이터 값을 현 서버에서 사용하길 원하는 result 변수 안에 할당해주는 함수 입니다. 이 때 할당은 ABI 규격(specification)에 맞게 이뤄지고, result 파라미터는 포인터 형태로 넘겨 주어야 합니다. 다시 말해, rpc 통신으로 받은 데이터를 바로 프로젝트 안에서 사용하는 변수로 이용하기 쉽게 만들어주는 함수입니다.
Unpack 함수를 더 자세히 들여다 보면, ABI를 통해 메소드 혹은 이벤트를 호출하고, 그에 따라 따라 분기가 나뉘는걸 볼 수 있습니다. 이 프로젝트의 경우에는 스마트 컨트랙트의 메소드 호출만을 하기 때문에 해당 경우를 자세히 설명하겠습니다.
// Unpack performs the operation hexdata -> Go format
func (arguments Arguments) Unpack(v interface{}, data []byte) error {
// make sure the passed value is arguments pointer
if reflect.Ptr != reflect.ValueOf(v).Kind() {
return fmt.Errorf("abi: Unpack(non-pointer %T)", v)
}
marshalledValues, err := arguments.UnpackValues(data)
if err != nil {
return err
}
if arguments.isTuple() {
return arguments.unpackTuple(v, marshalledValues)
}
return arguments.unpackAtomic(v, marshalledValues)
}
메소드를 호출해서 가져온 output(data 파라미터로 들어온 ABI로 인코딩된 hex data)을 ABI 규격에 맞춰 interface 배열(marshalledValues)로 변환합니다. output(data)이 tuple 인지 단일 값인지에 따라 처리방식이 달라지므로, ABI 를 참고하여 분기처리가 되어 있는 것을 볼 수 있습니다.
공통적인 unpack 과정은 다음과 같습니다.
- 해당 outputs(marshalledValues)들이 result에 들어갈 수 있는 타입인지 확인하고,
- ABI 규격에 맞춰 result 타입의 필드명 혹은 index를 기준으로 outputs을 result에 assign 합니다.
Assign 방식은 result 각 필드 혹은 원소의 포인터에 output 주소를 set 하는 식으로 작동합니다. result와 outputs 모두 go의 reflect 패키지를 이용해 Value 객체로 바꿔주는데, Value는 ptr라는 내부 변수를 이용해 가지고 있습니다. 이 ptr 변수를 이용해 output 값을 result 변수에 set 할 수 있는 것입니다.
type Value struct {
// typ holds the type of the value represented by a Value.
typ *rtype
// Pointer-valued data or, if flagIndir is set, pointer to data.
// Valid when either flagIndir is set or typ.pointers() is true.
ptr unsafe.Pointer
flag
}
func set(dst, src reflect.Value, output Argument) error {
dstType := dst.Type()
srcType := src.Type()
switch {
case dstType.AssignableTo(srcType):
dst.Set(src)
case dstType.Kind() == reflect.Interface:
dst.Set(src)
case dstType.Kind() == reflect.Ptr:
return set(dst.Elem(), src, output)
default:
return fmt.Errorf("abi: cannot unmarshal %v in to %v", src.Type(), dst.Type())
}
return nil
}
이 과정이 끝나면 우리는 rpc call 한 값을, 원하는 go 타입으로 받을 수 있게 됩니다.
- 문제 원인
해당 패키지를 내부 함수로 옮기는데 어려웠던 이유가 있습니다. klaytn 패키지에서는 스마트 컨트랙트(이하 컨트랙트)와 통신하는 rpc 객체를 만들면서 해당 컨트랙트의 address, service ID 등을 통해 rpc 구조체 안에 ABI 객체를 직접 가지고 있었습니다. 이 ABI 객체의 메소드로, abi 규격을 이용한 unpack 메소드가 있습니다. 아래는 klaytn의 ABI 구조체 선언 입니다.
// The ABI holds information about a contract's context and available
// invokable methods. It will allow you to type check function calls and
// packs data accordingly.
type ABI struct {
Constructor Method
Methods map[string]Method
Events map[string]Event
}
하지만 이제 블록체인 네트워크와의 rpc 통신은 모두 gateway에서 담당하게 되었고, 제가 맡은 서버는 gateway와의 rpc 통신을 통해 데이터를 전달 받는 상황이었습니다. 따라서 gateway와 통신하는 rpc 객체는 당연히 ABI 객체를 가지고 있지 않았고 이는 다른 서버들도 마찬가지였습니다. 데이터를 필요한 변수로 받기 위해 Unpack 이 필요한 서버들이 Unpack을 사용할 수 없게 된 것입니다. (Unpack은 ABI 규격을 보며 output을 go 타입에 넣어주는데 ABI 객체를 통해 해당 정보를 알 수 없음)
- 해결 방법 모색 1
응답 데이터는 interface이므로 기존 go 방식대로 interface를 원하는 타입으로 assertion 해서 사용하면 된다고 생각했습니다. 즉, unpack 함수가 굳이 필요가 없다고 판단한 것이죠.
그런데 *big.Int 값이 응답으로 넘어오면서 float64 형태로 인식 되어 big.Int 값으로 assertion이 되지 않아 값을 받지 못하는 에러 상황이 발생하였습니다. big.Int 인 값을 float64로 받기에는 overflow가 발생하여 더미값이 들어가는 치명적인 문제가 발생할 수 있기에 반드시 big.Int 변수로 데이터를 받아야 했습니다.
- 에러 원인 1
big.Int로 전송한 데이터를 받아서 assertion 할 경우에는 float64로 인식되는 이유는 go 자체에 있었습니다.
내부 서버끼리 통신할 경우의 응답 형식은 convention으로 정해져 있습니다. 응답 헤더와 데이터로 이루어져 있고, 이때 데이터는 interface 형식으로 받게 되어 있습니다. gateway도 해당 형식에 맞춰 데이터를 보내고, 다른 서버에서 응답을 읽을 때도 해당 형식으로 읽게 됩니다.
gateway에서 rpc call 해서 받은 응답을 우선 byte 배열로 읽어 옵니다. []byte를 공통 응답 형식에 맞게 marshal 시킵니다. 이처럼 byte 배열을 interface 로 marshal 하면서 타입을 지정하지 않으면 go는 숫자를 모두 float64로 인식해버립니다. 이 때문에 gateway에서 big.Int 데이터를 그대로 전송해도 float64로 인식을 해버린 것이죠.
데이터가 big.Int가 아닌 int, float 등으로 오는 경우도 있기에 marshal 할 때 타입을 강제해줄 수는 없었습니다.
- 해결 방법 모색 2
기존 unpack 처럼 포인터로 값을 assign 하기 보다는 interface 인 장점을 최대한 이용해서 문제를 해결하려고 했습니다. (Unpack 자체를 다시 구현하기에는 복잡해서 최대한 피하려고 했었죠…) 그래서 interface로 받은 output을 marshal, unmarshal 하여 result에 넣어주는 방법을 생각했습니다.
기존에 result를 선언만하고 초기화를 안한 상태에서 unpack을 했었기 때문에 이번 방법에서도 그렇게 시도했지만 실패했습니다. marshal, unmarshal 의 경우에는 result가 반드시 초기화가 되어야 했습니다! unmarshal 된 데이터를 저장할 유효할 대상이 필요하고, nil 포인터에는 값을 저장할 유효한 메모리 위치가 없기 때문입니다.
result 변수를 초기화해주면 되지만! 기존 로직을 그대로 재현하고 싶었기 때문에 이 방법은 우선 보류했습니다. (지금보니 이 경우는 해당되지 않지만 마음 한 구석에 go 컴파일러에서 경고가 나타날까봐 우려도 있었습니다.)
- 해결 방법 모색 3
결국 unpack을 다시 구현하기로 결심했습니다. 기존 Unpack을 따르되, ABI 규격을 사용하는 부분을 덜어주기만 하면 된다고 생각했습니다.
그래서 output 데이터를 result로 넣어줄 때, ABI 규격을 통해 필드명이나 index를 확인하는 부분을 빼고, 그저 순서대로 값을 넣어주도록 코드를 짰습니다. 예를 들어 호출하고자 하는 메소드의 리턴 값이 [id, name] 순서로 되어 있는 배열이라면 result도 {id, name} 순서로 구조체를 선언하여 output[0]은 무조건 result의 첫번째 필드인 id로 들어가게 하는 것이죠. (result를 선언할 때 output 순서를 따르도록 함. 이전에는 순서 상관없이 필드가 존재하기만 하면 됐었음.)
그러나 문제는 또 big.Int 데이터가 float64로 인식되어 big.Int 포인터에 할당해 줄 때 발생했습니다. float64로 인식되는 문제를 해결하지 않으면 답이 없어 보였습니다.
- 기존 unpack은 왜 result 초기화가 필요 없나요?
(4번 코드 참고) 기존 set에서는 assignable 하면 바로 result에 output을 넣어주는 것을 볼 수 있습니다. 기존에는 big.Int 값을 공통 응답 형식(컨벤션)으로 받지 않고, 바로 원하는 result 변수에 넣어줬습니다. 때문에 big.Int 값이 *big.Int 에 assignable 하다고 판단되어 값이 제대로 들어갔습니다.
그러나 현재 상황에서는 float64로 인식되므로 *big.Int 타입에 assignable 하지 않아서 pointer case로 넘어가게 됩니다. 그러면 재귀 호출을 통해 포인터 값의 Elem(즉, 포인터가 가리키는 값)에 output이 할당 가능한지 확인하는데 포인터가 가리키는 값이 nil이므로 당연히 할당을 할 수 없어 에러가 발생합니다.
즉, pointer case로 넘어가면서 포인터 자체가 아닌 포인터가 가리키는 값(nil)에 값을 넣어주는 상황이 되어 에러가 발생한 것입니다.
- 해결 방법 모색 4
포기할 수 없어 에러가 나는 부분, 포인터에 값을 할당해주는 Set 함수(4. set 함수에서 호출하는 Set 메소드)를 더 분석했습니다. Value 메소드 Set을 쓰지 않고 내부에서 커스텀한 Set 함수를 쓰면 되지 않을까 하는 희망이 있었습니다.
func (v Value) Set(x Value) {
v.mustBeAssignable()
x.mustBeExported() // do not let unexported x leak
var target unsafe.Pointer
if v.kind() == Interface {
target = v.ptr
}
x = x.assignTo("reflect.Set", v.typ, target)
if x.flag&flagIndir != 0 {
if x.ptr == unsafe.Pointer(&zeroVal[0]) {
typedmemclr(v.typ, v.ptr)
} else {
typedmemmove(v.typ, v.ptr, x.ptr)
}
} else {
*(*unsafe.Pointer)(v.ptr) = x.ptr
}
}
assignTo라는 함수에서 값(x)이 주어진 변수(v)에 assignable 한지 검사합니다. 여기가 float64를 big.Int에 넣어줄 수 없다는 에러가 발생한 부분입니다.
그래서 할당 여부를 검사하지 않고 포인터를 형변환해서 강제로 넣어주는 default 로직을 무조건 쓸 수 있게 Set 함수를 밖에 새로 만드려고 했는데, ptr 변수 자체가 Value 객체 안의 내부 변수라 외부 함수에서 접근할 수 없어 이 방법도 할 수 없었습니다.
결국 다시 해결 방안 모색 3 으로 돌아가, set 함수 단에서 해결이 필요 했습니다!
- 해결 방법
기존 unpack에서 사용하는 set 함수는 엄격한 type saftey를 강제합니다. 그래서 호환이 되는 타입들일지라도 다른 타입 간의 direct assign을 허용하지 않습니다.
따라서 strict 하게 타입을 검사하진 않고, convertible 여부를 체크한 다음, output 값의 타입이 result 타입에 형변환을 해서 들어갈 수 있다면 명시적으로 convert를 시켜주는 로직을 추가한 set 함수를 만들기로 결정했습니다.(float64과 big.Int는 convert 가능하기 때문)
또한 assignable 하지 않아 pointer case로 넘어가서 발생되는 에러 역시, 예외처리를 하도록 했습니다. 프로젝트에서 쓰는 *big.Int 타입과 *decimal.Decimal 타입은 Elem 타입으로 재귀호출하지 않고, 바로 포인터 변수에 값을 할당할 수 있도록 했습니다. Set 함수를 호출하면 또 에러가 발생되기 때문에 이 경우는 marshal, unmarshal 을 이용해 값을 할당했습니다.
그렇게 해서 완성한 제 코드는 아래와 같습니다. 응답값을 Unpack까지 해주는 call 함수를 완성했고, 현재까지 운영환경에서 에러 없이 동작하고 있습니다. (영어 주석도 제가 달았어요!)
// unpack data based on result type
func (g *GateWayRouter) Call(result interface{}, chainName, contractName, method string, args ...string) error {
data, err := g.post(g.ChainGateWay[chainName].Url, &protocol.LowCallReq{
Chain: chainName,
Contract: contractName,
Method: method,
Args: args,
})
if err != nil {
return err
}
d := data.([]interface{})
if err := Unpack(result, d); err != nil {
g.log.Error("Failed to unpack data", "err", err, "data", d)
return err
}
return nil
}
func Unpack(result interface{}, data []interface{}) error {
if len(data) != 1 {
// make sure the passed value is arguments pointer
if reflect.Ptr != reflect.ValueOf(result).Kind() {
return fmt.Errorf("argument should be a pointer %T", result)
}
var (
value = reflect.ValueOf(result).Elem()
typ = value.Type()
kind = value.Kind()
)
for i, b := range data {
reflectValue := reflect.ValueOf(b)
switch kind {
case reflect.Struct:
if err := set(value.Field(i), reflectValue); err != nil {
return err
}
case reflect.Slice, reflect.Array:
if value.Len() < i {
return fmt.Errorf("insufficient number of arguments for unpack, want %d, got %d", len(data), value.Len())
}
v := value.Index(i)
if err := requireAssignable(v, reflectValue); err != nil {
return err
}
if err := set(v.Elem(), reflectValue); err != nil {
return err
}
default:
return fmt.Errorf("cannot unmarshal tuple in to %+v", typ)
}
}
return nil
} else {
elem := reflect.ValueOf(result).Elem()
kind := elem.Kind()
reflectValue := reflect.ValueOf(data[0])
if kind == reflect.Struct {
return set(elem.Field(0), reflectValue)
}
return set(elem, reflectValue)
}
}
// reflect.Value.Set only operate when dst, src types must match perfectly
// this provides flexibility of original Set function
func set(dst, src reflect.Value) error {
dstType := dst.Type()
srcType := src.Type()
switch {
case dstType.AssignableTo(srcType):
dst.Set(src)
case dstType.Kind() == reflect.Interface:
dst.Set(src)
case dstType.Kind() == reflect.Ptr:
if dstType.String() == "*decimal.Decimal" || dstType.String() == "*big.Int" {
// force to assign data to unassignable pointer type(cannot use reflect.Value.Set)
return unpack(src.Interface(), dst.Interface())
}
return set(dst.Elem(), src)
case dstType.Kind() == reflect.Array:
for i := 0; i < src.Len(); i++ {
srcElem := src.Index(i)
dstElem := dst.Index(i)
d := dstElem.Interface()
if b, err := json.Marshal(srcElem.Interface()); err != nil {
return err
} else {
if err := json.Unmarshal(b, &d); err != nil {
return err
}
}
dValue := reflect.ValueOf(d)
if dstElem.Type().ConvertibleTo(dValue.Type()) {
dstElem.Set(dValue.Convert(dstElem.Type()))
} else {
return fmt.Errorf("cannot unmarshal %+v in to %+v", src.Type(), dst.Type())
}
}
default:
if srcType.ConvertibleTo(dstType) {
dst.Set(src.Convert(dstType))
} else {
return fmt.Errorf("cannot unmarshal %+v in to %+v", src.Type(), dst.Type())
}
}
return nil
}
func unpack(data interface{}, result interface{}) error {
if b, err := json.Marshal(data); err != nil {
return err
} else {
if err := json.Unmarshal(b, &result); err != nil {
return err
}
return nil
}
}
// requireAssignable assures that `dst` is a pointer and it's not an interface.
func requireAssignable(dst, src reflect.Value) error {
if dst.Kind() != reflect.Ptr && dst.Kind() != reflect.Interface {
return fmt.Errorf("cannot unmarshal %+v into %+v", src.Type(), dst.Type())
}
return nil
}
결과 회고를 정리해보았다.
- go reflect 패키지를 이용해 go 어떻게 데이터를 변수에 assign 하는지 공부하는 기회가 되었습니다.
- 제 실력으로는 불가능해 보이는 문제에 굴하지 않고 부딪혀보는 경험을 하면서, 앞으로 개발하면서 어떤 풀 수 없어 보이는 문제에 마주 하더라도 “끈기있게 도전하면 해결할 수 있다!”는 마음가짐을 가지게 되었습니다.
- 문제를 해결하는데 너무 몰두해 Unpack과 set 함수를 모두 재구현했지만, 그냥 변수들을 초기화해주고 marshal, unmarshal을 하는게(아래 해결 방법 모색 2) 간단하지 않았을까 생각합니다. 물론 이 방법을 이어나갔을 때 다른 문제가 발생했을 수도 있지만요.
- 현재 위 코드는 제가 담당하는 서버에만 적용되어 있고, 다른 서버에는 아직 적용이 되어 있지 않습니다. 테스트 코드를 범용적으로 짜서 다른 팀원들에게 안정성을 증명해 신뢰를 받아 다른 서버에도 적용을 시켰더라면 하는 아쉬움이 있습니다.
- 현재는 gateway 코드가 수정되어, rpc call에서 응답이 숫자인 경우는 string으로 형변환을 해서 전달하도록 되었습니다. Unapck 함수는 더 이상 필요가 없어졌지만, 많은 것을 느끼고 배울 수 있는 귀중한 경험이었기에 여전히 뿌듯합니다!
뭔가 어려워보이는 task → 재밌음 → 좌절 → 반복…→ 어?! → 좌절 → 어?!→ 됐다!!!!! 행복
희노애락 = 코딩… 이 아닐까.. 허허