객체 지향 프로그래밍

김민우·2024년 1월 9일
0

오브젝트 스터디

목록 보기
2/13

객체지향 프로그래밍을 하는 가장 큰 이유는 추후 유지보수 + 확장에 대비하기 위함이다. 단순히, 객체지향 언어를 사용한다고 이게 가능한 것은 아니며 여러 규칙들을 준수하여 설계를 하였을 때 원하는 목표를 이룰 수 있다.

지금부터 객체지향 프로그래밍을 구성하는 다양한 요소 및 구현 기법을 살펴보자.

객체와 클래스


객체지향 프로그래밍은 말 그대로 객체를 지향한다. 여기서 객체는 요구 사항, 프로그램, 도메인 등 다양하게 해석될 수 있다. 하지만, 객체와 클래스는 엄연히 다르다. 설계 시 어떤 클래스가 필요한지 고민하는건 잘못된 방식이다.

클래스란 공통적인 상태와 행동을 공유하는 객체를 추상화한 것이다. 따라서, 객체가 결정되어야 클래스를 설계할 수 있다.

객체지향적인 설계를 하기 위해선 다음 2가지를 기억하자.

  1. 어떤 클래스가 아닌 객체가 필요한지 생각하자. 클래스의 윤곽을 잡기 위해선 객체를 결정해야 한다.

  2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일환으로 보자.

객체들의 모양와 윤곽이 얼추 잡히면 공통된 특성/상태를 타입으로 분류하고 이를 기반으로 클래스로 구현하자. 훌륭한 협력이 훌륭한 객체를 낳고 훌륭한 클래스가 된다.

도메인

문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야, 해결하고자 하는 문제의 영역이다. 요구사항, 문제 영역 등이 이에 해당한다.
e.g. 쇼핑몰 플랫폼 : 게시글, 댓글, 결제, 정산 등...

앞서 OOP에서 객체를 다양한 관점으로 볼 수 있다고 했다. 이를 통해 도메인을 구성하는 개념들이 객체와 클래스로 매끄럽게 연결될 수 있다.


이처럼 클래스 사이의 관계도 최대한 도메인 사이의 관계와 유사해야 이해하기 쉽고 예상이 가능해진다.

클래스 설계

위처럼 도메인 구조를 반영한 적절한 클래스 구조를 어떻게 프로그래밍 언어로 표현하면 좋을까? 우선 접근 지정자(public, private) 을 통해 객체 내/외부를 구분지어야 한다.

객체의 중요한 2가지 사실을 살펴보자.

  • 상태와 행동을 함께 가지는 복합적인 존재
  • 스스로 판단하고 행동하는 자율적인 존재

자율적인 존재가 되기 위해선 스스로 판단을 해야한다. 스스로 판단한다는 것은 외부에서 객체가 어떤 상태인지, 어떤 생각을 하고 있는지 알면 안된다는 것이다.

외부에서 객체에게 원하는 것을 요청하고 객체 스스로 최선의 방법을 결정하리라 믿어야 한다.

따라서, 접근 지정자를 통해 객체 내부 접근을 막는 이유는 객체 스스로 상태를 관리함으로써 자율성을 부여하기 위함이다. 이러한 방식을 구현 은닉(implementaion hiding)이라 한다.

참고
이는 getter 지양 + 디미터 법칙과 연관된다.

협력하는 객체들의 공동체

영화 예매 시 요금을 Long 자료형으로 관리할 수 있다. 요금이란 상태는 다양한 클래스에 포진되있다. 그러나, 이는 요금이라는 것을 명확히 표현하지 못한다.

하나의 인스턴스 변수만 포함하더라도 다양한 곳에서 사용된다면 반드시 객체화를 하자. 전체적인 설계의 명확성과 유연성이 높아진다. 객체지향의 장점은 이렇겍 객체를 통해 도메인의 의미를 더욱 풍부하게 표현한다는 것이다.

참고
이렇게 단순히 값만 가지는 객체를 값 객체(Value Object)라 한다.

Money.java

public class Money implements Comparable<Money> {
    private static final long CACHE_INIT_START_VALUE = 0L;
    private static final long CACHE_SIZE = 500_000L;
    private static final int ZERO = 0;
    private static final Map<BigDecimal, Money> AMOUNT_CACHE = new ConcurrentHashMap<>();

    private final BigDecimal value;

    static {
        LongStream.range(CACHE_INIT_START_VALUE, CACHE_SIZE)
                .forEach(l -> {
                    final BigDecimal amount = BigDecimal.valueOf(l);
                    AMOUNT_CACHE.put(amount, new Money(amount));
                });
    }

    private Money(final BigDecimal value) {
        this.value = value;
    }

    public static Money of(final BigDecimal amount) {
        if (!AMOUNT_CACHE.containsKey(amount)) {
            AMOUNT_CACHE.put(amount, new Money(amount));
        }

        return AMOUNT_CACHE.get(amount);
    }

    public static Money zero() {
        return AMOUNT_CACHE.get(BigDecimal.ZERO);
    }

    public Money add(final Money money) {
        return Money.of(this.value.add(money.value));
    }

    public Money subtract(final Money money) {
        return Money.of(this.value.subtract(money.value));
    }

    public Money multiply(final Money money) {
        return Money.of(this.value.multiply(money.value));
    }

    public Money divide(final Money money) {
        return Money.of(this.value.divide(money.value, DECIMAL128));
    }

    public boolean isGreater(final Money money) {
        return this.compareTo(money) < ZERO;
    }

    public boolean isLess(final Money money) {
        return this.compareTo(money) > ZERO;
    }

    public boolean isPositive() {
        return this.compareTo(zero()) < ZERO;
    }

    public boolean isNegative() {
        return this.compareTo(zero()) > ZERO;
    }

    public boolean isZero() {
        return this.equals(zero());
    }

    public double getDoubleValue() {
        return value.doubleValue();
    }

    @Override
    public boolean equals(final Object object) {
        if (this == object)  {
            return true;
        }
        if (object == null || getClass() != object.getClass()) {
            return false;
        }

        final Money other = (Money) object;
        return Objects.equals(value, other.value);
    }

    @Override
    public int hashCode() {
        return Objects.hash(value);
    }

    @Override
    public String toString() {
        return value.toString();
    }

    @Override
    public int compareTo(final Money money) {
        return this.value.compareTo(money.value);
    }
}

협력


앞서 훌륭한 협력이 훌륭한 객체를 낳으므로 협력 내에서 어떤 객체가 필요한지를 결정해야 한다고 했다. 여기서 말하는 협력은 뭘까?

객체끼리 서로 상호작용을 하는 유일한 수단은 메시지를 전송/수신하는 것이다.

참고
여기서 말하는 메시지와 메서드(method)는 다르다. 메서드는 수신된 메시지를 처리하기 위한 객체 자신만의 방법이다.

설계에서 인터페이스란 객체가 이해할 수 있는 메시지 목록을 정의해놓은 것을 일컫는다.

Screening.java

public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    public Screening(Movie 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();
    }

    public Reservation reserve(Customer customer, int audienceCount) {
        return new Reservation(customer, this, calculateFee(audienceCount),
                audienceCount);
    }

    private Money calculateFee(int audienceCount) {
        return movie.calculateMovieFee(this).times(audienceCount);
    }
}

Reservation.java

public class Reservation {
    private Customer customer;
    private Screening Screening;
    private Money fee;
    private int audienceCount;

    public Reservation(Customer customer, Screening Screening, Money fee, int audienceCount) {
        this.customer = customer;
        this.Screening = Screening;
        this.fee = fee;
        this.audienceCount = audienceCount;
    }
}

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

메시지 vs 메서드

객체끼리 서로 상호작용을 하는 수단은 메시지를 전송/수신 하는 것이다. 수신된 메시지를 처리하기 위한 자신의 방법이 메서드다.

private Money calculateFee(int audienceCount) {
	return movie.calculateMovieFee(this).times(audienceCount);
}
  • caculateFee() : ScreeningMovie 에게 메시지를 전송한다. (o)
  • MoviecalculateMovieFee 메서드를 호출한다 (x)

외부에서 screening.caculateFee() 메서드 호출 시 ScreeningMoviecalculateMovieFee 메서드를 호출한다는 것을 모른다. Movie가 스스로 적절한 메서드를 선택하여 요청에 응답한다.

이처럼 객체가 메시지를 처리하는 방법을 자율적으로 결정할 수 있다.

추상화


1. 요구사항을 높은 수준에서 서술이 가능하다.

할인 요구사항을 한 문장으로 표현하면 다음과 같다.

영화 예매 요금은 최대 1개의 할인 정책과 여러 할인 조건을 이용해 계산한다.

이 문장은 금액 할인정책과 다양한 할인 조건을 포괄하는 문장이라 볼 수 있다. 실생활에서 우리는 모든 경우의 수를 일일히 나열하지 않고 위와 같이 하나의 포괄적인 문장으로 설명한다. 이처럼 추상화를 사용하면 세부적인 내용 대신 상위 개념으로 도메인 핵심 개념을 설명할 수 있다.

2. 설계가 유연해진다.

만약, 또 다른 할인 정책이나 조건이 추가된다면 어떨까? 지금처럼 추상화를 사용했다면 기존 코드를 수정하지 않고 새로운 클래스를 추가하는 것으로 기능이 확장된다.

이처럼 확장은 재사용성과 확장에 유리하다. 유연성이 필요하다면 반드시 추상화를 사용하자.

코드 재사용


기존 클래스에 작성된 코드를 다른 클래스에서 사용하려면 어떻게 할까? 일일히 작성한다면 중복 코드가 발생하므로 좋은 설계라할 수 없다. 이런 상황에 대표적으로 사용하는 방법은 상속과 합성이 있다.

아래 상황을 예로 들어 이 둘의 특징을 설명해보겠다.
e.g. MovieDiscountPolicy 코드가 필요하다면?

1. 상속을 활용

상속의 가장 큰 이점은 코드를 재사용하여 생산성을 높힌다는 것이다.


그러나, 내가 필요한 건 클래스의 하나의 메서드인데 이를 위해 해당 클래스의 모든 것을 부여받게 된다. 이는 부모 클래스 내부를 자유롭게 접근할 수 있다. 이처럼, 상속을 사용하면 자식 클래스가 부모 클래스 내부를 알게되어 캡슐화가 약해진다는 치명적인 단점이 존재한다. 또한, 실행 중 할인 정책이 바뀐다면 유연하게 대처하기가 어렵다.

이러한 이유로 상속은 코드를 재사용하기 적절하지 않다고 볼 수 있다.

2. 합성을 이용

합성(Composition)
다른 객체 인스턴스를 자신의 인스턴스 변수(필드)로 포함

재사용하려는 대상 클래스를 필드로 하여 이를 통해 메서드를 호출하는 방식이다.

Movie 입장에선 DiscountPolicy가 변경된다 사용하는 메서드가 변경되지 않는 이상 정상적으로 동작한다. 또한, DiscountPolicy가 이런 값을 준다(what)만 알지 어떻게(how) 주는진 알지 못한다.

인터페이스를 통한 합성을 사용하자

앞서 언급한 것처럼 합성을 이용하면 MovieDiscountPolicy가 어떤 식으로 동작하는지 알지 못한다.

이처럼 재사용하는 곳에서 기존 코드를 자세히 알 필요가 없다. 단지 이를 통해 메시지를 수신/응답 할 수 있으면 된다.

이렇게 인터페이스에 정의된 메시지로만 코드를 재사용하자. 인터페이스에 의존하므로 캡슐화가 가능하며 추후 교체도 비교적 쉬워 설계를 유연하게 만들어준다.

참고
이 실행 시점에 의존성이 추가되는 동적 바인딩(dynamic binding)을 통해 구현된다.

0개의 댓글