본문으로 건너뛰기

· 약 28분
Alvin Hong

플러터란?

플러터는 구글이 출시한 오픈소스 크로스플랫폼 GUI 애플리케이션 프레임워크입니다. 안드로이드, iOS, 윈도우즈, 리눅스 및 웹 애플리케이션과 구글 퓨시아 앱의 주된 소스코드로 사용됩니다. 2.0 버전부터 웹 애플리케이션을 지원했으며, 3.0 버전에서는 맥OS와 윈도우즈 애플리케이션도 지원하게 되었습니다.

플러터 기반의 대표적인 서비스

우리가 알만한 몇 가지 서비스를 소개하겠습니다. 플러터 공식 홈페이지에서 제공되는 각 서비스의 성공 지표는 다음과 같습니다. 네이버 지식iN에 대한 설명은 다음 장에서 계속됩니다.

  • 네이버 지식iN
  • 구글 페이
    • 엔지니어의 노력 70% 감소, 코드 라인 수 35% 감소
  • 텐센트
    • 개발 시간이 33% 감소, 앱 코드에서 플러터의 비중은 90%
  • eBay 모터스
    • 네이티브보다 개발 속도가 2배 빠르다고 생각하는 개발자 비중이 70%
  • BMW

플러터 구조 및 동작 원리

플러터는 세 가지 계층으로 구성됩니다. 하드웨어와 직접 통신하는 임베더 계층, C++로 작성된 엔진 계층, 그리고 개발자들이 주로 사용하는 프레임워크 계층입니다. 임베더 계층은 6개 플랫폼의 네이티브 플랫폼과 통신하며 운영체제 기능을 모듈화합니다. 엔진 계층은 플러터 코어 API, 스키아 그래픽 엔진, 파일 시스템, 네트워크 기능 등을 정의합니다.

프레임워크 계층에는 플러터를 사용하는 데 필수적인 위젯, 애니메이션, 매터리얼 디자인 패키지, 쿠퍼티노 디자인 패키지 등이 포함됩니다. 이렇게 세 가지 계층으로 나뉘고 잘 모듈화된 아키텍처 덕분에 플러터는 다양한 플랫폼을 쉽게 지원하며, 일관된 API 및 개발 경험을 제공합니다.

image-1

플러터는 스키아 엔진을 지원하는 모든 플랫폼에서 실행될 수 있도록 구현됩니다. 플러터 팀은 iOS와 안드로이드 앱뿐만 아니라 윈도우, 리눅스, 맥OS 애플리케이션, 심지어 웹사이트까지 플러터 코드로 배포할 수 있도록 했습니다. 플러터를 사용하면 한 번에 6가지 플랫폼에 애플리케이션을 배포할 수 있습니다.

플러터가 스키아 엔진을 사용할 때의 장점은 무엇일까요? 대부분의 크로스플랫폼 앱 개발 프레임워크들은 웹뷰를 사용하거나 각 플랫폼의 UI 라이브러리를 사용합니다. 하지만 플러터는 웹뷰를 사용하지 않고 직접 스키아 엔진을 이용하여 화면에 UI를 그립니다.

기존의 플랫폼에서 제공되는 UI를 모두 버리고 자체적으로 UI를 렌더링하기 때문에 iOS에서 매터리얼 디자인과 ripple 애니메이션을 볼 수 있고, 안드로이드에서 쿠퍼티노 디자인을 사용할 수 있습니다.

플러터는 화면 전체를 2D 그래픽 API로 fillRect하고, drawText와 drawImage를 사용하여 앱을 만드는 것과 같은 방식으로 스키아 기반으로 렌더링해줍니다. 이는 웹 개발에서 HTML을 모두 무시하고 전체를 플래시나 캔버스로 만드는 것과 유사합니다.

예를 들어, 플러터의 대표적인 경쟁 프레임워크인 리액트 네이티브는 자바스크립트 브릿지를 통해 플랫폼과 통신합니다. 또한 플랫폼의 UI(OEM 위젯)를 그대로 사용합니다. 그래서 플랫폼과 리액트 네이티브 간의 통신에는 상당한 리소스 비용이 듭니다. 하지만 플러터는 위젯을 스키아 엔진에 직접 그려내고 필요한 제스처 및 이벤트를 브릿지를 통하지 않고 실행하기 때문에 리액트 네이티브에 비해 상당히 빠른 성능을 자랑합니다.

참고로 플러터 팀은 초당 60프레임을 목표로 많은 노력을 기울인다고 합니다.

image-2

다트 언어를 채택한 이유

다트는 2011년에 구글이 개발한 클라이언트-사이드 개발 언어입니다. 처음에는 자바스크립트를 대체하기 위한 언어로 소개되었으며, 실제로 처음 제공한 트랜스 컴파일 결과물도 자바스크립트였습니다. 다트는 JIT(Just-In-Time) 컴파일을 통해 VM 위에서 동작하는 방식과 AOT(Ahead-Of-Time) 컴파일을 통해 런타임에서 바로 동작하는 방식을 모두 지원합니다.

JIT 컴파일은 인터프리터처럼 실행 전에 코드를 컴파일하지 않아도 되기 때문에, 코드 수정 시 발생하는 빌드 시간을 줄여 개발자들이 더 편리하게 개발할 수 있는 장점이 있습니다. 그러나 VM 위에서 동작하기 때문에 대부분의 코드가 네이티브 플랫폼에 비해 성능상 느리다는 단점이 있습니다. 그러나 다트는 AOT 컴파일도 지원하기 때문에 실제 배포 시 AOT 컴파일을 통해 런타임에서 바로 동작하는 코드를 생성할 수 있습니다.

AOT 컴파일은 다트 2버전이 나오면서 추가된 기능인데, 아마도 플러터 팀의 요청이 있었을 것이라고 생각합니다.

image-3

혹시 다른 언어도 다트처럼 JIT와 AOT 컴파일러를 둘다 사용하는지 궁금해서 검색을 해봤는데, 다트처럼 개발 단계와 릴리즈 단계에서 각각 다른 컴파일 방식을 사용하는 언어는 없었습니다.

  1. C# / .NET: .NET Core와 .NET 프레임워크는 JIT 컴파일을 기본으로 하지만, .NET Native나 AOT 컴파일 옵션을 통해 AOT 컴파일도 가능합니다.
  2. Dart: Dart 언어는 플러터에서 주로 사용되며, AOT와 JIT 둘 다 지원합니다. 개발 중에는 JIT 컴파일을 사용하여 빠른 개발 사이클을 유지하고, 릴리즈 시에는 AOT 컴파일을 사용하여 최적의 성능을 달성합니다.
  3. JavaScript / TypeScript: 일반적으로 자바스크립트는 인터프리터 언어로 분류되며, JIT 컴파일이 많이 사용됩니다. 그러나 웹 어셈플리를 통해 AOT 컴파일도 가능합니다.
  4. Rust: 러스트는 기본적으로 AOT 컴파일을 사용하지만, JIT 컴파일을 가능하게 하는 라이브러리도 존재합니다.
  5. Go: Go 언어는 주로 AOT 컴파일을 사용하지만, 몇몇 특별한 상황에서는 JIT 컴파일을 사용하는 경우도 있습니다.

hot reload

플러터의 hot reload를 사용하면 개발 시간을 단축시키고 거의 즉시에 가까운 속도로 개발할 수 있습니다. 이 기능은 다트의 JIT 컴파일을 기반으로 하며, 다트가 JIT 컴파일을 지원하기 때문에 앱을 즉시 수정하고 실행할 수 있습니다.

제니퍼 화면 개발 중 웹팩 번들러에서 제공하는 hot reload를 사용합니다. 수정된 코드가 빌드되고 브라우저에 반영되는 과정에는 몇 초의 시간이 소요되어 답답한 경우가 종종 있습니다. 이는 제니퍼의 소스 코드 규모가 방대하고 웹팩을 그대로 사용하기 때문에 발생하는 현상일 수 있습니다. 물론 최근 Vercel에서 발표한 터보팩과 같은 번들러는 더 빠를 수도 있습니다.

네이버의 플러터 도입 과정

최근에 데이빗과 플러터 스터디를 진행하면서 모니터링 스터디 주제로 선정해야겠다는 생각을 했었습니다. 관련해서 검색해보는데, 네이버의 플러터 도입 과정을 블로그에 자세히 올린게 있어서 개인적으로 느낀 점이 많아 이렇게 소개해보려고 합니다.

먼저, 크로스플랫폼 도입을 검토하게 된 이유가 흥미로웠습니다. 네이버 정도되는 규모의 회사에서는 서비스 앱 개발을 어떤 프로세스로 진행하는지 어느 정도 파악할 수 있었습니다. 특히 디자인 단계에서의 어려움은 iOS와 안드로이드에서 모두 동일한 UI를 제공하려고 하더라도 구현하기 어려운 UI가 존재한다는 것이었습니다.

각 플랫폼마다 개발, 테스트, 배포를 진행하며, 플랫폼 상황에 따라 배포 일정이 달라질 수 있고, 서비스 상황에 따라 개발자 불균형 문제가 발생할 수 있다는 점입니다. 또한, 기획을 제외하고 디자인부터 별도의 프로세스로 진행되는 것이 인상 깊었습니다.

image-4

이와 같은 상황에서 최대한 개발 효율성을 높이고자 다양한 방법을 시도했는데, 과거 네이버 블로그 앱 개발팀에 있던 시절에는 아래와 같은 방법을 시도했다고 말합니다.

  • 기술 공유
  • 네이밍 컨벤션 맞추기
  • 라이브러리 개발

기술 공유의 경우에는 서로 다른 플랫폼 간에 도움을 주는 지식이 아니기 때문에 시간이 흐를수록 꾸준한 참여를 유지하기 어려웠으며, 플랫폼 별로 선호하는 네이밍 컨벤션이 다르고 개발 일정에 따라 매번 컨벤션을 맞추기 어렵다는 한계가 있었습니다.

또한, 팀 내에서 라이브러리 개발을 담당하는 인원이 필요하다는 문제가 있었고, 꾸준한 관리가 어려워서 서비스 별로 다른 스펙을 라이브러리로 구현하기도 어렵다는 문제도 있습니다.

다음은 플랫폼의 차이와 관련하여 비슷한 경험이 있는지 사내 모바일 개발자를 대상으로 설문 조사를 진행하여 다음과 같은 결과를 얻었습니다.

image-5

대다수 개발자들은 플랫폼에 특화된 스펙을 구현하는 것이 어려웠고, 개발자 불균형으로 인해 프로젝트 진행에 문제가 있었다는 것을 알 수 있습니다. 또한, 서비스 상황에 따라 크로스플랫폼이 필요하다고 생각하는 비율도 월등히 높았다고 언급합니다.

결국 네이버는 프로토타입 프로젝트도 진행해 보고, 개발자 불균형 문제를 겪으면서 더 효율적인 개발 방법은 없을까 고민한 결과, 팀에서 크로스플랫폼을 검토하기로 결정했습니다. 대부분의 팀원들은 크로스플랫폼에 대해 회의적인 시각을 가졌으며, 네이티브 방식으로 개발한 앱보다 실행 성능이 낮을 것으로 예상하여 검토를 시작했다고 합니다. 하지만 검토를 진행하면서 이 부분이 편견이었음을 알았으며, 지식iN 앱을 플러터 기반으로 개발하고 정식 서비스를 시작했습니다.

크로스플랫폼에 도전

1차 테스트로 일주일 동안 6명의 개발자가 플러터, 리액트 네이티브, 자마린(xamarin), SCADE 4개의 크로스플랫폼에 대한 성능과 생산성을 검토했습니다. 크로스플랫폼 도입을 위한 과정이 체계적이고, 많은 노력을 기울였기 때문에 크로스플랫폼 도입을 검토하는 사람들에게 많은 도움이 될 것으로 생각합니다.

table-1

평가 기준을 바탕으로 각 플랫폼을 비교한 결과, 플러터가 가장 좋은 평가를 받았습니다. 특히, 플러터의 성능은 팀에서 기대한 것보다 훨씬 우수하게 나왔습니다. 화면 동작에서는 네이티브 방식과 구분하기 어려울 정도의 성능을 보였다고 합니다.

네이버에서는 추가적인 검증을 위해 2차 테스트를 진행했는데, 플러터로 어떤 단계까지 구현이 가능한지 확인하기 위해 조금 더 난이도가 있는 앱을 과제로 삼았습니다. 약 2개월 동안 진행된 프로토타입 서비스를 플러터로 구현하기로 하고, iOS 개발자 4명과 안드로이드 개발자 3명이 약 2주 동안 테스트를 진행했습니다.

추가 기능을 테스트하기 위해 라이브러리, Google 지도, 웹뷰, 커스텀 UI 등을 구현하여 확인한 결과, iOS 개발자 6명과 안드로이드 개발자 6명이 2개월 동안 개발한 서비스를 2주 만에 유사하게 구현했습니다.

table-2

팀원들의 공통적인 의견은 각각 네이티브 방식으로 개발할 때보다 훨씬 수월했다는 점입니다. 사용성 확인을 위해 UI에 굉장히 집중한 프로토타입 서비스에서는 플러터가 네이티브 방식보다 훨씬 빠르고 편하게 UI를 구현할 수 있기 때문입니다. 특히 안드로이드 개발자들의 만족도가 높았다고 합니다.

다음은 2차 테스트를 마무리하고, 팀원들이 작성한 플러터에 대한 피드백을 요약한 내용입니다. 내용을 가볍게 살펴보시면 좋을 것 같습니다.

table-3

플러터 스터디를 하면서 느낀점

다트 문법이 자바나 코틀린이랑 비슷하기 때문에 플러터 개발은 고대(?) 유물인 자바 스윙이 떠오르긴 했었는데, 전혀 아니었습니다. 네이버 팀에서 플러터로 개발할 때 개발 기간이 짧아진 이유에 대해 다트 언어의 어렵지 않은 문법과 플러터의 선언형 UI 방식 때문이라고 결론을 내렸는데, 제 생각도 동일합니다.

선언형 UI 방식은 복잡한 UI를 작성할 때 이해하기에 더 직관적이기 때문에 유리합니다. 사실, 프론트엔드 개발자라면 익숙한 HTML이나 JSX, 뷰 템플릿 문법들은 모두 선언형으로 볼 수 있습니다.

아래 코드를 비교하면 명령형과 선언형의 차이를 쉽게 이해할 수 있습니다.

// Imperative style
b.setColor(red)
b.clearChildren()
ViewC c3 = new ViewC(...)
b.add(c3)

Win32에서 웹, Android 및 iOS에 이르는 프레임워크는 일반적으로 명령형 UI 프로그래밍 스타일을 사용합니다. 이 스타일은 여러분이 가장 익숙한 스타일일 수 있는데, UIView 또는 이와 동등한 기능을 갖춘 UI 엔티티를 수동으로 구성하고 나중에 UI가 변경되면 메서드와 설정자를 사용하여 변경하는 방식입니다.

// Declarative style
return ViewB(
  color: red,
  child: const ViewC(),
);

반면, 플러터는 개발자가 다양한 UI 상태 간 전환 방법을 프로그래밍해야 하는 부담을 덜어주기 위해 개발자가 현재 UI 상태를 설명하고 전환은 프레임워크에 맡길 수 있도록 합니다.

개발자 도구

플러터 2.0부터 개발자 도구가 지원되었는데, 직접 사용해보니 매우 편리했습니다. 앞에서 언급한 hot reload와 함께 개발자 도구는 개발 생산성을 크게 향상시킬 것으로 기대됩니다.

image-6

예제 소개

먼저, 웹툰 예제는 노마드코더의 클론 코딩 강의에서 다룬 내용을 따라서 구현한 것입니다. 플러터는 네이티브 앱 수준의 페이지 전환 효과를 쉽게 구현할 수 있도록 관련 API를 제공합니다. 강의 댓글 중에서 흥미로운 내용을 발견했는데, 리액트 네이티브로 동일한 기능을 구현하는 것은 매우 어렵다며, 플러터로는 정말 간단하게 구현할 수 있다는 점이 좋았다는 것이었습니다.

플러터는 iOS의 쿠퍼티노 디자인 패키지를 지원하지만 네이티브 앱만큼 완벽하지는 않습니다. 시간이 지남에 따라 네이티브 앱과의 디자인 격차는 더 벌어질 것으로 예상됩니다. 필요에 따라 플러터의 디자인 패키지에서 제공하지 않는 UI를 개발해야 할 수도 있습니다. 이런 경우, 개발자는 CustomPainter 클래스를 상속받아 직접 캔버스에 그릴 수 있습니다.

이렇게 구현한 클래스는 결국 플러터에서 제공되는 위젯들과 동일한 위젯으로 취급되기 때문에 기존의 위젯들과 함께 자연스럽게 사용할 수 있습니다. 제니퍼에서 캔버스를 활용하여 만든 컴포넌트들 중에는 기존의 마크업이나 스타일과 함께 사용되는 경우도 있지만, 서로 독립적인 부분도 있으며, 특히 이벤트를 구현할 때 고려해야 할 사항이 많습니다.

마지막으로 플러터와 관련된 검색 중에 토스 앱에 있는 3D 인터랙션을 플러터로 구현한 예제를 발견했습니다. 개인적으로 토스의 UX/UI가 매우 훌륭하다고 생각하는데, 일부 기능 중 하나를 플러터로 쉽게 구현한 것을 보고 플러터의 가능성을 높게 평가하게 되었습니다.

image-7

  1. 페이지 전환 예제 : 페이지 전환시 효과를 쉽게 구현할 수 있습니다. (데스크탑 브라우저에서는 이미지가 보이지 않음)
  2. 캔버스 그리기 예제 : 캔버스에 그라디언트 효과를 주고, 심볼(별모양)을 직접 그려서 무작위로 화면에 보여지는 배경 위젯입니다.
  3. 3D 인터랙션 예제 : 토스 앱의 상품권 추가시 3D 인터랙션을 구현한 예제입니다.

글을 마치며

플러터 스터디를 진행하고 모니터링 스터디 발표 자료를 준비하면서 몇 가지 결론을 내리게 되었습니다. 첫째로, 데스크탑과 모바일은 완전히 다른 기획과 사용자 경험이 필요하다는 것입니다. 모바일은 프론트엔드 영역이 아니라 플랫폼에 종속된 전혀 다른 분야로 생각하고 크게 고민하지 않았습니다. 사실 플랫폼이 iOS와 안드로이드로 크게 나뉘어져 있기 때문에 어느 한 쪽을 선택하는 것이 어려웠습니다. 플러터 덕분에 이러한 선택의 어려움을 해결하고, 본격적으로 모바일 UX에 대해 알아볼 수 있게 되었습니다.

두 번째로, 이건 지극히 개인적인 사정이긴 한데, 프레임워크의 발전과 ChatGPT의 등장으로 프론트엔드 개발자로서 성장의 한계를 느끼고 있던 시점에 플러터를 발견하여 데스크탑 웹을 넘어 여러 플랫폼으로 프론트엔드 역량을 확장해나가야겠다는 새로운 목표가 생겼습니다.

데스크탑과 모바일을 모두 고려한 서비스가 무조건 성공하는 것은 아니지만, 현재 성공한 서비스들을 살펴보면 둘 중 하나만 지원되는 서비스는 드물다고 생각됩니다. 네이버의 플러터 도입 검토 과정을 보면 알 수 있듯이, 보다 적은 인원으로 네이티브 수준의 애플리케이션을 개발할 수 있다는 것은 개발 인원이 적은 조직에서 충분히 고려해볼 만하다고 생각합니다.

참고 링크

· 약 20분
Alvin Hong

개요

2020년 12월 React 팀은 서버 주도(Server-Driven) 멘탈 모델로써 모던 UX를 가능하게 하는 것을 목표로 하는 zero-bundle React 서버 컴포넌트를 시연했습니다. 이때 소개한 서버 컴포넌트는 서버 사이드 렌더링(SSR)과는 상당히 다르며 클라이언트 측 번들 크기를 매우 줄일 수 있다고 하였으며, 서버와 클라이언트(브라우저)가 React 애플리케이션을 서로 협력하여 렌더링 할 수 있게 됩니다.

그리고 2022년 10월 Next.js Conf에서 Next.js 13부터 React 서버 컴포넌트를 지원한다고 발표했습니다. 처음 서버 컴포넌트를 소개한 후, 시간이 꽤 지났음에도 불구하고, 아직도 실험적인 단계이긴 하지만 Next.js 기반으로 서버 컴포넌트를 구현해볼 수 있게 되었습니다. Next.js 관련해서는 데이빗이 작성한 문서를 참고하시면 됩니다.

SSR의 한계

오늘날 자바스크립트의 서버 사이드 렌더링(SSR)은 초기 페이지 로드 시간을 개선하기 위한 차선책입니다. 컴포넌트의 자바스크립트는 서버에서 HTML 문자열로 렌더링됩니다. HTML은 브라우저에게 전달되고 나타나며, 빠른 FCP(First Contentful Paint) 혹은 LCP(Largest Contentful Paint)를 가지게 됩니다.

하지만 Hydration 단계를 통해 인터렉션이 가능해진 엘리먼트를 위해서는 여전히 자바스크립트가 필요합니다. 서버 사이드 렌더링은 일반적으로 최초 페이지 로드를 위해 사용되기 때문에 Hydration 이후에는 추가적으로 쓰이지 않습니다.

클라이언트에서의 Hydration을 전혀 하지 않고, React와 SSR만 가지고 애플리케이션을 만들 수도 있지만 애플리케이션에서 인터렉션을 많이 해야 하는 경우에는 어쩔 수 없이 클라이언트 측으로 코드를 보내야 합니다. 서버 컴포넌트의 하이브리드 모델은 이 선택을 컴포넌트 기준으로 가능하도록 해줍니다.

React의 서버 컴포넌트를 활용하면 컴포넌트들을 정기적으로 다시 받아올 수 있습니다. 새로운 데이터가 있을 때 서버에서 애플리케이션의 컴포넌트들을 리-렌더링 할 수 있으므로 클라이언트 측에 전송해야 하는 코드량을 줄일 수 있습니다.

SSR은 서버에서 HTML을 생성하고 클라이언트로 전송하는 방식입니다. SSR은 초기 로딩 속도를 높일 수 있지만 RSC는 더 많은 기능을 제공합니다.

Hydration이란?

  1. 컴포넌트를 렌더링하고, 이벤트 핸들러를 연결하는 프로세스
  2. 서버 단에서 렌더링한 정적 컨텐츠와 번들링 된 JS 파일을 클라이언트에게 보낸 뒤, 클라이언트 단에서 HTML 코드와 React인 JS 코드를 서로 매칭 시키는 과정

RSC vs SSR

결론부터 말하자면 RSC는 SSR을 대체할 수 없습니다. SSR은 해당 페이지의 HTML 코드를 미리 빌드합니다. 이것은 SEO에 효과적이고, 사용자가 자바스크립트 코드를 다운받는 동안 해당 페이지의 내용을 미리 볼 수 있습니다. 즉, SSR은 해당 페이지 자체를 렌더링하는 것이고, 서버 컴포넌트는 각각의 컴포넌트마다 클라이언트 컴포넌트로 렌더링 할 것인지 서버 컴포넌트로 렌더링 할 것인지를 선택해야 합니다.

Next.js와 SSR의 차이점에 대해 Dan과 Lauren의 talk에서 언급한 내용을 정리해 보면:

  • 서버 컴포넌트의 코드는 클라이언트로 전달되지 않습니다. 하지만 서버 사이드 렌더링의 모든 컴포넌트의 코드는 자바스크립트 번들에 포함되어 클라이언트로 전송됩니다.
  • 서버 컴포넌트는 페이지 레벨에 상관없이 모든 컴포넌트에서 서버에 접근 가능합니다. 하지만 Next.js의 경우 가장 top level의 페이지에서만 getServerProps()나 getInitialProps()로 서버에 접근 가능합니다.
  • 서버 컴포넌트는 클라이언트 상태를 유지하며 re-fetch 될 수 있습니다. 서버 컴포넌트는 HTML이 아닌 특별한 형태로 컴포넌트를 전달하기 때문에 필요한 경우 포커스, 인풋 입력값 같은 클라이언트 상태를 유지하며 여러 번 데이터를 가져오고 리렌더링하여 전달할 수 있습니다. 하지만 SSR의 경우 HTML로 전달되기 때문에 새로운 re-fetch가 필요한 경우 HTML 전체를 다시 렌더링 해야 하며, 이로 인해 클라이언트 상태를 유지할 수 없습니다.

탄생 배경

React 팀은 다방면으로 성능 향상을 해왔으나 Data Fetching의 경우, 사용자와 개발자 경험 모두를 만족시킬 수있는 방법을 찾기가 어려웠습니다. React 컴포넌트의 비동기적 Data Fetching의 가장 큰 문제점은 클라이언트와 서버 간 요청의 High Latency와 연속된 클라이언트-서버 API 요청으로 발생하는 Waterfall이었습니다.

image-1

이와 같이 클라이언트 컴포넌트에서 비동기 Data Fetching은 클라이언트-서버의 Waterfall을 야기하고, 성능을 저하시키는 원인이 됩니다.

이를 위한 해결책으로 서버 컴포넌트가 탄생하게 되었으며, RSC는 용어 그대로 서버에서 동작하는 React 컴포넌트입니다. 서버 컴포넌트를 사용하면 컴포넌트 렌더링을 클라이언트가 아닌 서버에서 수행할 수 있습니다.

서버에서 렌더링을 수행하기 때문에 API를 통한 데이터 요청의 Latency를 줄일 수 있고, 클라이언트에서의 연속된 API 호출을 제거하여, 클라이언트-서버의 Waterfall을 막을 수 있습니다.

image-2

아쉽게도 서버 컴포넌트에서의 데이터 요청을 처리하여도 중첩된 컴포넌트에서의 여러 API 요청에 따른 네트워크 Waterfall은 여전히 존재합니다.

하지만 서버 컴포넌트는 서버에서 동작하고 렌더링 된다는 특성상 다양한 종류의 백엔드 리소스에 접근할수 있고, HTML이 아닌 ‘특별한’ 형태로 렌더링 되어 클라이언트에 전달되기 때문에 클라이언트로 전달되는 번들 사이즈 또한 감소시킬 수 있습니다.

서버 컴포넌트란?

React가 UI를 구축하는 방식을 바꾼 것과 유사하게, 서버와 클라이언트를 활용하는 하이브리드 애플리케이션을 구축하기 위한 새로운 사고 모델을 도입했습니다.

SPA의 경우처럼 전체 애플리케이션을 클라이언트 측에서 렌더링하는 대신, React는 이제 컴포넌트의 목적에 따라 렌더링할 위치를 선택할 수 있는 유연성을 제공합니다.

image-3

페이지를 더 작은 컴포넌트로 분할하면 대부분의 컴포넌트가 비대화형이며 서버에서 서버 컴포넌트로 렌더링 할 수 있음을 알 수 있습니다. 더 작은 인터랙티브 UI의 경우, 클라이언트 컴포넌트로 나타낼 수 있습니다. 이는 Next.js의 서버 우선 접근 방식과 일치합니다.

서버 컴포넌트의 특징을 정리하면 다음과 같습니다.

  • 자유로운 서버 리소스 접근 : 데이터베이스, 파일 시스템 등등
  • 제로 번들 사이즈 컴포넌트 : 서버 컴포넌트는 브라우저에 다운로드 되지 않고, 서버에서 미리 렌더링 된 정적 컨텐츠로 전달됨
  • 자동 코드 분할 : 서버 컴포넌트에서 Import 되는 모든 클라이언트 컴포넌트를 코드 분할 포인트로 간주하기 때문에 Lazy 로드가 필요하지 않음

클라이언트와 서버 컴포넌트 구성하기

서버에서 React는 결과를 클라이언트로 보내기 전에 모든 서버 컴포넌트를 렌더링 합니다. 그리고 클라이언트에서 React는 클라이언트 컴포넌트를 렌더링하고 서버 컴포넌트의 렌더링 결과에 슬롯을 생성하여 서버와 클라이언트에서 수행한 작업을 병합합니다.

클라이언트 컴포넌트는 코드 상단에 ‘use client’ 디렉티브를 명시해야 합니다. 그리고 서버 컴포넌트를 import 할 수 없습니다.

대신 클라이언트 컴포넌트를 디자인 할 때, React 프로퍼티를 사용하여 서버 컴포넌트의 “holes”를 표시할 수 있습니다. 서버 컴포넌트는 서버에서 렌더링되고, 클라이언트 컴포넌트가 클라이언트에서 렌더링되면 서버 컴포넌트의 렌더링 된 결과로 “hole”이 채워집니다.

일반적인 패턴은 React의 children 프로퍼티를 사용하여 “hole”을 만드는 것입니다.

'use client';
 
import { useState } from 'react';
 
export default function ExampleClientComponent({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      {children}
    </>
  );
}

<ExampleClientComponent>는 자식 컴포넌트가 무엇인지 전혀 알지 못합니다. 사실, 이 컴포넌트의 관점에서는 자식들이 결국 서버 컴포넌트의 결과에 의해 채워질 것이라는 사실조차 알지 못합니다.

// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ExampleClientComponent from './example-client-component';
import ExampleServerComponent from './example-server-component';
 
// Pages in Next.js are Server Components by default
export default function Page() {
  return (
    <ExampleClientComponent>
      <ExampleServerComponent />
    </ExampleClientComponent>
  );
}

그리고 서버 컴포넌트는 클라이언트 컴포넌트를 Import 할 수 있지만 서버에서 클라이언트 컴포넌트로 전달되는 프로퍼티는 직렬화가 가능해야 합니다. 즉, 함수나 날짜 등과 같은 값은 클라이언트 컴포넌트에 직접 전달할 수 없습니다.

다음은 클라이언트와 서버 컴포넌트의 특징을 정리한 표입니다.

특징서버클라이언트
데이터 가져오기OX
백엔드 리소스에 직접 액세스 (직접)OX
서버에 민감한 정보 보관 (액세스 토큰, API 키 등)OX
서버에 대한 대규모 종속성 유지 / 클라이언트 측 자바스크립트 감소OX
인터랙티브 및 이벤트 리스너 추가 (onClick, onChange 등)XO
상태 및 라이프사이클 효과 사용 (useState, useReducer, useEffect 등)XO
브라우저 전용 API 사용XO
상태, 효과 또는 브라우저 전용 API에 의존하는 사용자 지정 후크 사용XO
React 클래스 컴포넌트 사용XO

데모 살펴보기

데모 화면은 Postgres DB에 저장된 사용자 목록을 가져와서 테이블 형태로 보여줍니다. 캐시 처리를 위해 Redis에 사용자 목록을 10초간 보관합니다.

// lib/service.ts

import kv from "@vercel/kv";
import { sql } from "@vercel/postgres";

export interface User {
  id: number;
  name: string;
  image: string;
  email: string;
  createdAt: Date;
}

export async function getUsers(cacheKey: string) {
  const cachedData = await kv.get<User[]>(`users-${cacheKey}`);
  if (cachedData === null) {
    const data = await sql<User>`SELECT * FROM users`;
    await kv.set(`users-${cacheKey}`, JSON.stringify(data.rows), { ex: 10, nx: true })
    return data.rows;
  } else {
    return cachedData;
  }
}
// app/api/route.ts

import { getUsers } from '@/lib/service';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
    return NextResponse.json({ users: await getUsers('client') });
}

아래는 클라이언트 컴포넌트로 구현된 테이블 컴포넌트입니다. 데이터를 가져오기 위해 /api/users를 호출하고, API 내부에는 앞에서 구현한 서비스 함수를 사용합니다.

// components/client-table.tsx

'use client'

import type { User } from '@/lib/service';
import { useState, useEffect } from 'react';
import ReloadButton from './reload-button'
import TableHead from './table-head';
import TableRow from '@/components/table-row';

export default function ClientTable() {
  const [users, setUsers] = useState<User[]>([]);
  const [duration, setDuration] = useState<number>(0);
  const [loading, setLoading] = useState<boolean>(false);

  const loadData = () => {
    let startTime = Date.now();
    setLoading(true);
    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => {
          setUsers(data.users)
          setDuration(Date.now() - startTime)
          setLoading(false)
      })
  }

  useEffect(() => {
    loadData();
  }, [])

  return (
    <div className="bg-white/30 p-12 shadow-xl ring-1 ring-gray-900/5 rounded-lg backdrop-blur-lg max-w-xl mx-auto w-full">
      <TableHead userCount={users.length} duration={duration}>
        <ReloadButton onClick={loadData} loading={loading} />
      </TableHead>
      <div className="divide-y divide-gray-900/5">
        {users.map((user, index) => (
          <TableRow key={index} user={user}></TableRow>
        ))}
      </div>
    </div>
  )
}

클라이언트 컴포넌트와 달리 API 호출을 위한 코드는 생략하고, 앞에서 구현한 서비스 함수를 바로 사용할 수 있습니다.

// components/server-table.tsx

import { getUsers } from '@/lib/service';
import RefreshButton from './refresh-button'
import TableHead from './table-head';
import TableRow from './table-row';

export default async function ServerTable() {
  const startTime = Date.now()
  const users = await getUsers('server');
  const duration = Date.now() - startTime

  return (
    <div className="bg-white/30 p-12 shadow-xl ring-1 ring-gray-900/5 rounded-lg backdrop-blur-lg max-w-xl mx-auto w-full">
      <TableHead userCount={users.length} duration={duration}>
        <RefreshButton />
      </TableHead>
      <div className="divide-y divide-gray-900/5">
        {users.map((user, index) => (
          <TableRow key={index} user={user}></TableRow>
        ))}
      </div>
    </div>
  )
}

데모 화면과 소스 코드는 아래 링크를 통해 직접 확인할 수 있습니다.

글을 마치며

이번 스터디 발표 준비를 하면서 RSC에 대해 알아보고 이해하는 과정을 겪으면서 느꼈던 점은 언젠가 APM 에이전트 라인업에 RSC가 포함될 수도 있을 것 같다는 생각이 들었습니다.

지금처럼 프론트엔드와 백엔드 개발 영역의 구분이 명확하지 않았던 시절의 웹 개발을 했던 모습도 떠오릅니다. 물론 그때와 지금은 엄청난 차이가 있고, 자바스크립트는 웹 페이지의 인터랙티브 한 기능을 제공하기 위한 보조적인 역할만 했던 시기이기도 합니다.

RSC가 추구하는 방향성을 이해하려고 하다보니 문득 이런 생각이 들었습니다. 어쩌면 다시 옛날처럼 프론트엔드와 백엔드를 구분하지 않고 개발하는 시대가 올 수도 있겠구나. 다만, 과거와 달리 앞으로는 메인은 화면 개발이고, 서브로 렌더링 성능 개선을 위한 서버 개발을 해야하는 상황이 올 수도 있겠다는 생각을 했습니다. (프론트엔드 개발자 기준에서…)

앞으로 웹 생태계에 어떤 변화가 다가올지 알 수는 없지만 한가지 분명한 것은 RSC가 큰 영향을 줄 수 있을 것 같습니다. 그래서 꾸준한 관심과 기대를 가져볼만하다고 생각합니다.

참고 링크

문서를 읽고 이해하는 것보다 직접 구현해보는게 나을 것 같아서 이번에도 데모 개발에 시간을 많이 할애했습니다. 본문 내용의 대부분은 아래 문서를 인용한 것이니 참고하시길 바랍니다.

· 약 6분
Alvin Hong

children을 포함하는 계층 데이터

D3에서 사용할 데이터를 정의할 때, 부모-자식 관계를 정하기 위해서 children을 반드시 포함시켜야 합니다. children에 자식 노드를 추가하면서 계층 데이터 형태로 구성할 수 있습니다.

interface TreeData {
    id: string;
    children?: TreeData[];
}

const TREE_DATA: TreeData = {
    id: 'Chaos',
    children: [
        {
            id: 'Gaia',
            children: [
                {
                    id: 'Mountains',
                },
                {
                    id: 'Pontus',
                },
                {
                    id: 'Uranus',
                },
            ],
        },
        {
            id: 'Eros',
        },
        {
            id: 'Erebus',
        },
        {
            id: 'Tartarus',
        },
    ],
};

기본적인 Hierarchy 객체 생성하기

hierarchy 함수를 호출하면 D3에서 정의한 HierarchyNode 타입의 객체가 리턴되며, 각각의 프로퍼티에 대한 설명은 다음과 같습니다.

  • children : 자식 노드들
  • data : 사용자가 정의한 데이터
  • depth : 트리에서 자신의 노드의 깊이
  • height : 자식 노드의 최대 깊이
  • parent : 부모 노드 없으면 null
