Meloming
· 49 min read

SOOP, 치지직, 씨미 채팅 수집 커넥터로 12,000개 방송 채팅 수집하기

조현우
조현우

CEO & Fullstack Engineer

1. 연결은 조용히 죽습니다

멜로밍은 여러 라이브 스트리밍 플랫폼에서 채팅을 수집합니다. 이 채팅은 신청곡, 오버레이, 키워드 알림, 랭킹 집계의 입력이 됩니다. 대상 플랫폼은 치지직, SOOP, 씨미 세 곳입니다.

설계 시점에 잡은 규모는 이랬습니다.

플랫폼피크 동시 방송 수
치지직6,293
SOOP4,828
씨미60
합계약 11,400

피크 시 예상 메시지 처리량은 15,000에서 50,000 msg/s입니다. 실제 운영을 보면 워커 32개 파드가 SOOP 2,576개 채널과 치지직 3,674개 채널을 동시에 물고 있었습니다. 씨미는 30개 안팎이었습니다. 파드 수는 KEDA가 채널 450개를 기준으로 3개에서 50개 사이에서 조절합니다.

수집 자체는 어렵지 않습니다. 채널마다 WebSocket을 열고 메시지를 받아 공통 형식으로 바꿔 Redis Streams에 흘려보내면 됩니다. 수천 개 채널로 늘어나도 구조는 같습니다.

진짜 문제는 다른 데 있었습니다. 연결이 끊어졌다는 사실을 모르는 상태입니다.

이 상태는 명시적인 실패보다 나쁩니다. 에러가 나면 재연결하면 됩니다. 그런데 소켓이 살아 있는 것처럼 보이면서 아무 데이터도 오지 않으면 워커는 그 채널을 계속 담당하고 있다고 믿습니다. 코디네이터도 재배정하지 않습니다. 해당 방송의 채팅은 그냥 사라집니다.

신고도 들어오지 않습니다. 서비스는 정상으로 보이고, 그 채널의 신청곡만 조용히 안 됩니다.

전수 조사를 했더니 SOOP 연결의 약 7퍼센트, 2,576개 중 180건 정도가 이 상태였습니다. connections_active 게이지에는 잡히는데 Redis 스트림에는 메시지가 0건인 연결들이었습니다.

이 글은 그 뒤로 커넥터에 무엇을 넣었는지 정리한 기록입니다.


2. 커넥터 경계는 다섯 개의 메서드입니다

플랫폼마다 채널을 찾는 방법, 채팅 서버에 접속하는 절차, 메시지 형식이 다릅니다. 후원과 구독을 표현하는 방식도 제각각입니다.

이 차이가 제품 코드까지 올라오면 신청곡, 오버레이, 통계에 전부 플랫폼 분기가 생깁니다. 그래서 커넥터 인터페이스를 아주 좁게 정의했습니다.

Go
// PlatformConnector defines the interface for connecting to a live streaming
// platform's chat and receiving messages.
type PlatformConnector interface {
	// Connect establishes a connection to the platform's chat for the given channel.
	Connect(ctx context.Context, channel model.LiveChannel) error

	// Disconnect gracefully closes the connection.
	Disconnect() error

	// IsAlive returns true if the connection is still active.
	IsAlive() bool

	// Messages returns a read-only channel that delivers parsed chat messages.
	Messages() <-chan model.ChatMessage

	// Errors returns a read-only channel that delivers connection errors.
	// A value on this channel typically means the connection has been lost.
	Errors() <-chan error
}

플랫폼별 구현이 아무리 복잡해도 바깥에서 보이는 것은 이 다섯 개뿐입니다. 그 뒤의 서비스는 메시지가 어느 플랫폼에서 어떤 패킷으로 들어왔는지 몰라도 됩니다.

팩토리는 플랫폼 enum 하나로 구현체를 고릅니다. 배포 환경에 따라 달라지는 값은 프로세스가 뜰 때 한 번 전역 설정에 담아두고 그 값이 필요한 커넥터에만 넘깁니다. 아래 코드에서 치지직만 인자를 하나 받는 것도 그래서입니다.

Go
func NewConnector(platform model.Platform) (PlatformConnector, error) {
	switch platform {
	case model.PlatformChzzk:
		return NewChzzkConnector(globalConfig.HTTPEgressProxyURL), nil
	case model.PlatformSoop:
		return NewSoopConnector(), nil
	case model.PlatformCime:
		return NewCimeConnector(), nil
	default:
		return nil, fmt.Errorf("unknown platform: %s", platform)
	}
}

세 커넥터 모두 단일 사용입니다. 재연결은 기존 인스턴스를 다시 쓰는 것이 아니라 새 인스턴스를 만드는 것으로 처리합니다. 상태를 재사용하지 않으면 half-open 소켓이 남긴 잔재를 물려받을 일도 없습니다.

공통 상수도 셋이 같습니다. 메시지 버퍼는 256, 에러 버퍼는 8, 읽기 상한은 1 << 20으로 1 MiB입니다. 읽기 상한은 큰 후원 메시지 때문에 올려둔 값입니다.


3. 치지직: 접속 전에 조회가 두 번 필요합니다

치지직은 WebSocket을 열기 전에 플랫폼 API를 두 번 호출해야 합니다. 채널 ID만으로는 채팅방에 들어갈 수 없습니다. 먼저 방송 정보를 조회해서 이번 방송에만 유효한 채팅방 식별자를 받고 그 식별자로 읽기 전용 토큰을 발급받습니다. 그다음에야 채팅 서버에 접속해 연결 명령을 보내고 읽기 루프와 ping 루프를 띄웁니다.

채팅방 식별자가 채널 ID와 다른 값이라는 점이 중요합니다. 방송이 시작될 때마다 새로 발급되기 때문에 캐시해 두고 재사용할 수 없습니다. 두 번의 HTTP 호출이 재연결할 때마다 그대로 반복된다는 얘기입니다. 나중에 출구 경로를 분리하게 되는 지점이 바로 여기입니다.

생존 판정과 관련된 우리 쪽 상수는 이렇습니다.

Go
const (
	chzzkPingInterval = 20 * time.Second
	chzzkPingTimeout  = 5 * time.Second
	chzzkMsgBufSize   = 256
	chzzkErrBufSize   = 8
)

우리는 채팅을 보내지 않고 받기만 하는 익명 읽기 전용 참여자로 접속합니다. 보낼 것이 없으니 남는 질문은 하나입니다. 접속 성공을 언제 선언하느냐입니다.

치지직 Connect는 접속 성공 응답을 기다리지 않고 반환합니다. 연결 명령 쓰기가 성공하면 성공으로 봅니다. 뒤에 나올 SOOP과 다른 지점인데 의도한 차이입니다. 치지직은 잘못된 토큰으로 접속하면 읽기 루프에서 곧바로 에러가 나기 때문에 별도의 ACK 대기가 필요 없었습니다.

봉투와 내용물이 분리된 JSON

치지직은 SOOP과 정반대로 전부 JSON 텍스트입니다. 프레임은 명령 코드와 본문 두 칸짜리 봉투입니다. 본문의 스키마는 명령 코드에 따라 달라집니다.

Go
type chzzkWSMessage struct {
	Cmd int             `json:"cmd"`
	Bdy json.RawMessage `json:"bdy"`
}

