몇 달 전 한 웹 애플리케이션을 개발하였는데, 해당 서비스는 웹 애플리케이션이기 때문에 알림으로 sse를 이용한 알림 방식만을 채택했었다.
그런데 최근 해당 서비스를 안드로이드 스튜디오를 통해 웹뷰로 구현하면서 FCM 알림을 도입하게 되었다.
나는 서버에서 특정 이벤트가 발생하면 웹뷰로 구현된 안드로이드에 알림을 보내줘야 하는 상황이었다.
참고)
Android(Kotlin) : React 기반 웹뷰로 구현
SpringBoot(Java) : 백엔드 서버
먼저 FCM 알림은 Firebase로 구현하기로 결정하였다.
서버비를 팀내에서 부담하고 있기 때문에 무료인 점이 가장 큰 이유로 작용했다.
Firebase는 XMPP와 HTTP 두 가지 프로토콜을 선택할 수 있다. 차이점은 다음과 같다.
여기서 말하는 업스트림이란 개별 유저의 디바이스에서 앱 서버(Spring)로 알림을 전송하는 것이며,
'다운스트림'이란 앱 서버에서 디바이스로 알림을 전송하는 것이다.
앞서 언급했듯이, 서버에서 특정 이벤트가 발생하면 유저 기기로 알림을 전송해야 하기 때문에 HTTP 프로토콜을 선택했다.
발급된 FCM 토큰이 서버에 저장되고, 활용되는 로직은 다음과 같다.
그런데 문제가 있었다.
🤔 : 프론트를 안드로이드 스튜디오에서 웹뷰로 결과를 뿌려주기만 하다보니 카카오 로그인이 완료된 시점을 구별하는 방법과 해당 유저의 정보(서버에서 발급한 access token이나 유저의 id)를 알 수 있는 방법이 없는데?
웹뷰로 구현하는 것은 프론트분들이 진행해주셨고, 나는 안드로이드 스튜디오가 처음이었기 때문에 웹뷰에서 일방적으로 웹페이지를 뿌려주는 형태를 이해하는 것 자체가 오래 걸렸다.
우선 기기별로 발급되는 FCM 토큰이 유효하려면, 로그인한 유저의 정보와 FCM 토큰이 세트로 관리되어야 한다.
그래서 접근 가능한 정보를 최대한 이용해 서버에 유저의 정보와 그와 매칭되는 FCM 토큰을 전송하고, 서버에서 이를 관리할 수 있는 방법을 모색해보고자 했다.
일단 카카오 로그인 로직은 다음과 같다.
그래서 필자는 카카오 로그인 시에 리다이렉트 되는 url 패턴이 안드로이드에서 감지되면, url의 쿼리 파라미터로부터 access token을 추출하고, 서버에 access token과 FCM token을 전송하여 서버에서 이를 관리하도록 구현했다.
build.gradle 같은 초기 설정은 아래 문서를 참고했다.
[공식문서] Android 프로젝트에 Firebase 추가
FirebaseMessagingService를 상속하여 MyFirebaseMessagingService 클래스를 구현하였다.
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d("new token", token)
saveTokenLocally(token)
}
override fun onCreate() {
super.onCreate()
// 앱이 시작될 때마다 토큰을 다시 요청
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
saveTokenLocally(token)
} else {
Log.e("Firebase", "Failed to get token")
}
}
//토픽 구독
FirebaseMessaging.getInstance().subscribeToTopic("testMessage")
val sharedPreferences = applicationContext.getSharedPreferences("MyAppPrefs", Context.MODE_PRIVATE)
val accessToken: String? = sharedPreferences.getString("access_token", "")
if (accessToken != null) {
val savedToken = sharedPreferences.getString("firebase_token", "")
Log.d("SavedFCMToken", savedToken.toString());
TokenSender().sendTokenToServer(applicationContext, savedToken, accessToken)
}
}
private fun saveTokenLocally(token: String) {
val shredPref = applicationContext.getSharedPreferences("MyAppPrefs", Context.MODE_PRIVATE)
with(shredPref.edit()) {
putString("firebase_token", token)
apply()
}
val sharedPreferences = applicationContext.getSharedPreferences("MyAppPrefs", Context.MODE_PRIVATE)
val savedToken = sharedPreferences.getString("firebase_token", "")
Log.d("SharedPreferences", "Saved token: $savedToken")
}
}
카카오 로그인 성공시 리다이렉트는 url이 감지되면 access token과 firebase token을 스프링 서버에 저장하도록 POST 요청을 보낸다.
accessToken = Uri.parse(request.url.toString()).getQueryParameter("accessToken")
val shredPref = applicationContext.getSharedPreferences("MyAppPrefs", Context.MODE_PRIVATE)
with(shredPref.edit()) {
putString("access_token", accessToken)
apply()
}
val sharedPreferences = applicationContext.getSharedPreferences("MyAppPrefs", Context.MODE_PRIVATE)
val savedToken = sharedPreferences.getString("firebase_token", "")
Log.d("SavedFCMToken", savedToken.toString());
TokenSender().sendTokenToServer(applicationContext, savedToken, accessToken)
참고로 로그인 한 뒤에 로그아웃 없이 앱에 접속할 경우, 만약에 firebas token이 갱신되면 서버에도 다시 저장해줘야 하는데 이때 access token도 필요하다. 따라서 로그인 이후에 access token도 SharedPreferences에 캐시한다.
implementation 'com.google.firebase:firebase-admin:9.1.1'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
Firebase 앱을 초기화하는 로직을 포함하는 initialize()가 빈 초기화 이후 수행될 수 있도록 @PostContstruct를 사용한다.
@PostConstruct
public void intialize() throws IOException {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(new ClassPathResource(serviceKeyFilePath).getInputStream()))
.setProjectId(projectId)
.build();
FirebaseApp.initializeApp(options);
}
먼저 안드로이드 유저로부터 로그인 이후나 앱에 접속했을 때 firebase token과 access token을 담은 POST 요청을 받는다.
Spring에서는 access token으로부터 얻은 유저 정보를 key값으로 하여 firebase token을 redis에 저장한다.(또는 업데이트한다.)
알림이 전송되어야 하는 상황이 되면 앞서 저장한 firebase token으로 알림을 받아야 하는 유저에 푸쉬 알림을 전송한다.
아래 코드는 푸쉬 메시지를 발송하는 메서드이다.
public void sendPushMessage(Long memberId, String title, String body) throws FirebaseMessagingException {
String fcmToken = redisService.getData("FcmToken:" + memberId);
FirebaseMessaging.getInstance().send(Message.builder()
.setNotification(Notification.builder()
.setTitle(title)
.setBody(body)
.build())
.setToken(fcmToken)
.build());
}
성공하면 다음과 같이 푸쉬 알림이 발송된다.

필자는 안드로이드와 스프링 서버를 모두 로컬에 띄워두고 테스트할 때, 안드로이드는 에뮬레이터를 통해 하지 않고 기기와 연결해서 테스트하였다.
그런데 기기에서 테스트하는 앱을 통해 스프링으로 API 요청을 할 때 경로를 찾지 못해 요청이 제대로 전송되지 못하는 문제가 있었다.
해결책은 아래 링크를 통해 찾을 수 있었다.
✅ 결론만 말하자면
에뮬레이터를 사용할 경우 localhost를 가리킬 때 10.0.2.2를 사용하고,
기기를 연결하여 사용할 경우 내부 ip주소를 사용해야 한다.