📘FCM 토큰 저장 및 알림 전송 기능 고도화. 토큰 관리부터 안정적인 전송까지
기본적인 FCM 전송 기능을 구현한 뒤, 실제 서비스에 적용하기 위해 토큰을 DB에 저장하고 관리하는 구조로 확장한다.
이번에는 저장된 토큰을 조회해 알림을 전송하고, 전송 중 발생할 수 있는 예외 상황까지 처리할 수 있도록 구현한다.
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "fcm_token", uniqueConstraints = {
@UniqueConstraint(name = "uq_user_token", columnNames = {"user_id", "device_id"})
})
public class FcmToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false)
private String token;
@Column(name = "device_id", nullable = false)
private String deviceId;
@Enumerated(EnumType.STRING)
@Column(name = "platform_type", nullable = false)
private PlatformType platformType;
public void updateToken(String newToken) {
this.token = newToken;
}
}
@Table에 user_id + device_id 복합 유니크 조건을 걸어 한 사용자의 동일 디바이스에 대해 중복 저장을 방지한다.token : Firebase에서 발급된 FCM 토큰deviceId : 클라이언트에서 식별할 수 있는 디바이스 정보 (UUID 등)platformType : WEB / ANDROID / IOS 등 플랫폼 구분을 위한 enumupdateToken : 토큰 갱신을 위한 업데이트 메서드도 정의public record FcmCreateRequestDto(
String token, String deviceId, PlatformType platformType) {
public FcmToken toEntity(Long userId) {
return FcmToken.builder()
.userId(userId)
.token(token)
.deviceId(deviceId)
.platformType(platformType)
.build();
}
}
toEntity 메서드를 통해 FcmToken 엔티티로 바로 변환할 수 있다.@PostMapping
public ResponseEntity<ApiResponse<Void>> createToken(
@RequestBody FcmCreateRequestDto dto,
@AuthenticationPrincipal AuthUser authUser) {
fcmService.createToken(authUser.getId(), dto);
return ResponseEntity.ok(ApiResponse.success("저장완료", null));
}
userId를 인증 객체에서 가져와 서비스로 전달한다."저장완료" 메시지를 반환한다.@Override
public void createToken(Long userId, FcmCreateRequestDto requestDto) {
Optional<FcmToken> existingToken =
fcmTokenRepository.findByUserIdAndDeviceId(userId, requestDto.deviceId());
if (existingToken.isPresent()) {
// 기존 디바이스의 토큰 갱신
FcmToken findToken = existingToken.get();
findToken.updateToken(requestDto.token());
} else {
// 새로운 디바이스 등록
FcmToken token = requestDto.toEntity(userId);
fcmTokenRepository.save(token);
}
}
userId와 deviceId로 기존 토큰이 있는지 조회한다.updateToken()으로 새 값으로 갱신한다.FcmToken 엔티티를 만들어 저장한다.FcmSender 인터페이스public interface FcmSender {
PlatformType handles();
void send(FcmMessageDto dto) throws Exception;
}
handles()는 자신이 처리할 플랫폼을 명시한다. → ex) PlatformType.WEBsend()는 실제로 해당 플랫폼 디바이스에 메시지를 전송하는 책임을 가진다.WebFcmSender, AndroidFcmSender 등의 구현체를 만들게 된다.@Service
public class WebFcmSender implements FcmSender {
@Override
public PlatformType handles() {
return PlatformType.WEB;
}
@Override
public void send(FcmMessageDto dto) throws Exception {
Message message = Message.builder()
.setToken(dto.getToken())
.setNotification(Notification.builder()
.setTitle(dto.getType().getLabel())
.setBody(dto.getContent())
.build())
.build();
FirebaseMessaging.getInstance().send(message);
}
}
@Service
public class AndroidFcmSender implements FcmSender {
@Override
public PlatformType handles() {
return PlatformType.ANDROID;
}
@Override
public void send(FcmMessageDto dto) throws Exception {
Message message = Message.builder()
.setToken(dto.getToken())
.setNotification(Notification.builder()
.setTitle(dto.getType().getLabel())
.setBody(dto.getContent())
.build())
.setAndroidConfig(AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
.setNotification(AndroidNotification.builder()
.setSound("default") // 사운드 설정
.build())
.build())
.build();
FirebaseMessaging.getInstance().send(message);
}
}
@Service
public class IosFcmSender implements FcmSender {
@Override
public PlatformType handles() {
return PlatformType.IOS;
}
@Override
public void send(FcmMessageDto dto) throws Exception {
Message message = Message.builder()
.setToken(dto.getToken())
.setNotification(Notification.builder()
.setTitle(dto.getType().getLabel())
.setBody(dto.getContent())
.build())
.setApnsConfig(ApnsConfig.builder()
.setAps(Aps.builder()
.setSound("default") // iOS 사운드 설정
.setBadge(1) // 뱃지 1 증가
.build())
.build())
.build();
FirebaseMessaging.getInstance().send(message);
}
}
@Override
public void processFcmIfTokenExists(FcmMessageDto messageDto) {
//userId 로 토큰 리스트 생성 -> 유저가 가지고 있는 모든 토큰에 전송
List<FcmToken> tokens = fcmTokenRepository.findValidTokens(messageDto.getUserId());
//토큰이 하나도 없으면 전송하지 않고 로그만 남긴다
if (tokens.isEmpty()) {
log.warn("유효한 토큰이 없습니다.");
return;
}
//전송 실패 리스트 생성 -> 실패한 토큰 삭제 예정
List<FcmToken> failedList = new ArrayList<>();
//하나라도 전송 성공 여부를 추적
boolean successAtLeastOnce = false;
for (FcmToken fcmToken : tokens) {
// 플랫폼에 맞는 전송 전략(FcmSender) 조회
FcmSender fcmSender = getOrSkipSender(fcmToken, failedList);
if (fcmSender == null)
continue;
// 전송 대상 토큰을 DTO에 주입
messageDto.updateToken(fcmToken.getToken());
// 실제 전송 시도
boolean success = sendMessage(fcmSender, messageDto);
// 하나라도 성공하면 true 유지
if (success) {
successAtLeastOnce = true;
} else {
failedList.add(fcmToken);
}
}
// 실패한 토큰은 정리
deleteFailedTokens(failedList);
// 토큰은 있었지만 모두 전송 실패했을 경우 로그 남김
if (!successAtLeastOnce) {
log.warn("FCM 전송 모두 실패");
}
}
FcmSender)로 메시지 전송//플랫폼 생성자 리스트 주입
private final List<FcmSender> fcmSenders;
//지원하는 플랫폼 서비스 선택
private FcmSender getOrSkipSender(FcmToken token, List<FcmToken> failedList) {
return fcmSenders.stream()
.filter(sender -> sender.handles() == token.getPlatformType())
.findFirst()
.orElseGet(() -> {
log.warn("지원하지 않는 플랫폼");
failedList.add(token);
return null;
});
}
fcmSenders: FcmSender 구현체들(Web, Android, iOS 등)을 DI 받아 리스트로 가지고 있음token.getPlatformType()에 맞는 구현체(handles() 반환값 기준)를 찾아 반환failedList.add(token))null 반환하여 전송에서 제외private boolean sendMessage(FcmSender sender, FcmMessageDto messageDto) {
try {
//플랫폼 Sender 에 send 메서드로 전송
sender.send(messageDto);
return true;
} catch (Exception e) {
log.warn("FCM 전송 실패");
}
return false;
}
FcmSender 구현체의 send() 메서드를 호출해 메시지를 전송한다.true 반환warn 로그를 남기고 false 반환failedList에 토큰 추가 및 후처리에 사용됨//전송 실패 토큰 리스트 삭제
private void deleteFailedTokens(List<FcmToken> failedList) {
if (!failedList.isEmpty()) {
fcmTokenRepository.deleteAll(failedList);
log.info("FCM 실패 토큰 {}건 삭제 완료", failedList.size());
}
}
List<FcmToken>이 비어있지 않은 경우에만 삭제 수행