일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 구글 플레이 비공개 테스트
- 설계
- 클린코드
- 구글 플레이 스토어 배포 방법
- 객체지향설계
- 기능명세서
- 우테코
- 프리코스
- 구글 비공개 테스트 20명
- 플레이스토어 비공개 테스트
- 객체지향
- 운영체제 #CS지식
- 커밋 메시지
- git
- 플레이 스토어 20명
GYUD-TECH
[스터디: 오브젝트] 디미터 법칙과 트레이드오프 본문
책의 매 장마다 강조하는 내용은 메시지를 통해 협력하는 책임 주도 설계이다.
메시지를 먼저 생각하고 협력하여 캡슐화를 수행하고, 높은 응집도와 강한 결합도를 만든다.
이번 장에서 전달하고자 하는 궁극적인 내용은 위와 동일하지만, 좋은 인터페이스 설계를 위해 활용할 수 있는 몇가지 원칙을 추가적으로 소계한다.
원칙을 이해하기 전에 먼저 오퍼레이션의 개념에 대해 배워보자.
오퍼레이션
객체가 전달하는 메시지는 오퍼레이션명과 인자로 구성되고, 메시지 전송시에는 수신자가 추가된다.
condition.isSatisfiedBy(screening);
위와 같이 메시지를 전송할 때 순서대로 수신자.오퍼레이션명(인자) 의 형태를 띈다.
이렇게만 보면 메서드명과 오퍼레이션명이 같은 것으로 오해할 수 있다.
앞서 배웠던 역할과 객체와의 관계와 비슷한 개념으로 생각하면 둘을 구별하기 더 쉬울 것이다.
역할은 객체들의 집합으로 객체들을 추상화하여 나타낸 개념이다.
오퍼레이션도 역시 메서드를 추상화한 개념으로 생각하면 된다.
위 코드에서는 isSatisfiedBy 라는 오퍼레이션을 호출하였지만, 다형성으로 인해 PeriodCondition 객체의 isSatisfiedBy 메서드가 호출될 수도 있고, SequenceCondition 객체의 isSatisfiedBy 메서드가 호출 될 수 도 있다.
다형성을 활용하여 하나의 오퍼레이션에 대해서 다양한 메서드를 구현하면 변화에 용이한 좋은 설계를 할 수 있을 것이다.
좋은 인터페이스를 만드는 원칙
책에서는 좋은 인터페이스를 만들기 위해서 아래 4가지 원칙을 소개한다.
- 디미터 법칙
- 묻지말고 시켜라
- 명령/쿼리 분리
- 의도를 드러내는 인터페이스
디미터 법칙
디미터 법칙의 정의는 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하는 것이다.
정의만 봤을 때는 이전에 많이 말했던 말과 비슷하여, 정확하게 어떤 의미인지 이해하기 어렵다.
디미터 법칙을 이야기 할때 가장 많이 나오는 말이 하나의 도트(.) 만 사용하라 라는 것이다.
우테코를 할때도 한줄에 하나의 도트(.) 만 사용하는 코드를 작성하라라는 말을 많이 들었다.
그 당시에는 원칙 의미를 명확하게 이해하지 못해기 때문에 아래와 같은 코드도 원칙을 위반한 것이라고 생각했다.
List<Integer> numbers = List.of(1, 2, 3, 4);
return numbers.stream().map(number -> number+1).filter(number -> number > 3).toList();
한줄에 .을 매우 많이 사용했기 때문에 이렇게 사용하면 안된다고 생각하여 반복문과 조건문으로 분리하여 표현하였다.
하지만 사실 위의 코드는 디미터 법칙을 위반한 것이 아니다.
내가 사용한 stream(), map(), filter() 메서드는 모두 IntStream이라는 동일한 클래스의 인스턴스를 반환한다.
디미터 법칙이 말하고자 하는 것은 단순하게 도트를 많이 쓰지 말라는 것이 아니라 도트를 사용하여 내부의 구조를 노출하지 말자는 의미이다.
위 코드에서는 IntStream 클래스의 어떠한 구현도 노출되지 않았기 때문에 디미터법칙을 위반한 것이 아니다.
아래와 같이 Movie 객체의 내부에 DIscountConditions 라는 구현이 노출 되었을 때 디미터 법칙을 어긴 것이다.
screening.getMovie().getDiscountConditions();
나도 프로젝트를 하면서 위와 같은 코드를 자주 작성했었다.
위 코드는 Movie 객체에 getDiscountCondition() 라는 메서드가 있다는 것을 노출하는데, 어차피 public 메서드이기 떄문에 이게 큰 문제가 되는지 의문이었다.
하지만 getDiscountConditions() 를 한 이후에 리턴되는 객체들에게 또 다른 메시지를 보낼텐데, 그러면 객체는 Movie 뿐만 아니라 DiscountCondition 객체도 참조하게 되는 것이다.
이렇게 하면 객체들이 강하게 결합되고, 캡슐화가 꺠지는 문제가 발생한다.
그러면 디미터 법칙을 위반했는지 어떻게 확인할 수 있을까?
객체가 디미터 법칙을 위반하지 않기 위해서는 아래의 객체들과만 협력해야 한다.
- this 객체
- this 객체의 속성으로 선언된 객체
- 전역 객체
- 메서드의 매개변수로 전달된 객체
- 메서드 내에서 또는 그 하위에서 생성된 객체
쉽게 말하면, 직접적인 연결이 없는 객체와는 협력하지 말고, 연관성이 있는 객체와 이야기하는 것이 디미터 법칙을 따르는 것이다.
원칙의 함정
하지만, 디미터 법칙을 무조건 따르는 것이 항상 옳은 것은 아니다.
디미터 법칙을 사용했을 때 오히려 응집도가 낮아지는 경우가 있다.
public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {
return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) && ...
}
}
위 코드는 할인조건은 판단하기 위한 PeriodCondition의 isSatisfiedBy() 메서드이다.
getter() 메서드를 사용하여 screening의 startTime을 가져온 후 getDayOfWeek()를 다시 호출함으로써 디미터 법칙을 위반했다.
디미터 법칙을 만족하도록 설계를 바꾸면 아래와 같은 코드를 작성할 수 있다.
public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {
return screening.isDiscountable(dayOfWeek, ...);
}
}
public class Screening {
pubic boolean isDiscountable(DayOfWeek dayOfWeek, ...){
return whenScreened.getDayOfWeek().equals(dayOfWeek) && ...
}
}
// DayOfWeek() 객체와의 연관관계는 제외하고 보자.
DiscountCondition 객체에서 Screening의 isDiscountable() 메서드를 호출함으로써, 조건 비교에 필요한 whenScreened 필드를 객체 내에서 다루도록 하여 캡슐화를 수행할 수 있었다.
하지만, 결국 screening 이 할인 가능 여부를 체크하는 기능을 담당하게 되었다.
만약 PeriodCondition의 할인 조건이 변경된다면, periodCondition 뿐만 아니라 Screening 역시 변경해야 한다.
캡슐화를 수행하고, 디미터 원칙을 지켰지만 오히려 응집도가 낮아지고 결합도가 높아져 변경이 어려운 설계가 되었다.
또한, 디미터 법칙을 지키면 코드를 작성할 수 없는 경우가 있다.
public class BagWithNone implements Bag{
@Override
public Long setTicket(Ticket ticket) {
this.ticket = ticket;
minusAmount(ticket.getFee());
return ticket.getFee();
}
private void minusAmount(Long amount) {
this.amount -= amount;
}
}
데이터로써 활용하기 위한 값을 가져오는 경우에는 getter()를 쓰지않고는 값을 외부에서 가져올 수 없다.
이런 경우에는 디미터 법칙을 어기더라도 올바른 객체에게 책임을 할당하고, 코드를 작성하는 것이 더 좋다.
디미터 법칙이 좋은 인터페이스를 설계하기 위한 원칙이기는 하지만 단점도 존재한다.
원칙이라고 무조건적으로 따르는게 아니라 상황에 따라 원칙을 선택적으로 사용해야 한다.
원칙을 아는 것보다 중요한 것은,
언제 원칙이 유용하고 언제 유용하지 않은지를 판단할 수 있는 능력이다.
묻지말고 시켜라
디미터 원칙과 비슷한 내용이지만 좀 더 직관적으로 이해가 가능한 원칙이다.
public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedBy(Screening screening) {
return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) && ...
}
}
앞서 소개한 디미터 원칙을 어긴 코드에서는 getStartTime()으로 startTime이 무엇인지를 물어본 것이다.
이렇게 묻지않고, screening 객체에 직접 책임을 할당하는 것이 묻지말고 시켜라의 원칙을 따른 것이다.
하지만 앞선 예시에서 봤듯이 가끔은 물어야할 필요가 있다.
그러면 언제 묻고 언제 시키는 것이 좋을까?
물어서 객체를 가져오더라도 이를 데이터로써 조회만 하는 경우에는 묻는것이 문제가 되지 않는다.
하지만 이를 활용하여 값을 변경하거나 하는 명령을 수행한다면, 해당 책임을 객체 본인에게 할당하는 것이 좋다.
이 내용은 명령-쿼리 분리 원칙과 연결된다.
명령-쿼리 분리
명령이란 객체의 상태를 수행하는 프로시저의 개념이고, 쿼리는 객체와 관련된 정보를 반환하는 조회의 개념이다.
명령은 어떤 작업을 수행하기만 할 뿐 반환값을 가질 수 없다.
반대로 쿼리는 정보를 조회하는 목적이기 떄문에 반환값을 가지지만, 어떤 작업을 수행할 순 없다.
만약 특정 오퍼레이션이 명령과 쿼리의 역할을 모두 수행한다면 부수효과가 발생하기 떄문에 문제가 발생하기 쉽다.
알고리즘 문제를 풀 때 아래와 같은 코드를 작성하여 문제가 발생한 적 있는데, 이 예시가 부수효과의 문제점을 잘 드러내는 것 같다.
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
for (int i = 0; i < list.size(); i++) {
if (condition) {
list.add(i);
}
}
위 코드는 조건에 따라서 list에 값을 추가하는 코드이다.
코드의 의도는 반복문을 수행하기 전에 list에 포함된 데이터에 대해서만 조건을 확인하고 만족하면 list에 추가하는 것이다.
하지만 위와 같이 코드를 짜면 오류가 발생한다.
list.size() 메서드를 통해서 list의 크기를 조회함과 동시에, list.add() 메서드로 list의 크기가 변경되기 때문이다.
다시 말해서 명령과 쿼리가 동시에 일어나기 때문에 문제가 발생한다.
따라서 이를 해결하기 위해서는 아래와 같이 명령과 쿼리를 분리해야한다.
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
int size = list.size();
for (int i = 0; i < size; i++) {
if (condition) {
list.add(i);
}
}
이렇게 명령과 쿼리를 분리한다면, 내가 의도한 대로 반복문을 사용할 수 있다.
웹 개발 시에도 HTTP 메서드를 GET 과 PUT / PATCH 요청을 구분하는 것도 예시에 해당될 것 같다.
GET 요청은 주로 데이터를 조회하는 목적으로 사용하기 떄문에 관련된 데이터를 반환하지만, 값을 수정할 수 없어 읽기전용으로 사용한다.
그에 반해 PUT이나 PATCH 요청은 데이터를 수정하기만 하고, 값이 아닌 HTTP 상태코드만을 반환한다.
명령과 쿼리를 분리하면, 서비스가 예측가능하고, 이해하기 쉬우며 디버깅과 유지보수에 용이하기 때문에 주변에서도 명령-쿼리 분리 원칙을 많이 따른다는 것을 알 수 있다.
의도를 드러내는 인터페이스
결론부터 말하면, 디미터 원칙을 따르면 인터페이스의 의도를 잘 드러내는 인터페이스명을 붙일 수 있다.
public class PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening) { ... }
}
public class SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening) { ... }
}
처음 위 코드를 보았을 떄는 그 의미가 명확하게 드러난다고 생각했다.
하지만 두 메서드는 객체만 다를 뿐 할인 조건을 판단하는 동일한 행동을 수행하는데, 이름이 달라 동일한 행동을 하는지 전혀 예측할 수 없다.
할인 조건을 period로 판단하는지 sequence로 판단하는지는 어떻게에 해당하는 구현의 영역이기 때문에 이를 노출하지 않고 isSatisfiedBy와 같이 무엇에 해당하는 책임만 드러내는 것이 더 좋은 메서드명이다.
책에서는 추가로 메서드명을 변경하는 예시를 소개해준다.
아래 코드는 책에서 제시한 코드에 이전에 배웠던 Polymolphism 패턴을 Bag에 적용한 것이다.
(if-else 문의 분기문을 사용하지 않고 다형성을 활용하여 구현을 숨기고자 변경하였다.)
public class Theater {
public void enter(Audience audience) {
ticketSeller.setTicket(audience);
}
}
public class TicketSeller {
public void setTicket(Audience audience) {
ticketOffice.plusAmount(audience.setTicket(ticketOffice.getTicket());
}
}
public class Audience {
public Long setTicket(Ticket ticket) {
return bag.setTicket(ticket);
}
}
public abstract class Bag {
private Long amount;
private Ticket ticket;
public abstract Long setTicket(Ticket ticket);
}
public class BagWithInvitation implements Bag{
@Override
public Long setTicket(Ticket ticket) {
this.ticket = ticket;
return 0L;
}
}
public class BagWithNone implements Bag{
@Override
public Long setTicket(Ticket ticket) {
this.ticket = ticket;
minusAmount(ticket.getFee());
return ticket.getFee();
}
private void minusAmount(Long amount) {
this.amount -= amount;
}
}
위 코드에서는 TicketSeller 객체, Audience 객체, Bag 객체 모두 setTicket() 메서드를 가진다.
결론적으로 ticket을 가방에 세팅하는 행위를 하는것은 맞지만, 객체간 협력관계에서 의미를 드러내기에는 부족한 것 같다.
Theater 객체가 TicketSeller 객체에게 보내는 메시지는 '티켓을 팔아라' 라는 메시지이다.
따라서 setTicket이 아닌 sellTo 와 같은 오퍼레이션명이 더 잘 어울린다.
TicketSeller 객체가 Audience 객체에게 보내는 메시지는 '티켓을 사라' 라는 메시지이기 때문에 buy 라는 오퍼레이션명이 적절하다.
Audience 객체는 Bag 객체에게 '티켓을 보관해라' 라는 메시지를 전송하기 떄문에 hold라는 오퍼레이션명이 적절하다.
public class TicketSeller {
public void sellTo(Audience audience){ ... }
}
public class Audience {
public void buy(Ticket ticket){ ... }
}
public class Bag {
public void hold(Ticket ticket) { ... }
}
오퍼레이션명은 클라이언트의가 메시지를 요청한 목적을 표현해야 한다.
디미터 법칙을 지키지 않고 gettter와 setter로 오퍼레이션명을 나타내는 것에 비하여, 법칙을 지켰을 때 인터페이스로 메시지의 의도를 명확하게 드러낼 수 있다.
정리
디미터 법칙은 좋은 인터페이스를 설계하기 위해 활용하기 좋은 원칙임은 분명하다.
책임주도설계를 통해서 변경에 대처하기 쉬운 코드를 작성할 수 있는 것도 맞다.
하지만 모든 구현을 숨기는 것이 장점만 존재하진 않는다.
실행시점에 구현체가 연결된다면, 실행 시키기 전에는 프로그램이 어떻게 동작하는지 명확하게 알기 어려울 것이다.
결국 모든 설계는 트레이드오프이다.
책임주도설계가 항상 올바른 원칙은 아니기 때문에 무작정 따르는 것은 옳지 않다.
원칙을 잘 따르는 능력을 기르는것이 아니라 상황에 맞게 원칙을 활용하는 능력을 기르는 것이 중요하고 느꼈다.
소프트웨어의 항상 옳은 법칙은,
상황에 따라 다르다는 법칙이다.
'스터디' 카테고리의 다른 글
[스터디: 오브젝트] 유연한 설계 (1) | 2024.02.26 |
---|---|
[스터디: 오브젝트] 의존성 관리 (1) | 2024.02.22 |
[스터디: 오브젝트] 설계 개선하기 (1) | 2024.02.18 |
[스터디: 오브젝트] 데이터 중심 설계 (1) | 2024.02.14 |
[스터디: 오브젝트] 객체지향 프로그래밍 (2) | 2024.02.05 |