_

Always be tactful

MAIN/IMDEF

05. 현대 소프트웨어 아키텍처에 대한 고찰

택트 2025. 7. 26. 07:26

싱글톤 패턴의 출현

설계를 하다 보면, 애플리케이션 전반에 걸쳐 유일하게 존재해야 하는 객체가 있다. 싱글톤 패턴은 불필요한 리소스 낭비를 방지할 뿐 아니라, 관리를 용이하게 한다는 점에서 한때 각광받던 디자인 패턴 중 하나다. 어디서든 쉽게 접근해서 쓸 수 있다는 점도 개발자들에게 충분히 매력적으로 느껴졌을 것이다. 마치 전역 변수처럼 편의성을 제공하면서도 최소한의 객체 지향 원칙을 지키는 듯 보였으니 말이다.

 

하지만 싱글톤 패턴의 가장 고질적인 문제는 강한 결합에 있었다. 싱글톤에 의존하는 클래스들은 싱글톤의 존재를 직접적으로 알게 되고, 이는 곧 테스트를 매우 어렵게 만든다는 말과 같다. 싱글톤의 상태가 전역적으로 공유되기 때문에, 예측 불가능한 사이드 이펙트가 발생하기 쉬웠으며, 병렬 처리 환경에서는 동기화 문제로 테스트가 불가능했다. 꼭 테스트가 아니더라도, 일단 유연성이 떨어지기 때문에 동일한 종류의 객체가 여러 개 필요하게 되거나 다른 구현체로 고려해야 하는 상황에서 어려움을 겪는다.

 

그런데 이 얘기를 할 때, 충분히 의심할만한 지점이 있다. 그건 바로 '동일한 종류의 객체가 여러 개 필요하게 되거나'에 대한 부분이다. 분명 동일한 종류의 객체가 여러 개 필요할 일이 없기 때문에 싱글톤 패턴을 적용한 것일 텐데, 이러한 객체가 여러 개 필요할 일이 있겠냐는 것이다.

 

모든 가정은 리스크를 지닌다. 단 하나의 인스턴스만 필요하다는 전제도 마찬가지다. 소프트웨어는 살아있는 유기체와 같다. 오늘날 필요 없는 것이 내일은 필요해질 수 있고, 현재의 요구사항이 미래에도 변하지 않으리라는 가정은 막연하다.

 

예를 들어, 초기 애플리케이션에서 하나의 결제 시스템만 필요했기에 PaymentService를 싱글톤으로 설계했다고 하자. 시간이 지나 카카오페이라던가 네이버페이 등 다양한 결제 수단을 추가해야 하는 상황이 올 수 있다. 이때 싱글톤으로 묶인 PaymentService는 새로운 결제 로직을 유연하게 추가하고 관리하기 어렵게 만든다. 결국 기존 코드를 크게 수정하거나, 더 복잡한 우회 로직을 도입해야 할 것이다. 이것은 개방 폐쇄 원칙을 위배하며, 변화에 취약한 구조로 만들 뿐이다.

 

그럼에도 앞서 제시한 예시가 와닿지 않을 수 있다고 생각한다. 왜냐하면 결제 시스템 같은 경우 처음부터 확장을 고려해 싱글톤으로 설계하지 않을 가능성이 높기 때문이다. 그렇다면 이건 어떨까? 데이터베이스 연결 정보 같은 애플리케이션의 전반적인 설정을 다루는 설정 관리자(ConfigManager) 말이다. 이건 분명 전역적으로 하나만 존재하고 접근해야 할 것처럼 느껴진다. 로거도 마찬가지다. 로그를 기록하는 로거 인스턴스는 하나만 있으면 충분하다고 생각하기 쉽다. 실제로 모든 로깅 요청이 한 곳으로 모여 처리되니 합리적인 경우도 존재한다.

 

하지만 아쉽게도 두 객체의 구조 역시 영원할 수 없다. 서비스형 소프트웨어 애플리케이션(SaaS)처럼 멀티테넌시로 확장하게 된다던가, 로거가 여러 곳으로 동시에 로그를 보내야 하는 *다중 출력 상황 따위를 생각해 볼 수 있다. *파일, DB, 외부 서비스 로깅

 

유틸리티 클래스도, 헬퍼 클래스도 전부 마찬가지다. 이 세상에 싱글톤 패턴으로 확정 지을 수 있는 클래스란 존재하지 않는다. 절대 없다고 생각했던 상황은 생각보다 자주 발생하며, 특히 테스트 용이성과 미래의 확장성 측면에서 싱글톤 패턴의 약점은 매우 치명적이다. 이러한 싱글톤의 단점을 해소하기 위해 도입된 것이 바로 서비스 계층이다.

