Engineering
오픈채팅 Lite FE 성능 개선의 모든 것
stella.inter, roddy.chan, holly.wood카카오
2025년 2월 7일
원문에서 보기 ↗서론
안녕하세요, 오픈채팅 Lite의 stella, roddy, holly입니다.
오픈채팅 Lite는 부담 없이 수많은 사람들과 대화할 수 있도록 하는 것을 목표로 개발된 서비스로, 지금 이 순간에도 다양한 주제로 많은 이야기가 만들어지고 있습니다.
하나의 주제로 많은 사람이 제한 없이 참여할 수 있다는 서비스 특성상, 유저들의 화력에 따라 짧은 시간 안에 한 화면에 적게는 수십 개, 많게는 수백, 수천 개의 메시지가 몰릴 수 있습니다. 그만큼 오픈채팅 Lite에서는 화면 렌더링 성능에 큰 관심을 기울이고 있습니다. 약간의 개선 혹은 개악으로도 유저들의 체감 성능이 극적으로 바뀔 수 있기 때문이에요.
이번 포스트에서는 오픈채팅 Lite FE에서 진행했던 여러 렌더링 성능 개선 시도, 그리고 그 결과를 공유합니다. 간단한 컴포넌트 개선부터 라이브러리 사용 컨벤션, 코어 로직의 변경까지 여러 방면에 걸쳐 개선을 진행하였으니 많은 관심을 가지고 지켜봐 주세요!
disclaimer
이 게시글에서 발생하는 여러 가지 문제 상황들은 단숨에 발생한 것이 아니라, 23년 5월 오픈채팅 Lite 런칭 이후 꾸준한 기능 개발 및 구조 개선을 거쳐오며 수정된 것입니다. 전후 비교 영상 혹은 그래프는 개선 양상을 보여드리기 위해 테스트 환경을 구성하여 촬영된 것으로, 오픈채팅 Lite의 현재 성능 및 동작과는 다를 수 있습니다.
주요 성능 이슈
먼저, 오픈채팅 Lite를 개발하면서 직면했던 여러 가지 성능 문제를 소개하겠습니다.
사용할수록 점점 느려지는 앱
잠깐 텍스트를 보내고 나가는 사용성에서는 크게 문제가 되지 않았지만, 한 주제에서 여러 채팅방을 이동하며 대화하거나, 오랜 시간 채팅방에 머물며 여러 개의 메시지를 받을 경우 유저가 체감할 수 있을 정도로 점점 동작이 느려지는 현상이 발생했습니다.

말풍선이 그려지는 동안 유저 동작 무시
메시지를 주고받는 동안에도 문제가 발생했어요. 누군가 보낸 메시지가 화면에 그려지는 동안, 유저가 누른 버튼이 동작하지 않다가 뒤늦게 동작하는 현상이 발생했습니다. 평소에도 종종 발생하지만, 시즈널 이벤트를 기념하는 채팅방에서는 수많은 사람이 메시지를 작성하기 때문에 이 문제점이 더 극명하게 드러났습니다. 이 현상은 안드로이드보다는 iOS에서 더 잘 재현되었어요.
화면이 깜빡이는 현상
마지막으로, 유저의 참여율이 높은 주제에서 채팅방에 너무 빠른 속도로 메시지가 쌓일 때 발생하는 문제입니다. 우리가 기대한 동작은 메시지가 빠르게 주르룩 올라가는 그림이었는데, 메시지가 전역 스토어에 쌓이는 속도 대비 메시지가 그려지는 속도가 충분하지 못해서 화면이 하얗게 보이는 현상이었어요. iOS보다는 안드로이드에서 더 잘 발생했습니다.

