FCM, Spring Boot를 사용하여 웹 푸시 기능 구현하기

김지훈·2019년 9월 25일
12

Firebase

Firebase는 웹과 모바일 개발에 필요한 기능을 제공하는 BaaS(BackEnd as a Service) 이다. 백엔드 서버의 인프라들을 제공해주고 많은 기능들을 지원한다.

  • 머신러닝
  • 사용자 인증
  • 파일 저장 및 제공
  • 데이터 저장 및 동기화
  • 앱 성능 통계 파악
  • 실시간 오류 보고
  • 앱 테스트
  • 클라우드 메세징

사용법

Step 1 - Firebase 프로젝트 만들기

https://console.firebase.google.com/

Step 2 - 웹 앱의 구성 스니펫 가져오기

  1. Firebase에 로그인하고 프로젝트를 엽니다.
  2. 개요 페이지에서 앱 추가를 클릭합니다.
  3. 웹 앱에 Firebase 추가를 선택합니다.
  4. 스니펫을 복사하여 애플리케이션 HTML에 추가합니다.

  • templates/firebase-snippet.html
<!-- The core Firebase JS SDK is always required and must be listed first -->
<script src="https://www.gstatic.com/firebasejs/6.5.0/firebase-app.js"></script>

<!-- TODO: Add SDKs for Firebase products that you want to use
     https://firebase.google.com/docs/web/setup#config-web-app -->

<script>
  // Your web app's Firebase configuration
  var firebaseConfig = {
    apiKey: "AIzaSyBWbLdjemTbpsGckTlyDqaATpbI_0usdHw",
    authDomain: "woowa-turkey.firebaseapp.com",
    databaseURL: "https://woowa-turkey.firebaseio.com",
    projectId: "woowa-turkey",
    storageBucket: "",
    messagingSenderId: "732784692174",
    appId: "1:732784692174:web:555e23a165bac677"
  };
  // Initialize Firebase
  firebase.initializeApp(firebaseConfig);
</script>

Step 3 - 비밀 KEY 파일 생성

