JavaScript에는 객체 지향 프로그래밍(OOP)을 위한 기능이 있어요. OOP에서는 "템플릿"(클래스)에서 객체(인스턴스)를 만들어 특정 데이터와 기능을 담도록 해요. 데이터 속성은 OOP 맥락에서 _필드_라고 하고, 함수 속성은 _메서드_라고 해요.
JavaScript는 2015년에 클래스가 언어 명세에 추가되기 전까지는 클래스라는 게 아예 없었지만, 프로토타입 기반 상속으로 객체 지향 프로그래밍을 할 수는 있었어요.
그리고 요즘에는 class 키워드를 쓸 수 있지만, JavaScript는 여전히 프로토타입 기반 언어예요.
프로토타입 기반 언어라는 게 무슨 뜻인지, JavaScript가 실제로 어떻게 동작하는지 이해하려면 클래스가 없던 시절로 돌아가 봐야 해요.
JavaScript에서는 템플릿(클래스)을 일반 함수로 만들어요.
어떤 함수를 이런 템플릿으로 쓰기로 하면 그 함수를 _생성자 함수_라고 부르고, 관례상 함수 이름을 대문자로 시작해요.
생성자 함수를 호출할 때 new 키워드를 사용하면 템플릿에서 인스턴스(객체)를 만들어요.
function Car() {
// ...
}
const myCar = new Car();
const yourCar = new Car();
JavaScript에서는 인스턴스를 만든 뒤에도 인스턴스와 생성자 함수가 서로 관계를 맺고 있다는 점이 중요해요.
모든 인스턴스 객체에는 언어 명세에서 [[prototype]]이라고 부르는 숨겨진 내부 속성이 있어요.
이 속성은 생성자 함수의 prototype 키 값에 대한 참조를 담고 있어요.
네, 맞아요. JavaScript 함수는 내부적으로 객체이기도 해서 키/값 쌍을 가질 수 있어요.
2015년부터는 Object.getPrototypeOf()로 [[prototype]]에 접근할 수 있어요.
그전에는 여러 환경에서 __proto__ 키로 접근할 수 있었어요.
객체의 프로토타입([[prototype]])과 생성자 함수의 prototype 속성을 혼동하면 안 돼요.
정리하면:
prototype 속성이 인스턴스의 _프로토타입_이 돼요.파생된 객체(인스턴스)가 모두 특정 필드를 갖게 하고, 객체를 만들 때 그 필드에 초깃값을 넘겨주고 싶을 때가 많아요.
이때 this 키워드를 사용할 수 있어요.
생성자 함수 안에서 this는 new로 만들어질 새 객체를 나타내요.
생성자 함수를 new와 함께 호출하면 this가 자동으로 반환돼요.
즉, 생성자 함수에서 this에 필드를 추가하면 새 인스턴스에 필드를 더할 수 있어요.
function Car(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
const myCar = new Car('red', '2mt');
myCar.color;
// => 'red'
myCar.engineRunning;
// => false
메서드는 생성자 함수의 prototype 속성을 통해 추가해요.
메서드 안에서는 this로 인스턴스의 필드에 접근할 수 있어요.
이게 가능한 이유는 다음 일반 규칙 때문이에요.
함수가 객체의 메서드로 호출되면, 그
this는 메서드가 호출된 객체로 설정돼요. 1
function Car() {
this.engineRunning = false;
// ...
}
Car.prototype.startEngine = function () {
this.engineRunning = true;
};
Car.prototype.addGas = function (litre) {
// ...
};
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
위 예시의 myCar는 평범한 JavaScript 객체이고, 이걸 살펴봐도(예를 들어 브라우저 콘솔에서) myCar 객체 안에 startEngine이라는 속성이 함수를 값으로 직접 갖고 있는 걸 찾을 수 없어요.
그런데 위 코드는 어떻게 동작하는 걸까요?
비밀은 _프로토타입 체인_에 있어요.
객체의 속성(필드나 메서드)에 접근하려고 하면, JavaScript는 먼저 그 키가 객체 자체에 직접 있는지 확인해요.
없으면 원래 객체의 [[prototype]] 속성이 가리키는 객체에서 키를 계속 찾아요.
앞서 말했듯이 예시에서 [[prototype]]은 생성자 함수의 prototype 속성을 가리켜요.
우리가 거기에 startEngine 함수를 추가했기 때문에, JavaScript는 바로 그곳에서 이 함수를 찾아요.
function Car() {
// ...
}
Car.prototype.startEngine = function () {
// ...
};
그리고 체인은 거기서 끝나지 않아요.
Car.prototype의 [[prototype]] 속성(myCar.[[prototype]].[[prototype]])은 Object.prototype(Object 생성자 함수의 prototype 속성)을 참조해요.
여기에는 모든 JavaScript 객체가 쓸 수 있는 일반 메서드가 들어 있어요. 예를 들어 toString()이요.
Object의 [[prototype]]은 보통 null이라서 프로토타입 체인은 거기서 끝나요.
결국 myCar.toString()을 호출할 수 있는 건, JavaScript가 프로토타입 체인 전체를 뒤져 그 메서드를 찾기 때문이에요.
자세한 예시는 MDN 문서 "상속과 프로토타입 체인"에서 볼 수 있어요.
프로토타입 체인은 값을 가져올 때만 따라간다는 점을 기억해요. 인스턴스 객체의 속성을 직접 설정하거나 삭제하면 그 인스턴스만 바뀌어요. 클래스 기반 상속을 가진 언어에 익숙하다면 예상과 다를 수 있어요.
JavaScript에서는 인스턴스를 만든 뒤에도 모든 기존 인스턴스에 메서드를 추가할 수 있어요.
모든 인스턴스는 생성자 함수의 prototype 속성에 대한 참조를 유지한다는 걸 배웠어요.
즉, 그 prototype 객체에 항목을 추가하면 그 새 항목(예를 들어 새 메서드)을 그 생성자 함수로 만든 모든 인스턴스에서 곧바로 쓸 수 있게 돼요.
function Car() {
this.engineRunning = false;
}
const myCar = new Car();
// Calling myCar.startEngine() here would result in "TypeError:
// myCar.startEngine is not a function".
Car.prototype.startEngine = function () {
this.engineRunning = true;
};
myCar.startEngine();
// This works, even though myCar was created before the method
// was added.
이론적으로 동적 메서드는 Object나 Array 같은 내장 객체의 프로토타입을 수정해서 기능을 확장하는 데도 쓸 수 있어요.
이걸 _몽키 패칭_이라고 해요.
이 변경은 애플리케이션 전체에 영향을 주기 때문에, 의도치 않은 부작용을 막으려면 피해야 해요.
합리적인 사용 사례는 오래된 환경에서 빠진 메서드를 위한 폴리필을 제공하는 경우뿐이에요.
요즘 JavaScript는 class 키워드로 클래스를 정의하는 걸 지원해요.
이건 2015년에 언어 명세에 추가됐어요.
한편으로는 클래스를 더 읽고 쓰기 쉽게 만들어 주는 편의 문법을 제공해요.
새 문법은 C++나 Java 같은 언어에서 클래스를 쓰는 방식과 더 비슷해서, 그런 언어에서 넘어온 개발자는 더 쉽게 적응할 수 있어요.
다른 한편으로, 클래스 문법은 프로토타입 문법에서는 쓸 수 없는 새로운 언어 기능의 길을 열어 줘요.
새 문법에서는 class 키워드 뒤에 클래스 이름과 중괄호로 둘러싼 클래스 본문을 써서 클래스를 정의해요.
본문에는 생성자 함수, 즉 constructor라는 이름의 특별한 메서드 정의가 들어 있어요.
이 함수는 프로토타입 문법의 생성자 함수와 똑같이 동작해요.
클래스 본문에는 모든 메서드 정의도 들어 있어요.
메서드 문법은 객체 안에 함수를 값으로 추가할 때 본 축약 표기법과 비슷해요. 객체 개념을 참고해요.
class Car {
constructor(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
startEngine() {
this.engineRunning = true;
}
addGas(litre) {
// ...
}
}
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
함수 선언과 함수 표현식처럼, JavaScript는 위에서 본 클래스 선언 외에 클래스 표현식도 지원해요.
내부적으로 JavaScript는 여전히 프로토타입 기반 언어라는 점을 기억해요. 위의 "프로토타입 문법" 절에서 배운 모든 메커니즘이 그대로 적용돼요.
기본적으로 JavaScript에서 모든 인스턴스 필드는 공개돼 있어요. 곧바로 접근하고 값을 할당할 수 있어요.
실제 비공개 필드를 언어 명세에 추가하는 작업은 진행 중이에요. 자세한 내용은 제안 문서를 봐요.
그전까지는 밑줄로 시작하는 필드와 메서드를 비공개로 취급하는 확립된 관례를 따를 수 있어요. 이런 필드와 메서드는 클래스 밖에서 직접 접근하면 안 돼요.
비공개 필드에는 게터와 세터가 함께 있는 경우가 있어요.
get과 set 키워드를 쓰면, 함수와 같은 이름의 속성에 접근하거나 값을 할당할 때 실행되는 함수를 정의할 수 있어요.
class Car {
constructor() {
this._mileage = 0;
}
get mileage() {
return this._mileage;
}
set mileage(value) {
throw new Error(`Mileage cannot be manipulated, ${value} is ignored.`);
// Just an example, usually you would not provide a setter in this case.
}
}
const myCar = new Car();
myCar.mileage;
// => 0
myCar.mileage = 100;
// => Error: Mileage cannot be manipulated, 100 is ignored.
OOP에서는 특정 인스턴스에 의존하지 않는 유틸리티 필드나 메서드를 제공하고 싶을 때가 있어요.
그런 건 인스턴스가 아니라 클래스 자체에 정의해요.
static 키워드로 이걸 할 수 있어요.
class Car {
static type = 'vehicle';
static isType(targetType) {
return targetType === 'vehicle';
}
}
Car.type;
// => 'vehicle'
Car.isType('road sign');
// => false
앞에서 본 프로토타입 체인을 따른 상속 외에도, JavaScript에서는 클래스 사이의 상속을 표현할 수도 있어요. 이 내용은 상속 개념에서 다뤄요.
this 예제 - 객체 메서드로서, MDN. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#as_an_object_method (2021년 12월 3일 확인) ↩