본문 바로가기

0/javascript

Learning JavaScript 요약 정리 (3) 8장~10장 (배열, 객체지향 프로그래밍, 맵과 셋)

반응형

8장 배열과 처리

배열의 마법 reduce

누적값이 제공되지 않으면 reduce는 첫 번째 배열 요소를 초기값으로 보고 두 번째 요소에서부터 함수를 호출한다.

const words = ["Beachball", "Rodeo", "Angel", "Aardvark", "Xylophone",
"Npvember", "Chocolate"];
const longWords = words.reduce((a, w) => w.length>6 ? a + " " + w : a, "").trim();

 

9장 객체와 객체지향 프로그래밍

OOP의 기본 아이디어는 단순하고 직관적이다. 객체는 데이터와 기능을 논리적으로 묶어놓은 것이다. 

OOP의 기본 용어에 대해 알아보자.

  • 클래스 : 추상적이고 범용적인 것 (ex. 어떤 자동차)
  • 인스턴스 : 구체적이고 한정적인 것 (ex. 특정 자동차)
  • 메서드 : 기능
  • 클래스 메서드 : 클래스에 속하지만 특정 인스턴스에 묶이지 않는 기능
  • 생성자 : 인스턴스를 처음 만들 때 실행됨.

ES6에서는 클래스를 만드는 간편한 새 문법을 도입했다.

class Car {
    constructor(make, model){
    	this.make = make;
        this.model = model;
        this.userGears = ['P', 'N', 'R', 'D'];
        this.userGear = this.userGears[0];
    }
    shift(gear){
    	if(this.userGears.indexOf(gear)<0)
        	throw new Error(`Invalid gear: ${gear}`);
        this.userGear = gear;
    }
}

여기서 this 키워드는 의도한 목적, 즉 메서드를 호출한 인스턴스를 가리키는 목적으로 쓰였다.

const car1 = new Car("Tesla", "ModelS");
const car2 = new Car("Mazda", "3i");

car1.shift('D'); // this는 car1에 묶임.
car2.shift('R'); // this는 car2에 묶임.

car1.userGear // 'D'
car2.userGear // 'R'

 직접 car1.userGear = 'X' 라고 설정하는 것을 막을 수 없다.

class Car {
    constructor(make, model){
    	this.make = make;
        this.model = model;
        this._userGears = ['P', 'N', 'R', 'D'];
        this._userGear = this._userGears[0];
    }
    get userGear(){ return this._userGear; }
    set userGear(value){
    	if(this._userGears.indexOf(value)<0)
        	throw new Error(`Invalid gear: ${value}`);
        this._userGear = value;
    }
    
    shift(gear) { this.userGear = gear; }
}

이 또한 직접 car1._userGear = 'X'처럼 변경할 수 있다. 이 예제에서는 외부에서 접근하면 안되는 프라퍼티 이름 앞에 밑줄을 붙이는, 소위 '가짜 접근 제한'을 사용했다. 진정한 제한이라기보다는 "아, 밑줄이 붙은 프로퍼티에 접근하려고 하네? 이건 실수로군."하면서 빨리 찾을 수 있도록 하는 방편이라고 봐야한다.

프로퍼티를 꼭 보호해야 한다면 스코프를 이용해 보호하는 WeakMap 인스턴스를 사용할 수 있다. Car 클래스를 다음과 같이 고치면 기어 프로퍼티를 완벽하게 보호할 수 있다.

const Car = (function(){
    const carProps = new WeakMap();
    
    class Car{
    	constructor(make, model){
            this.make = make;
            this.model = model;
            this._userGears = ['P', 'N', 'R', 'D'];
            carProps.set(this, {userGear: this._userGears[0] });
        }
        get userGear() { return carProps.get(this).userGear; }
        set userGear(value) {
    	    if(this._userGears.indexOf(value) < 0)
            	throw new Error(`Invalid gear: ${value}`);
            carProps.get(this).userGear = value;
        }
        
        shiff(gear) { this.userGear = gear; }
    }
    
    return Car;
})();