컴포넌트를 빠르게 그리는 법
여러 가지 방법으로 두 현상을 개선할 수 있겠지만, 여기서는 컴포넌트를 좀 더 빨리 그리는 방법에 집중해 보겠습니다.
먼저 첫 번째, 앱이 점점 느려지는 이유 중 하나는 DOM에 그려진 컴포넌트가 시간이 지날수록 점점 늘어나기 때문입니다. 말풍선 하나를 그릴 때마다 a만큼의 메모리를 사용한다고 하면, n개의 말풍선을 그리기 위해서는 총 a*n만큼의 메모리가 필요하죠. a와 n을 줄일 수 있다면 위의 두 가지 문제를 해결할 수 있을 것 같네요!
가상 스크롤
가상 스크롤(Virtual Scroll)이라고 불리는 기술이 있습니다. 화면에 보이는 DOM Element만을 그리고 남은 부분은 비워서 한 페이지에 그려야 하는 컴포넌트의 최대치를 지정해 줄 수 있는데요, 메시지의 수와 상관없이 정해진 양의 말풍선을 그릴 수 있어 메시지 데이터의 수가 늘어나더라도 일정한 수준의 성능을 유지할 수 있습니다. 위에서 사용한 공식을 적용하자면, n의 최댓값을 지정한다고도 할 수 있겠네요.

다만, 단순히 가상 스크롤 적용만으로 모든 문제가 해결되지는 않습니다. 화면에 그려지는 컴포넌트의 수를 줄이는 대신, 그만큼 DOM의 수정이 빈번하게 발생하기 때문입니다. 스크롤이 위아래로 움직일 때의 리렌더링을 최대한 억제하기 위해, React.memo를 이용해 컴포넌트를 메모이제이션하는 것이 도움이 됩니다.


그래프만으로도 성능이 극적으로 상승한 것을 확인할 수 있네요!
비제어 컴포넌트 사용 및 HTML 태그 교체
이번엔 각 컴포넌트가 렌더링 시 사용하는 메모리의 크기, a를 줄여서 성능을 개선해 봅시다.
우리가 일반적으로 React를 이용해 컴포넌트를 만들 때는, onXXX 형식의 프로퍼티를 주입해서 이벤트 리스너를 붙입니다. 이 방식으로 만든 컴포넌트를 제어 컴포넌트라고 부릅니다. 반대로, 이벤트를 붙일 때 ref 객체를 이용하여 바로 DOM에 이벤트 리스너를 붙일 때도 있는데 이런 방식을 사용하는 컴포넌트를 비제어 컴포넌트라고 부릅니다. 제어 컴포넌트에 비해, 비제어 컴포넌트는 React의 상태를 업데이트하지 않아도 된다는 특성이 있어 이렇게 성능 개선을 해야 할 때 자주 선택되는 방식입니다.
마지막으로, 만약 여러분이 유독 iOS에서만 인터랙션 블로킹을 경험한다면, 마지막으로 HTML의 button 태그를 span 태그로 변경하는 것도 고려해 볼 수 있습니다. button 태그는 span 태그에 비해 접근성, 스타일 등 적용해야 하는 기본 설정이 많기 때문에 span 태그 대비 렌더링에 1.5배 이상의 시간을 소모합니다(출처). button 태그를 span 태그로 교체하여 이 시간까지 아껴볼 수 있겠습니다. 접근성을 위해 role=”button” 필드를 붙이는 것을 잊지 마세요!

오픈채팅 Lite에서도 메시지가 도달할 때 순간적으로 유저 인터랙션이 버벅거리는 현상을 이 두 가지 방법을 이용해 크게 개선했습니다. 다만, 비제어 컴포넌트를 사용하는 방법, button 대신 span 태그를 사용하는 방법 모두 일반적으로 프론트엔드 개발자의 눈에 익숙한 사용법은 아니기 때문에, 오픈채팅 Lite에서는 이런 구현체를 안으로 숨긴 커스텀 Button 컴포넌트를 만들어 사용하고 있습니다.
설계를 변경하여 성능 개선하기
배경
앞에서 다룬 개선방안이 DOM 구조 및 말풍선 리렌더링과 연관된 사항이었다면, 이번에는 서버로부터 받는 메시지 데이터를 어떤 방식으로 관리하고, 말풍선으로 그릴 때 어떻게 처리해서 성능을 향상했는지 살펴보고자 합니다.
앞서 설명드렸듯 말풍선을 화면에 그릴 때, 유저 인터랙션에 제 때 반응하지 않거나 채팅 목록 화면이 깜빡이는 문제점이 있었는데요, 이번 장에서는 이 문제가 왜 발생했는지, 그리고 어떻게 이를 해결할 수 있었는지 알아봅시다.
원인
오픈채팅 Lite는 서버로부터 웹소켓을 통해 사용자들의 메시지 데이터를 전달받습니다. 메시지 데이터를 화면에 동기적으로 그리는 renderBubble이라는 함수가 있다고 해 봅시다. 만약 1초에 100건 이상 메시지가 들어온다면, renderBubble도 그만큼 반복적이고 다량으로 호출될 것입니다. 자바스크립트 단일 스레드 특성상 콜스택이 높은 빈도의 renderBubble 호출 및 처리로 점유되고 있는 상황에서는 다른 작업들을 처리할 시간이 부족해집니다.
콜스택이 비워지지 않으면 이벤트 루프는 태스크 큐에서 대기 중인 작업(이벤트 핸들러 등)을 콜스택으로 옮길 수 없기 때문에, 버튼 클릭과 같은 사용자 인터랙션이 지연될 수 있습니다. 이로 인해 사용자는 화면이 멈춘 듯한 느낌을 받을 수 있죠.

