우리가 흔히 사용하는 메신저나 실시간 고객센터 채팅은 어떻게 동작할까요? 단순한 HTTP 요청으로는 구현이 어렵습니다. 사용자가 채팅을 입력할 때마다 서버에 계속 요청을 보내고, 또 서버의 응답을 기다리는 방식은 비효율적일 뿐 아니라 실시간성이 떨어지죠.
이럴 때 필요한 것이 바로 소켓(Socket) 통신입니다. 특히 Flutter 같은 모바일 프레임워크에서 실시간 기능을 구현하려면 Socket과 WebSocket에 대한 이해가 필수입니다. 이번 글에서는 소켓 통신이란 무엇인지, 그리고 기존 HTTP 방식과 어떻게 다른지를 살펴보겠습니다.
소켓 통신의 개념과 HTTP 방식과의 차이
기본적으로 우리가 API를 사용할 때 쓰는 HTTP 통신은 요청 → 응답 → 연결 종료로 구성된 비연결 지향형 방식입니다. 클라이언트가 서버에 요청을 보내고 응답을 받으면 그 연결은 종료됩니다. 이런 구조는 대부분의 정보 조회에는 적합하지만, 실시간으로 메시지를 주고받아야 하는 채팅, 알림, 라이브 피드 같은 기능에는 매우 비효율적입니다.
반면 소켓 통신은 처음 연결을 맺으면 그 연결을 지속적으로 유지합니다. 즉, 한 번 연결하면 계속 같은 통로를 통해 데이터를 실시간으로 주고받을 수 있습니다. 이 구조 덕분에 클라이언트와 서버가 상호작용하는 데 지연이 거의 없고, 양방향 통신이 가능합니다.
그리고 이 소켓 연결을 웹 환경에서 구현하는 것이 바로 WebSocket입니다. WebSocket은 HTTP와 다르게 연결을 끊지 않고 계속 유지하며, 텍스트 기반의 프로토콜을 통해 메시지를 실시간으로 주고받습니다.
여기서 한 단계 더 나아가, WebSocket 위에서 사용되는 STOMP(Simple Text Oriented Messaging Protocol)라는 프로토콜이 있습니다. STOMP는 메세지를 주제(Topic) 기반으로 발행-구독(Pub/Sub)할 수 있게 해주어, 채팅방마다 구독하는 형태의 구조를 구현하기에 적합합니다.
이처럼 HTTP 방식과 소켓 방식은 각각의 특징과 목적이 다르며, 채팅 기능과 같은 실시간 처리가 필요한 경우에는 반드시 소켓 기반 통신을 사용해야 합니다.
Flutter에서 소켓 통신 구현하기
Flutter에서 실시간 채팅 기능을 구현할 때는 stomp_dart_client 패키지를 활용해 WebSocket에 쉽게 연결하고 STOMP 프로토콜을 사용할 수 있습니다. 특히 서버가 SockJS 기반으로 구성되어 있다면 StompConfig.sockJS 생성자를 사용해야 하며, 보안을 위해 헤더에 토큰을 포함시킬 수도 있습니다.
기본 구현 흐름은 다음과 같습니다:
- 소켓 연결을 위한 StompClient 설정
- 연결 후 특정 주제를 구독 (subscribe)
- 메시지를 수신할 때마다 처리
- 메시지 전송 기능 구현
- 연결 유지 및 종료 로직 관리
전체 예제 코드
import 'dart:async';
import 'dart:convert';
import 'package:stomp_dart_client/stomp_dart_client.dart';
import 'package:stomp_dart_client/stomp_config.dart';
import 'package:stomp_dart_client/stomp_frame.dart';
// 채팅 메시지 스트림과 전송 기능을 담은 클래스
class ChatSocket {
ChatSocket({
required this.messageStream,
required this.sendMessage,
});
final Stream<ChatRoom> messageStream;
final void Function({
required int roomId,
required String content,
}) sendMessage;
}
// ChatRepository 클래스 내부에 socket 연결 함수 작성
class ChatRepository {
final String host = 'http://localhost:8080';
final String bearerToken = 'your_jwt_token_here'; // 인증 토큰
ChatSocket connectSocket() {
StompClient? stompClient;
final chatStreamController = StreamController<ChatRoom>(
onListen: () => stompClient?.activate(),
onCancel: () => stompClient?.deactivate(),
);
stompClient = StompClient(
config: StompConfig.sockJS(
url: '$host/ws',
webSocketConnectHeaders: {
'Authorization': bearerToken,
'content-type': 'application/octet-stream',
'transports': ['websocket'],
},
onConnect: (StompFrame frame) {
stompClient?.subscribe(
destination: '/user/queue/pub',
callback: (frame) {
final data = jsonDecode(frame.body!);
chatStreamController.add(ChatRoom.fromJson(data));
},
);
},
onWebSocketError: (error) => print('Socket error: $error'),
),
);
return ChatSocket(
messageStream: chatStreamController.stream,
sendMessage: ({required content, required roomId}) {
stompClient?.send(
destination: '/chat-socket/chat.send',
body: jsonEncode({'roomId': roomId, 'content': content}),
);
},
);
}
}
// 사용 예시 (뷰모델 등에서)
void main() {
final repository = ChatRepository();
final socket = repository.connectSocket();
socket.messageStream.listen((chat) {
print('New message from room ${chat.roomId}: ${chat.messages.last.content}');
});
socket.sendMessage(roomId: 1, content: '안녕하세요!');
}
위의 예시는 서버와의 연결, 구독, 수신, 전송 흐름을 모두 포함하고 있어 실무에서 바로 적용 가능한 구조입니다. 단, ChatRoom 모델 클래스는 실제 앱 로직에 맞게 수정해 주세요.
소켓 통신을 앱 상태에 연결하기
소켓 통신을 단순히 구현하는 것에 그치지 않고, 앱의 전역 상태에 적절히 연결해줘야 실제 서비스에 쓸 수 있는 기능이 됩니다. 예를 들어 채팅 메시지를 실시간으로 받아 화면에 띄우거나, 채팅방 목록을 자동으로 갱신해야 할 때, 상태 관리와의 연동이 필수입니다.
Flutter에서는 Riverpod을 활용하면 전역 상태 관리가 매우 효율적입니다. 채팅 상태를 NotifierProvider로 관리하면, 메시지가 들어올 때마다 자동으로 UI가 업데이트됩니다. 아래 예시는 ChatGlobalViewModel이라는 이름의 전역 뷰모델을 정의하고, 거기서 소켓을 연결하며 메시지를 받아 상태를 업데이트하는 로직입니다.
final chatGlobalViewModel =
NotifierProvider<ChatGlobalViewModel, ChatGlobalState>(() => ChatGlobalViewModel());
class ChatGlobalViewModel extends Notifier<ChatGlobalState> {
final chatRepository = ChatRepository();
ChatSocket? _chatSocket;
StreamSubscription<ChatRoom>? _subscription;
@override
ChatGlobalState build() {
_initialize();
return ChatGlobalState(chatRooms: [], currentChat: null);
}
Future<void> _initialize() async {
final rooms = await chatRepository.list();
if (rooms != null) {
state = state.copyWith(chatRooms: rooms);
_connectSocket();
}
}
void _connectSocket() {
_chatSocket = chatRepository.connectSocket();
_subscription = _chatSocket!.messageStream.listen((newChat) {
// 메시지 수신 시 상태 업데이트
_handleIncomingMessage(newChat);
});
ref.onDispose(() {
_subscription?.cancel();
});
}
void _handleIncomingMessage(ChatRoom newChat) {
final updatedRooms = [
for (final room in state.chatRooms)
if (room.roomId == newChat.roomId) newChat else room
];
state = state.copyWith(chatRooms: updatedRooms);
// 현재 열려있는 채팅방이 해당 채팅일 경우 메시지만 추가
if (state.currentChat?.roomId == newChat.roomId) {
state = state.copyWith(
currentChat: state.currentChat!.copyWith(
messages: [...state.currentChat!.messages, newChat.messages.last],
),
);
}
}
void send(String content) {
final current = state.currentChat;
if (current != null) {
_chatSocket?.sendMessage(roomId: current.roomId, content: content);
}
}
}
위와 같은 구조를 사용하면 채팅방 간 메시지 공유, 실시간 알림, 자동 스크롤 등 복잡한 UI 처리도 깔끔하게 구현할 수 있습니다. ViewModel은 상태 업데이트만 담당하고, 실제 로직은 ChatSocket 내부로 위임해 유지보수성과 테스트도 향상됩니다.
마치며...
실시간 채팅 기능을 구현하려면 기존 HTTP 방식으로는 한계가 있습니다. 소켓 통신, 특히 WebSocket과 STOMP 프로토콜을 활용하면 연결을 유지한 채 실시간 데이터 송수신이 가능해져 사용자 경험을 크게 향상시킬 수 있습니다.
Flutter에서는 stomp_dart_client와 같은 라이브러리를 활용해 비교적 간단하게 소켓 연결을 구성할 수 있으며, 이를 Riverpod 같은 상태 관리 도구와 연동하면 강력하면서도 안정적인 채팅 시스템을 만들 수 있습니다. 단순한 실습을 넘어서, 실무에서도 확장성과 유지보수성 높은 아키텍처를 구현할 수 있습니다.
세 줄 요약
- 실시간 채팅 구현을 위해선 HTTP보다 WebSocket 기반 소켓 통신이 효과적이다.
- Flutter에서는 stomp_dart_client를 활용해 STOMP 기반 실시간 통신이 가능하다.
- 소켓 통신은 상태관리(ViewModel)와 결합해야 진짜 “서비스용” 기능이 된다.
'IT' 카테고리의 다른 글
앱 UI 구현, 무엇부터 시작해야 할까요? (0) | 2025.04.18 |
---|---|
[항해플러스 | 항해99] 1인 개발자를 위한 백엔드 부트캠프, 할인코드 있어요! (2) | 2025.04.17 |
Docker에 대해 알아보자! (2) | 2025.04.16 |
코딩 몰라도 개발하는 시대, 바이브 코딩 (2) | 2025.04.15 |
Firebase를 연동해보자! (0) | 2025.04.15 |