안녕하세요? 교토 개발실에서 근무하는 @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.Client
의 ReplyMessage(replyToken string, messages ...Message)
메서드에는 Message
interface를 구현한 객체를 전달하게 되어 있습니다. Flex Message를 전송할 경우, func NewFlexMessage(altText string, container FlexContainer)
함수로 만들어진 *FlexMessage
가 Message
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
내용의 첫 번째는 size
나 url
을 포함하는 *IconComponent
타입, 두 번째는 color
나 margin
등을 포함하는 *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.Unmarshal
은 type
항목만 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!