Banksalad Product Language를 소개합니다

Banksalad Product Language를 소개합니다

안녕하세요 뱅크샐러드의 iOS개발자 류성두입니다. 오늘은 뱅크샐러드가 UI를 디자인하고 구현하는 방식의 큰 기둥인 BPL, 즉 Banksalad Product Language에 대해 이야기 해보려 해요. 먼저 본격적으로 소개하기 전에, 맛보기로 BPL을 활용해 UI를 만드는 방식을 잠깐 보여드릴게요.

이건 그저 맛보기일 뿐입니다. BPL은 그저 편하게 UI를 개발하는 툴 정도가 아니에요. 지금부터 BPL이 뭔지, 왜 만들었는지, 어떻게 만들었는지를 차근차근 알려드리도록 하겠습니다.

Product Language가 뭔가요?

먼저 Product Language란 무엇일까요? 아마 Product Language라는 말 자체가 생소하신 분들도 많으실 겁니다. 하지만 디자인 시스템이라고 하면 좀 익숙하신 분들도 계시겠죠? Product Language는 디자인 시스템의 확장판이라고 보시면 이해가 수월할 것 같습니다. 즉 일반적인 디자인 시스템이 디자인에 한정된 내용을 다루는 경향이 있다고 한다면, Product Language는 제품을 만드는 구성원 모두가 공유하고 사용하며 만들어가는 언어라고 할 수 있어요.

아마 이 글을 읽고 계신 독자분도, 제품을 만드는 여러 구성원이 서로 다른 언어를 쓰고 있다고 느끼신 순간이 있을 겁니다. 혹시 다음과 같은 대화가 익숙하지 않으신가요?

기획자: 그래서, 여기서 딱 다이얼로그가 뜨면 좋겠다는 겁니다. 디자이너:아 그거 저희는 팝업이라고 하는데 그거 말씀이신가요? 개발자: 아 알럿 말씀하시는 거군요? 하지만 사실은 다들 서로 다른 것을 말하고 있었다고 한다
또는 이런 대화는 어떤가요?


디자이너: 여기 이 버튼옆에 이미지만 하나 추가해 주세요. 개발자: 아 그건 구현상 뭐가 어떻게 되어있어서 이걸 바꾸면 다른데도 영향이 가고 이러저러해서 복잡해요😭 디자이너:(뭐가 복잡하단건지 설명은 들었지만 이해가 안 간다 ㅠㅠ 우리 스케치 심볼처럼 만들어 놓으면 옆에 이미지 하나 추가하는 건 일도 아닐텐데… 😭😭)


이런 상황이 벌어지는 근본적인 이유는 직군이나 조직별로 UI를 추상화하는 단위나 방법이 다르기 때문입니다. 즉 디자이너 입장에서는 특정 아트보드에만 영향이 가는 심볼을 수정한 것인데 iOS입장에서는 특정 네비게이션 플로우 전체에 영향이 가는 수정이 되고 Android는 앱 전체에 영향이 가는 수정이 되어버리는 상황이 얼마든지 일어날 수 있는 것이죠. 또 추상화의 단위와 방법이 다르니 자연스레 각 컴포넌트를 부르는 이름들도 달라질 수밖에 없습니다.

디자이너와 개발자가 서로 다른 단위로 컴포넌트를 추상화 한 예시

생각하는 단위가 다른 두 사람끼리, 대화가 되는 것이 더 신기하지 않을까요?

서로 비슷한 대상을 다른 이름으로 부르고, 심지어는 다른 대상을 같은 이름으로 부르는 경우까지도 발생하니 회의 시간의 첫 30분이 “용어정리”에 고스란히 할애되는 상황은 종종 발생할 수밖에 없습니다.

따라서 해결책은 명확합니다. 제품을 만드는 모든 구성원이 UI를 추상화하는 규칙 및 단위를 공유하는 것이죠. 그리고 그 공유의 방식과 내용을 뱅크샐러드에서는 BPL이라고 부릅니다.

어떻게 만들었나요? - 협업편

이런 문제를 해결하기 위해, 각 플랫폼별 개발자 및 디자이너들이 모여 BPL TF를 만들었습니다. 그런데 “왜 BPL이 필요한지”에 대해서는 다들 충분히 공감했지만 “그래서 구체적으로 무엇을 만들어야 하는지에 대해서는 각자 생각하고 있는 바가 미묘하게 달랐습니다. 그래서 TF를 만들고 처음 얼마간은 다음과 같은 대화를 심심치않게 볼 수 있었습니다.