여기서는 즉시 호출하는 함수 표현식을 써서 WeakMap을 클로저로 감싸고 바깥에서 접근할 수 없게 헀다. WeakMap은 클래스 외부에서 접근하면 안 되는 프로퍼티를 안전하게 저장한다.

간편한 새 문법이 생겼을 뿐 클래스가 바뀐 것은 아니다.

프로토타입

클래스의 인스턴스에서 사용할 수 있는 메서드라고 하면 그건 프로토타입 메서드를 말하는 것이다. 예를 들어 Car의 인스턴스에서 사용할 수 있는 shift 메서드는 프로토타입 메서드이다. 프로퇕입 메서드는 Car.prototype.shift 처럼 표기할 때가 많다. 이제 프로토타입이 무엇인지, 자바스크립트가 프로토타입 체인을 통해 어떻게 동적 디스패치를 구현하는지 알아보자.

...더보기

최근에는 프로토타입 메서드를 #로 표시하는 기법이 널리 쓰인다. 예를 들어 Car.prototype.shifft를 Car#shift로 쓰는 것이다.

모든 함수에는 prototype이라는 특별한 프로퍼티가 있다. 일반적인 함수에서는 프로토타입을 사용할 일이 없지만, 객체 생성자로 동작하는 함수에서는 프로토타입이 대단히 중요하다.

함수의 prototype 프로퍼티가 중요해지는 시점은 new 키워드로 새 인스턴스를 만들었을 때이다. new 키워드로 만든 새 객체는 생성자의 prototype 프로퍼티에 접근할 수 있다. 인스턴스는 생성자의 prototype 프로퍼티를 __proto__ 프로퍼티에 저장한다. (자바스크립트의 내부 동작 방식에 영향을 미치기 때문에 수정하지 않는 것을 권함.)

프로토타입에서 중요한 것은 동적 디스패치(호출)라는 메커니즘이다. 객체의 프로퍼티나 메서드에 접근하려 할 때 그런 프로퍼티나 메서드가 존재하지 않으면 자바스크립트는 객체의 프로토타입에서 해당 프로퍼티나 메서드를 찾는다. 클래스의 인스턴스는 모두 같은 프로토타입을 공유하므로 프로토타입에 프로퍼티나 메서드가 있다면 해당 클래스의 인스턴스는 모두 그 프로퍼티나 메서드에 접근할 수 있다.

인스턴스에서 메서드나 프로퍼티를 정의하면 프로토타입에 있는 것을 가리는 효과가 있다. 자바스크립트는 먼저 인스턴스를 체크하고 거기에 없으면 프로토타입을 체크하기 때문이다.

const car1 = new Car();
const car2 = new Car();

car1.shift === Car.prototype.shift; // true
car1.shift('D'); 
car1.shift('d'); // error
car1.userGear; // 'D'
car1.shift === car2.shift // true

car1.shift = function(gear){ this.userGear = gear.toUpperCase(); }
car1.shift === Car.prototype.shift; // false
car1.shift === car2.shift // false
car1.shift('d');
car1.userGear; // 'D'

이 예제는 자바스크립트에서 동적 디스패치를 어떻게 구현하는지 잘 보여준다. car1 객체에는 shift 메서드가 없지만 car1.shift('D')를 호출하면 자바스크립트는 car1의 프로토타입에서 그런 이름의 메서드를 검색한다. car1에 shift 메서드를 추가하면 car1과 프로토타입에 같은 이름의 메서드가 존재하게 된다. 이제 car1.shift('d')를 호출하면 car1의 메서드가 호출되고 프로토타입의 메서드는 호출되지 않는다.

정적 메서드

