Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Summernote용 AngularJS 디렉티브 - angular-summernote 릴리즈

Summernote홍영택님이 만드신 부트스트랩기반의 WYSIWYG 에디터다. 최근에 해외에서도 언급이 종종 되고 인기를 얻고 있는 걸로 보이는데 Summernote를 안 지는 좀 되었지만 내가 하는 작업에서 WYSIWYG 에디터를 쓸 일이 없었기 때문에 써보지는 않았었다. 그러다 얼마 전에 뭔 일이 있어서(무슨 일이었더라...) 들어가서 저장소를 보다가 이슈에 AngularJS에 대한 이슈가 있는 걸 보고 홍영택님이 AngularJS를 하시는 걸 본적이 없었는데 마침 여유도 좀 있어서 테스트를 좀 해보고 댓글을 달았다.(그러다가 중간에 생각난 이슈도 하나 추가하고..)

그러고 나서 홍영택님과 온라인에서 얘기하다가 AngularJS 디렉티브를 만들어 달라는 요청이 종종 온다는 얘기를 들었다. 개인적으론 왜 특정 프레임워크와의 연동을 라이브러리 작성자한테 요청하는지 모르겠지만 요즘 AngularJS를 열심히 만져보고 있었기 때문에 관심이 갔다. 생각하면 해볼수록 재밌을 것 같아서 하던 다른 작업을 멈추고 작업에 들어갔다.(섬머노트의 인기에 좀 업혀가려고...)

angular-summernote

그래서 angular-summernote를 만들었다. Bower에 배포했으므로 bower install angular-summernote로 설치할 수 있다. HTML에서 angular-summernote를 인클루드하고 나면 angular.module('myApp', ['summernote']);로 Angular 애플리케이션에 angular-summernote를 주입할 수 있다. (물론 Summernote와 Angular관련 의존성은 모두 필요하다.)

angular-summernote는 엘리먼트와 어트리뷰트로 동작하도록 만들었으므로 다음 두가지 방법으로 Summernote를 불러올 수 있다.

<summernote></summernote>
<div summernote></div>

이렇게 디렉티브만 선언하면 자동으로 해당 영역이 Summernote로 바뀐다.

옵션

옵션은 속성으로 지정할 수 있다.

<summernote height="300"></summernote>

<summernote focus></summernote>

일일이 지정하지 않고 옵션을 객체로 만들어서 전달할 수 있다.

<summernote config="options"></summernote>
function DemoController($scope) {
  $scope.options = {
    height: 300,
    focus: true,
    toolbar: [
      ['style', ['bold', 'italic', 'underline', 'clear']],
      ['fontsize', ['fontsize']],
      ['color', ['color']],
      ['para', ['ul', 'ol', 'paragraph']],
      ['height', ['height']]
    ]
  };
}

스코프 내에 옵션객체를 정의하고 config 속성으로 지정하면 해당 설정을 사용한다. 툴바는 이 방법으로만 지정할 수 있다.

양방향 바인딩

Angular이므로 당연히 양방향 바인딩이 지원돼야 한다고 생각했다.

<summernote code="text"></summernote>
function DemoController($scope) {
  $scope.text = "Hello World";
}

Summernote는 code()라는 함수로 본문의 HTML 문자열을 가져올 수 있는데 이를 code라는 속성으로 지정하면 외부 스코프의 변수랑 서로 동기화를 하고 있다. 위 예제에서 Summernote내에서 내용을 입력하면 text라는 값도 바뀌고 외부 컨트롤러에서 text를 바꾸어도 Summernote내의 내용이 변경된다.(Summernote에 onChange가 없어서 이 부분은 좀 힘들게 구현했는데 제대로 동작할지는 잘...)

이벤트 리스너

