WebSocket 이벤트 처리하기

WebSocket 이벤트를 가로채고 모의 처리하는 방법을 배워보세요.

MSW는 전용 ws API를 사용하여 WebSocket 연결을 가로채고 모의(mocking)할 수 있습니다. 이 페이지에서는 WebSocket 이벤트를 처리하는 기본 사항을 안내하고, MSW가 양방향 연결을 다룰 때 사용하는 개념적 모델을 설명하며, 개발자 경험을 향상시키기 위해 라이브러리가 제공하는 기본값에 대해 자세히 알아봅니다.

표준 준수

Mock Service Worker는 웹 표준을 존중하고, 홍보하며, 여러분에게 가르치는 데 전념합니다. WebSocket 통신을 가로채고 모의로 처리하는 방식은 WHATWG WebSocket 표준에 따라 진행됩니다. 이는 클라이언트를 EventTarget으로 취급하고, "message""close" 같은 이벤트를 수신하며, MessageEvent 객체에서 송수신된 데이터를 읽는 것을 의미합니다.

HTTP 폴링이나 XMLHttpRequest를 사용하는 것과 같은 커스텀 WebSocket 프로토콜은 지원할 계획이 없습니다. 이러한 프로토콜은 이를 구현하는 타사 도구에 고유하며, 비표준적이고 라이브러리 전용 로직을 도입하지 않고는 이러한 프로토콜을 가로채는 신뢰할 수 있는 방법이 없습니다.

그렇지만, 표준 WebSocket 인터페이스가 실제 프로덕션 시스템에서 그대로 사용되는 경우는 드물다는 점을 인지하고 있습니다. 종종 SocketIO나 PartyKit와 같은 더 편리한 타사 추상화를 위한 기본 구현 세부 사항으로 사용됩니다. 이러한 경험 격차를 바인딩을 통해 해소하고자 합니다.

이벤트 타입

웹소켓 통신은 _양방향(duplex)_으로 이루어집니다. 이는 클라이언트와 서버가 독립적이고 동시에 이벤트를 주고받을 수 있음을 의미합니다. MSW를 사용하여 처리할 수 있는 이벤트는 두 가지 타입이 있습니다:

  • 클라이언트에서 보내는 이벤트. 애플리케이션이 웹소켓 서버로 전송하는 이벤트입니다.
  • 서버에서 받는 이벤트. 원본 서버가 전송하고 클라이언트가 "message" 이벤트 리스너를 통해 수신하는 이벤트입니다.

연결 가로채기

웹소켓 통신의 양방향 특성을 지원하고 클라이언트가 보낸 이벤트와 서버가 보낸 이벤트를 모두 가로챌 수 있도록 MSW는 클라이언트와 웹소켓 서버 사이에 위치하는 미들웨어 계층 역할을 효과적으로 수행합니다.

client ⇄ MSW ⇄ server

MSW를 어떻게 활용할지는 여러분이 결정할 수 있습니다. 모크 우선 개발에서 웹소켓 서버의 완전한 대체물이 될 수도 있고, 프로덕션 서버에서 오는 이벤트를 관찰하고 수정하기 위한 프록시로 작동할 수도 있으며, 다양한 서버 동작을 테스트하기 위해 클라이언트가 보낸 이벤트를 에뮬레이트할 수도 있습니다.

웹소켓 이벤트 처리는 클라이언트가 연결하는 서버 URL을 정의하는 것부터 시작합니다. 이는 ws.link() 메서드를 사용하여 수행됩니다.

import { ws } from 'msw'
 
const chat = ws.link('wss://chat.example.com')

웹소켓에 대해 http 핸들러와 동일한 URL 조건을 사용할 수 있습니다: 상대 및 절대 URL, 정규 표현식, 매개변수와 와일드카드가 포함된 경로 등이 있습니다.

다음으로, 핸들러 목록에 이벤트 핸들러를 추가합니다:

export const handlers = [
  chat.addEventListener('connection', () => {
    console.log('outgoing WebSocket connection')
  }),
]

