1. 객체지향적으로 머드게임 만들기 (2022년)
2022년 말에 객체지향적으로 머드게임 만들기라는 주제로 회사 내부 세미나를 진행했었다.
해당 세미나는 언어나 프레임워크에 제한이 없어서 Typescript로 프레임워크나 라이브러리 사용 없이 구현했었다. 그러면서 객체지향적으로 구현하기 위해서 역할과 책임에 따라 나누고 객체 간의 관계를 의존성 주입으로 설계했기 때문에 객체 간에 얽혀있는 의존성을 직접 관리해 줘야하는 불편함이 있었다.
아래는 그때 구현한 코드 중 의존성을 관리하는 코드이다.
1-1. 객체들간의 관계를 관리하는 부분 (의존성 관리)
// dependency.ts
// 의존성 주입
// 그냥 공통으로 사용하는 기타 함수들
const etc = new Etc();
// 게임 시작 전
// 게임 중에 계속 바뀌는 class (상태)
const mapList = new MapList();
const jobList = new JobList();
const userData = new UserData();
// 위의 클래스들을 만들어주는 class (행동)
const item = new ItemGenerator(etc);
const mapGenerator = new MapGenerator(item, etc, mapList);
const jobGenerator = new JobGenerator(jobList);
const userGenerator = new UserGenerator(userData);
// 게임 시작 후
// 바깥쪽 (게임 세팅)
const characterCommandOutput: ICharacterCommandOutput = new CharacterCommandOutput(jobList, userData);
const characterQueryOutput: ICharacterQueryOutput = new CharacterQueryOutput(jobList, userData);
const moveOutput: IMoveOutput = new MoveOutput(userData, mapList);
const npcOutput: INpcOutput = new NpcOutput(mapList);
const enemyOutput: IEnemyOutput = new EnemyOutput(mapList);
// 핵심 로직
const charaterDomain = new CharacterDomain(characterCommandOutput, characterQueryOutput);
const moveDomain = new MoveDomain(moveOutput, characterQueryOutput);
const npcDomain = new NpcDomain(npcOutput, characterCommandOutput);
const enemyDomain = new EnemyDomain(enemyOutput, characterCommandOutput, characterQueryOutput, etc);
// 바깥쪽2 (유저 입력)
const input = new Input(charaterDomain, npcDomain, moveDomain, enemyDomain);
// 최종 인스턴스
const game = new Game(mapGenerator, jobGenerator, userGenerator, input);
game.start(21, 21);
1-2. 해당 코드의 문제점
간단한 기능만 있는 구현임에도 역할과 책임에 따라 객체를 나누다 보니 의존성이 복잡하게 얽혀있는 것을 볼 수 있다. 물론 이런 간단한 기능의 경우 관계 관리를 직접 해도 문제없이 가능하지만 조금만 더 복잡해지면 수많은 에러를 만날 가능성이 있다. 또한 변경에 대해 유연하게 대응하기 위해 객체들 간의 관계를 의존성 주입으로 관리하는 건데 이렇게 직접 의존성을 관리 하게 되면 휴면 에러가 생길 가능성이 있어 유연한 대응이라고 하기 어렵다.
1-3. 해결책
이런 의존성들의 관계를 손쉽게 관리하려면 DI (Dependency Injection) 과 IoC(Inversion of Control)의 개념이 적용된 프레임워크나 라이브러리를 사용하면 된다. (Nest.js, Spring 등의 프레임워크나 typedi와 같은 라이브러리)
현재 백엔드 팀에선 DI 프레임워크인 Nest.js를 활용해 모든 서비스를 구현하고 있지만, 최근에 Express 환경에서 Reflect API를 활용해 직접 데코레이터를 만들어 DI 프레임워크를 구현해 봤기 때문에 직접 만든 DI 프레임워크를 활용하게 리팩토링을 진행했다.
2. 직접 만든 DI 프레임워크
직접 만든 DI 프레임워크에 대한 글은 위 링크에 있는 글에서 자세히 설명했기 때문에 여기선 간단하게 데코레이터와 폴더 구조에 대해서만 말하려고 한다.
2-1. IoC 컨테이너
// IoC 컨테이너
type Constructor<T> = new (...args: any[]) => T;
class Container {
private layers: Map<string, Constructor<any>> = new Map();
register<T>(key: string, type: Constructor<T>) {
this.layers.set(key, type);
}
resolve<T>(key: string): T {
const targetType = this.layers.get(key);
if (!targetType) {
throw new Error(`Service not found: ${key}`);
}
const dependencies = Reflect.getMetadata('design:paramtypes', targetType) || [];
const instances = dependencies.map((dependency: Constructor<any>) => this.resolve(dependency.name));
return new targetType(...instances);
}
}
export const container = new Container();
2-2. @Injectable() 데코레이터
// @Injectable() 데코레이터
export function Injectable() {
return function (target: Constructor<any>) {
container.register(target.name, target);
};
}
2-3. 기존 폴더 구조
기존 폴더 구조는 프레임워크나 라이브러리를 사용하지 않았기 때문에 다른 폴더 없이 game 폴더만 존재했다. 해당 프로젝트를 진행할 때 아키텍처에 대한 고민도 있었기 때문에 DIP 개념을 이용해 비즈니스 로직을 보호하기 위한 구조로 작성했다.
game.ts 파일이 프로그램의 시작파일이고 dependency.ts 파일에서 모든 의존성들의 관계를 직접 세팅해 주는 구조였다.
2-4. 리팩토링 후 폴더 구조
리팩토링 후의 폴더 구조를 보면 기존과 거의 동일하지만 Express에서 DI 프레임워크를 만들어 적용했기 때문에 Express 관련 세팅 파일들이 있다. 또한 직접 구현한 IoC 컨테이너와 DI 데코레이터가 들어있는 decorators 폴더가 추가됐고, 의존성들의 관계를 관리하던 dependency.ts 파일이 삭제됐다. 프로그램의 시작파일은 Express의 시작파일인 app.ts로 변경됐다.
2-5. 리팩토링 내용
이번 리팩토링은 클린코드나 성능 개선에 중점을 둔 리팩토링이 아니다. 직접 관리하던 복잡한 의존성들을 @Injectable()이라는 데코레이터를 만들어 런타임때 알아서 객체들을 찾아 의존성이 주입되게 하는게 목표였다. 그래서 dependency.ts 파일이 없어지고 모든 객체(class)에 @Injectable()이 적용됐다. 자세한 코드는 아래 링크에 나와있다.
https://github.com/JooYoung2274/mud_game-oop-solid-
3. 리팩토링 과정에서 생긴 문제
사실 직접 만든 DI 프레임워크를 적용해 리팩토링하면서 돌아가는 프로그램을 만드는 것은 전혀 문제가 없었지만 기존에 설계했던 아키텍처적인 관점에서 문제가 생겼다.
3-1. 기존에 설계했던 아키텍처
기존에 설계했던 아키텍처는 클린 아키텍처 개념을 적용해 구현했다. DIP의 개념을 활용해 비즈니스 로직이 모여있는 서비스 계층은 가장 안쪽으로 몰아넣는 구조였다. 외부의 데이터에 접근해야 하면 Interface로 port를 만들어 추상화된 계층을 의존하게 구현했다.
3-2. 문제_1
기존처럼 객체들 간의 관계를 직접 관리하게되면 추상화된 계층을 의존하더라도 어차피 타입으로 핸들링되기 때문에 프로그램 동작에는 전혀 문제가 없다. 하지만 직접 만든 DI 프레임워크를 통해 런타임 환경에서 알아서 의존성을 주입하게 수정하면 추상화된 계층을 의존할 때 해당 계층과 실체화 관계에 있는 객체를 찾아서 매핑할 수 있게 구현해줘야 하는 문제가 생겼다.
(Nest.js에선 @Module 데코레이터 에서 provider라는 기능을 통해 해당 관계를 설정해 주고 있다)
현재 상태는 일단 클린 아키텍처 개념을 무시하고 서비스 계층에서 외부 계층을 바로 의존하게 바꿔서 수정해놓은 상태다. 직접 만드는 DI 프레임워크의 다음 단계로 코드상에서 주입하는 추상화 계층과 실제 동작하는 실체화 계층을 매핑할 수 있는 기능을 구현할 예정이다.
3-3. 문제_2
두 번째 문제는 싱글톤이 지켜지지 않는다는 문제가 있다. Nestjs의 경우 IoC 컨테이너에 의해서 주입되는 모든 객체는 싱글톤으로 핸들링된다. 하지만 내가 만든 DI 프레임워크는 매핑되어있는 모든 객체들을 인스턴스화해서 집어넣기 때문에 싱글톤이 지켜지지 않는다. 물론 지속적으로 변경되고 유지되어야하는 데이터들의 경우 @Injectable() 데코레이터를 사용하지 않고 직접 인스턴스화해서 사용하기 때문에 프로그램 동작에는 문제가 없지만 같은 역할을 하는 객체를 여러개 만드는건 좋지 않기 때문에 수정할 예정이다.
끝
<머드게임 리팩토링 예제 코드 링크> https://github.com/JooYoung2274/mud_game-oop-solid-
<DI 프레임워크 관련 코드 링크> https://github.com/JooYoung2274/di_framework
<DI 프레임워크에 대한 설명> https://joorrr.tistory.com/25
<Reflect API에 대한 설명> https://joorrr.tistory.com/26
'세미나' 카테고리의 다른 글
테스트 구축하기 관련 발표 자료 (0) | 2023.02.28 |
---|---|
객체지향적으로 간단한 머드게임 만들기 (0) | 2022.12.29 |
백엔드 아키텍처 관련 정리자료 (0) | 2022.12.02 |