배민쇼핑라이브를 만드는 기술: 채팅 편

Oct.14.2021 오지산

Backend

안녕하세요, 라이브선물스쿼드의 오지산입니다.

이 글에서는 저희 배민쇼핑라이브에서 “채팅을 자체 구현하게 된 배경”과, “기술적인 부분”을 설명드리려고 합니다.

배민쇼핑라이브에서 채팅은 스태프와 고객 사이의 주요한 소통 창구가 되며, 구매인증 이벤트의 수단이기도 합니다.
특히 구매인증 상황에서는 분당 2만 건이 넘는 메시지들이 송수신되기도 하는데요.

채팅 폭발

1분 22293건 최고기록

저희는 이러한 채팅을 자체 구현했으며, 현재까지도 성장하는 트래픽을 잘 받아내고 있습니다.

왜 자체 구현했는가?

저희가 초반에 쇼핑라이브를 구현했을 때에는, Sendbird나 FCM과 같은 외부 메시징 서비스를 도입하는 것을 고려했었습니다.

Sendbird의 경우 배민 고객센터 채팅에서 이미 사용하고 있기 때문에 앱 개발자분들이 빠르게 이를 도입할 수 있을 거라고 생각했으나,
도입 과정에 어느 정도의 노하우가 쌓이지 않았다면 상당한 시행착오를 겪을 수 있을 거라는 의견을 전해 들었습니다.
비용 측면이나, Sendbird의 인프라에 의존해야 한다는 점도 부담으로 다가왔었습니다.

FCM도 잠시 고려했었으나, 단일 기기에 보낼 수 있는 최대 메시지 수가 분당 240개인 등
저희가 필요로 하는 처리량에는 크게 못 미치는 것으로 보여 도입할 수 없었습니다.

여러 솔루션을 고려하는 과정에서 저희는 기존의 경험을 살려 자체 구현하는 것이
쇼핑라이브에 가장 알맞은 채팅을 만들 수 있다는 방향으로 의견을 모으게 되었습니다.

기존의 경험

저희 배민쇼핑라이브 서비스는 (지금은 서비스 종료된) 영상 기반 SNS인 Thiiing을 만들었던 사람들이
대거 참여하여 개발을 하게 되었는데요.
이는 기존의 미디어 관련 개발 경험이나, 영상 송출 경험을 배민쇼핑라이브에서 활용할 수 있겠다는 판단 때문이었습니다.

특히 채팅과 관련해서는 저희가 Thiiing 시절 가지고 있었던 경험이 많은데요.
Thiiing에서 구현했던 다이렉트 메시징(DM)과 같은 기능들이 대표적입니다.
당시 채팅 서버도 Netty로 자체 구현했었고, UI도 웹뷰로 만들어서 탑재했었어요.

더불어 저희는 Thiiing 서비스를 피보팅하면서 잠깐 화상대화 기능을 개발했었던 적도 있습니다.
지금은 팀에 계시지 않은 박상윤님께서 WebRTC를 사용해서 Zoom과 같은 화상대화 기능을 개발하셨었는데요.
이렇듯 기존에 저희가 Thiiing을 만들면서 쌓아왔던 기술 유산들을 쇼핑라이브에서 꽃피우는 차원에서라도
채팅을 자체 구현하는 것은 매우 그럴듯한 선택이었습니다.

자체 구현하기로 결정한 뒤, 상윤님은 쇼핑라이브 채팅의 아키텍처를 설계해 주시게 됩니다.

아키텍처

아래는 상윤님이 처음 그려주셨던 아키텍처입니다.

쇼핑라이브 채팅 아키텍처

쇼핑라이브 채팅은 각각의 채팅 서버들이 Redis Pub/Sub을 통해 메시지를 주고받으며,
Webflux를 이용해서 채팅 트래픽을 non-blocking하게 처리합니다.
클라이언트와의 통신은 WebSocket을 활용하고 있고요.

처음 Webflux를 도입하기로 제안 주셨던 분은 저희 팀의 허승원님이신데요.
당시 승원님이 담당하셨던 일부 도메인에 대해서 Webflux를 도입할 생각을 갖고 계셨고,
Webflux를 사용하여 서버 자원을 효율적으로 활용할 수 있지 않겠느냐는 차원에서 제안을 주셨었습니다.
당시 저와 상윤님은 Webflux에 대해서 잘 알지는 못했지만, 승원님의 취지에 공감하여 Webflux를 도입하자고 뜻을 모았어요.