지금까지 우리는 인스턴스 메서드, 즉 인스턴스에서 사용하게끔 만든 메서드를 주로 살펴봤다. 메서드에는 인스턴스 메서드 외에도 정적 메서드(클래스 메서드)가 있다. 이는 특정 인스턴스에 적용되지 않는다. 정적 메서드에서 this는 인스턴스가 아니라 클래스 자체에 묶인다. 하미나 일반적으로 정적 메서드에는 this 대신 클래스 이름을 사용하는 것이 좋은 습관이다.

정적 메서드는 클래스와 관련되지만 인스턴스와는 관련이 없는 범용적인 작업에 사용된다.

class Car {
    static getNextVin(){
    	return Car.nextVin++; // this.nextVin 이라고 써도 되지만, Car를 앞에 쓰면 정적 메서드라는 점을 상기하기 쉽다. 
    }
    
    constructor(make, model){
    	this.make = make;
        this.model = model;
        this.vin = Car.getNextVin();
    }
    
    static areSimilar(car1, car2){
    	return car1.make === car2.make && car1.model === car2.model
    }
    
    static areSame(car1, car2){
    	return car1.vin === car2.vin;
    }
}

Car.nextVin = 0;

const car1 = new Car("Tesla", "S");
const car2 = new Car("Mazda", "3");
const car3 = new Car("Mazda", "3");

car1.vin; // 0
car2.vin; // 1
car3.vin; // 2

Car.areSimilar(car1, car2); // false
Car.areSimilar(car2, car3); // true
Car.areSame(car2, car3); // false
Car.areSame(car2, car2); // true

상속

클래스의 인스턴스는 클래스의 기능을 모두 상속한다. 상속은 한 단계로 끝나지 않는다. 객체의 프로토타입에서 메서드를 찾지 못하면 자바스크립트는 프로토타입의 프로토타입을 검색한다. 프로토타입 체인은 이런 식으로 만들어진다. 자바스크립트는 조건에 맞는 프로토타입을 찾을 때까지 프로토타입 체인을 계속 거슬러 올라간다. 조건에 맞는 프로토타입을 찾지 못하면 에러를 일으킨다.

클래스의 계층 구조를 만들 때 프로토타입 체인을 염두에 두면 효율적인 구조를 만들 수 있다. 즉, 프로토타입 체인에서 가장 적절한 위치에 메서드를 정의하는 것이다. 자동차는 운송 수단의 일종이다. 예를 들어 자동차에는 deployAirbas란 메서드가 있을 수 있다. 이 메서드를 운송 수단 클래스에 정의할 수도 있겠지만, 에어백이 달린 보트는 본 적이 없지 않은가? 반면 운송 수단은 대부분 승객을 태울 수 있으므로, addPassenger 메서드는 운송 수단 클래스에 적당하다.

class Vehicle{
    constructor(){
    	this.passengers = [];
        console.log("Vehicle created");
    }
    addPassenger(p){
    	this.passengers.push(p);
    }
}

class Car extends Vehicle{
    constructor(){
    	super();
        console.log("Car created");
    }
    deployAirbags(){
    	console.log("BWOOSH!");
    }
}

extends 키워드는 Car를 Vehicle의 서브클래스로 만든다. super()도 처음 보는 것이다. super()는 슈퍼클래스의 생성자를 호출하는 특별한 함수이다. 서브클래스에서는 이 함수를 반드시 호출해야 한다.

const v = new Vehicle();
v.addPassenger("Frank");
v.addPassenger("Judy");
v.passengers; // ["Frank", "Judy"]
const c = new Car();
c.addPassenger("Alice");
c.addPassenger("Cameron");
c.passengers; // ["Alice", "Cameron"]
v.deployAirbags(); // error
v.deployAirbags(); // "BWOOSH!"

c에서는 deployAirbags를 호출할 수 있지만, v에서는 불가능하다. 달리 말하면, 상속은 (당연히) 단방향이다. Car 클래스의 인스턴스는 Vehicle 클래스의 모든 메서드에 접근할 수 있지만, 반대는 불가능하다.

다형성

객체지향 언어에서 여러 슈퍼클래스의 멤버인 인스턴스를 가리키는 말이다.

