이번에 전역 후 새로운 프로젝트를 진행하며 치지직과 숲의 채팅 데이터를 실시간으로 스크래핑(크롤링)할 필요가 생겨 전용 라이브러리를 찾아보았다. 보통 치지직이나 숲과 같은 라이브 스트리밍 서비스의 채팅 기능은 웹소켓 기반으로 동작하는 것을 확인하였다. 웹소켓 기반으로 데이터를 스크래핑하고 NoSQL DB(MongoDB 채택)에 데이터를 넣고, Message Queueing System(Kafka 채택)에 새로운 채팅을 저장한 신호를 다른 Micro Service에게 전달해야하는 상황이 필요했기 때문에 I/O 이벤트가 많이 발생할 것이라고 생각했고, 웹소켓 개발에 용이하고 I/O 기반 이벤트 처리에 최적화하기 좋은 node기반 프레임워크인 Nestjs를 사용하여 서버를 개발하기로 하였다.
그런데 숲 채팅 스크래퍼를 개발하는 과정에서 문제가 발생하였다. Python3를 기반으로 만들어진 스크래핑 라이브러리는 있었으나, Typescript를 기반으로 만들어진 라이브러리는 없었고, 특히 Python3 기반으로 만들어진 라이브러리도 유지보수가 종료되었는지 숲의 이름이 바뀌기 전인 아프리카TV에서 사용되던 API가 아직 적용된 모습을 확인할 수 있었다. 그래서 이번 기회에 숲의 채팅 프로토콜을 분석하고 Typescript 기반의 라이브러리를 개발하여 npmjs에 배포하기로 하였다.
혹시 관심있는 사람은 아래 명령어를 통해 라이브러리를 다운로드 받아 사용해보길 바란다. 재밌는 서드파티 서비스를 만들 수 있을 것이라 생각한다.
$ npm install soop-extension
1. 숲 채팅의 특징
위 Python3 기반의 라이브러리를 개발하신 cha2hyun님의 블로그를 참고하여 분석을 진행하였고, 잘 정리해주신 덕분에(지식 공유해주심에 감사드립니다😊) 생각보다 시간이 오래 걸리진 않았다(어렵지는 않긴 했는데, 이전에 취약점 분석 관련하여 활동했던 것이 도움이 많이 되었다고 생각한다).
그런데 스펙이 바뀐건지 내가 이해를 잘못한 것인진 모르겠지만, 블로그 내용 중 적용되지 않는 점이 있다는 점이 몇 가지 있었다. 그 내용은 아래와 같다.
Q. 사람이 동영상을 안보고있다고 판단되면 모든 소켓 연결을 끊어버립니다.
A. 이건 이제 끊어지진 않는 것 같습니다. 개발 당시 2개의 웹소켓 중 채팅 관련 웹소켓만 연결하여 개발 진행하였고, 문제 없이 지속적으로 연결되어 동작함을 확인하였습니다.
Q. 쿠키로 함께 보내지는 PdboxTicket 값임을 확인
A. 해당 Ticket의 이름이 변경되었습니다. 자세한 내용은 아래 글을 확인하시면 좋을 것 같습니다.
Q. 2번째 핸드쉐이크를 위해 send하는 패킷을 그대로 전송했을때 연결이 되는지 확인
A. 2번째 핸드쉐이크를 위해 send하는 패킷의 경우 비로그인 사용자의 경우 그냥 비워둬도 문제가 되지 않음을 확인하였습니다.
또한 숲 채팅의 경우 데이터 크기를 최대한 축소하여 최적화시키기 위해 웹 소켓에 사용되는 데이터를 바이너리 형태의 데이터로 송수신되도록 커스텀한 프로토콜을 사용하는 것으로 확인된다. 예를 들어 아래와 같다. 물론 숲이 런칭한지 오랜 시간이 흘렀기 때문에 네트워크 속도가 느리던 옛날 환경에선 아래와 같이 커스텀 프로토콜을 사용하는 것이 유리했을 것이라고 생각한다.
아래 사진을 참고하면 좋을 것 같다. 왼쪽부터 차례대로 숲의 채팅 데이터를 UTF-8로 변환하여 본 모습, 숲의 채팅 데이터를 HEX Viewer로 변환하여 본 모습, 치지직의 채팅 데이터의 모습이다. 치지직의 경우 직관적인 데이터를 송수신하여 분석하기 훨씬 수월함을 알 수 있다.



