Go SDK로 Flex Message 구현하기

안녕하세요? 교토 개발실에서 근무하는 @sugyan입니다. 교토의 여름은 이번에 처음 겪는데, 와 정~말 엄청난 더위네요.

2018년 6월, LINE Messaging API에서 사용할 수 있는 새로운 메시지 유형 ‘Flex Message’가 추가되었습니다. 복잡한 레이아웃이 가능해지면서 맞춤형 메시지를 보낼 수 있게 되었지요.

공식 SDK에서도 Flex Message를 지원하기 위해 즉각 구현에 착수했습니다. 이번 포스팅에서는 Go SDK 담당자로서 Flex Message 지원 기능을 구현하면서 얻은 노하우를 공유할까 합니다.

Go SDK에서 Flex Message 사용하기

전송받은 메시지에 Flex Message로 회신하려면, 다음과 같이 코딩하면 됩니다.

import "github.com/line/line-bot-sdk-go/linebot"

func reply(replyToken string) error {
    container := &linebot.BubbleContainer{
        Type: linebot.FlexContainerTypeBubble,
        Body: &linebot.BoxComponent{
            Type:   linebot.FlexComponentTypeBox,
            Layout: linebot.FlexBoxLayoutTypeHorizontal,
            Contents: []linebot.FlexComponent{
                &linebot.TextComponent{
                    Type: linebot.FlexComponentTypeText,
                    Text: "Hello,",
                },
                &linebot.TextComponent{
                    Type: linebot.FlexComponentTypeText,
                    Text: "World!",
                },
            },
        },
    }
    if _, err := client.ReplyMessage(
        replyToken,
        linebot.NewFlexMessage("alt text", container),
    ).Do(); err != nil {
        return err
    }
    return nil
}

*linebot.ClientReplyMessage(replyToken string, messages ...Message) 메서드에는 Message interface를 구현한 객체를 전달하게 되어 있습니다. Flex Message를 전송할 경우, func NewFlexMessage(altText string, container FlexContainer) 함수로 만들어진 *FlexMessageMessage interface를 구현한 객체가 됩니다. 또한 NewFlexMessage() 함수에 전달할 두 번째 인자는 FlexContainer interface를 구현한 객체를 전달해야 합니다. FlexContainer interface는 *BubbleContainer나 *CarouselContainer가 구현하고 있는데요. 위 예시에서는 *BubbleContainer를 사용하고 있습니다.

모든 struct를 일일이 작성하는 것은 번거로운 일입니다. 그래서 소위 ‘Builder’라고 말하는 것을 이용하는 방식도 생각해보았습니다. 하지만 Type 항목을 생략할 수 있다는 점 말고는 다른 장점이 없었고, 자칫 SDK 점검 부담만 늘어날 것 같아서 접게 되었습니다. 대신 더욱 실용적 방법을 준비했습니다. JSON 문자열에서 FlexMessage 객체를 생성할 수 있는 함수 func UnmarshalFlexMessageJSON([]byte)를 활용하는 것입니다.

Simulator에서 생성된 JSON 이용하기

FlexMessage는 특정 JSON 데이터를 요청(request) body에 담아 POST하게 되어 있습니다. 이때 JSON과 그 표현 결과를 동시에 확인할 수 있는 툴로 Simulator가 제공됩니다.

예를 들어, "Restaurant"이라는 샘플 메시지에서 Create를 선택하면 다음과 같은 화면이 나타납니다. 화면 왼쪽에 표현 결과가 보이는데요. 오른쪽의 JSON을 편집해서 Preview 버튼을 누르면 변경 내용이 표현 결과에 반영됩니다.

작성한 JSON이 Simulator에서 제대로 표현되었다면, 해당 JSON은 요청 body를 통해 그대로 POST 할 수 있는 유효한 데이터입니다. 따라서 SDK입장에서도 이런 JSON 데이터를 그대로 FlexMessage로 보낼 수 있게 하면 좋겠지만, 그러기 위해선 []byte(또는 string)를 인자로 보낼 수 있게 해야 합니다. 그런데 그렇게 되면 유효하지 않은 엉터리 JSON 데이터(애초에 정상적이지 않은 JSON이므로)도 전송할 수 있게 되어버립니다.

그래서 SDK에서는 JSON 데이터를 받아서 FlexContainer interface를 구현한 객체로 변환하는 UnmarshalFlexMessageJSON([]byte) 함수를 준비하였습니다. 이 함수를 사용하면 프로그램은 정상적인 JSON 데이터만 수신하여 위 예시와 동일한 방식으로 FlexMessage를 전송할 수 있게 됩니다.

    jsonData := []byte(`{
  "type": "bubble",

  ...

}`)
    container, err := linebot.UnmarshalFlexMessageJSON(jsonData)
    if err != nil {
        // 정상적으로 Unmarshal할 수 없는 invalid한 JSON일 경우 err가 반환됨
        ...
    }
    message := linebot.NewFlexMessage("alt text", container)

