LINE 메신저 프로필 꾸미기에 CSS Flexbox를 활용한 Flex 레이아웃 적용하기

인사말

안녕하세요. LINE 프로필과 스토리를 담당하고 있는 조영준, 박현민입니다. 올해 진행한 여러 프로젝트 중에서 ‘프로필 꾸미기’라는 프로젝트를 여러분들과 공유하고 싶어서 이렇게 글로 전하게 되었습니다. 혹시 비슷한 상황에서 저희와 같은 고민을 하시는 분들에게 이 글이 도움이 되기를 바라며, 즐겁고 재미있게 읽어주시길 바라겠습니다.

 

프로필 꾸미기란?

지난 8월에 LINE 클라이언트 10.13 버전으로 프로필 꾸미기라는 기능이 런칭되었습니다. 이 기능을 통해 LINE 사용자는 본인의 LINE 프로필에 여러 가지 스티커나 위젯, 효과 등을 넣어 개성 있는 자신만의 프로필을 만들 수 있습니다. 위젯은 단순 스티커나 스탬프, 이미지에 비해 다소 이미지 레이어의 복잡도가 높은 구성 요소입니다. 스티커와 D-day 위젯, 포토 프레임 위젯, 텍스트 위젯 등으로 구성된 아래와 같은 이미지로 대표할 수 있습니다. 

 

프로필 꾸미기 개발 목표

아래는 프로필 꾸미기 중 하나인 ‘D-day 위젯’의 초기 디자인 시안 중 일부입니다. 다양한 형태로 D-day를 표현하고 있으며, 프로필 꾸미기 기능 릴리스 후에도 다른 형태의 D-day를 추가할 수 있습니다.

  

D-day 위젯을 클라이언트에서 구현하면, 추후 D-day 위젯을 추가했을 때 클라이언트에 기능을 추가해서 다시 릴리스해야 하며, 하위 버전의 클라이언트에선 추가된 D-day 위젯을 볼 수 없게 됩니다. 이를 방지하고자 저희는 확장 가능하고 하위 호환성이 유지되는 구조를 목표로 다음과 같이 개발 목표를 수립했습니다.

  • 프로필 꾸미기 위젯 화면을 서버에서 구성한다.
    • 위젯 화면에 필요한 데이터와 리소스를 서버에서 내려받아 화면을 구성한다.
    • 위젯 수정이나 추가가 필요한 경우 서버에서 수정한다.
  • 하위 버전에서도 수정 혹은 추가된 위젯을 표시할 수 있도록 한다.

 

프로필 꾸미기 디자인

위젯 하나를 표현하기 위해서 레이아웃 구조와 텍스트, 이미지 등의 리소스를 정의하고, 레이아웃과 리소스를 연결할 방법을 고민했습니다.

 

레이아웃

서버에서 위젯 화면을 구성하기 위해선 XML이나 JSON 기반의 레이아웃 엔진이 필요했습니다. Flex Message나 Activity Card(LINE Timeline 피드의 한 종류) 등 LINE에서 제공하는 기존 서비스를 참고하면서 오픈 소스 기반의 레이아웃 엔진을 검토해 본 결과, 저희는 웹 표준 기술인 FlexBox를 도입하기로 결정했습니다. FlexBox는 iOS와 Android 크로스 플랫폼을 지원하며, Flex Message에서 Yoga Layout 라이브러리를 이용하여 이미 안정적으로 서비스하고 있기 때문입니다. 또한 웹 표준 기술이므로 향후 CMS(Contents Management System)를 위해 웹 서비스를 개발해야 할 때도 쉽게 적용할 수 있습니다.

참고. Yoga Layout은 Facebook에서 Flexbox를 보여주기 위해 만든 크로스 플랫폼 엔진으로, LINE 메신저에 포함되어 있습니다.

레이아웃 엔진으로는 Flex Message와 마찬가지로 Yoga Layout을 도입했습니다. 다만 Yoga Layout은 엔진이기 때문에 XML이나 JSON 형태의 데이터 처리를 지원하지 않아서, 프로필 꾸미기 위젯에 적합한 JSON 구조는 직접 개발하기로 결정했습니다. 프로필 꾸미기의 다양한 위젯을 표현하기 위해 FlexBox에서 지원하는 레이아웃 속성을 그대로 이용하는 형태로 JSON을 구성했습니다(Yoga Layout에서 지원하는 FlexBox 속성은 Yoga Layout 사이트에서 확인할 수 있습니다). 프로필 꾸미기 위젯의 레이아웃 JSON 형태는 다음과 같습니다. 필요에 따라 margin 등 Yoga Layout에서 지원하는 속성을 추가할 수도 있습니다.

{
 "positionType": "RELATIVE",
 "size": {
   "width": 119,
   "height": 73
 }
}

 

레이아웃 구성

레이아웃은 하나의 뷰를 나타내며, type 값에 따라 FlexBox 컨테이너 또는 TextView나 ImageView가 될 수도 있습니다. 레이아웃은 children 필드 아래에 자식 레이아웃을 포함할 수 있으며 자식 또한 자신의 children을 가질 수 있습니다.

{
  "layout": {
    "type": "FLEX",
    "flex": {
      "positionType": "RELATIVE",
      "size": {
        "widthAuto": true,
        "heightAuto": true
      }
    },
    "children": [
      {
        "type": "IMAGE",
        "flex": {
          "positionType": "RELATIVE",
          "size": {
            "width": 119,
            "height": 73
          }
        }
      }
    ]
  }
}

 

리소스

레이아웃으로 구성한 뷰에서 텍스트나 이미지 등의 리소스를 표현하기 위해선 리소스를 정의해야 합니다. 리소스를 정의할 땐 사용자가 수정할 수 있는 데이터(data)와 수정할 수 없는 데이터(res)를 구분하며, 레이아웃에서 참조할 수 있도록 ID도 정의합니다.

{
  "data": [
    {
      "id": "value1",
      "type": "text",
      "value": {
        ...
      }
    },
    {
      "id": "value2",
      "type": "image",
      "value": {
        ...
      }
    }
  ],
  "extraMeta": {
    "template": {
      "res": [
        {
          "id": "value3",
          "type": "text",
          "value": {
            ...
          }
        },
        {
          "id": "value4",
          "type": "image",
          "value": {
            ...
          }
        }
      ],
      "layout": {
        ...
      }
    }
  }
}

res와 layout이 합쳐져 template이 구성됩니다. template은 하나의 위젯에 대한 화면 구성을 나타냅니다.

 

리소스 바인딩

텍스트와 이미지를 뷰에 표시할 때는 background와 content 필드를 통해 레이아웃에서 리소스를 참조하도록 설정합니다.

{
  "data": [
    {
      "id": "value1",
      "type": "text",
      "value": {
      }
    }
  ],
  "extraMeta": {
    "template": {
      "res": [
        {
          "id": "value2",
          "type": "text",
          "value": {
          }
        }
      ],
      "layout": {
        ...,
        "background": {
          "value": "values/value1"
        },
        "content": {
          "value": "values/value2"
        }
      }
    }
  }
}

 

이미지 텍스처 패킹

이미지 리소스가 여러 개 필요한 경우엔 하나의 큰 이미지로 패킹(packing)하는 것이 좋은 경우가 있습니다. 예를 들어, D-day 위젯 같은 경우엔 D-day 표시를 위해 0에서 9까지의 숫자 이미지가 필요합니다. 이때 숫자 이미지를 개별로 관리하면 이미지를 여러 번 다운로드해야 하고, 일부 이미지를 다운로드하지 못했을 경우 위젯 표현이 잘못될 수도 있습니다. 따라서 이럴 땐 여러 이미지를 하나로 패킹하여 이미지를 한 번만 받도록 합니다.

아래와 같이 이미지 패킹 정보를 imageMap[]에 정의하면, 다른 곳에서 특정 key로 참조할 때 좌표를 기준으로 병합된 이미지에서 어느 부분을 표현할지 판단합니다.

