아무도 손 못 댔던 채팅, 뜯어고치기까지 — 1편
.md100개가 넘는 파일이 의존하는 상태와 수백 줄짜리 WebSocket 핸들러를, 멈추지 않고 바꾸는 법
안녕하세요, 스터닝 프론트엔드 개발자 이희찬입니다.
이번 글에서는 LOUD 채팅을 리팩토링하면서 겪은 구조 개선 이야기를 정리해보려 합니다.
채팅은 LOUD에서 가장 복잡한 도메인 중 하나입니다. WebSocket 기반의 실시간 통신 위에 다양한 메시지 타입, Deal(거래) 상태, 파일 전송까지 한 화면에 얽혀 있습니다.
문제는 이 복잡성이 시간이 지나며 그대로 쌓였다는 점이었습니다. 어느 순간부터는 기능이 아니라, “어디를 건드리면 어떤 일이 터질지 모르는 상태” 가 되어 있었습니다.
👀 겉으로 보이던 증상들
- 모든 WebSocket 이벤트를 하나의 함수에서 처리하는 수백 줄짜리 메시지 핸들러
- 채팅 / 딜 / 메시지 / 스크롤 상태가 하나의 Redux 스토어에 섞여 작은 변경에도 전체 리렌더 발생
- 채팅방이 많아질수록 눈에 띄게 느려지는 리스트와 스크롤 에러
- 북마크, 읽음 상태, 견적 단계 등 실시간 동기화가 어긋나는 문제들
이 문제들은 각각 따로 보면 다른 버그처럼 보입니다. 하지만 하나로 묶어보면 결국 같은 원인이었습니다.
👉 잘못 설계된 상태 관리 구조 + 뒤엉킨 실시간 이벤트 흐름
🤔 왜 이제서야 고치게 되었나
사실 이 작업은 오래 전부터 알고 있던 문제였습니다.
“언젠가 정리해야지”
하지만 우선순위에서 계속 밀렸고, 그 사이에 임시 패치들이 쌓였습니다. 그러다 어느 시점부터는 패치로도 더 이상 막히지 않는 상태가 됩니다.
ChatList 한 파일이 880줄, 그 안에서 useSelector와 dispatch가 11곳에서 Redux를 건드리고 있었어요.
- 버그가 반복적으로 발생하고
- 원인을 추적하는 시간이 점점 늘어나고
- 수정할 때마다 다른 곳이 깨지기 시작합니다
이때부터는 확실해집니다.
👉 “이건 기능 문제가 아니라 구조 문제다”
이 글은 그 구조를 어떻게 바꿨는지, 그리고 멈추지 않고 바꾸기 위해 어떤 전략을 썼는지에 대한 이야기입니다. 이 글에서 다루는 네 영역의 변화를 한 장으로 보면 이렇게 됩니다.