"connection" 이벤트 리스너 내에서 클라이언트가 보낸 이벤트와 서버가 보낸 이벤트를 모두 처리하게 됩니다.

기본 동작 설정

MSW는 WebSocket과 관련된 다양한 테스트 및 개발 시나리오에서 좋은 개발자 경험을 보장하기 위해 기본 동작 집합을 구현합니다. 여러분은 이러한 기본 설정을 모두 해제하고, 필요에 맞게 인터셉션 동작을 세밀하게 조정할 수 있습니다.

클라이언트 연결

기본적으로 가로챈 WebSocket 연결은 열리지 않습니다. 이는 목업 우선 개발을 장려하고 존재하지 않는 서버에 대한 연결을 더 쉽게 관리할 수 있게 합니다. server.connect()를 호출하여 실제 서버 연결을 설정할 수 있습니다.

클라이언트에서 서버로 이벤트 전달

기본적으로 실제 서버 연결을 설정한 후에는 클라이언트에서 발생한 이벤트가 원래 서버로 전달됩니다. 서버 연결이 아직 설정되지 않았다면, 이벤트는 전달되지 않습니다(전달할 곳이 없기 때문). 이 동작을 원하지 않는다면 클라이언트 메시지 이벤트에서 event.preventDefault()를 호출하여 옵트아웃할 수 있습니다.

클라이언트에서 서버로 이벤트 전달에 대해 더 알아보세요.

서버에서 클라이언트로 이벤트 전달

기본적으로 실제 서버 연결을 설정하면 모든 수신 서버 이벤트가 클라이언트로 전달됩니다. 이 동작을 원하지 않는다면 서버 메시지 이벤트에서 event.preventDefault()를 호출하여 선택적으로 해제할 수 있습니다.

서버에서 클라이언트로의 전달에 대해 더 알아보세요.

Client events

클라이언트 이벤트 가로채기

클라이언트에서 보내는 이벤트를 가로채려면, "connection" 이벤트 리스너의 인자에서 client 객체를 가져온 후, 해당 객체에 "message" 리스너를 추가합니다.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    console.log('클라이언트로부터:', event.data)
  })
})

이제 WebSocket 클라이언트가 .send() 메서드를 통해 데이터를 보낼 때마다, 이 핸들러의 "message" 리스너가 호출됩니다. 리스너는 클라이언트로부터 받은 MessageEventevent 인자를 노출하며, 보낸 데이터는 event.data로 확인할 수 있습니다.

클라이언트로 데이터 보내기

연결된 클라이언트로 데이터를 보내려면 "connection" 이벤트 리스너의 인자에서 client 객체를 가져온 후, 보낼 데이터와 함께 .send() 메서드를 호출하면 됩니다.

chat.addEventListener('connection', ({ client }) => {
  client.send('서버에서 보내는 안녕!')
})

MSW는 문자열, Blob, 그리고 ArrayBuffer를 보내는 것을 지원합니다.

client.send(data)

`client.send()` API.

클라이언트에게 데이터 브로드캐스트하기

모든 연결된 클라이언트에게 데이터를 브로드캐스트하려면, 이벤트 핸들러 객체(ws.link() 호출에서 반환된 객체)의 .broadcast() 메서드를 사용하고 브로드캐스트할 데이터를 제공하세요.

chat.addEventListener('connection', () => {
  chat.broadcast('Hello everyone!')
})

특정 클라이언트를 제외한 모든 클라이언트에게 데이터를 브로드캐스트하려면, 이벤트 핸들러 객체의 .broadcastExcept() 메서드를 사용할 수 있습니다.