{
  "type": "IMAGE",
  "imageMap": [
   {
     "key": "0",
     "frame": {
       "x": 0,
       "y": 0,
       "w": 28,
       "h": 40
     }
   },
   {
     "key": "1",
     "frame": {
       "x": 28,
       "y": 0,
       "w": 24,
       "h": 40
     }
   },
   ...
 ]
}

레이아웃에서는 imageMapKey를 통해 패킹된 개별 이미지를 참조합니다. 예를 들어 아래 예시 코드와 같이 참조하면, imageMap[]에서 key 값 0에 해당하는 좌표인 (0, 0, 28, 40)을 가져와 병합된 이미지에서 좌표에 해당하는 부분을 사용하게 됩니다.

{
  ...
  "content": {
    "value": "values/dday",
    "imageMapKey": "0"
  }
}

 

이미지 늘이기

백그라운드 이미지는 뷰 크기에 따라 아래와 같이 이미지가 늘어날(stretch) 수 있도록 설정합니다.

 

아래는 예시 코드입니다. stretch 안 start와 endtopbottom 값은 백그라운드 이미지와 콘텐츠 뷰 사이의 패딩 값을 의미합니다. 콘텐츠 뷰가 글자 길이나 이미지 크기에 따라 늘어나면 패딩 영역을 제외한 나머지 부분이 늘어납니다. 이에 따라 위 예시 이미지와 같이 글자가 길어지더라도 패딩에 해당하는 사선 부분의 모양은 그대로 유지가 되면서 나머지 수평선 부분만 늘어나게 됩니다. 

{
 "type": "IMAGE",
 "stretch": {
   "start": 32,
   "end": 32,
   "top": 32,
   "bottom": 32
 }
}

 

스크립트 바인딩

위젯 중에서는 실시간 계산이 필요한 위젯도 있습니다. 예를 들어 D-day 위젯에선 시간의 경과에 따른 D-day를 실시간으로 계산해 숫자를 보여주어야 합니다. 그런데 미리 정의된 template만으로는 시간의 변경에 따른 표현을 할 수 없기 때문에 스크립팅을 통해 D-day를 계산해 위젯을 표현해야 합니다. 스크립팅 엔진에는 여러 가지가 있는데요. 저희는 사내에서 이미 안정적으로 사용해 본 Lua로 결정했습니다. 오래된 기술이지만 여전히 가볍고 빠르며 안정적이기 때문에 프로필 꾸미기에 적합합니다. 참고로 Lua 스크립트를 사용하기 위해선 라이브러리를 추가해야 하며, 작동은 런타임 중에 필요에 따라 스크립트를 받아 실행하고, 도출된 결과 값을 다시 네이티브 코드에서 사용하는 방식으로 작동합니다.

 

Lua 리소스

Lua 스크립트는 Base64로 인코딩되어 res로 저장됩니다.

{
 "res": [
   {
     "id": "res_lua",
     "type": "LUA",
     "lua": "bG9jYWw..."
   }
 ]
}

 

Lua 리소스 바인딩

클라이언트는 Lua에 리소스가 존재하는 경우 약속된 함수를 호출해서 Lua에서 계산된 데이터를 전달받습니다. Lua 함수 호출 시 파라미터와 반환 값은 Lua 테이블 형태로 주고받으며, 편의를 위해 문자열 값만 기존 데이터와 동일하게 key, value 형식으로 레이아웃에서 처리합니다. 

Lua에서 반환받은 값은 data나 res와 동일하게 리소스 바인딩에 사용합니다.

 

뷰 바인딩

레이아웃 구조에는 위젯에서 필요한 모든 뷰에 대해서 레이아웃을 정의하지만, 필요에 따라 일부 뷰는 활성화되지 않도록 만들 필요가 있습니다. 예를 들어, D-day 위젯에서는 숫자를 다섯 자리까지 표시할 수 있도록 레이아웃을 정의했지만, 실제로 D-day 계산 결과는 세 자리 또는 한자리 숫자만 나올 수도 있기 때문에 불필요한 자릿수는 표시하지 말아야 합니다. 따라서 레이아웃 내 binding 필드에 정의된 텍스트 리소스의 값이 empty인 경우엔 해당 레이아웃이 활성화되지 않도록 설정합니다. 아래와 같이 Lua를 통해 도출된 문자열 중 binding 값을 확인해 각 뷰의 활성화 유무를 판단합니다.