1. 전략: 한 번에 바꾸지 않는다
“Redux를 Zustand로 바꾸자”는 말은 쉽지만, 실제로는 100개 파일의 useSelector 호출을 동시에 바꿔치기하는 배포를 뜻합니다. 한 PR로 내면 리뷰가 불가능하고, 한 번에 배포하면 롤백 단위가 너무 커요.
필요한 건 “바꾸지 않고 바꾸는 방법” 이었습니다.
1.1. 소비 지점을 한 훅 뒤로 모은다
레거시 컴포넌트들은 각자 useSelector로 Redux를 직접 구독했고, 가져온 데이터를 다루는 로직이 호출부마다 흩어져 있었습니다.
먼저 한 일은 모든 소비자가 같은 훅 하나를 거치게 만드는 것. 이름은 useDeal, 내부는 Feature Flag로 분기합니다.
function useDeal() {
const zustandDeal = useZustandStore(...); // 항상 호출
const reduxDeal = useSelector(...); // 항상 호출
return flag.on ? zustandDeal : reduxDeal;
}
소비자는 내부가 Redux인지 Zustand인지 모릅니다. useDeal()만 호출하면 돼요. 컴포넌트가 할 일은 둘 중 하나로 좁아집니다 — 신규는 처음부터 useDeal() 호출, 레거시는 useSelector(...) → useDeal() 로 한 줄씩 치환.
두 훅을 항상 호출하는 건 React의 Rules of Hooks 때문입니다. 플래그가 꺼졌다고 한쪽 훅을 건너뛰면 매 렌더의 훅 호출 순서가 깨지거든요. 그래서 양쪽을 모두 호출하고 결과만 고릅니다. (사용하지 않는 쪽 store가 바뀌면 어댑터를 쓰는 컴포넌트도 리렌더되긴 하지만, 마이그레이션 기간엔 양쪽이 같은 데이터를 들고 있어 실무상 추가 리렌더는 거의 없습니다.)
1.2. PR을 쪼갤 수 있는 구조가 된다
이 패턴의 가치는 코드가 예쁘다는 게 아니라 배포 단위가 쪼개진다는 것. 어댑터를 도입한 시점부터 플래그를 켜는 시점까지, 그 사이의 작업은 전부 “한 줄씩 치환하는 PR” 이 됩니다. 각 PR은 독립적으로 리뷰·배포·롤백 가능하고, 어느 단계에서 멈춰도 서비스는 정상 동작해요. 플래그가 꺼져 있는 동안엔 어댑터가 그냥 얇은 중간 레이어일 뿐이거든요.
같은 패턴을 채팅 리팩토링 전체에서 반복했습니다 — useDeal, useDealActions, usePeer, useSelectedChat 등. 어댑터의 마지막 단계는 자기 자신을 지우는 것이에요. 플래그 정리와 함께 레거시 경로가 떨어져 나가도록 단계별로 진행 중입니다.
2. 상태 재배치: 전역과 로컬, 그리고 생명주기
소비 지점을 한 훅 뒤로 모았으니, 이제 그 뒤를 바꿀 차례입니다. 문제는 단순히 “Redux냐 Zustand냐” 가 아니었습니다.
👉 이 상태를 어디에 두는 게 맞는가
레거시 구조는 답이 하나였습니다. 👉 “전부 전역” Deal.ts, Chat.ts, Message.ts 세 파일에 selectedChat, messageList, bookmark, readInfo 모든 상태가 들어 있었고, 로그인부터 로그아웃까지 계속 메모리에 남아 있었습니다. 그런데 이 상태들을 다시 보면 이상합니다.
👉 채팅 상세 화면을 벗어나면 대부분 필요 없는 값들이었습니다.
2.1. 생명주기로 줄을 세운다
✅ “이 상태는 언제부터 언제까지 살아야 하는가?”
이 질문 하나로 상태는 자연스럽게 두 무리로 나뉩니다.
A. 어디서나 필요한 상태 (전역)
dealdealTasks
👉 어드민 / 콘테스트 / 마이페이지 / 채팅 — 여러 화면에서 계속 참조되는 값
B. 특정 화면에서만 필요한 상태 (로컬)
- 현재 채팅방
- 메시지 목록
- 스크롤 위치
- 전송 중 파일
👉 채팅 상세를 벗어나면 의미가 사라지는 상태
이걸 나누지 않으면
- 이전 채팅방 상태가 다음 채팅방에 섞이고
- 필요 없는 데이터가 계속 메모리에 남고
- 상태 변경이 불필요하게 전역에 퍼집니다
2.2. 폴더 구조로 규칙을 드러내기

여기서 중요한 건 기술이 아니라 구조입니다.
👉 폴더 위치 자체가 규칙이 된다
새 상태를 만들 때 고민은 하나뿐입니다. 👉 “이 상태는 화면을 벗어나도 살아야 하는가?”
- YES → 전역
- NO → 로컬
참고로, 이 구조 자체는 Redux로도 만들 수 있어요. 다만 사내에서 이미 상태관리 라이브러리를 점진적으로 Zustand로 교체하고 있던 시점이라, 이번 채팅 리팩토링에서 같이 적용한 것에 가깝습니다. (서버 상태와 클라이언트 상태를 더 분리하는 작업은 일부분만 진행되고, 다음 영역으로 남겼어요.)
2.3. 상태의 생명주기를 컴포넌트에 맡기기
로컬 상태는 Context Provider로 감쌉니다. 이렇게 하면 페이지가 언마운트될 때 스토어도 함께 사라집니다.
덕분에 이전 채팅방의 상태가 남지 않고, 별도의 cleanup 코드도 필요 없습니다. 상태는 자연스럽게 GC 대상이 됩니다.
👉 컴포넌트의 생명주기가 곧 상태의 생명주기
이 구조 하나로, 상태 관리의 책임이 훨씬 명확해졌습니다.
2.4. 상태가 제자리를 찾으면 Props drilling은 사라진다
상태가 제자리를 찾으면 변화가 하나 더 생깁니다.
👉 자식 컴포넌트가 props 없이 직접 상태를 구독할 수 있다
이전에는 상위가 selector를 여러 개 모아 자식에게 prop으로 넘기는 형태였어요.
const commonProps = {
selectedChat, deal, isDealLoading, isMarketInquireChat, ...
};
return <ChatPanelContent {...commonProps} />;
이제는 필요한 컴포넌트가 직접 구독합니다.
const ChatPanelCommonContent = () => {
const isMarketInquireChat = useIsMarketInquireChat();
return isMarketInquireChat
? <MarketInquireSection />
: <DealInfoSection />;
};
더 이상 props로 상태를 전달할 필요가 없습니다. props drilling은 대개 상태가 잘못된 위치에 있다는 신호거든요. Context를 쓰기 전에 먼저 물어보는 게 빠릅니다.
👉 “이 상태, 지금 위치가 맞는가?”
이 질문 하나로 채팅 패널의 컴포넌트 트리는 눈에 띄게 단순해졌습니다.
3. WebSocket: Transport와 Domain을 분리한다
레거시 WebSocket 메시지 핸들러는 한 함수가 모든 이벤트를 처리했습니다. 새 이벤트가 추가될수록 로직은 계속 비대해졌고, dispatch가 직접 들어가 있어 핸들러를 독립적으로 테스트하기도 어려운 구조였습니다.
그래서 “메시지를 받는 역할”과 “받은 메시지를 처리하는 역할”을 분리했습니다. 이 과정에서 중요한 목표는 하나였습니다.
👉 WebSocket과 UI(상태 업데이트) 간의 관심사를 분리하고, 결합도를 낮추는 것
이를 위해 Event-Driven Architecture를 적용했습니다.
Transport (WebSocketManager) — 연결, 재연결, raw 메시지 파싱만 담당합니다. 파싱된 메시지는 타입 안전한 EventEmitter로 emit만 하고, 스토어는 전혀 알지 못합니다.
Domain (도메인별 handler 훅) — dealHandlers / messageHandlers / chatHandlers처럼 도메인별로 나누고, 각 핸들러는 자신의 스토어만 책임지고 업데이트합니다.