이후 채팅의 주요 구현은 제가 맡게 되었습니다.

초기 구현

작년 10월, 저는 구현을 시작할 때만 해도 채팅 서버를 만들어 본 적도 없었고, WebFlux에 대해서 아는 바도 없었습니다.
Mono나 Flux가 나오면 아 이상한 코드다 그만 알아보도록 하자 했던 그런 개발자였고요.
사실 Webflux를 도입하는 취지에 대해서는 공감은 하지만, 모르는 기술을 가지고 첫걸음을 내딛는 게 걱정되기도 했습니다.

하지만 라이브커머스의 채팅은 일반적인 인스턴트 메신저의 채팅만큼 복잡하지 않기 때문에
텍스트 기반의 메시지를 보내고 받는다는 것에만 집중한다면 잘 만들 수 있지 않을까 생각하기도 했어요.
아키텍처를 설계하신 상윤님께서도 범용성을 고려하는 것보다는 서버가 터지지 않는 방향으로,
계속 중심을 잡아주시기도 했습니다.

저는 모르는 도메인을 생소한 기술로 개발해야 했기 때문에
최대한 제가 이해할 수 있는 만큼 단순하게 개발해야 나중에도 제가 감당할 수 있겠다고 생각했어요.

말하는 감자

그래서 우선 말하는 감자의 심정으로 개발을 시작했습니다. 🥔 집에 가서 WebFlux 공부부터 후다닥 시작했었어요.
이 과정에서 오라일리의 Learning Path: Reactive Spring에 큰 도움을 받았습니다.
(여담이지만 저희 회사에서는 개발자들이 누구나 오라일리의 교육 리소스에 접근할 수 있는데,
이렇게 기술적으로 도전해야 하는 상황에서 큰 도움이 됩니다. 🥰)

기본적인 이해를 마치고 나서는 스프링 리액티브 스택의 WebSocket 관련 문서나, Spring Data Redis 문서를 보면서
ReactiveStringRedisTemplate을 통해 Redis Pub/Sub으로 메시지를 주고받는 정말 간단한 채팅 서버를 만들었습니다.

아래 코드는 제가 작년 10월에 처음으로 커밋한 코드의 일부인데요.
미리 발급한 토큰을 파싱해서 채팅방 ID를 가져오고, 25초 간격으로 핑 메시지를 내리고,
클라이언트에서 받은 메시지는 Redis Pub/Sub으로 보내고, Redis Pub/Sub에서 받은 메시지는 클라이언트로 보내는 코드입니다.

@Component
@RequiredArgsConstructor
public class ChattingWebSocketHandler implements WebSocketHandler {
    private static final Duration PING_INTERVAL = Duration.ofSeconds(25);
    private static final byte[] PING_PAYLOAD = new byte[0];

    private final ChattingProperties properties;
    private final TokenService tokenService;
    private final PubSubService pubSubService;
    private final ChattingService chattingService;

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        List<String> tokenHeaders = session.getHandshakeInfo().getHeaders().getOrEmpty(properties.getWsTokenHeader());

        Mono<Long> channelId = Flux.fromIterable(tokenHeaders)
                .flatMap(tokenService::parseToken)
                .next();

        Flux<WebSocketMessage> pingSource = Flux.interval(PING_INTERVAL)
                .map(i -> session.pingMessage(factory -> factory.wrap(PING_PAYLOAD)));

        return channelId.flatMap(cid -> {
            Mono<Void> input = session.receive()
                    .filter(message -> message.getType() == TEXT)
                    .flatMap(chattingService::processMessage)
                    .concatMap(message -> pubSubService.sendMessage(cid, message))
                    .then();

            Flux<WebSocketMessage> messageSource = pubSubService.receiveMessages(cid)
                    .map(session::textMessage);

            Mono<Void> output = session.send(Flux.merge(messageSource, pingSource));

            return Mono.zip(input, output).then();
        });
    }
}

매우 단순한 코드이지만 시작점을 여기서 잡은 게 다행이라고 생각하고 있습니다.
만약 제가 다른 메시징 서비스를 살펴보면서 스펙을 짜깁기해서 채팅 도메인을 만들고,
나중에 사용하지 않을 것을 개발했다면 짧은 시간 안에 좋은 채팅을 만들기는 어려웠을 거라고 생각합니다.

