본문 바로가기
Java

[오브젝트] 객체지향 프로그래밍

by hseong 2023. 7. 26.

0.

객체지향 스터디를 통해 조영호님의 오브젝트를 읽고 있습니다. 2장 객체지향 프로그래밍에 대해서 정리하며 문장 하나하나가 모두 중요한 내용이었고 꼭꼭 씹어삽켜야 하는 내용들이었습니다.

본 게시글에서는 스터디를 위해 정리한 내용을 기록합니다. 해당하는 챕터는 2장 객체지향 프로그래밍입니다. 오브젝트를 읽기전에 개구리책에 대한 스터디를 진행했었기에 해당 책에서 나온 용어가 일부 등장할 수 있으나 특별한 부분은 아닙니다.

게시글에서 사용되는 그림 자료는 오브젝트에서 가져왔으며 문제가 될 시 삭제하겠습니다.

1. 객체지향

객체지향의 본질은 말 그대로 객체를 지향하는 것입니다. 이를 위해 우리는 다음과 같은 방식으로 객체지향을 바라봐야 합니다.

  • 첫째, 어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민하라. 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것이다.
  • 둘째, 객체를 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.

2. 객체

우리는 도메인으로부터 요구사항을 도출할 것이다. 이것을 객체라는 동일한 관점에 바라보고 공통적인 상태와 행동을 뽑아내 클래스로 옮긴다.


이 때, 중요한 것은 클래스의 내부와 외부를 명확히 구분하는 것이다. 이를 통해 클래스간의 경계가 명확해지고 객체의 자율성을 보장할 수 있다. 근데 자율적인 객체라는 것은 또 무엇인가?

자율적인 객체

우리는 객체에 대해 중요한 두 가지 사실을 알아야 한다.

  • 첫째, 객체는 상태(state)행동(behavior) 을 함께 가지는 복합적인 존재이다.
  • 둘째, 객체는 스스로 판단하고 행동하는 자율적인 존재이다.

상태와 행동을 하나로 묶어 그 자체가 문제 영역의 아이디어를 나타내는 것을 캡슐화라 한다. 객체 지향 언어는 여기서 더 나아가 접근 제한자를 통해 이러한 데이터와 기능에 선택적인 접근 메커니즘을 제공한다.

이 모든 것은 객체를 자율적인 존재로 만들기 위함이다. 클라이언트가 객체에 대해 원하는 것을 요청하면 객체는 최선의 방법을 결정하여 응답을 내리는 자율적인 존재로 만드는 것이다. 그리고 그 과정에서 클라이언트는 객체의 상태, 생각, 결정 어떤 것에도 개입할 수 없도록 만드는 것이다.

이와 같이 클라이언트와 클래스의 경계를 명확히 구분하는 것은 설계에 있어서도 옳다. 다른 말로는 구현 은닉, 익숙한 말로는 정보 은닉이 되시겠다.

클라이언트는 객체에 메시지를 보내면 원하는 응답을 반환할 것이라고 믿는다.

객체는 클라이언트가 어떤 상황에서 메시지를 보내든 최선의 방법에 따라 응답을 반환해주기만 하면 된다.

따라서 구현의 은닉은 외부와 내부가 독립적으로 변경될 수 있음을 의미한다.

협력하는 객체

영화 예매 시스템에서 예매를 위해 Reservation, Movie, Screening 인스턴스들은 서로의 메서드를 호출하며 상호작용한다. 이처럼 어떤 기능을 구현하기 위해 객체들 사이에서 이뤄지는 상호작용을 협력(Collaboration)이라고 부른다.

클라이언트는 객체에게 특정한 행동을 수행할 것을 요청하는 메시지를 전송할 수 있다.

객체는 메시지를 수신하고, 메시지를 처리할 수 있는 최선의 방법을 선택한다. 이렇게 선택한 자신만의 방법을 메서드(method)라고 부른다.

메시지를 전송하는 클라이언트는 해당 메시지를 처리하는 방법, 메서드를 모른다. 그저 수신자인 객체가 이를 처리할 수 있다고 믿을 뿐이다.

