📊
제목은 Vue에서의 데이터 시각화로 하겠습니다, 근데 이제 Chart.js를 곁들인

Vue와 Chart.js, Annotation 플러그인을 활용해 데이터 시각화 컴포넌트를 제작한 경험을 공유합니다.

July 02, 2021


JavaScript Vue FrontEnd


올해 상반기는 학업과 업무를 병행하면서 바쁜 일정들을 보냈습니다. 이 과정에서 회사 업무로 몇 가지 프로젝트를 담당하여 진행하게 되었는데요, 그 중 하나가 바로 반려동물의 행동 인식 및 영역 감지 데이터를 차트로 시각화하는 작업이었습니다.

미리보기 웹뷰로 제작된 페이지고, 이렇게 동작합니다

모니터링 앱을 통해 측정된 데이터를 서버에서 한 번 가공해주고, 프론트엔드에서는 이를 차트로 시각화해주면 되었습니다. 하지만 생각했던 것보다 복잡한 요구사항들과 저의 경험 부족으로 인해 생각했던 것보다는 시간을 오래 쏟게 되었죠.

그래도 삽질을 거듭한 덕분에 주어진 요구사항을 만족하는 컴포넌트를 제작할 수 있었습니다. 재사용성과 확장성을 사전에 염두해서 설계했기 때문에, 나중에 추가된 추가 요구사항을 구현할 때에도 시간 절약에 많은 도움이 되었습니다.

그래서 오늘은 구현 과정에서 경험한 라이브러리인 Chart.js, 그리고 추가적인 스타일링을 가능하게 하는 어노테이션(Annotation) 플러그인의 사용 방법에 대해 새롭게 알게된 바를 정리해보고자 합니다. 사실 공식 문서를 찾아보면 친절하게 설명이 되어 있긴 하지만, 한글로 작성된 자료는 거의 없었기 때문에… 혹시라도 같은 환경에서 작업하시는 분들께 많은 도움이 될 것이라 생각합니다.

이 포스트를 통해 Vue에서의 데이터 시각화 방법이 궁금하신 개발자분, 그리고 Chart.js와 플러그인을 어떻게 잘 활용할 수 있는지에 대해 궁금하신 개발자분들께 도움이 되기를 바랍니다.


요구사항 분석

우선 본격적으로 글을 시작하기 전에, 우리가 무엇을 만들 것인가 에 대해 정의를 하고 다음 단계로 넘어가야겠죠? 위에서의 움짤을 자세히 보신 분들은 아시겠지만, 다음의 8가지로 요약할 수 있을 것 같습니다.

다이어그램 전체 요구사항들을 모두 포함하고 있는 차트 이미지

  1. 우선 차트는 반응형이어야 하고, 막대형(Bar)의 그래프로 나타나야 한다
  2. 차트의 범례(타입)는 일간, 주간, 월간 세 가지 종류가 있다
  3. 차트의 범례를 변경할 때마다 차트가 갱신되어야 한다
  4. 차트를 그릴 수 있는 충분한 데이터가 없을 때에는 투명한 텍스트 레이어를 띄워 사용자에게 안내 메시지를 띄운다
  5. 아직까지 분석을 위한 정확한 통계가 수집되지 않았을 때에는 회색 레이어를 띄워 비활성화됨을 사용자에게 알려야 한다
  6. 차트의 범례에 따라 X, Y축 라벨링, 메인 컬러, 라벨 주기 등이 달라진다
  7. 특정한 X축 값을 하이라이팅할 수 있어야 한다
  8. 특정한 Y축 값을 점선으로 강조할 수 있어야 한다

실제로는 퍼포먼스 향상과 기존 비즈니스 로직 호환을 위한 추가적인 작업들이 더 들어갔지만, 이번 포스트의 주제와는 어울리지 않아 다루지 않을 예정입니다.

범례에 따라 달라져야 하는 내용들이 많기 때문에, 처음 디자인만 보고 생각했던 것보다는 좀 더 복잡하다는 것을 알 수 있습니다. 이러한 요구사항들을 어떻게 기술적으로 해결할 수 있을까요?

다이어그램 요구사항을 만족시키는 컴포넌트의 전체적인 구조를 다이어그램으로 나타내보았습니다

결론부터 말하자면 다음과 같습니다. (각 요구사항을 클릭하면 해당 부분의 설명으로 이동합니다.)

  • 요구사항 1번: Chart.js라는 데이터 시각화 라이브러리를 쓰는데, 이를 Vue 환경에서 편리하게 쓸 수 있도록 포팅(porting)해주는 vue-chartjs라는 라이브러리를 활용한다
  • 요구사항 2, 3번: vue-chartjs라는 외부 라이브러리를 한 번 래핑(wrapping)한 컴포넌트를 제작한다
  • 요구사항 4, 5번: 위 컴포넌트에 레이어 기능, 차트 관련된 분기 로직을 추가하여 새로운 컴포넌트로 다시 래핑한다
  • 요구사항 6번: 위 컴포넌트에 차트 이외의 기능이 포함된 새로운 컴포넌트를 만들어 다시 래핑한다
  • 요구사항 7, 8번: Chart.js의 플러그인 중 어노테이션 플러그인을 활용한다

2번부터 6번까지의 방법을 보고 고개를 갸웃거릴 수도 있을 것 같습니다. 래핑과 포팅이라는 단어가 계속 나오고 있네요. 기껏 만들어놓은 컴포넌트를 다시 또 다른 컴포넌트로 래핑하고, 그 컴포넌트를 다시 래핑합니다.

