Book

[객체지향의 사실과 오해: 마무리] 07 함께 모으기 / 부록 A

YATTA! 2024. 11. 20. 13:00

✏️, 💡,❓ 해당 이모지는 저의 생각임을 나타냅니다.

 

객체지향 설계 안에 존재하는 관점이 있다.

개념 관점(Conceptual Perpective): 설계는 도메인 안에 존재하는 개념과 개념들 사이의 관계를 표현한다. 이 관점은 사용자가 도메인을 바라보는 관점을 반영하고, 실제 도메인의 규칙과 제약을 최대한 유사하게 반영하는 것이 핵심이다. 

명세 관점(Specification Perspective): 사용자 영역인 도메인을 벗어나 개발자 영역인 소프트웨어로 초점이 옮겨진다. 실제 소프트웨어 안 객체들의 책임에 초점을 맞추게 된다. (객체의 인터페이스) 명세 관점에서 프로그래머는 객체가 '무엇'을 할 수 있는가에 초점을 맞춘다. 

구현 관점(Implemenation Perspective)은 실제 작업을 수행하는 코드와 연관돼 있다. 구현 관점 초점은 객체가 책임을 수행하는 데 필요한 코드를 작성하는 것이다. 프로그래머는 책임을 '어떻게' 수행할 것인가 초점을 맞춘다.

 

개념 관점, 명세 관점, 구현 관점의 순서대로 소프트웨어를 개발한다는 의미는 아니다. 세 관점은 동일한 클래스를 다른 방향에서 바라보는 것을 의미한다. 

 

클래스는 세 가지 관점을 모두 수용할 수 있도록 개념, 인터페이스, 구현을 함께 드러내야 한다. 그리고 그들을 깔끔히 분리해야 한다.

 

이번 장에서는 명세 관점에 더해 개념 관점과 구현 관점을 다룰것이다. 첫번째 목표는 도메인 모델 -> 최종 코드까지의 구현 과정을 간략히 설명하는 것이고 두 번째 목표는 구현 클래스를 개념 명세 구현 관점에서 바라본다는 것이 무엇을 의미하는지 설명하는 것이다. 

 

커피 전문점 도메인

커피를 주문하는 과정을 객체들의 협력관계로 구현한다.

- 객체로 볼 수 있는 것: 메뉴판, 메뉴 항목, 손님, 바리스타, 커피

손님 -> 메뉴판 (연관 관계)

손님 -> 바리스타 (연관 관계)

바리스타 -> 커피

메뉴판 -> 메뉴 항목 (포함 관계, 합성 관계)

 

객체지향 설계의 첫 번째는 '객체'를 설계하는 것이 아니라 '협력'을 설계하는 것이라는 점을 잊지 말자. 메시지를 먼저 선택하고 메시지를 수신할 객체를 선택하라. 우리의 첫 번째 협력은 '커피 주문'일 것이다.

메시지: 커피를 주문하라(메뉴 이름)

도메인 모델 안에 책임을 수행하기 적절한 타입이 존재하는지 살펴본 후 인스턴스로 만들어라. 커피를 주문할 책임을 지는 것은 '손님'일 것이다. 손님 객체는 커피를 주문할 책임을 할당 받았다. 그러나 손님은 메뉴 항목을 알지 못한다. 

커피를 주문하라 -> 손님 -> 메뉴 항목을 찾아라 (새로운 메시지)

커피를 주문하라 -> 손님 -> 메뉴 항목을 찾아라 -> 메뉴판

손님은 메뉴판에서 반환된 메뉴 항목에 맞는 커피를 제조해달라고 요청할 수 있다. 새로운 메시지인 커피를 제조하라가 필요하다.

이렇게 협력에 필요한 객체의 종류와 책임, 메시지에 대한 윤곽이 잡혔다. 객체가 수신한 메시지가 객체의 인터페이스를 결정하는 사실을 기억하라. 객체가 어떤 메시지를 수신한다는 것은 그 객체의 인터페이스에 오퍼레이션이 존재한다는 것을 의미한다.

 

객체의 타입을 구현하는 일반적인 방법은 클래스를 이용하는 것이다. 구현 도주에 객체의 인터페이스가 변경될 수 있다. 설계 작업은 구현을 위한 스케치 단계지 그 자체일 수는 없다. 

💡 설계 중에는 인터페이스가 변경될 수 있다. 하지만 수정 작업에서의 인터페이스 변경은 안 하는 게 좋다.