Bdyjson.RawMessage로 받는 것이 요점입니다. 봉투를 열 때는 본문을 해석하지 않고 바이트로 들고 있다가, 명령 코드를 보고 나서 알맞은 구조체로 다시 풉니다. 명령 코드마다 본문 모양이 다른 프로토콜에서 한 번에 전부 파싱하려 들면 어느 한쪽이 반드시 깨집니다.

명령 코드는 다섯 개만 씁니다.

cmd의미
100접속
10000앱 레벨 킵얼라이브
10100접속 응답
93101채팅
93102후원 채팅

채팅 본문은 이렇게 생겼습니다.

Go
type chzzkChatBody struct {
	UID         string          `json:"uid"`
	Msg         string          `json:"msg"`
	MsgTypeCode int             `json:"msgTypeCode"`
	Profile     json.RawMessage `json:"profile"`
	Extras      json.RawMessage `json:"extras"`
}

여기서도 ProfileExtras가 다시 json.RawMessage입니다. 봉투 안에 또 봉투가 있는 구조인데, 이게 다음 문제로 이어집니다.

부가 정보는 Extras에 들어 있습니다. 후원 금액과 익명 여부, 이모티콘 치환 표가 여기 담깁니다.

Go
type chzzkExtras struct {
	PayAmount   int               `json:"payAmount"`
	IsAnonymous bool              `json:"isAnonymous"`
	Emojis      map[string]string `json:"emojis"`
}

파싱에서 실제로 발목을 잡은 것은 두 겹으로 인코딩된 JSON이었습니다. 같은 필드가 어떤 프레임에서는 객체로 오고 어떤 프레임에서는 객체를 직렬화한 문자열로 옵니다.

Go
// Some frames deliver this field as a plain object, others as a
// double-encoded JSON string. Accept both: the first unmarshal extracts
// the inner JSON string, the second parses it.
var profile chzzkProfile
if len(raw) > 0 {
	var inner string
	if json.Unmarshal(raw, &inner) == nil && inner != "" {
		_ = json.Unmarshal([]byte(inner), &profile)
	} else {
		_ = json.Unmarshal(raw, &profile)
	}
}

두 형태를 모두 받아들이도록 짠 이유는 실제로 두 형태가 다 관측됐기 때문입니다. 한쪽만 가정하면 어느 날 조용히 닉네임이 전부 물음표가 됩니다.

플랫폼 공지도 일반 채팅과 같은 프레임 종류로 섞여 옵니다. 발신자가 시스템으로 표시되는 프레임입니다. 이걸 일반 채팅으로 흘려보내면 키워드 알림과 랭킹 집계가 오염되므로 MessageTypeSystem으로 표시해서 내려보냅니다. 반대로 발신자가 익명이고 후원도 아니고 본문도 빈 프레임은 아예 버립니다. 채팅이 아니라 접속 이벤트인데, 오버레이에 물음표만 렌더링되던 원인이 이것이었습니다.

후원은 별도 타입으로 구분됩니다. 치지직 후원은 금액이 그대로 원화 환산치라 AmountAmountKRW가 같고 통화 코드는 CHZZK_CHEESE로 붙입니다. 뒤에 나올 SOOP 별풍선이 개수에 100을 곱해야 원화가 되는 것과 대비되는 지점입니다. 익명 후원이면 닉네임을 “익명”으로 바꿉니다.


4. SOOP: 소켓이 아니라 입장 완료가 성공입니다

SOOP은 셋 중 유일하게 바이너리 프로토콜을 씁니다. 접속한 뒤에 별도의 입장 절차를 거쳐야 하는 것도 SOOP뿐입니다. 채널 정보를 조회해 채팅 서버 주소와 입장에 필요한 값을 받고, 그 주소로 접속한 다음 로그인 명령을 보내 응답을 확인하고, 그다음 채널 입장 명령을 보냅니다.

생존 판정과 관련된 우리 쪽 상수입니다.

Go
const (
	soopPingInterval = 60 * time.Second
	// soopReadTimeout is the per-read deadline. If no data (chat frame, app keepalive,
	// WS ping/pong) arrives within this window, readLoop returns a timeout error so
	// manager can reap the zombie half-open connection. Chosen as 3x ping interval to
	// tolerate scheduler jitter and occasional missed beats without false positives.
	soopReadTimeout    = 3 * soopPingInterval
	soopWSPingWriteTTL = 5 * time.Second
	soopMsgBufSize     = 256
	soopErrBufSize     = 8
)

soopReadTimeout은 상수 곱셈으로 정의돼 있어 실제 값이 180초입니다. ping 간격의 3배입니다.

순서상 중요한 것은 읽기 루프를 로그인 명령보다 먼저 띄운다는 점입니다. 로그인 응답을 받을 수 있는 상태가 아니면 그다음 단계로 넘어갈 수 없기 때문입니다.

14바이트 헤더와 폼피드 구분자

SOOP 패킷은 고정 길이 헤더 14바이트에 가변 길이 본문이 붙는 형태입니다. 헤더는 이렇게 나뉩니다.

TEXT
+--------+--------+------------+----------------+--------+
| 0x1B   | 0x09   | cmd (4)    | bodyLen (6)    | ret(2) |
| ESC    | TAB    | "0005"     | "000042"       | "00"   |
+--------+--------+------------+----------------+--------+
0        1        2            6                12      14

특이한 것은 길이 필드가 바이너리 정수가 아니라 ASCII 십진 문자열이라는 점입니다. 본문 길이 42는 0x2A가 아니라 "000042" 여섯 글자로 들어옵니다. 그래서 파싱이 비트 연산이 아니라 strconv.Atoi가 됩니다. 자리수가 고정이라 본문은 최대 999,999바이트까지만 표현됩니다.

보내는 쪽은 그대로 문자열을 조립합니다.

Go
func buildSoopPacket(cmd int, body string) []byte {
	b := []byte(body)
	header := fmt.Sprintf("\x1b\t%04d%06d00", cmd, len(b))
	return append([]byte(header), b...)
}

마지막 00이 보낼 때는 그냥 자리 채움인데 받을 때는 결과 코드가 됩니다. 로그인 응답에서 이 두 자리가 00이 아니면 거부된 것입니다.

본문의 필드 구분자는 폼피드(\f, 0x0C)입니다. 콤마나 탭이 아니라 폼피드를 쓰는 덕분에 채팅 본문에 구분자가 섞여 들어갈 일이 사실상 없습니다. 본문 양끝에도 폼피드가 붙어 오므로 앞뒤를 떼고 나눕니다.

Go
body = strings.TrimPrefix(body, soopFieldSep)
body = strings.TrimSuffix(body, soopFieldSep)
fields = strings.Split(body, soopFieldSep)

프레임 하나에 패킷 여러 개

WebSocket 프레임 하나 안에 논리 패킷이 여러 개 이어붙어 옵니다. 그래서 파서는 프레임을 한 번 읽고 끝내는 것이 아니라, 위치를 옮겨가며 헤더를 계속 찾아야 합니다.

Go
func parseSoopPackets(raw []byte) []soopPacket {
	var packets []soopPacket
	for pos := 0; pos+soopHeaderLen <= len(raw); {
		if raw[pos] != 0x1B || raw[pos+1] != 0x09 {
			pos++
			continue
		}

		cmd, err1 := strconv.Atoi(string(raw[pos+2 : pos+6]))
		bodyLen, err2 := strconv.Atoi(string(raw[pos+6 : pos+12]))
		ret, err3 := strconv.Atoi(string(raw[pos+12 : pos+14]))
		if err1 != nil || err2 != nil || err3 != nil || pos+soopHeaderLen+bodyLen > len(raw) {
			pos++
			continue
		}
		// ... 패킷 적재 ...
		pos += soopHeaderLen + bodyLen
	}
	return packets
}

이 루프에서 중요한 것은 실패했을 때 pos++로 한 바이트만 전진한다는 점입니다. 헤더 시그니처가 안 맞거나, 길이 필드가 숫자가 아니거나, 선언된 본문 길이가 버퍼를 넘으면 그 자리에서 포기하고 다음 바이트부터 다시 시그니처를 찾습니다.

프레임이 잘려서 왔다고 그 프레임 전체를 버리면 그 안에 온전히 들어 있던 다른 패킷까지 같이 잃습니다. 한 바이트씩 밀면서 재동기화하면 손상 구간만 건너뛰고 나머지는 살립니다. 길이 필드를 신뢰해서 건너뛰는 방식은 손상된 길이 값이 파서를 엉뚱한 곳으로 보내 그 뒤 전부를 잃게 만듭니다.

명령 코드는 곧 이벤트 종류입니다

헤더의 4자리 cmd가 이벤트 종류를 결정합니다.

cmd의미
0킵얼라이브
1로그인
2채널 입장
5채팅
10공지
18별풍선
37초콜릿
41슈퍼챗
91, 93팔로우 아이템
104방송인 공지
108구독
121미션 선물

여기에 이 프로토콜의 가장 불편한 성질이 있습니다. 본문 필드에 이름이 없습니다. 폼피드로 나눈 배열의 몇 번째 칸에 무엇이 있는지는 명령 코드마다 다릅니다. 그 규칙은 어디에도 문서화되어 있지 않습니다. 별풍선 개수는 3번 칸, 슈퍼챗 금액도 3번 칸이지만 초콜릿은 앞 4칸을 이어 붙여야 사람이 읽을 수 있는 문장이 됩니다.

그래서 필드 접근을 전부 범위 검사가 들어간 헬퍼로 감쌌습니다. 플랫폼이 칸 하나를 중간에 끼워 넣으면 인덱스가 통째로 밀립니다. 이때 패닉으로 워커가 죽는 것과 빈 문자열이 나오는 것은 완전히 다른 사고입니다. 이름 없는 위치 기반 프로토콜을 다룰 때는 파서가 틀리는 것을 전제로 짜야 합니다.

여기서 가장 중요한 것은 Connect입장 완료를 기다린다는 점입니다. 이 대기가 없던 시절에는 TCP는 붙었지만 입장을 못 한 소켓이 약 2,900건 쌓였습니다. 연결은 살아 있고 지표에도 잡히는데 채팅방에 들어가지 않았으니 메시지가 0건인 상태입니다. 그래서 지금은 로그인 응답의 결과 코드를 확인하고 입장 명령을 보낸 뒤에야 joinedCh로 신호를 줍니다. 10초 안에 신호가 없으면 핸드셰이크 타임아웃으로 실패 처리합니다.

Go
select {
case <-joinedCh:
	return nil
case err := <-c.errCh:
	c.closeConn()
	return fmt.Errorf("soop handshake failed for %s: %w", channel.ChannelID, err)
case <-time.After(10 * time.Second):
	c.closeConn()
	return fmt.Errorf("soop handshake timeout for %s", channel.ChannelID)
case <-ctx.Done():
	c.closeConn()
	return ctx.Err()
}

연결 성공의 정의를 소켓이 아니라 프로토콜 상태로 옮긴 것이 이 코드의 핵심입니다. 소켓이 열렸다는 사실은 우리가 원하는 것을 증명하지 않습니다.

SOOP은 후원 계열 이벤트가 유독 많습니다. 별풍선, 초콜릿, 슈퍼챗, 미션 선물이 모두 후원으로 매핑됩니다.

미션 선물은 특이합니다. 폼피드로 나눈 칸 하나에 JSON이 통째로 실려 옵니다. 위치 기반 프로토콜 안에 구조화된 페이로드가 한 칸을 차지하고 들어앉은 셈입니다. 그래서 이 명령만 별도 파서를 둡니다. type 값이 GIFT면 대결미션, CHALLENGE_GIFT면 도전미션으로 구분해 “대결미션 별풍선 33개: 제목” 형태의 본문을 만듭니다. 둘 다 아니면 그냥 “미션”으로 둡니다. 제목이 비어 있는 경우도 있어서 그때는 개수만 씁니다.

금액 환산도 명령마다 다릅니다. 별풍선은 1개가 100원이라 AmountKRW는 개수에 100을 곱합니다. 앞서 본 치지직 후원이 금액 그대로 원화인 것과 대비되는 지점입니다. 같은 “후원”이라는 도메인 개념도 플랫폼마다 단위가 다릅니다. 환산은 반드시 커넥터 경계 안에서 끝내야 합니다.

한 가지 더, SOOP 커넥터만 gorilla/websocket을 씁니다. 나머지 둘은 nhooyr.io/websocket입니다. 처음에는 셋 다 nhooyr였는데 SOOP 채팅 서버와는 TLS 핸드셰이크가 끝까지 진행되지 않았습니다. nhooyr는 핸드셰이크를 net/http에 위임하는데 그 경로가 상대 구현과 맞지 않았습니다. gorilla는 다이얼과 TLS 핸드셰이크를 직접 수행해서 문제없이 붙었습니다. 라이브러리를 통일하지 못한 것은 아쉽습니다. 그래도 붙지 않는 라이브러리를 통일성 때문에 붙잡고 있을 이유는 없습니다.


5. 씨미: 인증이 핸드셰이크 안에서 끝납니다

씨미는 접속 절차가 가장 단순합니다. 채팅 토큰을 발급받아 접속 단계에서 함께 넘기면 끝입니다. 인증이 핸드셰이크 안에 들어 있으므로 로그인도 입장도 없습니다. 다이얼이 성공하면 그 순간부터 메시지가 들어옵니다.

생존 판정과 관련된 우리 쪽 상수입니다.

Go
const (
	cimePingInterval = 30 * time.Second
	cimePingTimeout  = 5 * time.Second
	cimeReadTimeout  = 5 * time.Minute
	cimeMsgBufSize   = 256
	cimeErrBufSize   = 8
)

채팅 프로토콜 프레임은 전부 JSON 텍스트이고 타입 필드로 분기합니다. 채팅, 후원을 포함한 이벤트, 에러 세 가지를 각각 처리하고 그 외 타입도 버리지 않고 시스템 메시지로 올려보냅니다. 알 수 없는 타입을 조용히 버리면 나중에 플랫폼이 새 이벤트를 추가했을 때 아무도 모르기 때문입니다.

반대로 JSON으로 파싱되지 않는 프레임은 채팅 프로토콜이 아니라 전송 계층의 제어용이므로 조용히 버립니다.

Go
func (c *CimeConnector) handleRawMessage(data []byte) {
	var msg cimeWSMessage
	if err := json.Unmarshal(data, &msg); err != nil {
		// Non-JSON frame (transport-level control). Discard silently.
		return
	}
	...
}

관리형 채팅 서비스 위에 얹힌 스키마

