AI를 활용한 Web 성능개선 실전 사례

한달 걸릴 작업을 AI를 사용해서 하루만에 임팩트 내기

Mong • Engineer

  • 엔지니어링

성능개선은 타이밍

개발하면서 성능을 항상 챙기기는 어렵습니다. 기능을 만들고, 버그를 고치고, 일정을 맞추다 보면 성능 부채는 조금씩 쌓입니다. 그리고 어느 순간 UI가 느려지죠. 필연적입니다.

그래서 저는 모니터링을 꾸준히 하든, 유저에게 "느리다"는 피드백이 들어오든 간에, 문제가 드러난 그 순간에 집중해서 고칩니다. 미리 최적화하겠다고 코드를 복잡하게 만드는 것보다, 문제가 보일 때 집중해서 잡는 쪽이 낫다고 생각합니다. 저희는 채널톡을 팀 메신저로 활용하고 있어서, 실 유저로써 체감을 함 수 있습니다.

채널톡 관리자 페이지(이하 Desk)는 실시간 채팅 SaaS입니다. 수백 개의 방, WebSocket 상시 연결, 매 초 쏟아지는 소켓 이벤트. 이 정도 복잡도의 앱에서 성능 부채가 쌓이면, 원인이 한 곳에 있는 경우가 드뭅니다.

성능 문제를 추적할 때 React DevTools Profiler를 먼저 여는 분이 많습니다. 컴포넌트별 렌더 소요 시간, 리렌더 원인을 보여주니까 합리적인 선택이죠. 하지만 Profiler는 React의 commit phase 안에서 일어나는 일만 봅니다.

Redux saga가 소켓 이벤트를 받아서 store를 8번 갈아치우는 과정은 saga 미들웨어의 제너레이터 루프에서 실행됩니다. React render cycle 바깥이죠. Profiler에는 마지막 dispatch가 트리거한 리렌더만 찍힙니다. Worker.postMessage()로 657번 메시지를 주고받는 structured clone 직렬화 비용도 마찬가지입니다. 플랫폼 레벨 비용이고, Profiler의 시야 밖입니다. hook 안의 O(n²) 탐색은 증상은 보입니다 - "이 컴포넌트 렌더에 200ms 걸렸다." 하지만 어떤 함수가, 어떤 루프가 시간을 먹는지는 안 보이죠.

그래서 저는 flame chart를 선호합니다. Chrome DevTools Performance 탭은 JS 전체 call stack을 기록합니다. saga 내부 실행, postMessage의 직렬화 비용, task queue와 microtask queue의 인터리빙까지 다 보입니다. 50ms를 넘기는 Long Task를 잡아내고, bottom-up call tree로 hot function을 정확히 짚을 수 있습니다. Desk처럼 복잡한 앱에서는 문제가 컴포넌트 트리 바깥에 있는 경우가 많아서, flame chart 를 보면서 성능개선을 주기적으로 진행하고 있습니다.

Chrome DevTools Performance의 flame chart 예시.

2. 사람의 역할이 바뀌었다

원래 성능개선은 이런 과정입니다. flame chart를 열고, 의심 가는 구간을 눈으로 찾고, 코드를 따라가며 원인을 좁혀갑니다. 예를 들어 Cmd+K 네비게이션에서 Long Task가 보이면, 그 안에서 가장 넓은 블록의 함수명을 확인하고, 소스맵을 따라가서 원본 파일을 열고, 해당 함수가 왜 느린지 코드를 한 줄씩 읽습니다. .find()가 루프 안에 있는 걸 발견하면 "아, 이거 O(n²)이네" 하고 깨닫는 식이죠. 경험과 감이 필요한 작업이고, 시간이 오래 걸립니다.

이 디깅 과정이 많이 축소됐습니다. Claude Code와 Codex가 의심 지점을 잘 잡아줍니다. flame chart에서 핫패스가 보이면 해당 구간의 함수명이나 파일명을 AI에게 넘깁니다. "이 훅이 왜 느린지 분석해봐." AI가 코드베이스를 훑고, 호출 경로를 추적해서 병목 원인을 찾아냅니다. HAR 파일의 WebSocket 메시지를 통째로 넘기고 "불필요한 소켓 이벤트 패턴 찾아봐"라고 하면, 사람이 로그를 한 줄씩 읽을 필요 없이 패턴이 정리돼서 나옵니다.

