객체 패턴

이효범·2022년 5월 10일
0
post-thumbnail

자바스크립트에서는 여러 패턴으로 객체를 생성할 수 있으며 대개 같은 작업도 여러 가지 방식으로 수행할 수 있다. 자신만의 타입을 작성하거나 원한다면 언제든 일반 객체도 만들 수 있다. 상속이나 믹스인 등의 기술을 사용해 두 객체가 같은 메소드를 공유하도록 만들 수 있다. 또한 객체의 구조가 변경되지 않기를 원한다면 자바스크립트의 고급 기능을 사용해 변경을 막을 수도 있다. 이 장에서 다루는 패터는 객체를 생성하거나 조작할 때 큰 도움이 될 것이다.

비공개 멤버와 특권 멤버

자바스크립트에서 객체 프로퍼티는 모두 공개(public)되어 있으며 객체 외부에서 특정 프로퍼티에 접근할 수 없도록 설정할 명시적인 방법이 없다. 하지만 때로는 데이터를 공개하지 않아야 할 때도 있다. 예를 들어 객체에 어떤 값이 있는데 이 값을 통해 임의의 상태를 결정한다고 생각해보자. 만약 그 값을 객체 모르게 변경한다면 상태 관리는 엉망이 되어버릴 것이다. 이런 문제를 피할 방법 중 하나는 명명규칙(naming conventions)을 사용하는 것이다. 공개된 프로퍼티가 아니라는 의미로 프로퍼티 이름 앞에 언더스코어 문자를 접두어로 사용하는 방법을 예로 들 수 있다(예: this._name). 하지만 명명 규칙에만 기대지 않고 더 안전하게 비공개 데이터의 수정을 방지할 방법도 몇 가지 존재한다.

모듈 패턴

모듈 패턴(module pattern)은 비공개 데이터를 가진 싱글톤(singletone) 객체를 만들 때 사용하는 객체 생성 패턴이다. 이 방식을 객체를 반환하는 즉시 실행 함수 표현식(immediately invoked function expression, IIFE)을 사용한다. IIFE는 함수를 생성하자마자 즉시 호출하여 결과를 내는 함수 표현식으로서 이 함수 바깥에서는 접근할 수 없는 지역 변수를 함수 안에 몇 개든 정의할 수 있다. 반환된 객체는 이 함수 안에서 정의되었기 때문에 객체의 메소드는 함수의 지역 변수에 접근할 수 있다(IIFE 안에서 정의된 모든 객체는 함수 안의 지역 변수에 접근할 권한을 가진다). 이런 방식을 통해 비공개 데이터에 접근하는 메소드를 가리켜 특권(previleged) 메소드라 한다. 다음은 모듈 패턴의 기본 형식이다.

let yourObject = (funciton() {
  // 비공개 데이터 변수
  
  return {
    // 공개 메소드 및 프로퍼티              
  };
                 
}());

이 패턴에서는 익명 함수가 만들어지는 즉시 실행된다(함수를 감싼 괄호는 함수의 야 끝에 있어야 한다는 점에 주의하자. 이 문법을 사용하면 익명 함수를 즉시 실행할 수 있다). 즉 익명 함수는 잠깐만 존재했다가 실행되고 사라진다. IIFE는 자바스크립트에서 상당히 자주 쓰이는 패턴이며 이처럼 모듈 패턴에서 사용되기도 한다.

모듈 패턴을 사용하면 평범한 변수를 객체의 비공개 프로퍼티처럼 사용할 수 있는데, 이렇게 쓰려면 객체 메소드를 클로저(closure) 함수로 작성해야 한다. 클로저는 쉽게 말해 클로저 함수 스코프 바깥의 변수에 접근할 수 있는 함수를 말한다. 예를 들어 함수 안에서 웹 브라우저의 window와 같은 전역 객체에 접근하면 이 함수는 함수 스코프 바깥의 변수에 접근한 것이다. 모듈 패턴이 조금 다른 부분은 접근하는 외부 변수가 IIFE 안에 정의되어 있으며 IIFE 안에서 정의된 함수가 이 변수에 접근한다는 것이다. 다음 예제를 보자.

let person = (function() {
  let age = 25;
  
  return {
   name: "Nicholas",
    
   getAge: function() {
     return age;
   },
    
   growOlder: function() {
    age++; 
   }
  };
}());

