오브젝트-코드로 이해하는 객체지향 설계 : Part 1

21061 단어 OOPOOP

머릿말

이번 서적은 '오브젝트:코드로 이해하는 객체지향 설계' 입니다. OOP에 대한 많은 지식이 없어 서적의 완성도를 논하기엔 힘들지만, 객체지향에 대해 첫 발을 내딛으려 할 때는 이 책이 '객체지향의 사실과 오해'와 더불어 객체지향의 개념을 쉬운 예시와 코드를 통해 대단히 잘 풀어낸 서적이라고 생각합니다.

본문은 총 15개의 장으로 이루어져 있습니다. '객체지향의 사실과 오해'에서 객체지향의 개념들과 세계에 대해 탐구했다면, 이번에는 예시 코드와 함께 직접 우리가 소프트웨어를 개발해보며 확인해봅니다. 개인적으로는 이번 서적을 읽으며 훨씬 개념이 깊게 잡힌 것 같습니다. 서문이 길었네요. 제 정리가 다른 분들께 많은 도움이 되었으면 좋겠습니다. 시작하시죠.

예제 소스 코드 Link

닭? 달걀?

본문은 로버트 L.글래스가 소프트웨어 크래에이티비티 2.0에서 '이론 대 실무'라는 주제에 대해 밝히는 견해로 시작한다. 여기서 다루는 질문은 "이론이 먼저일까, 실무가 먼저일까?". 그 대답으로 대부분의 사람은 이론이 먼저일 것이라 생각하지만 분야를 막론하고 초기에는 실무가 급속한 발전을 이루고, 실무가 어느 정도 발전을 한 뒤에서야 실무의 실용성을 입증하는 이론이 생겨난다는 것이다.

이는 '소프트웨어 설계', '소프트웨어 유지보수' 분야에서도 마찬가지인데 우리가 보고있는 이 서적도 그 이론인 설명하기 위해 쓰여진 책이다. 다만 개발자에게는 줄줄이 나열된 역사와 난해한 개념들보다는 코드가 훨씬 친숙하니 훌륭한 설계를 가능케 해주는 도구들에 대한 내용을 구체적인 코드를 통해서 접근하고자 한다. 간단한 프로그램들을 통해 배워보자.


티켓 판매 애플리케이션 구현

소극장을 운영하는 상황이다. 작은 이벤트로 추첨을 통해 관람객에게 공연을 무료 관람할 수 있는 초대장을 발송하기로 했다. 이벤트가 종료되고 예정된 공연 날에 공연을 진행하려 한다. 이 때 공연을 보려는 사람들 중에는 이벤트에 당첨되어 초대장을 가진 사람과 아닌 사람들이 있다.

이 때 우린 두 부류의 사람들을 다른 방식으로 입장시켜야 한다. 티켓을 구매하거나, 초대장을 티켓으로 교환하거나 둘 중 하나로 입장시켜야 한다.

아래는 티켓을 판매하기 위해 필요한 객체들이다. 각 객체들은 객체간의 연관 관계에 따라 인스턴스 변수로 다른 객체의 참조를 가지며 이는 화살표로 표시된다. 자세한 내용은 예제 코드를 확인하자.

  • 이벤트에 당첨되었을 경우 티켓으로 교환가능한 초대장: invitation
  • 공연을 관람하기 위해 소지해야 하는 티켓: Ticket
  • 소지품들을 넣을 수 있는 가방: Bag
  • 티켓을 구매할 수 있는 매표소: TicketOffice
  • 초대장과 티켓을 교환해주거나 티켓을 판매하는 판매원: TicketSeller
  • 공연을 관람하는 관람객: Audience

클래스

위 클래스들과 협력하는 소극장(Theater) 객체

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

예상을 빗나가는 코드

지금 이 애플리케이션에 어떤 문제가 있을까. 로버트 마틴은 모듈은 제대로 실행되어야 하며, 변경이 용이해야 하며, 이해하기 쉬워야 한다 고 말한다.

위 애플리케이션은 티켓을 가진 손님과 아닌 손님을 명확하게 구분하며, 티켓을 구매할 때 현금의 증감까지 완벽하게 구현하였다. 분명히 제대로 수행되고 있다. 하지만 문제는 분명히 존재한다. 바로 2,3번째 조건을 만족시키지 못한다. 그 이유는 바로 enter 메서드가 수행하는 일에 있다.

enter() 메서드의 프로세스
1. 소극장 객체는 관람객의 가방을 열어 그 안에 초대장이 들어 있는지 살펴본다.
2. 가방 안에 초대장이 들어 있으면 판매원은 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮긴다.
3. 가방 안에 초대장이 들어 있지 않다면 관람객의 가방에서 티켓 금액만큼의 현금을 꺼내 매표소에 적립한 후에 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮긴다.

