블록을 다루고 뒤집고 돌리는 방법을 다뤄보자.
코드는 '프로그래밍 언어'이다. 언어는 소통을 위해 존재한다. 귀로 들어도 눈으로 읽어도 이해가 잘 되는 것이 언어를 잘 사용하고 다루는 것이다. 프로그래밍 언어도 마찬가지로 코드를 읽어도 화자(코더)의 의도가 파악이 되고 코드의 역할이 파악이 되어야 한다. 지난 글에 담은 코드 블록을 읽으면 이 코드가 무슨 역할을 하는지, 이 라인이 무슨 의도로 작성된 건지 나에게조차 전혀 전달이 되지 않는다.
이렇게 작성하면 어떨까?
export const 놓을_수_있는지_확인 = (...) => {
입력이_유효한가요();
규칙_위배_안_하나요();
};
export const 보드_위에_블록_놓기 = (...) => {
const 블록 = 블록을_가져와();
놓을_수_있는지_확인(보드, 블록, 위치);
놓기(보드, 블록, 위치);
};
클린 코드의 저자 대 마틴 씨가 괜히 함수를 쪼개라고 조언하는 것이 아니다. if, for statement를 통해 이중 배열을 검사하는 코드가 읽기 힘들다고 한들 함수로 분리하면 함수 이름이 이들의 의미를 명확히 설명해줄 수 있다. 입력이_유효한가요()
, 규칙_위배_안_하나요()
함수 내부에 관심을 가져야 할 때는 구현할 때, 오류 수정을 할 때 뿐이다. 보드게임 Blokus는 2024년까지 메이저 패치나 마이너 패치를 진행하는 온라인 게임이 아니다. 오류가 발생하지 않게 공들여 잘 구현해보자.
이번 편에서 리팩터링을 진행할 파트는 블록을 '다루는', '가져오는' 로직이다.
기존 로직은 JSON으로 1*5/2*4/3*3 형태의 블록을 전달 받고 있다. 일단 리팩터링을 위해 블록을 다루는 규약을 만들어보자.
블록 데이터는 어떻게 다루는 것이 좋을까? 기존 블록 구현은 아래와 같이 되어있다.
export const BLOCK: Record<string, Record<string, number[][]>> = {
five: {
a: [[1, 1, 1, 1, 1]],
b: [
[1, 1, 1],
[1, 0, 0],
[1, 0, 0],
],
c: [
[0, 1, 0],
[1, 1, 1],
[0, 1, 0],
],
d: [
[0, 0, 1],
[0, 1, 1],
[1, 1, 0],
],
...
Record<string, Record<string, number[][]>> 타입은 지금 봐도 도무지 납득할 수 없는 구조이다. 심지어는 PATCH 메서드를 통해서 블록 전체를 전달 받았지 BLOCK 전역변수는 사용하지도 않았다. JS에서는 array, object가 포인터 처럼 주소로 다뤄지기 때문에 새로운 변수에 const block = BLOCK.five.a;
와 같이 사용해도 실제로는 원본 전역 변수를 사용하기 때문에 블록을 뒤집거나 돌리는 순간 일이 복잡해지기 때문에 저렇게 비효율적으로 구현하였다. 적어도 전역변수 BLOCK을 선언하고 사용하지 않을 것이면 안 하느니만 못 하지 않는가. { space: 'five', type: 'a' }
형태로 입력을 받고 해당하는 블록을 복사해서 사용하지 못 한 나의 무지함을 반성하게 되었다.
만약 유효하지 않은, BLOCK['six']['z']
와 같은 접근이 발생하면 에러가 발생할 것이다. 이를 막기 위해 input이 in-range인지 체크하는 로직을 추가하는 것은 어떨까? 상상만 해도 끔찍하다. 어떻게 접근하는 것이 좋을지 고민해보다 이전 회사의 배치 작업에서 사용했던 프리셋 방식을 활용해보고자 아래와 같이 구현해봤다.
// $lib/types.ts
export type BlockType = '50' | '51' | '52' | '53' | '54' | ... | '10';
export type BlockMatrix = boolean[][];
// $lib/game.ts
const preset: Record<BlockType, BlockMatrix> = {
'50': [[true, true, true, true, true]],
'51': [
[true, true, true],
[true, false, false],
[true, false, false],
],
...
'10': [[true]],
};
블록 타입은 두 글자의 string으로 이뤄져 있고, 첫 글자는 블록의 칸 수, 두 번째 글자는 타입을 hex로 입력하였다. 블록 a, 블록 b, ..., 블록 s, 블록 t로 다루는 것보다 알아보기 쉽게 짜인 것 같다(특히 디버깅 시). boolean으로 선언한 이유는 if statement에서 유효성 검사 시 'number 1인지, 2인지, 1이 아닌지, ...'보다 합리적이라고 느꼈기 때문이다. 보드도 빈 칸은 false로, 안 빈 칸은 1~4로 표기하는 것이 나을 것 같다. 보드의 타입이 number[][]
에서 (number | boolean)[][]
으로 바뀌겠지만 영향이 그렇게 클 것 같진 않다. 이제 변수 preset은 BlockType을 넣으면 BlockMatrix를 반환해주는 녀석이 되었다.
앞서 지나가듯 언급한 "실제로는 원본 전역 변수를 사용하기 때문에" 문제를 해결하기 위해 이 preset에서 블록을 copy하여 꺼내주는 녀석을 작성해보자. 새로운 블록을 생성하는 함수니 이름은 createBlock으로 적당할 것 같다.
const createBlock = (type: BlockType) => preset[type].map(row => Array.from(row)) as BlockMatrix;
Factory 패턴을 사용하지 않은 이유는, 환경에 따라 구현이 바뀌는 등 느슨한 결합이 필요한 상황도 아니고 블록 객체가 복잡하지도 않을 뿐더러 이 케이스에서는 오버엔지니어링이 될 수 있다고 판단했기 때문이다.
아니 글을 쭉 읽고 있었는데
createBlock
으로 블록을 이미 가져왔는데요?
맞다. 나는 해당 함수로 프리셋의 복사본을 얻었다. 그렇다면 얻은 블록을 통해 유효성 검사 후 보드를 수정할 수 있을까? 블록을 사용하기 위해서는 우리가 실제로 보드게임을 플레이할 때처럼 블록을 돌리거나 뒤집어야 한다. 블록을 돌리거나 뒤집으면 최대 총 8가지의 모양을 얻을 수 있다.
(사진은 이전에 CLI 환경에서 이쁘게 출력하려고 별의별 짓을 했던 함수가 만들어줬다)
실제로 쓸 블럭을 가져오기 위해서는 뒤집거나 돌리는 등의 작업이 미리 돼있어야 한다. 즉, createBlock은 1차적으로 프리셋에서 블록을 복사하는 용도로만 사용하고 실제로 보드에 올리기 직전 형태의 블록을 만드는 함수를 따로 작성할 것이다. 앞에서 블록의 타입을 BlockMatrix
로 지었으니 새 함수 이름은 getBlockMatrix
로 지어보겠다. 두 개의 TODO 주석을 모두 채워보자.
const getBlockMatrix = (/* [TODO] */) => {
// [TODO]
};
일단 파라미터 자리의 TODO를 채워보자. getBlockMatrix
는 어디서 호출될 함수일까? 내가 생각한 가장 이상적인 방법은 전화에서 소개했던 putBlockOnBoard
내부에서 호출하는 것이다. 일단 멍청하게 블록 자체를 JSON req body로 받지 않기 위해 블록을 다루는 규약을 한 가지 설정해보겠다.
/**
* 프리셋에 저장된 블록을 제어하는 정보
* 블록은 preset에 저장된 배열 형태로 핸들
* 블록 타입 네이밍은 블록 개수와 타입(hex)을 합
* 연산 순서는 type > rotation > flip
*/
export type Block = {
type: BlockType;
rotation: 0 | 1 | 2 | 3;
flip: boolean;
}
보다 완벽하다. 이제 클라이언트가 되도 않는유효하지 않은 블럭([[true, false, true], [false, true, false], [true, false, true]]
같은)을 입력하는 것을 type
속성을 검사하는 것으로 막을 수 있게 되었다. 연산 순서는 임의로 정해봤다. 이 단계에서는 서버 사이드 연산을 최적화하는 것보다 가독성을 더 중요하게 평가했다. 위 타입을 정의하는 것으로 파라미터 타입은 확정이 된 것 같다.
이제 함수 내부 TODO를 순서대로 채워보자.
import type { Block, BlockMatrix, BlockType } from "./types";
const getBlockMatrix = (blockInfo: Block): BlockMatrix => {
const block = createBlock(blockInfo.type);
블록_돌리기(block);
블록_뒤집기(block);
return block;
};
블록 뒤집기는 아주 쉽게 구현이 가능해보인다. Array.prototype.reverse()
는 원본 배열을 뒤집는 함수이니 이를 활용해보겠다.
const flipBlock = (blockMatrix: BlockMatrix) =>
blockMatrix.reverse();
누워서 떡먹기가 아닐 수 없다. 블록을 돌리는 것은 누워서 떡먹다 역류성 식도염에 걸린 것과 다름이 없다. 구현하다보니 조금 지저분해졌다.
const rotateBlock = (blockMatrix: BlockMatrix) => {
const rotatedBlock: boolean[][] = Array.from({ length: blockMatrix[0].length }, () => {
const newArr: boolean[] = [];
newArr.length = blockMatrix.length;
newArr.fill(false);
return newArr;
});
blockMatrix.forEach((blockLine, xIdx) => {
blockLine.forEach((blockSpace, yIdx) => {
if (blockSpace) {
rotatedBlock[yIdx][blockMatrix.length - xIdx - 1] = true;
}
});
});
blockMatrix.length = rotatedBlock.length;
rotatedBlock.forEach((blockLine, idx) => {
blockMatrix[idx] = [...blockLine];
});
};
rotatedBlock
변수에 돌린 블럭을 임시로 저장하고 파라미터로 전달된 원본 블럭에 멤버들을 재할당한다. 겉보기엔 지저분해보여도 구현부가 그리 나쁘다는 생각이 들진 않는다. 이를 취합하여 getBlockMatrix
함수를 완성해보자.
const getBlockMatrix = (blockInfo: Block): BlockMatrix => {
const block = createBlock(blockInfo.type);
if (blockInfo.rotation !== 0) {
for (let rotationTime = 1; rotationTime <= blockInfo.rotation; rotationTime += 1) {
rotateBlock(block);
}
}
if (blockInfo.flip === true) {
flipBlock(block);
}
return block;
};
블록을 가져오는 로직은 꽤 깔끔해졌다.
다음 편에서는 유효성 검사인 놓을_수_있는지_확인()
을 작성해보겠다.