협력, 객체, 클래스
객체 지향이란 객체를 지향하는 것이다.
대부분의 사람들이 객체지향 프로그램을 작성할 때 가장 먼저 고려하는 것은 어떤 클래스가 필요한지, 클래스 내부에 어떤 클래스의 속성과 메서드가 필요한지 고민한다.
하지만 진정한 객체지향 패러다임의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있으므로 다음과 같은 것에 집중해야한다.
- 어떤 클래스가 필요한지 고민하기 전에 어떤 객체가 필요한지 고민해야한다. 어떤 객체들이 어떠한 상태와 행동을 가지는지 먼저 결정해야한다.
- 객체들이 어떻게 협력할지에 대해서 고민해야한다. 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현해야한다.
도메인의 구조를 따르는 프로그램 구조
소프트웨어는 사용자가 원하는 어떤 문제를 해결하기 위해 만들어진다.
예를들어 영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것이다.
✂️ 도메인 : 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
클래스 구현하기
훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지 결정하는 것이다.
클래스의 내부와 외부를 구분해야하는 이유는?
경계와 명확성이 객체의 자율성을 보장하고 프로그래머에게 구현의 자유를 제공할 수 있기 때문이다.
자율적인 객체
객체는 상태와 행동을 함께 가지는 복합적인 존재다. 또한 스스로 판단하고 행동하는 자율적인 존재이다.
- 캡슐화 : 데이터의 기능을 객체 내부로 함께 묶는 것
- 접근 제어 : 외부에서의 접근을 통제함.
- 접근 수정자 : public, protected, private
객체 내부에 대한 접근을 통제하는 이유는?
객체를 자율적인 존재로 만들기 위해서다. 객체 지향의 핵심은 스스로가 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다.
인터페이스와 구현의 분리는 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙이다.
- 퍼블릭 인터페이스 : 외부에서 접근 가능한 부분
- 구현 : 외부에서 접근이 불가능하고 오직 내부에서만 접근 가능한 부분
→ 일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야함.
프로그래머의 자유
클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 숨겨야한다.
✂️ 구현 은닉 : 클라이언트 프로그래머가 숨겨놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 클라이언트 프로그래머에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다.
객체의 외부와 내부를 구분하면 클라이언트 프로그래머는 신경쓰지 않아도 되는 부분이 생기고 클래스 작성자는 자유롭게 코드를 수정할 수 있다. 따라서 클래스를 개발할 때 인터페이스와 구현을 분리하기 위해 노력해야한다.
설계가 필요한 이유는 변경을 관리하기 위해서다. 객체의 변경을 관리할 수 있는 기법 중 가장 대표적인 것이 접근제어 이며 세부적인 구현 내용을 private으로 감춤으로써 변경으로 인한 혼란을 최소화할 수 있다.
협력에 관한 짧은 이야기
객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청을 할 수 있고, 요청 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답한다.
메서드
객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것 뿐이며 다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신 했다고 한다. 메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정하는데 이처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드라고 한다.
보통 ‘메서드를 호출한다.’ 라고 하지만 ‘메시지를 전송한다.’ 라고 말하는 것이 더 적절한 표현이다.
A 메서드에서 B 메서드를 호출하는 상황을 가정해보면, 사실 A는 B 메서드가 존재하고 있는지조차 알지 못한다. 단지 A 메서드가 B 메서드 메시지에 응답할 수 있다고 믿고 메시지를 전송할 뿐이다.
컴파일 시간 의존성과 실행 시간 의존성
어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말한다.
위의 클래스 다이어그램과 같이 계층 사이의 관계가 맺어져 있다.
public abstract class DiscountPolicy {
public DiscountPolicy(DiscountCondition ... conditions) {
this.conditions = Arrays.asList(conditions); }
}
public class Movie {
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
...
this.discountPolicy = discountPolicy; }
}
위와 같이 객체가 설계되어 있을 때 Movie 생성자에서는 실제 구현체가 아닌 추상메서드를 인자로 받는다.
그리고 실제로 생성할 때는 구현체를 인자로 주어다음과 같이 호출할 수 있다.
Movie avatar = new Movie("아바타", Duration.ofMinutes(120),Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800), ...));
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000),
new PercentDiscountPolicy(0.1, ...));
이처럼 인터페이스와 추상클래스를 이용한 다형성을 통해 코드를 작성했다면 코드의 의존성과 실행 시점의 의존성이 다를 수 있다.(클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다.)
코드의 의존성과 실행 시점의 의존성이 다르면 코드를 이해하기 위해서 코드 뿐만 아니라 객체를 생성하고 연결하는 부분을 찾아야 하기 때문에 코드를 이해하기 어려워진다.
하지만 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해진다.
이와 같은 의존성의 양면성은 설계가 트레이드오프의 산물이라는 사실을 잘 보여준다.
객체지향 코드의 유연성과 가독성
설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다. 반면 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용과 확장 가능성은 낮아진다. 무조건 유연한 설계도, 무조건 이해하기 쉬운 코드도 정답이 아니며 이것이 객체지향 설계가 어려우면서 매력적인 이유다.
차이에 의한 프로그래밍
A라는 클래스를 하나 추가하고 싶은데 그 A라는 클래스가 기존의 B라는 클래스와 매우 흡사하다면 상속을 통해 클래스의 코드를 전혀 수정하지 않고 재사용할 수 있다.
상속을 이용하면 클래스 사이에 관계를 설정하는 것만으로 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함할 수 있다.
또한 상속을 이용하면 부모 클래스의 구현은 공유하면서 행동이 다른 자식 클래스를 쉽게 추가할 수 있다.
이처럼 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍(programming by difference) 이라고 부른다.
상속과 인터페이스
상속은 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려 받을 수 있다. 인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다. 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하게 된다. 결과적으로 자식 클래스는 부모 클래스가 수신하는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
Movie가 DiscountPolicy의 인터페이스에 정의된 calculateDiscountAmount 메시지를 전송하고 있다.
Movie 입장에서는 자신과 협력하는 객체가 어떤 클래스의 인스턴스인지가 중요한 것이 아니라 calculateDiscountAmount 메시지를 수신할 수 있다는 사실이 중요하다.
Movie는 협력 객체가 calculateDiscountAmount라는 메시지를 이해할 수만 있다면 그 객체가 어떤 클래스의 인스턴스인지는 상관하지 않는다는 것이다.
따라서 calculateDiscountAmount 메시지를 수신 할 수 있는 AmountDiscountPolicy와 PercentDiscountPolicy 모두 DiscountPolicy를 대신해서 Movie와 협력 할수있다.
자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다. → 컴파일러는 코드 상에 부모 클래스가 나오는 모든 장소에서 자식 클래스를 사용하는 것을 허용
✂️ 업캐스팅(upcasting) : 자식 클래스가 부모 클래스를 대신하는 것
TEMPLATE METHOD 패턴
부모 클래스에 기본적인 알고리 즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴
다형성
동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라 부른다.
다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력이다.
다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야한다. 즉, 인터페이스가 동일해야한다는 것이다. 상속은 인터페이스를 통일하기 위한 방법중 하나이다.
상속을 이용하면 동일한 인터페이스를 공유하는 클래스들을 하나의 타입 계층으로 묶을 수 있다. 그래서 보통 다형성을 이야기할 때 상속을 함께 언급하는 것이다.
물론 상속이 다형성을 구현할 수 있는 유일한 방법은 아니다.
인터페이스와 다형성
부모 클래스를 추상 클래스로 구현하고 자식 클래스들이 인터페이스와 내부 구현을 함께 상속받도록 만들 수 있다.
하지만 종종 구현은 필요없고 순수하게 인터페이스 만을 공유하고 싶을 때는 자바의 인터페이스를 사용하면 된다.
추상화의 힘
추상화를 사용한다면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있으며, 추상화의 이런 특징은 세부사항에 억눌리지 않고 상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 한다.
추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것 을 의미한다.
이 개념은 매우 중요한데, 재사용 가능한 설계의 기본을 이루는 디자인 패턴(design pattern)이나 프레임워크(framework) 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용하고 있기 때문이다.
추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다
즉, 설계를 유연하게 만들 수 있다.
코드 재사용
상속은 코드를 재사용하기 위해 널리 사용되지만 가장 좋은 방법은 아니다.
- 합성 : 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법
Movie에서 DiscountPolicy의 코드를 재사용하는 방법이 바로 합성이다.
이 설계를 상속을 사용하도록 변경한다면 다음과 같이 Movie를 직접 상속받아 AmountDiscountMovie와 PercentDiscountMovie라는 두 개의 클래스를 추가하면 합성을 사용한 기존 방법과 기능적인 관점에서 완벽히 동일하다. 그럼에도 많은 사람들이 상속 대신 합성을 선호하는 이유는 무엇일까?
상속
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이지만 두 가지 관점에서 설계에 안좋은 영향을 미친다.
- 상속은 캡슐화를 위반한다.
- 설계를 유연하지 못하게 만든다.
캡슐화를 위반한다.
상속의 가장 큰 문제점은 캡슐화를 위반한다는 것이다.
상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다.
결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.
캡슐화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높힌다.
결과적으로 상속을 과도하게 사용한 코드는 변경하기도 어려워진다.
설계를 유연하지 못하게 만든다.
상속의 두 번째 단점은 설계가 유연하지 않다는 것이다.
상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다.
따라서 실행 시점에 객체의 종류를 변경하는게 불가능해진다.
합성
합성은 상속이 가지는 두 가지 문제점을 모두 해결한다. 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다. 또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다. 상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다.
따라서 코드 재사용을 위해서는 상속보다는 합성을 선호하는 것이 더 좋은 방법이다.
정리
객체지향 프로그램을 작성할 때 어떤 클래스가 필요한지 고민하기 전에 어떤 객체가 필요한지 고민할 것.
객체 간의 협력에 참여하는 협력자로 바라보고 공통된 특성과 상태를 가진 타입으로 분류하고 타입을 기반으로 클래스를 구현할 것.
도메인 : 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
객체 지향의 4가지
캡슐화 : 접근 제어자를 사용하여 외부에서의 접근을 막음으로써 객체의 상태와 행동을 통제하는 것.
- 객체를 더욱 더 자율적인 존재로 만들 수 있다.
- 다른 개발자가 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지하여 안정적인 프로그램을 설계할 수 있다.
- 객체의 변경을 관리할 수 있다.
- 세부적인 구현 내용을 private 영역 안에 감춤으로써 변경으로 인한 혼란을 최소화 할 수 있다.
상속 : 클래스의 관계를 정의하기 위한 방법으로 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려 받도록 함으로써 코드를 재사용할 수 있게하는 방법
- 자식 클래스는 부모 클래스가 수신하는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.
- 자식 클래스는 상속을 통해 부모의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다. → 업캐스팅
다형성 : 하나의 인터페이스나 추상클래스를 통해 여러 객체를 동일한 타입으로 다룰 수 있는 방법
- 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다.
- 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력
- 어떠한 메서드가 실행될지는 컴파일 시점이 아닌 런타임 시점에 결정된다.
추상화 : 클래스들이 공통으로 가질 수 있는 인터페이스를 정의하는 것.
- 일부(추상 클래스인 경우) 또는 전체(자바 인터페이스인 경우)를 자식 클래스가 결정할 수 있도록 결정권을 위임한다.
- 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다.
추상 클래스 vs 인터페이스
다른 클래스에게 내부 구현을 공유하고 싶을 때는 상속 관계를 사용하여 부모 클래스를 추상 클래스로 만들고 자식클래스에서 부모 클래스의 내부 구현을 공유하여 확장할 수 있다.
구현은 필요없고 순수하게 인터페이스 만을 공유하고 싶을 때는 자바의 인터페이스를 사용한다.
합성 : 다른 클래스의 인스턴스를 포함하여 새로운 클래스를 만드는 방법
'독서 > 오브젝트' 카테고리의 다른 글
오브젝트 : 객체, 설계 (0) | 2023.08.18 |
---|
댓글