GYUD-TECH

[스터디: 오브젝트] 객체지향 프로그래밍 본문

스터디

[스터디: 오브젝트] 객체지향 프로그래밍

GYUD 2024. 2. 5. 20:58

객체지향 프로그래밍

객체지향 프로그래밍을 위한 방법

이번 챕터에서는 영화 예매 시스템을 예로 들면서 객체지향 프로그래밍을 설계하는 방법과 필요한 개념들을 설명해준다.

 

영화 예매 시스템은 실제 내가 수행했던 프로젝트들과 요구사항이 비슷하여, 나라면 어떻게 했을까를 고민하며 책을 읽을 수 있어서 좋았다.

 

책에서 소개하는 객체지향 프로그래밍을 위한 순서는 아래와 같다.

1. 요구사항 분석
2. 도메인 모델을 바탕으로 필요한 객체들과 협력을 설계
3. 클래스로 객체들의 협력을 구현

 

앞부분의 내용들은 대부분 책 '객체지향의 사실과 오해'에 나오는 내용이었기 때문에 편하게 읽을 수 있었다.

 

책에서 소개하는 메시지, 캡슐화, 도메인과 같은 개념은 아래 글에 자세히 정리하였기 때문에 생략하고 다른 인상 깊었던 내용들을 위주로 블로그를 기록하였다. 

https://gyuwon-tech.tistory.com/24

 

[스터디: 객체지향의 사실과 오해] 추상화와 클래스

3. 타입과 추상화 객체의 분류 2장에서 객체는 행동, 상태, 식별자를 가진다고 이야기 했다. 식별자에 의해 모든 객체를 구분할 수 있지만, 이러한 구분작업은 목적에 따라서 필요한 작업일 수도

gyuwon-tech.tistory.com

https://gyuwon-tech.tistory.com/26

 

[스터디: 객체지향의 사실과 오해] 자율적인 객체

5. 책임과 메시지 자율적인 객체 자율적이라는 말은 자기 스스로의 원칙에 따라 일을 하거나 절제하는 성질이나 특성을 뜻한다. 이를 객체에 대입해보면 자신의 행동을 스스로 결정할 수 있는

gyuwon-tech.tistory.com

https://gyuwon-tech.tistory.com/27

 

[스터디: 객체지향의 사실과 오해] 도메인 모델과 유스케이스

기능 중심 vs 구조 중심 해운대에 가기 위해서 길을 알아봐야 한다면, 아래 2가지 방법을 사용할 수 있다. 부산에 사는 친구에게 해운대에 어떻게 가는지 물어본다. 부산 지도를 보고 해운대로 가

gyuwon-tech.tistory.com

 


원시 타입을 클래스로 바꾼다

영화예매 시스템을 구현하기 위해서 아래와 같이 도메인 모델을 설계하였다.

 

할인 조건에 부합하는 상영 시간대의 영화는 할인 정책이 적용되어 더 저렴한 가격으로 영화를 예매할 수 있다.

 

이러한 설계에서 지불해야하는 금액을 계산하는 부분은 Movie 객체와 PercentDiscountPolicy 객체가 있다.

  • DiscountPolicy 객체는 영화 금액을 받아서 퍼센트를 곱해서 할인금액을 계산해야하는 기능이 필요하다.
  • Movie 객체는 영화 가격에서 할인 금액을 빼서 최종 결제 금액을 계산하는 기능이 필요하다.

만약 내가 코드를 작성한다면, DiscountPolicy와 Movie 클래스에 long 타입 변수를 선언하고, 메서드에 값을 계산하는 로직을 작성하여 설계했을 것이다.

이렇게 설계를 한다면, 코드를 직관적으로 이해할 수 있다는 장점이 있긴 하지만 아래와 같은 단점도 존재한다.

  • 특정 객체에서는 long 타입으로 money를 다루고, 특정 객체에서는 double 타입으로 money를 다루면 이 둘이 협력하였을 때 문제가 발생할 수 있다.
  • 금액의 크기를 비교하는 등의 로직은 모두 똑같은 방식이지만, 이를 Movie 클래스와 Money 클래스에 중복해서 작성해야 한다.

 

