👾

프론트엔드에서 Business Logic의 분리와 아키텍처

Created
Mar 30, 2024 03:06 PM
설명
웹에서 게임을 제작하며 프론트엔드에 넘어온 큰 Business Logic을 어떻게 분리해야 할까요? 또 어떻게 해야 확장성과 유연성을 갖출 수 있을까요?
Status
작성 완료
Tags
Architecture
Design Pattern
Snack Game
Select
Dev Log
notion image

아키텍처

아키텍처와 관련된 좋은 글들이 많으니 설명은 넘어가겠습니다.
아키텍처 하면 보통 유명한 아키텍처인 MVC, MVVM, Clean Architecture 등 여러가지가 패러다임이 떠오르는데요.
제가 아키텍처나 디자인 패턴과 관련된 글을 읽으면서 생각하는점을 정리하면 다음과 같습니다.
👀
프로덕트에 대해서 잘 이해하고 있는 팀의 의사결정에 의해 도출된 결정들. 모두가 이해하고 따를 수 있으며 공통된 지식으로 이후 어이질 활동의 기준.
이런 아키텍처의 구조나 이름에 신경을 쓰기 보다는 기존의 설계 방식들의 지식을 알아두고 팀원과의 의사결정을 통해 우리의 프로덕트와 비즈니스 로직에 알맞게 녹여내는 것이 핵심이라고 생각합니다.

아키텍처의 공통점

제가 느끼기에 아키텍처들의 공통점은 “서로 다른 관심사를 분리하고 복잡성을 해소하는 것,
이를 위해선 View와 Business Logic은 역할과 책임이 달라 분리되어야 한다.” 였습니다.
그런데 Business Logic은 왜 분리하고 어떻게 분리를 해야할까요?

Business Logic은 왜 분리해야하나?

프론트엔드는 그 자체로 View의 성격을 가집니다.
보통의 어플리케이션은 대부분의 Business Logic이 벡엔드에서 처리되어 반영되게 되는데요.
하지만 요즘엔 복잡한 View를 가진 어플리케이션도 많고 Business Logic이 프론트엔드로 많이 넘어오는 경우가 있습니다.
게임이 여기에 좋은 예시가 될 수 있을 것 같은데요.
단순하게 스타크래프트에서 유저는 유닛의 이동을 위해 오른쪽 클릭을 하면 그만이지만 그 안에서는 많은 일들이 일어나고 유저는 내부에서 무슨일이 일어나는지 알필요가 없기 때문이죠.
스타크래프트
스타크래프트
이처럼 View는 명령의 결과를 잘 보여주기만 하면 되고, Business Logic은 이 명령이 실제로 동작하도록 명령을 처리하기만 하면 됩니다.
이렇게 서로의 관심사가 다른데 이 둘이 뒤엉켜 있다면 복잡성이 증가하고 재사용성이 떨어지게 됩니다.
예를 들어 어택땅은 해당 지점으로 이동하며 마주치는 적을 공격하게 되는데, 일반 이동 명령과 겹치는 부분이 존재함에도 일반 이동 명령을 재사용 하지 못하고 중복되는 코드를 작성해야 할 수 있다는 점이죠.

나는 왜 Business Logic을 분리해야 했나?

제가 팀과 함께 운영하는 스낵게임은 Canvas API React를 사용해 제작한 판당 2분이라는 짧은 볼륨의 캐주얼한 게임입니다.
베타버전 스낵게임
베타버전 스낵게임
notion image
처음에 제작할 때는 그냥 평소에 하던 대로 상태와 훅을 조합해 게임을 만들고 게임에 필요한 함수들은 따로 분리해 게임 메니저라고 칭했습니다.
초기 Snack Game의 구조
초기 Snack Game의 구조
그림을 잘 보면 훅이 굉장히 많은일을 하고 있는걸 볼 수 있는데요.
이런 훅은 게임 메니저와 강하게 결합되었고 각각의 수정은 서로에게 많은 영향을 미쳤습니다.
또, 훅은 굉장히 길고 복잡한 코드를 가지게 되었고 게임 메니저는 재사용이 힘들어졌습니다.
하지만 이렇게 작성을 했더라도 게임이 돌아가는데는 문제가 없는데 왜 분리를 결심했을까요?

게임 모드의 도입

저희 스낵게임은 짧은 볼륨의 단순한 게임이기에 유저가 쉽게 흥미를 잃을 수 있습니다.
이런 부분을 해소하고 유저에게 다양한 즐거움을 제공하기 위해서 게임 모드를 도입하기로 결정했는데요.
스낵게임 방향성 회의 문서
스낵게임 방향성 회의 문서
문제는 기존의 스낵게임 구조는 강한 결합도로 재사용이 힘들었고 모드마다 일일히 코드를 작성해주면 되겠지만, 언제 어떤 모드가 추가될 지 모르고 게임 모드의 방향이 달라질 수 있기에 유연성과 확장성을 갖추어야 했습니다.
이런 이유로 현재 존재하는 복잡함을 해결하고 잘 유지되는 코드를 작성하기 위해 결정하게 되었습니다.
어떤 아키텍처를 도입하자! 보다는 관심사를 분리하고 역할과 책임을 잘 분배하는데 초점을 맞추었습니다.

무엇을 분리하나?

스낵게임의 비즈니스 로직은 무엇인지를 먼저 고민했는데요.
테트리스 게임에서 영감을 받았습니다. 다음 두 게임은 같은 테트리스류 게임입니다.
notion image
notion image
이렇게 다른 모습의 게임인데 무엇이 이 두 게임을 “테트리스류 게임” 으로 만들어줄까요?
두 게임은 모습은 달라도 기존 Tetris 게임의 룰을 잘 지키고 있습니다.
  • 정해진 모양의 블럭 중 랜덤한 블럭이 내려온다.
  • 블럭을 쌓는다.
  • 행이 채워지면 점수를 얻고 채워진 행을 지운다.
이런 기본적인 게임의 규칙을 지키면서 저마다의 다른 방식으로 해당 규칙을 만족합니다.
이처럼 저는 게임의 룰 Business Logic으로 정의했습니다.
스낵게임의 룰
  • 스낵을 선택한다.
  • 선택된 스낵의 가장 가까운 스낵이 선택 가능해 진다.
  • 선택된 스낵의 숫자합이 10이면 점수를 얻고 스낵을 제거한다.

Business Logic 분리

이런 스낵게임의 룰을 만족시키는 함수들을 비즈니스 로직 객체로 만들어 분리할 수 있었습니다.
  • 스낵을 선택하는 조건을 만족시키는 caculateSnackClicked
  • 선택 가능한 스낵을 계산하는 calculatePossibleSelect
  • 스낵의 합을 계산하고 제거하는 removeSnacks
