객체지향 프로그래밍에 대한 가장 큰 오해는 애플리케이션이 클래스의 집합으로 구성된다는 것이다. 클래스는 그저 도구일 뿐이다. 따라서 좋은 객체지향 코드를 얻기 위해서는 협력 안에서 객체가 수행하는 책임에 초점을 맞추어야 한다. 책임은 객체가 수신할 수 있는 메시지의 기반이 된다. 애플리케이션은 클래스로 구성되지만 메시지를 통해 정의된다.
객체가 수신하는 메시지들이 객체의 퍼블릭 인터페이스를 구성한다. 좋은 퍼블릭 인터페이스를 얻기 위해서는 책임 주도 설계와 유연하고 재사용 가능한 퍼블릭 인터페이스를 만드는 데 도움이 되는 설계 원칙과 기법을 적용해야 한다. 이런 설게 원칙과 기법을 살펴보기 전에 우선 협력과 메시지를 설명하겠다.
협력과 메시지
클라이언트 - 서버 모델
협력은 어떤 객체가 다른 객체에게 무언가를 요청할 때 시작된다. 또 한 메시지를 매개로 하는 요청과 응답의 조합이 두 객체 사이의 협력을 구성한다.
객체는 협력을 수행하는 동안 클라이언트오 서버의 역할을 모두 수행한다. 협력의 관점에서 객체는 두 가지 종류의 메시 지 집합으로 구성된다.
1. 객체가 수신하는 메시지 집합
2. 외부의 객체에게 전송하는 메시지의 집합
메시지와 메시지 전송
1. 메시지: 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단이다.
2. 메시지 전송, 메시지 패싱: 한 객체가 다른 객체에게 도움을 요청하는 것
3. 메시지 수신자: 메시지를 수신하는 객체
4. 메시지의 구성: 오퍼레이션명, 인자
5. 메시지 전송: 메시지 구성 + 메시지 수신자. (ex: condition.isSatisfiedBy(screening) -> 수신자.오퍼레이션명(인 자))
메시지와 메서드
메시지를 수신했을 때 실제로 실행되는 코드는 메시지 수신자의 타입에 달려있다. 이렇게 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라 한다. 여기서 코드 상에서 동일한 이름의 상태에게 동일한 메시지를 전송했다 해도 객체의 타입에 따라 실행되는 메서드가 달라질 수 있다는게 중요하다. 즉, 코드의 의미가 컴파일 시점과 실행 시점 에서 다르다.
메시지와 메서드의 구분은 메시지 전송자와 메시지 수신자가 느슨허게 결합될 수 있게 한다. 메시지 전송자는 자신이 어 떤 메시지를 전송해야 하는 지만 알면 된다. 메시지 수신자는 메시지가 도착했다는 사실만 알면 된다. 메시지 수신자는 메 시지를 처리하기 위해 필요한 메서드를 스스로 결정할 수 있는 자율권을 누린다.
실행 시점에서 메시지와 메서드를 바인딩하는 메커니즘은 두 객체 사이의 결합도를 낮추어 유연하고 확장 가능한 코드 를 작성할 수 있게한다.
퍼블릭 인터페이스와 오퍼레이션
외부 객체는 오직 객체가 의사소통을 위해 공개한 퍼블릭 인터페이스로만 객체와 상호작용할 수 있다. 프로그래밍 언어 관점에서는 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션(operation)이라 한다. 오퍼레이션은 수행 가능한 어떤 행동 에 대한 추상화다. 대부분의 경우 오퍼레이션은 내부 구현을 제외한 단순히 메시지와 관련된 시그니처를 가리킨다. 그에 비 해 메시지를 수신했을 떄 실제로 실행되는 코드를 메서드라고 부른다.
프로그래밍 언어의 관점에서 객체가 다른 객체에게 메시지를 전송하면 런타임 시스템은 메시지 전송을 오퍼레이션 호출 로 해석하고 메시지를 수신한 객체의 실제 타입을 기반으로 적절한 메서드를 찾아 실행한다. 따라서 퍼블릭 인터페이스와 메시지의 관점에서 보면 '메서드 호출'보다는 '오퍼레이션 호출'이 더 적절하다.
시그니처
오퍼레이션의 이름과 파라미터 목록을 합쳐서 시그니처(signature)라 한다. 여기세 구현을 더하면 메서드가 된다. 객체지 향에서는 다형성 때문에 오퍼레이션과 메서드가 일대일 대응이 되지 않는다. 따라서 오퍼레이션 관점에서 다형성은 동일한 오퍼레이션 호출에 대해 서로 다른 메서드들이 실행되는 것이다.
객체가 수신할 수 있는 메시지가 객체의 퍼블릭 인터페이스와 그 안에 포함될 오퍼레이션을 결정한다. 객체의 퍼블릭 인터페이스가 객체의 품질을 결정하기 때문에 결국엔 메시지가 객체의 품질을 결정한다.
인터페이스와 설계 품질
좋은 인터페이스는 최소한의 인터페이스와 추상적인 인터페이스라는 조건을 갖추어야 한다. 최소한의 인터페이스는 반드시 필요한 오퍼레이션만을 인터페이스에 포함한다. 추상적인 인터페이스는 무엇을 하는지를 포함한다.
좋은 인터페이스의 조건을 맞추기 위해서는 책임 주도 설계를 따라야 한다. 책임 주도 설계는 메시지를 우선적으로 선택해서 최소한의 오퍼레이션만 포함시키고 메시지가 객체를 선택해 클라이언트의 의도가 메시지에 표현된다.
책임 주도 설계를 통해 좋은 인터페이스를 얻는 지침을 아는 것도 중요하지만, 인터페이스가 가지는 공통적인 특징을 알아야 올바를 설계를 할 수 있다. 퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법은 다음과 같다.
1. 디미터 법칙
2. 묻지 말고 시켜라
3. 의도를 드러내는 인터페이스
4. 명령-쿼리 분리
디미터 법칙
디미터 법칙은 객체의 내부 구조에 대한 결합으로 인해 발생하는 설계 문제를 해결하기 위해 제안되었다. 디미터 법칙은 객체 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 것이다.
디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송하도록 프로그래밍 해야
한다. 모든 클래스 C와 C에 구현된 모든 메서드 M에 대해서, M이 메시지를 전송할 수 있는 모든 객체는 아래에 서술된
클래 스의 인스턴스여야 한다. 이때 M에 의해 생성된 객체나 M이 호출하는 메서드에 의해 생성된 객체, 전역 변수로
선언된 객체는 모두 M의 인자로 간주한다.
1. M의 인자로 전달된 클래스(C 자체를 포함)
2. C의 인스턴스 변수의 클래스)
또는 다음과 같은 인스턴스들을 M이 메시지를 전송할 수 있는 모든 객체라 생각해도 된다.
1. this 객체
2. 메서드의 매개변수
3. this의 속성
4. this의 속성인 컬렉션의 요소
5. 메서드 내에서 생성된 지역 객체
디미터 법칙은 캡슐화를 다른 관점에서 표현한 것이다. 캡슐화는 클래스의 구현을 감춰야 한다는 사실을 강조하고 디미터 법칙은 협력하는 클래스의 캡슐화를 지키기 위해 접근해야 하는 요소를 제한한다.
다음 코드는 디미터 법칙을 위반한다.
1
|
screening.getMovie().getDiscountConditions();
|
cs |
메시지 전송자가 수신자의 내부 구조를 알아야 하고, 반환받은 요소에 대해 연쇄적으로 메시지를 전송한다. 이런 코드를 기차 충돌(train wreck)이라 한다. 기차 충돌은 클래스의 내부 구현이 외부로 노출됐을 때 나타나는 전형적인 형태로, 메 시지 전송자는 메시지 수신자의 내부 정보를 자세히 알아야 한다.
디미터 법칙은 객체가 자기 자신을 책임지는 자율적인 존재여야 한다는 사실을 강조한다. 하지만 무조건 적으로 디미터 법칙을 수용하면 퍼블릭 인터페이스 관점에서 객체의 응집도가 낮아질 수 도 있다.
묻지 말고 시켜라(Tell, Don't Ask)
디미터 법칙은 좋은 메시지는 객체의 상태에 관해 묻지 않고 원하는 것을 시켜야 한다는 사실을 강조한다. 묻지 말고 시켜 라는 이런 스타일의 메시지 작성을 장려하는 원칙을 가리키는 용어다.
메시지 전송자는 메시지 수신자의 상태를 기반으로 결정을 내린 후 메시지 수신자의 상태를 바꿔서는 안된다. 이는 캡슐 화를 위반한다.
묻지 말고 시켜라 라는 원칙을 따르면 객체의 정보를 이용하는 행동을 객체의 외부가 아닌 내부에 위치시키기 때문에 자 연스레 정보와 행동을 동일한 클래스 안에 두게 된다. 그때문에 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을 확률이 높아진다. 상태를 묻는 오퍼레이션을 행동을 요청하는 오퍼레이션으로 대체해서 인터페이스를 향상 시켜라.
하지만 단순히 묻지 말고 시키는 것으로는 좋은 인터페이스를 만들 수 없다. 좋은 인터페이스를 만들기 위해서는 객체의 구현을 노출해서는 안된다. 인터페이스는 객체가 어떻게 하는지를 노출하는 것이 아닌 무엇을 하는지를 서술해야 한다.
의도를 드러내는 인터페이스
만일 메서드명을 내부의 구현이 드러나도록 작성하면 다음과 같은 단점이 존재한다.
1
2
3
4
5
6
7
8
9
10
11
12
13 |
public class PeriodCondition {
// 내부의 구현을 드러내는 메서드명
public boolean isSatisfiedByPeriod(Screening screening) {
...
}
}
public class SequenceCondition {
// 내부의 구현을 드러내는 메서드명 public boolean isSatisfiedBySequence(Screening screening) {
...
}
}
|
cs |
1. 메서드에 대해 제대로 커뮤니케이션 하지 못한다. 위 두 메서드는 클라이언트 관점에서 동일한 일을 수행한다. 하지만 메서드명이 다르기 때문에 두 메서드의 내부 구현을 정확하게 이해하지 못하면 두 메서드가 동일한 작업을 하는지 알 수 없다.
2. 메서드 수준에서 캡슐화를 위반한다. 이 매서드들은 클라이언트가 협력하는 객체의 종류를 알도록 강요한다. 즉, 할인 종류가 바뀐다면 클라이언트 코드 역시 변해야 한다. 따라서 책임 수행 방식이 드러나는 코드는 변경에 취약하다.
그에 반해 무엇을 하는지를 드러내는 메서드명은 코드는 읽고 이해하기 쉬우며 유연한 코드를 낳는다. 무엇을 하는지를 드러내는 메서드명은 객체가 협력 안에서 수행해야 한느 책임에 관해 고민하게 한다. 따라서 협력하는 클라이언트의 의도 에 부합하게 메서드의 이름을 짓는다.
위 코드의 경우 클라이언트 입장에서 할인 여부를 판단하기 위해 상요한다. 따라서 다음과 같이 메서드명을 바꾸는 것이 적절하다.
1
2
3
4
5
6
7
8
9
10
11
|
public class PeriodCondition {
public boolean isSatisfiedBy(Screening screening) {
...
}
}
public class SequenceCondition {
public boolean isSatisfiedBy(Screening screening) {
...
}
}
|
cs |
하지만 자바 같은 정적 타이핑 언어는 단순히 메서드의 이름만 같다고 동일한 메시지를 처리할 수 없다. 클라이언트가 두 메서드를 가진 객체를 동일한 타입으로 간주하기 위해서는 동일한 타입 계층으로 묶어야 한다. 이를 위해 DiscountCondition이라는 인터페이스를 정의하고 이를 isSatisfiedBy 오퍼레이션에 정의하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class PeriodCondition implements DiscountCondition {
// 내부의 구현을 드러내는 메서드명
public boolean isSatisfiedBy(Screening screening) {
...
}
}
public class SequenceCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {
...
}
}
|
cs |
이처럼 어떻게 하느냐가 아닌 무엇을 하느냐에 따라 메서드의 이름을 짓는 패턴을 의도를 드러내는 선택자(Intention Revealing Selector)라고 한다.
원칙의 함정
디미터 법칙, 묻지 말고 시켜라 스타일은 좋은 설계를 위한 원칙이다. 하지만 설계는 트레이트 오프의 산물이기 때문에 이런 스타일들을 모든 상황에서 강제하면 안된다. 원칙이 현재 상황에 부적합 하다고 느낀다면 원칙을 무시해야 한다. 원칙을 아는 것도 중요하지만 언제 원칙이 유용한지를 판단하는 능력 역시 중요하다.
디미터 법칙은 하나의 '.'를 강제하는 규칙이 아니다.
디미터 법칙은 하나의 '.'만을 사용하는 것이 중점이 아니다. 디미터 법칙의 핵심은 결합도와 관련된 것이며, 이 결합도가 문제가 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다. 따라서 아무리 기차 충돌 처럼 보이는 코드여도 객체 내부 구현을 노출시키지 않았다면 이는 디미터 법칙을 준수한 코드이다.
1
2
3
4
5
|
// 디미터 법칙을 준수하는 코드
IntStream.of(1, 15, 20)
.filter(x -> x > 10)
.distinct()
.count();
|
cs |
결합도와 응집도의 충돌
모든 상황에서 디미터 법칙과 묻지 말고 시켜라 스타일을 고수하는 것은 때로는 독이 된다. 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 객체와 상관없는 책임을 떠안아 응집도가 낮아진다.
1
2
3
4
5
6
7
8
|
public class PeriodCondition implements DiscountCondiiton {
@Override
public boolean isSatisfiedBy(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek())
&& startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0
&& endTime.isAfter(screening.getWhenScreened().toLocalTime()) >= 0;
}
}
|
cs |
위 코드를 보면 Screening의 내부 상태를 가져와 사용하기 때문에 얼핏 보면 캡슐화를 위반하는 것처럼 보인다. 따라서 할인 여부를 판단하는 로직을 Screening의 isDiscountable 메서드로 옮기고 PeriodCondition이 이 메서드를 호출하게 변경하면 묻지 말고 시켜라 스타일을 준수하는 퍼블릭 인터페이스를 얻을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public class Screening {
public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime start time, LocalTime endTime) {
return whenScreened.getDayOfWeek().equals(dayOfWeek)
&& startTime.compareTo(whenScreened.toLocalTime()) <= 0
&& endTime.compareTo(whenScreened.toLocalTime()) > 0;
}
}
public class PeriodCondition implements DiscountCondiiton {
@Override
public boolean isSatisfiedBy(Screening screening) {
return screening.isDiscountable(dayOfWeek, startTime, endTime);
}
}
|
cs |
하지만 Screening의 본질적인 책임은 영화를 예매하는 것이다. 따라서 isDiscountable은 Screening이 져야 할 책임이 아니다. 또 한 Screening은 PeriodCondition의 인스턴스 변수 목록에 대한 영향도 받는다. 이는 두 객체 간의 결합도를 높이는 행위다. 따라서 Screening의 캡슐화를 향상시키는 것보다 Screening의 응집도를 높이고 PeriodCondition 사이의 결합도를 낮추는 것이 전체적인 관점에서 더 좋은 방법이다.
간혹 가다 묻는 것 외에 다른 방법이 존재할 때도 있다. 물으려는 객체가 자료구조인 경우다. 클린코드에서 설명하는 디미터 법칙에 따르면 디미터 법칙 위반 여부는 묻는 대상이 객체인지, 자료구조인지에 달려있다. 자료구조는 내부를 노출하는 것이 당연하므로 디미터 법칙을 적용할 필요가 없다.
요약하자면 소프트웨어 설계 법칙은 존재하지 않는다. 원칙을 맹신하지 말라, 설계는 트레이드 오프의 산물이다. 따라서 경우에 따라 다르다.
명령-쿼리 분리 원칙
위에서 설명한거 처럼 필요에 따라 물어야 한다. 명령-쿼리 분리 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있는 지침을 제공한다.
어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈을 루틴(routine)이라 한다. 루틴은 다시 프로시져(procedure)와 함수로 구분된다. 프로시져와 함수는 부수효과와 반환값의 유무라는 측면에서 명확히 구분된다. 프로시저는 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류다. 함수는 어떤 절차에 따라 필요한 값을 계산해서 반환하는 루틴의 한 종류다. 프로지저와 함수를 명확히 구분하기 위해 루틴을 작성할 때 다음과 같은 제약을 따라야 한다.
1. 프로시저는 부수효과를 발생시킬 수 있지만 값을 반환할 수 없다.
2. 함수는 값을 반환할 수 있지만 부수효과를 발생시킬 수 없다.
명령과 쿼리는 객체 인터페이스 측면에서 프로시저와 함수를 부르는 또 다른 이름이다. 객체 상태를 수정하는 오퍼레이션을 명령(프로시저)라 하고 객체와 관련된 정보를 반환하는 오퍼레이션을 쿼리(함수)라 한다.
명령-쿼리 분리 원칙은 어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안된다는 것을 시사한다.따라서 명령과 쿼리를 분리하기 위해 다음과 같은 규칙을 준수해야 한다.
1. 객체의 상태를 변경하는 명령은 반환값을 가질 수 없다.
2. 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없다.
부수효과를 발생시키지 않는 것을 함수로 제한해서 소프트웨어에서 말하는 함수의 개념과 일반 수학에서 말하는 함수의 개념이 상충되지 않게 한다.
반복 일정의 명령과 쿼리 분리하기
반복되는 이벤트를 쉽게 관리하는 소프트웨어 예시를 통해 명령-쿼리 분리 원칙을 살펴보자.
우선 도메인의 중요한 용어인 "이벤트(evnet)"와 "반복 일정(recurring schedule)"에 대해 살펴보자.
1. 이벤트: 특정 일자에 실제로 발생하는 사건을 의미한다.
특정 시간대에 회의가 잡혀 있다면 이는 이벤트이다.
2. 반복 일정: 일주일 단위로 돌아오는 특정 시간 간격에 발생하는 사건 전체를 포괄적으로 지칭한다.
매주 특정 시간대에 회의가 반복되면 이는 반복 일정이다.
이제 Event클래스와 RecurringSchedule 클래스를 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
public class Event {
private String subject;
private LocalDateTime from;
private Duration duration;
public Event(String subject, LocalDateTime from, Duration duration) {
this.subject = subject;
this.from = from;
this.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();
}
}
public class RecurringSchedule {
private String subject;
private DayOfWeek dayOfWeek;
private LocalTime from;
private Duration duration;
public RecurringSchedule(String subject, DayOfWeek dayOfWeek,
LocalTime from, Duration duration) {
this.subject = subject;
this.dayOfWeek = dayOfWeek;
this.from = from;
this.duration = duration;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public LocalTime getFrom() {
return from;
}
public Duration getDuration() {
return duration;
}
}
|
cs |
현재의 구현을 테스트 하기 위해 다음 코드를 돌리면 처음엔 false를 반환하고 두번째는 true를 반환한다. 즉, 같은 메서드를 두번 호출했더니 결과가 달랐다.
1
2
3
4
5
6
7
8
|
RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.WEDNESDAY,
LocalTime.of(10, 30), Duration.ofMinutes(30));
Event metting = new Event("회의",
LocalDateTime.of(2019, 5, 9, 10, 30), Duration.ofMinutes(30));
assert meeting.isSatisfied(schedule) == false;
assert meeting.isSatisfied(schedule) == true;
|
cs |
이 버그의 문제는 Event 클래스의 isSatisfied 메서드와 reschedule 메서드에 있다. isSatisfied 메서드는 조건에 맞지 않은 스케줄이 들어오면 reschedule 메서드를 호출해 Event의 상태를 isSatisfied 메서드의 조건에 만족하도록 수정하고 false를 반환한다.
이 버그를 찾기 어려운 이유는 isSatisfied가 명령과 쿼리의 두 가지 역할을 수행하기 때문이다.
1. isSatisfied 메서드는 쿼리다. isSatisfied 메서드는 Event가 RecurringSchedule의 조건에 부합하는지에
따라 boolean을 반환한다.
2. isSatisfied 메서드는 명령이다. isSatisfied 메서드는 Event가 RecurringSchedule의 조건에 부합하지 않으면
Event의 상태를 변경한다.
isSatisfied 메서드는 내부 로직을 정확히 알고 있지 않으면 내부에 부수효과가 있다는 것을 알기 어렵다. 즉, 명령과 쿼리를 뒤섞으면 실행 결과를 예측하기 어렵다. 이를 해결하는 가장 깔끔한 방식은 명령과 쿼리를 분리하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
public class Event {
...
public boolean isSatisfied(RecurringSchedule schedule) {
if (from.getDayOfWeek() != schedule.getDayOfWeek()
|| !from.toLocalTime().equals(schedule.getFrom())
|| !duration.equals(schedule.getDuration())) {
// reschedule 호출 부분 삭제
return false;
}
return true;
}
// private 에서 public 으로 변경
public void reschedule(RecurringSchedule schedule) {
from = LocalDateTime.of(from.toLocalDate()
.plusDays(daysDistance(schedule)), schedule.getFrom());
duration = schedule.getDuration();
}
...
}
|
cs |
어떤 메서드가 부수효과를 가졌는지를 확인하기 위해 코드를 전부 분석하는 것은 매우 버거운 일이다. 그보자 반환값의 존재 여부만을 확인하는 것이 훨씬 간단하다.
명령-쿼리 분리와 참조 투명성
명령과 쿼리를 명확히 분리하는 것은 부수효과 제어에 용이하다. 쿼리는 객체의 상태를 변경하지 않기 때문에 반복적으로 호출할 수 있고 결과를 예측하기 쉬워진다. 또 한 순서를 자유롭게 할 수 있다.
명령과 쿼리를 분리하면 명령형 언어의 틀 안에서 참조 투명성(referential transparency)의 장점을 제한적이나마 누릴 수 있다. 참조 투명성은 어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체해도 결과가 달라지지 않는 특성을 의미한다. 참조 투명성을 잘 활용하면 버그가 적고, 디버깅이 용이하며 쿼리의 순서에 따라 실행 결과가 변하지 않는 코드를 작성할 수 있다.
수학은 참조 투명성을 엄격히 준수하는 가장 유명한 체계다. 어떤 함수 f(n)이 있고 n이 1일때 f(n)의 결과가 3이라 하자. 그러면 f(n)*2=6이 되고 여기서 f(n)을 3으로 바꿔도 결과는 변하지 않는다(3*2=6). 수학에서 함수는 동일한 입력에 대해 항상 동일한 값을 반환하기 때문에 수학의 함수는 참조 투명성을 만족시키는 이상적인 예시다. "f(1)의 결과는 3이다" 처럼 변하지 않는 성질을 불변성(immutability)이라 한다. 불변성은 부수효과가 발생하지 않는다는 말과 동일하다.
수학에서의 함수는 어떤 값도 변경하지 않기 때문에 부수효과가 존재하지 않는다. 그리고 부수효과가 없는 불변의 세상에서는 모든 로직이 참조 투명성을 만족시킨다. 따라서 불변성은 부수효과의 발생을 방지하고 참조 투명성을 만족시킨다.
참조 투명성을 만족하는 식은 다음과 같은 두 가지 장점을 제공한다.
1. 모든 함수를 이미 알고 있는 하나의 결과값으로 대체할 수 있기 때문에 식을 쉽게 계산할 수 있다.
2. 모든 곳에서 함수의 결과값이 동일하기 때문에 식의 순서를 변경해도 각 식의 결과는 달라지지 않는다.
객체지향 패러다임이 객체의 상태를 변경이라는 부수효과를 기반으로 하기 때문에 참조 투명성은 예외에 가깝다. 하지만 명령-쿼리 분리 원칙을 이용해 명령과 쿼리를 분리해서 제한적으로 나마 참조 투명성의 혜택을 누릴 수 있다.
명령형 프로그래밍(imperative programming)과 함수형 프로그래밍(functional programming)
명령형 프로그래밍은 부수효과를 기반으로 하는 프로그래밍 방식이다. 명령형 프로그래밍은 상태를 변경시키는 연산들을 적절한 순서대로 나열해 프로그램을 작성한다. 대부분의 객체지향 프로그래밍들은 객체의 상태를 변경에 집중하기 때문에 명령형 프로그래밍이다.
최근들어 주목받고 있는 함수형 프로그래밍은 부수효과가 존재하지 않는 수학적인 함수에 기반한다. 따라서 함수형 프로그래밍은 참조 투명성의 장점을 극대화할 수 있고 명령형 프로그래밍에 비해 프로그램의 실행 결과를 이해하고 예측하기 더 쉽다. 또 한 하드웨어의 발달로 병렬 처리가 중요해진 최근에는 함수형 프로그래밍의 인기가 상승하고 있고 다양한 객체지향 언어들이 함수형 프로그래밍 패러다임을 접목시키고 있다.
책임에 초점을 맞춰라
좋은 객체지향 설계를 하기 위해서는 책임에 초점을 맞추어야 한다. 이를 위해서는 매시지를 먼저 선택해야 한다. 위에서 살펴본 디미터 원칙, 묻지 말고 시켜라, 의도를 드러내는 인터페이스, 명령-쿼리 분리 원칙이 메시지를 먼저 선택하는 방식에 미치는 영향은 다음과 같다.
1. 디미터 법칙:
협력이라는 컨텍스트 안에서 객체보다 메시지를 먼거 졀정하면 두 객체 사이의 구조적인 결합도를 낮출 수 있다. 수신할 객체를 알지 못한 상태에서 메시지를 먼저 선택하기 때문에 객체의 내부 구조에 대해 고민할 필요가 없어진다. 따라서
메시지가 객체를 선택하게 해서 의도적으로 디미터 법칙을 위한할 위험을 최소화 시킨다.
2. 묻지 말고 시켜라:
메시지를 먼저 선택하면 묻지 말고 시켜라 스타일에 따라 협력을 구조화하게 된다. 클라이언트의 관점에서 메시지를
선택하기 때문에 필요한 정보를 물을 필요 없이 원하는 것을 표현한 메시지를 전송하면 된다.
3. 의도를 드러내는 인터페이스:
메시지를 먼저 선택한다는 것은 메시지를 전송하는 클라이언트의 관점에서 메시지의 이름을 정한다는 것이다. 당연히 그 이름에는 클라이언트가 원하는 것, 그 의도가 드러날 수밖에 없다.
4. 명령-쿼리 분리 원칙:
메시지를 먼저 선택한다는 것은 협력이라는 문맥 안에서 객체의 인터페이스에 관해 고민한다는 것이다. 객체가 단순히 어떤 일을 해야 하는지뿐만 아니라 협력 속에서 객체의 상태를 예측하고 이해하기 쉽게 만들기 위한 방법에 관해
고민하게 된다. 따라서 예측 가능한 협력을 만들기 우해 명령과 쿼리를 분리하게 될 것이다.
지금까지 살펴본 원칙득은 구형과 부수효과를 캡슐화하고, 높은 응집도와 낮은 결합도를 가진 인터페이스를 만들 수 있는 지침을 제공한다. 하지만 실제로 실행 시점에 필요한 구체적인 제약이나 조건을 명확히 표현하지는 못한다. 오퍼레이션의 시그니처는 단지 오퍼레이션의 이름과 인자와 반환값의 타입만 명시할 수 있다. 시그니처는 어떤 조건이 만족되야 오퍼레이션을 호출할 수 있고 어떤 경우에 결과를 반환받을 수 없는지를 표현할 수 없다. 즉, 협력을 위해 두 객체가 보장행야 하는 실행 시점의 제약을 인터페이스에 명시할 수 있는 방법이 없다.
버트란드 마이어는 이런 문제를 해결하기 위해 계약에 의한 설계(Design by Contract)개념을 제안했다. 계약에 의한 설계는 협력을 위해 클라이언트와 서버가 준수해야 하는 제약을 코드 상에 명시적으로 표현하고 강제할 수 있는 방법이다.
출처 - 오브젝트