My Proect/채팅 프로젝트

채팅 프로젝트 - 4 구현 및 영상

형디 2025. 6. 23. 10:41

Front-end

page.tsx
'use client';

// 'use client' 지시어는 이 파일이 클라이언트 측에서 렌더링되고 실행되는 "클라이언트 컴포넌트"임을 Next.js에 알립니다.
// useState, useEffect와 같은 React 훅을 사용하거나 브라우저 API와 상호작용하려면 이 지시어가 반드시 필요합니다.

import { useEffect, useState } from "react";
import { Box, Heading, VStack } from "@chakra-ui/react"; // UI 구성을 위한 Chakra UI 컴포넌트들
import socket from "../lib/socket"; // 앞서 설정한 socket.io 클라이언트 인스턴스를 가져옵니다.
import { ChatMessage } from "../types/message"; // 메시지 객체의 타입 정의
import ChatWindow from "../components/ChatWindow"; // 채팅 메시지를 보여주는 컴포넌트
import MessageInput from "../components/MessageInput"; // 메시지 입력창 컴포넌트
import StatusBanner from "../components/StatusBanner"; // 현재 상태를 보여주는 배너 컴포넌트

export default function Home() {
  // React의 state 훅들을 사용하여 컴포넌트의 상태를 관리합니다.
  // 이 상태들이 변경되면 컴포넌트가 다시 렌더링되어 화면이 업데이트됩니다.
  const [userId, setUserId] = useState(""); // 사용자의 고유 소켓 ID를 저장
  const [peerId, setPeerId] = useState(""); // 매칭된 상대방의 소켓 ID를 저장
  const [messages, setMessages] = useState<ChatMessage[]>([]); // 채팅 메시지 목록을 배열로 저장
  const [status, setStatus] = useState("매칭 중..."); // 현재 연결 상태를 문자열로 저장 (예: "매칭 중...", "상대 찾는 중...")

  // useEffect 훅은 컴포넌트가 렌더링될 때 특정 작업을 수행하도록 합니다.
  // 주로 데이터 fetching, 구독(subscription) 설정 등 부수 효과(side effect)를 처리하는 데 사용됩니다.
  // 두 번째 인자로 빈 배열([])을 전달하면, 컴포넌트가 처음 마운트될 때 한 번만 실행됩니다.
  useEffect(() => {

    
    // 서버로부터 오는 다양한 이벤트를 수신 대기(listen)하는 리스너들을 설정합니다.

    // 'connect' 이벤트: 서버와 성공적으로 연결되었을 때 발생합니다.
    socket.on("connect", () => {
      // socket.id는 서버가 각 클라이언트에게 부여하는 고유한 식별자입니다.
      // 이 ID를 userId 상태에 저장하여 나중에 메시지를 보낼 때 누가 보냈는지 식별하는 데 사용합니다.
      setUserId(socket.id ?? "");
      console.log("포트 연결 성공", window.location.port);
    });

    // 'matched' 이벤트: 서버에서 매칭이 성사되었을 때 발생합니다.
    socket.on("matched", ({ peerId }) => {
      // 서버가 보내준 상대방의 ID를 peerId 상태에 저장합니다.
      setPeerId(peerId);
      // 상태 배너에 매칭되었음을 표시합니다.
      setStatus(`상대와 연결됨 (${peerId})`);
    });

    // 'waiting' 이벤트: 매칭 대기 상태가 되었을 때 발생합니다. (처음 접속했거나, 상대방이 나갔을 때)
    socket.on("waiting", () => setStatus("상대 찾는 중..."));

    // 'receive_message' 이벤트: 상대방으로부터 메시지를 수신했을 때 발생합니다.
    socket.on("receive_message", (data: ChatMessage) => {
      // 기존 메시지 목록에 새로 받은 메시지를 추가하여 messages 상태를 업데이트합니다.
      // (prev) => [...prev, data]는 불변성을 유지하며 배열에 새 항목을 추가하는 일반적인 React 패턴입니다.
      setMessages((prev) => [...prev, data]);
    });

    // useEffect의 return 함수는 "cleanup" 함수라고 부릅니다.
    // 컴포넌트가 언마운트(사라질 때)될 때 실행됩니다.
    // 여기서는 설정했던 모든 이벤트 리스너를 제거합니다.
    // 이렇게 하지 않으면 컴포넌트가 사라져도 리스너가 메모리에 남아 메모리 누수(memory leak)의 원인이 될 수 있습니다.
    return () => {
      socket.off();
    };
  }, []); // 빈 배열은 이 useEffect가 컴포넌트 마운트 시 한 번만 실행되도록 합니다.

  // 메시지를 전송하는 함수
  const handleSend = (msg: string) => {
    // 전송할 메시지 객체를 생성합니다. 누가 보냈는지(from)와 메시지 내용(message)을 포함합니다.
    const message = { from: userId, message: msg };
    // 'send_message' 이벤트를 서버로 발송(emit)합니다. 서버는 이 메시지를 상대방에게 전달해 줄 것입니다.
    socket.emit("send_message", message);
    // "낙관적 업데이트(Optimistic Update)": 서버의 응답을 기다리지 않고 내가 보낸 메시지를 바로 UI에 표시합니다.
    // 이렇게 하면 사용자는 자신의 메시지가 즉시 전송된 것처럼 느끼게 되어 사용자 경험이 향상됩니다.
    setMessages((prev) => [...prev, message]);
  };

  // 이 컴포넌트가 실제로 화면에 그릴 JSX(JavaScript XML) 코드입니다.
  return (
    <VStack h="100vh" spacing={0}>
      <Heading size="md" mt={4}>
        랜덤 채팅
      </Heading>
      <StatusBanner status={status} />
      <Box flex="1" w="100%">
        <ChatWindow messages={messages} userId={userId} />
      </Box>
      <MessageInput onSend={handleSend} />
    </VStack>
  );
}

 

 

