WebSocket은 클라이언트와 서버 간에 지속적인 양방향 통신을 가능하게 해주는 프로토콜로, HTTP와 달리 서버가 클라이언트에게 실시간으로 데이터를 푸시할 수 있는 기능을 제공한다. Dart에서 WebSocket을 사용하면 서버와 클라이언트 간의 실시간 통신을 구축할 수 있으며, 일반적으로 채팅 애플리케이션이나 실시간 게임, 주식 거래 시스템 등과 같은 곳에서 많이 사용된다.

WebSocket 기본 개념

WebSocket은 전통적인 HTTP 프로토콜과 다르게 클라이언트와 서버가 지속적으로 연결된 상태를 유지할 수 있다. 이를 통해 클라이언트는 서버에 요청을 보내지 않더라도 서버가 실시간으로 데이터를 전송할 수 있게 된다. WebSocket은 TCP를 기반으로 하며, 클라이언트와 서버 간에 핸드셰이크가 성공적으로 이루어지면 이후 통신은 양방향으로 이루어진다.

WebSocket의 중요한 특징은 다음과 같다.

WebSocket의 작동 원리

WebSocket은 HTTP 프로토콜을 사용하여 클라이언트와 서버 간의 초기 연결(핸드셰이크)을 설정한다. 이 단계에서 클라이언트는 HTTP의 Upgrade 헤더를 통해 WebSocket 연결을 요청하며, 서버가 이 요청을 승인하면 프로토콜이 WebSocket으로 변경된다. 그 후로는 TCP 소켓을 통해 지속적인 통신이 가능한다.

핸드셰이크 과정에서 클라이언트는 다음과 같은 HTTP 요청을 보낸다.

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

서버가 이 요청을 승인하면 다음과 같은 응답을 보낸다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

WebSocket 핸드셰이크 수식

WebSocket 프로토콜의 핵심 중 하나는 클라이언트가 보낸 Sec-WebSocket-Key를 기반으로 서버가 Sec-WebSocket-Accept 값을 계산하는 것이다. 이 과정은 다음과 같은 수식으로 설명할 수 있다.

클라이언트가 보낸 Sec-WebSocket-Key 값은 16바이트로 인코딩된 문자열이다. 서버는 이 키에 고정된 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11을 결합한 후, SHA-1 해시를 적용하고, 그 결과를 Base64로 인코딩하여 클라이언트에게 응답한다.

이를 수식으로 표현하면 다음과 같다.

\mathbf{Accept} = \mathrm{Base64}(\mathrm{SHA1}(\mathbf{Key} + \mathbf{GUID}))

여기서: - \mathbf{Accept}는 서버가 클라이언트에게 보낼 Sec-WebSocket-Accept 값이다. - \mathbf{Key}는 클라이언트가 보낸 Sec-WebSocket-Key이다. - \mathbf{GUID}는 고정된 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11이다.

WebSocket 통신의 장점

  1. 실시간 데이터 전송: WebSocket은 서버가 클라이언트의 요청 없이도 실시간으로 데이터를 보낼 수 있는 기능을 제공하여, 실시간 데이터 전송에 매우 적합한다. 예를 들어 주식 가격 변동을 실시간으로 클라이언트에게 전달하거나, 실시간 채팅 시스템에서 메시지를 즉각적으로 전송할 수 있다.

  2. 낮은 대기 시간: HTTP 기반의 요청-응답 모델과 달리, WebSocket은 지속적인 연결을 유지하면서 양방향 통신이 가능하기 때문에 응답 대기 시간이 짧다.

WebSocket 클라이언트 구현

Dart에서 WebSocket 클라이언트를 구현하는 것은 매우 간단한다. Dart의 dart:io 라이브러리를 사용하여 WebSocket 연결을 만들 수 있다. WebSocket 클라이언트는 서버에 연결하여 메시지를 보내고, 서버로부터 메시지를 받을 수 있다.

다음은 Dart에서 WebSocket 클라이언트를 구현하는 기본 예제이다.

import 'dart:io';

void main() async {
  // 서버에 WebSocket 연결 생성
  var socket = await WebSocket.connect('ws://echo.websocket.org');

  // 서버에 메시지 전송
  socket.add('Hello, WebSocket!');

  // 서버로부터 메시지 수신
  await for (var message in socket) {
    print('Received message: $message');
  }

  // 연결 종료
  await socket.close();
}

이 예제는 echo.websocket.org 서버에 연결하여 메시지를 보낸 후, 서버로부터 응답을 받는다. WebSocket을 사용하면 클라이언트는 서버로 메시지를 쉽게 보낼 수 있고, 서버로부터 메시지를 실시간으로 받을 수 있다.

WebSocket 서버 구현

Dart에서 WebSocket 서버도 dart:io 라이브러리를 사용하여 구현할 수 있다. WebSocket 서버는 클라이언트와의 연결을 수락하고, 클라이언트가 보내는 메시지를 처리할 수 있다.

