Chapter 2. 객체지향 프로그래밍

제목처럼 챕터 2에서는 객체지향 프로그래밍에 대해 이야기한다

협력, 객체, 클래스

객체지향이란 말 그대로 객체를 지향하는 것이다.

그리고 객체지향에 익숙한 사람이라면 클래스에 대해 고민할 것이고 어떤 속성과 메서드가 들어갈 것인가를 고민할 것이다.

하지만 이건 객체지향의 본질과는 거리가 멀다.

위에 말한 것처럼 객체지향은 객체를 지향하는 것이다.

진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 개체에 초점을 맞춰야 얻을 수 있다.

그러기 위해서는 우리는 두 가지에 집중해야 한다.

  1. 어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민하기

    • 클래스는 공통적인 상태와 행동을 공유하는 것을 추상화한 것.
    • 클래스의 윤곽을 잡기 위해서 어떤 객체들이 어떤 상태와 행동을 가지는지 결정할 것.
  2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 보기.

    • 객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만든다.
    • 객체지향적으로 생각하고 싶다면 객체를 고립된 존재로 바라보지 말고 협력에 참여하는 협력자로 봐야 한다.
    • 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현한다.

이렇게 객체를 중심에 두는 접근 방법을 설계를 단순하고 깔끔하게 만들며 훌륭한 협력이 훌륭한 객체를 훌륭한 객체가 훌륭한 클래스를 만든다.

도메인의 구조를 따르는 프로그램 구조

소프트웨어는 사용자가 원하는 문제를 해결하기 위해 만들어지게 되는데 이 사용자가 프로그램을 사용하는 분야를 도메인이라 한다.

객체지향의 패러다임이 강력한 이유는 처음부터 끝까지 객체라는 동일한 추상화 기법을 사용할 수 있기 때문이다.

요구 사항과 프로그램을 객체라는 동일한 관점에서 볼 수 있기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있다.

일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 유사하게 지어야 하며 클래스 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사하게 만들어 구조를 이해하고 예상하기 쉽게 만들어야 한다.

클래스 구현하기

클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다.

클래스는 내부와 외부로 구분되며 훌륭한 클래스는 이 내부또는 외부의 기능을 공개하고 감출 것에 대한 결정이다.

그렇다면 왜 클래스의 내부와 외부를 구분해야 할까? 그 이유는 내부와 외부의 경계의 명확성이 객체의 자율성을 보장하고 우리에게도 구현의 자유를 제공하기 때문이다.

자율적인 객체

우리는 객체를 만들기 전에 두 가지를 알아야 한다.

  1. 객체가 상태와 행동을 함께 가지는 복합적인 존재라는 것.

  2. 객체가 스스로 판단하고 행동하는 자율적인 존재라는 것.

이 두 가지는 서로 깊이 연관돼 있다.

객체지향 이전의 패러다임에서는 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성했다.

하지만 객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 했다. (데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 한다.)

거기다 객체지향에는 외부에서의 접근을 통제하는 접근 제어도 제공하고 있다.

객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위함이고 그렇게 만들어진 자율적인 객체들을 공동체로 구성하는 것이다.

캡슐화와 접근 제어는 객체를 두 부분으로 나누다.

  1. 외부에서 접근 가능한 부분(퍼블릭 인터페이스, public interface)

  2. 외부에서는 접근 불가능하고 오직 내부에서만 접근이 가능한 부분(구현, implementiation)

인터페이스와 구현의 분리 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙이다.

일반적으로 객체의 상태는 숨기고 행동만 외부에 공개하며 어떠한 메서드들이 서브클래스나 내부에서만 접근 가능해야 한다면 protected나 private으로 지정해야 하며 이때 퍼블릭 인터페이스는 public으로 지정된 메서드만 포함된다.

그 밖의 private 메서드나 protected 메서드, 속성은 구현에 포함된다.

프로그래머의 자유

프로그래머의 역할을 클래스 작성자와 클라이언트 프로그래머로 구분하는 것이 유용하다.

클래스 작성자는 새로운 데이터 타입을 추가하고, 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용한다.

클래스 작성자는 필요한 부분만 공개하고 나머지는 꽁꽁 숨겨야 하며 클라이언트 프로그래머가 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 걱정하지 않고 내부 구현을 마음대로 변경할 수 있다.

이를 구현 은닉(implementation hiding)이라 한다.

