[2025_12_24] 컴포지션 패턴

2025. 12. 24. 20:45TIL

문제 상황

버프와 디버프 스킬을 각각 구현하다 보니 심각한 코드 중복 발생:

 
 
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줄로 다이어트했다. 상속보다 조합이 이번 기능에선 유효했다!"