재귀 크롤러의 세 부품과 런타임별 세금

lychee 저자가 muffet·LinkChecker·linkinator·broken-link-checker 소스를 읽고 내린 결론은 비밀 소스가 없다는 것이다.

Every recursive checker is a worklist plus a visited set plus a quiescence detector.

  • frontier — 발견된 URL 이 다시 들어가는 가변 작업 큐. 고정 입력 스트림이 아니다.
  • visited set — enqueue 시점에 갱신되는 중복 방지 집합.
  • quiescence detector — “이제 다 끝났나”에 답하는 종료 감지자.

그래서 “언어마다 어떻게 구현하나”는 **“각 런타임이 이 셋 중 무엇을 대신 처리하고 무엇을 개발자에게 청구하나”**로 환원된다. 알고리즘은 상수고, 변하는 건 세금이 앉는 위치뿐이다.

종료 감지 dedup 동기화 대가로 내는 것
Node (이벤트 루프) 공짜 (onIdle·drain) 공짜 (락이 없다) 처리량 — 파싱이 단일 코어를 막고 결과가 인메모리로 팽창
Go (goroutine) 공짜 (WaitGroup) mutex (직접) 무거운 런타임(GC·스케줄러), unbounded goroutine → RAM
Python (스레드) 직접 (condition var) 직접 (센티넬) GIL·스레드 천장 → 처리량 최저, unbounded 큐 → RAM
Rust (Tokio) 직접 조립 mutex + borrow checker Send + 'static 마찰. 대신 레이스가 컴파일조차 안 된다

진짜 갈림길은 첫 커밋에 있다

응답이 새 입력을 만드는 사이클(back-edge)이 크롤러고, inputs → extract → check → outputDAG 다. 나머지 넷은 첫 커밋부터 크롤러 모양이었고 lychee 만 스트림으로 시작했다 — 그 차이가 “Five years and four attempts later” 다.

크롤러:  frontier → worker pool → fetch/parse ─┐
             ↑────────── new links ────────────┘   (사이클)

lychee:  inputs → extractor → checker → results     (DAG, back-edge 없음)

파이프라인에 사이클을 나중에 우겨넣는 건 처음부터 사이클을 갖는 것보다 압도적으로 어렵다. 대신 lychee 가 얻은 건 비재귀 경로의 속도다 — 재귀가 1급 요구사항이 아니면 DAG 가 틀린 선택이 아니다. 언어가 아니라 아키텍처 결정이라는 게 요점이다.

종료 감지 — 불변식은 하나뿐

큐가 비어도 in-flight 워커가 새 링크를 밀어넣을 수 있으므로 “큐가 비면 종료”는 틀렸다. 지켜야 하는 건 하나다.

일이 남아 있는 동안 카운터가 순간적으로라도 0 을 읽는 창이 있으면 안 된다.

그래서 자식을 부모가 done 처리되기 전에 카운트하고, bootstrap 에서 최소 1 을 올려둔 뒤 wait 에 진입한다. lychee 의 Attempt 1·4 가 이 불변식을 손으로 지키지 못해 실패했다.

// Go: bootstrap 에서 addPage 1회 → 카운터가 양수인 채로 Wait 진입
func (m daemonManager) Run() {
    go func() { for f := range m.daemons { go f() } }()
    m.waitGroup.Wait() // 카운터 0 → 종료
}
# Python: increment(_put)와 decrement(task_done)가 둘 다 Condition 락 안에 있고,
# task_done 은 자식 enqueue 를 끝낸 뒤에만 불린다 → 이른 0 이 없다
def task_done(self, url_data):
    with self.all_tasks_done:
        self.unfinished_tasks -= 1
        if self.unfinished_tasks <= 0:
            self.all_tasks_done.notify_all()

Node 는 단일 스레드라 카운터 락이 아예 필요 없고 await queue.onIdle() 한 줄이다. Rust 는 2026 에 기여된 WaitGroup 으로 같은 자리에 도달했다 — 같은 아이디어가 런타임마다 다른 옷을 입었을 뿐이다.

중복 제거는 요청 이전, enqueue 시점

두 페이지가 같은 링크를 동시에 발견하면 둘 다 제출된다. 그래서 visited 표시는 enqueue 와 atomic 해야 하고 네트워크 요청보다 먼저여야 한다. 요청을 보낸 뒤에 캐시에 쓰면 그 사이에 중복 제출 창이 열린다 — lychee 가 네 번의 시도 내내 저지른 dedup 레이스의 본체다.

# Python: 요청 이전에 None 센티넬로 URL 을 선점한다
def _put(self, url_data):
    key = url_data.cache_url
    if cache.has_result(key): return    # 이미 큐잉·체크됨
    self.queue.append(url_data)
    self.unfinished_tasks += 1
    cache.add_result(key, None)         # "내 거" 표시. 이후 동시 발견은 no-op
// Node: 세 문장이 통째로 중단 없이 실행된다
const inCache = options.cache.has(url.href)
if (!inCache) {
  options.cache.add(url.href)                 // mark
  options.pendingChecks.set(url.href, checkPromise)
  options.queue.add(() => checkPromise)       // enqueue
}

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 이라고 부르는 것이 그 값이다.

frontier 와 rate limiter 를 겸하면 데드락

“다음에 뭘 할까”와 “얼마나 빨리 갈까”를 하나의 bounded channel 로 겸하면, 채널이 차는 순간 producer(응답 핸들러)가 블로킹되고 → 응답이 drain 되지 않고 → 슬롯이 안 비워져 영구 정지한다. LinkChecker 의 첫 설계 주석이 이걸 명시적으로 피해 간다.

# Note: don't put a maximum size on the queue since it would
# lead to deadlocks when all worker threads called put().

이 분리는 다섯 구현이 전부 지킨다 — 안 지키면 언어와 무관하게 데드락이다. 대신 unbounded frontier 는 데드락을 메모리 증가와 맞바꾼 것이라, 거대 사이트에서는 URL 상한과 주기적 정리로 완화한다(muffet 도 unbounded goroutine + 인메모리 set 이라 결국 RAM 에 묶인다). 디스크 백드 frontier 를 가진 구현은 이 다섯에 없다.

재귀하려면 body 가 있어야 한다

링크 유효성만 보면 HEAD 가 싸지만, 재귀는 더 많은 링크를 찾기 위해 응답 본문이 필요하다. linkinator 는 크롤할 때 항상 GET 을 쓰는 쪽으로 단순하게 풀었다.

response = await makeRequest(options.crawl ? 'GET' : 'HEAD', options.url.href)

lychee 의 계획은 check 단계에서 이미 받은 body 를 캐시에 들고 있다가 재귀 때 재사용하는 것이다 — 같은 제약을 중복 요청 없이 푼다.

#597

네이버 지도 마커 + fitBounds

언제 — naver.maps v3 에 마커를 여러 개 찍고 카메라를 자동으로 맞출 때. 증상이 있으면 바로 간다:

증상 레시피
“상위 N개”인데 지도엔 N개보다 적게 뜬다 R2
latitude! 단언이 붙는다 / NaN 좌표가 샌다 R3
마커가 하나일 때 카메라가 아예 안 움직인다 R5
같은 건물에 두 곳이라 줌이 최대까지 파고든다 R6
margin 을 줬는데 여백이 안 생긴다 R7
오른쪽·아래 가장자리 마커의 라벨이 잘린다 R8
숨겨진 탭에서 열면 줌이 엉뚱하다 / 지도가 회색 R9
목록이 바뀌었는데 옛 마커가 남아 있다 R10
목록이 자주 바뀌어 깜빡인다 R11
마커가 수백 개라 무겁다 R12

R1·R4 는 나머지가 딛고 서는 기반이다.

재료

declare const MAX_MARKERS: number
declare function createMarkerContent(p: Place): string

R1. bounds 는 마커가 아니라 데이터에서 계산한다

마커는 렌더링 산출물이지 좌표의 source of truth 가 아니다.

// ✗ 좌표를 marker 에 넣었다가 도로 꺼낸다
const bounds = new naver.maps.LatLngBounds(
  markers[0].getPosition() as naver.maps.LatLng,
  markers[0].getPosition() as naver.maps.LatLng
)

// ✓ positions 를 먼저 만들고 marker 와 bounds 양쪽이 그걸 쓴다
const positions = targets.map((p) => new naver.maps.LatLng(p.latitude, p.longitude))

Marker.getPosition() 의 선언 타입이 Coord(= LatLng | Point)라 캐스팅이 강제된다. 이 캐스팅은 정보 손실을 되돌리는 작업이고, 애초에 손실시키지 않으면 필요 없다. 부수 효과로 map.getProjection() 이나 Marker 인스턴스 없이 bounds 계산을 단위 테스트할 수 있게 된다.

R2. filter 먼저, slice 나중

// ✗ 앞 N개 중 좌표 없는 항목이 섞이면 실제 표시 개수가 N보다 적다
places.slice(0, MAX_MARKERS).filter(hasCoords)

// ✓
places.filter(hasCoords).slice(0, MAX_MARKERS)

“상위 N개”의 N 은 표시 가능한 것 중 N개를 뜻한다. 순서가 바뀌면 뒤에 유효 데이터가 남아 있는데도 지도가 비어 보인다.

R3. truthiness 대신 타입 가드

// ✗ 0 을 탈락시키고, narrowing 이 안 되어 뒤에서 ! 가 붙는다
.filter((p) => p.latitude && p.longitude)   // → p.latitude!, p.longitude!

// ✓
type Located = Place & { latitude: number; longitude: number }

const isLocated = (p: Place): p is Located =>
  Number.isFinite(p.latitude) && Number.isFinite(p.longitude)

.filter(isLocated)   // 이후 p.latitude 는 number

Number.isFinite!= null 보다 나은 이유는 API 가 NaN 이나 문자열 "" 를 흘려보내는 경우까지 한 번에 막히기 때문이다 — new LatLng(NaN, NaN) 은 예외를 던지지 않고 조용히 지도를 망가뜨린다.

한국 좌표계에서 0 은 안 나온다는 건 사실이지만, 이 필터가 만드는 진짜 이득은 ! 제거다.

R4. degenerate bounds seeding

LatLngBounds 는 빈 상태로 만들 수 없다. 그래서 면적 0 인 bounds 를 만들고 extend 로 키운다.

const bounds = positions.reduce(
  (acc, p) => acc.extend(p),
  new naver.maps.LatLngBounds(positions[0], positions[0])
)
  • extend 는 mutable accumulator 다. 자기를 변형하고 자기를 반환한다.
  • 첫 좌표가 seed 와 루프에서 두 번 들어가도 idempotent 라 무해하다.
  • sw/ne 순서를 직접 계산하다 뒤집는 실수를 원천 차단한다.

마커가 수백 개면 extend 반복 대신 숫자로 min/max 를 구해 한 번에 만드는 게 싸다:

const lats = targets.map((p) => p.latitude)
const lngs = targets.map((p) => p.longitude)
const bounds = new naver.maps.LatLngBounds(
  new naver.maps.LatLng(Math.min(...lats), Math.min(...lngs)),
  new naver.maps.LatLng(Math.max(...lats), Math.max(...lngs))
)

함정Math.min(...arr) 는 배열이 만 단위가 되면 스택을 넘긴다(그 규모면 reduce). 날짜변경선(±180°)을 걸치는 데이터는 min/max 방식이 지구를 반대로 감싼다(국내 서비스면 무시).

R5. 개수별 카메라 분기 (0 / 1 / N)

// ✗ 하나일 때 카메라가 아예 안 움직인다
if (markers.length > 1) { map.fitBounds(bounds, PADDING) }

한 곳뿐이면 지도는 초기 중심에 그대로 있고 마커는 화면 밖일 수 있다. 셋 다 처리한다:

if (positions.length === 0) {
  // 아무것도 안 한다 / 빈 상태 UI 로 넘긴다. 카메라는 건드리지 않는다.
} else if (positions.length === 1) {
  map.setCenter(positions[0])
  map.setZoom(SINGLE_MARKER_ZOOM)   // 15~17 정도
} else {
  map.fitBounds(bounds, BOUNDS_MARGIN)
}

