Table of contents
TDA — 묻지 말고 시켜라
getter 중독
TDA(Tell, Don’t Ask)는 이름 그대로다. 객체에게 상태를 물어본 뒤 호출자가 판단하지 말고, 객체에게 행동을 지시하라는 원칙이다.
실무 코드에서 가장 흔하게 보이는 TDA 위반은 getter 남용이다. 아래 코드를 보자.
주문에 할인을 적용하는 로직이다.
public class OrderService {
public void applyDiscount(Order order) {
if (order.getTotalPrice() > 100_000) {
double discounted = order.getTotalPrice() * 0.9;
order.setTotalPrice(discounted);
}
}
}
Order의 상태를 꺼내서(getTotalPrice), OrderService가 판단하고(> 100_000), 다시 값을 넣어준다(setTotalPrice). Order는 그저 데이터를 담는 그릇으로 전락했다. 이 패턴이 퍼지면 비즈니스 로직이 서비스 계층 여기저기에 흩어지고, Order 클래스는 getter/setter 덩어리가 된다.
행동을 맡기면 달라진다
같은 기능을 TDA 방식으로 바꿔보자.
할인 판단과 적용을 Order 내부로 옮긴다.
public class Order {
private double totalPrice;
public void applyDiscountIfEligible() {
if (this.totalPrice > 100_000) {
this.totalPrice *= 0.9;
}
}
}
호출하는 쪽은 단순해진다.
public class OrderService {
public void applyDiscount(Order order) {
order.applyDiscountIfEligible();
}
}
OrderService는 할인 조건을 알 필요가 없다. Order에게 할인을 적용해라고 시키기만 하면 된다. 할인 기준이 10만 원에서 5만 원으로 바뀌어도 Order 내부만 수정하면 된다.
의존 흐름의 변화
TDA 적용 전후의 의존 흐름을 비교하면 차이가 명확하다.
flowchart LR
subgraph before["TDA 적용 전 — 상태를 꺼내서 판단"]
direction LR
SVC1["OrderService"] -->|"getTotalPrice()"| ORD1["Order"]
SVC1 -->|"setTotalPrice()"| ORD1
SVC1 -.-|"할인 로직이<br/>Service에 존재"| SVC1
end
적용 전에는 OrderService가 Order의 내부 상태를 알아야 한다. getter로 꺼내고, 조건을 판단하고, setter로 다시 넣는다. 서비스가 도메인 객체의 세부사항에 깊이 결합된 구조다.
flowchart LR
subgraph after["TDA 적용 후 — 행동을 위임"]
direction LR
SVC2["OrderService"] -->|"applyDiscountIfEligible()"| ORD2["Order"]
ORD2 -.-|"할인 로직이<br/>Order 내부에 캡슐화"| ORD2
end
적용 후에는 OrderService가 보내는 메시지가 하나로 줄었다. Order의 내부 구조가 바뀌어도 서비스 코드에는 영향이 없다.
더 복잡한 예시 — 결제 검증
좀 더 실무에 가까운 상황을 보자. 결제를 처리하기 전 회원 등급과 잔액을 확인하는 코드다.
getter로 상태를 꺼내 판단하는 방식이다.
public class PaymentService {
public void processPayment(Member member, int amount) {
if (member.getGrade() == Grade.BLOCKED) {
throw new IllegalStateException("차단된 회원");
}
if (member.getBalance() < amount) {
throw new IllegalStateException("잔액 부족");
}
member.setBalance(member.getBalance() - amount);
}
}
PaymentService가 Member의 등급 체계, 잔액 구조를 모두 알고 있다. Member의 필드가 하나 바뀌면 이 서비스도 바뀌어야 한다.
TDA를 적용하면 Member에게 결제 가능 여부 판단과 잔액 차감을 맡긴다.
public class Member {
private Grade grade;
private int balance;
public void pay(int amount) {
if (this.grade == Grade.BLOCKED) {
throw new IllegalStateException("차단된 회원");
}
if (this.balance < amount) {
throw new IllegalStateException("잔액 부족");
}
this.balance -= amount;
}
}
서비스는 한 줄로 끝난다.
public class PaymentService {
public void processPayment(Member member, int amount) {
member.pay(amount);
}
}
비즈니스 규칙이 Member 안에 응집되었다. 등급 체계가 BLOCKED, SUSPENDED, ACTIVE로 세분화되더라도 PaymentService는 수정할 게 없다.
TDA의 핵심
TDA를 한 문장으로 요약하면 이렇다. 객체에게 상태를 묻고 호출자가 결정하는 대신, 객체에게 결정과 행동을 함께 맡겨라. 이것이 캡슐화의 본질이기도 하다. 데이터와 그 데이터를 다루는 로직이 같은 곳에 있어야 한다.
다만 모든 getter가 나쁜 것은 아니다. 뷰에 데이터를 전달하거나, 로깅을 위해 상태를 읽는 것은 자연스럽다. 문제는 getter로 상태를 꺼낸 뒤 호출자가 비즈니스 판단을 하는 패턴이다. 이 패턴이 보이면 TDA 위반을 의심해야 한다.
디미터 법칙 — 낯선 객체에게 말 걸지 않기
한 점만 찍어라의 진짜 의미
디미터 법칙(Law of Demeter, LoD)은 1987년 노스이스턴 대학교의 디미터 프로젝트에서 이름을 따온 설계 원칙이다. 핵심은 간단하다. 객체는 자기가 직접 아는 친구에게만 메시지를 보내야 한다.
좀 더 형식적으로 말하면, 메서드 M이 메시지를 보낼 수 있는 대상은 다음으로 한정된다.
M이 속한 객체 자신 (this)M의 매개변수로 전달된 객체M안에서 직접 생성한 객체M이 속한 객체의 필드
이 규칙을 어기는 대표적인 코드가 메서드 체인이다.
디미터 법칙을 위반하는 전형적인 예다.
// 고객의 도시 이름을 가져온다
String city = customer.getAddress().getCity().getName();
customer는 직접 아는 친구다. 하지만 getAddress()가 반환한 Address는? customer의 친구의 친구다. getCity()가 반환한 City는 친구의 친구의 친구다. 낯선 객체의 내부를 줄줄이 탐색하고 있다.
한 점만 찍어라(Only use one dot)라는 격언이 여기서 나온다. 하지만 이 격언은 문자 그대로 받아들이면 안 된다. 점(.)의 개수가 아니라 객체의 내부 구조를 얼마나 드러내느냐가 기준이다.
이 코드는 점이 여러 개지만 디미터 법칙 위반이 아니다.
String result = "hello"
.trim()
.toUpperCase()
.substring(0, 3);
String의 메서드는 매번 새 String을 반환한다. 내부 구조를 탐색하는 게 아니라 같은 타입에 대해 연산을 이어가는 것이다. 이런 패턴을 메서드 체이닝(method chaining) 또는 플루언트 인터페이스(fluent interface)라 부른다. Java의 Stream API나 Kotlin의 스코프 함수도 마찬가지다.
위반의 구조
디미터 법칙 위반이 만드는 의존 구조를 다이어그램으로 보면 문제가 선명해진다.
flowchart LR
subgraph violation["디미터 법칙 위반 — 내부 구조를 줄줄이 탐색"]
direction LR
SVC["Service"] -->|"getAddress()"| CUST["Customer"]
SVC -->|"getCity()"| ADDR["Address"]
SVC -->|"getName()"| CITY["City"]
end
style ADDR fill:#ff6b6b,color:#fff
style CITY fill:#ff6b6b,color:#fff
Service가 Customer, Address, City 세 객체의 구조를 모두 알고 있다. Address의 내부 구조가 바뀌면? City의 이름 필드가 label로 변경되면? Service가 깨진다. 변경의 파급 범위가 넓다.
디미터 법칙을 지킨 구조는 다르다.
flowchart LR
subgraph compliant["디미터 법칙 준수 — 직접 아는 친구에게만"]
direction LR
SVC2["Service"] -->|"getCityName()"| CUST2["Customer"]
CUST2 -->|"내부에서 위임"| ADDR2["Address"]
ADDR2 -->|"내부에서 위임"| CITY2["City"]
end
style ADDR2 fill:#51cf66,color:#fff
style CITY2 fill:#51cf66,color:#fff
Service는 Customer만 안다. Address와 City의 존재를 알 필요가 없다.
리팩토링
위반 코드를 고치는 방법은 위임 메서드를 만드는 것이다.
Customer에 위임 메서드를 추가한다.
public class Customer {
private Address address;
public String getCityName() {
return address.getCityName();
}
}
public class Address {
private City city;
public String getCityName() {
return city.getName();
}
}
호출하는 쪽은 깔끔해진다.
String city = customer.getCityName();
한 점만 찍었고, Service는 Customer의 내부 구조를 모른다. Address가 Region으로 바뀌든, City에 code 필드가 추가되든, Service에는 영향이 없다.
위임 메서드의 비용
그러면 위임 메서드만 잔뜩 늘어나는 거 아니냐는 반론이 있다. 맞는 지적이다. Customer.getCityName(), Customer.getZipCode(), Customer.getStreet() — 이런 메서드가 열 개, 스무 개 쌓이면 Customer가 비대해진다.
이런 상황이 발생한다면 두 가지를 점검해야 한다.
첫째, 호출자가 정말로 그 데이터를 필요로 하는가? 도시 이름을 꺼내서 뭘 하려는 건지 생각해 봐야 한다. 배송비를 계산하려는 거라면 customer.calculateShippingFee()가 더 나은 설계일 수 있다. TDA와 연결되는 지점이다.
둘째, 객체의 책임 분배가 잘못된 것은 아닌가? Customer에 너무 많은 정보가 몰려 있다면 SRP 관점에서 분리를 고려해야 한다.
디미터 법칙은 맹목적으로 따르는 법률이 아니다. 내부 구조에 대한 지식이 퍼져나가면 변경이 어려워진다는 경고등이다. 점의 개수가 아니라, 변경의 파급 범위를 기준으로 판단해야 한다.
CQS — 질문과 명령을 섞지 않기
하나의 메서드, 두 가지 역할
CQS(Command-Query Separation)는 Bertrand Meyer가 Object-Oriented Software Construction에서 제안한 원칙이다. 규칙은 명쾌하다.
모든 메서드는 명령(Command) 이거나 쿼리(Query) 여야 한다. 둘 다여서는 안 된다.
- Command: 상태를 변경한다. 반환값이 없다(
void) - Query: 값을 반환한다. 상태를 변경하지 않는다(부수효과 없음)
왜 이렇게 나눠야 할까? 메서드를 호출했을 때 예측 가능성이 달라지기 때문이다. 쿼리는 몇 번을 호출해도 결과가 같고 시스템 상태가 변하지 않는다. 안심하고 호출할 수 있다. 명령은 상태를 바꾸니까 호출 순서와 횟수에 주의해야 한다.
이 둘이 섞이면 문제가 생긴다.
CQS를 위반하는 Stack의 pop 메서드다.
public class Stack<T> {
private final List<T> elements = new ArrayList<>();
// 명령 + 쿼리가 합쳐져 있다
public T pop() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1); // 상태 변경 + 값 반환
}
}
pop()은 맨 위 원소를 반환하면서 동시에 제거한다. 질문과 명령이 하나로 합쳐진 전형적인 CQS 위반이다. “맨 위에 뭐가 있지?”라고 물어봤는데, 물어보는 행위 자체가 상태를 바꿔버린다.
분리하면 어떻게 되는가
CQS를 적용해 명령과 쿼리를 나눈다.
public class Stack<T> {
private final List<T> elements = new ArrayList<>();
// Query — 상태를 변경하지 않고 값을 반환한다
public T peek() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.get(elements.size() - 1);
}
// Command — 상태를 변경하고 반환값이 없다
public void remove() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
elements.remove(elements.size() - 1);
}
}
peek()은 몇 번을 호출해도 스택이 변하지 않는다. remove()는 반환값이 없으니 상태를 바꿀 것이다라고 시그니처만 봐도 예상할 수 있다.
사용하는 쪽에서는 의도가 분명해진다.
T top = stack.peek(); // 상태 변경 없음 — 안전하게 호출
doSomething(top);
stack.remove(); // 명시적으로 상태 변경
실무에서 마주치는 CQS 위반
Stack.pop()은 교과서적인 예다. 실무에서 더 자주 마주치는 위반 패턴을 몇 가지 보자.
저장하면서 ID를 반환하는 메서드다.
public class UserRepository {
public Long save(User user) {
// DB에 INSERT (상태 변경)
// 생성된 ID 반환 (쿼리)
return generatedId;
}
}
save()는 명령이면서 쿼리다. 엄밀한 CQS에 따르면 save()는 void를 반환하고, ID를 알고 싶으면 별도 쿼리로 조회해야 한다. 하지만 이 경우 CQS를 엄격히 적용하면 실용적이지 않다. DB에 INSERT 후 바로 ID가 필요한 건 너무 흔한 패턴이기 때문이다.
Meyer 본인도 이 점을 인정했다. CQS는 원칙이지 절대 법칙이 아니다. 중요한 것은 “이 메서드가 상태를 바꾸는가, 안 바꾸는가?”를 호출자가 명확히 알 수 있어야 한다는 점이다.
또 하나, 동시성 환경에서 자주 보이는 패턴이다.
putIfAbsent는 없으면 넣고, 기존 값이 있으면 그 값을 반환한다.
V existingValue = map.putIfAbsent(key, newValue);
이것도 엄밀히는 CQS 위반이지만, 원자성(atomicity)을 보장하기 위해 의도적으로 합친 것이다. 명령과 쿼리를 분리하면 사이에 다른 스레드가 끼어들 수 있다.
CQS와 CQRS
CQS와 이름이 비슷한 CQRS(Command-Query Responsibility Segregation)는 CQS를 아키텍처 수준으로 확장한 패턴이다. CQS가 메서드 단위에서 명령과 쿼리를 분리한다면, CQRS는 시스템 수준에서 쓰기 모델과 읽기 모델을 아예 분리한다. 쓰기 전용 DB와 읽기 전용 DB를 따로 두는 식이다.
CQS를 이해하면 CQRS의 동기를 파악하기가 쉬워진다. 메서드 단위에서 질문과 명령을 분리했을 때의 이점 — 예측 가능성, 테스트 용이성, 추론의 단순화 — 을 시스템 전체로 끌어올린 것이기 때문이다.
세 원칙의 공통 방향
TDA, 디미터 법칙, CQS. 출발점이 다르고 강조점도 다르지만, 세 원칙이 겹치는 영역이 있다.
| 원칙 | 핵심 메시지 | 금지하는 것 |
|---|---|---|
| TDA | 상태를 묻지 말고 행동을 시켜라 | getter로 꺼낸 뒤 호출자가 판단하는 패턴 |
| 디미터 법칙 | 직접 아는 친구에게만 말 걸어라 | 객체 내부 구조를 줄줄이 탐색하는 메서드 체인 |
| CQS | 질문과 명령을 섞지 마라 | 상태 변경과 값 반환을 하나의 메서드에 합치는 것 |
공통 방향은 객체의 자율성 존중이다. 객체가 자기 데이터에 대한 결정권을 갖고, 외부는 내부를 들여다보지 않으며, 메시지의 의도가 명확해야 한다.
이 원칙들은 SOLID와도 자연스럽게 연결된다.
- TDA는 SRP와 맞닿는다. 비즈니스 로직이 서비스에 흩어지는 대신 도메인 객체에 응집되니까
- 디미터 법칙은 ISP와 통한다. 객체가 알아야 할 인터페이스를 최소화한다는 점에서
- CQS는 OCP를 돕는다. 쿼리가 부수효과 없이 동작하면, 쿼리를 조합해 새로운 기능을 확장하기 쉬우니까
결국 좋은 객체지향 설계의 지향점은 같다. 객체 간 결합을 느슨하게 유지하면서, 각 객체의 책임을 명확히 하는 것. SOLID가 구조의 뼈대를 잡는다면, TDA·디미터 법칙·CQS는 그 뼈대 위에서 객체가 대화하는 방식을 다듬는다.
Kotlin에서는 이 원칙들이 언어 차원에서 잘 지원된다. 데이터 클래스의 copy()는 불변성을 유지하면서 상태 변경을 표현하고, 확장 함수는 디미터 법칙을 지키면서도 풍부한 API를 제공하며, val과 var의 구분은 CQS적 사고와 궤를 같이한다.
TDA·디미터 법칙·CQS를 Kotlin으로 간결하게 정리한 코드다.
// TDA — 행동을 맡긴다
class Order(private var totalPrice: Long) {
fun applyDiscountIfEligible() {
if (totalPrice > 100_000) totalPrice = (totalPrice * 0.9).toLong()
}
}
// 디미터 법칙 — 친구에게만 말 건다
class Customer(private val address: Address) {
fun getCityName(): String = address.getCityName()
}
// CQS — 질문과 명령을 분리한다
class Wallet(private var balance: Long) {
fun getBalance(): Long = balance // Query
fun withdraw(amount: Long) { // Command
require(balance >= amount) { "잔액 부족" }
balance -= amount
}
}
다음 편에서는 이 시리즈의 마지막 주제, 상속과 조합을 다룬다. 상속보다 조합을 선호하라는 GoF의 조언이 왜 나왔는지, 상속이 정말 적절한 경우는 언제인지를 살펴본다.




Loading comments...