console.log(person.name); // "Nicholas"
console.log(person.getAge()); // 25

person.age = 100;
console.log(person.getAge()); // 25

person.growOlder();
console.log(person.getAge()); // 26

이 코드는 모듈 패턴을 사용해 person 객체를 생성한다. 이 객체의 age 변수는 마치 비공개 프로퍼티처럼 동작한다. 객체 외부에서는 age 변수에 접근할 수 없지만 객체의 메소드에서는 자유롭게 사용할 수 있다. 이 객체는 특권 메소드가 두 개 있는데, 하나는 age 변수의 값을 읽는 getAge() 이고 다른 하나는 age 값을 증가시키는 growOlder() 이다. age 변수는 두 메소드가 정의된 외부 함수에 선언되어 있으므로 두 메소드 모두 age 변수에 접근할 수 있다.

모듈 패턴을 변형한 모듈 노출 패턴(revealing module pattern)에서는 모든 변수와 메소드를 IIFE 상단에 정의해두고 반환하는 객체에 정의했던 변수와 메소드를 할당한다. 바로 앞에서 보았던 예제에 모듈 노출 패턴을 적용하면 다음과 같이 수정할 수 있다.

let person = (function() {
   let age = 25;
  
   function getAge() {
     return age;
   },
    
   function growOlder() {
    age++; 
   }
  
   return {
     name: "Nicholas",
     getAge: getAge,
     growOlder: growOlder
   };
}());

모듈 노출 패턴에서 age.getAge()와 growOlder()는 모두 IIFE 안에 지역 함수로서 정의되어 있다. 이후 객체를 반환할 때 getAge()와 growOlder() 함수를 객체에 할당하여, 이 함수들을 IIFE 바깥으로 "노출(revealing)" 시켰다. 이 코드는 일반적인 모듈 패턴을 사용한 예제와 기본적으로는 같지만 변수와 함수를 같이 선언한다는 점 때문에 이 패턴을 더 선호하는 사람도 있다.

생성자의 비공개 멤버

모듈 패턴은 비공개 프로퍼티를 가진 개별 객체를 선언하기에는 좋지만 비공개 프로퍼티가 필요한 타입에도 적용할 수 있을까? 정답은 '그렇다'이다. 생성자 함수 내부에 모듈 패턴과 비슷한 패턴을 사용하면 인스턴스마다 비공개 데이터를 만들어 줄 수 있다. 다음 예제를 보자.

function Person(name) {
 // Person 생성자 안에서만 접근할 수 있는 변수 선언
  let age = 25;
  
  this.name = name;
  
  this.getAge = function() {
    return age;
  };
  
  this.growOlder = function() {
   age++; 
  };
}

let person = new Person("Nicholas");

console.log(person.name); // "Nicholas"
console.log(person.getAge()); // 25

person.age = 100;
console.log(person.getAge()); // 25

person.growOlder();
console.log(person.getAge()); // 26

이 코드에서 Person 생성자에는 age라는 지역 변수가 있다. 이 변수는 getAge()와 growOlder() 메소드에서 사용된다. Person의 인스턴스를 생성할 때 인스턴스는 자신만의 age 변수, getAge() 메소드, growOlder() 메소드를 만든다. 이 방식은 생성자가 지역 범위를 만들고 this 객체를 반환하는 등 여러 면에서 모듈 패턴과 비슷하다. 이전에 다루었듯이 객체 인스턴스의 메소드를 바꾸는 것은 프로토타입에 정의하는 것보다 비효율적이지만, 인스턴스의 비공개 고유 프로퍼티는 이 방법으로만 작성할 수 있다.

프로토타입에 추가한 프로퍼티처럼 모든 인스턴스가 공유하는 비공개 데이터를 만들고 싶다면 모듈 패턴과 비슷하지만 생성자를 이용한 혼합 방식을 사용하면 된다.

let Person = (function() {

  // everyone shares the same age
  let age = 25;
  
  function InnerPerson(name) {
   this.name = name; 
  }
  
  InnerPerson.prototype.getAge = function() {
   return age; 
  }
  
  InnerPerson.prototype.growOlder = function() {
    age++;
  }
  
  return InnerPerson;
}());