문제점이 발생했던 이유를 요약하면 다음과 같습니다.
renderBubble함수가 연속적으로 호출되는 상황에서 콜 스택이 과부하되면, 렌더링 작업이 지연되어 말풍선이 제대로 그려지지 않을 수 있습니다.- 콜 스택이 계속해서 점유되고 있는 상황이므로, 버튼 클릭 이벤트가 태스크 큐에 머무르며 대기하지만 결국 콜 스택으로 옮겨져 처리되지 않아 UI 반응이 응답하지 않는 것처럼 느껴질 수 있습니다.
const renderBubble = (data) => {...}
// 웹소켓을 처리하는 custom hook
// 도착하는 message를 아래와 같이 바로 그려주면 이슈 발생 가능
useSocket('msg', (data) => {
renderBubble(data);
})
해결 방안
Task Queue로 스케줄링
함수는 호출 시 콜 스택에 쌓이고, 비동기 작업이 있을 경우 task queue로 이동합니다. 그리고 콜 스택이 비워졌으면 이벤트 루프는 task queue에서 대기하던 작업을 콜 스택으로 이동시켜 실행합니다.
따라서 renderBubble 함수를 setTimeout, setInterval과 같은 비동기 작업으로 처리되게 하면 콜 스택에 여유가 생기게 되고, 콜 스택이 비어있는 시간이 생기면 자연스럽게 다른 작업들도 콜 스택에 옮겨져 수행될 수 있는 기회가 늘어날 것이라 판단하였습니다.
실제로 렌더 함수를 비동기 처리될 수 있도록 전환한 뒤 동일한 스펙으로 스트레스 테스트를 진행한 결과, 말풍선이 제대로 그려지지 않거나 유저 터치가 무시되는 현상이 상당히 개선되었습니다.

MessageQueue 도입
사용자 인터랙션이 블로킹되는 현상은 개선되었지만, 단순히 비동기 처리로만 전환한다고 해서 문제가 완전히 해결되지는 않습니다. 예를 들어, 서버에서 수신한 메시지 속도와 화면에 그려지는 속도 간에 차이가 발생할 수 있습니다. 서버에서는 이미 수천 개의 메시지를 모두 전송했지만, 화면에서는 아직 말풍선이 모두 그려지지 않은 경우입니다.
이 문제의 원인은 setTimeout, setInterval 등의 딜레이 값에 있습니다. 딜레이에 설정된 시간마다 말풍선 렌더 함수가 실행되기 때문에, 서버에서 더 빠른 속도로 메시지가 들어올 경우 서버 데이터 응답 속도와 화면 렌더링 간의 간극이 더 커지게 됩니다.
여러 가지 상황을 논의한 결과, 서버에서 받은 메시지를 중간에 저장하는 Message Queue를 도입하기로 결정했습니다.

