Project

[Project] 실시간 채팅 구현 시 FCM Token 발송 여부 결정하기

woo0doo 2024. 3. 25. 21:24

서론

 tincle 앱 1:1 대화 기능을 개발 중, 대화방에 모두 들어와있을 경우에 메세지 전송을 하게 된다면 FCM Token을 발송하지 않고, 한 명만 들어와있을 경우에는 FCM Token을 발송하는 로직을 구현해야 했습니다. 프론트 쪽에서 대화방에 들어와 있을 경우 인앱 알림을 끄는 방안도 있었으나, 백엔드에서 처리하는 방법이 없을까? 고민을 하다가 이 방안이 괜찮은 것 같아서 백엔드에서 처리하기로 했습니다.

 

 

socket, stomp 사전 설정

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler; // jwt 인증

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/queue/chat");
        config.setApplicationDestinationPrefixes("/ws");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/connection")
                .setAllowedOriginPatterns("*")
                .withSockJS();
        registry.addEndpoint("/connection")
                .setAllowedOriginPatterns("*");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}

 

StompHandler

@Configuration
@RequiredArgsConstructor
@Slf4j
public class StompHandler implements ChannelInterceptor {

    private final JwtProvider jwtProvider;
    private final EntryService entryService;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        String sessionId = accessor.getSessionId();

        if(accessor.getCommand() == StompCommand.CONNECT) {
            String loginAccountSocialEmail = getSocialEmailAndvalidateToken(accessor);
            entryService.enterSocket(loginAccountSocialEmail, sessionId);

        } else if(accessor.getCommand() == StompCommand.SUBSCRIBE) {
            String destination = accessor.getDestination();
            entryService.enterRoom(destination, sessionId);

        } else if (accessor.getCommand() == StompCommand.UNSUBSCRIBE) {
            entryService.quitRoom(sessionId);

        } else if (accessor.getCommand() == StompCommand.DISCONNECT) {
            entryService.quitSocket(sessionId);
        }

        return message;
    }

    private String getSocialEmailAndvalidateToken(StompHeaderAccessor accessor) {
        String accessToken = jwtProvider.resolveToken(accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION));

        if (accessToken == null || !jwtProvider.validate(accessToken))
            throw new AccountException(StatusCode.FILTER_ACCESS_DENIED);

        return jwtProvider.getSocialEmailAtSocket(accessToken);
    }
}

 

핵심 로직 흐름

 

요약

socket 연결 시 account에 sessionId를 저장 -> 채팅방 입장 시 session(account, room, sessionId) 테이블을 두어 account에 저장된 sessionId를 session.sessionId와 일치시킴 -> 채팅방 퇴장 시 session에 있는 sessionId를 초기화 -> socket 연결 해제 시 account sessionId 초기화

 

상대방 accountId.sessionId 와 session.sessionId이 같은 것이 있다면 어떤 room에 있는지 확인할 수 있게 됩니다.

 

 

디버거를 통해 socket 연결할 때 어떤 정보를 주는지 확인해 본 결과는 다음과 같습니다.

 

소켓 연결(CONNECT)

여기서 봐야할 부분은 simpSessionId입니다.

 

채팅방 구독(SUBSCRIBE)

  • simpSessionId값도 같이 오는 걸 볼 수 있는데 소켓 연결할 때와 값이 같은 것을 볼 수 있습니다.
  • simpDestination을 통해 어느 채팅방에 들어오려고 시도 중인지 알 수 있습니다.

소켓 연결 끊을 시(DISCONNECT)

  • 마찬가지로 연결할 때의 simpSessionId와 똑같습니다. 즉 이거로 어느 채팅방에 있는지 추적 가능하다는 결론이 나오게 됩니다!

구현

stompHandler에 있는 enterService의 코드입니다.

 

socket 연결 시 (CONNECT)

    @Transactional
    public void enterSocket(String socialEmail, String sessionId) {
        Account loginAccount = accountRepository.findBySocialEmail(socialEmail)
                .orElseThrow(() -> new AccountException(StatusCode.NOT_FOUND_ACCOUNT));

        loginAccount.updateSessionId(sessionId);
    }

 

Account의 부분 코드는 다음과 같습니다.

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "account_id")
    private Long id;

    --- 중략
    
    // 추가된 코드
    private String sessionId;
    
    public void updateSessionId(String sessionId) {
        this.sessionId = sessionId;
    }

account에 sessionId 컬럼을 추가해 어느 session에 있는지 확인할 수 있도록 하였습니다.

 

채팅방 입장 시 (SUBSCRIBE)

    @Transactional
    public void enterRoom(String destination, String sessionId) {
        Account loginAccount = accountRepository.findBySessionId(sessionId)
                .orElseThrow(() -> new AccountException(StatusCode.NOT_FOUND_ACCOUNT));

        Long roomId = Long.valueOf(destination.split("/")[4]);
        Room room = roomService.getRoomById(roomId);
        messageRepository.readAllMessage(loginAccount, room);

        Optional<Session> sessionOptional = sessionRepository.findByAccountAndRoom(loginAccount, room);

        if (sessionOptional.isEmpty()) {
            Session session = Session.of(loginAccount, room, sessionId);
            sessionRepository.save(session);
        } else {
            Session session = sessionOptional.get();
            session.updateSessionId(sessionId);
        }

    }

 

 

 