const root = hierarchy<TreeData>(TREE_DATA);

console.log(root);
{
  children: [Node, Node, Node, Node],
  data: {id:  "Chaos", children: Array(4)}
  depth: 0,
  height: 2,
  parent: null
}

레이아웃 객체 생성하기

앞에서 생성한 객체를 가지고, D3에서 제공하는 레이아웃 객체를 생성할 수 있습니다. 아래는 트리를 그리기 위한 레이아웃 객체를 생성하는 코드이며, HierarchyNode 타입을 상속한 HierarchyPointNode 타입의 객체로 변경됩니다.

x, y 프로퍼티가 추가된 것을 확인할 수 있으며, 캔버스나 SVG로 무엇인가 그릴 때, 해당 값을 그대로 사용하면 됩니다.

HierarchyNode 타입을 상속하는 타입은 HierarchyPointNode 외에도 HierarchyRectangularNode, HierarchyCircularNode 타입이 있습니다.

const treeLayout = tree().size([WIDTH, HEIGHT]);
treeLayout(root);

console.log(root);
{
  children: [Node, Node, Node, Node],
  data: {id:  "Chaos", children: Array(4)}
  depth: 0,
  height: 2,
  parent: null,
  x: 233.33333333333334,
  y: 0
}

로우셋을 계층 데이터로 변환하기

때로는 children 프로퍼티가 포함되어있지 않고, 부모 키만 알고 있는 데이터를 가지고 있을 수 있습니다.

interface RowData1 {
    id: string;
    parentId?: string;
}
interface RowData2 {
    id2: string;
    pid?: string;
}

const ROW_DATA_1: RowData1[] = [
    { id: 'Chaos' },
    { id: 'Gaia', parentId: 'Chaos' },
    { id: 'Eros', parentId: 'Chaos' },
    { id: 'Erebus', parentId: 'Chaos' },
    { id: 'Tartarus', parentId: 'Chaos' },
    { id: 'Mountains', parentId: 'Gaia' },
    { id: 'Pontus', parentId: 'Gaia' },
    { id: 'Uranus', parentId: 'Gaia' },
];

const ROW_DATA_2: RowData2[] = [
    { id2: 'Chaos' },
    { id2: 'Gaia', pid: 'Chaos' },
    { id2: 'Eros', pid: 'Chaos' },
    { id2: 'Erebus', pid: 'Chaos' },
    { id2: 'Tartarus', pid: 'Chaos' },
    { id2: 'Mountains', pid: 'Gaia' },
    { id2: 'Pontus', pid: 'Gaia' },
    { id2: 'Uranus', pid: 'Gaia' },
];

D3에서는 children과 마찬가지로 id와 parentId 프로퍼티를 스스로 인식합니다. stratify 함수를 사용하면 hierarchy 함수와 동일한 객체를 리턴하게 됩니다.

하지만 데이터 id와 parentId 프로퍼티가 포함되어 있지 않다면 id와 parentId 메소드를 통해 부모-자식 관계를 설정할 수 있습니다.

const root1 = stratify<RowData1>()(ROW_DATA_1);
const root2 = stratify<RowData2>()
    .id((data) => data.id2)
    .parentId((data) => data.pid)(ROW_DATA_2);

console.log(root2);
{
  children: [Node, Node, Node, Node],
  data: {id:  "Chaos", children: Array(4)}
  depth: 0,
  height: 2,
  parent: null
}

Hierarchy 객체의 메소드 소개

계층 데이터를 쉽게 핸들링할 수 있는 몇가지 메소드들을 소개합니다.

const chaos = root2;
const gaia = chaos.children[0];
const pontus = chaos.children[0].children[1]
const tartarus = chaos.children[3];

ancestors

자신을 포함하여 루트까지 모든 부모 노드의 배열을 반환합니다.

gaia.ancestors();

ancestors-1

pontus.ancestors();

ancestors-2

descendants

자신을 포함하여 모든 자식 노드의 배열을 반환합니다.

gaia.descendants();

descendants

leaves

해당 노드의 자식 중에 자식이 없는 노드의 배열을 반환합니다

gaia.leaves();

leaves

path

해당 노드부터 대상 노드까지 연결된 모든 노드의 배열을 반환합니다.

Tartarus -> Chaos -> Gaia -> Pontus

tartarus.path(pontus);

path

해당 노드의 자식 링크를 나타내는 객체 배열을 반환합니다. 각 링크 객체의 target은 자식이고, source는 부모입니다. gaia의 자식 노드인 mountains, pontus, uranus를 target으로 가진 HierarchyPointLink 타입을 가지는 배열을 리턴합니다.

앞에서 트리 레이아웃을 사용했기 때문에 HierarchyLink가 아닌 HierarchyPointLink 타입의 객체를 리턴합니다. 그 밖에도 HierarchyCircularLink, HierarchyRectangularLink 타입이 있습니다.

gaia.links();

descendants

· 약 9분
Alvin Hong

캔버스 애니메이션의 문제점

웹 개발시 다이나믹한 그래픽 처리가 필요하면 캔버스를 사용합니다. 이제 캔버스는 HTML 엘리먼트 중에서 사용 통계가 40%가 넘을 정도로 아주 범용적입니다.

Example banner

캔버스는 애니메이션을 잘 처리하기 위한 목적으로 나온 태그이지만 현실은 다이나믹한 그래픽 처리에 어려움이 있습니다.

  • 자바스크립트 엔진과 렌더링 엔진 사이의 바인딩 오버헤드 발생
  • GPU 가속을 사용하더라도 Skia 내부의 오버헤드 발생
  • GPU 가속 쓰더라도 CPU를 써야하는 부분에서 페인팅 라이브러리 오버헤드 발생
  • 위 프로세스 처리 후 DOM 렌더링을 처리하기에도 벅찬 메인 스레드

Example banner

프레임 드랍이 발생하지 않게 하기 위해서는 메인 스레드에서 1프레임(16.7ms) 안에 DOM, Javascript, Painting을 처리하고, 남는 시간에 캔버스 렌더링을 시도해야 합니다. 하지만 메인 스레드는 위 그림처럼 이미 많은 일을 하고 있는 경우가 많습니다.

기존의 문제점 개선 방법

풀 스크린으로 캔버스 렌더링하기

UI에 제약이 생기지만 캔버스를 풀 스크린으로 렌더링하면 핸들링할 Target DOM 이 없기 때문에 메인 스레드 자원을 더 사용할 수 있으며, GPU 가속까지 붙이면 성능을 개선할 수 있습니다. 애니메이션을 전체 화면에서 처리할 경우는 고려해볼 수 있는 선택지입니다.

백그라운드 캔버스

프리젠테이션용, 백그라운드용 캔버스를 두개 만듭니다. 백그라운드에서 여러 오브젝트를 다 그린 다음에 프레젠테이션 캔버스에 백그라운드 캔버스에서 그린 오브젝트를 가져와 한번에 한번에 엎어치기 하는 방식입니다. (현재 제니퍼 대시보드 차트에서도 사용되고 있는 방식)

오프스크린 캔버스를 사용한 개선

이미 사용되고 있는 WebWorker API를 통해 새로운 스레드 생성하여 렌더링 처리를 시도할 수 있으며, 캔버스를 DOM에서 분리할 수 있습니다.

등장 배경

  • 자바스크립트 부하를 줄이기 위해 다른 스레드에서 실행할 수 있으면 좋겠음
  • 크진 않지만 스키아의 부하를 줄이기 위해 다른 스레드에서 실행하면 좋겠음
  • DOM 렌더링과 분리되면 좋겠음

제약 사항

WebWorker에서는 직접적으로 DOM 엑세스가 불가능합니다. 캔버스가 HTML(DOM)이기에 메인 스레드에서 분리가 불가능합니다.(HTML은 JS, CSS 등 다른 자원에서 DOM 트리 접근시 싱크가 맞아야 하는데, 다른 스레드에서 처리하게 되면 싱크 이슈가 발생함) React, Vue와 같이 Shadow DOM을 사용하게 되면 Atomic 하게 처리할 수 있습니다.

개발 방식

OffscreenCanvas 객체에서 만들어진 프레임을 동기적으로 화면에 보여주는 방법

  • 새 프레임이 컨텍스트에 렌더링이 되고나면, 가장 최근에 렌더링 된 이미지를 저장하는 transferToImageBitmap 메소드를 호출할 수 있습니다. 이 메소드는 다른 수 많은 Web API에서 사용되고 있는 ImageBitmap 객체를 리턴합니다.
  • <canvas> 엘리먼트를 여러개 생성하여 더블버퍼링 구현을 할 필요가 없으며, 프레젠테이션 캔버스에 비트맵 복사가 일어나지 않기 때문에 성능상 이점이 있습니다.
var one = document.getElementById("one").getContext("bitmaprenderer");
var two = document.getElementById("two").getContext("bitmaprenderer");

var offscreen = new OffscreenCanvas(256, 256);
var gl = offscreen.getContext('webgl');

// ... gl 컨텍스트를 이용해 첫 번째 캔버스에 무언가를 그립니다 ...

// 첫 번째 캔버스에 렌더링을 수행합니다.
var bitmapOne = offscreen.transferToImageBitmap();
one.transferFromImageBitmap(bitmapOne);

// ... gl 컨텍스트를 이용해 두 번째 캔버스에 무언가를 그립니다 ...

// 두 번째 캔버스에 렌더링을 수행합니다.
var bitmapTwo = offscreen.transferToImageBitmap();
two.transferFromImageBitmap(bitmapTwo);

OffscreenCanvas 객체에서 만들어진 프레임을 비동기적으로 화면에 보여주는 방법

  • worker 또는 메인 스레드 위에서 <canvas> 엘리먼트의 transferControlToOffscreen 메소드를 호출하여, OffscreenCanvas 객체를 얻고, 해당 컨텍스트는 worker에서 새 프레임을 렌더링 할 수 있습니다.
// main.js (메인 스레드 코드)
var htmlCanvas = document.getElementById("canvas");
var offscreen = htmlCanvas.transferControlToOffscreen();

var worker = new Worker("offscreencanvas.js");
worker.postMessage({canvas: offscreen}, [offscreen]);
// offscreencanvas.js (worker 코드)
onmessage = function(evt) {
  const canvas = evt.data.canvas;
  const gl = canvas.getContext("webgl");

  function render(time) {
    // ... gl 컨텍스트를 이용해 무언가를 그립니다 ...
    requestAnimationFrame(render);
  }
  requestAnimationFrame(render);
};

데모

데모 화면은 다음과 같이 구성되어 있습니다.

  • 일반 캔버스 샘플
  • 메인 스레드에서 동작하는 오프스크린 캔버스 샘플
  • worker에서 동작하는 오프스크린 캔버스 샘플

Example banner

결론

  • 기존의 캔버스 관련 코드는 고치지 않고, WebWorker로만 옮겨가서 그대로 사용할 수 있습니다. 다만, 이벤트 처리와 데이터 가져오는 방식에 대한 고민이 필요합니다.
  • 메인 스레드와 worker 간의 데이터 전송을 위한 postMessage 함수 사용은 지양해야 합니다.
  • postMessage 함수 사용시 정책상의 이유로 데이터 복사본이 생성되는데, 이때 오버헤드가 발생합니다. (WebWorker API를 사용하지 않는 가장 큰 이유) 그래서 데이터 가져오는 코드를 worker 안에서 처리하면 좋습니다.
  • 웹팩 같은 번들러의 도움으로 worker 안에서 미리 작성한 모듈 재사용이 가능합니다.
  • Zero-copy 백그라운드 렌더링을 오프스크린 캔버스로 구현할 수 있습니다.
  • 크롬 69 버전과 파이어폭스 105 버전(지난 달인 9월 20일 릴리즈)부터 지원됩니다. 오프스크린 캔버스의 사용률은 공개된 이후부터 지속적으로 증가해서 현재는 25%가 넘었습니다. 많은 프론트엔드 개발자들이 캔버스의 문제점을 인식하고, 개선하기 위한 방법으로 오프스크린 캔버스를 선택한 것이라고 생각합니다.

Example banner

  • Three.js에서 오프스크린 캔버스를 사용할 수 있습니다.
  • WebGL 및 WebXR을 오프스크린 캔버스로 구현할 수 있습니다.

참고

· 약 10분
Alvin Hong

개요

D3 스케일(=척도)은 시각화의 기본 작업을 위한 편리한 추상화 모델입니다. 추상 데이터의 차원을 시각적 표현으로 매핑합니다. 예를 들어 산점도 차트에서 x축과 y축에 대한 값을 픽셀 단위로 변경하는 것처럼 정량적 데이터를 좌표 값으로 변경하는 데 가장 많이 사용됩니다. 스케일은 색상, 선, 폭 또는 심볼 크기와 같은 거의 모든 시각적 인코딩을 나타낼 수 있습니다. 또한 정의된 범주형 데이터 또는 구분이 필요한 개별 데이터와 같은 거의 모든 유형의 데이터와 함께 사용할 수 있습니다.

scaleLinear (선형)

가장 보편적으로 사용하는 스케일이며, 다른 스케일과 마찬가지로 domain(정의역)과 range(치역)만 기억하면 됩니다. domain은 입력 값, range는 입력 값에 대응하는 출력 값입니다. invert는 반대로 출력 값을 입력 값으로 넣으면 원래의 입력 값을 반환하는 역함수입니다.

y = mx + b

const func = scaleLinear().domain([0, 10]).range([0, 80]);
func(2); // return 16;
func(5); // return 40;
func(6); // return 48
func(15); // return 120

clamp(true)

ticks(3)

0
5
10

invert

0

scalePower (지수)

결과 값을 보간하기 위해 제공되는 함수이며, 지수(exponent)에 따라 결과 값을 서서히 늘리거나 줄일 수 있습니다.

y = mx^k + b

거듭제곱을 하다보면 기하급수적으로 값이 커지기 때문에 D3 스케일에서는 domain 범위에 맞게 별도의 보정을 합니다. (Math.pow(입력값, exponent) / Math.pow(최대값, exponent - 1))

const func = scaleLinear().domain([0, 100]).range([10, 50]);
func(0); // return 10;
func(25); // return 20;
func(50); // return 30
func(75); // return 40
func(100); // return 50

const func2 = scalePow().exponent(0.5).domain([0, 100]).range([10, 50]);
const func3 = scaleSqrt().domain([0, 100]).range([10, 50]);
const func4 = scalePow().exponent(2).domain([0, 100]).range([10, 50]);

exponent(0.5)

sqrt

exponent(2)


linear colors

sqrt or exponent(0.5) colors

exponent(2) colors

scaleLog, scaleSymlog (로그)

최대값이 극단적으로 클 경우, 결과값의 분포를 비교적 완만하게 보정해주는 스케일입니다. Symlog 함수는 대칭 로그를 의미하며, 양수 및 음수 값을 허용합니다.

y = mlog(x) + b

const data = [ 10, 100, 10000, 1000000 ];

linear

log

scaleBand (밴드)

Linear 스케일과 비슷하게 동작하지만 domain 사이에 여백(padding)을 비율로 설정할 수 있으며, 일반적으로 바나 컬럼 차트를 구현할 때, 사용하는 목적으로 만들어졌습니다.

Example banner

const DOMAIN = [0, 1, 2, 3];
const RANGE = [0, 400];

const func1 = scaleBand<number>().domain(DOMAIN).range(RANGE);
const func2 = scaleBand<number>().domain(DOMAIN).range(RANGE)
.paddingInner(0.6);
const func3 = scaleBand<number>().domain(DOMAIN).range(RANGE)
.paddingOuter(0.2).align(0.5); // 0이면 맨 왼쪽으로 당김

no padding (step:100)

inner padding (step:117.64705882352942)

outer padding (step:90.9090909090909)

scalePoint (점)

Band 스케일과 달리 domain 개별로 여백이 적용되며, bandwidth 개념이 없고, (최대 range/domain 수)로 시작점을 정합니다.

Example banner

no padding (step:133.33333333333334)

padding (step:125)

scaleTime (시간)

Linear 스케일과 동일하지만 domain 범위를 Date 객체로 정할 수 있습니다. 앞에서 설명한 ticks 함수를 사용하여 X축을 구현하고, 마우스 오버시 구해지는 실제 좌표값을 invert 함수로 넘겨서 현재 시간을 구합니다.

const now = Date.now();
const start = new Date(now - 1000 * 60 * 60);
const end = new Date(now);

const func = scaleTime().domain([start, end]).range([0, 600]);
1:25:54 PM
12:30
12:35
12:40
12:45
12:50
12:55
13:00
13:05
13:10
13:15
13:20
13:25

scaleSequential (연속)

보간기(Interpolator) 함수로 정의된 연속된 값에 매핑합니다. 고유한 보간기 기능을 정의하거나 내장된 D3 보간기 기능을 사용할 수 있습니다. 일반적으로 숫자값의 연속 간격을 일련의 색상으로 매핑하는데 유용합니다.

scaleSequential 함수는 선형적으로 매핑하는데, 앞에서 설명한 다른 스케일 방식으로 매핑하는 함수도 제공됩니다. (scaleSequentialLog, scaleSequentialSymlog, scaleSequentialPow, scaleSequentialSqrt, scaleSequentialQuantile)

import { interpolateRainbow } from 'd3-scale-chromatic';

const func = scaleSequential() .domain([0, 100])
.interpolator(interpolateRainbow);

scaleDiverging (양방향)

양수와 음수, 위쪽과 아래쪽의 두가지 반대 방향으로 진행되는 현상을 시각화하는데 도움이 됩니다.

import { interpolateRdBu } from 'd3-scale-chromatic';

const func = scaleDiverging<string>() .domain([-50, 0, 50])
.interpolator(interpolateRdBu);

scaleRadial (방사형)

입력 값이 제곱된 출력 값에 선형적으로 대응하도록 범위가 내부적으로 제곱되는 Linear 스케일의 변형입니다.

const innerRadius = 80;
const outerRadius = 220;

const x = scaleBand() .domain(data.map((row) => row.name))
.range([0, 2 * Math.PI]);

const y = scaleRadial().domain([0, 1000])
.range([innerRadius, outerRadius]);

const z = scaleSequential() .domain([0, 2 * Math.PI])
.interpolator(interpolateRainbow);

scaleOrdinal (서수)

지정된 domain과 range를 갖는 순서를 나타내는 스케일을 생성하고 반환하는 데 사용됩니다.

