디어 앱 리뉴얼

deer-app-preview

Summary

전동 킥보드 공유 서비스 앱 "디어"를 새롭게 구현하는 프로젝트였습니다. 디자인 시스템 구축, 재사용 가능한 컴포넌트 설계, 유연하고 확장성 있는 코드 아키텍쳐, React Query의 도입 등에 초점을 맞췄습니다. 프론트엔드, 백엔드, 디자이너 총 3명이 함께 했고, 저는 프론트엔드 개발과 전체적인 프로젝트 매니징 및 일정 관리를 맡았습니다.

Period

2023.01 - 2023.04

TechStack

Typescript, React Native, Next.js, React Query, Zustand, React Hook Form, Bitrise, Storybook

Problems

앱 개편을 진행하게 된 배경에는 그동안 꾸준히 개발 난이도를 높였던 몇 가지 근본적인 문제점들이 있었습니다.

디자인 시스템의 부재

기존 앱에서는 디자인 시스템이 존재하지 않았습니다. 디자인 시스템은 앱 내에서 사용되는 디자인 구현체들을 파악하고, 관리하고, 재사용할 수 있도록 도와주는 시스템입니다. 이러한 시스템이 없던 시절에는 디자이너와 개발자가 함께 소통할 수 있는 "개념"이 존재하지 않았습니다. 매 기능마다 새로운 디자인이 탄생하고 개발자는 매번 새로운 디자인을 구현해야 했습니다. 서비스가 점점 복잡해짐에 따라 코드의 복잡도도 기하급수적으로 증가하게 되었습니다.

역할 단위 프로젝트 구조

프로젝트의 유지보수를 어렵게 했던 요인 중에 프로젝트의 구조도 한몫을 차지했습니다. 기존 프로젝트는 역할 단위로 코드를 구조화하고 있었습니다. 예를 들어, 컴포넌트라는 역할을 하는 파일은 Components라는 디렉토리 내에, 유저가 보는 화면을 담당하는 파일은 Pages라는 디렉토리 내에 있었습니다.

성장 중인 스타트업은 빠른 실험과 배움이 중요합니다. 저희 팀도 스프린트 단위로 새로운 피쳐를 추가하기도 하고 제거하기도 했습니다. 하지만 프로젝트 구조상 특정 피쳐에만 사용되는 요소들을 찾는 데 어려움이 컸습니다. 또한 파일들 간에 서로를 참조하는 명확한 기준이 없었기에 코드를 이해하고 변경하는 데 많은 비용이 발생했습니다.

복잡한 Client Store

Client Store(이하: 스토어)는 Redux, Mobx와 같은 툴로 대표되는 전역적으로 사용되는 상태를 관리하는 객체입니다. 기존 앱에서는 Mobx를 사용하여 API 응답값과 클라이언트 전역 상태를 스토어에 함께 저장해두고 있었습니다. 그중에서도 복잡도를 높이는 주요한 요인은 API 응답값이었는데요, 기존 앱에서 서버로부터 받은 데이터를 화면 단까지 전달하기 위해서는 다음과 같은 과정을 거쳐야했습니다.

  1. API 레이어에서 요청을 보내 서버로부터 응답을 받는다.
  2. 도메인별 Mobx 스토어에서 API 레이어에서 받아온 값을 전역 상태로 할당한다.
  3. Component에서 스토어의 상태값을 읽어 화면에 그려준다.

하지만 위 방식을 취했을 때 몇 가지 아쉬운 부분들이 존재했습니다.

  1. API 엔드포인트가 추가될 때마다 스토어에서 응답값을 저장해두는 상태와 그를 호출하는 메서드가 생겨야 한다.
    이는 API에 비례하여 전역 스토어가 복잡해지는 문제를 초래했습니다.
  2. 단순히 API 응답값을 저장하기 위한 전역 상태가 증가한다.
    어디서든 읽고 수정할 수 있는 전역 상태가 많아지면 코드의 수정과 관리가 어려워집니다.
  3. React 컴포넌트의 생명주기와 별개로 전역 상태를 관리해줘야 한다.
    컴포넌트가 unmount 될 때마다 전역 상태의 초기화를 신경써야하는 등 개발 복잡도가 증가합니다.