그러면 사람이 할 일은 뭘까요. 어디가 UX 관점에서 크리티컬 패스인지 정의하고, 우선순위를 설정하는 것입니다. flame chart의 의심 지점을 찾는 건 AI도 잘합니다. 하지만 "이 구간이 유저에게 정말 체감되는가", "이걸 먼저 고쳐야 하는가"는 제품을 아는 사람이 판단해야 합니다.

제 워크플로우는 이렇습니다. 크리티컬 패스에서 레코딩을 하고, flame chart에서 병목 위치만 대충 잡습니다. 깊이 파는 건 AI 몫이죠. 제가 생각한 것과 다른 방향이 나올 때도 있지만, 정량 지표 기반 분석은 확실히 잘합니다. "이 함수가 전체 Long Task의 60%를 차지한다"는 식으로 수치를 뽑아주니까, 감으로 우선순위를 정하던 과정이 줄었습니다.

참고로, 최근 Karpathy가 제안한 autoresearch(AI 에이전트가 정량 지표를 기준으로 실험 루프를 자율 반복하는 패턴) 방식을 바탕으로, Datadog MCP와 Chrome DevTools Protocol(CDP)을 사용해 유저 크리티컬 패스를 유추하고 성능 측정부터 개선까지 자동 루프를 돌리는 실험도 해봤는데, 꽤 괜찮은 결과가 나왔습니다. 이건 다음에 별도로 다뤄보려고 합니다.

하루 동안 이 워크플로우로 여러 가지 개선을 진행했고, 소개할 만한 건 아래 세 가지입니다.

3. 사례들

사례 1. O(n²) → O(n): 배열 순회의 함정

팀챗 사이드바가 느렸습니다. flame chart를 떠보면 useSectionizedItems라는 훅이 매번 핫패스에 찍힙니다. 팀챗 목록을 섹션별로 구성하는 훅인데, 방이 수백 개인 환경에서 방을 이동할 때마다 실행됩니다.

답은 명쾌했습니다. .map() 안에서 .find()를 돌리고 있었거든요.

TypeScript

전형적인 O(n²) 패턴입니다. 데이터가 적을 때는 문제없지만, 수백 개가 되면 체감됩니다. 같은 파일에 .reduce()에서 [...acc, item] spread로 누적하는 패턴도 있었습니다. 이것도 O(n²)입니다. 매 iteration마다 배열 전체를 복사하기 때문이죠.

TypeScript

해결은 교과서적입니다. 순회 전에 Map을 만들어서 O(1) lookup으로 바꾸고, spread 누적을 push로 교체합니다.

TypeScript
TypeScript

CDP flame chart 기반으로 비교하면 팀챗 리스트 섹션 연산이 399ms → 72ms로 82% 개선됐습니다. (source-map 을 켜놓고 진행해서 production 환경보다 느립니다.)

기초적이지만 코드 리뷰에서 놓치기 쉽습니다. 개발 환경에서는 데이터가 적어서 티가 안 나기 때문이죠. 그러다 프로덕션에서 데이터가 쌓이면 느려집니다.

실제로 바꾼 것들:

  • allSessions.find(), directChats.find(), groups.find() → 각각 Map 기반 O(1) lookup

  • sectionizedChatItems.some()Set.has()로 membership check

  • [...acc, item] spread 누적 → acc.push(item)

  • concat()으로 새 배열 생성 → useMemo 안에서 slice() + push()로 안정 참조

사례 2. 657번의 Worker round-trip → 배치 1번

방을 이동하면 메시지를 파싱합니다. 채널톡은 메시지 블록(텍스트, 코드, 이미지 등)을 구조화된 노드로 변환하는 파서를 Web Worker에서 돌립니다. 메인 스레드 블로킹을 피하기 위해서죠.

그런데 파서가 텍스트 줄마다 Worker.postMessage()를 개별 호출하고 있었습니다. 메시지 하나에 줄이 10개면 10번, 메시지가 50개면 그 10배. 한 번의 방 이동에서 657번의 Worker round-trip이 발생하고 있었습니다.

Plaintext
Main Thread          Worker
    |--- postMessage(line1) -->|
    |<-- result1 --------------|
    |--- postMessage(line2) -->|
    |<-- result2 --------------|
    |  ... (×657)              |

