객체 지향

Chapter 2

절차 지향과 객체 지향

절차 지향

소프트웨어를 구현한다는 것은 데이터와 데이터를 조작하는 코드를 작성하는 것이다. 데이터를 조작하는 코드를 별도로 분리해서 함수나 프로시저와 같은 형태로 만들고, 각 프로시저가 데이터를 조작하는 방식으로 코드를 작성할 수 있을 것이다. 프로시저는 다른 프로시저를 사용할 수도 있고, 각각의 프로시저가 같은 데이터를 사용할 수도 있다. 이와 같이, 프로시저(Procedure)로 프로그램을 구성하는 기법절차 지향 프로그래밍이라고 부른다.

각 프로시저는 데이터를 사용해서 기능을 구현하며, 필요에 따라 다른 프로시저를 사용하기도 한다. 또한, 여러 프로시저가 동일한 데이터를 공유한다.

문제점 1. 데이터 중심적으로 구현을 하게 된다. 2. 데이터 타입이나 의미를 변경해야 할 때, 함께 수정해야 하는 프로시저가 증가한다. 3. 같은 데이터를 프로시저들이 서로 다른 의미로 사용하는 경우가 발생한다.

위와 같은 문제로 인해, 새로운 요구 사항이 생겨서 프로그램의 한 곳을 수정하게 되면 다른 곳에서 문제가 발생하고, 다시 그 곳을 수정하면 또 다른 곳에서 문제가 발생하는 악순환이 발생하기도 한다. 결과적으로 코드 수정을 어렵게 만들며, 새로운 기능을 추가하는데 많은 개발 비용을 투입하게 만든다.

객체 지향

객체 지향은 데이터 및 데이터와 관련된 프로시저를 객체(Object) 라는 단위로 묶는다. 객체는 프로시저를 실행하는데 필요한 만큼의 데이터를 가지며, 자율적인 객체들의 협력을 통해 애플리케이션을 구성한다.

객체 지향적으로 만든 코드에서는 객체의 데이터를 변경하더라도 해당 객체로만 변화가 집중되고 다른 객체에는 영향을 주지 않기 때문에, 요구 사항의 변화가 발생했을 때 쉽게 변경할 수 있는 장점을 갖는다.

객체 지향은 절차 지향에 반해 프로그램을 상대적으로 쉽게 수정할 수 있는 유연함을 제공하기 때문에, 변화된 요구 사항을 빠르게 반영할 수 있도록 만들어 준다.

객체

객체의 핵심은 기능을 제공하는 것

객체 지향의 기본은 객체 이다. 앞서 객체는 데이터와 메서드로 구성된다고 했지만, 이는 객체의 물리적인 특징이다. 실제로 객체 정의에 사용되는 것은 객체가 제공해야 할 기능이며, 객체가 내부적으로 어떤 데이터를 갖고 있는 지로는 정의되지 않는다.

인터페이스와 클래스

보통 객체가 제공하는 기능을 오퍼레이션 이라고 부른다. 즉, 객체는 오퍼레이션으로 정의된다. 결과적으로, 객체가 제공하는 기능을 사용한다는 것은 객체의 오퍼레이션을 사용한다는 의미가 된다.

오퍼레이션의 사용법은 시그니쳐(Signature)를 통해 알 수 있다.

  [시그니쳐 구성]

- 기능 식별 이름
- 파라미터 및 파라미터 타입
- 기능 실행 결과 값

인터페이스(Interface) 는 객체가 제공하는 모든 오퍼레이션 집합이고, 타입(Type) 은 서로 다른 인터페이스를 구분하는 기준이다. 즉, 인터페이스는 객체에 대한 일종의 명세 및 규칙이다. 인터페이스는 객체가 제공하는 기능에 대한 명세서일 뿐, 실제 객체가 기능을 어떻게 구현하고 있는 지에 대한 내용은 포함하고 있지 않다.

개념적으로 인터페이스와 클래스는 구분되어 있지만, 실제로 Java, C# 등의 언어는 인터페이스와 클래스가 각 언어에서 클래스라고 부르는 것에 함께 정의되어 있다.

메시지

오퍼레이션의 실행을 요청하는 것을 "메시지를 보낸다"고 표현한다.

객체의 책임과 크기

객체는 자신만의 책임(Responsibility) 를 갖으며, 책임을 정의한 것이 타입/인터페이스 이다. 기능을 어떻게 객체들에게 분배하느냐(책임 분배)에 따라서 객체의 구성이 달라진다.

모든 상황에 들어맞는 객체-책임 구성 규칙은 존재하지 않지만, 객체가 얼마나 많은 기능을 제공할 것인가에 대한 확실한 규칙은 존재한다. 객체가 갖는 책임의 크기(제공하는 기능의 개수)는 작을 수록 좋다는 것이다.

