Logo

Prolip's Blog

웹소켓으로 통신해보기..
  • #Project
  • #Burgerput

웹소켓으로 통신해보기..

Prolip

2024-08-02

버거풋에 관리자에게 문의하기 기능 만들기...(웹소켓을 곁들여..)

현재의 문제

버거풋을 사용하다 갑자기 건의사항 혹은 에러가 발생했습니다.. 그런데 만약 버거풋 이용자가 현재 우리가 모르는 사람이라면 어떡하죠?

우리가 모르는 사용자가 버거풋을 사용하다 문제를 마주했습니다. 그런데 우리 관리자들의 핸드폰 번호가 없어 연락을 하지 못한다면 아주 큰일이겠습니다!

그렇다면 웹 사이트에서 관리자에게 바로 연락을 할 수 있다면 좋지 않을까요??

WebSocket?

WebSocke은 클라이언트와 서버 간의 상호작용(대표적으로 대화가 있겠죠?)을 실시간으로 가능하게 해주는! 통신 프로토콜입니다. HTTP 요청과 달리 WebSocket은 연결을 지속적으로 유지해 양방향 통신을 가능하게 해줍니다. WebSocket을 사용한다면 서버랑 클라이언트가 어디 하나가 나가지 않는 이상 계속해서 데이터를 주고 받을 수 있게 해줍니다.

이는 실시간 채팅(제가 버거풋에 적용하고자 하는 기능입니다..), 라이브 업데이트 등이 필요한 상황에서 사용이 가능합니다.

기본 개념 뜯어보기

1. HTTP vs WebSocket

  • HTTP
    • HTTP는 클라이언트가 서버에 요청을 보내고, 서버가 이 요청에 응답하는 요청 - 응답 모델입니다. 제 블로그에 접속할 때도 사용자가 서버에 제 블로그 페이지를 요청하고 서버에서 제 블로그의 HTML, CSS, JavsScript 파일을 응답으로 보내주는 것입니다!
    • HTTP는 기본적으로 단방향 통신을 지원합니다. 이게 무슨 말이냐! 클라이언트가 요청을 보내야만 서버가 응답을 할 수 있는 것입니다. 아무도 요청 안 했는데 어디 제 블로그 HTML을 보낼 수는 없으니까요!
    • HTTP는 요청 간의 상태를 저장하지 않습니다. 이건 무슨 말이냐! 각 요청이 독립적이며 이전 요청의 정보가 포함되지 않는다는 뜻입니다! 쉽게 설명하자면 서버는 클라이언트가 요청을 여러번 보내도 처음 받은 것처럼 처리합니다.
      • 클라이언트 - 안녕하세요,, 저 지민인데 오늘 저녁 밥이 뭡니까?
      • 서버 - 안녕하세요 지민님? 오늘 저녁밥은 삼겹살입니다..
      • 1분뒤 클라이언트 - 안녕하세요,, 저 아까 지민인데 오늘 저녁 밥이 뭐였죠?
      • 다시 서버 - 안녕하세요 아까 지민이요? 누구신지 모르겠는데 저녁은 삼겹살입니다.
  • WebSocket
    • WebSocket은 클라이언트와 서버 간의 지속적인 연결을 유지합니다. 핸드셰이크 이후에 양방향 통신이 가능합니다.
    • 연결이 설정된 후 클라이언트와 서버는 상시적으로 데이터를 주고받을 수 있습니다. HTTP와는 다르게 서버는 클라이언트가 요청하지 않아도 데이터를 보낼 수 있습니다.
    • 연결이 유지되는 동안 상태를 저장할 수 있습니다. 즉, 실시간으로 이루어지는 행동에 있어서 아주 유용합니다!

그럼 WebSocket은 어떻게 동작할까요??

1. Opening Handshake

클라이언트는 HTTP 요청을 통해 서버에 WebSocket 연결을 요청하게 됩니다. 이 요청에는 ‘Upgrade’ 헤더가 포함되어 있어 서버에게 프로토콜을 HTTP에서 WebSocket으로 업그레이드할 것을 요청하게 됩니다.

GET /chat HTTP/1.1
Host: www.gojimin.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-key: 어쩌구어려운암호화키
Sec-WebSocket-Version: 13

Upgrade: websocket - 서버에게 HTTP 연결을 WebSocket 프로토콜로 업그레이드할 것을 요청하는 헤더입니다.

Connection: Upgrade - 해당 연결이 업그레이드 요청을 포함하고 있음을 나타내는 헤더입니다.

Sec-WebSocket-Key - 클라이언트가 생성한 임의의 키로 서버는 이 키를 기반으로 응답을 생성합니다.