여기서 문제는 Audience과 TicketSeller가 Theater의 통제를 받는 수동적인 존재라는 점이다. 동작하는 대부분의 일들이 우리의 예상을 벗어난다. 소극장이 관람객의 가방을 연다는 개념이 이해되는가? 판매원이 티켓을 관람객의 가방으로 멋대로 옮기거나, 관람객의 가방에서 현금을 꺼내는게 이해되는가?

티켓을 관람객의 가방 안으로 옮기거나, 현금을 꺼내거나, 초대장이 들어있는지 확인하는 것 모두 관람객이 해야하는 일이지 판매원이 하는 일이 아니다. 코드의 동작방식은 우리의 상식과는 많이 다르다.

또한 하나의 클래스 혹은 메서드가 너무 많은 세부사항을 다룰 뿐더러 Audience와 TicketSeller 를 변경할 경우 Theater도 변경해야 한다는 가장 중요한 문제점이 있다.


변경에 취약한 코드

또한 가장 중요한 문제는 변경에 취약하다는 점이다. 만약 관람객이 가방을 들고 있지 않다고 가정해보자. 이럴때 우린 Audience 뿐만 아니라 Audience의 Bag 에 접근하는 Theater.enter() 까지 수정해야한다.

Theater는 Audience 에 의존한다. 그리고 이것은 변경과 관련되어있다. 어떤 객체(Audience)가 변경될 때 그 객체에 의존하는 다른 객체(Theater)도 함께 변경될 수 있다는 것이다. 우리는 객체 사이의 의존성이 과한 경우를 결합도(coupling)가 높다고 말한다.

물론 의존성을 완전히 제거하는 것이 정답이 아니라 기능을 구현하는 데에 필요한 최소한의 의존성만 유지하는 것이 좋다. 즉, 우리의 최종 목표는 의존성을 낮춰 객체의 변경이 다른 객체에 많은 영향을 끼치지 않는 변경이 용이한 설계를 만드는 것이다.

여기서 키워드는 변경과 의사소통이라는 문제가 서로 엮여 있다는 것이다. 우리는 관람객과 판매원이 자신의 일을 스스로 처리하길 기대하지만 코드는 그렇지 않기 때문에 이해하기 어려워진 것이다. 객체가 다른 객체에 메세지를 보내지 않고 직접 접근하는 것은 객체가 서로 결합되어있다는 것을 의미한다.

해결방법은 간단하다. 객체가 다른 객체의 세세한 부분까지 알지 못하도록 정보를 차단하면 된다. 즉, 관람객이 가방을 갖고 있다는 사실과 판매원이 매표소에서 티켓을 판매한다는 사실을 Theater가 알 필요가 없도록 만들면 된다. 다시 말해 관람객과 판매원을 자율적인 존재로 만들면 된다.


자율성을 높이자

설계를 변경하기 어려웠던 이유는 Theater 가 Audience, TicketSeller , Bag, TicketOffice 를 모두 직접 접근할 수 있었기 때문이다. 우리는 Audience, TicketSeller 가 직접 Bag, TicketOffice 을 처리하는 자율적인 존재가 되도록 변경하면 된다.

TicketSeller에 sellTo 메서드를 추가한 뒤 enter의 로직을 옮긴다

public class Theater {
	private TicketSeller ticketSeller;

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

	public void enter(Audience audience) {
		ticketSeller.sellTo(audience);
	}
}
// TicketSeller.sellTo()
public void sellTo(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);
	}
}

getTicketOffice() 메서드가 제거됐다. TicketSeller 가 일하는 ticketOffice 필드는 private이고, 접근 가능한 퍼블릭 메서드가 없다. 따라서 TicketSeller만 접근 가능하다.

위 변경을 통해 TicketSeller는 ticketOffice에서 티켓을 꺼내거나 판매 요금을 적립하는 일들을 스스로 수행하도록(할 수 밖에 없다) 변경되었다. 이처럼 개념,물리적으로 객체 내부의 세부사항을 감추는 것을 캡슐화라고 한다.

이제 수정된 Theater 클래스 어디서도 ticketOffice에 접근하지 않는다. 오직 TicketSeller의 인터페이스(sellTo)에만 의존하며, TicketSeller가 ticketOffice 인스턴스를 포함하고 있다는 사실은 구현의 영역에 속하게 되고 자연스럽게 Theater의 enter() 메소드를 통해 갖고 있던 TicketOffice에 대한 의존성이 제거되었다.