Long roomId = Long.valueOf(destination.split("/")[4]);

이 부분은 채가 보내는 채팅방 주소가 /queue/chat/rooms/1 이렇게 구성되어 있어 roomId를 가져오기 위한 코드입니다.

 

추가된 Session의 코드는 다음과 같습니다.

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Session extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "account_id")
    private Account account;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "room_id")
    private Room room;
    private String sessionId;

    public static Session of(Account account, Room room, String sessionId) {
        return Session.builder()
                .account(account)
                .room(room)
                .sessionId(sessionId)
                .build();
    }

    public void updateSessionId(String sessionId) {
        this.sessionId = sessionId;
    }
}

 

session에 있는 account가 room에 접속을 할 때 sessionId를 저장하게 됩니다.

 

이러면 상대방이 채팅방에 들어오게 되면 account에 있는 sessionId(account.sessionId)와 session에 있는 sessionId(session.sessionId)가 똑같게 됩니다.

 

메세지 보낼 때

    @Transactional
    public void createMessage(Long roomId, SaveMessageRequest saveMessageRequest) {
        Account sender = accountService.getAccountById(saveMessageRequest.accountId());
        Room room = roomService.getRoomById(roomId);

        Message message = messageRepository.save(handleMessage(sender, room, saveMessageRequest.content()));

        ChatResponse chatResponse = ChatResponse.of(roomId, message);
        messagingTemplate.convertAndSend("/queue/chat/rooms/" + roomId, chatResponse);

        sendFcm(message, sender, room);
    }

    private void sendFcm(Message message, Account sender, Room room) {
        Account receiver = message.getReceiver();
        Optional<Session> session = sessionRepository.findBySessionIdAndRoom(receiver.getSessionId(), room);
        if (session.isPresent())
            return;
        String nickname = friendshipService.getFriendNicknameSingle(receiver, sender);
        fcmService.sendPushMessage(receiver.getFcmToken(), NotificationDto.NotifyParams.ofSendMessage(message, nickname), 0L);
    }

sendFcm 매서드 쪽을 보면 receiver는 받는 사람이고, receiver의 sessionId와 메세지를 보내는 room으로 session을 찾았을 때, session이 존재하면 채팅에 참여 중인 것으로 확인이 되는 것이고, 그게 아닐 경우 fcm을 보내는 로직입니다.

 

채팅방 퇴장 시 (UNSUBSCRIBE)

    @Transactional
    public void quitRoom(String sessionId) {
        Session session = sessionRepository.findBySessionId(sessionId)
                .orElseThrow(() -> new RoomException(StatusCode.NOT_FOUND_SESSION));

        messageRepository.readAllMessage(session.getAccount(), session.getRoom());

        session.updateSessionId("");
    }

 

session에 있는 sessionid를 ""로 바꾸게 됩니다. 이렇게 되면 account에 있는 sessionId(account.sessionId)와 session에 있는 sessionId(session.sessionId)의 값이 달라지게 됩니다. 즉 상대방이 메세지를 보내게 되면 Fcm Token을 발송하게 됩니다.

 

소켓 연결을 끊을 때 (DISCONNECT)

    @Transactional
    public void quitSocket(String sessionId) {
        Account loginAccount = accountRepository.findBySessionId(sessionId)
                .orElseThrow(() -> new AccountException(StatusCode.NOT_FOUND_ACCOUNT));

        loginAccount.updateSessionId("exit");
    }

account.sessionId를 "exit"으로 변경합니다. 

 

결론

 이렇게 구현해서 Fcm Token이 올 땐 오고 안올 땐 안오는지 테스트 해보았는데 정상적으로 작동했습니다. 채팅 구현은 어떻게 구현하느냐에 따라 방식이 크게 달라지는 것 같습니다. 현재 프론트는 어떻게 진행하는지, 백엔드(나의 생각)은 어떤 식으로 구현하면 좋을 지 충분한 논의를 나누어보고 소통하며 함께 진행해야 금방 끝나는 작업인 것 같습니다. 다행히 저는 충분한 의사소통으로 인해 금방 작업할 수 있었습니다. 저의 미숙한 설명으로 인해 이해가 잘 안될 수도 있습니다. 아래에 제가 진행한 프로젝트의 github를 남기겠습니다. 이렇게 구현함으로써 발생할 수 있는 문제점, 이해가 안가는 부분들이 있으시다면 댓글로 달아주세요!! 함께 고민하고 최적화를 해나가겠습니다.

 

github

https://github.com/DoDream-dev/Tincle-Server

PlayStore

팅클(Tincle) - 우리들만의 피드 - Apps on Google Play

AppStore

‎팅클(Tincle)