chat.addEventListener('connection', ({ client }) => {
  // 현재 클라이언트를 제외한 모든 클라이언트에게 데이터를 브로드캐스트합니다.
  chat.broadcastExcept(client, 'Hello everyone except you!')
 
  // 특정 조건을 만족하는 클라이언트에게만 데이터를 브로드캐스트합니다.
  chat.broadcastExcept(chat.clients.filter((client) => {
    return client
  }, "Hello to some of you!")
})

.broadcast(data)

`.broadcast()` API.

.broadcastExcept(clients, data)

`.broadcastExcept()` API.

클라이언트 연결 종료

client.close()를 호출하면 언제든지 기존 클라이언트 연결을 종료할 수 있습니다.

chat.addEventListener('connection', ({ client }) => {
  client.close()
})

기본적으로 .close() 메서드는 연결을 정상적으로 종료합니다(코드 1000). .close() 메서드에 codereason 인자를 전달하여 연결 종료 방식을 제어할 수 있습니다.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    if (event.data === 'hello') {
      client.close(1003)
    }
  })
})

예를 들어, 이 핸들러에서는 클라이언트가 "hello" 메시지를 보내면 1003 코드로 연결이 종료됩니다(서버가 받아들일 수 없는 데이터).

WebSocket.prototype.close() 메서드와 달리, client 연결의 .close() 메서드는 1001, 1002, 1003과 같이 사용자가 설정할 수 없는 종료 코드도 허용합니다. 이를 통해 WebSocket 통신을 더 유연하게 설명할 수 있습니다.

client.close(code, reason)

`client.close()` API.

Server events

서버 연결 설정하기

실제 WebSocket 서버에 연결하려면 "connection" 이벤트 리스너의 인자에서 server 객체를 가져와 .connect() 메서드를 호출하세요.

chat.addEventListener('connection', ({ server }) => {
  server.connect()
})

server.connect()

`server.connect()` API.

클라이언트에서 서버로 전달하기

서버 연결이 설정되면, 모든 클라이언트의 발신 메시지 이벤트가 서버로 전달됩니다. 이 동작을 방지하려면 클라이언트 메시지 이벤트에서 event.preventDefault()를 호출하세요. 이를 통해 클라이언트가 보낸 데이터가 서버에 도달하기 전에 수정하거나 완전히 무시할 수 있습니다.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    // 기본 클라이언트-서버 전달을 방지합니다.
    event.preventDefault()
 
    // 원본 클라이언트가 보낸 데이터를 수정하고
    // 서버로 대신 전송합니다.
    server.send(event.data + 'mocked')
  })
})

서버 이벤트 가로채기

실제 서버에서 들어오는 이벤트를 가로채려면, "connection" 이벤트 리스너의 인자에서 server 객체를 가져와서 해당 객체에 "message" 이벤트 리스너를 추가하세요.

chat.addEventListener('connection', ({ server }) => {
  server.addEventListener('message', (event) => {
    console.log('서버에서 온 메시지:', event.data)
  })
})

이제 실제 서버가 데이터를 보낼 때마다, 이 핸들러의 "message" 리스너가 호출됩니다. 리스너는 클라이언트로부터 받은 MessageEventevent 인자를 노출하며, 전송된 데이터는 event.data로 확인할 수 있습니다.

서버에서 클라이언트로의 전달

기본적으로 모든 서버 이벤트는 연결된 클라이언트로 전달됩니다. 서버 메시지 이벤트에서 event.preventDefault()를 호출하면 이 동작을 선택적으로 해제할 수 있습니다. 이는 서버에서 보낸 데이터가 클라이언트에 도달하기 전에 수정하거나, 특정 서버 이벤트가 클라이언트에 도달하지 못하도록 방지하려는 경우에 유용합니다.

chat.addEventListener('connection', ({ client, server }) => {
  server.addEventListener('message', (event) => {
    // 서버에서 클라이언트로의 기본 전달을 방지합니다.
    event.preventDefault()
 
    // 원본 서버에서 보낸 데이터를 수정한 후
    // 클라이언트로 전송합니다.
    client.send(event.data + 'mocked')
  })
})

서버로 데이터 보내기

실제 서버로 데이터를 보내려면 "connection" 이벤트 리스너 인수에서 server 객체를 가져온 후, 서버로 보낼 데이터와 함께 .send() 메서드를 호출하세요.