마치 예전 SNL에 나온 준하김밥이 떠오르는 건 저만 그런가요?

준하김밥 준하김밥은 여기서 끝이 아니죠! 컴포넌트를 네 겹까지 더 말아줍니다.

그래서 이왕 이렇게 된 거, 오늘 포스트의 컨셉은 김밥 말기에 비유해서 가보도록 해보겠습니다. 그럼 이제 각 요구사항을 해결하기 위한 김밥을 말아보겠습니다!


한 번 말기: Chart.js

Charjs 다이어그램 우선 가장 깊은 단계인, Chart.js에 대해 알아봅시다.

김밥에서 단무지가 핵심 역할을 하듯이, 이번 프로젝트의 핵심 라이브러리는 Chart.js입니다.

사실 JavaScript에서 사용할 수 있는 데이터 시각화 라이브러리들이 여러 종류가 있는데, 이 중에서는 D3.js가 아마 가장 유명할 것입니다. Github 기준으로 D3.js는 10년 전에 처음 나왔고 스타가 9.5만 개, Chart.js는 8년 전에 나왔고 스타가 5만 개 정도네요.

Chartjs Chart.js 공식 홈페이지

차트 라이브러리 계의 콩 라인이긴 하지만, 비교적 오래 전에 만들어진 덕분에 안정성도 높고 레퍼런스도 풍부합니다. 업데이트 역시 최근까지 계속 진행되고 있죠. 공식 홈페이지를 보게 되더라도 나름 문서가 잘 작성된 편입니다. 그리고 별도의 Vue 래핑 라이브러리인 vue-chartjs도 존재하죠.

결정적으로 예전 회사에서 Chart.js 기반으로 데이터 시각화 작업을 진행하는 것을 어깨너머로 살펴본 바가 있던 것이 컸습니다. 직접 구현에 참여하지는 않았고 다른 분이 작성한 코드를 리뷰한 것이긴 했지만 동작하는 예시 코드를 살펴본 기억을 떠올리면서 이번 작업에 Chart.js 라이브러리를 사용했습니다.

D3.jsChart.js를 비교한 글 역시 찾아 읽어보았는데 Chart.js가 더 배우기 쉬운 반면, 복잡하고 풍부한 기능이 필요할 때에는 D3.js가 더 유용하다고 합니다.


두 번 말기: vue-chartjs

VueChart

지금부터는 Chart.js의 Vue 래핑 라이브러리인 vue-chartjs를 이용하여 VueChart.vue 라는 이름의 싱글 파일 컴포넌트를 생성하는 방법에 대해 알아보겠습니다.

왜 쓰는가?

ChartJs vs VueChartjs 일반적인 사용 방법 vs 컴포넌트로 제작했을 때의 사용 방법

우선 이 라이브러리를 왜 쓰는지에 대해 알고 가는 것이 중요하겠죠? 한 마디로 이야기하자면 vue-chartjs는 Vue에서 Chart.js를 쓰기 쉽게 만들어주기 때문입니다.

Chart.js의 공식 문서를 보면 아시겠지만, HTML에 <canvas> 엘리먼트를 만들고 바닐라 JavaScript를 이용해 설정값을 런타임에서 넣어주는 것이 기본적인 사용법입니다. 다만 이 방법이 가상 DOM을 활용하는 SPA 프레임워크에서는 효율적이지 않기 때문에, 이를 위해서 Vue의 빌트인 렌더 메소드createElement()를 활용할 수 있습니다. 이 로직을 vue-chartjs에서 대신 해 주는 것이죠.

이 방법을 활용하면 동적으로 생성된 <canvas> 엘리먼트를 하나의 싱글 파일 컴포넌트 방식으로도 사용할 수 있게 해줍니다. 아시다시피 컴포넌트를 활용하면 중복되는 코드의 양을 줄일 수 있고, 재사용성이 높아지니 외부에서 가져다 쓰기도 편해지죠.

즉, 우리는 vue-chartjs를 통해 Chart.js를 별도의 싱글 파일 컴포넌트로 관리할 수 있고, 차트의 생성과 갱신, 삭제에 관한 로직 을 설정할 수 있습니다. 차트를 어떤 모양으로 그릴 것인지에 관련된 세부 설정들은 Chart.js의 옵션을 이용해 설정해야 합니다.

컴포넌트를 만들어보자

위 내용을 실제로 구현한 VueChart라는 컴포넌트를 제작해보고자 합니다. 우선 기존의 Vue 프로젝트에 Chart.jsvue-chartjs를 의존성으로 설치합니다.

npm install vue-chartjs chart.js
// 또는
yarn add vue-chartjs chart.js

그 후, 새로운 Vue 컴포넌트를 만듭니다. 만드는 방법은 공식 문서에도 나와있습니다.

Chart.js에는 막대형(Bar), 선형(Line) 등 여러 개의 차트 타입이 있습니다. 다만 우리가 구현하고자 하는 그래프 타입은 막대형이기 때문에, 지금은 막대형에 대해서만 설명하고자 합니다. 차트 타입에 따라 세부적으로 설정할 수 있는 옵션들에 차이가 있기 때문에, 혹시라도 도넛형, 파이형 등 다른 타입의 그래프를 그려야 한다면 vue-chartjs에서 지원하는 타입을 참조해주세요.

