디자인 패턴 공부 - 상태 패턴

이혁진·2023년 2월 3일
0

상태 패턴

상태 패턴은 상태에 따라 다른 행동을 하는 상황에서(if절), 행동과 상태를 State로 캡슐화한 것이다. 취할 행동을 State 인터페이스의 구현체에 위임한다. 다른 말로 하면 객체 지향 방식으로 상태 기계를 구현하는 것이라고도 한다. 아무튼 캡슐화해서 인터페이스에 의존하게 하므로 결합이 느슨해진다. 또한, 상태별로 클래스를 나누었으니 응집도 상승의 효과도 볼 수 있겠다. 이 패턴이 특히 중요한 이유는 다른 디자인 패턴보다도 특히 비즈니스 로직에서 쓸 일이 많기 때문이다. 쓰면 당연히 좋겠지만, 유난히 크게 체감이 된다고 한다.

구현

if절로 상태에 따라 행동을 분기할 때, 상태와 행동을 State로 캡슐화한다고 했다. 이걸 그대로 구현하면 된다. 다이어그램을 보자.

일단, Context가 그 if절 로직이 있는 클래스이다. 가령 이렇게 있다고 해보자.

public class Context {
	...
    // State : Enum
    setState(State newState) {
    	this.state = newState
    }
    ...
    public handle() {
    	if(state == State.STATE1) {
        	this.operation1();
        }
        else if(state == State.STATE2) {
        	this.operation2();
        }
        else if(state == State.STATE3) {
        	this.operation3();
        }
        else {
        	...
        }
    }
}

지금이야 간단하지만, 나중에 보면 진짜 겁나 복잡해진다. 또 어디서 봤는데 if절은 쓰는거 아니라고 했다. 대신에 다형성을 쓰라고 했던거 같은데, 그게 이거인 것 같다. 아무튼 코드를 설명하면 setState로 상태를 바꾸거나 하고, handle은 그것에 따라 분기된 작업을 수행한다. 상태 패턴을 쓰면

public class Context {
	...
    // State : Enum
    setState(State newState) {
    	this.state = newState
    }
    ...
    public handle() {
    	state.handle();
    }
}

무척이나 간단해졌다. 상태 관련 로직이 바뀌어도 위임된 클래스만 바뀌면 되고, DIP의 충족으로 인해 서브클래싱해서 넣어주기만 하면 된다. 스프링과 같이 쓰면 금상첨화.

일단 저렇게 해서 if절 구조가 잘 캡슐화된 것은 알겠다. 또, 인터페이스로 빼내서 뭔가 바꿔끼기도 쉽다는 점도 알겠다. 근데, 잘 생각해보면 위임을 하는 부분이 좀 이상하다. 원래 자기 필드로 잘 처리하던 Context의 필드를 State의 구현체들도 다 알아야 하는 것 아닌가? 싶다.

가령 저 handle()에서 멤버로 가지고 있던 리스트의 특정 원소를 삭제한다고 해보자. handle()의 이름은 deleteOne()정도가 적당하겠다. 그러면, State에서는 다음과 같이 구현한다.

public class ConcreteStateA implements State {
	@Override
    public void deleteOne() {
    	...
    }
}

결국 저기에서도 Context의 필드 리스트에 접근해야 할 텐데, private으로 되어있어서 막 접근할 수 없다. 다음의 해결책이 있다.

첫째, 리스트를 public으로 바꾼다. 이거는 말이 필요 없다. 그냥 안된다.

둘째, 리스트 getter를 만든다. 위랑 비슷하다. 변경을 가해야 하니 복사본을 전달할 수도 없다. 굉장히 위험한 방식이다.

셋째, Context에 리스트에 접근하는 internalDeleteOne()이라는 함수를 만들고 State에서는 리스트의 참조를 받는 대신 internalDeleteOne()함수를 사용한다. 근데 이것도 이상하다. 따른 클래스에서 Context를 쓸 때, internalDeleteOne()이 public으로 열려있기 때문이다. 또다른 묘수는 protected로 놓고 State가 Context를 상속하는 건데, 이거는 논리적으로 아다리가 안맞는다.

넷째, call stack을 조사해서 호출자가 State의 구현체이면, 그냥 리스트에 접근하고 아니면 State에 위임한다. 이거는 말이 되는 것 같지만 안된다. 애초에 리스트에 접근한다는 작업 자체가 State를 무시하는 것이다. 가령

public class Context {
	...
    public deleteOne(~) {
    	if (콜스택 State 구현체 발견!) {
        	state.deleteOne(~);
        }
        else {
        	list.remove(~);
        }
    }
    ...
}