const func = scaleOrdinal<number, string>() .domain([0, 1])
.range(['white', 'pink', 'red']);

func(0); // white
func(1); // pink
func(2); // red
func(3); // white

scaleThreshold (임계점)

결과 값을 구분하는 절단 값(=domain)을 직접 지정할 수 있습니다.

const linear = scaleLinear<string>() .domain([0, 100000])
.range(['white', 'red']);

// 10000보다 작으면 white, 100000보다 크면 red, 나머지는 pink로 구분
const threshold = scaleThreshold<number, string>() .domain([10000, 100000])
.range(['white', 'pink', 'red']);

linear

threshold

scaleQuantile (분위수)

domain을 동일한 절대 빈도의 간격으로 분할합니다. 분할 수는 range에 정의된 값들의 수와 같습니다.

// 100개의 데이터를 균등하게 나눠서 값의 크기에 맞게 분류함

const sampleData: number[] = [ 11002, 29017, 21699, 47058, 24001, 6000, ... ];
const quantile = scaleQuantile<string>() .domain(sampleData)
.range(['white', 'pink', 'red']);

linear

quantile

scaleQuantize (양자화)

domain 범위에서 임계값 선택을 처리하고, range에 정의된 값들의 수만큼 규칙적인 간격으로 세분화합니다.

// 0~100192 white, 100193~200384 pink, 200385~300577 red

const quantize = scaleQuantize<string>() .domain([0, 300577])
.range(['white', 'pink', 'red']);

linear

quantize

결론

제니퍼5 안에는 한정된 스케일 종류로 구현된 차트와 컴포넌트 등이 존재합니다. 다만 그때그때 상황에 맞게 구현했으며, 어떤 스케일이다라고 정의하지 않았을 뿐입니다. 지난 모니터링 스터디 준비를 하면서 몇가지 모니터링 제품을 살펴보았는데, 분위수나 로그 스케일 등을 사용하는 것을 목격한 적이 있습니다. 그동안 데이터를 표현하는데, 데이터를 왜곡하면 안된다는 이유로 여러가지 시도를 해보지 못한 것에 대해 아쉬움이 있었습니다. 이번에 D3 스케일을 살펴보고, 이해하는 과정에서 데이터 시각화에 대한 본질적인 목적, 이유에 대해 다시 한번 생각해보는 계기가 되었습니다.

참고

대부분의 데이터는 본질적으로 계층 구조를 가지고 있습니다. D3는 이런 계층적 구조를 시각화하기 위한 다양한 기능을 제공합니다. 해당 모듈이 제공하는 계층형 데이터 다이어그램의 종류는 3가지입니다. (노드-링크형, 인접형, 포함형)

노드-링크 형은 제니퍼5의 콜-트리와 같은 Indented Tree나 Tiny Tree, Dendrogram이 있다. 아래는 인접형과 포함형 다이어그램입니다.

Example banner

· 약 22분
Alvin Hong

최근에 SVN에서 Git으로 형상 관리 시스템을 변경하면서 GitLab을 접하게 되었는데, 필자는 GitLab의 명성(?)만 들어봤지 직접 사용해본 것은 이번이 처음이었다. GitHub에서 오픈소스 프로젝트를 수년간 진행하고 있어서 GitLab이랑 얼마나 차이가 있겠어라는 생각을 했던 것이 사실이다. 하지만 GitLab CI 기능을 접하게 되면서 새로운 세계에 눈을 뜨게 되었다.

GitLab vs GitHub

진행 중인 프로젝트의 특성에 따라 선택하면 된다. 필자처럼 범용적인 프레임워크나 라이브러리를 개발한다면 아무거나 사용해도 상관없지만 아무래도 개발자 유입이 많고, 필자처럼 늘어나는 별을 보며 즐거움을 얻고자 한다면 GitHub를 추천한다. 다만 프로덕트나 서비스 개발을 해야한다면 GitLab을 선택하는 것이 좋다. 프라이빗 프로젝트를 기본적으로 생성할 수 있고, 몇년전부터 이슈가 되고 있는 DevOps를 실현하기 위해서 GitLab CI 기능을 사용하여 테스트 및 빌드, 배포를 자동화할 수 있다.

GitHub 프로젝트는 Travis CI를 통해 테스트 및 빌드, 배포를 자동화할 수 있다.

개발환경 설정

본문에서는 “Webpack+SpringBoot 기반의 프론트엔드 개발환경 구축하기”에서 다루지 않는 몇가지 추가 설정에 대한 내용만 설명할 것이기 때문에 해당 글을 먼저 읽는 것을 권장한다.

Babel 7

먼저 babel 6버전을 7버전으로 업그레이드 했기 때문에 웹팩 설정 파일에 있는 babel-loader의 preset 설정을 babel.config.js로 이전해야 한다. 테스트를 위해 Jest 프레임워크를 사용하거나 polyfill을 사용할 때도 필요한 설정이니 기억해두자.

npm i -D @babel/core @babel/preset-env [email protected]

module.exports = {
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "entry"
      }
    ]
  ],
  "env": {
    "test": {
      "presets": [
        [
          "@babel/preset-env",
          {
            "targets": {
              "node": true
            }
          }
        ]
      ]
    }
  }
}

Polyfill

서버에서 데이터를 비동기로 가져오기 위해 Promise 대신 async/await 키워드를 사용했기 때문에 polyfill을 로드해야 한다. 설정 방법은 여러가지가 있는데, 필자는 먼저 babel.config.js에서 useBuiltIns 값을 entry로 설정하고, 엔트리 js 파일에서 import ‘@babel/polifill’ 구문을 추가하는 방법을 택했다.

npm i @babel/polyfill

Jest

Jest 프레임워크의 테스트는 노드 환경에서 동작하기 때문에 babel.config.js 파일에서 targets.node 값을 true로 설정해야 한다. 뷰 컴포넌트를 테스트하기 위해 다음 모듈을 추가로 설치하자.

npm i -D babel-jest jest jest-serializer-vue vue-jest vue-template-compiler vue-test-utils sinon

다음은 Jest 설정 파일이다. 여기서 중요한 부분은 transformIgnorePatterns 값을 빈 배열로 설정해야 한다. 기본값이 node_modules라서 NPM에 배포된 모듈을 사용한다면 테스트 실행시 에러가 발생한다.

module.exports = {
    "setupFiles": [ "./src/test/client/setup.js" ],
    "verbose": true,
    "moduleFileExtensions": [
        "js",
        "json",
        "vue"
    ],
    "moduleNameMapper": {
        "^@/(.*)$": "<rootDir>/src/main/client/$1"
    },
    "transform": {
        "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
        ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
    },
    "snapshotSerializers": [
        "<rootDir>/node_modules/jest-serializer-vue"
    ],
    "transformIgnorePatterns": []
}

Jest 프레임워크는 브라우저가 아닌 노드 환경에서 실행되기 때문에 사용할 수 없는 객체에 대한 처리가 필요하다. 화면을 구성하기 위해 차트 모듈을 사용하였고, 차트 모듈 내부적으로 캔버스 객체를 생성하기 때문에 아래와 같이 처리하였다. 참고로 sinon은 테스트 스텁을 위한 프레임워크인데, 본문에서 관련 내용은 다루지 않을 것이다.

import sinon from 'sinon';

const createElement = global.document.createElement;
const FAKECanvasElement = {
    getContext: jest.fn(() => {
        return {
            fillStyle: null,
            fillRect: jest.fn(),
            drawImage: jest.fn(),
            getImageData: jest.fn(),
        };
    }),
};

sinon.stub(global.document, 'createElement')
    .callsFake(createElement)
    .withArgs('canvas')
    .returns(FAKECanvasElement);

Vue.js

사실 Vue.js 관련 설정은 특별할 것이 없어서 본문에서 다루지 않을것이다. 다만 Jest 프레임워크와 vue-test-utils를 사용하여 뷰 컴포넌트 단위의 테스트 방법에 대해 간략하게 알아보자.

import { shallow } from 'vue-test-utils'
import DetailMarketComp from '@/detailMarket'
import { data } from '../samples.js'

describe('detailMarket.vue', () => {
    let cmp ;

    beforeEach(() => {
        cmp = shallow(DetailMarketComp, {
            propsData: {
                title: 'Min Market Cap',
                data: data[2]
            }
        });
    });

    it('snapshot', () => {
        cmp.vm.$nextTick(() => {
            expect(cmp.vm.$el).toMatchSnapshot();
        });
    });

    it('computed', () => {
        expect(cmp.vm.maxYear).toEqual('2017');
        expect(cmp.vm.minYear).toEqual('2015');
    });
});

detailMarket.spec.js는 기업의 시가총액이 최대인 연도와 최소인 연도를 테이블로 보여주는 뷰 컴포넌트에 대한 테스트 코드이다. 샘플 데이터에 대한 computed properties를 검증하고, 실제 마크업 코드로 출력될 텍스트를 스냅샷 한다.

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`detailMarket.vue snapshot 1`] = `
<div>
  <h5>
    Min Market Cap
  </h5>

  <table>
    <thead>
      <tr>
        <th>
          Company
        </th>

        <th>
          Max Year
        </th>

        <th>
          Min Year
        </th>
      </tr>
    </thead>

    <tbody>
      <tr>
        <td>
          Facebook
        </td>

        <td>
          2017
        </td>

        <td>
          2015
        </td>
      </tr>
    </tbody>
  </table>
</div>
`;

필자의 경험상 간단한 마크업 구조의 뷰 컴포넌트나 SVG 기반의 뷰 컴포넌트는 이미지 스냅샷 테스트를 하지 않는 것이 정신 건강에 좋다고 생각한다. 특히 SVG는 마크업과 달리 엘리먼트 속성에 대부분의 스타일이 들어가기 때문에 텍스트 스냅샷만으로도 충분히 검증할 수 있다.

프로젝트 스펙

본문을 잘 이해시키기 위해 샘플 프로젝트를 GitLab에 생성해두었으며, 프로젝트 스펙은 다음과 같다.

기능 스펙

서버는 글로벌 기업에 대한 마켓 데이터를 JSON 형태로 전달해주며, 클라이언트는 서버로부터 전달받은 데이터를 각각의 뷰 컴포넌트에 맞게 가공하여 화면을 구현한다. 좌측은 기업별 시가총액에 대한 비율을 차트로 보여주며, 우측은 최대/최소 기업에 대한 정보를 테이블로 보여준다.

Example banner

기술 스펙

  1. 모듈 번들러 : Webpack 4
  2. 개발환경 서버 : Webpack Dev Server
  3. JavaScript 컴파일러 : Babel 7
  4. JavaScript 프레임워크 : Vue.js 2
  5. Java 프레임워크 : Spring Boot 1.5
  6. 서버 사이드 템플릿 : Thymeleaf
  7. CSS 컴파일러 : Sass
  8. JavaScript 테스트 도구 : Jest, Sinon
  9. Java 테스트 도구 : JUnit
  10. 프로젝트 빌드 도구 : Maven 3

CI 프로세스 요약

스프링부트는 간단하게 하나의 JAR 파일로 배포할 수 있다. 물론 프로젝트 규모에 따라 번들 파일이나 이미지 등은 JAR 파일에서 분리하여 별도의 CDN 서버에 배포할 수도 있다. 참고로 클라이언트 번들 파일들과 최종 프로젝트 아웃풋 파일은 버전 관리 대상에 포함되지 않는다. 해당 파일들은 CI 환경에서만 생성해야하며 최종 아웃풋 파일은 다른 레파지토리에 전달하는 형태로 진행해야 한다.

  1. 클라이언트 테스트
  2. 클라이언트 빌드
  3. 서버 테스트
  4. 서버 빌드
  5. JAR 파일 배포
  6. Git 태그 생성

CI 프로세스 상세

먼저 GitLab에서 프로젝트를 생성하면 Set up CI/CD 버튼을 클릭하여 .gitlab-ci.yml 파일을 쉽게 생성할 수 있다. 본문에서는 샘플 프로젝트에 작성되어 있는 .gitlab-ci.yml의 내용을 앞에서 요약한 CI 프로세스의 단계 별로 자세히 알아볼 것이다. 참고로 각각의 단계를 잡(Job) 단위로 구성할 수 있는데, 실행 순서를 임의로 설정할 수 있다. 이를 스테이지(Stage)라고 말하며 모든 스테이지가 순차적으로 실행되는 일련의 과정을 파이프라인(Pipeline)이라 한다.

GitLab에서 개인 프로젝트는 그룹당 파이프라인 수행시간이 월 2000분(약 33시간)으로 제한되어 있다. 물론 돈으로 해결할 수 있다.

공통 설정 (variables)

.gitlab-ci.yml에서 사용할 수 있는 변수들을 정의할 수 있다. 참고로 $ACCESS_TOKEN는 ‘GitLab 프로젝트 > Settings > CI / CD > Variables 설정’에서 추가한 변수인데, (6) Git 태그 생성에서 자세히 다룰 것이다.

공통 설정 (cache)

스테이지가 진행될 때, 생성되는 파일들은 다음 스테이지에서 사용할 수 없기 때문에 이미 설치된 모듈들을 다시 설치해야 하거나 생성된 파일들을 다음 스테이지에서 사용해야 하는 경우에 필요한 설정이다. 참고로 key에 설정된 $CI_COMMIT_REF_NAME은 GitLab CI 환경에서 미리 정의해둔 변수인데, 파이프라인이 동작할 때 대상이 되는 브랜치 이름이다.

공통 설정 (stages)

각각의 잡(Job)들의 실행 순서를 정할 수 있다.

공통 설정 (only)

파이프라인의 동작 조건을 설정할 수 있다. 특정 디렉토리나 파일이 변경될 때 동작되도록 설정하기 위해서는 changes 설정을 사용하면 되고, refs 설정을 통해 동작 조건의 기본 정책을 정할 수 있다. 필자는 머지 요청(MR)이 발생할 때만 동작하도록 설정했다.

.Job_Only

사용자가 임의로 만들 수 있는 일종의 클래스라고 볼 수 있으며, 잡(Job)들은 상속받을 수 있다. 중복 설정을 막을 수 있는 효과가 있기 때문에 설정 파일을 간결하게 작성할 수 있다.

variables:
  MAVEN_CLI_OPTS: "--batch-mode"
  MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
  CI_REPOSITORY_URL: https://alvin.h:[email protected]/$CI_PROJECT_PATH.git

cache:
  key: "$CI_COMMIT_REF_NAME"
  paths:
    - src/main/resources/static/
    - dist/
    - node_modules/
    - .m2/repository/

stages:
  - fetest
  - febuild
  - betest
  - bebuild
  - deploy
  - patch

.Job_Only:
  only:
    changes:
      - "src/**/*"
    refs:
      - merge_requests
...

(1) 클라이언트 테스트

npm install 명령어를 통해 테스트에 필요한 모듈들을 설치하고, Jest 테스트를 실행한다. 참고로 실행 결과 텍스트를 정규식으로 파싱해서 커버리지 값으로 사용할 수 있다.

...
FE_Test:
  extends: .Job_Only
  image: node:10
  stage: fetest
  before_script:
    - npm install
  script:
    - npm test
  coverage: /All files\s*\|\s*([\d\.]+)/
...

커버리지 텍스트 중에서 All files 부분을 파싱하며, ‘GitLab 프로젝트 > Settings > CI / CD 설정’에서 Pipeline status와 Coverage report를 마크다운이나 HTML 코드로 가져올 수 있다.

Example banner

(2) 클라이언트 빌드

fetest 스테이지에서 node_modules 디렉토리를 캐시해두었기 때문에 before_script 명령어는 조금 빨리 실행될 것이다. 웹팩 빌드를 하고 생성되는 번들 파일들은 공통 설정에서 캐시 처리를 해두었기 때문에 BE_Build 스테이지에서 메이븐 빌드시 최종 아웃풋 파일에 포함된다.

...
FE_Build:
  extends: .Job_Only
  image: node:10
  stage: febuild
  before_script:
    - npm install
  script:
    - npm run dist
    - ls -all ./src/main/resources/static
...

(3) 서버 테스트

메이븐 테스트를 실행하며, 테스트에 필요한 모듈들은 알아서 설치된다.

...
BE_Test:
  extends: .Job_Only
  image: maven:3.3.9
  stage: betest
  script:
    - mvn test $MAVEN_OPTS
...

(4) 서버 빌드

betest 스테이지에서 .m2/repository 디렉토리를 캐시해두었기 때문에 메이븐 빌드가 조금 빨리 실행될 것이다. 참고로 클라이언트는 테스트와 빌드를 분리했기 때문에 서버도 분리하기 위해 -DskipTests=true를 통해 메이븐 빌드만 실행되게 설정했다.

...
BE_Build:
  extends: .Job_Only
  image: maven:3.3.9
  stage: bebuild
  script:
    - mvn install -DskipTests=true $MAVEN_OPTS $MAVEN_CLI_OPTS
...

(5) JAR 파일 배포 bebuild 스테이지에서 dist 디렉토리를 캐시해두었기 때문에 최종 아웃풋 파일을 가져와서 별도의 서버에 배포할 수 있다. 배포 방법은 자신의 환경에 맞게 정하면 되는데, 필자는 sshpass+scp 명령어를 사용하여 지인에게 대여(?) 받은 서버에 전송하는 형태로 구현하였다.

처음에는 expect를 사용하여 scp 명령어를 수행하였는데, GitLab CI 환경에서는 잘 동작하지 않는다.

deploy 스테이지에서는 $POM_VERSION과 $SSH_PASSWORD 변수를 사용하는데, 먼저 메이븐 명령어를 통해 프로젝트의 pom.xml에 명시된 버전을 가져와서 $POM_VERSION 변수에 값을 할당한다. 프로젝트의 버전이 변경되면 배포 서버에 전송할 파일의 이름도 변경되는 문제를 해결할 수 있다. 그리고 SSH 계정의 비밀번호를 .gitlab-ci.yml 파일에 공개할 수 없기 때문에 ‘GitLab 프로젝트 > Settings > CI / CD > Variables 설정'에 $SSH_PASSWORD 변수를 추가하였다.

...
Deploy:
  extends: .Job_Only
  image: maven:3.3.9
  stage: deploy
  before_script:
    - POM_VERSION=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec)
    - apt-get update -qq && apt-get install -y -qq sshpass
  script:
    - ls -all ./dist
    - sshpass -V
    - export SSHPASS=$SSH_PASSWORD
    - sshpass -e scp -r -oStrictHostKeyChecking=no -P51022 ./dist/vuejs-springboot-starter-$POM_VERSION.jar [email protected]:/opt/public