layout.tsx
import "./globals.css";
import ChakraProviders from "../components/ChakraProviders";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ChakraProviders>
          {children}
        </ChakraProviders>
      </body>
    </html>
  );
}

 

 

ChatWindow.tsx
import { VStack, Box, Text } from "@chakra-ui/react";
import { ChatMessage } from "../types/message";
import { useEffect, useRef } from "react";

export default function ChatWindow({
  messages,
  userId,
}: {
  messages: ChatMessage[];
  userId: string;
}) {
  // `useRef`를 사용하여 스크롤의 맨 아래에 위치할 DOM 요소에 대한 참조를 생성합니다.
  // 이 참조는 스크롤을 맨 아래로 이동시키는 데 사용됩니다.
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // `scrollToBottom` 함수는 `messagesEndRef`가 가리키는 요소가 화면에 보이도록 스크롤을 부드럽게 이동시킵니다.
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  };

  // `useEffect` 훅을 사용하여 `messages` 배열이 변경될 때마다 `scrollToBottom` 함수를 호출합니다.
  // 즉, 새로운 메시지가 추가될 때마다 채팅창 스크롤이 자동으로 맨 아래로 이동합니다.
  useEffect(() => {
    scrollToBottom();
  }, [messages]);
  
  return (
    // 전체 채팅 창을 감싸는 컨테이너입니다.
    // h="700px"로 높이를 고정하고, 내용이 이 높이를 초과하면
    // overflowY="auto" 설정에 의해 세로 스크롤바가 자동으로 나타납니다.
    <VStack p={4} spacing={2} align="stretch" overflowY="auto" h="700px">
      {messages.map((msg, i) => (
        <Box
          key={i}
          // 메시지를 보낸 사람(from)이 현재 사용자의 ID(userId)와 같으면 메시지를 오른쪽에, 아니면 왼쪽에 정렬합니다.
          alignSelf={msg.from === userId ? "flex-end" : "flex-start"}
          // 내가 보낸 메시지는 노란색, 상대방이 보낸 메시지는 회색으로 배경색을 지정합니다.
          bg={msg.from === userId ? "yellow.200" : "gray.200"}
          p={2}
          borderRadius="lg"
          maxW="70%"
        >
          <Text>{msg.message}</Text>
        </Box>
      ))}
      {/* 
        이 비어있는 div가 항상 메시지 목록의 맨 아래에 위치하게 됩니다.
        `messagesEndRef`가 이 요소를 참조하므로, `scrollToBottom` 함수는 스크롤을 여기까지 이동시킵니다.
      */}
      <div ref={messagesEndRef} />
    </VStack>
  );
}

 

 

messageInput.tsx
import { HStack, Input, IconButton } from "@chakra-ui/react";
import { ArrowForwardIcon } from "@chakra-ui/icons";
import { useState } from "react";

