본문 바로가기
Study/오브젝트

[오브젝트] 책 내용 전체 정리

by Nahwasa 2023. 1. 21.

스터디 메인 페이지 (챕터별 정리 내용 따로 보기 가능)

목차

    - ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한 방식을 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.

    - 모든 이미지의 출처는 오브젝트(조용호 저) 책 입니다.

     


     

    들어가며 - 프로그래밍 패러다임

    01 패러다임의 시대

    - 현대인들에게 패러다임 → 한 시대의 사회 전체가 공유하는 이론이나 방법, 문제의식 등의 체계 (e.g. 천동설 → 지동설 로의 패러다임의 전환)

    - 이 책에서 얘기하는 패러다임 전환 - 절차형 패러다임에서 절차형 패러다임으로의 변화

     

    02 프로그래밍 패러다임

    - ☆P.S 좋아하는 알고리즘 중 하나인 플로이드 와샬 알고리즘의 그 플로이드 와샬이 프로그래밍 패러다임 용어 처음 사용해서 신기했음!

    - 프로그래밍 패러다임 - 특정 시대의 어느 성숙한 개발자 공동체에 의해 수용된 프로그래밍 방법과 문제 해결 방법, 프로그래밍 스타일

    - 어떤 패러다임을 쓰느냐에 따라 해결할 문제를 바라보는 방식과 프로그램을 작성하는 방법이 달라진다.

    - 프로그래밍 언어와 프로그래밍 패러다임을 분리해서 설명할 수 없다.

    - 이책과 객사오와 다른 점 → 코드를 통해 패러다임을 이해하고 적용할 기술을 습득할 수 있음.

    - 프로그래밍 패러다임은 과거의 패러다임의 단점을 보완함. 혁명적이 아니라 발전적.

     


     

    CHAPTER 01. 객체, 설계

    ‘소프트웨어 설계’와 ‘소프트웨어 유지보수’ 분야에서는 실무가 이론보다 앞서 있다. → 소프트웨어 생명주기(계획>분석>설계>구현>테스트>유지보수(가장 김))를 동안 유지보수가 차지하는 비중을 보면 실망스러운 수준 → 소프트웨어 설계와 유지보수에 중점을 두려면 이론이 아닌 실무에 초점을 맞추는 것이 효과적이다.


    01 티켓 판매 애플리케이션 구현하기 - step1 (github

    - ☆ TicketOffice의 getTicket()은 getter인데 로직이 들어가 있어서 인터페이스만 보고 유추할 수 없다.

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

     

     

    - step1의 경우 메인 로직인 Theater의 의존성이 높다.

     

    - 절차지향적인 Theater class

    package org.eternity.theater.step01;
    
    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);
            }
        }
    }

     

     

    - 작성된 프로그램의 로직은 예상대로 동작한다. 하지만 몇 가지 문제점이 있다.


    02 무엇이 문제인가

    - 소프트웨어 모듈이 가져야 하는 세 가지 기능 (로버트 마틴)

    • 실행 중에 제대로 동작
    • 간단한 작업만으로도 변경이 가능해야 함
    • 특별한 훈련 없이도 개발자가 쉽게 읽고 이해할 수 있어야 함

     

    - ☆ 변경하기 어렵고 이해하기 어렵지만 명백히 효율적인 코드가 있다며 어떻게 해야 할까? → 고객에게 영향을 안끼칠 정도라면 이해하기 쉬운걸 선택하는게 좋다. (예를들어 이하의 코드에서 N이 1000만 이하 정도라면 크게 영향을 끼치는 수준은 아닐 것이다.)

    private int getSequenceSum1(int N) {
        int sum = 0;
        for (int i = 1; i <= N; i++) {
            sum += i;
        }
        return sum;
    }
    
    private int getSequenceSum2(int N) {
        return (N+N*N)/2;
    }
    

     

     

    - step 1의 문제A - 이해하기 어렵다.

    • 관람객과 판매원이 소극장의 통제를 받는 수동적 존재이다. → 소극장이 관람객의 가방을 마음대로 열어본다. 매표소의 티켓과 현금도 마음대로 접근한다.
    • 이해 가능한 코드란 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드 → 현재의 코드는 우리의 상식과는 너무나도 다르게 동작하므로 코드를 읽는 사람과 제대로 의사소통하지 못한다.

     

    - step 1의 문제B - 변경에 취약하다.

    • Audience와 TicketSeller를 변경할 경우 Theater도 함께 변경해야 한다. → 관람객이 가방을 들고 있지 않다면?, 관람객이 현금이 아니라 신용카드를 사용한다면?, 판매원이 매표소 밖에서 티켓을 판매해야 한다면?
    • 다른 클래스가 Audience의 내부에 대해 더 많이 알면 알수록 Audience를 변경하기 어려워진다.

     

    - 객체 사이의 의존성(dependency)

    • 어떤 객체가 변경될 때 그 객체에 의존하는 다른 객체도 함께 변경해야 함.
    • 의존성이 과한 경우 = 결합도(coupling)이 높다

     

    - 우리의 목표

    • 애플리케이션의 기능을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거 (완전히 없애는 것이 정답은 아님. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것)
    • 객체 사이의 결합도를 낮춰 변경이 용이한 설계를 만들어야 함.

    03 설계 개선하기 - step 2 (github)

    - 관람객과 판매원을 자율적인 존재로 만들자

    • 관람객이 스스로 가방 안의 현금과 초대장을 처리
    • 판매원이 스스로 매표소의 티켓과 판매 요금을 다루자
    • Theater가 Audience와 TicketSeller에 관해 너무 세세히 알지 못하도록 정보를 차단

     

    - 캡슐화 (encapsulation)

    • 객체 내부의 세부적인 사항을 감추는 것
    • 캡슐화를 통해 변경하기 쉬운 객체를 만들 수 있다.
    • 결합도 낮춤 → 자율성 높힘
    • 캡슐화 → 결합도 낮춤, 자율성 높힘, 응집도 높힘 (29page 내용)

     

    - 개선A. Theater 클래스의 enter()에 있던 메인 로직을 TicketSeller의 sellTo() 함수로 가져가 캡슐화 하자.

    • 캡슐화 후 간단해진 Theater
    package org.eternity.theater.step02;
    
    public class Theater {
        private TicketSeller ticketSeller;
    
        public Theater(TicketSeller ticketSeller) {
            this.ticketSeller = ticketSeller;
        }
    
        public void enter(Audience audience) {
            ticketSeller.sellTo(audience);
        }
    }

     

     

    - TicketSeller의 인터페이스에만 의존하는 Theater

    • 이제 Theater는 TicketSeller 내부에 TicketOffice가 있는걸 알지 못한다. (의존성이 제거됨)

     

    - 개선B. 기존 Theater의 enter()에 있다가 TicketSeller의 sellTo()로 넘어간 로직에 있던 Audience도 buy() 함수를 만들어 캡슐화할 수 있다. 

    package org.eternity.theater.step02;
    
    public class TicketSeller {
        private TicketOffice ticketOffice;
    
        public TicketSeller(TicketOffice ticketOffice) {
            this.ticketOffice = ticketOffice;
        }
    
        public void sellTo(Audience audience) {
            ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
        }
    }
    
    ---
    
    package org.eternity.theater.step02;
    
    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();
            }
        }
    }

     

     

    - 마찬가지로 TicketSeller는 Audience의 인터페이스에만 의존한다.

    • 외부에서는 더이상 Audience가 Bag을 소유하고 있다는 사실을 알 필요가 없다.
    • Audience의 구현을 수정하더라도 TicketSeller에는 영향을 미치지 않는다.

     

    - 자신의 문제를 스스로 책임지고 해결하는 자율적인 존재가 된 Audience와 TicketSeller

    • Audience가 가방이 아니라 작은 지갑을 소지하도록 변경 → 이제 Audience만 변경하면 된다.
    • TicketSeller가 매표소가 아니라 은행에 돈을 보관하도록 변경 → 이제 TicketSeller 내부만 변경하면 된다.

    - 객체의 자율성을 높이는 방향으로 설계를 개선 → 이해하기 쉽고 유연한 설계를 얻을 수 있다.

     

    - 핵심

    • 객체 내부의 상태를 캡슐화
    • 객체 간에 오직 메시지를 통해서만 상호작용하도록 함
    • 외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 훌륭한 객체지향 설계를 얻을 수 있는 지름길!

     

    - 절차지향

    • step 1의 코드에서 모든 처리는 Theater의 enter() 내에 존재했다.
    • Theater의 enter()는 프로세스(Process)이고, Audience, TicketSeller, Bag, TicketOffice는 데이터(Data)이다.
    • 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라 한다.
    • 모든 처리가 하나의 클래스 안에 위치하고 나머지 클래스는 단지 데이터의 역할만 수행 ㅜ
    • “변경은 버그를 부르고 버그에 대한 두려움은 코드를 변경하기 어렵게 만든다.”

     

    - 객체지향

    • 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다.
    • 자신의 데이터를 스스로 처리하도록 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍이라 한다.
    • 훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다.
    • 책임의 이동! Theater에 몰려 있던 책임이 개별 객체로 이동했다.
    • 위의 객체지향 프로그래밍의 정의는 구현 관점에서만 바라본 지극히 편협한 시각이지만, 갓 입문한 사람에게 도움이 되는 실용적인 조언이다. → 사실 객체지향 설계의 핵심은 적절한 객체에 적절한 책임을 할당하는 것이다. 따라서 객체가 어떤 데이터를 가지느냐보다는 객체에 어떤 책임을 할당할 것이냐에 초점을 맞춰야 한다. (☆클래스 이름에 맞는 행위를 줘야한다.)

     

    트레이드 오프 - step 3 (github)

    - 좀 더 개선해보자. Audience에게 끌려다니는 Bag도 자율적인 존재로 만들 수 있다. 이건 별 문제 없이 개선할 수 있다.

    package org.eternity.theater.step03;
    
    public class Bag {
        private Long amount;
        private Ticket ticket;
        private Invitation invitation;
    
        public Long hold(Ticket ticket) {
            if (hasInvitation()) {
                setTicket(ticket);
                return 0L;
            } else {
                setTicket(ticket);
                minusAmount(ticket.getFee());
                return ticket.getFee();
            }
        }
    
        private void setTicket(Ticket ticket) {
            this.ticket = ticket;
        }
    
        private boolean hasInvitation() {
            return invitation != null;
        }
    
        private void minusAmount(Long amount) {
            this.amount -= amount;
        }
    }
    
    ---
    
    package org.eternity.theater.step03;
    
    public class Audience {
        private Bag bag;
    
        public Audience(Bag bag) {
            this.bag = bag;
        }
    
        public Long buy(Ticket ticket) {
            return bag.hold(ticket);
        }
    }

     

     

    - 이번엔 TicketSeller에게 끌려다니는 TicketOffice도 자율적인 존재로 만들어보자. 이번에도 문제가 없을까?

    package org.eternity.theater.step03;
    
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    
    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));
        }
    
        // 아래 코드는 책에서 설명한 것처럼 트레이드오프 후에 원래의 step02의 구현으로 복구해야 합니다.
        public void sellTicketTo(Audience audience) {
            plusAmount(audience.buy(getTicket()));
        }
    
        private Ticket getTicket() {
            return tickets.remove(0);
        }
    
        private void plusAmount(Long amount) {
            this.amount += amount;
        }
    }
    
    ---
    
    package org.eternity.theater.step03;
    
    public class TicketSeller {
        private TicketOffice ticketOffice;
    
        public TicketSeller(TicketOffice ticketOffice) {
            this.ticketOffice = ticketOffice;
        }
    
        // 아래 코드는 책에서 설명한 것처럼 트레이드오프 후에 원래의 step02의 구현으로 복구해야 합니다.
        public void sellTo(Audience audience) {
            ticketOffice.sellTicketTo(audience);
        }
    }
    • 이제 TicketSeller는 TicketOffice의 인터페이스에만 의존하게 되었는데, 문제는 기존에 없던 TicketOffice와 Audience 사이의 의존성이 추가되어 버렸다.
    • TicketOffice의 자율성은 높였지만, 전체 설계의 관점에서 결합도가 상승해버림.

     

    - 현재로서는 결합도와 자율성 모두를 만족시키는 방법이 떠오르지 않는다. → 트레이드 오프! → 선택해야 한다.

    • 어떤 기능을 설계하는 방법은 한가지 이상일 수 있다.
    • 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있으므로 결국 설계는 트레이드오프의 산물이다.
    • 어떤 경우에도 모든 사람들을 만족시킬 수 있는 설계를 만들 수는 없다.

     

    - “설계는 균형의 예술이다. 훌륭한 설계는 적절한 트레이드오프의 결과물이다.”

     

    - 의인화 (anthropomorphism)

    • 사실 Theater, Bag, TicketOffice는 실세계에서 자율적인 존재가 아니다. 따라서 우리가 세상을 바라보는 직관과 일치하지 않는다.
    • 현실에서는 수동적인 존재라도 객체지향의 세계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀐다.
    • 훌륭한 객체지향 설계란 소프트웨어를 구성하는 모든 객체들이 자율적으로 행동하는 설계를 가리킨다.

     

    04 객체지향 설계

    - “설계란 코드를 배치하는 것이다.”

     

    - “설계는 코드 작성의 일부이며 코드를 작성하지 않고서는 검증할 수 없다.”

     

    - 좋은 설계

    • 우리가 짜는 프로그램의 두 가지 요구사항
    • 좋은 설계란 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계이다.

     

    - 객체지향 설계

    • 우리가 진정으로 원하는 것은 변경에 유연하게 대응할 수 있는 코드이다.
    • 객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공한다. → 요구사항 변경에 좀 더 수월하게 대응할 수 있는 가능성을 높혀준다.
    • 1장에서 나온 얘기인 데이터와 프로세스를 하나의 덩어리로 모으는 것은 훌륭한 객체지향 설계로 가는 첫걸음일 뿐이다. 진정한 객체지향 설계로 나아가는 길은 협력하는 객체들 사이의 의존성을 적절하게 조절함으로써 변경에 용이한 설계를 만드는 것이다.

     


     

    CHAPTER 02. 객체지향 프로그래밍

    01 영화 예매 시스템

    - 코드 (github)


    02 객체지향 프로그래밍을 향해

    - 협력, 객체, 클래스

    • 대부분의 사람들은 클래스(class)를 경험한 후에 클래스에 어떤 속성과 메서드가 필요한지 고민한다.
    • 진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞춰야 얻을 수 있다.
    • 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다. 협력에 참여하는 협력자로써의 객체가 어떤 상태와 행동을 가지는지 결정한 후, 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현해야 한다.

     

    - 도메인

    • 도메인(domain)은 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야이다.
      • 예를들어 영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것
      • ☆개인적으로 생소한 용어였다. 내 경우엔 "소프트웨어로 해결하고자 하는 문제 영역" 혹은 요구사항 보다는 좀 더 큰 범위이지만, "요구사항" 정도로 바꿔서 읽으니 잘 읽히긴 했다.
    • 객체지향 패러다임은 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있게 해준다.
    • 요구사항을 분석하는 초기 단계부터 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있다.
    • 따라서 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있다.
    • 일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 한다.

     

    - 클래스의 내부와 외부를 구분

    • 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지를 결정하는 것이다.
    • 외부에서는 객체의 속성에 직접 접근할 수 없도록 막고, 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 해야 한다.
    • 경계의 명확성이 객체의 자율성을 보장
    • 프로그래머에게 구현의 자유를 제공

     

    - 자율적인 객체

    • 객체는 상태(state)와 행동(behavior)을 함께 가지는 복합적인 존재
    • 객체는 스스로 판단하고 행동하는 자율적인 존재
    • 객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현
    • 캡슐화 : 데이터와 기능을 객체 내부로 함께 묶는 것
    • 접근 제어 : 대부분의 객체지향 프로그래밍 언어들은 캡슐화에서 한걸음 더 나아가 접근 제어(access control) 매커니즘도 보통 함께 제공됨
      • 접근 수정자(access modifier) : public, protected, private 등을 사용
    • 다른 객체에게 원하는 것을 요청하고는 객체가 스스로 최선의 방법을 결정할 수 있을 것이라는 점을 믿고 기다려야 한다.
    • 캡슐화와 접근 제어를 통해 객체를 외부에서 접근 가능한 부분(public interface)와 오직 내부에서만 접근 가능한 부분(implementation)으로 나눌 수 있다.
      • public interface(퍼블릭 인터페이스) : public으로 지정된 메서드만 포함
      • implementation(구현) : private 메서드, protected 메서드, 속성
      • ☆자바의 경우 public은 전부 접근 가능, private는 자기 자신과 동일한 클래스에서만 접근 가능(동일 클래스라면 다른 객체(인스턴스)도 접근 가능함), default(접근 수정자를 안쓴거)는 동일 패키지에서 접근 가능, protected는 동일패키지 및 다른 패키지에 있더라도 자식(하위) 클래스면 접근 가능.

     

    - 구현 은닉

    • 클래스 작성자(class creator) : 새로운 데이터 타입을 프로그램에 추가
      • ☆ 라이브러리를 제공하는 쪽이라고 보면 됨. API를 통한 서비스라면 API를 만들어 제공하는 쪽에 해당
      • 클라이언트 작성자에게 필요한 부분만 공개해야 함. 클라이언트 프로그래머가 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지함으로써, 클라이언트 작성자에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있음. -> 즉, public 영역을 변경하지 않는다면 코드를 자유롭게 수정할 수 있음.
    • 클라이언트 작성자(client programmer) : 클래스 작성자가 추가한 데이터 타입을 사용
      • ☆ 라이브러리를 사용하는쪽. API를 통한 서비스라면 제공된 API를 호출하는 쪽에 해당
      • 클래스 작성자가 숨겨둔 부분은 접근할 수 없으므로 내부의 구현은 무시한 채 인터페이스만 알고 있으면 되므로 담아둬야 하는 지식의 양을 줄일 수 있음

     

    - 협력하는 객체들의 공동체

    • ☆ 47page의 코드 관련해서, 1. isLessThan, isGreaterThanOrEqual 보다 Comparable을 implements해서 compareTo를 두는게 더 낫지 않았을까? -> 좀 더 명확한 메소드명으로 표기할 수 있으니 isLessThan, isGreaterThanOrEqual도 좋은 것 같다. 2. 오브젝트 책 깃헙에 가보면 equals랑 hashcode 함수도 있는데, '@Override' 표기가 없다. -> 안써도 동작은 하지만, 명시적으로 써주는게 더 좋을 것 같다.
    • 협력(collaboration) : 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용
    • 객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다.
    • ☆ 인터페이스 = 메시지 = 요청(request)
    • ☆ 구현 = 메서드 = 응답(response)
    • 객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것 뿐이다. -> 메시지를 수신한 객체는 스스로 결정에 따라 자율적으로 메시지를 처리할 방법(메서드)를 결정한다.

    03 할인 요금 구하기

    - Movie 클래스 코드 어디에도 할인 정책을 판단하는 코드는 존재하지 않는다. 단지 discountPolicy에게 메시지를 전송할 뿐이다.

     

    - 할인 정책인 금액 할인 정책과 비율 할인 정책은 대부분의 코드가 유사하고 할인 요금을 계산하는 방식만 조금 다르다.

    • 중복 코드를 제거하기 위해 공통 코드를 부모 클래스에 두고, 금액 할인 정책과 비율 할인 정책 클래스가 상속받게 한다.
    • 이 때 프로그램 내에서 부모 클래스의 인스턴스를 생성할 필요가 없으므로 추상 클래스(abstract class)를 사용했다.
    • 이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLACTE METHOD 이라고 부른다.

     

    - ☆ 56page 2번째 코드 관련 : 빌더로 구현하는게 더 깔끔하지 않을까? -> 전부 필수값이니 빌더로 구현하는건 오류를 발생시킬 여지가 있어 별로일 것 같고, 빌더에서 필수값을 체크하게 변경한다면 책에 나온 예시에서는 사용할 의미가 없을 것 같다.


    04 상속과 다형성

    -  컴파일 시간 의존성과 실행 시간 의존성

    • 어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말한다. (그림 2.7에서 코드상으로는 Movie는 DiscountPolicy에만 의존)

    • 하지만 Movie 인스턴스는 실행 시에 추상 클래스인 DiscountPolicy가 아니라 AmountDiscountPolicy 혹은 PercentDiscountPolicy에 의존해야 한다.
    • ☆ 설계시 의존성은 컴파일 시간 의존성과 실행 시간 의존성 중 어떤걸 고려해야 할까? -> 컴파일 시간 의존성을 고려하면 될 것 같다.
    • 코드의 의존성(컴파일 시간 의존성)과 실행 시점의 의존성은 서로 다를 수 있다.
    • 유연하고, 쉽게 재사용할 수 있으며, 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성과 실행 시점의 의존성이 다르다.
    • 다만, 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기는 어려워진다.
    • 트레이드오프 : 설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다.

     

    - 상속과 인터페이스

    • 일반적으로 대부분의 사람들은 상속의 목적이 메서드나 인스턴스 변수를 재사용하는 것이라고 생각한다.
    • 하지만 상속이 가치 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.
      • 상속을 통해 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있다.
    • 업캐스팅 : 자식 클래스가 부모 클래스를 대신하는 것
    • 구현 상속 : 서브클래싱(subclassing), 코드를 재사용하기 위한 목적으로 상속을 사용하는 것. 지양하는 것이 좋다.
    • 인터페이스 상속 : 서브타이핑(subtyping), 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것. 상속은 구현 상속이 아니라 인터페이스 상속을 위해 사용해야 한다.

     

    - 코드 재사용 (70 page 내용임) 

    • 위에서 일반적으로 대부분의 사람들은 상속의 목적이 재사용이라고 얘기했다.
    • 일반적으로 코드 재사용을 위해서는 상속보다는 합성(composition)을 선호하는 것이 더 좋은 방법이다.
      • 합성 : 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법
    • 상속의 단점 1 - 캡슐화를 위반 : 상속을 이용하기 위해서는 부모 클래스의 내부구조를 잘 알고 있어야 한다.
      • 캡슐화의 약화 -> 자식과 부모 클래스가 강하게 결합 -> 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률이 높음 -> 상속을 과도하게 사용할 시 코드를 변경하기도 어려워짐.
    • 상속의 단점 2 - 설계가 유연하지 않다. : 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시 결정하므로 실행 시점에 객체의 종류를 변경할 수 없다.
    • 코드 재사용을 위해 합성을 사용할 경우의 장점
      • 인터페이스를 통해 합성으로 이용하려는 객체와 약하게 결합됨 -> 즉, 인터페이스에 정의된 메시지를 통해서만 코드를 재사용할 수 있다. -> 구현을 효과적으로 캡슐화할 수 있고, 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다.
    • 상속을 절대 사용하지 않아야 한다는 것은 아니다. 코드를 재사용하는 경우에는 상속보다 합성을 선호하는 것이 옳지만, 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수 밖에 없다.

     

    - 다형성

    • 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 개체의 클래스가 무엇이냐에 따라 달라진다(즉, 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다.).
    • 다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미한다.
    • 따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다. (인터페이스가 동일해야 한다)
    • 상속은 인터페이스를 통일하기 위해 사용한 구현 방법 중 하나이다.
      • 상속을 이용하면 동일한 인터페이스를 공유하는 클래스들을 하나의 타입 계층으로 묶을 수 있으므로 대부분의 사람들은 다형성을 얘기할 때 상속을 함께 언급하지만, 상속이 다형성을 구현할 수 있는 유일한 방법은 아니다.
    • 다형성을 구현하는 방법은 매우 다양하지만, 메시지에 응답하기 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있다.
      • 즉, 메시지와 메서드를 실행 시점에 바인딩한다. -> 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding)
    • ☆ 다형성은
      • 컴파일 시간 의존성과 실행 시간 의존성이 달라야 함
      • 외부 인터페이스가 존재해야만 함
      • 실행 시간 의존성이 바뀔 때 컴파일 시간 의존성과 동일한 외부 인터페이스를 지원해야 함

     

    - ☆ 상속을 통한 다형성

    • ☆ 외부 인터페이스가 없는 상태에서의 상속은 다형성이 아니다.
      • This principle can also be applied to object-oriented programming and languages like the Java language. Subclasses of a class can define their own unique behaviors and yet share some of the same functionality of the parent class. (oracle java tutorials - Polymorphism)
      • e.g. @MappedSuperclass 를 통해 공통 매핑 정보를 상속받아 사용하는 경우 -> 혹시 공통 함수를 생성하더라도 실행 시점에서 업클래스할 일이 없으므로 다형성일 수 없음.
    • 상속은 다형성을 구현하는 하나의 방법 중 하나이다.
    • ☆ 실행 시간 의존성이 바뀔 때 컴파일 시간 의존성과 동일한 외부 인터페이스를 지원해야 함. 다만 추가로 더 넣는건 괜찮음(부모가 3개라고 자식도 상속받은 3개 외에 추가로 만들지 않을 필요는 없음)
    • ☆ 결론적으로 상속을 통한 다형성 구현은
      • 부모클래스를 상속(extends) 또는 구현(implements)을 한다. (이 때 인터페이스가 없다면 다형성과 관련 없음.)
      • 함수를 재정의한다. (안하면 그냥 구현 상속(subclassing))
      • 그리고 실행 시점에 메서드가 결정된다.

    05 추상화와 유연성

    - 추상화의 힘

    • 추상화 : 구현의 일부(추상 클래스인 경우) 또는 전체(자바 인터페이스 경우)를 자식 클래스가 결정할 수 있도록 결정권을 위임한다.
    • 장점1 : 추상화의 계층만 따로 떼어 놓고 보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.
      • 세부사항(금액 할인 정책과 비율 할인 정책을 사용한다)에 억눌리지 않고 상위 개념(할인 정책이 존재한다)만으로 도메인의 중요한 개념을 설명할수 있게 한다.
      • 재사용 가능한 설계의 기본을 이루는 디자인 패턴(design pattern)이나 프레임워크(framework) 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용한다.
    • 장점2 : 설계가 좀 더 유연해진다.
      • 추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다.

     

    - 추상 클래스와 인터페이스 트레이드오프

    • 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다.
    public class Movie {
        public Money calculateMovieFee(Screening screening) {
        	if (discountPolicy == null) {
            	return fee;
            }
            return fee.minus(discountPolicy.calculateDiscountAmount(screening));
        }
    }
    • 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택해야 한다.

     

    • 방법1 : 기존 클래스 구조 그대로 NoneDiscountPolicy 클래스 추가
      • 문제 : Money.ZERO로 의도를 전달한 건 좋았으나, 애초에 부모 클래스인 DiscountPolicy에서 할인 조건이 없을 경우에는 getDiscountAmount()를 호출하지 않으므로 사실 어떤 값을 넣더라도 상관이 없다. -> 따라서 부모인 DiscountPolicy 레벨에서 해결을 해줘보자(->방법2).
    public class NoneDiscountPolicy extends DiscountPolicy {
        @Override
        protected Money getDiscountAmount(Screening screening) {
            return Money.ZERO;
        }
    }

     

    • 방법2 : 방법1에서 문제가 됬던걸 해결하기 위해 DiscountPolicy를 인터페이스로 바꾸고, NoneDiscountPolicy가 getDiscountAmount()가 아닌 calculateDiscountAmount()를 오버라이딩 하도록 변경.
      • 이상적으로는 방법2가 더 좋지만, 현실적으로는 NoneDiscountPolicy만을 위해 인터페이스를 추가하는 것이 과하다는 생각이 들 수도 있을 것이다. 어쨌든 방법1의 NoneDiscountPolicy 클래스 역시 할인 금액이 0원이라는 사실을 효과적으로 전달했었다. -> 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있다. "고민하고 트레이드오프하라."
    public class NoneDiscountPolicy implements DiscountPolicy {
        @Override
        public Money calculateDiscountAmount(Screening screening) {
            return Money.ZERO;
        }
    }

     


     

    CHAPTER 03. 역할, 책임, 협력

    [ 객체지향 패러다임의 관점에서 핵심 ]

    ⚈ 클래스, 상속, 지연 바인딩이 중요하지 않은 것은 아니지만, 구현 측면에 치우쳐 있으므로 객체지향 패러다임의 본질과는 거리가 멀다.

    • 클래스와 상속은 객체들의 책임과 협력이 어느 정도 자리를 잡은 후에 사용할 수 있는 구현 메커니즘일 뿐이다.

     

    ⚈ 객체지향 패러다임의 핵심은 역할(role), 책임(responsibility), 협력(collaboration)이다.

     

    ⚈ 객체지향의 본질은 협력하는 객체들의 공동체를 창조하는 것이다.

     

    ⚈ ☆ 객사오(객체의 사실과 오해)에서도 나오던 역할, 책임, 협력이 드디어 제대로 등장하는 챕터이다. 1~3장을 대강 좁게 정리해보자면 1장에서는 객체 지향에 대해 운을 띄우면서 내부 구현 캡슐화를 통해 변경이 외부로 파급되지 않고 개발자의 자유도를 높이는 법에 대해 알려줬다. 2장에서는 접근제어자를 통한 인터페이스와 구현의 구분으로 구현 은닉하는 것과, 추상화로 상위 정책을 표현해 유연하게 하는법, 다형성을 통한 유연성과 확장성, 그리고 그에 따른 트레이드 오프를 알려줬다. 이제 3장에서는 1,2장에서 배운 좀 더 구현에 치중한 기본적인 객체지향 프로그래밍 언어들의 특징 이전에, 좀 더 개념적인 역할, 책임, 협력이 있다고 얘기하면서 RDD(책임 주도 설계)의 예시를 보여주려고 하는 챕터이다.

     

    ⚈ ☆ 우선 이 책 3장에 나온 역할, 책임, 협력 등을 어떻게 해석해야 할지부터 얘기해보자. 3장을 여러번 읽어보고, 세미나때 나온 얘기들을 종합해서 개인적으로 이해되도록 정의 및 제한을 한 것으로, 저자가 생각한 것과 다를 수도 있다. 이하 용어에 대해 정의 및 제한을 하지 않으면 3장 읽으면서 너무 꼬이는 것 같다(내가 머리가 나빠서 그럴지도 ㅠ).

    • ☆ 협력 : 객체들이 어플리케이션의 기능을 구현하기 위해 수행하는 상호작용. 객체 끼리의 인터페이스(메시지)를 통한 요청-응답 과정이 곧 협력. 이 때 협력과정에서 상대에게 메시지를 보낼 때 요청을 보낸 객체는 요청을 받아 응답해줄 객체가 해당 응답을 어떻게 처리하는지는 신경쓰지 않아야 함(위임).
    • ☆ 책임 : 객체가 협력에 참여하기 위해 수행하는 로직. 객체가 협력에 참여하기 위해 유지해야 하는 정보와 수행할 수 있는 행동에 대해 개략적으로 서술한 문장이다. 즉, 객체가 가져야 하는 상태(아는 것) 및 행동(하는 것) 각각에 대해 서술한 추상적인 개념들이다. 따라서 하나의 객체는 여러 책임을 가질 수 있다.
    • ☆ 객체 : 상태와 행동을 함께 캡슐화하는 실행 단위. 협력이 객체의 행동 목적이고, 상태는 목적을 위해 필요한 정보이므로 협력이 없다면 객체도 없다. 상태 및 행동을 개략적으로 서술한 책임들이 모여 구체화된 것. 코드 구현 수준에서의 객체로 이해해도 될 것 같다. 객체는 여러 역할을 가질 수 있지만 특정한 협력 안에서는 일시적으로 오직 하나의 역할만이 보여진다(implements가 여러개 된 경우 여러 역할을 가지는 경우라고 생각됨).
    • ☆ 역할 : 객체들이 협력 안에서 수행하는 책임들의 집합. 객체가 참여할 수 있는 일종의 슬롯. 공통된 책임을 가진 객체들을 집합시킨 것이 역할이므로, 역할이 된다는건 곧 다형성을 의미한다. 따라서 다형성이 적용된 곳만 역할이라고 표현해야 한다. 즉, 객체와 역할 모두 책임의 집합인데, 하나의 객체로 협력이 가능하다면 객체이고, 다형적으로 사용해서 협력이 가능하다면 객체의 슬롯인 역할이 됨.
    • ☆ 자바의 Map의 경우를 보자. 프로그램을 짜다보니 key-value 쌍으로 데이터를 표현해야 하는 경우가 있다. 현재 그런걸 표현할 방법이 없어서 다른 객체에게 도움을 요청하려고 한다. 즉, 협력을 요청하려고 한다. 그렇다면 협력을 요청할 객체가 가져야 하는 책임은 'key-value 쌍으로 데이터를 표현할 수 있어야 한다.', '여러 건의 데이터를 가질 수 있어야 한다.', '특정 데이터를 가져올 수 있어야 한다.', '특정 데이터를 추가할 수 있어야 한다.' 등이 될 것이다. 이 책임들을 조합하다보니 HashMap, LinkedHashMap('key가 순차적으로 저장되야 한다'는 책임도 추가된 객체) 객체들이 나온다. 그리고 책임에서 좀 차이는 있지만, 협력에 쓰일 객체들의 공통된 책임들을 모아보니 Map이라는 추상화된 역할이 나왔다. 즉, 책임의 집합인 객체에서 공통된 책임을 빼내서 다형적으로 쓰이는 여러 객체가 갈아끼워질 수 있는 슬롯 부분이 역할이다.

     


    01 협력

    [ 영화 예매 시스템 돌아보기 ]

     

     객체지향 원칙을 따르는 애플리케이션의 제어 흐름은 어떤 하나의 객체에의해 통제되지 않고 다양한 객체들 사이에 균형 있게 분배되는 것이 일반적이다.

     

     다양한 객체들이 영화 예매라는 기능을 구현하기 위해 메시지를 주고받으면서 상호작용하고 있다.

    • 이처럼 객체들이 애플리케이션의 기능을 구현하기 위해 수행하는 상호작용을 협력이라고 한다.
    • 객체가 협력에 참여하기 위해 수행하는 로직은 책임이라고 부른다.
    • 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할을 구성한다.

     

     

    [ 협력 ]

     객체지향 시스템은 자율적인 객체들의 공동체

     

     메시지 전송(☆ 인터페이스를 호출하는 것)은 객체 사이의 협력을 위해 사용할 수 있는 유일한 커뮤니케이션 수단이다.

    • 다른 객체의 상세한 내부 구현에 직접 접근할 수 없기 때문

     

     두 객체 사이의 협력은 하나의 객체가 다른 객체에게 도움을 요청할 때(메시지를 전송할 때) 시작된다.

     

    ⚈ 메시지를 수신한 객체는 메서드(☆ 구현)를 실행해 요청에 응답한다. 이 때 객체가 메시지를 처리할 방법(메서드)을 스스로 선택한다는 점이 중요하다. -> 객체는 자신의 일을 스스로 처리할 수 있는 자율적인 존재

     

     자율적인 객체는 자신에게 할당된 책임을 수행하던 중에 필요한 정보를 알지 못하거나 외부의 도움이 필요한 경우 적절한 객체에게 메시지를 전송해서 협력을 요청한다. -> 메시지를 수신한 객체 역시 메시지를 처리하던 중에 직접 처리할 수 없는 정보나 행동이 필요한 경우 또 다른 객체에게 메시지를 전송해서 도움(협력)을 요청한다. -> 이처럼 객체들 사이의 협력을 구성하는 일련의 요청과 응답의 흐름을 통해 애플리케이션의 기능이 구현된다.

     

     애플리케이션 안에 어떤 객체가 필요하다면 그 이유는 단 하나여야 한다. 그 객체가 어떤 협력에 참여하고 있기 때문이다.  객체란 상태와 행동을 함께 캡슐화하는 실행 단위이다.

    • 객체의 행동을 결정하는 것은 객체가 참여하고 있는 협력이다.
    • 협력이 바뀌면 객체가 제공해야 하는 행동 역시 바뀌어야 한다.
    • 협력은 객체가 필요한 이유와 객체가 수행하는 행동의 동기를 제공한다. (즉, 객체의 행동을 결정하는 것이 협력)
    • 객체의 상태는 그 객체가 행동을 수행하는 데 필요한 정보가 무엇인지로 결정된다.
    • 문맥(context) : 협력이 객체를 구성하는 행동과 상태 모두를 결정한다. 따라서 협력은 객체를 설계하는 데 필요한 일종의 문맥을 제공한다.
    • ☆ 내 경우 context의 '어떤 행위를 위해 필요한 정보' 정도로 정의해서 읽으니 이해하기 좋았다.

     


    02 책임

    [ 책임 ]

     협력이 갖춰졌다면, 다음으로는 협력에 필요한 행동을 수행할 수 있는 적절한 객체를 찾는 것이다. 이 때 협력에 참여하기 위해 객체가 수행하는 행동을 책임이라 부른다.

     

     책임이란 객체가 유지해야 하는 정보와 수행할 수 있는 행동에 대해 개략적으로 서술한 문장이다.

     

     책임의 범주 1 : 하는 것(doing)

    • 객체를 생성하거나 계산을 수행하는 등의 스스로 하는 것
    • 다른 객체의 행동을 시작시키는 것
    • 다른 객체의 활동을 제어하고 조절하는 것

     

     책임의 범주 2 : 아는 것(knowing)

    • 사적인 정보에 관해 아는 것
    • 관련된 객체에 관해 아는 것
    • 자신이 유도하거나 계산할 수 있는 것에 관해 아는 것

     

     "객체지향 개발에서 가장 중요한 능력은 책임을 능숙하게 소프트웨어 객체에 할당하는 것"

    • 객체에게 얼마나 적절한 책임을 할당하느냐가 설계의 전체적인 품질을 결정한다.
    • ☆ 객사오의 모든 책임을 가지는 god object는 품질이 낮은것!

     

     

    [ 객체지향 설계 (협력 설계) ]

     책임 할당 : 자율적인 객체를 만드는 가장 기본적인 방법은 책임을 수행하는 데 필요한 정보를 가장 잘 알고 있는 전문가에게 그 책임을 할당하는 것이다.

     

     협력을 설계하는 출발점은 시스템이 사용자에게 제공하는 기능을 시스템이 담당할 하나의 책임으로 바라보는 것이다. 그리고 책임을 완료하는 데 필요한 더 작은 책임을 찾아내고 이를 객체들에게 할당하는 반복적인 과정을 통해 모양을 갖춰간다.

    • 즉, 객체지향 설계는 협력에 필요한 메시지(퍼블릭 인터페이스)를 찾고 메시지에 적절한 객체를 선택하는 반복적인 과정을 통해 이루어진다.
    • 기본적인 전략은 책임을 수행할 정보 전문가를 찾는 것이다.

     

    1. 영화 예매 시스템을 예로 들면 우선 '예매하라'라는 이름의 메시지로 협력을 시작한다.

     

    2. 메시지를 처리할 적절한 객체를 선택한다. 정보 전문가에게 책임을 할당하는 것이다. 영화 예매를 위해서는 상영 시간과 기본 요금을 알아야 한다. 이 정보를 소유하고 있거나 해당 정보의 소유자를 가장 잘 알고 있는 전문가를 선택하자.

     

    3. Screening은 예매 가격을 계산하는데 필요한 정보를 충분히 알고 있지 않다. 예매 정보 전문가이긴 하지만, 영화 가격 자체에 대해서는 정보 전문가가 아니다.

     

    4. 가격을 계산하는 데 필요한 정보를 가장 많이 알고 있는 정보 전문가를 선택해야 한다.

     

     

    [ 책임 주도 설계 ]

     RDD(Responsibility-Driven Design) : 책임을 찾고 책임을 수행할 적절한 객체를 찾아 책임을 할당하는 방식으로 협력을 설계하는 방법. 위의 예시를 보면 이하과 같은 RDD 과정을 거친다.

    • 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.
    • 시스템 책임을 더 작은 책임으로 분할한다.
    • 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.
    • 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
    • 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 된다.

     

     메시지가 객체를 결정한다.

    • 객체에게 책임을 할당하는 데 필요한 메시지를 먼저 식별하고 메시지를 처리할 객체를 나중에 선택했다는 점이 중요하다.
    • 메시지가 객체를 선택하게 한 것이다.
    • 이에 따라 1. 객체가 최소한의 인터페이스를 가질 수 있게 된다. (꼭 필요한 크기의 퍼블릭 인터페이스)
    • 2. 객체는 충분히 추상적인 인터페이스를 가질 수 있게 된다. (인터페이스는 무엇(what)을 하는지는 표현해야 하지만 어떻게(how) 수행하는지를 노출해서는 안된다.)

     

     행동이 상태를 결정한다.

    • 객체를 객체답게 만드는 것은 객체의 상태가 아니라 객체가 다른 객체에게 제공하는 행동이다.
    • 개별 객체의 상태와 행동이 아닌 시스템의 기능을 구현하기 위한 협력에 초점을 맞춰야만 응집도가 높고 결합도가 낮은 객체들을 창조할 수 있다.
    • 상태는 단지 객체가 행동을 정상적으로 수행하기 위해 필요한 재료일 뿐이다.

     


    03 역할

    [ 역할과 협력 ]

     객체는 협력이라는 주어진 문맥 안에서 특정한 목적을 갖게 된다.

    • 객체의 목적은 협력 안에서 객체가 맡게 되는 책임의 집합으로 표시된다.
    • 객체가 어떤 특정한 협력 안에서 수행하는 책임의 집합을 역할이라고 한다.
    • 역할이라는 개념을 고려하지 않을 경우 객체에게 바로 책임을 할당한다면, 이 책에서 예시로 들었던 할인 정책의 경우 금액 할인 정책과 비율 할인 정책에 대해 두 종류의 객체가 참여하는 협력을 개별적으로 만들어야 한다. -> 대부분의 코드가 중복될 것이다. -> 둘 다 '할인 요금 계산'이라는 동일한 책임을 수행할 수 있다. -> 따라서 여러 종류의 객체를 교대로 바꿔 끼울 수 있는 일종의 슬롯인 역할을 추가하자. -> 책임을 수행하는 역할을 기반으로 두 개의 협력을 하나로 통합할 수 있고, 불필요한 중복 코드를 제거할 수 있으며 협력이 더 유연해진다(새로운 할인 정책이 추가될 경우).

    별도의 객체로 보게되면 코드가 중복될 것이다.

    객체의 슬롯인 역할을 추가하자.

     

     

    [ 객체, 역할 ]

     역할은 객체가 참여할 수 있는 일종의 슬롯이다.

     

     책임을 수행하는 대상이 한 종류라면 간단하게 객체로 간주한다.

     

     만약 여러 종류의 객체들이 참여할 수 있다면 역할이라고 부르면 된다.

     

     대부분의 경우 어떤 것이 역할이고 어떤 것이 객체인지 또렷하게 드러나지 않는다.

    • 설계 초반에는 적절한 책임과 협력의 큰 그림을 탐색하는 것이 가장 중요한 목표
    • 역할과 객체를 명확하게 구분하는 것은 그렇게 중요하지 않다.
    • 애매하다면 단순하게 객체로 시작하고 반복적으로 책임과 협력을 정제해가면서 필요한 순간에 객체로부터 역할을 분리해내는 것이 가장 좋은 방법이다. -> 중요한 것은 협력을 위해 어떤 책임이 필요한지를 이해하는 것이다.

     

     역할을 객체의 추상화로 볼 수 있다. 추상화는 상황을 단순화한다. 역할은 협력을 추상화한다. 협력이라는 관점에서는 세부적인 사항을 무시하고 추상화에 집중하는 것이 유용하다.

     

     프레임워크나 디자인 패턴과 같이 재사용 가능한 코드나 설계 아이디어를 구성하는 핵심적인 요소가 바로 역할이다.

     

     객체는 협력이라는 실행 문맥 안에서 특정한 역할을 수행한다. 객체는 협력이 끝나고 협력에서의 역할을 잊고 원래의 객체로 돌아올 수 있다.

    • 동일한 역할을 수행하는 객체들은 서로 대체 가능하다.
    • 객체는 여러 역할을 가질 수 있지만 특정한 협력 안에서는 일시적으로 오직 하나의 역할만이 보여진다.

     


     

    CHAPTER 04. 설계 품질과 트레이드오프

    - 코드 (github)

     

    ⚈ ☆ (2장 관련한 내용) 예를들어 책에서 설명된 영화 할인 정책에서, 현재는 정책이 금액 정책 하나지만 차후 정책이 추가될게 분명해서 미리 추상화 해둔다면 이건 다형성일까? -> 다형성이 맞긴 하지만, 의미 없는 다형성 (YAGNI)

     

    ⚈ 객체지향 설계의 핵심은 책임

    • 책임이 객체지향 애플리케이션 전체의 품질을 결정
    • 객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동

     

    ⚈ 설계는 변경을 위해 존재하고 변경에는 어떤 식으로든 비용이 발생한다.

    • 훌륭한 설계란 합리적인 비용 안에서 변경을 수용할 수 있는 구조를 만드는 것

     

     가끔씩은 좋은 설계보다는 나쁜 설계를 살펴보는 과정에서 통찰을 얻기도 한다. -> ☆ 챕터 4에서 하려고 하는 것!

     


    01 데이터 중심의 영화 예매 시스템

    ⚈ 객체지향 설계에서 시스템을 객체로 분할하는 두 가지 방법

    • 상태(데이터)를 분할의 중심축으로 : 객체는 자신이 포함하고 있는 데이터를 조작하는 데 필요한 오퍼레이션을 정의
    • 책임을 분할의 중심축으로 : 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관

     

     객체의 상태 = 객체가 저장해야 하는 데이터의 집합 (상태 = 데이터). 객체의 상태는 구현에 속한다.

     

     객체의 상태는 구현에 속한다.

    • 구현은 불안정하기 때문에 변화기 쉽다.
    • 상태를 객체 분할의 중심축으로 삼으면 구현에 관한 세부사항이 객체의 인터페이스에 스며들게 되어 캡슐화의 원칙이 무너진다.
    • 상태 변경 -> 인터페이스 변경을 초래 -> 인터페이스에 의존하는 모든 객체에게 변경의 영향이 퍼짐
    • 따라서 변경에 취약

     

     객체의 책임은 인터페이스에 속한다.

    • 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는 데 필요한 상태를 캡슐화 -> 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지
    • 따라서 책임에 초점을 맞추면 상대적으로 변경에 안정적인 설계를 얻을 수 있다.

     


    02 설계 트레이드오프

    [ 캡슐화 ]

    ⚈ 상태와 행동을 하나의 객체 안에 모으는 이유는 객체의 내부 구현을 외부로부터 감추기 위해서이다.

     

     객체지향이 강력한 이유는 한 곳에서 일어난 변경이 전체 시스템에 영향을 끼치지 않도록 파급효과를 적절하게 조절할 수 있는 장치를 제공하기 때문

     

    ⚈외부에는 상대적으로 안정적인 부분만 공개함으로써 변경의 여파를 통제

     

     객체지향에서 가장 중요한 원리

     

     변경될 수 있는 어떤 것이라도 캡슐화해야 한다!

    • ☆ 내부 필드 전부 private으로 두고, 외부에 모든 필드에 대해 getter-setter 썼다고 캡슐화한게 아님. 

     

     

    [ 응집도와 결합도 ]

     응집도

    • 모듈에 포함된 내부 요소들이 연관돼 있는 정도
    • 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈을 높은 응집도를 가짐
    • 객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타낸다.
    •  행동들이 얼마나 상태를 응집도 있게 쓰는지
    • 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도.
    • 응집도 높음 = 변경의 대상과 범위가 명확해지므로 코드를 변경하기 쉬워짐 = 하나의 변경을 수용하기 위해 모듈 전체가 함께 변경됨, 하나의 변경에 대해 하나의 모듈만 변경됨

     

     결합도

    • 의존성의 정도
    • 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도
    • 어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면 두 모듈은 높은 결합도를 가진다.
    • 어떤 모듈이 다른 모듈에 대해 꼭 필요한 지식만 알고 있다면 낮은 결합도를 가진다.
    • 객체지향의 관점에서 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.
    • 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도 -> 하나의 모듈을 수정할 때 얼마나 많은 모듈을 함께 수정해야 하는지
    • 내부 구현을 변경했을 때 이것이 다른 모듈에 영향을 미치는 경우 두 모듈 사이의 결합도가 높다고 표현한다.
    • 퍼블릭 인터페이스를 수정했을 때만 다른 모듈에 영향을 미치는 경우에는 결합도가 낮다고 표현한다.
    • 변경될 확률이 매우 적은 안정적인 모듈(자바의 String, ArrayList처럼)에 의존하는 것은 아무런 문제가 안된다. (결합도에 대해 고민할 필요가 없다.)

     

     좋은 설계

    • 높은 응집도와 낮은 결합도를 가진 모듈로 구성된 설계
    • 오늘의 기능을 수행하면서 내일의 변경을 수용할 수 있는 설계

     


    03 데이터 중심의 영화 예매 시스템의 문제점

    ⚈ 데이터 중심의 설계가 가진 대표적인 문제점

    • 캡슐화 위반
    • 높은 결합도
    • 낮은 응집도

     

     

    [ 캡슐화 위반 ]

     Movie 코드 -> 직접 객체의 내부에 접근할 수 없어서 캡슐화의 원칙을 지키고 있는 것 처럼 보인다.

    public class Movie {
        private Money fee;
        
        public Money getFee() {
            return fee;
        }
    
        public void setFee(Money fee) {
            this.fee = fee;
        }
    }
    • Movie 내부에 Movie 타입의 fee라는 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 드러낸다.
    • 접근자와 수정자 메서드는 객체 내부의 상태에 대한 어떤 정보도 캡슐화하지 못한다.

     

     추측에 의한 설계 전략(design-by-guessing strategy)

    • 접근자와 수정자에 과도하게 의존하는 설계 방식
    • 대부분의 내부 구현이 퍼블릭 인터페이스에 그대로 노출
    • 객체가 사용될 문맥을 추측할 수밖에 없는 경우 개발자는 어떤 상황에서도 해당 객체가 사용될 수 있게 최대한 많은 접근자 메서드를 추가하게 되는 것이다.

     

     

    [ 높은 결합도 ]

     구현이 객체의 인터페이스에 드러난다는 것은 클라이언트가 구현에 강하게 결합된다는 것을 의미

     

     ReservationAgency 코드 -> 위의 Movie 클래스에서 fee의 타입이 바뀌면 ReservationAgency도 수정해야 한다.

    public class ReservationAgency {
        public Reservation reserve(Screening screening, Customer customer,
                                   int audienceCount) {
    		...
            Money fee;
            if (discountable) {
    			...
                fee = movie.getFee().minus(discountAmount).times(audienceCount);
            } else {
                fee = movie.getFee().times(audienceCount);
            }
    
            return new Reservation(customer, screening, fee, audienceCount);
        }
    }
    • 데이터 중심 설계 : 제어 객체가 다수의 데이터 객체에 강하게 결합된다. -> 전체 시스템을 하나의 거대한 의존성 덩어리로 만듬
    • 이 결합도로 인해 어떤 데이터 객체를 변경하더라도 제어 객체를 함께 변경할 수 밖에 없다. -> 어떤 변경이라도 일단 발생하고 나면 시스템 전체가 요동친다.

     

     ReservationAgency는 모든 의존성이 모이는 결합도의 집결지

     

     

    [ 낮은 응집도 ]

     낮은 응집도의 문제

    • 변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 상관이 없는 코드들이 영향을 받게된다.
    • 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다.

     

     어떤 요구사항 변경을 수용하기 위해 하나 이상의 클래스를 수정해야 하는 것은 설계의 응집도가 낮다는 증거

     

     


    04 자율적인 객체를 향해

    [ 캡슐화를 지켜라 ]

    ⚈ 캡슐화는 설계의 제1원리

    • 데이터 중심의 설계가 낮은 응집도와 높은 결합도라는 문제로 몸살을 앓게 된 근본적 원인은 캡슐화의 원칙을 위반했기 때문
    • 객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드를 통해서만 상태에 접근할 수 있어야 한다.
    • 속성의 가시성을 private으로 설정했다고 해도 접근자와 수정자를 통해 속성을 외부로 제공하고 있다면 캡슐화를 위반한 것

     

     

    [ 스스로 자신의 데이터를 책임지는 객체 ]

    ⚈ 우리가 상태와 행동을 객체라는 하나의 단위로 묶는 이유는 객체 스스로 자신의 상태를 처리할 수 있게 하기 위해서이다.

     

    ⚈ 객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.

     

     코드 개선 (github의 step2)

    • ReservationAgency로 새어나간 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮기는 과정 -> DiscountCondition에 isDiscountable 메서드가 필요. Screening을 통해 Reservation을 생성
    • Movie -> getMovieType 메서드, isDiscountable 메서드.
    • Screening -> Movie를 통해 영화 요금 계산
    • 결합도 측면에서 ReservationAgency에 의존성이 몰려있던 첫 번째 설계보다 개선되었다. -> 두 번째 설계가 첫 번째 설계보다 내부 구현을 더 면밀하게 캡슐화하고 있다.

     


    05 하지만 여전히 부족하다

    ⚈ 두 번째 설계 역시 데이터 중심의 설계 방식에 속한다

     

     

    [ 캡슐화 위반 ]

     DiscountCondition

    public class DiscountCondition {
        private DiscountConditionType type;
        private int sequence;
        private DayOfWeek dayOfWeek;
        private LocalTime startTime;
        private LocalTime endTime;
    
        public DiscountConditionType getType() {
            return type;
        }
    
        public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {
           ...
        }
        
        public boolean isDiscountable(int sequence) {
            ...
        }
    }
    • isDiscountable(DayOfWeek dayOfWeek, LocalTime time) -> 객체 내부에 DayOfWeek, LocalTime이 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출한다.
    • DiscountCondition의 속성을 변경할 경우 -> 두 isDiscountable 메서드의 파라미터를 수정하고 해당 메서드를 사용하는 모든 클라이언트도 함께 수정해야 할 것이다. -> 내부 구현의 변경이 외부로 퍼져나가는 파급 효과(ripple effect)는 캡슐화가 부족하다는 명백한 증거

     

     Movie

    public class Movie {
        public Money calculateAmountDiscountedFee() {
            ...
        }
    
        public Money calculatePercentDiscountedFee() {
            ...
        }
    
        public Money calculateNoneDiscountedFee() {
            ...
        }
    }
    • 할인 정책의 종류를 인터페이스에 노출시킨다. (금액 할인 정책, 비율 할인 정책, 미적용)
    • 만약 새로운 할인 정책이 추가되거나 제거될 경우 -> 이 메서드에 의존하는 모든 클라이언트가 영향을 받는다.

     

     

    [ 높은 결합도 ]

     Movie

    public class Movie {
        public boolean isDiscountable(LocalDateTime whenScreened, int sequence) {
            for(DiscountCondition condition : discountConditions) {
                if (condition.getType() == DiscountConditionType.PERIOD) {
                    if (condition.isDiscountable(whenScreened.getDayOfWeek(), whenScreened.toLocalTime())) {
                        return true;
                    }
                } else {
                    if (condition.isDiscountable(sequence)) {
                        return true;
                    }
                }
            }
    
            return false;
        }
    }
    • DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경되면 Movie를 수정해야 한다.
    • DiscountCondition의 종류가 추가되거나 삭제된다면 Movie안의 if - else 구문을 수정해야 한다.
    • condition.isDiscountable 메소드의 인자가 변경될 경우, Movie의 isDiscountable 인자도 변경되어야 하고 결과적으로 Screening까지 변경을 초래하게 된다.
    • 모든 문제의 원인은 캡슐화 원칙을 지키지 않았기 때문

     

     

    [ 낮은 응집도 ]

    ⚈ Screening

    public class Screening {
        public Money calculateFee(int audienceCount) {
            switch (movie.getMovieType()) {
                case AMOUNT_DISCOUNT:
                    if (movie.isDiscountable(whenScreened, sequence)) {
                        return movie.calculateAmountDiscountedFee().times(audienceCount);
                    }
                    break;
                case PERCENT_DISCOUNT:
                    if (movie.isDiscountable(whenScreened, sequence)) {
                        return movie.calculatePercentDiscountedFee().times(audienceCount);
                    }
                case NONE_DISCOUNT:
                    movie.calculateNoneDiscountedFee().times(audienceCount);
            }
    
            return movie.calculateNoneDiscountedFee().times(audienceCount);
        }
    }
    • DiscountCondition의 isDiscountable의 인자가 바뀌면 Movie도 바뀌어야 하고 Screening도 변경되어야 한다. -> 즉, 하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다 = 설계의 응집도가 낮다는 증거

     


    06 데이터 중심 설계의 문제점

    ⚈ 데이터 중심 설계가 변경에 취약한 이유

    • 데이터 중심 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.
    • 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.

     

     데이터 중심 설계는 객체의 행동보다는 상태에 초점을 맞춘다.

    • 데이터 중심 설계의 첫 질문 : "이 객체가 포함해야 하는 데이터가 무엇인가?"
    • 데이터 중심 설계에 익숙한 개발자 : 일반적으로 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다.
    • 접근자와 수정자를 과도하게 추가하게 된다. -> 접근자와 수정자는 public 속성과 큰 차이가 없기 때문에 캡슐화가 무너진다.
    • 개선된 step2 코드에서도 데이터를 먼저 결정하고 데이터를 처리하는데 필요한 오프레이션을 나중에 결정하므로 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러난다. -> 캡슐화에 실패하고 코드는 변경에 취약해진다.
    • 결론적으로 데이터 중심의 설계는 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패하게 된다.
    • 객체의 내부 구현이 객체의 인터페이스를 어지럽히고 객체의 응집도와 결합도에 나쁜 영향을 미치기 때문에 변경에 취약한 코드를 낳게 된다.

     

     데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다.

    • 중요한 것은 객체가 다른 객체와 협력하는 방법이다.
    • 데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다. -> 이미 구현된 객체의 인터페이스를 억지로 끼워맞출수밖에 없다.
    • 따라서 변경에 유연하게 대처하지 못한다.

     


     

    CHAPTER 05. 책임 할당하기

     

      2장에서는 책임을 중심으로 설계된 객체지향 코드의 대략적인 모양을 살펴봄. 5장에서는 2장에서 소개한 코드의 설계 과정을 따라가 보면서 객체에 책임을 할당하는 기본적인 원리를 살펴보는 챕터.

     


    01 책임 주도 설계를 향해

    ⚈ 데이터 중심의 설계에서 책임 중심의 설계로 전환하기 위한 원칙

    • 데이터보다 행동을 먼저 결정하라
    • 협력이라는 문맥 안에서 책임을 결정하라

     

     

    [ 데이터보다 행동을 먼저 결정하라 ]

    ⚈ 데이터는 객체가 책임을 수행하는 데 필요한 재료를 제공할 뿐이다.

     

     객체를 설계하기 위한 질문의 순서를 바꾸자.

    • 데이터 중심의 설계 : "이 객체가 포함해야 하는 데이터가 무엇인가" -> "데이터를 처리하는 데 필요한 오퍼레이션은 무엇인가"
    • 책임 중심의 설계 : "이 객체가 수행해야 하는 책임은 무엇인가" -> "이 책임을 수행하는 데 필요한 데이터는 무엇인가"

     

     

    [ 협력이라는 문맥 안에서 책임을 결정하라 ]

    ⚈ 적합한 책임이란 메시지 수신자가 아니라 메시지 전송자에게 적합한 책임을 의미한다. 즉, 메시지를 전송하는 클라이언트의 의도에 적합한 책임을 할당해야 한다.

     

     "객체를 가지고 있기 때문에 메시지를 보내는 것이 아니다. 메시지를 전송하기 때문에 객체를 갖게 된 것이다."

     

     

    [ 책임 주도 설계 ]

     핵심은 책임을 결정한 후에 책임을 수행할 객체를 결정하는 것. -> 협력에 참여하는 객체들의 책임이 어느 정도 정리될 때까지는 객체의 내부 상태에 대해 관심을 가지지 않는 것.

     

     책임 주도 설계의 흐름

    • 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악
    • 시스템 책임을 더 작은 책임으로 분할
    • 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당
    • 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다
    • 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 된다.

     


    02 책임 할당을 위한 GRASP 패턴

    ⚈ GRASP

    • General Responsibility Assignment Software Pattern (일반적인 책임 할당을 위한 소프트웨어 패턴)
    • 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 지합을 패턴 형식으로 정리한 것

     

     도메인 개념에서 출발하기

    • 설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려 보는 것이 유용하다. -> 출발점으로 삼기 위해.

     

     정보 전문가에게 책임을 할당하라 - Information Expert

    • 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다.
    • 객체가 정보를 '알고' 있다고 해서 그 정보를 '저장'하고 있을 필요는 없다. -> '정보' 전문가에서 '정보'는 '데이터'와 다르다.
    • 스스로 처리할 수 없는 작업이 있다면 외부에 도움을 요청해야 한다.

     

     높은 응집도와 낮은 결합도 - High Cohesion & Low Coupling

    • Low Coupling : 설계의 전체적인 결합도가 낮게 유지되도록 책임을 할당하라.
    • High Cohesion : 높은 응집도를 유지할 수 있게 책임을 할당하라
    • 책임과 협력의 품질을 검토하는 데 사용할 수 있는 중요한 평가 기준

     

     창조자에게 객체 생성 책임을 할당하라 - Creator

    • 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침
    • 객체 A를 생성해야 할 때 이하 조건을 최대한 많이 만족하는 B에게 객체 생성 책임을 할당하라.
      • B가 A 객체를 포함하거나 참조한다.
      • B가 A 객체를 기록한다.
      • B가 A 객체를 긴밀하게 사용한다.
      • B가 A 객체를 초기화하는 데 필요한 데이터를 가지고 있다(이 경우 B는 A에 대한 정보 전문가다)

     

     협력과 책임이 제대로 동작하는지 확인할 수 있는 유일한 방법은 코드를 작성하고 실행해 보는 것뿐이다. 올바르게 설계하고 있는지 궁금한가? 코드를 작성하라.

     


    03 구현을 통한 검증

    ⚈ 변경에 취약한 클래스 : 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스 -> 낮은 응집도 -> 연관성이 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐져 있다 -> 변경의 이유에 따라 클래스를 분리해야 한다.

     

     객체지향 설계에 갓 입문한 개발자들은 클래스 안에서 변경의 이유를 찾는 것이 생각보다 어렵다.

    • 인스턴스 변수가 초기화되는 시점을 살펴보자. -> 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다. -> 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
    • 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼 수 있다. -> 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮은 것이다 -> 이들 그룹을 기준으로 클래스를 분리해야 한다.

     

    ⚈ 다형성을 통해 분리하기 - Polymorphism

    •  객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라. (역할)

     

     변경으로부터 보호하기 - Protected Variations

    • 변경을 캡슐화하도록 책임을 할당하는 것

     

     설계를 주도하는 것은 변경이다.

    • 1. 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계
    • 2. 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것
    • 대부분의 경우 '1'이 더 좋은 방법이지만 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 '2'의 방법이 더 좋다.

     


    04 책임 주도 설계의 대안

    ⚈ 책임 주도 설계에 익숙해지기 위해서는 부단한 노력과 시간이 필요하다.

     

     책임 주도 설계 방법에 익숙하지 않다면 일단 데이터 중심으로 구현한 후 이를 리팩터링하더라도 유사한 결과를 얻을 수 있다.

     

    ⚈ 책임과 객체 사이에서 방황할 때 돌파구를 찾기 위한 방법

    • 최대한 빠르게 목적한 기능을 수행하는 코드를 작성
    • 아무것도 없는 상태에서 책임과 협력에 관해 고민하기 보다는 일단 실행되는 코드를 얻고 난 후에 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키기

     

     리팩터링 : 겉으로 보이는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것

     

     긴 메서드(몬스터 메서드)가 유지보수에 미치는 부정적 영향

    • 코드를 전체적으로 이해하는 데 너무 많은 시간이 걸린다.
    • 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다.
    • 일부 로직만 수정하더라도 버그가 발생할 확률이 높다.
    • 로직의 일부만 재사용하는 것이 불가능
    • 코드를 재사용하는 유일한 방법은 복붙 뿐이므로 코드 중복을 초래하기 쉽다.

     

     메서드를 응집도 있는 수준으로 분해

    • public 메서드가 상위 수준의 명세를 읽는 것 같은 느낌이 든다.
    • 단, 이름을 잘 지었을 때만 그 진가가 드러난다.

     


     

    CHAPTER 06. 메시지와 인터페이스

    ⚈ 훌륭한 객체지향 코드를 얻기 위해서는 클래스가 아니라 객체를 지향해야 한다. -> 즉, 협력 안에서 객체가 수행하는 책임에 초점을 맞춰야 한다.

    • 책임이 객체가 수신할 수 있는 메시지의 기반이 된다.
    • 애플리케이션은 클래스로 구성되지만 메시지를 통해 정의된다.

     

     이번 장은 유연하고 재사용 가능한 퍼블릭 인터페이스를 만드는 데 도움이 되는 설계 원칙과 기법을 살펴보는 것이 주제이다.

     


    01 협력과 메시지

    ⚈ 클라이언트-서버(Client-Server) 모델

    • 협력은 어떤 객체가 다른 객체에게 무언가를 요청할 때 시작된다. -> 객체가 다른 객체에게 접근할 수 있는 유일한 방법은 메시지를 전송하는 것 뿐이다.
    • 두 객체 사이의 협력 관계를 설명하기 위해 사용하는 전통적인 메타포가 클라이언트-서버 모델
    • 클라이언트 : 메시지를 전송하는 객체
    • 서버 : 메시지를 수신하는 객체
    • 협력은 클라이언트가 서버의 서비스를 요청하는 단방향 상호작용이다.

     

     객체는 협력에 참여하는 동안 클라이언트와 서버 역할을 동시에 수행하는 것이 일반적이다.

     

     객체가 독립적으로 수행할 수 있는 것보다 더 큰 책임을 수행하기 위해서는 다른 객체와 협력해야 한다 -> 두 객체 사이의 협력을 가능하게 해주는 매개체가 바로 메시지이다.

     

     용어 정리

    • 메시지 : 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단
      • 객체가 다른 객체와 협력하기 위해 사용하는 의사소통 매커니즘. 
      • 메시지는 오퍼레이션명(operation name)과 인자(argument)로 구성되며, 메시지 전송은 여기에 메시지 수신자를 추가한 것.
    • 메시지 전송(메시지 패싱) : 한 객체가 다른 객체에게 도움을 요청하는 것. 메시지 수신자, 오퍼레이션명, 인자의 조합.
      • condition.isSatisfiedBy(screening); // condition: 수신자, isSatisfiedBy: 오퍼레이션명, screening: 인자
    • 메시지 전송자(sender), 클라이언트 : 메시지를 전송하는 객체. 
    • 메시지 수신자(receiver), 서버 : 메시지를 수신하는 객체
    • 메서드 : 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저
    • 퍼블릭 인터페이스 : 객체가 의사소통을 위해 외부에 공개하는 메시지의 집합
    • 오퍼레이션 : 퍼블릭 인터페이스에 포함된 메시지
    • 시그니처 : 오퍼레이션(또는 메서드)의 이름과 파라미터 목록을 합쳐 부르는 것. 

     


    02 인터페이스와 설계 품질

    ⚈ 좋은 인터페이스

    • 최소한의 인터페이스 : 꼭 필요한 오퍼레이션만을 인터페이스에 포함
    • 추상적인 인터페이스 : 어떻게 수행하는지가 아니라 무엇을 하는지를 표현
    • 좋은 인터페이스를 설계할 수 있는 가장 좋은 방법은 책임 주도 설계 방법을 따르는 것.

     

     퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법

    • 디미터 법칙
    • 묻지 말고 시켜라
    • 의도를 드러내는 인터페이스
    • 명령-쿼리 분리

     

     디미터 법칙(Law of Demeter)

    • 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라.
    • 디미터 법칙을 위반하는 설계는 인터페이스와 구현의 분리 원칙을 위반한다. 또한 위반할 경우 다른 객체의 내부 구조까지 속속들이 알고 있어야 하므로 사용하기도 어렵다.
    • "오직 하나의 도트만 사용하라" -> 단, 디미터 법칙은 객체의 내부 구조가 외부로 노출되는 경우의 결합도에 관한 이야기 이므로, 무조건 하나의 도트만 쓰라는게 아니다. 예를들어 이하 코드와 같이 IntStream이라는 동일한 인스턴스를 반환하는 경우에는 해당되지 않는다.(198page 내용) 
    IntStream.of(1, 15, 20, 3, 9).filter(x->x>10).distinct().count();
    • 이하는 디미터 법칙을 위반하는 코드의 전형적인 모습이다. 전송자가 수신자의 내부 구조에 대해 물어보고 반환받은 요소에 대해 연쇄적으로 메시지를 전송한다. -> 기차 충돌(train wreck) -> 클래스의 내부 구현이 외부로 노출된 경우이다.
    screening.getMovie().getDiscountConditions();

     

     묻지 말고 시켜라(Tell, Don't Ask)

    • 객체의 상태에 관해 묻지 말고 원하는 것을 시켜야 한다.
    • 즉, 메시지 전송자가 수신자의 상태를 기반으로 결정을 내린 후 메시지 수신자의 상태를 바꿔서는 안 된다.
    • 예시
    // 묻지 말고 시켜라 원칙 위배
    public class Audience {
    	public Long setTicket(Ticket ticket) {
        	if (bag.hasInvitation()) {
            	...
            } else {
            	...
            }
        }
    }
    
    VS
    
    // 묻지 말고 시켜라 원칙에 맞게 수정한 코드
    public class Audience {
    	public Long setTicket(Ticket ticket) {
        	return bag.setTicket(ticket);
        }
    }

     

     의도를 드러내는 인터페이스

    • 메서드가 어떻게 수행하느냐가 아니라 무엇을 하느냐에 초점을 맞추면 클라이언트의 관점에서 동일한 작업을 수행하는 메서드들을 하나의 타입 계층으로 묶을 수 있는 가능성이 커진다.
    • 객체의 퍼블릭 인터페이스에 어떤 이름이 드러나야 하는지에 대한 지침을 제공함으로써 코드의 목적을 명확하게 커뮤니케이션할 수 있게 해준다.
    // 메서드가 작업을 어떻게 수행하는지
    public class PeriodCondition {
    	public boolean isSatisfiedByPeriod(Screening screening) {...}
    }
    public class SequenceCondition {
    	public boolean isSatisfiedBySequence(Screening screening) {...}
    }
    
    VS
    
    // 메서드가 무엇을 수행하는지
    public interface DiscountCondition {
    	boolean isSatisfiedBy(Screening screening);
    }
    public class PeriodCondition implements DiscountCondition {
    	public boolean isSatisfiedBy(Screening screening) {...}
    }
    public class SequenceCondition implements DiscountCondition {
    	public boolean isSatisfiedBy(Screening screening) {...}
    }

     


    03 원칙의 함정

    ⚈ 설계는 트레이드오프의 산물이다. 설계를 적절하게 트레이드오프 할 수 있는 능력이 숙련자와 초보자를 구분하는 가장 중요한 기준이다.

     

     묻지 말고 시켜라와 디미터 법칙을 준수하는 것이 항상 긍정적인 결과로만 귀결되는 것은 아니다. 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. -> 응집도가 낮아진다.

     

     객체에게 시키는 것이 항상 가능한 것은 아니다. 가끔씩은 물어야 한다. 원칙을 맹신하지 마라.

     


    04 명령-쿼리 분리 원칙

    ⚈ 용어 정의

    • 루틴(routine) : 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈. 프로시저와 함수로 구분할 수 있다.
    • 프로시저(procedure) : 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류이다. 부수효과를 발생시킬 수 있지만 값을 반환할 수 없다.
    • 함수(function) : 어떤 절차에 따라 필요한 값을 계산해서 반환하는 루틴의 한 종류이다. 값을 반환할 수 있지만 부수효과를 발생시킬 수 없다.
    • 명령(Command) : 객체의 인터페이스 측면에서 프로시저를 부르는 또 다른 이름. 객체의 상태를 수정하는 오퍼레이션.
    • 쿼리(Query) : 객체의 인터페이스 측면에서 함수를 부르는 또 다른 이름. 객체와 관련된 정보를 반환하는 오퍼레이션.

     

     명령-쿼리 분리(Command-Query Separation) 원칙

    • 오퍼레이션은 부수효과(side effect)를 발생시키는 명령이거나 부수효과를 발생시키지 않는 쿼리 중 하나여야 한다
    • "질문이 답변을 수정해서는 안 된다"
    • 쿼리는 객체의 상태를 변경하지 않기 때문에 몇 번이고 반복적으로 호출하더라도 상관이 없다. -> 명령이 개입하지 않는 한 쿼리의 값은 변경되지 않는다. 또한 쿼리들의 순서를 자유롭게 변경할 수도 있다.
    • 부수효과를 가지는 명령으로부터 부수효과를 가지지 않는 쿼리를 명백하게 분리함으로써 제한적이나마 참조 투명성의 혜택을 누릴 수 있게 된다.
      • 참조 투명성(referential transparency) : "어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성"

     


     

    CHAPTER 07. 객체 분해

    ⚈ 문제를 해결하기 위해 사용하는 저장소는 장기 기억이 아니라 단기기억이다.

    • 문제 해결에 필요한 요소의 수가 단기 기억의 용량을 초과하는 순간 문제 해결 능력은 급격하게 떨어진다. -> 인지 과부하(cognitive overload)
    • 인지 과부하 방지 : 단기 기억 안에 보관할 정보의 양을 조절하는 것

     인류가 복잡한 분야(소프트웨어 개발 영역)의 문제를 해결하기 위해 사용한 것

    • 추상화 : 불필요한 정보를 제거하고 현재의 문제 해결에 필요한 핵심만 남기는 작업. 즉, 한 번에 다뤄야 하는 문제의 크기를 줄이는 것 -> 한 번에 단기 기억에 담을 수 있는 추상화의 수에는 한계가 있지만 추상화를 더 큰 규모의 추상화로 압축시킴으로써 단기 기억의 한계를 초월할 수 있다.
    • 분해(decomposition) : 큰 문제를 해결 가능한 작은 문제로 나누는 작업

     


    01 프로시저 추상화와 데이터 추상화

     프로그래밍 언어의 발전

    • 좀 더 효과적인 추상화를 이용해 복잡성을 극복하려는 개발자들의 노력에서 출발
    • 어셈블리어 : 기계어에 인간이 이해할 수 있는 상징을 부여하려는 노력
    • 고수준언어 : 인간의 눈높이에 맞는 기계 독립적이고 의미 있는 추상화를 제공하려는 시도
    • 프로그래밍 언어를 통해 표현되는 추상화의 발전 -> 다양한 프로그래밍 패러다임의 탄생으로 이어짐

     

     추상화와 분해

    • 프로그래밍 패러다임 : 적절한 추상화의 윤곽을 따라 시스템을 어떤 식으로 나눌 것인지를 결정하는 원칙과 방법의 집합
    • 모든 프로그래밍 패러다임은 추상화와 분해의 관점에서 설명할 수 있다.
    • 소프트웨어는 데이터를 이용해 정보를 표현하고 프로시저를 이용해 데이터를 조작한다.
    • 프로시저 추상화(procedure abstraction) : 프로그램이 무엇을 해야 하는지를 추상화
      • 프로시저 추상화를 중심으로 시스템을 분해 -> 기능 분해(functional decomposition, =알고리즘 분해)
    • 데이터 추상화(data abstraction) : 소프트웨어가 무엇을 알아야 하는지를 추상화.
      • 데이터 추상화를 중심으로 시스템을 분해할 경우엔 두 가지 중 하나를 선택해야 함 -> 1. 데이터를 중심으로 타입을 추상화(추상 데이터 타입, Abstract Data Type), 2. 데이터를 중심으로 프로시저를 추상화(객체지향, Object-Oriented)
      • '역할과 책임을 수행하는 객체'가 바로 객체지향 패러다임이 이용하는 추상화. 기능을 '협력하는 공동체'를 구성하도록 객체들로 나누는 과정이 바로 객체지향 패러다임에서의 분해를 의미.

     


    02 프로시저 추상화와 기능 분해

     책에서 객체지향 분해가 '효과적'이라고 말하는 이유를 보기 위해 우선 전통적인 기능 분해 방법을 살펴보는 내용이다.

     

    ⚈ ☆절차지향 패러다임 생각하고 보면 될듯.

     

    ⚈ 전통적인 기능 분해 방법

    • 기능 분해의 관점에서 추상화의 단위는 프로시저. 시스템은 프로시저를 단위로 분해된다.
    • 프로시저는 반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아놓음으로써 로직을 재사용하고 중복을 방지할 수 있는 추상화 방법
    • 시스템은 입력 값을 계산해서 출력 값을 반환하는 수학의 함수와 동일 -> 시스템은 필요한 더 작은 작업으로 분해될 수 있는 하나의 커다란 메인 함수
    • 하향식 접근법(Top-Down Approach)을 따른다. -> 가장 최상위 기능을 정의하고 최상위 기능을 좀 더 작은 단계의 기능으로 분해해 나간다. 분해는 세분화된 마지막 하위 기능이 프로그래밍 언어로 구현 가능한 수준이 될 때까지 계속됨. (트리 구조)
    • 기능 분해의 결과는 최상위 기능을 수행하는 데 필요한 절차들을 시간 순서에 따라 나열한 것
    직원의 급여를 계산한다
      사용자로부터 소득세율을 입력받는다
        "세율을 입력하세요: "라는 문장을 화면에 출력한다
        키보드를 통해 세율을 입력받는다
      직원의 급여를 계산한다
        전역 변수에 저장된 직원의 기본급 정보를 얻는다
        급여를 계산한다
      양식에 맞게 결과를 출력한다
        "이름: {직원명}, 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다

     

     기능 분해 방법은 기능을 중심으로 필요한 데이터를 결정한다.

    • 먼저 필요한 기능을 생각하고 이 기능을 분해하고 정제하는 과정에서 필요한 데이터의 종류와 저장 방식을 식별

     

     하향식 기능 분해의 문제점 1 - 하나의 메인 함수라는 비현실적인 아이디어

    • 어떤 시스템이라도 시간이 지나고 사용자를 만족시키기 위한 새로운 요구사항을 도출해나가면서 지속적으로 새로운 기능을 추가하게 된다.
    • 이 때 대부분의 경우 추가되는 기능은 최초에 배포된 메인 함수의 일부가 아닐 것이다.
    • "실제 시스템이 정상(top)이란 존재하지 않는다."

     

     하향식 기능 분해의 문제점 2 - 메인 함수의 빈번한 재설계

    • 새로운 기능을 추가할 때마다 매번 메인 함수를 수정해야 한다.
    • 기존 코드를 수정하는 것은 항상 새로운 버그를 만들어낼 확률을 높인다 -> 시스템은 변경에 취약해진다.

     

     하향식 기능 분해의 문제점 3 - 비즈니스 로직과 사용자 인터페이스의 결합

    • 하향식 접근법은 비즈니스 로직을 설계하는 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요한다.
    • 사용자 인터페이스는 시스템 내에서 가장 자주 변경되는 부분이고, 비즈니스 로직은 변경이 적게 발생한다. 하지만 하향식 접근법은 이 둘을 섞기 때문에 사용자 인터페이스를 변경하는 경우 비즈니스 로직까지 변경에 영향을 받게 된다.
    • 따라서 변경에 불안정한 아키텍처를 낳는다.

     

     하향식 기능 분해의 문제점 4 - 성급하게 결정된 실행 순서

    • 설계를 시작하는 시점부터 시스템이 무엇(what)을 해야 하는지가 아니라 어떻게(how) 동작해야 하는지에 집중하도록 만든다.
    • 실행 순서나 조건, 반복과 같은 제어 구조를 미리 결정하지 않고는 분해를 진행할 수 없기 때문에 중앙집중 제어 스타일(centralized control style)의 형태를 띌 수밖에 없다.
    • 문제는 함수의 제어 구조가 빈번한 변경의 대상이라는 점 -> 결과적으로 기능을 추가하거나 변경하는 작업은 매번 기존에 결정된 함수의 제어구조를 변경하도록 만든다.

     

     하향식 기능 분해의 문제점 5 - 데이터 변경으로 인한 파급효과

    • 하향식 기능 분해는 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵다. (개별 함수에서 어떤 데이터가 쓰이는지는 쉽지만, 그 반대로 어떤 데이터가 어느 함수에서 사용하는지 추적하기가 어렵다는 말)
    • 따라서 데이터 변경으로 인해 어떤 함수가 영향을 받을지 예상하기 어렵다.

     

     하향식 기능 분해의 문제점 해결책 : 정보 은닉과 모듈

    • 의존성 관리 : 데이터 변경으로 인한 영향을 최소화하려면 데이터와 함께 변경되는 부분과 그렇지 않은 부분을 명확하게 분리해야 한다.
    • 변경에 대한 영향을 최소화하기 위해 영향을 받는 부분과 받지 않는 부분을 명확하게 분리하고 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제하자 -> 정보 은닉과 모듈 개념 제시

     

     하향식 분해가 유용한 경우

    • 설계가 어느 정도 안정화 된 후에는 설계의 다양한 측면을 논리적으로 설명하고 문서화하기에 용이하다.
    • 하향식은 이미 완전히 이해된 사실을 서술하기에 적합한 방법이다.
    • 작은 프로그램과 개별 알고리즘을 위해서는 유용한 패러다임 -> 특히 이미 해결된 알고리즘을 문서화하고 서술하는 데는 훌륭한 기법.

     


    03 모듈

     시스템의 변경을 관리하는 기본적인 전략

    • 함께 변경되는 부분을 하나의 구현단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하도록 만드는 것 -> 즉, 기능이 아니라 변경의 방향에 맞춰 시스템을 분해하는 것.

     

     정보 은닉(information hiding)

    • 시스템에서 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스 뒤로 감쳐워 한다
    • 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리

     

     모듈의 장점과 한계

    • 모듈 내부의 벼눗가 변경되더라도 모듈 내부에만 영향을 미친다.
    • 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다.
    • 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염(namespace pollution)을 방지한다.

     


    04 데이터 추상화와 추상 데이터 타입

     타입(type) : 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수. (예를들어 정수 타입이라면 변수는 임의의 정숫값으로 간주) -> 변수의 값이 어떻게 행동할 것이라는 것을 예측할 수 있게 한다.

     

     기능 분해의 시대에 사용되던 절차형 언어들은 적은 수의 내장 타입만을 제공했으며 새로운 타입을 추가하는 것이 불가능하거나 제한적이었다. -> 보완하기 위해 데이터 추상화(data abstraction) 개념을 제안.

     

     추상 데이터 타입을 구현하기 위해 필요한 프로그래밍 언어의 지원

    • 타입 정의를 선언할 수 있어야 한다
    • 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다
    • 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다
    • 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다

     

     ☆ 추상 데이터 타입은 struct라고 보면 될 것 같다.

     


    05 클래스

     클래스 vs 추상 데이터 타입

    • 클래스는 상속과 다형성을 지원하는 데 비해 추상 데이터 타입은 지원하지 못한다
    • 객체지향 프로그래밍(Object-Oriented Programming) : 상속과 다형성 지원
    • 객체기반 프로그래밍(Object-Based Programming) : 상속과 다형성을 지원하지 않는 추상 데이터 타입 기반의 프로그래밍 패러다임
    • 추상 데이터 타입 : 오퍼레이션을 기준으로 타입을 묶는 방법
    • 객체지향 : 타입을 기준으로 오퍼레이션을 묶는다.

     

     개방-폐쇄 원칙(Open-Closed Principle, OCP)

    • 기존 코드에 아무런 영향도 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성
    • 객체지향 설계가 전통적인 방식에 비해 변경하고 확장하기 쉬운 구조를 설계할 수 있는 이유

     


     

    CHAPTER 08. 의존성 관리하기

    ⚈ ☆ 마침 운좋게 스터디에서 8장, 9장이 같은 주차에 들어갔다. 8장과 9장 자체가 연계되는거라 운이 좋았다. 객체에서 자기 자신이 사용할걸 직접 생성하는게 맞는지, 쓰는쪽에서 주입해주는게 맞는지와 유연성있게 짜면 코드가 복잡해지는 느낌이라는게 고민이었다. 8장, 9장에서 그 부분을 설명해줘서 평소 궁금하던 부분이 해결되어 좋았다.

     

    ⚈ 8장은 충분히 협력적이면서도 유연한 객체를 만들기 위해 의존성을 관리하는 방법을 살펴본다.

     


    01 의존성 이해하기

    ⚈ 의존성

    • 두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께 변경될 수 있다는 것을 의미한다.
    • 어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 존재
    • 의존성은 방향성을 가지며 항상 단방향이다.
    • 의존성은 변경에 의한 영향의 전파 가능성을 암시한다.
    • 즉, 의존성은 함께 변경될 수 있는 가능성을 의미한다.

     

    ⚈ 예를들어 이하와 같이 의존성이 존재할 경우 어떤 형태로든 DayOfWeek, LocalTime, Screening, DiscountCondition이 변경된다면 PeriodCondition도 함께 변경될 수 있다.

     

     의존성 전이 (transitive dependency)

    • A가 B에 의존하고, B가 C에 의존할 경우 A도 C에 간접적으로 의존하게 된다는 의미
    • 실제로 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다. 즉, 의존성 전이는 변경에 의해 영향이 널리 전파될 수도 있다는 경고일 뿐이다.

     

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

    • 런타임 : 애플리케이션이 실행되는 시점
    • 컴파일타임 : 일반적으로 작성된 코드를 컴파일하는 시점이지만, 여기서는 코드 그 자체를 가리킴.
    • 런타임 의존성 : 객체 사이의 의존성
    • 컴파일타임 의존성 : 클래스 사이의 의존성
    • 런타임 의존성과 컴파일타임 의존성이 다를 수 있다. (☆ 다형성)
    • 유연하고 확장 가능한 설계를 만들기 위해서는 컴파일타임 의존성과 런타임 의존성이 달라야 한다.
    • e.g. 컴파일타임 의존성(위), 런타임 의존성(아래) : 코드 작성 시점의 Movie 클래스는 할인 정책을 구현한 두 클래스의 존재를 모르지만 실행 시점의  Movie 객체는 두 클래스의 인스턴스와 협력할 수 있게 된다.

    Movie의 컴파일타임 의존성

    Movie의 런타임 의존성 예시

     

     컨텍스트 독립성

    • 클래스는 자신과 협력할 객체의 구체적인 클래스에 대해 알아서는 안 된다. 구체적인 클래스를 알면 알수록 그 클래스가 사용되는 특정한 문맥에 강하게 결합되기 때문이다. 강하게 결합될수록 다른 문맥에서 사용하기는 더 어려워진다.
    • 컨텍스트 독립성 : 클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해진다.

     

     의존성 해결

    • 클래스가 실행 컨텍스트에 독립적인데도 어떻게 런타임에 실행 컨텍스트에 적절한 객체들과 협력할 수 있을까?
    • 컴파일타임 의존성은 구체적인 런타임 의존성으로 대체돼야 한다.
    • 컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 부른다.

     

     의존성 해결 방법

    • 객체를 생성하는 시점에 생성자를 통해 의존성 해결 (☆스프링 DI가 해결해주는 것)
      • ☆ 제일 좋은 방법같다.
    • 객체 생성 후 setter 메서드를 통해 의존성 해결 (☆스프링 DI가 해결해주는 것)
      • 객체를 생성한 이후에도 의존하고 있는 대상을 변경할 수 있다.
      • 의존 대상 설정 전까지는 불완전할 수 있으므로 생성자 방식과 setter 방식을 혼합하는 것이 더 좋다.
    • 메서드 실행 시 인자를 이용해 의존성 해결
      • 협력 대상에 대해 지속적으로 의존 관계를 맺을 필요 없이 메서드가 실행되는 동만만 일시적으로 의존 관계가 존재해도 무방하거나, 메서드가 실행될 때마다 의존 대상이 매번 달라져야 하는 경우에 유용하다.

     


    02 유연한 설계

     의존성과 결합도

    • 의존성과 결합도는 일반적으로 동의어이지만 서로 다른 관점에서 관계의 특성을 설명하는 용어다.
    • 의존성은 두 요소 사이의 관계 유무를 설명한다.(e.g. "의존성이 존재한다.")
    • 결합도는 두 요소 사이에 존재하는 의존성의 정도를 상대적으로 표현한다. (e.g. "결합도가 느슨하다")
    • 의존성이 바람직할 때(재사용을 쉽게 허용한다면) 두 요소가 느슨한 결합도(loose coupling) 또는 약한 결합도(weak coupling)을 가진다고 말한다.
    • 두 요소 사이의 의존성이 바람직하지 못할 때(어떤 의존성이 재사용을 방해할 때) 단단한 결합(tight coupling) 또는 강한 결합도(strong coupling)을 가진다고 말한다.
    • 모든 의존성이 나쁜 것은 아니다. 의존성은 객체들의 협력을 가능하게 만드는 매개체라는 관점에서 바람직하지만, 의존성이 과하면 문제가 될 수 있다.

     

     지식의 양과 결합도

    • 서로에 대해 알고 있는 지식의 양이 결합도를 결정한다.
    • 한 요소가 다른 요소에 대해 더 많은 정보를 알고 있을수록 두 요소는 강하게 결합된다.
    • 한 요소가 다른 요소에 대해 더 적은 정보를 알고 있을수록 두 요소는 약하게 결합된다.
    • 결합도를 느슨하게 만들기 위해서는 협력하는 대상에 대해 필요한 정보 외에는 최대한 감추는 것이 중요하다. -> 추상화

     

     추상화와 결합도

    • 추상화 : 특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법
    • 대상에 대해 알아야 하는 지식의 양을 줄일 수 있기 때문에 결합도를 느슨하게 유지할 수 있다.
    • 의존하는 대상이 더 추상적일수록 결합도는 더 낮아진다.

     

     의존 대상 구분 (아래쪽으로 갈수록 알아야 하는 지식의 양이 적어지기 때문에 결합도가 느슨해진다.)

    • 구체 클래스 의존성
    • 추상(abstract) 클래스 의존성
    • 인터페이스(interface) 의존성

     

     명시적인 의존성 (explicit dependency)

    • 퍼블릭 인터페이스에 의존성을 명시적으로 노출하는 것
    • 의존성이 명시적이지 않으면 의존성을 파악하기 위해 내부 구현을 직접 살펴볼 수밖에 없다(숨겨진 의존성(hidden dependency)). 또한 클래스를 다른 컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 한다.
    • 의존성을 명시적으로 드러내면 코드를 직접 수정해야 하는 위험을 피할 수 있다. 실행 컨텍스트에서 적절한 의존성을 선택할 수 있기 때문이다.
    • 유연하고 재사용 가능한 설계란 퍼블릭 인터페이스를 통해 의존성이 명시적으로 드러나는 설계다.

     

     new는 해롭다

    • new 연산자 사용을 위해 구체 클래스의 이름을 직접 기술해야 한다. new를 사용하는 클라이언트는 추상화가 아닌 구체 클래스에 의존할 수밖에 없기 때문에 결합도가 높아진다.
    • new 연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 이용해 클래스의 생성자를 호출해야 하는지도 알아야 한다. new를 사용하면 클라이언트가 알아야 하는 지식의 양이 늘어나기 때문에 결합도가 높아진다.
    • 예를들어 코드가 아래와 같은 경우, Movie가 더 많은 것에 의존하면 의존할수록 점점 더 변경에 취약해진다.
    public class Movie {
    	private DiscountPolicy discountPolicy;
        public Movie(String title, Duration runningTime, Money fee) {
        	this.discountPolicy = new AmountDiscountPolicy(Money.wons(800),
            					new SequenceCondition(1),
                                new SequenceCondition(10),
                                new PeriodCondition(DayOfWeek.MONDAY,
                                	LocalTime.of(10, 0), LocalTime.of(11, 59)),
                                new PeriodCondition(DayOfWeek.THURSDAY,
                                	LocalTime.of(10, 0), LocalTime.of(20,59))));
        }
    }

     

     new로 인한 결합도 증가 해결 방법

    • 인스턴스를 생성하는 로직과 생성된 인스턴스를 사용하는 로직을 분리해서 해결할 수 있다.
    • 위 코드에서 Movie는 인스턴스를 생성해서는 안 된다. 단지 해당하는 인스턴스를 전달받아(생성자, setter, 인자를 통해) 사용하기만 해야 한다.
    • 이하는 Movie에서 사용할 DiscountPolicy를 생성하는 책임을 Movie의 클라이언트로 옮기고, Movie에서는 생성된 인스턴스를 사용하는 책임만 남은 코드이다.
    public class Movie {
    	private DiscountPolicy discountPolicy;
        public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        	this.discountPolicy = discountPolicy;
        }
    }
    • 생성의 책임을 클라이언트로 옮김으로써 이제 Movie는 DiscountPolicy의 모든 자식 클래스와 협력할 수 있게 됐다. -> 설계가 유연해졌다.
    • 사용과 생성의 책임을 분리하고 생성하는 책임을 클라이언트로 옮김, 의존성을 생성자에 명시적으로 드러냄, 구체 클래스가 아닌 추상 클래스에 의존하게 함 -> 설계가 유연해졌다.

     

     가끔은 생성해도 무방하다

    • 인스턴스를 직접 생성하는 방식이 유용한 경우도 있다. 주로 협력하는 기본 객체를 설정하고 싶은 경우이다.
    • 기본값을 생성하는 메서드와 인스턴스를 인자로 받는 메서드를 함께 사용한다면 클래스의 사용성을 향상시키면서도 다양한 컨텍스트에서 유연하게 사용될 수 있는 여지를 제공할 수 있다.
    public class Movie {
    	private DiscountPolicy discountPolicy;
        
        public Movie(String title, Duration runnintTime, Money fee) {
    		this(title, runningTime, fee, new AmountDiscountPolicy(...));
    	}
        
        public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        	this.discountPolicy = discountPolicy;
        }
    }
    • 단 이건 결합도와 사용성의 트레이드오프 이다. 구체 클래스에 의존하게 되더라도 클래스의 사용성이 더 중요하다면 결합도를 높이는 방향으로 작성할 수 있다.
    • 종종 모든 결합도가 모이는 FACTORY를 추가함으로써 사용성과 유연성 두 마리 토끼를 잡을 수 있는 경우도 있다. (9장에 나옴)
    • ☆ 클린코드에 생성자 오버로딩이 필요할 시 정적 팩토리 메소드를 쓰라는 말이 있다. 위 예시도 저 방식보다는 정적 팩토리 메소드로 withAmountDiscountPolicy()로 구현하는게 더 좋을 것 같다.

     

     표준 클래스에 대한 의존은 해롭지 않다.

    • 변경될 확률이 거의 없는 클래스라면 의존성이 문제되지 않는다.
    • 이런 클래스들에 대해서는 구체 클래스에 의존하거나 직접 인스턴스를 생성하더라도 문제가 없다. -> 물론 추상적인 타입을 사용하는 것이 확장성 측면에서 유리하다.

    Movie의 결합도를 낮춤으로써 무엇이 가능해졌는가?

     Movie를 DiscountPolicy라는 추상화에 의존하게 했고, 생성자를 통해 DiscountPOolicy에 대한 의존성을 명시적으로 드러냈으며, new와 같이 구체 클래스를 직접적으로 다뤄야 하는 책임을 Movie 외부로 옮겼다. -> 유연한 설계 ! -> Movie를 수정하지 않고도 새로운 기능을 추가하는 것이 쉬워진다.

     

     할인 혜택을 제공하지 않는 영화의 예매 요금을 계산 -> Movie는 바뀔 필요가 없다.

    public class NoneDiscountPolicy extends DiscountPolicy {
    	@Override
        protected Money getDiscountAmount(Screening screening) {
        	return Money.ZERO;
        }
    }
    
    ...
    Movie avatar = new Movie("아바타",
    			Duration.ofMinutes(120),
                Money.wons(10000),
                new NoneDiscountPolicy());

     

     중복 적용이 가능한 할인 정책 구현 -> Movie는 바뀔 필요가 없다.

    public class OverlappedDiscountPolicy extends DiscountPolicy {
    	
        private List<DiscountPolicy> discountPolicies = new ArrayList<>();
        
        public OverlappedDiscountPolicy(DiscountPolicy... discountPolicies) {
        	this.discountPolicies = Arrays.asList(discountPolicies);
        }
        
    	@Override
        protected Money getDiscountAmount(Screening screening) {
        	Money result = Money.ZERO;
            for (DiscountPolicy each : discountPolicies) {
            	result = result.plus(each.calculateDiscountAmount(screening));
            }
        }
    }
    
    ...
    Movie avatar = new Movie("아바타",
    			Duration.ofMinutes(120),
                Money.wons(10000),
                new OverlappedDiscountPolicy(
                	new AmountDiscountPolicy(...),
                    new PercentDiscountPolicy(...)));

     

     결합도를 낮춤으로써 얻게 되는 컨텍스트의 확장이라는 개념이 유연하고 재사용 가능한 설계를 만드는 핵심이다.

     

     유연하고 재사용 가능한 설계는 객체가 어떻게(how) 하는지를 장황하게 나열하지 않고도 객체들의 조합을 통해 무엇(what)을 하는지를 표현하는 클래스들로 구성된다.

     


     

    CHAPTER 09. 유연한 설계

    ⚈ 8장에서 살펴본 다양한 의존성 관리 기법들을 원칙이라는 관점에서 정리하는게 9장이다.

    • ☆ 마침 스터디 순서상 이번주차에 8장과 9장이었는데, 같이보게 되서 매우 다행이었다. 8장 내용 중복 겸 +@로 설명해주는게 9장이다. 특히 평소 헷갈렸던 부분들을 해결해주는 부분이 많아서 개인적으로 많이 좋았던 장이다. SOLID 중 OCP도 다른 글들 봐도 명확히 이해가 안됬는데 이 책에 답이 있었다.

     


    01 개방-폐쇄 원칙

     OCP (Open-Closed Principle)

    • "소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다."

     

     '확장'과 '수정'

    • 확장 = 동작의 관점, 수정 = 코드의 관점
    • '확장'에 대해 열려 있다 : 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 '동작'을 추가해서 애플리케이션의 기능을 '확장'할 수 있다.
    • '수정'에 대해 닫혀 있다 : 기존의 '코드'를 수정하지 않고도 애플리케이션의 '동작'을 추가하거나 변경할 수 있다.

     

    ⚈ OCP는 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기한다. 애플리케이션의 동작을 확장하려면 일반적으로 어찌됬든 코드를 수정해야 하는데 어떻게 코드를 수정하지 않고도 동작을 추가할 수 있을까?

     

     사실 OCP는 런타임 의존성과 컴파일타임 의존성에 관한 이야기다(8장에 나오는 얘기임).

    • 런타임 의존성 : 실행시에 협력에 참여하는 객체들 사이의 관계
    • 컴파일타임 의존성 : 코드에서 드러나는 클래스들 사이의 관계
    • 8장에서 얘기했듯이 유연하고 재사용 가능한 설계에서 런타임 의존성과 컴파일타임 의존성은 서로 다른 구조를 가진다.
    • OCP를 수용하는 코드는 컴파일타임 의존성을 수정하지 않고도 런타임 의존성을 쉽게 변경할 수 있는 코드이다.
      (☆ OCP에 대해 그동안 명확하지 않았었는데, 이 내용을 보고 ㅗㅜㅑ 소리가 났다. 굳)

     

     예를들어 8장 마지막 부분에서 나왔던 Movie의 경우

    • 확장에 대해 열려 있다 : 새로운 할인 정책을 추가해서 기능을 확장할 수 있도록 허용한다.
    • 수정에 대해서는 닫혀 있다 : 기존 코드를 수정할 필요 없이 새로운 클래스를 추가하는 것만으로 새로운 할인 정책을 확장할 수 있다.

     

    ⚈ 추상화가 핵심이다

    • 추상화 : 핵심적인 부분만 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법
    • OCP의 핵심은 추상화에 의존하는 것이다.
    • OCP의 관점에서 생략되지 않고 남겨진 부분은 다양한 상황에서 공통점을 반영한 추상화의 결과물이다. 공통적인 부분은 문맥이 바뀌더라도 변하지 않아야 한다. 즉, 수정할 필요가 없어야 한다. 따라서 추상화 부분은 수정에 대해 닫혀 있다.
    • 추상화를 통해 생략된 부분은 확장의 여지를 남긴다. 언제라도 추상화의 생략된 부분을 채워넣음으로써 새로운 문맥에 맞게 기능을 확장할 수 있다. 즉, 확장에 대해 열려 있다. 

     

    ⚈ 추상화를 했다고 OCP를 만족하게 되는 것은 아니다.

    • OCP를 만족하는 설계는 공짜로 얻어지지 않는다.
    • 추상화가 수정에 대해 닫혀 있을 수 있는 이유는 변경되지 않을 부분을 신중하게 결정하고 올바른 추상화를 주의 깊게 선택했기 때문이다.

     


    02 생성 사용 분리

    ⚈ 추상화에만 의존하기 위해서는 구체 클래스의 인스턴스를 직접 생성해서는 안 된다.

    • 동작을 추가하거나 변경하기 위해 기존의 코드를 수정하도록 만들기 때문에 OCP를 위반한다.
    • 물론 객체 생성을 피할 수는 없다. 어딘가에서는 반드시 객체를 생성해야 한다. 문제는 객체 생성 자체가 아니라 부적절한 곳에서 객체를 생성하는 것이다.
    • 즉, 동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제이다.

     

    ⚈ 생성, 사용

    • 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다.
    • 하나는 객체를 '생성'하는 것이고
    • 다른 하나는 객체를 '사용'하는 것이다.
    • 객체에 대한 '생성과 사용을 분리(separating use from creation)' 해야 한다.

     

    ⚈ 생성의 책임을 클라이언트로 옮기자

    • 가장 보편적인 생성 사용 분리 방법이다.
    • 예를 들어 Movie에게 금액 할인 정책을 적용할지, 비율 할인 정책을 적용할지를 알고 있는 것은 그 시점에 Movie와 협력할 클라이언트이다. 현재의 컨텍스트에 관한 결정권을 가지고 있는 클라이언트로 컨텍스트에 대한 지식을 옮김으로써 Movie는 특정한 클라이언트에 결합되지 않고 독립적일 수 있다.

     

    ⚈ FACTORY 추가하기

    • 위처럼 클라이언트로 생성의 책임을 옮기는 것은, Movie는 특정 컨텍스트에 묶여서는 안 되지만 Client는 묶여도 상관없다는 전제가 깔려 있다.
    • 이 때 Client도 생성과 사용의 책임을 함께 지니고 있다고 해보자.
    • 그럼 Movie에서 생성의 책임을 Client로 옮긴 것과 마찬가지로, Movie를 생성하는 책임을 Client의 인스턴스를 사용할 문맥을 결정할 클라이언트로 옮길 수 있다.
    • 하지만 이 때 객체 생성과 관련된 지식이 Client와 협력하는 클라이언트에게까지 새어나가기를 원하지 않는다고 가정해보자. 즉, Client도 특정한 컨텍스트에 묶이지 않기를 바란다고 가정해보자.
    • 이 경우 객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 클라이언트는 이 객체를 사용하도록 만들 수 있다. 이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부른다.

     


    객체지향이 실세계의 모방이라는 말은 옳지 않다.

     GRASP - INFORMATION EXPERT

    • ☆ GRASP에 대해서는 5장 정리 내용 참고. (링크)
    • 어떤 책임을 할당하고 싶다면 제일 먼저 도메인 모델 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 한다.
    • 그런데 위에 나온 FACTORY는 도메인 모델에 속하지 않는다.
    • FACTORY를 추가한 이유는 순수하게 기술적인 결정이다. 도메인 개념에게 할당돼 있던 객체 생성 책임을 도메인 개념과는 아무런 상관이 없는 가공의 객체로 이동시킨 것이다.

     

     순수한 가공물 (PURE FABRICATION)

    • 종종 도메인 개념을 표현하는 객체에게 책임을 할당하는 것만으로는 부족한 경우가 발생한다. 
    • 실제로 애플리케이션은 DB 접근을 위한 객체와 같이 도메인 개념들을 초월하는 기계적인 개념들을 필요로 할 수 있다.
    • 모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 심각한 문제점에 봉착하게 될 가능성이 높아진다.
    • 이 경우 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당해서 문제를 해결해야 한다.
    • 이처럼 책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체를 순수한 가공물이라고 부른다.

     

     객체지향

    • 도메인 개념뿐만이 아니라 설계자들이 임의적으로 창조한 인공적인 추상화들을 포함하고 있다.
    • 이런 측면에서 객체지향이 실세계의 모방이라는 말은 옳지 않다.
    • 객체지향이 실세계를 모방해야 한다는 헛된 주장에 현혹될 필요가 없다. -> 우리가 애플리케이션을 구축하는 것은 사용자들이 원하는 기능을 제공하기 위해서지 실세계를 모방하거나 시뮬레이션하기 위한 것이 아니다.

     

     설계

    • 먼저 도메인의 본질적인 개념을 표현하는 추상화를 이용해 애플리케이션 구축 시작
    • 도메인 개념이 만족스럽지 못하다면 인공적인 객체를 창조하라
    • 예를 들어 FACTORY는 객체의 생성 책임을 할당할 만한 도메인 객체가 존재하지 않을 때 선택할 수 있는 PURE FABRICATION 이다.

     


    03 의존성 주입

     DI (Dependency Injection)

    • 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법
    • DI는 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서 외부에서 필요한 런타임 의존성을 전달할 수 있또록 만드는 방법을 포괄하는 명칭이다.

     

     의존성 주입에서 의존성을 해결하는 세 가지 방법을 가리키는 용어 정의

    • 생성자 주입(constructor injection) : 객체를 생성하는 시점에 생성자를 통한 의존성 해결
    • setter 주입(setter injection) : 객체 생성 후 setter 메서드를 통한 의존성 해결. 런타임에 의존성 대상을 변경 가능하다. 다만 setter 메서드 호출을 누락한다면 객체는 비정상적인 상태로 생성될 것이다.
    • 메서드 주입(method injection) : 메서드 실행 시 인자를 이용한 의존성 해결. 메서드가 의존성을 필요로 하는 유일한 경우일 때 사용할 수 있다. 주입된 의존성이 한 두 개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 것이 더 나은 방법일 수 있다.
    • 번외 - 인터페이스 주입(interface injection) : 근본적으로 setter 주입이나 프로퍼티 주입과 동일하지만, 인터페이스에 명시적으로 주입이 필요하다는 점을 선언하는 방법. 이하 인터페이스 주입의 예
    public interface DiscountPolicyInjectable {
    	public void inject(DiscountPolicy discountPolicy);
    }

     

     숨겨진 의존성은 나쁘다

    • SERVICE LOCATOR 패턴은 나쁘다. -> 의존성을 감춘다. ☆ 책에서 제시된 코드만 보면 동시성 문제도 있어보인다.
    • 의존성을 감추는 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다.
    • 숨겨진 의존성은 문제점을 발견할 수 있는 시점을 실행 시점으로 미루기 때문에 이해하기 어렵고 디버깅하기 어렵다.

     

     숨겨진 의존성은 캡슐화를 위반한다.

    • 변수의 가시성을 private으로 선언하고 변경되는 내용을 숨겼다고 해서 캡슐화가 지켜지는 것은 아니다.
    • 클래스의 사용법을 익히기 위해 구현 내부를 샅샅이 뒤져야 한다면 그 클래스의 캡슐화는 무너진 것이다.
    • 명시적인 의존성이 숨겨진 의존성보다 좋다. 가급적 의존성을 객체의 퍼블릭 인터페이스에 노출하라. 의존성을 구현 내부에 숨기면 숨길수록 코드를 이해하기도, 수정하기도 어려워진다.

     

     요점은 명시적인 의존성

    • 유연성을 향상시키는 가장 효과적인 방법이다.

     


    04 의존성 역전 원칙

     DIP (Dependency Inversion Principle)

    • 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
    • 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.
    • 로버트 마틴이 '역전'이라는 단어를 사용한 이유 : 의존성 역전 원칙을 따르는 설계는 의존성의 방향이 전통적인 절차형 프로그래밍과는 반대 방향으로 나타나기 때문
    • ☆ 자바에서 Map<HashSet<String>> 과 같은 형태로 사용하는 것도 추상화 내부에 구체클래스가 나오므로 동일하게 DIP를 위반할듯하다.

     

     해결사는 추상화

    • 유연하고 재사용 가능한 설계를 원한다면 모든 의존성의 방향이 추상 클래스나 인터페이스와 같은 추상화를 따라야 한다.
    • ☆ 결국 SOLID 원칙 중 OCP와 DIP의 해결책은 둘 다 추상화이다. 추상화를 바라보는 관점에 따라 의존성의 개념이라면 DIP, 확장의 개념이라면 OCP를 충족시킬 수 있다.

     

     대부분의 경우 우리가 재사용하려는 대상은 상위 수준의 클래스이다.

    • 상위 수준의 클래스가 하위 수준의 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워진다.
    • 같은 의미로 추상화를 별도의 독립적인 패키지가 아니라 클리이언트가 속한 패키지에 포함시켜야 한다. 그리고 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다. (SEPARATED INTERFACE 패턴)
    • DIP에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 한다.

     

     훌륭한 객체지향 설계를 위해서는 의존성을 역전시켜야 한다.

    • 의존성을 역전시켜야만 유연하고 재사용 가능한 설계를 얻을 수 있다.

     


    05 유연성에 대한 조언

     유연한 설계가 항상 좋은 것은 아니다.

    • 변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 단순함과 명확함의 미덕을 버리게될 가능성이 높다.
    • 유연한 설계라는 말의 이면에는 복잡한 설계라는 의미가 숨어 있다.
    • 설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어진다. 유연함은 단순성과 명확성의 희생 위에서 자라난다.

     

     정말 유연성이 필요한가?

    • 변경은 예상이 아니라 현실이어야 한다.
    • 미래에 변경이 일어날지도 모른다는 막연한 불안감은 불필요하게 복잡한 설계를 낳는다.
    • 아직 일어나지 않은 변경은 변경이 아니다. (☆ 익스트림 프로그래밍(XP)의 YAGNI 원칙을 뜻하는 것 같다.)

     

     트레이드 오프

    • 단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거하라.
    • 유연성은 코드를 읽는 사람들이 복잡함을 수용할 수 있을 때만 가치가 있다.
    • 하지만 복잡성에 대한 걱정보다 유연하고 재사용 가능한 설계의 필요성이 더 크다면 코드의 구조와 실행 구조를 다르게 만들어라.

     


    핵심은 협력과 책임

     객체를 생성하는 방법에 대한 결정은 모든 책임이 자리를 잡은 후 가장 마지막 시점에 내리는 것이 적절하다.

    • 핵심은 협력과 책임이 중요하다는 것이다.
    • 다양한 컨텍스트에서 협력을 재사용할 필요가 없다면 설계를 유연하게 만들 당위성도 함께 사라진다.
    • 동일한 책임을 수행하는지 여부를 판단할 수 없다면 공통의 추상화를 도출할 수 없다.
    • 역할을 통해 객체들을 대체 가능하게 만들지 않았다면 협력에 참여하는 객체들을 교체할 필요가 없다.
    • 책임을 할당하고 협력의 균형을 맞추는 것이 객체 생성에 관한 책임을 할당하는 것보다 우선이다.
    • 책에 나온 개발자가 평소 하던 일 : "객체가 무엇이 되고 싶을지를 알게 될 때까지 객체들을 어떻게 인스턴스화할 것인지에 대해 전혀 신경쓰지 않았다. 마치 객체가 이미 존재하는 것처럼 이들 간의 관계를 신경 쓰는 일이다."

     

     의존성을 관리해야 하는 이유는 역할, 책임, 협력의 관점에서 설계가 유연하고 재사용 가능해야 하기 때문이다.

    • 9장의 기법들을 적용하기 전에 역할, 책임, 협력의 모습이 선명하게 그려지지 않는다면 의존성을 관리하는 데 들이는 모든 노력이 물거품이 될 수도 있다.

     


     

    CHAPTER 10. 상속과 코드 재사용

    ⚈ 코드 재사용

    • 전통적인 패러다임 : 코드를 복사한 후 수정
    • 객체지향 : 코드를 재사용하기 위해 '새로운' 코드를 추가. 객체지향에서 클래스를 재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것.

     

    ⚈ 이번 장에서는 상속을 통한 코드 재사용을 알아보고, 11장에서 코드를 효과적으로 재사용할 수 있는 합성을 알아본 후 상속과 합성의 장단점을 비교하게 된다.

     


    01 상속과 중복 코드

    ⚈ 중복 코드는 사람들의 마음속에 의심과 불신의 씨앗을 뿌린다.

    • ☆ ㄹㅇㅋㅋ 단순 복붙해둔 중복코드 보면 다른 부분은 보고싶지도 않음.

     

     중복 여부를 판단하는 기준은 변경이다.

    • 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다.
    • ☆ 똑같이 생긴걸 복붙한걸 중복 코드라고 생각했는데, 이 책에서 말하는 중복의 기준을 보니 확실히 책의 말이 맞는 것 같다. 동일한 이유로 수정이 되는 부분이라면 중복이 맞지!
    • DRY 원칙 : Don't Repeat Yourself - 동일한 지식을 중복하지 말라. 모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다.

     

     중복 코드는 서로 다르게 수정하기가 쉽다

    • 많은 코드 더미 속에서 어떤 코드가 중복인지를 파악하는 일은 쉬운 일이 아니다.
    • 중복 코드는 항상 함께 수정돼야 한다.
    • 중복 코드는 새로운 중복 코드를 부른다.
    • 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다.
    • ☆ 변경되는 부분과 안되는 부분을 잘 파악해서 디자인 패턴을 적용하자!

     

    ⚈ 타입 코드 사용하기

    • 두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
    • 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시키는 방식
    • 낮은 응집도와 높은 결합도라는 문제에 시달리게 된다.
    public Money calculateFee() {
        Money result = Money.ZERO;
    
        for(Call call : calls) {
            if (type == PhoneType.REGULAR) {
                result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                    result = result.plus(nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
                } else {
                    result = result.plus(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
                }
            }
        }
    
        return result;
    }

     

    ⚈ 상속 사용하기

    • 상속은 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법을 제공한다.
    • 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 코드를 재사용하자 -> ☆ 바로 단점이 나오긴 하지만, 어쨌든 코드 복붙보단 낫다!
    public class NightlyDiscountPhone extends Phone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
    
        public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
            super(regularAmount, seconds);
            this.nightlyAmount = nightlyAmount;
        }
    
        @Override
        public Money calculateFee() {
            // 부모클래스의 calculateFee 호출
            Money result = super.calculateFee();
    
            Money nightlyFee = Money.ZERO;
            for(Call call : getCalls()) {
                if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                    nightlyFee = nightlyFee.plus(
                        getAmount().minus(nightlyAmount).times(
                            call.getDuration().getSeconds() / getSeconds().getSeconds()));
                }
            }
    
            return result.minus(nightlyFee);
        }
    }

     

     위 코드로 알 수 있는 상속을 통한 코드 재사용의 문제점

    • 위 코드의 부모 클래스인 Phone의 calculateFee()는 아래와 같다.
    public Money calculateFee() {
        Money result = Money.ZERO;
    
        for(Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }
    
        return result;
    }

     

    • 저걸 재사용하기 위해 상속을 통해 짰더니 minus를 통한 계산 식이 복잡하게 짜여졌다. -> 개발자의 가정을 이해하기 전에는 코드를 이해하기 어렵다.
    • 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 생각처럼 쉽지 않다. 또한 직관에도 어긋날 수 있다.
    • 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다.
    • 따라서 상속은 결합도를 높인다. 상속이 초래하는 부모 클래스와 자식 클래스 사이의 강한 결합이 코드를 수정하기 어렵게 만든다.

     

      자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.

    • 위 코드에 추가로 세금을 부과하는 요구사항이 추가되었다고 해보자. 그럼 아래와 같이 변경될 것이다.
    // Phone에 추가된 세금 요구사항 로직(taxRate)
    public Money calculateFee() {
        Money result = Money.ZERO;
    
        for(Call call : calls) {
            result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
        }
    
        return result.plus(result.times(taxRate));
    }
    
    // NightlyDiscountPhone에 추가된 세금 요구사항 로직(taxRate)
    @Override
    public Money calculateFee() {
        // 부모클래스의 calculateFee() 호출
        Money result = super.calculateFee();
    
        Money nightlyFee = Money.ZERO;
        for(Call call : getCalls()) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                nightlyFee = nightlyFee.plus(
                    getAmount().minus(nightlyAmount).times(
                        call.getDuration().getSeconds() / getSeconds().getSeconds()));
            }
        }
    
        return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate())));
    }

     

    • Phone의 코드를 재사용하고 중복 코드를 제거하기 위해 Phone의 자식 클래스로 NightlyDiscountPhone을 만든건데, 세금을 부과하는 로직을 추가하기 위해 Phone을 수정할 때 유사한 코드를 NightlyDiscountPhone에도 추가해야 했다. 즉 새로운 중복 코드를 만들었다.
    • 이것은 NightlyDiscountPhone이 Phone의 구현에 너무 강하게 결합돼 있기 때문에 발생하는 문제다.
    • ☆ 부모클래스를 알고 있어야 하는 것도 캡슐화를 어기는 것이다. 부모가 바뀌면 자식도 바뀌어야 하니

     


    02 취약한 기반 클래스 문제

     위에서 본 것 처럼 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제(Fragile Base Class Problem, Brittle Base Class Problem)라고 한다.

    • 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.
    • 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어다. 상속 관계를 추가할수록 전체 시스템의 결합도가 높아진다는 사실을 알고 있어야 한다.
    • 객체를 사용하는 이유는 구현과 관련된 세부사항을 퍼블릭 인터페이스 뒤로 캡슐화할 수 있기 때문이다. 캡슐화는 변경에 의한 파급효과를 제어할 수 있기 때문에 가치있다.
    • 안타깝게도 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다. 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.

     

     불필요한 인터페이스 상속 문제

    • 자바의 Stack은 Vector를 상속받는다. Vector의 퍼블릭 인터페이스를 이용하면 임의의 위치에서 요소를 추가하거나 삭제할 수 있다. 따라서 Stack의 규칙을 쉽게 위반할 수 있다.
    • 아래와 같은 코드는 스택 구조의 규칙에 따르면 3이 출력되야 하지만, 실제론 2가 출력된다.
    Stack<Integer> stack = new Stack<>();
    stack.push(0);
    stack.push(1);
    stack.push(2);
    stack.add(0, 3);
    System.out.println(stack.pop());

     

    • 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.

     

     메서드 오버라이딩의 오작용 문제

    • 이하의 코드는 set에 요소가 추가된 횟수를 기록하기 위해 HashSet을 상속해 작성된 코드이다.
    • addAll에 3개의 요소를 집어넣을 경우 예상은 addCount가 3이 되는 것이지만, 실제론 6이 된다. 부모 클래스인 HashSet의 addAll에서 add를 호출하기 때문이다.
    public class InstrumentedHashSet<E> extends HashSet<E> {
    	private int addCount = 0;
        
        @Override
        public boolean add(E e) {
        	addCount++;
            return super.add(e);
        }
        
        @Override
        public boolean addAll(Collection<? extends E> c) {
        	addCouhnt += c.size();
            return super.addAll(c);
        }
    }

     

    • 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
    • 조슈아 블로치 : 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다.
    • 객체지향의 핵심이 구현을 캡슐화하는 것인데도 이렇게 내부 구현을 공개하고 문서화하는 것이 옳은가?
    • 설계는 트레이드오프 활동이다.
    • 상속은 코드 재사용을 위해 캡슐화를 희생한다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.

     

     부모 클래스와 자식 클래스의 동시 수정 문제

    • 음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정하자.음악 정보를 저장할 Song 클래스와 음악 목록을 저장할 Playlist는 아래와 같다.
    public class Song {
        private String singer;
        private String title;
    
        public Song(String singer, String title) {
            this.singer = singer;
            this.title = title;
        }
    
        public String getSinger() {
            return singer;
        }
    
        public String getTitle() {
            return title;
        }
    }
    
    ----
    
    public class Playlist {
        private List<Song> tracks = new ArrayList<>();
    
        public void append(Song song) {
            getTracks().add(song);
        }
    
        public List<Song> getTracks() {
            return tracks;
        }
    }

     

    • 이제 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 필요하다고 해보자. 상속을 통해 Playlist의 코드를 재사용하는게 가장 빠른 방법일 것이다.
    public class PersonalPlaylist extends Playlist {
        public void remove(Song song) {
            getTracks().remove(song);
        }
    }

     

    • 문제는 요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 한다고 가정하자. 그럼 PlayList와 PersonalPlaylist는 아래와 같이 변경될 것이다. 
    public class Playlist {
        private List<Song> tracks = new ArrayList<>();
        private Map<String, String> singers = new HashMap<>();
    
        public void append(Song song) {
            tracks.add(song);
            singers.put(song.getSinger(), song.getTitle());
        }
    
        public List<Song> getTracks() {
            return tracks;
        }
    
        public Map<String, String> getSingers() {
            return singers;
        }
    }
    
    ----
    
    public class PersonalPlaylist extends Playlist {
        public void remove(Song song) {
            getTracks().remove(song);
            getSingers().remove(song.getSinger());
        }
    }

     

    • 위의 예를 통해 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스를 수정할 때 자식 클래스를 함께 수정해야 할 수도 있다는 사실을 알 수 있다.
    • 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수박에 없다.
    • 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.

     


    03 Phone 다시 살펴보기

     상속으로 인한 피해를 최소화할 수 있는 방법을 찾아보자. 취약한 기반 클래스 문제를 완전히 없앨 수는 없지만 어느 정도까지 위험을 완화시키는 것은 가능하다.

     

     열쇠는 추상화다. 추상화에 의존하자.

    • 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 해야 한다.

     

     코드 중복을 제거하기 위해 상속을 도입할 때 따르는 두 가지 원칙

    • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라.
    • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라.

     

     기존 Phone, NightlyDiscountPhone

    public class Phone {
        private Money amount;
        private Duration seconds;
        private List<Call> calls = new ArrayList<>();
        private double taxRate;
    
        public Phone(Money amount, Duration seconds, double taxRate) {
            this.amount = amount;
            this.seconds = seconds;
            this.taxRate = taxRate;
        }
    
        public void call(Call call) {
            calls.add(call);
        }
    
        public List<Call> getCalls() {
            return calls;
        }
    
        public Money getAmount() {
            return amount;
        }
    
        public Duration getSeconds() {
            return seconds;
        }
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for(Call call : calls) {
                result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
    
            return result.plus(result.times(taxRate));
        }
    }
    public class NightlyDiscountPhone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
        private List<Call> calls = new ArrayList<>();
        private double taxRate;
    
        public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
            this.nightlyAmount = nightlyAmount;
            this.regularAmount = regularAmount;
            this.seconds = seconds;
            this.taxRate = taxRate;
        }
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for(Call call : calls) {
                if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                    result = result.plus(nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
                } else {
                    result = result.plus(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
                }
            }
    
            return result.minus(result.times(taxRate));
        }
    }

     

     차이를 메서드로 추출 후 중복 코드를 부모 클래스로 올린 코드

    public abstract class AbstractPhone {
        private List<Call> calls = new ArrayList<>();
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for(Call call : calls) {
                result = result.plus(calculateCallFee(call));
            }
    
            return result;
        }
    
        abstract protected Money calculateCallFee(Call call);
    }
    public class Phone extends AbstractPhone {
        private Money amount;
        private Duration seconds;
    
        public Phone(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    public class NightlyDiscountPhone extends AbstractPhone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
            this.nightlyAmount = nightlyAmount;
            this.regularAmount = regularAmount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            } else {
                return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
        }
    }

     

     자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다. 이제 우리의 설계는 추상화에 의존하게 된다.

    • '위로 올리기' 전략은 실패했더라도 수정하기 쉬운 문제를 발생시킨다.
    • 위로 올리기에서 실수하더라도 추상화할 코드는 눈에 띄고 결국 상위 클래스로 올려지면서 코드의 품질은 높아진다.

     

     추상화가 핵심이다.

    • 위처럼 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다. (☆ 즉, 중복이 없어졌다.) 
    • AbstractPhone은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만 변경된다.
    • Phone은 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경된다.
    • NightlyDiscountPhone은 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경된다.
    • 세 클래스는 각각 하나의 변경 이유만을 가진다 -> 단일 책임 원칙을 준수하기 때문에 응집도가 높다.
    • 또한 caculateCallFee 메서드의 시그니처가 변경되지 않는한 부모 클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다 -> 낮은 결합도를 유지하고 있다.
    • 새로운 요금제를 추가하기도 쉽다. 새로운 요금제가 필요하다면 AbstractPhone을 상속받는 새로운 클래스를 추가한 후 caculateCallFee 메서드만 오버라이딩하면 된다. -> OCP (개방-폐쇄 원칙) 역시 준수한다.
    • 위의 모든 장점은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점이다.

     

     의도를 드러내는 이름 선택하기

    • 기존 Phone은 '일반 요금제'와 관련된 내용을 구현한다는 사실을 명시적으로 전달하지 못한다. 또한 AbstractPhone은 전화기를 포괄한다는 의미를 명확하게 전달하지 못한다. 따라서 이하와 같이 변경하는 것이 적절할 것이다.
    • AbstractPhone -> Phone
    • Phone -> RegularPhone
    • NighlyDiscountPhone -> 그대로

     

     이번에 '01'에서 봤던 것 처럼 세금 요구사항을 추가하는건 더 쉬워졌을까?

    public abstract class Phone {
        private double taxRate;
        private List<Call> calls = new ArrayList<>();
    
        public Phone(double taxRate) {
            this.taxRate = taxRate;
        }
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for(Call call : calls) {
                result = result.plus(calculateCallFee(call));
            }
    
            return result.plus(result.times(taxRate));
        }
    
        protected abstract Money calculateCallFee(Call call);
    }
    public class RegularPhone extends Phone {
        private Money amount;
        private Duration seconds;
    
        public RegularPhone(Money amount, Duration seconds, double taxRate) {
            super(taxRate);
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    public class NightlyDiscountPhone extends Phone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
            super(taxRate);
            this.nightlyAmount = nightlyAmount;
            this.regularAmount = regularAmount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            } else {
                return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
        }
    }
    • 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발한다.
    • 하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 현명한 선택이다.
    • 객체 생성 로직의 변경에 유연하게 대응할 수 있는 다양한 방법이 존재한다 (8장, 9장 내용이다.)  따라서 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막아라. 핵심 로직은 한 곳에 모아 놓고 조심스럽게 캡슐화해야 한다. 그리고 공통적인 핵심 로직은 최대한 추상화해야 한다.
    • 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 상속은 어떤 방식으로든 부모 클래스와 자식 클래스를 결합시킨다.

     


    04 차이에 의한 프로그래밍

     기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍(programming by difference)이라고 부른다.

    • 차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다.

     

     중복 코드는 악의 근원이다.

    • 코드를 재사용하기 위해서는 중복 코드를 제거해서 하나의 모듈로 모아야 한다.
    • 중복 코드 제거와 코드 재사용은 동일한 행동을 가리키는 서로 다른 단어다.
    • 중복 코드를 제거하기 위해 최대한 코드를 재사용해야 한다.

     

     상속 (Inheritance)

    • 객체지향에서 중복 코드를 제거하고 코드를 재사용할 수 있는 가장 유명한 방법이다.

    • 상속은 너무나도 매력적이기 때문에 객체지향에 갓 입문한 프로그래머들은 이에 도취된 나머지 모든 설계에 상속을 적용하려고 시도하곤 한다.
    • 상속이 코드 재사용이라는 측면에서 매우 강력한 도구이지만, 강력한 만큼 잘못 사용할 경우 돌아오는 피해 역시 크다.
    • 상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다.
    • 정말로 필요한 경우에만 상속을 사용하라.

     

     합성 (Composition)

    • 상속은 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다.
    • 상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은방법이 합성이다.
    • ☆ 11장에서 살펴볼 내용임.

     


     

    CHAPTER 11. 합성과 유연한 설계

    ⚈ 상속(Inheritance)과 합성(Composition)

    • 객체지향 프로그래밍에서 가자 널리 사용되는 코드 재사용 기법이다.
    • 상속 : 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용. 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결된다. =is-a 관계
    • 합성 : 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용. 두 객체 사이의 의존성은 런타임에 해결된다. =has-a 관계
    • 부모 클래스의 내부 구현에 대해 상세히 알아야 하므로 부모와 자식 클래스간의 결합도가 높아진다. 상속은 코드를 재사용할 수 있는 쉽고 간단한 방법일지는 몰라도 우아한 방법이라고 할 수는 없다.
    • 상속은 부모 클래스 안에 구현된 코드 자체를 재사용하지만 합성은 포함되는 객체의 퍼블릭 인터페이스를 재사용한다.
    • 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 변경의 영향을 최소화 할 수 있고, 실행 시점에 동적으로 변경도 할 수 있다.

     


    01 상속을 합성으로 변경하기

    ⚈ 합성을 사용하면 10장에서 나온 상속이 초래하는 세 가지 문제점을 해결할 수 있다.

    • 불필요한 인터페이스 상속 문제
    • 메서드 오버라이딩의 오작용 문제
    • 부모 클래스와 자식 클래스의 동시 수정 문제

     

    ⚈ 상속을 합성으로 바꾸는 방법 : 자식 클래스에 선언된 상속 관계를 제거하고 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언

     

    ⚈ 불필요한 인터페이스 상속 문제 해결 - Stack이 Vector를 상속해서 발생했던 문제

    • 이제 Stack의 퍼블릭 인터페이스에는 불필요한 Vector의 오퍼레이션들이 포함되지 않게 된다.
    public class Stack<E> {
        private Vector<E> elements = new Vector<>();
    
        public E push(E item) {
            elements.addElement(item);
            return item;
        }
    
        public E pop() {
            if (elements.isEmpty()) {
                throw new EmptyStackException();
            }
            return elements.remove(elements.size() - 1);
        }
    }

     

     메서드 오버라이딩의 오작용 문제 해결 - InstrumentedHashSet 문제

    • 이제 super를 사용해 호출한 addAll에서 InstrumentedHashSet의 add가 호출되어 addCount가 중복으로 세어지던 문제가 없어진다.
    public class InstrumentedHashSet<E> implements Set<E> {
        private int addCount = 0;
        private Set<E> set;
    
        public InstrumentedHashSet(Set<E> set) {
            this.set = set;
        }
    
        @Override
        public boolean add(E e) {
            addCount++;
            return set.add(e);
        }
    
        @Override
        public boolean addAll(Collection<? extends E> c) {
            addCount += c.size();
            return set.addAll(c);
        }
    
        public int getAddCount() {
            return addCount;
        }
    }

     

    • 다만 이 경우엔 Set의 인터페이스도 모두 제공해줘야 한다. 이 경우엔 아래와 같이 하면 된다. 그럼 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 유지할 수 있다.
    public class InstrumentedHashSet<E> implements Set<E> {
        private int addCount = 0;
        private Set<E> set;
    
        public InstrumentedHashSet(Set<E> set) {
            this.set = set;
        }
    
        @Override
        public boolean add(E e) {
            addCount++;
            return set.add(e);
        }
    
        @Override
        public boolean addAll(Collection<? extends E> c) {
            addCount += c.size();
            return set.addAll(c);
        }
    
        public int getAddCount() {
            return addCount;
        }
    
        @Override public boolean remove(Object o) {
            return set.remove(o);
        }
    
        @Override public void clear() {
            set.clear();
        }
    
        @Override public boolean equals(Object o) {
            return set.equals(o);
        }
    
        @Override public int hashCode() {
            return set.hashCode();
        }
    
        @Override public Spliterator<E> spliterator() {
            return set.spliterator();
        }
    
        @Override public int size() {
            return set.size();
        }
    
        @Override public boolean isEmpty() {
            return set.isEmpty();
        }
    
        @Override public boolean contains(Object o) {
            return set.contains(o);
        }
    
        @Override public Iterator<E> iterator() {
            return set.iterator();
        }
    
        @Override public Object[] toArray() {
            return set.toArray();
        }
    
        @Override public <T> T[] toArray(T[] a) {
            return set.toArray(a);
        }
    
        @Override public boolean containsAll(Collection<?> c) {
            return set.containsAll(c);
        }
    
        @Override public boolean retainAll(Collection<?> c) {
            return set.retainAll(c);
        }
    
        @Override public boolean removeAll(Collection<?> c) {
            return set.removeAll(c);
        }
    }

     

    • 이처럼 동일한 메서드 호출을 그대로 전달하는걸 포워딩(forwarding)이라 부르고, 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드(forwarding method)라고 부른다.

     

     부모 클래스와 자식 클래스의 동시 수정 문제 - PersonalPlaylist 문제

    • 이 경우엔 합성으로 변경하더라도 Playlist와 PersonalPlaylist를 함께 수정해야 하는 문제가 해결되지는 않는다.
    public class Playlist {
        private List<Song> tracks = new ArrayList<>();
        private Map<String, String> singers = new HashMap<>();
    
        public void append(Song song) {
            tracks.add(song);
            singers.put(song.getSinger(), song.getTitle());
        }
    
        public List<Song> getTracks() {
            return tracks;
        }
    
        public Map<String, String> getSingers() {
            return singers;
        }
    }
    
    ----
    
    public class PersonalPlaylist {
        private Playlist playlist = new Playlist();
    
        public void append(Song song) {
            playlist.append(song);
        }
    
        public void remove(Song song) {
            playlist.getTracks().remove(song);
            playlist.getSingers().remove(song.getSinger());
        }
    }

     

    • 문제가 해결되진 않았지만 여전히 상속보다는 합성을 사용하는 게 더 좋은데, 향후에 Playlist의 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlaylist 내부로 캡슐화할 수 있기 때문이다.

     

     대부분의 경우 구현에 대한 결합보다는 인터페이스에 대한 결합이 더 좋다.

     

     몽키 패치(Monkey Patch) : 현재 실행 중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장하는 것

     


    02 상속으로 인한 조합의 폭발적인 증가

    ⚈ ☆ '01' 에서 10장에서 살펴봤던 상속으로 코드 재사용 할 때의 문제점을 해결했다. 그리고 '02'에서 상속으로 인한 문제점을 하나 더 보여주고, '03'에서 합성으로 이를 해결하고 있다. 그러니 '02'는 다시 상속의 문제점을 보여주기 위한 예시이다.

     

     상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다. 가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같은 두 가지 문제점이 발생한다.

    • 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
    • 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.

     

     기본 정책과 부가 정책 조합하기

    • 10장에서 봤던 핸드폰 과금 시스템(Phone 클래스)에 새로운 요구사항을 추가해보자.
    • 기존엔 일반 요금제와 심야 할인 요금제가 있었다.
    • 새로운 요구사항은 이 두 요금제에 부가 정책을 추가하는 것이다. 이후로는 핸드폰 요금제가 '기본 정책'과 '부가 정책'을 조합해서 구성된다고 가정한다.

     

     부가 정책 특성

    • 세금 정책과 기본 요금 할인 정책이 존재한다.
    • 기본 정책의 계산 결과에 적용된다. 기본 정책의 계산이 끝난 결과에 세금을 부과한다.
    • 선택적으로 적용할 수 있다. 기본 정책의 계산 결과에 세금 정책을 적용할 수도 있고 적용하지 않을 수도 있다.
    • 조합 가능하다. 부가 정책 각각을 단일로 적용하는 것도 가능하고, 함께 적용하는 것도 가능하다.
    • 부가 정책은 임의의 순서로 적용 가능하다. 부가 정책을 적용하는 순서는 임의로 조정 가능하다.

     

     기본 정책과 부가 정책의 조합 가능한 수가 매우 많다. 따라서 설계는 다양한 조합을 수용할 수 있도록 유연해야 한다.

     

     10장에서 보았던 상속을 통해 구현된 기본 정책 코드는 아래와 같다.

    public abstract class Phone {
        private List<Call> calls = new ArrayList<>();
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for(Call call : calls) {
                result = result.plus(calculateCallFee(call));
            }
    
            return result;
        }
    
        abstract protected Money calculateCallFee(Call call);
    }
    
    ----
    
    public class RegularPhone extends Phone {
        private Money amount;
        private Duration seconds;
    
        public RegularPhone(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    
    ----
    
    public class NightlyDiscountPhone extends Phone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
            this.nightlyAmount = nightlyAmount;
            this.regularAmount = regularAmount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            } else {
                return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
        }
    }

     

     기본 정책에 부가 정책 중 하나인 세금 정책 조합하기

    • 가장 간단한 방법은 RegularPhone 클래스를 상속받은 TaxableRegularPhone 클래스를 추가하는 것이다.
    public class TaxableRegularPhone extends RegularPhone {
        private double taxRate;
    
        public TaxableRegularPhone(Money amount, Duration seconds,
                                   double taxRate) {
            super(amount, seconds);
            this.taxRate = taxRate;
        }
    
        @Override
        public Money calculateFee() {
            Money fee = super.calculateFee();
            return fee.plus(fee.times(taxRate));
        }
    }

     

     위처럼 짜게될 경우, 10장에서 나온 내용처럼 부모 클래스의 메서드를 재사용하기 위해 super 호출을 사용하면 원하는 결과를 쉽게 얻을 수 있지만 자식 클래스와 부모 클래스 사이의 결합도가 높아진다.

    • 따라서 역시 10장에서 나온 것 처럼 추상메서드에 의존하도록 변경하자. afterCalculated가 추가되었고, 전체적으로 모든 클래스가 변경되었다.
    public abstract class Phone {
        private List<Call> calls = new ArrayList<>();
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for(Call call : calls) {
                result = result.plus(calculateCallFee(call));
            }
    
            return afterCalculated(result);
        }
    
        protected abstract Money calculateCallFee(Call call);
        protected abstract Money afterCalculated(Money fee);
    }
    
    ----
    
    public class RegularPhone extends Phone {
        private Money amount;
        private Duration seconds;
    
        public RegularPhone(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee;
        }
    }
    
    ----
    
    public class NightlyDiscountPhone extends Phone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
            this.nightlyAmount = nightlyAmount;
            this.regularAmount = regularAmount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            } else {
                return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee;
        }
    }

     

     이 때, 부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩해야 하는 문제가 발생함을 알 수 있다.

    • 또한 모든 추상메서드의 구현이 동일하다. 이걸 해결하려면 Phone에서 afterCalculated 메서드의 기본 구현을 함께 제공해야 한다. (추상 메서드와 동일하게 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공하는 메서드를 '훅 메서드(hook method)' 라고 부른다.)
    public abstract class Phone {
        private List<Call> calls = new ArrayList<>();
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for(Call call : calls) {
                result = result.plus(calculateCallFee(call));
            }
    
            return afterCalculated(result);
        }
    
        protected Money afterCalculated(Money fee) {
            return fee;
        }
    
        protected abstract Money calculateCallFee(Call call);
    }
    
    ----
    
    public class RegularPhone extends Phone {
        private Money amount;
        private Duration seconds;
    
        public RegularPhone(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    
    ----
    
    public class NightlyDiscountPhone extends Phone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
            this.nightlyAmount = nightlyAmount;
            this.regularAmount = regularAmount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            } else {
                return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
        }
    }
    
    ----
    
    public class TaxableRegularPhone extends RegularPhone {
        private double taxRate;
    
        public TaxableRegularPhone(Money amount, Duration seconds, double taxRate) {
            super(amount, seconds);
            this.taxRate = taxRate;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.plus(fee.times(taxRate));
        }
    }

     

     이제 심야 할인 요금제인 NightlyDiscountPhone에도 세금을 부과할 수 있도록 추가하자.

    public class TaxableNightlyDiscountPhone extends NightlyDiscountPhone {
        private double taxRate;
    
        public TaxableNightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
            super(nightlyAmount, regularAmount, seconds);
            this.taxRate = taxRate;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.plus(fee.times(taxRate));
        }
    }
    • 이렇게되면 TaxableNightlyDiscountPhone과 TaxableRegularPhone 사이에 코드를 중복했다는 문제가 발생한다.
    • 자바를 비롯한 대부분의 객체지향 언어는 단일 상속만 지원하기 때문에 상속으로 인해 발생하는 중복 코드 문제를 해결하기가 쉽지 않다.

     

     이제 기본 정책에 기본 요금 할인 정책을 조합한 코드를 추가하자.

    public class RateDiscountableRegularPhone extends RegularPhone {
        private Money discountAmount;
    
        public RateDiscountableRegularPhone(Money amount, Duration seconds, Money discountAmount) {
            super(amount, seconds);
            this.discountAmount = discountAmount;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.minus(discountAmount);
        }
    }
    
    ----
    
    public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone {
        private Money discountAmount;
    
        public RateDiscountableNightlyDiscountPhone(Money nightlyAmount,
                                                    Money regularAmount, Duration seconds, Money discountAmount) {
            super(nightlyAmount, regularAmount, seconds);
            this.discountAmount = discountAmount;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.minus(discountAmount);
        }
    }

    • 어떤 클래스를 선택해느냐에 따라 적용하는 요금제의 조합이 결정된다.
    • 이번에도 역시 중복 코드가 추가되었다.

     

     중복의 덫

    • 상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스를 하나씩 추가하는 것이다.
    • 두 가지의 부가 정책을 혼합한 정책과, 새로운 부가 정책을 추가하게 되면 아래와 같이 될 것이다.

    • 새로운 부가 정책을 추가하기 위해 5개의 새로운 클래스가 추가되었고, 두 부가 정책이 혼합된 정책과 새로운 부가 정책 모두 중복 코드가 추가되었다.

     

     이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발(class explosion) 문제 또는 조합의 폭발(combinational explosion) 문제라고 부른다.

    • 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제이다.
    • 컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.

     

     또한 세금 정책을 변경해야 한다면 관련 코드가 여러 클래스 안에 중복돼 있기 때문에 세금 정책과 관련된 모든 클래스를 찾아 동일한 방식으로 수정해야 할 것이다. 하나라도 누락한다면 세금이 부과되지 않는 버그가 발생할 것이다. 

     

     최선의 방법은 상속을 포기하는 것이다.

     


    03 합성 관계로 변경하기

    ⚈ 합성은 컴파일타임 관계를 런타임 관계로 변경함으로써 '02'의 문제를 해결한다. 합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있다.

     

     합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다.

     

     코드가 유연해지면 복잡성은 올라간다. 대부분의 경우에는 단순한 설계가 정답이지만 변경에 따르는 고통이 복잡성으로 인한 혼란을 넘어서고 있다면 유연성의 손을 들어주는 것이 현명한 판단일 확률이 높다.

    • 변경하기 편리한 설계를 만들기 위해 복잡성을 더하고 나면 원래의 설계보다 단순해지는 경우도 종종 볼 수 있다. -> 이하 코드들에서 볼 수 있음

     

     기본 정책 합성하기

    public interface RatePolicy {
        Money calculateFee(Phone phone);
    }
    
    ----
    
    public abstract class BasicRatePolicy implements RatePolicy {
        @Override
        public Money calculateFee(Phone phone) {
            Money result = Money.ZERO;
    
            for(Call call : phone.getCalls()) {
                result.plus(calculateCallFee(call));
            }
    
            return result;
        }
    
        protected abstract Money calculateCallFee(Call call);
    }
    
    ----
    
    public class RegularPolicy extends BasicRatePolicy {
        private Money amount;
        private Duration seconds;
    
        public RegularPolicy(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    
    ----
    
    public class NightlyDiscountPolicy extends BasicRatePolicy {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        public NightlyDiscountPolicy(Money nightlyAmount, Money regularAmount, Duration seconds) {
            this.nightlyAmount = nightlyAmount;
            this.regularAmount = regularAmount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
    
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    
    ----
    
    public class Phone {
        private RatePolicy ratePolicy;
        private List<Call> calls = new ArrayList<>();
    
        public Phone(RatePolicy ratePolicy) {
            this.ratePolicy = ratePolicy;
        }
    
        public List<Call> getCalls() {
            return Collections.unmodifiableList(calls);
        }
    
        public Money calculateFee() {
            return ratePolicy.calculateFee(this);
        }
    }

     

     부가 정책 적용하기

    public abstract class AdditionalRatePolicy implements RatePolicy {
        private RatePolicy next;
    
        public AdditionalRatePolicy(RatePolicy next) {
            this.next = next;
        }
    
        @Override
        public Money calculateFee(Phone phone) {
            Money fee = next.calculateFee(phone);
            return afterCalculated(fee) ;
        }
    
        abstract protected Money afterCalculated(Money fee);
    }
    
    ----
    
    public class TaxablePolicy extends AdditionalRatePolicy {
        private double taxRatio;
    
        public TaxablePolicy(double taxRatio, RatePolicy next) {
            super(next);
            this.taxRatio = taxRatio;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.plus(fee.times(taxRatio));
        }
    }
    
    ----
    
    public class RateDiscountablePolicy extends AdditionalRatePolicy {
        private Money discountAmount;
    
        public RateDiscountablePolicy(Money discountAmount, RatePolicy next) {
            super(next);
            this.discountAmount = discountAmount;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.minus(discountAmount);
        }
    }

     

     이제 새로운 정책 추가는 하나의 클래스 추가만으로 가능해진다.

     

     필요한 조합의 수만큼 매번 새로운 클래스를 추가해야 했던 '02'의 상속 방식과 비교해보자.

     

     더 중요한 것은 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다는 것이다.

    • 변경 후의 설계는 단일 책임 원칙을 준수하고 있다.

     

     객체지향에서 코드 재사용하면서도 건전한 결합도를 유지할 수 있는 더 좋은 방법은 합성을 이용하는 것이다.

     

     상속을 사용해서는 안 되는 것인가?

    • 상속은 구현 상속과 인터페이스 상속의 두 가지로 나눠야 한다.
    • 이 글에서 살펴본 상속에 대한 모든 단점들은 구현 상속에 국한된다.

     


    04 믹스인

     믹스인(mixin) : 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법

     


     

    CHAPTER 12. 다형성

     

    ⚈ 코드 재사용을 목적으로 상속을 사용하면 변경하기 어렵고 유연하지 못한 설계에 이를 확률이 높아진다. 상속의 목적은 코드 재사용이 아니다. 상속은 타입 계층을 구조화하기 위해 사용해야 한다.

     

     12장 : 상속의 일차적인 목적이 코드 재사용이 아니라 서브타입의 구현이라는 사실을 이해하기 위한 챕터

     


    01 다형성

    ⚈ 다형성(Polymorphism) : 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력. 즉, 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법

     

    ⚈ 다형성의 분류

    • 매개변수 다형성 : 클래스의 인스턴스 변수나 메서드의 배개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식. 예를들어 자바에서 제너릭 타입으로 List에서 임의의 타입 T로 보관할 요소를 지정하고 있다. 실제 인스턴스를 생성하는 시점에 T를 구체적인 타입으로 지정할 수 있다.
    • 포함 다형성 : 특별한 언급 없이 다형성이라고 할 때는 포함 다형성을 의미한다. 메시지가 동일하더라도 수신할 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력 (=서브타입(subtype) 다형성)
    • 오버로딩 다형성 : 클래스 안에 동일한 이름의 메서드가 존재하는 경우 (메서드 오버로딩)
    • 강제 다형성 : 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 상용할 수 있는 방식. 예를들어 "ABC" + 3 = "ABC3"

     


    02 상속의 양면성

     데이터 관점의 상속 : 상속을 이용하면 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시킬 수 있다.

     

     행동 관점의 상속 : 부모 클래스에서 정의한 일부 메서드 역시 자동으로 자식 클래스에 포함시킬 수 있다.

     

     타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 만들어질 확률이 높다..

     

     상속을 이용한 강의 평가

    • 코드는 아래와 같다.
    public class Lecture {
        private int pass;
        private String title;
        private List<Integer> scores = new ArrayList<>();
    
        public Lecture(String title, int pass, List<Integer> scores) {
            this.title = title;
            this.pass = pass;
            this.scores = scores;
        }
    
        public double average() {
            return scores.stream().mapToInt(Integer::intValue).average().orElse(0);
        }
    
        public List<Integer> getScores() {
            return Collections.unmodifiableList(scores);
        }
    
        public String evaluate() {
            return String.format("Pass:%d Fail:%d", passCount(), failCount());
        }
    
        private long passCount() {
            return scores.stream().filter(score -> score >= pass).count();
        }
    
        private long failCount() {
            return scores.size() - passCount();
        }
    }
    
    ----
    
    public class Grade {
        private String name;
        private int upper,lower;
    
        private Grade(String name, int upper, int lower) {
            this.name = name;
            this.upper = upper;
            this.lower = lower;
        }
    
        public String getName() {
            return name;
        }
    
        public boolean isName(String name) {
            return this.name.equals(name);
        }
    
        public boolean include(int score) {
            return score >= lower && score <= upper;
        }
    }
    
    ----
    
    public class GradeLecture extends Lecture {
        private List<Grade> grades;
    
        public GradeLecture(String name, int pass, List<Grade> grades, List<Integer> scores) {
            super(name, pass, scores);
            this.grades = grades;
        }
    
        @Override
        public String evaluate() {
            return super.evaluate() + ", " + gradesStatistics();
        }
    
        private String gradesStatistics() {
            return grades.stream().map(grade -> format(grade)).collect(joining(" "));
        }
    
        private String format(Grade grade) {
            return String.format("%s:%d", grade.getName(), gradeCount(grade));
        }
    
        private long gradeCount(Grade grade) {
            return getScores().stream().filter(grade::include).count();
        }
    
        public double average(String gradeName) {
            return grades.stream()
                    .filter(each -> each.isName(gradeName))
                    .findFirst()
                    .map(this::gradeAverage)
                    .orElse(0d);
        }
    
        private double gradeAverage(Grade grade) {
            return getScores().stream()
                    .filter(grade::include)
                    .mapToInt(Integer::intValue)
                    .average()
                    .orElse(0);
        }
    }

     

    • 데이터 관점의 상속 : 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 볼 수 있다. 따라서 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 된다.

     

    • 행동 관점의 상속 : 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다. 따라서 외부의 의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송할 수 있다. 이 때 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색한다. 자바에서는 모든 클래스의 부모 클래스인 Object까지 상속 계층을 탐색한다.

     


    03 업캐스팅과 동적 바인딩

     코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는 것은 업캐스팅과 동적 바인딩이라는 메커니즘이 작용하기 때문이다.

    • 업캐스팅 : 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능
    • 동적 바인딩 : 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정

     

     업캐스팅, 다운캐스팅

     

     동적 바인딩

    • 전통적인 언어들은 호출될 함수를 컴파일타임에 결정한다. -> 정적 바인딩(static binding), 초기 바인딩(early biding), 또는 컴파일타임 바인딩(compile-time binding)
    • 실행될 메서드를 런타임에 결정하는 방식 -> 동적 바인딩(dynamic binding) 또는 지연 바인딩(late binding)

     


    04 동적 메서드 탐색과 다형성

     객체지향 시스템은 다음 규칙에 따라 실행할 메서드를 선택한다.

    • 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.
    • 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.
    • 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단한다.

     

     

     self 참조(self reference) : 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다. (자바에서는 self 참조를 this라고 부른다.)

     

     동적 메서드 탐색의 입장에서 상속 계층은 메시지를 수신한 객체가 자신이 이해할 수 없는 메시지를 부모 클래스에게 전달하기 위한 물리적인 경로를 정의한 것으로 볼 수 있다. 적절한 메서드를 찾을 때까지 상속 계층을 따라 부모 클래스로 처리가 위임된다.

     

     ☆ 정적 타입 언어 : 컴파일 시에 변수의 타입이 결정되는 언어 - 자바, C, C++, C# 등

     

     ☆ 동적 타입 언어 : 런타임 시 자료형이 결정 - Python, js, Ruby 등

     

     super 참조(super reference) : '지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요' 

     


     

    CHAPTER 13. 서브클래싱과 서브타이핑

     

    ⚈ 상속은 두 가지 용도로 사용된다.

    • 첫 번째 용도는 타입 계층을 구현하는 것이다. 타입 계층 안에서 부모 클래스는 일반적인 개념을 구현하고 자식 클래스는 특수한 개념을 구현한다. 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화(generalization)이고 자식 클래스는 부모 클래스의 특수화(specialization)다.
    • 두 번째 용도는 코드 재사용이다. 재사용을 위해 상속을 사용할 경우 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 변경하기 어려운 코드를 얻게 될 확률이 높다.

     

    ⚈ 상속을 사용하는 일차적인 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이어야 한다. 동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 행동을 기반으로 타입 계층을 구성해야 한다.

     


    01 타입

    ⚈ 프로그래밍 언어 관점에서 타입은 연속적인 비트에 의미와 제약을 부여하기 위해 사용된다.

     

    ⚈ 객체지향 패러다임 관점의 타입

    • 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스(=객체가 수신할 수 있는 메시지의 집합)를 정하는 것과 동일하다.
    • 동일한 퍼블릭 인터페이스를 가지는 객체들은 동일한 타입으로 분류할 수 있다.
    • 어떤 객체들이 내부 상태는 다르지만 동일한 퍼블릭 인터페이스를 공유한다면 이들은 동일한 타입으로 분류된다.
    • 객체를 바라볼 때는 항상 객체가 외부에 제공하는 행동에 초점을 맞춰야 한다. 객체의 타입을 결정하는 것은 내부의 속성이 아니라 객체가 외부에 제공하는 행동이다.

     


    02 타입 계층

    ⚈ 슈퍼타입과 서브타입

    • 슈퍼타입(supertype) : 더 일반적인 타입.
    • 서브타입(subtype) : 더 특수한 타입
    • 프로그래밍 언어 타입은 객체지향 언어 타입과 절차적 언어 타입의 슈퍼타입. 객체지향 언어 타입은 클래스 기반 언어 타입과 프로토타입 기반 언어 타입의 슈퍼타입.

     

     객체지향 프로그래밍과 타입 계층

    • 객체의 타입을 결정하는 것은 퍼블릭 인터페이스
    • 퍼블릭 인터페이스 관점에서 슈퍼타입 : 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것
    • 퍼블릭 인터페이스 관점에서 서브타입 : 슈퍼타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것
    • 서브타입의 인스턴스는 슈퍼타입의 인스턴스로 간주될 수 있다.

     


    03 서브클래싱과 서브타이핑

     상속을 이용해 타입 계층을 구현한다는 것은 부모 클래스가 슈퍼타입의 역할을, 자식 클래스가 서브타입의 역할을 수행하도록 클래스의 관계를 정의한다는 것을 의미한다.

     

     언제 상속을 사용해야 하는가?

    • 상속의 올바른 용도는 타입 계층을 구현하는 것
    • 이하 두 질문에 모두 '예'라고 답할 수 있는 경우에만 상속을 사용하라.
    • 1. 상속 관계가 is-a 관계를 모델링하는가?
    • 2. 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가? (행동 호환성)

     

     ☆ is-a 관계 및 행동 호환성 둘 다 앞에 '클라이언트 관점에서' 라고 붙여서 생각해야 함.

    • is-a 관계에서 어휘적인 정의가 아니라 기대되는 행동에 따라 타입 계층을 구성해야 한다. 펭귄은 새지만 클라이언트 입장에서 새의 정의에 날 수 있다는 행동이 포함된다면 is-a 관계가 아니다. 어떤 애플리케이션에서 새에게 날 수 있다는 행동을 기대하지 않고 단지 울음 소리를 낼 수 있다는 행동만 기대한다면 새와 펭귄을 타입 계층으로 묶어도 무방하다.
    public class Bird {
    	public void fly() {...}
        ...
    }
    
    public class Penguin extends Bird {
    	...
    }

     

    • 행동의 호환 여부를 판단하는 기준 또한 클라이언트의 관점이다. 클라이언트가 두 타입이 동일하게 행동하지 않을 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안된다.

     

    • 이런 경우 상속이 아니라 합성을 통해 인터페이스를 클라이언트의 기대에 따라 분리해 해결하는게 더 좋은 방법이다. 이처럼 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)라고 부른다.

     

     서브클래싱(subclassing) : 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우. 자식 클래스와 부모 클래스의 행동이 호환되지 않기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 없다. (=구현 상속, 클래스 상속)

     

     서브타이핑(subtyping) : 타입 계층을 구성하기 위해 상속을 사용하는 경우. 자식 클래스와 부모 클래스의 행동이 호환되기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 있다. 이 때 부모 클래스는 자식 클래스의 슈퍼타입이 되고 자식 클래스는 부모 클래스의 서브타입이 된다. (=인터페이스 상속)

     

     행동 호환성을 만족하는 상속 관계는 부모 클래스를 새로운 자식 클래스로 대체하더라도 시스템이 문제없이 동작할 것이라는 것을 보장해야 한다.

    • 즉, 부모 클래스와 자식 클래스의 행동 호환성은 부모 클래스에 대한 자식 클래스의 대체 가능성(subtitutability)을 포함한다.
    • 행동 호환성과 대체 가능성은 올바른 상속 관계를 구축하기 위해 따라야 할 지침이라고 할 수 있고, 이 지침을 리스코프 치환 원칙이라 한다.

     


    04 리스코프 치환 원칙

     리스코프 치환 원칙(Liskov Substitution Principle, LSP)

    • "서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다"
    • 클라이언트가 "차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다"
    • '03'에서 논의한 행동 호환성을 설계 원칙으로 정리한 것이 리스코프 치환 원칙

     

     리스코프 치환 원칙에 따르면 자식 클래스가 부모 클래스와 행동 호환성을 유지함으로써 부모 클래스를 대체할 수 있도록 구현된 상속 관계만을 서브타이핑이라고 불러야 한다.

     

     자바의 Stack과  Vector는 리스코프 치환 원칙을 위반하는 전형적인 예

    • 클라이언트가 부모 클래스인 Vector에 대해 기대하는 행동을 Stack에 대해서는 기대할 수 없기 때문에 행동 호환성을 만족시키지 않기 때문이다.
    • ☆ Vector로 짜둔 코드는 전부 new Stack()으로 바꿔도 동작은 한다. 코드적으로만 보면 부모를 대신할 수 있으나, 클라이언트 입장에서 Vector에게 기대하는 행동을 Stack에게 기대할 수 없기 때문에 LSP 위반이다.

     

     정사각형은 직사각형이다.

    • 말 자체만 보면 맞지만, 코드적으로 본다면 리스코프 치환 원칙을 위반하는 고전적인 사례 중 하나다. resize 메서드의 관점에서 Rectangle 대신 Square를 사용할 수 없기 때문에 Square는 Rectangle이 아니다. (Square는 Rectangle의 구현을 재사용하고 있을 뿐이다.). 즉, 클라이언트 관점에서 Square와 Rectangle이 다르기 때문에 대체할 수 없다.
    public class Rectangle {
        private int x, y, width, height;
    
        public Rectangle(int x, int y, int width, int height) {
            this.x = x;
            this.y = y;
            this.width = width;
            this.height = height;
        }
    
        public int getX() {
            return x;
        }
    
        public void setX(int x) {
            this.x = x;
        }
    
        public int getY() {
            return y;
        }
    
        public void setY(int y) {
            this.y = y;
        }
    
        public int getWidth() {
            return width;
        }
    
        public void setWidth(int width) {
            this.width = width;
        }
    
        public int getHeight() {
            return height;
        }
    
        public void setHeight(int height) {
            this.height = height;
        }
    }
    
    class Square extends Rectangle {
    
        public Square(int x, int y, int size) {
            super(x, y, size, size);
        }
    
        @Override
        public void setWidth(int width) {
            super.setWidth(width);
            super.setHeight(width);
        }
    
        @Override
        public void setHeight(int height) {
            super.setHeight(height);
            super.setWidth(height);
        }
    }
    
    class RectangleUser {
        private static void resize(Rectangle rectangle, int width, int height) {
            rectangle.setWidth(width);
            rectangle.setHeight(height);
            assert rectangle.getWidth() == width && rectangle.getHeight() == height;
        }
    
        public static void main(String[] args) {
            Square square = new Square(10, 10, 10);
            resize(square, 50, 100);
        }
    }

     

     리스코프 치환 원칙의 결론 : "클라이언트와 격리한 채로 본 모델은 의미 있게 검증하는 것이 불가능하다."

     

     이 책에서 설명하던 영화 관련 예시

    • 의존성 역전 원칙(DIP) : 구체 클래스인 Movie와 OverlappedDiscountPolicy 모두 추상 클래스인 DiscountPolicy에 의존한다. 상위 수준의 모듈인 Movie와 하위 수준의 모듈인 OverlappedDiscountPolicy는 모두 추상 클래스인 DiscountPolicy에 의존한다. 따라서 DIP를 만족한다.
    • 리스코프 치환 원칙(LSP) : Movie의 관점에서 DiscountPolicy 대신 OverlappedDiscountPolicy와 협력하더라도 아무런 문제가 없다. 다시 말해서 OverlappedDiscountPolicy는 클라이언트에 대한 영향 없이도 DiscountPolicy를 대체할 수 있다. 따라서 LSP를 만족한다.
    • 개방-폐쇄 원칙(OCP) : 중복 할인 정책이라는 새로운 기능을 추가하기 위해 OverlappedDiscountPolicy를 추가하더라도 Movie에는 영향을 끼치지 않는다. 다시 말해서 기능 확장을 하면서 기존 코드를 수정할 필요는 없다. 따라서 OCP를 만족한다.

     

     LSP를 준수해야만 서브타이핑 관계라고 말할 수 있다.

     


     

    CHAPTER 14. 일관성 있는 협력

    ⚈ 애플리케이션을 개발하다 보면 유사한 요구사항을 반복적으로 추가하거나 수정하게 되는 경우가 있다.

    • 이러한 상황에서 각 협력이 서로 다른 패턴을 따를 경우에는 전체적인 설계의 일관성이 서서히 무너지게 된다.
    • 객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다. -> 재사용은 공짜로 얻어지지 않는다. 재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다.
    • 과거의 해결 방법을 반복적으로 사용해서 유사한 기능을 구현하는 데 드는 시간과 노력을 대폭 줄일 수 있다. 또한 코드가 이해하기 쉬워진다.

     

    ⚈ 지금 보고 있는 코드가 얼마 전에 봤던 코드와 유사하다는 사실을 아는 순간 새로운 코드가 직관적인 모습으로 다가오는 것을 느끼게 될 것이다.

     

    ⚈ 14장의 주제 : 일관성 있는 협력 패턴을 적용하면 여러분의 코드가 이해하기 쉽고 직관적이며 유연해진다는 것

     


    01 핸드폰 과금 시스템 변경하기

    ⚈ 기본 정책 확장

    • 11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 수정 (11장 정리내용)
    • 기존에는 기본 정책에 일본 요금제와 심야 할인 요금제 두 가지 종류가 있었음
    • 14장에서는 기본 정책을 이하와 같이 확장할 것. 부가 정책은 변화 없음

     

     11장에서 조합의 폭발 얘기 나올 때 설명한 그림처럼 14장에서 새로운 기본 정책을 적용할 때 조합 가능한 모든 경우의 수 그림

     

     14장에서 구현하게 될 클래스 구조 (짙은 색이 새로운 기본 정책)

     

     구현된 코드

    public class FixedFeePolicy extends BasicRatePolicy {
        private Money amount;
        private Duration seconds;
    
        public FixedFeePolicy(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
    
    ---
    
    public class DateTimeInterval {
        private LocalDateTime from;
        private LocalDateTime to;
    
        public static DateTimeInterval of(LocalDateTime from, LocalDateTime to) {
            return new DateTimeInterval(from, to);
        }
    
        public static DateTimeInterval toMidnight(LocalDateTime from) {
            return new DateTimeInterval(from, LocalDateTime.of(from.toLocalDate(), LocalTime.of(23, 59, 59, 999_999_999)));
        }
    
        public static DateTimeInterval fromMidnight(LocalDateTime to) {
            return new DateTimeInterval(LocalDateTime.of(to.toLocalDate(), LocalTime.of(0, 0)), to);
        }
    
        public static DateTimeInterval during(LocalDate date) {
            return new DateTimeInterval(
                    LocalDateTime.of(date, LocalTime.of(0, 0)),
                    LocalDateTime.of(date, LocalTime.of(23, 59, 59, 999_999_999)));
        }
    
        private DateTimeInterval(LocalDateTime from, LocalDateTime to) {
            this.from = from;
            this.to = to;
        }
    
        public Duration duration() {
            return Duration.between(from, to);
        }
    
        public LocalDateTime getFrom() {
            return from;
        }
    
        public LocalDateTime getTo() {
            return to;
        }
    
        public List<DateTimeInterval> splitByDay() {
            if (days() > 0) {
                return split(days());
            }
    
            return Arrays.asList(this);
        }
    
        private long days() {
            return Duration.between(from.toLocalDate().atStartOfDay(), to.toLocalDate().atStartOfDay()).toDays();
        }
    
        private List<DateTimeInterval> split(long days) {
            List<DateTimeInterval> result = new ArrayList<>();
            addFirstDay(result);
            addMiddleDays(result, days);
            addLastDay(result);
            return result;
        }
    
        private void addFirstDay(List<DateTimeInterval> result) {
            result.add(DateTimeInterval.toMidnight(from));
        }
    
        private void addMiddleDays(List<DateTimeInterval> result, long days) {
            for(int loop=1; loop < days; loop++) {
                result.add(DateTimeInterval.during(from.toLocalDate().plusDays(loop)));
            }
        }
    
        private void addLastDay(List<DateTimeInterval> result) {
            result.add(DateTimeInterval.fromMidnight(to));
        }
    
        public String toString() {
            return "[ " + from + " - " + to + " ]";
        }
    }
    
    ---
    
    public class Call {
    	private DateTimeInterval interval;
    
    	public Call(LocalDateTime from, LocalDateTime to) {
    		this.interval = DateTimeInterval.of(from, to);
    	}
    
    	public Duration getDuration() {
    		return interval.duration();
    	}
    
    	public LocalDateTime getFrom() {
    		return interval.getFrom();
    	}
    
    	public LocalDateTime getTo() {
    		return interval.getTo();
    	}
    
    	public DateTimeInterval getInterval() {
    		return interval;
    	}
    
    	public List<DateTimeInterval> splitByDay() {
    		return interval.splitByDay();
    	}
    }
    
    ---
    
    public class TimeOfDayDiscountPolicy extends BasicRatePolicy {
        private List<LocalTime> starts = new ArrayList<LocalTime>();
        private List<LocalTime> ends = new ArrayList<LocalTime>();
        private List<Duration> durations = new ArrayList<Duration>();
        private List<Money>  amounts = new ArrayList<Money>();
    
        @Override
        protected Money calculateCallFee(Call call) {
            Money result = Money.ZERO;
            for(DateTimeInterval interval : call.splitByDay()) {
                for(int loop=0; loop < starts.size(); loop++) {
                    result.plus(amounts.get(loop).times(Duration.between(from(interval, starts.get(loop)),
                            to(interval, ends.get(loop))).getSeconds() / durations.get(loop).getSeconds()));
                }
            }
            return result;
        }
    
        private LocalTime from(DateTimeInterval interval, LocalTime from) {
            return interval.getFrom().toLocalTime().isBefore(from) ? from : interval.getFrom().toLocalTime();
        }
    
        private LocalTime to(DateTimeInterval interval, LocalTime to) {
            return interval.getTo().toLocalTime().isAfter(to) ? to : interval.getTo().toLocalTime();
        }
    }
    
    ---
    
    public class DayOfWeekDiscountRule {
        private List<DayOfWeek> dayOfWeeks = new ArrayList<>();
        private Duration duration = Duration.ZERO;
        private Money amount = Money.ZERO;
    
        public DayOfWeekDiscountRule(List<DayOfWeek> dayOfWeeks,
                                     Duration duration, Money  amount) {
            this.dayOfWeeks = dayOfWeeks;
            this.duration = duration;
            this.amount = amount;
        }
    
        public Money calculate(DateTimeInterval interval) {
            if (dayOfWeeks.contains(interval.getFrom().getDayOfWeek())) {
                return amount.times(interval.duration().getSeconds() / duration.getSeconds());
            }
    
            return Money.ZERO;
        }
    }
    
    ---
    
    public class DayOfWeekDiscountPolicy extends BasicRatePolicy {
        private List<DayOfWeekDiscountRule> rules = new ArrayList<>();
    
        public DayOfWeekDiscountPolicy(List<DayOfWeekDiscountRule> rules) {
            this.rules = rules;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            Money result = Money.ZERO;
            for(DateTimeInterval interval : call.getInterval().splitByDay()) {
                for(DayOfWeekDiscountRule rule: rules) { result.plus(rule.calculate(interval));
                }
            }
            return result;
        }
    }
    
    ---
    
    public class DurationDiscountRule extends FixedFeePolicy {
        private Duration from;
        private Duration to;
    
        public DurationDiscountRule(Duration from, Duration to, Money amount, Duration seconds) {
            super(amount, seconds);
            this.from = from;
            this.to = to;
        }
    
        public Money calculate(Call call) {
            if (call.getDuration().compareTo(to) > 0) {
                return Money.ZERO;
            }
    
            if (call.getDuration().compareTo(from) < 0) {
                return Money.ZERO;
            }
    
            // 부모 클래스의 calculateFee(phone)은 Phone 클래스를 파라미터로 받는다.
            // calculateFee(phone)을 재사용하기 위해 데이터를 전달할 용도로 임시 Phone을 만든다.
            Phone phone = new Phone(null);
            phone.call(new Call(call.getFrom().plus(from),
                                call.getDuration().compareTo(to) > 0 ? call.getFrom().plus(to) : call.getTo()));
    
            return super.calculateFee(phone);
        }
    }
    
    ---
    
    public class DurationDiscountPolicy extends BasicRatePolicy {
        private List<DurationDiscountRule> rules = new ArrayList<>();
    
        public DurationDiscountPolicy(List<DurationDiscountRule> rules) {
            this.rules = rules;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            Money result = Money.ZERO;
            for(DurationDiscountRule rule: rules) {
                result.plus(rule.calculate(call));
            }
            return result;
        }
    }

     

     문제점 - 비일관성

    • 위 클래스들은 기본 정책을 구현한다는 공통의 목적을 공유한다. 하지만 정책을 구현하는 방식이 완전히 다르다. 다시 말해서 개념적으로는 연관돼 있지만 구현 방식에 있어서는 완전히 제각각이다.
    • 비일관성은 두 가지 상황에서 발목을 잡는다. 하나는 새로운 구현을 추가해야 하는 상황이고, 또 다른 하나는 기존의 구현을 이해해야 하는 상황이다.
    • 유사한 기능을 서로 다른 방식으로 구현해서는 안 된다.

     


    02 설계에 일관성 부여하기

     일관성 있는 설계를 위한 조언

    • 다양한 설계 경험을 익히라는 것 -> 하지만 이런 설계 경험을 단기간에 쌓아 올리는 것은 생각보다 어려운 일이다.
    • 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 디자인 패턴을 적용해 보라는 것. (디자인 패턴 : 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는 경험 법칙을 모아놓은 일종의 설계 탬플릿)

     

     협력을 일관성 있게 만들기 위한 기본 지침

    • 변하는 개념을 변하지 않는 개념으로부터 분리하라. (☆ 디자인 패턴 얘기!)
    • 변하는 개념을 캡슐화하라.

     

     위 두 가지 지침은 훌륭한 구조를 설계하기 위해 따라야 하는 기본적인 원칙이기도 하다. 지금까지 이 책에서 설명했던 모든 원칙과 개념들 역시 대부분 변경의 캡슐화라는 목표를 향한다.

     

     객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다.

     

     캡슐화는 데이터 은닉(data hiding) 이상이다.

    • 데이터 은닉 : 오직 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한함으로써 객체 내부의 상태 구현을 숨기는 기법
    • 캡슐화 : 단순히 데이터를 감추는 것이 아니라 소프트웨어 안에서 변할 수 있는 모든 '개념'을 감추는 것이다. 즉, "캡슐화란 변하는 어떤 것이든 감추는 것이다"
    • 캡슐화란 단지 데이터 은닉을 의미하는 것이 아니다. 코드 수정으로 인한 파급효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다.

     


    03 일관성 있는 기본 정책 구현하기

     협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다.

    • 변경을 캡슐화하는 가장 좋은 방법은 변하지 않는 부분으로부터 변하는 부분을 분리하는 것이다.

     

     구현된 코드

    public interface FeeCondition {
        List<DateTimeInterval> findTimeIntervals(Call call);
    }
    
    ---
    
    public class FeeRule {
        private FeeCondition feeCondition;
        private FeePerDuration feePerDuration;
    
        public FeeRule(FeeCondition feeCondition, FeePerDuration feePerDuration) {
            this.feeCondition = feeCondition;
            this.feePerDuration = feePerDuration;
        }
    
        public Money calculateFee(Call call) {
            return feeCondition.findTimeIntervals(call)
                    .stream()
                    .map(each -> feePerDuration.calculate(each))
                    .reduce(Money.ZERO, (first, second) -> first.plus(second));
        }
    }
    
    ---
    
    public class FeePerDuration {
        private Money fee;
        private Duration duration;
    
        public FeePerDuration(Money fee, Duration duration) {
            this.fee = fee;
            this.duration = duration;
        }
    
        public Money calculate(DateTimeInterval interval) {
            return fee.times(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
        }
    }
    
    ---
    
    public final class BasicRatePolicy implements RatePolicy {
        private List<FeeRule> feeRules = new ArrayList<>();
    
        public BasicRatePolicy(FeeRule ... feeRules) {
            this.feeRules = Arrays.asList(feeRules);
        }
    
        @Override
        public Money calculateFee(Phone phone) {
            return phone.getCalls()
                    .stream()
                    .map(call -> calculate(call))
                    .reduce(Money.ZERO, (first, second) -> first.plus(second));
        }
    
        private Money calculate(Call call) {
            return feeRules
                    .stream()
                    .map(rule -> rule.calculateFee(call))
                    .reduce(Money.ZERO, (first, second) -> first.plus(second));
        }
    }
    
    ---
    
    public class TimeOfDayFeeCondition implements FeeCondition {
        private LocalTime from;
        private LocalTime to;
    
        public TimeOfDayFeeCondition(LocalTime from, LocalTime to) {
            this.from = from;
            this.to = to;
        }
    
        @Override
        public List<DateTimeInterval> findTimeIntervals(Call call) {
            return call.getInterval().splitByDay()
                    .stream()
                    .filter(each -> from(each).isBefore(to(each)))
                    .map(each -> DateTimeInterval.of(
                                    LocalDateTime.of(each.getFrom().toLocalDate(), from(each)),
                                    LocalDateTime.of(each.getTo().toLocalDate(), to(each))))
                    .collect(Collectors.toList());
        }
    
        private LocalTime from(DateTimeInterval interval) {
            return interval.getFrom().toLocalTime().isBefore(from) ?
                    from : interval.getFrom().toLocalTime();
        }
    
        private LocalTime to(DateTimeInterval interval) {
            return interval.getTo().toLocalTime().isAfter(to) ?
                    to : interval.getTo().toLocalTime();
        }
    }
    
    ---
    
    public class DayOfWeekFeeCondition implements FeeCondition {
        private List<DayOfWeek> dayOfWeeks = new ArrayList<>();
    
        public DayOfWeekFeeCondition(DayOfWeek ... dayOfWeeks) {
            this.dayOfWeeks = Arrays.asList(dayOfWeeks);
        }
    
        @Override
        public List<DateTimeInterval> findTimeIntervals(Call call) {
            return call.getInterval()
                    .splitByDay()
                    .stream()
                    .filter(each ->
                            dayOfWeeks.contains(each.getFrom().getDayOfWeek()))
                    .collect(Collectors.toList());
        }
    }
    
    ---
    
    public class DurationFeeCondition implements FeeCondition {
        private Duration from;
        private Duration to;
    
        public DurationFeeCondition(Duration from, Duration to) {
            this.from = from;
            this.to = to;
        }
    
        @Override
        public List<DateTimeInterval> findTimeIntervals(Call call) {
            if (call.getInterval().duration().compareTo(from) < 0) {
                return Collections.emptyList();
            }
    
            return Arrays.asList(DateTimeInterval.of(
                    call.getInterval().getFrom().plus(from),
                    call.getInterval().duration().compareTo(to) > 0 ?
                            call.getInterval().getFrom().plus(to) :
                            call.getInterval().getTo()));
        }
    }

     

     일관성 있는 협력

    • 이제 기본 정책을 추가하기 위해 규칙을 지키는 것보다 어기는 것이 더 어렵다.
    • 일관성 있는 협력은 개발자에게 확장 포인트를 강제하기 때문에 정해진 구조를 우회하기 어렵게 만든다.

     

     지속적으로 개선하라

    • 처음에는 일관성을 유지하는 것처럼 보이던 협력 패턴이 시간이 흐르면서 새로운 요구사항이 추가되는 과정에서 일관성의 벽에 조금씩 금이 가는 경우를 자주 보게 된다.
    • 초기 단계에서는 모든 요구사항을 미리 예상할 수 없기 때문에 이것은 잘못이 아니며 꽤나 자연스러운 현상이다.
    • 오히려 새로운 요구사항을 수용할 수 있는 협력 패턴을 향해 설계를 진화시킬 수 있는 좋은 신호로 받아들여야 한다.
    • 협력은 고정된 것이 아니다. 만약 현재의 협력 패턴이 변경의 무게를 지탱하기 어렵다면 변경을 수용할 수 있는 협력패턴을 향해 과감하게 리팩터링하라.

     

     패턴을 찾아라

    • 일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다.
    • 협력을 일관성 있게 만드는 과정은 유사한 기능을 구현하기 위해 반복적으로 적용할 수 있는 협력의 구조를 찾아가는 기나긴 여정이다. 따라서 협력을 일관성 있게 만든다는 것은 유사한 변경을 수용할 수 있는 협력 패턴을 발견하는 것과 동일하다.
    • 협력 패턴과 관련한 두 가지 개념 : 패턴, 프레임워크 (15장에서 할 얘기)

     


     

    CHAPTER 15. 디자인 패턴과 프레임워크

    ⚈ 디자인 패턴 - 협력을 일관성 있게 만들기 위해 재사용할 수 있는 설계의 묶음

    • 애플리케이션을 설계하다 보면 어떤 요구사항을 해결하기 위해 과거에 경험했던 유사한 해결 방법을 다시 사용하는 경우가 있다.
    • 이처럼 소프트웨어 설계에서 반복적으로 발생하는 문제에 대해 반복적으로 적용할 수 있는 해결 방법을 디자인 패턴이라고 부른다.
    • 디자인 패턴의 목적은 설계를 재사용하는 것이다.
    • 디자인 패턴은 다양한 변경을 다루기 위해 반복적으로 재사용할 수 있는 설계의 묶음이다.
    • 디자인 패턴은 특정한 변경을 일관성 있게 다룰 수 있는 협력 탬플릿을 제공한다.

     

     프레임워크 - 일관성 있는 협력을 제공하는 확장 가능한 코드

    • 디자인 패턴이 설계를 재사용하기 위한 것이라면 프레임워크는 설계와 코드를 함께 재사용하기 위한 것이다.
    • 프레임워크가 제공하는 아키텍처가 요구사항에 적합하다면 다양한 환경에서 테스트를 거친 견고한 구현 코드를 쉽고 빠르게 재사용할 수 있다.
    • 프레임워크는 특정한 변경을 일관성 있게 다룰 수 있는 확장 가능한 코드 탬플릿을 제공한다.

     


    01 디자인 패턴과 설계 재사용

     패턴 : 하나의 실무 컨텍스트(practical context)에서 유용하게 사용해 왔고 다른 실무 컨텍스트에서도 유용할 것이라고 예상되는 아이디어(idea)

     

    ⚈ 패턴으로 인정하기 위한 조건 - '3의 규칙(Rule of Three)'

    • 최소 세 가지의 서로 다른 시스템에 특별한 문제 없이 적용할 수 있고 유용한 경우에만 패턴으로 간주할 수 있다.

     

    ⚈ 패턴의 가치

    • 경험을 통해 축적된 실무 지식을 효과적으로 요약하고 전달할 수 있다 (패턴은 경험의 산물)
    • 패턴의 이름은 커뮤니티가 공유할 수 있는 중요한 어휘집을 제공한다. ("인터페이스를 하나 추가하고 이 인터페이스를 구체화하는 클래스를 만든 후 객체의 생성자나 setter 메서드에 할당해서 런타임 시에 알고리즘을 바꿀 수 있게 하자" 대신 "STRATEGY 패턴을 적용하자")

     

     패턴 분류

    • 디자인 패턴(Design Pattern) : 특정 정황 내에서 일반적인 설계 문제를 해결하며, 협력하는 컴포넌트들 사이에서 반복적으로 발생하는 구조를 서술한다.
    • 아키텍쳐 패턴(Architecture Pattern) : 디자인 패턴의 상위. 소프트웨어의 전체적인 구조를 결정하기 위해 사용
    • 이디엄(Idiom) : 디자인 패턴의 하위. 특정 프로그래밍 언어에만 국한된 하위 레벨 패턴.
    • 분석 패턴(Analysis Pattern) : 위 3개가 주로 기술적인 문제를 해결하는 데 초점을 맞추고 있다면 분석 패턴은 도메인 내의 개념적인 문제를 해결하는 데 초점을 맞춘다. 분석 패턴은 도메인 업무 모델링 시에 발견되는 공통적인 구조를 표현하는 개념들의 집합이다.

     

    ⚈ 대부분의 디자인 패턴은 협력을 일관성 있고 유연하게 만드는 것을 목적으로 한다.

    • 영화 예매 시스템의 경우 객체 합성을 이용한 STRATEGY 패턴을 적용한 예이다. STRATEGY 패턴의 목적은 알고리즘의 변경을 캡슐화하는 것이다.

    • 변경 캡슐화를 위해 상속을 이용할 수도 있다. 이하는 변경을 캡슐화하기 위해 상속을 사용한 예다. 알고리즘을 캡슐화하기 위해 합성 관계가 아닌 상속 관계를 사용하는 것을 TEMPLATE METHOD 패턴이라고 부른다. 변경하지 않는 부분은 부모 클래스로, 변하는 부분은 자식 클래스로 분리함으로써 변경을 캡슐화한다.

    • 핸드폰 과금 시스템 설계는 DECORATOR 패턴을 기반으로 한다. DECORATOR 패턴은 객체의 행동을 동적으로 추가할 수 있게 해주는 패턴으로 기본적으로 객체의 행동을 결합하기 위해 객체 합성을 사용한다. DECORATOR 패턴은 선택적인 행동의 개수와 순서에 대해 변경을 캡슐화할 수 있다.

     

     패턴은 출발점이다.

    • 패턴은 출발점이지 목적지가 아니다.
    • 디자인 패턴이 현재의 요구사항이나 적용 기술, 프레임워크에 적합하지 않다면 패턴을 그대로 따르지 말고 목적에 맞게 패턴을 수정하라.
    • 패턴을 사용하면서 부딪히게 되는 대부분의 문제는 패턴을 맹목적으로 사용할 때 발생한다.
    • 패턴에 처음 입문한 사람들은 패턴의 강력함에 매료된 나머지 아무리 사소한 설계라도 패턴을 적용해보려고 시도한다. 그러나 명확한 트레이드오프 없이 패턴을 남용하면 설계가 불필요하게 복잡해지게 된다.
    • 정당한 이유 없이 사용된 모든 패턴은 설계를 복잡하게 만드는 장애물이다.

     


    02 프레임워크와 코드 재사용

    ⚈ 가장 이상적인 형태의 재사용 방법은 설계 재사용과 코드 재사용을 적절한 수준으로 조합하는 것이다.

    • 추상적인 수준에서의 설계 재사용을 강조하는 디자인 패턴은 재사용을 위해 매번 유사한 코드를 작성해야만 한다.
    • 설계를 재사용하면서도 유사한 코드를 반복적으로 구현하는 문제를 피할 수 있는 방법은 없을까? -> 프레임워크!

     

    ⚈ 프레임워크

    • 프레임워크의 구조적인 측면 : '추상 클래스나 인터페이스를 정의하고 인스턴스 사이의 상호작용을 통해 시스템 전체 혹은 일부를 구현해 놓은 재사용 가능한 설계'
    • 코드와 설계의 재사용이라는 프레임워크의 목적 측면 : '애플리케이션 개발자가 현재의 요구사항에 맞게 커스터마이징할 수 있는 애플리케이션의 골격(skeleton)'

     

     제어 역전 원리

    • 의존성 역전 원리는 전통적인 설계 방법과 객체지향을 구분하는 가장 핵심적인 원리다.
    • "좋은 객체지향 설계의 증명이 바로 의존성의 역전이다. ... 그 의존성이 역전돼 있지 않다면. 절차적 설계를 갖는 것이다."
    • 의존성을 역전시킨 객체지향 구조에서는 반대로 프레임워크가 애플리케이션에 속하는 서브클래스의 메서드를 호출한다. 따라서 프레임워크를 사용할 경우 개별 애플리케이션에서 프레임워크로 제어 흐름의 주체가 이동한다. 즉, 의존성을 역전시키면 제어 흐름의 주체 역시 역전된다. 이를 제어 역전(Inversion of Control) 원리, 또는 할리우드(Hollywood) 원리라고 한다.

     

     프레임워크에서는 일반적인 해결책만 제공하고 애플리케이션에 따라 달라질 수 있는 특정한 동작은 비워둔다. -> 이렇게 완성되지 않은 채로 남겨진 동작을 훅(hook)이라고 부른다.

    • 재정의된 훅은 제어 역전 원리에 따라 프레임워크가 원하는 시점에 호출된다.

     

     제어 역전

    • 우리는 프레임워크가 적절한 시점에 실행할 것으로 예상되는 코드를 작성할 뿐이다.
    • 과거에는 우리가 직접 라이브러리의 코드를 호출했지만 객체지향의 시대에는 그저 프레임워크가 호출하는 코드를 작성해야만 한다.
    • 제어가 우리에게서 프레임워크로 넘어가 버린 것이다.

    댓글