📦

[JS] 객체지향 프로그래밍(Object-Oriented Programming) - 1


캡슐화(Encapsulation)

객체 지향의 핵심은 프로그램을 작은 조각으로 나누는 것이다. 각 조각들은 자신의 상태를 자체적으로 관리한다.

이 조각들은 객체를 사용해서 모델링한다. 객체들은 interface를 통해 서로 상호작용한다. 각 인터페이스는 특정 프로퍼티와 메서드로 구성된다. 인터페이스의 일부분인 프로퍼티를 'public'이라고 하고, 외부에서 접근하지 못하는 나머지 프로퍼티를 'private'이라고 한다. Interface를 제공함으로써 안의 세부 구현을 숨길 수 있게 된다.

이렇게 인터페이스와 구현을 분리하는 방법을 '캡슐화'라고 한다.

메서드와 this

객체의 프로퍼티에 할당된 함수를 '메서드(method)'라고 부른다.

프로퍼티에 굳이 함수를 넣어주는 이유는 객체에 저장된 정보와 함수의 기능이 어떤 연관을 가지기 때문일 것이다. 그렇다면 메서드에서 객체 프로퍼티 값에 어떻게 접근할까?

함수가 메서드로 호출되면, 함수 내부에서 사용되는 this 키워드는 자동적으로 호출한 객체를 가리킨다.

1function speak(line) {
2 console.log(`The ${this.type} rabbit says '${line}'`);
3}
4let whiteRabbit = { type: "white", speak };
5whiteRabbit.speak("Give me carrot");
6// -> The white rabbit says 'Give me carrot'

명시적으로 this 매개변수를 전달할 수도 있다. 함수의 call 메서드는 첫 번째 인수로 this의 값을 받고 나머지 인수를 일반 파라미터로 받는다.

1speak.call(whiteRabbit, "Brup!");
2// -> The white rabbit says 'Burp!'

this가 없는 화살표 함수

모든 함수는 자신만의 this를 가진다. 따라서 function 키워드로 정의한 함수 안에서 주변 범위의 this를 참조할 수 없다.

근데 arrow function은 좀 다르다. 요놈은 자신만의 this를 가지지 않는 대신, 주변 범위의 this를 참조할 수 있다. 아래 예를 보자.

1function normalize() {
2 console.log(this.coords.map(n => n / this.length));
3}
4normalize.call({coords: [0, 2, 3]}, length: 5);
5// -> [0, 0.4, 0.6]

화살표 함수를 사용했기 때문에 this.lengththis는 주변 범위 함수인 map의 객체인 coords를 참조할 수 있게된다. 화살표 함수 대신 function 키워드를 사용히면 thismap를 가리키기 때문에 코드가 동작하지 않는다.

자유로운 this

자바스크립트에서 this의 대상은 런타임에 결정된다. 동일한 함수라도 다른 객체에서 호출됐다면 this가 참조하는 값이 달라진다.

1let woong = { name: "Woong-jae" };
2let cheol = { name: "Cheol-su" };
3
4function sayHi() {
5 console.log(this.name);
6}
7
8woong.f = sayHi;
9cheol.f = sayHi;
10
11woong.f();
12cheol.f();
13// -> Woong-jae
14// -> Cheol-su

잃어버린 this

자유롭다는 특징 때문에 this 정보가 사라지는 문제도 발생한다. 특히 콜백으로 메서드롤 넘길 때 자주 일어난다.

setTimeout의 경우를 보자.

1let user = {
2 firstName: "Woong",
3 sayHi() {
4 console.log(`Hello, ${this.firstName}!`);
5 }
6};
7
8setTimeout(user.sayHi, 1000);
9// -> Hello, undefined!

setTimeout에 분리된 함수인 user.sayHi가 할당돼서 this의 컨텍스트를 잃어버리게 된다. 이때 컨텍스트를 제대로 유지하기 위해서는 어떻게 해야할까?

래퍼 함수 사용하기

1setTimeout(function () {
2 user.sayHi();
3}, 1000);
4// -> Hello, Woong!

함수를 호출하기 때문에 외부 렉시컬 환경에서 user를 찾아 원하던 대로 동작하게 된다.

이 방법대로 구현하면 setTimeout이 트리거 되기 전에 user.sayHi가 변경되면, 변경된 객체의 메서드가 호출된다는 것이 취약점이다.

bind

모든 함수는 this를 수정하게 해주는 내장 메서드 bind가 제공된다.

1let boundFunc = func.bind(context);

bind의 리턴 값으로 함수처럼 호출 가능한 특수 객체가 반환된다. 이 객체를 호출하면 thiscontext로 고정된 함수 func가 반환된다. 쉽게 말하면 bind를 통해 thiscontext로 고정한 함수를 얻을 수 있다.

1setTimeout(user.sayHi.bind(user), 1000);
2// -> Hello, Woong!

bind는 인수도 바인딩 가능하다.

프로토타입(Prototypes)

자바스크립트의 객체는 [[Prototype]]이라는 숨은 프로퍼티를 갖는다. 이 프로퍼티의 값은 null이거나 다른 객체에 대한 참조가 되는데, 다른 객체를 참조하는 경우 참조 대상을 '프로토타입'이라 부른다.

어떤 객체에서 프로퍼티를 읽으려고 하는데 해당 프로퍼티가 없으면 자바스크립트는 자동으로 프로토타입에서 프로퍼티를 찾는다. 이것을 '프로토타입 상속'이라 부른다.

