상태마저 객체로 만들어보자
라는 아이디어에서 프로그램을 조금 더 객체 지향적으로 구현하기 위해 상태 패턴을 사용하였다. (feat. 네오)
이번 포스팅에서는 상태 패턴이 무엇인지, 그리고 프로그램에 상태 패턴을 적용한 예시를 보여주고 어떠한 장점과 단점이 있는 지 분석해보려고 한다.
상태 패턴이란?
상태 패턴(State Pattern)은 객체가 상태에 따라 행위를 다르게 할 때, 상태를 객체화하여 이러한 행위를 상태 객체에게 위임하는 디자인 패턴이다.
객체의 상태가 클래스로 구현되고, 상태에 따른 행위가 클래스 내 메서드로 구현된다.
아주 간단한 예시로 상태 패턴을 이해해보자.
public interface YoutubeState {
YoutubeState start();
YoutubeState stop();
YoutubeState end();
}
유튜브의 재생 상태를 제어하기 위한 인터페이스이다.
이 인터페이스를 구현해서 상태마다 각 행위가 요청되었을 때 어떤 동작을 할 지 정의해보겠다.
public class StartState implements YoutubeState {
@Override
public YoutubeState start() {
throw new IllegalStateException("이미 재생 중입니다.");
}
@Override
public YoutubeState stop() {
return new StopState();
}
@Override
public YoutubeState end() {
return new EndState();
}
}
영상이 재생 중인 경우, start()
메서드에 대한 요청이 들어오면 잘 못된 메서드 호출이므로 IllegalStatementException
예외를 반환한다.
그리고 stop()
메서드가 호출되면 영상이 재생 중일 때, 중단 요청이 들어온 것이므로 새롭게 갱신되어야 할 상태인 StopStatus
객체를 반환한다.
마지막으로 영상을 종료시키는 요청인 end()
가 호출되면 영상을 종료한 상태인 EndState
객체를 반환한다.
마찬가지로 StopState, EndState에 대한 구현을 해본다.
public class StopState implements YoutubeState {
@Override
public YoutubeState start() {
return new StartState();
}
@Override
public YoutubeState stop() {
throw new IllegalStateException("이미 중단된 상태입니다.");
}
@Override
public YoutubeState end() {
return new EndState();
}
}
public class EndState implements YoutubeState {
@Override
public YoutubeState start() {
return new StartState();
}
@Override
public YoutubeState stop() {
throw new IllegalStateException("재생이 끝나서 중단할 수 없습니다.");
}
@Override
public YoutubeState end() {
return this;
}
}
이렇게 상태 패턴의 간단한 예시를 보았다.
어떤 생각이 드는가?
나 같은 경우에는 조금 불편한 것 같다고 생각했었다. 클래스 개수가 늘어나고, 굳이 선언할 필요가 없다고 생각하는 예외가 던져진다고 생각했다. 그냥 분기문으로 처리하면 제일 간단해질 것 같은데 .. 😮 하는 생각이 들었다.
그래서 미션 도중에는 상태 패턴을 적용해보지 않았지만, 새로운 것을 배우는 것에 의의가 있다는 생각이 들어 한 번 시도해보았다. 어떻게 적용하였는지, 그리고 그 과정에서 느낀 장점과 단점은 무엇인지 고찰해보자.
기존 프로그램에 적용해보기
체스 게임을 구현한 프로그램에 상태 패턴을 적용해보았다.
기존에 구현한 코드
체스 게임을 진행하는 가장 중요한 객체는 ChessGame이다.
ChessGame에서 게임을 시작하고, 체스 말을 이동하고, 게임을 끝내는 역할을 담당한다. 게임 상태에 따라 서로 다른 동작을 하기 위해 ChessGame이 GameState를 가지고 있고, 이 GameState는 ENUM 클래스로 정의된다.
public class ChessGame {
private ChessBoard chessBoard;
private GameState gameState;
private Turn turn;
public ChessBoard start() {
validateAlreadyRunning();
gameState = RUNNING;
turn = new Turn(Color.WHITE);
chessBoard = ChessBoardGenerator.generate();
return chessBoard;
}
public MoveResult move(final Position source, final Position target) {
validateNotRunning();
validateInvalidTurn(source);
this.gameState = chessBoard.move(source, target);
turn.changeTurn();
ChessGameStatus chessGameStatus = new ChessGameStatus(chessBoard, turn);
return new MoveResult(chessGameStatus, gameState, chessBoard.findPieceByPosition(target));
}
public void end() {
gameState = STOPPED;
}
public enum GameState {
READY,
RUNNING,
STOPPED,
;
}
상태가 하나의 객체가 아닌 단순히 ChessGame이 알고 있는 지식
일 때의 코드이다. 상태를 관리하는 역할이 ChessGame에 있기 때문에 (1) 상태를 변경하고 (2) 올바르지 않은 상태일 때 요청된 행위인 경우 예외를 반환하는 책임을 짊어지고 있다.
그러다보니 ChessGame의 역할이 너무 과도하다라는 생각이 들었다. ChessGame의 역할은 체스 게임을 진행하는 역할인데, 상태 관리에 대한 역할도 수행하고 있었다.
이러한 역할을 누구에게 위임할 수 있을까? 상태 패턴을 사용해서 상태 객체에게 위임해보자.
상태 패턴 적용 후
게임 특성 상 시작 전 / 시작 후 (진행 중) / 종료 세 가지 상태로 나눌 수 있었다. 이에 맞추어 게임의 상태를 나타내는 인터페이스를 만들고, 프로그램에서 발생할 수 있는 행위를 메서드로 선언하였다.
public interface GameState {
GameState start();
GameState move(ChessBoard chessBoard, Position source, Position target);
GameState end();
}
이제 상태 클래스를 구현하여 각 상태마다의 행위를 상태 객체에게 위임해보자.
start() 개선하기
먼저 start() 메서드의 상태 관리 기능을 위임하여 어떻게 달라졌는지 살펴보겠다
public ChessBoard start() {
validateAlreadyRunning();
gameState = RUNNING;
chessBoard = ChessBoardGenerator.generate();
return chessBoard;
}
private void validateAlreadyRunning() {
if (gameState == RUNNING) {
gameState = READY;
throw new IllegalStateException("[ERROR] 게임이 이미 진행 중입니다. 기존 게임을 종료하고 다시 시작합니다.");
}
}
게임을 시작하기 위해서 start() 메서드는 아래 여러 가지 책임을 가지고 있었다.
(1) RUNNING
상태인지 체크하고 그렇다면 오류를 반환한다
(2) 게임 상태를 RUNNING으로 초기화한다.
(3) 체스판을 초기화한다.
(1) 번은 이미 시작된 상태인 경우에 start() 메서드가 호출되면 처리해야하는 부분이다. 따라서 RunningState
구현체를 만들고 내부에 메서드를 오버라이딩하여 오류를 반환하도록 한다.
public class RunningState implements GameState {
@Override
public GameState start() {
throw new IllegalStateException("이미 시작한 경우 다시 시작할 수 없습니다.");
}
...
}
(2) 번의 경우 준비 상태인 경우에 start() 메서드가 호출되면 처리해야하는 부분이므로 아래와 같이 ReadyState
클래스에서 구현한다.
public class ReadyState implements GameState {
@Override
public GameState start() {
return new RunningState();
}
...
}
코딩 중 ... 👩💻👩💻👩💻👩💻 ...
public ChessBoard start() {
this.gameState = gameState.start();
chessBoard = ChessBoardGenerator.generate();
return chessBoard;
}
짠 ! 이렇게 start() 메서드가 단 세 줄로 줄어들었다.
move() 개선하기
public ChessBoard move(final Position source, final Position target) {
validateNotRunning();
validateInvalidTurn(source);
this.gameState = chessBoard.move(source, target);
return chessBoard;
}
private void validateNotRunning() {
if (gameState != RUNNING) {
throw new IllegalStateException("[ERROR] 게임이 진행 중인 상태가 아닙니다.");
}
}
private void validateInvalidTurn(final Position source) {
Piece sourcePiece = chessBoard.findPieceByPosition(source);
if (turn.isNotTurn(sourcePiece)) {
throw new IllegalArgumentException("[ERROR] 현재는 " + turn.getName() + "의 이동 차례입니다.");
}
}
기존의 코드이다. 마찬가지로 상태에 대한 검증과 이동을 담당하고 있었다.
더 심각한 점은, 게임의 상태를 전혀 알고 있을 필요가 없는 ChessBoard가 아래와 같이 상태를 반환해야하는 것이다.
private GameState checkGameIsEnded(final Position target) {
if (isCheckmate(target)) {
return CHECKMATE;
}
return RUNNING;
}
해결 방법은 상태 객체가 위임 받을 수 있는 부분을 최대한 걸러내는 것이다.
(1) validateNotRunning()
위와 마찬가지로 RUNNING 상태가 아닌 모든 상태 객체에서 move()
가 호출된 경우 예외를 반환하도록 한다.
(2) validateInvalidTurn()
(3) chessBoard.move()
현재 Turn을 ChessGame이 직접 가지게 되면서, 이 검증 역할도 ChessGame이 맡게 되는 경우이다. 이를 상태 객체가 담당하게 할 수 없을까?
RUNNING 상태를 담당하는 객체인 RunningState를 상속한 MoveState 객체를 만들었다. (Move 상태는 Running 상태에서 동작할 수 있는 행위 중 하나인 하위 상태
라고 생각해서 이렇게 구현하였다.)
public class MoveState extends RunningState {
private final Color color;
public MoveState(final Color color) {
this.color = color;
}
@Override
public GameState move(
final ChessBoard chessBoard,
final Position source,
final Position target
) {
if (source.isDifferentColor(color)) {
throw new IllegalArgumentException("현재는 " + color.name() + "차례입니다.");
}
chessBoard.move(source, target);
if (chessBoard.isCheckmate(target)) {
return new CheckmateState();
}
return new MoveState(color.opposite());
}
}
그리고 MoveState가 현재 Turn인 Color를 직접 갖도록 하여, 관련된 로직도 여기서 처리하였다.
와! ChessGame의 검증 역할이 두 개나 줄었다.
더구나 ChessBoard도 게임의 상태를 신경쓰지 않고 체스판의 말을 이동시키는 것에만 집중할 수 있게 되었다.
이제 바뀐 move() 메서드를 볼까?
public ChessBoard move(final Position source, final Position target) {
this.gameState = chessBoard.move(source, target);
return chessBoard;
}
이동도, 검증도 모두 상태 객체가 대신 해주고 있어서 move() 메서드가 아주 깔끔해졌다. 👍👍👍
end() 개선하기
end는 간단하다.
public void end() {
gameState = STOPPED;
}
기존에서도 상태를 변경하는 역할만 하기 떄문에, RUNNING 상태일 때 호출된 경우에 EndState 객체를 반환해주고, 그 외에는 예외를 던지면 된다.
public abstract class RunningState implements GameState {
..
@Override
public GameState end() {
return new EndState();
}
그럼 이렇게 무엇으로 변경할 지
결정하는 책임이 상태 객체로 넘어가서, 조금 더 캡슐화를 적용할 수 있다.
public void end() {
this.gameState = gameState.end();
}
짠!
이렇게 상태 패턴을 사용해서 ChessGame의 상태 관리 역할을 상태 객체에게 위임하였고, 이를 통해 코드를 개선해보았다.
상태 패턴을 이용해 개선하며 느낀 장단점
- 장점
- 게임 도메인처럼 상태가 명확하고 상태에 따라 행위가 많이 달라지는 경우 상태 패턴을 적용했을 때 그 효과가 컸다. 상태가 객체로 분리됨에 따라 상태를 가지고 있던 도메인이 상태 관리를 하지 않게 되었고, 코드가 훨씬 깔끔해졌다.
- A라는 상태에서 B라는 행위를 하면 C라는 상태를 반환한다. 혹은 A라는 상태에서 B라는 행위를 하면 오류를 반환한다. 등 요구사항에서 제시된 부분들을 코드로 직관적으로 확인할 수 있다. 만약 A 상태에서 B 행위를 했을 때 C가 아니라 D를 반환하라 ! 라고 요구사항이 변경된다면 A 상태 객체에 찾아가서 B 메서드를 변경해주기만 하면 되니까.
- 단점
- 상태 객체의 책임이 어디까지인지 결정하기 어려웠다. 나는 MoveState를 구현하면서 move()를 위한 모든 정보들이 상태 객체로 넘어가는데, 그럼 상태 객체에서 실제로 chessBoard.move()를 호출해도 되는건가? 라는 의문이 들었다. 코드가 깔끔해지는 이점이 있더라도 상태 객체는 상태 외부의 것은 일절 모르고, 오로지 상태만 핸들링하는 것에서 끝나야 하는 건가 ? 이러한 책임의 경계를 결정하는 것이 어려울 수 있을 것 같았다. 만약 상태 패턴을 적용해야한다면, 상태 객체가 어디까지 책임을 지는 지 확실히 결정하는 것이 좋겠다.
- 클래스가 늘어난다. 디자인 패턴을 적용하다보면 공통적으로 생기는 문제점인데 상태를 모두 객체화하다보니 상태의 개수만큼 클래스가 생성되었다. 이 또한, 상태가 얼마나 많은지 / 상태마다 처리해줘야 하는 행위가 얼마나 복잡한지에 따라 상태 패턴의 적용 여부를 결정하면 좋을 것 같다. (경우에 따라서는 분기문으로 단순하게 처리해주는 게 더 간단할 수 있다. 😄)
상태 패턴 공부 끗 !
참고 자료
https://tecoble.techcourse.co.kr/post/2021-04-26-state-pattern/
+) ⭐네오 ⭐ 의 명 강 의
'우아한테크코스 > 레벨1' 카테고리의 다른 글
[회고] 우아한테크코스 6기 백엔드 레벨1 방학 회고 & 레벨 2 목표 (7) | 2024.04.20 |
---|---|
[OOP] 객체 분리의 필요성? 객체에게 적절한 책임을 부여해야 하는 이유 (3) | 2024.04.11 |
[OOP] 값 객체(VO, Value Object)란? 원시값 포장과 값 객체의 차이점은? (4) | 2024.04.10 |
[회고] 우아한테크코스 레벨 1 회고 : 성장에 대하여 (7) | 2024.04.04 |
[회고] 우아한테크코스 6기 백엔드 레벨1 7주차 회고 (0) | 2024.04.01 |