class Customer {
    public void order(String menuName, Menu menu, Barista barista) {
    	MenuItem menuItem = menu.choose(menuName);
        Coffe coffe = barista.makeCoffe(menuItem);
        ...
    }
}
class Barista {
    public Coffe makeCoffe(MenuItem menuItem) {
    	Coffee coffee = new Coffee(menuItem);
        return coffee;
    }
}

class Coffee {
    private String name;
    private int price;
    
    pubilc Coffe(MenuItem menuItem) {
    	this.name = menuItem.getName();
        this.price = menuItem.cost();
    }
}

 

💡 MenuItem과 Coffee는 비슷한 상태로 이루어져있지만 엄연히 다르게 취급한다. 

❓ price가 coffee 생성 시 책정 되는 것이 어색하게 느껴지기도 했다. 만약 11월달이라 할인이 들어간다면 '할인'은 어디에 구현되는 게 좋을까?

💡내 생각에는 우선 캐셔라는 객체가 새로 생성되어야 할 것 같다. Coffee에 있는 가격 상태를 빼고 캐셔가 MenuItem에 있는 가격을 가져온 후 할인이 있다면 할인을 적용한다. Coffee에 가격이 있는 걸 어색하게 느껴도 되나 고민하였는데 그 자체에는 가격이 있는 게 어색한 상황이 맞는 것 같다. 그냥 공짜로 주는 것일 수도 있으니까....(바리스타가 만드는 커피가 price가 있을 것이라는 법은 없으니까?) (어떻게 생각해?) 

 

코드와 세 가지 관점

앞서 작성한 코드는 개념 관점, 명세 관점, 구현 관점에서 각기 다른 사항들을 설명해준다. 개념 관점에서 코드를 바라보면 고객, 메뉴, 메뉴 아이템, 바리스타, 커피 클래스가 보인다. 클래스가 도메인 개념 특성을 수용하면 변경을 관리하기 쉽다. (커피 제조 과정 변경 시 어디를 수정해야 할까?)

명세 관점은 인터페이스를 바라본다. 인터페이스는 수정하기 어렵다. 안정적인 인터페이스를 만들기 위해서는 구현 관련 세부 사항이 드러나지 않게 해야 한다.

구현 관점은 내부 구현을 바라본다. 구현과 속성은 외부의 객체에게 영향을 미쳐서는 안된다. 

 

메시지가 있을 때 그 메시지를 수신할 객체를 어떻게 선택하는가? 첫 번째로는 도메인 개념 중에서 가장 적절한 것을 선택하면 된다. 이는 도메인에 대한 지식을 기반으로 코드의 구조와 의미를 쉽게 유추할 수 있게 한다.

소프트웨어는 항상 변하고 설계는 변경을 위해 존재한다. 

❓ 인터페이스와 구현을 분리하는 이유 - 변경이 발생했을 때 코드를 더 수월하게 수정하길 원해서 <- 라는 부분이 아직도 이해가 잘 가지 않는다. 사실 이런 물음을 갖는 것이 부끄럽기도 하다. 왜냐면 책에서 내내... 그걸 설명했다고 생각하기 때문이다. 그런데 잘 모르겠다. 인터페이스는 정말 '수정'을 용이하게 해주나?

✏️ 인터페이스가 구현 세부 사항을 노출하게 되면 구현이 변경 될 시 인터페이스도 변경되어야 하기때문에 그렇게 해서는 안된다. 그런데 인터페이스와 구현을 분리하는 이유가 수정이 용이해서라는 말이 와닿지 않았다. 구현을 변경할 때 클래스만 변경하면 되기때문에 수정이 용이한 것일까? 내가 그렇게 '용이한' 수정을 해본 적이 없어서 잘 와닿지 않는 것 같다.

✏️ 호출하는 쪽에서 변경이 없도록 하면 코드가 더 수월하게 수정될 것이다. 파라미터를 dto로 숨긴다던지 하면.

 

다시 한 번 강조한다. 인터페이스와 구현을 분리하라. 

명세 관점과 구현 관점이 섞여 머릿속을 어지럽히지 못하게 하라. 명세 관점은 안정적인 측면을, 구현 관점은 불안정한 측면을 드러내야 한다. 인터페이스가 구현 세부 사항을 노출하면 안된다.

 

명세 관점과 구현 관점을 분리하는 것은 중요하다. 우리는 구현 관점을 빈번하게 사용하겠지만 실제로 훌륭한 설계를 결정하는 것은 인터페이스다. 명세 관점이 설계를 주도하게 하면 설계의 품질이 향상될 수 있다는 사실을 기억하라.

 

 


 

부록 A: 추상화 기법

