본문 바로가기
  • soldonii's devlog
Javascript 공부/Advanced Javascript(-)

자바스크립트의 객체 지향 프로그래밍 1

by soldonii 2019. 10. 19.

*Udemy의 "Advanced Javascript Concepts"강의에서 학습한 내용을 정리한 포스팅입니다.

*자바스크립트를 배우는 단계라 오류가 있을 수 있습니다. 틀린 내용은 댓글로 말씀해주시면 수정하겠습니다. 감사합니다. :)


1. 프로그래밍 패러다임?

객체지향 프로그래밍(OOP - Object Oriented Programming), 함수형 프로그래밍(FP - Functional Programming) 모두 프로그래밍 패러다임의 종류 중 하나이다. 프로그래밍 패러다임을 사용하는 목적은 아래와 같다.

1. Clear + Understandable
2. Easy to Extend
3. Easy to Maintain
4. Memory Efficient
5. DRY(Do not Repeat Yourself)

 

# OOP vs. FP

모든 프로그래밍 언어의 동작을 위한 구성요소는 크게 두가지이다.

1) 데이터(state)

2) 동작(function)

 

OOP, 즉 객체지향 프로그래밍은 '데이터'와 '동작'을 한 곳(객체)에 모아놓아서 더 이해하기 쉬운 코드를 만들고자 하는 프로그래밍 기법이다. 반면 FP, 함수지향 프로그래밍은 '데이터'와 '동작'은 서로 다른 것이므로 따로 관리하여 구분짓는 프로그래밍 기법이다.

 

이 둘의 관계는 vs.의 관계가 아니다. 무엇이 더 우위에 있는 것도 아니며 서로 상호보완적이기 때문에 내가 직면한 문제에 따라서 여러가지 멀티 패러다임을 다양하게 활용하는 것이 자바스크립트의 정수이다.

 

프로그래밍 패러다임을 배우기 전까지 클로져와 프로토타입에 대해서 학습했는데, 이 중 클로져는 함수와 아주 크게 연관이 되어 있기 때문에 함수형 프로그래밍과 관련이 깊은 반면, 프로토타입은 객체와 아주 큰 연관이 있기 때문에 객체지향 프로그래밍의 아주 주요한 개념이 된다.

 

2. 객체지향 프로그래밍이란?

# 객체지향 프로그래밍 입문

let dragon = {
  name: 'Tanya',
  fire: true,
  fight() {
    return 5;
  }
}

위 코드의 사례가 바로 객체지향 프로그래밍의 전형적인 패턴이다. dragon이라는 객체 안에 dragon의 특성을 설명해주는 name과 fire라는 데이터(state)를 보관하고 있고, 동시에 fight라는 dragon 객체가 취해야 하는 동작(function)도 함께 보관하고 있다. 이처럼 객체 내의 데이터(state)를 통해 해당 객체의 상태를 계속 추적할 수 있으며, 함수를 통해 객체의 상태(state)를 변경하면서 유기적으로 상호작용 하고 있다.

 

# OOP를 향한 1단계 : Encapsulation

간단한 게임을 만든믄데, 게임에는 Elf, Ogre, Knight, Hunter 등 다양한 캐릭터가 존재한다고 가정해보자. 만약 사용자가 Elf 캐릭터를 하나 만든다고 할 때, 얼마나 많은 반복 작업이 필요할지 아래 코드를 통해 상상해보자.

const elf = {
  name: 'Orwell',
  weapon: 'bow',
  attack() {
    return `attack with ${elf.weapon}`;
  }
}

elf.attack(); // 'attack with bow'

첫번째 유저가 elf 캐릭터를 생성했다. 그런데 만약 두번째 유저가 곧바로 elf 캐릭터를 또 생성한다면?

const elf2 = {
  name: 'Sally',
  weapon: 'bow',
  attack() {
    return `attack with ${elf2.weapon}`;
  }
}

이 코드들은 name과 weapon이라는 데이터(state)와 데이터를 이용한 동작인 attack 함수(function)을 elf2라는 하나의 객체 안에 보관하고 있다. 이러한 개념을 Encapsulation이라고 한다. 무언가를 구현하기 위해 필요한 데이터와 동작을 하나의 객체에 보관한 것이다. 그러나 문제는 만약 1억개의 elf 캐릭터를 생성해야 한다면? 이 코드를 1억번 복사 붙여넣기 한 후, name과 weapon을 모두 일일이 수정해주어야 할 것이다.

 

# OOP를 향한 2단계 : Factory Functions

function createElf(name, weapon) {
  return {
    name: name,
    weapon: weapon,
    attack() {
      return 'attack with ' + weapon;
    }
  }
}

const peter = createElf('Peter', 'stones');
peter.attack(); // 'attack with stones'
const sam = createElf('Sam', 'stones');
sam.attack(); // 'attack with fire'