이 구현 은닉은 클래스 작성자와 클라이언트 프로그래머 모두에게 유용한 개념이며 클라이언트 프로그래머에게 내부 구현은 신경 쓰지 않고 인터페이스만으로도 클래스를 사용할 수 있기 때문에 부담을 줄일 수 있다.

또한 인터페이스를 바꾸지 않는 한 외부에 미치는 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다.

그렇기 때문에 클래스를 개발할 때마다 인터페이스와 구현을 깔끔하게 분리하기 위해 노력해야 한다.

설계가 필요한 이유는 변경을 관리하기 위해서라는 것을 기억해야 한다.

협력하는 객체들의 공동체

객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다.

따라서 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 사용해서 해당 개념을 구현하자 비록 하나의 인스턴스 변수만 포함하더라고 개념을 명시적으로 표현하는 것은 전체적인 설계의 명확성과 유연성을 높여준다.

시스템의 어떤 기능을 구현하기 위해 객체들 사이에서 이뤄지는 상호작용을 협력이라 한다.

객체지향 프로그램을 작성할 때는 협력의 관점에서 어떤 객체가 필요한지를 결정하고 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다.

협력에 관한 짧은 이야기

객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메세지를 전송하는 것뿐이다.

다른 객체에게 요청이 도착할 때 해당 객체가 메세지를 수신했다고 한다.

메세지를 수신한 객체는 스스로 결정에 따라 자율적으로 메세지를 처리할 방법을 결정하며 이 메세지 처리를 위한 자신만의 방법을 메서드라고 한다.

메세지와 메서드를 구분하는 것은 매우 중요하며 객체지향 패러다임이 유연하고, 확장 가능하며, 재사용 가능한 설계를 낳는다는 명성을 얻게 된 배경에는 메시지와 메서드를 명확하게 구분한 것도 단단히 한몫한다.

이 두 가지를 구분하는 것에서 다형성의 개념이 출발한다.

협력 시작하기

객체지향에서 중요하다고 여겨지는 두 가지 개념이 있다.

  1. 상속(inheritance)
  2. 다형성

그리고 그 기반에는 추상화(abstraction)라는 원리가 숨겨져 있다.

..........................

디자인 패턴 TEMPLATE METHOD PATTERN[GOF94]

디자인패턴 - 템플릿 메소드 패턴

Design Patterns - Template Pattern

오버라이딩과 오버로딩

오버라이딩(overriding)

부모 클래스에 정의된 같은 이름, 같은 파라미터 목록을 가진 메서드를 자식 클래스에서 재정의하는 경우를 가리킨다.

자식 클래스의 메서드는 오버라이딩한 부모 클래스의 메서드를 가리기 때문에 외부에서는 부모 클래스의 메서드가 보이지 않는다.

오버로딩(overloading)

메서드의 이름은 같지만 제공되는 파라미터 목록이 다르다.

오버로딩한 메서드는 원래의 메서드를 가리지 않기 때문에 이 메서드들은 공존할 수 있다.

오버라이딩의 예제

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)));
    }
}

plus 메서드는 이름은 같지만 파라미터가 다르며 이 경우 두 메서드는 공존하며 외부에서는 둘 다 호출할 수 있다.

컴파일 시간 의존성과 실행 시간 의존성

어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말한다.

코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다. 서로 다를 수 있다는 것은 클래스 사이의 의존성과 객체 사이의 의존성이 동일하지 않을 수 있다는 것이다.

코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다.

하지만 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해진다.

설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 어려워진다.

유연성을 억제하면 코드를 이해하고 디버깅하는 것은 쉬워질 수 있지만 재사용 성과 확장 가능성은 낮아진다.

그렇기 때문에 우리는 항상 유연성과 가독성 사이에서 고민해야 할 필요가 있다.

차이에 의한 프로그래밍

상속은 객체지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법이다.

상속은 기존 클래스를 기반으로 새로운 클래스를 쉽고 빠르게 추가할 수 있는 간편한 방법을 제공한다.

또한 상속을 이용하면 부모 클래스의 구현은 공유하면서도 행동이 다른 자식 클래스를 쉽게 추가할 수 있다.

이렇게 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍(programming by difference)이라고 한다

상속과 인터페이스

상속이 가치 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.

대부분 사람들은 상속의 목적이 메서드나 인스턴스 변수를 재사용하는 것이라 생각하지만 인터페이스는 객체가 이해할 수 있는 메세지의 목록을 정의한다는 것을 기억해야한다.

자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다.

컴파일러는 코드 상에서 부모 클래스가 나오는 모든 장소에서 자식 클래스를 사용하는 것을 허용한다.

이처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting)이라 부른다.

upcasting downcasting upcasting-downcasting

다형성

동일한 메시지를 전송해도 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다.

이를 다형성이라고 한다.

다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 하며 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미한다.

따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다.

이 말은 인터페이스가 동일해야 하다는 것이고 자식 클래스가 다형적인 협력에 참여할 수 있는 이유는 이들이 부모 클래스로부터 동일한 인터페이스를 물려받았기 때문이다.

이 자식 클래스의 인터페이스를 통일하기 위해 사용한 구현 방법이 바로 상속이다.

다형성을 구현하는 방법은 매우 다양하지만 메시지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결한다는 공통점이 있다.

메시지와 메서드를 실행 시점에 바인딩한다는 것이다.

이를 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding)이라고 한다.

그에 반해 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩(early binding) 또는 정적 바인딩(static binding)이라고 한다.

객체지향이 컴파일 시점의 의존성과 실행 시점의 의존성을 분리하고 하나의 메시지를 선택적으로 서로 다른 메서드에 연결할 수 있는 이유가 바로 지연 바인딩이라는 메커니즘을 사용하기 때문이다.

하지만 클래스를 상속받는 것 만이 다형성을 구현할 수 있는 유일한 방법은 아니다.

인터페이스와 다형성

C#과 자바에서느 인터페이스라는 프로그래밍 요소를 제공한다.

자바의 인터페이스는 말 그대로 구현에 대한 고려 없이 다형적인 협력에 참여하는 클래스들이 공유 가능한 외부 인터페이스를 정의한 것이다.

동일한 인터페이스를 공유하는 클래스들은 다형적인 협력에 참여할 수 있다.

이 경우에도 업캐스팅이 적용되며 협력은 다형적이다.

추상화의 힘

추상화를 사용할 경우 두가지의 장점을 보여준다.

  1. 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.
  2. 추상화를 이용하면 설계가 좀 더 유연해진다.

추상화를 사용하면 세부적인 내용은 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다.

추상화의 이런 특징은 상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 한다.

추상화를 이용한 설계는 필요에 따라 표현의 수준을 조정하는 것을 가능하게 해준다.

추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것을 의미한다.

재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용하고 있기 때문이다.

추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다.(설계를 유연하게 만들수 있다)

유연한 설계

책임의 위치를 결정하기 위해 조건물을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다.

예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택해야 된다.

추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계를 만들 수 있다.

추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다.

유연성이 필요한 곳에 추상화를 사용하자.

추상 클래스와 인터페이스 트레이드오프

구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있다는 사실이다.

우리가 작성하는 모든 코드에는 합당한 이유가 있어야 한다.

아주 사소한 결정이더라도 트레이드오프를 통해 얻어진 결론과 그렇지 않은 결론 사이의 차이는 크다.

고민하고 트레이드오프하자.

코드 재사용

상속은 코드를 재사용하기 위해 널리 사용되는 방법이다 하지만 널리 사용되는 방법이라고 해서 가장 좋은 방법인 것은 아니다.

상속보다는 합성(composition)이 더 좋은 방법이 될 수 있다.

합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법을 말한다.

상속

상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이다.

하지만 두 가지 관점에서 설계에 안 좋은 영향을 미친다.

  1. 캡슐화를 위반한다.
  2. 설계를 유연하지 못하게 만든다.

상속의 가장 큰 문제점은 캡슐화를 위반하는 것이고 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다.

부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.

그렇게 되면 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다.

결과적으로 상속을 과도하게 사용한 코드는 변경하기도 어려워지게 된다.

상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능하다.

상속보다 인스턴스 변수로 관계를 연결한 설계가 더 유연하다.

합성

인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 한다.

합성을 사용하면 상속이 가지는 두 가지 문제점을 모두 해결할 수 있다.

인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다.

또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다.

상속은 클래스를 통해 강하게 결합되는데 비해 합성은 메시지를 통해 느슨하게 결합된다.

따라서 코드 재사용을 위해서는 상속보다는 합성을 선호하는 것이 더 좋은 방법이다.

그렇다고 해서 상속을 절대 사용하지 말라는 것은 아니고 상속과 합성을 함께 사용해야 한다.