의료 앱을 개발하면서 가장 복잡했던 기능 중 하나가 바로 실시간 채팅이었다.
단순히 메시지를 보내고 받는 것만으로는 부족하고, 읽지 않은 메시지 개수 관리, 이미지 처리, 다양한 메시지 타입(예약, 진료 등) 처리까지 고려해야 했다... 사실 기본적인 crud만 구현하기에는 한국인들에게 채팅은 너무나도 익숙한 기능이다. 카카오톡 정도는 되어야 채팅이라고 할 수 있는 느낌이랄까. 덕북에 신경써야 하는 것들이 참 많은데, 어중간하게 하면 개발자 스스로도 마음에 썩 차지 않는터라... 정말 삽질을 많이했다. 그냥 단순히 기능만 한다 치면 크게 어렵지는 않다.
REST API로 폴링(polling) 방식을 고려한다면 아래와 같은 문제가 발생하게 된다.
1. 배터리 소모: 주기적으로 서버에 요청을 보내면 배터리를 많이 소모한다.
2. 실시간성 부족: 폴링 주기를 짧게 하면 서버 부하가 늘고, 길게 하면 메시지가 늦게 도착한다.
3. 서버 부하: 불필요한 요청이 계속 발생
그래서 WebSocket 기반의 실시간 통신이 필요했고, Socket.IO를 선택했다. Socket.IO는 WebSocket이 지원되지 않는 환경에서도 자동으로 폴링으로 fallback해주고, 재연결 로직도 내장되어 있어서 편리하다.
처음에는 단순하게 연결만 했는데, 인증 토큰이 만료되거나 네트워크가 끊기면 문제가 발생한다.
(keepAlive: true)
class SocketService extends _$SocketService {
Socket? _socket;
Future<Socket?> build() async {
// Provider가 dispose될 때 소켓도 정리
ref.onDispose(() {
_socket?.dispose();
_socket = null;
});
// 인증 토큰 가져오기
final tokenMap = await ref.read(authProvider.notifier).getTokenFromDevice();
if (tokenMap == null) {
_socket?.disconnect();
return null;
}
final accessToken = tokenMap['accessToken']!;
// 이미 연결되어 있으면 재사용
if (_socket != null && _socket!.connected) {
return _socket;
}
// 기존 소켓 정리
_socket?.dispose();
// 새 소켓 생성
_socket = io(
Config.example,
OptionBuilder()
.setPath('/chat/socket.io')
.setTransports(['websocket']) // WebSocket만 사용
.setAuth({'token': 'Bearer $accessToken'}) // 인증 토큰 포함
.enableReconnection() // 자동 재연결
.build(),
);
_setupListeners();
// 연결 완료를 기다림
final completer = Completer<Socket?>();
_socket!.onConnect((_) {
if (!completer.isCompleted) completer.complete(_socket);
});
_socket!.onConnectError((error) {
if (!completer.isCompleted) completer.completeError(error as Object);
});
return completer.future;
}
}
처음에는 채팅방을 열 때마다 새로 연결했는데, 이렇게 하면:
그래서 이미 연결된 소켓이 있으면 재사용하도록 한다. keepAlive: true로 설정해서 Provider가 dispose되지 않도록 했다.
소켓 이벤트는 별도의 핸들러로 분리했다. 이렇게 하면 테스트하기도 쉽고, 로직을 분리할 수 있다.
void _setupListeners() {
if (_socket == null) return;
_socket!.clearListeners(); // 기존 리스너 제거 (중복 방지)
final eventHandler = ref.read(chatEventHandlerProvider);
_socket!
..on('connect', (_) => log('[SocketProvider] Event: connect'))
..on('disconnect', (reason) => log('[SocketProvider] Event: disconnect - $reason'))
..on('channel.message.new', eventHandler.handleMessage) // 새 메시지
..on('room.user.join', eventHandler.handleJoin) // 사용자 입장
..on('room.invited', eventHandler.handleInvite) // 초대
..on('*', (data) {
log('[SOCKET] 모든 이벤트 수신: $data'); // 디버깅용
});
}
채팅 기능은 상태가 복잡하다:
처음에는 하나의 Provider에 다 넣으려고 했는데, 코드가 너무 길어지고 테스트하기 어려워졌다. 그래서 역할별로 분리하도록 했음.
현재 보고 있는 채팅방의 메시지와 상태를 관리한다.
(keepAlive: true)
class Chat extends _$Chat {
ChatState build() {
return ChatState.init();
}
// 메시지 목록에 새 메시지 추가
void handleIncomingMessage(ChatMessage message) async {
try {
if (message.files.isEmpty) {
// 텍스트 메시지는 바로 추가
state = state.copyWith(
showNewMessageIndicator: true,
messages: [message, ...state.messages],
);
}
if (message.files.isNotEmpty) {
// 이미지 메시지는 파일 다운로드 후 추가
final updatedFiles = await Future.wait(
message.files.map((file) async {
final res = await ref
.read(imageHandlerProvider.notifier)
.handleFileDownloadAndSave(file);
return file.copyWith(
url: res?.url,
localPath: res?.localPath,
);
}),
);
final updatedMessage = message.copyWith(files: updatedFiles);
state = state.copyWith(
messages: [updatedMessage, ...state.messages],
showNewMessageIndicator: true,
);
}
} catch (e, stackTrace) {
// 에러 처리
}
}
}
모든 채팅방 목록과 읽지 않은 메시지 개수를 관리한다.
(keepAlive: true)
class ChatRooms extends _$ChatRooms {
Future<ChatRoomsState> build() async {
final userId = ref.watch(userProfileProvider).user.id;
if (userId.isEmpty) {
return ChatRoomsState.init();
}
return _fetchData();
}
// 읽지 않은 메시지 개수 업데이트
void updateUnreadCount(ChatMessage message) {
final currentState = state.value;
if (currentState == null) return;
final updatedRooms = currentState.chatRooms.map((room) {
if (room.chatRoom.room.id == message.roomId) {
return room.copyWith(unreadCount: room.unreadCount + 1);
}
return room;
}).toList();
state = AsyncData(currentState.copyWith(chatRooms: updatedRooms));
}
// 마지막 메시지 업데이트
void updateLastMessage(ChatMessage newMessage) {
final currentState = state.value;
if (currentState == null) return;
final updatedRooms = currentState.chatRooms.map((room) {
if (room.chatRoom.room.id == newMessage.roomId) {
return room.copyWith(lastMessage: newMessage);
}
return room;
}).toList();
state = AsyncData(currentState.copyWith(chatRooms: updatedRooms));
}
}
이렇게 분리하니 각 Provider의 역할이 명확해지고, 테스트하기도 쉬워졌다.
메시지를 전송할 때는 낙관적 업데이트(Optimistic Update)를 사용했다. 서버 응답을 기다리지 않고 먼저 UI에 표시하고, 실패하면 롤백하는 방식이다. 빠르게 화면에 보여주기 위해 이렇게 작업했다.
Future<Map<String, dynamic>> sendMessage(
String roomId,
String uuid, {
String? message,
XFile? file,
}) async {
String? fileId;
final imageNotifier = ref.read(imageHandlerProvider.notifier);
try {
// 메시지와 파일 중 하나는 반드시 있어야 함
if ((message == null || message.isEmpty) && (file == null)) {
throw Exception('메시지와 파일 중 하나는 반드시 있어야 합니다.');
}
// 파일이 있으면 먼저 업로드
if (file != null) {
final res = await imageNotifier.uploadFile(roomId, file);
fileId = res?[0]['id'];
}
// 메시지 전송
final data = await ref.read(chatServiceProvider).sendMessage(
roomId,
uuid,
message: message,
fileId: fileId,
);
// 서버 응답으로 받은 메시지를 상태에 추가
final newMessage = ChatMessage.fromJson(data["data"]);
// 파일이 있으면 DB에 저장
if (newMessage.files.isNotEmpty) {
await imageNotifier.saveFilesToDB(
newMessage.files.where((file) => file != null).toList()
as List<FileModel>,
);
}
return data["data"];
} catch (e, stackTrace) {
// 에러 발생 시 Crashlytics에 기록
await ref.read(crashlyticsServiceProvider).recordError(
e,
stackTrace,
reason: '메시지 전송 실패',
information: [
'채팅방 ID: $roomId',
'UUID: $uuid',
'메시지: $message',
'파일: ${file?.name}',
],
);
rethrow;
}
}
메시지 전송 시 UUID를 함께 보내는 이유는:
1. 중복 전송 방지: 같은 메시지가 여러 번 전송되는 것을 방지
2. 임시 메시지 식별: 서버 응답이 오기 전까지 임시로 메시지를 식별
3. 에러 추적: 어떤 메시지 전송이 실패했는지 추적 가능
사실 UUID는 크게 필요하지는 않는데, 레거시 코드긴 함. 처음 개발시에 백엔드 개발자랑 테스트하면서 들어간 코드다. 해당 코드는 없앨 예정임.
소켓으로 메시지를 받았을 때, 현재 보고 있는 채팅방인지 아닌지에 따라 다르게 처리해야 했다. 왜냐면 읽은 메시지인지 아닌지 여부를 판별하기 위해 디바이스에 마지막에 확인한 메시지 id를 저장하고 있기 때문. 이는 1:1 대화창이기 때문에 가능한 방식이다. 이 앱같은 경우에는 병원과 환자가 일대일로 대화를 하기 때문에 다대 일이라는 선택지가 없어서 이렇게 작업했다.
현재 채팅방의 메시지라면:
1. 메시지를 바로 목록에 추가
2. 읽지 않은 메시지 개수는 증가시키지 않음 (이미 보고 있으니까)
3. 마지막 메시지 업데이트
void _handleChatInRoom(ChatMessage newMessage, Map<String, dynamic> message) {
final chatNotifier = ref.read(chatProvider.notifier);
final chatRoomsNotifier = ref.read(chatRoomsProvider.notifier);
// 메시지를 채팅 화면에 추가
chatNotifier.handleIncomingMessage(newMessage);
// 읽지 않은 메시지 개수 초기화 (이미 보고 있으니까)
chatRoomsNotifier.resetUnreadCount(newMessage.roomId);
// 마지막 메시지 업데이트
chatRoomsNotifier.updateLastMessage(newMessage);
}
다른 채팅방의 메시지라면:
1. 채팅방 목록의 마지막 메시지만 업데이트
2. 읽지 않은 메시지 개수 증가
3. 채팅방 목록에 없으면 새로 추가
Future<void> _handleChatOutsideRoom(ChatMessage newMessage) async {
try {
final chatRoomsNotifier = ref.read(chatRoomsProvider.notifier);
await Future.microtask(() async {
final chatRoomsState = ref.read(chatRoomsProvider);
final chatRooms = chatRoomsState.value?.chatRooms ?? [];
final exists = chatRooms
.any((room) => room.chatRoom.room.id == newMessage.roomId);
// 채팅방이 목록에 없으면 새로고침
if (!exists) {
ref.invalidate(chatRoomsProvider);
}
// 마지막 메시지와 읽지 않은 개수 업데이트
chatRoomsNotifier.updateLastMessage(newMessage);
chatRoomsNotifier.updateUnreadCount(newMessage);
});
} catch (e) {
// 에러 발생 시 재초기화 시도
ref.invalidate(chatRoomsProvider);
// ...
}
}
이미지 메시지는 텍스트 메시지보다 훨씬 복잡하다. 업로드, 다운로드, 로컬 저장, 캐싱까지 고려해야 한다.
이미지를 전송할 때는:
1. 파일 크기 체크 (5MB 제한)
2. 로컬에 임시 저장
3. 서버에 업로드
4. 업로드된 파일 ID로 메시지 전송
5. DB에 파일 정보 저장
Future<({bool isSuccess, String? errorMessage, String? fileId})>
handleImageUpload(String roomId, XFile image) async {
try {
final file = File(image.path);
final fileSize = await file.length();
// 파일 크기 체크
if (fileSize >= maxImageSize) {
return (
isSuccess: false,
errorMessage: '전송 가능한 이미지의 최대 용량은 5mb입니다.',
fileId: null,
);
}
// 로컬에 임시 저장
final localPath = await saveFileToLocal(image);
if (localPath == null) {
return (
isSuccess: false,
errorMessage: '파일 저장에 실패했습니다.',
fileId: null,
);
}
// 서버에 업로드
final res = await uploadFile(roomId, image);
if (res == null || res.isEmpty) {
return (
isSuccess: false,
errorMessage: '파일 업로드에 실패했습니다.',
fileId: null,
);
}
final fileId = res[0]['id'] as String;
// DB에 파일 정보 저장
await saveFileToDB(
FileModel(
id: fileId,
name: image.name,
localPath: localPath,
),
);
return (
isSuccess: true,
errorMessage: null,
fileId: fileId,
);
} catch (e) {
return (
isSuccess: false,
errorMessage: '이미지 처리에 실패했습니다.',
fileId: null,
);
}
}
메시지를 받았을 때 이미지가 포함되어 있으면:
1. 서버에서 이미지 다운로드
2. 로컬에 저장
3. 파일 정보 업데이트
처음에는 매번 다운로드하도록 했지만, 이미 다운로드한 이미지도 다시 다운로드하면서 계속 비용을 발생시키기 때문에 로컬 디바이스의 특정 경로에 폴더를 만들어서 전송한 이미지를 저장하고, 이미지에 대한 정보도 sqflight를 통해 저장하도록 했다.
Future<FileModel?> handleFileDownloadAndSave(FileModel file) async {
try {
// 이미 로컬에 저장된 파일이 있으면 재다운로드하지 않음
if (file.localPath != null) {
final localFile = File(file.localPath!);
if (await localFile.exists()) {
return file;
}
}
// 서버에서 다운로드
final downloadedFile = await downloadFile(file.id);
// 로컬에 저장
final localPath = await saveFileToLocal(downloadedFile);
// DB 업데이트
await updateFileUrlToDB(file.id, downloadedFile.url, localPath);
return file.copyWith(
url: downloadedFile.url,
localPath: localPath,
);
} catch (e) {
debugPrint('파일 다운로드 실패: $e');
return file; // 실패해도 원본 파일 정보 반환
}
}
처음에는 여러 이미지를 동시에 처리(Future.wait)했는데, 메모리 부족 문제가 발생했다. 그래서 순차적으로 처리하도록 변경해야 했다.
Future<List<ChatMessage>> _processMessages(List<dynamic> data) async {
final List<ChatMessage> messages = [];
// 순차적으로 처리 (동시 처리 대신)
for (var msg in data) {
final chatMessage = ChatMessage.fromJson(msg);
if (chatMessage.files.isNotEmpty) {
final List<FileModel?> updatedFiles = [];
for (var file in chatMessage.files) {
if (file != null) {
try {
final processedFile = await ref
.read(imageHandlerProvider.notifier)
.handleFileDownloadAndSave(file);
updatedFiles.add(processedFile);
} catch (e) {
debugPrint('개별 이미지 처리 실패: $e');
updatedFiles.add(file); // 실패해도 원본 파일 유지
}
}
}
messages.add(chatMessage.copyWith(files: updatedFiles));
} else {
messages.add(chatMessage);
}
}
return messages;
}
읽지 않은 메시지 개수를 관리하는 방법을 고민했다. 처음에는 마지막으로 본 메시지의 시간을 저장했는데 이는 문제가 있었다:
그래서 커서(cursor) 기반으로 변경했다. 마지막으로 본 메시지의 ID를 저장하고, 그 이후의 메시지 개수를 서버에서 조회하는 api를 만들어 달라고 백엔드 개발자에게 요청했다.
// 커서 ID 저장 (마지막으로 본 메시지 ID)
Future<void> resetCursorId(String roomId, String cursorId) async {
try {
final dbRepository = await ref.read(dbRepositoryProvider.future);
await dbRepository.saveCursorId(roomId, cursorId);
} catch (e, stackTrace) {
// 에러 처리
}
}
// 읽지 않은 메시지 개수 조회
Future<int> fetchUnreadMessageCount(String roomId, String cursorId) async {
try {
final unreadCount = await ref
.read(chatServiceProvider)
.fetchUnreadMessageCount(roomId, cursorId);
return unreadCount['data'];
} catch (e) {
rethrow;
}
}
채팅방을 열 때 첫 번째 메시지의 ID를 커서로 저장하고, 채팅방 목록을 불러올 때 각 채팅방의 읽지 않은 메시지 개수를 조회한다.
채팅 화면에서 위로 스크롤하면 이전 메시지를 불러오는 기능을 구현했다. 처음에는 스크롤 위치를 계산해서 자동으로 로딩했는데, 사용자 경험이 좋지 않았다. 그래서 스크롤이 맨 위에 도달했을 때만 로딩하도록 변경했다.
void _onScrollToTop() {
// 스크롤이 맨 위에 도달했을 때
if (_scrollController.offset ==
_scrollController.position.maxScrollExtent &&
!_scrollController.position.outOfRange) {
_loadPreviousMessages();
}
}
Future<void> _loadPreviousMessages() async {
try {
final chatNotifier = ref.read(chatProvider.notifier);
final state = ref.read(chatProvider);
// 전체 메시지 개수 확인
final messageCount = await chatNotifier.getMessageCount(widget.chatId);
// 이미 모든 메시지를 불러왔으면 중단
if (state.messages.length >= messageCount) {
debugPrint('모든 메시지를 불러왔습니다.');
return;
}
// 가장 오래된 메시지의 ID를 커서로 사용
final oldestMessageId = state.messages.last.id;
// 이전 메시지 조회
final prevChatMsgs = await chatNotifier.getPreviousMessages(
widget.chatId,
oldestMessageId,
tag: tag, // 필터링 태그 (예약, 진료 등)
);
// 기존 메시지 뒤에 추가
chatNotifier.updatePrevMessage(prevChatMsgs);
} catch (e) {
debugPrint('이전 메시지 로딩 실패: $e');
}
}
메시지는 최신 메시지가 앞에 오도록 배열했다. 이렇게 하면:
하지만 이전 메시지를 불러올 때는 뒤에 추가해야 해서, updatePrevMessage 메서드에서 순서를 조정했다. 이때 좀 헷갈릴 수 있는데, 잘못하면 새로 온 메시지를 앞에 추가해서 메시지가 왔는지 안왔는지 맨 앞(가장 처음 메시지가 시작된 시점)으로 가야 수신 여부를 확인할 수 있게 되기 때문에ㅋㅋㅋ 정신 차리고 해야한다. 나는 몇번 헷갈려서 삽질을 좀 했다. 가던 메시지가 안가서 왜지? 하면서 삽질하다가 저 순서가 잘못된것을 확인하고 이마를 탁침...
Future<void> updatePrevMessage(List<ChatMessage> messages) async {
if (messages.isEmpty) return;
state = state.copyWith(
messages: [
...state.messages, // 비교적 최신 (앞)
...messages, // 더 이전 (뒤)
],
);
}
채팅 메시지는 단순 텍스트뿐만 아니라 예약, 진료, 비대면 진료 등 다양한 타입이 있었다. 각 타입에 따라 다른 처리가 필요하다.
메시지에는 tag 필드가 있어서 타입을 구분함:
CHAT: 일반 채팅BOOKING: 예약 관련TREATMENT: 진료 관련VISIT: 접수 관련void _handleNewMessage(Map<String, dynamic> message) async {
try {
final newMessage = ChatMessage.fromJson({
...message,
'deleted': message['deleted'] ?? false,
});
final chatState = ref.read(chatProvider);
final isCurrentRoom = chatState.curChatRoom?.id == newMessage.roomId;
// 1. 일반 채팅 메시지 처리
if (isCurrentRoom) {
_handleChatInRoom(newMessage, message);
} else {
await _handleChatOutsideRoom(newMessage);
}
// 2. 예약 취소 메시지 처리
if (message['metadata'] != null && _isCanceledBooking(message)) {
_handleBookingCancel(message);
}
// 3. 예약 확정 메시지 처리
if (message['metadata'] != null && _isBookingConfirmed(message)) {
await _handleBookingConfirmed(message);
}
// 4. 진료 종료 메시지 처리
if (message['metadata'] != null && _isTreatmentEndMessage(message)) {
_handleTreatmentEnd(message);
}
// 5. 비대면 진료 메시지 처리
if (message['metadata'] != null) {
bool isRemoteTreatment = message['tag'] == 'VISIT' &&
message['metadata']['visit']['type'] == 'REMOTE' &&
message['metadata']['visit']['meeting_id'] != null;
if (isRemoteTreatment) {
_handleRemoteTreatment(newMessage, message);
}
}
} catch (e, stack) {
// 에러 처리
}
}
예약이 확정되면 예약 목록을 업데이트해야 했다. 특히 예약 변경(CHANGE)인 경우, 기존 예약 정보도 찾아서 처리해야 했다.
void _handleBookingConfirmed(Map<String, dynamic> message) async {
final bookingEventHandler = ref.read(bookingEventHandlerProvider);
String? oldBookId;
DateTime? oldBookTime;
// 예약 변경인 경우 기존 예약 정보 찾기
final bookingRequest =
message['metadata']['booking_request'] as Map<String, dynamic>?;
if (bookingRequest != null && bookingRequest['type'] == 'CHANGE') {
oldBookId = bookingRequest['edit_booking_id'];
if (oldBookId != null) {
try {
final confirmedListState = ref.read(confirmedListProvider);
final oldBooking = confirmedListState.value?.confirmedList
.firstWhere((booking) => booking.id == oldBookId);
oldBookTime = oldBooking?.bookTime;
} catch (e) {
log('기존 예약을 찾을 수 없습니다: $oldBookId');
}
}
}
await bookingEventHandler.handleBookingConfirmed(
message['metadata'],
oldBookId: oldBookId,
oldBookTime: oldBookTime,
);
}
비대면 진료가 시작되면 영상통화 알림을 띄워야 한다. 앱이 포그라운드에 있는지 백그라운드에 있는지에 따라 다르게 처리했다.
void _handleRemoteTreatment(
ChatMessage newMessage, Map<String, dynamic> message) {
String messageText = newMessage.text ?? '';
if (messageText.contains('시작')) {
// 앱이 포그라운드에 있으면 바로 처리
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
ringerNotifier.onAcceptCall(newMessage);
return;
}
// 백그라운드면 알림으로 표시
RingerService(
onAcceptCall: (message) {
ringerNotifier.onAcceptCall(message);
},
onDeclineCall: (message) {
ringerNotifier.onDeclineCall(message);
},
ref: ref,
).onRinging(newMessage);
}
}
증상: 채팅방을 열 때마다 소켓이 새로 연결되어, 메시지가 중복으로 수신됨
원인: Provider가 재생성되면서 소켓도 새로 생성됨
해결: keepAlive: true로 설정하고, 이미 연결된 소켓이 있으면 재사용
증상: 같은 이미지를 여러 번 다운로드해서 데이터 사용량이 증가
원인: 다운로드한 파일 정보를 저장하지 않아서, 매번 새로 다운로드
해결: DB에 파일 정보 저장하고, 이미 다운로드한 파일은 재다운로드하지 않음
증상: 채팅방을 열어도 읽지 않은 메시지 개수가 0으로 안 바뀜
원인: 커서 ID를 저장하지 않아서, 서버에서 개수를 조회할 때 기준이 없음
해결: 채팅방을 열 때 첫 번째 메시지 ID를 커서로 저장
증상: 이전 메시지를 불러올 때 스크롤 위치가 갑자기 위로 이동
원인: 메시지를 추가한 후 스크롤 위치를 보정하지 않음
해결: 메시지 추가 전 스크롤 위치를 저장하고, 추가 후 복원
Future<void> _fetchPreviousMessages(chatNotifier, state) async {
try {
// 현재 스크롤 위치 저장
final scrollPosition = _scrollController.position.pixels;
final scrollMaxExtent = _scrollController.position.maxScrollExtent;
chatNotifier.setIsLoading(true);
// 이전 메시지 조회
final prevChatMsgs = await chatNotifier.getPreviousMessages(
widget.chatId,
oldestMessageId,
tag: tag,
);
chatNotifier.updatePrevMessage(prevChatMsgs);
// 스크롤 위치 복원
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
final newMaxExtent = _scrollController.position.maxScrollExtent;
final offset = scrollPosition + (newMaxExtent - scrollMaxExtent);
_scrollController.jumpTo(offset);
}
});
} catch (e) {
// 에러 처리
}
}
증상: 같은 메시지가 여러 번 표시됨
원인: 리스너를 제거하지 않고 계속 추가해서, 이벤트가 중복으로 발생
해결: 리스너 설정 전에 clearListeners() 호출
처음에는 메시지를 동시에 처리했는데, 메모리 부족 문제가 발생했다. 특히 이미지가 많은 메시지를 받을 때 문제가 컸다. 그래서 순차적으로 처리하도록 변경했다.
채팅방에 메시지가 너무 많으면 메모리 문제가 발생할 수 있다. 그래서 화면에 표시할 메시지 개수를 제한하고, 오래된 메시지는 필요할 때만 불러오도록 했다.
Future<List<ChatMessage>> getPreviousMessages(
String roomId,
String cursorId, {
ChatMessageTag? tag,
}) async {
try {
// 한 번에 100개씩만 가져옴
final res = await ref.read(chatServiceProvider).fetchMessages(
roomId,
cursorId: cursorId,
take: 100,
tag: tag,
);
return await _processMessages(res['data']);
} catch (e, stackTrace) {
// 에러 처리
}
}
사용자가 채팅방 위쪽을 보고 있을 때 새 메시지가 오면, 아래로 스크롤할 수 있는 인디케이터를 표시했다.
void _scrollListener() {
if (_scrollController.position.pixels == 0) {
_isBottom = true; // 맨 아래에 있음
toggleNewMessageIndicator(false);
} else {
_isBottom = false; // 위쪽을 보고 있음
}
}
// 새 메시지가 왔을 때
if (state.showNewMessageIndicator == true &&
newMessage != null &&
!_isBottom) { // 맨 아래가 아니면 인디케이터 표시
Positioned(
bottom: 64 + 16,
child: NewChatIndicator(
onTap: () {
_scrollToBottom();
toggleNewMessageIndicator(false);
},
text: newMessage.text ?? '',
// ...
),
);
}
메시지를 보낼 때는 자동으로 맨 아래로 스크롤하고, 새 메시지가 왔을 때는 사용자가 맨 아래에 있을 때만 자동 스크롤한다다.
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0.0, // 맨 아래 (역순이므로 0이 맨 아래)
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
채팅 기능을 구현하면서 가장 많이 느낀 점은 상태 관리의 중요성이었다. 실시간으로 데이터가 들어오고, 여러 화면에서 상태를 공유해야 하기 때문에, 상태를 어떻게 구조화하느냐가 성능과 유지보수성에 큰 영향을 미친다.
특히: