본문 바로가기

IT/Android

대부분의 Android 앱이 어기는 이 한 가지 클린 코드 원칙

728x90
반응형

많은 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


728x90
반응형