객체지향 #1부 - 객체의 추상적 설계와 구현법

객체지향 #1부 - 객체의 추상적 설계와 구현법

이 글은 객체지향 프로그래밍의 근본적 개념을 통해 일상의 현실 세계를 코드로 어떻게 표현할 수 있는지 탐구한다. 객체의 상태, 행동, 식별자와 같은 기본 요소부터 좋은 객체지향 설계의 중심에 있어야할 변경 용이성과 의존성 관리, 그리고 다형성을 통한 유연한 구현까지, 객체지향의 핵심 원리와 이를 통한 현실적인 코드 설계 전략을 다룬다.

객체지향 프로그래밍은 일상의 현실 세계를 코드로 표현하는 강력한 도구입니다.
이 글에서는 객체지향 프로그래밍의 근본적인 개념과 좋은 객체지향 설계의 기반을 이해하고자 합니다.

객체의 세계

객체는 상태, 행동, 식별자를 가진 실체로, 이러한 객체들은 서로 협력하여 더 큰 기능을 이룹니다.
객체의 상태는 속성과 다른 객체를 참조하는 링크로 구성되며, 이를 프로퍼티라고 통칭합니다.
객체는 의인화를 통해 스스로의 상태를 관리하며, 외부의 객체가 이 상태를 변경하거나 조회하게 만듭니다.

💡중간 용어 정리
의인화 : 현실의 객체보다 더 많은 일을 할 수 있는 소프트웨어 객체의 특징이다. 객체는 자신의 행동을 통해 상태를 스스로 결정할 수 있다.


객체지향 패러다임의 핵심

오브젝트라는 책은 이러한 객체를 기반으로 객체지향 패러다임을 설명합니다.

패러다임이란 한 시대의 사람들의 견해나 사고를 근본적으로 규정하고 있는 인식의 체계 또는 사물에 대한 이론적인 틀이나 체계. 다른 말로는 틀이다.

프로그래밍에서의 패러다임은 과학에서의 파괴적 의미와 다르게 과거로부터 발전되었으며, 상호 보완적인 학습 패턴을 형성하게 됩니다.
기본적인 틀이 제공된다면 맨 땅에서 시작하는 것과 다르게 필요에 따라 틀을 다듬고, 내용물을 손보며 상호 합의된 작업,학습 흐름을 만들기 편하게 됩니다.
이른바 합의된 개념 을 공부하기 쉬워집니다.

지금부터 오브젝트로 객체지향적 개발 방식의 틀을 흡수하여 온고지신하려 합니다.


소프트웨어 모듈의 목적 3가지

클린 시리즈로 유명한 로버트 마틴은 소프트웨어 모듈의 목적을 다음과 같이 정의합니다.

  1. 실행 중에 제대로 동작 한다.
  2. 변경에 쉬워야 한다.
  3. 이해하기 쉬워야 한다.

책을 읽으며 느낀 점은 과거에 자신이 절차지향적 발상이 뿌리깊게 박혀있다는 점입니다.
1번에 주된 목적을 두고 작동 흐름에 초점을 맞춰 개발을 하였습니다.
이렇게 되면 변경 용이성읽는 사람과의 의사소통이라는 목적을 만족시키지 못합니다.

객체지향에서 객체는 스스로 살아있는 생명체와 같습니다.
물병에 담긴 물은 다른 객체가 자신을 마시는 행위를 하면 스스로 양을 줄입니다.
가방은 스스로 자신의 내부에 무슨 물건이 있는지 체크를 해줍니다.

  • 절차 지향 : 기능 진행 프로세스와 데이터를 분리하여 설계하는 방식
  • 객체 지향 : 데이터와 기능을 하나의 객체로 묶어서 설계하는 방식

Q : 그렇다면 변경에 용이한 코드란?

의존성과 관련된 문제입니다. 의존성은 변경에 대한 영향의 정도로 정의할 수 있습니다.
어떤 객체가 변경될 경우, 그 객체를 바라보며 의존하고 있던 다른 객체들도 함께 변경됩니다.
그렇다고 의존성이 아예 없을 수록 좋은 건 아닙니다. 적절한 설계로 객체끼리 협력하는데 필요합니다.
의존성이 과하면 결합도가 높다고 합니다. 반대로 결합도가 낮다는건 0에 수렴이 아니라 의존성이 적절하다는 의미입니다.
이는 특정 기능을 구현할 때 역할별로 객체를 잘 배정하고, 책임을 적절히 분산시키면 자연스래 변경이 쉬워집니다.

목표는 객체 사이의 결합도를 적절히 낮춰 변경에 용이한 설계를 만드는 것이다.

Q : 예상 가능한 코드란?

가독성이 좋고 말이 되어야 합니다.
코드를 보고 어떤 기능인지 구두로 설명이 가능한 수준이면 좋다고 생각합니다.
예전 MVC 구조에서 Service에 비즈니스 로직을 몰빵하고 여러 엔티티의 Repository를 남용했다면,
비즈니스 로직의 어떤 목적을 위해 DB에서 가져왔으면 좋겠는지를 담기 위해 Reader, Writer 라는 Wrapper Class로 감싸서
가독성을 쉽게 해주는 식의 개선으로 해결하려 합니다.

보통 구구절절 코드가 늘어져서 구두로 설명하기 어려워 진다면, 지금 객체의 책임이 너무 과중한건 아닌지 체크해봐야 합니다.

💡중간 용어 정리

  • 의존성 : 변경에 대한 영향의 정도
  • 결합도 : 객체끼리 의존하는 정도
  • 캡슐화 : 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것
  • 응집도 : 자신과 밀접한 작업만 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 것을 응집도가 높다고 한다. 객체는 스스로 자신의 데이터를 책임져야 한다.

좋은 객체지향 설계란?

역으로 설계를 어렵게 만드는 큰 요소는 불필요한 의존성입니다. 불필요한 의존성을 제거하고, 구체적인 구현을 캡슐화 하여 요청 메시지만 남기면 객체 사이의 결합도를 낮아집니다.
캡슐화는 객체 자체의 자율성을 높이고 응집도 높은 객체들의 공동체가 되는데 큰 기여를 합니다.

따라서 훌륭한 객체지향 설계란?

협력에 불필요한 세부사항이 캡슐화자율적인 객체들이 낮은 결합도높은 응집도를 가지고 협력하도록 최소한의 의존성만을 남기는 것이다.

메시지 vs 메서드

앞서 알아본 것 처럼 좋은 객체지향은 내부의 구현을 캡슐화하여 외부에 원하는 부분만 공개해 소통합니다.
메서드를 흔히 Function Box에 비교하는데, 이제는 그 의미를 알게 되었습니다.

FunctionBox

메서드는 말하자면 객체 스스로 다른 객체와 소통하기 위한 메시지를 구현한 Function Box 내부의 로직입니다. (x^2)
특정 객체는 위 사진처럼 숫자 3이 들어가면 제곱된 9가 나오는 숫자 제곱해줘인 f 메세지를 요청할 것이고,
요청된 객체는 어떻게 해서든 숫자를 받으면 제곱을 뱉는 함수 박스 내부를 구현해서 사용하게 줄 것입니다.
펑션 박스를 쓸 때 내부는 포장되어 보이지 않습니다. 단지 제곱된 숫자를 받기 위해 가져다 쓸 뿐입니다.

메시지는 다른 객체에게 요청, 메서드는 요청받은 메시지를 구현한 내부이다.

객체간의 협력에서 다형성이 주는 이점

다형성은 설계상의 역할과 메시징을 추상적인 관념에서 구체적인 구현으로 전환하는 데에 중요한 역할을 합니다.
설계 이후 각 역할에 적절한 객체가 배정받고, 역할에 배정된 객체들은 설계된대로 작동할 책임을 가지게 됩니다.
역할에 배정된 객체에 대한 결합도를 관리하면 객체간의 협력이 유연해집니다.
역할에 1:1 매핑으로 객체를 부여하지 않고, 상황에 따라 교체하며 갈아끼울 수 있습니다.
결과적으로 구현때문에 설계 흐름이 망가지지 않고 수정하기 쉬워집니다.

어떻게 다형성이 구현될 수 있을까?

메세지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정합니다.
이를 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding) 이라고 합니다.

설계상 역할에 대한 객체의 변동이 클 것 같은 경우, interface로 구현하여 다형성을 이용해 객체를 교체하면 된다.

유연한 구현을 위한 합성과 상속