이러한 점을 고려하여 책에서는 아래와 같이 Money 클래스를 따로 선언하여 Money 스스로 값을 제어하도록 하였다.

public class Money {
    public static final Money ZERO = money.wons(0);
    
    private final BigDecimal amount;
    
    public static Money wons(long amount) {
        return new Money(BigDecimal.valueOf(amount));
    }
    
    public static Money wons(double amount) {
        return new Money(BigDecimal.valueOf(amount));
    }
    
    Money(BigDecimal amount) {
        this.amount = amount;
    }
    
    public Money plus(Money amount){
        return new Money(this.amount.add(amount.amount));
    }
    
    ...
    
    public boolean isLessThan(Money other) {
        return amount.compareTo(other.amount) < 0;
    }
}

 

우테코 프리코스 2주차 자동차 경주문제를 풀 때에도 이 방식을 적용했었다.

 

자동차 객체가 필요하여 처음에는 Car 클래스 내부에 String 타입의 name 필드를 선언하였다.

 

하지만 Car 객체가 생성되기 이전에 자동차 name의 유효성을 먼저 검사하여, Car 객체의 생성 여부를 결정해야 했었고, 유효성 검사 로직이 복잡하여 Car 객체가 너무 길어지다는 문제가 있었다.

또한 자동차 이름은 생성시 한번만 지정되면 더이상 Car의 다른 메서드와는 따로 협력하지 않았고, 단순히 이름을 기억하고만 있으면 되는 상황이었다.

 

그래서 String 타입의 name 필드를 CarName 클래스로 분리하여 CarName만의 책임을 할당하여서 문제를 해결했다.

 

이렇게 원시타입 변수를 객체로 분리하는 것은 현실의 도메인 모델과는 거리가 멀어서 이해하기 어렵다는 단점이 있기 때문에 항상 정답은 아니지만, 이러한 설계방식도 있다는 것을 다시 한번 상기시킬 수 있었다.

 


객체는 스스로 실행할 메서드를 선택할 수 있다

책 '객체지향의 사실과 오해'를 읽으면서 수도 없이 등장했었던 말이다.

 

처음 이 문장을 읽었을 떄는 메서드를 스스로 선택한다는 것이 이해되지 않았다.

객체에 특정 메시지를 전달하면 이를 수행하기 위해 정의된 메서드를 실행하기 때문에 결국에는 외부에서 메서드를 선택하는 것이라고 생각했다.

 

그래서 이 내용을 인터페이스와 연관지어 생각하고 이와 관련하여 블로그를 작성하였다.

인터페이스에 메시지를 전달하면, 실행 시점에 구현체를 선택함으로써 어떤 메서드를 실행할 지 정할 수 있기 때문이다.

 

이렇게 메시지와 메서드를 컴파일 시점이 아닌 실행시점에 바인딩 하는 것을 동적바인딩 또는 지연 바인딩 이라고 한다.

 

'객체지향의 사실과 오해'를 읽을때는 정확하게 이 내용을 짚어주지 않았기 때문에 스스로 해석하고 받아들였다.

 

'오브젝트' 에서는 메서드를 스스로 선택하는 경우에 대해서 2가지 방법으로 자세히 설명해주어서 내용을 이해할 수 있었다.

 

1. 루비나 스몰토크 같은 동적타이핑 언어에서는 해당 메서드가 아닌 다른 시그니처를 가진 메서드로도 메시지에 응답할 수 있다.

2. 구현체가 아닌 인터페이스에 메시지를 전달하여 메서드를 선택할 수 있다.

 

두번째 방법이 스스로 생각했던 방법과 일치했기 때문에 저자의 설계방식과 비교하여 책을 읽었다.