export default function MessageInput({
  onSend,
}: {
  onSend: (msg: string) => void;
}) {
  const [msg, setMsg] = useState("");

  const handleSend = () => {
    if (!msg.trim()) return;
    onSend(msg);
    setMsg("");
  };

  return (
    <HStack p={2}>
      <Input
        placeholder="메시지를 입력하세요"
        value={msg}
        onChange={(e) => setMsg(e.target.value)}
        onKeyDown={(e) => e.key === "Enter" && handleSend()}
      />
      <IconButton
        aria-label="보내기"
        icon={<ArrowForwardIcon />}
        onClick={handleSend}
      />
    </HStack>
  );
}

 

 

StatusBanner.tsx
import { Box, Text } from "@chakra-ui/react";

export default function StatusBanner({ status }: { status: string }) {
  return (
    <Box bg="gray.100" p={2} textAlign="center">
      <Text fontSize="sm">{status}</Text>
    </Box>
  );
}

 

 

chakraProvider.tsx
'use client';

import { ChakraProvider } from "@chakra-ui/react";
import type { ReactNode } from "react";

export default function ChakraProviders({ children }: { children: ReactNode }) {
  return <ChakraProvider>{children}</ChakraProvider>;
}

 

 


 

Back-end

main.py
# main.py
from fastapi import FastAPI
import socketio
import uvicorn

# python-socketio의 비동기 서버(AsyncServer)를 생성합니다.
# async_mode="asgi"는 ASGI 애플리케이션과 함께 사용할 것임을 의미합니다.
# cors_allowed_origins는 Cross-Origin Resource Sharing을 허용할 출처를 지정합니다.
# 여기서는 "http://localhost:3000" (아마도 프론트엔드 개발 서버)에서의 요청을 허용합니다.
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=["http://localhost:3000"])

# FastAPI 애플리케이션을 생성합니다.
# FastAPI는 웹 API를 만들기 위한 현대적이고 빠른 웹 프레임워크입니다.
app = FastAPI()

# Socket.IO 서버를 FastAPI 애플리케이션에 연결(마운트)합니다.
# 이렇게 하면 하나의 서버에서 HTTP 요청과 WebSocket 요청을 모두 처리할 수 있습니다.
socket_app = socketio.ASGIApp(sio, app)

# 매칭을 기다리는 사용자의 소켓 ID(sid)를 저장하는 변수입니다.
# 한 명만 저장할 수 있으므로, 1:1 매칭만 간단히 구현됩니다.
waiting = None

# 매칭된 사용자 쌍을 저장하는 딕셔너리입니다.
# { '사용자A_sid': '사용자B_sid', '사용자B_sid': '사용자A_sid' } 형태로 저장됩니다.
peers = {}

@sio.event
async def connect(sid, environ):
    """
    클라이언트가 서버에 연결될 때 호출되는 이벤트 핸들러입니다.
    1:1 매칭 로직을 수행합니다.
    """
    global waiting, peers
    print("connect ", sid)
    
    # 이미 기다리는 사용자(waiting)가 있고, 그 사용자가 지금 연결한 사용자와 다른 경우
    if waiting and waiting != sid:
        # 두 사용자를 매칭시킵니다.
        peers[sid] = waiting
        peers[waiting] = sid
        
        # 각 사용자에게 매칭이 성공했음을 알리고, 상대방의 ID를 전달합니다.
        await sio.emit("matched", {"peerId": waiting}, to=sid)
        await sio.emit("matched", {"peerId": sid}, to=waiting)
        
        # 대기자를 초기화합니다.
        waiting = None
    else:
        # 기다리는 사용자가 없는 경우, 현재 사용자를 대기자로 설정합니다.
        waiting = sid
        await sio.emit("waiting", to=sid)

@sio.event
async def send_message(sid, msg):
    """
    클라이언트로부터 메시지를 수신했을 때 호출되는 이벤트 핸들러입니다.
    매칭된 상대방에게 메시지를 전달합니다.
    """
    # 메시지를 보낸 사용자의 상대방(peer_id)을 찾습니다.
    peer_id = peers.get(sid)
    if peer_id:
        # 상대방에게 'receive_message' 이벤트와 함께 메시지를 전달합니다.
        await sio.emit("receive_message", msg, to=peer_id)

