Front End/JavaScript

객체 지향 프로그래밍

객체 지향 프로그래밍 (OOP, Object-oriented programming)

객체 지향 프로그래밍은 사람이 세계를 보고 이해하는 방법과 매우 흡사하다. 코드를 추상화하여 직관적으로 생각 할 수 있기 때문에 오래전부터 프로그래밍 방법론으로 빠르게 적용되었다.

객체 지향 프로그래밍을 철저하게 적용한 Java나 C#과는 다르게, JavaScript에서 OOP를 구현하는 방법은 조금 독특하다.

객체 지향 vs. 절차 지향

절차 지향 프로그래밍(대표적으로는 C, Fortran)은 단순히 기능들을 나열하여 순차적으로 작성하는 것에 반해, 객체 지향 프로그래밍은 데이터와 기능을 한 곳에 묶어서, 다시 말해 객체로 그룹화하여 처리할 수 있다. 이 객체는 한 번 만들고 나면 메모리상에서 반환되기 전까지 객체 내에 모든 것이 유지된다.

JavaScript는 엄밀히 말해 객체 지향 언어는 아니지만, 객체 지향 패턴으로 작성할 수 있다. 속성(data)과 메서드(function)가 하나의 객체라는 개념에 포함되며, 이는 데이터 타입(object)와는 달리 클래스(Class)라는 이름으로 부른다.

객체 지향 프로그래밍 기본 개념 (OOP Basic Concepts)

Encapsulation (캡슐화)

  • 데이터(속성)과 기능(메서드를) 하나의 객체 안에 넣어서 묶는 것

    → 이를 통해 코드가 복잡하지 않게 만들고 재사용성을 높인다.

  • 느슨한 결합(Loose Coupling)에 유리 : 언제든 구현을 수정할 수 있음

    → 코드 실행 순서에 따라 절차적으로 작성하는게 아니라 코드가 상징하는 실제 모습과 닮게 코드를 모아 결합하는 것을 의미한다. 예를 들어, 마우스 구동을 위한 코드를 작성한다면 스위치가 눌리고, 전기 신호가 생겨서, 전선을 타고 흐르는 등의 전 과정을 나누어서 작성하는게 아니라 마우스의 상태를 속성(property)로 정하고 클릭이나 이동을 메서드(method)로 정해 코드만 보고도 인스턴스 객체의 기능을 상상할 수 있게 작성하는 것이 느슨한 결합을 추구하는 작성법이다.

  • 은닉화(hiding) : 구현은 숨기고, 동작은 노출시킴

    → 캡슐화는 은닉화의 특징도 포함하는데, 은닉화는 내부 데이터나 내부 구현이 외부로 노출되지 않도록 만드는 것이다. 디테일한 구현이나 데이터는 숨기고, 객체 외부에서 필요한 동작(메서드)만 노출시켜야 한다.

    → 은닉화의 특징을 잘 살려서 코드를 작성하면 객체 내의 메서드의 구현만 수정하고, 노출된 메서드를 사용하는 코드 흐름은 바뀌지 않도록 만들 수 있기 때문에 유지 보수에 유리하다. 그래서 더 엄격한 클래스는 은닉화를 위해 속성의 직접적인 접근을 막고, 설정하는 함수(setter), 불러오는 함수(getter)를 철저하게 나누기도 한다.

Abstraction (추상화)

  • 내부 구현은 아주 복잡한데, 실제로 노출되는 부분은 단순하게 만든다는 개념

    → 예를 들어 전화라는 객체가 있다면, 그 안에 스피커와 마이크가 존재하고 서킷 보드 등이 존재하는 등 내부 구현이 되어 있을 것이다. 하지만 실제로 사용할 때는 이런 존재에 대해서는 생각하지 않고 단순히 수화기를 들고 버튼을 눌러서 해결하는 것으로 인터페이스의 단순화가 가능하다.

  • 많은 기능을 드러내지 않고 인터페이스를 단순화 하는 것

    → 캡슐화와 비교했을 때 헷갈릴 수 있지만, 캡슐화는 코드나 데이터의 은닉에 포커스가 맞춰졌다면 추상화는 클래스를 사용하는 사람이 필요하지 않은 메서드 등을 노출시키지 않고 단순한 이름으로 정의하는 데에 포커스가 맞춰져있다. 클래스 정의 시, 메서드와 속성만 정의한 것을 인터페이스라고 부르고, 이것이 추상화의 본질이다.

  • 코드가 복잡하지 않게 만들고, 단순화된 사용으로 변화에 대한 영향을 최소화한다.