처음에는 저도 범용성이 있는 채팅 도메인으로 발전하게 될까, 이런 건 필요하지 않을까 고민하던 때가 있었는데
중심을 잡아주시는 분이 계셨기 때문에 단순함을 유지할 수 있었던 것 같습니다.

구현의 방향성

저희는 이전에 채팅이나, 화상대화 등을 바닥에서부터 구현했던 경험이 있었기 때문에
쇼핑라이브의 채팅을 자체 구현할 때에는 기존에 겪었던 시행착오를 다시 저지르지 않을 수 있었어요.
저도 (프론트엔드 측이었지만) 채팅 구현에 참여했던 기억이 있기 때문에, 당시의 기억을 바탕으로 두 가지 방향성을 세우려고 했습니다.

  • 첫째, WebSocket을 최소한으로 사용한다
  • 둘째, RDB 직접 접근을 배제한다

첫째, WebSocket을 최소한으로 사용한다

채팅을 처음 만들기 시작하면 커맨드를 통해서 모든 것을 해결하려는 관성이 생기는 것 같습니다.
Thiiing 채팅의 경우도 커맨드가 많았었고, 초기에 스펙을 구상할 때도 커맨드가 아래와 같이 많았었는데요.
이런 커맨드를 줄일 수 있으면 줄이려고 했어요. 주석 처리된 것이 현재 사용하지 않는 커맨드입니다.

package com.woowahan.nx.chatting.model;

public enum ChattingCommand {
  // HEARTBEAT,
  RECONNECT_BRD,
  QUIT_ALL_BRD,
  // EVENT_REQ,
  // EVENT_RES,
  EVENT_BRD,
  // ROOM_INFO_REQ,
  ROOM_INFO_RES,
  MESSAGE_REQ,
  MESSAGE_RES,
  MESSAGE_BRD,
  // MESSAGE_LIST_REQ,
  // MESSAGE_LIST_RES,
  // MESSAGE_UPDATE_STATUS_REQ,
  // MESSAGE_UPDATE_STATUS_RES,
  // MESSAGE_UPDATE_STATUS_BRD,
  // USER_BAN_REQ,
  // USER_BAN_RES,
  // USER_BAN_BRD,
  // USER_UNBAN_REQ,
  // USER_UNBAN_RES,
  // USER_UNBAN_BRD,
  // ... 이하 생략
  ;
}

저희에게 소켓 통신이 필요했던 근본적인 이유는 실시간으로 메시지를 보내고 받기 위해서,
그리고 시청자 수, 상품의 품절 여부 등 실시간 정보(저희 팀 용어로는 ‘실시간 이벤트’)를 브로드캐스팅 하기 위해서가 다였어요.
나머지 기능들, 즉 유저의 채팅을 제한하거나 메시지 목록을 검색하는 기능들은 REST API로도 충분히 처리할 수 있었습니다.

커맨드가 많을수록 코드상에서의 분기가 늘어나고 다양한 요청들을 처리해야 하기 때문에 복잡성이 늘어난다고 생각했어요.
그래서 REST API로 가능한 것들이 소켓 커맨드로 들어오는 일은 막고자 했습니다.
실시간 이벤트 또한 받는 것은 WebSocket으로 받더라도, 보내는 것은 REST API로 하게 했습니다.

결과적으로 지금 커맨드 분기에는 이것 하나만 남게 되었습니다.
메시지를 보내고 받는 본질만 남겨두어서 복잡함이 스며들지 않게 했어요.

switch (message.getCommand()) {
  case MESSAGE_REQ:
    responseBody = processNormalMessage(tokenBody, message.getBody());
    responseCommand = ChattingCommand.MESSAGE_RES;
    break;
  default:
    throw new ChattingException(MessageCode.BAD_REQUEST);
}

둘째, RDB 직접 접근을 배제한다

WebFlux로 열심히 코드를 짜고 나서 JDBC를 사용하게 된다면 non-blocking의 이점을 얻지 못할 것이라고 생각했어요.
그래서 가능한 선에서 필요한 데이터들은 모두 Redis에 저장하고 사용했습니다.

가령 방송이 시작될 때 특정 채팅방을 열고 닫고 확인하는 동작은 이렇게 구현되어 있습니다.
Redis의 특정 방송 관련 키에 플래그 값을 저장하는 정도입니다.

private final ReactiveStringRedisTemplate template;

public Mono<Boolean> createRoom(long broadcastId) {
  ReactiveValueOperations<String, String> opsForValue = template.opsForValue();
  return opsForValue.set(ROOM_ALIVE.toString(broadcastId), "true");
}

public Mono<Boolean> hasRoom(long broadcastId) {
  ReactiveValueOperations<String, String> opsForValue = template.opsForValue();
  return opsForValue.get(ROOM_ALIVE.toString(broadcastId))
      .map(v -> v.equals("true"))
      .switchIfEmpty(Mono.just(false));
}

public Mono<Boolean> deleteRoom(long broadcastId) {
  ReactiveValueOperations<String, String> opsForValue = template.opsForValue();
  return opsForValue.delete(ROOM_ALIVE.toString(broadcastId));
}

이것 외에도 채팅방 공지사항은 문자열로, 채팅을 제한할 유저 목록은 리스트로…
DB에 저장이 필요하지 않고 방송이 끝나면 휘발되는 데이터들은 Redis에 저장하는 쪽으로 구현했습니다.

이렇게 구현해도 사실 RDB에 데이터를 보관해야 하는 시점이 오긴 합니다.
이력 관리가 필요할 수도 있고, constraint를 걸어야 하는 데이터가 있을 수도 있고…
모든 데이터를 Redis에 저장시켜두는 것은 이상적이지 못합니다.

저희는 그러한 필요가 느껴지면 DB에 영구히 저장해야 하는 정보를 저장하고,
Redis에 휘발되는 정보를 저장하도록 API를 나누었습니다.

가령 어드민측 서버에서 해당 방송의 공지사항 목록을 조회/수정/삭제할 수 있는 API가 있다면,
그 API들은 채팅 서버의 인터널 API를 호출해서, Redis에 저장된 현재 공지사항을 수정/삭제하면서
젓가락처럼 함께 움직일 수 있겠죠.

@Transactional
public void pinNotice(long broadcastId, long noticeId) {
  noticeRepository.resetPin(broadcastId);

  // DB상에 저장해야 하는 작업을 처리하고
  ChattingNoticeEntity notice = noticeRepository.findByBroadcastIdAndId(broadcastId, noticeId)
      .orElseThrow(() -> new EntityNotFoundException("ChattingNoticeEntity", noticeId));

  notice.setPin(true);
  noticeRepository.save(notice);

  sendNoticeToChatting(notice);
}

private void sendNoticeToChatting(ChattingNoticeEntity notice) {
  ChattingNoticeReq req = ChattingNoticeReq.builder()
      .message(notice.getMessage())
      .build();

  // 채팅 서버의 인터널 API를 호출
  chatApi.updateNotice(notice.getBroadcastId(), req)
      .doOnSuccess(v -> eventService.sendNotice(notice.getBroadcastId(), notice.getMessage()))
      .subscribeOn(backgroundScheduler).subscribe();
}

이 방법이 베스트라고 말씀드리는 것은 아니지만,
가벼운 채팅을 만들기 위해서 진심이었다는 것만은 전달드릴 수 있을 것 같습니다. 😅

몇 가지 시행착오들

새로운 도메인을 만들게 되면 장애를 피할 수 없죠.
여기서는 저희가 채팅을 자체 구현하면서 겪게 되었던 시행착오에 대해서 소개해 드리려고 합니다.

WebSession 사용으로 인한 장애

저희 채팅에 동시접속자가 유달리 많았던 어느 날, 예기치 못한 장애가 발생한 적이 있습니다.
이 장애에 대해서 설명드리려면 쇼핑라이브 채팅이 인증을 처리하는 방식을 짚고 넘어가야 하는데요.

저희 채팅은 접속 정보를 얻어오는 API가 따로 있습니다.
특정 웹소켓 엔드포인트 URL을 클라이언트에 저장해둔 것이 아니라, 서버가 전달해 주기 때문에 조금 더 유연한데요.
여기서 클라이언트의 회원 정보를 읽어서 채팅 전용 토큰을 발급해 주기도 합니다.

GET /chat/{broadcastId}/join HTTP/1.1  
Host: example.com:8000  
Content-Type: application/json

{  
  "auth": "dGhlIHNhbXBsZSBub25jZQ=="  
  "webSocketEndpoint": "http://example.com:8000/chat"  
}

