오브젝트 Ch.1 — 객체, 설계
캡슐화와 의존성 관점에서 객체지향 설계를 정리하고, TicketOffice 예제를 인터페이스 분리로 직접 개선해 본다.
시리즈 · BackEnd 서적9 / 17
- SQL 레벨업 Ch.1 — DBMS 아키텍처
- SQL 레벨업 Ch.3 — SQL의 조건 분기
- SQL 레벨업 Ch.4~5 — 집약과 자르기, 반복문
- SQL 레벨업 Ch.6 — 결합
- SQL 레벨업 Ch.7 — 서브쿼리
- SQL 레벨업 Ch.8 — SQL의 순서
- SQL 레벨업 Ch.9 — 갱신과 데이터 모델
- SQL 레벨업 Ch.10 — 인덱스 사용
- 오브젝트 Ch.1 — 객체, 설계
- 오브젝트 Ch.2 — 객체지향 프로그래밍
- 오브젝트 Ch.3~4 — 역할, 책임, 협력 / 설계 품질과 트레이드오프
- 오브젝트 Ch.5 — 책임 할당하기
- 오브젝트 Ch.6 — 메시지와 인터페이스
- 오브젝트 Ch.8 — 의존성 관리하기
- 오브젝트 Ch.9 — 유연한 설계
- 오브젝트 Ch.10, 12 — 상속과 코드 재사용, 다형성
- 오브젝트 Ch.13 — 서브클래싱과 서브타이핑
「오브젝트: 코드로 이해하는 객체지향 설계」를 스터디로 읽으며 정리한 기록이다. 1장의 핵심은 두 가지로 요약된다.
- 객체지향이란 무엇인가
- 캡슐화가 왜 중요한가
모듈이 가져야 하는 세 가지 기능
- 제대로 실행돼야 한다.
- 변경이 용이해야 한다.
- 이해하기 쉬워야 한다.
프로그래밍 패러다임은 개발자 공동체가 동일한 스타일과 모델을 공유하게 함으로써 불필요한 부분에 대한 의견 충돌을 방지한다. MVC 패턴처럼 이론이 정립되어 있으면 “왜 이런 구조로 짰는가”를 두고 다툴 일이 줄어든다.
변경에 취약한 설계
- 하나의 클래스나 메서드가 너무 많은 세부사항(의존 클래스들에 대한 모든 정보)을 다룬다.
- 지나치게 세부적인 사실에 의존한다. (관람객은 가방을 들고 있다, 판매원은 매표소에서만 티켓을 판매한다)
- 객체 사이의 의존성이 과하다. 즉 결합도가 높다.
이런 설계는 변경하기 힘들다. 해결 방법은 객체 사이의 결합도를 낮추는 것이고, 그 수단이 캡슐화다.
캡슐화와 자율적인 객체
객체를 인터페이스와 구현으로 나누고 인터페이스만 공개하는 것은 결합도를 낮추고 변경하기 쉬운 코드를 만들기 위한 가장 기본적인 설계 원칙이다.
객체 내부의 상태를 캡슐화하고 객체 간에는 오직 메시지를 통해서만 상호작용하게 만든다. 한 클래스는 다른 클래스의 동작 방식을 몰라도 되고, 그 객체가 메시지에 응답할 수 있다는 사실만 알면 된다. 밀접하게 연관된 작업만 수행하고 나머지는 다른 객체에게 위임하는 객체를 응집도가 높다고 말한다.
자율적인 객체란 결합도는 낮고 응집도는 높은 객체다.
절차지향과 객체지향
- 절차지향: 모든 처리가 하나의 클래스 안에 위치하고 나머지 클래스는 데이터 역할만 수행한다.
- 객체지향: 데이터와 프로세스가 동일한 모듈 내부에 위치해, 객체가 자신의 데이터를 스스로 처리한다.
객체에 ’어떤 데이터를 넣을지’보다 ’어떤 책임을 할당할 것인가’가 중요하다.
한 클래스의 결합도를 낮추면 다른 클래스의 결합도가 높아지는 경우도 생긴다. 이때 무엇을 더 우선할지 결정하는 것이 설계다.
직접 해보기: TicketSeller가 은행에 돈을 보관하게 하려면
책에서는 “TicketSeller가 매표소가 아니라 은행에 돈을 보관하도록 만들고 싶을 때 TicketSeller 내부만 변경하면 된다”고 한다. 여기서 한 걸음 더 나아가 단계별로 개선해 보았다.
원래의 코드
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;
}
}
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가 구체 클래스인 TicketOffice에 직접 의존하므로, 보관 장소를 은행으로 바꾸려면 필드와 그 필드를 사용하는 모든 부분을 고쳐야 한다. SOLID 원칙 중 DIP와 OCP(개방-폐쇄 원칙)를 위반한 상태다.
1단계: 인터페이스와 구현 분리
생성자는 생략했다.
public interface Office {
Ticket getTicket();
void minusAmount(Long amount);
void plusAmount(Long amount);
}
public class TicketOffice implements Office {
private Long amount;
private List<Ticket> tickets = new ArrayList<>();
// getTicket, minusAmount, plusAmount 구현은 동일
// ...
}
public class TicketSeller {
private Office office = new TicketOffice();
public void sellTo(Audience audience) {
office.plusAmount(audience.buy(office.getTicket()));
}
}
이제 수정할 곳은 new TicketOffice() 한 군데로 줄었다. 하지만 구현을 바꿀 때 클라이언트인 TicketSeller의 코드를 수정해야 한다는 점에서 여전히 OCP에 위배된다.
2단계: 생성 책임 분리
어떤 구현을 쓸지 결정하는 책임을 별도 객체로 옮긴다.
// 돈을 어디에 저장할지는 극장 대표가 결정한다
public class TheaterCeo {
public Office office() {
return new TicketOffice();
}
}
public class TicketSeller {
TheaterCeo theaterCeo = new TheaterCeo();
Office office = theaterCeo.office();
public void sellTo(Audience audience) {
office.plusAmount(audience.buy(office.getTicket()));
}
}
BankOffice로 바꾸고 싶다면 Office를 구현하는 BankOffice 클래스를 만든 뒤 TicketSeller가 아닌 TheaterCeo만 수정하면 된다. 간략하게 알고 있던 의존성 주입(Dependency Injection)의 아이디어를 적용해 본 것이다. 다만 TicketSeller가 TheaterCeo를 직접 생성하고 있어 완전한 주입은 아니며, 2장 정리에서 생성자로 Office를 전달받는 형태로 다시 고친다.
결론
객체지향적 설계란 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계다. 그리고 훌륭한 객체지향 설계는 모든 객체가 자율적으로 행동하는 설계다.