new 를 부르는 자리를 한 곳으로 모은다
Esc

    new 를 부르는 자리를 한 곳으로 모은다

    –Unity

    앞의 세 글에서 행동을 인터페이스 뒤로 숨겼습니다. AbilityRunner 는 Ability 만 알고 AirStrike 를 모릅니다.

    그런데 이런 코드가 남아 있다면 어떨까요.

    void OnItemPickup(string itemId)
    {
        if (itemId == "airstrike") runner.Equip(new AirStrike());
        else if (itemId == "radar") runner.Equip(new RadarPulse());
        else if (itemId == "firstaid") runner.Equip(new FirstAid());
    }

    switch 를 없애려고 클래스를 나눴는데 switch 가 여기로 옮겨 왔을 뿐입니다. 그리고 이 코드는 구체 타입 이름을 셋 다 알고 있습니다. 아이템을 하나 추가하면 여기를 또 고쳐야 합니다.

    new 는 생각보다 무거운 한 줄입니다. 그 줄을 쓰는 순간 그 클래스와 묶입니다.

    만드는 일을 따로 떼어 놓는다

    가장 단순한 형태는 정적 메서드 하나입니다.

    public static class AbilityFactory
    {
        public static Ability Create(string id) => id switch
        {
            "airstrike" => new AirStrike(),
            "radar"     => new RadarPulse(),
            "firstaid"  => new FirstAid(),
            _           => new NoAbility(),
        };
    }

    switch 가 사라진 것은 아닙니다. 한 곳으로 모였을 뿐입니다. 그게 이 패턴의 전부이고, 그것만으로도 값을 합니다.

    • 아이템을 추가할 때 고칠 파일이 하나로 정해집니다. 찾아 헤맬 일이 없습니다
    • 다른 코드는 구체 타입 이름을 몰라도 됩니다
    • 만드는 절차가 여러 줄이어도 부르는 쪽은 한 줄입니다

    마지막 항목이 실제로는 가장 큽니다. 만드는 일이 new 한 줄로 끝나는 경우는 드뭅니다.

    public static Enemy CreateEnemy(EnemyData data, Vector3 pos)
    {
        var go = Object.Instantiate(data.prefab, pos, Quaternion.identity);
        var enemy = go.GetComponent<Enemy>();
        enemy.Init(data.hp, data.speed);
        enemy.Machine.Change(enemy.IdleState);
        EventBus.Publish(new EnemySpawned(enemy));
        return enemy;
    }

    이 다섯 줄이 코드 네 군데에 복사돼 있으면, 한 줄을 추가할 때 네 군데를 고쳐야 합니다. 그리고 한 곳을 빠뜨립니다.

    유니티에서는 이미 쓰고 있습니다

    Instantiate 가 팩토리입니다. 프리팹이 “무엇을 만들지” 를 담은 데이터이고, Instantiate 는 그걸 받아서 만들어 주는 메서드입니다. 클래스 이름을 코드에 적지 않아도 되는 이유가 이것입니다.

    그럼 그 위에 팩토리를 또 만드는 이유는 뭘까요. 둘입니다.

    초기화 절차를 한 곳에 두기 위해서. 위의 다섯 줄 이야기입니다.

    나중에 만드는 방법을 바꾸기 위해서. 이쪽이 더 중요합니다.

    나중에 바꿀 자리를 남겨 두는 것

    총알을 만드는 코드가 이렇게 흩어져 있다고 해 봅시다.

    팩토리를 두는 진짜 이유는 지금 편해지려는 게 아니라 나중에 바꿀 자리를 하나로 만들어 두는 것입니다. 총알을 오브젝트 풀에서 꺼내 쓰도록 바꾸는 작업이 뒤에 나오는데, 그때 고칠 곳이 열두 군데인지 한 군데인지가 여기서 정해집니다.

    인터페이스로 두면 통째로 갈아 끼울 수도 있습니다.

    public interface IBulletFactory
    {
        Bullet Create(Vector3 pos, Quaternion rot, int damage);
    }
    
    public class InstantiateBulletFactory : IBulletFactory { ... }
    public class PooledBulletFactory : IBulletFactory { ... }

    앞에서 본 서비스 로케이터나 의존성 주입이 여기서 쓰입니다. 등록만 바꾸면 프로젝트 전체의 총알 생성 방식이 바뀝니다.

    팩토리가 다시 지옥이 될 때

    switch 를 한 곳에 모았다고 했는데, 그 한 곳이 백 줄이 되면 결국 같은 문제입니다. 아이템 종류가 계속 늘어난다면 분기 대신 등록으로 갑니다.

    public class AbilityFactory
    {
        readonly Dictionary<string, Func<Ability>> makers = new();
    
        public void Register(string id, Func<Ability> maker) => makers[id] = maker;
    
        public Ability Create(string id)
            => makers.TryGetValue(id, out var m) ? m() : new NoAbility();
    }

    이제 새 능력을 추가할 때 팩토리도 안 고칩니다. 자기를 등록하는 한 줄만 어딘가에 있으면 됩니다.

    유니티에서는 더 간단한 답이 있습니다. ScriptableObject 목록을 인스펙터에 두는 것입니다.

    [SerializeField] List<Ability> catalog;   // 에셋을 끌어다 놓는다
    
    public Ability Find(string id) => catalog.FirstOrDefault(a => a.Id == id);

    코드를 안 고치고 에셋만 추가하면 됩니다. 프로젝트가 커지면 어드레서블로 필요할 때만 불러오는 방식으로 넘어갑니다.

    팩토리를 만들면 switch 가 사라진다고 오해하기 쉬운데, 분기는 어딘가에 반드시 남습니다. 무엇을 만들지는 누군가 정해야 하니까요. 패턴이 하는 일은 그 분기를 한 곳에 모으고, 데이터로 옮길 수 있게 하는 것입니다.

    언제 쓰지 않는가

    new 한 줄로 끝나고, 부르는 곳이 한 군데고, 앞으로 바뀔 일이 없다면 그냥 new 하면 됩니다.

    팩토리는 간접 층을 하나 더 놓는 일입니다. 코드를 읽는 사람이 “이게 실제로 뭘 만들지” 를 알려면 한 번 더 따라가야 합니다. 그 비용을 치를 이유가 있을 때만 놓습니다.

    만드는 절차가 여러 줄이거나, 부르는 곳이 여러 군데이거나, 만드는 방법이 바뀔 것 같으면 팩토리를 놓는다.

    정리

    • new 를 쓰는 순간 그 클래스와 묶인다. 인터페이스로 나눠 놔도 new 가 흩어지면 소용이 적다.
    • 팩토리는 분기를 없애지 않는다. 한 곳으로 모을 뿐이다. 그것만으로 고칠 자리가 정해진다.
    • 만드는 절차가 여러 줄일 때 이득이 가장 크다. 복사된 다섯 줄 중 하나는 반드시 빠진다.
    • 유니티의 Instantiate 와 프리팹이 이미 팩토리다. 그 위에 또 두는 이유는 초기화와 교체다.
    • 진짜 이유는 나중에 바꿀 자리를 하나로 만들어 두는 것이다. 풀로 바꿀 때 드러난다.
    • 팩토리 안이 다시 길어지면 분기 대신 등록으로 간다. 딕셔너리, 에셋 목록, 어드레서블.
    • 부르는 곳이 하나고 new 한 줄이면 그냥 new 한다. 간접 층도 비용이다.