pandaterry's 개발로그

[OOP 안티패턴] God Object? 이 정도는 괜찮지 않나요 본문

개발/OOP

[OOP 안티패턴] God Object? 이 정도는 괜찮지 않나요

pandaterry 2025. 6. 23. 23:34

 

 

서비스 클래스가 점점 무거워질 때, 우리는 한 번쯤 이런 생각을 합니다.

“나는 오케스트레이션만 하니까 괜찮지 않나?”
“도메인 객체는 그냥 데이터 구조잖아.”
“유효성 검사는 서비스에서 다 하면 되는 거 아냐?”

 

실제로 몇몇 개발자들도 이런 구조를 문제 삼지 않고 넘기곤 합니다.

 

SRP를 지켰다거나, 도메인을 분리했다는 형식적 명분으로 충분하다고 느낄 수도 있기 때문입니다.

 

하지만 진짜 중요한 건 그 구조가 정말 도메인의 책임과 역할을 반영하고 있는가입니다.

 

이번 글에서는 흔히 "이 정도면 괜찮다"라고 여겨지는 코드들이 실제로는 어떤 문제를 유발할 수 있는지를 검토하고, 그에 대한 개선 방향을 구체적인 코드와 함께 살펴보았습니다.

 

총 4가지 예시를 준비해봤습니다  :)


 

예시 1. OrderService – "나는 오케스트레이션만 하니까 괜찮지 않나요?"

A 개발자 : SRP를 지켰어요. 각각 결제, 재고, 쿠폰 처리를 전담 객체에 위임했으니까요.”

B 개발자 : “서비스는 단지 조율자일 뿐이에요. OrderService는 흐름만 담당하고, 나머지는 각 도메인 로직이 처리하고 있죠.”
“그래서 이 정도면 괜찮은 거 아닐까요?”


초기 코드

public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentProcessor paymentProcessor;
    private final InventoryManager inventoryManager;
    private final CouponValidator couponValidator;
    private final NotificationService notifier;

    public OrderService(
            OrderRepository orderRepository,
            PaymentProcessor paymentProcessor,
            InventoryManager inventoryManager,
            CouponValidator couponValidator,
            NotificationService notifier
    ) {
        this.orderRepository = orderRepository;
        this.paymentProcessor = paymentProcessor;
        this.inventoryManager = inventoryManager;
        this.couponValidator = couponValidator;
        this.notifier = notifier;
    }

    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);

        paymentProcessor.process(order);
        inventoryManager.reserve(order);
        couponValidator.validate(order);

        order.markAsCompleted();
        orderRepository.save(order);

        notifier.sendOrderCompleted(order.getId());
    }
}

 

 

문제 1.

도메인 객체는 아무것도 하지 않고, 서비스가 모든 흐름을 알고 있습니다
paymentProcessor.process(order);
inventoryManager.reserve(order);
couponValidator.validate(order);

 

서비스가 모든 처리 순서를 결정하고 있으며, 각 객체는 단순히 “수행 대상”입니다.

 

즉, 결제 정책이 바뀌면 서비스가 바뀌고, 재고 처리 방식이 바뀌어도 서비스가 바뀌게 됩니다.

 

이는 도메인 객체에 책임을 분산하지 못한 구조입니다.


문제 2.

오케스트레이터처럼 보이지만, 사실상 업무 정책을 서비스에 몰아넣었습니다
public void completeOrder(Long orderId) { ... }

 

completeOrder()는 이름만 보면 하나의 책임처럼 보이지만, 실제로는

 

  • 결제를 처리하고
  • 재고를 예약하고
  • 쿠폰을 검증하고
  • 주문을 완료시키고
  • 알림까지 보내고 있습니다

이 모든 것은 하나의 변경 사유로 보기 어렵습니다.

 

정책별로 서로 다른 변화가 발생할 수 있고, 이는 단일 책임 원칙 위반입니다.


문제 3.

도메인 객체는 자신의 상태와 관련된 행동을 외부에 의존합니다
Order order = orderRepository.findById(orderId);

 

Order는 자신의 결제수단, 상품 목록, 쿠폰을 알고 있음에도

 

그걸 바탕으로 아무 행동도 하지 않고 수동적으로 조작만 당합니다.

 

