GitHub

socketIO를 이용한 실시간 두더지 게임 구현하기 (2) | 웹소켓 연결 sveltekit & nodejs = socketIO

hojun lee · 01/09/2025
커버이미지

스벨트킷으로 구성된 프론트엔드와 nodejs로 구성된 백엔드를 소켓으로 실시간 연결한다. 평소 쉽게 네트워킹하던 restAPI와 달리 실시간으로 뚫어놓는 것이다.

웹소켓이란 (WebSocket)

웹소켓은 클라이언트와 서버 간의 양방향 통신을 가능하게 해주는 프로토콜(약속)입니다. 일반적인 HTTP 요청-응답 방식과는 달리, 웹소켓은 연결이 한 번 수립되면 클라이언트와 서버가 서로 자유롭게 데이터를 주고받을 수 있습니다. 이로 인해 실시간 애플리케이션, 예를 들어 채팅 앱이나 실시간 게임에서 매우 유용하게 사용됩니다.

쉽게 말해 횡단보도가 없고 고속도로가 뚫려있는 하이패스 통신!

  • 채팅
  • 실시간게임
  • 주식 시세 업데이트
  • 알림 서비스

양방향 통신: 클라이언트와 서버가 서로 데이터를 자유롭게 주고받을 수 있습니다.

지속적인 연결: 초기 연결이 설정된 후에는 연결이 유지되며, 추가적인 HTTP 요청 없이 데이터를 주고받을 수 있습니다.

낮은 레이턴시: 헤더 크기가 작고, 연결 수립 후 추가적인 핸드셰이크가 필요 없기 때문에 더 빠른 응답을 제공합니다.

웹소켓 기반 Socket.IO

Socket.IO는 웹소켓 프로토콜을 기반으로 한 JavaScript 라이브러리로, 실시간 웹 애플리케이션을 쉽게 개발할 수 있도록 돕습니다. Socket.IO는 웹소켓이 지원되지 않는 브라우저에서도 사용할 수 있도록 자동으로 폴백(fallback) 메커니즘을 제공합니다. 즉, 웹소켓이 불가능한 환경에서도 다른 방법(예: 폴링)을 통해 통신할 수 있습니다.

연결과정 (헤더 업그레이드!)

Socket.IO 연결 과정

초기 연결을 HTTP로 설정 > 웹소켓 프로토콜 업그레이드 = 실시간 통신

  1. 초기 HTTP 요청: 클라이언트가 Socket.IO 서버에 연결 요청을 보냅니다. 이 요청은 특정 엔드포인트(예: /socket.io/)로 전송됩니다. 서버 응답: 서버는 클라이언트의 요청에 응답하여 사용할 수 있는 전송 방법을 알려줍니다.
  2. 웹소켓 업그레이드: 클라이언트는 웹소켓으로 전환하기 위해 Upgrade 헤더를 포함한 요청을 다시 서버에 보냅니다. 서버는 이 요청을 수락하고, 101 Switching Protocols 응답을 반환하여 연결을 웹소켓으로 업그레이드합니다.
  3. 실시간 통신 시작: 업그레이드가 완료되면, 클라이언트와 서버 간에 실시간으로 데이터를 주고받을 수 있습니다.

WebSocket의 헤더를 "업그레이드"한다는 것은 클라이언트와 서버 간의 HTTP 프로토콜을 사용하여 초기 연결을 설정한 후, 이 연결을 WebSocket 프로토콜로 변경하는 과정을 의미합니다. 이를 통해 실시간 양방향 통신을 가능하게 합니다.

주요 특징 자동 폴백: 웹소켓이 지원되지 않는 환경에서도 안정적으로 연결을 유지합니다. 이벤트 기반: 클라이언트와 서버 간의 통신이 이벤트를 통해 이루어져, 코드가 직관적이고 간단합니다. 방(room) 기능: 여러 클라이언트를 그룹으로 묶어 특정 메시지를 전송할 수 있는 기능을 제공합니다. 네임스페이스: 여러 개의 독립적인 소켓 연결을 생성하여 관리할 수 있습니다.

svelte & nodejs 실시간 통신

digda/
├── apps/
│   ├── client/               # SvelteKit 프론트엔드
│   │   ├── src/
│   │   │   ├── lib/
│   │   │   │   └── socket.js
│   │   │   ├── routes/
│   │   │   │   ├── +layout.svelte
│   │   │   │   └── +page.svelte #여기서 client 연결
│   │   │   └── app.css
│   │   ├── package.json
│   │   ├── svelte.config.js
│   │   └── vite.config.js
│   │
│   └── server/              # Node.js 백엔드
│       ├── src/
│       │   └── socket/
│       │       └── server_sockets.js
│       ├── index.js # 여기서 백엔드 연결
│       └── package.json

server

서버 준비 서버 설정: 먼저, Socket.IO 서버가 실행되고 있어야 합니다. 서버는 클라이언트가 연결 요청을 받을 준비가 되어 있어야 합니다. 이때 서버는 특정 엔드포인트(예: /socket.io/)를 통해 클라이언트의 요청을 기다립니다.

// index.js

import http from "http";
import { attach_sockets } from "./lib/server_sockets.js";

// HTTP 서버 생성
const server = http.createServer();

// 서버 실행
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
    console.log(`Server is running on http://localhost:${PORT}`);
});

