감싸는 것은 같고 무엇을 바꾸는지가 다르다
Esc

    감싸는 것은 같고 무엇을 바꾸는지가 다르다

    –Unity

    광고 SDK 를 붙였습니다. 문서를 보니 이렇게 씁니다.

    AdMobSdk.Initialize(appId, testMode: false);
    var ad = AdMobSdk.CreateRewardedAd(unitId);
    ad.OnUserEarnedReward += (s, e) => { ... };
    ad.Show();

    우리 게임에는 이미 이렇게 부르고 있는 코드가 스무 군데 있습니다.

    adService.ShowRewarded(onReward: GiveCoins);

    모양이 안 맞습니다. 그리고 SDK 는 남의 코드라 고칠 수 없습니다.

    이런 상황이 세 가지 있습니다. 모양이 안 맞을 때, 부를 것이 너무 많을 때, 그리고 원래 하던 일에 뭔가를 더 얹고 싶을 때. 답은 전부 감싸는 것입니다.

    셋 다 감쌉니다

    세 패턴의 코드는 놀랄 만큼 비슷합니다. 원본을 필드로 들고 있다가 호출을 넘깁니다.

    class Wrapper
    {
        readonly Original inner;      // 감싼 것을 들고 있고
        public void Do() => inner.Do();   // 넘긴다
    }

    갈리는 것은 감싼 뒤에 인터페이스가 어떻게 되느냐입니다.

    어댑터: 모양을 바꾼다 A 모양 어댑터 B 모양 들어간 모양과 나온 모양이 다르다 퍼사드: 개수를 줄인다 퍼사드 여럿을 하나의 창구로 데코레이터: 겹쳐 쌓는다 원본 모양이 그대로라 몇 겹이든 쌓을 수 있다
    감싸는 방식이 아니라 감싼 뒤의 모양이 셋을 가릅니다.

    어댑터: 모양을 맞춘다

    우리가 원하는 인터페이스를 먼저 정하고, SDK 를 거기에 맞춰 넣습니다.

    public interface IAdService
    {
        void ShowRewarded(Action onReward);
    }
    
    public class AdMobAdapter : IAdService
    {
        public AdMobAdapter(string appId, string unitId)
        {
            AdMobSdk.Initialize(appId, testMode: false);
            this.unitId = unitId;
        }
    
        public void ShowRewarded(Action onReward)
        {
            var ad = AdMobSdk.CreateRewardedAd(unitId);
            ad.OnUserEarnedReward += (s, e) => onReward();   // 모양을 바꾼다
            ad.Show();
        }
    
        readonly string unitId;
    }

    우리 코드는 이제 IAdService 만 압니다. SDK 이름이 코드에서 사라졌습니다.

    그리고 앞에서 본 것들이 여기에 붙습니다. 서비스 로케이터에 등록하면 개발 중에는 가짜 광고를, 빌드에서는 진짜를 꽂을 수 있습니다. 광고사를 갈아타야 할 때 고칠 파일은 어댑터 하나입니다.

    어댑터를 만드는 진짜 이유는 편의가 아니라 경계를 긋는 것입니다. 남의 코드가 우리 코드 안으로 번지지 않게 막는 벽입니다.

    유니티에서 자주 쓰는 자리가 하나 더 있습니다. 엔진 API 를 감싸는 것입니다. Input.GetKeyDown 을 코드 곳곳에서 부르면 새 입력 시스템으로 옮길 때 전부 고쳐야 합니다.

    퍼사드: 부를 것을 줄인다

    세이브 한 번에 이만큼이 필요하다고 해 봅시다.

    var json = JsonUtility.ToJson(data);
    var bytes = Encoding.UTF8.GetBytes(json);
    var encrypted = Crypto.Encrypt(bytes, key);
    File.WriteAllBytes(path, encrypted);
    CloudSync.Upload(path);
    PlayerPrefs.SetString("lastSave", DateTime.UtcNow.ToString("o"));

    이 여섯 줄이 게임 안 네 군데에 복사돼 있으면, 암호화 방식을 바꿀 때 네 군데를 고칩니다. 그리고 한 곳을 빠뜨립니다.

    public class SaveService
    {
        public void Save(GameData data) { /* 위 여섯 줄 */ }
        public GameData Load() { ... }
    }

    부르는 쪽은 saveService.Save(data) 한 줄입니다. 인터페이스를 바꾼 게 아니라 개수를 줄인 것입니다.

    주의할 것 하나. 퍼사드는 매니저 클래스로 부풀기 쉽습니다. “관련된 것 같으니 여기 넣자” 가 반복되면 천 줄짜리 신이 됩니다. 퍼사드는 하나의 목적을 위한 창구입니다. 세이브면 세이브만 합니다.

    데코레이터: 겹쳐 쌓는다

    소리를 재생하는데 로그도 남기고, 설정 볼륨도 적용하고, 음소거 중이면 건너뛰고 싶습니다.

    public void PlaySfx(AudioClip clip)
    {
        if (logging) Debug.Log($"play {clip.name}");
        if (muted) return;
        source.PlayOneShot(clip, settings.Volume);
    }

    기능이 늘 때마다 if 가 하나씩 늘고, 어떤 조합을 쓸지는 플래그로 정하게 됩니다.

    데코레이터는 각각을 같은 인터페이스를 가진 껍데기 하나씩으로 만듭니다.

    public class LoggingAudio : IAudioService
    {
        readonly IAudioService inner;
        public LoggingAudio(IAudioService inner) => this.inner = inner;
    
        public void PlaySfx(AudioClip clip)
        {
            Debug.Log($"play {clip.name}");
            inner.PlaySfx(clip);          // 하던 일은 안쪽에 그대로 넘긴다
        }
    }
    
    public class VolumeAudio : IAudioService
    {
        readonly IAudioService inner;
        readonly Settings settings;
    
        public void PlaySfx(AudioClip clip)
        {
            if (settings.Muted) return;   // 넘기지 않는 것도 선택이다
            inner.PlaySfx(clip);
        }
    }

    감싼 것도 IAudioService 라서 또 감쌀 수 있습니다.

    IAudioService audio = new LoggingAudio(new VolumeAudio(new UnityAudio()));

    전략과 헷갈리기 쉬운데 다릅니다. 전략은 여럿 중 하나를 고르고, 데코레이터는 여럿을 겹칩니다. 그래서 전략은 인터페이스를 구현하기만 하면 되고, 데코레이터는 같은 인터페이스를 받아서 같은 인터페이스로 내놓아야 합니다. 그 조건이 있어야 쌓입니다.

    구분하는 질문 셋

    물어볼 것 그렇다면
    모양이 안 맞아서 맞추려는 것인가 어댑터
    부를 것이 많아서 줄이려는 것인가 퍼사드
    하던 일에 뭔가를 더 얹으려는 것인가 데코레이터

    한 문장으로는 이렇습니다. 어댑터는 모양을 바꾸고, 퍼사드는 개수를 줄이고, 데코레이터는 동작을 더합니다.

    대가

    호출이 깊어집니다. 세 겹으로 감싸면 소리 한 번 내는 데 네 번의 호출을 거칩니다. 디버거로 따라가려면 그만큼 들어가야 하고, 예외 스택도 길어집니다.

    데코레이터는 순서가 결과를 바꿉니다. 음소거를 로그보다 바깥에 두면 음소거 상태에서는 로그가 안 남습니다. 안쪽에 두면 남습니다. 어느 쪽이 맞는지는 그때그때 다른데, 조립하는 줄만 봐서는 그 차이가 잘 안 보입니다.

    감싼 것이 원본의 전부를 못 덮습니다. SDK 에 특수한 기능이 하나 있는데 우리 인터페이스에 자리가 없으면, 결국 그것만 뚫는 구멍을 만들게 됩니다. 그 구멍이 생기는 순간 경계를 그은 의미가 절반쯤 사라집니다.

    정리

    • 셋 다 원본을 들고 있다가 호출을 넘긴다. 코드 모양은 거의 같다.
    • 갈리는 것은 감싼 뒤의 인터페이스다. 바꾸느냐, 줄이느냐, 유지하느냐.
    • 어댑터는 모양을 맞춘다. 진짜 목적은 편의가 아니라 남의 코드와 경계를 긋는 것이다.
    • 퍼사드는 부를 것을 줄인다. 목적 하나짜리 창구로 두지 않으면 매니저로 부푼다.
    • 데코레이터는 같은 인터페이스를 유지해서 몇 겹이든 쌓을 수 있게 한다.
    • 전략은 하나를 고르고 데코레이터는 여럿을 겹친다. 그래서 조건이 더 까다롭다.
    • 층이 깊어지면 호출 스택도 깊어진다. 데코레이터는 순서가 결과를 바꾼다.
    • 감싼 인터페이스가 원본을 다 못 덮으면 구멍이 생기고, 그러면 경계가 무너진다.

    감싸는 것으로 바깥의 것을 다뤘습니다. 못 고치는 코드를 그대로 두고 내 쪽 모양만 바꾸는 방법입니다.