도메인 객체는 행위의 주체여야 하며, 내부 상태와 관련된 행동은 스스로 수행할 수 있어야 합니다.


개선된 코드

public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentProcessor paymentProcessor;
    private final InventoryManager inventoryManager;
    private final CouponValidator couponValidator;
    private final NotificationService notifier;

    public OrderService(
            OrderRepository orderRepository,
            PaymentProcessor paymentProcessor,
            InventoryManager inventoryManager,
            CouponValidator couponValidator,
            NotificationService notifier
    ) {
        this.orderRepository = orderRepository;
        this.paymentProcessor = paymentProcessor;
        this.inventoryManager = inventoryManager;
        this.couponValidator = couponValidator;
        this.notifier = notifier;
    }

    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);

        order.processPaymentWith(paymentProcessor);
        order.reserveInventoryWith(inventoryManager);
        order.validateCouponWith(couponValidator);

        order.markAsCompleted();
        orderRepository.save(order);

        notifier.sendOrderCompleted(order.getId());
    }
}
public class Order {

    public void processPaymentWith(PaymentProcessor processor) {
        processor.process(this.paymentInfo());
    }

    public void reserveInventoryWith(InventoryManager manager) {
        manager.reserve(this.items());
    }

    public void validateCouponWith(CouponValidator validator) {
        validator.validate(this.appliedCoupon());
    }

    public void markAsCompleted() {
        this.status = OrderStatus.COMPLETED;
    }
}

 


왜 이렇게 개선했는가?

개선1. 

서비스에서 흐름만 담당하게 함으로써 정책 책임을 도메인으로 분리
  • order.processPaymentWith(...) 형태로 개선함으로써, 실제 '행동'의 주체가 도메인 객체로 전환됩니다.
  • 이는 "결제를 어떻게 처리할지"라는 정책적 책임이 서비스가 아니라 도메인에 있도록 보장합니다.

개선2.

도메인은 협력을 이끄는 주체가 되며, 외부 기능을 의존성으로만 활용
  • Order 객체가 PaymentProcessor를 직접 사용하는 것이 아닌, 그와 협력할 수 있는 명시적인 메서드를 정의했습니다.
  • 도메인은 스스로 결정하고, 외부는 단지 도우미 역할로 위치시킵니다.

개선3

서비스가 무거워지는 것을 방지하고, 변경에 유연한 구조로 전환
  • 서비스는 "어떤 일이 일어나야 하는가"에만 집중합니다. (예: 결제 → 재고 → 쿠폰 → 완료 → 알림 순서)
  • 실제 어떻게 처리되는가는 도메인이 캡슐화하므로, 재고 처리 정책이나 결제 방식이 바뀌더라도 OrderService는 그대로 유지됩니다.

개선4

테스트 및 유지보수성이 획기적으로 향상됨
  • 각각의 도메인 메서드는 단위 테스트가 가능합니다.
  • 서비스는 흐름을 검증하는 통합 테스트 대상이 됩니다.
  • 따라서 단위 수준에서도, 흐름 수준에서도 테스트가 더 쉬워집니다.

예시 2. SubscriptionService – "이벤트 퍼블리싱은 서비스에서 다 해야지 않나요?"

A 개발자:
"도메인은 말 그대로 데이터와 상태 변경만 담당해야지.
이벤트 퍼블리싱은 외부 시스템과의 연결이니까, 당연히 서비스에서 해줘야죠.
그리고 도메인에서는 이벤트만 모아두고, 호출한 쪽에서 퍼블리시하면 책임이 깔끔하게 나뉘잖아요."
B 개발자:
"그렇게 보면 맞는 것 같지만, 진짜 위험한 건 이벤트를 '발생시켰는지 여부'가 호출자에게 전가된다는 거예요.
비즈니스 로직은 그 자체로 닫혀 있어야 신뢰할 수 있는데, 이벤트 퍼블리싱을 서비스에 위임하면
그걸 빼먹는 순간 로직이 '절반만 실행된 셈'이 되지 않을까요.....??"

 

초기 코드

public class SubscriptionService {