생성한 프로젝트 페이지 > 설정 > 서비스 계정항목

  • woowa-turkey-firebase-adminsdk.json
{
  "type": "service_account",
  "project_id": "woowa-turkey",
  "private_key_id": "ef30ede0806a64f1acb2a35e3dd8ebd858cae458",
  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvQgYywH82jL1R\nTlNIWDW1txvZmdMa8vAKePPJioXAZGuAP4HY/+prbEGHKMPE+kml5fK9Mw8JNRb6\n7t9napjcTSSvPpN+1SBPTFomxVx7ko8ktjCIbMUjp7Dpeg7ePRUE/4m+RIIAJPSh\nhG2kvqMfIMM/9RoYGqfzo1BO503/U3Kzhjz9pWtyiWpXv0dAgVEVobKm8EzWSS/y\n3Frxm10pS2jMmuMHaBnReqPa0Db344sl1Q/4SPB6g6mKCsBvx5EoN+WoAmFkuiMu\noIptT6JKMECZQIIarO6ylwyjGC/k5U6cL/hNeqewpTNViRYQk006B5L0r3JevyLU\nhU7qNHBbAgMBAAECggEADqlE0HkXC06xxbv6Lpqmf5meXFFIfkAlgZLFvi3263Xb\nImLtr2QVTcBEKRTpb7CKebTnXhmH3/rPfdV+3e1mdUOE1p4zA5C7v7aA8i5fgrzr\neaQ+IiOLSGTOYXHDDadpez4PXVno0C9HQb3M2FPUXJXGj7Km6K5OL40RelBST5Id\nQ4bbcLO2g/NazRhujBiF/Cp2op/si99BqNJcZTZ1AkupUZ9u4tfQd+fZ2TuAUUxn\n5EvPKOK/kd6s0XizBgwdDcPUJ+GE4RyLyS6p2qeRIvQYQdEf4MY9qHC9Gpgc6kc4\nh5pm9hrnf+Xcmpb2bfs+bZLeVtIOReeFMTHq9P+W/QKBgQDzyX0EOdqRBKEntZ73\nIruueVWF66JDoyRO2nj3pwmlsnzDOg7mGc5DCa/Q2OrOeuPQc0A/nLChZcpGQNa2\nnVgZJQLKC0YFuzwiw0wsALaOFGkm3I0Ws0BU0DloMKETaGVm7LYiZhWABXDWmNHb\ngsGKQBD8J6/2CIoKTts8yrZV7wKBgQC4CapjQOzcFE639FH3I7jiStecxRWfHtdj\nmfaUpLgKxxzsiLq0eC3wGgdl2zGcSAOejhSeFSNRsLesupNXUpSt4Tg9dKQKqkob\nJBOsLaJ/XzU388gwdR+VHOzH5UySlKJT1kgmbWnLzrjJU47Ed6er0x+RMEw5E2vX\nDAtrtZyYVQKBgQCmkhZci9ceXSZrxnn+bHoGZZsc7Pqq8k59nmMm+7fowoLitcm2\nSQ2Y2oaJ0ZzmRitgo07T6BD7xlwmEmlizzGBxLsaKAfJJLybGAv0yOvzOlj6l5nD\nV+jtynRfD1MPurYGVFjCOjQYjB8kUbiaHsZ02v9/+vIMzprbHjRIXk2lewKBgHWc\nZ7FKWpRCvhO9JWyE/bBQF/5tzOWFdiyGxhg0NBFfMdLPhlGyw9i/KZ8kN04ij32S\nWwazh4C7KIZQLb0OX0nHtoM/uV2/eeBia9gKyeAQXhhFyNd+4hIPDBqrJQY/yoU9\nWZqFLak7kMfIiGJx/UtHftLwRKXtDWu+nMXWqB6JAoGARb+KXnhDXpLhqp+bVmMQ\n9WkmVdSsZNrHVhzqW4Hbgl/Hkn8gwrgCXXUU7ojKWmVR2AIlZE/50xQPBphqPlFQ\nCpiJ/tljCL0+egXaxVm7Z0Jdjxurz2Bd7B5J+W6ekwpM/aP8ykjEmL3kc6blBobq\nxsixpOSn5c5MzbU746t+j5Y=\n-----END PRIVATE KEY-----\n",
  "client_email": "firebase-adminsdk-fplgj@woowa-turkey.iam.gserviceaccount.com",
  "client_id": "109761708146793381222",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fplgj%40woowa-turkey.iam.gserviceaccount.com"
}

Step 4 - 자바 백엔드

Dependency 설정

  • build.gradle
dependencies {
  implementation 'com.google.firebase:firebase-admin:6.8.1'
}

프로젝트로 생성된 비밀키를 이동

Step3 에서 생성한 비밀키 파일을 가져올 수 있도록 이동한다. 원래 깃허브와 같이 공개된 장소에 이 파일을 같이 올리는게 안전하지 않다고 한다. 이번 프로젝트에서는 resource 디렉토리에 저장했다.

FCM 초기화

어플리케이션이 시작될 때 Firebase 프로젝트에 앱을 등록해줘야 한다. (두번 등록 되면 에러가 나므로 시작할 때 초기화 해준다.) @PostConstruct 어노테이션을 활용하여 Bean Object가 생성되고, DI 작업까지 마친 다음 실행시키도록 한다.

  • commons/firebase/FCMInitializer.class
@Service
public class FCMInitializer {

    private static final Logger logger = LoggerFactory.getLogger(FCMInitializer.class);
    private static final String FIREBASE_CONFIG_PATH = "woowa-turkey-firebase-adminsdk.json";

    @PostConstruct
    public void initialize() {
        try {
            FirebaseOptions options = new FirebaseOptions.Builder()
                    .setCredentials(GoogleCredentials.fromStream(new ClassPathResource(FIREBASE_CONFIG_PATH).getInputStream())).build();
            if (FirebaseApp.getApps().isEmpty()) {
                FirebaseApp.initializeApp(options);
                logger.info("Firebase application has been initialized");
            }
        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }

}
.setCredentials(GoogleCredentials.fromStream(new ClassPathResource(FIREBASE_CONFIG_PATH).getInputStream()))

위 라인을 통해 다운받은 비밀키를 가져와서 증명한다.

메세지 보내기

메세지를 보낼 때 필요한 요소는 두가지이다.

