vgrok - 터널링의 핵심은 역방향 영속 연결
localhost는 inbound 연결을 못 받는다(NAT/방화벽 뒤). 터널링의 원리는 공개 머신에서 로컬로 향하는 영속 연결을 미리 깔아두고, 외부 요청을 그 연결 위로 거꾸로 흘려보내는 것.
vgrok에서 그 영속 연결은 WebSocket이다. local → sandbox 방향으로 한 번 열어두면, sandbox로 들어온 HTTP 요청을 같은 소켓에 실어 local로 되돌릴 수 있다.
외부 요청 ──HTTP──▶ [공개 머신] server.js ──┐
│ WebSocket (local이 미리 연결해둠)
localhost ◀──HTTP── [local] client.js ◀──┘
요청/응답 매칭
WebSocket은 하나인데 동시 요청은 여럿. 그래서 요청마다 UUID를 붙이고, 공개 머신 쪽에서 Map<id, res>로 응답 객체를 들고 있다가 돌아온 메시지의 id로 짝을 찾는다.
// [server] 외부 요청 수신 → 보류 + 전달
const id = randomUUID()
responses.set(id, res)
client.send(JSON.stringify({ id, method, url, headers, body }))
// [server] WS로 응답 돌아오면 id로 원래 res 복원
const { id, statusCode, headers, body } = JSON.parse(msg)
responses
.get(id)
.writeHead(statusCode, headers)
.end(Buffer.from(body, 'base64url'))
body는 base64url — 이미지·파일 같은 바이너리를 JSON에 안전하게 싣기 위함.
Sandbox는 “터널 머신”의 일회용 대체재
여기서 신선한 점: 보통 터널링은 ngrok처럼 전용 공개 서버가 필요한데, vgrok은 Vercel Sandbox를 그 자리에 끼워넣는다. Sandbox가 마침 두 가지를 다 주기 때문:
sandbox.domain(port)→ 즉시 공개 HTTPS URL (도메인/인증서 설정 0)- 임의 코드 실행 → WebSocket 프록시(server.js)를 그 위에 배포
const sandbox = await Sandbox.create({ runtime: 'node22', ports: [3000] })
const url = sandbox.domain(3000) // 외부 진입점
await sandbox.writeFiles([{ path: 'server.js', content }])
await sandbox.runCommand({ cmd: 'node', args: ['server.js'], detached: true })
즉 “공개 URL + 코드 실행”이 되는 임시 컴퓨팅이라면 무엇이든 터널 머신이 될 수 있고, Sandbox는 그걸 가장 싸게 얻는 한 수단일 뿐.
한계
- Hobby 45분 타임아웃, HTTP만(TCP 터널 X), 단일 클라이언트
- 핵심 코드 ~220줄 — 네트워킹/보안/HTTPS/DNS를 Sandbox가 다 추상화해서 가능
dnd-kit Sortable + API 연동 시 drop 직후 튕김(flicker) 문제
dnd-kit v6.x + TanStack Query 조합에서 드래그로 순서 변경 후 mutation을 트리거하면, drop 직후 한 프레임 동안 아이템이 원래 위치로 돌아갔다가 새 위치로 점프하는 현상.
증상
onDragEnd에서setQueryData로 캐시를 즉시 업데이트 (낙관적 업데이트)- 이론상 동기 업데이트라 즉시 새 순서로 렌더되어야 함
- 실제로는 drop 시점에 1~2 프레임 동안 원래 위치로 보였다가 새 위치로 settle
- DragOverlay를 쓰면 더 두드러짐
원인
dnd-kit 자체의 알려진 동작이며, 본인 코드 문제가 아님.
- dnd-kit#833 — DragOverlay의 drop 애니메이션이 destination position을 ASAP로 측정. reorder가 적용되기 전 1~2프레임을 catch해서 원래 위치로 애니메이션 후 점프
- dnd-kit#921 — react-query 캐시를 직접 업데이트하면 sorting이 flicker. 의미 없는 React state를 같이 set하면 해결되는 것처럼 보임 → dnd-kit이 drop 애니메이션 flush에 React state lifecycle에 의존한다는 추정
- dnd-kit Discussions#1522 — v6/v8에서도 동일 증상 재현. 답변자도 결국 별도 local state로 우회
핵심: dnd-kit의 측정 사이클 ↔ React Query 캐시 업데이트 사이클이 불일치. 캐시는 동기 업데이트지만 dnd-kit이 의존하는 commit phase를 타지 않음.
해결 패턴
렌더의 source-of-truth를 캐시가 아니라 local state로 둔다. 캐시 업데이트는 부수 효과(서버 동기화)로 격리.
function SortableList() {
const { data: serverItems = [] } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
})
// 렌더용 source of truth
const [items, setItems] = useState<Item[]>(serverItems)
// 서버 데이터가 바뀌면 동기화 (단, 드래그 직후 race 가드)
const isReorderingRef = useRef(false)
useEffect(() => {
if (!isReorderingRef.current) {
setItems(serverItems)
}
}, [serverItems])
const reorderMutation = useMutation({
mutationFn: reorderItems,
onSettled: () => {
isReorderingRef.current = false
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((i) => i.id === active.id)
const newIndex = items.findIndex((i) => i.id === over.id)
const next = arrayMove(items, oldIndex, newIndex)
isReorderingRef.current = true
setItems(next) // 1) local state 먼저 (dnd-kit이 이걸 보고 측정)
reorderMutation.mutate(next) // 2) 서버 동기화는 별개
}
return (
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i.id)}>
{items.map((item) => (
<SortableItem key={item.id} item={item} />
))}
</SortableContext>
</DndContext>
)
}
핵심 포인트
- 렌더는 local state, 캐시는 부수 효과 —
setQueryData결과를 렌더에 직접 쓰면 flicker. setState는 dnd-kit이 의존하는 React commit 사이클을 탐. - invalidate race 방지 —
isReorderingRef없이는 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local state를 덮어씀. 이게 또 다른 튕김으로 보일 수 있음.onSettled에서 플래그 내리고 invalidate. - 서버 응답으로 정렬 결과를 받으면 더 깔끔 —
mutationFn이 새 순서 반환 →onSuccess에서setItems(data)직접 동기화하고 invalidate 제거. race 가장 없음.
추가 점검 포인트
Key/ID 안정성
SortableContext items={...}, 자식의key,useSortable({ id })모두 같은 stable id 사용- 인덱스를 key로 쓰면 dnd-kit과 거의 항상 충돌
DragOverlay 사용 시
- 위 패턴으로도 잔여 flicker가 있으면
dropAnimation={null}또는 duration 단축 - drop animation measure 타이밍이 워낙 빠르므로 (#833) 애니메이션 자체를 줄이는 게 가장 확실
- 진단용으로 한 번
dropAnimation={null}적용해서 튕김이 사라지면 #833 케이스 확정
Mutation 응답 처리
- 백엔드가 update 직후 GET에서 옛 데이터 반환할 가능성이 있으면 mutation 응답 →
setQueryData명시 동기화 권장 invalidateQueries만 호출하면 race condition 재발 가능
관련 링크
- dnd-kit#833 — Asynchronous reordering and drop animation
- dnd-kit#921 — Sorting is not working as expected with react-query
- dnd-kit Discussions#1522 — React Query with DnD Kit: Item Goes Back to Original Position for a Split Second on Drop
버전 노트
- 본 메모는 dnd-kit v6.3.1 기준
- v10+는
OptimisticSortingPlugin이 기본 활성화되어 메커니즘이 달라짐. 마이그레이션 시 재검증 필요
PNG을 범용 압축 컨테이너로 해킹 — 임의의 바이트를 canvas 픽셀(R/G/B)에 인코딩하고 toDataURL("image/png")을 부르면 브라우저 내장 Deflate 압축을 JS에서 끌어쓸 수 있다. 복원은 <img>로 다시 로드해 getImageData로 픽셀을 읽는다.
텍스트를 “이미지로 만드는” 게 아니라, PNG가 무손실 압축(Deflate)을 쓴다는 점을 범용 압축 API로 전용한 것. 지금은 Compression Streams API가 널리 지원돼 실용성은 낮고, 레거시 대응이나 창의적 해킹 참고용.
참고
useClickOutside — ref callback 방식 정리
- ref callback은 DOM 노드 참조만 전달할 뿐, 내부에서 등록한 이벤트 리스너 등의 사이드이펙트는 자동 해제되지 않음
- React 18 이하: unmount 시
callback(null)호출되지만, 동일 함수 참조가 없어removeEventListener곤란 → 별도 ref 저장 필요 - React 19:
useEffect처럼 cleanup function return을 공식 지원 →return () => removeEventListener(...)패턴 사용 가능 - stale closure 방지는
handlerRef.current = handler패턴으로 해결 - 결론: React 19라면 ref callback + cleanup return이
useEffect방식의 깔끔한 대안
function useClickOutside(handler) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
return useCallback((node) => {
const handleClick = (e) => {
if (!node.contains(e.target)) handlerRef.current();
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
}
// 사용
function Modal({ onClose }) {
const ref = useClickOutside(onClose);
return <div ref={ref}>...</div>;
}
참고
애자일 의식(스쿼드·2주 스프린트·스탠드업)을 다 갖춰도, 수직 슬라이싱이 없으면 2주 컨테이너에 워터폴을 압축한 cargo cult agile이 된다.
증상
- 사일로/스쿼드 + 2주 스프린트라는 애자일 형식
- 실제 흐름: 기획 → 디자인 → 서버/클라 → QA 직렬
- 결과: 2주 컨테이너 안에 워터폴 압축
구조적 원인 (직군 구성 문제 아님)
- 작업 단위 = 기능 전체 (수직 슬라이싱 부재)
- 각 직군 산출물 = 다음 직군의 하드 의존성
- Discovery(무엇을)와 Delivery(만드는 일)를 같은 스프린트 같은 기능에서 동시에 시도
왜 아무도 의심하지 않는가
- 형식 요건(스탠드업, 회고, 번다운, Jira)이 갖춰져 있어 의심이 차단됨
- “애자일이 잘 안 된다”는 인식이 와도 의식 내부 처방(스탠드업 개선, 회고 솔직하게)으로만 귀결
- 의식 자체의 설계 오류는 검토 대상이 아님 → cargo cult agile
진짜 애자일은 모든 직군에 단기적으로 더 불편. 장기 처리량 이득은 조직 단위, 단기 불편은 개인 단위.
풀스택 경계의 통합 단위 = reactive state 그 자체. 서버의 signal/model이 클라이언트에서 같은 객체로 보인다.
// 서버 model이 클라이언트에 그대로 비친다
const todos = createReflectedModel('Todos')
todos.add('buy milk') // RPC인지 로컬 메서드인지 의식 안 함
todos.all.value[0].done.value = true // signal 토글 = 서버 상태 토글
통합 단위가 컴포넌트(RSC)도 함수 호출(server functions)도 DOM diff(LiveView)도 아니다. 다른 진영이 “서버 코드를 어떻게 부를까”를 풀 때, 여기는 “서버 상태를 어떻게 살게 할까”를 푼다. 태그라인 “as if they lived on the client”이 그 직접 표현.
참고
WeakMap의 키를 “입력 배열의 참조 자체”로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.
const indexCache = new WeakMap<Item[], Map<string, string>>()
// name → id 역인덱스를 1회 빌드 (O(n))
const buildIndex = (items: Item[]) =>
new Map(items.map((it) => [it.name, it.id]))
function lookup(items: Item[], name: string): string | null {
let index = indexCache.get(items)
if (!index) {
index = buildIndex(items) // 캐시 미스일 때만 빌드
indexCache.set(items, index)
}
return index.get(name) ?? null
}
- 같은 참조로 재호출 → cache hit, O(1)
- 데이터가 새 참조로 교체(refetch 등) → 자동 miss → 새 index 빌드. 옛 index는 옛 items와 함께 GC 대상
- 키가 weak reference라 items가 어디서도 안 잡히면 entry도 같이 사라짐. 일반
Map이면 참조가 영구히 붙들려 메모리 누수
전제: 호출 측 items 참조가 stable해야 작동. 매번 [...data]·data.filter()로 새 배열을 넘기면 항상 miss라 무의미. (TanStack Query의 data/select 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK)
참고
컴포넌트는 className을 안 만든다 — prop을 그대로 DOM 어트리뷰트로 흘리고, variant 분기는 CSS 어트리뷰트 셀렉터가 담당한다.
// 컴포넌트 전체 = h(tag, { ...props, k: name }) 한 줄
export const Badge = createSimpleComponent<'span', BadgeOwnProps>(
'badge',
'span',
)
// → <span k="badge" variant="secondary"> (variant가 class가 아니라 attr로 그대로 나감)
[k="badge"] { /* default */ }
[k="badge"][variant="secondary"] { … }
[k="badge"][variant="destructive"]:hover { … }
createSimpleComponent 가 하는 일
/**
* - defaults를 props 아래에 깔고
* - k={name} 할당하고
* - h(tag, normalizedProps)
*/
createSimpleComponent<T, P>(name, tag)
k?: never로 소비자가 셀렉터 키k를 덮어쓰는 걸 타입 레벨에서 차단.Props = Omit<JSX.IntrinsicElements[T], keyof P> & P→ own prop이 동명 intrinsic 어트리뷰트를 덮어씀 (Badge의variant가 우선).tag를(props) => T함수로도 받아 폴리모픽 (href있으면<a>, 없으면<button>). 단 분기에 들어가는 건 defaults 적용 전 원본 props.
대가: 런타임 동적 variant 계산·조건부 로직은 포기 (CSS 셀렉터로 표현 가능한 범위만).
참고
SDD(Kiro): 스펙을 requirements → design → tasks로 문서화하고, acceptance criteria에서 testable property를 뽑아낸다 — 명세가 곧 테스트의 원천.
신선한 지점: 보통 테스트는 코드 뒤에 짜는데, SDD는 명세 단계에서 검증 가능한 불변식을 먼저 규정한다.
- 그 property를 PBT(fast-check)로 검증 (순수 함수에서 강력)
- UI로 확장하면 Bombadil = “action generator + property”
참고
- Kiro — Specs — requirements → design → tasks 스펙 워크플로우
- fast-check — JavaScript PBT 프레임워크
- Bombadil (Antithesis) — 웹 UI property-based testing (TS로 property + action generator 작성)
여러 단계의 비동기 UI 업데이트를 setLoading → setProgress → setLoading(false)로 흩뿌리지 않고 async generator의 yield 시퀀스로 시간 순서대로 표현. 원본 트릭은 @ericclemmons.
type FlowEvent<P extends string, D> = { phase: P; data: D }
async function* loadingFlow(
signal: AbortSignal,
): AsyncGenerator<FlowEvent<'starting' | 'slow' | 'done', string>> {
yield { phase: 'starting', data: 'Starting…' }
await wait(1000, signal)
yield { phase: 'slow', data: 'Taking longer than usual' }
await wait(2000, signal)
yield { phase: 'done', data: 'Got it 🎉' }
}
원본의 빈 자리 → 소비 레이어 책임
원본 데모는 4가지가 비어 있음. hook이든 actor든 wrapping 레이어에서 채워야 함:
- cancellation: generator에
AbortSignal주입 →await대상(wait,fetch)이 signal-aware해야 진짜 취소됨 - 재진입: 새 실행 시 직전 iterator abort
- error path: generator 내부 throw → 상태 노출
- unmount/teardown: cleanup으로 in-flight iterator abort
- stale closure: 최신 클로저 참조 (hook은 ref, actor는 input)
- typed:
phase/data모두 좁힘
어디에 맞는가
generator는 시간이 코드의 한 방향(↓)으로만 흐르는 모델. 그 모양에 맞는 시나리오:
- 멀티 단계 loading 메시지 (위
loadingFlow) - progressive search — tier별로 점진적 결과 yield, tier 경계가 자연스러운 cancellation point, debounce 불필요
- optimistic mutation —
{ phase: 'optimistic' | 'reconciled' | 'rolledBack', data }전이를 4개 콜백 대신 한 함수로