디자이너가 설명한 것에 대해 iOS, Android, Web개발자가 각자 다시 설명해 보지만 디자이너는 모두 다 잘못 이해했다고 하는 장면

사실 그렇습니다. 애초에 모두모두 이야기가 아주 잘 통했다면 BPL을 만들 필요조차 없었겠지요. 우리는 최초에 의사소통이 잘 되지 않는 기간이 있을 것이란 것을 염두에 두고, 더 원활히 의사소통 할 수 있는 다양한 방법을 고민해 보았습니다. 그런 여러 노력들 중 가장 유효했던 두 가지 노력에 대해 이야기해 보겠습니다.

뭘 만들어야 할지 모르겠을 때는, 일단 만들어봅시다.

BPL을 만들면서, 제가 자주 듣고 또 제 스스로도 많이 되뇌이며 다른 사람들에게도 강조했던 말이 있습니다.

Communication cost is most expensive.
Code and Show first, argue after that.

혹시 개발을 하다가 기획서의 내용이 부족해서 답답했던 경험이 있으신가요? 그런 경우에 어떻게 개발을 해야 할지 고민하거나 기획자와 의사소통을 하는 일은 매우 힘들고 지난합니다. 당연히 그런 일은 최대한 피하고 싶죠. 그래서 개발자에게 가장 맘편한 환경은 “무엇을 만들어야 하는지 기획서에 모두 다 나와 있고 개발자는 그저 그것을 개발 하기만 하면 되는 상황”이라고 볼 수도 있겠습니다. 그런데 “무엇을 만들어야 할지”가 불분명한 상황에서는 그런 멋진 기획서를 기대할 수는 없는 노릇입니다. 그래서 이런 경우, 뱅크샐러드에서는 뭔가 부족하고 조잡하더라도, 일단은 어떻게든 각자의 생각을 최대한 코드로 구현해 돌아가는 제품을 먼저 만드는 문화를 가지고 있습니다.

기획이 명확하지 않은 상태에서 작성한 코드는, 기획이 명확해지면서 상당 부분이 버려질 수도 있고, 기획의 방향이 어떻게 결정 되느냐에 따라 완전히 폐기될 수도 있습니다. 그런 상황에서 시간과 노력을 들여 코드를 작성하는 일이 언뜻 불합리해 보일 수도 있습니다. 확실하게 의사소통해서 한 번에 하면 되는 일을 불명확한 의사소통 위에서 두 번 일하는 것처럼 보일 수도 있지요.

하지만 “확실하게 의사소통”하는 일은 매우 어렵고 심지어는 가능할지조차 불확실한 경우가 많습니다. 이런 불확실성을 줄일 수 있는 가장 빠르고 강력한 방법 중 하나가, 내 코드의 대부분이 버려질 것을 각오하고서라도 어떻게든 돌아가는 제품을 만드는 것입니다.

예컨대 BPL팀에서도 초창기에 프로젝트의 구조를 설계할 때, 핵심 개념 중 하나인 Container와 Child 개념을 정립하는 과정에서 아주 많은 시행착오가 있었습니다. 현재는 Child로 정립된 개념에 대한 용어 자체도 “ZComponent”라던가 “Atomic Component”라던가 “100 컴포넌트” 등등 시시각각 바뀌었고 각 용어에 대해서도 직군과 플랫폼 별로 조금씩 다른 생각을 가지고 있었습니다. “큰 것이 작은 것을 감싸는” 형태를 다들 바라는 것 같기는 한데, “큰 것”이 무엇이고 “작은 것”이 무엇인지, 그리고 각자가 이야기하는 개념이 확장 가능한 형태인지, 그 외 다양한 것들이 오리무중이었습니다. 그래서 우리는 각 플랫폼별로 “현재 가지고 있는 생각을 바탕으로 뱅샐의 첫 화면을 구현해온 다음 만나서 이야기하자”고 합의했습니다.

위 코드들고 구현한 화면

우리는 위와 같은 가장 간단한 화면의 Poc(Proof of Concept)를 먼저 만들고 논의를 이어가기로 했습니다.

그리고 며칠 뒤에 각 플랫폼별로 가져온 코드들을 비교하면서, 우리는 어디까지 서로 같은 개념을 공유하고 있고, 또 다르게 생각하고 있던 부분은 어디였는지 명확히 알 수 있었습니다.

// Web에서 구현한 BPL 최초 모습 
export const PageExample = () => (
<>
  <AssetSection
    leftTitle='계좌*현금'
    rightTitle='5,000,000원'
  >
    <AssetSubsection
      leftTitle='입출금'
      rightTitle='3,000,000원'
    >
      <ListItemContainer>
        <ListItem.LeftImageText
          imageSrc='https://cdn.banksalad.com/banks/aju-square.png'
          text='신한은행 입출금 통장'
        />
        <ListItem.RightText
          text='1,000,000원'
        />
      </ListItemContainer>
// Android에서 구현한 BPL 최초 모습
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/screen"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">

<com.rainist.banksalad.pl.library.container.ListItemContainer
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingStart="16dp"
    android:paddingTop="12dp"
    android:paddingEnd="16dp"
    android:paddingBottom="12dp">

    <com.rainist.banksalad.pl.library.component.BImageText
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:src="@drawable/ic_list_arrow"
        app:text="신한은행" />

    <com.rainist.banksalad.pl.library.component.BText
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:text="23000원" />
</com.rainist.banksalad.pl.library.container.ListItemContainer>
// iOS에서 구현한 BPL 최초 모습
override func loadView() {
    view =
    ListContainer.assetSection(headerTitle: "계좌*현금, headerValue:"5,00,000원") {
        ListContainer.assetSubsection(subHeaderTitle:"입출금", subHeaderValue:"3,000,000원") {
            ListItemContainer.singleLine {
                ListItem_LeftImageText()
                    .then { $0.image = BPL.Icon.Logo.shinhan.image}
                    .then { $0.text = "신한은행 입출금 통장" }

                ListItem_RightTextCaption()
                    .then { $0.text = "1,000,000원" }
            }
        }
    }

이렇게 각자의 코드 구조에서 어디를 수정해서 앱의 행동이 어떻게 변하는지를 서로 실시간으로 보여주니 전에는 보이지 않던 것들이 보이기 시작했습니다. 예컨대 Container와 Child의 관계는 세 플랫폼이 거의 비슷한 개념을 가지고 있었지만, 실제로 각 Container가 어떤 속성들을 들고 있는지는 조금씩 달랐습니다. 이렇게 코드를 비교하고 보니 “결국 우리가 합의해야 하는 것은 이 Container가 어떤 메소드와 속성을 가져야 하는지를 확립하는 것이구나!”라는 것을 알 수 있었고, 회의의 목적이 분명해지고 보니 회의는 허무할 정도로 빠른 속도로 끝이 났습니다.

말로만 의사소통 하다 보면 서로 비슷한 이야기를 하고 있다고, 심지어 서로를 모두 잘 이해하고 있다고 착각하기 쉽습니다. 상대방의 말을 잘 이해하는 것은 아주 어렵지만 고개를 끄덕이는 것은 너무도 쉬운 일이니까요. 오직 코드로 제품을 만들고 나야만 가시화되는 이해의 간극들이 있고, 그런 간극들이 제품을 통해서만 좁혀지는 경우도 있었습니다.

서로의 도구를 사용해 봅시다

올해 초 까지 뱅크샐러드는 다른 많은 회사들처럼, 디자이너가 스케치로 디자인을 한 뒤, 그 내용을 Zeplin을 통해 개발자와 공유하는 방식으로 일하고 있었습니다. 이 방식에는 많은 장점도 있지만 치명적인 단점이 하나 있었습니다. 바로 개발자가 디자이너의 작업방식에 대해 알기가 매우 어려워질 수 있다는 점이죠. 실제로 Zeplin만을 보는 개발자 입장에서는 디자이너가 스케치를 쓰는지 포토샵을 쓰는지 전혀 알 수도 없고 알 필요도 없는 상황이었습니다. 말하자면 Zeplin이 디자이너의 작업환경을 숨기는 Facade(외벽)와 같은 역할을 했던 것이죠.

Zeplin이 디자이너의 고민 내용을 개발자에게 가려주는 모습

Zeplin은 개발자 입장에서 디자이너들이 고민하는 세부적인 내용들을 가려주는 Facade(외벽) 역할을 했습니다.
이런 책임의 분리가 필요한 경우도 많습니다.

Seperation of Concern이라는 프로그래밍 원칙을 기계적으로 적용해 본다면 굉장히 바람직한 상황이라고도 볼 수 있겠네요. 하지만 함정은, 적어도 BPL에 한해서는 디자이너와 개발자가 같은 시점에 배포되는 같은 문제를 해결하는 제품, 즉 같은 Concern에 대해 얘기 해야만 했다는 점입니다. 이 단순한 진리를 깨닫고, 우리는 서로의 영역에 깊숙이 들어가 어떤 도구를 써서 어떤 방식으로 일하는지, 어떤 단위로 사고하고 무엇을 고려하는지 꼬치꼬치 캐묻기 시작했습니다.

개발자가 Zeplin의 벽을 넘어와 디자이너와 고민의 내용을 공유하는 모습

BPL은 개발자와 디자이너가 함께 만드는 제품입니다.
이를 제대로 만들기 위해서는 벽 바깥에서 벽 안으로 들어오려는 노력이 필요했습니다.

BPL 팀에서부터 사용하기 시작한 Figma라는 도구에서는 이 벽을 넘나드는 일이 굉장히 쉬웠습니다.

Figma의 한 페이지에 참여하는 참여자들의 권한은 크게 “Editor”와 “Viewer”로나뉩니다. 개발자들은 보통 “Viewer”권한으로 페이지에 접근하게 되는데, 이렇게 접근하게 되면 Zeplin을 통해 디자인 가이드를 보는 것과 별반 다를 바가 없습니다.

Figma에서 Viewer모드로 컴포넌트의 속성을 살펴보는 모습

Viewer모드에서는 디자이너가 작업한 결과물 + Figma가 자동으로 생성한 가이드들을 볼 수 있습니다.
하지만 이 가이드들은 디자이너가 의도하고 만든 가이드가 아니기 때문에 이 가이드대로만 만들면 디자인 의도를 살리지 못할 수 있습니다.

하지만 Viewer로 초대된 개발자도, 그 페이지의 사본을 생성하게 되면 그 사본에서 만큼은 Editor가 될 수 있습니다. Editor모드에서는 디자이너가 사용하는 메뉴와 툴들을 모두 쓸 수 있게 되며, 오직 디자이너가 수정한 숫자들만 보거나 만질 수 있습니다. 이 모드에서는 Zeplin같은 툴이 자동으로 생성해주는, 즉 디자이너가 직접 만들지 않은 가이드들을 만날 수 없기 때문에 디자이너가 어떤 의도로 이 컴포넌트를 만들었는지 훨씬 명확히 알 수 있습니다.

Figma에서 Editor모드로 컴포넌트의 속성을 살펴보는 모습

Editor모드에서는 실제로 디자이너가 어떤 기능을 써서 각 컴포넌트들이 어떤 관계를 가지도록 의도하며 만들었는지를 볼 수 있습니다.

이렇게 직접 디자이너들이 쓰는 도구를 사용해보고 관련 튜토리얼들을 따라해보며 디자이너 흉내를 내보는 기간을 지내고 나니, 비로소 눈에 보이는 것들이 있었습니다. 예컨대 Figma의 컴포넌트Class단위의 추상화와 얼마나 유사한지, 또 Autolayout이라는 개념이 iOS의 UIStackView 또는 Android의 LinearLayout과 얼마나 유사한지 등과 같은 것들 말이죠. 이런 부분들에 대해 약간의 이해가 생기자 개발자-디자이너간 의사소통에는 급격하게 속도가 붙었습니다.

어떻게 만들었나요? - iOS 구현편

디자인 도구들을 어느 정도 만지작 거리자, 우리는 개발차원에서 무엇을 만들어야 하는지 알 수 있게 되었습니다. 바로 디자이너가 컴포넌트를 만들면 1) 그것이 만들어진 방식을 최대한 모방하여, 2) 그것과 1대 1로 대응되는 클래스를 만드는 것이 개발자들의 할 일이었습니다.