@sio.event
async def disconnect(sid):
    """
    클라이언트 연결이 끊어졌을 때 호출되는 이벤트 핸들러입니다.
    매칭 정보를 정리합니다.
    """
    global waiting, peers
    # 연결이 끊어진 사용자의 상대방(peer_id)을 찾습니다.
    peer_id = peers.get(sid)
    
    # 만약 연결 끊어진 사용자가 대기자였다면, 대기자 정보를 초기화합니다.
    if waiting == sid:
        waiting = None
        
    # 만약 사용자에게 매칭된 상대방이 있었다면
    if peer_id:
        # 상대방에게는 다시 대기 상태가 되었음을 알립니다.
        await sio.emit("waiting", to=peer_id)
        # 매칭 정보를 양쪽 모두 삭제합니다.
        peers.pop(peer_id, None)
        peers.pop(sid, None)

@app.get("/")
async def root():
    """
    HTTP GET / 요청에 대한 핸들러입니다.
    서버가 정상적으로 동작하고 있는지 확인하는 용도로 사용됩니다.
    """
    return {"message": "KakaoTalk 클론 백엔드 서버가 정상 동작 중입니다."}

# 이 스크립트가 직접 실행될 때 (예: python main.py)
if __name__ == "__main__":
    # uvicorn을 사용하여 ASGI 애플리케이션(socket_app)을 실행합니다.
    # host="0.0.0.0"은 모든 네트워크 인터페이스에서 접속을 허용합니다.
    # port=3001은 3001번 포트를 사용하도록 설정합니다.
    uvicorn.run(socket_app, host="0.0.0.0", port=3001)

 

 

matcher.py
from collections import deque

# 사용자들이 매칭을 기다리는 대기열입니다.
# deque는 양쪽 끝에서 빠르게 추가하거나 제거할 수 있는 리스트와 같은 자료구조입니다.
# 여기서는 선입선출(FIFO) 방식으로 사용자를 매칭하기 위해 사용됩니다.
waiting_queue = deque()

# 현재 활발하게 연결되어 있는 사용자 쌍을 저장하는 딕셔너리입니다.
# { '사용자A_id': '사용자B_id', '사용자B_id': '사용자A_id' } 와 같은 형태로 저장됩니다.
# 이를 통해 특정 사용자의 대화 상대를 빠르게 찾을 수 있습니다.
active_pairs = {}  # user_id: peer_id

def match_user(user_id):
    """
    사용자를 매칭시키는 함수입니다.
    대기열에 다른 사용자가 있으면, 그 사용자와 현재 사용자를 매칭시킵니다.
    대기열이 비어있으면, 현재 사용자를 대기열에 추가합니다.
    """
    # 대기열에 기다리는 사용자가 있는지 확인합니다.
    if waiting_queue:
        # 대기열의 가장 앞에서 기다리던 사용자(peer_id)를 꺼냅니다.
        peer_id = waiting_queue.popleft()
        # 두 사용자를 active_pairs에 서로의 짝으로 등록합니다.
        active_pairs[user_id] = peer_id
        active_pairs[peer_id] = user_id
        # 매칭된 상대방의 ID를 반환합니다.
        return peer_id
    else:
        # 대기열에 아무도 없으면, 현재 사용자를 대기열에 추가합니다.
        waiting_queue.append(user_id)
        # 매칭된 상대가 없으므로 None을 반환합니다.
        return None

def get_peer(user_id):
    """
    주어진 사용자의 매칭된 상대방 ID를 반환하는 함수입니다.
    """
    # active_pairs 딕셔너리에서 user_id를 키로 사용하여 상대방 ID를 찾습니다.
    return active_pairs.get(user_id)

def remove_user(user_id):
    """
    사용자 연결이 끊어졌을 때 호출되는 함수입니다.
    active_pairs와 waiting_queue에서 해당 사용자의 정보를 제거합니다.
    """
    # active_pairs에서 user_id를 키로 사용하여 상대방 ID(peer_id)를 찾고, 해당 항목을 삭제합니다.
    peer_id = active_pairs.pop(user_id, None)
    # 만약 상대방이 있었다면, 상대방의 정보에서도 현재 사용자를 삭제합니다.
    if peer_id:
        active_pairs.pop(peer_id, None)
    # 만약 사용자가 대기열에 있었다면, 대기열에서도 제거합니다.
    if user_id in waiting_queue:
        waiting_queue.remove(user_id)

 

 

socket.py
import socketio
from matcher import match_user, get_peer, remove_user