    public void renew(Subscription subscription) {
        subscription.renew(); // 내부에서 상태 전이 및 이벤트 생성
        eventPublisher.publishAll(subscription.pullDomainEvents());
    }
}
public class Subscription {

    private final List<DomainEvent> domainEvents = new ArrayList<>();

    public void renew() {
        if (this.expired()) {
            this.status = SubscriptionStatus.ACTIVE;

            if (this.plan.isPremium()) {
                domainEvents.add(new PremiumRenewalCompletedEvent(this.userId));
            }
        }
    }

    public List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> copy = new ArrayList<>(domainEvents);
        domainEvents.clear();
        return copy;
    }
}

 

 

문제 1.

퍼블리싱 누락 가능성

 

퍼블리싱 호출 안 했을 경우 이벤트 발생이 없게 됩니다. 이는 결국 누락으로 이어져 비즈니스 로직에 문제가 발생하게 됩니다.

subscription.renew();

문제 2.

도메인 로직의 폐쇄성 부족

 

비즈니스 로직을 한 번 호출한 뒤, 이벤트가 쌓였다는 걸 개발자가 인지하고 퍼블리시를 수동 호출해야 합니다. 

그럼 다른 개발자가 이어서 개발할 때 모든 로직을 꼼꼼히 안보면 문제가 생기겠죠?

eventPublisher.publishAll(subscription.pullDomainEvents());

문제 3.

반복되는 퍼블리싱 보일러플레이트

 

모든 서비스 메서드마다 이 패턴을 반복적으로 작성해야 합니다.

eventPublisher.publishAll(도메인.pullDomainEvents());

 


개선 코드 

public class Subscription {

    private final List<DomainEvent> domainEvents = new ArrayList<>();

    public List<DomainEvent> renewAndGetEvents() {
        if (this.expired()) {
            this.status = SubscriptionStatus.ACTIVE;

            if (this.plan.isPremium()) {
                domainEvents.add(new PremiumRenewalCompletedEvent(this.userId));
            }
        }

        return pullDomainEvents(); // 내부에서 자동 수집하여 반환
    }

    private List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> copy = new ArrayList<>(domainEvents);
        domainEvents.clear();
        return copy;
    }
}
public class SubscriptionService {

    public void renew(Subscription subscription) {
        List<DomainEvent> events = subscription.renewAndGetEvents();
        eventPublisher.publishAll(events); // 한 문장으로 위임 처리
    }
}

 

 

왜 이렇게 개선했는가?

개선 1.

퍼블리싱 누락 가능성 제거

 

호출자가 이벤트 존재 여부를 신경 쓰지 않아도 되도록 한 곳에 묶음 처리합니다. 메서드만 호출해도 이벤트가 처리되게끔 하는 겁니다.

List<DomainEvent> events = subscription.renewAndGetEvents();

 


개선 2.

도메인 로직의 폐쇄성 회복

 

도메인이 상태 전이와 이벤트 반환을 묶어서 호출자의 책임을 최소화해야합니다. 

subscription.renewAndGetEvents();

public List<DomainEvent> renewAndGetEvents() {
        if (this.expired()) {
            this.status = SubscriptionStatus.ACTIVE;

            if (this.plan.isPremium()) {
                domainEvents.add(new PremiumRenewalCompletedEvent(this.userId));
            }
        }

        return pullDomainEvents(); // 내부에서 자동 수집하여 반환
    }

개선 3.

서비스 계층에서 보일러플레이트 제거

 

이벤트 추출과 퍼블리싱을 분리하는 대신 단일 책임화된 메서드로 만들어 이를 호출함으로서 보일러플레이트를 최소화합니다.

eventPublisher.publishAll(events);

 


예시 3. ProductCommandService – "나는 도메인이 자기 책임은 져야 한다고 생각함"

"서비스에서 유효성 검사 다 하면 되지 않나요?"
"도메인은 데이터만 가지면 되는 거 아니에요?"
"컨트롤러 → 서비스 → 도메인 호출이면 그 흐름대로 검증해도 문제없지 않나요?"

 

초기 코드

public class ProductCommandService {

    public void register(ProductCreateCommand command) {
        Product product = new Product(command.name(), command.price(), command.inventory());

        if (product.price().isNegative()) {
            throw new IllegalArgumentException("음수 가격은 허용되지 않습니다.");
        }

        repository.save(product);
    }
}

 

