초기 프로그래밍은 단순히 코드의 순서대로 실행되는 순차적 프로그래밍 방식이었습니다. 반복문이 존재했지만 한계가 있었고 실행 순서를 강제로 변경하는 등의 방법으로 코드의 흐름을 제어하기 힘들었습니다. 이후 일정하게 반복되는 코드를 함수로 만들어 사용하는 절차적 프로그래밍 패러다임이 등장했습니다. 하지만 전역 변수를 사용했기 때문에 프로그램이 커질수록 변수명 관리에 어려움을 겪었습니다. 비슷한 변수명을 묶어서 관리하면서 서로 연관있는 데이터들을 모아두기 시작했고, 이는 해당 변수에 접근할 수 있는 구조체를 만들어냈습니다. 이 구조체를 바탕으로 데이터를 중심으로 코딩을 하게 되면 프로그래밍이 커져도 일관성을 유지하기 편하며, 함수까지 더해지면서 class가 탄생하게 되었습니다. 즉, 데이터와 처리방법을 분리해서 개발하던 절차식 프로그래밍과 달리, 데이터가 처리방식을 하나의 모듈로서 관리를 하게 되면서 작은 프로그래밍들이 독립적으로 돌아가는 형태가 되었고, 이를 조립하고 결합하는 방식의 개발 방법론이 등장하게되었습니다. 이과정에서 구조체와 함수를 합쳐서 선언하는 것을 class라고 부르기로 했고, class 내의 값과 동작을 실체로 보며 object로 칭했습니다.
결국, 프로그램을 객체로 바라보는 관점으로 프로그래밍하는 객체지향 프로그래밍Object-Oriented Programming(OOP)방법이 지금까지도 유용한 방법론으로 소개되고 있습니다.
객체를 독립적으로 분리하면서, 객체의 모든 데이터에 접근할 필요성이 사라졌고, 내부의 데이터는 내부에서 알아서 조작할 수 있도록 하고 외부에서는 필요한 내용만 만들어 두는 편이 안정성과 사용성 측면에서 효율적이었습니다. 따라서 외부로 노출해야 하는 값과 내부에서만 사용하는 값을 구분하는 기능을 추가하였습니다. 이것을 데이터를 보호해주는 캡슐을 통해 내부 데이터에 바로 접근하지 못하게 하고 필요한 메소드만 열어두는 캡슐화라고 부릅니다.
class Human {
#age = 10;
getAge() { // getter
return this.#age;
}
}
const person = new Human();
console.log(person.#age); // Error TS18013: Property '#age' is not accessible outside class 'Human' because it has a private identifier.
console.log(person.getAge()); // 10
재사용성을 위해 객체를 사용하는 과정에서, 여러 개의 변수와 여러 개의 함수가 섞여 있다보니 일부 로직은 같고, 일부는 달라져야하는 상황이 발생했습니다. 따라서 객체의 일부분만 재사용하는 방법이 필요해졌고, 상속을 통해 객체에서 공통된 부분만 따로 만들어서 그 코드를 같이 상속받아서 활용을 하고 나머지 달라지는 것들만 각자 코드를 작성하는 방식을 만들게 되었습니다.
상속을 통해 객체를 분리하고 연결하는 과정에서, 공통적인 부분을 모아서 상위의 개념으로 새롭게 이름붙이는 추상화가 필요해졌습니다. 예를들어, 강아지와 고양이 등의 클래스의 공통부분을 동물이라는 클래스로 만들어 상속할 수 있습니다.
let zergling1 = new Zergling()
let zergling2 = new Zergling()
let hydralisk1 = new Hydralisk()
let hydralisk2 = new Hydralisk()
let mutalisk = new Mutalisk()
let overload = new Overload()
let units = [zergling1, zergling2, hydralisk1, hydralisk2, mutalisk, overload]
// 모두 같은 이름의 moveTo 메소드를 호출하지만 각자의 방식대로 동작한다.
units.forEach(unit => unit.moveTo(300, 400))
상속과 추상화를 사용해서 추상화된 유닛이라는 타입이 하위 타입인 여러가지 타입으로 참조할 수 있는 다형성을 활용할 수도 있습니다. 예를들어, 같은 Unit의 moveTo 메소드를 사요하지만, 각자 정의된 방식이 있기 때문에 각자의 방식대로 동작하며 다형성을 통해 객체의 일부분만 재사용할 수 있습니다.
객체지향 프로그래밍은 프로그램이 커지면서 생기는 문제점을 해결하기 위해 나온 하나의 관점이자 방법론입니다. 기존의 방식에서 변수를 하나씩 관리하다 보니 변수명 관리에 어려움이 있었고, 이러한 문제를 해결하기 위해 구조체라는 타입을 만들어 데이터를 중심으로 관리를 하였습니다. 또한 추가로 데이터와 함수를 한데 묶어서 관리하는 관점이 생겨났고, 하나의 큰 프로그래밍을 작은 문제를 해결하는 독립적인 단위로 만들 수 있었습니다. 이렇게 작은 단위로 관리를 하게 되니 개발과 유지보수가 간편하게 된다는 장점이 있었으며 현재 널리 사용되고 있는 방법론입니다.
사실 자바스크립트의 탄생 시점에서는 객체지향 프로그래밍의 필요성이 널리 알려지지 않은 상태였기 때문에, 자바스크립트에 클래스는 없었고, 생성자 함수를 통해 클래스를 흉내내는 방식이었습니다. 그러다 ES6에 들어 클래스가 추가되었습니다.
프로그래밍의 독립적인 단위인 객체를 설계하고 찍어낼 수 있는 틀과 같은 구조를 클래스라고 하고, 클래스에 만들어진 인스턴스를 객체라고 하여 프로그래밍의 모든 것들을 객체로 간주하여 객체간의 상호작용을 중심으로 생각하고 설계하는 프로그래밍 개념이 바로 Object-Oriented Programming입니다.
생성자는 클래스로부터 객체를 생성할 때 호출되는 특별한 메서드입니다. 클래스 내부에서 constructor 키워드를 사용하여 정의하며, 객체의 초기 상태를 설정하거나 인스턴스 변수를 초기화하는 등의 작업을 수행하기 때문에 클래스 내에 단 하나만 존재할 수 있습니다.
메서드는 클래스에 속한 함수로, 객체가 수행할 수 있는 동작이나 기능을 정의합니다. 클래스 내에 일반적인 함수처럼 정의되며 인스턴스가 생성될 때마다 메서드는 해당 인스턴스에 대해 별도로 생성되지 않고 클래스의 프로토타입에 할당됩니다.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
}
}
// 객체 생성
const person1 = new Person('John', 30);
// 메서드 호출
person1.greet(); // Hello, my name is John and I am 30 years old.
대부분의 분들이 카드 클래스를 만들어 카드 인스턴스를 생성하고 모듈화하는 것을 첫 접근으로 잘 시작해주는 것을 확인할 수 있었습니다. 여기에 객체지향 프로그래밍 관점을 좀 더 추가해보겠습니다.
단, 당연히 설계자의 의도에 따라 코드는 달라지기 때문에, 정답 코드가 아님을 명시합니다.
객체지향프로그래밍의 관점에서 보면, 각각의 클래스는 독립되지만 서로 연결되어 하나의 프로그래밍을 구성하게 됩니다. 즉, 카드 요소를 생성해서 이벤트핸들러를 추가하는 작업은 물론, 카드 인스턴스를 배열에 보관하고, 이 게임을 관리하는 것까지 객체로 만들어서 서로 상호작용하여 객체지향적인 프로그래밍으로 만들 수 있을 것입니다.
이때, 각각의 클래스를 어떻게 분리하여 어떤 클래스가 어떤 데이터를 갖고 있고 어떤 동작을 수행할지에 대한 고민을 시작해야합니다. 즉 각각의 클래스에 하나의 책임을 부여하면 클래스 간의 의존성을 줄이고 독립적으로 변경 및 확장하며 유지보수가 쉬워집니다. 이것을 단일책임원칙이라고 부릅니다.
1단계에서는 게임을 시작하는 기능만 추가되겠지만 추후 확장을 고려하여 게임을 관리하는 클래스를 만들어 게임의 흐름을 제어하는 등의 게임의 주요 로직을 담당하는 클래스입니다.
전체 카드를 생성하고 관리하며, 카드를 섞는 등의 카드 관련 작업을 수행할 수 있으며, 게임을 관리하는 클래스에서 게임을 시작했을 때 카드를 섞는 메서드가 동작하도록 연결시킬 수 있습니다. 즉, 이 클래스는 전체 카드를 관리하는 작업에만 집중하고 게임의 진행과는 독립적으로 진행되어야합니다.
개별 카드의 속성을 나타내고, 카드의 상태를 추적하는 등 개별 카드에 특화된 작업을 수행하는 클래스입니다. 카드의 스타일 속성을 지정하거나, 이벤트 핸들러를 추가하는 등의 작업을 수행할 수 있습니다.
또한 추상화와 상속을 사용하여 카드 클래스를 더 구체화할 수 있습니다. WinningCard와 LossingCard로 분리하고, 추상화된 Card 클래스를 만들어 공통된 카드 속성을 추가한 후 WinningCard와 LossingCard에 서로 다른 역할을 하는 메서드를 오버라이딩할 수 있습니다.
class AbstractCard {
constructor(isWinningCard) {
this.node = this.createCardElement();
this.isWinningCard = isWinningCard;
this.handleCardClick();
}
createCardElement() {
const button = document.createElement("button");
button.style.height = "200px";
button.style.width = "100px";
button.innerText = "두근두근";
return button;
}
handleCardClick() {
this.node.addEventListener("click", () => {
this.selectCard();
});
}
// 추상 메서드로 하위 클래스에서 구현하여 오버라이딩되도록 함
selectCard() {
throw new Error('Abstract method. Subclasses should implement this.');
}
}
class WinningCard extends AbstractCard {
selectCard() {
const contents = document.querySelector("#contents");
contents.innerText = "당첨입니다 :D";
}
}
class LosingCard extends AbstractCard {
selectCard() {
const contents = document.querySelector("#contents");
contents.innerText = "꽝입니다!";
}
}
카드를 보관하는 클래스 내에서 카드 인스턴스를 생성하여 카드 인스턴스들을 관리하는 방식은 클래스 간의 의존성이 높아 결합도가 높습니다. 카드를 보관하는 클래스가 카드 클래스에 의존하고 있다고 표현하는데, 이를 외부에서 주입받도록 구현하여 클래스 간의 결합도를 낮추고, 코드의 유연성과 재사용성을 높일 수 있습니다.
class GameManager {
constructor(cardsManager) {
this.cardsManager = cardsManager;
}
startGame() {
this.cardsManager.shuffleCards();
// 게임 시작 로직
}
}
class CardsManager {
constructor() {
this.cards = [];
this.createCards();
}
createCards() {
// 전체 카드 생성 및 초기화...
}
shuffleCards() {
// 카드 섞기 로직...
}
// 다른 카드 관련 메서드들...
}
class Card {
// 카드의 속성과 메서드...
}
const cardsManager = new CardsManager();
const gameManager = new GameManager(cardsManager);
gameManager.startGame();