우테코 - 체스 미션 회고
체스 미션을 진행하면서 있었던 과정을 회고해보자
얼마 전, Level 1 마지막 미션인 체스 미션을 마무리하였다. 하지만 뭔가 시원하지 않고 오히려 고민만 많아져서 글을 작성하는 지금도 찝찝함이 남아있다. 지금까지 배운 것들에 대해 아직 제대로 정리도 되지 않았고 또한, 여러 가지를 맛보다 보니 더 깊게 공부해야 될 필요성을 느끼게 되어 걱정이 많아진 것이다. 그래서 Level 2에서는 Level 2 미션과 동시에 Level 1 때 덜 한 것을 보충하려 한다.
목차
- mermaid 사용해서 도메인 다이어그램 나타내기
- 명령어 좀 더 깔끔하게 처리하기
- Null 객체 패턴
- Stream이 꼭 필요한 것일까?
- 상속의 단점을 직접 경험
- 오브젝트 스터디
mermaid 사용해서 도메인 다이어그램 나타내기
저번 블랙잭 회고할 때 Over README를 작성하다 보니 시간이 너무 지체되어 이번에는 간단히 mermaid라는 툴로 도메인 다이어그램을 그리고 그에 맞는 도메인 기능들을 작성하자 마음먹었었는데 계획했던 대로 잘 됐던 것 같아 뿌듯하다. 하지만 설계할 때 역시 누구한테 어떤 책임을 부여할지는 아직도 어려운 숙제인 것 같다.
우아한 객체지향 세미나에서 조영호 님께서 이런 말씀을 하셨다. “항상 코드를 짜면 저는 의존성(dependency)을 종이에 그려본다. 의존성을 그렸을 때 뭔가 이상한 게 있으면 코드는 정말 이상한 부분들이 많다. 코드는 최종 결과물은 예쁠 수 있지만 과정은 지저분하다. 초반에는 저도 코드를 절차적으로 짤 때가 많다. 왜냐면 생각이 나지 않는다. 이걸 어떻게 쪼갤지도 모르겠고 어디에 넣어야 될지도 모르겠음. 그래서 일단 코드를 짜고 의존성을 보면서 개선하다 보면 자기가 원하는 구조로 가는 경우가 많다.”
경험이 많으신 조영호 님도 한 번에 설계하는 것이 아니라 점차 개선하는데, 병아리인 내가 어떻게 한 번에 설계를 완성시키려 했나 생각이 든다. 앞으로는 의존성을 그려보고 점차 개선해 봐야겠다.
명령어 좀 더 깔끔하게 처리하기
이번 체스 미션에서는 start, end, status, move 등 다양한 명령어에 따른 행동이 실행된다. 그에 따라 어떠한 메커니즘을 사용하지 않으면 Controller에 if 분기문이 그만큼 많이 들어가게 되어 가독성이 좋지 않을 거라 생각되어서 커맨드 패턴과 비슷한 구조를 도입하게 되었다.
커맨드 패턴: 커맨드 패턴이란 요청을 객체의 형태로 캡슐화하여 사용자가 보낸 요청을 나중에 이용할 수 있도록 매서드 이름, 매개변수 등 요청에 필요한 정보를 저장 또는 로깅, 취소할 수 있게 하는 패턴
체스 미션 1, 2단계에서는 다음과 같이 BiConsumer 함수형 인터페이스를 Command 객체 안에 넣어놓고 controller에서 command.execute()만 실행하면 각 명령어가 실행되도록 만들어놨다.
package chess.controller;
import chess.domain.game.ChessGame;
import chess.domain.position.File;
import chess.domain.position.Position;
import chess.domain.position.Rank;
import java.util.Arrays;
import java.util.function.BiConsumer;
public enum Command {
START("start", (chessGame, ignored) -> chessGame.start()),
END("end", (chessGame, ignored) -> chessGame.end()),
MOVE("move", moveOrNot());
public static final int SOURCE_INDEX = 1;
public static final int TARGET_INDEX = 2;
public static final int FILE_INDEX = 0;
public static final int RANK_INDEX = 1;
private final String name;
private final BiConsumer<ChessGame, String[]> consumer;
Command(final String name, final BiConsumer<ChessGame, String[]> consumer) {
this.name = name;
this.consumer = consumer;
}
private static BiConsumer<ChessGame, String[]> moveOrNot() {
return (chessGame, splitCommand) -> {
final String[] source = splitPosition(splitCommand, SOURCE_INDEX);
final String[] target = splitPosition(splitCommand, TARGET_INDEX);
final Position sourcePosition = Position.of(File.getFile(source[FILE_INDEX]),
Rank.getRank(Integer.parseInt(source[RANK_INDEX])));
final Position targetPosition = Position.of(File.getFile(target[FILE_INDEX]),
Rank.getRank(Integer.parseInt(target[RANK_INDEX])));
chessGame.moveOrNot(sourcePosition, targetPosition);
};
}
private static String[] splitPosition(final String[] splitCommand, final int index) {
return splitCommand[index].split("");
}
public static Command findByString(final String name) {
return Arrays.stream(values())
.filter(command -> command.name.equals(name))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("잘못된 커맨드입니다."));
}
public void execute(final ChessGame chessGame, final String[] splitCommand) {
this.consumer.accept(chessGame, splitCommand);
}
}
하지만 3, 4단계에서 db가 도입되고 난 후 명령어를 실행하면 영속성까지 건드릴 일이 생겼다. 예를 들어, moveOrNot() 명령어를 실행하고 나면 db의 체스 위치를 update시켜줘야 한다. 그래서 BiConsumer 부분을 Controller로 빼기로 했고 EnumMap을 사용하여 기존의 Command를 각 Biconsumer에 매핑 시켜줬다.
package chess.controller;
import chess.domain.game.ChessGame;
import chess.domain.piece.Side;
import chess.domain.position.File;
import chess.domain.position.Position;
import chess.domain.position.Rank;
import chess.service.ChessGameService;
import chess.view.InputView;
import chess.view.OutputView;
import java.sql.SQLException;
import java.util.EnumMap;
import java.util.Map;
import java.util.function.BiConsumer;
public class ChessController {
public static final int COMMAND_INDEX = 0;
public static final int SOURCE_INDEX = 1;
public static final int TARGET_INDEX = 2;
public static final int FILE_INDEX = 0;
public static final int RANK_INDEX = 1;
private final Map<Command, BiConsumer<ChessGame, String[]>> commands = new EnumMap<>(Command.class);
private final ChessGameService chessGameService;
public ChessController(ChessGameService chessGameService) {
putCommands();
this.chessGameService = chessGameService;
}
private void putCommands() {
commands.put(Command.START, (chessGame, ignored) -> start(chessGame));
commands.put(Command.END, (chessGame, ignored) -> end(chessGame));
commands.put(Command.STATUS, (chessGame, ignored) -> status(chessGame));
commands.put(Command.MOVE, this::moveOrNot);
}
private void start(ChessGame chessGame) {
chessGame.start();
}
private void end(ChessGame chessGame) {
chessGame.end();
}
private void status(ChessGame chessGame) {
final Double whiteScore = chessGame.calculateScore(Side.WHITE);
final Double blackScore = chessGame.calculateScore(Side.BLACK);
OutputView.printScore(whiteScore, blackScore);
OutputView.printWinner(Side.calculateWinner(whiteScore, blackScore));
}
private void moveOrNot(ChessGame chessGame, String[] splitCommand) {
final String[] source = splitPosition(splitCommand, SOURCE_INDEX);
final String[] target = splitPosition(splitCommand, TARGET_INDEX);
final Position sourcePosition = Position.of(File.getFile(source[FILE_INDEX]),
Rank.getRank(parseRank(source)));
final Position targetPosition = Position.of(File.getFile(target[FILE_INDEX]),
Rank.getRank(parseRank(target)));
chessGame.moveOrNot(sourcePosition, targetPosition);
chessGameService.update(chessGame, sourcePosition, targetPosition);
}
private int parseRank(final String[] source) {
try {
return Integer.parseInt(source[RANK_INDEX]);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("올바른 위치를 입력해주세요.");
}
}
private String[] splitPosition(final String[] splitCommand, final int index) {
return splitCommand[index].split("");
}
public void run() throws SQLException {
ChessGame chessGame = initChessGame(inputInitCommand());
while (chessGame.isRunnable()) {
printChessBoard(chessGame);
executeCommand(chessGame);
}
processIfClear(chessGame);
}
private InitCommand inputInitCommand() {
try {
final String command = InputView.readInitCommand();
return InitCommand.findByString(command);
} catch (IllegalArgumentException e) {
OutputView.printErrorMessage(e);
return inputInitCommand();
}
}
private ChessGame initChessGame(InitCommand command) throws SQLException {
ChessGame chessGame = findChessGameIfContinue(command);
if (chessGame == null) {
OutputView.printNewGameMessage();
chessGameService.delete();
chessGame = chessGameService.save();
}
return chessGame;
}
private ChessGame findChessGameIfContinue(final InitCommand command) throws SQLException {
ChessGame chessGame = null;
if (command.isContinue()) {
chessGame = chessGameService.findChessGame();
printContinueMessage(chessGame);
}
return chessGame;
}
private void printContinueMessage(final ChessGame chessGame) {
if (chessGame == null) {
OutputView.printNonContinueMessage();
return;
}
OutputView.printContinueMessage();
}
private void printChessBoard(final ChessGame chessGame) {
if (chessGame.isStart()) {
OutputView.printBoard(chessGame.getBoard());
}
}
private void executeCommand(ChessGame chessGame) {
try {
final String[] splitCommand = InputView.readCommand().split(" ");
final Command command = Command.findByString(splitCommand[COMMAND_INDEX]);
commands.get(command).accept(chessGame, splitCommand);
} catch (IllegalArgumentException e) {
OutputView.printErrorMessage(e);
executeCommand(chessGame);
}
}
private void processIfClear(final ChessGame chessGame) {
if (chessGame.isClear()) {
final Side winner = chessGame.calculateWinner();
chessGameService.delete();
OutputView.printKingDie(winner);
OutputView.printScore(chessGame.calculateScore(Side.WHITE), chessGame.calculateScore(Side.BLACK));
OutputView.printWinner(winner);
}
}
}
Null 객체 패턴
이번에 체스보드를 보면 Map<Position, Piece>로 객체를 관리하였다. 그런데 생각해 보면 체스보드에 기물이 없는 빈칸도 있을 것인데 이를 어떻게 처리해야 될까 고민하다 빈 기물 객체를 넣기로 하였다. Null 객체 패턴이 있는 걸 몰랐었는데 코드를 구현하다 보니깐 Null 객체 패턴과 비슷하게 되어 뭔가 신기했다.
package chess.domain.piece;
import chess.domain.board.Board;
import chess.domain.position.Path;
import chess.domain.position.Position;
import java.util.List;
public class Empty extends Piece {
public Empty(final Type type, final Side side) {
super(type, side, List.of());
}
@Override
protected void validate(final Type type, final Side side) {
validateType(type);
validateSide(side);
}
@Override
public Path findMovablePositions(final Position source, final Board board) {
throw new UnsupportedOperationException("지원하지 않는 메서드입니다.");
}
}
Null 객체 패턴이란 뭘까? 우선, null 검사 코드를 사용할 때 단점은 개발자가 null 검사 코드를 누락하기 쉬워 NullPointerException(NPE)을 발생시킬 가능성을 높인다. 그래서 null 객체 패턴을 사용함으로 써 null을 대신할 객체를 리턴하여 null 검사 코드를 없앨 수 있도록 한다. 이렇게 되면 향후 코드 수정을 보다 쉽게 할 수 있다는 장점이 있다.
Stream이 꼭 필요한 것일까?
이번에 점수를 더할 때 for를 쓸지 stream을 써서 계산을 할지 고민했다. 현재 for문 같은 경우 그냥 한바퀴만 돌면 되지만 stream으로 하게되면 기본 점수로 더할 때 한바퀴, 그리고 폰이 2개이상 있는 곳마다 점수를 마이너스 해주기 위해 또 한바퀴가 돌게된다. 즉, stream을 사용함으로써 불필요하게 한바퀴를 더 도는거 같아서 적용을 결국 안했다.
코드로 둘 다 비교를 해보자
//for문
public double calculateScore(Side side) {
double sum = 0;
for (int file = LOWER_BOUNDARY; file <= UPPER_BOUNDARY; file++) {
sum += addScoreByRank(file, side);
}
return sum;
}
private double addScoreByRank(final int file, Side side) {
double sum = 0;
int pawnCount = 0;
for (int rank = LOWER_BOUNDARY; rank <= UPPER_BOUNDARY; rank++) {
final Piece piece = getPiece(Position.of(File.getFile(file), Rank.getRank(rank)));
sum += addScoreIfRightColor(piece, side);
pawnCount += addPawnCount(piece, side);
}
sum = minusIfPawnOver(sum, pawnCount);
return sum;
}
private double minusIfPawnOver(double sum, final int pawnCount) {
if (pawnCount >= OVER_COUNT) {
sum -= pawnCount * (Type.PAWN.getValue() / DIVIDE_VALUE);
}
return sum;
}
private int addPawnCount(final Piece piece, Side side) {
if (side.equals(piece.getSide()) && piece.isPawn()) {
return 1;
}
return 0;
}
private double addScoreIfRightColor(final Piece piece, Side side) {
if (side.equals(piece.getSide())) {
return piece.getScore();
}
return 0;
}
//stream문
final double score = board.values().stream()
.filter(piece -> piece.getSide() == side)
.mapToDouble(Piece::getScore)
.sum();
final Map<Integer, Long> countByFile = board.entrySet().stream()
.filter(entry -> entry.getValue().getSide() == side)
.filter(entry -> entry.getValue().isPawn())
.collect(Collectors.groupingBy(entry -> entry.getKey().getFileIndex(), Collectors.counting()));
final double minusScore = countByFile.values().stream()
.filter(count -> count >= OVER_COUNT)
.mapToDouble(score -> score / DIVIDE_VALUE)
.sum();
final double finalScore = score - minusScore;
stream문이 불필요하게 한바퀴 더 돌고는 있지만 확실히 stream문이 좀 더 간결하고 깔끔한거 같긴하다. 예전에 알고리즘 풀때 비용을 좀 더 중시했던 경향이 아직 남아있어 이번에도 for을 택하긴 했다. 하지만, 최근에 협력을 위한 코드를 좀 더 생각하고 있어서 앞으로는 stream을 택할거 같은데 이는 상황에 맞게 잘 사용하면 좋을 거 같다.
상속의 단점을 직접 경험
이전 단계 미션에서 상속보단 조합을 사용하자고 한 적이 있는데 그땐 이론적으로 공부한 것이라 직접 와닿지 않았었는데 이번에 체스 미션을 구현하면서 그 단점을 제대로 느껴본 것 같다. 이번 체스 미션에서는 Piece 기물을 상속받는 클래스가 무려 7개나 된다. 그래서 Piece를 수정하게 되면 아래 모든 클래스들을 수정해야 된다.
처음 설계에서 너무 많은 시간을 쏟게 되면 나중에 구현할 시간이 부족하게 되어 기한 내에 시간을 완료하지 못할 수 있기 때문에 적당한 설계를 하고 구현하면서 맞춰가자고 페어들과 항상 얘기했다. 그래서 코드를 구현하는 중간중간에 설계를 수정할 일이 잦았고, 또한 더 좋은 설계가 보이면 그걸로 수정할 일도 잦았다.
그렇게 수정하는 건 좋은데, 이게 Piece 부분을 수정할 때가 문제였다. Piece 부분을 수정했더니 오는 무수히 많은 빨간색들의 요청… Piece 부분들의 자식 클래스들이 문제였다. 7개가 관련해서 묶여있다 보니깐 전부 고치는데 상당히 많은 시간이 걸린다.(+테스트 코드도 고쳐야 됨..)
이 짓을 Piece와 관련된 수정을 할 때마다 해야 되니깐 이제 나중에는 Piece 부분에서 페어가 “어?”만 해도 ptsd가 왔다. “어” 금지.. 이렇게 직접 상속의 단점을 겪어보니깐 왜 “상속을 위한 설계와 문서가 없다면 상속하지 마라”라는지 직접 피부로 잘 느끼게 되었다. 앞으로는 특별한 이유가 없다면 상속보다는 조합을 좀 더 적극 사용할거 같다.
오브젝트 스터디
레벨 1 동안 오브젝트 스터디를 했었는데 현재 10장까지 읽고 토론했다.(방학 때 마무리했었어야 했는데ㅠㅠ) 책을 읽는 습관과 말주변이 없는 나에게는 매우 좋은 기회였다고 생각한다. 레벨 2 때 빨리 남은 5장을 마무리하고 관련해서 글을 정리해 보면 좋을 것 같고 레벨 2 때도 다시 책 한 권 정해서 스터디를 진행해 보려 한다. 💪
+조영호 님의 오브젝트가 지금까지 읽은 책 중 TOP3 안에 들 정도로 너무 맛있었기 때문에 강추.
+리뷰어님께 받은 코드 리뷰에 대해 관심이 있으면 다음 PR들을 참고!
- 이번에는 체스 미션할 때 힘들었던 거 같아서 리뷰어님께 많이 못 여쭤본게 아쉽네요..
- 1, 2단계 - 체스
- 3, 4단계 - 체스
*틀린 부분이 있으면 언제든지 말씀해 주시면 공부해서 수정하겠습니다.