안녕하세요, 저는 데브시스터즈 진저랩 웹서비스셀에서 소프트웨어 엔지니어로 일하고 있는 안정현입니다. 이 글에서는 데이터 앱 프레임워크인 Dash를 도입하면서 겪었던 아키텍처 측면의 이슈와 개선에 대해 공유해 드리고자 합니다.
데브시스터즈에서는 이미 Mode나 Databricks를 사용해 데이터의 수집, 적재, 시각화까지 모든 프로세스가 이미 잘 되어있었습니다. 하지만 이 많은 데이터를 시각화하기 위한 성능적인 측면과, 데이터를 보는 사용자에게 더욱 많은 인사이트를 제공하기 위한 다양한 기능들이 필요했습니다.
이를 위해 저희는 순수하게 Python만 사용해서 데이터 엔지니어도 쉽게 개발이 가능하면서도 직접 만든 React Component를 연동할 수 있어서 웹 개발자들에게도 친화적인 data app framework인 Dash를 사용해 자체적인 서비스를 구현하기로 했습니다.
Why Dash?
data app 개발을 위한 프레임워크가 많이 존재하고 있지만, Dash를 선택한 이유는 다음과 같습니다.
- 차트 라이브러리인 plotly.js를 개발한 Plotly에서 개발했기 때문에 차트 시각화 대시보드를 만들기에 궁합이 잘 맞습니다.
- 직접 만든 React 컴포넌트를 만들어서 사용할 수 있기 때문에 웹 개발자가 익숙한 방식으로 개발이 가능하고 사내 디자인시스템을 쉽게 적용할 수 있습니다.
- stateless한 디자인으로 scale out을 자유롭게 할 수 있어서 High Availability 구성이 가능합니다.
- Top of flask로 개발되어 있어서 아래의 예시와 같은 web app의 기능을 그대로 사용할 수 있습니다.
- middleware 연동 (Single Sign-On, ExceptionHandler, Cache 등)
- 추가적인 API 구현
- HTTP 압축
Dash callback
Dash는 브라우저의 이벤트를 받아서 callback이라고 하는 추상화된 API를 호출합니다. callback의 Input과 Output으로는 Dash로 작성한 HTML element의 property를 받을 수 있습니다.
callback은 Python으로 작성한 callback과 Javascript로 작성한 clientside callback으로 구분됩니다.
아래는 하나의 callback으로 데이터 요청 후 차트를 그리는 가장 간단한 구조를 도식화한 것입니다. 4단계에서 반환하는 figure는 차트를 그리는 데 필요한 데이터를 담고 있는 객체입니다.
비교적 간단해 보이는 이 구조는 아래와 같은 문제점이 있습니다.
callback의 Input과 Output이 많아질수록 복잡해지기 쉽고, 유지보수가 힘들어집니다. 하나의 callback 내에서 query부터 render까지의 모든 로직을 넣게 되면 다음과 같은 문제가 발생합니다.
- 시각화에 필요한 모든 로직을 넣어야 해서 코드 덩어리가 커지고 경계가 모호해집니다.
- 이미 가져온 데이터의 보여주는 방식만 변경하고 싶은 경우에 도 새롭게 query를 해야 합니다.
- 하나의 쿼리로 여러 차트를 그리는 경우 모든 로직이 순서대로 실행되기 때문에, n 배의 시간이 걸리게 됩니다.
실제 제품에서는 아래와 같은 추가적인 비즈니스 로직이 필요합니다.
- 데이터가 없는 경우 차트 대신 넘어진 용감한쿠키를 출력
- 데이터 캐싱
- 선택한 통화에 알맞은 표기로 전환 (ex. USD → $10.06만, KRW → ₩1.27억)
- 선택한 언어에 알맞은 표기로 전환 (ex. EN → ₩127.35M, KR → ₩1.27억)
이렇게 다양한 로직을 하나의 callback에 넣게 되면, 코드가 길어지고, 유지보수가 힘들어집니다.
callback을 분리하고 연결하기
위의 문제를 해결하기 위해 아래와 같은 방법을 사용했습니다.
하나의 callback을 각 역할에 맞는 callback으로 분리하는 작업을 진행하였고, 각 callback에는 역할에 맞는 로직만 작성할 수 있도록 규칙을 정했습니다.
일반적인 웹 애플리케이션 개발과 비슷한 경험을 이어 나가고자, MVC 패턴과 같이 크게 3가지 영역으로 나누었습니다. Dash가 top of React이고 React에서는 Provider라는 컴포넌트 형태의 wrapper를 사용하는 것에서 착안하여 각각의 이름을 FetchProvider, TransformProvider, RenderProvider로 정했고, 각 역할은 아래와 같습니다.
- FetchProvider: Databricks, Redis 등의 datastore로부터 데이터를 가져옵니다.
- TransformProvider: 가져온 데이터를 차트를 그리기 쉬운 형태로 가공합니다.
- RenderProvider: 가공된 데이터를 기반으로 차트를 그립니다. (Figure 객체를 생성합니다)
모든 callback은 API이므로, Output은 브라우저로 넘어가고, 다음 callback의 Input과 State로 재사용할 수 있습니다. 다시 말하자면, 언어 변경이나 누적값으로의 표현 방법 변경 등의 단순 view 변경 시 query를 다시 요청하지 않고, 이미 브라우저에 저장된 데이터를 재사용할 수 있다는 뜻입니다.
저장된 데이터를 재사용하는 경우, transform(아래 그림의 녹색 화살표)부터 실행되어, db를 거치지 않고도 변경된 차트를 화면에 반영할 수 있게 됩니다.
브라우저와 서버 간에 여러 번의 데이터 핑퐁이 있기 때문에 데이터를 빠르게 주고받는 것이 중요합니다. 저희는 gzip, br 두 가지의 압축을 적용하여 데이터를 약 1/5 크기로 줄였습니다.
Mixin으로 차트 그리는 로직 합치기
callback을 통해 요구사항은 충족되었지만, 차트를 그리는 코드가 재사용 되지 않는 문제가 여전히 남아있습니다.
또한 차트에서 데브시스터즈의 다양한 이벤트(대규모 업데이트, 마케팅 등)를 차트에서 같이 확인할 수 있는 history 기능도 제공하고 있는데요, 이러한 차트를 그리 는 코드가 render 콜백 여기저기서 반복된다면 유지보수가 힘들기 때문에 저희는 사용하는 패턴별로 차트를 모듈화하고자 했습니다.
디자인시스템의 톤앤매너에 맞게 차트의 스타일을 잡아야 했기 때문에 Dash의 Figure를 상속받아서 기본적인 디자인이 들어간 Figure 클래스를 생성하였고, Bar, Line, Area 등 다양한 시각화 로직을 Mixin 단위로 상속받는 방식을 택했습니다. Mixin에 대한 자세한 설명은 링크를 참고해주세요.
아래는 Mixin 클래스의 예시입니다. (복잡한 구현 부분은 생략하였습니다.)
# ChartMixin의 예시
class LineChartMixin(BaseMixin):
def add_cutsom_lines(self, traces: list[Trace]):
for trace in traces:
self.add_custom_line(trace)
def add_custom_line(self, trace: Trace):
self.add_trace(
graph_objects.Scatter(...) # Line 차트를 그려준다
)
class HistoryMixin(BaseMixin):
def add_histories(self, histories: list[History]):
self._add_background()
self._add_history_dots(histories=histories)
def _add_background():
self.add_shape(...)
def _add_history_dots(histories: list[History]):
self.add_trace(
graph_objects.Scatter(...)
)
class LineFigure(BaseAxisFigure, LineChartMixin, HistoryMixin):
def __init__(
self,
traces: list[Trace],
histories: list[History] = None,
):
super().__init__()
self.add_custom_lines(traces)
if histories is not None:
self.add_histories(histories=histories)
정리
데브시스터즈에서는 Dash를 도입하여 목적에 맞게 로직을 분리한 후, chaining 하여 복잡한 인터렉션에도 간단한 코드 구조를 가질 수 있게 되었습니다. 또한 Mixin 클래스를 활용하여 차트마다 사용되는 공통 기능을 관리하기 쉽게 되었고, gzip, br을 통한 데이터 압축을 통해 많은 데이터를 빠르게 주고받을 수 있게 되었습니다. Dash를 사용 중인 분들이 계신다면, 이번 글이 도움이 되셨으면 좋겠고, 더 좋은 방법이 있다면 공유해 주시면 좋을 것 같습니다.