사람들이 행복해하는 소프트웨어를 만드는 프로그래머가 되는 것을 목표로 살아가는 아들 바보 프로그래머. 우아한형제들에서 웹 프런트앤드 프레임워크, 서비스, 제품 등을 만들고 있습니다.

아마도, 많은 웹 프론트앤드 개발 조직은 나름의 자체 프레임워크이 있을 것입니다. 우아한형제들의 웹 프론트앤드 팀에서도 나름의 필요에 의해 자바스크립트 프레임워크를 만들어 사용하고 있습니다. 오픈소스 프레임워크인 BackboneJS 기반의 WoowahanJS를 오픈소스로 공개하며 어떤 문제를 해결하기 위해 만든 제품인지 생각을 나누고 공유해 보려 합니다.

무엇이 문제였나?

React, Angular, Backbone, Ember 등 범용적이고 활발한 커뮤니티를 보유한 많은 프레임워크가 있습니다. 대부분의 경우 나열된 것 중 하나를 선택하여 문제없이 웹앱을 개발할 수 있습니다. 그러나 실무에선 다양한 문제가 발생하기도 합니다. 심각하다고 생각하는 몇 가지 문제는 다음과 같습니다.

1. 팀 내에 안정적인 코드 베이스를 유지할 수 있는 아키텍처를 설계하고 지탱할 수 있는 개발자가 존재하는가?

이 문제는 가장 큰 현실적인 문제입니다.

제가 경험했던 개발팀들은 대규모 웹 애플리케이션 개발을 함에 있어 충분한 경험과 기술력을 가진 시니어가 부족했습니다. 비교적 작은 규모에선 문제가 드러나지 않지만 코드 베이스가 커지면 티 나지 않았던 문제가 극복할 수 없는 심각한 문제로 확대 대기도 합니다. 우아한형제들은 다양한 서비스를 개발하고 있고 수많은 B2B, B2C용의 많은 웹앱을 개발하고 있으나 모든 프로젝트에 충분히 숙련된 프론트앤드 개발자가 투입되기는 현실적인 어려움이 있었습니다.

유명한 프레임워크를 도입한다 해서 이 문제가 사리지지는 않습니다. 프레임워크의 형태에 따라 더 큰 설계 역량이 필요하기도 합니다. React 같은 경우 뷰의 랜더링 부분만을 담당하는 작은 라이브러리처럼 보이지만 규모가 커질 경우 뷰 컴포넌트의 설계가 견고하지 못하면 고통스러운 경험을 할 수 있습니다. 이는 어떤 프레임워크라 하더라도 완전히 해결되기는 어려운 문제라 생각합니다.

대부분의 프로그래머는 최신 기술을 도입하고 사용해 보는 것에 대한 욕망이 있고, 있어야만 한다고 생각합니다. 때론 그 욕망이 너무 과한 나머지 만들어야 하는 서비스나 제품의 상황과 맞지 않는 적정 기술 도입에 실패하곤 합니다. 노련한 프로그래머라면 욕망과 현실의 제약 조건 사이에서 적절한 타협점을 찾을 수 있어야 합니다.

2. 퍼블리셔, 디자이너와의 협업에 유리한 구조인가?

SPA - Single Page Application - 형태의 웹앱이 웹앱 생태계를 주도하면서 HTML을 데이터와 결합하는 방식에 대한 많은 방법이 제시되어 왔습니다. 안타깝게도 그러한 결합 방법들은 Javascript 코드로 다루기 유리한 형태로 발전되어 왔습니다. 프론트앤드 템플릿 엔진의 도입, 비표준 태그의 사용, data-* 를 기반으로 한 방식은 물론, 최근에 React를 중심으로 Virtual-DOM 으로까지 이르렀습니다. 새로운 방법들이 제안될수록 아이러니하게도 퍼블리셔와의 협업은 더욱더 어려워지고 있습니다. 최종 결과가 될 HTML과의 형태적 괴리감은 커지고 결합도를 예측하기 힘들 정도로 뷰는 작은 조각으로 파편화될 수 있습니다. 이런 경향은 과연 올바른 것일까요? 여러분의 프로젝트는 이 문제를 어떻게 해결하고 계신가요?

