StatCounter - Free Web Tracker and Counter

행동 관련 : State 패턴

반응형

들어가기 전

이 글은 인프런 백기선님의 GOF 강의를 수강하며 작성한 글입니다. 

 


State 패턴

  • GOF : 객체 내부 상태 변경에 따라 객체의 행동이 달라지는 패턴.
    • 특정한 상태에 따라 행동이 달라지는 객체들에게 적용할 수 있는 패턴임.
    • 예를 들면 리모컨은 TV가 켜져있는지, 꺼져있는지에 따라서 버튼을 눌렀을 때 다르게 동작함. 
      • 이런 경우 If ~ Else로 떡칠되게 됨. 이런 코드들을 읽기 어렵기 때문에 State에 따른 If - Else 문을 각 클래스로 분리하는 것임.
  • 디자인 패턴 Component 
    • Context
      • 상태를 관리하는 클래스임. 이를 위해 상태 변경 메서드도 추가되어야 함. 
      • 컨텍스트 객체가 원래 담고 있어야 하는 정보들을 가지고 있는 클래스.
    • State
      • 공통 인터페이스로 만들어 둔 것임. 이 인터페이스의 구현체는 상태에 따라 달라지는 행위들을 작성함. 
      • Context는 공통 인터페이스에 의존함. 
    • ConcreteState
      • 상태에 따라 달라지는 행위들을 작성함. 
  • Context 클래스는 상태에 따라 달라지는 Operation을 모두 State에게 위임한다. 즉, Operation은 ConcreteState가 모두 수행하도록 한다. 

 


디자인 패턴이 필요한 경우 

public class OnlineCourse {

    public enum State {
        DRAFT, PUBLISHED, PRIVATE
    }

	...

    public void addReview(String review, Student student) {
        if (this.state == State.PUBLISHED) {
            this.reviews.add(review);
        } else if (this.state == State.PRIVATE && this.students.contains(student)) {
            this.reviews.add(review);
        } else {
            throw new UnsupportedOperationException("리뷰를 작성할 수 없습니다.");
        }
    }

    public void addStudent(Student student) {
        if (this.state == State.DRAFT || this.state == State.PUBLISHED) {
            this.students.add(student);
        } else if (this.state == State.PRIVATE && availableTo(student)) {
            this.students.add(student);
        } else {
            throw new UnsupportedOperationException("학생을 해당 수업에 추가할 수 없습니다.");
        }

        if (this.students.size() > 1) {
            this.state = State.PRIVATE;
        }
    }
    ...
}

위의 OnlineCourse는 State 패턴을 적용하기 좋은 코드다. 왜냐하면 특정 상태에 의존적으로 Operation이 많이 변경되기 때문이다. 

  • 상태는 Draft, Published, Private이 있음. 
  • addReview(), addStudent()에서는 Draft, Published, Private 상태에 따라 서로 다르게 동작함. 

현재 이 코드의 문제점은 메서드 내에서 State에 따라 서로 다른 동작을 하는 것이 If ~ Else 구문으로 구별되어있고, 이런 코드들 때문에 '읽기 어려운 코드'가 된다는 것이다. 따라서 상태에 따른 If ~ Else 분기를 State 클래스로 분리하고, State 클래스에게 Deligation 하는 방식으로 코드를 개선해 볼 수 있다. 

 

 


State 패턴을 적용한 코드 

State 패턴을 적용하기 위해서 Context, State를 각각 정의해야한다. 어떻게 분리해 볼 수 있을까? 

  • Context 
    • OnlineCourse 클래스가 Context가 됨.
    • OnlineCourse는 상태를 가지고 있으며, State가 작업을 하는데 필요한 전반적인 정보들 (students, review)를 가지고 있기 때문임. 
  • State
    • Draft, Published, Private으로 분리함. 
    • 인터페이스는 addReview(), addStudent()를 선언한다. 기존 코드에서 상태에 따라 다르게 동작하는 코드는 addReview(), addStudent()에만 있었기 때문임. 

이런 것들을 고려해서 아래에 State, Context 코드를 하나씩 작성해보자. 

public class Draft implements State {

    private final OnlineCourse onlineCourse;

    public Draft(OnlineCourse onlineCourse) {
        this.onlineCourse = onlineCourse;
    }

    @Override
    public void addReview(String review, Student student) {
        throw new UnsupportedOperationException("리뷰를 작성할 수 없습니다.");
    }