TicketSeller가 Audience의 Bag에 직접 접근하는 것을 제거한다

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

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

변경 전엔 초대장이 없으면 티켓을 ticketOffice에게 전달받아 가방에 넣고, 초대장이 있으면 티켓을 구매하여 ticketOffice에게 전달받아 가방에서 현금을 마이너스하고, 가방에 넣는 행동을 sellTo() 안에서 TickSeller가 직접 수행했다.

즉 TickSeller가 Audience의 Bag에 직접 접근하게 되어 Audience는 여전히 자율적인 존재가 아니었다. 따라서 우리는 Audience 가 직접 buy() 메서드를 통해 자신의 가방안에 초대장이 들어있는지를 스스로 확인하고 티켓을 구매하도록 변경하였다.

이를 통해 TickSeller는 Audience가 Bag을 가지고 있는지 알 필요가 없어지게 됐고, 결과적으로 우리는 Bag의 존재를 내부로 캡슐화할 수 있게 됐다.


무엇이 개선됐는가

위 과정을 통해 우리는 모든 객체가 자율적인 존재가 되도록 변경해보았다. 그렇다면 로버트 마틴의 세 가지 조건을 만족하는지 다시 한번 확인해보자.

첫번째, 기능을 오류없이 수행한다. 두번째, 수정된 Audience와 TickSeller는 자신의 소지품을 스스로 관리한다. 이것은 우리의 예상과 일치하기 때문에 의사소통이라는 관점으로 바라보았을 때 이전의 코드가 갖던 비상식적인 행동 역시 개선되었다. 또한 각 내부 구현을 변경하더라도 우리는 Theater 를 변경할 필요가 없다. 따라서 세번째, 변경 용이성 역시 확실히 개선되었다.


어떻게 한 것인가

본인의 문제는 본인이 해결하도록 변경했다. 판매자가 티켓을 판매하기 위한 행동은 판매자(TickSeller)에게 옮겼고, 구매자가 티켓을 구매하기 위한 행동은 구매자(Audience)에게 옮겼다.


캡슐화와 응집도

이번 작업은 Theater가 Audience의 내부에 대해선 전혀 알지 못한 채(캡슐화 ) buy 라는 메시지에 응답할 수 있다고 인식한채 요청(위임 )하도록 수정했다. 즉 본인과 연관된 작업만 수행하고 연관성이 없는 작업은 다른 객체에게 위임하도록 변경된 것이다. 우리는 이를 응집도가 높다고 말한다.

객체의 응집도를 높이기 위해서는 객체 스스로 자신의 데이터를 책임져야 한다. 이것이 외부의 간섭을 배제하고 메시지를 통해서만 협력하는 설계, 훌륭한 객체지향 설계를 이루기 위한 지름길이다.


절차지향과 객체지향

우리는 변경 전 Theater의 enter() 메서드에서 Audience, Bag, TicketOffice를 가져와 관람객을 입장시키는 절차를 구현했다. 이 때 Audience, TickSeller, Bag, TicketOffice 는 절차를 구현하는데 필요한 정보를 제공했고, 입장시키는 처리 순서는 메서드 내부에 존재했다.

이 관점에서 enter() 메서드는 프로세스(Process) 이며, Audience, TickSeller, Bag, TicketOffice는 데이터(Data) 다. 이처럼 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 우리는 절차적 프로그래밍(Procedural Programming) 이라고 한다.

이 방식으로 코드가 작성될 경우 객체가 다른 객체에 강한 의존성을 띄게 된다. 이는 작은 변경이 수많은 객체에게까지 영향을 주는 변경하기 어려운 코드를 양산하는 기반이 된다. 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다. 절차적 프로그래밍은 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약하다.

따라서 우리는 객체가 자신의 데이터를 스스로 처리하도록 프로세스의 단계를 객체에게 분할하였고, 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 변경했다. 이를 우리는객체지향 프로그래밍(Object-Oriented Programming) 이라고 부른다.

물론 변경 후에도 Theater는 TickSeller에게, TickSeller는 Audience에게 의존한다. 하지만 이것은 적절히 통제되었으며 변경으로 인한 여파가 여러 클래스로 전파되는 것을 효율적으로 억제한다. 휼륭한 객체지향 설계는 의존성을 없애는 것이 아니다. 적절히 관리하여 객체 사이의 결합도를 낮추는 것이 핵심이다.


책임의 이동

‘책임’은 기능을 가리킨다. 위 두 방식의 차이점은 작업 흐름이 누구로부터 제어되는지를 보면 알 수 있다.

기존 코드(절차지향)의 경우 Theater에 의해 제어된다. 즉 책임이 Theater 에 집중되어 있다는 것이다.

