6 분 소요

객체지향 프로그래밍

협력, 객체, 클래스

대부분의 사람들은 클래스를 결정한 후에 클래스에 어떤 속성과 메소드가 필요한지 고민한다. 하지만 진정한 객체지향 패러다임으로서의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있다. 이를 위해서는 프로그래밍하는 동안 두 가지에 집중해야 한다.

  1. 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라. 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야 한다.
  2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체로 일원으로 봐라. 이는 설계를 유연하고 확장 가능하게 만든다. 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현하라.

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

문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인 이라고 부른다. 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있다. 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 한다. 클래스 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사하게 만들어서 프로그램의 구조를 이해하고 예상하기 쉽게 만들어야 한다.

클래스 구현

클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다. 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지를 결정하는 것. 그 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문이다. 더 중요한 이유로 프로그래머에게 구현의 자유를 제공하기 때문이다.

자율적인 객체

  • 객체는 상태와 행동을 함께 가지는 복합적인 존재이다.
  • 객체는 스스로 판단하고 행동하는 자율적인 존재이다.
    객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 한다. 이처럼 데이터와 기능을 객체 내부에 함께 묶는 것을 캡슐화 라고 부른다. 객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서다. 객체지향의 핵심은 스스로 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다. 객체가 자율적인 존재로 있기 위해 외부의 간섭을 최소화해야 한다.
    캡슐화와 접근 제어는 객체를 두 부분으로 나눈다. 외부에서 접근 가능한 부분으로 이를 퍼블릭 인터페이스라고 부르고 외부에서 접근이 불가능하고 내부에서만 접근 가능한 부분을 구현이라고 부른다. 인터페이스와 구현의 분리 원칙은 객체지향 프로그래밍의 핵심 원칙이다.
  • 어떤 메서드들이 서브클래스나 내부에서만 접근 가능해야 한다면 가시성을 protected or private 으로 지정한다.
  • 퍼블릭 인터페이스에서는 public으로 지정된 메서드만 포함한다.

프로그래머의 자유

  • 클래스 작성자 : 새로운 데이터 타입을 프로그램에 추가하는 사람
  • 클라이언트 프로그래머 : 클래스 작성자가 추가한 데이터 타입을 사용하는 사람
    클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 은닉해야 한다. 이를 통해 클래스 작성자는 클라이언트 프로그래머에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. 이를 구현 은닉이라고 부른다.
    클라이언트 프로그래머는 내부의 구현은 무시한 채 인터페이스만 알고 있어도 클래스를 사용할 수 있기 때문에 머릿속에 담아둬야 하는 지식의 양을 줄일 수 있다. 클래스 작성자는 인터페이스를 바꾸지 않는 한 외부에 미치는 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있고 변경의 폭이 넓어진다.
    ‘설계가 필요한 이유는 변경을 관리하기 위해서라는 것을 기억하자’ 객체지향 언어는 객체 사이의 의존성을 적절히 관리함으로써 변경에 대한 파급효과를 제어할 수 있는 다양한 방법을 제공한다. 객체의 변경을 관리할 수 있는 기법 중에서 가장 대표적인 것이 바로 접근 제어이다.

협력하는 객체들의 공동체

객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다. 따라서 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 사용하여 해당 개념을 구현하라. 그 개념이 비록 하나의 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것은 전체적인 설계의 명확성과 유연성을 높이는 첫걸음이다.
객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 매세지를 전송하는 것뿐이다. 다른 객체에게 요청이 도달할 때 해당 객체가 메시지를 수신했다고 이야기한다. 메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정한다. 이처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드라고 부른다. 메시지와 메서드를 구분하는 것은 매우 중요하다. 자세한 내용은 아래에서 차차 다루도록 하겠다.

상속과 다형성

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

만약 코드 상에서 A 클래스가 B 인터페이스 클래스에 의존한다고 하자 (컴파일 시간 의존성). B 인터페이스를 상속받은 자식 클래스로 C 클래스와 D 클래스가 있다. 코드에는 A 가 C 혹은 D 클래스에 의존하는 곳을 찾아볼 수 없다. 그러나 실행 시점에는 A의 인스턴스는 C 나 D 의 인스턴스에 의존한다. 이처럼 코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다. 다시 말해, 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다. 좋은 객체지향 설계가 가지는 특성은 코드의 의존성과 실행 시점의 의존성이 다르다는 것이다. 물론 이렇게 되면 코드를 이해하기 어려워진다. 코드를 이해하기 위해 객체를 생성하고 연결하는 부분을 찾아야 하기 때문이다. 하지만 각 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해진다. 이와 같은 의존성의 양면성은 설계가 트레이드오프의 산물이라는 사실을 잘 보여준다.

차이에 의한 프로그래밍

상속은 기존 클래스를 기반으로 새로운 클래스를 쉽고 빠르게 추가할 수 있는 간편한 방법을 제공한다. 상속을 이용하면 부모 클래스의 구현은 공유하면서도 행동이 다른 자식 클래스를 쉽게 추가할 수 있다. 이처럼 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍이라고 부른다.

상속과 인터페이스

인터페이스는 객체가 이해할 수 있는 메세지의 목록을 정의한다. 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하게 된다. 결과적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다. 메시지 발신 객체는 협력 객체가 메시지를 이해할 수만 있다면 그 객체가 어떤 클래스의 인스턴스인지는 상관하지 않는다. 자식 클래스는 부모 클래스를 대신해 발신자와 협력할 수 있다. 컴파일러는 코드 상에서 부모 클래스가 나오는 모든 장소에서 자식 클래스를 사용하는 것을 허용한다.

다형성

  • 다형성 : 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력
    위의 컴파일 시간 의존성과 실행 시간 의존성의 예를 이어서 설명하자면 A 클래스는 B 클래스에게 메시지를 전송하지만 실행 시점에 실제로 실행되는 메서드는 A 와 협력하는 객체의 실제 클래스가 무엇인지에 따라 달라진다. 다시 말해서 A 는 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인가는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라고 부른다. 다형성은 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다. 다형성을 구현하는 방법은 메시지와 메서드를 실행 시점에 바인딩한다는 것이다. 이를 지연 바인딩 또는 동적 바인딩 이라고 부른다. 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩 혹은 정적 바인딩 이라고 부른다.

추상화와 유연성

추상화

자식 클래스를 생략한 가장 추상적인 개념들로만 코드 구조를 그림으로 표현하면 추상화를 사용할 경우의 두 가지 장점을 보여준다.

  1. 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.
  2. 추상화를 이용하면 설계가 좀 더 유연해진다.
    추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것을 의미한다. 새로운 자식 클래스들은 추상화를 이용해서 정의한 상위의 협력 흐름을 그대로 따르게 된다. 이 개념은 매우 중요한데, 재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용하고 있기 떄문이다.

유연한 설계

책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다. 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하라. 추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계를 만들 수 있다.

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

결론만 말하겠다. 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있다. 모든 코드에는 합당한 이유가 있어야 한다. 비록 아주 사소한 결정이더라도 트레이드오프를 통해 얻어진 결론과 그렇지 않은 결론의 차이는 크다. 고민하고 트레이드오프하라!

코드 재사용, 상속과 합성

객체지향 설계에 코드 재사용을 위해서는 상속보다는 합성이 더 좋은 방법이다. 합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법을 말한다. 상속을 이용하게 되면 두가지 원칙을 위반한다.

  1. 캡슐화 위반 - 부모 클래스의 구현이 자식 클래스에게 노출된다. 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다.
  2. 설계의 비유연성 - 상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능하다.

합성

합성은 상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는 데 비해 부모 클래스가 부모 클래스를 통해 약하게 결합된다는 것이다 ( ex, A 클래스 <-> B 클래스 ). A 클래스는 B 클래스가 외부에 메소드를 제공한다는 사실만 알고 내부 구현에 대해서는 전혀 알지 못한다. 이처럼 인터페이스에 정의돈 메세지를 통해서만 코드를 재사용하는 방법이 합성이다. 합성은 상속의 두가지 문제를 모두 해결한다.

  1. 인터페이스에 정의된 메세지를 통해서만 재사용 가능 -> 캡슐화
  2. 의존하는 인스턴스를 교체하는 것이 비교적 쉬움 -> 코드의 유연성
    합성은 상속에 비해 메시지를 통해 느슨하게 결합된다. 따라서 코드 재사용성을 위해서는 합성을 선호하는 것이 좋은 방법이다. 하지만 중요한 점은, 대부분의 설계에서는 상속과 합성을 함께 사용해야 한다!! 다영성을 위해 인터페이스를 재사용하는 경우에는 상속고 합성을 함께 조합하여 사용할 수밖에 없다.

결론

객체지향 설계의 핵심은 적절한 협력을 식별하고 협력에 필요한 역할을 정의한 후에 역할을 수행할 수 있는 적절한 객체에게 적절한 책임을 할당하는 것이다.

업데이트: