객체지향 프로그래밍을 향해
협력, 객체, 클래스
진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에 얻을 수 있다.
- 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라.
- 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것
클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야함.
- 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.
- 객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만든다.
도메인의 구조를 따르는 프로그램 구조
- 도메인: 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Screening(Moive movie, int sequence, LocalDateTime whenScreened) {
this.movie = movie;
this.sequence = sequence;
this.whenScreened = whenScreened;
}
public LocalDateTime getStartTime() {
return whenScreened;
}
public boolean isSequence(int sequence) {
return this.sequence == sequence;
}
public Money getMovieFee() {
return movie.getFee();
}
}
인스턴스 변수의 가시성은 private고 메서드의 가시성은 public이라는 점에 주목하자!
가장 중요한 것은 클래스의 경계를 구분 짓는 것이다.
클래스 설계의 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지를 결정하는 것이다.
Screening에서 알 수 있듯이 외부에서는 객체의 속성에 직접 접근할 수 없도록 막고 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 해야한다.
클래스의 내부와 외부를 구분해야하는 이유는? ⇒
경계의 명확성이 객체의 자율성을 보장하기 때문.
자율적인 객체
객체가
상태
와행동
을 함께 가지는 복합적인 존재다.객체가 스스로 판단하고 행동하는
자율적인 존재
다.객체지향 이전 → 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성
객체지향 → 객체라는 단위 안에 데이터와 기능을 묶어서 표현. 이를
캡슐화
라고 한다.객체지향 프로그래밍 언어들은 외부에서의 접근을 통제할 수 있는
접근 제어
메커니즘도 함께 제공한다.- public ,protected, private와 같은 접근 수정자를 제공한다.
객체 내부에 대한 접근을 통제하는 이유: 객체를 자율적인 존재로 만들기 위해서다.
캡슐화와 접근 제어는 객체를 두 부분으로 나눈다.
- 퍼블릭 인터페이스: 외부에서 접근 가능한 부분
- 구현: 외부에서는 접근 불가능하고 오직 내부에서만 접근 가능한 부분
인터페이스와 구현의 분리 원칙
일반적으로
객체의 상태는 숨기고 행동만 외부에 공개해야 한다.
클래스의 속성은 private으로 선언해서 감추고 외부에 제공하는 일부 메서드만 public으로 선언해야 한다.
어떤 메서드들이 서브클래스나 내부에서만 접근 가능해야 한다면 가시성을 protected나 private으로 지정해야 한다.
퍼블릭 인터페이스에는 public으로 지정된 메서드만 포함한다. 그 밖의 private 메서드나 protected 메서드, 속성은 구현에 포함된다.
프로그래머의 자유
- 프로그래머의 역할 → 클래스 작성자와 클라이언트 프로그래머로 구분하는 것이 유용.
- 클래스 작성자는 새로운 데이터 타입을 프로그램에 추가하고, 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용한다.
구현 은닉
: 필요한 부분만 공개하고 나머지는 꽁꽁 숨김. → 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 내부 구현을 마음대로 변경 가능하다.- 객체의 외부와 내부를 구분하면 클라이언트 프로그래머가 알아야 할 지식의 양이 줄어들고 클래스 작성자가 자유롭게 구현을 변경할 수 있는 폭이 넓어진다 !! → 인터페이스와 구현을 분리하기 위해 노력하자.
- 객체지향의 장점은
객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다.
협력에 관한 짧은 이야기
- 객체의 내부 상태는 외부에서 접근하지 못하도록 감춰야 한다.
- 외부에 공개하는 퍼블릭 인터페이스를 통해
내부 상태에 접근할 수 있도록 허용
한다. - 객체가 다른 객체와 상호작용할 수 있는 유일한 방법은
메시지를 전송
하는 것뿐이다. - 다른 객체에게 요청이 도착할 때 해당 객체가
메시지를 수신
했다고 이야기한다. - 수신된 메시지를 처리하기 위한 자신만의 방법을
메서드
라고 한다.
할인 요금 구하기
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie (String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee (Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
- 상속과 다형성의 개념이 숨겨져있다.
- 그 기반에는 추상화라는 원리가 숨겨져있다.
할인 정책과 할인 조건
할인 정책: 금액 할인 정책, 비율 할인 정책
- 두 클래스의 중복 코드를 제거하기 위해 공통 코드를 보관할 장소가 필요 → 부모 클래스인 DiscountPolicy안에 중복 코드를 두고 AmountDiscountPolicy와 PercentDiscountPolicy가 이 클래스를 상속받도록 하자.
실제 애플리케이션에서는 DiscountPolicy 인스턴스를 생성할 필요가 없기 때문에 추상 클래스로 구현.
TEMPLATE METHOD 패턴
: 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에 위임하는 디자인 패턴
오바라이딩과 오버로딩
오버라이딩
- 부모 클래스에 정의된 같은 이름, 같은 파라미터 목록을 가진 메서드를 자식 클래스에서 재정의하는 경우를 가리킴.
- 자식 클래스의 메서드는 오버라이딩한 부모 클래스의 메서드를 가리기 때문에 외부에서는 부모 클래스의 메서드가 보이지 않는다.
오버로딩
메서드의 이름은 같지만 제공되는 파라미터의 목록이 다르다.
오버로딩한 메서드는 원래의 메서드를 가리지 않기 때문에 메서드 공존 가능.
// 예시 public class Money { public Money plus (Money amount) { return new Money(this.amount.add(amount.amount)); } public Money plus(long amount) { return new Money(this.amount.add(BigDecimal.valueOf(amount))); } }
두 개의 메서드 이름은 같지만 파라미터가 다르다. 두 메서드는 공존하며 외부에서는 두 개의 메서드 모두 호출 가능한데, 이 경우를
오버로딩
이라고 부른다.
상속과 다형성
컴파일 시간 의존성과 실행 시간 의존성
의존성: 어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말한다.
Movie는 DiscountPolicy와 연결돼 있으며, AmountDiscountPolicy와 PercentDiscountPolicy는 추상 클래스인 DiscountPolicy를 상속받는다.
영화 요금을 계산하기 위해서는 추상 클래스인 DiscountPolicy가 아니라 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스에 의존해야 한다. 하지만 코드 수준에서 Movie 클래스는 이 두 클래스 중 어떤 것에도 의존하지 않는다. 오직 추상 클래스인 DiscountPolicy에만 의존하고 있다.
Movie 인스턴스가 코드 작성 시점에는 존재조차 알지 못했던 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스와 실행 시점에 협력 가능한 이유는?
코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다.
클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다.
유연하고, 쉽게 재사용할 수 있으며, 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성과 실행 시점의 의존성이 다르다는 것이다.
but, 코드의 의존성과 실행 시점의 의존성이 다를수록 코드를 이해하기 어려워짐. ⇒ 트레이드오프의 산물.
설계가 유연해질수록 코드를 이해하고 디버깅하기는 어려워지지만, 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용성과 확장 가능성은 나아진다.
차이에 의한 프로그래밍
클래스를 하나 추가하고 싶은데 그 클래스가 기존의 어떤 클래스와 매우 흡사하다면? ⇒ 클래스 재사용 ⇒
상속
상속은 코드를 재사용하기 위해 가장 널리 사용되는 방법.
상속을 이용하면 클래스 사이에 관계를 설정하는 것만으로 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함시킬 수 있다.
상속은 기존 클래스를 기반으로 새로운 클래스를 쉽고 빠르게 추가할 수 있는 간편한 방법을 제공함.
상속을 이용하면
부모 클래스의 구현은 공유하면서도 행동이 다른 자식 클래스를 쉽게 추가할 수 있다.
AmountDiscountPolicy와 PercentDiscountPolicy의 경우 DiscountPolicy에서 정의한 추상 메서드인 getDiscountAmount 메서드를 오버라이딩해서 DiscountPolicy의 행동을 수정한다는 것을 알 수 있다.
부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍
이라고 부른다.
자식 클래스와 부모 클래스 (p.60 참고)
- 상속은 두 클래스 사이의 관계를 정의하는 방법
- 상속 관계를 선언함으로써 한 클래스는 자동으로 다른 클래스가 제공하는 코드를 자신의 일부로 합칠 수 있다.
- 따라서 상속을 사용하면 코드 중복을 제거하고 여러 클래스 사이에서 동일한 코드를 공유할 수 있게 된다.
상속과 인터페이스
부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문에 상속은 가치 있다.
인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다.
상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하게 된다.
결과적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 대문에 외부 객체는
자식 클래스를 부모 클래스와 동일한 타입으로 간주
할 수 있다.자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다.
컴파일러는 코드 상에서 부모 클래스가 나오는 모든 장ㅈ소에서 자식 클래스를 사용하는 것을 허용한다.
- Moive의 생성자에서 인자의 타입이 DiscountPolicy임에도 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스를 전달할 수 있는 이유도 바로 이 때문이다.
자식 클래스가 부모 클래스를 대신하는 것을
업캐스팅
이라고 부른다.
다형성
Moive는 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를
다형성
이라고 부른다.다형성은
객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반
으로 한다.컴파일 시간 의존성: Movie → DiscountPolicy
실행 시점에 Moive의 인스턴스와 실제로 상호작용하는 객체 → AmountDiscountPolicy나 PercentDiscountPolicy.
즉, 실행 시간 의존성: Movie → AmountDiscountPolicy나 PercentDiscountPolicy
다형성은 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다.
다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야되는데, 다시 말해
인터페이스가 동일해야 한다.
AmountDiscountPolicy와 PercentDiscountPolicy가 다형적인 협력에 참여할 수 있는 이유는 이들이 DiscountPolicy로부터 동일한 인터페이스를 물려받았기 때문.
이 두 클래스의 인터페이스를 통일하기 위해 사용한 구현 방법 ⇒ 상속
다형성을 구현하는 방법
메시지에 응답하기 위해 실행될 메서드를
컴파일 시점이 아닌 실행 시점에 결정한다는 공통점
이 있다. ⇒ 메시지와 메서드를 실행 시점에 바인딩한다.지연 바인딩과 또는 동적 바인딩
이라고 부른다.초기 바인딩 또는 정적 바인딩: 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것.
객체지향이 컴파일 시점의 의존성과 실행 시점의 의존성을 분리하고, 하나의 메시지를 선택적으로 서로 다른 메서드에 연결할 수 있는 이유가
지연 바인딩
이라는 메커니즘을 사용하기 때문.
구현 상속과 인터페이스 상속
상속을
구현 상속과 인터페이스 상속
으로 분류할 수 있다.구현 상속 → 서브클래싱
인터페이스 상속 → 서브타이핑
순수하게 코드를 재사용하기 위한 목적으로 상속을 사용하는 것을
구현 상속
이라고 부른다.다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것을
인터페이스 상속
이라고 부른다.상속은 구현 상속이 아니라 인터페이스 상속을 위해 사용해야 한다.
인터페이스와 다형성
- 앞에서는 DiscountPolicy를 추상 클래스로 구현함으로써 자식 클래스들이 인터페이스와 내부 구현을 함께 상속받도록 만들었지만, 종종 구현은 공유할 필요가 없고 순수 인터페이스만 공유하고 싶을 때가 있다.
- 그래서
인터페이스
라는 프로그래밍 요소를 제공한다.
추상화와 유연성
추상화의 힘
추상화를 사용할 경우의 두 가지 장점
- 추상화의 계층만 따로 떼어 놓고 보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.
- 추상화를 이용하면 설계가 좀 더 유연해진다.
추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다.
세부사항에 억눌리지 않고 상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 한다.
추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것을 의미.
재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용하고 있다.
추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다. ⇒
설계를 유연하게 만들 수 있다.
유연한 설계
책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다.
항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하자.
할인 정책이 없는 경우
기존에는 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy가 아닌 Movie쪽에 있었다.
일관성을 지킬 수 있는 방법은 0원이라는 할인 요금을 계산할 책임을 그대로 DiscountPolicy 계층에 유지시키는 것이다. NonDiscountPolicy 클래스를 추가해주면 된다.
public class NoneDiscountPolicy extends DiscountPolicy { @Override protected Money getDiscountAmount (Screening screening) { return Money.ZERO; } }
새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다.
추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계를 만들 수 있다.
추상화가 유연한 설계를 가능하게 하는 이유 ⇒ 설계가 구체적인 상황에 결합되는 것을 방지하기 때문.
Movie는 특정한 할인 정책에 묶이지 않음. DiscountPolicy 역시 특정한 할인 조건에 묶여있지 않음. DiscountCondition을 상속받은어떤 클래스와도 협력이 가능함. ⇒ DiscountPolicy와 DiscountCondition이 추상적이기 때문에 가능. ⇒
컨텍스트 독립성
유연성이 필요한 곳에 추상화를 사용하자.
추상 클래스와 인터페이스 트레이드오프
NoneDiscountPolicy 클래스에서 getDiscountAmount() 메서드가 어떤 값을 반환하더라도 상관이 없다.
부모 클래스인 DiscountPolicy에서 할인 조건이 없을 경우에는 getDiscountAmount() 메서드를 호출하지 않기 때문이다.
해결하는 방법은 DiscountPolicy를 인터페이스로 바꾸고 NoneDiscountPolicy가 DiscountPolicy의 method가 아닌 calculateDisscountAmount() 오퍼레이션을 오버라이딩 하도록 변경하면 된다.
public interface DiscountPolicy {
Money calculateDiscountAmount (Screening screeniing);
}
// 기존의 DiscountPolicy 클래스 이름 변경
public abstract class DefaultDiscountPolicy implements DiscountPolicy {
...
}
public class NoneDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscountAmount (Screening screening) {
return Money.ZERO;
}
}
코드 재사용
- 상속은 코드를 재사용하기 위해 널리 사용되는 방법.
- 코드 재사용을 위해서는 상속보다는
합성
이 더 좋은 방법이 될 수도 있다. - 합성: 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법
상속
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이지만, 두 가지 관점에서 설계에 안 좋은 영향을 미친다.
첫째,
상속이 캡슐화를 위반한다는 것
둘째,
설계를 유연하지 못하게 만든다는 것
상속의 가장 큰 문제점은 캡슐화를 위반한다는 것이다. 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다.
- AmountDiscountMovie와 PercentDiscountMovie를 구현하는 개발자 → 부모 클래스인 Movie의 calculateMovieFee 메서드 안에서 추상 메서드인 getDiscountAmount 메서드를 호출한다는 사실을 알고 있어야 한다.
결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.
캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다.
두 번째 단점은 설계가 유연하지 않다는 것.
상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정 ⇒
실행 시점에 객체의 종류를 변경하는 것이 불가능함.
반면, 인스턴스 변수로 연결한 기존 방법을 사용하면 실행 시점에 할인 정책을 간단하게 변경할 수 있다.
public class Movie {
private DiscountPolicy discountPolicy;
public void changeDiscountPolicy (DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
- 이를 통해 상속보다 인스턴스 변수로 관계를 연결한 원래의 설계가 더 유연하다는 사실을 알 수 있다.
합성
Movie는 요금을 계산하기 위해 DiscountPolicy의 코드를 재사용한다.
상속과 다른 점은 상속이 부모 클래스의 코드와 자식 크래스의 코드를 컴파일 시점에 하나의 단위로 결합하는 것에 비해 Movie가 DiscountPolicy의 인터페이스를 통해 약하게 결합된다는 것.
인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성
이라고 부른다.합성은 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다.
또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다.
상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다.
코드 재사용을 위해서는 상속보다는 합성을 선호하는 게 더 좋은 방법이 될 수 있다.
'개발서적' 카테고리의 다른 글
[오브젝트 - 코드로 이해하는 객체지향 설계] 1장. 객체, 설계 (2) | 2025.03.18 |
---|