0 개일 때 기본 좌표로 되돌릴지 사용자가 보던 위치를 유지할지는 제품 결정이다. 다만 말없이 아무 일도 안 일어나는 것의도적으로 유지하는 것은 코드에서 구분되어야 한다.

R6. 줌 오버슈트 클램프

가장 자주 터진다. 같은 건물·같은 블록에 두 곳이면 bounds 면적이 거의 0 이라 fitBounds 가 최대 줌까지 파고들어 건물 하나만 화면에 남는다.

A. 지도 레벨에서 상한 (권장) — 사용자 수동 줌인까지 같이 막히는 게 단점.

map.setOptions({ maxZoom: MAX_AUTO_ZOOM })  // 예: 17
map.fitBounds(bounds, BOUNDS_MARGIN)

B. 사후 클램프 — 동기적으로 적용되지 않는 빌드가 있다. 값이 안 먹으면 한 틱 뒤로 미룬다.

map.fitBounds(bounds, BOUNDS_MARGIN)
if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)

naver.maps.Event.once(map, 'idle', () => {
  if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)
})

C. bounds 에 최소 span — 겹친 마커가 화면 중앙에 모이는 대신 주변 맥락이 남는다.

const MIN_SPAN = 0.004  // 약 400m
const sw = bounds.getSW(), ne = bounds.getNE()
const latPad = Math.max(0, MIN_SPAN - (ne.lat() - sw.lat())) / 2
const lngPad = Math.max(0, MIN_SPAN - (ne.lng() - sw.lng())) / 2
if (latPad || lngPad) {
  bounds.extend(new naver.maps.LatLng(sw.lat() - latPad, sw.lng() - lngPad))
  bounds.extend(new naver.maps.LatLng(ne.lat() + latPad, ne.lng() + lngPad))
}

R7. fitBounds 두 번째 인자 확인

map.fitBounds(bounds, margin) 의 margin 은 버전·타입 정의에 따라 number 만 받기도 하고 { top, right, bottom, left } 를 받기도 한다. number 를 넘겼는데 타입 정의가 객체를 기대하면 조용히 무시된다.

확인 — 호출 전후로 map.getBounds() 를 찍어 여백 차이가 나는지 본다. 안 나면 안 먹은 것이다.

const BOUNDS_MARGIN = { top: 80, right: 24, bottom: 160, left: 24 }

하단을 크게 잡는 이유는 대개 바텀시트·목록 패널이 지도를 덮기 때문이다. 지도 컨테이너는 전체 화면인데 실제로 보이는 영역은 그보다 작다. 여백은 그 차이를 보정하는 값이지 미관용 패딩이 아니다.

R8. anchor 와 fitBounds 의 픽셀 불일치

icon: { content, anchor: new naver.maps.Point(0, 0) }

anchor (0, 0) 은 HTML 콘텐츠의 좌상단을 좌표에 붙이고, 마커 박스는 우하단으로 뻗는다. 그런데 fitBounds 는 지리 좌표만 보고 픽셀 크기를 모른다. 이름 길이에 따라 콘텐츠 폭이 달라지므로 오른쪽·아래 가장자리 마커는 고정 margin 으로 못 막고 잘린다.

  • 핀 형태: anchor = new Point(width / 2, height) — 뾰족한 끝이 좌표에 닿는다.
  • 말풍선·라벨: 콘텐츠 크기가 가변이라 anchor 를 상수로 못 준다. content 래퍼에 transform: translate(-50%, -100%) 를 걸고 anchor 는 Point(0, 0) 으로 두는 편이 안정적이다. CSS 가 픽셀 정렬을 맡고 anchor 는 관여하지 않는다.
  • 그래도 남는 클리핑은 BOUNDS_MARGIN 을 마커 최대 폭의 절반 이상으로 잡아 흡수한다.

R9. 컨테이너 크기 0 에서 호출하지 않는다

onReady 시점에 레이아웃이 확정되지 않았거나(숨겨진 탭, display:none, 애니메이션 중) 높이가 0 이면 fitBounds 가 엉뚱한 줌을 계산한다. 나중에 컨테이너가 커져도 카메라는 재계산되지 않는다.

const el = map.getElement()
if (el.clientWidth === 0 || el.clientHeight === 0) {
  const ro = new ResizeObserver(() => {
    if (el.clientWidth && el.clientHeight) {
      ro.disconnect()
      map.refresh()          // 내부 사이즈 재측정
      map.fitBounds(bounds, BOUNDS_MARGIN)
    }
  })
  ro.observe(el)
  return
}

탭 전환 후 지도가 회색으로 남는 증상도 같은 원인이고 처방도 map.refresh() 다.

R10. 생성과 부착을 분리하고 생명주기를 반환한다

// ✗ 변환 함수 안에서 부수 효과. 붙는 시점을 통제할 수 없다
const markers = targets.map((p) => new naver.maps.Marker({ position, map }))

new Marker({ map }) 은 생성 즉시 지도에 붙는다. 만들어두고 나중에 붙이려면 map 을 빼고 setMap(map) 을 따로 부른다. 더 중요한 건 떼는 경로가 있는가다 — 목록 필터가 바뀌어 함수가 다시 불리면 이전 마커는 지도에 그대로 남는다.

return {
  markers,
  destroy() {
    for (const m of markers) {
      naver.maps.Event.clearInstanceListeners(m)
      m.setMap(null)
    }
  },
}
useEffect(() => {
  if (!map) return
  const layer = placeMarkers(map, places)
  return () => layer.destroy()
}, [map, places])

함정setMap(null) 만으로는 리스너가 정리되지 않아 마커 인스턴스가 GC 되지 않는다. Event.addListener(marker, 'click', ...) 가 있으면 clearInstanceListeners 가 필수다. 그리고 places 가 매 렌더 새 배열이면 전량 재생성된다 — useMemo 로 안정화하거나 R11 로 넘어간다.

R11. 전량 재생성 대신 id 기준 diff

목록이 자주 바뀌고 마커가 수십 개 이상이면 전량 파기·재생성은 깜빡임과 GC 압박을 만든다.

const registry = new Map<string, naver.maps.Marker>()

function sync(map: naver.maps.Map, targets: Located[]) {
  const next = new Set(targets.map((p) => p.id))

  for (const [id, marker] of registry) {
    if (!next.has(id)) {
      naver.maps.Event.clearInstanceListeners(marker)
      marker.setMap(null)
      registry.delete(id)
    }
  }

  for (const p of targets) {
    const existing = registry.get(p.id)
    if (existing) {
      existing.setPosition(new naver.maps.LatLng(p.latitude, p.longitude))
    } else {
      registry.set(p.id, new naver.maps.Marker({ /* ... */ map }))
    }
  }
}

선택 상태 변화(강조 마커)는 마커를 재생성하지 말고 marker.setIcon({ content: ... }) 로 콘텐츠만 교체한다.

R12. 개수가 커지면 클러스터링

수백 개를 그대로 찍으면 DOM 마커(HTML content) 기준으로 스크롤·줌이 눈에 띄게 무거워진다. 네이버는 MarkerClustering 을 코어에 포함하지 않고 별도 오픈소스 모듈로 배포한다. MAX_MARKERS 로 자르는 건 그 전 단계의 임시방편이고, 상한을 넘겼을 때 “일부만 표시 중”임을 사용자에게 알릴지 결정해둔다.

조립

type Located = Place & { latitude: number; longitude: number }

const isLocated = (p: Place): p is Located =>
  Number.isFinite(p.latitude) && Number.isFinite(p.longitude)

const MAX_AUTO_ZOOM = 17
const SINGLE_MARKER_ZOOM = 16
const BOUNDS_MARGIN = { top: 80, right: 24, bottom: 160, left: 24 }

export function placeMarkers(map: naver.maps.Map, places: Place[]) {
  const targets = places.filter(isLocated).slice(0, MAX_MARKERS)
  const positions = targets.map(
    (p) => new naver.maps.LatLng(p.latitude, p.longitude)
  )

  const markers = targets.map(
    (place, i) =>
      new naver.maps.Marker({
        position: positions[i],
        map,
        icon: {
          content: createMarkerContent(place),
          anchor: new naver.maps.Point(0, 0), // 정렬은 content 래퍼 CSS 담당
        },
      })
  )

  if (positions.length === 1) {
    map.setCenter(positions[0])
    map.setZoom(SINGLE_MARKER_ZOOM)
  } else if (positions.length > 1) {
    const bounds = positions.reduce(
      (acc, p) => acc.extend(p),
      new naver.maps.LatLngBounds(positions[0], positions[0])
    )
    map.fitBounds(bounds, BOUNDS_MARGIN)
    if (map.getZoom() > MAX_AUTO_ZOOM) map.setZoom(MAX_AUTO_ZOOM)
  }

  return {
    markers,
    destroy() {
      for (const m of markers) {
        naver.maps.Event.clearInstanceListeners(m)
        m.setMap(null)
      }
    },
  }
}

확인

  • 좌표 결측 항목을 자르기 전에 걸러내는가
  • latitude! 같은 non-null 단언이 남아 있지 않은가
  • 마커가 0 개 / 1 개일 때 카메라 동작이 정의되어 있는가
  • 같은 건물에 두 곳일 때 줌이 최대까지 튀지 않는가
  • fitBounds 의 margin 이 실제로 반영되는가 (getBounds() 로 확인)
  • 바텀시트·헤더가 덮는 영역이 margin 에 반영되어 있는가
  • 가장 오른쪽·아래 마커의 라벨이 잘리지 않는가 (가장 긴 이름으로 테스트)
  • 숨겨진 탭에서 초기화될 때 map.refresh() 경로가 있는가
  • 재호출 시 이전 마커가 제거되는가
  • 리스너가 clearInstanceListeners 로 정리되는가
#595

React 컴포넌트 방어 열셋 — 앱 몫과 라이브러리 몫

언제 — 컴포넌트에 방어를 넣을지 말지 정할 때. 먼저 이게 앱 컴포넌트인지 라이브러리 컴포넌트인지 가른다. 앱 컴포넌트는 자기가 어디서 쓰이는지 알기 때문에 절반은 해당이 없다 — 전부 예방적으로 바르면 그게 새로운 over-engineering 이다.

# 방어 적용
R1 외부 store 는 useSyncExternalStore 공통
R2 FOUC 는 블로킹 스크립트 + 단일 소유권 공통
R3 render 출력을 client-only 값으로 가르지 않는다 공통
R4 파생은 순수 함수, 지속성이 correctness 면 state 공통
R5 SSR 안정 ID 는 useId 공통
R6 자식 주입은 cloneElement 아닌 context 공통
R7 isomorphic layout effect shim 조건부
R8 이벤트는 ownerDocument.defaultView 라이브러리
R9 Activity 로 숨을 때 전역 부수효과를 끈다 라이브러리·조건부
R10 ViewTransition 은 startTransition 으로 조건부
R11 민감 데이터는 prop 을 좁히고, taint 은 이중 안전망 공통(RSC)
R12 effect 는 StrictMode 이중 실행에 멱등 공통
R13 ref 콜백이 cleanup 을 반환한다 (React 19) 공통

공통 여덟 · 조건부/라이브러리 다섯. 앱 컴포넌트면 공통 여덟으로 대부분 끝난다. R1R4(상태·SSR)가 압도적으로 자주 물리고, R8R10 은 진짜 edge case 다.

R1. 외부 store 는 useSyncExternalStore 로 읽는다

증상 — 테마·인증·미디어쿼리처럼 React 밖에 사는 상태를 useState + useEffect 로 읽으면 concurrent 렌더링에서 트리 일부는 옛 값, 일부는 새 값으로 찢어진다(tearing). SSR 이면 초기값이 안 맞아 깜빡인다.

원인useEffect 는 커밋 라 첫 렌더가 항상 stale. concurrent 렌더러는 렌더 도중 외부 값이 바뀌면 일관성을 보장하지 않는다.

const value = useSyncExternalStore(
  subscribe,          // (cb) => unsubscribe
  getSnapshot,        // 클라이언트 현재값
  getServerSnapshot,  // SSR 안정값 — 이게 공짜로 딸려오는 SSR 대응
)