function DemoController($scope) {
  $scope.init = function() { console.log('Summernote is launched'); }
  $scope.enter = function() { console.log('Enter/Return key pressed'); }
  $scope.focus = function(e) { console.log('Editable area is focused'); }
  $scope.blur = function(e) { console.log('Editable area loses focus'); }
  $scope.keyup = function(e) { console.log('Key is released:', e.keyCode); }
  $scope.keydown = function(e) { console.log('Key is pressed:', e.keyCode); }
  $scope.imageUpload = function(files, editor, welEditable) {
    console.log('image upload:', files, editor, welEditable);
  }
}
<summernote on-init="init()" on-enter="enter()" on-focus="focus(evt)"
            on-blur="blur(evt)" on-keyup="keyup(evt)" on-keydown="keydown(evt)"
            on-image-upload="imageUpload(files, editor, welEditable);">
</summernote>

이벤트 리스너를 위처럼 속성으로 정의하면 외부 컨트롤러에서 해당 이벤트를 받을 수 있다.

i18n

<summernote lang="ko-KR"></summernote>와 같이 lang 속성을 사용하면 해당 Summernote에 언어를 지정할 수 있다. 물론 i18n을 사용하려면 해당 언어파일이 필요하고 이 파일이 없는 경우 오류를 던진다.

실제 동작하는 예제는 JSFiddle에서 확인해 볼 수 있다.

개발기

AngularJS를 처음 공부하면 보통 양방향 바인딩이 대표적으로 눈에 띄기는 하지만 디렉티브도 AngularJS를 구성하는 핵심 기능 중 하나다. 양방향 바인딩에 혹해서 AngularJS를 사용하다 보면 바로 만나게 되는 게 디렉티브인데 이게 은근 쉽지 않다. 기존에 하던 식으로 DOM을 일일이 가져와서 조작한 다음에 돌려주는 건 Angular스럽지 않으니 뭔가 디렉티브를 사용해서 만들어야 할 것 같은 기분이 드는데 이게 진입 장벽도 꽤 높고 남이 만든 디렉티브만 가져다 쓰려다 보면 흡사 예전에 jQuery 플러그인으로 도배하던 기분도 들어서 어느 쪽이 맞는지 헷갈리기 마련이다.

간단한 개인 프로젝트에서 디렉티브를 만들어 본 적인 있지만, 이번처럼 아예 모듈로써 만들어 본 것은 이번이 처음이었다. 그동안에도 디렉티브를 다수 만들긴 했지만 애플리케이션을 작성하는 중간에 간단한 기능을 디렉티브로 만들어 쓰다 보니 약간 내 요구사항에만 최적화되거나 상위 컨트롤러랑 의존성을 가지면서 만들었는데 모듈로 제공하기 위해서 만들다 보니 완전 새로운 차원이었다. 그동안 디렉티브에 대한 이해도가 높지 않아서 그렇겠지만, 공부차원에서라도 재사용 가능한 디렉티브를 만들어 보기를 권한다.

개발

막상 만들려고 하니까 어떻게 만들어야 하는지 좀 막막했다. 모듈로 제공해서 다른 사람들이 다운받아서 사용하게 하려면 어떻게 구성해야 하는지도 잘 모르겠고... 그래서 전에도 디렉티브 구조 참고하기 제일 좋다고 들은 AngularUIbootstrapui-codemirror의 구조를 많이 참고했다. 소스를 보니 bootstrap이 구조는 가장 좋아 보였으나 에디터의 특성은 ui-codemirror가 더 유사해서 상황에 따라 섞어서 사용했다.

디렉티브를 구성하고 비즈니스 로직을 넣을 수 있는 기본 구조를 잡는데 약간 고생을 했지만 일단 모양을 잡고 나니까 Summernote의 API 문서를 보고 하나하나 바인딩하기 시작했지만 어려운 문제가 참 많이 있었다. 내부에서 Summernote를 다루는 건 어렵지 않았지만(만들어진 API를 그냥 호출만 하면 되니까..) 간단한 디렉티브 사용만으로 Summernote를 사용할 수 있으면서도 필요한 커스터마이징 및 API를 다시 컨트롤러로 노출해 주어야 외부에서 제어할 수 있기 때문에 어떤 구조로 만들어야 하는지가 가장 고민되었다.

예를 들어 heightfocus같은 경우는 일반적인 디렉티브처럼 그냥 attribute로 받으면 되지만 toolbar같은 경우는 attribute로 받기에는 배열이라서 값이 너무 많았고 받을 수는 있었지만 보기에 좋아 보이지 않았다. 처음에는 <summernote> <toolbar></toolbar> </summernote>같은 중첩구조로 가서 toolbar이라는 디렉티브가 내부에 있으면 그 정보를 이용해서 툴바를 커스터마이징하려고 했지만 역시나 좋은 방법인지 모호해서 그냥 컨트롤러에서 ngModel을 설정으로 받기로 했다. 디렉티브에 compile, link 단계와 scope 등 많은 기능이 포함되어 있고 각 디렉티브마자 약간씩 다른 방법으로 사용하고 있어서 새로운 기능 하나 구현할 때마다 디렉티브의 동작 방식을 이해하는데 삽질을 많이 했지만, 덕분에 공부도 많이 했다.

테스트

최근에 AngularJS의 유닛테스트를 담당하는 Karma를 파보기 시작했으므로 이번 작업을 할 때도 초반부터 Karma로 테스트를 구성했다.(사용할 때마다 느끼지만, Karma는 정말 아름다운 도구다.) 테스트를 초반에 만들지 않으면 나중에 고생하므로 테스트를 계속 짜기는 했지만, 테스트를 짤 때 꽤 힘들었다. 이번 작업의 특성상 내부 비즈니스 로직이 있다기보다는 외부에서 디렉티브를 제어하면 Summernote의 API를 사용해서 만들고 결과적으로 제어되었는지를 확인해야 하다 보니 아무래도 DOM과 관련된 테스트가 많았다. Karma가 가진 특성들이 있어서 DOM을 다루기가 좀 만만치 않았기 때문에(특히 focus 이벤트) 실제론 제대로 동작하지만 유닛테스트 내에서 확인이 제대로 되지 않는 문제가 많이 있었다.(실제 오류인 것도 있었지만...)

DOM을 다뤄야 하니 e2e테스트인 Protractor로 넘어가야 하나 생각도 많이 했지만, 테스트 자체는 유닛테스트라고 생각했고 Protractor는 아직 안 써봤기에 그냥 Karma를 공부도 할 겸 계속 사용했다. 그래도 결과적으로는 공부도 많이 했고 이미지 업로드 외에는 얼추 테스트도 다 만들었다.

맺음말

모듈로써 제공하려고 하다 보니 확실히 내 비즈니스 로직에 한정해서 만들 때보다 훨씬 많은 고민이 되었고 공부도 많이 되었다. 덕분에 잘 알지 못하던 AngularJS의 디렉티브에 대해 상당히 많이 알게 됐다. 많이 고민한 덕에 구조는 제법 괜찮게 나온듯하지만 실제로 사용자들이 피드백을 주기 전에는 제대로 되었는지는 확신하기 어렵다. 특히 설정값 같은 경우 다른 디렉티브는 constant를 만들어서 주입하고 사용했는데 만들고 보니 이 constant객체가 디렉티브 사이에 간섭이 일어나 버려서 분리되도록 만들어 버렸다.

한 일주일 정도 angular-summernote를 만드는데 꽂혀있었는데 너무 길어졌으면 좀 힘들었을 텐데 적당한 기간에 마무리해서 릴리즈를 할 수 있었다. 다만 Karma를 사용한 테스트가 로컬에선 괜찮았는데 Travis CI에서는 Firefox와 PahntomJS에서 모두 오류가 발생해서 릴리즈 하자마자 Fail상태로 표시돼서 찝찝함이 남아있긴 하는데 체력이 좀 소모되어 차차 해결하기로 했다.(아~ 찝찝해..)

2014/01/20 20:47 2014/01/20 20:47