JSON을 Go 구조체로 완벽 변환하는 법 (기초부터 실전까지)

JSON을 Go 구조체로 완벽 변환하는 법 (기초부터 실전까지)

안녕하세요, 여러분.

오늘은 Go 개발자라면 누구나 마주하게 되는 관문, 바로 JSON 데이터를 구조체(Struct)로 변환하는 방법에 대해 아주 깊이 파고들어 볼까 하는데요.

웹 개발이든 API 서버든, JSON은 이제 우리와 뗄 수 없는 사이가 되었죠.

이 JSON 데이터를 Go에서 어떻게 하면 가장 효율적이고 우아하게 다룰 수 있는지, 기초부터 실전 꿀팁까지 차근차근 알려드릴게요.

왜 우리는 JSON을 구조체로 변환해야 할까

본격적으로 시작하기 전에, '왜 굳이 변환을 해야 하지?'라는 근본적인 질문부터 짚고 넘어가야겠죠.

물론 map[string]interface{} 같은 타입으로 JSON 데이터를 받아서 처리할 수도 있는데요.

하지만 이렇게 하면 아주 사소한 오타 하나 때문에 런타임에서 패닉이 발생할 수 있는 위험한 코드가 탄생하게 됩니다.

필드 이름에 오타가 나도 컴파일러는 잡아주지 못하고, 데이터 타입을 잘못 단언(type assertion)해도 마찬가지거든요.

바로 이 지점에서 '구조체'가 구원투수로 등판하는 거예요.

JSON 데이터를 미리 정의된 구조체에 딱 맞게 변환해주면, 우리는 컴파일 시점에 타입 체크의 이점을 누릴 수 있게 됩니다.

이걸 '타입 안전성(type safety)'이라고 하죠.

오타가 나면 컴파일이 안 되고, 정수(int) 필드에 문자열(string)을 넣으려고 하면 바로 에러가 나니, 훨씬 더 안정적이고 예측 가능한 코드를 작성할 수 있게 되는 겁니다.

기본 중의 기본 JSON을 구조체로

그럼 가장 기본적인 JSON 데이터를 구조체로 변환하는 것부터 시작해볼까요?

아래와 같은 간단한 JSON이 있다고 해보죠.

{
  "name": "Alice",
  "age": 25,
  "email": "alice@example.com"
}

이 JSON 구조에 딱 맞는 Go 구조체를 정의해야 하는데요.

이때 핵심적인 역할을 하는 게 바로 '구조체 필드 태그(Struct Field Tag)'입니다.

type Person struct {
    Name  string `json:"name"`
    Age   int    `json:"age"`
    Email string `json:"email"`
}


여기서 `json:"name"` 부분이 바로 필드 태그인데요.
'이 `Name` 필드는 JSON 데이터의 `name` 키에 해당합니다'라고 Go 컴파일러에게 알려주는 약속이죠.
이 태그 덕분에 Go의 필드 이름(Go는 보통 `CamelCase`를 사용하죠)과 JSON의 키 이름(`camelCase`나 `snake_case`가 일반적이죠)이 달라도 유연하게 매핑할 수 있는 거예요.

자, 이제 구조체도 준비됐으니 실제로 JSON 문자열을 파싱해서 구조체에 담아보겠습니다.
Go의 표준 라이브러리인 `encoding/json` 패키지가 이 모든 마법을 처리해주더라고요.

package main

import (
    "encoding/json"
    "fmt"
)

type Person struct {
    Name  string `json:"name"`
    Age   int    `json:"age"`
    Email string `json:"email"`
}

func main() {
    jsonData := `{"name": "Alice", "age": 25, "email": "alice@example.com"}`

    var person Person
    err := json.Unmarshal([]byte(jsonData), &person)
    if err != nil {
        fmt.Println("Error parsing JSON:", err)
        return
    }
    fmt.Println("Parsed Struct:", person)
    // 출력: Parsed Struct: {Alice 25 alice@example.com}
}


여기서 `json.Unmarshal` 함수가 마법을 부리는 주인공인데요.
첫 번째 인자로는 JSON 데이터를 바이트 슬라이스(`[]byte`)로 넘겨주고, 두 번째 인자로는 데이터를 채워 넣을 구조체의 '주소'를 넘겨줘야 합니다.
`&person`처럼 앰퍼샌드(`&`)를 붙여서 변수의 메모리 주소를 전달하는 거죠.
이렇게 해야 `Unmarshal` 함수가 그 주소에 있는 `person` 변수의 값을 직접 변경할 수 있거든요.