씨미는 자체 프로토콜을 만들지 않고 AWS IVS Chat 위에 얹혀 있습니다. 그래서 봉투는 IVS가 정한 모양입니다. 씨미 고유의 정보는 전부 Attributes라는 자유 형식 칸에 들어갑니다.

Go
type cimeWSMessage struct {
	Type         string                     `json:"Type"`
	Content      string                     `json:"Content"`
	EventName    string                     `json:"EventName"`
	ErrorMessage string                     `json:"ErrorMessage"`
	Sender       *cimeSender                `json:"Sender"`
	Attributes   map[string]json.RawMessage `json:"Attributes"`
}

Attributes의 값 타입이 json.RawMessage인 이유는 칸마다 들어오는 모양이 다르기 때문입니다. 관리형 서비스는 문자열 맵까지만 보장하고 그 안의 구조는 서비스를 쓰는 쪽이 정합니다. 결과적으로 스키마 검증이 프로토콜 계층에서 애플리케이션 계층으로 내려옵니다.

닉네임을 꺼내는 경로가 특히 특이합니다. 발신자 구조체가 주는 것은 숫자 문자열 ID와 또 하나의 문자열 맵뿐입니다.

Go
type cimeSender struct {
	UserID     string            `json:"UserId"`
	Attributes map[string]string `json:"Attributes"`
}

표시용 닉네임은 이 속성 맵 안에 JSON 문자열로 한 번 더 중첩돼 있습니다. 그 문자열을 다시 파싱하면 이런 구조가 나옵니다.

Go
type cimeUserProfile struct {
	ID json.Number `json:"id"`
	Ch struct {
		ID json.Number `json:"id"`
		Na string      `json:"na"`
	} `json:"ch"`
	Bg struct {
		Na string `json:"na"`
	} `json:"bg"`
}

키 이름이 ch, na, bg처럼 두 글자로 줄어 있습니다. 채팅은 초당 수만 건이 오가므로 필드명 한 글자가 곧 대역폭입니다. 다만 읽는 쪽에서는 ch.na가 채널 이름이라는 것을 코드만 보고 알 수 없습니다.

IDjson.Number로 받은 것도 이유가 있습니다. 같은 필드가 어떤 프레임에서는 12345로, 다른 프레임에서는 "12345"로 옵니다. int64로 선언하면 문자열이 온 순간 언마셜이 통째로 실패해서 그 메시지의 다른 필드까지 전부 잃습니다.

이 경로를 뚫기 전까지 씨미 시청자 닉네임이 전부 숫자로 표시됐습니다.

후원 이벤트도 값이 원시 객체와 이중 인코딩 문자열 두 형태로 옵니다. 페이로드는 이렇게 생겼고 여기서도 키가 줄어 있습니다.

Go
type cimeDonationExtra struct {
	Msg  string `json:"msg"`
	Amt  int64  `json:"amt"`
	Anon bool   `json:"anon"`
	Prof struct {
		ID   int64  `json:"id"`
		Name string `json:"name"`
	} `json:"prof"`
}

익명이거나 이름이 비어 있으면 “익명”으로 채웁니다. 통화 코드는 CIME_BEAM입니다.

세 플랫폼을 나란히 놓으면 같은 문제를 각자 다르게 푼 것이 보입니다.

인코딩이벤트 구분부가 정보 위치
치지직JSON명령 코드 5종extras 중첩
SOOP바이너리명령 코드 12종위치 기반 필드 배열
씨미JSON (관리형 서비스)타입 문자열Attributes 자유 칸

공통점은 셋 다 한 겹 더 감싸져 있다는 것입니다. 치지직은 RawMessage 안의 이중 인코딩, SOOP은 필드 칸 안의 JSON, 씨미는 속성 맵 안의 JSON 문자열입니다. 겉의 스키마가 안정적이어야 하는 프로토콜에서 가변적인 내용을 실어 나르려면 결국 문자열 한 겹을 더 두는 수밖에 없습니다. 파서를 짤 때는 그 한 겹이 어디에 있는지부터 찾는 편이 빠릅니다.

씨미에는 두 가지 구조적 함정이 있습니다.

첫째, 채팅 프로토콜의 발신자 ID는 플랫폼 REST API 어디에도 노출되지 않는 별개의 번호 체계입니다. REST가 주는 채널 ID와 번호대가 겹치기까지 합니다. 실제로 어떤 채널의 채널 ID가 다른 사람의 채팅 유저 ID와 정확히 같은 값이었습니다. 그 바람에 위클리 리캡에서 “가지도 않은 방송에 채팅 200개”라는 문의가 들어왔습니다. 채팅에서 쓰는 식별자와 REST에서 쓰는 식별자를 같은 것으로 가정하면 안 됩니다.

둘째, 씨미 토큰 요청만 재시도 래퍼를 타지 않습니다. 치지직과 SOOP은 DoWithRetry를 쓰는데 씨미는 httpClient.Do를 직접 호출합니다. 채널 수가 적어 문제가 표면화되지 않았을 뿐, 이 비대칭은 아직 그대로 남아 있습니다.


6. 두 가지 다른 죽음

여기서부터가 이 글의 핵심입니다. 연결이 죽는 방식이 하나가 아닙니다.

쓸 수 없게 된 소켓이 있습니다. 상대가 연결을 끊었거나 경로가 사라져서 쓰기 시도가 즉시 실패합니다. 이건 발견하기 쉽습니다. 무엇이든 보내보면 압니다.

절반만 열린 소켓이 있습니다. 쓰기는 성공합니다. 커널이 버퍼에 담고 성공을 돌려주기 때문입니다. 그런데 상대는 이미 사라졌고 아무 프레임도 돌아오지 않습니다. 이 상태에서는 쓰기 성공이 연결 생존을 증명하지 않습니다.

NAT 테이블에서 항목이 지워지거나, 네트워크가 분단되거나, 상대가 조용히 소켓을 버렸을 때 이렇게 됩니다.

그래서 keepalive를 두 겹으로 둡니다. 치지직 커넥터의 pingLoopchzzkPingInterval마다 앱 레벨 keepalive를 쓰고 이어서 WS 레벨 Ping을 보냅니다. 앞의 쓰기가 실패하면 소켓이 이미 쓸 수 없는 상태라는 뜻이고, 뒤의 Pong이 5초 안에 돌아오지 않으면 절반만 열린 소켓이라는 뜻입니다. 어느 쪽이 실패하든 errCh로 올려서 매니저가 그 연결을 수확하게 합니다.

Go
// (1) App-level keepalive. The platform does not ack with a data
// frame, so write success alone means "TCP write path still works";
// that's the lower-bound liveness guarantee.
c.wmu.Lock()
err := conn.Write(ctx, websocket.MessageText, ping)
c.wmu.Unlock()
if err != nil {
	select {
	case c.errCh <- fmt.Errorf("chzzk ping write failed: %w", err):
	default:
	}
	return
}

// (2) WS-level Ping. The platform replies reliably (verified) so this
// is the primary fast half-open detector.
pingCtx, pcancel := context.WithTimeout(ctx, chzzkPingTimeout)
wsErr := conn.Ping(pingCtx)
pcancel()
if wsErr != nil {
	select {
	case c.errCh <- fmt.Errorf("chzzk ws ping failed: %w", wsErr):
	default:
	}
	return
}

두 신호는 서로를 대체하지 않습니다.