let person1 = new Person("Nicholas");
let person2 = new Person("Greg");

console.log(person1.name); // "Nicholas"
console.log(person1.getAge());  // 25

console.log(person2.name); // "Greg"
console.log(person2.getAge());  // 25

person1.growOlder(); 
console.log(person1.getAge());  // 26
console.log(person2.getAge());  // 26

이 코드에서 InnerPerson 생성자는 IIFE 안에 정의되어 있다. age 변수는 생성자 바깥에 정의되어 있지만 객체의 프로토타입 메소드에서 이 값을 사용한다. InnerPerson 생성자는 반환된 후에 전역 범위의 Person 생성자가 된다. Person의 모든 인스턴스는 age 변수를 공유하므로 한 인스턴스에서 이 값을 변경하면 다른 인스턴스에서도 값이 변경된 효과가 난다.


믹스인

자바스크립트에서는 의사 클래스 상속과 프로토타입 상속을 주로 사용하지만 믹스인(mixins)을 통해 의사 상속(pseudoinheritance)을 하는 방법도 있다. 믹스인은 프로토타입을 조작하지 않고 한 객체의 프로퍼티를 다른 객체에 할당하는 방법이다. 첫 번쩨 객체(수신자(receiver))는 두 번째 객체(공급자(supplier))의 프로퍼티를 직접 복사해서 가져온다. 일반적으로 믹스인은 다음과 같이 함수를 사용해 구현한다.

function mixin(receiver, supplier) {
 
  for(let property in supplier) {
   if (supplier.hasOwnProperty(property)) {
    receiver[property] = supplier[property] 
   }
  }
  
  return receiver;
}

mixin() 함수에는 수신자와 공급자로 사용할 두 객체를 인수로 전달한다. 이 함수는 공급자의 열거 가능한 프로퍼티를 모두 수신자로 복사하는데, 코드를 보면 for-in 반복문을 사용해 supplier 객체의 프로퍼티를 훑은 뒤 receiver에 이름이 같은 프로퍼티를 만들고 값을 복사하는 식으로 구현했다. 이 방식을 사용하면 얕은 복사가 되기 때문에 프로퍼티의 값이 객체라면 공급자와 수신자가 같은 객체를 가리키게 된다. 자바스크립트에서 이 패턴은 기존에 있던 객체의 기능을 다른 객체에 추가할 때 주로 사용된다.

예를 들어 이벤트를 지원할 때는 상속보다는 믹스인이 더 어울린다. 우선 이벤트를 사용하도록 다음과 같이 미리 정의해둔 객체가 있다고 생각해보자.

function EventTarget() {};

EventTarget.prototype = {
  constructor: EventTarget,
  
  addListner: function(type, listner) {
   
    // 이벤트 리스너를 저장할 배열이 없으면 새로 만든다.
    if(!this.hasOwnProperty("_listners")) {
    	this._listners = [];
    }
    
    if(typeof this._listners[type] == "undefined") {
    	this._listners[type] = [];
    }
    
    this._listners[type].push(listner);
  },
  
  fire: function(event) {
   
    if(!event.target) {
     event.target = this; 
    }
    
    if(!event.type) {  // 거짓스러운(falsy) 값
      throw new Error("이벤트 객체에 'type' 프로퍼티가 없습니다.");
    }
    
    if(this._listners && this._listners[event.type] insatnceof Array) {
     let listners = this._listners[event.type];
     for(let i = 0; len=listners.length; i < len; i++) {
      listners[i].call(this, event); 
     }
    }
  },
  
  removeListner: function(type, listner) {
    if(this._listners && this._listners[event.type] insatnceof Array) {
     let listners = this._listners[event.type];
     for(let i = 0; len=listners.length; i < len; i++) {
      if(listners[i] === listner) {
       listners.splice(i, 1);
       break;
      }
     }
    }
  }
};

EventTarget 타입은 어느 객체에든 적용할 수 있는 기본적인 이벤트 처리 기능을 포함하고 있다. 대상 객체에 이벤트 리스너를 추가하거나 제거할 수도 있으며 이벤트를 발생시킬 수도 있다. 이벤트 리스너는 _listners 프로퍼티에 저장되는데 이 프로퍼티는 addListner()를 처음 호출할 때 만들어진다(이렇게 작성하면 믹스인이 더 쉬워진다). EventTarget의 인스턴스는 다음과 같이 사용할 수 있다.