객체를 생성해주는 함수를 만들어서, 유저가 추가될 때마다 반복적인 여러 줄의 코드를 복사하는 대신 1회 함수 실행으로 대신하고 있으며, 매개변수를 받도록 하고 있다. 하지만 이 경우에 메모리 공간의 낭비가 발생하고 있다. name과 weapon은 모든 캐릭터마다 다르기 때문에 캐릭터 생성마다 매번 새롭게 설정하여 메모리 공간에 저장하는 것이 맞지만, attack 메소드의 경우 캐릭터의 이름과 무기가 어떻든 관계 없이 동일한 동작이다. 그러나 매 캐릭터마다 이 메소드가 따로 메모리에 저장되고 있기 때문에 중복이 발생하고 있는 것이다.

 

이를 개선하기 위해 기존에 배운 Prototypal Inheritance, 프로토타입 상속 개념을 활용해보자.

 

# OOP를 향한 3단계 : Object.create()

const elfFunctionsStore = {
  attack() {
    return 'attack with ' + this.weapon;
  }
}

function createElf(name, weapon) {
  return {
    name: name,
    weapon: weapon
  }
}

const peter = createElf('Peter', 'stones');
peter.attack = elfFunctionsStore.attack;
peter.attack(); // 'attack with stones'
const sam = createElf('Sam', 'stones');
sam.attack = elfFunctionsStore.attack;
sam.attack(); // 'attack with fire'

모든 elf가 동일하게 공유하는 attack 함수를 하나의 변수에 담아둔 뒤, 캐릭터 생성 후 해당 함수를 캐릭터의 특성에 부여했다. 하지만 이 또한 여러차례의 수작업이 필요하다. Object.create()를 이용해서 개선해보자.

 

const elfFunctionsStore = {
  attack() {
    return 'attack with ' + this.weapon;
  }
}

function createElf(name, weapon) {
  let newElf = Object.create(elfFunctionsStore);
  newElf.name = name;
  newElf.weapon = weapon;
  return newElf;
}

const peter = createElf('Peter', 'stones');
peter.attack(); // 'attack with stones'
const sam = createElf('Sam', 'fire');
sam.attack(); // 'attack with fire'

엘프를 생성할 때, 최초에 attack 함수가 저장되어 있는 객체를 상속받도록 지정했다. (Object.create 관련 MDN 문서 참조) 덕분에 많은 코드의 반복이 줄어들었다. 하지만 이 방식이 자바스크립트 커뮤니티에서 많이 사용되는 표준이라고 보기는 어렵다. 조금 더 나은 다른 방식은 없을까?

 

# OOP를 향한 4단계 : Constructor Functions (1)

function Elf(name, weapon) {
  this.name = name;
  this.weapon = weapon;
}

const peter = new Elf('Peter', 'stones');
peter.attack(); // 'attack with stones'
const sam = new Elf('Sam', 'fire');
sam.attack(); // 'attack with fire'

대문자로 시작하는 함수 Elf는 생성자함수를 나타낸다. 이 생성자 함수 안에서는 따로 return 시키는 구문이 없지만, new 키워드를 통해서 생성자 함수를 이용해서 객체 초기화를 시킬 경우, 자동으로 생성한 객체를 리턴시키게 된다. 생성자 함수 또한 함수이기 때문에 함수 실행과 동시에 새로운 실행 컨텍스트가 생성되고, 이 실행 컨텍스트 내부에는 this와 argument 객체가 자동으로 생성된다.

일반적으로는 this는 window를 가리키지만, 생성자함수를 호출할 경우, this는 window 대신 생성자함수로 생성한 객체를 가리키게 된다.(즉, 위 사례에서는 peter, 그리고 sam이 각 생성자함수 실행 컨텍스트 내에서의 this가 된다.)

 

또한 모든 함수는 prototype 객체를 가지고 있다고 했는데, 일반적으로 사용자 정의 함수에서 prototype 객체는 사용할 일이 거의 없다. 그러나 생성자 함수의 경우 이야기가 다르다. 생성자 함수 내에 존재하는 prototype 객체는, 생성자 함수를 통해서 생성된 객체에게 상속이 된다. 따라서 생성자 함수 내의 prototype 객체는 중요한 역할을 한다.

 

function Elf(name, weapon) {
  this.name = name;
  this.weapon = weapon;
}
Elf.prototype.attack = function() {
  return 'attack with ' + this.weapon;
}

const peter = new Elf('Peter', 'stones');
peter.attack(); // 'attack with stones'

배운 내용을 토대로 위 코드를 천천히 해석해보자.

