프로젝트를 진행하게 되면서 1대1 알림에 대한 파트를 맡게 되었습니다.
해당 블로그는 FCM으로 1대1 알림 전송을 구현하는 과정을 적어봤습니다.
해당 블로그글은 FCM 설정을 마쳤다는 가정하에 작성된 글입니다.
🤔 FCM이란?
FCM은 Firebase Cloud Messaging의 약자로서, 무료로 메시지를 안정적으로 전송할 수 있는 교차 플랫폼 클라우드 서비스입니다.
구글 클라우드 서버를 사용하여 서버를 연결하지 않고, 기기 내부 연결을 통해서 메시지를 전송할 수 있습니다.
메시지 전송을 위한 자원은 아래와 같습니다.
Token : 디바이스마다 하나의 토큰을 통해서 한 명의 사용자를 구분하고, 알림을 전송할 수 있습니다.
Topic: 하나의 주제(Topic)으로 묶어서 해당 Topic을 구독한 사용자들에게 알림을 전송할 수 있습니다.
1. 디바이스에서 토큰 획득
2. 서버 DB에 토큰 저장
3. 토큰을 이용하여 서버에 메시지 전송 요청
4. 메시지 전송
5. 리스너를 이용한 메시지 수신
여기에서 Token은 JWT Token이 아닌, 디바이스에서 받아내는 FireBaseToken 입니다.
⚒️ SpringBoot 설정
먼저 firebase 설정을 위한 의존성을 추가합니다.
dependencies {
//firebase
implementation 'com.google.firebase:firebase-admin:9.2.0'
}
💭 FirebaseCofnig
다음으로 FCM 서버에 접근하기 위한 비공개 키를 발급 받습니다. 아래 사이트에서 발급 받으시면 됩니다.
https://console.firebase.google.com/
새 비공개 키 생성을 누르게 되면 json 파일이 다운로드 됩니다.
해당 JOSN 파일을 resources 아래에 firebase 폴더 안에 넣어주었습니다.
그리고 환경설정을 위한 코드를 작성할 때 해당 키의 파일을 불러와줍니다.
@Service
public class FirebaseConfig {
@PostConstruct
public void initialize() {
try {
InputStream serviceAccount = new ClassPathResource("firebase/firebase-service-account.json").getInputStream();
FirebaseOptions options = new FirebaseOptions.Builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp.initializeApp(options);
} catch (IOException e) {
e.printStackTrace();
}
}
}
⚒️Domain
FcmToken을 저장하는 notification 도메인입니다.
유저 테이블과 매핑되어 있습니다.
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "notification")
public class Notice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String token;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "userId", nullable = false)
private User user;
public String getFcmToken() {
return token;
}
public void setFcmToken(String token) {
this.token = token;
}
}
⚒️DTO
클라이언트에게 Token을 받는 DTO 입니다.
@Getter
public class FcmTokenDto {
@NotNull(message = "token을 입력해주세요.")
String token;
}
⚒️Repository
다음으로 NoticeRepository 입니다.
토큰을 조회하기 위한 JPQL이 작성되어 있습니다.
public interface NoticeRepository extends JpaRepository<Notice, Long> {
Notice findByUserId(Long userId);
@Query("SELECT n.token FROM Notice n WHERE n.user.id = :userId")
String findTokenByUserId(@Param("userId") Long userId);
}
⚒️ Controller 작성
클라이언트 단에서 받는 디바이스 토큰 저장과, 메시지 전송 컨트롤러를 작성합니다.
저희는 시큐리티를 사용하고 있기 때문에 @AuthenticationPrincipal 을 통해서 사용자 정보를 가져옵니다.
/**
* 1 대 1 채팅 전송
*
* @param fcmTokenSendDto
* @return
*/
@PostMapping("/send")
public ResponseEntity<RestResponse<Object>> pushMessage(@RequestBody @Validated FcmTokenSendDto fcmTokenSendDto
) {
log.debug("[+] 푸시 메시지를 전송 합니다.");
RestResponse<Object> response = fcmTokenService.sendMessage(fcmTokenSendDto);
return ResponseEntity.status(response.getStatus()).body(response);
}
/**
* FCM TOKEN 저장
*
* @param customUserDetails
* @param fcmTokenDto
* @return
*/
@PostMapping("/token")
public ResponseEntity<RestResponse<Object>> saveFcmToken(
@AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestBody @Valid FcmTokenDto fcmTokenDto) {
RestResponse<Object> response = fcmTokenService.saveFcmToken(customUserDetails, fcmTokenDto);
return ResponseEntity.status(response.getStatus()).body(response);
}
⚒️ Service 단 작성
인터페이스를 작성합니다.
@Service
public interface FcmTokenService {
@Transactional
RestResponse<Object> saveFcmToken(CustomUserDetails customUserDetails, FcmTokenDto fcmTokenDto);
RestResponse<Object> sendMessage(FcmTokenSendDto tokenSendDto);
String getFcmToken(Long userId);
}
서비스단 구현체 입니다.
@Service
@Slf4j
@RequiredArgsConstructor
public class FcmTokenServiceImpl implements FcmTokenService {
private final NoticeRepository noticeRepository;
private final UserRepository userRepository;
/**
* FCM TOKEN 저장
*
* @return RestResponse<Obejec
* @ param1 customUserDetails
* @ param2 fcmTokenDto
*/
@Override
public RestResponse<Object> saveFcmToken(CustomUserDetails customUserDetails, FcmTokenDto fcmTokenDto) {
try {
Long userId = customUserDetails.getId();
String token = fcmTokenDto.getToken();
// 기존에 저장된 FCM 토큰이 있는지 확인하고, 없으면 새로 저장
Notice existingNotice = noticeRepository.findByUserId(userId);
User user = userRepository.findById(Math.toIntExact(userId))
.orElseThrow(() -> new EntityNotFoundException("유저가 존재하지 않습니다."));
if (existingNotice == null) {
Notice notice = Notice.builder()
.user(user)
.token(token)
.build();
noticeRepository.save(notice);
return RestResponse.success("토큰 저장 성공");
} else {
existingNotice.setFcmToken(token);
noticeRepository.save(existingNotice);
return RestResponse.success("토큰 갱신 성공");
}
} catch (EntityNotFoundException e) {
return RestResponse.error(ResultCode.NO_MATCHING_USER_FOUND);
}
}
/**
* FCM SDK 방식을 통한 서버 메시지 전송
*
* @param tokenSendDto token, title, body
* @return RestResponse<Object>
*/
@Override
public RestResponse<Object> sendMessage(FcmTokenSendDto tokenSendDto) {
try {
validateFcmTokenSendDto(tokenSendDto);
// FCM 메시지 생성
Message.Builder message = Message.builder()
.setNotification(Notification.builder()
.setTitle(tokenSendDto.getTitle())
.setBody(tokenSendDto.getBody())
.setImage(null)
.build())
.setToken(tokenSendDto.getToken());
//비동기 처리
CompletableFuture.supplyAsync(() -> {
try {
return FirebaseMessaging.getInstance().send(message.build());
} catch (FirebaseMessagingException e) {
e.printStackTrace();
throw new BaseException(ResultCode.INTERNAL_SERVER_ERROR, "FCM 메시지 전송 실패: " + e.getMessage());
}
});
return RestResponse.success("메시지 전송 요청 완료");
} catch (Exception e) {
e.printStackTrace();
return RestResponse.error(ResultCode.INTERNAL_SERVER_ERROR,
"메시지 전송 실패: " + e.getMessage());
}
}
/**
* 주어진 사용자에 대한 FCM 토큰을 검색합니다.
*
* @param userId
* @return token
*/
public String getFcmToken(Long userId) {
String token = null;
try {
Notice notice = noticeRepository.findByUserId(userId);
if (notice == null || notice.getFcmToken() == null) {
throw new FcmTokenNotFoundException();
}
token = notice.getFcmToken();
} catch (DataAccessException e) {
throw new BaseException(ResultCode.INTERNAL_SERVER_ERROR);
}
return token;
}
/**
* FCM 전송 요청 DTO의 유효성을 검증합니다.
*
* @param dto FcmTokenSendDto
*/
@Override
public void validateFcmTokenSendDto(FcmTokenSendDto dto) {
if (dto.getToken() == null || dto.getToken().isBlank()) {
throw new CustomValidationException(ResultCode.VALIDATION_ERROR, "FCM 토큰은 필수입니다.");
}
if (dto.getTitle() == null || dto.getTitle().isBlank()) {
throw new CustomValidationException(ResultCode.VALIDATION_ERROR, "제목은 필수입니다.");
}
if (dto.getBody() == null || dto.getBody().isBlank()) {
throw new CustomValidationException(ResultCode.VALIDATION_ERROR, "내용은 필수입니다.");
}
if (dto.getPlatform().equalsIgnoreCase("unknown")) {
throw new CustomValidationException(ResultCode.VALIDATION_ERROR, "os가 존재하지 않습니다.");
}
}
}
}
✔️ CompletableFuture.supplyAsync()
- 비동기 처리를 위해서 Java의 Concurrency API 에서 제공해주는 ComplatableFuture를 사용합니다. 메시지가 정상적으로 전송되었는 지 확인하기 위해서 메시지 ID를 반환받습니다.
- ComplatableFuture에서 runAsync() 를 사용하면 void (반환값X), 반환값이 있는 경우엔 supplyAsync() 를 사용합니다. 여기에선 반환값이 필요하기 때문에 supplyAsync() 를 사용하였습니다.
✔️FirebaseMessaging.getInstance().send()
- FirebaseMessaging 을 통해 앞서 설정한 Firebase의 Firebase Admin SDK로 FCM 서버에 연결합니다.
- getInstance로 통해서 FirebaseMessaging의 싱글톤 인스턴스를 반환합니다.
- send 안에 Message 객체를 통해 메시지를 전송합니다.
다음으로
이렇게 백엔드 설정을 마쳤습니다.
다음 포스팅에서 간단한 웹 프론트 코드로 테스트를 하는 방법과 안드로이드, IOS(APNs) OS 별 분기와 Xcode 구현 시 ,,, 실패한 사례에 대해서 포스팅 하겠습니다.
현재 프로젝트는 SSE 를 통한 알림 구현으로 리팩토링 되어있습니다.
SSE 알림이 궁금하다면 ?
https://ryudain.tistory.com/34
[단잠] 알림 도입기 (feat. AMQP, RabbitMQ, SSE) [1편]
서론단잠 알림 도입기 포스팅 입니다. 사실 FCM 으로 진행하기 위해 코드들을 모두 작성하여서웹으로 모두 테스트까지 완료했었지만,,,, xCode 베타 버전 문제인지 ...........FCM 문제인지 .........
ryudain.tistory.com
'개발 > SpringBoot' 카테고리의 다른 글
JPA @Builder 사용시 유의 사항 (2) | 2025.01.01 |
---|---|
[Spring Boot] JPA 사용 시 Entity단에서 @NoArgsConstructor(access = AccessLevel.protected) 로 설정하는 이유 (4) | 2024.12.20 |
[Spring Boot] 싱글톤 컨테이너 (0) | 2024.11.08 |
@RequestBody @RequestPart @RequestParam @PathVariable (0) | 2024.11.07 |
[좋은 객체 지향 설계의 5원칙: SOLID] 객체지향과 스프링 (7) | 2024.11.06 |