6 분 소요

오브젝트를 공부하기 앞서

나는 4학년 2학기에 건국대학교에서 소프트웨어 아키텍처라는 수업을 들었고, 해당 수업에서 객체지향 설계에 대해 많은 것을 배웠다. 설계의 기초와 원리부터 다이어그램 작성법, 디자인패턴 그리고 기능과 그 기능의 개선에 대한 개념을 이해했다. 인공지능이 인간을 대신해 코드를 짜고있는 시대에 살며 10년 후의 나의 가치를 만들기 위해서는 코드를 짜는 사람보다 코드를 설계하는 사람이 되어야 한다고 생각했다. 앞서 말한 1학기동안 배운 수업도 나에겐 큰 재산이 됐지만 더 자세히 객체지향 설계에 대해 공부해보고자 ‘오브젝트’ 라는 책을 공부하기로 마음 먹었다. 해당 포스팅은 앞으로의 오브젝트 책의 내용을 정리하고자 올리는 것이며 이는 정답이나 정석이 아니고 그저 내 기억 아카이브의 용도임을 미리 밝힌다.

패러다임

패러다임의 전환

현대에 ‘패러다임’이란 ‘한 시대의 사회 전체가 공유하는 이론이나 방법, 문제의식 등의 체계’를 의미한다. 과거의 패러다임이 새로운 패러다임에 의해 대체됨으로써 정상과학의 방향과 성격이 변하는 것을 우리는 ‘패러다임의 전환’이라고 부른다. 소프트웨어 개발에 종사하는 대부분의 사람들에게 있어서 패러다임의 전환은 곧 ‘절차형 패러다임’에서 ‘객체지향 패러다임’으로의 변화를 가리킨다.

프로그래밍 패러다임

프로그래밍 패러다임이란 특정 시대의 어느 성숙한 개발자 공동체에 의해 수용된 프로그래밍 방법과 문제 해결 방법, 프로그래밍 스타일이라고 할 수 있다. 다시 말해 우리가 어떤 프로그래밍 패러다임을 사용하느냐에 따라 우리가 해결할 문제를 바라보는 방식과 프로그램을 작성하는 방법이 달라진다. 프로그래밍 패러다임은 개발자 공동체가 동일한 프로그래밍 스타일과 모델을 공유할 수 있게 함으로써 불필요한 부분에 대한 의견 충돌을 방지한다. 또한 프로그래밍 패러다임을 교육시킴으로써 동일한 규칙과 방법을 공유하는 개발자로 성장할 수 있도록 준비시킬 수 있다.

  • 프로그래밍 언어가 제공하는 특징과 프로그래밍 스타일은 해당 언어가 채택하는 프로그래밍 패러다임에 따라 달라진다.
  • 예를들어 C언어는 절차형 패러다임을, JAVA 언어는 객체지향 패러다임을 기반으로 하는 언어이다.
  • 소프트웨어 개발에 있어 프로그래밍 패러다임은 공존할 수 있으며 장단점을 보완한다.
  • 즉, 프로그래밍 패러다임은 혁명이 아닌 발전이라고 볼 수 있다.

객체, 설계

결론부터 말하면 설계에서 실무는 이론을 압도한다. 어떤 분야든 초기 단계에서는 아무것도 없는 상태에서 이론을 정립하기보다는 실무를 관찰한 결과를 바탕으로 이론을 정립하는 것이 최선이다. 소프트웨어 분야는 아직 초기단계이기에 이론부터 실무가 더 앞서 있으므로 실무가 더 중요하다는 것이다. 다시 말해 설계에 관해 말할 때 가장 유용한 도구는 이론으로 치장된 개념과 용어가 아니라 ‘코드’라는 의미이다.

티켓 판매 어플리케이션

우리는 티켓 판매 어플리케이션을 만들고자 한다. 초대장이 있는 관람객은 관람권으로 교환할 수 있고 그렇지 않은 관람객은 관람권을 구매하여야 한다. 극장, 매표소, 판매원 그리고 관람객이 존재한다. 어차피 나만 보는 포스팅이기에 모든 코드를 올리지 않고 중요한 코드만 편집하여 올린다.