1. Elf 생성자 함수를 기반으로, name이라는 key에는 'Peter'라는 value가, weapon key에는 'stones'라는 value가 대응된 객체가 생성 후 return되어 peter 변수에 할당된다.

2. 현재 peter 객체 내에는 name과 weapon 밖에 없기 때문에, peter.attack() 메소드는 원래는 사용이 불가능하다.

3. 하지만 peter 객체는 생성자 함수인 Elf 생성자 함수 내에 존재하는 prototype 객체를 상속받고 있다.

4. Elf 생성자 함수 내의 prototype 객체에 attack 이라는 메소드를 지정해주었다.

5. 그렇기 때문에 peter.attack()은 peter 내에서 우선적으로 attack 메소드를 찾지만 찾지 못하기 때문에, 프로토타입 체인이 형성된 상위 프로토타입 객체로 거슬러 올라가고, 그 곳에 attack 메소드가 정의되어 있기 때문에, 이를 상속받아 'attack with stones'를 출력한다.

 

console.log(peter.__proto__); // {attack: ƒ, constructor: ƒ}
Elf.prototype; // {attack: ƒ, constructor: ƒ}

peter 객체의 상위 프로토타입 체인으로 거슬로 올라갔을 때의 결과물은 Elf 생성자 함수 내의 프로토타입 객체와 동일함을 알 수 있다.

 

이렇게 프로토타입 개념을 활용하는 이유는, 앞서 언급했지만 메모리 효율성이 증대되기 때문이다. 엘프가 1억개 생성된다고 할 경우, 프로토타입을 상속받도록 함으로써, 해당 메소드는 1억번 생성되어 메모리에 할당되는 대신, 딱 1회만 메모리에 할당된다.

 

# OOP를 향한 4단계 : Constructor Functions (2)

생성자 함수에 대해서 더 알아보자.

function Elf(name, weapon) {
  this.name = name;
  this.weapon = weapon;
  var a = 5; // a는 객체의 프로퍼티로 추가되지 않는다.
}

this를 사용하지 않고 변수를 선언할 경우, Elf 생성자 함수를 사용해서 생성된 객체에 프로퍼티로 추가되지 않는다. 즉, a는 객체의 프로퍼티가 될 수 없다.

 

function Elf(name, weapon) {
  this.name = name;
  this.weapon = weapon;
}
Elf.prototype.build = function() {
  function building() {
    return this.name + ' builds a house';
  }
  building();
}

const peter = new Elf('Peter', 'stones');
console.log(peter.build()); // undefined

peter.build()는 왜 undefined를 출력할까? build 메소드는 또 다른 building 함수를 생성한 후, 이를 호출시키고 있다. 그런데 building 함수 내에서 return되는 구문은 this.name이다. 그런데 이 경우 this는 생성될 객체를 가리키는 대신 window를 가리키게 된다. 따라서 window.name이라는 소리인데, global에는 name 변수가 존재하지 않기 때문에 undefined를 출력하는 것이다. 이를 해결하기 위해서는?

 

// 1. this를 변수에 할당
function Elf(name, weapon) {
  this.name = name;
  this.weapon = weapon;
}
Elf.prototype.build = function() {
  const self = this; // 1) this를 변수에 묶어 놓은 후,
  function building() {
    return self.name + ' builds a house'; // 2) 해당 변수를 참조하게 한다.
  }
  return building();
}

const peter = new Elf('Peter', 'stones');
console.log(peter.build()); // undefined

// 2. bind 메소드 활용
function Elf(name, weapon) {
  this.name = name;
  this.weapon = weapon;
}
Elf.prototype.build = function() {
  function building() {
    return this.name + ' builds a house';
  }
  return building.bind(this); // building 함수를 bind를 이용해서 this를 현재의 this로 할당
}

const peter = new Elf('Peter', 'stones');
console.log(peter.build()()); // binding된 함수를 다시 한 번 더 실행해서 결과물 얻어내기

1. 사용하기를 원하는 this, 즉 이 경우 해당 객체를 변수에 담아놓은 뒤, 이 변수를 this 대신 사용한다. 이 변수에는 this에 대한 reference가 저장되어 있기 때문에 해당 시점에서의 this가 담겨있으므로 원하는 결과를 얻을 수 있다.

2. bind 메소드를 이용해서, 부여하고자 하는 this를 명시적으로 현재의 this로 변경시켜준다.

 

생성자 함수와 프로토타입 개념을 이용해서 OOP에 한 발 더 다가가보았다. 하지만 이것이 OOP의 최종 단계는 아니다. 사실 위와 같은 코드는 일반적으로 많이 사용되지는 않는다고 한다. 왜냐하면 프로토타입이라는 개념 자체가 이해하기에 쉽지 않기 때문이다. OOP의 최종 단계에 사용될 개념은 무엇일지 다음 글에서 살펴보자.

댓글