요약
- 객체 지향 프로그래밍에선 내부 인터페이스와 외부 인터페이스를 구분하는 것을 [캡슐화(encapsulation)]라는 용어를 사용해 설명합니다.
- 캡슐화는 이점은 다음과 같습니다.
1. 사용자가 자신의 발등을 찍지 않도록 보호
커피 머신를 함께 사용하는 개발팀이 있다고 상상해봅시다. "Best CoffeeMachine"이라는 회사에서 만든 이 커피 머신은 현재 잘 작동하고 있지만, 보호 커버가 없어서 내부 인터페이스가 노출되어있는 상황입니다.
교양있는 팀원들은 모두 설계 의도에 맞게 커피 머신을 사용합니다. 그런데 어느 날 John이라는 개발자가 자신의 능력을 과신하며 커피 머신 내부를 살짝 만지게 됩니다. 이틀 후, 커피 머신은 고장이 나버렸죠.
커피 머신이 고장 난 건 John의 잘못이라기보다는, 보호 커버를 없애고 John이 마음대로 조작하도록 내버려 둔 사람의 잘못입니다.
프로그래밍에서도 마찬가지입니다. 외부에서 의도치 않게 클래스를 조작하게 되면 그 결과는 예측할 수 없게 됩니다.2. 지원 가능
실제 개발 과정에서 일어나는 상황은 커피 머신 사례보다 훨씬 복잡합니다. 커피 머신은 한번 구매하면 끝이지만 실제 코드는 유지보수가 끊임없이 일어나기 때문입니다.
내부 인터페이스를 엄격하게 구분하면, 클래스 개발자들은 사용자에게 알리지 않고도 자유롭게 내부 프로퍼티와 메서드들을 수정할 수 있습니다.
내부 인터페이스가 엄격히 구분된 클래스를 만지고 있다면, 그 어떤 외부 코드도 내부 private 메서드에 의존하고 있지 않기 때문에 private 메서드의 이름을 안전하게 바꿀 수 있고, 매개변수를 변경하거나 없앨 수도 있다는 것을 알아 두면 됩니다.
사용자 입장에선 새로운 버전이 출시되면서 내부 정비가 전면적으로 이뤄졌더라도 외부 인터페이스만 똑같다면 업그레이드가 용이하다는 장점이 있습니다.3. 복잡성 은닉
사람들은 간단한 것을 좋아합니다. 내부는 간단치 않더라도 최소한 외형은 간단해야 하죠.
프로그래머들도 예외는 아닙니다.
구현 세부 사항이 숨겨져 있으면 간단하고 편리해집니다. 외부 인터페이스에 대한 설명도 문서화하기 쉬워지죠.
내부 인터페이스를 숨기려면
protected
나private
프로퍼티를 사용하면 됩니다.
protected
필드는_
로 시작합니다._
은 자바스크립트에서 지원하는 문법은 아니지만,protected
필드를 나타낼 때 관습처럼 사용됩니다. 개발자는protected
프로퍼티가 정의된 클래스와 해당 클래스를 상속받는 클래스에서만_
가 붙은 필드에 접근해야 합니다.private
필드는#
로 시작하며, 자바스크립트에서 지원하는 문법입니다.#
로 시작하는 필드는 해당 필드가 정의된 클래스 내부에서만 접근 가능합니다.
모든 브라우저에서private
필드를 지원하진 않지만 폴리필을 구현하여 사용할 수 있습니다.
커피 머신으로 비유하자면 기계 안쪽에 숨어있는 뜨거운 물이 지나가는 관이나 발열 장치 등이 내부 인터페이스가 될 수 있습니다.
내부 인터페이스의 세부사항들은 서로의 정보를 이용하여 객체를 동작시킵니다. 발열 장치에 부착된 관을 통해 뜨거운 물이 이동하는 것처럼 말이죠.
그런데 커피 머신은 보호 커버에 둘러싸여 있기 때문에 보호 커버를 벗기지 않고는 커피머신 외부에서 내부로 접근할 수 없습니다. 밖에선 세부 요소를 알 수 없고, 접근도 불가능합니다. 내부 인터페이스의 기능은 외부 인터페이스를 통해야만 사용할 수 있습니다.
이런 특징 때문에 외부 인터페이스만 알아도 객체를 가지고 무언가를 할 수 있습니다. 객체 안이 어떻게 동작하는지 알지 못해도 괜찮다는 점은 큰 장점으로 작용합니다.
자바스크립트에는 아래와 같은 두 가지 타입의 객체 필드(프로퍼티와 메서드)가 있습니다.
- public: 어디서든지 접근할 수 있으며 외부 인터페이스를 구성합니다. 지금까지 다룬 프로퍼티와 메서드는 모두 public입니다.
- private: 클래스 내부에서만 접근할 수 있으며 내부 인터페이스를 구성할 때 쓰입니다.
class CoffeeMachine {
waterAmount = 0; // 물통에 차 있는 물의 양
constructor(power) {
this.power = power;
alert( `전력량이 ${power}인 커피머신을 만듭니다.` );
}
}
// 커피 머신 생성
let coffeeMachine = new CoffeeMachine(100);
// 물 추가
coffeeMachine.waterAmount = 200;
waterAmount
를 protected
로 바꿔서 waterAmount
를 통제해 보겠습니다. 예시로 waterAmount
를 0
미만의 값으로는 설정하지 못하도록 만들어 볼 겁니다.protected
프로퍼티 명 앞엔 밑줄 _
이 붙습니다.class CoffeeMachine {
_waterAmount = 0;
set waterAmount(value) {
if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다.");
this._waterAmount = value;
}
get waterAmount() {
return this._waterAmount;
}
constructor(power) {
this._power = power;
}
}
// 커피 머신 생성
let coffeeMachine = new CoffeeMachine(100);
// 물 추가
coffeeMachine.waterAmount = -10; // Error: 물의 양은 음수가 될 수 없습니다.
power
프로퍼티를 읽기만 가능하도록 만들어봅시다. 프로퍼티를 생성할 때만 값을 할당할 수 있고, 그 이후에는 값을 절대 수정하지 말아야 하는 경우가 종종 있는데, 이럴 때 읽기 전용 프로퍼티를 활용할 수 있습니다.class CoffeeMachine {
// ...
constructor(power) {
this._power = power;
}
get power() {
return this._power;
}
}
// 커피 머신 생성
let coffeeMachine = new CoffeeMachine(100);
alert(`전력량이 ${coffeeMachine.power}인 커피머신을 만듭니다.`); // 전력량이 100인 커피머신을 만듭니다.
coffeeMachine.power = 25; // Error (setter 없음)
getter와 setter 함수
- 위에서는
get
,set
문법을 사용해서getter
와setter
함수를 만들었습니다.- 하지만 대부분은 아래와 같이
get.../set...
형식의 함수가 선호됩니다.class CoffeeMachine { _waterAmount = 0; setWaterAmount(value) { if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다."); this._waterAmount = value; } getWaterAmount() { return this._waterAmount; } } new CoffeeMachine().setWaterAmount(100);
- 다소 길어보이긴 하지만, 이렇게 함수를 선언하면 다수의 인자를 받을 수 있기 때문에 좀 더 유연합니다(위 예시에선 인자가 하나뿐이긴 하지만요).
- 반면
get
,set
문법을 사용하면 코드가 짧아진다는 장점이 있습니다. 어떤걸 사용해야 한다는 규칙은 없으므로 원하는 방식을 선택해서 사용하세요.
protected
필드는 상속됩니다.
class MegaMachine extends CoffeeMachine
로 클래스를 상속받으면, 새로운 클래스의 메서드에서this._waterAmount
나this._power
를 사용해 프로퍼티에 접근할 수 있습니다.- 이렇게
protected
필드는 아래에서 보게 될private
필드와 달리, 자연스러운 상속이 가능합니다.
private
프로퍼티와 메서드는 제안(proposal) 목록에 등재된 문법으로, 명세서에 등재되기 직전 상태입니다.private
프로퍼티와 메서드는 #
으로 시작합니다. #
이 붙으면 클래스 안에서만 접근할 수 있습니다.private
프로퍼티 #waterLimit
과 남아있는 물의 양을 확인해주는 private
메서드 #checkWater
를 구현해봅시다.class CoffeeMachine {
#waterLimit = 200;
#checkWater(value) {
if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다.");
if (value > this.#waterLimit) throw new Error("물이 용량을 초과합니다.");
}
}
let coffeeMachine = new CoffeeMachine();
// 클래스 외부에서 private에 접근할 수 없음
coffeeMachine.#checkWater(); // Error
coffeeMachine.#waterLimit = 1000; // Error
#
은 자바스크립트에서 지원하는 문법으로, private
필드를 의미합니다. private
필드는 클래스 외부나 자손 클래스에서 접근할 수 없습니다.private
필드는 public
필드와 상충하지 않습니다. private
프로퍼티 #waterAmount
와 public
프로퍼티 waterAmount
를 동시에 가질 수 있습니다.#waterAmount
의 접근자 waterAmount
를 만들어봅시다.class CoffeeMachine {
#waterAmount = 0;
get waterAmount() {
return this.#waterAmount;
}
set waterAmount(value) {
if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다.");
this.#waterAmount = value;
}
}
let machine = new CoffeeMachine();
machine.waterAmount = 100;
alert(machine.#waterAmount); // Error
protected
필드와 달리, private
필드는 언어 자체에 의해 강제된다는 점이 장점입니다.CoffeeMachine
을 상속받는 클래스에선 #waterAmount
에 직접 접근할 수 없습니다. #waterAmount
에 접근하려면 waterAmount
의 getter
와 setter
를 통해야 합니다.class MegaCoffeeMachine extends CoffeeMachine {
method() {
alert( this.#waterAmount ); // Error: CoffeeMachine을 통해서만 접근할 수 있습니다.
}
}
CoffeeMachine
을 상속받는 클래스에선 CoffeeMachine
의 내부에 접근해야 하는 정당한 사유가 있을 수 있기 때문이죠. 언어 차원에서 protected
필드를 지원하지 않아도 더 자주 쓰이는 이유가 바로 여기에 있습니다.private 필드는 this[name]로 사용할 수 없습니다.
private
필드는 특별합니다. 알다시피, 보통은this[name]
을 사용해 필드에 접근할 수 있습니다.- 하지만
private
필드는this[name]
으로 접근할 수 없습니다. 이런 문법적 제약은 필드의 보안을 강화하기 위해 만들어졌습니다.class User { ... sayHi() { let fieldName = "name"; alert(`Hello, ${this[fieldName]}`); } }
// 메서드 하나를 추가합니다(더 많이 추가하는 것도 가능).
class PowerArray extends Array {
isEmpty() {
return this.length === 0;
}
}
let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false
let filteredArr = arr.filter(item => item >= 10);
alert(filteredArr); // 10, 50
alert(filteredArr.isEmpty()); // false
filter
, map
등의 내장 메서드가 상속받은 클래스인 PowerArray
의 인스턴스(객체)를 반환합니다. 이 객체를 구현할 땐 내부에서 객체의 constructor
프로퍼티를 사용합니다.arr.constructor === PowerArray
arr.filter()
가 호출될 때, 내부에선 기본 Array
가 아닌 arr.constructor
를 기반으로 새로운 배열이 만들어지고 여기에 필터 후 결과가 담깁니다. 이렇게 되면 PowerArray
에 구현된 메서드를 사용할 수 있다는 장점이 생깁니다.getter
인 Symbol.species
를 클래스에 추가할 수 있는데, Symbol.species
가 있으면 map
, filter
등의 메서드를 호출할 때 만들어지는 개체의 생성자를 지정할 수 있습니다. 원하는 생성자를 반환하기만 하면 되죠.map
이나 filter
같은 내장 메서드가 일반 배열을 반환하도록 하려면 아래 예시처럼 Symbol.species
가 Array
를 반환하도록 해주면 됩니다.class PowerArray extends Array {
isEmpty() {
return this.length === 0;
}
// 내장 메서드는 반환 값에 명시된 클래스를 생성자로 사용합니다.
static get [Symbol.species]() {
return Array;
}
}
let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false
// filter는 arr.constructor[Symbol.species]를 생성자로 사용해 새로운 배열을 만듭니다.
let filteredArr = arr.filter(item => item >= 10);
// filteredArr는 PowerArray가 아닌 Array의 인스턴스입니다.
alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function
.filter
가 Array
를 반환합니다. 따라서 더는 확장 기능이 전달되지 않습니다.Map
, Set
같은 컬렉션도 위와 같이 동작합니다.Object.keys
, Array.isArray
등의 자체 정적 메서드를 갖습니다.Array
는 Object
를 상속받죠.Array
와 Date
는 모두 Object
를 상속받기 때문에 두 클래스의 인스턴스에선 Object.prototype
에 구현된 메서드를 사용할 수 있습니다. 그런데 Array.[[Prototype]]
와 Date.[[Prototype]]
은 Object
를 참조하지 않기 때문에 Array.keys()
나 Date.keys()
같은 정적 메서드를 인스턴스에서 사용할 수 없습니다.Date
와 Object
를 직접 이어주는 링크가 없습니다. Date
와 Object
는 독립적이죠. Date.prototype
만 Object.prototype
를 상속받습니다.extends
를 사용한 상속의 가장 큰 차이점이 여기에 있습니다.(9.3 정적 메서드와 정적 프로퍼티)
요약
동작대상 반환값 typeof 원시형 문자열 {}.toString 원시형, 내장 객체, Symbol.toStringTag가 있는 객체 문자열 instanceof 객체 true나 false
- 예시에서 보았듯이
{}.toString은 typeof
보다 ‘기능이 더’ 많습니다.instanceof
연산자는 계층 구조를 가진 클래스를 다룰 때나 클래스의 상속 여부를 확인하고자 할 때 그 진가를 발휘합니다.
instanceof
연산자를 사용하면 객체가 특정 클래스에 속하는지 아닌지를 확인할 수 있습니다. instanceof
는 상속 관계도 확인해줍니다.instanceof
를 사용해 인수의 타입에 따라 이를 다르게 처리하는 다형적인(polymorphic) 함수를 만드는데 사용해보겠습니다.obj instanceof Class
obj
가 Class
에 속하거나 Class
를 상속받는 클래스에 속하면 true
가 반환됩니다.class Rabbit {}
let rabbit = new Rabbit();
// rabbit이 클래스 Rabbit의 객체인가요?
alert( rabbit instanceof Rabbit ); // true
instanceof
는 생성자 함수에서도 사용할 수 있습니다.// 클래스가 아닌 생성자 함수
function Rabbit() {}
alert( new Rabbit() instanceof Rabbit ); // true
Array
같은 내장 클래스에도 사용할 수 있습니다.let arr = [1, 2, 3];
alert( arr instanceof Array ); // true
alert( arr instanceof Object ); // true
arr
은 클래스 Object
에도 속한다는 점에 주목해주시기 바랍니다. Array
는 프로토타입 기반으로 Object
를 상속받습니다.instanceof
연산자는 보통, 프로토타입 체인을 거슬러 올라가며 인스턴스 여부나 상속 여부를 확인합니다. 그런데 정적 메서드 Symbol.hasInstance
을 사용하면 직접 확인 로직을 설정할 수도 있습니다.obj instanceof Class
은 대략 아래와 같은 알고리즘으로 동작합니다.Symbol.hasInstance
가 구현되어 있으면, obj instanceof Class
문이 실행될 때, Class[Symbol.hasInstance](obj)
가 호출됩니다. 호출 결과는 true
나 false
이어야 합니다. 이런 규칙을 기반으로 instanceof
의 동작을 커스터마이징 할 수 있습니다.// canEat 프로퍼티가 있으면 animal이라고 판단할 수 있도록
// instanceOf의 로직을 직접 설정합니다.
class Animal {
static [Symbol.hasInstance](obj) {
if (obj.canEat) return true;
}
}
let obj = { canEat: true };
alert(obj instanceof Animal); // true, Animal[Symbol.hasInstance](obj)가 호출됨
Symbol.hasInstance
가 구현되어있지 않습니다. 이럴 땐 일반적인 로직이 사용됩니다. obj instanceOf Class
는 Class.prototype
이 obj
프로토타입 체인 상의 프로토타입 중 하나와 일치하는지 확인합니다. 비교는 차례 차례 진행됩니다.obj.__proto__ === Class.prototype?
obj.__proto__.__proto__ === Class.prototype?
obj.__proto__.__proto__.__proto__ === Class.prototype?
...
// 이 중 하나라도 true라면 true를 반환합니다.
// 그렇지 않고 체인의 끝에 도달하면 false를 반환합니다.
rabbit.__proto__ === Rabbit.prototype
가 true
이기 때문에 instanceof
는 true
를 반환합니다.class Animal {}
class Rabbit extends Animal {}
let rabbit = new Rabbit();
alert(rabbit instanceof Animal); // true
// rabbit.__proto__ === Rabbit.prototype
// rabbit.__proto__.__proto__ === Animal.prototype (일치!)
objA
가 objB
의 프로토타입 체인 상 어딘가에 있으면 true
를 반환해주는 메서드, objA.isPrototypeOf(objB)
도 있습니다. obj
instanceof Class
는 Class.prototype.isPrototypeOf(obj)
와 동일합니다.isPrototypeOf
는 Class
생성자를 제외하고 포함 여부를 검사하는 점이 조금 특이합니다. 검사 시, 프로토타입 체인과 Class.prototype
만 고려합니다.isPrototypeOf
의 이런 특징은 객체 생성 후 prototype
프로퍼티가 변경되는 경우 특이한 결과를 초래하기도 합니다. 아래와 같이 말이죠.function Rabbit() {}
let rabbit = new Rabbit();
// 프로토타입이 변경됨
Rabbit.prototype = {};
// 더 이상 Rabbit이 아닙니다!
alert( rabbit instanceof Rabbit ); // false
[object Object]
가 된다는 것을 알고 계실 겁니다.let obj = {};
alert(obj); // [object Object]
alert(obj.toString()); // 같은 결과가 출력됨
[object Object]
가 되는 이유는 toString
의 구현방식 때문입니다. 그런데 toString
엔 toString
을 더 강력하게 만들어주는 기능이 숨겨져 있습니다. toString
의 숨겨진 기능을 사용하면 확장 typeof
, instanceof
의 대안을 만들 수 있습니다.toString
을 추출하는 게 가능합니다. 이렇게 추출한 메서드는 모든 값을 대상으로 실행할 수 있습니다. 호출 결과는 값에 따라 달라집니다.[object Number]
[object Boolean]
null
– [object Null]
undefined
– [object Undefined]
[object Array]
// 편의를 위해 toString 메서드를 변수에 복사함
let objectToString = Object.prototype.toString;
// 아래 변수의 타입은 무엇일까요?
let arr = [];
alert( objectToString.call(arr) ); // [object Array]
call/apply
와 데코레이터, 포워딩 챕터에서 설명한 call
을 사용해 컨텍스트를 this=arr
로 설정하고 함수 objectToString
를 실행하였습니다.toString
알고리즘은 내부적으로 this
를 검사하고 상응하는 결과를 반환합니다. 예시를 더 살펴봅시다.let s = Object.prototype.toString;
alert( s.call(123) ); // [object Number]
alert( s.call(null) ); // [object Null]
alert( s.call(alert) ); // [object Function]
Symbol.toStringTag
를 사용하면 toString
의 동작을 커스터마이징 할 수 있습니다.let user = {
[Symbol.toStringTag]: "User"
};
alert( {}.toString.call(user) ); // [object User]
// 특정 호스트 환경의 객체와 클래스에 구현된 toStringTag
alert( window[Symbol.toStringTag]); // Window
alert( XMLHttpRequest.prototype[Symbol.toStringTag] ); // XMLHttpRequest
alert( {}.toString.call(window) ); // [object Window]
alert( {}.toString.call(new XMLHttpRequest()) ); // [object XMLHttpRequest]
Symbol.toStringTag
값은 [object ...]
로 쌓여진 값과 동일합니다.toString
과 toStringTag
– 옮긴이)은 원시 자료형뿐만 아니라 내장 객체에도 사용할 수 있습니다. 그리고 커스터마이징까지 가능합니다.instanceof
대신, {}.toString.call
을 사용할 수 있습니다.