해당 코드는 Theater 클래스의 enter 매소드이다. 해당 매소드에서 관람객이 초대장이 있다면 관람권으로 교환해주고 그렇지 않다면 관람권을 구매하게 된다.

    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);
        }
    }

문제점

모든 소프트웨어 모듈에는 세 가지 목적이 있다.

  1. 실행 중에 제대로 동작한다.
  2. 변경을 위해 존재한다. (간단한 작업만으로 변경이 가능해야 한다.)
  3. 코드를 읽는 사람과 의사소통한다.

위의 코드는 1번은 충분히 만족하는 코드이지만, 2번과 3번을 만족하지 못한다. 2번을 만족하지 못하는 이유는 극장 클래스가 판매원, 매표소 그리고 관람객을 통제한다. 예를들어 판매원이나 관람객의 정보를 바꾸고자 한다면 매표소 그리고 극장 클래스를 모두 변경해야하는 번거로움이 생긴다. 즉, 변경에 취약하고 용이하지 않다는 것이다. 이러한 객체 사이의 의존성 (dependency)과 관련된 문제다. 의존성에는 어떤 객체가 변경될 때 그 객체에게 의존하는 다른 객체도 함께 변경될 수 있다는 사실을 내포한다. 의존성에 대한 내용은 후에 더 다루도록 하겠다. 우리의 목표는 애플리케이션의 기능을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거해야 한다. 객체 사이의 의존성이 과한 경우를 가리켜 결합도가 높다 (high coupling) 이라고 한다. 우리는 높은 결합도와 낮은 응집도를 피해야 한다.

  • 의존성 (Dependency) : 파라미터나 리턴값 또는 지역변수 등으로 다른 객체를 참조하는 것
  • 결합도 (Coupling) : 다른 모듈과의 의존성이 정도
  • 응집도 (Cohension) : 모듈에 포함된 내부 요소들이 하나의 책임/ 목적을 위해 연결되어있는 연관된 정도

3번을 만족하지 못하는 이유는 2번과 비슷한 이유로 우리는 극장의 통제를 받지 않음과 동시에 현실과 위 코드의 세상은 크게 차이가 있으며 혹시 다른 사람이 해당 코드를 본다면, 코드를 이해하기 위해 얽혀있는 여러가지 세부적인 내용들을 한꺼번에 기억해야 한다는 점이다.

설계 개선

첫 번째 단계

극장 클래스의 enter 매소드에서 매표소에 접근하는 모든 코드를 판매원 클래스 내부로 숨기는 것이다. 기존 코드의 getTicketOffice()는 사라졌고 TicketOffice 를 private 으로 설정한다면 결과적으로 외부에서 직접 TicketOffice 에 접근할 수 없다. 즉, Theater 클래스는 TicketOffice 가 TicketSeller 안에 존재한다는 사실을 알지 못한다. 하지만 모듈1은 여전히 만족한다. 이처럼 개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화(encapsulation)이라고 한다. 캡슐화를 통해 객체 내부로의 접근을 제한하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 설계 변경이 쉬워진다.

    public TicketOffice ticketOffice;

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

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

Teater 는 오직 TicketSeller 의 인터페이스에만 의존한다. TicketSeller가 내부에 TicketOffice 인스턴스를 포함하고 있다는 사실은 구현의 영역에 속한다. 객체를 인터페이스와 구현으로 나누고 인터페이스만을 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 설계 원칙이다.

두 번째 단계

TicketSeller 는 여전히 Audience의 Bag 인스턴스에 직접 접근한다. 여전히 Audience는 자율적인 존재가 아니다. 이를 해결하기 위해 첫 번째 방법과 동일한 방법으로 Audience의 캡슐화를 개선할 수 있다.

    # TicketSller 의 sellTo 매소드
    public void sellTo(Audience audience) {
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
    }

    # Audience 의 buy 매소드
    public Long buy(Ticket ticket) {
        if (bag.hasInvitation()) {
            bag.setTicket(ticket);
            return 0L;
        } else {
            bag.setTicket(ticket);
            bag.minusAmount(ticket.getFee());
            return ticket.getFee();
        }
    }

변경된 코드에서 Audience는 자신의 가방 안에 초대장이 들어있는지를 스스로 확인한다. 즉 외부에서 직접 가방에 대한 접근을 허용하지 않는다. 결과적으로 TicketSeller와 Audience 사이의 결합도가 낮아졌고 내부 구현이 캡슐화됐다.

