[{"i":"597","t":"재귀 크롤러의 세 부품과 런타임별 구현 부담","k":["crawler","concurrency","go","python","rust"],"c":"2026-09-02","m":"2026-09-10","b":"[lychee 저자가 muffet·LinkChecker·linkinator·broken-link-checker 소스를 읽고](https://endler.dev/2026/how-other-link-checkers-recurse/) 내린 결론은 **비밀 소스가 없다**는 것이다.\n\n> Every recursive checker is a worklist plus a visited set plus a quiescence detector.\n\n- **frontier** — 발견된 URL 이 다시 들어가는 **가변** 작업 큐. 고정 입력 스트림이 아니다.\n- **visited set** — enqueue 시점에 갱신되는 중복 방지 집합.\n- **quiescence detector** — \"이제 다 끝났나\"에 답하는 종료 감지자.\n\n그래서 \"언어마다 어떻게 구현하나\"는 **\"각 런타임이 이 셋 중 무엇을 기본 제공하고 무엇을 직접 구현하게 하나\"**로 환원된다. 알고리즘은 상수고, 변하는 건 구현 부담이 어디에 놓이느냐뿐이다.\n\n| | 종료 감지 | dedup 동기화 | 대신 감수하는 제약 |\n|---|---|---|---|\n| **Node** (이벤트 루프) | 런타임 제공 (`onIdle`·drain) | **불필요** (락이 없다) | 처리량 — 파싱이 단일 코어를 막고 결과가 인메모리로 팽창 |\n| **Go** (goroutine) | **표준 라이브러리** (`WaitGroup`) | mutex (직접) | 무거운 런타임(GC·스케줄러), unbounded goroutine → RAM |\n| **Python** (스레드) | 직접 (condition var) | 직접 (센티넬) | GIL·스레드 천장 → 처리량 최저, unbounded 큐 → RAM |\n| **Rust** (Tokio) | 직접 조립 | mutex + **borrow checker** | `Send + 'static` 마찰. 대신 레이스가 컴파일조차 안 된다 |\n\n## 진짜 갈림길은 첫 커밋에 있다\n\n응답이 새 입력을 만드는 **사이클**(back-edge)이 크롤러고, `inputs → extract → check → output` 은 **DAG** 다. 나머지 넷은 첫 커밋부터 크롤러 모양이었고 lychee 만 스트림으로 시작했다 — 그 차이가 *\"Five years and four attempts later\"* 다.\n\n```text\n크롤러:  frontier → worker pool → fetch/parse ─┐\n             ↑────────── new links ────────────┘   (사이클)\n\nlychee:  inputs → extractor → checker → results     (DAG, back-edge 없음)\n```\n\n**파이프라인에 사이클을 나중에 우겨넣는 건 처음부터 사이클을 갖는 것보다 압도적으로 어렵다.** 대신 lychee 가 얻은 건 비재귀 경로의 속도다 — 재귀가 1급 요구사항이 아니면 DAG 가 틀린 선택이 아니다. 언어가 아니라 아키텍처 결정이라는 게 요점이다.\n\n## 종료 감지 — 불변식은 하나뿐\n\n큐가 비어도 in-flight 워커가 새 링크를 밀어넣을 수 있으므로 \"큐가 비면 종료\"는 틀렸다. 지켜야 하는 건 하나다.\n\n> 일이 남아 있는 동안 카운터가 **순간적으로라도 0 을 읽는 창**이 있으면 안 된다.\n\n그래서 자식을 부모가 done 처리되기 **전에** 카운트하고, bootstrap 에서 최소 1 을 올려둔 뒤 wait 에 진입한다. lychee 의 Attempt 1·4 가 이 불변식을 손으로 지키지 못해 실패했다.\n\n```go\n// Go: bootstrap 에서 addPage 1회 → 카운터가 양수인 채로 Wait 진입\nfunc (m daemonManager) Run() {\n    go func() { for f := range m.daemons { go f() } }()\n    m.waitGroup.Wait() // 카운터 0 → 종료\n}\n```\n\n```python\n# Python: increment(_put)와 decrement(task_done)가 둘 다 Condition 락 안에 있고,\n# task_done 은 자식 enqueue 를 끝낸 뒤에만 불린다 → 이른 0 이 없다\ndef task_done(self, url_data):\n    with self.all_tasks_done:\n        self.unfinished_tasks -= 1\n        if self.unfinished_tasks <= 0:\n            self.all_tasks_done.notify_all()\n```\n\nNode 는 단일 스레드라 카운터 락이 아예 필요 없고 `await queue.onIdle()` 한 줄이다. Rust 는 2026 에 기여된 `WaitGroup` 으로 같은 자리에 도달했다 — **같은 아이디어가 런타임마다 다른 옷을 입었을 뿐이다.**\n\n## 중복 제거는 요청 *이전*, enqueue 시점\n\n두 페이지가 같은 링크를 동시에 발견하면 둘 다 제출된다. 그래서 visited 표시는 enqueue 와 atomic 해야 하고 **네트워크 요청보다 먼저**여야 한다. 요청을 보낸 뒤에 캐시에 쓰면 그 사이에 중복 제출 창이 열린다 — lychee 가 네 번의 시도 내내 저지른 dedup 레이스의 본체다.\n\n```python\n# Python: 요청 이전에 None 센티넬로 URL 을 선점한다\ndef _put(self, url_data):\n    key = url_data.cache_url\n    if cache.has_result(key): return    # 이미 큐잉·체크됨\n    self.queue.append(url_data)\n    self.unfinished_tasks += 1\n    cache.add_result(key, None)         # \"내 거\" 표시. 이후 동시 발견은 no-op\n```\n\n```js\n// Node: 세 문장이 통째로 중단 없이 실행된다\nconst inCache = options.cache.has(url.href)\nif (!inCache) {\n  options.cache.add(url.href)                 // mark\n  options.pendingChecks.set(url.href, checkPromise)\n  options.queue.add(() => checkPromise)       // enqueue\n}\n```\n\n**Node 에서 이게 세 줄로 끝나는 건 설계 실력이 아니라 언어 기능이다.** Go·Python 은 mutex 를, Rust 는 mutex 와 borrow checker 협상을 직접 짜야 한다 — 저자가 *\"the ~30% 'Rust tax' I estimated last time: not the algorithm, but the friction of expressing shared mutable frontier state under `Send + 'static`\"* 이라고 부르는 것이 그 값이다.\n\n## frontier 와 rate limiter 를 겸하면 데드락\n\n\"다음에 뭘 할까\"와 \"얼마나 빨리 갈까\"를 **하나의 bounded channel** 로 겸하면, 채널이 차는 순간 producer(응답 핸들러)가 블로킹되고 → 응답이 drain 되지 않고 → 슬롯이 안 비워져 영구 정지한다. LinkChecker 의 첫 설계 주석이 이걸 명시적으로 피해 간다.\n\n```python\n# Note: don't put a maximum size on the queue since it would\n# lead to deadlocks when all worker threads called put().\n```\n\n**이 분리는 다섯 구현이 전부 지킨다** — 안 지키면 언어와 무관하게 데드락이다. 대신 unbounded frontier 는 데드락을 메모리 증가와 맞바꾼 것이라, 거대 사이트에서는 URL 상한과 주기적 정리로 완화한다(muffet 도 unbounded goroutine + 인메모리 set 이라 결국 RAM 에 묶인다). 디스크 백드 frontier 를 가진 구현은 이 다섯에 없다.\n\n## 재귀하려면 body 가 있어야 한다\n\n링크 유효성만 보면 `HEAD` 가 싸지만, **재귀는 더 많은 링크를 찾기 위해 응답 본문이 필요하다.** linkinator 는 크롤할 때 항상 `GET` 을 쓰는 쪽으로 단순하게 풀었다.\n\n```js\nresponse = await makeRequest(options.crawl ? 'GET' : 'HEAD', options.url.href)\n```\n\nlychee 의 계획은 check 단계에서 이미 받은 body 를 캐시에 들고 있다가 재귀 때 재사용하는 것이다 — 같은 제약을 중복 요청 없이 푼다."},{"i":"595","t":"네이버 지도 마커 + fitBounds","k":["naver-maps","map","typescript","react"],"c":"2026-09-02","m":"2026-09-02","b":"**언제** — naver.maps v3 에 마커를 여러 개 찍고 카메라를 자동으로 맞출 때. 증상이 있으면 바로 간다.\n\n| 증상 | 레시피 |\n|---|---|\n| \"상위 N개\"인데 지도엔 N개보다 적게 뜬다 | `R2` |\n| `latitude!` 단언이 붙는다 / `NaN` 좌표가 샌다 | `R3` |\n| 마커가 하나일 때 카메라가 아예 안 움직인다 | `R5` |\n| 같은 건물에 두 곳이라 줌이 최대까지 파고든다 | `R6` |\n| margin 을 줬는데 여백이 안 생긴다 | `R7` |\n| 오른쪽·아래 가장자리 마커의 라벨이 잘린다 | `R8` |\n| 숨겨진 탭에서 열면 줌이 엉뚱하다 / 지도가 회색 | `R9` |\n| 목록이 바뀌었는데 옛 마커가 남아 있다 | `R10` |\n| 목록이 자주 바뀌어 깜빡인다 | `R11` |\n| 마커가 수백 개라 무겁다 | `R12` |\n\n`R1`·`R4` 는 나머지가 딛고 서는 기반이다.\n\n**재료**\n\n```ts\ndeclare const MAX_MARKERS: number\ndeclare function createMarkerContent(p: Place): string\n```\n\n## R1. bounds 는 마커가 아니라 데이터에서 계산한다\n\n마커는 렌더링 산출물이지 좌표의 source of truth 가 아니다.\n\n```ts\n// ✗ 좌표를 marker 에 넣었다가 도로 꺼낸다\nconst bounds = new naver.maps.LatLngBounds(\n  markers[0].getPosition() as naver.maps.LatLng,\n  markers[0].getPosition() as naver.maps.LatLng\n)\n\n// ✓ positions 를 먼저 만들고 marker 와 bounds 양쪽이 그걸 쓴다\nconst positions = targets.map((p) => new naver.maps.LatLng(p.latitude, p.longitude))\n```\n\n`Marker.getPosition()` 의 선언 타입이 `Coord`(= `LatLng | Point`)라 캐스팅이 강제된다. **이 캐스팅은 정보 손실을 되돌리는 작업이고, 애초에 손실시키지 않으면 필요 없다.** 부수 효과로 `map.getProjection()` 이나 `Marker` 인스턴스 없이 bounds 계산을 단위 테스트할 수 있게 된다.\n\n## R2. filter 먼저, slice 나중\n\n```ts\n// ✗ 앞 N개 중 좌표 없는 항목이 섞이면 실제 표시 개수가 N보다 적다\nplaces.slice(0, MAX_MARKERS).filter(hasCoords)\n\n// ✓\nplaces.filter(hasCoords).slice(0, MAX_MARKERS)\n```\n\n\"상위 N개\"의 N 은 **표시 가능한 것 중 N개**를 뜻한다. 순서가 바뀌면 뒤에 유효 데이터가 남아 있는데도 지도가 비어 보인다.\n\n## R3. truthiness 대신 타입 가드\n\n```ts\n// ✗ 0 을 탈락시키고, narrowing 이 안 되어 뒤에서 ! 가 붙는다\n.filter((p) => p.latitude && p.longitude)   // → p.latitude!, p.longitude!\n\n// ✓\ntype Located = Place & { latitude: number; longitude: number }\n\nconst isLocated = (p: Place): p is Located =>\n  Number.isFinite(p.latitude) && Number.isFinite(p.longitude)\n\n.filter(isLocated)   // 이후 p.latitude 는 number\n```\n\n`Number.isFinite` 가 `!= null` 보다 나은 이유는 API 가 `NaN` 이나 문자열 `\"\"` 를 흘려보내는 경우까지 한 번에 막히기 때문이다 — `new LatLng(NaN, NaN)` 은 예외를 던지지 않고 조용히 지도를 망가뜨린다.\n\n**한국 좌표계에서 `0` 은 안 나온다**는 건 사실이지만, 이 필터가 만드는 진짜 이득은 `!` 제거다.\n\n## R4. degenerate bounds seeding\n\n`LatLngBounds` 는 빈 상태로 만들 수 없다. 그래서 면적 0 인 bounds 를 만들고 `extend` 로 키운다.\n\n```ts\nconst bounds = positions.reduce(\n  (acc, p) => acc.extend(p),\n  new naver.maps.LatLngBounds(positions[0], positions[0])\n)\n```\n\n- `extend` 는 mutable accumulator 다. 자기를 변형하고 자기를 반환한다.\n- 첫 좌표가 seed 와 루프에서 두 번 들어가도 idempotent 라 무해하다.\n- sw/ne 순서를 직접 계산하다 뒤집는 실수를 원천 차단한다.\n\n마커가 수백 개면 `extend` 반복 대신 숫자로 min/max 를 구해 한 번에 만드는 게 싸다:\n\n```ts\nconst lats = targets.map((p) => p.latitude)\nconst lngs = targets.map((p) => p.longitude)\nconst bounds = new naver.maps.LatLngBounds(\n  new naver.maps.LatLng(Math.min(...lats), Math.min(...lngs)),\n  new naver.maps.LatLng(Math.max(...lats), Math.max(...lngs))\n)\n```\n\n**함정** — `Math.min(...arr)` 는 배열이 만 단위가 되면 스택을 넘긴다(그 규모면 `reduce`). 날짜변경선(±180°)을 걸치는 데이터는 min/max 방식이 지구를 반대로 감싼다(국내 서비스면 무시).\n\n## R5. 개수별 카메라 분기 (0 / 1 / N)\n\n```ts\n// ✗ 하나일 때 카메라가 아예 안 움직인다\nif (markers.length > 1) { map.fitBounds(bounds, PADDING) }\n```\n\n한 곳뿐이면 지도는 초기 중심에 그대로 있고 마커는 화면 밖일 수 있다. 셋 다 처리한다:\n\n```ts\nif (positions.length === 0) {\n  // 아무것도 안 한다 / 빈 상태 UI 로 넘긴다. 카메라는 건드리지 않는다.\n} else if (positions.length === 1) {\n  map.setCenter(positions[0])\n  map.setZoom(SINGLE_MARKER_ZOOM)   // 15~17 정도\n} else {\n  map.fitBounds(bounds, BOUNDS_MARGIN)\n}\n```\n\n0 개일 때 기본 좌표로 되돌릴지 사용자가 보던 위치를 유지할지는 제품 결정이다. 다만 **말없이 아무 일도 안 일어나는 것**과 **의도적으로 유지하는 것**은 코드에서 구분되어야 한다.\n\n## R6. 줌 오버슈트 클램프\n\n가장 자주 터진다. 같은 건물·같은 블록에 두 곳이면 bounds 면적이 거의 0 이라 `fitBounds` 가 최대 줌까지 파고들어 건물 하나만 화면에 남는다.\n\n**A. 지도 레벨에서 상한 (권장)** — 사용자 수동 줌인까지 같이 막히는 게 단점.\n\n```ts\nmap.setOptions({ maxZoom: MAX_AUTO_ZOOM })  // 예: 17\nmap.fitBounds(bounds, BOUNDS_MARGIN)\n```\n\n**B. 사후 클램프** — 동기적으로 적용되지 않는 빌드가 있다. 값이 안 먹으면 한 틱 뒤로 미룬다.\n\n```ts\nmap.fitBounds(bounds, BOUNDS_MARGIN)\nif (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)\n\nnaver.maps.Event.once(map, 'idle', () => {\n  if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)\n})\n```\n\n**C. bounds 에 최소 span** — 겹친 마커가 화면 중앙에 모이는 대신 주변 맥락이 남는다.\n\n```ts\nconst MIN_SPAN = 0.004  // 약 400m\nconst sw = bounds.getSW(), ne = bounds.getNE()\nconst latPad = Math.max(0, MIN_SPAN - (ne.lat() - sw.lat())) / 2\nconst lngPad = Math.max(0, MIN_SPAN - (ne.lng() - sw.lng())) / 2\nif (latPad || lngPad) {\n  bounds.extend(new naver.maps.LatLng(sw.lat() - latPad, sw.lng() - lngPad))\n  bounds.extend(new naver.maps.LatLng(ne.lat() + latPad, ne.lng() + lngPad))\n}\n```\n\n## R7. fitBounds 두 번째 인자 확인\n\n`map.fitBounds(bounds, margin)` 의 margin 은 버전·타입 정의에 따라 `number` 만 받기도 하고 `{ top, right, bottom, left }` 를 받기도 한다. **number 를 넘겼는데 타입 정의가 객체를 기대하면 조용히 무시된다.**\n\n**확인** — 호출 전후로 `map.getBounds()` 를 찍어 여백 차이가 나는지 본다. 안 나면 안 먹은 것이다.\n\n```ts\nconst BOUNDS_MARGIN = { top: 80, right: 24, bottom: 160, left: 24 }\n```\n\n하단을 크게 잡는 이유는 대개 바텀시트·목록 패널이 지도를 덮기 때문이다. 지도 컨테이너는 전체 화면인데 **실제로 보이는 영역**은 그보다 작다. 여백은 그 차이를 보정하는 값이지 미관용 패딩이 아니다.\n\n## R8. anchor 와 fitBounds 의 픽셀 불일치\n\n```ts\nicon: { content, anchor: new naver.maps.Point(0, 0) }\n```\n\nanchor `(0, 0)` 은 HTML 콘텐츠의 **좌상단**을 좌표에 붙이고, 마커 박스는 우하단으로 뻗는다. 그런데 `fitBounds` 는 지리 좌표만 보고 **픽셀 크기를 모른다.** 이름 길이에 따라 콘텐츠 폭이 달라지므로 오른쪽·아래 가장자리 마커는 고정 margin 으로 못 막고 잘린다.\n\n- 핀 형태: `anchor = new Point(width / 2, height)` — 뾰족한 끝이 좌표에 닿는다.\n- 말풍선·라벨: 콘텐츠 크기가 가변이라 anchor 를 상수로 못 준다. `content` 래퍼에 `transform: translate(-50%, -100%)` 를 걸고 anchor 는 `Point(0, 0)` 으로 두는 편이 안정적이다. **CSS 가 픽셀 정렬을 맡고 anchor 는 관여하지 않는다.**\n- 그래도 남는 클리핑은 `BOUNDS_MARGIN` 을 마커 최대 폭의 절반 이상으로 잡아 흡수한다.\n\n## R9. 컨테이너 크기 0 에서 호출하지 않는다\n\n`onReady` 시점에 레이아웃이 확정되지 않았거나(숨겨진 탭, `display:none`, 애니메이션 중) 높이가 0 이면 `fitBounds` 가 엉뚱한 줌을 계산한다. **나중에 컨테이너가 커져도 카메라는 재계산되지 않는다.**\n\n```ts\nconst el = map.getElement()\nif (el.clientWidth === 0 || el.clientHeight === 0) {\n  const ro = new ResizeObserver(() => {\n    if (el.clientWidth && el.clientHeight) {\n      ro.disconnect()\n      map.refresh()          // 내부 사이즈 재측정\n      map.fitBounds(bounds, BOUNDS_MARGIN)\n    }\n  })\n  ro.observe(el)\n  return\n}\n```\n\n탭 전환 후 지도가 회색으로 남는 증상도 같은 원인이고 처방도 `map.refresh()` 다.\n\n## R10. 생성과 부착을 분리하고 생명주기를 반환한다\n\n```ts\n// ✗ 변환 함수 안에서 부수 효과. 붙는 시점을 통제할 수 없다\nconst markers = targets.map((p) => new naver.maps.Marker({ position, map }))\n```\n\n`new Marker({ map })` 은 생성 즉시 지도에 붙는다. 만들어두고 나중에 붙이려면 `map` 을 빼고 `setMap(map)` 을 따로 부른다. 더 중요한 건 **떼는 경로가 있는가**다 — 목록 필터가 바뀌어 함수가 다시 불리면 이전 마커는 지도에 그대로 남는다.\n\n```ts\nreturn {\n  markers,\n  destroy() {\n    for (const m of markers) {\n      naver.maps.Event.clearInstanceListeners(m)\n      m.setMap(null)\n    }\n  },\n}\n```\n\n```ts\nuseEffect(() => {\n  if (!map) return\n  const layer = placeMarkers(map, places)\n  return () => layer.destroy()\n}, [map, places])\n```\n\n**함정** — `setMap(null)` 만으로는 리스너가 정리되지 않아 마커 인스턴스가 GC 되지 않는다. `Event.addListener(marker, 'click', ...)` 가 있으면 `clearInstanceListeners` 가 필수다. 그리고 `places` 가 매 렌더 새 배열이면 전량 재생성된다 — `useMemo` 로 안정화하거나 `R11` 로 넘어간다.\n\n## R11. 전량 재생성 대신 id 기준 diff\n\n목록이 자주 바뀌고 마커가 수십 개 이상이면 전량 파기·재생성은 깜빡임과 GC 압박을 만든다.\n\n```ts\nconst registry = new Map<string, naver.maps.Marker>()\n\nfunction sync(map: naver.maps.Map, targets: Located[]) {\n  const next = new Set(targets.map((p) => p.id))\n\n  for (const [id, marker] of registry) {\n    if (!next.has(id)) {\n      naver.maps.Event.clearInstanceListeners(marker)\n      marker.setMap(null)\n      registry.delete(id)\n    }\n  }\n\n  for (const p of targets) {\n    const existing = registry.get(p.id)\n    if (existing) {\n      existing.setPosition(new naver.maps.LatLng(p.latitude, p.longitude))\n    } else {\n      registry.set(p.id, new naver.maps.Marker({ /* ... */ map }))\n    }\n  }\n}\n```\n\n선택 상태 변화(강조 마커)는 마커를 재생성하지 말고 `marker.setIcon({ content: ... })` 로 콘텐츠만 교체한다.\n\n## R12. 개수가 커지면 클러스터링\n\n수백 개를 그대로 찍으면 DOM 마커(HTML content) 기준으로 스크롤·줌이 눈에 띄게 무거워진다. 네이버는 `MarkerClustering` 을 코어에 포함하지 않고 별도 오픈소스 모듈로 배포한다. `MAX_MARKERS` 로 자르는 건 그 전 단계의 임시방편이고, 상한을 넘겼을 때 **\"일부만 표시 중\"임을 사용자에게 알릴지** 결정해둔다.\n\n## 조립\n\n```ts\ntype Located = Place & { latitude: number; longitude: number }\n\nconst isLocated = (p: Place): p is Located =>\n  Number.isFinite(p.latitude) && Number.isFinite(p.longitude)\n\nconst MAX_AUTO_ZOOM = 17\nconst SINGLE_MARKER_ZOOM = 16\nconst BOUNDS_MARGIN = { top: 80, right: 24, bottom: 160, left: 24 }\n\nexport function placeMarkers(map: naver.maps.Map, places: Place[]) {\n  const targets = places.filter(isLocated).slice(0, MAX_MARKERS)\n  const positions = targets.map(\n    (p) => new naver.maps.LatLng(p.latitude, p.longitude)\n  )\n\n  const markers = targets.map(\n    (place, i) =>\n      new naver.maps.Marker({\n        position: positions[i],\n        map,\n        icon: {\n          content: createMarkerContent(place),\n          anchor: new naver.maps.Point(0, 0), // 정렬은 content 래퍼 CSS 담당\n        },\n      })\n  )\n\n  if (positions.length === 1) {\n    map.setCenter(positions[0])\n    map.setZoom(SINGLE_MARKER_ZOOM)\n  } else if (positions.length > 1) {\n    const bounds = positions.reduce(\n      (acc, p) => acc.extend(p),\n      new naver.maps.LatLngBounds(positions[0], positions[0])\n    )\n    map.fitBounds(bounds, BOUNDS_MARGIN)\n    if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)\n  }\n\n  return {\n    markers,\n    destroy() {\n      for (const m of markers) {\n        naver.maps.Event.clearInstanceListeners(m)\n        m.setMap(null)\n      }\n    },\n  }\n}\n```\n\n## 확인\n\n- [ ] 좌표 결측 항목을 자르기 **전에** 걸러내는가\n- [ ] `latitude!` 같은 non-null 단언이 남아 있지 않은가\n- [ ] 마커가 0 개 / 1 개일 때 카메라 동작이 정의되어 있는가\n- [ ] 같은 건물에 두 곳일 때 줌이 최대까지 튀지 않는가\n- [ ] `fitBounds` 의 margin 이 실제로 반영되는가 (`getBounds()` 로 확인)\n- [ ] 바텀시트·헤더가 덮는 영역이 margin 에 반영되어 있는가\n- [ ] 가장 오른쪽·아래 마커의 라벨이 잘리지 않는가 (가장 긴 이름으로 테스트)\n- [ ] 숨겨진 탭에서 초기화될 때 `map.refresh()` 경로가 있는가\n- [ ] 재호출 시 이전 마커가 제거되는가\n- [ ] 리스너가 `clearInstanceListeners` 로 정리되는가"},{"i":"594","t":"React 컴포넌트 안정성 규칙 열셋 — 앱 컴포넌트와 라이브러리 컴포넌트","k":["react","ssr","hydration","concurrent","rsc"],"c":"2026-09-02","m":"2026-09-10","b":"**언제** — 컴포넌트에 이 규칙을 적용할지 정할 때. **먼저 이게 앱 컴포넌트인지 라이브러리 컴포넌트인지 가른다.** 앱 컴포넌트는 자기가 어디서 쓰이는지 알기 때문에 절반은 해당이 없다 — 전부 예방적으로 적용하면 그게 새로운 over-engineering 이다.\n\n| # | 규칙 | 적용 |\n|---|---|---|\n| R1 | 외부 store 는 `useSyncExternalStore` | 공통 |\n| R2 | FOUC 는 블로킹 스크립트 + 단일 소유권 | 공통 |\n| R3 | render 출력을 client-only 값으로 가르지 않는다 | 공통 |\n| R4 | 파생은 순수 함수, 지속성이 correctness 면 state | 공통 |\n| R5 | SSR 안정 ID 는 `useId` | 공통 |\n| R6 | 자식 주입은 `cloneElement` 아닌 context | 공통 |\n| R7 | isomorphic layout effect shim | 조건부 |\n| R8 | 이벤트는 `ownerDocument.defaultView` 에 | 라이브러리 |\n| R9 | Activity 로 숨을 때 전역 부수효과를 끈다 | 라이브러리·조건부 |\n| R10 | ViewTransition 은 `startTransition` 으로 | 조건부 |\n| R11 | 민감 데이터는 prop 을 좁히고, taint 은 이중 안전망 | 공통(RSC) |\n| R12 | effect 는 StrictMode 이중 실행에 멱등 | 공통 |\n| R13 | ref 콜백이 cleanup 을 반환한다 (React 19) | 공통 |\n\n**공통 여덟 · 조건부/라이브러리 다섯.** 앱 컴포넌트면 공통 여덟으로 대부분 끝난다. `R1`~`R4`(상태·SSR)가 압도적으로 자주 물리고, `R8`~`R10` 은 진짜 edge case 다.\n\n## R1. 외부 store 는 useSyncExternalStore 로 읽는다\n\n**증상** — 테마·인증·미디어쿼리처럼 React 밖에 사는 상태를 `useState` + `useEffect` 로 읽으면 concurrent 렌더링에서 트리 일부는 옛 값, 일부는 새 값으로 찢어진다(tearing). SSR 이면 초기값이 안 맞아 깜빡인다.\n\n**원인** — `useEffect` 는 커밋 *후* 라 첫 렌더가 항상 stale. concurrent 렌더러는 렌더 도중 외부 값이 바뀌면 일관성을 보장하지 않는다.\n\n```ts\nconst value = useSyncExternalStore(\n  subscribe,          // (cb) => unsubscribe\n  getSnapshot,        // 클라이언트 현재값\n  getServerSnapshot,  // SSR 안정값 — 이게 공짜로 딸려오는 SSR 대응\n)\n```\n\n**함정** — `getSnapshot` 이 객체를 반환하면 매번 새 참조라 무한 루프다. 값이 안 변하면 같은 참조를 캐시해서 돌려줄 것. primitive 면 안전하다. `subscribe` 의 add/remove 가 멱등이어야 StrictMode 이중 마운트에서 안 샌다(`R12`).\n\n## R2. FOUC 는 블로킹 스크립트 + 단일 소유권으로 막는다\n\n**증상** — 다크 테마 사용자가 새로고침하면 흰 화면이 한 프레임 번쩍인다. 서버는 유저 테마(localStorage)를 모르니 기본값으로 렌더하고, 하이드레이션 후에야 교정된다.\n\n**절차** — 첫 페인트 *전* 실행되는 스크립트가 `<html>` 에 속성 하나를 쓰고, **React 는 그 속성을 JSX 로 렌더하지 않는다.**\n\n```tsx\n<html lang=\"ko\" suppressHydrationWarning>\n  <head><script dangerouslySetInnerHTML={{ __html: themeScript }} /></head>\n```\n\n```js\n// themeScript: import 없는 자기완결 문자열\n(function(){try{\n  var d = /* resolve dark? */\n  var r = document.documentElement, t = d ? \"dark\" : \"light\"\n  r.dataset.theme = t; r.style.colorScheme = t\n}catch(e){}})()\n```\n\n**확인** — 스크립트가 건드리는 속성을 React 가 `className={...}` 으로 다시 렌더하고 있지 않은가. 둘 다 소유하면 mismatch 다.\n\n**함정** — `suppressHydrationWarning` 은 *그 엘리먼트의 속성*만 억제한다. 자식에 전파되지 않는다.\n\n## R3. render 출력을 client-only 값으로 가르지 않는다\n\n**증상** — `localStorage.getItem()` 을 컴포넌트 본문에서 부르면 서버에서 크래시하고, `typeof window` 가드로 우회하면 이번엔 hydration mismatch.\n\n**원인** — 문제는 \"브라우저 API\" 가 아니라 **render 단계에서 client-only 값으로 출력을 가르는 것**이다. 서버와 클라가 다른 트리를 만든다.\n\n**절차** — client-only 값은 effect 나 `useSyncExternalStore`(`R1`)를 거쳐 들어오게 하고, 첫 렌더는 서버와 같은 출력을 낸다.\n\n**함정** — `useState(() => localStorage...)` 의 lazy init 도 서버에서 실행된다. 크래시는 그대로다.\n\n## R4. 파생은 순수 함수, 지속성이 correctness 면 state\n\n**증상** — `useMemo(getRandomColors, [])` 로 만든 값이 HMR·리마운트 후 슬그머니 바뀐다.\n\n**원인** — 흔한 오진은 \"React 가 캐시를 버려서\". 진짜 원인은 `getRandomColors` 가 impure 라서다. 순수했다면 재계산돼도 같은 값이라 티가 안 난다. `useMemo` 는 성능 힌트지 의미 보장이 아니다.\n\n```tsx\n// 파생(순수)은 렌더 중 그냥 계산 — memo 도 과하다\nconst resolved = resolveTheme(mode, systemDark)\n\n// 한 번 만들고 유지돼야 correctness 면 state (lazy init 로 1회 실행)\nconst [colors] = useState(getRandomColors)\n```\n\n**함정** — 비결정적이거나 부수효과가 있는 계산을 `useMemo` 에 넣지 않는다.\n\n## R5. SSR 안정 ID 는 useId\n\n**증상** — `Math.random()`·모듈 카운터로 만든 id 가 서버·클라에서 불일치.\n\n```ts\nconst id = useId()  // 트리 위치 기반이라 SSR 안정\n```\n\n**함정** — 반환값에 특수문자가 들어갈 수 있다. `getElementById(id)` 는 안전하지만 `querySelector('#' + id)` 나 CSS 셀렉터에서는 깨진다. raw id 를 받는 API 를 쓴다.\n\n## R6. 자식 주입은 cloneElement 대신 context\n\n**증상** — `cloneElement` 로 자식에 props 를 꽂는 컴포넌트가 Fragment·lazy·다중 자식에서 부서진다.\n\n```tsx\nreturn <Ctx value={value}>{children}</Ctx>  // React 19: context 가 곧 provider\n// 자식: const v = use(Ctx)\n```\n\n`cloneElement` 는 타입이 불안정하고 자식 구조에 결합된다. 사실상 항상 context 가 낫다.\n\n## R7. useLayoutEffect 의 SSR 경고는 isomorphic shim 으로\n\n```ts\nconst useIsoLayoutEffect =\n  typeof window !== 'undefined' ? useLayoutEffect : useEffect\n```\n\n**적용** — 커밋 전에 DOM 을 만져 flash 를 막아야 하는데 SSR 도 하는 컴포넌트(테마 토글 등). 단순 effect 면 `useEffect` 로 충분하다.\n\n## R8. 이벤트는 올바른 window 에 건다\n\n**증상** — 컴포넌트를 `window.open`·포털로 다른 창에 렌더했더니 `window.addEventListener` 가 원래 창을 듣는다.\n\n```ts\nconst win = ref.current?.ownerDocument.defaultView ?? window\nwin.addEventListener(/* ... */)\n```\n\neffect 는 커밋 후라 `ref.current` 가 채워져 있다.\n\n**적용** — 멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트만. 리스너 정리 자체의 1차 도구는 `R13` 이다.\n\n## R9. Activity 로 숨겨질 때 전역 부수효과를 끈다\n\n**증상** — `<Activity mode=\"hidden\">` 안의 컴포넌트가 주입한 전역 `:root` 스타일이 숨긴 뒤에도 남아 샌다.\n\n**원인** — Activity 는 숨길 때 effect 를 정리하고 보일 때 재실행하지만, 순수 `<style>` DOM 노드 자체는 보존한다.\n\n```ts\nuseLayoutEffect(() => {\n  const el = ref.current\n  if (!el) return\n  el.media = 'all'\n  return () => { if (ref.current) ref.current.media = 'not all' }  // cleanup 도 가드\n}, [])\n```\n\n**적용** — Activity(실험적) 경계와 전역 스타일 주입이 겹칠 때만. 매우 니치.\n\n## R10. ViewTransition 을 트리거하려면 startTransition\n\n상태를 바꿨는데 `<ViewTransition>` 애니메이션이 안 도는 이유는 하나다 — ViewTransition 은 transition 으로 표시된 업데이트만 애니메이트한다. `startTransition(() => setState(...))`.\n\n## R11. 민감 데이터는 prop 을 좁히고, taint 은 이중 안전망\n\n**증상** — 서버 컴포넌트에서 `user` 객체를 통째로 클라이언트 컴포넌트에 넘겨 토큰·이메일이 번들에 실린다.\n\n```tsx\n<ClientThing theme={user.theme} />   // user 통째 ❌ → 필드만 ⭕\n```\n\n그 위에 2차 안전망으로:\n\n```ts\nexperimental_taintUniqueValue('서버에만 두세요', user, user.token)\n```\n\n**순서가 핵심이다** — prop 좁히기가 1차 해법이고 taint 은 defense-in-depth 다. 지루하고 올바른 수를 건너뛰고 실험적 도구로 점프하지 않는다. `experimental_` 이라 API 변동도 있다.\n\n## R12. effect 는 StrictMode 이중 실행에 멱등이어야 한다\n\n**증상** — dev 에서만 구독이 두 번 걸리거나 애니메이션이 두 번 뛴다.\n\n**원인** — StrictMode 는 mount → unmount → mount 로 effect 를 두 번 돌려 정리 누락을 드러낸다. 프로덕션 버그의 조기경보다.\n\n```ts\nuseEffect(() => {\n  const un = subscribe(cb)\n  return un        // 정리가 곧 멱등성\n}, [])\n```\n\nadd 에 대응하는 remove, 시작에 대응하는 취소. **경고가 아니라 무료 테스트라서 끄지 않는다.**\n\n## R13. ref 콜백은 정리 함수를 반환한다 (React 19)\n\n```tsx\n<div ref={(node) => {\n  const ctrl = new AbortController()\n  node.addEventListener('scroll', onScroll, { signal: ctrl.signal })\n  return () => ctrl.abort()   // 노드가 떠날 때 정리\n}} />\n```\n\n리스너를 노드 수명에 묶어야 할 때 effect + ref 조합보다 근본적이고 정확하다."},{"i":"593","t":"Chrome 외부 앱 딥링크 \"항상 열기\" 해제","k":["chrome","deep-link","troubleshooting","macos"],"c":"2026-09-01","m":"2026-09-01","b":"**언제** — 커스텀 스킴(`myapp://`) 다이얼로그에서 \"항상 열기 허용\"을 실수로 체크해 되돌리고 싶을 때. **설정 UI 에는 항목이 없고** 실제 상태는 프로필의 JSON 에 origin 단위로 저장된다.\n\n- 체크는 `Preferences` 의 `protocol_handler.allowed_origin_protocol_pairs` 에 `{ \"origin\": { \"scheme\": true } }` 로 저장된다.\n- `chrome://settings/handlers` 는 **웹 프로토콜 핸들러**(`mailto:` 등 사이트가 등록한 것)만 다룬다. 외부 앱 실행 허용과는 별개다.\n- **Chrome 은 종료 시 메모리 상태를 `Preferences` 에 덮어쓴다 — 실행 중에 파일을 고치면 되돌아간다.**\n- MAC 해시로 보호되는 pref 는 `Secure Preferences` 에 들어가며 손으로 고치면 무시·초기화된다.\n\n```\n다이얼로그를 다시 띄우고 싶다\n ├─ R1 사이트 데이터 삭제 ─── 해결? → 끝\n ├─ Chrome 완전 종료\n ├─ R2 Preferences 편집 ─── protocol_handler 있음? → 편집 → 끝\n ├─ R3 grep 추적 ─┬─ 다른 프로필/브라우저? → 경로 바꿔 R2\n │                ├─ Secure Preferences? → R1\n │                └─ chrome://policy → R4\n └─ macOS 인데 여전히 안 뜸 → R5 (Launch Services)\n```\n\n---\n\n## R1. 사이트 데이터 삭제 (가장 안전, 우선 시도)\n\n`chrome://settings/content/all` → 해당 사이트 검색 → 항목 클릭 → \"데이터 및 권한 삭제\".\n\n외부 프로토콜 허용이 origin 단위 사이트 데이터에 묶여 있어 함께 초기화되는 경우가 많다.\n\n---\n\n## R2. Preferences 직접 편집\n\nR1 로 안 될 때. **반드시 Chrome 을 완전히 종료**(백그라운드 프로세스 포함)한 뒤 진행한다.\n\n| OS | 경로 |\n|---|---|\n| macOS | `~/Library/Application Support/Google/Chrome/<Profile>/Preferences` |\n| Windows | `%LOCALAPPDATA%\\Google\\Chrome\\User Data\\<Profile>\\Preferences` |\n| Linux | `~/.config/google-chrome/<Profile>/Preferences` |\n\n`<Profile>` 은 보통 `Default` 또는 `Profile 1`… 정확한 값은 `chrome://version` 의 \"프로필 경로\"에서 본다.\n\n한 줄로 압축된 JSON 이라 `jq` 가 편하다.\n\n```bash\njq '.protocol_handler.allowed_origin_protocol_pairs' Preferences            # 확인\n\njq 'del(.protocol_handler.allowed_origin_protocol_pairs[\"https://example.com\"])' \\\n  Preferences > tmp && mv tmp Preferences                                   # 특정 origin\n\njq 'del(.protocol_handler.allowed_origin_protocol_pairs)' \\\n  Preferences > tmp && mv tmp Preferences                                   # 전체 초기화\n```\n\n**확인** — Chrome 재시작 후 다이얼로그가 다시 뜬다. `excluded_schemes` 에 `true` 로 들어간 스킴은 \"차단\" 상태이니 반대로 차단을 풀고 싶을 때 같이 본다.\n\n---\n\n## R3. 안 보일 때 추적\n\n`protocol_handler` 가 비어 보이거나 없을 때.\n\n```bash\ngrep -l \"myapp\" Preferences \"Secure Preferences\" \"Local State\" 2>/dev/null\ngrep -o '.\\{200\\}myapp.\\{200\\}' Preferences     # 한 줄 JSON이라 주변 컨텍스트째로\n```\n\n체크리스트 — **다른 프로필**(`Default` 가 아닐 수 있다, `chrome://version` 재확인) / **다른 브라우저**(Beta·Canary·Chromium·Edge 는 완전히 별도 디렉터리) / **`Secure Preferences` 에 있으면** 파일 편집을 포기하고 R1 로 / **검색 실패**는 Chrome 실행 중이라 덮어써졌거나 한 줄 JSON 에서 눈으로 놓친 것.\n\n---\n\n## R4. 정책으로 걸린 경우\n\n`chrome://policy` 에서 `AutoLaunchProtocolsFromOrigins` 를 본다. 값이 있으면 사용자가 체크한 게 아니라 **관리 정책으로 처음부터 다이얼로그가 억제된 것**이라 로컬 편집으로 못 지운다. 회사 관리 기기면 IT/MDM 쪽 문제다.\n\n반대로 스킴 자체를 막고 싶으면 `URLBlocklist` 에 `myapp://*` 를 넣는다.\n\n---\n\n## R5. macOS — Chrome 밖일 가능성\n\nChrome 이 아니라 OS(Launch Services)가 스킴을 확인 없이 넘기는 경우.\n\n```bash\n/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \\\n  -dump | grep -B5 -A5 \"myapp:\"\n```\n\n앱이 스킴 핸들러로 등록돼 있으면 여기서 확인된다."},{"i":"592","t":"TOTP 2FA 구현 (otpauth)","k":["totp","2fa","authentication","security","typescript"],"c":"2026-09-01","m":"2026-09-02","b":"**언제** — 앱 기반 2FA(TOTP, RFC 6238)를 직접 붙일 때. **먼저 직접 구현할 일인지부터 가른다.**\n\n- **TOTP 직접 구현이 디폴트다.** 위탁할 \"OTP 서비스\"라는 게 개념적으로 없다.\n- **매니지드 인증**(Auth0·Cognito·Firebase·Clerk·Supabase)을 쓰면 MFA 가 기능으로 내장돼 만질 일이 없다. 단 이건 \"인증 전체 위탁\"이지 \"OTP 만 위탁\"이 아니다.\n- **SMS/Email OTP** 는 별개다. 생성은 `랜덤 + Redis TTL` 로 trivial 하고, 서드파티를 쓰는 이유는 **전달 인프라** 때문이다.\n- **금융 OTP·본인인증**은 규제 영역이라 외부 인증기관이 강제된다. 직접 구현 대상이 아니다.\n\n**멘탈 모델을 먼저 잡는다.** TOTP 는 서버와 Authenticator 앱이 secret 을 공유한 뒤 **각자 독립적으로 같은 코드를 계산**하는 구조다. 등록 시점(QR 스캔) 이후로는 둘 사이에 통신이 없다.\n\n```\n[등록 시 단 한 번]  서버 ──(secret 을 QR 로)──> 사용자 폰 앱\n[그 이후 통신 0]    서버: secret + 현재시각 → HMAC → 코드 A\n                    앱:   secret + 현재시각 → HMAC → 코드 B\n                    사용자가 B 입력 → 서버가 A == B 비교\n```\n\n- ❌ \"OTP 검증 서버에 요청\" → **그런 서버 없다.** 라이브러리 안에 네트워크 호출 0.\n- ❌ \"서버가 사용자에게 코드 발송\" → 그건 SMS/Email OTP, 별개 물건.\n\n라이브러리는 **토큰 생성·검증(순수 알고리즘)만** 담당한다. secret 암호화 저장·복구 코드·재사용 방지·throttling 은 전부 직접 구현이다.\n\n---\n\n## R1. 등록 — secret 생성 & QR 용 URI (서버)\n\n```ts\nimport * as OTPAuth from \"otpauth\"\n\nconst secret = new OTPAuth.Secret({ size: 20 })    // 최소 128비트, 160비트(20B) 권장\n\nconst totp = new OTPAuth.TOTP({\n  issuer: \"Acme\",                  // 서비스명 (앱에 그룹명으로 표시)\n  label: \"user@example.com\",       // 계정 식별자\n  algorithm: \"SHA1\", digits: 6, period: 30,\n  secret,\n})\n\nconst uri = totp.toString()\n// otpauth://totp/Acme:user@example.com?issuer=Acme&secret=…&algorithm=SHA1&digits=6&period=30\nconst secretBase32 = secret.base32  // 저장할 값 — 아직 pending, R2 통과 전까지 활성화 X\n```\n\nQR 이미지 생성은 라이브러리 범위 밖이라 `qrcode` 같은 별도 패키지로 `uri` 를 인코딩한다.\n\n**함정** — `algorithm`·`digits`·`period` 는 특별한 이유 없으면 기본값을 고정한다. **대부분의 Authenticator 앱이 SHA1/6/30 만 안정적으로 지원한다.**\n\n---\n\n## R2. 등록 확정 — 초기 검증\n\nQR 스캔 직후 앱에 뜬 코드를 입력하게 해서 secret 이 제대로 공유됐는지 확인한다. **여기 통과해야만 2FA 를 활성화한다.**\n\n```ts\nconst delta = totp.validate({ token: userInput, window: 1 })\nif (delta !== null) {\n  // secret 을 암호화해 저장 + 2FA 활성 플래그 on + 복구 코드 발급\n} else {\n  // 재입력 요청 (등록 미완료 유지)\n}\n```\n\n`validate()` 는 **delta(정수) 또는 null** 을 반환한다. null 이면 window 안에서 못 찾은 것 = 무효다.\n\n---\n\n## R3. 로그인 시 검증 (매 로그인)\n\nID/PW 1차 인증 통과 후, DB 의 secret 으로 TOTP 를 재구성한다.\n\n```ts\nconst totp = new OTPAuth.TOTP({\n  issuer: \"Acme\", label: \"user@example.com\",\n  algorithm: \"SHA1\", digits: 6, period: 30,\n  secret: OTPAuth.Secret.fromBase32(savedSecretBase32),   // 복호화해 로드\n})\nconst delta = totp.validate({ token: userInput, window: 1 })\n```\n\n보조 메서드 — `totp.generate()` 는 지금 이 순간의 코드(앱이 하는 계산과 동일, 디버깅용), `totp.counter()` 는 경과 구간 수(**재사용 방지 키**), `totp.remaining()` 은 코드가 바뀌기까지 남은 ms(UI 카운트다운).\n\n**URI ↔ 객체** — `totp.toString()` / `OTPAuth.URI.parse(uri)`.\n\n---\n\n## R4. window 와 clock drift\n\n서버 시계와 폰 시계는 몇 초씩 어긋난다. `window: N` 은 현재 구간 기준 앞뒤 N개 구간(±N×period 초)까지 허용한다는 뜻이다.\n\n- `window: 1`, `period: 30` → **±30초 허용 (실무 표준)**\n- 키우면 유효 코드 개수가 늘어 브루트포스에 취약하다 → 최소화 원칙\n\n---\n\n## 라이브러리가 안 해주는 것 (전부 직접)\n\n| 항목 | 처리 |\n|---|---|\n| secret 저장 | 평문 금지. 앱 레벨 암호화(KMS/envelope) 후 DB |\n| secret 노출 | 서버에만 보관. 클라이언트엔 등록 시 QR 로 **1회만** |\n| window 크기 | 기본 1 유지 |\n| 재사용 방지 | 성공한 `counter()` 값을 저장 → 같은 구간 코드 재검증 차단 |\n| throttling | OTP 입력 시도 횟수 제한 (필수) |\n| 복구 수단 | 복구 코드 별도 발급·해시 저장 |\n\n보안 고려사항은 RFC 4226 §7, RFC 6238 §5.\n\n---"},{"i":"591","t":"영상 → 번역 자막 파이프라인 (온디바이스)","k":["subtitle","stt","translation","ffmpeg","macos"],"c":"2026-09-01","m":"2026-09-01","b":"**언제** — 영상에 번역 자막을 붙일 때. **배치 처리 전제, 실시간 아님.** STT 는 만들지 않고 이미 있는 걸 쓴다 — 새로 만드는 건 R4 이후 전부다.\n\n```\nvideo ──(R1)──> 단어 타임스탬프 JSON ──(R4)──> 문장 ──(R5)──> 번역\n                                            (R6) 문맥 보정 ──┤\n                                              (R7) 자막 큐 ──> (R8) VTT ──> (R9) mux\n```\n\n---\n\n## R0. 환경 확인\n\n```bash\nsw_vers -productVersion          # macOS 26 이상\nbrew install finnvoor/tap/yap\nyap --help                       # --json, --word-timestamps, --locale 이 보이면 정상\n```\n\n**함정** — 언어 asset 은 최초 사용 시 다운로드된다. **오프라인에서 첫 실행하면 실패한다.** 대상 언어로 짧은 파일 하나를 미리 돌려 받아둔다.\n\n---\n\n## R1. 영상에서 바로 전사\n\n```bash\nyap transcribe input.mp4 --json --word-timestamps -o out.json\n```\n\nffmpeg 없이 mp4/mov 에서 단어별 시작/끝 시간이 나온다.\n\n**함정** — **`--srt` 로 받으면 안 된다.** 큐 경계가 이미 고정되어 R7 에서 재분할할 수 없다. **이 파이프라인 전체가 단어 타임스탬프 위에 서 있다.**\n\n---\n\n## R2. 열리지 않는 파일 fallback\n\nR1 이 실패했을 때만 실행한다. 무조건 앞에 두지 않는다.\n\n```bash\nffmpeg -i input.mkv -vn -ac 1 -c:a pcm_s16le -ar 16000 audio.wav\nyap transcribe audio.wav --json --word-timestamps -o out.json\n```\n\n**함정** — sample rate 를 임의로 고정하지 말 것. 리샘플링이 한 번 더 들어가면 손해다. 위 16k 는 관례값일 뿐이므로 품질이 의심되면 `-ar` 없이 원본 rate 로 뽑아 비교한다.\n\n---\n\n## R3. 언어 판별 후 재전사\n\n소스 언어를 모를 때. 배치이므로 두 번 돌려도 된다.\n\n```bash\nffmpeg -i input.mp4 -t 60 -vn -c:a pcm_s16le probe.wav   # 앞 60초만\nyap transcribe probe.wav --txt -o probe.txt\n```\n\n`probe.txt` 를 판별기(`franc` 정도로 충분)에 넣어 locale 을 확정한 뒤 R1 을 그 locale 로 재실행한다.\n\n**함정** — 1차 전사를 틀린 locale 로 돌리면 출력이 **음차 표기**로 나온다. 그래도 언어 판별에는 충분한 신호가 남으니 결과가 이상하다고 겁먹지 말 것.\n\n---\n\n## R4. 단어 → 문장 재조립\n\n**큐 단위로 번역하면 어순이 깨진다.** 번역 단위를 문장으로 만든다. 문장 분리는 Node 내장 `Intl.Segmenter` 로 하면 Swift 브릿지가 필요 없다.\n\n```ts\ntype Word = { text: string; start: number; end: number }      // 초 단위\ntype Sentence = { text: string; start: number; end: number }\n\nfunction toSentences(words: Word[], locale: string): Sentence[] {\n  const full = words.map(w => w.text).join(' ')\n  const offsets: number[] = []                                 // 문자 오프셋 → 단어 인덱스\n  let pos = 0\n  for (const w of words) { offsets.push(pos); pos += w.text.length + 1 }\n\n  const seg = new Intl.Segmenter(locale, { granularity: 'sentence' })\n  const out: Sentence[] = []\n  for (const { segment, index } of seg.segment(full)) {\n    const text = segment.trim()\n    if (!text) continue\n    const from = offsets.findIndex(o => o >= index)\n    const toIdx = offsets.findIndex(o => o >= index + segment.length)\n    const last = (toIdx === -1 ? words.length : toIdx) - 1\n    out.push({ text, start: words[from].start, end: words[last].end })\n  }\n  return out\n}\n```\n\nyap JSON 스키마는 버전에 따라 다르므로 `normalize(raw): Word[]` 어댑터 하나만 두고 **거기만 고치면 나머지가 그대로 돈다.**\n\n**확인** — 문장 수가 육안 문장 수와 대략 맞고 `start` 가 단조 증가한다.\n\n**함정** — `Intl.Segmenter` 는 축약형(`Dr.`, `etc.`)에서 오분할한다. **번역 품질만 조금 떨어질 뿐 타임코드는 안 깨지므로** 초기에는 무시하고 넘어간다.\n\n---\n\n## R5. 문장 배치 번역\n\nstdin JSONL → stdout JSONL 인터페이스가 다루기 편하다.\n\n```bash\njq -c '.[] | {id, text}' sentences.json | translate-cli --from en --to ko > translated.jsonl\n```\n\n**확인** — **입력 문장 수와 출력 줄 수가 정확히 같아야 한다.** 다르면 매핑이 어긋나 이후 전부 밀린다. 여기에 assert 를 넣는다.\n\n**함정** — 문장을 개별 호출로 돌리면 세션 생성 비용이 지배적이 된다. 반드시 한 세션에서 배치로 넘긴다.\n\n---\n\n## R6. 문맥 보정 (선택)\n\n문장 단위 번역이 놓치는 것을 복구한다 — 용어 통일, 대명사·주어 복원, 고유명사 표기 고정. **배치이므로 전체 전사문을 컨텍스트로 줄 수 있다.** 실시간에서는 불가능했던 단계다.\n\n**확인** — 보정 전후 diff 를 눈으로 본다. 개선이 아니라 개악인 경우가 흔하다.\n\n**함정** — 모델이 문장을 병합하거나 분할하면 R4 의 타임코드 매핑이 무효가 된다. **문장 id 를 반드시 유지시키고, id 집합이 바뀌면 보정 결과를 버린다.** 이 단계는 실패해도 파이프라인이 멈추지 않아야 한다.\n\n---\n\n## R7. 번역문 → 자막 큐 재분할\n\n**여기가 유일하게 진짜 어려운 부분이다.** 문장을 읽을 수 있는 크기로 자르고 시간을 배분한다.\n\n```ts\nconst CFG = { maxCharsPerLine: 18, maxLines: 2, minDur: 1.0, maxDur: 7.0, maxCps: 12 }\n\nfunction toCues(s: Sentence): Cue[] {\n  const budget = CFG.maxCharsPerLine * CFG.maxLines\n  const chunks: string[] = []                       // 1) 어절 경계로 청크 분할\n  let cur = ''\n  for (const w of s.text.split(/\\s+/)) {\n    if (cur && (cur + ' ' + w).length > budget) { chunks.push(cur); cur = w }\n    else cur = cur ? cur + ' ' + w : w\n  }\n  if (cur) chunks.push(cur)\n\n  const total = chunks.reduce((a, c) => a + c.length, 0)   // 2) 글자수 비율로 시간 배분\n  const span = s.end - s.start\n  let t = s.start\n  return chunks.map(c => {\n    const dur = Math.max(CFG.minDur, Math.min(CFG.maxDur, span * (c.length / total)))\n    const cue = { start: t, end: t + dur, lines: wrap(c) }\n    t += dur\n    return cue\n  })\n}\n```\n\n**확인** — 큐가 겹치지 않고 각 큐의 CPS 가 `maxCps` 이하인가.\n\n**함정** — **`minDur` 클램프 때문에 짧은 청크가 늘어나면 시간이 문장 구간을 넘어가고, 그 오차가 누적되어 뒤로 갈수록 자막이 밀린다.** 문장 단위로 `t` 를 `s.start` 로 리셋하므로 문장 경계에서는 복구되지만, 문장 내부에서 밀림이 보이면 클램프 대신 **청크 수를 줄이는 쪽으로** 조정한다. `maxCharsPerLine`·`maxCps` 는 언어와 시청자에 따라 다르므로 실제 영상으로 보면서 맞춘다.\n\n---\n\n## R8. WebVTT 직렬화\n\nSRT 대신 VTT — 스타일과 다중 트랙에 유리하다.\n\n```ts\nconst ts = (sec: number) => {\n  const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60\n  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${s.toFixed(3).padStart(6,'0')}`\n}\nconst toVtt = (cues: Cue[]) => 'WEBVTT\\n\\n' + cues.map((c, i) =>\n  `${i + 1}\\n${ts(c.start)} --> ${ts(c.end)}\\n${c.lines.join('\\n')}\\n`).join('\\n')\n```\n\n원문과 번역을 **각각 별도 파일로** 뽑는다. 한 파일에 합치지 않는다.\n\n---\n\n## R9. mux\n\n```bash\nffmpeg -i input.mp4 -i ko.vtt -i en.vtt -map 0 -map 1 -map 2 -c copy -c:s webvtt \\\n  -metadata:s:s:0 language=kor -metadata:s:s:1 language=eng output.mkv\n```\n\n**함정** — mp4 는 `mov_text` 만 받으므로 WebVTT 를 넣으려면 **mkv 로 나가야 한다.** mp4 를 유지해야 하면 자막을 사이드카 파일로 둔다.\n\n---\n\n## 검증 체크리스트\n\n위에서부터 막히면 아래는 볼 필요 없다.\n\n1. **단어 타임스탬프가 실제 발화와 맞는가** — 영상 중간·끝에서 3곳 샘플링. **여기가 틀리면 전체 설계가 무너진다**\n2. 문장 수 = 번역 줄 수인가\n3. 문장 id 가 R6 전후로 보존됐는가\n4. 큐 시간이 겹치거나 역행하지 않는가\n5. 영상 끝부분 자막이 밀리지 않았는가 (누적 오차)\n6. CPS 위반 큐가 몇 %인가\n\n## 실패 지점\n\n| 증상 | 원인 | 조치 |\n|---|---|---|\n| 전사가 비어 있음 | 언어 asset 미다운로드 / locale 불일치 | R0, R3 |\n| 음차 표기로 나옴 | locale 틀림 | R3 후 재전사 |\n| 파일이 안 열림 | 미지원 컨테이너 | R2 |\n| 번역 어순이 깨짐 | 큐 단위로 번역함 | R4 후 번역 |\n| 뒤로 갈수록 자막이 밀림 | R7 `minDur` 클램프 누적 | 청크 수 축소 |\n| 번역문 개수가 안 맞음 | R6 에서 문장 병합 | 보정 결과 폐기, id 검사 강화 |\n\n**아직 안 정한 것** — `maxCharsPerLine`·`maxCps` 실측값, R6 를 넣을 가치가 있는지(보정 전후 diff 로 판단), 화자 분리(현재 파이프라인에 없음)."},{"i":"590","t":"SPA 메모리 누수 soak test","k":["playwright","memory-leak","testing","cdp","spa"],"c":"2026-09-01","m":"2026-09-10","b":"백엔드가 서버를 오래 돌려 누수를 잡듯, 프런트에서는 **시작 화면과 끝 화면이 같은 플로우**를 반복해서 잡는다. drawer 열고 닫기를 200번 돌린 뒤 리스너·노드 수가 처음보다 늘어 있으면 그게 누수다 — 같은 자리로 돌아왔는데 늘었으니까. Playwright 로 클릭을 반복하고 CDP 로 수를 읽는다.\n\n그래서 **플로우를 고르는 기준이 곧 판정 기준이다.** 무한 스크롤·채팅처럼 설계상 메모리가 늘어나는 화면은 이 방식으로 판정할 수 없다."},{"i":"589","t":"TypeScript 타입변수 추론 진단","k":["typescript","type-inference","generics","debug"],"c":"2026-09-01","m":"2026-09-01","b":"**언제** — 함수 호출에서 `T` 가 엉뚱하게 정해졌을 때. 증상으로 찾아 들어간다.\n\n| 증상 | 원인 | 절 |\n|---|---|---|\n| 이상하게 `unknown` 이 나온다 | 그 변수가 바운드·조건 부분·인자에 안 드러난 위치에만 있다 | §수집 제외, §우회 |\n| 형제 타입인데 첫 인자로 고정됐다 | 접기의 \"첫 멤버 유지\" | §해소 |\n| 추론은 됐는데 그 값으로 체크가 깨진다 | 조건부 가지를 평가하지 않는다 | §1.4 |\n| union 인자에서만 실패한다 | 분배 후보가 재-union 되지 않는다 | §1.6, §해소 |\n\n---\n\n## 모델 — 추론은 딱 2단계다\n\n```\nsource 타입(인자 + 기대 반환)  ┐\n                              ├─▶ ① 수집: 나란히 walk, bare T 만나면 후보 기록\ntarget 타입(매개변수 + 반환)   ┘        ├─▶ 공변(출력) 후보 리스트\n                                        └─▶ 반변(입력) 후보 리스트\n                              ② 해소: 각 리스트를 하나로 접기 → T 주입\n                              ③ *그제서야* 일반 타입 체크\n```\n\n**수집과 타입 체크는 별개 단계다.** 이게 조건부 타입에서 폭발한다.\n\n**하나만 외운다면** — 후보는 오직 **source 타입과 그 구조적 부분에서만** 줍는다. `extends` 바운드·기본값·조건부의 *조건 부분*·상위 타입은 **절대 후보가 안 된다.** 기괴한 사례 대부분이 이 한 문장의 따름정리다.\n\n---\n\n## 수집 규칙\n\n**1.1 기본** — target 에서 벌거벗은 타입변수를 만나면 그 자리 source 를 후보로 기록, 아니면 구조 안으로 walk.\n\n```ts\nfunction f<A, B, C>(x: A, y: (a: B) => C) {}\nf(true, (it: number) => \"hello\")   // A = boolean, B = number, C = string\n```\n\n`B` 는 함수 매개변수 위치라 **반변**, `A`·`C` 는 **공변**. 후보는 두 리스트에 나뉘어 쌓인다.\n\n**1.2 타입 별칭 안으로도 walk 한다.** `type ArrayContainer<T> = T[] | { array: T[] }` 에 `number[]` 를 매칭하면 배열 쪽이 맞아 `E = number`.\n\n**1.3 조건부는 RHS(결과 가지)에서 수집한다.** 조건 부분(`A extends string`)은 후보를 만들지 않는다.\n\n```ts\ntype Pair<A, B> = A extends string ? Map<A, B> : [A, B]\nfunction f<A, B>(x: Pair<A, B>) {}\nf([1, 2])              // A = number, B = number  (두 번째 가지)\nf(new Map([[\"a\",1]]))  // A = string, B = number  (첫 번째 가지)\n```\n\n**1.4 ★조건부 타입은 가지를 \"평가\"하지 않는다.**\n\n```ts\nf([\"a\", \"b\"])   // TS2345: string[] is not assignable to Map<string, string>\n```\n\n후보는 **두 번째 가지**에서 수집돼 `A = string, B = string` 이 되는데, 체크는 `A = string` 이므로 `Pair<string,string> = Map<string,string>` 인 **첫 번째 가지**로 걸어간다. **수집이 걸은 가지 ≠ 체크가 걷는 가지.**\n\n**1.5 `infer T` 는 새 타입변수를 추가한다.** 조건 부분이라도 `infer` 만은 예외로 새 변수를 만든다. `Awaited<Promise<Promise<number>>>` 에서 첫 전개의 `U = Promise<number>` 와 재귀 전개의 `U = number` 는 **별개 변수**다.\n\n**1.6 union 은 분배된다.** source 가 union 이고 target 이 bare T 가 아니면 각 가지에 대해 독립 재실행하고, 결과 후보들은 **같은 리스트에 쌓이고 다시 union 되지 않는다.**\n\n**1.7 source 의 intersection** 은 원칙적으로 유지되지만, 객체 타입(`{ x: T }`)에 **구조적으로 매칭**해야 할 때만 병합된 객체 형태가 생성된다.\n\n**1.8 수집에서 제외되는 것** — 조건부의 조건 부분, `extends` 바운드, 기본값, **source 의 상위 타입**. `Elephant<Savannah>` 에서 후보가 될 수 있는 건 `Elephant<Savannah>` 와 `Savannah` 뿐이고 `Animal`·`Location` 은 절대 안 된다.\n\n**따름정리** — `number` 인자로 `string` 바인딩을 만들 방법은 없다. 필요하면 §우회.\n\n---\n\n## target intersection 의 특이동작\n\n**모호하면 아무것도 기록하지 않는다** — 임의 선택조차 안 한다.\n\n```ts\nfunction foo<A, B>(it: A & B) {}\nfoo(42)   // A = unknown, B = unknown  (unknown & unknown = unknown)\n```\n\n**타입변수 \"peel off\" 는 차집합이 아니라 포획 유용성을 최대화하는 휴리스틱이다.**\n\n```ts\nfunction foo<A>(it: A & { x: number }) {}\nfoo({ x: 42, y: 42 })                 // A = { x: number, y: number }  ← 전체 객체\nfunction bar<A>(it: A & number) {}\nbar(42 as number & { brand: \"USD\" })  // A = { brand: \"USD\" }\nfunction qux<A>(it: A & \"a\") {}\nqux(\"a\" as \"a\" & { brand: 1 })        // A = { brand: 1 }\n```\n\n`A & {객체}` 면 전체 객체를, `A & 리터럴` 이면 정밀 리터럴을 남기는 쪽으로 동작한다. **intersection 순서는 source·target 모두 무관.**\n\n---\n\n## 해소 규칙\n\n**철칙 — 해소 결과는 후보 리스트에 없는 타입으로 절대 안 정해진다.** `[Cat, Dog]` → `Dog`(첫 멤버). 공통 상위인 `Animal` 도 `Cat|Dog` 도 아니다.\n\n**리스트별 접기** (공변 기준, 반변은 관계 반전)\n\n1. 후보가 전부 **같은 base 의 리터럴**이면 → union\n2. 아니면 **다른 모두의 strict 상위**인 후보를 찾으면 → 그것\n3. 없으면 느슨한 상위 체크로 left-reduce → 상위가 리스트에 없으면 **첫 후보 반환**\n\n```ts\ndeclare function f<T>(x: T, y: T): T\nf(d, a)    // Animal  (Animal 이 Dog 의 strict 상위)\nf(d, c)    // Dog     (형제 → 첫 멤버 유지)\nf(\"a\", 1)  // \"a\"     (다른 base → 첫 멤버 유지)\nf(\"a\",\"b\") // \"a\"|\"b\" (같은 base 리터럴 → union)\n```\n\n**익명 리터럴 객체/배열이 여럿이면(공변 한정) 접기 전에 union 한다.** named 타입끼리는 안 된다 — 서로 다른 named 타입을 같은 `T` 에 맞추려던 건 아닐 거라는 의도 추정이다.\n\n**공변·반변이 둘 다 있으면** ① 기본은 반변이 이긴다 ② 반변 결과가 바운드를 못 지키면 공변으로 폴백 ③ **공변 결과가 반변 결과의 하위 타입이면 공변이 이긴다**(더 정밀하므로).\n\n```ts\nfunction f<T>(x: T, sink: (t: T) => void) { sink(x) }\nf(new Dog(), (_: Animal) => {})   // T = Dog  (공변이 반변의 하위 → 공변 승)\n```\n\n**빈 리스트·바운드** — 둘 다 비면 파라미터의 기본값, 없으면 `unknown`. 해소값이 바운드를 못 지키면 **바운드로 교체.**\n\n**기본값은 별칭이 아니라 \"해소 대상 파라미터\"에서 찾는다.**\n\n```ts\ntype Result<V, E = never> = { okay: V } | { error: E }\nfunction f<V, E>(x: Result<V, E>) {}\nf({ okay: 42 })   // E = unknown — never 가 아니다\n```\n\n`Result` 의 `never` 기본값은 `Result<X>` 처럼 인자를 생략할 때만 발동한다. 호출부에서 `never` 를 원하면 `function f<V, E = never>` 로 선언한다.\n\n**우선순위 비트** — 수집 시 후보마다 태그가 붙고, **더 높은 우선순위 후보가 오면 낮은 것들은 전부 지워진다.** `ReturnType`(호출 문맥의 반환 타입 추론, 함수를 인자로 넘길 때), `LiteralKeyof`(리터럴을 `keyof T` 에 매칭), `MappedTypeConstraint` 셋은 \"combination\" 우선순위라 상위/하위 대신 **union(공변)/intersection(반변)** 으로 해소한다.\n\n---\n\n## 우회 패턴\n\n**제네릭 함수 안에서는 타입변수가 미확정이라 조건부를 평가할 수 없다.** `Exclude` 로 특정 타입을 막고 싶으면 **파라미터 위치에서 타입변수를 추론하는 형태만** 동작한다.\n\n```ts\nfunction foo(x: Exclude<unknown, number>) {}   // 파라미터가 unknown 으로 축약 → foo(42) 허용\nfunction bar<X>(x: Exclude<X, number>) {}      // X = number 추론 → Exclude<number,number> = never\nfunction quz<X, Y extends Exclude<X, number>>(x: Y) {}  // X 는 바운드에만 → 추론 불가 → unknown\n\nbar(42)   // 에러: 42 not assignable to never  ← 이것만 제대로 막는다\n```\n\n넘겨 쓸 때는 타입을 그대로 재현한다 — `function quuz<V>(x: Exclude<V, number>) { bar(x) }` 는 조건을 평가 못 해도 두 `x` 가 **구조적으로 동일**해서 통과한다.\n\n**`NoInfer<T>`** — 그 위치에서 `T` 의 후보를 수집하지 않는다. 다른 위치에서 정해진 `T` 를 그대로 강제할 때.\n\n**가장 짜증나는 상황** — 추론하고 싶은 타입변수가 인자에 드러나지 않을 때. `extends` 바운드는 후보를 안 만들기 때문이다.\n\n```ts\nfunction foo<K, V, T extends Map<K, V>>(map: T, key: K): [T, V] { return [map, map.get(key)!] }\nfoo(new Map([[42, \"42\"]]), 42)   // [Map<number,string>, unknown]  ← V = unknown\n```\n\n해법 둘, 트레이드오프가 있고 **둘 다는 안 된다.**\n\n```ts\n// 1) extractor 타입 — 캐스트 필요, union 지원\ntype GetV<T extends Map<unknown, unknown>> = T extends Map<unknown, infer V> ? V : never\nfunction foo<K, V, T extends Map<K, V>>(map: T, key: K): [T, GetV<T>] {\n  return [map, map.get(key) as GetV<T>]\n}\n\n// 2) map: T & Map<K, V> — 캐스트 불필요, union 인자에서 깨짐\nfunction foo<K, V, T extends Map<K, V>>(map: T & Map<K, V>, key: K): [T, V] { … }\n```\n\n**함정** — `A extends string ? { [key in A]: B } : …` 형태의 매핑드 타입에서는 키/값 추론이 안 된다. `[A, B]` 같은 구조로 쓴다. 그리고 일부 IDE 는 `NoInfer` 를 못 뚫고 헛경고를 낸다."},{"i":"588","t":"프로그래밍 원칙을 조작 가능하게 — 증상·절차·경계로 다시 쓰기","k":["architecture","react","refactoring","decision-making"],"c":"2026-09-01","m":"2026-09-10","b":"**언제** — 원칙 목록이 \"무엇을 믿어라\"는 주는데 \"언제 어떻게 하라\"를 안 줄 때. 원칙마다 **증상 → 절차 → 경계(언제 적용하지 말 것)** 를 붙인다. 경계가 핵심이다 — 슬로건은 자기가 틀리는 지점을 말하지 않는다.\n\n가치선언(정직하라, 좋은 일을 하라)은 이견 낼 여지가 없어 레시피가 되지 않으므로 뺀다. 남는 건 실제로 의사결정에 개입하는 일곱이다.\n\n| 증상 | 레시피 |\n|---|---|\n| 상태를 맞추려고만 존재하는 `useEffect` 가 있다 | 1. 파생 |\n| 중복 2곳을 보고 즉시 헬퍼로 뽑고 싶다 | 2. AHA |\n| \"이렇게 하면 그 문제 안 생겨요\" | 3. 이전 vs 제거 |\n| `undefined` 가 함수 셋을 지나 렌더에서 크래시 | 4. 경계 |\n| 선택 근거가 \"우리가 이미 잘 아니까\" 뿐 | 5. 익숙함 |\n| 도구 선택에서 분석 마비 | 6. 되돌릴 수 있는 문 |\n| 한 파일을 고치면 무관해 보이는 곳이 깨진다 | 7. 모듈 경계 |\n\n---\n\n## 1. 상태를 동기화하지 말고 파생하라\n\n가장 견고한 레시피. 단 **단일 런타임 안에서만** 무조건 참이다.\n\n**증상** — 한 값을 바꿀 때 다른 상태도 \"같이\" 갱신해야 한다. 오직 다른 상태를 맞추기 위해서만 존재하는 `useEffect` 가 있다. 미러 변수가 보인다(`isEven`, `winner`, `filteredList`, `sortedRows`).\n\n**절차**\n\n1. 각 상태에 묻는다 — \"이 값은 다른 상태만으로 계산 가능한가?\" 가능하면 상태가 아니라 파생값이다.\n2. 단일 소스만 남기고 나머지는 렌더 중 순수 함수로 계산한다.\n3. 동기화하던 `useEffect` 를 삭제한다.\n4. 파생 계산이 **프로파일에서** 비싸면 `useMemo` 로 감싼다 — **삭제가 아니라 캐시. 둘을 혼동하지 말 것.**\n\n**경계** — 소스가 분산되면(멀티 클라이언트, 서버·클라 이중 소스) 파생으로 못 푼다. 그건 진짜 sync 문제이고 CRDT·이벤트 소싱 영역이다. 파생 비용이 진짜 크고 입력이 자주 안 변하면 물질화가 정당하지만, 그 순간 **다시 동기화 계약을 지는 것임을 자각한다.**\n\n---\n\n## 2. 추상은 비명이 들릴 때까지 미뤄라 (AHA)\n\nAHA 는 DRY 와 WET 의 절충이 아니다. 원칙은 **대칭적**(과잉·과소 추상 둘 다 피함)이고 *타이브레이커만* 비대칭이다 — 확신이 없으면 중복 쪽. 근거는 가역성: **잘못된 추상은 중복보다 되돌리기가 비싸다.**\n\n**증상** — 중복 2곳을 보고 즉시 뽑고 싶다. 이미 뽑은 추상이 `options` 플래그와 조건 분기로 부풀고 있다. 추상 하나를 고치는데 무관한 호출부가 깨진다.\n\n**절차**\n\n1. 중복을 발견하면 놔둔다. 세 번째 사용처가 나타날 때까지.\n2. 세 번째에서 묻는다 — 세 곳이 *같은 이유로* 함께 변하는가, 지금 우연히 비슷한가? **같은 이유로 변할 때만** 추상한다.\n3. 가역성 테스트: \"틀린 추상이면 되돌리는 비용 vs 지금 중복 유지 비용?\" 되돌리기가 더 비싸면 아직 이르다.\n4. 이미 부푼 추상은 **역방향으로** — 인라인으로 되돌려 중복을 복원한 뒤 진짜 공통점만 다시 뽑는다.\n\n**경계** — \"세 번 규칙\"은 휴리스틱이지 법칙이 아니다. 계약(공개 API·타입 경계·프로토콜)은 중복 비용이 커서 더 일찍 추상해야 한다. **\"코드가 비명을 지른다\"는 판정이 안 서면 차라리 셀 수 있는 규칙을 써라** — 나쁜 규칙보다 낫고 규칙 없음보다도 낫다.\n\n---\n\n## 3. 문제를 이전시켰는지 제거했는지 검증하라\n\n대부분의 \"제거\"는 큰 문제를 작은 문제와 맞바꾼 것이지 소멸이 아니다. 그래서 핵심은 \"제거하라\"가 아니라 **제거인지 이전인지 판별하는 것**이다.\n\n**증상** — \"이렇게 하면 그 문제 안 생겨요\"라는 리팩터/설계 주장.\n\n**절차**\n\n1. 없앤 문제가 *어디로 갔는지* 추적한다. 사라졌나, 다른 레이어·사람·런타임으로 옮겼나?\n2. **이전**이면(런타임 복잡도를 빌드타임/서버로 밀기) 그건 트레이드오프다. 옮긴 곳에서 실제로 더 싸게 다뤄지는지 명시한다.\n3. **진짜 제거의 표식** — 상태 공간이 좁아진다. 잘못된 상태를 *표현할 수 없게* 된다. 문제의 한 클래스가 통째로 불가능해진다.\n4. 제거도 이전도 아니면(문제는 그대로, 코드만 이동) **아무것도 안 한 것이다. 되돌려라.**\n\n**경계** — \"완전 제거\"라는 수사에 속지 말 것. 트레이드오프임을 인정하는 게 정직이다. 타입으로 상태 공간을 좁히는 것(제거)과 복잡도를 다른 레이어로 미는 것(이전)을 가르는 선을 매번 긋는다.\n\n---\n\n## 4. 경계에서 엄격하게, 안에서 빨리 실패하라\n\n서로 다른 세 축을 먼저 분리한다. **\"관대한 파싱\"을 fail-fast 의 짝으로 묶는 건 방향 오류다.**\n\n- **fail-fast** — 에러를 *언제* 드러내나: 즉시, 전파 전에.\n- **parse, don't validate** — 경계에서 비구조→구조로 한 번 변환하고 증명을 타입에 실어 나른다: *어떻게*. 엄격하며 fail-fast 와 같은 편.\n- **Postel 의 관대함** — *무엇을* 받아들이나: 앞 둘과 **반대 방향**. 보완이 아니다.\n\n**증상** — 잘못된 입력이 조용히 흘러가 몇 레이어 뒤 애매한 에러로 터진다. 에러 스택이 원인이 아니라 증상 지점을 가리킨다.\n\n**절차**\n\n1. 바깥 경계(네트워크 응답·사용자 입력·파일)에서 **엄격하게** 받는다. 변형 입력은 일찍 거부하고, 통과한 값은 타입으로 그 사실을 들고 다니게 한다.\n2. 위반은 그 자리에서 던지거나 명시적 에러 값으로 반환한다. 하류로 넘기지 않는다.\n3. 신뢰 경계 *안쪽*은 fail-fast. 타입으로 표현 가능한 실패는 런타임 검사 대신 타입으로 막는다(→ 레시피 3의 \"표현 불가능하게\"). 엄격한 거부는 fail-fast 일 뿐 아니라 **공격의 한 클래스를 제거한다.**\n4. 개발 빌드에서는 시끄럽게, 프로덕션에서는 우아하게. 단 \"우아하게\"는 *거부를 사람에게 전달하는 방식*이지 *무엇을 시스템에 들이나*가 아니다.\n\n**경계** — 관대함은 기본값이 아니라 상호운용을 위한 **명시적 트레이드오프**로만 고르고, 그때 지불하는 비용을 안다(용인된 에러는 고착되어 구현을 복잡하게 만든다 — RFC 9413 Protocol Decay Hypothesis). 관대함이 옳은 실제 사례는 있다 — 브라우저의 forgiving HTML 파싱이 웹 상호운용을 살렸다. 요점은 금지가 아니라 **\"엄격을 기본으로, 관대함은 눈 뜨고 내리는 결정으로\"** 다.\n\n---\n\n## 5. 익숙함을 단순함으로 착각하지 마라\n\nRich Hickey 가 못박은 두 지점이 절차의 전부다.\n\n- **simple** = *simplex*, \"one fold\" — 한 역할·한 관심사가 얽히지 않음. **객관적.** 이건 *interleaving* 이지 *cardinality* 가 아니다 — **부품 수가 적은 게 simple 이 아니라, 다른 이유로 존재하는 것들이 안 엮인 게 simple.**\n- **easy** = *adjacens*, \"가까이 있음\" — 손에 익음. **주관적**(사람마다 다르다).\n- **complect ≠ compose** — 독립을 유지한 채 나란히 두는 건 좋고, **엮어서 서로 가정하게 만드는 것**이 복잡도의 근원이다.\n\n**증상** — 도구/패턴 선택 근거가 \"이미 잘 아니까\" / \"러닝커브 없어서\" 뿐이다.\n\n**절차**\n\n1. 후보를 두 축으로 **분리** 채점한다. simple = 하나를 *다른 이유로* 바꿀 때 *무관한 관심사*까지 건드려야 하나(객관). easy = 팀 숙련도·즉시 착수 비용(주관).\n2. 두 근거를 각각 따로 적는다. **두 근거가 한 칸에 섞이면 거기가 착각이 시작된 지점이다.**\n3. easy 만 높고 simple 이 낮으면 단기 속도를 얻고 얽힘 비용을 뒤로 미루는 것이다. 그 미룸이 *의식적 트레이드오프*인지 확인한다.\n4. 결정 문서에 두 점수를 남겨 나중에 검증 가능하게 한다.\n\n**경계** — easy 자체는 나쁜 게 아니라 바람직한 목표다. 오류는 (a) easy 를 simple 로 *착각*하거나 (b) 오래 살 구조에서 simple 을 easy 와 *맞바꾸는* 것이다.\n\n이 구분이 나머지의 뿌리다 — complect 는 *다른 변경 이유를 엮는 것*이라 레시피 7과 같은 축이고, 동기화된 미러(레시피 1)는 값과 갱신 시점을 complect 한 것이라 파생이 그걸 푼다.\n\n---\n\n## 6. 도구는 저부담 기본값으로, 되돌릴 수 있게\n\n**증상** — 도구 선택에서 분석 마비. 또는 반대로 니치한 도구를 \"정답\"이라 확신.\n\n**절차**\n\n1. 이 선택이 **one-way(고부담)인지 two-way(저부담)인지부터** 판정한다: 나중에 되돌리는 비용이 큰가?\n2. **two-way 면(대부분)** 생태계 있는 표준 도구로 빠르게 결정하고 넘어간다. 이상적으로 원하는 정보의 ~70%면 결정한다 — 지연 비용이 교정 가능한 실수의 위험보다 크다.\n3. **one-way 면**(데이터 모델·인증·공개 계약·핵심 의존성) 여기에만 시간을 쓴다. 소프트웨어에서 진짜 one-way 는 드물고 대개 \"되돌림 rework 가 클 뿐\"이므로, **결합도를 낮춰 rework 비용을 깎아 one-way 를 two-way 로 끌어내린다**(→ 레시피 7).\n4. \"정답은 없다\"를 아무 선택이나 정당화하는 방패로 쓰지 마라. 기준은 **트레이드오프를 명시했는가**다.\n\n**경계** — 오분류는 양방향으로 위험하다. two-way 를 one-way 로 다루면 분석 마비, 반대면 무모함. **숨은 one-way 를 조심한다** — 데이터 손실을 동반한 마이그레이션, 소비자가 이미 의존하는 공개 API, 코드베이스 전체로 전이된 프레임워크 선택. \"나중에 바꾸면 돼\"처럼 보이지만 커밋 후에야 되돌림 비용이 드러난다.\n\n---\n\n## 7. 모듈 경계: 변경 이유로 쪼개되 깊게 유지하라\n\n**핵심 기준은 하나다 — 변경 이유.** 같은 이유·같은 시점에 변하는 것은 모으고, 다른 이유·다른 시점에 변하는 것은 나눈다. 분리(SRP)와 응집(colocation)은 이 한 기준의 양면이지 두 원칙이 아니다. **그래서 타이브레이커가 필요할 일이 없다.**\n\n**증상** — 한 파일을 고치면 무관해 보이는 곳이 깨진다. import 그래프가 양방향/순환. 컴포넌트 하나가 변경 이유가 다른 여러 일을 한다. 이름·시그니처가 실제 하는 일과 다르다.\n\n**절차**\n\n1. **변경 이유로 자른다.** \"왜 바뀌는가\"가 둘 이상이면 그 축으로 쪼갠다. 관심사는 도메인 명사가 아니라 **변경 축**이다.\n2. **단, 깊게 유지한다.** 더 쪼갤 때 인터페이스가 늘어나는 속도가 숨겨지는 복잡도를 앞지르면 멈춘다. SRP 를 극단으로 밀면 작은 클래스가 잔뜩 생겨 전체 복잡도가 오르고, **정보 은닉은 모듈을 오히려 약간 키울 때 개선될 때가 많다.** 1과 2는 서로를 견제하는 역벡터다.\n3. **결합은 한 방향으로.** 순환·양방향 의존은 즉시 신호. 인터페이스로 뒤집어 되돌림 비용을 깎는다.\n4. **조합 우선.** 진짜 \"is-a\" 일 때만 상속.\n5. **최소 놀람.** 숨은 부수효과·암묵 규약은 표면으로 끌어올린다.\n\n**경계** — 분리 vs 응집에서 갈등이 *느껴지면* 그건 타이브레이커가 필요한 상황이 아니라 **엉뚱한 축으로 쪼갰다는 신호**다. 변경 이유가 아니라 도메인 토픽으로 갈랐는지 의심한다. \"깊은 모듈\"도 무비판 수용 대상은 아니다 — 2번은 분해를 **멈추는 체크**이지 분해하지 말라는 뜻이 아니다.\n\n---\n\n**절차는 근거를 외부 정전에 붙일수록 스스로 반증되거나 단단해진다 — 그게 슬로건과의 차이다.** 4와 7이 그렇게 교정됐다: 4는 \"관대한 파싱\"이 fail-fast 와 충돌하는 **별개 축**임을 놓쳐 엄격을 기본으로 되돌렸고, 7은 하나의 기준(변경 이유)을 둘로 쪼개 가짜 타이브레이커를 붙였던 것과 대립하는 두 원칙(SRP ↔ 깊은 모듈)을 동의어로 합쳤던 것을 갈랐다."},{"i":"587","t":"GenUI 도입 — 모델이 정하는 게 인자인가 배치인가 코드인가","k":["genui","llm","react","design-system","security"],"c":"2026-09-01","m":"2026-09-01","b":"**언제** — 누군가 \"우리도 GenUI 해보자\"고 말했을 때. 회의에서 그 단어가 나오면 **셋 중 뭔지부터 확정한다.**\n\n| 이름 | 모델이 정하는 것 | 실행되는 코드의 출처 | 검증 표면 |\n|---|---|---|---|\n| **Static/Controlled** | 툴 인자 | 우리 코드 | 툴 스키마 |\n| **Declarative** | 컴포넌트 트리·배치·props | 우리 코드 | 카탈로그(Zod) |\n| **Open-ended** | 코드 전체 | 모델 출력 | 없음 (샌드박스만) |\n\n직교하는 두 번째 축은 **transport** — 우리 앱에 그리나 남의 호스트에 그리나. \"어디에 그리나\"와 \"얼마나 자유롭나\"는 별개 결정이고 섞을 수 있다.\n\n---\n\n## R1. 지금 GenUI 가 필요한지 판별\n\n1. 사용자 입력이 **열거 가능한 선택지**인가 자유 텍스트인가?\n   - 열거 가능 → **여기서 중단.** 필터 UI 가 답이다.\n2. 자유 텍스트일 때 **나올 수 있는 화면 종류가 몇 개**인가?\n   - 5개 이하로 셀 수 있다 → **Static/Controlled. 대부분 여기서 끝난다.**\n3. 구성이 달라지는 게 **데이터 양** 때문인가 **관심사 조합** 때문인가?\n   - 데이터 양(0건/1건/N건) → Static 으로 충분. 컴포넌트 3개로 커버된다\n   - 관심사 조합(어떤 요청은 날씨+일정, 어떤 요청은 예산+체크리스트) → **Declarative**\n4. 우리 부품으로 표현이 아예 불가능한 일회성 위젯인가? → 그 조각만 **Open-ended**. 페이지 전체는 아님.\n\n**함정** — \"AI 붙이자\"가 실제로는 \"검색이 안 좋다\"인 경우가 많다. 랭킹 문제를 GenUI 로 덮으면 비용만 늘고 문제는 남는다. 그리고 **데모에서 인상적이었던 건 대부분 Declarative 인데 실제 필요한 건 대부분 Static 이다.**\n\n---\n\n## R2. 인텐트 계약 (GenUI 도입 여부와 무관하게 먼저)\n\n**언제** — 기획 → 디자인 → 구현 사이에서 케이스가 자꾸 누락될 때.\n\n1. 기능 하나를 **인텐트 문장** 하나로 쓴다. \"사용자가 조건을 말하면 조건에 맞는 장소를 찾아준다.\"\n2. **입력 인자 집합**을 확정한다. 무엇이 필수고 무엇이 옵션인지.\n3. **결과 상태 집합을 전부 열거한다. 이게 핵심이다.** 정상 경로가 아니라 나머지를 적는다.\n\n```\n- 결과 N건 / 결과 1건 (레이아웃이 다르면 별도 상태)\n- 조건은 유효한데 결과 0건 / 조건 자체를 해석 못 함\n- 부분 실패 (3개 소스 중 1개 죽음)\n- 권한 없음 (데이터 없음과 반드시 구분)\n- 오래된 캐시를 보여주는 중\n```\n\n4. 상태마다 **반환 필드**를 정한다. 최소한 \"왜 이 결과가 나왔는지\"를 UI 가 설명할 수 있어야 한다 — `{ query, appliedFilters, items, totalMatched }` 에서 `appliedFilters` 가 없으면 \"왜 이게 나왔죠?\"에 답할 수 없다.\n5. 기획·디자인·프론트·서버가 **같이 승인한다.** 이게 계약이다.\n\n**확인** — 디자인 시안 개수 == 결과 상태 개수인가? 적으면 누군가 런타임에 즉흥으로 정하게 된다. QA 가 이 목록만 보고 테스트 케이스를 쓸 수 있는가?\n\n**왜 먼저 하나** — Static 으로 갈 때 이 정의가 **그대로 툴 스키마가 된다.** 순서가 뒤집히지 않는다. GenUI 를 끝내 안 하기로 해도 이 작업만으로 커뮤니케이션 비용이 준다.\n\n---\n\n## R3. Static/Controlled 도입 (기본값)\n\n기존 컴포넌트를 쓴다 — **새로 안 만든다.**\n\n1. 인텐트 계약을 툴 스키마로 옮긴다. 모델이 채울 수 있는 건 **입력 인자뿐**이다.\n2. 툴 본체를 **앱 코드로** 쓴다. 필터·랭킹·권한 검사 전부 여기.\n3. 반환값에 결과 상태를 판별할 필드를 넣는다.\n4. 결과 상태 → 컴포넌트 매핑을 **결정적 코드로** 쓴다.\n\n```ts\n// 이 분기는 코드다. 모델이 정하지 않는다.\nif (totalMatched === 0) return <EmptyMatch filters={appliedFilters} />\nif (totalMatched === 1) return <PlaceCard place={places[0]} />\nreturn <PlaceResults places={places} />\n```\n\n5. 모델에게는 **반환된 결과에 대한 설명만** 쓰게 하고, 결과 밖의 이야기를 못 하게 프롬프트로 못박는다.\n\n**확인** — 툴 호출을 목으로 고정하면 UI 가 항상 같은 화면을 내는가?(아니면 결정성이 새고 있다) 모델을 완전히 죽여도 기존 필터 UI 로 기능이 동작하는가?(폴백 경로)\n\n**함정** — 툴이 반환하지 않은 항목을 모델이 설명에 끼워 넣는다. 반환 목록을 프롬프트에 명시하고 후처리로도 검사한다. 그리고 **필터링을 모델에게 시키고 싶은 유혹** — 시키는 순간 재현성과 감사 로그가 동시에 사라진다.\n\n---\n\n## R4. Declarative 도입 (R1 의 3번일 때만)\n\n여기부터 비용이 계단식으로 뛴다. 백엔드는 **그대로**고(에이전트는 안 바뀐다 — 이게 이 경로의 실용적 장점), 새로 만들어야 하는 유일한 것은 **컴포넌트 카탈로그**다.\n\n```\n에이전트  →  전송 스펙  →  UI 서술 스펙  →  React 컴포넌트\n (서버)   (어떻게 흐르나)  (모델이 뭘 생성)   (여전히 우리 코드)\n```\n\n1. 도메인 툴은 그대로 둔다.\n2. 런타임이 렌더 툴을 주입하고 모델에 **카탈로그 스키마를 노출**한다. 모델이 뭘 쓸 수 있는지 아는 유일한 경로.\n3. 순서를 강제한다 — **데이터 툴 먼저 → 렌더 서술은 마지막에 정확히 1회.**\n4. 미들웨어가 인자를 스키마 검증 후 UI operation 으로 확장한다.\n5. 클라이언트로 스트리밍. 로딩 상태와 완성 화면이 **같은 messageId** 를 쓰게 한다(append 아니라 replace).\n\n**확인** — 카탈로그에 없는 컴포넌트를 요청했을 때 조용히 빠지는가, 에러인가, 폴백인가? 셋 중 하나를 **명시적으로** 고른다. 렌더 툴이 두 번 호출되면? 스트림이 중간에 끊기면?\n\n**함정** — **정책이 타입 시스템이 아니라 프롬프트에 쌓인다.** 타입 검사도 테스트도 안 되는 규칙 더미가 자라는 게 이 경로의 진짜 비용이다. 조합 폭발도 사라진 게 아니라 **카탈로그 유지보수로 이동**했을 뿐이다. 그리고 데모가 죄다 read-only 대시보드인 건 우연이 아니다 — **상호작용·로컬 상태·폼 검증**이 이 방식에서 가장 약하다.\n\n---\n\n## R5. 카탈로그 설계 (R4 실제 작업량의 대부분)\n\n카탈로그 = **Zod 정의(모델이 요청할 수 있는 것) + React 렌더러(실제 실행되는 것) + 버전 붙은 catalogId.**\n\n1. **허용 목록이지 힌트가 아니다.** 목록 밖은 표현 자체가 불가능해야 한다.\n2. **props 를 최대한 좁힌다.** `className` 같은 탈출구를 열면 카탈로그가 무의미해진다. 자유 문자열보다 열거형(`variant: \"compact\" | \"full\"`). `href`·`src` 는 **반드시 허용 도메인 검사** — 외부 이미지 하나가 비콘이 된다.\n3. **레이아웃 컴포넌트를 명시적으로 넣는다.** 안 넣으면 모델이 텍스트로 레이아웃을 흉내 내기 시작한다.\n4. **버전을 붙인다.** 카탈로그 변경은 API 변경과 같은 무게다.\n5. **접근성은 렌더러가 소유한다.** 헤딩 레벨·포커스 순서·라벨을 모델에게 맡기면 요청마다 a11y 트리가 달라진다.\n\n**소유권 질문이 \"누가 UI 를 주도하는가\"의 실제 답이다** — 서버 스키마를 계약으로 두면 백엔드가, 인텐트+결과 상태 집합이면 기획/도메인이, 컴포넌트 카탈로그면 디자인 시스템이 주도권을 갖는다. **카탈로그를 소유한 팀이 사실상 UI 결정권을 갖는다.** 암묵적으로 두지 말고 명시한다.\n\n---\n\n## R6. Open-ended 를 안전하게 한 조각만\n\n1. 샌드박스 iframe. `allow-scripts` 만, **`allow-same-origin` 은 주지 않는다**(둘을 같이 주면 샌드박스가 무의미).\n2. 부모와의 통신은 좁은 postMessage 채널 하나로만. origin 검사 필수.\n3. 네트워크는 CSP 로 차단하거나 허용 목록만.\n4. 페이지 안에서의 크기·스크롤 소유권을 고정한다.\n\n**함정** — 디자인 시스템 충돌, 이중 스크롤바, 성능 비용, 그리고 **직접 관리해야 하는 보안 경계**가 생긴다. 지연·캐시 불가·감사 곤란이 전부 여기 몰려 있다. 프로덕션 기본값으로 쓰지 않는다.\n\n---\n\n## R7. 신뢰 경계 점검 (배포 전)\n\n**\"JSON 이라 안전하다\"는 절반만 맞다.** 임의 코드 실행은 막지만 나머지는 안 막힌다.\n\n1. **props 공격면** — `href`/`src` 가 카탈로그에 있고 도메인 검사가 있는가?\n2. **프롬프트 인젝션이 유도한 정당해 보이는 UI** ← Declarative 의 실제 최대 위협. 검색 결과나 외부 문서 텍스트가 모델 컨텍스트로 들어오는가? 카탈로그에 폼·버튼·링크가 있으면 **모델이 만든 피싱 화면은 스키마 검증을 통과한다.** 대응은 외부 텍스트를 데이터로만 격리, 입력형 컴포넌트를 카탈로그에서 분리·화이트리스트, 렌더 결과 후처리 검사.\n3. **권한** — 모델이 본 데이터 == 이 사용자가 볼 수 있는 데이터인가? 툴 레벨에서 걸렀는가?\n4. **감사** — 어떤 요청이 어떤 UI 를 만들었는지 재현 가능한가?(모델 출력 + catalogId + 입력을 함께 로깅)\n\n**코드 실행이 아니라 사회공학이 declarative 의 진짜 위협이고, 카탈로그는 이걸 막지 못한다.**\n\n---\n\n## R8. 테스트·회귀\n\nUI 가 비결정적이 되는 순간부터 스냅샷/E2E 가 그냥 무너진다.\n\n1. **층을 나눠서 테스트한다.** 툴 함수 → 순수 단위 테스트(모델 없음, 가장 많은 테스트가 여기). 컴포넌트 → 고정 props. 매핑 로직 → 결과 상태별 스냅샷. 모델 → 여기만 비결정적.\n2. **모델 출력을 픽스처로 고정한다.** 매 CI 마다 모델을 부르지 않는다.\n3. **스키마 위반을 계약 테스트로.** 카탈로그 변경 시 기존 픽스처가 깨지는지 본다.\n4. **모델 품질은 별도 평가 세트로.** 대표 쿼리 N개 → 기대 컴포넌트 집합, 통과율을 지표로. E2E 와 섞지 않는다.\n\n**함정** — 모델이 낀 E2E 를 CI 에 넣으면 플레이키가 되고 결국 다들 무시한다.\n\n---\n\n## R9. 실패 모드 정의 (안 정하면 장애 때 즉흥으로 정한다)\n\n| 상황 | 정해야 할 것 |\n|---|---|\n| 모델 타임아웃 | 기존 UI 폴백? 에러 화면? |\n| 카탈로그에 없는 컴포넌트 요청 | 무시 / 전체 실패 / 플레이스홀더 |\n| 스트리밍 중단 | 부분 화면 유지 vs 롤백 |\n| 도메인 툴 3개 중 1개 실패 | 부분 렌더 + 실패 표시 (조용히 빼지 말 것) |\n| 비용·쿼터 초과 | 비-AI 경로로 강등 |\n\n**킬 스위치** — 모델 경로를 끄면 R2 의 결정적 UI 로 돌아가는 플래그. 이게 있으면 훨씬 과감하게 실험할 수 있다.\n\n---\n\n## R10. 회의에서 쓰는 질문 세트\n\n1. AI 가 정하는 게 정확히 뭐예요? **인자? 배치? 코드?**\n2. 나올 수 있는 화면 종류를 셀 수 있어요, 없어요?\n3. 카탈로그는 누가 소유해요?\n4. \"이 케이스는 왜 이렇게 나왔죠\"라고 물으면 어디를 보고 답해요?\n5. 모델을 끄면 이 기능은 어떻게 돼요?\n\n**1번만 확정돼도 나머지 논의가 전부 명확해진다.**\n\n---\n\n**기억해둘 반론** — \"스펙이 코드를 대체한다\"는 절반만 맞다. 스펙과 SDK 도 코드다. 실제로 일어난 일은 추상화 상승이 아니라 **조합 결정 시점이 컴파일 타임에서 런타임으로 이동**한 것이고, 그 대가로 타입 안정성을 프롬프트 정책과 맞바꾼 것이다. 남은 열린 문제(지연·캐시·감사·일관성·비용, 상호작용 이후의 상태 왕복, 접근성 보장, 비결정적 UI 의 회귀 전략)는 전부 이 이동의 청구서다."},{"i":"586","t":"취향 판별기 만들기 — frozen backbone + pairwise head","k":["machine-learning","pytorch","dinov2","onnx","dataset"],"c":"2026-09-01","m":"2026-09-01","b":"**언제** — 사진을 보고 \"맛있어 보이는 정도\" 같은 **주관적 취향**을 매기는 모델을 만들 때. 워크드 예제는 간짜장 사진.\n\n**전제 셋.**\n\n- 목표는 실제 맛이 아니라 **사진에서 지각되는 맛있어 보임**이다. 라벨이 입력 픽셀 안에 완전히 들어있어야 문제가 성립한다.\n- backbone 은 학습하지 않는다. 남이 만든 범용 시각 표현을 빌리고 **그 위에 취향 head 만** 얹는다.\n- 컴퓨트 비용은 0원에 수렴한다. **병목은 GPU 가 아니라 라벨이다.**\n\n---\n\n## R1. 라벨링 툴을 먼저 만든다\n\n전체 프로젝트에서 **이걸 먼저 만드는 게 속도를 좌우한다.** 두 장 띄우고 ←/→ 로 고르는 게 전부고, 정적 페이지 + 무료 티어 DB 면 충분하다.\n\n요구사항은 넷 — 키보드만으로 진행(마우스를 쓰면 3배 느려진다), **다음 쌍 프리로드**(대기 시간이 라벨링 리듬을 깬다), 되돌리기 1단계, 진행률 표시(이게 있어야 지인들이 끝까지 한다).\n\n```json\n{\"a\": \"img_0042\", \"b\": \"img_0117\", \"winner\": \"a\", \"labeler\": \"kim\", \"ms\": 1840}\n```\n\n`ms`(응답 시간)를 남기면 나중에 200ms 미만 같은 대충 클릭을 걸러낼 수 있다.\n\n**왜 pairwise 인가** — 10점 척도는 평가자 간 분산 + 같은 사람의 시점 간 분산이 신호를 잡아먹는다. \"둘 중 뭐가 더 나아 보이나\"는 훨씬 안정적이고 빠르다.\n\n**확인** — 쌍 2,000개를 장당 2.5초면 약 1.4시간. 20명이 100쌍씩 하면 한 사람당 4분이다.\n\n---\n\n## R2. 수집 — `source_id` 가 절반이다\n\n직접 촬영은 산술적으로 불가능하다(한 끼 1만원 × 300 = 300만원 + 300끼). 본인 점심에서 자연 축적 + 지인 크라우드소싱이 현실적이고, **한 명이 100장보다 20명이 5장씩이 훨씬 낫다**(R5 split 때문). 리뷰 이미지 스크래핑은 ToS 소지가 있으므로 개인 실험용과 공개 배포용 데이터를 **처음부터 분리해서** 관리한다.\n\n수집 시점에 같이 기록한다. **나중에 추가할 수 없다.**\n\n```json\n{\"id\": \"img_0042\", \"source_id\": \"friend_kim\", \"container\": \"delivery|dine_in\",\n \"device\": \"phone|dslr\", \"lighting\": \"daylight|fluorescent|dark\", \"sauce_separated\": true}\n```\n\n`source_id` 가 없으면 group split 을 못 하고, split 이 새면 **성능 숫자가 전부 거짓말이 된다.**\n\n규모 목표 — 스모크 100장/100쌍(신호가 존재하는지), MVP 400장/1,500쌍, 안정화 1,000장/5,000쌍. 400장이면 가능한 쌍이 8만인데 1,500쌍은 2%도 안 된다. **라벨이 이미지보다 훨씬 싸다.**\n\n---\n\n## R3. 임베딩 추출 — 한 번만 돌리고 캐시\n\n이후 모든 실험이 이 캐시 위에서 이뤄진다.\n\n```python\nproc = AutoImageProcessor.from_pretrained(\"facebook/dinov2-small\")\nmodel = AutoModel.from_pretrained(\"facebook/dinov2-small\").to(device).eval()\n\n@torch.no_grad()\ndef embed(paths, bs=16):\n    out = []\n    for i in range(0, len(paths), bs):\n        imgs = [Image.open(p).convert(\"RGB\") for p in paths[i:i+bs]]\n        x = proc(images=imgs, return_tensors=\"pt\").to(device)\n        out.append(model(**x).pooler_output.float().cpu().numpy())\n    return np.concatenate(out)\n\nE = embed(paths)                                    # (N, 384)\nE = E / np.linalg.norm(E, axis=1, keepdims=True)    # L2 정규화\nnp.savez(\"cache/embeddings.npz\", ids=np.array(ids), E=E)\n```\n\n1,000장 기준 M 시리즈 맥에서 2분 내외.\n\n**함정** — 여기서 쓴 전처리(resize/crop/normalize)를 R8 브라우저 배포에서 **정확히 똑같이** 재현해야 한다. 가장 흔한 버그 지점이다.\n\n---\n\n## R4. head 학습 — Bradley-Terry\n\n각 이미지에 잠재 점수 `s` 를 주고 `P(a 승) = sigmoid(s_a − s_b)`. 구현은 그냥 **점수 차이에 BCE 를 씌우는 것**이다.\n\n```python\nclass Head(nn.Module):\n    def __init__(self, d=384, h=128, p=0.3):\n        super().__init__()\n        self.net = nn.Sequential(nn.Linear(d, h), nn.GELU(), nn.Dropout(p), nn.Linear(h, 1))\n    def forward(self, x): return self.net(x).squeeze(-1)\n\nlogits = head(E[ia]) - head(E[ib])          # ← Bradley-Terry\nloss = F.binary_cross_entropy_with_logits(logits, y)\n```\n\nCPU 에서 수십 초. 하이퍼파라미터를 50번 스윕해도 커피 한 잔 시간이다.\n\n---\n\n## R5. split — 여기가 제일 중요하다\n\n**절대 이미지 단위로 나누지 말 것.** 같은 촬영자/가게의 사진이 train 과 val 에 흩어지면 모델은 \"이 사람 사진 스타일\"을 외우고 **검증 점수가 실제보다 훨씬 좋게 나온다.**\n\n```python\ngroups = np.array([m[\"source_id\"] for m in meta])\ntr_img, va_img = next(GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=0)\n                      .split(np.arange(len(meta)), groups=groups))\n# 라벨 쌍은 \"양쪽 이미지가 모두 같은 split\" 일 때만 사용 — 넘나드는 쌍은 버린다\n```\n\n아까워 보이지만 여기서 새면 **이후 모든 숫자가 무의미해진다.**\n\n---\n\n## R6. 미학 신호 분해\n\n모델이 학습할 가능성이 가장 높은 건 취향이 아니라 **사진 잘 찍는 능력**이다. 측정해서 분리한다.\n\n1. 범용 미학 모델로 각 이미지에 `aesthetic_score` 부여 (추론만, 학습 없음)\n2. **미학 점수 하나만** 입력으로 쓰는 BT 모델 학습 → baseline accuracy\n3. 전체 임베딩을 쓰는 모델 → full accuracy\n4. **두 값의 차이 = 대상 고유 신호의 크기**\n\n| baseline / full | 의미 |\n|---|---|\n| 0.72 / 0.74 | 라벨의 대부분이 사진빨. 고유 신호가 거의 없다 |\n| 0.62 / 0.78 | 대상 특유의 시각 신호가 실재한다. 프로젝트 성립 |\n| 0.50 / 0.55 | 라벨 자체가 노이즈. R7 인간 천장부터 재확인 |\n\n**어느 쪽이 나와도 결과다.** 이 분해 자체가 가장 재밌는 산출물이고, UI 에서 \"사진빨 점수 / 대상 점수\" 두 축으로 보여줄 수 있다.\n\n---\n\n## R7. 인간 천장을 먼저 재라\n\n**이 단계를 건너뛰면 나머지가 전부 헛수고가 된다.** 같은 쌍 200개를 두 사람(또는 같은 사람이 2주 간격으로)에게 라벨링시켜 일치율을 잰다.\n\n```\n인간 일치율 0.75 → 모델 0.72 는 사실상 천장\n인간 일치율 0.75 → 모델 0.55 는 개선 여지 큼\n```\n\n이걸 모르고 \"0.72밖에 안 나오네\" 하며 몇 주를 태우는 게 가장 흔한 실패 패턴이다.\n\n주 지표는 **pairwise accuracy**, 보조로 Spearman/Kendall tau. 분류 정확도는 쓰지 않는다 — 애초에 분류 문제가 아니다.\n\n**교란변수 감사** — 임베딩으로 메타데이터를 맞혀본다. `LogisticRegression().fit(E_tr, container_tr).score(E_va, container_va)` 가 0.95면 임베딩에 촬영 조건이 잔뜩 들어있고 점수도 그걸 타고 있을 가능성이 높다.\n\n---\n\n## R8. 라벨 없이 돌리는 진단\n\n라벨이 픽셀 안에 있으므로 augmentation 이 곧 검증 도구가 된다.\n\n| 변형 | 기대 방향 | 어긋나면 |\n|---|---|---|\n| 색온도 +따뜻하게 | 점수 ↑ | 색을 안 보고 있음 |\n| 밝기 −40% | 점수 ↓ | 노출 신호 무시 |\n| 좌우 반전 | **변화 없음** | 위치에 과적합 |\n| 배경만 블러 | 거의 변화 없음 | 배경을 보고 있음 |\n\nGradCAM 으로 어디를 보는지도 띄운다. 소스 표면이 아니라 **그릇 테두리·배경 테이블·젓가락**을 보고 있는 경우가 초기에 흔하고, 그러면 크롭 전략을 다시 봐야 한다는 신호다.\n\n---\n\n## R9. 배포 — ONNX + 브라우저\n\n`.pt`/`.safetensors` 는 파라미터만 담기고 모델 구조는 파이썬 코드에 있어야 한다. `.onnx` 는 구조와 파라미터를 함께 담아 파이썬 없이 실행된다. **개발은 전자, 배포는 후자.**\n\n```python\nclass Full(nn.Module):\n    def forward(self, pixel_values): return self.h(self.b(pixel_values).pooler_output)\n\ntorch.onnx.export(full, torch.randn(1, 3, 224, 224), \"models/model.onnx\",\n    input_names=[\"pixel_values\"], output_names=[\"score\"],\n    dynamic_axes={\"pixel_values\": {0: \"batch\"}}, opset_version=17)\n```\n\n`onnxruntime-web` 으로 정적 호스팅하면 서버가 없고 사진이 업로드되지 않아 프라이버시 이슈도 없다. int8 양자화하면 backbone 22MB + head 200KB 로 프론트엔드 번들에 감당 가능하다.\n\n**\"판별기\"의 실체는 200KB 다.** 나머지는 빌려 쓴 범용 눈이다.\n\n**확인** — 전처리 불일치가 가장 자주 나오는 버그다. 파이썬과 JS 에 **같은 이미지 하나**를 넣고 임베딩 벡터를 비교한다.\n\n---\n\n## R10. 아티팩트 관리\n\n대체 불가능성 순서 — ① `labels.jsonl`(재생성 불가, 다시 클릭해야 한다) ② 원본 이미지 + `meta.jsonl`(재수집 비용이 크고 `source_id` 는 사후 복구 불가) ③ `embeddings.npz`(2분이면 재생성) ④ 가중치(사실상 빌드 산출물). 레포에는 라벨과 스크립트만 커밋한다.\n\n**같은 데이터로 다시 학습해도 가중치 파일은 매번 달라진다** — 초기값·셔플 순서·부동소수점 연산 순서 때문이고, 시드를 고정해도 하드웨어가 바뀌면 달라진다. 그래서 ML 에서 \"재현\"의 기준은 파일 해시가 아니라 **평가 지표가 비슷한 범위에 들어오는가**다. 학습마다 남긴다.\n\n```json\n{\"run\": \"2026-09-01T14:20\", \"n_images\": 412, \"n_pairs\": 1503, \"split_seed\": 0,\n \"val_pairwise_acc\": 0.731, \"baseline_aesthetic_acc\": 0.648, \"human_ceiling\": 0.762, \"commit\": \"a3f1c9d\"}\n```\n\n---\n\n## 실행 순서\n\n```\n[1] 라벨링 웹툴 ─── 이게 먼저다. 나머지 전체 속도를 좌우한다\n[2] 이미지 100장 + 본인 라벨 100쌍\n[3] CLIP zero-shot 베이스라인과 상관 측정\n      ├─ tau ≈ 0    → 문제 설정 재검토\n      └─ tau > 0.25 → 계속\n[4] 400장 → 임베딩 캐시 (R3)\n[5] 라벨 링크 배포, 1,500쌍 (모델이 생기면 active sampling — |s_a − s_b| 가 작은 쌍부터)\n[6] head 학습 (R4) + 인간 천장 측정 (R7)   ← 병행\n[7] 미학 분해 (R6) + 진단 (R8)\n[8] 속성 head (multi-task) — 근거 표시용\n[9] ONNX → 브라우저 (R9)\n```\n\n**[3] zero-shot 베이스라인**은 라벨 0장으로 만드는 최소 검증이다. `sim(img, \"잘 볶인 윤기 있는 …\") − sim(img, \"눅눅하고 물기 많은 …\")` 을 점수로 쓰고 본인이 매긴 100쌍과 Spearman 을 잰다. 0.3만 나와도 출발점이고, 0에 가까우면 문제 설정부터 다시 본다.\n\n**[8] 속성 head** — 점수만 뱉으면 아무도 안 믿는다. 같은 임베딩에서 head 를 분기시키고 최종 점수를 속성의 선형 결합으로 두면 `기름 분리 낮음 −1.2 / 양파 볶음 좋음 +0.8` 같은 근거를 낼 수 있다. 속성 라벨은 VLM 으로 자동화해도 된다(장당 30초 손 라벨 = 1,000장에 8시간).\n\n> [!WARNING]\n> **최종 점수 라벨을 VLM 으로 만들면 안 된다.** 그건 내 취향이 아니라 그 모델의 취향을 증류하는 것이고 프로젝트의 전제가 무너진다. VLM 은 객관적으로 기술 가능한 속성 태깅에만 쓴다.\n\n**함정** — \"GPU 가 부족하다\"는 느낌이 들면 장비 문제가 아니라, 데이터가 100장뿐인데 backbone 을 fine-tuning 하고 있다는 신호일 가능성이 높다."},{"i":"585","t":"표 중심 개발 — 게임 밸런스를 코드가 아니라 데이터로","k":["game-dev","data-driven","simulation","llm","validation"],"c":"2026-09-01","m":"2026-09-02","b":"**언제** — 밸런스를 바꾸려는데 어디를 고쳐야 할지 몰라 매번 코드를 만질 때. 또는 LLM 이 콘텐츠를 대량으로 뽑아줬는데 손 검수가 불가능할 때. 워크드 예제는 무역 항해 게임(항구·교역품·발견물).\n\n`01 → 02 → 03 → 04` 가 기반, `05 → 06 → 07` 이 측정 인프라, `08~11` 은 지표라 순서 무관, `12`·`13` 은 언제든. **표는 싸다. 루프는 안 싸다.**\n\n## 진단 역인덱스\n\n| 증상 | 의심 | 레시피 |\n|---|---|---|\n| 밸런스 고치려니 코드를 만진다 | 층 분리 실패 | 01, 02 |\n| 튜닝 표는 있는데 아무도 안 만진다 | 측정 수단 없음 | 06, 11 |\n| 후반이 지루하다 | 선택지 폭 붕괴 | 08, 09 |\n| 돈이 갑자기 무한대가 된다 | 차익 사이클 | 10 |\n| 표 고칠 때마다 뭔가 깨진다 | 파생값 중복 저장 | 03 |\n| LLM 데이터 오류를 못 찾는다 | 검증기 없이 생산 | 04, 13 |\n| 시뮬 결과가 매번 다르다 | RNG 미주입 | 05 |\n| 좋아졌는지 나빠졌는지 모른다 | 기준선 없음 | 11 |\n| 표는 완벽한데 재미가 없다 | Shape 문제 | 12 |\n\n---\n\n## 01. 튜닝 가능 표면 파악\n\n기능 추가를 멈추고 숫자 리터럴을 전수 조사한다.\n\n```bash\nrg -n --type ts '(?<![\\w.])[0-9]+(\\.[0-9]+)?(?![\\w])' src/ \\\n  | rg -v 'test|spec|\\.d\\.ts' | rg -v '\\b(0|1|-1|2)\\b'\n```\n\n세 층으로 가른다.\n\n| 층 | 판정 질문 | 처리 |\n|---|---|---|\n| **Shape** | 바꾸면 규칙의 *형태*가 바뀌는가 | 코드에 남긴다. 단 별도 문서에 명시 |\n| **Knob** | 바꾸면 게임의 *느낌*만 바뀌는가 | `data/tuning.ts` 로 |\n| **Content** | 인스턴스마다 다른 값인가 | 해당 개념의 표로 |\n\n경계 사례 판정법 — **\"이 값을 0이나 무한대로 만들면 게임이 다른 장르가 되는가?\"** 순풍 배수 `1.3` 은 0이어도 항해 게임이니 Knob, 최대 함대 선박 수 `4` 는 1이면 다른 게임이니 Shape.\n\n**확인** — `docs/shape.md` 에 Shape 목록을 적는다. **이 문서의 존재 자체가 산출물이다.** \"표로 못 바꾸는 것\"이 명시돼 있지 않으면 나중에 잘못된 축을 몇 주간 튜닝한다.\n\n**함정** — 애매하면 Knob 으로 넣고 싶어지지만 반대가 싸다. Shape 로 뒀다가 Knob 으로 내리는 건 쉽고, Knob 으로 뺐다가 Shape 였음을 알게 되면 **표 전체가 무의미해진 상태로** 발견된다.\n\n---\n\n## 02. Knob 표 + 리터럴 금지 린트\n\n표를 만들고 린트로 강제한다. **강제 없는 규칙은 LLM 상대로 이틀을 못 간다.**\n\n```ts\n// data/tuning.ts\nexport const TUNING = {\n  baseSpeedPerSail: 0.8, windTailwindMul: 1.3, scurvyOnsetDays: 45,\n  priceVolatility: 0.12, priceMeanReversion: 0.05, bulkPriceImpact: 0.008,\n} as const\n```\n\n```js\n// eslint.config.js — data/ 하위는 예외. 표는 숫자 덩어리인 게 정상이다\n'no-magic-numbers': ['error', { ignore: [0, 1, -1], ignoreArrayIndexes: true, enforceConst: true }]\n```\n\n**확인** — 아무 함수에 `* 1.5` 를 넣어보고 CI 가 빨개지는지 실제로 본다.\n\n**함정** — `TUNING.foo` 를 지역 변수로 복사한 뒤 그 위에서 산술하면 린트가 못 잡는다(`const eff = TUNING.baseSpeedPerSail * 1.2`). 리뷰에서 봐야 한다.\n\n---\n\n## 03. 표 스키마 — 참조는 ID, 해석은 로드 시 1회\n\n인덱스를 박으면 항구 하나 추가할 때 전부 밀리고, 문자열 참조는 오타로 조용히 깨진다. 작성은 문자열 ID 로, 런타임은 인덱스로.\n\n```ts\nexport const PORTS = [\n  { id: 'lisboa', name: '리스본', x: 120, y: 340, size: 3, specialty: 'wine' },\n  { id: 'sevilla', name: '세비야', x: 138, y: 352, size: 3, specialty: 'olive' },\n] as const satisfies readonly PortDef[]\n\nexport type PortId = typeof PORTS[number]['id']   // 리터럴 유니온\n```\n\n`PortId` 로 다른 표를 제약하면 **존재하지 않는 항구 참조를 타입 체커가 잡는다** — 참조 무결성 검사의 절반이 공짜다. 인덱스 해석은 초기화에서 한 번:\n\n```ts\nconst portIndex = new Map(PORTS.map((p, i) => [p.id, i]))\nconst routes = ROUTES.map(r => ({ ...r, from: portIndex.get(r.from)!, to: portIndex.get(r.to)! }))\n```\n\n**확인** — 항구 하나를 표 중간에 삽입한다. 아무것도 안 깨지면 성공.\n\n**함정** — **파생값을 표에 넣지 않는다.** 항구 간 거리는 좌표에서 계산한다. 표에 박으면 좌표를 옮길 때 조용히 어긋난다. 손으로 덮어써야 하면 `distanceOverride` 처럼 의도를 이름에 드러낸다.\n\n---\n\n## 04. 검증기 — 표 확장 속도의 상한\n\n**표를 늘리는 속도의 상한은 검증기가 결정한다.** 표마다 불변식을 코드로 적는다.\n\n```ts\n// src/validate.ts — 실패하면 exit 1\nconst dup = PORTS.map(p => p.id).filter((v, i, a) => a.indexOf(v) !== i)   // 1. ID 유일성\n// 2. 좌표 충돌  3. 해로 연결성(시작 항구에서 BFS 도달)  4. 발견물 도달 가능성\n// 5. 범위 검사(basePrice > 0)  6. 진행 가능성(초기 자금으로 흑자 경로가 최소 1개)\nconst reach = bfs(START_PORT, routes)\nfor (const p of PORTS) if (!reach.has(p.id)) fail('unreachable-port', p.id)\n```\n\n**확인** — 일부러 항구 하나를 육지 안쪽으로 옮기고 `unreachable-port` 가 뜨는지 본다. 안 뜨면 검증기가 거짓말을 하고 있다. **모든 규칙은 최소 한 번 실패시켜 본 적이 있어야 한다.**\n\n**함정** — 새 표를 추가하면서 불변식을 안 쓰는 것. **표 추가 PR 에는 반드시 validate 규칙이 함께 온다.**\n\n---\n\n## 05. 시드 고정 RNG\n\n재현 불가능한 시뮬레이션은 디버깅이 불가능하다. `Math.random()` 을 제거하고 주입 가능한 PRNG 로 바꾼 뒤 게임 상태가 `rng` 를 들고 다니게 한다 — 전역 싱글턴은 병렬 시행에서 깨진다.\n\n**확인** — `rg -n 'Math\\.random' src/` 가 0건, `assert(hash(run(42)) === hash(run(42)))`.\n\n**함정** — 시스템별로 스트림을 나누면(항해용/시세용/이벤트용) 한쪽을 고쳐도 다른 쪽 난수열이 안 밀려 비교가 깔끔해진다. 나중에 하면 아프다.\n\n---\n\n## 06. 헤드리스 시뮬레이터\n\nUI 없이 게임 루프를 N턴 돌리는 진입점. **창발적 문제는 코드를 읽어서 안 나온다** — 수십 년치를 돌려야 나오는 종류가 있다.\n\n```ts\nfor (let t = 0; t < turns && !state.over; t++) {\n  const actions = legalActions(state)\n  const chosen = bot(state, actions, rng)\n  state = advanceTurn(applyAction(state, chosen, rng), rng)\n  trace.push({ turn: t, gold: state.gold, action: chosen.kind, port: state.currentPort,\n               choiceCount: actions.filter(a => isMeaningful(state, a)).length })\n}\n```\n\n**확인** — 루프 한 사이클(표 수정 → validate → sim → 지표 diff)이 **몇 분 안에** 끝나야 한다. 이 시간이 개발 속도의 실질 상한이다.\n\n**함정** — `legalActions` 와 `applyAction` 을 UI 에서 분리해두지 않으면 시뮬레이터를 못 만든다. Shape 설계 단계에서 이 둘을 순수 함수로 잡아둔다.\n\n---\n\n## 07. 봇 3종 — 상한과 하한을 같이 잰다\n\n단일 봇 결과는 해석이 불가능하다.\n\n| 봇 | 구현 | 판정 |\n|---|---|---|\n| Random | 합법 행동 중 균등 추출 | 여기서 안 죽으면 **긴장감이 없다** |\n| Greedy | 1스텝 기대이익 최대 | 여기서 이기면 **전략 깊이가 없다** |\n| Optimizer | 빔서치 / 짧은 룩어헤드 | 지배 전략 탐색용 |\n\n**확인** — 셋의 결과가 **뚜렷이 갈려야** 한다. Random ≈ Greedy 면 플레이어 선택이 결과에 영향을 못 주고, Greedy ≈ Optimizer 면 앞을 내다볼 이유가 없다. 둘 다 게임이 죽어 있다는 뜻이다.\n\n**함정** — Optimizer 를 너무 강하게 만들면 게임이 아니라 봇을 튜닝하게 된다. 룩어헤드 3~5턴이면 충분하다.\n\n---\n\n## 08. 지표 — 선택지 폭 (샌드박스의 사망 지표)\n\n매 턴 열려 있는 *유의미한* 행동 수. 최선 대비 70% 이상 나오는 행동의 개수를 센다.\n\n```ts\nconst isMeaningful = (s, a) =>\n  estimateGain(s, a) > Math.max(...legalActions(s).map(x => estimateGain(s, x))) * TUNING.meaningfulRatio\n```\n\n전 구간에서 이 값이 3 아래로 내려가면 **그 시점부터 플레이어는 관객이다.**\n\n**확인** — 3개 시드에서 곡선이 비슷하면 구조적 문제, 제각각이면 초기 조건 문제.\n\n**함정** — `estimateGain` 이 부정확하면 지표 전체가 거짓말이 된다. Greedy 봇과 공유하므로 **봇이 멍청하면 지표도 멍청하다.**\n\n---\n\n## 09. 지표 — 지배 전략 고착 시점\n\n\"결국 리스본↔세비야 왕복만 하게 된다.\" Greedy 봇의 `action:port` 분포 엔트로피를 100턴 슬라이딩 윈도로 재고 `H < 1.0 bit` 로 떨어진 턴을 찾는다.\n\n**확인** — 총 턴 수의 20% 지점에서 나오면 게임의 80%가 반복 작업이다.\n\n**함정** — 고착 자체가 나쁜 게 아니다. 문제는 고착 이후의 **콘텐츠 소진율**이다. 발견물 60% 본 상태에서 고착되면 나머지 40%는 아무도 안 본다. 두 지표를 같이 본다.\n\n---\n\n## 10. 지표 — 무한 차익 검출\n\n그래프의 **양의 비율 사이클** 탐지. 간선 가중치를 `-log(왕복 수익률)` 로 두면 음수 사이클 문제가 되고 Bellman-Ford 로 푼다. `bestTradeRatio` 에 항해 일수·보급 소모·위험도를 전부 반영하지 않으면 실제로는 손해인 루트가 차익으로 잡힌다.\n\n**확인** — validate 에 넣는다. 항구·교역품을 추가할 때마다 자동으로 걸린다. **이것이 콘텐츠 대량 생산의 안전장치다.**\n\n**함정** — 완전 제거가 목표가 아니다. 어느 정도의 우위 루트는 플레이어가 찾아내는 재미다. 임계를 둔다(예: 왕복 수익률 1.5배 초과만 실패).\n\n---\n\n## 11. 회귀 기준선 — 지표 diff\n\n코드가 아니라 **결과를** 버전 관리한다. 20개 시드 중앙값으로 `metrics/baseline.json` 을 커밋하고 비교한다.\n\n`⚠ lockInTurn: 1840 → 620 (-66.3%) ← 의도한 변경인가?` 처럼 15% 넘는 변화만 표시한다.\n\n**확인** — 튜닝 값 하나를 명백히 나쁜 방향으로 바꿔보고 diff 에 잡히는지 본다.\n\n**함정** — 단일 시드로 기준선을 잡으면 분산이 변화로 오인된다. 그리고 baseline 갱신은 **의도적으로만** — 자동 갱신하면 서서히 나빠지는 걸 못 본다.\n\n---\n\n## 12. Shape 스파이크\n\n**표는 다 채웠는데 재미가 없고 튜닝으로 안 되는 것** — 가장 비싼 실패 모드다. Shape 는 표로 못 고치므로 **콘텐츠를 채우기 전에** 확인한다.\n\n규격: 항구 2개 / 교역품 3종 / 발견물 5개 / UI 없음, sim 만 / **24시간 안에 폐기 가능할 것.** 여기서 볼 것은 재미가 아니라 **곡선의 모양**이다 — 선택지 폭이 0으로 수렴하지 않는가, 자금 곡선이 지수가 아닌가, 세 봇이 갈리는가.\n\n**확인** — 항구 2개로 지루하면 40개여도 지루하다. 통과 못 하면 콘텐츠를 채우지 않는다.\n\n**함정** — 스파이크를 본 프로젝트에 병합하고 싶어진다. **버릴 수 없는 프로토타입은 프로토타입이 아니라 레거시다.**\n\n---\n\n## 13. 콘텐츠 대량 생산\n\n완료 조건을 검증기 통과로 건다.\n\n```\n❌ \"항구 40개 추가해줘\"\n✅ \"항구 40개 추가하고 npm run validate 를 통과시켜. 실패하면 고쳐서 다시 돌려.\"\n```\n\n작업 단위도 바꾼다 — 기능 단위가 아니라 **\"표 하나 + 불변식 + 지표 하나\"**. 기능 단위로 쪼개면 LLM 은 코드를 만들고, 데이터 단위로 쪼개면 표를 만든다. `CLAUDE.md` 에는 숫자 리터럴 금지·validate 통과 의무·파생값 저장 금지·`docs/shape.md` 항목은 먼저 물어보기를 넣는다.\n\n**함정** — **그냥 두면 숫자를 코드에 박는다.** 이게 가장 흔하고 조용한 붕괴 경로다 — 산출물은 늘어나는데 조정 가능한 상태로 남는 게 없고, \"진도가 안 나가는\" 느낌의 실체가 대개 이것이다. 레시피 02의 린트가 유일하게 믿을 만한 방어선이다.\n\n---"},{"i":"584","t":"에이전트 신뢰 구축 — 검증 경로부터 자동 머지까지","k":["claude-code","agent","verification","hooks","code-review"],"c":"2026-09-01","m":"2026-09-02","b":"**신뢰는 태도가 아니라 인프라의 결과물이다.** \"믿어보자\"는 결심으로는 아무것도 안 바뀌고, 검증 경로가 생기면 신뢰는 따라온다. 그래서 **순서를 바꾸면 안 된다** — R5 이전에 R9(병렬화)로 건너뛰면 토큰만 태운다.\n\n| # | 원칙 | 위반 신호 |\n|---|---|---|\n| P1 | 에이전트가 스스로 실행해서 확인할 수 없는 작업은 위임하지 않는다 | 스크린샷을 복붙하고 있다 |\n| P2 | 구현자와 검증자는 다른 컨텍스트여야 한다 | \"수정 완료했습니다\"를 구현자가 말한다 |\n| P3 | 소프트 규칙(문서·프롬프트)은 신뢰의 근거가 아니다 | CLAUDE.md 에 적었는데 안 지킨다 |\n| P4 | 가장 짧은 경로가 정답 경로가 되도록 설계한다 | 에이전트가 매번 우회로를 찾는다 |\n| P5 | 사람이 리뷰로 강제하는 불변식은 전부 코드 스멜이다 | 같은 지적을 두 번 했다 |\n\n---\n\n## R00. 적용하면 안 되는 곳 — 먼저 거른다\n\n자동 검증이 도달 불가능한 도메인(실제 예약·결제·외부 파트너 연동 — 여기서 검증 에이전트는 \"코드를 읽고 추론\"으로 퇴화한다), 도메인 규칙이 코드 밖에 있는 영역(규제 요건 — 검증할 대상이 코드베이스 안에 없다), R06 의 1~3층을 바꿀 권한이 없는 코드베이스.\n\n**해당하면 자동화 대상을 좁히는 게 답이지 방법론을 약화시키는 게 아니다.** 내부 도구에서 끝까지 밀고 위험 영역은 손대지 않는다.\n\n---\n\n## R01. 지금 어느 구간인지 확정\n\n| 구간 | 증상 | 다음 |\n|---|---|---|\n| 0 불신 | 출력을 매번 전부 읽는다. 병렬 0~1 | R02 |\n| 1 관찰 | 툴 콜을 읽으며 실패 패턴 수집 중. 병렬 1~3 | R04 |\n| 2 위임 | 검증 경로가 있고 판정을 에이전트가 낸다. 병렬 3~8 | R06 |\n| 3 자동화 | 하드 제약이 CI/hook 에 있다. 병렬 8+ | R09 |\n| 4 무감독 | 자동 머지 후 사후 리뷰 | R10 |\n\n**판정 질문 하나** — *\"지금 에이전트가 만든 PR 을 읽지 않고 머지한다면, 문제가 생겼을 때 며칠 뒤에 알게 되는가?\"* \"모른다\"면 구간 0이다.\n\n---\n\n## R02. 검증 스킬 부트스트랩 — 사람이 검증자 자리에서 내려온다\n\n**재료** — 앱을 헤드리스로 기동하는 명령, 실행 중 앱을 조작할 채널(CDP·Playwright MCP), 관찰 채널(콘솔·네트워크·DOM 스냅샷).\n\n1. **먼저 사람이 손으로 한 번 한다.** dev 서버 기동 → 특정 화면 도달 → 관찰. 여기서 막히면 에이전트도 막힌다.\n2. 그 과정을 `boot.sh`·`snapshot.sh` 로 스크립트화하고 `.claude/skills/verify-app/` 으로 감싼다.\n3. **출력 계약을 고정한다.** 산문 판정을 받으면 사람이 다시 해석해야 하고 거기서 신뢰가 샌다.\n\n```json\n{ \"verdict\": \"pass | fail | cannot_verify\",\n  \"steps_taken\": [\"실제 실행한 명령\"],\n  \"evidence\": [\"관찰한 원문 출력\"],\n  \"unverified\": [\"실행으로 확인 못 한 항목\"] }\n```\n\n- **`cannot_verify` 를 1급 값으로 둔다.** 없으면 에이전트가 pass/fail 중 하나를 억지로 고르고 그 순간 신뢰가 무너진다.\n- **evidence 에 코드 인용이 들어가면 검증이 아니다.** 코드를 읽고 추론한 건 관찰이 아니다. SKILL.md 에 명시한다.\n\n**확인** — 같은 버그를 3번 던졌을 때 `steps_taken` 이 일관되면 통과.\n\n**함정** — 검증 스킬과 구현 에이전트를 동시에 만들지 말 것. 구현자가 있으면 검증 없이도 진행되므로 검증 스킬을 다듬을 동기가 사라진다. 트레이스 수집도 처음부터 넣지 않는다 — 기동 + 도달 + 콘솔로 시작.\n\n---\n\n## R03. feature map\n\n검증 스킬만 있으면 에이전트는 **앱을 실행할 수는 있지만 그 앱이 뭔지 모른다.** \"사이드바가 버벅인다\"에서 사이드바까지 가는 방법을 모른다.\n\n화면마다 기록할 것 — 사용자가 부르는 이름(컴포넌트명 아님), 도달 경로(URL + 클릭 순서), 선택자(**없으면 \"없음\"이라고 쓴다**), 담당 디렉터리, 진입 조건(인증·권한·데이터 상태).\n\n라우팅 정의와 최상위 레이아웃부터 읽는다. 컴포넌트를 무작위로 열지 않는다. **전부 하지 않는다** — 문의가 많은 화면 10개만.\n\n**이 레시피의 진짜 산출물은 본문이 아니라 \"선택자 없음 목록\"이다.** 검증 자동화가 어디서 막히는지가 거기 그대로 나오고, 그 목록이 곧 다음 스프린트의 작업이다.\n\n---\n\n## R04. 실패 모드를 스킬로 승격\n\n**툴 콜과 thinking 블록을 펼쳐서 읽는다.** 최종 출력만 보면 아무것도 안 보인다.\n\n| 실패 유형 | 관찰 신호 | 대응 |\n|---|---|---|\n| 미탐색 | 파일을 읽지도 않고 원인 단언 | 스킬: 추측 전 검색 강제 |\n| 조기 확신 | 첫 가설을 검증 없이 채택 | 스킬: 가설 2개 + 배제 근거 |\n| 도달 실패 | UI 에서 기능까지 못 감 | R03 보강 |\n| 도구 부재 | 확인할 방법 자체가 없음 | R02 확장 |\n| 범위 이탈 | 시키지 않은 리팩터링 | R06 하드 제약 |\n\n**같은 유형이 3회 이상 나올 때만 스킬로 만든다.** 1회성 실패에 스킬을 만들면 스킬이 쓰레기가 된다. 스킬 하나에 실패 유형 하나. **\"하지 마라\"보다 \"대신 이걸 해라\"가 강하다** — 금지만 적으면 다른 방식으로 같은 실수를 한다. description 에 **\"언제 쓰지 말아야 하는지\"** 를 한 줄 넣으면 라우팅 정확도가 눈에 띄게 오른다.\n\n---\n\n## R05. eval 하네스\n\n스킬을 고쳤을 때 좋아졌는지 확인. 이게 없으면 스킬은 미신이 된다.\n\n1. 코디네이터가 **루브릭**을 먼저 만든다. 기대 동작이 아니라 채점 항목으로.\n2. 서브에이전트를 각각 독립 디렉터리에서 실행.\n3. **다른 모델의 judge** 로 채점. 같은 모델이면 자기 출력에 후한 점수를 준다.\n4. 점수를 기록하고 스킬을 고친 뒤 재실행. 힐 클라이밍.\n\n**함정** — **에이전트는 평가받는 걸 감지하면 행동이 바뀐다.** 디렉터리명·파일명·프롬프트 어디에도 `eval`·`test`·`평가` 를 넣지 말고, 워킹 디렉터리를 평범한 프로젝트 복제본처럼 보이게 한다. 힐 클라이밍은 무제한 토큰에서만 성립하므로 **반복 상한을 3회**로 두고, 못 가면 스킬 설계 자체를 의심한다.\n\n---\n\n## R06. 강제 계층 배치\n\n| 순위 | 수단 | 성질 |\n|---|---|---|\n| 1 | 코드베이스 구조(디렉터리 규약, feature 코로케이션) | 에이전트는 기존 패턴을 복사한다 |\n| 2 | 타입 시스템(strict, 브랜디드 타입) | 하드 |\n| 3 | CI / 린트 / 의존성 검사 | 하드 |\n| 4 | hooks (PreToolUse 차단) | 하드 (로컬) |\n| 5 | rules / skills / CLAUDE.md | **소프트** |\n| 6 | 코드 리뷰 | **최악** |\n\n5·6만 갖고 있다면 코드베이스가 무너지는 건 시간 문제다.\n\n- **PreToolUse** (exit 2 → 차단): 차단 사유가 stderr 로 전달되어 자가 수정된다\n- **PostToolUse**: 변경 파일에 한정. 전체 타입체크는 루프를 끊는다\n- **SubagentStop**: 출력 계약 위반 반려\n\n하드로 박을 후보 — 신규 파일 `useEffect` 금지(기존 파일은 허용해 점진 이행), 컴포넌트에서 fetch 직접 호출 금지, feature 간 cross-import 금지, `any`/`as` 신규 추가 금지.\n\n주석에 대해서는 전면 금지보다 **\"왜\"가 없는 주석 금지**가 현실적이다 — 에이전트가 쓰는 주석의 대부분은 코드가 아니라 **대화 이력**을 서술한다(일회성 지적을 전역 규칙으로 오해한 결과다).\n\n---\n\n## R07. 리뷰 코멘트를 하드 제약으로\n\n**트리거는 같은 지적을 두 번 하는 순간이다.** 세 번째를 기다리지 않는다.\n\n```\nPR 코멘트 → CLAUDE.md 규칙(소프트, 임시) → 린트/hook(하드, 목표)\n         → 타입으로 표현 불가능하게(최선) → 구조적으로 제거(최고)\n```\n\n판단 순서: ① 애초에 불가능하게 만들 수 있나(API 설계 변경) ② 타입으로 막을 수 있나 ③ 린트/hook 으로 막을 수 있나 ④ 셋 다 안 되면 — 정말 안 되는가, 아니면 귀찮은가? **4번에서 \"귀찮다\"가 답이면 아직 안 된 게 아니다.**\n\n---\n\n## R08. 그린필드 가드레일\n\n**신규가 더 위험하다.** 브라운필드엔 이미 컨벤션과 가드레일이 있어 오히려 좋은 환경이고, 가드레일 없이 태스크를 주면 에이전트는 **가장 편한 방법**으로 푼다. 시간이 지나면 지름길에 최적화된, 사람이 이해 못 하는 코드베이스가 남는다.\n\n코드 한 줄 쓰기 전에 — feature 코로케이션(작업의 80%가 한 디렉터리에서 끝나야 한다), 의존성 경계 + CI 검사, **명사 정의**(에이전트는 이름이 있는 것을 복사한다), strict 최대치(나중에 켜는 건 사실상 불가능), 최단 경로 = 정답 경로.\n\n**확인** — 새 기능 하나를 시켜보고 **몇 개 디렉터리를 건드렸는지** 센다. 3개를 넘으면 구조가 잘못됐다.\n\n---\n\n## R09. 병렬화 기준 — 개수가 아니라 충돌 가능성\n\n서로 다른 feature 디렉터리·서로 다른 앱이면 동시 가능. **공용 타입·디자인 토큰·API 클라이언트는 직렬 강제**, 마이그레이션·스키마 변경은 단독 실행. 공용 레이어는 병렬 이득이 거의 없고 머지 충돌 비용만 는다.\n\n```\n동시 개수 ≤ (1시간에 리뷰 가능한 PR 수) × (자동 검증 통과율)\n```\n\n통과율이 50%면 개수를 늘려도 사람 병목이 그대로다. **개수를 늘리기 전에 통과율을 올린다.**\n\n---\n\n## R10. 자동 머지 진입 조건\n\n전제부터 — 자동 머지를 하는 대상은 **본인이 재설계한 코드베이스**다. 같은 회사의 기존 코드베이스에는 하지 않고 회귀가 계속 난다. **자동 머지는 에이전트 신뢰의 결과가 아니라 아키텍처 저작권자의 특권이다.**\n\n전부 yes 여야 한다 — 아키텍처를 내가 설계했거나 완전히 이해한다 / 위반 시 CI 가 빨개지는 하드 제약이 최소 5개 / `cannot_verify` 비율 20% 미만 / 롤백 5분 내 / 장애를 24시간 안에 자동 인지 / PR 이 원자적이다.\n\n`위험 = 폭발 반경 × 발견 지연 × 되돌리기 비용`. 내부 운영 도구는 가능, 공개 웹(SEO)은 신중(인덱싱 회복이 느리다), **결제·예약·인증 플로우는 하지 않는다** — 실패한 사용자는 롤백으로 돌아오지 않는다. 같은 사람이 영역마다 다른 신뢰 수준으로 일해도 문제없다.\n\n---"},{"i":"583","t":"에이전트 코딩 환경 — 워크트리·포트·자격증명","k":["claude-code","git-worktree","hooks","dotfiles","security"],"c":"2026-09-01","m":"2026-09-01","b":"**언제** — 워크트리를 여럿 띄웠는데 서로의 서버를 죽이거나, 의존성 검증이 거짓 통과하거나, 새 워크트리에서 첫 명령이 실패할 때.\n\n먼저 세 갈래를 정한다. 이게 무엇을 채택할지 결정한다.\n\n| 질문 | 아니오 | 예 |\n|---|---|---|\n| 24시간 켜진 별도 호스트가 있나 | R8 폐기 | R8까지 |\n| 폰·외부 머신에서 세션을 이어받나 | R4를 localhost 포트 격리로 축소 | R4를 프록시 + tailnet URL로 |\n| 네이티브 API(Vision·Swift 바인딩)에 의존하나 | R5의 컨테이너 격리 가능 | 컨테이너 불가 — 호스트 직접 실행 + 토큰 축소로만 |\n\n프로젝트 종류가 섞여 있으면 **격리 정책도 프로젝트별로 가른다.** 단일 정책을 강제하면 반드시 가장 약한 쪽으로 수렴한다.\n\n---\n\n## R1. 워크트리를 프로젝트 밖으로\n\n기본 위치가 `<project>/.claude/worktrees/<name>` 이라 JS 생태계와 충돌한다. `node_modules` 해석이 상위로 올라가서 **의존성 삭제·다운그레이드 검증이 거짓 통과**하고, 재귀 순회하는 도구(`tsc` project references, vitest glob, `rg`, `find`)가 전부 워크트리로 들어간다.\n\n`AGENTS.md` 에 \"밖에 만들어\"라고 쓰는 건 확률적 층에 맡기는 것이다. 훅으로 내린다 — `WorktreeCreate`/`WorktreeRemove` 는 기본 git 동작을 **증강이 아니라 대체**한다.\n\n```bash\n# ~/.claude/hooks/worktree.sh — settings.json 에 WorktreeCreate/Remove 로 등록\nINPUT=$(cat)\nEVENT=$(jq -r '.hook_event_name' <<<\"$INPUT\")\nCWD=$(jq -r '.cwd' <<<\"$INPUT\")\nROOT=\"${WORKTREE_ROOT:-$HOME/.worktrees}\"\n\ncreate() {\n  NAME=$(jq -r '.name' <<<\"$INPUT\")\n  DIR=\"$ROOT/$(basename \"$CWD\")/$NAME\"\n  [ -d \"$DIR\" ] && { echo \"$DIR\"; exit 0; }   # 재사용\n  BASE=$(git -C \"$CWD\" rev-parse --abbrev-ref HEAD)\n  mkdir -p \"$(dirname \"$DIR\")\"\n  git -C \"$CWD\" worktree add -b \"wt/$NAME\" \"$DIR\" \"$BASE\" >&2\n  setup \"$CWD\" \"$DIR\" >&2       # R2·R3 이 여기 들어간다\n  echo \"$DIR\"                   # 읽히는 유일한 stdout\n}\n```\n\n**확인**\n\n```bash\necho '{\"hook_event_name\":\"WorktreeCreate\",\"cwd\":\"'$PWD'\",\"name\":\"probe\"}' \\\n  | bash ~/.claude/hooks/worktree.sh          # 경로 한 줄만 나와야 한다\ncd ~/.worktrees/<project>/probe && node -e \"console.log(require.resolve('some-dep'))\"\n```\n\n두 번째가 워크트리 밖 경로를 뱉으면 격리가 안 된 것이다.\n\n**함정** — **stdout 오염이 곧 실패다.** `git worktree add` 도 stdout 에 쓰므로 부가 출력은 전부 `>&2`, 진행 표시가 필요하면 `/dev/tty`. 그리고 다른 훅과 달리 `WorktreeCreate` 는 **비영 종료 = 생성 중단**이고 JSON 반환도 못 한다(stdout 이 경로 전용이라). 훅은 `settings.local.json` 이 아니라 `settings.json` 에 둔다.\n\n---\n\n## R2. 워크트리별 결정적 포트\n\n브랜치명을 해시해 고정한다. 같은 브랜치는 항상 같은 포트라 재현되고 URL 을 기억할 수 있다.\n\n```bash\nhash_port() {\n  local n; n=$(printf '%s' \"$1\" | shasum | tr -dc '0-9' | head -c 6)\n  echo $(( (10#$n % 6000) + 3100 ))\n}\nsetup() {\n  local PORT; PORT=$(hash_port \"$(basename \"$2\")\")\n  printf 'PORT=%s\\nVITE_PORT=%s\\n' \"$PORT\" \"$PORT\" >> \"$2/.env.local\"\n}\n```\n\n**함정** — macOS 엔 `md5sum` 이 없다(`md5` 다). 리눅스 기준 스크립트를 복사하면 여기서 깨지므로 `shasum`·`cksum` 을 쓴다. `10#$n` 은 선행 0을 8진수로 읽는 걸 막는다 — 빼면 간헐적으로 죽는다. 해시 충돌은 드물지만 **결정적이라 한 번 나면 그 브랜치쌍은 영원히 충돌한다.**\n\n---\n\n## R3. gitignore 된 파일 승계\n\n새 워크트리엔 `.env`, 로컬 인증서, 빌드 캐시가 없어서 에이전트가 첫 명령에서 실패하고 원인을 추측하다 세션 절반을 태운다. 승계 목록을 리포에 선언하고 훅이 읽게 한다.\n\n```bash\n# .worktreeinclude 에 .env / .env.local / .certs/ / .tool-versions\nwhile IFS= read -r pat; do\n  [ -z \"$pat\" ] && continue\n  (cd \"$SRC\" && rsync -R \"$pat\" \"$DIR/\")     # cp --parents 는 GNU 전용\ndone < \"$SRC/.worktreeinclude\"\n(cd \"$DIR\" && pnpm install --frozen-lockfile >&2)\n```\n\n**함정** — **심링크 금지.** `.env` 를 심링크하면 에이전트가 워크트리 안에서 값을 바꿨을 때 메인과 다른 워크트리 전부가 동시에 오염된다. 그리고 **승계 목록이 곧 유출 경로다** — 자격증명을 넣는 순간 워크트리 개수만큼 사본이 는다. `WorktreeRemove` 에서 확실히 지운다. 설치가 오래 걸리면 훅 `timeout` 을 늘린다(타임아웃 = 생성 실패).\n\n---\n\n## R4. 프리뷰 서버 규약\n\n에이전트가 \"확인해보세요\"라고 줄 URL 이 실제로 열려야 하고 secure context 여야 한다 — `crypto.subtle`·클립보드·서비스 워커가 여기 걸린다.\n\n- **로컬 전용**: `http://localhost:<port>` 는 이미 secure context 다. 필요한 건 R2 뿐. `AGENTS.md` 에 \"`.env.local` 의 `PORT` 를 쓰고, 점유돼 있으면 죽이지 말고 보고하라\"만 적는다.\n- **외부 접근**: `0.0.0.0` 바인딩 + IP 직접 접근은 secure context 가 **아니다.** 프로젝트 `dev` 스크립트를 고쳐서 풀지 말 것 — 환경 고유 관례를 리포에 침투시키는 것이다. 래퍼 바이너리 하나를 만들고 하네스가 그걸 부르게 한다.\n\n**함정** — 이 규약은 확률적 층에 얹혀 있다. 결정적으로 내리려면 `PreToolUse` 훅에서 dev 명령 패턴을 잡아 차단하거나 래퍼로 치환한다.\n\n---\n\n## R5. 자격증명 축소가 먼저다\n\nVM 으로 파일시스템 경계를 긋고 그 안에 전권 토큰을 넣으면 **파일시스템은 격리되지만 자격증명은 격리되지 않는다.** 실제 폭발 반경은 VM 이 아니라 계정 전체다. 게다가 위협 모델을 사고(accident)로만 잡으면 진짜 리스크인 prompt injection 이 빠진다 — 이슈 본문·PR 코멘트·의존성 README 를 읽는 순간 외부 텍스트가 명령이 된다.\n\n컨테이너를 \"cost/benefit 미달\"로 미룰 때 **비용이 거의 없는 것까지 같이 미루지 않는다.**\n\n1. **토큰 축소 (30분, 1회).** 기본 OAuth 스코프 대신 fine-grained PAT. 대상 리포 명시 선택, Contents·PR·Issues 만 read/write, **Workflows 제외**(CI 정의를 못 고치게 — 가장 위험한 승격 경로), Administration 제외, 만료일 설정. 이것만으로 레포 삭제·다른 프로젝트 오염·CI 를 통한 시크릿 탈취가 사라진다.\n2. **실행 격리.** 순수 웹/Node 만 devcontainer 안에서 `bypassPermissions`. 네이티브 의존은 컨테이너 불가(Apple Silicon 컨테이너는 리눅스 VM 위)라 권한 모드를 기본으로 유지한다.\n3. **주입 표면 좁히기.** 신뢰할 수 없는 텍스트를 읽는 세션과 쓰기 권한을 가진 세션을 나눈다. 웹 페치와 `gh` 쓰기가 같은 세션에 있으면 그게 곧 exfiltration 경로다.\n\n**확인** — `gh auth status` 로 스코프를 본다.\n\n---\n\n## R6. 사용자 전역 설정을 버전관리로\n\n`settings.json`·훅·스킬·`AGENTS.md` 가 홈에 흩어져 이력도 백업도 없다. **머신이 하나면 chezmoi 는 과잉이지만, 이력과 백업은 머신 수와 무관한 가치다.** private 리포 하나 + 심링크로 끝난다.\n\n**함정** — 하네스가 파일을 rename 으로 **대체**하면 심링크가 깨진다. `ls -l ~/.claude/` 로 주기적으로 확인한다. `.gitignore` 에 `**/*.local.json`·`.env*`·`*credentials*` 를 넣고 첫 커밋 전 `git diff --cached` 를 눈으로 훑는다.\n\n---\n\n## R7. 설계 결정을 세션 밖으로\n\n세션 트랜스크립트가 어떤 설계 결정의 유일한 기록인 경우가 있다. 이걸 \"세션 히스토리를 백업하고 싶다\"는 도구 문제로 프레이밍하면 틀린다 — 트랜스크립트는 신호 대 잡음비가 최악이고 백업해도 참조 대상이 안 된다. 진짜 문제는 **결정을 어디에 커밋하느냐**다.\n\n`AGENTS.md` 에: 기각한 대안이 있었다면 코드 커밋과 **같은 커밋에** `docs/decisions/NNNN-<slug>.md` 를 추가한다. 맥락 / 결정 / 기각한 대안과 이유 / 결과, 한 화면 이내, 대화를 옮기지 말고 결론만.\n\n**확인** — 나중에 \"왜 이렇게 했지?\"를 물었을 때 리포 안에서 답이 나오면 성공. `git log` 와 트랜스크립트를 뒤져야 하면 실패.\n\n**함정** — 조건 없이 시키면 사소한 것까지 쓴다. \"기각한 대안이 있었을 때만\"을 명시한다.\n\n---\n\n## R8. 원격 호스트 (조건부)\n\nTailscale 로 SSH·HTTPS 를 호스트명으로 열고, **HTTPS 인증서로 secure context 를 확보**한다 — 포트포워딩 대신 이걸 택하는 실질적 이유가 그것이다. 랩탑을 닫아도 지속되려면 하네스의 원격 세션 모드를 쓴다(tmux 는 최후 수단). 머신이 둘 이상이 됐으므로 여기서는 chezmoi 가 정당화된다.\n\n**함정 — 병렬화의 진짜 병목.** 워크트리와 포트를 아무리 격리해도 상한은 **사람의 diff 리뷰 대역폭**이다. 4개 동시 실행은 4개의 리뷰 큐를 만들 뿐이다. 병렬도를 올리기 전에 리뷰 처리량을 먼저 잰다.\n\n---\n\n## 실행 순서\n\n각 단계가 독립적으로 가치를 내므로 중간에 멈춰도 된다.\n\n1. **R6 (dotfiles 리포)** — 나머지 전부의 저장소가 된다\n2. **R5-1 (토큰 축소)** — 30분·1회·효과 최대. 격리 설계를 기다릴 이유가 없다\n3. **R1 (워크트리 위치)** — 훅 하나. 되면 R2·R3 은 같은 스크립트에 추가하는 것\n4. R2·R3 — R1 의 `setup()` 채우기\n5. R7 → R4 → R5-2 → R8\n\n**유통기한**: R1 의 절반은 언젠가 설정 한 줄로 대체된다(기본값 문제라서). R2·R3·R5·R7 은 도구가 아니라 워크플로 설계 문제라 대체되지 않는다."},{"i":"582","t":"Next.js SSG + S3 + CloudFront 캐시 정책","k":["aws","cloudfront","nextjs","cache","deploy"],"c":"2026-09-01","m":"2026-09-01","b":"정적 SPA에서 Next.js SSG로 옮길 때 **배포해도 구버전 HTML이 서빙되는** 문제를 헤더 설계로 해결한다. `example.com` 기준.\n\n## 왜 문제가 생기는가\n\nSPA는 캐시 대상 HTML이 `index.html` 하나뿐이라 진입점만 무효화하면 됐다. SSG는 라우트 수만큼 HTML 오브젝트가 생기고, 각 오브젝트가 **독립된 TTL 타이머**로 엣지에 캐시된다.\n\n`Cache-Control` 헤더 없이 올라가면 CloudFront는 배포판 Default TTL(미설정 시 86400초)을 적용하고, 브라우저는 휴리스틱 캐싱(`(Date - Last-Modified) × 10%`)을 한다. 이 신선 구간 동안 엣지는 **오리진에 접속조차 하지 않고** 캐시본을 내보내므로, S3에 새 HTML이 올라가 있어도 반영되지 않는다.\n\nTTL 타이머는 POP별·경로별로 독립이라 \"나는 새 화면, 저쪽은 구 화면\"이 재현성 없이 발생한다. **로컬에서 재현 안 돼도 문제가 없는 게 아니다.**\n\n## 진단 — 지금 어느 상태인가\n\n```bash\ncurl -sI https://example.com/  | grep -iE 'cache-control|x-cache|age'\ncurl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'\n```\n\n| `x-cache` | 의미 |\n|---|---|\n| `Miss from cloudfront` | 엣지 캐시에 없어 오리진에서 가져옴 (최신인 게 아니라 캐시가 비었을 뿐) |\n| `Hit from cloudfront` | 엣지 캐시본 서빙, 오리진 접속 안 함 — **여기가 구버전 서빙 지점** |\n| `RefreshHit from cloudfront` | TTL 만료 후 오리진 재검증 → 304 → 캐시본 서빙 (`max-age=0`의 정상 동작) |\n\n**판정**\n\n- HTML에 `cache-control`이 **없다** → 헤더 설계 미적용. 아래 적용 대상\n- HTML에 `max-age=0`인데 `Hit` + `age` 증가 → 배포판 **Minimum TTL이 오리진 헤더를 무시**하는 중. Cache Policy를 손본다\n- HTML이 `RefreshHit` → 목표 상태. 정상\n\n> [!NOTE]\n> 약한 ETag(`W/\"...\"`)와 `vary: Accept-Encoding`은 CloudFront 엣지 자동 압축의 흔적이다. S3 원본은 강한 ETag를 그대로 갖고 있고 재검증도 정상 동작하니 무시해도 된다.\n\n## 핵심 레시피 — 오브젝트 종류별로 정반대 헤더\n\n| 대상 | Cache-Control | 이유 |\n|---|---|---|\n| `_next/static/**` | `public,max-age=31536000,immutable` | 파일명에 콘텐츠 해시 → 내용이 바뀌면 이름이 바뀐다. 영구 캐시해도 낡은 걸 볼 수 없다 |\n| `*.html`, 데이터 파일 | `public,max-age=0,must-revalidate` | 매 요청 재검증. 배포 즉시 반영, 변경 없으면 304라 대역폭 낭비 없음 |\n\n```yaml\nversion: 0.2\n\nenv:\n  variables:\n    BUCKET: example.com\n\nphases:\n  post_build:\n    commands:\n      # 1) 해시 에셋: 영구 캐시, 삭제 없음\n      - aws s3 sync ./out/_next/static s3://$BUCKET/_next/static\n          --acl public-read\n          --cache-control 'public,max-age=31536000,immutable'\n\n      # 2) HTML·데이터: 매 요청 재검증, 구 HTML 정리\n      - aws s3 sync ./out s3://$BUCKET\n          --acl public-read\n          --exclude '_next/static/*'\n          --cache-control 'public,max-age=0,must-revalidate'\n          --delete\n\n      # 3) 엣지 무효화 (권한 있을 때만 — 아래 참고)\n      - export CF_DIST_ID=$(aws cloudfront list-distributions\n          --query \"DistributionList.Items[?Aliases.Items[?contains(@, '$BUCKET')]].Id\"\n          --output text)\n      - test -n \"$CF_DIST_ID\" || (echo \"CF_DIST_ID 조회 실패\" && exit 1)\n      - aws cloudfront create-invalidation --distribution-id $CF_DIST_ID --paths '/*'\n```\n\n**순서를 지켜야 한다**\n\n- **에셋(1) → HTML(2).** 단일 sync는 업로드 순서를 보장하지 않는다. HTML이 먼저 올라가면 **참조할 청크가 아직 없는 창**이 생긴다.\n- **1단계는 `--delete` 없음.** 구 해시 에셋을 남겨야 배포 순간 열려 있던 탭의 lazy chunk 404를 막는다.\n- **`--acl public-read`는 두 명령 모두에.** 한쪽만 빼면 그 오브젝트가 비공개가 되어 403.\n- 이 순서라면 **1 성공 + 2 실패**여도 \"새 에셋 + 구 HTML\"이라 서비스는 정상 동작하고 배포만 미반영된다.\n\n## 확인\n\n```bash\naws s3api head-object --bucket example.com --key index.html\naws s3api head-object --bucket example.com --key '_next/static/<hash>/...'\ncurl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'\n```\n\n| 대상 | cache-control | x-cache | age |\n|---|---|---|---|\n| HTML | `public,max-age=0,must-revalidate` | 반복 요청 시 `RefreshHit` | 없거나 0 |\n| `_next/static/**` | `public,max-age=31536000,immutable` | `Hit` | 계속 증가 |\n\nHTML이 `Hit` + `age` 증가면 **그때만** Cache Policy의 Minimum TTL을 확인한다.\n\n## 함정\n\n**`--exclude`는 삭제 판단에도 걸린다.** `--exclude '_next/static/*'`에 걸린 원격 객체는 `--delete` 대상에서도 빠진다. 구 해시 에셋이 남아 chunk 404가 원천 차단되는 건 **의도한 동작**이지만, `_next/static/**`이 무한 누적되므로 **S3 lifecycle rule이 따라와야 한다** (prefix `_next/static/`, 만료 30일쯤. 버킷 설정이라 한 번만 걸면 된다).\n\n`_next/data/**`는 exclude에 안 걸려 매 배포 정리된다. 구 buildId 데이터가 즉시 사라지지만 Next.js가 prefetch 실패 시 full page navigation으로 폴백하므로 보이는 오류는 없다. 신경 쓰이면 `--exclude '_next/data/*'`를 추가하고 lifecycle에 위임한다.\n\n**invalidation 비용 통념은 반대다.** 과금은 경로 단위인데 **와일드카드는 몇 개를 매칭하든 1 path**다. 경로 200개 열거 = 200 paths, `/*` = 1 path. 월 1,000 path 무료라 하루 30번 배포해도 무료 범위다. `/*` 한 줄이 가장 싸고 확실하다.\n\n**브라우저 캐시는 되돌릴 수 없다.** 헤더 없이 서빙된 응답을 받은 브라우저는 휴리스틱으로 수 시간 자체 보관하고, **invalidation은 여기 닿지 않는다.** 헤더 적용 후에도 일부 사용자는 각자 캐시가 만료될 때까지 구버전을 본다. 기다리는 것 외에 방법이 없고, 헤더 적용 이후 배포분부터는 재발하지 않는다.\n\n## 갈림길: invalidation 권한이 없다면\n\n`cloudfront:ListDistributions` + `cloudfront:CreateInvalidation`이 CodeBuild 서비스 롤에 없으면 3단계에서 AccessDenied.\n\n**헤더 설계가 invalidation을 불필요하게 만든다.** HTML에 `max-age=0,must-revalidate`가 박히면 엣지가 매 요청 재검증하므로 무효화 없이도 새 배포가 반영된다. 3단계는 \"즉시 반영 보장\" 보험일 뿐이다 — 권한이 없으면 세 줄을 통째로 지우고 1·2단계만으로 운영한다.\n\n**단 전환 최초 1회는 반드시 밀어야 한다.** 헤더 없이 올라간 구 HTML이 아직 엣지에 있다. 콘솔에서 `/*` 무효화(CLI 권한과 무관)하거나, Default TTL(기본 24시간)이 지나 자연 만료되기를 기다린다.\n\n## 갈림길: 업로드 도구가 prefix별 헤더를 못 줄 때\n\n`aws s3 sync`를 안 건드리고 엣지에서 해결한다.\n\n1. **Behavior 분리** — `/_next/static/*`용 behavior 추가, 나머지는 Default\n2. **Cache Policy** — static 쪽 Min/Default/Max TTL을 크게, Default behavior는 TTL 0\n3. **Response Headers Policy** — 뷰어로 나가는 `Cache-Control`을 덮어쓴다. 오리진에 헤더가 없어도 강제 적용\n\n파이프라인과 캐시 정책이 분리되어 더 깔끔하다. 단 `--delete` 부재 문제는 여전히 lifecycle로 보완해야 한다.\n\n## 부속\n\n**배포 ID는 CodeBuild가 자동 제공하지 않는다**(`CODEBUILD_*`에 없음). 배포판을 삭제·재생성하기 전까지 영구 고정이고 민감 정보도 아니라, **단일 서비스면 `env.variables`에 하드코딩이 가장 단순**하다. 위 buildspec의 별칭 조회 방식은 배포판을 재생성해도 buildspec을 안 건드리는 대신 `ListDistributions` 권한(`Resource: \"*\"`)과 실패 지점이 하나 는다.\n\n한 번 조회해 하드코딩할 때:\n\n```bash\naws cloudfront list-distributions \\\n  --query \"DistributionList.Items[?Aliases.Items[?contains(@, 'example.com')]].Id\" \\\n  --output text\n```\n\n배포 ID 고정 ≠ invalidation 매번 생성. 무효화 요청은 매 배포 새로 만들어지지만 **항상 같은 배포 ID를 향해** 날린다.\n\n**필요 IAM** — 업로드는 `s3:PutObject`·`s3:PutObjectAcl`·`s3:DeleteObject`·`s3:ListBucket`, 무효화는 `cloudfront:CreateInvalidation`(별칭 조회 시 `ListDistributions`). 기존에 `aws s3 sync ... --delete`가 돌고 있었다면 S3 권한은 이미 갖춰졌을 가능성이 높다.\n\n> [!WARNING]\n> `--acl public-read`가 동작한다는 건 버킷이 ACL 활성 상태라는 뜻이다. 2023-04 이후 생성된 버킷 기본값은 `BucketOwnerEnforced`라 `--acl` 자체가 에러다. 여유 있을 때 OAC + 버킷 정책으로 옮기는 걸 별도 과제로 잡는다."},{"i":"581","t":"harness 최적화 사다리 — 아래 칸을 손으로 못 짤 때만 올라간다","k":["llm","harness","context-engineering"],"c":"2026-09-01","m":"2026-09-10","b":"**최적화 대상은 사다리로 올라간다** — `prompt → structured context → workflow → harness code → optimizer code`. 위 칸이 더 세지만, **아래 칸을 손으로 못 짜는 게 확인될 때만** 올라간다.\n\n**언제**: harness를 짜다 \"이걸 자동화해야 하나\" 싶을 때. 먼저 지금 문제가 어느 칸인지 짚는다.\n\n| 칸 | 올라갈 신호 | 그 칸에서 하는 일 |\n|---|---|---|\n| 1 프롬프트 | — | |\n| 2 구조화된 컨텍스트 | 컨텍스트가 윈도우를 넘게 자란다 | 상태를 **파일시스템**에 두고 컨텍스트엔 포인터만 |\n| 3 워크플로 | 컨텍스트 관리 규칙이 태스크마다 달라 손으로 못 짠다 | subagent + process manager, 병렬 가설 |\n| 4 harness 코드 | 워크플로 설계 공간이 방대해 수작업으로 못 짠다 | 코드로 workflow 생성, 그래프 + 탐색 |\n| 5 optimizer 코드 | — | 진화 탐색, meta-prompt 공진화 |\n\n**확인**: 한 칸 올라간 뒤 **held-in(약점이 풀렸나) + held-out(새 이슈가 없나) 양쪽이 no-regression**일 때만 채택한다. 한쪽만 보면 그 신호에 overfit된다 — unit test면 test에, judge면 그 judge 특화 트릭에.\n\n**함정**\n\n- **재귀 구조만으론 부족하다.** 베이스 모델이 메커니즘을 개선할 만큼 유능하지 않으면 자기개선 루프는 **퇴화한다** — STOP은 GPT-4에서 올랐고 GPT-3.5·Mixtral에서 내렸다.[^weng-harness]\n- **평가가 빠르고 객관적일 때만 돈다.** taste·novelty·장기 가치처럼 verifier가 없는 축에선 루프가 헛돈다.\n- **evaluator와 권한 제어는 루프 바깥에 둔다.** harness를 진화시키는 루프가 자기 채점기를 못 만지게.\n- **컨텍스트를 통째로 다시 쓰지 마라.** 매 스텝 blob을 rewrite하면 디테일이 뭉개지고 점점 짧아진다(context collapse). 증분 항목만 병합한다.[^weng-harness]\n- **단순·generic이 이긴다.** 커스텀 메모리 API를 발명할수록 코어 모델 향상에 무임승차할 기회를 버린다. `bash`·파일시스템·표준 툴셋을 선호.\n\n그런데 **고정 모델에서도 harness만 진화시켜 크게 오른다** — DGM이 발견한 에이전트가 SWE-bench Verified에서 20 → 50%.[^weng-harness] 사다리를 올라갈 값어치가 있다는 쪽의 근거다.\n\n---\n\n칸마다 붙는 사례(ACE·MCE·ADAS·AFlow·Autodata·Self-Harness·DGM·AlphaEvolve·ShinkaEvolve…)는 [원문](https://lilianweng.github.io/posts/2026-07-04-harness)에 있다. 여기 남긴 건 **어느 칸에서 언제 올라가느냐**뿐이다.\n\n[^weng-harness]: *\"A harness should not carry the entire workflow and all logs in context; instead, it should keep durable state in files.\"* / *\"the curator does not rewrite a full prompt blob.\"* / *\"STOP improved mean downstream performance across iterations with GPT-4 but degraded with weaker models like GPT-3.5 and Mixtral.\"* / *\"the DGM-discovered agents are comparable to or outperform handcrafted agents on SWE-bench Verified (20% to 50%)\"* ([Harness Engineering for Self-Improvement — Lilian Weng](https://lilianweng.github.io/posts/2026-07-04-harness))"},{"i":"580","t":"MySQL TIME × 언어 타입 폭 불일치","k":["mysql","java","mybatis","typeorm","type-design","error-handling"],"c":"2026-08-31","m":"2026-09-10","b":"**언제** — MySQL `TIME` 컬럼을 언어 타입으로 역직렬화하다 예외가 났거나, 같은 컬럼을 두 스택이 읽고 있을 때. 장애 중이면 R1 부터, 예방이면 R8 부터.\n\n레시피 단위로 독립적으로 쓸 수 있게 정리. 각 레시피는 `언제 → 한다 → 함정` 순.\n\n워크드 예제: `operation_hours`에 `to = 24:00:00` 행 하나가 있어 운영시간 목록 API가 통째로 500. 프론트는 그 실패를 \"선택 가능한 날짜가 하나도 없는 정상 달력\"으로 렌더.\n\n---\n\n## R1. 목록 API가 통째로 500인데 원인 행을 못 찾을 때\n\n**언제** — 단건은 되는데 목록만 실패. 서비스 로직이 아니라 역직렬화 단계에서 터진 정황.\n\n**한다**\n\n스택트레이스 최하단을 본다. `DateTimeException` / `IllegalArgumentException`이 `ValueFactory`, `TypeHandler`, `ResultSetImpl` 프레임에서 나오면 매핑 실패다.\n\n```\nDateTimeException: Invalid value for HourOfDay (valid values 0 - 23): 24\n  at java.time.LocalTime.of\n  at com.mysql.cj.result.LocalTimeValueFactory...\n```\n\n메시지의 숫자(`24`)와 필드명(`HourOfDay`)이 곧 원인 행의 값이다. 이걸로 R2의 감사 쿼리 조건을 만든다.\n\n**함정** — 앱 로그에 `500 Internal Server Error`만 남고 원인 예외가 삼켜지는 경우가 흔하다. 글로벌 예외 핸들러가 cause 체인을 안 찍으면 여기서 막힌다. 그때는 R2를 먼저 돌려서 역으로 후보를 좁힌다.\n\n---\n\n## R2. 컬럼 도메인 밖 값 감사\n\n**언제** — 특정 행이 의심되지만 그 행만의 예외인지, 이미 퍼진 패턴인지 모를 때. **핫픽스보다 먼저** 돌린다.\n\n**한다**\n\n```sql\nSELECT owner_id, slot_type, `from`, `to`\nFROM operation_hours\nWHERE TIME_TO_SEC(`from`) >= 86400   -- 24시 이상\n   OR TIME_TO_SEC(`to`)   >= 86400\n   OR TIME_TO_SEC(`from`) <  0       -- MySQL TIME은 음수 허용\n   OR `from` > `to`;                 -- 익일 걸침 (별개 문제)\n```\n\n`86400`은 `24:00:00`의 초 환산. `TIME_TO_SEC`을 쓰는 이유는 문자열 비교로 `'24:00:00' > '23:59:59'`를 판정하면 `'9:00:00'` 같은 비제로패딩 값에서 틀리기 때문.\n\n**함정** — `from > to` 행이 이미 있으면 그건 \"야간 영업 걸침\"이라는 다른 도메인 요구다. 같은 핫픽스로 뭉개면 안 된다. 건수를 따로 세서 별건으로 분리한다.\n\n---\n\n## R3. MySQL TIME의 실제 정의역 확인\n\n**언제** — 읽는 쪽 타입을 고르기 전. 그리고 \"이건 더러운 데이터다\"라고 결론 내리기 전.\n\n**한다**\n\nMySQL `TIME`은 `-838:59:59 ~ 838:59:59`. 시각(time-of-day)이 아니라 **경과 시간(duration)** 타입이다. `24:00:00`은 위반이 아니라 합법값이다.\n\n| 언어/타입 | 정의역 | TIME 전 범위 |\n|---|---|---|\n| Java `LocalTime` | `00:00 ~ 23:59:59.999999999` | ✗ |\n| Java `Duration` | 사실상 무제한 | ✓ |\n| TS `string` | 제약 없음 | ✓ (검증 없음) |\n| Python `datetime.time` | `00:00 ~ 23:59:59.999999` | ✗ |\n| Go `time.Duration` | ±292년 | ✓ |\n\nConnector/J 8은 `rs.getObject(col, Duration.class)`를 지원한다. **MySQL TIME의 정타입 매핑은 `LocalTime`이 아니라 `Duration`이다.**\n\n**함정** — `zeroDateTimeBehavior` 같은 JDBC 관용 옵션은 DATE/DATETIME 전용이라 TIME 범위 초과엔 안 걸린다. 커넥션 옵션으로 우회하려다 시간 날린다.\n\n---\n\n## R4. 24:00:00을 무엇으로 정규화할지 결정\n\n**언제** — 데이터 핫픽스 직전. 그냥 `UPDATE` 치기 전에 슬롯 계산 로직을 먼저 읽어야 한다.\n\n**한다**\n\n| 후보 | 결과 | 확인할 것 |\n|---|---|---|\n| `23:59:59` | 1초 손실. 무해한 편 | 슬롯 생성이 `to`를 exclusive로 쓰면 23:30 슬롯이 사라짐 |\n| `00:00:00` | `from > to`가 되어 범위 비교 전부 뒤집힘 | 익일 처리 분기 없으면 **조용히 영업시간 0분** |\n| `Duration`으로 승격 | 손실 없음 | 하위 계산 로직 전부 수정 |\n\n`00:00:00`은 예외가 안 나기 때문에 지금보다 나쁘다. 터지는 버그가 침묵하는 버그로 바뀌는 것.\n\n**함정** — 두 서비스의 슬롯 계산을 **양쪽 다** 확인해야 한다. 한쪽만 보고 정규화하면 영업시간 표시가 어긋난다.\n\n---\n\n## R5. TypeHandler로 역직렬화 방어\n\n**언제** — 재발을 막아야 하는데 `Duration` 전면 교체는 비용이 클 때. **재발 방지의 핵심 레시피.**\n\n**한다**\n\n`rs.getString()`으로 받아서 직접 파싱하는 핸들러를 만들고 `@MappedTypes(LocalTime.class)`로 등록한다. 손실 정규화(`24:00:00 → 23:59:59`)를 이 한 곳에 가둔다.\n\n```java\n@MappedTypes(LocalTime.class)\npublic class SafeLocalTimeTypeHandler extends BaseTypeHandler<LocalTime> {\n  @Override\n  public LocalTime getNullableResult(ResultSet rs, String col) throws SQLException {\n    String raw = rs.getString(col);\n    if (raw == null) return null;\n    Duration d = parseMysqlTime(raw);\n    return d.compareTo(Duration.ofDays(1)) >= 0\n        ? LocalTime.MAX.truncatedTo(ChronoUnit.SECONDS)  // 23:59:59\n        : LocalTime.ofSecondOfDay(d.getSeconds());\n  }\n  // ...\n}\n```\n\n**함정**\n\n- 정규화 지점이 여기 하나여야 의미가 있다. 서비스 계층에도 clamp 로직이 흩어지면 어디서 잘렸는지 추적 불가.\n- clamp가 일어날 때 WARN 로그를 남긴다. 안 그러면 이상 데이터가 조용히 계속 들어와도 아무도 모른다.\n- 음수 TIME(`-01:00:00`)도 같이 처리해야 한다. R2에서 걸린 게 없어도 컬럼이 허용하는 값이다.\n\n---\n\n## R6. 쓰기 경로 막기\n\n**언제** — R5로 읽기가 안전해진 뒤. 순서 주의 — 쓰기부터 막으면 이미 들어있는 행 때문에 여전히 500이다.\n\n**한다**\n\nMySQL 8.0.16+:\n\n```sql\nALTER TABLE operation_hours\n  ADD CONSTRAINT chk_operation_hours_range\n  CHECK (TIME_TO_SEC(`to`) <= 86340 AND TIME_TO_SEC(`from`) >= 0);\n```\n\n버전이 낮으면 TypeORM validator(`@Max` 대신 커스텀 validator, `time` 컬럼은 string이라 범위 데코레이터가 안 먹는다).\n\n**함정** — 애플리케이션 validator는 배치·어드민 직접 UPDATE·수동 SQL 경로로 전부 뚫린다. DB 제약이 가능하면 DB에서 막는다. `CHECK` 추가 전에 R2를 다시 돌려야 한다 — 위반 행이 남아 있으면 DDL이 실패한다.\n\n---\n\n## R7. 결핍 상태를 분리해서 렌더\n\n**언제** — 백엔드가 500인데 화면이 \"정상적으로 비어 보일\" 때. 백엔드 수정과 **별개 티켓**으로 잡는다.\n\n**한다**\n\n지금 구조는 세 상태가 전부 `null`로 수렴한다.\n\n```\nuseQuery 에러 처리 없음 → hours = undefined\n→ getHoursForDate(undefined, ...) → 항상 null\n→ 전 날짜 선택 불가 → 달력 통째로 비활성\n→ 슬롯 섹션은 선택된 날짜가 없으면 null → 안내 문구조차 없음\n```\n\n`isPending` / `isError` / `data.length === 0`을 각각 다른 렌더로 분기한다. 최소한 실패는 재시도 UI가 나와야 한다.\n\n**함정** — \"빈 배열\"과 \"못 가져옴\"을 같은 empty state로 묶는 게 가장 흔한 실수다. 전자는 정상, 후자는 장애다. 사용자에게 주는 행동 지시가 다르다(\"휴무입니다\" vs \"다시 시도\").\n\n---\n\n## R8. 다국어 스택 타입 폭 감사\n\n**언제** — 같은 DB를 두 개 이상의 언어/ORM이 읽을 때. 장애 나기 전에 한 번.\n\n**한다**\n\n공유 테이블의 컬럼마다 각 스택의 매핑 타입을 적고 **정의역 폭을 비교**한다. 넓은 쪽이 쓸 수 있는 값이 좁은 쪽에서 예외가 되는 조합을 찾는다.\n\n```\n컬럼:   TIME              (-838:59:59 ~ 838:59:59)\n쓰는 쪽: string            (제약 없음)          ← 넓음\n읽는 쪽: LocalTime         (00:00 ~ 23:59:59)   ← 좁음\n```\n\n이 방향(넓은 쓰기 / 좁은 읽기)이면 시한폭탄이다. 반대 방향이면 안전하다.\n\n같은 패턴이 나오는 다른 조합:\n\n| 컬럼 타입 | 넓은 쪽 | 좁은 쪽 |\n|---|---|---|\n| `TIME` | string, Duration | LocalTime, `datetime.time` |\n| `DECIMAL(20,0)` | BigDecimal, string | JS `number` (2^53) |\n| `BIGINT` | int64 | JS `number` |\n| `VARCHAR` (utf8mb4) | string | 이모지 미대응 정규식 검증 |\n| `DATE` (`0000-00-00`) | string | `LocalDate` |\n\n**함정** — 스키마 정의 파일 두 개를 나란히 놓고 비교하는 걸로는 안 잡힌다. 양쪽 다 `time`이라고 써 있기 때문. **DB 컬럼 도메인을 기준선으로 놓고** 각 언어 타입을 그 위에 겹쳐야 폭 차이가 보인다.\n\n---\n\n## 장기: 스키마로 문제를 없애기\n\n`TIME` 대신 자정 기준 분 단위 `SMALLINT`(0~1440):\n\n- `24:00 = 1440`이 자연스럽게 표현됨\n- 양 언어 모두 `number` / `int`로 읽어서 타입 폭이 동일\n- 범위 제약이 `CHECK (col BETWEEN 0 AND 1440)`로 단순\n- 익일 걸침은 `> 1440`으로 명시 표현 가능\n\n마이그레이션 비용이 크므로 별건 티켓. R1~R7이 끝난 뒤 검토.\n\n---\n\n## 실행 순서\n\n```\nR2 (감사)  →  R4 (정규화 결정)  →  데이터 핫픽스\n                                    ↓\nR5 (TypeHandler)  →  R6 (쓰기 제약)        [백엔드 티켓]\nR7 (프론트 에러 표면화)                     [지금 바로 가능]\nR8 (타입 폭 감사)  →  스키마 재설계 검토    [예방]\n```\n\nR7만 프론트에서 독립적으로 진행 가능. 나머지는 백엔드에 넘겨야 한다.\n\n## 미해결\n\n- `24:00:00`이 어느 경로로 들어왔나. 어드민 UI면 R6의 validator로 충분하고, 배치면 DB 제약이 필수다.\n- 휴무 행의 `from`/`to`를 애초에 읽어야 하는가. 안 읽어도 되면 매핑 범위 조정이 R5보다 싼 수정이다."},{"i":"579","t":"에이전트 역할 분리 — 입력을 좁혀 성공 기준을 가른다","k":["claude-code","subagent","code-review","llm"],"c":"2026-09-01","m":"2026-09-10","b":"**64개 병렬은 결과지 원인이 아니다.** 일을 성립시킨 건 셋이다 — 컨텍스트 비대칭, 기계적으로 생성되는 큐, **언어를 바꿔도 통하는 합격 판정 기준**(테스트 오라클).\n\n**언제**: 실패를 기계적으로 열거할 수 있고, 그걸 반복해서 고쳐야 할 때.\n\n> [!IMPORTANT]\n> **그 판정 기준이 없으면 여기서 멈춘다.** [그 포팅](https://bun.com/blog/bun-in-rust)에는 4,174개 파일 6만여 개 `expect()` 짜리 TypeScript 테스트 스위트가 있었고 **스킵·삭제 0개**였다. 언어를 바꿔도 \"테스트 통과\"가 진짜 신호였다는 뜻이다. 그게 없으면 리뷰어를 둘 붙여도 **검증되지 않은 코드를 빠르게 생산할 뿐**이다. 순서를 뒤집는다 — 실패를 열거 가능한 상태(타입 에러 목록·스냅샷·시각 회귀)를 **먼저** 만들고 그다음에 에이전트를 붙인다.\n\n**절차** — 한꺼번에 세우지 않는다. 리뷰어 하나부터.\n\n1. `adversarial-reviewer` 하나만 만들어 `memory: project`로 몇 주 돌린다.\n\n   ```markdown\n   ---\n   name: adversarial-reviewer\n   description: diff 하나를 받아 틀린 이유만 찾는다. 수정하지 않는다.\n   tools: Read, Grep, Glob\n   memory: project\n   ---\n   이 diff는 틀렸다. 어디가 왜 틀렸는지 찾는 것이 유일한 임무다.\n   \"괜찮아 보인다\"는 결론은 허용되지 않는다. 최소 3개의 실패 시나리오를 제시하라.\n   구현자의 근거를 요청하지 마라. 주어진 diff와 원본 파일만 본다.\n\n   거절 규칙:\n   - 한 문단짜리 주석으로 우회를 정당화해야 한다면 코드가 틀린 것이다.\n   - 스텁·TODO·조건부 비활성화로 컴파일을 통과시킨 것은 미완성이다.\n   - 테스트를 수정해서 통과시킨 변경은 무조건 거절이다.\n\n   반복해서 나오는 패턴은 메모리에 기록하라.\n   ---\n   ```\n\n2. 쌓인 지적 패턴이 **거절 규칙 목록**이 된다. 그 목록이 생긴 뒤에야 나머지를 붙이는 게 의미가 있다.\n3. `porter`(구현, `isolation: worktree`)와 `fixer`(지적만 반영, 새 기능 금지)를 붙인다. 큐를 도는 건 세션 밖이다 — 실패 목록을 파일로 만들고 `claude -p`를 반복한다.\n\n**확인**: 리뷰어가 **`tsc`·`eslint`가 통과시키는 지점**을 잡고 있는가. 그게 이 역할의 존재 가치 전부다 — effect 의존성의 의미 변화, 이벤트 핸들러의 eager/lazy, `??`와 `||`의 단축평가 차이, 리렌더 타이밍, 정리 함수 누락. 린터가 잡을 걸 잡고 있으면 프롬프트가 헐거운 것이다.\n\n**함정**\n\n- **컨텍스트 비대칭은 저절로 유지되지 않는다.** 위임 프롬프트를 부모가 쓰기 때문에, 그냥 두면 구현 근거를 요약해서 리뷰어에게 넘겨버린다. `.work/<id>.diff`를 경유시키고 **\"리뷰어에게는 diff 경로와 원본 경로만 전달한다\"를 CLAUDE.md에 규약으로** 박는다.\n- **역할 분리를 프롬프트로 하면 무너진다.** 리뷰어 `tools`에 `Edit`/`Write`가 없는 것이 그 강제다 — 권한으로 막는다.\n- **같은 모델 둘은 실패 상관관계가 높다.** 리뷰어를 둘 둘 거면 하나는 `model`을 다르게 준다.\n- **`-p`(비대화형)에서는 fork mode가 꺼져 subagent가 포그라운드로 도는 경우가 있다.** 병렬은 셸 레벨에서 잡는 편이 예측 가능하다.\n- 병목은 모델이 아니라 **디스크 IOPS**였다 — *\"One slow `grep` command was all it took to freeze disk reads & writes for minutes.\"*\n\n**왜 되는가** — 같은 모델인데 **입력이 다르면 무엇을 성공으로 보는지가 갈린다.** 구현자는 원본과 자기 추론을 갖고 **머지되기를 원하고**, 리뷰어는 diff만 받고 \"이 코드는 틀렸다고 가정하라\"는 지시를 받아 **버그를 찾기를 원한다.** 그래서 입력 자체를 잘라야 한다.\n\n실패가 나오면 **코드가 아니라 프롬프트를 고친다.** 스텁으로 때우기·변명 주석 붙이기는 *\"한 문단짜리 주석이 필요하면 코드가 틀린 것\"* 이라는 거절 규칙 하나로 사라졌다."},{"i":"578","t":"useMemo 캐시 적중 진단 — 매 렌더 새로 만들어지는 deps","k":["react","usememo"],"c":"2026-06-23","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n`useMemo`가 실제로 재사용되고 있는지 계산 횟수로 확인한다.\n\n**언제**: 무거운 계산을 `useMemo`로 감쌌는데 렌더 시간이 줄지 않을 때.\n\n**절차**\n\n1. 계산 함수 안에 카운터를 넣는다.\n\n   ```jsx\n   const rows = useMemo(() => {\n     console.count('calc')\n     return items.filter(fn)\n   }, [items])\n   ```\n\n2. 리렌더를 몇 번 일으키고 `calc`가 렌더와 **같이** 늘어나는지 본다.\n\n3. 같이 늘어나면 `deps`를 의심한다. React는 재료 **안을 들여다보지 않고** `Object.is`로 *같은 물건인지*만 보므로,[^react-usememo] 매 렌더 새로 만들어지는 값이 `deps`에 있으면 **한 번도 재사용되지 않는다.**\n\n   ```jsx\n   <List items={data.map(transform)} />\n   ```\n\n4. 넘기는 쪽에서 참조를 붙잡아 둔다.\n\n   ```jsx\n   const items = useMemo(() => data.map(transform), [data])\n   ```\n\n**확인**: `calc`가 렌더 횟수와 따로 논다 — `data`가 바뀔 때만 늘어난다. 개발 모드에서는 React가 계산 함수를 **일부러 두 번 부르므로** 렌더당 2씩 뛰는 게 정상이다. 보는 건 절대값이 아니라 렌더를 따라 늘어나는지다.\n\n**함정**\n\n- **재사용을 못 하는 건 느린 것이지 틀린 것이 아니다.** 같은 재료로 늘 같은 답을 내는 계산이라면 답은 똑같다. 아래 둘만 답을 바꾼다.\n- **`deps`에 안 적어둔 값을 계산 안에서 쓰면**(stale closure) 그 값이 바뀌어도 React는 \"재료 그대로\"로 보고 **옛날 답을 내놓는다.** 게다가 무관한 재료가 바뀌는 순간 **우연히 맞아졌다가 다시 틀린다** — 그래서 원인을 찾기 어렵다.\n- **계산이 매번 다른 답을 내면**(랜덤 같은 것) React가 특별한 이유로 기억해둔 걸 버리는 순간 답이 바뀐다 → [574](/memo/574)\n\n<AutoIframe\n  src=\"/iframe/useMemo_cache_behavior.html\"\n  title=\"items 참조가 안정할 때와 매 렌더 새로 만들어질 때의 재계산 횟수 비교\"\n/>\n\n[^react-usememo]: *\"React will compare each dependency with its previous value using the `Object.is` comparison.\"* / *\"React will not throw away the cached value unless there is a specific reason to do that … This should be fine if you rely on `useMemo` solely as a performance optimization.\"* / *\"In Strict Mode, React will call your calculation function twice in order to help you find accidental impurities.\"* ([useMemo — React](https://react.dev/reference/react/useMemo))"},{"i":"576","t":"dayjs 날짜 표기 카탈로그","k":["typescript","dayjs","date-format","type-safety"],"c":"2026-06-28","m":"2026-09-10","b":"날짜 표기 통일에 **매핑 객체가 필요 없다** — dayjs는 포맷 문자열이 곧 키이자 값이다.[^dayjs-format]\ni18n은 이름표(`인사`)와 내용(`Hello`)이 달라서 짝지어주는 객체가 필수였다. 그 습관이 여기까지 따라온 것이다.\n\n**언제** — 날짜 표기를 받는 인자를 타입으로 닫을 때. 표기 이름과 포맷 문자열을 짝지을 매핑 객체부터 떠오르면 그게 신호다.\n\n# Schema\n\n| 이름 | 형태 | 역할 |\n|---|---|---|\n| `DATE_FORMATS` | `readonly string[]` (`as const`) | 허용 표기의 단일 출처 |\n| `DateFormat` | `(typeof DATE_FORMATS)[number]` | 배열에서 파생된 닫힌 union |\n| `formatDate` | `(date, format: DateFormat) => string` | 유일한 진입점 |\n\n# Examples\n\n```ts\nimport dayjs from 'dayjs'\n\nexport const DATE_FORMATS = [\n  'YYYY-MM-DD',\n  'YYYY-MM-DDTHH:mm:ss',\n  'HH:mm',\n  'YYYY년 M월',\n  'YY년 M월 D일',\n  'YYYY.MM.DD',\n  'YY.MM.DD',\n  'YYYY.MM.DD(ddd)',\n  'YYYY.MM.DD HH:mm',\n] as const\n\nexport type DateFormat = (typeof DATE_FORMATS)[number]\n\nexport const formatDate = (\n  date: dayjs.ConfigType,\n  format: DateFormat,\n): string => dayjs(date).format(format)\n```\n\n# 표기 추가하기\n\n**언제**: 카탈로그에 없는 표기가 필요할 때.\n\n**절차**\n\n1. **여러 곳에서 통일이 필요한 표기인가.** 일회성(`'M.D'` 등)이면 여기서 멈추고 raw\n   `dayjs().format()`으로 인라인 유지한다 — 이 기준이 없으면 배열이 잡동사니 enum이 된다.\n2. `DATE_FORMATS`에 추가한다. `DateFormat`은 배열에서 파생되므로 따로 손댈 곳이 없다.\n3. `ddd`가 들어 있으면 진입점에 `dayjs.locale('ko')`가 있는지 확인한다.[^dayjs-i18n]\n\n**확인**: 카탈로그에 있는 표기를 `formatDate`를 우회해 raw로 다시 쓴 곳이 없는가.\n**타입은 이걸 못 막는다** — [no-raw-date-format](/rules/no-raw-date-format.md)\n(`no-restricted-syntax`)이 필요한데 아직 작성하지 않았다. 그 규칙이 없으면 카탈로그의\n단일 출처 지위는 관행일 뿐이다.\n\n**함정**: `dayjs.locale('ko')` 없이 `ddd`를 쓰면 `Mon`·`Tue`처럼 영어로 **조용히** 샌다 —\n예외도 타입 오류도 없다. `YYYY.MM.DD(ddd)`는 그래서 다른 표기와 달리 런타임 전제를 가진\n항목이다.\n\n# 기각: 빌드타임 hoist / tagged-template 캐시\n\n포맷 문자열을 빌드 때 미리 계산해 상수로 끌어올리거나(hoist) 결과를 캐시해두는\n방법을 검토했다. 고유 패턴이 수십 개 규모라 중복 제거로도 컴파일 최적화로도 얻을 게\n없다. 닫힌 타입 + 함수 하나로 충분하다. 패턴 수가 수백 단위로 늘면 재검토한다.\n\n[^dayjs-format]: dayjs — Format\n[^dayjs-i18n]: dayjs — Loading locale"},{"i":"575","k":["react","suspense","declarative"],"c":"2026-06-23","m":"2026-09-01","b":"Suspense는 **비동기를 동기처럼 보이게** 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다. 그래서 상태를 조율하는 대신 **입력을 미루고 → 질의하고 → 그리는** 순서로 끝난다.\n\n**언제**: 비동기 데이터를 그리는데 로딩·에러가 늘면서 화면이 튈 때. 또는 새로 만들면서 어느 쪽으로 갈지 고를 때.\n\n**절차**\n\n1. 검색어·결과·로딩 중인지·에러인지를 **각각** 들고 있는지 본다. 넷이 따로 켜지고 꺼지니 **실제로는 있을 수 없는 조합까지 코드가 표현할 수 있다** — \"로딩 중이면서 동시에 에러\" 같은 것. 그 조합이 한 프레임이라도 그려지면 화면이 튄다.\n2. 분기를 `if/else`에서 **트리로 옮긴다** — 로딩은 `<Suspense>`, 에러는 `<ErrorBoundary>`, 성공은 본문.\n3. 본문에 loading·error를 인자로 넘기지 않는다. 안 넘겨도 **가장 가까운 경계가 대신 잡기** 때문이다 — `render`는 결과 하나만 받는다.\n\n**확인**: 있을 수 없는 조합을 적을 수 있는가. 적을 수 **없으면** 된 것이다 — 상태가 로딩이거나, 에러거나, 성공이거나 셋 중 하나가 된다.\n\n**함정**: 스피너가 너무 빨리 뜨고 사라지는 건 이 축으로 안 고쳐진다 — 다른 축이다 → [573](/memo/573)\n\n---\n\n곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler와 같다.[^algebraic-effects]\n\n[^algebraic-effects]: `try/catch`와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 **처리하고 끝나지 않는다**는 것이다. handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다. ([Algebraic Effects for the Rest of Us](https://overreacted.io/algebraic-effects-for-the-rest-of-us/))\n\nstale-while-revalidate(두 시계·isStale) 부분은 [570](/memo/570)으로."},{"i":"574","t":"useMemo 와 useState 의 갈림길 — 틀린 동작인가 느린 동작인가","k":[],"c":"2026-06-23","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n테마에서 뽑은 강조색처럼 **한 번 정해지면 유지돼야 하는 값**은 `useMemo`에 두면 안 된다.\n\n**언제**: 갈림길은 하나다 — \"이 값이 재계산되면 _틀린_ 동작인가, 아니면 그냥 _느린_ 동작인가.\"\n\n- **틀림** → `useState`/ref. 아래가 그 경우다\n- **느림** → `useMemo`. 재사용이 아예 안 되는 쪽(참조가 매 렌더 새로워지는 경우)도 속도 문제일 뿐이다 → [578](/memo/578)\n\n**왜 `useMemo`가 아닌가**: `useMemo`가 보장하는 건 \"값이 맞다\"이지 **\"같은 값이 유지된다\"가 아니다.** React는 특별한 이유가 있으면 캐시를 버린다 — 개발 중 파일을 고칠 때, 초기 마운트 중 컴포넌트가 suspend할 때, 앞으로 나올 기능(가상 리스트에서 화면 밖으로 스크롤된 항목 같은 것). 계산이 순수하면 버려져도 같은 답이 나오니 상관없지만, 랜덤처럼 **매번 다른 답을 내는 계산**이면 버려지는 순간 색이 바뀐다.\n\n<AutoIframe\n  src=\"/iframe/usememo_cache_discard.html\"\n  title=\"캐시 폐기 이벤트를 던지면 useMemo 쪽만 색이 다시 뽑혀 튀고, useState 쪽은 그대로다\"\n/>\n\n**절차**\n\n```tsx\nconst [colors, setColors] = useState(() => generateAccentColors(baseTheme))\nconst [prevTheme, setPrevTheme] = useState(baseTheme)\n\nif (baseTheme !== prevTheme) {\n  setPrevTheme(baseTheme)\n  setColors(generateAccentColors(baseTheme))\n}\n```\n\n1. **`useState(() => ...)` — 괄호를 붙이지 않는다.** 함수 자체를 넘기면 React가 처음 한 번만 부른다.[^react-usestate]\n2. **테마가 바뀌면 렌더 중에 갈아끼운다.** `if (이전 값 !== 지금 값)` 안에서 이전 값을 갱신하고 새 값을 만든다. React는 렌더 중 `set`을 보면 `return` 직후 **화면에 그리기 전에** 그 컴포넌트만 다시 렌더한다(자식은 두 번 안 그린다). 그래서 깜빡임이 없다.\n\n**확인**: 개발 중 파일을 고쳐 저장한다 — 캐시 폐기 이벤트 중 하나다. 색이 그대로면 된 것이고, 바뀌면 아직 `useMemo`에 있다.\n\n**함정**\n\n- `useState(generateAccentColors(baseTheme))`처럼 괄호를 붙이면 함수가 **매 렌더마다 돌고** 결과는 첫 렌더 말고는 전부 버려진다.\n- 2번에서 **조건이나 `setPrevTheme` 중 하나라도 빠지면 무한 루프로 터진다.**[^react-usestate]\n\n[^react-usestate]: 게으른 초기화 — *\"Although the result of `createInitialTodos()` is only used for the initial render, you're still calling this function on every render … If you pass a function to `useState`, React will only call it during initialization.\"* / 렌더 중 `set` — *\"React will re-render that component immediately after your component exits with a `return` statement, and before rendering the children.\"* / 필수 조건 — *\"it must be inside a condition like `prevCount !== count`, and there must be a call like `setPrevCount(count)` inside of the condition. Otherwise, your component would re-render in a loop until it crashes.\"* ([useState — React](https://react.dev/reference/react/useState))\n\n## 참고\n\n- [Building Bulletproof React Components — Shu Ding](https://shud.in/thoughts/build-bulletproof-react-components)\n- [useMemo — React (caveats)](https://react.dev/reference/react/useMemo)"},{"i":"573","t":"로딩 스피너 깜빡임의 두 끝 — 앞쪽은 CSS, 뒤쪽은 JS","k":["react","css","loading-ux"],"c":"2026-06-23","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n로딩 스피너의 깜빡임은 **양 끝에서 따로 생기는데** CSS는 한쪽만 잡는다.\n\n**언제**: 스피너가 한 프레임 번쩍하고 사라지거나, 떴다가 하드컷으로 끊길 때. 앞쪽인지 뒤쪽인지부터 판별한다.\n\n| 증상 | 어느 쪽 | 처방 |\n|---|---|---|\n| 응답이 너무 빨라 스피너가 _뜨기 전이나 직후_ 사라진다 | **앞쪽**(leading) | CSS — `animation-delay`/`transition-delay` |\n| 스피너가 떴는데 _너무 짧게_ 보이고 하드컷된다 | **뒤쪽**(trailing) | JS — `useMinimumLoading` |\n\n**절차**\n\n1. 앞쪽이면 **지연을 준다.** 응답이 지연보다 빨리 오면 **한 프레임도 안 보인다** — 스피너를 통째로 건너뛴다.\n2. 뒤쪽이면 **unmount를 `max(응답 시각, 최소 노출 시간)`으로 민다.** 한 번 뜬 스피너가 최소 노출 시간만큼은 보이게 된다.\n\n**확인**: 응답을 지연보다 빠르게 만들어 본다 — 스피너가 한 프레임도 안 뜨면 앞쪽이 잡힌 것이다. 응답을 최소 노출 시간보다 빠르게 만들어 본다 — 스피너가 그 시간만큼 유지되면 뒤쪽이 잡힌 것이다.\n\n<AutoIframe\n  src=\"/iframe/loading_flicker_explorer.html\"\n  title=\"지연·지속 시간·응답 시각과 최소 노출 시간을 조절하며 앞쪽 억제와 뒤쪽 깜빡임이 어떻게 달라지는지 관찰\"\n/>\n\n**왜 갈리나**: **CSS는 시작만 통제한다.** 스피너가 보이는 구간은 `[뜬 시각, unmount 시각]`인데, 지연은 앞쪽을 미루고 지속 시간은 애니메이션 길이일 뿐 요소가 DOM에 남는 시간이 아니다. 뒤쪽 끝은 응답이 언제 오느냐가 정하고 **그건 CSS가 모르는 값**이다. 그래서 \"최소 몇 ms는 보인다\"를 CSS만으로는 보장할 수 없다.\n\n**함정**\n\n- 억제력은 **지연에서만** 나온다 — `@starting-style` 단독(지연 0)으론 앞쪽을 못 막는다.\n- 이미 떴다는 사실은 못 되돌린다. 뒤쪽은 CSS가 가진 두 값(지연·지속 시간)으로 잡을 수 없고 **사라지는 시각 자체를 미뤄야** 한다 — CSS 밖, JS 타이밍이다.\n\n곁가지 — `@starting-style`은 **같은 뿌리의 반대편**이다. transition은 두 상태 사이를 보간하는데, 진입에는 옮겨갈 이전 상태가 없고[^starting-style] 퇴장에는 요소 자체가 이미 사라진다. `@starting-style`은 앞쪽의 출발값을 대신 정해주고(진입 fade-in), `useMinimumLoading`은 뒤쪽의 unmount를 미룬다. 뿌리는 같아도 **손대는 지점이 달라 별개 결정**이다.\n\n[^starting-style]: CSS transition은 요소의 첫 스타일 적용이나 `display: none` → 표시 전환에서는 기본적으로 발동하지 않는다. `@starting-style`이 \"무엇에서 출발할지\"를 정의해 그걸 가능하게 한다. ([@starting-style — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style))"},{"i":"572","t":"요청 취소의 경계 — 전송이 끝났으면 서버는 받는다","k":["http","abort","cancellation"],"c":"2026-06-23","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n**HTTP 요청 취소는 클라이언트에서만 일어난다.**[^fetch-abort] 이미 보낸 바이트를 회수하지도, 서버가 하고 있는 일을 멈추지도 못한다.\n\n갈리는 건 딱 하나 — **취소한 순간에 요청 전송이 이미 끝나 있었나.** 끝나 있었다면 서버는 그걸 받았고, 받았으면 처리한다. 취소는 그 처리를 못 막는다.\n\n- **reject된 promise와 \"서버가 다 처리했다\"는 동시에 참일 수 있다.** 전송이 끝난 뒤부터 응답이 오기 전까지가 그 구간이다. 취소했다고 서버에서 아무 일도 없었던 게 아니다.\n- **절약되는 건 클라이언트 쪽뿐** — 응답을 받아 처리하는 비용. 도달했다면 서버 작업은 그대로 수행된다.\n- **네트워크 속도가 결과를 뒤집는다.** `abort()` 를 부르는 시각이 같아도, 네트워크가 느려 전송이 그 시각을 넘겨 끝나면 \"서버에 도달함\"이 참에서 거짓으로 바뀐다.\n\n요청은 네 구간을 지난다. 취소가 어느 구간에서 걸리느냐로 결과가 달라진다.\n\n| 구간 | 어디 | 여기서 취소하면 |\n|---|---|---|\n| `send` | 클라이언트 | 요청이 다 전송되지 않는다 — 서버는 아무것도 못 받는다 |\n| `in-flight` | 네트워크 | 이미 다 보냈다. 서버는 받는다 |\n| `processing` | 서버 | 서버가 처리 중이고 멈출 방법이 없다 |\n| `resp` | 네트워크 → 클라이언트 | 응답이 오는 중이다. 클라이언트가 안 읽을 뿐 |\n\n구간 사이 경계는 셋인데 **결과를 가르는 건 첫 번째 하나**다 — `send`와 `in-flight` 사이, 전송이 끝나는 순간. 그 뒤로는 어디서 취소하든 서버 입장에선 똑같다.\n\n<AutoIframe\n  src=\"/iframe/request_abort_race_sim.html\"\n  title=\"취소한 시점이 전송 완료 전이냐 후냐에 따라 promise reject·서버 도달·서버 처리가 어떻게 갈리는지\"\n/>\n\n곁가지 — 라이브러리가 내부에서 만들고 돌려주지 않는 `AbortController` 는 밖에서 부를 수 없다. 노출된 `signal` 을 받는 요청만 취소가 닿는다.\n\n[^fetch-abort]: Fetch 표준의 abort 알고리즘은 controller 상태를 `aborted`로 바꾸고 이유를 기록할 뿐, 서버로 무엇을 보내라고 정하지 않는다. 연결을 닫거나 응답 읽기를 멈추는 건 구현 재량이다. ([Fetch Standard — abort a fetch](https://fetch.spec.whatwg.org/#abort-fetch))"},{"i":"570","t":"useDeferredValue 의 보장 범위 — 값은 보장, 반영 시점은 미보장","k":["react","usedeferredvalue"],"c":"2026-06-23","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n검색어를 치면 입력창은 바로 바뀌어야 하는데, 아래 무거운 목록까지 매번 다시 그리면 입력이 밀린다. `useDeferredValue(value)`는 같은 값을 **한 박자 늦게 따라오는 짝**으로 하나 더 만들어준다.\n\n**언제**: 같은 값을 쓰는 무거운 목록이 입력마다 다시 그려져 입력이 밀릴 때.\n\n**절차**\n\n1. `useDeferredValue(value)` 로 늦은 짝을 만든다.\n2. **입력창은 빠른 쪽, 무거운 목록은 늦은 쪽**을 쓴다.\n3. 둘이 벌어진 구간(`isStale`)을 알린다 — 보통 화면을 흐리게 해서 아직 이전 값이라는 것을 알린다.\n\n**확인**: 입력창이 즉시 반응하고, 손을 멈췄을 때 목록이 따라잡는가.\n\n## 늦은 쪽이 보여주는 값을 믿어도 되나\n\n**어떻게 동작하는지부터.** 글자를 하나 치면 React가 화면을 **두 번** 그린다. 첫 번째는 빠른 쪽만 새 값으로(`query`는 이미 `\"ab\"`인데 `deferredQuery`는 아직 `\"a\"`), 두 번째는 늦은 쪽까지 새 값으로. 두 번째 그리기는 **첫 번째가 끝나자마자** 시작한다 — 기다리는 시간 같은 건 없다. 그리는 도중에 글자를 더 치면 그때까지 그린 걸 버리고 **처음부터 다시** 그린다. 다 그려서 화면에 올리기 전까지는 아무것도 안 보이고 `useEffect`도 실행되지 않는다.[^react-deferred]\n\n- **없는 값을 만들지 않는다** — 늦은 쪽이 보여주는 건 언제나 _내가 실제로 쳤던 것 중 하나_다. 지어내지 않는다.\n- **결국 따라잡는다** — 손을 멈추면 따라잡는다. 계속 치는 동안은 다시 그리기가 반복해서 취소돼 **영영 못 따라잡을 수도 있는데, 고장이 아니라 의도된 동작이다.**\n\n둘 다 위 동작에서 그대로 나온다 — **돌려줄 값을 새로 만드는 일이 없으니** 지어낼 수가 없고, **다시 그리기가 늘 최신 값에서 출발하니** 손을 멈추면 그 그리기가 끝까지 간다. **믿어도 된다. 다만 언제 따라잡을지는 모른다.**\n\n**목록을 인라인으로 만들어도 늦은 쪽은 영향받지 않는다.** `items={data.map(...)}`처럼 써서 참조가 매번 새로 생겨도 상관없다 — 그 목록이 늦은 쪽 계산에 아예 안 들어가기 때문이다. 같은 상황에서 망가지는 건 useMemo 캐시 쪽이다 → [578](/memo/578)\n\n<AutoIframe\n  src=\"/iframe/two_clocks_deferred_vs_immediate.html\"\n  title=\"입력을 곧바로 따라가는 값과 뒤처지는 값의 두 시간축, 둘이 어긋난 구간이 isStale\"\n/>\n\n[^react-deferred]: 두 단계 — *\"First, React re-renders with the new `query` (`\"ab\"`) but with the old `deferredQuery` (still `\"a\"`) … In the background, React tries to re-render with **both** `query` and `deferredQuery` updated.\"* / 타이밍 — *\"There is no fixed delay caused by `useDeferredValue` itself. As soon as React finishes the original re-render, React will immediately start working on the background re-render.\"* / 중단 — *\"The background re-render is interruptible: if there's another update to the value, React will restart the background re-render from scratch.\"* / Effect — *\"does not fire Effects until it's committed to the screen.\"* ([useDeferredValue — React](https://react.dev/reference/react/useDeferredValue))"},{"i":"569","t":"location.href 는 암묵적 await 가 아니다 — setter 는 동기, navigation 은 태스크","k":["javascript","event-loop","navigation"],"c":"2026-06-23","m":"2026-09-10","b":"`location.href`에 값을 넣으면 그 줄에서 바로 페이지가 이동할 것 같다. 마치 **암묵적 `await`**처럼 거기서 멈춘다고 생각하기 쉽다. 그럼 뒤에 오는 코드는 실행되지 않을까.\n\n```js\nlocation.href = 'https://google.com'\nconsole.log('실행됨 1')\nlocation.href = 'https://google2.com'\nconsole.log('실행됨 2')\n```\n\n**둘 다 출력된다. 그리고 이동은 `google2.com`으로 간다.**\n\n`await`였다면 첫 줄에서 멈춰 `실행됨 1`도 출력되지 않고 `google.com`으로 갔어야 한다. 두 결과가 모두 어긋나니 가설은 틀렸다.\n\n실제로는 이렇다. setter는 **동기적으로 정상 실행되고 흐름을 끊지 않는다.** 문서를 언로드하고 네트워크 요청을 보내는 실제 작업은 태스크로 넘어가 지금 실행 중인 코드가 끝난 뒤에 시작된다. 그리고 두 번째 navigation이 시작되면 **진행 중이던 첫 번째가 버려진다**[^html-spec-navigate] — 마지막 값이 이기는 건 덮어써서가 아니라 앞의 것이 취소되기 때문이다.\n\n<img\n  src=\"/svg/event_loop_nav_slot.svg\"\n  alt=\"location.href setter가 실행되는 시점과 실제 navigation이 처리되는 시점이 분리되는 이벤트 루프 타임라인\"\n/>\n\n[^html-spec-navigate]: *\"Set the ongoing navigation for navigable to navigationId. This will have the effect of **aborting other ongoing navigations** of navigable, since at certain points during navigation changes to the ongoing navigation will cause further work to be abandoned.\"* — 그리고 문서 언로드·페치는 `in parallel` 로 넘어간다. ([HTML Standard — navigate](https://html.spec.whatwg.org/multipage/browsing-the-web.html#navigate))"},{"i":"568","k":["probability","coupon-collector","math"],"c":"2026-06-23","m":"2026-08-23","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n목록 90개에서 매번 무작위로 하나를 뽑아 보여준다고 하자 (작업 중에 문구를 바꿔 띄우는 스피너 같은 것). **90개를 다 보려면 90번쯤이면 될까.**\n\n아니다. **평균 457번**이다. 5배다. 1초에 하나씩이면 7분 반.\n\n목록을 한 번 섞어서 처음부터 끝까지 도는 게 아니라 **매번 90개 전체에서 다시 뽑기** 때문이다. 그러면 이미 본 것이 계속 다시 나온다. 섞어 돌았다면 90번에 정확히 끝났을 일이다.\n\n**꼬리가 어디서 생기는지가 핵심이다.** 89개를 모았을 때, 남은 마지막 하나가 뽑힐 확률은 90분의 1이다. 그 **한 개를 만나는 데만 평균 90번** — 섞어서 돌았다면 90개 전부를 봤을 횟수다.[^wikipedia-coupon-collector]\n\n이게 쿠폰 수집가 문제(coupon collector)다. 전부 모으는 데 걸리는 평균 횟수는\n\n```\nn × (1 + 1/2 + 1/3 + … + 1/n)\n```\n\n<AutoIframe\n  src=\"/iframe/coupon_collector_explorer.html\"\n  title=\"목록 크기를 바꿔가며, 뽑기 횟수가 늘수록 서로 다른 것을 몇 개나 보게 되는지 보여주는 곡선\"\n/>\n\n[^wikipedia-coupon-collector]: 전부 모으는 기대 횟수는 `n·H_n`(`H_n`은 조화수). 그리고 `i`번째 새 쿠폰까지의 기대 대기는 `n/(n-i+1)` 이므로 **마지막 한 장은 `n/1 = n`** — 단계 중 가장 오래 걸린다. ([Coupon collector's problem — Wikipedia](https://en.wikipedia.org/wiki/Coupon_collector%27s_problem))"},{"i":"567","t":"'blocking=render 는 <body> 전에만 등록된다 — 뒤에 붙으면 조용한 no-op'","k":["performance","rendering","fouc","html"],"c":"2026-06-23","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n**파서를 멈추는 것과 paint를 멈추는 것은 다르다.** 동기 `<script>`는 파서만 세운다. 그 위에 이미 파싱된 청크가 먼저 그려질지는 브라우저 재량이라, **default 상태가 한 프레임 노출되지 않는다는 보장이 없다.** `blocking=\"render\"`가 그 보장을 명시적으로 만든다.\n\n**언제**: 새로고침하면 한 프레임 동안 다른 화면이 보일 때. 먼저 원인을 판별한다.\n\n| 증상 | 원인 | 처방 |\n|---|---|---|\n| 스타일이 붙기 전 default 상태가 한 프레임 보인다 | 스타일 적용이 첫 페인트보다 늦다 | `blocking=\"render\"` — 아래 절차 |\n| 다크 테마인데 흰 화면이 번쩍인다 | 서버가 사용자 상태를 몰라 기본값으로 렌더했다 | 첫 페인트 전 인라인 스크립트 → [594](/memo/594) R2 |\n\n뒤쪽은 이 축으로 안 고쳐진다 — 서버가 낸 HTML 자체가 이미 다른 값이라 렌더를 막아도 그 값이 그대로 보인다.\n\n**절차**\n\n1. `blocking=\"render\"`를 붙인다.\n2. **`<body>`가 열리기 전에 둔다.** 컴포넌트 마크업 안이면 이미 늦다.\n\n**확인**: 마크업에서 **위치**를 본다 — `<body>`보다 앞인가. 화면으로는 구분이 안 된다(아래).\n\n**함정**: `<body>`가 생기는 순간 등록 자체가 닫힌다. 뒤에 붙인 `blocking=\"render\"`는 실패하지 않고 **조용한 no-op**이 된다 — attribute를 모르는 브라우저에서 옛 동작으로 떨어지는 것과 같은 모양이라, **안 먹는다는 사실이 드러나지 않는다.**\n\n**왜 `<body>`인가**: 스펙은 \"head 안에 둬라\"라고 말하지 않는다. 대신 등록 창을 이렇게 닫는다.\n\n> A `Document` document allows adding render-blocking elements if document's content type is \"text/html\" and **the body element of document is null**.\n>\n> — [HTML Standard](https://html.spec.whatwg.org/multipage/dom.html#allows-adding-render-blocking-elements)\n\n<AutoIframe\n  src=\"/iframe/render_blocking_paint_simulator.html\"\n  title=\"세 전략(async·defer / 동기 script / blocking=render)에서 사용자가 보는 화면을 시간순으로 재생해 깨진 프레임 노출 여부를 비교\"\n/>"},{"i":"565","t":"데스크톱 없이 안드로이드 Chrome 디버깅 — 로컬 프록시로 Origin 검사 우회","k":["chrome-devtools","cdp","websocket","proxy"],"c":"2026-06-23","m":"2026-09-10","b":"**데스크톱 없이 안드로이드 기기 한 대로 그 기기의 Chrome을 디버깅한다.** Termux에서 파이썬 스크립트를 돌리고, DevTools는 폰 브라우저로 연다.\n\n**순서를 기억하려고 적어둔 것이다.** 각 단계의 방법이 아니라 밟는 차례가 내용이다.\n\n**길이 셋인데 하나만 해봤다.** Origin 문제를 없애는 방법이 셋이다 — Chrome을 `--remote-allow-origins=*`로 띄우거나, 프론트엔드를 `localhost`에서 self-host하거나, **로컬 프록시로 Origin 검사를 우회**하거나. 아래는 프록시 길이고 **앞의 둘은 해보지 않았다.**\n\n폰 안에는 DevTools 프론트엔드가 없다. 그래서 구글이 호스팅하는 것(`chrome-devtools-frontend.appspot.com`)을 연다 — 그 순간 프론트엔드의 origin이 `appspot.com`이 되고, Chrome은 그 Origin이 붙은 CDP WebSocket을 거부한다.[^chromium-devtools-403] **로컬 프록시(9223)를 사이에 끼워 그 거부를 우회한다.**\n\n<img\n  src=\"/svg/aiohttp_proxy_origin_laundering.svg\"\n  alt=\"프론트엔드 WS를 로컬 프록시(9223)가 종단하고, 파이썬이 Origin 없는 새 WS로 9222에 재접속해 Chrome의 cross-origin 거부를 우회하는 구조\"\n/>\n\n연결 경로:\n\n- `adb forward`가 Chrome의 `chrome_devtools_remote` unix socket을 `localhost:9222`로 연다\n- Chrome이 9222에서 `/json`으로 탭 목록을, `/devtools/page/{id}`로 페이지별 CDP WS를 준다\n- aiohttp 프록시가 9223에서 프론트엔드 WS를 받아 9222로 양방향 중계한다\n- `termux-open-url`이 폰 브라우저로 프론트엔드를 열고, `?ws=localhost:9223/{id}`가 백엔드를 가리킨다\n\n<img\n  src=\"/svg/devtools_py_full_lifecycle.svg\"\n  alt=\"adb forward로 소켓을 노출하고 프록시를 띄운 뒤 DevTools 프론트엔드를 실행하기까지의 전체 연결 수명주기\"\n/>\n\n**프록시가 하는 일은 중계가 아니라 연결 종단과 재접속이다.** 프록시가 프론트엔드 WS를 종단하고, **파이썬 프로세스가 새 WS로** 9222에 다시 접속한다. 파이썬이 여는 소켓에는 브라우저가 붙이는 Origin 헤더가 없으니 통과한다. `cors_middleware`는 프론트엔드 쪽 응답에 허용 헤더를 붙인다.\n\n기억해둘 것:\n\n- `ws://localhost`는 HTTPS 페이지에서도 mixed-content로 안 막힌다 — **localhost가 secure context로 취급**되기 때문. IP나 도메인이었으면 `wss` 강제로 깨졌을 것이다.\n- `?@f84901f7…`는 DevTools 프론트엔드 빌드 리비전 핀이다.\n\n[^chromium-devtools-403]: Origin 검사(Chrome 111~)와 **DNS rebinding을 막는 Host 헤더 검사(Chrome 66~)는 다른 방어**다. 여기 걸리는 건 앞쪽 — WS 핸드셰이크에 `--remote-allow-origins`에 없는 Origin이 붙으면 거부된다. ([Chromium issues](https://issues.chromium.org/issues/40096993))"},{"i":"564","t":"Iterator Helpers 의 지연 평가","k":["javascript","iterator","lazy-evaluation","generator"],"c":"2026-06-22","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n**iterator는 이미 가진 데이터가 아니라 아직 일어나지 않은 작업(work)이다.** 소비하기 전엔 아무 일도 안 일어나고, 필요한 만큼만 하고, 한 번 하면 끝이다.\n\n```js\nnaturals().filter(isPrime).take(10).toArray()\n// [2, 3, 5, … 29] · checked === 28 — 29에서 스스로 멈춘다\n```\n\n<AutoIframe\n  src=\"/iframe/lazy_iterator_pull_pipeline.html\"\n  title=\"lazy iterator pull pipeline — take가 채워지면 소스가 멈춘다\"\n/>\n\n**정작 가장 쓰고 싶은 async 소스엔 아직 못 쓴다.** 페이지네이션 fetch를 `fetchPages().filter(isValid).take(10)`으로 감는 모양은 AsyncIterator Helpers가 필요한데 별개 제안이고 Stage 2.7이다 — Chrome 151·Node 24에서 `AsyncIterator` 전역 자체가 없고 체인은 `TypeError`로 떨어진다. Stage 4로 shipped된 건 sync 쪽(모던 브라우저·Node 22+)뿐이다.[^smitty-stop-arrays]\n\n곁가지 — iterator로 변환: `.values()` / `.keys()` / `.entries()` 또는 generator. `slice(0,n)` → `take(n)`, `slice(n)` → `drop(n)`.\n\n[^smitty-stop-arrays]: 원문은 *\"Async iterables have their own iterator helpers, which makes them a great fit for paginated APIs and streams\"*라며 `async function* fetchPages()` 예제를 싣는데, 그 체인은 아직 어디서도 돌지 않는다. ([Matt Smith](https://allthingssmitty.com/2026/01/12/stop-turning-everything-into-arrays-and-do-less-work-instead/) · [tc39/proposal-async-iterator-helpers](https://github.com/tc39/proposal-async-iterator-helpers))"},{"i":"563","t":"React 에 싱글톤 패턴 접목하기","k":["react","usesyncexternalstore","singleton","architecture"],"c":"2026-06-21","m":"2026-09-10","b":"**싱글톤이 안티패턴이라는 말은 싱글톤이 아니라 React에 붙이던 방식을 겨눈 것이었다.**\n\n**언제**: 프레임워크를 모르는 상태 덩어리(토스트 매니저 같은)를 React에 stale 없이 연결할 때. 코어(표준 JS 클래스)와 어댑터(`useSyncExternalStore` 한 겹)를 갈라두면 Vue·Svelte는 어댑터만 갈아끼운다.\n\n**절차**\n\n1. 코어를 `EventTarget` 확장으로 만들고, **상태 변경과 이벤트 발행을 setter 하나에 묶는다.**\n\n   ```ts\n   private set toasts(value: Toast[]) {\n     this._toasts = [...value]      // 새 배열 → 참조 동등성으로 변화 감지\n     this.dispatchEvent('changed')  // 변경 시 발행 누락 불가능\n   }\n   ```\n\n2. `subscribe`/`getSnapshot` 어댑터 한 겹을 얹어 `useSyncExternalStore`로 받는다.\n\n   ```tsx\n   const subscribe = (cb: () => void) => {\n     const ac = new AbortController()\n     toastManager.addEventListener('changed', cb, { signal: ac.signal })\n     return () => ac.abort()\n   }\n   const getSnapshot = () => toastManager.toasts\n\n   const toasts = useSyncExternalStore(subscribe, getSnapshot)\n   ```\n\n3. 쓰기는 코어를 직접 부른다 — **React 밖 어디서든.**\n\n   ```tsx\n   toastManager.add({ message: '저장됨' })\n   ```\n\n**확인**: React 트리 밖(모듈 최상단·다른 라이브러리 콜백)에서 `add()`를 불러 화면이 갱신되는가. provider도 prop drilling도 없어야 한다.\n\n**함정**: `getSnapshot`은 참조가 안정적이어야 한다. 매번 새 객체를 리턴하면 **무한 루프** — getter는 내부 배열을 그대로 반환하고, 새 배열은 setter에서만 만든다.\n\n**왜 `useSyncExternalStore`인가** — 연결 방식의 진화:\n\n1. polling / 수동 refresh — stale, 데이터 변경과 무관한 리렌더\n2. `useEffect` + `addEventListener` — 낫지만 로컬 `useState`를 손으로 동기화 (React state스럽지 않음)\n3. `useSyncExternalStore` — 구독·동기화를 React가 직접 관리, stale closure 제거\n\n곁가지 — 프레임워크 의존을 코어에서 걷어내는 다른 길: 빌드 타임 signal swap([562](/memo/562)).\n\n---\n\n- 출처: [React: Singletons aren't as evil as you think — Andrew Bone](https://dev.to/link2twenty/react-singletons-arent-as-evil-as-you-think-44m8)\n- [Type-Safe CustomEvents (TypedEventTarget)](https://dev.to/link2twenty/type-safe-customevents-better-messaging-with-native-apis-2dol)"},{"i":"560","t":"Fiber 가 렌더링을 쪼갤 수 있는 이유 — 콜 스택 대신 세 포인터","k":["react","fiber","algorithm","dfs"],"c":"2026-06-21","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n<AutoIframe\n  src=\"/iframe/fiber-traversal.html\"\n  title=\"React Fiber 순회 — 중단하면 콜 스택은 버려지고 workInProgress 포인터만 남는다\"\n/>\n\n**재귀(콜 스택) 대신 `child` / `sibling` / `return` 세 포인터로 트리를 순회한다 → 스택 없이 DFS pre-order, 언제든 중단·재개 가능.** Fiber가 렌더링을 쪼갤 수 있는 이유.\n\n재귀로 짜면 진행 상태가 JS 콜 스택에 쌓이는데, `requestIdleCallback`으로 도중에 yield하면 그 스택을 버리게 된다. 재개하려면 스택을 다시 쌓아야 해서 \"어디까지 했는지\"를 외부에서 들고 있어야 한다.\n\n그 상태를 `workInProgress` 포인터 하나로 환원한다. 노드마다 걸린 세 링크를 직접 따라가므로 호출 스택이 필요 없다.\n\n```\n1. child 있으면?      → 내려간다\n2. root에 도달했으면? → 종료\n3. sibling 없으면?    → return(부모)으로 올라가며 반복\n4. sibling 있으면?    → 옆으로 간다\n```\n\n내려가는 길 = `beginWork`, 올라오는 길 = `completeWork`. 한 노드를 두 번(하강·상승) 지나는 흐름이 그대로 두 단계로 갈린다.\n\n---\n\n- 참고: [React Fiber Architecture — facebook/react#7942](https://github.com/react/react/issues/7942)"},{"i":"559","t":"이벤트 루프 한 턴 — 마이크로태스크가 setTimeout(0) 보다 항상 먼저다","k":["javascript","event-loop","microtask"],"c":"2026-06-18","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n`queueMicrotask`·`Promise.then`이 `setTimeout(0)`·동기 코드와 얽히는 걸 한 스텝씩 보는 시각화. 핵심: **마이크로태스크 큐는 매 턴 끝까지 비워진다** → then/queueMicrotask는 항상 `setTimeout(0)`보다 먼저고, 드레인 도중 추가된 중첩 마이크로태스크까지 같은 턴에 처리된다(중첩이 유한한 한 starvation 없음).\n\n한 이벤트 루프 턴 = 네 단계:\n\n1. task queue에서 가장 오래된 매크로태스크 1개 실행\n2. **microtask checkpoint — 큐가 빌 때까지 전부 드레인** (드레인 중 추가된 것 포함)\n3. 렌더링 갱신 (필요 시)\n4. 1로 복귀\n\n그래서 한 턴의 트레이스 = `[micro들] ++ [macro 하나]` — 매크로는 맨 끝, 드레인 도중 끼어들 수 없다.\n\n<AutoIframe\n  src=\"/iframe/event-loop-sim.html\"\n  title=\"queueMicrotask 이벤트 루프 시뮬레이터\"\n/>"},{"i":"555","t":"Navigation API 이탈 방지의 두 갈래 — 사전 차단과 사후 롤백","k":["navigation-api","router"],"c":"2026-06-15","m":"2026-09-10","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\nNavigation API로 라우터를 만들면 **떠나는 모든 경로가 `navigate` 이벤트 하나로 모인다** — 링크 클릭·폼 제출·프로그램 호출은 물론 `history.pushState()`까지. 라우터가 가로채야 할 것을 한 자리에서 잡는다.\n\n그런데 라우터가 흔히 제공하는 기능 하나에서 막힌다 — **이탈 방지**(저장 안 된 폼에서 \"정말 나가시겠습니까\"). `preventDefault()`가 있으니 될 것 같은데, `push`·`replace`·`reload`는 되고 **뒤로·앞으로(`traverse`)만 안 된다.**\n\n그리고 이건 누락이 아니라 **의도된 설계**다.[^chrome-navigation-api]\n\n> 사용자가 브라우저의 뒤로·앞으로 버튼을 누르는 경우엔 `preventDefault()`로 취소할 수 없다 — **사용자를 사이트에 가둘 수 있으면 안 되기 때문이다.**\n\n## 그래서 라우터 안에 경로가 둘 생긴다\n\n**언제**: Navigation API로 만든 라우터에 이탈 방지(저장 안 된 폼에서 \"정말 나가시겠습니까\")를 붙일 때.\n\n셋은 **사전 차단**, 뒤로·앞으로는 이미 움직였으니 **사후 롤백**이다. 블로커가 켜져 있을 때:\n\n```js\nonNavigate(e) {\n  if (e.navigationType !== 'traverse') return e.preventDefault()   // 아예 안 움직임\n  history.go(navigation.currentEntry.index - e.destination.index)  // 일어난 뒤 되돌림\n}\n```\n\n되돌릴 거리는 API가 준다 — `destination.index`가 목적지 커서고, 지금 커서와의 차이가 곧 이동량이다.\n\n**그런데 그 `index`는 `traverse`일 때만 값이 있다. 나머지 셋에서는 `-1`이다.**[^mdn-navigation-destination] 라우터가 억지로 나눈 게 아니라 **API 자체가 같은 선을 긋고 있다.** blocker 하나로 통일할 수 없고, 라이브러리 안에서 두 갈래를 계속 유지해야 한다.\n\n## 롤백이 정말 제자리인가\n\nhistory를 **리스트 + 커서**로 보면 그렇다. `go(n)`은 리스트를 건드리지 않고 **커서만** 옮긴다. 그러니 `go(n)` 다음 `go(-n)`은 커서를 원래 자리로 돌려놓는다 — 겉보기엔 아무 일도 없었던 것처럼 된다.\n\n**확인**: 블로커를 켠 채 다섯 가지 이동을 다 해본다. `push`·`replace`·`reload`는 **아예 안 움직이고**, 뒤로·앞으로는 움직였다 제자리로 돌아오면 된 것이다.\n\n**함정**\n\n- **롤백은 범위 안에서만 제자리다.** `0 ≤ idx + n`을 벗어나는 `go`는 아무 일도 하지 않는다 — 그러니 일어나지도 않은 이동을 되돌리면 오히려 진짜로 움직인다. 이 패턴에선 그럴 일이 없지만(이동이 없으면 `navigate` 이벤트도 안 뜨니 롤백을 부를 일이 없다), 등식이 무조건 성립하는 건 아니다.\n- **우회라는 걸 알고 쓴다.** 브라우저가 사용자를 위해 막아둔 걸 되돌리는 것이라, 정말 붙잡아야 할 때만 쓴다.\n\n<AutoIframe\n  src=\"/iframe/traverse-rollback-explorer.html\"\n  title=\"블로커를 켠 채 다섯 가지 이동을 시도해, 넷은 아예 안 움직이고 뒤로·앞으로만 움직였다 되돌아오는 것을 히스토리 리스트와 커서로 보여준다\"\n/>\n\n\n[^chrome-navigation-api]: *\"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](https://developer.chrome.com/docs/web-platform/navigation-api))\n\n[^mdn-navigation-destination]: *\"Returns the `index` value of the destination `NavigationHistoryEntry` if the `NavigateEvent.navigationType` is `traverse`, or **`-1` otherwise**.\"* ([NavigationDestination.index — MDN](https://developer.mozilla.org/en-US/docs/Web/API/NavigationDestination/index))"},{"i":"553","t":"Native Messaging — 확장이 로컬 바이너리를 실행하는 법","k":["browser-extension","native-messaging","chrome"],"c":"2026-06-08","m":"2026-08-21","b":"확장은 로컬 프로그램을 직접 못 돌린다. 샌드박스 안에 있기 때문이다. Native Messaging은 그 벽을 허무는 대신 **구멍을 하나만 뚫는다** — 미리 등록해둔 프로그램 하나와 `stdin`/`stdout`으로만 대화한다. 띄우는 것도 확장이 아니라 **Chrome이 대신** 한다.\n\n## 1. 미리 등록해둔다\n\n호스트를 OS의 정해진 자리에 manifest로 올린다 — Windows는 레지스트리 키, macOS·Linux는 지정된 디렉터리다.[^developer-chrome-docs-extensions]\n\n```json\n// ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.example.ytdlp.json\n{\n  \"name\": \"com.example.ytdlp\",\n  \"description\": \"yt-dlp bridge\",\n  \"path\": \"/usr/local/bin/ytdlp_host.py\",\n  \"type\": \"stdio\",\n  \"allowed_origins\": [\"chrome-extension://<확장 ID>/\"]\n}\n```\n\n**`allowed_origins`가 게이트다.** 여기 적힌 확장만 이 호스트를 부를 수 있고 와일드카드는 못 쓴다. 확장이 아무 프로그램이나 실행하지 못하는 이유가 이것이다. `path`는 절대경로여야 한다.\n\n## 2. Chrome이 띄우고, 길이 먼저 주고받는다\n\n확장이 호출하면 Chrome이 `path`의 바이너리를 **자식 프로세스로 spawn**한다. 대화는 소켓도 HTTP도 아니고 `stdin`/`stdout`이다. 메시지마다 **4바이트 길이 헤더**(네이티브 바이트 순서) 다음에 UTF-8 JSON이 온다.\n\n```python\n#!/usr/bin/env python3\nimport json, struct, subprocess, sys\n\ndef read_message():\n    raw = sys.stdin.buffer.read(4)\n    if not raw:\n        sys.exit(0)                                  # Chrome이 파이프를 닫음\n    length = struct.unpack('@I', raw)[0]             # 길이 먼저\n    return json.loads(sys.stdin.buffer.read(length)) # 그만큼 본문\n\ndef send_message(obj):\n    data = json.dumps(obj).encode('utf-8')\n    sys.stdout.buffer.write(struct.pack('@I', len(data)))  # 보낼 때도 길이 먼저\n    sys.stdout.buffer.write(data)\n    sys.stdout.buffer.flush()\n\nmsg = read_message()\nresult = subprocess.run(\n    ['/opt/homebrew/bin/yt-dlp', '-o', '~/Downloads/%(title)s.%(ext)s', msg['url']],\n    capture_output=True, text=True,\n)\nsend_message({'ok': result.returncode == 0, 'error': result.stderr[-500:]})\n```\n\n`print` 대신 `sys.stdout.buffer.write`인 게 핵심이다. `stdout`에 글자 하나라도 새면 그게 길이 헤더 자리에 끼어들어 프로토콜이 깨진다. 로그는 `stderr`로 보낸다.\n\n`yt-dlp`를 **절대경로로** 부르는 것도 마찬가지다. GUI로 띄운 Chrome은 `.zshrc`를 안 읽고, 호스트는 독립 프로세스라 사용자 셸 환경을 물려받지 않는다.\n\n## 3. 확장에서 부른다 — background에서만\n\n`sendNativeMessage`·`connectNative`는 **background(service worker)에서만** 부를 수 있다. content script는 통로 밖이라, 흐름이 두 파일로 갈라진다.\n\n```js\n// content.js — 페이지에서 값만 뽑아 넘긴다\nchrome.runtime.sendMessage({ url: location.href })\n\n// background.js — 네이티브 통신은 여기서\nchrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {\n  chrome.runtime.sendNativeMessage('com.example.ytdlp', { url: msg.url }, sendResponse)\n  return true // 비동기 응답이라 채널을 열어둔다\n})\n```\n\n`sendNativeMessage`는 **첫 응답 뒤 프로세스가 끝난다** — \"값 던지고 끝\"에 맞다. 진행률을 흘려보내야 하면 `connectNative`로 `Port`를 열어둔다.\n\n## 크기 제한이 비대칭이다\n\n호스트 → Chrome은 **1MB**, Chrome → 호스트는 **64MiB**. 돌려보낼 게 크면 한 번에 못 부친다 — 위 코드가 `stderr`를 500자로 자르는 이유이고, 큰 결과를 나눠 보내려면 `connectNative` 쪽이어야 하는 이유다.\n\n[^developer-chrome-docs-extensions]: 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](https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging))"},{"i":"552","t":"vgrok — ngrok과 다른 두 가지","k":["tunneling","websocket","vercel-sandbox","networking"],"c":"2026-06-07","m":"2026-08-21","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n내 컴퓨터에서 서버를 띄워도 밖에서는 못 들어온다. **전화기는 있는데 번호가 없는 것과 같다** — 내가 거는 건 되는데 남이 나에게 걸 수는 없다(NAT·방화벽 뒤라서).\n\n터널링의 요령은 이 비대칭을 뒤집는 것이다. **내가 먼저 밖으로 걸어놓고 끊지 않는다.** 번호가 공개된 쪽이 그 통화를 붙들고 있다가, 자기한테 온 요청을 그 통화에 대고 흘려보낸다.\n\n```\n외부 요청 ──HTTP──▶ [공개 머신] ──┐\n                                │ WebSocket (내 컴퓨터가 먼저 걸어둔 것)\n              내 컴퓨터 ◀──HTTP──┘\n```\n\n<AutoIframe\n  src=\"/iframe/vgrok-reverse-tunnel.html\"\n  title=\"직접 연결은 밖에서 못 들어와 막히고, 터널은 내 컴퓨터가 먼저 연 연결 위로 요청이 거꾸로 흐르는 것을 단계별로 비교\"\n/>\n\n**여기까지는 ngrok도 같다.** vgrok이 다른 건 둘이다.\n\n## 하나 — 공개 머신을 빌린다\n\nngrok은 그 \"번호가 공개된 쪽\"을 자기 인프라로 굴린다. vgrok은 **잠깐 빌리는 컴퓨터(Vercel Sandbox)를 그 자리에 끼워넣는다.** 마침 필요한 두 가지가 다 딸려 오기 때문이다 — **공개 주소가 그냥 나오고**(도메인도 인증서도 설정 0), **코드를 올려 돌릴 수 있다.**\n\n그러니 터널 머신은 특별한 물건이 아니다. **\"공개 주소가 나오고 코드가 돌면\" 무엇이든 된다.** Sandbox는 그걸 가장 싸게 얻는 한 가지 방법일 뿐이다.\n\n핵심 코드가 **220줄쯤**인 것이 그 증거다 — 네트워킹·보안·HTTPS·DNS를 빌린 쪽이 다 떠안아서 남는 게 이만큼이다.\n\n대가는 수명이다. 빌린 거라 오래 못 쓴다 — Hobby는 **한 세션에 45분**.[^vercel-sandbox-pricing] 다만 세션 단위라 멈췄다 이어붙이면 총 수명은 늘릴 수 있다.\n\n## 둘 — 바이트가 아니라 메시지를 나른다\n\nngrok은 **TCP 터널도** 준다. 바이트를 그대로 흘려보내니 SSH·데이터베이스·MQTT·게임 서버까지 나른다.[^ngrok-tcp]\n\nvgrok은 그렇지 않다. HTTP 요청을 **뜯어서** `method`·`url`·`headers`·`body`로 만들고, 반대편에서 다시 조립한다. WebSocket은 끊기지 않는 통로 역할만 하고, 그 위로 흐르는 건 직렬화된 HTTP 메시지다.\n\n이 한 가지 선택에서 나머지가 전부 따라 나온다.\n\n| 그래서 | 왜냐면 |\n|---|---|\n| **번호표를 붙인다** | 통로 하나에 여러 요청·응답을 섞어 싣기 때문. 바이트 터널이면 연결마다 흐름이 따로라 필요 없다 |\n| **바이너리를 글자로 바꾼다** | 메시지를 JSON으로 만드니까. 이미지를 그대로 못 싣는다 |\n| **HTTP만 된다** | HTTP를 뜯어서 나르니까. 뜯을 수 없는 프로토콜은 못 나른다 |\n| **다 모아야 보낸다** | 메시지 한 덩어리로 부치니까. 양쪽 다 `on('data')`로 모았다가 `on('end')`에서 한 번에 넘긴다[^vgrok] |\n\n이미지·CSS 같은 static 리소스도 특별 취급이 없다. 똑같이 바이트를 글자로 바꿔 실었다 되돌린다. **되긴 되는데 하나씩 통째로 통과한다** — 점진 렌더가 안 되고, 큰 파일은 메모리에 통째로 올라가며, base64라 전송량이 3분의 1쯤 늘어난다. 끝나지 않는 응답(SSE 같은 것)은 아예 못 다룬다.\n\n---\n\n그 밖에 클라이언트는 하나만 붙는다.\n\n[^ngrok-tcp]: *\"TCP endpoints enable you to deliver any network service with a TCP-based protocol\"* — SSH·VNC·RDP, MySQL·Postgres, MQTT, 게임 서버 등. ([ngrok — TCP endpoints](https://ngrok.com/docs/universal-gateway/tcp/))\n\n[^vercel-sandbox-pricing]: 최대 세션 길이는 Hobby 45분 · Pro/Enterprise 24시간. *\"The maximum duration applies to a single session, not to the sandbox itself. The limit resets every time a sandbox stops and resumes, so the total lifetime of a persistent sandbox is effectively unbounded.\"* ([Vercel Sandbox — pricing and limits](https://vercel.com/docs/sandbox/pricing))\n\n[^vgrok]: `server.ts` 는 `req.on('data', chunk => chunks.push(chunk))` 로 모아 `req.on('end')` 에서 `Buffer.concat(chunks).toString('base64url')` 로 부치고, 받는 쪽은 `res.end(Buffer.from(body, 'base64url'))` 로 한 번에 쓴다. `client.ts` 도 응답을 같은 방식으로 모은다. ([vgrok — styfle](https://github.com/styfle/vgrok))"},{"i":"551","t":"dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다","k":["dnd-kit","tanstack-query","optimistic-update","race-condition","react","debug"],"c":"2026-06-07","m":"2026-09-10","b":"dnd-kit Sortable + react-query에서 **drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다.** 1~2프레임짜리 튕김이고, `DragOverlay`를 쓰면 더 두드러진다. `onDragEnd`에서 `setQueryData`로 즉시 캐시를 갱신해도 그대로다.\n\n## 왜 그런가\n\ndnd-kit의 drop 애니메이션은 **놓는 즉시 목적지를 잰다.** 그 순간 화면이 아직 옛 순서면 옛 자리를 목적지로 재고, 거기로 애니메이션한 다음 점프한다.[^dnd-kit-833]\n\n그러니 문제는 **새 순서가 언제 화면에 닿느냐**다. 두 경로의 시점이 다르다.\n\n- `onDragEnd` 안에서 부른 `setState` — 그 이벤트 처리 안에서 반영된다.\n- `queryClient.setQueryData` — 캐시는 즉시 바뀌지만, 구독자에게 알리는 건 react-query의 `notifyManager`를 거친다. **기본 스케줄러가 `setTimeout(fn, 0)`이라 리렌더가 다음 매크로태스크로 밀린다.**[^notify-manager]\n\n**캐시는 바로 바뀌는데 화면은 한 틱 뒤에 바뀐다.** 그 사이에 dnd-kit이 잰다.\n\n## 그래서 이렇게 한다\n\n렌더의 source-of-truth를 local state에 두고, 캐시 갱신은 서버 동기화 부수효과로 분리한다.\n\n```tsx\nfunction SortableList() {\n  const { data: serverItems = [] } = useQuery({ queryKey: ['items'], queryFn: fetchItems })\n  const [items, setItems] = useState<Item[]>(serverItems)\n\n  const isReorderingRef = useRef(false)\n  useEffect(() => {\n    if (!isReorderingRef.current) setItems(serverItems)\n  }, [serverItems])\n\n  const reorderMutation = useMutation({\n    mutationFn: reorderItems,\n    onSettled: () => {\n      isReorderingRef.current = false\n      queryClient.invalidateQueries({ queryKey: ['items'] })\n    },\n  })\n\n  const handleDragEnd = ({ active, over }: DragEndEvent) => {\n    if (!over || active.id === over.id) return\n    const next = arrayMove(items, idxOf(active.id), idxOf(over.id))\n    isReorderingRef.current = true\n    setItems(next)\n    reorderMutation.mutate(next)\n  }\n  // <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...\n}\n```\n\n주의:\n\n- **invalidate race** — `isReorderingRef` 없으면 mutation 직후 refetch가 옛 순서를 들고 와 `useEffect`가 local을 덮는다(또 다른 튕김). 서버가 새 순서를 반환한다면 `onSuccess`에서 바로 받아 이 가드를 통째로 없앨 수 있다.\n- **stable id** — `SortableContext`·자식 `key`·`useSortable({ id })`가 전부 같은 id여야 한다. 인덱스를 key로 쓰지 않는다.\n- **`DragOverlay` 잔여 튕김** — `dropAnimation={null}`로 없앨 수 있다. 진단도 겸한다: 이걸로 사라지면 위 원인이 맞다.\n\n## 교훈\n\n라이브러리가 **렌더 직후 DOM을 재는** 동작을 하면, **상태 갱신과 화면 반영 사이에 지연을 넣는 채널**로 상태를 바꿀 때 어긋난다. 상태가 언제 바뀌는지가 아니라 **화면이 언제 바뀌는지**가 기준이다.\n\n> v6.3.1 기준. v10+는 `OptimisticSortingPlugin`이 기본 활성화라 기제가 다르다 → 재검증 필요.\n\n[^dnd-kit-833]: *\"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](https://github.com/clauderic/dnd-kit/issues/833))\n\n[^notify-manager]: `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](https://tanstack.com/query/latest/docs/reference/notifyManager))"},{"i":"550","k":[],"c":"2026-06-07","m":"2026-06-07","b":"PNG을 범용 압축 컨테이너로 해킹 — 임의의 바이트를 canvas 픽셀(R/G/B)에 인코딩하고 `toDataURL(\"image/png\")`을 부르면 브라우저 내장 Deflate 압축을 JS에서 끌어쓸 수 있다. 복원은 `<img>`로 다시 로드해 `getImageData`로 픽셀을 읽는다.\n\n텍스트를 \"이미지로 만드는\" 게 아니라, PNG가 무손실 압축(Deflate)을 쓴다는 점을 범용 압축 API로 전용한 것. 지금은 Compression Streams API가 널리 지원돼 실용성은 낮고, 레거시 대응이나 창의적 해킹 참고용.\n\n## 참고\n\n- [Using the Browser's `<canvas>` for Data Compression — Jacob Strieb](https://jstrieb.github.io/posts/canvas-compress/)"},{"i":"549","k":[],"c":"2026-06-07","m":"2026-09-01","b":"**바깥 클릭 감지를 `useEffect` 없이 ref callback 하나로 만든다.**\n\n**언제**: React 19 이상일 때. 18 이하는 아래 함정을 보고 `useEffect` 쪽을 쓴다.\n\n**절차**\n\n```ts\nfunction useClickOutside(handler) {\n  const handlerRef = useRef(handler)\n  handlerRef.current = handler // stale closure 방지\n\n  return useCallback((node) => {\n    const handleClick = (e) => {\n      if (!node.contains(e.target)) handlerRef.current()\n    }\n\n    document.addEventListener('mousedown', handleClick)\n    return () => document.removeEventListener('mousedown', handleClick)\n  }, [])\n}\n\n// 사용\nfunction Modal({ onClose }) {\n  const ref = useClickOutside(onClose)\n  return <div ref={ref}>...</div>\n}\n```\n\n**확인**: 모달을 여러 번 열고 닫은 뒤 바깥을 클릭한다. 핸들러가 **한 번만** 불리면 cleanup이 돌고 있는 것이다 — 안 돌면 옛 리스너가 쌓여 여러 번 불린다.\n\n**함정**\n\n- **ref callback은 DOM 노드 참조만 전달한다.** 안에서 등록한 리스너 같은 사이드이펙트는 자동 해제되지 않는다.\n- **React 18 이하에서는 이 패턴이 안 된다.** unmount 시 `callback(null)`은 오지만 동일 함수 참조가 없어 `removeEventListener`를 못 부른다 → 리스너를 별도 ref에 저장해야 한다. cleanup return을 공식 지원하는 건 React 19부터다.\n\n## 참고\n\n- [React v19 — Cleanup functions for refs](https://react.dev/blog/2024/12/05/react-19)"},{"i":"548","k":[],"c":"2026-06-07","m":"2026-06-07","b":"애자일 의식(스쿼드·2주 스프린트·스탠드업)을 다 갖춰도, 수직 슬라이싱이 없으면 2주 컨테이너에 워터폴을 압축한 cargo cult agile이 된다.\n\n## 증상\n\n- 사일로/스쿼드 + 2주 스프린트라는 애자일 형식\n- 실제 흐름: 기획 → 디자인 → 서버/클라 → QA 직렬\n- 결과: 2주 컨테이너 안에 워터폴 압축\n\n## 구조적 원인 (직군 구성 문제 아님)\n\n1. **작업 단위 = 기능 전체** (수직 슬라이싱 부재)\n2. **각 직군 산출물 = 다음 직군의 하드 의존성**\n3. Discovery(무엇을)와 Delivery(만드는 일)를 같은 스프린트 같은 기능에서 동시에 시도\n\n## 왜 아무도 의심하지 않는가\n\n- 형식 요건(스탠드업, 회고, 번다운, Jira)이 갖춰져 있어 의심이 차단됨\n- \"애자일이 잘 안 된다\"는 인식이 와도 _의식 내부_ 처방(스탠드업 개선, 회고 솔직하게)으로만 귀결\n- 의식 자체의 설계 오류는 검토 대상이 아님 → **cargo cult agile**\n\n**진짜 애자일은 모든 직군에 단기적으로 더 불편**. 장기 처리량 이득은 조직 단위, 단기 불편은 개인 단위."},{"i":"547","k":[],"c":"2026-06-03","m":"2026-09-10","b":"풀스택 경계에서 서버와 클라이언트를 잇는 단위가 reactive state 자체다. 서버의 signal·model 이 클라이언트에서 *같은 객체*로 보인다.\n\n```ts\n// 서버 model이 클라이언트에 그대로 비친다\nconst todos = createReflectedModel('Todos')\ntodos.add('buy milk') // RPC 인지 로컬 메서드인지 구분하지 않는다\ntodos.all.value[0].done.value = true // signal 토글 = 서버 상태 토글\n```\n\n그 단위가 컴포넌트(RSC)도 함수 호출(server functions)도 DOM diff(LiveView)도 아니다. 다른 접근이 \"서버 코드를 어떻게 _부를까_\"를 풀 때, 여기는 \"서버 상태를 클라이언트에서 어떻게 _그대로 다루게_ 할까\"를 푼다. 태그라인 *\"as if they lived on the client\"* 이 그걸 그대로 말한다.\n\n## 참고\n\n- [developit/mixed-signals](https://github.com/developit/mixed-signals)"},{"i":"546","k":[],"c":"2026-06-03","m":"2026-09-01","b":"WeakMap의 키를 \"입력 배열의 참조 자체\"로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.\n\n**언제**: 같은 배열을 이름 같은 키로 **반복 조회**해서 O(n) 스캔이 계속 도는데, 그 배열이 언제 바뀌는지 추적하기는 싫을 때.\n\n**절차**\n\n```typescript\nconst indexCache = new WeakMap<Item[], Map<string, string>>()\n\n// name → id 역인덱스를 1회 빌드 (O(n))\nconst buildIndex = (items: Item[]) =>\n  new Map(items.map((it) => [it.name, it.id]))\n\nfunction lookup(items: Item[], name: string): string | null {\n  let index = indexCache.get(items)\n  if (!index) {\n    index = buildIndex(items) // 캐시 미스일 때만 빌드\n    indexCache.set(items, index)\n  }\n  return index.get(name) ?? null\n}\n```\n\n**확인**: 같은 배열 참조로 두 번 부른다. `buildIndex`가 **한 번만** 돌면 된 것이다.\n\n**함정**: 호출 측 `items` 참조가 **stable해야** 작동한다. 매번 `[...data]`·`data.filter()`로 새 배열을 넘기면 항상 miss라 무의미하다. (TanStack Query의 `data`/`select` 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK)\n\n**왜 되는가**\n\n- 같은 참조로 재호출 → cache hit, O(1)\n- 데이터가 새 참조로 교체(refetch 등) → 자동 miss → 새 index 빌드. 옛 index는 옛 items와 함께 GC 대상\n- 키가 weak reference라 items가 어디서도 안 잡히면 entry도 같이 사라짐. 일반 `Map`이면 참조가 영구히 붙들려 메모리 누수\n\n## 참고\n\n- [WeakMap - MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap)\n- [Render Optimizations (referential stability) - TanStack Query](https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations)"},{"i":"545","k":[],"c":"2026-06-03","m":"2026-06-03","b":"컴포넌트는 className을 안 만든다 — prop을 그대로 DOM 어트리뷰트로 흘리고, variant 분기는 CSS 어트리뷰트 셀렉터가 담당한다.\n\n```tsx\n// 컴포넌트 전체 = h(tag, { ...props, k: name }) 한 줄\nexport const Badge = createSimpleComponent<'span', BadgeOwnProps>(\n  'badge',\n  'span',\n)\n// → <span k=\"badge\" variant=\"secondary\">   (variant가 class가 아니라 attr로 그대로 나감)\n```\n\n```css\n[k=\"badge\"] { /* default */ }\n[k=\"badge\"][variant=\"secondary\"] { … }\n[k=\"badge\"][variant=\"destructive\"]:hover { … }\n```\n\n## createSimpleComponent 가 하는 일\n\n```ts\n/**\n * - defaults를 props 아래에 깔고\n * - k={name} 할당하고\n * - h(tag, normalizedProps)\n */\ncreateSimpleComponent<T, P>(name, tag)\n```\n\n- `k?: never` 로 소비자가 셀렉터 키 `k` 를 덮어쓰는 걸 **타입 레벨**에서 차단.\n- `Props = Omit<JSX.IntrinsicElements[T], keyof P> & P` → own prop이 동명 intrinsic 어트리뷰트를 덮어씀 (Badge의 `variant` 가 우선).\n- `tag` 를 `(props) => T` 함수로도 받아 폴리모픽 (`href` 있으면 `<a>`, 없으면 `<button>`). 단 분기에 들어가는 건 defaults 적용 _전_ 원본 props.\n\n대가: 런타임 동적 variant 계산·조건부 로직은 포기 (CSS 셀렉터로 표현 가능한 범위만).\n\n## 참고\n\n- [developit/kinu](https://github.com/developit/kinu)"},{"i":"544","k":["sdd","spec-driven-development","pbt","fast-check","bombadil","antithesis","model-based-testing","test"],"c":"2026-05-31","m":"2026-06-02","b":"SDD(Kiro): 스펙을 `requirements → design → tasks`로 문서화하고, acceptance criteria에서 testable property를 뽑아낸다 — 명세가 곧 테스트의 원천.\n\n신선한 지점: 보통 테스트는 코드 뒤에 짜는데, SDD는 명세 단계에서 검증 가능한 불변식을 먼저 규정한다.\n\n- 그 property를 PBT(fast-check)로 검증 (순수 함수에서 강력)\n- UI로 확장하면 Bombadil = \"action generator + property\"\n\n## 참고\n\n- [Kiro — Specs](https://kiro.dev/docs/specs/) — requirements → design → tasks 스펙 워크플로우\n- [fast-check](https://github.com/dubzzz/fast-check) — JavaScript PBT 프레임워크\n- [Bombadil (Antithesis)](https://github.com/antithesishq/bombadil) — 웹 UI property-based testing (TS로 property + action generator 작성)"},{"i":"541","k":[],"c":"2026-05-25","m":"2026-06-02","b":"시뮬레이터 Safari를 GUI로 디버깅하는 정도는 그냥 **macOS Safari 개발자용 메뉴**. inspect-webkit(CDP 브리지)으로 우회하려다 막혔다.\n\n이유: inspect-webkit은 README부터 \"for AI agents and CI\" 타겟 — 사람 GUI 디버깅이 애초에 주력 시나리오가 아니었다.\n\n교훈: **도구의 *의도된 사용자*를 README에서 먼저 확인**한다.\n\n## 참고\n\n- [inspect-webkit](https://github.com/EvanBacon/inspect-webkit) — Safari/WKWebView 타겟을 CDP로 브리지 (헤드리스, AI/CI 지향)\n- [Eruda](https://github.com/liriliri/eruda) — 페이지에 주입하는 모바일 콘솔 (브리지 불필요)\n- [Inspecting iOS and iPadOS — Apple](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) — macOS Safari로 시뮬레이터 디버깅 (정도)"},{"i":"540","k":["react","hooks","async-generator","pattern","typescript","xstate"],"c":"2026-05-25","m":"2026-09-01","b":"여러 단계의 비동기 UI 업데이트를 `setLoading → setProgress → setLoading(false)`로 흩뿌리지 않고 async generator의 `yield` 시퀀스로 시간 순서대로 표현. 원본 트릭은 [@ericclemmons](https://x.com/ericclemmons/status/2055085109573734784).\n\n**언제** — generator는 **시간이 코드의 한 방향(↓)으로만 흐르는** 모델이다. 그 모양에 맞는 시나리오:\n\n- 멀티 단계 loading 메시지 (아래 `loadingFlow`)\n- progressive search — tier별로 점진적 결과 yield, tier 경계가 자연스러운 cancellation point, debounce 불필요\n- optimistic mutation — `{ phase: 'optimistic' | 'reconciled' | 'rolledBack', data }` 전이를 4개 콜백 대신 한 함수로\n\n**절차**\n\n```ts\ntype FlowEvent<P extends string, D> = { phase: P; data: D }\n\nasync function* loadingFlow(\n  signal: AbortSignal,\n): AsyncGenerator<FlowEvent<'starting' | 'slow' | 'done', string>> {\n  yield { phase: 'starting', data: 'Starting…' }\n  await wait(1000, signal)\n  yield { phase: 'slow', data: 'Taking longer than usual' }\n  await wait(2000, signal)\n  yield { phase: 'done', data: 'Got it 🎉' }\n}\n```\n\n**확인** — 감싸는 레이어(hook이든 actor든)에서 여섯을 다 채웠는가. 원본 데모는 전부 비어 있다:\n\n- **cancellation**: generator에 `AbortSignal` 주입 → `await` 대상(`wait`, `fetch`)이 signal-aware해야 진짜 취소됨\n- **재진입**: 새 실행 시 직전 iterator abort\n- **error path**: generator 내부 throw → 상태 노출\n- **unmount/teardown**: cleanup으로 in-flight iterator abort\n- **stale closure**: 최신 클로저 참조 (hook은 ref, actor는 input)\n- **typed**: `phase`/`data` 모두 좁힘"},{"i":"539","k":["url","encoding","ios","debug","security"],"c":"2026-05-25","m":"2026-05-31","b":"URL `%2520` 버그 = iOS 17.0/17.1 WebKit pasteboard가 복사 시 URL을 재인코딩 (WebKit Bug 261936).\n\n## 현상\n\n- 공백이 `%2520`으로 깨짐, 한글은 `%EC%88%98` 정상 → **부분** 재인코딩\n- 복사-붙여넣기 경로만 영향, 클릭(JS redirect)은 정상\n- iOS 집중 + referrer 누락 패턴과 일치\n\n## 원인\n\n`%2520` = `%25`(= `%`) + 원래 있던 `20`. 공백(`%20`)이 한 번 더 인코딩된 것.\n\n```\n공백 → %20 → (% 만 재인코딩) → %2520\n```\n\nURL 인코딩은 멱등(idempotent)이 아니다 — `encodeURIComponent`를 두 번 하면 값이 달라진다.\n\n진범은 WebKit Bug 261936:\n\n- iOS 16↓: `NSURL URLWithString:`이 invalid char에 `nil`\n- iOS 17.0/17.1: invalid char를 자동 percent-encode (regression)\n- iOS 17.2: 수정\n\npasteboard가 NSURL을 만들 때 `%20`은 invalid로 잘못 판단해 재인코딩, 한글 percent-encoding(`%EA%B0%80`)은 valid UTF-8로 통과 → 비대칭의 원인.\n\n## 해결\n\n원인이 외부(OS 버그)면 추적보다 방어가 ROI 높음.\n\n- 화이트리스트 라우트 패턴에서만 디코딩\n- 디코딩 후 위험 패턴(`..`, `//`, `\\`) 차단\n- segment별 `encodeURIComponent` 후 301 redirect\n\n> [!CAUTION]\n> 무조건 이중 디코딩 금지 — `..%252f..%252f` → `../../` path traversal 우회 벡터.\n\n## 교훈\n\n- URL은 디코딩된 원본으로 보관, 인코딩은 출력 직전 한 번 (single source of truth)\n- \"방어적으로 한 번 더\"가 함정 — 멱등이 아닌 연산엔 통하지 않는다\n\n## 참고\n\n- [WebKit Bug 261936](https://bugs.webkit.org/show_bug.cgi?id=261936)\n- [Changes to URL string parsing in iOS 17 — Augmented Code](https://augmentedcode.io/2023/10/02/changes-to-url-string-parsing-in-ios-17/)\n- [OWASP — Double Encoding](https://owasp.org/www-community/Double_Encoding)\n- [WHATWG URL Standard](https://url.spec.whatwg.org/)"},{"i":"538","k":["react","as","as-child","render-prop","polymorphic"],"c":"2026-01-26","m":"2026-09-01","b":"컴포넌트가 렌더링하는 요소를 외부에서 제어하는 세 가지 패턴.\n\n**언제** — 무엇이 필요한지로 고른다.\n\n- 단순 태그 변경 → `as`\n- 기존 컴포넌트 합성 → `asChild` 또는 `render`\n- 상태 기반 커스터마이징 → `render`\n\n| 패턴    | 복잡도 | 타입 안전성 | 유연성 |\n| ------- | ------ | ----------- | ------ |\n| as      | 낮음   | 보통        | 낮음   |\n| asChild | 중간   | 보통        | 높음   |\n| render  | 중간   | 높음        | 높음   |\n\n## as\n\n태그명을 props로 전달. 가장 단순하지만 타입이 복잡해짐.\n\n```tsx\ntype PolymorphicProps<E extends React.ElementType> = {\n  as?: E\n} & React.ComponentPropsWithoutRef<E>\n\nfunction Button<T extends React.ElementType = 'button'>({\n  as,\n  ...props\n}: PolymorphicProps<T>) {\n  const Comp = as || 'button'\n  return <Comp {...props} />\n}\n```\n\n## asChild + Slot\n\nRadix UI 방식. 자식 요소로 렌더링 위임. prop 병합이 암묵적.\n\n```tsx\nimport { Slot } from '@radix-ui/react-slot'\n\nfunction Button({ asChild, ...props }) {\n  const Comp = asChild ? Slot : 'button'\n  return <Comp {...props} />\n}\n```\n\nSlot이 자동으로 처리하는 것: className 병합, 이벤트 핸들러 합성, ref 병합.\n\nSlot 직접 구현:\n\n```tsx\nconst Slot = forwardRef(({ children, ...props }, ref) => {\n  if (!isValidElement(children)) return null\n\n  return cloneElement(children, {\n    ...props,\n    ...children.props,\n    ref: ref || children.ref,\n  })\n})\n```\n\n자식 요소 타입에 따른 조건부 Props:\n\n```tsx\ntype ConditionalProps<T> = T extends ReactElement<any, 'a'>\n  ? { href?: string; external?: boolean }\n  : T extends ReactElement<any, 'button'>\n    ? { type?: 'button' | 'submit' }\n    : {}\n```\n\n## render\n\nBase UI, React Aria 방식. 명시적 prop 전달. 타입 추론이 가장 좋음.\n\n```tsx\n// Element 방식\n<Button render={<a href=\"/about\" />}>Link</Button>\n\n// Callback 방식 - state 접근 가능\n<Button render={(props, state) => (\n  <motion.button {...props} animate={state.isPressed ? ... : ...} />\n)}>\n  Animated\n</Button>\n```\n\nrender prop은 `(domProps, renderProps)` 시그니처. domProps는 ref 포함 DOM 속성, renderProps는 컴포넌트 상태(isPressed, isSelected 등).\n\n\n---\n\n- [Base UI useRender](https://base-ui.com/react/utils/use-render) - render prop 커스텀 훅 구현\n- [React Aria render prop PR](https://github.com/adobe/react-spectrum/pull/9499) - React Aria Components에 render prop 도입\n- [Slot/asChild Pattern](https://boda.sh/blog/react-slot-aschild-pattern) - Radix Slot vs Base UI render 비교\n- [React as prop (christianvm)](https://www.christianvm.dev/blog/react-as-prop) - ElementType, ComponentPropsWithoutRef 타입 활용\n- [slot-jsx-pragma](https://github.com/jjenzz/slot-jsx-pragma) - cloneElement 없이 asChild 구현하는 JSX pragma\n- [React as prop (robinwieruch)](https://www.robinwieruch.de/react-as-prop/) - 디자인 시스템에서 시맨틱 HTML 유지하며 스타일 분리\n- [asChild in React, Svelte, Vue, Solid](https://medium.com/@bryanmylee/aschild-in-react-svelte-vue-and-solid-for-render-delegation-645c73650ced) - 프레임워크별 render delegation 비교\n- [React asChild (jacobparis)](https://www.jacobparis.com/content/react-as-child) - asChild 패턴 구현 튜토리얼\n- [Polymorphism done well](https://www.kripod.dev/blog/behind-the-as-prop-polymorphism-done-well/) - 타입 안전한 polymorphic 컴포넌트"},{"i":"537","k":["bash","mv"],"c":"2026-01-07","m":"2026-01-07","b":"```bash\nfor file in *.md; do\n  new_name=\"${file#* }\"\n  mv \"$file\" \"$new_name\"\ndone\n```\n\n```\n| 개념        | JavaScript      | Bash                   |\n| ----------- | --------------- | ---------------------- |\n| 반복        | `for...of`      | `for...in...do...done` |\n| 문자열 분리 | `split(' ')[1]` | `${var#* }`            |\n| 문자열 치환 | `replace()`     | `${var/old/new}`       |\n| 파일 조작   | `fs.rename()`   | `mv`                   |\n| 출력        | `console.log()` | `echo`                 |\n```\n\nParameter Expansion\n\n```bash\nfile=\"351 529.md\"\n\n${file#* }       # 앞에서 \"* \" 제거 → \"529.md\"\n${file##* }      # greedy\n${file% *}       # 뒤에서 \" *\" 제거 → \"351\"\n${file%%.*}      # greedy → \"351 529\"\n${file/old/new}  # 첫 번째 치환\n${file//old/new} # 전체 치환\n```\n\n`#`은 앞(키보드에서 $보다 왼쪽), `%`는 뒤.\n\n따옴표\n\n```bash\nmv $file $new_name      # ❌ 공백 있으면 깨짐\nmv \"$file\" \"$new_name\"  # ✅\n```\n\n디버깅\n\n```bash\nset -x  # 실행 명령어 출력\nset -e  # 에러 시 중단\n```"},{"i":"536","k":["javascript","monad","promise"],"c":"2025-01-01","m":"2025-01-01","b":"콜백 → Promise → async/await\n\n```javascript\n// 콜백\nfetchData(() => {\n  processData(() => {\n    displayData()\n  })\n})\n\n// Promise 체인\nfetchData().then(processData).then(displayData)\n\n// async/await\nasync function main() {\n  const data = await fetchData()\n  const processed = await processData(data)\n  await displayData(processed)\n}\n```\n\nPromise는 모나드처럼 동작 - `then`이 bind/flatMap 역할\n\n- **map**: 값 변환 (중첩 허용)\n- **flatMap**: 값 변환 + 평탄화 (Promise의 then)"},{"i":"535","k":["typescript","monad","maybe"],"c":"2025-01-01","m":"2025-01-01","b":"TypeScript Maybe Monad 구현\n\n```typescript\ntype Maybe<T> = Just<T> | Nothing\n\nclass Just<T> {\n  constructor(public value: T) {}\n  bind<U>(fn: (value: T) => Maybe<U>): Maybe<U> {\n    return fn(this.value)\n  }\n}\n\nclass Nothing {\n  bind<U>(fn: (value: any) => Maybe<U>): Maybe<U> {\n    return this\n  }\n}\n\nconst nothing = new Nothing()\n\nfunction safeDivide(x: number, y: number): Maybe<number> {\n  return y === 0 ? nothing : new Just(x / y)\n}\n\nnew Just(10).bind((x) => safeDivide(x, 2)) // Just { value: 5 }\nnew Just(10).bind((x) => safeDivide(x, 0)) // Nothing {}\n```"},{"i":"534","k":["monad","functional-programming"],"c":"2025-01-01","m":"2025-01-01","b":"모나드 입문\n\n모나드 = 복잡한 처리를 숨기면서 연속된 연산을 가능하게 하는 패턴\n\n1. **타입 래퍼** - 값을 감싸는 구조 (예: `NumberWithLogs`)\n2. **래핑 함수** (unit/return) - 값을 모나드로 감쌈\n3. **바인딩 함수** (bind/flatMap) - 래핑된 값에 함수 적용\n\n```typescript\ninterface NumberWithLogs {\n  result: number\n  logs: string[]\n}\n\nfunction wrapWithLogs(n: number): NumberWithLogs {\n  return { result: n, logs: [] }\n}\n\nfunction runWithLogs(\n  input: NumberWithLogs,\n  transform: (n: number) => NumberWithLogs\n): NumberWithLogs {\n  const next = transform(input.result)\n  return { result: next.result, logs: [...input.logs, ...next.logs] }\n}\n```\n\nOption, Promise도 모나드. `then`이 바인딩 함수 역할."},{"i":"533","k":["typescript","monad","pattern"],"c":"2025-01-01","m":"2025-01-01","b":"Maybe Monad로 null 체크 체이닝\n\n```typescript\ntype Maybe<T> = T | null\n\nconst Maybe = {\n  of: <T>(value: T): Maybe<T> => (value != null ? value : null),\n  map: <T, U>(m: Maybe<T>, fn: (v: T) => U): Maybe<U> =>\n    m != null ? Maybe.of(fn(m)) : null,\n}\n\n// 사용 예: DOM 요소 찾아서 스크롤\nMaybe.of(scrollElement.current)\n  .map((root) => root.querySelector(`#${id}`))\n  .map((target) => target.getClientRects()[0])\n  .map((rect) => {\n    root.scrollLeft = rect.left\n  })\n```\n\nGenerator로 early return 패턴도 가능\n\n```typescript\nfunction* handleScroll(id) {\n  const root = scrollElement.current\n  if (!root) return\n  const target = root.querySelector(`#${id}`)\n  if (!target) return\n  yield target.getClientRects()[0]?.left ?? 0\n}\n```"},{"i":"532","k":["javascript","scroll","sync"],"c":"2025-01-01","m":"2025-01-01","b":"두 요소 스크롤 동기화\n\n```javascript\nconst box1 = document.getElementById('box1')\nconst box2 = document.getElementById('box2')\n\nlet isSyncing = false\n\nfunction syncScroll(source, target, sourceWidth, targetWidth) {\n  const ratio = source.scrollLeft / (source.scrollWidth - sourceWidth)\n  target.scrollLeft = ratio * (target.scrollWidth - targetWidth)\n}\n\nbox1.addEventListener('scroll', () => {\n  if (!isSyncing) {\n    isSyncing = true\n    syncScroll(box1, box2, 1280, 640)\n    isSyncing = false\n  }\n})\n\nbox2.addEventListener('scroll', () => {\n  if (!isSyncing) {\n    isSyncing = true\n    syncScroll(box2, box1, 640, 1280)\n    isSyncing = false\n  }\n})\n```\n\n`isSyncing` 플래그로 무한 이벤트 루프 방지"},{"i":"531","k":["javascript","date"],"c":"2025-01-01","m":"2025-01-01","b":"현재 시간부터 목표 시간까지 남은 시간 계산\n\n```javascript\nfunction calculateRemainingTime(targetTime) {\n  const now = new Date()\n  const [h, m, s] = targetTime.split(':').map(Number)\n\n  const target = new Date()\n  target.setHours(h, m, s)\n\n  const diff = target - now\n  if (diff < 0) return null\n\n  return {\n    hours: Math.floor(diff / 3600000) % 24,\n    minutes: Math.floor(diff / 60000) % 60,\n    seconds: Math.floor(diff / 1000) % 60,\n  }\n}\n\ncalculateRemainingTime('18:30:00')\n// { hours: 2, minutes: 15, seconds: 30 }\n```"},{"i":"530","k":["nodejs","turf","geojson"],"c":"2025-01-01","m":"2025-01-01","b":"turf.js로 좌표가 영역 내에 있는지 확인\n\n```javascript\nconst turf = require('@turf/turf')\n\nconst polygon = turf.polygon([\n  [\n    [-73.981, 40.768],\n    [-73.981, 40.764],\n    [-73.975, 40.764],\n    [-73.975, 40.768],\n    [-73.981, 40.768], // 닫기\n  ],\n])\n\nconst point = turf.point([-73.978, 40.766])\n\nturf.booleanPointInPolygon(point, polygon) // true/false\n```\n\n`npm install @turf/turf`"},{"i":"529","k":["typescript","state-management","pattern"],"c":"2025-01-01","m":"2025-01-01","b":"간단한 Redux 스타일 Store 구현\n\n```typescript\ntype Reducer<S, A> = (state: S, action: A) => S\ntype Listener<S> = (state: S) => void\n\ninterface Store<S, A> {\n  getState: () => S\n  subscribe: (listener: Listener<S>) => () => void\n  dispatch: (action: A) => A\n}\n\nfunction createStore<S, A>(\n  reducer: Reducer<S, A>,\n  preloadedState: S\n): Store<S, A> {\n  let currentState = preloadedState\n  let listeners: Listener<S>[] = []\n\n  return {\n    getState: () => currentState,\n    subscribe: (listener) => {\n      listener(currentState)\n      listeners.push(listener)\n      return () => {\n        listeners = listeners.filter((l) => l !== listener)\n      }\n    },\n    dispatch: (action) => {\n      currentState = reducer(currentState, action)\n      listeners.forEach((l) => l(currentState))\n      return action\n    },\n  }\n}\n```"},{"i":"528","k":[],"c":"2025-12-29","m":"2025-12-29","b":"[react-is](https://www.npmjs.com/package/react-is)로 children에서 특정 컴포넌트 필터링\n\n> [!NOTE]\n>\n> `react-is`는 React 공식 패키지로, React 요소 타입 확인 유틸리티. `typeof`나 `instanceof`로는 `React.memo()`, `forwardRef()` 등을 구분할 수 없어서 필요함. 라이브러리 개발, children 타입 검사에 필수.\n\n```tsx\nimport * as ReactIs from 'react-is'\n\n// 특정 컴포넌트 타입인지 확인\nfunction isComponentType<T>(element: ReactNode, Type: ComponentType<T>) {\n  return ReactIs.isElement(element) && element.type === Type\n}\n\n// children에서 특정 컴포넌트만 필터링\nfunction filterChildren<T>(children: ReactNode, Type: ComponentType<T>) {\n  return Children.toArray(children).filter(\n    (child): child is ReactElement<T> =>\n      ReactIs.isElement(child) && child.type === Type\n  )\n}\n\n// 사용: Layout에서 Header, Content, Footer 분리\nconst Layout = ({ children }) => {\n  const headers = filterChildren(children, Header)\n  const contents = filterChildren(children, Content)\n\n  return (\n    <div>\n      <div className=\"header\">{headers}</div>\n      <div className=\"content\">{contents}</div>\n    </div>\n  )\n}\n```"},{"i":"526","k":[],"c":"2025-12-29","m":"2025-12-29","b":"```less\n// 기본\n@primary: #3498db;\n.button { color: @primary; }\n\n// 보간 (선택자, 속성, URL에서)\n@component: 'button';\n.@{component} { ... }           // .button\nbackground-@{property}: blue;   // background-color: blue\nurl('@{path}/icon.svg')\n\n// 변수를 이용한 변수\n@theme: 'primary';\ncolor: @@theme;  // @primary 값\n\n// 맵 (LESS 3.5+)\n@colors: { primary: #3498db; danger: #e74c3c; };\ncolor: @colors[primary];\n\n// CSS 변수 조합\n:root { --primary: @primary; }\n.el { color: var(~'--@{prefix}-color'); }\n```\n\n---\n\n- `~\"\"` 이스케이프 (문자열 그대로 출력)\n- `@{}` 보간 (변수를 문자열로 치환)\n- `@@var` 변수를 이용한 변수 참조\n\n---\n\nhttps://lesscss.org/features/#variables-feature"},{"i":"525","k":[],"c":"2025-12-29","m":"2025-12-29","b":"`gulp.src` 파일 리스트 디버깅\n\n```javascript\n// 1. gulp-debug (가장 간단)\ngulp.src('src/**/*.js').pipe(require('gulp-debug')({ title: 'Files:' }))\n\n// 2. through2 (상세 정보)\nconst through2 = require('through2')\n\ngulp.src('src/**/*.js').pipe(\n  through2.obj((file, enc, cb) => {\n    console.log(file.relative)\n    cb(null, file)\n  })\n)\n\n// 3. glob 패턴 사전 확인\nrequire('glob')('src/**/*.js', (err, files) => console.log(files))\n\n// 조건부 디버깅: `NODE_ENV=debug gulp build`\nconst gulpif = require('gulp-if')\n\ngulp.src('src/**/*.js').pipe(gulpif(process.env.NODE_ENV === 'debug', debug()))\n```"},{"i":"524","k":[],"c":"2025-12-29","m":"2025-12-29","b":"TypeScript Discriminated Union 문서화 딜레마\n\n```typescript\n// 개발: 타입 안전, IDE 자동완성, 명확한 의도\ntype Button =\n  | { type: 'submit'; color: string }\n  | { type: 'reset'; text: string }\n```\n\n```markdown\n<!-- 문서화: 조건부 속성 설명 어려움, 테이블 복잡, 예시 다수 필요 -->\n\n| 속성  | 타입                | 조건                    |\n| ----- | ------------------- | ----------------------- |\n| type  | 'submit' \\| 'reset' | 필수                    |\n| color | string              | type='submit'일 때 필수 |\n| text  | string              | type='reset'일 때 필수  |\n```\n\n---\n\n- 자세한 타입 정의는 TypeScript 정의 파일 참고\n- [typedoc](https://typedoc.org/), [api-extractor](https://api-extractor.com/pages/overview/intro/)로 자동 생성"},{"i":"523","k":[],"c":"2025-12-29","m":"2025-12-29","b":"[react-spring](https://react-spring.dev/docs)\n\n- `onRest` 애니메이션이 물리적으로 정지했을 때 → UI 상태 업데이트용\n- `onResolve` Promise가 resolve될 때 → 비동기 플로우 제어용\n\n```javascript\nconst [springs, api] = useSpring(() => ({\n  x: 0,\n  onRest: () => setStatus('stopped'), // UI 상태\n  onResolve: () => console.log('done'), // Promise 기반 로직\n}))\n\n// Promise 방식도 가능\nawait api.start({ x: 100 })\n```"},{"i":"522","k":[],"c":"2025-12-29","m":"2025-12-29","b":"npm install 실패 시 플래그 선택\n\n```bash\n# 1. Node 버전 불일치 (engines 필드)\nnpm install --ignore-engines\n\n# 2. peer dependencies 충돌 (가장 흔함)\nnpm install --legacy-peer-deps\n\n# 3. 마지막 수단 (권장 안함)\nnpm install --force\n```\n\n> [!NOTE]\n>\n> `--legacy-peer-deps`가 필요한 상황\n\n```json\n{\n  \"react\": \"^18.2.0\",\n  // 라이브러리가 아직 React 18 공식 지원 안 하지만 실제로는 동작함.\n  \"react-beautiful-dnd\": \"^13.1.1\" // peer: react@^16.8 || ^17\n}\n```\n\n```bash\n# 프로젝트 전체 적용\necho \"legacy-peer-deps=true\" > .npmrc\n```"},{"i":"521","k":[],"c":"2025-12-28","m":"2025-12-28","b":"RTL에서 overflow scroll 상태 테스트\n\n```javascript\n// scrollWidth > clientWidth면 가로 스크롤 필요\nconst isOverflowScrollable = (el) => ({\n  horizontal: el.scrollWidth > el.clientWidth,\n  vertical: el.scrollHeight > el.clientHeight,\n})\n\ntest('overflow 확인', () => {\n  render(<ScrollableComponent parentWidth={300} childWidth={500} />)\n\n  const parent = screen.getByTestId('parent-container')\n\n  expect(isOverflowScrollable(parent).horizontal).toBe(true)\n})\n\ntest('동적 크기 변경', () => {\n  const { rerender } = render(<Comp parentWidth={400} childWidth={300} />)\n\n  expect(isOverflowScrollable(screen.getByTestId('parent')).horizontal).toBe(\n    false\n  )\n\n  rerender(<Comp parentWidth={400} childWidth={600} />)\n\n  expect(isOverflowScrollable(screen.getByTestId('parent')).horizontal).toBe(\n    true\n  )\n})\n```\n\n> [!CAUTION]\n> DOM 완전 렌더링 후 측정해야 정확. `getBoundingClientRect()`는 실제 크기 반환."},{"i":"520","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Node.js에서 윈도우즈 외부 경로 파일 읽기\n\n```javascript\nconst path = require('path')\nconst fs = require('fs').promises\n\n// 슬래시 사용 (Node.js가 자동 변환)\n// ('C:/Users/username/Documents/file.txt')\n\n// 또는 백슬래시 이스케이프\n// ('C:\\\\Users\\\\username\\\\Documents\\\\file.txt')\n\n// 권장: path.join 사용 (크로스 플랫폼)\npath.join(process.env.USERPROFILE, 'Documents', 'file.txt')\n\n// path.resolve로 절대 경로 생성\npath.resolve('../../Documents/file.txt')\n```\n\n> [!CAUTION]\n> 사용자 입력으로 경로 받을 때는 `path.normalize()`로 디렉토리 트래버설 공격 방지."},{"i":"519","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Date.getDay() - 요일 반환 (0=일요일, 6=토요일)\n\n```javascript\nnew Date().getDay() // 0~6\nnew Date('2024-12-25').getDay() // 3 (수요일)\nnew Date(2024, 11, 25).getDay() // 월은 0부터 시작\n\nconst days = ['일', '월', '화', '수', '목', '금', '토']\ndays[new Date().getDay()] // 오늘 요일\n```"},{"i":"518","k":[],"c":"2025-12-28","m":"2025-12-28","b":"검색어 필터링 UI → **Debounce** (300-500ms)\n\n타이핑 완료 후 검색이 더 자연스러움. \"자바스크립트\" 입력 시 매 글자마다 검색하면 비효율적.\n\n```javascript\nconst handleSearch = debounce((term) => performSearch(term), 300)\ninput.addEventListener('input', (e) => handleSearch(e.target.value))\n```\n\nThrottle은 실시간 검색 결과를 보여주면서 요청 제한할 때 사용 (1초마다 최대 1회 등)."},{"i":"516","k":[],"c":"2025-12-28","m":"2025-12-28","b":"TypeScript 제네릭 기본값 - `<T extends Type = DefaultValue>`\n\n```typescript\ntype SelectProps<T extends 'single' | 'multiple' = 'multiple'> = {\n  mode: T\n  value: T extends 'single' ? string : string[]\n}\n\nconst a: SelectProps = { mode: 'multiple', value: ['a', 'b'] } // 기본값 사용\nconst b: SelectProps<'single'> = { mode: 'single', value: 'a' } // 명시적 지정\n\n// 여러 매개변수에 각각 기본값\ntype ApiResponse<\n  TData = any,\n  TStatus extends 'loading' | 'success' | 'error' = 'loading'\n> = {\n  data: TStatus extends 'success' ? TData : null\n  status: TStatus\n}\n```\n\n가장 자주 사용되는 케이스를 기본값으로 설정하면 제네릭을 항상 명시하는 번거로움 줄어듦."},{"i":"515","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Vitest 모킹: `vi.mocked()` vs `vi.hoisted()`\n\n`vi.mocked()`는 타입만 제공, 실제 모킹 구현체는 별도로 필요.\n\n```typescript\n// ❌ mockImplementation이 undefined\nconst mockUseSize = vi.mocked(useSize)\n\n// ✅ vi.hoisted() 사용 (추천)\nconst mockUseSize = vi.hoisted(() => vi.fn())\n\nvi.mock('ahooks', () => ({ useSize: mockUseSize }))\n\nbeforeEach(() => {\n  mockUseSize.mockImplementation(() => ({ width: 100, height: 20 }))\n})\n```\n\nDOM 속성 모킹 (scrollWidth/clientWidth):\n\n```typescript\nbeforeEach(() => {\n  Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {\n    configurable: true,\n    get() {\n      return 150\n    },\n  })\n})\n\nafterEach(() => {\n  // 원래 속성 복원\n})\n```\n\nDOM 측정이 복잡하면 Playwright/Cypress로 통합 테스트 고려."},{"i":"514","k":[],"c":"2025-12-28","m":"2025-12-28","b":"ResizeObserver Mock (Vitest, ES Module 환경)\n\n`vi.hoisted()`로 모킹 함수 미리 선언 후 `vi.mock()`에서 사용:\n\n```typescript\nimport { vi } from 'vitest'\n\nconst { mockResizeObserver, MockResizeObserver } = vi.hoisted(() => {\n  let observers: { callback: ResizeObserverCallback; observer: any }[] = []\n\n  const MockResizeObserver = vi.fn().mockImplementation((callback) => {\n    const observer = {\n      observe: vi.fn(),\n      unobserve: vi.fn(),\n      disconnect: vi.fn(),\n    }\n    observers.push({ callback, observer })\n    return observer\n  })\n\n  return {\n    MockResizeObserver,\n    mockResizeObserver: {\n      triggerResize: (entries: ResizeObserverEntry[], index = 0) => {\n        observers[index]?.callback(entries, observers[index].observer)\n      },\n      reset: () => {\n        observers = []\n      },\n    },\n  }\n})\n\nvi.mock('resize-observer-polyfill', () => ({ default: MockResizeObserver }))\n\n// 테스트에서 사용\nbeforeEach(() => mockResizeObserver.reset())\n\nit('should handle resize', () => {\n  const mockEntry = {\n    target: document.createElement('div'),\n    contentRect: { width: 100 },\n  }\n  mockResizeObserver.triggerResize([mockEntry])\n})\n```\n\n핵심: ES Module에서는 `vi.hoisted()` 필수. 여러 observer는 배열로 추적."},{"i":"513","k":[],"c":"2025-12-28","m":"2025-12-28","b":"dayjs로 특정 날짜가 포함된 주의 모든 날짜 가져오기\n\n```javascript\nimport dayjs from 'dayjs'\nimport isoWeek from 'dayjs/plugin/isoWeek'\ndayjs.extend(isoWeek)\n\n// startOf('week') → 일요일 시작\n// startOf('isoWeek') → 월요일 시작 (캘린더 UI에 주로 사용)\nfunction getWeekDays(date, iso = true) {\n  const start = dayjs(date).startOf(iso ? 'isoWeek' : 'week')\n  return Array.from({ length: 7 }, (_, i) =>\n    start.add(i, 'day').format('YYYY-MM-DD')\n  )\n}\n\ngetWeekDays('2025-10-28') // ['2025-10-27', ..., '2025-11-02']\n```"},{"i":"512","k":[],"c":"2025-12-28","m":"2025-12-28","b":"JavaScript 부동소수점 오차 - `0.1 + 0.2 = 0.30000000000000004`\n\nIEEE 754 64비트 부동소수점 표준. 0.1은 이진법으로 무한 반복(`0.0001100110011...`)이라 근사치 저장됨.\n\n```javascript\n// 해결책\n;(1.1 * 10 + 0.1 * 10) / 10 // 정수 변환\nparseFloat((1.1 + 0.1).toFixed(1)) // toFixed\nMath.abs(1.1 + 0.1 - 1.2) < Number.EPSILON // 비교 시\n// 정밀 계산: decimal.js, big.js, bignumber.js\n```\n\nJS만의 문제 아님. Python, Java, C++ 등 IEEE 754 사용하는 모든 언어에서 동일."},{"i":"511","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Drizzle ORM에서 IN 절 사용\n\n```typescript\nimport { inArray } from 'drizzle-orm'\n\nconst authorIds = [1, 2, 3]\n\n// SELECT * FROM authors WHERE id IN (1, 2, 3)\nconst authors = await db\n  .select()\n  .from(schema.authors)\n  .where(inArray(schema.authors.id, authorIds))\n\n// 빈 배열 처리\nconst result =\n  authorIds.length > 0\n    ? await db\n        .select()\n        .from(schema.authors)\n        .where(inArray(schema.authors.id, authorIds))\n    : []\n```"},{"i":"510","k":[],"c":"2025-12-28","m":"2025-12-28","b":"캘린더 이벤트 겹침 처리 알고리즘\n\n겹치는 이벤트들을 나란히 배치하기 위한 레인 할당:\n\n```typescript\ninterface Event {\n  id: string\n  start: number\n  end: number\n}\ninterface PositionedEvent extends Event {\n  lane: number\n  totalLanes: number\n}\n\nfunction calculatePositions(events: Event[]): PositionedEvent[] {\n  // 1. 시작 시간 순 정렬\n  const sorted = [...events].sort((a, b) => a.start - b.start || a.end - b.end)\n\n  // 2. 겹침 그룹 찾기 + 레인 할당\n  const result: PositionedEvent[] = []\n  let group: Event[] = []\n  let groupEnd = 0\n\n  sorted.forEach((event) => {\n    if (group.length === 0 || event.start < groupEnd) {\n      group.push(event)\n      groupEnd = Math.max(groupEnd, event.end)\n    } else {\n      processGroup(group, result)\n      group = [event]\n      groupEnd = event.end\n    }\n  })\n  if (group.length) processGroup(group, result)\n\n  return result\n}\n\nfunction processGroup(group: Event[], result: PositionedEvent[]) {\n  const activeLanes: number[] = []\n\n  group.forEach((event) => {\n    // 사용 가능한 가장 낮은 레인 찾기\n    let lane = activeLanes.findIndex((end) => end <= event.start)\n    if (lane === -1) lane = activeLanes.length\n\n    activeLanes[lane] = event.end\n    result.push({ ...event, lane, totalLanes: 0 })\n  })\n\n  // totalLanes 업데이트\n  const maxLane =\n    Math.max(...result.slice(-group.length).map((e) => e.lane)) + 1\n  result.slice(-group.length).forEach((e) => (e.totalLanes = maxLane))\n}\n\n// CSS 적용: left = lane/totalLanes * 100%, width = 100%/totalLanes\n```\n\n- [FullCalendar](https://github.com/fullcalendar/fullcalendar)\n- [Interval Scheduling](https://en.wikipedia.org/wiki/Interval_scheduling)"},{"i":"509","k":[],"c":"2025-12-28","m":"2026-01-07","b":"RSC에서 날짜 처리: 쿠키 기반 타임존\n\n문제: Date 객체 직렬화, 서버/클라이언트 타임존 불일치, 하이드레이션 FOUT\n\n해결: 서버에서 타임존 적용해서 렌더링\n\n```javascript\n// middleware.js - 타임존 감지\nexport function middleware(request) {\n  const timezone =\n    request.cookies.get('user-timezone')?.value ||\n    request.geo?.timezone || // Vercel\n    'UTC'\n\n  const response = NextResponse.next()\n  response.headers.set('x-user-timezone', timezone)\n  return response\n}\n\n// 서버 컴포넌트\nconst getUserTimezone = cache(() => {\n  return headers().get('x-user-timezone') || 'UTC'\n})\n\n// 클라이언트 - 타임존 자동 감지 후 쿠키 저장\nuseEffect(() => {\n  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone\n  document.cookie = `user-timezone=${tz}; path=/; max-age=31536000`\n}, [])\n```\n\n첫 방문은 인프라 추정값 사용, 이후 정확한 타임존 적용. FOUT 없음.\n\n---\n\n대안: `useSyncExternalStore` (client component 전용)\n\n```tsx\n'use client'\n\nconst timezoneStore = {\n  getSnapshot: () => Intl.DateTimeFormat().resolvedOptions().timeZone,\n  getServerSnapshot: () => 'UTC',\n  subscribe: () => () => {},\n}\n\nfunction useTimezone() {\n  return useSyncExternalStore(\n    timezoneStore.subscribe,\n    timezoneStore.getSnapshot,\n    timezoneStore.getServerSnapshot\n  )\n}\n```\n\n서버: UTC → 클라이언트: 실제 타임존. 하이드레이션 에러 없음, 대신 FOUT 발생."},{"i":"508","k":[],"c":"2025-12-28","m":"2025-12-28","b":"대용량 리스트에서 selected 아이템 조회 최적화\n\nO(n×m) → O(n+m)로 개선: Map으로 인덱싱\n\n```typescript\n// 기존: 매번 find (느림)\nselected.map((key) => items.find((item) => item.key === key))\n\n// 개선: Map 인덱싱 (빠름)\nconst itemsMap = new Map(items.map((item) => [item.key, item]))\nselected.map((key) => itemsMap.get(key)).filter(Boolean)\n```\n\nReact에서 백그라운드 인덱싱:\n\n```typescript\nfunction useItemsIndex(items: Item[]) {\n  const [map, setMap] = useState(new Map())\n  const [ready, setReady] = useState(false)\n\n  useEffect(() => {\n    // 청크 단위로 처리하여 UI 블로킹 방지\n    const newMap = new Map(items.map((item) => [item.key, item]))\n    setMap(newMap)\n    setReady(true)\n  }, [items])\n\n  return { map, ready }\n}\n```\n\n10만개 이상이면 Web Worker 고려."},{"i":"507","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Tumblr 테마 Vue → Web Components 마이그레이션\n\nNPF 데이터는 `<script type=\"application/json\" data-npf>`에 저장. 컴포넌트에서 `closest`로 포스트 컨테이너 찾아서 참조 (React Context 패턴과 유사).\n\n```html\n<div id=\"{PostID}\" data-type=\"{PostType}\">\n  {block:Text}\n  <script type=\"application/json\" data-npf>\n    {NPF}\n  </script>\n  <tumblr-npf-media></tumblr-npf-media>\n  <tumblr-npf-text></tumblr-npf-text>\n  {/block:Text}\n</div>\n```\n\n```javascript\n// 데이터 참조\nconst post = this.closest('[id][data-type]')\nconst npf = JSON.parse(post.querySelector('script[data-npf]').textContent)\n\n// 렌더링 - createElement 사용 (Tumblr 템플릿 ${} 충돌 회피)\nconst img = document.createElement('img')\nimg.src = imageURL\nthis.appendChild(img)\n```\n\n제약: `{NPF}`는 Text 포스트에서만 사용 가능. Photo는 기존 Tumblr 변수 사용."},{"i":"506","k":[],"c":"2025-12-26","m":"2025-12-26","b":"React에서 iframe 내부에 컴포넌트 렌더링\n\n```tsx\n// react-frame-component 사용 (권장)\nimport Frame from 'react-frame-component'\n;<Frame head={<style>{`body { margin: 0; }`}</style>}>\n  <MyComponent />\n</Frame>\n\n// 직접 구현: createPortal + contentDocument\nfunction IframeRenderer({ children }) {\n  const iframeRef = useRef<HTMLIFrameElement>(null)\n  const [mountNode, setMountNode] = useState<HTMLElement | null>(null)\n  useEffect(() => {\n    const iframe = iframeRef.current\n    const handleLoad = () => setMountNode(iframe?.contentDocument?.body ?? null)\n    iframe?.addEventListener('load', handleLoad)\n    if (iframe?.contentDocument?.readyState === 'complete') handleLoad()\n    return () => iframe?.removeEventListener('load', handleLoad)\n  }, [])\n  return (\n    <>\n      <iframe ref={iframeRef} />\n      {mountNode && createPortal(children, mountNode)}\n    </>\n  )\n}\n```\n\n> [!WARNING]\n> iframe 내부는 부모 CSS 미적용 (스타일 별도 주입 필요), 이벤트 버블링 안 됨. 단순 스타일 격리 목적이면 Shadow DOM 고려."},{"i":"505","k":[],"c":"2025-12-26","m":"2025-12-26","b":"Jest setupFiles vs setupFilesAfterEnv - 실행 시점이 다르다.\n\n- **setupFiles**: 테스트 프레임워크 설치 **전**. Jest 전역 객체 없음. 환경 변수, 폴리필용.\n- **setupFilesAfterEnv**: 테스트 프레임워크 설치 **후**. `jest.setTimeout()`, 커스텀 matcher, 전역 `beforeEach`용.\n\n`process.env`는 setupFiles에서. 모듈이 import 시점에 환경 변수를 읽기 때문에 setupFilesAfterEnv에서 설정하면 이미 늦다.\n\n```js\n// jest.config.js\n{ setupFiles: ['./jest.env.js'], setupFilesAfterEnv: ['./jest.setup.js'] }\n\n// jest.env.js - 환경 변수\nprocess.env.API_URL = 'http://test-api.example.com'\n\n// jest.setup.js - Jest API 활용\nimport '@testing-library/jest-dom'\nbeforeEach(() => { jest.clearAllMocks() })\n```"},{"i":"503","k":[],"c":"2025-12-26","m":"2025-12-26","b":"yalc - 로컬 Node 모듈을 다른 프로젝트에서 바로 테스트. npm link보다 문제 적음 (파일 복사 방식).\n\n```bash\nnpm i -g yalc\n\n# 패키지에서\nyalc publish          # ~/.yalc에 저장\nyalc push             # 연결된 모든 프로젝트에 반영\nyalc publish --push   # 둘 다\n\n# 앱에서\nyalc add my-module\nyalc remove my-module && npm install  # 정리\n```\n\nwatch 모드: `\"dev\": \"tsup src/index.ts --watch --onSuccess 'yalc push'\"`\n\n`.gitignore`: `.yalc`, `yalc.lock`"},{"i":"501","k":[],"c":"2025-12-26","m":"2025-12-26","b":"tsconfig 핵심: target, lib, module\n\n```\nTypeScript (.ts)\n    ↓\nlib: 타입 체크 시 뭘 알고 있나? (Promise, Map 등)\n    ↓\ntarget: 문법을 얼마나 낮출 건가? (ES2020 → ?. 그대로, ES2019 → 삼항연산자로)\n    ↓\nmodule: import/export를 뭘로? (CommonJS → require, ESNext → import)\n    ↓\nJavaScript (.js)\n```\n\ntarget vs lib 분리 이유: 문법(syntax)과 API(runtime)는 다름\n\n- `?.` → target이 변환 (문법)\n- `Promise` → 폴리필이 해결 (API)\n\n```json\n{\n  \"target\": \"ES2019\", // 하위호환 (optional chaining 이전)\n  \"module\": \"ESNext\", // 트리쉐이킹 가능\n  \"lib\": [\"ES2020\", \"DOM\"], // 타입은 넉넉하게\n  \"moduleResolution\": \"Node\"\n}\n```\n\n> [!CAUTION]\n> TS 버전 올리면서 target 그대로 두면 Webpack4 같은 구형 번들러에서 파싱 실패할 수 있음."},{"i":"500","k":[],"c":"2025-12-26","m":"2025-12-26","b":"MDIR 스타일 인터페이스 개발 - SQLite DB를 탐색하는 도구\n\n1. [Neovim Telescope](https://github.com/nvim-telescope/telescope.nvim) 순수 Neovim 솔루션. `:StoryBrowse`, `:StorySearch`\n2. [VS Code 확장](https://code.visualstudio.com/api) Activity Bar + Tree View + Quick Pick\n3. [Bun Single-file Executable](https://bun.sh/docs/bundler/executables) `--compile`로 DB + 웹서버 + 프론트엔드 단일 파일 (~100MB)"},{"i":"499","k":[],"c":"2025-12-26","m":"2025-12-26","b":"TypeScript 중첩 객체 타입 부분 수정\n\n```typescript\ninterface O {\n  actions: { a: string; b: number }\n}\n\n// 중첩 속성 Optional로 변경\ntype MakeNestedOptional<T, K extends keyof T, OK extends keyof T[K]> = Omit<\n  T,\n  K\n> & {\n  [P in K]: Omit<T[K], OK> & Partial<Pick<T[K], OK>>\n}\ntype Result = MakeNestedOptional<O, 'actions', 'b'> // { actions: { a: string; b?: number } }\n\n// 중첩 속성 타입 오버라이드\ntype OverrideNested<T, K extends keyof T, Override> = Omit<T, K> & {\n  [P in K]: Omit<T[K], keyof Override> & Override\n}\ntype Result2 = OverrideNested<O, 'actions', { b: boolean }> // { actions: { a: string; b: boolean } }\n```\n\n한두 군데만 쓸 거면 그냥 손으로 타입 작성이 더 명확함. 유틸리티 타입은 반복 사용할 때만 가치.\n\n```typescript\n// 라이브러리 객체 변이 주의\n// ❌ info.actions.onDownload = undefined\n// ✅ const modifiedInfo = { ...info, actions: { ...info.actions, onDownload: undefined } }\n```"},{"i":"498","k":[],"c":"2025-12-26","m":"2025-12-26","b":"증기 계란 조리기: 계란이 많을수록 물을 적게 넣는 이유\n\n```\n계란 1개 (완숙): 117ml\n계란 6개 (완숙): 96ml\n```\n\n증기 조리 방식은 물의 양 = 조리 시간. 계란이 많으면:\n\n- 서로 열을 공유/유지 (\"집단 난방\" 효과)\n- 증기가 좁은 공간에 집중되어 효율 ↑\n- 열 손실 표면적이 상대적으로 ↓\n\n물에 삶는 것과 달리, 증기 조리는 배치 밀도에 따라 열 효율이 크게 달라짐."},{"i":"497","k":[],"c":"2025-12-26","m":"2025-12-26","b":"styled-components에서 `&-header` 같은 BEM 스타일 자식 선택자를 HTML에서 참조하는 방법? **없다.**\n\n```tsx\n// ❌ 해시된 클래스명과 매칭 안됨\nconst Container = styled.div`\n  &-header { color: red; }\n`\n\n// ✅ 방법 1: 일반 클래스 선택자\nconst Container = styled.div`\n  .header { color: red; }\n`\n<Container><div className=\"header\">Header</div></Container>\n\n// ✅ 방법 2: Styled 컴포넌트 변수로 선언 (추천)\nconst Header = styled.div`color: red;`\nconst Container = styled.div`\n  ${Header} { margin-bottom: 20px; }\n`\n```"},{"i":"496","k":[],"c":"2025-12-21","m":"2026-01-27","b":"React Children API - 가능하지만 비추천\n\n> [!WARNING]\n>\n> 암묵적 의존성, 타입 안전성 부족, 매 렌더 트리 순회, 예측 불가능\n\n## 재귀 순회\n\n```tsx\nfunction traverseReactNode(children: ReactNode, callback, typeToMatch?) {\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return\n    if (child.type === Fragment) {\n      traverseReactNode(child.props.children, callback, typeToMatch)\n      return\n    }\n    if (child.type === typeToMatch) callback(child)\n    if (child.props?.children) {\n      traverseReactNode(child.props.children, callback, typeToMatch)\n    }\n  })\n}\n```\n\n## 동적 래핑\n\n```tsx\nconst renderChildren = (children) => {\n  const elements = React.Children.toArray(children)\n  const hasLink = elements.some(\n    (el) => React.isValidElement(el) && el.props.url\n  )\n  return hasLink ? children : <ul>{children}</ul>\n}\n// toArray는 string, number도 포함 → isValidElement 체크 필수\n```\n\n## 대안: Compound Component\n\n```tsx\n// ❌ 마법처럼 동작 (예측 불가)\n<Tabs>{/* 어디에 넣든 Tab 찾아줌 */}</Tabs>\n\n// ✅ Compound Component\n<Tabs.Root>\n  <Tabs.List>\n    <Tabs.Trigger value=\"a\">A</Tabs.Trigger>\n  </Tabs.List>\n  <Tabs.Content value=\"a\">Content</Tabs.Content>\n</Tabs.Root>\n```\n\nReact 팀도 2021년부터 Children API 사용 권장하지 않음: \"Using Children is uncommon and can lead to fragile code\"\n\n역사적 배경: 2013년엔 Context API도 없었음. \"선언형\"이라면서 Children API로 명령형 트리 순회 제공하는 이중성.\n\n---\n\n- [react-children-utilities](https://github.com/fernandopasik/react-children-utilities) - deepMap, deepFind, deepFilter"},{"i":"495","k":["react","concurrent"],"c":"2025-12-21","m":"2025-12-21","b":"React 19 Concurrent 훅 - `useTransition`, `useOptimistic` vs React Query\n\n**useTransition**\n\n```tsx\nconst [isPending, startTransition] = useTransition()\n\nfunction handleFilter(value: string) {\n  startTransition(async () => {\n    const data = await fetchData(value)\n    setResults(data)\n  })\n}\n```\n\n- `startTransition`으로 감싼 state 업데이트는 **비긴급(non-urgent)**으로 처리\n- 급한 업데이트(타이핑, 클릭 피드백)를 먼저 처리하고, transition 작업은 뒤로 미룸\n- `isPending`으로 로딩 상태 확인, 기존 UI 유지하면서 백그라운드에서 새 UI 준비\n\n**useOptimistic**\n\n```tsx\nconst [optimisticItems, addOptimistic] = useOptimistic(\n  items,\n  (current, newItem) => [...current, newItem]\n)\n\nasync function handleAdd(item: Item) {\n  addOptimistic(item) // 즉시 UI 반영\n  await saveToServer(item) // 실패하면 자동 rollback\n}\n```\n\n- 서버 응답 전에 UI 먼저 업데이트, 실패 시 자동 복구\n- 좋아요 버튼, 장바구니 추가 같은 인터랙션에 적합\n\n---\n\n**현실: Query가 이미 너무 편함**\n\n```tsx\nconst { data, isPending } = useQuery({\n  queryKey: ['items', filter],\n  queryFn: () => fetchItems(filter),\n})\n\nuseMutation({\n  mutationFn: addItem,\n  onMutate: async (newItem) => {\n    const previous = queryClient.getQueryData(['items'])\n    queryClient.setQueryData(['items'], (old) => [...old, newItem])\n    return { previous }\n  },\n  onError: (err, _, context) => {\n    queryClient.setQueryData(['items'], context.previous)\n  },\n})\n```\n\n캐싱, 리페치, devtools, stale-while-revalidate까지 한 방에 해결. 팀에서 이미 쓰고 있으면 \"굳이?\" 됨.\n\n**useTransition이 의미 있는 지점** - 무거운 클라이언트 연산, Next.js Server Actions 조합\n\n```tsx\n<form action={(formData) => {\n  startTransition(async () => {\n    await createItem(formData)\n    router.refresh()\n  })\n}}>\n```"},{"i":"492","k":["sqlite","serverless","turso"],"c":"2025-04-06","m":"2025-04-06","b":"Serverless 환경에서 SQLite 사용 불가 → **Turso** 도입\n\nVercel 같은 플랫폼에서 SQLite 공식 미지원. 읽기 전용 데이터 파일도 최근 환경에서 에러 발생.\n\n> [!TIP]\n> [Turso](https://turso.tech/) - LibSQL 기반 SQLite-compatible serverless DB. 기존 쿼리 그대로 사용 가능.\n\nJSON 기반 대안:\n- [AlaSQL](https://github.com/AlaSQL/alasql) - SQL 문법으로 JSON 쿼리\n- [JSONata](https://github.com/jsonata-js/jsonata) - 선언적 JSON 질의"},{"i":"491","k":[],"c":"2025-04-01","m":"2026-07-27","b":"`llms.txt`는 웹사이트나 애플리케이션이 자신이 사용하는 LLM(대규모 언어 모델) 및 관련 설정에 대해 명시적으로 문서화할 수 있는 포맷이다.\n\n아래 링크들은 `llms.txt` 포맷이 실제로 어떻게 사용되고 있는지 참고한 자료들. 각 사이트는 자신들의 문서를 `llms.txt`에 구조적으로 명시하고 있다.\n\n---\n\n- [llms-txt](https://llmstxt.org/)\n- [effect.website](https://effect.website/llms.txt)\n- [hono.dev](https://hono.dev/llms.txt)"},{"i":"490","k":["visibility-state"],"c":"2025-03-25","m":"2025-03-25","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/nkzw-tech/use-visibility-state/\">\n  use-visibility-state\n</QuoteLink>\n\n```ts\nconst isVisible = document.visibilityState === 'visible'\nconst isHidden = document.visibilityState === 'hidden'\n\ndocument.addEventListener('visibilitychange', onChange)\n```\n\n---\n\n```ts\n/**\n * `visibilitychange` 이벤트는 정상적인 탭 전환 시에는 잘 작동하지만, 시스템 슬립, 화면 잠금, 또는 브라우저가 백그라운드에서 복귀할 때는 누락될 수 있다.\n * 그래서 수동으로 처리되는 부분이 필요\n */\ndocument.addEventListener('mousemove', setVisible)\ndocument.addEventListener('keydown', setVisible)\n```\n\n---\n\n```ts\nconst [isVisible, setIsVisible] = useState(true)\n\nuseEffect(() => {\n  const onChange = () => {\n    const newState = document.visibilityState !== 'hidden'\n\n    if (newState !== isVisible) {\n      setIsVisible(newState)\n    }\n  }\n\n  // addEventListener\n\n  return () => {\n    // removeEventListener\n  }\n}, [isVisible])\n```\n\n---\n\n- [`document.visibilityState`](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState)"},{"i":"488","k":["esm"],"c":"2025-03-08","m":"2025-03-08","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://stackblitz.com/edit/stackblitz-starters-qebq7z3z?file=index.html\">\n  ES Module Shims 2.0\n</QuoteLink>\n\n---\n\n- [Announcing ES Module Shims 2.0](https://guybedford.com/es-module-shims-2.0)\n- [ESM&gt;CDN](https://esm.sh/)\n- [Running React 19 From a CDN and using esm.sh](https://peterkellner.net/2024/05/10/running-react-19-from-a-cdn-and-using-esm.sh/)"},{"i":"485","k":["parallel","serial"],"c":"2025-03-05","m":"2025-03-05","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://stackblitz.com/edit/vitest-dev-vitest-jtqzu5sy?file=test%2Fbasic.test.ts\">\n  TaskRunner Test\n</QuoteLink>\n\n```ts\ntype Task = (callback: (result: string) => void) => void\n\ntype TasksCallback = (results: string[]) => void\n\nclass TaskRunner {\n  protected tasks: Task[] = []\n\n  protected results: Array<string> = []\n\n  addTask(task: Task) {\n    this.tasks.push(task)\n  }\n}\n\nclass ParallelTaskRunner extends TaskRunner {\n  run(callback: TasksCallback) {\n    const totalTasks = this.tasks.length\n\n    this.tasks.forEach((task) => {\n      task((result) => {\n        this.results.push(result)\n\n        if (this.results.size === totalTasks) {\n          callback([...this.results])\n        }\n      })\n    })\n  }\n}\n\nclass SerialTaskRunner extends TaskRunner {\n  index = 0\n\n  run(callback: TasksCallback) {\n    const executeTask = () => {\n      if (this.index >= this.tasks.length) {\n        callback([...this.results])\n        return\n      }\n\n      this.tasks[this.index]((result) => {\n        this.results.push(result)\n\n        this.index++\n\n        executeTask()\n      })\n    }\n\n    executeTask()\n  }\n}\n```\n\n- [Node.js script to run multiple commands in parallel · Muffin Man](https://muffinman.io/blog/node-script-to-run-multiple-commands-in-parallel/)[^485-1]\n\n---\n\n[^485-1]: [concurrently](https://github.com/open-cli-tools/concurrently)"},{"i":"484","k":["store","redux","pattern"],"c":"2025-03-05","m":"2026-08-02","b":"Redux 스타일 Store 직접 구현\n\n```ts\ntype Reducer<S, A> = (state: S, action: A) => S\ntype Listener<S> = (state: S) => void\n\ninterface Store<S, A> {\n  getState: () => S\n  subscribe: (listener: Listener<S>) => () => void\n  dispatch: (action: A) => A\n}\n\nfunction createStore<S, A>(\n  reducer: Reducer<S, A>,\n  preloadedState: S\n): Store<S, A> {\n  let currentState: S = preloadedState\n  let listeners: Listener<S>[] = []\n\n  function getState(): S {\n    return currentState\n  }\n\n  function subscribe(listener: Listener<S>): () => void {\n    listener(currentState)\n\n    listeners.push(listener)\n\n    return function unsubscribe() {\n      listeners = listeners.filter((l) => l !== listener)\n    }\n  }\n\n  function dispatch(action: A): A {\n    currentState = reducer(currentState, action)\n\n    listeners.forEach((listener) => {\n      listener(currentState)\n    })\n\n    return action\n  }\n\n  return {\n    subscribe,\n    getState,\n    dispatch,\n  }\n}\n\ntype Item = {\n  body: string\n}\n\ntype State = {\n  items: Item[]\n}\n\ntype Action = {\n  type: 'ADD'\n  payload: Item\n}\n\nconst store = createStore<State, Action>(\n  (state, action) => {\n    switch (action.type) {\n      case 'ADD':\n        return {\n          ...state,\n          items: state.items.concat(action.payload),\n        }\n      default:\n        return state\n    }\n  },\n  {\n    items: [],\n  }\n)\n\nstore.subscribe((state) => {\n  console.log(state.items)\n})\n\nstore.dispatch({\n  type: 'ADD',\n  payload: {\n    body: 'hello',\n  },\n})\n```"},{"i":"483","k":["typescript","Exclude"],"c":"2025-03-05","m":"2025-03-05","b":"```tsx\nimport * as React from 'react'\n\nexport type NonEmptyString<T> = Exclude<T, ''>\n\ntype Props<T> = {\n  src: NonEmptyString<T>\n}\n\nfunction Image<T extends string>({ src }: Props<T>) {\n  return <img src={src} alt=\"\" />\n}\n\nfunction App() {\n  return (\n    <>\n      {/* @ts-expect-error */}\n      <Image src=\"\" />\n\n      <Image src=\"ab\" />\n    </>\n  )\n}\n```"},{"i":"482","k":["typescript","union"],"c":"2025-03-05","m":"2025-03-05","b":"```tsx\nimport * as React from 'react'\nimport { IsEqual } from 'type-plus'\n\ntype ModalProps =\n  | {\n      variant: 'no-title'\n    }\n  | {\n      variant: 'title'\n      title: string\n    }\n\nexport const Modal = (props: ModalProps) => {\n  if (props.variant === 'no-title') {\n    return <div>No title</div>\n  } else {\n    type Test = IsEqual<\n      typeof props,\n      {\n        variant: 'title'\n        title: string\n      }\n    >\n\n    return <div>Title: {props.title}</div>\n  }\n}\n\nexport const Test = () => {\n  return (\n    <div>\n      <Modal variant=\"title\" title=\"Hello\" />\n      <Modal variant=\"no-title\" />\n\n      {/* @ts-expect-error */}\n      <Modal />\n      <Modal\n        variant=\"no-title\"\n        // @ts-expect-error\n        title=\"Oh dear\"\n      />\n    </div>\n  )\n}\n```"},{"i":"481","k":["typescript"],"c":"2025-03-05","m":"2025-03-05","b":"```ts\nimport * as React from 'react'\n\ntype Prettify<T> = {\n  [K in keyof T]: T[K]\n} & {}\n\ntype T = React.ComponentProps<'div'>\n\ntype P = Prettify<T>\n```"},{"i":"480","k":["typescript","generic","react"],"c":"2025-03-05","m":"2025-03-05","b":"React 제네릭 컴포넌트 패턴 - `<T extends Record<string, any>>`로 row/item 타입 추론\n\n```tsx\nimport * as React from 'react'\n\ntype Props<TRow> = {\n  rows: TRow[]\n  renderRow: (row: TRow, index: number) => React.ReactNode\n}\n\nconst Table = <TRow extends Record<string, any>>({\n  rows,\n  renderRow,\n}: Props<TRow>) => {\n  return (\n    <table>\n      <tbody>{rows.map((row, index) => renderRow(row, index))}</tbody>\n    </table>\n  )\n}\n\nfunction App() {\n  return (\n    <Table\n      rows={[{ name: 'lee' }]}\n      renderRow={(row, index) => (\n        <tr key={index}>\n          <td>{row.name}</td>\n        </tr>\n      )}\n    />\n  )\n}\n```\n\n```tsx\nimport * as React from 'react'\nimport { UseComboboxProps, useCombobox } from 'downshift'\n\nfunction Combobox<T extends Record<string, any>>(\n  props: UseComboboxProps<T> & {\n    renderItem: (item: T) => React.ReactNode\n  }\n) {\n  const combobox = useCombobox(props)\n\n  return (\n    <div>\n      <input\n        placeholder=\"구성원을 검색해주세요.\"\n        {...combobox.getInputProps()}\n      />\n      <div {...combobox.getMenuProps()}>\n        {combobox.isOpen &&\n          props.items.map((item, index) => (\n            <div key={item.id} {...combobox.getItemProps({ item, index })}>\n              {props.renderItem(item)}\n            </div>\n          ))}\n      </div>\n    </div>\n  )\n}\n\nfunction App() {\n  return (\n    <Combobox\n      items={[{ id: 1, name: 'eunsoo' }]}\n      itemToString={(item) => `${item?.id}`}\n      renderItem={(item) => {\n        return <div>{item.name}</div>\n      }}\n      onInputValueChange={({ inputValue }) => {\n        console.log(inputValue)\n      }}\n    />\n  )\n}\n```"},{"i":"477","k":["emoji"],"c":"2025-03-05","m":"2025-03-05","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/ealush/emoji-picker-react/blob/master/src/dataUtils/parseNativeEmoji.ts#L1\">\n  parseNativeEmoji.ts\n</QuoteLink>\n\n```ts\nexport function parseNativeEmoji(unified: string): string {\n  return unified\n    .split('-')\n    .map((hex) => String.fromCodePoint(parseInt(hex, 16)))\n    .join('')\n}\n```\n\n- `unified` 문자열을 `-`로 분리하여 개별 유니코드 코드 포인트를 얻는다.\n- 각 16진수 코드 포인트를 정수로 변환하고, 그에 해당하는 캐릭터를 반환한다.\n- 최종적으로 변환된 캐릭터들을 연결하여 하나의 문자열로 생성한다.\n\n---\n\n- [GitHub - iamcal/emoji-data: Easy to parse data and spritesheets for emoji](https://github.com/iamcal/emoji-data)"},{"i":"462","k":["typescript","verbatim-module-syntax"],"c":"2025-03-04","m":"2025-03-04","b":"```ts\nimport type { A } from 'a'\n```\n\n---\n\n- [GitHub - privatenumber/fix-verbatim-module-syntax: CLI tool to auto-fix verbatimModuleSyntax errors](https://github.com/privatenumber/fix-verbatim-module-syntax)\n- [TypeScript: TSConfig Reference - Docs on every TSConfig option](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax)\n- [Exploring TypeScript 5 features: Smaller, simpler, faster - LogRocket Blog](https://blog.logrocket.com/exploring-typescript-5-features-smaller-simpler-faster/#verbatimmodulesyntax)"},{"i":"461","k":["form-data","File"],"c":"2025-03-04","m":"2025-03-04","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://developer.mozilla.org/ko/docs/Web/API/File\">\n  File\n</QuoteLink>\n\n```ts\nconst emptyCsvFile = new File([''], 'default.csv', { type: 'text/csv' })\nconst emptyPngFile = new File([''], 'default.png', { type: 'image/png' })\n\nformData.append('product_coupons', emptyCsvFile)\nformData.append('product_imgs', emptyPngFile)\n\nconsole.assert(emptyCsvFile.size === 0)\n```\n\n빈 파일을 FormData에 첨부하여 전송하는 방법\n\n- `File` 객체를 사용하여 빈 파일을 생성할 수 있다.\n- 파일의 `MIME` 타입을 지정하여 타입에 맞는 빈 파일을 만들 수 있다.\n- 생성된 빈 파일을 `FormData` 객체에 추가하여 전송할 수 있다.\n\n이는 서버에 빈 형태의 파일을 전송할 때 유용하며, API 테스트 및 디폴트 값 설정에 활용될 수 있다."},{"i":"431","k":["ocr"],"c":"2025-03-03","m":"2025-03-03","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://simonwillison.net/2024/Mar/30/ocr-pdfs-images/\">\n  Running OCR against PDFs and images directly in your browser\n</QuoteLink>\n\n브라우저에서 PDF 및 이미지 파일에 대한 OCR 실행\n\n- [PDF.js](https://mozilla.github.io/pdf.js/)를 사용하여 PDF에서 이미지를 추출\n- [Tesseract OCR](https://github.com/tesseract-ocr/tesseract)로 추출된 이미지에서 텍스트를 인식\n- 직접 브라우저 상에서 OCR 작업을 실행"},{"i":"410","k":["next","next-sitemap"],"c":"2025-02-14","m":"2025-02-14","b":"```ts\ngetServerSideProps\n  |> getServerSideSitemapLegacy\n  |> withXMLResponseLegacy\n  |>\n    res.setHeader('Content-Type', 'text/xml')\n    res.write(content)\n    res.end()\n```\n\nhttps://github.com/iamvishnusankar/next-sitemap/blob/master/packages/next-sitemap/src/ssr/response.ts\n\nSSR에서 XML 형식의 사이트맵을 생성하기."},{"i":"386","k":["react"],"c":"2025-02-04","m":"2025-02-04","b":"```ts\nimport * as React from 'react'\n\ntype ContainerProps = {\n  children: typeof Body\n}\n\ntype BodyProps = {\n  id: string\n}\n\nfunction Container({ children }: ContainerProps) {\n  const id = '1234'\n\n  return <>{children({ id })}</>\n}\n\nexport function Body({ id }: BodyProps) {\n  return <>{id}</>\n}\n\nexport function Page() {\n  return <Container>{Body}</Container>\n}\n```\n\n컨테이너/프레젠테이션 패턴을 활용한 React 컴포넌트 구조.\n\n- 컨테이너 컴포넌트(`Container`)는 상태를 관리하고 데이터를 하위 컴포넌트에 전달함.\n- 프레젠테이션 컴포넌트(`Body`)는 데이터를 받아서 UI를 렌더링함.\n- 이 구조는 테스트를 쉽게 하고, 컴포넌트의 역할을 명확하게 분리함."},{"i":"385","k":["fp"],"c":"2025-02-02","m":"2025-02-02","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://janhesters.com/blog/unleash-javascripts-potential-with-functional-programming\">\nUnleash JavaScript's Potential with Functional Programming\n</QuoteLink>\n\n```ts\n/**\n * - 인자 수 확인: `curry` 함수는 전달된 함수 `fn`의 인자 수를 확인\n * - 인자 수가 충분한 경우: 만약 `args`의 길이가 `fn`의 인자 수 이상이면, `fn`을 호출하고 결과를 반환\n * - 인자 수가 부족한 경우: 그렇지 않으면 추가 인자(`moreArgs`)를 받을 수 있는 새 함수를 반환. 이 함수는 기존 인자(`args`)와 새로운 인자(`moreArgs`)를 합쳐 다시 `curry(fn)`을 호출하여 최종적으로 인자가 충분할 때까지 이 과정을 반복\n */\nconst curry =\n  (fn: Function) =>\n  (...args: any[]) =>\n    args.length >= fn.length\n      ? fn(...args)\n      : (...moreArgs: any[]) => curry(fn)(...args, ...moreArgs)\n\n/**\n * - `...fns: Function[]` 여러 개의 함수를 인자로 받는다\n * - 내부에서 `reduce` 메서드를 사용하여 함수 배열을 순회 초기값으로 `x`를 사용하며, 각 함수 `fn`을 차례로 호출하여 결과를 다음 함수로 전달\n */\nconst pipe =\n  (...fns: Function[]) =>\n  (x: any) =>\n    fns.reduce((acc, fn) => fn(acc), x)\n\n/**\n * - `...fns`: 여러 개의 함수를 매개변수로 받음\n * - `(x)`: 초기값 `x`를 인자로 받아 결과를 리턴\n * - `fns.reduceRight(...)`: 배열의 오른쪽부터 왼쪽으로 각 함수를 적용합니다. `y`는 이전 함수의 결과이며, `f`는 현재 함수\n * - `f(y)`: 현재 함수 `f`를 이전 함수의 결과 `y`에 적용\n */\nconst compose =\n  (...fns) =>\n  (x) =>\n    fns.reduceRight((y, f) => f(y), x)\n```\n\n---\n\n1. **함수 조합(Composition)**: 작은 함수들을 조합하여 새로운 함수를 만들며, 예를 들어 `compose(f, g, h)`는 `f(g(h(x)))`로 실행\n2. **`compose`**: 오른쪽에서 왼쪽으로 함수를 실행하며, `compose(square, double)(3)`은 36을 반환\n3. **`pipe`**: 왼쪽에서 오른쪽으로 함수를 실행하여, `pipe(double, square)(3)`의 결과는 36\n4. **커링(Currying)**: `f(a, b, c)`를 `f(a)(b)(c)` 형태로 변환하고, 예를 들어 `add(1)(2)(3)`의 결과는 6\n5. **부분 적용(Partial Application)**: 일부 인자만 미리 적용하여 새로운 함수를 만들 수 있으며, `const double = multiply(2, _)`로 정의\n6. **포인트-프리 스타일**: 변수를 사용하지 않고 함수를 조합하여 작성하며, 예를 들면 `compose(square, double)`와 같은 형태\n7. **데이터 마지막 원칙(Data Last)**: 데이터를 마지막 인자로 배치하여 조합성을 높이는데, 예를 들어 `const halve = divideDataLast(2)`와 같이 사용\n\n---\n\n- [Higher-Order Functions](https://eloquentjavascript.net/05_higher_order.html)\n- [How to compose JavaScript functions that take multiple parameters (the epic guide)](https://jrsinclair.com/articles/2024/how-to-compose-functions-that-take-multiple-parameters-epic-guide/)\n- [JavaScript function composition: What’s the big deal?](https://jrsinclair.com/articles/2022/javascript-function-composition-whats-the-big-deal/)"},{"i":"383","k":["mp4","file-reader","hdlr","ffmpeg"],"c":"2025-01-31","m":"2025-01-31","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://techblog.lycorp.co.jp/ko/check-mp4-file-has-audio-using-filereader-in-front-end\">\n프런트엔드 영역에서 FileReader를 이용해 MP4 파일 내 오디오 존재 여부 확인하기\n</QuoteLink>\n\n- 프런트엔드(클라이언트)에서 MP4 파일의 오디오 존재 여부를 확인하기\n- 브라우저 API 사용 시 호환성 문제 발생 (Chrome, Safari, Firefox 각각 다른 API 사용)\n  - MP4 파일의 구조를 분석하는 방법으로 방향 전환\n    - [hdlr](https://developer.apple.com/documentation/quicktime-file-format/metadata_handler_atom) 아톰에서 오디오 존재 여부를 나타내는 `Component subtype` 필드가 `soun`일 경우 오디오가 존재할 것임.\n    - MP4 파일의 특정 바이트를 선택적으로 요청하기 위해 [Range](https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Range) 필드 사용.\n    - 다양한 파일 크기에 따라 'soun'의 예상 위치를 조사하고, 적절한 범위 값 설정.\n- [FileReader API](https://developer.mozilla.org/en-US/docs/Web/API/FileReader)를 사용하여 서버에서 응답받은 바이너리 데이터를 읽고, 이를 분석해 오디오 여부 확인.\n  - 오디오 정보가 발견되지 않으면 추가 데이터 요청하여 확인 범위를 증가시켜 반복.\n\n---\n\n```ts\nawait ffmpeg.writeFile('input.mp4', await fetchFile(file))\nawait ffmpeg.ffprobe(['-i', 'input.mp4', '-show_streams', '-o', 'output.txt'])\n\nconst data = await ffmpeg.readFile('output.txt')\n```\n\n...하지만 [ffmpeg](https://www.ffmpeg.org/)의 중요성을 깨달았다."},{"i":"382","k":["dsl","permissions","typescript"],"c":"2025-01-29","m":"2025-01-29","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://www.figma.com/blog/how-we-rolled-out-our-own-permissions-dsl-at-figma/\">\nHow we built a custom permissions DSL at Figma\n</QuoteLink>\n\nFigma의 권한 관리 DSL - JSON 직렬화 가능한 DSL로 정책 표현, TypeScript 기반 평가 엔진 구현.\n\n기존 문제: 불필요한 복잡성, 계층적 권한 비효율, DB 부하, 여러 진실 소스\n\n```ts\ntype ExpressionDef = BinaryExpressionDef | OrExpressionDef | AndExpressionDef\n\n// 바이너리 표현식: [필드, 연산자, 값]\nconst binaryExpression = ['file.id', '<>', null] satisfies ExpressionDef\n\n// AND/OR 조합\nconst andExpression = {\n  and: [\n    ['file.id', '<>', null],\n    ['team.permission', '=', 'open'],\n  ],\n} satisfies ExpressionDef\n```"},{"i":"381","k":["class","pattern","typescript"],"c":"2025-01-28","m":"2025-01-28","b":"`static create` 패턴 - 생성자 대신 정적 메서드로 객체 생성\n\n언제 사용?\n- 생성 로직이 복잡하거나 유효성 검사 필요\n- 생성 실패 시 null/Result 반환 (생성자는 항상 인스턴스 반환)\n- 팩토리 패턴, 싱글톤\n\n```typescript\nclass User {\n  private constructor(private readonly name: string) {}\n\n  // 유효성 검사 + 실패 시 null 반환\n  static create(name: string): User | null {\n    if (!name || name.length < 3) return null\n    return new User(name)\n  }\n}\n\n// 팩토리 패턴\nclass Shape {\n  static create(type: 'circle' | 'rect', size: number): Shape {\n    return type === 'circle' ? new Circle(size) : new Rectangle(size)\n  }\n}\n\n// 싱글톤\nclass Config {\n  private static instance: Config\n  private constructor() {}\n  static create() {\n    return Config.instance ??= new Config()\n  }\n}\n```\n\n> [!TIP]\n> 단순 초기화만 필요하면 일반 생성자가 더 직관적. 복잡한 생성 로직에만 사용."},{"i":"380","k":["type"],"c":"2025-01-28","m":"2025-01-28","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://blog.jez.io/intro-elim/\">\nEvery type is defined by its intro and elim forms\n</QuoteLink>\n\n_Every type is defined by its intro and elim forms_\n\n- **Intro forms**: 타입의 인스턴스를 어떻게 \"생성\"하는지 정의.\n- **Elim forms**: 생성된 타입 인스턴스를 어떻게 \"사용\"하거나 \"해체\"할지 정의.\n\n타입을 정의할 때, 생성과 사용의 명확한 경계를 설정해서 **Intro/Elim 설계를 명시적으로 표현하기**\n\n```ts\nclass Rectangle {\n  private constructor(public width: number, public height: number) {}\n\n  static create(width: number, height: number) {\n    if (width <= 0 || height <= 0) {\n      return null\n    }\n\n    return new Rectangle(width, height)\n  }\n\n  getArea() {\n    return this.width * this.height\n  }\n}\n\nconst rect = Rectangle.create(10, 20)\n\nif (rect) {\n  console.log(rect.getArea())\n}\n```\n\n---\n\n_Types are not their elim forms_\n\n- `interface`와 `class`를 통해 **Intro/Elim 모두를 명시적으로 정의**\n- 팩토리 메서드 같은 패턴을 활용해 생성 방식을 추상화\n\n```ts\ninterface Shape {\n  getArea(): number\n}\n\nclass Circle implements Shape {\n  constructor(private radius: number) {}\n\n  getArea() {\n    return Math.PI * this.radius ** 2\n  }\n}\n\nclass Rectangle implements Shape {\n  constructor(private width: number, private height: number) {}\n\n  getArea() {\n    return this.width * this.height\n  }\n}\n\nfunction createShape(type: 'circle' | 'rectangle', ...args: number[]) {\n  if (type === 'circle' && args.length === 1) {\n    return new Circle(args[0])\n  }\n\n  if (type === 'rectangle' && args.length === 2) {\n    return new Rectangle(args[0], args[1])\n  }\n\n  return null\n}\n\nconst shape = createShape('circle', 10)\n\nif (shape) {\n  console.log(shape.getArea())\n}\n```"},{"i":"377","k":["github-actions","monorepo"],"c":"2025-01-19","m":"2025-01-19","b":"- https://stackoverflow.com/questions/58136102/deploy-individual-services-from-a-monorepo-using-github-actions\n- https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#example-including-paths\n\n최소한 하나의 경로가 `paths` 필터의 패턴과 일치하면 워크플로가 실행\n\n```yaml\non:\n  push:\n    paths:\n      - '**.js'\n```"},{"i":"376","k":["typescript","json"],"c":"2025-01-19","m":"2025-01-19","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/sindresorhus/type-fest/blob/49605b9770a344bac28ce8504045541dc0bdbd4b/source/basic.d.ts#L38-L68\">\n  basic.d.ts\n</QuoteLink>\n\n```ts\ntype JsonPrimitive = string | number | boolean | null\ntype JsonObject = { [Key in string]: JsonValue } & {\n  [Key in string]?: JsonValue | undefined\n}\ntype JsonArray = JsonValue[] | readonly JsonValue[]\ntype JsonValue = JsonPrimitive | JsonObject | JsonArray\n```\n\n예전에 JSON 타입 정의가 필요해서 찾아봤던 내용\n\n- `JsonObject`: 문자열 키와 JsonValue 타입의 값을 가진 JSON 객체를 정의\n- `JsonArray`: JsonValue 타입의 요소를 포함하는 JSON 배열을 정의\n- `JsonPrimitive`: 문자열, 숫자, 불린, 또는 null과 같은 유효한 JSON 기본 값을 정의\n- `JsonValue`: 유효한 JSON 값을 나타내며, JsonPrimitive, JsonObject, 또는 JsonArray로 구성\n\n---\n\n- https://github.com/sindresorhus/type-fest/blob/main/source/jsonify.d.ts"},{"i":"375","k":["active-element","dom","focus"],"c":"2025-01-18","m":"2026-08-02","b":"`document.activeElement` — 현재 포커스된 요소\n\n```javascript\nconst el = document.activeElement\n\nif (el.tagName === 'INPUT') console.log(el.value)\n\n// 모달 열릴 때 닫기 버튼으로 포커스 이동\ndocument.querySelector('.modal .close-button').focus()\n```\n\n- [MDN - document.activeElement](https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement)"},{"i":"374","k":["test","jest"],"c":"2025-01-18","m":"2026-08-02","b":"https://www.emgoto.com/jest-partial-match/\n\n`objectContaining`·`arrayContaining` 은 `toEqual`·`toHaveBeenCalledWith` 안에 중첩해서 쓴다.\n\n```typescript\ntest('호출 인자 부분 매칭', () => {\n  const mockFunction = jest.fn()\n\n  mockFunction({ id: 1, name: 'Alice', tags: ['developer', 'designer'] })\n\n  expect(mockFunction).toHaveBeenCalledWith(\n    expect.objectContaining({\n      name: 'Alice',\n      tags: expect.arrayContaining(['designer']),\n    })\n  )\n})\n\ntest('배열 안 객체 부분 매칭', () => {\n  const receivedArray = [\n    { id: 1, name: 'Alice' },\n    { id: 2, name: 'Bob' },\n  ]\n\n  expect(receivedArray).toEqual(\n    expect.arrayContaining([\n      expect.objectContaining({ name: 'Alice' }),\n      expect.objectContaining({ id: 2 }),\n    ])\n  )\n})\n```"},{"i":"373","k":["router"],"c":"2025-01-18","m":"2025-01-18","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://programmingarehard.com/2025/01/13/maybe-dont-navigate-1.html/\">\nMaybe don't navigate(-1) in Remix/React Router\n</QuoteLink>\n\n- `navigate(-1)`의 위험성: 브라우저 히스토리에서 이전 위치로 이동, 앱 내부 네비게이션과 혼란을 초래할 수 있음\n- 대신 `Link` 컴포넌트의 [`state`](https://api.reactrouter.com/v7/interfaces/react_router.LinkProps.html#state) 속성을 활용하여 안전하게 앱 내에서의 \"Back\" 네비게이션 구현 가능\n- 사용자에게 현재 URL을 반환하는 커스텀 훅 `useCurrentURL` 구현 및 재사용의 용이성을 제공하는 `useBackNavigation` 훅 정의\n\n```tsx\nfunction PreserveStateLink(props) {\n  const location = useLocation()\n  const currentURL = location.pathname + location.search\n\n  return (\n    <Link state={{ back: currentURL }} {...props}>\n      {children}\n    </Link>\n  )\n}\n\nfunction BackLink() {\n  const navigate = useNavigate()\n  const location = useLocation()\n\n  const handleClick: LinkProps['onClick'] = (e) => {\n    const back = location.state?.back\n\n    if (back) {\n      e.preventDefault()\n\n      navigate(back)\n    }\n  }\n\n  return (\n    <Link to=\"/todos\" onClick={handleBack}>\n      Back\n    </Link>\n  )\n}\n```"},{"i":"371","k":["css"],"c":"2025-01-17","m":"2025-01-17","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/css-modules/css-modules/blob/master/docs/values-variables.md\">\nExporting values variables\n</QuoteLink>\n\n```css\n@value b from \"./b.module.css\";\n\n.root {\n  color: aquamarine;\n}\n\n.root :global(.b) {\n  text-decoration: line-through;\n}\n```\n\n\nCSS 모듈에서 변수를 값으로 내보내고 사용하는 방법\n\n- PostCSS와 `postcss-modules-values` 플러그인을 사용하여 CSS 모듈 내에서 변수 값 내보내기 지원\n- 색상 변수를 정의하는 파일 생성\n  - 변수 선언: `@value` 구문 사용\n- 다른 CSS 모듈 파일에서 해당 변수를 가져와서 사용\n  - 변수 가져오기 및 CSS 클래스에 적용"},{"i":"368","k":["url"],"c":"2025-01-16","m":"2025-01-16","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://developer.mozilla.org/en-US/docs/Web/API/URL/parse_static\">\nURL: parse() static method\n</QuoteLink>\n\nURL.parse() 메서드를 활용하여 URL 객체를 생성하고 처리하는 방법\n\n- `URL.parse(url)` 메서드는 주어진 URL에 따라 새 URL 객체를 생성\n- 유효하지 않은 URL 값이 주어질 경우 `null`을 반환\n- 두 번째 파라미터 base는 상대 URL을 해석하기 위한 기준 URL로 사용되며, 이를 통해 URL의 경로가 올바르게 조정됨\n- URL 객체나 다른 문자열을 파라미터로 사용할 수 있으며, 내부에서 문자열로 변환됨\n\n```ts\ndescribe('null 입력에 대한 URL API 동작 테스트', () => {\n  test('new URL(null)은 TypeError를 발생시킨다', () => {\n    expect(() => new URL(null)).toThrow(TypeError)\n  })\n\n  test('URL.canParse(null)은 false를 반환한다', () => {\n    expect(URL.canParse(null)).toBe(false)\n  })\n\n  test('URL.parse(null)은 null을 반환한다', () => {\n    expect(() => URL.parse(null)).toBe(null)\n  })\n})\n```"},{"i":"367","k":["zod","search-params"],"c":"2025-01-16","m":"2025-01-16","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://www.jacobparis.com/content/zod-search-params-remix\">\nMerge search params with Zod in Remix\n</QuoteLink>\n\n- 검색 매개변수별로 데이터 유효성 검증\n- `useSearchParams`와 `Zod`를 활용해 모든 검색 매개변수를 병합하고 유효성 검증\n\n```ts\nimport { useMemo } from 'react'\nimport { useSearchParams } from 'react-router-dom'\nimport { z } from 'zod'\n\nconst CombinedSchema = PaginationSchema.merge(FilterSchema)\n  .merge(SortSchema)\n  .merge(SearchSchema)\n  .partial()\n\nexport function useSearchParamsWithSchema() {\n  const [searchParams, setSearchParams] = useSearchParams()\n\n  const parsedParams = useMemo(() => {\n    // searchParams를 객체로 변환\n    const paramsObject = Object.fromEntries(searchParams.entries())\n    // 결합된 스키마로 유효성 검사 및 파싱\n    const result = CombinedSchema.safeParse(paramsObject)\n\n    if (result.success) {\n      return result.data\n    }\n\n    console.error(result.error)\n\n    return {}\n  }, [searchParams])\n\n  /**\n   * 새로운 검색 매개변수로 업데이트하는 함수입니다.\n   *\n   * 1. 현재 검색 매개변수의 복사본을 생성합니다.\n   * 2. 새로운 매개변수를 순회하며 값을 설정하거나 삭제합니다.\n   * 3. 최종적으로 업데이트된 매개변수를 state에 설정합니다.\n   */\n  const updateSearchParams = (newParams: Record<string, string>) => {\n    const updatedParams = new URLSearchParams(searchParams)\n\n    Object.entries(newParams).forEach(([key, value]) => {\n      if (value) {\n        // 업데이트된 매개변수로 상태 갱신\n        updatedParams.set(key, value)\n      } else {\n        // 값이 없으면 삭제\n        updatedParams.delete(key)\n      }\n    })\n\n    // 업데이트된 매개변수로 상태 갱신\n    setSearchParams(updatedParams)\n  }\n\n  return {\n    parsedParams,\n    updateSearchParams,\n  }\n}\n```"},{"i":"360","k":["abort-controller"],"c":"2025-01-12","m":"2025-01-12","b":"취소 가능한 `fetch` 요청을 생성하는 `createCancelableFetch` 함수 구현\n\n````ts\n/**\n * 취소 가능한 fetch 요청\n *\n * @param url - 요청 URL\n * @param options - fetch 옵션\n * @returns {run, cancel} - run: 요청 실행 함수, cancel: 요청 취소 함수\n *\n * @example\n *\n * ```ts\n * const { run, cancel } = createCancelableFetch('/api/data')\n *\n * run()\n *   .then((data) => console.log('Fetched data:', data))\n *   .catch((err) => console.error('Error or canceled:', err))\n *\n * cancel()\n * ```\n */\nfunction createCancelableFetch(url: string, options: RequestInit = {}) {\n  const abortController = new AbortController()\n\n  const run = async () => {\n    const response = await fetch(url, {\n      ...options,\n      signal: abortController.signal,\n    })\n\n    if (!response.ok) {\n      throw new Error(`HTTP error! status: ${response.status}`)\n    }\n\n    return response.json()\n  }\n\n  const cancel = () => abortController.abort()\n\n  return {\n    run,\n    cancel,\n  }\n}\n````\n\n- `AbortController`를 사용하여 요청을 중단할 수 있는 기능 제공\n- `run` 함수로 `fetch` 요청을 실행하고, `cancel` 함수로 요청을 취소할 수 있음\n- 요청 실패 시 오류를 처리하고 예외를 발생시킴\n\n---\n\n- [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)\n- [Don't Sleep on AbortController](https://kettanaito.com/blog/dont-sleep-on-abort-controller)"},{"i":"358","k":["field-sizing","input","textarea","select"],"c":"2025-01-12","m":"2025-01-12","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://developer.chrome.com/docs/css-ui/css-field-sizing?&hl=ko\">\nCSS 필드 크기 조정\n</QuoteLink>\n\n\n\n> `field-sizing`를 사용하면 콘텐츠를 기반으로 크기 조절을 사용 설정하는 데 CSS 한 줄이 필요합니다. 이 콘텐츠 기반 크기 조절 스타일은 `textarea` 외에도 다른 요소에도 적용됩니다.\n\n<textarea style={{ padding: '10px', fieldSizing: 'content' }}>{`textarea, select, input {\n\\tfield-sizing: content;\n}`}</textarea>"},{"i":"357","k":["error","tuple"],"c":"2025-01-12","m":"2025-01-12","b":"- https://github.com/arthurfiorette/proposal-safe-assignment-operator\n- https://github.com/arthurfiorette/tuple-it\n\n---\n\n비동기 호출의 결과와 오류를 튜플 형식으로 반환\n\n```ts\nconst [error, data] = await tuple(someAsyncFunction())\n// 성공: [null, 결과값]\n// 실패: [error] 또는 [new TupleItError(error)]\n```\n\n오류 처리 간소화 - 단일 체크로 오류 관리 가능"},{"i":"354","k":["react"],"c":"2025-01-10","m":"2025-01-10","b":"import { Blockquote } from '@components/Blockquote'\n\n<Blockquote\n  cite={{\n    url: 'https://www.jameskerr.blog/posts/use-state-object/',\n    title: 'useStateObject: A Simple, Convenient API Around useState',\n  }}\n>\n  `useStateObject`는 React의 `useState`를 확장한 가벼운 래퍼로, 객체 상태 관리를\n  간편하게 할 수 있도록 설계되었습니다.\n</Blockquote>\n\n---\n\n```ts\nexport type StateObject<T extends object> = T & {\n  set: React.Dispatch<React.SetStateAction<T>>\n  setItem: <K extends keyof T>(key: K, value: T[K]) => void\n  merge: (newState: Partial<T>) => void\n  reset: () => void\n}\n```\n\n---\n\n그렇다면 `Map`과 `Set`도 시도해보기\n\n```ts\nfunction useStateMap<K, V>(init: Iterable<[K, V]> = []) {\n  const [map, setMap] = useState(new Map<K, V>(init))\n\n  const update = useCallback(\n    (updater: (currentMap: Map<K, V>) => void) => {\n      setMap((prev) => {\n        const newMap = new Map(prev)\n        updater(newMap)\n        return newMap\n      })\n    },\n    [setMap]\n  )\n\n  return {\n    map,\n    set: (key: K, value: V) => update((m) => m.set(key, value)),\n    delete: (key: K) => update((m) => m.delete(key)),\n    clear: () => setMap(new Map()),\n    has: (key: K) => map.has(key),\n    get: (key: K) => map.get(key),\n    entries: () => Array.from(map.entries()),\n    size: map.size,\n  }\n}\n```\n\n```ts\nfunction useStateSet<T>(init: Iterable<T> = []) {\n  const [set, setSet] = useState(new Set<T>(init))\n\n  const update = useCallback(\n    (updater: (currentSet: Set<T>) => void) => {\n      setSet((prev) => {\n        const newSet = new Set(prev)\n        updater(newSet)\n        return newSet\n      })\n    },\n    [setSet]\n  )\n\n  return {\n    set,\n    add: (value: T) => update((s) => s.add(value)),\n    delete: (value: T) => update((s) => s.delete(value)),\n    has: (value: T) => set.has(value),\n    clear: () => setSet(new Set()),\n    entries: () => Array.from(set),\n    size: set.size,\n  }\n}\n```\n\n---\n\n- [Map](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Map)\n- [Set](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Set)"},{"i":"352","k":["thisisunsafe"],"c":"2025-01-06","m":"2026-09-01","b":"로컬·테스트 서버에서 크롬 인증서 경고를 강제로 통과한다.\n\n**언제**: **이 연결은 비공개로 설정되지 않았습니다** 화면에 `NET::ERR_CERT_AUTHORITY_INVALID`·`NET::ERR_CERT_COMMON_NAME_INVALID` 가 뜨고, 고급 → 안전하지 않음(계속)이 없거나 매번 누르기 번거로울 때.\n\n**절차**\n\n1. 경고 화면 아무 곳이나 클릭한다 — 페이지에 포커스를 준다\n2. 입력창 없이 그대로 `thisisunsafe` 를 타이핑한다\n\n입력창이 없는 게 정상이다. 경고 페이지의 keypress 핸들러가 한 글자씩 받아 `BYPASS_SEQUENCE` 와 대조하고, 다 맞으면 `CMD_PROCEED` 를 보낸다 — \"안전하지 않음(계속)\"을 누른 것과 같은 명령이다.\n\n**확인**: 마지막 글자를 치는 순간 넘어간다. 안 넘어가면 포커스가 주소창에 있는 것이다 — 1번부터."},{"i":"351","k":["use-sync-external-store","react"],"c":"2025-01-06","m":"2025-01-06","b":"`useSyncExternalStore` 활용 가능한 부분들\n\n- [onLine](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine)\n- [matchMedia](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia)\n- [resize_event](https://developer.mozilla.org/ko/docs/Web/API/Window/resize_event)\n- [scroll_event](https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event)\n- [localStorage](https://developer.mozilla.org/ko/docs/Web/API/Window/localStorage)\n\n---\n\n- https://ko.react.dev/reference/react/useSyncExternalStore\n- https://julesblom.com/writing/usesyncexternalstore\n- https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api\n- https://www.56kode.com/posts/using-usesyncexternalstore-with-localstorage"},{"i":"350","k":["typescript"],"c":"2025-01-01","m":"2025-01-01","b":"템플릿 리터럴 타입을 활용하여 타입 정의하기\n\n````tsx\nimport * as React from 'react'\n\ntype RenderPropNames = 'Title' | 'Content' | 'Actions'\n\ntype RenderProps = {\n  [K in RenderPropNames as `render${K}`]: () => React.ReactNode\n}\n\ntype Props = RenderProps\n\n/**\n * @example\n *\n * ```tsx\n * <DialogComponent\n *   renderTitle={() => <h2>Title</h2>}\n *   renderContent={() => <p>Content</p>}\n *   renderActions={() => (\n *     <div>\n *       <button onClick={handleClose}>Close</button>\n *       <button onClick={handleSubmit}>Submit</button>\n *     </div>\n *   )}\n * />\n * ```\n */\nfunction Dialog({\n  renderTitle,\n  renderContent,\n  renderActions,\n}: Props) => {\n  return (\n    <div data-scope=\"root\">\n      <div data-part=\"content\">\n        {renderTitle()}\n        {renderContent()}\n      </div>\n      <div data-part=\"actions\">{renderActions()}</div>\n    </div>\n  )\n}\n````"},{"i":"349","k":["dialog","ux"],"c":"2025-01-01","m":"2025-01-01","b":"대화 상자(Dialog) 유형\n\n**Modal** - 상호작용 차단\n\n- System Modal: 닫기 전까지 다른 작업 불가\n- Application Modal: 프로그램 일시 중단\n- Document Modal: 부모 창만 차단 (macOS 시트)\n\n**Modeless** - 상호작용 허용 (예: 툴바)\n\n모달은 워크플로우 방해 → 비필수 작업에는 모델리스 선호\n\n313.md 참고"},{"i":"347","k":["vitest","jest","testing","mock"],"c":"2025-01-01","m":"2025-01-01","b":"Vitest/Jest Fake Timers로 시간 제어\n\n```javascript\ndescribe('time-dependent tests', () => {\n  beforeEach(() => {\n    vi.useFakeTimers() // jest.useFakeTimers('modern')\n  })\n\n  afterEach(() => {\n    vi.useRealTimers() // jest.useRealTimers()\n  })\n\n  it('특정 시간에 동작 확인', () => {\n    vi.setSystemTime(new Date(2000, 1, 1, 13)) // 13시 설정\n    expect(purchase()).toEqual({ message: 'Success' })\n  })\n\n  it('영업시간 외 동작', () => {\n    vi.setSystemTime(new Date(2000, 1, 1, 19)) // 19시 설정\n    expect(purchase()).toEqual({ message: 'Error' })\n  })\n})\n```\n\n`@sinonjs/fake-timers` 기반. Date, setTimeout 등 모킹."},{"i":"345","k":["typescript"],"c":"2025-01-01","m":"2025-01-01","b":"두 개의 오버로딩된 메서드에서 각각의 반환 타입을 명시적으로 추출하기\n\n```ts\ntype Year = {\n  year(): number\n  year(u: string): string\n}\n\ntype ReturnTo<T, R> = T extends R ? R : never\n\ntype GetYear = ReturnTo<Year['year'], () => number>\ntype SetYear = ReturnTo<Year['year'], (u: string) => string>\n```"},{"i":"344","k":["javascript","algorithm","dfs"],"c":"2025-01-01","m":"2025-01-01","b":"DFS (깊이 우선 탐색)\n\n```javascript\n// 재귀 방식\nfunction dfs(graph, node, visited = new Set()) {\n  visited.add(node)\n  console.log(node)\n  graph[node].forEach((neighbor) => {\n    if (!visited.has(neighbor)) dfs(graph, neighbor, visited)\n  })\n}\n\n// 스택 방식\nfunction dfsStack(graph, startNode) {\n  const stack = [startNode]\n  const visited = new Set()\n\n  while (stack.length > 0) {\n    const node = stack.pop()\n\n    if (!visited.has(node)) {\n      console.log(node)\n      visited.add(node)\n      graph[node]\n        .slice()\n        .reverse()\n        .forEach((neighbor) => {\n          if (!visited.has(neighbor)) stack.push(neighbor)\n        })\n    }\n  }\n}\n\nconst graph = {\n  0: [1, 2],\n  1: [0, 3, 4],\n  2: [0, 5],\n  3: [1],\n  4: [1],\n  5: [2],\n}\ndfs(graph, 0) // 0, 1, 3, 4, 2, 5\n```"},{"i":"343","k":["javascript","delete"],"c":"2025-01-01","m":"2025-01-01","b":"JavaScript delete 연산자\n\n```js\nconst obj = { name: 'Alice', age: 25 }\ndelete obj.age // true\n\n// 존재하지 않는 속성 삭제도 true\ndelete obj.city // true\n\n// configurable: false는 삭제 불가\nconst locked = Object.defineProperty({}, 'readOnly', {\n  value: 'I cannot be deleted',\n  configurable: false,\n})\ndelete locked.readOnly // false\n\n// 전역 변수 삭제 불가\nlet globalVar = 'exists'\ndelete globalVar // false\n\n// 배열 요소 삭제 (hole 생성)\nlet arr = [1, 2, 3]\ndelete arr[1] // [1, <empty>, 3]\n```\n\n- 설정 가능한(configurable) 속성에만 사용\n- 배열은 `splice` 권장"},{"i":"342","k":["sheets"],"c":"2025-01-01","m":"2025-01-01","b":"- 문제: 스프레드시트 데이터가 커서 내용 확인이 어려움.\n- 해결: 구글 드라이브에 스프레드시트 데이터를 CSV 파일로 업로드하여 확인.\n\n```js\n/**\n * 현재 활성 스프레드시트의 첫 번째 시트 데이터를 CSV 파일로 저장하고,\n * 구글 드라이브에 업로드합니다.\n * 생성된 파일의 URL은 콘솔에 로그로 출력됩니다.\n */\nfunction saveSpreadsheetDataAsCSV() {\n  /** 현재 활성 스프레드시트 */\n  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]\n  /** 시트의 모든 데이터의 2차원 배열 */\n  const data = sheet.getDataRange().getValues()\n  /** CSV 데이터 */\n  const csv = data.map((row) => row.join(',')).join('\\n')\n  /** 파일명 */\n  const fileName = 'spreadsheet_data.csv'\n  /** 구글 드라이브에 업로드 완료된 CSV 파일 */\n  const file = DriveApp.createFile(fileName, csv, MimeType.CSV)\n\n  console.log('파일이 생성되었습니다: ' + file.getUrl())\n}\n```"},{"i":"341","k":["css","color","rgba","hex"],"c":"2025-01-01","m":"2025-01-01","b":"```ts\nclass ColorManipulator {\n  private baseColor: string\n  private targetColor: string\n\n  constructor(baseColor: string, targetColor: string) {\n    this.baseColor = baseColor\n    this.targetColor = targetColor\n  }\n\n  private hexToRgb(hex: string) {\n    const bigint = parseInt(hex.slice(1), 16)\n\n    return {\n      r: (bigint >> 16) & 255,\n      g: (bigint >> 8) & 255,\n      b: bigint & 255,\n    }\n  }\n\n  public calculateOpacity() {\n    const target = this.hexToRgb(this.targetColor)\n    const baseRG = this.hexToRgb(this.baseColor)\n    const opacities = [\n      (target.r - baseRG.r) / (255 - baseRG.r),\n      (target.g - baseRG.g) / (255 - baseRG.g),\n      (target.b - baseRG.b) / (255 - baseRG.b),\n    ]\n    const averageOpacity =\n      opacities.reduce((sum, value) => sum + value, 0) / opacities.length\n\n    return averageOpacity\n  }\n\n  public getCssRGBA() {\n    const opacity = this.calculateOpacity()\n\n    return `rgba(0, 0, 0, ${opacity.toFixed(2)})`\n  }\n}\n\nconst manipulator = new ColorManipulator('#000000', '#D1D7DE')\nconst opacity = manipulator.calculateOpacity()\nconst cssRGBA = manipulator.getCssRGBA()\n```"},{"i":"340","k":["sheets"],"c":"2025-01-01","m":"2026-07-27","b":"구글 스프레드시트에서 중복 값에 스타일 주기 — 서식 > 조건부 서식 > 맞춤 수식에 넣는다.\n\n```sh\n=COUNTIF(A:A, A:A) > 1\n```"},{"i":"337","k":["apps-script"],"c":"2025-01-01","m":"2025-01-01","b":"Google Spreadsheet 팝업\n\n```javascript\nfunction showAlert() {\n  const ui = SpreadsheetApp.getUi()\n  ui.alert('팝업 창', '표시할 텍스트', ui.ButtonSet.OK)\n}\n\n// 메뉴에 추가\nfunction onOpen() {\n  SpreadsheetApp.getUi()\n    .createMenu('팝업 메뉴')\n    .addItem('팝업 띄우기', 'showAlert')\n    .addToUi()\n}\n```"},{"i":"336","k":["javascript","regex"],"c":"2025-01-01","m":"2025-01-01","b":"특수 기호 제거\n\n```javascript\nfunction removeSpecialCharacters(input) {\n  return input.replace(/[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]+/g, '')\n}\n\nremoveSpecialCharacters('Hello_World123! 안녕하세요?')\n// \"HelloWorld123 안녕하세요\"\n```"},{"i":"335","k":["react"],"c":"2025-01-01","m":"2025-01-01","b":"조건부 렌더링을 한다고 했을때 예전에는 주로 B로 처리했던 것 같은데 디버깅 때문에 고생해서 그런지 생각이 바뀌었다.\n\n```tsx\nfunction renderA() {\n  return <Item isPacked={true} name=\"Space suit\" />\n}\n\nfunction renderB() {\n  return <>{isPacked ? <Item name=\"Space suit\" /> : null}</>\n}\n```\n\n---\n\n조건부 렌더링을 보다 간결하게 표현하기 위해 `If`, `Then`, 그리고 `Else` 컴포넌트 개념을 차용하는 방법. 이 컴포넌트는 `If`에서 `condition` prop을 통해 조건을 받아들이고, 자식으로 `Then`과 `Else`를 받아 각각의 내용을 렌더링하도록 한다.\n\n```tsx\ntype Props = {\n  /** 렌더링할 조건 */\n  condition: boolean\n  /** 자식 컴포넌트 (Then, Else 포함) */\n  children: React.ReactNode\n}\n\n/**\n * If 컴포넌트는 조건부 렌더링을 위한 컴포넌트\n */\nfunction If({ condition, children }: Props) {\n  let thenChild = null\n  let elseChild = null\n\n  React.Children.forEach(children, (child) => {\n    if (!React.isValidElement(child)) return\n    if (child.type === Then) thenChild = child\n    if (child.type === Else) elseChild = child\n  })\n\n  return condition ? thenChild : elseChild\n}\n\n/**\n * Then, Else 컴포넌트는 조건이 참 또는 거짓일 때 렌더링되는 콘텐츠를 포함\n */\nconst Then = ({ children }: React.PropsWithChildren) => <>{children}</>\nconst Else = ({ children }: React.PropsWithChildren) => <>{children}</>\n```"},{"i":"334","k":["mongodb","ttl"],"c":"2025-01-01","m":"2025-01-01","b":"MongoDB TTL 인덱스 (자동 문서 삭제)\n\n```javascript\n// 데이터 삽입\ndb.collection.insertOne({\n  name: 'Temporary',\n  expireAt: new Date('2024-11-30T00:00:00Z'),\n})\n\n// TTL 인덱스 생성 (expireAt 시간 기준 삭제)\ndb.collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })\n```\n\n- 60초 간격 백그라운드 체크로 삭제 (즉시 아님)\n- `expireAt`은 ISODate 형식 필수\n- 활용: 세션 만료, 캐시 관리, 임시 데이터\n\nSQLite는 TTL 미지원 → 트리거 또는 외부 스케줄러로 구현"},{"i":"333","k":["oauth","jwt"],"c":"2025-01-01","m":"2025-01-01","b":"Google Auth ID Token 클레임\n\n| 클레임 | 의미 |\n|--------|------|\n| `sub` | 사용자 고유 식별자 (user_id로 사용 가능) |\n| `jti` | JWT 토큰 고유 ID (토큰 무효화/추적용) |\n| `iss` | 토큰 발급자 (`accounts.google.com`) |\n| `aud` | 대상 애플리케이션 (Client ID) |\n| `iat` | 발급 시각 |\n| `exp` | 만료 시각 |\n\n`sub`를 user_id로 사용 시 고려사항:\n- Google 계정 삭제 후 재생성 시 새 `sub` 할당됨\n- 여러 소셜 로그인 지원 시 별도 user_id + google_sub 매핑 권장"},{"i":"332","k":["sqlite","database-design"],"c":"2025-01-01","m":"2025-01-01","b":"SQLite 게시물 업데이트 내역 저장\n\n```sql\nCREATE TABLE posts (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  title TEXT NOT NULL,\n  content TEXT NOT NULL,\n  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE post_history (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  post_id INTEGER NOT NULL,\n  title TEXT,\n  content TEXT,\n  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n  FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE\n);\n\n-- 업데이트 전 내역 저장\nINSERT INTO post_history (post_id, title, content, updated_at)\nSELECT id, title, content, updated_at FROM posts WHERE id = 1;\n\n-- 게시물 업데이트\nUPDATE posts SET title = '새 제목', updated_at = CURRENT_TIMESTAMP WHERE id = 1;\n```\n\nposts : post_history = 1 : N 관계"},{"i":"331","k":["sqlite","json"],"c":"2025-01-01","m":"2025-01-01","b":"SQLite JSON 태그 빈도 정렬\n\n```sql\n-- 태그별 빈도 계산\nSELECT json_each.value AS tag, COUNT(*) AS frequency\nFROM example, json_each(example.tags)\nGROUP BY json_each.value\nORDER BY frequency DESC, tag ASC;\n\n-- 빈도순 정렬된 태그를 JSON 배열로\nSELECT json_group_array(tag) AS tags_by_frequency\nFROM (\n  SELECT json_each.value AS tag\n  FROM example, json_each(example.tags)\n  GROUP BY json_each.value\n  ORDER BY COUNT(*) DESC, tag ASC\n);\n```"},{"i":"330","k":["skia-canvas","pdf"],"c":"2025-01-01","m":"2025-01-01","b":"빈 PDF 파일이 필요해서 찾아본 방법들\n\n```js\nimport { createCanvas } from 'skia-canvas'\nimport { writeFile } from 'node:fs/promises'\n\n/**\n * A4 크기의 빈 PDF 파일을 생성합니다.\n *\n * @param {string} filename - 생성할 PDF 파일의 이름입니다.\n * @returns {Promise<void>}\n */\nasync function createEmptyPDF(filename) {\n  // A4 크기 (595x842 포인트)\n  const width = 595\n  const height = 842\n\n  // PDF 형식의 캔버스를 생성합니다\n  const canvas = createCanvas(width, height, 'pdf')\n  const ctx = canvas.getContext('2d')\n\n  // 아무 내용도 그리지 않고 현재 상태를 저장합니다\n  ctx.save()\n\n  // 캔버스를 버퍼로 변환하여 PDF로 저장합니다\n  const buffer = await canvas.toBuffer()\n  await writeFile(filename, buffer)\n  console.log(`${filename} 파일이 생성되었습니다!`)\n}\n\ncreateEmptyPDF('empty_skia.pdf')\n```\n\n---\n\n```sh\n# Ghostscript를 사용한 빈 PDF 생성\ngs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=empty.pdf -c \"[/PageSize [595 842]] setpagedevice\" -f /dev/null\n\n# ImageMagick의 `convert` 명령어로 빈 PDF 생성\nconvert xc:white -page A4 empty.pdf\n\n# `touch` 명령어와 PDF 헤더 직접 작성\necho -e \"%PDF-1.4\\n1 0 obj\\n<<>>\\nendobj\\nxref\\n0 1\\n0000000000 65535 f \\ntrailer\\n<<>>\\nstartxref\\n9\\n%%EOF\" > empty.pdf\n```"},{"i":"329","k":["apps-script"],"c":"2024-12-31","m":"2026-07-27","b":"Apps Script로 캘린더 예약을 만들 때 쓰는 건 결국 API 두 개다.\n\n`Calendar.Freebusy.query`로 바쁜 일정을 받아 근무 요일·근무 시간과 겹치는 슬롯을 빼면 예약 가능한 슬롯이 남는다. 예약은 `CalendarApp.getCalendarById`로 이벤트를 만들고 `sendInvites: true`로 초대 메일까지 보낸다.\n\nFreebusy는 슬롯을 계산할 때와 실제로 예약할 때 두 번 조회한다 — 그 사이에 일정이 생길 수 있으니.\n\n---\n\n- https://github.com/rbbydotdev/someday"},{"i":"328","k":["react","testing","context"],"c":"2024-12-31","m":"2024-12-31","b":"AuthContext 테스트 패턴\n\n```tsx\n// localStorage 함수 테스트\nvi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {\n  if (key === 'auth_token') return 'test_token'\n  return null\n})\n\n// AuthProvider 상태 테스트\nconst TestComponent = () => {\n  const { token, login, logout } = useAuth()\n\n  return (\n    <div>\n      <p data-testid=\"token\">{token || 'null'}</p>\n      <button onClick={() => login('test_token')}>Login</button>\n      <button onClick={logout}>Logout</button>\n    </div>\n  )\n}\n\n// useAuth 훅 테스트\nit('AuthProvider 외부에서 호출하면 에러', () => {\n  expect(() => renderHook(() => useAuth())).toThrowError()\n})\n\nit('AuthProvider 내부에서 정상 동작', () => {\n  const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>\n  const { result } = renderHook(() => useAuth(), { wrapper })\n  expect(result.current).toHaveProperty('token', null)\n})\n```"},{"i":"327","k":["test","mocking","timers"],"c":"2024-12-31","m":"2026-07-27","b":"타이머를 제어하기 위해 `vi.useFakeTimers`와 `vi.advanceTimersByTime`을 사용하기.\n\n```tsx\ndescribe('Countdown 컴포넌트', () => {\n  beforeEach(() => {\n    vi.useFakeTimers()\n  })\n\n  afterEach(() => {\n    vi.useRealTimers()\n  })\n\n  test('타이머가 동작하며 시간을 업데이트한다.', () => {\n    const endTime = new Date(Date.now() + 60000).toISOString()\n    const renderChild = vi.fn(({ targetRef }) => (\n      <div ref={targetRef} data-testid=\"countdown\" />\n    ))\n\n    render(<Countdown end_at={endTime}>{renderChild}</Countdown>)\n\n    expect(screen.getByTestId('countdown')).toBeEmptyDOMElement()\n\n    act(() => {\n      vi.advanceTimersByTime(10000)\n    })\n\n    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:50')\n\n    act(() => {\n      vi.advanceTimersByTime(50000)\n    })\n\n    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:00')\n  })\n})\n```\n\n---\n\n- https://vitest.dev/guide/mocking\n- https://vitest.dev/api/vi.html#fake-timers"},{"i":"326","k":["react","testing","formdata"],"c":"2024-12-31","m":"2024-12-31","b":"폼 제출 시 FormData 테스트\n\n```tsx\ntest('폼 제출 시 formData 확인', () => {\n  const handleSubmit = jest.fn((e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault()\n    const formData = new FormData(e.target as HTMLFormElement)\n    const data = Object.fromEntries(formData.entries())\n    expect(data).toEqual({ username: 'testuser', password: 'password' })\n  })\n\n  render(<LoginForm onSubmit={handleSubmit} />)\n\n  fireEvent.change(screen.getByPlaceholderText('아이디'), {\n    target: { value: 'testuser' },\n  })\n  fireEvent.change(screen.getByPlaceholderText('비밀번호'), {\n    target: { value: 'password' },\n  })\n  fireEvent.click(screen.getByRole('button', { name: '로그인' }))\n\n  expect(handleSubmit).toHaveBeenCalled()\n})\n```"},{"i":"325","k":["javascript","formatting"],"c":"2024-12-31","m":"2024-12-31","b":"거리 만km 포맷팅\n\n```typescript\nfunction formatToManKm(distance: number): string {\n  const manKm = (distance / 10000).toFixed(1)\n\n  return `${manKm}만km`\n}\n\nformatToManKm(104335) // \"10.4만km\"\n```"},{"i":"324","k":["accessibility","aria"],"c":"2024-12-31","m":"2024-12-31","b":"ARIA 속성 적용 패턴\n\n```tsx\n// region + labelledby\n<div role=\"region\" aria-labelledby={titleId}>\n  <h2 id={titleId}>제목</h2>\n</div>\n\n// list + listitem\n<div role=\"list\" aria-labelledby=\"list-title\">\n  <div role=\"listitem\" aria-labelledby={`${label}-label ${label}-value`}>\n    <span id={`${label}-label`}>{label}</span>\n    <span id={`${label}-value`}>{value}</span>\n  </div>\n</div>\n```\n\n- `aria-labelledby` - 연결된 요소의 텍스트를 레이블로\n- `aria-describedby` - 추가 설명이 필요할 때\n- 중복 제거: aria-label과 화면 텍스트 중복 시 aria-labelledby 사용"},{"i":"323","k":["javascript","regex","formatting"],"c":"2024-12-31","m":"2024-12-31","b":"전화번호 포맷팅\n\n```javascript\nfunction formatPhoneNumber(phoneNumber) {\n  const cleaned = phoneNumber.replace(/\\D/g, '')\n\n  if (cleaned.length === 11) {\n    return cleaned.replace(/(\\d{3})(\\d{4})(\\d{4})/, '$1-$2-$3')\n  } else if (cleaned.length === 10 && cleaned.startsWith('02')) {\n    return cleaned.replace(/(\\d{2})(\\d{3})(\\d{4})/, '$1-$2-$3')\n  } else if (cleaned.length === 10) {\n    return cleaned.replace(/(\\d{3})(\\d{3})(\\d{4})/, '$1-$2-$3')\n  } else if (cleaned.length === 9 && cleaned.startsWith('02')) {\n    return cleaned.replace(/(\\d{2})(\\d{3})(\\d{3})/, '$1-$2-$3')\n  }\n  return 'Invalid phone number'\n}\n\nformatPhoneNumber('01012341234') // 010-1234-1234\nformatPhoneNumber('021234567') // 02-123-4567\n```"},{"i":"322","k":["javascript","localstorage","map"],"c":"2024-12-31","m":"2024-12-31","b":"Map으로 localStorage 래핑\n\n```javascript\nclass LocalStorageMap {\n  constructor(storageKey) {\n    this.storageKey = storageKey\n    this.map = this.loadFromStorage()\n  }\n\n  loadFromStorage() {\n    const data = localStorage.getItem(this.storageKey)\n    return data ? new Map(JSON.parse(data)) : new Map()\n  }\n\n  saveToStorage() {\n    localStorage.setItem(this.storageKey, JSON.stringify([...this.map]))\n  }\n\n  set(key, value) {\n    this.map.set(key, value)\n    this.saveToStorage()\n  }\n\n  get(key) {\n    return this.map.get(key)\n  }\n\n  delete(key) {\n    const result = this.map.delete(key)\n    this.saveToStorage()\n    return result\n  }\n\n  clear() {\n    this.map.clear()\n    localStorage.removeItem(this.storageKey)\n  }\n}\n```\n\n> [!CAUTION]\n>\n> - localStorage는 문자열만 저장 → JSON 직렬화 필수\n> - 용량 제한 5~10MB 주의"},{"i":"321","k":["status","ternary"],"c":"2024-12-31","m":"2024-12-31","b":"```ts\nconst color =\n  (isUnchecked && 'palette.label.alternative') ||\n  (isChecked && 'palette.primary.normal') ||\n  'palette.label.normal'\n```\n\n중첩 삼항/논리 연산자는 간결하지만 상태가 늘어나면 유지보수 어려움.\n\n핵심: **`status` 모델을 어떻게 정의하고 참조할 것인가?**\n\n```ts\nclass StatusManager {\n  static Status = {\n    UNCHECKED: 'UNCHECKED',\n    CHECKED: 'CHECKED',\n    DEFAULT: 'DEFAULT',\n  } as const\n\n  static getColor(status: keyof typeof this.Status) {\n    switch (status) {\n      case this.Status.UNCHECKED:\n        return 'palette.label.alternative'\n      case this.Status.CHECKED:\n        return 'palette.primary.normal'\n      default:\n        return 'palette.label.normal'\n    }\n  }\n}\n\nconst color = StatusManager.getColor(StatusManager.Status.CHECKED)\n```\n\n---\n\n- [Expression of type string can&#x27;t be used to index type X | Total TypeScript](https://www.totaltypescript.com/concepts/type-string-cannot-be-used-to-index-type)"},{"i":"320","k":["variable"],"c":"2024-12-31","m":"2024-12-31","b":"변수의 **prefix**는 역할과 의미를 명확히 하기 위해 사용된다.\n\n```js\n/**\n * 초기값을 나타내며, 상태나 값이 처음 설정될 때 사용된다.\n * 적합한 상황: 프로그램이 시작되거나 객체가 처음 생성될 때의 값을 정의한다.\n */\nconst initialCount = 0 // 카운터의 초기값\nconst initialState = { loggedIn: false, user: null } // 초기 상태\nconst initialPosition = { x: 0, y: 0 } // 초기 좌표\n\n/**\n * 기본값으로 널리 사용되는 표준 값이다.\n * 적합한 상황: 값이 없을 경우 사용할 기본값을 정의한다.\n */\nconst defaultTheme = 'light' // 기본 테마\nconst defaultUser = { name: 'Guest', role: 'viewer' } // 기본 사용자\nconst defaultPageSize = 20 // 페이지당 기본 항목 수\n\n/**\n * 기준값 또는 다른 값의 참조점이 되는 값이다.\n * 적합한 상황: 값을 계산하거나 파생할 때 기준이 되는 값을 정의한다.\n */\nconst baseSalary = 3000 // 기준 급여\nconst baseUrl = 'https://api.example.com' // API의 기준 URL\nconst baseColor = '#FFFFFF' // 기준 색상\n\n/**\n * 변경되기 전 원래 상태 또는 초기 상태를 강조한다.\n * 적합한 상황: 데이터를 변경하기 전에 원래 값을 유지해야 할 때 사용한다.\n */\nconst originalText = 'Hello World' // 변경 전 텍스트\nconst originalSettings = { theme: 'dark', notifications: true } // 원래 설정값\nconst originalImage = image.clone() // 원본 이미지 복사\n```\n\n- `initial*` vs `default*` - initial은 초기 상태, 재설정 가능성 적음. default는 기본값, 교체 가능.\n- `base*` vs `original*` - base는 기준값(비교/계산용). original은 원래 상태 보존/복구용."},{"i":"319","k":["mermaid","state","react"],"c":"2024-09-17","m":"2024-09-17","b":"import { Blockquote } from '@components/Blockquote'\n\n<Blockquote\n  cite={{\n    url: 'https://massimilianomirra.com/notes/expressive-components-in-vanilla-react-part-1-type-states',\n    title: 'Expressive components in vanilla React, part 1: TypeStates',\n  }}\n>\n  이 패턴이 \"상태 머신\"처럼 들린다면, 그리 놀랄 일도 아닙니다. 결국, 선택의\n  문제는 상태 머신을 구축할지 말지가 아니라, 그것을 암시적으로 구축할지\n  명시적으로 구축할지에 달려 있습니다.\n</Blockquote>\n\n```mermaid\nstateDiagram-v2\n  [*] --> Mounting\n\n  Mounting --> AwaitingEmailInput\n  Mounting --> AwaitingCodeInput\n\n  AwaitingEmailInput --> SubmittingEmail\n\n  SubmittingEmail --> AwaitingCodeInput\n  SubmittingEmail --> AwaitingEmailInput\n\n  AwaitingCodeInput --> SubmittingCode\n\n  SubmittingCode --> Success\n  SubmittingCode --> AwaitingCodeInput\n\n  Success --> [*]\n```\n\n![](https://mermaid.ink/svg/pako:eNqFkktPhDAUhf8KuUsDZJjyKF2YGHVh4mycneKiQmUah5aUVh0J_93ymHFmDMqioed89_bkti3ksmBAoNFUsxtOS0Ur732ZCcd-BVcs11wK5_5hVMb16eLZ8bxLZyWN0FyUmRj1_X4wrz4o7ze3FeXbO1Eb_Qd0bVNMzEj9rh74tXmpuD4Ye_pMnmv9H3oc9TTGoclZil4_nsypM8F5zppm1p8dwVQ4QHbi4ELFlE1Y2OtqeyQDvWEVy4DY34Kqtwwy0VmOGi3XO5ED0cowF0xd_NwukFe6baxaUwGkhU8gXoCwH0RpmIQ4wmGyiFzYAQlj7CeLECNklyBNUdy58CWlbRH4SRJEcbBEC4QjZGuHfo-DOR7KCq6lWo3Pa3hlLihpys0UoPsGYNnNCQ)"},{"i":"318","k":["blob","script"],"c":"2024-09-16","m":"2024-09-16","b":"import { Blockquote } from '@components/Blockquote'\n\n<Blockquote \n  cite={{\n    url: \"https://violentmonkey.github.io/posts/inject-scripts-with-blob-urls/\",\n    title: \"Inject scripts with Blob URLs\"\n  }}\n>\nBlob URL 방식은 Firefox 58+에서 CSP 제한이 있는 페이지에서도 스크립트를 주입할 수 있는 방법입니다.\n이 방식을 사용할 때, 비동기성 때문에 발생할 수 있는 문제를 방지하려면 초기화 코드가 준비된 후 스크립트를 전달해야 합니다.\n</Blockquote>\n\n```ts\nconst b = new Blob([script], { type: 'text/javascript' })\nconst u = URL.createObjectURL(b)\nconst s = document.createElement('script')\ns.src = u\ndocument.body.appendChild(s)\ndocument.body.removeChild(s)\nURL.revokeObjectURL(u)\n```"},{"i":"317","k":["test","vitest","matchers","zod"],"c":"2024-09-15","m":"2024-09-15","b":"[vitest](https://vitest.dev/)에서 사용자 지정 assertion을 추가하여 Zod 스키마와 Response 객체를 비교하기\n\n```ts\nimport { expect } from 'vitest'\nimport type { ZodTypeAny } from 'zod'\n\nexpect.extend({\n  /**\n   * @param received 테스트할 Response 객체\n   * @param schema 검증할 Zod 스키마\n   */\n  async toMatchSchema(received: Response, schema: ZodTypeAny) {\n    const response = await received.json()\n    const result = await schema.safeParseAsync(response)\n\n    return {\n      message: () => '',\n      pass: result.success,\n    } satisfies ExpectationResult\n  },\n})\n```\n\n`vitest.d.ts` 파일에서 CustomMatchers 인터페이스를 확장하여 TypeScript와의 통합성을 유지.\n\n```ts\nimport type { ZodTypeAny } from 'zod'\n\ninterface CustomMatchers<R = unknown> {\n  toMatchSchema(schema: ZodTypeAny): Promise<R>\n}\n```\n\n`todoResponse`의 응답 데이터가 `todoSchema`에 정의된 Zod 스키마와 일치하는지 확인한다.\n\n```ts\ntest('todo', async () => {\n  expect(todoResponse.ok).toBeTruthy()\n  expect(todoResponse).toMatchSchema(todoSchema)\n})\n```\n\n---\n\n- [Extending Matchers | Guide | Vitest](https://vitest.dev/guide/extending-matchers)\n- [Expect · Jest](https://jestjs.io/docs/expect#expectextendmatchers)"},{"i":"315","k":["date","test"],"c":"2024-09-03","m":"2024-09-03","b":"import { transformerMetaHighlight } from '@shikijs/transformers'\nimport { Code } from 'astro:components'\n\nvitest와 jest에서 가짜 타이머를 사용하는 방법. 내부적으로는 [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers)를 사용.\nvitest와 jest 모두 시스템 시간을 조작할 수 있도록 하여 테스트가 일관되게 실행되도록 하고 다양한 시간과 날짜를 시뮬레이션하여 다양한 조건에서 코드가 예상대로 작동하는지 확인할 수 있음.\n\n<Code\n  code={`describe('isSameDate', () => {\n    beforeEach(() => {\n      vi.useFakeTimers()\n      vi.setSystemTime('2024-07-20')\n    })\n\n    afterEach(() => {\n      vi.useRealTimers()\n    })\n\n    it('현재 날짜와 동일한 날짜를 전달하면 true를 반환한다', () => {\n      expect(isSameDate('2024-07-20')).toBe(true)\n    })\n\n    it('현재 날짜와 다른 날짜를 전달하면 false를 반환한다', () => {\n      expect(isSameDate('2024-07-19')).toBe(false)\n      expect(isSameDate('2024-07-21')).toBe(false)\n    })\n})`}\n  lang=\"tsx\"\n  transformers={[transformerMetaHighlight()]}\n  meta=\"{3,4,8}\"\n/>\n\n---\n\n- https://jestjs.io/blog/2020/05/05/jest-26#new-fake-timers\n- https://vitest.dev/guide/mocking#dates"},{"i":"314","k":["date","random","test","flaky"],"c":"2024-09-01","m":"2024-01-09","b":"Flaky 테스트 방지 - `Date`, `Math.random` 같은 비순수 함수를 props로 주입\n\n- `new Date()`, `Math.random()` 같은 비순수 함수는 매번 다른 결과 → 테스트 불안정\n- 해결: 기본 매개변수로 주입하면 프로덕션 동작 유지 + 테스트에서 제어 가능\n\n```js\nfunction Date({ date = new Date() }) {\n  const [date, setDate] = React.useState(date)\n\n  return (\n    <input\n      type=\"date\"\n      onChange={(e) => setDate(e.target.value)}\n      defaultValue={date}\n    />\n  )\n}\n\nfunction Random({ randomizer = Math.random }) {\n  const [state, setState] = React.useState(randomizer())\n\n  return <div>{state}</div>\n}\n```\n\n테스트:\n\n```tsx\ndescribe('Date Component', () => {\n  it('should update state on date change', () => {\n    render(<Date date={new Date('2023-01-01')} />)\n\n    const input = screen.getByRole('textbox')\n\n    expect(input.value).toBe('2023-01-01')\n  })\n})\n\ndescribe('Random Component', () => {\n  it('should render a random number', () => {\n    const mockRandomizer = () => 0.5\n\n    render(<Random randomizer={mockRandomizer} />)\n\n    const div = screen.getByText('0.5')\n\n    expect(div).toBeInTheDocument()\n  })\n})\n```"},{"i":"313","k":["dialog"],"c":"2024-09-01","m":"2024-09-01","b":"> 대화 상자(Dialog Box)는 사용자에게 정보를 전달하고 응답을 요청하는 그래픽 제어 요소이다.\n> [Dialog box](https://en.wikipedia.org/wiki/Dialog_box)\n\n**Modal** - 대화 상자를 연 소프트웨어와의 상호작용을 차단\n\n- System Modal - 닫기 전까지 다른 작업 불가. 과거 단일 작업 시스템에서 사용.\n- Application Modal - 프로그램 일시 중단. 워크플로우 방해로 비판받음.\n- Document Modal - 부모 창만 차단. macOS 시트 형태.\n\n**Modeless** - 소프트웨어의 다른 부분과 상호작용 허용. 대화 상자가 열려 있어도 작업 가능. 툴바가 예시.\n\n**고려 사항:**\n\n- 모달의 문제점 - 사용자 흐름 방해. 습관적으로 *확인*을 누르게 되어 작업 손실 가능.\n- 경고 대신 실행 취소 - 경고는 한계가 있음. 실행 취소(Undo) 기능이 더 효과적.[^313-1]\n- *경고를 사용하는 대신, 실행 취소를 제공하라*\n\n---\n\n[^313-1]: https://alistapart.com/article/neveruseawarning/"},{"i":"312","k":["excel","active-x-object","node"],"c":"2024-08-19","m":"2026-07-27","b":"[Excel View](https://jsr.io/@timepp/ev)[^312-1] 라이브러리를 보던 중, Excel과 Node의 상호작용에 대한 궁금증이 생겼다. 아무래도 웹개발을 하다보니 어플리케이션과 통신할 수 있는 부분에 대해서 전혀 생각을 안했었다는 걸 깨닫고 이러한 부분을 보완하기 위해 Excel과의 통신 방식을 찾아봄.\n\n우선, 위 라이브러리 코드를 통해 `ActiveXObject('Excel.Application')`[^312-2]로 Excel 객체를 생성하고, 이를 통해 Excel의 다양한 기능에 접근할 수 있다는 것을 확인했다. 그리고 [node-activex](https://github.com/durs/node-activex)의 문서에서 링크를 통해 추가적인 정보들을 확인할 수 있었는데 아무래도 자주 보던 영역이 아니라 일단 확인만 하는 단계에서 멈춤.\n\n이 접근을 통해, Excel과 상호작용하는 방법에 대해 실마리를 찾을 수 있었음. 그러나 모든 과정이 순조롭지만은 않은게. `ActiveXObject`를 사용하는 부분에서 개념적 이해가 부족했고, 그밖에 Excel의 [객체모델](https://learn.microsoft.com/en-us/office/vba/api/overview/excel/object-model)에 대해서도 배경지식이 많이 부족하다는 걸 알게됨.\n\n이번 경험을 통해, 웹 개발자가 어플리케이션 레벨의 개념들도 이해하면 좋겠다는 생각을 하게 됨. 나중에 기회가 되면 살펴보고 일단 view 기능을 스프레드시트로 구현해 봐야겠다.\n\n---\n\n[^312-1]: 활성 셀의 행 전체를 별도 창에 띄워서 좌우 스크롤 없이 보게 해주는 도구\n[^312-2]: https://github.com/timepp/excelview/blob/master/excel.js#L70"},{"i":"311","k":["react"],"c":"2024-08-13","m":"2026-09-01","b":"import {\n  transformerNotationFocus,\n  transformerMetaHighlight,\n} from '@shikijs/transformers'\nimport { Code } from 'astro:components'\nimport { code } from './311.code'\n\n> Avoiding premature abstraction with Unstyled React Components ([buildui.com](https://buildui.com/posts/avoiding-premature-abstraction-with-unstyled-react-components))\n\nReact 컴포넌트를 작성할 때, 불필요한 추상화를 피하고 컴포넌트의 유연성을 유지하는 방법. 특히 스타일이 없는 컴포넌트를 통해 어떻게 컴포넌트의 기능에 집중할 수 있는지를 설명.\n\n<Code\n  code={code}\n  lang=\"tsx\"\n  transformers={[transformerMetaHighlight()]}\n  meta=\"{}\"\n/>\n\n이 패턴은 컴포넌트를 작성할 때 불필요한 스타일링이나 구조를 미리 정의하지 않고, 각 컴포넌트가 자신의 역할에 충실할 수 있도록 도와줍니다. 이를 통해 코드의 유연성을 유지하고, 필요에 따라 컴포넌트를 확장하거나 수정할 수 있는 여지를 남겨두게 됩니다."},{"i":"310","t":"React Router의 Link 컴포넌트 테스트하기","k":["test","react-router"],"c":"2024-08-06","m":"2026-07-27","b":"React 애플리케이션에서 라우팅은 중요한 부분. 특히, 사용자가 링크를 클릭할 때 적절한 페이지로 이동하는지 테스트하는 것은 중요. 다음은 [React Router](https://reactrouter.com/en/main)의 `<Link />` 컴포넌트를 테스트하는 코드.\n\n```tsx\ndescribe('Link', () => {\n  it('이동', () => {\n    const Home = () => <Link to=\"/about\">About</Link>\n    const About = () => <h1>About</h1>\n\n    const { getByRole } = render(\n      <MemoryRouter initialEntries={['/']}>\n        <Routes>\n          <Route path=\"/\" element={<Home />} />\n          <Route path=\"/about\" element={<About />} />\n        </Routes>\n      </MemoryRouter>\n    )\n\n    fireEvent.click(getByRole('link'))\n\n    expect(screen.getByRole('heading')).toHaveTextContent('About')\n  })\n})\n```\n\n가끔 `<Link />`도 테스트해야 할 때가 있다. 위 코드는 사용자가 \"About\" 링크를 클릭하면 \"About\" 페이지로 이동하는지 테스트. fireEvent.click을 사용해 링크 클릭 이벤트를 발생시키고, screen.getByRole을 통해 헤딩 요소의 텍스트가 'About'인지 확인. 이 테스트를 통해 라우팅이 제대로 동작하는지 확인할 수 있음.\n\n---\n\n- https://github.com/remix-run/react-router/blob/main/packages/react-router/__tests__/dom/link-click-test.tsx"},{"i":"309","k":["enzyme","test","react-testing-library"],"c":"2024-07-11","m":"2026-07-27","b":"최근에 [Enzyme](https://github.com/enzymejs/enzyme)을 제거하고 [React Testing Library](https://github.com/testing-library/react-testing-library)로 교체하는 작업을 진행했습니다. [JSX](https://facebook.github.io/jsx/) 영역은 별 문제 없이 진행되었으나, state나 props를 다루는 구현 부분에서 약간의 애매함이 있었습니다. 개인적으로는 기존 접근 방식이 나쁘지 않았다고 생각했기 때문에, 새로운 접근 방식으로 전환하는 데 주저하게 되었습니다.\n\n결론적으로, state와 props의 JSON 결과물을 렌더링하고 [getByTestId](https://testing-library.com/docs/queries/bytestid/)를 사용하여 이를 참조하는 방식으로 테스트를 진행하기로 했습니다. 다음은 그 예시입니다:\n\n```tsx\nrender() {\n  return <>\n    {process.env.NODE_ENV === 'test' && (\n      <pre data-testid=\"ThumbnailDebug\">\n        {JSON.stringify({\n          props: this.props,\n          state: this.state,\n        })}\n      </pre>\n    )}\n  </>\n}\n```\n\n이 방법을 통해 Enzyme을 제거한다는 목표를 달성했기 때문에, 어느 정도는 해결된 것처럼 보이며, 추후 더 나은 테스트 코드를 작성하기 위한 고민을 할 수 있을 것 같습니다.\n\n---\n\n- https://slack.engineering/balancing-old-tricks-with-new-feats-ai-powered-conversion-from-enzyme-to-react-testing-library-at-slack/"},{"i":"307","k":["react","suspense"],"c":"2024-06-24","m":"2026-08-19","b":"```ts\nif (!data) {\n  throw fetch()\n}\n```\n\nSuspense는 던져진 promise를 캐치해서 resolve될 때까지 fallback을 렌더하고, resolve되면 다시 렌더한다. 던지는 건 예외와 같은 메커니즘이다.\n\n---\n\n- https://github.com/facebook/react/blob/eb8feb71096eec5c885b2a4c7d8d030d3622f265/packages/react/src/ReactLazy.js#L222\n- https://github.com/TanStack/query/blob/b8e35598ef24855a2792414faea5721d833fa61e/packages/react-query/src/suspense.ts#L61\n- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/throw\n- https://jser.pro/ddir/rie?reactVersion=18.3.1&codeKey=ud62nsxll29yy0dzba8"},{"i":"306","k":["nextjs"],"c":"2024-06-24","m":"2024-08-07","b":"Next.js Route Loader & Mini CSS Extract Plugin - prefetch와 CSS chunk 에러 처리\n\n**prefetchViaDom** - [route-loader.ts](https://github.com/vercel/next.js/blob/16cf88e569552fe5060f1d28a657b749b967528d/packages/next/src/client/route-loader.ts#L121-L150)\n\n```typescript\nfunction prefetchViaDom(\n  href: string,\n  as: string,\n  link?: HTMLLinkElement\n): Promise<any> {\n  return new Promise<void>((resolve, reject) => {\n    const selector = `\n      link[rel=\"prefetch\"][href^=\"${href}\"],\n      link[rel=\"preload\"][href^=\"${href}\"],\n      script[src^=\"${href}\"]`\n    if (document.querySelector(selector)) {\n      return resolve()\n    }\n\n    link = document.createElement('link')\n\n    // The order of property assignment here is intentional:\n    if (as) link!.as = as\n    link!.rel = `prefetch`\n    link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!\n    link!.onload = resolve as any\n    link!.onerror = () =>\n      reject(markAssetError(new Error(`Failed to prefetch: ${href}`)))\n\n    // `href` should always be last:\n    link!.href = href\n\n    document.head.appendChild(link)\n  })\n}\n```\n\n- 이미 prefetch/preload된 리소스면 스킵\n- `<link rel=\"prefetch\">` 생성 후 head에 추가\n- `href`는 항상 마지막에 설정 (브라우저 요청 타이밍 제어)\n\n**CSS Chunk Load Error** - [Mini CSS Extract Plugin](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/3df97b62778fc4586c5198b9f3a447dc65979529/src/index.js#L947C19-L958C22)\n\n```javascript\n Template.indent([\n  \"var errorType = event && event.type;\",\n  \"var realHref = event && event.target && event.target.href || fullhref;\",\n  'var err = new Error(\"Loading CSS chunk \" + chunkId + \" failed.\\\\n(\" + errorType + \": \" + realHref + \")\");',\n  'err.name = \"ChunkLoadError\";',\n  // TODO remove `code` in the future major release to align with webpack\n  'err.code = \"CSS_CHUNK_LOAD_FAILED\";',\n  \"err.type = errorType;\",\n  \"err.request = realHref;\",\n  \"if (linkTag.parentNode) linkTag.parentNode.removeChild(linkTag)\",\n  \"reject(err);\",\n]),\n```\n\n- `ChunkLoadError` 커스텀 에러 생성\n- 실패한 `<link>` 태그 DOM에서 제거 후 reject"},{"i":"305","k":["google-sheets","google-cloud"],"c":"2024-06-22","m":"2026-09-01","b":"[Google Sheets API](https://developers.google.com/sheets/api/guides/concepts)를 쓰려면 GCP 설정을 먼저 하고 [google-spreadsheet](https://github.com/theoephraim/node-google-spreadsheet)로 조작한다.\n\n**순서를 기억하려고 적어둔 것이다.** 각 단계의 방법이 아니라 밟는 차례가 내용이다.\n\n1. 서비스 계정 설정\n2. [Google Cloud](https://console.cloud.google.com/) 프로젝트 생성\n3. Google Sheets API 활성화\n4. API 자격 증명 생성\n5. 서비스 계정 생성\n6. 서비스 계정 키 생성\n7. 환경 변수 설정\n\n---\n\n- [How To Use Google Sheets as a Database With React via Next.js](https://thenewstack.io/how-to-use-google-sheets-as-a-database-with-react-and-ssr/)"},{"i":"304","t":"자연어 날짜 처리와 접근성 높은 데이트피커 - chrono와 inclusive-dates","k":["dates","chrono","datepicker","inclusive-dates"],"c":"2024-06-02","m":"2024-08-09","b":"[chrono](https://github.com/wanasit/chrono)는 다양한 형식의 날짜/시간을 처리하고 주어진 텍스트에서 정보를 추출할 수 있도록 설계된 자연어 날짜 파서.\n\n- \"Today\", \"Tomorrow\", \"Yesterday\", \"Last Friday\" 등의 상대적 날짜 처리\n- \"17 August 2013 - 19 August 2013\"와 같은 날짜 범위 처리\n- \"This Friday from 13:00 - 16.00\"와 같은 시간 포함 날짜 처리\n- \"5 days ago\", \"2 weeks from now\"와 같은 상대적 시간 표현 처리\n- \"Sat Aug 17 2013 18:40:39 GMT+0900 (JST)\"와 같은 표준 날짜 형식 처리\n- \"2014-11-30T08:15:30-05:30\"와 같은 ISO 8601 형식 처리\n\n`parse()`[^304-1][^304-2][^304-3]\n\n```ts\nparse(text: string, referenceDate?: ParsingReference | Date, option?: ParsingOption): ParsedResult[] {\n  // 1. 파싱 컨텍스트 생성\n  // 2. 모든 파서를 실행하고 결과 수집\n  // 3. 결과를 인덱스 기준으로 정렬\n  // 4. 모든 리파이너를 적용하여 결과 개선\n  // 5. 최종 결과 반환\n}\n```\n\n[inclusive-dates](https://github.com/fymmot/inclusive-dates)는 자연어 입력을 지원하는 사용자 친화적이고 완전히 접근 가능한 데이트피커. 내부적으로 chrono를 사용하여 자연어 날짜 입력을 처리. 이는 두 라이브러리의 장점을 결합한 좋은 예시.\n\n```tsx\nconst parsedDate = await chronoParseDate(text, {\n  locale: this.locale.slice(0, 2),\n  minDate: this.minDate,\n  maxDate: this.minDate,\n  referenceDate: removeTimezoneOffset(new Date(this.referenceDate)),\n  ...chronoOptions,\n})\n```\n\n1. chrono의 강력한 자연어 날짜 파싱 능력을 활용.\n2. inclusive-dates는 이를 사용자 친화적이고 접근성 높은 UI 컴포넌트로 구현.\n\n이러한 조합을 통해, 개발자들은 사용자에게 직관적이고 유연한 날짜 입력 방식을 제공하면서도 접근성과 사용성을 높일 수 있음.\n\n---\n\n[^304-1]: [executeParser](https://github.com/wanasit/chrono/blob/master/src/chrono.ts#L83)\n[^304-2]: [sort](https://github.com/wanasit/chrono/blob/master/src/chrono.ts#L87)\n[^304-3]: [refine](https://github.com/wanasit/chrono/blob/master/src/chrono.ts#L92)"},{"i":"303","t":"Grid View","k":[],"c":"2024-06-02","m":"2024-08-12","b":"> [grid view](<(https://en.wikipedia.org/wiki/Grid_view)>)(또는 datagrid)는 데이터의 표 형식 보기를 제공하는 그래픽 제어 요소입니다.\n\n일반적으로 다음 중 일부 또는 전부를 지원합니다.\n\n- 열 머리글을 클릭하여 그리드의 정렬 순서 변경\n- 열 머리글을 끌어서 크기 및 순서 변경\n- 보기 데이터의 제자리 편집\n- 행과 열 구분 기호 및 행 배경색 번갈아 지정하기\n\n---\n\n- https://github.com/adazzle/react-data-grid\n- https://github.com/glideapps/glide-data-grid\n- https://github.com/finos/perspective\n- https://github.com/teableio/teable"},{"i":"302","k":[],"c":"2024-05-16","m":"2024-05-27","b":"- [Cornerstone.js](https://github.com/cornerstonejs/cornerstone) HTML5 canvas를 지원하는 웹 브라우저에서 가벼운 의료 이미지 표시에 사용되는 JavaScript 라이브러리\n- [OpenSeadragon](https://github.com/openseadragon/openseadragon) 데스크톱 및 모바일에서 사용 가능한 고해상도 줌 가능한 이미지를 위한 순수 JavaScript 뷰어\n- [dicomParser](https://github.com/cornerstonejs/dicomParser) DICOM 파일을 처리하여 의료 이미지 데이터를 JavaScript 객체로 변환하는 라이브러리\n- [AMI Medical Imaging (AMI)](https://github.com/FNNDSC/ami) 웹에서 의료 영상을 효과적으로 표시하고 주석을 추가하는 데 사용되는 JavaScript 라이브러리\n- [OHIF Medical Imaging Viewer](https://github.com/OHIF/Viewers)\n- [itk-wasm](https://github.com/InsightSoftwareConsortium/itk-wasm) 의료 영상 처리를 위한 JavaScript 라이브러리\n- [Brainchop](https://github.com/neuroneural/brainchop)\n\n---\n\n예전에 관련 회사 기술 블로그 보다가 생각나서 찾아본 라이브러리 리스트. 대용량, 의학용 이미지 포멧을 다루기 위한 도구들."},{"i":"301","k":["astro","node","memory"],"c":"2024-04-21","m":"2024-06-06","b":"[Astro](https://astro.build/)를 사용한 프로젝트에서 빌드를 하는데 _JavaScript heap out of memory_ 에러가 발생했다.\n쉽게 해결하자면 [`NODE_OPTIONS=--max_old_space_size`(in megabytes)](https://nodejs.org/docs/latest-v20.x/api/cli.html#--max-old-space-sizesize-in-megabytes) 설정해서 우회할수는 있겠지만 정리가 필요해서 메모를 남겨본다.\n일단 원인은 파일사이즈가 크다는 점, 그리고 카테고리(국가)별 데이터가 많다는 점인데 개선할만한 부분은 두가지 정도인 듯.\n\n- 가능한 전처리해서 데이터를 다시 생성해서 참조\n- [`Astro.glob`](https://docs.astro.build/en/guides/imports/#astroglob)을 사용해서 조건부로 데이터를 가져오기\n\n예상치 못한 에러였는데 역시 트레이드오프는 존재하기 마련이다.\n\n---\n\n- https://www.stevefenton.co.uk/blog/2023/07/astro-javascript-heap-out-of-memory/"},{"i":"300","k":["pkg","node","nexe"],"c":"2024-04-07","m":"2024-04-07","b":"로컬에서만 실행 해야하는 프로젝트가 있길래 아무래도 사전설정 같은 귀찮은 문제가 있으니 [pkg](https://github.com/vercel/pkg)를 사용하면 좋을 것 같아서 들어가봤는데 개발이 중단 되었다.\n\n노드 21버젼에서 해당 기능이 지원되는데 재미있는 시도들이 많이 나왔으면 좋겠다.\n\n---\n\n- https://nodejs.org/api/single-executable-applications.html\n- https://github.com/nexe/nexe"},{"i":"297","k":["ui","ark-ui","component"],"c":"2023-10-07","m":"2024-03-22","b":"```css\n[data-scope='slider'][data-part='thumb']\n\n[data-scope='slider'][data-part='track']\n\n[data-scope='slider'][data-part='control']\n```\n\n> Ark UI의 각 컴포넌트 파트는 `data-scope` 및 `data-part` 속성으로 지정됩니다. `data-scope` 속성은 컴포넌트를 식별하고, `data-part` 속성은 컴포넌트의 개별 부분을 지정합니다.\n\n[ark-ui](https://ark-ui.com)에서 사용하는 방식인데 적용해볼만한 컨셉이라고 생각한다.\n\n---\n\n- https://ark-ui.com/docs/overview/getting-started"},{"i":"294","k":["proxy","debug"],"c":"2023-09-04","m":"2026-08-02","b":"요청 본문은 한 번만 읽을 수 있다. 누가 미리 읽었는지는 읽기 메서드를 Proxy 로 감싸서 찾는다.\n\n```js\nconst bodyReadingMethods = ['arrayBuffer', 'blob', 'formData', 'text', 'json']\n\nbodyReadingMethods.forEach((methodName) => {\n  request[methodName] = new Proxy(request[methodName], {\n    apply(...args) {\n      console.trace(`Premature \"request.${methodName}\" call!`)\n      return Reflect.apply(...args)\n    },\n  })\n})\n```\n\nhttps://redd.one/blog/debugging-like-a-pro-xy"},{"i":"292","k":["webpack","esm"],"c":"2023-08-28","m":"2024-03-22","b":"```json\nconfigureWebpack: {\n    module: {\n      rules: [\n        {\n          test: /\\.mjs$/,\n          include: /node_modules/,\n          type: 'javascript/auto',\n        },\n      ],\n    },\n  },\n```"},{"i":"289","k":["modal","sheet","bottom-sheet"],"c":"2023-08-07","m":"2024-03-22","b":"Bottom Sheet 라이브러리 비교\n\n**react-modal-sheet** 추천:\n- 활발한 유지보수 (v5 최근 릴리즈)\n- Framer Motion 기반, Compound component 패턴\n- `avoidKeyboard`, `disableDismiss` 등 유용한 옵션\n\n```tsx\nimport { Sheet } from 'react-modal-sheet'\n\n<Sheet isOpen={isOpen} onClose={() => setOpen(false)}>\n  <Sheet.Container>\n    <Sheet.Header />\n    <Sheet.Content>콘텐츠</Sheet.Content>\n  </Sheet.Container>\n  <Sheet.Backdrop />\n</Sheet>\n```\n\n> [!WARNING]\n> react-spring-bottom-sheet는 3년간 업데이트 없음. 신규 프로젝트에선 피할 것.\n\n---\n\n- [react-modal-sheet](https://github.com/Temzasse/react-modal-sheet)\n- [vaul](https://github.com/emilkowalski/vaul) - Radix 기반 대안\n- [Material Design - Bottom Sheet](https://m3.material.io/components/bottom-sheets)\n- [Apple HIG - Sheet](https://developer.apple.com/design/human-interface-guidelines/sheets)"},{"i":"287","k":["error"],"c":"2023-07-03","m":"2024-03-22","b":"```ts\nclass CustomError extends Error {\n  name = 'CustomError'\n\n  constructor(message?: string) {\n    super(message)\n\n    Object.setPrototypeOf(this, CustomError.prototype)\n  }\n}\n\ntry {\n  throw new CustomError('This is a custom error message.')\n} catch (error) {\n  if (error instanceof CustomError) {\n    console.log('CustomError occurred:', error.message)\n    console.log('Error name:', error.name)\n  } else {\n    console.log('An error occurred:', error)\n  }\n}\n```\n\n- https://github.com/Alex-D/check-disk-space/blob/main/src/errors/invalidPathError.ts\n- https://github.com/adriengibrat/ts-custom-error/blob/main/src/custom-error.ts\n- [📘 타입스크립트 커스텀 Error 처리하기](https://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%BB%A4%EC%8A%A4%ED%85%80-Error-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0)\n- [Handling errors like a pro in TypeScript | by Kolby Sisk | Udacity Eng & Data](https://engineering.udacity.com/handling-errors-like-a-pro-in-typescript-d7a314ad4991)"},{"i":"281","k":["debug"],"c":"2023-06-18","m":"2024-03-22","b":"```ts\nenum LogLevel {\n  DEBUG = 'debug',\n  INFO = 'info',\n  WARN = 'warn',\n  ERROR = 'error',\n}\n\ntype Message = string\n\nclass Logger {\n  private level: LogLevel\n\n  constructor(level: LogLevel = LogLevel.DEBUG) {\n    this.level = level\n  }\n\n  private log(level: LogLevel, message: Message) {\n    if (this.level === LogLevel.DEBUG || level !== LogLevel.DEBUG) {\n      const label = level.toUpperCase()\n\n      console.log(`[${label}] ${message}`)\n    }\n  }\n\n  /**\n   * - 개발 혹은 테스트 단계\n   * - 운영 환경에서는 남기고 싶지 않은 로그 메세지\n   */\n  public debug(message: Message) {\n    this.log(LogLevel.DEBUG, message)\n  }\n\n  /**\n   * - 정상 작동에 대한 정보\n   * - 시스템을 파악하는데 유익한 정보\n   */\n  public info(message: Message) {\n    this.log(LogLevel.INFO, message)\n  }\n\n  /**\n   * - 잠재적으로 문제가 될 수 있는 상황\n   * - 언제든 발생할 수 있는 일반적인 문제 상황\n   * - 사용자에게 노출되는 메세지에 상세한 가이드가 필요\n   */\n  public warn(message: Message) {\n    this.log(LogLevel.WARN, message)\n  }\n\n  /**\n   * - 심각한 오류나 예외 상황\n   * - 즉시 조치가 필요할때\n   */\n  public error(message: Message) {\n    this.log(LogLevel.ERROR, message)\n  }\n}\n```\n\n---\n\n[1. 효율적으로 로그 모니터링하기 - 로그 레벨 구분하기](https://jojoldu.tistory.com/712)"},{"i":"280","k":["zod"],"c":"2023-06-14","m":"2024-03-22","b":"```ts\nimport z from 'zod'\n\nconst envSchema = z.object({\n  REACT_APP_FEATURE_VAC_ASK: z.string(),\n  REACT_APP_FEATURE_RECORDS: z.string(),\n  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),\n})\n\nconst windowSchema = z.object({\n   SOMETHING_COOL: z.string()\n})\n\nexport const ENV = envSchema.parse(process.env)\nexport const WINDOW = windowSchema.parse(window)\n```\n\n---\n\n- [Using Zod to safely read env variables by sergiodxa](https://sergiodxa.com/articles/using-zod-to-safely-read-env-variables)\n- [GitHub - lostfictions/znv: Type-safe environment parsing and validation for Node.js with Zod schemas](https://github.com/lostfictions/znv)\n- https://twitter.com/mattpocockuk/status/1615110808219918352?s=20"},{"i":"279","k":["css","ui"],"c":"2023-06-13","m":"2024-03-22","b":"```ts\ntype Props = {\n  popover: 'auto' | 'manual'\n  popovertarget: string\n  popovertargetaction: 'hide' | 'show' | 'toggle'\n}\n\ntype State = {\n  hasBackdrop: boolean\n  isPopoverOpen: boolean\n}\n\ntype Methods = {\n  hidePopover: () => void\n  showPopover: () => void\n  togglePopover: () => void\n}\n\ntype Events = {\n  beforetoggle: () => void\n  toggle: () => void\n}\n```\n\n---\n\n- https://developer.chrome.com/blog/introducing-popover-api/\n- https://developer.mozilla.org/en-US/docs/Web/API/Popover_API"},{"i":"278","k":["zod","error"],"c":"2023-06-13","m":"2026-08-02","b":"zodios는 `ZodError`를 `ZodiosError.cause`에 담아 던진다 — `instanceof z.ZodError`로는 안 잡힌다.\n\n```ts\nif (err instanceof ZodiosError && err.cause instanceof ZodError) {\n  console.log(fromZodError(err.cause).toString())\n}\n```\n\n- [isErrorFromAlias not finding errors when uri have undefined params · Issue #402 · ecyrbe/zodios](https://github.com/ecyrbe/zodios/issues/402)\n- https://www.jacobparis.com/content/type-safe-env"},{"i":"277","k":["openapi","zod"],"c":"2023-06-09","m":"2026-08-02","b":"Swagger 2.0 스펙은 바로 못 먹인다. OpenAPI 3으로 바꾼 다음 zod 클라이언트를 만든다.\n\n```json\n{\n  \"scripts\": {\n    \"convert\": \"swagger2openapi ./spec.json -o ./spec.yaml\",\n    \"zod\": \"openapi-zod-client -a \\\"./spec.yaml\\\" -o \\\"./spec.ts\\\"\"\n  }\n}\n```"},{"i":"276","k":["datadog","sourcemaps"],"c":"2023-06-09","m":"2026-08-02","b":"올린 뒤 배포물에서 지운다 — Datadog은 이미 받았고 브라우저에는 노출되지 않는다.\n\n```sh\nVERSION=$(git log --pretty=format:'%h' -n 1)\n\nyarn datadog-ci sourcemaps upload ./build \\\n  --service \"$SERVICE\" \\\n  --minified-path-prefix \"$MINIFIED_PATH_PREFIX\" \\\n  --release-version \"$VERSION\"\n\nrm ./build/static/js/*.map\n```"},{"i":"275","k":["javascript","typescript","ts-pattern"],"c":"2023-05-29","m":"2024-03-22","b":"```ts\nimport { match } from 'ts-pattern'\n\ntype Format = 'webp' | 'jpg'\n\ntype Params = {\n  id: string\n  quality: keyof typeof QUALITY_MAP\n  format: Format\n}\n\nconst QUALITY_MAP = {\n  player_background: '0',\n  video_frames_start: '1',\n  video_frames_middle: '2',\n  video_frames_end: '3',\n  lowest_quality: 'default',\n  medium_quality: 'mqdefault',\n  high_quality: 'hqdefault',\n  standard_quality: 'sddefault',\n  unscaled_resolution: 'maxresdefault',\n}\n\nconst BASE_URL = 'https://i.ytimg.com'\n\nconst VI = (format: Format) =>\n  match(format)\n    .with('jpg', () => 'vi')\n    .otherwise(() => ['vi', format].join('_'))\n\nexport function getThumbnail({ id, quality, format }: Params) {\n  return [BASE_URL, VI(format), id, QUALITY_MAP[quality]]\n    .join('/')\n    .concat(`.${format}`)\n}\n```\n\n---\n\n- https://christianheilmann.com/2023/01/11/getting-all-the-video-thumbnails-from-a-youtube-video/"},{"i":"274","k":["terms","버스-팩터"],"c":"2023-05-28","m":"2026-07-27","b":"버스 팩터 — 한꺼번에 빠지면 프로젝트가 멈추는 최소 인원 수. 1이면 그 사람이 곧 단일 장애점.\n\n- [Bus factor - Wikipedia](https://en.wikipedia.org/wiki/Bus_factor)"},{"i":"272","k":["git","stacking","sapling"],"c":"2023-05-20","m":"2026-07-27","b":"스택 = 서로 의존하는 PR의 연쇄. 기능 브랜치는 main에서 분기하지만 스택은 부모 PR에서 분기한다 — 그래서 검토를 기다리는 동안 다음 조각을 이어서 작업할 수 있다.\n\n병합은 스택 아래부터. 하나가 병합되면 그 위 PR의 base를 갱신해야 한다. git 기본 기능만으로도 되지만 리베이스가 계속 따라붙어서, Graphite·Sapling 같은 도구가 그 부분을 자동화한다.\n\n- [Stacking | Graphite](https://graphite.dev/stacking)\n- [코드 리뷰의 또 다른 접근 방법: Pull Requests vs. Stacked Changes | 인프콘 2022 - YouTube](https://youtu.be/XRZPkYnWa48)\n- [Sapling](https://sapling-scm.com/)\n- [The stacking workflow](https://stacking.dev/)\n- [Stacked Diffs(Stacked PR)](https://sungjk.github.io/2024/03/17/stacked-diffs.html)"},{"i":"271","k":["json","diff","deep-object-diff","json-diff","microdiff"],"c":"2023-05-18","m":"2026-07-27","b":"- [deep-object-diff](https://github.com/mattphillips/deep-object-diff)\n- [json-diff](https://github.com/andreyvit/json-diff)\n- [microdiff](https://github.com/AsyncBanana/microdiff)"},{"i":"270","k":["react","ui","confirm","react-confirm","react-confirm-alert"],"c":"2023-05-14","m":"2026-09-01","b":"```jsx\nconst root = createRoot(document.querySelector('#confirm-root'))\n\nroot.render(<Confirm />)\n```\n\nconfirm ui를 만들다보면 `window.confirm`을 호출하는 방식으로 사용하는게 가장 좋은 방법인데 (안그러면 `state`로 관리해야하고 결국 이건 무의미한 코드의 반복이다.)\n이걸 react로 구현하려면 결국 render를 사용해야함. *react-confirm*, *react-confirm-alert* 둘다 소스를 보면 비슷한 방식으로 접근한다.\n\n- https://github.com/haradakunihiko/react-confirm/tree/master\n- https://github.com/GA-MO/react-confirm-alert/tree/master"},{"i":"269","k":["ui","react","zoom"],"c":"2023-05-14","m":"2026-07-27","b":"모바일 웹뷰에서 특정 영역을 *zoom*할수 있게 해달라는 요청이 있어서 적용한 내역. 정확히 기억은 안나는데 *react-prismazoom*를 선택했다.\n\n---\n\n그 외 비슷한\n\n- [react-quick-pinch-zoom](https://github.com/retyui/react-quick-pinch-zoom)\n- [react-zoom-pan-pinch](https://prc5.github.io/react-zoom-pan-pinch)\n- [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom)\n- [react-image-zooom](https://mario-duarte.github.io/react-image-zooom)\n- [use-pan-and-zoom](https://github.com/wouterraateland/use-pan-and-zoom)"},{"i":"268","k":["performance","tachyon","prefetch","prerender"],"c":"2023-05-01","m":"2026-07-27","b":"prefetch는 내려받기만 하고, prerender는 내려받은 뒤 렌더링까지 시작한다.\n\nTachyon은 `<a>`에 커서가 50ms 이상 머물면 `<link rel=\"prerender\">`를 넣어주는 라이브러리.\n\n- https://fasterthanlight.net/\n- https://developer.chrome.com/blog/prerender-pages/\n- https://developer.mozilla.org/en-US/docs/Glossary/Prefetch\n- https://developer.mozilla.org/en-US/docs/Glossary/Prerender"},{"i":"267","k":["prompt","llm"],"c":"2023-05-01","m":"2026-07-27","b":"원칙 — 명확하고 구체적인 지시, 그리고 모델에게 생각할 시간 주기.\n\n전술 — 구분자로 입력 경계 표시(```, \"\"\", < >, `<tag> </tag>`, `:`), 구조화된 출력 요청(JSON·HTML), 조건 충족 여부를 모델에게 확인시키기, few-shot 으로 스타일 고정.\n\n---\n\nhttps://www.deeplearning.ai/short-courses/chatgpt-prompt-engineering-for-developers/"},{"i":"257","k":["web-api","ambient-light-sensor"],"c":"2023-03-24","m":"2024-03-22","b":"```js\nasync function main({ onActivate, onError, onReading, onDenied }) {\n  const result = await navigator.permissions.query({\n    name: 'ambient-light-sensor',\n  })\n\n  if (result.state === 'denied') {\n    onDenied()\n    return\n  }\n\n  const ambientLightSensor = new AmbientLightSensor({ frequency: 20 })\n  \n  ambientLightSensor.addEventListener('activate', onActivate)\n  ambientLightSensor.addEventListener('error', onError)\n  ambientLightSensor.addEventListener('reading', () => {\n    const ISO = 100\n    const C = 250\n\n    const EV = Math.round(Math.log2((ambientLightSensor.illuminance * ISO) / C))\n\n    onReading(EV)\n  })\n\n  ambientLightSensor.start()\n}\n```\n\n---\n\n- [AmbientLightSensor - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/AmbientLightSensor)\n- [Ambient Light Sensor](https://www.w3.org/TR/ambient-light/)\n- [Sensors for the web - Chrome Developers](https://developer.chrome.com/articles/generic-sensor/)[^257-1]\n- [Ambient Light Sensor | Can I use... Support tables for HTML5, CSS3, etc](https://caniuse.com/ambient-light)\n\n---\n\n[^257-1]: 주변광 센서 인터페이스가 포함된 일반 센서 API를 사용하는 방법을 설명"},{"i":"255","k":["typescript","jsx","parser"],"c":"2023-03-19","m":"2026-07-27","b":"```js\nconst { Parser } = require('acorn')\nconst JSXParser = Parser.extend(require('acorn-jsx')())\n\nconst isReactComponent = Boolean(\n  JSON.stringify(\n    JSXParser.parse(fileContent, {\n      sourceType: 'module',\n      ecmaVersion: 'latest',\n    })\n  ).includes('JSXIdentifier')\n)\n```\n\n---\n\n- https://github.com/vitejs/vite/discussions/3448\n- https://github.com/jyash97/cra-to-vite/blob/master/src/index.js#L40-L43"},{"i":"252","k":["javascript","immer","Map","Set"],"c":"2023-03-13","m":"2024-03-22","b":"Immer가 생성하는 맵과 세트는 인위적으로 불변으로 만들어집니다. 즉, 프로듀서 외부에서 세트, 클리어 등과 같은 변경 메서드를 시도할 때 예외(throw an exception)가 발생합니다.\n\n```js\ntest('Map and Set', () => {\n  const baseMap = new Map();\n\n  const nextBaseMap = create(baseMap, (draft) => {\n    draft.set('a', 1);\n  });\n\n  expect(nextBaseMap).toMatchInlineSnapshot(`\n    Map {\n      \"a\" => 1,\n    }\n  `);\n});\n```\n\n---\n\n- [Map and Set | Immer](https://immerjs.github.io/immer/map-set/)"},{"i":"249","k":["npm"],"c":"2023-02-27","m":"2024-03-22","b":"npm을 사용하여 패키지의 여러 버전을 설치하려면 npm 설치 명령 뒤에 패키지 이름과 설치하려는 버전 번호를 사용하면 됩니다. 이 명령을 다른 버전 번호로 반복하여 여러 버전의 패키지를 설치할 수 있습니다.\n\n```shell\nyarn add react-tooltip-5@npm:react-tooltip@5.8.3\n```\n\n```json\n{\n  \"react-tooltip-5\": \"npm:react-tooltip@5.8.3\"\n}\n```\n\n---\n\n- [how to install multiple versions of package using npm - Stack Overflow](https://stackoverflow.com/questions/26414587/how-to-install-multiple-versions-of-package-using-npm)\n- [How to install multiple versions of a npm package at the same time/project - DEV Community](https://dev.to/3imed_jaberi/how-to-install-multiple-versions-of-a-npm-package-at-the-same-time-project-2a29)"},{"i":"247","k":["wcag","ux"],"c":"2023-02-20","m":"2026-07-27","b":"- [Human Interface Guidelines - Human Interface Guidelines - Design - Apple Developer](https://developer.apple.com/design/human-interface-guidelines/)\n- [All WCAG 2.1 Techniques | WAI | W3C](https://www.w3.org/WAI/WCAG21/Techniques/)\n- [Close, Exit, Cancel: How To End User Interactions Well | CSS-Tricks](https://css-tricks.com/close-exit-cancel-how-to-end-user-interactions-well)"},{"i":"243","k":["font"],"c":"2022-12-29","m":"2026-08-19","b":"```js\nasync function loader() {\n  const family = 'Material Symbols Outlined'\n  const source =\n    'url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v75/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsLjBuVY.woff2)'\n  const fontFace = new FontFace(family, source)\n\n  document.fonts.add(fontFace)\n  await fontFace.load()\n}\n```\n\n---\n\n- 참고: [CSS Font Loading API](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Font_Loading_API)"},{"i":"238","k":["db"],"c":"2022-12-11","m":"2024-03-22","b":"- [Textdb](https://textdb.dev/)[^238-1]\n- [JavascriptDB](https://javascriptdb.com/docs)[^238-2]\n\n---\n\n[^238-1]: 일반 텍스트 파일을 데이터 소스로 사용하여 간단한 웹 애플리케이션을 구축하고 배포하기 위한 플랫폼. 백엔드 서버나 데이터베이스 없이 배포 가능. 텍스트 파일에서 데이터를 읽고 쓰고 검색하는 데 사용할 수 있는 간단한 REST API를 제공.\n[^238-2]: JavaScript 애플리케이션용 경량 데이터베이스 엔진. 브라우저에서 데이터를 저장, 쿼리 및 조작하기 위한 간단하고 직관적인 API를 제공하며 챗봇, 검색 엔진 및 게임을 포함한 다양한 유형의 애플리케이션을 구축하는 데 사용 가능. 이 데이터베이스는 오프라인에서 작동하도록 설계되어 PWA(Progressive Web Application) 및 기타 유형의 클라이언트측 애플리케이션을 구축하는 데 이상적."},{"i":"237","k":["ffmpeg"],"c":"2022-12-11","m":"2024-03-22","b":"```shell\n# 비디오 자르기\nffmpeg -i INPUT.mp4 -ss 00:00:00 -to 00:01:00 -c:v copy -c:a copy OUTPUT.mp4\n```\n\n---\n\n- [Cutting the videos based on start and end time using ffmpeg - Stack Overflow](https://stackoverflow.com/questions/18444194/cutting-the-videos-based-on-start-and-end-time-using-ffmpeg)"},{"i":"235","k":["shell","node","env"],"c":"2022-12-11","m":"2026-07-27","b":"```sh\n# 빌드 시 timestamp를 참조하기 위한 방법\ntimestamp=$(date +%s) # 1670759329\n```\n\n```sh\n# 하지만 현실에서는 `TURBO_HASH`값을 참조하는 방식을 `.env`에서 사용\nREACT_APP_VERSION=1.0.0-${TURBO_HASH}\n```\n\n---\n\n- [Create timestamp variable in bash script - Stack Overflow](https://stackoverflow.com/questions/17066250/create-timestamp-variable-in-bash-script)[^235-1]\n- [dotenv-expand](https://github.com/motdotla/dotenv-expand#expand)[^235-2]\n\n---\n\n[^235-1]: 스크립트에서 현재 타임스탬프를 특정 형식의 변수로 만드는 방법에 대한 질문. 특정 형식 문자열과 함께 날짜 명령을 사용하여 타임스탬프를 만들고 변수에 저장하도록 하는 답변들...\n[^235-2]: dotenv-expand는 `.env` 파일에서 환경 변수를 확장하기 위한 모듈입니다. 중첩 변수 확장 및 셸 명령 실행과 같은 더 많은 고급 기능을 사용 가능."},{"i":"234","k":["node","webpack"],"c":"2022-12-11","m":"2024-03-22","b":"```bash\n# `System limit for number of file watchers reached`\n\necho fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p\n```\n\n---\n\n- [Watch and WatchOptions | webpack](https://webpack.js.org/configuration/watch/#not-enough-watchers)"},{"i":"233","k":["ui","design-system","headless"],"c":"2022-12-05","m":"2026-07-27","b":"- 공통 컴포넌트\n  - `Tabs` 만들면서 이건 어렵다는 결론\n- 대안\n  - [react-spectrum](https://react-spectrum.adobe.com/react-spectrum/index.html)\n    - [Consider upgrading to a component-based API](https://github.com/adobe/react-spectrum/discussions/2368)\n    - [react-aria-components](https://github.com/devongovett/react-aria-components)[^233-1]\n  - [radix-ui](https://www.radix-ui.com/)\n    - [shadcn-ui](https://ui.shadcn.com/)\n    - [Origin UI](https://originui.com/)\n    - [Building Low Level Components the Radix Way](https://alexkondov.com/building-low-level-components-the-radix-way/)\n  - [Headless UI](https://headlessui.com/)\n  - [Reach UI](https://reach.tech/)\n  - [Ariakit](https://ariakit.org/)\n  - [Base UI](https://base-ui.com/)\n  - [JollyUI](https://www.jollyui.dev/)\n\n---\n\n> 좋은 시스템일수록 실제 서비스에 적용하기는 더 어렵다는 문제가 발생. 결국 필요한건 _headless_ 인데 생각보다 많지 않고 어떤 선택이 좋은 선택인가에 대한 조심스러운 부분이 있다.\n\n---\n\n[^233-1]: [참고](https://reactspectrum.blob.core.windows.net/reactspectrum/cee46dbc35a111b8e4ba2da28da542783de00a44/docs/react-aria/Button.html)"},{"i":"232","k":["editor","input"],"c":"2022-12-05","m":"2026-07-27","b":"- [react-mentions](https://www.npmjs.com/package/react-mentions)[^232-1]\n- [tribute](https://github.com/zurb/tribute)[^232-2]\n- [react-input-trigger](https://github.com/abinavseelan/react-input-trigger)[^232-3]\n- [react-contenteditable](https://github.com/lovasoa/react-contenteditable)[^232-4]\n- [draft-js](https://github.com/facebook/draft-js)[^232-5]\n\n---\n\n[^232-1]: `textarea`에서 특정 키입력이 발생할 경우 액션(멘션)을 발생시키는 기능\n[^232-2]: 옜날에는 어떻게 한거지? 궁금해서 찾아봤습니다\n[^232-3]: 입력필드에서 문자 트리거\n[^232-4]: [contenteditable](https://developer.mozilla.org/ko/docs/Web/HTML/Global_attributes/contenteditable) 컴포넌트\n[^232-5]: 의식의 흐름 속 갑자기 생각나버림"},{"i":"231","k":["monorepo"],"c":"2022-11-28","m":"2024-03-22","b":"```yaml\nversion: 1\napplications:\n  - frontend:\n      phases:\n        preBuild:\n          commands:\n            # AWS Amplify 에서 모노레포 구조를 사용 할 경우 root 레벨로 올라가서 install\n            - cd ../../\n            - echo \"$PWD\"\n            - yarn install\n        build:\n          commands:\n            - echo \"$PWD\"\n            # 현재 root로 이동한 상태이므로 $AMPLIFY_MONOREPO_APP_ROOT를 바로 참조하도록 설정\n            - if [ $NODE_ENV_VARIABLES = \".env.development\" ]; then cat \"./$AMPLIFY_MONOREPO_APP_ROOT/$NODE_ENV_VARIABLES\" > \"./$AMPLIFY_MONOREPO_APP_ROOT/.env.production\"; fi\n            - yarn run \"build:$AMPLIFY_MONOREPO_APP\"\n      artifacts:\n        baseDirectory: build\n        files:\n          - '**/*'\n      cache:\n        paths:\n          - node_modules/**/*\n    appRoot: apps/app\n```\n\n---\n\n- [Monorepo build settings - AWS Amplify Hosting](https://docs.aws.amazon.com/amplify/latest/userguide/monorepo-configuration.html)"},{"i":"229","k":["create-react-app","node"],"c":"2022-11-21","m":"2024-03-22","b":"```sh\n# .env\nREACT_APP_VERSION=0.0.0-${TURBO_HASH}\n```\n\nREACT_APP_VERSION을 자동으로 입력할 방법을 찾다가 결국 hash값을 추가했다.\n\n---\n\n- [Adding Custom Environment Variables | Create React App](https://create-react-app.dev/docs/adding-custom-environment-variables/#expanding-environment-variables-in-env)\n- [GitHub - motdotla/dotenv-expand: Variable expansion for dotenv. Expand variables already on your machine for use in your .env file.](https://github.com/motdotla/dotenv-expand)\n- [Create timestamp variable in bash script - Stack Overflow](https://stackoverflow.com/questions/17066250/create-timestamp-variable-in-bash-script)"},{"i":"228","k":["create-react-app","deprecated"],"c":"2022-11-14","m":"2024-03-22","b":"```sh\nyarn add -D react-app-rewired customize-cra\n```\n\n```json\n// package.json\n\"scripts\": {\n  \"start\": \"react-app-rewired start\",\n  \"build\": \"react-app-rewired build\",\n  \"test\": \"react-app-rewired test\"\n},\n```\n\n```json\n// .babelrc\n{\n  \"plugins\": [\n    [\"@babel/plugin-proposal-decorators\", { \"version\": \"legacy\" }],\n    [\"@babel/plugin-proposal-class-properties\", { \"loose\": true }]\n  ]\n}\n```\n\n```js\n// config-overrides.js\nconst { useBabelRc, override } = require('customize-cra')\n\nmodule.exports = override(useBabelRc())\n```\n\n---\n\n- [Simplest Way to Install Babel Plugins in Create React App - DEV Community 👩‍💻👨‍💻](https://dev.to/ansonh/simplest-way-to-install-babel-plugins-in-create-react-app-7i5)\n\n---\n\n> [CRACO](https://craco.js.org/)로 구성하는게 지금은 맞는 것 같다."},{"i":"227","k":["pagination"],"c":"2022-11-07","m":"2024-03-22","b":"```ts\ninterface PaginationProps {\n  /** 현재 페이지 번호 */\n  currentPage: number;\n  /** 전체 페이지 수 */\n  totalPages: number;\n  /** 페이지 변경 시 호출되는 콜백 함수 */\n  onPageChange: (pageNumber: number) => void;\n  /** 이전/다음 페이지 링크 표시 여부 (선택적) */\n  showPreviousNext?: boolean;\n  /** 페이지 번호 링크 표시 여부 (선택적) */\n  showPageNumbers?: boolean;\n  /** 페이지당 항목 수 (선택적) */\n  pageSize?: number;\n}\n```\n\n- [cbcruk/react-flat-pagination](https://github.com/cbcruk/react-flat-pagination)\n- [mayankshubham/react-pagination](https://github.com/mayankshubham/react-pagination)\n- [react-component/pagination](https://github.com/react-component/pagination)\n- [wwwaiser/react-js-pagination](https://github.com/wwwaiser/react-js-pagination)\n- [material-ui/react-pagination/](https://mui.com/material-ui/react-pagination/)\n- [Pagination - NuxtLabs UI](https://ui.nuxtlabs.com/navigation/pagination)\n- [AdeleD/react-paginate](https://github.com/AdeleD/react-paginate)\n  - [How to Implement Pagination in React](https://hygraph.com/blog/react-pagination)\n\n---\n\n> 하지만 너무 많은 기능이 필요없기 때문에 최대한 단순한 컴포넌트를 만들었다."},{"i":"223","k":["mobx","async","action"],"c":"2022-10-16","m":"2024-03-22","b":"MobX 비동기 action 처리 - `action`은 현재 스택에만 적용됨. await/then 이후 상태 변경은 별도 action 필요.\n\n```javascript\n// ❌ then 콜백은 action 범위 밖\n@action fetchProjects() {\n  fetchSomething().then(projects => {\n    this.data = projects  // 에러!\n  })\n}\n\n// ✅ 방법 1: runInAction\n@action async fetchProjects() {\n  const data = await fetchSomething()\n  runInAction(() => {\n    this.data = data\n  })\n}\n\n// ✅ 방법 2: flow (권장) - await 대신 yield\nfetchProjects = flow(function* () {\n  this.state = \"pending\"\n  try {\n    this.data = yield fetchSomething()  // 자동으로 action 래핑\n    this.state = \"done\"\n  } catch (e) {\n    this.state = \"error\"\n  }\n})\n```\n\n> [!TIP]\n> `flow`는 async/await와 동일하게 작동하면서 수동 action 래핑 불필요. 취소도 가능 (`cancel()`).\n\n- [MobX Actions 문서](https://github.com/mobxjs/mobx/blob/mobx4and5/docs/best/actions.md)"},{"i":"222","k":[],"c":"2022-10-16","m":"2024-03-22","b":"- [Choose Boring Technology](https://boringtechnology.club/)[^222-1][^222-2]\n\n```md\n- 프로젝트에 새롭고 반짝이는 기술보다는 지루한 기술을 선택하는 것이 좋습니다.\n- 흥미로운 기술은 프로젝트에 불필요한 위험을 초래할 수 있습니다.\n- 이미 확립되고 입증된 기술이 성공적인 결과로 이어질 가능성이 더 높습니다.\n- 신기술을 둘러싼 과대 광고는 오해의 소지가 있습니다.\n- 신기술의 수명을 예측하기는 어렵습니다.\n- 지루한 기술은 화려하지 않을 수 있지만 안정적이고 신뢰할 수 있으며 예측 가능합니다.\n- 최신 기술 유행보다는 비즈니스 문제 해결에 집중하세요.\n- 사람과 프로세스에 투자하면 더 나은 결과를 얻을 수 있습니다.\n- 지루한 기술을 선택하는 것은 보수적이거나 위험을 피하기 위한 것이 아니라 프로젝트의 장기적인 성공을 우선시하는 정보에 입각한 결정을 내리기 위한 것입니다.\n```\n\n---\n\n[^222-1]: [Dan McKinley :: Choose Boring Technology](https://mcfunley.com/choose-boring-technology)\n[^222-2]: 항상 최신의 최첨단 솔루션을 쫓기보다는 확립되고 신뢰할 수 있는 기술을 사용하는 것을 지지하는 글. 특히 장기 프로젝트의 경우 기술 선택에서 안정성과 예측 가능성의 중요성을 강조."},{"i":"221","k":["code-review"],"c":"2022-10-16","m":"2026-07-27","b":"- [피드백 사다리: Netlify에서 코드 리뷰를 인코딩하는 방법](https://www.netlify.com/blog/2020/03/05/feedback-ladders-how-we-encode-code-reviews-at-netlify/)[^221-2]\n- [기본적으로 코드 리뷰 없음](https://www.raycast.com/blog/no-code-reviews-by-default/)[^221-3]\n- [Post-Commit Reviews](https://copyconstruct.medium.com/post-commit-reviews-b4cc2163ac7a)\n\n---\n\n[^221-2]: Feedback Ladder: ⛰ 산(Mountain) / 차단 및 즉각적인 조치 필요, 🧗‍♀️ 볼더(Boulder) / 블로킹, ⚪️ 자갈(Pebble) / 비차단하지만 향후 조치가 필요함, ⏳ 모래(Sand) / 논블로킹이지만 향후 고려 필요, 🌫 먼지(Dust) / 비차단, \"받거나 놔두세요\"\n[^221-3]: 다른 사람들의 상황이 자신에게 적용되는지 자문해볼 필요가 있다."},{"i":"218","k":["bdd","test"],"c":"2022-10-16","m":"2024-03-22","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://school.cucumber.io/courses/take/bdd-with-cucumber-javascript\">\n  BDD with Cucumber\n</QuoteLink>\n\nBDD는 무엇을 의미합니까?\n\n> BDD는 시스템의 원하는 동작을 협력적으로 지정하는 접근 방식입니다. 행동의 일부가 합의될 때마다 우리는 그 행동을 구현할 코드의 개발을 \"추진\"하기 위해 해당 사양을 사용합니다.\n\nBDD의 세 가지 관행은 무엇이며 스토리에 어떤 순서로 적용합니까?\n\n> 우리는 스토리에 필요한 행동의 범위를 공동으로 발견 하는 것으로 시작합니다. 일단 우리가 행동에 동의하면 우리는 비즈니스가 읽을 수 있는 언어로 사양을 공식화 합니다. 마지막으로 공식화된 사양을 자동화 하여 시스템이 실제로 예상대로 작동하는지 확인합니다.\n\nCucumber와 BDD는 어떤 관련이 있습니까?\n\n> Cucumber는 문서를 이해하고 자동화된 테스트로 변환하는 도구입니다.\n>\n> BDD는 세 가지 방식으로 구성된 협업적 접근 방식입니다. BDD 실무자는 Cucumber를 사용하여 문서를 자동화할 수 있습니다.\n\n\"살아있는 문서\"의 특별한 점은 무엇입니까?\n\n> 문서가 애플리케이션의 동작과 동기화되지 않을 때 자동으로 알려 주기 때문에 \"살아있는 문서\"라고 부릅니다. 그것이 특별한 점입니다.\n>\n> 완료에 대한 정의의 일부로 이를 검토할 수 있지만 자동으로 유효성이 검사되지 않더라도 작성한 모든 사양 문서에 대해서도 마찬가지입니다.\n>\n> 그것은 자동화된 테스트에 의해 생성 되지 않습니다 - 여전히 작성해야 합니다! 자동화된 테스트는 귀하가 작성한 내용이 사실인지 아닌지를 알려줍니다.\n>\n> 이를 위한 변경 제어 프로세스가 있을 수 있습니다. 설명하는 코드와 함께 소스 제어에 유지하는 것이 좋습니다. 그러나 다시 말해서 특별한 것은 아닙니다. Word 문서에 대해 놀랍도록 투명한 변경 제어 프로세스를 가질 수 있지만 여전히 완전히 구식이고 잘못된 것일 수 있습니다."},{"i":"214","k":["image"],"c":"2022-10-16","m":"2024-03-22","b":"- [OG Image Generation – Vercel Docs](https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation)\n  - [GitHub - vercel/satori: Enlightened library to convert HTML and CSS to SVG](https://github.com/vercel/satori#documentation)\n  - [Vercel OG Image Playground](https://og-playground.vercel.app/)\n\n---\n\n[커스텀 폰트](https://vercel.com/docs/concepts/functions/edge-functions/og-image-examples#using-a-custom-font) 사용 시, 배포 중 **Provided Edge Function is too large** 에러가 발생할 경우 로컬 파일로 사용하지말고 `fetch`로 해당 폰트를 가져온다.[^214-1][^214-2]\n\n---\n\n[^214-1]: https://github.com/cbcruk/oz/commit/0cb90b558cca8bf97bfcc0b36914447845131ab7#diff-ea54c769db531913b015e15682e5b3f4364de740cc7d6c1cb2c8ee2a9ce77379L9-R14\n[^214-2]: https://vercel.com/docs/concepts/functions/edge-functions/limitations#code-size-limit"},{"i":"213","k":["package","shell","npm","node"],"c":"2022-10-12","m":"2024-03-22","b":"```json\n// 현재 실행된 stage 값을 참조하기\n// 하지만 스크립트를 따로 작성하는게 더 효율적으로 보인다.\n\n{\n  \"scripts\": {\n    \"dev:hello\": \"echo ${npm_lifecycle_event//dev:/}\"\n  }\n}\n```\n\n---\n\n- [yarnpkg - Is there a way to get the name of the npm script passed to the command specified by that script? - Stack Overflow](https://stackoverflow.com/questions/62865856/is-there-a-way-to-get-the-name-of-the-npm-script-passed-to-the-command-specified)[^213-1]\n- [scripts | npm Docs](https://docs.npmjs.com/cli/v8/using-npm/scripts)[^213-2]\n\n---\n\n[^213-1]: npm 스크립트의 이름을 얻는 방법에 대한 질문과 `npm_lifecycle_event` 환경 변수를 사용하는 방법으로 제안\n[^213-2]: package.json 파일에서 스크립트를 사용하는 방법에 대한 개요, 기본 제공 환경 변수 목록도 포함"},{"i":"210","k":["npm","node"],"c":"2022-09-29","m":"2024-03-22","b":"npm 레지스트리에서 특정 패키지에 대한 메타데이터 및 기타 정보를 볼 수 있습니다. 이 명령으로 패키지의 최신 버전, 패키지의 종속성, 작성자 및 라이선스 정보, 기타 세부 정보를 확인할 수 있습니다.\n\n```shell\n# 이전 버전 리스트를 확인하고 싶을때\nnpm view cowsay versions\n\n# 각 버전이 게시된 시간을 확인\nnpm view cowsay time\n```\n\n---\n\n- [npm-view | npm Docs](https://docs.npmjs.com/cli/v6/commands/npm-view)\n- [Find the version of an installed npm package | Sentry](https://sentry.io/answers/version-of-installed-npm-package/)[^210-1]\n\n---\n\n[^210-1]: 설치된 npm 패키지의 버전을 확인하는 방법에 대한 가이드"},{"i":"209","k":["aws"],"c":"2022-09-26","m":"2024-03-22","b":"[Clear CloudFront Cache with AWS CLI | bobbyhadz](https://bobbyhadz.com/blog/aws-cli-clear-cloudfront-cache#clearing-cloudfront-cache-for-the-entire-distribution)\n\n```js\n/**\n * CloudFront 배포에 대한 캐시를 무효화하려면 경로와 함께 명령을 실행합니다\n * \n * @param {object} params\n * @param {string} params.distributionId\n * @param {string} params.paths\n * @returns {CreateInvalidationResult}\n */\nfunction createInvalidation({\n  distributionId,\n  paths = `\"/*\"`\n}) {\n  const result = $`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`\n  return result\n}\n```"},{"i":"208","k":["aws"],"c":"2022-09-25","m":"2024-03-22","b":"Lambda@Edge (Node) 함수 작성 시 npm module을 사용하는 방법. **esbuild**를 사용하면 편하다.\n\n```json\n{\n  \"scripts\": {\n    \"build\": \"esbuild --bundle --minify --platform=node --target=node12 --outdir=build main.js\",\n    \"export\": \"cd build && zip main.js.zip main.js\"\n  }\n}\n```\n\n---\n\n- [amazon web services - How to load npm modules in AWS Lambda? - Stack Overflow](https://stackoverflow.com/questions/34437900/how-to-load-npm-modules-in-aws-lambda)\n- [함수를 사용하여 엣지에서 사용자 지정 - Amazon CloudFront](https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html)"},{"i":"207","k":["extensions"],"c":"2022-09-24","m":"2024-03-22","b":"- [Check whether user has a Chrome extension installed](https://stackoverflow.com/questions/6293498/check-whether-user-has-a-chrome-extension-installed) 특정 확장도구가 설치되어 있는지 판별하는 방법\n\n```sh\n# 크롬 익스텐션이 설치되는 경로는 다음과 같다\n/Users/USERNAME/Library/Application Support/Google/Chrome/Default/Extensions\n```\n\n```js\nfunction isInstalled(extensionId) {\n  return new Promise((resolve, reject) => {\n    const img = new Image()\n\n    img.src = `chrome-extension://${extensionId}/icon-128.png` // 해당 리소스가 `web_accessible_resources`에 선언되어 있는지 확인이 필요하다.\n    img.onload = () => {\n      resolve(true)\n    }\n    img.onerror = () => {\n      reject()\n    }\n  })\n}\n```"},{"i":"203","k":["react"],"c":"2022-09-23","m":"2024-03-22","b":"- [React + Webpack: ChunkLoadError: Loading chunk X failed. | by Raphaël Léger | Medium](https://raphael-leger.medium.com/react-webpack-chunkloaderror-loading-chunk-x-failed-ac385bd110e0)\n- [How to fix ChunkLoadError in your ReactJS application - Codemzy's Blog](https://www.codemzy.com/blog/fix-chunkloaderror-react)\n\n---\n\n`lazy`로 `import`했을 경우 *ChunkLoadError*가 발생하는데 이럴 경우 어떻게 대응할 수 있는지 정리해둔 글들."},{"i":"202","k":["git"],"c":"2022-11-03","m":"2024-03-22","b":"```sh\ngit remote add {alias} {url}\ngit fetch {alias}\ngit merge --allow-unrelated-histories {alias}/{branch}\n```\n\n---\n\n- [[git] 서로 다른 repository 합치기 — Falcon](https://m-falcon.tistory.com/522)\n- [How do you merge two Git repositories? - Stack Overflow](https://stackoverflow.com/questions/1425892/how-do-you-merge-two-git-repositories)\n- [How to merge git repositories preserving git history - JDriven Blog](https://blog.jdriven.com/2021/04/how-to-merge-multiple-git-repositories/)\n- [Git - git-mv Documentation](https://git-scm.com/docs/git-mv)\n- [Merging two GitHub repositories without losing commit history - Mozilla Hacks - the Web developer blog](https://hacks.mozilla.org/2022/08/merging-two-github-repositories-without-losing-commit-history/)"},{"i":"200","k":["react"],"c":"2022-09-23","m":"2024-03-22","b":"```js\n// cannot be used as a JSX component\n```\n\n`@types/react`, `@types/react-dom`에 대한 참조가 잘못되면서 발생하는 이슈\n\n---\n\n- [【React, TypeScript】「cannot be used as a JSX component...」エラー - Qiita](https://qiita.com/ayaka105/items/2cee20f99bf45aadffda)\n- [javascript - 'Component' cannot be used as a JSX component. Nextjs - Stack Overflow](https://stackoverflow.com/questions/71974065/component-cannot-be-used-as-a-jsx-component-nextjs)\n- [Component cannot be used as a JSX component in React | bobbyhadz](https://bobbyhadz.com/blog/react-cannot-be-used-as-a-jsx-component)"},{"i":"199","k":["storybook","chromatic"],"c":"2022-09-23","m":"2024-03-22","b":"[chromatic](https://www.chromatic.com/)\n\n- [Chromatic Storybook - Secure you project token in Next.js](https://www.sabbaticaldev.co.uk/post/chromatic-storybook)[^199-1]\n\n---\n\n[^199-1]: cli를 사용할 경우 `CHROMATIC_PROJECT_TOKEN`을 `.env`에서 관리하면 된다"},{"i":"197","k":["vite"],"c":"2022-09-19","m":"2024-03-22","b":"- [https://twitter.com/youyuxi/status/1362050255009816577](https://twitter.com/youyuxi/status/1362050255009816577)[^197-1]\n- [How to use `.js` instead of `.jsx`](https://github.com/vitejs/vite/discussions/3448)[^197-2]\n  - [How do you move multiple files in git? - Stack Overflow](https://stackoverflow.com/questions/2212857/how-do-you-move-multiple-files-in-git)\n\n```shell\n# 아마도 git을 사용하고 있을테니까 `git mv`를 사용해서 변경해주자\nfind src -type f | grep \"\\.[jt]s$\" | xargs -n1 grep -HE \"^[^*\\n]*(<\\/?[a-zA-Z]*>)[\\s\\w]*$\" | cut -d: -f1 | uniq | awk '{print \"git mv \"$1\" \"$1\"x\"}' | sh\n```\n\n---\n\n[^197-1]: Vite가 JSX 처리를 위해 .jsx 확장자를 요구하는 이유는 대부분의 경우 일반 .js 파일이 브라우저에서 작동하기 위해 전체 AST 변환이 필요하지 않아야 하기 때문입니다. .js 파일에서 JSX를 허용한다는 것은 제공되는 모든 파일이 JSX를 포함하는 경우에 대비하여 전체 AST 처리되어야 함을 의미합니다.\n[^197-2]: [`.jsx`](https://github.com/vitejs/vite/discussions/3448#discussioncomment-1859773) 또는 [`.js`](https://github.com/vitejs/vite/discussions/3448#discussioncomment-749919)를 사용해야할 경우에 대한 답변들이 소개되어 있다."},{"i":"193","k":["react","hack","dangerously-set-inner-html"],"c":"2022-09-18","m":"2024-03-22","b":"```jsx\n<div\n  dangerouslySetInnerHTML={{\n    __html: `\n      <img src=\"http://unsplash.it/100/100?random\" onload=\"console.log('you got hacked');\" />\n    `,\n  }}\n/>\n```\n\n가끔 아주 가끔 이상한 일을 해야할때가 있는데 그럴때는 이렇게 하면 된다.\n\n---\n\n- [How to inject scripts using dangerouslysetinnerhtml on Client-Side Rendering? | by SunCommander | Medium](https://suncommander.medium.com/how-to-inject-scripts-using-dangerouslysetinnerhtml-on-client-side-rendering-973037cc06b7)"},{"i":"191","k":["javascript","bug"],"c":"2022-09-18","m":"2024-03-22","b":"```js\nconst obj = {\n  a: 1,\n  b: 2,\n}\n\nconsole.log(obj[['a']]) // 1\nconsole.log(obj[['b']]) // 2\n```\n\n이게 되네 🤔"},{"i":"189","k":["git"],"c":"2022-06-18","m":"2024-03-22","b":"```sh\n# .gitignore가 규칙이 적용되지 않을때\ngit rm -rf --cached .\ngit add .\n```\n\n---\n\n- https://stackoverflow.com/questions/25436312/gitignore-not-working"},{"i":"187","k":["blob"],"c":"2022-05-29","m":"2024-03-22","b":"[blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#parameters)\n\n```js\nnew Blob(array, { type : 'text/html' })\n```\n\n파일 다운로드 생성이 잘 안되서 확인해보니 `type` 지정을 안했다."},{"i":"183","k":["photoshop"],"c":"2022-04-09","m":"2024-03-22","b":"[GitHub - meltingice/psd.js: A Photoshop PSD file parser for NodeJS and browsers](https://github.com/meltingice/psd.js)\n\npsd 파서. 이를 통해 관리 가능한 트리 구조에서 Photoshop 문서로 작업하고 다음과 같은 중요한 데이터를 찾을 수 있습니다.\n\n- 문서 구조\n- 문서 크기\n- 레이어/폴더 크기 + 위치 지정\n- 레이어/폴더 이름\n- 레이어/폴더 가시성 및 불투명도\n- 글꼴 데이터( psd-enginedata 를 통해 )\n  - 텍스트 영역 내용\n  - 글꼴 이름, 크기 및 색상\n- 색상 모드 및 비트 심도\n- 벡터 마스크 데이터\n- 병합된 이미지 데이터\n- 레이어 구성 요소"},{"i":"173","k":["font"],"c":"2022-04-09","m":"2026-07-27","b":"- [A Variable Fonts Primer](https://variablefonts.io/)\n- [Google Fonts](https://fonts.google.com/specimen/Hahmlet?vfonly=true&subset=korean#standard-styles)\n- [Getting the Most Out of Variable Fonts on Google Fonts | CSS-Tricks](https://css-tricks.com/getting-the-most-out-of-variable-fonts-on-google-fonts/)\n- [Variable Fonts for Developers](https://variablefonts.dev/)\n- [Using CSS Custom Properties to Adjust Variable Font Weights in Dark Mode | CSS-Tricks](https://css-tricks.com/using-css-custom-properties-to-adjust-variable-font-weights-in-dark-mode/)[^173-1]\n\n---\n\n[^173-1]: 다크모드에서 weight가 시각적으로 차이가 날수 있기때문에 그럴경우 변수로 분리해서 대응하는 방법"},{"i":"172","k":["font"],"c":"2022-04-09","m":"2024-03-22","b":"- [5 steps to faster web fonts /// Iain Bean](https://iainbean.com/posts/2021/5-steps-to-faster-web-fonts/)\n- [How to Stop Lighthouse Complaining About Render Blocking Google Fonts | Codeboosh](https://codeboosh.com/how-to-stop-lighthouse-complaining-about-render-blocking-google-fonts/)\n- [A Comprehensive Guide to Font Loading Strategies—zachleat.com](https://www.zachleat.com/web/comprehensive-webfonts/)\n- [How to avoid layout shifts caused by web fonts – Simon Hearne](https://simonhearne.com/2021/layout-shifts-webfonts/)\n- [The Fastest Google Fonts – CSS Wizardry – Web Performance Optimisation](https://csswizardry.com/2020/05/the-fastest-google-fonts/)\n- [Time to Say Goodbye to Google Fonts](https://wicki.io/posts/2020-11-goodbye-google-fonts/)\n- [The Best Font Loading Strategies and How to Execute Them | CSS-Tricks](https://css-tricks.com/the-best-font-loading-strategies-and-how-to-execute-them/)\n\n요약하자면\n\n- woff2\n- font-display로 레이아웃 이슈 최소화\n- preload\n- self (feat. [google webfonts helper](https://google-webfonts-helper.herokuapp.com/fonts))\n\n몇몇 권장사항은 한글 폰트에서는 사실상 불가능한 부분이라 가능한 부분들을 [적용](https://github.com/cbcruk/zitat/commit/8587c7f9669ad5b97facec9c4b2c918d7cb300ef)하면 된다."},{"i":"168","k":["autofill","html"],"c":"2022-04-09","m":"2024-03-22","b":"자동완성 기능은 편리할수도 있지만 비활성화가 필요한 경우도 있다. 하지만 chrome에서 무시되는 경우가 있는데...이게 거의  된다 지금은 안된다...되는 것 같다...이렇게 변질이 되는 것 같다.\n\n- [html - Disabling Chrome Autofill - Stack Overflow](https://stackoverflow.com/questions/15738259/disabling-chrome-autofill)[^168-1]\n- [Disabling autofill in Chrome | Codementor](https://www.codementor.io/@leonardofaria/disabling-autofill-in-chrome-zec47xcui)[^168-2]\n- [How to turn off form autocompletion - Web security | MDN](https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion)\n- [The Autofill Dark Pattern — Smashing Magazine](https://www.smashingmagazine.com/2021/10/autofill-dark-pattern/)\n\n---\n\n[^168-1]: 가끔 들어가보면 여전히 진행중이다...\n[^168-2]: 글쓴이는 결국 안되서 스크립트로 focus될때 `readonly` 속성으로 제어했다고 한다."},{"i":"167","k":["input","otp"],"c":"2022-04-09","m":"2024-03-22","b":"`autocomplete=\"one-time-code\"` 사용자가 SMS 메시지를 수신 할 때마다, 운영 체제는 SMS에서 OTP 구문을 분석하고 키보드는 OTP를 제안합니다. iOS, iPadOS 및 macOS의 Safari 12 이상에서만 작동하지만 해당 플랫폼에서 SMS OTP 환경을 쉽게 개선할 수 있는 방법이므로 사용하는 것이 좋습니다.\n\n```html\n<input \n  type=\"text\"\n  inputmode=\"numeric\"\n  autocomplete=\"one-time-code\"\n  pattern=\"\\d{6}\"\n  required\n/>\n```\n\n```js\nconst otp = await navigator.credentials.get({\n  otp: {\n    transport: ['sms']\n  }\n})\n\ninput.value = otp.code\n```\n\n참고\n\n- [SMS OTP form best practices](https://web.dev/sms-otp-form/)\n- [Apple Developer Documentation](https://developer.apple.com/documentation/security/password_autofill/enabling_password_autofill_on_an_html_input_element)\n- [HTML attributes to improve your users' two factor authentication experience](https://www.twilio.com/blog/html-attributes-two-factor-authentication-autocomplete)"},{"i":"166","k":["input","css"],"c":"2022-04-09","m":"2024-03-22","b":"```css\n::-webkit-input-placeholder /* for (Chrome/Safari/Opera) */\n:-ms-input-placeholder /* for IE. */\n::-ms-input-placeholder /* for Edge (also supports webkit prefix) */\n\n::-ms-clear {}\n::-ms-reveal {}\n```\n\n---\n\n- [On Edge Browser, how to change input placeholder text color using CSS?](https://stackoverflow.com/questions/32096102/on-edge-browser-how-to-change-input-placeholder-text-color-using-css)"},{"i":"164","k":["youtube-dl","zsh"],"c":"2022-04-09","m":"2024-03-22","b":"스트리밍 비디오 다운로드 할 경우 403 에러가 발생할때 해당 옵션으로 우회한다.[^164-1]\n\n```sh\nyoutube-dl --referer \"URL\" --user-agent \"UA\"\n```\n\n```sh\ndmRef() {\n  youtube-dl $1 --referer $1 --user-agent \"UA\"\n}\n```\n\n---\n\n[^164-1]: https://www.reddit.com/r/youtubedl/comments/g342nr/masterm3u8_http_error_403_forbidden_youtubedl_or/"},{"i":"163","k":["youtube-dl"],"c":"2022-04-09","m":"2024-03-22","b":"youtube-dl 업데이트가 안되면서 다운로드 속도가 굉장히 저조하다. 그래서 [yt-dlp](https://github.com/yt-dlp/yt-dlp)로 일단 변경해서 사용 중.\n\n---\n\n여러 개 주소를 한꺼번에 받으려면 주소를 txt 파일로 저장하고 `-a(-a, --batch-file FILE)` 옵션을 추가하면 된다.\n\n```sh\nyoutube-dl -a list.txt\n```\n\n---\n\n가끔 중간에 에러가 발생하는 경우도 있는데 이럴 경우 ignore로 방어 코드를 설정하고 실행시키면 잘 된다.\n\n```sh\nyoutube-dl -i, --ignore-errors\n```\n\n---\n\n자막을 다운로드해야 된다면 --sub-lang 옵션을 추가한다. 자동 자막이 필요하다면 `--write-auto-sub`\n\n```sh\nyoutube-dl --write-sub --sub-lang ko\nyoutube-dl --write-auto-sub\n```\n\n---\n\nplaylist를 다운 받을때 index도 파일에 포함시키고 싶을 경우[^163-1]\n\n```sh\n-o '%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'\n```\n\n---\n\n기본적으로 평소 사용하는 alias 설정\n\n```sh\nalias dm=\"youtube-dl\"\nalias da=\"youtube-dl -x --audio-format mp3\"\n```\n\n---\n\n[^163-1]: https://github.com/ytdl-org/youtube-dl#output-template"},{"i":"162","k":["youtube-dl","ffmpeg","brew"],"c":"2022-04-09","m":"2024-03-22","b":"brew로 ffmpeg 설치할때[^162-1]\n\n```sh\nbrew install ffmpeg $(brew options ffmpeg | grep -vE '\\s' | grep -- --with-' | tr '\\n' ' ')\n```\n\n---\n\n[^162-1]: [brew install ffmpeg with all options](https://gist.github.com/Piasy/b5dfd5c048eb69d1b91719988c0325d8)"},{"i":"161","k":["test"],"c":"2022-04-09","m":"2024-03-22","b":"connect가 실행된 컴포넌트를 (enzyme) `mount`로 테스트한 경우 실패 케이스가 발생해서 찾아본 내용들 인 것 같은데 정확하게 기억이 안남. `shallow`로 변경했더니 이번에는 ref를 못쓰는 문제도 있었다고 하고...\n여담이지만 컴포넌트를 명확하게 정의하고 분리해서 테스팅 스트레스를 줄이는 게 중요합니다.\n\n- [Enzyme mount test failing with redux store state update](https://stackoverflow.com/questions/58010552/enzyme-mount-test-failing-with-redux-store-state-update)\n- [httpsWhy I Always Use Shallow Rendering](https://hackernoon.com/why-i-always-use-shallow-rendering-a3a50da60942)"},{"i":"160","k":["optimize","charles"],"c":"2022-04-09","m":"2026-08-19","b":"> [!WARNING]\n> **Google Optimize는 2023년 9월 30일 종료됐다**[^optimize-sunset] — 아래 Optimize 링크 둘은 살아 있지 않다.\n> 남는 건 서비스가 아니라 교훈이다: **리다이렉트에서 query를 갈아끼우면 그 query에 실려 있던 측정 정보가 사라진다.**\n\n[Google Optimize](https://optimize.google.com/)에서 [Redirect 테스트](https://support.google.com/optimize/answer/6361119?hl=ko&ref_topic=6197696)를 진행했는데 기능 구현은 문제없는데 세션수가 안 잡히는 문제가 발생했다. \n\n이번에 문제되었던 부분은 query 처리를 인지하지 못했던 점이었다. Optimize에서 redirect 처리가 이루어질 때 `query`에 정보(예. `utm_expid`)들이 추가되는데 리다이렉트 url 변경하는 부분이 있었고 optimize에서 참조되어야 할 query를 날려버리면서 측정이 불가능했던 것. 결국 `replace` 실행 코드를 이전 query를 assign 시켜주도록 변경해서 해결.\n\n여담이지만 [Charles](https://www.charlesproxy.com/)를 사용하고 있어서 배포 없이 테스트 가능했던 점도 같이 메모.\n\n[^optimize-sunset]: [Google Optimize sunset](https://support.google.com/optimize/answer/12979939)"},{"i":"159","k":["javascript","date"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nimport isAfter from 'date-fns/isAfter';\n\nisAfter(new Date(), new Date(DATE))\n```\n\n날짜 비교 할 일이 있어서 별 생각 없이 `new Date`를 때렸는데 safari에서 안되는 문제가 발견되었다. 콘솔을 확인해보니 _yyyy-MM-dd HH:mm:ss_ 해당 형태의 포멧 에서는 안된다. 평소에 `new Date` 보다는 moment나 date-fns같은 라이브러리를 당연하게 써오다 보니 몰랐다. 그런데 또 다른 생각을 해보자면 저런 문제가 있기 때문에 더 적극적으로 라이브러리를 사용해야 한다는 게 함정.\n\n```js\nimport isAfter from 'date-fns/isAfter';\nimport format from 'date-fns/format';\n\nisAfter(new Date(), format(DATE))\n```\n\n- [Invalid date in safari](https://stackoverflow.com/questions/4310953/invalid-date-in-safari)\n- [Safari Javascript Date() NaN Issue (yyyy-MM-dd HH:mm:ss)](https://stackoverflow.com/questions/21883699/safari-javascript-date-nan-issue-yyyy-mm-dd-hhmmss)"},{"i":"152","k":["live2d"],"c":"2022-04-09","m":"2024-03-22","b":"live2d 구현이 필요할 경우 pixi+plugin을 사용하면 된다\n\n- https://codepen.io/guansss\n- [샘플 데이터 모음 - Live2D Cubism](https://www.live2d.com/ko/download/sample-data/)"},{"i":"149","k":["test","bdd"],"c":"2022-04-09","m":"2026-07-27","b":"```md\n**title**: 반품 및 교환은 재고로 이동합니다.\n\n**as a** 가게 주인으로서,\n**I want** 반품 또는 교환 시 상품을 재고에 다시 추가하고 싶습니다.\n**so that** 인벤토리를 추적할 수 있습니다.\n\n**Scenario 1:** 환불을 위해 반품된 항목은 인벤토리에 추가되어야 합니다.\n**Given** 고객이 이전에 나에게서 검은색 스웨터를 구매했다는 점을 감안할 때\n**and** 인벤토리에 검은색 스웨터 3개가 있습니다.\n**when** 환불을 위해 검은색 스웨터를 반환할 때\n**then** 그러면 인벤토리에 4개의 검은색 스웨터가 있어야 합니다.\n```\n\nFeature > [...Scenario] > [...Step]\n\n`Given` 초기 상태 — 보통 과거에 일어난 일. `When` 이벤트나 동작. `Then` 예상 결과. 단계를 이을 때 `And`·`But`.\n\n```md\nScenario: 항목 검색 후 결제 페이지로 이동\nGiven 사용자가 Greencart 방문 페이지에 있다\nWhen 사용자가 야채<이름>를 검색했을 때\nAnd 장바구니에 항목이 추가된다\nAnd 사용자는 구매를 위해 Checkout 페이지로 이동한다\nThen 선택한 <이름> 항목이 체크아웃 페이지에 표시된다\n```"},{"i":"148","k":["mock","test"],"c":"2022-04-09","m":"2024-03-22","b":"테스트 코드에서 mock으로 처리하는 경우[^148-1]\n\n- [Mocking React custom hook with Jest - Stack Overflow](https://stackoverflow.com/questions/65270255/mocking-react-custom-hook-with-jest)\n- [mrbenhowl/mocking-firebase-initializeApp-and-firebase-auth-using-jest](https://github.com/mrbenhowl/mocking-firebase-initializeApp-and-firebase-auth-using-jest)\n- [Avoid Nesting when you're Testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing)\n\n---\n\n[^148-1]: 그래서 내가 내린 결론은 저렇게 까지는 테스트할 필요가 없고 오히려 애매하게 결합된 컴포넌트들을 분리해서 관리하는게 맞을 것 같다는 생각을 해봤다."},{"i":"147","k":["test"],"c":"2022-04-09","m":"2024-03-22","b":"테스트 하다가 특정 사이즈 파일이 필요한 경우. 순수하게 사이즈 관련된 내용만 테스트가 가능하다.[^147-1]\n\n```bash\nmkfile 100m some100mfile.pdf\n```\n\n---\n\n[^147-1]: [How to create large PDF files (10MB, 50MB, 100MB, 200MB, 500MB, 1GB, etc.) for testing purposes? - Stack Overflow](https://stackoverflow.com/questions/5092518/how-to-create-large-pdf-files-10mb-50mb-100mb-200mb-500mb-1gb-etc-for-t)"},{"i":"146","k":["test","cypress"],"c":"2022-04-09","m":"2024-03-22","b":"```sh\n# 개발할때 편하다\nyarn add -D @types/cypress\n```\n\n```js\n// react-devtools 같은 확장도구가 필요할 경우\n\n// /plugins/index.js\nconst path = require('path')\n\nmodule.exports = (on, _config) => {\n  on('before:browser:launch', (browser, launchOptions) => {\n    if (browser.family === 'chromium') {\n      const extensionFolder = path.resolve(__dirname, '..', '..', '4.7.0_1')\n\n      launchOptions.args.push(`--load-extension=${extensionFolder}`)\n\n      return launchOptions\n    }\n  })\n}\n\n// /support/commands.js\nCypress.on('window:before:load', (win) => {\n  win.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.top.__REACT_DEVTOOLS_GLOBAL_HOOK__\n})\n```\n\n```js\n// 파일업로드 기능 테스트\nCypress.Commands.add(\n  'uploadFile',\n  { prevSubject: true },\n  (subject, fileName) => {\n    cy.fixture(fileName).then((content) => {\n      const el = subject[0]\n      const testFile = new File([content], fileName)\n      const dataTransfer = new DataTransfer()\n\n      dataTransfer.items.add(testFile)\n      el.files = dataTransfer.files\n      cy.wrap(subject).trigger('change', { force: true })\n    })\n  }\n)\n```\n\n```js\n// 에러때문에 테스트가 끊길 경우\nCypress.on('uncaught:exception', (err, runnable) => {\n  console.log(err)\n  return false\n})\n```"},{"i":"145","k":["test"],"c":"2022-04-09","m":"2024-03-22","b":"📌 테스트 전반에 대한 가이드 및 베스트 프랙티스\n\n- [이 가이드가 당신의 테스트 기술을 한 단계 끌어 올리는 이유](https://github.com/goldbergyoni/javascript-testing-best-practices/blob/master/readme.kr.md)\n- [테스트 함정과 해결 방법](https://www.smashingmagazine.com/2021/07/frontend-testing-pitfalls/)\n- [GitHub - mhevery/guide-to-testable-code](https://github.com/mhevery/guide-to-testable-code)\n- [Good Code, Testable Code | Epic Web Dev](https://www.epicweb.dev/good-code-testable-code)\n- [The True Purpose of Testing | Epic Web Dev](https://www.epicweb.dev/the-true-purpose-of-testing)\n\n---\n\n🛠 Cypress 관련 리소스\n\n- [Cypress Tips and Tricks | Better world by better software](https://glebbahmutov.com/blog/cypress-tips-and-tricks/?hss_channel=tw-2774638535)\n- [cypress-example-recipes/examples at master · cypress-io/cypress-example-recipes · GitHub](https://github.com/cypress-io/cypress-example-recipes/tree/master/examples)\n\n---\n\n🔎 React 테스트 관련 자료\n\n- [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)\n- [Best Practices for Writing Tests with React Testing Library | ClarityDev blog](https://claritydev.net/blog/improving-react-testing-library-tests)\n- [Avoid Nesting when you're Testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing)\n\n---\n\n📏 테스트 기법 및 접근법\n\n- [How to get started with property-based testing in JavaScript using fast-check](https://jrsinclair.com/articles/2021/how-to-get-started-with-property-based-testing-in-javascript-with-fast-check/)\n- [Inverse Assertions | Epic Web Dev](https://www.epicweb.dev/inverse-assertions)\n- [Cram tests: a hidden gem of dune | sancho.dev](https://sancho.dev/blog/cram-tests-a-hidden-gem-of-dune)\n\n---\n\n🔧 테스트 환경 및 도구\n\n- [Testing Playground](https://testing-playground.com/)\n- [Testing Types | Guide | Vitest](https://vitest.dev/guide/testing-types.html)\n- [Testing Types in TypeScript &#8211; Frontend Masters Boost](https://frontendmasters.com/blog/testing-types-in-typescript/)\n\n---\n\n🏗 테스트 아키텍처 및 설계\n\n- [Dependency Injection in React - full guide with examples | Code Driven Development](https://codedrivendevelopment.com/posts/dependency-injection-in-react)\n- [Building a JavaScript Testing Framework | Christoph Nakazawa](https://cpojer.net/posts/building-a-javascript-testing-framework)"},{"i":"143","k":["html"],"c":"2022-04-09","m":"2024-03-22","b":"html\n\n`<head />`\n\n- [🤯 HEAD - A simple guide to HTML `<head>` elements](https://htmlhead.dev/)\n\n구조화된 데이터\n\n- [Using Structured Data to Enhance Search Engine Optimization | CSS-Tricks](https://css-tricks.com/using-structured-data-to-enhance-search-engine-optimization/)\n- [Understand How Structured Data Works | Google Search Central](https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data)\n\n`lang`\n\n- [The lang attribute: browsers telling lies, telling sweet little lies - Manuel Matuzović](https://www.matuzo.at/blog/lang-attribute/)\n\n`favicon`\n\n- [How to Favicon in 2021: Six files that fit most needs — Martian Chronicles, Evil Martians’ team blog](https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs)\n- [How to Create a Favicon That Changes Automatically | CSS-Tricks](https://css-tricks.com/how-to-create-a-favicon-that-changes-automatically/)\n- [Emojis as Favicons | CSS-Tricks](https://css-tricks.com/emojis-as-favicons/)\n- [We Analyzed 425,909 Favicons • iconmap.io](https://iconmap.io/blog)[^143-1]\n\n`Microdata`\n\n- [Getting Started - schema.org](https://schema.org/docs/gs.html)\n\n`attributes`\n\n- [HTML attributes vs DOM properties - JakeArchibald.com](https://jakearchibald.com/2024/attributes-vs-properties/)\n\n---\n\n- [HTML for People](https://htmlforpeople.com/)\n\n---\n\n[^143-1]: 다양한 관점에서 파비콘을 분석한 글"},{"i":"134","k":["select","css"],"c":"2022-04-09","m":"2024-03-22","b":"[Under-Engineered Select Menus | Adrian Roselli](https://adrianroselli.com/2021/03/under-engineered-select-menus.html)\n\n- `font`, `letter-spacing`, `word-spacing` 상속\n- `appearance` 화살표 수정\n- 상태(focus, required, invalid)에 따른 스타일 추가\n\n---\n\n- [How to Build an Accessible Custom Dropdown Select Element](https://www.freecodecamp.org/news/how-to-build-an-accessible-custom-dropdown-select-element/#dropdown-keyboard-interaction)\n- [Custom and Accessible Select Menus Aren&#39;t Easy: Part One – Chris Henrick](https://clhenrick.io/accessible-select-element/)"},{"i":"133","k":["select","css","ui"],"c":"2022-04-09","m":"2024-03-22","b":"[Building a multi-select component](https://web.dev/building-a-multi-select-component/)\n\n다중 선택 UI를 구현하기위해서 checkbox, select 두가지 방법으로 작업하는 방식을 소개하고 있다. 그외 선택된 상태값을 얻기위한 `counter()` 함수, 모바일 체크를 위한 미디어쿼리도 알려주고 있다.\n\n```css\naside {\n  counter-reset: filters;\n\n  & :checked {\n    counter-increment: filters;\n  }\n\n  &::after {\n    content: counter(filters);\n  }\n}\n```\n\n```css\n@media (pointer: coarse) {\n  //\n}\n```\n\n---\n\n- [React-ComboBox-Select-Only-with-Tippy - Codesandbox](https://codesandbox.io/p/sandbox/react-combobox-select-only-with-tippy-58p0v1)\n- [Combobox Pattern | APG | WAI | W3C](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#examples)"},{"i":"132","k":["html","select"],"c":"2022-04-09","m":"2024-03-22","b":"디자인 된 `<select />`에 `placeholder` 개념이 있어서 어떻게 하면 좋을지 찾아봤다. 간략하게 설명하자면 선택이 불가능하게 `disabled` 추가하고 `hidden`으로 숨김 마지막으로 `selected`로 디폴트값을 처리하면 완성.\n\n```js\nfunction Select({ placeholder, children }) {\n  return (\n    <select>\n      <option value=\"\" disabled hidden selected>\n        {placeholder}\n      </option>\n      {children}\n    </select>\n  )\n}\n```\n\n---\n\n- [How do I make a placeholder for a 'select' box?](https://stackoverflow.com/questions/5805059/how-do-i-make-a-placeholder-for-a-select-box)"},{"i":"128","k":["extensions"],"c":"2022-04-09","m":"2024-03-22","b":"매니페스트 V3로 마이그레이션[^128-1]\n\n- host_permissions으로 분리\n- action으로 통합(두군데 수정이 필요하다)\n\n[^128-1]: https://github.com/cbcruk/webext/commit/cbb36355871fb36d5f6c21752aae3400d2a97abf#diff-0f354ef5fd807996fa3f5a7a83ceb74025aa8f85862e321b8938988031e99711L3-L25"},{"i":"124","k":["json"],"c":"2022-04-09","m":"2024-03-22","b":"[TIL — The power of JSON.stringify replacer parameter | pawelgrzybek.com](https://pawelgrzybek.com/til-the-power-of-json-stringify-replacer-parameter/)\n\n```js\nJSON.stringify(dude, (key, value) =>\n  value instanceof Set ? [...value] : value\n);\nJSON.stringify(dude, null, \"🍆\");\n```"},{"i":"122","k":["angle","math"],"c":"2022-04-09","m":"2024-03-22","b":"- [Tilted Angles in Sass - SitePoint](https://www.sitepoint.com/tilted-angles-in-sass/)\n- [Inverse trigonometric functions with Sass – TheSassWay.com](http://thesassway.com/advanced/inverse-trigonometric-functions-with-sass)\n\n```js\nfunction getTiltedHeight(angle) {\n  const a = 100;\n  const A = 90 - angle;\n  \n  const c = a / Math.sin(Math.PI * A / 180);\n  const b = Math.sqrt(Math.pow(c, 2) - Math.pow(a, 2));\n  \n  return `${Math.abs(b)}%`;\n}\n```"},{"i":"108","k":["css","ellipsis","table","text-overflow","layout"],"c":"2022-04-09","m":"2026-09-01","b":"텍스트 ellipsis는 **컨텍스트마다 패턴이 다르다.**\n\n**언제** — 먼저 어느 컨텍스트인지 가른다.\n\n| 컨텍스트 | 어디로 |\n|---|---|\n| `display: table-cell` | 아래 절차 |\n| flexbox | 맨 아래 참고 링크 |\n| 여러 줄(multiline) | 맨 아래 참고 링크 |\n\n## table cell\n\n**절차**\n\n1. 표에 `table-layout: fixed`를 준다.\n2. 셀에 `max-width: 0`과 `overflow: hidden` 셋을 함께 준다.\n\n```css\ntable {\n  table-layout: fixed;\n  width: 100%;\n}\n\ncol:nth-child(1) { width: 30%; }\ncol:nth-child(2) { width: 40%; }\ncol:nth-child(3) { width: 30%; }\n\ntd {\n  max-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n```\n\n**확인**: 긴 텍스트가 든 셀이 표를 옆으로 밀어내지 않고 `…`로 잘리는가.\n\n**함정** — `table-layout: fixed`와 `max-width: 0`은 **반드시 함께여야 한다.** `overflow` 셋만 주면 아무 효과가 없다.\n\n```css\n/* ❌ 아무 효과 없음 */\ntd {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n```\n\n`display: table-cell`은 width 제약 없이 content 크기에 맞게 늘어나서, `overflow`가 동작할 명시적 width bound가 없기 때문이다. 그리고 `max-width: 0`이 실제 0px이 되는 게 아니다 — `table-layout: fixed` 안에서 **\"이 셀은 content 기반으로 너비를 주장하지 않는다\"는 시그널**로 작동해, colgroup/th에서 받은 너비 안에서 overflow가 동작한다.\n\n### 컬럼별 선택적 적용\n\n```css\ntd:not(.col-action) {\n  max-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\ntd.col-action {\n  white-space: nowrap;\n}\n```\n\n### 셀 안에 복합 요소\n\n버튼/배지 등이 텍스트와 함께 있을 때 inner wrapper에 위임.\n\n```css\ntd {\n  max-width: 0;\n}\n\ntd > div {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  overflow: hidden;\n}\n\ntd > div > span {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  min-width: 0;\n}\n```\n\n### React 컴포넌트 패턴\n\nTanStack Table `size` 옵션과 조합.\n\n```tsx\nconst columns = [\n  columnHelper.accessor('name', {\n    size: 200,\n    cell: ({ getValue }) => (\n      <span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>\n        {getValue()}\n      </span>\n    ),\n  }),\n]\n\n<table style={{ tableLayout: 'fixed', width: '100%' }}>\n  <colgroup>\n    {table.getFlatHeaders().map(header => (\n      <col key={header.id} style={{ width: header.getSize() }} />\n    ))}\n  </colgroup>\n  ...\n</table>\n```\n\n재사용 셀 래퍼:\n\n```tsx\nfunction EllipsisCell({ children }: { children: React.ReactNode }) {\n  return (\n    <td style={{ maxWidth: 0 }}>\n      <div style={{\n        overflow: 'hidden',\n        textOverflow: 'ellipsis',\n        whiteSpace: 'nowrap',\n      }}>\n        {children}\n      </div>\n    </td>\n  )\n}\n```\n\n## flexbox / multiline 참고\n\n- [Using Flexbox and text ellipsis together · Leonardo Faria](https://leonardofaria.net/2020/07/18/using-flexbox-and-text-ellipsis-together/)[^108-1]\n- [Multiline truncated text with \"show more\" button (with just CSS) - Paul Bakaus' blog](https://paulbakaus.com/tutorials/css/multiline-truncated-text-with-show-more-button-with-just-css/)[^108-2]\n- [Recreating MDN's Truncated Text Effect](https://css-tricks.com/recreating-mdns-truncated-text-effect)\n\n---\n\n[^108-1]: flexbox | 파일명에 ellipsis 효과 적용, 단 파일 확장자는 제외.\n[^108-2]: `:truncated` 개념이 없기 때문에 ResizeObserver와 영역값을 체크해서 구현."},{"i":"107","k":["css","layout shift","bold"],"c":"2022-04-09","m":"2024-03-22","b":"인라인 요소에 `bold` 스타일이 적용될 경우 레이아웃 시프팅 현상이 발생하기 때문에 해당 이슈를 해결하는 방법들.\n\n- [html - Inline elements shifting when made bold on hover - Stack Overflow](https://stackoverflow.com/a/20249560)[^107-1]\n- [Bold on Hover... Without the Layout Shift | CSS-Tricks](https://css-tricks.com/bold-on-hover-without-the-layout-shift/)[^107-2]\n\n---\n\n[^107-1]: `text-shadow`로 우회하는 방법이 주 해결방법으로 올라왔다.\n[^107-2]: `content` 속성과, `grid` 레이아웃을 이용한 방법."},{"i":"106","k":["css"],"c":"2022-04-09","m":"2026-07-27","b":"최신 CSS 기능 및 트렌드\n\n- [Things You Can Do With CSS Today — Smashing Magazine](https://www.smashingmagazine.com/2021/02/things-you-can-do-with-css-today/)\n- [The Future of CSS: Cascade Layers (CSS @layer)](https://www.bram.us/2021/09/15/the-future-of-css-cascade-layers-css-at-layer/)\n- [The Undeniable Utility Of CSS :has • Josh W. Comeau](https://www.joshwcomeau.com/css/has/)\n- [An Interactive Guide to CSS Container Queries](https://ishadeed.com/article/css-container-query-guide/)\n- [컨테이너 쿼리 사용 방법 | web.dev](https://web.dev/blog/how-to-use-container-queries-now?hl=ko)\n- [What if you used Container Units for everything?](https://frontendmasters.com/blog/what-if-you-used-container-units-for-everything/)\n\n레이아웃과 반응형 디자인\n\n- [Fluid Sizing Instead Of Multiple Media Queries? — Smashing Magazine](https://www.smashingmagazine.com/2022/08/fluid-sizing-multiple-media-queries/)\n- [Complex conditional width using flex-basis with clamp](https://every-layout.dev/blog/sidebar-flex-basis-clamp/)\n- [Using grid to split a table cell](https://www.peterkrautzberger.org/0213/)\n- [TablesNG — Improvements to `<table>` rendering in Chromium](https://www.bram.us/2021/06/21/tablesng-improvements-to-table-rendering-in-chromium/)\n\nCSS 애니메이션 및 효과\n\n- [A CSS-only, animated, wrapping underline.](https://nickymeuleman.netlify.app/blog/css-animated-wrapping-underline/)\n- [Cubic Bézier: from math to motion](https://blog.maximeheckel.com/posts/cubic-bezier-from-math-to-motion/)\n- [Zero Trickery Custom Radios and Checkboxes - CSS-Tricks](https://css-tricks.com/zero-trickery-custom-radios-and-checkboxes/)\n- [CSS One-Liners to Improve (Almost) Every Project](https://alvaromontoro.com/blog/68055/ten-css-one-liners-for-almost-every-project)\n- [6 CSS Snippets Every Front-End Developer Should Know In 2025](https://nerdy.dev/6-css-snippets-every-front-end-developer-should-know-in-2025)\n\n텍스트 및 타이포그래피\n\n- [Using CSS to Set Text Inside a Circle](https://css-tricks.com/using-css-to-set-text-inside-a-circle/)\n- [The Trickery it Takes to Create eBook-Like Text Columns](https://css-tricks.com/the-trickery-it-takes-to-create-ebook-like-text-columns/)\n\n스타일 가이드 및 접근성\n\n- [Naming Variables In CSS](https://jwdallas.com/posts/namingcssvariables/)\n- [Margin considered harmful](https://mxstbr.com/thoughts/margin/)\n- [Defensive CSS - Ahmad Shadeed](https://ishadeed.com/article/defensive-css/)\n- [The wasted potential of CSS attribute selectors](https://elisehe.in/2022/10/16/attribute-selectors)\n\nCSS와 JavaScript의 조합\n\n- [Constructable Stylesheets: seamless reusable styles](https://developers.google.com/web/updates/2019/02/constructable-stylesheets)\n- [Replace JavaScript Dialogs With New HTML Dialog | CSS-Tricks](https://css-tricks.com/replace-javascript-dialogs-html-dialog-element/)\n- [How to prevent scrolling the page on iOS Safari 15](https://pqina.nl/blog/how-to-prevent-scrolling-the-page-on-ios-safari/)\n\nCSS 아키텍처 및 모듈화\n\n- [What is Modular CSS?](https://spaceninja.com/2018/09/17/what-is-modular-css/)\n- [Panda CSS - The Origin Story](https://www.adebayosegun.com/blog/panda-css-the-origin-story)\n\n`background`\n\n- [CSS Background Patterns by MagicPattern](https://www.magicpattern.design/tools/css-backgrounds)"},{"i":"105","k":["git"],"c":"2022-04-09","m":"2024-03-22","b":"[discard](https://stackoverflow.com/questions/52704/how-do-i-discard-unstaged-changes-in-git)[^105-1]\n\n```sh\ngit checkout -- <file>\n\ngit restore <file>\n\ngit reset --hard\n```\n\n리모트 브랜치 가져오기\n\n```sh\ngit remote update\n```\n\n---\n\n[^105-1]: 명령어로 discard를 하려면"},{"i":"101","k":["git"],"c":"2022-04-09","m":"2024-03-22","b":"```sh\n# 마지막 커밋 해시\ngit log --pretty=format:'%h' -n 1\n```\n\n```sh\n# 유저정보\ngit config user.name\ngit config user.email\n```"},{"i":"97","k":["google-apps-script","slack"],"c":"2022-04-09","m":"2026-09-01","b":"[Using Slack Slash Commands to Send Data from Slack into Google Sheets](https://davidwalsh.name/using-slack-slash-commands-to-send-data-from-slack-into-google-sheets)\n\n**순서를 기억하려고 적어둔 것이다.** 방법은 위 링크에 있다.\n\n1. 사람들이 추천한 책을 기록한다\n2. 슬랙 -> 구글시트\n3. 슬랙 커맨드 설정 `/book` + 구글앱스 스크립트 url 연결[^97-1]\n4. POST로 전송 받은 데이터값을 기반으로 데이터 처리 완료\n\n---\n\n[^97-1]: [꿀벌개발일지 :: 구글 앱스 스크립트에서 비동기 작업 추가하기](https://ohgyun.com/806) doPost에서 비동기 처리"},{"i":"96","k":["brew"],"c":"2022-04-09","m":"2024-03-22","b":"homebrew, brew[^96-1]\n\n```shell\nbrew bundle dump\nbrew bundle\n```\n\n- [My 2021 New Mac Setup - DEV Community](https://dev.to/swyx/my-2021-new-mac-setup-1b1)\n- [Homebrew 로 Mac 한 방에 셋업하기](https://blog.gangnamunni.com/post/brew_cask_mas/)\n\n---\n\n[^96-1]: https://gist.github.com/cbcruk/b417d085163bb7d7244dd1815885b8cb"},{"i":"95","k":["tools","filezilla","ftp"],"c":"2022-04-09","m":"2024-03-22","b":"1. [파일질라 서버](https://filezilla-project.org/download.php?type=server)를 설치\n2. Edit->Users->Add / 사용자 추가\n3. 제어판->시스템 및 보안->Windows Defender 방화벽->허용되는 앱 / (설정변경|다른 앱 허용) 파일질라 서버 추가"},{"i":"93","k":["puppeteer","image"],"c":"2022-04-09","m":"2024-03-22","b":"이미지 저장[^93-1]\n\n- 페이지에서 JavaScript를 통해 이미지 추출\n  - 캔버스에서 이미지 추출\n  - 서버에서 이미지 가져오기\n- DevTools 프로토콜을 사용하여 이미지 추출\n\n[^93-1]: [Saving Images from a Headless Browser](https://intoli.com/blog/saving-images/)\n\n```js\nconst tree = await page._client.send('Page.getResourceTree')\n\nfor (const resource of tree.frameTree.resources) {\n  const { content } = await page._client.send(\n    'Page.getResourceContent',\n    { frameId: String(page.mainFrame()._id), url: resource.url },\n  )\n  const contentBuffer = Buffer.from(content, 'base64')\n}\n```\n\n```js\npage.on('response', async (response) => {\n  const url = response.url()\n  const buffer = await response.buffer()\n})\n```"},{"i":"92","k":["puppeteer","headers"],"c":"2022-04-09","m":"2024-03-22","b":"`setExtraHTTPHeaders` 호출하기\n```js\nawait page.setExtraHTTPHeaders(headers)\n```\n\n인터셉트로 가로채기\n```js\nawait page.setRequestInterception(true)\n\npage.on('request', (request) => {\n  const headers = {\n    ...request.headers()\n  }\n\n  interceptedRequest({ headers })\n})\n```"},{"i":"91","k":["puppeteer","cookies"],"c":"2022-04-09","m":"2024-03-22","b":"httpOnly, secure 플래그 쿠키값이 필요한 경우 CDPSession 실행 후 클라이언트와 [통신](https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-getAllCookies)한다.\n```js\nconst client = await page.target().createCDPSession()\nconst data = await client.send('Network.getAllCookies')\n```\n\n로컬에 해당 쿠키정보를 임시로 저장해서 재활용하고 만료 시 갱신하는 방법을 사용.\n```js\nfs.writeFileSync('cookies.json', JSON.stringify(data))\n\nconst { cookies } = JSON.parse(fs.readFileSync('cookies.json', 'utf8'))\nawait page.setCookie(...cookies)\n```"},{"i":"87","k":["scroll","css"],"c":"2022-04-09","m":"2024-03-22","b":"hash 링크로 연결될 경우 스크롤위치가 최상단으로 위치하기 때문에 문제(헤더가 고정일 경우)가 있을수도 있어서 `scroll-margin-top`으로 제어가 가능한 부분을 설명하고 있다.\n\n- [Add scroll margin to all elements which can be targeted - Piccalilli](https://piccalil.li/quick-tip/add-scroll-margin-to-all-elements-which-can-be-targeted/)[^87-1]\n- [Fixed Headers and Jump Links? The Solution is scroll-margin-top | CSS-Tricks](https://css-tricks.com/fixed-headers-and-jump-links-the-solution-is-scroll-margin-top/)\n- [Prevent content from being hidden underneath a fixed header by using scroll-margin-top – Bram.us](https://www.bram.us/2020/03/01/prevent-content-from-being-hidden-underneath-a-fixed-header-by-using-scroll-margin-top/)\n\n---\n\n[^87-1]: `2ex` 유닛을 사용하여 선택한 글꼴의 x 높이의 상대적인 크기로 설정."},{"i":"85","k":["google-apps-script","geolocation","new-geocoder"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nfunction getReverseGeocodeData() {\n  const response = Maps.newGeocoder()\n    .setLanguage('ko')\n    .reverseGeocode(40.758577, -73.984464)\n\n  return response.results.map((result) => {\n    return {\n      formatted_address: result.formatted_address,\n      lat: result.geometry.location.lat,\n      lng: result.geometry.location.lng,\n    }\n  })\n}\n```\n\n- [`reverseGeocode(latitude, longitude)`](<https://developers.google.com/apps-script/reference/maps/geocoder#reverseGeocode(Number,Number)>)\n- [`setLanguage(language)`](https://developers.google.com/apps-script/reference/maps/geocoder#setlanguagelanguage)"},{"i":"84","k":["api"],"c":"2022-04-09","m":"2024-03-22","b":"[Cool Cats: The Coolest NFT's on the Blockchain!](https://www.coolcatsnft.com/gallery)\n\n```js\nconst imageSrc = `https://s3.amazonaws.com/api.coolcatsnft.com/thumbnails/${ID}_thumbnail.png`\nconst query = {\n  sortBy: 'token_id_asc',\n  limit: 48,\n  page: 1,\n  face: 'angry',\n  hats: 'admiral',\n  shirt: 'astro',\n  tier: 'cool_1',\n}\n\nfetch(\n  'https://prod-api.coolcatsnft.com/cat?sortBy=token_id_asc&limit=48&page=1&tier=${tier}'\n)\n```\n\n- **face** angry, angry cute, angry scar, beard brown, beard pirate, beard tan, derp, ditto, dizzy, double face, face face, glasses, glasses funny, glossy, grin, happy, happy cute, hearts, mononoke, mummy, ninja black, ninja blue, ninja red, owo, pixel, rich, shocked, smirk, stunned, sunglasses blue, sunglasses cool, sunglasses cowboy, sunglasses heart, sunglasses pixel, sunglasses squad, sunglasses yellow, three eyes, tired, tvface 404, tvface bobross, tvface nosignal, tvface xp, unamused, uwu, wink, zombie\n- **hat** admiral, admiral pink, afro black, afro brown, afro rainbow unicorn, antlers, apple, arrowhead, astro, astro cheeks, astro fishbowl, beanie black, beanie blue, beanie orange, beanie red, beret black, beret green, beret pink, beret red, bow, bucket hat blue, bucket hat green, bucket hat tan, bucket hat white, candle, costume dragon, costume frog, costume gorilla, cowboy black, cowboy brown, crown black, crown fire, crown gold, cupcake, deepsea bronze, deepsea orange, dutch, flower blue, flower pink, flower red, goggles seaweed, halo, halo fire, hat black, hat skull, hat visor blue, hat visor yellow, hat white, headband blue, headband red, helm army, helm biker, helm bronze, helm silver, horns, knight black, knight blue, knight red, mohawk green, mohawk purple, mohawk red, mullet blonde, mullet brown, ninja black, ninja blue, ninja red, nurse, piercings, pirate black, pirate red, prince, sunhat black, sunhat tan, sunhat white, sushi, top hat, tvhead grey, tvhead purple, tvhead white, unicorn horn, visor green, visor purple, wreath, wreath flowers\n- **shirt** astro, astro black, astro orange, bandana green, bandana purple, bandana red, baseball blue, baseball red, buttondown black flannel, buttondown blue flannel, buttondown green, buttondown red flannel, buttondown tan, chain, combat black, combat green, costume dragon, costume frog, costume gorilla, costume hotdog, cowboy black, cowboy brown, deepsea bronze, deepsea orange, epaulette black, epaulette red, epaulette white, gown black, gown purple, gown white, hoodie black, hoodie purple, hoodie red, knight, knight black, knight leather, labcoat, lederhosen, monk, mononoke, ninja black, ninja blue, ninja red, nurse, overalls blue, overalls flannel, overalls pink, overalls red, overalls yellow, pirate black, pirate red, punk, robe blue, robe king, robe red, robe white, shirt bowtie, shirt white, shirt yellow, sweater black, sweater green chain, sweater orange, sweater pink, tanktop orange, tanktop pink, tanktop sailor black, tanktop sailor blue, tanktop sailor red, tanktop tattoo, tanktop white, tiger, toga, tshirt blue, tshirt green, tshirt metal, tshirt pink, tshirt red, tshirt white, tshirt yellow, viking brown, viking navy, wetsuit, winter blue, winter red, work blue, work red\n- **tier** cool_1, cool_2, wild_1, wild_2, classy_1, classy_2, exotic_1, exotic_2"},{"i":"83","k":["api","github"],"c":"2022-04-09","m":"2026-07-27","b":"- [Objects - GitHub Docs](https://docs.github.com/en/graphql/reference/objects)[^83-1]\n  - [repository](https://docs.github.com/en/graphql/reference/objects#repository)\n  - [issue](https://docs.github.com/en/graphql/reference/objects#issue)\n- [GitHub Next | Flat Data](https://githubnext.com/projects/flat-data#part-iii-visualizing-our-data-for-easy-sharing-optional)\n  - [GitHub - githubocto/flat-demo-google-sheets: A Flat Data GitHub Action demo repo](https://github.com/githubocto/flat-demo-google-sheets)\n  - [GitHub - mathiasbynens/covid-19-vaccinations-germany: Historical data on COVID-19 vaccination doses administered in Germany, per state.](https://github.com/mathiasbynens/covid-19-vaccinations-germany)\n- [GitHub Code Search (Preview)](https://cs.github.com/)[^83-3]\n- [Writing workflows - GitHub Docs](https://docs.github.com/en/actions/learn-github-actions)\n  - [Skipping workflow runs - GitHub Docs](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/skipping-workflow-runs)\n  - [Create and Publish a GitHub Action in Typescript - ULTIMATE 2023 GUIDE | Leonardo Montini](https://leonardomontini.dev/typescript-github-action)\n  - [Publishing a simple client-side JavaScript package to npm with GitHub Actions | Simon Willison’s TILs](https://til.simonwillison.net/npm/npm-publish-github-actions)\n\n---\n\n[^83-1]: GitHub GraphQL API를 통해 쿼리할 수 있는 다양한 개체에 대한 개요.\n[^83-3]: https://grep.app/"},{"i":"79","k":["api","notion","docs"],"c":"2022-04-09","m":"2024-03-22","b":"- [Use the Notion API to Create a Quiz with JavaScript - SitePoint](https://www.sitepoint.com/notion-api-javascript-quiz/)\n- [Collecting Email Signups With the Notion API | CSS-Tricks](https://css-tricks.com/collecting-email-signups-with-the-notion-api/)\n\n---\n\n1. [통합(integration)](https://www.notion.com/my-integrations)을 생성합니다.[^79-1]\n2. 통합과 데이터베이스 공유\n  2.1 `•••` -> `Add connections`\n\n---\n\n[^79-1]: https://developers.notion.com/docs"},{"i":"78","k":["cse","api"],"c":"2022-04-09","m":"2026-07-27","b":"> 검색기능을 정말 간단하게 구현하고 싶을때:\n> 프로그래밍 가능한 검색 엔진을 사용하면 웹사이트, 블로그 또는 웹사이트 모음에 대한 검색 엔진을 만들 수 있습니다. 웹 페이지와 이미지를 모두 검색하도록 엔진을 구성할 수 있습니다. 순위를 미세 조정하고 자신의 프로모션을 추가하고 검색 결과의 모양과 느낌을 사용자 지정할 수 있습니다. 엔진을 Google 애드센스 계정에 연결하여 검색으로 수익을 창출할 수 있습니다.\n\n---\n\n- [Method: cse.siterestrict.list](https://developers.google.com/custom-search/v1/reference/rest/v1/cse.siterestrict/list)"},{"i":"77","k":["api"],"c":"2022-04-09","m":"2024-03-22","b":"- [혜택](https://content.zigzag.kr/benefit)은 웹뷰로 존재함\n- GraphQL을 사용 중\n\n```js\nuseSWR(\n  `{\n    pages {\n      user_group\n      highlight_color\n      page_list {\n        type\n        page_id\n        page_name\n        is_new\n      }\n    }\n  }`,\n  (query) => request('https://api.zigzag.kr/api/2/graphql', query)\n)\n```"},{"i":"75","k":["zsh","dotfiles","shell"],"c":"2022-04-09","m":"2024-03-22","b":"```sh\n# /home/USERNAME/.zshrc\n\nHOME=\"/mnt/c/Users/cbcru\"\nDL=\"$HOME/Downloads\"\n\nif [[ $PWD == $HOME ]]; then\n    cd $DL\nfi\n```"},{"i":"74","k":["ffmpeg","video"],"c":"2022-04-09","m":"2026-07-27","b":"```shell\nextractFrames() {\n  ffmpeg -i $1 -vf fps=30 output_frame_%d.png\n}\n\n# $1 = frames로 사용될 파일들\n# $2 = output 파일\n# Example: concatFrames ./frame_%5d.png output.webm\nconcatFrames() {\n  ffmpeg -framerate 30 -i $1 -c:v libvpx-vp9 -pix_fmt yuva420p $2\n}\n\n# $1 = frame로 사용될 파일\n# $2 = output 파일\n# Example: frameToVideo frame.jpg output.mp4\nframeToVideo() {\n  ffmpeg -loop 1 -i $1 -c:v libx264 -t 10 -pix_fmt yuv420p $2\n}\n\n# $1 = 인코딩할 영상 video.mp4\n# $2 = output 파일명\nencodingVideo() {\n  ffmpeg -an -i $1 -vcodec libx264 -pix_fmt yuv420p -profile:v baseline -level 3 \"${$2}.mp4\"\n  ffmpeg -i \"${$2}.mp4\" -vcodec libvpx-vp9 -b:v 1M -acodec libvorbis \"${$2}.webm\"\n}\n```\n\n---\n\n- [Encoding Video\n](https://gist.github.com/glen-cheney/278e13915894821e1d6f)"},{"i":"72","k":["local"],"c":"2022-04-09","m":"2024-03-22","b":"서버/프론트 구분이 없는 환경일 경우 옜날에는 이렇게 세팅해서 개발\n\n```js\nconst bs = require('browser-sync').create()\n\nbs.init({\n  port: 8081,\n  https: false,\n  proxy: 'http://localhost:8080',\n  serveStatic: [\n    {\n      route: '/resources',\n      dir: 'src/main/webapp/resources',\n    },\n  ],\n  files: 'src/main/webapp/resources/**/*',\n  open: false,\n})\n```"},{"i":"71","k":["redux","debug"],"c":"2022-04-09","m":"2026-08-02","b":"redux 에서 trace 가 필요하면 redux-devtools 의 [trace 설정](https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/Features/Trace.md)을 켠다. 다만 메모리 릭 가능성이 있어서 상시로 켜두긴 어렵다 — 우회한다면 무식하지만 의심 지점에 [`console.trace()`](https://developer.mozilla.org/ko/docs/Web/API/Console/trace)를 직접 넣는다."},{"i":"70","k":["fetch","service-worker","mock","msw"],"c":"2022-04-09","m":"2024-03-22","b":"서비스워커로 [fetch](https://developer.mozilla.org/ko/docs/Web/API/FetchEvent)를 감지해서 해당 기능을 구현한다는 내용.\n개인적으로는 mock은 간단하게 구현 가능할 것 같은데 이미 같은 기능의 잘 만들어진 라이브러리들이 있으니까 아이디어 정도로 생각하면 될 것 같다.\n\n```js\naddEventListener('fetch', e => { \n  // e.request\n  // e.respondWith\n})\n```\n\n- [How To Run a Proxy Server Inside Your Browser | by Danny Moerkerke | Better Programming](https://betterprogramming.pub/how-to-run-a-proxy-server-inside-your-browser-8b96ea2ef1ea)\n- [MSW – Seamless API mocking library for browser and Node | Mock Service Worker](https://mswjs.io/)"},{"i":"69","k":["local","charles","debug"],"c":"2022-04-09","m":"2024-03-22","b":"charles\n\n- [The Android Emulator and Charles Proxy: A Love Story | by Mark Dappollone | Medium](https://medium.com/@daptronic/the-android-emulator-and-charles-proxy-a-love-story-595c23484e02)\n- [Is it possible to rewrite a status code with Charles Proxy? - Stack Overflow](https://stackoverflow.com/questions/14360125/is-it-possible-to-rewrite-a-status-code-with-charles-proxy)\n\nfiddler\n\n- [Web Performance Calendar » Fiddler AutoResponder](https://calendar.perfplanet.com/2019/fiddler-autoresponder/)[^69-1]\n\nmitmproxy\n\n- [mitmproxy로 iOS 기기의 네트워크 트래픽 살펴보기 :: Outsider's Dev Story](https://blog.outsider.ne.kr/1514)\n- [Android nougat 이상 emulator에서 mitmproxy 사용하기 | by Jungwook Park | kjcoop | Medium](https://medium.com/kjcoop/android-nougat-%EC%9D%B4%EC%83%81-emulator%EC%97%90%EC%84%9C-mitmproxy-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-98003008f78d)\n\n---\n\n[^69-1]: AutoResponder기능을 어떻게 활용 가능한지 알려주는 내용. [delay](https://docs.telerik.com/fiddler/knowledge-base/autoresponder#delay) 추가로 응답 시간 조절이 가능하므로 font, script 로딩 시간을 늘려서 시각적으로 확인이 가능하다."},{"i":"68","k":["local","debug","tunneling","localtunnel","ngrok","cloudflared"],"c":"2022-04-09","m":"2025-01-11","b":"[localtunnel](https://github.com/localtunnel/localtunnel)은 쉽게 테스트하고 공유할 수 있도록 로컬 호스트를 공개합니다! 다른 사람들이 변경 사항을 테스트하도록 하기 위해 DNS를 엉망으로 만들거나 배포할 필요가 없습니다.\n\n```js\napp.listen(PORT, async () => {\n  const tunnel = await localtunnel({\n    port: PORT,\n    subdomain: name,\n  })\n```\n\n> 하지만 너무 느려서 ngrok 쓰는게 현실적일수도 있겠다. -20220917\n\n- [awesome-tunneling](https://github.com/anderspitman/awesome-tunneling)\n- [cloudflared](https://github.com/cloudflare/cloudflared)\n- [ngrok](https://ngrok.com/)\n- [localxpose](https://localxpose.io/)\n\n---\n\n- [How to test webhooks on localhost](https://liveblocks.io/docs/guides/how-to-test-webhooks-on-localhost)[^68-1]\n\n---\n\n[^68-1]: 로컬 환경에서 웹훅 테스트를 위해, API 엔드포인트를 만들고 **localtunnel**이나 **ngrok**을 사용하기."},{"i":"66","k":["node","monorepo","yarn"],"c":"2022-04-09","m":"2024-03-22","b":"- [Monorepo Explained](https://monorepo.tools/)\n- [Monorepos are changing how teams build software – Vercel](https://vercel.com/blog/monorepos-are-changing-how-teams-build-software)\n\n```sh\n# 특정 패키지에 모듈을 설치해야한다면\nyarn workspace <workspace_name> <command>\n```"},{"i":"65","k":["node","npm","package"],"c":"2022-04-09","m":"2024-03-22","b":"```json\n// package.json 파일에 로컬 경로를 지정하는 방법. 파일 시스템에 있는 패키지 디렉터리를 사용할 수 있음.\n\n{\n  \"dependencies\": {\n    \"bar\": \"file:../foo/bar\"\n  }\n}\n```\n\n---\n\n- [package.json | npm Docs](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#local-paths)"},{"i":"64","k":["node","xlsx","excel"],"c":"2022-04-09","m":"2024-03-22","b":"CSV와는 다르다! CSV와는!\n\n```js\nconst fs = require('fs')\nconst XLSX = require('xlsx')\n\nconst buf = fs.readFileSync('developer_labels_20181221.xlsx')\nconst workbook = XLSX.read(buf, { type: 'buffer' })\n\nconst roa = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {\n  header: 1,\n})\n\nconst result = roa\n  .slice(1)\n  .map((item) => item.slice(1, 4))\n  .filter((item) => item.some(Boolean))\n  .reduce(\n    (p, n) => {\n      const [key, en, ko] = n\n      const newKey = key.toLowerCase().replace(/\\-/g, '_')\n\n      p['en'][newKey] = en\n      p['ko'][newKey] = ko\n\n      return p\n    },\n    { en: {}, ko: {} }\n  )\n\nfs.writeFileSync('en.json', JSON.stringify(result.en), 'utf8')\nfs.writeFileSync('ko.json', JSON.stringify(result.ko), 'utf8')\n```\n\n---\n\n- [sheetjs](https://docs.sheetjs.com/docs/)"},{"i":"63","k":["node","image","imagemin"],"c":"2022-04-09","m":"2026-07-27","b":"- 이미지 압축 기본 설정값 참조: mozjpeg(75), pngquant([75, 85])[^63-1]\n\n---\n\n[^63-1]: https://github.com/antonreshetov/image-optimizer/blob/master/src/main/store/module/app.ts#L33-L53"},{"i":"62","k":["node","yarn","package"],"c":"2022-04-09","m":"2024-03-22","b":"- [yarn upgrade-interactive | Yarn](https://classic.yarnpkg.com/lang/en/docs/cli/upgrade-interactive/#toc-yarn-upgrade-interactive)[^62-1]\n- [Find newer versions of package dependencies than what your package.json allows](https://github.com/raineorshine/npm-check-updates)[^62-2]\n\n---\n\n[^62-1]: 이 명령은 업그레이드를 수행하기 전에 오래된 패키지를 표시하여 사용자가 업그레이드할 패키지를 선택할 수 있도록 합니다.\n[^62-2]: [npm-check-updates](https://github.com/raineorshine/npm-check-updates)는 지정된 버전을 무시하고 [package.json](https://docs.npmjs.com/files/package.json/) 종속성을 최신 버전으로 업그레이드합니다 ."},{"i":"60","k":["node","sharp","image"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nimport sharp from 'sharp'\nimport fg from 'fast-glob'\n\nconst entries = await fg('./**/*.png')\n\nfor (const entry of entries) {\n  const trimmedBuffer = await sharp(entry).trim().toBuffer()\n  const trimmedImage = sharp(trimmedBuffer)\n  const trimmedMetadata = await trimmedImage.metadata()\n\n  trimmedImage\n    .resize({\n      width: Math.round(trimmedMetadata.width / 2),\n      height: Math.round(trimmedMetadata.height / 2),\n    })\n    .png()\n    .toFile('output.png')\n}\n```\n\noffset(top, left)값, 이미지 사이즈 설정으로 crop 구현이 가능하다\n\n```js\nsharp('img.png')\n  .extract({\n    left: 50,\n    top: 50, \n    width: 200, \n    height: 400\n  })\n```\n\n---\n\n- [resize](https://sharp.pixelplumbing.com/api-resize#resize)\n- [extract](https://sharp.pixelplumbing.com/api-resize#extract)"},{"i":"59","k":["node","buffer","fs","fetch"],"c":"2022-04-09","m":"2024-03-22","b":"이미지 다운로드 구현. 이미지 응답값을 buffer로 변환해서 파일쓰기로 저장한다.\n\n```js\nconst fs = require('fs')\nconst util = require('util')\nconst fetch = require('node-fetch')\n\nconst writeFile = util.promisify(fs.writeFile)\nconst mkdir = util.promisify(fs.mkdir)\n\nconst FOLDER_PATH = 'FOLDER_PATH'\n\nasync function download({ url }) {\n  const response = await fetch(url)\n  const buffer = Buffer.from(await response.arrayBuffer())\n\n  if (!fs.existsSync(FOLDER_PATH)) {\n    await mkdir(FOLDER_PATH)\n  }\n\n  await writeFile(url, buffer)\n}\n```"},{"i":"58","k":["esm","node"],"c":"2022-04-09","m":"2026-07-27","b":"[imagemin](https://github.com/imagemin/imagemin)을 사용하려고 하는데 [module](https://nodejs.org/api/esm.html)로 라이브러리가 업데이트 되어서 찾아본 내용. 혼란스러운 부분도 있지만 성숙해지는 과정이라고 본다.\n\n- [Pure ESM package · GitHub](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)\n- [Publish ESM and CJS in a single package](https://antfu.me/posts/publish-esm-and-cjs)[^58-1]\n\n---\n\n[^58-1]: [esm](https://nodejs.org/api/esm.html#modules-ecmascript-modules) 배경과 [tsup](https://tsup.egoist.dev/), [unbuild](https://github.com/unjs/unbuild) 라는 두개의 해결방법을 소개해주고 있다."},{"i":"57","k":["node"],"c":"2022-04-09","m":"2026-07-27","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n- [Node.js Child Processes: Everything you need to know](https://www.freecodecamp.org/news/node-js-child-processes-everything-you-need-to-know-e69498fe970a/)[^57-1]\n- [The definitive Node.js handbook](https://www.freecodecamp.org/news/the-definitive-node-js-handbook-6912378afc6e/)\n- [Tao of Node - Design, Architecture & Best Practices](https://alexkondov.com/tao-of-node/)\n- [Useful Built-in Node.js APIs](https://www.sitepoint.com/useful-built-in-node-js-apis/)\n\n---\n\n<QuoteLink url=\"https://github.com/sindresorhus/execa\">Execa</QuoteLink>\n\n- [Execa 9: our biggest release](https://medium.com/@ehmicky/execa-9-release-d0d5daaa097f)\n\n---\n\n<QuoteLink url=\"https://github.com/ds300/patch-package\">\n  patch-package\n</QuoteLink>\n\n- [GitHub - ds300/patch-package: Fix broken node modules instantly 🏃🏽‍♀️💨](https://github.com/ds300/patch-package)\n\n---\n\n<QuoteLink url=\"https://pm2.keymetrics.io/\">PM2</QuoteLink>\n\n- [[NODE] 📚 PM2 모듈 사용법 - 클러스터 / 무중단 서비스](https://inpa.tistory.com/entry/node-%F0%9F%93%9A-PM2-%EB%AA%A8%EB%93%88-%EC%82%AC%EC%9A%A9%EB%B2%95-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EC%84%9C%EB%B9%84%EC%8A%A4)\n- [PM2를 활용한 Node.js 무중단 서비스하기](https://engineering.linecorp.com/ko/blog/pm2-nodejs)\n\n---\n\n`glob`\n\n- [GitHub - isaacs/node-glob](https://github.com/isaacs/node-glob)\n- [GitHub - mrmlnc/fast-glob](https://github.com/mrmlnc/fast-glob)\n- [GitHub - sindresorhus/globby](https://github.com/sindresorhus/globby)\n- [GitHub - SuperchupuDev/tinyglobby](https://github.com/SuperchupuDev/tinyglobby)\n\n---\n\n[^57-1]: spawn(), exec(), execFile(), fork()"},{"i":"56","k":["promise","javascript"],"c":"2022-04-09","m":"2026-07-27","b":"reject는 `Error` 객체로 한다 — 문자열로 거부하면 스택 트레이스가 남지 않는다.[^56-1]\n\n```js\n// ❌\nPromise.reject('An error occurred');\n\n// ✅\nPromise.reject(new Error('An error occurred'));\n```\n\n---\n\n[^56-1]: [14 Linting Rules To Help You Write Asynchronous Code in JavaScript - Maxim Orlov](https://maximorlov.com/linting-rules-for-asynchronous-code-in-javascript/)"},{"i":"55","k":["fetch","formdata"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nconst formData = new FormData();\nconst single = document.querySelector('input[type=\"file\"]');\nconst multiple = document.querySelector('input[type=\"file\"][multiple]');\n\nformData.append('single', single.files[0]);\n\n[...multiple].forEach((file, index) => {\n  formData.append(`multiple_${index}`, file);\n})\n\nfetch('https://example.com/posts', {\n  method: 'POST',\n  body: formData,\n})\n```"},{"i":"53","k":["fetch","preflight","simple-requests"],"c":"2022-04-09","m":"2024-03-22","b":"- [조건](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests)에 따라서 브라우저에서 자동으로 발생[^53-1]\n- OPTIONS 메서드로 요청\n- cloudfront에 OPTIONS 메서드 설정이 안되어 있다면 실패[^53-2]\n\n---\n\n[^53-1]: [단순 요청(Simple requests)](https://developer.mozilla.org/ko/docs/Web/HTTP/CORS#%EB%8B%A8%EC%88%9C_%EC%9A%94%EC%B2%ADsimple_requests)\n[^53-2]: [CloudFront 배포의 캐시 동작이 HTTP 요청에 대한 OPTIONS 메서드를 허용함](https://aws.amazon.com/ko/premiumsupport/knowledge-center/no-access-control-allow-origin-error/)"},{"i":"52","k":["fetch","onprogress"],"c":"2022-04-09","m":"2024-03-22","b":"- [XMLHttpRequestEventTarget.onprogress](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestEventTarget/onprogress)[^52-1]\n- [ReadableStream - Web API | MDN](https://developer.mozilla.org/ko/docs/Web/API/ReadableStream)[^52-2]\n- [Request Config - onUploadProgress](https://github.com/axios/axios#request-config)[^52-3]\n\n---\n\n[^52-1]: XMLHttpRequest가 완전히 성공할때까지 정보를 주기적으로 호출하는 함수\n[^52-2]: `fetch` 에서는  `ReadableStream`, `Response` 조합으로 구현 가능\n[^52-3]: axios 에서는 `onUploadProgress` 콜백(`progressEvent`)으로 구현"},{"i":"51","k":["tailwindcss","create-react-app"],"c":"2022-04-09","m":"2024-03-22","b":"```json\n{\n  \"tailwindcss\": \"tailwindcss -i ./src/index.css -o ./src/tailwind.css\",\n  \"start\": \"concurrently \\\"yarn tailwindcss --watch\\\"\",\n  \"prebuild\": \"yarn tailwindcss --minify\",\n}\n```\n\ncreate-react-app 구형버젼(+eject)에서 설치할 경우 연관된 부분이 많아서 차라리 cli를 사용하는게 편한 상황. 그런데 [`concurrently`](https://github.com/open-cli-tools/concurrently#readme)[^51-1]로 프로세스를 동시에 실행시켜야 되는 부분이 있다.\n\n---\n\n[^51-1]: https://tailwindcss.com/docs/tailwind-cli"},{"i":"49","k":["next","static"],"c":"2022-04-09","m":"2024-03-22","b":"[Advanced Features: Static HTML Export | Next.js](https://nextjs.org/docs/advanced-features/static-html-export)\n\n```shell\nnext export\n```\n\n앱의 HTML 버전을 빌드. `.out` 디렉토리에 빌드된 페이지 파일을 복사합니다."},{"i":"48","k":["react","next","monorepo","hygen","husky","dependabot"],"c":"2022-04-09","m":"2026-08-19","b":"[How To Maintain A Large Next.js Application — Smashing Magazine](https://www.smashingmagazine.com/2021/11/maintain-large-nextjs-application/)\n\n- TypeScript 사용\n- Lerna , Nx , Rush , Turborepo , yarn workspaces를 사용하여 Mono-Repo 구조 사용\n- Hygen과 같은 코드 생성기를 사용하여 상용구 코드 생성\n- Redux 툴킷을 통해 하위 상용구와 함께 Redux와 같이 잘 설정된 패턴 사용\n- 비동기 데이터를 가져오기 위해 React 쿼리 또는 SWR 사용\n- Husky와 함께 Commitizen 및 Semantic Release 사용\n- UI 구성 요소 시각화를 위해 스토리북 사용\n- 처음부터 유지 관리 가능한 테스트 작성\n- Dependabot을 사용하여 자동으로 패키지 업데이트\n- [Production Checklist | Next.js](https://nextjs.org/docs/app/guides/production-checklist)"},{"i":"47","k":["state","ui"],"c":"2022-04-09","m":"2024-03-22","b":"[No, disabling a button is not app logic. - DEV Community](https://dev.to/davidkpiano/no-disabling-a-button-is-not-app-logic-598i)\n\n```md\n- \"idle\" 아무 것도 아직 일어나지 않았다.\n- \"loading\" 진행중\n- \"success\" 성공적\n- \"failure\" 오류가 발생했음\n```"},{"i":"46","k":["composition","react"],"c":"2022-04-09","m":"2026-07-27","b":"- [Using Composition in React to Avoid \"Prop Drilling\"](https://youtu.be/3XaXKiXtNjw)[^46-1]\n- [Merrick Christensen - Headless User Interface Components](https://www.merrickchristensen.com/articles/headless-user-interface-components/)[^46-2]\n- [Headless Component: a pattern for composing React UIs](https://martinfowler.com/articles/headless-component.html)\n\n---\n\n[^46-1]: React에서 컴포지션의 개념과 \"Prop Drilling\"을 피하기 위해 React에서 사용하는 방법을 설명.\n[^46-2]: Headless UI 컴포넌트의 개념을 소개. 헤드리스 UI 컴포넌트가 무엇인지, 기존 UI 컴포넌트와 어떻게 다른지 설명하고, 다양한 프로그래밍 언어와 프레임워크에서 헤드리스 UI 컴포넌트를 구현하는 방법에 대한 예제."},{"i":"38","k":["optimize"],"c":"2022-04-09","m":"2024-03-22","b":"[google-optimize](https://github.com/jaredLunde/react-hook/tree/master/packages/google-optimize)\n\n- [#L19-L24](https://github.com/jaredLunde/react-hook/blob/master/packages/google-optimize/src/index.tsx#L19-L24)[^38-1]\n- [#L46](https://github.com/jaredLunde/react-hook/blob/master/packages/google-optimize/src/index.tsx#L46)[^38-2]\n- [#L8](https://github.com/jaredLunde/react-hook/blob/master/packages/google-optimize/src/index.tsx#L8)[^38-3]\n\n---\n\n[^38-1]: `setTimeout`, `clearTimeout` 으로 최적화\n[^38-2]: `gtag` 에서도 `remove: true`값 전달로 해제\n[^38-3]: `variants`를 컴포넌트로 전달 받아서 리턴"},{"i":"36","k":["react","use-reducer"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nfunction toggleReducer(state, action) {\n  switch (action.type) {\n    default:\n      return state\n  }\n}\n\nfunction useToggle({ reducer = toggleReducer } = {}) {\n  const [state, dispatch] = useReducer(reducer, {})\n  return { state, dispatch }\n}\n\nexport function Component() {\n  useToggle({\n    reducer(currentState, action) {\n      console.log(currentState, action)\n    },\n  })\n}\n```\n\n`useReducer`를 이용한 커스텀훅 사용에 대한 간단한 예시. 생각해보니 reducer를 전달해서 재사용하는 방법은 잘 생각못했는데 응용할 수 있을 것 같다.\n\n---\n\n- [The State Reducer Pattern with React Hooks](https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks)[^36-1]\n\n---\n\n[^36-1]: 리듀서를 사용하여 예측 가능하고 테스트 가능한 방식으로 상태 업데이트 및 작업을 캡슐화하는 방법과 State Reducer 패턴을 사용하여 이를 사용하는 구성 요소에서 상태 업데이트를 추상화하여 해당 구성 요소가 특정 기능에 더 집중하도록 만드는 방법을 설명."},{"i":"30","k":["gsap","draggable","360","ui"],"c":"2022-04-09","m":"2024-03-22","b":"360 이미지를 드래그해서 회전시키는 기능을 개발할때 3d 리소스를 제공 받을 수 있다면 three.js 같은 라이브러리를 사용해서 쉽게(?) 구현이 가능하다. three.js가 초반 진입 장벽이 높은 것 같지만 단순히 모델 리소스를 보여주는 정도는 배경지식이나 기본지식이 없더라도 어느정도 예제 코드들을 본다면 구현이 가능하다고 본다. 물론 제대로 하고 싶다면 배경지식과 three.js 자체에 대한 학습이 필요.\n\n그런데 부득이하게 여러장으로 된 이미지만 제공 받을 수 있다면 직접 구현해야한다. 그런데 생각보다 드래그 기능을 처음부터 구현한다는게 보통의 개발자들에게는 불편한게 사실이므로 이미 존재하는 플러그인이나 라이브러리를 사용하는게 현실적이다.\n\n그래서 찾아본 gsap의 [Draggable](https://greensock.com/draggable/). 그런데 문서를 보면 다이얼 같이 (뭐라 표현하는게 적당한지 모르겠지만) 직접적으로 드래그 하는 관점의 설명들이 대부분이다. 그런데 360 이미지 같은 경우는 직접적으로 드래그 라기보다는 액션을 빌린 동작이어서 처음에 약간 혼란스러웠는데 좀 더 찾아보니 proxy의 개념을 빌려서 설명된 부분이 있었고 저런식으로 하면 쉽게 [구현](https://codepen.io/eunsoolee/pen/BaZwxoO)이 가능하다.\n\n참고\n\n- [Advanced tutorial: CubeDial, a 3D Carousel made with GSAP - Blog - GreenSock](https://greensock.com/cube-dial-tutorial)\n- https://codepen.io/GreenSock/pen/nHpec/\n- https://codepen.io/GreenSock/pen/LVEXrB\n- https://codepen.io/jamiejefferson/pen/kFhKE/\n\n반대 방향으로 진행시 계산법\n\n- https://codepen.io/GreenSock/pen/b99ec2f21dcd53ecd19c004744b0b6c1"},{"i":"29","k":["calendar","date"],"c":"2022-11-05","m":"2024-03-22","b":"```js\nimport {\n  addDays,\n  addMonths,\n  eachDayOfInterval,\n  eachWeekOfInterval,\n  startOfMonth,\n} from 'date-fns'\n\nconst startOfMonthDate = startOfMonth(new Date())\nconst matrix = eachWeekOfInterval({\n  start: startOfMonthDate,\n  end: addMonths(startOfMonthDate, 1),\n}).map((weekDay) => {\n  const startDate = new Date(weekDay)\n\n  return eachDayOfInterval({\n    start: startDate,\n    end: addDays(startDate, 6),\n  })\n})\n```\n\n- matrix 구현\n  - [bclinkinbeard/calendar-matrix](https://github.com/bclinkinbeard/calendar-matrix)\n  - [Calendar Matrix (date-fns, ES6)](https://gist.github.com/miljan-aleksic/bd70452a3f0cd6a11545db9f6ab57df6)\n- 튜토리얼\n  - [How to Make a Monthly Calendar With Real Data | CSS-Tricks](https://css-tricks.com/how-to-make-a-monthly-calendar-with-real-data)\n  - [Let's Make a Vue-Powered Monthly Calendar | CSS-Tricks](https://css-tricks.com/lets-make-a-vue-powered-monthly-calendar)"},{"i":"28","k":["calendar","ui","date"],"c":"2022-04-09","m":"2026-09-01","b":"**어느 캘린더 라이브러리를 쓸까.**\n\n| 상황                   | 추천                   |\n| ---------------------- | ---------------------- |\n| 완전 커스텀 UI 필요    | react-aria + 직접 구현 |\n| 빠르게 기본 기능 필요  | react-big-calendar     |\n| 모던한 DX, 가벼움 중시 | Schedule-X             |\n| 엔터프라이즈급 기능    | FullCalendar           |\n\n- 컴포넌트[^28-2]\n  - calendar\n    - [react-calendar](https://github.com/wojtekmaj/react-calendar/tree/fe83e095f0b744ddaf09bb909bf15d9e45054809)\n    - [react-big-calendar](https://github.com/jquense/react-big-calendar)\n    - [fullcalendar](https://github.com/fullcalendar/fullcalendar)\n    - [schedule-x](https://github.com/schedule-x/schedule-x)\n  - date-picker\n    - [gpbl/react-day-picker](https://github.com/gpbl/react-day-picker)\n    - [dayzed](https://github.com/deseretdigital/dayzed)\n    - [react-datepicker](https://github.com/Hacker0x01/react-datepicker/)\n  - headless\n    - [use-lilius](https://github.com/its-danny/use-lilius)\n    - [h6s](https://github.com/h6s-dev/h6s/tree/main/packages/calendar)\n- 그외\n  - [you-dont-need/You-Dont-Need-Momentjs](https://github.com/you-dont-need/You-Dont-Need-Momentjs)[^28-1]\n\n**라이브러리 특징:**\n\n- **react-aria** - Adobe Spectrum 팀의 headless. 스타일 없이 로직과 접근성만. `useCalendar`, `useDatePicker` 훅 기반. ARIA 구현 꼼꼼. 일정 표시는 직접 구현.\n- **Schedule-X** - 최신. React/Vue/Angular 어댑터. 드래그/리사이즈 지원. 월/주/일 뷰. 가벼움. 레퍼런스 적음.\n- **FullCalendar** - 가장 오래됨. 플러그인 아키텍처. 타임라인, 리소스 뷰 등 고급 기능. 일부 유료, 번들 큼.\n- **react-big-calendar** - 순수 React. Google Calendar 스타일. moment/date-fns/dayjs 선택 가능. 타입 지원 아쉬움.\n\n---\n\n[^28-1]: 네이티브로 날짜 계산(?)을 구현하려면 참고\n[^28-2]: headless 모드가 있으면 좋을 것 같은데 찾기 어렵다. 이런게 있다...정도로만 생각하자. 실제 갖다 써보면 뭔말인지 알 수 있을거다."},{"i":"27","k":["video","ffmpeg","canvas"],"c":"2022-04-09","m":"2025-01-12","b":"- [How to use transparent videos on the web in 2021 - Rotato](https://www.rotato.app/read/transparent-videos-for-the-web)\n- [How to make HEVC, H265 and VP9 videos with an alpha channel for the web | Kit Cross](https://kitcross.net/hevc-web-video-alpha-channel/)\n- [Alpha Masking with FFMPEG | Curio Museum](https://curiosalon.github.io/blog/ffmpeg-alpha-masking/)[^27-1]\n\n```sh\nffmpeg -framerate 25 -i image_%1d.png -c:v libvpx-vp9 -pix_fmt yuva420p output.webm\n```\n\n단점\n\n- 두벌로 인코딩 작업을 해야한다. MacOS가 아닐경우 번거로운 부분이 존재한다.\n- 브라우저 지원이 애매하게 걸친 부분이 존재한다.\n- (개인적인 느낌) 사이즈가 커졌을 경우 프레임 드랍이 있다.\n\n...그래서 어차피 안되는거 새로운 도전을 해보고 싶어서 크로마키 효과를 떠올렸다. sharp로 이미지 배경을 green 컬러로 채우고 그 이미지들을 합쳐서 동영상으로 변환. 그리고 canvas에 그리고 색상을 추출해서 green값을 alpha값으로 변환하면 완벽하지 않을까 싶었는데 겹치는 영역을 전혀 생각못했다. 이부분은 뭔가 특정 알고리즘이 있는 것 같은데 그냥 단순히 근사치[^27-5]로 적용했을때 결과물이 완벽하지는 않다.\n\n- [캔버스(canvas)를 이용한 비디오 조작하기 - Web API | MDN](https://developer.mozilla.org/ko/docs/Web/API/Canvas_API/Manipulating_video_using_canvas)[^27-2]\n- [Green Screen in the Browser With HTML Canvas](https://www.kapwing.com/blog/green-screen-in-browser/)[^27-3]\n- [Canvas: Do cool stuff with video in the browser | Mux blog](https://mux.com/blog/canvas-adding-filters-and-more-to-video-using-just-a-browser/)[^27-4]\n\n---\n\n[^27-1]: 여기서는 좀 더 나아가서 ffmpeg으로 드로잉 하는 부분까지 알려주고 있다.\n[^27-2]: 기본 배경지식을 설명하고 있다. video->canvas\n[^27-3]: hsv로 색상값을 체크하는 방법을 알려주고 있다. 그리고 해당 연산은 worker로 따로 분리해서 처리.\n[^27-4]: canvas 에서 색상값을 체크해서 필터 기능을 구현하고 머신러닝으로 모션탐지 기능구현이 가능하다는 예제를 보여준다.\n[^27-5]: [javascript - How to accurately filter RGB value for chroma-key effect - Stack Overflow](https://stackoverflow.com/questions/38419980/how-to-accurately-filter-rgb-value-for-chroma-key-effect)"},{"i":"24","k":["globe","webgl"],"c":"2022-04-09","m":"2026-07-27","b":"- [To design and develop an interactive globe](https://stripe.com/blog/globe)[^24-1]\n- [How we built the GitHub globe | The GitHub Blog](https://github.blog/2020-12-21-how-we-built-the-github-globe/)[^24-2]\n- [Gatsby Serverless Functions And The International Space Station — Smashing Magazine](https://www.smashingmagazine.com/2021/07/gatsby-serverless-functions-international-space-station/)[^24-3]\n- [GitHub - shuding/cobe: 5kB WebGL globe lib.](https://github.com/shuding/cobe)\n\n---\n\n[^24-1]: WebGL + [Three.js](https://threejs.org/), 데이터는 [Mapbox](https://www.mapbox.com/)\n[^24-2]: WebGL + [d3-geo](https://github.com/d3/d3-geo)\n[^24-3]: ISS 위치 추적 — Three.js + [satellite.js](https://github.com/shashwatak/satellite-js)"},{"i":"23","k":["raspberry-pi"],"c":"2022-04-09","m":"2026-07-27","b":"- [Raspberry Pi 'WeatherClock' shows you the hour's forecast - Raspberry Pi](https://www.raspberrypi.org/blog/raspberry-pi-weatherclock-shows-you-the-hours-forecast/)[^23-1]\n- [Show Dev: RaspberryPi Weather ⛈ fuelled by Netlify functions and Preact - DEV Community](https://dev.to/moigonzalez/show-dev-raspberrypi-weather-fuelled-by-netlify-functions-and-preact-bne)[^23-2]\n- https://weather-mu.vercel.app/\n\n---\n\n[^23-1]: Raspberry Pi Zero W + 1.3인치 OLED, [OpenWeatherMap API](https://openweathermap.org/api) 를 Python 스크립트로\n[^23-2]: [Preact](https://preactjs.com/) + [Netlify Functions](https://www.netlify.com/products/functions/)"},{"i":"22","k":["throttle","debounce","lodash","event","ui"],"c":"2022-04-09","m":"2024-03-22","b":"[Debouncing and Throttling Explained Through Examples | CSS-Tricks](https://css-tricks.com/debouncing-throttling-explained-examples/)[^22-1]\n\n- throttle: 함수가 호출되는 속도를 제한 (일정시간 동안)\n- debounce: 추가 호출 없이 일정 시간이 경과할 때까지 함수 실행을 지연하거나 제한 (마지막 이벤트만)\n\n---\n\n[^22-1]: 검색 입력, 스크롤 이벤트, 크기 조정 이벤트 등 디바운싱과 스로틀링이 유용할 수 있는 사례들"},{"i":"21","k":["ui","builder","dnd"],"c":"2022-04-09","m":"2026-07-27","b":"- [Builder.io - Drag and drop experience builder - Builder.io](https://www.builder.io/)[^21-1]\n- [Blocks UI](https://blocks-ui.com/)[^21-2]\n- [Craft.js](https://craft.js.org/)[^21-3]\n- [Pagedraw — Effortlessly turn mockups into functional UI code](https://pagedraw.io/)[^21-4]\n- [OpenChakra](https://openchakra.app/)[^21-5]\n- [GitHub - chriskitson/react-drag-drop-layout-builder: Drag and drop (DnD) UI layout builder using React and ImmutableJS](https://github.com/chriskitson/react-drag-drop-layout-builder)[^21-6]\n- [Unlayer Embed Plugin | Email and Page Editor for SaaS](https://unlayer.com/embed)\n- [Puck](https://puckeditor.com/)\n\n---\n\n[^21-1]: 랜딩 페이지, 마케팅 사이트, 전자상거래 스토어를 만들 수 있는 드래그 앤 드롭 빌더\n[^21-2]: 직관적인 UI로 복잡한 반응형 레이아웃, 프로토타입 및 랜딩 페이지를 제작\n[^21-3]: React 기반 비주얼 에디터\n[^21-4]: 반응형 디자인을 만들고 자동으로 코드를 생성\n[^21-5]: 실시간 피드백을 통해 UI 컴포넌트를 빌드할 수 있는 React 기반 비주얼 에디터\n[^21-6]: React 및 ImmutableJS를 사용하여 반응형 UI 레이아웃을 만들기 위한 오픈 소스 드래그 앤 드롭 레이아웃 빌더입니다."},{"i":"19","k":["ui","calculator","math"],"c":"2022-04-09","m":"2026-07-27","b":"자연어로 계산을 쓰는 계산기들.\n\n- [The creation of Soulver. How two teenagers made a new kind of… | by Zac Cohan | Soulver | Medium](https://medium.com/soulver/the-creation-of-soulver-454dee1e2fd1)\n- [Designing calculator apps](https://fvsch.com/calculators)\n- [Numi. Beautiful calculator app for Mac.](https://numi.app/)\n- [Parsify Desktop](https://parsify.app/)\n- [Math Notepad](https://mathnotepad.com/)[^19-5]\n\n---\n\n[^19-5]: [math.js](https://mathjs.org/)"},{"i":"15","k":["typescript","function"],"c":"2022-04-09","m":"2026-07-27","b":"```ts\ninterface MathFn {\n  (a: number, b: number): number\n}\nconst sum: MathFn = (a, b) => a + b\n```\n\n- [TypeScript Function Syntaxes](https://kentcdodds.com/blog/typescript-function-syntaxes) [(번역)](https://ui.toast.com/weekly-pick/ko_20210521)[^15-1]\n- [Typing functions in TypeScript](https://2ality.com/2020/04/typing-functions-typescript.html)[^15-2]\n\n---\n\n[^15-1]: function declarations, function expressions, arrow functions, methods등 TypeScript에서 함수를 선언하는 다양한 방법들.\n[^15-2]: TypeScript에서 함수를 입력하는 방법에 대한 종합적인 가이드"},{"i":"11","k":["javascript","date","temporal","timezone"],"c":"2022-04-09","m":"2024-03-22","b":"- [Temporal documentation](https://tc39.es/proposal-temporal/docs/)[^11-1]\n  - [Temporal Cookbook](https://tc39.es/proposal-temporal/docs/cookbook.html)\n- [Is It Time for the JavaScript Temporal API?](https://blog.openreplay.com/is-it-time-for-the-javascript-temporal-api)[^11-2]\n- [JS Dates Are About to Be Fixed | TimeTime](https://docs.timetime.in/blog/js-dates-finally-fixed/)\n- [Using Intl.RelativeTimeFormat for Localized Relative Timings](https://www.raymondcamden.com/2024/03/07/using-intlrelativetimeformat-for-localized-relative-timings)\n- [Everything You Need to Know About Date in JavaScript | CSS-Tricks](https://css-tricks.com/everything-you-need-to-know-about-date-in-javascript/)[^11-3]\n- [자바스크립트에서 타임존 다루기 (1) : NHN Cloud Meetup](https://meetup.toast.com/posts/125)[^11-4]\n- [자바스크립트에서 타임존 다루기 (2) : NHN Cloud Meetup](https://meetup.toast.com/posts/130)\n\n---\n\n[^11-1]: 날짜 및 시간 작업을 위한 Temporal API 공식 문서\n[^11-2]: 날짜 및 시간을 사용하여 작업할 때의 문제를 논의하고 솔루션으로 Temporal API를 제안하는 블로그 게시물\n[^11-3]: Date 개체, 날짜 형식, 시간대 및 날짜 라이브러리를 포함하여 JavaScript에서 날짜 작업에 대한 포괄적인 설명\n[^11-4]: 타임존 작업의 기본 사항을 설명"},{"i":"8","k":["javascript","array"],"c":"2022-04-09","m":"2024-03-22","b":"[Quick tip: reusable Array search predicates - JASON Format](https://jasonformat.com/reusable-array-search-predicates/)\n\n```js\narr.filter(callback(element[, index[, array]])[, thisArg])\n```\n\n배열 메서드에서 2번째 인자 `thisArg`에 참조값을 전달해서 재사용 가능한 함수를 만드는 트릭. 단 성능 이슈가 있으므로 주의해야 한다."},{"i":"7","k":["plugin","javascript"],"c":"2022-04-09","m":"2024-03-22","b":"[Designing a JavaScript Plugin System | CSS-Tricks](https://css-tricks.com/designing-a-javascript-plugin-system/)[^7-1]\n\n---\n\n[^7-1]: 플러그인은 라이브러리와 프레임워크의 공통 기능이며 개발자가 안전하고 확장 가능한 방식으로 기능을 추가할 수 있도록 한다. 그래서 추가 유지 관리 부담이 없다."},{"i":"6","k":["ast","parser","unified","css","javascript"],"c":"2022-04-09","m":"2024-03-22","b":"- [How to Modify Nodes in an Abstract Syntax Tree | CSS-Tricks](https://css-tricks.com/how-to-modify-nodes-in-an-abstract-syntax-tree/)[^6-1]\n- [AST for JavaScript developers. TL;DR This article is my talk for… | by Bohdan Liashenko | ITNEXT](https://itnext.io/ast-for-javascript-developers-3e79aeb08343)[^6-2]\n- [GitHub - NV/CSSOM: Unmaintained! ⚠️ CSS Object Model implemented in pure JavaScript. Also, a CSS parser.](https://github.com/NV/CSSOM)[^6-3]\n- [GitHub - csstree/csstree: A tool set for CSS including fast detailed parser, walker, generator and lexer based on W3C specs and browser implementations](https://github.com/csstree/csstree)\n\n---\n\n[^6-1]: markdown을 [unified](https://unifiedjs.com/)를 이용해서 파싱하고 html로 변환. (플러그인 기능을 추가해서 `img`->`figcaption` 기능 추가)\n[^6-2]: AST가 무엇이며 일반 코드에서 어떻게 구축 하는지에 대한 설명과 기반으로 하는 사용 사례와 프로젝트 소개\n[^6-3]: css 데이터 조작이 필요한 [경우](https://yy644.csb.app/)가 있어서 찾아봤다. 예를들어 특정 속성값만 추출해서 유닛값은 제거 한다든가."},{"i":"5","k":["javascript","class"],"c":"2022-04-09","m":"2024-03-22","b":"- [The Complete Guide to JavaScript Classes](https://dmitripavlutin.com/javascript-classes-complete-guide/)[^5-1]\n- [The Flavors of Object-Oriented Programming (in JavaScript) | CSS-Tricks](https://css-tricks.com/the-flavors-of-object-oriented-programming-in-javascript/)[^5-2]\n\n---\n\n[^5-1]: 클래스에 대한 구문, 메서드, 속성, 상속 등에 대한 포괄적인 설명\n[^5-2]: 클래스, 프로토타입 및 팩토리 함수 사용과 같이 객체 지향 프로그래밍을 구현하는 다양한 접근 방식을 설명과 각 접근 방식의 예와 장단점들"},{"i":"4","k":["blob","javascript","file","axios"],"c":"2022-04-09","m":"2024-03-22","b":"- 캔버스로 동영상 프레임을 캡쳐한다. \n- 텍스트를 입력 받는다. 해당 텍스트를 서버에 보내고 음성파일을 응답받는다. \n  - `Blob`데이터는 `URL.createObjectURL()`로 변환해서 `img`, `audio` 태그에 연결한다.\n\n---\n\n- [[JS] Blob와 Blob URL - 코드쓰는사람](https://taegon.kim/archives/5078)\n- [Handle Blobs requests with Axios the right way | by Marios Fakiolas | Medium](https://medium.com/@fakiolinho/handle-blobs-requests-with-axios-the-right-way-bb905bdb1c04)\n- [Blob(블랍) 이해하기 | HEROPY](https://heropy.blog/2019/02/28/blob/)\n- [File API 정리하기. 최근 FileReader 를 사용할 일이 생겨 File API 를 정리… | by 박성룡 ( Andrew park ) | Medium](https://pks2974.medium.com/file-api-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0-729fa6a3a0ba)"},{"i":"1","k":["javascript"],"c":"2022-04-09","m":"2024-03-22","b":"- [Rethinking the JavaScript ternary operator](https://jrsinclair.com/articles/2021/rethinking-the-javascript-ternary-operator/)[^1-1]\n- [Break a forEach Loop with JavaScript](https://davidwalsh.name/javascript-array-tricks-3)[^1-2]\n- [You Can Label a JavaScript `if` Statement | CSS-Tricks](https://css-tricks.com/you-can-label-a-javascript-if-statement/)[^1-3]\n- [Refactoring optional chaining into a large codebase: lessons learned – Lea Verou](https://lea.verou.me/2020/06/refactoring-optional-chaining-into-a-large-codebase-lessons-learned/)[^1-4]\n- [[HTML5] 꼼꼼히 살펴보는 SCRIPT 엘리먼트 - 코드쓰는사람](https://taegon.kim/archives/6804)[^1-6]\n- [Single Page Applications using Rust](https://www.sheshbabu.com/posts/rust-wasm-yew-single-page-application/)[^1-5]\n- [Fast and maintainable patterns for fetching from a database – Sophie Alpert](https://sophiebits.com/2020/01/01/fast-maintainable-db-patterns.html)[^1-7]\n- [Deep-copying in JavaScript using structuredClone](https://web.dev/structured-clone/)[^1-8]\n- [Holistic Review of TC39 \"Dataflow\" Proposals — Tab Completion](https://www.xanthir.com/b5Gd0)[^1-9]\n\n---\n\n[^1-1]: __삼항연산자__(ternary)의 어두운면과 우려를 나타내는 글\n[^1-2]: `forEach` 루프 `break`하는 트릭. 참조하는 배열의 length값을 0으로.\n[^1-3]: 조건문에서 [`label`](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/label)을 지정해서 해당 블록으로 `break`가 가능하다는걸 보여주는 내용. 실제 적용할만한 사례는 거의 없고 글쓴이는 while+switch문에서 break를 좀 더 효율적으로 사용한 예를 보여주고 있다.\n[^1-4]: [optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining) 문법으로 리팩토링 하면서...변경 가능한 부분(삼항연산자, 배열, 기능탐지)과 주의사항(값 할당, 잘못된 위치에 표기), 조심할 부분(null/undefined, 연산 순서와 순위, return 항상 호출됨)으로 구분해서 정리한 글. 커밋을 보면 실제로 어떻게 작업했는지 확인도 가능하다.\n[^1-5]: rust로 작성하고 wasm으로 컴파일해서 클라이언트 프로그램을 작성하는 방법을 자세하게 설명하고 있다. rust에 관심이 있거나 하다면 볼만한 글.\n[^1-6]: `<script />` 태그와 속성값에 대한 정리\n[^1-7]: 종속성 구조를 파악해서 최적의 병렬화로 빠르게 데이터 가져오기\n[^1-8]: `structuredClone()`\n[^1-9]: pipe, flow 제안(초안)사항을 검토하는 관점에서 보는 시각"}]