<!-- VueChart.vue -->

<script>
  import { Bar, mixins } from "vue-chartjs";
  import { locale } from "moment";

  export default {
    name: "vue-chart",
    extends: Bar,
    mixins: [mixins.reactiveProp],
    props: ["chartData", "chartOptions"],

    mounted() {
      this.renderChart(this.chartData, this.chartOptions);
    },

    created() {
      // 혹시 시계열 데이터 관련해서 언어 설정이 필요하다면,
      // 전역적으로 혹은 컴포넌트 생성 단계에서 할 수 있습니다
      locale("ko");
    },

    watch: {
      chartOptions() {
        // 옵션은 반응형 지원하지 않기 때문에 변경되었을 때 재렌더
        this.renderChart(this.chartData, this.chartOptions);
      },
    },
  };
</script>

예제 코드는 JavaScript를 기반으로 작성했습니다.

컴포넌트를 뜯어보자

위 컴포넌트의 코드 내용을 간단하게 요약하자면, extends 혹은 mixin을 이용해 vue-chartjs의 사전 정의된 컴포넌트를 확장해서 쓰는 방식입니다. (공식 문서에 따르면 둘 중에 아무거나 써도 된다고 하네요.)

템플릿 코드가 없네?

위 컴포넌트에서 눈에 띄는 부분 중 하나가 바로 템플릿(<template>) 부분이 없다는 것입니다. vue-chartjs에서 이미 템플릿을 생성하는 코드가 정의되어 있기 때문에, 여기에서 다시 템플릿을 정의하면 템플릿 충돌이 발생하게 됩니다. 그래서 공식 문서에서도 템플릿 코드를 정의하지 말라고 되어 있습니다.

두 개의 Prop을 받네?

다시 컴포넌트 속성으로 돌아와보죠. 이 컴포넌트는 두 개의 Prop을 받는데, 각각 chartDatachartOptions입니다. 이 두 개의 Prop은 아주아주 중요하기 때문에, 꼭 알아두셔야 합니다.

chartData실제로 차트에 표현될 정보를 담은 객체고, chartOptions데이터를 이용해 그려진 차트를 어떻게 보여줄 것인지에 대한 정보를 담은 객체입니다.

Chart.js에서 정의한 용어로 표현하자면 chartData데이터 구조(Data Structure)에 해당하고, chartOptions옵션에 해당합니다. 현재 컴포넌트를 사용하는 쪽에서 앞서 이야기한 객체들을 Prop으로 넘겨주는 방식으로 사용하게 됩니다.

그래서 실제 사용하는 쪽에서의 코드는 아래와 같이 간단한 모양이 됩니다.

<vue-chart :chartData="chartData" :chartOptions="chartOptions" />

렌더 메소드를 라이프사이클 내에서 호출하네?

이런 방식으로 두 개의 Prop을 받게 되면, Vue의 라이프사이클 중 mounted 훅에서 renderChart()라는 메소드를 호출하게 됩니다. 해당 메소드는 라이브러리에 내장된 메소드로, 넘겨받은 Prop을 이용해 실제로 <canvas> 엘리먼트를 생성하고 차트 데이터를 주입합니다.

감시자가 달려있네?

그런데 chartOptions 부분을 살펴보면 감시자(watcher)가 달려 있고 다시 차트를 그리는 코드가 중복되어 있습니다. 이 이유는 사실 vue-chartjs 의존적인 이슈라서, 이해가 잘 안 되면 그냥 넘어가도 괜찮습니다.

이 이유는 바로 vue-chartjs에서 chartData의 변경 감시는 지원하지만 chartOptions의 변경 감시는 기본적으로 지원해주지 않기 때문입니다. 공식 문서에도 이 한계점이 나와 있습니다.

위의 컴포넌트 코드 중에서 reactiveProp이라는 믹스인(mixin)을 확장해 쓰고 있는 부분이 있는데, 해당 믹스인은 chartData가 변경되었을 때 이를 별도의 설정 없이 다시 갱신하게 해주는 코드가 포함되어 있습니다. 그런데 chartOptions에 대해서는 해당 기능이 지원되지 않기 때문에, 매뉴얼하게 감시자를 붙여주는 것입니다.

왜냐하면 우리는 어떤 범례를 선택했는지에 따라 차트 옵션을 계속 갱신할 예정이고, 이것이 즉각적으로 차트에 반영되어야 하거든요.

로케일을 설정하네?

마지막으로 우리는 시계열 데이터를 다룰 예정이기 때문에, Chart.js와 호환되는 날짜 포매터 라이브러리의 로케일(locale)을 설정해 줄 필요가 있습니다. moment, luxon 등 사전 정의된 포매터만 인식 가능하기 때문에, 별도로 확인이 필요합니다.

저 같은 경우는 moment를 별도로 추가해서 로케일 설정을 컴포넌트 내에서 했습니다. 물론 main.js 같은 곳에서 전역적으로 설정하여 쓸 수도 있습니다.


세 번 말기: 래핑 컴포넌트 AnalysisChart 제작

AnalysisChart 지금까지 제작한 컴포넌트를 한 번 더! 말아줍니다

지금까지 우리는 Chart.js를 Vue에서 잘 쓰기 위해 vue-chartjs라는 래핑 라이브러리를 활용해 싱글 파일 컴포넌트를 만들었는데요, 이 컴포넌트를 한 번 더 감싸는 AnalysisChart 컴포넌트를 만드려고 합니다.

