Command-Query 분리 원칙

Command-Query 분리 원칙이란?

Command-Query Separation CQS 원칙은 객체지향 설계에서 명령Command과 질의Query를 구분하라는 원칙입니다. 이 원칙은 버트란드 메이어Bertrand Meyer가 제안한 개념으로, 객체의 메서드가 하나의 역할만을 수행하도록 명확히 구분하여 코드의 예측 가능성과 유지보수성을 높이기 위한 목적을 가지고 있습니다.

기본 개념

  • Command: 시스템의 상태를 변경하는 동작으로, 상태를 바꾸는 것을 목적으로 하는 메서드. 명령은 값을 반환하지 않고, 부수적인 효과(예: 상태 변경)를 발생시킵니다.
  • Query: 시스템의 상태를 조회하는 동작으로, 상태를 변경하지 않고 정보를 반환하는 메서드. 질의는 상태를 변경하지 않으며, 오직 값을 반환합니다. CQS 원칙에 따르면, 명령과 질의는 하나의 메서드 안에서 동시에 일어나지 않아야 합니다. 즉, 어떤 메서드가 시스템의 상태를 변경한다면 그 메서드는 값을 반환해서는 안 되고, 반대로 상태를 조회하는 메서드는 상태를 변경해서는 안 됩니다.

예시

다음은 CQS 원칙을 따르지 않은 코드의 예입니다.

public class User
{
    public string Name { get; set; }
    // 상태를 변경하고 값을 반환하는 메서드 (CQS 위반)
    public string UpdateName(string newName)
    {
        Name = newName; // 상태 변경
        return Name;    // 값 반환
    }
}

위 예제에서 UpdateName 메서드는 상태를 변경하면서 동시에 값을 반환하고 있어, CQS 원칙을 위반하고 있습니다. 이를 수정하면 다음과 같이 나눌 수 있습니다.

public class User
{
    public string Name { get; set; }
    // Command: 상태를 변경하는 메서드
    public void ChangeName(string newName)
    {
        Name = newName;
    }
    // Query: 상태를 조회하는 메서드
    public string GetName()
    {
        return Name;
    }
}

이렇게 구분하면, ChangeName 메서드는 상태를 변경하고 값을 반환하지 않으며, GetName 메서드는 값을 반환하지만 상태를 변경하지 않습니다. 이렇게 명령과 조회의 역할을 분리함으로써 코드의 예측 가능성을 높일 수 있습니다.

CQS 원칙의 장점

  • 코드의 예측 가능성 증가: 메서드가 상태를 변경하지 않거나 조회만을 목적으로 한다는 점을 알면 코드의 동작을 더 쉽게 이해할 수 있습니다.
  • 유지보수성 향상: 명령과 조회가 명확히 구분되어 있어 코드 수정 시 의도치 않은 상태 변경이나 부수 효과를 방지할 수 있습니다.
  • 디버깅 용이성: 명령이 상태를 변경하고 조회가 상태를 확인하는 구조로 인해, 오류 발생 시 어느 부분에서 상태가 변경 되었는지를 더 명확하게 파악할 수 있습니다.

CQS 원칙의 한계

복잡한 로직에 대한 한계

단순한 애플리케이션에서는 CQS 원칙이 큰 도움이 되지만, 복잡한 비즈니스 로직에서는 명령과 조회를 엄격히 분리하는 것이 오히려 불필요한 복잡성을 추가할 수 있습니다.

성능 문제

조회 메서드와 명령 메서드를 분리함으로써 코드가 중복되거나 성능에 불리할 수 있습니다. 특히 조회 후에 곧바로 상태를 변경해야 하는 경우에는 두 개의 메서드 호출로 나뉘어 성능 저하가 발생할 수 있습니다.

특정 언어에서의 제한

CQS 원칙은 모든 프로그래밍 언어에서 반드시 지원되지는 않습니다. 일부 언어에서는 메서드가 반환값을 가질 때나 명령과 조회를 구분하는 데 제약이 있을 수 있습니다.

상태의 동기화 문제

CQS 원칙을 엄격히 따르다 보면, 명령과 조회 사이에 상태가 변경될 위험이 존재합니다. 이는 시스템에서 동시성이 문제가 되는 상황에서는 적절한 동기화 메커니즘이 없을 경우, CQS의 원칙이 오히려 복잡성을 증가시킬 수 있습니다. 예를 들어 도서관 관리 시스템에서, 사용자가 책을 대출할 때마다 대출 가능한지 여부를 확인한 후 상태를 변경하는 로직이 필요합니다. 그러나 대출 가능 여부를 묻고 상태를 변경하는 과정이 하나의 메서드에서 처리된다면, 코드의 의도가 불분명해지고 예측하기 어려울 수 있습니다.

// CQS 위반 사례
public class Library
{
    public string BorrowBook(User user, int bookId)
    {
        var book = FindBook(bookId);
        if (book == null || !book.IsAvailable)
        {
            return "Book is not available";
        }
        book.Borrow(user); // 상태 변경 (대출)
        return "Book borrowed successfully"; // 값 반환
    }
}

위 코드는 상태를 조회(책 대출 가능 여부 확인)하고, 동시에 책의 상태를 변경(대출)하고 있습니다. 명령과 조회가 결합되어 있어 유지보수 시 예상하지 못한 동작이 발생할 수 있습니다.

// CQS 적용사례
public class Library
{
    // Query: 상태를 조회하는 메서드
    public bool CanBorrowBook(int bookId)
    {
        var book = FindBook(bookId);
        return book != null && book.IsAvailable;
    }
    // Command: 상태를 변경하는 메서드
    public void BorrowBook(User user, int bookId)
    {
        var book = FindBook(bookId);
        book.Borrow(user); // 상태 변경 (대출)
    }
}

이 코드는 상태 조회와 상태 변경을 명확히 분리하였습니다.

  • CanBorrowBook 메서드는 책의 대출 가능 여부를 조회하는 역할만 수행하며, 상태를 변경하지 않습니다.
  • BorrowBook 메서드는 상태 변경만 수행하며 값을 반환하지 않습니다. CQS를 작 적용한 사례이지만, 다음과 같은 문제가 발생할 수 있습니다. 이 구조에서는 사용자가 CanBorrowBook 메서드를 호출해 책이 대출 가능한지 확인한 후, BorrowBook 메서드를 호출하여 책을 대출합니다. 그러나 CanBorrowBook을 호출한 이후에 다른 사용자가 BorrowBook을 호출해 해당 책을 대출해버린다면, 첫 번째 사용자는 이미 대출된 책을 대출하려 시도하는 문제가 발생할 수 있습니다. 이 문제를 해결하기 위해서는 조회와 상태 변경을 트랜잭션 내에서 처리하거나, 동시성 제어 메커니즘을 추가해야 합니다. 동일 트랜잭션 내에서 처리한다는 것은 코드가 다시 CQS 위반 사례의 코드로 돌아가는 것을 의미하므로, CQS 적용 사례가 오히려 코드의 안전성을 해치는 결과가 되어 버립니다.

맺음말

Command-Query 분리 원칙CQS는 객체지향 설계에서 명령과 질의를 명확히 구분하여 코드의 예측 가능성, 가독성, 유지보수성을 높이는 중요한 원칙입니다. 하지만 모든 시스템에 적용할 수 있는 만능 원칙은 아니며, 애플리케이션의 복잡성과 성능 요구 사항에 따라 유연하게 적용해야 합니다.

심화 학습

CQS와 단일 책임 원칙과의 연관성

CQS는 SOLID 원칙 중에서 특히 단일 책임 원칙SRP과 깊이 연관됩니다. CQS는 한 메서드가 한 가지 역할만을 수행하도록 하여 책임을 분리하는 것이 핵심이므로, 단일 책임 원칙을 구현하는 좋은 방법 중 하나입니다.

  • Command는 상태를 변경하는 데만 책임이 있으며,
  • Query는 상태를 조회하는 데만 책임이 있습니다. 이 방식은 각각의 메서드나 클래스가 하나의 책임을 가지도록 설계해 유지보수성을 향상시키며, 의도치 않은 부수 효과를 줄일 수 있습니다.

CQS와 CQRS의 관계

CQS 원칙은 나중에 발전된 개념인 CQRSCommand Query Responsibility Segregation의 기반이 됩니다. CQRS는 읽기 작업Query과 쓰기 작업Command을 완전히 다른 경로로 처리하는 아키텍처 패턴으로, 대규모 시스템의 확장성과 성능을 개선하는 데 자주 사용됩니다. CQRS는 CQS의 개념을 확장하여 읽기와 쓰기를 물리적으로 분리하여 각 작업에 적합한 기술과 구조를 사용할 수 있도록 합니다.

CQS와 디자인 패턴의 결합

  • Command 패턴: CQS와 자연스럽게 결합됩니다. Command 패턴을 사용하면, 시스템의 상태를 변경하는 작업을 Command 객체로 캡슐화하여 CQS 원칙을 준수할 수 있습니다.
  • Facade 패턴: 복잡한 비즈니스 로직을 단순한 인터페이스로 노출하면서, 내부적으로 Command와 Query를 분리하는 방식으로 CQS 원칙을 적용할 수 있습니다.

CQS와 테스트 용이성

CQS 원칙을 따르면 각 메서드가 명확히 구분되기 때문에, 테스트 코드 작성이 쉬워집니다. 명령Command과 질의Query가 분리되어 있으므로, 메서드의 부수 효과나 상태 변경을 테스트할 때 더 직관적으로 파악할 수 있습니다. 또한, CQS를 따르면 단위 테스트에서 한 메서드가 상태 변경과 상태 조회를 동시에 수행하지 않으므로, 테스트의 범위가 명확해집니다.

// 테스트 코드 예시
[Test]
public void TestCanBorrowBook()
{
    var library = new Library();
    var canBorrow = library.CanBorrowBook(1);
    
    Assert.IsTrue(canBorrow);
}
[Test]
public void TestBorrowBook()
{
    var library = new Library();
    library.BorrowBook(new User(), 1);
    
    // 상태가 변경되었는지 검증
    Assert.AreEqual(library.GetBook(1).Status, BookStatus.Borrowed);
}

CQS 적용의 실무적인 도전

비용과 이득의 균형

소규모 프로젝트나 단순한 애플리케이션에서는 CQS를 엄격하게 적용하는 것이 오히려 개발 속도를 늦추거나 복잡성을 추가할 수 있습니다. 모든 메서드를 Command와 Query로 분리하면 오버헤드가 발생할 수 있으며, 성능 저하로 이어질 수도 있습니다. 실무에서는 이러한 트레이드오프를 잘 판단해야 합니다.

기존 코드에 CQS 적용하기

이미 많은 메서드가 Command와 Query를 결합한 형태로 구현되어 있는 경우, 이를 CQS에 맞게 리팩토링하는 데 큰 비용이 들 수 있습니다. 따라서, CQS 적용이 반드시 필요한 부분에만 선별적으로 적용하는 것이 더 적절할 수 있습니다. 심화 학습에 더 추가할 내용으로는 CQS가 대규모 시스템에서의 확장성과 관련된 주제를 다룰 수 있습니다. 특히, CQS는 단순히 객체지향 설계 원칙에 그치지 않고, 대규모 분산 시스템이나 복잡한 애플리케이션에서 성능과 확장성을 높이는 데 중요한 역할을 합니다.

대규모 시스템에서의 CQS 적용

CQS는 단일 애플리케이션뿐만 아니라 마이크로서비스 아키텍처나 분산 시스템에서 강력하게 적용됩니다. 시스템의 각 서비스가 명령과 조회를 명확히 구분하면, 읽기와 쓰기 작업을 별도 인프라에서 처리할 수 있어 확장성이 커집니다.

  • 읽기 작업은 캐싱이나 고성능 읽기 전용 데이터베이스를 활용할 수 있고,
  • 쓰기 작업은 안정성을 위해 트랜잭션 처리를 강화하는 구조로 구현할 수 있습니다. 이는 특히 CQRS 패턴과 연계하여 대규모 시스템에서 데이터 일관성을 관리하면서도 성능을 최적화하는 데 도움이 됩니다.

비동기 프로세스와의 조합

CQS를 비동기 작업과 결합하면 비동기 프로세싱이나 이벤트 기반 아키텍처와도 잘 맞아떨어집니다. 예를 들어, Command 작업은 비동기로 실행되어 상태를 변경하고, Query 작업은 즉각적으로 결과를 조회하는 방식으로 설계할 수 있습니다.

  • 예시: Command 작업은 메시지 큐를 통해 비동기 처리되고, Query 작업은 빠르게 결과를 캐싱 시스템에서 조회할 수 있습니다. 이를 통해 사용자는 상태 변경 작업의 결과를 기다리지 않고 바로 조회 결과를 얻을 수 있습니다.

CQS와 데이터베이스 설계

CQS는 데이터베이스와 관련된 설계에서도 영향을 미칩니다. 예를 들어, 읽기 최적화된 데이터베이스 테이블과 쓰기 최적화된 테이블을 분리하여 관리할 수 있습니다. 이 방식은 특히 읽기 작업이 많은 시스템에서 성능을 크게 향상시키며, 데이터 무결성을 유지하면서도 성능 최적화를 달성할 수 있습니다.

  • 쓰기 전용 테이블은 데이터 일관성을 유지하고, 트랜잭션 관리가 중요할 때 유용하며,
  • 읽기 전용 테이블은 조회 속도를 높이기 위해 최적화됩니다.

실시간 데이터 처리와 CQS

실시간으로 데이터를 처리해야 하는 시스템에서도 CQS는 중요한 역할을 합니다. 실시간 애플리케이션에서 명령과 질의를 구분하면 실시간 데이터 스트림과 배치 처리를 별도로 관리할 수 있습니다. 실시간 시스템에서는 상태 변경이 자주 발생할 수 있기 때문에, 명령 작업은 비동기적으로 처리하고, 질의 작업은 빠르게 반영된 상태만 조회하도록 분리하는 것이 이상적입니다.