다음은 간단한 WebSocket 서버 구현 예제이다.

import 'dart:io';

void main() async {
  // WebSocket 서버 시작
  var server = await HttpServer.bind('127.0.0.1', 8080);
  print('WebSocket server is running on ws://127.0.0.1:8080');

  await for (var request in server) {
    if (request.uri.path == '/ws') {
      // WebSocket 연결 수락
      var socket = await WebSocketTransformer.upgrade(request);
      socket.listen((message) {
        print('Received message: $message');

        // 클라이언트에게 메시지 전송
        socket.add('Echo: $message');
      });
    }
  }
}

이 서버는 로컬호스트에서 WebSocket 연결을 수락하고, 클라이언트가 보낸 메시지를 그대로 다시 클라이언트에게 반환하는 "에코" 서버이다.

WebSocket 연결 관리

WebSocket 통신에서 중요한 부분은 연결 관리이다. 클라이언트와 서버 간의 연결이 끊어질 경우, 이를 복구하거나 적절하게 처리해야 한다. 또한, 서버가 여러 클라이언트와 통신할 때, 각 클라이언트에 대한 연결을 관리하는 것이 중요하다.

연결 유지 및 복구

WebSocket 연결이 끊어질 수 있는 여러 가지 이유가 있다. 예를 들어, 네트워크 상태가 불안정하거나 서버가 다운될 수 있다. 이러한 상황에서는 클라이언트가 연결을 복구할 수 있는 메커니즘을 갖추는 것이 중요하다.

다음과 같은 방식으로 WebSocket 연결을 주기적으로 확인하고, 끊어진 경우 다시 연결을 시도할 수 있다.

import 'dart:async';
import 'dart:io';

WebSocket? socket;

void connect() async {
  try {
    socket = await WebSocket.connect('ws://localhost:8080/ws');
    socket!.listen((message) {
      print('Received: $message');
    }, onDone: () {
      print('Disconnected. Reconnecting...');
      reconnect();
    });
  } catch (e) {
    print('Error: $e');
    reconnect();
  }
}

void reconnect() {
  Timer(Duration(seconds: 5), () {
    print('Attempting to reconnect...');
    connect();
  });
}

void main() {
  connect();
}

이 코드는 WebSocket 연결이 끊어지면 5초 후에 자동으로 재연결을 시도하는 방식이다. 이를 통해 연결이 끊어져도 지속적으로 서버와 연결을 유지할 수 있다.

WebSocket 데이터 형식

WebSocket은 바이너리 데이터와 텍스트 데이터를 모두 전송할 수 있다. 기본적으로 WebSocket 메시지는 텍스트 형식으로 전송되지만, Dart에서는 바이너리 데이터도 쉽게 전송할 수 있다. 예를 들어, 파일을 WebSocket을 통해 전송하려는 경우, Dart의 Uint8List를 사용하여 바이너리 데이터를 처리할 수 있다.

텍스트 데이터 예제:

socket.add('Hello, World!');

바이너리 데이터 예제:

import 'dart:typed_data';

// Uint8List 데이터 전송
socket.add(Uint8List.fromList([0xDE, 0xAD, 0xBE, 0xEF]));

서버는 이 데이터를 그대로 수신하며, 클라이언트는 이를 처리하여 텍스트 또는 바이너리 데이터로 변환할 수 있다.

WebSocket에서의 보안 문제

WebSocket은 기본적으로 안전하지 않은 ws:// 프로토콜을 사용한다. 따라서 전송 중에 데이터가 중간에서 가로채일 수 있는 보안 문제에 노출될 수 있다. 이러한 문제를 방지하기 위해, WebSocket은 wss:// 프로토콜을 지원하며 이는 SSL/TLS를 통해 통신을 암호화한다.

다음은 WebSocket 보안 관련 주요 고려 사항이다.

1. SSL/TLS 암호화

WebSocket의 보안 버전인 wss://는 HTTPS와 동일한 방식으로 SSL/TLS를 사용하여 데이터를 암호화한다. 이를 통해 중간자 공격(Man-in-the-Middle Attack)을 방지할 수 있다. Dart에서 WebSocket 연결을 생성할 때 wss:// 프로토콜을 사용하여 보안 연결을 설정할 수 있다.

WebSocket.connect('wss://example.com/ws');

2. 인증 및 권한 부여

WebSocket 연결은 기본적으로 HTTP 핸드셰이크 과정을 통해 설정되기 때문에, 초기 연결 시점에 HTTP 헤더를 통해 인증 정보를 전달할 수 있다. 이를 통해 WebSocket 연결을 맺기 전에 클라이언트의 신원을 확인할 수 있다.

다음과 같이 WebSocket 요청 시 인증 정보를 포함할 수 있다.

var headers = {'Authorization': 'Bearer your_token_here'};
WebSocket.connect('wss://example.com/ws', headers: headers);

서버는 이 인증 정보를 확인하여 연결을 수락할지 거부할지 결정할 수 있다.

