[2025_12_24] 컴포지션 패턴
2025. 12. 24. 20:45ㆍTIL
문제 상황
버프와 디버프 스킬을 각각 구현하다 보니 심각한 코드 중복 발생:
csharp
// BuffSkill.cs (400줄)
- UpdatePassiveBuff() - 80줄
- ApplyBuffToUnit() - 20줄
- CreateBuffEffect() - 30줄
- IsAlreadyBuffed() - 10줄
// ... 나머지
// DebuffSkill.cs (400줄)
- UpdatePassiveDebuff() - 80줄 ← 거의 똑같음!
- ApplyDebuffToUnit() - 20줄 ← 거의 똑같음!
- CreateDebuffEffect() - 30줄 ← 거의 똑같음!
// ... 나머지
문제점:
- 총 800줄 중 대부분 이 중복
- 버그 수정 시 2곳 모두 수정 필요
- 새 스킬 추가시 매우 많은 공수 필요
해결 방법: 컴포지션 패턴
컴포지션 패턴이란?
"상속(IS-A) 대신 조합(HAS-A)을 사용하자"
csharp
// 상속 방식
BuffSkill IS-A SkillBase // 부모의 모든 것을 물려받음
// 컴포지션 방식
BuffSkill HAS-A StatusEffectManager // 필요한 기능을 가지고 있음
핵심 아이디어:
- 공통 로직을 별도 클래스로 분리
- 스킬은 그 클래스를 "가지고" 사용
- 상속의 경직성 없이 유연하게 조합
구현 과정
1단계: 공통 로직 추출 (StatusEffectManager)
csharp
public class StatusEffectManager
{
private SkillBase ownerSkill;
private bool isTargetingAllies; // true: 버프, false: 디버프
public StatusEffectManager(SkillBase skill, bool targetAllies, ...)
{
this.isTargetingAllies = targetAllies;
}
public void UpdatePassiveEffect(...)
{
// 버프든 디버프든 동일한 로직!
// 타겟 검증 → 무효 제거 → 새 타겟 선택
}
public void ApplyActiveEffect(...)
{
if (isTargetingAllies)
target.ApplyBuff(...); // 아군에게 버프
else
target.ApplyDebuff(...); // 적에게 디버프
}
}
핵심:
- 버프/디버프 로직이 95% 동일하다는 걸 발견
- isTargetingAllies 플래그로 구분
- 300줄짜리 공통 모듈 완성
2단계: 얇은 래퍼로 구현 (BuffSkill, DebuffSkill)
csharp
public class BuffSkill : SkillBase
{
private StatusEffectManager manager; // ← HAS-A!
public BuffSkill(UnitSkillData skillData) : base(skillData)
{
Color color = GetBuffColor();
// Manager 생성 (아군 타겟팅)
manager = new StatusEffectManager(this, true, "Effects/Buff", color);
}
private void UpdatePassive()
{
alliesList.Clear();
FindAlliesInRadius(range);
// Manager에게 위임!
manager.UpdatePassiveEffect(alliesList, maxTargets, IsAlreadyBuffed);
}
}
public class DebuffSkill : SkillBase
{
private StatusEffectManager manager; // ← HAS-A!
public DebuffSkill(UnitSkillData skillData) : base(skillData)
{
Color color = GetDebuffColor();
// Manager 생성 (적 타겟팅)
manager = new StatusEffectManager(this, false, "Effects/Debuff", color);
}
private void UpdatePassive()
{
enemiesList.Clear();
FindEnemiesInRadius(range);
// Manager에게 위임!
manager.UpdatePassiveEffect(enemiesList, maxTargets, IsAlreadyDebuffed);
}
}
차이점:
- BuffSkill: targetAllies = true, 아군 리스트 사용
- DebuffSkill: targetAllies = false, 적 리스트 사용
- 나머지는 완전히 동일!
3단계: UnitBase도 분리 (StatusEffectController)
UnitBase가 1100줄이라 상태 효과 시스템도 분리:
csharp
public class StatusEffectController
{
private UnitBase owner;
public void ApplyBuff(UnitSkillData skillData, SkillBase source)
{
// 버프 적용 로직 (300줄)
}
public void ApplyDebuff(UnitSkillData skillData, SkillBase source)
{
// 디버프 적용 로직 (300줄)
}
}
// UnitBase.cs
public class UnitBase : MonoBehaviour
{
private StatusEffectController statusEffectController; // ← HAS-A!
public void ApplyBuff(UnitSkillData skillData, SkillBase source)
{
statusEffectController?.ApplyBuff(skillData, source); // ← 위임!
}
}
```
**결과:**
- UnitBase: 1100줄 → 500줄 (55% 감소)
- 책임 분리: 유닛은 유닛 로직, 상태 효과는 Controller가 담당
---
## 성과
### 정량적 개선
| 지표 | Before | After | 개선율 |
|------|--------|-------|--------|
| **스킬 시스템 코드량** | 800줄 | 460줄 | **-42%** |
| **중복 코드** | 700줄 | 0줄 | **-100%** |
| **UnitBase 코드량** | 1100줄 | 500줄 | **-55%** |
| **새 스킬 추가** | 400줄 | 80줄 | **-80%** |
| **버그 수정 범위** | 2~3파일 | 1파일 | **-66%** |
### 구조 비교
**Before:**
```
BuffSkill (400줄)
├─ UpdatePassiveBuff()
├─ ApplyEffect()
└─ CreateEffect()
DebuffSkill (400줄)
├─ UpdatePassiveDebuff() ← 중복!
├─ ApplyEffect() ← 중복!
└─ CreateEffect() ← 중복!
```
**After:**
```
StatusEffectManager (300줄)
├─ UpdatePassiveEffect()
├─ ApplyEffect()
└─ CreateEffect()
BuffSkill (80줄)
└─ manager.UpdatePassiveEffect() ← 위임
DebuffSkill (80줄)
└─ manager.UpdatePassiveEffect() ← 위임
```
---
중요: 한 번에 하지 말고 점진적으로!
배운 점
컴포지션 vs 상속 판단 기준
상속이 적합한 경우:
- "A는 B이다" 관계가 명확
- 부모의 모든 기능이 필요
- 계층이 단순
컴포지션이 적합한 경우:
- "A는 B를 사용한다" 관계
- 기능을 선택적으로 조합
- 같은 로직을 여러 곳에서 재사용
- **중복 코드가 많을 때** ← 이번 경우!
2. 리팩토링 과정
1. 중복 코드 발견 (800줄 중 700줄)
↓
2. 공통 로직 추출 (StatusEffectManager)
↓
3. 래퍼로 재구현 (BuffSkill, DebuffSkill)
↓
4. 추가 분리 (StatusEffectController)
↓
5. 테스트 및 검증
3. 실무에서의 적용
언제 사용할까?
- AI 시스템 (여러 AI가 공통 로직 사용)
- UI 시스템 (비슷한 UI 컴포넌트들)
- 이펙트 시스템 (파티클, 사운드 등)
- 코드 리뷰에서 "중복"이라는 말이 나올 때!
핵심 코드
StatusEffectManager (핵심 부분)
csharp
public class StatusEffectManager
{
private bool isTargetingAllies; // 버프/디버프 구분
public void UpdatePassiveEffect(List<UnitBase> targets, ...)
{
// 1. 유효/무효 분리
foreach (var unit in currentAffected)
{
if (unit == null || unit.IsDead || !targets.Contains(unit))
invalidUnits.Add(unit);
}
// 2. 무효 제거
foreach (var unit in invalidUnits)
RemoveEffect(unit);
// 3. 새 타겟 선택
var candidates = targets
.Where(u => !currentAffected.Contains(u))
.ToList();
foreach (var newTarget in SelectTargets(candidates))
{
ApplyEffect(newTarget);
currentAffected.Add(newTarget);
}
}
private void ApplyEffect(UnitBase target)
{
if (isTargetingAllies)
target.ApplyBuff(skillData, ownerSkill);
else
target.ApplyDebuff(skillData, ownerSkill);
}
}
고민했던 점
Q1. 왜 상속이 아닌 컴포지션?
A: C#은 단일 상속만 지원하고, 이미 SkillBase를 상속 중. 또한 "BuffSkill은 StatusEffectSkill이다"보다 "BuffSkill은 StatusEffect 기능을 사용한다"가 더 자연스러웠음.
Q2. 성능 차이는?
A: 컴포지션이 한 단계 더 거치지만 무시할 수준. 오히려 1초에 1번만 호출되는 Passive 특성상 체감 불가. 코드 품질이 훨씬 중요.
Q3. 너무 많은 클래스?
A:
- Before: 2개 클래스 (각 400줄)
- After: 3개 클래스 (300 + 80 + 80줄)
파일은 늘었지만 각각의 책임이 명확해서 오히려 유지보수 쉬움.
💬 한줄 정리
"중복 코드 700줄을 컴포지션 패턴으로 해결하고, UnitBase를 500줄로 다이어트했다. 상속보다 조합이 이번 기능에선 유효했다!"
'TIL' 카테고리의 다른 글
| [2025_12-29] 유닛 떨림 현상 해결 - 유닛 회피 시스템 (0) | 2025.12.29 |
|---|---|
| [2025_12_26] 공격 범위 계산 버그 - 좌표 기준점 설정의 중요점 (0) | 2025.12.26 |
| [2025_12_23] 에셋 정하기 (0) | 2025.12.23 |
| [2025_12_22] 모의면접 피드백 (1) | 2025.12.22 |
| [2025_12_18] Factory패턴의 실적용 여부 (0) | 2025.12.18 |