문제 1.

유효하지 않은 도메인 객체가 생성될 수 있음
Product product = new Product(command.name(), command.price(), command.inventory());

 

  • 이 시점에 이미 음수 가격이라는 잘못된 상태가 객체로 만들어집니다.
  • 객체가 외부로 유출되면 의도치 않은 오류가 발생할 수 있습니다.
  • 도메인이 유효하지 않은 상태로 만들어지면 테스트, 배포, 운영 중 치명적인 장애를 유발할 수 있습니다.

 

문제 2.

유효성 검증 책임이 도메인 바깥에 있음

 

생성자만 열어두고 외부에서 조건을 걸기 시작하면, 언젠가는 누락됩니다.

if (product.price().isNegative()) {
    throw new IllegalArgumentException("음수 가격은 허용되지 않습니다.");
}
  • 동일한 검증 로직이 서비스 곳곳에 반복될 수 있고, 빠지는 경우도 생깁니다.
  • 도메인의 정합성이 외부에 의해 관리되는 구조는 유지보수 비용을 증가시킵니다.

개선 코드

public class ProductCommandService {

    public void register(ProductCreateCommand command) {
        Product product = Product.create(command.name(), command.price(), command.inventory());
        repository.save(product);
    }
}

public class Product {

    public static Product create(String name, Money price, int inventory) {
        if (price.isNegative()) {
            throw new IllegalArgumentException("음수 가격은 허용되지 않습니다.");
        }

        return new Product(name, price, inventory);
    }
}

왜 이렇게 개선했는가?

개선 1.

정적 팩토리 메서드를 통한 검증 일원화

 

public static Product create(...) {
    if (price.isNegative()) {
        throw new IllegalArgumentException(...);
    }
    return new Product(...);
}
  • 생성과 동시에 검증을 수행하므로 잘못된 객체가 아예 만들어지지 않습니다.
  • Product 객체는 무조건 유효한 상태로만 존재하게 됩니다.
  • 검증을 객체 내부로 넣는 건 단순히 "깔끔해 보이기 위해서"가 아니라, 도메인의 완전성 보장을 위한 필수 조건입니다.

개선 2.

도메인 객체의 책임 명확화
Product product = Product.create(...);
  • Product 스스로 유효성을 판단하게 되면서 책임이 명확해집니다.
  • 서비스 계층은 객체를 "어떻게" 만드는지는 몰라도 되고, "정상적인 객체만 받아 저장" 하면 됩니다.

 


 

 

예시 4. SignupService – 이벤트를 도메인이 아닌 서비스에서 직접 발생시키는 경우

"referral 코드는 비즈니스 로직이지, 굳이 도메인까지 갈 필요 있나요?"
"이벤트는 서비스에서 처리하는 게 더 깔끔하죠."
"도메인은 그냥 데이터 구조잖아요. 이벤트는 서비스 책임 아닐까요?"

 

초기 코드

public class SignupService {

    public void signup(UserSignupCommand command) {
        User user = new User(command.name(), command.email(), command.password());
        userRepository.save(user);

        if (command.referralCode() != null) {
            eventPublisher.publish(new ReferralBonusEvent(command.referralCode(), user.id()));
        }
    }
}

 

 

문제 1.

도메인이 아닌 서비스가 이벤트를 생성
eventPublisher.publish(new ReferralBonusEvent(command.referralCode(), user.id()));
  • 이벤트가 User 객체의 상태 변화와 무관하게 서비스 계층에서 독립적으로 생성됩니다.
  • User는 referral 코드가 적용되었는지, 어떤 이벤트가 발생했는지 알 수 없습니다.

문제 2.

도메인의 응집도와 책임 분산
  • 이벤트가 도메인의 맥락 밖에서 생성되기 때문에 의미와 일관성이 약해집니다.
  • 향후 도메인 로직이 변경되거나 이벤트 구조가 달라질 경우, 서비스 로직도 직접 수정해야 합니다.

개선 코드

public class SignupService {