반대로, 구조체를 JSON으로

데이터를 받는 것만큼이나 보내는 것도 중요하죠.

Go 구조체에 담긴 데이터를 다시 JSON 문자열로 만드는 과정은 '마샬링(Marshalling)'이라고 부르는데요.

이 또한 json.Marshal 함수를 사용하면 아주 간단하게 해결됩니다.

// 위 main 함수에 이어서...
personData := Person{Name: "Bob", Age: 30, Email: ""}

encodedData, err := json.Marshal(personData)
if err != nil {
    fmt.Println("Error encoding JSON:", err)
    return
}
fmt.Println("JSON Output:", string(encodedData))
// 출력: JSON Output: {"name":"Bob","age":30,"email":""}


방금 만든 `personData` 구조체 인스턴스를 `json.Marshal` 함수에 넘겨주기만 하면, JSON 형식의 바이트 슬라이스를 반환해줍니다.
이걸 다시 `string()`으로 변환해주면 우리가 흔히 보는 JSON 문자열이 되는 거죠.

그런데 여기서 한 가지 꿀팁이 있는데요.
만약 특정 필드가 비어있을 때(예를 들어 문자열이 `""`이거나 숫자가 `0`일 때) JSON 결과에 아예 포함시키고 싶지 않다면 어떻게 할까요?
이때 바로 `omitempty` 옵션을 사용합니다.

type Person struct {
    Name  string `json:"name"`
    Age   int    `json:"age"`
    Email string `json:"email,omitempty"` // omitempty 추가!
}


`email` 필드 태그에 `omitempty`를 추가했죠?
이제 `Email` 필드가 비어있는 `personData`를 마샬링하면 결과가 달라집니다.

// Email 필드가 비어있는 구조체
personData := Person{Name: "Bob", Age: 30, Email: ""}

encodedData, err := json.Marshal(personData)
// ...
fmt.Println("JSON Output:", string(encodedData))
// 출력: JSON Output: {"name":"Bob","age":30}


보세요.
`email` 키가 결과에서 아예 사라졌죠?
API 응답을 더 깔끔하게 만들고 싶을 때 정말 유용한 옵션입니다.

복잡한 구조 다루기 중첩 객체와 배열

실제 세상의 JSON 데이터는 이렇게 단순하지만은 않죠.

객체 안에 또 다른 객체가 들어있는 '중첩 구조'나, 여러 개의 값을 담고 있는 '배열'이 아주 흔하게 등장합니다.



먼저 중첩된 JSON 구조를 한번 볼까요?

{
  "name": "Alice",
  "contact": {
    "email": "alice@example.com",
    "phone": "123-456-7890"
  }
}


이런 구조는 Go에서도 똑같이 구조체 안에 구조체를 정의해서 해결할 수 있습니다.
아주 직관적이죠.

```go type Contact struct { Email string `json:"email"` Phone string `json:"phone"` }

type Person struct {
Name string json:"name"
Contact Contact json:"contact"
}

<br />
`Person` 구조체 안에 `Contact` 타입의 필드를 그대로 넣어주면, `encoding/json` 패키지가 알아서 중첩 구조를 파싱해줍니다.<br />
<br />
다음은 배열인데요.<br />
JSON 배열은 Go의 '슬라이스(slice)' 타입과 완벽하게 호환됩니다.<br />

```json
{
  "groupName": "Developers",
  "members": [
    { "name": "Alice", "age": 25 },
    { "name": "Bob", "age": 30 }
  ]
}


`members` 키는 `Person` 객체들의 배열이죠?
이걸 Go 구조체로 표현하면 이렇게 됩니다.

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

type Group struct {
    GroupName string   `json:"groupName"`
    Members   []Person `json:"members"` // Person 타입의 슬라이스
}


`Members` 필드의 타입을 `[]Person`처럼 슬라이스로 선언해주기만 하면 끝입니다.
정말 간단하죠?

실전 꿀팁 선택적 필드와 고급 에러 처리

여기서부터는 조금 더 깊이 들어가 볼 텐데요.