함정getSnapshot 이 객체를 반환하면 매번 새 참조라 무한 루프다. 값이 안 변하면 같은 참조를 캐시해서 돌려줄 것. primitive 면 안전하다. subscribe 의 add/remove 가 멱등이어야 StrictMode 이중 마운트에서 안 샌다(R12).

R2. FOUC 는 블로킹 스크립트 + 단일 소유권으로 막는다

증상 — 다크 테마 사용자가 새로고침하면 흰 화면이 한 프레임 번쩍인다. 서버는 유저 테마(localStorage)를 모르니 기본값으로 렌더하고, 하이드레이션 후에야 교정된다.

절차 — 첫 페인트 실행되는 스크립트가 <html> 에 속성 하나를 쓰고, React 는 그 속성을 JSX 로 렌더하지 않는다.

<html lang="ko" suppressHydrationWarning>
  <head><script dangerouslySetInnerHTML={{ __html: themeScript }} /></head>
// themeScript: import 없는 자기완결 문자열
(function(){try{
  var d = /* resolve dark? */
  var r = document.documentElement, t = d ? "dark" : "light"
  r.dataset.theme = t; r.style.colorScheme = t
}catch(e){}})()

확인 — 스크립트가 건드리는 속성을 React 가 className={...} 으로 다시 렌더하고 있지 않은가. 둘 다 소유하면 mismatch 다.

함정suppressHydrationWarning그 엘리먼트의 속성만 억제한다. 자식에 전파되지 않는다.

R3. render 출력을 client-only 값으로 가르지 않는다

증상localStorage.getItem() 을 컴포넌트 본문에서 부르면 서버에서 크래시하고, typeof window 가드로 우회하면 이번엔 hydration mismatch.

원인 — 문제는 “브라우저 API” 가 아니라 render 단계에서 client-only 값으로 출력을 가르는 것이다. 서버와 클라가 다른 트리를 만든다.

절차 — client-only 값은 effect 나 useSyncExternalStore(R1)를 거쳐 들어오게 하고, 첫 렌더는 서버와 같은 출력을 낸다.

함정useState(() => localStorage...) 의 lazy init 도 서버에서 실행된다. 크래시는 그대로다.

R4. 파생은 순수 함수, 지속성이 correctness 면 state

증상useMemo(getRandomColors, []) 로 만든 값이 HMR·리마운트 후 슬그머니 바뀐다.

원인 — 흔한 오진은 “React 가 캐시를 버려서”. 진짜 원인은 getRandomColors 가 impure 라서다. 순수했다면 재계산돼도 같은 값이라 티가 안 난다. useMemo 는 성능 힌트지 의미 보장이 아니다.

// 파생(순수)은 렌더 중 그냥 계산 — memo 도 과하다
const resolved = resolveTheme(mode, systemDark)

// 한 번 만들고 유지돼야 correctness 면 state (lazy init 로 1회 실행)
const [colors] = useState(getRandomColors)

함정 — 비결정적이거나 부수효과가 있는 계산을 useMemo 에 넣지 않는다.

R5. SSR 안정 ID 는 useId

증상Math.random()·모듈 카운터로 만든 id 가 서버·클라에서 불일치.

const id = useId()  // 트리 위치 기반이라 SSR 안정

함정 — 반환값에 특수문자가 들어갈 수 있다. getElementById(id) 는 안전하지만 querySelector('#' + id) 나 CSS 셀렉터에서는 깨진다. raw id 를 받는 API 를 쓴다.

R6. 자식 주입은 cloneElement 대신 context

증상cloneElement 로 자식에 props 를 꽂는 컴포넌트가 Fragment·lazy·다중 자식에서 부서진다.

return <Ctx value={value}>{children}</Ctx>  // React 19: context 가 곧 provider
// 자식: const v = use(Ctx)

cloneElement 는 타입이 불안정하고 자식 구조에 결합된다. 사실상 항상 context 가 낫다.

R7. useLayoutEffect 의 SSR 경고는 isomorphic shim 으로

const useIsoLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect

적용 — 커밋 전에 DOM 을 만져 flash 를 막아야 하는데 SSR 도 하는 컴포넌트(테마 토글 등). 단순 effect 면 useEffect 로 충분하다.

R8. 이벤트는 올바른 window 에 건다

증상 — 컴포넌트를 window.open·포털로 다른 창에 렌더했더니 window.addEventListener 가 원래 창을 듣는다.

const win = ref.current?.ownerDocument.defaultView ?? window
win.addEventListener(/* ... */)

effect 는 커밋 후라 ref.current 가 채워져 있다.

적용 — 멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트만. 리스너 정리 자체의 1차 도구는 R13 이다.

R9. Activity 로 숨겨질 때 전역 부수효과를 끈다

증상<Activity mode="hidden"> 안의 컴포넌트가 주입한 전역 :root 스타일이 숨긴 뒤에도 남아 샌다.

원인 — Activity 는 숨길 때 effect 를 정리하고 보일 때 재실행하지만, 순수 <style> DOM 노드 자체는 보존한다.

useLayoutEffect(() => {
  const el = ref.current
  if (!el) return
  el.media = 'all'
  return () => { if (ref.current) ref.current.media = 'not all' }  // cleanup 도 가드
}, [])

적용 — Activity(실험적) 경계와 전역 스타일 주입이 겹칠 때만. 매우 니치.

R10. ViewTransition 을 트리거하려면 startTransition

상태를 바꿨는데 <ViewTransition> 애니메이션이 안 도는 이유는 하나다 — ViewTransition 은 transition 으로 표시된 업데이트만 애니메이트한다. startTransition(() => setState(...)).

R11. 민감 데이터는 prop 을 좁히고, taint 은 이중 안전망

증상 — 서버 컴포넌트에서 user 객체를 통째로 클라이언트 컴포넌트에 넘겨 토큰·이메일이 번들에 실린다.

<ClientThing theme={user.theme} />   // user 통째 ❌ → 필드만 ⭕

그 위에 방어층으로:

experimental_taintUniqueValue('서버에만 두세요', user, user.token)

순서가 핵심이다 — prop 좁히기가 1차 해법이고 taint 은 defense-in-depth 다. 지루하고 올바른 수를 건너뛰고 실험적 도구로 점프하지 않는다. experimental_ 이라 API 변동도 있다.

R12. effect 는 StrictMode 이중 실행에 멱등이어야 한다

증상 — dev 에서만 구독이 두 번 걸리거나 애니메이션이 두 번 뛴다.

원인 — StrictMode 는 mount → unmount → mount 로 effect 를 두 번 돌려 정리 누락을 드러낸다. 프로덕션 버그의 조기경보다.

useEffect(() => {
  const un = subscribe(cb)
  return un        // 정리가 곧 멱등성
}, [])

add 에 대응하는 remove, 시작에 대응하는 취소. 경고가 아니라 무료 테스트라서 끄지 않는다.

R13. ref 콜백은 정리 함수를 반환한다 (React 19)

<div ref={(node) => {
  const ctrl = new AbortController()
  node.addEventListener('scroll', onScroll, { signal: ctrl.signal })
  return () => ctrl.abort()   // 노드가 떠날 때 정리
}} />

리스너를 노드 수명에 묶어야 할 때 effect + ref 조합보다 근본적이고 정확하다.

#594

Chrome 외부 앱 딥링크 "항상 열기" 해제