Sec-WebSocket-Version - WebSocket 프로토콜의 버전을 지정하는데 일반적으로 13을 쓴다고 합니다.

그럼 이후에 서버가 WebSocket 연결 요청을 수락하면, HTTP 101 Switching Protocols 응답을 클라이언트에게 보내게 됩니다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 어쩌구어려운암호화키

101 Switching Protocols - 서버가 클라이언트의 업그레이드 요청을 승인했음을 나타내는 헤더입니다.

Upgrade: websocket - 서버가 연결을 WebSocket 프로토콜로 업그레이드했음을 확인하는 헤더입니다.

Connection: Upgrade - 연결이 업그레이드되었음을 다시 확인합니다.

Sec-WebSocket-Accpet - 서버가 클라이언트의 ‘Sec-WebSocket-Key’를 기반으로 생성한 응답 키로 이 키는 클라이언트가 서버의 응답이 올바른지 확인하는데 사용되는 헤더입니다.

Upgrade 헤더는 어디에 쓰일까요??

  • 프로토콜 전환 : 서버와 초기 연결은 HTTP 프로토콜로 이루어집니다. 하지만 실제로는 지속적이고 양방향 통신을 위해 WebSocket 프로토콜을 사용하려고 합니다. 이 때 ‘Upgrade’ 헤더는 서버에게 전환을 요청하는 역할을 합니다.
  • ‘Upgrade’ 헤더는 서버에게 클라이언트가 단순한 HTTP 요청이 아닌 WebSocket 연결을 원한다는 것을 명확하게 전달합니다. 즉, 명시적 요청입니다.
  • 업그레이드 요청을 통해 서버는 클라이언트가 WebSocket 연결을 시작하려고 함을 인식할 수 있고 이를 안전하게 처리할 수 있습니다. 그

그럼 HandShake의 중요성이 있을까요??

  • 클라이언트와 서버는 HandShake를 통해 서로의 신뢰성을 확인할 수 있습니다. 위에서 살펴본 Sec-WebSocket-KeySec-WebSocket-Accept 헤더가 이 과정에서 중요한 역할을 합니다.
  • 그리고 이러한 과정을 통해 HandShake가 성공적으로 완료되면, HTTP 연결이 WebSocket 연결로 업그레이드되어 양방향 통신이 가능해집니다. 후에 클라이언트와 서버는 지속적으로 데이터를 주고받을 수 있게 됩니다.

2. 데이터 전송

자 그럼 WebSocket 연결이 설정되면 데이터 전송이 어떻게 이루어질까요?

양방향 통신

양방향 통신은 클라이언트와 서버가 서로 동시에 데이터를 주고 받을 수 있는 통신 방식을 의미합니다.

HTTP와 다르게 WebSocket은 지속적인 연결을 유지하므로 클라이언트가 서버에 데이터를 보내는 동안에도 서버가 클라이언트에게 데이터를 보낼 수 있습니다!

이는 채팅 혹은 게임, 심지어 주식 거래 시스템 등 실시간 통신이 필요한 모든 어플리케이션에서 사용될 수 있습니다.

프레임 단위로 전송

WebSocket 프로토콜은 데이터를 프레임(Frame) 단위로 전송합니다. 각 프레임은 여러 필드로 구성되기 때문에 다양한 유형의 데이터를 효율적으로 주고받을 수 있게 합니다.

