이 글은 Michael Phillips 가 2014년 6월, [Crush&Lovely]에 연재한 ‘자바스크립트 어플리케이션 리팩토링을 위한 7가지 패턴’에 대해 번역한 것이다.
들어가며
2012년 10월 17일, Bryan Helmkamp, Code Climate 설립자가 Ruby on Rails의 액티브 레코드 모델들(대표적인 객체 레이어들)을 리팩토링할 7개의 패턴을 요약하여 블로그 포스트를 작성했다.
여기 [Crush & Lovely]의 포스트는 모든 Rails개발자를 위해 어떻게 문제를 분리하고, 모듈 형으로 작성하며, 간결하면서도 표현적인 코드를 만들고, 매우 간단한 테스트를 만드는 지에 대해 설명하는 핵심 레퍼런스가 될 것이다.
이 시리즈의 포스트들은 자바스크립트 환경에서 다음과 같은 개념에 대해 직접 보여준다 ; 자바스크립트의 데이터 모델보다 더 적합하거나 거기에 필적할 만한 것은 없다.
매주, 일곱 패턴 중 하나를 설명한다. 이번 주는 Value Object에 대해 이야기할 것이다.
Patterns
1) Value Objects
Bryan의 글에는 Value Object를 이렇게 묘사했다. “비교 연산 시 자신의 상태보다 값(value)을 우선하는 단순한 객체다.”
자바스크립트는 모든 객체에 대하여 “참조를 통한 전달”(pass-by-reference) 원칙을 준수하기 때문에, ECMAScript 5 나 Harmony(ECMAScript5 이후 버전들)에는 원시 타입을 저장하는 예제는 없다.
예를 들면:
var foo = new Number( 2 ); var bar = new Number( 2 ); foo === bar; // => false
첫 번째 예제에서는 원시 타입 정수를 변수 foo와 bar에 바로 할당했다. 비록 이들의 값은 같을지라도 원시 요소들은 기술적으로 여전히 자바스크립트의 객체임을 알 수 있다. (역자: Java와 같은 언어에는 new Integer(2);와 같은 것은 원시 타입임으로 true로 평가된다. 이와 달리 자바스크립트에서는 foo와 bar 각각이 다른 객체임으로 false로 평가된다.) 정수 생성자가 원시 타입의 래퍼를 제공할지라도, 값이 아닌 참조에 의해 비교 연산을 하는 그저 평범하고 구식의 자바스크립트 객체일 뿐이다. 따라서, 두 번째 예제에서 foo와 bar는 Number 인스턴스로 동일한 정수 값을 나타내고 있지만 완전히 똑같지 않다.
하지만, 값 객체는 도메인 로직이 존재하기 위한 좋은 장소를 제공한다. 어플리케이션에서 거의 모든 값들은 관련된 로직을 지니고 있고(예, 비교 연산) 그 로직이 존재할 최적의 장소는 값 객체의 인스턴스다.
Example
학생의 등급 어플리케이션을 살펴보자. 여기서 학생들의 백분율 점수 집계는 문자 등급 성적을(역주. A,B,C 등급 등을 나타냄) 할당하고, 학생이 통과했는지 또는 개선 여부를 결정하는 데 사용된다.
var _ = require('underscore'); var Grade = function( percentage ) { this.percentage = percentage; this.grade = this.grade( percentage ); }; Grade.prototype = _.extend( Grade.prototype, { grades: [ { letter: 'A', minimumPercentage: 0.9, passing: true }, { letter: 'B', minimumPercentage: 0.8, passing: true }, { letter: 'C', minimumPercentage: 0.7, passing: true }, { letter: 'D', minimumPercentage: 0.6, passing: true }, { letter: 'F', minimumPercentage: 0, passing: false } ], passingGradeLetters: function() { return _.chain( this.grades ).where({ passing: true }).pluck('letter').value(); }, grade: function( percentage ) { return _.find( this.grades, function( grade ) { return percentage >= grade.minimumPercentage; }); }, letterGrade: function() { return this.grade.letter; }, isPassing: function() { return this.grade.passing }, isImprovementFrom: function( grade ) { return this.isBetterThan( grade ); }, isBetterThan: function( grade ) { return this.percentage > grade.percentage; }, valueOf: function() { return this.percentage; } }); module.exports = Grade;
이는 추가적으로 코드 베이스가 더 많은 표현을 할 수 있다는 이점이 있고, 다음과 같은 두 코드를 가능하게 한다:
var firstStudent = { grade: new Grade(0.45) }; var secondStudent = { grade: new Grade(0.70) }; firstStudent.grade.isPassing() //=> false firstStudent.grade.isBetterThan( secondStudent.grade ); //=> false
어플리케이션에 값 객체를 통합함에 있어 몇 가지 주의 사항이 있다:
ECMAScript 명세에 의해 ‘valueOf, toString’ 메소드들은 특별한 목적을 가지고 있으며, 모든 값 객체에 대해 제공하고 있다. 위 예제의 새로 정의한 valueOf 메소드를 사용하여 표준 ECMASCript 구문을 확인해 볼 수 있다.
var myGrade = new Grade(0.65); alert('My Grade is ' + myGrade + '!'); // alerts, 'My Grade is 0.65!' var myOtherGrade = new Grade(0.75); myGrade < myOtherGrade; // true
만약 두 객체가 valueOf 메소드를 통해 같은 값을 반환할지라도, 여전히 === 연산을 통해 같다고 평가되진 않는다.
var myGrade = new Grade(0.65); var myOtherGrade = new Grade(0.65); myGrade === myOtherGrade; // false
JSON.stringify를 이용하여 값 객체를 JSON 객체로 변환할 때, 컨벤션은 stringified하길 원하는 값을 반환하는 toJSON 메소드를 정의한다. 만약 toJSON 메소드가 정의되지 않았다면, JSON.stringify는 valueOf 메소드를 평가할 것이다. 만약 valueOf 메소드도 정의되지 않았다면, (대부분 바라지 않겠지만) 객체는 객체로 평가될 것이다.
좋은 패턴은 오브젝트가 초기화 된 값과 동일한 값을 반환하는 valueOf 메소드가 있는 것이다. 만약 어플리케이션의 서버 단과 클라이언트 단에서 같은 값 객체를 함께 사용하고 있다면 특히 유용하다. 같은 값을 사용하는 인풋과 아웃풋이 있다면, 서버 단에서 값 객체를 사용해서 처리한 후, valueOf 메소드를 사용할 클라이언트에 값을 전달하고, 클라이언트 단에서 다시 재생성 한다.
값 객체에 접근하는 더 함수형인 프로그래밍을 선호한다면, 메소드를 프로토타입이 대신 생성자 함수에 추가할 수 있다. 다음 예제를 살펴보자:
Grade.equal = function( grade1, grade2 ) { return grade1.valueOf() === grade2.valueOf(); } var myFirstGrade = new Grade( 0.7 ); var mySecondGrade = new Grade( 0.7 ); Grade.equal( myFirstGrade, mySecondGrade ) // => true
객체 지향적 접근과 함수형 접근 모두 가능하고, 단지 사용자의 방식에 따라 달라질 뿐이다.
Testing
하나의 객체에 로직을 집중시키기 때문에, 테스트는 더 쉽고 빠르게 할 수 있고, 많은 어플리케이션 로직을 커버하기 위한 데스트 세트는 더 작을 수 있다. 다음 테스트를 살펴보자:
var Grade = require('./grade'); var grade1; var grade2; describe('Grade', function() { describe('#isPassing', function() { it('returns true if grade is passing', function() { grade1 = new Grade(0.8); expect(grade1.isPassing()).to.be.true; }); it('returns false if grade is not passing', function() { grade1 = new Grade(0.58); expect(grade1.isPassing()).to.be.false; }) }); describe('#letterGrade', function() { it('returns correct letter for percentage', function() { grade1 = new Grade(0.8); expect(grade1.letterGrade()).to.equal('B'); }); it('returns A for 100 percent', function() { grade1 = new Grade(1); expect(grade1.letterGrade()).to.equal('A'); }); it('returns F for 0 percent', function() { grade1 = new Grade(0); expect(grade1.letterGrade()).to.equal('F'); }); it('returns F for anything lower than 0.6', function() { grade1 = new Grade(0.4); expect(grade1.letterGrade()).to.equal('F'); }); }); describe('#passingGradeLetters', function() { it('returns all passing letters', function() { grade1 = new Grade(0.8); expect(grade1.passingGradeLetters()).to.have.members(['A', 'B', 'C', 'D']); }); }); describe('#isImprovementFrom', function() { it('returns true if grade is better than comparison grade', function() { grade1 = new Grade(0.8); grade2 = new Grade(0.7); expect(grade1.isImprovementFrom( grade2 )).to.be.true; }); it('returns false if grades are equal', function() { grade1 = new Grade(0.7); grade2 = new Grade(0.7); expect(grade1.isImprovementFrom( grade2 )).to.be.false; }); }); describe('#isBetterThan', function(){ it('returns true if grade is better than comparison grade', function() { grade1 = new Grade(0.8); grade2 = new Grade(0.7); expect(grade1.isImprovementFrom( grade2 )).to.be.true; }); it('returns false if grades are equal', function() { grade1 = new Grade(0.7); grade2 = new Grade(0.7); expect(grade1.isImprovementFrom( grade2 )).to.be.false; }); }); });
이처럼 값 객체를 테스트하는 이점은 테스트를 위한 설정이 더 이상 쉬울 수 없다는 것이다. 여러 순열을 테스트하는 것은 당신이 가짜 모델을 만들거나, 복잡한 로직을 작성하지 않도록 신속하고 효율적이다.
또한, 모델의 테스트와 로직이 분리됨으로써 테스트 세트는 더 작아지고 더 집중적일 수 있다.
다음 포스트에선 프로시져 코드를 분리하는데 최적의 도구인 서비스 객체에 대해 알아보겠다.
2) Service Objects
서비스 객체란 개별 작업이나 동작을 수행하는 객체이다. 프로세스가 복잡해지고, 테스트가 어려워지거나, 여러 타입의 모델들을 건드려야 할 때, 서비스 객체는 코드 베이스를 깔끔하게 만드는데 도움을 준다.
서비스 객체의 목표는 작업을 분리하고, 다음 원칙들을 따르는 것을 목표로 한다.
입력과 출력에 엄격해라. 서비스 객체는 매우 세밀하게 프로세스를 처리하도록 구성되어야 아주 별개의 목적을 위한 도구를 만드는 데 찬성하는Robustness Principle (Be tolerant of inputs, strict on outputs ; 입력에 관대하고 출력에 엄격하라) 을 선행 할 수 있다.
철저하게 문서화 하라. 이 모듈은 실행되는 위치에서 추출할 수 있는데, 따라서 객체의 의도와 사용이 더욱 필수적으로 잘 설명되어야 한다.
작업이 완료되면 종료한다. 이 패턴은 간격을 설정하거나, 계속적으로 웹 소켓 메시지를 듣거나, 즉각적으로 끝나지 않는 다른 작업과 같은 작업자 프로세스에 포함돼서는 안된다. 서비스 객체는 호출된 후 즉각적으로 작업을 수행하고(동기적이던 비동기적이던) 종료해야 한다.
Example
학생 성적 평가를 해야 하는 선생님을 위해 만들어 진 프로그램은 학생이 통과했는지 여부를 판단하는 연말 프로세스가 있다. 이 프로세스에는 평가될 모든 과제들과 백분율 점수의 평균을 가지고 학생에게 등급을 부여한다.
var _ = require('underscore'); var DetermineStudentPassingStatus = function( student ) { this.student = student; } DetermineStudentPassingStatus.prototype = _.extend( DetermineStudentPassingStatus.prototype, { minimumPassingPercentage: 0.6, fromAssignments: function( assignments ) { return _.compose( this.determinePassingStatus.bind( this ), this.averageAssignmentGrade, this.extractAssignmentGrades )( assignments ); }, extractAssignmentGrades: function( assignments ) { return _.pluck( assignments, 'grade' ); }, averageAssignmentGrade: function( grades ) { return grades.reduce( function( memo, grade ) { return memo + grade.percentage; }, 0) / grades.length; }, determinePassingStatus: function( averageGrade ) { return averageGrade >= this.minimumPassingPercentage; } }); module.exports = DetermineStudentPassingStatus;
이 로직을 단일 모듈로 추출하면, 이 작업과 관련된 향후 연결 고리들의 중심을 제공하게 된다.
예를 들면, 학생이 통과하지 못했을 경우, 그의 부모님에게 이메일을 전송해야 할 필요가 있다. 우리는 새로운 메소드를 추가하거나, 아니면 더 나은 방법으로 다른 서비스 객체를 사용하여 객체의 작업 흐름에 손쉽게 추가 할 수 있다.
Testing
작업의 복잡성이 커질 경우에도, 테스트 스위트(역주. 여러 개의 테스트를 묶은 그룹)는 여전히 단일 작업에 초점을 맞춰 머무를 수 있고, 큰 테스트 파일이나 번거로운 환경 준비를 방지할 수 있다.
var expect = require('chai').expect; var DetermineStudentPassingStatus = require('./determineStudentPassingStatus'); var Grade = require('./grade'); describe('DetermineStudentPassingStatus', function(){ var student = {}; var assignments = [ {grade: new Grade(0.5)}, {grade: new Grade(0.8)}, {grade: new Grade(0.9)}, {grade: new Grade(0.6)}, ]; var determineStudentPassingStatus = new DetermineStudentPassingStatus( student ); describe('#extractAssignmentGrades', function(){ it('returns an array of grade value objects', function(){ var grades = determineStudentPassingStatus.extractAssignmentGrades( assignments ); expect( grades[0] ).to.be.an.instanceof( Grade ); }); }); describe('#averageAssignmentGrade', function(){ it('returns the average of all of the grades', function(){ var grades = determineStudentPassingStatus.extractAssignmentGrades( assignments ); var averageGrade = determineStudentPassingStatus.averageAssignmentGrade( grades ); expect( averageGrade ).to.equal( ( (0.5 + 0.8 + 0.9 + 0.6) / 4 ) ); }); }); describe('#determinePassingStatus', function(){ it('returns whether or not the student is passing', function(){ var grades = determineStudentPassingStatus.extractAssignmentGrades( assignments ); var averageGrade = determineStudentPassingStatus.averageAssignmentGrade( grades ); var passing = determineStudentPassingStatus.determinePassingStatus( averageGrade ); expect( passing ).to.be.true; }); }); describe('#fromAssignments', function(){ var passing; it('returns the correct passing state', function(){ passing = determineStudentPassingStatus.fromAssignments( assignments ); expect( passing ).to.be.true; // overwrite to test false return assignments = [ {grade: new Grade(0.5)}, {grade: new Grade(0.4)}, {grade: new Grade(0.8)}, {grade: new Grade(0.6)}, ]; passing = determineStudentPassingStatus.fromAssignments( assignments ); expect( passing ).to.be.false; }); }); });
서비스 객체는 코드를 깔끔히 하고 리팩토링 하는 데 매우 유용한 도구가 될 수 있다. 또한 작업을 분리하는 것은 로직을 깔끔하게 유지하고, 정돈하며, 테스트를 쉽게 한다. 그리고 궁극적으로 코드 베이스를 쉽게 유지보수 할 수 있도록 한다.
다음 포스트에선, Form 검증과 낮은 지속성을 가지며 특정 문맥에 맞게 만들 수 있는 Form 객체 에 대해 살펴볼 것 이다.
3) Form Objects
Form은 종종 복잡한 로직이 적용되어 있다. 일반적으로, 로직은 ‘유효성’, ‘지속성’ 혹은 ‘다른 연산’, 그리고 ‘피드백’ 같은 분야로 나누어진다 .
Form 객체는 포커스를 유지하고, 분리하며, 테스트를 쉽게 하는 것과 같은 모든 관련 로직을 하나의 객체로 캡슐화 할 수 있다. 만약 계정을 만드는 등록 폼이 있다면, 관련된 폼 객체는 다음 로직에 따라 처리 될 것이다.
필요한 모든 필드들이 존재하는지 확인한다.
모든 값이 유효한지 확인한다.
모든 데이터는 데이터베이스에 유지한다.
사용자에게 성공이나 실패에 대한 피드백을 제공한다.
중앙 집중식 모델 대신 폼 객체에 모델 검증을 배치한다면, 동일한 모델에 영향을 미치는 여러 폼 객체에서 이러한 유효성 검사를 반복해야 할 수 있기 때문에 반-직관적일 지도 모른다. 여기서 한 가지 물음이 있다: 검증은 양식 제출의 라이프 사이클 중 어디에 배치할 것인가?
개인적으론, 검증을 데이터베이스보단 폼(양식) 가까이에 두는 것을 선호하는데 이렇게 하면 빠른 피드백을 제공할 수 있게 된다. 검증은 모델 정의에 깊게 있는 것 보다 폼 전송 클릭의 다른 편에 있어야 한다는 것이 더 의미론 적으로 느껴질 것이다. 또한 이런 방법은 특정 상황에서 검증을 통해 미세 조정 제어 제공하고 모델에 대한 모든 시나리오를 대비하지 않는다.
사실, 좀 더 고차원적으로 생각한다면, 폼 객체에서 ‘모델’의 개념을 덜어내고, ‘모델’객체는 데이터 엑세스 객체나 DAO처럼 다뤄야 할 것 이다. 만약 이게 사실이 되려면 모델과 폼 객체 사이에는 모델로 전달될 값이 순수하다는 신뢰의 결합이 있어야 한다. Form은 어플리케이션의 구조적 관점에서 정말 좋은 디자인 패턴이 될 수 있다.
다음 두 가지의 예제를 살펴보자, 하나는 전체 폼 객체가 모든 폼 동작들을 커버해서 검증하고, 다른 하나는 다른 컴포넌트들과 병렬될 수 있는 검증 객체가 있다.
Example
교사가 해당 학년에 새로운 학생을 추가하는 어플리케이션을 생각해보자. 이 어플리케이션은 프로세스 처리 흐름의 모든 측면을 다루기 위해 폼 데이터를 폼 객체로부터 가져온다.
var _ = require('underscore'); var async = require('async'); var Q = require('q'); var NewStudentForm = function( formData ) { this.formData = formData; }; NewStudentForm.prototype = _.extend( NewStudentForm.prototype, { process: function() { this.token = Q.defer(); async.series([ this.validate, this.persist, ], this.result ); return this.token.promise; }, validate: function( next ) { // validate object properties, // e.g. required fields, pattern matching, etc next(); }, persist: function( next ) { // persistence, such as write to DB or send to server new CreateNewStudent( this.formData ).run() .then(function() { next(); }) .fail( next ); }, result: function( err ) { // resolve or reject the deferred if ( err ) { this.error( err ); this.token.reject( err ); } else { this.token.resolve(); } } error: function( err ) { // send errors back to the user } });
이 폼은 컨트롤러나 클라이언트의 뷰와 같이, 메인 어플리케이션 컴포넌트에서 폼을 실행하기 위한 짧고, 표현적인 API를 제공한다:
new NewStudentForm( formData ).process() .then(function() { // success callback }) .fail(function() { // error callback });
자바스크립트 환경에서 폼 객체의 매력은 재사용에 대한 잠재력이다. 우리는 프로세싱을 위해 서버로 보내기 전에, 클라이언트 측에서 form을 검증하길 원할 것이다. 하지만 사용자가 검증을 조작할 수 있기에 클라이언트 측에서만 검증하는 것을 원하는 것이 아니라, 서버에서도 지켜보길 원한다. API 호출 또한 방지하길 원할 것이다.
폼 객체의 구성에 대해 창의적으로 생각해본다면, 어플리케이션의 모든 측면에서 일관된 API를 만들 수 있다. 예를 들어, 만약 폼 처리의 모든 측면을 포괄하는 폼 객체 대신에, 폼의 값들만 감시하는 검증 객체를 만들면, 표현과 상황 별 처리 흐름에 따라 일관된 구성으로 사용할 수 있다:
var _ = require('underscore'); var async = require('async'); var Q = require('q'); var NewStudentFormValidator = function( formData ) { this.formData = formData; }; NewStudentFormValidator.prototype = _.extend( NewStudentFormValidator.prototype, { validate: function() { this.token = Q.defer(); async.series([ this.validateEmail, this.validatePhoneNumber // any other validations ], this.result ); return this.token.promise; }, validateEmail: function( next ) { // run email validation next(); }, validatePhoneNumber: function( next ) { // run phone number validation next(); }, result: function( err ) { // resolve or reject the deferred if ( err ) { this.token.reject( err ); } else { this.token.resolve(); } } });
이러한 방법은 보다 크고 단일화된 객체의 유연한 조합을 선호할 수 있게 하는 멋진 방법이다.
// Client-side new NewStudentFormValidator( formData ).validate() .then(function() { // submit form to server via standard HTTP form // or via AJAX }) .fail(function( err ) { // message errors to user }); // Server-side (application route) new NewStudentFormValidator( formData ).validate() .then(function() { return new CreateNewStudent( formData ).run(); }) .then(function() { // send user to the success page }) .fail(function( err ) { // set flash, send user back to form }); // Server-side (API route) new NewStudentFormValidator( formData ).validate() .then(function() { return new CreateNewStudent( formData ).run(); }) .then(function() { // send 200 OK }) .fail(function( err ) { // send 422 with errors });
어떻게 검증 객체를 한번만 정의하고 모든 측면에서 지속적으로 감시하면서 데이터베이스의 각각의 엔트리 포인트에서 사용하는지 보았을 것이다. 이 방법은 드라이하면서 조직적으로 구성되게 돕는다.
만약 전문적인 컴포넌트의 사이에 세분화된 프로세스를 갖기보단 하나의(잠재적으로 큰) 폼 객체로 쉽게 모든 것을 살펴보는 방법을 찾는다면 거기에도 완전히 적합하다. 이젠 어떤 타입의 구성이 당신과 팀이 적합하다고 느끼는 지가 전부이다.
Testing
폼 객체들의 구성을 어떻게 하든 어플리케이션으로부터 그것을 추출하여 테스트는 간단하게 만들어진다. 당신이 할 일은 테스트할 폼 데이터로 이루어진 객체를 구성하고, 이를 통해 전달하는 것이다.
모든 적용 가능한 오류들을 메시지을 통해 어플리케이션으로 되돌아오는지 확인하는 것 역시 오류 처리를 테스트하는 좋은 방법이다.
var _ = require('underscore'); var expect = require('chai').expect; var chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); var NewStudentForm = require('./NewStudentForm'); describe('NewStudentForm', function(){ describe('Passing Data', function(){ var formData = { firstName: 'John', lastName: 'Smith', gender: 'm' }; before(function(){ var newStudentForm = new NewStudentForm( formData ).process(); }); it('persists the data', function(){ // check database for persisted documents }); it('resolves the promise', function(){ expect( newStudentForm ).to.eventually.be.fulfilled; }); }); describe('Failing Data', function(){ var formData = { firstName: null, lastName: 'Smith', gender: 'm' }; before(function(){ var newStudentForm = new NewStudentForm( formData ).process(); }); it('does not persist the data', function(){ // check database for absence of persisted data }); it('rejects the promise', function(){ expect( newStudentForm ).to.eventually.be.rejected; }); }); });
다음 포스팅에서는 데이터 베이스에서 레코드를 검색하거나 컬렉션을 필터링 할 수 있는 정말 인상적이고 깔끔한 방법을 제공하는 쿼리 객체에 대해 살펴보겠다.
4) Query Objects
데이터베이스 쿼리는 (심지어 간단한 것 일지라도) 열심히 읽고 충분히 이해하는 과정을 반복 할 수 있다. 특히 여러 컬렉션이나 테이블에 포함되어있는 데이터와 같이 더 복잡한 쿼리라면, 이 과정은 쓰기에도 지저분하고, 심지어 유지 보수 하기엔 더 엉망일 것 이다.
쿼리 객체는 쿼리 로직을 추출하고, 관련된 연산을 모듈에 합치고, 더 유지 보수하기 좋고 읽기 편한 구조로 로직을 끌어 당기고, 또한 쿼리 객체가 쓰이는 곳에 읽기 좋은 API를 제공하는 등의 좋은 도구를 제공한다.
Example
자, 이제 현재 패스한 모든 학생들을 표현하는 JSON을 리턴하는 API의 엔드포인트를 상상해보자. 쿼리 객체를 사용하지 않는다면, API를 컨트롤 메소드나 이와 같은 서비스 객체를 가진 함수가 가질 것이다.( DetermineStudentPassingStatus는 서비스 객체 포스트의 예에서 사용되었다.)
// expecting all collection variables to be defined var _ = require('underscore'); var Q = require('q'); var DetermineStudentPassingStatus = require('./determineStudentPassingStatus'); var getCurrentlyPassingStudents = function() { var token = Q.defer(); // find all current students studentCollection.findAll({ isCurrent: true }, function( students ) { var studentIds = _( students ).pluck('_id'); // find all assignments for those current students assignmentsCollection.findAll({ studentId: { $in: studentIds }}, function( assignments ) { var passingStudentIds = []; // group the assignments by studentId and then assess passing status // adding the studentId to the array of passing students if passing _( assignments ).chain() .groupBy('studentId') .each( function( assignments, studentId ) { var passingStatus = new DetermineStudentPassingStatus( studentId ).run( assignments ); if ( passingStatus === true ) passingStudentIds.push( studentId ); }) .value(); // filter all current students down to those that are passing // and resolve the deferred var passingStudents = _( students ).filter( function( student ) { return passingStudentIds.indexOf( student._id ) !== -1; }); token.resolve( passingStudents ); }) }) return token.promise; };
우리는 콜백 지옥의 일부 깊은 층에 빠진 것 뿐만 아니라, 매우 읽기 힘든 코드를 작성할 수 밖에 없다.
만약, 쿼리 객체를 사용한다면 보다 더욱 표현적인 모듈을 만들 수 있다.
var _ = require('underscore'); var async = require('async'); var Q = require('q'); var CurrentlyPassingStudentsQuery = function() {}; CurrentlyPassingStudentsQuery.prototype = _.extend( CurrentlyPassingStudentsQuery.prototype, { run: function() { this.deferred = Q.defer(); _.bindAll( this, 'fetchCurrentStudents', 'fetchAssignmentsForCurrentStudents', 'compilePassingStudentIds', 'filterAllPassingStudents', 'result' ); async.waterfall([ this.fetchCurrentStudents, this.fetchAssignmentsForCurrentStudents, this.filterAllPassingStudents ], this.result ); return this.deferred.promise; }, fetchCurrentStudents: function( next ) { studentCollection.findAll({ isCurrent: true }, function( currentStudents ) { next( null, currentStudents ); }); }, fetchAssignmentsForCurrentStudents: function( currentStudents, next ) { var currentStudentIds = _( currentStudents ).pluck('_id'); assignmentsCollection.findAll({ studentId: { $in: studentIds }}, function( assignments ) { next( null, currentStudents, assignments ); }); }, compilePassingStudentIds: function( currentStudents, assignments, next ) { var passingStudentIds = []; _( assignments ).chain() .groupBy('studentId') .each( function( assignments, studentId ) { var passingStatus = new DetermineStudentPassingStatus( studentId ).run( assignments ); if ( passingStatus === true ) passingStudentIds.push( studentId ); }) .value(); next( null, passingStudentIds ); }, filterAllPassingStudents: function( passingStudentIds, next ) { var currentlyPassingStudents = _( students ).filter( function( student ) { return passingStudentIds.indexOf( student._id ) !== -1; }); next( null, currentlyPassingStudents ); }, result: function( err, currentlyPassingStudents ) { if ( err ) { this.deferred.reject( err ); } else { this.deferred.resolve( currentlyPassingStudents ); } } });
쿼리와 관련된 모든 연산들을 캡슐화하는 것이 더 조직적으로 느껴지며, 어플리케이션에 통합될 표현적인 API를 제공해준다.
Express 컨트롤러 메소드의 예를 보자:
var CurrentlyPassingStudentsQuery = require('./currentlyPassingStudentsQuery'); // for route GET /api/students/passing var currentlyPassingStudents = function( req, res ) { new CurrentlyPassingStudentsQuery().run() .then(function( currentlyPassingStudents ) { res.send( 200, currentlyPassingStudents ); }) .fail(function( err ) { res.send( 422, err ); }); };
이 API의 호출에 반환 된 데이터는 당신이 절대 원할 리 없는 데이터 저장소에서 직접 가져온 원시 데이터 일 것 이다. 이 예제를 통해, 쿼리 객체는 프리젠테이션을 위한 데이터 변환 장소를 제공하는 뷰 객체와 쌍을 이룰 수 있다.(관련된 내용은 다음 포스트에서 설명한다.)
또 하나의 언급할만한 가치가 있는 것은, 이 패턴은 구성(컴포지션)에 흥미로운 기회를 제공한다는 것이다. 예를 들어, 학생들에게 할당된 모든 과제를 찾고 싶은 많은 곳들이 있을 것이다. 따라서 우리는 별도의 쿼리 객체에 해당 프로세스를 추출하고 #fetchAssignmentsForCurrentStudents 메소드에서 사용할 수 있다.
Testing
문맥 밖에서 쿼리 객체를 작성하는 것은 테스트를 굉장히 간단하게 할 수 있게 한다. 만약 테스팅 데이터베이스를 사용한다면 의미있는 쿼리 결과를 제공하고, 쿼리 객체의 실행하며, 결과의 정확성을 확인하기 위해 필요한 데이터를 채우는 게 문제일 뿐이다.
var expect = require('chai').expect; var CurrentlyPassingStudentsQuery = require('./currentlyPassingStudentsQuery'); describe('CurrentlyPassingStudentsQuery', function(){ var currentlyPassingStudents; var err; before(function( done ){ // first build all records in the necessary // tables for testing (steps not shown) // then run the Query Object new CurrentlyPassingStudentsQuery().run() .then( function( _currentlyPassingStudents ) { currentlyPassingStudents = _currentlyPassingStudents; done(); }); .fail( function( _err ) { err = _err; done(); }); }); it('returns the correct set of records', function(){ expect( currentlyPassingStudents ).to.have.length( expectedLength ); //however many you are expecting }); });
다음 포스트에선, 특정 뷰 모델의 변환을 분리하는 뷰 객체에 대해 살펴보겠다.
5) View Objects
모델에 연관된 로직이나 오직 표면적인 모델의 표현에만(표준 웹의 HTML이나 JSON과 같은 엔드포인트) 사용되는 속성이 있다면, 이러한 연산이나 값을 모델에 직접 저장하는 것을 피하는 것이 최고의 방법이다. 뷰에 한정된 속성들을 모델에 저장하는 것은 무엇이 “진짜”인지(데이터베이스에 저장된) 그리고 순수하게 나타내고자 하는 것이 무언인지 헷갈리게 한다. 뷰 객체는 실제 값과 표현되는 값 사이의 어댑터처럼 동작한다.
예를 들면, 상상 속 저장소의 아이템의 실제 값은 데이터 베이스에 저장된 가격 속성 599센트이지만, 상품 페이지에 대한 표현은 $5.99 같이 실제 값을 변환한 값이 될 것이다. 모델의 보조 가격 속성으로서 표현된 데이터를 저장하는 것은 부적절하다. 만약 템플릿에 변환 로직을 삽입하는 것은 더 악화 시킬 것 이다.
뷰 객체가 하는 일은 변환이란 방법으로 데이터를 “드레스업”하고, 데이터 속성을 추가하거나 삭제하고, 표현 계층에서 쓰일 새로운 객체를 반환하는 것이다. 이러한 방법은 우리의 모델의 실제 값에서 지워질 상세한 표현 로직과 속성을 위한 좋은 집을 만들어 낸다.
또한 언급하고 싶은 것은 이 패턴을 뭐라고 부를 지에 대한 몇 가지의 충돌이 있어왔다는 것이다. Helmkamp도 자신의 포스팅에서 이러한 문제를 고심한 적이 있고, 이 곳 [Crush & Lovely]에서도 자주 거론되는 주제다.
“뷰”는 HTML에 대한 가장 일반적인 용어로 쓰이면서, 주로 “뷰 객체”라는 이름이 이 패턴의 다양성을 흐리게 만들었기에 “뷰 객체”라는 단어는 엔지니어들 사이에서 선호하지 않는 용어다. 이 패턴은 데이터를 제 3자 서비스로 전달하거나 기타 다른 이유 등으로 API 응답에 사용될 수 있다.
“프리젠터”는 주로 패턴의 기능을 적절히 설명하기 때문에 [Crush]가 선호하는 용어다: 데이터는 어떤 형태의 응답이 오던 관계없이, 응답으로 이용될 수 있게 보여주게 된다.
Example
예를 들어, 연말에 선생님은 각 학생들의 성적표를 출력한다. 다른 정보 중에서, 성적표는 학생들의 평균 등급과 통과 여부 및 학생의 전화번호를 나타낸다. 성적표를 생성하는 스크립트에서 각 학생들과 연관된 일 년 간의 과제에 대해 “사실적인” 표현을 생산하는 객체는 다음과 같다.
{ "id": "123456", "firstName": "Susan", "lastName": "Smith", "gender": "f", "phone": "5551234567", "assignments": [ { "grade": 0.65 }, { "grade": 0.83 }, { "grade": 0.90 }, ... ] }
성적표 PDF에 대한 마크업은, 데이터의 서식을 무시하는 “멍청한” 뷰의 신조를 유지한다.
<p class="average-grade">Average grade across all assignments: {{averageGrade}}</p> <p class="passing-status">Passing: {{isPassing}}</p> ... <p> For any questions, please call the teacher at {{teacher.phone}}. </p> ...
학생 객체를 뷰 객체에 던지고 뷰 객체를 HTML에 차례로 던진다면, 학생 객체를 제공하여 뷰에서 사용하는 것은 매우 쉬워진다.
다음 예에서는 뷰 객체가 HTML에서 이용하기 위해 학생 모델을 드레스 업하는 것을 포함할 수 있는지 보여준다:
var _ = require('underscore'); var DetermineStudentPassingStatus = require('./determineStudentPassingStatus'); var GetAverageGradeFromAssignments = require('./getAverageGradeFromAssignments'); var StudentGradeReportPresenter = function( students ) { // ensure students variable is array this.students = ( students instanceof Array ) ? students : [students]; }; StudentGradeReportPresenter.whitelistKeys = [ 'firstName', 'lastName', 'isPassing', 'averageGrade', 'phone' ]; StudentGradeReportPresenter.prototype = _.extend( StudentGradeReportPresenter.prototype, { present: function() { var process = _.compose( this.sanitizeAttributes.bind( this ), this.getAverageGrade.bind( this ), this.getPassingStatus.bind( this ), this.formatPhoneNumber.bind( this ) ); this.result = _( this.students ).map( process ); // return same type of primitive that was passed in // either Array or single object return ( this.students.length > 1 ) ? this.result : this.result[0]; }, sanitizeAttributes: function( student ) { student = _.pick.apply( null, [student].concat( StudentGradeReportPresenter.whitelistKeys )); return student; }, getAverageGrade: function( student ) { student.isPassing = new DetermineStudentPassingStatus( student.id ).fromAssignments( student.assignments ); return student; }, getPassingStatus: function( student ) { student.averageGrade = new GetAverageGradeFromAssignments( student.assignments ).run(); return student; }, formatPhoneNumber: function( student ) { student.phone = student.phone.replace(/(\d{3})(\d{3})(\d{4})/, "$1-$2-$3"); return student; } }); module.exports = StudentGradeReportPresenter;
그 다음으로 필요 한 것은 학생 데이타를 제공하고, 렌더링을 위해 view에 전달하는 일이다.
new CurrentStudentsWithAssignmentsQuery().run() .then(function( students ) { return new StudentGradeReportPresenter( students ).present(); }) .then(function( students ) { res.render('reportCard', students); });
휴대폰 번호를 포매팅 하는 것처럼 일반적으로 사용되는 뷰 객체의 메소드를 추출할 좋은 기회가 있다는 것에 주목해야 한다. 이런 메소드를 뷰 객체에서 쓸 수 있는 모듈로 뽑거나, 구성의 기회로 볼 수 있거나, 뷰 객체에서 모듈식 도구로 쪼개야 한다. 조직화를 위한 기회는 뷰 객체를 빈번하고 유연하게 사용하면서 각 엔지니어가 최선의 방법에 대해 자신의 스타일과 어플리케이션을 살펴볼 때 생긴다.
Testing
이러한 변화를 위한 단위 테스트는 당신이 하고 있는 모든 일이 하나의 객체 또는 배열을 전달하고 다른 기대를 하고 있는 이후에 쾌적하고 수월하다. 따라서, 데이터가 전달된 후에 올바른 속성과 값인지 테스트 할 수 있다.
var expect = require('chai').expect; var StudentGradeReportPresenter = require('./studentGradeReportPresenter'); var Grade = require('./grade'); describe('StudentGradeReportPresenter', function(){ var student; var presentedStudent; before(function(){ student = { id: '123456', firstName: 'Susan', lastName: 'Smith', gender: 'f', phone: "5551234567", assignments: [ { grade: new Grade(0.65) }, { grade: new Grade(0.83) }, { grade: new Grade(0.90) } ] }; presentedStudent = new StudentGradeReportPresenter( student ).present(); }); it('returns only the specified properties', function(){ expect( presentedStudent ).to.have.keys('firstName', 'lastName', 'phone', 'averageGrade', 'isPassing'); }); describe('.phone', function(){ it('returns the correct value', function() { expect( presentedStudent.phone ).to.equal('555-123-4567'); }); }); describe('.isPassing', function(){ it('returns the correct value', function() { expect( presentedStudent.isPassing ).to.equal(true); }); }); describe('.averageGrade', function(){ it('returns the correct value', function(){ expect( presentedStudent.averageGrade ).to.equal(0.79); }); }); });
다음 포스팅에선, 캡슐화 비지니스 로직을 제공해주는 좋은 도구인 정책(Policy) 객체에 대해 살펴볼 것이다.
6) Policy Objects
비즈니스 로직의 한 부분이 모델과 연관되어 있고 상당히 복잡하거나 모델 로직의 핵심이 아닌 경우, 정책(Policy)객체로 추출할 후보가 된다. 이러한 객체는 모델을 해석하고, 오직 boolean 값들을 반환하고, 정책이 pass나 non-pass했는 지를 설명하는 작업들을 캡슐화 한다.
예를 들어, 작업자 프로세스가 사용자 그룹에게 이메일을 보낸다면, 어떤 사용자가 이메일을 받아야 하는지 결정하는데 정책(Policy) 객체를 사용할 수 있을 것이다. 정책(Policy) 객체는 유효하며, 수신 거부 하지 않은 사용자의 이메일 주소가 있다면 true 값을 반환할 것이다.
Helmkamp는 정책(Policy) 객체와 쿼리 객체나 서비스 객체 사이의 잠재적인 중복에 대해 설명했다. 그는 “정책(Policy) 객체는 서비스 객체와 유사하지만, 나는 ‘서비스 객체’를 작업을 적기 위해 사용하고, ‘정책(Policy) 객체’는 그것을 읽어 들이기 위해 사용한다. 또한 쿼리 객체와도 유사하지만, 쿼리 객체는 SQL문을 실행하고 결과 집합을 반환하는데 초점을 둔 반면, 정책(Policy) 객체는 이미 메모리에 로드된 도메인 모델에서 동작한다.”
Example
신입생을 포함한 전체 학생들의 컬렉션을 상상해보자. 아래와 같은 규칙을 정의한 대로, 경기에 등록하기에 적격인 모든 학생들을 필터링 하길 원한다:
정학 당한 적이 없다.
퇴학 당한 적이 없다.
모든 클래스를 pass하였다.
경기에 등록되기 위한 자격은 학생 모델의 핵심 개념이 아니고 로직은 복잡하다. 따라서 이러한 것들을 다음과 같이 정책(Policy) 객체에 추출할 수 있다.
var _ = require('underscore'); var PassingStudentPolicy = require('./passingStudentPolicy'); var EligibleForSportsEnrollmentPolicy = function( student ) { this.student = student; }; EligibleForSportsEnrollmentPolicy.prototype = _.extend( EligibleForSportsEnrollmentPolicy.prototype, { run: function() { return this.isNotExpelled() && this.isNotSuspended() && this.isPassing(); }, isNotExpelled: function() { return this.student.isExpelled !== true; }, isNotSuspended: function() { return this.student.isSuspended !== true; }, isPassing: function() { return new PassingStudentPolicy( this.student ).run(); } }); module.exports = EligibleForSportsEnrollmentPolicy;
해당 학생이 pass인지 아닌 지를 반환하는 하나의 정책(Policy) 객체를 사용하는 #isPassing 메소드를 통해 정책(Policy) 객체로 구성할 수 있는 기회를 볼 수 있을 것이다. 이제 비즈니스 로직의 일부분인 정책들은 모델 자체에서 추출하여 테스트할 단위, 이해하기 쉽고, 합성 가능한 구성 요소로 배치되어야 한다.
Testing
정책(Policy) 객체의 단위 테스트는 그다지 단순하지 않다 – 하나의 객체를 만들어 정책 객체가 올바른 boolean 값을 반환하는 지 확인하여 테스트를 통과하거나 실패하는지 확인해야 한다.
var expect = require('chai').expect; var EligibleForSportsEnrollmentPolicy = require('./eligibleForSportsEnrollmentPolicy'); describe('EligibleForSportsEnrollmentPolicy', function(){ var student = { firstName: 'John', lastName: 'Smith' }; var eligibility; before(function(){ eligibility = new EligibleForSportsEnrollmentPolicy( student ).run(); }); it('returns true if the object passes the policy tests', function(){ expect( eligibility ).to.be.true; }); it('returns false if the student is expelled', function(){ before(function(){ var expelledStudent = _.clone(student) expelledStudent.isExpelled = true; eligibility = new EligibleForSportsEnrollmentPolicy( expelledStudent ).run(); }); expect(eligibility).to.be.false; }); it('returns false if the student is suspended', function(){ before(function(){ var suspendedStudent = _.clone(student) suspendedStudent.isSuspended = true; eligibility = new EligibleForSportsEnrollmentPolicy( suspendedStudent ).run(); }); expect(eligibility).to.be.false; }); });
다음은 이 시리즈의 마지막 포스트가 될, 프로세스의 복잡한 구성과 변화에 용이한 데코레이터에 대해 살펴볼 것이다.
7) Decorators
프로세스에서 특정 상황에서만 실행 되어야 하는 사이드 이펙트가 있다면, 이 기능은 데코레이터를 사용하여 기존 작업에 레이어드 할 수 있다. 데코레이터는 객체를 가지고 주위의 보조 기능을 랩핑하고, 핵심 프로세스는 유지하면서도 필요한 것을 필요한 시점에 추가 시킨다.
Example
학급의 각 학생들의 성적표를 만드는 서비스 객체를 상상해보자. 선생님은 언제라도 성적표를 생성할 수 있지만, 특정 상황에서는 학생의 부모님께 성적표를 메일로 전송해야 한다.
이 문제를 해결하는 한 가지 방법은 GenerateReportCards와 GenerateReportCardsAndEmailParent, 두 개의 각기 다른 서비스 객체를 두는 것이지만 이는 중복을 만들고, 조건에 따른 단계들이 많은 경우에 유지하기가 어렵다. 또 다른 방법은 다음과 같이 콜백으로 함께 모아두는 것이다.
new GenerateReportCard( student ).run() .then(function( student ) { return new EmailReportCardToParent( student ).run(); });
이 방법이 나쁘지 않지만, 그 이후의 프로세스에 사용할 수 있는 서비스 객체의 반환 값에 의존한다. 덧붙여, 성적표에 대한 HTML 생성과 같은 동일한 프로세스가 두 절차에서 발생해야 할 수도 있다.
이러한 문제는 데코레이터를 필요로 한다. 특정한 메소드를 타겟으로 레이어를 그 위에 두고, 기존의 동작을 활용하고 보조 기능을 추가하는 것을 허용한다. 따라서, 이번 예에서 서비스 객체는 다음과 같아진다:
var _ = require('underscore'); var GenerateReportCard = function( student ) { this.student = student; }; GenerateReportCard.prototype = _.extend( GenerateReportCard.prototype, { run: function() { return _.compose( this.saveReportCardAsPDF.bind(this), this.generateReportCardHtml.bind(this), this.getStudentGrade.bind(this) )(); }, getStudentGrade: function() { // determine grade return grade; }, generateReportCardHtml: function( grade ) { // build html for report card return html; }, saveReportCardAsPDF: function( html ) { // save PDF and get the url return pdfUrl; } });
서비스 객체의 인스턴스를 인스턴스로 허용하고 추가 기능을 감싸 지정된 방법으로 그 서비스 객체를 반환하는 데코레이터 객체를 생성 할 수 있다.
var EmailReportCardToParent = function( obj ) { this.obj = obj; this.decorate(); return obj; }; EmailReportCardToParent.prototype = _.extend( EmailReportCardToParent.prototype, { // specify the method that you want to decorate methodToDecorate: 'saveReportCardAsPDF', decorate: function() { var self = this; // store the original function for use in a closure var originalFn = this.obj[ this.methodToDecorate ]; // define the decorated function, which captures the originalFn in its closure var decoratedFn = function( html ) { // call the decorator method with the result of the originalFn as the argument var res = originalFn( html ); self.sendPdfAsEmail( res ); return res; }; // override the method on the object with the new decoratedFn this.obj[ this.methodToDecorate ] = decoratedFn; }, sendPdfAsEmail: function() { // send email to parent } }); // Example usage new EmailReportCardToParent(new GenerateReportCard()))).run();
데코레이터 패턴의 중요한 목표 중 하나는, 변경된 속성을 제외하고 아이덴티티와 API 면에서는 반환되는 객체가 인풋 오브젝트와 같다는 점이다.
따라서, 객체에 적절하게 데코레이트되어 있는 경우 다음과 같이 true가 돼야 한다:
var generateReportCardService = new GenerateReportCard(); var decoratedService = new EmailReportCardToParent( generateReportCard ); // returned object is exact same object decoratedService === generateReportCardService // true
실제로 이 패턴에서, 원래의 서비스 객체에 많은 메소드를 데코레이트한 데코레이터를 얼마든지 레이어 할 수 있다.
new GenerateReportCard( new PrintReportCard( new GenerateReportCard(student))).run();
Testing
데코레이터를 테스트할 때, 모든 메소드가 적절하게 원래 객체를 장식하고 보조 메소드와 같은 메소드들이 올바른 작업을 수행하는 지 테스트 하는 것이 현명하다.
EmailReportCardToParent 데코레이터에 대한 꽤 포괄적인 테스트 세트는 다음과 같이 짤 수 있다:
var chai = require('chai'); var expect = chai.expect; var sinon = require('sinon'); chai.use(require('sinon-chai')); describe('EmailReportCardToParent', function(){ var generateReportCard; var decorated; before(function(){ generateReportCard = new GenerateReportCard(); decorated = new EmailReportCardToParent( generateReportCard ); }); it('returns the original object', function(){ expect(decorated).to.be.generateReportCard; }); it('calls the original method', function(){ var originalDecoratedFn = sinon.spy( generateReportCard, EmailReportCardToParent.prototype.methodToDecorate ); decorated.run(); expect(originalDecoratedFn).to.have.been.calledOnce; }); it('calls the decorator method with the result of the original method', function(){ var decoratorMethodFn = sinon.spy( EmailReportCardToParent.prototype, 'sendPdfAsEmail' ); decorated.run(); expect(decoratorMethodFn).to.have.been.calledOnce; }); describe('#sendPdfAsEmail', function(){ // any tests for the decorator method itself }); });
이것으로 자바스크립트 어플레케이션 리팩토링을 위한 7가지 패턴에 대해 마치겠다. 이 개념들이 당신의 어플리케이션을 리팩토링할 수 있는 방법의 핵심을 이해하는데 도움 되기를 희망한다.
각 패턴은 각각의 엔지니어마다 구현 방식, 접근 방식과 매력의 정도가 다를 것이다. 하지만, 이러한 개념들은 배틀 테스트를 거치고 ‘Crush & Lovely’의 어플리케이션에서 성공적으로 입증되었고, 우리는 어플리케이션을 새롭고 더 나은 객체로 리팩토링하려고 항상 생각하고 노력한다는 점은 알아주길 바란다.
여기까지가 원문입니다.
자바스크립트 리팩토링에 대해 패턴을 명시해 주어 개인적으로 도움이 많이 될 것 같아요. 열심히 적용해 봐야겠어요! (실전에서 이렇게 예쁘게 리팩토링이 된다면 얼마나 좋을까요..?)
이 외에도 자신이 쓰는 또 다른 리팩토링 기술이 있다면 알려주세요~!!
(+ 번역 스킬이 부족해요..ㅠㅠ 피드백은 언제나 환영입니다! 반영하여 수정하도록 하겠습니다~~)
2개의 댓글
JiHan Kim · 2015년 3월 5일 9:17 오후
오타가 있네요 ^^; toJOSN => toJSON
Rosalia Sungyu Li · 2015년 3월 17일 6:16 오후
@jihankim:disqus 수정하였습니다. 감사합니다^^