이런 함수들이 여기에 해당합니다. 이 함수들은 명령을 내리는 View에서 호출하게 됩니다.
class를 사용한 이유는 함수보다 값에 대해 덜 의존적이게 됩니다. 매개변수를 추가하고 분기로 대응하기 보다는 대응하는 방법을 추가하는 식으로 유연한 대처가 가능합니다.
notion image
SnackGame 객체의 코드 요약
export class SnackGame { protected score = 0; protected snacks: Snack[] = []; protected row: number; protected column: number; protected borderOffset = 10; protected selectedSnacks: Snack[] = []; constructor({ ... } setSnackGame(row: number, column: number, snacks: Snack[]) { ... } isSnackNearby(selectedSnack: Snack, targetSnack: Snack): boolean { ... } calculatePossibleSelect() { ... } updateSelectedSnacks(snack: Snack): void { ... } caculateSnackClicked(mousePosition: { x: number; y: number }) { ... } removeSnacks() { ... } updateSnackPosition(offsetWidth: number, offsetHeight: number) { ... } ... }

확장

이렇게 분리된 SnackGame 객체는 확장에서도 편리한데요.
룰에 무언가를 추가하거나 변경하고 싶은 경우가 있다면 상속을 통해 확장하고 SnackGame의 메서드를 오버라이딩 하거나 필요한 메서드를 추가할 수 있습니다.
notion image
SnackGame 객체는 Snack 객체를 다루게 되는데요.
이 Snack 객체는 테트리스에서 블럭의 역할을 하는 객체로 반지름, 이미지, 숫자, 렌더링 정보 등의 값을 가진 도메인 객체 입니다.

도메인 객체와 다형성

앞서 Snack은 테트리스에서 블럭의 역할을 한다고 말했습니다.
테트리스에서 블럭은 다양한 모양을 가지는데 모양마다 객체를 일일히 만들어 주는건 별로 좋은 생각이 아닌데요.
왜냐하면 이 블럭을 떨어트리고 쌓는일만 하는 입장에서는 블럭이 어떤 모양으로 생겼는지는 관심사가 아닙니다.
블럭을 사용하는 입장에서 모양을 알아야 한다면 새로운 모양의 블럭이 추가될 때 마다 새로운 객체를 만들고 블럭을 사용하는 측도 새로운 객체를 알아야하니 양쪽의 수정이 일어납니다.
이는 블럭의 추상화를 통해 해결이 가능합니다. 블럭을 사용하는 측은 “블럭” 이라는 정보만을 알고 블럭의 생김새는 getBlockShap() 같은 메서드로 객체에 물어보면 되기 때문이죠.
notion image
이런 부분은 Snack Game에서도 마찬가지였는데요.
notion image
황금 스낵과 일반 스낵 2가지가 존재하는데요.
현재는 황금 스낵과 일반 스낵 두 가지만 있지만 나중에 새로운 스낵이 추가될지 모릅니다.
그래서 Snack이란 추상 클래스를 만들어 추상화를 진행했고 다음과 같은 구조를 가질 수 있었습니다.
notion image
abstract class Snack { // 추상화 객체 Snack private index: number; private coordinates: { y: number; x: number }; private position = { x: 0, y: 0 }; private radius = 0; private snackNumber: number; private image = new Image(); ... } export class GoldenSnack extends Snack { // 추상화 객체 Snack에 의존하는 황금 스낵 constructor(appleProp: SnackPropType) { super({ ...appleProp }); super.setImage(GoldenAppleImage); } }
Business Logic 객체는 여러 종류의 스낵을 알 필요 없이 추상화된 Snack 만 알면 되기에 쉽게 새로운 스낵을 추가할 수 있게 되었습니다.

View 컴포넌트의 분리

👀
저의 주관적인 생각입니다.
게임에 있어서 View를 크게 분리해서 본다면 2개로 나눌 수 있다고 생각합니다.
  • 실제 유저와 상호작용하고 렌더링 되는 게임화면
  • 게임의 형태를 정의하는 화면
실제 게임의 예시와 함께 글을 적어보겠습니다.

실제 유저와 상호작용하고 렌더링 되는 게임화면

말 그대로 실제 유저의 상호작용이 일어나고 상호작용의 결과가 표시되는 화면입니다.
유저의 명령을 받고 Business Logic에서 명령을 처리하고 명령의 수행 결과를 표시하는 부분이죠.
notion image
notion image
스낵게임 에서는 유저와 상호작용 하기 위해 cavnas 에 이벤트를 등록하고 Business Logic 객체와 소통하고 도메인 객체들을 렌더링 하는 SnackGameView 컴포넌트가 이에 해당합니다.
notion image
const SnackGameView = ({ isOngoing, snackGame, onRemove }: SnackGameProps) => { ... const animationFrame = (ctx: CanvasRenderingContext2D) => { ... // 렌더링 로직 }; ... const handleMouseDown = (event: MouseEventType) => { ... }; const handleMouseUp = async () => { ... }; ... return ( <div ref={canvasBaseRef} className={'max-w-xl mx-auto mb-20 h-[75%] w-full'} > {isOngoing && ( <canvas ref={canvasRef} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} ></canvas> )} </div> ); };
제가 생각하기에 게임 진행에 있어서 게임의 시작과 종료는 이 컴포넌트의 관심사가 아닙니다.
그러면 누가 게임의 시작과 종료를 관리하고, SnackGameView 가 사용해야할 Business Logic 객체가 모드마다 다른데 이건 누가 알려줄까요?

게임의 형태를 정의하는 화면

앞서 이야기한 게임의 시작과 종료, 모드의 선택을 이 화면에서 알려주어야 한다고 생각합니다.
이 외에도 게임의 난이도 설정, 게임 설정 화면 등이 여기에 들어간다고 생각하는데요.
실제 게임이 진행되기 전 단계이자 게임이 어떤식으로 진행될지 정의하는 부분으로 때문에 실제 게임 화면과는 관심사가 다르다고 생각합니다.
notion image
notion image
notion image
실제 게임 화면은 정보만 있다면 이런 부분이 없어도 게임이 돌아가는데 무리가 없기 때문이죠.
스낵게임에서도 이런 부분을 따로 분리할 수 있습니다.
베타 버전 게임시작 화면
베타 버전 게임시작 화면
SnackGameMod 컴포넌트는 게임의 시작과 종료 즉, 게임의 생명 주기를 관리하며 유저가 가장 처음 상호작용 하는 컴포넌트입니다.
SnackGameView에게 사용해야할 Business Logic 객체를 전달하고 필요에 따라 추가적인 로직도 전달할 수도 있습니다.
const snackGameMods = { default: SnackGameD, // Business Logic 객체들 inf: SnackGameC, }; const SnackGameMod = () => { ... const startGame = async (snackGameMod: SnackGameMod) => { ... }; const onRemove = async (removedApples: NewApple[]) => { ... // }; const endGame = async () => { ... }; useEffect(() => { ... // 2분이 지나면 게임을 종료하는 로직 }, [...]); return ( <div className="flex h-full w-full flex-col"> <SnackGameHUD score={score} time={remainingTime} handleRefresh={refreshGame} /> {snackGame && ( <SnackGameView isOngoing={isOngoing} onRemove={onRemove} snackGame={snackGame} // SnackGameView가 사용할 Business Logic 객체를 넘김 /> )} {!isOngoing && ( <div> ... // 모드 선택 버튼 </div> )} </div> ); };

얻은점

스낵게임 아키텍처

지금까지 스낵게임의 Business Logic을 분리하고 관심사를 나누어 봤는데요.
이렇게 분리한 부분을 합쳐서 본다면 다음과 같습니다.
notion image
notion image
의존성이 단방향으로 흐르게 되었고 각자의 역할과 관심사가 잘 분리되었다고 생각하는데요.
스낵게임의 아키텍처를 기존의 패러다임들과 비교해 본다면 MVC와 가까워 보이는것 같습니다.
어떤 아키텍처를 도입해야겠다! 라고 시작하지 않고 방향성을 정하고 목적에 따라 설계하니 “아키텍쳐는 실제가 아니라 방향성” 이라는 말이 더 와닿았고 좋은 경험이 되었습니다.

데이터의 단방향 흐름

기존에 하나의 Hook이 모든걸 했던 기존 구조는 다른 요소들과 강하게 결합되었고 몇가지 주요 로직은 따로 분리가 되어있어 코드를 왔다갔다 하며 봐야하는 불편함이 있었습니다.
변경된 스낵게임 아키텍처는 데이터가 단방향으로 흐르게 되어 데이터의 흐름을 이해하고 디버깅하기 쉬워졌고,
관련된 코드가 집약적으로 관리되기에 코드를 한 눈에 파악하기도 쉬워졌다고 생각합니다.
notion image
초기 Snack Game의 구조
초기 Snack Game의 구조
 

유연성과 확장성

분리된 Business Logic은 View와 의존성이 없어졌고 UI를 마음껏 수정할 수 있었습니다.
또 상속과 추상화를 통해 Business Logic과 도메인 객체를 쉽게 확장할 수 있었습니다
의존성을 가지고 있는 입장에서도 SnackGame 객체는 추상화된 Snack 객체만 알면 되고 SnackGameView 는 SnackGame 객체만 알고 있어도 되기에 서로간의 수정을 최소화할 수 있었습니다.

마무리

여기까지 스낵게임에서 Business Logic을 분리하고 아키텍처를 설계하는 배경과 과정을 적어보았는데요.
아키텍처와 Business Logic의 분리의 필요성을 느끼고 해결함으로써 왜 이런 패러다임들이 나왔고 많은 프로덕트에서 이를 도입하는지 알 수 있었습니다.
혼자 개발하던 이전과 달리 팀이된 지금 스낵게임의 구성원이 구조를 쉽게 이해하고 같은 그림을 바라봄으로써 얻을 수 있는 장점도 기대가 됩니다.
게임이라는 도메인 외에도 현재 작성하는 코드가 너무 복잡하고 재사용이 힘들다면 Business Logic의 분리를 고려해 보는것도 좋은 방법 같습니다.
 
ℹ️
이번 리펙토링에서 도움을 많이 받은 글과 영상들 입니다.