위에서도 설명했지만, 래핑 컴포넌트를 다시 만드는 이유는 외부 라이브러리에 대한 의존성을 낮추기 위한 목적이 큽니다. 디자인 패턴을 알고 계신 분이라면, 어댑터(Adaptor) 패턴이 이에 해당하죠.

래핑 컴포넌트는 어댑터 패턴의 구현체

래핑 컴포넌트 외부 라이브러리에 래핑 컴포넌트를 쓰지 않은 경우와 쓴 경우 비교

만약 비즈니스 로직 단에서 외부 라이브러리를 직접 가져다 쓰는 일이 생긴다면, 해당 로직은 외부 라이브러리에 직접적인 의존성을 갖게 됩니다. 이 상태에서 만약 사용하고 있는 외부 라이브러리에 변경이 일어나게 된다면, 라이브러리 의존적인 코드들이 모두 영향을 받게 되죠.

하지만 외부 라이브러리를 래핑 컴포넌트로 감싸게 되면 상황이 달라집니다. 래핑 컴포넌트만 외부 라이브러리에 의존성을 갖고, 나머지 비즈니스 로직은 래핑 컴포넌트에 의존성을 갖죠. 외부 라이브러리에서 변경이 일어나도 래핑 컴포넌트라는 중간 단계의 레이어가 있기 때문에, 여기에만 수정을 하면 비즈니스 로직에 영향을 주지 않게 만들 수 있죠. 덕분에 외부 의존성이 크게 줄었습니다.

이러한 래핑 컴포넌트의 특징을 이용해, 기존의 외부 라이브러리에서 지원하지 않는 기능을 확장한 컴포넌트를 만들 수 있습니다. 요구사항 중 2번과 3번을 다시 떠올려봅시다. 데이터가 없거나 비활성화된 상태에서 별도의 레이어를 띄워야 하는데, 이를 VueChart의 래핑 컴포넌트 AnalysisChart에서 기능을 추가하는 방식으로 해결할 수 있습니다.

컴포넌트 템플릿을 살펴보자

아래는 AnalysisChart 컴포넌트의 템플릿입니다.

<!-- AnalysisChart.vue -->

<template>
  <div class="analysis-chart">
    <vue-chart
      class="analysis-chart__content"
      :chartData="chartData"
      :chartOptions="chartOptions"
      :width="null"
      :height="null"
    />
    <div class="analysis-chart__backdrop" v-if="backdrop"></div>
    <div class="analysis-chart__empty-layer" v-if="empty">
      <p>데이터가 없어요</p>
    </div>
  </div>
</template>

템플릿 코드에서는 기존의 VueChart 컴포넌트를 비롯해 컴포넌트 자체에서 제공되지 않는 기능들을 확장한 부분이 보입니다. backdrop, empty 속성에 따라 조건부 렌더링을 하는 부분들이 보이네요. 물론 해당 속성들은 AnalysisChart의 Prop으로 받아야겠죠?

VueChart의 Prop으로 widthheightnull로 선언해서 넘겨주는 이유는 vue-chartjs에서 기본값을 주고 있기 때문입니다. 차트를 반응형으로 만들기 위해서는 chartOptions 객체에 별도 설정을 해주어야 하는데, 이를 동작하게 하기 위해서 너비 고정값을 해제해주어야 합니다.

컴포넌트의 Prop을 살펴보자

<script>
  export default {
    props: [
      "data", // 배열, 서버에서 직접 날려준 시계열 데이터가 담김, X축과 Y축에 표시될 정보를 갖고 있음
      "color", // 스트링, 차트의 메인 컬러 정보를 HEX로 표시
      "legend", // 스트링, 차트 범례로 이 값이 바뀔 때마다 `chartOption`을 변경하여 차트 업데이트를 유도해야 함
      "backdrop", // 불리언, true일 때 차트 비활성화를 표현하는 반투명 검은색 레이어를 띄움
      "empty", // 불리언, true일 때 측정된 데이터 없음을 표현하는 투명 레이어를 띄움
      "yAxis", // 스트링, y축 단위 라벨, y축 단위가 횟수 또는 시간(분)일수도 있기 때문
    ],
  };
</script>

AnalysisChart는 Prop으로 받은 Chart.js의 속성을 VueChart에 동적으로 넘겨주어야 할 뿐만 아니라 선택된 범례에 따라 분기 처리하는 비즈니스 로직까지 가진만큼, Prop과 관련된 코드가 길고 복잡합니다. 각 프로퍼티의 역할은 코드 옆에 주석으로 써놓았습니다.

받을 Prop을 위와 같이 선언해두었기 때문에, AnalysisChart를 실제로 사용하는 부분에서는 아래와 같이 사용할 수 있습니다.

<analysis-chart
  :data="data"
  :color="color"
  :legend="legend"
  :backdrop="backdrop"
  :empty="empty"
  :yAxis="yAxis"
/>

지금부터는 각 Prop의 조합을 이용해, VueChart 컴포넌트에서 차트를 그리는데 필요한 데이터인 chartDatachartOptions를 생성하는 과정에 대해 설명하고자 합니다.

차트에 표현될 데이터를 나타내는 chartData

AnalysisChart chartData는 차트에 직접 표현되는 데이터다

