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가 다 추상화해서 가능

#552

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>
  )
}

핵심 포인트

  1. 렌더는 local state, 캐시는 부수 효과setQueryData 결과를 렌더에 직접 쓰면 flicker. setState는 dnd-kit이 의존하는 React commit 사이클을 탐.
  2. invalidate race 방지isReorderingRef 없이는 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local state를 덮어씀. 이게 또 다른 튕김으로 보일 수 있음. onSettled에서 플래그 내리고 invalidate.
  3. 서버 응답으로 정렬 결과를 받으면 더 깔끔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 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주 컨테이너 안에 워터폴 압축

구조적 원인 (직군 구성 문제 아님)

  1. 작업 단위 = 기능 전체 (수직 슬라이싱 부재)
  2. 각 직군 산출물 = 다음 직군의 하드 의존성
  3. 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”

참고

여러 단계의 비동기 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개 콜백 대신 한 함수로
#540
26 중 1페이지