👀 1. FCM ?
FCM이란 Firebase Cloud Messaging의 약자로써, 메시지를 무료로 보낼 수 있는 메시징 솔루션입니다.
다양한 플랫폼에서 개발하고 FCM backend에 push 요청만 보내면 FCM backend에서 플랫폼 별로 push를 전송합니다.
👀 2. FCM 사용하는 이유
- 다양한 플랫폼에서 push를 보내기 위해선 플랫폼 환경별로 push 서비스 개발 필요합니다.
- FCM은 중간에서 플랫폼에 종속되지 않고도 push 전송 가능합니다.
- 서버로부터 push 알림을 받기 위해서는 client가 서버에 계속 접속해야 합니다. 이는 전력 사용, 네트워크 효율 문제를 야기합니다.
- FCM은 이를 어느 정도 해결해줄 수 있다는 점이 장점입니다.
🪜 3. Spring Boot - FCM 설정
가장 먼저 firebase console에 접속하여 프로젝트를 생성합니다.
https://console.firebase.google.com/?hl=ko
[프로젝트] -> [프로젝트 개요] -> [프로젝트 설정]을 클릭합니다.
[프로젝트 설정] - [서비스 계정] 항목에서 [새 비공개 키 생성]을 클릭합니다.
생성된 json 파일은 src/main/resourcs 에 위치시킵니다.
해당 파일은 FCM 초기화 시 해당 키 인증정보를 이용합니다.
build.gradle
//firebase
implementation 'com.google.firebase:firebase-admin:8.1.0'
application.yml
FCM에서 발급 받은 키 경로를 지정합니다.
fcm:
path: test-firebase-adminsdk-efjfl-a513eecf0c.json
scope: https://www.googleapis.com/auth/cloud-platform
expire-time: 36000000 # 10시간
FCMConfig.java
스프링 bean이 모두 올라가고 DI가 끝난 이후 FCM 초기화 작업을 진행합니다.
@Slf4j
@Configuration
@RequiredArgsConstructor
public class FCMConfig {
private final FCMProperties properties;
/*
1) init Method
- 스프링이 올라가며 오브젝트 생성과 DI 작업까지 마친 직후 실행
- 생성자에서 해도 좋지만, 혹시 해당 bean을 주입받는 다른 변수나 프로퍼티가 초기화 되지 않을 가능성 있음
- 따라서 해당 bean을 주입받는 모든 의존관계 변수와 프로퍼티의 DI가 끝난 시점 이후에 초기화하기 위해 사용
*/
@PostConstruct
public void init() {
String databaseName = properties.getScope();
String accountPath = properties.getPath();
try {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(new ClassPathResource(accountPath).getInputStream()))
.build();
if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(options);
log.info("Firebase application has been initialized");
}
} catch (IOException e) {
throw new FCMInitializedException("Failed to initialize fcm.", e);
}
}
}
FCMInitializedException.java
FCM 초기화와 관련된 예외처리 코드입니다.
public class FCMInitializedException extends RuntimeException {
public FCMInitializedException(String message, Throwable cause) {
super(message, cause);
}
}
FCMProperties.java
- Properties 설정 이후에는 추가적으로 다음의 Spring Boot Application 어노테이션을 선언합니다.
- `@EnableConfigurationProperties(value = {FCMProperties.class})`
- ConfigurationProperties를 사용하면 Compile Level에서 설정 오류를 발견할 수 있습니다.
@Getter
@Validated
@ConstructorBinding
@ConfigurationProperties(prefix = "fcm")
public class FCMProperties {
@NotBlank
private final String scope;
@NotBlank
private final String path;
@NotNull
private final Long expireTime;
public FCMProperties(String scope, String path, String expireTime) {
this.scope = scope;
this.path = path;
this.expireTime = Long.parseLong(expireTime);
}
}
FCMRedisConfig.java
토큰을 저장하는 장소로 DB도 있겠지만 Redis로 간편하게 구현할 수 있습니다.
@Configuration
public class FCMRedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Value("${spring.redis.password}")
private String redisPassword;
@Bean
public RedisConnectionFactory redisTokenConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(redisHost, redisPort);
redisStandaloneConfiguration.setPassword(redisPassword);
LettuceClientConfiguration lettuceClientConfiguration = LettuceClientConfiguration.builder()
.clientOptions(ClientOptions.builder().build())
.build();
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration, lettuceClientConfiguration);
lettuceConnectionFactory.setValidateConnection(true);
return lettuceConnectionFactory;
}
@Bean(name = "fcmRedisTemplate")
public StringRedisTemplate stringRedisTemplate() {
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
stringRedisTemplate.setConnectionFactory(redisTokenConnectionFactory());
return stringRedisTemplate;
}
}
FCMRequest.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@JsonNaming(UpperSnakeCaseStrategy.class)
public class FCMRequest {
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@JsonNaming(UpperSnakeCaseStrategy.class)
public static class SubscribeRequest {
String token;
@Builder
public SubscribeRequest(String token) {
this.token = token;
}
}
String userId;
String title;
String message;
@Builder
public FCMRequest(String userId, String title, String message) {
this.userId = userId;
this.title = title;
this.message = message;
}
}
FCMController.java
로그인, 로그아웃 시 구독 / 구독 해제가 진행되게끔 합니다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/fcm")
public class FCMController {
private final FCMService fcmService;
/* /api/fcm/unsubscribe */
@PostMapping("/subscribe")
public void subscribe(@RequestBody FCMRequest.SubscribeRequest subscribeRequest, @UserSession UserVo userVo) {
fcmService.saveToken(userVo.getUserId(), subscribeRequest.getToken());
}
/* /api/fcm/unsubscribe */
@PostMapping("/unsubscribe")
public void subscribe(@UserSession UserVo userVo) {
fcmService.deleteToken(userVo.getUserId());
}
}
FCMDao.java
@Slf4j
@Repository
@RequiredArgsConstructor
public class FCMDao {
private final StringRedisTemplate fcmRedisTemplate;
private static final String FCM_PREFIX = "FCM_";
public void saveToken(String userId, String token) {
fcmRedisTemplate.opsForValue()
.set(FCM_PREFIX + userId, token);
}
public String getToken(String userId) {
return fcmRedisTemplate.opsForValue().get(FCM_PREFIX + userId);
}
public void deleteToken(String userId) {
fcmRedisTemplate.delete(FCM_PREFIX + userId);
}
public Boolean hasKey(String userId) {
return fcmRedisTemplate.hasKey(FCM_PREFIX + userId);
}
}
MessagingService.java
public interface MessageService {
void sendPush(FCMRequest fcmRequest);
}
FCMService.java
FCM 서버로 메시지 전송 시 `sendAsync()`를 사용하여 비동기처리 하도록 하였습니다.
서버는 FCM 서버로부터 응답 결과에 대해 기다릴 필요가 없으므로 Throughput에 이점이 있습니다.
@Slf4j
@Service
public class FCMService implements MessageService {
private final FCMDao FCMDao;
@Builder
public FCMService(FCMDao FCMDao) {
this.FCMDao = FCMDao;
}
@Override
public void sendPush(FCMRequest fcmRequest) {
if (!hasKey(fcmRequest.getUserId())) {
log.info("No Such Device Id : {}", fcmRequest.getUserId());
return;
}
String token = getToken(fcmRequest.getUserId());
Message message = writeWebMessage(fcmRequest, token);
send(message);
}
private Message writeWebMessage(FCMRequest request, String token) {
return Message.builder()
.setWebpushConfig(WebpushConfig.builder()
.setNotification(WebpushNotification.builder()
.setTitle(request.getTitle())
.setBody(request.getMessage())
.build())
.build())
.setToken(token)
.build();
}
public void saveToken(String userId, String token) {
FCMDao.saveToken(userId, token);
}
public void deleteToken(String userId) {
FCMDao.deleteToken(userId);
}
public boolean hasKey(String userId) {
return FCMDao.hasKey(userId);
}
public String getToken(String userId) {
return FCMDao.getToken(userId);
}
private void send(Message message) {
ApiFuture<String> response = FirebaseMessaging.getInstance().sendAsync(message);
try {
log.info("Success to send message : " + response.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
throw new IllegalStateException("Send Failed", e);
}
}
}
📗 **. 출처
'Spring' 카테고리의 다른 글
스프링 시큐리티 - Method Security (0) | 2023.09.15 |
---|---|
[Mybatis] Mybatis에서 DTO로 분리하기 (0) | 2023.03.16 |
@Transactional Annotation 정리 (0) | 2022.09.13 |
빈 등록 어노테이션 @Configuration, @Component, @Bean에 대해서 (2) | 2022.08.27 |
세션 vs JWT (0) | 2022.06.21 |