모든 내용은 MDN과 이웅모님의
javascript deep dive
에서 발췌
https://developer.mozilla.org/ko/docs/Learn/JavaScript/Objects/Object-oriented_programming
프론트엔드 개발자니까, JS를 다룰거니까. 하며 은근슬쩍 객체지향 프로그래밍에서 회피했다.
하지만 이젠 안다.
프론트라고 객체지향을 쓰지 않는게 아니다.
js라고 객체지향을 쓰지 않는게 아니다.
그냥 겁나고 어려울 것 같아서 피한 것이다.
진행할 사이드프로젝트를 컴포넌트화시키려니 클래스 상속부터 시작된다.
이젠 알아야 할 시간이다.
OOJS를 알기전에, JS에서 객체지향 방식을 어떻게 다루는지 봐야한다.
본래 객체지향에서는 클래스를 상속받는다. JS에도 클래스가 존재하지만, 원본은 사실 Prototype이다.
JS는 객체지향에서 클래스대신 Prototype을 택한 프로토타입기반 언어이다.
프로토타입은 곧 원형이다.
모든 객체들은 프로토타입 객체에서 메소드와 속성을 상속받는다
원본이 되는 프로토타입을 상속받아 사용하는 것이다.
그리고 그 상속받은 프로토타입을 또 상속받아 사용하고...
이런 일련의 과정을 프로토타입 체인이라고 한다. => Object.prototype
은 null
임. 즉, 끝이다.
서로 체이닝 하면 어떡하지? => 단방향으로만 가능함. 일종의 링크드 리스트라고 볼수있음
또한 객체의 속성,메소드는 사실 객체가 아닌 객체 생성자(constructor)의 prototype이라는 속성에 정의되어있다.
참고로 prototype
은 객체의 내부슬롯[[Prototype]]
이다.
__proto__
__proto__
는 Object.prototype.__proto__
를 상속받은것.
브라우저는prototype
이 아닌__proto__
라는 프로퍼티를 사용해[[Prototype]]
슬롯에 접근한다.
prototype
으로 접근시undefined
를 출력.
es6이후 부터는Object.getPrototypeOf()
로 접근하는걸 권장한다.
프로퍼티(메소드)가 현재 객체에 없다면, 스코프 체인처럼 프로토타입 체인(__proto__
)을 타고감.
만약 이름이 겹친다면, 가장 가까이 있는 프로퍼티(메소드)에 도달한다.
=> 이를 섀도잉이라고 함.
프로토 타입의 프로퍼티를 순차적으로 검색. 종점은 항상Object.prototype
.
프로토타입을 바꾸는 법은?
obj.__proto__ = ...
로 프로토타입을 바꾸는 행위는 권장되지 않는다.
시간이 많이 소요되는 작업임.
Object.create(proto[, propertiesObject])
로 새로운 프로토타입을 참조하는 객체를 생성해야 한다.
클래스가 등장한 이유도 재사용성이 용이해서이다.
프로토타입역시 마찬가지.
예시로 보자.
function Circle(r){
this.radius = r;
this.getArea = function () {
return Math.PI * this.radius ** 2;
};
}
const circle1 = new Circle(5);
const circle2 = new CIrcle(10);
circle1.getArea === circle2.getArea // false
circle1.getArea();
circle2.getArea();
위의 코드는 생성자함수를 사용하여 두개의 인스턴스를 생성했다.
중복되는게 보이지 않는가?
this.getArea()
도 두개가 생성되었다. 두개뿐이면 다행이지만, 단위가 커지면 그만큼 메모리 낭비도 심해진다.
이를 해결하기위해 JS는 프로토타입을 도입했다.
function Circle(r){
this.radius = r;
}
Circle.prototype.getArea = function() {
return Math.PI * this.radius ** 2;
};
circle1.getArea === circle2.getArea // true
const circle1 = new Circle(5);
const circle2 = new CIrcle(10);
circle1.getArea();
circle2.getArea();
JS에서 함수는 객체다. 정확히 말하면 일급 객체.
4가지 조건을 만족하면 일급 객체라고한다.
고로 함수는 객체다.
그렇기에 역시 프로토 타입을 가진다.
하지만 사용하는 목적이 다르다.
__proto__
: 객체가 본인의 프로토타입을 찾기 위해서.prototype
: 함수가 생성할 인스턴스의 프로토타입을 할당하기 위해서.그렇기에 생성자 함수로써 호출수 없는 화살표 함수나 축약표현 메소드는 prototype
이 없다.
사실, 정확히 모르고있었다. 생성자 함수는 어떤걸 만들어주는 함수라고만 막연히 알고있었다.
js deepdive에서는 이렇게 나와있다.
new 연산자와 함께 호출하여 객체(인스턴스)를 생성하는 함수를 말한다
그러니까 new String, new Number, new Object...
등은
생성자 함수로 구현되어 있다.
function String(str){
this.str = str;
...
}
function Number(num){
this.num = num;
...
}
function Object(obj){
this.obj = obj;
...
}
생성자 함수를 이용한 방법은, 리터럴 ({}, [], "", 0
)등 보다 길어서 불편해보인다.
존재하는 이유는 뭘까?
리터럴은 간편하다. 하지만 동일 프로퍼티를 갖는 객체를 여러개 생성하면, 매번 같은 프로퍼티를 기술해야함.
//리터럴 방식
const person1 = {
name : john,
age : 20,
hi(){
console.log(this.name);
};
}
const person2 = {
name : sancho,
age : 99,
hi(){
console.log(this.name);
};
}
// 생성자를 사용하면 같은 프로퍼티를 기술하지 않아도 됨
function Person(name,age){
this.name = name;
this.age = age;
this.hi = function() {
return console.log(`hi my name is ${this.name}`)
};
}
const person1 = new Person(john,20);
const person2 = new Person(sancho,99);
person1.hi() // hi my name is john
person2.hi() // hi my name is sancho
this 바인딩은 추후 자세히 다루겠다. 세가지만 알고 가자
사실, 생성자 함수 그 어디에도 객체를 반환하는 코드는 없다.
다만new
연산자와 함께 함수를 호출하면 암묵적으로 인스턴스를 생성, 초기화, 반환한다.
마지막에 return
값이 존재하면, 생성자 함수의 기본 동작을 훼손한다.
생성자 함수가 new
연산자 없이 호출되는걸 방지하기위해 나온 기능.
function Person(name,age){
//함수가 new연산자로 호출되지 않으면 new.target === undefined;
if(!new.target){
return new Person(name,age);
}
this.name = name;
this.age = age;
this.hi = function() {
return console.log(`hi my name is ${this.name}`)
};
}
대부분의 기본 생성자 함수들은 이 기능이 탑재되어있다(폴리필)
전통적인 OOP는 문제 모델링을 위해 추상화된 객체를 생성한다.
그러니까, 교수 한명이 있다. 이를 의사코드로 추상화해보겠다.
클래스 교수
속성
이름
과목
행위
논문채점()
자기소개()
이런식이겠지.
클래스 자체로는 아무기능도 없다. 인스턴스, 즉 객체를 생성해야 클래스는 진가를 발휘한다.
클래스에서는 객체를 생성하기위한 생성자가 존재해야한다.
클래스 교수
속성
이름
과목
생성자(이름, 과목)
행위
논문채점()
자기소개()
이러면 const 교수1 = new 교수(이름,과목)
으로 받아온 인자들이, 속성에 들어간다.
다만 속성을 굳이 선언할 필요는 없다. 생성자가 자동으로 생성해준다.
다음으로 학생을 만들어보고싶다.
클래스 학생
속성
이름
나이
생성자(이름, 나이)
행위
출튀하기()
자기소개()
교수 클래스와 겹치는 부분이 존재한다.
이를 다시 추상화 해보자
클래스 사람
속성
이름
생성자(이름)
행위
자기소개()
클래스 교수 : 사람으로부터 상속받기
속성
과목
생성자(이름, 과목)
행위
논문채점()
클래스 학생 : 사람으로부터 상속받기
속성
나이
생성자(이름,나이)
행위
출튀하기()
이처럼 겹치는 부분을 뽑아내, 상속을 받으면 불필요한 중복을 방지할 수 있다.
이때 자기소개()
가 안녕하세요 저는 [이름] 입니다
라고 나온다 해보자.
교수는 자신을 교수라 소개하고싶고, 학생은 나이를 사용해서 소개하고 싶다.
이는 같은 내용이 아니다. 따라서 각기 다른 메소드를 사용할 필요가 있다.
클래스 교수
...
자기소개() //내용이 다르다고 가정
클래스 학생
...
자기소개() //내용이 다르다고 가정
이처럼 메서드 이름은 같지만, 클래스 별 기능이 다른경우 다형성(polymorphism)이라 하고
하위 클래스의 메서드가 상속받은 상위 클래스 메서드의 구현을 대체할때 재정의(overriding)한다고 말한다.
캡슐화는 다른 객체가 아닌 객체 자체 메서드를 통해서만 접근 가능하게 하는 것이다.
공개-비공개를 명확히 나누는 것.
왜 필요할까?
간단한 예시로 알아보겠다.
const privateData = Symbol("privateData"); // 고유함을 보장하는 Symbol 사용. 호출할때마다 값이 달라진다
class Product{
constructor(name, price){
this[privateData] = { name, price };
}
// Getter 메서드로 이름 반환
getName() {
return this[privateData].name;
}
getPrice() {
return this[privateData].price;
}
setPrice(newPrice) {
if (newPrice > 0) {
this[privateData].price = newPrice;
} else {
console.error("가격은 0보다 커야 합니다.");
}
}
}
const product1 = new Product("노트북", 1200);
console.log(product1.getName()); // "노트북"
console.log(product1.getPrice()); // 1200
product1.setPrice(1000); // 유효한 가격 변경
console.log(product1.getPrice()); // 1000
console.log(product1[privateData]); // 접근 불가능
이 예제에서 가격을 쉽게 수정할 수 있다면, 실수에 의해 문제가 생길것이다.
음수 가격이 된다거나, 원치 않게 비싸진 다거나 하는 일 마리다.
따라서 가격을 Symbol
을 사용해 은닉화 하고, getter
와 setter
을 사용해서 접근 및 관리를 하는것이다.
이런식으로 정보를 은닉화 하는 과정이 캡슐화다.
또한, 공개할 정보를 고르는 것 역시 캡슐화다.
클래스는 프로토타입을 잘 추상화한 기능이다.
상속역시 다른 객체지향 언어에서 사용되는 기능이 아니다.
엄연히 말하자면 위임에 가깝다. 프로토타입의 속성을 빌려 사용하는 것이니까.
그렇다면, prototype으로 class를 구현해보면 더 와닿을 것 같은데?
es6부터는 class
가 권장되지만, 원본은 결국 prototype
이다.
따라서 es6이전에 사용했던 prototype
을 이용한 객체지향을 자세히 알아보자!
function Person(name){
this.name = name;
}
// Person에 프로토타입 함수 추가. => 같은 함수 계속 찍어내지 않아도 됨.
Person.prototype.introduce = function(){
console.log(`hi my name is ${this.name}`);
}
function Student(name, year){
//apply, call()은 호출한 함수의 this에 call의 첫번째 인자로 받아온 객체를 바인딩 한다.
Person.call(this, name);
this.year = year;
}
//프로토타입 상속
Student.prototype = Object.create(Person.prototype);
//call로 this바인딩을 해서 생성했기에, 생성자 함수가 부모 함수로 되어있음.
//작동하는데 문제는 없지만, 인스턴스를 확실히 해야함.
Student.prototype.constructor = Student
const student1 = new Student("john", 22);
student1.introduce(); //hi my name is john
이를 클래스로 표현하면 이렇다.
class Person{
constructor(name){
this.name = name;
}
introduce(){
console.log(`hi my name is ${this.name}`);
}
}
class Student extends Person{
constructor(name, year){
//super을 사용하지 않으면, Student에서도 this.name = name 선언이 필요하다(call과같은 기능)
super(name);
this.year = year;
}
}
const student1 = new Student("kim", 20);
student1.introduce();// hi my name is kim;
막상 공부해보니 그렇게 어렵지 않은 내용이라는 걸 알수있었다.
물론, 실제 코드에 도입하는건 다른 경우겠지만. 아마 추상화 하는데 시간이 제일 많이 걸릴 것 같다.
재사용성을 위해 상속을 받으면서도, 상속이 너무 깊어지지 않게 유지...잠깐만
이거 재사용이 용이한 컴포넌트를 만들때 하던 생각 아닌가?
생각해보니 그렇다. 객체지향이던, 함수형 프로그래미이던, 반복되는 부분을 추출해서 공유하는건 같다.
핵심은 반복을 줄이는 것.
또한, 반복을 줄이는 것에 집착해 코드를 너무 길게 쓰지 않는 것.
뭐던 간에 균형이 중요해보인다!