오랜만이에요! 텔링미(https://tellingme.co.kr) 개발팀장이자 서버 개발자인 키태 입니다. 이번 1차 출시에서 푸시알림을 구현하면서 너무나 많은 어려움을 겪었고 레퍼런스가 부족하다고 생각해 모두를 위해 또 저를 위해 저만의 레퍼런스를 작성해볼까 합니다!
개발 환경
사실 참 사소한 글이지만 저는 다른 레퍼런스들을 보면서 저의 개발 환경과 달라 따라하다가 그만두고 실패하고를 많이 반복했기에.. 레퍼런스에 개발 환경을 적어주는 경우가 편했습니다. 다른 분들도 혹시 저의 레퍼런스를 보고 따라하기 전, 자신의 개발 환경과 맞는지 꼭 확인해보세요!
- Spring Framework - 2.5.9
- java - 11
- com.google.firebase:firebase-admin:6.8.1
그리고 저희는 클라이언트와 REST API 방식으로 통신하고 있다는 점! 참고로 저는 철저히 서버 입장에서만 구현 방법을 적을 것이기에 오해 없으시길 바랍니다.
build.gradle 라이브러리 추가
우선 시작하기 전, 라이브러리를 받아야겠죠
implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2'
implementation 'com.google.firebase:firebase-admin:6.8.1'
우선 저는 이렇게 2개만 라이브러리 주입을 해줬습니다.
Firebase 세팅
우선 Firebase 프로젝트를 생성 후에 서비스 계정에 가면
이렇게 '새 비공개 키 생성' 이 있습니다. 이 비공개 키를 생성하면 json 형식의 파일을 다운받을 수 있는데 이를 resources 단에 저장해줍시다. 참고로 말 그대로 '비공개' 키 이니까 서버 개발자분들은 gitignore를 꼭 해주셔야 합니다!
FCM 원리
우선 코드를 짜기 전에 전체적인 원리를 알고가면 편합니다!
- 처음은 클라이언트단에서 Firebase SDK 등을 이용해 토큰을 요청하고 얻어옵니다.
- 이렇게 얻어온 토큰을 서버단에게 보내고 서버는 이 토큰을 DB에 저장합니다. (앞으로 토큰을 푸시토큰이라 하겠습니다.)
- 서버단에서는 푸시토큰을 이용해 Firebase에게 푸시알림을 요청합니다.
- Firebase는 푸시토큰을 확인 후 유효한 토큰으로 확인되면 클라이언트에게 푸시알림을 전송합니다.
- 클라이언트단에서 Firebase로부터 리스너를 통해 푸시알림을 수신합니다.
여기서 보면 알 수 있듯이 서버단에서는 2,3번만 구현해주면 됩니다. 자 이제 시작해봅시다.
2번 과정
사실 2번 과정을 적을게 없습니다. 저같은 경우에는 소셜로그인을 진행하며 추가 정보를 받아오는데 그 과정에서 pushToken 컬럼을 하나 추가해주고 저장해주는 과정이 다입니다.
위 코드를 보시면, joinRequestDto 객체에 회원가입을 위한 추가정보를 받는데 pushToken도 하나 추가해줘서 받고 저장해주는게 다입니다!
3번 과정
3번 과정을 진행하기 전, 다들 물론 잘하시겠지만 저는 약간 어려웠던게 서버 -> Firebase로 요청을 보내야하기에 저희가 클라이언트가 됩니다. 서버 개발자라면 요청을 받기만 했지 요청을 보내는 건 많이 안해보셨을겁니다.(저처럼..)
@Scheduled(cron = "0 0 12 * * *") // 매일 12:00에 실행
public void sendMessageTo() throws IOException {
List<User> userList = userRepository.findAllByAllowNotificationAndUserStatus(true, true); // 푸시알림 동의한 사용자들
for(User user : userList) {
if(user.getPushToken() != null) {
String message = makeMessage(user.getPushToken());
OkHttpClient client = new OkHttpClient();
RequestBody requestBody = RequestBody.create(message,
MediaType.get("application/json; charset=utf-8"));
Request request = new Request.Builder()
.url(API_URL)
.post(requestBody)
.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken())
.addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8")
.build();
Response response = client.newCall(request).execute();
log.info(Objects.requireNonNull(response.body()).string());
}
else {
log.info("알림은 허용이지만, 토큰이 없는 유저");
}
}
}
저희 서비스는 매일 점심(12:00)에 푸시 알림을 보내야했기에 스프링 스케줄링을 통해 구현해놨습니다. 저희는 유저들에게 알림 허용 유무와 토큰을 받기에 매일 12시에 모든 유저를 순회하며 알림이 허용이면서 푸시토큰이 null이 아닌 경우에 요청을 보내는 방식입니다. log로 찍는 경우에는 테스트를 위해 임시로 넣어둔 코드이니 편하신대로 참고하면 될 것 같습니다! Request를 보시면 평소 클라이언트에서 서버단으로 보낼 때 많이 쓰는 application/json이나 charset=utf-8 등이 보이는데 이는 우리가 Firebase로 보내는 요청자가 된 것이니 저한테는 조금 신기했습니다..
위의 코드에서 makeMessage 메소드를 보시면
private String makeMessage(String targetToken) throws JsonProcessingException {
FcmMessage fcmMessage = FcmMessage.builder()
.message(FcmMessage.Message.builder()
.token(targetToken)
.notification(FcmMessage.Notification.builder()
.title("telling me | 나를 깨닫는 시간")
.body("https://tellingme.co.kr\n'나'를 발견할 오늘의 질문이 도착했어요!")
.image("https://velog.velcdn.com/images/kitaee/post/4f2102e2-1608-41fc-b64e-e7624a1324d9/image.jpg")
.build()
).build()).validateOnly(false).build();
이렇게 되어있는데 저희 기획팀에서 푸시알림을 기획할 때
이렇게 이미지도 넣어달라고 요청했기에 이미지 주소를 넣어줬습니다.
그 다음 getAccessToken 메소드의 경우
private String getAccessToken() throws IOException {
String serverPath = "/home/ubuntu/app/src/main/resources/firebase.json";
String localPath = "/Users/kitae/Desktop/server/src/main/resources/firebase.json";
GoogleCredentials googleCredentials = GoogleCredentials.fromStream(new FileInputStream(serverPath))
.createScoped(List.of("https://www.googleapis.com/auth/cloud-platform"));
googleCredentials.refreshIfExpired();
return googleCredentials.getAccessToken().getTokenValue();
}
이렇게 되어있는데 저희 서비스도 클라이언트에서 서버로 통신할 때 accessToken, refreshToken으로 통신을 진행합니다. 그래서 이 경우 이해가 쉬웠는데 당연히 보안 등의 이유로 저희 서버단에서 Firebase로 요청을 보낼 때도 accessToken을 심어줘야 안전하게 통신을 할 수 있습니다. 이 accessToken은 위에 비공개 키의 json 파일에서 받아올 수 있으므로 보안을 위해선 gitignore가 정말 중요하겠죠?
저는 맨 처음에 상대 경로의 classPathResource를 이용하다 도저히 서버에서는 인식을 못하길래 절대 경로를 사용했습니다. 또, 로컬 환경과 서버 환경이 다르기에 각각 절대 경로를 다르게 지정해주고 로컬에서 테스트할 때는 바꾸는 편입니다. gitignore를 해줬다면 서버단에 직접 올려줘야하는건 다들 필수!
푸시알림 결과
이렇게 구현해주면 모든게 끝입니다. 간혹 사용자의 푸시토큰이 세션, 쿠키 등의 이유로 바뀌는 경우가 있다는데 이는 클라이언트 개발자와 협의해서 특정 주기에 확인 후에 바꿔주면 될 듯 합니다.
저희는 매 로그인마다 클라이언트에서 받아온 푸시토큰과 DB에 저장되어있는 토큰을 비교해서 다르면 업데이트해주는 방식을 택했습니다.
이렇게 구현해도 서버단에서 짜증나는 점은 테스트를 혼자 못한다는 점입니다.. ㅎㅎ; 위에서 스케줄링 되어있던 코드를 임시 컨트롤러를 만들어
@GetMapping("/api/notification")
public void notificationTest() throws IOException {
firebaseCloudMessageService.sendMessageTo();
}
이렇게 만들어두면 테스트 때 참 편합니다. 서버뿐만 아니라 클라이언트 개발자도 이 api를 통해 언제든 보내볼 수 있습니다.
마지막으로 결과를 보여드리면,
이렇게 푸시알림이 잘 온걸 확인할 수 있습니다. 맨 밑에 localhost:3000은 아직 클라이언트에서 배포를 안하고 로컬에서 확인했기때문입니다. 배포를 하고나면 tellingme.co.kr로 바뀔 것이고 저 푸시알림을 클릭하면 텔링미 홈페이지로 가집니다.
구현 후기
사실 레퍼런스가 너무 부족하다는 느낌이 들어 공식문서와 수많은 구글링을 통해 겨우 구현했습니다.. 구현했을 때 그 느낌은 아직도 잊을 수 없습니다 ㅠ 저희는 토큰 방식을 사용했는데 추후 토픽 방식으로도 구현할 수 있다니 조금 여유가 생기면 코드 리팩토링과 함께 토픽 방식도 올려보도록 하겠습니다.
궁금하신 점이 있으면 댓글 달아주시면 확인 후에 꼭 답장하겠습니다. 감사합니다:)
'구현' 카테고리의 다른 글
[구현] 멀티스레드 상황에서의 자원 경쟁 (2) (1) | 2024.12.02 |
---|---|
[구현] 멀티스레드 상황에서의 자원 경쟁 (1) (0) | 2024.12.01 |
[구현] 싱글톤(Singleton) 패턴 직접 적용해보기 (0) | 2023.12.06 |
[구현] BaseTimeEntity로 불필요한 코드 줄이기 (JPA) (1) | 2023.11.08 |
[구현] 애플 소셜로그인 탈퇴 / SpringBoot (0) | 2023.07.28 |