예를 들어 서버에서 온 10개의 메시지가 큐에 쌓였다면, setInterval의 딜레이가 경과한 후 큐에 쌓여 있는 메시지 데이터들을 여러 개 dequeue 하여, 한 번의 렌더 함수 호출로 append 혹은 prepend 하여 화면에 그려주는 것이죠.
이렇게 하면 서버 속도와 화면의 간극도 해소되고, 말풍선을 그리는 함수를 여러 번 호출할 필요가 없어지므로 일종의 배치 처리(Batching)를 하여 추가적인 성능 개선을 도모할 수 있습니다.
socket.addEventListener('message', (event) => {
// 받은 메시지를 메시지 큐에 enqueue
MessageQueueService.enqueue(event.data);
});
// In MessageQueueService
setInterval(() => {
if (queue.length > 0) {
const messages = this.dequeueMany();
renderBubble(messages);
}
}, this.delay);
비동기 작업을 처리하는 방법으로 setTimeout, setInterval 외에도 requestIdleCallback, requestAnimationFrame(이하 rAF) 같은 브라우저 API도 존재합니다. 이들 각각은 dequeue 로직에 적합한지 추가적으로 파악해 보았습니다.
requestIdleCallback은 브라우저가 idle 상태일 때 실행할 함수를 대기열에 추가하는 방식(출처)으로 동작합니다. 이 API는 우선순위가 낮은 작업을 처리할 때 유용하지만, 브라우저 부하 상태에 따라 실행 타이밍이 보장되지 않는 한계가 있습니다. 메시지 데이터를 화면에 렌더링하는 작업은 우선순위가 높은 작업이므로 requestIdleCallback의 목적과는 달랐습니다. 또한, dequeue 로직은 일정한 주기로 큐를 확인하고 데이터를 처리해야 해서 idle 상태에 실행되는 requestIdleCallback과는 맞지 않았습니다. 무엇보다도 글을 작성한 날짜 기준 iOS Safari에서 아직 지원되지 않아 고려 대상에서 제외했습니다.
requestAnimationFrame(이하 rAF)은 화면의 프레임 주기와 동기화되어 콜백을 실행합니다. 예를 들어 60Hz 모니터에서는 약 16.66ms 간격으로, 144Hz 모니터에서는 약 6.94ms 간격으로 실행됩니다. 이렇게 일정한 간격으로 실행되는 특성이 있기에 큐를 주기적으로 확인하는 것은 가능합니다. 하지만 모든 사용자에게 동일한 dequeue 주기를 적용하기가 번거로워집니다.
또한 rAF는 주로 화면 프레임 주기와 동기화된 애니메이션 작업에 적합하지만, dequeue 및 말풍선 렌더 작업은 서버에서 받는 데이터의 속도에 의존하므로 화면의 프레임 주기와는 무관한 작업이었습니다.
주의점
단순하게 함수의 로직을 setInterval, setTimeout 등으로 감싸기만 한다면 문제가 생길 수 있습니다.
작업들이 task queue로 이동은 하겠지만, 빠르게 메시지를 수신하는 경우 매번 새로운 timer가 등록되는 것과 마찬가지이므로 아래처럼 메모리 점유 상태와 CPU Usage가 폭발적으로 상승할 수 있습니다. 아래 Main 항목에 가득 찬 작업들은 모두 Timer Fired 항목입니다.

그러므로 메시지를 수신할 때마다 새로운 setInterval, setTimeout 을 호출하는 것이 아니라, 전체적으로 단일 timer를 유지할 수 있도록 하거나, 새로운 타이머를 설정하기 전에 timer를 clear하는 등의 조치가 반드시 필요합니다.
결론
스트레스 테스트를 진행하고 해결 방법들을 논의하면서, 단순히 서버에서 받은 데이터를 그대로 렌더링하면 성능 저하를 피할 수 없음을 알게 되었습니다.
서버가 아무리 빠르고 지연 없이 빠르게 응답을 내려주더라도, 프론트엔드에서 데이터 처리 및 화면 렌더링 작업이 원활하지 않으면, 최종적으로 보이는 화면에 표시되는 내용이 지연되거나 유저의 터치가 무시되는 등의 문제가 발생하여 사용자 경험에 직접적으로 악영향을 미치게 됩니다.
이러한 문제점들을 분석하고 해결하기 위해서는 JavaScript의 Event Loop 동작 방식을 깊이 이해하고, 메시지 렌더링 방식의 효율적인 처리에 대해 고민하는 것이 필수적입니다.
참고 : https://ko.javascript.info/event-loop
상태관리 라이브러리(zustand) 성능 개선
useBubbleAction 훅 개선(zustand 성능 개선)
개요
useBubbleAction은 채팅 말풍선에 대한 신고, 삭제 버튼 클릭 등 유저 말풍선 내에서 취하는 액션을 처리하는 훅입니다. 이 훅에서 역시 몇 가지 사소한 변경을 통해서 성능을 개선시킬 수 있습니다.
문제점
아래는 기존의 useBubbleAction 훅 코드입니다.
export const useBubbleAction = (message: Message) => {
const chatMap = useChatMapStore((state) => state.chatMap);
const currentChat = chatMap.get(message.chatRoomId) as Chat;
const handleDeleteClick = () => {}
const handleReportClick = () => {}
const handleBlockClick = () => {}
return {
handleDeleteClick,
handleReportClick,
handleBlockClick
};
}
언뜻 보면 문제가 되지 않아 보일 수 있습니다. 하지만 useChatMapStore에서 chatMap 전체를 가져오는 것에서 문제가 발생합니다. 여기서 chatMap은 모든 채팅방에 대한 메시지의 모든 데이터를 저장하는 Map이기 때문에 당연히 큰 데이터를 지니는 경우가 많습니다. 하지만 모든 채팅방의 데이터를 각각의 메시지 말풍선이 지닐 경우 사용하지도 않는 불필요하게 많은 데이터를 가져오는 게 아닐까요?