Inheritance (상속)

  • 부모 클래스의 특징을 자식 클래스가 물려 받는 것

    부모/자식으로 이야기 할 수 있지만, ‘기본 클래스(base class)의 특징을 파생 클래스(derived class)가 상속받는다’ 는 표현이 적합하다.

    예를 들어, 사람이라는 클래스가 있다고 가정할 때 기본적으로 가지는 속성과 메서드가 있을 것이다. 이 때 추가적으로 학생이라는 클래스를 작성한다면 학생의 본질은 결국 사람이므로, 상속을 이용해 학생 클래스는 사람 클래스를 상속받아 추가적으로 필요한 속성과 메서드를 작성할 수 있다.

  • 불필요한 코드를 줄여 재사용성을 높인다.

Polymorphism (다형성)

  • 다양한 형태를 가질 수 있는 것

    객체는 똑같은 메서드라 하더라도, 다른 방식으로 구현될 수 있다. 예를 들어 HTML의 Element라는 객체 내에 textbox, select, checkbox 등 다양한 요소들이 render라는 메서드를 통해 화면에 구현된다고 가정했을 때, render메서드가 모든 요소가 똑같은 방식으로 구현한다면 우리가 보는 HTML 화면에서 요소의 특징적인 부분을 확인할 수 없다. 하지만 같은 메서드를 쓰더라도 요소에 따라 render가 다르게 동작할 수 있다면 이는 다형성을 가질 수 있는 것이다.

  • 동일한 메서드에 대해 조건문 대신 객체의 특성에 맞게 달리 작성하는 것이 가능하다.

JavaScript가 일반적인 OOP 프로그래밍 언어와 다른 점

은닉화의 한계 (private키워드)

Java나 TypeScript는 클래스 내부에서만 쓰이는 속성과 메서드를 구분하기 위해 private라는 키워드를 제공한다. JavaScript에는 이런 은닉화를 도와주는 기능(키워드)이 지원하는 브라우저가 적어 널리 쓰이지 않으며, 보통 은닉화를 위해 일반적으로는 클로저 모듈 패턴을 사용한다.

// TypeScript 문법

class Animal {
  private name: string;

  constructor(theName: string) {
    this.name = theName;
  }
}

new Animal("Cat").name; // 사용 불가
// Property 'name' is private and only accessible within class 'Animal'.

추상화 기능의 부재 (interface 키워드)

추상화는 속성과 메서드의 이름만 노출시켜 사용을 단순화한다는 의미를 갖고 있고 이는 인터페이스(interface)의 단순화를 의미한다. Java나 TypeScript는 주요 기능으로 interface를 구현했다. 그러나 이런 부분은 JavaScript에는 존재하지 않는다.

인터페이스의 이점은 인터페이스가 일종의 규약처럼 간주되어 인터페이스를 클래스로 구현하는 사람들이 이에 맞게 작성할 수 있도록 돕는다. 이는 클래스를 이용하는 입장에서 노출된 인터페이스를 통해 메서드 이름에 맞게 클래스가 의도한대로 작동할거라는 것을 드러내주고 실질적인 구현 방법을 공개하지 않고 사용법만 노출시키기에도 유리하다.

// TypeScript 문법

// interface를 따로 정의
interface ClockInterface {
  currentTime: Date;
  setTime(d: Date): void;
}

// 실제 구현은 아래의 클래스로 정의됨
class Clock implements ClockInterface {
  currentTime: Date = new Date();
  setTime(d: Date) {
    this.currentTime = d;
  }
  constructor(h: number, m: number) {}
}

메서드 호출

메서드 호출은 Object.method()와 같이 객체 내에 메서드를 호출하는 방법을 의미한다. 단순 객체를 사용해 아래의 예제처럼 메서드를 구현할 수 있다.