최대한 Flat하게

먼저 내부적인 구현은 디자이너가 만든 방식을 최대한 모방해야 했습니다. 나중에 디자이너가 어떤 수정을 요청했을 때 개발자는 정확히 디자이너가 Figma에서 수정한 만큼만 수정하면 되도록 말이죠. 그래서 디자이너가 AutoLayout을 쓰면 UIStackView를 써서 구현했고, Constraint를 쓰면 NSLayoutConstraint를 써서 구현했습니다.

어려웠던 것은 “성급한 추상화를 하고 싶은 유혹”을 견디는 일이었습니다. 예컨대 아래의 두 컴포넌트를 보면, 실질적으로 다른 것은 레이아웃과 폰트 뿐입니다. 이런 컴포넌트들은 레이아웃과 폰트를 매개변수로 받아 초기화되는 클래스로 구현하고 싶은 욕망이 듭니다.

매우 유사한 두 개의 Figma 컴포넌트

이거 두 개..하나의 클래스로 만들면 안 되나요?

하지만 이는 우리가 BPL을 통해 얻으려 했던 “추상화 단위의 일치”를 이룰 수 없게 만듭니다. 이 두 컴포넌트는 완전히 다른 목적으로 다른 화면에서 각각 쓰이게 될 수도 있습니다. 따라서 훗날 하나의 컴포넌트에만 수정이 발생했을 경우, 다른 하나에도 그 수정이 의도치 않게 반영될 가능성이 존재합니다. 그때가 되면 “우리는 이 컴포넌트만 수정했는데 왜 전혀 상관없는 이 컴포넌트에 변경사항이 생기는지”에 대해 또다시 이해의 격차가 벌어지고 이는 의사소통 비용 증가로 이어질 것이었습니다.