...

(6) Git 태그 생성

필자는 배포 서버에 최종 아웃풋 파일이 전송되면 프로젝트의 pom.xml에 명시된 버전을 Git 태그 이름으로 샘플 프로젝트에 생성하고 싶었다. 공통 설정에서 variables에 정의한 $CI_REPOSITORY_URL 변수를 풀어쓰면 다음과 같다.

https://아이디:패스워드@gitlab.com/alvin.h/vuejs-springboot-starter.git

샘플 프로젝트에서 필자의 아이디는 alvin.h이며, 패스워드는 우측 상단의 ‘프로필 메뉴 > Settings > Access Tokens > Personal Access Tokens 설정'에서 발급한 토큰을 ‘GitLab 프로젝트 > Settings > CI / CD > Variables 설정’에 $ACCESS_TOKEN 변수 값으로 추가한 것이다.

...
Patch:
  image: maven:3.3.9
  only:
    changes:
      - "pom.xml"
    refs:
      - merge_requests
  stage: patch
  before_script:
    - POM_VERSION=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec)
    - echo $POM_VERSION
    - git config --global user.name "alvin.h"
    - git config --global user.email "[email protected]"
  script:
    - git checkout $CI_COMMIT_REF_NAME
    - git remote remove origin
    - git remote add origin $CI_REPOSITORY_URL
    - git tag -a $POM_VERSION -m "Version created by gitlab-ci Build"
    - git push origin --tags

파이프라인 동작 화면

스테이지 진행 상태와 현재 실행 중인 잡(Job)의 콘솔 화면을 볼 수 있다.

Example banner

아래는 배포 서버에 전송된 최종 아웃풋 파일 목록이다. 그리고 파이프라인 동작 실패시 필자가 사용하고 있는 슬랙 채널에 메시지가 전송되게 설정했다. 참고로 ‘GitLab 프로젝트 > Settings > Integrations 설정'에서는 슬랙 뿐만이 아니라 다른 서비스와도 연동할 수 있는 기능을 제공한다.

Example banner

글을 마치며…

처음에는 프론트엔드 개발환경을 최신화하기 위해 알아본 내용들을 정리하기 위해 글을 썼다. 그러다보니 실제 업무에 적용해보고 싶어서 레거시 시스템에 적용하는 방법에 대해 연구하기 시작했다. 운이 좋게도 작년 연말에 기회가 생겨서 일을 진행하였고, 현재는 어느 정도 마무리가 된 상태이다.

게다가 형상 관리 시스템을 SVN에서 Git으로 변경하면서 사내에 GitLab 서버를 구축해서 사용하기 시작했다. 사실 프론트엔드 개발환경을 최신화하면서 번들 파일을 버전 관리하기가 애매했었는데, GitLab CI 기능을 통해 해결되면서 필자가 그동안 진행했던 모든 일들이 하나의 연결고리로 이어졌다.

사실 처음부터 GitLab CI에 관심을 가졌던 것은 아니었다. 최근에 참여하고 있는 스터디에서 교재로 사용하고 있는 테스트 주도 개발로 배우는 객체 지향 설계와 실천이라는 책에 이런 구절이 나온다.

우선 동작하는 골격을 대상으로 테스트하라

‘동작하는 골격'이란 전 구간을 대상으로 자동 빌드, 배포, 테스트를 할 수 있는 실제 기능을 가장 얇게 구현한 조각을 말한다. 여기엔 첫 기능을 구현할 수 있을 정도의 자동화, 주요 컴포넌트, 통신 매커니즘이 포함될 것이다. 골격에 포함된 애플리케이션 기능을 단순하게 유지하면 골격은 신경 쓰지 않고도 기반 구조에만 마음껏 집중할 수 있다.

필자가 공개한 샘플 프로젝트는 책에서 말한 ‘동작하는 골격'과 동일하다고 보면 된다. 다만 요즘 시대에 맞게 트렌디함을 더했을 뿐이다. 이 글을 읽는 사람들에게 단순한 레퍼런스로써 도움이 되길 바랄 뿐이다.

참고 링크

아래 링크들은 본문에서 자세히 다루지 않는 특정 기술들에 대한 개발가이드와 샘플 프로젝트의 산출물이다.

프로젝트 링크

https://gitlab.com/alvin.h/vuejs-springboot-starter

· 약 29분
Alvin Hong

필자의 올해 목표 중 하나는 오랜기간 조금씩 진행해왔던 제니퍼 뷰서버 플랫폼화를 마무리하는 것이었다. 기획했던 플랫폼 요소는 여러가지가 있지만 그 중에서도 제니퍼 화면을 독립적인 개발환경에서 구현할 수 있게 하는 기능이 제일 중요했다. 그래서 생각해낸 것이 서버 환경은 스프링부트로 심플하게 구성하고, 모던한 프론트엔드 개발을 위해 모듈 번들러로 웹팩을 선택했다. 관련해서는 필자가 쓴 “Webpack+SpringBoot 기반의 프론트엔드 개발 환경 구축하기”를 참고하자.

새로 갖춰진 개발환경에서 기존의 제니퍼 뷰서버 플러그인 중 일부를 테스트 삼아 마이그레이션 해보니까 나름 신선하더라. 기존의 JSP 템플릿에 마크업과 공존하고 있던 자바스크립트 코드는 ES6 스펙에 맞춰서 수정하고, 모듈 별로 분리를 하고나니 필자가 사용하고 있는 IntelliJ IDEA의 정적 코드 분석이나 메소드 힌트 같은 기능들을 제대로 활용할 수 있게 되었다. 요즘 같은 세상에서는 너무 당연한 것이지만 수년간 쌓아온 레거시란 벽은 이미 넘을 수 없을만큼 높아진 상태였다.

필자는 지난 8월에 “레거시(Legacy) 시스템에 웹팩 개발환경 적용하기”를 쓰면서 언젠가는 실무에 꼭 적용해보겠다는 다짐을 했었다. 그리고 얼마지나지 않아 여유 일정이 생겼고, 더 이상 미룰 수 없는 일이기에 바로 시작했다.

(1) 마이그레이션 대상 선택하기

제니퍼 화면은 크게 메인 대시보드와 사용자정의 대시보드, 리얼타임, 분석, 통계, 관리, 보고서 템플릿, 사용자 메뉴로 나눌 수 있다. 참고로 사용자정의 대시보드와 보고서 템플릿은 화면 단위가 아니라 컴포넌트 단위의 기능으로 복잡하게 얽혀있어서 마이그레이션 대상에서 제외했다. 다음은 제니퍼에서 제공하는 타입 별 화면 개수이다.

메인 대시보드 6종, 리얼타임 8종, 분석 21종, 통계 6종, 관리 44종

제니퍼는 한달에 최소 두번 이상의 마이너 버전이 릴리즈 되는 온-프레미스(On-premise) 제품이다보니 문제가 되는 버전이 고객사에 설치되면 되돌리기가 쉽지 않다. 서비스형 제품처럼 피드백이 즉각적으로 나타나진 않지만 수없이 많은 과거 버전들 속에서 다양한 문제에 직면하게 된다. 필자는 지난 수년간 수많은 고객사에 설치되어 어느 정도 안정성이 확보된 85종의 화면들을 마이그레이션 해야한다.

(2) 목표 설정하기

모든 일의 시작은 목표를 잘 정하는 것이다. 너무 당연한 말이지만 일의 규모가 크거나 앞에서 말한 것처럼 이미 검증된 일을 뒤엎고 새로운 것을 적용하는 일은 진행하는 사람이나 직책자 또는 구성원들에게 큰 부담을 안겨준다. 그래서 필자는 다음과 같은 목표를 정하고, 일의 당위성을 확보하기 위한 논리를 정리했다.

  1. 유닛 테스트와 스냅샷 테스트가 가능해야 함
  2. 툴에서 디버깅이 가능한 코드를 개발할 수 있어야 함
  3. 화면 단위로 프레임워크나 라이브러리를 자유롭게 사용할 수 있어야 함
  4. 마이그레이션 된 화면과 기존의 화면이 모두 잘 동작해야 함

4번에 대해 조금 더 설명을 하자면 한번에 모든 화면을 마이그레이션 할 수 있다면 정말 좋겠지만 현실적으로 불가능한 일이기 때문에 새로 갖춰진 개발환경에서는 기존의 화면과 마이그레이션 된 화면이 모두 잘 동작해야 한다.

(3) 기술 스펙 정하기

목표 설정이 끝났으니 이제는 새로 갖춰질 개발환경의 기술 스펙을 정해야 한다. 본문에서는 각각의 기술 스펙에 대한 설명은 생략하겠으며, 서론에서도 언급한 필자가 작성한 글을 참고하면 도움이 될 것이다.

  1. 모듈 번들러 : webpack 4
  2. 개발환경 서버 : webpack-dev-server
  3. JavaScript 컴파일러 : babel 6
  4. JavaScript 프레임워크 : vuejs 2
  5. CSS 컴파일러 : sass
  6. 테스트 도구 : jest
  7. 기타 : eslint, prettier

(4) 레이어 기반의 화면 정리하기

목표와 기술 스펙이 정해졌으니 현재 시점에서 바로 본론으로 넘어가야 하는데, 뜬금없이 레이어 기반의 화면에 대한 설명을 보게 되서 당혹스럽겠지만 제니퍼는 언제 어디서든 관리 화면을 띄울 수 있도록 레이어 기반으로 구현되었다.

즉, 마이그레이션 대상 화면의 절반이 레이어 기반인 것이다. 필자의 계획은 화면 단위(URL 별)로 엔트리를 설정하고, 공통 모듈을 제외한 아웃풋 파일들을 화면 별 디렉토리 안에 생성해두려고 했었다. 하지만 관리 화면들이 레이어 기반이기 때문에 시작부터 큰 난관에 부딪치게 되었다.

Example banner

고민 끝에 필자는 모든 관리 화면을 iFrame 기반으로 변경하기로 했다. 물론 리소스 중복 로드에 따른 로딩 속도 문제나 컨텐츠에 따라 iFrame 크기를 유동적으로 변경해야 하는 등의 몇가지 문제점이 있었지만 어렵지 않게 해결할 수 있었다. 간단하게 정리하자면 다음과 같다.

  1. 관리 화면 특성상 다른 종류의 화면에 비해 공통 모듈이나 라이브러리를 적게 사용하기 때문에 마이그레이션이 완료되면 로딩 속도 문제가 어느 정도 개선될 것이다.
  2. “iFrame Resizer”라는 완성도가 높은 라이브러리를 사용했기 때문에 컨텐츠에 따른 iFrame 크기 조절을 자연스럽게 처리 할 수 있었다.

참고로 페이지 기반으로 화면이 변경되면서 고정 크기의 윈도우 컴포넌트에서 벗어나 별도의 팝업이나 URL로 접근할 수 있게 되어 사용성이 많이 개선되었다.

(5) 레이아웃 구조 살펴보기

제니퍼 화면은 JSTL 커스텀 태그로 공통 레이아웃을 화면 별로 구성하는데, 화면 타입 별로 조금씩 다르게 처리되어 있다. 문제는 마크업 뿐만이 아니라 템플릿, 자바스크립트, 스타일까지 함께 포함되어 있기 때문에 우선 자바스크립트를 분리하면서 ES6 모듈로 마이그레이션을 진행해야 한다.

일단 기존의 화면 타입 별 레이아웃에 대한 설명을 하자면 다음과 같다.

  1. 화면_타입_header.jsp : default_css.jsp와 default_js.jsp를 로드함
  2. 화면_타입_body_start.jsp : toolbar.jsp를 로드함
  3. 화면_타입_body.end.jsp : 화면 타입 별로 공통으로 사용되는 마크업과 스크립트가 들어가고, footer.jsp를 로드함
  4. common_ui.jsp : 제니퍼에서 사용되는 모든 컴포넌트들에 대한 템플릿과 스크립트가 포함되어 있음

다음은 화면 타입 별 레이아웃 내부에서 로드하는 공통 레이아웃에 대한 설명이다.

  1. default_css.jsp : 제니퍼 화면 구성에 필요한 css 파일과 JUI 라이브러리의 css 파일들을 로드함
  2. default_js.jsp : 제니퍼 캔버스 차트와 유틸리티 js 파일과 jQuery나 JUI 같은 라이브러리의 js 파일들을 로드함
  3. toolbar.jsp : 제니퍼 화면 상단에 보이는 툴바 영역에 대한 마크업과 스크립트가 포함되어 있음

예를 들어 EVENT 분석 화면은 /analysis/event으로 접근할 수 있는데, 해당 JSP 템플릿 파일은 /WEB-INF/jsp/analysis/event.jsp에 위치한다.

Example banner

(6) 레이아웃 구조 분리하기

일단 default_css.jsp는 화면 별 스타일과 ES6로 마이그레이션 된 컴포넌트 스타일만 분리했기 때문에 생각보다 간단하게 끝났다. 하지만 문제는 default_js.jsp와 toolbar.jsp, analysis_body_end.jsp, common_ui.jsp였다.

기존의 화면들은 잘 동작해야하므로 그대로 두고, 마이그레이션 대상 화면에 대해서만 레이아웃을 다르게 구성하기로 했다. 필자는 모듈 번들러로 웹팩을 선택했기 때문에 development 모드와 production 모드에 따라 output.path를 다르게 생성했다.

  1. development 모드 : $프로젝트_디렉토리/.webpack/bundles
  2. production 모드 : $프로젝트_디렉토리/src/main/webapp/bundles

JSP 템플릿에서는 pageConext 내장 객체를 사용할 수 있는데, 요청 헤더 정보 중에 request.getServletPath() 메소드를 사용하여, 화면 타입과 화면 이름을 분류했다. 제니퍼 뷰서버는 다음과 같이 단순한 URL 구조를 가진다.

URL : http://127.0.0.1:8080/analysis/event
(화면 타입은 analysis, 화면 이름은 event)

만약에 development 모드이고, EVENT 분석 화면이라면 $프로젝트_디렉토리/.webpack/bundles/analysis/event에 디렉토리가 생성된다. 사용자가 특정 화면에 접근했을 때, 앞에서 말한 pageContext 내장 객체를 사용하여 화면 타입과 화면 이름을 분류하고, output.path에 해당 디렉토리가 존재하는지 확인한다. 만약에 디렉토리가 존재한다면 마이그레이션 대상 화면이라고 간주하고, 다음과 같은 레이아웃 구조로 변경한다.

Example banner

기존에는 모든 화면에서 default_js.jsp에 정의된 js 파일들을 로드했었다. jquery나 moment, lodash 같은 유명한 라이브러리도 포함되어 있고, 제니퍼 캔버스 차트나 유틸리티, JUI 등 내부에서 사용되는 모듈들도 포함된다. 필자는 마이그레이션 대상 화면에서 의존성이 너무 높은 jquery를 제외하고, 번들 파일들만 로드할 수 있도록 제니퍼 뷰서버를 대대적으로 수정하였다.

(7) 웹팩 기본 설정하기

본문에서는 웹팩 설정 방법에 대해서 자세히 다루지는 않고, 중요하다고 생각하는 부분만 짚고 넘어가려고 한다. 먼저 모드에 따라 output.path를 다르게 설정해주고, 마이그레이션 대상 화면은 계속 늘어날 것이기 때문에 entry를 멀티로 설정해야 한다.

const path = require('path');

module.exports = (env) => {
  const clientPath = path.resolve(__dirname, 'src/main/client');
  const outputPath = path.resolve(__dirname, (env == 'production') ?
                                  'src/main/webapp' : '.webpack');
  const publicPath = '/bundles';

  return {
        mode: env,
        entry: {
          'analysis/event/app': `${clientPath}/analysis/event/index.js`,
          'realtime/event/app': `${clientPath}/realtime/event/index.js`,
          ...
        },
        output: {
            path: outputPath + publicPath,
            publicPath: publicPath,
            filename: '[name].js'
        },
    ...
  }
}

먼저 clientPath에는 JSP 템플릿 파일에서 분리한 자바스크립트 코드를 ES6 모듈로 마이그레이션 한 index.js 파일들이 위치한다. 화면 별 index.js 파일들은 entry가 되는데, 여기서 entry 키에 주목하자.

output.filename이 [name].js로 설정되어 있는데, [name]은 entry 키로 치환되어 output.path 디렉토리에 생성된다. 만약에 analysis/event/index.js 파일을 production 모드에서 빌드를 하면 번들링 된 파일 경로는 다음과 같다.

$프로젝트_디렉토리/src/main/webapp/bundles/analysis/event/app.js

참고로 output.publicPath를 /bundles로 설정했기 때문에 웹에서는 다음과 같이 접근할 수 있다.

http://127.0.0.1:8080/bundles/analysis/event/app.js

이제 entry 모듈에서 화면 별로 필요한 라이브러리만 import해서 사용할 수 있게 되었다. 물론 jquery는 여전히 webpack_default_js.jsp에서 로드되고 있기 때문에 다음 설정을 통해서 번들 파일에 포함되지 않게 해야한다.

...
return {
  ...
  externals: {
      jquery: 'jQuery'
  },
  ...
}

(8) 웹팩 개발서버 설정하기

필자는 development 모드일 때, webpack-dev-server를 사용하기로 결정했는데, 이유는 HMR(Hot Module Replacement)를 적용해보고 싶었기 때문이다. 하지만 몇가지 문제로 인해 현재는 Live-Reload만 적용한 상태이다.

webpack-dev-server는 번들 파일을 메모리 상에서 제공하기 때문에 output.path로 설정한 .webpack 디렉토리가 필요없다. 하지만 특정 화면으로 접근했을 때, 제니퍼 뷰서버가 마이그레이션 대상 화면인지 판단하기 위해서는 실제 파일이 필요했고, JSP 템플릿에서 로드해야 하는 파일들은 화면 별로 조금씩 다르기 때문에 구분이 필요했다. 그래서 마음에 들진 않지만 webpack 명령어를 함께 사용했다.

webpack --watch --env=development & webpack-dev-server --env=development

실은 webpack-dev-server가 컴파일 할 때, 번들 파일 경로를 얻어오는 방법을 열심히 알아봤으나 아직까지도 답을 찾지 못했다. 하지만 Express는 webpack-dev-server를 미들웨어로 추가하면, 번들 파일 경로를 가지고 올 수 있어서 참 아쉬웠다. (제니퍼 뷰서버는 자바 스프링을 사용함 ㅜㅜ)

