이번에는 FCM 토큰 관리 방법 및 스프링에 FCM을 작성하는 방법에 대해 알아보겠습니다.
FCM 토큰
FCM을 이용하기 위해서는 토큰 (token)을 관리해야 합니다.
FCM은 클라이언트가 앱을 처음 시작할 때 토큰을 만들고, 이 토큰을 활용해서 토큰의 소비자에게 코드를 전달하는 방식을 이용하고 있습니다. 토큰 재발급은 새 기기에서 앱을 복원하거나 앱을 제거/재설치하는 경우, 사용자가 앱 데이터를 소거하는 경우 이뤄집니다.
토큰을 어디에 저장할까? (RDB vs Redis - Redis)
토큰을 저장하는 데에는 크게 RDB와 Redis가 있습니다. RDB를 이용하면 토큰이 데이터베이스에 저장되어 있기 때문에 애플리케이션이 꺼지더라도 정보가 저장될 수 있습니다. 그러나 알림을 보내는 과정마다 RDB에 접속하여 토큰을 가져오는 만큼, 이 과정에서의 커넥션 부담이 발생합니다.
반면 Redis를 이용하면 인-메모리 구조로 토큰이 저장되기에 RDB 방식보다 더 빠르게 가져올 수 있습니다. 비록 인-메모리이기 때문에 휘발성의 위험이 따르지만, 이는 향후 주기적으로 스냅샷을 만들어 해결하는 등의 해결법이 존재할 수 있을 것으로 생각하여 토큰은 레디스를 이용해 적용하기로 하였습니다.
FCM 설정에 필요한 파일 다운로드
FCM 콘솔 - 프로젝트 만들기 - 프로젝트 설정 - 서비스 계정 - 자바 - 새 비공개 키 생성을 통해 스프링에서 필요한 FCM 관련 json 파일을 만들 수 있습니다. 해당 파일을 src/main/resources에 firebase.json으로 보관합니다.
스프링 적용 방법
이제 본격적으로 FCM을 스프링에 적용하는 방식을 알려드리겠습니다.
필요한 구현체 등록하기
우선 FCM과 레디스 (등록하지 않았을 시)를 build.gradle에 등록합니다.
dependencies {
// JPA, MySQL 등 기타 필요한 것들도 등록되어 있다고 간주
...
// Firebase
implementation 'com.google.firebase:firebase-admin:9.3.0'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
FCM 버전
처음에는 다른 글들을 참고하면서 8.0.0 버전을 이용했었으나, 9.3.0 (24.07 기준 최신 버전) 버전에서의 파이어베이스의 업데이트 내역을 보니 sendEachAsync, 트래픽 처리 개선 등의 수정 사항이 발견되었고 이는 알림 기능이 발전됨에 따라 유용하게 쓰일 수 있겠다고 생각이 들어 버전 업을 하였습니다.
Spring Data Redis
스프링에서는 레디스를 이용할 때 Spring Data Redis를 통해 손쉽게 이용할 수 있도록 제공하고 있습니다. 본 프로젝트를 하면서 레디스에 대해 공부하고 있는데, 자세한 내용은 별도의 글에서 다루겠습니다.
알림 도메인 설계하기 (alert/domain)
프로젝트에서 사용되는 알림보다는 일단 보여드리기 위해 간단한 알림 도메인을 아래와 같이 설계해 보았습니다.
// 기타 어노테이션은 생략
@Getter
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Alert extends SoftDeleteBaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Boolean isRead;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String body;
@Column(nullable = false)
private Long receiverId;
public static Alert createWith(final String title, final String body, final Long receiverId) {
return Alert.builder()
.title(title)
.body(body)
.receiverId(receiverId)
.isRead(false)
.build();
}
public void read() {
if (!isRead) {
this.isRead = true;
}
}
}
@Getter
@MappedSuperClass
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public abstract class SoftDeleteBaseEntity extends BaseEntity {
private LocalDateTime deletedAt;
}
@Getter
@MappedSuperClass
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
}
@EnableJpaAuditing // BaseEntity 위해 필요
@SpringBootApplication
public class FcmDemoApplication {
public static void main(String[] args) {
SpringApplication.run(FcmDemoApplication.class, args);
}
}
- Alert는 알림 엔티티를 의미합니다. 읽음 기능이 별도로 존재합니다. 본 프로젝트는 DDD 구조를 따르기에 최대한 의존성이 줄어들어야 한다고 판단, 수신 대상이 되는 회원의 ID만을 가지도록 설계하였습니다.
- SoftDeleteBaseEntity는 삭제 시각을 추가로 가질 수 있도록 해 줍니다. (soft delete를 진행한다고 가정합니다.)
- BaseEntity는 생성/수정 시각을 추가로 가질 수 있도록 해 줍니다.
- 메인 애플리케이션에 @EnableJpaAuditing을 두어 시간이 기록되도록 합니다.
도메인 리포지터리 (AlertRepository, AlertTokenRepository)
public interface AlertRepository {
Alert save(Alert alert);
// 기타 다른 메서드들은 생략
}
public interface AlertTokenRepository {
void saveToken(Long id, String token);
String getToken(Long id);
void deleteToken(Long id);
boolean hasKey(Long id);
}
- 도메인 단에는 AlertRepository와 AlertTokenRepository를 두었습니다.
- AlertRepository는 실제 데이터베이스에 알림을 통계 정보 등을 위해 저장해야 할 필요가 있어 설계하였습니다.
- AlertTokenRepository는 FCM 토큰을 <회원 ID, 회원 토큰> 형태로 레디스에 관리하기 위해 설계하였습니다.
- 각 구현체에 대해서는 프로젝트 Pull Request를 통해 참고하실 수 있습니다. (AlertRepositoryImpl, FirebaseRedisTokenRepository)
AlertManager 인터페이스 (alert/application)
알림을 실질적으로 보내는 AlertManager 인터페이스 및 이에 대한 구현체인 FirebaseAlertManager (alert/infrastructure)를 다음과 같이 설계하였습니다.
public interface AlertManager {
void send(Alert alert, String sender, String token);
}
@Slf4j
@Component
public class FirebaseAlertManager implements AlertManager {
private static final String EXECUTOR = "alertCallbackExecutor";
private static final String SENDER = "sender";
private static final String CREATED_TIME = "created_at";
private static final String BODY = "body";
private static final String TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
private static final String THREAD_PROBLEM = "FCM 스레드에서 문제가 발생했습니다.";
private static final String ALERT_FAIL = "알림 전송 실패";
private static final String ALERT_RETRY_FAIL = "알림 재전송 실패";
private static final String ALERT_THREAD_WAIT_FAIL = "알림 재전송 스레드 대기 예외";
private static final int RETRY_MAX = 3;
private static final int[] RETRY_TIMES = {1000, 2000, 4000};
private final Executor executor;
public FirebaseAlertManager(@Qualifier(value = EXECUTOR) final Executor executor) {
this.executor = executor;
}
@Override
public void send(final Alert alert, final String sender, final String token) {
Message firebaseMessage = createAlertMessage(alert, sender, token);
FirebaseMessaging firebase = FirebaseMessaging.getInstance();
ApiFuture<String> process = firebase.sendAsync(firebaseMessage);
Runnable task = () -> logAlertResult(process, firebaseMessage);
process.addListener(task, executor);
}
private Message createAlertMessage(final Alert alert, final String sender, final String token) {
LocalDateTime createdAt = alert.getCreatedAt();
String time = createdAt.format(DateTimeFormatter.ofPattern(TIME_FORMAT));
Notification firebaseNotification = Notification.builder()
.setTitle(alert.getTitle())
.build();
return Message.builder()
.setToken(token)
.setNotification(firebaseNotification)
.putData(SENDER, sender)
.putData(CREATED_TIME, time)
.putData(BODY, alert.getBody())
.build();
}
private void logAlertResult(final ApiFuture<String> process, final Message message) {
try {
process.get();
} catch (InterruptedException exception) {
log.error(THREAD_PROBLEM);
} catch (ExecutionException exception) {
log.error(ALERT_FAIL);
retryAlert(message, exception);
}
}
private void retryAlert(final Message message, final ExecutionException exception) {
try {
Throwable cause = exception.getCause();
FirebaseMessagingException firebaseException = (FirebaseMessagingException) cause;
MessagingErrorCode errorCode = firebaseException.getMessagingErrorCode();
if (!isRetryErrorCode(errorCode)) {
return;
}
retryInThreeTimes(message);
} catch (ClassCastException e) {
return;
}
}
private boolean isRetryErrorCode(final MessagingErrorCode errorCode) {
return errorCode.equals(MessagingErrorCode.INTERNAL) || errorCode.equals(MessagingErrorCode.UNAVAILABLE);
}
private void retryInThreeTimes(final Message message) {
int retryCount = 0;
while (retryCount < RETRY_MAX) {
if (!shouldRetry(message, retryCount)) {
break;
}
retryCount++;
}
if (retryCount == RETRY_MAX) {
log.error(ALERT_RETRY_FAIL);
}
}
private boolean shouldRetry(final Message message, final int retryCount) {
wait(retryCount);
FirebaseMessaging firebase = FirebaseMessaging.getInstance();
try {
firebase.sendAsync(message).get();
} catch (Exception exception) {
return shouldRetryFirebaseException(exception);
}
return false;
}
private boolean shouldRetryFirebaseException(final Exception exception) {
try {
Throwable cause = exception.getCause();
FirebaseMessagingException firebaseException = (FirebaseMessagingException) cause;
MessagingErrorCode errorCode = firebaseException.getMessagingErrorCode();
return isRetryErrorCode(errorCode);
} catch (ClassCastException e) {
return false;
}
}
private void wait(final int retryCount) {
try {
Thread.sleep(RETRY_TIMES[retryCount]);
} catch (InterruptedException exception) {
log.error(ALERT_THREAD_WAIT_FAIL);
}
}
}
코드가 복잡한데, 전체적으로 설명한 뒤 각 과정에 대해 다시 말씀드리겠습니다.
이벤트 관리
DDD에서는 의존성을 줄이면서 다른 애그리거트 간 상호작용을 할 수 있도록 이벤트를 지원합니다. 예를 들어 회원이 로그인했다면 토큰 발급을 위한 이벤트를 발생시키고, 호감 서비스에서 상대방에게 호감을 보냈다면 알림 생성 이벤트를 발생시킴에 따라 알림 서비스가 실행되도록 할 수 있습니다.
@RequiredArgsConstructor
@Configuration
public class EventsConfiguration {
private final ApplicationContext applicationContext;
@Bean
public InitializingBean eventInitializer() {
return () -> Events.setPublisher(applicationContext);
}
}
@Getter
public abstract class Event {
private final Long timestamp;
protected Event() {
this.timestamp = System.currentTimeMillis();
}
}
public class Events {
private static ApplicationEventPublisher publisher;
public static void setPublisher(final ApplicationEventPublisher publisher) {
Events.publisher = publisher;
}
public static void raise(final Object object) {
if (publisher != null) {
publisher.publishEvent(event);
}
}
}
public record AlertCreatedEvent(
String title,
String body,
String sender,
Long receiverId
) {
}
public record AlertTokenCreatedEvent(
Long id,
String token
) {
}
@RequiredArgsConstructor
@Component
public class AlertEventHandler {
private static final String ASYNC_EXECUTOR = "asyncExecutor";
private final AlertService alertService;
@Async(value = ASYNC_EXECUTOR)
@TransactionalEventListener
public void sendAlertCreatedEvent(final AlertCreatedEvent event) {
alertService.sendAlert(event.title(), event.body(), event.sender(), event.receiverId());
}
@TransactionalEventListener
public void sendAlertTokenCreatedEvent(final AlertTokenCreatedEvent event) {
alertService.saveToken(event.id(), event.token());
}
}
- EventsConfiguration, Event, Events은 스프링에서 이벤트를 실행시키기 위해 필요한 파일입니다.
- AlertCreatedEvent, AlertTokenCreatedEvent는 외부 서비스 (ex: 로그인 서비스, 호감 서비스)에서 알림 서비스에게 이벤트를 발생시킬 때 필요한 이벤트 형식을 의미합니다. 레코드의 장점을 이용하고자 레코드 형태로 설계하였습니다.
- AlertEventHandler는 위의 알림 이벤트를 수신했을 때 알림 서비스의 기능이 동작하도록 할 때 쓰입니다. 비동기로 동작하며, TransactionalEventListener로 두었습니다. 이 또한 추가적으로 말씀드리겠습니다.
파이어베이스 설정
@Configuration
public class FirebaseConfig {
private static final String FIREBASE_FILE_URL = "src/main/resources/firebase.json";
private static final String FIREBASE_FILE_EXCEPTION_MESSAGE = "FCM 파일 변환 과정에서 예외가 발생했습니다.";
@Bean
public FirebaseApp firebaseApp() {
if (!FirebaseApp.getApps().isEmpty()) {
return FirebaseApp.getInstance();
}
ThreadManager threadManager = new FirebaseThreadManager();
FirebaseOptions options = new FirebaseOptions.Builder()
.setThreadManager(threadManager)
.setCredentials(getCredentials())
.build();
return FirebaseApp.initializeApp(options);
}
private GoogleCredentials getCredentials() {
try {
FileInputStream serviceAccount = new FileInputStream(FIREBASE_FILE_URL);
return GoogleCredentials.fromStream(serviceAccount);
} catch (IOException e) {
throw new RuntimeException(FIREBASE_FILE_EXCEPTION_MESSAGE);
}
}
}
@Component
public class FirebaseThreadManager extends ThreadManager {
private static final int FIREBASE_THREADS_SIZE = 40;
@Override
protected ExecutorService getExecutor(final FirebaseApp firebaseApp) {
return Executors.newFixedThreadPool(FIREBASE_THREADS_SIZE);
}
@Override
protected void releaseExecutor(final FirebaseApp firebaseApp, final ExecutorService executorService) {
executorService.shutdownNow();
}
@Override
protected ThreadFactory getThreadFactory() {
return Executors.defaultThreadFactory();
}
}
- 프로젝트에서는 firebase.json을 공유하게 되는 과정을 방지하고자 application.yml에 각 요소들을 전부 암호화한 뒤 만드는 방식으로 이용하였으나, 빠른 설명을 위해 파일을 다운로드하고 읽어 들이는 코드를 작성하였습니다.
- FirebaseThreadManager는 파이어베이스에서 사용되는 스레드를 관리하기 위해 작성하였습니다.
비동기 설정
마지막으로 설정한 파일은 비동기와 관련된 파일입니다. 스프링의 @Async를 이용하는 과정에서 비동기로 이용되는 스레드를 별도로 설정할 수 있습니다.
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {
private static final String ASYNC_EXECUTOR = "asyncExecutor";
private static final String ALERT_CALLBACK_EXECUTOR = "alertCallbackExecutor";
private static final String THREAD_PREFIX_NAME = "ATWOZ_ASYNC_THREAD: ";
private static final String CALLBACK_THREAD_PREFIX_NAME = "ATWOZ_ALERT_THREAD: ";
private static final int DEFAULT_THREAD_SIZE = 30;
private static final int MAX_THREAD_SIZE = 40;
private static final int QUEUE_SIZE = 100;
private static final boolean WAIT_TASK_COMPLETE = true;
private static final int DEFAULT_CALLBACK_THREAD_SIZE = 10;
@Bean(name = ASYNC_EXECUTOR)
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(DEFAULT_THREAD_SIZE);
executor.setMaxPoolSize(MAX_THREAD_SIZE);
executor.setQueueCapacity(QUEUE_SIZE);
executor.setThreadNamePrefix(THREAD_PREFIX_NAME);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE);
executor.initialize();
return executor;
}
@Bean(name = ALERT_CALLBACK_EXECUTOR)
public Executor getAsyncCallbackExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(DEFAULT_CALLBACK_THREAD_SIZE);
executor.setThreadNamePrefix(CALLBACK_THREAD_PREFIX_NAME);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE);
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new AsyncExceptionHandler();
}
}
@Slf4j
@Component
public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
private static final String ASYNC_MESSAGE_PREFIX = "Async Error Message = ";
@Override
public void handleUncaughtException(final Throwable throwable, final Method method, final Object... params) {
log.warn(ASYNC_MESSAGE_PREFIX + "{}", throwable.getMessage());
}
}
- AsyncConfig는 비동기 설정 파일을 의미합니다.
- AsyncExceptionHandler는 비동기에서 예외가 발생했을 때 로그 메시지를 남기는 데 사용됩니다. 아직 별도로 논의된 게 없어 우선은 단순히 로그 메시지로만 남겨두기로 하였습니다.
실제 사용
실제로 사용할 경우는 아래처럼 WebService 등 외부 서비스에서 이벤트를 호출시키면 됩니다.
@RequiredArgsConstructor
@RequestMapping("/sample")
@RestController
public class WebController {
private final WebService webService;
@PostMapping("/alert")
public ResponseEntity<String> send(@RequestBody final AlertRequest request) {
webService.call(request);
return ResponseEntity.ok("알림 전송 완료");
}
}
@RequiredArgsConstructor
@Transactional // 별도의 트랜잭션 작업이 이루어진다고 가정합니다.
@Service
public class WebService {
// 데모를 위해 발신자와 수신자 id는 상수 값을 이용합니다.
private static final String SENDER = "발신자";
private static final Long RECEIVER_ID = 1L;
public void call(final AlertRequest request) {
// 코드 작성..
Events.raise(new AlertCreatedEvent(request.title(), request.body(), SENDER, RECEIVER_ID));
}
}
@RequiredArgsConstructor
@Transactional
@Service
public class AlertService {
private static final String TOKEN = "...."; // 실제 FCM 토큰으로 테스트 해 보시면 됩니다.
private final AlertRepository alertRepository;
private final AlertTokenRepository tokenRepository;
private final AlertManager alertManager;
// 토큰 저장
public void saveToken(final Long id, final String token) {
tokenRepository.saveToken(id, token);
}
// 알림 전송
public void sendAlert(final String title, final String body, final String sender, final Long receiverId) {
// String token = tokenRepository.getToken(receiverId); 프로젝트에서는 레디스를 이용해서 토큰을 조회합니다.
Alert alert = Alert.createWith(title, body, receiverId);
Alert savedAlert = alertRepository.save(alert);
alertManager.send(savedAlert, sender, TOKEN);
}
}
- WebService 등 외부 서비스가 이벤트를 호출하면 그에 맞는 AlertService 로직이 실행됩니다.
- 토큰이 저장되어 있다고 가정하겠습니다.
- 알림이 DB에 저장된 시각을 클라이언트에게 전달해야 하기에 savedAlert를 AlertManager에 전달하도록 하였습니다.
결론
본 글은 파이어베이스 토큰 관리 방법에 대한 고민과, 실습을 하실 수 있도록 코드를 먼저 보여드리는 데 집중하였습니다.
다음 글 부터는 코드를 작성함에 있어 들었던 다른 고민들과 결론 과정들을 하나씩 작성하겠습니다.
추가로 걸어둔 데모 코드 리포지터리에서 실습을 해보실 수 있습니다.