Coaction v1.0 — Web Worker로 멀티스레딩 상태 관리하기
JavaScript는 근본적으로 단일 스레드 언어다. 복잡한 계산이나 대규모 상태 처리가 메인 스레드에 쌓이면 UI가 멈추고, 사용자는 응답 없는 화면을 마주한다. React, Vue, Svelte 어느 프레임워크를 쓰더라도 이 벽은 동일하다.
Coaction은 이 구조적 한계를 정면으로 겨냥한 상태 관리 라이브러리다. Web Worker와 SharedWorker를 활용해 상태 로직을 별도 스레드로 옮기고, 메인 스레드와 동기화한다. 2025년 말 v1.0이 공개됐고, React·Vue·Angular·Svelte·Solid 다섯 프레임워크를 모두 지원한다.
Coaction이란
메인 스레드가 아닌 Worker 스레드를 **상태의 원천(primary source of state)**으로 삼는 상태 관리 라이브러리.
일반적인 상태 관리 라이브러리는 상태를 메인 스레드에 두고, 컴포넌트가 구독한다. Coaction의 Shared 모드는 이 구조를 뒤집는다. 상태는 Worker 안에 있고, 메인 스레드의 컴포넌트는 패치(patch) 기반 동기화로 최신 상태를 받는다.
비유하면 기존 방식은 모든 직원이 사장 책상 위에 놓인 서류를 돌아가며 꺼내 쓰는 구조다. 책상(메인 스레드)이 붐빌수록 병목이 생긴다. Coaction의 방식은 서류를 별도 문서 보관실(Worker)에 두고, 직원(컴포넌트)들이 창구를 통해 필요한 것만 전달받는 구조다. 메인 책상은 UI 렌더링에만 집중할 수 있다.
[💡 잠깐! 이 용어는?]
Web Worker: 브라우저에서 메인 스레드와 별개로 실행되는 백그라운드 스레드. DOM에는 접근할 수 없지만 무거운 연산을 메인 스레드 밖에서 처리할 수 있다. SharedWorker는 같은 출처(origin)의 여러 탭/페이지가 하나의 Worker를 공유하는 변형이다.
Standard vs Shared — 두 가지 모드
Coaction은 두 가지 동작 모드를 제공한다. 용도에 따라 골라 쓰면 된다.
| 항목 | Standard 모드 | Shared 모드 |
|---|---|---|
| 상태 위치 | 메인 스레드 | Worker 스레드 |
| 주 사용 목적 | 단순 대체, 마이그레이션 | 고성능 멀티스레딩 |
| Worker 설정 | 불필요 | Worker 파일 별도 작성 |
| 상태 동기화 | 불필요 | 패치(patch) 기반 자동 동기화 |
| 탭 간 공유 | 불가 | SharedWorker 사용 시 가능 |
| 진입 난이도 | 낮음 | 중간 |
Standard 모드는 기존 Zustand나 Redux를 쓰던 방식과 거의 동일하다. API 형태를 유지하면서 Coaction의 computed, 네임스페이스 슬라이스 같은 부가 기능을 쓰고 싶을 때 적합하다. Shared 모드는 Worker를 상태의 원천으로 두기 때문에 설정이 조금 더 복잡하지만, 메인 스레드 병목을 실질적으로 줄일 수 있다.
Standard 모드 — 기본 사용법
Standard 모드는 Zustand와 구조가 거의 같아서 이미 Zustand를 쓰던 팀이라면 학습 비용이 거의 없다.
import { create } from '@coaction/react';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => state.count++),
}));import { useStore } from './store/counter';
export function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>{count}</button>;
}create 함수의 시그니처가 Zustand와 동일하다. 기존 코드를 import 경로만 바꿔서 적용할 수 있다.
Shared 모드 — Worker로 상태 분리
Shared 모드는 세 파일로 역할을 나눈다. 상태 정의, Worker 진입점, 메인 스레드 훅이다.
export const counter = (set) => ({
count: 0,
increment: () => set((state) => state.count++),
});import { create } from '@coaction/react';
import { counter } from './store/counter';
create(counter);import { create } from '@coaction/react';
import { counter } from './store/counter';
const worker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
);
const useStore = create(counter, { worker });같은 counter 슬라이스 정의를 Worker와 메인 스레드 양쪽에서 참조하는 구조다. Worker가 상태를 소유하고, 메인 스레드는 Worker와 연결된 useStore를 통해 상태를 읽고 쓴다.
[💡 잠깐! 이 용어는?] 패치(Patch) 기반 동기화: 상태 전체를 매번 복사하는 대신, 변경된 부분만 diff로 추출해서 전달하는 방식. Immer의 structural sharing과 비슷한 원리로, 전송 데이터량을 최소화한다.
Slices와 Computed — 네임스페이스 구조
실제 앱에서는 상태가 여러 도메인으로 나뉜다. Coaction은 슬라이스를 객체로 묶어 네임스페이스를 자동으로 구성하고, computed 속성도 내장 지원한다.
import { create } from '@coaction/react';
const counter = (set, get) => ({
count: 0,
get tripleCount() {
return this.count * 3;
},
doubleCount: get(
(state) => [state.counter.count],
(count) => count * 2
),
increment() {
set(() => {
this.count += 1;
});
},
});
const useStore = create({ counter });get tripleCount()는 JavaScript의 네이티브 getter를 그대로 쓴다. get() 함수 방식은 의존성 배열을 명시적으로 선언하는 방식으로, 다른 슬라이스의 값을 참조할 때 쓴다. 비유하면 엑셀 셀 참조와 같다. tripleCount는 count 셀을 참조하는 수식이고, 참조 셀이 바뀔 때만 재계산이 일어난다.
Coaction vs Zustand — 기능 비교
| 기능 | Coaction | Zustand |
|---|---|---|
| 멀티스레딩(Web Worker) | 내장 | 없음 |
| computed 속성 | 내장 | 없음 (별도 라이브러리 필요) |
| 네임스페이스 슬라이스 | 내장 | 없음 (수동 구성) |
| 자동 셀렉터 | 지원 | 없음 |
this 지원 | 지원 | 없음 |
| 프레임워크 지원 | React·Vue·Angular·Svelte·Solid | React (주) |
| Immer 통합 | Mutative 내장 | 별도 미들웨어 |
벤치마크
| 시나리오 | ops/sec |
|---|---|
| Coaction (기본) | ~5,272 |
| Zustand (기본) | ~5,233 |
| Coaction + Mutative | ~4,626 |
| Zustand + Immer | ~253 |
기본 ops/sec 차이는 크지 않다. 눈에 띄는 수치는 불변 업데이트 미들웨어를 붙였을 때다. Coaction은 자체 통합된 Mutative를 쓰고, Zustand는 일반적으로 Immer를 쓴다. Mutative가 Immer보다 구조적으로 빠른 라이브러리라 조합 결과가 크게 벌어진다. 공식 문서에서는 이 차이를 18.3배로 표기한다.
단순 카운터 증가 같은 마이크로벤치마크는 실제 앱 성능과 다를 수 있다. Worker 오버헤드(메시지 직렬화/역직렬화)가 존재하므로, 상태 업데이트가 매우 빈번하고 가벼운 경우엔 Standard 모드가 더 적합할 수 있다.
어떤 상황에 쓸 만한가
Shared 모드가 유효한 상황:
- 실시간 데이터 처리 (주식 차트, 게임 상태, 라이브 대시보드)
- 대용량 목록의 필터링/정렬/집계 로직이 메인 스레드를 막는 경우
- 여러 탭이 동일한 상태를 공유해야 하는 앱 (SharedWorker 활용)
Standard 모드로 충분한 상황:
- 대부분의 일반 CRUD 앱
- Zustand에서 마이그레이션 비용 없이 computed, 슬라이스 네임스페이스를 추가하고 싶은 경우
정리
- 단일 스레드 병목이 실제 문제라면 Shared 모드로 상태를 Worker로 옮겨라
- Zustand 대체를 고려한다면 Standard 모드로 computed, 네임스페이스 슬라이스,
this지원을 바로 얻을 수 있다 - 성능이 핵심인 앱이라면 Coaction + Mutative 조합이 Zustand + Immer 대비 18배 이상 빠르다
- 일반적인 앱이라면 굳이 Worker를 도입할 필요는 없다. 진짜 병목이 생겼을 때 마이그레이션 경로가 열려 있다는 것만으로도 충분히 의미 있는 선택지다
참고:
- Coaction GitHub: https://github.com/unadlib/coaction
관심 있을 만한 포스트
React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가
React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.
LCP 28초짜리 React 앱을 1초로 깎아낸 기록 — 4단계 성능 수술 프레임워크
번들 분석부터 에셋 최적화까지, React 앱의 LCP를 단계적으로 개선하는 실전 프레임워크를 다룬다.
Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지
Velog 스타일 카드 UI와 MDX 렌더링 상세 페이지 구현. 반응형 그리드, SEO 메타데이터, 정적 사이트 생성까지.
AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다
PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.
Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건
1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.
V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법
V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.
Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기
Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.
Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험
TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.