Navigation API 이탈 방지의 두 갈래 — 사전 차단과 사후 롤백

언제: Navigation API로 만든 라우터에 이탈 방지(저장 안 된 폼에서 “정말 나가시겠습니까”)를 붙일 때.

링크 클릭·폼 제출·프로그램 호출·history.pushState()·뒤로·앞으로까지 떠나는 모든 경로가 navigate 이벤트 하나로 모인다. 블로커가 켜져 있을 때 navigationType 이 push·replace·reload 면 preventDefault() 로 아예 안 움직이게 막는다(사전 차단) — 이때 destination.index 는 -1 이다. traverse(뒤로·앞으로)는 사용자를 사이트에 가둘 수 없도록 preventDefault() 로 취소할 수 없어 이미 움직인 뒤다. 그래서 history.go(현재 index − destination.index) 로 되돌린다(사후 롤백) — destination.index 는 traverse 일 때만 목적지 커서 값을 준다. API 자체가 같은 선을 긋고 있어 블로커 하나로 통일할 수 없고 라우터 안에 두 갈래가 남는다.

history 를 리스트와 커서로 보면 go(n) 은 리스트를 건드리지 않고 커서만 옮긴다. 커서가 C 에 있을 때 사용자가 뒤로 가면 커서가 B 로 이미 옮겨지고, 롤백 go(+1) 이 커서를 C 로 돌려놓는다 — 리스트는 그대로라 겉보기엔 아무 일도 없었던 것처럼 된다. 다만 롤백은 범위 안에서만 제자리다: 0 ≤ idx + n 을 벗어나는 go 는 아무 일도 하지 않으니, 일어나지도 않은 이동을 되돌리면 오히려 진짜로 움직인다. 이 패턴에선 이동이 없으면 navigate 가 안 떠 롤백을 부를 일이 없다. 브라우저가 사용자를 위해 막아둔 걸 되돌리는 우회라 정말 붙잡아야 할 때만 쓴다.

근거 — 뒤로·앞으로는 취소할 수 없도록 설계됐고1, destination.index 는 traverse 일 때만 값이 있다.2

확인: 블로커를 켠 채 다섯 가지 이동을 다 해본다. push·replace·reload는 아예 안 움직이고, 뒤로·앞으로는 움직였다 제자리로 돌아오면 된 것이다.

Footnotes

  1. “it will fire for all types of navigations, whether the user performed an action (such as clicking a link, submitting a form, or going back and forward) or when navigation is triggered programmatically” / “you can’t cancel a navigation via preventDefault() if the user is pressing the Back or Forward buttons in their browser; you should not be able to trap your users on your site.” (Navigation API — Chrome for Developers) ↩

  2. “Returns the index value of the destination NavigationHistoryEntry if the NavigateEvent.navigationType is traverse, or -1 otherwise.” (NavigationDestination.index — MDN) ↩

#555

Native Messaging — 확장이 로컬 바이너리를 실행하는 법

확장 샌드박스 안: content.js → runtime.sendMessage → background.js(service worker). content.js 에서는 네이티브 통신을 못 한다. background.js → sendNativeMessage → Chrome. Chrome 은 OS 지정 위치의 manifest 에서 allowed_origins 에 이 확장이 있는지 대조한 뒤 호스트를 자식 프로세스로 spawn 한다. Chrome → 호스트는 stdin 으로 최대 64MiB, 호스트 → Chrome 은 stdout 으로 최대 1MB. 메시지 하나는 4바이트 길이 헤더 다음 UTF-8 JSON 이다. stdout 에 print 한 글자라도 새면 길이 자리에 끼어 프로토콜이 깨지므로 로그는 stderr 로 보낸다.

호스트를 OS 의 정해진 자리에 manifest 로 올린다 — Windows 는 레지스트리 키, macOS·Linux 는 지정된 디렉터리(macOS 는 ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/). manifest 는 name·description·path·type(stdio)·allowed_origins 를 갖는다. path 는 절대경로여야 한다. allowed_origins 가 게이트다: 확장이 호출하면 Chrome 이 이 목록에 그 확장이 있는지 대조해 있을 때만 path 의 바이너리를 띄운다. 와일드카드는 못 쓴다 — 확장이 아무 프로그램이나 실행하지 못하는 이유다.

