실시간 위치 공유 구현기

Choi Wontak·2025년 4월 14일

아이쿠MSA

목록 보기
8/12

난이도 ⭐️⭐️
작성 날짜 2024.03.25

고민 내용

아이쿠 프로젝트의 핵심 기능은 바로 '실시간 위치 공유'이다.
약속 과정에서 친구들에게 너 어디야??를 반복하며 답답해 하던 과정에서 떠오른 아이디어인 만큼, 약속 참가자들의 위치를 실시간으로 알려주는 이 기능은 이 프로젝트에서 빼놓을 수 없는 기능이다.

🤔
아이디어는 있는데... 이 기능을 어떻게 구현해야 될까?


찾아보기

소켓 (Socket)

우리는 위치를 알려주는 실시간 진행 과정이 채팅 앱과 비슷하다고 생각했다.
누군가 메시지를 보내면, 채팅에 속한 사용자들은 바로 메시지를 확인할 수 있다.
그래서 자연스럽게 떠오른 생각이 소켓이다.

그러나 다음의 이유로 소켓은 부적합하다고 생각했다.

  • 상태를 유지해야 한다.
    상태 유지에 대한 리소스가 추가적으로 필요하며,
    소켓 연결 종료 후 다시 접속 시 다시 소켓을 연결해주어야 하는 복잡함이 있다.

FCM (Firebase Cloud Messaging) 활용하기

FCM은 보통 푸시 알림을 구현하기 위해 사용한다.
그런데 Notification이 아닌 Data 메시지는 단순한 정보 전달로도 사용할 수 있겠다고 생각하였다.

https://medium.com/harrythegreat/android-fcm-data%EC%99%80-notification-36a5285cfae5

다음의 이유로 FCM을 선택하였다.

  • 이미 FCM을 사용하고 있다보니 기술 추가에 대한 부담이 덜했다.
    약속을 관리하는 앱이다 보니, 해당 약속에 관련해서 다양한 푸시 알림이 서버에서 클라이언트로 전송되어야 했다.
  • 알림 전송이나 상태 관리도 Firebase에 위임할 수 있어 편리하다.
    FCM Token의 경우는 푸시알림에서도 필요한 값이라 원래도 관리해야 하는 값이었다.
  • 알림 전송처럼 실시간으로 전달도 되고, 알림을 받을 때만 동작하면 되니 비교적 저전력으로 작동할 것이다.

방식은 단순하다.

  1. 클라이언트에서 5초에 한 번씩 REST API로 본인의 위치 좌표를 전달한다.
  2. 서버에서 해당 위치를 별다른 기록 없이 바로 알람 서버에 전달한다.
  3. 해당 약속에 속한 사용자들의 FCM Token을 모아 한번에 Data Message의 형태로 전송한다.

REST로 받은 위치를 MAP 서버로 넘기는 코드

public Long sendLocation(Long memberId, Long scheduleId, RealTimeLocationDto realTimeLocationDto) {
        // 카프카로 위도, 경도 데이터를 스케줄 상의 다른 유저에게 전송하는 로직
        AlarmMemberInfo memberInfo = getMemberInfo(memberId);
        List<String> fcmTokensInSchedule = findAllFcmTokensInSchedule(scheduleId);
        
        kafkaService.sendMessage(KafkaTopic.alarm,
                new LocationAlarmMessage(fcmTokensInSchedule, AlarmMessageType.MEMBER_REAL_TIME_LOCATION,
                        scheduleId,
                        memberInfo,
                        realTimeLocationDto.getLatitude(),
                        realTimeLocationDto.getLongitude()
                )
        );
        
        return scheduleId;
}

MAP 서버에서 Firebase 서버로 메시지를 전달하는 코드
다른 알림의 경우 로그를 기록해야 하기 때문에 비동기로 작성하였다.

private void sendMessageToUsers(Map<String, String> messageDataMap, List<String> receiverTokens) {
        log.info("Firebase sendMessageToUsers");
        MulticastMessage message = MulticastMessage.builder()
                .putAllData(messageDataMap)
                .addAllTokens(receiverTokens)
                .build();

        ApiFuture<BatchResponse> future = FirebaseMessaging.getInstance().sendEachForMulticastAsync(message);

        future.addListener(() -> {
            try {
                BatchResponse batchResponse = future.get();
                log.info("Successfully sent messages: " + batchResponse.getSuccessCount());
                log.info("Failed messages: " + batchResponse.getFailureCount());

                batchResponse.getResponses().forEach(response -> {
                    if (!response.isSuccessful()) {
                        log.error("Error sending message: " + response.getException().getMessage());
                    }
                });
            } catch (Exception e) {
                throw new MessagingException(FAIL_TO_SEND_MESSAGE);
            }
        }, Executors.newSingleThreadExecutor());
}