그럼 프레임의 필드는 어떻게 구성될까요?

  1. FIN bit: 데이터 메세지의 종료를 나타냅니다. 즉 메세지의 마지막 프레임인지 여부를 나타냅니다.
    • 위치: 프레임의 첫 번째 비트에 위치합니다.
    • 크기: 1비트
    • 이 비트가 1이면 마지막 프레임, 0이면 메세지가 여러 프레임으로 나뉘어 있음을 나타내며 뒤로 더 많은 프레임이 이어질 수 있음을 의미합니다.
    • FIN bit1이면 단일 프레임 메세지이고 마지막 프레임의 FIN bit1이면 다중 프레임 메세지입니다.
      • 여기서 다중 프레임 메세지란 WebSocket에서 큰 메세지나 연속적인 데이터 전송을 처리하기 위해 메세지를 여러 프레임으로 나누어 보낼 수 있는데 이를 의미합니다.
      • 예를 들어 클라이언트에서 사이즈가 큰 메세지를 서버로 보낸다고 가정해봅시다. 이 때 첫 번째 프레임의 ‘FIN’ bit0으로 설정됩니다. 이는 해당 메세지가 여러 프레임으로 나뉘어 있음을 나타내며 이후에 추가적인 프레임이 더 있을 것임을 의미합니다. 또한 이 프레임은 메세지의 일부를 포함하고 있습니다.
      • 이후에 두 번째 프레임도 ‘FIN’ bit0으로 설정됩니다. 이 프레임에도 메세지의 일부가 포함되어 있으며 뒤로 프레임이 이어질 것임을 의미합니다.
      • 마지막 프레임의 ‘FIN’ bit1로 설정되어 이 프레임이 메세지의 마지막 프레임임을 알리고 이후에 더 추가되는 프레임이 없음을 의미합니다.
  2. Opcode: Opcode는 WebSocket 프레임의 유형을 지정하는 4비트 필드로 프레임의 내용과 수신자가 프레임을 어떻게 처리해야 하는지를 나타냅니다.
    • 위치: 첫 번째 바이트의 5번째 비트부터 8번째 비트까지 위치합니다.
    • 크기: 4비트
    • 0x0: 이전에 보내진 프레임의 연속된 프레임을 뜻하며 하나의 메세지가 여러 프레임으로 나뉘어 전송될 때 사용됩니다. 이 프레임은 메세지의 일부를 포함할 수 있고 나머지 부분이 뒤따를 수도 있습니다!
    • 0x1: 텍스트 데이터가 포함된 프레임을 뜻하며 텍스트 데이터는 UTF-8 인코됭된 문자열로 전송됩니다.
    • 0x2: 이진 데이터가 포함된 프레임을 뜻하며 텍스트가 아닌 모든 종류의 데이터 전송에 사용됩니다.
    • 0x3: 연결을 닫기 위한 프레임으로 연결 종료 요청과 함께 종료 코드를 포함할 수 있습니다.
    • 0x9: 핑 프레임으로 연결의 상태를 확인할 때 사용됩니다. 핑 프레임은 수신자가 퐁 프레임으로 응답합니다.
    • 0xA: 퐁 프레임으로 핑 프레임에 대한 응답 프레임입니다.
  3. Mask Bit: WebSocket 프레임의 첫 번째 바이트의 6번째 비트에 위치한 1비트 필드로 데이터가 마스킹 되었는지 나타냅니다. 여기서 마스킹은 데이터를 전송할 때 보안을 위해 데이터를 변형하는 과정으로 클라이언트가 서버로 데이터를 보낼 때 사용됩니다.
    • 위치: 첫 번째 바이트의 6번째 비트에 위치합니다.
    • 크기: 1 비트
    • 클라이언트가 데이터를 보낼 때는 항상 마스킹되어 1로 설정되고, 서버에서 보낸 데이터는 일반적으로 마스킹되지 않아 0으로 설정됩니다.
      • 만약 마스킹 비트가 1인 경우 실제 데이터에 마스킹 키가 같이 보내지며 마스킹된 데이터를 수신한 사용자는 마스킹 키를 사용해 원래 데이터로 복원합니다.
      • 마스킹 키는 4바이트 길이의 값으로 마스킹 비트가 1인 경우 프레임 헤더 뒤(Payload Length)에 추가됩니다.
      • 마스킹 과정은 클라이언트에서 무작위로 4바이트 길이의 마스킹 키를 생성해 각 데이터 바이트를 해당하는 마스킹 키의 바이트와 XOR 연산을 통해 이루어집니다. 이후에 마스킹된 데이터를 네트워크를 통해 전송하고, 서버는 마스킹 키를 사용해 동일한 XOR 연산을 수행해 원래 데이터로 복원하게 됩니다.
    • 이후에 서버는 마스킹 키와 마스킹된 데이터를 받아 원래의 데이터로 복원합니다. 이 때 복원 과정도 마스킹 과정과 동일하게 XOR 연산으로 복원합니다.
      • 저는 이거 알아보는 과정에서 마스킹 키를 같이 보내면 이거 패킷을 공격자가 탈취하면 어차피 똑같이 복호화할 수 있는 거니까 의미 없는 과정 아닌가? 싶어서 알아보니 이게 일반적인 암호화랑은 다르다고 합니다. 이게 실제로 보안 강화를 위해 암호화하는게 아니고 주로 프로토콜 스펙 충족 및 특정 공격 방지 목적으로 한답니다. MITM 공격 같은 거 말이죠
      • 데이터 패킷을 분석하고 이걸 캐싱하는 과정을 마스킹을 통해 패턴이 반복되지 않게 변경해 어렵게 만들고 데이터를 변형해 중간에 데이터를 단순히 가로채는 공격에선 원래 데이터를 쉽게 알 수 없게 만든다고 합니다. 이게 그렇다고 정말 안전한 보안 메커니즘이냐? 하면 그건 또 아니고 데이터 무결성을 높이기 위한 수준으로 보는듯 합니다.
      • 그래서 결국 데이터 자체를 가로채는 공격에선 마스킹 키를 알 수 있기 때문에 결국 해독되기 때문에 보안 수준은 현저히 낮은게 맞습니다. 이 때 TLS 같은 암호화 프로토콜을 실제로 보안에 사용하게 됩니다.
  4. Payload Length: 전송되는 데이터의 길이를 나타내는 필드로 수신자가 얼마나 많은 데이터를 읽어야 하는지 결정합니다.
    • 위치: 첫 번째 바이트의 7번째 비트부터 15번째 비트까지 위치합니다.
    • 크기: 기본적으로 7비트지만 필요하다면 확장이 가능합니다.
    • 각 길이의 값에 따른 구조는 다음과 같습니다.
      • 0-125: 기본 길이로 0에서 125 사이의 값이라면 실제 데이터의 길이를 나타냅니다. 그냥 125 이하면 Payload Length 자체가 payload의 크기라고 생각하면 됩니다.
      • 126: 126일 경우 다음 2 바이트가 실제 데이터의 길이를 나타냅니다. 예를 들어 Payload Length가 126이고 다음 2바이트가 00 7A라면 123 바이트겠죠??
      • 127: 127일 경우 다음 8바이트가 실제 데이터의 길이를 나타냅니다.
  5. Masking Key: 위에서 설명한 클라이언트가 서버로 데이터를 전송할 때 전송되는 데이터를 마스킹하고 이를 복원하는데 사용되는 4바이트 길이의 키입니다.
    • 위치: 바로 위 ‘Payload Length’ 필드 바로 뒤에 위치합니다. ‘Mast Bit’이 1로 설정된 경우에만 존재합니다.
    • 크기: 4바이트
  6. Payload Data: 실제 데이터를 포함하는 필드입니다. 텍스트 혹은 이진 데이터를 전달할 수 있습니다.
    • 위치: ‘Masking Key’ 필드 뒤에 위치합니다. 이 때 **‘Masking Key’가 없는 경우(’Mask Bit’**이 0인 경우)에는 ‘Payload Length’ 필드 뒤에 위치합니다.
    • 크기: 크기가 가변적입니다. ‘Payload Length’ 필드에 따라 결정됩니다.