Chrome 이 path 의 바이너리를 자식 프로세스로 spawn 하고, 대화는 소켓도 HTTP 도 아니고 stdin/stdout 이다. 메시지마다 네이티브 바이트 순서의 4바이트 길이 헤더 다음에 UTF-8 JSON 본문이 온다. 읽을 때는 4바이트를 읽어 길이를 풀고 그만큼 본문을 읽는다 — 4바이트가 비어 있으면 Chrome 이 파이프를 닫은 것이니 종료한다. 보낼 때도 길이를 먼저 쓰고 본문을 쓰고 flush 한다. stdout 에 print 로 글자 하나라도 새면 그게 길이 헤더 자리에 끼어 프로토콜이 깨지니 로그는 stderr 로 보낸다. 호스트가 부르는 프로그램(yt-dlp)도 절대경로로 부른다 — GUI 로 띄운 Chrome 은 .zshrc 를 안 읽고 호스트는 사용자 셸 환경을 물려받지 않는다.

sendNativeMessage·connectNative 는 background(service worker)에서만 부를 수 있다. content script 는 통로 밖이라 흐름이 두 파일로 갈라진다: content.js 는 페이지에서 값만 뽑아 runtime.sendMessage 로 넘기고, background.js 가 네이티브 통신을 한다 — 응답이 비동기라 onMessage 리스너에서 true 를 돌려 채널을 열어둔다. sendNativeMessage 는 첫 응답 뒤 프로세스가 끝나 값 던지고 끝에 맞고, 진행률을 흘려보내려면 connectNative 로 Port 를 열어둔다. 크기 제한이 비대칭이다: 호스트 → Chrome 은 1MB, Chrome → 호스트는 64MiB. 돌려보낼 게 크면 잘라 보내거나(stderr 를 500자로) connectNative 로 나눠 보낸다.

근거 — manifest 필드·와일드카드 불가·크기 제한은 공식 문서 그대로다.1

Footnotes

  1. manifest는 name·description·path·type·allowed_origins 를 갖는다. “List of extensions that should have access to the native messaging host. allowed-origins values can’t contain wildcards.” / 크기 제한 — “The maximum size of a single message from the native messaging host is 1 MB”, “The maximum size of the message sent to the native messaging host is 64 MiB.” (Native messaging — Chrome for Developers) ↩

#553

dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다

dnd-kit Sortable + react-query에서 drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고 onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.

① 최소 재현: Sortable 리스트에 useQuery 를 붙이고 CDP Input.dispatchMouseEvent 로 드래그한다. ② 숫자로 잡기: requestAnimationFrame 마다 DragOverlay·아이템의 top 을 기록하는 프로브를 먼저 걸고, drop 뒤 최종 배치와 다른 프레임이 있으면 튕김이다. ③ 가설을 하나씩 끈다: dropAnimation={null} 로 사라지면 drop 애니메이션이 즉시 재는 탓, setState 와 setQueryData 를 번갈아 돌려 후자만 튕기면 캐시 알림 경로, notifyManager.setScheduler(queueMicrotask) 로 사라지면 원인 확정. ④ 고친다: 렌더는 local state, 캐시 갱신은 부수효과로. ⑤ 회귀 확인: ② 와 같은 프로브를 refetch 뒤까지 돌린다. 다시 튕기면 refetch 가 옛 순서를 덮음 → isReorderingRef 가드, id 흔들림 → stable id, DragOverlay 만 남음 → dropAnimation={null}.

③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데1, setQueryData의 리렌더는 notifyManager의 setTimeout(0)을 거쳐 한 틱 늦다.2

drop 하면 onDragEnd 가 돌고, dnd-kit 의 drop 애니메이션은 곧바로(ASAP) 목적지를 잰다. setState 로 바꾸면 그 이벤트 처리 안에서 새 순서가 렌더되어, 잴 때 이미 새 자리라 새 자리로 애니메이션한다. setQueryData 로 바꾸면 캐시는 즉시 바뀌지만 구독자 알림은 notifyManager 의 기본 스케줄러 setTimeout(0) 을 거쳐 다음 매크로태스크에 리렌더된다. 그 사이 dnd-kit 이 옛 순서를 재서 옛 자리로 애니메이션하고, 리렌더 순간 새 자리로 점프한다 — 1~2프레임 튕김. 캐시는 바로, 화면은 한 틱 뒤다. 눈금은 비례가 아니다.

④의 코드:

