Go에서 데이터를 변환하는 방법
In computer science, marshalling or marshaling (US spelling) is the process of transforming the memory representation of an object into a data format suitable for storage or transmission. It is typically used when data must be moved between different parts of a computer program or from one program to another. wikipedia
마셜링이란 데이터를 전송이나 보관에 유리한 방식으로 변경하는 것을 말한다. 컴퓨터에 유리한 방식이니 당연히 byte형태의 어떤 데이터의 나열이 될 것이고, 그렇다면 마셜링의 반대 방향, 언마셜링은 byte 형식에서 다른 형태로 바뀌는 것을 말한다.
바이트는 컴퓨터에서 사용하는 값이다. 바이트를 어떤 형태의 데이터로 보는가를 정하는 것은, 그 값이 어떠한 타입에 담기는가에 따라 다르다. 예를 들어 바이트 0x41
, 65는 int 타입이라면 숫자 65가 되고 char 타입에서 A
로 인식한다. 따라서 마셜링이라는 것은 어떤 데이터 타입에서 byte로의 일방향 변환이지만, 언마셜링은 한가지 방향으로 진행되지 않는다. 즉 언마셜링할 때 어떤 데이터 타입으로 변환해야 할지를 지정해 줄 필요가 있다. 마셜링의 방향은 byte
라면 언마셜링의 방향은 유저의 의도가 담겨 있다고나 할까.
JSON으로, JSON으로의 변환
json은 키와 값의 매핑이 나열되어 있는 텍스트라고 볼 수 있다. 키가 있으면 그에 해당하는 값이 있다. 값의 종류는 다시 값이 될 수도 있으며, 다시 다른 값을 가리키는 키가 될 수도 있다. 그리고 Go에서도 이런 형태의 내부 구조를 가진 타입이 있는데 이를 struct
, 구조체라고 부른다.
구조체는 값을 가리키는 필드들의 모임으로 정의된다. 필드는 변수이며, 변수를 키
라고 하면 변수에 담긴 값을 통해 이를 키와 값의 매핑으로 볼 수 있다. 그래서 구조체는 json과 굉장히 흡사한 모양을 가진다.(사실 생각해보면 json이 자바스크립트의 오브젝트 표현식이므로 크게 다르진 않을 것이다). 어쨌든 이런 흡사한 모습 때문에 구조체는 json으로 변환이 가능하다. 따라서 json을 string, 그러니까 []byte의 형태로 본다면, 구조체를 마셜링하여 []byte형태로 바꾸는 것이 가능하고, []byte를 마찬가지로 언마셜링하여 구조체로 다시 변경할 수 있다.
그림상으로는 []byte에 대한 이야기가 빠졌는데 string과 []byte가 그냥 같다고 생각하자. 어쨌든, 이렇게 하면 구조체와 json간 상호 변환이 가능하다. 그리고 이는 위에서 이야기한 마셜링과 언마셜링의 뜻과 딱 맞아떨어진다. 데이터를 byte형태로 변환하였으니까 말이다. 구조체에 담긴 다양한 값들을 []byte로 변환한 것이다.
json.Marshal
어떤 json을 []byte 타입으로 변경하기 위해서 encoding/json
패키지의 Marshal
함수를 사용할 수 있다.
func Marshal(v interface{}) ([]byte, error)
위에서 계속 이야기한 대로 마셜링은 데이터를 []byte
의 형태로 변경하는 것이 목표이므로 이 함수의 리턴은 마셜링된 데이터 []byte
를 리턴한다. 그리고 변환에 실패한 경우를 위해 error
도 같이 리턴한다. 파라미터 v
는 인터페이스 형태인데, 이는 어떠한 값이 파라미터로 들어오더라도 json 형태로 변경한다는 뜻이다. 즉 이 파라미터는 꼭 구조체일 필요가 없다. 음? json이려면 키와 값이 있어야하지 않나? 하는데, go의 패키지 json
은 RFC 7159를 구현하였다고 명시되어 있으며, RFC 7159의 내용은 간략히 말하면 이와 같다
JSON-text = ws value ws
ws는 빈 칸이나 줄바꿈 같은 캐릭터를 이야기하는 거고, value는 true
, false
, null
, object
, array
, number
, string
이다. 따라서 단순 숫자만 있다고 해도 이건 json이며 따라서 json.Marshal의 파라미터로 구조체 뿐 아니라 일반 변수도 Marshalling 가능하다. 그럼 한번 간단히 json을 마셜링해 보자.
package main
import (
"encoding/json"
"fmt"
)
func main() {
a, _ := json.Marshal(1)
b, _ := json.Marshal("1")
c, _ := json.Marshal([]int{1,2,3,4,5})
d, _ := json.Marshal([]string{"abcd", "efgh", "ijkl"})
fmt.Println("a? ", a, string(a))
fmt.Println("b? ", b, string(b))
fmt.Println("c? ", c, string(c))
fmt.Println("d? ", d, string(d))
}
$ go run main.go
a? [49] 1
b? [34 49 34] "1"
c? [91 49 44 50 44 51 44 52 44 53 93] [1,2,3,4,5]
d? [91 34 97 98 99 100 34 44 34 101 102 103 104 34 44 34 105 106 107 108 34 93] ["abcd","efgh","ijkl"]
값이 그대로 json의 number나 string, array로 바뀌어서 이게 byte형태로 저장된 것을 확인할 수 있다. 뭔가 변환이 되거나 해서 그러는게 아니라 단순히 그 바이트를 그대로 변환하는 것이다. 그래서 그 []byte를 그대로 string형태로 바꾸면 json이 출력된다. json.Marshal이 하는 일이 이게 다다. 변수를 그대로 json의 Value로 바꾸고 이를 byte형태로 변경하는 것.
그러면 이번에는 구조체의 Marshal을 생각해 보자. 구조체는 어떻게 하면 Marshal할 수 있을까? 구조체는 값을 담는 필드가 모여 있는 타입이므로, 결국 구조체의 marshal이라는 것은 각 구조체의 필드마다 전부 Marshal을 진행하면 된다. 구조체는 결국 object이므로 {
로 시작해서 object안의 필드를 깊이든 너비든 순회하며 마셜링하고 마지막에 }
로 닫아주면 Marshaling이 끝인 것.
이번에는 []byte을 어떻게 Marshal할 것인가? 값을 byte로 바꾸는 것이 마셜링인데 이미 바이트라면? 이는 base64로 인코딩하여 처리한다.
package main
import (
"encoding/json"
"fmt"
)
func main() {
a := struct {
Name string
Data []byte
}{
Name: "Byte data type",
Data: []byte{1, 2, 3, 4, 5},
}
b, _ := json.Marshal(a)
fmt.Println("a?: ", string(b))
}
// https://play.golang.org/p/T5IQhqLrBhP
// a?: {"Name":"Byte data type","Data":"AQIDBAU="}
이번엔 json의 키워드 중 null
을 생각해 보자. Go는 변수가 선언되면 무조건 초기값이 들어가는 것이 보장되므로, 어떠한 변수를 선언하더라도 항상 값이 들어가 있는 상태가 된다. string이라면 빈 문자열 ""
, 숫자라면 0
, bool 타입이라면 false
… 그러면 null은 어떻게 넣을 수 있는가? null은 마셜링 대상 타입이 포인터 타입일 경우에 처리할 수 있다.
아래에는 json의 각 value에 따라서 대강 이런 느낌으로 매핑되는 것들을 간단히 코드로 작성했는데, d
의 경우 슬라이스를 선언만 하는 경우 nil
이므로 :=
를 통해 초기화를 진행했다.
package main
import (
"encoding/json"
"fmt"
)
func main() {
var a int // number
var b string // string
var c bool // false
d := []interface{}{} // array
var e struct{} // object
var f *int // null
result_int, _ := json.Marshal(a)
result_string, _ := json.Marshal(b)
result_bool, _ := json.Marshal(c)
result_array, _ := json.Marshal(d)
result_object, _ := json.Marshal(e)
result_null, _ := json.Marshal(f)
fmt.Println("a? ", a, string(result_int))
fmt.Println("b? ", b, string(result_string))
fmt.Println("c? ", c, string(result_bool))
fmt.Println("d? ", d, string(result_array))
fmt.Println("e? ", e, string(result_object))
fmt.Println("f? ", f, string(result_null))
}
// a? 0 0
// b? ""
// c? false false
// d? [] []
// e? {} {}
// f? <nil> null
// https://play.golang.org/p/PmuIu8dk-1c
json.Unmarshal
어떤 []byte
, 사실상 string 형태의 데이터가 있는 경우 이를 json형태로 되돌릴 수 있다. 이를 Unmarshaling, 언마셜이라고 하며 Go에서는 encoding/json
패키지의 Unmarshal
함수를 통해서 처리할 수 있다.
func Unmarshal(data []byte, v interface{}) error
언마샬링은 위에서 이야기한 대로 어떤 byte값을 타입에 적용하는 것이다. 같은 바이트값이라 하더라도 그 값이 담긴 타입에 따라 처리가 달라진다. 그런 면에서 이 함수를 생각해 보면 결국 Unmarshal이란 것은 data
를 v
타입에 적용시키는 것이다. 그게 불가능하다면 error
를 리턴하는 것이고. 그래서 이 함수는 에러만 리턴한다. 즉 결과물이 없고 파라미터로 들어온 v를 직접 변경시킨다.
v
를 변경시키기 위해 이 v
는 반드시 포인터 타입이어야 한다. 그게 아니면 값 복사니까.
package main
import (
"encoding/json"
"fmt"
)
func main() {
var a int
json.Unmarshal([]byte("65"), a) //call of Unmarshal passes non-pointer as second argument
fmt.Println("a?: ", a)
}
그리고 data를 v에 적용시키는 것이므로 v는 어떤 형태든 가능하도록 interface{}
타입이어야 한다.
package main
import (
"encoding/json"
"fmt"
)
func main() {
var a int8
var b uint8
var c rune
var d string
errA := json.Unmarshal([]byte("128"), &a)
fmt.Println("errA?: ", errA)
errB := json.Unmarshal([]byte("128"), &b)
fmt.Println("errB?: ", errB)
errC := json.Unmarshal([]byte("128"), &c)
fmt.Println("errC?: ", errC)
errD := json.Unmarshal([]byte("128"), &d)
fmt.Println("errD?: ", errD)
fmt.Println("a?: ", a)
fmt.Println("b?: ", b)
fmt.Println("c?: ", c)
fmt.Println("d?: ", d)
}
// errA?: json: cannot unmarshal number 128 into Go value of type int8
// errB?: <nil>
// errC?: <nil>
// errD?: json: cannot unmarshal number into Go value of type string
// a?: 0
// b?: 128
// c?: 128
// d?:
128이라는 숫자는 int8
의 범위를 넘으므로, 그리고 string이 아니므로 a,d에는 바인딩에 실패했다. 그러나 uint8
과 rune
에는 이를 적용할 수 있다. 즉 같은 숫자가 int8과 int32에 적용된 것이다. 그럼 string에 바인딩하기 위해서는? 문자열이어야 하므로 []byte(\""128\"")
였다면 가능했다.
태그를 사용한 의도 주입
태그는 구조체에만 붙일 수 있는 특별한 메타데이터라고 할 수 있다. 태그를 사용하면 마셜링과 언마셜링 과정에 우리가 개입할 수 있게 된다. json의 마셜링/언마셜링 관련된 태그 키는 json
이며, 여기에 부여할 수 있는 값들은 아래와 같다.
`json:“FIELD_NAME[,omitempty,TYPE] || -”`
,
기준으로 대략 세 필드로 나눠볼 수 있는데 각각 다음과 같다.
- FIELD_NAME: json 필드의 이름을 나타낸다. 필드에 붙인 이 이름이 json태그의 키값과 일치한다면 이를 대신 사용한다.
- omitempty: 구조체에 값이 없는 경우(empty) 이를 생략(omit)한다.
- TYPE: json의 값을 해당 type으로 변환한다.
- -: 해당 필드를 숨긴다.
태그는 구조체에만 붙이는 것이 가능하므로 이 태그 또한 대상이 구조체일 때만 가능하다고 생각하면 된다. 아무튼 설명보다는 몇 줄의 코드가 더 이해하기 쉬우니 우선 FIELD_NAME
부터 보자.
package main
import (
"encoding/json"
"fmt"
)
type Test struct {
ID int `json:"id"`
Body string `json:"body"`
}
func main() {
a := Test{ID: 1, Body: "asdf"}
b, _ := json.Marshal(a)
fmt.Println("result?: ", string(b))
}
// result?: {"id":1,"body":"asdf"}
// https://play.golang.org/p/6jHV5Ad4556
위와 같이 구조체와 태그가 명시되어 있다면 구조체의 값과 json의 값은 아래와 같다.
{"id":1,"body":"asdf"}
태그가 없었다면 아래와 같이 출력된다.
{"ID":1,"Body":"asdf"}
태그의 키로 FIELD_NAME body
를 주었기 때문에 Body
대신 body
가 출력되었다. 그럼 언마셜링일 때는 어떻게 될까?
package main
import (
"encoding/json"
"fmt"
)
type Test struct {
ID int `json:"id,omitempty"`
Body string `json:"body,omitempty"`
}
func main() {
a := []byte(`{"id":1,"body":"contents"}`)
result := Test{}
json.Unmarshal(a, &result)
fmt.Println("result?: ", result)
}
// result?: {1 contents}
// https://play.golang.org/p/ids8BqCxnw6
출력값은 아래와 같다.
{1 contents}
json 필드 id
와 body
가 구조체에 잘 매핑된 것 같다. 만약 위 코드에서 a
에 구조체의 태그와 다른 형태로 json을 전달하면?
...
...
type Test struct {
ID int `json:"id,omitempty"`
Body string `json:"body,omitempty"`
}
func main() {
...
a := []byte(`{"ID":1,"BODY":"contents"}`)
...
}
음? 매칭이 안되어야 할 것 같지만 출력은 정상적으로 만들어진다.
{1 contents}
언마셜링의 경우 태그의 대소문자를 신경쓰지 않고 매칭한다. 아예 키가 다르다면 매칭하지 않는 것이다. 따라서 body가 BODY
여도 상관없고 심지어 bOdY
이런식이어도 상관없이 매칭된다.
To unmarshal JSON into a struct, Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match. By default, object keys which don’t have a corresponding struct field are ignored (see Decoder.DisallowUnknownFields for an alternative).
이렇게까지 키를 맞춰서 언마셜링을 해야 하는 이유를 잘 모르겠는데, 키가 다르면 그냥 에러 처리하는게 쉽지 않나? 어쨌든 코드를 보면 equalFold로 다시 같은지를 처리하는 모양이다.
이번에는 태그 omitempty
에 대해서 정리해 보자. 이는 그냥 마셜링시 empty
라면 omit
해주는 역할을 한다. 언마셜링의 경우 매칭되는 키가 없다면 구조체에 기본값이 들어갈 것이므로 언마셜링 케이스에서 이 태그는 별 의미가 없다.
package main
import (
"encoding/json"
"fmt"
)
type Test struct {
ID int `json:"id,omitempty"`
Body string `json:"body,omitempty"`
}
func main() {
a := Test{ID: 0, Body: ""}
b, _ := json.Marshal(a)
fmt.Println("result?: ", string(b))
}
// result?: {}
// https://play.golang.org/p/U_FF7nemqBM
출력값은 다음과 같다.
{}
Go의 타입에는 nil
이 들어갈 수 없으므로 기본값이 있으며, 따라서 어떤 필드에 기본값이 있는 경우 마셜링시 이 필드를 생략해버린다. 이게 무슨 뜻이냐 하면 유저가 의도하여 어떤 int 필드에 0을 넣었다면, 이를 마셜링시 이 필드가 그냥 사라진다는 뜻이다. 이게 사용하면서 굉장히 불편한 부분 중 하나고, 따라서 omitempty는 잘 사용하지 않으려고 하는 편이다.
태그 TYPE
은 json의 데이터 타입을 명시하는 역할을 한다. 이게 무슨 뜻이냐 하면
package main
import (
"encoding/json"
"reflect"
"fmt"
)
type Test struct {
ID int `json:"id,omitempty,string"`
Body string `json:"body,omitempty"`
}
func main() {
a := []byte(`{"id": "1", "body": "test"}`)
b := Test{}
json.Unmarshal(a, &b)
fmt.Println("result?: ", b)
}
출력값은
{1 test}
json의 id
는 문자열 "1"
의 형태로 값이 지정되었지만 이를 json 태그 type
애 명시하여 암묵적으로 Go의 int 타입으로 변환시켰다.
정리하면 다음과 같다.
- json태그의 첫 번째 필드는 변환에 필요한 필드 이름
- 두 번째 필드부터는 omitempty 혹은 변환할 타입에 대한 명시 가능
마지막으로는 태그 "-"
가 있다. 이 경우 마셜링 대상에서 아예 제외한다. 단지 json으로 마셜링시 이를 제외하는 것이므로 필드 자체는 접근이 가능한데, 따라서 이를 아래 소개할 Marshal/Unmarshal을 통해 조합하여 다양한 방식으로 사용할 수 있다.
Marshaler/Unmarshaler 인터페이스 구현
태그는 구조체에만 붙일 수 있으므로 이를 소극적인 개입이라고 한다면, Marshaler, 혹은 Unmarshaler 인터페이스를 구현하여 더 적극적으로 마셜링 과정에 개입할 수 있다.
Marshaler
인터페이스의 정의는 아래와 같다.
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
태그와는 다르게 구조체가 아닌 타입이라도 MarshalJSON
을 구현하기만 하면 Marshaler를 충족하므로 어떠한 타입이든간에 원하는 방식으로 마셜링할 수 있다. 물론 primitive, unnamed type에는 인터페이스의 구현을 붙일 수 없지만 이는 go에서 미리 다 구현해 두었으므로 상관없고, 커스텀한 마셜링이 필요하다면 타입을 새로 정의해서 처리하면 될 것이다.
실제로 어떻게 사용하는지 간단한 예를 들면 아래와 같다.
package main
import (
"encoding/json"
"fmt"
)
type CustomData string
func (c CustomData) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`{"customdata": "%s"}`, c)), nil
}
func main() {
p := "teststring"
withoutmarshaljson, _ := json.Marshal(p)
c := CustomData("teststring")
withmarshaljson, _ := json.Marshal(c)
fmt.Println("string?: ", string(withoutmarshaljson))
fmt.Println("customdata?: ", string(withmarshaljson))
}
// string?: "teststring"
// customdata?: {"customdata":"teststring"}
//
// https://play.golang.org/p/lHma6an78eq
실행 결과는 다음과 같다.
string?: "teststring"
customdata?: {"customdata":"teststring"}
CustomData
타입의 json 변환은 위처럼 json형태로 출력되도록 만들었다. json패키지 내 내부적인 구현을 생각해 보면, 파라미터로 넘어온 인터페이스가 Marshaler를 구현했다면 MarshalJSON을,, 없다면 단순 변환을 하도록 할 것이다.
주의할 점이 있다면 MarshalJSON
의 구현 안에서 json.Marshal을 다시 자기 자신으로 하면 안된다. json.Marshal이 호출되면 Marshaler를 호출하는데, 거기서 다시 json.Marshal을 하면 재귀적으로 Marshaler를 호출하다 에러가 발생한다.
$ cat main.go
...
type CustomData string
func (c CustomData) MarshalJSON() ([]byte, error) {
return json.Marshal(c)
}
...
$ go run main.go
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc0200e03e0 stack=[0xc0200e0000, 0xc0400e0000]
fatal error: stack overflow
runtime stack:
runtime.throw(0x4dbb7d, 0xe)
/home/cublr/.gvm/gos/go1.15.8/src/runtime/panic.go:1116 +0x72
runtime.newstack()
/home/cublr/.gvm/gos/go1.15.8/src/runtime/stack.go:1067 +0x78d
...
...
...
따라서 구조체 안에서 json.Marshal을 호출해야 하는 경우 보통 다음과 같이 같은 구조의 익명 구조체를 사용하여 재귀를 피하도록 처리한다.
package main
import (
"encoding/json"
"fmt"
)
type TestStruct struct {
String string
Int int
}
func (t TestStruct) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
String string
Int int
}{
String: t.String,
Int: t.Int,
})
}
func main() {
t := TestStruct{String: "string", Int: 1}
result, _ := json.Marshal(t)
fmt.Println("result?: ", string(result))
}
실행 결과는 다음과 같다.
$ go run main.go
result?: {"String":"string","Int":1}
익명 구조체로 하는 이유는 TestStruct
에 붙은 Marshaler
구현을 사용하지 않으려는 것이다. 따라서 피할 수만 있다면 생각할 수 있는 다른 방법도 사용하는 것이 가능하다. 이를테면, 필드는 다 같은데 타입이 다른 새로운 구조체를 선언한다거나, 구조체의 각 필드를 돌면서 하나씩 json.Marshal을 한다거나…
Unmarshaler
언마셜러 인터페이스는 아래와 같이 정의한다.
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
실제로 어떻게 사용하는지 간단한 테스트는 다음과 같다.
package main
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
type TestStruct struct {
String string
Int int
}
func (t *TestStruct) UnmarshalJSON(b []byte) error {
s := string(b)
trimmed := strings.Trim(s, "{}")
for _, kv := range strings.Split(trimmed, ",") {
splited := strings.Split(kv, ":")
k := strings.Trim(splited[0], "\"")
v := strings.Trim(splited[1], "\"")
if k == "String" {
t.String = v
} else if k == "Int" {
i, _ := strconv.ParseInt(v, 10, 32)
t.Int = int(i)
}
}
return nil
}
func main() {
t := `{"String":"string","Int":1}`
result := TestStruct{}
json.Unmarshal([]byte(t), &result)
fmt.Println("result?: ", result.String, result.Int)
}
Marshal과는 다르게 string을 파싱해야 하므로 조금 더 까다로운 것 같다. 주의사항으로는 위에서 Unmarshal에 대해 이야기했던 것처럼 Unmarshal은 값을 업데이트하는 작업이므로, UnmarshalJSON은 포인터 리시버로 구현하여야 한다. 또 Marshaler와 마찬가지로 자기 자신을 다시 호출하면 재귀호출이 발생하게 될 것이다.
특이한 형태의 json 컨트롤
같은 타입이면서 이름이 다르면?
구조체로 표현하지 못하는 json형태가 있다. 이를테면 json의 키가 타입이 아니라 이름을 표현하는 케이스가 바로 그렇다.
{
"red": {
"R": 255,
"G": 0,
"B": 0
},
"green": {
"R": 0,
"G": 255,
"B": 0
},
"blue": {
"R": 0,
"G": 0,
"B": 255
}
}
이런 경우… 필드 이름이 고정이 아니므로 Go의 특정 타입에 이를 매핑할 수 없다. 따라서 단순 언마셔링이 불가능하다. 그래서 이런 케이스에서는 map
을 사용하여 표현한다.
package main
import (
"encoding/json"
"fmt"
)
type RGB struct {
Red int `json:"R"`
Green int `json:"G"`
Blue int `json:"B"`
}
type ColorIndex map[string]RGB
func main() {
rawjson := `{"red": {"R": 255, "G": 0, "B": 0}, "green": {"R": 0, "G": 255, "B": 0}, "blue": {"R": 0, "G": 0, "B": 255}}`
result := ColorIndex{}
json.Unmarshal([]byte(rawjson), &result)
fmt.Println("result?: ", result)
}
출력 결과는 다음과 같다.
$ go run main.go
result?: map[blue:{0 0 255} green:{0 255 0} red:{255 0 0}]
맵이 너무 복잡한 경우를 생각해 보자. 파이썬이라면 dict
를 통해 간단히 처리할 수 있지만 go에서는 쓰기도 힘든 map[string]map[string]string
같은 걸로 처리해야 하므로 이게 매우 복잡한데, 깊이가 큰 맵을 처리하기 위해 mapstructure, 그리고 flat 같은 라이브러리를 사용하여 처리할 수 있겠다.
mapstructure의 경우 키에 해당 안되는 필드를 ,remain
을 통해 한 필드의 맵으로 다 몰아넣을 수 있고, flat의 경우 구분자로 연결된 json의 키를 파싱해서 키를 적절한 깊이의 맵으로 변경하여 저장하도록 도와 준다.