위의 대표적인 문제점들 이외에도 오랜 기간 명확한 기준없이 짜여진 코드들, 히스토리가 불분명한 로직들, 구식화된 의존성 라이브러리의 호환성 문제 및 크고 작은 버그들이 개발 경험과 속도를 상당히 해치고 있었습니다. 이에 위 문제점들을 한번에 해결할 수 있는 새로운 앱을 구현하기로 결정했습니다.

What I did

Design System

디자이너와 협업하여 디자인 시스템을 구축했습니다. 먼저 앱에서 사용될 색상, 타이포그라피 등을 tailwindcss의 custom class를 활용하여 토큰화했습니다.

Codes of Deer Design Token
디자인 토큰

그리고 앱에서 사용되는 컴포넌트를 역할에 따라 분류하여 유사한 역할을 하는 컴포넌트는 하나로 합치고 너무 많은 역할을 하는 컴포넌트는 여러 개로 분리하는 작업을 진행했습니다. 이후 컴포넌트 구현에는 Storybook을 활용했습니다. 구현이 완료될 때마다 Storybook 테스트 앱을 배포하여 디자이너와 실시간으로 UI 요구사항에 대해 소통했습니다. 최종적으로 약 30여개의 컴포넌트가 완성되었고 모든 컴포넌트는 사전에 논의한 디자인 토큰을 기반으로 구현되었습니다.

컴포넌트 설계 케이스: BottomSheet

구현된 컴포넌트는 대부분 직접 구현한 것들이었지만 몇 가지 복잡한 동작을 하는 컴포넌트들은 써드 파티 라이브러리를 래핑하여 앱 내 요구사항에 맞게 수정한 경우도 있습니다. 대표적으로 BottomSheet컴포넌트가 있었습니다. 모바일 환경에서 BottomSheet은 Modal과 같이 특정 UI 컨텍스트에 종속되지 않고 전역적으로 사용되곤 합니다. 하지만 대부분 써드 파티 라이브러리들은 BottomSheet의 구현 코드를 보여주고자 하는 UI 컨텍스트 (ex. 컴포넌트) 내에 위치시키는 방법을 택하곤 했습니다. 아래 코드는 RN진영의 대표적인 BottomSheet 라이브러리를 사용한 코드입니다.

BottomSheet Code Before

하지만 이러한 방식을 사용해본 결과, 몇 가지 문제점을 발견했습니다.

  1. 확장 가능하지 않습니다. 해당 UI 컨텍스트에 여러 개의 BottomSheet이 생겨야 한다면, 그에 따라 BottomSheet 관련 UI 코드와 BottomSheet을 제어하는 상태 및 Ref를 함께 추가해줘야 합니다.
  2. 해당 컴포넌트 밖(ex. 자식 컴포넌트)에서 BottomSheet을 제어하기 어렵습니다. 자식 컴포넌트에서 BottomSheet을 열고 닫고 싶다면, 관련 callback들을 props로 전달해주는 등 번거로운 작업이 필요합니다.

다시 한번 BottomSheet의 역할을 생각해보면, BottomSheet은 특정 컨텍스트에 종속되지 않고 최상단 Layer를 차지하는 UI 요소입니다. BottomSheet은 어떠한 스크린이나 컴포넌트 위에서 열릴 수 있어야 합니다. 즉, 전역적으로 관리되고 필요한 곳에서 쉽게 열고 닫을 수 있도록 만들어야 합니다. 아래는 제가 생각한 이상적인 BottomSheet의 용례입니다.

BottomSheet Code After

위와 같이 구현된다면, 앱 내에서 사용되는 BottomSheet 컴포넌트를 미리 선언해두고 필요한 곳이 어디든 컴포넌트 메서드를 호출하면 됩니다. 또한 BottomSheet의 내부 구현은 숨기고 최소한의 API를 노출함으로써 "어떻게" 사용하는지는 신경쓰지 않고 "어디서" 사용할지 즉, 비즈니스 로직에만 집중할 수 있게 됩니다. 구현은 아래와 같이 컴포넌트 외부에서 접근 가능한 Ref를 컴포넌트 메서드를 통해 제어하도록 만들어주었습니다. 더 자세한 코드는 여기서 확인할 수 있습니다.

BottomSheet Code

