저는 “프로그래밍은 복잡성을 관리하는 방법이다”라는 말을 좋아합니다. 여러분은 아마 “컴퓨터의 세계는 추상화로 세워진 거대한 건축과 같다”는 말도 들어보셨을 겁니다. 우리는 작업들을 간단하게 래핑(wrapping)하고 새로운 도구를 끊임없이 만들어냅니다. 잠시만 생각해 봅시다. 여러분이 사용하는 언어들은 내장된 기능들이 있고, 그러한 내장 기능들은 저수준의 동작을 추상화한 함수들일 것입니다. 이것은 JavaScript에서도 마찬가지입니다. 어쨌거나 우리는 다른 개발자가 만든 추상화를 필요로 합니다. 저는 의존성이 없는 모듈을 좋아하지만, 의존성 없는 모듈의 개발은 상당히 힘듭니다. 블랙박스로 작동하는 컴포넌트들을 잘 만들어 놓는다고 하더라도, 결국 컴포넌트들을 조합하는 부분이 필요합니다. 바로 그 지점에서 의존성 주입(DI, dependency injection)이 사용됩니다. 의존성을 효과적으로 관리하는 능력은 꼭 필요합니다. 이 글에는 제가 의존성 관리와 관련해 가지고 있던 문제를 정리했습니다. 1)
두 개의 모듈이 있다고 가정합니다. 첫 번째는 Ajax 요청을 작성하는 서비스이고, 다른 하나는 라우터입니다.
var service = function() { return { name: 'Service' }; } var router = function() { return { name: 'Router' }; }
이 두 모듈을 필요로 하는 또다른 함수도 있습니다.
var doSomething = function(other) { var s = service(); var r = router(); };
그리고 약간의 재미를 위해서 이 함수는 매개변수를 하나 더 받는 다고 해 봅시다. 당연히 우리는 위의 코드를 그대로 사용할 수도 있겠지만, 이런 방식은 그다지 유연하지 않습니다. 우리가 ServiceXML
혹은 ServiceJSON
을 사용하려고 한다면 위의 코드는 재작성돼야 합니다. 또는 테스트를 위해서 일부 모듈을 목업(mockup)하는 경우도 생길 수 있습니다. 함수의 내용을 그때마다 수정하지 않고 문제를 해결할 수는 없을까요. 가장 먼저 떠오르는 방법은, 아래와 같이 의존성을 매개변수로 함수에 전달하는 방법입니다.
var doSomething = function(service, router, other) { var s = service(); var r = router(); };
이렇게 함으로써, 우리는 필요로 하는 모듈의 구현을 그대로 함수에 전달할 수 있게 됩니다. 하지만 이 방식은 새로운 문제를 일으킵니다. 우리가 doSomething
을 이미 온데사방에서 사용하고 있는데, 세 번째 의존성을 추가해야 한다면 어떻게 될까요. 모든 호출을 일일이 수정할 수는 없습니다. 그래서 우리는 그러한 작업을 대신 해 줄 도구가 필요합니다. 의존성 주입은 바로 이러한 문제를 해결하는 방법입니다. 우리가 구현하려는 목표를 적어봅시다.
멋진 목록이네요. 본격적으로 시작해 봅시다.
여러분도 requirejs는 이미 알고 계실 겁니다. 의존성을 처리하는 좋은 방법이죠.
define(['service', 'router'], function(service, router) { // ... });
먼저 필요한 의존성을 기술하고 나서 함수를 작성하는 게 기본적인 원리입니다. 인수의 순서는 당연히 중요합니다. requirejs와 같은 구문으로 작동하는 injector
모듈을 만들어 봅시다.
var doSomething = injector.resolve(['service', 'router'], function(service, router, other) { expect(service().name).to.be('Service'); expect(router().name).to.be('Router'); expect(other).to.be('Other'); }); doSomething("Other");
원주: 계속 진행하기 전에 잠시만doSomething
함수의 내용을 살펴봅시다. 저는 코드가 제대로 작동하는지 확인하기 위해서 expect.js를 사용했습니다. 일종의 TDD적인 접근이죠.
injector
은 다음과 같이 구현됩니다. 싱글턴 패턴으로 작성하면 프로그램의 모든 지점에서 사용할 수 있습니다.
var injector = { dependencies: {}, register: function(key, value) { this.dependencies[key] = value; }, resolve: function(deps, func, scope) { } }
저장소 역할을 하는 변수 한 개와 함수 두 개를 갖고 있는 아주 간단한 객체입니다. 이제 deps
행렬을 확인해서 dependencies
변수에 들어갈 의존성을 찾아내야 합니다. 그 다음에는 func
함수의 .apply
메서드를 호출하기만 하면 됩니다.
resolve: function(deps, func, scope) { var args = []; for(var i=0; i<deps.length, d=deps[i]; i++) { if(this.dependencies[d]) { args.push(this.dependencies[d]); } else { throw new Error('Can\'t resolve ' + d); } } return function() { func.apply(scope || {}, args.concat(Array.prototype.slice.call(arguments, 0))); } }
이렇게 하면 scope
가 지정되면 해당 유효범위를 함수 호출에 사용합니다. Array.prototype.slice.call(arguments, 0)
는 arguments
변수를 실제 핼렬로 변환하기 위해서 꼭 필요합니다. 2) 지금까지는 아주 좋습니다. 테스트도 잘 통과합니다. 하지만 이 방식에서는 필요한 컴포넌트의 이름을 두 번이나 써야 하고, 그 순서도 편리하게 바꿀 수 없습니다. 인수를 추가하는 것도 모든 의존성을 적고 나서야 할 수 있습니다.
위키피디아에 따르면 반영(reflection)이란 프로그램이 객체의 구조와 동작을 런타임에 확인하고 수정할 수 있는 능력입니다. 그러므로 간단히 말해서 JavaScript에서의 반영이란 객체나 함수를 기술하는 소스코드를 읽고 분석하는 동작을 뜻합니다. 위에서 만든 doSomething
함수를 처음부터 다시 살펴봅시다. doSomething.toString()
을 출력시켜보면 다음과 같은 문자열이 나올 것입니다.
"function (service, router, other) { var s = service(); var r = router(); }"
메서드가 문자열로 변환되었기 때문에 메서드가 사용하는 매개변수를 알아낼 수 있게 되었습니다. 그리고 그 매개변수들의 이름도 알게 되었다는 것이 중요합니다. Angular에서의 의존성 주입은 바로 이런 방식으로 구현되었습니다. Angular의 소스코드를 살짝 컨닝해 보면 다음과 같은 정규식을 사용하고 있습니다.
/^function\s*[^\(]*\(\s*([^\)]*)\)/m
이 정규식을 사용해서 resolve
를 다음과 같이 수정할 수도 있습니다.
resolve: function() { var func, deps, scope, args = [], self = this; func = arguments[0]; deps = func.toString().match(/^function\s*[^\(]*\(\s*([^\)]*)\)/m)[1].replace(/ /g, '').split(','); scope = arguments[1] || {}; return function() { var a = Array.prototype.slice.call(arguments, 0); for(var i=0; i<deps.length; i++) { var d = deps[i]; args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift()); } func.apply(scope || {}, args); } }
함수 정의에 대해서 정규식을 사용해서 매칭하면 결과는 다음과 같습니다.
["function (service, router, other)", "service, router, other"]
우리는 매칭 결과에서 두 번째 원소만 있으면 됩니다. 문자열에서 공백을 제거하고 split하면 deps
를 얻을 수 있습니다. 그리고 위에서는 한 가지 작업을 더 수행하고 있습니다.
var a = Array.prototype.slice.call(arguments, 0); ... args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift());
필요한 의존성을 하나 씩 확인하면서 dependencies
에서 찾을 수 없는 의존성은 arguments
에서 찾아옵니다. 고맙게도 shift
메서드는 행렬이 비어있으면 오류를 발생시키지 않고 undefined
를 반환합니다. 새로 만든 injector
는 다음과 같이 사용할 수 있습니다.
var doSomething = injector.resolve(function(service, other, router) { expect(service().name).to.be('Service'); expect(router().name).to.be('Router'); expect(other).to.be('Other'); }); doSomething("Other");
의존성을 중복해서 적을 필요도 없고, 순서를 섞어도 문제 없습니다.
하지만 반영(reflection)을 통한 주입에는 큰 문제가 하나 있습니다. 소스코드를 최소화(minification, uglification)하면 오류가 생깁니다. 최소화 작업은 매개변수의 이름을 변경시키는데, 변경된 이름으로는 의존성을 처리할 수 없기 때문입니다.
var doSomething=function(e,t,n){var r=e();var i=t()}
위의 코드가 최소화(minification) 처리된 우리의 doSomething
함수입니다. Angular팀에은 아래와 같은 방법으로 이 문제를 해결했습니다.
var doSomething = injector.resolve(['service', 'router', function(service, router) { }]);
이래서는 처음에 사용한 방법과 다를 게 없어 보입니다. 저는 개인적으로 두 방법을 절충한 다음 방법이 좋다고 생각합니다.
var injector = { dependencies: {}, register: function(key, value) { this.dependencies[key] = value; }, resolve: function() { var func, deps, scope, args = [], self = this; if(typeof arguments[0] === 'string') { func = arguments[1]; deps = arguments[0].replace(/ /g, '').split(','); scope = arguments[2] || {}; } else { func = arguments[0]; deps = func.toString().match(/^function\s*[^\(]*\(\s*([^\)]*)\)/m)[1].replace(/ /g, '').split(','); scope = arguments[1] || {}; } return function() { var a = Array.prototype.slice.call(arguments, 0); for(var i=0; i<deps.length; i++) { var d = deps[i]; args.push(self.dependencies[d] && d != '' ? self.dependencies[d] : a.shift()); } func.apply(scope || {}, args); } } }
resolve
메서드는 매개변수를 두 개 받을 수도 있고 세 개 받을 수도 있습니다. 두 개가 들어온다면 조금 앞의 방식과 똑같이 작동하지만, 세 개가 들어오면 첫 번째 매개변수를 파싱해서 deps
행렬을 만들게 됩니다. 예를 들어서 다음과 같이 사용할 수 있습니다.
var doSomething = injector.resolve('router,,service', function(a, b, c) { expect(a().name).to.be('Router'); expect(b).to.be('Other'); expect(c().name).to.be('Service'); }); doSomething("Other");
쉼표가 두 개 있는 것을 확인할 수 있습니다. 오타가 아닙니다. 쉼표 사이의 빈 값은 "Other"
매개변수를 나타냅니다. 매개변수의 순서를 이런 식으로 관리할 수 있습니다.
저는 가끔 앞의 두 방식과는 다른 제3의 방법을 사용하기도 합니다. 함수의 유효범위를 직접 조작하는 방법입니다. (다시 말해서, this
객체를 조작한다고도 할 수 있습니다.) 그래서 이 방법을 사용하기엔 부적합한 경우도 있습니다.
var injector = { dependencies: {}, register: function(key, value) { this.dependencies[key] = value; }, resolve: function(deps, func, scope) { var args = []; scope = scope || {}; for(var i=0; i<deps.length, d=deps[i]; i++) { if(this.dependencies[d]) { scope[d] = this.dependencies[d]; } else { throw new Error('Can\'t resolve ' + d); } } return function() { func.apply(scope || {}, Array.prototype.slice.call(arguments, 0)); } } }
의존성을 유효범위에 추가하기만 하면 됩니다. 이 방법을 사용하면 의존성을 매개변수에 다시 적을 필요가 없다는 장점이 있습니다. 의존성은 함수의 유효범위에 직접 추가됩니다.
var doSomething = injector.resolve(['service', 'router'], function(other) { expect(this.service().name).to.be('Service'); expect(this.router().name).to.be('Router'); expect(other).to.be('Other'); }); doSomething("Other");
개발자들은 누구나 의존성 주입을 사용하지만 깊게 생각해 보지는 않는 주제입니다. 여러분도 용어 자체는 들어보지 못했다고 하더라도 분명히 수 만 번 씩 사용해 왔을 겁니다.
이 글에서 사용된 모든 예제는 이곳에서 확인할 수 있습니다.
arguments
는 키(key)가 '0'
, '1'
, '2'
, …로 이루어져 있어서 행렬(array)로 착각하기 쉽지만 분명히 Object
입니다. Array.prototype.slice.call(arguments, 0)
를 사용하면 Object
에는 없는 Array.prototype.slice
메서드의 구현을 활용해서 행렬-같은(array-like) 값을 실제 행렬로 쉽게 변환할 수 있습니다.