미니 프로젝트 - FCM 전송 기능 확장

Zyoon·2025년 7월 31일

미니프로젝트

목록 보기
31/35
post-thumbnail

📘FCM 토큰 저장 및 알림 전송 기능 고도화. 토큰 관리부터 안정적인 전송까지


FCM 토큰 DB 연동 및 예외 처리 구현

기본적인 FCM 전송 기능을 구현한 뒤, 실제 서비스에 적용하기 위해 토큰을 DB에 저장하고 관리하는 구조로 확장한다.

이번에는 저장된 토큰을 조회해 알림을 전송하고, 전송 중 발생할 수 있는 예외 상황까지 처리할 수 있도록 구현한다.


Entity

@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;
	}
}
  • @Tableuser_id + device_id 복합 유니크 조건을 걸어 한 사용자의 동일 디바이스에 대해 중복 저장을 방지한다.
  • token : Firebase에서 발급된 FCM 토큰
  • deviceId : 클라이언트에서 식별할 수 있는 디바이스 정보 (UUID 등)
  • platformType : WEB / ANDROID / IOS 등 플랫폼 구분을 위한 enum
  • updateToken : 토큰 갱신을 위한 업데이트 메서드도 정의

토큰 저장 로직

DTO

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();
	}
}
  • 토큰, 디바이스 ID, 플랫폼 정보를 담아 서버로 전달한다.
  • toEntity 메서드를 통해 FcmToken 엔티티로 바로 변환할 수 있다.

Controller

@PostMapping
public ResponseEntity<ApiResponse<Void>> createToken(
	@RequestBody FcmCreateRequestDto dto,
	@AuthenticationPrincipal AuthUser authUser) {
	
	fcmService.createToken(authUser.getId(), dto);
	return ResponseEntity.ok(ApiResponse.success("저장완료", null));
}
  • 로그인한 사용자의 userId를 인증 객체에서 가져와 서비스로 전달한다.
  • 정상적으로 처리되면 "저장완료" 메시지를 반환한다.

Service

@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);
	}
}
  • userIddeviceId로 기존 토큰이 있는지 조회한다.
  • 토큰이 있으면 updateToken()으로 새 값으로 갱신한다.
  • 없으면 새 FcmToken 엔티티를 만들어 저장한다.

플랫폼별 전송 전략 – FcmSender 인터페이스

FcmSender 인터페이스

public interface FcmSender {

	PlatformType handles();

	void send(FcmMessageDto dto) throws Exception;
}
  • 각 플랫폼(Android, iOS, Web)에 따라 전송 방식이 다를 수 있으므로, 전략 패턴을 사용해 분리한다.
  • handles()는 자신이 처리할 플랫폼을 명시한다. → ex) PlatformType.WEB
  • send()는 실제로 해당 플랫폼 디바이스에 메시지를 전송하는 책임을 가진다.
  • 이후 WebFcmSender, AndroidFcmSender 등의 구현체를 만들게 된다.

Web 전송 메세지

@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);
	}
}

Android 전송 메세지

@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);
	}
}

IOS 전송 메세지

@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);
	}
}

메세지 전송 로직

process (전송 로직 흐름 부분)

@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)로 메시지 전송
  • 전송 결과 처리
    • 하나라도 성공하면 성공으로 간주
    • 모두 실패 시 → 로그 기록 or 메시지 큐 등록 등 후처리 가능
  • 실패 토큰 정리 → 신뢰도 낮은 토큰을 DB에서 제거

플랫폼 분기

//플랫폼 생성자 리스트 주입
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());
	}
}
  • FCM 전송 도중 실패한 토큰들은 유효하지 않다고 판단하여 한 번에 삭제 처리한다.
  • List<FcmToken>이 비어있지 않은 경우에만 삭제 수행
  • 삭제 후 로그로 삭제 건수를 출력해 추적 가능하도록 한다
profile
기어 올라가는 개발

0개의 댓글