public class Movie {
    ....
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

calculateMovieFee 메서드는 discountPolicy가 어떤 메서드를 지니고 있는지 모른다. 그저 calculateDiscountAmount라는 메시지를 전송하면 할인 요금을 반환할 것이라고 믿을 뿐이다.

3. 캡…상추다!

오브젝트의 1장에서는 통해 캡슐화를 통해서 객체들간의 의존성을 줄이고 변경의 영향을 최소화 할 수 있음을 배웠다.

그리고 2장의 전반부에서 객체 지향 언어의 접근 제한자를 통해 외부에 공개되는 public 인터페이스를 정의하고 경계를 명확히 나눔으로써 자율적인 객체, 외부와 독립적으로 변경될 수 있는 객체를 만들 수 있음을 배웠다.

객체들끼리 메시지를 주고 받고, 메시지를 처리하기 위한 최선의 방법을 결정하는 과정은 상속다형성에 밀접한 연관이 있다.

컴파일 타임 의존성과 런타임 의존성

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

Movie는 추상적인 DiscountPolicy에만 의존하고 있다. 따라서 컴파일 타임에는 DiscountPolicy가 어떤 객체에 의존하는지 전혀 알 수 없다.

그러나 런타임에는 Movie가 클라이언트로부터 전달받은 인자에 따라 Amount 또는 Precent 인스턴스에 의존하게 된다.

public Money calculateMovieFee(Screening screening) {
    return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}

Movie에게 중요한 사실은 discountPolicycalculateDiscountAmount라는 메시지를 수신할 수 있다는 사실이다. 이는 DiscountPolicy상속(inheritance) 한 자식 클래스가 모두 calculateDiscountAmount라는 메시지를 수신할 수 있기 때문에 Movie는 어떤 할인 정책이 오든 협력이 가능해진다.

그리고 해당 메시지를 처리하는 메서드가 결정되는 것은 메시지를 수신하는 객체의 클래스에 따라 달라지며 이를 다형성(polymorphism) 이라 한다.

상속과 다형성

다형성은 컴파일 타임과 런타임 의존성이 다를 수 있다는 사실을 기반으로 한다. 이를 이용해 서로 다른 메서드를 실행할 수 있는 것이다.

또한, 다형성이란 동일한 메시지를 수신했을 때 수신한 객체에 따라 다르게 응답할 수 있는 능력이다. 그렇기에 다형적인 협력에 함여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다. AmountPercent가 다형적 협력에 참여할 수 있는 이유가 DiscountPolicy로부터 동일한 인터페이스를 물려 받았기 때문이다. 즉, 두 클래스의 인터페이스를 통일하게 사용한 구현 방법이 상속인 것이다.

이 처럼 상속과 다형성은 서로 땔래야 땔 수 없는 관계이다. 상속은 구현의 재사용이 아닌 다형적 협력을 위한 인터페이스의 재사용이 그 목적이 되어야 한다.

추상화

그럼 객체 지향의 남은 특성인 추상화, 추상적이다라는 것이 뜻하는 바는 무엇인가?

DiscountPolicy는 모든 할인 정책들이 수신할 수 있는 calculateDiscountAmount 메시지를 정의한다.

DiscountCondition은 모든 할인 조건들이 수신할 수 있는 isSatisfiedBy 메시지를 정의한다.

부모는 자식이 공통으로 가질 수 있는 인터페이스를 정의하고 자식 클래스가 그 구현을 결정한다.

이 처럼 추상적인 것은 인터페이스 그 자체에 초점을 맞추는 것이다.

추상화를 통해 높은 수준의 정책을 서술하여 고정 금액을 할인하든, 일정 비율로 할인하든 세부사항에 대한 것을 신경쓰지 않고 할인이라는 도메인 중요한 개념 그 자체를 설명할 수 있게 한다.

또한, 구체적인 상황과 결합되지 않음으로써 자유롭게 세부사항을 변경할 수 있다. 할인 정책이라는 DiscountPolicy를 상속하기만 했으면 어떤 클래스와 협력이 가능하다.

4. 그러자 트레이드오프가 있었다.

객체지향 설계를 위한 과정은 트레이드오프에 대한 고민이다.

책에서는 할인정책없음을 구현하여 이에 대해 잘 설명해준다.

public Money calculateDiscountAmount(Screening screening) {
    for (DiscountCondition each : conditions) {
        if (each.isSatisfiedBy(screening)) {
            return getDiscountAmount(screening);
        }
    }

    return Money.ZERO;
}

public class NoneDiscountPolicy extends DiscountPolicy {
    @Override
    protected Money getDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

할인정책없음할인 조건에 해당하지 않는 경우 0원을 반환한다. 라는 부모 클래스의 개념과 결합되어 있다. 이러한 개념적인 결합을 줄이고 객체지향적인 설계를 위해 한 걸음 더 나아갈 수도 있다.

별개로 나의 트레이드오프도 보여주겠다.

다음은 데브코스에서 바우처 관리 애플리케이션을 만들며 객체지향적으로 작성하기 위해 고민한 흔적이다. 가장 처음의 VoucherDtofrom과 두 번째 VoucherDtofrom부터 이어지는 코드들은 동일한 응답을 반환하기 위한 서로 다른 방식이다.

instanceof 사용

public class VoucherDto {
    private final UUID voucherId;
    private final VoucherType voucherType;
    private final long amount;

    ...
    public static VoucherDto from(Voucher voucher) {
        if (voucher instanceof FixedAmountVoucher) {
            return new VoucherDto(voucher.getId(), VoucherType.FIXED, voucher.getAmount());
        } else if (voucher instanceof PercentAmountVoucher) {
            return new VoucherDto(voucher.getId(), VoucherType.PERCENT, voucher.getAmount());
        }
        throw new IllegalStateException();
    }
    ...
}

visitor 패턴 사용

public class VoucherDto {
    private final UUID voucherId;
    private final VoucherType voucherType;
    private final long amount;

