Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

코딩블로그

[Spring] FCM 구축하기 & FCM과 @Scheduled을 이용하여 특정 시간대에 알림 보내기 본문

PopcornMate

[Spring] FCM 구축하기 & FCM과 @Scheduled을 이용하여 특정 시간대에 알림 보내기

_hanbxx_ 2024. 3. 11. 23:29
728x90

팝콘메이트에서는 상영회를 찜하기를 한다면 상영회 하루 전날 아침 열 시에 본인이 찜한 상영회에 대한 알림을 보내는 기능이 있다.

주변에 인앱 푸쉬 알람을 구현해본 친구들이 별로 없었기에 깃허브 레퍼런스를 찾기도 힘들었고 시작하기 전에는 막막했지만 막상 도전 해보니 공식문서에도 친절하게 나와 있고 깃허브 돌아다니면서 참고도 많이 해보았다

https://firebase.google.com/docs/cloud-messaging/server?hl=ko&_gl=1*gph5n9*_up*MQ..*_ga*MTYwNzk4NTg5MS4xNzEwMTY1MzAz*_ga_CW55HF8NVT*MTcxMDE2NTMwMy4xLjAuMTcxMDE2NTMwMy4wLjAuMA..

 

Firebase 클라우드 메시징

Google I/O 2023에서 Firebase의 주요 소식을 확인하세요. 자세히 알아보기 의견 보내기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 서버 환경 및 FCM Firebase 클

firebase.google.com

 

설정

FCM Console에 들어가서

 

프로젝트 설정 -> 서비스 계정
여기에서 json파일을 다운 받을 수 있다

application.yml

app:
  firebase-configuration-file: {파일이름}.json

 

이 json파일을 어떻게 관리해야 하는지는 뒤에 가서 얘기를 하겠다.

FcmInitializer Class

@Configuration
public class FcmInitializer {
    @Value("${app.firebase-configuration-file}")
    private String firebaseConfigPath;

    @Bean
    FirebaseMessaging firebaseMessaging() throws IOException {
        ClassPathResource resource = new ClassPathResource(firebaseConfigPath);
        InputStream resourceInputStream = resource.getInputStream();
        FirebaseApp firebaseApp = null;
        List<FirebaseApp> apps = FirebaseApp.getApps();

		//if부터 else 전까지 코드는 현재 애플리케이션에 이미 
        //Firebase 앱이 있는 경우 그 앱을 재사용하기 위해 체크하는 부분입니다.
        if (apps != null && !apps.isEmpty()) {
            for (FirebaseApp app : apps) {
                if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) {
                    firebaseApp = app;
                }
            }
        }
        else {
            FirebaseOptions options = FirebaseOptions.builder()
                    .setCredentials(GoogleCredentials.fromStream(resourceInputStream)).build();
            firebaseApp = FirebaseApp.initializeApp(options);
        }
        return FirebaseMessaging.getInstance(firebaseApp);
    }
}

이 클래스는 애플리케이션에서 FCM을 사용할 수 있도록 Firebase를 초기화하고, 필요한 설정을 적용하여 FirebaseMessaging 객체를 생성하는 역할을 한다

 

FcmRegistrationRequest Class

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class FcmRegistrationRequest {
    @NotEmpty
    private String fcmToken;
}

 

NotificationRequest Class

@Getter
public class NotificationRequest {
    private Long userId;
    private String title;
    private String body;

    public NotificationRequest(Screening screening, Long user, String title) {
        this.userId= user;
        this.title = title;
        this.body = screening.getInformation();
    }

    public NotificationRequest( Long user, String title) {
        this.userId= user;
        this.title = title;
    }
}

상영회의 제목을 받을 수 있는 requestDTO

FCMRepository Class

public interface FcmRepository extends JpaRepository<FCMToken, Long> {
    Optional<FCMToken> findByUserId(Long userId);
    void deleteByUserId(Long userId);
}

FCM Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class FCMToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @JoinColumn(name = "user_id", nullable = false)
    @OneToOne(fetch = FetchType.LAZY)
    private User user;

    @Column(name = "fcm_token",columnDefinition = "TEXT")
    private String fcmToken;

    @Builder(access = AccessLevel.PRIVATE)
    private FCMToken(User user, String fcmToken) {
        this.user = user;
        this.fcmToken = fcmToken;
    }

    public static FCMToken createFCMToken(User user, String fcmToken) {
        return FCMToken.builder()
                .user(user)
                .fcmToken(fcmToken)
                .build();
    }

    public void updateToken(String fcmToken) {
        this.fcmToken = fcmToken;
    }
}

 

User와 매핑하여 저장할 수 있게끔 설정하였다

 

FCMService Class

@Slf4j
@Service
@RequiredArgsConstructor
public class FcmService {
    private final FirebaseMessaging firebaseMessaging;
    private final UserRepository userRepository;
    private final FcmRepository fcmRepository;
    @Transactional
    public void registerFCMToken(Long userId, FcmRegistrationRequest request) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> UserNotFoundException.EXCEPTION);

        // 이미 등록된 FCMToken이 있는지 확인
        Optional<FCMToken> existingToken = fcmRepository.findByUserId(userId);

        if (existingToken.isPresent()) {
            // 이미 등록된 FCMToken이 있는 경우 값을 업데이트
            existingToken.get().updateToken(request.getFcmToken());
        } else {
            // 등록된 FCMToken이 없는 경우 새로 생성하여 저장
            FCMToken newToken = FCMToken.createFCMToken(user, request.getFcmToken());
            fcmRepository.save(newToken);
        }
    }


    public void sendMessageByToken(NotificationRequest request) {
        User user = userRepository.findById(request.getUserId()).orElseThrow(IllegalArgumentException::new);
        FCMToken fcm = fcmRepository.findByUserId(user.getId()).get();
        String fcmToken = fcm.getFcmToken();
        if (!fcmToken.isEmpty()) {
            Message message = getMessage(request, fcmToken);

            try {
                firebaseMessaging.send(message);
                log.info("푸시 알림 전송 완료 userId = {}", user.getId());
            } catch (FirebaseMessagingException e) {
                log.info("푸시 알림 전송 실패 userId = {}", user.getId());
                throw new RuntimeException(e);
            }
        }
    }

    private static Message getMessage(NotificationRequest request, String fcmToken) {
        Notification notification = Notification.builder()
                .setTitle(request.getTitle())
                .setBody(request.getBody())
                .build();

        Message message = Message.builder()
                .setToken(fcmToken)
                .setNotification(notification)
                .build();
        return message;
    }
}

 

registerFcmToken 메서드는, 특정 유저에 대한 fcm token을 프론트에서 생성해줘서 서버한테 전달해줘야 하는 로직을 구현한 것이다. 이미 등록된 토큰이 있다면 갱신하도록 하는 메서드를 구현하였다. 

이를 위한 API는

    @PostMapping("/{userId}")
    public ResponseEntity<Void> fcmTokenRegistration(
            @PathVariable("userId") Long userId,
            @RequestBody FcmRegistrationRequest request) {
        fcmService.registerFCMToken(userId, request);
        return ResponseEntity
                .status(CREATED.code())
                .build();
    }

 

sendMessageByToken과 getMessage메서드는 상영회 하루 전 날 아침 열시가 되었을 때 스케쥴링이 되어 실행되는 메서드 이다

 @Scheduled(cron = "0 0 10 * * *")
 private void notifyReservation() {
        LocalDateTime now = LocalDateTime.now().withSecond(0).withNano(0);
        LocalDateTime reservationTime = now.plusDays(1);

        //userScreening에서 isBookMarked인 것들 중에서 user id, screening id가져와서 List<User> List<Screening>
        //screening에서 startDate가져와서 startDate가 내일이면 알람을 보낼 수 있게 짜봐 fcm이랑 스프링 쓰고 있어

        List<UserScreening> bookmarkedUserScreenings =  userScreeningAdaptor.findByBookMarked();

        for (UserScreening userScreening : bookmarkedUserScreenings) {
            LocalDateTime screeningStartDate = userScreening.getScreening().getScreeningStartDate();

            // 오늘이 screeningStartDate의 하루 전인 경우 해당 Screening을 가져옴
            if (screeningStartDate.toLocalDate().isEqual(ChronoLocalDate.from(reservationTime))) {
                Long userId = userScreening.getUser().getId();
                if (checkFcmExists(userId)) {
                    NotificationRequest notificationRequests = new NotificationRequest(userScreening.getScreening(), userId, userScreening.getScreening().getTitle());
                    sendNotifications(notificationRequests);
                }
            }
        }
    }
    
    private boolean checkFcmExists(Long userId) {
        if (fcmRepository.findByUserId(userId).isPresent()){
            return true;
        } else {
            return false;
        }
    }


    private void sendNotifications(NotificationRequest requests) {
        // FCM을 사용하여 알림을 보내는 로직
        fcmService.sendMessageByToken(requests);
    }

 

 