{
 "layout": {
   ...
   "children": [
     {
       "id": "digit1",
       "binding": [
         "gen_digit1"
       ],
       "flex": {
         ...
       }
     },
     {
       "id": "digit2",
       "binding": [
         "gen_digit2"
       ],
       "flex": {
         ...
       }
     },
     {
       "id": "digit3",
       "binding": [
         "gen_digit3"
       ],
       "flex": {
         ...
       }
     },
     {
       "id": "digit4",
       "binding": [
         "gen_digit4"
       ],
       "flex": {
         ...
       }
     },
     {
       "id": "digit5",
       "binding": [
         "gen_digit5"
       ],
       "flex": {
         ...
       }
     }
   ]
 }
}

 

모델 정의

앞서 말씀드린 디자인 내용을 토대로 JSON 모델을 작성한 뒤, 설명에 필요한 부분을 아래와 같이 간단하게 도식화했습니다.

위 그림에서 노란색으로 강조된 ProfileDecorationItemModel이 프로필 꾸미기의 각 위젯마다 생성되어 형태를 결정합니다. 앞서 설명드린 내용과 더불어 위젯의 실제 위치나 수정 가능 여부에 관한 속성들이 추가되어 있습니다. 저희가 활용하고자 했던 FlexBox는 레이아웃의 구성 요소를 나타내는 DecoLayout에 포함되었습니다. 다른 속성과 어우러져 위젯을 어떻게 그릴지 결정합니다. 무엇을 보여줄 것인지를 결정하는 또 다른 중요한 요소인 리소스는 위 도식에서 DecoData로 표현되어 있습니다. 앞서 설명드린 바와 같이 사용자의 변경 가능 여부에 따라 각각 ProfileDecorationItemModel에 속한 data와 DecoTemplate 내 res로 나뉘어 배치되어 있는 걸 확인할 수 있습니다.

 

실제 동작 메커니즘

앞서 정의한 JSON 모델은 다음의 과정을 거쳐 실제 화면에 나타나게 됩니다.

  1. 모델 내의 DecoData를 취합해 데이터 맵을 만듭니다.
    • 이때 앞서 설명드린 바와 같이 Lua 관련 DecoData가 존재한다면 스크립트를 구동해 나온 결과 값들도 데이터 맵에 추가합니다.
  2. 데이터 맵이 완성되었다면 DecoLayout을 활용할 차례입니다.
    • DecoLayout은 children, 즉 하위 레이어가 있는지를 판단하고 하위 레이어부터 해당 뷰를 생성해서 상위 레이어에 추가하는 방식으로 동작합니다.

아래는 동작 메커니즘을 표현한 그림입니다.

FlexBox 요소를 실제로 뷰에 반영하는 지점은 generate View(그림에서 연한 핑크색)입니다. 여기에서 type이나 binding 여부와 같은 DecoLayout의 속성을 확인한 뒤, Yoga Layout을 활용해 뷰에 대입합니다. 대입할 때 JSON 형태로 바로 사용할 수 있다면 좋겠지만 아쉽게도 그렇지 않습니다. 호수 위로 평온하게 보이는 백조가 물속에서 끊임없이 다리를 움직이듯, 단순화한 함수 내부의 대입하는 코드는 하나하나 노동력을 투자해서 직접 작성해야 합니다(iOS 기준). 아래는 예시 코드입니다. 

view.configureLayout { layout in
    layout.isEnabled = true
    if let positionType = flex.yogaPositionType {
        layout.position = positionType
    }
    if let start = flex.position?.start {
        layout.start = YGValue(CGFloat(start))
    }
    if let top = flex.position?.top {
        layout.top = YGValue(CGFloat(top))
    }
    if let end = flex.position?.end {
        layout.end = YGValue(CGFloat(end))
    }
    if let bottom = flex.position?.bottom {
        layout.bottom = YGValue(CGFloat(bottom))
    }
    if let flexDirection = flex.yogaFlexDirection {
        layout.flexDirection = flexDirection
    }
    if let justifyContent = flex.yogaJustifyContent {
        layout.justifyContent = justifyContent
    }
    if let alignContent = flex.yogaAlignContent {
        layout.alignContent = alignContent
    }
    if let alignItems = flex.yogaAlignItems {
        layout.alignItems = alignItems
    }
    if let alignSelf = flex.yogaAlignSelf {
        layout.alignSelf = alignSelf
    }
    if let aspectRatio = flex.aspectRatio {
        layout.aspectRatio = CGFloat(aspectRatio)
    }
    if let flexWrap = flex.yogaFlexWrap {
        layout.flexWrap = flexWrap
    }
    ...
}

위 과정이 끝난 뷰는 DecoLayout을 확인해 하위 레이어 존재 유무를 파악한 뒤, 더 이상 하위 레이어가 존재하지 않을 때까지 재귀적으로 위 과정을 반복합니다. 이후 자신의 하위 레이어에 구성이 끝난 뷰를 더하고, DecoLayout의 content나 background 속성의 값에 따라 데이터 맵에서 해당 값을 가져와 무엇을 보여줄지를 결정합니다. 이 과정이 모두 끝나 최상위 레이어에 해당하는 뷰가 반환되면 아래와 같이 Yoga Layout API를 통해 FlexBox의 요소를 렌더링해서 화면에 표현할 준비를 합니다(iOS 기준).

view.yoga.isEnabled = true
view.yoga.applyLayout(preservingOrigin: true)

이후 ProfileDecorationItemModel의 다른 속성에 따라 위치나 변형 등을 결정한 뒤, 아래와 같이 실제 프로필 꾸미기 화면에 뷰를 적용합니다.

 

 

실제 활용 사례

프로필 꾸미기 런칭 이후 실제로 클라이언트 대응 없이 위젯이나 테마를 업데이트해야 하는 경우가 발생했습니다. JSON 모델을 간단하게 서버에 추가 배포하는 것으로 대응할 수 있었습니다. 

 

대만 프로 야구

  

 

할로윈

 

 

마치며

처음 Flexbox를 도입할 때만 해도 ‘너무 과하게 준비하는 게 아닌가?’ 혹은 ‘노력에 비해 결과물이 별로이지 않을까?’하는 생각들이 많았습니다. 하지만 실제로 과정을 한 번 끝내고 나니 디자인 요소를 추가하는 게 훨씬 간단해져서 다양한 요소를 프로필 꾸미기에 추가할 수 있었습니다. 또한 무엇보다 개발자가 더 이상 관여하지 않아도 된다는 점이 좋았습니다.

물론 아직까지 불편한 점은 있습니다. 현재는 디자인 팀에서 새로운 레이아웃의 가이드를 공유하면 개발 팀에서 직접 JSON을 제작해 서버에 업로드를 요청하는 방식으로 진행하고 있는데요. 개발 팀의 손을 거쳐야 하기 때문에 디자이너는 자유도에 제한을 받고 변경 사항을 요청할 때도 제약을 받습니다. 개발 팀 또한 가이드를 보고 단순 작업을 반복 진행해야 하는 불편함이 있고요. 다행히 이런 불편함을 다들 인식하여 현재 서버 팀에서 툴을 제작하고 있습니다. 이 툴을 통해 불편함이 해결된다면 디자이너와 개발자가 모두 행복해지는 날이 조만간 오지 않을까…라는 상상도 합니다. 🙂

앞으로도 다양한 디자인 요소와 즐길거리가 많이 추가될 예정이니 이 글을 보고 계신 여러분도 Flexbox와 함께하는 LINE의 프로필 꾸미기를 즐겁고 재미있게 이용해 주시길 바라며 글을 마칩니다. 긴 글 읽어주셔서 감사합니다.