Go

json.Unmarshal 사용시 타임(time) 형식을 유연하게 변경하는 방법

드리프트2 2024. 5. 4. 17:41

 

Go 언어에서 json.Unmarshal을 사용하여 JSON 데이터를 구조체로 변환할 때, 시간 필드의 형식을 유연하게 지정할 수 있는 방법이 있는데요.

 

사용자 정의 시간 필드 타입을 만들거나, 라이브러리를 사용하여 기본적인 time.Time 타입 대신 다른 형식의 시간 문자열을 처리할 수 있다는 겁니다.

 

예를 들어, tson 라이브러리는 사용자가 시간 형식을 설정할 수 있게 해주며, 이 설정을 통해 json.Unmarshal을 사용할 때 해당 형식을 사용할 수 있는겁니다.

 

** 목차 **


앞으로 사용할 구조체와 변수

이 글에서 사용할 구조체와 변수를 먼저 정의해 보면,

 

구조체 Person은, Person의 특징 Feature와, Person이 생성된 날짜 CreatedAt을 가지고 있습니다.

type Person struct {
    Feature   Feature    `json:"feature"`
    CreatedAt *time.Time `json:"created_at"`
}

 

구조체 Feature는, Person이 가진 특징의 내용을 나타냅니다.

type Feature struct {
    Name  string     `json:"name"`
    Birth *time.Time `json:"birth"`
}

 

변수 jsonString은, 구조체 Person의 객체에 언마샬링할 대상이 되는 JSON 문자열입니다.

 

특히 시간 필드(birth와 created_at)가 오늘의 핵심이므로 기억해 주시기 바랍니다.

var jsonString = `
{
    "feature": {
        "name": "cia_rana",
        "birth": "1987-10-08"
    },
    "created_at": "2017-12-05"
}
`

tson 라이브러리 사용시

tson을 사용한 JSON 문자열에서 구조체 객체로의 언마샬링은, 표준 라이브러리 encoding/json의 함수 json.Unmarshal과 같은 방법, 즉 함수 tson.Unmarshal로 할 수 있습니다.

 

하지만 일반적인 json.Unmarshal과 다른 점은, tson.Unmarshal을 하기 전에 함수 tson.SetLayout을 호출한다는 겁니다.

 

tson.SetLayout은 시간 포맷을 변경하는 함수로, 이 함수를 호출한 후에 tson.Unmarshal을 할 때는 변경한 포맷이 사용됩니다.

var person Person

tson.SetLayout(`2006-01-02`)

err := tson.Unmarshal([]byte(jsonString), &person)
if err != nil {
    fmt.Println("error:", err)
} else {
    fmt.Println(person)
}
// => {{cia_rana 1987-10-08 00:00:00 +0000 UTC} 2017-12-05 00:00:00 +0000 UTC}

 

제대로 언마샬링이 되었네요!


좀 더 생각해 보기

그런데, Go로 JSON 문자열을 구조체 객체에 언마샬링했을 때, 아래와 같은 경험을 한 적이 있을건데요.

var person Person

err := json.Unmarshal([]byte(jsonString), &person)
if err != nil {
    fmt.Println("error:", err)
} else {
    fmt.Println(person)
}
// => error: parsing time ""1988-10-10"" as ""2006-01-02T15:04:05Z07:00"": cannot parse """ as "T"

 

Go에서 JSON 중의 시간을 *time.Time에 매핑할 때, 그 시간이 Go가 원래 준비해 둔 포맷으로 기술되어 있지 않으면 제대로 매핑할 수 없습니다.

 

조금 더 융통성이 있으면 좋겠다고 생각하지만, Go의 구조상 어쩔 수 없는 부분입니다.

 

참고로 시간 문자열을 time.Time 타입의 객체로 변환할 경우, 시간 포맷을 명시적으로 지정할 수 있는데, Go는 그 지정 방식이 특별해서, YYYY-MM-DD와 같이 지정하는 것이 아니라, 프로그래머가 읽기 쉬운 포맷으로 지정할 수 있도록 되어 있습니다.


해결책

자, 에러 없이 제대로 언마샬링하기 위해서는, 보통 구조체 내의 *time.Time 타입을 UnmarshalJSON을 갖춘 오리지널 Time 타입으로 변경하죠?

 

하지만, 매번 구조체를 변경하여 UnmarshalJSON을 구현하는 것은 번거롭고, 라이브러리 내의 구조체를 사용하고 싶을 때 그 구조체를 쉽게 변경할 수 없는 경우도 있습니다.

 

그래서, 프로그램 처리 중에 기존의 구조체를 기반으로 '시간 포맷을 자유롭게 변경할 수 있는 새로운 구조체를 동적으로 생성하는' 새로운 접근 방식을 제안합니다.

 

이 접근 방식을 통해, 준비된 함수에 구조체 객체를 전달하기만 하면, 미리 설정한 시간 포맷에 따라 JSON 문자열 내의 시간을 *time.Time에 잘 매핑하면서 언마샬링을 할 수 있게 됩니다.

 

이제 이 접근 방식이 어떻게 실현되는지, 자세히 알아 보겠습니다.