신호잡는 것왕복 여부
앱 레벨 keepalive 쓰기쓰기 자체가 실패하는 소켓단방향
WS Ping / Pong쓰기는 되는데 아무것도 돌아오지 않는 소켓왕복

응답이 필요 없는 신호는 죽음의 절반만 잡습니다. 상대가 실제로 살아 있는지 확인하려면 왕복이 필요합니다.

SOOP은 순서가 반대입니다. WS Ping을 먼저 보내고 앱 레벨 keepalive를 나중에 보냅니다. gorilla를 쓰기 때문에 pong 처리 방식도 다릅니다.

Go
if err := conn.SetReadDeadline(time.Now().Add(soopReadTimeout)); err != nil { ... }
conn.SetPongHandler(func(string) error {
	return conn.SetReadDeadline(time.Now().Add(soopReadTimeout))
})

gorilla는 pong을 받으면 핸들러를 부릅니다. 그래서 pong 자체가 읽기 데드라인을 연장하는 세 번째 장치가 됩니다. 채팅 프레임이든 공지든 pong이든 무엇이라도 돌아오면 데드라인이 밀립니다. 180초 동안 아무것도 돌아오지 않으면 그때 읽기가 타임아웃으로 반환됩니다. 데드라인을 ping 간격의 3배로 잡은 것은 스케줄러 지터나 한두 번의 누락으로 멀쩡한 연결을 끊지 않기 위해서입니다.

세 커넥터의 생존 판정 장치를 정리하면 이렇습니다.

플랫폼ping 간격ping 타임아웃읽기 데드라인앱 레벨 keepalive
치지직20초5초없음있음
SOOP60초쓰기 5초180초있음
씨미30초5초5분없음

치지직만 읽기 데드라인이 없습니다. 왜 없는지가 다음 절의 내용입니다.


7. 상수는 추측이 아니라 측정에서 나왔습니다

치지직의 읽기 데드라인은 처음부터 없었던 것이 아닙니다. 넣었다가 롤백했고, 다시 넣었다가 다시 뺐습니다. 그 과정이 이 시스템에서 가장 값비싼 교훈이었습니다.

순서대로 정리합니다.

시작은 SOOP이었습니다. SOOP 전용으로 좀비 수확을 먼저 도입했습니다. 앞서 본 전수 측정에서 워커 32개 파드, 2,576개 채널 중 약 180건(7퍼센트)이 “connected 상태이면서 메시지 0건”이었습니다. SetReadDeadlineSetPongHandler를 걸고, ping 실패를 errCh로 올리고, 매니저가 정리하도록 고쳤습니다. 여기까지는 잘됐습니다.

처음에는 같은 패턴을 치지직과 씨미에 그대로 이식했습니다. 이때 치지직의 읽기 데드라인을 60초로 잡았습니다. 근거는 “SOOP에서 잘 됐으니 여기도 되겠지”였습니다. 정확히는, 치지직이 우리가 보낸 앱 레벨 keepalive를 데이터 프레임으로 되돌려줄 것이라고 가정했습니다.

곧 롤백했습니다. 치지직은 앱 레벨 keepalive에 데이터 프레임으로 답하지 않습니다. 조용한 채널은 60초 동안 데이터 프레임이 0건이었고 그 전부가 좀비로 분류됐습니다. 분당 약 1,000건의 오탐 끊김이 발생했습니다. 멀쩡한 연결을 죽이는 감지기는 감지기가 아니라 장애 유발기입니다.

롤백한 뒤에는 가정을 버리고 측정하기로 했습니다. CHZZK_TRACE_PONG 환경변수로 감싼 계측 전용 probe를 넣었습니다. 이 probe는 errCh에 아무것도 올리지 않습니다. 재연결을 트리거할 수 없게 만들어서 측정이 서비스에 영향을 주지 않도록 했습니다. 질문은 하나였습니다. 치지직이 RFC 6455 WS Ping에 5초 안에 Pong으로 답하는지였습니다.

그다음 측정 결과가 나왔습니다. QA에서 프로덕션과 같은 규모, 치지직 약 2,300개 채널, 워커 11개 파드, 10분 창으로 돌린 값입니다.

TEXT
app-level keepalive ack (data frame):  0 / many                    ->  NEVER replies
WS-level Ping (RFC 6455):              68,248 / 68,248 within 5 s  ->  100% reliable

68,248번 보내서 68,248번 다 5초 안에 돌아왔습니다. 그래서 conn.Ping을 1차 감지기로 승격하고 읽기 데드라인은 5분짜리 안전망으로만 남겼습니다.

그러고 나서 그 5분짜리 안전망도 뺐습니다. QA 검증에서 5분 동안 다음 값이 나왔습니다.

TEXT
WS Ping:                27,357 ok / 0 fail (100%)  - TCP 계층은 살아 있음
zombie_reaped:          93   (reason="ws read timeout after 5m0s")
ws_closed (reconnect):  87

Ping은 100퍼센트 성공하는데 읽기 데드라인만 93개 채널을 죽였습니다. 치지직은 조용한 채팅방에 주기적인 데이터 프레임을 아예 밀어주지 않습니다. 그래서 어떤 값을 잡아도 조용하고 건강한 채널이 오탐으로 잡힙니다. Ping/Pong을 100퍼센트 믿을 수 있다면 데드라인 폴백은 이득 없는 위험일 뿐입니다.

그래서 치지직에서는 conn.Ping이 유일한 빠른 생존 신호이고, 읽기 데드라인은 폴백으로도 걸지 않습니다.

씨미는 5분 데드라인을 그대로 둡니다. 프로덕션 채널이 30개 남짓이라 같은 규모로 측정한 적이 없습니다. 측정하지 않은 값을 근거로 안전망을 걷어낼 이유는 없습니다. 같은 코드라도 측정한 플랫폼과 측정하지 않은 플랫폼에 같은 결론을 적용하지 않습니다. 코드가 비대칭인 것이 지식이 비대칭인 것보다 낫습니다.

마지막으로 계측용 토글과 로그를 제거했습니다. conn.PingerrCh로 에러를 올리는 실제 감지 경로는 그대로 두고 측정하려고 넣었던 INFO 로그만 걷어냈습니다.

이 과정에서 남은 교훈은 하나입니다. 타임아웃 상수는 다른 플랫폼에서 잘 됐다는 이유로 옮겨 쓸 수 없습니다. 상수는 상대 서버가 무엇을 보내주느냐를 두고 세운 가정이고, 그 가정은 측정으로만 확인됩니다.


8. 감지했으면 반드시 보고해야 합니다

실제로 있었던 버그 하나가 이 설계의 진짜 교훈입니다.

ping 실패를 감지하는 코드는 처음부터 있었습니다. 그런데 실패했을 때 무엇을 했는지가 문제였습니다. 치지직의 수정 전 코드입니다.

Go
c.wmu.Lock()
err := conn.Write(ctx, websocket.MessageText, ping)
c.wmu.Unlock()
if err != nil {
	slog.Warn("chzzk ping failed", "error", err)
	return
}

씨미는 더했습니다. 로그조차 없었습니다.

Go
if err := conn.Ping(ctx); err != nil {
	return
}

결과는 이렇습니다. ping 루프는 종료됩니다. 읽기 루프는 여전히 Read에서 블록된 채 남고, 매니저는 아무 에러도 받지 못합니다. conns 맵에서 지워지지도 않습니다. 그래서 이 연결은 영원히 “살아 있는” 것으로 집계됐습니다. 감지 자체는 정상이었는데 그 사실이 아무 데도 전달되지 않았습니다.

수정은 실패를 에러 채널로 밀어 올리는 것이었습니다.

Go
if err != nil {
	select {
	case c.errCh <- fmt.Errorf("chzzk ping write failed: %w", err):
	default:
	}
	return
}

selectdefault가 붙은 것은 의도적입니다. 에러 채널은 버퍼가 8짜리라 이미 에러가 차 있다면 이 ping 실패는 버려도 됩니다. 어차피 앞선 에러 하나만으로 매니저가 연결을 정리하기 때문입니다. 여기서 블록되면 ping 루프가 종료되지 못하고 goroutine이 샙니다.

감지 로직이 있다는 것과 그 감지가 시스템의 상태를 바꾼다는 것은 다른 문제입니다. 로그는 사람이 읽을 때만 의미가 있고 자동 복구는 코드가 받을 수 있는 신호에서만 시작됩니다.

이 경험 이후로 판단 기준이 하나 생겼습니다. 어떤 실패를 감지하는 코드를 볼 때 “이게 감지되면 무엇이 달라지는가”를 먼저 묻습니다. 답이 “로그가 하나 남는다”뿐이면 그건 감지가 아닙니다.


9. 매니저는 에러를 수확해서 상태를 바꿉니다

커넥터가 errCh에 에러를 올리면 그 뒤는 매니저의 일입니다. 워커의 Manager는 커넥션마다 goroutine 두 개를 띄웁니다. 하나는 메시지를 퍼블리시하는 forwardMessages, 하나는 에러를 수확하는 handleErrors입니다.

flowchart TD
    A["pingLoop 또는 readLoop<br/>실패 감지"] --> B["errCh 에 push<br/>select 의 default 로 논블로킹"]
    B --> C["handleErrors 수신"]
    C --> D["classifyConnectorError<br/>reason 라벨 결정"]
    D --> E["ac.cancel 호출<br/>per-conn context 취소"]
    E --> F["connector.Disconnect<br/>WebSocket 종료"]
    F --> G["conns 맵에서 제거"]
    G --> H["metrics 갱신<br/>DisconnectsTotal, ZombieReapedTotal"]
    H --> I["Redis Pub/Sub 에 ws_closed 발행"]
    I --> J["코디네이터가 방송 생존 확인 후<br/>재연결 또는 종료 처리"]

분류 함수가 에러 문자열을 들여다보는 건 우아하지 않습니다. 커넥터가 타입 있는 센티널 에러 대신 래핑된 에러를 반환하니 이 방식을 골랐습니다.

Go
func classifyConnectorError(err error) string {
	if err == nil {
		return "ws_closed"
	}
	msg := strings.ToLower(err.Error())
	switch {
	case strings.Contains(msg, "read timeout"):
		return "zombie_reaped"
	case strings.Contains(msg, "ws ping failed"), strings.Contains(msg, "ping write failed"):
		return "liveness_ping_failed"
	case strings.Contains(msg, "handshake"), strings.Contains(msg, "login rejected"), strings.Contains(msg, "join rejected"):
		return "handshake_failed"
	default:
		return "ws_closed"
	}
}

문자열 매칭은 리팩터링에 취약합니다. 그래서 회귀 테스트로 7가지 케이스를 고정해 뒀습니다. 에러 메시지 문구가 바뀌면 테스트가 먼저 깨지도록 만드는 것이 최소한의 방어입니다.

Go
{"nil", nil, "ws_closed"},
{"read_timeout", fmt.Errorf("soop ws read: soop ws read timeout after 3m0s: i/o timeout"), "zombie_reaped"},
{"ws_ping_failed", fmt.Errorf("soop ws ping failed: write tcp: broken pipe"), "liveness_ping_failed"},
{"app_ping_write_failed", fmt.Errorf("soop ping write failed: broken pipe"), "liveness_ping_failed"},
{"handshake", fmt.Errorf("soop handshake failed for x: login rejected: ret=-1"), "handshake_failed"},
{"login_rejected", fmt.Errorf("soop login rejected: ret=5"), "handshake_failed"},
{"generic_read", fmt.Errorf("soop ws read: connection reset by peer"), "ws_closed"},

handleErrors의 정리 순서도 중요합니다. 컨텍스트 취소와 소켓 종료를 먼저 하고 맵에서 지우는 것은 마지막입니다.

Go
// Full teardown BEFORE removing the map entry: cancel per-conn
// context and close the WS. This unblocks forwardMessages (which
// is selecting on Messages()) and any other connector goroutines.
if ac.cancel != nil {
	ac.cancel()
}
if disconnectErr := ac.connector.Disconnect(); disconnectErr != nil { ... }

// Remove from active connections.
m.mu.Lock()
delete(m.conns, key)
m.mu.Unlock()

맵에서 먼저 지우고 정리를 나중에 하면 그 사이에 forwardMessagesMessages() 채널에서 계속 블록된 채로 남아 goroutine과 소켓이 함께 샙니다. 수천 개 연결이 하루에도 여러 번 재연결하는 시스템에서는 이런 누수가 며칠 안에 파드를 죽입니다.

좀비로 분류된 사건은 별도 카운터로 노출합니다.

Go
// ZombieReapedTotal counts silent/stalled connections forcefully reaped
// by the worker, labelled by platform and specific cause. A subset of
// DisconnectsTotal surfaced separately to make zombie incidents visible.
ZombieReapedTotal *prometheus.CounterVec

DisconnectsTotal의 부분집합을 굳이 중복 노출하는 이유는 전체 끊김 수 안에 섞여 있으면 정상 재연결과 구분되지 않기 때문입니다. 정상과 이상이 같은 카운터에 합쳐지면 이상을 볼 수 없습니다.

마지막으로 코디네이터에게 ws_closed를 알립니다. 이때 발행 컨텍스트를 context.WithoutCancel로 떼어냅니다. 방금 취소한 커넥션 컨텍스트를 그대로 쓰면 정리 도중에 이벤트 발행까지 취소돼 버립니다. 그러면 정작 코디네이터가 재배정할 기회를 잃습니다.

Go
publishCtx, publishCancel := context.WithTimeout(context.WithoutCancel(ctx), CoordEventPublishTimeout)
if pubErr := m.redisStore.PublishCoordEvent(publishCtx, event); pubErr != nil {
	slog.Error("failed to publish ws_closed event", "error", pubErr)
}
publishCancel()

워커는 자의적으로 재연결을 결정하지 않습니다. 끊겼다는 사실만 보고하고, 방송이 실제로 끝났는지 잠시 끊긴 것인지는 코디네이터가 플랫폼 API로 단건 확인한 뒤 판단합니다. 판단을 한 곳에 모아두면 수십 개 워커가 각자 다른 판단을 내리는 일이 없습니다.


10. 공유 출구 IP 하나가 만든 P0

앞 절에서 본 Ping/Pong 측정을 QA에서 프로덕션 규모로 돌리는 도중에 예상 못 한 일이 벌어졌습니다.

프로덕션의 치지직 채널이 3,674개에서 183개로 떨어졌습니다. 98퍼센트입니다.