자바스크립트 코드를 작성하다 보면 '이런 메서드가 있고 저런 메서드가 있으니 아마 그 클래스의 인스턴스일 것이다'처럼 짐작할 때가 많다. Car 예제에 적용해 본다면, deployAirbags 메서드가 있는 객체는 Car의 인스턴스라고 생각할 수 있다. 물론 아닐 수도 있지만, 그 같은 짐작이 근거 없는 추측은 절대 아니다.

자바스크립트에는 객체가 클래스의 인스턴스인지 확인하는 instanceof 연산자가 있다. 이 연산자를 속일 수도 있지만 prototype과 __proto__ 프로퍼티에 손대지 않았다면 정확한 결과를 기대할 수 있다. 

class Motocycle extends Vehicle {}

const c = new Car();
const m = new Motorcycle();

c instanceof Car; // true
c instanceof Vehicle; // true
m instanceof Car; // false
m instanceof Motorcycle; // true
m instanceof Vehicle; // true

* 자바스크립트의 모든 객체는 루트 클래스인 Object의 인스턴스이다. 즉, 객체 o에서 o instanceof Object는 항상 true이다. 모든 객체가 Object의 인스턴스인 것은 toString 같은 중요한 메서드를 상속하기 위해서이며, 염두에 둘 만큼 중요한 영향은 없다.

객체 프로퍼티 나열

객체 obj와 프로퍼티 x에서, obj.hasOwnProperty(x)는 obj에 프로퍼티 x가 있다면 true를 반환하며, 프로퍼티 x가 obj에 정의되지 않았거나 프로토타입 체인에만 정의되었다면 false를 반환한다.

ES6 클래스를 설계 의도대로 사용한다면 데이터 프로퍼티는 항상 프로토타입 체인이 아니라 인스턴스에 정의해야 한다. 하지만 프로퍼티를 프로토타입에 정의하지 못하도록 강제하는 장치는 없으므로 확실히 확인하려면 항상 hasOwnProperty를 사용하는 편이 좋다.

class Super{
    constructor(){
    	this.name = 'Super';
        this.isSuper = true;
    }
}

// 유효하지만, 권장 x
Super.prototype.sneaky = 'not recommended!';

class Sub extends Super{
    constructor(){
    	super();
        this.name = 'Sub';
        this.isSub = true;
    }
}

const obj = new Sub();
for(let p in obj){
	console.log(`${p} : ${obj[p]}` +
    	(obj.hasOwnProperty(p) ? '' : ' (inherited)'));
}

 name, isSuper, isSub 프로퍼티는 모두 프로토타입 체인이 아니라 인스턴스에 정의됐다(슈퍼클래스 생성자에서 선언한 프로퍼티는 서브클래스 인스턴스에도 정의된다). 반면 sneaky 프로퍼티는 슈퍼클래스의 프로토타입에 정의했다.

Object.keys()를 사용하면 프로토타입 체인에 정의된 프로퍼티를 나열하는 문제를 피할 수 있다. 

다중 상속, 믹스인, 인터페이스

일부 객체지향 언어에서는 다중 상속이란 기능을 지원한다. 이 기능은 클래스가 슈퍼클래스 두 개를 가지는 기능이며, 슈퍼클래스의 슈퍼클래스가 존재하는 일반적인 상속과는 다르다. 다중 상속에는 충돌의 위험이 있다. 예를 들어 어떤 클래스에 두 개의 슈퍼 클래스가 있고, 두 슈퍼클래스에 모두 greet 메서드가 있다면 서브클래스는 어느 쪽의 메서드를 상속해야할까? 다중 상속을 지원하지 안흔ㄴ 언어가 많은 이유는 이런 문제를 피하기 위해서이다.

자바스크립트는 흥미로운 방시긍로 이들을 절충했다. 자바스크립트는 프로토타입 체인에서 여러 부모를 검색하지는 않으므로 단일 상속 언어라고 해야하지만, 어떤 면에서는 다중 상속이나 인터페이스보다 더 나은 방법을 제공한다(물론 더 못할 때도 있다).