실시간 통신

WebSocket은 클라이언트와 서버 간의 연결이 설정되면 특별한 이벤트, 명령이 없다면 연결을 유지합니다. HTTP 요청은 요청과 응답이 한번에 끝나는데 WebSocket은 이와 달리 지속적으로 데이터를 주고받을 수 있습니다.

또한 연결이 지속되기 때문에 새로운 데이터를 보낼 때도 추가적으로 연결을 설정하는 과정 없이 바로 전송할 수 있습니다.

그렇다면 WebSocket의 실시간 통신이 가지는 특징과 주요 메커니즘을 정리해보고 어떻게 사용할지 알아봅시다.

  • 지속적인 연결이 가능하다.
    • 위에서 설명했듯 WebSocket은 클라이언트와 서버 사이에 연결이 설정되면 이후엔 특별히 연결을 끊지 않는다면 지속적으로 연결이 유지됩니다. 즉 지속적으로 데이터를 주고받는게 가능합니다.
  • 오버헤드가 비교적 거의 없다.
    • HTTP 통신의 경우 새로운 요청을 설정하고 해제하는 과정이 매번 일어나고 이 과정에서 오버헤드가 발생하는데 비해 WebSocket은 초기에 연결을 설정하면 연결을 계속 유지하기 때문에 오버헤드가 거의 없다고 볼 수 있습니다.
    • 여기서 오버헤드란 어떤 기능을 수행하는데 들어가는 간접적인 처리 시간 혹은 메모리를 뜻합니다.
  • 이벤트 기반 통신
    • WebSocket은 이벤트 기반으로 동작합니다. 특정 이벤트가 발생할 때 미리 정의된 콜백 함수를 실행하는 방식으로 동작합니다.
    • 위에서 설명한 방식을 이벤트 드리븐이라고 말할 수 있습니다. 이는 특정 이벤트(WebSocket의 경우 메세지 수신, 연결 설정, 연결 해제 등)가 발생할 때마다 미리 정의된 코드 블록(콜백 함수)이 실행되는 방식입니다.

WebSocket 사용해보기

이제 WebSocket이 뭔지는 알았으니 써봐야겠죠??

버거풋엔 어떻게…