[[Prototype]]은 숨김 프로퍼티이지만, 개발자가 직접 값을 설정할 수 있다.

Object.getPrototypeOf/Object.setPrototypeOf 메서드는 프로토타입의 getter와 setter 역할을 한다.

1let animal = { eats: true };
2let rabbit = { jumps: true };
3Object.setPrototypeOf(rabbit, animal);
4console.log(rabbit.eats);
5// -> true

rabbiteats 프로퍼티가 없지만, 자바스크립트는 [[Prototype]]이 참조하고 있는 객체인 animal에서 eats를 얻어낸다. 이렇게 프로토타입에서 상속받은 프로퍼티를 '상속 프로퍼티(inherited property)'라고 한다.

rabbitanimal을, animalObject.prototype을, Object.prototypenull을 상속받고 있다. 거의 모든 프로토타입은 조상으로 Object.prototype을 가진다.

animalObject.prototype을 상속받는 이유는 객체 리터럴로 선언했기 때문이다.

객체의 프로토타입을 변경하는 것은 브라우저 및 자바스크립트 엔진에서 매우 느린 작업이다. 상속 구조를 변경하는 것은 광범위한 영향을 미치고, 프로토타입이 변경된 객체에 접근할 수 있는 모든 코드들에도 영향을 줄 수 있기 때문이다.

따라서 Object.setPrototypeOf보다는 Object.create를 사용해 원하는 프로토타입으로 객체를 만드는 것이 바람직하다.

1let protoRabbit = {
2 speak(line) {
3 console.log(`The ${this.type} rabbit says '${line}'`);
4 }
5}
6let whiteRabbit = Object.create(protoRabbit);
7whiteRabbit.type = "white";
8whiteRabbit.speak("hello");
9// -> The white rabbit says 'hello'

클래스(Classes)

자바스크립트의 프로토타입 체계는 'Class'라는 객체 지향의 개념을 비공식적으로 적용한 것으로 볼 수 있다.

클래스는 객체 타입이 어떤 프로퍼티와 메서드를 가지고 있는지 정의한다. 클래스로부터 만들어진 객체를 클래스의 'instance'라고 한다. 프로토타입은 클래스의 모든 인스턴스에서 동일해야 할 프로퍼티를 정의하는데 사용한다.

클래스의 인스턴스를 생성할 때, 만들어진 객체가 적절한 프로토타입에서 파생되고 필요한 모든 프로퍼티를 가지고 있도록 해야한다. 이것을 해주는 것이 'constructor(생성자)' 함수의 역할이다.

아래 예시는 protoRabbit을 프로토타입으로 하는 객체를 생성한다. Object.create() 메서드는 지정된 프로토타입 객체 및 프로퍼티를 갖는 새 객체를 만든다.

1function makeRabbit(type) {
2 let rabbit = Object.create(protoRabbit);
3 rabbit.type = type;
4 return rabbit;
5}

자바스크립트에서 생성자를 더 쉽게 정의할 수 있는 방법을 제공한다.

함수 호출 부분 앞에 new 키워드를 사용하면 그 함수는 생성자로 작용한다. new 연산자를 사용해 만든 객체는 생성자 함수의 프로토타입 정보를 사용해 [[Prototype]]을 설정한다.

생성자 함수 F의 프로토타입은 F.prototype을 통해 설정할 수 있다. F.prototype은 '일반 프로퍼티'로, 앞서 언급한 프로토타입과는 다르다.

1function Rabbit(type) {
2 this.type = type;
3}
4Rabbit.prototype.speak = function (line) {
5 console.log(`The ${this.type} rabbit says '${line}'`);
6}
7let weirdRabbit = new Rabbit("weird");

일반적으로 생성자 함수의 이름은 대문자로 시작해서 다른 함수들과 구분한다.

위 예시에서 Rabbit 생성자의 프로토타입은 무엇일까? Function.prototype이다. 생성자는 근본적으로 함수이기 때문이다.

weirdRabbit은 생성자의 프로토타입 정보를 사용해서 생성했기 때문에 프로토타입이 Rabbit.prototype이다. 즉, 생성자의 prototype 프로퍼티가 인스턴스의 프로토타입이 된다.

모든 함수는 기본적으로 prototype 프로퍼티를 갖는다. 프로토타입 프로퍼티는 기본값으로 constructor 프로퍼티 하나만을 가지는 객체다. constructor 프로퍼티는 함수 자신을 가리킨다.

1function Rabbit() {}
2// Rabbit.prototype = { constructor: Rabbit }

파생 프로퍼티 Override

객체에 프로퍼티를 주게되면, 그 프로퍼티가 프로토타입에 프로퍼티로 있던 없던 해당 객체에 프로퍼티가 추가된다. 만약 프로토타입에 같은 이름의 프로퍼티가 이미 존재하면, 이 프로퍼티는 객체의 프로퍼티에 가려져 더이상 객체에 영향을 미치지 못하게 된다.

1Rabbit.prototype.teeth = "작음";
2console.log(killerRabbit.teeth);
3// -> 작음
4killerRabbit.teeth = "길고 날카로움";
5console.log(killerRabbit.teeth);
6// -> 길고 날카로움

참조:
Eloquent JavaScript
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/proto
ko.javascript.info