풀스택 경계에서 서버와 클라이언트를 잇는 단위가 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”
참고
- Kiro — Specs — requirements → design → tasks 스펙 워크플로우
- fast-check — JavaScript PBT 프레임워크
- Bombadil (Antithesis) — 웹 UI property-based testing (TS로 property + action generator 작성)
시뮬레이터 Safari를 GUI로 디버깅하는 정도는 그냥 macOS Safari 개발자용 메뉴. inspect-webkit(CDP 브리지)으로 우회하려다 막혔다.
이유: inspect-webkit은 README부터 “for AI agents and CI” 타겟 — 사람 GUI 디버깅이 애초에 주력 시나리오가 아니었다.
교훈: 도구의 의도된 사용자를 README에서 먼저 확인한다.
참고
- inspect-webkit — Safari/WKWebView 타겟을 CDP로 브리지 (헤드리스, AI/CI 지향)
- Eruda — 페이지에 주입하는 모바일 콘솔 (브리지 불필요)
- Inspecting iOS and iPadOS — Apple — macOS Safari로 시뮬레이터 디버깅 (정도)
여러 단계의 비동기 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모두 좁힘
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)
- “방어적으로 한 번 더”가 함정 — 멱등이 아닌 연산엔 통하지 않는다
참고
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 # 에러 시 중단
콜백 → 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)