상속과 합성의 차이

상속 관계는 is-a관계라고 부르고 합성 관계는 has-a관계라고 부른다. 둘은 코드 재사용이라는 공통점이 있지만 구현 방식과 다루는 방식에 있어서 큰 차이를 보인다.

상속은 자식클래스가 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 부모 클래스와 강한 결합도를 가진다. 둘의 관계는 컴파일 타임에 결정된다.

반면 합성은 객체의 구현이 아닌 내부에 포함되는 인터페이스에 의존하며 객체 내부 구현이 변경되더라고 영향을 최소화 할 수 있다. 또한 런타임 시점에 동적으로 변경할 수 있어 변경하기 쉽고 유연한 설계가 가능하다.

따라서 코드 재사용을 위해서는 객체 합성이 클래스 합성보다 더 좋은 방법이다.

상속에서 합성으로 변경하기

상속의 단점

  1. 불필요한 인터페이스를 상속하게 된다.
  2. 부모클래스의 메서드 호출방법에 의존하게 된다.
  3. 부모클래스의 변경이 자식클래스에게 영향을 미치게 된다.

상속관계는 컴파일 타임에 결정되기 때문에 부가기능을 추가하고자 한다면, 모든 가능한 조합의 클래스를 생성해야한다는 단점이 있다. 합성은 컴파일 타임의 정적인 관계를 런타임의 동적인 관계로 변경하여 이러한 문제를 해결할 수 있다.

상속이 아니라 합성을 통해 기존 객체의 코드를 재사용하면서 부가기능을 추가하는 대표적인 사례가 데코레이터 패턴이다.

img

public interface Component {

    void execute();
}

public class ConcreteComponent implements Component {

    @Override
    public void execute() {
        System.out.println("Concrete");
    }
}


public class Decorator1 implements Component {

    private final Component component;

    public Decorator1(Component component) {
        this.component = component;
    }

    @Override
    public void execute() {
        System.out.println("Decorator 1");
        component.execute();
    }
}

public class Decorator2 implements Component {

    private final Component component;

    public Decorator2(Component component) {
        this.component = component;
    }

    @Override
    public void execute() {
        System.out.println("Decorator 2");
        component.execute();
    }
}

public class Main {

    public static void main(String[] args) {
        Component component = new ConcreteComponent();

        Decorator1 decorator1 = new Decorator1(component);
        Decorator2 decorator2 = new Decorator2(decorator1);
        decorator2.execute();
    }
}

위의 설계에서는 기존의 ConcreteComponent의 코드를 수정하지 않으면서 부가기능을 추가할 수 있도록 하였다. Decorator1,2 클래스는 Component를 합성관계로 가지면서 부가기능을 런타임에 추가할 수 있다.

믹스인

믹스인은 객체를 생성할 때 코드 일부를 클래스 안에 섞어넣어 재사용하는 기법이다. 상속과 유사해 보이지만, 상속이 클래스와 클래스 사이의 관계를 고정시키는 데 반해 유연하게 관계를 재구성 할 수 있어 상속과 같은 결합도 문제에서 자유롭다.

이터레이터를 추상화한 예제를 통해 믹스인을 이해해보자.

이터레이터
abstract class AbsIterator {
  type T
  def hasNext: Boolean
  def next(): T
}

//이터레이터가 반환하는 항목에 함수를 적용하는 메서드(foreach)를 정의
trait RichIterator extends AbsIterator {
  def foreach(f: T => Unit): Unit = { while (hasNext) f(next()) }
}

//문자열의 캐릭터를 차례로 반환하는 이터레이터
class StringIterator(s: String) extends AbsIterator {
  type T = Char
  private var i = 0
  def hasNext = i < s.length()
  def next() = { val ch = s charAt i; i += 1; ch }
}

//문자열의 각 문자를 출력하는 예제
object StringIteratorTest {
  def main(args: Array[String]): Unit = {
    class Iter extends StringIterator("Scala") with RichIterator
    val iter = new Iter
    iter foreach println
  }
}

위는 스칼라라는 언어를 통해 믹스인을 적용한 사례이다. 스칼라에서는 with 키워드를 통해 런타임에 동적으로 부가기능을 추가할 수 있다.

class A extends Three with Four with Two 와 같이 여러 믹스인을 쌓을 수 있으며, 이러한 특징을 쌓을수 있는 변경(Stackable Modification)이라고 한다.

스칼라에서는 여러 trait를 쌓아 올렸다 하여 stackable trait pattern이라고 부르기도 한다.

Appendix

프록시(데코레이터 패턴 vs 프록시 패턴 정리)

스칼라 문법

파이썬에서의 믹스인

'Book Review' 카테고리의 다른 글

[오브젝트] 객체, 설계  (0) 2022.08.28

로버트 L. 글래스 - 이론 vs 실무

건축처럼 역사가 오래된 여느 다른 공학 분야에 비해 상대적으로 짧은 소프트웨어 분야의 역사를 감안 했을 때, 소프트웨어 분야는 아직 걸음마 단계다. 따라서 이론보다 실무가 더 앞서 있으며, 실무가 더 중요하다.

