Spring boot/Spring boot websocket

Spring Boot - STOMP(DTO) 변환

whs5758 2025. 9. 9. 12:08
package com.example.class_step_websocket.chat;

public enum MessageType {
    CHAT, SYSTEM
}
package com.example.class_step_websocket.chat;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatMessageDto {
    private String id;
    private String content;
    private String sender;
    private String type;
    private String timestamp;

    // 내부적으로 enum으로 변환하는 메서드 추가
    public MessageType getMessageType() {
        return "system".equals(this.type) ? MessageType.SYSTEM : MessageType.CHAT;
    }

}
package com.example.class_step_websocket.chat;


import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import java.time.LocalDateTime;

@Slf4j
@RequiredArgsConstructor
@Controller // 뷰 반환
public class ChatController {

    private final ChatService chatService;

    // 메세지 작성 폼 페이지
    @GetMapping("/save-form")
    public String saveForm() {
        return "save-form";
    }

    // 채팅 목록 페이지
    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("models", chatService.findAll());
        return "index";
    }

    // 채팅 메세지 저장 요청
    @PostMapping("/chat")
    public String save(String msg) {
        chatService.save(msg);
        // POST 맵핑에서 (웹 에서 주의할 점) - 중복 등록 조심
        return "save-form";
        // PRG 패턴
        //return "ridirect:/";
    }

    /**
     *
     * @MessageMapping - 내부 동작 원리 (AOP)
     *  /app/chat -->  xxxxx --> 브로드 캐스트 처리
     *  순수 웹소켓 구현시 만들었던 웹 소켓 핸들러를 대체 한다
     *  MessageMapping -> handleMessage() 로직을 대체 함  if --> CHAT:
     *
     *   @SendTo("/topic/message") -- 기존에 로직인 broadcastMessage() 를 대체
     */
    @MessageMapping("/chat")
    @SendTo("/topic/message")
    public ChatMessageDto sendMessage(ChatMessageDto messageDto) {
        log.info("---> STOMP 메세지 수신 : {}", messageDto);

        try {
            if(messageDto == null || messageDto.getContent().trim().isEmpty()) {
                log.warn("빈 메세지 수신, 브로드 캐스트 하지 않음");
                return null;
            }

            // 메세지 내용 DB 저장 로직 그대로 사용
            Chat savedChat = chatService.save(messageDto.getContent().trim());
            // 클라이언트들 에게 던지는 메세지 타입은 수정이 되어야 한다.
            ChatMessageDto chatMessageDto = ChatMessageDto.builder()
                    .id(savedChat.getId().toString())
                    .content(savedChat.getMsg())
                    .sender(messageDto.getSender())
                    .type("CHAT")
                    .timestamp(LocalDateTime.now().toString())
                    .build();
            return chatMessageDto;
        } catch (Exception e) {
            log.error("메세지 저장 실패 : ", e);
            return createErrorMessage("메세지 전송 및 저장에 실패");
        }
    }

    /**
     * 에러 메세지 생성 헬퍼 메서드
     */
    private ChatMessageDto createErrorMessage(String errorMessage) {
        return ChatMessageDto.builder()
                .id(System.currentTimeMillis() + "")
                .content(errorMessage)
                .sender("SYSTEM")
                .timestamp(LocalDateTime.now().toString())
                .build();
    }

}
index.mustache
<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>채팅 (WebSocket)</title>
    <style>
        body { margin: 20px; font-family: Arial, sans-serif; }
        nav ul { list-style: none; padding: 0; display: flex; gap: 10px; }
        nav a { padding: 8px 15px; background: #666; color: white; text-decoration: none; }

        .chat-area {
            border: 1px solid #ccc;
            height: 300px;
            padding: 10px;
            overflow-y: scroll;
            background: #f9f9f9;
        }

        .message {
            padding: 5px 10px;
            margin: 3px 0;
            background: white;
            border-radius: 8px;
            max-width: 80%;
        }

        .message:nth-child(odd) {
            background: #e1f5fe;
            margin-left: 20%;
        }

        /* 연결 상태 표시용 스타일 */
        .status {
            padding: 5px 10px;
            border-radius: 3px;
            font-size: 12px;
            margin-bottom: 10px;
        }
        .connected { background: #d4edda; color: #155724; }
        .disconnected { background: #f8d7da; color: #721c24; }

        /* 메시지 입력 폼 스타일 */
        .message-form {
            margin-top: 15px;
            padding: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            background: #f8f9fa;
        }

        .message-form input {
            padding: 8px;
            width: 70%;
            margin-right: 10px;
            border: 1px solid #ccc;
            border-radius: 3px;
        }

        .message-form button {
            padding: 8px 15px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 3px;
            cursor: pointer;
        }

        .message-form button:disabled {
            background: #6c757d;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
<nav>
    <ul>
        <li><a href="/">목록</a></li>
        <li><a href="/save-form">작성</a></li>
    </ul>
</nav>

<h3>채팅 (WebSocket 방식)</h3>
<p style="color: #666; font-size: 13px;">실시간 업데이트</p>

<!-- 연결 상태 표시 영역 -->
<div id="status" class="status disconnected">연결 중...</div>

<!-- 채팅 메시지 표시 영역 -->
<div class="chat-area" id="chatArea">
    {{#models}}
        <div class="message">{{msg}}</div>
    {{/models}}
</div>

<!-- 메시지 입력 폼 -->
<div class="message-form">
    <form id="chatForm">
        <input type="text" id="msgInput" placeholder="메시지를 입력하세요..." required>
        <button type="submit" id="sendButton" disabled>연결 중...</button>
    </form>
</div>


<!-- STOMP 라이브러리 로드 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>

<script>
    <!--  이 페이지에 전역 변수 선언    -->
    // DOM 요소 참조
    const chatArea = document.getElementById('chatArea');
    const status = document.getElementById('status');
    const msgInput = document.getElementById('msgInput');
    const chatForm = document.getElementById('chatForm');
    const sendButton = document.getElementById('sendButton');

    <!--  이 페이지에 전역 변수 선언    -->
    let stompClient = null;

<!--    스톰프 연결 시작 -->
    function connect() {
        const socket = new SockJS('http:/192.168.0.132:8080/ws');
        stompClient = Stomp.over(socket);

        // 스톰프 연결 시도
        stompClient.connect({}, onConnected, onError);
    }

<!--    연결 요청 함수 실행-->
    connect();

<!--    연결 성공 콜백 메서드 선언-->
    function onConnected() {
        status.textContent = "실시간 연결됨(STOMP)";
        status.className = 'status connected';
        sendButton.disabled = false;
        sendButton.textContent = '전송';
        msgInput.focus();
       console.log('11111111111');
        // 메시지 채널 구독 설정
        stompClient.subscribe('/topic/message', onMessageReceived);

        console.log('STOMP 연결 및 구독 처리 완료');
    }
<!--    연결 실패시 콜백 -->
    function onError(error) {
        status.textContent = "연결 에러 발생";
        status.className = 'status disconnected';
        sendButton.disabled = true;
        console.error('STOMP 연결 에러', error);
    }
<!-- 메세지 수신 처리 -->
    function onMessageReceived(message) {

      const chatMessageDto =  JSON.parse(message.body);
      console.log('chatMessageDto', chatMessageDto);
      // 에러 메세지 처리
      if(chatMessageDto.type === 'SYSTEM') {
        const errorDiv = document.createElement('div');
        errorDiv.className = 'error-message';
        errorDiv.textContent = '[시스템] 에러 발생';
        chatArea.appendChild(errorDiv);
        chatArea.scrollTop = chatArea.scrollHeight;
        return;
      }

      // 일반 채팅 메세지 정상 수신
      const messageDiv = document.createElement('div');
      messageDiv.className = 'message';

      // 발신자와 메세지 내용을 함께 표시해 보자.
      const senderText = chatMessageDto.sender ? `[${chatMessageDto.sender}]` : '';
      messageDiv.textContent = `${senderText} ${chatMessageDto.content}`;
      chatArea.appendChild(messageDiv);

      // 스크롤 맨 아래로 이동 처리
      chatArea.scrollTop = chatArea.scrollHeight;
    }

<!--        메세지 전송 로직 처리-->
    chatForm.addEventListener('submit', function(e) {
        e.preventDefault();
        sendMessage();
    });

<!--    메세지 전송 로직 처리 -->
    function sendMessage() {
        // 사용자가 작성한 글자 가져오기
        const sendMessage = msgInput.value.trim();
        // 가져온 글자를 서버로 보내는 처리 - (검증...방어적 코드 작성)
        if(sendMessage != null && stompClient != null && stompClient.connected) {
        // {'content-Type': 'application/json'} <-- 헤더 설정

            // js 객체를 생성 --> 직렬화 --> json 형식으로 서버에 전송
            const messageDto = {
                id : Date.now().toString(),
                content: sendMessage,
                sender:'웹[김근호]',
                type: "CHAT",
                timestamp: new Date().toString()
            };

            // JSON 문자열로 변환하여 서버측으로 전송해야 한다.
            stompClient.send('/app/chat', {}, JSON.stringify(messageDto));

            msgInput.value = '';
            msgInput.focus();
        } else {
            alert('Stomp 연결이 끊어져 있습니다');
        }
    }

<!--     엔터키 이벤트 등록 및 처리   -->
        msgInput.addEventListener('keypress', function(e) {
            if(e.key === 'Enter') {
                sendMessage();
            }
        });

        // 페이지 종료 시 연결 종료 처리
        window.addEventListener('beforeunload', () => {
            if(stompClient) {
                stompClient.disconnect();
                console.log('stomp연결 정리 완료');
            }
        });


</script>


</body>
</html>

플러터 채팅 기능 (STOMP) 구현

패키지 구조 확인


핵심 코드

의존성 확인

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8
  stomp_dart_client: ^1.0.2
  flutter_riverpod: ^2.4.7
ChatMessage 모델 설계
enum MessageType { CHAT, SYSTEM }

class ChatMessage {
  final String id;
  final String content;
  final String sender;
  final MessageType type;
  final DateTime timestamp;
  final bool isRead; // UI 측 데이터

  const ChatMessage({
    required this.id,
    required this.content,
    required this.sender,
    required this.type,
    required this.timestamp,
    this.isRead = false,
  });

  // 일반 채팅 메세지 생성자 (네임드)
  factory ChatMessage.createChat(
      {required String content, required String sender}) {
    return ChatMessage(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      content: content,
      sender: sender.trim(),
      type: MessageType.CHAT,
      timestamp: DateTime.now(),
    );
  }

  //  시스템 메세지 생성
  factory ChatMessage.createSystem(String content) {
    return ChatMessage(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      content: content,
      sender: 'system',
      type: MessageType.SYSTEM,
      timestamp: DateTime.now(),
    );
  }

  // 시간 포맷팅 기능
  String get formattedTime {
    // 시간 2자리 수 (08), (11) , 자동으로 0을 붙여서 만들어 내. 9 -> 09
    final hour = timestamp.hour.toString().padLeft(2, '0');
    final minute = timestamp.minute.toString().padLeft(2, '0');
    return '$hour:$minute';
  }

  // 시스템 메세지 여부
  bool get isSystemMessage {
    return type == MessageType.SYSTEM;
  }

  // 내 메세지 여부 (UI 확인용)
  bool isMyMessage(String currentUsername) {
    if (isSystemMessage) return false;
    return sender == currentUsername;
  }

  // 읽음 처리(불편 객체이므로 새 인스턴스 생성)
  ChatMessage markAsRead() {
    return ChatMessage(
      id: id,
      content: content,
      sender: sender,
      type: type,
      timestamp: timestamp,
      isRead: true,
    );
  }
}
ChatMessageDto 클래스 설계
import '../models/chat_message.dart';

class ChatMessageDto {
  final String id;
  final String content;
  final String sender;
  final String type; // 'CHAT' or 'SYSTEM'
  final String timestamp;

  ChatMessageDto({
    required this.id,
    required this.content,
    required this.sender,
    required this.type,
    required this.timestamp,
  });

  // JSON 에서 Map 구조로 변환 한 뒤 map 구조에서 DTO 클래스를 생성(수신)
  factory ChatMessageDto.fromJson(Map<String, dynamic> json) {
    return ChatMessageDto(
      id: json['id'] as String,
      content: json['content'] as String,
      sender: json['sender'] as String,
      type: json['type'] as String,
      timestamp: json['timestamp'] as String,
    );
  }

  // DTO 객체를 JSON 으로 변환 시 사용 (전송)
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'content': content,
      'sender': sender,
      'type': type,
      'timestamp': timestamp,
    };
  }

  // Model 객체를 DTO 로 변환 하는 기능
  factory ChatMessageDto.fromModel(ChatMessage model) {
    return ChatMessageDto(
      id: model.id,
      content: model.content,
      sender: model.sender,
      type: model.type.toString(),
      timestamp: model.timestamp.toString(),
    );
  }

  // ChatMessageDto -> 에서 ChatMessage 클래스 생성하는 기능
  // ChatMesageDto.toModel() ; --> ChatMessage() 생성 하는 코드
  ChatMessage toModel() {
    return ChatMessage(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      content: content,
      sender: sender,
      type: MessageType.CHAT,
      timestamp: DateTime.now(),
    );
  }
}
chat_repository.dart
import 'dart:convert';
import 'dart:io';

import 'package:class_websocket_flutter/data/dtos/chat_message_dto.dart';
import 'package:class_websocket_flutter/data/models/chat_message.dart';
import 'package:stomp_dart_client/stomp.dart';
import 'package:stomp_dart_client/stomp_config.dart';
import 'package:stomp_dart_client/stomp_frame.dart';

// 웹 소켓 네트워크만 담당
// 어떤 기능이 필요하지?
// 스톰프 클라이언트 초기화 처리
// 연결 성공 처리
// 연결 해제 처리
// 메세지 전송 처리
// 메세제 수진 처리
// 에러처리 등
class ChatRepository {
  late StompClient stompClient;

  // 플랫폼별 서버 주소 설정
  static String get serverUrl {
    if (Platform.isAndroid) {
      return 'http://10.0.2.2:8080';
      // 192.1680.13 <- 강사 컴퓨터 서버 주소
    } else if (Platform.isIOS) {
      return 'http://localhost:8080';
    } else {
      return 'http://localhost:8080';
    }
  }

  // 연결 상태 확인 getter
  bool get isConnected => stompClient.connected;

  // 콜백 함수 만들기
  // 네트워크 이벤트를 상위 클래스에 알려주기 위함
  // ChatRepository 목적은 네트워크 통신을 통해서 무슨 이벤트만 들어 왔는지만 알면 되고
  // 위 알림을 바탕으로 어떤게 처리할지는 상위 클래스가 결정 ....
  void Function()? onConnected;
  void Function()? onDisconnected;
  void Function(String error)? onError;
  void Function(ChatMessage message)? onMessage;

  // 스톰프 클라이언트 초기화
  void init() {
    stompClient = StompClient(
      config: StompConfig(
        url: '$serverUrl/ws',
        onConnect: _handleConnected,
        onDisconnect: _handleDisconnected,
        onWebSocketError: (error) => onError?.call('연결 오류 : $error'),
        useSockJS: true, // 호환성
      ),
    );
  }

  // StompFrame --> 스톰프 클라이언트가 가지고 있음
  // frame.body    (편지 내용)
  // frame.headers (받는 사람 주소, 보내는 사람 주소, 배송 옵션)
  // frame.command (일반우편, 등기우편, 택배)
  void _handleConnected(StompFrame frame) {
    // 연결 성공시 == 어떤 메세지를 구독할지 바로 호출
    _subscribeToMessage(); // 선이 연결 된 상태니깐 그 중에 나는 /topic/message 만 받을꺼야!
    onConnected?.call();
  }

  void _handleDisconnected(StompFrame frame) {
    onDisconnected?.call();
  }

  // 서버 연결
  void connect() {
    stompClient.activate();
  }

  // 연결 해제
  void disconnect() {
    if (stompClient.connected) {
      stompClient.deactivate();
    }
  }

  // 메세지 전송
  void sendMessage(ChatMessage message) {
    if (stompClient.connected == false) {
      onError?.call('웹 소켓 서버와 연결 안됨');
      return;
    }

    final ChatMessageDto dto = ChatMessageDto.fromModel(message);
    final String jsonBody = jsonEncode(dto);

    stompClient.send(
      destination: '/app/chat',
      body: jsonBody,
    );
  }

  // 메세지 구독
  void _subscribeToMessage() {
    stompClient.subscribe(
      destination: '/topic/message',
      callback: (StompFrame frame) {
        if (frame.body != null) {
          // 메세지 수신 처리
          _handleIncomingMessage(frame.body!);
        }
      },
    );
  }

  void _handleIncomingMessage(String messageBody) {
    final jsonData = jsonDecode(messageBody);
    final dto = ChatMessageDto.fromModel(jsonData);
    final ChatMessage message = dto.toModel();
    onMessage?.call(message);
  }
}
connection_provider.dart
// 창고 데이터
import 'package:class_websocket_flutter/data/repository/chat_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ConnectionState {
  final bool isConnected;
  final String status;

  ConnectionState({required this.isConnected, required this.status});

  ConnectionState copyWith({bool? isConnected, String? status}) {
    return ConnectionState(
      isConnected: isConnected ?? this.isConnected,
      status: status ?? this.status,
    );
  }
} // end of ConnectionState

// 창고 매뉴얼 (확장된 - VM 개념)
class ConnectionStateNotifier extends Notifier<ConnectionState> {
  late ChatRepository _chatRepository;

  @override
  ConnectionState build() {
    _chatRepository = ChatRepository();
    _setupCallbacks();
    _chatRepository.init();

    return ConnectionState(isConnected: false, status: "연결 준비중");
  }

  void _setupCallbacks() {
    _chatRepository.onConnected = () {
      state = state.copyWith(isConnected: true, status: "연결됨");
    };

    _chatRepository.onDisconnected = () {
      state = state.copyWith(isConnected: false, status: "연결 해제됨");
    };

    _chatRepository.onError = (error) {
      state = state.copyWith(isConnected: false, status: '$error');
    };
  }

  // 연결 시작
  void connect() {
    state = state.copyWith(status: "연결 중...");
    _chatRepository.connect();
  }

  // 연결 해제
  void disconnected() {
    state = state.copyWith(status: "연결 해제 중...");
    _chatRepository.disconnect();
  }
}

// 실제 창고 개설
final connectedStateProvider =
    NotifierProvider<ConnectionStateNotifier, ConnectionState>(
  () => ConnectionStateNotifier(),
);
chat_message_provider.dart
// 창고 데이터
import 'package:class_websocket_flutter/data/models/chat_message.dart';
import 'package:class_websocket_flutter/data/repository/chat_repository.dart';
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatState {
  final List<ChatMessage> messages;
  final String username;

  ChatState({required this.messages, required this.username});

  ChatState copyWith({List<ChatMessage>? messages, String? username}) {
    return ChatState(
        messages: messages ?? this.messages,
        username: username ?? this.username);
  }
}

// 창고 매뉴얼
class ChatMessageNotifier extends Notifier<ChatState> {
  late ChatRepository _chatRepository;

  @override
  ChatState build() {
    final connectionNotifier = ref.watch(connectedStateProvider.notifier);
    _chatRepository = connectionNotifier.chatRepository;
    _setupMessageCallback(); // 콜백 메서드 등록 처리

    return ChatState(
        username: "김강사", messages: [ChatMessage.createSystem("채팅이 시작 되었습니다")]);
  }

  void _setupMessageCallback() {
    _chatRepository.onMessage = (messageBody) {
      // 디버그 용
      print("메세지 수신 됨 : ${messageBody.content} ${messageBody.sender}");

      if (messageBody.sender == state.username) {
        // 내 메세지의 브로드 캐스트는 무시
        return;
      }

      // 새로운 ChatMessage 객체가 들어 왔다면 내 변수 List<ChatMessage> 에 추가 해줘야
      // UI 화면을 재 갱신 처리 한다(자동)
      // 스프레드 연산자 사용하기
      state = state.copyWith(
        messages: [...state.messages, messageBody],
      );
    };
    // TODO - 필요 하다면 구현 하기
    // _chatRepository.onError
    // _chatRepository.onDisconnected
  }

  void sendMessage(String content) {
    if (content.trim().isEmpty) {
      return;
    }

    ChatMessage message =
        ChatMessage.createChat(content: content, sender: state.username);

    // 메세지 수신측 콜백메서드에 내가 발송한 메세지는 무시 처리 해놨음
    // 상태 변경 --> UI watch 할 예정이기 때문에 자동으로 UI 업데이트 됨
    state = state.copyWith(messages: [...state.messages, message]);

    // 서버로 전송 하기
    _chatRepository.sendMessage(message);
  }

  // TODO - 추가 비즈니스 로직 설계 (사용자명 변경 또는 내 메세지 삭제)
}

// 창고 개설
final chatMessageProvider = NotifierProvider<ChatMessageNotifier, ChatState>(
    () => ChatMessageNotifier());
main.dart
import 'package:class_websocket_flutter/pages/chat/chat_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  // ProviderScope 리버팟에서 제공하는 위젯들 제공한다.
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'stomp chat',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
        // 최산 머리티얼 디자인 사용 하겠다.
        useMaterial3: true,
      ),
      home: const ChatPage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

 

ChatPage
import 'package:class_websocket_flutter/pages/chat/widgets/chat_body.dart';
import 'package:flutter/material.dart';

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('채팅'),
        backgroundColor: Colors.blue,
        foregroundColor: Colors.white,
      ),
      body: SafeArea(child: ChatBody()),
    );
  }
}