일단! 제가 현재 생각 중인 우리 사이트의 통신 방식은 다음과 같습니다.

  1. WebSocket을 이용한 통신 서버를 따로 구현한다. (혹시 서버가 터지는 등의 문제라면 기껏 만든 통신도 먹통이 될듯하니 통신용 서버는 새로 구현한다.)
  2. 문제가 발생해 애를 먹고 있는 사용자는 관리자 호출 버튼을 클릭한다.
    • 이 때, 호출 버튼을 클릭하면 현재 사이트를 관리하고 있는 prolip과 yellta에게 메일을 발송해 이를 알릴 수 있도록 한다.
  3. 사용자가 채팅방에 입장한다.
  4. 이제 관리자가 채팅방에 입장해 사용자와 채팅을 나눌 수 있도록 구현한다.
    • 관리자용 채팅 페이지의 경우 로그인을 통해 접근을 제한한다. 버거풋은 이미 사용에 앞서 로그인을 수행.

Socket.io

제가 사용할 라이브러리는 Socket.io입니다!

Socket.io는 Node.js를 기반으로 만들어진 실시간 양방향 통신 라이브러리로 클라이언트와 서버 간의 실시간 이벤트 기반 통신을 지원합니다! WebSocket을 기본적으로 사용하지만, 브라우저 호환성을 위해 다른 프로토콜(fallback)을 지원한다고 합니다.

해당 라이브러리가 가지는 특징으로

  1. 실시간 양방향 통신
    • 당연히 웹소켓 라이브러리니까 클라이언트와 서버 간에 실시간으로 데이터를 주고받는게 가능합니다.
  2. 자동 재연결
    • 연결이 끊어지면 자동으로 재연결을 시도한다고 합니다.
  3. 이벤트 기반 통신
    • 특정한 이벤트에 콜백 함수를 등록시켜 사용합니다.
  4. 다양한 브라우저 지원
    • WebSocket을 지원하지 않는 브라우저에서도 사용할 수 있다고 합니다. AJAX long-polling 등의 fallback을 사용한다고 합니다.
    • 그런데 버거풋은 사실 다양한 사용자에 대해 지원하는게 목적이 아닌 특정 사용자가 확실히 정해져있어서 별로 고려할 사항은 아닙니다..
  5. 실시간 분석 및 로깅
    • 데이터 모니터링 및 분석 기능을 제공한다고 합니다.

자 일단 저는 통신용 서버를 Next.js로 구현하려고 합니다.

그런데…! 만들고 Vercel에 업로드해서 배포를 날로 먹어보려고 했는데..

Do Vercel Serverless Functions support WebSocket connections? 서버리스 함수는 지원하지 않는다는군요…. EC2에 올려야겠습니다.

서버 구현하기

일단 간단하게 기존 프로젝트와 연결이 가능한지부터 확인을 해보고자 Socket.IO의 Documentation을 참고해봤습니다..

아 그리고 해당 기능은 Next의 Configuring: Custom Server | Next.js (nextjs.org) 커스텀 서버 기능을 이용합니다. 사실 저도 이 기능은 맨땅에다 머리 박아가며 구현하고 있습니다.

우선! 이번 포스팅에선 커스텀 서버 말고! 어떻게 채팅 서버를 구현했는지를 정리해보려고 합니다!!

  1. 필요한 라이브러리 설치
  • 서버 구현을 위한 socket.io, 관리자가 접속할 페이지도 함께 구현하기 위해 socket.io-client도 설치했습니다.
pnpm i axios socket.io socket.io-client
  1. server.js 파일 생성
  • 커스텀 서버 생성 및 웹소켓 서버 초기화
import { createServer } from "http"; import next from "next"; import { Server } from "socket.io"; const dev = process.env.NODE_ENV !== "production"; const hostname = "localhost"; const port = 4000; const app = next({ dev, hostname, port }); const handler = app.getRequestHandler(); app.prepare().then(() => { const httpServer = createServer(handler); const io = new Server(httpServer, { cors: { origin: "http://localhost:3000", methods: ["GET", "POST"], }, }); });

채팅 서버의 기능을 수행할 커스텀 서버를 생성해야됩니다..

dev 변수를 통해 현재 환경이 개발 모드인지 판단합니다. 로컬에서 간단히 돌려볼 거니까 4000 포트에서 실행해봅시다.

우선 커스텀 서버를 만들기 위해 http 모듈의 createServer에 Next.js의 요청 핸들러를 인자로 전달해 httpServer라는 HTTP 서버 객체를 생성합니다..

이후에 socket.io의 모듈로 웹소켓 서버를 초기화합니다. 저는 이 때 로컬 테스트를 위해 cors 설정을 3000포트로 지정했습니다..

  • 동작할 이벤트 등록
// 위는 생략 app.prepare().then(() => { // 위는 생략 io.on("connection", (socket) => { console.log("사용자가 입장했습니다."); socket.on("disconnect", () => { console.log("유저가 퇴장했습니다."); }); }); httpServer .once("error", (err) => { console.error(err); process.exit(1); }) .listen(port, () => { console.log(`> Ready on http://${hostname}:${port}`); }); });