!!트러블 슈팅!!

다른 환경변수들과 다르게 FCM은 secret key가 json형식이다. 그래서 단순하게 .env파일에 담으려고 해도 인식을 못하는 에러가 생겨서 여러가지 방법을 시도해보았다.

 

1. 서브모듈

깃허브에서 서브 모듈이라는 기능을 제공한다는 사실을 듣고 어차피 멀티 모듈을 사용하니까 서브모듈로 환경변수를 관리해서 배포를 하자!라는 도전을 하게 되었다. 

서브 모듈에 대해 아직 잘 이해하지 못한 것이 문제였던 건지... Infra부분의 property파일에 대해서만 서브 모듈을 사용하니 빌드는 되는 데 배포할 때 서브 모듈을 인식을 못하고 계속 오류가 나는 상황을 마주하였다. 그래서  2번째 방법을 찾아 해결할 수 밖에 없어서 아쉬웠다. 이왕 멀티 모듈을 사용하는 김에 실용적인 기술을 사용해보고 싶었는데 결국 실패해서 아쉬웠다.

 

2. github workflow파일에 직접 파일을 생성하고 copy하는 script를 작성하기

다른 환경변수를 세팅 하는 것 처럼 

      - name: 환경 변수를 세팅합니다.
        run: |
          cd ./Infra/src/main/resources
          sudo touch ./popcornmateprod-firebase-adminsdk-yvb81-02b4302a03.json
          echo "$FIREBASE_JSON" | sudo tee ./popcornmateprod-firebase-adminsdk-yvb81-02b4302a03.json > /dev/null
          sed -i 's/#/"/g' ./popcornmateprod-firebase-adminsdk-yvb81-02b4302a03.json
        env:
          FIREBASE_JSON: ${{ secrets.FCM_SECRET }}
          
      
      - name: Deploy
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.EC2_DEV_HOST }}
          username: ubuntu
          key: ${{ secrets.EC2_DEV_KEY }}
          script: |
          .....
            cd Infra/src/main/resources
            sudo touch popcornmate-d7ca1-firebase-adminsdk-svbpw-fce737e873.json
            echo -ne "${{ secrets.FCM_SECRET }}" | sudo tee popcornmate-d7ca1-firebase-adminsdk-svbpw-fce737e873.json > /dev/null

 

이런식으로 sudo tee, sed ,echo명령어를 이용하여 환경변수를 배포환경에 주입시킬 수 있었다

 

 

+ 서브 모듈로 삽질하면서 공개 레포인데 json파일을 실수로 올려버려서 aws한테 전화까지 왔던 썰..~믿거나 말거나..

아무튼 굉자히 삽질을 많이 한 부분인 만큼 내가 코드를 짠 부분에 대해서는 확실하게 이해할 수 있었고 다음 프로젝트를 하게 된다면 FCM에 대해 어려움 없이 코드를 작성해 나갈 것 같다.

 

개선 점

앱을 킨 상태에서는 푸쉬 알람이 안간다! 이에 대한 해결방법이 프론트에서 따로 설정하는 부분이 있다고는 하는데 프론트분들이나 나나 제대로 해본 적이 없어서 이 부분에 대한 해결책은 아직도 못찾고 있다. 그리고 FCM에 대해 이해는 했지만 완전한 이해를 하려면 더 많은 공부를 해야 할 것 같다! 팝콘메이트 디벨롭 하면서 이에 대한 해결점을 찾아 보고 싶다.