Spring Boot에서 FCM 메시지를 보내는 호출 자체는 짧다. 실제 서비스에서는 그 앞의 인증 설정과 수신 대상 관리, 그 뒤의 부분 실패 처리가 더 많은 판단을 요구한다.
이 글은 등록 토큰을 사용하는 기존 FCM 연동을 기준으로 서버 초기화, 발송, 토큰 정리까지 정리한다. 예제 범위는 Spring Boot 3.x, Java 17, Firebase Admin Java SDK 9.8.0이다. 클라이언트의 알림 권한 요청과 플랫폼별 수신 코드는 별도로 구현해야 한다.
버전 범위: 9.8.0은 이 예제의 기준 버전이며 최신 버전이라는 의미는 아니다. 9.10.0 릴리스 노트에는
setToken등 등록 토큰 API의 사용 중단 예정과 FID 기반 API 안내가 추가됐다. 신규 도입이나 업그레이드 시에는 아래 공식 릴리스 노트와 클라이언트 지원 범위를 함께 확인한다.
전체 흐름은 다음과 같다.
클라이언트 → 수신 식별자 등록 → Spring Boot → DB
서비스 이벤트 → 발송 작업 → FCM → 클라이언트
서버의 발송 호출이 성공했다는 것은 FCM이 요청을 받아들였다는 의미다. 사용자가 알림을 확인했다거나, 모든 기기에 즉시 전달됐다는 보장은 아니다. 네트워크 연결, 알림 권한, OS의 백그라운드 제한, 메시지 TTL 등에 영향을 받는다.
FCM 메시지는 크게 두 종류로 구분한다.
| 종류 | 내용 | 클라이언트에서 확인할 것 |
|---|---|---|
| Notification | 제목·본문 등 알림 표시 정보 | 포그라운드와 백그라운드에서 표시 주체가 달라질 수 있다 |
| Data | 애플리케이션이 해석할 문자열 키·값 | OS와 앱 상태에 따라 수신 및 실행이 제한될 수 있다 |
두 내용을 함께 보낼 수도 있다. 예를 들어 Notification에 “새 댓글이 있습니다”를 넣고 Data에 postId를 넣어, 알림을 눌렀을 때 이동할 위치를 결정한다. 개인정보나 반드시 즉시 처리해야 하는 업무를 푸시 전달에만 의존하지 않는다.
“Data 메시지는 앱 상태와 관계없이 항상 콜백에서 실행된다”는 설명은 정확하지 않다. Android, Apple 플랫폼, 웹의 서비스 워커는 수신 조건이 다르므로 각 플랫폼 문서를 확인해야 한다.
의존성은 다음과 같이 지정한다.
implementation 'com.google.firebase:firebase-admin:9.8.0'
서버 인증에는 Application Default Credentials(ADC)를 사용한다. 실행 환경에 연결된 서비스 계정을 활용할 수 있다면 장기 비공개 키 파일을 배포하지 않아도 된다. 외부 키 파일을 사용해야 하는 환경에서는 프로젝트 밖에 보관하고 GOOGLE_APPLICATION_CREDENTIALS로 파일 경로를 제공한다.
GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json
위 경로는 예시다. 실제 파일은 배포 환경의 비밀 관리 기능으로 제공하며, 서비스 계정의 권한도 필요한 범위로 제한한다.
src/main/resources에 키를 넣은 뒤 .gitignore에 추가하는 것만으로는 충분하지 않다. Git에 추적되지 않는 파일도 빌드 산출물에 포함될 수 있기 때문이다. 키의 원문은 소스, JAR, 컨테이너 이미지, 로그에 포함하지 않는다.
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
@Configuration
public class FcmConfig {
@Bean(destroyMethod = "delete")
public FirebaseApp firebaseApp() throws IOException {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.getApplicationDefault())
.build();
return FirebaseApp.initializeApp(options, "push-service");
}
@Bean
public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) {
return FirebaseMessaging.getInstance(firebaseApp);
}
}
예제는 하나의 Spring 컨텍스트가 push-service라는 FirebaseApp을 소유하는 구성이다. 여러 컨텍스트나 여러 Firebase 프로젝트를 사용하는 경우 이름과 수명 주기를 별도로 관리한다.
초기화 오류를 로그로만 남기고 계속 시작하지 않도록 예외를 전파했다. 알림이 선택 기능인 서비스라면 비활성 상태를 명시적으로 노출하는 별도 정책을 만들 수 있다. 초기화에 성공했더라도 실제 발송 권한과 네트워크 연결까지 검증된 것은 아니므로, 테스트 프로젝트에서 발송도 확인해야 한다.
sendEach()는 메시지 목록을 한 메서드 호출로 처리하지만, 각 메시지마다 HTTP 호출을 수행한다. 최대 500개의 요청을 단 한 번의 HTTP 요청으로 줄이는 기능이라고 설명하면 안 된다.
같은 내용을 여러 등록 토큰에 보내는 sendEachForMulticast()도 최대 500개를 받는다. 이는 별도의 “기기 그룹” 기능과 구분한다. sendEach() 계열은 Admin Java SDK 9.2.0에서 도입됐으므로, 9.1.1 의존성과 함께 사용하는 예제는 맞지 않는다.
다음 코드는 호출 결과를 수신 대상과 연결하는 예시다. 입력 순서를 유지하고 결과를 건별로 반환한다. DB 갱신, 재시도 작업 저장, 요청 제한은 호출하는 서비스에서 구현해야 한다.
import com.google.firebase.messaging.BatchResponse;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MessagingErrorCode;
import com.google.firebase.messaging.Notification;
import com.google.firebase.messaging.SendResponse;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class FcmSender {
private final FirebaseMessaging messaging;
public FcmSender(FirebaseMessaging messaging) {
this.messaging = messaging;
}
public record DeliveryResult(int inputIndex, boolean accepted,
String messageId, MessagingErrorCode errorCode) {}
public List<DeliveryResult> send(List<String> tokens, String title, String body)
throws FirebaseMessagingException {
List<String> targets = List.copyOf(tokens);
if (targets.isEmpty()) {
return List.of();
}
if (targets.size() > 500 || targets.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException("유효한 토큰 1~500개가 필요합니다.");
}
Notification notification = Notification.builder()
.setTitle(title)
.setBody(body)
.build();
List<Message> messages = targets.stream()
.map(token -> Message.builder()
.setToken(token)
.setNotification(notification)
.build())
.toList();
BatchResponse response = messaging.sendEach(messages);
List<DeliveryResult> results = new ArrayList<>(targets.size());
for (int i = 0; i < response.getResponses().size(); i++) {
SendResponse item = response.getResponses().get(i);
results.add(new DeliveryResult(
i,
item.isSuccessful(),
item.isSuccessful() ? item.getMessageId() : null,
item.isSuccessful() ? null : item.getException().getMessagingErrorCode()
));
}
return List.copyOf(results);
}
}
BatchResponse의 응답 순서는 입력 메시지 순서와 같다. 호출자는 inputIndex로 원래 발송 작업을 찾아 상태를 갱신한다. 원본 토큰을 결과 로그에 그대로 남길 필요는 없다. accepted는 FCM 접수 여부를 의미하며, 기기의 실제 수신 여부를 표현하지 않는다.
전체 호출이 예외로 끝나면 이 메서드는 예외를 전파한다. 성공한 것처럼 빈 결과를 반환하거나 전체 토큰을 삭제하지 않는다. 특히 네트워크 타임아웃에서는 실제 접수 여부가 불명확할 수 있으므로 재시도로 중복 알림이 생길 가능성도 다뤄야 한다.
| 결과 | 후속 처리 |
|---|---|
| 성공 | 해당 발송 작업의 FCM 접수 성공을 기록 |
UNREGISTERED | 해당 등록 토큰을 비활성화하고 이후 발송에서 제외 |
INVALID_ARGUMENT | 메시지 형식부터 점검. 페이로드가 유효하고 토큰 문제로 확인된 경우에만 토큰 정리 |
UNAVAILABLE, INTERNAL 등 일시 오류 | 문서의 재시도 지침에 따라 제한된 횟수로 재시도 |
| 할당량 관련 오류 | 발송 속도를 줄이고 지연 재시도 |
| 인증·프로젝트 설정 오류 | 설정을 수정. 같은 요청을 빠르게 반복하지 않음 |
INVALID_ARGUMENT는 토큰뿐 아니라 메시지 내용이 잘못됐을 때도 발생한다. 이 코드만 보고 일괄 삭제하면 정상 토큰까지 잃을 수 있다.
부분 실패에서는 재시도 가능한 실패 항목만 다음 작업으로 남긴다. 지수 백오프와 지터를 적용하고, 제공되는 Retry-After 지침을 따른다. 재시도 횟수와 알림의 유효 기한도 제한한다. 이미 만료된 주문 안내를 장애 복구 후 계속 발송하는 것은 도움이 되지 않는다.
알림 중복이 문제가 되는 업무라면 발송 작업 ID를 관리하고, 필요에 따라 클라이언트도 이벤트 ID를 이용해 중복 표시를 제어한다. 일반적인 FCM 발송만으로 사용자 화면의 정확히 한 번 표시를 보장하지는 않는다.
등록 토큰은 영구적인 사용자 ID가 아니다. 사용자 한 명이 여러 기기를 쓸 수 있고, 같은 기기에서 계정을 바꾸거나 로그아웃할 수도 있다.
서버에는 사용자 ID, 앱·기기 식별 정보, 등록 토큰, 플랫폼, 마지막 갱신 시각, 활성 상태 등을 관리한다. 사용자 ID는 클라이언트가 임의로 지정한 값을 신뢰하지 말고 인증된 요청에서 결정한다.
“2개월 후 반드시 삭제”는 모든 서비스에 적용되는 공식 규칙이 아니다. 마지막 갱신 시각과 실제 발송 오류를 함께 사용하고, 다시 활성화된 기기가 등록할 수 있는 흐름도 마련한다.
공개 공지처럼 다수에게 같은 내용을 보낼 때는 토픽을 사용할 수 있다. 다만 클라이언트가 구독할 수 있는 토픽 이름을 개인 정보 접근 권한처럼 취급하면 안 된다. 개인화 알림의 대상 선정과 권한 검증은 서버에서 수행한다.
서버에서 예약 발송을 구현할 때는 예약 시각과 발송 작업 상태를 DB에 저장하고, 스케줄러가 실행할 작업을 가져오게 할 수 있다. 인스턴스가 여러 개라면 같은 작업을 동시에 가져가지 않도록 점유 규칙이 필요하다.
비동기 메서드를 사용한다고 할당량과 자원 제한이 사라지지는 않는다. 동시 발송 수를 제한하고 큐 적체, 요청 지연, 오류율을 관찰한다. 토큰 조회가 실제 병목인지 확인한 뒤 캐시를 추가한다. 캐시로 인해 로그아웃이나 토큰 교체가 늦게 반영되는 비용도 함께 고려한다.
최소한 테스트 프로젝트에서 정상 접수, 잘못된 페이로드, 만료 토큰, 부분 실패, 전체 호출 오류를 나누어 확인한다. Android·iOS·웹에서는 각각 포그라운드와 백그라운드 수신을 검증해야 한다. 이 글은 그 플랫폼 통합 검증 결과를 주장하지 않는다.