나는 체스 미션의 설계를 아주 많이 갈아엎었다..
대공사가 정말 많았다. 한 4-5번 정도? (이 과정은 다른 포스팅에서 기록해두려고 한다.)
오늘은 상속으로 이루어져있던 코드를 전략패턴을 도입해 조합으로 바꾼 대공사에 대해 설명하고자 한다.
기존의 코드(step1)에서, 나는 Piece라는 추상 클래스를 상속하는 방법으로 각 기물들을 구현했다.
Piece라는 추상클래스가 있고, 기물별로 구현체가 존재했다.
하지만 해당 코드에서 한가지 찜찜한 점이 있었다.
public class Bishop extends Piece {
/***/
@Override
public PieceType getType() {
return PieceType.BISHOP;
}
}
그것은 바로 위와 같이 getType 함수를 통해 해당 클래스가 어떤 기물 클래스인지 알려준다는 점이었다.
이렇게 되면 객체지향에서 핵심인 캡슐화가 깨질 가능성이 생긴다.
그 이유는 외부에서 PieceType을 알게되기 때문이다.
외부에서 PieceType으로 분기문을 실행하는 경우가 그 예시이다.
PieceType type = piece.getType();
if (type == BISHOP) {
score += 3;
} else if (type == ROOK) {
score += 5;
}
/***/
위와 같이 외부에서 PieceType에 따라 어떠한 행위를 할 수 있다.
클래스 내부에서 수행해야하고 과정을 은닉화해야하는데, getter로 열어둠으로써 외부에서 하게 될 가능성이 생긴다.
(절차지향적인 코드가 된다.)
그래도 이는 당시에 뷰에서 기물을 그려주기 위해서만 사용되므로 눈을 꾹 감고 넘어갔다.
새로운 요구사항의 등장
3-4단계 미션을 진행하며 점수 계산 요구사항이 추가되었다.
요약하자면 각 기물별로 다른 점수를 가진다는 얘기다.
지금의 내 설계에서 각 기물별로 다른 점수를 반환하려면 어떻게 해야할까?
PieceType Enum에 score 필드를 추가하는 방법이 있을 것 같다.
추가해보자.
public enum PieceType {
KING(0), QUEEN(9), PAWN(1), ROOK(5), BISHOP(3), KNIGHT(2.5);
private final double score;
PieceType(double score) {
this.score = score;
}
public double getScore() {
return score;
}
}
public class Bishop extends Piece {
/***/
@Override
public PieceType getType() {
return PieceType.BISHOP;
}
@Override
public double getScore() {
return PieceType.BISHOP.getScore();
}
}
위와 같이 추가해보았다.
하지만 이러니 PieceType.BISHOP 부분이 중복되는 것 같다.
각 기물별로 하나의 타입을 가지는데, 타입을 인스턴스 변수로 가지면 좋지 않을까?
이를 인스턴스 변수로 빼보자.
public abstract class Piece {
protected final PieceType type;
protected final Color color;
public Piece(Color color, PieceType type) {
this.color = color;
this.type = type;
}
/***/
}
public class Bishop extends Piece {
public Bishop(Color color) {
super(color, PieceType.BISHOP);
}
/***/
@Override
public PieceType getType() {
return type;
}
@Override
public double getScore() {
return type.getScore();
}
}
바꿔보았는데.. 이게 과연 좋은 설계가 맞을까?
각 기물 구현체는 서로 다른 PieceType를 상태로 가진다.
그리고 그 PieceType에 따른 행동을 한다.
그러면 이들이 서로 다른 클래스여야 하는 이유가 있을까?
하나의 클래스에서 PieceType을 상태로 가지고, 상태에 따라 수행하면 되지 않을까?
각 기물을 서로 다른 클래스로 나눈 궁극적 이유는, 서로 다른 움직임을 하기 때문이었다.
public class Bishop extends Piece {
@Override
public boolean isValidMove(Move move, Piece targetPiece) {
return move.isDiagonal();
}
/***/
}
public class Queen extends Piece {
@Override
public boolean isValidMove(Move move, Piece targetPiece) {
return move.isStraight() || move.isDiagonal();
}
/***/
}
즉, 각 기물들은 isValidMove의 판단 조건이 다르다.
하지만 전략패턴을 도입한다면 이 또한 상태(PieceType)에 따라 수행하도록 풀어낼 수 있을 것 같다.
전략 패턴 도입
PieceType enum에 움직임의 판단 기준을 추가해보자.
public enum PieceType {
KING(0, ?),
QUEEN(9, ?),
ROOK(5, ?),
BISHOP(3, ?),
KNIGHT(2.5, ?),
PAWN(1, ?),
;
private final double score;
private final MoveStrategy moveStrategy;
/***/
}
대충 이런 구조가 될 것 같다.
물음표에 해당 기물의 전략을 추가해야한다.
여기서 기물의 전략 즉, MoveStrategy는 움직임을 판단하는 메서드 단 하나만을 가지는 함수형 인터페이스이다.
public interface MoveStrategy {
boolean canMove(Move move);
}
다음은 기물별 이동 전략이다.
[폰] 생략
[나이트] 한 방향으로 한 칸, 그리고 그 방향의 양 대각선 방향 중 한 방향으로 움직일 수 있다.
[비숍] 대각선 무한으로 움직일 수 있다.
[룩] 가로,세로 무한으로 움직일 수 있다.
[퀸] 가로,세로 / 대각선 무한으로 움직일 수 있다.
[킹] 가로,세로 / 대각선 한 칸 움직일 수 있다.
움직임이 다소 복잡한 폰과 나이트를 제외하고 다른 기물들(비숍, 룩, 퀸, 킹)의 움직임 전략을 보자.
해당 기물들은 다음과 같은 전략의 조합으로 표현할 수 있다.
[ 움직임 방향 ]
가로,세로로 움직일 수 있는 기물
대각선으로 움직일 수 있는 기물
[ 움직임 횟수 ]
무한히 움직일 수 있는 기물
한 칸만 움직일 수 있는 기능
다음은 기물별 전략의 조합이다.
[룩] 가로세로 and 무한
[비숍] 대각선 and 무한
[퀸] (가로세로 or 대각선) and 무한
[킹] (가로세로 or 대각선) and 한칸
그러면 이를 코드로 어떻게 표현할 수 있을까?!
우선 각 전략은 다음과 같이 정의할 수 있다.
가로,세로로 움직일 수 있는 기물 | StraightStrategy |
대각선으로 움직일 수 있는 기물 | DiagonalStrategy |
무한히 움직일 수 있는 기물 | X (움직임 횟수에 제약을 주지 않으면, 방향만 판단함. 즉, 움직임 횟수에 대한 전략을 추가하지 않는 것이 곧 무한히 움직일 수 있음을 뜻함.) |
한 칸만 움직일 수 있는 기능 | UnitStrategy |
문제는 여러 전략을 어떻게 조합할 것인지이다!
[or / and]를 [합집합 / 교집합] 개념으로 풀어내자 가 나의 아이디어다.
UnionStrategy(합집합)와 IntersectionStrategy(교집합)는 다음과 같이 만들 수 있다.
public class UnionStrategy implements MoveStrategy {
private final List<MoveStrategy> moveStrategies;
private UnionStrategy(List<MoveStrategy> moveStrategies) {
this.moveStrategies = moveStrategies;
}
public static UnionStrategy of(MoveStrategy... moveStrategies) {
return new UnionStrategy(List.of(moveStrategies));
}
@Override
public boolean canMove(Move move) {
boolean canMove = false;
for (MoveStrategy moveStrategy : moveStrategies) {
if (moveStrategy.canMove(move)) {
canMove = true;
}
}
return canMove;
}
}
public class IntersectionStrategy implements MoveStrategy {
private final List<MoveStrategy> moveStrategies;
private IntersectionStrategy(List<MoveStrategy> moveStrategies) {
this.moveStrategies = moveStrategies;
}
public static IntersectionStrategy of(MoveStrategy... moveStrategies) {
return new IntersectionStrategy(List.of(moveStrategies));
}
@Override
public boolean canMove(Move move) {
for (MoveStrategy moveStrategy : moveStrategies) {
if (!moveStrategy.canMove(move)) {
return false;
}
}
return true;
}
}
위 코드에서 UnionStrategy의 canMove 메서드는 자신의 전략들 중 하나라도 만족하면 true를, 모두 만족하지 않으면 false를 반환한다.
마찬가지로 IntersectionStrategy의 canMove 메서드는 자신의 전략들 중 하나라도 만족하지 않으면 false를, 모두 만족하면 true를 반환한다.
이제 다시 PieceTypeEnum으로 돌아가, 위에서 정의한 전략의 조합을 코드로 나타내보자.
public enum PieceType {
KING(0, IntersectionStrategy.of(
UnionStrategy.of(new StraightStrategy(), new DiagonalStrategy()),
new UnitStrategy())),
QUEEN(9, UnionStrategy.of(new StraightStrategy(), new DiagonalStrategy())),
ROOK(5, new StraightStrategy()),
BISHOP(3, new DiagonalStrategy()),
KNIGHT(2.5, new KnightStrategy()),
PAWN(1, new NoneStrategy()),
;
private final double score;
private final MoveStrategy moveStrategy;
/***/
}
다소 복잡해보일 수 있지만, 킹 / 퀸 / 룩 / 비숍의 전략을 천천히 보면 위에서 정의한 내용이 그대로 들어가있다.
[킹] (Straight || Diagonal) && Unit
[퀸] Straight || Diagonal
[룩] Straight
[비숍] Diagonal
위 코드에서 한가지 특이한 점을 발견할 수 있는데, 그는 바로 Pawn의 이동 전략으로 NoneStrategy가 들어가있다는 점이다.
그 이유는 폰의 움직임은 기물의 상태와도 깊게 연관이 되어있어서, 다른 기물들과 움직임 전략의 결이 다르기 때문이다.
폰을 제외한 다른 기물들은 오로지 시작 좌표와 끝 좌표간의 차이만으로 자신의 움직임인지 판단한다.
하지만 폰은 기물의 색깔, 한 번도 움직인 적 없는지 여부, 끝 좌표에 다른 기물의 존재 여부에 따라 움직임의 판단 조건이 다르다.
즉, 단순하게 전략을 구할 수 없다.
따라서 Pawn은 전략패턴을 사용해 움직임을 판단하는 것이 아니라, Piece를 상속받아 Pawn 클래스 안에서 판단하기로 결정했다.
하지만 enum의 특성상 Pawn에게도 움직임 전략을 부여하긴 해야한다. (비록 그 전략을 사용하지 않을 예정이라도 말이다!)
그래서 나는 움직임 판단을 하지 않는 NoneStrategy를 정의해, 해당 전략의 canMove 메서드 호출시 UnsupportedException 예외를 반환하도록 했다.
(거듭 말하지만, 해당 전략은 사용되지 않을 전략이기 때문이다.)
public class NoneStrategy implements MoveStrategy {
@Override
public boolean canMove(Move move) {
throw new UnsupportedOperationException("해당 이동 전략을 불러올 수 없습니다.");
}
}
그리고 다음과 같이 Piece를 상속받아 Pawn 클래스를 정의해줬다.
public class Pawn extends Piece {
public Pawn(Color color) {
super(color, PieceType.PAWN);
}
@Override
public boolean canMove(Move move, Piece targetPiece) {
if (isAttack(targetPiece)) {
return canAttack(move);
}
if (isFirstMove(move)) {
return canInitialMove(move);
}
return canMove(move);
}
/***/
}
다음은 최종적인 나의 클래스 다이어그램이다.
'우테코 5기 > 레벨1' 카테고리의 다른 글
[레벨1] 정적 팩토리 메서드 활용법 총 정리 (4) | 2023.04.07 |
---|---|
[체스 미션] 클래스 abstract / final로 만들기 (6) | 2023.03.29 |
정적 팩토리 메서드로 생성자 다이어트 시키기 (4) | 2023.03.05 |
전략 패턴을 통한 테스트 (2) | 2023.02.17 |
View단에서 도메인 객체를 생성해 return하는게 좋을까, 아니면 입력값을 그대로 return하는게 좋을까? (3) | 2023.02.17 |