
객체는 데이터를 담는 상자가 아니다라는 말은 객체지향을 이해할 때 꽤 중요한 출발점입니다. 객체를 데이터 통으로만 보면 왜 서비스에 로직이 몰리고 설계가 답답해지는지 잘 보이지 않기 때문입니다. 이 글에서는 상태와 행위를 함께 본다는 뜻, DTO와 도메인 객체 차이, 실무에서 메서드를 어디에 두면 자연스러운지를 쉬운 예시로 정리하겠습니다.
객체는 데이터를 담는 상자가 아니다
객체지향에서 객체를 본다는 말은 필드 목록만 본다는 뜻이 아닙니다. 그 객체가 어떤 상태를 가지고 있고, 그 상태를 바꾸거나 검증하는 책임을 누가 가져야 하는지까지 같이 보는 겁니다.
예를 들어 계좌에는 잔액이라는 상태가 있습니다. 그렇다면 출금 가능 여부를 확인하고, 잔액을 줄이고, 허용되지 않는 출금을 막는 규칙은 계좌와 꽤 가깝습니다. 상태와 함께 자주 바뀌는 규칙이라면 그 상태 가까이에 두는 편이 자연스럽다고 이해하면 감이 빨리 잡힙니다.
데이터 통 패턴
객체를 데이터 통처럼 쓰는 코드는 처음에는 단순해 보입니다. 하지만 기능이 늘어나면 서비스 클래스가 객체 내부 사정을 점점 더 많이 알게 됩니다.
class Account(
var balance: Int
)
class AccountService {
fun withdraw(account: Account, amount: Int) {
if (amount <= 0) {
throw IllegalArgumentException("출금 금액은 0보다 커야 합니다.")
}
if (account.balance < amount) {
throw IllegalStateException("잔액이 부족합니다.")
}
account.balance -= amount
}
}이 구조에서는 AccountService가 계좌 규칙을 점점 더 많이 알게 됩니다. 반면 Account는 가장 중요한 상태를 들고 있으면서도 자기 규칙을 잘 다루지 못합니다. 이런 모습은 Martin Fowler가 설명한 Anemic Domain Model과 닮아 있습니다.
상태와 행위 결합
같은 예시를 객체 쪽으로 조금만 옮겨도 읽는 방식이 달라집니다. 이제 바깥에서는 잔액을 꺼내 와서 계산하는 대신, 계좌에게 출금하라고 말하면 됩니다.
class Account(
private var balance: Int
) {
fun withdraw(amount: Int) {
require(amount > 0) { "출금 금액은 0보다 커야 합니다." }
check(balance >= amount) { "잔액이 부족합니다." }
balance -= amount
}
fun currentBalance(): Int = balance
}이 감각은 Tell-Don’t-Ask와도 연결됩니다. 즉, 데이터를 계속 꺼내 외부에서 판단하기보다 객체에게 필요한 일을 시키는 쪽입니다.

DTO와 도메인 객체
여기서 DTO와 도메인 객체를 구분하는 것이 중요합니다. DTO는 프로세스 사이에서 데이터를 전달하기 위한 객체입니다. 즉, 핵심 역할이 판단이 아니라 전달입니다.
data class OrderSummaryResponse(
val orderId: Long,
val status: String,
val totalAmount: Int
)이런 DTO는 가볍게 데이터를 담아도 괜찮습니다. 반대로 주문 취소 가능 여부, 상태 전이, 수량 제한처럼 상태와 함께 움직이는 규칙이 있는 객체라면 단순 전달 상자에 머물지 않는 편이 낫습니다. 즉, DTO는 데이터를 옮기는 역할, 도메인 객체는 상태와 규칙을 함께 다루는 역할에 더 가깝습니다.
서비스 비대화
상태와 행위가 분리되면 가장 흔하게 생기는 문제는 서비스 비대화입니다. 주문 객체는 값만 들고 있고, 진짜 판단은 서비스가 전부 하는 구조를 떠올려보겠습니다.
class Order(
val status: OrderStatus,
val paid: Boolean,
val shipped: Boolean
)
class OrderService {
fun cancel(order: Order) {
if (order.status != OrderStatus.CREATED) {
throw IllegalStateException("이미 진행된 주문입니다.")
}
if (order.paid && order.shipped) {
throw IllegalStateException("이미 배송된 주문은 취소할 수 없습니다.")
}
// 취소 처리
}
}이 구조는 규칙이 늘어날수록 서비스가 주문에 관한 거의 모든 지식을 가져가게 만듭니다. 어떤 메서드가 자기 데이터보다 다른 객체의 getter를 훨씬 더 많이 만진다면 Feature Envy 신호로 의심해볼 수 있습니다.
책임 기준
- 이 규칙이 가장 많이 의존하는 상태는 누구 것인가
- 이 규칙이 바뀌면 누가 같이 바뀌는가
- 밖에서 getter를 여러 번 꺼내 조합하고 있지는 않은가
- 이 로직은 도메인 판단인가, 흐름 조정인가, 외부 연동인가
항상 정답이 하나로 고정되지는 않지만, 이 질문들만 잘 써도 메서드 위치가 꽤 선명해집니다. 상태와 함께 바뀌는 판단은 객체 가까이 두고, 여러 객체를 묶는 흐름은 서비스에 둔다고 이해하면 실무에서 훨씬 덜 헤맵니다.
실전 예시
- 주문 상태 검증, 취소 가능 여부, 수량 변경 제한은 주문 객체 쪽이 자연스럽다
- 결제 API 호출, 저장, 알림 발송 순서 조정은 서비스가 맡아도 괜찮다
- 조회 전용 응답 모델은 DTO처럼 가볍게 유지해도 된다
이 구분이 잡히면 객체는 데이터만 있는 상자가 아니게 되고, 서비스도 모든 것을 다 아는 거대한 클래스가 되는 것을 막기 쉬워집니다.
예외와 균형
중요한 것은 모든 객체에 로직을 많이 넣으라는 뜻이 아니라는 점입니다. 조회 전용 모델, 통계 집계, 화면 표시용 응답 모델처럼 애초에 전달과 표현이 목적이라면 데이터 중심이어도 전혀 이상하지 않습니다.
더 정확히는 도메인 규칙까지 필요한 객체를 데이터 통처럼만 다루면 설계가 점점 불편해진다고 보는 편이 맞습니다.
마무리
객체는 데이터를 담는 상자가 아닙니다. 객체지향에서 더 중요한 것은 상태와 행위를 함께 보면서 누가 자기 규칙을 맡아야 자연스러운지 판단하는 일입니다.
관련 글로는 객체지향 설계의 핵심, 객체지향 설계에서 결합도가 중요한 이유, 객체지향에서 책임을 잘 나누는 기준을 이어서 보면 흐름이 더 잘 잡힙니다.