위에서 말했듯 웹소켓은 이벤트 기반 통신 방식으로 우리가 방금 생성한 io 객체에 on 함수를 통해 발생하는 이벤트에 콜백함수를 등록해 지정된 코드를 실행시킬 수 있습니다.

여기서 ‘connection’ 이벤트는 클라이언트로부터의 연결 시 발생하는 이벤트로 socket 인스턴스를 받을 수 있습니다.. 반대로 ‘disconnect’ 이벤트는 클라이언트로부터의 연결 해제 시 발생하는 이벤트입니다.

socket 인스턴스는 클라이언트와 상호 작용하기 위한 기본 클래스로 후에 사용할 emit, on, once, removeListener 와 같은 Node의 EventEmitter의 모든 메서드를 상속합니다.. 공식문서에 적혀있습니다.

이제 클라이언트의 연결, 연결 해제 이벤트를 등록했으니 4000 포트로 접속하면 서버 콘솔에 사용자가 입장했다, 퇴장했다. 표시가 나오겠죠??

  • 서버 설정 및 시작
// 위는 생략 app.prepare().then(() => { // 위는 생략 httpServer .once("error", (err) => { console.error(err); process.exit(1); }) .listen(port, () => { console.log(`> Ready on http://${hostname}:${port}`); }); });

자 이제 설정 다 했으니까 우리가 만든 서버 객체에 에러 처리와 서버를 시작하도록 설정하고 마무리해봅시다..

아니 .on이랑 .once는 무슨 차이일까요? 네 on은 발생하는 이벤트마다 콜백 함수를 실행시키고 once는 한 번만 실행시키는 함수입니다.

즉 서버에 에러 이벤트가 발생하면 해당 콜백 함수를 한 번 실행시키는데 콘솔에 에러 메세지를 출력 시키고 process.exit(1)으로 프로세스를 종료 시킵니다.

이후에 .listen을 통해 서버를 지정된 포트인 4000 포트에서 작동하도록 설정합니다. 뒤에 콜백함수를 통해 콘솔에 우리 hostname이랑 포트를 출력합니다.

클라이언트 설정

  • 클라이언트 초기화

자 서버 설정은 간단하게 잘 끝났으니까 클라이언트쪽에서 연결이 잘 되는지 확인은 해봐야겠죠?? 기존 버거풋 프로젝트에 클라이언트 소켓 인스턴스를 초기화합시다.

import { io } from "socket.io-client"; const URL = "http://localhost:4000"; export const socket = io(URL);

이제 해당 소켓 인스턴스를 사용해 서버와 연결해봅시다.

  • 서버랑 연결하기
import { socket } from "@/socket"; import { ChangeEvent, FormEvent, useEffect, useState } from "react"; export default function Socket() { const [isConnected, setIsConnected] = useState(socket.connected); const [transport, setTransport] = useState("N/A"); useEffect(() => { const onConnect = () => { setIsConnected(true); setTransport(socket.io.engine.transport.name); socket.io.engine.on("upgrade", (transport) => { setTransport(transport.name); }); }; const onDisconnect = () => { setIsConnected(false); setTransport("N/A"); }; socket.on("connect", onConnect); socket.on("disconnect", onDisconnect); if (socket.connected) onConnect(); return () => { socket.off("connect", onConnect); socket.off("disconnect", onDisconnect); }; }, []); return ( <section> <article> <p>Status: {isConnected ? "connected" : "disconnected"}</p> <p>Transport: {transport}</p> </article> </section> ) }

우리가 생성한 socket.io 클라이언트를 포함하는 모듈을 가져와 사용합니다.

useEffect을 사용해 컴포넌트가 마운트 될 때 socket에 connect 이벤트가 발생하면 onConnect 함수를 실행합니다. 위에선 connection으로 시작했었죠?? 클라이언트에선 connect, disconnect 이벤트가 발생합니다.

onConnect 함수는 isConnected와 setIsConnected를 업데이트합니다. 이 때 transport는 N/A 값에서 업데이트되는데 이 때 처음엔 HTTP 롱 폴링에서 시작되어 WebSocket으로 업그레이드 되어 변경됩니다. socket.io.engine.on(event, callback) 함수는 upgrade 이벤트를 사용하는데 HTTP 롱 폴링에서 WebSocket으로 업그레이드 될 때 발생하는 이벤트로 업그레이드가 성공해 전송 메커니즘이 변경 되어 콜백함수에 전달됩니다. 이를 이용해 transport를 업데이트 합니다.

