운좋게 2023년 송도에서 열리는 마지막 devfest 에 참여하였다.
나는 여러가지 세션을 들었는데 그 중 하나의 세션이 정말 감명 깊었다.
그 세션은 바로 '플러터 모듈화 - git submodule 를 이용한 프로젝트 코드 재사용 전략' 이었는데
아마도 내가 그때 당시 '중복 코드를 어떻게 패키지 코드처럼 나눌 수 있을까?' 에 대한 고민이 많아서 감명 깊지 않았을까 생각한다.
또한 이것이 모듈화라고 불린다라는 사실을 몰랐어서 듣고 나서 더 충격적으로 다가옴(?)도 있었던 것 같다.
모듈화를 적용해 볼 기회가 오다
개념을 알고 나서 그런가 모듈화를 적용해 볼 기회가 빨리 왔다.
요즘 회사에서 한창 겪고 있는 이슈가 광고 관련인데 그 이유는 애드몹의 엄격한 제한 사항이 원인이었다.
그래서 애드몹 광고가 제재될 경우를 대비한 여러가지 광고를 추가했다. (ex. 유니티, 앱러빈..)
처음엔 하나의 파일, 즉 하나의 클래스에 모든 광고들의 로직이 들어 있었다.
대략적으로 설명하자면 애드몹을 6번 정도 try 시도를 하고 그래도 성공이 안되면 유니티 광고를 2개 시도, 그래도 안되면 앱러빈 광고를 2개 시도하는 로직이었다.
그리고 이 클래스를 광고 사용하는 곳에 사용하면 되었다.
(한 서너군데였던 것 같다.)
그러다가 애드몹 광고가 제재되고 나는 다른 앱들은 어떻게 구현했을까 궁금증에 설치하고 비교 분석하기 시작했다.
그 와중에 제재 사항과 별개로 이사님이 찾아와서 우리 앱에 또 하나의 이슈가 있다고 했다.
'바로 광고 버튼 클릭 후, 다른 페이지로 이동 시 광고가 재생이 된다는 것이다.'
그럴 수 밖에 없었던 것이 여러 군데에서 동일한 광고 로직을 편리하게 쓰도록 하기 위해 모든 광고 로직 loading, show 를 하나의 클래스에 모아둔 것이 원인이었다.
버튼을 클릭하면 loading 하는데 시간이 오래 걸렸고 (심지어 광고가 6개 있었으니 오래 걸렸음을 예상했다) 사용자들은 기다리다가 페이지를 이탈했다.
그렇게 다른 페이지에서 광고가 재생되어버렸다.
다른 앱들을 모두 분석해보니 페이지에 들어가면 loading 이 되었고 버튼을 누르면 이미 로딩이 된 상태이니 바로 show 가 되었던 상태였다.
이 사실을 알고 나는 착잡함을 느꼈다.
'젠장.. 6개의 광고의 loading 과 show 가 모두 하나의 클래스에 들어있는데 이걸 어떻게 나누지?'
여기서 모듈화의 필요성을 느껴버렸다.
처음에 만들기 쉬울지 몰라도 나중에 유지보수가 힘들구나..
시간이 걸려도 처음에 모듈화 구성은 정말 필요하구나..
그렇게 시간이 될 때 광고를 모두 분리하는 작업을 시작하였다.
첫 모듈화의 경험은 쉽지 않았다
그래! 모듈화를 해야겠어! 란 당찬 포부와 다르게 구조 설계에서 어떻게 해야할지 난감하였다.
모듈화를 적용시키고 나서 사용하려면 편리한 방식이어야 할텐데 거기서 고민이 길어졌다. (정의해둔 메서드들을 깔끔한 방식으로 가져다 쓰길 원했고 남이 보기에 이해하기 쉽고 간결한 코드였으면 했었다.)
여기서 내가 겪었던 고민의 과정을 나열해보겠다.
추상 클래스를 써야 하나 인터페이스를 써야 하나
6개의 광고는 loading 과 show 의 메서드를 동일하게 갖고 있어야 했다.
그러면 일단 Base Class 를 만들고 이를 6개의 광고에 적용하면 될 것 같았다.
여기서 고민이 되었다.
Base Class 는 추상 클래스를 써야 하는게 맞을까? 인터페이스를 쓰는게 맞을까?
abstract class AdBase {
final AdType adType;
AdBase({required this.adType});
/// Confirm if the ad is loaded.
///
/// If the result of [isLoaded] is false, then do not execute [startAd].
Future<bool> isLoaded();
/// Execute [startAd] if [isLoaded] returns true.
Future<bool> startAd();
}
처음에 인터페이스라고 생각했다. 왜냐하면 모두 다른 형식의 광고이지만 같은 메서드를 취할 수 있게끔 강제해야 한다고 생각을 했다.
하지만 인터페이스를 쓰고나서 찝찝함을 느꼈다. 이게 진짜 맞을까?
추상 클래스와 인터페이스 차이는 알고 있는 개념라고 생각했는데 그럼에도 헷갈리는 걸 보면 나는 확실히 알고 있지 않았다는 것을 깨달았다.
여기서 나는 명확한 기준을 정하기로 했다.
느낌적으로 선택하고 있었다면 나중에 또 다시 헷갈릴 가능성이 크다고 생각했기 때문이다.
공부를 많이 하고 추상 클래스와 인터페이스 구별법에 차이점을 찾아내었다.
광고 사이에서 공통적으로 필드를 갖고 있는 것은 없는가?
class AppLovinInterstitialAd implements AdBase {
AppLovinInterstitialAd({required AdType adType}) {
switch (adType) {
case AdType.mission:
unitId = GetPlatform.isAndroid
? Constants.applovinInterstitialAdMissionAosUnitId
: Constants.applovinInterstitialAdMissionIosUnitId;
break;
case AdType.cashstep:
unitId = GetPlatform.isAndroid
? Constants.applovinInterstitialAdCashStepAosUnitId
: Constants.applovinInterstitialAdCashStepIosUnitId;
break;
case AdType.roulette:
unitId = GetPlatform.isAndroid
? Constants.applovinInterstitialAdRouletteAosUnitId
: Constants.applovinInterstitialAdRouletteIosUnitId;
break;
case AdType.lotto:
unitId = GetPlatform.isAndroid
? Constants.applovinInterstitialAdLottoAosUnitId
: Constants.applovinInterstitialAdLottoIosUnitId;
break;
case AdType.lottery:
unitId = GetPlatform.isAndroid
? Constants.applovinInterstitialAdLotteryAosUnitId
: Constants.applovinInterstitialAdLotteryIosUnitId;
break;
}
setListener();
}
@override
late final AdType adType;
late final String unitId;
Completer<bool> loadCompleter = Completer();
Completer<bool> showCompleter = Completer();
Future<void> setListener() async {
AppLovinMAX.setInterstitialListener(
InterstitialListener(
onAdLoadedCallback: (ad) {
debugPrint('AppLovin InterstitialAd ${ad.adUnitId} loaded');
loadCompleter.complete(true);
},
onAdLoadFailedCallback: (adUnitId, error) async {
debugPrint('AppLovin InterstitialAd ($adUnitId) Show Failed : ${error.code} ${error.message}');
loadCompleter.complete(false);
},
onAdDisplayedCallback: (ad) {
debugPrint('AppLovin InterstitialAd ${ad.adUnitId} displayed');
},
onAdDisplayFailedCallback: (ad, error) async {
debugPrint('AppLovin InterstitialAd (${ad.adUnitId}) Show Failed : ${error.code} ${error.message}');
showCompleter.complete(false);
},
onAdClickedCallback: (ad) {},
onAdHiddenCallback: (ad) {
showCompleter.complete(true);
},
onAdRevenuePaidCallback: (ad) {},
),
);
}
@override
Future<bool> isLoaded() async {
AppLovinMAX.loadInterstitial(unitId);
return loadCompleter.future;
}
@override
Future<bool> startAd() async {
final adReady = await AppLovinMAX.isInterstitialReady(unitId) ?? false;
if (!adReady) {
return false;
}
AppLovinMAX.showInterstitial(unitId);
return showCompleter.future;
}
}
위는 한 가지 광고의 예시로, Base Class 는 interface 로 정하고 작성한 코드이다.
interface 로 생각했던 이유는 어쨌든 모두 다른 타입의 광고여서 unit id 를 공통적인 필드로 생각하지 않았었다.
하지만 다음 예시를 보고 깨달았다.
(아래 예시는 추상 클래스 예시이다)
abstract class Animal {
String name;
Animal(this.name); // 모든 동물은 이름을 가짐
void makeSound();
}
class Dog extends Animal {
Dog(String name) : super(name); // 'Animal'의 생성자를 사용하여 'name' 초기화
@override
void makeSound() {
print('$name says Woof!');
}
}
모든 동물은 이름을 가지고 있다.
이를 다시 생각해보면 광고는 어쨌든 모두 유니크한 id 가 존재한다. 유니티 광고는 placement id 라고 불리지만 이는 unit id 와 동일한 존재이다.
unit id 는 광고 종류에 따라 다르고 AdType 에 따라 또 다르다.
또한 안드로이드인지, iOS 인지에 따라 또 달라진다.
하지만 어쨌든 광고들은 unit id 라는 유니크한 아이디를 가진다는 사실에는 변함 없다.
또한 unit id 를 Base Class 에 써버리게 됨으로써의 장점은 자식 클래스에서 따로 선언할 필요 없이 자식클래스 메서드에서 사용 가능하다는 것이다.
즉 간결하게 작성할 수 있고 -> 이는 다른 사람들이 보기에 쉽게 이해할 수 있는 코드가 된다.
공통적인 필드를 가지고 있다는 사실을 발견했으므로, Base Class 를 다음과 같이 추상 클래스로 바꾸어 선언하도록 하였다.
/// The base class for all ads.
abstract class AdBase {
final AdType adType;
late final String unitId;
AdBase({required this.adType}) {
unitId = _setUnitId(adType);
}
String _setUnitId(AdType adType) {
if (runtimeType == AdmobRewardedAd) {
switch (adType) {
case AdType.mission:
return GetPlatform.isAndroid ? '' : '';
case AdType.cashstep:
return GetPlatform.isAndroid ? '' : '';
case AdType.lottery:
return GetPlatform.isAndroid ? '' : '';
case AdType.lotto:
return GetPlatform.isAndroid ? '' : '';
case AdType.roulette:
return GetPlatform.isAndroid ? '' : '';
}
} else if (runtimeType == UnityRewardedAd) {
switch (adType) {
case AdType.mission:
return GetPlatform.isAndroid ? '' : '';
case AdType.cashstep:
return GetPlatform.isAndroid ? '' : '';
case AdType.lottery:
return GetPlatform.isAndroid ? '' : '';
case AdType.lotto:
return GetPlatform.isAndroid ? '' : '';
case AdType.roulette:
return GetPlatform.isAndroid ? '' : '';
}
} else if (runtimeType == AppLovinRewardedAd) {
switch (adType) {
case AdType.mission:
return GetPlatform.isAndroid ? '' : '';
case AdType.cashstep:
return GetPlatform.isAndroid ? '' : '';
case AdType.lottery:
return GetPlatform.isAndroid ? '' : '';
case AdType.lotto:
return GetPlatform.isAndroid ? '' : '';
case AdType.roulette:
return GetPlatform.isAndroid ? '' : '';
}
} else {
throw UnimplementedError('Unknown $runtimeType');
}
}
/// Confirm if the ad is loaded.
///
/// If the result of [isLoaded] is false, then do not execute [startAd].
Future<bool> isLoaded();
/// Execute [startAd] if [isLoaded] returns true.
Future<bool> startAd();
}
위와 같이 작성하니 아래 자식 Class 는 unit id 를 결정하는 로직을 쓰지 않아도 됨으로 훨씬 간결해졌다!
또한 load, show 메서드에서 이 unit id 를 그대로 갖다 쓴다니..! 신기했다.
class AppLovinRewardedAd extends AdBase {
AppLovinRewardedAd({required AdType adType}) : super(adType: adType) {
setListener();
}
Completer<bool> loadCompleter = Completer();
Completer<bool> showCompleter = Completer();
Future<void> setListener() async {
AppLovinMAX.setRewardedAdListener(
RewardedAdListener(
onAdLoadedCallback: (ad) {
loadCompleter.complete(true);
},
onAdLoadFailedCallback: (adUnitId, error) async {
TeamsWebHook.instance.recordError(runtimeType: this, loadFailed: true, errorMessage: '${error.code} ${error.message}');
loadCompleter.complete(false);
},
onAdDisplayedCallback: (ad) {
debugPrint('AppLovin RewardedAd ${ad.adUnitId} displayed');
},
onAdDisplayFailedCallback: (ad, error) async {
TeamsWebHook.instance.recordError(runtimeType: this, showFailed: true, errorMessage: '${error.code} ${error.message}');
showCompleter.complete(false);
},
onAdClickedCallback: (ad) {},
onAdHiddenCallback: (ad) {
showCompleter.complete(false);
},
onAdReceivedRewardCallback:(ad, reward) {
showCompleter.complete(true);
},
),
);
}
@override
Future<bool> isLoaded() async {
AppLovinMAX.loadRewardedAd(unitId);
return loadCompleter.future;
}
@override
Future<bool> startAd() async {
final adReady = await AppLovinMAX.isRewardedAdReady(unitId) ?? false;
if (!adReady) {
return false;
}
AppLovinMAX.showRewardedAd(unitId);
return showCompleter.future;
}
}
이제 추상 클래스를 써야 하는 건 알았다.
하지만 두 번째 난관도 있었다.
정적 메서드를 써야 할까? 인스턴스 메서드를 써야 할까?
느낌상? 객체에 할당된 메서드(= 인스턴스 메서드)를 써야 한다고는 생각했는데 이 역시 느낌이 아니라 정확하게 찾아보고 정리해야겠다고 생각했다.
어떤 블로그를 보고 이런 특징이 있구나를 새롭게 알게 되었다.
- 인스턴스 메소드라고 해서 매번 객체가 생성될 때마다 함께 생성되는 것이 아니다. 메모리에 한 번 할당이되고 각 생성된 객체들은 그 메소드가 메모리 어디에 존재하는지를 알 뿐이다. 객체가 메모리가 할당된 메모리 주소를 담고 있어 메소드를 호출하면 메모리 주소를 통해 메소드를 호출하게 된다.
- 같은 클래스를 통해 생성된 객체들간 같은 코드를 사용하는 것을 보장하기 위해 사용하는 것은 정적 메서드이다.
또한 추가적으로 정적 메서드를 쓸 때에는 인스턴스 변수를 사용하지 않는 경우에도 해당된다.
정적 메서드를 쓰게 하는 방법도 생각해보았는데, 그렇게 되면 unit id 를 기반으로 load, show 를 해야 하는 메서드는 unid id 를 파라미터를 받아서 정적 메서드를 써야 하게 될 것 같다.
그러면 base class 에서 unit id 를 결정하는 로직을 빼내어 외부에서 결정하게 하는 것이 맞을 것 같다.
하지만 결정적으로 정적 메서드를 쓰기에 망설인 까닭은
앱러빈과 같은 광고는 클래스가 생성될 때 Listener 를 달아주는 것이 맞을 것 같고 (애초에 광고 예제가 페이지에 생성될 때 Listener 를 달아주는 예제였음) Listener 의 콜백으로 load, show 결과를 받게 되므로 객체에 종속되어 있는 것에 가깝다고 생각해서 인스턴스 메서드를 써주었다. (인스턴스 변수에 접근해야 하니까!)
느낀점
사실 하나의 클래스로 나눠 버린 것보다 코드 양이 급증한 것 같기는 하지만 로직을 바꾸기 용이함은 틀림 없는 것 같다.
언제든지 코드 변경이 일어나지 않을까 예상하며 모듈화를 해보았다.
직접 해보고 나니 알던 개념이라고 생각했지만 알지 못했던 것 같다. 코드를 써봄으로써 차이점이 명확하게 인지가 되는 것 같다.
지금 이 코드가 정답은 아니다. 하지만 구현을 계속하다보면 어느 새 정답을 찾아가지 않을까?
경험을 계속해서 좋은 피드백과 코드 리뷰를 해주고 싶다.
'⏳ 회고' 카테고리의 다른 글
2023 회고록 (0) | 2024.02.11 |
---|---|
[Flutter] 만보기 개발 기록 (4) | 2023.09.24 |
2022 회고록 (2) | 2023.01.02 |