손이 좀 가긴 했지만, 덕분에 Simulator에서 확인한 JSON을 그대로 복사해서 사용할 수 있게 되었습니다.

JSON Unmarshal하기

이제 UnmarshalFlexMessageJSON([]byte) 함수를 구현할 차례입니다.

기본적으로는 json.Unmarshal을 통해 JSON 데이터를 Unmarshal한 다음 각각의 타입에 매핑하는 방식인데요. 그리 쉬운 작업은 아닙니다. 매핑할 타입이 정해져 있지 않기 때문입니다.

우선 FlexContainer interface는 앞서 말씀드린 것처럼, *BubbleContainer*CarouselContainer 중 하나의 객체입니다. 그 안에 담긴 header, body, footer*BoxComponent 요소가 각각 가지고 있는 contents 요소는 다시 *BoxComponent*ButtonComponent 등의 FlexComponent interface로 정의한 것을 구현한 객체의 배열이 됩니다. 또 몇몇 FlexComponent가 포함하는 action 요소는 Template Message 등에서도 사용하는 객체로, 이 역시 TemplateAction interface를 구현한 타입이 여럿 존재합니다.

JSON 내의 모든 객체에는 type 항목이 필수로 포함되어 있어 어떤 타입의 데이터인지 표시됩니다.

{
  ...

    "contents": [
      {
        "type": "icon",
        "size": "sm",
        "url": "https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gray_star_28.png"
      },
      {
        "type": "text",
        "text": "4.0",
        "size": "sm",
        "color": "#999999",
        "margin": "md",
        "flex": 0
      }
    ],

  ...
}

type 항목을 보면, contents 내용의 첫 번째는 sizeurl을 포함하는 *IconComponent 타입, 두 번째는 colormargin 등을 포함하는 *TextComponent 타입에 Unmarshal 하면 된다는 것을 알 수 있습니다.

Unmarshal용 type 사용하기

Unmarshal할 곳의 타입이 먼저 정해져 있지 않으면, json.Unmarshal함수는 호출할 수 없습니다. 그런데 type 항목의 내용을 읽지 않은 상태에서는 Unmarshal할 타입을 알 수가 없기 때문에 호출 전에 이를 정의하는 것은 불가능합니다.

따라서 SDK에서는 Unmarshal 용의 별도 타입을 준비한 뒤, 그 안에서 type 항목만 먼저 읽어들이고, 이후 다시 타입을 결정해서 Unmarshal하는 방식을 택했습니다.

예를 들어 *FlexContainer 객체로 변환하는 부분은 다음과 같이 구현했습니다.

func UnmarshalFlexMessageJSON(data []byte) (FlexContainer, error) {
    raw := rawFlexContainer{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return nil, err
    }
    return raw.Container, nil
}

type rawFlexContainer struct {
    Type      FlexContainerType `json:"type"`
    Container FlexContainer     `json:"-"`
}

func (c *rawFlexContainer) UnmarshalJSON(data []byte) error {
    type alias rawFlexContainer
    raw := alias{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    var container FlexContainer
    switch raw.Type {
    case FlexContainerTypeBubble:
        container = &BubbleContainer{}
    case FlexContainerTypeCarousel:
        container = &CarouselContainer{}
    default:
        return errors.New("invalid container type")
    }
    if err := json.Unmarshal(data, container); err != nil {
        return err
    }
    c.Type = raw.Type
    c.Container = container
    return nil
}

Unmarshal만을 위해 사용하는 rawFlexContainer라는 타입이 포인트입니다. UnmarshalJSON([]byte)함수를 구현하고 있기 때문에, json.Unmarshal함수에서 rawFlexContainer 타입의 객체가 인자로 지정되어 있으면 UnmarshalJSON함수가 호출됩니다.

alias type 이용하기

func (c *rawFlexContainer) UnmarshalJSON([]byte)내 첫 번째 줄에서 type alias rawFlexContainer를 사용해 ‘내용은 같지만 이름이 다른 타입’을 정의합니다. alias 타입은 rawFlexContainer와는 달리 json.Unmarshal함수의 인자로 지정되어도 (*rawFlexContainer) UnmarshalJSON이 호출되지는 않습니다.

json tag를 보면 아시겠지만, 이 alias 타입 객체에 대한 json.Unmarshaltype 항목만 Unmarshal합니다. 하지만 이를 통해 매핑할 타입을 알 수 있으므로, type 항목값에 맞는 타입의 객체를 준비한 뒤, 다시 전달된 데이터를 원하는 타입으로 json.Unmarshal함수를 사용해 변환할 수 있습니다.

이렇게 내부적으로는 전달된 데이터를 두 번 json.Unmarshal하는 형태가 되는데요. 이 방법을 통해 올바른 매핑 타입을 알아내고, Unmarshal할 수 있게 되는 것입니다.

마찬가지로 FlexComponent interface의 객체를 Unmarshal 할 때도 rawFlexComponent라는 타입을 준비 한 뒤, 다시 alias type을 이용해 매핑할 타입의 객체를 준비하는 방식으로 처리합니다.

type rawFlexComponent struct {
    Type      FlexComponentType `json:"type"`
    Component FlexComponent     `json:"-"`
}

func (c *rawFlexComponent) UnmarshalJSON(data []byte) error {
    type alias rawFlexComponent
    raw := alias{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    var component FlexComponent
    switch raw.Type {
    case FlexComponentTypeBox:
        component = &BoxComponent{}
    case FlexComponentTypeButton:
        component = &ButtonComponent{}
    case FlexComponentTypeFiller:
        component = &FillerComponent{}
    case FlexComponentTypeIcon:
        component = &IconComponent{}
    case FlexComponentTypeImage:
        component = &ImageComponent{}
    case FlexComponentTypeSeparator:
        component = &SeparatorComponent{}
    case FlexComponentTypeSpacer:
        component = &SpacerComponent{}
    case FlexComponentTypeText:
        component = &TextComponent{}
    default:
        return errors.New("invalid flex component type")
    }
    if err := json.Unmarshal(data, component); err != nil {
        return err
    }
    c.Type = raw.Type
    c.Component = component
    return nil
}

다음은 이를 이용해 Contents []FlexComponent 요소를 갖는 *BoxComponent를 Unmarshal하는 방법입니다.

type rawFlexComponent struct {
    Type      FlexComponentType `json:"type"`
    Component FlexComponent     `json:"-"`
}

func (c *BoxComponent) UnmarshalJSON(data []byte) error {
    type alias BoxComponent
    raw := struct {
        Contents []rawFlexComponent `json:"contents"`
        *alias
    }{
        alias: (*alias)(c),
    }
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    components := make([]FlexComponent, len(raw.Contents))
    for i, content := range raw.Contents {
        components[i] = content.Component
    }
    c.Contents = components
    return nil
}

alias에서 Contents만 겹쳐쓰기한 다른 struct를 이용하면, Contents 요소 이외의 값을 호출한 쪽의 *BoxComponent 값으로 Unmarshal할 수 있습니다. 그렇게 되면 Contents 요소만 겹쳐쓰기된 채 rawFlexComponent단위로 Unmarshal되어 있으므로, 각 내용의 Component를 꺼내어 Unmarshal 완료된 모든 것들을 가져올 수 있습니다.

switch문을 reflect로 대체해보기

여담이지만, type 항목을 구한 다음, switch문을 이용해 매핑할 타입의 객체를 정의하는 부분이 어딘가 좀 마음에 들지 않습니다.

이때 reflect를 사용한다면, 만들어야 하는 객체의 타입을 미리 값으로 준비해 두고 그 타입의 객체를 동적으로 만들어 사용할 수 있습니다. 예를 들어 rawFlexContainer의 경우, 타입명과 reflect.Type의 매핑을 준비한 뒤, 다음과 같이 재작성할 수 있습니다.

import "reflect"

var containerTypeMap = map[FlexContainerType]reflect.Type{
    FlexContainerTypeBubble:   reflect.TypeOf(BubbleContainer{}),
    FlexContainerTypeCarousel: reflect.TypeOf(CarouselContainer{}),
}

func (c *rawFlexContainer) UnmarshalJSON(data []byte) error {
    type alias rawFlexContainer
    raw := alias{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    container := reflect.New(containerTypeMap[raw.Type]).Interface()
    if err := json.Unmarshal(data, container); err != nil {
        return err
    }
    if flexContainer, ok := container.(FlexContainer); ok {
        c.Container = flexContainer
    }
    return nil
}

switch문이 없으니 확실히 깔끔한 느낌이지만, 이번 SDK에서는 일부러 이 방법을 선택하지 않았습니다. reflect가 편리하긴 하지만 잘못 사용하면 자칫 ‘흑마술화’될 수 있고, 유지보수도 어려워지기 때문입니다. 단순한 switch문 등으로도 원하는 로직을 구현할 수 있다면, 굳이 reflect를 도입할 필요는 없습니다. 어떤 기능을 구현하면서 reflect를 꼭 써야하는 경우가 아니라면, 기본적으로는 사용하지는 말자는 게 제 생각입니다.

맺으며

Messaging API의 Go SDK로 Flex Message를 구현하는 방법을 소개해 드렸습니다. 정적 타입 언어(Typed language)인 Go로 다양한 타입의 JSON 데이터를 다루기가 다소 힘들었지만, 방식을 살짝 바꾸면 구현할 수 있다는 점을 확인할 수 있었습니다.

(본 포스팅의 내용은 글을 작성한 2018년 8월 기준입니다. 더 좋은 방법을 아시는 분은 Pull-Request 보내주시기 바랍니다. )

Special thanks to @mokejp!