어떤 State 구현체는 deletion을 안할수도 있고, 추가를 할 수도 있는데, 막 remove() 해버리면 안된다는 말이다.

내 머리로는 뾰족한 수가 떠오르지 않는다. 둘째 방식인 필드 자체의 참조를 getter로 넘겨버리거나, 셋째 방식대로 internal~ 이런 식으로 State가 사용할 메소드를 또 만드는 것이 적당할 것 같다. stack 조사하는거 구현했는데, 구현하고나서야 뭔가 말이 안맞는걸 깨달아버렸다. 쌔빠지게 짰는데, 안타깝구만. 다시 보니까 진짜 말이 안되는 것 같다.

public void addStudent(Student student) {
    // State 가 콜스택에 있다면
    if(isStateInterfaceCalled()) {
        students.add(student);
    }
    else {
        state.addStudent(this, student);
    }
}

private boolean isStateInterfaceCalled() {
    StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
    String targetCallerClassName = "Behavior.State._01_state_on.State";

    boolean found = false;
    // 호출 스택 순회
    for(StackTraceElement s : stackTraceElements) {
        // 현재 호출자 클래스의 인터페이스들 순회
        try {
            Class<?>[] cs = Class.forName(s.getClassName()).getInterfaces();
            for(Class<?> c : cs) {
                if (c.getName().equals(targetCallerClassName)) {
                    found = true;
                    break;
                }
            }
        } catch (ClassNotFoundException e) {
            System.out.println("Class not found");
        }

        if (found) {
            break;
        }
    }

    return found;
}

또 개선할 포인트가 있긴 하다. State 객체를 캐싱해도 될 것 같은데, 싱글턴 말고는 lazy 방식으로 생성하는 수가 안떠오른다. (싱글턴은 인터페이스에 못쓰는 걸로 알고 있다. 구상클래스에 일일이 싱글턴 구현하는건 좀 그렇지 않은가.) 그래서 그냥 냅둔다.

아무튼 이러한 상태 패턴의 적용 전과 후로 나누어 구현해보겠다.

일단 상태를 분기하는 것을 그림으로 나타내면 저렇다. 저리 보면 알아보기 쉬운데, if절로 바꾸니 뭔가 복잡해보인다. 실전에서는 아마 더 복잡할 것이다. 아무튼 저렇게 강의의 상태에 따라 학생을 등록하고, 학생의 리뷰를 저장하는 온라인 강의 코스 클래스는 다음과 같다.

public class OnlineCourse {
	public enum State {
    	DRAFT, PUBLISHED, PRIVATE
	}

	private State state;
	private List<String> reviews;
	private List<Student> students;
	private List<Integer> availableStudents;

	public OnlineCourse() {
    	this.state = State.DRAFT;
    	this.reviews = new ArrayList<>();
    	this.students = new ArrayList<>();
    	this.availableStudents = new ArrayList<>();
	}

	public void addReview(String review, Student student) {
    	if (state == State.PUBLISHED) {
        	// PUBLISHED 상태이면 리뷰를 누구나 달 수 있다.
        	reviews.add(review);
    	}
    	else if (state == State.PRIVATE && students.contains(student)) {
        	// PRIVATE 상태이면 등록된 회원만 리뷰 달 수 있음
        	reviews.add(review);
    	}
    	else {
        	// DRAFT 상태이거나
        	// PRIVATE 인데, 회원이 등록이 안되어있다면
        	throw new UnsupportedOperationException("review addition failure!!");
    	}
	}

	public void addStudent(Student student) {
    	if (state == State.DRAFT || state == State.PUBLISHED) {
        	// DRAFT 이거나 PUBLISHED 상태이면 학생 등록 가능
        	students.add(student);
    	}
    	else if (state == State.PRIVATE && isAvailable(student)) {
        	// PRIVATE 상태이더라도 쿠폰? 같은 게 있으면 등록이 가능
        	students.add(student);
    	}
    	else {
        	// PRIVATE 상태 중에서 학생이 불가능하면 등록 불가능
        	throw new UnsupportedOperationException("student addition failure!!!");
    	}

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

	public void changeState(State newState) {
    	this.state = newState;
	}

	public State getState() {
    	return state;
	}

	public boolean isAvailable(Student student) {
    	return availableStudents.contains(student.getId());
	}

	public void setAvailable(Student student) {
    	if(!isAvailable(student)) {
        	availableStudents.add(student.getId());
    	}
	}

	public void setUnAvailable(Student student) {
    	if(isAvailable(student)) {
        	availableStudents.remove(student.getId());
    	}
	}
}

if절이 엄청 복잡하니까 이 상태들과 행동을 State로 캡슐화한다. 자연히 응집도가 높아지고, Context에 대한 ConcreteState들의 의존 관계가 발생하게 된다.(Context를 알아야 Context에서 하던 일을 위임받지 않겠는가) 그리고 리스트들은 어쩔 수 없이 getter로 참조를 받는다.

public interface State {
	// 컨텍스트는 여러번 쓰일 테니까 위임 아니고, 연관으로 하자.
	// 쓰일 Context 를 거기에서 주입받아 사용
	public void addStudent(OnlineCourse o, Student student);
	public void addReview(OnlineCourse o, String review, Student student);
}

Context를 필드에 놓고 생성자로 받아버리면, State를 Client 같은 데에서 쓸 때

onlineCourse.setState(new Draft(onlineCourse));

이런 이상한 코드가 나오니까, 그냥 매개변수로 받는다.

public class OnlineCourse {
	private State state;
	private List<String> reviews;
	private List<Student> students;
	private List<Integer> availableStudents;

