박성범님의 ES6와 함께 JavaScript로 OOP하기를 바탕으로 여러 내용을 첨삭하여 쓴 글입니다.
객체지향 프로그래밍은 실세계에 존재하고 인지하고 있는 객체(Object)를 소프트웨어 세계에서 표현하기 위해 객체의 핵심적인 개념 또는 기능만을 추출하는 추상화(abstraction)를 통해 모델링하려는 프로그래밍 패러다임을 말한다.
즉, 우리가 주변의 실세계에서 사물을 인지하는 방식을 프로그래밍에 접목하려는 사상을 의미한다.
객체지향 프로그래밍(OOP, Object-Oriented Programming)은 절차지향 프로그래밍(Procedural Programming)과 대비되는 프로그래밍 방법론이다.
절차지향 프로그래밍 방식 언어로는 대표적으로 C언어가 있다. 일반적으로 C코드는 특정 기능을 수행하는 함수들로 구성되어 있다. C 프로그래머는 프로그램의 각 기능을 구현하고, 이 기능들이 어떤 절차로 수행되는가를 중심으로 개발한다.
객체지향 프로그래밍 방식 언어는 Java가 대표적이다. Java 프로그래머는 프로그램에 필요한 각 객체들의 속성과 동작을 구현하고, 이 객체들이 어떻게 상호작용하는가를 중심으로 개발한다.
(절차지향 프로그래밍은 명령을 순차적으로 수행하고, 객체지향은 그렇지 않다는 의미는 아니다. C든 Java든 기본적으로 명령은 순서대로 수행된다.)
C와 Java를 예시로 들었는데, 절차지향과 객체지향은 방법론이지 언어의 속성은 아니다. Java로도 절차지향 프로그래밍을 할 수 있고, C언어로 객체지향 프로그래밍을 할 수 있다. 다만 C언어는 문법 자체로 객체지향을 지원하지 않기 때문에 매우 비효율적이다. 반면 Java는 언어가 자체적으로 객체지향 프로그래밍을 위한 다양한 문법을 제공하고 있어, 굳이 자바로 절차지향 프로그래밍을 할 이유가 없다.
유지보수가 쉽고, 협업시 발생하는 혼란을 줄여준다.
절차지향 프로그래밍 방식으로 협업을 하게 되면, 내가 작성한 함수를 다른 부분에도 사용하기 위해 내용을 고칠 수 있는데 이는 내 함수를 쓰던 다른 사람의 코드에 문제가 생길 수 있는 행위이다. 따라서 다른 사람이 작성한 코드를 매번 해석해야 하며, 내 함수가 어디서 어떻게 사용되는지 항상 신경쓰고 있어야 한다는 단점이 있다.
각 기능을 독립적인 모듈로 관리할 수 있고 다른 사람이 내 코드의 내용을 직접 수정하지 않고 데이터에 접근하게 만들 수 있다. 따라서 코드 재사용성을 높이고 의존성을 관리하기 쉬워진다.
대신 코드 설계를 잘해야 한다. 객체 사이의 관계를 생각하지 않고 무작정 코드를 작성하기 시작하면 모든 것이 꼬여버릴 수 있다.
처음 객체지향 프로그래밍 방식으로 개발을 하면 굉장히 번거롭다고 느껴진다. 말을 만들기 위해 말 공장부터 만들어야 한다.
공장의 장점은 한 번 만들어두면 이후에 반복적으로 사용할 수 있고, 말에 추가적인 기능을 붙일 때도 공장에 장비를 하나 더 들여 놓기만 하면 된다는 것이다.
객체지향 방식은 현실 세계를 표현하기에 적합하고, 또 직관적이다. 코드와 서비스의 미래를 생각한다면 객체지향 프로그래밍을 하자!
자바스크립트는 멀티-패러다임 언어로 명령형(imperative), 함수형(functional), 프로토타입 기반(prototype-based) 객체지향 언어이다.
🔍 참고 | 클래스 기반 언어
클래스로 객체의 자료구조와 기능을 정의하고 생성자를 통해 인스턴스를 생성한다. (Java, C++, C#, Python, PHP, Ruby, Object-C)모든 인스턴스는 오직 클래스에서 정의된 범위 내에서만 작동하며, 런타임에 그 구조를 변경할 수 없다. 이러한 특성은 정확성, 안정성, 예측성 측면에서 클래스 기반 언어가 프로토타입 기반 언어보다 좀더 나은 결과를 보장한다.
자바스크립트는 이미 생성된 인스턴스의 자료구조와 기능을 동적으로 변경할 수 있다. 또한 객체 지향의 상속, 캡슐화(정보 은닉)등의 개념은 프로토타입 체인과 클로저 등으로도 구현할 수 있고 extends, # 등의 ES6+문법을 이용해서도 가능하다.
자바스크립트에서는 함수 객체로 많은 것을 할 수 있는데 클래스, 생성자, 메소드도 모두 함수로 구현 가능하다.
function Cat(name, age){
this.name=name;
this.age=age;
}
Cat.prototype.makeNoise=function(){
console.log('Meow!');
}
const cake=new Cat('Cake',3);
cake.makeNoise(); // Meow!
다른 객체지향 언어를 사용해 본 사람이라면 일반적인 객체지향 언어와 굉장히 다르다는 것을 알 수 있다. 클래스 없이 그냥 함수가 쓰였고, 심지어 메소드는 그 함수 밖에서 정의되었다.
이는 ES6에 여러 객체지향 문법이 추가되기 전에 사용하던 방법으로, ES6부터는 자바나 C++과 같은 다른 객체지향 언어들과 비슷한 방식으로 보다 간결하게 객체지향 프로그래밍을 할 수 있게 되었다.
// Animal.js
class Animal{
}
export default Animal;
클래스의 new 연산자를 이용해 새로운 인스턴스를 생성하면 오브젝트가 된다. 클래스는 정의만 한 것이라 실제 메모리에 올라가지 않지만, 틀에 데이터를 넣으면(인스턴스를 생성하면) 오브젝트는 메모리에 올라가게 된다.
// index.js
import Animal from './Animal';
let anim=new Animal();
위의 코드는 anim 변수를 만들고 new 키워드를 통해 Animal을 생성하고 있다. 여기서 anim은 Animal 클래스를 가리키는 래퍼런스 변수(Reference variable)이며 인스턴스(Instance)라고 부른다.
생성자(Constructor)는 class로 생성된 객체를 생성하고 초기화하기 위한 특수한 메서드이다.
// Animal.js
class Animal{
constructor(name){
}
}
export default Animal;
constructor는 name과 같은 매개변수도 둘 수도 있다. 만약 constructor를 명시하지 않는다면 비어있는 default constructor가 만들어진다. 굳이 빈 constructor를 만들 필요는 없다.
❗ 클래스는 하나의 constructor만 가질 수 있다. 여러 개가 존재하면 SyntaxError가 발생한다.
// Animal.js
class Animal{
constructor(name){
this.name=name;
}
}
export default Animal;
클래스의 멤버 프로퍼티는 constructor 안에 선언한다. 다른 언어에서는 이를 인스턴스 변수(Instance variable)라고 부르지만, 앞서 언급했듯 클래스는 사실 함수고, 자바스크립트에서 함수는 객체이기 때문에 this.name은 변수가 아닌 프로퍼티(Property)다.
// index.js
import Animal from './Animal';
let anim=new Animal('Jake');
console.log(anim.name); // Jake
메소드는 함수와 비슷하며, 객체의 동작을 정의한다.
// Animal.js
class Animal {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
export default Animal;
위의 getName() 메소드는 Animal 클래스의 프로퍼티인 this.name을 반환한다.
// index.js
import Animal from './Animal';
let anim = new Animal('Jake');
console.log(anim.getName()); // 'Jake'
호출 역시 직관적이다.
class 안에 있는 field와 method들은 새로운 object를 만들 때마다 값만 전달받은 인자로 변경된 채 복제되어져 만들어진다.
하지만, object에 들어오는 데이터에 상관없이 공통적으로 class에서 쓸 수 있는 거라면 static을 이용해 해당 메소드 및 프로퍼티를 클래스 자체에만 생성하여 메모리 사용을 줄일 수 있다.
// Animal.js
class Animal {
static message='I like animals';
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
static sleep() {
console.log('Zzz');
}
}
export default Animal;
사용법은 메소드 혹은 프로퍼티 앞에 static 키워드를 붙여주면 된다. static은 캐쉬, 고정된 설정값, 또는 인스턴스 간 복제할 필요가 없는 어떤 데이터 등에 유용하게 쓰일 수 있다.
// index.js
import Animal from './Animal';
let anim = new Animal('Jake');
Animal.sleep(); // 'Zzz'
anim.sleep(); // Uncaught TypeError: anim.sleep is not a function
❗ 인스턴스를 통해 static 메소드를 호출하면 TypeError가 발생한다.
자바에는 private, protected, public과 같은 접근제어자가 있어서 외부에서 인스턴스 멤버에 접근하는 것을 통제할 수 있지만, 자바스크립트 class 속성(property)들은 기본적으로 public 하여 class 외부에서 읽히고 수정될 수 있다.
이렇게 외부에서 함부로 변수의 값을 마음대로 바꿀 수 없도록 내부의 정보를 외부로부터 은폐하는 것을 캡슐화라고 한다.
종종 프로퍼티 이름 앞에 언더스코어(_)를 붙이는 방식(this._name)으로 private한 변수임을 표현하는 경우도 있는데, 실제로 프로퍼티가 private하게 동작하는 것은 아니기 때문에 오해를 불러일으킬 수 있어 이 방법은 쓰지 않는 것이 좋다.
프로퍼티 대신 변수로 정의하고 클로저를 통해서 데이터에 접근하게 되면 정보를 은닉하는 효과를 낼 수 있다.
// Animal.js
class Animal {
constructor(name) {
let name = name;
this.getName = () => {
return name;
};
this.setName = (newName) => {
name = newName;
}
}
}
export default Animal;
변수는 해당 블록 안에만 존재하기 때문에 해당 블록을 벗어나서 접근하면 undefined가 된다. (블록 스코프를 갖는 let, const만 해당)
따라서 constructor 안에 변수를 선언하면 외부에서 name에 직접 접근할 수 없다. name을 가져오는 프로퍼티와 name을 설정하는 프로퍼티를 두면 외부에서 getName과 setName을 통해 name에 간접적으로 접근할 수 있다.
ES2019에서는 해쉬 #
prefix 를 추가해 private class 필드를 선언할 수 있게 되었다.
class Animal {
// constructor를 쓰지 않고도 field를 생성할 수 있다.
publicField = 2;
#privateField = 0;
}
const anim = new Animal();
console.log(anim.publicField); // 2
console.log(anim.privateField); // undefined
publicField = 2;
→ 그냥 작성하면, 외부에서 접근이 가능한 public으로 생성된다.
#privateField = 0;
→ '#'을 사용하면, 클래스 내부에서만 값에 접근 및 변경이 가능한 private으로 생성된다.
이는 최신 문법으로 호환성 문제가 있으므로 사용에 유의하자.
상속(또는 확장)은 코드 재사용의 관점에서 매우 유용하다. 새롭게 정의할 클래스가 기존에 있는 클래스와 매우 유사하다면, 상속을 통해 다른 점만 구현하면 된다.
❗ 코드 재사용은 개발 비용을 현저히 줄일 수 있는 잠재력이 있기 때문에 매우 중요하다.
객체는 클래스의 인스턴스이며 클래스는 다른 클래스로 상속될 수 있다.
// Animal.js
class Animal {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
export default Animal;
상속을 사용하면 공통된 것들을 일일히 작성하지 않아도 extends를 이용해 재사용할 수 있다.
상속은 말 그대로 해당 클래스의 모든 내용을 다른 클래스에 그대로 복사한다는 의미다. 즉, Animal 클래스의 프로퍼티 this.name과 메소드 getName()을 다른 클래스에 그대로 상속할 수 있다.
// Dog.js
import Animal from './Animal';
class Dog extends Animal {
constructor(name) {
super(name);
}
}
export default Dog;
extends 키워드를 사용해 Dog 클래스가 Animal 클래스를 상속했다. 이제 Animal 클래스는 Dog 클래스의 superclass가 되었고, Dog 클래스는 Animal 클래스의 subclass가 되었다. Dog 클래스는 Animal 클래스가 가지고 있는 this.name과 getName()을 똑같이 갖는다.
subclass의 constructor에는 super()를 넣어 superclass의 constructor를 호출할 수 있다. subclass에서 super()를 사용하지 않아도 되는 경우 에러가 발생하지는 않지만, 그래도 super()를 명시하길 권장한다.
클래스를 상속할 때는 IS-A 관계나 HAS-A 관계를 만족하는지 확인해야 한다. 가령 "사과는 과일이다"는 IS-A 관계를 만족하므로 Fruit 클래스가 Apple 클래스의 superclass가 될 수 있다. 또한 "차에는 바퀴가 있다"는 HAS-A 관계를 만족하므로 Car 클래스가 Wheel 클래스의 superclass가 될 수 있다.
// index.js
import Dog from './Dog';
let jake = new Dog('Jake');
console.log(jake.getName()); // 'Jake'
이런 식으로 사용한다. Dog 인스턴스 jake가 Animal 클래스의 getName()을 호출할 수 있다.
▽ 추가적으로 보면 좋은 상속 예제
class Shape {
constructor(width, height, color) {
this.width = width;
this.height = height;
this.color = color;
}
draw() {
console.log(`drawing ${this.color} color of`);
}
//여기서 무언가를 수정하면 Shape을 상속하는 클래스들도 변경되서 적용됨
getArea() {
return this.width * this.height;
}
}
class Rectangle extends Shape {}
// Shape에 있는 모든 것들이 Rectangle에 포함되게 됨
class Triangle extends Shape {
// 새로운 것을 작성하면 새롭게 생성되고(여기선 toString),
// 원래 있던 것에 다르게 작성하면 재정의 되고(여기선 getArea()),
// 원래 있던 것을 언급하지 않고 넘어가면 그대로 복제되어 생성됨
draw() {
super.draw();
// 부모 클래스의 draw를 호출하고 싶으면 super를 사용하면 됨
console.log("🔺");
// overriding하면 더이상 Shape에 정의된 draw함수가 호출되지 않고 재정의한 함수만 호출된다.
}
getArea() {
return (this.width * this.height) / 2;
}
toString() {
return `Triangle color: ${this.color}`;
}
}
const rectangle = new Rectangle(20, 20, "blue");
rectangle.draw(); // drawing blue color of
console.log(rectangle.getArea()); // 400
const triangle = new Triangle(20, 20, "red");
triangle.draw(); // drawing red color of // 🔺
console.log(triangle.getArea()); // 200
console.log(triangle.toString()); // Triangle color: red
다형성은 동일한 요청에대해 서로 다른 방식으로 응답할 수 있도록 만드는 것을 말한다. 즉 어떤 변수, 메소드가 상황에 따라 다른 결과를 내는 것.
오버로딩(Overloading)은 같은 이름, 다른 매개변수를 가진 메소드가 여러개 존재하는 것을 말한다. 매개변수가 다르면 다른 메소드임을 알 수 있기 때문에 가능한 기능이다.
하지만 자바스크립트는 한 클래스 안에 같은 이름을 가진 메소드가 여러 개 존재할 수 없으며, constructor도 반드시 하나만 있어야 한다. 즉 오버로딩은 자바스크립트에는 없는 개념이다.
대신 매개변수의 존재 여부에 따라 분기를 나누는 방식으로 오버로딩과 비슷한 동작을 구현할 수는 있다.
오버라이딩(Overriding)은 superclass(부모)의 메소드를 subclass(자식)의 용도에 맞게 재정의하는 것을 말한다.
// Animal.js
class Animal {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
makeNoise() {
console.log('It makes a noise');
}
}
export default Animal;
먼저 Animal 클래스에 makeNoise() 메소드를 추가했다.
// Dog.js
import Animal from './Animal';
class Dog extends Animal {
constructor(name) {
super(name);
}
// Override
makeNoise() {
console.log('Bark!');
}
}
export default Dog;
subclass인 Dog 클래스에 같은 이름의 메소드 makeNoise()를 정의했다.
// index.js
import Dog from './Dog';
let jake = new Dog('Jake');
console.log(jake.getName()); // 'Jake'
jake.makeNoise(); // 'Bark!'
Animal 클래스의 makeNoise()가 Dog 클래스의 makeNoise()로 오버라이드된 것을 볼 수 있다.
Animal 클래스가 분명 존재하지만, 단순히 '동물'을 만든다는 것은 조금 이상한 일이다. 동물은 추상적인 개념이기 때문에 Animal 객체를 생성하는 일이 있어서는 안된다. 이럴 때 추상화(Abstraction)을 통해 new Animal(...);와 같은 명령을 미연에 방지할 수 있다.
Java의 경우 public abstract class Animal {...}
과 같은 방법으로 추상 클래스를 만들 수 있으나, 자바스크립트에서는 추상 클래스나 메소드를 만들 수 없다. 다만 추상 메소드를 직접 구현하는 방법은 있다.
// Animal.js
class Animal {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
// Abstract
makeNoise() {
throw new Error('makeNoise() must be implement.');
}
}
export default Animal;
makeNoise()를 추상 메소드로 만들어 subclass에서 구현되지 않은 makeNoise()를 호출하면 에러를 발생시키도록 했다. 이 경우 추상 메소드는 반드시 subclass에서 오버라이드 되어야 한다.
추상 클래스를 만드는 것은 조금 더 번거롭다. 직접 Abstract 클래스를 만들어 상속시키는 방식인데, 스택오버프롤우의 Does ECMAScript 6 have a convention for abstract classes?를 참고하자.
인터페이스(Interface)는 추상 메소드들의 집합이다. 클래스와 다르며, 인스턴스 변수를 가실 수 없다. 자바의 경우 인터페이스는 public interface Pet {...}
과 같이 만들고, 다른 클래스에서 public class Dog extends Animal implements Pet
과 같은 방식으로 구현한다.
이 코드에서 Dog 클래스는 Animal 클래스를 상속받고, Pet 인터페이스를 구현한다. 즉, Animal 클래스의 메소드, 인스턴스 변수와 Pet 인터페이스의 추상 메소드를 가진다.
인터페이스만 보면 이를 구현하는 클래스가 어떤 동작을 하는지 직관적으로 볼 수 있고, 자바에서는 각 타입별로 새로운 메소드를 오버로딩할 필요가 없어진다.
매우 편리한 기능이지만, 자바스크립트는 타입이 없는 덕 타이핑(Duck typing)언어이기 때문에 인터페이스와 같은 문법이 없다.
타입스크립트에는 자바와 유사한 방식으로 인터페이스를 사용할 수 있다.