원인은 코드가 아니라 네트워크 토폴로지였습니다. QA와 프로덕션 워커가 같은 VPC에서 돌고 바깥으로 나가는 트래픽은 공유 NAT 게이트웨이 하나의 IP를 씁니다. QA 부하 테스트가 수천 개 채널의 정보 조회를 동시에 때리자 플랫폼 입장에서는 IP 하나가 갑자기 폭주하는 것으로 보였습니다. 그 IP에서 오는 요청이 connection reset by peer로 끊기기 시작했고 같은 IP를 쓰던 프로덕션 워커까지 함께 죽었습니다.

애플리케이션 레벨에서는 각 워커가 초당 10건 제한기까지 걸고 아주 얌전하게 요청하고 있었습니다. 출구에서 합쳐지면서 한도에 걸린 것입니다.

수평 확장은 출구 IP를 확장해주지 않습니다. 파드를 늘리면 처리량은 늘지만 외부에서 보이는 발신자는 그대로 하나입니다.

응급 조치로 별도 NAT 게이트웨이와 EIP를 만들어 워커 전용 출구를 분리했고, 이어서 HTTP API 호출만 별도 출구 경로로 내보내도록 바꿨습니다. 출구 경로는 시크릿 저장소에서 환경변수로 주입하고, 커넥터는 그 값이 들어와 있을 때만 HTTP 클라이언트를 갈아끼웁니다.

Go
// NewChzzkConnector creates a new ChzzkConnector. httpProxyURL, when
// non-empty, routes the platform HTTP API calls through a separate egress
// path so they do not share the cluster's default NAT exit IP.
// Empty = direct HTTP. The WebSocket dial is always direct.
func NewChzzkConnector(httpProxyURL string) *ChzzkConnector {
	httpClient := &http.Client{Timeout: 10 * time.Second}
	if httpProxyURL != "" {
		parsed, err := url.Parse(httpProxyURL)
		if err != nil || parsed.Host == "" {
			slog.Error("chzzk: invalid egress proxy URL, falling back to direct HTTP",
				"error", err, "url_len", len(httpProxyURL))
		} else {
			t := http.DefaultTransport.(*http.Transport).Clone()
			t.Proxy = http.ProxyURL(parsed)
			httpClient = &http.Client{Timeout: 12 * time.Second, Transport: t}
			slog.Info("chzzk: HTTP API calls routed through separate egress")
		}
	}
	return &ChzzkConnector{
		httpClient: httpClient,
		msgCh:      make(chan model.ChatMessage, chzzkMsgBufSize),
		errCh:      make(chan error, chzzkErrBufSize),
	}
}

여기서 판단이 갈리는 지점은 무엇을 분리할 것인가입니다.

WebSocket은 분리하지 않습니다. 항상 직접 나갑니다. 이유가 있습니다. WebSocket은 채널 하나당 하나씩 오래 유지되는 연결이라 개수는 채널 수에 비례해 늘지만 요청 빈도는 사실상 0입니다. 반면 접속 전 조회 호출은 짧지만 재연결할 때마다 발생하고 대량 reconcile 시 순간적으로 수천 건이 몰립니다. 한도에 걸리는 쪽은 후자입니다.

부수 효과도 있습니다. 별도 경로로 흘려보내는 트래픽이 HTTP 호출로만 한정되므로 그 경로의 대역폭은 재연결 빈도에 비례할 뿐 채팅 트래픽 총량에는 비례하지 않습니다. 채팅은 초당 수만 건이 흐르는데 그걸 전부 별도 경로에 태웠다면 비용이 완전히 다른 이야기가 됐을 것입니다.

로그에 url_len만 찍는 것도 의도적입니다. 이 URL에는 인증 정보가 들어 있어서 원문을 로그에 남길 수 없습니다. 길이만 남기면 “환경변수가 비어 있는지 오타인지”는 구분할 수 있습니다.

설정이 잘못돼도 워커 프로세스는 죽지 않고 직접 연결로 떨어집니다. 워커에서는 이 선택이 맞습니다. 설정 실수 하나로 전체 채팅 수집이 멈추는 것보다 직접 연결로라도 도는 편이 낫기 때문입니다. 대신 이 폴백은 ERROR로 남깁니다. 조용히 떨어지면 같은 한도에 다시 걸릴 때까지 아무도 눈치채지 못합니다.

다만 같은 상황에서 디스커버리 서비스는 정반대로 동작합니다.