참고로 치지직의 채팅 스크래핑 라이브러리도 재밌고, 코드도 잘 작성되어 있어서 구경해보면 좋을 것 같다(사실 지금의 Typescript 전용 숲 채팅 스크래핑 라이브러리를 개발할 때 많은 참고가 되었다. 좋은 코드 공유해주심에 감사드립니다😊). 지금은 유지보수가 잠시 중단된 것 같다.
또한 live detail api를 사용할 때 Content-Type을 json은 허용하지 않는다. 무조건 x-www-form-urlencoded으로 body를 만들어 송신해야 올바른 값을 수신할 수 있다는 특징도 있다.
2. 프로토콜의 기본적인 분석
1) 바이너리값
분석에 들어가기 전에 어떤 바이너리 값으로 각 데이터를 구분하는지 미리 정리하고 가면 이해가 쉽다. 크게 보면 2가지가 있고, 작게 보면 3가지가 더 추가되는데, 내용은 아래와 같다.
export enum ChatDelimiter {
STARTER = "\x1b\t", // 모든 패킷이 시작할때 사용하는 바이너리 값
SEPARATOR = "\x0c", // 모든 패킷의 데이터를 나누는 기준
// 핸드쉐이크 과정에서 2번째로 송신하는 패킷에서만 사용
ELEMENT_START = "\x11", // body 중 방송 설정 관련 데이터의 시작
ELEMENT_END = "\x12", // body 중 방송 설정 관련 데이터의 끝
SPACE = "\x06" // body 중 방송 설정 관련 데이터의 구분자
}
2) 패킷의 Header
웹 소켓으로 주고 받는 모든 패킷은 헤더 영역이 있고, 그 구조는 다음과 같다.
패킷의 Header의 경우 아래와 같이 설정한다.
패킷의 타입: 4byte
패킷의 바디 데이터 크기: 6byte
패딩: 1byte
여러 예시들을 보면 바로 파악이 가능하다.
export enum ChatType {
PING = "0000",
CONNECT = "0001",
ENTERCHATROOM = "0002",
EXIT = "0004",
CHAT = "0005",
DISCONNECT = "0007",
ENTER_INFO = "0012",
TEXTDONATION = "0018",
ADBALLOONDONATION = "0087",
SUBSCRIBE = "0093",
NOTIFICATION = "0104",
EMOTICON = "0109",
VIDEODONATION = "0105",
VIEWER = "0127",
// UNKNOWN = "0009",
// UNKNOWN = "0054",
// UNKNOWN = "0088",
// UNKNOWN = "0094",
}
3) 패킷의 Body
웹 소켓으로 주고 받는 모든 패킷은 헤더 영역이 있고, 그 구조는 정해져 있지 않다. 오직, "\x0c"를 기준으로 분리되어있다는 점 정도만 공통이고 각 데이터가 가지는 의미는 패킷의 Header에 존재하는 타입에 따라 바뀌기 때문이다.
4) 패킷의 예시 및 간단한 분석 내용
Header는 쉽게 확인할 수 있고, 그 뒤로 Body에 해당하는 내용이 들어있는 것을 확인할 수 있다. 해당 패킷을 확인하기 위해선 아래 단계를 수행하면 된다.
- 숲 스트리머 방송에 들어간다.
- F12를 클릭하면 개발자도구가 열린다.
- 개발자도구 상단에 Network 탭을 클릭한다.
- 하단에 패킷에 대한 종류를 필터링할 수 있는 칸에서 "WS"라고 적힌 버튼을 클릭한다.
- 3개의 웹소켓 채널이 보일텐데, 거기서 스트리머 아이디로 된 2개의 채널을 확인한다.
- 2개의 채널 중 Name위에 마우스를 올려놓으면 URI가 보일텐데 거기서 "wss://chat-..."으로 적힌 채널을 클릭한다.
- Message 탭을 클릭 한 후 개발자도구 하단쯤에 "Hex Viewer"라고 적힌 옵션을 "UTF-8"로 변경하여 확인한다.



