소켓 없이 채팅을 폴링(Polling)으로 구현한 이유와 코드

채팅 기능을 만든다고 하면 다들 관성적으로 WebSocket이나 Socket.io를 떠올린다. 나 역시 그랬다. “채팅 = 소켓”은 공식이니까.

하지만 이번 프로젝트에서는 소켓이 아닌 3초 간격의 API 폴링(Short Polling) 방식을 선택했다. “구식 아니냐”, “서버 부하 괜찮냐”는 걱정이 생길 수 있다. 하지만 MVP(Minimum Viable Product) 단계에서 제한된 시간과 비용을 고려했을 때, 이것만큼 합리적인 선택은 없었다.

오늘은 왜 소켓을 버렸는지, 그리고 플러터에서 어떻게 3초 폴링을 효율적으로 구현했는지 정리해본다.

1. 딜레마: 소켓 vs 폴링

처음엔 당연히 소켓 서버를 붙이려고 했다. 하지만 현실적인 문제들이 발목을 잡았다.

소켓(WebSocket)의 비용

  • 구현 복잡도: 연결 유지(Heartbeat), 끊어졌을 때 재연결(Reconnection), 로드 밸런싱 등 인프라 레벨에서 신경 쓸게 너무 많다.
  • 서버 리소스: 연결 하나하나가 메모리를 잡고 있는다. 동시 접속자가 늘어나면 서버 스케일링이 까다로워진다.
  • 시간 부족: 백엔드, 프론트 혼자 다 하는데 소켓 안정화까지 할 시간이 없었다.

폴링(Polling)의 매력

  • 압도적인 개발 속도: 그냥 기존에 쓰던 HTTP API를 Timer로 돌리면 끝이다. 1시간이면 구현 가능하다.
  • 충분한 UX: 카카오톡 같은 메신저가 아닌 이상, 3초 정도의 딜레이는 사용자가 크게 불편함을 느끼지 않는다. (실제로 테스트해보니 “느리다”는 피드백이 거의 없었다.)

그래서 결론은? “일단 폴링으로 빠르게 출시하고, 나중에 유저가 폭발하면 그때 소켓으로 넘어가자.” 였다.

2. 구현: 3초의 마법

구현은 GetX 컨트롤러 내부에서 Timer.periodic을 사용하는 것으로 간단히 해결했다.

핵심 로직 (ChatRoomController.dart)

무작정 3초마다 전체 리스트를 긁어오면 데이터 낭비다. 그래서 _lastMessageTime(마지막 메시지 시간)을 기억해뒀다가, “이 시간 이후의 메시지만 줘” 라고 요청하는 증분 업데이트(Incremental Update) 방식을 썼다.

// ChatRoomController.dart

Timer? _pollTimer;
String? _lastMessageTime; // 마지막으로 받은 메시지 시간

@override
void onInit() {
  super.onInit();
  _startPolling(); // 방 입장시 폴링 시작
}

void _startPolling() {
  _stopPolling(); // 안전하게 기존 타이머 있다면 해제
  
  // 3초마다 새 메시지 확인
  _pollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
    _pollNewMessages();
  });
}

Future<void> _pollNewMessages() async {
  if (roomId == null) return;
  
  try {
    // [핵심] 마지막 확인 시간 이후의 데이터만 가볍게 요청
    final result = await HomeApi.to.reqNewChatMessages(
      roomId!,
      _lastMessageTime ?? '',
    );
    
    final newMessages = result['messages'] ?? [];
    if (newMessages.isNotEmpty) {
      // 1. UI 목록에 추가
      chatList.addAll(newMessages); 
      // 2. 마지막 시간 갱신
      _lastMessageTime = newMessages.last['createdAt']; 
      // 3. 스크롤 하단으로
      scrollToBottom(); 
    }
  } catch (e) {
    // 에러나면 로그만 찍고 무시 (다음 턴에 다시 시도하니까)
    Get.log('Polling Error: $e');
  }
}

3. 디테일 챙기기 (배터리 & 데이터 보호)

폴링의 가장 큰 단점은 앱을 안 쓰고 있어도 계속 요청을 날린다는 거다. 데이터 도둑이 되지 않으려면 앱 생명주기(Lifecycle) 관리가 필수다.

WidgetsBindingObserver를 사용해서 앱이 백그라운드로 내려가면 타이머를 끄고, 다시 올라오면 켜주는 로직을 추가했다.

// ChatRoomController.dart

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.paused) {
    // 앱이 내려가면 폴링 중단 (배터리/데이터 절약)
    _stopPolling(); 
  } else if (state == AppLifecycleState.resumed) {
    // 앱이 다시 올라오면 폴링 재개 & 즉시 한 번 갱신
    _startPolling(); 
    _pollNewMessages(); 
  }
}

4. 실제 API 호출부 (HomeApi.dart)

프론트 요청은 심플하다. 파라미터로 afterTime만 잘 넘겨주면 된다.

// HomeApi.dart

// GET /chat/rooms/{roomId}/messages?afterTime={2024-02-03T12:00:00}
Future<Map<String, dynamic>> reqNewChatMessages(String roomId, String afterTime) async {
  return await _dio.get(
    '/chat/rooms/$roomId/messages',
    queryParameters: afterTime.isNotEmpty ? {'afterTime': afterTime} : {},
  );
}

정리

“채팅인데 소켓을 안 쓴다고?” 처음엔 나도 의구심이 있었지만, 실제로 구현해보니 MVP 모델에는 최적의 선택이었다. 복잡한 소켓 연결 관리할 시간에 채팅방 UI UX에 더 신경 쓸 수 있었고, 사용자 경험도 크게 해치지 않았다.

물론 유저가 수만 명이 되면 서버 부하 때문에라도 소켓이나 MQTT로 넘어가야겠지만, 그건 “즐거운 비명을 지를 때” 고민해도 늦지 않다. (제발 그런 날이 오기를… 🙏)

댓글 남기기