객체지향

질문이 답변을 수정해서는 안 된다 - CQS, 읽히는 코드를 만드는 원칙

경딩 2026. 4. 21. 21:29

코드는 동작하는 것만 봤는데, 이번에 왜 이렇게 설계했는지 보려고 했다.

 

이 글에서 얻어갈 것들

  • 동작은 되는데 찜찜한 코드의 정체
  • 명령과 쿼리를 왜 나눠야 하는지에 대한 실무적인 이유
  • 메서드 시그니처만 보고 부수효과를 예측하는 방법
  • CQS 를 단순 개념이 아닌 설계 기준으로 사용하는 방법
  • 값 객체와 결합해 부수효과 범위를 좁히는 패턴
  • CQS가 오히려 독이 되는 상황과 그 판단 기준

개발을 하다 보면 이런 코드를 만나게 된다.

boolean result = schedule.includes(day);

 

겉으로 보면 "값을 반환하는 함수"다.

그런데 실행해보면 결과가 달라진다.

같은 파라미터인데, 두 번째 호출은 다른 값을 돌려준다.

 

이런 코드는 처음엔 잘 동작한다.

하지만 시간이 지날수록 점점 이해하기 어려워진다.

 

이 글에서 그 이유를 설명하고, 어떻게 고쳐야 하는지를 다룬다.


명령과 쿼리 - 개념 정리

모든 메서드는 두 가지로 나뉜다.

 

종류 설명
명령 (Command)  객체의 상태를 변경하지만 값을 반환하지 않는 메서드
쿼리 (Query) 객체의 상태를 변경하지 않지만 값을 반환하는 메서드

 

CQS - 질문이 답변을 수정해서는 안된다

Command-Query Separation (CQS)
메서드는 명령이거나 쿼리여야 하고, 둘 다여서는 안된다.

원칙은 단순하다.

  • 명령은 반환값을 가져서는 안 된다. 상태를 바꾸면 void로 끝낸다.
  • 쿼리는 상태를 바꿔선 안 된다. 값을 반환하면 읽기 전용이어야 한다.

왜 이게 중요할까?

 

명령과 쿼리의 역할이 섞이면, 코드를 실행해보기 전까지 결과를 예측할 수 없다.

내부 구현을 직접 열어보지 않으면 "이 메서드를 부르면 상태가 바뀌는가" 를 알 수 없게 된다.


나쁜 예시 - includes()가 상태를 바꾸는 순간

반복 일정을 관리하는 Schedule 클래스가 있다.

새로운 요구사항이 추가됐다.

"날짜가 일정 조건을 만족하지 않으면, 그 날짜를 포함하도록 일정을 자동으로 조정해라."

이걸 구현한 결과가 이렇다.

public boolean includes(LocalDate day) {
    if (!plan.includes(day)) {
        plan.reschedule(day); // ← 상태를 변경!
        return false;
    }
    return true;
}

 

시그니처만 보면 boolean includes(LocalDate day) - 쿼리처럼 생겼다. "오늘 일정이 있나요" 라고 묻는 것 같다.

그런데 실제로는 일정을 조정해버린다.

assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isFalse(); // false — 그리고 일정이 조정됨
assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isTrue();  // true — 방금 조정됐으니까

 

같은 인자로 같은 메서드를 두 번 불렀는데 결과가 달라진다. 이게 바로  CQS 위반이 만들어내는 함정이다.

협업에서 이런 버그는 찾기 매우 어렵다. 메서드 이름은 쿼리인데 명령처럼 동작하기 떄문에, 테스트를 짤 때도 "설마 이게 상태를 바꾸겠어?" 하고 방심하게 된다. 이런 코드가 쌓이면 팀 전체가 메서드 이름을 믿지못하고, 결국 모든 코드를 내부까지 열어봐야 이해할 수 있는 코드 베이스가 된다.

 


리펙토링 - 명령과 쿼리를 분리한다.

// 쿼리: 값만 반환, 상태 변경 없음
public boolean includes(LocalDate day) {
    return plan.includes(day);
}

// 명령: 상태만 변경, 반환값 없음
public void adjust(LocalDate day) {
    plan.reschedule(day);
}


이제 includes()를 몇 번 불러도 결과가 항상 같다.

assertThat(schedule.includes(day)).isFalse();
assertThat(schedule.includes(day)).isFalse();
assertThat(schedule.includes(day)).isFalse(); // 몇 번을 불러도 안전

schedule.adjust(day); // 명시적으로 상태 변경

assertThat(schedule.includes(day)).isTrue();

 

그리고 여기서 중요한 게 하나 더 생긴다.

 

명령 전후로 쿼리를 여러 번 호출해도 결과가 동일하다는 보장이 생긴다.

includes() 를 열 번 부르고 adjust()를 한 번 부른 뒤 다시 includes()를 열 번 불러도, adjust()호출 전까지는 모든 includes() 결과가 동일하다고 확신할 수 있다.

 

한 번만 분석하면 나머지는 신뢰할 수 있다는 뜻이다.

이게 단순히 "코드가 깔끔해진다" 를 넘어서, 읽는 사람이 머릿속에 추적해야 할 상태의 양이 줄어드는 것이다.


메서드 시그니처가 계약이 되는 순간

CQS 를 팀 전체가 따르면, 메서드 시그니처가 계약서가 된다.

시그니처 의미 사용방식
boolean includes(...) 쿼리 — 상태 변경 없음 마음껏 여러 번 호출 가능
void adjust(...) 명령 — 상태 변경 있음 신중하게, 의도적으로 호출

 

반환 타입이 있으면 쿼리다. 부수효과 없이 안전하게 사용할 수 있다.

반환 타입이 없으면 명령이다. 부수효과가 있으니 주의가 필요하다.

 

이 코드를 처음 보는 사람이 내부를 열지 않고도 이해 할 수  있는가? 를 생각하면 된다.

코드는 작성하는 시간보다 읽는 시간이 훨씬 길다.

메서드 이름과 시그니처만으로 동작을 예측할 수 있어야, 팀이 그 코드를 믿고 사용할 수 있다.

 


한 단계 더 - 값 객체와 결합하면 부수효과 범위가 좁아진다.

명령과 쿼리를 분리해도 문제가 하나 남는다.

schedule.adjust(day)를 호출하면 상태 변경이 일어나는데, 실제 변경은 Schedule이 아니라 WeeklyPlan 또는 MonthlyPlan 내부에서 일어난다.

schedule.adjust() 호출
  → plan.reschedule() 호출
    → WeeklyPlan 또는 MonthlyPlan 내부 상태 변경

 

부수 효과를 이해하려면 세 개의 클래스를 동시에 봐야 한다는 뜻이다.

메시지를 받는 객체와 상태가 바뀌는 객체가 다르기 때문이다.

해결책은 RecurringPlan을 불변 값 객체로 만드는 것이다

// 기존: 자신의 상태를 직접 변경 (명령)
public void reschedule(LocalDate day) {
    this.dayOfWeek = day.getDayOfWeek();
    this.ordinal = (day.getDayOfMonth() / DAYS_IN_WEEK) + 1;
}

// 변경 후: 새로운 객체를 반환 (쿼리처럼 동작)
public MonthlyPlan reschedule(LocalDate day) {
    return new MonthlyPlan(
        day.getDayOfWeek(),
        (day.getDayOfMonth() / DAYS_IN_WEEK) + 1
    );
}


이제 Schedule.adjust()는 이렇게 된다.

public void adjust(LocalDate day) {
    this.plan = plan.reschedule(day); // ← 여기서만 상태 변경
}


RecurringPlan 계층은 더 이상 스스로 상태를 바꾸지 않는다.

부수효과가 Schedule.adjust()하나로 집약된다.

"이 코드에서 상태가 어디서 바뀌는가" 라는 질문에 이제 Schedule 클래스 하나만 보면 된다.

구체적으로 어떤 값이 바뀌는지 궁금할 때만  RecurringPlan 계층을 들여다보면 된다.

값 객체는  단순히 불변성을 위한 기술이 아니다. 부수효과의 범위를 명시적으로 좁히는 설계 전략이다. 부수효과가 어디서 일어나는지 눈에 보이게 만들면, 버그가 생겼을 때 찾아야 하는 코드의 양이 극적으로 줄어든다.

실전 예제 — 텍스트 어드벤처 게임

게임 플레이어의 이동 코드다.

public boolean move(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {
        return false;
    }
    this.position = this.position.shift(direction); // 부수효과!
    return true;
}
 

시그니처만 보면 쿼리처럼 보인다. boolean 을 반환하니까, "동쪽으로 이동할 수 있나요" 라고 묻는 것 같다.

 

그런데 내부에서는 위치를 바꾸고 있다.

player.move(Direction.EAST); // 이동하면서 true 반환
player.move(Direction.EAST); // 이미 벽에 닿았으니 false 반환


두 번째 호출이 다른 결과를 내는 이유를 알려면, 첫 번째 호출이 상태를 바꿨다는 사실을 알아야 한다.

시그니처만 믿었다가 틀리는 전형적인 패턴이다.

 



어떤 문제가 발생하는지 알아보기 위해 테스트 케이스를 하나 만들어 보겠습니다.

    @Test
    public void day_of_week_in_month_includes() {
        Schedule schedule = new Schedule(
                "월간 회의", LocalTime.of(13, 0), Duration.ofHours(1),
                new MonthlyPlan(DayOfWeek.MONDAY, 2));

        assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isFalse();
        assertThat(schedule.includes(LocalDate.of(2025, 1, 16))).isTrue();
    }


리팩토링:

// 쿼리: 이동 가능 여부만 확인
public boolean canMove(Direction direction) {
    return !worldMap.isBlocked(position.shift(direction));
}

// 명령: 실제 이동 (이동 불가 시 예외)
public void move(Direction direction) {
    if (!canMove(direction)) {
        throw new IllegalStateException();
    }
    this.position = this.position.shift(direction);
}


사용하는 쪽은 이렇게 된다.

public void tryMove(Direction direction) {
    if (player.canMove(direction)) {
        player.move(direction);
        showRoom();
    } else {
        showBlocked();
    }
}

 

여기서 하나 짚고 넘어갈 것이 있다.

누군가는 이렇게 물을 수 있다.

"Game이 player에게 canMove()로 물어보고 나서 move()를 시키는 건 묻지말고 시켜라 원칙 위반 아닌가?"

 

아니다. 

Player.move() 내부를 보면, canMove()를 호출해서 이동 가능한 경우에만 위치를 변경한다. 

플레이어가 자신이 이동할 수 있는지 스스로 판단하고, 스스로 상태를 변경하고 있다.

Game은 그저 "이동해라"고 시킬 뿐이다.

 

판단과 실행의 주체가 Game이 아니라 Player 자신이라는게 핵심이다.

CQS 를 따를 때 이 패턴은 자주 나온다. 클라이언트가 쿼리로 상태를 확인하고, 명령으로 변경을 요청하는 구조다.


CQS의 트레이드오프 — 원칙이 독이 되는 순간

CQS를 배우고 나면 모든 메서드에 적용하고 싶어진다. 그런데 현업에서는 이 원칙을 기계적으로 적용했다가 오히려 코드가 나빠지는 경우가 있다.

대표적인 케이스가 검증 비용이 클 때다.

canMove() → move() 패턴을 보면, canMove() 내부에서 이미 벽 충돌 여부를 계산한다. 그리고 move() 내부에서도 같은 계산을 다시 한다.

public boolean canMove(Direction direction) {
    return !worldMap.isBlocked(position.shift(direction)); // 계산
}

public void move(Direction direction) {
    if (!canMove(direction)) { // 같은 계산을 다시
        throw new IllegalStateException();
    }
    this.position = this.position.shift(direction);
}

 

지금 예제에서는 단순한 위치 계산이라 비용이 거의 없다.  하지만 이 검증 로직이 DB 조회거나 외부 API  호출이라면 이야기가 달라진다.

같은 검증을 두 번 수행하는 셈이 된다.

 

일정 조정이 필요한 경우에는 adjust 명령을 이용해서 명시적으로 상태를 변경시켜야 합니다.

이런 상황에서 실무 감각은 이렇다.

검증 비용이 무시할 없는 때는, 쿼리를 분리하지 않고 명령 안에 검증을 포함시킨다.

public void move(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {
        throw new IllegalStateException("이동 불가");
    }
    this.position = this.position.shift(direction);
}

 

호출하는 쪽은 분기 없이 명령만 던지고, 실패 시 예외로 처리한다. 검증이 한 번만 일어나고, 책임도 Player 안에 있다.

 

물론 이 방식은 CQS 를 엄격하게 따르지 않는다.

move()가 성공/실패를 예외로 전달하기 때문에, 호출자가 사전에 가능 여부를 확인 할 수 없다.

 

이게 나쁜 선택인가? 상황에 따라 다르다.


상황 선택
검증 비용이 낮고, 호출 전 확인이 필요한 경우 canMove() + move() 분리
검증 비용이 높거나, 항상 실행 후 결과만 필요한 경우 명령 안에 검증 포함

핵심 판단 기준은 하나다.

이 쿼리를 분리하는 게 복잡도를 낮추는가? 아니면 높이는가?

CQS 는 복잡도를 낮추기 위한 도구다. 원칙을 지키기 위해 복잡도가 올라간다면, 그건 도구를 잘 못 쓰는것이다.

원칙을 처음 배울 때는 "항상 적용해야 한다"고 생각하기 쉽다. 하지만 실무에서는 원칙과 판단을 위한 기준이지, 사고를 대신하는 규칙이 아니다. CQS를 이해했다면 다음 질문은 "어떻게 적용하지" 가 아니라 "지금 이 상황에 분리하는 게 맞는가" 여야 한다.

 


정리

CQS가 주는 것을 한 줄씩 정리하면 이렇다.

  • 쿼리는 믿을 수 있다. 몇 번을 불러도 결과가 같다.
  • 명령은 표시가 난다. void 시그니처가 여기서 상태가 바뀐다고 선언한다.
  • 명령 사이 쿼리는 안전하다. 명령이 호출되기 전까지 쿼리 결과는 동일하다고 확신할 수 있다.
  • 값 객체와 결합하면 부수효과 범위가 좁아진다. 분석해야 할 클래스가 줄어든다.
  • 분리가 복잡도를 높인다면 분리하지 않는다. 원칙은 목적이 아니라 수단이다.

좋은 코드는 단순히 오해하지 않는 코드가 아니다.

읽는 사람이 이전 호출과 숨겨진 상태 변화를 머릿속에 계속 추적하지 않아도 되는 코드다.

CQS 는 그 인지 비용을 줄이기 위한 설계 원칙이다.

이렇게 명령 쿼리 분리 원칙과 값 객체를 혼합하면 도
수 효과를 이해하기 위해 분석해야 하는 코드의 수를 감소시킬 수 있습니다.