코드 수준에서 객체를 하나로 제한하는 대신, 스프링 같은 프레임워크에서는 어노테이션을 통해 스코프를 줄여 관리한다.

 

비즈니스 로직을 서비스 계층에 분리하여 배치함으로써, 특정 객체에 대한 의존성을 줄이고, 테스트하기 쉬운 구조로 만들었다. 컨트롤러나 프레젠테이션 계층은 서비스 계층을 통해 비즈니스 로직을 수행하고, 서비스 계층은 필요한 도메인 객체들을 조작하는 역할을 맡는다. 그리고 이는 분명 싱글톤의 문제를 해결하고 코드의 응집도를 높이는 데 기여한 것이다.

 

그런데 흥미로운 현상이 발생하기 시작한다. 서비스 계층이 비즈니스 로직을 너무 많이 가져가게 되면서, 우리의 도메인 객체들은 데이터만 담고 있는 껍데기로 전락하고 만다. 우리가 과연 이러한 설계를 '객체 지향'이라고 부를 수 있을까?

 

도메인 모델, 빈혈에 걸리다

public class Order {
    private String orderId;
    private String customerId;
    private Date orderDate;
    private double totalAmount;
    private OrderStatus status;

    public Order(String orderId, String customerId, Date orderDate, double totalAmount, OrderStatus status) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.orderDate = orderDate;
        this.totalAmount = totalAmount;
        this.status = status;
    }

    public String getOrderId() { return orderId; }
    public String getCustomerId() { return customerId; }
    public Date getOrderDate() { return orderDate; }
    public double getTotalAmount() { return totalAmount; }
    public OrderStatus getStatus() { return status; }

    public void setTotalAmount(double totalAmount) { this.totalAmount = totalAmount; }
    public void setStatus(OrderStatus status) { this.status = status; }
}

 

여기서부터는 싱글톤과 관련된 이야기라기보다는 스프링 MVC에 대한 이야기다. 서비스 계층이 생기면서 발생할 수 있는 빈약한 도메인에 대해 이야기해보려 한다.

 

주문 도메인을 생각해 보자. Order 클래스는 orderId, customerId, orderDate, totalAmount, status 같은 주문 정보를 담고 있을 것이다. 주문의 상태를 변경하거나, 총액을 계산하거나, 주문을 취소하는 등의 모든 비즈니스 로직은 OrderService에 위치한다. 이건 건강한 도메인 모델일까?

 

빈혈 도메인 모델이란 데이터만 가지고 있고, 해당 데이터를 조작하는 비즈니스 로직이 거의 존재하지 않는 도메인 객체를 뜻한다. 모든 중요 로직들이 서비스 계층에 집중되어 있다는 이야기다. 위 예시에서 Order 객체는 status 필드를 가지고 있음에도, 결국 이 status가 어떻게 변경되어야 하는지에 대한 규칙은 OrderService에만 존재한다. 이 경우 Order 객체 자체는 어떤 상태로든 마음대로 변경될 수 있는 '취약한 데이터 컨테이너'에 불과하다.

 

Order 객체는 '주문'이라는 개념을 행동 측면에서 전혀 표현하지 못하고, 오직 데이터 측면에서만 표현하는 꼴이다. 이해를 돕기 위해 DeliveryService에서 주문의 상태를 변경해야 하는 상황이 발생했다고 가정하자. OrderService의 메서드를 호출해야 하나? 직접 Order 객체의 상태를 변경하는 중복 로직을 만들 가능성은 없나? 혹여, 로직이 꼬여 잘못된 상태 변경을 유발하지는 않으려나?

 

최악이다. 객체 지향의 핵심 원칙인 캡슐화와 응집도를 심각하게 훼손한다. 객체가 자신의 상태를 온전히 책임지지 못하고, 관련 로직은 여기저기 흩어져 유지보수를 어렵게 만들었다. 객체가 자신의 존재 의미를 잃어버려 생긴 결과다.

 

풍부한 도메인 모델이란

풍부한 도메인 모델이란 데이터(속성)뿐만 아니라, 그 데이터를 조작하는 비즈니스 로직까지 도메인 객체 내부에 포함하는 모델을 뜻한다. 이는 객체가 자신의 상태를 스스로 관리하고, 관련된 비즈니스 규칙을 책임지도록 함으로써 캡슐화와 응집도를 극대화하는 방식이다.

 

앞서 예시로 들었던 주문 도메인을 다시 생각해 보자. 빈혈 모델에서는 Order가 그저 데이터를 담는 그릇에 불과했다면, 풍부한 도메인 모델 상황에서는 Order 객체 자신이 '주문'으로서의 행동들을 책임지게 된다.