let target = new EventTarget();
target.addListner("message", function(event) {
  console.log("받은 메시지" + event.data);
})

target.fire({
  type: "message",
  data: "Hello World!"
});

자바스크립트에서 객체가 이벤트 기능을 지원한다면 유용할 것이다. 여러 다른 종류의 객체에서도 EventTarget 타입을 통해 이벤트를 지원할 방법이 몇 가지 있다. 첫 번째 방법은 EventTarget의 인스턴스를 새로 만들고 인스턴스에 원하는 프로퍼티를 추가하는 것이다.

let person = new EventTarget();
person.name = "Nicholas";
person.sayName = function() {
  console.log(this.name);
  this.fire({ type: "namesaid", name: name });
};

이 코드는 EventTarget의 인스턴스인 person이라는 새 변수를 만들 후 person과 관련된 프로퍼티를 추가한다. 아쉬운 부분이 있다면 여기서 person은 Object나 고유한 타입이 아닌
EventTarget의 인스턴스이다. 게다가 새 프로퍼티를 일일히 추가해야 하는 부하도 생긴다. 이보다는 조금 더 구조적인 방법을 사용하는 편이 좋을 것이다.

이 문제를 해결한 두 번째 방법은 의사 클래스 상속(pseudoclassical inferitance)을 사용하는 것이다.

function Person(name) {
 this.name = name; 
}

Person.prototype = Object.create(EventTarget.prototype);
Person.prototype.constructor = Person;

Person.prototype.sayName = function() {
  console.log(this.name);
  this.fire(){ type: "namesaid", name: name });
};

let person = new Person("Nicholas");

console.log(person insatanceof Person); // true
console.log(person instanceof EventTarget);  // true

여기서는 EventTarget을 상속받는 Person 타입을 만들었다. Person의 포로토타입에 원하는 메소드를 추가할 수도 있다. 하지만 그렇게 간결해 보이지도 않고 올바른 상속 관계가 맞는지 의문이 든다. 시람(person)을 일종의 이벤트 대상(EventTarget)으로 볼 수 있을까? 믹스인을 사용하면 프로퍼티를 프로토타입에 할당하기 위해 필요했던 코드를 줄일 수 있다.

function Person(name) {
 this.name = name; 
}

mixin(Person.prototype, new EventTarget());
mixin(Person.prototype, {
 constructor: Person,
  
  sayName: function() {
   console.log(this.name);
   this.fire({ type: "namesaid", name: name });
  }
})l

let person = new Person("Nicholas");

console.log(person insatanceof Person); // true
console.log(person instanceof EventTarget);  // true

여기서 Person.prototype은 이벤트 기능을 추가하기 위해 EventTarget의 인스턴스에서 프로퍼티를 가져온다. 그 후 Person.prototype은 constructor와 sayName()도 가져와 프로토타입을 완성한다. 이 예제에서 Person의 인스턴스는 EventTarget을 상속하지 않으므로 EventTarget의 인스턴스가 아니다.

물론 프로퍼티는 사용하고 싶지만 굳이 생성자를 통한 의사 클래스 상속은 사용하고 싶지 않을 수 있다. 이때는 새로 만든 객체에 바로 믹스인을 사용하면 된다.

let person = mixin(new EventTarget(), {
  name: "Nocholas",
  
  sayName: function() {
  	console.log(this.name);
    this.fire({ type: "namesaid", name: name });
  }
});

이 예제는 EventTarget의 인스턴스에 몇 가지 프로퍼티를 더 추가하여 person의 프로토타입 체인에는 아무런 영향도 주지 않을 채 person 객체를 생성한다.

이 같은 방식으로 믹스인을 사용할 때는 주의할 점이 한 가지 있다. 공급자의 접근자 프로퍼티가 수신자에서는 데이터 프로퍼티로 돼버리기 때문에 자칫 잘못하면 프로퍼티를 재정의하게 될 수 있다는 점이다. 이런 문제는 Object.defineProperty()를 사용하지 않고 할당으로만 수신자 프로퍼티를 정의하기 때문에 공급자 프로퍼티에서 읽어온 현재 값이 수신자 프로퍼티에 할당되어 발생한다. 다음 예제를 보자.

