메모 압축 — 존댓말이 신호였다
메모가 늘면서 어떻게 줄일지가 고민이었다. 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개인 이유가 스키마 부족이 아니라 후보를 본 적이 없어서일 수 있다.
날짜 표기 통일에 매핑 객체가 필요 없다 — 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·컴파일 최적화가 풀 문제 자체가 없다. 닫힌 타입 + 함수 하나로 충분.
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.)
Effect lifting = 자유 변수가 트리의 형태로. loading/error가 render의 인자(자유 변수)에서 사라지고, 가장 가까운 경계(<Suspense>/<ErrorBoundary>)가 잡는 ambient effect로 들린다 — render는 Results 하나만 받는다. 대수적 효과(algebraic effects)의 handler 구조와 같은 모양.
stale-while-revalidate(two clocks·isStale) 부분은 [[570]]로.
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)은 별개 결정.
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만 취소 가능.
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 처리 시점은 분리된다. 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가 흐름을 끊지 않는다는 직접 증거다.
spinner 메시지가 매 틱 **셔플이 아니라 독립 균등추출(복원)**이면, 체감 “다양함”의 한계는 coupon collector 꼬리다 — 풀 n(≈90)을 다 보려면 평균 nH_n ≈ 7.6분, 마지막 한 단어가 제일 오래 걸린다. 셔플이었다면 정확히 n틱에 끝났을 것 — 복원추출이라 꼬리가 생긴다.
탐색기: 풀 크기 슬라이더로 기대 누적 unique 곡선 — 커스텀 단어를 append할수록 분모가 커져 빌트인 단어 확률이 희석된다.
핵심: parser 진행과 first paint는 분리된다. 깃발(first paint)이 보라 사각형(JS 실행)보다 왼쪽이면 — JS가 손대기 전에 그려지면 — 사용자가 잘못된 상태를 본다.
- non-blocking (async/defer): paint가 JS보다 먼저 → flash window(빨간 구간) 발생.
- parser-blocking (동기
<script>): parser는 멈추지만 그 위 청크는 먼저 그려질 수 있어, default 상태가 한 프레임 노출될지가 브라우저 재량 = 보장 없음. - blocking=render (
<head>module): paint가 JS 뒤로 → 유일하게 깨끗.
트레이드오프: “1프레임 flash/CLS”를 막는 대가로 “FCP를 JS 실행시간만큼 미루는” 거래라, 스크립트가 작고 인라인일 때만 성립한다.
성립 조건 셋:
- 스크립트가 작고 인라인 (FCP 지연을 감당할 만큼).
<head>안에서만 동작 — 컴포넌트 마크업 뒤가 아니라 head에type="module" blocking="render"로 올려야 보장이 생긴다 (원문이 놓친 부분).- attribute가 무시돼도 옛 동작으로 떨어질 뿐 깨지지 않는다 → 라이브러리가 권장 패턴으로 제공하려면 이 안전한 폴백이 핵심.