Table of contents
Open Table of contents
결합도 줄이기
높은 결합도는 변경의 적이다. 결합도가 높으면 이리저리 연결되어 있어서 여러 가지를 동시에 바꿔야 하기 때문이다.
결합은 추이적(transitive)이다. A가 B에 결합되어 있고 B가 C에 결합되어 있으면 A는 C에 결합되어 있다.
그러니 다음의 간단한 원칙을 따라야 한다.
결합도가 낮은 코드가 바꾸기 쉽다.
열차 사고
fun applyDiscount(customer: Customer, orderId: Long, discount: Double) {
val totals = customer.orders.find { it.id == orderId }.totals
totals.grandTotal = totals.grandTotal * (1 - discount)
totals.discount = discount
}
이 코드는 고객에서 합계까지 다섯 단계의 추상화를 오간다. 결국 최상위 코드가 모든 것을 알아야 한다.
고객 객체는 주문을 알아야 하고 주문은 합계를 알아야 한다. 이런 식으로 계속해서 추상화가 이어지면 결합도가 높아진다.
이 코드는 열차 사고를 보여준다. 열차 사고란 객체가 다른 객체의 메서드를 호출하고 그 객체가 다시 다른 객체의 메서드를 호출하는 식으로 계속 이어지는 코드를 말한다.
이것은 책임의 문제라고 볼 수도 있다. 당연히 tatals 객체가 합계를 관리하는 책임을 져야 한다.
묻지 말고 말하라.
이 원칙은 다른 객체의 내부 상태에 따라 판단을 내리고 그 객체를 갱신해서는 안 된다는 것이다. 객체의 내부 상태를 묻는 것으로 인하여 캡슐화의 장점은 완전히 사라지고 또 그 과정에서 구현에 대한 지식이 코드 여기저기로 퍼져 버린다. 따라서 열차 사고를 고치는 첫 발짝은 할인 처리를 totals 객체로 옮기는 것이다.
fun applyDiscount(customer: Customer, orderId: Long, discount: Double) {
customer.orders.find { it.id == orderId }.applyDiscount(discount)
}
데메테르 법칙(Law of Demeter, LoD)
사람들이 결합도에 대한 이야기를 할 때 데메테르 법칙이라는 것은 언급하는 경우가 많다.
LoD는 어떤 클래스 C에 정의된 메서드가 다음 목록에 속하는 것만 사용할 수 있다고 제한한다.
- C의 다른 인스턴스 메서드
- 메서드의 매개 변수
- 스택이나 힙에 자신이 생성하는 객체의 메서드
- 전역 변수
글로벌화의 해악
어디서나 접근할 수 있는 데이터는 교묘하게 애플리케이션 컴포넌트 간의 결합을 만들어 낸다.
전역(global) 데이터 하나하나는 애플리케이션의 모든 메서드에 갑자기 매개 변수가 추가된 것과 같은 효과를 낸다.
싱글턴도 전역 데이터다.
외부 리소스도 전역 데이터다.
데이터베이스나 저장소, 파일 시스템, 서비스 API 등을 사용한다면 전역 데이터의 함정에 빠질 위험이 있는 것이다.
전역적이어야 할 만큼 중요하다면 API로 감싸라.
상속은 결합을 늘린다.
상속으로 다른 클래스의 상태와 행동을 그대로 가져올 수 있다.
상속을 잘못 사용하는 문제는 너무 중요해서 별도로 항목을 만들었다.
이벤트
이벤트는 무언가 정보가 있다는 것을 의미한다. 정보는 사용자가 버튼을 클릭하거나, 주가 정보가 갱신될 때처럼 외부에서 올 수 있다.
어떻게 이벤트에 잘 반응하는 애플리케이션을 만들 수 있을까? 아무 전략이 없다면 우리는 금방 혼란에 빠질 것이고, 우리의 애플리케이션은 서로 긴밀하게 얽힌 엉망진창 코드 덩어리가 되고 말 것이다.
유한 상태 기계 (finite state machine, FSM)
기본적으로 상태 기계는 이벤트를 어떻게 처리할지 정의한 명세일 뿐이다.
정해진 상태들이 있고 그중 하나가 ‘현재 상태’다. 상태마다 그 상태일 때 의미가 있는 이벤트들을 나열하고, 이벤트별로 시스템의 다음 ‘현재 상태’를 정의한다.
감시자 패턴 (observer pattern)
감시자 패턴은 이벤트를 발생시키는 쪽인 감시대상(observable)과 이런 이벤트에 관심 있는 클라이언트인 감시자로 이루어진다.
감시자는 자신이 관심 있는 이벤트를 감시 대상에 등록한다. 보통은 호출될 함수의 참조도 등록할 때 함께 넘긴다.
나중에 해당 이벤트가 발생하면 감시 대상은 등록된 감시자 목록을 보면서 함수들을 일일이 호출한다.
하지만 감시자 패턴에는 문제가 하나 있다. 모든 감시자가 감시 대상에 등록을 해야하기 떄문에 결합이 생긴다.
더군다나 일반적으로 감시 대상이 콜백을 직접 호출하도록 구현하기 때문에 이 부분이 성능 병목이 될 수 있다.
동기적 처리의 특성상 콜백 실행이 끝날 때까지 감시 대상이 계속 기다려야하기 때문이다.
이 문제는 다음 전략인 ‘게시-구독’으로 해결한다.
게시-구독 (publish-subscribe)
게시 구독 혹은 발행 구독 모델은 줄여서 펍섭(pubsub)이라고도 부르며 감시자 패턴을 일반화한 것이다.
동시에 감시자 모델의 결합도를 높이는 문제와 성능 문제도 해결한다.
게시-구독 모델에는 게시자와 구독자가 있고, 이들은 채널로 연결된다.
채널은 별도의 코드로 구현되는데, 라이브러리인 경우도 있고 프로세스 혹은 분산 인프라인 경우도 있다.
각 채널에는 일므이 있다. 구독자는 관심있는 관심사를 하나 이상의 채널에 등록하고, 게시자는 채널에 이벤트를 보낸다.
반응형 프로그래밍과 스트림 그리고 이벤트
이벤트를 사용하여 코드가 반응하도록 할 수 있다는 것은 명백하다. 하지만 이벤트를 이리저리 연결하는 것도 쉽지만은 않다. 그래서 스트림(stream)이 필요하다.
스트림은 이벤트를 일반적인 자료 구조처럼 다룰 수 있게 해준다.
이벤트 스트림은 동기적 처리와 비동기적 처리를 하나의 편리한 공통 API로 감싸서 통합한다.
변환 프로그래밍
모든 프로그램은 데이터를 변환한다. 프로그램이란 입력을 출력으로 바꾸는 것이라는 사고방식으로 돌아가면 그동안 고민하던 많은 세부 사항이 모두 사라진다.
구조는 명확해지고 더 일관적으로 오류를 처리하게 되어 결합도 대폭 줄어들 것이다.
프로그래밍은 코드에 관한 것이디만, 프로그램은 데이터에 관한 것이다.
객체 지향 프로그래밍 경험이 많다면 반사적으로 데이터를 숨기고, 객체 안을 캡슐화해야 한다고 느낄 것이다.
이런 객체들은 서로 이리저리 이야기하며 서로의 상태를 변경한다.
상태를 쌓아 놓지 말고 전달하라.
변환 모델에서는 이런 사고를 근본적으로 뒤엎는다. 데이터를 전체 시스템 여기저기의 작은 웅덩이에 흩어 놓는 대신, 데이터를 거대한 강으로 흐름으로 생각하라.
파이프라인은 코드 -> 데이터 -> 코드 -> 데이터 .. 의 연속이다.
오류처리는 어떻게 하나?
여러 가지 방법이 있지만 공통으로 사용하는 기본적인 관례가 하나 있다.
바로 변환 사이에 값을 절대 날것으로 넘기지 않는 것이다. 대신 래퍼 역할을 하는 자료구조나 타입으로 값을 싸서 넘긴다.
이런 자료구조나 타입은 안에 들어있는 값이 유효한지를 추가로 알려 준다.
상속세
우리가 맞닥트린 객체 지향 개발자 세대는 다음 둘 중 하나의 이유로 상속을 사용한다. 타입이 싫어서 아니면 타입이 좋아서.
타입을 싫어하는 이들은 입력하는 글자 수를 줄이기 위해 상속을 쓴다. 상속으로 공통 기능을 기반 클래스에서 자식 클래스로 넘기는 것이다.
코드를 공유하기 위해 상속을 쓸 떄의 문제
상속도 일종의 결합이다. 자식 클래스를 사용하는 코드도 이 클래스의 모든 조상과 얽히게 된다.
타입을 정의하기 위해 상속을 쓸 때의 문제
어떤 이들은 상속을 새로운 타입을 정의하는 방법이라고 여긴다. 이들이 설계할 때 가장 좋아하는 글미은 클래스 계층도다.
안타깝게도 클래스 사이의 아주 작은 미묘한 차이까지 잡아내서 표현하기 위해 계층 위에 계층을 덧붙이다 보면, 클래스 계층도는 순식간에 벽면 전체를 덮는 괴물로 자라난다.
이런 복잡도는 애플리케이션을 더 취약하게 만든다. 변경 사항이 위나 아래로 여러 단계에 걸쳐 영향을 미칠 수 있기 때문이다.
더 나은 대안
인터페이스와 프로토콜
대부부분의 객체 지향 언어는 클래스가 특정한 동작을 구현한다고 지정할 수 있다.
문법은 언어마다 상이한데, 자바는 인터페이스라고 부른다. 프로토콜이라고 부르는 언어도 있고 트레이트라고 부르는 언어도 있다.
인터페이스는 상속 없이도 다형성을 가져다 준다.
위임
상속은 개발자들이 점점 더 메서드가 많은 클래스를 만들도록 유도한다.
부모 클래스에 메서드가 20개 있으면 하위 클래스는 그중 딱 두 개만 사용하고 싶더라도 필요 없는 18개의 메서드까지 함께 따라와서 자리를 차지하고 호출되기만을 기다린다.
Has-A가 Is-A보다 낫다.
믹스인, 트레이트, 카테고리, 프로토콜 확장 등
기본 발상은 단순하다. 클래스나 객체에 상속을 사용하지 않고 새로운 기능을 추가하여 확장하고 싶다. 그래서 일련의 함수를 만들고 여기에 이름을 묻인 다음, 이것으로 어떻게든 클래스나 객체를 확장한다.
이 시점에서 기존 클래스와 그에 덧붙인느 믹스인의 동작을 모두 합한 새로운 클래스나 객체를 만든 것이다.
우리는 믹스인을 사용하여 각 상황에 맞는 전문화된 클래스는 만드는 것이 더 낫다고 생각한다.
class AccountForCustomer extends Account with AccountValidation, AccountCustomerValidation
class AccountForAdmin extends Account with AccountValidation, AccountAdminValidation
여기서 두 파생 클래스 모두 계정 객체에 공통으로 적용해야할 검증을 포함하고 있다.