
계좌이체가 성공한 경우 푸시알람을 보내야 하는 상황이 발생했다. 당연히 Firebase를 활용하기로 했고, 세팅하는 방법은 구글링하면 워낙 많이 나오니까 생략...! 프론트엔드 설정 파일을 건드려야 해서 Firebase 세팅은 프론트엔드 팀원에게 맡기고 필요한 json 파일만 넘겨 받아서 백엔드 측 설정을 완료했다.
@Configuration
public class FirebaseConfig {
@PostConstruct
public void init() throws IOException {
InputStream serviceAccount = getClass().getClassLoader().getResourceAsStream("firebase-key.json");
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp.initializeApp(options);
}
@Bean
public FirebaseMessaging firebaseMessaging() {
return FirebaseMessaging.getInstance();
}
}
사용자 A → 사용자 B에게 계좌이체 요청
↓
Bank Service → 이체 처리 완료
↓
Kafka Event 발행 (비동기)
↓
수신자 정보 조회 및 푸시알림 데이터 생성
↓
Kafka → 알림 서비스로 메시지 전송
↓
FCM API 호출 → 사용자 B에게 푸시알림 전송
동기 처리 시 사용자가 푸시알림 전송까지 대기해야 하기 때문에 응답 시간이 지연된다. 또한 FCM 서버 장애 시 전체 이체 프로세스에 영향을 미치기 때문에 바람직하지 않다.
반면 비동기로 처리할 경우 이체 완료 즉시 사용자에게 응답할 수 있고, 푸시알림 전송에 실패해도 이체는 정상 완료된다.
@Service
@Slf4j
@RequiredArgsConstructor
public class FCMService {
private final UserInfoService userInfoService;
private final EncryptionService encryptionService;
private final FCMTokenRepository fcmTokenRepository;
/*
* FCM token 저장
*/
@Transactional
public void saveToken(FCMTokenSaveDto request) {
Integer userId = request.getUserId();
// user 존재 여부 확인
userInfoService.getUserInfoById(userId);
try {
FCMToken fcmToken = FCMToken.builder()
.userId(userId)
.build();
fcmToken.setToken(request.getToken(), encryptionService);
log.info("FCM 토큰 암호화 완료");
fcmTokenRepository.save(fcmToken);
log.info("FCM Token 암호화 후 저장 완료 : userId = {}", userId);
} catch (Exception e) {
throw new RuntimeException("FCM 토큰 암호화 실패", e);
}
}
}
프론트에서 받아오는 FCM 토큰은 개인 디바이스 식별자로 민감정보이기 때문에 암호화하여 저장했다.
@Transactional
public void transferAccount(AccountTransferDto request) {
// 이체 처리 로직
// 계좌 이체 완료
apiService.processApiRequest(ApiEndPoint.UPDATE_DEMAND_DEPOSIT_ACCOUNT_TRANSFER, requestBody);
log.info("계좌 이체 완료");
// 이체 성공 후 Kafka 이벤트 발행 (비동기)
transferEventPublisher.publishTransferEvent(request);
}
@Transactional 내에서 이체 처리와 이벤트 발행을 분리했다. 따라서 이체 완료 후에만 이벤트 발행하여 데이터 일관성을 보장할 수 있다.
@RequiredArgsConstructor
@Service
@Slf4j
public class KafkaEventPublisher {
private final KafkaProducerService kafkaProducerService;
private final AccountRepository accountRepository;
private final UserInfoService userInfoService;
public void publishTransferEvent(AccountTransferDto request) {
// 수신자 정보 조회
String depositAccount = request.getDepositAccountNumber();
UserInfoResponse depositUser = userInfoService.getUserInfoById(
accountRepository.findByAccountNo(depositAccount)
.orElseThrow(AccountNotFoundException::new)
.getUserId());
Integer depositUserId = depositUser.getUserId();
// 푸시 알림 전송
log.info("푸시 알람 전송 시작");
sendPushAlarm(request.getAmount(), depositUserId, null);
// 기타 이벤트 처리 (이체 내역, 거래 내역 등)
}
// 푸시 알림 전송 메서드
public void sendPushAlarm(Long amount, Integer depositUserId, String authCode) {
String bodyMessage;
// 인증번호 포함 여부에 따른 메시지 생성
if (authCode != null && !authCode.isBlank()) {
bodyMessage = String.format("%,d원이 입금되었습니다. 인증번호는 %s입니다", amount, authCode);
} else {
bodyMessage = String.format("%,d원이 입금되었습니다.", amount);
}
// 푸시알림 DTO 생성
TransferNotificationDto transferNotificationDto = TransferNotificationDto.builder()
.receiverUserId(depositUserId) // 수신자 ID
.title("노크은행") // 알림 제목
.body(bodyMessage) // 알림 내용
.build();
// Kafka로 푸시알림 이벤트 발행
kafkaProducerService.sendPushNotification(transferNotificationDto);
log.info("Kafka 이벤트 발행 완료 - Transfer Notification: {}", transferNotificationDto);
}
}
1원 인증 API에서도 푸시 알림을 전송해야 하기 때문에 메시지 포맷을 동적으로 생성했다.
@RequiredArgsConstructor
@Service
@Slf4j
public class KafkaProducerService {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Value("${spring.kafka.topic.name.transfer-notification}")
private String transferNotificationTopic;
public void sendPushNotification(TransferNotificationDto dto) {
try {
log.info("KafkaProducerService 실행 - TransferNotification");
// DTO를 JSON 문자열로 직렬화
String message = objectMapper.writeValueAsString(dto);
// Kafka 토픽으로 메시지 전송
kafkaTemplate.send(transferNotificationTopic, message);
} catch (JsonProcessingException e) {
log.error("푸시 알림 Kafka 메시지 전송 실패: {}", e.getMessage());
}
}
}
위에서 언급한 것처럼 푸시 알림 전송 중 에러가 발생해도 이체는 이미 완료된 상태이다.
@Service
@RequiredArgsConstructor
@Slf4j
public class TransferNotificationConsumer {
private final ObjectMapper objectMapper;
private final FCMTokenRepository fcmTokenRepository;
private final EncryptionService encryptionService;
private final FirebaseMessaging firebaseMessaging;
@KafkaListener(topics = "${spring.kafka.topic.name.transfer-notification}",
groupId = "transfer-notification-group")
public void consume(String message, Acknowledgment ack) {
try {
// 1. 메시지 파싱
TransferNotificationDto dto = objectMapper.readValue(message, TransferNotificationDto.class);
// 2. 사용자의 모든 FCM 토큰 조회
List<FCMToken> tokenEntities = fcmTokenRepository
.findAllByUserIdOrderByCreatedAtDesc(dto.getReceiverUserId());
boolean isSuccess = false;
// 3. 모든 디바이스에 푸시 전송
for (FCMToken tokenEntity : tokenEntities) {
try {
String decryptedToken = tokenEntity.getDecryptedToken(encryptionService);
Message firebaseMessage = Message.builder()
.setNotification(Notification.builder()
.setTitle(dto.getTitle())
.setBody(dto.getBody())
.build())
.putData("title", dto.getTitle())
.putData("content", dto.getBody())
.setToken(decryptedToken)
.build();
String response = firebaseMessaging.getInstance().send(firebaseMessage);
log.info("푸시 알림 전송 성공: tokenId: {}, response: {}",
tokenEntity.getId(), response);
isSuccess = true;
} catch (FirebaseMessagingException e) {
// 잘못된 토큰 자동 삭제
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED ||
e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
fcmTokenRepository.delete(tokenEntity);
}
}
}
// 4. 하나라도 성공하면 Kafka 커밋
if (isSuccess) {
ack.acknowledge();
}
} catch (Exception e) {
log.error("푸시 알림 처리 중 오류 발생: {}", e.getMessage());
}
}
}
putData가 어떤 역할을 하는지 궁금해져서 찾아보았다.
FCM에서 데이터 메시지를 구성할 때 사용되는데, Notification 메시지와 달리, 앱이 백그라운드일 때는 자동으로 알림이 안 뜨고 앱이 직접 처리해야 하지만 앱이 포그라운드에 있을 때는 이 데이터를 앱이 받아서 원하는 동작을 할 수 있다.
| 구분 | Notification 메시지 | Data 메시지 (putData) |
|---|---|---|
| 동작 방식 | 시스템이 알림을 자동으로 표시 | 앱이 직접 메시지를 처리 |
| 포그라운드 | 앱이 직접 처리 | 앱이 직접 처리 |
| 백그라운드 | 시스템이 자동으로 알림 표시 | 백그라운드에선 대부분 무시됨 (Android 13부터 더 까다로움) |
| 사용 예 | 단순 알림 표시 | 특정 동작 처리, 화면 이동, 클릭 이벤트 등 |
앱이 직접 처리한다는게 무슨 의미일까?
Notification 메시지만 보냈을 때는 안드로이드 OS 자체가 푸시 알림을 띄워준다. 따라서 앱이 열려 있든 꺼져 있든 간에 상단에 알림이 뜨고 앱 쪽에서 따로 코드를 작성하지 않아도 된다.
반면 putData()로 Data 메시지를 보냈을 때 앱이 받자마자 앱의 코드가 직접 이 데이터를 보고 알림을 띄울지, 어떤 화면으로 이동할지, 로그를 남길지 등을 앱이 스스로 판단하고 실행해야 한다.
따라서 간단한 정보 전달용 알림은 Notification을 사용해도 되지만 앱 내부에서 아래와 같은 데이터 기반 로직을 처리해야 한다면 putData()를 사용해야 한다.
따라서 확장성을 위해 putData를 사용해서 푸시 알림을 전송하기로 했다.
구현 흐름이 약간 복잡해서 정리해봤다.
1. 계좌이체 완료 (AccountService)
↓
2. transferEventPublisher.publishTransferEvent()
↓
3. sendPushAlarm() → TransferNotificationDto 생성
↓
4. kafkaProducerService.sendPushNotification()
↓
5. Kafka Topic: transfer-notification-topic
↓
6. TransferNotificationConsumer.consume()
↓
7. FirebaseMessaging.getInstance().send() ← 실제 FCM 전송
한번쯤은 구현하고 싶었던 기능인데 이번 기회에 공부할 수 있어서 좋았다..!!