구조를 나누는 순간, 흐름이 단순해졌습니다.
👉 이벤트 흐름을 따라가는 대신, 구조를 보면 답이 보이게 됩니다.
4. Layout 재구조화, 그리고 Panel 해체기
상태와 이벤트 흐름을 정리하고 나니, 문제는 결국 컴포넌트 구조로 돌아왔습니다. 채팅 상세 페이지는 반응형 분기, 상태 Provider, WebSocket 연결, 도메인 UI가 하나의 트리 안에 뒤섞여 있었습니다.
4.1. 레이어를 책임별로 쌓다
신규 채팅 상세 페이지는 네 개의 레이어로 분해됩니다.

- Layout: 화면 구조와 배치
- State: 클라이언트 상태 관리
- Data Layer: API / WebSocket 등 외부 데이터 소스
- Domain: 실제 사용자에게 보이는 기능 UI
각 레이어는 아래 레이어에만 의존하도록 구조를 정리했습니다. 이전에는 이 네 가지가 하나의 컴포넌트 트리 안에 섞여 있었고, 이제는 “어느 레이어의 문제인지”가 디버깅의 시작점이 됩니다.
도메인 분기는 컴포넌트 단위로 나뉘었고, 그 결과 자연스럽게 번들 분리(lazy import)까지 이어졌습니다.
👉 구조만 봐도 문제의 위치가 보이기 시작했습니다.
4.2. 사이드 패널 해체기 — 훅은 데이터만, 컴포넌트는 UI만
이 리팩토링에서 가장 극적으로 바뀐 부분은 사이드 패널이었습니다.
레거시 ChatPanel.tsx는 937줄짜리 단일 파일이었고, UI, 데이터 페칭, Redux 바인딩, Chart.js 엔진, 반응형 분기가 모두 한 파일 안에 들어 있었습니다.
하지만 진짜 문제는 그 안에 있던 “이상한 훅”이었습니다.
훅 하나가 다섯 가지 일을 하던 시절
useConnectTaskItems — 이름은 훅이지만 실제로 하던 일은 이랬습니다.
- JSX 생성 (
<Button>,<Tooltip>,<StrapiText>등) - 모달 제어 (setter 6개 props로 전달)
- 거래 상태 머신 계산
- 조건 분기 처리
- UI 데이터 구성
const useConnectTaskItems = ({
setOpenReviewModal,
setSelectedAdditionalDealTask,
setOpenConnectTaskEndModal,
setOpenAdditionalRequestModal,
setOpenNewDealEstimateModal,
setOpenRequestModal, // ← 모달 setter 6개를 props로 받음
}: UseConnectTaskItemsProps) => {
// 거래 라이프사이클 상태 머신이 이 훅 안에 숨어있음
const dealStep = useMemo(() => {
if (dealState === DEAL_STATE.BEGIN) return DEAL_FLOW_STEP.BEGIN;
if (dealState === DEAL_STATE.ACTIVE && !dealTasks?.length) return DEAL_FLOW_STEP.NO_TASK;
// ...
}, [deal, dealState, dealTasks, hasNoPeer]);
// 훅이 JSX 배열을 반환함
const taskItems = useMemo(() => [
{
titleProps: { title: '...' },
contentProps: {
guideBox: <StrapiText id="..." />,
etcContent: (
<Button onClick={() => setOpenReviewModal(true)} ...>
<StrapiText id="..." />
</Button>
),
},
hidden: !endConfirm || !isClient,
},
// ...
], [/* 의존성 14개 */]);
return { taskItems };
};
이 훅 하나가 사실상 👉 컴포넌트 + 상태 머신 + 컨트롤러 역할을 동시에 하고 있었습니다.
이 훅을 사용하는 DealInfo.tsx도 복잡했습니다.
useState2개 (탭, 모달)useSelector3개useEffect로 상태 강제 초기화- DOM 직접 조작 (
querySelector)
// 지우면 버그 생김
setTabActiveIndex(0);
// 이래야 동작함
document.querySelector('.item.active')?.classList.add('request-btn');
주석이 모든 걸 설명합니다.
👉 “지우면 버그 생김” 👉 “이래야 동작함”
원인은 모르지만, 건드리면 깨지는 상태. 전형적인 레거시 코드의 모습이었습니다.
해체의 원칙: 훅은 데이터만, 컴포넌트는 UI만
핵심은 단순했습니다. 👉 역할을 기준으로 분리한다
1. 규칙을 데이터로 분리 (tasksByRole.ts)
const CLIENT_TASKS: TaskDefinition[] = [
{ type: ContestTaskType.SELECTED,
condition: { states: [DEAL_TASK_STATE.BEGIN] } },
{ type: ContestTaskType.DEAL_REVIEW,
condition: { states: [DEAL_TASK_STATE.END] } },
{ type: ContestTaskType.ADDITIONAL,
condition: {
states: [DEAL_TASK_STATE.ACTIVE, DEAL_TASK_STATE.END],
hasAdditionalTasks: true,
} },
// ...
];
👉 어떤 조건에서 무엇을 보여줄지를 “코드”가 아니라 “데이터”로 표현
2. 도메인 훅은 계산만 담당 (useContestDealTasks)
const useContestDealTasks = () => {
return tasksByRole.filter(...);
};
👉 상태 + 규칙 → 표시할 목록 계산
3. 컴포넌트 매핑은 정적으로 (taskComponentMap)
const taskComponentMap = {
DEAL_REVIEW: DealReviewSection,
// ...
};
4. 렌더 컴포넌트는 그리기만
👉 조건을 모른 채, 받은 데이터만 렌더링
tasks.map(task => {
const Component = taskComponentMap[task.type];
return <Component key={task.type} />;
});
그 결과 DealInfo.tsx는 27줄이 됐습니다.
useState❌useEffect❌useSelector❌- DOM 조작 ❌
남은 건 단 하나입니다. 👉 “딜 타입에 따라 컴포넌트를 선택하는 것”
진짜 바뀐 건 “조건”의 존재 형식
레거시에서는 UI와 비즈니스 로직이 분리되지 않은 채 함께 섞여 있었습니다. 아래와 같은 조건이 JSX 옆에 붙어 있었고, 어떤 조건에서 노출되는지 이해하려면 TypeScript 제어 흐름을 머릿속으로 실행해야 했습니다.
// before
hidden: dealStep < DEAL_FLOW_STEP.HAS_TASK || isDesigner
// after
{ type: DEAL_REVIEW, condition: { states: [END] } }
신규 구조에서는 이 조건을 코드가 아니라 데이터로 분리했습니다. 조건이 데이터로 분리되면서, 기획자와 공유하고 QA 명세로 활용할 수 있게 됐습니다.
마치며
채팅을 멈추지 않고 갈아엎기 위한 네 가지 작업 — 어댑터로 소비 지점을 모으고, 상태를 생명주기로 재배치하고, WebSocket을 Transport와 Domain으로 분리하고, Panel을 데이터와 UI로 해체한 것 —
이 모든 작업은 결국 하나의 질문에서 출발했습니다.
“이 로직은 지금 위치에 있는 것이 맞는가?”
소비 지점이 잘못된 곳에 있어서 100개 파일을 동시에 바꿔야 했고, 상태가 잘못된 곳에 있어서 props가 흘러야 했고, 이벤트 처리가 한 함수에 모여 있어서 추적이 불가능했고, 도메인이 UI 훅 안에 숨어 있어서 “건드리면 터지는” 코드가 됐어요. 답을 얻고 나면 리팩토링은 “그것을 마땅한 자리로 옮기는 일” 이 됩니다.
채팅에는 더 많은 영역이 남아있어요 — 모달 시스템, 리스트 가상화, 서비스 메시지, 그리고 이번 작업에서 배운 회고들. 그건 2편에서 이어갑니다.