채팅 전용 토큰을 발급하는 이유는 배민 회원, 배민 비회원, 어드민 회원 등 다양한 주체들이
채팅에 접근하기 때문에 채팅 접속 이후에 일관된 방식으로 인증을 처리하기 위함이기도 하고요.
일단 채팅을 시작하고 나면 외부 인증 서버에 의존하지 않으니 연쇄적인 장애 발생 위험을 줄일 수 있기 때문이기도 합니다.

클라이언트는 위에서 받은 토큰을 WebSocket 핸드셰이킹 할 때 HTTP 헤더에 넣어 전달합니다.

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

그리고 서버는 여기서 전달받은 토큰을 파싱해서
방송 ID, 유저 정보 등의 필요한 정보를 얻고 WebSocket 로직에서 두고두고 사용합니다.
여기서 문제가 하나 생깁니다.

스프링에서 WebSocket의 핸드셰이크를 담당하는 친구는 HandshakeWebSocketService고,
연결을 맺은 뒤 오가는 메시지를 처리하는 친구는 WebSocketHandler입니다.
이 둘은 분리되어 있기 때문에 HandshakeWebSocketService는 WebSocketSession에 필요한 정보를 넣고,
WebSocketHandler는 WebSocketSession에서 그 정보를 빼내어서 로직에 사용해야 합니다.

HandshakeWebSocketService의 handleRequest는 이렇게 생겨 있는데,
WebSocketSession을 어떻게 접근해야 하는 것일까요?

public reactor.core.publisher.Mono<Void> handleRequest(ServerWebExchange exchange,
                                                        WebSocketHandler handler)

사실 WebSocketSession은 WebSocketHandler의 파라미터로 전달되니,
람다 함수를 만들어서 handleRequest의 두 번째 파라미터로 던지면 그 안에서 접근할 수 있겠죠.

그러나 저는 등잔 밑을 보지 못했고요.
대신 HandshakeWebSocketService에 setSessionAttributePredicate()라는 메서드가 존재한다는 것을 알게 됩니다.
메서드가 하나 있으면 “이걸로 전달하는 게 표준적인 방법인가 보다” 하는 묘한 신뢰감을 받게 됩니다. 왜일까요….

@ParametersAreNonnullByDefault
@Component
@RequiredArgsConstructor
public class ChattingHandshakeWebSocketService extends HandshakeWebSocketService {

  @PostConstruct
  private void init() {
    setSessionAttributePredicate(key -> key.equals(TOKEN_BODY_ATTRIBUTE_NAME));
  }

  @Nonnull
  @Override
  public Mono<Void> handleRequest(ServerWebExchange exchange, WebSocketHandler handler) {
    // 중간 생략...
    return roomService.hasRoom(token.getBroadcastId()).flatMap(hasRoom -> {
      if (!hasRoom) {
        return handleHandshakeError(MessageCode.ROOM_NOT_FOUND, exchange);
      }

      return exchange.getSession().doOnNext(session -> {
        Map<String, Object> attributes = session.getAttributes();
        attributes.put(TOKEN_BODY_ATTRIBUTE_NAME, token);
      });
    }).then(super.handleRequest(exchange, handler));
  }
}

해당 메서드를 사용했던 제 코드인데요.
“ServerWebExchange에 있는 WebSession의 attributes에 정보를 저장하고
setSessionAttributePredicate를 사용해서 WebSocketSession으로 옮기면 되겠다,
이게 표준적인 방법이구나!” 하고 문서를 열심히 읽은 저는 생각했었습니다.
그리고 이 코드는 훗날 장애의 원인이 됩니다. 🤪

동시접속자수가 유달리 많았던 어느 날, 채팅 서버에 아래 에러가 뿜어져 나오기 시작했습니다.

Max sessions limit reached: 10000

무척 당황했던 저는 앞서 소개 드렸던 승원님으로부터 그 이유를 알 수 있게 됩니다.
스프링 내부적으로 WebSession을 저장하는 InMemoryWebSessionStore에서는
최대 세션의 개수를 기본 10,000개로 설정해 두고 있었는데요,
그날 방송에서 새로운 마케팅 유입 경로를 시도하면서 동접자가 많아져서 최대 세션의 개수를 넘어버린 것이었죠.
이로 인해 방송에 새로 들어오는 사람들은 채팅에 연결이 되지 않는 장애가 발생하게 됩니다.