캡슐화와 응집도

위 개선사항의 핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다. 밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 개체를 가리켜 응집도가 높다고 말한다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을뿐더러 응집도를 높일 수 있다.

절차지향과 객체지향

위에서 Teater의 enter 매소드는 프로세스이며 Audience, TicketSeller, Bag 그리고 TicketOffice 는 데이터이다. 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍(Procedural Programmin)이라고 부른다. 절차적 프로그래밍은 우리의 직관에 위배된다. 더욱이 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다. 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다. 절차적 프로그래밍은 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약할 수밖에 없다. 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 이동시켜 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍(Object-Oriented Programming)이라고 부른다. 훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사의의 결합도를 낮추는 것이다.

책임의 이동

책임이란 쉽게 말해 객체의 기능을 가리키는 용어이다. 위에서 Theater에 집중되어 있는 책임들을 개별 객체로 이동시켰고 이를 ‘책임의 이동’을 의미한다. 각 객체는 자신을 스스로 책임지고 이들의 협력을 통한 공동체를 구성함으로써 완성된다. 따라서 객체가 어떤 데이터를 가지느냐보다는 객체에 어떤 책임을 할당할 것이냐에 초점을 맞춰야 한다.

추가 개선 1

사실 우리는 TicektOffice 의 자율권을 찾아줄수도 있다. 아래 코드 수정으로 getTicket 매서드와 plusAmount 매서드는 TicketOffice 내부에서만 사용되기 때문에 결합도가 낮아지고 매서드의 가시성을 private 으로 바꿀 수 있다.

    # TicketSller 의 바뀌기 전 sellTo 매소드
    public void sellTo(Audience audience) {
        ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
    }

    # TicketSeller 의 바뀐 후 sellTo 매소드
    public void sellTo(Audience audience) {
        ticketOffice.sellTicketTo(audience);
    }

    # TicketOffice 의 sellTicketTo 매소드
    public void sellTicketTo(Audience audience) {
        plusAmount(audience.buy(getTicket()));
    }

하지만 위 코드는 다시 살펴보면 TicketOffice 와 Audience 사이의 의존성이 추가됐다. 우리는 해당 예를 통해 2가지를 알 수 있다.

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

추가 개선 2

추가로 Audience 와 Bag를 분리시켜 Bag 또한 자율적인 객체로 만들어 결합도를 낮추고 응집도가 높은 객체로 만들 수 있다. 해당 부분은 코드를 포스팅하지 않지만 설명하는 이유가 있다. Bag은 사실 실제로 능동적인 존재는 아니다. 하지만 객체지향의 세계에서는 모든 것은 능동적이고 자율적인 객체로 취급한다. 이러한 원칙을 의인화(anthropomorphism) 이라고 부른다.

객체지향 설계

요구사항은 항상 변경된다. 개발을 시작하는 시점에 구현에 필요한 모든 요구사항을 수집하는 것은 불가능에 가깝다. 또, 코드를 변경할 때 버그가 추가될 가능성이 높다. 요구사항 변경은 필연적으로 코드 수정을 초래하고 버그 발생 가능성을 높인다. 즉, 우리는 변경을 수용할 수 있는 설계를 매우 중요하게 생각해야 한다.

결론

객체지향 패러다임을 통해 우리는 세상을 객체의 관점으로 바라볼 수 있어야 한다. 우리는 앞서 캡슐화, 응집도 그리고 결합도 등을 통한 객체지향 설계에 대해 살펴봤다. 하지만 단순히 데이터와 프로세스를 객체에 넣었다고 하여 변경하기 쉬운 설계라고 할 수 없다. 객체지향의 세계에서 애플리케이션은 객체들간의 상호작용으로 구현된다. 이러한 객체들의 협력 과정 속에서 객체들은 다른 객체에 의존하게 된다. 메세지를 전송하기 위한 정보들이 두 객체를 결합시키고 이 결합이 객체 사이의 의존성을 만든다. 우리는 협력하는 객체들 사이의 의존성을 적절히 조절함으로써 변경에 용이한 설계를 만들어야 한다.

업데이트: