이 글은 우아한기술블로그에서 읽은 생각하라, 객체지향처럼을 읽고 작성한 카피캣 글입니다.
https://techblog.woowahan.com/2502/
객체지향.. 객체지향... OOP... 객체지향 언어의 대표인 Java를 사용하고 있지만 다시 공부하고 있는 지금 객체지향의 개념에 대해서 제대로 숙지하지 못하고 있는거 같습니다. 그래서 쉽게 정리된 글을 읽고 저도 작성해보았습니다.
객체 지향 프로그래밍이란? 객체 지향 프로그래밍(영어: Object-Oriented Programming, OOP)은 컴퓨터 프로그래밍의 패러다임 중 하나이다. 객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 객체들의 모임으로 파악하고자 하는 것이다. 각각의 객체는 메시지를 주고받고, 데이터를 처리할 수 있다.
커피 전문점을 도메인으로 삼고 설명하겠습니다.
도메인이란? 사용자들이 관심을 가지고 있는 특정 분야나 주제를 말하며 소프트웨어는 도메인에 존재하는 문제를 해결하기 위해 개발된다.
다음 예제의 목적은 커피 전문점에서 커피를 주문하는 과정들의 협력 관계로 구현하는 것입니다.
예제 설명 - 커피 제조하기
이번에 다룰 도메인은 동네 어디서나 볼 수 있는 아담한 커피 전문점이다. 커피 전문점에서는 아메리카노, 카푸치노, 카페라떼, 에스프레소의 네 가지 커피를 판매하고 있다. 판매하는 커피도 몇 종류 되지 않고 내부도 그리 넓지 않지만 하늘색 페인트로 칠해진 벽과 코를 간지럽히는 고소한 커피 향이 어우러져 전체적으로 아늑하고 편안한 분위기를 풍기고 있었다. 조촐한 가게 내부를 둘러보니 하얀 색의 테이블들이 커피 향으로 가득찬 공간을 채우듯 옹기종기 놓여져 있었으며 그 위에는 커피의 이름과 가격이 적힌 작은 메뉴판이 비치돼 있었다. 손님이 테이블에 앉아 메뉴판을 잠시 훑어본 후 커피를 주문한다. 이제 주문받은 커피를 제조하는 것은 바리스타의 몫이다. 커피 전문점은 작고, 단순하고, 고려해야 할 사항도 거의 없는 아주 간단한 도메인이다. 우리의 최종 목표는 손님이 커피를 주문하는 사건을 컴퓨터 안에 재구성하는 것이다. 물론 객체를 이용해서 말이다.
객체지향의 패러타임에서 가장 중요한 것은 당연히 객체입니다. 그렇게 때문에 먼저, 커피 전문점을 구성하는 요소들(손님, 바리스타 등)을 객체들로 보고 커피 전문점은 그 객체들로 이루어진 작은 세상으로 생각해보겠습니다. 손님이 커피를 주문하는 이 예제를 한 줄로 요약해보면 다음과 같습니다.
➡️ 손님이 메뉴판에서 4가지 메뉴 항목들 중 하나를 선택 후 바리스타에게 선택한 메뉴(커피)를 주문하고, 바리스타는 커피를 제조해서 손님에게 건네줍니다.
여기서 손님, 메뉴판, 메뉴 항목들(4가지), 바리스타, 커피(4가지)가 각각 하나의 객체가 될 수 있습니다.
다시 순서대로 보면
1. 손님 객체가 주문을 위해서는 메뉴 객체가 필요할 것입니다.
2. 메뉴 객체들이 들어있는 메뉴판 객체가 필요합니다.
3. 바리스타 객체가 손님 객체의 주문에 따라 만들어서 제공해야 할 커피 객체 들이 필요할 것입니다.
즉, 객체지향 관점에서 커피 전문점이라는 도메인은 손님 객체, 메뉴 항목 객체, 메뉴판 객체, 바리스타 객체, 커피 객체로 구성된 작은 세상인 것입니다. 커피를 주문하는 과정을 객체지향의 관점에서 다시 설명해본다면, 아래처럼 말할 수 있을 것 같습니다.
이와 같이 객체지향의 관점에서 객체들로 이루어진 커피 전문점 작은 세상을 그림으로 정리하면 아래와 같이 표현됩니다. 그림으로 보니까 이해가 좀 되는거 같습니다.
[a.객체들로 구성된 커피 전문점 세상]
다음으로는 각 객체들 사이의 관계에 대해서 살펴보겠습니다. 사실 그림 [a]에서도 각 객체들 사이의 관계를 쉽게 알 수 있습니다.
손님은 메뉴판에서 커피를 선택할 수 있습니다. 이런 관계가 있는 것이죠. 손님과 바리스타, 바리스타와 커피도 마찬가지입니다. 멘뉴판과 커피와는 직접적으로 관계가 없는 것도 알 수 있죠.
이제는 동적인 객체들을 정적인 타입으로 간단하게 추상화해보겠습니다.
(추상화란? 실세계의 복잡한 상황을 간결하고 명확하게 핵심 위주로 단순화시킴)
이를 위해 객체들을 분류할건데, 분류를 위해서는 타입(type)을 사용합니다. 4가지 커피 객체들을 모두 같은 커피 타입
으로 분류할 수 있는 것입니다.
동일한 상태와 동일한 행동을 가질 수 있는 객체는 같은 타입의 인스턴스로 분류할 수 있습니다.
참고
타입을 클래스라고 생각하면 안된다고 합니다. 클래스와 타입 사이의 차이는 꼭 이해해 두어야 합니다.객체의 클래스는 그 객체가 어떻게 구현되느냐를 정의합니다. 클래스는 객체의 내부 상태와 그 객체의 연산에 대한 구현 방법을 정의합니다.
반면, 객체의 타입은 그 객체의 인터페이스, 즉 그 객체가 응답할 수 있는 요청의 집합을 정의합니다. 하나의 객체가 여러 타입을 가질 수 있고 서로 다른 클래스의 객체들이 동일한 타입을 가질 수 있습니다. 즉, 객체의 구현은 다를지라도 인터페이스는 같을 수 있다는 의미입니다.
— GoF의 디자인 패턴, p.46
각 객체들은 아래와 같이 모델링 할 수 있습니다.
손님 타입
의 인스턴스바리스타 타입
의 인스턴스커피 타입
의 인스턴스메뉴판 타입
의 인스턴스메뉴 항목 타입
의 인스턴스각 객체들을 타입별로 분류했으니 이제는 그 타입들 간의 관계를 살펴보겠습니다. 어떤 객체들이 존재하는지 보고 객체들 사이의 관계를 알아본 것처럼 말이죠.
이어서 나올 내용에서 관계의 종류(합성관계, 연관관계)가 중요하다고 느낄 수 있는데, 사실 중요하지는 않다고 합니다. 단지, 설명을 위해서 관계를 구분하는 것 입니다.
오히려, 도메인 모델을 작성하는 단계에서는 다음 두 가지에 초점을 맞추는 것만으로도 충분하다고 합니다.
1. 어떤 타입이 도메인을 구성하는지
2. 타입들 사이에 어떤 관계가 존재하는지 파악함으로써 도메인을 이해하는 것
메뉴 항목 객체가 메뉴판 객체에 포함되어 있으므로, 메뉴판 타입과 메뉴 항목 타입은 합성(composition) 관계로 단순화할 수 있습니다.
[b.메뉴판 타입과 메뉴 항목 타입 간의 포함관계]
검정 마름모는 포함(containment) 관계 또는 합성(composition) 관계를 나타내고, 숫자 4는 메뉴판에 포함되는 메뉴 항목 갯수를 의미합니다.
손님 타입은 주문을 하려면 메뉴판 타입을 알아야 하지만, 그렇다고 메뉴판 타입은 손님의 일부가 아니므로 이 관계는 합성 관계는 아닙니다.
이 경우는 단순한 선으로 연결하고, 연관(association) 관계라고 합니다.
[c.손님과 메뉴판 사이의 연관관계]
위와 마찬가지로 손님 타입과 바리스타 타입의 관계나 바리스타 타입과 커피 타입의 관계도 동일한 연관 관계입니다.
이렇게 해서 그림 d 와 같이 커피 전문점 도메인을 구성하는 타입들의 종류와 관계를 표현하게 되었습니다.
이처럼 소프트웨어가 대상으로 하는 영역인 도메인을 단순화해서 표현한 모델을 도메인 모델이라고 합니다.
[d.커피 전문점을 구성하는 타입들]
지금까지 커피 전문점이라는 도메인을 단순화해서 이해해봤습니다. 객체지향의 세계는 협력하는 자율적인 객체들의 공동체라고 합니다.
다음 단계는 각 객체들의 협력을 설계하는 것입니다.
즉, 적절한 객체에게 적절한 책임을 할당하는 것입니다.
객체지향 설계의 첫 번째 목표는 휼륭한 객체를 설계하는 것이 아니라 휼륭한 협력을 설계하는 것입니다.
훌륭한 객체는 훌륭한 협력을 설계할 때만 얻을 수 있습니다.
설계 과정에 대해 요약하면 다음과 같습니다.
협력을 설계할 때는
즉, 메세지가 객체를 선택하도록 하는 것입니다. 그 후 메세지를 수신할 객체를 메세지를 처리할 책임을 맡게 되고 객체가 수신하는 메세지는 객체가 외부에 제공하는 공용 인터페이스에 포함됩니다.
이제 실제로 커피를 주문하는 협력을 설계해보겠습니다. 첫 번째 메세지는 커피를 주문하라
일 것입니다.
[e.협력을 시작하게 하는 첫 번째 메세지]
메세지 위에 붙은 화살표는 메세지에 담아 전달될 부가 정보인 인자를 의미합니다. 나중에 메뉴를 주문하라(카페라떼)
와 같이 인자를 포함하는 형식으로 구현될 것입니다.
메세지를 찾았으니 이제 메세지를 수신하기에 적절한 객체를 선택해야 합니다.
어떤 객체를 선택해야 할까요? 어떤 객체가 커피를 주문할 책임을 가져야 할까요?
당연히 손님이겠죠?! 따라서 메세지를 처리할 객체는 손님 타입의 인스턴스입니다. 이제 손님 객체는 커피를 주문할 책임을 할당 받았습니다.
[f.첫 번째 메세지가 손님이라는 객체를 선택했다.]
그런데 손님이 커피를 주문하라
는 메세지를 받자마자 혼자 할 수 있는 것이 없습니다. 메뉴 항목을 모르기 때문에 스스로 바로 주문할 수 없는 상황인 것입니다. 스스로 할 수 없는 일이 있다면 다른 객체에게 이를 요청해야 합니다. 이 요청이 손님 객체에서 외부로 전송되는 메세지를 정의합니다.
손님이 자신이 선택한 메뉴 항목을 누군가가 제공해 줄 것을 요청합니다. (누구일까요?)
여기서 메뉴 항목을 찾아라
라는 새로운 메세지가 등장하는 것입니다.
[g.스스로 할 수 없는 일은 메세지를 전송해 다른 객체에게 요청한다.]
화살표 위에 있는 메뉴 이름
이라는 인자를 포함해 함께 전송하고, 화살표 아래에 붙은 메뉴 항목
은 손님에게 응답해야 하는 것을 의미합니다.
즉, 메뉴 항목을 찾아라
라는 메세지를 수신한 객체는 메뉴 이름에 대응되는 메뉴 항목
을 반환해야 하는 것입니다. 그럼 새로운 메시지인 메뉴항목을 찾아라
를 수신해서 메뉴 항목을 찾을 책임을 어떤 객체에게 할당하는 것이 적절할까요?
메뉴판 객체가 메뉴 항목을 포함하고 있으므로 가장 적절해 보입니다.
[h.두 번째 객체를 찾았다.]
메뉴판이 메세지를 수신하고 메뉴 항목을 찾아서 제공한다는 개념이 어색할 수 있습니다. 그런데 객체 지향 세계에서는 모든 객체들이 능동적이고 자율적인 존재라고 생각합니다. 이 점도 현실과 객체지향 세계가 다른 부분들 중 하나인 것 같습니다.
그렇게 때문에 설계자는 무생물도 생물처럼 의인화해야 한다고 합니다.
이제 손님은 자신이 주문한 커피에 대한 메뉴 항목을 얻었으니 이제 메뉴 항목에 맞는 커피를 제조해달다고 요청할 수 있습니다.
새로운 요청은 새로운 메세지가 필요하다는 신호이므로, 메세지를 먼저 정의합니다. 손님은 커피를 제조하라
는 메시지 인자로 메뉴 항목
을 전달하고 반환값으로 제조된 커피를 받아야 합니다.
[i.새로운 메세지를 찾았다.]
그럼 커피는 누가 제조해야 할까요? 당연히 바리스타입니다.
[j.커피를 제조하라는 메시지가 바리스타라는 객체를 선택했다.]
위에서 보면 알 수 있듯이 커피를 제조하라(메뉴 항목)
는 메세지를 먼저 정의하고, 그 메시지로 객체를 선택했다는 것을 잊지 말아야합니다.
지금까지 계속해서 메세지를 먼저 정의하고 그 메세지를 수신할 객체를 선택해왔습니다.
바리스타는 아메리카노를 만드는데 필요한 정보와 기술을 함께 지니고 있는 전문가입니다. 아메리카노를 만들기 위한 지식을 바리스타의 상태로, 기술은 바리스타의 행동으로 간주할 수 있습니다. 이런 관점에서 바리스타는 스스로의 판단과 지식에 따라 행동하는 자율적인 존재라고 할 수 있습니다.
커피 주문을 위한 협력을 이제 바리스타가 새로운 커피를 만드는 것으로 끝납니다.
[k.커피 주문을 위한 객체 협력.]
이로써 협력에 필요한 객체의 종류와 책임, 주고받아야 하는 메세지에 대한 대략적인 윤곽이 잡혔습니다. 이제 남은 일은 각 객체의 인터페이스를 구현 가능할 정도로 메시지들을 상세하게 정제하는 것입니다.
지금까지 우리가 얻어낸 것은 객체들의 인터페이스입니다. 객체가 수신한 메시지가 객체의 인터페이스를 결정한다는 사실을 기억해야합니다.
메시지가 객체를 선택했고, 선택된 객체는 메시지를 자신의 인터페이스로 받아드립니다. 각 객체를 협력이라는 문맥에서 떼어내어 수신 가능한 수신 가능한 메시지만 추려내면 객체의 인터페이스가 됩니다. 객체가 어떤 메시지를 수신할 수 있다는 것은 그 객체의 인터페이스 안에 메시지에 해당하는 오퍼레이션이 존재한다는 것을 의미합니다.
각 객체별로 설명하면 다음과 같습니다.
제품을 주문하라
라는 오퍼레이션이 존재합니다.메뉴 항목을 찾아라
라는 오퍼레이션이 존재합니다.커피를 제조하라
라는 오퍼레이션이 존재합니다.생성하라
라는 오퍼레이션이 존재합니다.[l.각 객체들이 수신하는 메시지는 객체의 인터페이스를 구성한다.]
실제로 소프트웨어의 구현은 객체들을 포괄하는 타입을 정의한 후 식별된 오퍼레이션을 타입의 인터페이스에 추가해야 합니다.
객체의 타입을 구현하는 일반적인 방법은 클래스를 이용하는 것입니다. 협력을 통해 식별된 타입의 오퍼레이션은 외부에서 접근 가능한 공통 인터페이스의 일부라는 사실을 기억해야 합니다.
따라서 인터페이스에 포함된 오퍼레이션 역시 외부에서 접근 가능하도록 public
으로 선언돼 있어야 합니다. 클래스의 인터페이스는 자바의 문법을 이용해 표기합니다.
public interface ICustomer {
void order(String name);
}
public interface IBarista {
public Coffee makeCoffee(MenuItem menuItem);
}
public interface IMenu {
public MenuItem choose(String name);
}
Coffee와 MenuItem은 메시지가 없으므로 Interface가 없어도 되겠죠?
(행동보다는 상태를 나타내므로, 인터페이스보다는 클래스나 구조체로 표현하는 것이 일반적입니다.)
클래스의 인터페이스를 식별했으므로 이제 오퍼레이션을 수행하는 방법을 메소드로 구현해보겠습니다.
먼저 Customer의 협력을 살펴보겠습니다.
Customer는 Menu에게 menuName에 해당하는 MenuItem을 찾아달라고 요청해야 합니다. 그리고 MenuItem을 받아 이를 Barista에게 전달해서 원하는 커피를 제조하도록 요청해야 합니다.
[m.손님의 구현은 메뉴판과 바리스타와 협력해야 한다.]
Customer가 Menu객체와 Barista객체에 접근하기 위해서, 즉 메시지를 전송하기 위해서는 어떠한 방법으로든 자신과 협력하는 Menu객체와 Barista객체에 대한 참조를 알고 있어야 합니다.
객체 참조를 얻는 방법은 다양한 방법이 있지만 여기서는 Customer의 order() 메서드의 인자로 Menu와 Barista 객체를 전달받는 방법으로 참조 문제를 해결하기로 합니다.
public interface ICustomer {
void order(String menuName, Menu menu, Barista barista);
}
이제 order() 메서드의 구현부를 채워보겠습니다.
public class Customer implements ICustomer {
@Override
public void order(String menuName, Menu menu, Barista barista) {
MenuItem menuItem = menu.choose(menuName);
Coffee coffee = barista.makeCoffee(menuItem);
.....
}
}
참고
구현하지 않고 머릿속으로만 구상한 설계는 코드로 구현하는 단계에서 대부분 변경된다. 설계 작업은 구현을 위한 스케치를 하는 단계이지 구현 구 자체일 수는 없다.
주요한 것은 설계가 아니라 코드다. 따라서 협력을 구상하는 단계에 너무 오래 시간을 쏟지 말고 최대한 빨리 코드를 구현해서 설계에 이상이 없는지, 설계가 구현 가능한지를 판단해야 한다.
코드를 통한 피드백 없이는 깔끔한 설계를 얻을 수 없다.
Menu는 menuName에 해당하는 MenuItem을 찾아야 하는 책임이 있습니다.
도메인 모델을 설계할 때처럼 간단히 Menu가 내부적으로 MenuItem을 포함하게 하겠습니다.
Menu의 choose() 메서드는 MenuItem의 목록을 하나씩 검사해가면서 이름이 동일한 MenuItem을 찾아 반환합니다.
public class Menu implements IMenu {
private List<MenuItem> menuItems;
public Menu(List<MenuItem> menuItems) {
this.menuItems = menuItems;
}
@Override
public Optional<MenuItem> choose(String name) {
return menuItems
.stream()
.filter(menuItem -> menuItem.getName().equals(name))
.findFirst();
}
}
참고
MenuItem의 목록을 Menu의 속성으로 포함시킨 결정 역시 클래스를 구현하는 도중에 내려졌다는 사실에 주목하라. 객체의 속성은 객체의 내부 구현에 속하기 때문에 캡슐화되어야 한다.
객체의 속성이 캡슐화된다는 이야기는 인터페이스에는 객체의 내부 속성에 대한 어떤 힌트도 제공되어서는 안 된다는 것을 의미한다.
이를 위한 가장 훌륭한 방법은 인터페이스를 정하는 단계에서는 객체가 어떤 속성을 가지는지, 또 그 속성이 어떤 자료 구조로 구현됐는지를 고려하지 않는 것이다.
객체에게 책임을 할당하고 인터페이스를 결정할 때는 가급적 객체 내부의 구현에 대한 어떤 가정도 하지 말아야 한다.
객체가 어떤 책임을 수행해야 하는지를 결정한 후에야 책임을 수행하는 데 필요한 객체의 속성을 결정하라.
이것이 객체의 구현 세부 사항을 객체의 공용 인터페이스에 노출시키지 않고 인터페이스와 구현을 깔끔하게 분리할 수 있는 기본적인 방법이다.
public class Barista implements IBarista {
@Override
public Coffee makeCoffee(MenuItem menuItem) {
Coffee coffee = new Coffee(menuItem);
return coffee;
}
}
Coffee는 자기 자신을 생성하기 위한 생성자를 제공합니다.
Coffee는 커피 이름과 가격을 속성으로 가지고 생성자 안에서 MenuItem에 요청을 보내 커피 이름과 가격을 얻은 후 Coffee의 속성에 저장합니다.
public class Coffee {
private String name;
private int price;
public Coffee(MenuItem menuItem) {
this.name = menuItem.getName();
this.price = menuItem.getPrice();
}
}
MenuItem은 getName()과 getPrice() 메시지에 응답할 수 있도록 메서드를 구현해야 합니다.
public class MenuItem {
private String name;
private int price;
public String getName() {
return name;
}
public int getPrice() {
return price;
}
}
아래 그림 m 는 커피 전문점 코드를 클래스 다이어그램으로 나타낸 것입니다. 몇가지 사소한 관계는 생략되어 있지만 중요한 측면은 모두 포함하고 있습니다.
[n.손님의 구현은 메뉴판과 바리스타와 협력해야 한다.]
드디어 구현까지 끝났습니다! 마지막으로 중요하게 기억해야 할 개념들을 정리해보겠습니다.
이렇게 현실세계에 빗대어 먼저 객체가 될 요소에 대해 생각을 하고 그 객체가 가져야 하는 요청 또는 응답해야할 메시지 등을 고려해본 후 그림을 그려보고 실제 코드로 구현해보는 과정을 겪는 과정에 이해하는데 많은 도움이 있었던거 같습니다. 이 글이 객체지향을 이해하는데 작은 도움이 되었으면 좋겠습니다.
객체 지향을 한 번에 이해하는 것은 무리지만 덕분에 많은 부분이 이해 됐습니다😊😊