이후 컴포넌트가 언마운트될 때 소켓 이벤트 리스너를 정리해 메모리 누수를 방지합니다.

서버와 클라이언트 연결 확인해보기

서버랑 버거풋 둘 다 로컬에서 실행시키고 들어가보면!!!

entrance.png

야호 서버 콘솔에 잘 연결 되었음을 확인할 수 있습니다.

메세지 보내보기

이제 클라이언트 코드에 form을 하나 생성해 사용자가 input 요소로 입력한 값을 onSubmit 이벤트가 호출될 때 서버에 emit을 사용해 데이터를 전송하면 됩니다.

export function InputChatMessage() { const [message, setMessage] = useState(""); const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setMessage(e.target.value); }; const handleSubmit = (e: FormEvent) => { if(message.length === 0) return; e.preventDefault(); socket.emit("chat", message); } return ( <form> <input type="text" value={message} autoFocus placeholder="메세지를 입력해주세요." onChange={handleChange} /> <button>보내기</button> </form> ); }

정말 간단하지 않나요??

화면에 메세지 표시하기

메세지 보냈는데 받은 메세지는 화면에 어떻게 표시할까요?

저는 상태를 하나 선언하고 화면에 표시하도록 구현했습니다.

export function useSocket() { const userName = useUserName(); const setUserName = useSetUserName(); const setUserId = useSetUserId(); const [logs, setLogs] = useState([]); useEffect(() => { const onConnect = () => { socket.emit("join", userName); setUserId(socket.id); }; const onDisconnect = () => { setUserName(""); }; const handleSetInfoLogs = (data) => { setLogs((prev) => [...prev, { type: data.type, message: data.message }]); }; socket.on("connect", onConnect); socket.on("disconnect", onDisconnect); socket.on("join", (data) => handleSetInfoLogs(data)); socket.on("leave", (data) => handleSetInfoLogs(data)); socket.on("chat", (chatData) => { setLogs((prev) => [...prev, { type: "chat", ...chatData }]); }); if (socket.connected) onConnect(); return () => { socket.off("connect", onConnect); socket.off("disconnect", onDisconnect); socket.off("join"); socket.off("leave"); socket.off("chat"); }; }, []); return logs; }

갑자기 확 변하긴 했는데… 하여튼 보시면 chat이라는 이벤트가 발생하면 전 chatData를 logs 배열에 추가하고 있습니다.

여기서 입장과 퇴장 이벤트에 따라 메세지의 타입을 바꿔주고 있습니다.

이제 이 logs를 어떻게 사용하고 있을까요??

export default function ChatWindow() { const logs = useSocket(); return ( <section className={styles.section}> {logs.length === 0 && ( <p className={styles.desc}>채팅에 참여했습니다! 잠시만 기다려주세요.</p> )} <ChatLogs logs={logs} /> <InputChatMessage disabled={logs.length === 0} /> </section> ); } export default function ChatLogs({ logs }) { const scrollRef = useRef(); const scrollToBottom = () => { const scrollHeight = scrollRef?.current.scrollHeight; scrollRef.current?.scrollTo({ top: scrollHeight, behavior: "smooth" }); }; useEffect(() => { scrollToBottom(); }, [logs]); return ( <ul className={styles.chats} ref={scrollRef}> {logs?.map((log, idx) => ( <li className={styles.message} key={idx}> {log.type === "info" ? ( <InfoMessage message={log.message} /> ) : ( <ChatMessage data={log} /> )} </li> ))} </ul> ); }

이렇게 사용 중입니다…

  1. 사용자가 처음 채팅방에 입장하면 메세지 logs가 비어있기 때문에 사용자에게 잠시 기다려달라는 안내 메세지를 출력하게 됩니다.
  2. 이후 메세지 로그가 생성되면 ChatLogs 컴포넌트로 보내 화면에 렌더링하게 됩니다. 여기서 커스텀 훅에서 설정한 type을 사용해 이게 채팅 메세지인지 알림 메세지인지 판단해 화면에 표시합니다.
  3. 중간에 scrollToBottom 함수를 사용해 채팅 메세지가 화면 표시 영역에서 벗어날 경우 아래로 내려줍니다.

입장 알림 구현하기

이제 사용자가 채팅방에 입장했을 때 어떻게 관리자에게 알릴 수 있을까요?

처음 시도한 방법은 카카오톡 메세지 REST API입니다.

  • 사용자가 채팅방에 입장해 호출 버튼을 클릭합니다.
  • 이 때 제 카카오 계정을 통해 생성된 토큰을 사용해 Yellta씨에게 메세지를 보내고, 나에게 보내기 기능을 통해 제 자신에게 메세지를 보내게 됩니다.