문제 해결
문제를 해결하는 방법은 엄청 간단합니다. chatMap 전체를 가져오는 대신 필요한 채팅방 데이터만 직접 가져오는 방식으로 개선할 수 있습니다.
// AS-IS
const chatMap = useChatMapStore((state) => state.chatMap);
const currentChat = chatMap.get(message.chatRoomId);
// TO-BE
const currentChat = useChatMapStore((state) => state.chatMap.get(message.chatRoomId));
성능 개선 효과
위의 간단한 수정이 과연 성능에 영향을 주었을까요? 코드를 수정한 뒤에 확인한 성능 개선 결과는 다음과 같습니다.
- 성능 개선 전

- 성능 개선 후

위 성능 그래프를 통해서 확인할 수 있듯이 리소스 소비가 크게 감소하였습니다. 단순히 원하는 데이터만 바로 가져왔을 뿐인데 성능 차이가 이렇게 크다니 대단하지 않나요?
리액트 훅에서 바닐라 유틸 함수로의 전환
배경
페이지에 처음 접근했을 때는 문제가 없었지만 말풍선이 점차 렌더링 될 때마다 페이지가 점점 느려지는 현상을 발견했습니다. 원인 파악을 위해서 성능 측정을 해보니, 모달을 생성하는 데 사용하는 훅인 useModal에서 이상하리만치 과도하게 높은 리소스를 소모하는 것을 확인할 수 있었습니다.