따라서 우리는 각 컴포넌트들을 만들 때 상속이나 추상화보다는 “복사/붙여넣기” 신공을 훨씬 많이 썼습니다. 설령 지금은 그 둘이 굉장히 비슷해 보인다고 하더라도, “Figma에서 서로 의존성이 없는 독립적 컴포넌트라면 코드레벨에서도 서로 의존성이 없는 독립적 컴포넌트여야” 했기 때문입니다.

매우 유사한 두 개의 컴포넌트를 구현한 소스코드

위 두 컴포넌트의 BPL 구현입니다.
코드의 내용 대부분이 비슷하지만 둘 사이의 의존성을 끊기 위해 추상화를 하지 않았습니다.

SwiftUI 처럼

다음으로, 외부에 노출되는 API들을 만들 때에도 원칙이 있었습니다. Figma에서 나타나는 컴포넌트간의 Hierarchy가 BPL의 API를 활용한 코드에서 그대로 나타나야 한다는 것이었죠. 예컨대 Figma에서 BPL의 컴포넌트를 활용해 어떤 화면을 만들었다면 Figma의 왼쪽 구역에는 이 컴포넌트들간의 위계가 표현됩니다. 바로 이 위계가 코드레벨에서도 보여야 한다는 것이죠.

컴포넌트 사이의 위계가 Figma에서 드러나는 모습

디자인 시안에 드러나는 UI간의 계층구조가 코드레벨에 바로 표현되는 것이 BPL iOS 구현체의 주요 목표중 하나였습니다.

이런 위계를 보여주기 위해서는 SwiftUI의 구조를 많이 참고했습니다. 실제로 이런 위계가 코드에 드러나도록 만드는 것이 SwiftUI가 해결하고자 했던 주요 문제들 중 하나였으니까요. 예컨대 우리는 SwiftUI를 참고해 컴포넌트들을 그 성격에 따라 크게 둘로 나누었습니다. VStack, HStack과 같이 레이아웃에 대해서만 책임을 지는 Container컴포넌트들과, Text나 Button 과 같이 실제로 컨텐츠를 표현하는 Child컴포넌트들로 말이죠. 그리고 Container컴포넌트들의 선언부는 실제로 SwiftUI의 List나 VStack의 선언부를 그대로 긁어서 만들었습니다. 이렇게 해서 실질적으로 SwiftUI와 거의 똑같은 Syntax로 코드를 짜는 것이 가능했고 그 결과 아주 쉽게 Figma에서 보여지는 위계를 코드로 그대로 표현 할 수 있었습니다.

List.Section10(header: mainHeader(for:sectionData), collection: viewModel.subSections) { subSectionData in
    List.Margin0(header: subHeader(for: subSectionData), collection: subSectionData.assets) { item in
        ListItem.small1 {
            ListItem_LeftImageText()
                .then { $0.image = item.image }
                .then { $0.text = item.name }
            ListItem_RightTextMedium1()
                .then { $0.text = item.amountText }
        }
    }
}
이렇게 놓고 보니까 SwiftUI 코드 같죠? 하지만 UIKit 코드랍니다 😁

SwiftUI의 영향을 받은 것은 Container컴포넌트들 뿐만은 아니었습니다. Child컴포넌트들도 마찬가지였죠.