문제는 round-trip 횟수 자체만이 아닙니다. Worker 통신 래퍼가 요청마다 listener를 등록하는 구조라서, 657개 요청이 동시에 걸리면 reply마다 모든 pending listener가 UUID를 비교합니다. 요청 수의 제곱에 비례하는 오버헤드가 생기죠.

해결은 줄 단위 요청을 모아서 한 번에 보내는 것입니다.

TypeScript

657번의 round-trip과 657개 listener가 각각 1로 줄었습니다. trace 비교 결과, 전체 블로킹이 5,608ms → 4,144ms로 26% 개선, GC는 390ms → 152ms로 61% 감소했습니다.

사례 3. 소켓 이벤트 폭탄 제거반

실시간 앱은 소켓 이벤트와 함께 삽니다. 누군가 타이핑을 시작하면 이벤트가 오고, 방 정보가 바뀌면 이벤트가 오고, 유저가 접속하면 이벤트가 옵니다. 문제는 이 이벤트를 전부 그대로 store에 반영하고 있었다는 겁니다.

근본적으로는 socket room을 나눠서 필요한 이벤트만 수신하면 됩니다. 하지만 지금 당장 서버 구현체를 건드려서 수정을 나가는 건 염두에 두고 있지 않았습니다. 클라이언트에서 할 수 있는 선에서 최대한 줄이는 게 목표였습니다.

중복 제거 (Dedup)

채팅방을 나갈 때 typing stop 이벤트가 8번 발신되고 있었습니다. 같은 chatId, 같은 chatType. 원인은 방을 나가면서 여러 컴포넌트가 각각 cleanup하며 stop을 보내기 때문이었습니다.

TypeScript

8번이 1번이 됐습니다.

필터링 (Filter)

수신 쪽에서도 문제가 있었습니다. 다른 방에서 누군가 typing을 시작하면 그 이벤트가 와서 store를 업데이트하고 있었거든요. 지금 보고 있지 않은 방의 "입력 중..." 표시를 store에 넣을 이유가 없습니다.

TypeScript

배치 (Batch)

update:group 이벤트가 55건이 연속으로 들어왔습니다. AI가 분석한 결과, 55건 전부 updatedAt 타임스탬프만 바뀌어 있었습니다. 제목, 아이콘, 설명 같은 시각적 필드는 변경 없음. 그런데 55번의 store update가 일어나고, 55번의 리렌더가 트리거됐습니다. 실질적 UI 변경은 0건이 됩니다.

TypeScript

hasGroupVisualChanges는 title, icon, description 등 실제 화면에 그려지는 6개 필드만 비교하는 함수입니다. updatedAt 같은 메타데이터 변경은 무시합니다.

이건 변경 감지를 통한 불필요 업데이트 스킵입니다. 한편, 빈도 자체를 줄이는 방법도 있습니다.

user/manager online 이벤트는 유저가 접속할 때마다 건건이 store를 업데이트하고 있었습니다. 이건 시간 윈도우로 묶어서 한 번에 dispatch하는 방식으로 해결했습니다. SizedBufferActionQueue라는 유틸리티로 시간 윈도우와 최대 버퍼 크기(64개)를 설정하면, 일정 시간 안에 들어온 이벤트를 모아서 배치 처리합니다.

TypeScript

합산하면, typing stop 8회 → 1회, 비가시 채팅 typing store update → 0회, online 이벤트 30건 개별 처리 → 1건 배치, group updatedAt-only 55건 → 0건 으로 최적화했습니다.

이 세 가지 패턴(dedup, filter, batch) 은 실시간 앱에서 반복적으로 나타나는 문제입니다. 소켓 이벤트가 도착했다고 전부 store에 넣으면 이벤트 수만큼 리렌더가 일어납니다. 프로덕션에서 동시 접속자가 많아지면 이벤트 수는 선형으로 증가하고, 성능은 거기에 비례해서 나빠지죠.

마치며

프로덕션 정량 지표를 별도로 측정하지는 않았습니다. 사실 했으면 더 좋았겠죠. 하지만 다음 날 팀원에게서 이런 메시지가 왔습니다.

최근 개발자분들이 Claude Code를 터미널로 여러 개 띄워 사용하면서 컴퓨터가 느려지는 현상을 많이 겪고 있었거든요. 그 와중에 체감될 정도면 의미 있는 개선이었다고 생각합니다. 기존에는 숙련된 엔지니어가 문제를 발굴하는 데만 몇 주 걸렸던 일을, 하루 만에 잡아냈으니까요.

We Make a Future Classic Product