let counter1 = {
  value: 0,
  increase: function() {
    this.value++; // 메서드 호출을 할 경우, this는 counter1을 가리킴
  },
  decrease: function() {
    this.value--;
  },
  getValue: function() {
    return this.value;
  }
}

counter1.increase(); // value: 1
counter1.increase(); // value: 2
counter1.increase(); // value: 3
counter1.decrease(); // value: 2
counter1.getValue(); // 2

클로저를 이용해 매번 새로운 객체 만들기

위의 방법은 단 하나의 객체만 만들 수 있다. 만약 똑같은 기능을 하는 카운터가 여러개 필요하다면 앞서 배웠던 클로저 모듈 패턴을 이용해 아래와 같이 만들 수 있다.

function makeCounter() {
  let value = 0;
  return { // 객체를 리턴
    increase: function() {
      value++;
    },
    decrease: function() {
      value--;
    },
    getValue: function() {
      return value;
    }
  }
}

let counter1 = makeCounter()
counter1.increase()
counter1.getValue() // 1

let counter2 = makeCounter()
counter2.decrease()
counter2.decrease()
counter2.getValue() // -2

클래스와 인스턴스

객체 지향 프로그래밍은 하나의 모델이 되는 청사진(blueprint)을 만들고, 그 청사진을 바탕으로 한 객체를 만드는 프로그래밍 패턴이다.

예를 들어 자동차를 만든다고 했을 때, 자동차의 청사진은 기능이 되는 기본적인 설계(네 바퀴와 핸들, 좌석, 엔진 등)로 차의 종류와 상관없이 대체적으로 동일하게 적용된다. 그리고 생산되는 차량의 종류는 이 청사진을 바탕으로 각각의 특성을 살려 다양한 차종을 생산할텐데, 이렇게 청사진을 바탕으로 한 차량을 객체(object)에 빗댈 수 있는 것이다.

하지만 이미 JavaScript에는 객체(object)라는 개념이 객체 지향 프로그래밍과 무관하게 존재한다. 그래서 청사진을 바탕으로 한 객체는 인스턴스 객체(instance object), 줄여서 인스턴스(instance)라 부르고, 청사진은 클래스(class)라 부른다.

클래스를 만드는 방법

클래스는 함수를 정의(function)하듯 만들 수 있다. 하지만 일반적인 다른 함수와 구분이 필요하기 때문에 클래스는 보통 대문자로 시작하여 일반명사로 만든다. 반대로 일반적인 함수는 적절한 동사를 포함하여 소문자로 시작해 구분짓는다.

또한 ES6부터 클래스를 만드는 방법이 새롭게 도입되었는데, class 키워드를 이용하는 방법이다. 최근에는 ES6 방법을 주로 사용하며, 클래스 내부에 들어가는 함수는 생성자 함수(constructor)라 하여 인스턴트가 만들어질 때 실행되는 코드가 들어간다. 이 때, 생성자 함수는 return 값을 만들지 않는다.

// ES5 이전 : 함수로 정의하는 클래스
function Car (brand, name, color){
	// 인스턴스가 만들어질 때 실행되는 코드
}

// ES6 : class 키워드로 정의하는 클래스
class Car {
  constructor(brand, name, color) { // 생성자 함수
		// 인스턴스가 만들어질 때 실행되는 코드
  }
}

클래스 : 속성의 정의

// ES5 이전 : 함수로 정의하는 속성
function Car (brand, name, color){
  this.brand = brand;
  this.name = name;
  this.color = color;
}

// ES6 : class 키워드로 정의하는 속성
class Car {
  constructor(brand, name, color) { // 생성자 함수
    this.brand = brand;
    this.name = name;
    this.color = color;
  }
}
🤔
this? → 객체 지향 프로그래밍에선 빠지지 않고 등장하는 키워드 → 함수가 실행될 때, 해당 scope마다 생성되는 고유한 실행 context(execution context) → 위의 예제에서는 인스턴스 객체를 의미하며, 위와 같이 this에 할당한다는 것은 만들어진 인스턴스에 각각의 속성과 값을 부여하겠다는 의미이다. → new 키워드로 인스턴스를 생성했을 때, 해당 인스턴스가 this의 값이 된다.

클래스 : 메서드의 정의

ES6 이전의 함수로 정의하는 방식에서는 prototype이라는 키워드를 사용해야 메서드를 정의할 수 있다. prototype은 이후 자세히 배우겠지만, 청사진의 숨겨진 설계도 정도로 단순히 이해하고 넘어가자.

ES6에서는 생성자 함수와 함께 class키워드 안쪽에 묶어서 정의한다.

// ES5 이전 : 함수로 정의하는 메소드 -> prototype 키워드 사용
function Car (brand, name, color){
  this.brand = brand;
  this.name = name;
  this.color = color;
}

Car.prototype.refuel = function(){
  // 연료 공급을 구현하는 코드
}

Car.prototype.drive = function(){
  // 운전을 구현하는 코드
}

// ES6 : class 키워드로 정의하는 메소드
class Car {
  constructor(brand, name, color) { // 생성자 함수
    this.brand = brand;
    this.name = name;
    this.color = color;
  }
  refuel(){
    // 연료 공급을 구현하는 코드
  }

  drive(){
    // 운전을 구현하는 코드
  }
}
⚠️
메서드 호출 방식을 작성할 때는 화살표함수를 쓰지 않는다. 화살표함수는 자신의 this를 가지고 있지 않아 바인딩 될 수 없기 때문이다.

인스턴스를 만드는 방법

만들어진 클래스를 이용해 인스턴스를 만드려면 new라는 키워드를 사용해야 한다. 즉시 생성자 함수가 실행되며, 변수에 클래스의 설계를 가진 새로운 인스턴스가 할당된다. 각각의 인스턴스는 클래스의 고유한 속성과 메서드를 갖는다.

let avante = new Car('hyundai', 'avante', 'black');
let mini = new Car('bmw', 'mini', 'white');
let beetles = new Car('valkswagen', 'beetles', 'red');

속성과 메서드의 차이

속성은 단순히 객체 내의 키-값처럼 데이터를 저장한다면, 메서드는 객체에 딸린 함수이기 때문에 특정 동작을 수행할 수 있다. 자동차로 비유하자면 다음과 같다.

속성메소드
brand name color currentFuel maxSpeedrefuel() setSpeed() drive()

인스턴스에서 속성과 메서드의 사용

// ES6 : class 키워드로 정의하는 메소드
class Car {
  constructor(brand, name, color) { // 생성자 함수
    this.brand = brand;
    this.name = name;
    this.color = color;
  }
  refuel(){
    // 연료 공급을 구현하는 코드
  }

  drive(){
    // 운전을 구현하는 코드
  }
}

let avante = new Car('hyundai', 'avante', 'black');
let mini = new Car('bmw', 'mini', 'white');

avante.name; // 'avante'
mini.color; // 'white'

avante.refuel();
mini.drive();

위와 같이 작성된 클래스가 있을 때, 새로운 변수를 선언하고 인스턴스를 할당하면 클래스에서 작성된 생성자 함수로 정의 된 속성과 함수로 작성된 메서드를 사용할 수 있다. 이는 우리가 익히 알고 있던 속성과 메소드를 활용하는 방법과 크게 다르지 않다.

실전 예제 : 배열

앞서 말했듯 우리는 이미 객체지향적인 방법을 이미 사용하고 있다. 우리가 자주 만들어 사용하던 배열 역시 Array의 인스턴스이다.

let arr1 = ['nine', 'floor', 'ninefloor'];
let arr2 = new Array('nine', 'floor', 'ninefloor');

arr.length; // 3
arr.push('#GG4EVA');

실습 : ES5 방식으로 작성된 클래스와 인스턴스를 ES6로 작성

class Car {
  constructor(brand, name, color) {
    this.brand = brand;
    this.name = name;
    this.color = color;
  }

  drive(){
    console.log(`${this.name}가 운전을 시작합니다.`);
  }
}

let avante = new Car('hyundai', 'avante', 'black');
avante.color;
avante.drive();

프로토타입

