[Refac] 보드게임 BLOKUS 리팩터링하기 - 2

este·2024년 10월 28일
0

BLOKUS-SvelteKit

목록 보기
3/8

블록을 해당하는 위치에 놓을 수 있는지 확인하는 함수를 작성해보자

클린 코드에서는 "코드를 깔끔하게 정리하고 표현력을 강화하는 방향으로, 그래서 애초에 주석이 필요 없는 방향으로 에너지를 쏟겠다"라고 말한다(p.69). "함수나 변수로 추가할 수 있다면 주석을 달지 마라"는 저자의 메세지와 같이 주석이 필요 없는 코드를 위해 고민해보자. 오늘은 놓을_수_있는지_확인() 부분을 작성해보겠다.

들어가기에 앞서

수정해야 할 부분이 있다. 바로 블록을 "누가 놓는지" 알 수 없는 파라미터 구조이다. 간단하게 플레이어가 몇 번째 차례인지 확인하도록 인덱스만 넘겨받도록 해보자.

export type BlockMatrix = (number | false)[][];

export interface PlaceBlockDTO {
  block: BlockMatrix;
  position: number[];
  board: BoardMatrix;
  playerIdx: 0 | 1 | 2 | 3;
}

export interface PutBlockDTO {
  board: BoardMatrix;
  blockInfo: Block;
  position: number[];
  playerIdx: 0 | 1 | 2 | 3;
}

플레이어 인덱스가 턴 정보와 일치하는지, 요청자가 해당 인덱스를 가진 플레이어가 맞는지 체크가 된 상태에서

놓을_수_있는지_확인()

Blokus의 게임 룰은 다음과 같다.

  1. 색상에 기반하여 순서를 정하여 게임 진행(ex. 파-노-빨-초-파-노-...)
  2. 첫 블록은 보드의 모서리([0,0], [19,0], [0,19], [19,19])에 배치되어야 함
    2-1. 블록은 보드를 벗어날 수 없음
  3. 같은 색상의 블록끼리는 모서리끼리 연결되어야 함
    3-1. 같은 색상의 블록끼리는 직접 닿을 수 없음
  4. 다른 색상의 블록은 닿아도 상관 없으나, 이미 블록이 존재하는 칸에는 배치할 수 없음

1은 턴으로 구현되어야 하기 때문에 해당 함수에서 검사하는 것이 아닌 플레이어들이 요청을 보낼 때 확인하도록 한다. 2~4를 한 번에 검사하면 가독성이 박살난다는 것을 상식과 감(과 경험)으로 알기 때문에 이를 조금 분리해보려고 한다. 일단 이 모든 과정을 수행하는 함수를 다음과 같이 작성해보자.

export const isBlockPlaceable = ({ block, position, board, playerIdx }: PutBlockDTO) => {
  // 2
  // 2-1
  // 3
  // 3-1
  // 4
};

블록 위치가 유효한가?

블록이 보드를 벗어나지 않는지, 첫 번째 턴에 모서리에 잘 뒀는지 확인을 해야 한다. 이 작업을 각각 아래 함수로 작성해보자.

const isWithinBoardBounds = ... ;
const isFirstMoveValid = ... ;

코딩할 때 가장 어려운 것 중 하나인 네이밍을 했으니 내부를 아름답게 구현해보자.
일단 isWithinBoardBounds 함수에 대해 생각해보자.

isWithinBoardBounds()

position이 in-range인데 true인 블록 셀이 보드를 벗어날 수 있는가?

즉,

  1. position이 유효한가(in-range인가)?
  2. 블록의 true 셀이 모두 보드 안에 있는가?

1을 만족하고 2를 어길 수가 있을까? 모든 블록과 모서리를 고려해봐도 없는 것 같다. 즉 isWithinBoardBounds 함수에서는 position과 블록의 높이와 너비를 체크하면 될 것 같다.

const [row, col] = position;
const boardHeight = 20;
const boardWidth = 20;
const blockHeight = block.length;
const blockWidth = block[0].length;

return !(
  row < 0 ||
  row + blockHeight > boardHeight ||
  col < 0 ||
  col + blockWidth > boardWidth
);

가독성을 위해 row, col 등의 '이름에 의미가 담긴' 변수들을 사용해봤다.

isFirstMoveValid()

코너에 올라갈 블록의 셀이 true일까?

