CORS error 원인 찾기
| 언제 | 왜 |
|---|---|
Console·Network 에 CORS error |
preflight 에 Access-Control-Allow-Origin(ACAO) 이 없으면 브라우저는 5xx·404·타임아웃을 전부 CORS 로 뭉갠다 |
preflight 가 server: awselb/2.0 의 5xx |
LB 가 타깃에 닿기 전에 직접 만든 응답이다 — 앱을 안 거쳤으니 CORS 헤더가 붙을 수 없다 |
preflight 의 status 와 server 헤더 둘로 가른다.
| preflight | server | ACAO | 갈래 | 원인 |
|---|---|---|---|---|
503 |
awselb/2.0 | 없음 | 인프라 | healthy target 0 — 서비스 다운·배포 실패·CrashLoop |
502 |
awselb/2.0 | 없음 | 인프라 | 타깃이 끊거나 잘못 응답 — 크래시·OOM·포트 불일치 |
504 |
awselb/2.0 | 없음 | 인프라 | LB idle timeout 초과 — 느린 쿼리·외부 연동 지연 |
404 |
앱 | 없음 | 경로 | path 오타, ingress rule 불일치 |
204·200 |
앱 | 없음 | CORS | CORS 미설정 |
204 |
앱 | 있음 | CORS | access-control-allow-headers 에 요청 헤더가 빠짐 |
204 |
앱 | * |
CORS | credentials: 'include' 와 * 는 병용 불가 — 오리진을 명시한다 |
204 |
앱 | 정상 | 정상 | CORS 아님. 본 요청의 본문·인증·에러를 본다 |
Node 에서 재귀 크롤을 이벤트 루프에 맡기기
종료 감지를 큐의 onIdle() 에, 중복 제거의 원자성을 단일 스레드에 맡기면 in-flight 카운터와 락이 사라진다.
Node 로 링크를 따라가며 페이지를 재귀 크롤할 때 꺼낸다. 처리 결과(새 링크)가 다시 입력으로 돌아가서 일이 언제 바닥날지 미리 모르는 경우다.
import PQueue from 'p-queue'
const queue = new PQueue({ concurrency: 10 }) // 동시 요청 수만 제한한다. add 는 막히지 않는다(무제한 대기열)
const seen = new Set()
function enqueue(url) {
if (seen.has(url)) return
seen.add(url) // has 와 add 사이에 await 가 없으니 다른 코드가 끼어들 수 없다 — 락이 필요 없다
queue.add(() => visit(url)) // 요청보다 먼저 표시한다. 요청 뒤에 표시하면 그 사이 같은 URL 이 또 들어온다
}
async function visit(url) {
const res = await fetch(url) // HEAD 가 아니라 GET — 링크를 더 찾으려면 본문이 있어야 한다
const html = await res.text()
for (const link of extractLinks(html, url)) enqueue(link) // 이 태스크가 끝나기 전에 자식을 넣는다
}
enqueue(startUrl)
await queue.onIdle() // 대기열이 비고 실행 중인 태스크도 0 일 때 resolve
extractLinks 는 HTML 에서 같은 사이트 링크를 뽑는 함수(자리표시자). 서로 가리키는 페이지가 있어도 onIdle() 이 resolve 되고 seen.size 가 고유 URL 수와 같으면 된 것이다.
- 출처: How Other Link Checkers Do Recursion — linkinator(
onIdle·GET)·broken-link-checker 분석
렌더 흐름에서 규칙이 걸리는 자리
① render — 유지돼야 하는 값은 useMemo 가 아니라 state
useMemo(getRandomColors, []) 로 만든 값이 HMR 뒤에 바뀐다.
const resolved = resolveTheme(mode, systemDark) // 순수 파생은 그냥 계산
const [colors] = useState(getRandomColors) // 유지가 곧 correctness 면 state
② useLayoutEffect — 서버 경고는 shim 으로
const useIsoLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect
③ useEffect — 이벤트는 노드가 사는 window 에
window.open·포털로 다른 창에 렌더해도 window.addEventListener 는 원래 창을 듣는다.
const win = ref.current?.ownerDocument.defaultView ?? window
멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트에만 필요하다.
④ ref 콜백 — cleanup 을 반환한다 (React 19)
<div
ref={(node) => {
const ctrl = new AbortController()
node.addEventListener('scroll', onScroll, { signal: ctrl.signal })
return () => ctrl.abort()
}}
/>
Chrome 외부 앱 딥링크 "항상 열기" 해제
커스텀 스킴(myapp://) 다이얼로그에서 “항상 열기 허용”을 실수로 체크해 되돌리고 싶을 때. 설정 UI 에는 항목이 없다 — 체크는 프로필의 Preferences JSON 에 origin 단위로 저장된다.
"protocol_handler": {
"allowed_origin_protocol_pairs": { "https://example.com": { "myapp": true } }
}
| OS | 경로 |
|---|---|
| macOS | ~/Library/Application Support/Google/Chrome/<Profile>/Preferences |
| Windows | %LOCALAPPDATA%\Google\Chrome\User Data\<Profile>\Preferences |
| Linux | ~/.config/google-chrome/<Profile>/Preferences |
SPA 메모리 누수 soak test
Soak 테스트는 일반적인 프로덕션 부하를 사용하여 연속 가용성 기간 동안 시스템 동작을 프로덕션 사용 환경에서 검증하기 위한 시스템 테스트 유형입니다.
SPA 메모리 누수는 시작 화면과 끝 화면이 같은 플로우를 반복해서 잡는다. SPA는 페이지를 새로고침하지 않아 서버와 같은 상황이어서 한 플로우를 동일한 브라우저 컨텍스트 안에서 수백 번 돌린다.
drawer 열고 닫기를 200번 돌린 뒤 리스너·노드 수가 처음보다 늘어 있으면 그게 누수다 — 같은 자리로 돌아왔는데 늘었으니까. 클릭 반복은 Playwright로, 데이터는 CDP 로 읽는다.
측정은 CDP 로 한다 — Playwright 는 Chromium 에서만 CDP 세션을 열 수 있어서 Chromium 전용이다.
판정은 노드·리스너 수로만 한다. heap 은 앱과 무관하게 실행마다 흔들린다. 워밍업도 힙 때문이다 — 첫 루프에 코드·데이터를 받아와 힙이 뛰지만, 수는 첫 루프부터 같은 만큼 오른다.
출처
Next.js SSG + S3 + CloudFront 캐시 정책
정적 SPA에서 Next.js SSG로 옮길 때 배포해도 구버전 HTML이 서빙되는 문제를 헤더 설계로 해결한다. example.com 기준.
왜 문제가 생기는가
SPA는 HTML이 index.html 하나라 진입점만 무효화하면 됐다. SSG는 라우트마다 HTML 오브젝트가 생기고 각자 독립된 TTL로 엣지에 캐시된다.
Cache-Control 이 없으면 CloudFront는 Default TTL(미설정 시 86400초), 브라우저는 휴리스틱 캐싱((Date - Last-Modified) × 10%)을 적용한다. 그 동안 엣지는 오리진에 가지 않는다 — 로컬에서 재현 안 돼도 문제다.
디버깅 — 오리진 → 엣지 → 브라우저 순으로 좁힌다
배포했는데 구버전이 보이면 어느 층이 구버전을 들고 있는지부터 가른다. 안쪽부터 본다 — 오리진이 구버전이면 바깥을 볼 이유가 없다.
1. 오리진에 새 HTML이 올라갔나
aws s3api head-object --bucket example.com --key index.html
LastModified가 배포 시각보다 이전 → 배포가 안 됐다. buildspec 2단계 로그를 본다CacheControl이 없다 → 헤더 미적용. 2단계에서Hit로 드러난다
2. 엣지가 무엇을 내보내나
curl -sI https://example.com/ | grep -iE 'cache-control|x-cache|age'
curl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'
cache-control없음 +Hit→ 헤더 미적용 → 헤더 레시피 + 전환 최초 1회/*무효화max-age=0인데Hit+age증가 → Minimum TTL이 오리진 헤더를 무시. Cache Policy 수정RefreshHit·Miss→ 엣지는 정상. 3으로
약한 ETag(W/"...")·vary: Accept-Encoding은 엣지 자동 압축 흔적이다. 무시한다.
3. 브라우저만 구버전인가
curl 은 새 버전인데 브라우저만 구버전 → 헤더 없이 받았던 응답을 휴리스틱으로 들고 있다. invalidation이 못 닿는다. 만료를 기다린다. 헤더 적용 이후 배포분부터는 재발하지 않는다.
곁증상
| 증상 | 원인 | 처방 |
|---|---|---|
| 배포 직후 lazy chunk 404 | 1단계에 --delete, 또는 HTML이 에셋보다 먼저 올라감 |
순서·--delete 위치 |
| 일부 오브젝트만 403 | 한쪽 sync에 --acl public-read 누락 |
두 명령 모두에 |
--acl 자체가 에러 |
버킷이 BucketOwnerEnforced |
--acl 빼고 OAC + 버킷 정책 |
| 3단계 AccessDenied | 롤에 cloudfront:CreateInvalidation 없음 |
3단계 무효화 |
오브젝트 종류별로 정반대 헤더
| 대상 | Cache-Control | 이유 |
|---|---|---|
_next/static/** |
public,max-age=31536000,immutable |
파일명에 콘텐츠 해시 — 내용이 바뀌면 이름이 바뀐다 |
*.html, 데이터 파일 |
public,max-age=0,must-revalidate |
매 요청 재검증, 안 바뀌었으면 304 |
version: 0.2
env:
variables:
BUCKET: example.com
phases:
post_build:
commands:
# 1) 해시 에셋: 영구 캐시, 삭제 없음
- aws s3 sync ./out/_next/static s3://$BUCKET/_next/static
--acl public-read
--cache-control 'public,max-age=31536000,immutable'
# 2) HTML·데이터: 매 요청 재검증, 구 HTML 정리
- aws s3 sync ./out s3://$BUCKET
--acl public-read
--exclude '_next/static/*'
--cache-control 'public,max-age=0,must-revalidate'
--delete
# 3) 엣지 무효화 (권한 있을 때만 — 아래 참고)
- export CF_DIST_ID=$(aws cloudfront list-distributions
--query "DistributionList.Items[?Aliases.Items[?contains(@, '$BUCKET')]].Id"
--output text)
- test -n "$CF_DIST_ID" || (echo "CF_DIST_ID 조회 실패" && exit 1)
- aws cloudfront create-invalidation --distribution-id $CF_DIST_ID --paths '/*'
순서를 지켜야 한다
- 에셋(1) → HTML(2). 단일 sync는 순서를 보장하지 않아, HTML이 먼저 가면 청크가 없는 창이 생긴다. 이 순서면 2가 실패해도 “새 에셋 + 구 HTML”로 정상 동작한다.
- 1단계는
--delete없음. 구 해시 에셋이 남아야 열려 있던 탭의 lazy chunk 404가 안 난다. --acl public-read는 둘 다에. 빠지면 그 오브젝트가 403.
3단계 무효화 — 어디까지 닿나
확인
aws s3api head-object --bucket example.com --key index.html
aws s3api head-object --bucket example.com --key '_next/static/<hash>/...'
curl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'
| 대상 | cache-control | x-cache | age |
|---|---|---|---|
| HTML | public,max-age=0,must-revalidate |
반복 요청 시 RefreshHit |
없거나 0 |
_next/static/** |
public,max-age=31536000,immutable |
Hit |
계속 증가 |
HTML이 Hit + age 증가일 때만 Minimum TTL을 본다.
MySQL TIME × 언어 타입 폭 불일치
운영시간 목록 API가 통째로 500이고, 프론트는 그걸 “선택 가능한 날짜가 없는 정상 달력”으로 그린다. 원인은 operation_hours의 to = 24:00:00 행 하나였다.
1. 스택트레이스 최하단
DateTimeException: Invalid value for HourOfDay (valid values 0 - 23): 24
at java.time.LocalTime.of
at com.mysql.cj.result.LocalTimeValueFactory...
ValueFactory·TypeHandler·ResultSetImpl 프레임의 DateTimeException이면 매핑 실패다. 24·HourOfDay가 원인 행의 값 → 2의 조건. 로그에 cause 체인이 없으면 2부터 돌려 역으로 좁힌다.
2. 도메인 밖 값 감사 — 핫픽스 전에
SELECT owner_id, slot_type, `from`, `to`
FROM operation_hours
WHERE TIME_TO_SEC(`from`) >= 86400 -- 24시 이상
OR TIME_TO_SEC(`to`) >= 86400
OR TIME_TO_SEC(`from`) < 0 -- MySQL TIME은 음수 허용
OR `from` > `to`; -- 익일 걸침 (별개 문제)
문자열 비교는 '9:00:00' 같은 비제로패딩에서 틀리니 TIME_TO_SEC. from > to(야간 걸침)는 다른 요구라 따로 세서 별건으로 뺀다.
3. 정의역 대조 — 데이터가 더러운가, 타입이 좁은가
MySQL TIME은 -838:59:59 ~ 838:59:59인 duration 타입이라 24:00:00은 합법값이다.
| 언어/타입 | 정의역 | TIME 전 범위 |
|---|---|---|
Java LocalTime |
00:00 ~ 23:59:59.999999999 |
✗ |
Java Duration |
사실상 무제한 | ✓ |
TS string |
제약 없음 | ✓ (검증 없음) |
Python datetime.time |
00:00 ~ 23:59:59.999999 |
✗ |
Go time.Duration |
±292년 | ✓ |
읽는 쪽 LocalTime이 좁은 것이다. 정타입 매핑은 Duration(Connector/J 8 rs.getObject(col, Duration.class)). zeroDateTimeBehavior는 DATE/DATETIME 전용이라 우회가 안 된다.
4. 정규화 후보를 슬롯 계산에 넣어 본다
UPDATE 전에 두 서비스의 슬롯 계산을 읽고 후보를 넣어 본다.
| 후보 | 결과 | 확인할 것 |
|---|---|---|
23:59:59 |
1초 손실. 무해한 편 | 슬롯 생성이 to를 exclusive로 쓰면 23:30 슬롯이 사라짐 |
00:00:00 |
from > to가 되어 범위 비교 전부 뒤집힘 |
익일 처리 분기 없으면 조용히 영업시간 0분 |
Duration으로 승격 |
손실 없음 | 하위 계산 로직 전부 수정 |
00:00:00은 예외가 안 나서 지금보다 나쁘다 — 터지는 버그가 침묵하는 버그가 된다.
5. 읽기 방어 — TypeHandler
@MappedTypes(LocalTime.class)
public class SafeLocalTimeTypeHandler extends BaseTypeHandler<LocalTime> {
@Override
public LocalTime getNullableResult(ResultSet rs, String col) throws SQLException {
String raw = rs.getString(col);
if (raw == null) return null;
Duration d = parseMysqlTime(raw);
return d.compareTo(Duration.ofDays(1)) >= 0
? LocalTime.MAX.truncatedTo(ChronoUnit.SECONDS) // 23:59:59
: LocalTime.ofSecondOfDay(d.getSeconds());
}
// ...
}
6. 쓰기 제약 — 읽기 다음에
쓰기부터 막으면 이미 들어 있는 행 때문에 여전히 500이다. 2를 다시 돌려 0건인지 보고 건다.
ALTER TABLE operation_hours
ADD CONSTRAINT chk_operation_hours_range
CHECK (TIME_TO_SEC(`to`) <= 86340 AND TIME_TO_SEC(`from`) >= 0);
MySQL 8.0.16+. DDL이 성공하면 위반 행이 없다는 확인이기도 하다. 낮은 버전의 TypeORM validator는 배치·수동 SQL로 뚫린다.
7. 프론트 — 별개 티켓
useQuery 에러 → hours = undefined → 모든 날짜 null → 달력 통째로 비활성. isPending / isError / 빈 배열을 따로 그리고 실패엔 재시도 UI. 빈 배열은 “휴무”, 못 가져옴은 “다시 시도”다.
8. 예방 — 같은 폭탄 찾기
공유 테이블의 컬럼마다 스택별 매핑 타입을 DB 도메인 위에 겹친다(3의 그림). 스키마 파일끼리 비교하면 양쪽 다 time이라 안 잡힌다. 넓은 쓰기 / 좁은 읽기가 시한폭탄이다.
| 컬럼 타입 | 넓은 쪽 | 좁은 쪽 |
|---|---|---|
TIME |
string, Duration | LocalTime, datetime.time |
DECIMAL(20,0) |
BigDecimal, string | JS number (2^53) |
BIGINT |
int64 | JS number |
VARCHAR (utf8mb4) |
string | 이모지 미대응 정규식 검증 |
DATE (0000-00-00) |
string | LocalDate |
장기 · 미해결
TIME대신 자정 기준 분SMALLINT(0~1440):24:00 = 1440, 양 언어 모두 정수라 폭이 같고,CHECK (col BETWEEN 0 AND 1440). 별건 티켓.24:00:00이 어디서 들어왔나 — 어드민 UI면 validator로 충분, 배치면 DB 제약 필수.- 휴무 행의
from/to를 애초에 읽어야 하나 — 안 읽어도 되면 5보다 싸다.
useMemo 캐시 적중 진단 — 매 렌더 새로 만들어지는 deps
무거운 계산을 useMemo로 감쌌는데 렌더 시간이 줄지 않는다. 각 단계는 질문 하나에 답하고, 그 답이 다음 질문을 만든다. 단계마다 추론이 아니라 관찰로 넘어간다.
1. 정말 이 계산이 느린가
React DevTools Profiler로 조작 하나를 녹화하고, flamegraph에서 그 컴포넌트의 렌더 시간(ms)을 본다. 여기서 이 컴포넌트가 병목이 아니면 useMemo를 볼 이유가 없다. 이 숫자는 5에서 수정 전후를 비교할 기준선이 된다.
2. 정말 재사용되고 있나
계산 함수 안에 카운터를 넣고 리렌더를 몇 번 일으킨다.
const rows = useMemo(() => {
console.count('calc')
return items.filter(fn)
}, [items])
calc가 렌더와 같이 늘어나면 한 번도 재사용되지 않고 있다. 개발 모드에서는 React가 계산 함수를 일부러 두 번 부르므로1 렌더당 2씩 뛰는 게 정상이다 — 보는 건 절대값이 아니라 렌더를 따라 늘어나는지다.
CDP로 읽을 때. Runtime.enable 뒤 Runtime.consoleAPICalled가 type: "count"로 온다. 숫자는 따로 오지 않고 args[0].value에 "calc: 3" 같은 문자열 하나로 오므로 /: (\d+)$/로 꺼낸다. console.countReset은 이벤트를 내지 않는다. Runtime.enable 전에 찍힌 메시지도 enable 시점에 다시 보내준다.2
코드를 고치지 않고 셀 때. 앱에 로그를 심을 수 없으면 V8 커버리지의 호출 횟수를 쓴다.
await send('Profiler.enable')
await send('Profiler.startPreciseCoverage', { callCount: true, detailed: true })
// 리렌더 한 번 일으키고
const { result } = await send('Profiler.takePreciseCoverage')
// result[].functions[].ranges[0].count = 그 사이 호출 횟수
takePreciseCoverage는 부를 때마다 카운터를 0으로 되돌린다.2 리렌더마다 한 번씩 부르면 그 렌더 동안의 호출 횟수가 바로 나온다. 다만 useMemo(() => …)의 화살표 함수는 functionName이 비어 있어 스크립트 url과 ranges 오프셋으로 찾아야 하고(번들이면 소스맵으로 되짚는다), 개발 모드의 두 번 호출도 그대로 세어진다.
3. 왜 매번 다시 계산하나
계산 쪽이 아니라 deps를 의심한다. React는 재료 안을 들여다보지 않고 Object.is로 같은 물건인지만 본다.1 매 렌더 새로 만들어지는 값이 deps에 있으면 내용이 같아도 매번 “다른 재료”다.
의심만 하지 말고 참조가 실제로 바뀌는지 찍는다.
const prev = useRef(items)
useEffect(() => {
console.log('items same?', Object.is(prev.current, items))
prev.current = items
})
매 렌더 false면 확정이다. Profiler 설정의 “Record why each component rendered while profiling”을 켜면 코드 없이 Props changed: items로도 보인다.
4. 누가 새 참조를 만드나
같은 비교를 부모 쪽에 두고 거슬러 올라간다. 렌더 중에 새 배열을 만들어 넘기고 있었다.
<List items={data.map(transform)} />
넘기는 쪽에서 참조를 붙잡아 둔다.
const items = useMemo(() => data.map(transform), [data])
Footnotes
-
“React will compare each dependency with its previous value using the
Object.iscomparison.” / “React will not throw away the cached value unless there is a specific reason to do that … This should be fine if you rely onuseMemosolely as a performance optimization.” / “In Strict Mode, React will call your calculation function twice in order to help you find accidental impurities.” (useMemo — React) ↩ ↩2 -
Chrome 154 headless 에서 원시 CDP 로 실측 (2026-09-30).
console.count('calc')세 번 →"calc: 1"·"calc: 2"·"calc: 3",countReset뒤 다시"calc: 1". 커버리지는 3회 호출 뒤take→ 3, 2회 더 호출 뒤take→ 2. ↩ ↩2
dayjs 날짜 표기 카탈로그
1. 문제 — 날짜 표기를 한곳으로 모으고 싶다
화면마다 dayjs().format('…')이 흩어져 같은 날짜가 다르게 찍힌다. 날짜 표기를 받는 인자를 타입으로 닫아 허용된 표기만 쓰게 하고 싶다.
2. 첫 발상 — 매핑 객체
표기 이름과 포맷 문자열을 짝짓는 객체({ DATE: 'YYYY-MM-DD', … })부터 떠올랐다. i18n 습관이다 — i18n은 이름표(인사)와 내용(Hello)이 달라서 짝지어 주는 객체가 필수였다.
그런데 dayjs는 포맷 문자열이 곧 키이자 값이다.1 이름을 따로 붙이면 같은 정보를 두 번 들고 있게 된다. 매핑 객체는 필요 없다.
3. 해결 — 배열 하나에서 파생
허용 표기를 as const 배열 하나에 두고, 타입과 진입점을 거기서 파생한다.
import dayjs from 'dayjs'
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)
DATE_FORMATS— 허용 표기의 단일 출처DateFormat— 배열에서 파생된 닫힌 union. 따로 손댈 곳이 없다formatDate— 유일한 진입점
4. 새 표기가 필요해지면
- 여러 곳에서 통일이 필요한 표기인지 먼저 본다. 일회성(
'M.D'등)이면 rawdayjs().format()으로 둔다 — 이 기준이 없으면 배열이 잡동사니 enum이 된다. DATE_FORMATS에 추가한다. 그게 전부다.ddd가 들어 있으면 진입점에dayjs.locale('ko')가 있는지 본다.2
5. 남은 구멍
- raw 우회는 타입이 못 막는다. 카탈로그에 있는 표기를
formatDate없이 raw로 다시 써도 오류가 없다. no-raw-date-format(no-restricted-syntax)이 필요한데 아직 안 썼다. 그 규칙 전까지 단일 출처는 관행일 뿐이다. ddd는 조용히 샌다.dayjs.locale('ko')없이 쓰면Mon·Tue처럼 영어로 나오고, 예외도 타입 오류도 없다.YYYY.MM.DD(ddd)만 런타임 전제를 가진 항목이다.
Footnotes
Suspense는 비동기를 동기처럼 보이게 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다. 그래서 상태를 조율하는 대신 입력을 미루고 → 질의하고 → 그리는 순서로 끝난다.
언제: 비동기 데이터를 그리는데 로딩·에러가 늘면서 화면이 튈 때. 또는 새로 만들면서 어느 쪽으로 갈지 고를 때.
절차
- 검색어·결과·로딩 중인지·에러인지를 각각 들고 있는지 본다.
- 분기를
if/else에서 트리로 옮긴다 — 로딩은<Suspense>, 에러는<ErrorBoundary>, 성공은 본문. - 본문에 loading·error를 인자로 넘기지 않는다. 안 넘겨도 가장 가까운 경계가 대신 잡기 때문이다 —
render는 결과 하나만 받는다.
확인: 있을 수 없는 조합을 적을 수 있는가. 적을 수 없으면 된 것이다.
함정: 스피너가 너무 빨리 뜨고 사라지는 건 이 축으로 안 고쳐진다 — 다른 축이다 → 573
곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler와 같다.1
stale-while-revalidate(두 시계·isStale) 부분은 570으로.
Footnotes
-
try/catch와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 처리하고 끝나지 않는다는 것이다. handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다. (Algebraic Effects for the Rest of Us) ↩