3. 선택한 프레임워크에 대한 기술 확보는 충분한가?

커뮤니티가 충분히 성숙한 프레임워크라 해도 해결하기 힘든 문제는 언제나 발생하기 마련입니다. 프레임워크 자체의 버그일 수 도 있고, 개발된 애플리케이션의 버그일 수 도 있습니다. 다양한 서드파트 라이브러리와의 종속성 문제일 수 도 있고 문제는 다양하게 발생합니다. 이런 문제를 극복하기 위해 사용하는 프레임워크의 학습 숙련도는 매우 중요합니다. 대부분의 경우 사용자 수준의 학습 비용을 치르고 개발을 하게 됩니다. 정신없이 진행되는 개발 일정 속에서 원인이 불명확한 이슈 발생은 개발자를 괴롭힐 수 있습니다. 팀 내에 사용중인 프레임워크의 코드 분석이 가능한 멤버가 없다면 적절한 이슈 대응에 실패하여 프로젝트 진행에 심각한 타격을 주기도 합니다.

해결책은?

조직마다 상황에 따라 다양한 해결책을 찾을 수 있을 겁니다. 우아한형제들은 자체 프레임워크를 만드는 방법을 선택했습니다. 프레임워크를 만듦에 있어 주요 테마는 협업생산성 이었습니다.

현재도 그렇지만 개발 초기에 빠르게 형태를 잡기 위해 Backbone 기반으로 작성되었으며, 현재 버전은 0.1.5이며 Backbone의 흔적은 초기와 다르게 상당히 희석된 형태를 유지하고 있습니다.

향후 Backbone 종속성을 완전히 제거할 계획도 가지고 있습니다.

자, 이제 WoowahaJS를 소개해보도록 하겠습니다.

설치

$ npm install --save woowahan

Hello World

이 글 내에 등장하는 모든 예제는 편의를 위해 Webpack, ES2015 환경을 전제로합니다.

index.html

<!doctype html>
<html>
<body>
  <div id="content"></div>
  <script src="main.js"></script>
</body>  
</html>

main.js

import Woowahan from 'woowahan';

let app = new Woowahan();

app.start({
  url: '/',
  container: '#content',
  view: Woowahan.View.create('Hello', {
    template: '<h1>Hello, WoowahanJs</h1>'
  });
});

Woowahan 어플리케이션 인스턴스를(app) 생성한 후 app.start() 메소드를 호출하여 어플리케이션을 시작합니다. app.start() 메소드는 인자로 라우팅을 기술한 객체를 받습니다. 라우팅 객체는 라우팅을 경로와 연결될 뷰 컴포넌트의 바인딩을 처리하고 적절한 순간에 뷰를 로딩하고 제거하는 역할을 수행합니다. 대개의 경우 웹앱은 하나 이상의 페이지로 구성되기 때문에 라이팅 객체 역시 계층적 구조로 기술될 수 있습니다.

상대 경로로 작동되는 라우팅 객체

app.start({
  url: '/', container: '#content', view: HomeView, pages: [
      {
        url: 'users', view: UsersView, pages: [
          {
            url: ':userid', view: UserView
          }
        ]
      }
  ]
});

절대 경로로 작동되는 라우팅 객체

app.start({
  url: '/', container: '#content', view: HomeView, pages: [
      { url: '/users', view: UsersView },
      { url: '/users/:userid', view: UserView }
  ]
});

View 컴포넌트

WoowahaJS의 모든 View 컴포넌트는 Woowahan.View.creat()로 생성됩니다. 가장 간단한 뷰 컴포넌트는 다음과 같습니다.

export default Woowahan.View.create('view name', {
  template: '<p>Hello?</p>'
});

뷰는 뷰 이름과 뷰 내용을 기술한 객체로 구성됩니다. 뷰 객체는 HTML 템플릿 역할을 하는 template 키를 최소 요건으로 구성됩니다.

View 라이프 사이클