	public OnlineCourse() {
    	changeState(new Draft());
    	this.reviews = new ArrayList<>();
    	this.students = new ArrayList<>();
    	this.availableStudents = new ArrayList<>();
	}

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

	public void addStudent(Student student) {
        state.addStudent(this, student);
    }

	public boolean isAdded(Student student) {
    	return students.contains(student);
	}

	public int getReviewSize() {
    	return reviews.size();
	}

	public void changeState(State newState) {
    	this.state = newState;
	}

	public State getState() {
    	return state;
	}

	public boolean isAvailable(Student student) {
    	return availableStudents.contains(student.getId());
	}

	public void setAvailable(Student student) {
    	if(!isAvailable(student)) {
        	availableStudents.add(student.getId());
    	}
	}

	public void setUnAvailable(Student student) {
    	if(isAvailable(student)) {
        	availableStudents.remove(student.getId());
    	}
	}
	
    public List<Student> getStudents() {
    	retern students;
    }
    
    public List<String> getReviews() {
    	return reviews;
    }
}

public class Draft implements State {
	@Override
	public void addStudent(OnlineCourse o, Student student) {
    	o.getStudents().add(student);
    	System.out.println(student.getName() + " : " + Message.addStudentSucceed);
	}

	@Override
	public void addReview(OnlineCourse o, String review, Student student) {
    	System.out.println(review + " : " + Message.addReviewFailure);
    	if (o.getReviewSize() > 1) {
        	o.changeState(new Private());
    	}
	}
}

public class Private implements State {
	@Override
	public void addStudent(OnlineCourse o, Student student) {
    	if(o.isAvailable(student)) {
        	o.getStudents().add(student);
        	System.out.println(
            	student.getName() + " : " + Message.addStudentSucceed
        	);
    	}
    	else {
        	System.out.println(
            	student.getName() + " : " + Message.addStudentFailure
        	);
    	}
	}

	@Override
	public void addReview(OnlineCourse o, String review, Student student) {
    	if(o.isAdded(student)) {
        	o.getReviews().add(review);
        	System.out.println(
                	review + " : " + Message.addReviewSucceed
        	);
    	}
    	else {
        	System.out.println(
                	review + " : " + Message.addReviewFailure
        	);
    	}
	}
}

public class Published implements State {
	@Override
	public void addStudent(OnlineCourse o, Student student) {
    	o.getStudents().add(student);
    	System.out.println(student.getName() + " : " + Message.addStudentSucceed);
	}

	@Override
	public void addReview(OnlineCourse o, String review, Student student) {
    	o.getReviews().add(review, student);
    	System.out.println(review + " : " + Message.addReviewSucceed);
	}
}

이렇게 된다. 아까 복잡했던 부분이 훨씬 간단해지고, 변경이 용이해졌다.

장점

장점은 State를 응집하여 관리할 수 있고, 그것의 행동을 인터페이스로 캡슐화함으로써 확장성과 유연성을 얻게 되었다는 것이다. 반면 이전보다 훨씬 클래스가 많아졌고, State의 구현체들이 Context에서 하던 작업을 위임받아 하기 위해 Context의 필드들을 알게 되었다. 즉, Context가 여러 Concrete State에게 의존당하는 높은 결합도의 클래스가 되어버린 것이다. 결합도와 응집도 사이에서 잘 고민을 해보는 것이 좋을 것 같다.

그림으로 보면 이렇다.

원래는 Context 안에 다 있었는데, 위임하다보니 클래스가 쪼개지고, 데이터들이 공유되느라 의존관계가 발생하게 되었다. 결합도와 응집도의 trade-off를 잘 보여주는 예시인 것 같다.

profile
한양대학교 정보시스템학과 22학번 이혁진 입니다

0개의 댓글