function SortableList() {
  const { data: serverItems = [] } = useQuery({
    queryKey: ['items'],
    queryFn: fetchItems,
  })
  const [items, setItems] = useState<Item[]>(serverItems)

  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 = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id) return
    const next = arrayMove(items, idxOf(active.id), idxOf(over.id))
    isReorderingRef.current = true
    setItems(next)
    reorderMutation.mutate(next)
  }
  // <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}

라이브러리가 렌더 직후 DOM을 재면, 기준은 상태가 바뀌는 시점이 아니라 화면이 바뀌는 시점이다.

v6.3.1 기준. v10+는 OptimisticSortingPlugin이 기본 활성화라 기제가 다르다 → 재검증 필요.

Footnotes

  1. “the drop animation for DragOverlay measures the destination position ASAP, and so it catches the 1 or 2 frames before my reorder is applied, and the item animates back to its original position.” (dnd-kit #833) ↩

  2. notifyManager.schedule 은 “schedules a function to be run on the next batch. By default, the batch is run with a setTimeout” — setScheduler 로 queueMicrotask·requestAnimationFrame 으로 바꿀 수 있다. (notifyManager — TanStack Query) ↩

바깥 클릭 감지를 useEffect 없이 ref callback 하나로 만든다.

React 19 이상에서만 된다.

function useClickOutside(handler) {
  const handlerRef = useRef(handler)
  handlerRef.current = handler // stale closure 방지

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

모달을 열기 → 닫기 → 열기 → 닫기 → 열기 한 뒤 바깥을 클릭한다. React 19: 열 때 ref(node) 가 mousedown 리스너를 추가하고, 닫을 때 반환한 cleanup 이 제거해 남는 리스너가 1 → 0 → 1 → 0 → 1 이다. 바깥 클릭에 handler 가 한 번만 불린다. React 18 이하: 반환한 cleanup 은 무시되고, 닫을 때 같은 콜백이 ref(null) 로 다시 불려 본문이 또 돈다 — node 가 null 인 리스너가 하나 더 붙는다. 남는 리스너가 1 → 2 → 3 → 4 → 5 로 쌓이고, 바깥 클릭에 handler 가 세 번 불리고 null.contains 에서 TypeError 가 두 번 난다. 18 이하는 리스너를 별도 ref 에 저장하거나 useEffect 를 쓴다.

참고

애자일 의식(스쿼드·2주 스프린트·스탠드업)을 다 갖춰도, 수직 슬라이싱이 없으면 2주 컨테이너에 워터폴을 압축한 cargo cult agile이 된다.

형식만 애자일: 2주 스프린트 안에서 기능 전체를 한 단위로 기획 → 디자인 → 서버/클라 → QA 가 차례로 넘긴다. 각 직군의 산출물이 다음 직군의 하드 의존성이라, 2주 컨테이너에 워터폴을 압축한 모양이 된다. 수직 슬라이싱: 같은 2주를 얇은 슬라이스 셋으로 잘라, 슬라이스마다 기획·디자인·서버/클라·QA 가 한 바퀴씩 돈다. 단기적으로는 모든 직군에 더 불편하다.

증상

  • 사일로/스쿼드 + 2주 스프린트라는 애자일 형식
  • 실제 흐름: 기획 → 디자인 → 서버/클라 → QA 직렬
  • 결과: 2주 컨테이너 안에 워터폴 압축

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

  1. 작업 단위 = 기능 전체 (수직 슬라이싱 부재)
  2. 각 직군 산출물 = 다음 직군의 하드 의존성
  3. Discovery(무엇을)와 Delivery(만드는 일)를 같은 스프린트 같은 기능에서 동시에 시도

왜 아무도 의심하지 않는가

  • 형식 요건(스탠드업, 회고, 번다운, Jira)이 갖춰져 있어 의심이 차단됨
  • “애자일이 잘 안 된다”는 인식이 와도 의식 내부 처방(스탠드업 개선, 회고 솔직하게)으로만 귀결
  • 의식 자체의 설계 오류는 검토 대상이 아님 → cargo cult agile

진짜 애자일은 모든 직군에 단기적으로 더 불편. 장기 처리량 이득은 조직 단위, 단기 불편은 개인 단위.

WeakMap의 키를 “입력 배열의 참조 자체”로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.

같은 배열을 이름 같은 키로 반복 조회해서 O(n) 스캔이 계속 도는데, 그 배열이 언제 바뀌는지 추적하기는 싫을 때 쓴다.

// 키가 weak — items 가 어디서도 안 잡히면 entry 도 GC. 일반 Map 이면 영구히 붙들려 누수
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 {
  // 키는 내용이 아니라 참조(===)로 비교된다. 불변 업데이트면 "데이터가 바뀜" = "참조가 바뀜"이라
  // 이 조회가 곧 무효화 검사다 — 그래서 delete·clear 가 어디에도 없다
  let index = indexCache.get(items)
  if (!index) {
    // 새 참조(refetch 등) → 자동 miss. 옛 index 는 옛 items 와 함께 GC
    index = buildIndex(items)
    indexCache.set(items, index)
  }
  return index.get(name) ?? null // 여기부터 O(1) — 호출마다 돌던 find 의 O(n) 스캔이 사라진 자리
}

흩어진 호출 지점이 모두 lookup 하나를 거쳐 배열 참조로 캐시를 찾는다. 같은 참조면 인덱스를 재사용하고, refetch 로 새 참조가 오면 한 번 다시 빌드하며 옛 entry 는 GC 된다. push 처럼 제자리에서 바꾸면 참조가 같아 옛 인덱스가 나온다. 매번 새 배열을 넘기면 항상 miss 다.

같은 배열 참조로 두 번 불러 buildIndex가 한 번만 돌면 된 것이다.

단 호출 측 items 참조가 stable해야 작동한다. 매번 [...data]·data.filter()로 새 배열을 넘기면 항상 miss라 무의미하다. (TanStack Query의 data/select 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK) 반대로 push·splice 로 제자리에서 바꾸면 참조가 같아 옛 인덱스가 그대로 나온다 — 이쪽은 느린 게 아니라 틀린 값이다.

참고

보통은 코드를 짠 뒤 테스트를 짠다. SDD(Kiro)는 스펙을 requirements → design → tasks 로 문서화하면서 acceptance criteria 에서 검증 가능한 불변식(testable property)을 먼저 뽑는다 — 명세가 곧 테스트의 원천이다. 그 property 를 순수 함수에서는 fast-check 로 PBT 하고, UI 로 넓히면 Bombadil 이 action generator + property 로 검증한다.

참고

isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.

useGenerator 의 상태는 useReducer 하나다. idle 에서 start 로 running 이 되고(에러도 함께 비운다), yield 는 status 를 두고 value 만 바꾸며, generator 가 끝나면 done, 본문 어디서든 throw 하면 catch 에서 error 로 errored 다. cancel 은 abort 하고 idle 로 되돌린다 — 안 하면 running 에 멈춘다. unmount 되면 cleanup 이 abort 하고, 기다리던 await wait 가 reject 돼 throw 가 나지만 aborted 라 에러로 치지 않는다. 같은 훅을 쓰는 쪽이 재진입 정책을 고른다: LoadingButton 은 running 동안 disabled 로 다시 못 누르게 하고 JSX 를 yield 하면 그대로 라벨이 된다. 검색은 run(q) 마다 직전 실행을 abort 하고 다시 시작해, a 다음 ab 를 치면 a 의 remote 결과가 끼어들지 않는다.

import { useEffect, useReducer, useRef } from 'react'

type Status = 'idle' | 'running' | 'errored' | 'done'

type State<T> = { status: Status; value: T; error: unknown }

type Action<T> =
  | { type: 'start' }
  | { type: 'yield'; value: T }
  | { type: 'done' }
  | { type: 'error'; error: unknown }
  | { type: 'cancel' }

function reducer<T>(state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    case 'start':
      return { ...state, status: 'running', error: null } // 상태와 에러 초기화가 한 번에 — 따로 set 하다 어긋날 일이 없다
    case 'yield':
      return { ...state, value: action.value }
    case 'done':
      return { ...state, status: 'done' }
    case 'error':
      return { ...state, status: 'errored', error: action.error }
    case 'cancel':
      return { ...state, status: 'idle' } // 없으면 'running' 에 멈춰 있다
  }
}

function useGenerator<T, A extends unknown[]>(
  fn: (signal: AbortSignal, ...args: A) => AsyncGenerator<T>, // signal 은 훅이 첫 인자로 끼워 넣는다
  initial: T,
) {
  const [state, dispatch] = useReducer(reducer<T>, {
    status: 'idle',
    value: initial,
    error: null,
  })
  const ctrlRef = useRef<AbortController | null>(null)

  useEffect(() => () => ctrlRef.current?.abort(), []) // unmount 되면 진행 중인 흐름을 끊는다

  const run = async (...args: A) => {
    // 매 렌더 새로 만들어져 항상 최신 fn 을 읽는다
    ctrlRef.current?.abort() // 재진입 — 직전 실행을 끊고 다시 시작
    const ctrl = new AbortController()
    ctrlRef.current = ctrl
    dispatch({ type: 'start' })
    try {
      for await (const value of fn(ctrl.signal, ...args)) {
        if (ctrl.signal.aborted) return
        dispatch({ type: 'yield', value }) // yield 마다 리렌더
      }
      dispatch({ type: 'done' })
    } catch (error) {
      if (ctrl.signal.aborted) return // abort 로 생긴 throw 는 에러가 아니다
      dispatch({ type: 'error', error })
    }
  }

  const cancel = () => {
    ctrlRef.current?.abort()
    dispatch({ type: 'cancel' })
  }

  return { ...state, run, cancel }
}

검색은 같은 훅에서 재진입이 재시작이 된다 — 새 입력이 들어오면 이전 tier 가 끊긴다.

async function* searchTiers(signal: AbortSignal, q: string): AsyncGenerator<string[]> {
  await wait(100, signal); yield [`${q}:local`]
  await wait(300, signal); yield [`${q}:local`, `${q}:remote`]
}

const { value, run } = useGenerator(searchTiers, [])
<input onChange={(e) => run(e.target.value)} />

0ms 에 a 를 치면 run(a) 가 시작해 100ms 대기 뒤 a:local 을 yield 하고, 300ms 더 기다려 400ms 에 a:remote 를 낼 참이다. 그런데 150ms 에 ab 를 치면 run(ab) 가 직전 실행을 abort 하고, a 가 기다리던 wait 가 reject 돼 그 자리에서 멈춘다 — a:remote 는 오지 않는다. run(ab) 는 250ms 에 ab:local, 550ms 에 ab:local,ab:remote 를 내고 done 이 된다. 화면에는 a:local → ab:local → ab:local,ab:remote 순서로만 보인다. tier 경계(await wait)가 그대로 취소 지점이라 debounce 가 필요 없다.

언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 loading · progressive search(tier 경계 = 취소 지점, debounce 불필요) · optimistic mutation(optimistic | reconciled | rolledBack 전이를 콜백 넷 대신 한 함수로).

확인 — 원본 데모 대비 채운 셋: ERRORED 를 선언만 하고 안 쓰던 것(→ catch), unmount 시 취소(→ AbortSignal + cleanup), 타입(→ 제네릭 훅). 검색창에 a 다음 바로 ab 를 치면 a:remote 가 끼어들지 않고 ab:local → ab:local,ab:remote 로만 바뀌면 된 것이다.


#540

URL의 공백이 %2520으로 깨져 들어온다. 원인은 iOS 17.0/17.1 WebKit pasteboard가 복사할 때 URL을 다시 인코딩하는 버그(WebKit Bug 261936)였다. Claude Code라면 깨진 값 해독 → 경로 좁히기 → 우리 코드 배제 → 방어 → 방어 확인 순으로 간다.

① 깨진 값 해독: decodeURIComponent 를 한 번 돌려도 %20 이 남으면 이중 인코딩이고, 한글은 풀리니 공백만 다시 인코딩됐다. ② 경로 좁히기: 액세스 로그의 %2520 요청이 iOS 에 몰리고 Referer 가 비어 있으면 복사-붙여넣기 경로다. ③ 우리 코드 배제: 링크 생성이 인코딩을 한 번만 하면 원인은 밖이고, iOS 버전으로 좁혀 WebKit Bug 261936 을 찾는다. ④ 고칠 수 없으니 막는다: 화이트리스트 라우트에서만 한 번 더 디코딩, 디코딩 뒤 .. // \ 차단, segment 별 재인코딩 후 301. 무조건 이중 디코딩은 ..%252f 를 ../ 로 만들어 path traversal 이 된다. ⑤ 방어 확인: curl -I 로 %2520 URL 은 301 로 정상 경로, ..%252f URL 은 차단되는지 본다.

1. 깨진 값을 해독한다

decodeURIComponent('%2520')을 한 번 돌리면 %20이 남는다 — %25(= %) 뒤에 원래의 20, 두 번 인코딩된 것이다. 같은 URL의 한글(%EC%88%98)은 한 번 디코딩으로 풀리니 공백만 다시 인코딩됐다. URL 인코딩은 멱등이 아니다.

2. 어느 경로에서 깨지나

서버 액세스 로그에서 %2520이 든 요청만 모아 User-Agent·Referer를 센다. iOS에 몰려 있고 Referer가 비어 있다 — 링크 클릭(JS redirect)이 아니라 주소를 복사해 붙여넣은 경로다. 클릭 경로의 같은 URL은 정상이다.

3. 우리 코드인가, 밖인가

링크를 만드는 코드에서 encodeURIComponent 호출을 찾아 출력이 %20 한 번인지 본다. 한 번이면 이중 인코딩은 우리 밖에서 일어난다. iOS 버전으로 좁혀 검색하면 WebKit Bug 261936이 나온다.

  • iOS 16↓: NSURL URLWithString:이 invalid char에 nil
  • iOS 17.0/17.1: invalid char를 자동 percent-encode (regression)
  • iOS 17.2: 수정

공백 → 인코딩 1회 → %20 → iOS 17.0·17.1 pasteboard 가 NSURL 을 만들며 %20 을 invalid 로 판단해 % 를 다시 인코딩 → %2520. 한글 가 → 인코딩 1회 → %EA%B0%80 → 유효한 UTF-8 이라 통과 → %EA%B0%80 그대로. %2520 은 %25(= %) 뒤에 원래 20 이 붙은 것으로, 공백만 한 번 더 인코딩됐다. 클릭(JS redirect)은 pasteboard 를 거치지 않아 정상이다.

4. 고칠 수 없으니 막는다

원인이 OS 버그면 추적보다 방어가 싸다.

  • 화이트리스트 라우트 패턴에서만 한 번 더 디코딩
  • 디코딩 후 위험 패턴(.., //, \) 차단
  • segment별 encodeURIComponent 후 301 redirect

무조건 이중 디코딩 금지 — ..%252f..%252f → ../../ path traversal 우회 벡터.

5. 방어를 확인한다

%2520이 든 URL과 ..%252f..%252f가 든 URL을 curl -I로 보낸다. 앞의 것은 301로 정상 경로에 가고, 뒤의 것은 막혀야 한다.

교훈

  • URL은 디코딩된 원본으로 보관하고, 인코딩은 출력 직전 한 번 (single source of truth)
  • “방어적으로 한 번 더”가 함정 — 멱등이 아닌 연산엔 통하지 않는다

참고

#539

컴포넌트가 렌더링하는 요소를 외부에서 제어하는 세 가지 패턴.

as: 소비자가 태그 이름(as=“a”)을 넘기면 Button 이 Comp = as 로 그 태그를 렌더하고 props 를 그대로 펼친다 — 단순 태그 변경에 쓴다. asChild: 소비자가 자식 요소(<a href />)를 넘기면 Button 이 Slot 으로 그 자식을 cloneElement 해 className 병합·핸들러 합성·ref 병합을 암묵적으로 한다 — 기존 컴포넌트 합성에 쓴다. render: 소비자가 render 에 요소나 (props, state) => 함수를 넘기면 Button 이 ref 포함 DOM props 와 isPressed 같은 상태를 명시적으로 건네고 소비자가 직접 펼친다 — 상태 기반 커스터마이징과 합성에 쓰고 타입 추론이 가장 좋다.

as

type PolymorphicProps<E extends React.ElementType> = {
  as?: E
} & React.ComponentPropsWithoutRef<E>

function Button<T extends React.ElementType = 'button'>({
  as,
  ...props
}: PolymorphicProps<T>) {
  const Comp = as || 'button'
  return <Comp {...props} />
}

asChild + Slot

import { Slot } from '@radix-ui/react-slot'

function Button({ asChild, ...props }) {
  const Comp = asChild ? Slot : 'button'
  return <Comp {...props} />
}

Slot 직접 구현:

const Slot = forwardRef(({ children, ...props }, ref) => {
  if (!isValidElement(children)) return null

  return cloneElement(children, {
    ...props,
    ...children.props,
    ref: ref || children.ref,
  })
})

자식 요소 타입에 따른 조건부 Props:

type ConditionalProps<T> = T extends ReactElement<any, 'a'>
  ? { href?: string; external?: boolean }
  : T extends ReactElement<any, 'button'>
    ? { type?: 'button' | 'submit' }
    : {}

render

// Element 방식
<Button render={<a href="/about" />}>Link</Button>

// Callback 방식 - state 접근 가능
<Button render={(props, state) => (
  <motion.button {...props} animate={state.isPressed ? ... : ...} />
)}>
  Animated
</Button>

#538
34 중 3페이지