    ...
    public static VoucherDto from(Voucher voucher) {
        VoucherDtoConverter voucherDtoConverter = new VoucherDtoConverter();
        return voucherDtoConverter.convert(voucher);
    }
    ...
}
public interface VoucherVisitor {
    void visit(FixedAmountVoucher voucher);
    void visit(PercentDiscountVoucher voucher);
}
public class VoucherDtoConverter implements VoucherVisitor {
    private VoucherDto voucherDto;

    public VoucherDto convert(Voucher voucher) {
        voucher.accept(this);
        return voucherDto;
    }

    @Override
    public void visit(FixedAmountVoucher voucher) {
        voucherDto = new VoucherDto(
                voucher.getVoucherId(),
                VoucherType.FIXED_AMOUNT,
                voucher.getAmount(),
                voucher.getCreatedAt());
    }

    ...
}
public class FixedAmountVoucher extends Voucher {
    ...
    @Override
    public void accept(VoucherVisitor visitor) {
        visitor.visit(this);
    }
        ...
}

첫 번째 VoucherDto에서는 instanceof를 이용하여 Voucher의 타입을 구분해서 적절한 VoucherDto를 생성하고 반환해준다.

두 번째 VoucherDto에서는 visitor 패턴을 이용하여 적절한 VoucherDto을 생성하여 반환해준다.

지금에야 눈에 익어서 괜찮지만 처음 visitor 패턴을 적용해봤을 때는 한눈에 들어오지 않았고 너무 어렵게 느껴졌다. 더하여 추가적인 관리지점 또한 늘어났다.

visitor 인터페이스, visitor 구현체, visitor 패턴을 위한 Voucher의 추상 메서드와 그것들을 오버라이딩한 자식 클래스들의 메서드까지... VoucherDto 하나를 만들어주기 위한 것치고는 복잡하게 느껴진다.

책에서는 객체지향과 트렌이드오프에 대해서 다음과 같이 설명한다.

유연하고, 재사용 가능하고, 확장 가능한 객체지향 설계를 추구하는 것은 객체 사이의 연결점을 찾는데 많은 시간이 소요될 수 있다는 단점을 안고 있다.

설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 어려워진다. 유연성을 억제한다면 가독성은 올라가지면 재사용과 확장 가능성은 낮아진다.

우리는 우리가 구현한 모든 것들이 트레이드오프의 대상이 될 수 있다는 사실에 대해 인지하고 있어야 한다. 우리가 작성하는 모든 코드에는 합당한 이유가 있어야 하고 충분한 고민을 통해 객체지향을 선택할 것인지, 가독성을 선택할 것인지 결정해야 한다.

5. 그리고 합성만이 남았다.

상속은 단순히 코드의 재사용을 위해서 사용되서는 안 된다. 일반적으로 코드의 재사용을 위해서는 합성(composition) 이 더 선호되는 방법이다. 어째서인가?

상속을 통한 코드의 재사용은 부모-자식간의 캡슐화를 약화시킨다.

자식은 부모의 구현에 대해 낱낱이 알고 있어야 한다. calculateMovieFeegetDiscountAmount 메서드를 호출한다는 사실을 알고 있어야 한다. 이러한 부모-자식간의 결합으로 인해 부모가 변경되면 자식 또한 변경될 가능성을 높인다.

또한, 실행 시점에 유연한 교체도 불가능하다. 런타임에 할인 정책을 변경하기 위해서는 AmountDiscountMovie 상태를 PercentDiscountMovie로 복사하는 것 말고는 방법이 없다.

반면 DiscountPolicy를 인스턴스 변수로 포함하게 되면 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하게 된다. 상속과는 달리 약하게 결합되는 것이다.

또한 구현을 캡슐화함으로써 의존하는 인스턴스를 교체하는 것이 비교적 쉽다.

6. 그래서 객체지향이란?

객체지향이란 객체를 지향하는 것이다.

객체지향 패러다임의 중심에는 객체가 위치하며 애플리케이션의 기능을 구현하기 위해 협력에 참여하는 객체들 사이에서 상호작용이 이루어진다. 객체들은 협력에 참여하기 위해 역할을 부여받고 역할에 적합한 책임을 수행한다.

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

참조

조영호님의 오브젝트 2장 객체지향 프로그래밍

모든 그림 자료는 해당 서적으로부터 발췌하였습니다. 문제가 될시 삭제하겠습니다.

'Java' 카테고리의 다른 글

Java의 데이터 입출력 스트림  (0) 2024.04.24
함수형 인터페이스와 람다 표현식  (0) 2023.06.18
문자열  (0) 2023.04.20