Movie 객체는 DiscountPolicy를 구현한 세부 구현체가 아닌 추상 클래스 DiscountPolicy를 의존하고 있다.

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


public abstract class DiscountPolicy {
    
    private List<DiscountCondition> conditions = new ArrayList<>();
    
    public Money calculateDiscountAmount(Screening screening) {
        for (DiscountCondition each: conditions) {
            if (each.isSatisfiedBy(screening)) {
                return get DiscountAmount(screening);
            }
        }
        return Money.Zero;
    }
    
    abstract protected Money getDiscountAmount(Screening screening);
}

코드 상으로는 Movie 객체는 특정 구현체에 의존하지 않기 때문에 DiscountPolicy의 구현체를 스스로 선택할 수 있다.

 

이를 위해서는 Movie 객체 생성 시 특정 구현체를 지정하여 넣어줌 으로써 실행시점에 의존성을 지정할 수 있는 것이다.

Movie avatar = new Movie(..., new AmoundDiscountPolicy(Money.wons(800),...));

이렇게 코드의 의존성과 실행시점의 의존성을 다르게 설정할 수 있고, 이러한 방식으로 재사용이나 확장이 쉬운 코드를 작성할 수 있다.

 

하지만 이 방식은 코드를 이해하고 디버깅 하기에는 어렵다는 단점이 있다.

반대로 특정 구현체에 의존하도록 설계하면 코드를 이해하거나 디버깅은 편해지지만, 재사용성과 확장가능성이 낮아진다.

 

결국 정답은 없고, 상황에 맞게 잘 선택하는것이 좋은 개발자의 능력이라 생각한다.

 


추상클래스와 인터페이스

책에 있는 코드를 읽다보니, DiscountPolicy는 왜 추상 클래스로 선언했고, DiscountCondition은 왜 인터페이스로 선언했는지 궁금증이 생겼다.

 

추상 클래스와 인터페이스의 가장 큰 차이는 상속받는 내용의 차이이다.

인터페이스는 말 그대로 외부에서도 접근할 수 있는 인터페이스만을 상속 받을 수 있지만, 추상 클래스는 인터페이스 뿐만 아니라 구현까지 함께 상속받을 수 있다.

 

DiscountPolicy는 할인 정책을 적용하기 위한 List<DiscountConditions> conditions 와 calculateDiscountAmount() 메서드를 내부에 가지고 있기 때문에 abstract로 선언한 것이다.

물론 인터페이스에서도 필드나 메서드를 선언할 수 있지만, public 으로만 선언 가능하기 때문에 캡슐화를 지키기 위해서 추상 클래스를 사용한 것이라고 생각했다.

 

이렇게 슈퍼클래스에 기본적인 알고리즘의 흐름을 구현하고, 세부적인 처리는 서브클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 하기도 한다.

 

추가로 책에서는 상속의 목적에 대해서도 소개하는데 이는 '객체지향의 사실과 오해' 부록 부분에서 상속에 대한 내용을 공부하면서 상속과 합성을 비교했기 때문에 아래 정리한 내용을 같이 보면 좋을 것 같다.

 

https://gyuwon-tech.tistory.com/29

 

[스터디: 객체지향의 사실과 오해] 추상화기법, 상속, 합성

추상화 기법 추상화를 사용하는 이유는 도메인의 복잡성을 단순화하고, 직관적인 모델을 만들 수 있기 때문이다. 이러한 추상화의 기법에는 3가지가 있다. 분류와 인스턴스화 일반화와 특수화

gyuwon-tech.tistory.com

 

 

만약 할인정책이 적용되지 않는 영화가 있을 떄는 어떻게 할 수 있을까?

 

어차피 Movie 객체는 DiscountPolicy 객체를 참조하기 떄문에 단순하게 DiscountPolicy를 상속받는 NoneDiscountPolicy를 구현하고 연결해 주기만 하면 된다고 생각했다.

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


Movie starWars = new Movie(..., new NoneDiscountPolicy());

 

 

그런데 이 코드에는 한가지 문제가 있다.

