본문 바로가기

카테고리 없음

FCM + 잠금화면 으로 제공하는 정확한 막차 알림!

⏰ 막차 알림! 어떻게 해야 막차를 안 놓치게 할까?!

저희 서비스 앗차의 가장 중요한 기능 중 하나는 막차 알림이에요!

이 알림을 어떤 방식으로 제공할 수 있을지 고민해 봤어요.

FCM 알림만 제공하면 너무 단순한 알림만 제공하는 것 같았기에, 잠금화면을 통해 알림을 제공하기로 했어요!

 


💻 잠금화면 알림 구현

우선, 잠금화면 알림을 어떻게 구현했는지 간단하게 소개해드릴게요.

 

구성 요소

  • LockService : 백그라운드에서 실행되는 잠금화면 표시 및 알림음 재생
  • LockActivity : Compose를 통한 잠금화면 UI 구현
  • LockReceiver : 화면 켜짐/꺼짐 등 시스템 이벤트를 수신하는 BroadcastReceiver
  • LockServiceManager : Service의 생명주기 관리
  • LockScreenNavigator : 잠금화면 activity 로의 이동 담당

 

동작 흐름

1. 서비스 시작 및 준비

 

LockService

override fun onCreate() {
    super.onCreate()
    LockReceiver.initialize(lockScreenNavigator, taxiCostUseCase)
    startForeground(NOTIFICATION_ID, createForegroundNotification())
}
  • onCreate에서 LockReceiver를 초기화하고 포그라운드 서비스로 등록하여 시스템에 의해 쉽게 종료되지 않도록 합니다.

 

2. 잠금화면 표시

 

LockService

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    val showLockScreen = intent?.getBooleanExtra(EXTRA_SHOW_LOCK_SCREEN, false) ?: false

    startLockReceiver()

    if (showLockScreen) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                withContext(Dispatchers.Main) {
                    // 화면 깨우기 및 키가드 해제
                    val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
                    keyguardManager.requestDismissKeyguard(LockActivity(), null)
                    
                    // 알림음 재생
                    playAlarmSound()
                    
                    // 잠금화면으로 이동
                    lockScreenNavigator.navigateToLockScreen(applicationContext, taxiCost)
                }
            } catch (e: Exception) {
                // 오류 처리
            }
        }
    }
    return START_STICKY
}
  • 코루틴을 사용하여 비동기적으로 화면을 깨우고, 알림음을 재생하며, lockScreenNavigator을 통해 잠금화면 activity를 시작합니다.

LockActivity

@AndroidEntryPoint
class LockActivity : ComponentActivity() {
    private val viewModel: LockViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 잠금화면 위에 표시되도록 설정
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
            setShowWhenLocked(true)
            setTurnScreenOn(true)
        } else {
            window.addFlags(
                WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
                WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
                WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
                WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
            )
        }

        setContent {
            Team6Theme {
                LockRoute(
                    padding = PaddingValues(),
                    viewModel = viewModel,
                    onTimerFinish = {
                        stopLockServiceAndExit(this)
                    },
                    onDepartureClick = {
                        viewModel.setEvent(LockContract.LockEvent.OnDepartureClick)
                        finish()
                    },
                    onLateClick = {
                        viewModel.setEvent(LockContract.LockEvent.OnLateClick)
                        finish()
                    }
                )
            }
        }
    }
}
  • Compose를 사용해 구현한 UI를 잠금화면 위에 표시합니다.

 

3. 화면 깨우기 이벤트 처리

 

LockReceiver

override fun onReceive(context: Context, intent: Intent) {
    when (intent.action) {
        Intent.ACTION_SCREEN_ON -> {
            // 화면이 켜지면 잠금화면 다시 표시
            navigator.navigateToLockScreen(context, taxiCost)
        }
    }
}
  • 사용자가 기기를 깨우면 잠금화면이 다시 표시되도록 합니다.

 

4. 서비스 생명주기 관리

 

LockServiceManager

class LockServiceManager @Inject constructor(
    @ApplicationContext val applicationContext: Context
) : BaseForegroundServiceManager<LockService>(
    context = applicationContext,
    targetClass = LockService::class.java
)