3. 오픈소스에 기여하기
해당 프로젝트는 오픈소스로써 누구나 기여할 수 있게끔 오픈하는 것이 목적이다. 실제로 2개의 issue가 신청되었고, 해당 issue를 해결하는 과정에서 타 개발자분이 감사하게도 기능을 구현하여 새로운 기능을 추가한 좋은 사례가 남아있다.


혹여나 추가 기능을 요구하거나, 해당 프로젝트에 기여하고 싶은 사람들을 위해, Contributor가 되기 위한 방법을 아래 간단하게 기술해놓겠다.
1) 블로그 댓글로 분석한 내용 공유
해당 라이브러리를 사용하면 개발자가 분석하지 못한 패킷에 대해 아래 코드를 사용하여 파악할 수 있다. 출력되는 데이터를 보고 혹여나 구독이나 팬클럽처럼 일정 금액이 지출되어야 확인할 수 있는 패킷에 대해 알려주면 구독자나 팬클럽 가입자가 사용할 수 있는 기능도 추가 확장을 할 수 있을 것이라 생각한다(예를 들어 이모티콘 송신이나 도네이션 발행과 관련된 패킷/기능).
const streamerId = process.env.STREAMER_ID
const client = new SoopClient();
// 아래와 같이 숲 ID, PASSWORD 문자열 입력 가능 (그대로 VCS 업로드 시 공개된 공간에 노출될 수 있음)
const soopChat = client.chat({
streamerId: streamerId,
login: { userId: process.env.USERID, password: process.env.PASSWORD } // sendChat 기능을 사용하고 싶을 경우 세팅
})
// 연결 성공
soopChat.on('connect', response => {
if(response.username) {
console.log(`[${response.receivedTime}] ${response.username} is connected to ${response.streamerId}`)
} else {
console.log(`[${response.receivedTime}] Connected to ${response.streamerId}`)
}
console.log(`[${response.receivedTime}] SYN packet: ${response.syn}`)
})
// 채팅방 입장
soopChat.on('enterChatRoom', response => {
console.log(`[${response.receivedTime}] Enter to ${response.streamerId}'s chat room`)
console.log(`[${response.receivedTime}] SYN/ACK packet: ${response.synAck}`)
})
// 특정하지 못한 패킷
soopChat.on('unknown', packet => {
console.log(packet)
})
// 패킷을 바이너리 형태로 확인
soopChat.on('raw', packet => {
console.log(packet)
})
// 채팅 연결
await soopChat.connect()
만약 직접 분석하기 귀찮으면 블로그 댓글에 "~기능을 구현해주세요!" 와 같이 요청해주시면 좋을 것 같다. 해당 기능을 구현하기 위해 필요한 내용을 요청하는 방식으로 해결할 수 있을 것이다.
2) Pull Request를 통해 기여하는 방법
pull request를 통해 기여 역시 가능하다. 단, 현재 브랜치 전략을 "우아한 형제들"에서 사용하는 git flow 전략을 사용하고 있기 때문에 PR 신청 시 feature 브랜치에서 develop 브랜치로 merge하면 된다. 만약 해당 내용을 이행하기 귀찮다면 그냥 구현한 코드를 issue 신청을 통해 첨부하여 적용해달라고 요청하면 좋을 것 같다.
4. 마무리하며
혹시 위 내용으로 숲 채팅 프로토콜에 대한 이해가 충분하지 않다면 cha2hyun님의 블로그를 추가로 참고하길 바란다. 해당 라이브러리가 필요했던 이유가 되어주었던 프로젝트인 ScoinOne 프로젝트의 개발도 이제 거의 마무리 단계에 접어들었다. soop-extension 라이브러리는 시작에 불과하다. 내가 목표로 하는 "스트리머와 시청자가 함께 만들어가는 문화"라는 목적을 달성하기 위해 최선을 다하고 싶다. 그리고 개발자를 지망하는 라이브 스트리밍 시청자들이 해당 프로젝트를 통해 서로 지식을 공유하고 새로운 문화를 만들기 위해 노력했으면 하는 바램이 있다.
'scoinone' 카테고리의 다른 글
| jetbrains IDE를 사용한 SSH 원격 개발 시 멀티 포트포워딩 (0) | 2025.09.10 |
|---|---|
| PR 던진 후 github action 동작 시 secret_key 에러 (0) | 2025.09.09 |
| Spring Security에 JWT를 어떻게 적용했는가? (0) | 2025.09.09 |
댓글