SwiftUI의 가장 간단한 구조는 다음과 같습니다. View에서 표현되어야 하는 정보들을 상단부에 모아놓고, 이 정보를 담은 변수들을 @State로 표시하면, SwiftUI의 엔진이 이 변수들에 변화가 생길 때마다 “레이아웃이 더러워졌다(dirty the layout)“라고 판단해서 View를 처음부터 다시 그리는 형태죠.

struct ContentView: View {

    // @State로 annotate된 변수에 변화가 생기면 뷰를 처음부터 다시 그립니다.
    @State var text: String = "HI"
    @State var image: UIImage = UIImage(systemName: "circle")!

    var body: some View {
        HStack {
            Image(uiImage: self.image)
            Text(self.text)
        }
    }
}

뱅크샐러드는 iOS11까지 지원하고 있기 때문에 SwiftUI를 쓸 수 없고 당연히 @State등과 같은 어노테이션도 쓸 수 없습니다. 하지만 @State와 비슷한 행동을 유발하도록 코드를 짜는 것은 그리 어려운 일이 아닙니다. WWDC 2018에서 다루었던 LayoutDrivenUI라는개념을 활용하면 아주 쉽게 이 행동을 만들어낼 수 있습니다.

/// 위 코드의 LayoutDrivenUI 버전
class ContentView_: UIView {
    var text: String = "HI" { // 표현해야 할 정보들에 변화가 생기면 레이아웃을 더럽힙니다.
        didSet { setNeedsLayout() }
    }
    
    var image: UIImage = UIImage(systemName: "circle")! {
        didSet { setNeedsLayout() }
    }
    
    private lazy var imageView = UIImageView()
    private lazy var textLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        layout()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // layout을 다시 하는 layoutSubView안에서 상태를 최신화 합니다.
        imageView.image = image
        textLabel.text = text
    }
    
    func layout() {
        addSubViews([imageView, textLabel])
        // 레이아웃 초기화 코드는 초기화 메소드 안에서만 불립니다.
    }
}
LayoutDrivenUI를 활용하면 UIKit 코드로도 SwiftUI를 흉내낼 수 있습니다

하지만 SwiftUI는 @State만으로 돌아가지는 않습니다. 바로 Combine이라는 비동기적 이벤트 전달방식을 통해 새로운 정보를 업데이트 하는 것이 더 일반적이죠. 예컨대 다음과 같은 코드 처럼 말입니다.

class MainViewModel: ObservableObject {
    @Published var text: String = "Hello"
    @Published var image: UIImage = UIImage(systemName: "circle")!
}

struct ContentView: View {
    // observed되고 있는 viewModel 에 어떤 변화가 생기면 View를 처음부터 다시 그립니다.
    @ObservedObject var viewModel = MainViewModel()
    var body: some View {
        HStack {
            Text(viewModel.text)
            Image(uiImage: viewModel.image)
        }
        
    }
}

그런데 Combine도 역시 SwiftUI처럼 iOS13 이상부터만 쓸 수 있습니다. 하지만 우리는 다행히 Combine을 대체 할 수 있는 매력적인 툴을 아주 오래 전부터 쓰고 있었습니다. 바로 RxSwift라는 녀석이죠! 우리는 이 녀석을 활용해 ViewModel을 만들고, 이 ViewModel의 정보가 바뀔 때마다 BPL컴포넌트를 업데이트 시키는 방식으로 SwiftUI를 흉내낼 수 있었습니다.

struct BankAccountListItemViewModel: BankAccountListItemViewBindable {
    let resource: Signal<UIImageView.Resource>
    let title: Signal<String>
    let amountText: Signal<String>
}

func mapBankAccountItem(viewModel: BankAccountListItemViewBindable) -> UIView {
    ListItem.small1 {
        ListItem_LeftImageTextXSmall1()
            .then { left in
                viewModel.resource
                    .emit(onNext: { left.image = $0 })
                    .disposed(by:$0.disposeBag)
                viewModel.title
                    .emit(onNext: { left.text = $0 })
                    .disposed(by: $0.disposeBag)
            }
        ListItem_RightTextMedium1_Animation()
            .then { right in
                viewModel.amountText
                    .emit(onNext: { right.text = $0 })
                    .disposed(by: $0.disposeBag)
            }
    }
}
RxSwift를 활용하면 Combine 없이도 SwiftUI를 흉내낼 수 있습니다

결과적으로 우리는 RxSwift와 LayoutDrivenUI를 이용해, Figma의 디자인을 코드로 선언적으로 작성하면 그대로 UI가 완성되는, 뱅크샐러드만의 선언적 UI프레임워크를 만든 것입니다.

