BrainFit: 32개 미니게임을 관리하는 Game Registry 패턴
BrainFit을 처음 만들기 시작했을 때는 미니게임이 네다섯 개뿐이었다. 관리가 간단했다. 필요한 곳에서 직접 인스턴스를 만들면 그만이었다. 그런데 지금 BrainFit에는 6개 인지 영역에 걸쳐 32개의 미니게임이 있다. 그 순진한 방식으로는 더 이상 감당할 수 없었다.
이 글에서는 게임 관리 아키텍처를 어떻게 설계했는지 이야기하려 한다. 경량 설정 객체 위에 구축한 레지스트리 패턴, 그리고 상태 머신 역할을 하는 공유 게임 컨트롤러까지. BrainFit의 실제 코드를 보여주면서 어떤 트레이드오프를 했는지 설명하겠다.
문제: 시작 시 32개 게임 전부 초기화?
근본적인 문제는 단순하다. BrainFit의 각 미니게임은 자체 위젯 트리, 렌더링 로직, 때로는 오디오 에셋, 그리고 고유한 난이도 곡선을 가지고 있다. 32개 게임 위젯과 컨트롤러를 앱 시작 시 전부 즉시 생성하면, 체감할 수 있을 정도로 실행이 느려진다. 저사양 안드로이드 기기에서는 이론적인 문제가 아니라 실제로 측정한 결과였다.
성능 문제 외에도 구조적인 문제가 있었다. 새 게임을 추가할 때마다 여러 파일을 수정해야 했다: 라우팅 테이블, 트레이닝 화면, 통계 대시보드, 일일 미션 생성기... 전형적인 산탄총 수술(shotgun surgery) 패턴이었다. "이 앱에 어떤 게임들이 존재하는가"에 대한 단일 진실 소스(single source of truth)가 필요했다.
해결책: GameConfig + Registry
핵심 아이디어는 게임 메타데이터와 게임 런타임을 분리하는 것이다. 메타데이터는 저렴하다 -- 문자열과 enum만 담긴 데이터 클래스다. 런타임은 비싸다 -- 위젯 트리, 타이머, 상태 머신, 오디오가 관여한다. 그래서 게임을 실행하지 않고도 게임에 대해 알아야 할 모든 것을 담는 GameConfig 클래스를 정의했다.
GameConfig: 데이터 클래스
실제 코드베이스의 GameConfig 클래스다:
class GameConfig {
final String id;
final String nameKey; // i18n key
final String descriptionKey;
final BrainArea area;
final int timeLimitSeconds;
final int initialLevel;
final int maxLevel;
final bool isPro;
final String? evidenceKey;
const GameConfig({
required this.id,
required this.nameKey,
required this.descriptionKey,
required this.area,
this.timeLimitSeconds = 60,
this.initialLevel = 1,
this.maxLevel = 20,
this.isPro = false,
this.evidenceKey,
});
}
몇 가지 주목할 점이 있다.
const 생성자. 모든 GameConfig 인스턴스는 컴파일 타임 상수다. 런타임 할당 비용이 제로라는 뜻이다. Dart 컴파일러가 이 값들을 바이너리에 직접 포함시킨다. 32개가 되면 이 차이가 의미 있다.
문자열 대신 i18n 키. nameKey와 descriptionKey 필드는 표시 문자열이 아니라 번역 키다. BrainFit은 영어와 한국어를 지원하는데, 게임 이름이 두 언어에서 상당히 다르다. 키를 저장함으로써 설정이 언어에 종속되지 않는다.
isPro 플래그. BrainFit은 프리미엄 모델을 따른다: 24개는 무료, 8개는 Pro 전용이다(분류 기준은 나중에 설명). Pro 게임 목록을 별도로 관리하는 대신, 플래그를 설정 자체에 넣었다. 진실의 소스가 하나다.
evidenceKey. 일부 게임은 특정 인지과학 연구에 기반한다. 이 선택적 필드는 근거 설명의 로컬라이즈된 텍스트와 연결되며, 게임 상세 화면에서 표시된다.
BrainArea enum. 각 게임을 6개 인지 영역 중 하나에 연결한다:
enum BrainArea {
wis, // 기억력
agi, // 집중력
int_, // 논리력 (int는 Dart 예약어)
cha, // 언어력
spd, // 처리 속도
spa, // 공간 인지
}
int_에 밑줄이 붙은 걸 주목하자. Dart에서 int는 키워드로 예약되어 있어서 우회해야 했다. 사소한 불편이지만, enum의 확장 메서드가 표시 매핑을 깔끔하게 처리한다.
레지스트리: 하나의 Const 리스트
레지스트리 자체는 의도적으로 단순하다. game_registry.dart의 최상위 const 리스트다:
const allGames = <GameConfig>[
// WIS - 기억력
GameConfig(id: 'pattern_matrix', nameKey: 'game.pattern_matrix',
descriptionKey: 'game.pattern_matrix.desc', area: BrainArea.wis,
timeLimitSeconds: 60),
GameConfig(id: 'face_memory', nameKey: 'game.face_memory',
descriptionKey: 'game.face_memory.desc', area: BrainArea.wis,
timeLimitSeconds: 60),
GameConfig(id: 'card_flip', nameKey: 'game.card_flip',
descriptionKey: 'game.card_flip.desc', area: BrainArea.wis,
timeLimitSeconds: 90),
GameConfig(id: 'sound_sequence', nameKey: 'game.sound_sequence',
descriptionKey: 'game.sound_sequence.desc', area: BrainArea.wis,
timeLimitSeconds: 60),
GameConfig(id: 'n_back', nameKey: 'game.n_back',
descriptionKey: 'game.n_back.desc', area: BrainArea.wis,
timeLimitSeconds: 60, evidenceKey: 'game.n_back.evidence'),
GameConfig(id: 'memory_palace', nameKey: 'game.memory_palace',
descriptionKey: 'game.memory_palace.desc', area: BrainArea.wis,
timeLimitSeconds: 90, isPro: true,
evidenceKey: 'game.memory_palace.evidence'),
// ... 나머지 26개 게임
];
리스트가 const이기 때문에 전체 레지스트리의 런타임 비용은 사실상 제로다. 힙에 객체가 할당되지 않는다. 초기화 코드가 실행되지 않는다. 프로그램의 데이터 세그먼트에 속한다.
쿼리 함수
레지스트리 위에 몇 가지 쿼리 함수를 정의했다:
List<GameConfig> getGamesByArea(BrainArea area) =>
allGames.where((g) => g.area == area).toList();
GameConfig? getGameById(String id) =>
allGames.where((g) => g.id == id).firstOrNull;
List<GameConfig> getProGamesByArea(BrainArea area) =>
allGames.where((g) => g.area == area && g.isPro).toList();
List<GameConfig> getFreeGamesByArea(BrainArea area) =>
allGames.where((g) => g.area == area && !g.isPro).toList();
의도적으로 함수형 스타일 필터로 만들었다. 미리 계산한 맵 대신. 32개 항목에서의 선형 탐색은 무시할 수 있는 수준이고, 동기화가 어긋날 수 있는 별도 데이터 구조를 관리하지 않아도 된다. 만약 수백 개까지 늘어나면 재고하겠지만, 32개에서는 단순함이 승리한다.
32개 게임: 전체 구성표
6개 인지 영역에 걸친 32개 미니게임의 전체 구성이다:
| 영역 | 코드 | 무료 게임 | Pro 전용 게임 | |------|------|-----------|--------------| | 기억력 | WIS | PatternMatrix, CardFlip, FaceMemory, SoundSequence | NBack, MemoryPalace | | 집중력 | AGI | ColorStroop, SpeedTap, TrackingBall, DualTask | GoNoGo, AttentionalBlink | | 논리력 | INT | NumberChain, BlockPuzzle, Balance, PatternMatching | MatrixReasoning, TowerPlanning | | 언어력 | CHA | WordScramble, AssociationChain, FillBlank, CategorySort | VerbalFluency, VerbalAnalogy | | 처리속도 | SPD | StarlightSearch, MeteorSort | SignalSwitch, BlackholeDodge | | 공간인지 | SPA | PlanetRotation, SpaceMaze | NebulaAssembly, OrbitPredict |
무료 24개, Pro 전용 8개다. 이 분류는 의도적이다. 모든 영역에 최소 2개 무료 게임이 있어서, 무료 사용자도 6개 인지 영역 전부를 훈련할 수 있다. Pro 게임은 특정 인지과학 패러다임(N-Back, Go/No-Go, Attentional Blink 등)에 기반한 것들이 많아서, 뇌 훈련에 더 진지한 사용자에게 어필한다.
각 영역은 UI에서 고유한 색상을 가진다. WIS는 teal, AGI는 orange, INT는 purple, CHA는 yellow, SPD는 pink, SPA는 blue. 이 색상들은 BrainArea enum 자체에 확장 메서드로 정의되어 있다:
Color get color => switch (this) {
BrainArea.wis => const Color(0xFF36D6B5),
BrainArea.agi => const Color(0xFFFF8A4C),
BrainArea.int_ => const Color(0xFF8B6FE8),
BrainArea.cha => const Color(0xFFF5C542),
BrainArea.spd => const Color(0xFFFF4C8B),
BrainArea.spa => const Color(0xFF4CA1FF),
};
덕분에 어떤 위젯에서든 game.area.color를 호출하면 올바른 색상을 얻을 수 있다. 룩업 테이블이나 UI 코드 전체에 흩어진 switch 문 없이.
GameController: 공유 상태 머신
GameConfig가 게임이 무엇인지 설명한다면, GameController는 게임이 어떻게 실행되는지 관리한다. 32개 게임 각각이 자체 타이머 로직, 콤보 추적, 점수 계산을 구현하는 대신, 모두 하나의 GameController 클래스를 공유한다.
컨트롤러는 네 가지 상태(phase)를 가진 상태 머신을 구현한다:
enum GamePhase { ready, playing, paused, finished }
생명주기는 단순하다: ready -> playing -> paused(선택) -> finished. 컨트롤러는 카운트다운 타이머를 관리하고, 콤보 스트릭을 추적하며, UI가 반응할 수 있는 이벤트를 발행한다.
생성자를 보자:
GameController({
required this.config,
double? playerElo,
int? gamesPlayed,
bool vibrationEnabled = true,
double gameSpeedMultiplier = 1.0,
}) : _vibrationEnabled = vibrationEnabled,
_state = GameState(
totalTimeMs: (config.timeLimitSeconds * gameSpeedMultiplier * 1000).round(),
remainingTimeMs: (config.timeLimitSeconds * gameSpeedMultiplier * 1000).round(),
level: config.initialLevel,
) {
_difficulty = DifficultyAdapter(
minLevel: config.initialLevel,
maxLevel: config.maxLevel,
playerElo: playerElo,
gamesPlayed: gamesPlayed,
);
_state = _state.copyWith(level: _difficulty.currentLevel);
}
컨트롤러가 GameConfig를 받아서 타이머 시간과 레벨 범위를 설정하는 것을 볼 수 있다. gameSpeedMultiplier 파라미터는 타이머가 빠르거나 느리게 돌아가는 챌린지 모드에 사용된다. DifficultyAdapter는 플레이어의 Elo 레이팅을 사용해 시작 난이도를 결정한다 -- 게임별 난이도 적응은 Elo 시스템을 설명한 이 글에서 자세히 다룬다.
이벤트 스트림
유용했던 설계 결정 중 하나가 이벤트 스트림이다:
enum GameEvent {
combo3, combo5, combo10, combo20,
comboBreak,
missStreak3, missStreak5,
levelUp, levelDown,
timeWarning30s, timeWarning10s,
perfectRound,
correct, wrong,
}
final _eventController = StreamController<GameEvent>.broadcast();
Stream<GameEvent> get events => _eventController.stream;
UI는 이 스트림을 구독하고 그에 따라 반응한다. combo5 이벤트가 발생하면 UI가 콤보 애니메이션을 보여준다. timeWarning10s가 발생하면 타이머가 빨갛게 변하고 기기가 진동한다. 게임 종료 시 perfectRound가 발생하면 특별한 축하 화면이 나타난다.
이렇게 하면 게임 로직과 프레젠테이션이 분리된다. 컨트롤러는 UI가 이 이벤트로 무엇을 하는지 알지도 못하고 신경 쓰지도 않는다. 무슨 일이 일어났는지 알려주기만 할 뿐이다.
정답과 오답 기록
recordCorrect()와 recordWrong() 메서드는 개별 게임 위젯과 컨트롤러 사이의 주요 인터페이스다:
void recordCorrect() {
if (!_state.isPlaying) return;
final responseTime = _questionStartTime != null
? DateTime.now().difference(_questionStartTime!).inMilliseconds
: 2000;
final newCombo = _state.combo + 1;
final points = ScoreCalculator.calculateScore(
level: _state.level,
responseTimeMs: responseTime,
combo: newCombo,
);
_difficulty.recordAnswer(correct: true);
final newLevel = _difficulty.currentLevel;
_state = _state.copyWith(
score: _state.score + points,
combo: newCombo,
maxCombo: max(newCombo, _state.maxCombo),
correctCount: _state.correctCount + 1,
level: newLevel,
missStreak: 0,
lastPointsEarned: points,
);
if (_vibrationEnabled) HapticFeedback.lightImpact();
_eventController.add(GameEvent.correct);
// ... 콤보 이벤트, 레벨 변경 이벤트
}
점수 계산은 세 가지 변수를 고려한다: 현재 난이도 레벨, 반응 시간, 현재 콤보 스트릭. 이 조합이 만족스러운 피드백 루프를 만든다 -- 높은 난이도에서 빠르고 정확하게 플레이하면 극적으로 높은 점수를 얻는다.
일시정지와 재개
일시정지 처리에는 미묘하지만 중요한 디테일이 있다. 플레이어가 일시정지하면 컨트롤러가 타임스탬프를 기록한다. 재개 시, 일시정지 기간만큼 문제 시작 시간을 조정해서 반응 시간 측정이 불이익을 받지 않도록 한다:
void resume() {
if (_questionStartTime != null && _pausedAt != null) {
_questionStartTime = _questionStartTime!.add(
DateTime.now().difference(_pausedAt!),
);
}
_pausedAt = null;
_state = _state.copyWith(phase: GamePhase.playing);
_startTimer();
notifyListeners();
}
반응 시간이 점수에 직접 영향을 미치기 때문에 이게 중요하다. 이 보정이 없으면, 전화를 받으려고 일시정지한 플레이어가 돌아왔을 때 엄청난 반응 시간 페널티를 받게 된다.
GameState: 불변 스냅샷
GameState 클래스는 copyWith를 사용하는 불변 패턴을 따른다:
class GameState {
final GamePhase phase;
final int score;
final int level;
final int combo;
final int maxCombo;
final int correctCount;
final int wrongCount;
final int remainingTimeMs;
final int totalTimeMs;
final int missStreak;
final int lastPointsEarned;
double get timeProgress => remainingTimeMs / totalTimeMs;
int get totalAttempts => correctCount + wrongCount;
double get accuracy =>
totalAttempts == 0 ? 0 : correctCount / totalAttempts;
bool get isPlaying => phase == GamePhase.playing;
}
상태가 불변이기 때문에 UI가 참조를 안전하게 유지할 수 있다. 내부에서 변경될 걱정이 없다. 컨트롤러는 매 업데이트마다 새로운 GameState를 생성하고 notifyListeners()를 호출한다. Flutter의 ChangeNotifier와 Riverpod의 상태 관리와 잘 맞는 방식이다.
지연 초기화: 왜 중요한가
핵심적인 아키텍처 결정은 allGames 리스트가 오직 GameConfig 객체만 담고 있다는 것이다 -- 경량이고, const이며, 비용이 제로다. 실제 게임 위젯, 에셋, 컨트롤러는 플레이어가 특정 게임의 "플레이" 버튼을 탭하기 전까지 생성되지 않는다.
시작 시와 플레이 시 각각 무엇이 일어나는지 비교해보자:
앱 시작 시:
allGamesconst 리스트가 즉시 사용 가능하다 (바이너리에 포함되어 있으므로).- 트레이닝 화면이 레지스트리에서 읽어 게임 카드를 표시한다.
- 일일 미션 생성기가 레지스트리를 쿼리해서 랜덤 게임을 선택한다.
- 게임 위젯이나 컨트롤러는 하나도 인스턴스화되지 않는다.
플레이 시:
- 사용자가 게임 카드를 탭한다.
- 라우터가 게임 화면으로 이동한다.
- 게임 화면이 해당
GameConfig로GameController를 생성한다. - 게임 고유의 위젯 트리가 빌드된다.
- 에셋(이미지, 오디오)이 로드된다.
이 말은 레지스트리에 게임을 아무리 많이 추가해도 시작 시간은 일정하게 유지된다는 뜻이다. 비용은 게임이 실제로 플레이될 때만, 그 하나의 게임에 대해서만 지불된다.
개별 게임이 레지스트리를 사용하는 방법
32개 게임 각각은 GameConfig를 받아서 자체 GameController를 생성하는 Flutter 위젯이다. 게임 위젯이 해야 할 일은 딱 두 가지다: 게임 고유의 UI를 렌더링하고, 플레이어가 상호작용할 때 controller.recordCorrect() 또는 controller.recordWrong()을 호출하는 것.
그 외 모든 것 -- 타이머, 점수 계산, 콤보 추적, 난이도 적응, 이벤트 스트림 -- 은 공유 인프라가 처리한다. 새 게임을 추가하는 것은 놀라울 정도로 간단하다:
- 레지스트리의
allGames에GameConfig항목 추가. - 게임 위젯 생성.
- 라우트 추가.
게임 위젯은 점수 공식, Elo 레이팅, 콤보 배수, 타이머 관리에 대해 알 필요가 없다. 문제를 제시하고 정답/오답을 보고하면 된다.
트레이드오프와 대안
리스트 대신 맵은 왜 안 썼나? 게임 ID를 키로 하는 Map<String, GameConfig>도 고려했다. ID로 O(1) 조회가 가능하니까. 하지만 실제로 가장 흔한 접근 패턴은 "특정 영역의 모든 게임 가져오기"와 "모든 게임 순회하기"인데, 둘 다 리스트가 더 적합하다. getGameById 함수는 선형 탐색을 하지만, 32개 항목에서는 마이크로초 단위로 완료된다.
코드 생성은 왜 안 쓰나? 일부 Flutter 프로젝트는 어노테이션에서 레지스트리를 생성하는 build_runner를 사용한다. 나는 레지스트리가 수동으로 관리할 수 있을 만큼 단순하고, 파일 하나를 위해 코드 생성을 추가하는 것은 복잡성 대비 이점이 없다고 판단했다. BrainFit에 200개 게임이 있었다면 재고했을 것이다.
GetIt 같은 서비스 로케이터는? 앱 전체에서 의존성 주입으로 Riverpod을 사용한다. 게임만을 위한 별도 서비스 로케이터를 추가하면 일관성이 깨진다. const 리스트 접근 방식이 더 단순하고 어떤 프레임워크도 필요 없다.
정리
BrainFit의 게임 레지스트리 패턴은 몇 가지 원칙으로 요약된다:
- 메타데이터와 런타임을 분리한다.
GameConfig는 저렴하고, 게임 위젯은 비싸다. 둘을 분리해서 관리한다. - 가능한 한 const를 사용한다.
const객체의const리스트는 런타임 비용이 제로다. - 상태 머신을 공유한다.
GameController가 타이머, 점수, 콤보, 이벤트를 처리하므로 개별 게임이 직접 구현할 필요가 없다. - 미리 계산하지 말고 쿼리한다. 32개 항목에서 리스트 필터링은 충분히 빠르고, 동기화 버그를 방지한다.
- 사용하는 것만 비용을 지불한다. 게임 위젯과 컨트롤러는 실제 플레이할 때만 생성된다.
이 아키텍처는 BrainFit이 4개 게임에서 32개 게임으로 성장하는 과정에서 잘 작동했다. 시작 시간을 빠르게 유지하고, 새 게임 추가를 쉽게 만들며, 코드베이스를 관리 가능하게 유지한다. 유사하지만 다른 기능이 많은 Flutter 앱을 만들고 있다면, 같은 패턴이 도움이 될 수 있다.