책 소개
객체지향의 사실과 오해, 우테코 프리코스를 진행하며 객체지향에 대해 공부할 필요가 있었는데 어렴풋이 알고 있떤 객체지향의 개념을 구체적으로 알게해준 책이었다. 이제는 우테코를 진행하며 객체지향에 대해 공부할 필요가 있어 다시 꺼내게되었다. 이번 기회에 책 내용을 정리해보며 한 번 더 상기해보고자 한다.
이 책은 ‘이상한 나라의 엘리스’라는 현실에 빗대어 객체지향을 설명한다. 찰떡같은 비유덕분에 추상적인 내용을 구체적으로 이해할 수 있게 해준다.
객체지향
객체지향의 핵심은 적절한 책임을 수행하는 역할 간의 유연하고 견고한 협력 관계를 구축하는 것이다.
클래스는 협력에 참여하는 객체를 만드는데 필요한 구현 매커니즘일 뿐이다.
이 책의 핵심을 관통하는 문장이다. 객체지향은 클래스가 아니고, 클래스는 객체지향을 구현하기 위한 도구일 뿐이다. 객체지향은 각 책임과 역할을 수행하는 객체가 서로 견고하게 협력을 하도록 구축하는 것이다.
객체 (클래스)
객체란 식별 가능한 개체 또는 사물이다. 객체는 자동차처럼 만질 수 있는 구체적인 사물일 수도 있고, 시간처럼 추상적인 개념일 수도 있다. 객체는 구별 가능한 식별자, 특징적인 행동, 변경 가능한 상태를 가진다.
이 글만 읽었을 때는 다소 추상적이라 느껴질 수 있다. 저자는 이에 대해 ‘이상한 나라의 엘리스’에 빗대어 설명하며 엘리스와 음료라는 객체가 등장한다.
상태 (프로퍼티)
상태는 특정 시점에 객체가 가지고 있는 정보의 집합으로 객체의 구조적 특징을 표현한다. 객체의 상태는 객체에 존재하는 정적인 프로퍼티와 동적인 프로퍼티 값으로 구성된다.
엘리스라는 객체에는 키와 위치라는 프로퍼티, 음료라는 객체에는 양이라는 상태가 존재한다. 그리고 이 둘의 상태는 시간의 흐름에 따라 변화한다. 프로퍼티인 키, 위치, 양은 고정되기 때문에 정적이고 프로퍼티 값인 130cm, “통로”, 0.5L는 시간이 흐름에 따라 변경되기 때문에 동적이라 할 수 있다.
행동 (메서드)
행동이란 외부의 요청 또는 수신된 메시지에 응답하기 위해 동작하고 반응하는 활동이다. 행동의 결과로 객체는 자신의 상태를 변경하거나 다른 객체에게 메시지를 전달할 수 있다. 객체는 행동을 통해 다른 객체와의 협력에 참여하므로 행동은 외부에 가시적이어야한다.
행동은 곧 객체가 외부와 상호작용하기 위한 방법이다. 외부에서 객체에 메시지를 보내면 객체는 행동을 한다. 그리고 행동의 결과로 자신의 상태를 변경하거나, 다른 객체에게 메세지를 전달할 수 있다. 여기서 행동, 메시지는 곧 클래스에서의 메서드를 의미한다.
미리 말해두자면, 아래 설명은 '키'라는 상태부터 꺼내지만 이건 어디까지나 설명의 편의다. 코드는 불가피하게 상태를 적으며 시작하지만, 설계의 순서는 반대여야 한다. '문을 통과한다'는 행동을 먼저 정하고, 그 행동에 필요하니까 '키'라는 상태가 따라오는 거다.
엘리스의 이야기로 돌아가보면 다음과 같은 두 가지 행동이 있을 수 있다.
- 엘리스의 키가 40cm 이하라면 문을 통과할 수 있다.
엘리스의 키는 ‘엘리스’라는 객체의 ‘키’라는 상태이다. 그리고 이 행동은 상태의 조건에 따라 ‘엘리스’라는 객체가 통과할 수 있게 된다.
- 문을 통과한 후 엘리스의 위치는 아름다운 정원으로 바뀌어야 한다.
엘리스의 위치는 ‘엘리스’라는 객체의 ‘위치’라는 상태이다. 그리고 이 행동은 엘리스의 ‘위치’라는 상태를 아름다운 정원으로 바꾼다.
위와 같은 행동을 객체의 행동으로 미리 등록을 해놓는다. 그리고 외부에서 메시지를 전송하면 위 행동을 취해 객체의 상태를 변경하는 것이다. 이것이 객체지향에서 핵심인 객체들간의 ‘협력’이다.
협력
객체는 서로 협력한다. 메시지로 요청하고, 메서드로 응답한다. 메시지를 요청하는 객체는 어떻게 수행하는지는 모르고, 무엇을 수행하는지만 알고 있다.
위 ‘행동’에서 이야기한 것과 같은 맥락이다. 객체는 서로 협력하며 프로그램의 역할을 수행한다. 이 때 메서드를 요청하는 객체는 어떻게 수행하는지를 모르고, 무엇을 수행하는지만 알고 있다. 다시 말해 메서드는 객체의 행동을 추상화한 것이다.
행동이 상태를 결정한다.
객체의 상태는 행동을 통해서만 접근할 수 있고 변경할 수 있다. 우리는 상태가 아니라 행동에 초점을 맞춰 객체를 만들어야한다.
중심은 행동이다. 행동이 먼저 결정이 되고 그에 따라 필요한 상태를 결정해야한다. 상태에 초점을 맞출 경우 상태가 객체 내부로 깔끔하게 캡슐화되지 못하고 공용 인터페이스에 그대로 노출되버릴 확률이 높아진다.
또한 객체를 협력자가 아닌 고립된 섬으로 만든다. 객체가 필요한 이유는 애플리케이션의 문맥 내에서 다른 객체와 협력하기 위해서이다. 불행하게도 상태를 먼저 고려하는 방식은 협력이라는 문맥에서 멀리 벗어난 채 객체를 설계하게 함으로써 자연스럽게 협력에 적합하지 못한 객체를 창조하게 된다.
우리는 객체를 구현하기에 앞서 어떤 행위를 할 것인가를 먼저 결정해야한다. 즉 응집도 있는 행위의 집합인 책임을 먼저 결정해야한다. 이러한 방식을 책임 주도 설계라고 하며, 이를 위한 베스트 프랙티스를 모아놓은 디자인패턴, TDD를 사용하는 것도 좋다. TDD는 테스트가 핵심이 아니라, 설계가 핵심인 방법이다.
역할과 책임
각 객체에는 책임이 따르며, 책임은 객체에 의해 정의되는 응집도 높은 행위의 집합이다.
각 객체에는 역할이 있으며, 역할에는 하나의 협력 안에 여러 종류의 객체가 대체될 수 있음으로써 협력을 추상화할 수 있다.
객체의 책임은 객체가 알아야하는 정보와 객체가 수행할 수 있는 행위에 대해 개략적으로 서술한 문장이다. 즉 객체의 책임은 ‘객체가 무엇을 알고 있는가’와 ‘무엇을 할 수 있는가’로 구성된다. 이러한 책임은 객체가 협력에 참여하기 위해 수행해야 하는 행동을 의미한다.
그리고 역할은 이러한 책임의 집합을 의미하며, 동일한 책임을 수행할 수 있는 객체들은 동일한 역할을 수행할 수 있다. 이는 객체지향 설계에서 다형성을 가능하게 하는 기반이 된다.
나만의 객체지향 설계 방법
이 책을 읽으며, 과제에 실제로 객체지향을 도입해보며 만든 나만의 객체지향 설계 방법이다.
- 도메인 모델을 그린다.
- 현실 세계의 도메인을 모델간의 관계로 그린다
- 도메인 모델에서 각 모델이 서로에게 어떤 요청을 하는지 메시지를 선택한다.
- 메시지를 먼저 선택하고, 그 메시지를 수행할 객체를 선택한다. 이로써 자율적인 객체를 만들 수 있다.
- 각 모델을 클래스로, 요청을 공용 인터페이스로 구현한다.
- 공용 인터페이스는 외부에 노출시키고, 코드의 안정적인 측면을 드러내야 한다.
- 각 클래스 내부에서 메서드와 프로퍼티를 구현한다.
- 내부 메서드와 프로퍼티는 캡슐화하여 철저하게 외부에 드러나지 않도록 해야한다.
좋은 객체지향 설계를 하는 방법
- 객체는 자율적이다.
자율적인 책임은 협력을 단순하게 만들고, 책임을 수행하는 내부적인 방법을 변경하더라도 외부에 영향을 미치지 않는다. 책임이 자율적일수록 적절하게 추상화되며, 응집도가 높아지고, 결합도가 낮아지며, 캡슐화가 증진되고, 인터페이스와 구현이 명확히 분리되며, 설계의 유연성과 재사용성이 향상된다.
- 객체는 서로 협력한다.
객체들은 서로 메시지를 주고받으며 협력한다. 이때 메시지는 '무엇'을 하는지만 정의하고, '어떻게' 수행할지는 메시지를 수신하는 객체가 결정한다. 이러한 협력을 통해 객체들은 시스템의 행위를 구현하게 된다.
위 객체지향을 코드로 옮긴다면?
// 객체와 상태, 행동의 예
class Alice {
#height; // private field (캡슐화)
#position;
constructor(height, position) {
this.#height = height;
this.#position = position;
}
// 행동: 문을 통과할 수 있는지 확인
canPassThroughDoor() {
return this.#height <= 40;
}
// 행동: 위치 변경
moveTo(newPosition) {
this.#position = newPosition;
return this;
}
// 객체의 상태에 접근하는 메서드
getPosition() {
return this.#position;
}
}
위와 같이 키와 위치라는 상태(프로퍼티)를 가진 엘리스 객체가 있다. 키에 따라 문을 통과할 수 있는지 확인하는 메시지(메서드), 위치를 변경하는 메시지, 객체의 상태에 접근하는 메시지가 있다. 이러한 메시지를 객체에게 요청함으로써 객체가 자율적으로 행동하도록 할 수 있는 것이다.
// 객체 간 협력의 예
class Door {
#location;
constructor(location) {
this.#location = location;
}
// 객체 간 협력: Alice 객체와 협력
letPass(person) {
if (person.canPassThroughDoor()) {
return person.moveTo(this.#location);
}
return person;
}
}
// 사용 예시
const alice = new Alice(35, "hallway");
const magicDoor = new Door("beautiful garden");
// 객체 간 메시지 전송을 통한 협력
magicDoor.letPass(alice);
console.log(alice.getPosition()); // "beautiful garden"
이번에는 Door라는 객체가 추가되었다. 이제는 Door객체와 Alice객체가 서로 협력하며 상호작용한다. Door 객체는 Alice가 문을 통과할 수 있는지 검사하고, 가능하다면 Alice의 위치를 변경해준다. 이처럼 객체들은 서로 메시지를 주고받으며 자신의 역할을 수행하고, 그 과정에서 시스템의 기능을 구현하게 된다. 객체지향 설계의 핵심은 바로 이러한 객체 간의 협력 관계를 잘 설계하는 것이다.
여기에 역할이 왜 중요한지 코드로 확인해보자. Rabbit이라는 새 객체를 추가한다.
class Rabbit {
#height;
#position;
constructor(height, position) {
this.#height = height;
this.#position = position;
}
canPassThroughDoor() {
return this.#height <= 20;
}
moveTo(newPosition) {
this.#position = newPosition;
return this;
}
getPosition() {
return this.#position;
}
}
const rabbit = new Rabbit(15, "hallway");
magicDoor.letPass(rabbit);
letPass는 상대가 Alice인지 Rabbit인지 전혀 모른다. canPassThroughDoor()와 moveTo() 메시지를 이해하는 객체이기만 하면 된다. 둘은 '문을 통과하는 자'라는 같은 역할을 수행하고, Door는 그 역할하고만 협력한다. 새 객체가 와도 이 역할만 하면 Door는 고칠 필요가 없다. 이것이 다형성이고, 역할이 협력을 유연하게 만든다는 의미다.
맺으며
객체지향은 현실을 그대로 베끼는 게 아니라 새 세계를 만드는 일에 가깝다. 이 책을 읽으며 배운 대로, 나는 도메인 모델로 현실을 추상화하고 메시지를 먼저 정한 뒤 그 메시지를 수행할 객체의 책임과 역할을 정의하는 순서로 설계를 고민했다. 각 객체가 자신의 상태를 스스로 관리하면서 다른 객체와 유연하게 협력하도록 만들면 변경에 강하고 재사용하기 좋은 구조가 된다.
앞으로 우테코를 진행하며 이 책에서 배운 내용들을 실제 코드에 적용해보고, 더 나은 객체지향 설계를 위해 계속해서 고민하고 발전시켜 나가야겠다.