객체지향 프로그래밍은 클래스가 아닌 객체에 초점을 둬야한다. 더 정확히 말하면 협력 안에서 객체가 수행하는 책임에 초점을 두자.
클래스는 개발자가 직접 다룰 수 있는 실제적이면서 구체적인 도구일 뿐이다. 이에 너무 집착하면 유연하지 못한 설계가 된다.
책임은 곧 객체가 수신할 수 있는 메시지의 기반이 된다. 따라서, 객체들이 주고 받는 메시지는 객체지향 설계에서 매우 중요한 자료라 볼 수 있다. 이러한 메시지들은 퍼블릭 인터페이스로 정의된다.
결론은 훌륭한 퍼블릭 인터페이스를 얻어야 좋은 설계가 된다는 것이다. 이를 얻기 위해선 책임 주도 설계를 따르는 것 외에 다양한 원칙들도 준수해야 한다. 유연하고 재사용성이 높은 퍼블릭 인터페이스를 얻기 위한 원칙과 기법들을 살펴보자.
참고
어플리케이션은 클래스로 구성되고, 메시지를 통해 정의된다.
어떤 객체가 다른 객체에게 요청을 하는 순간 협력이 시작된다. 요청하는 유일한 방법은 메시지를 전송하는 것이다.
객체는 자신의 희망을 메시지라는 형태로 전송하며 이를 수신한 객체는 요청을 적절히 처리 후 응답하는 방식이다. 메시지를 기반으로 두 객체 사이의 협력을 구성한다.
클라이언트-서버 구조는 클라이언트가 서버에 요청을 보내고 적절한 응답을 돌려받는 구조이다. 앞서 언급한 구조도 이에 해당한다 볼 수 있다.
협력을 하면서 요청을 보내는 객체는 클라이언트가, 이를 수신하는 객체는 서버가 된다. 물론, 수신하면서 제 3의 객체가 필요하다면 서버는 또 다른 클라이언트가 될 수 있다.
Movie
는 Sreening
입장에선 서버이지만, DiscountPolicy
입장에선 클라이언트이다.앞서 언급했듯 메시지는 객체끼리 협력을 위해 사용할 수 있는 유일한 소통 수단이다. 이는 크게 3가지로 구성된다.
e.g. 메시지 : `isSatisfied(screening)
isSatisfied
screening
condition
참고
언어별 메시지 전송 표기법
메서드란 실제로 실행되는 함수 또는 프로시저이다. 동일한 메시지를 전송하더라도 객체의 타입에 따라 실행되는 메서드는 달라질 수 있다. 대표적으로 다형성을 활용하여 컴파일 시점 의존성과 실행 시점 의존성이 달라지는 경우가 이에 해당한다.
클라이언트가 서버로 메시지를 보낼 때 이를 응답할 수 있는 객체가 존재하고 그 객체가 적절한 메서드를 선택하여 응답한다.
이러한 메시지와 메서드의 구분을 통해 전송자와 수신자가 느슨하게 결합됨으로써 캡슐화를 강하게 하고 수신자는 요청을 처리할 메서드를 스스로 선택한다는 점에서 자율적인 객체가 된다. 따라서, 결합도가 낮고 확장성이 높은 코드를 얻을 수 있다.
퍼블릭 인터페이스란 객체가 의사소통을 위해 외부에 공개하는 메시지 집합이다. 이는 객체의 품질을 결정하므로 이를 구성하는 메시지들이 곧 객체의 품질을 결정한다 볼 수 있다.
오퍼레이션이란 프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 뜻한다. 이러한 오퍼레이션이 호출됐을 때 실제로 실행되는 코드를 메서드라 한다.
참고
Java에서 오퍼레이션, 메서드의 역할은 아래와 같다 생각한다.
- 오퍼레이션 : 추상 메서드(abstract method)
- 메서드 : 구현 메서드
다형성은 하나의 오퍼레이션에 여러 개의 메서드를 구현하여 클라이언트 입장에서 하나의 오퍼레이션이 여러 동작을 하는 것 처럼 보여지게 한다.
협력 시 클라이언트는 어떤 메서드가 실행되는지 알지 못하므로 퍼블릭 인터페이스의 오퍼레이션을 호출한다는 용어가 더 적절하다.
시그니처란 오퍼레이션(혹은 메서드)의 이름과 파라미터 목록들을 말한다. 쉽게 말해 오퍼레이션은 시그니처만 정의한 것이라 볼 수 있으며, 메서드는 시그니처 + 실행 코드이다.
좋은 인터페이스는 최소한의 인터페이스와 추상적인 인터페이스이라는 조건을 만족해야 한다.
이에 적합한 설계 방안은 책임 주도 설계이다. 이는 아래와 같은 이유로 좋은 인터페이스를 만들 수 있게 한다.
물론 책임 주도 설계를 통해 만들어진 인터페이스가 다 좋다고 볼 수 없다. 이 외에 좋은 인터페이스를 만들 수 있는 원칙과 기법에 대해 살펴보자.
Don't talk to strangers. Only talk to your immediate neighbor.
Java나 C#에서 하나의 도트(.
)만을 이용해 메시지를 전송하라. 라는 말로 요약할 수 있다. 이는 객체 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하는 방법이다.
screening.getMovie().getDiscountConditions();
객체는 자기 자신을 책임지는 자율적인 존재여야 한다. 그러나, 여러 도트(.
)를 통해 로직을 구현하게 된다면 다른 객체의 내부 구조를 다 알게되어 캡슐화가 무너지며 변경의 여파가 외부로 전파되게 된다.
참고
이러한 구조로 인해 변경의 여파가 외부로 전달되는 현상을 기차 충돌(train wreck)이라 한다.
이는 구현부가 외부에 노출되므로 인터페이스 구현/분리 법칙을 위반하게 된다. 따라서, 디미터 법칙을 준수하여 외부에 자신의 상태를 노출하지 않도록 해야한다. 이렇듯 불필요한 어떤 것도 다른 객체에게 보여주지 않으며, 다른 객체의 구현에 의존하지 않는 코드를 부끄럼타는 코드(shy code)라 한다.
상태를 묻는 오퍼레이션을 행동을 요청하는 오퍼레이션으로 대체하자. 로 대체할 수 있다.
return bag.getInvication() == null; // (x)
return bag.hasInvication(); // (o)
객체 지향은 함께 변경될 가능성이 높은 정보와 행동을 하나의 단위로 통합해야 한다. 따라서, 객체 정보를 이용하는(묻는) 행동은 내부에 위치시켜야 정보와 행동을 하나의 클래스에 둘 수 있다.
또한, 객체의 상태를 외부에서 묻는 것은 캡슐화를 위반한다. 이는 낮은 응집도를 갖는 코드를 낳는다.
디미터 법칙과도 연관된다. 상태를 묻는 오퍼레이션을 통해(e.g. getter) 추가 로직을 이어나가면 디미터 법칙을 위반하기 때문이다.
이 법칙을 준수하다 보면 자연스럽게 정보 전문가에게 책임을 할당하게 되어 높은 응집도를 가진 클래스를 얻게 될 것이다.
인터페이스 메서드명은 철저히 클라이언트 관점으로 생각해야 한다.
e.g. 영화가 제공하는 할인 조건을 찾는 경우
Movie
가 클라이언트가 되며 DiscountCondition
은 서버가 된다. 그렇다면 DiscountCondition
은 클라이언트의 의도를 담을 수 있도록 isSatisfied()
로 작명하는게 적절하다.
또한, "어떻게"가 아닌 "무엇을" 하는지 드러내야 한다.
public class PeriodConditon {
public boolean isStatisfiedByPeriod(final Screening screening) {
...
}
}
public class SequenceConditon {
public boolean isStatisfiedBySequence(final Screening screening) {
...
}
}
이처럼 어떻게 할인 조건을 판단하는지를 메서드명에 포함한다면 어떨까? 클라이언트 입장에서 두 메서드의 내부 구현에 대한 정확히 이해가 요구된다. 이는 클라이언트가 필요하지 않은 부분까지 알게되므로 메서드 수준에서 캡슐화를 위반한다 볼 수 있다.
무엇을 하는지 드러내는 메서드명은 설계의 유연성을 향상시킨다.
public class PeriodConditon {
public boolean isStatisfiedBy(final Screening screening) {
...
}
}
public class SequenceConditon {
public boolean isStatisfiedBy(final Screening screening) {
...
}
}
변경된 코드는 클라이언트 입장에서 동일한 메시지를 처리하는 것이므로 서로 대체 가능하다. 다형성을 활용한다면 동적 바인딩을 통해 이들 중 알맞는 메서드를 사용할 수 있다.
이렇게 무엇을 하느냐에 따라 메서드명을 짓는 패턴을 의도를 드러내는 선택자 라고 한다. 이들로 만들어진 인터페이스를 의도를 드러내는 인터페이스라고 한다. 구현과 관련된 모든 정보를 캡슐화하고 인터펭시ㅡ는 협력과 관련된 의도만 표현한다 볼 수 있다.
수행 방법을 언급하지 말고 결과와 목적만 포함하도록 클래스와 오퍼레이션을 작명하자.
앞서, 디미터 법칙, 묻지말고 시켜라 를 무작정 준수하면 응집도가 낮은 객체가 많아질 수 있다. 이는 철저히 규칙에 따라 다르다.
예를 들어, 디미터 법칙의 경우 컬렉션에는 적용할 필요가 없다. 컬렉션은 당연히 내부를 노출하기 때문이다.
// 디미터 법칙 위반 X
List<Integer> numbers = IntStream.range(0, 101)
.filter(i -> i % 2 == 0)
.distinct()
.toList();
마찬가지로 무조건 묻지말고 시키면 안되며 필요에 따라 물어보는 경우도 생긴다.
이처럼, 원칙을 적용할 때 이것이 적합한지 판단할 수 있어야 한다. 원칙을 아는 것 보다 더 중요한 건 원칙이 언제 중요하고 유용하지 않은지를 판단할 수 있는 능력이다.
명령-쿼리 분리 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 사용할 수 있는 지침을 제공하며 경우에 따라 물어야되는 경우를 판단할 때 유용하다.
어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능을 루틴이라 한다. 이 루틴은 크게 2가지로 구분된다.
void
)명령-쿼리 분리 원칙은 오퍼레이션은 명령이나 쿼리 둘 중 하나여야 한다는 것 이다. 바꿔말하면 부수효과가 있으면서 값 반환이 있으면 안된다는 것이다. 이를 요약하면 아래와 같다.
아래 예시 코드를 살펴보자.
Event.java
public class Event {
private String subject;
private LocalDateTime from;
private Duration duration;
public boolean isSatisfied(RecurringSchedule schedule) {
if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
!from.toLocalTime().equals(schedule.getFrom()) ||
!duration.equals(schedule.getDuration())) {
reschedule(schedule);
return false;
}
return true;
}
private void reschedule(RecurringSchedule schedule) {
from = LocalDateTime.of(from.toLocalDate().plusDays(daysDistance(schedule)),
schedule.getFrom());
duration = schedule.getDuration();
}
private long daysDistance(RecurringSchedule schedule) {
return schedule.getDayOfWeek().getValue() - from.getDayOfWeek().getValue();
}
}
reschedule()
메서드는 상태를 바꾸고 있다. 문제는 isSatisfied()
메서드는 정보를 반환함과 동시에 reschedule()
를 호출하면서 상태도 변화시키고 있다.
외부에서 isSatisfied()
메서드는 단순히 만족 여부만 반환하는 정도로 생각하여 반복 호출을 할 수 있을 것이다. 그 때마다 객체의 상태가 변경된다면 심각한 버그로 이어질 가능성이 높다.
이 규칙을 준수한다면 어떨까? 어떤 메서드가 부수효과가 있는지 반환값 유무로 간단하고 명확히 파악이 가능하다. 명령-쿼리를 동시에 수행하는 메서드는 실행 결과를 예측하기 어려워 앞서 언급했듯 추후 찾기 힘든 버그의 원인이 된다.
참고
명령-쿼리 분리 원칙에 따라 작성된 객체의 인터페이스를 __명령-쿼리 인터페이스 라 한다.
지금까지 좋은 인터페이스를 설계하기 위해 준수해야 되는 4가지 원칙을 살펴보았다. 이 4가지를 적합한 선에서 최대한 지키기 위해선 어떻게 하는게 좋을까?
바로 책임에 초점을 두면 된다. 이것이 원칙에 미치는 긍정적인 영향은 아래와 같다.
이처럼 훌륭한 메시지를 얻기 위한 출발점은 책임 주도 설계 원칙을 따르는 것이라 볼 수 있다. 메시지가 객체를 선택하므로 협력에 적합한 메시지를 결정할 수 있으며 앞서 4가지 원칙을 준수할 가능성이 높아져 유연한 설계가 된다.