아래와 같이 구현된 DiscountPolicy에서는 calculateDiscountAmount() 메서드가 호출되었을 때 getDiscountAmount 메서드는 호출되진 않는다.

conditions가 emptyList 이기 때문에 for 문 내부로 들어가지 않고 Money.ZERO를 리턴할 것이다.

public abstract class DiscountPolicy {
    private List<DiscountCOndition> conditions = new ArrayList<>();
    
    public DiscountPolicy(DiscountCondition ... conditions) {
        this.conditions = Arrays.asList(conditions);
    }
    
    public Money calculateDiscountAmount(Screening screening) {
        for (DiscountCondition each: conditions) {
            if (each.isSatisfyBy(screening)){
                return getDiscountAmount(screening);
            }
        }
        return Money.ZERO;
    }
    
    ...
}

 

이를 해결하는 방법은 기존의 DiscountPolicy를 DefaultDiscountPolicy로 변경하고, 상위에 DiscountPolicy라는 인터페이스를 추가하여 해결하면 된다.

 

public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}


public abstract class DefaultDiscountPolicy implements DiscountPolicy {
    ...
}


public class NoneDiscountPolicy implements DiscountPolicy {
    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

 

이렇게 설계하면 AmountDiscountPolicy 나 PercentDiscountPolicy는 DefaultDiscountPolicy에 구현된 calculateDiscountAmount()를 호출 할 것이고, NoneDiscountPolicy는 NoneDiscountPolicy에 구현된 calculateDiscountAmount() 를 호출하여 위의 문제를 해결할 수 있다.

 

이걸 보고, 어차피 결과는 똑같이 나오는데, NoneDiscountPolicy를 위해서 상위 인터페이스를 추가하는 것이 과하다고 생각할 수 있다.

책에서도 역시 트레이드 오프 관계이기 때문에, 상황에 맞게 선택하여 사용하면 된다고 소개한다.

 

하지만 나는 반드시 상위 인터페이스를 두는 두번째 방법을 따라야 한다고 생각한다.

첫번째 방법으로 코드를 작성한 후, 할인정책이 없을 때도 100원이 할인되는 것으로 변경된다고 가정하자.

 

기존 코드를 인수인계 받은 입장이라서 코드를 잘 모르고 수정한다면, 당연히 NoneDiscountPolicy의 return 값을 바꿀 것이다.

public class NoneDiscountPolicy extends DiscountPolicy {
    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return Money.ONE_HUNDRED;
    }
}

 

하지만 calculateDiscountAmount()는 절대로 호출되는 일이 없기 때문에 계속 Money.ZERO 를 리턴할 것이다.

 

아무리 값을 바꾸어봐도 계속 값이 잘못 나올 것이고, 이러한 오류는 코드의 오류가 아니라 설계의 오류이기 때문에 매우 찾기 어렵다.

 

결국 첫번째 방법으로 설계한다면, 변경에 용이하지 않은 코드를 사용하는 것이다.

 

이런 이유로 상황에 따라 선택한다는 저자의 생각에는 공감하지 못했다.

하지만, 상위 인터페이스를 만드는 방법은 기존에 생각하지 못한 방법이었기 때문에 재밌게 책을 읽을 수 있었다.

저자의 생각과 나의 생각을 비교해 보면서 많은 고민을 하게 해주는 좋은 책이라 다른 사람들도 꼭 읽어보면 좋을 것 같다.

 


참고자료

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-vs-%EC%B6%94%EC%83%81%ED%81%B4%EB%9E%98%EC%8A%A4-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

 

☕ 인터페이스 vs 추상클래스 용도 차이점 - 완벽 이해

인터페이스 vs 추상클래스 비교 이 글을 찾아보는 독자분들은 아마도 이미 인터페이스와 추상클래스 개념을 학습한 뒤에 이 둘에 대하여 차이의 모호함 때문에 방문 했겠지만, 그래도 다시한번

inpa.tistory.com