let person = mixin(new EventTarget(), {
 get name() {
  return "Nicholas" 
 },
  
 sayName: function() {
  console.log(this.name);
  this.fire({type: "namesaid", name: name});
 }
});

console.log(person.name); // "Nicholas"

person.name = "Greg";
console.log(person.name); // "Greg"

이 코드에서 name은 게터(getter)만 있는 접근자 프로퍼티로 정의되었다. 즉 이 프로퍼티에는 값을 할당할 수 없다. 하지만 person 객체에서는 이 접근자 프로퍼티가 데이터 프로퍼티로 바뀌기 때문에 name 프로퍼티에 다른 값을 할당할 수 있게 된다. mixin() 함수는 공급자에서 name의 값을 읽어와 수신자의 name 프로퍼티에 할당한다. 이때 수신자 프로퍼티의 name 프로퍼티를 접근자 프로퍼티로 만드는 과정은 어디에도 없으며 오히려 name을 데이터 프로퍼티로 만드는 과정만 있다.

공급자의 접근자 프로퍼티를 수신자에서도 접근자 프로퍼티가 되도록 복사하려면 mixin() 함수를 다음과 같이 조금 수정해야 한다.

function mixin(receiver, supplier) {
 Object.keys(supplier).forEach(function(property) {
   let descriptor = Object.getOwnPropertyDescriptor(supplier, property);
   Object.defineProperty(receiver, property, descriptor);
 }); 
  
 return receiver;
}

let person = mixin(new EventTarget(), {
 get name() {
   return "Nicholas"
 },
  
 sayName: function() {
  console.log(this.name);
  this.fire({ type: "namesaid", name: name });
 }
});

console.log(person.name); // "Nicholas"

person.name = "Greg";
console.log(person.name); // "Nicholas"

여기서 사용한 mixin()은 Object.keys()를 사용하여 supplier 객체의 열거 가능한 고유 프로퍼티를 배열로 가져온다. 이 배열에 forEach() 메소드를 사용해 프로퍼티를 훑으며 supplier의 각 프로퍼티에 대하 프로퍼티 서술자를 가져온 다음 Object.defineProperty()를 통해 receiver 객체에 프로퍼티를 추가한다. 이 방식을 사용하면 프로퍼티의 값은 물론 특성에 대한 정보도 모두 receiver로 전달할 수 있다. 따라서 person 객체에는 name이라는 접근자 프로퍼티가 그대로 추가될 것이다.

Object.keys()는 열거 가능한 프로퍼티만 반환한다. 열거 가능하지 않은 프로퍼티도 복사하고 싶다면 Object.getOwnPropertyNames()를 사용해야 한다.

스코프 세이프 생성자

모든 생성자는 함수이기 때문에 new 연산자 없이 생성자를 호출할 수 있으며 이 때문에 this의 값이 달라질 수도 있다. 단순한 함수로서 실행하면 엄격하지 않은 모드에서의 this의 값은 전역 객체(웹 브라우저의 window)가 this가 되어 의도하지 않은 결과가 나타날 수 있고, 엄격한 모드에서는 에러를 발생시킬 것이다. 앞서 우리는 다음과 같은 예제를 보았다.

function Person(name) {
 this.name = name; 
}

Person.prptotype.sayName = function() {
 console.log(this.name); 
};

let person1 = Person("Nicholas");

console.log(person1 instanceof Person);  // false
console.log(typeof person1); // "undefined"
console.log(name); // "Nicholas"

여기서 Person 생성자는 new 없이 호출되었으므로 name은 전역 변수가 된다. 이 코드는 엄격하지 않은 모드에서 실행되었기에 이 같은 결과가 나타났지만, 엄격한 모드에서는 new를 빠트린 채 실행하면 에러가 발생한다. 생성자의 이름은 new를 앞에 사용하라는 의미에서 보통 대문자로 시작한다. 그런데 만약 new 없이 호출하는 위와 같은 방식도 허용해주고 싶다면 어떻게 작성하면 될까? Array나 RegExp와 같은 내장 생성자는 어느 스코프에서도 안전하게 동작하도로걔(scope safe) 작성되었기 때문에 new가 없어도 잘 동작한다. 스코프 세이프 생성자(scope-sage constructor)는 new가 있을 때도 없을 때도 잘 동작하며 어느 때든 생성자를 사용해 새로 생성된 객체를 반환한다.

생성자를 new와 함께 호출하면 this는 새로 생성된 객체를 사리키며 이 객체는 실행한 생성자가 정의하는 타입의 인스턴스가 된다. 따라서 instanceof를 사용하면 함수를 호출할 때 new가 사용되었는지 확인할 수 있다.

function Person(name) {
 if (this instanceof Person) {
  // "new"와 함께 호출됨 
 } else {
  // "new" 없이 호출됨 
 }
}

이 패턴을 사용하면 new의 사용 여부에 따라서 함수가 다르게 동작하도록 만들 수 있다. 각 사례마다 완전히 다르게 동작하도록 만들 수도 있지만 대개는 똑같이 동작하기를 원할 것이다. 똑같이 동작하도록 작성하면 실수로 new를 빠뜨리더라도 코드는 new를 사용했을 때와 똑같이 동작하게 된다. 다음은 Person을 어느 스코프에서도 안전하게 동작하도록 수정한 것이다.

function Person(name) {
 if (this instanceof Person) {
  this.name = name;
 } else {
   return new Person(name);
 }
}

이 생성자는 new가 사용됐을 때는 늘 name 프로퍼티에 값을 할당한다. new가 사용되지 않았을 때는 재귀적으로 new와 함께 생성자 자신을 호출하여 인스턴스를 생성한 후 반환한다. 이 방식을 사용하면 다음과 같이 동작한다.

let person1 = new Person("Nicholas");
let person2 = Person("Nicholas");

console.log(person1 instanceof Person); // true
console.log(person2 instanceof Person); // true

new 연산자 없는 객체 생성은 new를 생략해도 에러가 발생하지 않아 점점 더 많이 도입되고 있다. 이미 자바스크립트 언어에는 Object, Array, RegExp, Error 등 여러 참조 타입이 어느 스코프에서든 안전하게 사용할 수 있게 정의되어 있다.

요약

자바스크립트에서 객체를 만들고 구성하는 방법은 여러 가지가 있다. 자바스크립트에는 공식적으로 비공개 프로퍼티의 개념이 없지만 객체 안에서만 접근할 수 있는 데이터와 함수를 만들 수는 있다. 싱글톤(singletone=) 객체에는 모듈 패턴을 사용해 객체 바깥에서 데이터에 접근할 수 없도록 만들 수 있다. 새롭게 생성된 객체에서만 접근할 수 있는 지역 변수와 지역 함수는 즉시 실행 함수 표현식을 사용하여 작성할 수 있다. 객체에 있는 메소드 중 비공개 데이터에 접근할 수 있는 것을 가리켜 특권 메소드(previleged method)라 한다. 생성자 함수 안에서 변수를 작성하면 비공개 데이터를 가진 생성자 함수를 만들 수 있으며, IIFE를 사용하면 모든 인스턴스가 공유하는 비공개 데이터를 작성할 수 있다.

믹스인(mixin)은 객체에 기능을 추가하되 상속을 사용하지 않는 방법이다. 믹스인은 한 객체에서 다른 객체로 프로퍼티를 복사하는 식으로 공급자 객체를 상속하지 않고 수신자 객체에 기능을 추가한다. 상속과 달리 믹스인을 사용하면 객체가 생성된 후에 추가된 기능의 출처를 알 수 없다. 이 때문에 믹스인은 데이터 프로퍼티나 작은 기능을 추가할 때 적합하다. 많은 기능을 추가하거나 기능의 출처를 알고 싶다면 상속을 사용하는 편이 좋다.

스코프 세이프(scope-safe) 생성자는 new의 사용 여부와 상관없이 객체 인스턴스를 생성할 수 있는 생성자이다. 이 패턴은 new와 함께 생성자를 실행하면 생성자 안에서 this의 값이 해당 타입의 인스턴스로 설정되는 성질을 이용하는데, new 연산자의 사용 여부에 따라 생성자의 동작을 달리 하도록 만든다.

profile
I'm on Wave, I'm on the Vibe.

0개의 댓글