chartDataAnalysisChart 컴포넌트 내에 계산된 속성(computed property)으로 정의된 것으로, Prop으로 넘겨받은 data를 가공하여 VueChart로 넘겨주는 역할을 합니다. 이 외에도 대부분의 상태들이 계산된 속성으로 만들어져 있는데, 이는 Prop으로 넘어오는 데이터 변경에 즉각적으로 대응할 수 있도록 하기 위함입니다.

<!-- AnalysisChart.vue -->

<script>
  export default {
    computed: {
      bars() {
        return this.data.map((item) => {
          return {
            x: item.timestamp, // x축은 시계열 데이터인 유닉스 타임스탬프
            y: item.data ?? 0, // y축은 실제 측정된 값
          };
        });
      },

      chartData() {
        return {
          datasets: [
            {
              data: this.bars, // 위에서 정의한 { x, y } 배열
              barThickness: "flex", // 하나의 x축 영역이 얼마만큼 너비를 가지는지
              barPercentage: 0.5, // 하나의 x축 영역 내에서 실제 막대 그래프가 몇 퍼센트만큼 너비를 가지는지
              backgroundColor: this.color, // 백그라운드 색상
            },
          ],
        };
      },

      // ...
    },
  };
</script>

chartDataChart.js에서 정의한 데이터 구조의 형태를 갖고 있도록 전처리 해주는 역할을 합니다. 그리고 이 과정에서 더 작은 단위의 계산된 속성을 두어 관리하고 있죠.

bars는 외부에서 넘겨받은 Prop인 data 배열을 전문적으로 전처리해주는 속성입니다. 배열의 아이템에 들어있는 값들을 정해진 규칙에 따라 x, y 프로퍼티로 치환하면서 혹시 모를 null 값을 대비해 폴백(fallback)도 함께 넣어주고 있죠.

차트를 어떻게 표현할지를 나타내는 chartOptions

AnalysisChart chartOptions 는 조건에 따라 달라지는 것들이 많아서 좀 복잡하다

chartOptions는 차트의 전체적인 스타일 및 세부 설정들을 관리하는 객체입니다. 아래 코드에서는 차트를 반응형으로 유지하기 위한 속성들, 반응형 비율, 애니메이션 등을 비활성화한 모습을 볼 수 있습니다.

<!-- AnalysisChart.vue -->

<script>
  export default {
    computed: {
      chartOptions() {
        return {
          responsive: true, // 반응형
          aspectRatio: 1.19, // 반응형 유지하면서 가로/세로 비율을 고정
          legend: {
            display: false, // 차트 내 범례 디스플레이 비활성화
          },
          scales: {
            xAxes: this.xAxes, // x축 세부 설정, 별도 getter로 분리
            yAxes: this.yAxes, // y축 세부 설정, 별도 getter로 분리
          },
          animation: {
            duration: 0, // 트랜지션 효과 비활성화
          },
        };
      },
    },
  };
</script>

이 중에서도 특히 x축 세부 설정을 담은 xAxes, y축 세부 설정을 담은 yAxes 같은 경우는 별도의 계산된 속성으로 분리했습니다. 지금부터는 이에 대해 살펴보고자 합니다.

x축 커스터마이징을 위한 xAxes

AnalysisChart 선택된 범례에 따라 x축의 표현 주기, 라벨링, 그리드 등이 변경되어야 한다

xAxes는 x축과 관련된 세부 설정을 관리하는 객체들의 배열입니다. 지금 제가 만들 차트는 오직 한 개의 데이터를 표현하기 때문에 하나의 객체만 들어있습니다. 해당 코드는 아래와 같습니다.

<!-- AnalysisChart.vue -->

<script>
  export default {
    computed: {
      xAxes() {
        return [
          {
            type: "time", // axes 타입을 time으로 설정하여 시계열 데이터 관련 옵션들을 사용할 수 있게 하기
            time: this.currentLegend, // 시계열 관련 데이터, 별도 getter로 분리
            gridLines: {
              borderDash: [4, 6], // 그리드 관련 점선 스타일링
            },
            ticks: this.ticks, // 그리드로 나누어 그려지는 하나의 틱(주기) 관련 설정
          },
        ],
      }
    }
  };
</script>

우선 x축에 표현될 값이 시계열 데이터이기 때문에, type"time"으로 설정한 후 time 프로퍼티를 별도로 설정해야 합니다.

하지만 요구 조건에 의하면, x축 관련 속성들은 어떤 범례를 선택했는지에 따라 즉각적으로 변경되어야 합니다. 일간 그래프를 볼 때에는 24시를 기준으로 나타나야 하고, 주간 그래프를 볼 때에는 일 주일을 기준으로 나타나야 하며, 월간 그래프를 볼 때에는 한 달을 기준으로 나타나야 하기 때문이죠.

AnalysisChart 선택된 범례에 따라 x축 라벨과 주기가 달라져야 한다

해당 코드를 하나의 계산된 속성에 모든 코드를 넣으면 장황해지기 때문에, 조건부로 바뀌어야 하는 속성들은 currentLegendticks 라는 계산된 속성을 따로 만들었습니다.

<!-- AnalysisChart.vue -->