Go
// Returns an error if a non-empty proxy URL is invalid, to prevent silent degradation.
func NewChzzkDiscovery(apiMetrics APIMetrics, proxyURL string) (*ChzzkDiscovery, error) {
	if proxyURL != "" {
		parsed, err := url.Parse(proxyURL)
		if err != nil || parsed.Host == "" {
			return nil, fmt.Errorf("invalid egress proxy URL: %w", err)
		}
		...
	}

디스커버리는 잘못된 설정을 만나면 기동 자체를 실패시킵니다. 인스턴스가 하나뿐이고 폴링 주기가 짧아서 기본 경로로 도는 순간 곧바로 같은 한도에 다시 걸리기 때문입니다. 같은 실패라도 서비스마다 다른 답이 맞을 수 있고, 그 판단 근거는 “실패했을 때 무엇이 더 나쁜가”입니다.

디스커버리에는 하나가 더 있습니다. 여러 페이지를 순회하다 중간에 실패했을 때 지금까지 모은 채널을 버리지 않고 Partial: true를 달아 반환합니다. 호출자는 이 플래그를 보고 “목록에 없는 채널은 방송이 끝난 것”이라는 판정을 보류합니다. 불완전한 데이터를 완전한 데이터처럼 취급하면 수천 개 채널이 한꺼번에 종료 처리됩니다.

이 인시던트에서 얻은 운영 규칙은 하나입니다. 프로덕션 규모의 부하 테스트는 프로덕션과 다른 출구 IP에서만 돌립니다. QA와 프로덕션이 코드도 다르고 클러스터도 논리적으로 분리돼 있어도 출구 IP를 공유하면 외부 플랫폼 입장에서는 같은 발신자입니다.


11. 경계에서 흡수하는 나머지 것들

커넥터 경계에서 처리하는 것 중에 사소해 보이지만 중요한 게 몇 가지 더 있습니다.

구독자 등급 접미사

SOOP은 구독자의 사용자 ID 뒤에 등급을 나타내는 접미사를 붙여서 보냅니다.

Go
// soopUserIDSuffix strips the "(N)" tier suffix SOOP appends to user IDs
// for subscribers (e.g. "user1234(2)" -> "user1234").
var soopUserIDSuffix = regexp.MustCompile(`\(\d+\)$`)

같은 사람인데 구독 전후로 다른 ID처럼 보이고 등급이 올라가면 또 달라집니다. 이걸 경계에서 벗겨내지 않으면 통계에서 한 사람이 여럿으로 갈라지고, 사용자 차단이 우회되고, 랭킹 집계가 어긋납니다. 채팅뿐 아니라 후원과 구독 이벤트에서도 같은 정규화를 적용합니다.

플랫폼의 표현 방식이 우리 도메인의 식별자 정의를 오염시키지 않도록 커넥터 안에서 정규화합니다.

이모티콘 토큰

치지직과 씨미는 채팅에 이모티콘 이미지 URL을 함께 실어 보냅니다. 이걸 버리면 렌더러가 별도로 이모티콘 팩을 조회해야 하므로 본문에서 토큰 위치를 찾아 오프셋과 URL을 함께 붙여 내려보냅니다.

둘은 토큰을 찾는 방법부터 다릅니다. 한쪽은 토큰이 일반 문장에 나올 일이 없는 형태라 패턴 매칭으로 찾아도 안전합니다. 다른 쪽은 토큰 표기가 평범한 문장부호와 겹칠 수 있어서 패턴으로 찾으면 멀쩡한 문장이 이모티콘으로 오인됩니다. 그래서 다른 쪽은 패턴을 아예 쓰지 않고, 플랫폼이 함께 보내준 이모티콘 맵의 키 자체를 검색어로 씁니다.

관대함의 방향도 반대입니다. 한쪽은 맵에 없는 토큰이라도 URL을 비운 채 그대로 내보냅니다. 렌더러가 카탈로그를 뒤질지 텍스트로 남길지 결정할 수 있어야 하기 때문입니다. 다른 쪽은 맵 항목이 기대한 형태가 아니면 그 항목을 조용히 건너뜁니다.

같은 기능인데 한쪽은 관대하고 다른 쪽은 엄격합니다. 이 방향은 취향이 아니라 틀렸을 때 무엇이 더 나쁜가로 정합니다. 관대한 쪽에서 토큰을 놓치면 이모티콘 하나가 텍스트로 남을 뿐입니다. 엄격한 쪽에서 잘못 매칭하면 평범한 문장이 이미지로 깨집니다.

이모티콘 정보는 Redis Stream 필드에 JSON 문자열로 직렬화해서 싣습니다. 비어 있으면 필드 자체를 넣지 않아서 이모티콘이 없는 메시지의 페이로드는 이전과 완전히 동일하게 유지됩니다.

SOOP은 이 처리를 하지 않습니다. 렌더러가 전역 카탈로그에서 해석합니다.

재시도할 것과 하지 않을 것

플랫폼 API 호출에는 지수 백오프 재시도를 겁니다. 다만 모든 실패를 재시도하지는 않습니다.

Go
var retryableStatusCodes = map[int]bool{
	http.StatusBadGateway:          true, // 502
	http.StatusServiceUnavailable:  true, // 503
	http.StatusGatewayTimeout:      true, // 504
	http.StatusTooManyRequests:     true, // 429
	http.StatusRequestTimeout:      true, // 408
	http.StatusInternalServerError: true, // 500
}

func DefaultRetryConfig() HTTPRetryConfig {
	return HTTPRetryConfig{
		MaxRetries: 2,
		BaseDelay:  200 * time.Millisecond,
		MaxDelay:   2 * time.Second,
		Multiplier: 2.0,
	}
}

재시도가 의미 있는 것은 다시 하면 결과가 달라질 수 있는 실패뿐입니다. 400이나 404는 몇 번을 더 보내도 같은 답이 옵니다. 재시도하면 지연만 늘고 상대 서버에 부담만 줍니다.

재시도 횟수를 2회로 낮게 잡은 것도 의도적입니다. 수천 개 채널이 동시에 재연결을 시도하는 상황에서는 재시도 횟수가 곧 상대 서버에 가하는 부하의 배수가 됩니다. 여기서 필요한 것은 끈질김이 아니라 빠른 포기입니다. 실패를 인정하고 코디네이터가 재배정하게 두는 편이 낫습니다.

SOOP 채널 정보 조회는 POST라 본문 재사용이 필요합니다. 그래서 GetBody를 명시적으로 설정합니다. 이걸 빼먹으면 첫 시도에서 본문이 소모되고 재시도는 빈 요청이 나갑니다.

Go
req.GetBody = func() (io.ReadCloser, error) {
	return io.NopCloser(strings.NewReader(bodyStr)), nil
}

재연결 폭주를 막는 장치

매니저 쪽에도 상대 서버를 배려하는 상수가 여럿 있습니다. 플랫폼별로 초당 연결 시도를 제한하고, 실패한 채널은 30초 백오프 창에 넣고, 3회 연속 실패하면 코디네이터에 알리고, 5분 넘게 실패가 이어지면 아예 배정 해제를 요청합니다.

Go
rateLimiters: map[model.Platform]*rate.Limiter{
	model.PlatformChzzk: rate.NewLimiter(rate.Limit(10), 15),
	model.PlatformSoop:  rate.NewLimiter(rate.Limit(5), 10),
	model.PlatformCime:  rate.NewLimiter(rate.Limit(10), 15),
},

reconcile은 플랫폼마다 독립된 goroutine과 독립된 동시성 풀에서 돕니다. SOOP 쪽이 타임아웃으로 느려져도 치지직의 재연결이 그 풀을 기다리다 굶지 않게 하기 위해서입니다.

Go
// PerPlatformConcurrency is the max concurrent Connect calls PER PLATFORM
// during reconciliation. Each platform gets its own pool so a failing
// platform (e.g. SOOP timeout) cannot starve healthy ones (e.g. Chzzk).
PerPlatformConcurrency = 30

한 플랫폼이 죽었을 때 나머지 두 플랫폼까지 같이 멈추면 장애 하나가 장애 셋이 됩니다. 실제로 치지직 채널이 대량으로 끊긴 인시던트에서는 그 복구에 매달리다 워커를 축소했고, 그 여파로 SOOP과 씨미까지 절반이 끊기는 2차 사고가 났습니다. 격리는 코드 안에서만이 아니라 대응 절차 안에도 필요했습니다.


12. 마무리

플랫폼 세 곳의 채팅을 다섯 개짜리 인터페이스 뒤로 밀어 넣고, 그 뒤에서 연결이 살아 있는지 판정하는 것이 이 글의 내용이었습니다. 프로토콜은 셋 다 달랐지만 판정의 구조는 같았습니다. 접속 성공을 소켓이 아니라 프로토콜 상태로 정의하고, 왕복하는 신호로 절반만 열린 소켓을 잡고, 감지한 사실을 에러 채널로 올려 매니저가 그 연결을 수확하게 하는 것입니다.

가장 오래 걸린 부분은 상수였습니다. 다른 플랫폼에서 잘 돌던 값을 옮겨 붙였다가 멀쩡한 연결을 대량으로 끊었고 결국 측정으로 다시 세웠습니다. 그 측정을 프로덕션 규모로 돌리는 도중에는 공유 출구 IP라는 전혀 다른 층의 문제까지 드러났습니다.

여기서 정규화한 채팅 스트림은 신청곡, 오버레이, 랭킹 집계의 입력이 됩니다. 이 스트림을 받아 조직별 키워드로 나눠 보내는 쪽은 채팅 키워드알림 서비스 - 멀티테넌트 키워드 알림에 정리했습니다.

시리즈 · 채팅 수집과 처리

2 / 5

SOOP, 치지직, 씨미의 라이브 채팅을 모아 랭킹과 알림으로 바꾸기까지의 기록.

  1. 1.멜로밍 랭킹 - SOOP, 치지직, 씨미 채팅 수집 및 처리 아키텍처
  2. 2.SOOP, 치지직, 씨미 채팅 수집 커넥터로 12,000개 방송 채팅 수집하기
  3. 3.라이브 스트리밍 채팅 수집 시스템을 KEDA와 Karpenter로 확장한 방법
  4. 4.채팅 키워드알림 서비스 - 멀티테넌트 키워드 알림
  5. 5.방송에서 가장 많이 나온 단어는? : NLP를 활용한 채팅 워드클라우드