이번 게시글에서는 구조 패턴 중 하나인 브리지 패턴에 대해서 이야기해보려고 합니다.
브리지 패턴이라고 하면, 이름만 보았을 때 A와 B가 있다고 할 때 두 개사이를 이어줄 것처럼 보입니다.
관련 문제 상황을 예시로 보고 하나씩 알아가 보겠습니다.
상황 가정
RPG 게임을 만든다고 가정해 봅시다. 직업(Character)과 무기(Weapon)가 있습니다.
- 직업: 전사(Warrior), 마법사(Mage)
- 무기: 검(Sword), 활(Bow)
상속만으로 이 모든 경우의 수를 처리하려고 하면 아래와 같은 결합형태의 클래스가 계속 생기게 됩니다.
- WarriorWithSword (전사 + 검)
- WarriorWithBow (전사 + 활)
- MageWithSword (마법사 + 검)
- MageWithBow (마법사 + 활)
문제점
만약 여기서 새로운 직업 '도적(Thief)' 하나를 추가하면 어떻게 될까요?
ThiefWithSword, ThiefWithBow... 클래스를 계속 늘려야 합니다.
무기까지 하나 더 추가되면? M x N 개의 클래스를 모두 만들어야 하는 최악의 유지보수 상황이 옵니다.
해결 방법 (The Solution): 브리지 패턴 적용
직업과 무기를 상속으로 섞지 말고, 따로 떼어내서 연결(브리지)합니다.
- Abstraction (추상화 계층): Character (누가 사용하는가?)
- Implementor (구현 계층): IWeapon (무엇을 사용하여 행동하는가?)
- Bridge: Character가 IWeapon을 멤버 변수로 가지고 있음.
이렇게 하면 M x N 개의 클래스에서 M + N개의 클래스로 줄어들게 됩니다. 또한, 새로운 추상화 계층이나, 구현 계층이 늘어나게 돼도 유지보수 측면에서 어렵지 않게 추가할 수 있습니다.
클래스 다이어그램
상세 클래스 정의
NameSpace Implementor
using UnityEngine;
// [Implementor] 무기 인터페이스
public interface IWeapon
{
void Attack(string targetName);
string GetWeaponName();
}
// [Concrete Implementor B] 활
using UnityEngine;
public class Bow : IWeapon
{
public void Attack(string targetName)
{
Debug.Log($"[Bow] 핑-! {targetName}에게 화살을 쐈습니다. (원거리 타격 효과)");
}
public string GetWeaponName() => "엘프의 활";
}
using UnityEngine;
public class Sword : IWeapon
{
public void Attack(string targetName)
{
Debug.Log($"[Sword] 슉슉! {targetName}을(를) 베었습니다. (근거리 타격 효과)");
}
public string GetWeaponName() => "전설의 검";
}
IWeapon 인터페이스에 미리 무기에 대한 기능을 명시하고, Sword, Bow 상속받은 클래스에서 재정의하여, 추상화를 진행합니다. 이때 행위자 Character는 현재 무기가 어떤 무기인지(Bow or Sword)인지 궁금하지 않고, Attack만 수행하기 때문에, 무기에 대한 책임을 모두 위임했다고 생각할 수 있습니다.
Namespace Abstraction
// [Abstraction] 캐릭터 기본 클래스
using UnityEngine;
public abstract class Character
{
// ★ 이것이 Bridge! 구현부(Weapon)를 참조로 가짐
protected IWeapon weapon;
// 생성자를 통해 무기를 주입받거나 변경 가능
public Character(IWeapon weapon)
{
this.weapon = weapon;
}
public void ChangeWeapon(IWeapon newWeapon)
{
this.weapon = newWeapon;
Debug.Log($"-> 무기를 {newWeapon.GetWeaponName()}(으)로 교체했습니다.");
}
// 기능의 위임 (Delegation)
public abstract void Fight();
}
// [Refined Abstraction A] 전사
using UnityEngine;
public class Warrior : Character
{
public Warrior(IWeapon weapon) : base(weapon) { }
public override void Fight()
{
Debug.Log("전사가 포효하며 공격합니다!");
// 구체적인 타격은 무기에게 위임
weapon.Attack("오크");
}
}
// [Refined Abstraction B] 마법사 (물리 공격을 할 때)
public class Mage : Character
{
public Mage(IWeapon weapon) : base(weapon) { }
public override void Fight()
{
Debug.Log("마법사가 마법 대신 무기를 휘둡니다 (어설픔)");
weapon.Attack("슬라임");
}
}
캐릭터, 기능의 최상위에서 IWeapon 인터페이스만을 보고, Attack을 진행하게 될 때 캐릭터가 무기를 휘두른다는 행위만 수행하고, 내부적으로 처리되는 로직은 IWeapon을 상속받은 클래스에서 책임을 진다고 생각하면 됩니다. 그러면 현재 캐릭터가 가지고 있는 Weapon에 따라, 무기의 공격이 달라지게 됩니다.
마치며
이번에 브리지 패턴(Bridge Pattern)을 깊이 있게 학습하면서, 과거 프로젝트에서 콘텐츠(이미지, PDF, FBX) 생성 및 공유 기능을 구현했던 경험이 떠올랐습니다.
당시에는 어떤 소스 구조를 가져갈지 치열하게 고민한 끝에, 콘텐츠 공유를 담당하는 최상위 클래스와 각 콘텐츠별(Image, PDF, FBX) 실질적인 기능을 처리하는 구현부를 책임별로 분리하여 설계를 진행했었습니다. 그때는 이것이 '브리지 패턴'이라는 것을 인지하지 못하고 진행했었지만, 이번 학습을 통해 제 코드를 다시 분석해 본 결과 정확히 브리지 패턴의 구조를 따르고 있었다는 사실을 발견하게 되었습니다.
돌이켜보면, 새로운 콘텐츠 타입이 추가될 때마다 수정 사항이 이곳저곳 산발적으로 발생하지 않고, 새로운 구현 클래스에만 집중될 수 있었던 덕분에 유지보수와 확장이 매우 수월했었습니다. 결국 좋은 설계란, 패턴의 이름을 모르더라도 책임의 분리라는 본질을 좇을 때 자연스럽게 도출된다는 것을 다시금 깨닫게 된 소중한 계기였습니다.