행동 관련 : 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