...
return {
  ...
  devServer: {
      host: '127.0.0.1',
      port: 8081,
      progress: true,
      inline: true,
      hot: false,
      proxy: [{
          context: [ '**', '!/ws/**' ],
          target: 'http://127.0.0.1:8080'
      }, {
          context: [ '/ws/**' ],
          target: 'ws://127.0.0.1:8080',
          ws: true
      }]
  },
  ...
}

webpack-dev-server 포트로 제니퍼 화면에 접근했을 때, 웹소켓으로 데이터를 가져오는 대시보드가 제대로 동작하지 않았다. 그래서 조금 헤맸었는데, http와 ws 프록시 컨텍스트만 겹치지 않게 설정하면 해결되는 문제였다.

(9) 공통 모듈 청크하기

앞에서 entry를 멀티로 설정하여 화면 별로 번들 파일을 생성하는 방법에 대해 알아봤다. 하지만 네비게이션 바나 사용자 메뉴, 알림 등 모든 화면에서 공통으로 사용되는 기능이나 유틸리티 모듈들은 어떻게 번들링 될까? 웹팩 기반으로 개발하는 사람은 누구나 알고 있는 splitChunks 옵션을 사용하면 되는데, 일단 다음 설정을 보자.

module.exports = (env) => {
  const clientPath = path.resolve(__dirname, 'src/main/client');
  ...
  return {
    ...
    optimization: {
      splitChunks: {
          cacheGroups: {
              common: {
                  test: clientPath + '/common',
                  chunks: 'all',
                  name: 'base/common'
              },
              modules: {
                  test: clientPath + '/modules',
                  chunks: 'all',
                  name: 'base/modules'
              }
          }
      },
      ...
    }
  }
}

여기서 중요한 부분은 cacheGroups 모듈의 name 설정 부분인데, 앞에서 설명한 output.filename의 [name]과 치환되어 output.path 디렉토리에 번들 파일이 생성되는 것이다. production 모드일 때, 번들링 된 파일 경로는 다음과 같다.

$프로젝트_디렉토리/src/main/webapp/bundles/base/common.js
$프로젝트_디렉토리/src/main/webapp/bundles/base/modules.js

(10) 이미지 파일 관리하기

보통은 스프라이트 이미지를 사용하는데, 부득이하게 특정 스타일에 단일 이미지를 사용해야 하는 경우가 종종 발생한다. 그래서 필자는 웹팩으로 번들링 할 때, development 모드에서는 url-loader를 사용하고, production 모드에서는 file-loader를 사용했다.

이미지가 base64로 인코딩되서 CSS 파일에 포함되기 때문에 개발할 때는 매우 편리하지만 용량이 많이 커지는 문제가 있다. 그래서 개발이 완료되면 file-loader를 사용하기로 했다. 다만 여기서 주의할 점은 이미지 파일들은 화면 별 디렉토리에 포함되어 있지만 배포 할때는 다른 화면의 이미지 파일과 함께 동일한 디렉토리에 복사된다.

...
return {
  ...
  module: {
    rules: [].concat([ env == 'production' ?
        {
            test: /\.(jpe?g|png|gif|svg)$/i,
            use: [{
                loader: 'file-loader',
                options: {
                    name: '[hash].[ext]',
                    outputPath: '/images',
                    publicPath: '/bundles/images'
                }
            }]
        } : {
            test: /\.(jpe?g|png|gif|svg)$/i,
            use: [{
                loader: 'url-loader',
                options: {
                    limit: 1024 * 1024
                }
            }]
        }
    ])
  },
  ...
}

이미지가 복사되는 디렉토리는 앞에서 설명한 output.path에 options.outputPath가 더해진 경로이므로 다소 헷갈릴 수도 있기 때문에 주의해서 설정해야 한다.

$프로젝트_디렉토리/src/main/webapp/bundles/images

모든 화면의 이미지 파일이 동일한 디렉토리에 복사되기 때문에 파일명이 겹치지 않도록 options.name을 [name] 대신 [hash]로 변경하였다. 그리고 options.publicPath를 /bundles/images로 설정했기 때문에 웹에서는 다음과 같이 접근할 수 있다.

http://127.0.0.1:8080/bundles/images/[hash].jpg

(11) JavaScript 프레임워크 선택하기

Vue.js를 선택한 이유는 여러가지가 있는데, 일단 필자는 템플릿 방식을 선호한다. 2.x 버전부터 render 함수를 지원하지만 이건 개인 취향이니 그냥 넘어가고, 가장 큰 이유는 기존의 레거시 화면과 공존해야 하는 특수한 경우라서 경량 프레임워크를 선택하는 것이 옳은 판단이라고 생각했다.

JSP 템플릿에 포함된 자바스크립트 코드는 ES6 모듈로 마이그레이션 했지만 여전히 마크업 코드는 남아있었다. 하지만 싱글 파일 컴포넌트에 거의 수정하지 않고 옮길 수 있었기 때문에 마이그레이션 속도가 많이 향상되었다. 참고로 제니퍼 화면은 서버에서 넘겨주는 필수 데이터들이 많아서 JSP 같은 서버 템플릿을 완전히 제거할 수는 없었다. 그래서 최소한의 마크업만 남겨두고, 최대한 뷰 컴포넌트 단위로 화면을 분리해서 개발했다.

어차피 테스트가 가능한 코드를 만드는 것이 최종 목표이기 때문에 뷰 컴포넌트 단위로 테스트를 진행하기로 결정했다. 그리고 필자는 Vue.js 하위 프로젝트인 “vue-test-utils”를 아주 잘 사용하고 있다.

(12) 뷰 컴포넌트로 마이그레이션 하기

제니퍼 화면에서는 자체 개발한 수많은 컴포넌트들을 사용한다. 크게 대시보드와 리얼타임 화면에서 사용되는 캔버스 차트가 있고, 분석이나 통계, 보고서 템플릿 화면에서 사용되는 SVG 차트가 있다. 그리고 모든 화면에서 그리드, 달력, 콤보박스 등등 수많은 컴포넌트들을 두루 사용하고 있다. JUI 라이브러리는 그 중에서 일부를 공개한 것이다.

막상 Vue.js로 화면 개발을 하다보니 ES6 모듈로 마이그레이션 된 기존의 컴포넌트들을 사용하기가 어려웠다. 아무래도 제니퍼 화면은 컴포넌트 비중이 높기 때문에 Vue.js가 제공하는 기능들을 제대로 활용하지 못했다. 그래서 먼저 JUI 라이브러리를 뷰 컴포넌트로 마이그레이션 하기로 결정했다. 불행(?) 중 다행으로 차트는 몇달 전부터 시작해서 어느 정도 마무리가 된 상태였다.

JUI 라이브러리가 가지는 기존의 색은 모두 버리고, 최대한 Vue.js 특성에 맞게 마이그레이션 하려고 신경썼다. 쉽지 않은 일이었지만 결국 차트(23종), 그리드(2종), UI(13종)의 뷰 컴포넌트를 제공하게 되었고, GitHub에 프로젝트를 공개했다. 현재는 제니퍼 화면에 의존성이 높은 전용 컴포넌트들을 마이그레이션 하고 있다.

Example banner

(13) Jest 설정시 주의사항

테스트 프레임워크는 요즘 많이 사용하고 있는 Jest를 선택했는데, 기본 설정을 하는 과정에서 몇일동안 삽질한 부분만 짧막하게 짚고 넘어가려고 한다. 만약에 테스트 대상 모듈에서 NPM으로 설치한 모듈을 사용하고 있다면 SyntaxError: Unexpected identifier 에러가 발생한다.

필자가 공식 매뉴얼만 제대로 읽었다면 쉽게 해결할 수 있는 간단한 문제였다. 그것은 바로 transformIgnorePatterns 옵션의 기본값이 [“/node_modules/”]로 설정되어 있기 때문이다. 간단하게 빈 배열로 변경하거나 각자의 프로젝트에 맞는 패턴을 설정하면 된다.

(14) 번들 파일 배포하기

일반적인 서비스라면 CDN에 번들 파일들을 업로드하는 형태의 배포 방법을 선택할 수 있지만 제니퍼 뷰서버는 정적 리소스를 WAR 파일에 묶어서 배포한다. 참고로 제니퍼는 모든 프로젝트를 메이븐으로 관리하기 때문에 빌드시 NPM 명령어를 실행할 수 있는 frontend-maven-plugin을 사용하기로 결정했다.

다음은 package.json에 명시된 모듈들을 먼저 설치하고, 웹팩을 production 모드로 빌드하게 하는 설정이다.

<plugin>
    <groupId>com.github.eirslett</groupId>
    <artifactId>frontend-maven-plugin</artifactId>
    <version>1.6</version>
    <executions>
        <execution>
            <id>install node and npm</id>
            <goals>
                <goal>install-node-and-npm</goal>
            </goals>
            <phase>generate-resources</phase>
        </execution>
        <execution>
            <id>npm install</id>
            <goals>
                <goal>npm</goal>
            </goals>
            <configuration>
                <arguments>install</arguments>
            </configuration>
        </execution>
        <execution>
            <id>npm build</id>
            <goals>
                <goal>npm</goal>
            </goals>
            <configuration>
                <arguments>run dist</arguments>
            </configuration>
        </execution>
    </executions>
    <configuration>
        <nodeVersion>v10.10.0</nodeVersion>
        <npmVersion>6.4.1</npmVersion>
    </configuration>
</plugin>

글을 마치며…

결과는 만족스러웠지만 조금만 더 빨리 시작했으면 좋았을텐데, 후회도 아쉬움도 많이 남는 일이었다. 일을 진행하면서 문제가 생겨 몇번의 핫픽스 버전을 릴리즈 했었다. 그만큼 레거시 시스템을 엎는다는건 조심스럽고 예민한 일이다. 하지만 앞으로 몇년을 생각하면 언젠가는 해야만 하는 일이다. 늦으면 늦을수록 위험부담이 커지기 때문에 기회가 오면 바로 시작해야 한다.

· 약 12분
Alvin Hong

“Webpack+SpringBoot 기반의 프론트엔드 개발 환경 구축하기” 편에서 스프링부트 프로젝트를 생성하고, 서버와 클라이언트 코드를 통합 빌드/배포할 수 있는 개발 환경을 구축해봤다. 모던한 개발 환경에서 코드를 구현한다는 것은 프로그래머에게 축복이나 다름없다. 바벨(Babel)의 등장으로 브라우저가 지원하지 않더라도 최신 자바스크립트 스펙을 적용한다는 것이 얼마나 행복한 일인지는 클라이언트 개발을 해본 사람이라면 누구나 공감할 것이다.

하지만 현실은 우리의 앞을 가로막고 있는 레거시(Legacy)라는 커다란 벽이다. 개인 프로젝트가 아니라면 기존의 시스템을 새롭게 바꾼다는 것이 얼마나 어려운 일인지 다 알고 있으리라 생각된다. 그렇다면 왜 어려울까? 필자가 생각하는 가장 큰 이유는 공감을 얻기가 어렵기 때문이다. 이미 안정적으로 잘 돌아가고 있는 시스템을 괜히 건들여서 장애가 생긴다면 앞으로의 개발 일정에 차질이 생길 수 있는데, 과연 누가 이해해줄 수 있을까?

앞에서 말한 문제만으로도 글 한편은 쓸 수 있을 것 같은데, 할 말은 많지만 일단 이 정도로 하겠다. 지난 글에 이어 이번에도 서론이 길었다. 그리고 지난 글과 달리 이번에는 확실한 솔루션이 아니라는 점을 참고하길 바란다. 무구한 역사와 전통이 담긴 레거시(?)는 쉽게 바꿀 수 있는 것이 아니다. 단지 필자는 조금이나마 도움이 될 수 있는 기법 몇가지를 본문에서 다룰 것이다.

(1) 번들링 파일에서 모듈 제외하기

기존의 웹 페이지에 내가 만든 새로운 기능이 번들링되서 배포되었다고 가정해보자. 해당 웹 페이지는 공통 기능 구현을 위한 라이브러리(jquery, moment)가 이미 로드되어 사용 중이었다. 하지만 내가 만든 새로운 기능에도 개발의 편의를 위해 jquery를 사용했다. 이런 상황이면 글로벌 모듈인 jquery가 로드되고, 새로운 기능의 번들링 파일에도 포함되어 있기 때문에 웹페이지는 jquery를 두번 로드하는게 된다.

웹팩의 externals 옵션을 통해 개발 코드가 번들링 될 때, 특정 모듈을 제외시킬 수 있는데, 위와 같은 상황에서는 jquery를 제외하면 문제가 해결된다. 단지 번들링 되는 파일에서 제외하는 것이기 때문에 기존의 모듈 기반의 자바스크립트 코딩을 그대로 할 수 있게 된다.

그럼, 먼저 이전 강좌에서 개발했던 프로젝트의 src/main/resources/templates/index.html에 CDN으로 배포된 jQuery 라이브러리를 로드하는 코드를 넣어보자.

<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.js"></script>

그리고 템플릿에서 jQuery 라이브러리를 로드하기 때문에 번들링 할 필요가 없기 때문에 웹팩 설정 파일에서 entry.vendors 옵션의 jquery 값을 제거하자. 마지막으로 번들링에서 제외하기 위해 externals 옵션에 key는 모듈명, value는 글로벌 변수 이름 형태로 추가할 수 있다. 글로벌 변수 이름은 문자열 뿐만이 아니라 정규식, 콜백 함수, 객체 형태로도 설정할 수 있으니 자세한 사항은 공식 문서를참고하자.

...
        entry: {
            vendors: [ 'moment' ],
            index: clientPath + '/index.js'
        },
        externals: {
            jquery: "jQuery"
        },
        ...

다음은 externals 옵션을 적용하고, 번들링 했을 때의 내부 구조이다. 이전 강좌에 봤던 트리맵에 jquery.js 노드가 빠진 것을 확인할 수 있다.

Example banner

프로젝트 다운로드

이전 강좌에서 제공한 프로젝트에서 extenals 브랜치로 변경하여 체크아웃 받으면 된다.

(2) 레거시 코드를 모듈로 로드하기

레거시 시스템이 커지다보면 어쩔 수 없이 사용해야만 하는 자바스크립트 코드들이 존재한다. 필자의 경우, 다국어 처리를 위한 메시지를 가져오는 공통 함수를 클라이언트 개발시 사용한다.

해당 자바스크립트 파일은 서버에서 특정 주기로 동기화하는 조금은 복잡한 과정을 거쳐 생성된다. 결국 레거시 코드를 모듈로 로드하여 사용해야만 하는데, externals-loader를 사용하면 이러한 문제를 해결할 수 있다.

npm install — save-dev exports-loader

다음 코드를 살펴보자. 오랜만에 ES5 코드를 보니 반갑기도 하다.

var file = 'blah.txt';

var helpers = {
    test: function() {
        console.log('test something');
    },
    parse: function() {
        console.log('parse something');
    }
};

var math = {
    add: function(a, b) {
        return a + b;
    },
    subtract: function(a, b) {
        return a - b;
    }
};

다음 설정을 통해 lib/utils.js 파일을 모듈로 임포트해서 사용해보자.

...
            , {
                test: path.resolve(__dirname, 'lib', 'utils.js'),
                use: 'exports-loader?file,math,parse=helpers.parse'
            }]
        },
        ...
    }
}

콤마(,)로 구분해서 글로벌 변수 이름을 넣어주면 되고, 만약에 객체라면 특정 프로퍼티나 메소드만 설정할 수 있다. 다음은 exports-loader 설정을 통해 생성된 모듈을 임포트하여 사용하는 코드이다.

import Styles from './index.css'
import $ from 'jquery'
import mt from 'moment'
import {file, math, parse} from '../../../lib/utils.js'

$(function() {
    $("strong > div").html(mt().format('MMMM Do YYYY, h:mm:ss a'));

    console.log(file)
    parse();

    alert(math.add(6, 4));
});

프로젝트 다운로드

이전 강좌에서 제공한 프로젝트에서 externals-loader 브랜치로 변경하여 체크아웃 받으면 된다.

(3) 글로벌 변수로 모듈 사용하기

웹팩은 모듈을 임포트(import) 하지 않아도, 글로벌 종속성을 가지는 객체(또는 변수)를 제공할 수 있는 기능을 제공한다. 웹팩에서 기본적으로 배포하는 ProvidePlugin을 사용하면 하는데, 동작 원리는 매우 심플하다. 코드 상단에 플러그인에 추가된 key 이름을 가지는 변수에 value 이름을 가지는 모듈을 require 해주기 때문에 jQuery를 임포트 하지 않아도 사용할 수 있다.

const $ = require('jquery');
// 첫번째 라인은 ProvidePlugin에서 추가함
// 실제로는 존재하지 않음

$(function() {
  alert('onload');
}

다음 코드는 순수 레거시 코드를 복사하여 붙여넣기 한 것이다. 참고로 mt는 moment 라이브러리인데, 레거시 코드에서 글로벌 변수 이름 중첩으로 인해 mt로 변경해서 사용하고 있다고 가정해보자. 그리고 math는 필자가 미리 구현해둔 커스텀 모듈이다.

$(function() {
    $("strong > div").html(mt().format('MMMM Do YYYY, h:mm:ss a'));

    alert(math.add(6, 4));
});
export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

ProvidePlugin은 파일시스템 기반으로 동작하기 때문에 사용자가 만든 모듈의 경로만 넣어주면 얼마든지 글로벌 변수를 추가할 수 있다. 그리고 imports-loader를 사용하면 좀 더 세밀한 기능을 사용할 수 있다.

plugins: [
            ...,
            new webpack.ProvidePlugin({
                $: 'jquery',
                mt: 'moment',
                math: path.resolve(__dirname, 'lib', 'math.js')
            })
        ]
    }
}

npm install — save-dev imports-loader

다음은 config.size 값을 imports-loader를 통해 설정하여, 기존의 레거시 코드에서 사용하는 코드이다.

$(function() {
    $("strong > div").html(mt().format('MMMM Do YYYY, h:mm:ss a'));

    alert(math.add(6, 4) + ', ' + config.size);
});

imports-loader는 다양한 표현식을 사용할 수 있는데, 자세한 사항은 공식 문서를 참고하자.

...
            , {
                test: clientPath + '/index.js',
                use: 'imports-loader?config=>{size:50}'
            }]
        },
        ...
    }
}

