강의

멘토링

로드맵

Inflearn brand logo image

인프런 커뮤니티 질문&답변

강명덕님의 프로필 이미지
강명덕

작성한 질문수

오브젝트 - 설계 원칙편

6-2. 명령 쿼리 분리 원칙으로 부수효과 관리하기

6-2. 명령과 쿼리 분리 원칙 질문

해결된 질문

작성

·

343

1

6-2. 명령 쿼리 분리 원칙으로 부수효과 관리하기에 관해 궁금한 점이 있어 질문드립니다!

 

명령과 쿼리 분리하기 14:43 ~

해당 부분에서 Playermove 메서드를 canMove라는 쿼리와 move라는 명령으로 분리했는데, 여기서 궁금한 점이 있습니다.

 

  1. Player는 이동 가능 여부에 대해 스스로 판단하고 그 결과에 따라 상태를 스스로 변경하는데, GametryMove에서 성공과 실패에 대해 try-catch 구문을 사용하지 않고 if 분기를 사용하는 이유가 무엇인가요?


    저는 tryMove에서 canMove 쿼리 호출 없이 move 명령을 호출하고, 성공/실패에 대한 처리는 try-catch에서 예외처리하는 것이 더 간단하다 생각했습니다.



    이번 강의인 6-2의 Player와 8-2 아이템 이동 로직 개선 강의의 Transfer 객체 둘 다 동일한 구조로 코드가 짜여져있어 if 분기를 사용한 이유가 궁금합니다!

  1. 현재 구조에서 Playermove 메서드에 예외를 던지는 if 분기가 없다면 GamePlayer의 이동에 관련된 모든 것을 결정하며 TDA를 위반하는 코드라 생각합니다.



    그렇다면 if 분기는 TDA를 위반하지 않기 위해 넣은 것인가요? 아니면 Player 와 협력하는 다른 클라이언트가 canMove 호출 없이 move를 호출할 경우를 대비해 넣은 것인가요?

    후자라고 생각하기에는 1번 질문처럼 Player가 스스로 판단할텐데 try-catch 대신 if 분기를 넣은게 잘 와닿지 않아 질문드립니다!

 

답변 2

1

조영호님의 프로필 이미지
조영호
지식공유자

강명덕님 안녕하세요.

두 가지 질문에 대해 차례대로 답변을 드릴게요.

 

1. Game에서 if 분기를 사용한 이유

강의의 흐름을 보시면 Player 클래스의 move 메서드가 CQS를 위반하는 move 메서드를 제공한다는 것을 알 수 있습니다.

move는 이동 여부를 반환하는 쿼리인 동시에 position을 변경하는 명령이었죠.