  1. 누가한테 보낼지 (토큰) - 토큰과 토픽 방식으로 보낼 수 있는데, 토큰을 사용했다.
  2. 어떤 정보를 보낼지

(FCM 메세지 참조 : https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages)

아래와 같이 Message를 보내는 별도의 서비스를 만들었다. NotificationRequest에는 받을 상대의 토큰값, 푸시될 알림 메세지와 제목을 가지고 있고, 사용은 하지 않았지만 이미지도 지정해서 넣어줄 수 있다.

  • commons/firebase/FCMService.class
@Service
public class FCMService {

    private static final Logger logger = LoggerFactory.getLogger(FCMService.class);

    public void send(final NotificationRequest notificationRequest) throws InterruptedException, ExecutionException {
        Message message = Message.builder()
                .setToken(notificationRequest.getToken())
                .setWebpushConfig(WebpushConfig.builder().putHeader("ttl", "300")
                        .setNotification(new WebpushNotification(notificationRequest.getTitle(),
                                notificationRequest.getMessage()))
                        .build())
                .build();

        String response = FirebaseMessaging.getInstance().sendAsync(message).get();
        logger.info("Sent message: " + response);
    }

}

Message Builder를 통해 만들어진 메세지를 정상적으로 보낸다면 response에는 projects/woowa-turkey/messages/0:1568180254610207%cc9b4facf9fd7ecd 과 같은 message의 값을 리턴받는다.

토큰 관리

푸시 알림은 사용자와 서버가 서로 주고받는 과정으로 통신하는게 아니라 서버에서 일방적으로 사용자에게 보내줘야 한다. 따라서 사용자의 토큰을 서버가 관리하고 있어야 한다.

  1. 토큰 생성

    • NotificationApiController.class

      @RestController
      public class NotificationApiController {
      
          private final NotificationService notificationService;
      
          public NotificationApiController(NotificationService notificationService) {
              this.notificationService = notificationService;
          }
      
          @PostMapping("/register")
          public ResponseEntity register(@RequestBody String token, @LoginUser UserSession userSession) {
              notificationService.register(userSession.getId(), token);
              return ResponseEntity.ok().build();
          }
      
      }

      사용자가 로그인 된후 Firebase에게 전달받은 token 값을 웹서버에게 등록한다.

  • NotificationService.class

    @Service
    public class NotificationService {
        
        ...
    
        private final Map<Long, String> tokenMap = new HashMap<>();
            
    	...
        
        public void register(final Long userId, final String token) {
                tokenMap.put(userId, token);
        }
    }

    사용자의 Id값을 Key, 토큰 값을 Value로 갖는 Map을 사용해서 토큰 값을 관리한다.

  1. 토큰 사용

    • 로그인 된 유저에게만 푸시를 보내줘야한다.

      private void createReceiveNotification(User sender, User receiver) {
          if (receiver.isLogin()) {
              NotificationRequest notificationRequest = NotificationRequest.builder()
                  .title("POST RECEIVED")
                  .token(notificationService.getToken(receiver.getId()))
                  .message(NotificationType.POST_RECEIVED.generateNotificationMessage(sender, receiver))
                  .build();
              notificationService.sendNotification(notificationRequest);
          }
      }
      
      private void createTaggedNotification(User sender, List<User> receivers) {
          receivers.stream()
              .filter(User::isLogin)
              .forEach(receiver -> {
          		NotificationRequest notificationRequest = NotificationRequest.builder()
                      .title("POST TAGGED")
                      .token(notificationService.getToken(receiver.getId()))
                      .message(NotificationType.POST_TAGGED.generateNotificationMessage(sender, receiver))
                      .build();
                  notificationService.sendNotification(notificationRequest);
              });
      }
      
  1. 토큰 삭제

    • 로그아웃 할 때 Map에서 삭제해준다.

      @PostMapping("/logout")
      public ResponseEntity logout(@LoginUser UserSession userSession, HttpSession httpSession) {
          loginService.logout(userSession.getId());
          notificationService.deleteToken(userSession.getId());
          httpSession.removeAttribute(USER_SESSION_KEY);
          return ResponseEntity.ok().build();
      }
      

Step 5 - 프론트엔드

Firebase 자바스크립트 추가

  • script.html
<script src="https://www.gstatic.com/firebasejs/5.9.2/firebase.js"></script>
<script src="https://www.gstatic.com/firebasejs/5.9.2/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/5.9.2/firebase-messaging.js"></script>

Firebase 등록