이 에러는 최대 세션의 개수를 늘려서 해결할 수도 있지만, 더 간단하게는 WebSession을 사용하지 않으면 해결되는 문제입니다.

@Nonnull
@Override
public Mono<Void> handleRequest(ServerWebExchange exchange, WebSocketHandler handler) {
  // 중간 생략...
  WebSocketHandler decorator = session -> {
    session.getAttributes().put(TOKEN_BODY_ATTRIBUTE_NAME, token);
    return handler.handle(session);
  };

  return roomService.hasRoom(token.getBroadcastId()).flatMap(hasRoom -> {
    if (!hasRoom) {
      return handleHandshakeError(MessageCode.ROOM_NOT_FOUND, exchange);
    }

    return super.handleRequest(exchange, decorator);
  });
}

이렇게 수정되고 나서는 위와 같은 오류가 더 이상 발생하지 않았습니다.
인터페이스에 대한 전체적인 이해가 부족했던 것 같아 반성하게 되었죠…. 🙏

멈춰 버린 어드민

배민쇼핑라이브에는 영상 송출, 채팅, 상품, 지표 등 방송 진행에 필요한 것들을 한 화면에서 접근할 수 있는 어드민이 있는데요.
내부적으로는 ‘온에어’라고 부르고 있습니다.
(저희 온에어… 나름 워크맨에도 나왔었습니다)

온에어

방송을 진행하시는 분들은 바로 이 온에어를 통해서 채팅에 접속하고 (민트색) 스태프 메시지를 남길 수 있는데요.
이 온에어를 제가 구현했었습니다.
(저희 팀은 지금까지도 백엔드 개발자들이 어드민의 많은 부분을 React+TypeScript로 구현하고 있어요)

오픈 초기에 이 온에어를 구현할 때는 채팅 메시지가 쌓이는 대로 전부 화면에 렌더링을 했었는데요.
구현 당시에 채팅이 얼마나 흥할지(?) 몰랐었고, 브라우저 성능 측면에 대한 이해가 부족했던 탓에
시간이 지나면서 점차 온에어가 느려지고 멈추는 현상이 일어나게 되었습니다.

이 성능 문제를 해결해서, 몇십만 건의 메시지가 쌓이더라도 온에어가 느려지지 않게 해야 했습니다.

제가 첫 번째로 시도해 본 방안은 리스트 가상화였는데요,
react-virtualized 라이브러리를 사용해 리스트 가상화를 시도했습니다.

react-virtualized는 제작자가 말하는 것처럼 사용하기에 편리한 라이브러리는 아니었어요.
제작자는 대신 react-window를 추천하지만 그렇다고 react-window를 쓸 수 있는 상황은 아니었습니다.
채팅은 텍스트 길이가 일정하지 않아 2줄이 될 수도, 3줄이 될 수도 있는데요.
react-window는 이렇게 높이가 일정하지 않은 리스트 아이템에 대해서는 사용하기 어려운 것으로 보입니다. (2021년 10월 현재)

const cellMeasurerCache = new CellMeasurerCache({
    fixedWidth: true,
});

CellMeasurerCache를 만들고, 대략 이런 코드를 작성해서 리스트 가상화를 했었습니다.
(보시다시피 이해가 어려운데요. 최근에 다시 알아보니 react-virtual이 더 간결한 API를 제공하는 것 같아
이 라이브러리로 교체할 수 있을지에 대해서 조사 중인 상황입니다. 😅)