상세 구현

시간 포맷을 고려하며 JSON 문자열을 Person 객체에 언마샬링하는 절차는 다음과 같습니다.

  1. Person의 타입 정보를 얻습니다.
  2. Person 타입 정보 내의 *time.Time 타입을 tson에서 준비한 오리지널 Time 타입으로 교체한 새로운 타입을 생성합니다.
  3. 새로운 타입의 객체를 생성합니다.
  4. JSON 문자열을 새로운 타입의 객체에 언마샬링합니다.
  5. 새로운 타입의 객체를 다시 JSON 문자열로 변환합니다.
  6. 변환된 JSON 문자열을 Person 객체에 언마샬링합니다.

reflect를 많이 사용하는데요. 조금 어럽습니다.

 

그럼, 각 단계를 살펴보겠습니다.

  1. Person의 타입 정보를 얻습니다.

먼저 기본이 되는 Person의 타입 정보를 얻습니다.

 

타입 정보를 얻기 위해서는 보통 reflect.TypeOf 함수를 사용합니다.

 

하지만, Person은 포인터 타입으로 들어온다고 가정하므로 (tson.Unmarshal([]byte(jsonString), &person)의 &person 부분에서),

 

일단 포인터가 가리키는 값을 얻은 후 그 타입 정보를 얻습니다.

 

포인터가 가리키는 값을 얻기 위해서는 reflect.Elem 함수를 사용합니다.

 

그 후 그 반환값의 Type 함수를 호출하면 됩니다.

var v interface{} = &person // `Person`은 포인터로 들어옵니다
rv := reflect.ValueOf(v)    // person의 타입 정보를 얻습니다. person의 타입 정보는 `reflect.Ptr`입니다
rt := rv.Elem().Type()      // `reflect.Ptr`이 가리키는 값의 타입 정보를 얻습니다

 

이로써 Person의 타입 정보를 얻었습니다.

 

  1. Person 타입 정보 내의 *time.Time 타입을 tson에서 준비한 오리지널 Time 타입으로 교체한 새로운 타입을 생성합니다.

여기서는, 앞서 얻은 Person의 타입 정보에 포함된 *time.Time을 오리지널 Time 타입(의 포인터)으로 교체해 나갑니다.

 

하지만, 그 전에 오리지널 Time 타입에 대해 설명하겠습니다.

 

오리지널 Time 타입(tson.Time이라고 부릅니다)은, 함수 tson.SetLayout으로 지정한 시간 포맷에 따라 JSON 문자열 내의 시간을 tson.Time에 매핑하는 특징을 가지고 있습니다.

 

tson.SetLayout으로 지정한 시간 포맷은 tson 측의 비공개 변수 format에 저장되어 있습니다.

 

또한, 시간 포맷은 스레드 안전하게 지정할 수 있도록 되어 있습니다. 그 외에는 흔히 보는 time.Time의 래퍼 형태입니다.

var format = struct {
    layout string
    mutex  *sync.Mutex
}{
    time.RFC3339,
    new(sync.Mutex),
}

func SetLayout(layout string) {
    format.mutex.Lock()
    format.layout = layout
    format.mutex.Unlock()
}

type Time struct {
    time.Time
}

func (t *Time) UnmarshalJSON(data []byte) error {
    if string(data) == "null" {
        return nil
    }

    tm, err := time.Parse(`"`+format.layout+`"`, string(data))
    t.Time = tm
    return err
}

 

tson.Time의 타입 정보는 자주 사용하기 때문에, tson 측의 비공개 변수 rtt로 미리 가지고 있습니다.

var rtt = reflect.TypeOf(&Time{})

 

이제 준비가 되었는데요.

 

할 일은 매우 간단하고, *time.Time을 교체하는 것뿐입니다.

 

여기서는 편의상 Person의 타입 정보를 rt로 해두겠습니다.

 

먼저, 새로 생성할 구조체의 필드를 모아둔 슬라이스를 정의합니다.

 

슬라이스의 타입은 []reflect.StructField입니다.

 

필드의 수는 rt의 필드 수와 같으며, 슬라이스의 길이가 알려져 있기 때문에, 메모리 할당 횟수를 줄이는 의미도 담아 슬라이스 정의는 다음과 같이 합니다.

rs := make([]reflect.StructField, rt.NumField())

 

다음으로 rt의 필드에 접근합니다.

 

타입 정보의 종류가 Struct인 rt의 필드에 접근하는 것은, 인덱스를 사용하여 다음과 같이 접근합니다.

f := rt.Field(i)

 

그리고, 여기서부터 구조체의 필드를 교체하는 작업이 시작됩니다.

 

반환값 f의 필드 Type이 *time.Time인 경우, 그것을 tson.Time의 타입 정보로 교체합니다.

 

교체는 단순히 tson.Time의 타입 정보를 대입하는 것으로 충분합니다.

f.Type = rtt

 

반면, f.Type이 *time.Time이 아닌 경우, 처음에는 교체할 필요가 없어 보일 수 있습니다.

 

하지만, f.Type의 종류가 Array나 Slice, Struct 등인 경우, 그 구성 요소 중에서 *time.Time이 사용될 수 있습니다.

 