언제 — 커스텀 스킴(myapp://) 다이얼로그에서 “항상 열기 허용”을 실수로 체크해 되돌리고 싶을 때. 설정 UI 에는 항목이 없고 실제 상태는 프로필의 JSON 에 origin 단위로 저장된다.

  • 체크는 Preferencesprotocol_handler.allowed_origin_protocol_pairs{ "origin": { "scheme": true } } 로 저장된다.
  • chrome://settings/handlers웹 프로토콜 핸들러(mailto: 등 사이트가 등록한 것)만 다룬다. 외부 앱 실행 허용과는 별개다.
  • Chrome 은 종료 시 메모리 상태를 Preferences 에 덮어쓴다 — 실행 중에 파일을 고치면 되돌아간다.
  • MAC 해시로 보호되는 pref 는 Secure Preferences 에 들어가며 손으로 고치면 무시·초기화된다.
다이얼로그를 다시 띄우고 싶다
 ├─ R1 사이트 데이터 삭제 ─── 해결? → 끝
 ├─ Chrome 완전 종료
 ├─ R2 Preferences 편집 ─── protocol_handler 있음? → 편집 → 끝
 ├─ R3 grep 추적 ─┬─ 다른 프로필/브라우저? → 경로 바꿔 R2
 │                ├─ Secure Preferences? → R1
 │                └─ chrome://policy → R4
 └─ macOS 인데 여전히 안 뜸 → R5 (Launch Services)

R1. 사이트 데이터 삭제 (가장 안전, 우선 시도)

chrome://settings/content/all → 해당 사이트 검색 → 항목 클릭 → “데이터 및 권한 삭제”.

외부 프로토콜 허용이 origin 단위 사이트 데이터에 묶여 있어 함께 초기화되는 경우가 많다.


R2. Preferences 직접 편집

R1 로 안 될 때. 반드시 Chrome 을 완전히 종료(백그라운드 프로세스 포함)한 뒤 진행한다.

OS 경로
macOS ~/Library/Application Support/Google/Chrome/<Profile>/Preferences
Windows %LOCALAPPDATA%\Google\Chrome\User Data\<Profile>\Preferences
Linux ~/.config/google-chrome/<Profile>/Preferences

<Profile> 은 보통 Default 또는 Profile 1… 정확한 값은 chrome://version 의 “프로필 경로”에서 본다.

한 줄로 압축된 JSON 이라 jq 가 편하다.

jq '.protocol_handler.allowed_origin_protocol_pairs' Preferences            # 확인

jq 'del(.protocol_handler.allowed_origin_protocol_pairs["https://example.com"])' \
  Preferences > tmp && mv tmp Preferences                                   # 특정 origin

jq 'del(.protocol_handler.allowed_origin_protocol_pairs)' \
  Preferences > tmp && mv tmp Preferences                                   # 전체 초기화

확인 — Chrome 재시작 후 다이얼로그가 다시 뜬다. excluded_schemestrue 로 들어간 스킴은 “차단” 상태이니 반대로 차단을 풀고 싶을 때 같이 본다.


R3. 안 보일 때 추적

protocol_handler 가 비어 보이거나 없을 때.

grep -l "myapp" Preferences "Secure Preferences" "Local State" 2>/dev/null
grep -o '.\{200\}myapp.\{200\}' Preferences     # 한 줄 JSON이라 주변 컨텍스트째로

체크리스트 — 다른 프로필(Default 가 아닐 수 있다, chrome://version 재확인) / 다른 브라우저(Beta·Canary·Chromium·Edge 는 완전히 별도 디렉터리) / Secure Preferences 에 있으면 파일 편집을 포기하고 R1 로 / 검색 실패는 Chrome 실행 중이라 덮어써졌거나 한 줄 JSON 에서 눈으로 놓친 것.


R4. 정책으로 걸린 경우

chrome://policy 에서 AutoLaunchProtocolsFromOrigins 를 본다. 값이 있으면 사용자가 체크한 게 아니라 관리 정책으로 처음부터 다이얼로그가 억제된 것이라 로컬 편집으로 못 지운다. 회사 관리 기기면 IT/MDM 쪽 문제다.

반대로 스킴 자체를 막고 싶으면 URLBlocklistmyapp://* 를 넣는다.


R5. macOS — Chrome 밖일 가능성

Chrome 이 아니라 OS(Launch Services)가 스킴을 확인 없이 넘기는 경우.

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
  -dump | grep -B5 -A5 "myapp:"

앱이 스킴 핸들러로 등록돼 있으면 여기서 확인된다.

#593

TOTP 2FA 구현 (otpauth)

언제 — 앱 기반 2FA(TOTP, RFC 6238)를 직접 붙일 때. 먼저 직접 구현할 일인지부터 가른다.

  • TOTP 직접 구현이 디폴트다. 위탁할 “OTP 서비스”라는 게 개념적으로 없다.
  • 매니지드 인증(Auth0·Cognito·Firebase·Clerk·Supabase)을 쓰면 MFA 가 기능으로 내장돼 만질 일이 없다. 단 이건 “인증 전체 위탁”이지 “OTP 만 위탁”이 아니다.
  • SMS/Email OTP 는 별개다. 생성은 랜덤 + Redis TTL 로 trivial 하고, 서드파티를 쓰는 이유는 전달 인프라 때문이다.
  • 금융 OTP·본인인증은 규제 영역이라 외부 인증기관이 강제된다. 직접 구현 대상이 아니다.

멘탈 모델을 먼저 잡는다. TOTP 는 서버와 Authenticator 앱이 secret 을 공유한 뒤 각자 독립적으로 같은 코드를 계산하는 구조다. 등록 시점(QR 스캔) 이후로는 둘 사이에 통신이 없다.

[등록 시 단 한 번]  서버 ──(secret 을 QR 로)──> 사용자 폰 앱
[그 이후 통신 0]    서버: secret + 현재시각 → HMAC → 코드 A
                    앱:   secret + 현재시각 → HMAC → 코드 B
                    사용자가 B 입력 → 서버가 A == B 비교
  • ❌ “OTP 검증 서버에 요청” → 그런 서버 없다. 라이브러리 안에 네트워크 호출 0.
  • ❌ “서버가 사용자에게 코드 발송” → 그건 SMS/Email OTP, 별개 물건.

라이브러리는 토큰 생성·검증(순수 알고리즘)만 담당한다. secret 암호화 저장·복구 코드·재사용 방지·throttling 은 전부 직접 구현이다.


R1. 등록 — secret 생성 & QR 용 URI (서버)

import * as OTPAuth from "otpauth"

const secret = new OTPAuth.Secret({ size: 20 })    // 최소 128비트, 160비트(20B) 권장

const totp = new OTPAuth.TOTP({
  issuer: "Acme",                  // 서비스명 (앱에 그룹명으로 표시)
  label: "user@example.com",       // 계정 식별자
  algorithm: "SHA1", digits: 6, period: 30,
  secret,
})

const uri = totp.toString()
// otpauth://totp/Acme:user@example.com?issuer=Acme&secret=…&algorithm=SHA1&digits=6&period=30
const secretBase32 = secret.base32  // 저장할 값 — 아직 pending, R2 통과 전까지 활성화 X

QR 이미지 생성은 라이브러리 범위 밖이라 qrcode 같은 별도 패키지로 uri 를 인코딩한다.

함정algorithm·digits·period 는 특별한 이유 없으면 기본값을 고정한다. 대부분의 Authenticator 앱이 SHA1/6/30 만 안정적으로 지원한다.


R2. 등록 확정 — 초기 검증

QR 스캔 직후 앱에 뜬 코드를 입력하게 해서 secret 이 제대로 공유됐는지 확인한다. 여기 통과해야만 2FA 를 활성화한다.

const delta = totp.validate({ token: userInput, window: 1 })
if (delta !== null) {
  // secret 을 암호화해 저장 + 2FA 활성 플래그 on + 복구 코드 발급
} else {
  // 재입력 요청 (등록 미완료 유지)
}

validate()delta(정수) 또는 null 을 반환한다. null 이면 window 안에서 못 찾은 것 = 무효다.


R3. 로그인 시 검증 (매 로그인)

ID/PW 1차 인증 통과 후, DB 의 secret 으로 TOTP 를 재구성한다.

const totp = new OTPAuth.TOTP({
  issuer: "Acme", label: "user@example.com",
  algorithm: "SHA1", digits: 6, period: 30,
  secret: OTPAuth.Secret.fromBase32(savedSecretBase32),   // 복호화해 로드
})
const delta = totp.validate({ token: userInput, window: 1 })

보조 메서드 — totp.generate() 는 지금 이 순간의 코드(앱이 하는 계산과 동일, 디버깅용), totp.counter() 는 경과 구간 수(재사용 방지 키), totp.remaining() 은 코드가 바뀌기까지 남은 ms(UI 카운트다운).

URI ↔ 객체totp.toString() / OTPAuth.URI.parse(uri).


R4. window 와 clock drift

서버 시계와 폰 시계는 몇 초씩 어긋난다. window: N 은 현재 구간 기준 앞뒤 N개 구간(±N×period 초)까지 허용한다는 뜻이다.

  • window: 1, period: 30±30초 허용 (실무 표준)
  • 키우면 유효 코드 개수가 늘어 브루트포스에 취약하다 → 최소화 원칙

라이브러리가 안 해주는 것 (전부 직접)

항목 처리
secret 저장 평문 금지. 앱 레벨 암호화(KMS/envelope) 후 DB
secret 노출 서버에만 보관. 클라이언트엔 등록 시 QR 로 1회만
window 크기 기본 1 유지
재사용 방지 성공한 counter() 값을 저장 → 같은 구간 코드 재검증 차단
throttling OTP 입력 시도 횟수 제한 (필수)
복구 수단 복구 코드 별도 발급·해시 저장

보안 고려사항은 RFC 4226 §7, RFC 6238 §5.


#592

영상 → 번역 자막 파이프라인 (온디바이스)

언제 — 영상에 번역 자막을 붙일 때. 배치 처리 전제, 실시간 아님. STT 는 만들지 않고 이미 있는 걸 쓴다 — 새로 만드는 건 R4 이후 전부다.

video ──(R1)──> 단어 타임스탬프 JSON ──(R4)──> 문장 ──(R5)──> 번역
                                            (R6) 문맥 보정 ──┤
                                              (R7) 자막 큐 ──> (R8) VTT ──> (R9) mux

R0. 환경 확인

sw_vers -productVersion          # macOS 26 이상
brew install finnvoor/tap/yap
yap --help                       # --json, --word-timestamps, --locale 이 보이면 정상

함정 — 언어 asset 은 최초 사용 시 다운로드된다. 오프라인에서 첫 실행하면 실패한다. 대상 언어로 짧은 파일 하나를 미리 돌려 받아둔다.


R1. 영상에서 바로 전사

yap transcribe input.mp4 --json --word-timestamps -o out.json

ffmpeg 없이 mp4/mov 에서 단어별 시작/끝 시간이 나온다.

함정--srt 로 받으면 안 된다. 큐 경계가 이미 고정되어 R7 에서 재분할할 수 없다. 이 파이프라인 전체가 단어 타임스탬프 위에 서 있다.


R2. 열리지 않는 파일 fallback

R1 이 실패했을 때만 실행한다. 무조건 앞에 두지 않는다.

ffmpeg -i input.mkv -vn -ac 1 -c:a pcm_s16le -ar 16000 audio.wav
yap transcribe audio.wav --json --word-timestamps -o out.json

함정 — sample rate 를 임의로 고정하지 말 것. 리샘플링이 한 번 더 들어가면 손해다. 위 16k 는 관례값일 뿐이므로 품질이 의심되면 -ar 없이 원본 rate 로 뽑아 비교한다.


R3. 언어 판별 후 재전사

소스 언어를 모를 때. 배치이므로 두 번 돌려도 된다.

ffmpeg -i input.mp4 -t 60 -vn -c:a pcm_s16le probe.wav   # 앞 60초만
yap transcribe probe.wav --txt -o probe.txt

probe.txt 를 판별기(franc 정도로 충분)에 넣어 locale 을 확정한 뒤 R1 을 그 locale 로 재실행한다.

함정 — 1차 전사를 틀린 locale 로 돌리면 출력이 음차 표기로 나온다. 그래도 언어 판별에는 충분한 신호가 남으니 결과가 이상하다고 겁먹지 말 것.


R4. 단어 → 문장 재조립

큐 단위로 번역하면 어순이 깨진다. 번역 단위를 문장으로 만든다. 문장 분리는 Node 내장 Intl.Segmenter 로 하면 Swift 브릿지가 필요 없다.

type Word = { text: string; start: number; end: number }      // 초 단위
type Sentence = { text: string; start: number; end: number }

function toSentences(words: Word[], locale: string): Sentence[] {
  const full = words.map(w => w.text).join(' ')
  const offsets: number[] = []                                 // 문자 오프셋 → 단어 인덱스
  let pos = 0
  for (const w of words) { offsets.push(pos); pos += w.text.length + 1 }

  const seg = new Intl.Segmenter(locale, { granularity: 'sentence' })
  const out: Sentence[] = []
  for (const { segment, index } of seg.segment(full)) {
    const text = segment.trim()
    if (!text) continue
    const from = offsets.findIndex(o => o >= index)
    const toIdx = offsets.findIndex(o => o >= index + segment.length)
    const last = (toIdx === -1 ? words.length : toIdx) - 1
    out.push({ text, start: words[from].start, end: words[last].end })
  }
  return out
}

yap JSON 스키마는 버전에 따라 다르므로 normalize(raw): Word[] 어댑터 하나만 두고 거기만 고치면 나머지가 그대로 돈다.

확인 — 문장 수가 육안 문장 수와 대략 맞고 start 가 단조 증가한다.

함정Intl.Segmenter 는 축약형(Dr., etc.)에서 오분할한다. 번역 품질만 조금 떨어질 뿐 타임코드는 안 깨지므로 초기에는 무시하고 넘어간다.


R5. 문장 배치 번역

stdin JSONL → stdout JSONL 인터페이스가 다루기 편하다.

jq -c '.[] | {id, text}' sentences.json | translate-cli --from en --to ko > translated.jsonl

확인입력 문장 수와 출력 줄 수가 정확히 같아야 한다. 다르면 매핑이 어긋나 이후 전부 밀린다. 여기에 assert 를 넣는다.

함정 — 문장을 개별 호출로 돌리면 세션 생성 비용이 지배적이 된다. 반드시 한 세션에서 배치로 넘긴다.


R6. 문맥 보정 (선택)

문장 단위 번역이 놓치는 것을 복구한다 — 용어 통일, 대명사·주어 복원, 고유명사 표기 고정. 배치이므로 전체 전사문을 컨텍스트로 줄 수 있다. 실시간에서는 불가능했던 단계다.

확인 — 보정 전후 diff 를 눈으로 본다. 개선이 아니라 개악인 경우가 흔하다.

함정 — 모델이 문장을 병합하거나 분할하면 R4 의 타임코드 매핑이 무효가 된다. 문장 id 를 반드시 유지시키고, id 집합이 바뀌면 보정 결과를 버린다. 이 단계는 실패해도 파이프라인이 멈추지 않아야 한다.


R7. 번역문 → 자막 큐 재분할

여기가 유일하게 진짜 어려운 부분이다. 문장을 읽을 수 있는 크기로 자르고 시간을 배분한다.

const CFG = { maxCharsPerLine: 18, maxLines: 2, minDur: 1.0, maxDur: 7.0, maxCps: 12 }

function toCues(s: Sentence): Cue[] {
  const budget = CFG.maxCharsPerLine * CFG.maxLines
  const chunks: string[] = []                       // 1) 어절 경계로 청크 분할
  let cur = ''
  for (const w of s.text.split(/\s+/)) {
    if (cur && (cur + ' ' + w).length > budget) { chunks.push(cur); cur = w }
    else cur = cur ? cur + ' ' + w : w
  }
  if (cur) chunks.push(cur)

  const total = chunks.reduce((a, c) => a + c.length, 0)   // 2) 글자수 비율로 시간 배분
  const span = s.end - s.start
  let t = s.start
  return chunks.map(c => {
    const dur = Math.max(CFG.minDur, Math.min(CFG.maxDur, span * (c.length / total)))
    const cue = { start: t, end: t + dur, lines: wrap(c) }
    t += dur
    return cue
  })
}

확인 — 큐가 겹치지 않고 각 큐의 CPS 가 maxCps 이하인가.

함정minDur 클램프 때문에 짧은 청크가 늘어나면 시간이 문장 구간을 넘어가고, 그 오차가 누적되어 뒤로 갈수록 자막이 밀린다. 문장 단위로 ts.start 로 리셋하므로 문장 경계에서는 복구되지만, 문장 내부에서 밀림이 보이면 클램프 대신 청크 수를 줄이는 쪽으로 조정한다. maxCharsPerLine·maxCps 는 언어와 시청자에 따라 다르므로 실제 영상으로 보면서 맞춘다.


R8. WebVTT 직렬화

SRT 대신 VTT — 스타일과 다중 트랙에 유리하다.

const ts = (sec: number) => {
  const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60
  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${s.toFixed(3).padStart(6,'0')}`
}
const toVtt = (cues: Cue[]) => 'WEBVTT\n\n' + cues.map((c, i) =>
  `${i + 1}\n${ts(c.start)} --> ${ts(c.end)}\n${c.lines.join('\n')}\n`).join('\n')

원문과 번역을 각각 별도 파일로 뽑는다. 한 파일에 합치지 않는다.


R9. mux

ffmpeg -i input.mp4 -i ko.vtt -i en.vtt -map 0 -map 1 -map 2 -c copy -c:s webvtt \
  -metadata:s:s:0 language=kor -metadata:s:s:1 language=eng output.mkv

함정 — mp4 는 mov_text 만 받으므로 WebVTT 를 넣으려면 mkv 로 나가야 한다. mp4 를 유지해야 하면 자막을 사이드카 파일로 둔다.


검증 체크리스트

위에서부터 막히면 아래는 볼 필요 없다.

  1. 단어 타임스탬프가 실제 발화와 맞는가 — 영상 중간·끝에서 3곳 샘플링. 여기가 틀리면 전체 설계가 무너진다
  2. 문장 수 = 번역 줄 수인가
  3. 문장 id 가 R6 전후로 보존됐는가
  4. 큐 시간이 겹치거나 역행하지 않는가
  5. 영상 끝부분 자막이 밀리지 않았는가 (누적 오차)
  6. CPS 위반 큐가 몇 %인가

실패 지점

증상 원인 조치
전사가 비어 있음 언어 asset 미다운로드 / locale 불일치 R0, R3
음차 표기로 나옴 locale 틀림 R3 후 재전사
파일이 안 열림 미지원 컨테이너 R2
번역 어순이 깨짐 큐 단위로 번역함 R4 후 번역
뒤로 갈수록 자막이 밀림 R7 minDur 클램프 누적 청크 수 축소
번역문 개수가 안 맞음 R6 에서 문장 병합 보정 결과 폐기, id 검사 강화

아직 안 정한 것maxCharsPerLine·maxCps 실측값, R6 를 넣을 가치가 있는지(보정 전후 diff 로 판단), 화자 분리(현재 파이프라인에 없음).

#591

SPA 메모리 누수 soak test

언제 — drawer·모달·라우트 왕복처럼 시작 화면과 끝 화면이 같은 플로우가 메모리를 흘리는지 확인할 때. Chromium 전용(Playwright 가 CDP 세션을 열 수 있는 게 그것뿐).


R1. 일단 돌려보기

// tests/soak/helpers.js
export async function getPageMetrics(client) {
  await client.send('HeapProfiler.collectGarbage')
  await client.send('HeapProfiler.collectGarbage')     // ← 2회. R4 참조
  const { metrics } = await client.send('Performance.getMetrics')
  const { JSHeapUsedSize, Nodes, JSEventListeners } =
    Object.fromEntries(metrics.map((m) => [m.name, m.value]))
  return { heap: JSHeapUsedSize, nodes: Nodes, listeners: JSEventListeners }
}

export async function soak(page, runFlow, loops = 200, warmup = 5) {
  const client = await page.context().newCDPSession(page)
  await client.send('Performance.enable')
  for (let i = 0; i < warmup; i++) await runFlow()      // ← 워밍업. R5 참조
  const baseline = await getPageMetrics(client)
  for (let i = warmup; i < loops; i++) await runFlow()
  return { baseline, after: await getPageMetrics(client) }
}
test('the dashboard drawer does not leak', async ({ page }) => {
  await page.goto('/dashboard')
  const { baseline, after } = await soak(page, () => openAndCloseDrawer(page))
  expect(after.listeners).toBeLessThanOrEqual(baseline.listeners)
  expect(after.nodes).toBeLessThan(baseline.nodes + 100)
})

200루프는 보통 몇 분이면 끝난다. Playwright 는 앱이 따라올 수 있는 최대 속도로 클릭한다.


R2. 어떤 플로우를 고를 것인가

규칙: 시작 화면과 끝 화면이 같아야 한다.

쓸 수 있음 쓸 수 없음
drawer 열기 → 닫기 무한 스크롤 피드
필터 걸기 → 해제 메시지가 쌓이는 채팅
모달 open → close 장바구니에 계속 담기
라우트 A → B → A 로그가 누적되는 콘솔 뷰

오른쪽은 설계상 메모리가 늘어나는 게 정상이라 이 판정식으로는 100% 실패한다. 억지로 넣지 말 것 — 하려면 절대량이 아니라 증가 기울기를 보는 별도 판정식이 필요하다.


R3. 무엇을 assert 할 것인가

지표 성격 판정
JSEventListeners addEventListener/remove 에만 반응. 거의 노이즈 없음 after ≤ baseline
Nodes 리스너 없이 참조만 남은 detached DOM 을 잡음 after < baseline + 100
JSHeapUsedSize GC 타이밍·lazy chunk·캐시가 다 섞임 assert 하지 말 것

리스너 누수가 의심되면 리스너 카운트가 가장 확실한 신호고, 노드 카운트는 그물이다.


R4. 정상인 앱이 6번에 1번씩 실패한다

원인 — GC 를 1회만 돌리면 프레임워크가 참조를 다 놓기 전에 측정된다. 프레임워크 없는 순수 JS 페이지는 1회로도 깨끗했다.

처방collectGarbage 를 연속 2회(R1 에 반영됨).

확인 — 누수 없는 플로우로 10회 돌려 전부 통과하는지 본다.


R5. 힙이 계단처럼 뛰어서 실패한다

원인 — 첫 루프에서 lazy chunk 다운로드 + 최초 데이터 fetch 가 일어나고 둘 다 힙에 남는다. 1회성인데 baseline 을 그 앞에서 잡으면 누수처럼 보인다.

처방 — 워밍업 5루프 후 baseline(R1 에 반영됨).

노드·리스너 카운트에는 원래 워밍업이 필요 없다 — lazy 코드와 캐시는 JS 힙에 살지 DOM 에 살지 않아서 카운트는 첫 루프부터 같은 폭으로 오른다. 5루프는 몇 초라 그냥 둔다.


R6. 작은 컴포넌트에서만 실패한다

원인 — 허용치를 퍼센트로 잡았다. 33노드짜리 drawer 에서 노드 1개가 새면 3%, 2,000노드 화면에서 같은 1개는 0.05%다. 같은 버그인데 감도가 60배 차이 난다.

처방 — 고정 상수를 쓴다. 직접 정하려면:

  1. 누수 없는 플로우로 소크를 10회 반복
  2. after.nodes - baseline.nodes 의 평균 μ, 표준편차 σ
  3. ε = μ + 3σ
  4. 검산: ε / (loops - warmup) 이 잡고 싶은 누수 크기보다 작은가
    • ε=100, loops=200 → 루프당 0.5노드 미만은 못 잡는다
    • 더 미세한 걸 잡으려면 ε 를 낮추는 게 아니라 loops 를 올린다

R7. 타이머 누수가 안 잡힌다

원인 — 타이머는 클릭이 아니라 시계에 매달려 있다. 200루프가 실제 2분이면 30초 폴러는 4번밖에 안 돈다. 실사용 1시간이면 120번이다.

처방 — 브라우저 시계를 가짜로 바꾸고 루프마다 직접 진행시킨다.

const start = new Date('2026-07-28T09:00:00')
await page.clock.install({ time: start })                       // ① goto 이전
await page.goto('/dashboard')
await page.clock.pauseAt(new Date(start.getTime() + 10_000))    // ② 부팅 후 정지

const { baseline, after } = await soak(page, async () => {
  await openAndCloseDrawer(page)
  await page.clock.runFor(18_000)                               // ③ 루프당 18초
})
  • install()Date·performance·타이머·requestAnimationFrame 을 전부 교체한다. 네비게이션 전에 설치해야 앱 부팅 코드가 가짜 시계를 본다
  • install() 만으로는 시간이 계속 흐른다. pauseAt() 으로 세워야 runFor() 만이 시간을 움직인다
  • ③ 커버 시간 = 루프당 진행량 × 루프 수. 1시간을 200루프로 덮으려면 18초

R8. 폴링 횟수가 계산과 안 맞는다

증상 — 30초 폴러에 1시간치를 태웠는데 요청이 120번이 아니라 100번쯤 나간다.

원인 — 시계가 두 개다. 응답 도착 후 스스로 재예약하는 폴러가 범인이다.

async function poll() {
  const res = await fetch('/api/feed')   // 이건 실제 시간에 걸린다
  render(await res.json())
  setTimeout(poll, 30_000)               // 응답이 온 뒤에 예약된다
}

runFor() 가 타임아웃을 발화시키면 poll()await 까지 가고 리턴한다. 요청은 아직 떠 있고 응답은 다음 루프 도중 실제 시간에 도착하며, 그 시점의 가짜 시계 위치에서 다음 타임아웃이 잡힌다. 결국 폴링 주기를 API 응답 속도가 결정한다.

처방 — 네트워크도 mock 하고, 응답이 도착할 때까지 시계를 못 움직이게 막는다.

await page.route('**/api/feed', route => route.fulfill({ json: feed }))
// …
const landed = page.waitForResponse('**/api/feed')
await page.clock.runFor(30_000)
await landed                              // ← 배리어

진행량을 폴러 간격과 같게 두면 루프당 정확히 1회 발화라 세기 쉽다. WebSocket 은 page.routeWebSocket() 으로 같은 일을 하고, 역시 navigate 이전에 설치한다.


R9. 테스트는 통과하는데 프로덕션은 샌다

원인 — mock 응답이 실제보다 훨씬 작다. 응답을 캐시나 배열에 쌓는 종류의 누수는 크기에 비례하므로, 실제가 50kB 인데 mock 이 200B 면 재현되는 누수가 250분의 1이라 허용치 아래로 사라진다.

처방 — 실제 응답을 그대로 캡처해서 fixture 로 쓴다(DevTools Network → Copy response). 스키마도 크기도 동일하게.


R10. 샌다는 건 알았는데 어디서 새는지 모른다

카운트는 존재만 알려준다. 위치는 수동으로 찾는다 — DevTools Memory 패널에서 heap snapshot → 클래스 필터에 Detached → 남은 항목이 화면에서 제거됐는데 아직 참조가 붙들려 있는 DOM → 클릭해서 retainer 트리에서 어떤 리스너/변수가 잡고 있는지 확인.


R11. CI 에 넣기

nightly job 에 넣고 PR 게이트에는 넣지 않는다. 실행 시간(몇 분)이 문제가 아니라 readings 변동으로 인한 flaky 가 PR 흐름을 막는 게 문제다. 실패 시 baseline/after 값을 그대로 로그에 남긴다 — 어느 지표가 얼마나 넘었는지가 다음 액션을 정한다.

누수 커밋 당일 밤에 실패하므로 탐색 범위가 하루치 커밋으로 줄어든다. 이게 실질적 가치다.


함정

  • listeners ≤ baseline 은 앱에 따라 flaky 할 수 있다. IntersectionObserver·passive scroll listener 처럼 지연 등록되는 게 있으면 워밍업 5루프로 안 잡힌다. 도입 전에 워밍업 구간에서 리스너 카운트가 평평해지는지 한 번 찍어본다.
  • GC 2회는 관측치이지 보장이 아니다. 자기 앱에서 1/2/3회의 실패율을 재보면 좋다.
  • 스트리밍 응답은 못 다룬다. route.fulfill() 은 string/buffer 만 받아 chunk 도착 속도를 재현할 수 없다.
  • fetch 자체는 여전히 실제 시간에 걸린다. 가짜 시계는 타이머만 속인다.
#590

TypeScript 타입변수 추론 진단

언제 — 함수 호출에서 T 가 엉뚱하게 정해졌을 때. 증상으로 찾아 들어간다.

증상 원인
이상하게 unknown 이 나온다 그 변수가 바운드·조건 부분·인자에 안 드러난 위치에만 있다 §수집 제외, §우회
형제 타입인데 첫 인자로 고정됐다 접기의 “첫 멤버 유지” §해소
추론은 됐는데 그 값으로 체크가 깨진다 조건부 가지를 평가하지 않는다 §1.4
union 인자에서만 실패한다 분배 후보가 재-union 되지 않는다 §1.6, §해소

모델 — 추론은 딱 2단계다

source 타입(인자 + 기대 반환)  ┐
                              ├─▶ ① 수집: 나란히 walk, bare T 만나면 후보 기록
target 타입(매개변수 + 반환)   ┘        ├─▶ 공변(출력) 후보 리스트
                                        └─▶ 반변(입력) 후보 리스트
                              ② 해소: 각 리스트를 하나로 접기 → T 주입
                              ③ *그제서야* 일반 타입 체크

수집과 타입 체크는 별개 단계다. 이게 조건부 타입에서 폭발한다.

하나만 외운다면 — 후보는 오직 source 타입과 그 구조적 부분에서만 줍는다. extends 바운드·기본값·조건부의 조건 부분·상위 타입은 절대 후보가 안 된다. 기괴한 사례 대부분이 이 한 문장의 따름정리다.


수집 규칙

1.1 기본 — target 에서 벌거벗은 타입변수를 만나면 그 자리 source 를 후보로 기록, 아니면 구조 안으로 walk.

function f<A, B, C>(x: A, y: (a: B) => C) {}
f(true, (it: number) => "hello")   // A = boolean, B = number, C = string

B 는 함수 매개변수 위치라 반변, A·C공변. 후보는 두 리스트에 나뉘어 쌓인다.

1.2 타입 별칭 안으로도 walk 한다. type ArrayContainer<T> = T[] | { array: T[] }number[] 를 매칭하면 배열 쪽이 맞아 E = number.

1.3 조건부는 RHS(결과 가지)에서 수집한다. 조건 부분(A extends string)은 후보를 만들지 않는다.

type Pair<A, B> = A extends string ? Map<A, B> : [A, B]
function f<A, B>(x: Pair<A, B>) {}
f([1, 2])              // A = number, B = number  (두 번째 가지)
f(new Map([["a",1]]))  // A = string, B = number  (첫 번째 가지)

1.4 ★조건부 타입은 가지를 “평가”하지 않는다.

f(["a", "b"])   // TS2345: string[] is not assignable to Map<string, string>

후보는 두 번째 가지에서 수집돼 A = string, B = string 이 되는데, 체크는 A = string 이므로 Pair<string,string> = Map<string,string>첫 번째 가지로 걸어간다. 수집이 걸은 가지 ≠ 체크가 걷는 가지.

1.5 infer T 는 새 타입변수를 추가한다. 조건 부분이라도 infer 만은 예외로 새 변수를 만든다. Awaited<Promise<Promise<number>>> 에서 첫 전개의 U = Promise<number> 와 재귀 전개의 U = number별개 변수다.

1.6 union 은 분배된다. source 가 union 이고 target 이 bare T 가 아니면 각 가지에 대해 독립 재실행하고, 결과 후보들은 같은 리스트에 쌓이고 다시 union 되지 않는다.

1.7 source 의 intersection 은 원칙적으로 유지되지만, 객체 타입({ x: T })에 구조적으로 매칭해야 할 때만 병합된 객체 형태가 생성된다.

1.8 수집에서 제외되는 것 — 조건부의 조건 부분, extends 바운드, 기본값, source 의 상위 타입. Elephant<Savannah> 에서 후보가 될 수 있는 건 Elephant<Savannah>Savannah 뿐이고 Animal·Location 은 절대 안 된다.

따름정리number 인자로 string 바인딩을 만들 방법은 없다. 필요하면 §우회.


target intersection 의 특이동작

모호하면 아무것도 기록하지 않는다 — 임의 선택조차 안 한다.

function foo<A, B>(it: A & B) {}
foo(42)   // A = unknown, B = unknown  (unknown & unknown = unknown)

타입변수 “peel off” 는 차집합이 아니라 포획 유용성을 최대화하는 휴리스틱이다.

function foo<A>(it: A & { x: number }) {}
foo({ x: 42, y: 42 })                 // A = { x: number, y: number }  ← 전체 객체
function bar<A>(it: A & number) {}
bar(42 as number & { brand: "USD" })  // A = { brand: "USD" }
function qux<A>(it: A & "a") {}
qux("a" as "a" & { brand: 1 })        // A = { brand: 1 }

A & {객체} 면 전체 객체를, A & 리터럴 이면 정밀 리터럴을 남기는 쪽으로 동작한다. intersection 순서는 source·target 모두 무관.


해소 규칙

철칙 — 해소 결과는 후보 리스트에 없는 타입으로 절대 안 정해진다. [Cat, Dog]Dog(첫 멤버). 공통 상위인 AnimalCat|Dog 도 아니다.

리스트별 접기 (공변 기준, 반변은 관계 반전)

  1. 후보가 전부 같은 base 의 리터럴이면 → union
  2. 아니면 다른 모두의 strict 상위인 후보를 찾으면 → 그것
  3. 없으면 느슨한 상위 체크로 left-reduce → 상위가 리스트에 없으면 첫 후보 반환
declare function f<T>(x: T, y: T): T
f(d, a)    // Animal  (Animal 이 Dog 의 strict 상위)
f(d, c)    // Dog     (형제 → 첫 멤버 유지)
f("a", 1)  // "a"     (다른 base → 첫 멤버 유지)
f("a","b") // "a"|"b" (같은 base 리터럴 → union)

익명 리터럴 객체/배열이 여럿이면(공변 한정) 접기 전에 union 한다. named 타입끼리는 안 된다 — 서로 다른 named 타입을 같은 T 에 맞추려던 건 아닐 거라는 의도 추정이다.

공변·반변이 둘 다 있으면 ① 기본은 반변이 이긴다 ② 반변 결과가 바운드를 못 지키면 공변으로 폴백 ③ 공변 결과가 반변 결과의 하위 타입이면 공변이 이긴다(더 정밀하므로).

function f<T>(x: T, sink: (t: T) => void) { sink(x) }
f(new Dog(), (_: Animal) => {})   // T = Dog  (공변이 반변의 하위 → 공변 승)

빈 리스트·바운드 — 둘 다 비면 파라미터의 기본값, 없으면 unknown. 해소값이 바운드를 못 지키면 바운드로 교체.

기본값은 별칭이 아니라 “해소 대상 파라미터”에서 찾는다.

type Result<V, E = never> = { okay: V } | { error: E }
function f<V, E>(x: Result<V, E>) {}
f({ okay: 42 })   // E = unknown — never 가 아니다

Resultnever 기본값은 Result<X> 처럼 인자를 생략할 때만 발동한다. 호출부에서 never 를 원하면 function f<V, E = never> 로 선언한다.

우선순위 비트 — 수집 시 후보마다 태그가 붙고, 더 높은 우선순위 후보가 오면 낮은 것들은 전부 지워진다. ReturnType(호출 문맥의 반환 타입 추론, 함수를 인자로 넘길 때), LiteralKeyof(리터럴을 keyof T 에 매칭), MappedTypeConstraint 셋은 “combination” 우선순위라 상위/하위 대신 union(공변)/intersection(반변) 으로 해소한다.


우회 패턴

제네릭 함수 안에서는 타입변수가 미확정이라 조건부를 평가할 수 없다. Exclude 로 특정 타입을 막고 싶으면 파라미터 위치에서 타입변수를 추론하는 형태만 동작한다.

function foo(x: Exclude<unknown, number>) {}   // 파라미터가 unknown 으로 축약 → foo(42) 허용
function bar<X>(x: Exclude<X, number>) {}      // X = number 추론 → Exclude<number,number> = never
function quz<X, Y extends Exclude<X, number>>(x: Y) {}  // X 는 바운드에만 → 추론 불가 → unknown

bar(42)   // 에러: 42 not assignable to never  ← 이것만 제대로 막는다

넘겨 쓸 때는 타입을 그대로 재현한다 — function quuz<V>(x: Exclude<V, number>) { bar(x) } 는 조건을 평가 못 해도 두 x구조적으로 동일해서 통과한다.

NoInfer<T> — 그 위치에서 T 의 후보를 수집하지 않는다. 다른 위치에서 정해진 T 를 그대로 강제할 때.

가장 짜증나는 상황 — 추론하고 싶은 타입변수가 인자에 드러나지 않을 때. extends 바운드는 후보를 안 만들기 때문이다.

function foo<K, V, T extends Map<K, V>>(map: T, key: K): [T, V] { return [map, map.get(key)!] }
foo(new Map([[42, "42"]]), 42)   // [Map<number,string>, unknown]  ← V = unknown

해법 둘, 트레이드오프가 있고 둘 다는 안 된다.

// 1) extractor 타입 — 캐스트 필요, union 지원
type GetV<T extends Map<unknown, unknown>> = T extends Map<unknown, infer V> ? V : never
function foo<K, V, T extends Map<K, V>>(map: T, key: K): [T, GetV<T>] {
  return [map, map.get(key) as GetV<T>]
}

// 2) map: T & Map<K, V> — 캐스트 불필요, union 인자에서 깨짐
function foo<K, V, T extends Map<K, V>>(map: T & Map<K, V>, key: K): [T, V] { … }

함정A extends string ? { [key in A]: B } : … 형태의 매핑드 타입에서는 키/값 추론이 안 된다. [A, B] 같은 구조로 쓴다. 그리고 일부 IDE 는 NoInfer 를 못 뚫고 헛경고를 낸다.

#589

프로그래밍 원칙을 조작 가능하게 — 증상·절차·경계로 다시 쓰기

언제 — 원칙집이 “무엇을 믿어라”는 주는데 “언제 어떻게 하라”를 안 줄 때. 원칙마다 증상 → 절차 → 경계(언제 적용하지 말 것) 를 붙인다. 경계가 핵심이다 — 슬로건은 자기가 틀리는 지점을 말하지 않는다.

가치선언(정직하라, 좋은 일을 하라)은 이견 낼 여지가 없어 레시피가 되지 않으므로 뺀다. 남는 건 실제로 의사결정에 개입하는 일곱이다.

증상 레시피
상태를 맞추려고만 존재하는 useEffect 가 있다 1. 파생
중복 2곳을 보고 즉시 헬퍼로 뽑고 싶다 2. AHA
“이렇게 하면 그 문제 안 생겨요” 3. 이전 vs 제거
undefined 가 함수 셋을 지나 렌더에서 크래시 4. 경계
선택 근거가 “우리가 이미 잘 아니까” 뿐 5. 익숙함
도구 선택에서 분석 마비 6. 되돌릴 수 있는 문
한 파일을 고치면 무관해 보이는 곳이 깨진다 7. 모듈 경계

1. 상태를 동기화하지 말고 파생하라

가장 견고한 레시피. 단 단일 런타임 안에서만 무조건 참이다.

증상 — 한 값을 바꿀 때 다른 상태도 “같이” 갱신해야 한다. 오직 다른 상태를 맞추기 위해서만 존재하는 useEffect 가 있다. 미러 변수가 보인다(isEven, winner, filteredList, sortedRows).

절차

  1. 각 상태에 묻는다 — “이 값은 다른 상태만으로 계산 가능한가?” 가능하면 상태가 아니라 파생값이다.
  2. 단일 소스만 남기고 나머지는 렌더 중 순수 함수로 계산한다.
  3. 동기화하던 useEffect 를 삭제한다.
  4. 파생 계산이 프로파일에서 비싸면 useMemo 로 감싼다 — 삭제가 아니라 캐시. 둘을 혼동하지 말 것.

경계 — 소스가 분산되면(멀티 클라이언트, 서버·클라 이중 소스) 파생으로 못 푼다. 그건 진짜 sync 문제이고 CRDT·이벤트 소싱 영역이다. 파생 비용이 진짜 크고 입력이 자주 안 변하면 물질화가 정당하지만, 그 순간 다시 동기화 계약을 지는 것임을 자각한다.


2. 추상은 비명이 들릴 때까지 미뤄라 (AHA)

AHA 는 DRY 와 WET 의 절충이 아니다. 원칙은 대칭적(과잉·과소 추상 둘 다 피함)이고 타이브레이커만 비대칭이다 — 확신이 없으면 중복 쪽. 근거는 가역성: 잘못된 추상은 중복보다 되돌리기가 비싸다.

증상 — 중복 2곳을 보고 즉시 뽑고 싶다. 이미 뽑은 추상이 options 플래그와 조건 분기로 부풀고 있다. 추상 하나를 고치는데 무관한 호출부가 깨진다.

절차

  1. 중복을 발견하면 놔둔다. 세 번째 사용처가 나타날 때까지.
  2. 세 번째에서 묻는다 — 세 곳이 같은 이유로 함께 변하는가, 지금 우연히 비슷한가? 같은 이유로 변할 때만 추상한다.
  3. 가역성 테스트: “틀린 추상이면 되돌리는 비용 vs 지금 중복 유지 비용?” 되돌리기가 더 비싸면 아직 이르다.
  4. 이미 부푼 추상은 역방향으로 — 인라인으로 되돌려 중복을 복원한 뒤 진짜 공통점만 다시 뽑는다.

경계 — “세 번 규칙”은 휴리스틱이지 법칙이 아니다. 계약(공개 API·타입 경계·프로토콜)은 중복 비용이 커서 더 일찍 추상해야 한다. “코드가 비명을 지른다”는 판정이 안 서면 차라리 셀 수 있는 규칙을 써라 — 나쁜 규칙보다 낫고 규칙 없음보다도 낫다.


3. 문제를 이전시켰는지 제거했는지 검증하라

대부분의 “제거”는 큰 문제를 작은 문제와 맞바꾼 것이지 소멸이 아니다. 그래서 핵심은 “제거하라”가 아니라 제거인지 이전인지 판별하는 것이다.

증상 — “이렇게 하면 그 문제 안 생겨요”라는 리팩터/설계 주장.

절차

  1. 없앤 문제가 어디로 갔는지 추적한다. 사라졌나, 다른 레이어·사람·런타임으로 옮겼나?
  2. 이전이면(런타임 복잡도를 빌드타임/서버로 밀기) 그건 트레이드오프다. 옮긴 곳에서 실제로 더 싸게 다뤄지는지 명시한다.
  3. 진짜 제거의 표식 — 상태 공간이 좁아진다. 잘못된 상태를 표현할 수 없게 된다. 문제의 한 클래스가 통째로 불가능해진다.
  4. 제거도 이전도 아니면(문제는 그대로, 코드만 이동) 아무것도 안 한 것이다. 되돌려라.

경계 — “완전 제거”라는 수사에 속지 말 것. 트레이드오프임을 인정하는 게 정직이다. 타입으로 상태 공간을 좁히는 것(제거)과 복잡도를 다른 레이어로 미는 것(이전)을 가르는 선을 매번 긋는다.


4. 경계에서 엄격하게, 안에서 빨리 실패하라

서로 다른 세 축을 먼저 분리한다. “관대한 파싱”을 fail-fast 의 짝으로 묶는 건 방향 오류다.

  • fail-fast — 에러를 언제 드러내나: 즉시, 전파 전에.
  • parse, don’t validate — 경계에서 비구조→구조로 한 번 변환하고 증명을 타입에 실어 나른다: 어떻게. 엄격하며 fail-fast 와 같은 편.
  • Postel 의 관대함무엇을 받아들이나: 앞 둘과 반대 방향. 보완이 아니다.

증상 — 잘못된 입력이 조용히 흘러가 몇 레이어 뒤 애매한 에러로 터진다. 에러 스택이 원인이 아니라 증상 지점을 가리킨다.

절차

  1. 바깥 경계(네트워크 응답·사용자 입력·파일)에서 엄격하게 받는다. 변형 입력은 일찍 거부하고, 통과한 값은 타입으로 그 사실을 들고 다니게 한다.
  2. 위반은 그 자리에서 던지거나 명시적 에러 값으로 반환한다. 하류로 넘기지 않는다.
  3. 신뢰 경계 안쪽은 fail-fast. 타입으로 표현 가능한 실패는 런타임 검사 대신 타입으로 막는다(→ 레시피 3의 “표현 불가능하게”). 엄격한 거부는 fail-fast 일 뿐 아니라 공격의 한 클래스를 제거한다.
  4. 개발 빌드에서는 시끄럽게, 프로덕션에서는 우아하게. 단 “우아하게”는 거부를 사람에게 전달하는 방식이지 무엇을 시스템에 들이나가 아니다.

경계 — 관대함은 기본값이 아니라 상호운용을 위한 명시적 트레이드오프로만 고르고, 그때 지불하는 비용을 안다(용인된 에러는 고착되어 구현을 복잡하게 만든다 — RFC 9413 Protocol Decay Hypothesis). 관대함이 옳은 실제 사례는 있다 — 브라우저의 forgiving HTML 파싱이 웹 상호운용을 살렸다. 요점은 금지가 아니라 “엄격을 기본으로, 관대함은 눈 뜨고 내리는 결정으로” 다.


5. 익숙함을 단순함으로 착각하지 마라

Rich Hickey 가 못박은 두 지점이 절차의 전부다.

  • simple = simplex, “one fold” — 한 역할·한 관심사가 얽히지 않음. 객관적. 이건 interleaving 이지 cardinality 가 아니다 — 부품 수가 적은 게 simple 이 아니라, 다른 이유로 존재하는 것들이 안 엮인 게 simple.
  • easy = adjacens, “가까이 있음” — 손에 익음. 주관적(사람마다 다르다).
  • complect ≠ compose — 독립을 유지한 채 나란히 두는 건 좋고, 엮어서 서로 가정하게 만드는 것이 복잡도의 근원이다.

증상 — 도구/패턴 선택 근거가 “이미 잘 아니까” / “러닝커브 없어서” 뿐이다.

절차

  1. 후보를 두 축으로 분리 채점한다. simple = 하나를 다른 이유로 바꿀 때 무관한 관심사까지 건드려야 하나(객관). easy = 팀 숙련도·즉시 착수 비용(주관).
  2. 두 근거를 각각 따로 적는다. 두 근거가 한 칸에 섞이면 거기가 착각이 시작된 지점이다.
  3. easy 만 높고 simple 이 낮으면 단기 속도를 얻고 얽힘 비용을 뒤로 미루는 것이다. 그 미룸이 의식적 트레이드오프인지 확인한다.
  4. 결정 문서에 두 점수를 남겨 나중에 검증 가능하게 한다.

경계 — easy 자체는 나쁜 게 아니라 바람직한 목표다. 오류는 (a) easy 를 simple 로 착각하거나 (b) 오래 살 구조에서 simple 을 easy 와 맞바꾸는 것이다.

이 구분이 나머지의 뿌리다 — complect 는 다른 변경 이유를 엮는 것이라 레시피 7과 같은 축이고, 동기화된 미러(레시피 1)는 값과 갱신 시점을 complect 한 것이라 파생이 그걸 푼다.


6. 도구는 저부담 기본값으로, 되돌릴 수 있게

증상 — 도구 선택에서 분석 마비. 또는 반대로 니치한 도구를 “정답”이라 확신.

절차

  1. 이 선택이 one-way(고부담)인지 two-way(저부담)인지부터 판정한다: 나중에 되돌리는 비용이 큰가?
  2. two-way 면(대부분) 생태계 있는 표준 도구로 빠르게 결정하고 넘어간다. 이상적으로 원하는 정보의 ~70%면 결정한다 — 지연 비용이 교정 가능한 실수의 위험보다 크다.
  3. one-way 면(데이터 모델·인증·공개 계약·핵심 의존성) 여기에만 시간을 쓴다. 소프트웨어에서 진짜 one-way 는 드물고 대개 “되돌림 rework 가 클 뿐”이므로, 결합도를 낮춰 rework 비용을 깎아 one-way 를 two-way 로 끌어내린다(→ 레시피 7).
  4. “정답은 없다”를 아무 선택이나 정당화하는 방패로 쓰지 마라. 기준은 트레이드오프를 명시했는가다.

경계 — 오분류는 양방향으로 위험하다. two-way 를 one-way 로 다루면 분석 마비, 반대면 무모함. 숨은 one-way 를 조심한다 — 데이터 손실을 동반한 마이그레이션, 소비자가 이미 의존하는 공개 API, 코드베이스 전체로 전이된 프레임워크 선택. “나중에 바꾸면 돼”처럼 보이지만 커밋 후에야 되돌림 비용이 드러난다.


7. 모듈 경계: 변경 이유로 쪼개되 깊게 유지하라

핵심 기준은 하나다 — 변경 이유. 같은 이유·같은 시점에 변하는 것은 모으고, 다른 이유·다른 시점에 변하는 것은 나눈다. 분리(SRP)와 응집(colocation)은 이 한 기준의 양면이지 두 원칙이 아니다. 그래서 타이브레이커가 필요할 일이 없다.

증상 — 한 파일을 고치면 무관해 보이는 곳이 깨진다. import 그래프가 양방향/순환. 컴포넌트 하나가 변경 이유가 다른 여러 일을 한다. 이름·시그니처가 실제 하는 일과 다르다.

절차

  1. 변경 이유로 자른다. “왜 바뀌는가”가 둘 이상이면 그 축으로 쪼갠다. 관심사는 도메인 명사가 아니라 변경 축이다.
  2. 단, 깊게 유지한다. 더 쪼갤 때 인터페이스가 늘어나는 속도가 숨겨지는 복잡도를 앞지르면 멈춘다. SRP 를 극단으로 밀면 작은 클래스가 잔뜩 생겨 전체 복잡도가 오르고, 정보 은닉은 모듈을 오히려 약간 키울 때 개선될 때가 많다. 1과 2는 서로를 견제하는 역벡터다.
  3. 결합은 한 방향으로. 순환·양방향 의존은 즉시 신호. 인터페이스로 뒤집어 되돌림 비용을 깎는다.
  4. 조합 우선. 진짜 “is-a” 일 때만 상속.
  5. 최소 놀람. 숨은 부수효과·암묵 규약은 표면으로 끌어올린다.

경계 — 분리 vs 응집에서 갈등이 느껴지면 그건 타이브레이커가 필요한 상황이 아니라 엉뚱한 축으로 쪼갰다는 신호다. 변경 이유가 아니라 도메인 토픽으로 갈랐는지 의심한다. “깊은 모듈”도 무비판 수용 대상은 아니다 — 2번은 분해를 멈추는 체크이지 분해하지 말라는 뜻이 아니다.


절차는 근거를 외부 정전에 붙일수록 스스로 반증되거나 단단해진다 — 그게 슬로건과의 차이다. 4와 7이 그렇게 교정됐다: 4는 “관대한 파싱”이 fail-fast 와 충돌하는 별개 축임을 놓쳐 엄격을 기본으로 되돌렸고, 7은 하나의 기준(변경 이유)을 둘로 쪼개 가짜 타이브레이커를 붙였던 것과 대립하는 두 원칙(SRP ↔ 깊은 모듈)을 동의어로 합쳤던 것을 갈랐다.

#588

GenUI 도입 — 모델이 정하는 게 인자인가 배치인가 코드인가

언제 — 누군가 “우리도 GenUI 해보자”고 말했을 때. 회의에서 그 단어가 나오면 셋 중 뭔지부터 확정한다.

이름 모델이 정하는 것 실행되는 코드의 출처 검증 표면
Static/Controlled 툴 인자 우리 코드 툴 스키마
Declarative 컴포넌트 트리·배치·props 우리 코드 카탈로그(Zod)
Open-ended 코드 전체 모델 출력 없음 (샌드박스만)

직교하는 두 번째 축은 transport — 우리 앱에 그리나 남의 호스트에 그리나. “어디에 그리나”와 “얼마나 자유롭나”는 별개 결정이고 섞을 수 있다.


R1. 지금 GenUI 가 필요한지 판별

  1. 사용자 입력이 열거 가능한 선택지인가 자유 텍스트인가?
    • 열거 가능 → 여기서 중단. 필터 UI 가 답이다.
  2. 자유 텍스트일 때 나올 수 있는 화면 종류가 몇 개인가?
    • 5개 이하로 셀 수 있다 → Static/Controlled. 대부분 여기서 끝난다.
  3. 구성이 달라지는 게 데이터 양 때문인가 관심사 조합 때문인가?
    • 데이터 양(0건/1건/N건) → Static 으로 충분. 컴포넌트 3개로 커버된다
    • 관심사 조합(어떤 요청은 날씨+일정, 어떤 요청은 예산+체크리스트) → Declarative
  4. 우리 부품으로 표현이 아예 불가능한 일회성 위젯인가? → 그 조각만 Open-ended. 페이지 전체는 아님.

함정 — “AI 붙이자”가 실제로는 “검색이 안 좋다”인 경우가 많다. 랭킹 문제를 GenUI 로 덮으면 비용만 늘고 문제는 남는다. 그리고 데모에서 인상적이었던 건 대부분 Declarative 인데 실제 필요한 건 대부분 Static 이다.


R2. 인텐트 계약 (GenUI 도입 여부와 무관하게 먼저)

언제 — 기획 → 디자인 → 구현 사이에서 케이스가 자꾸 누락될 때.

  1. 기능 하나를 인텐트 문장 하나로 쓴다. “사용자가 조건을 말하면 조건에 맞는 장소를 찾아준다.”
  2. 입력 인자 집합을 확정한다. 무엇이 필수고 무엇이 옵션인지.
  3. 결과 상태 집합을 전부 열거한다. 이게 핵심이다. 정상 경로가 아니라 나머지를 적는다.
- 결과 N건 / 결과 1건 (레이아웃이 다르면 별도 상태)
- 조건은 유효한데 결과 0건 / 조건 자체를 해석 못 함
- 부분 실패 (3개 소스 중 1개 죽음)
- 권한 없음 (데이터 없음과 반드시 구분)
- 오래된 캐시를 보여주는 중
  1. 상태마다 반환 필드를 정한다. 최소한 “왜 이 결과가 나왔는지”를 UI 가 설명할 수 있어야 한다 — { query, appliedFilters, items, totalMatched } 에서 appliedFilters 가 없으면 “왜 이게 나왔죠?“에 답할 수 없다.
  2. 기획·디자인·프론트·서버가 같이 승인한다. 이게 계약이다.

확인 — 디자인 시안 개수 == 결과 상태 개수인가? 적으면 누군가 런타임에 즉흥으로 정하게 된다. QA 가 이 목록만 보고 테스트 케이스를 쓸 수 있는가?

왜 먼저 하나 — Static 으로 갈 때 이 정의가 그대로 툴 스키마가 된다. 순서가 뒤집히지 않는다. GenUI 를 끝내 안 하기로 해도 이 작업만으로 커뮤니케이션 비용이 준다.


R3. Static/Controlled 도입 (기본값)

기존 컴포넌트를 쓴다 — 새로 안 만든다.

  1. 인텐트 계약을 툴 스키마로 옮긴다. 모델이 채울 수 있는 건 입력 인자뿐이다.
  2. 툴 본체를 앱 코드로 쓴다. 필터·랭킹·권한 검사 전부 여기.
  3. 반환값에 결과 상태를 판별할 필드를 넣는다.
  4. 결과 상태 → 컴포넌트 매핑을 결정적 코드로 쓴다.
// 이 분기는 코드다. 모델이 정하지 않는다.
if (totalMatched === 0) return <EmptyMatch filters={appliedFilters} />
if (totalMatched === 1) return <PlaceCard place={places[0]} />
return <PlaceResults places={places} />
  1. 모델에게는 반환된 결과에 대한 설명만 쓰게 하고, 결과 밖의 이야기를 못 하게 프롬프트로 못박는다.

확인 — 툴 호출을 목으로 고정하면 UI 가 항상 같은 화면을 내는가?(아니면 결정성이 새고 있다) 모델을 완전히 죽여도 기존 필터 UI 로 기능이 동작하는가?(폴백 경로)

함정 — 툴이 반환하지 않은 항목을 모델이 설명에 끼워 넣는다. 반환 목록을 프롬프트에 명시하고 후처리로도 검사한다. 그리고 필터링을 모델에게 시키고 싶은 유혹 — 시키는 순간 재현성과 감사 로그가 동시에 사라진다.


R4. Declarative 도입 (R1 의 3번일 때만)

여기부터 비용이 계단식으로 뛴다. 백엔드는 그대로고(에이전트는 안 바뀐다 — 이게 이 경로의 실용적 장점), 새로 만들어야 하는 유일한 것은 컴포넌트 카탈로그다.

에이전트  →  전송 스펙  →  UI 서술 스펙  →  React 컴포넌트
 (서버)   (어떻게 흐르나)  (모델이 뭘 생성)   (여전히 우리 코드)
  1. 도메인 툴은 그대로 둔다.
  2. 런타임이 렌더 툴을 주입하고 모델에 카탈로그 스키마를 노출한다. 모델이 뭘 쓸 수 있는지 아는 유일한 경로.
  3. 순서를 강제한다 — 데이터 툴 먼저 → 렌더 서술은 마지막에 정확히 1회.
  4. 미들웨어가 인자를 스키마 검증 후 UI operation 으로 확장한다.
  5. 클라이언트로 스트리밍. 로딩 상태와 완성 화면이 같은 messageId 를 쓰게 한다(append 아니라 replace).

확인 — 카탈로그에 없는 컴포넌트를 요청했을 때 조용히 빠지는가, 에러인가, 폴백인가? 셋 중 하나를 명시적으로 고른다. 렌더 툴이 두 번 호출되면? 스트림이 중간에 끊기면?

함정정책이 타입 시스템이 아니라 프롬프트에 쌓인다. 타입 검사도 테스트도 안 되는 규칙 더미가 자라는 게 이 경로의 진짜 비용이다. 조합 폭발도 사라진 게 아니라 카탈로그 유지보수로 이동했을 뿐이다. 그리고 데모가 죄다 read-only 대시보드인 건 우연이 아니다 — 상호작용·로컬 상태·폼 검증이 이 방식에서 가장 약하다.


R5. 카탈로그 설계 (R4 실제 작업량의 대부분)

카탈로그 = Zod 정의(모델이 요청할 수 있는 것) + React 렌더러(실제 실행되는 것) + 버전 붙은 catalogId.

  1. 허용 목록이지 힌트가 아니다. 목록 밖은 표현 자체가 불가능해야 한다.
  2. props 를 최대한 좁힌다. className 같은 탈출구를 열면 카탈로그가 무의미해진다. 자유 문자열보다 열거형(variant: "compact" | "full"). href·src반드시 허용 도메인 검사 — 외부 이미지 하나가 비콘이 된다.
  3. 레이아웃 컴포넌트를 명시적으로 넣는다. 안 넣으면 모델이 텍스트로 레이아웃을 흉내 내기 시작한다.
  4. 버전을 붙인다. 카탈로그 변경은 API 변경과 같은 무게다.
  5. 접근성은 렌더러가 소유한다. 헤딩 레벨·포커스 순서·라벨을 모델에게 맡기면 요청마다 a11y 트리가 달라진다.

소유권 질문이 “누가 UI 를 주도하는가”의 실제 답이다 — 서버 스키마를 계약으로 두면 백엔드가, 인텐트+결과 상태 집합이면 기획/도메인이, 컴포넌트 카탈로그면 디자인 시스템이 주도권을 갖는다. 카탈로그를 소유한 팀이 사실상 UI 결정권을 갖는다. 암묵적으로 두지 말고 명시한다.


R6. Open-ended 를 안전하게 한 조각만

  1. 샌드박스 iframe. allow-scripts 만, allow-same-origin 은 주지 않는다(둘을 같이 주면 샌드박스가 무의미).
  2. 부모와의 통신은 좁은 postMessage 채널 하나로만. origin 검사 필수.
  3. 네트워크는 CSP 로 차단하거나 허용 목록만.
  4. 페이지 안에서의 크기·스크롤 소유권을 고정한다.

함정 — 디자인 시스템 충돌, 이중 스크롤바, 성능 비용, 그리고 직접 관리해야 하는 보안 경계가 생긴다. 지연·캐시 불가·감사 곤란이 전부 여기 몰려 있다. 프로덕션 기본값으로 쓰지 않는다.


R7. 신뢰 경계 점검 (배포 전)

“JSON 이라 안전하다”는 절반만 맞다. 임의 코드 실행은 막지만 나머지는 안 막힌다.

  1. props 공격면href/src 가 카탈로그에 있고 도메인 검사가 있는가?
  2. 프롬프트 인젝션이 유도한 정당해 보이는 UI ← Declarative 의 실제 최대 위협. 검색 결과나 외부 문서 텍스트가 모델 컨텍스트로 들어오는가? 카탈로그에 폼·버튼·링크가 있으면 모델이 만든 피싱 화면은 스키마 검증을 통과한다. 대응은 외부 텍스트를 데이터로만 격리, 입력형 컴포넌트를 카탈로그에서 분리·화이트리스트, 렌더 결과 후처리 검사.
  3. 권한 — 모델이 본 데이터 == 이 사용자가 볼 수 있는 데이터인가? 툴 레벨에서 걸렀는가?
  4. 감사 — 어떤 요청이 어떤 UI 를 만들었는지 재현 가능한가?(모델 출력 + catalogId + 입력을 함께 로깅)

코드 실행이 아니라 사회공학이 declarative 의 진짜 위협이고, 카탈로그는 이걸 막지 못한다.


R8. 테스트·회귀

UI 가 비결정적이 되는 순간부터 스냅샷/E2E 가 그냥 무너진다.

  1. 층을 나눠서 테스트한다. 툴 함수 → 순수 단위 테스트(모델 없음, 가장 많은 테스트가 여기). 컴포넌트 → 고정 props. 매핑 로직 → 결과 상태별 스냅샷. 모델 → 여기만 비결정적.
  2. 모델 출력을 픽스처로 고정한다. 매 CI 마다 모델을 부르지 않는다.
  3. 스키마 위반을 계약 테스트로. 카탈로그 변경 시 기존 픽스처가 깨지는지 본다.
  4. 모델 품질은 별도 평가 세트로. 대표 쿼리 N개 → 기대 컴포넌트 집합, 통과율을 지표로. E2E 와 섞지 않는다.

함정 — 모델이 낀 E2E 를 CI 에 넣으면 플레이키가 되고 결국 다들 무시한다.


R9. 실패 모드 정의 (안 정하면 장애 때 즉흥으로 정한다)

상황 정해야 할 것
모델 타임아웃 기존 UI 폴백? 에러 화면?
카탈로그에 없는 컴포넌트 요청 무시 / 전체 실패 / 플레이스홀더
스트리밍 중단 부분 화면 유지 vs 롤백
도메인 툴 3개 중 1개 실패 부분 렌더 + 실패 표시 (조용히 빼지 말 것)
비용·쿼터 초과 비-AI 경로로 강등

킬 스위치 — 모델 경로를 끄면 R2 의 결정적 UI 로 돌아가는 플래그. 이게 있으면 훨씬 과감하게 실험할 수 있다.


R10. 회의에서 쓰는 질문 세트

  1. AI 가 정하는 게 정확히 뭐예요? 인자? 배치? 코드?
  2. 나올 수 있는 화면 종류를 셀 수 있어요, 없어요?
  3. 카탈로그는 누가 소유해요?
  4. “이 케이스는 왜 이렇게 나왔죠”라고 물으면 어디를 보고 답해요?
  5. 모델을 끄면 이 기능은 어떻게 돼요?

1번만 확정돼도 나머지 논의가 전부 명확해진다.


기억해둘 반론 — “스펙이 코드를 대체한다”는 절반만 맞다. 스펙과 SDK 도 코드다. 실제로 일어난 일은 추상화 상승이 아니라 조합 결정 시점이 컴파일 타임에서 런타임으로 이동한 것이고, 그 대가로 타입 안정성을 프롬프트 정책과 맞바꾼 것이다. 남은 열린 문제(지연·캐시·감사·일관성·비용, 상호작용 이후의 상태 왕복, 접근성 보장, 비결정적 UI 의 회귀 전략)는 전부 이 이동의 청구서다.

#587
31 중 1페이지