본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 29. · 3 Views
Flame 고급 컴포넌트 패턴 완벽 가이드
Flutter의 Flame 엔진으로 게임을 만들 때 필수적인 고급 컴포넌트 패턴을 배워봅니다. 상속, Mixin, HasGameRef 등 실무에서 자주 쓰이는 패턴을 초급자도 이해할 수 있도록 쉽게 설명합니다.
목차
1. 컴포넌트 상속 패턴
어느 날 김개발 씨가 Flame으로 첫 게임을 만들고 있었습니다. 적 캐릭터를 10종류나 만들어야 하는데, 매번 똑같은 코드를 반복해서 작성하고 있었습니다.
선배 박시니어 씨가 코드를 보더니 말했습니다. "이거, 상속 패턴으로 정리하면 훨씬 깔끔해질 텐데요?"
컴포넌트 상속 패턴은 공통 기능을 가진 부모 클래스를 만들고, 자식 클래스에서 이를 확장하는 방식입니다. 마치 자동차 설계도에서 기본 모델을 만들고, 스포츠카와 SUV를 파생시키는 것과 같습니다.
이 패턴을 사용하면 코드 중복을 크게 줄이고, 유지보수가 쉬워집니다.
다음 코드를 살펴봅시다.
// 기본 적 캐릭터 클래스 - 모든 적의 공통 기능
class BaseEnemy extends SpriteComponent with HasGameRef {
double health = 100;
double speed = 50;
// 모든 적이 공유하는 기본 이동 로직
void moveTowardsPlayer(Vector2 playerPosition, double dt) {
final direction = (playerPosition - position).normalized();
position += direction * speed * dt;
}
// 데미지 처리 로직
void takeDamage(double damage) {
health -= damage;
if (health <= 0) removeFromParent();
}
}
// 빠른 적 - 상속으로 확장
class FastEnemy extends BaseEnemy {
FastEnemy() {
speed = 120; // 기본보다 빠름
health = 50; // 대신 체력은 낮음
}
}
// 탱크 적 - 느리지만 튼튼함
class TankEnemy extends BaseEnemy {
TankEnemy() {
speed = 30; // 느림
health = 300; // 높은 체력
}
}
김개발 씨는 게임 개발 2주 차 주니어 개발자입니다. 오늘도 열심히 적 캐릭터를 만들고 있었는데, 뭔가 이상했습니다.
FastEnemy, SlowEnemy, BossEnemy... 10개의 클래스를 만들었는데 똑같은 코드가 계속 반복됩니다.
선배 박시니어 씨가 모니터를 보더니 웃으며 말했습니다. "아, 이거 전형적인 코드 중복이네요.
상속 패턴을 한번 써보는 게 어때요?" 그렇다면 컴포넌트 상속 패턴이란 정확히 무엇일까요? 쉽게 비유하자면, 상속은 마치 가족의 유전자와 같습니다.
부모에게서 물려받은 눈 색깔, 키, 체형 같은 기본 특징이 있고, 거기에 자신만의 개성이 더해지는 것처럼요. 프로그래밍에서도 부모 클래스의 기능을 물려받아 자식 클래스가 자신만의 특징을 추가할 수 있습니다.
상속이 없던 시절에는 어땠을까요? 개발자들은 비슷한 기능을 가진 클래스를 만들 때마다 모든 코드를 처음부터 다시 작성해야 했습니다.
적 캐릭터 10개를 만든다면, 이동 로직을 10번, 데미지 처리를 10번, 체력 관리를 10번 작성하는 식이었죠. 코드가 길어지고, 실수하기도 쉬웠습니다.
더 큰 문제는 나중에 수정할 때였습니다. 이동 로직에 버그가 있다면?
10개 파일을 모두 찾아서 고쳐야 했습니다. 바로 이런 문제를 해결하기 위해 상속 패턴이 등장했습니다.
상속을 사용하면 공통 기능을 한 곳에 모아둘 수 있습니다. BaseEnemy에 이동, 데미지, 체력 관리를 한 번만 작성하면, 모든 자식 클래스가 이를 자동으로 물려받습니다.
또한 각 적의 개성도 쉽게 표현할 수 있습니다. FastEnemy는 speed만 높이고, TankEnemy는 health만 높이면 됩니다.
무엇보다 유지보수가 엄청나게 쉬워진다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 BaseEnemy 클래스를 보면 SpriteComponent를 상속받고 있습니다. 이 부분이 Flame의 핵심입니다.
SpriteComponent는 이미 화면에 그리기, 업데이트 등의 기능을 가지고 있고, 우리는 여기에 게임 로직만 추가하면 됩니다. 다음으로 moveTowardsPlayer 메서드에서는 플레이어를 향해 이동하는 로직이 구현되어 있습니다.
마지막으로 takeDamage에서 체력 관리와 사망 처리가 이루어집니다. 자식 클래스인 FastEnemy를 보면 정말 간단합니다.
생성자에서 speed와 health 값만 바꿔주면 끝입니다. moveTowardsPlayer나 takeDamage는 이미 부모에게 있으니 작성할 필요가 없습니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 RPG 게임을 개발한다고 가정해봅시다.
플레이어 캐릭터, NPC, 몬스터 모두 공통적으로 이동하고, 대화하고, 아이템을 주고받는 기능이 필요합니다. 이럴 때 BaseCharacter 클래스를 만들어두고, Player, NPC, Monster 클래스가 이를 상속받게 하면 코드량이 절반 이하로 줄어듭니다.
실제로 많은 게임 회사에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 상속을 너무 깊게 만드는 것입니다. BaseEnemy → GroundEnemy → FastGroundEnemy → BossGroundEnemy처럼 4~5단계로 상속하면, 나중에 코드를 읽는 사람이 "이 기능이 어디서 온 거지?"라며 헤매게 됩니다.
따라서 2~3단계 이내로 유지하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 매번 똑같은 코드를 작성하고 있었군요!" 상속 패턴을 제대로 이해하면 더 깔끔하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 공통 기능은 부모 클래스에, 개별 특성은 자식 클래스에 작성하세요
- 상속 깊이는 2~3단계 이내로 유지하는 것이 좋습니다
- extends 키워드로 상속하고, super로 부모의 생성자나 메서드를 호출할 수 있습니다
2. Mixin 활용하기
김개발 씨가 게임 개발을 계속하던 중, 새로운 문제에 부딪혔습니다. 적 캐릭터와 플레이어 모두 "충돌 감지" 기능이 필요한데, 두 클래스는 서로 다른 부모를 상속받고 있었습니다.
"상속은 하나만 받을 수 있는데 어떡하지?" 고민하던 김개발 씨에게 박시니어 씨가 말했습니다. "그럴 땐 Mixin을 쓰면 됩니다!"
Mixin은 클래스에 기능을 추가하는 독립적인 기능 모듈입니다. 상속과 달리 여러 개를 동시에 사용할 수 있어서, 마치 레고 블록처럼 필요한 기능만 골라서 조합할 수 있습니다.
Flame에서는 CollisionCallbacks, TapCallbacks 등 다양한 Mixin을 제공합니다.
다음 코드를 살펴봅시다.
// Mixin 정의 - 폭발 효과 기능
mixin Explodable on Component {
void explode() {
// 폭발 파티클 생성
final explosion = ParticleSystemComponent(
particle: CircleParticle(radius: 2.0),
)..position = position;
parent?.add(explosion);
removeFromParent();
}
}
// 적 캐릭터 - 여러 Mixin 조합
class Enemy extends SpriteComponent
with CollisionCallbacks, Explodable, HasGameRef {
@override
void onCollision(Set<Vector2> points, PositionComponent other) {
if (other is Bullet) {
explode(); // Explodable Mixin의 기능 사용
}
}
}
// 플레이어도 같은 Mixin 사용 가능
class Player extends SpriteComponent
with CollisionCallbacks, Explodable, TapCallbacks, HasGameRef {
@override
void onTapDown(TapDownEvent event) {
// 터치 처리
}
}
김개발 씨는 게임 개발 3주 차에 접어들었습니다. 상속 패턴을 잘 활용하고 있었지만, 새로운 난관에 부딪혔습니다.
적 캐릭터와 플레이어 캐릭터 모두에게 "폭발 효과"를 넣고 싶은데, 문제가 있었습니다. Enemy는 BaseEnemy를 상속받고, Player는 BaseCharacter를 상속받고 있었습니다.
Dart는 단일 상속만 지원하기 때문에, 새로운 BaseExplodable 클래스를 만들어 상속받을 수가 없었습니다. "이걸 어떻게 해결하지?" 고민하던 김개발 씨에게 박시니어 씨가 Mixin을 소개해주었습니다.
그렇다면 Mixin이란 정확히 무엇일까요? 쉽게 비유하자면, Mixin은 마치 스마트폰의 기능 모듈과 같습니다.
카메라 모듈, 블루투스 모듈, NFC 모듈을 원하는 대로 조합해서 폰을 만드는 것처럼요. 상속이 "하나의 부모로부터 모든 것을 물려받는" 방식이라면, Mixin은 "필요한 기능만 골라서 추가하는" 방식입니다.
Mixin이 없던 시절에는 어땠을까요? 개발자들은 같은 기능을 여러 클래스에 추가하고 싶을 때 곤란했습니다.
복사-붙여넣기로 코드를 중복시키거나, 억지로 상속 구조를 복잡하게 만들어야 했습니다. 예를 들어 "충돌 감지"를 10개 클래스에 넣으려면, 코드를 10번 복사하거나, 모든 클래스가 CollisionBase를 상속받도록 구조를 뒤바꿔야 했죠.
바로 이런 문제를 해결하기 위해 Mixin이 등장했습니다. Mixin을 사용하면 기능을 독립적인 모듈로 분리할 수 있습니다.
Explodable이라는 Mixin을 한 번 만들어두면, 어떤 클래스든 with Explodable만 추가하면 폭발 기능을 쓸 수 있습니다. 또한 여러 Mixin을 동시에 사용할 수 있습니다.
CollisionCallbacks, Explodable, TapCallbacks를 한 클래스에 모두 추가할 수 있죠. 무엇보다 코드 재사용성이 극대화된다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 mixin Explodable on Component를 보면, Mixin을 정의하고 있습니다.
on Component는 "이 Mixin은 Component를 상속받은 클래스에서만 사용할 수 있다"는 제약 조건입니다. 다음으로 explode 메서드에서는 파티클 시스템을 생성하고 자기 자신을 제거합니다.
Enemy 클래스를 보면 with 키워드로 여러 Mixin을 추가하고 있습니다. CollisionCallbacks는 충돌 감지를, Explodable은 폭발 효과를, HasGameRef는 게임 참조를 제공합니다.
onCollision에서 총알과 충돌하면 explode()를 호출하는데, 이 메서드는 Explodable Mixin에서 온 것입니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 모바일 게임에서 "터치 가능한 모든 객체"를 만든다고 가정해봅시다. 버튼, 아이템, 캐릭터, 건물 등 서로 완전히 다른 객체들이지만, 모두 터치 이벤트 처리는 필요합니다.
이럴 때 TapCallbacks Mixin을 사용하면, 각 클래스가 어떤 상속 구조를 가지든 상관없이 터치 기능을 추가할 수 있습니다. Unity나 Unreal Engine에서도 비슷한 컴포넌트 시스템을 사용합니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 Mixin을 너무 많이 추가하는 것입니다.
with A, B, C, D, E, F, G처럼 78개의 Mixin을 한 클래스에 붙이면, "이 메서드가 어느 Mixin에서 온 건지" 파악하기 어려워집니다. 따라서 꼭 필요한 기능만 추가하고, 34개 이내로 유지하는 것이 좋습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. Mixin을 배운 김개발 씨는 감탄했습니다.
"와, 이렇게 쉽게 기능을 추가할 수 있다니!" Mixin을 제대로 이해하면 코드 재사용성을 극대화하고, 유연한 설계를 할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - Mixin은 with 키워드로 추가하며, 여러 개를 동시에 사용할 수 있습니다
- on 키워드로 Mixin을 사용할 수 있는 클래스를 제한할 수 있습니다
- Flame의 기본 Mixin들(CollisionCallbacks, TapCallbacks 등)을 적극 활용하세요
3. HasGameRef 사용법
김개발 씨가 적 캐릭터에서 플레이어 위치를 알아내려고 했습니다. 그런데 문제가 생겼습니다.
적 컴포넌트는 게임 전체 정보에 접근할 방법이 없었습니다. "어떻게 다른 컴포넌트 정보를 가져오지?" 막막해하던 김개발 씨에게 박시니어 씨가 힌트를 줬습니다.
"HasGameRef를 써보세요. 게임 전체를 참조할 수 있어요."
HasGameRef는 컴포넌트가 게임 인스턴스에 접근할 수 있게 해주는 Mixin입니다. 마치 직원이 회사 전체 자원에 접근할 수 있는 사원증을 받는 것과 같습니다.
이를 통해 다른 컴포넌트를 찾거나, 게임 설정을 읽거나, 전역 상태에 접근할 수 있습니다.
다음 코드를 살펴봅시다.
// 게임 클래스 정의
class MyGame extends FlameGame {
late Player player;
int score = 0;
@override
Future<void> onLoad() async {
player = Player();
add(player);
}
}
// HasGameRef<MyGame>을 사용하는 적 컴포넌트
class Enemy extends SpriteComponent with HasGameRef<MyGame> {
@override
void update(double dt) {
super.update(dt);
// gameRef로 게임 인스턴스 접근
final playerPos = gameRef.player.position;
final distance = position.distanceTo(playerPos);
// 플레이어가 가까우면 추적
if (distance < 200) {
final direction = (playerPos - position).normalized();
position += direction * 50 * dt;
}
// 게임 점수 업데이트
if (isRemoved) {
gameRef.score += 10;
}
}
}
김개발 씨는 게임 개발 한 달 차가 되었습니다. 적 캐릭터의 AI를 만들고 있었는데, 큰 벽에 부딪혔습니다.
적이 플레이어를 향해 움직이려면 플레이어의 위치를 알아야 하는데, Enemy 클래스 안에서는 Player 정보에 접근할 방법이 없었습니다. "생성자로 플레이어를 넘겨받을까?
아니면 전역 변수를 만들까?" 고민하던 김개발 씨에게 박시니어 씨가 코드 리뷰를 해주며 말했습니다. "Flame에는 이미 좋은 해결책이 있어요.
HasGameRef를 써보세요." 그렇다면 HasGameRef란 정확히 무엇일까요? 쉽게 비유하자면, HasGameRef는 마치 회사의 인트라넷 접속 권한과 같습니다.
신입 사원이 입사하면 사원증을 받고, 그것으로 회사 내부망, 자료실, 회의실 예약 시스템 등에 접근할 수 있죠. 마찬가지로 컴포넌트에 HasGameRef를 추가하면, gameRef라는 특별한 속성이 생기고, 이를 통해 게임의 모든 자원에 접근할 수 있습니다.
HasGameRef가 없던 시절에는 어땠을까요? 개발자들은 컴포넌트 간 통신을 위해 복잡한 방법을 써야 했습니다.
생성자로 다른 컴포넌트의 참조를 일일이 넘겨주거나, 전역 변수를 남발하거나, 이벤트 시스템을 직접 구축해야 했습니다. 적 10개가 모두 플레이어를 참조해야 한다면?
생성자에 player 파라미터를 10번 넘겨줘야 했습니다. 코드가 지저분해지고, 실수하기도 쉬웠습니다.
바로 이런 문제를 해결하기 위해 HasGameRef가 등장했습니다. HasGameRef를 사용하면 게임 인스턴스에 직접 접근할 수 있습니다.
Enemy 안에서 gameRef.player로 플레이어를 바로 찾을 수 있죠. 또한 타입 안전성도 보장됩니다.
HasGameRef<MyGame>으로 제네릭을 지정하면, IDE가 MyGame의 속성들을 자동완성해줍니다. 무엇보다 생성자가 깔끔해진다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 MyGame 클래스를 보면 player와 score라는 전역 상태를 가지고 있습니다.
이것들은 게임 전체에서 공유되는 정보입니다. 다음으로 Enemy 클래스에서 **with HasGameRef<MyGame>**을 추가했습니다.
제네릭으로 MyGame을 지정해서, gameRef의 타입이 MyGame임을 명시합니다. update 메서드 안에서 gameRef.player.position으로 플레이어 위치를 가져옵니다.
생성자로 player를 받지 않아도, 언제든지 gameRef를 통해 접근할 수 있습니다. distanceTo로 거리를 계산하고, 200 이내면 플레이어를 향해 이동합니다.
마지막으로 적이 죽으면 gameRef.score를 증가시킵니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 타워 디펜스 게임을 만든다고 가정해봅시다. 타워 컴포넌트는 게임의 웨이브 정보, 골드, 현재 난이도 등을 알아야 합니다.
적 컴포넌트는 맵 정보, 경로, 플레이어 타워들을 알아야 하죠. 이 모든 정보를 생성자로 넘기면 코드가 너무 복잡해집니다.
하지만 HasGameRef를 사용하면, gameRef.waveInfo, gameRef.gold처럼 간단하게 접근할 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 onLoad 이전에 gameRef를 사용하는 것입니다. 컴포넌트가 게임에 추가되기 전에는 gameRef가 null이므로, 접근하면 에러가 발생합니다.
따라서 onLoad나 update 안에서만 gameRef를 사용해야 합니다. 생성자나 초기화 블록에서는 사용할 수 없습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. HasGameRef를 적용한 김개발 씨는 놀라워했습니다.
"와, 이렇게 간단하게 다른 컴포넌트에 접근할 수 있다니!" HasGameRef를 제대로 이해하면 컴포넌트 간 통신이 쉬워지고, 코드가 훨씬 깔끔해집니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - HasGameRef<YourGame>으로 제네릭 타입을 지정하면 타입 안전성이 보장됩니다
- gameRef는 onLoad 이후에만 사용할 수 있으니 주의하세요
- 게임 전역 상태(점수, 설정 등)를 Game 클래스에 두고 gameRef로 접근하세요
4. 커스텀 컴포넌트 생성
김개발 씨가 게임에 체력바를 추가하려고 했습니다. RectangleComponent와 TextComponent를 조합해서 만들 수 있지만, 적마다, 보스마다 똑같은 코드를 반복해야 했습니다.
"이것도 컴포넌트로 만들 수 있을까?" 궁금해하던 김개발 씨에게 박시니어 씨가 말했습니다. "당연하죠!
커스텀 컴포넌트를 만들면 됩니다."
커스텀 컴포넌트는 기존 컴포넌트들을 조합하거나 확장해서 만드는 재사용 가능한 UI 또는 게임 요소입니다. 마치 레시피처럼, 자주 쓰는 재료 조합을 하나의 요리로 만들어두는 것과 같습니다.
한 번 만들어두면 어디서든 쉽게 재사용할 수 있습니다.
다음 코드를 살펴봅시다.
// 커스텀 체력바 컴포넌트
class HealthBar extends PositionComponent {
final double maxHealth;
double currentHealth;
final Vector2 barSize;
late RectangleComponent background;
late RectangleComponent healthFill;
late TextComponent healthText;
HealthBar({
required this.maxHealth,
required this.barSize,
}) : currentHealth = maxHealth;
@override
Future<void> onLoad() async {
// 배경 (빨간색)
background = RectangleComponent(
size: barSize,
paint: Paint()..color = const Color(0xFFFF0000),
);
add(background);
// 체력 표시 (초록색)
healthFill = RectangleComponent(
size: barSize,
paint: Paint()..color = const Color(0xFF00FF00),
);
add(healthFill);
// 숫자 표시
healthText = TextComponent(
text: '${currentHealth.toInt()}/${maxHealth.toInt()}',
textRenderer: TextPaint(
style: const TextStyle(color: Colors.white, fontSize: 12),
),
);
add(healthText);
}
// 체력 업데이트 메서드
void updateHealth(double newHealth) {
currentHealth = newHealth.clamp(0, maxHealth);
final ratio = currentHealth / maxHealth;
healthFill.size.x = barSize.x * ratio;
healthText.text = '${currentHealth.toInt()}/${maxHealth.toInt()}';
}
}
// 사용 예시
class Enemy extends SpriteComponent {
late HealthBar healthBar;
@override
Future<void> onLoad() async {
healthBar = HealthBar(maxHealth: 100, barSize: Vector2(50, 8));
healthBar.position = Vector2(0, -20); // 적 위에 표시
add(healthBar);
}
void takeDamage(double damage) {
healthBar.updateHealth(healthBar.currentHealth - damage);
}
}
김개발 씨는 게임 개발 5주 차에 접어들었습니다. 게임이 제법 모양새를 갖춰가고 있었지만, 새로운 요구사항이 생겼습니다.
"모든 적과 보스에 체력바를 표시해주세요." 기획자의 요청이었습니다. 김개발 씨는 RectangleComponent로 빨간 배경을 만들고, 그 위에 초록색 체력 바를 겹치고, TextComponent로 숫자를 표시하는 코드를 작성했습니다.
그런데 문제가 있었습니다. 적이 10종류, 보스가 3종류인데, 13번이나 똑같은 코드를 복사해야 했습니다.
"이거 뭔가 비효율적인데..." 고민하던 중 박시니어 씨가 말했습니다. "커스텀 컴포넌트를 만들어보세요!" 그렇다면 커스텀 컴포넌트란 정확히 무엇일까요?
쉽게 비유하자면, 커스텀 컴포넌트는 마치 자주 먹는 요리의 레시피와 같습니다. 김치찌개를 만들 때마다 김치, 돼지고기, 두부, 양파를 준비하는 것처럼, 체력바를 만들 때마다 배경, 체력 표시, 텍스트를 조합하죠.
이걸 "HealthBar"라는 하나의 레시피로 만들어두면, 다음부터는 "HealthBar 하나 주세요"라고만 하면 됩니다. 커스텀 컴포넌트가 없던 시절에는 어땠을까요?
개발자들은 비슷한 UI를 만들 때마다 모든 하위 요소를 일일이 조합해야 했습니다. 체력바를 10개 만든다면, RectangleComponent 20개(배경 10개, 체력바 10개), TextComponent 10개를 각각 생성하고, 위치를 맞추고, 색상을 지정하는 코드를 10번 작성해야 했죠.
코드가 엄청나게 길어지고, 나중에 "체력바 디자인을 바꿔주세요"라는 요청이 오면? 10곳을 모두 찾아서 수정해야 했습니다.
바로 이런 문제를 해결하기 위해 커스텀 컴포넌트가 등장했습니다. 커스텀 컴포넌트를 사용하면 복잡한 UI를 하나로 캡슐화할 수 있습니다.
HealthBar 클래스 하나로 배경, 체력바, 텍스트를 모두 관리합니다. 또한 재사용성이 극대화됩니다.
Enemy, Boss, Player 어디서든 HealthBar 하나만 추가하면 끝입니다. 무엇보다 유지보수가 엄청나게 쉬워집니다.
디자인을 바꾸고 싶으면 HealthBar 클래스만 수정하면, 모든 체력바가 한 번에 바뀝니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 HealthBar 클래스는 PositionComponent를 상속받습니다. 위치를 가질 수 있고, 다른 컴포넌트를 자식으로 가질 수 있는 기본 컴포넌트죠.
생성자에서 maxHealth와 barSize를 받습니다. 이렇게 설정을 외부에서 받으면 유연하게 사용할 수 있습니다.
onLoad에서 세 개의 하위 컴포넌트를 생성합니다. background는 빨간색 배경, healthFill은 초록색 체력 표시, healthText는 숫자 표시입니다.
모두 add()로 자식에 추가됩니다. updateHealth 메서드에서는 체력 비율에 따라 체력바 너비를 조절합니다.
ratio가 0.5면 체력바가 절반만 표시됩니다. Enemy 클래스에서 사용할 때는 정말 간단합니다.
HealthBar를 하나 생성하고 add()만 하면 끝입니다. takeDamage에서 healthBar.updateHealth()를 호출하면 체력바가 자동으로 업데이트됩니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 모바일 RPG 게임을 만든다고 가정해봅시다.
게임에는 체력바, 마나바, 경험치바, 스킬 쿨다운 표시, 버프 아이콘 등 다양한 UI가 필요합니다. 이런 것들을 매번 조합해서 만들면 코드가 수천 줄이 됩니다.
하지만 각각을 커스텀 컴포넌트로 만들어두면, 재사용이 쉽고, 일관된 디자인을 유지할 수 있습니다. 실제로 상용 게임에서는 수십, 수백 개의 커스텀 컴포넌트를 만들어 라이브러리처럼 사용합니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 구체적인 컴포넌트를 만드는 것입니다.
"Boss1HealthBar", "Boss2HealthBar"처럼 특정 상황에만 쓰이는 컴포넌트를 만들면, 재사용성이 떨어집니다. 따라서 범용적으로 사용할 수 있도록 설정을 생성자로 받는 것이 좋습니다.
HealthBar(maxHealth: 1000, barSize: Vector2(100, 10))처럼요. 다시 김개발 씨의 이야기로 돌아가 봅시다.
커스텀 컴포넌트를 만든 김개발 씨는 감동했습니다. "와, 13곳에 복사-붙여넣기 하려던 걸 한 줄로 끝낼 수 있다니!" 커스텀 컴포넌트를 제대로 이해하면 코드 재사용성이 극대화되고, 유지보수가 쉬워집니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 자주 쓰는 UI나 게임 요소는 커스텀 컴포넌트로 만들어두세요
- 생성자로 설정을 받아 유연하게 사용할 수 있도록 하세요
- PositionComponent를 상속받으면 위치와 크기를 가질 수 있습니다
5. 컴포넌트 재사용 전략
김개발 씨의 게임이 점점 커지면서 문제가 생겼습니다. 비슷한 컴포넌트가 여러 개 생기기 시작했습니다.
FireBullet, IceBullet, PoisonBullet... 모두 거의 같은 코드인데 색깔과 데미지만 다릅니다.
"이걸 하나로 합칠 수는 없을까?" 고민하던 김개발 씨에게 박시니어 씨가 조언했습니다. "컴포넌트 재사용 전략을 배워보세요."
컴포넌트 재사용 전략은 비슷한 기능을 가진 컴포넌트들을 효율적으로 관리하는 방법입니다. 설정을 외부에서 주입하거나, 팩토리 패턴을 사용하거나, 컴포지션으로 조합하는 등의 기법이 있습니다.
이를 통해 코드 중복을 최소화하고 유지보수성을 높일 수 있습니다.
다음 코드를 살펴봅시다.
// 재사용 가능한 총알 컴포넌트 (설정 주입 방식)
class Bullet extends SpriteComponent with HasGameRef {
final double damage;
final double speed;
final Color effectColor;
final BulletType type;
Bullet({
required this.damage,
required this.speed,
required this.effectColor,
required this.type,
});
@override
Future<void> onLoad() async {
// 타입에 따라 다른 스프라이트 로드
sprite = await gameRef.loadSprite('bullets/${type.name}.png');
size = Vector2.all(16);
}
@override
void update(double dt) {
super.update(dt);
position.y -= speed * dt; // 위로 이동
}
}
enum BulletType { fire, ice, poison, normal }
// 팩토리 패턴으로 쉽게 생성
class BulletFactory {
static Bullet createFireBullet() {
return Bullet(
damage: 30,
speed: 200,
effectColor: Colors.red,
type: BulletType.fire,
);
}
static Bullet createIceBullet() {
return Bullet(
damage: 20,
speed: 150,
effectColor: Colors.blue,
type: BulletType.ice,
);
}
static Bullet createPoisonBullet() {
return Bullet(
damage: 15,
speed: 180,
effectColor: Colors.green,
type: BulletType.poison,
);
}
}
// 사용 예시
class Player extends SpriteComponent {
void shoot(BulletType bulletType) {
Bullet bullet;
switch (bulletType) {
case BulletType.fire:
bullet = BulletFactory.createFireBullet();
case BulletType.ice:
bullet = BulletFactory.createIceBullet();
case BulletType.poison:
bullet = BulletFactory.createPoisonBullet();
default:
bullet = Bullet(damage: 10, speed: 200, effectColor: Colors.white, type: BulletType.normal);
}
bullet.position = position.clone();
parent?.add(bullet);
}
}
김개발 씨는 게임 개발 2개월 차가 되었습니다. 게임에 다양한 총알 타입을 추가하고 있었는데, 문제가 생겼습니다.
FireBullet.dart, IceBullet.dart, PoisonBullet.dart... 파일이 계속 늘어나는데, 각 파일을 열어보면 90%가 똑같은 코드였습니다.
"이거 뭔가 잘못되고 있는 것 같은데..." 불안해하던 김개발 씨에게 박시니어 씨가 코드 리뷰를 해주며 말했습니다. "아, 이건 전형적인 코드 중복이에요.
컴포넌트 재사용 전략을 적용해야 합니다." 그렇다면 컴포넌트 재사용 전략이란 정확히 무엇일까요? 쉽게 비유하자면, 컴포넌트 재사용 전략은 마치 커피 자판기와 같습니다.
아메리카노, 카페라떼, 카푸치노를 만들기 위해 자판기 3대를 따로 만들지 않죠. 하나의 자판기에서 버튼에 따라 다른 음료를 만듭니다.
마찬가지로 하나의 Bullet 컴포넌트에서 설정에 따라 다른 종류의 총알을 만들 수 있습니다. 재사용 전략이 없던 시절에는 어땠을까요?
개발자들은 비슷한 기능을 가진 클래스를 수십 개 만들어야 했습니다. 총알 타입이 10개면 클래스 10개, 적 타입이 20개면 클래스 20개를 작성했죠.
더 큰 문제는 나중에 "모든 총알에 회전 효과를 추가해주세요"라는 요청이 오면, 10개 파일을 모두 열어서 수정해야 했습니다. 하나라도 빠뜨리면 버그가 되었습니다.
바로 이런 문제를 해결하기 위해 컴포넌트 재사용 전략이 등장했습니다. 재사용 전략을 사용하면 하나의 클래스로 여러 변종을 처리할 수 있습니다.
Bullet 클래스 하나로 불, 얼음, 독 총알을 모두 만들 수 있죠. 또한 새로운 타입 추가가 쉬워집니다.
번개 총알을 추가하고 싶으면? 새 클래스를 만들 필요 없이, BulletFactory에 createLightningBullet 메서드만 추가하면 됩니다.
무엇보다 유지보수가 엄청나게 쉬워집니다. 회전 효과를 추가하고 싶으면 Bullet 클래스만 수정하면 모든 타입에 적용됩니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 Bullet 클래스를 보면 생성자로 설정을 받고 있습니다.
damage, speed, effectColor, type을 외부에서 주입받죠. 이게 핵심입니다.
클래스 내부에 고정된 값이 없으니, 어떤 타입의 총알도 만들 수 있습니다. onLoad에서는 type에 따라 다른 스프라이트를 로드합니다.
fire면 fire.png, ice면 ice.png를 불러옵니다. BulletFactory를 보면 팩토리 패턴을 사용하고 있습니다.
각 메서드에서 Bullet을 생성하면서 적절한 값을 넘겨줍니다. createFireBullet은 데미지 30, 속도 200, 빨간색으로 설정하고, createIceBullet은 다른 값으로 설정합니다.
이렇게 하면 총알 생성 로직이 한 곳에 모입니다. Player 클래스의 shoot 메서드에서 사용할 때는, BulletFactory의 메서드를 호출하기만 하면 됩니다.
팩토리가 알아서 적절한 설정으로 총알을 만들어줍니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 RPG 게임에서 무기 시스템을 만든다고 가정해봅시다. 검, 활, 지팡이, 도끼 등 수십 종류의 무기가 있고, 각 무기마다 일반, 희귀, 전설 등급이 있습니다.
만약 각각을 클래스로 만들면 수백 개의 클래스가 필요합니다. 하지만 Weapon 클래스 하나에 설정을 주입하는 방식으로 만들면, WeaponFactory에서 모든 무기를 생성할 수 있습니다.
실제로 대형 게임에서는 이런 방식을 적극 활용합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 너무 많은 설정을 생성자로 받는 것입니다. Bullet(damage, speed, color, type, size, rotationSpeed, particleEffect, soundEffect, ...)처럼 10개 이상의 파라미터를 받으면, 사용하기가 오히려 복잡해집니다.
따라서 꼭 필요한 설정만 받고, 나머지는 기본값을 사용하거나, 설정 객체로 묶는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
재사용 전략을 적용한 김개발 씨는 놀라워했습니다. "10개 클래스가 1개로 줄어들다니!" 컴포넌트 재사용 전략을 제대로 이해하면 코드 중복을 제거하고, 확장성 높은 설계를 할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 비슷한 컴포넌트가 3개 이상 생기면 재사용 전략을 고려하세요
- 팩토리 패턴으로 생성 로직을 한 곳에 모으면 관리가 쉬워집니다
- 설정이 많아지면 Config 객체로 묶어서 전달하는 것도 좋은 방법입니다
6. 컴포넌트 트리 최적화
김개발 씨의 게임이 출시를 앞두고 있었습니다. 그런데 QA팀에서 심각한 보고가 들어왔습니다.
"적이 100마리만 넘어가면 게임이 버벅거려요!" 프레임이 60fps에서 30fps로 떨어지는 문제였습니다. "왜 이렇게 느린 거지?" 당황하던 김개발 씨에게 박시니어 씨가 말했습니다.
"컴포넌트 트리를 최적화해야 할 시간이 온 것 같네요."
컴포넌트 트리 최적화는 게임 성능을 개선하기 위해 컴포넌트 구조를 효율적으로 관리하는 기법입니다. 불필요한 업데이트 방지, 컴포넌트 풀링, 화면 밖 객체 비활성화 등의 방법으로 프레임 드롭을 막을 수 있습니다.
대규모 게임에서 필수적인 기술입니다.
다음 코드를 살펴봅시다.
// 최적화된 적 컴포넌트
class OptimizedEnemy extends SpriteComponent with HasGameRef {
static const double screenMargin = 100;
bool isActive = true;
@override
void update(double dt) {
// 화면 밖에 있으면 업데이트 스킵
if (!isVisibleOnScreen()) {
return;
}
super.update(dt);
// 실제 게임 로직...
}
// 화면에 보이는지 확인
bool isVisibleOnScreen() {
final camera = gameRef.camera;
final screenRect = camera.visibleWorldRect;
return screenRect.overlaps(toRect().inflate(screenMargin));
}
}
// 객체 풀링 시스템
class BulletPool {
final List<Bullet> _available = [];
final List<Bullet> _inUse = [];
final int maxSize;
BulletPool({this.maxSize = 100});
// 재사용 가능한 총알 가져오기
Bullet obtain() {
Bullet bullet;
if (_available.isNotEmpty) {
// 기존 객체 재사용
bullet = _available.removeLast();
} else {
// 새로 생성 (풀이 비었을 때만)
bullet = Bullet(damage: 10, speed: 200, effectColor: Colors.white, type: BulletType.normal);
}
_inUse.add(bullet);
return bullet;
}
// 사용 완료된 총알 반환
void release(Bullet bullet) {
if (_inUse.remove(bullet)) {
bullet.removeFromParent();
if (_available.length < maxSize) {
_available.add(bullet);
}
}
}
// 통계
int get activeCount => _inUse.length;
int get pooledCount => _available.length;
}
// 사용 예시
class MyGame extends FlameGame {
late BulletPool bulletPool;
@override
Future<void> onLoad() async {
bulletPool = BulletPool(maxSize: 200);
}
void shootBullet(Vector2 position) {
final bullet = bulletPool.obtain(); // 재사용
bullet.position = position.clone();
add(bullet);
}
void onBulletHit(Bullet bullet) {
bulletPool.release(bullet); // 반환
}
}
김개발 씨는 게임 개발 3개월 차, 드디어 출시를 앞두고 있었습니다. 모든 기능이 완성되었고, QA팀에서 마지막 테스트를 진행하고 있었습니다.
그런데 심각한 문제가 발견되었습니다. 적이 적을 때는 부드럽게 돌아가는데, 100마리가 넘어가면 화면이 버벅거리기 시작했습니다.
"왜 이렇게 느려지는 거지?" 프로파일러를 돌려본 김개발 씨는 충격을 받았습니다. 화면 밖에 있는 적들도 매 프레임마다 업데이트되고 있었고, 총알이 사라질 때마다 새로운 객체를 생성하고 있었습니다.
박시니어 씨가 코드를 보더니 말했습니다. "이제 컴포넌트 트리 최적화를 배울 때가 됐네요." 그렇다면 컴포넌트 트리 최적화란 정확히 무엇일까요?
쉽게 비유하자면, 컴포넌트 트리 최적화는 마치 회사의 효율적인 업무 분배와 같습니다. 모든 직원이 동시에 일하는 것이 아니라, 필요한 사람만 일하고, 쉬는 사람은 휴식을 취하게 합니다.
또한 같은 도구를 매번 새로 사는 것이 아니라, 사용한 도구를 깨끗이 씻어서 재사용하죠. 게임도 마찬가지입니다.
화면에 보이는 객체만 업데이트하고, 총알 같은 객체는 재사용합니다. 최적화가 없던 시절에는 어땠을까요?
개발자들은 모든 객체를 매 프레임마다 업데이트했습니다. 적 1000마리가 있으면, 화면에 10마리만 보여도 1000마리 전부를 계산했죠.
총알이 터질 때마다 새 객체를 만들고, 가비지 컬렉터가 메모리를 회수하느라 게임이 멈췄습니다. 초당 60번 객체를 생성하고 파괴하면, 1분에 3600개의 가비지가 발생했습니다.
성능이 나쁠 수밖에 없었습니다. 바로 이런 문제를 해결하기 위해 컴포넌트 트리 최적화가 등장했습니다.
최적화를 사용하면 화면에 보이는 객체만 업데이트할 수 있습니다. isVisibleOnScreen으로 화면 밖 적들은 업데이트를 스킵하죠.
또한 객체 풀링으로 메모리 할당을 줄일 수 있습니다. 총알 200개를 미리 만들어두고 재사용하면, 가비지가 거의 발생하지 않습니다.
무엇보다 프레임이 안정적으로 유지된다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 OptimizedEnemy의 update를 보면 isVisibleOnScreen 체크가 맨 앞에 있습니다. 화면에 안 보이면 바로 return으로 빠져나가서, 비싼 연산을 하지 않습니다.
isVisibleOnScreen 메서드에서는 카메라의 보이는 영역과 적의 위치를 비교합니다. screenMargin을 둔 이유는 화면 경계에서 갑자기 나타나는 것을 방지하기 위해서입니다.
BulletPool 클래스를 보면 객체 풀링 패턴을 구현하고 있습니다. _available은 재사용 가능한 총알 리스트이고, _inUse는 현재 사용 중인 총알 리스트입니다.
obtain에서 총알을 가져올 때, _available에 있으면 재사용하고, 없으면 새로 생성합니다. release에서 총알을 반환하면 _available에 다시 넣어서 재사용 대기 상태로 만듭니다.
MyGame에서 사용할 때는, shootBullet에서 **bulletPool.obtain()**으로 총알을 가져오고, 충돌하면 **bulletPool.release()**로 반환합니다. 이렇게 하면 총알 객체가 계속 재사용되어 메모리 할당이 최소화됩니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 대규모 전략 시뮬레이션 게임을 만든다고 가정해봅시다.
맵에 유닛 1000개, 건물 500개, 자원 300개가 있습니다. 모두 업데이트하면 초당 1800번의 연산이 발생합니다.
하지만 카메라 뷰 컬링으로 화면에 보이는 200개만 업데이트하면 연산이 90% 줄어듭니다. 또한 객체 풀링으로 총알, 파티클, 이펙트를 재사용하면 가비지가 거의 사라져서 프레임이 안정됩니다.
실제로 모든 상용 게임은 이런 최적화를 필수로 적용합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 너무 이른 최적화입니다. 게임 초반부터 모든 것을 풀링하고, 모든 곳에 뷰 컬링을 넣으면 코드가 복잡해집니다.
따라서 먼저 만들고, 느려지면 최적화하는 것이 좋습니다. 프로파일러로 병목을 찾아서 그 부분만 집중적으로 최적화하세요.
다시 김개발 씨의 이야기로 돌아가 봅시다. 최적화를 적용한 김개발 씨는 놀라워했습니다.
"30fps에서 60fps로 올랐어요!" 컴포넌트 트리 최적화를 제대로 이해하면 대규모 게임에서도 안정적인 성능을 유지할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 화면 밖 객체는 업데이트를 스킵해서 CPU 사용량을 줄이세요
- 자주 생성/파괴되는 객체는 풀링으로 메모리 할당을 최소화하세요
- 성능 문제가 실제로 발생했을 때 프로파일러로 병목을 찾아 최적화하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
타일맵 시스템 완벽 가이드
Flame 게임 엔진에서 타일맵을 활용하여 효율적으로 게임 월드를 구성하는 방법을 배웁니다. Tiled 에디터부터 충돌 처리, 동적 타일 변경까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.
게임 루프와 컴포넌트 완벽 가이드
Flame 게임 엔진의 핵심인 게임 루프와 컴포넌트 시스템을 이해하고, 실전에서 활용하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.
Flutter/Flame 커스텀 렌더링 파이프라인 완벽 가이드
게임 엔진의 핵심인 렌더링 파이프라인을 직접 커스터마이징하는 방법을 배웁니다. Canvas부터 셰이더, 배치 렌더링까지 실무에서 바로 쓸 수 있는 최적화 기법을 소개합니다.
Flame 소개 및 환경 설정 완벽 가이드
Flutter 기반의 2D 게임 엔진 Flame의 기초부터 환경 설정까지 차근차근 알아봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 중심으로 설명합니다.
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.