<AutoSizer disableHeight>
    {({ width }) => (
        <List
            width={width}
            height={height ?? DEFAULT_HEIGHT}
            ref={listRef}
            rowCount={messages.length}
            rowHeight={cellMeasurerCache.rowHeight}
            rowRenderer={({ index, key, parent, style }) => (
                <CellMeasurer cache={cellMeasurerCache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
                    <BroadcastControlBoardChattingRow
                        style={style}
                        broadcastId={broadcastId}
                        body={messages[index].body}
                    />
                </CellMeasurer>
            )}
        />
    )}
</AutoSizer>

아무튼 이렇게 리스트 가상화를 했는데도 성능상으로 개선되지는 않았습니다.
당시에 32000개의 메시지를 딜레이 없이 우다다다 보내는 방식으로 UI의 성능 테스트를 했었는데요.
채팅 입력칸은 버벅거리고 들어오는 메시지들도 매우 빠르게 깜빡여서 보이지 않았습니다.

이후 여러 가지 시행착오를 겪고 나서 결국 컴포넌트의 렌더링 횟수를 줄이는 방향이 좋은 결과를 가져온다는 것을 알게 되었어요.

아래는 현재 온에어의 채팅 관련 로직을 대략적으로 React Hook으로 표현한 것인데요.
컴포넌트에 임시로 메시지를 담는 배열인 messageBuffer를 만들고,
메시지가 50개 이상 쌓이거나 마지막으로 쌓은 지 50ms가 지나면
state를 업데이트하고 버퍼를 비우는 방식으로 로직을 변경했습니다.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useChattingAPI from '../api/ChattingAPI';
import { ChattingMessage, MessageBrd, MessageReq } from '../type/Chatting';

type Helper = {
  ready: boolean;
  messages: MessageBrd[];
  send: (message: string) => void;
};

const CHATTING_DELAY = 50;
const QUEUE_LIMIT = 50;

const useStaffChatting = (broadcastId: string, staffName: string) => {
  const ChattingAPI = useChattingAPI();
  const [ready, setReady] = useState(false);
  const [messages, setMessages] = useState<MessageBrd[]>([]);

  const webSocketRef = useRef<WebSocket>();
  const messageBufferRef = useRef<MessageBrd[]>([]);
  const timeoutHandleRef = useRef(0);

  useEffect(() => {
    let abort = false;

    const connectWebSocket = async () => {
      if (abort) return;
      const { token, wsUrl } = await ChattingAPI.getJoinInfo(
        broadcastId,
        staffName
      );
      const webSocket = new WebSocket(wsUrl);

      const onOpen = () => webSocket.send(token);
      const onMessage = ({ data }: MessageEvent) => {
        if (data === 'OK' && !abort) {
          return setReady(true);
        }

        try {
          if (!abort) updateFromMessage(JSON.parse(data));
        } catch (e) {
          return;
        }
      };
      const onClose = () => {
        if (!abort) setReady(false);
      };

      webSocket.addEventListener('open', onOpen, { once: true });
      webSocket.addEventListener('message', onMessage);
      webSocket.addEventListener('close', onClose, { once: true });
      webSocketRef.current = webSocket;
    };

    const updateFromMessage = (message: ChattingMessage) => {
      if (message.command === 'MESSAGE_BRD') {
        clearTimeout(timeoutHandleRef.current);
        messageBufferRef.current.push(message);
        if (messageBufferRef.current.length > QUEUE_LIMIT) {
          updateFromMessageBuffer();
        } else {
          timeoutHandleRef.current = window.setTimeout(
            () => updateFromMessageBuffer(),
            CHATTING_DELAY
          );
        }
      }
    };

    const updateFromMessageBuffer = () => {
      const snapshot = messageBufferRef.current;
      messageBufferRef.current = [];
      setMessages((messages) => messages.concat(snapshot));
    };

    connectWebSocket();

    return () => {
      abort = true;
      webSocketRef.current?.close();
    };
  }, [ChattingAPI, broadcastId, staffName]);

  const send = useCallback((message: string) => {
    // 생략...
  }, []);

  return useMemo<Helper>(
    () => ({ ready, messages, send }),
    [ready, messages, send]
  );
};

export default useStaffChatting;

결국 리스트 가상화렌더링 횟수 줄이기가 모두 필요했던 셈이 되었는데요.
어드민을 구현하면서 이런 종류의 성능 문제를 겪기는 쉽지 않을 것 같은데, 값진 경험을 하게 된 것 같아 뿌듯했던 기억이 납니다.

글을 마치며

사실 채팅을 자체 구현한다는 것은 오버 스펙이 아닌지를 충분히 고민해 봐야 하는 문제인데요.
이 글에서는 저희가 라이브커머스를 개발하면서 채팅을 자체 구현했던 배경과 그로 인한 성취를 전달드리고자 했던 부분이 큽니다.
읽으시는 분들께 잘 전달이 되었을지 모르겠습니다. 😅

채팅을 자체 구현하는 방안을 고민하시거나, 혹시나 배민쇼핑라이브의 채팅에 대해서 궁금한 부분이 있으셨다면
이 글이 그러한 부분들을 해소해 드릴 수 있었기를 바랍니다.

감사합니다.