    @Override
    public void addStudent(Student student) {
        this.onlineCourse.getStudents().add(student);
    }
}
  • Draft는 State 인터페이스를 구현한다. 
  • 기존 OnlineCourse에서 if (this.state == DRAFT) 분기로 처리되던 코드들을 모두 Draft로 옮긴다. 
  • 이 때, 각 addReview(), addStudent()는 자신의 상태에 대한 내용만 정의되어있다. 이렇게 되기 때문에 State 패턴을 적용하면 더욱 읽기 쉬운 코드가 되는 것이다.

State를 분리하게 되면서 UnsupportedOperationException도 더 명확해진다. 각 State에 맞는 에러 문구를 출력해 줄 수 있께 된다. 예를 들어 기존 코드에서는 Private에서는 '리뷰를 작성할 수 없습니다'라고 공통으로 처리되었겠지만, State 클래스로 분리된 후에 Private State 클래스에서는 '비공개 회원은 리뷰를 작성할 수 없습니다'라는 식으로 따로 명시해 줄 수 있기 때문이다.

// Context Class
public class OnlineCourse {

    private State state;
    private List<String> reviews = new ArrayList<>();
    private List<Student> students = new ArrayList<>();

    public OnlineCourse() {
        this.state = new Draft(this);
    }

    public void addReview(String review, Student student) {
        this.state.addReview(review, student);
    }

    public void addStudent(Student student) {
        this.state.addStudent(student);
        if (this.students.size() > 1) {
            changeState(new Private(this));
        }
    }

    public void changeState(State newState) {
        this.state = newState;
    }
    ...
}
  • OnlineCourse의 addReview(), addStudent()에 있던 State에 의존적인 분기는 모두 State 클래스로 넘겨졌다.
  • 따라서 OnlineCourse의 코드도 읽기 편해졌고, 각 State별로 필요한 내용만 가지고 있기 때문에 전체적으로 읽기 쉬운 코드가 된다. 

이 때 OnlineCourse라는 Context는 State가 바뀔 수 있기 때문에 다음 기능을 추가하는 작업을 해야했다.

  1. 자신이 어떤 상태인지 State를 내부 참조로 가지고 있음. 
  2. 자신의 상태를 외부에서 변경할 수 있도록 public API를 작성함. 

 

 


State 패턴의 장단점

  • 장점 
    • State에 따라 다른 동작을 각 Concrete State 클래스에 옮겨담아서 가독성을 올림. 
    • 개별적인 클래스로 State와 Operation을 분리했기 때문에 Unit Test가 더 쉬워짐. 
    • 새로운 State, Operation을 추가하기 쉬워짐.
  • 단점
    • 상태를 나누는 경우가 불필요함에도 이렇게 나누는 경우가 있을 수 있음. (ON / OFF 정도의 상태라면... 오버엔지니어링..?)

State 패턴을 적용할 수 있으면 적용하는 것이 좋을 듯 하다. 

개별적인 클래스로 State와 Operation을 분리했기 때문에 단위 테스트 하기가 더 쉬워진다. 기존에는 거대한 클래스에 If ~ Else 문으로 구현이 되어있어 테스트 해야하는 범위가 넓어서 테스트 코드를 작성하기가 어려웠었다. 그러나 이런 부분이 State 단위로 Operation이 분리되었기 때문에 Operation을 테스트 하기 매우 쉬워진 편이다. 

또한, Context - State는 '인터페이스'를 기반으로 느슨한 결합을 하고 있다. 따라서 새로운 State + Operation이 정의되더라도 기존에 존재하던 Context, State 클래스에는 어떠한 영향도 주지 않는다. 즉, OCP 원칙을 지키게 된다.

만약 State 패턴을 적용하지 않았다면 기존 OnlineCourse 클래스에 If ~ Else 문으로 새로운 상태에 대한 정의를 추가해줘야만 한다. 이런 경우, 기존의 코드를 수정하는 작업이 이루어지기 때문에 확장에 용이한 구조는 아니다. 

 


State 패턴과 Strategy 패턴의 차이점은? 

처리해야 할 알고리즘을 Strategy, State로 캡슐화해서 사용하는 것은 비슷하지만 두 가지가 다른 점이 있다.

  • Strategy 패턴은 객체의 내부 상태와는 관련없이 자신이 실행할 알고리즘을 선택해서 동작한다.
  • State 패턴은 객체의 내부 상태에 맞추어서 정해진 알고리즘대로 동작한다. 

즉, 둘다 캡슐화된 알고리즘 인터페이스에 위임하는 것은 맞지만 Strategy 패턴은 객체의 내부 상태를 고려하지 않는다. 

댓글

Designed by JB FACTORY