여기서 문제가 발생하는데 나에게 보내기 기능은 메모 목적으로 개발된 기능으로 이후에도 알림에 대한 부분은 추가되지 않을 것이라고 답변해주셨기 때문에 포기했습니다.

그렇다고 이 문제를 해결할 방법이 없었는가? 하면 또 방법은 있었습니다.

  1. 카카오 계정을 하나 더 만들어서 해당 계정에 제 계정과 Yellta 씨의 계정을 추가해 메세지를 보낸다.
  2. 버거풋에 카카오 로그인 기능을 만들어 사용자의 토큰을 사용해 관리자들에게 메세지를 보낸다.
  3. 알림톡 기능을 사용해본다. (이건 돈이 듭니다…!!!!)

네 방법은 있지만 전혀 효율적이지 못했습니다.

그래서 최종적으로 미리 구축한 채팅 서버에서 node-mailer를 통해 메일을 전송하고 있습니다.

사용자가 입장할 때 api를 통해 사용자의 닉네임을 채팅 서버로 전송하고 서버에선 사용자의 닉네임을 관리자에게 전송합니다.

export async function notifyUserEntry(userName) { return await fetch(`${import.meta.env.VITE_CHAT_SERVER_URL}/api/entrance`, { method: "POST", body: JSON.stringify({ userName }), headers: { "Content-Type": "application/json", }, }); } const onSubmit = async (data) => { setUserName(data.userName); const res = await notifyUserEntry(data.userName); if (res.status === 200) { socket.connect(); } };

이렇게 말이죠.. 입력 폼에서 사용자 이름을 작성해 제출하면 제출 이벤트에서 입장 알림을 위한 API 통신 후 성공했을 때 소켓을 연결합니다.

결국 버리게 된 코드

class Kakao { constructor() { this.appKey = // 이곳에 로그인시의 앱 키가 필요합니다. // 저장된 json 파일 읽어오기 const tokenData = fs.readFileSync('kakao_token.json', 'utf8'); this.tokens = JSON.parse(tokenData); this.refreshToken(); } // 카카오 토큰 갱신하기 refreshToken() { const url = 'https://kauth.kakao.com/oauth/token'; const data = new URLSearchParams({ grant_type: 'refresh_token', client_id: this.appKey, refresh_token: this.tokens.refresh_token }); axios.post(url, data) .then(response => { const result = response.data; // 갱신된 토큰 내용 확인 및 업데이트 if (result.access_token) { this.tokens.access_token = result.access_token; } if (result.refresh_token) { this.tokens.refresh_token = result.refresh_token; } // 갱신된 내용으로 파일 업데이트 fs.writeFile('kakao_token.json', JSON.stringify(this.tokens), err => { if (err) { console.error('Error saving tokens:', err); } }); }) .catch(error => { console.error('Error refreshing token:', error); }); } sendToKakao(text) { const url = 'https://kapi.kakao.com/v2/api/talk/memo/default/send'; const headers = { 'Authorization': 'Bearer ' + this.tokens.access_token }; const content = { object_type: 'text', text: text, link: { mobile_web_url: // 버거챗 URL } }; const data = new URLSearchParams({ template_object: JSON.stringify(content) }); axios.post(url, data, { headers: headers }) .then(response => { console.log(response.data); }) .catch(error => { console.error('Error sending message:', error); }); } }

이거 사실 문제가 좀 있었는데요.

편법인데 원래 카카오 메세지 REST API를 사용하려면 앱 키가 필요합니다. 앱 키가는 사용자가 로그인하면 생기구요.

근데 이게 로그인할 때 네트워크를 끊어놓고 로그인하면 주소창에 키가 생성되는데 그걸 사용해서 토큰을 발급 받고 로컬에 저장해두면 위 코드로 만료되면 갱신하는 방법으로 무한으로 즐길 수 있습니다.

근데 이게 서버로 올라가는 과정에서 계속해서 앱 키가 변경되고 이걸 계속해서 env 파일에 업데이트하는 과정이 좀 부담스럽게 느껴지긴 했습니다.

원래 이 기능 자체가 특정 서비스를 이용하다 어떤 요소를 쉽게 공유하도록 도와주는 기능이지 제 프로젝트와는 맞지 않았습니다.

마치며..

네.. 채팅 서버로 api 날리는데 cors 문제 생기지 않았겠습니까?

그리고 vercel 날로 먹기도 실패했는데 ec2에 올렸을테고..

그럼 자동화도 시켰을텐데…

그거 2편에서 정리해보겠습니다..

.

.

.

뿅..

버거풋에 로그인 기능 도입하기..

버거풋에 로그인 기능 도입하기..

middleware로 CORS 해결하기

middleware로 CORS 해결하기