public class Order {
    private String orderId;
    private String customerId;
    private Date orderDate;
    private double totalAmount;
    private OrderStatus status;

    // 생성자 (데이터 유효성 검사 및 초기 상태 설정)
    public Order(String orderId, String customerId, Date orderDate, double totalAmount) {
        if (totalAmount < 0) {
            throw new IllegalArgumentException("주문 총액은 0보다 작을 수 없습니다.");
        }
        this.orderId = orderId;
        this.customerId = customerId;
        this.orderDate = orderDate;
        this.totalAmount = totalAmount;
        this.status = OrderStatus.CREATED;
    }

    public String getOrderId() { return orderId; }
    public String getCustomerId() { return customerId; }
    public Date getOrderDate() { return orderDate; }
    public double getTotalAmount() { return totalAmount; }
    public OrderStatus getStatus() { return status; }

    public void complete() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("대기 중인 주문만 완료할 수 있습니다.");
        }
        this.status = OrderStatus.COMPLETED;
    }

    public double calculateDiscountedTotal(double discountRate) {
        return this.totalAmount * (1 - discountRate);
    }
    
    public void cancel() {
        if (this.status == OrderStatus.COMPLETED || this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException("이미 완료되거나 배송된 주문은 취소할 수 없습니다.");
        }
        if (this.status == OrderStatus.CANCELLED) {
            throw new IllegalStateException("이미 취소된 주문입니다.");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

 

핵심 비즈니스 로직들이 Order 객체 내부에 존재하게 되니 도메인 모델은 확실히 풍부해진 것 같다. 그렇다면 도대체 서비스 계층은 무엇을 해야 하는 걸까?

 

이제 서비스 계층은 도메인 객체들에게 '무엇을 하라!'라고 지시하는 역할로 변모하게 된다. 즉, 서비스 계층은 여러 도메인 객체나 외부 시스템 간의 조정자 역할에 집중한다는 의미다.

public class OrderService {
    private OrderRepository orderRepository;
    private PaymentGateway paymentGateway;

    public OrderService(OrderRepository orderRepository, PaymentGateway paymentGateway) {
        this.orderRepository = orderRepository;
        this.paymentGateway = paymentGateway;
    }

    public Order placeOrder(String customerId, List<Item> items) {
        Order newOrder = new Order("ORD-" + UUID.randomUUID().toString(), customerId, new Date(), calculateItemsTotal(items));
        paymentGateway.processPayment(newOrder.getOrderId(), newOrder.getTotalAmount());
        orderRepository.save(newOrder);
        
        return newOrder;
    }

    public void cancelCustomerOrder(String orderId) {
        Order order = orderRepository.findById(orderId);
        if (order == null) {
            throw new IllegalArgumentException("주문을 찾을 수 없습니다.");
        }
        
        order.cancel();
        orderRepository.save(order);
    }
}

 

OrderService는 order.setStatus(OrderStatus.CANCELLED)처럼 객체의 내부 상태를 직접 조작하는 대신, order.cancel()과 같이 객체에게 취소하라고 지시할 뿐이다. Order 객체는 이 지시에 따라 자신의 내부에 정의된 취소 규칙을 검증하고 스스로 상태를 변경하게 된다.

 

도메인 객체 자체를 독립적인 단위로 테스트하기 훨씬 용이해졌다. Order 객체의 cancel() 메서드만 테스트하면, 주문 취소에 대한 모든 비즈니스 규칙이 올바르게 작동하는지 확인할 수 있다. 뿐만인가, 코드를 읽는 사람 입장에서 해당 도메인 객체를 통해 비즈니스 도메인 규칙을 파악하기 쉽고, 전체적인 시스템의 이해도를 높인다. 코드 중복 발생 위험이 줄어들게 되면서 유지보수성도 향상된다. 결국 같은 얘기지만 캡슐화와 응집도 측면에서 강화되었다는 이야기다.


 

오늘은 어제 배운 싱글톤 패턴과 잘못 설계한 도메인 모델을 엮어, 고민하고 기록하는 시간을 가져보았다. 사실 과거에도 도메인 핵심 규칙과 비즈니스 로직을 구분하는 것에 대해 이해가 어려워 찾아보고 기록한 적이 있는데, 아무래도 그때 제대로 이해하지 못했었나 보다.

 

아무튼, 공부하다 보니 요 며칠 연이어 포스팅을 했는데 아무래도 당분간은 어려울 듯하다. 그동안 매일매일 싸피 다녀와서 헬스하고 알고리즘 풀며 꽉 찬 하루를 보내고 있었는데, 이제는 해커톤 준비도 해야 해서 정말이지 몸이 남아나질 않을 예정이다.