UI 작업 부분 및 연동

ChatBody
import 'package:class_websocket_flutter/pages/chat/widgets/chat_connection_status.dart';
import 'package:class_websocket_flutter/pages/chat/widgets/chat_input_field.dart';
import 'package:class_websocket_flutter/pages/chat/widgets/chat_message_list.dart';
import 'package:class_websocket_flutter/providers/local/chat_message_provider.dart';
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatBody extends ConsumerStatefulWidget {
  const ChatBody({super.key});

  @override
  ConsumerState<ChatBody> createState() => _ChatBodyState();
}

class _ChatBodyState extends ConsumerState<ChatBody> {
  final TextEditingController _editingController = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      // 위젯이 안정적으로 바인딩 된 후에 ref 호출
      ref.read(connectedStateProvider.notifier).connect();
    });
  }

  // 메모리 해제
  @override
  void dispose() {
    _editingController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  // 메세지 전송
  void _sendMessage() {
    final content = _editingController.text;
    if (content.isEmpty) return;
    _editingController.clear();

    // 기능 호출 한번 마
    ref.read(chatMessageProvider.notifier).sendMessage(content);
  }

  void _scrollToBottom() {
    // 이 위젯이 완전희 렌더링 된 시점에 한 번만 콜백을 실행하도록 보장
    // 위젯의 크기나 위치 정보가 정확히 확정된 시점에 네트워크 연결 또는
    // 안정작인 동작을 보장 합니다.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeIn,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    // ref.listen() - 상태가 변경될 때 특정 로직만 실행 (UI 재구축이랑 별개)
    ref.listen<ChatState>(
      chatMessageProvider,
      (previous, next) {
        if (previous != null &&
            previous.messages.length < next.messages.length) {
          // 자동 스크롤 내리는 기능 만들기
          _scrollToBottom();
        }
      },
    );

    return Column(
      children: [
        ChatConnectionStatus(),
        // ListView...
        Expanded(child: ChatMessageList(scrollController: _scrollController)),
        ChatInputField(
            controller: _editingController, onSendMessage: _sendMessage)
      ],
    );
  }
}
ChatConnectionStatus
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatConnectionStatus extends ConsumerWidget {
  const ChatConnectionStatus({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 상태 감지 가능
    final connection = ref.watch(connectedStateProvider);
    return Container(
      width: double.infinity,
      padding: EdgeInsets.all(12.0),
      // 동적 컬러 설정
      color: connection.isConnected ? Colors.green : Colors.red,
      child: Text(
        '${connection.status}',
        style: TextStyle(color: Colors.white),
        textAlign: TextAlign.center,
      ),
    );
  }
}
ChatInputField
import 'package:class_websocket_flutter/providers/local/connection_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatInputField extends ConsumerWidget {
  final TextEditingController controller;
  final VoidCallback onSendMessage;

  const ChatInputField({
    required this.controller,
    required this.onSendMessage,
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final connection = ref.watch(connectedStateProvider);

    return Container(
      padding: EdgeInsets.all(8),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: controller,
              decoration: InputDecoration(
                hintText: '메세지를 입력하세요',
                border: OutlineInputBorder(),
              ),
              // 엔터키 입력시 처리 됨 : 키보드의 완료/ 전송 버튼 터치시 동작 함
              onSubmitted: (value) => onSendMessage(),
            ),
          ),
          const SizedBox(width: 10),
          ElevatedButton(
            onPressed: connection.isConnected ? onSendMessage : null,
            child: Text('전송'),
          )
        ],
      ),
    );
  }
}
chat_message_item
import 'package:class_websocket_flutter/data/models/chat_message.dart';
import 'package:flutter/material.dart';