<script>
  export default {
    computed: {
      currentLegend() {
        switch (this.legend) {
          case "d": // 일간
            return {
              unit: "hour", // 각 x축의 시계열 데이터를 어떤 시간 단위 기준으로 나열할 것인지
              round: "minute", // 시계열 데이터를 반올림할 단위
              unitStepSize: 4, // 몇 개만큼의 unit을 하나의 tick으로 계산할 것인지
              displayFormats: {
                hour: "H", // 날짜 포맷 스트링
              },
            };

          case "w": // 주간
            return {
              unit: "day",
              round: "hour",
              unitStepSize: 1,
              displayFormats: {
                day: "dd",
              },
            };

          case "m": // 월간
            return {
              unit: "day",
              round: "hour",
              unitStepSize: 7,
              displayFormats: {
                day: "D",
              },
            };

          default:
            return {};
        }
      },

      ticks() {
        switch (this.legend) {
          case "d": // 일간
            return {
              callback: (tick, index, _array) => {
                // 다음날 0시를 일간 그래프 x축에 나타내기
                // index가 6인 이유는 unitStepSize를 4로 설정하였기 때문에
                // 6번째 오는 인덱스는 항상 다음날 0시를 나타냄
                if (index === 6) {
                  return 24;
                }
                return tick;
              },
            };

          case "m": // 월간
            return {
              // 매달 1일은 틱으로 나타내지 않음
              callback: (tick, index, _array) => {
                if (index === 0) {
                  return "";
                }
                return `${tick}일`;
              },
            };

          case "w": // 주간
          default:
            return {};
        }
      },
    },
  };
</script>

currentLegendxAxes에서 시계열 데이터를 디스플레이하기 위한 속성들을 현재 범례에 따라 조건부로 정의해주는 역할을 하는 계산된 속성입니다. 각 속성에 대한 설명은 코드 옆에 주석으로 남겨두었습니다.

tickscurrentLegend에서 설정한 unitStepSize 숫자만큼의 unit들을 하나의 그리드 단위로 생각하겠다는 의미입니다. unitStepSize는 몇 개의 x축을 하나의 틱으로 설정할 것인가를 나타내는 단위이며, 이렇게 묶인 틱은 ticks라는 속성에서 별도의 설정을 할 수 있습니다.

AnalysisChart 틱이 눈에 잘 띄지 않아 보라색으로 색을 바꿔보았다. 일간, 주간, 월간의 unitStepSize에 따라 그리드가 다르게 그려지는데, 이를 ticks에서 처리한다

아무래도 틱에 대한 설명이 복잡하니, 예제와 사진을 기준으로 설명하는 것이 나아보입니다. 위 예시 코드를 일간, 주간, 월간 세 종류의 범례에 따라 구분하면 다음과 같습니다. 예제에서는 콜백 함수를 이용해 몇 가지 경우에 대한 예외 처리를 해 놓은 것이 보입니다.

  • 일간에서는 시간 표현 단위를 시(hour)로 설정, 4시간마다 하나의 그리드(틱), 6번째 틱은 무조건 24가 오도록 예외처리
  • 주간에서는 시간 표현 단위를 일(day)로 설정, 1일마다 하나의 그리드(틱)
  • 월간에서는 시간 표현 단위를 일(day)로 설정, 7일마다 하나의 그리드(틱), 0번째 틱은 숫자 나타나지 않게 예외처리

y축 커스터마이징을 위한 yAxes

AnalysisChart y축의 조건문은 분기 처리가 없기 때문에, x축보다 훨씬 간단하다

마찬가지로 yAxes는 y축과 관련된 세부 설정을 관리하는 객체들의 배열입니다.

<!-- AnalysisChart.vue -->

<script>
  export default {
    computed: {
      yAxes() {
        return [
          {
            gridLines: {
              display: false, // y축은 그리드를 선으로 표현하지는 않음
            },
            position: "right", // y축 라벨의 위치
            ticks: {
              // 라벨에 표현되는 y축 그리드는 항상 3등분하기
              stepSize: Math.ceil(
                Math.max(
                  this.bars.reduce((acc, cur) => {
                    return Math.max(acc, Number(cur.y));
                  }, 0) / 3,
                  1
                )
              ),
              suggestedMax: 3, // y축 디폴트 최대값
              beginAtZero: true, // y축 시작을 항상 0부터 시작하게
              callback: (value, _index, _values) => {
                return `${value}${this.yAxis}`;
              },
            },
          },
        ],
      }
    }
  };
</script>

y축은 시계열 데이터가 아니라 일반 데이터를 나타냅니다. 즉 단순한 횟수만을 표시하면 되기에 코드가 훨씬 간단합니다. 다만 일부 코드에서 약간의 트리키한 부분이 있어, 해당 부분을 짚고 넘어가고자 합니다.

AnalysisChart 어떠한 극단적인 값이 들어오더라도 y축이 4개의 틱으로 나눠지는 모습. 고정값 설정으로는 불가능하기 때문에 값을 동적으로 계산하는 식을 넣어주었다.

우선 ticks 안에 있는 stepSize입니다. y축의 틱이 0을 포함해 항상 4개가 되도록, Max(data.y의 최대값)을 3으로 나눈 값으로 설정해야 합니다. 실제 코드에서는 이를 Array.reduce를 이용해 동적으로 계산하고 있습니다.

suggestedMax는 y축의 최대값이 적어도 해당 값보다 커야함을 나타냅니다. 해당 로직을 의사 코드로 나타내면 Max(data.y의 최대값, suggestedMax)입니다. 이를 설정한 이유는 위의 우측 하단 사진에서 볼 수 있듯이, 차트에 그릴 데이터가 없을 때에도 y축을 제대로 표현하기 위한 목적입니다.

AnalysisChart 모든 y축 그래프에서 공통적으로 나타나는 부분을 스킵해버리는 beginAtZero 속성

beginAtZero는 y축 데이터에서 공통적으로 발견되는 범위를 생략하고 보여주는 속성입니다. 위 사진의 오른쪽에서 y의 실제 범위는 100~140이지만, 94 미만의 값이 생략된 것이 보이시나요? 이것이 Chart.js의 기본 설정입니다.

우리는 범위 생략을 할 필요가 없기 때문에, beginAtZero 속성을 true로 설정함으로써 기본 속성을 비활성화 할 수 있습니다.

네 번 말기: 콜랩스로 감싸진 컴포넌트

AnalysisChart 지금까지 만든 건 차트 컴포넌트에 불과합니다. 차트 이외의 기능까지 포함한 것을 컴포넌트로 만들어봅시다.

이렇게까지 하면, 요구사항 5번까지를 만족할 수 있는 차트 컴포넌트가 모두 구현이 되었습니다.

차트 외에도 부가적이고 중복되는 기능들을 하나의 콜랩스 컴포넌트 내에 묶어서 이를 컴포넌트로 제작하면 요구사항 6번까지도 만족을 할 수 있습니다. 다만 이 부분은 데이터 시각화와는 연관이 없기 때문에 이번 포스트에서는 다루지 않습니다.

AnalysisChart 오른쪽에서 볼 수 있는 UI까지를 컴포넌트로 만들면, 다양한 내용을 담은 차트를 쉽게 만들 수 있다

여기까지 따라오시느라 정말 고생이 많으셨습니다. 하지만 아직 끝난 것이 아닙니다. 왜냐하면 아직 두 개의 요구사항이 남았거든요. 바로 어노테이션 플러그인의 사용입니다.

토핑 얹기: 어노테이션 플러그인 사용하기

AnalysisChart 엄밀히 말하자면 어노테이션은 chartOptions 내의 속성입니다

우리는 아직 차트의 특정 영역을 색칠하고, 평균과 관련한 점선 긋기 기능을 추가하지는 못했습니다. 해당 기능은 기본적으로는 구현할 수 없지만, Chart.js의 추가 확장 기능 중 하나인 어노테이션 플러그인을 설치함으로써 구현이 가능합니다.

AnalysisChart 어노테이션 플러그인 홈페이지에서 설명하는 다양한 종류의 어노테이션 예제들

어노테이션이라는 단어 자체는 주석(comment)이라는 의미인데, 차트에 부가적으로 기하학적인 도형들을 추가하기 위한 목적의 플러그인입니다. 추가할 수 있는 도형에도 여러 가지 종류가 있는데, 우리는 다음과 같은 두 가지 종류의 어노테이션을 사용할 예정입니다.

  • 라인 어노테이션(Line Annotation): 차트에 선을 그리기 위한 용도
  • 박스 어노테이션(Box Annotation): 차트의 특정 영역을 색칠하기 위한 용도

설치하고 사용하기

플러그인을 사용하기 위해서는 기존의 프로젝트에 의존성을 추가로 설치하고, 초기화 관련 코드를 수정해야 합니다.

npm install chartjs-plugin-annotation
// or
yarn add chartjs-plugin-annotation

어노테이션 플러그인을 사용할 때에는 버전 충돌 이슈가 있어서 그런지 chart.js 의 버전을 2.9.3 이하로 고정해야 합니다. Github에서 관련 이슈를 확인할 수 있습니다.

기존의 VueChart 컴포넌트에 아래와 같은 코드도 추가합니다.

<!-- VueChart.vue -->

<script>
  // 우선 플러그인 자체를 임포트하고,
  import ChartJSAnnotation from 'chartjs-plugin-annotation';

  // 컴포넌트의 mounted 훅에서 초기화함
  mounted() {
    this.addPlugin(ChartJSAnnotation);
    this.renderChart(this.chartData, this.chartOptions);
  }
</script>

AnalysisChart에서는 선으로 표현할 데이터들을 Prop에 추가합니다. 그리고 chartOptionsannotations라는 프로퍼티도 추가해줍니다.

<!-- AnalysisChart.vue -->

<script>
  export default {
    // 선으로 표현하고 싶은 데이터들을 추가 Prop으로 받음
    props: ["average", "averageAll"],
    computed: {
      chartOptions() {
        return {
          // 원래 있던 속성들 뒤에 추가
          annotations: this.annotations,
        };
      },
    },
  };
</script>

annotationsxAxesyAxes처럼 배열로 넣어주면 됩니다. 우선 완성된 코드를 볼게요.

<!-- AnalysisChart.vue -->

<script>
  export default {
    computed: {
      annotations() {
        return [
          // 라인 어노테이션
          this.lineAnnotationFactory({
            value: Number(this.averageAllUser),
            borderColor: "#989898",
          }),
          this.lineAnnotationFactory({
            value: Number(this.average),
            borderColor: this.color,
          }),

          // 박스 어노테이션
          ...this.data
            .filter((datum) => {
              return datum.highlighted;
            })
            .map((datum2) => {
              return this.boxAnnotationFactory({
                xMin: dayjs(datum2.timestamp)
                  .subtract(30, "minute")
                  .format("YYYY-MM-DD HH:mm:ss"),
                xMax: dayjs(datum2.timestamp)
                  .add(30, "minute")
                  .format("YYYY-MM-DD HH:mm:ss"),
                backgroundColor: `${this.color}${0x15}`,
              });
            }),
        ];
      },
    },
  };