abstract class BaseForegroundServiceManager<T : Service>(
    val context: Context,
    val targetClass: Class<T>
) {
    fun start() = synchronized(this) {
        val intent = Intent(context, targetClass)

        if (!context.isServiceRunning(targetClass)) {
            context.startForegroundService(intent)
        }
    }

    fun stop() = synchronized(this) {
        val intent = Intent(context, targetClass)

        if (context.isServiceRunning(targetClass)) {
            context.stopService(intent)
        }
    }
}
  • LockService의 시작과 중지를 관리하며, 서비스가 이미 실행 중인지 확인해서 중복 시작을 방지합니다.

 

이런 방식으로 백그라운드에서도 잠금화면을 통해 알림을 받을 수 있도록 구현했습니다!

 


💻 Multi-Activity 적용

앞서 보여드린 구현 방식을 보면, LockActivity를 추가로 구현한 것을 보실 수 있는데요!

최근 안드로이드 아키텍처 트렌드는 Single Activity 패턴을 따르는 경우가 많아요. 하지만, 앗차에서는 기존의 Single Activity 구조에서 벗어나 잠금화면 구현을 위한 별도의 LockActivity를 추가했어요.

 

그 이유를 설명해 드릴게요!

 

시스템 레벨 기능

잠금화면의 특성상 시스템 레벨 기능들이 필요했습니다.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
    setShowWhenLocked(true)
    setTurnScreenOn(true)
} else {
    window.addFlags(
        WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
        WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
        WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
        WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
    )
}

이러한 설정들은 Activity 수준에만 적용이 가능한데, MainActivity에 적용하면 모든 화면에 영향을 주게 되어 앱의 다른 기능에 문제가 생길 수 있습니다.

 

독립적인 생명주기

잠금화면은 일반적인 앱 흐름과 별개로 동작해야 합니다.

  • 앱이 백그라운드 상태여도 표시되어야 함
  • 화면이 꺼져 있을 때도 화면을 켜고 표시해야 함
  • 사용자가 앱을 사용하지 않을 때도 최상위에 표시해야 함

이러한 잠금화면과 관련된 요구사항들을 충족하기 위해 독립된 Activity에서 구현했습니다.

 

AndroidManifest.xml 설정

<activity 
    android:name=".presentation.ui.lock.LockActivity"
    android:showOnLockScreen="true"
    android:showWhenLocked="true"
    android:turnScreenOn="true"
    android:launchMode="singleTop"/>

Manifest에서 잠금화면을 표시하기 위한 속성들을 설정할 수 있기 때문에, activity를 따로 분리했습니다.

 

결론

Single-Activity 패턴은 일반적인 앱 화면 흐름에 적합하지만, 앗차는 잠금화면 구현을 위해 특별한 시스템 수준의 기능이 필요해서 Activity를 추가로 만드는 게 적합하다고 판단했어요.

 

또한, 잠금화면은 앱의 주요 기능 흐름과 분리된 특별한 기능이기 때문에 Activity를 구분하여 명확한 관심사 분리도 가능하도록 했습니다!

 

이처럼 단순히 트렌드를 따라가는 것보다는 저희 프로젝트에 필요한 기술들을 선정해서 구현했어요.

 


🔫  FCM + 잠금화면 알림 트러블 슈팅

정확한 알림 시간 측정을 위해

앗차는 잠금화면 알림을 사용자가 출발해야 할 정확한 시간에 제공하는 게 중요했어요!

기존에는, 백그라운드에서 1분마다 출발 시간을 받아오는 API를 호출해서 정확한 시간을 계속 업데이트하고 해당 시간에 잠금화면 알림을 제공했습니다.

 

하지만 1분마다 계속 API를 호출하는 비용이 너무 크다고 생각했고, 서버 분들께서 FCM 알림에 type을 제공하는 방식을 제안해 주셔서 해당 방식으로 변경하기로 했어요!

 

FCM RemoteMessage

알림 구현에 많이 사용하는 FCM에서 RemoteMessage 객체로 메세지를 받을 수 있는 방법은 다음과 같아요.

  1. 알림 메시지 (Notification Message)
    • message.notification 객체를 통해 접근
    • title, body 등 기본 데이터 추출 가능
  2. 데이터 메시지 (Data Message)
    • message.data 객체를 통해 접근
    • 커스텀 데이터 추출 가능

 