실무에서 JSON을 다루다 보면 꼭 마주치는 문제들을 해결하는 팁들입니다.



만약 JSON에 특정 필드가 아예 없거나, 값이 null로 오는 경우가 있다면 어떻게 될까요?

Go에서 Unmarshal을 할 때 JSON에 없는 필드는 해당 타입의 '제로 값(zero value)'으로 초기화됩니다.

예를 들어 int0, string""이 되죠.

그런데 만약 '값이 0인 것'과 '값이 아예 없는 것'을 구분해야 한다면 어떨까요?

이때 바로 '포인터'를 사용합니다.

type User struct {
    Name string `json:"name"`
    Age  *int   `json:"age,omitempty"` // int 포인터 타입
}


`Age` 필드를 `int`가 아닌 `*int`, 즉 `int` 포인터 타입으로 선언했는데요.
이렇게 하면, JSON에 `age` 필드가 없거나 `null`로 오면 `Age` 필드는 `nil`이 됩니다.
만약 `age: 0`처럼 값이 0으로 오면, `Age` 필드는 0을 가리키는 유효한 포인터 주소를 갖게 되죠.
덕분에 우리는 `if user.Age == nil` 같은 코드로 '값이 없는 경우'를 명확하게 구분해 처리할 수 있게 되는 거예요.

에러 처리도 좀 더 섬세하게 할 수 있는데요.
`json.Unmarshal`이 반환하는 `error`는 생각보다 많은 정보를 담고 있습니다.
예를 들어, `int` 타입으로 기대한 필드에 문자열이 들어오는 경우 `json.UnmarshalTypeError`라는 구체적인 에러가 발생하거든요.
이걸 활용하면 사용자에게 훨씬 더 친절한 에러 메시지를 보여줄 수 있죠.

var data User
err := json.Unmarshal(jsonData, &data)

if err != nil {
    var unmarshalTypeError *json.UnmarshalTypeError
    if errors.As(err, &unmarshalTypeError) {
        fmt.Printf("Wrong type for field %s. Expected %s, but got %s\n", 
            unmarshalTypeError.Field, 
            unmarshalTypeError.Type, 
            unmarshalTypeError.Value)
    } else {
        fmt.Println("Error parsing JSON:", err)
    }
}


Go 1.13부터 추가된 `errors.As`를 사용하면 에러 체인 안에서 특정 타입의 에러가 있는지 확인할 수 있습니다.
이를 통해 그냥 "파싱 에러"라고 뭉뚱그려 말하는 대신 "age 필드에 숫자 대신 문자열 'thirty'가 들어왔습니다"처럼 훨씬 구체적인 피드백을 줄 수 있는 거죠.

마지막 비밀 병기 json.RawMessage

가끔은 JSON의 특정 부분만 파싱을 미루고 싶을 때가 있는데요.

예를 들어 type 필드의 값에 따라 data 필드의 구조가 완전히 달라지는 경우죠.

이럴 때 json.RawMessage를 사용하면 아주 유용합니다.

json.RawMessage는 JSON 데이터의 일부를 파싱하지 않고 날 것 그대로([]byte) 저장하는 특별한 타입이거든요.

type Message struct {
    Type string          `json:"type"`
    Data json.RawMessage `json:"data"`
}


먼저 이렇게 전체 구조를 파싱한 다음, `msg.Type`의 값을 확인해서 `msg.Data`를 각각 다른 구조체로 다시 한번 `Unmarshal` 해주는 방식으로 처리할 수 있습니다.
동적인 JSON 구조를 다룰 때 정말 강력한 무기가 되어줄 거예요.

마무리하며

오늘은 Go에서 JSON 데이터를 구조체로 변환하는 방법에 대해 정말 A부터 Z까지 샅샅이 훑어봤는데요.

단순한 변환을 넘어 omitempty 같은 유용한 태그 옵션, 중첩 구조와 배열 처리, 그리고 포인터를 이용한 선택적 필드 핸들링과 상세한 에러 처리까지 알아봤습니다.

JSON을 구조체로 잘 다루는 것은 안정적이고 유지보수하기 좋은 Go 애플리케이션을 만드는 첫걸음이라고 할 수 있죠.

오늘 배운 내용들이 여러분의 Go 개발 여정에 든든한 무기가 되기를 바랍니다.