하지만 변경된 코드(객체지향)는 제어 흐름이 각 객체에 적절하게 분산되어 있다. 하나의 기능을 완성하는데 필요한 책임이 여러 객체에 알맞게 분산되어 있다는 것이다.

객체지향은 단순히 데이터와 프로세스를 하나의 객체 안으로 모으는 것 이상으로, 적절한 객체에 적절한 책임을 할당하는 것이다.

설계를 어렵게 만드는 것은 의존성이다. 불필요한 의존성을 제거하면 결합도가 낮아지고, 이를 위해 우리는 캡슐화라는 방법을 사용했었다. 불필요한 세부사항을 객체 내부로 캡슐화하는 것은 객체의 자율성을 높이고 응집도 높은 객체들의 공동체를 창조할 수 있는 방법이다.


더 개선할 수 있다

우리는 추가적인 수정을 통해 Bag, TicketOffice 의 자율권을 찾아줬다. 결국 우리는 인터페이스에만 의존할 수 있도록 변경했다. 하지만 이는 결과적으로 TicketOffice 와 Audience 사이에 의존성을 추가하는 또 다른 단점을 낳았다.

여기서 우리는 결국 TicketOffice의 자율성과 Audience의 결합도 중 둘 중 하나를 포기해야하는 트레이드 오프 상황에 닥치게 된다. 우리는 결국 훌륭한 설계라는 것은 적절한 트레이드 오프의 결과물이라는 것을 인식해야 한다.


그래, 거짓말이다!

보통 코드는 우리 평소의 직관에 따를 때에 이해하기 쉬운 경향이 있다. 하지만 Theater, Bag, TicketOffice 를 생각해보면 우리의 직관과는 일치하지 않는다. 이들은 실세계에서 자율적인 존재가 아니기 때문이다. 하지만 객체지향의 세계에서는 모두가 능동적이고 자율적인 존재다. 이처럼 소프트웨어 객체를 능동적이고 자율적으로 설계하는 원칙을 레베카 워프스브룩은 의인화라고 칭했다.

객체지향 세계는 무조건 실세계와 일치하지 않는다. 실세계에서는 생명이 없고 수동적이라도 객체지향의 세계에선 모두가 생명과 지능을 가진 자율적인 존재가 된다는 사실을 잊지말자.


객체지향 설계

우리가 첫번째로 설계한 코드와 수정한 설계는 사실 동일한 결과물을 도출한다. 다만 첫번째는 데이터와 프로세스를 나누어 별도의 클래스에 배치했고, 수정된 코드는 필요한 데이터를 보유한 클래스 안에 프로세스를 함께 배치했다. 동일한 결과물을 갖는 다른 설계라는 것이다.

좋은 설계는 무엇일까. 단순히 결과물만 뱉으면 되는 것은 좋은 설계라고 볼 수 없다. 소프트웨어의 요구사항은 항상 변화한다. 오늘 설계한 것도 내일 변경될 수 있다. 따라서 우린 변경을 쉽게 수용하며 요구하는 기능을 온전히 수행하는 설계를 해야만 코드 수정마다 발생하는 버그에 대한 위험성을 최소화시킬 수 있다. 즉 변경에 유연하게 대응하는 코드가 좋은 설계다.


맺음말

이번 장은 티켓 판매 애플리케이션을 구현해보았습니다. 객체들을 추출하고 그 관계들을 정립하는 과정에서 우리는 마틴 파울러가 말하는 모듈이 갖춰야 하는 제대로 실행되어야 하며, 변경이 용이해야 하고 이해하기 쉬어야 한다는 세가지 조건을 생각하며 구현을 수정해 나갔습니다.

이를 지키기 위해서 우린 우리의 예상에서 벗어난 객체들이 우리의 예상처럼 동작하도록 이해하기 쉽게 구조를 변경했습니다. 또한 변경하기 용이하도록 객체들을 변경했습니다. 위 과정에서 우린 다른 객체가 객체 내부의 세부사항을 함부로 접근할 수 없도록 캡슐화 작업을 통해 의존성을 제거할 수 있었죠.

결국 이 모든 작업들은 객체들을 자율적인 존재가 되도록 만들어주는 과정이었습니다. 또한 절차지향과 객체지향의 차이점도 알아보며 절차지향적인 코드가 객체지향적으로 변경되며 가져오는 장점도 살펴봤습니다. 결국 핵심은 객체에게 명확한 책임을 부여하고 그 사이에서 적절한 의존성이 배치되도록 애플리케이션을 설계하는 것이 아닌가 싶습니다.

좋은 웹페이지 즐겨찾기