메모 압축 — 존댓말이 신호였다

메모가 늘면서 어떻게 줄일지가 고민이었다. OKF를 대조해보다가 의도치 않게 그 방향이 열렸다 — 압축할 대상을 문체로 찾을 수 있다.

번역문·AI 요약·문서 발췌는 존댓말(“~합니다”)로 들어온다. 내 메모는 평서체(“~다”)로 쓴다. 그래서 인용 표시 없이 존댓말이 지배하는 산문 = 아직 내 언어로 줄이지 않은 남의 문장이다. 542개 중 29개, 그중 23개가 공개 중이었다.

가장 선명한 건 268. “이것이 바로 제가 Tachyon을 만든 이유이며” — 메모 안의 1인칭이 내가 아니었다. 벤더 홍보문을 번역해 넣은 것.

압축해보니:

272 (Graphite 문서 요약) 17줄 2줄
268 (벤더 홍보문) 5단락 2줄
274 (AI가 지어낸 우화) 4단락 1줄

줄이니 형태가 드러났다. 268은 note였는데 군더더기를 걷어내자 남은 게 링크 4개라서 bookmarks가 됐다. 부풀린 산문이 형태를 가리고 있었던 것.

OKF는 규칙집이 아니다

필수 필드가 type 하나뿐이고 그마저 개방 어휘다 — “Type values are not registered centrally.” 규칙을 주지 않으니 참조할 대상이 못 된다. 실제로 가져온 건 필드 이름 하나와 “없는 것도 의미를 갖는다”는 패턴 하나.

규칙은 내 데이터가 정했다. 예전에 손으로 bookmarks 태그를 붙여둔 33개가 정답지 역할을 했고, 처음 만든 분류 규칙은 그중 10개를 놓쳤다 — 링크 1개짜리 메모. 내 기준에선 링크 하나만 저장해둔 것도 링크 모음이었다는 걸 규칙을 고치면서 알았다.

type을 데이터로 넣자 분포가 보였다. bookmarks 274개 중 231개(84%)가 draft. 그런데 링크 모음에 “작성 중”은 없다 — 붙여넣은 순간이 최종형이다. draft가 “작성 중”과 “공개 안 함” 두 뜻으로 쓰이고 있었고, 작성 중 큐 268개가 완성될 일 없는 링크로 막혀 있었다. archive로 분리해서 큐가 37개가 됐다.

안 가져온 것: stale_after(버전을 고정한 메모가 10개뿐 — 만료를 선언할 표면이 없다), verified(단일 저자), resource(URL이 2개 이상인 메모가 359개라 대상을 하나로 특정 못 한다).

씨앗

같은 URL을 가리키는 메모쌍이 29개, 그중 14쌍은 공유 태그가 0개다. 태그 IDF 유사도로는 영원히 못 만난다. parent가 542개 중 0개인 이유가 스키마 부족이 아니라 후보를 본 적이 없어서일 수 있다.


#577

날짜 표기 통일에 매핑 객체가 필요 없다 — dayjs는 포맷 문자열이 곧 키이자 값이다. (i18n은 키≠값이라 객체가 필수였다.)

import dayjs from 'dayjs'

// 허용 표기의 단일 출처 — union 타입과 ESLint drift 가드가 여기서 파생
export const DATE_FORMATS = [
  'YYYY-MM-DD',
  'YYYY-MM-DDTHH:mm:ss',
  'HH:mm',
  'YYYY년 M월',
  'YY년 M월 D일',
  'YYYY.MM.DD',
  'YY.MM.DD',
  'YYYY.MM.DD(ddd)',
  'YYYY.MM.DD HH:mm',
] as const

export type DateFormat = (typeof DATE_FORMATS)[number]

export const formatDate = (
  date: dayjs.ConfigType,
  format: DateFormat,
): string => dayjs(date).format(format)

규율: 여러 곳에서 통일이 필요한 표기만 배열에 담는다. 일회성('M.D' 등)은 raw dayjs().format()으로 인라인 유지 — 안 그러면 배열이 잡동사니 enum이 된다. ESLint no-restricted-syntax로 카탈로그 표기를 raw로 다시 쓰면 경고(drift 가드).

ddd(요일)는 진입점에서 dayjs.locale('ko')를 호출해야 한국어로 나온다 — 안 하면 영어로 조용히 샌다. 타입은 이 런타임 전제를 못 잡는다.

곁가지 — 빌드타임 hoist·tagged-template 캐시도 검토했으나 접음: 고유 패턴이 수십 개 규모라 dedup·컴파일 최적화가 풀 문제 자체가 없다. 닫힌 타입 + 함수 하나로 충분.

#576

declarative Combobox = render ∘ query ∘ defer. Suspense가 query: Q→Promise⟨D⟩를 throw로 바꿔 동기 Q→D로 위장하니, 전체가 명령형 조율이 아니라 함수 합성이 된다.