attach_sockets(server);

commonjs 모듈이 아닌 esm을 사용한다. nodejs도 가능함 http 서버만 필요하기때문에 express는 설치 하지 않는다.

서버를 실행한 뒤 별로도 만든 소켓 모듈을 실행한다.

// lib/server_socket.io

import { Server } from "socket.io";

export function attach_sockets(server) {
    // Socket.IO 서버 초기화
    const io = new Server(server, {
        cors: {
            origin: "http://localhost:5173", // SvelteKit 기본 포트
            methods: ["GET", "POST"],
            credentials: true,
        },
    });

    io.on("connection", (socket) => {
        console.log(socket.id, "user connected");

        socket.on("eventFromClient", (msg) => {
            console.log("📟 Message from client:", msg);
            // 클라이언트에 응답
            socket.emit("eventFromServer", `서버입니다: ${msg}`);
        });

        socket.on("disconnect", () => {
            console.log("User disconnected");
        });
    });
}

client

  1. 클라이언트 연결 요청 클라이언트 요청: 클라이언트가 Socket.IO 서버에 연결을 요청합니다. 이 요청은 HTTP 요청 형태로 전송됩니다. 클라이언트는 이 요청을 통해 서버와의 연결을 시작합니다.

socket을 class 로 모듈화

socket을 class로 구현하여 메서드를 활용해 구조화를 높였다.

  1. 캡슐화
  • private 필드(#socket, #isConnected)로 내부 상태 보호
  • 외부에서 직접 접근 불가능
  • 데이터 무결성 보장

2 . 상태 관리

class SocketWrapper {
  #socket;
  #isConnected;

  constructor() {
    this.#isConnected = writable(false);
  }

  get isConnected() {
    return { subscribe: this.#isConnected.subscribe };
  }
}


    /**
     * 소켓 연결을 초기화합니다
     *
     * @param {string} [url='http://localhost:3000'] - 서버 URL. Default is `'http://localhost:3000'`
     * @param {SocketOptions} [options={}] - 소켓 설정. Default is `{}`
     */
    connect(url = 'http://localhost:3000', options = {}) {
        try {
            this.#socket = io(url, {
                withCredentials: true,
                transports: ['websocket'],
                reconnectionAttempts: 5,
                reconnectionDelay: 1000,
                ...options
            })

            this.#socket.on('connect', () => {
                console.log('Connected with ID:', this.#socket.id)
                this.#isConnected.set(true) // 연결 상태 업데이트
            })

            this.#socket.on('disconnect', () => {
                console.log('Disconnected')
                this.#isConnected.set(false) // 연결 해제 상태 업데이트
            })

            // 연결 에러 이벤트 리스너
            this.#socket.on('connect_error', (error) => {
                console.error('Connection error:', error)
            })

            return this.#socket
        } catch (error) {
            console.error('Socket initialization error:', error)
            throw error
        }
    }
  1. 인터페이스 일관성
class SocketWrapper {
  emit(event, data) {
    this.#socket?.emit(event, data);
  }

  on(event, callback) {
    this.#socket?.on(event, callback);
    return () => this.#socket?.off(event, callback);
  }
}
  1. 에러 처리 통합
connect(url, options = {}) {
  try {
    // 연결 로직
  } catch (error) {
    console.error('Socket initialization error:', error);
    this.#isConnected.set(false);
    throw error;
  }
}
  1. 테스트 용이성
  • 모킹(mocking) 용이
  • 단위 테스트 작성 쉬움
  • 의존성 주입 가능
  1. 유지보수성
  • 관련 코드 그룹화
  • 책임 분리
  • 코드 재사용성 향상
  1. 코드 확장성
  • 새로운 기능 추가 용이
  • 기존 코드 수정 최소화
  • 인터페이스 유지하며 내부 구현 변경 가능
//+page.svelte

<script>
    import { socket } from '$lib/socket'
    import { onMount } from 'svelte'


    onMount(() => {
        socket.connect()

mount 되자마자 연결해버린다.


    let currentMessage = $state('') // 입력창에 입력된 메시지를 저장할 변수
    let returnMessage = $state(['']) // 서버로부터 받은 메시지를 저장할 변수

    onMount(() => {
        socket.connect()

        socket.on('connect', () => {
            console.log('🧑🏾‍💻 Socket connected')
        })

        socket.on('disconnect', () => {
            console.log('🧑🏾‍💻 Socket disconnected')
        })

        if (!socket.isConnected) {
            return console.log('🧑🏾‍💻 Socket is not connected')
        }
        const unsubscribe = socket.on('eventFromServer', (message) => {
            // 불변성을 유지하면서 배열 업데이트
            returnMessage = [...returnMessage, message]
        })

        return () => unsubscribe()
    })

    function sendMessage() {
        if (currentMessage.trim()) {
            socket.emit('eventFromClient', currentMessage)
            currentMessage = ''
        }
    }
</script>

<form onsubmit={sendMessage} class="flex max-w-lg mt-10 items-center">
    <Input
        class="w-full"
        bind:value={currentMessage}
        placeholder="Type a message..."
        onkeypress={(e) => e.key === 'Enter'}
    />
    <Button type="submit">Send</Button>
</form>

<ul>
    {#each returnMessage as message}
        <li>{message}</li>
    {/each}
</ul>