알림을 DB에 기록하는 로그에서도 따로 예외 처리를 해주어야 한다는 불편함은 있었으나 구현은 정상적으로 작동하였다.

실제로 모놀리식 서버에선 FCM을 이용해 시연까지 성공하였다.

FCM의 치명적인 문제

그러나...
런칭을 준비하면서 새롭게 들어오시게 된 프론트 팀원 분께서 의문을 제기하셨다.

FCM은 사용자가 알람을 켜지 않으면 Data도 수신이 안될텐데요?

실제로 테스트해 보셨고, 알람이 꺼져있는 상태에선 Data를 수신할 수 없다고 말씀하셨다.
또한, FCM은 알림이 많으면 누락되는 경우가 좀 있어 실시간성을 보장하기도 어렵다는 문제도 알게 되었다.
이래서 프론트 분들과의 소통이 중요합니다ㅠㅠ

이게 왜 문제냐면, 사용자에게 알람을 강제하는 앱은 앱스토어 심사에서도 거절당할 확률이 크고,
운 좋게 통과된다고 하더라도 사용자는 첫 인상부터 거부감을 느끼고 말 것이다.

그래서 프론트 팀 분들과 함께 기획 요구사항을 재정리하고, 회의에 들어갔다.

  1. 위치 전달은 5초에 한 번씩 이루어져야 한다.
  2. 다른 사람의 위치도 5초에 한 번 업데이트 되어야 한다.
  3. 사용자 위치는 최대 50분 간 공유되어야 한다.

폴링 (Polling)

위치 전달을 REST API로 진행하고 있으며
전달 간격이 일정하고, 위치 공유를 받는 간격도 일정하기 때문에
폴링을 사용하면 충분히 구현이 가능할 것이라는 결론을 얻었다!

위치 정보를 사용자가 전달하면 위치를 DB에 저장하고,
DB에서 해당 스케줄에 해당하는 위치 정보를 모두 가져와 Response로 전달하는 방식이다.

위치 정보는 각 개인에 대한 정보만 갱신 되기 때문에 DB에서 충돌이 생길 문제도 없었다.

빠른 조회와 갱신을 위해 Redis를 사용하였다.

  • 키 값은 schedule:{ScheduleId}:{MemberId} 형식이며, 위치, 도착 여부를 값으로 갖는다.
  • 위치 공유가 연결 상태나 백그라운드에서 종료된 경우 데이터를 삭제하여 현위치가 정확하지 않음을 나타내기 위해 TTL을 5분으로 설정하였다.
    도착한 경우 배터리 소모를 막기 위해 위치 공유가 중지된다. 이때에는 위치를 고정하기 위해 TTL은 제거하였다.
  • 또한, 데이터가 무한히 늘어나는 것을 막기 위해 종료된 스케줄의 데이터는 제거하였다.
@Transactional
public LocationsResponseDto saveAndSendAllLocation(Long memberId, Long scheduleId, RealTimeLocationDto realTimeLocationDto) {
        // 검증
        checkMemberInSchedule(memberId, scheduleId);

        // Redis에 해당 위치 저장
        scheduleLocationRepository.saveLocation(scheduleId, memberId, realTimeLocationDto.getLatitude(), realTimeLocationDto.getLongitude());

        // Redis에 담긴 scheduleId에 해당하는 모든 위치 Load
        List<RealTimeLocationResDto> scheduleLocations = scheduleLocationRepository.getScheduleLocations(scheduleId);

        // Response로 전달
        return new LocationsResponseDto(scheduleLocations.size(), scheduleLocations);
}

이를 통해 사용자는 위치 공유를 위해 알람을 켤 필요가 없었으며,
MAP 서버에 전달되는 부하도 줄일 수 있었다.

매 번 같은 간격으로 보내기 때문에 롱 폴링은 무의미하고,
5초 내로 갱신을 목표로 하고 있기 때문에 약간의 딜레이는 허락된다.

초반에 데이터가 존재하지 않아 위치가 보이지 않아도 5초 내로 해결되니 사용자 경험에 있어서도 크게 문제가 되지 않는 부분이다.


결론

팀원 분들 덕분에 기획에 맞는 더 좋은 방향으로 돌릴 수 있었다!
이것이 최적해일지는... 알 수 없지만 기술적인 공부가 더 필요하다는 것을 느꼈다.
그리고 소통은 항상 새로운 관점으로 문제를 바라보게 만들어준다.

구현했다고 끝이 아니다. 항상 더 나은 방식을 고민하자!


profile
백엔드 주니어 주니어 개발자

0개의 댓글