본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 2. 2. · 4 Views
Flame 커스텀 컴포넌트 시스템 완벽 가이드
Flutter Flame 게임 엔진에서 ECS 패턴을 활용한 커스텀 컴포넌트 시스템 구축 방법을 다룹니다. 컴포넌트 간 통신, 이벤트 버스, 의존성 주입까지 모듈러 아키텍처의 핵심을 배웁니다.
목차
1. ECS 패턴 심화
김개발 씨는 첫 번째 Flame 게임을 완성한 후 뿌듯해하고 있었습니다. 그런데 코드 리뷰 시간에 박시니어 씨가 한 가지 질문을 던졌습니다.
"이 게임 오브젝트들, 나중에 100개로 늘어나면 어떻게 관리할 건가요?"
ECS(Entity-Component-System) 패턴은 게임 오브젝트를 세 가지 요소로 분리하는 설계 방식입니다. 마치 레고 블록처럼 작은 부품들을 조립해서 복잡한 캐릭터를 만드는 것과 같습니다.
Entity는 빈 그릇이고, Component는 데이터를 담은 재료이며, System은 요리하는 셰프입니다.
다음 코드를 살펴봅시다.
// Entity: 고유 ID를 가진 빈 컨테이너
class GameEntity extends Component with HasGameRef {
final String entityId;
final Map<Type, Component> _components = {};
GameEntity({required this.entityId});
// 컴포넌트 추가 - 레고 블록 끼우기
void addEntityComponent<T extends Component>(T component) {
_components[T] = component;
add(component);
}
// 컴포넌트 조회 - 필요한 블록 찾기
T? getEntityComponent<T extends Component>() {
return _components[T] as T?;
}
}
// Component: 순수 데이터만 담는 클래스
class HealthComponent extends Component {
int maxHealth;
int currentHealth;
HealthComponent({required this.maxHealth})
: currentHealth = maxHealth;
}
김개발 씨는 입사 6개월 차 게임 개발자입니다. 첫 번째 모바일 게임을 출시한 후 자신감이 붙었습니다.
그런데 두 번째 프로젝트를 시작하면서 문제가 생겼습니다. 적 캐릭터 종류가 20개를 넘어가자 코드가 스파게티처럼 엉키기 시작한 것입니다.
선배 개발자 박시니어 씨가 화면을 들여다보며 말했습니다. "클래스 상속으로만 해결하려고 하니까 이렇게 복잡해진 거예요.
ECS 패턴을 적용해 보는 건 어때요?" 그렇다면 ECS 패턴이란 정확히 무엇일까요? 쉽게 비유하자면, ECS는 마치 레고 블록 시스템과 같습니다.
레고로 자동차를 만들 때 바퀴 블록, 창문 블록, 엔진 블록을 조립하듯이, 게임 캐릭터도 체력 컴포넌트, 이동 컴포넌트, 공격 컴포넌트를 조립해서 만드는 것입니다. 필요한 블록만 끼우면 어떤 캐릭터든 만들 수 있습니다.
전통적인 객체지향 방식에서는 어땠을까요? 개발자들은 상속에 의존했습니다.
Character 클래스를 만들고, 그 아래 Enemy, Player, NPC를 만들고, 또 그 아래 FlyingEnemy, WalkingEnemy를 만들었습니다. 그런데 날면서 걷기도 하는 적이 필요하면 어떻게 해야 할까요?
다중 상속의 늪에 빠지게 됩니다. 바로 이런 문제를 해결하기 위해 ECS 패턴이 등장했습니다.
Entity는 고유 ID만 가진 빈 컨테이너입니다. 그 자체로는 아무 기능이 없습니다.
Component는 순수한 데이터 덩어리입니다. 체력, 위치, 속도 같은 정보만 담고 있습니다.
System은 특정 컴포넌트들을 가진 Entity를 찾아서 로직을 실행합니다. 위의 코드를 살펴보겠습니다.
GameEntity 클래스는 컴포넌트들을 담는 컨테이너 역할을 합니다. _components 맵에 타입별로 컴포넌트를 저장하고, 필요할 때 꺼내 쓸 수 있습니다.
HealthComponent는 체력 데이터만 담고 있고, 어떤 로직도 포함하지 않습니다. 실제 게임에서는 이렇게 활용합니다.
플레이어 캐릭터에는 HealthComponent, MovementComponent, AttackComponent를 붙입니다. 나무 같은 장애물에는 HealthComponent만 붙입니다.
같은 컴포넌트를 재사용하면서 다양한 게임 오브젝트를 만들 수 있습니다. 주의할 점이 있습니다.
초보 개발자들이 흔히 하는 실수는 컴포넌트에 로직을 넣는 것입니다. 컴포넌트는 순수한 데이터만 담아야 합니다.
로직은 System이 담당합니다. 이 원칙을 지키지 않으면 ECS의 장점이 사라집니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 후, 김개발 씨는 기존 코드를 ECS 방식으로 리팩토링했습니다.
20종류의 적 캐릭터가 5개의 컴포넌트 조합으로 깔끔하게 정리되었습니다.
실전 팁
💡 - Component는 데이터만, System은 로직만 담는 원칙을 철저히 지키세요
- Entity ID는 디버깅 시 추적이 쉽도록 의미 있는 이름을 붙이세요
2. 커스텀 System 구현
김개발 씨가 ECS 패턴을 적용한 후 한 가지 의문이 생겼습니다. "컴포넌트는 만들었는데, 이 데이터들을 누가 처리하죠?" 박시니어 씨가 웃으며 대답했습니다.
"이제 System을 만들 차례예요."
System은 특정 컴포넌트를 가진 Entity들을 찾아서 게임 로직을 실행하는 역할을 합니다. 마치 공장의 컨베이어 벨트처럼, 조건에 맞는 제품들만 골라서 같은 공정을 적용하는 것입니다.
System 덕분에 게임 로직이 한 곳에 모여 관리가 쉬워집니다.
다음 코드를 살펴봅시다.
// System 기본 인터페이스
abstract class GameSystem extends Component with HasGameRef {
// 매 프레임 실행되는 메인 로직
void processEntities(double dt);
@override
void update(double dt) {
super.update(dt);
processEntities(dt);
}
}
// 이동 처리 시스템
class MovementSystem extends GameSystem {
@override
void processEntities(double dt) {
final entities = gameRef.children
.whereType<GameEntity>()
.where((e) =>
e.getEntityComponent<PositionComponent>() != null &&
e.getEntityComponent<VelocityComponent>() != null);
for (final entity in entities) {
final position = entity.getEntityComponent<PositionComponent>()!;
final velocity = entity.getEntityComponent<VelocityComponent>()!;
// 속도에 따라 위치 업데이트
position.x += velocity.vx * dt;
position.y += velocity.vy * dt;
}
}
}
김개발 씨는 ECS의 E와 C는 이해했습니다. 그런데 S, 즉 System이 왜 필요한지 아직 감이 오지 않았습니다.
박시니어 씨가 화이트보드에 그림을 그리며 설명을 시작했습니다. "게임에 100마리의 적이 있다고 생각해 봐요.
각 적 객체 안에 이동 로직이 있으면 어떻게 될까요?" 김개발 씨가 대답했습니다. "100개의 클래스에 비슷한 이동 코드가 흩어져 있겠네요." System은 마치 공장의 품질 관리사와 같습니다.
컨베이어 벨트 위를 지나가는 수많은 제품 중에서 특정 조건에 맞는 것만 골라서 같은 작업을 수행합니다. 이동 시스템은 "위치와 속도 정보를 가진 모든 Entity"를 찾아서 위치를 업데이트합니다.
System이 없던 시절의 코드는 어땠을까요? 각 게임 오브젝트 클래스마다 update 메서드에 이동, 충돌, 렌더링 로직이 뒤섞여 있었습니다.
이동 방식을 바꾸려면 모든 클래스를 수정해야 했습니다. 버그가 생기면 어디서 발생했는지 찾기도 어려웠습니다.
System을 사용하면 이런 문제가 해결됩니다. MovementSystem은 이동 로직만 담당합니다.
CollisionSystem은 충돌 판정만 담당합니다. RenderSystem은 화면 그리기만 담당합니다.
각 시스템이 명확한 책임을 가지므로 코드 수정이 한 곳에서 이루어집니다. 위 코드를 자세히 살펴보겠습니다.
processEntities 메서드에서 먼저 조건에 맞는 Entity를 필터링합니다. PositionComponent와 VelocityComponent를 모두 가진 Entity만 대상이 됩니다.
그다음 각 Entity의 위치를 속도에 따라 업데이트합니다. 실무에서는 시스템의 실행 순서도 중요합니다.
InputSystem이 먼저 실행되어 입력을 처리하고, MovementSystem이 위치를 업데이트하고, CollisionSystem이 충돌을 검사하고, RenderSystem이 화면에 그립니다. 이 순서가 바뀌면 버그가 발생합니다.
주의할 점이 있습니다. System에서 직접 컴포넌트의 데이터를 수정해도 되지만, 큰 변화는 이벤트로 처리하는 것이 좋습니다.
예를 들어 체력이 0이 되어 캐릭터가 죽는 상황은 이벤트로 알려야 다른 시스템들이 적절히 반응할 수 있습니다. 박시니어 씨의 설명이 끝나자 김개발 씨의 눈이 반짝였습니다.
"이제 로직을 어디에 넣어야 할지 명확해졌어요!"
실전 팁
💡 - System의 실행 순서를 명시적으로 관리하세요. 순서가 곧 게임 로직입니다
- 한 System이 너무 많은 일을 하면 분리를 고려하세요
3. 컴포넌트 통신 패턴
김개발 씨가 System을 구현하다가 막혔습니다. 플레이어가 적을 공격하면 적의 체력이 깎여야 하는데, AttackSystem에서 어떻게 HealthComponent에 접근해야 할지 모르겠습니다.
"컴포넌트끼리 어떻게 대화하죠?"
컴포넌트 통신은 서로 다른 컴포넌트가 데이터를 주고받는 방법입니다. 마치 회사에서 부서 간 협업을 위해 공식 채널을 만드는 것처럼, 컴포넌트들도 정해진 방식으로 소통해야 합니다.
직접 참조, 메시지 전달, 공유 상태 등 다양한 패턴이 있습니다.
다음 코드를 살펴봅시다.
// 방법 1: 직접 참조를 통한 통신
class CombatSystem extends GameSystem {
@override
void processEntities(double dt) {
final attackers = _getEntitiesWithAttack();
final targets = _getEntitiesWithHealth();
for (final attacker in attackers) {
final attack = attacker.getEntityComponent<AttackComponent>()!;
for (final target in targets) {
if (_isInRange(attacker, target)) {
final health = target.getEntityComponent<HealthComponent>()!;
// 직접 체력 수정
health.currentHealth -= attack.damage;
}
}
}
}
}
// 방법 2: 메시지 객체를 통한 통신
class DamageMessage {
final String targetId;
final int amount;
final DamageType type;
DamageMessage({
required this.targetId,
required this.amount,
required this.type,
});
}
// 메시지 큐를 가진 컴포넌트
class MessageQueueComponent extends Component {
final Queue<dynamic> messages = Queue();
void send(dynamic message) => messages.add(message);
dynamic receive() => messages.isNotEmpty ? messages.removeFirst() : null;
}
김개발 씨는 전투 시스템을 구현하던 중 고민에 빠졌습니다. 공격 컴포넌트는 공격자에게 있고, 체력 컴포넌트는 피해자에게 있습니다.
이 둘을 어떻게 연결해야 할까요? 박시니어 씨가 설명했습니다.
"컴포넌트 통신에는 세 가지 방법이 있어요. 상황에 따라 골라 쓰면 됩니다." 첫 번째는 직접 참조 방식입니다.
System이 필요한 컴포넌트를 직접 찾아서 값을 수정합니다. 마치 사무실에서 동료 책상으로 직접 걸어가서 서류를 전달하는 것과 같습니다.
간단하고 직관적이지만, 컴포넌트 간 의존성이 생깁니다. 두 번째는 메시지 전달 방식입니다.
공격이 발생하면 DamageMessage를 만들어서 대상의 메시지 큐에 넣습니다. 마치 사내 메일을 보내는 것과 같습니다.
발신자와 수신자가 서로를 몰라도 됩니다. 세 번째는 공유 상태 방식입니다.
글로벌한 게임 상태 객체에 데이터를 저장하고, 여러 시스템이 이를 참조합니다. 마치 회사 게시판에 공지를 붙이는 것과 같습니다.
위 코드에서 CombatSystem은 직접 참조 방식을 사용합니다. 공격자와 대상을 찾아서 거리를 체크하고, 조건이 맞으면 체력을 직접 수정합니다.
간단한 게임에서는 이 방식으로 충분합니다. 반면 MessageQueueComponent는 메시지 방식을 위한 기반입니다.
DamageMessage 객체에 피해 정보를 담아서 전달하면, 나중에 HealthSystem이 메시지를 읽고 처리합니다. 어떤 방식을 선택해야 할까요?
단순한 게임이라면 직접 참조로 시작하세요. 코드가 복잡해지면 메시지 방식으로 전환하면 됩니다.
멀티플레이어 게임이라면 처음부터 메시지 방식을 고려하는 것이 좋습니다. 네트워크를 통해 메시지를 전송하기 쉽기 때문입니다.
주의할 점이 있습니다. 메시지 방식을 과도하게 사용하면 코드 흐름을 따라가기 어려워집니다.
디버깅할 때 메시지가 어디서 발생했는지 추적하기 힘들 수 있습니다. 적절한 균형을 찾는 것이 중요합니다.
김개발 씨는 우선 직접 참조 방식으로 전투 시스템을 구현했습니다. 나중에 이펙트나 사운드 시스템이 추가되면 그때 메시지 방식을 도입하기로 했습니다.
실전 팁
💡 - 단순한 상호작용은 직접 참조, 복잡한 연쇄 반응은 메시지 방식을 사용하세요
- 메시지 타입은 sealed class로 만들면 타입 안전성을 확보할 수 있습니다
4. 이벤트 버스 구축
프로젝트가 커지면서 김개발 씨는 새로운 문제에 부딪혔습니다. 적이 죽으면 점수 시스템, 사운드 시스템, 파티클 시스템이 모두 반응해야 합니다.
"이 많은 시스템들을 일일이 연결하면 코드가 거미줄처럼 엉키겠는데요?"
이벤트 버스는 게임 내 모든 이벤트를 중앙에서 관리하는 통신 허브입니다. 마치 공항의 관제탑처럼, 모든 비행기의 이착륙을 한 곳에서 조율합니다.
이벤트를 발행하면 구독한 시스템들이 자동으로 알림을 받습니다.
다음 코드를 살펴봅시다.
// 이벤트 기본 클래스
abstract class GameEvent {
final DateTime timestamp = DateTime.now();
}
// 구체적인 이벤트들
class EntityDestroyedEvent extends GameEvent {
final String entityId;
final String destroyedBy;
EntityDestroyedEvent({required this.entityId, required this.destroyedBy});
}
class DamageDealtEvent extends GameEvent {
final String targetId;
final int amount;
DamageDealtEvent({required this.targetId, required this.amount});
}
// 이벤트 버스 구현
class EventBus {
static final EventBus _instance = EventBus._internal();
factory EventBus() => _instance;
EventBus._internal();
final Map<Type, List<Function>> _listeners = {};
// 이벤트 구독
void subscribe<T extends GameEvent>(void Function(T) handler) {
_listeners.putIfAbsent(T, () => []).add(handler);
}
// 이벤트 발행
void publish<T extends GameEvent>(T event) {
final handlers = _listeners[T] ?? [];
for (final handler in handlers) {
(handler as void Function(T))(event);
}
}
// 구독 해제
void unsubscribe<T extends GameEvent>(void Function(T) handler) {
_listeners[T]?.remove(handler);
}
}
김개발 씨의 게임은 점점 복잡해지고 있었습니다. 적이 죽으면 일어나야 하는 일이 한두 가지가 아닙니다.
점수가 올라야 하고, 폭발 사운드가 나야 하고, 파티클 이펙트가 터져야 하고, 퀘스트 진행도가 업데이트되어야 합니다. 처음에는 CombatSystem에서 직접 다른 시스템들의 메서드를 호출했습니다.
그런데 새로운 시스템이 추가될 때마다 CombatSystem을 수정해야 했습니다. 박시니어 씨가 말했습니다.
"이건 강한 결합이에요. 이벤트 버스로 해결해 봅시다." 이벤트 버스는 마치 공항의 관제탑과 같습니다.
비행기 조종사는 다른 비행기와 직접 통신하지 않습니다. 관제탑에 상황을 보고하면, 관제탑이 필요한 곳에 알려줍니다.
마찬가지로 CombatSystem은 "적이 죽었다"는 이벤트만 발행하면, 관심 있는 시스템들이 알아서 반응합니다. 이벤트 버스가 없던 시절에는 어땠을까요?
A 시스템이 B, C, D 시스템에 직접 의존했습니다. B 시스템이 바뀌면 A도 수정해야 했습니다.
새로운 E 시스템을 추가하려면 A를 또 수정해야 했습니다. 시스템이 10개만 되어도 의존성 지옥에 빠졌습니다.
이벤트 버스를 사용하면 느슨한 결합이 가능해집니다. 위 코드를 보면, EventBus는 싱글톤 패턴으로 구현되어 있습니다.
subscribe 메서드로 특정 이벤트를 구독하고, publish 메서드로 이벤트를 발행합니다. 제네릭을 사용해서 타입 안전성도 확보했습니다.
실제 사용 예시를 보겠습니다. ScoreSystem은 EntityDestroyedEvent를 구독합니다.
SoundSystem도 같은 이벤트를 구독합니다. CombatSystem이 적을 처치하고 이벤트를 발행하면, 두 시스템 모두 자동으로 알림을 받습니다.
주의할 점이 있습니다. 컴포넌트가 제거될 때 반드시 구독을 해제해야 합니다.
그렇지 않으면 메모리 누수가 발생하고, 이미 사라진 객체에 이벤트가 전달되어 오류가 생깁니다. 또한 이벤트가 너무 많아지면 디버깅이 어려워집니다.
이벤트 로깅 시스템을 함께 구축하는 것이 좋습니다. 어떤 이벤트가 언제 발생했는지 추적할 수 있어야 합니다.
김개발 씨는 이벤트 버스를 도입한 후 시스템 간 의존성이 크게 줄었음을 느꼈습니다. 새로운 기능을 추가할 때 기존 코드를 건드리지 않아도 되니 마음이 한결 편해졌습니다.
실전 팁
💡 - 이벤트 클래스는 불변(immutable)으로 만들어 부작용을 방지하세요
- onRemove 시점에 반드시 구독을 해제하는 습관을 들이세요
5. 의존성 주입
김개발 씨가 단위 테스트를 작성하려는데 막혔습니다. MovementSystem을 테스트하려면 실제 게임 화면이 필요하고, 데이터베이스 연결도 필요합니다.
"테스트할 때마다 진짜 게임을 실행해야 하나요?"
**의존성 주입(Dependency Injection)**은 객체가 필요로 하는 의존성을 외부에서 넣어주는 설계 패턴입니다. 마치 요리사에게 재료를 가져다주는 것처럼, 시스템이 필요한 서비스를 직접 생성하지 않고 외부에서 제공받습니다.
테스트 시 가짜 의존성을 주입하면 격리된 테스트가 가능해집니다.
다음 코드를 살펴봅시다.
// 서비스 인터페이스 정의
abstract class IAudioService {
void playSound(String soundId);
void stopAll();
}
abstract class IStorageService {
Future<void> save(String key, dynamic value);
Future<dynamic> load(String key);
}
// 실제 구현
class AudioService implements IAudioService {
@override
void playSound(String soundId) => print('Playing: $soundId');
@override
void stopAll() => print('Stopping all sounds');
}
// 서비스 로케이터 패턴
class ServiceLocator {
static final ServiceLocator _instance = ServiceLocator._internal();
factory ServiceLocator() => _instance;
ServiceLocator._internal();
final Map<Type, dynamic> _services = {};
// 서비스 등록
void register<T>(T service) {
_services[T] = service;
}
// 서비스 조회
T get<T>() {
final service = _services[T];
if (service == null) {
throw Exception('Service ${T.toString()} not registered');
}
return service as T;
}
}
// 시스템에서 사용
class SoundSystem extends GameSystem {
late final IAudioService _audioService;
@override
Future<void> onLoad() async {
_audioService = ServiceLocator().get<IAudioService>();
}
}
김개발 씨는 열심히 게임을 만들다가 테스트의 중요성을 깨달았습니다. 그런데 MovementSystem을 테스트하려니 문제가 생겼습니다.
시스템 내부에서 직접 오디오 서비스를 생성하고 있어서, 테스트할 때도 진짜 사운드가 재생됩니다. 박시니어 씨가 물었습니다.
"만약 오디오 서비스를 바꾸는 것처럼 주입할 수 있다면 어떨까요? 테스트할 때는 아무 소리도 내지 않는 가짜 서비스를 넣으면 되잖아요." 의존성 주입은 마치 레스토랑의 주방과 같습니다.
셰프가 직접 농장에 가서 채소를 캐오지 않습니다. 누군가가 신선한 재료를 주방까지 배달해 줍니다.
셰프는 어떤 농장에서 왔는지 신경 쓰지 않고 요리에만 집중합니다. 의존성 주입이 없던 코드는 어땠을까요?
시스템 내부에서 final audio = AudioService()처럼 직접 객체를 생성했습니다. 이렇게 하면 테스트할 때 진짜 서비스가 동작합니다.
데이터베이스 서비스라면 테스트할 때마다 실제 DB에 데이터가 쌓입니다. 외부 API라면 매번 네트워크 요청이 발생합니다.
의존성 주입을 사용하면 이 문제가 해결됩니다. 위 코드에서 IAudioService는 인터페이스입니다.
실제 구현은 AudioService이지만, 시스템은 인터페이스만 알고 있습니다. ServiceLocator에 어떤 구현체를 등록하느냐에 따라 동작이 달라집니다.
테스트할 때는 MockAudioService를 등록합니다. 이 클래스는 IAudioService를 구현하지만 실제로 소리를 내지 않습니다.
대신 어떤 사운드가 재생되었는지 기록만 합니다. 테스트에서 이 기록을 확인하면 됩니다.
ServiceLocator 패턴 외에도 생성자 주입 방식이 있습니다. SoundSystem(this._audioService)처럼 생성자에서 의존성을 받는 것입니다.
더 명시적이고 테스트하기 쉽지만, 의존성이 많아지면 생성자가 복잡해집니다. 주의할 점이 있습니다.
서비스 로케이터를 남용하면 의존성이 숨겨집니다. 코드만 보고는 이 시스템이 어떤 서비스에 의존하는지 알기 어렵습니다.
중요한 의존성은 생성자 주입을, 부가적인 서비스는 로케이터를 사용하는 것이 좋습니다. 김개발 씨는 의존성 주입을 적용한 후 드디어 단위 테스트를 작성할 수 있게 되었습니다.
테스트 실행 시간도 크게 줄었습니다.
실전 팁
💡 - 핵심 의존성은 생성자로, 선택적 의존성은 서비스 로케이터로 주입하세요
- 테스트용 Mock 클래스를 미리 만들어두면 테스트 작성이 빨라집니다
6. 모듈러 아키텍처
프로젝트가 1년째 접어들자 코드가 수만 줄로 불어났습니다. 김개발 씨는 한숨을 쉬었습니다.
"새로운 기능을 추가할 때마다 어디를 수정해야 할지 찾는 데만 한 시간이 걸려요." 박시니어 씨가 말했습니다. "이제 모듈로 나눌 때가 됐네요."
모듈러 아키텍처는 게임을 독립적인 기능 단위로 나누어 관리하는 설계 방식입니다. 마치 아파트 단지처럼 각 동이 독립적으로 운영되면서도 전체가 하나의 단지를 이루는 것과 같습니다.
모듈 간 경계를 명확히 하면 대규모 프로젝트도 관리하기 쉬워집니다.
다음 코드를 살펴봅시다.
// 모듈 인터페이스
abstract class GameModule {
String get moduleName;
List<Type> get dependencies;
Future<void> initialize();
void registerSystems(FlameGame game);
void registerServices(ServiceLocator locator);
void dispose();
}
// 전투 모듈 예시
class CombatModule implements GameModule {
@override
String get moduleName => 'Combat';
@override
List<Type> get dependencies => [CoreModule, AudioModule];
@override
Future<void> initialize() async {
// 전투 관련 에셋 로드
}
@override
void registerSystems(FlameGame game) {
game.add(CombatSystem());
game.add(DamageSystem());
game.add(HealthBarSystem());
}
@override
void registerServices(ServiceLocator locator) {
locator.register<ICombatService>(CombatService());
}
@override
void dispose() {
// 리소스 정리
}
}
// 모듈 매니저
class ModuleManager {
final List<GameModule> _modules = [];
final ServiceLocator _locator;
final FlameGame _game;
ModuleManager(this._game, this._locator);
Future<void> loadModule(GameModule module) async {
// 의존성 체크
for (final dep in module.dependencies) {
if (!_modules.any((m) => m.runtimeType == dep)) {
throw Exception('Missing dependency: $dep');
}
}
await module.initialize();
module.registerServices(_locator);
module.registerSystems(_game);
_modules.add(module);
}
}
김개발 씨의 게임은 이제 제법 규모가 커졌습니다. 전투 시스템, 인벤토리 시스템, 퀘스트 시스템, 대화 시스템, 상점 시스템까지.
파일이 수백 개로 늘어나자 더 이상 전체 구조를 머릿속에 담기 어려워졌습니다. 박시니어 씨가 조언했습니다.
"이제는 기능별로 모듈을 나눠야 해요. 마치 회사가 커지면 부서를 나누는 것처럼요." 모듈러 아키텍처는 마치 아파트 단지와 같습니다.
101동, 102동, 103동이 각각 독립적으로 관리됩니다. 101동 엘리베이터가 고장 나도 다른 동에는 영향이 없습니다.
하지만 모든 동은 같은 관리사무소의 규칙을 따르고, 같은 단지 내 편의시설을 공유합니다. 모듈화되지 않은 코드는 어떤 문제가 있을까요?
하나의 기능을 수정하면 예상치 못한 곳에서 버그가 터집니다. 새로운 개발자가 합류해도 전체 코드를 이해하기 전에는 작업하기 어렵습니다.
특정 기능만 테스트하고 싶어도 전체를 실행해야 합니다. 모듈러 아키텍처를 적용하면 이런 이점이 있습니다.
위 코드의 CombatModule을 보세요. 전투와 관련된 모든 것이 이 모듈 안에 있습니다.
CombatSystem, DamageSystem, HealthBarSystem 같은 시스템들. CombatService 같은 서비스들.
전투 기능을 수정하려면 이 모듈만 보면 됩니다. dependencies 속성도 중요합니다.
CombatModule은 CoreModule과 AudioModule에 의존합니다. ModuleManager가 로드 순서를 자동으로 관리하고, 의존성이 없으면 오류를 발생시킵니다.
실수로 잘못된 순서로 모듈을 로드하는 일을 방지합니다. 실무에서는 모듈별로 폴더를 나눕니다.
modules/combat/, modules/inventory/, modules/quest/ 처럼요. 각 폴더 안에 해당 모듈의 컴포넌트, 시스템, 서비스가 모여 있습니다.
새로운 개발자도 자신이 담당할 모듈만 집중해서 파악하면 됩니다. 주의할 점이 있습니다.
모듈 간 경계를 명확히 해야 합니다. CombatModule에서 InventoryService를 직접 호출하면 안 됩니다.
반드시 이벤트 버스나 공개된 인터페이스를 통해 통신해야 합니다. 이 원칙이 무너지면 모듈화의 장점이 사라집니다.
김개발 씨는 모듈러 아키텍처를 적용하고 나서 드디어 큰 그림이 보이기 시작했습니다. 각 모듈이 명확한 책임을 가지니 어디를 수정해야 할지 바로 알 수 있게 되었습니다.
신입 개발자가 들어와도 하나의 모듈부터 시작해서 점점 범위를 넓혀갈 수 있습니다. "이게 바로 확장 가능한 아키텍처구나." 김개발 씨는 지난 1년간의 여정을 돌아보며 뿌듯해했습니다.
ECS 패턴으로 시작해서, 커스텀 System을 만들고, 컴포넌트 통신을 배우고, 이벤트 버스를 구축하고, 의존성 주입을 적용하고, 마침내 모듈러 아키텍처까지. 이 모든 조각이 맞물려 하나의 견고한 게임 엔진 구조가 완성되었습니다.
실전 팁
💡 - 모듈 간 통신은 반드시 이벤트 버스나 공개 인터페이스를 통해서만 하세요
- 새 기능을 추가할 때 기존 모듈에 넣을지, 새 모듈을 만들지 신중하게 결정하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
AAA급 게임 프로젝트 완벽 가이드
Flutter와 Flame 엔진을 활용하여 AAA급 퀄리티의 모바일 게임을 개발하는 전체 과정을 다룹니다. 기획부터 앱 스토어 출시까지, 실무에서 필요한 모든 단계를 이북처럼 술술 읽히는 스타일로 설명합니다.
빌드와 배포 자동화 완벽 가이드
Flutter 앱 개발에서 GitHub Actions를 활용한 CI/CD 파이프라인 구축부터 앱 스토어 자동 배포까지, 초급 개발자도 쉽게 따라할 수 있는 빌드 자동화의 모든 것을 다룹니다.
게임 분석과 메트릭스 완벽 가이드
Flutter와 Flame으로 개발한 게임의 성공을 측정하고 개선하는 방법을 배웁니다. Firebase Analytics 연동부터 A/B 테스팅, 리텐션 분석까지 데이터 기반 게임 운영의 모든 것을 다룹니다.
게임 보안과 치팅 방지 완벽 가이드
Flutter와 Flame 게임 엔진에서 클라이언트 보안부터 서버 검증까지, 치터들로부터 게임을 보호하는 핵심 기법을 다룹니다. 초급 개발자도 쉽게 따라할 수 있는 실전 보안 코드와 함께 설명합니다.
애니메이션 시스템 커스터마이징 완벽 가이드
Flutter와 Flame 게임 엔진에서 고급 애니메이션 시스템을 구현하는 방법을 다룹니다. 스켈레탈 애니메이션부터 절차적 애니메이션까지, 게임 개발에 필요한 핵심 애니메이션 기법을 실무 예제와 함께 배워봅니다.