로버트 마틴 - 소프트웨어 모듈의 세 가지 목적

  1. 실행 중에 제대로 동작해야 한다.
  2. 변경을 위해 존재해야 한다.
  3. 코드를 읽는사람과 의사소통해야 한다.

예상을 빗나가는 코드

    public class Theater {
        private TicketSeller ticketSeller;

        public Theater(TicketSeller ticketSeller) {
            this.ticketSeller = ticketSeller;
        }

        public void enter(Audience audience) {
            if(audience.getBag().hasInvitation()) {
                Ticket ticket = ticketSeller.getTicketOffice().getTicket();
                audience.getBag().setTicket(ticket);
            } else {
                Ticket ticket = ticketSeller.getTicketOffice().getTicket();
                audience.getBag().minusAmount(ticket.getFee());
                ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
                audience.getBag().setTicket(ticket);
            }
        }
    }

이해가능한 코드란 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드다. 현실에서는 관람객이 직접 자신의 가방에서 초대장을 꺼내 판매원에게 건네지만, 앞선 코드에서는 그렇지않다. 이는 우리의 상식과 너무나 다르게 동작하기에, 코드를 읽는 사람과 제대로 의사소통 하지 못한다.

또한 Theater의 enter메서드를 이해하기 위해서는 Audience와 Bag의 내부 구현을 이해하고 있어야 한다. enter 메서드는 다른 도메인의 너무 많은 세부사항을 다루기 때문에 코드를 읽고 이해해야 하는 사람 모두에게 큰 부담을 준다.

변경에 취약한 코드

TheaterAudience와 Bag의 세부사항에 강하게 의존하고 있기에 해당 객체들의 세부사항이 변경되면, Theater도 변경이 되어야 한다.

객체 사이의 의존성을 완전히 없애는 것이 정답은 아니다. 객체 지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는것이다.

따라서 우리의 목표는 최소한의 의존성만 유지하고, 불필요한 의존성을 제거하는 것이다.

자율적인 존재로 만들자

TheaterAudienceTicketSeller에 관해 너무 세세한 부분까지 알 필요가 없도록, 관람객이 스스로 가방안의 현금과 초대장을 처리하고, 판매원이 스스로 매표소의 티켓과 판매 요금을 다루게 하면 된다.

즉, 관람객과 판매원을 자율적인 존재로 만들면 되는 것.

티켓을 관람객의 가방에서 꺼내서 확인하고 판매하는 역할은 소극장의 역할이 아니다. 가방에서 티켓을 꺼내는것은 관람객의 역할이며, 티켓을 판매하는 역할은 판매원의 역할이다.

각자의 역할에만 충실하도록 코드를 변경하자.

티켓 판매

소극장 -> 판매원

    public class Theater {
        private TicketSeller ticketSeller;

        public Theater(TicketSeller ticketSeller) {
            this.ticketSeller = ticketSeller;
        }

        public void enter(Audience audience) {
            ticketSeller.toSell(audience);
        }
    }
    public class TicketSeller {
        private TicketOffice ticketOffice;

        public TicketSeller(TicketOffice ticketOffice) {
            this.ticketOffice = ticketOffice;
        }

        public void toSell(Audience audience) {
            if(audience.getBag().hasInvitation()) {
                Ticket ticket = ticketOffice.getTicket();
                audience.getBag().setTicket(ticket);
            } else {
                Ticket ticket = ticketOffice.getTicket();
                audience.getBag().minusAmount(ticket.getFee());
                ticketOffice.plusAmount(ticket.getFee());
                audience.getBag().setTicket(ticket);
            }
        }
    }

TicketSeller 에서 getTicketOffice메서드가 제거되어 외부에서는 TicketOffice에 접근할 수 없게되었다. 따라서 TicketSellerTicketOffice에 대한 작업을 스스로 수행해야한다.

이렇게 객체 내부의 세부사항을 감추는것을 캡슐화 라고 하며, 캡슐화를 통해 객체와 객체 사이의 결합도를 낮추어 변경이 쉬운 객체를 만들 수 있다.

티켓 확인

소극장 -> 관람객

    public class TicketSeller {
        private TicketOffice ticketOffice;

        public TicketSeller(TicketOffice ticketOffice) {
            this.ticketOffice = ticketOffice;
        }

        public void toSell(Audience audience) {
            ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
        }
    }
    public class Audience {
        private Bag bag;

        public Audience(Bag bag) {
            this.bag = bag;
        }

        public Long buy(Ticket ticket) {
            if(bag.hasInvitation()) {
                bag.setTicket(ticket);
                return 0L;
            } else {
                bag.setTicket(ticket);
                bag.minusAmount(ticket.getFee());
                return ticket.getFee();
            }
        }
    }

TicketSellerAudience의 buy 메서드에만 의존하게 된다. 즉 관객이 스스로 가방을 확인하고 티켓 구매를 진행한다. 외부에는 Bag을 노출하지 않게 되므로 Bag의 존재를 내부로 캡슐화 할 수 있게 된다.

개선점

