소켓 없는 채팅 백엔드 설계

지난 글에서 프론트엔드가 3초마다 “새 메시지 있어?”라고 물어보는(polling) 구조를 소개했다. 이제 백엔드가 답할 차례다.

“3초마다 요청이 들어온다고? 서버 DB 괜찮아?” 나도 이 부분이 가장 걱정이었다. 앱 사용자가 1,000명만 되어도 단순히 계산하면 초당 수백 건의 조회가 발생한다. 그래서 백엔드 설계의 핵심은 “최대한 빠르고 가볍게 응답하기”였다.

오늘은 Spring Boot와 JPA를 사용해 무거운 소켓 대신 가벼운 조회 API로 채팅을 처리한 경험을 공유한다.

1. 구조적 선택: Stateless의 매력

소켓 서버를 안 쓰고 REST API로 구현했을 때 얻을 수 있는 가장 큰 이점은 Stateless(무상태)다.

  • Socket: “누가 지금 접속해 있는가?”(세션)를 메모리에 들고 있어야 한다. 서버를 여러 대 띄우면(Scale-out) Redis 같은 외부 저장소를 써서 세션 공유를 해야 하고, 아키텍처가 복잡해진다.
  • REST API: 그냥 DB 보고 리턴값 주면 땡이다. 서버가 죽었다 살아나건, 로드밸런서가 어디로 연결하건 상관없다.

덕분에 서버 확장성 면에서는 훨씬 유리했다.

2. 핵심 쿼리: “그 시간 이후의 것만 줘”

프론트에서 afterTime 파라미터를 들고 오면, 서버는 딱 그 시간 이후에 생성된 메시지만 긁어와야 한다. JPA Repository에 메서드 하나만 잘 짜면 된다.

// ChatController.java

@GetMapping("/rooms/{chatRoomId}/messages")
public ApiResponse<List<MessageResponse>> getMessages(
    @PathVariable String chatRoomId,
    @RequestParam(required = false) LocalDateTime afterTime) {
    
    // afterTime이 있으면 -> 그 시간 이후 데이터만 (Polling)
    // afterTime이 없으면 -> 그냥 최근 N개 (처음 방 입장시)
    List<MessageResponse> messages = chatService.getMessages(chatRoomId, afterTime);
    
    return ApiResponse.success("조회 성공", messages);
}

실제 쿼리는 이렇게 나간다. 인덱스(roomIdcreatedAt)를 잘 태우는 게 핵심이다.

-- 대충 이런 느낌
SELECT * FROM chat_message 
WHERE room_id = ? 
  AND created_at > ? 
ORDER BY created_at ASC;

이렇게 하면 99%의 요청은 빈 리스트([])를 반환한다. (3초 동안 새 메시지가 없을 확률이 높으니까). 빈 리스트 반환은 DB와 서버에 거의 부하를 주지 않는다. 이게 3초 폴링이 가능한 이유다.

3. 메시지 전송과 FCM의 꿀조합

폴링은 데이터를 가져가는(Pull) 방식이다. 그럼 내가 메시지를 보냈을 때(Push) 상대방은 어떻게 빨리 알까? 물론 상대방도 폴링 중이면 3초 안에 알게 되겠지만, 우리에겐 FCM(푸시 알림)이라는 강력한 무기가 있다.

앱이 백그라운드에 있거나 화면이 꺼져있을 땐 폴링이 멈춘다. 이때 알림을 주는 역할을 FCM이 한다. 소켓 연결 유지가 필요 없는 이유기도 하다.

// ChatService.java

@Transactional
public void sendMessage(String roomId, MessageRequest req) {
    // 1. DB 저장 (일단 데이터는 남겨야 하니까)
    ChatMessage message = saveMessage(roomId, req);
    
    // 2. 채팅방에 있는 다른 멤버들에게 FCM 발송
    // -> 소켓처럼 실시간 패킷은 아니지만, 알림으로 "새 메시지 왔어"라고 찔러줌
    members.stream()
        .filter(m -> !m.isMe(req.senderId))
        .forEach(m -> fcmService.sendChatNotification(m.token, ...));
}

즉, 실시간성은 “3초 폴링 + FCM” 조합으로 커버치고, 데이터 정합성은 DB가 책임지는 구조다.

4. 성능 최적화 고민들

트래픽이 늘어나면 DB 조회가 부담될 수 있다. 여기서 더 최적화한다면?

  1. Cache-ControlafterTime 이후에 메시지가 없으면 304 Not Modified를 응답해서 네트워크 비용을 더 줄일 수 있다.
  2. Redis Caching: 최근 메시지 10~20개를 Redis List 자료구조에 넣어두고, 폴링 요청을 DB까지 안 가게 막을 수도 있다. (지금은 유저가 적어서 도입 안 함)

정리

“소켓 없이 채팅이 될까?” 라는 의문은 “네, 됩니다. 심지어 꽤 잘 됩니다.”로 결론 났다.

개발자의 자존심 때문에 오버엔지니어링을 하기보다, **현재 상황(인력 부족, 빠른 출시 필요)**에 맞춰 기술을 선택하는 것. 그것이 진짜 실력 아닐까? (라고 스스로 위로해본다… 언젠가 대박 나서 소켓 서버 짜는 날이 오길!)

댓글 남기기