public class Player {  
  public boolean move(Direction direction) {
    if (worldMap.isBlocked(position.shift(direction))) {      
      return false;    
    } 
    
    this.position = this.position.shift(direction);
    return true;     
  }

이 메서드를 CQS를 만족하도록 만들려면 move 메서드의 반환 타입은 void로 변경해야 합니다.

하지만 이렇게 하면 클라이언트인 Game 클래스가 성공 여부를 알 수 없겠죠.

여기에서 잠깐 객체지향에서 예외(exception)의 올바른 사용 방법을 살펴볼 필요가 있는데 예외는 정상적인 플로우가 아니라 발생할 수 없는 예외적인 케이스에만 사용하는게 좋습니다.

(실무에서는 실용적인 이유때문에 플로우 변경을 위해 예외를 사용하는 경우가 많지만 엄격하게 보면 올바른 예외 사용 방법은 아니라고 생각하시는게 좋아요).

여기에서 캐릭터가 지도를 이동하던 도중에 벽이 막혀서 이동할 수 없다는 플로우는 예외적인 케이스가 아니라 정상적인 플로우에 속합니다.

따라서 예외를 던지는게 아니라 canMove 같은 쿼리를 이용해서 클라이언트가 이동 가능한 지 체크해서 막히자 않은 경우에만 이동한다는 플로우를 명시적으로 표시해 주는게 좋겠죠.

public class Game {
  private void tryMove(Direction direction) {    
    if (player.canMove(direction)) {    // 이동 가능한 경우에는
      player.move(direction);           // 지도 이동
      showRoom();
      return;    
    }
    
    showBlocked();                      // 막혀있다면 이동 불가능
  }
}

그러면 Player의 move 메서드에서 다시 canMove 메서드를 호출한 뒤에 예외를 던지는 이유가 궁금하실텐데요

이 상황에서 예외가 발생한다는 것은 클라이언트가 반드시 canMove 메서드를 호출해서 이동 가능한지 여부를 확인한 후에 move 메서드를 호출해야 한다는 계약을 어긴 경우이기 때문입니다.

따라서 정상적인 상황이 아니라 인터페이스를 잘못 사용한 예외적인 경우이기 때문에 예외를 던지는게 정당화됩니다.

 

2. TDA 위반 여부

1번에 대한 답변에서 if문을 사용한 이유는 이해가 되셨을것 같고 대신 TDA에 대해 좀 더 자세히 설명드릴게요.

Player의 move 메서드를 보시면 canMove 메서드를 호출해서 이동 가능한지 스스로 체크하고 이동 불가능한 경우 예외를 던지기 때문에 자신의 상태를 스스로 관리하고 있습니다.

따라서 Player의 move 메서드는 스스로 판단하고 스스로 자신의 위치를 변경하기 때문에 자율적인 객체이며 TDA 위반이 아닙니다.

TDA를 위반하는 코드는 아래 처럼 필요한 상태를 물어본 후에 반환 값을 이용해서 직접 객체의 상태를 변경하는 구조를 가집니다.

public class Game {
    private void tryMove(Direction direction) {
        Position position = player.position();
        WorldMap worldMap = player.worldMap();
        
        if (!worldMap.isBlocked(position.shift(direction))) {
            player.setPosition(position.shift(direction));
            showRoom();
            return;
        }
        
        showBlocked();
    }
 }

위 코드를 보시면 Player에게 내부에 포함하고 있는 position과 worldMap에 대해 물어본 후에 이들을 기반으로 결정하고 position을 직접 수정하고 있습니다.

반면에 강의의 코드는 내부 상태가 아니라 이동 가능한지 여부를 물어보고(이 판단은 Player가 직접 합니다) 어떤 위치로 가야하는지도 Player가 직접 결정합니다.

move 메서드 안의 if문을 제거하더라도 클라이언트가 내부의 상태를 물어본 후에 직접 가능 여부를 판단하지 않고 Player가 스스로 판단하기때문에 TDA 위반이 아닙니다.

따라서 앞에서 설명드린 것처럼 if문은 Player 와 협력하는 다른 클라이언트가 canMove 호출 없이 move를 호출하는 인터페이스 사용 규칙을 위반한 경우를 대비해서 넣은게 맞습니다.

 

답변이 되었는지 모르겠네요. 😊

강명덕님의 프로필 이미지
강명덕
질문자

상세한 답변 감사합니다! 2번 답변에 대한 궁금한 점과 추가 질문이 있어 답변 드립니다.

 

1번 답변은 이해가 되었습니다!

"캐릭터가 지도를 이동하던 도중에 벽이 막혀서 이동할 수 없다" 가 정상 플로우 이므로 클라이언트가 플로우를 명시적으로 표시 하는 방식으로 사용하는 것이네요.

 

"1) canMove 메서드로 이동 가능 여부 확인하고 2) 이동 가능하면 move 호출"을 해야하는데, 그렇지 않고 "인터페이스를 잘못 사용한 예외적인 경우"에는 예외를 던지도록 설계했다는 점을 이해했습니다!

 

2번 답변에 대해서는 의문점이 남아있습니다

제가 질문을 잘못 드린 것 같은데, 기존 코드인 Playermove 내에서 canMove를 호출해 스스로 이동 가능 여부를 확인하는 상황에서는 TDA를 위반하지 않는다고 생각합니다!

 

제가 가정했던 상황은 Playermove 메서드의 예외를 던지는 if 구문을 제거한다면 아래와 같은 코드라고 생각했습니다.

public void move(Direction direction) {
    this.position = this.position.shift(direction);
}

이 상황에서 GamecanMovePlayer 상태를 확인하고 move로 직접 이동시키는 것 같아서 TDA를 위반한다고 생각했습니다.

public void tryMove(Direction direction) {
    if (player.canMove(direction)) { // Player 상태 확인
        player.move(direction); // Player를 direction 으로 이동
        showRoom();
        return;
    }

    showBlocked();
}

위 상태에서도 GametryMove 로직은 TDA를 위반하지 않는 것인가요?

 

추가 질문 - 인터페이스 사용 규칙 강제 또는 위반 여부 확인 방법

그렇다면 Player를 사용하는 다른 클라이언트를 사용할때, Player가 제공하는 인터페이스 사용 방식(여기서는 if분기를 두고 canMove 호출 후 move 호출)을 강제하거나 위반한 여부를 확인하는 방법은 어떤게 있나요?

 

현업에서는 여러 개발자들과 함께 협업하게 될 것 같아서 이런 부분을 어떻게 신경써야 하나 고민을 좀 해보았는데요,