특성을 공유하는 객체들을 동일한 타입으로 분류하는 것은 객체지향 패러다임에서 사용하는 추상화 기법의 한 예다. 

추상화 기법의 종류

- 분류와 인스턴스화

- 일반화와 특수화

- 집합과 분해

 

분류와 인스턴스화

객체를 분류하고 범주로 묶는 것은 객체의 특정 집합에 공통의 개념을 적용하는 것을 의미한다. 자동차의 범주에 적용되는 개념은 '바퀴를 이용해 사람을 운반하는 운송수단'이다. 

객체에 개념을 적용하는 것을 분류라고 한다. 분류는 객체를 특정한 개념을 나타내는 집합의 구성 요소로 포함시킨다. 사람들은 분류를 통해 개별 현상을 하나의 개념으로 다룬다. 개별 현상을 객체라고 하고 하나의 개념을 타입이라고 한다. 분류는 객체를 타입과 연관시키는 것이다. 분류의 역은 인스턴스화 또는 예시라고 한다.

💡분류의 역이라는 것은 분류를 통해 개별 현상을 묶는 것과 반대를 말한다. 즉, 타입(개념)으로부터 해당 객체를 구체적으로 만들어내는 과정이다. 타입으로부터 객체를 생성하는 과정은 인스턴스화라고 한다.

- 개념 = 타입

- 객체 = 타입의 인스턴스

 

객체를 타입에 따라 분류하기 위해서는 객체가 어떤 타입인지 알 수 있어야 한다. 또한 타입으로 객체를 분류하려면 아래와같은 세 가지 관점에서의 정의가 필요하다.

- 심볼: 타입 이름

- 내연: 타입의 정의 (객체가 타입에 속하는지 여부 확인 가능)

- 외연: 타입에 속하는 모든 객체들의 집합 (집합 = 외연)

 

객체들은 동시에 서로 다른 집합에 포함될 수 있다. 한 객체가 하나의 타입에 속하는 것을 단일 분류라고 하고 여러 타입에 속하는 것을 다중 분류라고 한다.

대부분의 언어들은 단일 분류만을 지원한다. 대부분의 언어에서 한 객체는 오직 한 클래스의 인스턴스여만 하며 두 개의 클래스의 인스턴스일 수는 없다. 다중 분류와 다중 상속은 혼동해서는 안된다. 다중 상속은 하나의 타입이 다수의 슈퍼타입을 가질 수 있도록 하지만 타입 정의를 생략할 수는 없다. 다중 분류는 특정한 타입을 정의하지 않고도 하나의 객체가 서로 다른 타입의 인스턴스가 되도록 허용해야 한다.

 

만약 객체가 타입을 변경할 수 있다면 어떻게 될까? 객체가 한 집합에서 다른 집합의 원소로 타입을 변경할 수 있는 경우를 동적 분류라고 하고 못하는 경우를 정적 분류라고 한다. 

다중 분류와 동적 분류는 배타적 개념이 아니다. 우리가 사용하는 대부분의 언어는 정적 분류만 허용한다. 단순함을 위해서는 단일 분류와 정적 분류를 선택하는 것이 현명하다.

 

타입을 구현하는 가장 보편적인 방법은 클래스를 이용하는 것이다. 클래스와 타입이 동일한 개념은 아니다. (클래스는 타입 구현 외에도 다양한 용도로 쓸 수 있기 때문)

객체지향 패러다임은 아리스토텔레스의 철학(분류법)을 기반으로 한다. 아리스토텔레스는 객체의 특성을 본질적 속성과 우연적 속성으로 분류했다. 어떤 사람이 취직을 해서 회사원이 되어도 그 사람은 여전히 그 사람이다. 회사원이라는 역할이 그 사람의 본질을 바꾸지는 못한다. 대부분의 객체지향 언어에서 동일 범주에 속하는 객체는 동일한 클래스의 인스턴스여야 한다. (우연적 속성은 대부분의 프로그래밍 언어에서 표현할 수 없다.)

 

일반화와 특수화

동물계 -> 척색동물문 -> 포유류강 -> 육식동물목 -> 고양이과 -> 고양이속 -> 고양이종

위는 린네의분류 체계이다. 이런 계층 구조는 더 세부적인 범주가 계층의 하위에 위치하고 더 일반적인 범주가 상위에 위치한다. 이때 상위에 위치한 범주를 하위에 위치한 범주의 일반화라고 하고, 하위에 위치한 범주는 상위에 위치한 범주의 특수화라고 한다. 