  • notification.js
const firebaseModule = (function () {
    async function init() {
        // Your web app's Firebase configuration
        if ('serviceWorker' in navigator) {
            window.addEventListener('load', function() {
                navigator.serviceWorker.register('/firebase-messaging-sw.js')
                    .then(registration => {
                        var firebaseConfig = {
                            apiKey: "AIzaSyBWbLdjemTbpsGckTlyDqaATpbI_0usdHw",
                            authDomain: "woowa-turkey.firebaseapp.com",
                            databaseURL: "https://woowa-turkey.firebaseio.com",
                            projectId: "woowa-turkey",
                            storageBucket: "",
                            messagingSenderId: "732784692174",
                            appId: "1:732784692174:web:555e23a165bac677"
                        };
                        // Initialize Firebase
                        firebase.initializeApp(firebaseConfig);


                        // Show Notificaiton Dialog
                        const messaging = firebase.messaging();
                        messaging.requestPermission()
                        .then(function() {
                            return messaging.getToken();
                        })
                        .then(async function(token) {
                            await fetch('/register', { method: 'post', body: token })
                            messaging.onMessage(payload => {
                                const title = payload.notification.title
                                const options = {
                                    body : payload.notification.body
                                }
                                navigator.serviceWorker.ready.then(registration => {
                                    registration.showNotification(title, options);
                                })
                            })
                        })
                        .catch(function(err) {
                            console.log("Error Occured");
                        })
                    })
            })
        }
    }      

    return {
        init: function () {
            init()
        }
    }
})()

firebaseModule.init()

Step 2에서 받은 자바스크립트 스니펫을 사용하여 초기화 시켜준다. messaging.requestPermission()을 사용하면 다음과 같은 권한 요청 화면이 나온다.

messaging.getToken()을 통해 받은 토큰 값을 서버에 보내서 등록해준다.

서비스 워커 등록

  • 서비스 워커 (firebase-messaging-sw.js)
importScripts('https://www.gstatic.com/firebasejs/5.9.2/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/5.9.2/firebase-messaging.js');

firebase.initializeApp({
    messagingSenderId: "732784692174"
});

const messaging = firebase.messaging()

서비스 워커는 브라우저의 백그라운드에서 실행하는 스크립트로, 웹페이지와는 별개로 작동된다. 백그라운드 동기화나 푸시 알림 기능 등을 가능하게 한다.

https://developers.google.com/web/fundamentals/primers/service-workers/?hl=ko

notification.js 의 navigator.serviceWorker.register('/firebase-messaging-sw.js') 에서 서비스 워커를 등록해줄 수 있다.

Chrome - 개발자도구 - Application 탭 - Service Workers 에서 등록된 서비스워커를 볼 수 있다.

사용화면

  • 파이어 폭스 - 마이페이지 글 남김
  • 크롬 - 글 태그

삽질기

서비스 워커 등록

상황

크롬에서는 서비스 워커를 등록했는데, Firebase에서 서비스워커를 등록하지 못하고 404 에러가 나옴.

해결법

서비스워커를 서버의 루트에 위치시켜야함. 현재 상황으로 예를 들면 firebase-messaging-sw.js가 static 디렉토리에 있어서 localhost:8080/firebase-messaging-sw.js 로 접근 가능하도록 만들었음.

자료의 부족

Firebase에 대한 자료와 예제 코드를 찾아보는데 대부분이 안드로이드 환경이었다. 그리고 참고할 사이트가 많지 않다보니 사소한 에러들이 비일비재했다.

참고

https://golb.hplar.ch/2018/01/Sending-Web-push-messages-from-Spring-Boot-to-Browsers.html

https://www.youtube.com/watch?v=BsCBCudx58g&t=310s

10개의 댓글

comment-user-thumbnail
2020년 7월 30일

/녕하세요. NotificationRequest 클래스는 어떻게 구현되어있는지 알 수 있을까요?

1개의 답글
comment-user-thumbnail
2021년 2월 14일

안녕하세요.
.message(NotificationType.POST_RECEIVED.generateNotificationMessage(sender, receiver))
이 부분 어떻게 구현되어잇는지 알 수 있을까요?

2개의 답글