이전글에서 이어지는 내용입니다.
미리보기
완성된 사과게임 계층화 구조
순수한 사과게임
확장 가능한 구조를 만들기 위해서는 “어디서부터 확장해야 하는가” 를 정의 해야한다고 생각했다.
그래서 가장먼저 사과게임의 룰을 정직하게 지키는 순수한 사과게임을 먼저 설계를 시작했다.
순수한 사과게임 룰
- 게임 시작 시 격자로 사과를 만든다.
- 드래그를 한다.
- 사과들을 드래그 영역 내부에 넣는다.
- 드래그가 끝나면 드래그 영역 내부의 사과들의 숫자를 더한다.
- 10이면 점수를 얻고 드래그 영역 내부의 사과들을 제거한다.
- 일정시간 후 게임 종료
해당 룰을 반영하면서 각 계층의 관심사를 기준으로 분리하여 아래와 같은 순수한 사과게임을 제작할 수 있다.
본격적인 확장
확장을 하기에 앞서서 확장이 되는 부분을 먼저 정의했다.
나는 게임에서 확장 가능한 부분은 행위자에게 정보를 제공하는 부분이라고 생각한다.
위의 두 테트리스 게임을 보자
블럭을 떨어트린다 라는 행위자.
- 물리법칙이 적용된 블럭과, 모래처럼 부서지는 블럭을 제공한다.
블럭을 쌓는다 라는 행위자.
- 블럭이 어떻게 쌓이는지 방법을 제공하고 있다.
블럭을 지운다 라는 행위자.
- 블럭이 어떻게 지워지는 기준과 방법을 제공하고 있다.
사과게임에서 이걸 적용한다면.
게임이 시작되면 사과를 격자(row, column)모양으로 만든다 라는 행위자.
- row와 column을 제공한다.
- 그려질 게임판의 생김새 정보를 제공한다.
10이면 점수를 얻고 드래그 영역 내부의 사과들을 제거한다 라는 행위자.
- 점수를 어떻게 올릴지 정보를 제공한다.
- 특정 사과가 지워지면 수행될 행위를 제공한다.
일정시간 후 게임 종료한다 라는 행위자.
- 게임이 언제 끝날지 제공한다.
- 게임이 어떻게 끝나야 하는지 정보를 제공한다. (ex 진행사항 채점 후 종료)
View 확장하기
View 단계를 확장하는 행위자는 2가지가 있다.
게임이 시작되면 사과를 격자(row, column)모양으로 만든다 라는 행위자.
- row와 column을 제공한다.
- 그려질 게임판의 생김새 정보를 제공한다.
일정시간 후 게임 종료한다 라는 행위자.
- 게임이 언제 끝날지 제공한다.
- 게임이 어떻게 끝나야 하는지 정보를 제공한다. (ex 진행사항 채점 후 종료)
그렇다면 기존의 사과게임에서 행위자에게 정보를 제공하는 부분은 게임모드가 된다.
게임모드를 상위 컴포넌트로 만들어 사과게임을 더욱 순수하게 만들어 보면 아래와 같다.
const ClassicMode = () => {
const startLogic = (): AppleData[][] => {
// 1 ~ 9 까지 범위의 숫자를 랜덤으로 뽑아 2차원 배열로 만드는 2중 for문
};
const refreshLogic = (): AppleData[][] => {
// 게임 새로고침시 게임을 다시 시작하면 된다.
return startLogic();
};
return (
<AppleGame
// 게임이 시작하면 AppleData[][]를 반환할 로직
startLogic={startLogic}
refreshLogic={refreshLogic}
// 게임모드에 따라 사용하는 컨트롤러가 달라지니
// 게임 모드도 전달해준다.
gameMode={'classic'}
column={18}
row={10}
/>
);
};
이렇게 한다면 ClassicMode컴포넌트는 게임 시작시 게임판의 생김새를 정하는 로직을 전달한다.
AppleGame컴포넌트는 유저가 게임 시작 버튼을 누르면 startLogic을 실행시키고 반환된 AppleData[][]를 컨트롤러의 객체 생성 메서드로 전달한다.
황금사과모드는 서버로부터 게임판의 생김새를 전달받고, 종료 시에도 서버와 소통해야 하는데 이런경우에도 쿼리를 수행하는 행위를 전달해 주면 된다.
// GoldMode.tsx
const startLogic = async () => {
const { apples, sessionId } = await gameStart();
setSessionId(sessionId);
return apples;
};
AppleGame은 유니언 타입으로 이런 확장을 보장한다.
type ServedAppleData = Promise<AppleData[][]> | AppleData[][];
type GameMode =
| GoldModController
| ClassicModController
| PlayGroundModeController;
...
const generateApples = async () => {
const apples = await startLogic();
gameController.generateApples(apples);
gameController.updateApplePosition(offsetWidth, offsetHeight);
};
// AppleGame.tsx
...
const gameController: GameMode = useMemo(() => {
switch (gameMode) {
case 'gold':
return new GoldModController({ row, column, sessionId });
case 'classic':
return new ClassicModController({ row, column });
case 'practice':
return new PlayGroundModeController({ row, column });
default:
return new GoldModController({ row, column, sessionId });
}
}, [gameMode, row, column]);
...
const handleStartButton = async () => {
try {
await generateApples();
setStart(true);
setTimeRemaining(time);
openToast(TOAST_MESSAGE.GAME_START, 'success');
} catch (e) {
setError(new Error('게임 시작에 실패했습니다.'));
}
};
완성된 View의 모습
Controller 확장하기
컨트롤러는 아래의 행위자 이외에 게임 내에서 이루어지는 거의 모든 행위를 확장할 수 있다.
10이면 점수를 얻고 드래그 영역 내부의 사과들을 제거한다 라는 행위자.
- 점수를 어떻게 올릴지 정보를 제공한다.
- 특정 사과가 지워지면 수행될 행위를 제공한다.
원시 데이터를 가지고 도메인 객체 배열을 생성한다.
- 도메인 객체(GoldApple, PlainApple 등)을 제공한다.
등등 많은 부분이 재정의 될 수 있고 확장될 수 있다.
상속으로 확장
ClassicMode는 가장 게임의 원형이 되는 모드이다.
이 ClassicMode를 상속(extends)해 기존 로직들을 재사용 하고 확장하고 싶은 메서드를 오버라이딩(Overriding) 하면된다.
아래는 GoldModController의 드래그 영역 검사 메서드의 예시 pseudo 코드 이다.
export class GoldModController extends ClassicModController {
async checkApplesInDragArea() {
// ClassicModController의 checkApplesInDragArea로직 수행
const { getScore, applesInDragArea } = super.checkApplesInDragArea();
let isGold = false;
if (getScore) {
applesInDragArea.foreach(
(apple: Apple) => {
// 만약 점수를 얻었는데 드래그 영역 내부에 황금사과가 있었다면
if (apple instanceof GoldenApple) isGold = true;
},
);
if (isGold) {
// 황금사과가 제거되었다면 게임판을 서버에서 새로 받아와 사과배열을 업데이트하는 로직
}
}
return { getScore, applesInDragArea, isGold };
}
}
완성된 컨트롤러의 모습
Model 확장하기
우선 도메인 객체는 자기 자신의 상태만 알면 된다.
사과객체는 행열상 위치, Canvas에 렌더링될 위치, 그려질 이미지, 반지름 등등을 포함하고 있다.
그런대 만약 이미지만 다른 황금사과나 포도를 추가한다고 해보자.
이미지만 다른데도 사과객체가 가지고 있는 상태를 똑같이 구현해야 한다.
또 황금사과, 포도, 귤 등등 객체의 종류가 추가될 때 마다 사용하는 컨트롤러 입장에서도 유니온 타입등으로 어떤 객체들이 있는지 알아야한다.
추상화로 확장하기
abstract class Apple {
private coordinates: { y: number; x: number };
private position = { x: 0, y: 0 };
private velocity = { x: Math.random() * 4 - 2, y: 0 };
private radius = 0;
private appleNumber: number;
private image = new Image();
private inDragArea = false;
constructor({ coordinates, appleNumber }: ApplePropType) {
this.coordinates = coordinates;
this.appleNumber = appleNumber;
}
...
}
사과 객체를 abstract로 추상화를 했다.
이렇게 추상화를 하면 Apple는 혼자서 쓰일 수 없다. (ex: new Apple())
하지만 이런 추상화의 장점은 사용하는 컨트롤러는 객체 베열에 (apples: Apple[] = [];
) 황금사과, 포도, 배, 귤 어떤것이 들어오더라도 추상화된 Apple만 알면 된다.
// PlainApple
class PlainApple extends Apple {
constructor(appleProp: ApplePropType) {
super({ ...appleProp });
super.setImage(AppleImage);
}
}
// GoldenApple
export class GoldenApple extends Apple {
constructor(appleProp: ApplePropType) {
super({ ...appleProp });
super.setImage(GoldenAppleImage);
}
}
완성된 모델의 모습
마무리
새로운 구조의 이점은?
기존에 모델들을 컴포넌트에서 상태로 관리하던 때 보다 응집성이 향상되었다.
각 계층은 자신이 어떻게 사용될 지 몰라 의존성이 단방향으로 흐르게 되어 재사용성과 확장성이 높아졌다.
게임 컨트롤러는 도메인 객체 배열에만 의존해 테스트가 쉬워진다.
컨트롤러는 모듈화 되어 View가 React에서 Angular로 바뀌더라도 그대로 사용할 수 있다.
개선할 점
상속의 사용
나는 컨트롤러를 상속을 통해 확장했다.
상속은 하위 모드 컨트롤러가 상위 컨트롤러 ClassicMode 컨트롤러 클래스에 강하게 의존, 결합 하기 때문에 ClassicMode 컨트롤러에 큰 변화가 생기면 하위 컨트롤러에 영향을 주게 될 수 있다고 생각한다.
이걸 추후에 조합(Composition)을 통해 해결해 볼 수 있을 것 같다.
instanceof
AppleGame에서 instanceof를 통해서 어떤 컨트롤러인지 알아야하는 로직이 있다.
이 부분을 instanceof로 판단하고 있는데 AppleGame컴포넌트가 컨트롤러가 모드에 따라 추가된 새로운 로직을 AppleGame컴포넌트가 알아야 한다는 말이다.
이는 새로운 모드 컨트롤러가 추가되면 AppleGame에 찾아가 고쳐야하고 계방-폐쇄 원칙에 위반된다.
추후에 모델의 다형성을 고려한 리펙토링을 통해 이걸 해결 해보겠다.