감싸는 것은 같고 무엇을 바꾸는지가 다르다
광고 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(); // 넘긴다
}
갈리는 것은 감싼 뒤에 인터페이스가 어떻게 되느냐입니다.
어댑터: 모양을 맞춘다
우리가 원하는 인터페이스를 먼저 정하고, 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 에 특수한 기능이 하나 있는데 우리 인터페이스에 자리가 없으면, 결국 그것만 뚫는 구멍을 만들게 됩니다. 그 구멍이 생기는 순간 경계를 그은 의미가 절반쯤 사라집니다.
정리
- 셋 다 원본을 들고 있다가 호출을 넘긴다. 코드 모양은 거의 같다.
- 갈리는 것은 감싼 뒤의 인터페이스다. 바꾸느냐, 줄이느냐, 유지하느냐.
- 어댑터는 모양을 맞춘다. 진짜 목적은 편의가 아니라 남의 코드와 경계를 긋는 것이다.
- 퍼사드는 부를 것을 줄인다. 목적 하나짜리 창구로 두지 않으면 매니저로 부푼다.
- 데코레이터는 같은 인터페이스를 유지해서 몇 겹이든 쌓을 수 있게 한다.
- 전략은 하나를 고르고 데코레이터는 여럿을 겹친다. 그래서 조건이 더 까다롭다.
- 층이 깊어지면 호출 스택도 깊어진다. 데코레이터는 순서가 결과를 바꾼다.
- 감싼 인터페이스가 원본을 다 못 덮으면 구멍이 생기고, 그러면 경계가 무너진다.
감싸는 것으로 바깥의 것을 다뤘습니다. 못 고치는 코드를 그대로 두고 내 쪽 모양만 바꾸는 방법입니다.