# Socket.IO 서버를 생성합니다.
# cors_allowed_origins="*"는 모든 도메인에서의 접속을 허용합니다.
# 실제 서비스에서는 보안을 위해 특정 도메인만 허용하는 것이 좋습니다.
sio = socketio.AsyncServer(cors_allowed_origins="*")

# 사용자 ID와 소켓 ID를 매핑하는 딕셔너리입니다.
# user_id를 통해 특정 사용자의 소켓 ID를 빠르게 찾을 수 있습니다.
# 예: { 'user123': 'socket_id_abc' }
user_socket_map = {}  # user_id ↔ socket.id

# 소켓 ID와 사용자 ID를 매핑하는 딕셔너리입니다.
# 소켓 ID(sid)를 통해 어떤 사용자인지 빠르게 찾을 수 있습니다.
# 예: { 'socket_id_abc': 'user123' }
socket_user_map = {}  # socket.id ↔ user_id

@sio.event
async def connect(sid, environ):
    """
    클라이언트가 서버에 처음 연결되었을 때 호출되는 이벤트 핸들러입니다.
    sid: 각 클라이언트에게 부여되는 고유한 소켓 ID입니다.
    environ: 연결에 대한 환경 정보입니다. (여기서는 사용되지 않음)
    """
    print(f"[CONNECT] sid: {sid}")

@sio.event
async def register(sid, data):
    """
    클라이언트가 자신의 사용자 ID를 서버에 등록할 때 호출되는 이벤트 핸들러입니다.
    클라이언트는 연결 후 'register' 이벤트를 통해 자신의 userId를 보내야 합니다.
    """
    user_id = data["userId"]
    # 사용자 ID와 소켓 ID를 양방향으로 매핑하여 저장합니다.
    user_socket_map[user_id] = sid
    socket_user_map[sid] = user_id

    # matcher 모듈을 사용하여 사용자를 매칭시킵니다.
    peer_id = match_user(user_id)

    # 매칭이 성공했을 경우
    if peer_id:
        print(f"[MATCHED] {user_id} <-> {peer_id}")
        # 현재 사용자에게 매칭된 상대방(peer_id) 정보를 보냅니다.
        await sio.emit("matched", {"peerId": peer_id}, to=sid)
        
        # 상대방의 소켓 ID를 찾습니다.
        peer_sid = user_socket_map.get(peer_id)
        if peer_sid:
            # 상대방에게도 매칭된 사용자(user_id) 정보를 보냅니다.
            await sio.emit("matched", {"peerId": user_id}, to=peer_sid)
    # 매칭이 되지 않고 대기열에 들어갔을 경우
    else:
        print(f"[WAITING] {user_id} 대기열에 추가")
        # 현재 사용자에게 대기 중임을 알립니다.
        await sio.emit("waiting", {}, to=sid)

@sio.event
async def send_message(sid, data):
    """
    클라이언트가 메시지를 보낼 때 호출되는 이벤트 핸들러입니다.
    'send_message' 이벤트를 통해 메시지를 보내면, 서버는 상대방에게 메시지를 전달합니다.
    """
    # 메시지를 보낸 사람의 ID와 메시지 내용을 추출합니다.
    sender_id = data["from"]
    message = data["message"]
    # 보낸 사람의 상대방 ID를 찾습니다.
    peer_id = get_peer(sender_id)
    # 상대방의 소켓 ID를 찾습니다.
    peer_sid = user_socket_map.get(peer_id)
    print(f"[MESSAGE] {sender_id} -> {peer_id}: {message}")
    if peer_sid:
        # 상대방의 소켓으로 'receive_message' 이벤트를 통해 메시지를 전달합니다.
        await sio.emit("receive_message", data, to=peer_sid)

@sio.event
async def disconnect(sid):
    """
    클라이언트의 연결이 끊어졌을 때 호출되는 이벤트 핸들러입니다.
    """
    # 연결이 끊어진 소켓 ID(sid)를 통해 사용자 ID를 찾습니다.
    user_id = socket_user_map.pop(sid, None)
    if user_id:
        print(f"[DISCONNECT] {user_id}")
        # matcher에서 해당 사용자의 매칭 정보 및 대기열 정보를 삭제합니다.
        remove_user(user_id)
        # 맵에서도 사용자 정보를 삭제합니다.
        user_socket_map.pop(user_id, None)

 

 

 

728x90