객체가 갖는 책임이 커질수록 절차 지향적으로 구조가 변질되며, 절차 지향의 가장 큰 단점인 기능 변경의 어려움 문제가 발생한다. 따라서 객체가 갖는 책임의 크기는 작아질수록 객체 지향의 장점인 변경의 유연함을 얻을 수 있다. 이와 같이 객체 책임 크기에 대한 원칙을 단일 책임 원칙(Single Responsibility Principle; SRP) 고 부른다. 이는 이름에서도 알 수 있듯이 객체는 단 하나의 책임만을 가져야 한다는 원칙 이다. 단일 책임 원칙을 따르다 보면 자연스럽게 변경해야 할 부분이 한 곳으로 집중되어 변경의 유연함을 얻을 수 있다.

객체가 책임을 갖는다는 것은 객체가 역할(Role) 을 수행한다는 의미를 갖는다.

의존

객체를 생성하든 메서드를 호출하든 또는 파라미터로 전달받든 다른 타입에 의존을 한다는 것은 의존하는 타입에 변경이 발생할 때 나도 함께 변경될 가능성이 높다는 것을 뜻한다. 의존의 이런 특징 때문에 의존이 순환해서 발생할 경우 다른 방법이 없는지 고민해야 한다. 순환 의존이 발생하지 않도록 하는 원칙 중의 하나로 의존 역전 원칙(Dependency Inversion Principle; DIP) 이다.

의존의 양면성

의존은 상호간에 영향을 줄 수 있다.

  • 내가 변경되면 나에게 의존하고 있는 코드에 영향을 준다.

  • 나의 요구가 변경되면 내가 의존하고 있는 타입에 영향을 준다.

캡슐화

객체 지향의 장점은 한 곳의 구현 변경이 다른 곳에 변경을 가하지 않도록 해준다 데 있다. 즉, 수정을 좀 더 원활하게 할 수 있게 된다. 객체 지향은 기본적으로 캡슐화 를 통해서 한 곳의 변화가 다른 곳에 미치는 영향을 최소화한다.

캡슐화(Encapsulation)는 객체가 내부적으로 기능을 어떻게 구현하는지를 감추는 것 이다. 이를 통해 내부의 기능 구현이 변경되더라도 그 기능을 사용하는 코드는 영향을 받지 않도록 만들어 준다. 즉, 내부 구현 변경의 유연함을 주는 기법 이 바로 캡슐화이다.

절차 지향 방식 코드

절차 지향 방식은 데이터를 직접적으로 사용하기 때문에 데이터의 구조나 쓰임새가 변경되면 이로 인해 데이터를 사용하는 코드들도 연쇄적으로 수정이 발생한다.

캡슐화된 기능 구현

객체 지향 방식으로 기능 구현을 내부적으로 캡슐화하게 되면 구조나 쓰임새가 변경되더라도 기능을 사용하는 코드에는 영향을 미치지 않는다.

캡슐화의 결과는 내부 구현 변경의 유연성 획득

기능 구현을 캡슐화하면 내부 구현이 변경되더라도, 기능을 사용하는 곳의 영향을 최소화할 수 있다. 이는 캡슐화를 통해서 내부 기능 구현 변경의 유연함을 얻을 수 있다는 것을 의미한다.

캡슐화를 위한 두 개의 규칙

  • Tell, Don't Ask

  • 데미테르의 법칙(Law of Demeter)

"Tell, Don't Ask" 규칙은 데이터를 물어보지 않고, 기능을 실행해 달라고 말하라는 규칙이다. 기능 실행을 요청(메시지)하는 방식으로 코드를 작성하다 보면, 자연스럽게 해당 기능을 어떻게 구현했는지 여부가 감춰진다.

"데미테르의 법칙(Law of Demeter)" 은 "Tell, Don't Ask" 규칙을 따를 수 있도록 만들어 주는 또 다른 규칙이다. 데미테르의 법칙은 다음과 같이 간단한 규칙으로 구성된다.

  • 메서드에서 생성한 객체의 메서드만 호출

  • 파라미터로 받은 객체의 메서드만 호출

  • 필드로 참조하는 객체의 메서드만 호출

이는 데이터 중심이 아닌 기능 중심으로 코드를 작성하도록 유도하기 때문에, 기능 구현의 캡슐화를 향상시켜 준다.

신문 배달부와 지갑 예시

/**
 * 
 * 신문 배달부가 고객에게 요금을 받아 가는 상황을 코드로 작성
 *
 */

/**
 * 고객
 */
class Customer {
    private Wallet wallet;

    public Wallet getWallet() {
        return wallet;

    }

}

/**
 * 지갑
 */
class Wallet {
    private int money;

    public int getTotalMoney() {
        return money;

    }

    public void subtractMomey(int debit) {
        money -= debit;

    }

}

/**
 * 신문 배달부
 * 데미테르의 법칙을 어기고 있다.
 * Paperboy 입장에서 customer 객체의 메서드만 사용해야 하는데
 * customer 객체를 통해서 가져온 wallet 객체의 메서드를 호출하고 있다.
 * 
 */
class Paperboy {
    public void takeCharge(Customer customer) {
        int payment = 10000;
        Wallet wallet = customer.getWallet();
        if(wallet.getTotalMoney() >= payment) {
            wallet.subtractMomey(payment);

        } else {
            // 다음에 요금 받으러 오는 처리

        }

    }

}

이 코드 동작에는 문제가 없다. 하지만, 개념적으로 위 코드는 신문 배달부가 아래와 같은 방법으로 요금을 받아 가는 것이다.

  • 고객님 지갑 주세요: customer.getWallet()

  • 지갑에 돈이 있는지 확인합니다: wallet.getTotalMoney() >= payment

  • 지갑에서 돈을 빼나가겠습니다: wallet.subtractMoney(payment)

실제로 신문 배달부 입장에서는 고갤이 지갑을 가졌는지 또는 돈을 주머니에 보관하고 있는지 여부는 중요하지 않다. 단지 고객으로부터 요금을 받아 가기만 하면 된다.

조금 더 현실적으로 코드를 바꿔보자. 이제 신문 배달부가 직접 지갑을 뒤지지 않고 고객이 돈을 지불하는 방식으로 바꾼다.

/**
 * 고객
 */
class Customer {
    private Wallet wallet;

    public int getPayment(int payment) {
        if(wallet == null) throw new NotEnoughMoneyException();
        if(wallet.getTotalMoney() >= payment) {
            wallet.subtractMomey(payment);
            return payment;

        }

        throw new NotEnoughMoneyException();

    }

}

/**
 * 지갑
 */
class Wallet {
    private int money;
    public int getTotalMoney() {
        return money;

    }

    public void subtractMomey(int debit) {
        money -= debit;

    }

}

/**
 * 신문 배달부
 * 데미테르 법칙에 따라 customer 객체의 메서드만 호출하고 있다.
 * 데미테르 법칙을 지키기 위해 자연스럽게 Customer 객체의 getPayment() 메서드를 호출하는 것으로 변경하며
 * 비용 지불 기능에 대한 캡슐화를 향상되어 변경에 영향을 받지 않게 된다.
 * 
 */
class Paperboy {
    public void takeCharge(Customer customer) {
        int payment = 10000;
        try {
            int paidAmount = customer.getPayment(payment);
            ...

        } catch (NotEnoughMoneyException ex) {
            // 다음에 요금 받으러 오는 처리

        }

    }

}

데미테르 법칙을 지키지 않는 전형적인 두 가지 증상이 있다.

  • 연속된 get 메서드 호출

  • 임시 변수의 get 호출이 많음

연속된 get 메서드 호출은 아래와 같은 모양을 갖는다.

  value = someObject.getA().getB().getValue();

임시 변수에 할당된 객체의 get 메서드 호출이 많은 경우는 첫 번째 증상과 동일하지만, 코드가 흩어져 있을 경우 발견하기 어려울 수 있다.

  A a = someObject.getA();
  B b = a.getB();
  value = b.getValue();

두 가지의 증상을 보인다면 데미테르 법칙을 어기고 있을 가능이 높고, 이는 캡슐화를 약화시켜서 코드 변경을 어렵게 만드는 원인이 될 수 있다. 따라서, 데미테르 법칙을 어기느 코드를 적극적으로 캡슐화하도록 노력해야 한다.

객체 지향 설계 과정

객체 지향 설계 과정을 살펴보면 아래와 같다.

  1. 제공해야 할 기능을 찾고 또는 세분화하고, 그 기능을 알맞은 객체에 할당한다.

    A. 기능을 구현하는데 필요한 데이터를 객체에 추가한다. 객체에 데이터를 먼저 추가하고 그 데이터를 이용하는 기능을 넣을 수도 있다.

    B. 기능은 최대한 캡슐화해서 구현한다.

  2. 객체 간에 어떻게 메시지를 주고 받을 지 결정한다.

  3. 과정1과 과정2를 개발하는 동안 지속적으로 반복한다.

객체의 크기는 한 번에 완성되기 보다는 구현을 진행하는 과정에서 점진적으로 명확해진다. 이는 최초에 만든 설계가 완벽하지 않으며, 개발이 진행되면서 설계도 함께 변경된다는 것을 의미한다. 따라서 설계를 할때에는 변경되는 부분을 고려한 유연한 구조를 갖도록 노력해야 한다.

Last updated