상태공간 곱→합. manual은 Q × Results × loading × error 곱집합이라 loading∧error 같은 모순 셀이 타입상 실재 — flicker는 렌더가 그 셀을 지날 때의 증상. 선언적 합 타입(loading | error | success)은 모순을 구성 불가능하게 만들고, case 분기를 if/else가 아니라 트리(<Suspense> / <ErrorBoundary> / 본문)가 담당한다. (형식 닻: ui_exclusive — 모든 값은 정확히 한 case.)

manual의 곱집합 상태공간엔 모순 셀(loading∧error 등)이 물리적으로 존재 — 선언적 합 타입은 그걸 표현 불가능하게 좁히고, case 분기를 트리 형태(Suspense/ErrorBoundary/본문)가 담당

Effect lifting = 자유 변수가 트리의 형태로. loading/error가 render의 인자(자유 변수)에서 사라지고, 가장 가까운 경계(<Suspense>/<ErrorBoundary>)가 잡는 ambient effect로 들린다 — renderResults 하나만 받는다. 대수적 효과(algebraic effects)의 handler 구조와 같은 모양.


stale-while-revalidate(two clocks·isStale) 부분은 [[570]]로.

#575

useMemo는 성능 힌트지 의미적 보장이 아니다 — 정확성을 캐시에 의존하면 안 된다. React는 특별한 이유가 있으면 캐시를 버린다: 개발 중 HMR, 초기 마운트 중 suspend, 미래 기능(오프스크린/가상 리스트). 그래서 useMemo 결과로 “한 번 정해지면 유지돼야 하는 값”을 만들면, 캐시가 폐기되는 순간 값이 바뀐다.

// ❌ 캐시 폐기 시 색이 재생성 → flicker
const colors = useMemo(() => getRandomColors(baseTheme), [baseTheme])

// ✅ 영속성이 정확성 조건이면 state로
const [colors, setColors] = useState(() => generateAccentColors(baseTheme))
const [prevTheme, setPrevTheme] = useState(baseTheme)
if (baseTheme !== prevTheme) {
  // 렌더 중 prev 비교 = derived state
  setPrevTheme(baseTheme)
  setColors(generateAccentColors(baseTheme))
}

판별 기준: “이 값이 재계산되면 틀린 동작인가, 아니면 그냥 느린 동작인가.” 틀림 → state/ref. 느림 → useMemo.

참고

로딩 flicker는 두 엣지인데 CSS는 한쪽만 잡는다:

  • leading — 응답이 너무 빨라 스피너가 뜨기 전/직후 사라짐. 언제 보이기 시작하나(delay D)의 문제. → CSS가 잡음: animation-delay/transition-delay가 곧 D. τ ≤ D면 한 프레임도 안 보인다(빠른 응답이 스피너 통째로 건너뜀). 단 억제력은 오직 D에서 — @starting-style 단독(D=0)으론 못 막는다.
  • trailing — 스피너가 떴는데 너무 짧게 보이고 하드컷. 언제 사라지나(τ)의 문제. 떴다는 사실은 못 되돌리니 (D,P)로는 못 잡고 τ 자체를 미뤄야 한다 = CSS 밖, JS 타이밍. → useMinimumLoading: unmount를 max(resolve, minLoad)로 밀어 한 번 뜬 스피너 가시 길이 ≥ minLoad 보장.

형식 닻: leading_edge_suppressed(τ≤D면 비가시), visible_ge_minLoad(τ-push 보장).

곁가지 — @starting-style은 flicker와 무관한 다른 축: transition의 from↔to를 distinct하게 만들어 마운트 보간이 죽지 않게 하는 것(진입 fade-in). 그래서 진입 애니메이션과 flicker 해법(useMinimumLoading)은 별개 결정.

#573

HTTP 요청 취소는 클라이언트 측 사건이다. 취소 시각 tA가 요청 lifecycle의 어느 구간이냐가 promise reject·서버 도달·서버 처리를 가르지만 — 취소는 이미 전송된 byte를 회수하지도, 진행 중인 서버 처리를 멈추지도 못한다.

뿌리는 한 정의 등식: Reached = Processed(둘 다 tSendDone ≤ tA) — 전송이 취소 전 끝났으면 서버는 처리하고, 취소는 그걸 못 멈춘다. 따라서:

  • reject된 promise는 완전히 처리된 서버와 양립한다(구간 [tSendDone, tResp)에서 둘 다 참). 취소 ≠ 서버에서의 무사건.
  • 절약되는 건 클라이언트 측(응답 처리)뿐 — 도달했다면 서버 작업은 안 절약됨.
  • 경계는 tSendDone — 취소 시각이 고정이어도 네트워크가 느려 tSendDone이 그 시각을 넘으면 도달 판정이 참→거짓으로 뒤집힌다(네트워크 속도가 도달 여부를 좌우).

형식 닻: no_server_cancellation(Reached→Processed), reachability_monotone_in_send_time(네트워크 속도가 도달 뒤집음). 곁가지: 캡슐화돼 반환 안 된 취소 핸들은 도달 불가 → 노출된 signal만 취소 가능.

#572

useMemo — 적중은 Object.is(deep-equal 아님)

useMemo 적중 조건은 Object.is(참조/원시 동등)이지 deep-equal이 아니다. 그래서 인라인 items={data.map(...)}은 내용이 같아도 매 렌더 miss(참조가 매번 새로움). 정확성은 별개 — 순수 가정(매 렌더 f() = φ(deps))이 서면 적중이든 miss든 반환값은 항상 fresh φ(deps). stale closure(deps에 없는 변수 포획)는 그 가정 자체를 깨서 stale 값을 낸다.

useDeferredValue — items와 무관 (핵심)

deferred 궤적은 자기 입력(query)에만 의존하고 items 참조 안정성과 무관(증명이 rfl인 이유 — items가 계산에 안 들어감). 그래서 items가 매 렌더 새로 만들어져도 죽는 건 useMemo 캐시 한 층뿐, deferred는 안 흔들린다.

안전성: deferred는 항상 지금까지 본 입력 중 하나(미래·날조값 아님). 수렴: 입력이 안정화되고 저우선순위 렌더가 무한 선점 안 되면 따라잡음 — 그 격차 구간이 isStale(opacity dim 창). 연속 타이핑 시 영영 못 따라잡을 수 있는데 버그가 아니라 의도(그 내내 dim 유지).

형식 닻: renderDeferred_const_in_items(items에 상수), converge(안정화 후 따라잡음).

location.href setter 실행 시점(②)과 실제 navigation 처리 시점(④)이 분리되는 이벤트 루프 타임라인

location.href setter의 실행 시점과 실제 navigation 처리 시점은 분리된다. setter는 동기적으로 정상 실행되지만(흐름을 끊지 않음), 그 효과는 브라우저 슬롯에 “마지막 값으로 덮어쓰기”로만 남는다. 실제 이동은 콜 스택이 비고 마이크로태스크까지 소진된 뒤(④)에야 일어난다.

그래서 “암묵적 await” 가설은 틀렸다 — await였다면 첫 줄에서 멈춰 google.com으로 갔겠지만, 실제로는 두 setter가 모두 실행되고 마지막 값 google2.com으로 간다.

location.href = 'https://google.com'
console.log('실행됨 1') // 출력됨
location.href = 'https://google2.com'
console.log('실행됨 2') // 출력됨 → await였다면 안 찍힘

console.log가 모두 찍히는 것이 setter가 흐름을 끊지 않는다는 직접 증거다.

#569

spinner 메시지가 매 틱 **셔플이 아니라 독립 균등추출(복원)**이면, 체감 “다양함”의 한계는 coupon collector 꼬리다 — 풀 n(≈90)을 다 보려면 평균 nH_n7.6분, 마지막 한 단어가 제일 오래 걸린다. 셔플이었다면 정확히 n틱에 끝났을 것 — 복원추출이라 꼬리가 생긴다.

탐색기: 풀 크기 슬라이더로 기대 누적 unique 곡선 — 커스텀 단어를 append할수록 분모가 커져 빌트인 단어 확률이 희석된다.

#568

핵심: parser 진행과 first paint는 분리된다. 깃발(first paint)이 보라 사각형(JS 실행)보다 왼쪽이면 — JS가 손대기 전에 그려지면 — 사용자가 잘못된 상태를 본다.

세 로딩 전략의 타임라인 — first paint(깃발)와 JS 실행(보라 사각형)의 선후로 flash window 발생 여부 비교
  • non-blocking (async/defer): paint가 JS보다 먼저 → flash window(빨간 구간) 발생.
  • parser-blocking (동기 <script>): parser는 멈추지만 그 위 청크는 먼저 그려질 수 있어, default 상태가 한 프레임 노출될지가 브라우저 재량 = 보장 없음.
  • blocking=render (<head> module): paint가 JS 뒤로 → 유일하게 깨끗.

트레이드오프: “1프레임 flash/CLS”를 막는 대가로 “FCP를 JS 실행시간만큼 미루는” 거래라, 스크립트가 작고 인라인일 때만 성립한다.

blocking=render를 써도 되는 세 조건 — 작고 인라인인 스크립트, head 안 배치, 무시돼도 안전한 폴백

성립 조건 셋:

  1. 스크립트가 작고 인라인 (FCP 지연을 감당할 만큼).
  2. <head> 안에서만 동작 — 컴포넌트 마크업 뒤가 아니라 head에 type="module" blocking="render"로 올려야 보장이 생긴다 (원문이 놓친 부분).
  3. attribute가 무시돼도 옛 동작으로 떨어질 뿐 깨지지 않는다 → 라이브러리가 권장 패턴으로 제공하려면 이 안전한 폴백이 핵심.
#567
30 중 1페이지