</script>

annotations라는 계산된 속성은 배열을 리턴하는데, 여기에 라인 어노테이션들과 박스 어노테이션들이 들어가있는 모습을 볼 수 있습니다.

라인 어노테이션 같은 경우는 lineAnnotationFactory의 리턴값으로 완성된 어노테이션 객체가 배열에 담기게 됩니다. 하나의 객체가 하나의 선을 의미하죠.

박스 어노테이션 같은 경우에는 약간 복잡한데, 순서를 정리하자면 아래와 같습니다.

AnalysisChart 하나의 박스 어노테이션을 초록색으로 색칠해두었다. 저렇게 각 시간대 별로 하나의 박스 어노테이션을 만든다!

  • 모든 시계열 데이터에 대해서 highlighted 속성이 true인 것만 걸러냄
  • 해당하는 x값에 대해서 xMinx - 30분, xMax 값을 x + 30분으로 설정함
  • 따라서 해당 시각을 기준으로 딱 한 시간짜리 영역이 색칠됨, 이를 모든 시계열 데이터에 대해 반복함
  • 백그라운드 컬러에는 오퍼시티를 주기 위해 컬러 HEX값 뒤에 알파 값을 덧붙임

팩토리 함수 뜯어보기

뜬금없이 팩토리 함수가 나와서 당황하셨을텐데, 각 팩토리 함수들은 아래와 같이 생겼습니다.

// 완성된 라인 어노테이션 객체를 리턴
lineAnnotationFactory(options) {
  return {
    drawTime: 'afterDatasetsDraw', // Chart.js의 라이프사이클 중 어느 시점에 그려질지를 결정
    type: 'line', // 어노테이션 타입을 라인으로 설정
    mode: 'horizontal', // 선을 어떤 방향으로 그릴지
    borderWidth: 2, // 선 두께
    borderDash: [6, 4], // 점선 스타일링
    ...options,
  };
}

// 완성된 박스 어노테이션 객체를 리턴
boxAnnotationFactory(options) {
  return {
    drawTime: 'beforeDatasetsDraw', // Chart.js의 라이프사이클 중 어느 시점에 그려질지를 결정
    type: 'box', // 어노테이션 타입을 박스로 설정
    yMin: 0, // 선택될 영역의 y축 최솟값
    yMax: Infinity, // 선택될 영역의 y축 최댓값
    borderWidth: 0, // 테두리 두께
    borderColor: 'transparent', // 테두리 색
    ...options,
  };
}

lineAnnotationFactory는 타입이 line인 객체를, boxAnnotationFactorybox인 객체를 만들어 리턴하는 함수입니다. 두 함수 모두 파라미터로 넘겨받은 options 객체에 추가 옵션을 덧붙여서 리턴하는 함수임을 볼 수 있습니다. 각 옵션에 대한 설명은 주석으로 덧붙여놓았습니다.

이 중에서도 특별히 drawTime 속성에 대해서는 마지막으로 언급하고 넘어가고 싶네요. drawTimeChart.js의 라이프사이클과 관련이 있는데, 어떤 시점에 도형을 그려야 하는지를 결정하는 속성입니다.

AnalysisChart drawTime에 따른 z-index 차이. 시점에 따른 회색 선과 막대 그래프의 z-index 위치를 살펴보세요

beforeDraw, beforeDatasetsDraw, afterDatasetsDraw, afterDraw의 순서로 라이프사이클이 진행되며 나중에 진행될수록 위에 덮어씌워지기 때문에 z-index 가 높아집니다.

만약 우리가 beforeDraw, beforeDatasetsDraw에서 그리게 되면, 막대 그래프가 그려지기 전에 어노테이션이 그려질 것이고, afterDatasetsDraw, afterDraw를 선택하면 막대 그래프가 그려진 후 어노테이션이 그려집니다.

박스 어노테이션 같은 경우는 막대 그래프를 가리면 안되기 때문에 beforeDatasetsDraw을, 라인 어노테이션 같은 경우는 막대 그래프 위에 표시되어야 하기 때문에 afterDatasetsDraw를 선택한 것을 볼 수 있죠.

마무리

gif 진짜 끝!

이렇게 해서 요구사항을 모두 만족시키는 차트 컴포넌트를 구현할 수 있었습니다. 여기까지 읽으시느라 정말 고생이 많으셨습니다!

처음 사진만 보았을 때에는 간단해보이지만, 세세한 요구사항들을 구현하다보면 쏟아야 하는 노력이 정말 많습니다. 그래서 최대한 자세하게 내용을 작성하려다보니 글이 많이 길어지게 되었습니다. 이 과정에서 코드 양을 줄이기 위해, 사소한 스타일링 코드들은 생략했다는 점 알아두셨으면 좋겠습니다.

그래서 본문의 코드만 복붙한 채 실제로 구현을 하게 되면 이슈에 맞닥드릴 가능성이 높습니다. 특히 저는 스타일링을 하는 것이 많이 까다로웠습니다. 때문에 만약 데이터 시각화를 시작하시는 분들이라면 구현에 있어 충분한 시간을 가지고 시작하시는 것을 추천드립니다.

여담이지만, 파이썬의 데이터 시각화 라이브러리인 matplotlib를 사용해본 분들이라면 제가 어떤 노력(?)을 했는지 잘 알아주실 것 같다는 생각이 드네요.