글을 작성하게 된 계기
사람들과 스프링 서버를 만들어보는 프로젝트를 진행하며 디미터의 법칙에 대해 질문받았습니다. PR을 달고 보니, 이전에 학습한 내용을 잘못 이해하고 있는 것 같아 이를 다시 한번 정리하고 싶었고, 이에 글을 작성하게 되었습니다.
1. 디미터의 법칙
디미터의 법칙(Law-Of-Demeter)은 최소한의 지식만 노출시키는 소프트웨어 개발 방식입니다. 이를 통해 캡슐화 유지, 객체 간 결합도 감소 와 같은 이점을 얻을 수 있으며, 이를 통해 유지보수가 용이한 소프트웨어를 개발할 수 있습니다.
디미터 법칙에는 객체는 자신이 직접적으로 소유하거나 사용하는 객체의 메서드만 호출 해야 하며, 간접적으로 알고 있는 객체의 메서드는 호출해서는 안 된다. 라는 규칙이 있습니다. 예를 들어, 다음과 같이 사용자(User)가 자기 주소(zipCode)를 얻고 싶을 때, Home으로부터 Address를 호출해서는 안 됩니다. 이는 User에 Address의 내부 구현을 알려주는 것이기 때문입니다.
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
public class User {
private Home home;
public String getZipCode() {
// User가 Address의 내부 구현을 알게 됨.
Address address = home.getAddress();
return address.getZipCode();
}
}
public class Home {
private Address address;
public Address getAddress() {
return address;
}
}
public class Address {
private String zipCode;
public String getZipCode() {
return zipCode;
}
}
이를 디미터의 법칙을 준수하도록 하려면 다음과 같이 코드를 수정할 수 있습니다. User가 Home을 통해 ZipCode를 가져오는 것입니다. User는 Address가 존재하는지, 내부에 어떤 메서드가 존재하는지, 내부 구현을 몰라도 되며, 자신이 직접적으로 알고 있는 Home을 통해서만 값을 가져오는 것입니다. 이를 통해 객체의 세부 구현을 숨길 수 있으며, 객체 간의 결합도를 낮출 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class User {
private Home home;
public String getZipCode() {
// User는 Address를 몰라도 되며, Home을 통해서만 그 값을 가져옵니다.
return home.getZipCode();
}
}
public class Home {
private Address address;
public String getZipCode() {
return address.getZipCode();
}
}
public class Address {
private String zipCode;
public String getZipCode() {
return zipCode;
}
}
간략하게 디미터의 법칙에 대해 살펴보았는데, 이제 어떤 것을 잘못 알고 있었는지 한 번 살펴보겠습니다.
2. 어떤 것을 잘못 알고 있었을까?
같이 프로젝트를 진행하던 MU가 디미터의 법칙에 대해 질문을 했는데, 다음과 같이 답변을 달았습니다. 디미터의 법칙은 객체의 캡슐화, 결합도에 관한 것이 핵심인데 문맥 이라는 잘못된 방향의 답변을 한 것입니다.
코드는 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
@DisplayName("[UnitTest] 쿠키 단위 테스트")
class CookiesUnitTest {
@Test
@DisplayName("쿠키 목록에 key를 넣으면 value 값이 나온다.")
void cookieGetValueTest() {
Cookies cookies = new Cookies(List.of("name=value"));
Cookie cookie = cookies.getValue("name");
assertEquals("value", cookie.value());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Cookies {
......
private final Map<String, Cookie> cookiesMap;
public static final Cookies emptyCookies = new Cookies();
......
public Cookie getValue(String name) {
return this.cookiesMap.get(name);
}
......
}
따라서 답변을 정정해 보면 다음과 같이 고칠 수 있습니다.
추가로 처음에 달았던 리플렉션과 디미터의 법칙에 대해서도 한 번 살펴보겠습니다.
리플렉션을 사용한 체인 호출 은 객체의 내부 구조나 상태를 외부로 노출하는 것이 아닙니다. 이는 단지 타입 정보를 통해 동적으로 객체를 생성하거나 메서드를 호출합니다.
1
2
Class<?> clazz = User.class.getDeclaringClass( );
Object object = clazz.newInstance( );
즉, 객체 내부를 노출하는 것이 아니기 때문에 다음과 같이 사용해도 상관이 없습니다. 무조건 메서드를 체이닝 한다고 해서 디미터의 법칙을 어기는 것이 아니며, 이 기준은 객체의 캡슐화를 어기는지/아닌지 에 따라 달라집니다.
1
Object object = User.class.getDeclaringClass( ).newInstance( );
물론 구현은 자유고, 사람마다 생각이 다르기 때문에 이는 제 개인적인 판단입니다.
3. 디미터의 법칙의 예외
디미터의 법칙을 적용하지 않아도 괜찮은 케이스가 있는데, 이에 대해 살펴보겠습니다.
- 자료구조
- DTO
- Stream API
3-1. 자료구조
객체는 동작을 공개하고 데이터를 숨기며, 자료 구조는 별다른 동작 없이 자료를 노출 합니다. 따라서 객체는 메서드를 외부에 공개하며 데이터는 숨기며, 자료구조는 이를 숨기지 않고 바깥에 드러냅니다.
즉, 자료구조는 무조건 함수 없이 공개 변수만 포함하고 객체는 비공개 변수와 공개 함수를 포함합니다. 단, 단순한 자료구조에도 조회 함수와 설정 함수를 정의하라는 프레임워크와 표준이 존재합니다.
이로 인해 절반은 객체, 절반은 자료 구조인 잡종 구조가 나오기도 하는데, 이는 새로운 함수나 새로운 자료구조를 추가하기 어렵습니다.
3-2. DTO
DTO도 디미터의 법칙을 신경 쓸 필요가 없습니다. 이는 객체기는 하지만 특수한 객체로, 순순히 데이터 전달만 담당하기 때문입니다.
1
2
3
4
class UserResponse {
private final Long id;
private final String name;
}
3-3. Stream API
Stream API 같은 경우에는 동일한 Stream으로 변환하여 반환할 뿐, 캡슐화는 그대로 유지하므로 신경 쓸 필요가 없습니다.
1
2
3
4
5
6
7
8
class Main {
public static void main(String[] args) {
List<User> users = new ArrayList<>();
List<Long> ids = users.stream()
.map(User::getId)
.toList();
}
}
4. 정리
디미터의 법칙에 대해 다시 한번 정리해 보았습니다. 디미터의 법칙은 객체의 내부 구현을 숨겨서 캡슐화를 유지하고, 객체간의 결합도를 낮춥니다. 하지만 자료구조, DTO, Stream API와 같이 예외 케이스도 존재하기 때문에, 자신이 잘 판단해서 이를 적용하도록 합니다.