[OOP] 상태 패턴과 리스코프 치환 원칙
(상태 패턴과 리스코프 치환 원칙에 대한 자세한 설명을 하지는 않습니다.)
레벨 1의 체스 미션 리팩토링을 진행하며 체스 게임에 상태 패턴을 적용하였다.
이 과정에서 상태 패턴이 리스코프 치환 원칙을 만족하는지 의문이 들었고, 고민을 해결한 과정을 기록해 둔다.
기존의 ChessGame 코드
본론에 들어가기에 앞서, 상태 패턴을 적용하게 된 이유를 간략하게 설명하고 넘어간다.
기존의 나의 ChessGame 코드는 대략 다음과 같았다.
(설명에 필요한 부분만 최소한으로 작성 되었다. 이 때의 코드는 https://github.dev/zangsu/java-chess/tree/eb80050b953edaf5f1316183ee92beaf3620f9c3 에서 볼 수 있다. )
public class ChessGame {
private Board board = new Board();
private GameState state = GameState.PREPARING;
public void tryMove(Location source, Location target) {
if (state != GameState.PLAYING) {
throw new IllegalStateException("게임이 시작되지 않았습니다. 게임을 시작해 주세요");
}
board.tryMove(source, target);
}
public void endGame() {
state = GameState.END;
}
}
public class GameController {
private final ChessGame chessGame;
public GameController() {
chessGame = new ChessGame();
}
private void play() {
while (chessGame.isPlayable()) {
Command command = INPUT_VIEW.readCommand();
Optional.ofNullable(commandFunctions.get(command))
.orElseThrow(() -> new IllegalArgumentException("잘못된 커멘드 입력입니다."))
.run();
}
}
private void move() {
Location source = Location.of(INPUT_VIEW.readLocation());
Location target = Location.of(INPUT_VIEW.readLocation());
chessGame.tryMove(source, target);
OUTPUT_VIEW.printBoard(chessGame.getBoard());
}
private void end() {
chessGame.endGame();
}
}
보는 바와 같이 게임의 진행 상태가 가변 필드인 GameState 로 관리되고 있다.
프로젝트에서 전반적으로 불변 객체를 사용하고 있었기 때문에 이 부분 역시 불변성을 제공해 보면 어떻겠냐고 리뷰어가 제안을 해 주셨다.
게임의 상태는 항상 변화되어야 하는 값인데, 불변 객체로 만드는 것이 어색하게 느껴졌고, 이 부분을 DM으로 질문을 드렸다.
그리고, 답은 다음과 같았다.
결국 내 코드의 endGame() 은 chessGame.setState(State.END) 와 다를 바가 없었던 것이다.
이 부분을 해결하기 위해 상태 패턴을 도입했다.
변경된 코드
이제 나의 ChessGame 는 체스 게임이 제공해야 하는 동작들을 정의한 인터페이스로 변경한다.
public interface ChessGame {
boolean isNotEnd();
ChessGame startGame(Supplier<Boolean> checkRestart);
ChessGame endGame();
void move(Location source, Location target);
}
게임을 진행하는 GameController 는 상태가 변화할 수 있는 ChessGame 의 메서드를 실행할 때 필드에 메서드의 반환값을 재할당한다.
( 물론, 이제는 GameController 의 chessGame 이 가변 필드이지만, 프로그램이 동작하기 위해선 어딘가는 변화해야 한다. Controller 의 필드가 변화하는 것은 도메인이 변경되는 것에 비해 훨씬 자연스럽다고 생각한다. )
public class GameController {
private ChessGame chessGame;
public GameController() {
chessGame = new InitialGame();
}
private void play() {
while (chessGame.isNotEnd()) {
Command command = INPUT_VIEW.readCommand();
Optional.ofNullable(commandFunctions.get(command))
.orElseThrow(() -> new IllegalArgumentException("잘못된 커멘드 입력입니다."))
.run();
}
}
private void move() {
Location source = Location.of(INPUT_VIEW.readLocation());
Location target = Location.of(INPUT_VIEW.readLocation());
chessGame.move(source, target);
OUTPUT_VIEW.printBoard(chessGame.getBoard());
}
private void end() {
chessGame = chessGame.endGame();
}
}
마지막으로, 각 상태를 나타내는 ChessGame 의 구현체들을 구현한다.
public class EndGame implements ChessGame {
@Override
public boolean isNotEnd() {
return false;
}
@Override
public ChessGame startGame(Supplier<Boolean> checkRestart) {
throw new IllegalStateException("이미 게임이 종료되었습니다.");
}
@Override
public ChessGame endGame() {
throw new IllegalStateException("이미 게임이 종료되었습니다.");
}
@Override
public void move(Location source, Location target) {
throw new IllegalStateException("이미 게임이 종료되었습니다.");
}
}
혹시 무슨 문제라도...?
내 코드를 구경하던 초코칩이 말했다.
짱수의 EndGame 은 상위 타입인 ChessGame 이 제공해 주어야 하는 기능을 제공하지 못하는 거 아니야?
ChessGame.move() 를 호출하면 당연히 기물이 움직일 것으로 예상하지만, EndGame.move() 는 기물을 움직이는 대신 예외를 발생시키잖아.
머리에 번개가 쳤다.
정말 내 EndGame 은 리스코프 치환 원칙을 지키지 못하는 것일까?
대부분의 상태 패턴은 지금의 내 코드와 같이 흐름을 진행하는 곳에서의 조건 분기를 없애고 해당 상태에서 사용할 수 없는 메서드를 호출하면 예외를 발생시키도록 구현된다. 그렇다면, 상태 패턴이 적용된 모든 코드는 리스코프 치환 원칙을 지키지 못하는 것인가?
이에 대해 구구, 네오의 이야기를 들을 수 있었으며, 의견은 다음과 같다.
구구 :
딱히 리스코프 치환 원칙을 어기고 있는 것 같지 않다. 상위 클래스가 제공하려고 했던 메서드를 수행하지 못해 예외를 터뜨리는 것은 충분히 자연스러운 모습 아닐까?
네오 :
충분히 리스코프 치환 원칙을 어기고 있다고 생각할 수 있지만 SOLID 는 말 그대로 준수하기를 권장되는 원칙이다.
개발자는 항상 모순과 싸워야 하는 직업이다. sealed 예약어만 보더라도, 상위 클래스에서 하위 클래스로 의존성이 생길 수 밖에 없지만 때로는 사용하는 것이 더 좋은 경우가 있는 것 처럼.
상태패턴 역시 리스코프 치환 원칙을 어기고 있지만, 그 의도가 충분히 담겨있다면 문제가 되지 않는다고 생각한다.
오... 벌써 두 코치의 의견이 조금 다른 것을 볼 수 있다.
그렇다면, SOF 의 다른 개발자들은 어떻게 생각할까? 구글에 'does state pattern violate liskov substitution?' 를 키워드로 검색을 해 보았다! 그 결과 다음의 두 가지 아티클을 찾아볼 수 있었다.
https://stackoverflow.com/questions/40082090/state-design-pattern-in-compliance-with-liskov
https://softwareengineering.stackexchange.com/questions/181922/does-the-state-pattern-violate-liskov-substitution-principle
보여준 예시가 상태패턴인지 아닌지 부터 다양한 의견이 있었는데, 그 중 내가 주목했던 의견은 다음과 같다.
(This is written from the point of view of C#, so no checked exceptions.)
According to Wikipedia article about LSP, one of the conditions of LSP is:
"No new exceptions should be thrown by methods of the subtype, except where those exceptions are themselves subtypes of exceptions thrown by the methods of the supertype."
How should you understand that? What exactly are “exceptions thrown by the methods of the supertype” when the methods of the supertype are abstract? I think those are the exceptions that are documented as the exceptions that can thrown by the methods of the supertype.
What this means is that if OrderState.Ship() is documented as something like “throws InvalidOperationException if this operations is not supported by the current state”, then I think this design does not break LSP. On the other hand, if the supertype methods are not documented this way, LSP is violated.
즉, 상위 타입이 명시하지 않은 예외를 하위 타입이 던지는 것은 명백히 LSP 를 위반하는 상황이라는 설명이다.
이 쯤에서 다시 생각을 해 보자.
체스 게임에서 "움직임" 을 요청했는데, 실제로 움직임을 수행할 수 없는 상태라 예외가 발생하는 것은 자연스러운가?
게임을 시작하기 전, 또는 게임이 종료된 이후 "움직임" 이 요청된다면, 예외가 발생해야 할 것 같다.
때문에 우리의 인터페이스 ChessGame 의 메서드들은 잘못된 요청에 대해 예외를 던지도록 명시를 해 줄 수 있다.
public interface ChessGame {
boolean isNotEnd();
ChessGame startGame(Supplier<Boolean> checkRestart);
ChessGame endGame();
void move(Location source, Location target) throws NotPlayingGameException;
}
이제 우리는 움직임을 수행할 수 없는 메서드에 대해 ChessGame 이 명시하고 있는 NotPlayingGameException 을 던져 상위 타입을 대체할 수 있게 되었다.
자바 API 에서의 예시
위와 같이 상위 타입이 명시하고 있는 예외를 던져 리스코프 치환 원칙을 지키는 예시는 얼마든지 찾아볼 수 있다.
간단하게 List 를 구현하는 UnmodifiableList 를 뜯어보자. 아래는 UnmodifiableList 의 코드 중 일부이다.
static class UnmodifiableList<E> extends UnmodifiableCollection<E> implements List<E> {
@SuppressWarnings("serial") // Conditionally serializable
final List<? extends E> list;
UnmodifiableList(List<? extends E> list) {
super(list);
this.list = list;
}
public E set(int index, E element) {
throw new UnsupportedOperationException();
}
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
public E remove(int index) {
throw new UnsupportedOperationException();
}
}
리스트의 수정을 막기 위해 set(), add(), remove() 등의 메서드에서 UnsupportedOperationException 을 던지고 있는 것을 볼 수 있다. List 타입이 요소를 추가, 삭제하는 메서드를 호출했는데 예외가 발생하는 것이 과연 LSP 를 준수하는 상황인걸까?
이제 상위 타입인 List 를 확인해 보자.
public interface List<E> extends Collection<E> {
// Query Operations
/**
* ... 중간 생략 ...
* @throws UnsupportedOperationException if the {@code set} operation
* is not supported by this list
* ... 이후 생략 ...
*/
E set(int index, E element);
/**
* ... 중간 생략 ...
* @throws UnsupportedOperationException if the {@code add} operation
* is not supported by this list
* ... 이후 생략 ...
*/
void add(int index, E element);
/**
* ... 중간 생략 ...
* @throws UnsupportedOperationException if the {@code remove} operation
* is not supported by this list
* ... 이후 생략 ...
*/
E remove(int index);
}
코드에서 throws 로 예외를 명시하는 대신, Javadoc 으로 가능한 예외들을 명시해 주고 있다. 그리고 이 중UnsupportedOperationException 이 존재한다. 즉, UnmodifiableList 역시 상위 타입이 명시해 둔 예외를 사용하고 있기 때문에 LSP 를 준수하고 있다고 볼 수 있다.
결론
결국, 위에서 적은 것 처럼 코치들의 의견, SOF 의 개발자들의 의견은 모두 조금씩 다르다. 언제나 그렇듯 정답은 없다는 뜻이고, 그렇기에 충분히 고민해 보고 자신만의 기준을 잡아가는 것이 중요할 것 같다.
나는 상위 타입의 인터페이스, 추상 클래스 등을 정의할 때는 예외 상황들을 코드에 명시해 주어 하위 타입들의 구현 범위를 명시해 주는 것이 가장 가독성이 좋을 것이라 판단했다.
+) 나의 경우는 주석은 코드 수정마다 개발자가 신경쓰기 위한 리소스가 코드보다 많이 필요하며, 사용자 입장에서도 코드에 비해 신뢰도가 떨어지다고 생각해 주석보단 throws 로 명시해 줄 것 같다! (물론, Javadoc을 제공해 주면서 매번 신경써 준다면 사용자 입장에서 더할 나위 없이 좋겠지만!)