내가 생각하는 해법은 두 가지이다. 첫째는 turn과 관련된 파라미터를 전달받는 것이고 둘째는 모서리를 체크하는 것이다. 나는 두 번째 방식을 해답으로 택하고 싶었다. 즉 보드의 모서리가 비어있으면 해당 플레이어는 첫 턴이라고 판정하고 싶었다.

혈압이 오른 로버트 마틴 씨가 이의를 제기한다. 결합성 관점에서 모서리를 체크하는 방식이 왜 나쁜지 알아보자.

if (board[0][0] === false || board[0][19] || board[19][0] || board[19][19]) {
  // [TODO] check whether the block's cell is on the edge of the board or not
}

이렇게 구현 시 해당 함수는 board의 구현에 의존하게 된다. 만일 board의 구현을 아래와 같이 수정한다고 가정해보자.

const board = {
  0: [ /*first line of the board*/],
  1: [ /*second line of the board*/],
  ...
};

board에 생긴 수정사항으로 인해 validation을 담당하는 함수들까지 수정해야 하는 끔찍한 일이 발생한다.

// after
if (board.0[0] === false || ...)

고수준 모듈(에 가까운) 블록 배치 검증 로직이 저수준 모듈(에 가까운) board의 구체적 구현에 의존하게 되기 때문에 의존성 역전 법칙을 위배한다고 볼 수 있다.

하지만, 모든 함수 구현을 보드나 블록 등의 구현 방식에 의존하지 않도록 SOLID 원칙에 맞춰 구현하는 방법이 나에게는 떠오르지 않는다. 더 atomic하게 구성하는 방법은 실력이 더 쌓이고 고민해보도록 하겠다.

일단 가장 상위 함수인 putBlockOnBoard에서 turn 정보를 받아와서 체크하도록 해보자.

// $lib/types.ts
export interface PutBlockDTO {
  turn: number;
  ...
 export interface PlaceBlockDTO {
  turn: number;
  ...
// $lib/game.ts
const isFirstMoveValid = ({ block, position, board, playerIdx, turn }: PlaceBlockDTO) => {
  if (turn > 3) {
    return;
  }
  // [TODO] check the block is on the edge of the board
};

어떻게 체크해야 할까? 우선 나는 각 플레이어의 턴이 clockwise로 진행된다고 확정하겠다. 좌상단-우상단-우하단-좌하단 순으로 게임을 진행한다고 가정했을 때, playerIdx가 0이면 (0,0), 1이면 (0,19), 2면 (19, 19), 3이면 (19, 0)에 true인 셀이 위치해야 한다. 셀이 위치해야 함은, 블록의 모서리도 true여야 하며 이 모서리 셀이 보드의 모서리에 위치해야 한다. 다시 말해 이를 따로 검사하는 것보다 프리셋을 사용해 한 번에 검사하는 것이 나을 것이다. 메모리 공간을 조금 더 활용해 if 분기를 많이 줄일 수 있다.

export const isFirstMoveValid = ({ board, block, playerIdx, turn, position }: PlaceBlockDTO) => {
  if (turn > 3) {
    return true;
  }
  const [row, col] = position;

  const blockHeight = block.length;
  const blockWidth = block[0].length;
  const cornerPositionsPreset = {
    0: { board: [0, 0], block: [0, 0] },
    1: { board: [0, 19], block: [0, blockWidth - 1] },
    2: { board: [19, 19], block: [blockHeight - 1, blockWidth - 1] },
    3: { board: [19, 0], block: [blockHeight - 1, 0] },
  };

  const { board: boardPosition, block: blockPosition } = cornerPositionsPreset[playerIdx];

  if (!block[blockPosition[0]][blockPosition[1]]) {
    return false;
  }
  return row + blockHeight - 1 === boardPosition[0]
    && col + blockWidth - 1 === boardPosition[1];
};

export const isBlockPlaceable = ({ block, position, board, playerIdx, turn }: PlaceBlockDTO) => {
  if (!isWithinBoardBounds({ block, position, board, playerIdx, turn })) {
    return false;
  }

  if (!isFirstMoveValid({ block, position, board, playerIdx, turn })) {
    return false;
  }
  ...

마치며

3, 3-1, 4에 해당하는 validation은 다음 글에 이어서 작성해보겠다. 해당 글에서 다뤘던 부분을 이렇게 작성해버렸던 21개월 전의 나를 반성해보는 시간을 가지며 글을 마무리하겠다.

profile
este / 에스테입니다.

0개의 댓글