기존

기존에는 message.notification만 사용해서 title, body를 받아서 알림을 제공했습니다.

 

개선

잠금화면 알림을 제공하는 시간을 클라이언트에서는 따로 측정하지 않고, 서버에서 정확한 시간에 Fcm을 활용해 제공하도록 변경했습니다.

message.data[”type”] 을 추가로 받아서 type이 FULL_SCREEN_ALERT 일 때는 잠금화면 알림을 시작하고, PUSH_ALERT 일 때는 기존 푸시 알림을 제공하도록 구현했습니다.

class FcmService: FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        super.onMessageReceived(message)

        val title = message.notification?.title
        val body = message.notification?.body
        val type = message.data["type"] ?: ""
        
        if (message.data.isNotEmpty()) {
		    if (type == "FULL_SCREEN_ALERT")
	            startLockService()
	        else if (type == "PUSH_ALERT")
		         sendNotification(title, body)
		    else
				 sendDefaultNotification()
        } else {
            Timber.d("[FCM] FcmService -> empty data")
        }
    }
}

 

이 방식이 잘 동작하는 것을 확인한 후, 기존에 출발 시간을 받아오는 API 호출은 제거해서 비용을 절약할 수 있었어요!

 


 

백그라운드 상태에서 알림이 안 오는데!?

하지만 테스트를 할 때 문제가 발생했어요😢

 

분명 기존에는 백그라운드에서도 잠금화면이 잘 실행됐었는데,

FCM에서 type을 받아서 잠금화면을 실행하는 방식으로 변경하니까 백그라운드 상태에서는 잠금화면이 동작하지 않았어요.

 

처음에는 잠금화면 구현에 문제가 있나 싶어서 열심히 코드를 뜯어고쳤는데요! 해결되지 않았어요 😭

그래서 차근차근 다시 생각하던 중, type을 받아서 처리하는 로직이 추가된 이후 실행이 안 되는 거라면 해당 부분에 문제가 있다고 판단했습니다.

 

백그라운드에서 onMessageReceived 동작이 안 되는 문제

Firebase 공식문서

 

Firebase 공식문서에 따르면, 백그라운드 상태일 때 notification 값이 존재하면 onMessageReceived()가 호출되지 않고 디바이스 시스템에서 처리된다고 나와 있습니다.

 

onMessageReceived()에서 잠금화면을 실행시키는 로직을 작성했는데, 백그라운드에서는 이 부분이 실행되지 않고 있었던 거였어요.

 

onMessageReceived가 호출되도록 해결

이를 해결하기 위해 onMessageReceived가 호출되도록 하는 2가지 방법이 있어요.

 

해결 방법 1 : 서버에서 처리

서버에 메시지 형태를 data로만 보내주고, notification은 제거해 달라고 요청합니다.

메시지 자체에 notification이 없으면, 백그라운드일 때도 onMessageReceived가 실행됩니다.

 

해결 방법 2 : handleIntent 오버라이딩

override fun handleIntent(intent: Intent?) {
    val new = intent?.apply {
        val temp = extras?.apply {
            remove(MessageNotificationKeys.ENABLE_NOTIFICATION)
            remove("gcm.notification.e")
        }
        replaceExtras(temp)
    }
    super.handleIntent(new)
}

handleIntent 함수를 오버라이딩해서, intent의 extra에 notification 키 값을 제거한 후 전달하면 notification이 제거되기 때문에 onMessageReceived가 실행됩니다.

 

저는 두 번째 방법을 선택했어요!

그 이유는, 현재 앗차서비스는 안드로이드만 제공 중이지만 iOS나 웹 서비스로도 확장이 된다면 서버를 같이 사용해야 하기 때문에 서버 측 변경보다는 안드로이드에서 처리하는 게 맞다고 판단했기 때문이에요.

 

 

 

이렇게 FCM과 잠금화면 알림을 엮어서 구현한 결과, 앗차만의 멋지고 귀엽고 정확하고 멋지고 귀여운 잠금화면 알림을 성공적으로 구현할 수 있었답니다 ~!

 

알림 소리와 진동도 정말 재밌는데요, 궁금하신가요?!

 

그렇다면 한번 사용하러 가보실래요 ~? 👉  앗차 알림 받으러 가기 😆