무지성으로 개발만 하다가, 설계에 중요성을 느껴 살펴보게 된 책.
객체지향은 무엇일까? 객체지향적으로 설계한다는 것은 뭐지?
객체지향적인 설계는 어떤 장점을 가져다주는가 등등에 대한 해답을
이 책에서 찾아보려고 한다
프로그래밍 패러다임
프로그래밍에서 패러다임이란,
특정 시대에 수용된 프로그래밍 방법과 문제 해결 방법, 프로그래밍 스타일을 의미한다고 볼 수 있다.
어떤 패러다임을 따르느냐에 따라서 문제를 바라보는 방식과 해결하는 방식이 달라진다.
저자는 객체지향 패러다임
을 설명하고자 이 책을 집필했다고 하고 있다.
객체지향에 대한 어느 정도 유사한 그림을 각자의 머릿속에 그리고,
객체지향의 오해를 제거하는 것이 목적 !
은총알은 없다 - 프레디 브룩스
객체지향 패러다임은 은총알이 아니다.
언제라도 다른 패러다임을 적용할 수 있는 시야를 기르자 !
객체, 설계
이론이 먼저인가 실무가 먼저인가? 에 대한 질문에 답해보자.
건축처럼 역사가 오래된 분야에 비해 소프트웨어 분야는 상대적으로 역사가 짧다.
소프트웨어 설계와 유지보수를 생각해보면 여러 실무 상황에선 이를 효과적으로 해내고 있지만,
이론은 실무에 수에 비해 발전된 것이 더디다고 볼 수 있다.
그래서 설계를 할 때 가장 유용한 도구는 개념과 용어보다 코드
그 자체이다.
간단한 프로그램을 통해서 설계를 파헤쳐보자 !
티켓 판매 어플리케이션 구현
나는 극장을 운영 중인 사장이다. (일단 그렇게 상상해보자 🤔)
간단한 추첨을 통해 공연을 무료로 볼 수 있는 이벤트를 진행했고
이벤트 당첨자들은 관람 초대장을 받았다.
그리고 드디어 공연날이 되었다.
이벤트에 당첨된 관람객은 초대장을 티켓으로 교환한 후에 입장할 수 있고,
이벤트에 당첨되지 않은 관람객은 티켓을 구매해야 입장할 수 있다.
이를 코드로 구현해보자 !
초대장 클래스 Invitation
public class Invitation {
private LocalDateTime when;
}
초대장엔 공연을 관람할 수 있는 초대일자 when
을 인스턴스 변수로 가지고 있다.
티켓 클래스 Ticket
public class Ticket {
private Long fee;
public Long getFee() {
return fee;
}
}
공연을 관람하려면 티켓을 소지하고 있어야 한다.
이벤트 당첨자는 초대장으로 티켓을 교환하고, 미당첨자는 현금으로 티켓을 교환한다고 치자.
관람객이 소지품을 보관할 용도로 가방을 가지고 있다고 할 때 Bag 클래스를 추가하자.
가방 클래스 Bag
public class Bag {
private Long amount;
private Invitation invitation;
private Ticket ticket;
public Bag(long amount) {
this(null, amount);
}
public Bag(Invitation invitation, long amount) {
this.invitation = invitation;
this.amount = amount;
}
public boolean hasInvitation() {
return invitation != null;
}
public boolean hasTicket() {
return ticket != null;
}
public void setTicket(Ticket ticket) {
this.ticket = ticket;
}
public void minusAmount(Long amount) {
this.amount -= amount;
}
public void plusAmount(Long amount) {
this.amout += amount;
}
}
Bag 클래스를 설명하면 아래와 같다.
- 인스턴스 생성 시점에 현금만 보관하거나 현금, 초대장을 함께 보관하도록 강제하는 생성자
- 초대장 보유 여부를 판단하는
hasInvitation
메소드 - 티켓 소유 여부를 판단하는
hasTicket
메소드 - 현금을 증가/감소 시키는
plusAmount
/minusAmount
메소드 - 초대장을 티켓으로 교환하는
setTicket
메소드
관람객을 구현하는 Audience 클래스를 만들어보자.
관람객 클래스 Audience
public class Audience {
private Bag bag;
public Audience(Bag bag) {
this.bag = bag;
}
public Bag getBag() {
return bag;
}
}
이제 관람객을 입장시키기 위해 티켓 매표소 클래스를 만들어보자.
매표소 클래스 TicketOffice
public class TicketOffice {
private Long amount;
private List<Ticket> tickets = new ArrayList<>();
public TicketOffice(Long amount, Ticket ... tickets) {
this.amount = amount;
this.tickets.addAll(Arrays.asList(tickets));
}
public Ticket getTicket() {
return tickets.remove(0);
}
public void minusAmount(Long amount) {
this.amount -= amount;
}
public void plusAmount(Long amount) {
this.amount += amount;
}
}
매표소에서 일하는 판매원 클래스를 만들어보자.
초대장을 티켓으로 교환 or 티켓 판매하는 일을 한다.
자신이 일하는 매표소도 알고 있어야 한다.
판매원 클래스 TicketSeller
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public TicketOffice getTicketOffice() {
return ticketOffice;
}
}
극장을 구현하는 클래스만 남았다.
관람객을 맞이할 수 있도록 enter
메소드를 구현하자 !
극장 클래스 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);
}
}
}
극장은 관람객 가방 안에 초대장을 확인하고, 있으면 티켓을 가방에 넣어준다.
초대장이 없으면 티켓을 판매한다.
로직은 상당히 간단하고 잘 동작할 것이다.
하지만 이 프로그램은 문제점들이 있다 !
무엇이 문제인가
로버트 C. 마틴은 클린 소프트웨어
에서 소프트웨어 모듈이 가져야 하는 세 가지 기능에 대해 설명한다.
- 실행 중에 제대로 동작하는 것
- 변경이 용이해야 함
- 이해하기 쉬워야 함
우리가 작성한 프로그램은 정상 동작은 하지만 변경이 용이하지 않고 읽는 사람과의 의사소통이라는
목적도 만족시키기 못한다.
Theater
클래스의 enter
메소드가 수행하는 것을 말로 풀어보면
극장은 관람객의 가방을 열어 초대장이 들어 있는지 살펴본다.
가방 안에 초대장이 들어있으면 판매원은 매표소에 보관되어 있는 티켓을 관람객의 가방으로 옮긴다.
가방 안에 초대장이 들어있지 않다면 관람객의 가방에서 티켓 금액만큼의 현금을 꺼내
매표소 돈 보관소에 적립하고 매표소에 보관되어 있는 티켓을 관람객의 가방으로 옮긴다.
여기서 문제는 관람객과 판매원이 극장의 통제를 받는 수동적인 존재 라는 것이다 !
극장이 마음대로 관람객의 가방을 열어본다는 게 말이 안 되긴 하다..
판매원의 입장에서도 이상하다. 극장이 매표소 티켓과 현금에 마음대로 접근할 수 있고..
더 큰 문제는 티켓을 꺼내 관람객 가방에 넣고, 받은 돈을 매표소에 적립하는 것을 극장
이 수행한다는 점 !
예상을 빗나가는 코드
이해 가능한 코드란 동작이 우리의 예상에서 크게 벗어나지 않는 코드이다.
현실에서는 관람객이 직접 가방에서 초대장을 꺼내 판매원에게 건네거나,
돈을 직접 꺼내 판매원에게 지불한다.
판매원도 매표소에 있는 티켓을 직접 꺼내 관람객에게 건네고
받은 돈을 매표소에 보관한다.
현재 코드는 우리의 생각과 너무 다르게 동작 하기 때문에 읽는 사람과 제대로 의사소통하지 못한다 !
또 여러 세부사항을 한번에 기억하고 있어야 코드를 이해할 수 있기 때문에
코드를 이해하기가 굉장히 어렵다.
하지만 저자는 이게 가장 큰 문제는 아니라고 한다.
그렇다면 가장 큰 문제는 과연 ..?
변경에 취약한 코드
가장 큰 문제는 변경에 취약
하다는 것이다 !
- 만약 관람객이 현금이 아니라 카드로 결제한다면?
- 만약 판매원이 매표소 밖에서 티켓을 판매하게 된다면?
상상만 해도 모든 코드를 변경해야 할 생각에 머리가 어지럽다..
예를 들어 관람객이 가방을 들고 있다는 가정이 변경된다면?
Audience
클래스에서 Bag
을 제거해야 하고,
Theater
클래스의 enter
메소드 역시 수정해야 한다.
이처럼 아주 작은 사실 중 하나라도 바뀌면 해당 클래스 뿐 아니라
이 클래스에 의존해야 하는 Theater
도 함께 변경해야 한다.
이처럼 다른 클래스가 Audience
의 내부에 대해 많이 알면 알수록
Audience
를 변경하기가 어려워진다.
이것이 바로 객체 사이 dependency 와 관련된 문제 !
그럼 의존성을 완전히 없애는 것이 정답인가?
→ 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것 !
따라서 우리의 목표는 구현하는 데 필요한 최소한의 의존성만 유지하고
불필요한 의존성은 삭제 하는 것이다 !
의존성이 과한 경우를 가리켜 결합도(coupling)
가 높다고 말한다.
객체 사이의 결합도가 낮은, 변경이 용이한 설계를 만들어나가자 !
설계 개선하기
지금 문제는 우리의 직관과 코드가 다르기 때문에 이해하기 어렵고 변경이 용이하지 않다는 것 !
해결 방법은 간단하다 (고 책에서 말한다)
Theater 가 Audience 와 TicketSeller 에 관해 너무 세세한 부분까지 알지 못하도록
정보를 차단하면 된다.
관람객이 가방을 가지고 있다는 사실과,
판매원이 매표소에서 티켓을 판매한다는 사실을 Theater 가 알 필요가 없다 !
Theater 는 관람객이 극장에 입장하는 것만 원한다.
관람객과 판매원을 자율적인 존재 로 만들면 된다 !
자율성을 높이자
Audience
와 TicketSeller
가 직접 Bag
과 TicketOffice
를 처리하는
자율적인 존재가 되도록 설계를 변경하자 !
- Theater 의 enter 메소드에서 TicketOffice 에 접근하는 모든 코드를
TicketSeller 내부로 숨기자 !
변경 후 TicketSeller 클래스
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
// getter 제거
// public TicketOffice getTicketOffice() {
// return ticketOffice;
// }
// Theater 클래스의 enter 메소드 부분을 이 메소드로 옮겼다
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);
}
}
}
TicketSeller
에서 getTicketOffice
메소드가 제거됐다 !
ticketOffice
는 private
이고 getter
가 사라졌기 때문에 이제 외부에서 직접 접근은 불가능하다.
결과적으로 TicketSeller
가 ticketOffice
에서 티켓을 꺼내거나 판매요금을 적립하는 일을
스스로 수행할 수 밖에 없다
이렇게 객체 내부의 세부사항을 감추는 것이 캡슐화(encapsulation) !
이제 Theater
의 enter
메소드는 sellTo
메소드를 호출하는 간단한 코드로 바뀐다.
변경 후 Theater 클래스
public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
public void enter(Audience audience) {
ticketSeller.sellTo(audience);
}
}
수정된 Theater
클래스 는 이제 ticketOffice
에 접근하지 않고,
ticketOffice
가 TicketSeller
내부에 있다는 사실도 알지 못한다 !
인터페이스와 구현의 분리
Theater
는 TicketSeller
의 인터페이스 에만 의존한다
TicketSeller
가 TicketOffice
인스턴스를 포함하는 사실은 구현 의 영역에 속한다.
이렇게 객체를 인터페이스 와 구현 으로 나누고 인터페이스 만을 공개하는 것은
객체 사이 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는
가장 기본적인 설계 원칙 이다 !
이제 Audience
의 캡슐화를 개선하자.
Bag
인스턴스에 접근하는 객체가 Theater
에서 TicketSeller
로 바뀌었을 뿐
Audience
는 여전히 자율적인 존재가 아니다.
변경 후 Audience 클래스
public class Audience {
private Bag bag;
public Audience(Bag bag) {
this.bag = bag;
}
// getter 제거
// public Bag getBag() {
// return bag;
// }
// TicketSeller 클래스의 sellTo 메소드 부분을 이 메소드로 옮겼다
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 클래스
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public void sellTo(Audience audience) {
ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
}
}
이제 Audience
클래스에서 getBag
메소드를 제거하고,
결과적으로 Bag
의 존재를 내부로 캡슐화할 수 있게 됐다.
TicketSeller
는 Audience
의 인터페이스만 의존할 수 있도록 변경되었다.
무엇이 개선됐는가
수정된 Audience 와 TicketSeller 는 자신이 가지고 있는 소지품을 스스로 관리한다 !
→ 우리의 예상과 일치. 코드를 읽는 사람과의 의사소통 개선 !
더 중요한 점은 Audience 와 TicketSeller 의 내부 구현을 변경하더라도
Theater 를 함께 변경할 필요가 없어졌다.
→ 변경 용이성 개선 !
캡슐화와 응집도
핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것 !
밀접하게 연관된 작업만을 수행하고, 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜
→ 응집도(cohesion)
가 높다고 말함 !
자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면,
결합도를 낮추고 응집도를 높일 수 있다 !
객체의 응집도를 높이기 위해서는 객체 스스로 자신의 데이터를 책임져야 한다.
객체는 자신의 데이터를 스스로 처리하는 자율적인 존재 !
외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하도록 하자.
절차지향과 객체지향
수정하기 전 코드는 Bag 과 TicketOffice 를 가져와 극장 입장 절차를 Theater 안에서 구현했다.
이 관점에서 Theater 의 enter 메소드는 Process,
Audience, TicketSeller, Bag, TicketOffice 는 Data 이다.
이렇게 Process 와 Data 를 별도의 모듈에 위치시키는 방식을
절차적 프로그래밍 (Procedural Programming) 이라고 한다 !
절차적 프로그래밍은 우리의 직관에 위배된다.
관람객과 판매원이 그저 수동적인 존재이기 때문에 우리의 예상과 다르고,
코드를 읽는 사람과 원활하게 의사소통할 수 없다.
더 큰 문제는 데이터가 변경되면 코드도 함께 변경되어야 한다.
변경은 버그 가능성이 생기게 되고, 버그 가능성은 코드 손대기 무섭게 만듬..
변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계 !
절차적 프로그래밍은 Process 가 필요한 모든 Data 에 의존. 변경에 취약하다.
수정한 후 코드는 Data 와 Process 가 동일한 모듈 내부에 위치하도록 하는
객체지향 프로그래밍 (Object-Oriented Programming) 이다 !
훌륭한 객체지향 설계는 캡슐화를 이용해 의존성을 적절히 관리,
객체 사이의 결합도를 낮추는 것이다 !
이래서 절차지향보다 객체지향이 변경에 유연한 것 !
책임의 이동
절차지향과 객체지향 사이 근본적인 차이를 만드는 것은
책임의 이동 (shift of responsibility) 이다.
책임 : 기능 을 객체지향 관점에서 바라본 용어라고 생각하자
객체지향 설계에서는 제어 흐름이 각 객체에 분산,
즉 하나의 기능을 완성하는 데 필요한 책임이 여러 객체에 걸쳐 분산되어 있는 것 !
한 곳에 몰려있던 책임을 각 객체로 이동시켜 객체가 스스로 책임 지도록 하자 !
더 개선해보자
이제 Audience
클래스를 다시 확인해보자.
Bag 클래스는 여전히 수동적인 존재인 것을 눈치챘다면 챕터1 내용 좀 이해한 듯 ?
Bag
을 자율적인 존재로 바꿔보자.
Bag
의 내부 상태에 접근하는 로직을 Bag
안으로 캡슐화해서 결합도를 낮추자.
변경 후 Bag 클래스
public class Bag {
private Long amount;
private Invitation invitation;
private Ticket ticket;
public Long hold(Ticket ticket) {
if (hasInvitation()) {
setTicket(ticket);
return 0L;
} else {
setTicket(ticket);
minusAmount(ticket.getFee());
return ticket.getFee();
}
}
private boolean hasInvitation() {
return invitation != null;
}
private void setTicket(Ticket ticket) {
this.ticket = ticket;
}
private void minusAmount(Long amount) {
this.amount -= amount;
}
}
Bag
에 hold
메소드를 추가했다.
public
메소드였던 hasInvitation
, minusAmount
, setTicket
들은 내부에서만 사용되기 때문에
private
으로 변경하였다.
변경 후 Audience 클래스
public class Audience {
public Long buy(Ticket ticket) {
return bag.hold(ticket);
}
}
Bag
의 구현을 캡슐화시켰으니 Audience
를 Bag
의 구현이 아닌 인터페이스에만 의존하도록 수정하였다.
Trade-Off
TicketSeller
역시 TicketOffice
에 있는 Ticket
을 마음대로 꺼내서 Audience
에게 팔고
Audience
에게 받은 돈을 마음대로 TicketOffice
에 넣고 있다.
TicketOffice
의 자율권을 찾아주자 !
변경 후 TicketOffice 클래스
public class TicketOffice {
public void sellTicketTo(Audience audience) {
plusAmount(audience.buy(getTicket()));
}
private Ticket getTicket() {
return tickets.remove(0);
}
private void plusAmount(Long amount) {
this.amount += amount;
}
}
변경 후 TicketSeller 클래스
public class TicketSeller {
public void sellTo(Audience audience) {
ticketOffice.sellTicketTo(audience);
}
}
완벽한가 ? (처음엔 그런 줄 알았다..)
하지만 이렇게 하면 TicketOffice
와 Audience
사이에 의존성이 추가된다.
변경 전에는 TicketOffice
가 Audience
에 대해 알지 못했었지만
변경 후에는 TicketOffice
가 Audience
에게 직접 티켓을 판매하므로 Audience
에 대해
알게 되었다.
TicketOffice
의 자율성은 높였지만 전체 설계 관점에서는 결합도가 상승하였다..
여기서 생각해봐야 할 것들이 있다.
- 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다.
- 동일한 기능을 여러 방법으로 설계할 수 있다? → 결국 Trade-Off
완벽한 설계란 불가능할 수 있다.
훌륭한 설계는 적절한 Trade-Off 의 결과물 !
의인화 anthropomorphism
Audience 와 TicketSeller 같은 경우 스스로 자신의 일을 처리하는 자율적인 존재라는 것이
우리의 직관과 일치한다.
하지만 Theater 나 Bag, TicketOffice 같은 경우 무생물이기 때문에
실세계에서는 자율적인 존재가 아니다.
객체지향의 세계에서 모든 것을 자율적인 존재로 바뀌는 것. 이것을 의인화 라고 부른다.
설계란 코드를 배치하는 것
설계는 코드 작성의 일부이고 코드를 작성하지 않고서는 검증할 수 없다 !
우리는 오늘 구현하는 코드를 짜야 하며, 내일 쉽게 변경할 수 있는 코드를 짜야 한다.
항상 요구사항은 변경되고, 그에 따라 코드가 변경되면 버그는 따라온다.
버그 가능성이 생기면 코드 수정의 의지가 꺾인다.. (무서워 🫠)
객체지향적 설계를 통해 코드 변경에 안정감을 느껴보자.
정리
- 무지성 Getter 로 데이터를 가져와서 처리하지말자 (절차지향적 관점이라면 얘기가 다르다 🤔)
- 인터페이스와 구현부를 나누어 생각하자 (객체간 메시지를 던져라)
- 결합도를 낮추고 응집력을 높이자 (불필요한 의존성을 제거 🚀)
- Data 와 Process 를 한 객체에 위치시키자 (단순히 이동이 아니라 책임을 적절히 할당 !)
- 책임이 한 곳에 몰려있다면 “책임을 이동” 시키자
- 나의 직관을 믿자 (나도 이해 못하면 상대방도 이해 못한다 🫠)