JavaScript는 프로토타입 기반 언어다. 여기서 프로토타입(Prototype)은 원형 객체를 의미하며 모든 객체들이 메소드와 속성을 상속받기 위해 템플릿으로써 프로토타입 객체를 가진다. 프로토타입 객체는 상속을 위한 일종의 유전자로, 원시형 자료와 자료 참조값을 제외하고 복사를 수행할 수 없는 JavaScript가 객체를 상속하게 하는 주요 기능으로서 동작하게 된다.

class Human {
  constructor (name, age) {
    this.name = name;
    this.age = age;
  }
  sleep(){
    console.log(`${this.name}은 잠에 들었습니다`);
  }
}

let ninefloor = new Human('nine', 30);


Human.prototype.constructor === Human; // true
Human.prototype === ninefloor.__proto__; // true
Human.prototype.sleep === ninefloor.sleep; // true

위와 같은 코드에서 ninefloor.valueOf()를 입력하면 인스턴스에 valueOf()라는 메서드가 정의되어 있지 않음에도 불구하고 정상적으로 작동하여 객체의 값을 단순 반환한다. 이 때, 작동되는 원리는 다음과 같다.

  • 브라우저는 먼저 생성된 ninefloor인스턴스에 valueOf()메서드가 있는지 확인한다.
  • ninefloor에 존재하지 않기 때문에 그의 프로토타입 객체(Human.prototypevalueOf()메서드가 있는지 확인한다.
  • 여전히 없기 때문에 Human.prototype의 프로토타입 객체(Object().prototype)가 valueOf() 메서드를 가지고 있는지 확인한다. 여기에 있으니 호출 완료.

그 이유가 바로 우리가 생성한 객체의 생성자인 Human, 그리고 Human의 생성자인 Object의 prototype에 존재하는 메서드가 존재하기 때문에 메서드가 동작하는 것이다. 이렇게 동작하는 것을 프로토타입 체이닝이라 부르며, 이는 아래에서 다시 소개한다.

클래스, 인스턴스, 프로토타입의 관계

Human이라는 클래스와 인스턴스, 그리고 프로토타입의 관계

그래서 클래스와 인스턴트를 연결해주는 것이 프로토타입이라 부를 수 있다. 클래스에서 정의된 대로 생성을 하게 되면 클래스의 프로토타입을 참조하여 인스턴스가 생성이 되고, 이 때 클래스의 프로토타입의 .constructor는 클래스 자체이다. 그리고 인스턴스에서 참조하는 프로토타입에 접근하려면 .__proto__를 사용하면 된다.

prototype

앞서 소개했듯 .prototype은 부모 객체의 유전자로, 하위 객체가 생성될 때 참조할 수 있는 속성과 메서드를 의미한다.

예를 들어, class 키워드를 이용해 클래스를 정의하고 이를 통해 인스턴스를 생성하게 되면 인스턴스는 클래스의 .prototype을 상속(참조)받아 인스턴스에서 정의되지 않은 속성이나 메서드도 사용 가능하다. 이 때, 클래스의 .constructor(함수 생성자) 내부의 속성과 메서드는 .prototype의 개별 속성으로 동작하지 않고 인스턴스에 그대로 작성된다.

class GG {
  constructor (name) {
    this.name = name;
    this.hello = function(){
      console.log(`안녕하세요, 소녀시대 ${this.name} 입니다.`);
    }
  }
}

let taeyeon = new GG('태연');
let tiffany = new GG('티파니');
let sunny = new GG('써니');

위의 코드를 봤을 때, 클래스로 GG가 정의되어 총 3개의 인스턴스가 생성되었다. 만약 여기서 모든 멤버가 같은 인사를 하는 메서드를 추가하려면 GG.prototype에 새로운 메서드를 추가하면 된다. (또는 클래스 내부에 메서드를 입력해도 동작한다)

GG.prototype.helloGG = function(){console.log(`안녕하세요, 소녀시대 입니다.`)}

taeyeon.helloGG(); // 안녕하세요, 소녀시대 입니다.

이렇게 .prototype을 활용하면 constructor로 동일하게 인스턴스 내에 작성이 되지 않고도 메서드나 값을 활용할 수 있다.

__proto__

prototype은 기본적인 속성으로 constructor__proto__를 가지고 있다. 이 중 __proto__는 생성된 인스턴스에서 상속받은(참조하고 있는) 프로토타입에 접근한다. 단순히 확인 뿐 아니라, 서로 다른 객체를 연결하여 프로토타입을 상속하도록 만들 수도 있다.

const a = {}
const b = {}

a.__proto__ = b
a.__proto__ // b.prototype

프로토타입 체인

상속을 JavaScript에서 구현할 때에는 앞서 말한 프로토타입 체인을 사용한다.

let ninefloor= new Human('nine', 30);

// 속성
ninefloor.age;
ninefloor.gender;
// 메서드
ninefloor.eat();
ninefloor.sleep();

위의 코드에는 사람(Human)과 학생(Student)이라는 클래스가 각각 존재한다고 가정한다. 이 때, 학생은 학생이기 이전에 사람이므로 클래스 Student는 클래스 Human의 기본적인 메서드를 상속받을 수 있다. 대신 학생은 일반적인 사람의 특징에 추가적인 특징이 필요하다.

let parkhacker = new Student('박해커', 22);

// 속성
parkhacker.grade;
// 메서드
parkhacker.learn();

학생(Student)이라고 해서 agegender 같은 속성이 존재하지 않거나 sleep()과 같은 메서드가 필요하지 않은 것은 아니다. StudentHuman의 특징을 그대로 물려받는다.

이렇게 속성과 메서드를 물려주는 클래스를 부모 클래스, 물려받는 클래스를 자식 클래스, 그리고 이 과정을 상속이라 표현한다.

ES6 : class키워드에서의 상속

부모 클래스 안에 자식 클래스를 상속시키는 방법은 extends 키워드를 사용하는 것이다.

class GG {
  constructor(name) {
    this.name = name;
    this.hello = function () {
      console.log(`안녕하세요, 소녀시대 ${this.name} 입니다.`);
    }
  }
  helloGG(){
    console.log(`안녕하세요, 소녀시대 입니다.`);
  }
}

class Member extends GG {
  constructor(name, age, position){
    this.name = name;
		this.hello = function () {
      console.log(`안녕하세요, 소녀시대 ${this.name} 입니다.`);
		}
    // 멤버에 추가되는 속성
    this.age = age;
    this.position = position;
  }
}

이 때, 상속받을 클래스의 생성자를 super()를 사용해 constructor 첫 줄에 부모 클래스의 매개변수를 입력하면 부모 클래스의 속성을 상속받을 수 있다.

class Member extends GG {
  constructor(name, age, position){
    super(name);
		this.hello = function () {
      console.log(`안녕하세요, 소녀시대 ${this.name} 입니다.`);
		}
    // 멤버에 추가되는 속성
    this.age = age;
    this.position = position;
  }
}

DOM과 프로토타입

브라우저에서 DOM을 이용하면 document.createElement('div')로 새로운 div엘리먼트를 만들 수 있다. 이렇게 생성된 div엘리먼트는 HTMLDivElement라는 클래스의 인스턴스이다.

DOM 엘리먼트는 .innerHTML과 같은 속성이나 append()같은 메서드가 있다. 각각의 다른 엘리먼트가 해당 메서드나 속성이 있다는 것을 통해 Element라는 공통의 부모가 있음을 알 수 있다.

div 엘리먼트의 상속 관계, 화살표가 부모를 의미하며 EventTarget의 부모로 모든 클래스의 조상인 Object가 있다.

인스턴스에 __proto__를 이용하면 좀 더 확실히 확인할 수 있다. __proto__를 이용해 부모 클래스의 프로토타입은 물론 부모의 부모클래스의 프로토타입도 탐색할 수 있다. 단, 모든 클래스의 최상위 부모는 Object이므로 Object.__proto__null값이 반환된다.

let div = document.createElement('div');

div.__proto__ // HTMLDivElement
div.__proto__.__proto__ // HTMLElement
div.__proto__.__proto__.__proto__ // Element
div.__proto__.__proto__.__proto__.__proto__ // Node
div.__proto__.__proto__.__proto__.__proto__.__proto__ // EventTarget
div.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__ // Object
div.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__ // null


Uploaded by N2T