객체지향 프로그래밍에서는 객체 간의 협력을 추상적으로 설계하며, 다형성을 통해 유연하게 협력을 구현합니다.
다형성을 달성하기 위해 합성과 상속이라는 두 가지 기법이 사용됩니다.
이 두 기법은 코드의 재사용성과 확장성을 증가시키며, 객체 간의 결합도를 관리하는 데에도 중요한 역할을 합니다.

합성한 객체는 자신이 요청한 메세지에서 반환만 잘 받으면, 어떤 구현체가 해주던지 상관없다. 의존성을 가지지 않아 결합도도 내려간다.

합성

합성은 다른 객체의 인스턴스를 현재 객체의 인스턴스 변수로 포함하여 재사용하는 방법입니다.
이 방법은 ‘has-a’ 관계를 표현하며, 호출되는 객체는 인터페이스를 통해 추상화됩니다.
이러한 구조는 호출된 메서드의 구현체가 어떻든간에, 호출하는 객체는 올바른 반환값만 받으면 됩니다.
이 통해 객체 간의 의존성을 줄이고 결합도를 낮출 수 있습니다.


interface Engine {
    void start();
}

class GasEngine implements Engine {
    public void start() {
        System.out.println("Gas Engine starting...");
    }
}

class Car {
    private Engine engine;

    Car(Engine engine) {
        this.engine = engine;
    }

    void start() {
        engine.start();
    }
}



상속

상속은 기존 클래스의 속성과 메서드를 새로운 클래스에 전달하여 코드를 재사용하고 확장하는 방법입니다.
이 방법은 ‘is-a’ 관계를 표현하며, 기능의 변화가 많을 경우 새로운 하위 클래스를 생성해야 하므로, 설계를 덜 유연하게 만들 수 있습니다.


class Animal {
    void eat() {
        System.out.println("Eating...");
    }
}

class Dog extends Animal {
    void bark() {
        System.out.println("Barking...");
    }
}

💡중간 용어 정리

  • 합성: 객체의 인스턴스를 현재 객체의 인스턴스 변수로 포함하여 재사용하고, 객체 간의 결합도를 낮춘다.
  • 상속: 기존 클래스의 속성과 메서드를 새로운 클래스에 전달하여 코드의 재사용성과 확장성을 제공한다.

모듈은 기능적인 수행 뿐만이 아니라 변경에도 용이하고 읽는 사람과 의사소통이 되어야 한다.

//file: `Theater.java`
public void enter(Audience audience){
        if(audience.getBag().hasInvitation()){
            Ticket ticket=ticketSeller.getTicketOffice().getTicket();
            audience.getBag().setTicket(ticket);
        }
        else{
            Ticket ticket=ticketSeller.getTicketOffice().getTicket();
            audienc.getBag().minusAmount(ticket.getFee());
            ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
            audience.getBag().setTicket(ticket);
            }
        }

BadCase. 객체가 수동적으로 소극장에게 휘둘리고, 가독성도 좋지 않다. (의사소통 불가)

  • 동작이 우리의 예상을 벗어난다. (의사소통이 어려워진다.)
    • 관람객이 직접 자신의 가방에서 초대장을 꺼낸다.
    • 돈을 꺼내 지불한다
  • 하나의 메서드에서 너무 많은 세부사항을 다루므로 불편하다.
    • Audience-Bag, Bag - Cash,Ticket, TicketSeller가 TicketOffice에서 티켓 판매 등
  • Audience, TicketSeller를 변경한 경우 Theater도 함께 변경되어야 한다. (의존성)

Q : 의존성이 변경에 취약하게 만드는 이유는 무엇일까?

  1. 결합도(Coupling)의 증가: 한 모듈이 다른 모듈의 내부 구현에 깊숙이 의존할 때, 하나를 변경하면 다른 하나도 영향을 받게 됩니다. 결합도가 높으면 유지보수와 확장성이 떨어집니다.

  2. 재사용성 감소: 특정 구현에 강하게 의존하는 코드는 그 의존성이 없는 다른 상황에서 재사용하기 어렵습니다.

  3. 변경의 전파: 의존하는 코드가 변경될 때, 그 의존성을 가지는 모든 코드를 찾아 수정해야 하는 노동 집약적인 과정이 필요합니다.

  4. 테스트의 어려움: 의존성이 많으면 각각을 격리하여 테스트하기가 어렵습니다. 모의 객체(mock objects)나 스텁(stubs)을 사용하여 이를 해결할 수 있지만, 이는 추가적인 작업을 필요로 합니다.

  5. 이해하기 어려움: 의존성이 많고 복잡하면 코드를 이해하기 어려워지고, 그로 인해 새로운 개발자가 기존 시스템에 적응하는 데 더 많은 시간이 필요하게 됩니다.


