풀스택 경계에서 서버와 클라이언트를 잇는 단위가 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줄이 된다 — 무효화를 참조 동등성에 위임.

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

절차

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
}

확인: 같은 배열 참조로 두 번 부른다. buildIndex한 번만 돌면 된 것이다.

함정: 호출 측 items 참조가 stable해야 작동한다. 매번 [...data]·data.filter()로 새 배열을 넘기면 항상 miss라 무의미하다. (TanStack Query의 data/select 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK)

왜 되는가

  • 같은 참조로 재호출 → cache hit, O(1)
  • 데이터가 새 참조로 교체(refetch 등) → 자동 miss → 새 index 빌드. 옛 index는 옛 items와 함께 GC 대상
  • 키가 weak reference라 items가 어디서도 안 잡히면 entry도 같이 사라짐. 일반 Map이면 참조가 영구히 붙들려 메모리 누수

참고

컴포넌트는 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”

참고

시뮬레이터 Safari를 GUI로 디버깅하는 정도는 그냥 macOS Safari 개발자용 메뉴. inspect-webkit(CDP 브리지)으로 우회하려다 막혔다.

이유: inspect-webkit은 README부터 “for AI agents and CI” 타겟 — 사람 GUI 디버깅이 애초에 주력 시나리오가 아니었다.

교훈: 도구의 의도된 사용자를 README에서 먼저 확인한다.

참고

여러 단계의 비동기 UI 업데이트를 setLoading → setProgress → setLoading(false)로 흩뿌리지 않고 async generator의 yield 시퀀스로 시간 순서대로 표현. 원본 트릭은 @ericclemmons.

언제 — generator는 시간이 코드의 한 방향(↓)으로만 흐르는 모델이다. 그 모양에 맞는 시나리오:

  • 멀티 단계 loading 메시지 (아래 loadingFlow)
  • progressive search — tier별로 점진적 결과 yield, tier 경계가 자연스러운 cancellation point, debounce 불필요
  • optimistic mutation — { phase: 'optimistic' | 'reconciled' | 'rolledBack', data } 전이를 4개 콜백 대신 한 함수로

절차

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

확인 — 감싸는 레이어(hook이든 actor든)에서 여섯을 다 채웠는가. 원본 데모는 전부 비어 있다:

  • 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 모두 좁힘
#540

URL %2520 버그 = iOS 17.0/17.1 WebKit pasteboard가 복사 시 URL을 재인코딩 (WebKit Bug 261936).

현상

  • 공백이 %2520으로 깨짐, 한글은 %EC%88%98 정상 → 부분 재인코딩
  • 복사-붙여넣기 경로만 영향, 클릭(JS redirect)은 정상
  • iOS 집중 + referrer 누락 패턴과 일치

원인

%2520 = %25(= %) + 원래 있던 20. 공백(%20)이 한 번 더 인코딩된 것.

공백 → %20 → (% 만 재인코딩) → %2520

URL 인코딩은 멱등(idempotent)이 아니다 — encodeURIComponent를 두 번 하면 값이 달라진다.

진범은 WebKit Bug 261936:

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

pasteboard가 NSURL을 만들 때 %20은 invalid로 잘못 판단해 재인코딩, 한글 percent-encoding(%EA%B0%80)은 valid UTF-8로 통과 → 비대칭의 원인.

해결

원인이 외부(OS 버그)면 추적보다 방어가 ROI 높음.

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

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

교훈

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

참고

#539

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

언제무엇이 필요한지로 고른다.

#538
for file in *.md; do
  new_name="${file#* }"
  mv "$file" "$new_name"
done
| 개념        | JavaScript      | Bash                   |
| ----------- | --------------- | ---------------------- |
| 반복        | `for...of`      | `for...in...do...done` |
| 문자열 분리 | `split(' ')[1]` | `${var#* }`            |
| 문자열 치환 | `replace()`     | `${var/old/new}`       |
| 파일 조작   | `fs.rename()`   | `mv`                   |
| 출력        | `console.log()` | `echo`                 |

Parameter Expansion

file="351 529.md"

${file#* }       # 앞에서 "* " 제거 → "529.md"
${file##* }      # greedy
${file% *}       # 뒤에서 " *" 제거 → "351"
${file%%.*}      # greedy → "351 529"
${file/old/new}  # 첫 번째 치환
${file//old/new} # 전체 치환

#은 앞(키보드에서 $보다 왼쪽), %는 뒤.

따옴표

mv $file $new_name      # ❌ 공백 있으면 깨짐
mv "$file" "$new_name"  # ✅

디버깅

set -x  # 실행 명령어 출력
set -e  # 에러 시 중단
#537

콜백 → Promise → async/await

// 콜백
fetchData(() => {
  processData(() => {
    displayData()
  })
})

// Promise 체인
fetchData().then(processData).then(displayData)

// async/await
async function main() {
  const data = await fetchData()
  const processed = await processData(data)
  await displayData(processed)
}

Promise는 모나드처럼 동작 - then이 bind/flatMap 역할

  • map: 값 변환 (중첩 허용)
  • flatMap: 값 변환 + 평탄화 (Promise의 then)
#536
31 중 5페이지