class ChatMessageItem extends StatelessWidget {
  final ChatMessage message;
  final String currentUsername;

  const ChatMessageItem({
    required this.message,
    required this.currentUsername,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    if (message.isSystemMessage) {
      return Container(
        margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
        child: Center(
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
            decoration: BoxDecoration(
              // Colors.grey[300]
              color: Colors.grey.shade300,
              borderRadius: BorderRadius.circular(12),
            ),
            // 시스템 메세지
            child: Text(
              message.content,
              style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
            ),
          ),
        ),
      );
    }

    // 내 메세지 or 다른 사람 메세지
    final isMyMessage = message.isMyMessage(currentUsername);

    if (isMyMessage) {
      // 나의 메세지
      return Container(
        margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            // 말풍선 위젯
            // Expanded - 전체 공간을 차지 (클수 있을 만큼)
            // Flexible - 필요한 만큼 차지 (보통 Container 과 많이 활용 됨)
            Flexible(
              child: Container(
                // 컨테이너 위젯에 제약 조건을 설정하는 속성
                constraints: BoxConstraints(
                  minWidth: 60,
                  maxWidth: MediaQuery.of(context).size.width * 0.7,
                ),
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.blue,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Text(
                  message.content,
                  style: TextStyle(color: Colors.white),
                ),
              ),
            ),
            const SizedBox(width: 8),
            Text(
              message.formattedTime,
              style: TextStyle(
                fontSize: 10,
                color: Colors.grey,
              ),
            ),
          ],
        ),
      );
    } else {
      // 다른 사람 메세지
      return Container(
        margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Text(
              message.formattedTime,
              style: TextStyle(
                fontSize: 10,
                color: Colors.grey,
              ),
            ),
            const SizedBox(width: 8),
            // 말풍선 위젯
            // Expanded - 전체 공간을 차지 (클수 있을 만큼)
            // Flexible - 필요한 만큼 차지 (보통 Container 과 많이 활용 됨)
            Flexible(
              child: Container(
                // 컨테이너 위젯에 제약 조건을 설정하는 속성
                constraints: BoxConstraints(
                  minWidth: 60,
                  maxWidth: MediaQuery.of(context).size.width * 0.7,
                ),
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.grey,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Column(
                  children: [
                    Text(
                      message.sender,
                      style: TextStyle(color: Colors.black),
                    ),
                    Text(message.content,
                        style: TextStyle(color: Colors.black)),
                  ],
                ),
              ),
            ),
          ],
        ),
      );
    }

    return const Placeholder();
  }
}
ChatMessageList
import 'package:class_websocket_flutter/pages/chat/widgets/chat_message_item.dart';
import 'package:class_websocket_flutter/providers/local/chat_message_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatMessageList extends ConsumerWidget {
  // 외부에서 주입 받는 스크롤 컨트롤러
  // 상위 위젯에서 자동 스크롤 기능을 제어하기 위해 필요
  final ScrollController scrollController;

  const ChatMessageList({required this.scrollController, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 상태 구독
    final ChatState chatState = ref.watch(chatMessageProvider);
    // ListView() <== List<ChatMessage> 100 개
    return ListView.builder(
      controller: scrollController,
      itemCount: chatState.messages.length,
      itemBuilder: (context, index) {
        return ChatMessageItem(
          message: chatState.messages[index],
          currentUsername: chatState.username,
        );
      },
    );
  }
}