절차지향에서 객체지향적인 설계로 변경해보자.

  • 객체를 자율적인 존재로 만든다.
  • 객체는 스스로 자신의 상태를 책임지고 적절한 책임을 부여한다.
  • 내부의 구현을 외부에 감춰 결합도를 낮춘다.(캡슐화)
public void enter(Audience audience){
    ticketSeller.sellTo(audience);
}

Q : 의존성 , 결합도, 캡슐화, 응집도, 객체, 자율성으로 객체지향을 설명해보자
객체지향 프로그래밍에서 객체는 데이터와 행위를 캡슐화하여 자율적인 단위를 형성하며, 이들 간의 상호작용을 통해 프로그램이 기능을 수행하게 됩니다.
캡슐화를 통해 객체는 내부 구현을 숨기고 외부 인터페이스만을 노출함으로써 자율성을 갖고,
이는 각 객체의 응집도를 높여서 특정 목적이나 기능에 집중할 수 있도록 합니다.
반면, 의존성결합도객체 사이의 관계를 나타내는 지표로서,
객체가 다른 객체의 내부 구현이 아닌, 추상화된 인터페이스에만 의존하도록 함으로써 낮은 결합도를 유지하게 하는 것이 중요합니다.
낮은 결합도는 시스템의 유연성을 보장하고 변경에 강한 설계를 가능하게 하여, 각 객체가 주어진 책임을 자율적으로 수행할 수 있는 견고한 구조를 만드는 데 기여합니다.

Q : abstract class와 interface의 차이점은?

용도 차이 공통 기능의 상속: 추상 클래스를 사용하여 공통 기능(상태와 행위)을 여러 클래스에 상속시킬 수 있습니다. 인터페이스: 클래스가 따라야 할 메소드 시그니처의 집합을 정의하고, 다형성을 활용한 느슨한 결합을 촉진합니다. abstract class는 “is-a” 관계가 성립할 때 주로 사용되며, 클래스 계층구조에서 상위 클래스의 역할을 합니다. 반면에 interface는 “can-do” 관계(클래스가 특정 행위를 할 수 있는 능력)를 정의하는 데 사용됩니다. Java 8 이후에는 interface가 일부 “is-a” 관계를 모델링할 때도 사용되기 시작했습니다(예: default 메소드를 통해).

설계 단계에서 클래스가 다른 클래스로부터 많은 행동을 상속받아야 할 필요가 있다면 추상 클래스를 사용하고, 여러 클래스 간에 구현을 공유하지 않고 행동만을 정의하고 싶다면 인터페이스를 사용합니다.

Q : Template Method 패턴

기본적인 기능 Template를 만들고 다형성을 이용해 이를 확장시켜 세부적인 타입의 기능들을 구현한다.

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


public abstract class DefaultDiscountPolicy implements DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

    public DefaultDiscountPolicy(DiscountCondition... conditions) {
        this.conditions = Arrays.asList(conditions);
    }

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

        return Money.ZERO;
    }

    abstract protected Money getDiscountAmount(Screening Screening);
}

  • DiscountPolicy의 구체화 예제
public class PercentDiscountPolicy extends DefaultDiscountPolicy {
    private double percent;

    public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
        super(conditions);
        this.percent = percent;
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(percent);
    }
}


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


public class AmountDiscountPolicy extends DefaultDiscountPolicy {
    private Money discountAmount;

    public AmountDiscountPolicy(Money discountAmount, DiscountCondition... conditions) {
        super(conditions);
        this.discountAmount = discountAmount;
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return discountAmount;
    }
}

  • 내부에 템플릿에서 사용되는 discountCondition은 다형성으로 여러 컨디션을 받을 수 있다.
public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}


public class SequenceCondition implements DiscountCondition {
    private int sequence;

    public SequenceCondition(int sequence) {
        this.sequence = sequence;
    }

    public boolean isSatisfiedBy(Screening screening) {
        return screening.isSequence(sequence);
    }
}

public class PeriodCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public boolean isSatisfiedBy(Screening screening) {
        return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
                startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
    }
}