작동방식
nwjs를 이용하여 작동되는 웹사이트를 데스크탑 어플리케이션처럼 만드는 방법에 관해서 소개를 해봅니다. 우리가 이 글에서 논하고자하는 것은 웹기술을 이용하여 독립된 어플을 만드는 일반적인 SPA형 어플리케이션이 아니라, 그냥 웹서비스로 접속해도 되는 것을 단순하게 어플리케이션으로 랩핑만하여 제공하는 것에 대한 이야기입니다.
'어차피 브라우저를 통해서 접속하면 되는 것을 굳이 어플리케이션처럼 만들어서 뭐가 좋은가?'라고 할 수 있습니다. 사실 이미 만들어진 것을 랩핑하는 것이라서 환경을 셋팅하는 것 이외에는 그다지 힘이 들지 않습니다. 하지만 단순하게 랩핑하는 것만으로도 아래와 같은 많은 장점들이 있습니다.
1. 독립된 마켓 이용에 따른 마케팅 효과 증대
웹 서비스를 런칭한 이후에는 우리는 많은 광고비를 뿌려 구글과 페이스북에서 사용자들을 유입시키려 노력합니다. 그럼에도 불구하고 웹서비스는 영원히 접근할 수 없는 앱 마켓라는 벽이 존재합니다. 아직까지 윈도우스토어는 활성화되어 있지 않지만 OSX의 경우에는 앱마켓의 접근성이 좋아서 많은 분들이 사용합니다. nwjs를 통해서 랩핑을 하는 경우에 이와 같은 마켓에 등록하여 마케팅 채널을 하나 확보하는 셈이 될 수 있습니다. 와 공짜 마케팅이다
2. 웹키트 기반의 렌더링 사용률 증대
익스플로러 최신버전은 HTML5나 CSS3 스펙을 무리없이 사용가능합니다. 그러나 아직까지 한국에서는 많은 사용자들이 익스플로러 엣지 이전버전을 사용하기 때문에, 웹서비스를 지속적으로 제공함에 있어서 의식할 수 밖에 없는 문제들이 있습니다. 죽어라 익스플로러!! 조삼모사같이 느껴질 수도 있지만, 이러한 고객분들에게 nwjs로 랩핑된 데스크탑 어플리케이션 설치를 유도하면 자연스럽게 지속적으로 웹키트 엔진으로의 사용을 유도하게 됩니다.
3. 작업 표시줄에서 독립된 접근성 확보 가능
독립된 프로그램처럼 작동한다는 것은 곧 작업표시줄에서 브라우저안에 탭으로 존재하는 다른 서비스와 다르게 직접적인 접근성을 확보할 수 있다는 이야기가 됩니다. 창이 따로 논다는 것은 사용자의 활용도에 따라 여러가지로 이점을 제공합니다. 가상 화면을 통해서 하거나 단축키를 통해서 사용되어질 수도 있고, 특정 창크기로 고정하여 사용하게될 수도 있습니다.
4. 네이티브 API의 활용
브라우저상에서 동작하도록 설계된 원래의 웹사이트 목적상, 아무래도 랩핑한 프로그램에서 네이티브 API를 많이 활용하지는 않을 것입니다. 그래도 조건부라도 마음만 먹으면 활용할 수 있으니 일단 장점에 포함된다 생각합니다. nwjs가 활용할 수 있는 네이티브의 목록은 여기
에 있습니다.
5. 제작 및 유지보수의 비용 최소화
랩핑을 하는 최초의 과정 이외에 추가적으로 드는 비용이 거의 제로에 가깝습니다. nwjs 전용으로 모든 정적 리소스들을 사용하는 경우라면 조금 다를지 모르겠습니다. 그런데 실제로 돌아가는 사이트를 랩핑하는 것은 모든 자원을 원격에서 끌어와서 동작시키는 것이라, 사실상 이 프로그램은 우리의 웹사이트 하나만을 띄우는 전용 브라우저가 되는 셈이 됩니다.
NWJS란?
이전에는 Node-Webkit이라고 불리었으며, 요새 유행하는 Electron(aka atom-shell)과 비슷하게 Chromium과 Node.js 기반에 데스크탑 어플리케이션에서 웹을 구동할 수 있는 좋은 도구입니다. 그래서 데스크탑에서 실행하는 어플리케이션에다가 기존 웹 개발 방법대로 설계 코딩할 수 있습니다.
장점
- HTML5, CSS3, Javascript과 같은 웹기술 사용가능
- Node.js API 와 서드파티 모듈 지원
- DOM 과 Web-Workers를 Node.js에서 호출 가능
- Javascript 소스 보호
- MacOS, WindowOS, LinuxOS 모두 호환
NWJS vs Electron
NWJS vs Electron 에서 둘을 비교한 표를 볼수 있습니다. 간단히 살펴보면,
- 브라우저 런타임용에 차이가 있습니다. NWJS는 Chromium을 바로 사용하지만, Electorn은 Chromium을 커스텀을 통해서 빌드 복잡도를 낮추기 위해 libchromiumcontent을 사용합니다.
- Entry Point가 다릅니다. NWJS는 HTML,Javascript 둘다 사용되고, Electron은 Javascript만을 제공합니다.
- Chrome App과 API를 지원 하는 차이 유무입니다.
- 가장 중요한 이슈 해결시간과 이슈 발생 빈도입니다.. NWJS가 좀 불리합니다.
- 하지만 Github Watch, Start, Fork는 별차이가 없습니다.
최초에는Electron을 이용하여 제작을 시도하였으나, Electron의 webview
를 썼을 때에 기본 자바스크립트의 confirm
, alert
, prompt
, beforeunload
등이 정상적으로 작동되지 않는 문제점이 있었습니다. 즉, 웹브라우저라면 당연히 되어야하는 스펙들이 Electron Webview에서는 알게모르게 제한이 되는 경우가 있습니다. 분명 안되는것이 더 많겠지만 이쯤에서 깔끔하게 포기하고 nwjs로 돌아서서 더 이상은 모르겠습니다. Electron이 대세인 느낌은 있지만 이렇게 랩핑을 하는 목적으로는 적합하지 않았습니다.
제작방법
1. 설치
2. 디렉토리 구조
항상 개발 처음 시작할 때 디렉토리 구조를 어떻게 잡을까 부터 고민을 하여 보일러플레이트를 참고하거나 자신만의 구조를 잡기 마련입니다. 저희는 처음 해보는 부분이라 보편적인 디렉토리 구조를 따랐습니다.
NWJS는 아래 예시와 같이 production 배포 부분과 빌드하기 위한 부분을 나뉘어 구조를 잡는게 편합니다. 왜냐하면 빌드에 필요한 node modules이나, resources나 그외 빌드 관련 소스들은 production에 포함되지 않아도 상관없기 때문입니다.
주요 부분
- .cache : 빌드에 필요한 리소스 캐싱
- build : 빌드 결과물
- resources : 어플리케이션 icon 관련 리소스
- src : production 용 디렉토리
- index.html : entry point
이외 특이한점은 package.json
가 빌드 디렉토리와 production 디렉토리에 각각 한개씩 존재합니다. 이는 node module 설치를 다르게 하고, NWJS Config 설정, 빌드 스크립특 작성등 각 용도에 맞게 분기 시키기 위함입니다.
3. 요구사항
- 기존 서비스중인 tyle.io를
iframe
으로 호출 - 상단에 Native Menu Bar를 설정
- Window Event(
loaded
)와 Native Window Event Handler(close
) 등록 - Mac(x64)용 dmg, Window(x64,32)용 MSI installer 생성(Linux 제외)
- 어플리케이션 종료 후 열때 종료할때 window size 로 resizing
4. 개발
-
기존 서비스중인 tyle.io를
iframe
으로 호출// etnry point HTML에 iframe 쓰던 방식 적용 <iframe src="https://tyle.io" id="tyle" ></iframe>
-
상단에 Native Menu Bar를 설정
var MenuItems = { "darwin" : { //mac "history" : [ // menu label { label : '뒤로', click : function(){ window.history.back(); }, key: '[', modifiers: 'cmd' }, { label : '앞으로', click : function(){ window.history.forward(); }, key: ']', modifiers: 'cmd' }, { label : '새로고침', click : function(){ document.getElementById('tyle').contentDocument.location.reload(true); }, key: 'R', modifiers: 'cmd' } ] }, "win" : { //window "history" : [ // menu label { label : '뒤로', click : function(){ window.history.back(); }, key: '[', modifiers: 'ctrl' }, { label : '앞으로', click : function(){ window.history.forward(); }, key: ']', modifiers: 'ctrl' }, { label : '새로고침', click : function(){ document.getElementById('tyle').contentDocument.location.reload(true); }, key: 'R', modifiers: 'ctrl' } ] } } // Menu 생성 var addMenu = function(){ var topMenu = new nw.Menu({type: "menubar"}); // mac이랑 winodw,linux는 다른 menu 프로세스를 제공하기 위해 또 Menubar 객체를 할당 var menuBar = topMenu; //darwin===mac 이고, 따로 menubar를 생성 후에 시작. if(process.platform === 'darwin'){ topMenu.createMacBuiltin("tyle"); menuBar = win.menu = topMenu; } // menuItems' 에서 메뉴들을 가져와 등록. for (var menuKey in MenuItems) { if (MenuItems.hasOwnProperty(menuKey)) { var submenu = new nw.Menu(); MenuItems[menuKey].forEach(function(menuItem){ submenu.append(new nw.MenuItem(menuItem)); }); menuBar.append(new nw.MenuItem({ label : menuKey, submenu : submenu })); } } // window,linux 는 따로 menu를 window에 등록. if(process.platform !== 'darwin'){ win.menu = menuBar; } }
-
Window Event(
loaded
)와 Native Window Event Handler(close
) 등록window ifrmae이 호출 된 이후 loaded 이벤트가 호출 됩니다. (
dom ready
랑 비슷 합니다.)
Native Window 를 닫으면 close 이벤트가 호출 됩니다.var nw = require('nw.gui'); // 이벤트 정의 var BrowserEvent = { evtList : ['close','loaded'], close : function(){}, loaded : function(){} } // 필요한 이벤트들 NW.GUI를 통해 등록 var addEvent = function(){ BrowserEvent.evtList.forEach(function(evt){ nw.Window.get().on(evt, BrowserEvent[evt]); },this); }
-
Mac(x64)용 dmg, Window(x64,32)용 MSI installer 생성(Linux 제외)
Grunt-nw-builder를 참고하고, nw-builder 레퍼런스와 비슷합니다.
grunt-nw-builder
는grunt
기반으로 만들어진nw-builder
모듈로써 nwjs를 빌드 패키징 작업을 통해서 저희가 실제로 데스크탑 어플리케이션 APP을 생성시켜주며, 인스톨러도 만들어줍니다. 또한msi-pacakger
는 빌드된 결과물을 window installer로 만들어줍니다.module.exports = function(grunt) { grunt.initConfig({ nwjs: { options: { name : 'tyle', platforms: ['osx64','win64'], macIcns : './src/resources/osx/tyle_logo_electron.icns', winIco : './src/resources/windows/tyle_logo_electron.ico', cacheDir : './.cache', buildDir: './build', flavor: 'normal', zip : true, zipOptions : { forceLocalTime : true, comment : "tyle.io desktop app window version" }, macPlist: { CFBundleDevelopmentRegion: "ko", CFBundleDisplayName: "tyle", CFBundleName: "tyle", } }, src: './src/**/*' // Your NW.js app }, appdmg: { options: { basepath: './', title: 'tyle-mac', icon: 'src/resources/osx/tyle_logo_electron.icns', background: 'resources/osx/dmg-background.png', contents: [ {x: 370, y: 160, type: 'link', path: '/Applications'}, {x: 140 , y: 160, type: 'file', path: 'build/tyle/osx64/tyle.app'} ] }, target: { dest: 'build/tyle/osx64/tyle-mac.dmg' } } }); grunt.loadNpmTasks('grunt-nw-builder'); grunt.loadNpmTasks('grunt-appdmg'); grunt.registerTask('default', ['nwjs','appdmg']); };
// window msi installer var createMsi = require("msi-pacakger"); var options = { // required source: '~/tyle-nw/build/tyle/win64', output: '~/tyle-nw/build/tyle/msi', name: 'tyle', upgradeCode: 'YOUR-GUID-HERE', version: '1.0.0', manufacturer: 'loopjs.com', iconPath: '~/tyle-nw/src/resources/windows/tyle_logo_electron.ico', executable: 'tyle.exe', // optional arch: 'x64', localInstall: true } createMsi(options, function (err) { if (err) throw err console.log('Outputed to ' + options.output) })
-
어플리케이션 종료 후 열때 종료할때 window size 로 resizing
window를 종료할 경우 close 이벤트에서 현재 종료창의 사이즈를 로컬 저장소에
json file
로 저장합니다. 그리고 다시 앱을 열었을 때 기존에 저장해놓은 사이즈를 가져와 window를 리사이징을 진행 하는 방식입니다.tyle.windowSize = (function(){ var jetpack = require('fs-jetpack'); var win = require('nw.gui').Window.get(); var pjson = require('./package.json'); // window size를 저장할 공간(directory) 설정 var userDataDir = jetpack.cwd(nw.App.dataPath); var stateStoreFile = 'window-state-tyle.json'; // 기본 window size (package.json 에서 가져온다) var defaultSize = { width : pjson.window.width, height: pjson.window.height }; // 상태 저장 객체 var state = {}; /* 저장된 window size 반환 */ var restore = function(){ var restoredState = {}; try{ restoredState = userDataDir.read(stateStoreFile, 'json'); }catch(err){ }
return Object.assign({}, defaultSize, restoredState);
} /* 현재 window size 구하기 */ var getCurrentPosition = function(){ return { width : win.width, height : win.height } } /* 현재 window size 저장 */ var saveState = function(){ Object.assign(state, getCurrentPosition()); userDataDir.write(stateStoreFile, state, {atomic : true}); } /* 생성된 window resizing 하 */ var resize = function(){ var resize = restore();
win.width = resize.width; win.height = resize.height;
} return { restore : restore, save : saveState, resize : resize } })();
결과물
위 요구사항에 맞게 개발을 진행하면 아래와 같이 기본적인 골격을 갖추게 됩니다. 전반적인 소스를 공유하진 않았지만, 큰 틀에서 벗어나지 않은 것이기 때문에 쉽게 적용할 수 있습니다.
iframe 방식으로 기존에 개발해놓은 웹을 그대로 가져다 쓸수있고 설치방식과 Chromium 기반이기 때문에 IE에서 겪던 문제를 해결 할 수 있는 좋은 기회를 가질 수 있습니다.
자 다들 시작 해보시죠?
주의사항
위에 기술하였듯이 랩핑하는 것만으로 많은 효과들이 있습니다. 하지만 보다 이질감이 없는 사용자 경험을 위하여 데스크탑 어플과 같은 빠른 반응속도를 보장하여야합니다. 전통적인 페이지 리다이렉션으로 인해서 버튼 클릭마다 blank 화면이 번쩍거린다면 사용자로 하여금 내가 가짜 앱을 사용하고 있다고 느끼게 하기 때문이지요.
따라서 Single Page Application으로 제작하여 Spinner가 표시되더라도 UI가 사용자 행동에 즉각 반응하는 것이 좋고, 페이지 리다이렉션이라면 pjax
등을 통하여 어떻게든 빠른 반응이 나타나도록 하는 것이 좋습니다(참고 : https://tyle.io/blog/52)