chat.addEventListener('connection', ({ server }) => {
  server.send('hello from client!')
}

이는 클라이언트가 서버로 해당 데이터를 보내는 것과 동일합니다.

server.send(data)

`server.send()` API.

서버 연결 종료

이전에 생성된 원본 WebSocket 서버 연결은 server.close()를 호출하여 종료할 수 있습니다.

chat.addEventListener('connection', ({ server }) => {
  server.connect()
  server.close()
})

server.close()를 호출하면 서버 연결이 종료되고, server 객체에서 close 이벤트가 발생합니다.

로깅

MSW는 WebSocket 가로채기 모의(mock)를 우선적으로 구현하기 때문에, 여러분이 명시적으로 요청하지 않는 한 실제 연결이 이루어지지 않습니다. 이는 모의 시나리오가 브라우저의 개발자 도구(DevTools)에서 네트워크 항목으로 나타나지 않으며, 해당 도구에서 이를 관찰할 수 없음을 의미합니다.

MSW는 브라우저에서 모의 및 실제 WebSocket 연결에 대한 커스텀 로깅을 제공합니다.

로그 출력 읽기

로거는 웹소켓 통신 중 발생하는 다양한 이벤트를 브라우저 콘솔에서 접을 수 있는 콘솔 그룹으로 출력합니다.

여러분이 확인할 수 있는 네 가지 유형의 로그가 있습니다:

  1. ▶■×시스템 이벤트;

  2. ⬆⇡클라이언트 이벤트;

  3. ⬇⇣서버 이벤트.

  4. ⬆⬇모의 이벤트.

System events

연결이 열림

[MSW] 12:34:56 ▶ wss://example.com

웹소켓 클라이언트가 open 이벤트를 발생시킬 때(즉, 연결이 열릴 때) 전달됩니다.

× 연결 오류 발생

[MSW] 12:34:56 × wss://example.com

클라이언트가 오류를 수신할 때 발생합니다 (즉, WebSocket 클라이언트가 error 이벤트를 발생시킬 때).

연결 종료

[MSW] 12:34:56 ■ wss://example.com

웹소켓 클라이언트가 close 이벤트를 발생시킬 때 연결이 종료됩니다.

메시지 이벤트

보내는 메시지든 받는 메시지든 모든 메시지는 동일한 구조를 따릅니다:

        타임스탬프         전송된 데이터
[MSW] 00:00:00.000  hello from client 17
                  아이콘              바이트 길이

바이너리 메시지는 전송된 바이너리의 텍스트 미리보기와 전체 바이트 길이를 함께 출력합니다:

[MSW] 12:34:56.789 ⬆ Blob(hello world) 11
[MSW] 12:34:56.789 ⬆ ArrayBuffer(preview) 7

긴 텍스트 메시지와 텍스트 미리보기는 생략됩니다:

[MSW] 12:34:56.789 ⬆ this is a very long stri… 17

콘솔 그룹을 클릭하고 원본 MessageEvent 인스턴스를 확인하면 전체 메시지를 볼 수 있습니다.

⬆⇡ 클라이언트에서 보낸 메시지

[MSW] 12:34:56.789 ⬆ 클라이언트 17에서 보낸 메시지

여러분의 애플리케이션에서 WebSocket 클라이언트가 보낸 원시 메시지입니다. 화살표가 점선으로 표시된 경우, 이 메시지 이벤트의 전달이 이벤트 핸들러에서 차단되었음을 의미합니다.

클라이언트에서 보낸 모의 메시지

[MSW] 12:34:56.789 ⬆ 모의 서버에서 보낸 메시지 15

이벤트 핸들러가 server.send()를 통해 클라이언트에서 보낸 메시지입니다. 서버 연결이 열려 있어야 합니다.

⬇⇣ 서버에서 들어오는 메시지

[MSW] 12:34:56.789 ⬇ 서버에서 온 메시지 17

원본 서버에서 보낸 들어오는 메시지입니다. 서버 연결 열기가 필요합니다. 화살표가 점선으로 표시된 경우, 이 메시지 이벤트의 전달이 이벤트 핸들러에서 차단되었음을 의미합니다.

들어오는 모의 서버 메시지

[MSW] 12:34:56.789 ⬇ 모의 서버에서 보낸 메시지 15

이벤트 핸들러를 통해 client.send()로 클라이언트에 전송된 모의 메시지입니다.

이벤트 흐름

웹소켓 통신과 마찬가지로, MSW를 사용한 처리도 이벤트 기반입니다. 웹소켓을 모킹할 때는 EventTarget과 자바스크립트에서 이벤트가 어떻게 동작하는지 이해해야 합니다. 간단히 복습해 보겠습니다.

애플리케이션이 웹소켓 연결을 설정하면, _모든 일치하는 웹소켓 링크_에서 connection 이벤트가 발생합니다.

const chat = ws.link('wss://example.com')
 
export const handlers = [
  chat.addEventListener('connection', () => console.log('This is called')),
  chat.addEventListener('connection', () => console.log('This is also called')),
]

이렇게 하면, 정상적인 경로와 런타임 핸들러 모두 동일한 연결에 반응할 수 있습니다.

클라이언트/서버 이벤트는 동일한 연결의 모든 clientserver 객체에서도 전달됩니다. 동시에, 여러 리스너를 동일한 객체에 연결하거나, 다른 핸들러 간에 다른 객체에 연결할 수 있습니다.

export const handlers = [
  chat.addEventListener('connection', ({ client }) => {
    // 동일한 `client` 객체에 여러 메시지 리스너를 연결합니다.
    client.addEventListener('message', () => console.log('This is called'))
    client.addEventListener('message', () => console.log('This is also called'))
  }),
  chat.addEventListener('connection', ({ client }) => {
    // 다른 `client` 객체에 또 다른 메시지 리스너를 연결합니다.
    client.addEventListener('message', () =>
      console.log('Hey, this gets called too!'),
    )
  }),
]

이러한 이벤트는 동일한 이벤트 타겟에 대해 전달되므로, 이를 활용하여 이벤트를 _방지_할 수 있습니다. 이는 런타임 핸들러(예: 네트워크 동작 재정의)를 생성할 때 유용하며, 재정의가 특정 이벤트 처리를 _보강_할지 아니면 _완전히 재정의_할지 제어할 수 있습니다.

const server = setupServer(
  chat.addEventListener('connection', ({ client }) => {
    client.addEventListener('message', (event) => {
      // 정상적인 경로 핸들러에서 웹소켓 클라이언트로부터 받은 이벤트 데이터를 다시 보냅니다.
      client.send(event.data)
    })
  }),
)
 
it('handles error payload', async () => {
  server.use(
    chat.addEventListener('connection', ({ client }) => {
      client.addEventListener('message', (event) => {
        // 이 런타임 핸들러에서 "message" 클라이언트가 다른 이벤트 타겟(정상적인 경로 핸들러)으로 전파되는 것을 방지합니다.
        // 그런 다음, 클라이언트에 완전히 다른 메시지를 보냅니다.
        event.stopPropagation()
        client.send('error-payload')
      })
    }),
  )
})

event.stopPropagation()을 생략하면 동일한 이벤트를 받았을 때 클라이언트에게 _두 개_의 메시지가 전송됩니다. 먼저 'error-payload'가 전송되고, 그 다음 원래의 event.data가 전송됩니다.

일반적인 EventTarget과 마찬가지로, event.preventImmediatePropagation()을 사용하여 이벤트가 형제 리스너 간에 전파되는 것을 막을 수 있습니다. 예를 들어, 특정 웹소켓 이벤트를 처리할 때 이를 사용하여 다른 이벤트 리스너가 호출되는 것을 차단할 수 있습니다.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    if (event.data === 'special-scenario') {
      // 이 "message" 이벤트가 아래의 "message" 이벤트 리스너로 전파되는 것을 방지합니다.
      event.stopImmediatePropagation()
      client.close()
    }
  })
 
  client.addEventListener('message', (event) => {
    client.send(event.data)
  })
})

