Go에서 json.RawMessage 및 사용자 정의 UnmarshalJSON을 사용하여 복잡한 JSON의 복잡성 해독하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Go에서 복잡한 JSON 구조 해독하기
현대 소프트웨어 개발의 세계는 JSON과 깊이 얽혀 있습니다. 웹 API부터 구성 파일에 이르기까지 다양한 시스템 간의 데이터 교환을 위한 링구아 프랑카입니다. Go의 encoding/json 패키지는 JSON을 마샬링하고 언마샬링하는 강력하고 편리한 도구를 제공하지만, 개발자는 들어오는 JSON 구조가 동적이거나, 다형적이거나, 단순히 표준 자동 디코딩에는 너무 복잡한 시나리오에 직면하는 경우가 많습니다. 이때 json.RawMessage와 사용자 정의 UnmarshalJSON 구현이 JSON 처리를 마스터하려는 모든 Go 개발자에게 필수적인 도구가 됩니다. 이 도구들은 파싱을 연기하고, 원시 데이터를 검사하고, 맞춤형 로직을 적용할 수 있는 유연성을 제공하여 예측 불가능한 JSON 앞에서도 강력하고 탄력적인 데이터 처리를 보장합니다.
고급 파싱을 위한 Go-JSON 툴킷
고급 기술에 대해 자세히 알아보기 전에 Go의 JSON 처리를 뒷받침하는 핵심 개념을 간략하게 살펴보겠습니다.
JSON (JavaScript Object Notation): 사람이 쉽게 읽고 기계가 쉽게 파싱할 수 있도록 설계된 경량 데이터 교환 형식입니다. 이름/값 쌍 모음(객체)과 순서 있는 값 목록(배열)의 두 가지 기본 구조를 기반으로 합니다.
encoding/json 패키지: Go의 표준 라이브러리 패키지로 JSON을 인코딩하고 디코딩합니다. Go 구조체를 JSON으로, 그 반대로 변환하기 위한 json.Marshal 및 json.Unmarshal과 같은 함수를 제공합니다.
json.Unmarshal: JSON 데이터를 Go 값으로 디코딩하는 함수입니다. 기본적으로 리플렉션을 사용하여 이름(또는 json 구조체 태그)을 기준으로 JSON 필드와 구조체 필드를 일치시킵니다.
json.RawMessage: 이것이 우리의 주인공입니다. json.RawMessage는 type RawMessage []byte으로 정의됩니다. 이것은 원시, 파싱되지 않은 JSON 데이터를 보유하는 바이트 슬라이스입니다. Unmarshal이 json.RawMessage 필드를 만나면 해당 JSON 값을 추가로 디코딩하려고 시도하지 않고 바이트 슬라이스로 취급합니다. 이것은 전체 JSON 객체, 배열, 문자열, 숫자, 부울 또는 null을 구조체 내의 원시 바이트 슬라이스로 저장할 수 있음을 의미합니다.
json.Unmarshaler 인터페이스: 이 인터페이스는 단일 메서드 UnmarshalJSON([]byte) error를 정의합니다. json.Unmarshal이 이 인터페이스를 구현하는 타입으로 값을 디코딩할 때, 해당 특정 필드에 대한 원시 JSON 데이터(또는 구조체 수준에서 구현된 경우 전체 객체)로 UnmarshalJSON 메서드를 호출합니다. 이를 통해 개발자는 디코딩 프로세스를 완전히 제어할 수 있습니다.
json.RawMessage의 힘
API가 이벤트 목록을 반환하는 시나리오를 상상해 보세요. 각 이벤트에는 id 및 timestamp와 같은 일부 공통 필드가 있지만 details 필드는 이벤트의 type에 따라 크게 달라질 수 있습니다.
json.RawMessage가 없으면 많은 선택적 필드가 있는 매우 큰 구조체를 정의하거나, 여러 번의 Unmarshal 호출과 타입 캐스팅을 수행해야 할 수 있으며, 이는 번거롭고 오류가 발생하기 쉽습니다.
json.RawMessage가 이를 어떻게 단순화하는지 보여드리겠습니다:
package main import ( "encoding/json" "fmt" ) // Event는 일반적인 이벤트 구조를 나타냅니다. type Event struct { ID string `json:"id"` Timestamp int64 `json:"timestamp"` Type string `json:"type"` Details json.RawMessage `json:"details"` // 다양한 세부 구조를 보유하기 위한 RawMessage } // LoginDetails는 "login" 이벤트에 대한 세부 정보를 나타냅니다. type LoginDetails struct { Username string `json:"username"` IPAddress string `json:"ip_address"` } // PurchaseDetails는 "purchase" 이벤트에 대한 세부 정보를 나타냅니다. type PurchaseDetails struct { ProductID string `json:"product_id"` Amount float64 `json:"amount"` Currency string `json:"currency"` } func main() { jsonBlob := ` [ { "id": "evt-123", "timestamp": 1678886400, "type": "login", "details": { "username": "alice", "ip_address": "192.168.1.100" } }, { "id": "evt-456", "timestamp": 1678886500, "type": "purchase", "details": { "product_id": "prod-A", "amount": 99.99, "currency": "USD" } }, { "id": "evt-789", "timestamp": 1678886600, "type": "logout", "details": "user logged out successfully" } ] ` var events []Event err := json.Unmarshal([]byte(jsonBlob), &events) if err != nil { fmt.Println("Error unmarshaling events:", err) return } for _, event := range events { fmt.Printf("Event ID: %s, Type: %s\n", event.ID, event.Type) switch event.Type { case "login": var loginDetails LoginDetails if err := json.Unmarshal(event.Details, &loginDetails); err != nil { fmt.Println(" Error unmarshaling login details:", err) continue } fmt.Printf(" Login Details: Username=%s, IP=%s\n", loginDetails.Username, loginDetails.IPAddress) case "purchase": var purchaseDetails PurchaseDetails if err := json.Unmarshal(event.Details, &purchaseDetails); err != nil { fmt.Println(" Error unmarshaling purchase details:", err) continue } fmt.Printf(" Purchase Details: ProductID=%s, Amount=%.2f %s\n", purchaseDetails.ProductID, purchaseDetails.Amount, purchaseDetails.Currency) case "logout": var message string if err := json.Unmarshal(event.Details, &message); err != nil { fmt.Println(" Error unmarshaling logout message:", err) continue } fmt.Printf(" Logout Message: %s\n", message) default: fmt.Printf(" Unhandled event type, raw details: %s\n", string(event.Details)) } fmt.Println("---") } }
이 예에서 Event.Details는 json.RawMessage입니다. 초기 json.Unmarshal은 공통 필드(ID, Timestamp, Type)를 파싱하고 details 필드를 원시 바이트 슬라이스로 그대로 둡니다. 나중에 Type 필드를 검사한 다음 event.Details를 올바른 구체적 타입으로 선택적으로 언마샬링할 수 있습니다. 이 접근 방식은 매우 유연하며 동적 JSON 구조를 처리할 때 데이터 손실이나 오류를 방지합니다.
사용자 정의 UnmarshalJSON 구현
json.RawMessage는 파싱을 연기하기에 훌륭하지만, 사용자 정의 UnmarshalJSON 구현은 파싱 전, 중, 후에 JSON 데이터를 조작하거나 특정 조건에 따라 다른 타입으로 디코딩할 수 있는 훨씬 더 세분화된 제어를 제공합니다.
User의 Age가 정수 또는 문자열(예: "unknown")로 표현될 수 있는 시나리오를 생각해 보세요. int를 예상했는데 string을 받으면 표준 Unmarshal이 실패합니다.
package main import ( "encoding/json" "fmt" "strconv" ) type User struct { Name string Age int // Age는 문자열 "unknown"으로 들어와도 항상 int로 만들고 싶습니다. } // CustomUnmarshalerUser는 사용자 정의 UnmarshalJSON을 구현하는 구조체입니다. type CustomUnmarshalerUser User // 무한 재귀를 피하기 위한 별칭 func (u *CustomUnmarshalerUser) UnmarshalJSON(data []byte) error { // 원시 데이터를 보유하는 임시 구조체를 정의합니다. Age도 원시 메시지로 포함됩니다. // 주의하지 않고 `CustomUnmarshalerUser`로 직접 언마샬링할 경우 무한 재귀를 방지하는 데 도움이 됩니다. // Age에 json.RawMessage를 사용하면 타입을 검사할 수 있습니다. type TempUser struct { Name string `json:"name"` Age json.RawMessage `json:"age"` // Age를 원시 바이트로 보유 } var temp TempUser if err := json.Unmarshal(data, &temp); err != nil { return err } u.Name = temp.Name // 이제 Age 필드를 지능적으로 파싱합니다. if temp.Age == nil { // "age": null 또는 누락된 경우 u.Age = 0 // 또는 기본값 return nil } // int로 언마샬링 시도 var ageInt int if err := json.Unmarshal(temp.Age, &ageInt); err == nil { u.Age = ageInt return nil } // int로 실패하면 문자열로 언마샬링 시도 var ageStr string if err := json.Unmarshal(temp.Age, &ageStr); err == nil { if ageStr == "unknown" || ageStr == "" { u.Age = 0 // "unknown" 또는 빈 문자열을 0으로 표현 } else { // 문자열 형태의 숫자이면 문자열을 int로 파싱 시도 parsedAge, err := strconv.Atoi(ageStr) if err == nil { u.Age = parsedAge } else { // 다른 예상치 못한 문자열 값 처리 또는 오류 반환 return fmt.Errorf("could not parse age string '%s'", ageStr) } } return nil } return fmt.Errorf("age field is neither a number nor a recognized string: %s", string(temp.Age)) } func main() { jsonUsers := []string{ `{"name": "Alice", "age": 30}`, `{"name": "Bob", "age": "unknown"}`, `{"name": "Charlie", "age": "25"}`, // Age를 문자열 숫자로 `{"name": "David", "age": null}`, `{"name": "Eve"}`, // Age 누락 `{"name": "Frank", "age": "thirty"}`, // 잘못된 문자열 } for i, j := range jsonUsers { var user CustomUnmarshalerUser err := json.Unmarshal([]byte(j), &user) if err != nil { fmt.Printf("User %d: Error unmarshaling: %v\n", i+1, err) continue } fmt.Printf("User %d: Name: %s, Age: %d\n", i+1, user.Name, user.Age) } }
이 향상된 예에서 CustomUnmarshalerUser에 대한 UnmarshalJSON은 먼저 Age가 json.RawMessage인 임시 구조체를 사용하여 최상위 필드를 언마샬링합니다. 이를 통해 encoding/json이 Age에 대해 즉각적이고 잠재적으로 실패할 수 있는 타입 캐스팅을 시도하는 것을 방지합니다. 그런 다음 temp.Age의 원시 바이트를 검사하고 int로, 그 다음 string으로 언마샬링을 시도하여 null, "unknown" 및 문자열로 인코딩된 숫자까지도 우아하게 처리할 수 있습니다. 이는 강력한 오류 복구 및 데이터 표준화를 보여줍니다.
또한 기본 언마샬링 동작과 함께 UnmarshalJSON을 구현할 때 일반적인 패턴은 별칭 타입을 정의하는 것입니다:
type MyConfig struct { ... 일반 필드 ... SpecialField string } type AliasMyConfig MyConfig // 무한 재귀를 피하기 위해 별칭 사용 func (mc *MyConfig) UnmarshalJSON(data []byte) error { var alias AliasMyConfig if err := json.Unmarshal(data, &alias); err != nil { return err } *mc = MyConfig(alias) // 별칭에서 원래 구조체로 데이터 복사 // 이제 mc.SpecialField 또는 다른 필드에 사용자 정의 로직 적용 // 예: 유효성 검사, 변환. if mc.SpecialField == "oldvalue" { mc.SpecialField = "newvalue" } return nil }
이 별칭 패턴은 json.Unmarshal이 AliasMyConfig에 대해 기본 리플렉션 기반 언마샬링을 사용하도록 허용한 다음 사용자 정의 로직을 추가할 수 있습니다.
언제 무엇을 사용할 것인가
json.RawMessage: 내부 구조가 변하는 특정 필드(또는 여러 필드)가 있고, 어떤 구체적 타입이어야 하는지 알 때까지 해당 파싱을 연기하려는 경우에 이상적입니다. 전체 구조체에 대한 완전한 사용자 정의UnmarshalJSON을 작성할 필요 없이 다형적 데이터 구조에 탁월합니다.- 사용자 정의
UnmarshalJSON: 궁극적인 제어를 제공합니다. 다음의 경우 사용하십시오:- 단일 필드에 대해 여러 형식으로 들어올 수 있는 데이터(예:
Age를int또는string으로)를 처리해야 할 때. - 언마샬링 중에 필드를 유효성 검사해야 할 때.
- 언마샬링 중에 변환 또는 데이터를 보강해야 할 때.
- 전체 파싱 로직이 동일한 객체 내의 다른 필드 값에 의존할 때(예:
Type필드가 다른Data필드를 파싱하는 방식을 결정할 때). - 알 수 없는 필드를 무시하거나 고급 오류 복구를 수행해야 할 때.
- 단일 필드에 대해 여러 형식으로 들어올 수 있는 데이터(예:
결론
json.RawMessage와 사용자 정의 UnmarshalJSON은 Go의 encoding/json 패키지에서 기본 JSON 파싱을 넘어서게 해주는 강력한 기능입니다. json.RawMessage를 마스터하면 컨텍스트가 요구할 때까지 특정 필드 파싱을 연기하면서 동적이고 다형적인 JSON 페이로드를 우아하게 처리하는 능력을 얻게 됩니다. 타입 변환, 유효성 검사 또는 복잡한 조건부 로직에 대한 디코딩 프로세스를 완전히 제어해야 할 때, 사용자 정의 UnmarshalJSON 메서드를 구현하는 json.Unmarshaler 인터페이스는 궁극적인 유연성을 제공합니다. 이러한 도구는 실제의 종종 혼란스러운 JSON API와 상호 작용하는 강력한 Go 애플리케이션을 구축하는 데 중요합니다. 이를 통해 스키마 변형에 탄력적이고 데이터 해석에 정확한 코드를 작성할 수 있어 애플리케이션이 받은 모든 JSON 데이터를 자신 있게 처리할 수 있습니다.