문제점
useModal 훅은 많은 종류의 모달을 열 수 있는 함수들의 집합체로 페이지에서 사용하는 모든 모달을 생성하는 함수들이 포함되어 있었습니다. 여기서 문제는 이 훅을 모든 채팅 말풍선 컴포넌트에서 사용하는 부분에 있었는데요, 메시지 하나를 렌더링할 때마다 useModal 훅이 호출되고 사용하지도 않는 모든 모달을 생성하는 함수들을 새로 정의하게 되면서 성능 저하를 초래하였습니다.
해당 문제를 더 뚜렷이 확인을 할 수 있었던 이유는 앞서 언급했던 가상 스크롤을 적용한 데에 있었는데요, 스크롤을 하면 채팅 말풍선이 빈번하게 생성 및 삭제되기 때문에 훅을 더욱 빈번하게 콜을 했었기에 성능저하를 더욱 체감하기 쉬웠습니다.
이러한 문제를 해결하려면 어떻게 해야 할까요?
해결 방안
해결을 하기 전에 모달을 여는 함수를 바로 만들지 않고 useModal 훅으로 만들었던 이유를 한 번 살펴볼까요? useModal 코드를 살펴보니 i18next의 useTranslation 훅과 zustand 스토어를 사용하는 부분이 존재하네요. 혹시 이 두 개의 훅을 제거하면 useModal는 훅의 형태로 유지하지 않아도 되지 않을까요?
코드 개선 전
export const useModal = () => {
const {t} = useTranslation();
const openModal = useModalStore((state) => state.openModal);
const openAlertModal = openModal({type: 'alert'});
const openConfirmModal = openModal({type: 'confirm'});
const openCustomModal = openModal({type: 'custom'});
const openHelloModal = openAlertModal({text: t('Hello')});
const openWorldModal = openAlertModal({text: t('World')});
const openErrorModal = openAlertModal({text: t('Error')});
const openChooseModal = openConfirmModal({text: t('Choose')});
const openUserModal = openCustomModal({text: t('User')});
/** 이하 40개 이상의 모달들 */
return {
openHelloModal,
openWorldModal,
openErrorModal,
openChooseModal,
openUserModal,
/** 이하 40개 이상의 모달들 */
};
}
1. useTranslation 대체
가장 먼저 useTranslation 훅을 제거해 볼까요?
useTranslation 대신 t를 직접 import해서 사용한다면 useTranslation 없이 번역을 할 수 있습니다.
// AS-IS
const {t} = useTranslation();
const text = t('Hello');
// TO-BE
import {t} from 'i18next';
const text = t('Hello');
2. Zustand 스토어의 useModalStore 대체
useModalStore는 모달의 열림과 닫힘 상태를 관리하는 저장소입니다. 이 저장소에서 모달의 정보를 저장하여 모달을 열고 정보를 지워서 모달을 닫을 수 있습니다.
그러면 생기는 궁금증이 있을 겁니다. useModal에서는 useModalStore 훅을 무엇을 위해서 사용하고 있을까요? 코드를 확인해 보면 useModal에서는 저장소의 상태를 가지고 렌더링을 하는 것이 아니라 상태를 변경하기 위한 함수들만 사용하고 있습니다. 다시 말해서 상태를 유지할 필요가 없이 함수를 호출할 수 있도록 하면 됩니다.
이러한 방식은 zustand의 getState() 메서드를 활용하여 해결할 수 있습니다.^1^ 해당 메서드로 값을 가져올 경우 상태 변화를 감지를 못하지만 우리는 상태를 변경하는 함수만 가져올 거고 그 함수 자체는 상태가 변화하지 않기 때문에 모달을 여닫는데 문제가 없습니다.
코드 개선 후
t를 직접 사용하는 방식과 getState를 통해서 저장소의 값을 수정하는 함수를 사용할 경우 아래와 같이 훅을 전부 빼낼 수 있게 됩니다.
// 유틸
import {t} from 'i18next';
const {openModal} = useModalStore.getState();
const openConfirmModal = openModal({type: 'confirm'});
export const openAModal = openConfirmModal({text: t('A')});
export const openBModal = openConfirmModal({text: t('B')});
// 사용처
import {openChooseModal} from '@/modal';
const handleClick = () => {
openChooseModal();
}
성능 개선 결과
- 성능 개선 전

- 성능 개선 후

위의 예제와 같이 동일한 로직을 가지면서 불필요한 훅을 제거하여 성능을 개선할 수 있습니다. 이런 사소한 변경을 통해서 렌더링 시 불필요한 함수 생성을 줄이고 전체적인 성능을 향상 시킬 수 있습니다!
결론

위와 같이 언뜻 보면 쉽게 놓칠 수 있는 부분들을 수정해도 성능을 크게 개선시킬 수 있습니다. 오픈채팅 Lite의 경우 앞서 설명드린 변화를 통해서 70점대였던 파루스(라이트하우스 기반 성능 측정) 점수를 90점대로 대폭 상승시켰습니다.
마치며…
이렇게 여러 가지 방법으로 각각 서비스의 렌더링 성능을 개선해 볼 수 있었습니다. 초기 개발 중엔 성능이 낮을 때 사용성이 크게 떨어지는 것을 보며 난감해하기도 하고 이렇게 열심히 성능을 깎을 필요가 있나 의구심이 들기도 했는데요. 돌아보니 이렇게 미리미리 테스트해 보며 대응했던 덕에 신년 맞이 채팅방, 아시안컵, 올림픽 채팅방 등 높은 트래픽을 보여주는 채팅방에서도 큰 불편함 없이 유저들이 즐겁게 저희 서비스를 사용해 주신 것 같아 보람차네요.
앞으로도 다양한 곳에서 여러분의 즐거운 대화를 위해 노력하는 오픈채팅 Lite가 되겠습니다. :)

^1^ zustand github: https://github.com/pmndrs/zustand