클라이언트가 메시지로 'special-scenario' 페이로드를 보내면, 연결이 종료되고 두 번째 이벤트 리스너의 client.send(event.data) 로직은 호출되지 않습니다.

타입 안전성

TypeScript를 사용할 때 WebSocket 통신에 타입 안전성을 부여하는 것은 매우 중요합니다. 여기에는 핸들러도 포함됩니다! 하지만 MSW는 의도적으로 나가는/들어오는 이벤트에 주석을 달기 위한 타입 인수를 지원하지 않습니다:

import { ws } from 'msw'
 
ws.link<Arg1, Arg2>(url)
//     ^^^^^^^^^^^^ 타입 에러!

이 결정에는 두 가지 이유가 있습니다:

  1. 데이터 타입을 좁히는 것이 네트워크를 통해 다른 데이터 타입이 전송되지 않을 것임을 보장하지 않습니다 (클래식한 타입 대 런타임 논쟁);
  2. 메시지 이벤트 리스너에서 받는 event.data 값은 항상 string | Blob | ArrayBuffer 타입입니다. MSW는 메시지 파싱을 제공하지 않기 때문입니다.

WebSocket 서버와 객체를 사용하여 통신한다면, 해당 객체를 보낼 때와 받을 때 각각 문자열로 변환하고 파싱해야 합니다. 이는 이미 애플리케이션에 파싱 계층이 존재함을 의미합니다.