객체지향의 세계에서 범주는 개념을, 개념은 타입을 의미한다. 어떤 타입이 다른 타입보다 일반적이라면 이 타입을 슈퍼타입이라고 하고 더 특수하다면 서브타입이라고 한다. 슈퍼 타입은 서브타입의 일반화이고 서브타입은 슈퍼타입의 특수화다. 어떤 범주에 속하는 다른 객체가 특정 속성을 가지고 있음을 알게 되면, 그 범주와 하위 범주에 속하는 다른 객체도 그 속성을 가지고 있을 것이라고 추론할 수 있다.

크레이그 라만은 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 '100% 규칙'과 'is-a 규칙'을 준수해야 한다고 말한다.
두 타입이 두 규칙을 만족시키지 못할 경우 두 타입간에 일반화 관계는 성립하지 않는다.

'100%' 규칙: 타입의 내연과 관련. 슈퍼타입의 정의가 100% 서브타입에 적용되어야 한다. 서브타입은 속성과 연관관계 면에서 슈퍼타입과 100% 일치해야 한다.

'is-a' 규칙: 타입의 외연과 관련. 서브타입의 모든 인스턴스는 슈퍼타입 집합에 포함돼야 한다. 이는 대개 영어로 subtype is a supertype라는 구문을 만듦으로써 테스트 할 수 있다. '고양이는 육식동물이다.' '육식동물은 고양이다.' is-a 관계의 본질은 서브타입이 슈퍼타입의 부분집합이라는 것이다.

 

일반화와 특수화 관계를 구현하는 가장 일반적인 방법은 상속을 사용하는 것이다. 그러나 모든 상속 관계가 일반화 관계인 것은 아니다. 일반화의 원칙은 서브타입이 되기 위해선 슈퍼타입에 순응해야 한다는 것이다.

순응에는 구조적 순응행위적 순응이 있는데, 구조적 순응은 100%규칙을 의미하고 행위적 순응은 서브타입은 슈퍼타입을 행위적으로 대체 가능해야 함을 의미한다. (리스코프 치환 원칙)

하지만 상속의 또 다른 용도는 코드 중복을 방지하고 공통 코드를 재사용하기 위한 매커니즘을 제공하는 것이다. 상속한다면 부모 클래스의 데이터와 메서드를 수정하고, 확장할 수 있다. 어떤 프로그래밍 언어도 상속이 대체 가능성을 만든다는 것을 보장하지 않는다. 

 

집합과 분해

안정적인 형태의 부분으로부터 전체를 구축하는 행위를 집합이라고 하고, 전체를 부분으로 분할하는 행위를 분해라고 한다. 집합은 불필요한 세부 사항을 추상화해 복잡성을 줄일 수 있다. 하지만 필요한 시점에는 전체를 분해함으로써 그 안에 포함된 부분들을 새로운 전체로 다룰 수 있다. 집합은 추상화 메커니즘인 동시에 캡슐화 메커니즘이다. 외부에서는 전체에 관해서만 알고 있고 내부의 세부 사항에 대해서는 알지 못한다.

 

상품 주문을 생각해볼때 여러 상품을 한 번에 주문할 수 있다. 가가 상품을 몇 개 주문했는지를 주문 항목이라고 한다. 주문 항목은 주문과 독립적일 수 없다. 주문 항목은 반드시 어떤 주문의 일부로 생성되기때문이다. 이 경우 합성 관계를 사용한다. 합성 관계는 부분을 전체 안에 캡슐화함으로써 인지 과부화를 방지한다. 

주문항목은 주문의 일부이므로 항목과 관련된 세부사항은 무시하고 주문과 상품만 존재하는 것처럼 모델을 다룰 수 있다. 필요하다면 주문 내부로 들어가 주문 항목 관련 세부 사항을 확인할 수 있다. 상품과 주문 항목 사이에도 관계가 존재하지만 상품은 주문 항목의 일부가 아니다. 이를 연관 관계라고 한다. 합성 관계와 연관 관계의 차이가 항상 명확하진 않지만 일반적으로 합성 관계라면 상위 객체가 제거될 때 내부 객체도 함께 제거된다. 반면 연관 관계로 연결된 객체는 생명주기 관련 어떤 제약도 부과하지 않는다.

 

관련된 집합을 하나의 논리적인 단위로 묶는 요소를 패키지 또는 모듈이라고 한다. 합성 관계가 내부에 포함된 객체들의 존재를 감춤으로써 내부 구조를 추상화하는 것처럼 패키지는 내부에 포함된 클래스들을 감춤으로써 시스템의 구조를 추상화한다.