    public void signup(UserSignupCommand command) {
        User user = User.create(command.name(), command.email(), command.password());
        userRepository.save(user); // ID 생성 이후에 이벤트 필요

        if (command.referralCode() != null) {
            user.applyReferral(command.referralCode());
            eventPublisher.publishAll(user.pullDomainEvents());
        }
    }
}
public class User {

    private final List<DomainEvent> domainEvents = new ArrayList<>();
    private Long id;

    public static User create(String name, String email, String password) {
        return new User(name, email, password);
    }

    public void applyReferral(String referralCode) {
        domainEvents.add(new ReferralBonusEvent(referralCode, this.id));
    }

    public List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> copy = new ArrayList<>(domainEvents);
        domainEvents.clear();
        return copy;
    }
}

 

왜 이렇게 개선했는가?

개선 1.

이벤트 생성 책임을 도메인에 위임
user.applyReferral(referralCode);
  • 이벤트 생성 책임을 User 내부로 이동시켜 응집도를 높입니다.
  • User는 자신의 상태 변화와 그에 따른 이벤트 발행을 함께 관리하게 됩니다.
  • 상태를 바꾸는 주체가 이벤트까지 생성해야, 변화의 근거 의미가 일치합니다.
  • 이벤트 생성이 서비스에 존재하면 도메인은 이벤트와 무관한 단순 데이터 구조가 되어,
    → 유지보수 시 응집도는 낮고, 버그 발생 가능성은 높아집니다.

개선 2.

이벤트 발행 흐름의 명확한 분리
eventPublisher.publishAll(user.pullDomainEvents());
  • 서비스는 이벤트를 직접 생성하지 않고, 도메인이 만든 이벤트만 발행합니다.
  • 이로써 도메인은 이벤트의 생성자, 서비스는 전달자로서 명확히 역할이 구분됩니다.
  • 도메인 주도 설계 관점에서 이벤트는 도메인 모델의 상태 변화의 부산물이어야 합니다.

 

 

실무에서 어디까지 개선해야 하는가

많은 개발자들이 "서비스가 너무 무겁다"는 진단을 하면서도 왜 그렇게 되었는지 혹은 어디까지가 도메인 책임인지는 구분을 힘들어합니다. 

 

이번 글에서 다룬 사례들을 종합해 보면 God Object 문제는 다음과 같은 특성을 가집니다.

  • 의사 결정이 모두 서비스에 집중되어 있고
  • 도메인은 단지 전달자 또는 수동적 데이터 구조에 머무르며
  • 변경 발생 시, 도메인보다 서비스부터 바뀌게 되는 구조

이는 단순히 SRP 위반 문제가 아니라 유지보수성과 변경 유연성을 갉아먹는 구조적 결함입니다.


실무에서의 선택 기준

도메인이 어떤 행동을 할 수 있다면, 그 행동의 시작점은 도메인 내부에 있어야 합니다.

 

서비스는 도메인을 사용하는 사용자에 가깝고, 도메인은 자기 상태를 관리하며, 외부 기능과 협력하는 주체가 되어야 합니다.

 

그리고 이벤트는 도메인 내부에서 발생되어야만 그 의미와 근거가 일치하게 됩니다.

 

실무에서는 "서비스를 얼마나 얇게 만들 수 있는가"가 중요한 게 아니라

 

"도메인이 스스로 결정할 수 있는가"

 

그리고

 

"서비스는 그 결정만을 위임받고 있는가"

 

를 기준으로 판단해야 합니다.


마무리하며 – 아직 God Object를 놓지 못하고 있다면

이 글의 모든 예시들은 실제 실무에서 흔히 보이는 코드들입니다.

 

오히려 "그렇게까지 도메인을 쓰면 너무 과한 거 아닌가?"라는 말을 듣기도 합니다.

 

하지만 도메인을 신뢰할 수 있어야, 테스트도 단순해지고, 구조도 명확해지며, 서비스가 의사 결정에서 자유로워집니다.

 

도메인이 더 많은 책임을 질수록, 서비스는 더 가벼워지고, 시스템은 더 예측 가능해집니다.

 

God Object는 결국, 책임을 나누지 못한 결과물입니다.

 

한 줄로 정리하자면,

"도메인은 단순히 데이터를 담는 그릇이 아니라,
행동의 주체이며 정책의 수호자입니다."