객체 지향 프로그래밍이 컴퓨터 프로그래밍 패러다임 중에 하나인 것은 알겠는데.. 그래서 그러니까 객체 지향 프로그래밍이 뭐야? 하고 궁금한 것이 개인적으로 당연한 것 같다. 잡힌 듯 잡힌 듯 하지만 한편으로는 실체가 전혀 없는 것 같기도 한 OOP.. 도대체 무엇일까?
OOP란.. 한마디로 축약해서 설명하기 힘든 어떤 추상적인 개념과 같아서 이 코드가 OOP가 아니고! 저 코드는 OOP!라고 특정할 수 없다.
따라서 개념과 그 개념을 적용하고 응용하는 상황을 구분해서 생각할 필요가 있는 것 같다.
아무튼 나는 OOP의 주요 특징들에 관해 글을 쓸 예정인데
Object 객체를 그 원래의 의미로 사물 혹은 물건, OOP란 사물을 생산하는 방식이라고 생각하는 편이 훨씬 쉽다.
어떤 학문이던 친숙하지 않은 개념을 이해하기 위해서는 생활 친화적인.. 적합하고 친근한 예를 들어 공부하는 것이 최고당..
OOP를 지원하는 여러 언어가 있는데 대표적으로 클래스 기반 언어로는 Java, C++, dart...등, 프로토타입 기반 언어로는 자바스크립트가 있다.
dart를 좀 찾아보다가 OOP를 정리할 겸 이 글을 쓰게 되었는데 아무래도 클래스 기반 언어들은 제대로 공부한 것이 없어 자바스크립트를 중점으로 작성할 예정이다.
객체를 생성하는 방법과 객체 지향의 주요 특징들을 분리하지 않고 겹쳐서 이해하는 편이 훨씬 도움이 되었던 것 같아서 그대로 작성하겠다.
자바스크립트에는 다른 언어에서 사용하는 동일한 개념의 class
가 존재하지 않으나 객체를 생성하는 다른 방법들이 있다.
ES6에서 등장한 class
도 내부는 prototype
으로 구성 되어있으므로..
1. literal
2. new 생성자 함수
3. Object.create();
{}
Object literal객체를 생성하는 첫번째 방법인 literal으로 가장 간단하게 변수에 직접 객체를 작성해 넣는 것이다. 방법은 아래와 같다.
const headphone = {
volume: 0,
volumeUp: function() {
if (this.volume < 10) {
this.volume++;
}
},
volumeDown: function () {
if (this.volume > 0) {
this.volume--;
}
}
};
장점: 간단하게 작성할 수 있다.
단점: 외부에서 내부 속성에 접근할 수 있기 때문에 객체의 기능을 임의로 조작할 수 있다.
단점을 이해하기 위해 객체 리터럴로 생성된 headphone의 속성으로 sound
, volumeUp
, volumeDown
세 가지가 있다고 가정해보자.
headphone.volumeUp();
headphone.volumeDown();
위의 두 메소드를 실행하면 headphone의 볼륨이 0 - 10 사이에서 커지거나 작아지게 될 것이며,
headphone.volume;
으로 현재 볼륨을 확인까지 할 수 있을 것이다.
headphone의 모든 속성에 접근할 수 있기 때문이다.
그런데 만약 사용자가 메소드 사용 없이 아래와 같은 볼륨 조작을 하게 된다면 어떻게 될까.
headphone.volume = 1000;
headphone의 모든 속성에 접근할 수 있기 때문에 당연한 얘기로 헤드폰의 볼륨은 1000으로 변경된다.
..?!?!
헤드폰 볼륨을 1000으로 사용해도 문제 없다면 뭐 크게 들리고 좋을 수도 있겠지만, 사용자로 하여금 메소드로만 볼륨 조절을 가능하게 하여 의도적인 혹은 실수로 인한 볼륨 임의 조작을 막고 싶다면 어떻게 할 수 있을까?
캡슐화를 통해 특정 속성을 외부에 노출시키지 않고 내부에서만 조작하는 방법
즉시 실행 함수에 노출시키지 않을 데이터를 변수에 담아 scope로 묶어놓고 반환되는 객체 메소드에서만 접근할 수 있도록 하는 방법이다.
const headphone = (function () {
let volume = 0;
return {
volumeUp: function() {
if (volume < 10) {
volume++;
}
},
volumeDown: function () {
if (volume > 0) {
volume--;
}
}
};
})();
위의 예시에서 headphone
의 속성을 확인해보면 volumeUp
와 volumeDown
두 메소드 뿐이지만 함수가 선언되는 시점에 스코프가 생성되므로 클로저를 사용해 함수의 스코프 내에 접근할 수 있다
따라서 headphone
의 메소드(volumeUp
와 volumeDown
)를 사용하게 되면 상위 스코프의 변수인 volumn
에 접근해 조절할 수 있다.
하지만 즉시실행함수가 반환하는 객체에 headphone.volume
은 포함되지 않았기 때문에 외부에 노출되지 않아 객체 리터럴 방식에서처럼 임의 조작이 불가능하게 된다.
그런데 만약 이렇게 캡슐화된 헤드폰을 대량 생산해야하는 경우가 생긴다면 어떨까..?
const headphoneA = (function () {
let volume = 0;
return {
volumeUp: function() {
if (volume < 10) {
volume++;
}
},
volumeDown: function () {
if (volume > 0) {
volume--;
}
}
};
})();
const headphoneB = (function () {
let volume = 0;
return {
volumeUp: function() {
if (volume < 10) {
volume++;
}
},
volumeDown: function () {
if (volume > 0) {
volume--;
}
}
};
})();
위와 같은 식으로 '복붙'하면서 하면 될까?
코드에 정답은 없지만 불필요한 중복을 피해 유지 보수를 보다 편하게 하고 재사용성 가능한 코드가 좋은 코드인 것은 틀림 없다.
new
생성자 함수new 연산자는 사용자 정의 객체 타입 또는 내장 객체 타입의 인스턴스를 생성한다.
출처 MDN
다시 말해 생성자 함수를 정의하고 new
연산자와 함께 생성자 함수를 호출해서 생성한 객체를 인스턴스라 부른다. 생성자 함수의 자식이라고 이해해도 된다.
function Headphone() {
this.volume = 0;
Headphone.prototype.volumeUp = function () {
if (this.volume < 10) {
this.volume++;
}
}
Headphone.prototype.volumeDown = function () {
if (this.volume > 0) {
this.volume--;
}
}
}
const headphoneA = new Headphone();
const headphoneB = new Headphone();
생성자 함수에 재사용할 메소드를 prototype
으로 만든 후 new
키워드를 사용하여 원하는 만큼 instance
를 생성하면 된다.
생성된 instance
는 prototype chain
을 통해 생성자 함수의 메소드를 중복된 코드 없이 사용할 수 있게 된다.
하지만 이렇게 되면 객체 리터럴 방식과 마찬가지로
headphone.volume = 1000;
위와 같은 코드로 외부에서 내부 값을 변경할 수 있게 되는데 객체 리터럴과 다른 점은 생성자 함수 사용의 경우 대량 생산이 가능하다는 점이다.
만약 사용자에게 충분히 조작 방식에 대해 설명할 수 있거나 외부에서 내부 값 변경을 해야하는 특별한 상황에서 사용할 수 있다.
Factory 함수란 생성자 함수가 아니지만 객체를 반환하는 함수 일컫는다.
function factoryHeadphone () {
let volumne = 0;
return {
volumeUp: function () {
if (volume < 10) {
volume++;
}
},
volumeDown: function () {
if (volume > 0) {
volume--;
}
}
};
}
const headphoneA = factoryHeadphone();
const headphoneB = factoryHeadphone();
함수가 정의되었다는 것을 제외하면 IIFE
를 사용하여 scope
를 사용한 것과 거의 동일한 방식으로 볼 수 있다.
headphoneA
와 headphoneB
에 각각 factoryHeadphone
함수가 반환하는 객체를 할당하는데 그 객체의 속성 값은 volumeUp
과 volumeDown
뿐이다. 이 메소드를 사용하여 상위 스코프안에 숨겨져 있는 volume
변수를 내부에서만 조절할 수 있게 된다.
Object.create
는 괄호 속 객체를 prototype
으로 하는 객체를 생성한다.
따라서 Object.create(obj.prototype)
은 obj.protytpe
을 protytpe
으로 하는 객체를 생성하는 것이다.
프로토타입을 프로토타입으로 한다고요..???????? 네에.. 그렇습니다..
이때 주의 할 것은 객체 생성으로 인해 끊어진 constructor
를 재연결해주어야 상위 메소드와 본인이 가지고 있는 메소드를 모두 사용할 수 있다.
MDN에 실제로 고전적인 방법이라고 쓰여져 있으며.. 실제로 고전적임.. 하지만 모오던한 방법의 내부도 결국 이 고전적인 방법으로 되어있다..
Audio
생성자 함수
function Audio() {
this.volume = 0;
this.power = false;
}
Audio.prototype.volumeUp = function() {
if (this.volume < 10) {
this.volume++;
}
}
Audio.prototype.volumeDown = function() {
if (this.volume > 0) {
this.volume--;
}
}
Audio.prototype.powerOnAndOff = function() {
this.power = !this.power;
}
Audio
를 __proto__
로 가지는 Headphone
생성자 함수
Audio
와 Headphone
의 속성들을 모두 사용할 수 있다.
function Headphone () {
Audio.call(this);
this.noiseCancelling = false;
}
Headphone.prototype = Object.create(Audio.prototype); // prototype 연결
Headphone.prototype.constructor = Headphone; // constructor 재연결
Headphone.prototype.setNoiseCancelling = function () {
this.noiseCancelling = !this.noiseCancelling;
}
const headphoneA = new Headphone();
const headphoneB = new Headphone();
Audio
를 __proto__
로 가지는 AirPod
생성자 함수
Audio
와 AirPod
의 속성들을 모두 사용할 수 있다.
function AirPod () {
Audio.call(this);
this.pairingConnected = false;
}
AirPod.prototype = Object.create(Audio.prototype); // prototype 연결
AirPod.prototype.constructor = AirPod; // constructor 재연결
AirPod.prototype.pairingConnect = function () {
this.pairingConnected = !this.pairingConnected;
}
const airpodA = new AirPod();
const airpodB = new AirPod();
AirPod.prototype.constructor
위의 코드를 콘솔 창에 찍어보면 Object.create
이후에 값이 달라지는 것을 확인할 수 있다. 따라서 상/하위 생성자 함수의 속성들을 모두 사용하려면 반드시 끊어진 체인을 다시 연결해주어야 한다..
장점: 중복 코드를 줄이며 프로토타입 체인으로 메소드를 상속(위임) 가능하다.
단점: 길어지는 코드와 중요해지는 코드의 위치.
하위 생성자의 경우 메소드 정의 후 상위 생성자와 체인 연결하면 메소드들이 사라진다.
이런 저런 문제점들을 보안하기 위해서 타 언어에서 사용하는 class가 등장하는데..
ES6부터 상속의 개념으로 class
가 등장했는데 이름은 class
이지만 작성법이 달라졌을 뿐 prototype
과 크게 다르지 않다
클래스 선언 후 메소드를 작성해주면 된다.
class Audio {
constructor() {
this.volume = 0;
this.power = false;
}
volumeUp = function () {
if (this.volume < 10) {
this.volume++;
}
}
volumeDown = function () {
if (this.volume > 0) {
this.volume--;
}
}
}
프로토타입 연결을 각기 따로 해줄 필요 없이
class Headphone extends Audio {
constructor(props) {
super(props);
this.noiseCancelling = false;
}
setNoiseCancelling = function () {
this.noiseCancelling = !this.noiseCancelling;
}
}
class AirPod extends Audio {
constructor(props) {
super(props);
this.pairingConnected = false;
}
pairingConnect = function () {
this.pairingConnected = !this.pairingConnected;
}
}
이렇게 상속(위임)이 가능하다. 위의 코드들과 비교하면 언뜻봐도 코드가 매우 간결해진 것을 알 수 있다.
타 언어를 사용해보지 않았고 OOP의 개념도 아주 명확하게 인지하고 있는 것이 아니기 때문에 작성하기가 조심스럽지만
개인적으로 봤을 때 (자바스크립트) 클래스 작성의 장점들은..
1. 프로토타입로 작성시 각각의 메소드를 따로 작성해주어야 하는데 클래스는 해당 클래스 내에 여러 개 작성 가능
프로토타입은 각 메소드가 개별적으로 작성되기 때문에 특정 생성자의 프로토타입들을 전체적으로 볼 때 가독성이 떨어지는데 클래스는 하나의 클래스내에 묶여 있기 때문에 가독성이 높고 보기 편함
코드가 간결해서 프로토타입보다 작성하기 쉬움.
단점은..
글쎄 아직 잘 모르겠다.
그래서 Object.create();
연결이 고전적인 방법인 것이려나
아무튼 위의 방법들을 통해 즉, 객체 지향 프로그래밍의 특징들을 적용해 데이터 구조를 설계하게 되면 OOP의 궁극적인 목적인 객체 내부의 응집력을 높이고 외부 객체들과의 결합력을 낮추는 방향을 지향하게 된다.
OOP에는 클로저, 스코프, 프로토타입 체인 등등 여러 가지가 엮여있다.
파편적인 학습으로는 전체적인 모습을 그릴 수가 없음..
특정 개념을 이해하기 쉽지 않을 때 대체로 비판하는 글을 읽으면 도움이 된다.
다각적으로 시각을 가질 수 있음... 최근에 읽은 번역 참조..
OOP를 빨리 잊을 수록 여러분과 여러분의 소프트웨어에 좋습니다
factory 함수 참고
JavaScript Factory Functions vs Constructor Functions vs Classes
OOP 이해하는데 많은 도움이 됐습니다. 감사합니다.