날짜 표기를 통일하는 데 매핑 객체가 필요 없다 — dayjs는 포맷 문자열이 곧 키이자 값이라, 허용 표기를 as const 배열 하나로 두면 그게 닫힌 집합이다. 닫힘은 union 타입이, 통로는 함수 하나가 맡는다. (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가 무시돼도 옛 동작으로 떨어질 뿐 깨지지 않는다 → 라이브러리가 권장 패턴으로 제공하려면 이 안전한 폴백이 핵심.
prop 프리셋을 데이터로 중앙화하는 타입 헬퍼를, 가로축 excess(프로퍼티명 오타) 검출 · 세로축 개별 리터럴 보존으로 좌표에 놓으면 단일 named 헬퍼로 네 칸을 다 차지하는 점은 없다가 한눈에 보인다.
우상단(둘 다 ✓)엔 satisfies와 Exact-never가 있지만, 각각 “어노테이션 반복” / “메시지 나쁨”을 대가로 치른다.
type TextStyleProps = Omit<TextProps, 'children'>
// ① 제네릭 항등 — 리터럴 O, excess 검출 X (조용히 T로 흡수)
const createTextStyles = <T extends Record<string, TextStyleProps>>(s: T): T =>
s
// ② 키 제네릭 — excess O(메시지 좋음), 키 O, 개별 리터럴은 유니온으로 넓어짐 ← 추천
const createTextStyles = <K extends string>(
s: Record<K, TextStyleProps>,
): Record<K, TextStyleProps> => s
// ③ Exact-never — 리터럴·excess O, 단 에러가 "not assignable to never"
type Exact<V> = {
[P in keyof V]: P extends keyof TextStyleProps ? TextStyleProps[P] : never
}
const createTextStyles = <T extends Record<string, TextStyleProps>>(
s: T & { [K in keyof T]: Exact<T[K]> },
): T => s
// ④ 인라인 satisfies — 리터럴·excess·메시지 다 O, 단 named 헬퍼 아님 + 어노 반복
const guideStyles = {
title: { type: 'body1_700', color: COLORS.GRAY_90 },
} satisfies Record<string, TextStyleProps>
값 오타(type: 'nope')·키 오참조(styles.titel)는 ①②③④ 전부 잡는다. 갈리는 건 프로퍼티명 오타와 개별 type 리터럴뿐.
구조적 이유: excess 검출은 고정 target을, 리터럴 보존은 *제네릭 캡처(T)*를 요구해 서로 반대로 당긴다. satisfies만 컴파일러가 특별 취급해 둘을 한 번에 주지만, 그 대가가 “구문이지 헬퍼가 아님”이다.
권장: 엄격성이 목표면 ② (잃는 건 거의 안 쓰는 개별 type 리터럴). 개별 리터럴이 진짜 필요하면 ④. color가 required면 ①도 프로퍼티명 오타가 “필수 누락”으로 걸려 갭이 거의 사라진다.
프리셋을 컴포넌트가 아니라 prop 데이터로 다루면 래퍼 컴포넌트 비용(displayName·리마운트·추가 fiber)을 회피하고, override는 평범한 JSX 스프레드로 투명하다.