  • 강제하는 방법

     

     

    • 룰 또는 컨벤션을 정하고 모두가 인지하게 한다. (문서화나 구두 전달 등)


      ex) 클라이언트에서 사용할 메서드 구현 내용을 확인하고 move 처럼 내부에 예외를 던지는 로직이 있다면, 해당 조건식에 사용된canMove 등의 쿼리를 클라이언트에서 이용하도록 한다.
      (올바른 인터페이스 사용 방법을 준수하도록 한다)

  • 위반 여부를 확인하고 고치는 방법


    • move를 호출하는 클라이언트 메서드에 대해 테스트코드를 작성하고 예외가 던져진다면 위반한 것이므로 앞서 언급한 강제 방법에 기술했던 규칙 등을 참고해 올바르게 사용하도록 고친다.
      (테스트코드를 작성하도록 강제한다)

    • 클라이언트에서 사용할 객체의 작성자(Player 작성자)를 코드 리뷰어로 두고 코드리뷰를 진행한다.

위 방법들이 적절한지는 모르겠습니다. 현업에서도 테스트코드를 작성하지 않는 곳도 있다고 하고, 모두가 이해하고 인정하고 인지해야 인터페이스의 적절한 사용 규칙을 준수할텐데, 추천하는 다른 방법이 있는지 궁금합니다!

조영호님의 프로필 이미지
조영호
지식공유자

1. TDA 위반 여부

네, TDA 위반이 아닙니다.

TDA는 반환값을 가지는 메서드를 사용하느냐가 아니라 내부 상태를 캡슐화하고 있느냐로 결정됩니다.

앞에서 예로 들었던 TDA 위반 코드는 Player 내부에 postion과 worldMap이 존재한다는 사실과, worldMap을 이용해서 이동 가능한지 여부를 결정한다는 사실을 Game에게 노출시킵니다.

그리고 그 판단을 이용해서 다시 Player의 내부의 position을 직접 수정하고 있습니다.

public class Game { 
  private void tryMove(Direction direction) { 
    Position position = player.position(); 
    WorldMap worldMap = player.worldMap();

    if (!worldMap.isBlocked(position.shift(direction))) {
        player.setPosition(position.shift(direction));
        showRoom();
        return;
    }
    
    showBlocked();
  }
}

즉, Game이 Player가 내부에 캡슐화하고 있는 position과 worldMap에 관해 물은 후에, 이 값을 기반으로 이동 가능한 지 직접 결정한 후, position을 직접 변경하고 있습니다.

따라서, TDA 위반입니다.

 

반면에 강의의 예제 코드는 이동 가능한지 여부를 Player가 직접 판단하고 내부 상태도 직접 변경합니다.

Game에게 Player 내부에 대해 어떤 정보도 제공하지 않고, 모든 결정은 Player가 직접 결정합니다.

어떤 클래스가 어떤 부분에 대해 질문한다고 해서 반드시 TDA 위반인 것은 아닙니다.

TDA의 핵심은 데이터와 그 데이터를 직접 사용하는 행동을 같은 객체 안에 위치시키는 것입니다.

TDA 위반은 내부 상태와 상태에 기반한 결정이 외부로 노출되었는지, 다시 말해서 캡슐화를 위반했는지 여부로 결정됩니다.

Player의 canMove 메서드는 비즈니스 로직을 실행하지 내부 상태를 직접 반환하고 있지 않습니다.

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