다음은 ProvidePlugin과 imports-loader를 사용하고, 번들링 했을 때의 내부 구조이다. 작아서 잘 보이지 않지만 필자가 구현해둔 math.js도 포함되어 있다.

Example banner

프로젝트 다운로드

이전 강좌에서 제공한 프로젝트에서 shimming 브랜치로 변경하여 체크아웃 받으면 된다.

글을 마치며…

그동안 프론트엔드 개발 환경은 많은 변화가 있었다. 하지만 하나의 제품을 수년간 개발하고 있는 필자는 변화의 중심에 서지 못하고, 멀찌감치 떨어져서 지켜보기만 했었다. 이제와서 고백하자면 이 글은 필자가 앞으로 해야할 일에 대한 이정표다.

책 몇권 읽고, 샘플도 몇개 만들어보고, 단지 조금 안다고해서 실제 업무에 적용할 수 있는 것은 아니다. 그래서 필자는 생각을 정리하고, 누구에게나 공감을 이끌어낼 수 있는 논리를 완성하기 위해 다시 글을 썼다.

· 약 14분
Alvin Hong

글을 시작하며…

작년에 머신러닝에 대한 검색을 하다가 우연히 “수학을 포기한 직업 프로그래머가 기계학습을 시작하기 위한 최단경로”라는 번역 글을 보고, 처음으로 선형대수에 대해 관심을 가지고 공부를 시작했다. 해당 글에서 프로그래머를 위한 수학책으로 “코딩 더 매트릭스”를 추천했는데, 실은 그 책 때문에 연재 글을 기획하게 되었다. 필자는 수포자의 길을 30년 넘게 걸어온 사람으로서 “코딩 더 매트릭스”는 굉장히 이해하기가 어려운 책이었다. 그래서 필자는 큰 흐름은 선형대수를 시각적으로 이해하는 형태로 글을 연재할 예정이며, 세부 내용은 “코딩 더 매트릭스”에서 많이 차용할 생각이다.

코딩 더 매트릭스

필자는 일주일에 한번 “코딩 더 매트릭스” 스터디 모임을 갖는다. 벌써 스터디가 시작된지 1년이 넘었지만 이제 겨우 70% 정도 진행한 것 같다. 원래 공부를 잘하는 사람이 아니지만 살면서 이렇게 난해한 책은 처음봤다. 물론 번역서라서 원서의 의도가 제대로 전달되지 않은 것일 수도 있지만 저자가 무엇을 말하고 싶은지 어떤 의미를 말하는지 필자의 수준으로는 해석하기 어려운 부분이 너무 많았다. 또한 “코딩 더 매트릭스” 본문에서 제공되는 각종 예제들이 제대로 동작하지 않을뿐더러 관련된 자료를 찾기가 매우 어려웠다. 그래서 자료를 찾기 위해 열심히 검색해봤는데, 우리나라 뿐만이 아니라 외국에서도 “코딩 더 매트릭스”를 완독한 사람이 많지 않은 것으로 파악되어 1년이 넘도록 완독하지 못한 자괴감에서 조금이나마 벗어날 수 있었다.

진행 방식

본문에서는 주제와 관련된 수학 공식이나 개념에 대해 자세히 다루지 않을 것이다. 그래서 글을 읽기 전에 최소한 주제에 해당하는 수학적 지식은 갖추고 있어야 한다. 필자는 수포자가 되기 전에는 왜 수학을 공부해야 하는지 몰랐었고, 개발자로 일하면서 중요성은 알았지만 수학의 필요성에 대해 제대로 이해하지 못했었다. 그래서 필자는 본문에서 다룰 주제에 대해 어떻게 응용될 수 있는지 필요성에 대한 근본적인 질문을 해볼 것이며, 최대한 시각적으로 표현하거나 실제로 응용해볼 수 있는 방법으로 답을 찾아볼 것이다. 그리고 본문에서 소개되는 예제에서 기하학적 요소는 자바스크립트(jui)로 구현할 것이며, 행렬이나 복소수 연산을 위해 파이썬(numpy)을 사용할 것이다.

선형연립방정식

2개 이상의 미지수를 포함하는 2개 이상의 방정식의 쌍이 주어지고, 미지수가 주어진 모든 방정식을 동시에 만족할 것이 요구되어 있을때, 이 방정식의 쌍을 연립방정식이라고 한다. 아래 인용구는 이상구 교수님의 선형대수 강의에서 발췌한 내용이다. (링크)

역사적으로 선형대수학의 가장 중요한 주제 중 하나가 선형연립방정식과 해법이다. 21세기 우리는 수천 개의 미지수를 갖는 연립방정식을 자연과학, 공학, 경제학, 교통의 흐름, 일기예보, 의사결정 등 수많은 분야에서 만난다. 컴퓨터의 계산능력(computing power)이 향상됨에 따라 응용학문에서 선형대수학의 중요성은 날로 커져 왔다. 컴퓨터는 병렬처리와 대규모 계산을 매개로 선형대수학과 긴밀하게 연결된다. 이로 인해 현재의 과학자, 공학자들은 이전에는 상상만 하던 매우 복잡한 문제를 다를 수 있게 되었다. 또 선형대수학의 지식은 어떤 과목보다 더 넓은 과학, 경영분야의 활용도를 지닌다.

예를 들어 직선의 방정식으로 그린 두 개의 직선이 있을 때, 두 직선의 교점을 선형연립방정식의 유일한 해(solution)라고 한다. 만약에 두 직선이 평행하다면 교점이 존재하지 않으므로 해를 갖지 않는게 되며, 두 직선이 겹친다면 무수히 많은 해를 가지게 된다. 즉, 선형연립방정식의 해집합은 앞에서 말한 세가지 가능성 중에 하나만 성립한다. 그럼, 먼저 간단하게 두 직선의 방정식의 해를 구해보도록 하자.

Example banner

아래 예제에서 빨간색 직선은 방정식 (1)이며, 파란색 직선은 방정식 (2)를 그린 것이다. 두 직선이 교차하는 부분에 마우스를 올리면 나타나는 좌표가 바로 교점(3,2)이다. 해당 좌표 값을 방정식 (1), (2)에 각각 대입해보면 방정식이 성립한다는 것을 확인할 수 있다.

Example banner

선형연립방정식 응용

앞에서 직선의 방정식이 다수가 존재하고 유일한 해가 존재할 때, 교점을 구하는 방법에 대해 알아보았다. 지금부터는 반대로 지나는 점들을 가지고 원의 방정식과 방데르몽드 다항식을 구하여 차트로 그려볼 것이다. 원의 방정식의 일반형은 아래와 같다.

Example banner

지나는 점의 좌표값 (-2,2), (4,-6), (5, -5)를 알고 있다면 원의 방정식의 일반형에 각각 x와 y값을 대입한다.

Example banner

다소 복잡해졌지만 연립방정식을 풀면 각각 a=-2, b=4, c=-20가 구해지는데, 이 값들을 다시 원의 방정식에 대입한다.

Example banner

완성된 원의 방정식에 x값을 차트에서 지정된 모든 좌표를 대입하여 y값을 구하는 방식으로 그렸는데, 성능에 문제가 많고 최적의 방법은 아니라서 권장하지는 않는다. 관련해서는 각자 기호에 맞게 구현해보는 것으로 하자.

Example banner

이제 어느 정도는 선형연립방정식을 어떻게 활용해야 하는지에 대한 감이 올 것 같은데, 추가로 다수의 점을 지나는 곡선을 그릴 수 있는 라그랑주(lagrange) 다항식을 구하여 차트로 그려보면 좀 더 확신이 생길것 같다.

Example banner

일단 네 점의 좌표값 (1,3), (2,-2), (3,-5), (4,0)를 라그랑주 다항식에 각각 x와 y 값을 대입한다.

Example banner

원의 방정식과 동일하게 연립방정식을 풀면 각각 a0=3, a1=3, a2=-5, a3=1이 구해는데, 이 값들을 다시 라그랑주 다항식에 대입한다.

Example banner

완성된 라그랑주 다항식에 원의 방정식과 동일한 방법으로 x값에 지정된 모든 좌표를 대입하여 y값을 구하는 방식으로 그리면 된다.

Example banner

결론

웹에서 그래픽을 표현하기 위해 SVG나 캔버스에서 제공되는 API를 사용하면 매우 쉽게 선이나 도형 같은 그래픽 요소들을 그릴 수 있다. 예를 들어 선을 그리기 위해서 두 점의 좌표만 입력하면 쉽게 그릴 수 있는데, 내부적으로는 두 점의 관계에 대한 직선의 방정식을 구하고, 최대한 자연스러운 형태로 그려질 수 있도록 별도의 알고리즘이 적용되어 있을 것이다. 이는 직선 뿐만이 아니라 원이나 타원 등의 다양한 방정식에 적용될 수 있다. 필자는 방정식의 원형을 일종의 템플릿으로 규정하고, 지나는 점들의 좌표를 템플릿에 적용되는 입력 값이라고 생각한다. 값이 입력이 되고 생성된 템플릿의 집합을 연립방정식으로 다시 규정하고, 구해진 유일한 해(solution)를 방정식의 계수에 대입하여 최종의 방정식을 구한다. 조금 복잡한 과정이라고 생각할 수 있지만 입력하는 부분과 해를 구하는 부분은 공통적인 사항이고, 그려지는 모양에 따라 방정식만 다르게 적용하면 되기 때문에 일종의 템플릿이라고 규정한 것이다.

글을 마치며…

선형대수를 공부하는 것 자체만으로도 필자에게는 매우 큰 도전이었다. 그리고 이에 대한 글을 쓴다는 것은 상상조차 할 수 없는 일이었다. 그래서 누군가에게 내가 알고 있는 것을 공유하겠다는 차원이 아닌 필자의 생각을 정리하고, 이게 정말 옳은 생각인지 스스로에게 묻기 위한 행동이라고 말하고 싶다. 마지막으로 이상구 교수님의 선형대수 강의에서 소개된 응용 사례를 소개하고 글을 마치겠다.

한 천문학자가 어떤 소행성 궤도를 구하려고 한다. 이를 위해 그는 태양을 원점으로 갖는 좌표평면을 설정하였다. 축의 눈금 단위들은 천문학의 측정단위(천문학 단위 1은 지구에서 태양까지의 평균거리이며 약 149,637,000km)가 사용된다. 행성은 타원형 궤도를 따라 운행한다. (케플러의 제1법칙)

천문학자는 다섯 번에 걸쳐 행성의 위치를 나타내는 (8.025,8.310), (10.170,6.355), (11.202,3.212), (10.736,0.375), (9.092,2.267) 관측점을 구하고, 이를 본문에서 다룬 방법으로 타원의 방정식을 구해서 그리면 행성의 궤도를 알 수 있게 된다.

Example banner

· 약 28분
Alvin Hong

필자가 회사에서 개발 중인 제니퍼5의 수요가 크게 늘어남에 따라 고객의 기호에 맞게 화면에 대한 커스터마이징을 요구하는 경우가 많아졌다. 하지만 기존의 프로젝트에 커스터마이징 코드를 넣는 것은 바람직하지 않기 때문에 외부에서 구현하고, 플러그인처럼 추가하고 삭제할 수 있는 구조를 고민해야만 했다.

제니퍼5 뷰서버는 개발을 시작한지 7년이 넘은 꽤 오래된 코드로 구현되어 있다. 기반 프레임워크는 스프링3 버전이었는데, 플러그인 구조를 만들기 위해 큰 용기(?)를 내서 스프링4 버전으로 업그레이드 했다

어떻게 구현할까 오랫동안 고민했었는데, 그냥 단순하게 스프링 컨텍스트에 같이 올리는 방법을 택했다. 회사의 서버 개발자 분의 도움을 받아 간단한 클래스 로더를 만들었고, 외부에서 개발한 플러그인 jar 파일을 제니퍼5 뷰서버가 시작할 때, 같이 올라가는 구조였다.

이렇다보니 플러그인을 수정할 때마다 jar 파일을 생성하기 위해 빌드하고, 제니퍼5 뷰서버를 재시작 해야하는 번거로운 일을 반복하게 되었다. 그래서 독립적인 개발환경 구성을 위해 스프링부트(SpringBoot)와 웹팩(Webpack)을 고려하게 되었다.

서론이 많이 길어져서 독립적인 개발환경 구성에 대한 내용은 이 정도로 하고, 바로 본론으로 넘어가도록 하겠다. 본문에서는 인텔리제이(IntelliJ)에서 스프링부트 프로젝트를 생성하고, 스프링부트 재시작 없이 클래스와 리소스를 동적으로 반영해주는 핫스왑(Hot-Swap)에 대해 알아볼 것이다. 또한 웹팩 기반으로 스프링부트 프로젝트와 연계하여 프론트엔드 개발환경을 직접 구성해볼 것이다.

(1) 스프링부트 핫스왑 설정하기

그럼, 먼저 스프링부트 프로젝트를 생성해보자.

  1. File > New > Project 클릭
  2. Spring Initialzr > Project SDK 선택 (1.8) > Next 클릭
  3. Project Metadata 입력 > Type 선택 (Maven Project) > Next 클릭
  4. Dependencies 입력 > Spring Boot 버전 선택 (1.5.15) > 라이브러리 선택
  5. 본문에서는 화면 개발과 핫스왑을 위한 최소한의 라이브러리만 선택하지만 개발 환경에 맞게 알아서 추가로 선택하면 된다.

Example banner

템플릿 엔진은 기호에 맞게 다른걸 사용해도 상관없다. 이제 프로젝트가 생성되었으니 앞으로 두가지 설정만 하면 핫스왑 기능을 사용할 수 있게 된다.

다시 확인해보니 DevTools 라이브러리를 사용하지 않아도 된다. 핫스왑은 인텔리제이에서 제공해주는 아주 유용한 기능이다.

먼저 Preferences… 메뉴로 가서 Build, Execution, Deployment 탭의 Compiler 메뉴를 선택하고, Build project automatically를 체크해주면 된다.

Example banner

마지막으로 Registry 설정 화면을 열어서 compiler.automake.allow.when.app.running 옵션을 체크해주자.

Example banner

Example banner

서버 개발환경은 이렇게 간단하게 구성하였지만 클라이언트 개발환경은 프로젝트 목적에 따라 차이가 있을 수 있다.

핫스왑 기능이 동작하기 위해서는 메인 클래스를 디버그 모드로 실행해야 하며, 코드를 수정하고나서 Build Project를 직접 실행해야만 반영이 빠르다. 참고로 순수 자바 클래스만 변경되었을 때만 동작하며, 스프링 관련 설정이나 컨텍스트에 변화가 있으면 메인 클래스를 재시작해야 한다.

(2) 웹팩 설치하기

본문에서는 React나 Vue.js와 같은 프레임워크는 다루지 않으며, 클라이언트 개발을 위한 최소한의 환경만 구성할 것이다. 아주 당연한 얘기지만 Node.js와 NPM 패키지 관리자가 설치되어 있어야 하며, 모듈 번들링이 무엇인지에 대한 사전 이해가 필요하다.

그럼, 먼저 package.json 파일을 생성해보자. 인텔리제이 하단에 있는 터미널 탭을 선택하여 콘솔에 npm init을 입력한다.

Example banner

명령어를 입력하고, 패키지 이름이나 버전 등을 입력할 수 있는데, 프로젝트 특성에 맞게 적당히 입력하거나 그냥 엔터키를 눌러서 기본값으로 설정할 수도 있다.

웹팩은 버전마다 설정 방식이 많이 다르므로 본문에서 다루는 내용은 최신 버전인 4.16.5를 기준으로 한다.

npm install — save-dev webpack webpack-cli webpack-dev-server

[email protected]
[email protected]
[email protected]

webpack-cli는 웹팩 명령어 수행을 위한 패키지이고, webpack-dev-server는 스프링부트로 실행한 서버와 연계하기 위한 프록시 서버로 사용된다. 관련된 설명은 잠시 후에 나오니 일단은 넘어가도록 하자.

웹팩 설치가 완료되었으니 이제 모듈 번들링을 위한 로더들을 설치해야 한다. 로더의 종류는 매우 다양하지만 본문에서는 필자가 생각했을 때, 기본이 된다고 생각하는 것들만 다룰 것이다.

  • babel-loader : 자바스크립트 모듈 번들링을 위한 로더이며, 보통 ES6 코드를 ES5로 변환하기 위해 사용한다.
  • css-loader : 모듈 번들링은 자바스크립트 기반으로 이뤄지기 때문에 CSS 파일을 자바스크립트로 변환하기 위해 사용한다.
  • style-loader : css-loader에 의해 모듈화 되고, 모듈화 된 스타일 코드를 HTML 문서의 STYLE 태그 안에 넣어주기 위해 사용된다.
  • url-loader : 스타일에 설정된 이미지나 글꼴 파일을 문자열 형태의 데이터(Base64)로 변환하여 해당 CSS 파일 안에 포함시켜버리기 때문에 정적 파일을 관리하기 쉬워진다. 하지만 실제 파일들보다 용량이 커지고, CSS 파일이 무거워지므로 적당히 사용하는 것을 권장한다.
  • file-loader : 정적 파일을 로드하는데 사용되며, 보통 프로젝트 환경에 맞게 특정 디렉토리에 파일을 복사하는 역할을 수행한다.

참고로 바벨은 프리셋을 함께 설치해줘야 한다. 관련해서 설명할 내용이 많지만 본문의 주제에서 벗어나니 보편적으로 사용되는 것을 선택했다.

npm install — save-dev babel-core babel-loader babel-preset-env css-loader style-loader url-loader file-loader

마지막으로 웹팩 실행을 위한 NPM 스크립트를 추가해보자. package.json의 최종 모습은 다음과 같을 것이다. 특이한건 env 변수인데, 웹팩 설정 파일 내에서 개발/배포 모드를 구분하기 위함이다.

{
  "name": "frontend",
  "version": "1.0.0",
  "description": "",
  "main": "src/main/client/index.js",
  "scripts": {
    "prod": "webpack --env=production",
    "dev": "webpack-dev-server --env=development",
    "start": "npm run dev"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-loader": "^7.1.5",
    "babel-preset-env": "^1.7.0",
    "css-loader": "^1.0.0",
    "file-loader": "^1.1.11",
    "style-loader": "^0.22.1",
    "url-loader": "^1.0.1",
    "webpack": "^4.16.5",
    "webpack-cli": "^3.1.0",
    "webpack-dev-server": "^3.1.5"
  }
}