예를 들어 f가 슬라이스 []*time.Time인 경우 f.Type은 Slice이지만, 이 슬라이스의 요소 타입은 *time.Time이며, 교체 대상이 됩니다.

 

또한, f의 타입 구성 요소가 직접 *time.Time을 가지고 있지 않더라도, 구성 요소의 구성 요소가 *time.Time일 가능성이 있습니다. 즉, 재귀적으로 f의 구성 요소에 접근하여 교체해 나갑니다.

 

구성 요소에 접근하고 교체된 구성 요소를 가진 새로운 타입을 생성하는 것은, 각각의 타입의 종류에 맞게 진행해야 합니다.

 

마지막으로, 새로 생성할 구조체의 필드를 모아둔 슬라이스를, 함수 reflect.StructOf(rs)를 통해 새로 생성할 구조체의 타입 정보로 변환합니다.

 

이 모든 것을 종합하면, 다음과 같은 함수 newStruct가 됩니다.

func newStruct(rt reflect.Type) reflect.Type {
    rs := make([]reflect.StructField, rt.NumField())

    for i := 0; i < rt.NumField(); i++ {
        f := rt.Field(i)

        if f.Type.String() == "*time.Time" {
            f.Type = rtt
        } else {
            switch f.Type.Kind() {
            case reflect.Array:
                f.Type = reflect.ArrayOf(f.Type.Len(), newStruct(f.Type.Elem()))
            case reflect.Chan:
                f.Type = reflect.ChanOf(f.Type.ChanDir(), newStruct(f.Type.Elem()))
            case reflect.Func:
                ins := make([]reflect.Type, f.Type.NumIn())
                outs := make([]reflect.Type, f.Type.NumOut())
                for i := 0; i < f.Type.NumIn(); i++ {
                    ins[i] = newStruct(f.Type.In(i))
                }
                for i := 0; i < f.Type.NumOut(); i++ {
                    outs[i] = newStruct(f.Type.Out(i))
                }
                f.Type = reflect.FuncOf(ins, outs, f.Type.IsVariadic())
            case reflect.Interface:
                // TODO
            case reflect.Map:
                f.Type = reflect.MapOf(newStruct(f.Type.Key()), newStruct(f.Type.Elem()))
            case reflect.Ptr:
                f.Type = reflect.PtrTo(newStruct(f.Type.Elem()))
            case reflect.Slice:
                f.Type = reflect.SliceOf(newStruct(f.Type.Elem()))
            case reflect.Struct:
                f.Type = newStruct(f.Type)
            case reflect.UnsafePointer:
                // TODO
            }
        }

        rs[i] = f
    }

    return reflect.StructOf(rs)
}

 

//TODO로 표시된 부분은 아직 구현되지 않았지만, 이번 데모에서는 필요하지 않으므로 생략했습니다.

 

이 글의 하이라이트는 여기까지였습니다.

 

이제부터는 간단하게 설명하겠습니다!

  1. 새로운 타입의 객체를 생성합니다.

먼저, 방금 새로 생성한 구조체의 타입 정보를 변수 rt에 저장해 둡니다.

 

이 rt는 2번에서의 rt와는 다릅니다.

rt, err := NewStruct(v)

 

타입 정보 rt에서 객체를 생성하려면 다음과 같이 합니다. 새로 생성된 vi의 타입은 interface{}입니다.

vi := reflect.New(rt).Interface()
  1. JSON 문자열을 새로운 타입의 객체에 언마샬링합니다.

생성된 객체에 JSON 문자열을 평소처럼 언마샬링합니다.

 

자신이 지정한 시간 포맷에 따라 언마샬링하기 때문에, 그 포맷의 지정 방식이 틀리지 않았다면 제대로 언마샬링될 것입니다.

err := json.Unmarshal(jsonBytes, &vi)

 

  1. 새로운 타입의 객체를 다시 JSON 문자열로 변환합니다.

왜 여기서 JSON 문자열을 생성하는 걸까요?

 

이는 최종적으로 Person 객체에 JSON 문자열을 제대로 언마샬링하기 위해 수행됩니다.

 

원래 JSON 문자열 중의 시간 필드가 Go 표준의 시간 포맷으로 변환된 새로운 JSON 문자열이 생성되므로, 아무것도 손대지 않은 Person 객체에 JSON 문자열을 제대로 언마샬링할 수 있게 됩니다.

 

data, err := json.Marshal(vi)

 

  1. 변환된 JSON 문자열을 Person 객체에 언마샬링합니다.

생성된 JSON 문자열을 Person 객체에 언마샬링합니다.

 

5번에서 설명한 대로 제대로 언마샬링될 것입니다.

err := json.Unmarshal(data, v)

마무리

지금까지 알아본 내용은 조금 어려울 수 있지만 단지 타입을 교체한 새로운 구조체를 동적으로 만드는 것뿐이었습니다.

 

하지만, reflect를 많이 사용하고 있어 속도 면이나 컴파일 시의 타입 검사가 불충분한 경우가 있을 수 있으므로 주의가 필요할 듯 하네요.

 

그럼.