디자인 시스템 구축을 통해 앱 내에서 사용되는 디자인 요소와 컴포넌트를 명확히 규정할 수 있게 되었습니다. 이후 디자이너와의 소통 비용이 획기적으로 감소하게 되었고, 프론트엔드 개발자의 작업 효율도 개선되었습니다.

Colocation

프로젝트의 아키텍쳐를 재설계하며 고려한 중요한 원칙은 Colocation이었습니다. Colocation은 함께 변하는 것들을 최대한 가까이에 두는 것을 말합니다. 프로젝트의 최상단에 features라는 폴더를 두고 새로운 피쳐나 도메인이 추가될 때마다 하위 폴더를 늘려가는 방식으로 구성했습니다. 각 피쳐 폴더는 이전의 역할 단위 구조와 유사한 형태를 띄게 하였습니다.

Deer App Folder Structure

이를 통해, 특정 도메인의 변경 사항은 해당 도메인 폴더 하위에서 처리할 수 있게 되었습니다. 도메인 별 의존성이 최소화되어 있기 때문에 특정 피쳐를 추가하고 제거하는 비용이 현저하게 줄었습니다.

React Query

React Query는 서버 상태를 관리하는 라이브러리로 리액트 프로젝트에서 서버와 클라이언트 사이의 비동기 로직들을 손쉽게 다룰 수 있게 도와줍니다. React Query를 사용하면 로딩, 에러, 캐싱 전략 등 비동기 로직을 관리할 때 필요한 여러 작업들을 라이브러리가 대신해주기 때문에 비동기 통신을 위한 불필요한 작업이 현저하게 줄어들게 되었습니다. API 응답값을 컴포넌트에 렌더링하는 과정까지의 코드를 React Query를 도입하기 전과 후로 비교해보았습니다.

Before React Query
Before React Query
After React Query
After React Query

React Query를 도입한 이후에는 API 응답값을 받기 위한 불필요한 보일러 플레이트가 줄어들었고, 매번 전역상태의 초기화를 신경써야하는 비용도 사라졌습니다. 기존에 직접 구현했던 비동기 통신 중의 로딩, 에러 등의 상태도 React Query의 Api가 대신해주었기 때문에 개발 경험도 상당히 향상되었습니다. Client 단에서만 필요한 전역 상태는 Zustand와 같은 가벼운 라이브러리를 사용하여 구현하였습니다. 그 외에도 화면에 따라 React Query와 리액트의 Suspense, Error Boundary를 적절히 조합해서 사용함으로써 선언적으로 비동기 통신을 구현했습니다.

Outcome

약 3개월에 걸쳐 앱 리뉴얼 프로젝트를 완성했고, 이후 점진적인 배포를 통해 신규 앱의 트래픽을 늘려갔습니다. 프로젝트를 통해 레거시 프로젝트에 잔재해 있던 크고 작은 버그들을 해결할 수 있었고, 불필요한 코드와 로직을 개선함으로써 더 가볍고 빠른 앱을 고객들에게 제공할 수 있게 되었습니다. 팀 차원에서도 디자인 시스템을 활용하여 개발 속도가 대폭 향상되었고 실제로 코드를 관리하는 개발자들의 개발 경험도 개선되었습니다.

What I Learned

  • 낮은 결합도 높은 응집도. 아키텍쳐 레벨에서부터 구체적인 컴포넌트 레벨까지 변경에 유연한 설계에 대해 고민해 볼 수 있었습니다. 프로젝트의 유지보수가 어려워지는 이유는 구조가 변경의 속도를 따라가지 못해서라고 생각합니다. 변경의 속도에 맞는 적합한 구조를 취하는 것이 중요하다는 것을 배웠습니다.
  • 컴포넌트의 구현보다는 중요한 것은 인터페이스의 설계다. 인터페이스는 컴포넌트가 외부 세계와 소통하는 채널입니다. 적절한 인터페이스는 컴포넌트의 구현을 보지 않고도 컴포넌트의 역할을 알 수 있게 해줍니다. 편의를 위해 너무 많은 인터페이스를 제공한다면 컴포넌트의 역할이 모호해지고 컴포넌트 설계 당시에 의도하지 않은 결과가 생길 수 있습니다. 설계 원칙에 맞는 정확하고 최소한의 인터페이스를 제공하는 것의 중요성을 배우게 되었습니다.