3. CSRF 및 DoS 공격 방어

WebSocket은 전통적인 HTTP 요청과는 달리 동일 출처 정책(Same-Origin Policy)을 따르지 않기 때문에, 클라이언트의 원본을 명확하게 확인하지 못하는 문제가 있다. 이를 통해 Cross-Site WebSocket Hijacking과 같은 공격에 노출될 수 있다. 서버는 반드시 클라이언트의 도메인 정보를 확인하고, 적절한 인증 과정을 거친 후에 WebSocket 연결을 허용해야 한다.

DoS 공격을 방지하기 위해 서버는 연결 요청 수를 제한하거나 타임아웃을 설정하여 비정상적인 트래픽을 차단할 수 있다.

WebSocket의 메시지 전송 및 이벤트 처리

WebSocket은 기본적으로 비동기 방식으로 메시지를 주고받는다. 즉, 클라이언트는 서버에 메시지를 보낼 때 응답을 기다리지 않고 다른 작업을 계속 진행할 수 있으며, 서버는 언제든지 클라이언트로 메시지를 전송할 수 있다.

Dart에서는 WebSocket의 메시지 전송 및 수신을 StreamSink로 처리한다. Stream은 서버로부터 수신된 데이터를 처리하고, Sink는 서버로 데이터를 전송하는 데 사용된다.

다음은 WebSocket을 사용하여 메시지를 비동기적으로 처리하는 예제이다.

import 'dart:async';
import 'dart:io';

void main() async {
  var socket = await WebSocket.connect('ws://echo.websocket.org');

  // 서버로 메시지 전송
  socket.add('Hello, WebSocket!');

  // 서버로부터 메시지 수신
  await for (var message in socket) {
    print('Received message: $message');
  }
}

이 코드는 서버로부터 메시지를 수신할 때까지 await 키워드를 사용하여 대기하고, 그 이후에 메시지를 출력한다. WebSocket에서 이벤트를 비동기적으로 처리함으로써 클라이언트는 서버와 지속적으로 데이터를 주고받을 수 있다.

멀티플렉싱

WebSocket은 하나의 연결에서 여러 채널을 관리하는 멀티플렉싱을 지원하지 않기 때문에, 멀티플렉싱이 필요할 경우에는 애플리케이션 레벨에서 이를 직접 구현해야 한다. 예를 들어, 각 메시지에 대해 특정 채널을 나타내는 식별자를 포함하고, 서버와 클라이언트에서 이를 기반으로 메시지를 구분하는 방식을 사용할 수 있다.

예제: 채널 식별자를 사용한 멀티플렉싱

socket.add('channel1: Hello, Channel 1!');
socket.add('channel2: Hello, Channel 2!');

서버는 메시지를 수신할 때 각 채널을 식별하고, 적절한 처리 로직을 적용할 수 있다.

WebSocket 성능 최적화

WebSocket 연결을 사용할 때 성능을 최적화하는 것은 매우 중요하다. 특히, 대규모 애플리케이션에서는 다수의 클라이언트와 서버 간의 WebSocket 연결을 효율적으로 관리해야 한다. 성능 최적화 전략에는 다음과 같은 내용이 포함된다.

1. 메시지 크기 최소화

WebSocket은 양방향 통신에서 데이터를 자주 주고받기 때문에, 각 메시지의 크기를 최소화하는 것이 중요하다. 이를 위해 불필요한 데이터 전송을 줄이고, 데이터 압축을 사용할 수 있다.

2. 메시지 압축

Dart에서 기본적으로 WebSocket에서의 메시지 압축을 지원하지 않지만, 서버와 클라이언트 간에 데이터를 전송할 때 자체적으로 데이터를 압축하여 전송할 수 있다. 예를 들어, 텍스트 데이터를 Gzip 형식으로 압축한 후 WebSocket으로 전송할 수 있다.

3. 타임아웃 설정

WebSocket은 지속적인 연결을 유지하기 때문에, 장시간 동안 사용되지 않는 연결이 있을 경우 이를 적절하게 종료하는 타임아웃을 설정하는 것이 좋다. 이를 통해 시스템 자원을 효율적으로 사용할 수 있다.

socket.done.timeout(Duration(seconds: 30), onTimeout: () {
  print('Connection timed out. Closing WebSocket.');
  socket.close();
});

WebSocket 이벤트 흐름

WebSocket의 이벤트 흐름을 간단한 다이어그램으로 나타내면 다음과 같다.

graph TD A[클라이언트] --> B[WebSocket 연결 요청] B --> C{핸드셰이크} C -->|성공| D[WebSocket 연결 수립] D --> E[양방향 데이터 통신] E --> F[메시지 수신/전송] F --> G{연결 종료}

이 다이어그램은 클라이언트가 서버에 WebSocket 연결 요청을 보내고, 핸드셰이크가 성공하면 양방향 데이터 통신이 시작되는 과정을 보여준다.