
메서드를 어디에 둬야 할지 헷갈리는 순간은 대개 비슷합니다. Order에 둘지, OrderService에 둘지, 아니면 별도 정책 객체로 뺄지 애매할 때입니다. 이 글에서는 서비스에 로직이 몰리는 문제와 빈약한 도메인 객체 문제를 함께 보면서, 객체지향 책임 배치 기준을 실전 감각으로 정리하겠습니다.
메서드를 어디에 둬야 할지
객체지향에서 책임을 나눈다는 말은 거창한 이론이 아닙니다. 실제로는 이 판단은 누가 가장 잘 아는가, 이 규칙이 바뀌면 누가 같이 바뀌는가, 누가 맡아야 다른 코드가 덜 참견하게 되는가를 묻는 일에 가깝습니다.
클래스 이름이 그럴듯하다고 책임이 맞는 것은 아닙니다. 더 중요한 것은 상태와 판단이 얼마나 가까이 붙어 있느냐입니다.
흔한 실패 패턴
메서드 위치를 잘못 잡을 때 가장 흔한 모습은 도메인 객체는 데이터만 들고 있고, 진짜 판단은 서비스가 전부 대신하는 구조입니다. 예를 들어 주문 취소 가능 여부를 판단한다고 해보겠습니다.
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("이미 배송된 주문은 취소할 수 없습니다.")
}
// 취소 처리
}
}처음에는 편하지만 규칙이 늘어날수록 OrderService는 주문 내부 사정을 점점 더 많이 알게 됩니다. 반면 Order는 자기 상태를 가장 많이 가지고 있으면서도 정작 자기 상태에 대한 판단을 하지 못합니다. 이런 구조는 Martin Fowler가 설명한 anemic domain model과 닮아 있습니다.
데이터 근접성
메서드를 어디에 둘지 헷갈리면 먼저 데이터를 보세요. 그 판단에 필요한 데이터를 가장 많이 들고 있는 객체가 누구인지 확인하는 겁니다. 주문 취소 가능 여부를 판단할 때 필요한 것은 주문 상태, 결제 여부, 배송 여부입니다.
class Order(
private var status: OrderStatus,
private val paid: Boolean,
private val shipped: Boolean
) {
fun cancel() {
validateCancelable()
status = OrderStatus.CANCELED
}
private fun validateCancelable() {
if (status != OrderStatus.CREATED) {
throw IllegalStateException("이미 진행된 주문입니다.")
}
if (paid && shipped) {
throw IllegalStateException("이미 배송된 주문은 취소할 수 없습니다.")
}
}
}이제 취소 규칙은 주문 객체 가까이에 있습니다. 다른 코드가 주문 내부 사정을 길게 들여다보지 않아도 됩니다. 이런 방향은 Tell-Don’t-Ask가 말하는 감각과도 잘 맞습니다. 즉, 데이터를 꺼내 외부에서 판단하기보다 객체에게 일을 시키는 쪽입니다.
변경 축
데이터만 보면 부족합니다. 함께 바뀌는 것도 같이 봐야 합니다. 좋은 책임 배치는 대개 함께 바뀌는 것들을 가까이 둡니다.
- 주문 상태 규칙과 함께 바뀌면 Order 쪽이 더 자연스럽다
- 할인 정책 종류가 계속 늘어나면 DiscountPolicy 같은 별도 역할이 더 자연스럽다
- 외부 결제 API 호출 순서를 조정하는 문제라면 OrderService가 더 자연스럽다
즉, 메서드 위치는 객체지향 예절이 아니라 변경 이유의 묶음으로 판단하는 편이 정확합니다.
서비스와 객체 경계
그렇다고 모든 메서드를 엔티티 안으로 넣으면 되는 것은 아닙니다. 서비스가 나쁜 것이 아니라, 서비스가 도메인 객체의 내부 판단까지 다 대신할 때 문제가 커집니다.
- 여러 객체를 묶어 한 흐름을 조정하는 일
- 저장, 메시징, 외부 API 호출 순서를 관리하는 일
- 트랜잭션 경계를 조립하는 일
- 도메인 규칙보다 애플리케이션 유스케이스를 실행하는 일
class OrderService(
private val orderRepository: OrderRepository,
private val paymentGateway: PaymentGateway
) {
fun cancel(orderId: Long) {
val order = orderRepository.findById(orderId)
order.cancel()
paymentGateway.refund(order.refundAmount())
orderRepository.save(order)
}
}여기서 OrderService는 흐름을 조정합니다. 하지만 주문 취소 가능 여부 같은 핵심 판단은 Order가 맡습니다. 이 구분이 잡히면 서비스는 덜 비대해지고 객체는 덜 빈약해집니다.
정책 객체 기준
어떤 로직은 엔티티보다 정책 객체에 두는 편이 더 좋습니다. 대표적으로 규칙 종류가 자주 늘어나고 교체 가능성이 큰 경우입니다. 예를 들어 할인 규칙이 정액 할인, 등급 할인, 쿠폰 할인처럼 계속 늘어난다면 할인 계산은 별도 역할로 빼는 편이 낫습니다.
interface DiscountPolicy {
fun discount(order: Order, customer: Customer): Money
}
class VipDiscountPolicy : DiscountPolicy {
override fun discount(order: Order, customer: Customer): Money {
return if (customer.isVip()) order.totalPrice().percent(10) else Money.zero()
}
}핵심은 모든 책임을 한곳에 몰아넣지 않는 것입니다. 변경 방향이 다른 책임을 분리하는 것도 좋은 책임 배치의 일부입니다.
Feature Envy 신호
메서드 위치가 이상한지 빠르게 판단하는 좋은 힌트가 있습니다. 어떤 메서드가 자기 데이터보다 다른 객체의 getter를 더 많이 만지는지 보는 겁니다. Refactoring.Guru는 이런 냄새를 Feature Envy라고 설명합니다.
class ShippingService {
fun calculateFee(order: Order): Int {
if (order.destination().isJeju() && order.totalWeight() > 5) {
return 8000
}
if (order.destination().isRemoteArea()) {
return 6000
}
return 3000
}
}물론 서비스가 무조건 잘못이라는 뜻은 아닙니다. 하지만 배송비 계산이 주문 목적지, 무게, 배송 옵션과 함께 자주 바뀐다면 이 계산이 정말 서비스에 있어야 하는지 다시 볼 수 있습니다.
실전 체크리스트
- 이 메서드가 가장 많이 읽는 데이터는 누구 것인가
- 이 규칙이 바뀌면 어떤 객체가 같이 바뀌는가
- 외부에서 getter를 여러 번 꺼내 조합하고 있지는 않은가
- 이 책임은 도메인 판단인가, 흐름 조정인가, 정책 교체인가
- 이 메서드를 옮기면 호출하는 쪽이 더 단순해지는가
짧게 줄이면 이렇습니다. 가장 많이 알고, 함께 바뀌고, 남의 데이터를 덜 훔쳐보게 만드는 쪽으로 메서드를 둔다. 이 기준이 있으면 무턱대고 서비스에 몰아넣는 습관을 꽤 많이 줄일 수 있습니다.
예외와 균형
조회 전용 모델, 통계 집계, 외부 시스템 조합처럼 데이터를 읽어 보여주는 책임은 꼭 도메인 객체 내부로 들어가지 않아도 됩니다. 또 Tell-Don’t-Ask도 절대 규칙은 아닙니다. 중요한 것은 getter를 썼느냐가 아니라, 핵심 판단이 불필요하게 바깥으로 새고 있느냐입니다.
마무리
객체지향에서 책임을 잘 나누는 기준은 생각보다 실무적입니다. 메서드를 어디에 둘지 헷갈릴 때는 클래스 이름보다 누가 그 데이터를 가장 잘 아는가, 무엇이 함께 바뀌는가, 누가 맡아야 바깥 코드가 덜 참견하게 되는가를 먼저 보세요.
관련 글로는 객체지향은 왜 클래스 많이 만드는 기술이 아닐까, 객체지향 설계에서 결합도가 중요한 이유, 상속 vs 조합: inheritance와 composition 비교를 이어서 보면 흐름이 더 잘 잡힙니다.