디미터 법칙과 Tell, Don't Ask 로 만드는 자율적인 객체 설계
이 글에서 얻어갈 것들
- Anemic Domain Model (빈혈 도메인 모델이 무엇인지, 그리고 내 코드가 이미 그 상태인지 알게 된다.
- 디미터 법칙과 Tell, Don't Ask를 추상적 원인이 아니라 코드 한 줄 단위에서 적용할 수 있게 된다.
- Feature Envy, 열차 충돌 같은 코드 냄새를 감지하는 기준과 코드 패턴을 갖게 된다
- Spring + JPA 환경에서 이 원칙들이 @Transactional, Dirty Checking과 어떻게 연결되는지 이해하게 된다
- 규칙을 언제 지키고 언제 의도적으로 어겨야 하는지 — 이유를 설명할 수 있는 판단 기준을 얻는다
프폴로그 - 이미 짜고 있을지도 모른다
Spring 기반 프로젝트에서 아래 패턴, 한 번쯤 써봤을 것이다.
@Service
public class OrderService {
@Transactional
public void shipOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.getStatus() != OrderStatus.PAID) {
throw new IllegalStateException("결제 완료 상태에서만 배송 가능합니다.");
}
order.setStatus(OrderStatus.SHIPPED);
order.setShippedAt(LocalDateTime.now());
}
}
동작한다. 테스트도 가능하다. 그런데 시간이 지나면 문제가 드러난다.
"배송 가능 조건" 이 바뀌면 어디를 찾아가야 하는가? Order 가 아니라 OrderService 다. 이 로직이 다른 서비스에도 흩어져 있다면?
Order를 열어보면 getter/setter 만 있고 비즈니스 로직은 단 한줄도 없다.
이것이 Anemic Domain Model(빈혈 도메인 모델)이다.
1. 빈혈 도메인 모델의 본질
Martin Fowler 가 2003년 명명한 안티패턴이다.
// Anemic Domain Model — 상태는 있지만 행동이 없다
public class Order {
private OrderStatus status;
private LocalDateTime shippedAt;
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus status) { this.status = status; }
public LocalDateTime getShippedAt() { return shippedAt; }
public void setShippedAt(LocalDateTime shippedAt) { this.shippedAt = shippedAt; }
}
이 객체는 상태만 있고 행동이 없다. 겉보기에는 객체지향 같지만 실제 구조는 이렇다.
- 데이터 -> Entity
- 로직 -> Service (절차지향 트랜잭션 스트립트)
반면 Rich Domain Model은 행동이 데이터와 함께 있다.
// Rich Domain Model — 스스로 판단하고 행동한다
public class Order {
private OrderStatus status;
private LocalDateTime shippedAt;
public void ship() {
if (this.status != OrderStatus.PAID) {
throw new IllegalStateException("결제 완료 상태에서만 배송 가능합니다.");
}
this.status = OrderStatus.SHIPPED;
this.shippedAt = LocalDateTime.now();
}
}
Order 를 열면 배송인 언제 가능한지 바로 알 수 있다. 로직이 데이터 곁에 있다.
Anemic Domain Model과 Rich Domain Model 의 차이는 getter/setter 의 유무가 아니다. "이 객체에 대한 결정을 이 객체가 내리고 있는가" 다. 그리고 더 중요한 건, "그 결정이 외부에서 중복되고 있는가"다. 빈혈 도메인 모델의 진짜 위험은 로직이 여러 Service에 흘어져 일관성을 잃는 것이다.
2. 문제 정의 - 책임이 섞이면 테스트가 무너진다.
게임 코드의 tryMove를 보자.
// Game.java
private void tryMove(Direction direction) {
if (worldMap.isBlocked(position.shift(direction))) {
showBlocked();
} else {
position = position.shift(direction);
showRoom();
}
}
이 메서드 안에는 세 가지 서로 다른 관심사가 섞여 있다.
- 비즈니스 로직 - 이동 가능 여부 판단
- 상태 변경 - position 업데이트
- 외부 출력 - 콘솔에 결과 출력
결과적으로 테스트는 이렇게 된다.
// 로직 검증이 아니라 "환경 제어"가 된다
OutputStream output = new ByteArrayOutputStream();
System.setOut(new PrintStream(output));
player.tryMove(Direction.EAST);
assertThat(output.toString()).containsSequence("당신은 [(1,0)]에 있습니다.\n", " 방 (1,0)");
픽스처(초기 상태)와 결과의 관계가 눈에 들어오지 않는다. 플레이어 위치가 바뀌었는지 검증하는게 아니라, 콘솔이 어떤 문자열이 출력됐는지 검증하고 있다.
테스트가 어려운 이유는 private이기 때문이 아니다. 책임과 외부 의존성이 섞여 있기 때문이다. private 은 증상이고, 설계 과부하가 원인이다.
3. 1차 리펙토링 - 책임의 주인을 바꾼다.
tryMove는 플레이어를 이동시킨다. 플레이어 스스로가 이 책임을 져야 한다.
메서드를 이동시킬 때는 그 메서드가 접근하는 인스턴스 변수와 관련 메서드도 함께 이동해야 한다.
// Player.java — 1차 분리
public class Player {
private WorldMap worldMap;
private Position position;
public void tryMove(Direction direction) {
if (worldMap.isBlocked(position.shift(direction))) {
showBlocked();
} else {
position = position.shift(direction);
showRoom();
}
}
private void showRoom() {
System.out.println("당신은 [" + worldMap.roomAt(position).name() + "]에 있습니다.");
}
private void showBlocked() {
System.out.println("이동할 수 없습니다.");
}
}
책임은 이동했다. 하지만 문제는 그대로다. 콘솔 의존성을 함께 끌고 왔기 때문이다.
테스트는 여전히 콘솔 스트림을 가로채야 한다.
책임을 올바른 클래스로 옮기는 것은 첫 번째 단계다. 그 다음에 나쁜 의존성까지 함께 따라왔는지 확인해야 한다. 메서드를 옮기면서 문제의 위치만 바뀐 것인지, 실제로 개선됐는지를 테스트가 알려준다.
4. 2차 리펙토링 - 외부 의존성 제거
System.out은 정적 멤버다. 외부에서 교체하거나 주입할 수 없다. 이게 테스트를 어렵게 만드는 근본 원인이다.
Player 에서 출력을 걷어내고, 이동 성공 여부만 반환하게 만든다.
// Player.java
public boolean move(Direction direction) {
if (worldMap.isBlocked(position.shift(direction))) {
return false;
}
this.position = this.position.shift(direction);
return true;
}
// Game.java — 흐름만 조율한다
public void tryMove(Direction direction) {
if (player.move(direction)) {
showRoom();
} else {
showBlocked();
}
}
테스트가 바뀐다.
// Before — 환경을 제어해야 했다
assertThat(output.toString()).containsSequence("당신은 [(1,0)]에 있습니다.\n", " 방 (1,0)");
// After — 상태를 직접 검증한다
assertThat(player.move(Direction.EAST)).isTrue();
assertThat(player.position()).isEqualTo(Position.of(1, 0));
단순하고 명확하다. 픽스처와 결과의 관계가 한눈에 들어온다.
테스트가 쉬어진 건 테스트 기술이 좋아진 게 아니다. 객체의 역할이 명확해졌기 때문이다. 테스트 용이성 목표가 아니라 좋은 설계의 결과물이다.
5. 3차 리팩토링 - 디미터 법칙: 내부를 드러내지 말라
showRoom 을 보면 새 문제가 보인다.
// Game.java — 열차 충돌(Train Wreck)
player.worldMap().roomAt(player.position()).name()
점(.)이 연이어 나오는 이 코드를 열차 충돌(Train Wreck)이라 부른다. 겉모습 문제가 아니다. 구조적 위험이다.
Game은 Player의 내부 구조를 모두 알고 있다. WorldMap이 있고, Posion이 있고, worldMap.roomAt(position)이 Room을 반환한다는 것까지. Player 내부 구조가 바뀌면 Game도 함께 바뀐다. 결합도가 높다.
그리고 실질적인 버그도 있다. player.worldMap().roomAt(player.position())를 두 번 호출하고 있다. 같은 내부 탐색을 중복 수행한다.
디미터 법칙: 오직 인접한 이웃에게만 말하라
// Player.java — 내부 구조를 캡슐화한다
public Room currentRoom() {
return worldMap.roomAt(position);
}
// Game.java
public void showRoom() {
Room room = player.currentRoom(); // 한 번만 호출, 내부 구조 비노출
System.out.println("당신은 [" + room.name() + "]에 있습니다.");
System.out.println(room.description());
}
디미터 법칙의 본질은 점의 개수 줄이기가 아니다. 변경될 가능성이 있는 내부 구조를 숨기는 것이다. player.currentRoom().name()이 괜찮은 이유는 Room의 name()이 내부 구조가 아니라 Room의 공개 인터페이스이기 때문이다. 판단 기준은 점의 수가 아니라 내부 구조가 노출되는가다.
디미터 법칙을 따르는 코드를 부끄럼 타는 코드라고 부른다. 자기 내부를 드러내지 않는다는 뜻이다. 이 부끄럼이 캡슐화가 살아있는 증거다.
6. 4차 리팩토링 — Tell, Don't Ask: Feature Envy 제거
tryMove의 최초 형태를 다시 보자
// Game이 Player를 대신해서 판단한다 — Feature Envy
if (player.worldMap().isBlocked(player.position().shift(direction))) {
showBlocked();
} else {
player.move(player.position().shift(direction));
showRoom();
}
이 코드는 Player 상태를 꺼내 Game이 직접 판단하고, 판단 결과에 따라 Player의 상태를 변경한다. 이것을 Featur Envy(기능에 대한 부러움)라고 부른다. Fowler 의 Refactoring에서 정의한 코드 냄새다. Game이 Player의 데이터를 지나치게 부러워하며 가져다 쓰고 있는 것이다.
묻지 말고 시켜라(Tell, Don' Ask): 상태를 꺼내 외부에서 판단하는 대신, 객체에게 직접 수행하도록 위임하라.
// Player.java — 스스로 판단하고 행동한다
public boolean move(Direction direction) {
if (worldMap.isBlocked(position.shift(direction))) {
return false;
}
this.position = this.position.shift(direction);
return true;
}
// Game.java — 묻지 않고 시킨다
public void tryMove(Direction direction) {
if (player.move(direction)) {
showRoom();
} else {
showBlocked();
}
}
데이터와 그 데이터를 이용하는 로직이 서로 다른 클래스에 있으면., 그 로직은 반드시 중복된다. if (order.getStatus() == PAID)가 OrderService, ShippingService, NotificationService에 각각 흩어지는 것이 그 예다. GRASP의 Information Expert 원칙은 이 문제의 해법을 명확히 말한다. "어떤 책임을 수행하기 위해 필요한 정보를 가진 클래스에 그 책임을 부여하라."
7. 실무 적용 - Spring/JPA에서 이게 어떤 의미인가
나쁜 코드 - Servie 가 Entity를 대신해서 결정한다.
@Transactional
public void shipOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// Feature Envy: Service가 Order 상태를 꺼내 판단한다
if (order.getStatus() != OrderStatus.PAID) {
throw new IllegalStateException("결제 완료 상태에서만 배송 가능합니다.");
}
// Anemic Model: Order는 setter만 제공한다
order.setStatus(OrderStatus.SHIPPED);
order.setShippedAt(LocalDateTime.now());
// orderRepository.save(order) — 이걸 명시적으로 호출하는 코드도 많다
}
좋은 코드 - Entity가 스스로 결정하고 행동한다.
// Order.java
public void ship() {
if (this.status != OrderStatus.PAID) {
throw new IllegalStateException("결제 완료 상태에서만 배송 가능합니다.");
}
this.status = OrderStatus.SHIPPED;
this.shippedAt = LocalDateTime.now();
// "배송 가능 조건"이 바뀌면 이 메서드 하나만 수정한다
}
// OrderService.java
@Transactional
public void shipOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.ship(); // Order가 스스로 판단하고 상태를 변경한다
// orderRepository.save(order) 불필요
}
좋은 코드 - Entity가 스스로 결정하고 행동한다.
// Order.java
public void ship() {
if (this.status != OrderStatus.PAID) {
throw new IllegalStateException("결제 완료 상태에서만 배송 가능합니다.");
}
this.status = OrderStatus.SHIPPED;
this.shippedAt = LocalDateTime.now();
// "배송 가능 조건"이 바뀌면 이 메서드 하나만 수정한다
}
// OrderService.java
@Transactional
public void shipOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.ship(); // Order가 스스로 판단하고 상태를 변경한다
// orderRepository.save(order) 불필요
}
JPA Dirty Checking과의 연결
@Transactional 안에서 영속성 컨텍스트가 관리하는 Entity 의 상태가 변경되면, 트랜잭션 커밋 시점에 JPA가 자동으로 변경을 감지하고, UPDATE 쿼리를 실행한다. 이를 Dirty Checking 이라 한다.
order.ship() 한 줄이 내부 상태 (status, shippedAt)를 바꾸면 JPA 가 그 변경을 감지해서 save() 호출없이 DB에 반영한다.
Rich Domain Model 은 Dirty Checking과 자연스럽게 맞는다.
8. 코드 냄새 감지 체크 리스트
실제 코드를 짜거나 리뷰할 때 아래 팬턴이 보이면 멈추고 생각하라.
Feature Envy
// 이 패턴이 보이면 경고
if (something.getX() == SOME_CONDITION) {
something.setY(newValue);
}
somthing 에게 상태를 묻고, 판단하고, 다시 something 의 상태를 바꾼다. 이 판단과 변경 로직을 something 안으로 옮겨라.
something.doY() 형태로 위임할 수 있다면 거의 항상 그게 낫다.
열차 충돌 (Train Wreck)
// 점이 두 개 이상 연속으로 나오면 의심하라
a.getB().getC().doSomething();
a 가 B 를 갖고 있고, B 가 C 를 갖고 있다는 내부 구조가 외부에 노출됐다. B 와 C 사이에 뭔가 끼어들면 a를 사용하는 모든 곳이 바뀐다.
a 에게 doSomthingOnC() 메서드를 위임할 수 있는지 확인하라.
Anemic Domain Model
// Entity 파일을 열었을 때 이게 전부라면 경고
public class Order {
private OrderStatus status;
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus status) { this.status = status; }
}
Order에 관한 비즈니스 판단이 Service에 있지 않은지 찾아라. if (order.getStatus() == ...) 패턴이 Service 여러 곳에 흩어져 있다면 빈혈 상태다.
과도한 Mock 설정
// 테스트에서 Mock 설정이 4개 이상 필요하다면 경고
when(mockA.getB()).thenReturn(b);
when(mockB.getC()).thenReturn(c);
when(mockC.doSomething()).thenReturn(result);
when(mockRepository.findById(id)).thenReturn(Optional.of(order));
테스트 대상이 너무 많은 외부 의존성을 갖고 있거나, 디미터 법칙을 위반하고 있다는 신호다.
순수한 도메인 로직 테스트에는 Mock이 거의 필요 없어야 한다.
거대한 Service
Servive 메서드가 50줄을 넘거나, Service 클래스가 500 줄을 넘기 시작하면 거의 확실하게 Anemic Domain Model 상태다.
Service 가 Entity 대신 모든 결정을 내리고 있기 때문이다.
하나라도 보이면 설계를 다시 보자. 당장 고칠 수 없더라도, 다음 번 수정 시 개선할 포인트로 기록해두는 것만으로도 달라진다.
9. 언제 규칙을 어겨도 되는가
규칙을 외운 사람과 이해한 사람의 차이는 예외를 이유와 함께 말할 수 있는가에서 드러난다.
Builder / Fluent Interface — OK
new StringBuilder()
.append("Hello")
.append(", World")
.toString();
같은 타입을 반환하는 체이닝이다. 내부 구조가 바뀌어도 사용하는 쪽은 영향받지 않는다. 열차 충돌처럼 보이지만 내부 구조를 노출하지 않기 때문에 디미터 법칙 위반이 아니다.
Stream API — OK
list.stream()
.filter(Item::isActive)
.map(Item::getName)
.collect(Collectors.toList());
각 연산이 스트림 파이프라인의 공개 인터페이스를 통해 연결된다. 어떤 컬렉션인지 내부 구조가 노출되지 않는다.
조회용 getter — OK
// 판단하려는 게 아니라 표시하려는 것
String displayName = order.getCustomerName();
response.setName(displayName);
상태를 꺼내 표시하는 건 Tell, Don't Ask 위반이 아니다. 문제는 상태를 꺼내서 그 객체를 대신해 판단하고 변경할 때 발생한다.
디미터 법칙과 Tell, Don't Ask는 목적이 있는 원칙이다. 결합도를 낮추고 캡슐화를 지키기 위한 것이다. 이 목적에 부합하지 않는 경우에는 의도적으로 어겨도 된다. 중요한 건 "왜 어기는지 설명할 수 있는가"다. 설명할 수 있는 예외는 판단이고, 설명 못 하는 예외는 실수다.
10. 최종 구조
4단계의 리펙토링을 거쳐 두 클래스의 역할이 명확해졌다.
Player - 자율적인 도메인 객체
- 이동 가능 여부를 스스로 판단한다.
- 자신의 상태를 스스로 관리한다.
- 외부 의존성 (콘솔, DB) 이 없다.
- 테스트가 단순하다. Mock 없이 , 상태 기반으로 검증 가능하다.
Game - 흐름 조율자
- 사용자 입력을 받아 명령을 해석한다.
- Player에게 이동을 위임하고 결과에 따라 흐름을 결정한다.
- 콘솔 출력(외부 의존성)을 담당한다.
역할 분리 완료- 각자 하나의 이유로만 변경된다.
에필로그 - 누가 결정해야 하는가
이 모든 변화의 출발점은 기술이 아니었다.
"이 결정은 누가 내려야 하는가?"
이 질문 하나가 tryMove를 Player로 옮기게 했고, 콘솔 의존성을 분리하게 했고, Game이 묻는 대신 시키도록 만들었다.
- 나는 코드를 작성할 때 이 질문으로 설계를 검증한다.
- 테스트가 어색하다 -> 책임이 잘못된 위치에 있다
- 코드가 질문을 많이 한다 -> 객체가 수동적이다.
- 점이 연이어 나온다 -> 내부 구조가 노출되고 있다.
이 신호들을 읽는 눈이 생겼다면, 코드를 고치는 방향은 이미 보이기 시작한 것이다.
좋은 객체는 내부를 드러내지 않는다. 부끄럼을 탄다. 그리고 그 부끄럼이 캡슐화가 살아있다는 증거다.
'객체지향' 카테고리의 다른 글
| 질문이 답변을 수정해서는 안 된다 - CQS, 읽히는 코드를 만드는 원칙 (0) | 2026.04.21 |
|---|---|
| 테스트가 무거운 건 의지 문제가 아니라 설계 문제다 (0) | 2026.03.16 |
| 2-1 텍스트 어드벤처 _2 (1) | 2025.06.27 |
| 값 객체(Value Object)를 사용해야 할 때 – Game 클래스 리팩터링 예시 (0) | 2025.05.21 |