Java와 같은 언어는 객체 지향 패러다임을 지향한다고 알려져있다. 패러다임이란 무엇일까? 더 나아가 소프트웨어에서 흔히 말하는 패러다임에 대해 알아보자.
패러다임이란 한 시대의 사회 전체가 공유하는 이론이나 방법, 문제 의식 체계이다.
이러한 패러다임의 공존이 가능하다. C++의 경우 절차지향, 객체지향 모두 지원을 하며 스칼라의 경우 함수형 프로그래밍, 객체지향을 모두 지원한다. 이러한 언어들을 다중 패러다임 언어라 한다.
참고
Java의 경우도 함수형 프로그래밍을 위한 문법(람다식, Stream 문법)등을 지원한다.
많은 분야에서 이론을 기반으로 하여 이를 실무에 적용하기 위해 노력한다. 프로그래밍에선 어떨까?
프로그래밍의 역사는 다른 분야에 비해 기간이 매우 짧다. 이 기간에 이론이 수립되는건 불가능하다. 실무에서 써보고 괜찮다고 판단되는 것이 이론으로 발전하기 부지수다. 즉, 실무를 관찰한 결과가 이론으로 정립된다.
따라서, 소프트웨어 설계와 유지보수에 중점을 두기 위해선 실무에 초점을 두자. 추상적 개념과 이론은 실무에서 훌륭한 코드를 작성하는데 필요한 도구임을 기억하자.
객체지향을 처음 배울 때 객체(Object)라는 개념부터 배운다. 실세계의 모든 것은 이 객체로 이루어져 있고 이들끼리 상호작용하면서 작동을 한다.
소프트웨어 설계시에도 실세계를 기반으로 모델링하여 객체마다 균등하게 역할을 분배한다. 이를 객체지향적 설계라 한다. 이를 통해 객체마다 자율성이 부여되고 절차지향과 다르게 한 객체로 책임이 몰리는 것을 방지할 수 있다.
물론, 실세계에서 모든 객체에 자율성이 있는 것은 아니다. 가방, 극장과 같은 객체는 실세계에서 생명이 없다. 그러나, 객체지향 세계에선 이들을 모두 생명과 역할을 가지는 존재로 간주하는데 이를 의인화(anthoropomorphism)이라 한다.
e.g.) 관람객(
Audience
)이 자신의 가방(Bag
)에서 티켓(Ticket
)을 꺼낸다.
- 관람객이 가방을 참조 (X)
- 가방이 티켓을 가져오도록 한다. (O)
가방이라는 객체에 생명을 부여하여 특정 행동을 하도록 자율성을 부여 -> 의인화
즉, 실세계에서는 생명이 없는 수동적인 존재라 할지라도 객체지향 세계에서 그들은 생명과 지능을 가지는 객체가 된다.
대부분 처음 객체지향을 배운다면 항상 드는 생각이 있다. 왜 이렇게 만들어야되지? 더 복잡한데..
모든 로직은 단순히 작동된다고 끝이 아니다. 요구사항 변경에 따른 무수히 많은 수정 사항이 생긴다. 이러한 변경사항 마다 유연하게 대처할 수 있어야 하는데 객체지향 설계는 사실상 이를 위해 존재하는 패러다임이라 봐도 무방하다.
이를 자세히 이해하기 위해 소프트웨어 모듈의 3가지 목적을 살펴보자.
모듈
- 프로그램을 구성하는 구성 요소
- 관련된 데이터나 함수를 하나로 묶은 단위
모든 소프트웨어 모듈은 아래 3가지 목적이 존재한다. 이들 중 하나라도 만족하지 못하면 변경에 유연하지 않은 코드가 된다.
정상적인 동작
당연하겠지만 모든 모듈은 요구사항을 만족하면서 정상적으로 동작해야 한다.
변경을 위한 존재
대부분 모듈은 생명주기 동안 변경되므로 간단한 작업으로 변경이 가능해야 한다. 따라서, 변경이 어려운 모듈은 제대로 동작하더라도 반드시 개선해야 한다.
코드를 읽은 사람과 의사소통
특별한 과정(e.g. 주석)없이 다른 개발자가 쉽게 읽고 이해할 수 있어야 한다. 이를 위해선 코드의 동작이 예상과 크게 벗어나지 않아야 한다.
즉, 모든 소프트웨어 모듈은 제대로 실행 + 변경에 용이 + 이해하기 쉬움 이 3가지를 만족해야한다.
Theater.java
public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
public void enter(Audience audience) {
if (audience.getBag().hasInvitation()) {
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().setTicket(ticket);
} else {
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().minusAmount(ticket.getFee());
ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
audience.getBag().setTicket(ticket);
}
}
}
극장(Theater
)이 관람객의 가방을 직접 열어(audience.getBag()
) 그 안의 초대장이 있는지 확인(hasInvication()
)한다.
초대장이 있다면, 판매원은 매표소에서 티켓을 가져와(ticketSeller.getTicketOffice().getTicket()
) 관람객의 가방으로 옮긴다.(audience.getBag().setTicket()
)
초대장이 없다면, 관람객의 가방에서 티켓 금액만큼 현금을 꺼내(audience.getBag().minusAmount(ticket.getFee())
) 매표소에 적립(ticketSeller.getTicketOffice().plusAmount(ticket.getFee())
) 후 매표소에 있는 티켓을 가방으로 옮긴다.(audience.getBag().setTicket(ticket)
)
이는 동작에 전혀 문제는 없다. 그러나, 이해하기 쉬운가? 에 대해선 그렇다 라고 쉽사리 답하긴 힘들다.
앞서 언급한 소프트웨어 모듈의 목적성을 고려하여 문제점을 살펴보자.
일단, 관람객(Audience
)와 판매원(TicketSeller
)가 지나치게 수동적이다. 이들은 모두 극장(Theater
)의 통제를 받는다.
실세계를 생각해본다면 이는 매우 어색하다. 이들은 모두 우리 상식에서 벗어나는 행동을 보인다. 이게 바로 예상을 벗어나는 포인트이다.
또한, 코드의 전체 동작을 이해하기 위해선 이와 관련된 모든 객체들의 세부적인 내용(객체의 의존관계)까지 알아야 한다. 이는 다른 개발자가 코드를 이해하기 어렵게 한다. 하나의 클래스(여기서는 Theater
)에 책임이 가중되었기 때문이다.
이렇게 책임이 가중되있으면 변경에 매우 취약함을 알 수 있다.
e.g.) Audience
변경 시 Theater
도 변경해야함
만약, 관람객의 결제 수단이 추가된다면 어떨까? 판매원이 매표소 밖에서 티켓을 판매한다면? 관람객에게 가방이 없다면??
절차지향적 코드에선 이 질문에 답하긴 쉽지 않을 것이다. 위와 같은 상황을 당연시하고 설계했기 때문이다. 설계를 할때는 이런 질문들 또한 항상 염두해두어야 한다.
지금은 Theater
가 지나치게 세부적인 사실들에 의존하고 있다고 볼 수 있다.
e.g.) 관람객이 가방을 보유, 가방에서 티켓 또는 현금을 꺼냄
이는 당연하게도 다른 객체의 사소한 변경에도 영향을 받을 수 밖에 없는 구조다.
이렇게 다른 클래스의 내부에 대해 더 많이 알수록 변경에 어려워진다. 이를 의존성이 높다라고 한다. 기능 구현에 필요한 최소한의 의존성만 유지하고 이외에 불필요한 의존성은 없애자.
궁극적인 설계의 목표는 객체 사이의 결합도를 낮춰 변경에 용이한 설계를 만드는 것이다. 이를 위해선 아래와 같은 것이 필요하다 생각한다.
코드에서 이해하기 어려운 부분을 자세히 확인해보자.
여기서 핵심은 다른 객체를 너무 자세히 알 필요가 없다는 것이다. 캡슐화를 통해 이를 해결할 수 있다. 다른 객체에게 메시지를 어떤식으로 요청보내면 어떤식으로 받을 수 있는지 정도만 알면되고 동작 원리는 감춘다.
또한, 수동적인 객체들에게도 책임을 부여하여 자율적인 존재로 만들면 된다. 기존 코드에 책임이 집중되었던 객체의 메서드 일부를 다른 객체로 이전할 수 있다.
이렇게 한다면 외부에서 특정 객체를 호출(getter)하는 일이 없어진다. 또한, 캡슐화를 구현함으로써 변경하기 쉬운 객체를 만들 수 있다.
참고
- 테코블에서 설명하는 디미터 법칙을 지향해야 하는 이유가 위와 같다.
Theater.java
public class Theater {
private TicketSeller ticketSeller;
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
public void enter(Audience audience) {
ticketSeller.sellTo(audience);
}
}
enter()
메서드를 주목해보자. 극장(Theater
) 입장에선 판매원이 어떤식으로 행동하는지 알 수 없다. 코드 입장에선 TicketSeller
라는 인터페이스에 의존하고 있다. sellTo()
의 내부 동작 방식 같은 구현은 알지 못한다.
극장입장에선 판매원이 관람객에게 티켓을 판매하는 것만 알면되고 어떻게 판매하는지는 몰라도 된다. 단순히 팔아라 라는 메시지를 보내면 팔았어 라는 응답만 받으면 되는것이다.
이처럼 밀접하게 연관된 작업만 수행하고 연관성 없는 작업은 다른 객체에 위임함으로써 응집도가 높아질 수 있다. 이를 위해선 객체 스스로 자신의 데이터에 대한 책임을 져야한다. 객체는 자신의 데이터를 스스로 처리하는 자율적인 존재라는 것을 꼭 기억하자.
한 객체에 자율성을 부여함으로써 기존엔 없던 새로운 의존관계가 추가될 수 있다. 여기서 선택의 기로에 서게된다. 객체의 자율성과 의존관계 중 어떤걸 우선시할지... 아래 코드 리펙토링 과정을 살펴보자.
기존 Audience.java
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();
}
}
}
기존 Bag.java
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.amount += amount;
}
}
기존 TicketOffice.java
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;
}
}
기존 TicketSeller.java
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public void sellTo(Audience audience) {
ticketOffice.plusAmount(audience.buy(ticketOffice.getTicket()));
}
}
TicketSeller
가 Audience
를 의존하고 있고 TicketOffice
와 Audience
의 의존관계는 없다. 그러나, Bag
이 Audience
에 의해 통제되는 수동적인 객체다.
만약, Audience
에 있는 책임 일부를 Bag
에 위임하면 어떻게 될까? 객체의 자율성이 생기므로 더 좋은 설계가 될 것이다!
변경된 Audience.java
public class Audience {
private Bag bag;
public Audience(Bag bag) {
this.bag = bag;
}
public Long buy(Ticket ticket) {
return bag.hold(ticket);
}
}
변경된 Bag.java
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;
}
}
변경된 TicketOffice.java
public class TicketOffice {
private Long amount;
private List<Ticket> tickets = new ArrayList<>();
public TicketOffice(Long amount, Ticket... tickets) {
this.amount = amount;
this.tickets.addAll(Arrays.asList(tickets));
}
public void sellTicketTo(Audience audience) {
plusAmount(audience.buy(getTicket()));
}
private Ticket getTicket() {
return tickets.remove(0);
}
private void plusAmount(Long amount) {
this.amount += amount;
}
}
변경된 TicketSeller.java
public class TicketSeller {
private TicketOffice ticketOffice;
public TicketSeller(TicketOffice ticketOffice) {
this.ticketOffice = ticketOffice;
}
public void sellTo(Audience audience) {
ticketOffice.sellTicketTo(audience);
}
}
Bag
객체에 자율성을 부여하니 비지니스 로직을 수행하는 계층이 더 깊어져 Audience
와 TicketOffice
와의 의존성이 추가되었다. 이게 과연 좋은 리펙토링일까?
결론부터 말하자면 명확한 답은 없다. 이는 팀원들끼리 적절한 협의가 필요하다. 모든 것을 만족하는 설계는 없다는것을 기억하자. 이러한 트레이드 오프속에서 적절한 선택의 결과가 곧 훌륭한 선택이다.
객체지향적인 설계는 실세계를 기반으로 모델링을 하여 모든 것에 능동성과 자율성을 부여하여 추후 유지보수나 확장에 유연하게 대응할 수 있게하는 방법이다. 실세계에서 자율적인 존재가 아니더라도 그들은 생명과 지능을 가진 존재가 된다. 이를 통해 실세계와 비슷하게(우리 상식과 마찬가지로) 동작하는 것을 보장하여 코드의 이해력을 높여준다.
이렇게 설계한 객체들끼리 메시지를 주고받을 수 있을 정도로 의존관계를 설정(메시지의 구체적인 구현은 몰라도 된다)하고 적절한 트레이트 오프 속에서 최선의 선택을 하는게 개발자의 역할이라 생각한다. 객체는 메시지에 답변만 줄 수 있으면 된다. 어떤식으로 답변을 만들어야 하는지, 뭐가 필요한지는 알 필요가 없다.
앞서 언급한 소프트웨어 모듈의 목적성은 곧 좋은 설계가 되기 위한 조건이라고 생각한다. 이 3가지를 다시 정리하면 아래와 같다.