자바스크립트가 다중 상속이 필요한 문제에 대한 해답으로 내놓은 개념이 믹스인(mixin)이다. 믹스인이란 기능을 필요한 만큼 섞어 놓은 것이다. 자바스크립트는 느슨한 타입을 사용하고 대단히 관대한 언어이므로 그 어떤 기능이라도 언제든, 어떤 객체에든 추가할 수 있다. 

자동차에 적용할 수 있는 보험 가입 믹스인을 만들어보자. 믹스인 외에도 InsurancePolicy 클래스를 만들자.

class InsurancePolicy {}
function makeInsurable(o){
    o.addInsurancePolicy = function(p){ this.insurancePolicy = p; }
    o.getInsurancePolicy = function(){ return this.insurancePolicy; }
    o.isInsured = function(){ return !!this.insurancePolicy; }
}

const car1 = new Car();
makeInsurable(car1);
car1.addInsurancePolicy(new InsurancePolicy()); // works

이 방법은 동작하지만, 모든 자동차에서 makeInsurable을 호출해야 한다. Car 생성자에 추가할 수도 있지만, 그렇게 하면 이 기능을 모든 자동차에 복사하는 형국이 된다. 다행히 해결책은 쉬운 편이다.

makeInsurable(Car.prototype);
const car1 = new Car();
car1.addInsurancePolicy(new InsurancePolicy()); // works

이제 보험 관련 메서드들은 모두 Car 클래스에 정의된 것처럼 동작한다. 자바스크립트의 관전에서는 실제로 그렇다. 개발자의 관점에서는 중요한 두 클래스를 관리하기 쉽게 만들었다. 자동차 회사에서 Car 클래스의 개발과 관리를 담당하고, 보험 회사에서 InsurancePolicy 클래스와 makeInsurable 믹스인을 관리하게 된다. 두 회사의 업무가 충돌할 가능성을 완전히 없앤 건 아니지만, 모두 거대한 Car 클래스에 달라붙어 일하는 것보다는 낫다.

물론 믹스인이 모든 문제를 해결해 주지는 않는다. 보험 회사에서 shift 메서드를 만들게 된다면 Car 클래스의 동작이 이상해질 것이다. instanceof 연산자로 보험에 가입할 수 있는 객체를 식별할 수도 없다. 'addInsurancePolicy 메서드가 있다면 틀림없이 보험에 가입할 수 있다'는 식의 짐작만 가능할 뿐이다.

심볼을 사용하면 이런 문제 일부를 경감할 수 있다. 보험 회사에서 매우 범용적인 메서드 이름을 계속 사용해서, 우연히 Car 클래스의 메서드와 충돌할까 봐 걱정된다고 가정하자. 그러면 보험 회사에 키를 모두 심볼로 사용해 달라고 요청할 수 있다. 보험사가 제공하는 믹스인은 다음과 같은 형태가 될 것이다.

class InsurancePolicy {}

const ADD_POLICY = Symbol();
const GET_POLICY = Symbol();
const IS_INSURED = Symbol();
const _POLICY = Symbol();

function makeInsurable(o){
    o[ADD_POLICY] = function(p){ this[_POLICY] = p; }
    o[GET_POLICY] = function(){ return this[_POLICY]; }
    o[IS_INSURED] = function(){ return !!this[_POLICY]; }
}

심볼은 항상 고유하므로 믹스인이 Car 클래스의 기능과 충돌할 가능성은 없다.

 

10장 맵과 셋

ES6 이전에는 키와 값을 연결하려면 객체를 사용해야 헀다. 하지만 객체를 이런 목적으로 사용하면 여러가지 단점이 생긴다.

  • 프로토타입 체인 때문에 의도하지 않은 연결이 생길 수 있다.
  • 객체 안에 연결된 키와 값이 몇 개나 되는지 쉽게 알아낼 수 있는 방법이 없다.
  • 키는 반드시 문자열이나 심볼이어야 하므로 객체를 키로 써서 값과 연결할 수 없다.
  • 객체는 프로퍼티 순서를 전혀 보장하지 않는다.