나중에 자세히 설명하겠지만, 일단 NPM 스크립트를 기억하고 있자.

개발할 때, npm run dev 또는 npm start
배포할 때, npm run prod

(3) 스프링부트 컨트롤러 만들기

갑자기 스프링부트로 바뀌어서 당혹스럽겠지만 웹팩 설정을 하기에 앞서 간단하게 컨트롤러와 뷰를 만들어보자.

package com.example.frontend;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class FrontendController {
    @RequestMapping("/hello")
    public String home(Model model){
        model.addAttribute("message", "Hello, World!!!");
        return "index";
    }
}

다음은 홈 컨트롤러에서 받은 메시지 텍스트를 출력하는 템플릿 코드이다. 여기서 index.js를 로드하는데, 스프링부트는 src/main/resources/static 디렉토리를 정적 리소스 루트로 설정하기 때문에 index.js 파일은 여기에 두어야 한다. 물론 해당 디렉토리는 개발자 임의로 변경할 수도 있다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <script type="text/javascript" src="/index.js" th:src="@{/index.js}"></script>
</head>
<body>
<strong><div th:text="${message}"></div></strong>
</body>
</html>

스프링부트를 실행하고, http://127.0.0.1:8080/hello으로 이동하면 “Hello, World!!!” 메시지가 HTML 문서에 출력되고, 경고창에 “onload!!” 메시지가 보일 것이다.

window.onload = function() {
    alert("onload!!");
}

(4) 웹팩 기본 설정하기

웹팩은 진입점이라고 할 수 있는 엔트리 js 파일을 설정해야 한다. 엔트리 js 파일을 통해 각각의 모듈들을 로딩하고, 하나의 아웃풋 파일로 묶어(bundle)준다. 웹팩 개발 서버(webpack-dev-server)는 코드의 변경을 감지할 때마다 다시 번들링을 한다. 또한 아웃풋 파일이 적용된 화면을 실시간 리로딩(Live Reloading)하는 inline 옵션과 부분 모듈 리로딩(Hot Module Reloading)하는 hot 옵션을 제공한다.

앞에서 개발한 스프링부트 뷰에서 사용하기 위해서는 아웃풋 파일이 최종적으로 src/main/resources/static 디렉토리에 위치해야 한다. 하지만 클라이언트 코드를 수정할 때마다 static 디렉토리에 아웃풋 파일이 반영되면 스프링부트 핫스왑 기능이 활성화 되어 불필요한 스프링 컨텍스트 리로드가 발생할 수 있다.

그래서 필자는 클라이언트 아웃풋 파일을 별도의 디렉토리에 생성하게 하고, 해당 디렉토리를 웹팩 개발 서버의 루트(contentBase)로 설정할 것이다. 그렇다면 이미 실행 중인 스프링부트와 연계는 어떻게 할 수 있을까? 일단 웹팩 설정 파일부터 살펴보자.

const path = require('path')

module.exports = (env) => {
    let clientPath = path.resolve(__dirname, 'src/main/client');
    let outputPath = path.resolve(__dirname, 'out');

    return {
        mode: !env ? 'development' : env,
        entry: {
            index: clientPath + '/index.js'
        },
        output: {
            path: outputPath,
            filename: '[name].js'
        }
    }
}

일단 env 변수는 NPM 명령어 실행시 넘겨주는 값이다. 웹팩4부터는 mode에 development 또는 production을 명시해야 한다. 일단 배포 환경 구성은 현재 다루지 않으니 이 부분은 넘어가도록 하겠다.

앞에서 생성한 스프링부트 프로젝트에서 클라이언트 개발 코드는 src/main/client 디렉토리에서 관리하기로 하고, 해당 디렉토리에 있는 index.js 파일을 엔트리 js 파일로 설정했다. 그리고 아웃풋 파일의 생성 디렉토리는 src/main/resources/static이 아닌 out으로 설정했다. 참고로 output.filename 옵션 값이 [name].js인데, 여기서 [name]은 entry 옵션의 key 이름으로 치환된다. 이 밖에도 [id], [hash] 같은 치환 문자열이 있다.

다음은 웹팩 개발 서버 설정이다. 먼저 devServer.contentBase 옵션 값은 아웃풋 파일들이 생성되는 디렉토리와 맞춰야 하기 때문에 output.path와 동일하게 설정해야 한다. devServer.port는 스프링부트 포트와 다른 값을 설정하면 된다. 마지막으로 웹팩 개발 서버와 스프링부트를 연계하기 위해 devServer.proxy 옵션을 사용한다. key는 요청 URL이고, value는 프록시 대상이다.

http://127.0.0.1:8081/hello로 접근하면 스프링부트 컨트롤러에서 넘긴 값으로 구현된 뷰에서 웹팩 개발 서버의 contentBase 옵션에 설정된 디렉토리 안에 있는 정적 파일들을 로드할 수 있게 된다.

...,
        devServer: {
            contentBase: outputPath,
            publicPath: '/',
            host: '0.0.0.0',
            port: 8081,
            proxy: {
                '**': 'http://127.0.0.1:8080'
            },
            inline: true,
            hot: false
        }
    }
}

간단하게 프록시 설정이 잘되어 있는지 확인하기 위해 src/main/client 디렉토리에 index.js 파일을 추가하자. 아까 만든 index.js와 차이를 두기 위해 메시지를 “proxy onload!!”로 변경했다.

window.onload = function() {
    alert("proxy onload!!");
}

그럼, 스프링부트와 웹팩 개발 서버를 실행해보자.

npm start
webpack-dev-server — env=development

ℹ 「wds」: Project is running at http://0.0.0.0:8081/
ℹ 「wds」: webpack output is served from /
ℹ 「wds」: Content not from webpack is served from /Users/alvin/Documents/Workspace/springboot/frontend/out
ℹ 「wdm」: Hash: 5d0bf01db3a4b9a400bf
Version: webpack 4.16.5
Time: 395ms
Built at: 08/16/2018 7:25:02 PM
Asset Size Chunks Chunk Names
index.js 338 KiB index [emitted] index
Entrypoint index = index.js

http://127.0.0.1:8080/hello로 접속하면 경고창에 “onload!!” 메시지가 보일 것이고, http://127.0.0.1:8081/hello로 접속해서 “proxy onload!!” 메시지가 보인다면 웹팩 기본 설정이 잘 완료된 것이다.

(5) 웹팩 로더 설정하기

앞에서 엔트리와 아웃풋을 설정했다. 현재 설정으로는 오직 js 파일만 번들링 할 수 있다. 하지만 클라이언트 개발은 css 파일이나 이미지 파일들도 필요하기 때문에 관련된 웹팩 로더를 추가해줘야 한다.

참고로 css 파일은 로드하더라도 js 파일로 번들링 되기 때문에 관련 플러그인을 추가로 설치하여 css 파일만 따로 분리할 것이다.

npm install — save-dev mini-css-extract-plugin

앞에서 style-loader에 대한 설명을 했는데, 본문에서는 다루지 않을 것이다. 개발 뿐만이 아니라 배포까지 생각하면 STYLE 태그에 스타일 코드가 들어가는 것보단 하나의 css 파일로 번들링되는 것이 바람직하다고 생각한다.

const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = (env) => {	module.exports = (env) => {
    let clientPath = path.resolve(__dirname, 'src/main/client');
    let outputPath = path.resolve(__dirname, 'out');

    return {
        ...,
        module: {
            rules: [{
                test: /\.js$/,
                use: [{
                    loader: 'babel-loader',
                    options: {
                        presets: 'env'
                    }
                }]
            }, {
                test: /\.(css)$/,
                use: [{
                    loader: MiniCssExtractPlugin.loader
                }, {
                    loader: 'css-loader'
                }]
            }]
        },
        plugins: [
            new MiniCssExtractPlugin({
                path: outputPath,
                filename: '[name].css'
            })
        ]
    }
}

웹팩 설정 파일에 추가된 module 옵션을 살펴보면 babel-loader를 통해 ES6 코드를 ES5로 변환할 수 있다. style-loader는 방금 설치한 MiniCssExtractPlugin.loader로 변경되었고, 엔트리 js 파일에서 css 파일을 로드하기 위해 css-loader도 함께 사용되었다.

여기서 중요한 점은 plugins 옵션에 MiniCssExtractPlugin 객체가 추가되었다는 사실이다. 번들링 된 css 파일은 기존의 아웃풋 파일과 같은 디렉토리에 위치해야하므로 path 옵션 값은 output.path와 동일하다. 그리고 css 파일 이름은 entry 옵션에 설정된 key 이름으로 치환된다.

이제 js 파일 뿐만이 아니라 css 파일까지 번들링 할 수 있게 되었는데, 뭔가 부족하다. 스타일에서 이미지를 사용하고 싶을 때는 어떻게 해야할까?

이미지 파일 사용하기

답은 바로 url-loader와 file-loader를 사용하는 것이다. 필자는 정적 파일 관리가 귀찮아서 url-loader를 선호하는 편인데, 아무래도 번들링 된 css 파일이 엄청 커질 수 있기 때문에 크기가 작은 이미지만 사용하는게 좋다.

본문에서는 두가지 로더를 모두 테스트해보기 위해 svg 파일은 file-loader를 사용하고, 나머지 이미지 파일은 url-loader를 사용하였다. 단순히 테스트 용도이므로 취향에 맞게 알아서 고쳐서 사용하면 된다.

...
        module: {
            rules: [..., {
                test: /\.(jpe?g|png|gif)$/i,
                use: [{
                    loader: 'url-loader',
                    options: {
                        limit: 1024 * 10 // 10kb
                    }
                }]
            }, {
                test: /\.(svg)$/i,
                use: [{
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: 'images/'
                    }
                }]
            }]
        },
        ...

두개의 로더를 추가했다. 먼저 url-loader는 10KB 미만의 jpg, png, gif 파일을 문자열 형태의 데이터(Base64)로 변환한다. 그리고 svg 파일만 file-loader에서 가져오게 되는데, outputPath 옵션을 통해 가져온 svg 파일의 디렉토리를 설정한다. svg 파일 이름이 webpack.svg라면 http://127.0.0.1:8081/images/webpack.svg 경로로 화면에 로드된다.

프로젝트 코드 수정하기

이제 웹팩 로더 설정도 끝났겠다. 앞에서 말한 내용들을 테스트해보기 위해 index.css와 index.html 파일을 수정해보자.

strong {
    color : red;
}

.pattern {
    width: 300px;
    height: 64px;
    display: inline-block;
    background-image: url("./images/globe.png");
    background-repeat: repeat-x;
}

.image {
    width: 300px;
    height: 300px;
    display: inline-block;
    background-image: url("./images/webpack.svg");
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <script type="text/javascript" src="/index.js" th:src="@{/index.js}"></script>
    <link rel="stylesheet" type="text/css" media="all" href="/index.css" th:href="@{/index.css}" />
</head>
<body>
    <h1>default</h1>
    <strong><div th:text="${message}"></div></strong>
    <br/>

    <h1>url-loader</h1>
    <div class="pattern"></div>
    <br/>

    <h1>file-loader</h1>
    <div class="image"></div>
</body>
</html>

(6) 청크(chunk) 관리하기

클라이언트 개발을 하다보면 다양한 라이브러리들을 사용하게 된다. 현재 웹팩 설정으로 번들링하게 되면 아웃풋 파일의 크기가 엄청 커질 것이고, 초기 로딩 속도에도 악영향을 미칠 것이다. 그럼, 지금부터 아웃풋 파일을 현재 개발 중인 코드와 라이브러리로 분리해보자.

npm install — save jquery moment

테스트를 위해 jquery와 moment 라이브러리를 설치했다. 그리고 jquery와 moment를 사용하여 onload 시점에 현재 시간을 출력해주는 코드를 추가했다.

import Styles from './index.css'
import $ from 'jquery'
import moment from 'moment'

$(function() {
    $("strong > div").html(moment().format('MMMM Do YYYY, h:mm:ss a'));
});

코드를 분리하지 않으면 다음과 같이 아웃풋 파일의 용량이 매우 커진다.

Example banner

그럼, 다시 웹팩 설정 파일을 수정해보자.

...
        entry: {
            vendors: [ 'jquery', 'moment' ],
            index: clientPath + '/index.js'
        },
        output: {
            path: outputPath,
            filename: '[name].js'
        },
        optimization: {
            splitChunks: {
                chunks: 'all',
                cacheGroups: {
                    vendors: {
                        test: /[\\/]node_modules[\\/]/,
                        name: 'vendors'
                    }
                }
            }
        },
        ...

entry 옵션에 vendors가 추가된 것을 확인할 수 있다. index와 달리 문자열 배열 값이 들어있는데, 실제로 사용한 라이브러리 이름을 명시하면 된다. 다음은 optimization.splitChunks 옵션이다. 여기서 중요한건 청크 대상을 node_modules 디렉토리 내로 제한한 것이다.

청크 방법은 너무 다양해서 별도의 주제로 다루는 것이 좋을 것 같으니 본문에서는 이 정도로 설명을 마치겠다. 그리고 아웃풋 파일이 하나 더 생겼으니 index.html 파일도 변경하도록 하자.

<head>
    <meta charset="utf-8" />
    <script type="text/javascript" src="/vendors.js" th:src="@{/vendors.js}"></script>
    <script type="text/javascript" src="/index.js" th:src="@{/index.js}"></script>
    <link rel="stylesheet" type="text/css" media="all" href="/index.css" th:href="@{/index.css}" />
</head>

(7) 배포 환경 설정하기

그동안 번들링 된 아웃풋 파일들을 웹팩 개발 서버의 contentBase 디렉토리에 두고 프록시를 통해 스프링부트 뷰에서 로드했었다. 배포 시점이 되면 아웃풋 파일들을 스프링부트의 정적 리소스 루트 디렉토리로 옮겨야 한다.

배포를 위한 빌드 설정은 매우 간단하다. 웹팩 설정 파일에서 처음 정의한 outputPath 변수 값을 스프링부트의 정적 리소스 루트 디렉토리로 설정하면 된다.

// Before
let outputPath = path.resolve(__dirname, 'out')

// After
let outputPath = path.resolve(__dirname, (env == 'production') ? 'src/main/resources/static' : 'out')

수정이 완료되었으면 다음과 같이 NPM 스크립트를 실행한다.

npm run prod

> [email protected] prod /Users/alvin/Documents/Workspace/springboot/frontend
> webpack — env=production

Hash: 8d360cf3a938a8cf0cf7
Version: webpack 4.16.5
Time: 7262ms
Built at: 08/17/2018 1:39:52 AM

이제는 스프링부트만 실행해도 클라이언트 코드가 잘 동작하는 것을 확인할 수 있을 것이다. 하지만 아직 뭔가 부족하다. 아무래도 배포되는 파일인데, 최적화를 해야할 것 같다.

웹팩4에서 기본으로 배포되고 있는 uglify-webpack-plugin과 optimize-css-assets-webpack-plugin을 추가로 설치하여 배포 시점의 아웃풋 파일들을 최적화 해보자.

npm install — save-dev optimize-css-assets-webpack-plugin

드디어 마지막 단계이다. 배포 모드일 때, optimization.minimizer 옵션에 두 플러그인의 객체를 추가해주면 모든 웹팩 설정이 끝이난다.

const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')

module.exports = (env) => {
    let clientPath = path.resolve(__dirname, 'src/main/client');
    let outputPath = path.resolve(__dirname, (env == 'production') ? 'src/main/resources/static' : 'out')

    return {
        mode: !env ? 'development' : env,
        entry: {
            vendors: [ 'jquery', 'moment' ],
            index: clientPath + '/index.js'
        },
        output: {
            path: outputPath,
            filename: '[name].js'
        },
        optimization: {
            splitChunks: {
                chunks: 'all',
                cacheGroups: {
                    vendors: {
                        test: /[\\/]node_modules[\\/]/,
                        name: 'vendors'
                    }
                }
            },
            minimizer: (env == 'production') ? [
                new UglifyJsPlugin(),
                new OptimizeCssAssetsPlugin()
            ] : []
        },
        devServer: {
            contentBase: outputPath,
            publicPath: '/',
            host: '0.0.0.0',
            port: 8081,
            proxy: {
                '**': 'http://127.0.0.1:8080'
            },
            inline: true,
            hot: false
        },
        module: {
            rules: [{
                test: /\.js$/,
                use: [{
                    loader: 'babel-loader',
                    options: {
                        presets: 'env'
                    }
                }]
            }, {
                test: /\.(css)$/,
                use: [{
                    loader: MiniCssExtractPlugin.loader
                }, {
                    loader: 'css-loader'
                }]
            }, {
                test: /\.(jpe?g|png|gif)$/i,
                use: [{
                    loader: 'url-loader',
                    options: {
                        limit: 1024 * 10 // 10kb
                    }
                }]
            }, {
                test: /\.(svg)$/i,
                use: [{
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: 'images/'
                    }
                }]
            }]
        },
        plugins: [
            new MiniCssExtractPlugin({
                path: outputPath,
                filename: '[name].css'
            })
        ]
    }
}

(8) 보너스

이렇게 강좌가 끝나면 아쉬워할것 같아서 개발할 때, 도움이 될 만한 플러그인을 추가로 소개할까한다. js 파일들이 하나의 아웃풋 파일로 번들링 될 때, 내부 구조가 궁금할 수 있다. 또한 청크 설정이 잘못되어 생각했던 것과 다르게 파일이 분리되는 경우도 있다.

npm install — save-dev webpack-bundle-analyzer

플러그인을 설치하고, 다음과 같이 웹팩 설정 파일을 수정하자.

...
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = (env) => {
    ...

    return {
        plugins: [
            ...
            , new BundleAnalyzerPlugin()
        ]
    }
}

웹팩 개발 서버가 실행되면 8888 포트로 웹 페이지가 하나 열리는데, 트리맵 형태로 js 파일들이 보여지며, 파일 용량이 클수록 트리 노드가 넓어진다.

Example banner

(9) 프로젝트 다운로드

본문에서 다룬 내용은 모두 GitHub 저장소에 올려놨으니 바로 테스트 해볼 수 있다. 다음 강좌를 위해서 계속 업데이트할 프로젝트이니 Watch나 Star를 누르면 최신 상태를 업데이트 받을 수 있을 것이다. ㅎㅎ

https://github.com/seogi1004/webpack-springboot-starter