웹앱을 구성하는 기본 구성 요소인 뷰 컴포넌트는 결국 데이타를 결합한 HTML로 랜더링되게 됩니다. 이를 위해 WoowahanJS는 React 컴포넌트의 라이프 사이클과 유사한 라이프 사이클을 제공하며 다음과 같은 순서로 실행됩니다.

  1. viewWillMount
  2. viewDidMount
  3. viewWillUnmount

viewWillMount

viewWillMount(renderData) {
  renderData.isEmpty = renderData.rows.length === 0;

  return renderData;
}

HTML을 랜더링하기 전 호출됩니다. 인수로 HTML과 결합할 renderData 객체가 전달되며 랜더링 되기 전 데이타를 변경하거나 새로운 데이타를 주입시킬 수 있습니다. 변경된 renderData 를 return 함으로서 Template에 데이타를 전달할 수 있습니다.

viewDidMount

viewDidMount($el) {
  $el.find('.datepicker').datetimepicker();
}

HTML을 랜더링하고 DOM에 마운트한 후 호출됩니다. DOM에 마운트된 다음 수행할 처리가 있다면 viewDidMount에서 할 수 있습니다. 인수로 전달되는 $el 객체는 랜더링된 DOM의 최상위 엘리먼트를 참조하고 있는 jQuery 객체입니다.

viewWillUnmount

viewDidUnmount() {
  this.$('.memo').summernote('destroy');
}

뷰가 제거되기 전에 호출됩니다. 이벤트 리스너를 제거하거나 삭제될 컴포넌트가 있다면 이곳에서 기술할 수 있습니다.

이벤트

events: {
  'click .btn.btn-save': 'onSave',
  '@click .btn.btn-search': 'onSearch(.keyword)'
},

onSearch(keyword) {

}

뷰의 UI 요소와 이벤트 리스너를 연결하기 위해 events를 기술할 수 있습니다. events 기술 방법은 Backbone의 방식 그대로 사용하고 있습니다.

Backbone 이벤트와는 다른 이벤트를 제공합니다. @ 이벤트라 부르는 형식이며 위 예제의 onSearch 함수가 @ 이벤트입니다. @ 이벤트는 이벤트와 리스너를 연결할 때 리스너에 기술된 DOM 셀렉터의 값을 추출하여 리서너 함수에 인수로서 전달합니다. FORM의 Submit 처리나 검색 버튼 클릭시 하나 이상의 검색 옵션을 리스너에 전달하게 되는 경우가 많습니다. 이 때 뷰 코드에서 반복적인 DOM 접근 처리를 제거하기 위한 방법으로 @ 이벤트를 제공하고 있습니다.

자식 뷰

viewDidMount() {
  this.addView('.child-container', ChildView);
},

onReload() {
  this.updateView('.child-container', ChildView, { ...data });
}

모든 뷰는 자식 뷰를 몇개라도 소유할 수 있습니다. 자식 뷰를 추가하기 위한 addView() 와 자식 뷰를 업데이트하기 위한 updateView를 제공합니다. 두 메소드 모두 첫 번째 인수는 자식 뷰가 추가될 컨테이너의 셀렉터 문자열 입니다. 두 번째 인수는 뷰 컴포넌트이며, updateView인 경우 세번째 인수로 데이타를 기입하여 뷰에 전달할 수 있습니다. 자식 뷰는 보모 뷰가 제거될 때 자동으로 제거되며 중첩된 깊은 뷰의 계층 구조를 가지고 있다 해도 정확히 동작합니다.

Action & Reducer

WoowahanJS는 뷰와 데이타 처리(API 연동) 로직을 완전히 격리한 후 Action을 기반으로 느슨하게 연결된 아키텍처를 제공합니다. 이런 구조는 네트워크 처리 등 뷰와 관계없는 코드를 뷰와 분리함으로서 뷰의 코드를 가볍게 유지할 수 있도록 합니다. 기본 아이디어는 Redux 및 Flux 아키텍처와 유사합니다.

Action

Action은 특정 작업을 시작하게하는 “키”로서 정의되며 Reducer와 1:1 관계를 가집니다. Action을 Reducer에 전달하기 위해서 dispatch를 제공하며 어떤 View에서도 호출할 수 있습니다.