Map 객체는 이들 결함을 모두 해결했고, 키와 값을 연결할 목적이라면 객체보다 나은 선택이다.

const u1 = { name: 'Beenzino' };
const u2 = { name: 'Post Malone' };

const userRoles = new Map();

userRoles.set(u1, 'User');
userRoles.set(u2, 'Admin');

// 또는

userRoles
	.set(u1, 'User')
	.set(u2, 'Admin');
        
userRoles.get(u2); // 'Admin'
userRoles.size; // 2

userRoles.delete(u2);
userRoles.size; // 1

userRoles.clear();
userRoles.size; // 0
for(let u of userRoles.keys())
	console.log(u.name);
    
for(let r of userRoles.values())
	console.log(r);

// 맵도 분해가 가능하다.
for(let [u, r] of userRoles.entries())
	console.log(`${u.name}: ${r}`);
    
// entries()는 맵의 기본 이터레이터이다.
for(let [u, r] of userRoles)
	console.log(`${u.name}: ${r}`);

 

위크맵

WeakMap은 다음 차이점을 제외하면 Map과 완전히 같다.

  • 키는 반드시 객체여야 한다.
  • WeakMap의 키는 가비지 콜렉션에 포함될 수 있다.
  • WeakMap은 이터러블이 아니며 clear() 메서드도 없다.

일반적으로 자바스크립트는 코드 어딘가에서 객체를 참조하는 한 그 객체를 메모리에 계속 유지한다. 예를 들어 Map의 키인 객체 o가 있다면, 자바스크립트는 Map이 존재하는 한 o를 메모리에 계속 유지한다. WeakMap에서는 그렇지 않다. 따라서 WeakMap은 이터러블이 될 수 없다. 가비지 콜렉션 중인 객체를 노출할 위험이 너무 크기 때문이다.

WeakMap의 이런 특징은 객체 인스턴스의 전용(private) 키를 저장하기에 알맞다.

const SecretHolder = (function(){
    const secrets = new WeakMap();
    return class {
    	setSecret(secret){
        	secrets.set(this, secret);
        }
        getSecret(){
        	return secrets.get(this);
        }
    }
})();
const a = new SecretHolder();
const b = new SecretHolder();

a.setSecret('secret A');
b.setSecret('secret B');

a.getSecret(); // "secret A"
b.getSecret(); // "secret B"

일반적인 Map을 써도 되지만, 그렇게 하면 SecretHolder 인스턴스에 저장한 내용은 가비지 콜렉션에 포함되지 않는다.

 

위크셋

셋은 중복을 허용하지 않는 데이터 집합이다. 위크셋은 객체만 포함할 수 있으며, 이 객체들은 가비지 콜렉션의 대상이 된다. WeakMap과 마찬가지로 WeakSet도 이터러블이 아니므로 위크셋의 용도는 매우 적다. 위크셋의 실제 용도는 주어진 객체가 셋 안에 존재하는지 아닌지를 알아보는 것뿐이라고 해도 과언이 아니다.

const naughty = new WeakSet();

const children = [
	{name : "Suzy"},
	{name : "Derek"},
];

naughty.add(children[1]);

for(let child of children){
    if(naughty.has(child))
    	console.log(`Coal for ${child.name}!`);
    else
    	console.log(`Presents for ${child.name}!`);
}

객체를 만들려 할 때마다 잠시 멈추고, 객체를 맵 대신 쓰려고 하는 건지 생각해 보라. 만약 그렇다면, Map을 대신 쓰는 걸 고려해보자.

 

이 글은 이선 브라운. n.d. Learning JavaScript. n.p.: 한빛미디어(주).를 참고하여 정리한 글입니다. 

반응형