두 개의 샘플앱

BPL의 iOS구현체인 BPL_iOS는 뱅크샐러드와는 완전히 별도인 프레임워크로 만들어졌습니다. 이 프레임워크는 어떤 프로젝트에도 import해서 바로 사용 할 수 있었습니다. 그리고 우리는 이 프레임워크를 import하는 두 개의 샘플 앱을 만들었습니다. 하나는 BPL_Component_Example이었고, 다른 하나는 BPL_Integral_Example 입니다.

BPL_Component_Example은 다양한 BPL의 대분류 리스트를 보여주고 그 중 한 항목을 탭해서 디테일로 들어가면 그 대분류에 속하는 모든 컴포넌트들을 낱개로 보여주는 앱입니다. 개발자는 새로운 BPL 컴포넌트를 만들면 이 샘플앱에 그 컴포넌트를 반영하고, 그 앱을 디자이너분들에게 전달해 검토를 받습니다. 검토가 끝나면 그 컴포넌트들에 대해 FBSnapshotTestCase를 활용한 스냅샷 테스트를 만들고 CI에서 해당 테스트가 돌아가도록 만듭니다.

class AssetsTest: FBSnapshotTestCase {

    override func setUp() {
        super.setUp()
        recordMode = false
        if UIDevice.current.name != "iPhone 11" {
            assertionFailure("스냅샷 테스트는 언제나 같은 디바이스에서 돌아가야 합니다")
        }
    }

    func testAssets() {
        let sut = AssetsViewController()
        let viewModel = StubAssetsViewModel()
        sut.bind(viewModel)
        sut.view.frame = CGRect(x: 0, y: 0, width: sut.view.frame.width, height: 4000)
        
        FBSnapshotVerifyView(sut.view)
    }
}
가짜데이터로 채워진 단순한 앱의 UI변경만 확인하는 테스트인 만큼, 테스트코드가 아주 간단합니다
새로운 수정으로 인해 기존 UI에 1픽셀이라도 오차가 발생하게 되면 위 이미지와 같은 diff를 출력하게 됩니다

특히 일반적으로 디자이너분들이 Figma에서 디자인 가이드를 줄 때는, 하나의 컴포넌트가 활용되는 다양한 예시를 함께 제시해주는 경우가 많은데, 샘플앱에서는 그런 경우의 수들을 모두 표현함으로써 디자인 의도가 명확하게 반영되었음을 확실히 합니다.

BPL_iOS 샘플앱의 모습
BPL의 샘플앱에서는 컴포넌트의 다양한 조합의 예시를 한 번에 확인 할 수 있습니다.

하지만 언제나 낱개 컴포넌트(유닛)만을 테스트해서는 발견 할 수 없는 버그들이 있습니다. 그 유닛들을 통합했을 때만 나타나는 버그들도 있죠. 이런 버그들이 발생하는 것을 캐치하기 위하여, 우리는 가장 현실적인 시나리오로 각 컴포넌트들이 조합되어 만들어지는 화면을 BPL로 구현하는 IntegralExample앱도 만들었습니다. 이 IntegralExample앱에서는 실제 서비스에서 조합되는 형식대로 BPL컴포넌트를 조합해 화면을 구성합니다. 다만 모든 데이터는 실제 서버에서 받아오는 데이터가 아니라 하드코딩된 가짜데이터를 사용하지요.

BPL_IntegralExample 샘플앱의 모습
겉보기에는 뱅샐 앱과 다름 없지만, IntegralExample은 오직 BPL을 활용하여 UI만 그릴 줄 아는 바보 앱입니다.

이 IntegralExample을 만들기 위해 별도의 노력을 들이는 것이 비효율적이라고 생각 할 수도 있겠지만, 사실은 그 반대입니다. 대부분의 UI코드는 이 IntegralExample 앱에서 먼저 작성되어 테스트되고, 실제 뱅크샐러드 앱에는 그 코드들이 거의 그대로 복사되게 됩니다. 그 결과, 추후에 뱅크샐러드 앱에서 UI관련한 버그가 발생했을 때, 대부분의 경우에는 IntegralExample에서 바로 재현이 가능하고 따라서 IntegralExample에서 수정을 마친 뒤, 해당 수정을 뱅크샐러드 앱이나 BPL컴포넌트에 적용하게 됩니다.

“처음부터 뱅크샐러드 앱에서 모든 수정을 다 마치는게 더 효율적이지 않나?”라고 생각 할 수도 있겠습니다. 하지만 일정 규모 이상의 Swift앱을 만드는 개발자라면 모두 공감하시겠지만, Swift기반의 iOS앱을 빌드하는데는 아주 많은 시간이 걸립니다. 뱅크샐러드 역시 100% Swift로 만들어진 앱인 만큼, 최근 여러 노력으로 빌드속도를 많이 개선하고 있지만, 대부분의 빌드 시나리오에서 2~3분정도가 걸리고 있습니다. 하지만 BPL_iOS 및 IntegralExample은 아주 제한된 책임만을 지는 작은 프로그램이기 때문에 수 초 이내에 빌드가 됩니다. 뱅크샐러드 앱이 빌드되는 180초 동안 10번은 더 다양한 수정을 해보고 확인하는 사이클을 돌 수 있는 것이죠.

이렇듯 BPL_iOS는 비단 개발자와 디자이너간의 의사소통을 줄여준다는 차원에서 뿐만이 아니라 순수한 개발 차원에서도 고속도로를 뚫어주었습니다.

  • 디자인 시안에 나와있는 컴포넌트의 이름을 Xcode에서 검색하면 그 컴포넌트의 구현체를 바로 발견 할 수 있고
  • 그 컴포넌트를 디자인 시안에 나와있는 방식 그대로 구성하면 UI가 완성되며
  • 그 UI를 확인하는데 까지 드는 시간이 10배 이상 짧아진 것입니다.

그 외에도 다양한 소득이 있었는데요, 특히 자랑하고 싶은 것 중 하나는 BPL을 통해 뱅크샐러드 앱의 접근성이 대폭 개선되었다는 것입니다. 이 역시 SwiftUI가 그 선언적 특성을 십분 활용해 접근성을 대폭 개선한 것에서 영감을 받은 것인데요, 이 부분은 별도의 블로그 글로 정리해 더 자세히 공유 드리도록 하겠습니다.

마치며

이전부터 뱅크샐러드를 써오신 분들이라면 눈치채셨겠지만, 7월 초부터 배포되기 시작한 이번 개편은 거의 대부분의 주요 UI를 갈아엎는 대공사였습니다. 그 큰 규모의 변화를 짧은 시간 안에 이뤄내야 했을 때, “이 많은 화면이 이 기간 안에 다 만들어질 수 있나?”라는 감각을 느꼈던 것을 기억합니다. 기존에 작업하던 방식으로는 엄두도 낼 수 없는 양이었거든요.

그렇지만 BPL이라는 고속도로가 있었기에, 우리는 결과적으로 예정했던 시간에 맞추어 무사히 개편된 뱅크샐러드의 새로운 모습을 선보일 수 있었습니다. 처음으로 BPL_iOS로 만들어진 뱅크샐러드를 손에 쥐었을 때의 희열이 아직도 기억나네요. 이 짧은 기간에 기획에 적힌 스펙은 물론이고 대부분의 접근성 지원까지 모두 고려된 앱이 나올 수 있을 것이라고 머리로는 이해하고 있었지만, 감각적으로는 쉽게 받아들여지지 않았거든요.

개편된 뱅크샐러드와 이전 뱅크샐러드의 비교

우리가 `뱅샐 2.0` 이라고 부르는 이번 개편은 뱅크샐러드가 가장 전면에 내새우는 기능들 대부분의 UI를 수정하는 대공사였습니다.

하지만 뱅크샐러드는 지금 막 새롭게 깔린 고속도로의 톨게이트를 지났을 뿐입니다. 우리는 이제 여태까지와는 차원이 다른 속도로 달려 나갈 겁니다. 유저들이 느끼는 불편을 해소하고, 유저들이 원하는 기능을 만드는 일에 더 이상의 제동장치는 없습니다. 이미 유저들이 느끼는 불편을 해소하기 위한 다양한 실험들이 동시다발적으로 진행되고 있습니다. 이전 같으면 개선을 위한 기획, 디자인, 개발, 배포까지 몇 주는 걸렸을 일들이 1~2주 안에 끝나고 있죠. 이렇게 빠르고 다양하게 진행되는 실험들의 결과를 바탕으로 우리는 훨씬 더 민첩하게 유저들이 정말로 원하고 필요로 하는 기능들을 발굴해 내고 그런 기능들을 마구마구 만들어낼 겁니다.

뱅크샐러드의 이 질주에, 함께하지 않으실래요?

보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기