많은 Android 개발자들이 모르게 빠지는 동일한 아키텍처 함정이 있습니다: FirebaseMessagingService, BroadcastReceiver, Activity, Service와 같은 프레임워크 클래스에 너무 많은 로직을 넣는 것입니다. 처음에는 빠르고 쉬워 보이지만, 곧 코드는 취약해지고, 테스트하기 어려우며, 유지보수가 거의 불가능해집니다.
이 글에서는 그 함정을 피하는 간단한 규칙을 소개합니다:
> Android 컴포넌트는 단순하게 유지하세요.
우리는 Firebase Cloud Messaging(FCM)을 사례로 사용하겠지만, 이 원칙은 앱 아키텍처 전반에 걸쳐 적용됩니다.
---
FCM 푸시 알림: 사례 연구
Android에서 Firebase Cloud Messaging(FCM)을 설정하는 것은 간단합니다. AndroidManifest.xml을 설정하고, 권한을 요청하며, 두 가지 주요 메서드를 오버라이드합니다:
onNewToken(String token) — 새로운 FCM 토큰이 생성될 때 호출됩니다.
onMessageReceived(RemoteMessage message) — 푸시 메시지가 도착할 때 트리거됩니다.
그런 다음 다음과 같은 코드를 작성할 수 있습니다:
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val title = remoteMessage.data["title"]
val body = remoteMessage.data["body"]
val deeplink = remoteMessage.data["deeplink"]
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplink))
val notification = NotificationCompat.Builder(this, "channel_id")
.setContentTitle(title)
.setContentText(body)
.setContentIntent(PendingIntent.getActivity(this, 0, intent, 0))
.build()
NotificationManagerCompat.from(this).notify(1, notification)
}
⚠️ 이 코드는 작동합니다... 하지만 곧 문제가 발생합니다. 왜일까요?
❌ 단위 테스트를 할 수 없습니다.
❌ 로직을 재사용할 수 없습니다.
❌ 실제 푸시를 테스트해야 합니다.
이제 이를 정리해봅시다.
---
✅ 더 나은 방법: 모든 것을 위임하세요
1. FirebaseMessagingService를 간단하게 유지하세요
서비스는 로직을 포함해서는 안 됩니다. 대신, 주입된 테스트 가능한 컴포넌트에 작업을 전달해야 합니다.
@AndroidEntryPoint
class MyFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var pushHandler: dagger.Lazy<PushHandler>
@Inject lateinit var pushMessageMapper: dagger.Lazy<PushMessageMapper>
override fun onNewToken(token: String) {
// 서버와 통신하여 토큰을 업데이트하세요
}
override fun onMessageReceived(message: RemoteMessage) {
val mapped = pushMessageMapper.get().map(message)
pushHandler.get().handle(mapped)
}
}
이렇게 하면 Hilt나 Koin과 같은 의존성 주입의 힘을 활용하고, 로직을 재사용 및 테스트할 수 있도록 격리할 수 있습니다.
---
2. 클린 도메인 모델로 매핑하세요
Firebase의 RemoteMessage를 직접 전달하지 마세요. 대신, 앱의 요구사항을 반영하는 플랫폼에 독립적인 모델을 추출하세요.
data class PushMessage(
val title: String?,
val body: String?,
val deeplink: String?,
val type: String?
)
FCM 데이터를 이 클래스에 매핑하세요:
class PushMessageMapper @Inject constructor() {
fun map(message: RemoteMessage): PushMessage {
val data = message.data
return PushMessage(
title = data["title"],
body = data["body"],
deeplink = data["deeplink"],
type = data["type"]
)
}
}
RemoteMessage를 추상화함으로써, 핵심 로직을 Firebase로부터 분리할 수 있습니다.
---
3. 비즈니스 로직을 주입하고 테스트하세요
이제 도메인 모델을 받아서 처리할 PushHandler를 생성하세요.
interface PushHandler {
fun handle(message: PushMessage)
}
class DefaultPushHandler @Inject constructor(
private val notificationFactory: NotificationFactory,
private val deeplinkParser: DeeplinkParser,
@ApplicationContext private val context: Context
) : PushHandler {
override fun handle(message: PushMessage) {
val notification = notificationFactory.createNotification(context, message)
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(1001, notification)
}
}
}
이제 로직은 테스트 가능하고, 확장 가능하며, 유지보수가 쉬워집니다.
---
BroadcastReceiver에도 동일한 패턴을 적용하세요
이 실수는 푸시 알림에만 국한되지 않습니다. BroadcastReceiver도 또 다른 고전적인 문제점입니다.
잘못된 패턴 — 리시버 내부에 로직이 있는 경우:
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val workManager = WorkManager.getInstance(context)
workManager.enqueue(...) // 로직이 여기에 있음
}
}
더 나은 패턴 — 로직을 위임하는 경우:
class BootCompletedReceiver : BroadcastReceiver() {
private val rescheduler: TaskRescheduler by inject()
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
rescheduler.reschedule()
}
}
}
interface TaskRescheduler {
fun reschedule()
}
class DefaultTaskRescheduler @Inject constructor(
private val workManager: WorkManager
) : TaskRescheduler {
override fun reschedule() {
workManager.enqueue(...) // 깔끔하고 테스트 가능
}
}
이렇게 하면 브로드캐스트를 시뮬레이션할 필요 없이 리스케줄링 로직을 단위 테스트할 수 있습니다.
---
Robolectric과 Fakes를 사용한 단위 테스트
이제 재미있는 부분입니다. 핵심 로직이 더 이상 Android 컴포넌트에 묶여 있지 않기 때문에, 테스트가 간단해집니다:
@RunWith(RobolectricTestRunner::class)
class DefaultPushHandlerTest {
private lateinit var context: Context
private val posted = mutableListOf<Notification>()
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun `push handler posts notification`() {
val handler = DefaultPushHandler(
FakeNotificationFactory(context, posted),
FakeDeeplinkParser(),
context
)
handler.handle(PushMessage("Hello", "World", null, null))
assertEquals("Hello", posted.first().extras.getString(Notification.EXTRA_TITLE))
}
}
디바이스나 에뮬레이터가 필요 없습니다 — 그냥 순수 Kotlin과 Robolectric만 있으면 됩니다.
---
UIAutomator를 사용한 엔드 투 엔드 검증
UIAutomator를 사용하여 실제 시스템 수준의 동작을 테스트할 수도 있습니다:
@RunWith(AndroidJUnit4::class)
class NotificationUITest {
@Test
fun notificationIsDisplayed() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val context = ApplicationProvider.getApplicationContext<Context>()
val handler = DefaultPushHandler(
FakeNotificationFactoryThatShowsRealNotif(context),
FakeDeeplinkParser(),
context
)
handler.handle(PushMessage("Test Title", "Test Body", null, null))
device.openNotification()
device.wait(Until.hasObject(By.textContains("Test Title")), 5000)
val notif = device.findObject(UiSelector().textContains("Test Title"))
assertTrue("Notification not found", notif.exists())
}
}
class FakeNotificationFactoryThatShowsRealNotif(
private val context: Context
) : NotificationFactory {
init {
createNotificationChannel()
}
override fun createNotification(context: Context, message: PushMessage): Notification {
return NotificationCompat.Builder(context, TEST_CHANNEL_ID)
.setContentTitle(message.title ?: "Test Title")
.setContentText(message.body ?: "Test Body")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build().also {
NotificationManagerCompat.from(context).notify(TEST_NOTIFICATION_ID, it)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Test Channel"
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(TEST_CHANNEL_ID, name, importance)
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
}
companion object {
private const val TEST_CHANNEL_ID = "test_channel"
private const val TEST_NOTIFICATION_ID = 42
}
}
CI나 사전 릴리스 테스트에서 실제 디바이스 동작을 검증하는 데 완벽합니다.
---
최종 조언
Android 컴포넌트를 단순하게 유지하고, 로직은 깔끔하게 유지하세요.
FirebaseMessagingService에는 알림 코드를 포함하지 마세요.
BroadcastReceiver는 작업을 큐에 추가하지 마세요.
Activity에는 비즈니스 로직을 포함하지 마세요.
@Inject, @Binds, 그리고 interfaces를 사용하여 시스템의 모든 부분을 테스트 가능하고 모듈화하세요.
---
✅ 요약
Android 컴포넌트는 작업을 수행하지 말고, 위임하세요.
로직을 주입 가능한 클래스에 이동하세요.
SDK에서 분리된 도메인 모델을 생성하세요.
Fakes를 사용하여 단위 테스트
---
📎 출처 및 원문 링크:
https://proandroiddev.com/most-android-apps-break-this-one-clean-code-rule-f2fb44f98e90
IT/Android
대부분의 Android 앱이 어기는 이 한 가지 클린 코드 원칙
728x90
반응형
728x90
반응형