“객체지향의 사실과 오해” 책을 바탕으로 정리를 진행했습니다. 제가 이해한 바를 정리하고 싶어서 적은 포스팅이라 책에 있는 내용과는 맞지 않거나 오류가 있을 수 있습니다. 더 많은 내용을 보고 싶으신 분들은 책을 직접 읽으시는걸 추천드립니다.
객체 지도
기능이 아니라 구조를 기반으로 모델을 구축하는 것이 더 범용적이고 이해하기 쉽고 변경에 안정적입니다. 기능을 중심으로 구조를 종속시키면 범용적이지 않고 재사용 불가능하며 변경에 취약한 모델을 만들게 되죠.
즉, 객체지향은 자주 변경되는 기능이 아니라 안정적인 구조 기반으로 시스템을 구조화해야 한다는 겁니다.
기능은 “제품이 사용자를 위해 무엇을 할 수 있는가”에 초점을 맞춥니다. 구조는 “제품의 형태가 어떠해야 하는가”에 초점을 맞춥니다. 이 두 측면을 함께 녹여 조화를 이루도록 만들어 주어야 합니다.
성공적인 소프트웨어는 훌륭한 기능을 제공하는 동시에 사용자가 원하는 새로운 기능을 빠르고 안정적으로 추가 가능해야 합니다.
소프트웨어는 요구사항이 항상 변경됩니다. 훌륭한 설계자는 사용자가 만족할 수 있는 훌륭한 기능을 제공하는 동시에 예측 불가능한 요구사항 변경에 유연하게 대처할 수 있는 안정적인 구조를 제공해야 주어야 합니다.
하지만, 설계는 쉽지 않습니다.
어제 약속했던 기능을 제공하는 동시에 내일 변경될지도 모르는 요구사항도 수용할 수 있는 코드를 짜야하기 때문이죠. 그럼, 변경을 예측하지 말고 변경을 수용할 수 있는 선택의 여지를 설계에 마련해둡시다.
설계의 목적은 나중에 설계하는 것을 허용하는 것이며, 변경에 소요되는 비용을 낮추는 겁니다.
그러기 위해서는 안정적인 구조를 중심으로 설계해야 합니다. 객체 구조를 바탕으로 시스템 기능을 객체 간의 책임으로 분배하는거죠. 기능이 객체의 구조를 따르게 만드는 겁니다.
구조는 사용자나 이해 관계자들이 도메인에 관해 생각하는 개념과 개념들 간의 관계로 표현해야 합니다.
도메인은 사용자가 프로그램을 사용하는 대상 분야입니다. 도메인에 대상을 단순화해서 표현한다는 뜻을 가진 모델이라는 단어를 붙이면 “도메인 모델”이 됩니다.
도메인 모델은 사용자가 프로그램을 사용하는 대상 영역에 관한 지식을 선택적으로 단순화하고 의식적으로 구조화한 형태를 말합니다. 도메인 모델은 단순한 다이어그램이 아닌 멘탈 모델입니다.
멘탈 모델이란, 사람들이 다른 사람, 환경, 자기 자신이 상호 작용하는 사물들에 대해 갖는 모형입니다. 사람들은 자신의 멘탈 모델과 유사한 방식으로 제품이 반응하고 움직일거라고 기대하기 때문에 훌륭한 디자인이란 사용자가 예상하는 방식에 따라 정확하게 반응하는 제품을 만드는 것이 됩니다.
멘탈 모델은 3가지로 구분됩니다.
사용자 모델, 시스템 이미지, 디자인 모델입니다.
사용자 모델은 사용자가 제품에 대해 가지고 있는 개념들의 모습입니다. 디자인 모델은 설계자가 마음속에 갖고 있는 시스템에 대한 개념화입니다. 시스템 이미지는 최종 제품을 뜻합니다.
설계자는 디자인 모델 기반으로 만든 시스템 이미지가 사용자 모델을 정확하게 반영하도록 노력해야 합니다.
도메인 모델은 도메인에 대한 사용자 모델, 디자인 모델, 시스템 이미지를 포괄하도록 추상화한 소프트웨어 모델입니다. 즉, 소프트웨어에 대한 멘탈 모델이며, 사용자들이 도메인을 바라보는 관점을 나타냅니다.
최종 제품은 사용자의 관점을 반영해야 합니다.
개발자의 관점에서는 최종 코드가 사용자가 도메인을 바라보는 관점을 반영해야 한다는 걸 의미합니다.
애플리케이션은 도메인 모델 기반으로 설계되어야 합니다.
객체지향을 이용하면 도메인에 대한 사용자 모델, 디자인 모델, 시스템 이미지 모두 유사한 모습을 유지하도록 만드는 것이 가능해집니다.
우리가 객체를 창조할 때에 도메인 모델을 기반으로 설계하고 구현하는 것은 사용자가 도메인을 바라보는 관점을 그대로 코드에 반영할 수 있게 합니다. 이는 코드 안에 존재하는 미로를 헤쳐나갈 수 있는 지도를 제공해줍니다.
도메인 모델이 제공하는 구조는 상대적으로 안정적입니다.
우리는 사용자가 도메인을 바라보는 관점을 반영해서 소프트웨어를 설계하고 구현하면 됩니다.
왜 사용자의 관점을 반영하면 좋은 걸까요?
사용자는 도메인을 구성하는 중요한 개념, 개념간의 관계를 가장 잘 알고 있는 사람입니다. 즉, 도메인의 본질적인 측면을 가장 잘 이해하고 있습니다.
‘본질적인’이라는 단어에서 알 수 있듯이 도메인은 변경이 적고 그 특징이 오래 유지됩니다.
사용자 모델에 포함된 개념, 규칙은 비교적 변경될 확률이 적기 때문에 사용자 모델 기반으로 설계와 코드를 작성하면 변경에 쉽게 대처할 수 있는 가능성이 커집니다.
예를 들어서, 은행 서비스를 만든다고 합시다. 우리는 정기 예금 기능을 만들겁니다.
정기 예금의 정의가 변경되지 않는 한 정기 예금에 대한 로직은 쉽게 바뀌지 않습니다. 중요한 비즈니스 규칙과 정책을 반영하고 있기 때문입니다. 비즈니스 규칙이 크게 변경되지 않는 한 안정적으로 유지됩니다.
따라서, 안정적인 구조 기반으로 자주 변경되는 기능을 배치함으로써 기능의 변경에 대해 안정적인 소프트웨어를 구현할 수 있게 됩니다. 도메인 모델을 기반으로 소프트웨어를 설계하면 변경에 유연하게 대응할 수 있는 탄력적인 소프트웨어를 만들 수 있습니다.
그렇다면, 이젠 사용자에게 제공할 기능을 기술할 정보가 필요합니다.
기능에 대한 건 “유스케이스 기법”을 사용하면 될 것 같네요.
유스케이스는 사용자와 시스템 간에 이뤄지는 상호작용의 흐름을 텍스트로 정리한 것입니다. 유스케이스를 사용하면 사용자들의 목표를 중심으로 시스템의 기능적인 요구사항들을 이야기 형식으로 묶을 수 있습니다.
유스케이스는 사용자 목표와 관련된 모든 시나리오의 집합이며, 단순한 피처 목록이 아닙니다.
피처를 유스케이스로 묶고 사용자와의 상호작용 흐름 속에서 피처를 포함한 이야기를 제공함으로써 시스템 기능에 대한 의사소통할 수 있는 문맥을 얻을 수 있습니다. 즉, 단순한 기능 나열이 아닌 이야기를 통해서 연관 기능을 함께 묶을 수 있다는 겁니다.
하지만, 유스케이스에서는 사용자 인터페이스와 관련된 세부 정보를 포함하면 안됩니다.
사용자 인터페이스 요소를 배제하고 사용자 관점에서 시스템의 행위에 초점을 맞춰야 합니다. 이런 유스케이스를 “본질적인 유스케이스”라고 합니다.
사용자 인터페이스와 관련된 세부 정보를 배제해야 한다는건 내부 설계와 관련된 정보도 포함하지 않아야 한다는걸 뜻합니다.
연관된 시스템의 기능을 이야기 형식으로 모으는 것이 유스케이스의 목적입니다. 단지, 사용자가 시스템을 통해 무엇을 얻을 수 있고 어떻게 상호작용할 수 있느냐에 대한 정보만 기술되면 됩니다.
무엇을 얻을 수 있고 어떻게 상호작용할 수 있는가에 대한 정보는 시스템 외부에 제공해야 하는 행위만을 포함해야 한다는 겁니다. 내부 구조를 유추할 수 있는 방법이 존재하면 안됩니다. 즉, 객체 구조, 책임에 대한 어떤 정보도 제공하면 안됩니다.
변경에 유연한 소프트웨어를 만들기 위해서는 유스케이스에 정리된 시스템 기능을 도메인 모델을 기반으로 한 객체들의 책임으로 분배해야 합니다.
책임-주도 설계에서는 시스템의 기능을 역할, 책임을 수행하는 객체들의 협력 관계로 바라보게 함으로써 유스케이스와 도메인 모델을 통합합니다.
시스템을 사용자와 협력하는 커다란 객체로 생각하고, 시스템의 기능을 시스템의 책임으로 바꿈으로써 이를 협력의 출발로 보는 겁니다.
이렇게 하면, 유스케이스가 안정적인 구조에 책임을 분배할 수 있는 출발점을 제공합니다. 도메인 모델은 기능을 수용하기 위해 은유할 수 있는 안정적인 구조를 제공하게 됩니다.
견고한 객체지향 어플리케이션 개발을 위해서는 사용자 관점에서 시스템 기능을 명시하고 사용자와 설계자가 공유하는 안정적인 구조를 기반으로 기능을 책임으로 변환하는 체계적인 절차를 따라야 합니다.
객체지향이 강력한 이유는 코드의 변경으로부터 도메인 모델의 변경 사항을 유추 가능하다는 겁니다.
즉, 도메인 모델이 코드와 분리된 별도의 산출물이 아니라는거죠.
안정적인 도메인 모델을 기반으로 시스템의 기능을 구현하세요!
함께 모으기
객체지향 설계 안에 존재하는 3가지 상호 연관된 관점이 있습니다.
1.
개념 관점
도메인 안에 존재하는 개념과 개념들 사이의 관계를 나타냅니다. 즉, 사용자가 도메인을 바라보는 관점입니다.
개념 관점은 실제 도메인의 규칙과 제약을 최대한 유사하게 반영하고 있습니다.
2.
명세 관점
도메인을 벗어나 개발자의 영역인 소프트웨어로 초점을 옮겼습니다. 명세 관점에서는 객체의 인터페이스를 바라봅니다. 객체가 협력을 위해서 무엇을 할 수 있는가에 초점을 맞춥니다.
3.
구현 관점
실제 작업을 수행하는 코드와 연관있습니다. 책임을 수행하는데 필요한 동작을 하는 코드를 작성합니다.
클래스가 은유하는 개념은 개념(도메인) 관점을 반영합니다.
클래스의 공용 인터페이스는 명세 관점을 반영합니다.
클래스의 속성과 메서드는 구현 관점을 반영합니다.
클래스는 3가지 관점을 모두 수용할 수 있도록 개념, 인터페이스, 구현을 함께 드러내야 합니다.
3가지 관점을 모두 드러내면서도 코드 안에서 3가지 관점을 쉽게 식별할 수 있도록 깔끔하게 분리해야 합니다.
커피 협력 예시
1장에서 커피 협력에 대한 예시를 들었을 겁니다. 손님, 캐시어, 바리스타 간의 협력과 그들의 역할과 책임에 대해서 얘기했을 겁니다. 이를 개념, 명세, 구현 관점에서 봅시다.
먼저, 각각의 관점에서 보기 전에 그들이 어떤 협력 관계를 가지고 있고, 어떤 역할과 책임을 가지고 있었는지 얘기해봅시다. 그리고 손님, 캐시어, 바리스타의 협력을 잘 나타내기 위해서 몇 가지 객체들을 더 추가해볼게요.
바로, “메뉴판”과 “커피”입니다.
그러면, 역할들 사이의 관계가 이렇게 나타날 수 있겠네요.
객체세계에 있는 “메뉴판”과 “커피”는 능동적으로 행동합니다. 따라서, 손님과 바리스타가 요청을 보내면 스스로 요청에 대한 책임을 다한 후에 적절한 응답을 해줄 수 있습니다.
위의 협력을 개념 관점에서 나타내봅시다. 사용자가 도메인을 바라보는 관점에서 말이죠.
그러면, 도메인 모델로 협력을 나타낼 수 있겠네요.
도메인 모델로 나타내면 이런 모습일 겁니다.
왜 도메인 개념을 참조하냐구요?
소프트웨어는 항상 변합니다. 하지만, 도메인은 쉽게 바뀌지 않습니다. 따라서, 도메인을 따르면 변환에 쉽게 대응할 수 있습니다. 설계는 변경을 위해서 존재합니다.
도메인으로 나타냈으니 협력을 찾아봅시다.
위에서 나타낸 협력은 이런 모습이겠네요.
이제 객체들이 어떤 역할이고, 어떤 책임을 가졌는지 눈에 보이는 군요.
그렇다면, 공통 인터페이스로 정리할 수 있겠네요. 명세 관점으로 넘어갑시다.
저는 Swift를 사용해서 Protocol로 공통 인터페이스를 나타냈습니다.
struct MenuItem { }
class Coffee {
init(menuItem: MenuItem) { }
}
protocol MenuProtocol {
func choose(name: String) -> MenuItem?
}
protocol CustomerProtocol {
func order(name: String)
}
protocol CashierProtocol {
func takeOrder(menuItem: MenuItem) -> Coffee
}
protocol BaristaProtocol {
func makeCoffee(menuItem: MenuItem) -> Coffee
}
Swift
복사
각자의 책임을 이렇게 나타낼 수 있겠네요. 프로토콜에 요구 사항으로 넣은 함수는 다른 객체와의 소통에 쓰이기 때문에 public 한 함수일 겁니다.
공통 인터페이스를 나타냈으니 하나씩 구현해봅시다. 구현 관점으로 넘어갑시다.
일단, Protocol를 준수하는 클래스들을 만들어 주겠습니다.
class Menu: MenuProtocol {
func choose(name: String) -> MenuItem? {
}
}
class Customer: CustomerProtocol {
func order(name: String) {
}
}
class Cashier: CashierProtocol {
func takeOrder(menuItem: MenuItem) -> Coffee {
}
}
class Barista: BaristaProtocol {
func makeCoffee(menuItem: MenuItem) -> Coffee {
}
}
Swift
복사
이제 하나씩 코드를 채워가봅시다. 먼저, Customer 부터 코드를 넣어볼게요.
Customer는 Menu에서 메뉴 항목(MenuItem)를 받아서 Cashier에게 메뉴 항목을 보내고 커피를 받습니다. Customer가 메뉴판에 없는 이름을 보냈을 시에는 주문이 더이상 진행되지 않습니다. Menu도 nil를 반환할 겁니다. 그럴때는 더이상 주문이 진행되지 않게 guard을 사용해줬어요.
class Customer: CustomerProtocol {
func order(name: String) {
guard let menuItem: MenuItem = Menu().choose(name: name) else { return }
let coffee: Coffee = Cashier().takeOrder(menuItem: menuItem)
}
}
Swift
복사
위에 설명한 말을 이렇게 적을 수 있겠군요.
그 다음엔 Menu 안을 채워봅시다.
Menu는 private하게 MenuItem이라는 상태를 배열 형태로 가지고 있습니다. Customer로부터 메뉴 이름(name)을 받아서 메뉴 이름과 부합하는 항목이 있다면 해당 메뉴 항목(MenuItem)을 반환해줍니다. 메뉴판에 없는 이름을 보내면 nil를 반환해주면 됩니다.
class Menu: MenuProtocol {
private var menuItems: Array<MenuItem> = []
init() {
self.menuItems = [
MenuItem(name: "아메리카노", price: 2500),
MenuItem(name: "카푸치노", price: 4000),
MenuItem(name: "카페라떼", price: 4000),
MenuItem(name: "연유라떼", price: 4500)
]
}
func choose(name: String) -> MenuItem? {
for menuItem in menuItems {
if menuItem.name == name {
return menuItem
}
}
return nil
}
}
Swift
복사
이번엔 Cashier 안을 채워봅시다.
Cashier는 Customer에게 메뉴 항목으로 주문을 받습니다. 주문을 받은 Cashier는 해당 주문을 Barista에게 넘기고 Barista에게 Coffee를 받아서 Customer에게 넘겨줍니다.
class Cashier: CashierProtocol {
func takeOrder(menuItem: MenuItem) -> Coffee {
return Barista().makeCoffee(menuItem: menuItem)
}
}
Swift
복사
Cashier에게 커피 생성을 요청받은 Barista도 채워봅시다.
Barista는 커피 생성을 요청받고 Coffee를 제조합니다. 그리고 제조한 Coffee를 반환합니다.
class Barista: BaristaProtocol {
func makeCoffee(menuItem: MenuItem) -> Coffee {
return Coffee(menuItem: menuItem)
}
}
Swift
복사
마지막으로 Coffee 코드를 작성해봅시다.
Coffee는 그저 자신을 생성하면 됩니다. 그리고 들어온 가격과 이름을 저장합니다. 가격과 이름은 상태이기 때문에 객체 외부에서는 볼 수 없어야 합니다. 그리고 상태에 접근할 수 있는 메서드가 필요합니다.
class Coffee {
private let name: String
private let price: Int
init(menuItem: MenuItem) {
self.name = menuItem.name
self.price = menuItem.price
}
func getName() -> String {
return self.name
}
func getPrice() -> Int {
return self.price
}
}
Swift
복사
우리는 도메인 관점에서 커피 협력을 보고, 명세 관점에서 공용 인터페이스를 만들고, 구현 관점에서 코드로 작성했습니다. 인터페이스와 구현을 분리하면서 클래스의 안정적인 측면은 밖으로 드러나고, 클래스의 불안정한 측면인 구현 부분은 숨겨졌습니다.
클래스를 봤을 때, ⓵명세, 구현 관점을 나눠볼 수 있어야 하고, ⓶캡슐화를 통해 구현을 인터페이스 밖으로 노출시키지 않아야 합니다. ⓷인터페이스와 구현을 명확하게 분리하지 않고 흐릿하게 섞어 놓아서도 안됩니다.