이 메서드를 호출하는 클라이언트는 Player의 내부 상태에 속하는 position과 worldMap에 결합되지 않습니다.

단지, Player가 이동 가능한지 여부를 확인할 뿐이며 이건 상태가 아니라 런타임에 계산된 결과를 요청하는 것뿐입니다.

Player의 move 메서드는 단지 자신이 캡슐화하고 있는 position을 이용해서 direction으로 어떻게 이동해야 하는지를 스스로 결정하고 스스로 상태를 변경하기 때문에 TDA를 준수합니다.

Game은 내부에 position이 포함되어 있다는 사실은 전혀 모른 채 단순히 ‘어떤 방향으로 이동해 주세요’라고 말하고 있을 뿐이죠.
(move 메서드의 파라미터가 내부에 캡슐화하고 있는 Position 타입이 아니라 내부 상태와는 아무런 상관이 없는 Direction이라는 점이 중요합니다. Game은 Player의 내부에 Position이 있다는 사실을 아예 알지 못합니다.)

단지 내부 구현 측면에서 정합성 체크를 누락했기 때문에 좌표가 마이너스가 되는 버그가 발생할 수 있다는 점은 문제겠죠.

public void move(Direction direction) {
  this.position = this.position.shift(direction);
}

 

2. 추가 질문 - 인터페이스 사용 규칙 강제 또는 위반 여부 확인 방법

이 질문은 단순히 canMove와 move 메서드로 나눈 경우에만 국한된 문제는 아닙니다.

  • 리팩터링 전의 boolean을 반환하는 메서드에서 boolean이 이동 성공 여부를 반환한다는것을 커뮤니케이션해야 합니다.

public boolean move(Direction direction) { 
  ...
}
  • canMove를 구현하지 않고 move 내부에서 예외를 던지도록 구현할 경우 move 메서드를 호출하는 클라이언트는 이 예외를 항상 catch해서 처리해야 한다는 사실을 커뮤니케이션해야 합니다.

public void move(Direction direction) {
  ...
  throw new IllegalArgumentException("");
  ...
} 

즉, 어떤 경우에도 객체는 내부 상태에 기반해서 동작하기 때문에, 메서드를 호출할 때 전달되는 파라미터와 객체의 상태를 기반으로 호출 가능 여부를 판단합니다.

예를 들어서 자바 표준에서 제공하는 Iterator의 경우에도 hasNext()를 호출해서 요소가 존재할 경우에만 next() 메서드를 호출할 수 있습니다.

이 규칙을 어기면 NoSuchElementException이 던져지죠.

Iterator<String> iterator = list.iterator(); 
while (iterator.hasNext()) { 
  String item = iterator.next(); ... 
}

객체지향 설계를 하게 되면 항상 객체의 상태에 따라 메서드를 호출할 수 있는지, 규칙을 어겼을 때 어떤 예외가 던져지는지를 전달해야 하는 상황에 직면하게 됩니다.

자세한 내용은 계약에 의한 설계(Design By Contract)라는 개념을 한번 보시면 좋습니다.제가 집필한 오브젝트라는 책의 부록에서도 계약에 의한 설계 내용을 자세히 다루고 있으니 참고 하셔도 좋을것 같습니다.

 

질문으로 돌아와서 이 내용을 커뮤니케이션 할 수 있는 다양한 방법이 있습니다.

  • 이 코드가 범용적으로 사용되는 코드라면 javadoc으로 문서화를 하는게 가치가 있습니다. Iterator의 javadoc을 보시면 이 인터페이스를 사용할 개발자를 위해 hasNext와 next 사이의 관계에 대해 문서화하고 있습니다.

public interface Iterator<E> {
    /**
     * Returns {@code true} if the iteration has more elements.
     * (In other words, returns {@code true} if {@link #next} would
     * return an element rather than throwing an exception.)
     *
     * @return {@code true} if the iteration has more elements
     */
    boolean hasNext();

    /**
     * Returns the next element in the iteration.
     *
     * @return the next element in the iteration
     * @throws NoSuchElementException if the iteration has no more elements
     */
    E next();
    ...
}
public class PlayerTest {
    @Test
    public void move_east() {
        ...      
        assertThat(player.canMove(Direction.EAST)).isTrue();

        // canMove가 true일 때 호출하면 정상적으로 실행
        player.move(Direction.EAST);     
        assertThat(player.position()).isEqualTo(Position.of(1, 0));
    }