수정된 AudienceTicketSeller는 자신이 가지고 있는 소지품을 스스로 관리한다. 이것은 우리의 예상과 정확하게 이라하며, 코드를 읽는 사람과의 의사소통이라는 관점에서 개선된 것이다.

또한 AudienceTicketSeller의 내부 구현을 변경하더라도 Theater를 변경 할 필요가 없으므로 변경 용이성 측면에서도 개선이 된것이다.

객체지향의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다. 객체지향 코드는 각 객체의 문제를 스스로 처리한다는 점에서 이해하기 쉽고, 객체 내부의 변경이 외부에 영향을 주지 않으므로 변경하기가 수월하다.

개선 방향

설계를 어렵게 만드는 것은 의존성이다. 다른 객체가 몰라도 되는 불필요한 세부사항을 객체 내부로 캡슐화하여 자율적인 객체들이 낮은 결합도와 높은 응집도를 가지고 협력하도록 최소한의 의존성을 남기는것이 휼륭한 객체지향 설계다.

더 개선해 보기

TicketSellerAudience는 자율적인 객체가 되었다. 하지만 TicketOfficebag은 여전히 수동적인 존재이다. 이 둘도 자율적인 객체로 개선할 수 있다.

   public class Bag {
       private Long amount;
       private Invitation invitation;
       private Ticket ticket;

       public Bag(Long amount) {
           this(null,amount);
       }

       public Bag(Invitation invitation, long amount) {
           this.amount = amount;
           this.invitation = invitation;
       }

       public Long hold(Ticket ticket) {
           if(hasInvitation()) {
               setTicket(ticket);
               return 0L;
           } else {
               setTicket(ticket);
               minusAmount(ticket.getFee());
               return ticket.getFee();
           }
       }

       private boolean hasInvitation() {
           return invitation != null;
       }

       public boolean hasTicket() {
           return ticket != null;
       }

       private void setTicket(Ticket ticket) {
           this.ticket = ticket;
       }

       private void minusAmount(Long amount) {
           this.amount -= amount;
       }

       public void plusAmount(Long amount) {
           this.amount += amount;
       }
   }
    public class Audience {
      public Long buy(Ticket ticket) {
        return bag.hold(ticket);
      }
    }

캡슐화를 통해 Audiencebag의 인터페이스에만 의존하게 된다.

    public class TicketOffice {
        private Long amount;
        private List<Ticket> tickets = new ArrayList<>();

        public TicketOffice(Long amount, Ticket ... tickets) {
            this.amount = amount;
            this.tickets.addAll(Arrays.asList(tickets));
        }

        public void sellTicketTo(Audience audience) {
            plusAmount(audience.buy(getTicket()));
        }

        private Ticket getTicket() {
            return tickets.remove(0);
        }

        public void minusAmount(Long amount) {
            this.amount -= amount;
        }

        private void plusAmount(Long amount) {
            this.amount += amount;
        }

    }
    public class TicketSeller {
      public void sellTo(Audience audience) {
        ticketOffice.sellTicketTo(audience);
      }
    }

캡슐화를 통해 TicketSellerTicketOffice의 인터페이스에만 의존하게 된다. 하지만 TicketOffice는 판매를 위해 Audience에 의존하게 된다. 새로운 의존성이 추가되었으므로, 전체 설계의 관점에서는 결합도가 상승한것이다.

결합도를 낮추는것이 우선일까 각 객체의 자율성을 만족시키는것이 우선일까?
설계는 트레이드오프의 산물이다. 모든 사람을 만족시킬 수는 없다.

의인화

bagTicketOffice, Theater는 현실세계에서는 자율적인 존재가 아니다. 하지만 객체지향 세계에서는 모든 객체들이 자율적으로 행동한다.
이처럼 모든 객체를 능동적이고 자율적인 존재로 객체를 설계하는 원칙을 의인화 라고 한다.

좋은 설계

요구사항이 항상 변경되기에 변경을 수용할 수 있는 설계가 중요하다. 또한 코드를 변경할 때 버그가 추가될 가능성이 높기 때문에 최소한의 변경으로 요구사항을 만족시킬 수 있어야 한다.

변경할 수 있는 코드는 이해하기 쉬운 코드다. 이해하기 쉬운 코드만이 코드를 선뜻 수정하게 만든다.

개인적으로

각 객체의 자율성을 만족시키는 것이 우선일까? 결합하는 낮추는 것이 우선일까? 트레이드오프는 개발 공부를 하면서 매번 듣고 느끼는 단어입니다.

협업하면서 정답이 없는 문제에 대해 논쟁하다 보면 정말 정답은 없지만, 상황에 따라 조금 더 타당한 논리가 지지받습니다. 때문에 코드 작성 만큼 다양한 관점에서의 논리와 해석을 많이 찾아보면서 자신만의 논리를 찾아가는 과정도 중요하다 생각합니다.

오브젝트 책을 통해 논리가 분명한 설계를 할 수 있게 되길 바랍니다.

'Book Review' 카테고리의 다른 글

[오브젝트] 합성과 유연한 설계  (0) 2022.11.18

+ Recent posts