파싱 유틸리티를 도입하여 WebSocket에서 적절한 타입 및 런타임 안전성을 달성할 수 있습니다. Zod와 같은 라이브러리는 타입 및 런타임 안전성을 달성하는 데 큰 도움을 줄 수 있습니다.

import { z } from 'zod'
 
// 들어오는 이벤트에 대한 Zod 스키마를 정의합니다.
// 여기서는 WebSocket 통신이 "chat/join"과 "chat/message" 두 가지 이벤트를 지원합니다.
const incomingSchema = z.union([
  z.object({
    type: z.literal('chat/join'),
    user: userSchema,
  }),
  z.object({
    type: z.literal('chat/message'),
    message: z.object({
      text: z.string(),
      sentAt: z.string().datetime(),
    }),
  }),
])
 
chat.addEventListener('connection', ({ client, server }) => {
  client.addEventListener('message', (event) => {
    const result = incomingSchema.safeParse(event.data)
 
    // 일치하지 않는 이벤트는 무시합니다.
    if (!result.success) {
      return
    }
 
    const message = result.data
 
    // 타입 안전한 방식으로 들어오는 이벤트를 처리합니다.
    switch (message.type) {
      case 'chat/join': {
        // ...
        break
      }
 
      case 'chat/message': {
        // ...
        break
      }
    }
  })
})

메시지 이벤트에 대한 고차 리스너를 도입하여 파싱을 추상화하고, 핸들러 간에 재사용할 수 있도록 하는 것도 좋은 방법입니다.

바인딩

MSW는 서드파티 WebSocket 클라이언트를 모킹할 때 더 친숙한 경험을 제공하기 위해 _바인딩_을 사용합니다. 바인딩은 표준 WebSocket 클래스를 감싸는 래퍼로, 메시지 파싱과 같은 서드파티 특정 동작을 캡슐화하고, 바인딩된 서드파티 라이브러리와 유사한 공개 API를 제공합니다.

예를 들어, MSW와 지정된 SocketIO 바인딩을 사용하여 Socket.IO 통신을 처리하는 방법은 다음과 같습니다:

import { ws } from 'msw'
import { toSocketIo } from '@mswjs/socket.io-binding'
 
const chat = ws.link('wss://chat.example.com')
 
export const handlers = [
  chat.addEventListener('connection', (connection) => {
    const io = toSocketIo(connection)
 
    io.client.on('hello', (username) => {
      io.client.emit('message', `hello, ${username}!`)
    })
  }),
]

@mswjs/socket.io-binding

Connection wrapper for mocking Socket.IO with MSW.

바인딩은 해당 서드파티 라이브러리의 모든 공개 API를 커버하기 위한 것이 아닙니다. 해당 라이브러리에서 바인딩을 제공하지 않는 한, 완전한 호환성을 유지하는 것은 불가능합니다.