    @Test
    public void can_not_move_west() {
        try {
            ...
            assertThat(player.canMove(Direction.WEST)).isFalse();
            
            // canMove가 false일 때 호출하면 테스트 실패
            player.move(Direction.WEST);
            fail();
        } catch (IllegalArgumentException ex) {}
    }
    ...
}
  • 예외가 발생하지 않아야 한다면 예외 메시지에 명시적으로 명시해주는 것도 대안이 될 수 있습니다.

     

public void move(Direction direction) {
  if (!canMove(direction)) {
    throw new IllegalArgumentException("canMove 메서드가 true를 반환하는 경우에만 호출가능합니다. 먼저 canMove 메서드를 호출하세요.");
  }
  ...
}

 

이렇게 문서화된 내용을 코드 리뷰나 전파 등을 통해 명시적으로 커뮤니케이션하는게 가장 좋겠죠.

 

답변이 되었는지 모르겠네요. 🙂

강명덕님의 프로필 이미지
강명덕
질문자

답변 감사드립니다! 특히 중요한 부분인 TDA의 핵심과 위반 여부 결정요소에 관해 볼드와 파란색으로 하이라이팅 해서 더 집중해 읽을 수 있었습니다.

 

PlayercanMove 메서드는 비즈니스 로직을 실행하는 것으로 “내부 상태”를 직접 반환하지 않고, move 메서드도 캡슐화하고 있는 position을 이용해 스스로 결정하므로 TDA를 준수한다는 것 이해했습니다!

 

move의 메서드 파라미터 Direction이 캡슐화 하고 있는 내부 상태와는 상관이 없기 때문에 GamePlayer가 캡슐화한 position에 대해 모르는 것이군요. 

 

커뮤니케이션 방법으로 javadoc 문서화와 동작 방식을 파악할 수 있는 테스트케이스 작성 방식 및 명시적인 예외 메시지 등 다양한 방법도 알려주셔서 감사합니다! 많이 배워갑니다.

0

안녕하세요, 인프런 AI 인턴입니다. 강명덕님. 질문 주셔서 감사합니다.

명령과 쿼리를 분리하는 원칙에서 발생할 수 있는 의문점들을 잘 지적해 주셨습니다. 이에 대해 아래와 같이 답변 드리겠습니다.

  1. try-catch 대신 if 분기를 사용하는 이유:

    • 예외 처리는 일반적으로 오류 상황이나 비정상적인 흐름을 처리하는 데 사용됩니다. 반면, 옵션 확인 후 실행과 같은 흐름 제어에는 if 분기가 적합합니다. canMove 메서드를 통해 이동 가능 여부가 확인되면 상태 변경이 가능한 상태를 명확하게 알 수 있습니다.
    • 예외 처리는 코드를 복잡하게 만들고 성능에 영향을 줄 수 있기 때문에, 가능한 경우 정상 플로우(옵션 체크 후 명령)에서는 사용을 지양하는 것이 좋습니다. 이는 개발자들이 상당 부분 동의하는 방식입니다.
  2. if 분기를 사용한 이유:

    • TDA 준수: Player 객체가 스스로 자신의 상태를 관리하게 하는 것은 정보 은닉과 캡슐화를 유지하게 합니다. if 분기는 Player의 상태를 외부에서 판단하기보다, Player가 스스로 판단하게 함으로써 TDA를 지키려는 의도와 관계가 있습니다.
    • 방어적 프로그래밍: canMove 메서드 없이 호출되는 move에 대해 방어적인 역할을 합니다. 이는 잘못된 호출을 대비한 검증 역할을 하며, 로직의 안전성을 높이는 데 기여합니다.

질문에서 제기된 모든 사항은 결국 명령과 쿼리 분리의 목적이 코드의 명확성과 유지보수성, 그리고 객체의 책임을 명확히 분할하게 하기 위한 선택입니다.

관련하여 도움될만한 자료는 다음과 같습니다:

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

강명덕님의 프로필 이미지
강명덕

작성한 질문수

질문하기