액션을 쉽게 만들 수 있는 Action Creator 유틸리티를 제공합니다. dispatch에 전달된 액션 객체를 직접 기술할 수도 있으나 Action Creator를 사용하면 좀 더 편리합니다.

Woowahan.Action.create('ActionName', data);

Dispatch

모든 뷰는 dispatch를 제공합니다. dispatch로 액션을 보낼 수 있으며 액션의 수행 결과는 지정한 핸들러를 통해 받을 수 있습니다. 핸들러가 없는 액션이 있을 수도 있기 때문에 핸들러는 옵션입니다. 다음과 같이 사용할 수 있습니다.

onReceive(data) {

}

onAction(data) {
  this.dispatch(Woowahan.Action.create('SearchOrders', data), this.onReceive);
}

Reducer

Reducer Creator로 리듀서를 만들 수 있습니다. WoowahanJS에서 리듀서의 역할은 약속된 액션의 작업을 처리하는 작업 처리자입니다. 웹 어플리케이션에서 작업 처리자가 처리해야할 주된 작업 중 하나는 API 호출과 관련되어있습니다. Ajax로 대변되는 XHR 처리는 리듀서가 담당하며 보다 효과적인 처리를 위해 몇 가지 헬퍼 함수가 제공됩니다. 사용자 정보 API를 호출한 후 결과를 반환하는 전형적인 코드는 다음과 같습니다.

const FETCH_USER_INFO = 'fetch-user-info';

Woowahan.Reducer.create(FETCH_USER_INFO, function(data) {
  this.onSuccess = function(response) {
    this.finish(response);
  };

  this.getData(`/api/users/${data.userid}`);
});

getData는 GET XHR 요청을 보내는 함수이며 첫 번째 인자로 URL을 받습니다. HTTP 메소드 타입에 대응하는 postData, putData, deleteData가 제공됩니다. XHR 요청이 완료되면 this.onSuccess로 결과 값이 반환됩니다. 응답 처리를 위해 onSuccess 구현은 필수 요소입니다.

리듀서의 처리 결과를 dispatch한 핸들러에 보내기 위해 finish 메소드가 제공됩니다. 리듀서 내에서 언제든 finish 메소드를 호출함으로서 리듀서 수행을 종료하고 결과를 dispatch시 지정된 핸들러로 전송할 수 있습니다.

Reducer 등록

만들어진 리듀서가 사용되기 위해선 등록 과정이 필요합니다. 리듀서 등록은 어플리케이션이 처리합니다.

import Woowahan from 'woowahan';
import FetchUserReducer from './reducer/fetch-user';

let app = new Woowahan();

app.use(FetchUserReducer);

app.start();

More…

One-way 데이타 바인딩, 전역 및 지역 오류 처리, 리듀서에 전달된 데이타 검증용 스키마, 전역 공통 컴포넌트 등록 및 사용 등 설명하지 않은 많은 기능들을 WoowahanJS는 포함하고 있습니다. 좀 더 구체적이고 상세한 내용은 Github 저장소의 문서와 예제 코드를 참고해 주세요.

Github WoowahanJS

운영은 어떻게?

점진적으로 다양한 웹앱 개발을 WoowahanJS로 하고 있습니다. 그러나 프레임워크 보다 프론트앤드 개발에 익숙하지 않은 개발자들에게 UI 개발은 큰 허들로 작용합니다. 주로 백오피스 형태의 웹앱을 많이 만들다 보니 Bootstrap 기반의 UI를 많이 사용합니다. UI 프레임웍은 Bootstrap 기반으로 개발한 후 이를 이용하여 다양한 UI를 WoowahanJS 기반으로 사내에 제공하고 있습니다.

유아이마트

UI-MART란 사내 서비스가 있고 개발자들은 필요한 UI컴포넌트를 선택적으로 다운로드할 수 있습니다. WoowahaJS 기반이 기본 형태이고 경우에 따라 HTML과 CSS만을 다운로드할 수 도 있습니다. 이런 내부 서비스를 통해 모든 팀이 빠르게 웹앱을 개발해 나갈 수 있도록 돕고 있습니다.

다양한 의견과 PR 언제든 환영합니다.

:-)