[{"i":"599","t":"CORS error 원인 찾기","k":["cors","preflight","alb","http","debug"],"c":"2026-09-04","m":"2026-09-29","b":"| 언제                                     | 왜                                                                                                                                                                                                              |\n| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Console·Network 에 `CORS error`          | preflight 에 [`Access-Control-Allow-Origin`(ACAO)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin) 이 없으면 브라우저는 5xx·404·타임아웃을 전부 CORS 로 뭉갠다 |\n| preflight 가 `server: awselb/2.0` 의 5xx | LB 가 타깃에 닿기 전에 직접 만든 응답이다 — 앱을 안 거쳤으니 CORS 헤더가 붙을 수 없다                                                                                                                           |\n\n![정상이면 OPTIONS 가 LB 를 지나 앱에 닿고 앱이 204 와 access-control-allow-origin 을 돌려준다. 장애면 healthy 타깃이 0 이라 LB 가 CORS 헤더 없는 503 을 직접 만들어 돌려주고, 브라우저는 그걸 CORS error 로 표시한다.](/svg/cors_preflight_lb_503.svg)\n\npreflight 의 status 와 `server` 헤더 둘로 가른다.\n\n| preflight   | server     | ACAO | 갈래       | 원인                                                             |\n| ----------- | ---------- | ---- | ---------- | ---------------------------------------------------------------- |\n| `503`       | awselb/2.0 | 없음 | **인프라** | healthy target 0 — 서비스 다운·배포 실패·CrashLoop               |\n| `502`       | awselb/2.0 | 없음 | **인프라** | 타깃이 끊거나 잘못 응답 — 크래시·OOM·포트 불일치                 |\n| `504`       | awselb/2.0 | 없음 | **인프라** | LB idle timeout 초과 — 느린 쿼리·외부 연동 지연                  |\n| `404`       | 앱         | 없음 | **경로**   | path 오타, ingress rule 불일치                                   |\n| `204`·`200` | 앱         | 없음 | **CORS**   | CORS 미설정                                                      |\n| `204`       | 앱         | 있음 | **CORS**   | `access-control-allow-headers` 에 요청 헤더가 빠짐               |\n| `204`       | 앱         | `*`  | **CORS**   | `credentials: 'include'` 와 `*` 는 병용 불가 — 오리진을 명시한다 |\n| `204`       | 앱         | 정상 | **정상**   | CORS 아님. 본 요청의 본문·인증·에러를 본다                       |"},{"i":"594","t":"렌더 흐름에서 규칙이 걸리는 자리","k":["react","ssr","usememo","ref"],"c":"2026-09-02","m":"2026-09-30","b":"![렌더 흐름. 클라이언트: render → commit(DOM 반영·ref 콜백) → useLayoutEffect(페인트 전) → paint → useEffect(페인트 후) → … → 노드 제거(ref cleanup). 서버: render → HTML 문자열, useLayoutEffect 는 실행 안 됨·경고, useEffect 는 실행 안 됨. ① render 중 useMemo: 캐시는 버려질 수 있다, 순수하면 재계산돼도 같은 값, 유지해야 하면 useState(init). ② useLayoutEffect: 페인트 전에 DOM 을 고쳐 flash 를 막는다, 서버엔 이 단계가 없어 경고가 난다, shim 으로 서버에선 useEffect. ③ useEffect: ref.current 가 채워진 뒤라 ownerDocument.defaultView = 노드가 실제로 사는 창. ④ commit 의 ref 콜백이 cleanup 을 반환하고 노드 제거 때 실행된다 → 리스너 수명 = 노드 수명.](/svg/react_render_commit_timeline.svg)\n\n## ① render — 유지돼야 하는 값은 useMemo 가 아니라 state\n\n`useMemo(getRandomColors, [])` 로 만든 값이 HMR 뒤에 바뀐다.\n\n![useMemo(calc, []): 마운트 → 캐시 없음. 캐시 없음 → 렌더·계산 → 캐시 있음(값 v). 캐시 있음 → deps 같음 → 재사용. 캐시 있음 → 캐시 폐기(HMR·첫 마운트 중 suspend·화면 밖) → 캐시 없음. 다시 계산하면 순수는 같은 v 라 드러나지 않고, 랜덤은 다른 v′ 라 값이 바뀐다. useState(init): 마운트·init 1회 → state(값 v). state → 리렌더·HMR → 유지. state → 언마운트 → 끝. 값이 사라지는 길은 언마운트뿐.](/svg/usememo_vs_usestate_lifecycle.svg)\n\n```tsx\nconst resolved = resolveTheme(mode, systemDark) // 순수 파생은 그냥 계산\nconst [colors] = useState(getRandomColors) // 유지가 곧 correctness 면 state\n```\n\n## ② useLayoutEffect — 서버 경고는 shim 으로\n\n![페인트 전에 DOM 을 고쳐야 하나? 아니오 → useEffect. 예 → SSR 도 하나? 아니오 → useLayoutEffect. 예 → useIsoLayoutEffect. useIsoLayoutEffect 는 서버에선 useEffect 가 되어 실행 안 되고 경고도 없으며, 브라우저에선 useLayoutEffect 가 되어 페인트 전에 실행된다.](/svg/layout_effect_ssr_choice.svg)\n\n```ts\nconst useIsoLayoutEffect =\n  typeof window !== 'undefined' ? useLayoutEffect : useEffect\n```\n\n## ③ useEffect — 이벤트는 노드가 사는 window 에\n\n`window.open`·포털로 다른 창에 렌더해도 `window.addEventListener` 는 원래 창을 듣는다.\n\n![컴포넌트 코드는 원래 창에서 돌고, window.open·포털로 렌더한 DOM 노드(ref.current)는 새 창에 산다. window.addEventListener → 원래 창의 window 에 붙는다, 노드가 없는 창이다. ref.current.ownerDocument.defaultView → 새 창의 window 에 붙는다, 노드가 사는 창이다.](/svg/listener_owner_window.svg)\n\n```ts\nconst win = ref.current?.ownerDocument.defaultView ?? window\n```\n\n멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트에만 필요하다.\n\n## ④ ref 콜백 — cleanup 을 반환한다 (React 19)\n\n![컴포넌트가 마운트된 동안 DOM 노드가 A 에서 B 로 교체되고, 마지막에 언마운트된다. useEffect + ref: effect 가 한 번 돌아 ref.current(=A) 에 리스너를 붙인다. A 가 떠난 뒤에도 리스너는 떠난 A 에 남아 언마운트까지 가고, B 에는 리스너가 없다. ref 콜백: ref(A) 가 A 에 붙이고 A 가 떠날 때 cleanup 이 abort() 로 떼며, ref(B) 가 B 에 붙이고 B 가 떠날 때 cleanup 이 뗀다. effect 는 컴포넌트 수명, ref 콜백은 노드 수명을 따른다.](/svg/ref_callback_listener_lifetime.svg)\n\n```tsx\n<div\n  ref={(node) => {\n    const ctrl = new AbortController()\n    node.addEventListener('scroll', onScroll, { signal: ctrl.signal })\n    return () => ctrl.abort()\n  }}\n/>\n```"},{"i":"593","t":"Chrome 외부 앱 딥링크 \"항상 열기\" 해제","k":["chrome","deep-link","troubleshooting","macos"],"c":"2026-09-01","m":"2026-09-30","b":"커스텀 스킴(`myapp://`) 다이얼로그에서 \"항상 열기 허용\"을 실수로 체크해 되돌리고 싶을 때. **설정 UI 에는 항목이 없다** — 체크는 프로필의 `Preferences` JSON 에 origin 단위로 저장된다.\n\n```json\n\"protocol_handler\": {\n  \"allowed_origin_protocol_pairs\": { \"https://example.com\": { \"myapp\": true } }\n}\n```\n\n![\"항상 열기\"를 체크하면 허용이 origin 단위로 저장된다. chrome://settings/handlers 는 웹 프로토콜 핸들러(mailto: 등)만 다루는 별개 화면이라 여기서는 못 푼다. ① chrome://settings/content/all 에서 사이트 데이터를 삭제한다 → 재시작해 다이얼로그가 다시 뜨면 해제된 것이다. 안 뜨면 ② Chrome 을 백그라운드까지 완전 종료하고 → Preferences 에서 그 origin 의 스킴 항목을 지운 뒤 → 재시작해 다시 확인한다. ② 를 건너뛰고 실행 중에 편집하면 종료 시 메모리 상태로 덮어써 되돌아간다. excluded_schemes 에 true 인 스킴은 반대로 차단 상태이고, 같은 파일에서 푼다.](/svg/chrome_external_protocol_reset.svg)\n\n| OS      | 경로                                                                |\n| ------- | ------------------------------------------------------------------- |\n| macOS   | `~/Library/Application Support/Google/Chrome/<Profile>/Preferences` |\n| Windows | `%LOCALAPPDATA%\\Google\\Chrome\\User Data\\<Profile>\\Preferences`      |\n| Linux   | `~/.config/google-chrome/<Profile>/Preferences`                     |"},{"i":"582","t":"Next.js SSG + S3 + CloudFront 캐시 정책","k":["aws","cloudfront","nextjs","cache","deploy"],"c":"2026-09-01","m":"2026-09-30","b":"정적 SPA에서 Next.js SSG로 옮길 때 **배포해도 구버전 HTML이 서빙되는** 문제를 헤더 설계로 해결한다. `example.com` 기준.\n\n## 왜 문제가 생기는가\n\nSPA는 HTML이 `index.html` 하나라 진입점만 무효화하면 됐다. SSG는 라우트마다 HTML 오브젝트가 생기고 각자 **독립된 TTL**로 엣지에 캐시된다.\n\n`Cache-Control` 이 없으면 CloudFront는 Default TTL(미설정 시 86400초), 브라우저는 휴리스틱 캐싱(`(Date - Last-Modified) × 10%`)을 적용한다. 그 동안 엣지는 오리진에 가지 않는다 — 로컬에서 재현 안 돼도 문제다.\n\n## 디버깅 — 오리진 → 엣지 → 브라우저 순으로 좁힌다\n\n배포했는데 구버전이 보이면 **어느 층이 구버전을 들고 있는지**부터 가른다. 안쪽부터 본다 — 오리진이 구버전이면 바깥을 볼 이유가 없다.\n\n### 1. 오리진에 새 HTML이 올라갔나\n\n```bash\naws s3api head-object --bucket example.com --key index.html\n```\n\n- `LastModified` 가 배포 시각보다 이전 → 배포가 안 됐다. buildspec 2단계 로그를 본다\n- `CacheControl` 이 없다 → 헤더 미적용. 2단계에서 `Hit` 로 드러난다\n\n### 2. 엣지가 무엇을 내보내나\n\n```bash\ncurl -sI https://example.com/  | grep -iE 'cache-control|x-cache|age'\ncurl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'\n```\n\n![Miss: 브라우저 → 엣지 → 오리진(S3)까지 가서 새로 받는다. 엣지 캐시가 비어 있었을 뿐 최신이라는 뜻은 아니다. Hit: 브라우저 → 엣지에서 멈추고 오리진에는 가지 않은 채 캐시본을 돌려준다 — 구버전이 서빙되는 지점이다. RefreshHit: 브라우저 → 엣지 → 오리진에 재검증 → 304 → 엣지가 캐시본을 돌려준다 — max-age=0 의 정상 동작이고 HTML 의 목표 상태다. 엣지의 TTL 타이머는 POP·경로별로 독립이라 새 화면과 구 화면이 재현성 없이 섞인다.](/svg/cloudfront_xcache_paths.svg)\n\n- `cache-control` 없음 + `Hit` → 헤더 미적용 → [헤더 레시피](#오브젝트-종류별로-정반대-헤더) + 전환 최초 1회 `/*` 무효화\n- `max-age=0` 인데 `Hit` + `age` 증가 → **Minimum TTL이 오리진 헤더를 무시**. Cache Policy 수정\n- `RefreshHit` · `Miss` → 엣지는 정상. 3으로\n\n약한 ETag(`W/\"...\"`)·`vary: Accept-Encoding`은 엣지 자동 압축 흔적이다. 무시한다.\n\n### 3. 브라우저만 구버전인가\n\n`curl` 은 새 버전인데 브라우저만 구버전 → 헤더 없이 받았던 응답을 휴리스틱으로 들고 있다. **invalidation이 못 닿는다.** 만료를 기다린다. 헤더 적용 이후 배포분부터는 재발하지 않는다.\n\n### 곁증상\n\n| 증상                     | 원인                                                 | 처방                                         |\n| ------------------------ | ---------------------------------------------------- | -------------------------------------------- |\n| 배포 직후 lazy chunk 404 | 1단계에 `--delete`, 또는 HTML이 에셋보다 먼저 올라감 | 순서·`--delete` 위치                         |\n| 일부 오브젝트만 403      | 한쪽 sync에 `--acl public-read` 누락                 | 두 명령 모두에                               |\n| `--acl` 자체가 에러      | 버킷이 `BucketOwnerEnforced`                         | `--acl` 빼고 OAC + 버킷 정책                 |\n| 3단계 AccessDenied       | 롤에 `cloudfront:CreateInvalidation` 없음            | [3단계 무효화](#3단계-무효화--어디까지-닿나) |\n\n## 오브젝트 종류별로 정반대 헤더\n\n| 대상                  | Cache-Control                       | 이유                                               |\n| --------------------- | ----------------------------------- | -------------------------------------------------- |\n| `_next/static/**`     | `public,max-age=31536000,immutable` | 파일명에 콘텐츠 해시 — 내용이 바뀌면 이름이 바뀐다 |\n| `*.html`, 데이터 파일 | `public,max-age=0,must-revalidate`  | 매 요청 재검증, 안 바뀌었으면 304                  |\n\n```yaml\nversion: 0.2\n\nenv:\n  variables:\n    BUCKET: example.com\n\nphases:\n  post_build:\n    commands:\n      # 1) 해시 에셋: 영구 캐시, 삭제 없음\n      - aws s3 sync ./out/_next/static s3://$BUCKET/_next/static\n        --acl public-read\n        --cache-control 'public,max-age=31536000,immutable'\n\n      # 2) HTML·데이터: 매 요청 재검증, 구 HTML 정리\n      - aws s3 sync ./out s3://$BUCKET\n        --acl public-read\n        --exclude '_next/static/*'\n        --cache-control 'public,max-age=0,must-revalidate'\n        --delete\n\n      # 3) 엣지 무효화 (권한 있을 때만 — 아래 참고)\n      - export CF_DIST_ID=$(aws cloudfront list-distributions\n        --query \"DistributionList.Items[?Aliases.Items[?contains(@, '$BUCKET')]].Id\"\n        --output text)\n      - test -n \"$CF_DIST_ID\" || (echo \"CF_DIST_ID 조회 실패\" && exit 1)\n      - aws cloudfront create-invalidation --distribution-id $CF_DIST_ID --paths '/*'\n```\n\n**순서를 지켜야 한다**\n\n- **에셋(1) → HTML(2).** 단일 sync는 순서를 보장하지 않아, HTML이 먼저 가면 청크가 없는 창이 생긴다. 이 순서면 2가 실패해도 \"새 에셋 + 구 HTML\"로 정상 동작한다.\n- **1단계는 `--delete` 없음.** 구 해시 에셋이 남아야 열려 있던 탭의 lazy chunk 404가 안 난다.\n- **`--acl public-read`는 둘 다에.** 빠지면 그 오브젝트가 403.\n\n### 3단계 무효화 — 어디까지 닿나\n\n![create-invalidation --paths '/*' 는 모든 엣지 POP 의 캐시본을 비운다. 브라우저 캐시(휴리스틱)에는 닿지 않는다. 과금은 경로 단위라 경로 200개를 열거하면 200 paths, /* 는 몇 개를 매칭하든 1 path 이고 월 1,000 path 까지 무료다. 헤더 적용 전에는 Cache-Control 없는 구 HTML 이 엣지에 Default TTL(24시간) 동안 남으므로 전환 최초 1회는 무효화가 필수다. 헤더 적용 후에는 HTML 이 max-age=0 이라 엣지가 매 요청 재검증하므로 무효화는 즉시 반영을 위한 보험일 뿐이다.](/svg/cloudfront_invalidation_reach.svg)\n\n## 확인\n\n```bash\naws s3api head-object --bucket example.com --key index.html\naws s3api head-object --bucket example.com --key '_next/static/<hash>/...'\ncurl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'\n```\n\n| 대상              | cache-control                       | x-cache                   | age       |\n| ----------------- | ----------------------------------- | ------------------------- | --------- |\n| HTML              | `public,max-age=0,must-revalidate`  | 반복 요청 시 `RefreshHit` | 없거나 0  |\n| `_next/static/**` | `public,max-age=31536000,immutable` | `Hit`                     | 계속 증가 |\n\nHTML이 `Hit` + `age` 증가일 때만 Minimum TTL을 본다."},{"i":"580","t":"MySQL TIME × 언어 타입 폭 불일치","k":["mysql","java","mybatis","typeorm","type-design","error-handling"],"c":"2026-08-31","m":"2026-09-30","b":"운영시간 목록 API가 통째로 500이고, 프론트는 그걸 \"선택 가능한 날짜가 없는 정상 달력\"으로 그린다. 원인은 `operation_hours`의 `to = 24:00:00` 행 하나였다.\n\n![1 원인 행 찾기 → 2 값 감사 → 4 정규화 결정 → 데이터 핫픽스 → 5 TypeHandler → 6 쓰기 제약 순으로 백엔드를 고친다. 4 를 고르기 전에 3 정의역 확인을 본다. 5 → 6 은 읽기가 먼저다 — 쓰기부터 막으면 이미 들어 있는 행 때문에 여전히 500 이다. 7 결핍 상태 분리 렌더는 프론트에서 백엔드와 별개로 지금 바로 할 수 있다. 8 타입 폭 감사 → 스키마 재설계 검토는 예방이다.](/svg/mysql_time_recipe_order.svg)\n\n## 1. 스택트레이스 최하단\n\n```\nDateTimeException: Invalid value for HourOfDay (valid values 0 - 23): 24\n  at java.time.LocalTime.of\n  at com.mysql.cj.result.LocalTimeValueFactory...\n```\n\n`ValueFactory`·`TypeHandler`·`ResultSetImpl` 프레임의 `DateTimeException`이면 매핑 실패다. `24`·`HourOfDay`가 원인 행의 값 → 2의 조건. 로그에 cause 체인이 없으면 2부터 돌려 역으로 좁힌다.\n\n## 2. 도메인 밖 값 감사 — 핫픽스 전에\n\n```sql\nSELECT owner_id, slot_type, `from`, `to`\nFROM operation_hours\nWHERE TIME_TO_SEC(`from`) >= 86400   -- 24시 이상\n   OR TIME_TO_SEC(`to`)   >= 86400\n   OR TIME_TO_SEC(`from`) <  0       -- MySQL TIME은 음수 허용\n   OR `from` > `to`;                 -- 익일 걸침 (별개 문제)\n```\n\n문자열 비교는 `'9:00:00'` 같은 비제로패딩에서 틀리니 `TIME_TO_SEC`. `from > to`(야간 걸침)는 다른 요구라 따로 세서 별건으로 뺀다.\n\n## 3. 정의역 대조 — 데이터가 더러운가, 타입이 좁은가\n\nMySQL `TIME`은 `-838:59:59 ~ 838:59:59`인 duration 타입이라 `24:00:00`은 합법값이다.\n\n| 언어/타입              | 정의역                       | TIME 전 범위  |\n| ---------------------- | ---------------------------- | ------------- |\n| Java `LocalTime`       | `00:00 ~ 23:59:59.999999999` | ✗             |\n| Java `Duration`        | 사실상 무제한                | ✓             |\n| TS `string`            | 제약 없음                    | ✓ (검증 없음) |\n| Python `datetime.time` | `00:00 ~ 23:59:59.999999`    | ✗             |\n| Go `time.Duration`     | ±292년                       | ✓             |\n\n![기준선은 MySQL TIME 컬럼의 정의역 -838:59:59 ~ 838:59:59 다. 쓰는 쪽 string·Duration 은 이 범위를 전부 덮는다. 읽는 쪽 LocalTime·datetime.time 은 00:00 ~ 24:00 미만만 덮어서, 그 바깥 구간은 컬럼에 들어갈 수 있는데 읽으면 예외가 난다. 24:00:00 과 -01:00:00 이 그 구간에 있다 — 컬럼 안, LocalTime 밖. 넓은 쓰기 / 좁은 읽기면 시한폭탄이고 반대 방향이면 안전하다. 눈금은 비례가 아니다.](/svg/mysql_time_domain_width.svg)\n\n읽는 쪽 `LocalTime`이 좁은 것이다. 정타입 매핑은 `Duration`(Connector/J 8 `rs.getObject(col, Duration.class)`). `zeroDateTimeBehavior`는 DATE/DATETIME 전용이라 우회가 안 된다.\n\n## 4. 정규화 후보를 슬롯 계산에 넣어 본다\n\n`UPDATE` 전에 **두 서비스의** 슬롯 계산을 읽고 후보를 넣어 본다.\n\n| 후보                | 결과                                     | 확인할 것                                               |\n| ------------------- | ---------------------------------------- | ------------------------------------------------------- |\n| `23:59:59`          | 1초 손실. 무해한 편                      | 슬롯 생성이 `to`를 exclusive로 쓰면 23:30 슬롯이 사라짐 |\n| `00:00:00`          | `from > to`가 되어 범위 비교 전부 뒤집힘 | 익일 처리 분기 없으면 **조용히 영업시간 0분**           |\n| `Duration`으로 승격 | 손실 없음                                | 하위 계산 로직 전부 수정                                |\n\n![지금: to = 24:00:00 이라 영업 구간이 from 부터 24:00 까지이고 마지막 슬롯 23:30~24:00 이 있지만 LocalTime 이 못 읽는다. 23:59:59 로 바꾸면 1초가 줄어, 슬롯 생성이 to 를 exclusive 로 쓰면 23:30 슬롯이 들어가지 못한다. 00:00:00 으로 바꾸면 to 가 from 보다 앞이 되어 범위 비교가 전부 뒤집히고, 익일 분기가 없으면 예외 없이 영업시간 0분이 된다. Duration 으로 승격하면 손실 없이 24:00 까지 그대로지만 하위 계산 로직을 전부 고쳐야 한다. 눈금은 비례가 아니다.](/svg/mysql_time_normalize_choices.svg)\n\n`00:00:00`은 예외가 안 나서 지금보다 나쁘다 — 터지는 버그가 침묵하는 버그가 된다.\n\n## 5. 읽기 방어 — TypeHandler\n\n```java\n@MappedTypes(LocalTime.class)\npublic class SafeLocalTimeTypeHandler extends BaseTypeHandler<LocalTime> {\n  @Override\n  public LocalTime getNullableResult(ResultSet rs, String col) throws SQLException {\n    String raw = rs.getString(col);\n    if (raw == null) return null;\n    Duration d = parseMysqlTime(raw);\n    return d.compareTo(Duration.ofDays(1)) >= 0\n        ? LocalTime.MAX.truncatedTo(ChronoUnit.SECONDS)  // 23:59:59\n        : LocalTime.ofSecondOfDay(d.getSeconds());\n  }\n  // ...\n}\n```\n\n## 6. 쓰기 제약 — 읽기 다음에\n\n쓰기부터 막으면 이미 들어 있는 행 때문에 여전히 500이다. 2를 다시 돌려 0건인지 보고 건다.\n\n```sql\nALTER TABLE operation_hours\n  ADD CONSTRAINT chk_operation_hours_range\n  CHECK (TIME_TO_SEC(`to`) <= 86340 AND TIME_TO_SEC(`from`) >= 0);\n```\n\nMySQL 8.0.16+. DDL이 성공하면 위반 행이 없다는 확인이기도 하다. 낮은 버전의 TypeORM validator는 배치·수동 SQL로 뚫린다.\n\n## 7. 프론트 — 별개 티켓\n\n`useQuery` 에러 → `hours = undefined` → 모든 날짜 `null` → 달력 통째로 비활성. `isPending` / `isError` / 빈 배열을 따로 그리고 실패엔 재시도 UI. 빈 배열은 \"휴무\", 못 가져옴은 \"다시 시도\"다.\n\n## 8. 예방 — 같은 폭탄 찾기\n\n공유 테이블의 컬럼마다 스택별 매핑 타입을 **DB 도메인 위에 겹친다**(3의 그림). 스키마 파일끼리 비교하면 양쪽 다 `time`이라 안 잡힌다. 넓은 쓰기 / 좁은 읽기가 시한폭탄이다.\n\n| 컬럼 타입             | 넓은 쪽            | 좁은 쪽                    |\n| --------------------- | ------------------ | -------------------------- |\n| `TIME`                | string, Duration   | LocalTime, `datetime.time` |\n| `DECIMAL(20,0)`       | BigDecimal, string | JS `number` (2^53)         |\n| `BIGINT`              | int64              | JS `number`                |\n| `VARCHAR` (utf8mb4)   | string             | 이모지 미대응 정규식 검증  |\n| `DATE` (`0000-00-00`) | string             | `LocalDate`                |\n\n## 장기 · 미해결\n\n- `TIME` 대신 자정 기준 분 `SMALLINT`(0~1440): `24:00 = 1440`, 양 언어 모두 정수라 폭이 같고, `CHECK (col BETWEEN 0 AND 1440)`. 별건 티켓.\n- `24:00:00`이 어디서 들어왔나 — 어드민 UI면 validator로 충분, 배치면 DB 제약 필수.\n- 휴무 행의 `from`/`to`를 애초에 읽어야 하나 — 안 읽어도 되면 5보다 싸다."},{"i":"578","t":"useMemo 캐시 적중 진단 — 매 렌더 새로 만들어지는 deps","k":["react","usememo"],"c":"2026-06-23","m":"2026-09-30","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n무거운 계산을 `useMemo`로 감쌌는데 렌더 시간이 줄지 않는다. 각 단계는 질문 하나에 답하고, 그 답이 다음 질문을 만든다. 단계마다 **추론이 아니라 관찰로** 넘어간다.\n\n![1 렌더가 느린가(Profiler ms) → 2 재사용되나(calc 횟수) → 3 왜 다시 계산하나(Object.is 로 참조 비교) → 4 누가 새 참조를 만드나(부모로 거슬러 올라가 useMemo) → 5 고쳐졌나(같은 조작으로 횟수와 ms 비교). 1 에서 병목이 아니면 멈춘다. 5 에서 둘 다 줄었으면 해결이고, 횟수는 줄었는데 ms 가 그대로면 병목이 다른 곳이라 1 로 돌아간다. 6 값이 틀리다면 느린 게 아니라 별개 축이다 — stale closure 는 exhaustive-deps 로, 비결정 계산은 두 번 호출 결과 비교나 HMR 로 확인한다.](/svg/usememo_debug_flow.svg)\n\n## 1. 정말 이 계산이 느린가\n\nReact DevTools **Profiler**로 조작 하나를 녹화하고, flamegraph에서 그 컴포넌트의 렌더 시간(ms)을 본다. 여기서 이 컴포넌트가 병목이 아니면 `useMemo`를 볼 이유가 없다. 이 숫자는 5에서 수정 전후를 비교할 기준선이 된다.\n\n## 2. 정말 재사용되고 있나\n\n계산 함수 안에 카운터를 넣고 리렌더를 몇 번 일으킨다.\n\n```jsx\nconst rows = useMemo(() => {\n  console.count('calc')\n  return items.filter(fn)\n}, [items])\n```\n\n`calc`가 렌더와 **같이** 늘어나면 한 번도 재사용되지 않고 있다. 개발 모드에서는 React가 계산 함수를 **일부러 두 번 부르므로**[^react-usememo] 렌더당 2씩 뛰는 게 정상이다 — 보는 건 절대값이 아니라 렌더를 따라 늘어나는지다.\n\n**CDP로 읽을 때.** `Runtime.enable` 뒤 `Runtime.consoleAPICalled`가 `type: \"count\"`로 온다. 숫자는 따로 오지 않고 `args[0].value`에 `\"calc: 3\"` 같은 문자열 하나로 오므로 `/: (\\d+)$/`로 꺼낸다. `console.countReset`은 이벤트를 내지 않는다. `Runtime.enable` 전에 찍힌 메시지도 enable 시점에 다시 보내준다.[^cdp-observed]\n\n**코드를 고치지 않고 셀 때.** 앱에 로그를 심을 수 없으면 V8 커버리지의 호출 횟수를 쓴다.\n\n```js\nawait send('Profiler.enable')\nawait send('Profiler.startPreciseCoverage', { callCount: true, detailed: true })\n// 리렌더 한 번 일으키고\nconst { result } = await send('Profiler.takePreciseCoverage')\n// result[].functions[].ranges[0].count = 그 사이 호출 횟수\n```\n\n`takePreciseCoverage`는 부를 때마다 카운터를 0으로 되돌린다.[^cdp-observed] 리렌더마다 한 번씩 부르면 그 렌더 동안의 호출 횟수가 바로 나온다. 다만 `useMemo(() => …)`의 화살표 함수는 `functionName`이 비어 있어 스크립트 `url`과 `ranges` 오프셋으로 찾아야 하고(번들이면 소스맵으로 되짚는다), 개발 모드의 두 번 호출도 그대로 세어진다.\n\n## 3. 왜 매번 다시 계산하나\n\n계산 쪽이 아니라 `deps`를 의심한다. React는 재료 **안을 들여다보지 않고** `Object.is`로 *같은 물건인지*만 본다.[^react-usememo] 매 렌더 새로 만들어지는 값이 `deps`에 있으면 내용이 같아도 매번 \"다른 재료\"다.\n\n의심만 하지 말고 참조가 실제로 바뀌는지 찍는다.\n\n```jsx\nconst prev = useRef(items)\nuseEffect(() => {\n  console.log('items same?', Object.is(prev.current, items))\n  prev.current = items\n})\n```\n\n매 렌더 `false`면 확정이다. Profiler 설정의 \"Record why each component rendered while profiling\"을 켜면 코드 없이 `Props changed: items`로도 보인다.\n\n## 4. 누가 새 참조를 만드나\n\n같은 비교를 부모 쪽에 두고 거슬러 올라간다. 렌더 중에 새 배열을 만들어 넘기고 있었다.\n\n```jsx\n<List items={data.map(transform)} />\n```\n\n넘기는 쪽에서 참조를 붙잡아 둔다.\n\n```jsx\nconst items = useMemo(() => data.map(transform), [data])\n```\n\n<AutoIframe\n  src=\"/iframe/useMemo_cache_behavior.html\"\n  title=\"items 참조가 안정할 때와 매 렌더 새로 만들어질 때의 재계산 횟수 비교\"\n/>\n\n[^react-usememo]: _\"React will compare each dependency with its previous value using the `Object.is` comparison.\"_ / _\"React will not throw away the cached value unless there is a specific reason to do that … This should be fine if you rely on `useMemo` solely as a performance optimization.\"_ / _\"In Strict Mode, React will call your calculation function twice in order to help you find accidental impurities.\"_ ([useMemo — React](https://react.dev/reference/react/useMemo))\n\n[^cdp-observed]: 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."},{"i":"576","t":"dayjs 날짜 표기 카탈로그","k":["typescript","dayjs","date-format","type-safety"],"c":"2026-06-28","m":"2026-10-01","b":"## 1. 문제 — 날짜 표기를 한곳으로 모으고 싶다\n\n화면마다 `dayjs().format('…')`이 흩어져 같은 날짜가 다르게 찍힌다. 날짜 표기를 받는 인자를 타입으로 닫아 허용된 표기만 쓰게 하고 싶다.\n\n## 2. 첫 발상 — 매핑 객체\n\n표기 이름과 포맷 문자열을 짝짓는 객체(`{ DATE: 'YYYY-MM-DD', … }`)부터 떠올랐다. i18n 습관이다 — i18n은 이름표(`인사`)와 내용(`Hello`)이 달라서 짝지어 주는 객체가 필수였다.\n\n그런데 dayjs는 **포맷 문자열이 곧 키이자 값이다.**[^dayjs-format] 이름을 따로 붙이면 같은 정보를 두 번 들고 있게 된다. 매핑 객체는 필요 없다.\n\n## 3. 해결 — 배열 하나에서 파생\n\n허용 표기를 `as const` 배열 하나에 두고, 타입과 진입점을 거기서 파생한다.\n\n```ts\nimport dayjs from 'dayjs'\n\nexport const DATE_FORMATS = [\n  'YYYY-MM-DD',\n  'YYYY-MM-DDTHH:mm:ss',\n  'HH:mm',\n  'YYYY년 M월',\n  'YY년 M월 D일',\n  'YYYY.MM.DD',\n  'YY.MM.DD',\n  'YYYY.MM.DD(ddd)',\n  'YYYY.MM.DD HH:mm',\n] as const\n\nexport type DateFormat = (typeof DATE_FORMATS)[number]\n\nexport const formatDate = (\n  date: dayjs.ConfigType,\n  format: DateFormat,\n): string => dayjs(date).format(format)\n```\n\n- `DATE_FORMATS` — 허용 표기의 단일 출처\n- `DateFormat` — 배열에서 파생된 닫힌 union. 따로 손댈 곳이 없다\n- `formatDate` — 유일한 진입점\n\n## 4. 새 표기가 필요해지면\n\n1. **여러 곳에서 통일이 필요한 표기인지** 먼저 본다. 일회성(`'M.D'` 등)이면 raw `dayjs().format()`으로 둔다 — 이 기준이 없으면 배열이 잡동사니 enum이 된다.\n2. `DATE_FORMATS`에 추가한다. 그게 전부다.\n3. `ddd`가 들어 있으면 진입점에 `dayjs.locale('ko')`가 있는지 본다.[^dayjs-i18n]\n\n## 5. 남은 구멍\n\n- **raw 우회는 타입이 못 막는다.** 카탈로그에 있는 표기를 `formatDate` 없이 raw로 다시 써도 오류가 없다. [no-raw-date-format](/rules/no-raw-date-format.md)(`no-restricted-syntax`)이 필요한데 아직 안 썼다. 그 규칙 전까지 단일 출처는 관행일 뿐이다.\n- **`ddd`는 조용히 샌다.** `dayjs.locale('ko')` 없이 쓰면 `Mon`·`Tue`처럼 영어로 나오고, 예외도 타입 오류도 없다. `YYYY.MM.DD(ddd)`만 런타임 전제를 가진 항목이다.\n\n---\n\n[^dayjs-format]: [dayjs — Format](https://day.js.org/docs/en/display/format)\n\n[^dayjs-i18n]: [dayjs — Loading locale](https://day.js.org/docs/en/i18n/loading-into-nodejs)"},{"i":"575","k":["react","suspense","declarative"],"c":"2026-06-23","m":"2026-09-30","b":"Suspense는 **비동기를 동기처럼 보이게** 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다. 그래서 상태를 조율하는 대신 **입력을 미루고 → 질의하고 → 그리는** 순서로 끝난다.\n\n**언제**: 비동기 데이터를 그리는데 로딩·에러가 늘면서 화면이 튈 때. 또는 새로 만들면서 어느 쪽으로 갈지 고를 때.\n\n![각각 들고 있으면: loading 과 error 가 따로 켜지고 꺼져 네 칸이 생긴다 — 둘 다 꺼짐은 성공, error 만 켜짐은 에러, loading 만 켜짐은 로딩, 둘 다 켜짐은 있을 수 없는 \"로딩 + 에러\"인데도 적을 수 있다. 그 칸이 한 프레임이라도 그려지면 화면이 튄다. 트리로 옮기면: 로딩(Suspense) → resolve → 성공(본문), 로딩 → reject → 에러(ErrorBoundary), 성공 → 입력 변경으로 promise 를 던지면 → 로딩. 상태는 셋 중 하나라 넷째 칸을 적을 수가 없다.](/svg/suspense_state_collapse.svg)\n\n**절차**\n\n1. 검색어·결과·로딩 중인지·에러인지를 **각각** 들고 있는지 본다.\n2. 분기를 `if/else`에서 **트리로 옮긴다** — 로딩은 `<Suspense>`, 에러는 `<ErrorBoundary>`, 성공은 본문.\n3. 본문에 loading·error를 인자로 넘기지 않는다. 안 넘겨도 **가장 가까운 경계가 대신 잡기** 때문이다 — `render`는 결과 하나만 받는다.\n\n**확인**: 있을 수 없는 조합을 적을 수 있는가. 적을 수 **없으면** 된 것이다.\n\n**함정**: 스피너가 너무 빨리 뜨고 사라지는 건 이 축으로 안 고쳐진다 — 다른 축이다 → [573](/memo/573)\n\n---\n\n곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler와 같다.[^algebraic-effects]\n\n[^algebraic-effects]: `try/catch`와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 **처리하고 끝나지 않는다**는 것이다. handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다. ([Algebraic Effects for the Rest of Us](https://overreacted.io/algebraic-effects-for-the-rest-of-us/))\n\nstale-while-revalidate(두 시계·isStale) 부분은 [570](/memo/570)으로."},{"i":"573","t":"로딩 스피너 깜빡임의 두 끝 — 앞쪽은 CSS, 뒤쪽은 JS","k":["react","css","loading-ux"],"c":"2026-06-23","m":"2026-09-30","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n스피너가 한 프레임 번쩍하고 사라지거나, 떴다가 하드컷으로 끊긴다. 깜빡임은 **양 끝에서 따로 생기는데** CSS는 한쪽만 잡는다. 어느 끝인지는 응답 시각을 붙잡고 **보인 시간**을 재서 가른다 — DOM에 붙어 있던 시간이 아니다.\n\n![요청 뒤 CSS 지연이 지나야 스피너가 뜬다. 앞쪽: 응답이 지연보다 먼저 오면 스피너는 한 프레임도 안 뜬다 — 지연이 통째로 건너뛰게 한다. 뒤쪽, CSS 만: 지연 뒤에 뜬 스피너가 곧바로 온 응답에 끊겨 짧게 번쩍하고 하드컷된다. 뒤쪽, useMinimumLoading: 응답이 와도 unmount 를 밀어 최소 노출 시간만큼은 보이게 한다. CSS 는 시작(지연)만 정하고, 끝은 응답 시각이 정하는데 CSS 는 그 값을 모른다.](/svg/spinner_flicker_two_ends.svg)\n\n## 흐름\n\n![Claude Code 의 node 스크립트가 Chrome(CDP)에 Fetch.enable 로 */api 를 가로채게 하고, 경계값을 볼 때는 Tracing.start 로 스크린샷도 켠다. hold 를 100·350·700 으로 바꿔 가며 반복한다: 프로브 evaluate 를 기다리지 않고 보낸 뒤 console.timeStamp 와 클릭을 보낸다. 페이지에 스피너가 붙고 fetch /api 가 나가면 Chrome 이 Fetch.requestPaused 를 보내고, 스크립트는 holdMs 를 기다린 뒤 Fetch.continueRequest 로 놓아준다. 요청이 서버에 가고 응답이 오면 스피너가 제거되고 프로브가 firstVisibleAt·visibleFrames·visibleMs 를 JSON 으로 돌려준다. 끝나면 Tracing.end 로 Screenshot 이벤트를 받는다 — 처음 물든 장부터 다음 흰 장까지가 실제로 보인 시간이고, 프로브는 1프레임쯤 더 센다.](/svg/cdp_spinner_measure_sequence.svg)\n\n1. headless Chrome + Node 전역 `WebSocket`으로 raw CDP. MCP `emulate`는 프리셋 스로틀링뿐이라 요청 하나를 못 붙잡는다.\n2. 프로브 — 스피너가 사라질 때 보인 시간을 돌려준다.\n3. 경계값이면 `disabled-by-default-devtools.screenshot` trace로 확인한다. 스크린샷은 바뀐 프레임에만 찍히니 처음 물든 장 → 다음 흰 장이 보인 시간이다.\n4. 응답 < 지연인데 보였으면 앞쪽, 응답 > 지연인데 최소 노출보다 짧으면 뒤쪽.\n\n| 지연 | 응답 | 최소 노출 | 프로브          | trace                         | 판정           |\n| ---- | ---- | --------- | --------------- | ----------------------------- | -------------- |\n| 0    | 100  | —         | 9프레임 · 133ms |                               | **앞쪽**       |\n| 300  | 100  | —         | 0               | 물든 장 없음                  | 앞쪽 억제됨    |\n| 300  | 350  | —         | 4프레임 · 66ms  | 약 48ms, 불투명도 최대 약 0.8 | **뒤쪽**       |\n| 300  | 350  | 500       | 515ms           |                               | 뒤쪽 잡힘      |\n| 300  | 100  | 500       | 0               |                               | 앞쪽 억제 유지 |\n\n실측 조건은 각주에 있다.[^spinner-observed] 셋째 줄이 뒤쪽 깜빡임의 실제 모습이다 — 페이드가 끝나기 전에 끊겨 **한 번도 제대로 진해지지 못하고** 사라진다.\n\n## 처방\n\n- **앞쪽** — `animation-delay`/`transition-delay`. 응답이 지연보다 빠르면 스피너를 통째로 건너뛴다. `@starting-style` 단독(지연 0)으로는 못 막는다.\n- **뒤쪽** — CSS는 시작만 통제하고 끝은 CSS가 모르는 응답 시각이 정하니, 사라지는 시각을 JS에서 민다(`useMinimumLoading`). 최소 노출은 **처음 보인 시각부터** 잰다: 사라지는 시각 = `max(응답 시각, firstVisibleAt + 최소 노출)`. 요청 시점부터 재면 표 마지막 줄에서 안 떠도 될 스피너를 붙잡는다.\n\n## 밟은 것\n\n- `spawn`한 프로세스만 죽이면 Chrome 자식이 남아 다음 실행이 옛 페이지에 붙는다 → `detached: true` + `process.kill(-pid)`.\n- 옛 페이지에 붙은 채 `Runtime.enable`하면 이전 console 메시지가 다시 온다.\n- 포트가 뜨기 전 `/json`은 `ECONNREFUSED` → 폴링.\n- 첫 측정은 느리다(hold 100에 응답 149ms) → 첫 줄은 다시 잰다.\n\n<AutoIframe\n  src=\"/iframe/loading_flicker_explorer.html\"\n  title=\"지연·지속 시간·응답 시각과 최소 노출 시간을 조절하며 앞쪽 억제와 뒤쪽 깜빡임이 어떻게 달라지는지 관찰\"\n/>\n\n곁가지 — `@starting-style`은 **같은 뿌리의 반대편**이다. transition은 두 상태 사이를 보간하는데, 진입에는 옮겨갈 이전 상태가 없고[^starting-style] 퇴장에는 요소 자체가 이미 사라진다. `@starting-style`은 앞쪽의 출발값을 대신 정해주고(진입 fade-in), `useMinimumLoading`은 뒤쪽의 unmount를 미룬다. 뿌리는 같아도 **손대는 지점이 달라 별개 결정**이다.\n\n[^starting-style]: CSS transition은 요소의 첫 스타일 적용이나 `display: none` → 표시 전환에서는 기본적으로 발동하지 않는다. `@starting-style`이 \"무엇에서 출발할지\"를 정의해 그걸 가능하게 한다. ([@starting-style — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style))\n\n[^spinner-observed]: Chrome 154 headless 에서 raw CDP 로 실측 (2026-09-30). `/api` 요청을 `Fetch.requestPaused` 로 100·350ms 붙잡고, 스피너(`animation: fade 100ms <지연> both`)의 `opacity` 를 매 `requestAnimationFrame` 마다 읽었다. 프로브를 먼저 걸고 버튼 클릭으로 스피너를 띄운 실행에서도 hold 100 → 0프레임, hold 350 → 2프레임(34ms)으로 같았다. 프로브 대 trace 비교는 스피너를 400px 빨간 상자로 두고 `Tracing` 의 `Screenshot` 이벤트를 받아, JPEG 를 `sips` 로 BMP 로 바꿔 상자 안 한 점의 픽셀을 읽었다 — hold 350 에서 `(255,199,200)` → `(255,109,110)` → `(255,51,52)` → 흰색."},{"i":"572","t":"요청 취소의 경계","k":["http","abort","cancellation"],"c":"2026-06-23","m":"2026-10-01","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n**HTTP 요청 취소는 클라이언트에서만 일어난다.**[^fetch-abort] 이미 보낸 바이트를 회수하지도, 서버가 하고 있는 일을 멈추지도 못한다. **취소한 순간에 요청 전송이 이미 끝나 있었나.** 끝나 있었다면 서버는 그걸 받았고, 받았으면 처리한다. 취소는 그 처리를 못 막는다.\n\n| 구간         | 어디                  | 여기서 취소하면                                             |\n| ------------ | --------------------- | ----------------------------------------------------------- |\n| `send`       | 클라이언트            | 요청이 다 전송되지 않는다 — 헤더·본문 일부는 도착할 수 있다 |\n| `in-flight`  | 네트워크              | 이미 다 보냈다. 서버는 받는다                               |\n| `processing` | 서버                  | 서버가 처리 중이고 멈출 방법이 없다                         |\n| `resp`       | 네트워크 → 클라이언트 | 응답이 오는 중이다. 클라이언트가 안 읽을 뿐                 |\n\n구간 사이 경계는 셋인데 **결과를 가르는 건 첫 번째 하나**다 — `send`와 `in-flight` 사이, 전송이 끝나는 순간. 그 뒤로는 어디서 취소하든 서버 입장에선 똑같다.\n\n![요청은 send(클라이언트) → in-flight(네트워크) → processing(서버) → resp(응답) 네 구간을 지난다. 결과를 가르는 경계는 send 가 끝나는 순간 하나다. 빠른 네트워크에서 전송이 끝난 뒤 abort 하면 promise 는 reject 되지만 서버는 요청을 받아 처리하고, 클라이언트는 응답을 안 읽을 뿐이다. 같은 시각에 abort 해도 네트워크가 느려 전송이 그 시각을 넘겨 끝나면 전송이 끊겨 서버는 요청을 다 받지 못한다(헤더·본문 일부는 도착할 수 있다). 서버가 처리 중일 때 abort 해도 서버 입장에선 전송 뒤 abort 와 같다 — reject 이면서 서버는 처리한다.](/svg/request_abort_boundary.svg)\n\n**클라이언트에서는 네 경우가 똑같이 보인다** — promise는 `AbortError`, CDP는 `Network.loadingFailed`(`canceled: true`, `net::ERR_ABORTED`). 응답 전에 끊기면 `responseReceived`도 안 와서 `timing.sendEnd`조차 없다. \"서버가 받았나\"의 정답은 **서버 로그에만** 있고, CDP는 취소를 원하는 구간에 정확히 일으키는 데 쓴다.[^abort-observed]\n\n| 구간         | CDP로 일으키기                                                                             | 서버 로그                              |\n| ------------ | ------------------------------------------------------------------------------------------ | -------------------------------------- |\n| `send` 전    | `Fetch.enable({ patterns: [{ requestStage: 'Request' }] })`로 보내기 전에 붙잡고 abort     | 없음                                   |\n| `send` 중    | `Network.emulateNetworkConditions`의 `uploadThroughput`을 낮추고(200KB 본문, 20KB/s) abort | 헤더 + 본문 16KB, 미완료, 핸들러 안 돎 |\n| `processing` | 서버가 오래 걸리는 엔드포인트에서 abort                                                    | 받음, 처리 완료                        |\n| `resp`       | `requestStage: 'Response'`로 응답을 붙잡고 abort                                           | 받음, 처리 완료                        |\n\n`send` 중 줄이 경계를 한 번 더 쪼갠다. 전송 도중 취소하면 서버는 아무것도 못 받는 게 아니라 **헤더와 본문 일부를 받는다.** 본문이 끝나야(`end`) 처리하는 서버라 핸들러가 안 돌았을 뿐이다. 헤더만 보고 일을 시작하는 서버라면 전송 완료 전 취소도 부수효과를 낸다 — \"경계는 전송 완료\"는 **본문까지 받고 처리하는 서버**에서의 이야기다.\n\n<AutoIframe\n  src=\"/iframe/request_abort_race_sim.html\"\n  title=\"취소한 시점이 전송 완료 전이냐 후냐에 따라 promise reject·서버 도달·서버 처리가 어떻게 갈리는지\"\n/>\n\n[^fetch-abort]: Fetch 표준의 abort 알고리즘은 controller 상태를 `aborted`로 바꾸고 이유를 기록할 뿐, 서버로 무엇을 보내라고 정하지 않는다. 연결을 닫거나 응답 읽기를 멈추는 건 구현 재량이다. ([Fetch Standard — abort a fetch](https://fetch.spec.whatwg.org/#abort-fetch))\n\n[^abort-observed]: Chrome 154 headless 에서 raw CDP 로 실측 (2026-10-01). 로컬 Node 서버가 요청 수신·본문 바이트·`end`·응답 완료를 기록했고, 페이지는 `AbortController` 로 POST 를 취소했다. 네 경우 모두 promise `AbortError`, `Network.loadingFailed` `canceled: true` `net::ERR_ABORTED` 로 같았다. `uploadThroughput` 스로틀링은 localhost 요청에도 걸렸다."},{"i":"569","t":"location.href 는 암묵적 await 가 아니다 — setter 는 동기, navigation 은 태스크","k":["javascript","event-loop","navigation"],"c":"2026-06-23","m":"2026-10-01","b":"`location.href`에 값을 넣으면 그 줄에서 멈추고 이동할 것 같다 — **암묵적 `await`** 처럼. 그럼 뒤의 코드는 안 돌까. 추측하지 말고 CDP로 깨 본다.\n\n## 1. 실험을 건다\n\n외부 도메인 대신 로컬 서버의 `/a`·`/b`로 이동시킨다(서버는 받은 요청을 기록한다). `Runtime`·`Page`·`Network`를 enable 하고, 마지막 식의 값까지 돌려받게 한 줄을 붙여 `Runtime.evaluate`로 실행한다.\n\n```js\nlocation.href = '/a'\nconsole.log('실행됨 1')\nlocation.href = '/b'\nconsole.log('실행됨 2')\n// 'evaluate 끝까지 돌았다'\n```\n\n`await`였다면 첫 줄에서 멈춰 `실행됨 1`도 안 찍히고 `/a`로 가야 한다.\n\n## 2. 이벤트 순서를 읽는다\n\nCDP가 받은 순서 그대로다.[^nav-observed]\n\n| ms  | 이벤트                                              |                                       |\n| --- | --------------------------------------------------- | ------------------------------------- |\n| 514 | `Page.frameRequestedNavigation`                     | `/a` · `scriptInitiated`              |\n| 516 | `Runtime.consoleAPICalled`                          | `실행됨 1`                            |\n| 517 | `Page.frameRequestedNavigation`                     | `/b`                                  |\n| 517 | `Runtime.consoleAPICalled`                          | `실행됨 2`                            |\n| 517 | `Runtime.evaluate` 응답                             | `'evaluate 끝까지 돌았다'`            |\n| 518 | `Page.frameStartedNavigating`                       | `/a`                                  |\n| 520 | `Network.requestWillBeSent`                         | `/a`                                  |\n| 521 | `Network.loadingFailed`                             | `net::ERR_ABORTED` · `canceled: true` |\n| 523 | `Page.frameStartedNavigating` · `requestWillBeSent` | `/b`                                  |\n| 528 | `Page.frameNavigated`                               | `/b`                                  |\n\n- **setter는 동기로 끝나고 흐름을 안 끊는다.** 이동 요청(`frameRequestedNavigation`)이 console 로그 사이사이에 찍히고, `evaluate`가 마지막 식의 값을 돌려준다 — 코드는 끝까지 돌았다.\n- **실제 이동은 그 뒤에 시작된다.** `frameStartedNavigating`은 `evaluate`가 끝난 다음이다. 문서 언로드·페치는 태스크로 넘어가 지금 코드가 끝난 뒤에 돈다.\n- **마지막 값이 이기는 건 앞의 것이 취소되기 때문이다.** `/a`가 시작됐다가 `/b`가 시작되며 `ERR_ABORTED`로 끊긴다.[^html-spec-navigate] 덮어쓴 게 아니다.\n\n<img\n  src=\"/svg/event_loop_nav_slot.svg\"\n  alt=\"JS(콜 스택): href = '/a' → 실행됨 1 → href = '/b' → 실행됨 2 → evaluate 반환까지 끊기지 않고 돈다. href 를 넣을 때는 이동 요청만 등록된다. await 였다면 첫 href 에서 멈췄어야 한다. 브라우저 이동은 evaluate 가 끝난 517ms 뒤에 시작된다: /a 가 시작되어 요청까지 나갔다가 /b 가 시작되며 ERR_ABORTED 로 취소되고, /b 가 요청되어 528ms 에 frameNavigated 로 도착한다. 서버는 /a 와 /b 를 둘 다 받았다. 마지막 값이 이기는 건 덮어써서가 아니라 앞의 이동이 시작됐다가 취소되기 때문이다. 눈금은 비례가 아니다.\"\n/>\n\n## 3. 덤 — 버려진 이동도 서버에는 갔다\n\n서버 기록은 `[\"/a\",\"/b\"]`다. `/a`는 `requestWillBeSent`까지 갔다가 클라이언트에서 끊겼을 뿐, 요청은 서버에 도착했다. 이동 전에 서버 부수효과가 있는 URL(로그아웃 링크 같은)이라면 \"결국 `/b`로 갔으니 `/a`는 없던 일\"이 아니다 — [572](/memo/572)의 \"취소는 클라이언트에서만 일어난다\"와 같은 이야기다.\n\n[^html-spec-navigate]: _\"Set the ongoing navigation for navigable to navigationId. This will have the effect of **aborting other ongoing navigations** of navigable, since at certain points during navigation changes to the ongoing navigation will cause further work to be abandoned.\"_ — 그리고 문서 언로드·페치는 `in parallel` 로 넘어간다. ([HTML Standard — navigate](https://html.spec.whatwg.org/multipage/browsing-the-web.html#navigate))\n\n[^nav-observed]: Chrome 154 headless 에서 raw CDP 로 실측 (2026-10-01). ms 는 스크립트 시작 기준이고, `evaluate` 를 보낸 시각은 512ms 다. 로컬 Node 서버가 문서 요청 경로를 기록했다."},{"i":"568","k":["probability","coupon-collector","math"],"c":"2026-06-23","m":"2026-08-23","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n목록 90개에서 매번 무작위로 하나를 뽑아 보여준다고 하자 (작업 중에 문구를 바꿔 띄우는 스피너 같은 것). **90개를 다 보려면 90번쯤이면 될까.**\n\n아니다. **평균 457번**이다. 5배다. 1초에 하나씩이면 7분 반.\n\n목록을 한 번 섞어서 처음부터 끝까지 도는 게 아니라 **매번 90개 전체에서 다시 뽑기** 때문이다. 그러면 이미 본 것이 계속 다시 나온다. 섞어 돌았다면 90번에 정확히 끝났을 일이다.\n\n**꼬리가 어디서 생기는지가 핵심이다.** 89개를 모았을 때, 남은 마지막 하나가 뽑힐 확률은 90분의 1이다. 그 **한 개를 만나는 데만 평균 90번** — 섞어서 돌았다면 90개 전부를 봤을 횟수다.[^wikipedia-coupon-collector]\n\n이게 쿠폰 수집가 문제(coupon collector)다. 전부 모으는 데 걸리는 평균 횟수는\n\n```\nn × (1 + 1/2 + 1/3 + … + 1/n)\n```\n\n<AutoIframe\n  src=\"/iframe/coupon_collector_explorer.html\"\n  title=\"목록 크기를 바꿔가며, 뽑기 횟수가 늘수록 서로 다른 것을 몇 개나 보게 되는지 보여주는 곡선\"\n/>\n\n[^wikipedia-coupon-collector]: 전부 모으는 기대 횟수는 `n·H_n`(`H_n`은 조화수). 그리고 `i`번째 새 쿠폰까지의 기대 대기는 `n/(n-i+1)` 이므로 **마지막 한 장은 `n/1 = n`** — 단계 중 가장 오래 걸린다. ([Coupon collector's problem — Wikipedia](https://en.wikipedia.org/wiki/Coupon_collector%27s_problem))"},{"i":"567","t":"'blocking=render 는 <body> 전에만 등록된다 — 뒤에 붙으면 조용한 no-op'","k":["performance","rendering","fouc","html"],"c":"2026-06-23","m":"2026-10-01","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n새로고침하면 한 프레임 동안 스타일이 붙기 전 default 상태가 보인다. **파서를 멈추는 것과 paint를 멈추는 것은 다르다** — 동기 `<script>`는 파서만 세우고, 그 위에 이미 파싱된 청크가 먼저 그려질지는 브라우저 재량이다. `blocking=\"render\"`가 paint까지 막는 걸 보장하는데, **`<body>` 뒤에 붙으면 조용히 아무 일도 안 한다.**\n\n## 1. 이 축의 문제인가\n\n| 증상 | 원인 | 처방 |\n|---|---|---|\n| 스타일이 붙기 전 default 상태가 한 프레임 보인다 | 스타일 적용이 첫 페인트보다 늦다 | `blocking=\"render\"` — 여기서 계속 |\n| 다크 테마인데 흰 화면이 번쩍인다 | 서버가 사용자 상태를 몰라 기본값으로 렌더했다 | 첫 페인트 전 인라인 스크립트 → [594](/memo/594) |\n\n뒤쪽은 렌더를 막아도 안 고쳐진다 — 서버가 낸 HTML 자체가 이미 다른 값이다.\n\n## 2. 붙였는데 먹었는지 무엇으로 아나\n\n로그도 DOM도 답을 안 준다. `<body>` 뒤에 붙여도 console·`Log`·`Audits` 어디에도 아무것도 안 찍히고, `el.blocking.contains('render')`는 앞이든 뒤든 `true`다.[^rb-observed] 갈리는 건 **첫 페인트 시각** 하나다 — 막는 리소스가 끝나기 전에 칠해졌으면 안 먹은 것이다.\n\n## 3. CDP로 잰다\n\n`Page.navigate` → `Page.loadEventFired`를 기다린 뒤, 페이지의 Performance 엔트리를 읽어 둘을 비교한다.\n\n```js\nconst fcp = performance.getEntriesByName('first-contentful-paint')[0].startTime\nconst done = performance.getEntriesByType('resource').find((e) => e.name.includes('slow.js')).responseEnd\nfcp < done // true 면 막는 리소스가 오기 전에 이미 칠했다 — 안 먹었다\n```\n\n800ms 걸리는 `<script async blocking=\"render\" src=\"/slow.js\">` 위치만 바꿔 잰 값이다.[^rb-observed]\n\n| 위치 | FCP | `slow.js` 끝 | 판정 |\n|---|---|---|---|\n| `blocking` 없음 | 68ms | 830ms | default 가 먼저 칠해짐 |\n| `<head>` | 836ms | 816ms | 스크립트 뒤에 첫 페인트 — 먹었다 |\n| `<body>` 안 | 16ms | 807ms | default 가 먼저 칠해짐 — **없는 것과 같다** |\n\n![파서는 head 를 읽고 body 를 연다. body 가 null 인 동안만 render-blocking 등록이 가능하고, body 가 열리는 순간 창이 닫힌다. head 안의 script blocking=render 는 등록되고, body 안의 같은 스크립트는 등록되지 않는데 경고도 없다. 실측: head 안이면 816ms 에 스크립트가 끝난 뒤 836ms 에 styled 상태로 첫 페인트한다. body 안이면 16ms 에 default 가 먼저 칠해지고 807ms 에 스크립트가 끝나서야 styled 로 바뀐다 — 그 사이 default 가 노출된다. 로그와 DOM(el.blocking)으로는 둘이 똑같고 첫 페인트 시각만 갈린다. 눈금은 비례가 아니다.](/svg/render_blocking_body_window.svg)\n\n## 4. `<body>`가 열리기 전에 둔다\n\n컴포넌트 마크업 안이면 이미 늦다. 스펙은 \"head 안에 둬라\"가 아니라 등록 창을 이렇게 닫는다.\n\n> A `Document` document allows adding render-blocking elements if document's content type is \"text/html\" and **the body element of document is null**.\n>\n> — [HTML Standard](https://html.spec.whatwg.org/multipage/dom.html#allows-adding-render-blocking-elements)\n\n`<body>`가 생기는 순간 등록이 닫히고, 뒤에 붙인 속성은 실패 없이 무시된다. 속성을 모르는 브라우저가 옛 동작으로 떨어지는 것과 같은 모양이라 3처럼 재지 않으면 드러나지 않는다.\n\n<AutoIframe\n  src=\"/iframe/render_blocking_paint_simulator.html\"\n  title=\"세 전략(async·defer / 동기 script / blocking=render)에서 사용자가 보는 화면을 시간순으로 재생해 깨진 프레임 노출 여부를 비교\"\n/>\n\n[^rb-observed]: Chrome 154 headless 에서 raw CDP 로 실측 (2026-10-01). 로컬 Node 서버가 `/slow.js` 를 800ms 늦게 주고, 스크립트는 `<p>` 텍스트를 바꾼다. `Log`·`Runtime`·`Audits` 를 enable 한 채 `<head>` / `<body>` 두 경우를 봤고 둘 다 메시지 0건이었다."},{"i":"565","t":"데스크톱 없이 안드로이드 Chrome 디버깅 — 로컬 프록시로 Origin 검사 우회","k":["chrome-devtools","cdp","websocket","proxy"],"c":"2026-06-23","m":"2026-10-01","b":"**데스크톱 없이 안드로이드 기기 한 대로 그 기기의 Chrome을 디버깅한다.** 순서를 기억하려고 적어둔 것이다 — 각 단계의 방법이 아니라 밟는 차례가 내용이다.\n\n![폰 한 대(Termux) 안에서 순서는 셋이다. ① adb forward 가 Chrome 의 chrome_devtools_remote unix socket 을 localhost:9222 로 연다 — 9222 는 /json 으로 탭 목록을, /devtools/page/\\{id\\} 로 페이지별 CDP WebSocket 을 준다. ② 파이썬 aiohttp 프록시를 9223 에 띄우고 /json 으로 탭을 고른다. ③ termux-open-url 로 폰 브라우저에서 appspot.com 의 DevTools 프론트엔드를 ?ws=localhost:9223/\\{id\\} 로 연다. 프론트엔드가 9222 에 직접 붙으면 Origin: appspot.com 이 붙어 Chrome 이 403 으로 거부한다(Origin 검사, Chrome 111~). 프록시는 중계가 아니라 프론트엔드 WS 를 종단하고, 파이썬이 Origin 없는 새 WS 로 9222 에 재접속해 통과한다. ws://localhost 는 localhost 가 secure context 라 HTTPS 페이지에서도 안 막힌다. 다른 길 둘 — Chrome 을 --remote-allow-origins=* 로 띄우거나 프론트엔드를 localhost 에 self-host — 은 해보지 않았다.](/svg/android_devtools_via_proxy.svg)\n\n막히는 건 Origin 검사 하나다.[^chromium-devtools-403]\n\n[^chromium-devtools-403]: Origin 검사(Chrome 111~)와 **DNS rebinding을 막는 Host 헤더 검사(Chrome 66~)는 다른 방어**다. 여기 걸리는 건 앞쪽 — WS 핸드셰이크에 `--remote-allow-origins`에 없는 Origin이 붙으면 거부된다. ([Chromium issues](https://issues.chromium.org/issues/40096993))"},{"i":"564","t":"Iterator Helpers의 지연 평가","k":["javascript","iterator","lazy-evaluation","generator"],"c":"2026-06-22","m":"2026-10-01","b":"**조기 종료를 `take`에 위임하면 필요한 만큼만 fetch 한다.** 페이지네이션 API에서 \"조건에 맞는 N개\"만 필요할 때. sync 는 네이티브(모던 브라우저·Node 22+), async 는 제안(Stage 2)이라 core-js 폴리필.\n\n```ts\nimport 'core-js/proposals/async-iterator-helpers.js'\n\ntype Page<T> = { items: T[]; next: string | null }\n\n/** 커서 페이지네이션 API 를 순회한다 — 현재 페이지를 다 당긴 뒤에야 다음 페이지를 받는다. */\nasync function* fetchPages<T>(url: string | null): AsyncGenerator<T> {\n  while (url) {\n    const { items, next }: Page<T> = await fetch(url).then((r) => r.json())\n    yield* items\n    url = next\n  }\n}\n\nawait AsyncIterator.from(fetchPages<Item>('/api/items')).filter(isValid).take(10).toArray()\n```\n\n![toArray 가 next() 로 당길 때만 take → filter → fetchPages 순으로 계산이 거슬러 올라가고, 아이템은 fetchPages → filter(isValid) → take(10) → toArray 로 흐른다. fetchPages 는 현재 페이지를 다 당긴 뒤에야 다음 페이지를 요청한다. 예를 들어 10개가 2페이지에서 차면 take 가 끝나며 return() 이 filter 를 거쳐 fetchPages 까지 거슬러 올라가 소스를 닫는다 — finally 가 돌아 연결·커서를 정리하고, 3페이지는 요청되지 않는다. 요청 수를 세면 확인된다. for await 에서 break 해도 같은 return() 이 일어난다.](/svg/iterator_take_return.svg)\n\n- **제안이 개정 중이다** — 동시 `next()` 동작이 미정이라 폴리필과 다를 수 있다. 순차로 당기는 `toArray`엔 영향 없음.\n- **TS 타입이 없다** — TS 5.8 lib·core-js 어디에도 `AsyncIterator.from` 선언이 없어 직접 선언한다."},{"i":"563","t":"React 밖 상태를 useSyncExternalStore로 구독하기","k":["react","usesyncexternalstore","event-target"],"c":"2026-06-21","m":"2026-09-28","b":"```js\nclass ToastManager extends EventTarget {\n  get toasts() {\n    return this._toasts // 새로 만들지 않는다 — 같은 참조면 안 바뀐 것\n  }\n  set toasts(value) {\n    this._toasts = [...value] // 새 참조 = \"바뀌었다\"는 신호\n    this.dispatchEvent(new Event('changed'))\n  }\n  // add·remove … 는 전부 이 setter 를 거친다 — 변경과 발행이 한 곳이라 발행 누락이 없다\n}\n```\n\n```jsx\nconst subscribe = (cb) => {\n  toastManager.addEventListener('changed', cb)\n  return () => toastManager.removeEventListener('changed', cb)\n}\n\n// useEffect + useState 로 손으로 복사하던 동기화를 React 가 맡는다.\n// changed 마다 getSnapshot 을 불러 참조를 비교하고, 다를 때만 리렌더한다.\nconst toasts = useSyncExternalStore(subscribe, () => toastManager.toasts)\n```\n\n![어디서든 add 를 부르면 setter 가 새 배열을 만들고 changed 를 발행한다. React 는 getSnapshot 으로 다시 읽어 이전 값과 Object.is 로 비교해 다르면 리렌더, 같으면 건너뛴다. getter 는 내부 배열을 그대로 돌려주므로 안 바뀌면 같은 참조다. getSnapshot 이 매번 새 배열을 만들면 항상 달라 무한 루프가 된다.](/svg/external_store_flow.svg)\n\n---\n\n- 출처: [React: Singletons aren't as evil as you think — Andrew Bone](https://dev.to/link2twenty/react-singletons-arent-as-evil-as-you-think-44m8)"},{"i":"559","t":"이벤트 루프 한 턴 — 마이크로태스크가 setTimeout(0) 보다 항상 먼저다","k":["javascript","event-loop","microtask"],"c":"2026-06-18","m":"2026-10-01","b":"import AutoIframe from '@components/AutoIframe/AutoIframe.astro'\n\n**마이크로태스크 큐는 매 턴 끝까지 비워진다** — 그래서 `then`·`queueMicrotask`는 항상 `setTimeout(0)`보다 먼저다.\n\n![한 턴은 ① task queue 에서 가장 오래된 매크로태스크 1개 실행 → ② microtask checkpoint, 큐가 빌 때까지 전부 드레인(드레인 중 추가된 것도 같은 체크포인트에서 처리) → ③ 필요하면 렌더링 → 다음 턴의 ① 로 돌아간다. 매크로는 드레인 도중 끼어들 수 없어 then·queueMicrotask 는 항상 setTimeout(0) 보다 먼저다. 예: setTimeout(timeout), then(then 안에서 queueMicrotask(nested)), 동기 log(sync) 를 실행하면 순서는 sync → then → nested → timeout 이다 — 스크립트 자체가 첫 매크로이고, then 과 그 안에서 추가된 nested 가 같은 드레인에서 돌고, timeout 은 다음 턴의 매크로다. 중첩이 끝없으면 렌더링과 다음 매크로가 굶는다.](/svg/event_loop_one_turn.svg)\n\n한 스텝씩 보려면:\n\n<AutoIframe\n  src=\"/iframe/event-loop-sim.html\"\n  title=\"queueMicrotask 이벤트 루프 시뮬레이터\"\n/>"},{"i":"551","t":"dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다","k":[],"c":"2026-06-07","m":"2026-10-01","b":"dnd-kit Sortable + react-query에서 **drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다.** 1~2프레임짜리 튕김이고 `onDragEnd`에서 `setQueryData`로 즉시 캐시를 갱신해도 그대로다.\n\n![① 최소 재현: Sortable 리스트에 useQuery 를 붙이고 CDP Input.dispatchMouseEvent 로 드래그한다. ② 숫자로 잡기: requestAnimationFrame 마다 DragOverlay·아이템의 top 을 기록하는 프로브를 먼저 걸고, drop 뒤 최종 배치와 다른 프레임이 있으면 튕김이다. ③ 가설을 하나씩 끈다: dropAnimation={null} 로 사라지면 drop 애니메이션이 즉시 재는 탓, setState 와 setQueryData 를 번갈아 돌려 후자만 튕기면 캐시 알림 경로, notifyManager.setScheduler(queueMicrotask) 로 사라지면 원인 확정. ④ 고친다: 렌더는 local state, 캐시 갱신은 부수효과로. ⑤ 회귀 확인: ② 와 같은 프로브를 refetch 뒤까지 돌린다. 다시 튕기면 refetch 가 옛 순서를 덮음 → isReorderingRef 가드, id 흔들림 → stable id, DragOverlay 만 남음 → dropAnimation={null}.](/svg/dndkit_debug_flow.svg)\n\n③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데[^dnd-kit-833], `setQueryData`의 리렌더는 `notifyManager`의 `setTimeout(0)`을 거쳐 한 틱 늦다.[^notify-manager]\n\n![drop 하면 onDragEnd 가 돌고, dnd-kit 의 drop 애니메이션은 곧바로(ASAP) 목적지를 잰다. setState 로 바꾸면 그 이벤트 처리 안에서 새 순서가 렌더되어, 잴 때 이미 새 자리라 새 자리로 애니메이션한다. setQueryData 로 바꾸면 캐시는 즉시 바뀌지만 구독자 알림은 notifyManager 의 기본 스케줄러 setTimeout(0) 을 거쳐 다음 매크로태스크에 리렌더된다. 그 사이 dnd-kit 이 옛 순서를 재서 옛 자리로 애니메이션하고, 리렌더 순간 새 자리로 점프한다 — 1~2프레임 튕김. 캐시는 바로, 화면은 한 틱 뒤다. 눈금은 비례가 아니다.](/svg/dndkit_setquerydata_tick.svg)\n\n④의 코드:\n\n```tsx\nfunction SortableList() {\n  const { data: serverItems = [] } = useQuery({\n    queryKey: ['items'],\n    queryFn: fetchItems,\n  })\n  const [items, setItems] = useState<Item[]>(serverItems)\n\n  const isReorderingRef = useRef(false)\n  useEffect(() => {\n    if (!isReorderingRef.current) setItems(serverItems)\n  }, [serverItems])\n\n  const reorderMutation = useMutation({\n    mutationFn: reorderItems,\n    onSettled: () => {\n      isReorderingRef.current = false\n      queryClient.invalidateQueries({ queryKey: ['items'] })\n    },\n  })\n\n  const handleDragEnd = ({ active, over }: DragEndEvent) => {\n    if (!over || active.id === over.id) return\n    const next = arrayMove(items, idxOf(active.id), idxOf(over.id))\n    isReorderingRef.current = true\n    setItems(next)\n    reorderMutation.mutate(next)\n  }\n  // <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...\n}\n```\n\n라이브러리가 **렌더 직후 DOM을 재면**, 기준은 상태가 바뀌는 시점이 아니라 **화면이 바뀌는 시점**이다.\n\n> v6.3.1 기준. v10+는 `OptimisticSortingPlugin`이 기본 활성화라 기제가 다르다 → 재검증 필요.\n\n[^dnd-kit-833]: _\"the drop animation for DragOverlay measures the destination position **ASAP**, and so it catches the 1 or 2 frames before my reorder is applied, and the item animates back to its original position.\"_ ([dnd-kit #833](https://github.com/clauderic/dnd-kit/issues/833))\n\n[^notify-manager]: `notifyManager.schedule` 은 _\"schedules a function to be run on the next batch. By default, the batch is run with a **setTimeout**\"_ — `setScheduler` 로 `queueMicrotask`·`requestAnimationFrame` 으로 바꿀 수 있다. ([notifyManager — TanStack Query](https://tanstack.com/query/latest/docs/reference/notifyManager))"},{"i":"549","k":[],"c":"2026-06-07","m":"2026-10-01","b":"**바깥 클릭 감지를 `useEffect` 없이 ref callback 하나로 만든다.**\n\nReact 19 이상에서만 된다.\n\n```ts\nfunction useClickOutside(handler) {\n  const handlerRef = useRef(handler)\n  handlerRef.current = handler // stale closure 방지\n\n  return useCallback((node) => {\n    const handleClick = (e) => {\n      if (!node.contains(e.target)) handlerRef.current()\n    }\n\n    document.addEventListener('mousedown', handleClick)\n    return () => document.removeEventListener('mousedown', handleClick)\n  }, [])\n}\n\n// 사용\nfunction Modal({ onClose }) {\n  const ref = useClickOutside(onClose)\n  return <div ref={ref}>...</div>\n}\n```\n\n![모달을 열기 → 닫기 → 열기 → 닫기 → 열기 한 뒤 바깥을 클릭한다. React 19: 열 때 ref(node) 가 mousedown 리스너를 추가하고, 닫을 때 반환한 cleanup 이 제거해 남는 리스너가 1 → 0 → 1 → 0 → 1 이다. 바깥 클릭에 handler 가 한 번만 불린다. React 18 이하: 반환한 cleanup 은 무시되고, 닫을 때 같은 콜백이 ref(null) 로 다시 불려 본문이 또 돈다 — node 가 null 인 리스너가 하나 더 붙는다. 남는 리스너가 1 → 2 → 3 → 4 → 5 로 쌓이고, 바깥 클릭에 handler 가 세 번 불리고 null.contains 에서 TypeError 가 두 번 난다. 18 이하는 리스너를 별도 ref 에 저장하거나 useEffect 를 쓴다.](/svg/ref_callback_cleanup_19_vs_18.svg)\n\n## 참고\n\n- [React v19 — Cleanup functions for refs](https://react.dev/blog/2024/12/05/react-19)"},{"i":"546","k":[],"c":"2026-06-03","m":"2026-09-28","b":"WeakMap의 키를 \"입력 배열의 참조 자체\"로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.\n\n같은 배열을 이름 같은 키로 **반복 조회**해서 O(n) 스캔이 계속 도는데, 그 배열이 언제 바뀌는지 추적하기는 싫을 때 쓴다.\n\n```typescript\n// 키가 weak — items 가 어디서도 안 잡히면 entry 도 GC. 일반 Map 이면 영구히 붙들려 누수\nconst indexCache = new WeakMap<Item[], Map<string, string>>()\n\n// name → id 역인덱스를 1회 빌드 (O(n))\nconst buildIndex = (items: Item[]) =>\n  new Map(items.map((it) => [it.name, it.id]))\n\nfunction lookup(items: Item[], name: string): string | null {\n  // 키는 내용이 아니라 참조(===)로 비교된다. 불변 업데이트면 \"데이터가 바뀜\" = \"참조가 바뀜\"이라\n  // 이 조회가 곧 무효화 검사다 — 그래서 delete·clear 가 어디에도 없다\n  let index = indexCache.get(items)\n  if (!index) {\n    // 새 참조(refetch 등) → 자동 miss. 옛 index 는 옛 items 와 함께 GC\n    index = buildIndex(items)\n    indexCache.set(items, index)\n  }\n  return index.get(name) ?? null // 여기부터 O(1) — 호출마다 돌던 find 의 O(n) 스캔이 사라진 자리\n}\n```\n\n![흩어진 호출 지점이 모두 lookup 하나를 거쳐 배열 참조로 캐시를 찾는다. 같은 참조면 인덱스를 재사용하고, refetch 로 새 참조가 오면 한 번 다시 빌드하며 옛 entry 는 GC 된다. push 처럼 제자리에서 바꾸면 참조가 같아 옛 인덱스가 나온다. 매번 새 배열을 넘기면 항상 miss 다.](/svg/weakmap_ref_cache.svg)\n\n같은 배열 참조로 두 번 불러 `buildIndex`가 **한 번만** 돌면 된 것이다.\n\n단 호출 측 `items` 참조가 **stable해야** 작동한다. 매번 `[...data]`·`data.filter()`로 새 배열을 넘기면 항상 miss라 무의미하다. (TanStack Query의 `data`/`select` 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK) 반대로 `push`·`splice` 로 **제자리에서 바꾸면** 참조가 같아 옛 인덱스가 그대로 나온다 — 이쪽은 느린 게 아니라 틀린 값이다.\n\n## 참고\n\n- [WeakMap - MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap)\n- [Render Optimizations (referential stability) - TanStack Query](https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations)"},{"i":"539","k":["url","encoding","ios","debug","security"],"c":"2026-05-25","m":"2026-10-01","b":"URL의 공백이 `%2520`으로 깨져 들어온다. 원인은 iOS 17.0/17.1 WebKit pasteboard가 복사할 때 URL을 다시 인코딩하는 버그(WebKit Bug 261936)였다. Claude Code라면 **깨진 값 해독 → 경로 좁히기 → 우리 코드 배제 → 방어 → 방어 확인** 순으로 간다.\n\n![① 깨진 값 해독: decodeURIComponent 를 한 번 돌려도 %20 이 남으면 이중 인코딩이고, 한글은 풀리니 공백만 다시 인코딩됐다. ② 경로 좁히기: 액세스 로그의 %2520 요청이 iOS 에 몰리고 Referer 가 비어 있으면 복사-붙여넣기 경로다. ③ 우리 코드 배제: 링크 생성이 인코딩을 한 번만 하면 원인은 밖이고, iOS 버전으로 좁혀 WebKit Bug 261936 을 찾는다. ④ 고칠 수 없으니 막는다: 화이트리스트 라우트에서만 한 번 더 디코딩, 디코딩 뒤 .. // \\ 차단, segment 별 재인코딩 후 301. 무조건 이중 디코딩은 ..%252f 를 ../ 로 만들어 path traversal 이 된다. ⑤ 방어 확인: curl -I 로 %2520 URL 은 301 로 정상 경로, ..%252f URL 은 차단되는지 본다.](/svg/url_2520_debug_flow.svg)\n\n## 1. 깨진 값을 해독한다\n\n`decodeURIComponent('%2520')`을 한 번 돌리면 `%20`이 남는다 — `%25`(= `%`) 뒤에 원래의 `20`, **두 번 인코딩**된 것이다. 같은 URL의 한글(`%EC%88%98`)은 한 번 디코딩으로 풀리니 **공백만** 다시 인코딩됐다. URL 인코딩은 멱등이 아니다.\n\n## 2. 어느 경로에서 깨지나\n\n서버 액세스 로그에서 `%2520`이 든 요청만 모아 User-Agent·Referer를 센다. **iOS에 몰려 있고 Referer가 비어 있다** — 링크 클릭(JS redirect)이 아니라 주소를 복사해 붙여넣은 경로다. 클릭 경로의 같은 URL은 정상이다.\n\n## 3. 우리 코드인가, 밖인가\n\n링크를 만드는 코드에서 `encodeURIComponent` 호출을 찾아 출력이 `%20` 한 번인지 본다. 한 번이면 이중 인코딩은 우리 밖에서 일어난다. iOS 버전으로 좁혀 검색하면 WebKit Bug 261936이 나온다.\n\n- iOS 16↓: `NSURL URLWithString:`이 invalid char에 `nil`\n- iOS 17.0/17.1: invalid char를 자동 percent-encode (regression)\n- iOS 17.2: 수정\n\n![공백 → 인코딩 1회 → %20 → iOS 17.0·17.1 pasteboard 가 NSURL 을 만들며 %20 을 invalid 로 판단해 % 를 다시 인코딩 → %2520. 한글 가 → 인코딩 1회 → %EA%B0%80 → 유효한 UTF-8 이라 통과 → %EA%B0%80 그대로. %2520 은 %25(= %) 뒤에 원래 20 이 붙은 것으로, 공백만 한 번 더 인코딩됐다. 클릭(JS redirect)은 pasteboard 를 거치지 않아 정상이다.](/svg/ios17_pasteboard_reencode.svg)\n\n## 4. 고칠 수 없으니 막는다\n\n원인이 OS 버그면 추적보다 방어가 싸다.\n\n- 화이트리스트 라우트 패턴에서만 한 번 더 디코딩\n- 디코딩 후 위험 패턴(`..`, `//`, `\\`) 차단\n- segment별 `encodeURIComponent` 후 301 redirect\n\n> [!CAUTION]\n> 무조건 이중 디코딩 금지 — `..%252f..%252f` → `../../` path traversal 우회 벡터.\n\n## 5. 방어를 확인한다\n\n`%2520`이 든 URL과 `..%252f..%252f`가 든 URL을 `curl -I`로 보낸다. 앞의 것은 301로 정상 경로에 가고, 뒤의 것은 막혀야 한다.\n\n## 교훈\n\n- URL은 디코딩된 원본으로 보관하고, 인코딩은 출력 직전 한 번 (single source of truth)\n- \"방어적으로 한 번 더\"가 함정 — 멱등이 아닌 연산엔 통하지 않는다\n\n## 참고\n\n- [WebKit Bug 261936](https://bugs.webkit.org/show_bug.cgi?id=261936)\n- [Changes to URL string parsing in iOS 17 — Augmented Code](https://augmentedcode.io/2023/10/02/changes-to-url-string-parsing-in-ios-17/)\n- [OWASP — Double Encoding](https://owasp.org/www-community/Double_Encoding)\n- [WHATWG URL Standard](https://url.spec.whatwg.org/)"},{"i":"538","k":["react","as","as-child","render-prop","polymorphic"],"c":"2026-01-26","m":"2026-10-01","b":"컴포넌트가 렌더링하는 요소를 외부에서 제어하는 세 가지 패턴.\n\n![as: 소비자가 태그 이름(as=\"a\")을 넘기면 Button 이 Comp = as 로 그 태그를 렌더하고 props 를 그대로 펼친다 — 단순 태그 변경에 쓴다. asChild: 소비자가 자식 요소(&lt;a href /&gt;)를 넘기면 Button 이 Slot 으로 그 자식을 cloneElement 해 className 병합·핸들러 합성·ref 병합을 암묵적으로 한다 — 기존 컴포넌트 합성에 쓴다. render: 소비자가 render 에 요소나 (props, state) =&gt; 함수를 넘기면 Button 이 ref 포함 DOM props 와 isPressed 같은 상태를 명시적으로 건네고 소비자가 직접 펼친다 — 상태 기반 커스터마이징과 합성에 쓰고 타입 추론이 가장 좋다.](/svg/render_delegation_three_ways.svg)\n\n## as\n\n```tsx\ntype PolymorphicProps<E extends React.ElementType> = {\n  as?: E\n} & React.ComponentPropsWithoutRef<E>\n\nfunction Button<T extends React.ElementType = 'button'>({\n  as,\n  ...props\n}: PolymorphicProps<T>) {\n  const Comp = as || 'button'\n  return <Comp {...props} />\n}\n```\n\n## asChild + Slot\n\n```tsx\nimport { Slot } from '@radix-ui/react-slot'\n\nfunction Button({ asChild, ...props }) {\n  const Comp = asChild ? Slot : 'button'\n  return <Comp {...props} />\n}\n```\n\nSlot 직접 구현:\n\n```tsx\nconst Slot = forwardRef(({ children, ...props }, ref) => {\n  if (!isValidElement(children)) return null\n\n  return cloneElement(children, {\n    ...props,\n    ...children.props,\n    ref: ref || children.ref,\n  })\n})\n```\n\n자식 요소 타입에 따른 조건부 Props:\n\n```tsx\ntype ConditionalProps<T> = T extends ReactElement<any, 'a'>\n  ? { href?: string; external?: boolean }\n  : T extends ReactElement<any, 'button'>\n    ? { type?: 'button' | 'submit' }\n    : {}\n```\n\n## render\n\n```tsx\n// Element 방식\n<Button render={<a href=\"/about\" />}>Link</Button>\n\n// Callback 방식 - state 접근 가능\n<Button render={(props, state) => (\n  <motion.button {...props} animate={state.isPressed ? ... : ...} />\n)}>\n  Animated\n</Button>\n```\n\n---\n\n- [Base UI useRender](https://base-ui.com/react/utils/use-render) - render prop 커스텀 훅 구현\n- [React Aria render prop PR](https://github.com/adobe/react-spectrum/pull/9499) - React Aria Components에 render prop 도입\n- [Slot/asChild Pattern](https://boda.sh/blog/react-slot-aschild-pattern) - Radix Slot vs Base UI render 비교\n- [React as prop (christianvm)](https://www.christianvm.dev/blog/react-as-prop) - ElementType, ComponentPropsWithoutRef 타입 활용\n- [slot-jsx-pragma](https://github.com/jjenzz/slot-jsx-pragma) - cloneElement 없이 asChild 구현하는 JSX pragma\n- [React as prop (robinwieruch)](https://www.robinwieruch.de/react-as-prop/) - 디자인 시스템에서 시맨틱 HTML 유지하며 스타일 분리\n- [asChild in React, Svelte, Vue, Solid](https://medium.com/@bryanmylee/aschild-in-react-svelte-vue-and-solid-for-render-delegation-645c73650ced) - 프레임워크별 render delegation 비교\n- [React asChild (jacobparis)](https://www.jacobparis.com/content/react-as-child) - asChild 패턴 구현 튜토리얼\n- [Polymorphism done well](https://www.kripod.dev/blog/behind-the-as-prop-polymorphism-done-well/) - 타입 안전한 polymorphic 컴포넌트"},{"i":"537","k":["bash","mv"],"c":"2026-01-07","m":"2026-01-07","b":"```bash\nfor file in *.md; do\n  new_name=\"${file#* }\"\n  mv \"$file\" \"$new_name\"\ndone\n```\n\n```\n| 개념        | JavaScript      | Bash                   |\n| ----------- | --------------- | ---------------------- |\n| 반복        | `for...of`      | `for...in...do...done` |\n| 문자열 분리 | `split(' ')[1]` | `${var#* }`            |\n| 문자열 치환 | `replace()`     | `${var/old/new}`       |\n| 파일 조작   | `fs.rename()`   | `mv`                   |\n| 출력        | `console.log()` | `echo`                 |\n```\n\nParameter Expansion\n\n```bash\nfile=\"351 529.md\"\n\n${file#* }       # 앞에서 \"* \" 제거 → \"529.md\"\n${file##* }      # greedy\n${file% *}       # 뒤에서 \" *\" 제거 → \"351\"\n${file%%.*}      # greedy → \"351 529\"\n${file/old/new}  # 첫 번째 치환\n${file//old/new} # 전체 치환\n```\n\n`#`은 앞(키보드에서 $보다 왼쪽), `%`는 뒤.\n\n따옴표\n\n```bash\nmv $file $new_name      # ❌ 공백 있으면 깨짐\nmv \"$file\" \"$new_name\"  # ✅\n```\n\n디버깅\n\n```bash\nset -x  # 실행 명령어 출력\nset -e  # 에러 시 중단\n```"},{"i":"536","k":["javascript","monad","promise"],"c":"2025-01-01","m":"2025-01-01","b":"콜백 → Promise → async/await\n\n```javascript\n// 콜백\nfetchData(() => {\n  processData(() => {\n    displayData()\n  })\n})\n\n// Promise 체인\nfetchData().then(processData).then(displayData)\n\n// async/await\nasync function main() {\n  const data = await fetchData()\n  const processed = await processData(data)\n  await displayData(processed)\n}\n```\n\nPromise는 모나드처럼 동작 - `then`이 bind/flatMap 역할\n\n- **map**: 값 변환 (중첩 허용)\n- **flatMap**: 값 변환 + 평탄화 (Promise의 then)"},{"i":"535","k":["typescript","monad","maybe"],"c":"2025-01-01","m":"2025-01-01","b":"TypeScript Maybe Monad 구현\n\n```typescript\ntype Maybe<T> = Just<T> | Nothing\n\nclass Just<T> {\n  constructor(public value: T) {}\n  bind<U>(fn: (value: T) => Maybe<U>): Maybe<U> {\n    return fn(this.value)\n  }\n}\n\nclass Nothing {\n  bind<U>(fn: (value: any) => Maybe<U>): Maybe<U> {\n    return this\n  }\n}\n\nconst nothing = new Nothing()\n\nfunction safeDivide(x: number, y: number): Maybe<number> {\n  return y === 0 ? nothing : new Just(x / y)\n}\n\nnew Just(10).bind((x) => safeDivide(x, 2)) // Just { value: 5 }\nnew Just(10).bind((x) => safeDivide(x, 0)) // Nothing {}\n```"},{"i":"534","k":["monad","functional-programming"],"c":"2025-01-01","m":"2025-01-01","b":"모나드 입문\n\n모나드 = 복잡한 처리를 숨기면서 연속된 연산을 가능하게 하는 패턴\n\n1. **타입 래퍼** - 값을 감싸는 구조 (예: `NumberWithLogs`)\n2. **래핑 함수** (unit/return) - 값을 모나드로 감쌈\n3. **바인딩 함수** (bind/flatMap) - 래핑된 값에 함수 적용\n\n```typescript\ninterface NumberWithLogs {\n  result: number\n  logs: string[]\n}\n\nfunction wrapWithLogs(n: number): NumberWithLogs {\n  return { result: n, logs: [] }\n}\n\nfunction runWithLogs(\n  input: NumberWithLogs,\n  transform: (n: number) => NumberWithLogs\n): NumberWithLogs {\n  const next = transform(input.result)\n  return { result: next.result, logs: [...input.logs, ...next.logs] }\n}\n```\n\nOption, Promise도 모나드. `then`이 바인딩 함수 역할."},{"i":"533","k":["typescript","monad","pattern"],"c":"2025-01-01","m":"2025-01-01","b":"Maybe Monad로 null 체크 체이닝\n\n```typescript\ntype Maybe<T> = T | null\n\nconst Maybe = {\n  of: <T>(value: T): Maybe<T> => (value != null ? value : null),\n  map: <T, U>(m: Maybe<T>, fn: (v: T) => U): Maybe<U> =>\n    m != null ? Maybe.of(fn(m)) : null,\n}\n\n// 사용 예: DOM 요소 찾아서 스크롤\nMaybe.of(scrollElement.current)\n  .map((root) => root.querySelector(`#${id}`))\n  .map((target) => target.getClientRects()[0])\n  .map((rect) => {\n    root.scrollLeft = rect.left\n  })\n```\n\nGenerator로 early return 패턴도 가능\n\n```typescript\nfunction* handleScroll(id) {\n  const root = scrollElement.current\n  if (!root) return\n  const target = root.querySelector(`#${id}`)\n  if (!target) return\n  yield target.getClientRects()[0]?.left ?? 0\n}\n```"},{"i":"532","k":["javascript","scroll","sync"],"c":"2025-01-01","m":"2025-01-01","b":"두 요소 스크롤 동기화\n\n```javascript\nconst box1 = document.getElementById('box1')\nconst box2 = document.getElementById('box2')\n\nlet isSyncing = false\n\nfunction syncScroll(source, target, sourceWidth, targetWidth) {\n  const ratio = source.scrollLeft / (source.scrollWidth - sourceWidth)\n  target.scrollLeft = ratio * (target.scrollWidth - targetWidth)\n}\n\nbox1.addEventListener('scroll', () => {\n  if (!isSyncing) {\n    isSyncing = true\n    syncScroll(box1, box2, 1280, 640)\n    isSyncing = false\n  }\n})\n\nbox2.addEventListener('scroll', () => {\n  if (!isSyncing) {\n    isSyncing = true\n    syncScroll(box2, box1, 640, 1280)\n    isSyncing = false\n  }\n})\n```\n\n`isSyncing` 플래그로 무한 이벤트 루프 방지"},{"i":"531","k":["javascript","date"],"c":"2025-01-01","m":"2025-01-01","b":"현재 시간부터 목표 시간까지 남은 시간 계산\n\n```javascript\nfunction calculateRemainingTime(targetTime) {\n  const now = new Date()\n  const [h, m, s] = targetTime.split(':').map(Number)\n\n  const target = new Date()\n  target.setHours(h, m, s)\n\n  const diff = target - now\n  if (diff < 0) return null\n\n  return {\n    hours: Math.floor(diff / 3600000) % 24,\n    minutes: Math.floor(diff / 60000) % 60,\n    seconds: Math.floor(diff / 1000) % 60,\n  }\n}\n\ncalculateRemainingTime('18:30:00')\n// { hours: 2, minutes: 15, seconds: 30 }\n```"},{"i":"530","k":["nodejs","turf","geojson"],"c":"2025-01-01","m":"2025-01-01","b":"turf.js로 좌표가 영역 내에 있는지 확인\n\n```javascript\nconst turf = require('@turf/turf')\n\nconst polygon = turf.polygon([\n  [\n    [-73.981, 40.768],\n    [-73.981, 40.764],\n    [-73.975, 40.764],\n    [-73.975, 40.768],\n    [-73.981, 40.768], // 닫기\n  ],\n])\n\nconst point = turf.point([-73.978, 40.766])\n\nturf.booleanPointInPolygon(point, polygon) // true/false\n```\n\n`npm install @turf/turf`"},{"i":"529","k":["typescript","state-management","pattern"],"c":"2025-01-01","m":"2025-01-01","b":"간단한 Redux 스타일 Store 구현\n\n```typescript\ntype Reducer<S, A> = (state: S, action: A) => S\ntype Listener<S> = (state: S) => void\n\ninterface Store<S, A> {\n  getState: () => S\n  subscribe: (listener: Listener<S>) => () => void\n  dispatch: (action: A) => A\n}\n\nfunction createStore<S, A>(\n  reducer: Reducer<S, A>,\n  preloadedState: S\n): Store<S, A> {\n  let currentState = preloadedState\n  let listeners: Listener<S>[] = []\n\n  return {\n    getState: () => currentState,\n    subscribe: (listener) => {\n      listener(currentState)\n      listeners.push(listener)\n      return () => {\n        listeners = listeners.filter((l) => l !== listener)\n      }\n    },\n    dispatch: (action) => {\n      currentState = reducer(currentState, action)\n      listeners.forEach((l) => l(currentState))\n      return action\n    },\n  }\n}\n```"},{"i":"528","k":[],"c":"2025-12-29","m":"2025-12-29","b":"[react-is](https://www.npmjs.com/package/react-is)로 children에서 특정 컴포넌트 필터링\n\n> [!NOTE]\n>\n> `react-is`는 React 공식 패키지로, React 요소 타입 확인 유틸리티. `typeof`나 `instanceof`로는 `React.memo()`, `forwardRef()` 등을 구분할 수 없어서 필요함. 라이브러리 개발, children 타입 검사에 필수.\n\n```tsx\nimport * as ReactIs from 'react-is'\n\n// 특정 컴포넌트 타입인지 확인\nfunction isComponentType<T>(element: ReactNode, Type: ComponentType<T>) {\n  return ReactIs.isElement(element) && element.type === Type\n}\n\n// children에서 특정 컴포넌트만 필터링\nfunction filterChildren<T>(children: ReactNode, Type: ComponentType<T>) {\n  return Children.toArray(children).filter(\n    (child): child is ReactElement<T> =>\n      ReactIs.isElement(child) && child.type === Type\n  )\n}\n\n// 사용: Layout에서 Header, Content, Footer 분리\nconst Layout = ({ children }) => {\n  const headers = filterChildren(children, Header)\n  const contents = filterChildren(children, Content)\n\n  return (\n    <div>\n      <div className=\"header\">{headers}</div>\n      <div className=\"content\">{contents}</div>\n    </div>\n  )\n}\n```"},{"i":"526","k":[],"c":"2025-12-29","m":"2025-12-29","b":"```less\n// 기본\n@primary: #3498db;\n.button { color: @primary; }\n\n// 보간 (선택자, 속성, URL에서)\n@component: 'button';\n.@{component} { ... }           // .button\nbackground-@{property}: blue;   // background-color: blue\nurl('@{path}/icon.svg')\n\n// 변수를 이용한 변수\n@theme: 'primary';\ncolor: @@theme;  // @primary 값\n\n// 맵 (LESS 3.5+)\n@colors: { primary: #3498db; danger: #e74c3c; };\ncolor: @colors[primary];\n\n// CSS 변수 조합\n:root { --primary: @primary; }\n.el { color: var(~'--@{prefix}-color'); }\n```\n\n---\n\n- `~\"\"` 이스케이프 (문자열 그대로 출력)\n- `@{}` 보간 (변수를 문자열로 치환)\n- `@@var` 변수를 이용한 변수 참조\n\n---\n\nhttps://lesscss.org/features/#variables-feature"},{"i":"525","k":[],"c":"2025-12-29","m":"2025-12-29","b":"`gulp.src` 파일 리스트 디버깅\n\n```javascript\n// 1. gulp-debug (가장 간단)\ngulp.src('src/**/*.js').pipe(require('gulp-debug')({ title: 'Files:' }))\n\n// 2. through2 (상세 정보)\nconst through2 = require('through2')\n\ngulp.src('src/**/*.js').pipe(\n  through2.obj((file, enc, cb) => {\n    console.log(file.relative)\n    cb(null, file)\n  })\n)\n\n// 3. glob 패턴 사전 확인\nrequire('glob')('src/**/*.js', (err, files) => console.log(files))\n\n// 조건부 디버깅: `NODE_ENV=debug gulp build`\nconst gulpif = require('gulp-if')\n\ngulp.src('src/**/*.js').pipe(gulpif(process.env.NODE_ENV === 'debug', debug()))\n```"},{"i":"524","k":[],"c":"2025-12-29","m":"2025-12-29","b":"TypeScript Discriminated Union 문서화 딜레마\n\n```typescript\n// 개발: 타입 안전, IDE 자동완성, 명확한 의도\ntype Button =\n  | { type: 'submit'; color: string }\n  | { type: 'reset'; text: string }\n```\n\n```markdown\n<!-- 문서화: 조건부 속성 설명 어려움, 테이블 복잡, 예시 다수 필요 -->\n\n| 속성  | 타입                | 조건                    |\n| ----- | ------------------- | ----------------------- |\n| type  | 'submit' \\| 'reset' | 필수                    |\n| color | string              | type='submit'일 때 필수 |\n| text  | string              | type='reset'일 때 필수  |\n```\n\n---\n\n- 자세한 타입 정의는 TypeScript 정의 파일 참고\n- [typedoc](https://typedoc.org/), [api-extractor](https://api-extractor.com/pages/overview/intro/)로 자동 생성"},{"i":"523","k":[],"c":"2025-12-29","m":"2025-12-29","b":"[react-spring](https://react-spring.dev/docs)\n\n- `onRest` 애니메이션이 물리적으로 정지했을 때 → UI 상태 업데이트용\n- `onResolve` Promise가 resolve될 때 → 비동기 플로우 제어용\n\n```javascript\nconst [springs, api] = useSpring(() => ({\n  x: 0,\n  onRest: () => setStatus('stopped'), // UI 상태\n  onResolve: () => console.log('done'), // Promise 기반 로직\n}))\n\n// Promise 방식도 가능\nawait api.start({ x: 100 })\n```"},{"i":"522","k":[],"c":"2025-12-29","m":"2025-12-29","b":"npm install 실패 시 플래그 선택\n\n```bash\n# 1. Node 버전 불일치 (engines 필드)\nnpm install --ignore-engines\n\n# 2. peer dependencies 충돌 (가장 흔함)\nnpm install --legacy-peer-deps\n\n# 3. 마지막 수단 (권장 안함)\nnpm install --force\n```\n\n> [!NOTE]\n>\n> `--legacy-peer-deps`가 필요한 상황\n\n```json\n{\n  \"react\": \"^18.2.0\",\n  // 라이브러리가 아직 React 18 공식 지원 안 하지만 실제로는 동작함.\n  \"react-beautiful-dnd\": \"^13.1.1\" // peer: react@^16.8 || ^17\n}\n```\n\n```bash\n# 프로젝트 전체 적용\necho \"legacy-peer-deps=true\" > .npmrc\n```"},{"i":"521","k":[],"c":"2025-12-28","m":"2025-12-28","b":"RTL에서 overflow scroll 상태 테스트\n\n```javascript\n// scrollWidth > clientWidth면 가로 스크롤 필요\nconst isOverflowScrollable = (el) => ({\n  horizontal: el.scrollWidth > el.clientWidth,\n  vertical: el.scrollHeight > el.clientHeight,\n})\n\ntest('overflow 확인', () => {\n  render(<ScrollableComponent parentWidth={300} childWidth={500} />)\n\n  const parent = screen.getByTestId('parent-container')\n\n  expect(isOverflowScrollable(parent).horizontal).toBe(true)\n})\n\ntest('동적 크기 변경', () => {\n  const { rerender } = render(<Comp parentWidth={400} childWidth={300} />)\n\n  expect(isOverflowScrollable(screen.getByTestId('parent')).horizontal).toBe(\n    false\n  )\n\n  rerender(<Comp parentWidth={400} childWidth={600} />)\n\n  expect(isOverflowScrollable(screen.getByTestId('parent')).horizontal).toBe(\n    true\n  )\n})\n```\n\n> [!CAUTION]\n> DOM 완전 렌더링 후 측정해야 정확. `getBoundingClientRect()`는 실제 크기 반환."},{"i":"520","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Node.js에서 윈도우즈 외부 경로 파일 읽기\n\n```javascript\nconst path = require('path')\nconst fs = require('fs').promises\n\n// 슬래시 사용 (Node.js가 자동 변환)\n// ('C:/Users/username/Documents/file.txt')\n\n// 또는 백슬래시 이스케이프\n// ('C:\\\\Users\\\\username\\\\Documents\\\\file.txt')\n\n// 권장: path.join 사용 (크로스 플랫폼)\npath.join(process.env.USERPROFILE, 'Documents', 'file.txt')\n\n// path.resolve로 절대 경로 생성\npath.resolve('../../Documents/file.txt')\n```\n\n> [!CAUTION]\n> 사용자 입력으로 경로 받을 때는 `path.normalize()`로 디렉토리 트래버설 공격 방지."},{"i":"519","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Date.getDay() - 요일 반환 (0=일요일, 6=토요일)\n\n```javascript\nnew Date().getDay() // 0~6\nnew Date('2024-12-25').getDay() // 3 (수요일)\nnew Date(2024, 11, 25).getDay() // 월은 0부터 시작\n\nconst days = ['일', '월', '화', '수', '목', '금', '토']\ndays[new Date().getDay()] // 오늘 요일\n```"},{"i":"518","k":[],"c":"2025-12-28","m":"2025-12-28","b":"검색어 필터링 UI → **Debounce** (300-500ms)\n\n타이핑 완료 후 검색이 더 자연스러움. \"자바스크립트\" 입력 시 매 글자마다 검색하면 비효율적.\n\n```javascript\nconst handleSearch = debounce((term) => performSearch(term), 300)\ninput.addEventListener('input', (e) => handleSearch(e.target.value))\n```\n\nThrottle은 실시간 검색 결과를 보여주면서 요청 제한할 때 사용 (1초마다 최대 1회 등)."},{"i":"516","k":[],"c":"2025-12-28","m":"2025-12-28","b":"TypeScript 제네릭 기본값 - `<T extends Type = DefaultValue>`\n\n```typescript\ntype SelectProps<T extends 'single' | 'multiple' = 'multiple'> = {\n  mode: T\n  value: T extends 'single' ? string : string[]\n}\n\nconst a: SelectProps = { mode: 'multiple', value: ['a', 'b'] } // 기본값 사용\nconst b: SelectProps<'single'> = { mode: 'single', value: 'a' } // 명시적 지정\n\n// 여러 매개변수에 각각 기본값\ntype ApiResponse<\n  TData = any,\n  TStatus extends 'loading' | 'success' | 'error' = 'loading'\n> = {\n  data: TStatus extends 'success' ? TData : null\n  status: TStatus\n}\n```\n\n가장 자주 사용되는 케이스를 기본값으로 설정하면 제네릭을 항상 명시하는 번거로움 줄어듦."},{"i":"515","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Vitest 모킹: `vi.mocked()` vs `vi.hoisted()`\n\n`vi.mocked()`는 타입만 제공, 실제 모킹 구현체는 별도로 필요.\n\n```typescript\n// ❌ mockImplementation이 undefined\nconst mockUseSize = vi.mocked(useSize)\n\n// ✅ vi.hoisted() 사용 (추천)\nconst mockUseSize = vi.hoisted(() => vi.fn())\n\nvi.mock('ahooks', () => ({ useSize: mockUseSize }))\n\nbeforeEach(() => {\n  mockUseSize.mockImplementation(() => ({ width: 100, height: 20 }))\n})\n```\n\nDOM 속성 모킹 (scrollWidth/clientWidth):\n\n```typescript\nbeforeEach(() => {\n  Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {\n    configurable: true,\n    get() {\n      return 150\n    },\n  })\n})\n\nafterEach(() => {\n  // 원래 속성 복원\n})\n```\n\nDOM 측정이 복잡하면 Playwright/Cypress로 통합 테스트 고려."},{"i":"514","k":[],"c":"2025-12-28","m":"2025-12-28","b":"ResizeObserver Mock (Vitest, ES Module 환경)\n\n`vi.hoisted()`로 모킹 함수 미리 선언 후 `vi.mock()`에서 사용:\n\n```typescript\nimport { vi } from 'vitest'\n\nconst { mockResizeObserver, MockResizeObserver } = vi.hoisted(() => {\n  let observers: { callback: ResizeObserverCallback; observer: any }[] = []\n\n  const MockResizeObserver = vi.fn().mockImplementation((callback) => {\n    const observer = {\n      observe: vi.fn(),\n      unobserve: vi.fn(),\n      disconnect: vi.fn(),\n    }\n    observers.push({ callback, observer })\n    return observer\n  })\n\n  return {\n    MockResizeObserver,\n    mockResizeObserver: {\n      triggerResize: (entries: ResizeObserverEntry[], index = 0) => {\n        observers[index]?.callback(entries, observers[index].observer)\n      },\n      reset: () => {\n        observers = []\n      },\n    },\n  }\n})\n\nvi.mock('resize-observer-polyfill', () => ({ default: MockResizeObserver }))\n\n// 테스트에서 사용\nbeforeEach(() => mockResizeObserver.reset())\n\nit('should handle resize', () => {\n  const mockEntry = {\n    target: document.createElement('div'),\n    contentRect: { width: 100 },\n  }\n  mockResizeObserver.triggerResize([mockEntry])\n})\n```\n\n핵심: ES Module에서는 `vi.hoisted()` 필수. 여러 observer는 배열로 추적."},{"i":"513","k":[],"c":"2025-12-28","m":"2025-12-28","b":"dayjs로 특정 날짜가 포함된 주의 모든 날짜 가져오기\n\n```javascript\nimport dayjs from 'dayjs'\nimport isoWeek from 'dayjs/plugin/isoWeek'\ndayjs.extend(isoWeek)\n\n// startOf('week') → 일요일 시작\n// startOf('isoWeek') → 월요일 시작 (캘린더 UI에 주로 사용)\nfunction getWeekDays(date, iso = true) {\n  const start = dayjs(date).startOf(iso ? 'isoWeek' : 'week')\n  return Array.from({ length: 7 }, (_, i) =>\n    start.add(i, 'day').format('YYYY-MM-DD')\n  )\n}\n\ngetWeekDays('2025-10-28') // ['2025-10-27', ..., '2025-11-02']\n```"},{"i":"512","k":[],"c":"2025-12-28","m":"2025-12-28","b":"JavaScript 부동소수점 오차 - `0.1 + 0.2 = 0.30000000000000004`\n\nIEEE 754 64비트 부동소수점 표준. 0.1은 이진법으로 무한 반복(`0.0001100110011...`)이라 근사치 저장됨.\n\n```javascript\n// 해결책\n;(1.1 * 10 + 0.1 * 10) / 10 // 정수 변환\nparseFloat((1.1 + 0.1).toFixed(1)) // toFixed\nMath.abs(1.1 + 0.1 - 1.2) < Number.EPSILON // 비교 시\n// 정밀 계산: decimal.js, big.js, bignumber.js\n```\n\nJS만의 문제 아님. Python, Java, C++ 등 IEEE 754 사용하는 모든 언어에서 동일."},{"i":"511","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Drizzle ORM에서 IN 절 사용\n\n```typescript\nimport { inArray } from 'drizzle-orm'\n\nconst authorIds = [1, 2, 3]\n\n// SELECT * FROM authors WHERE id IN (1, 2, 3)\nconst authors = await db\n  .select()\n  .from(schema.authors)\n  .where(inArray(schema.authors.id, authorIds))\n\n// 빈 배열 처리\nconst result =\n  authorIds.length > 0\n    ? await db\n        .select()\n        .from(schema.authors)\n        .where(inArray(schema.authors.id, authorIds))\n    : []\n```"},{"i":"510","k":[],"c":"2025-12-28","m":"2025-12-28","b":"캘린더 이벤트 겹침 처리 알고리즘\n\n겹치는 이벤트들을 나란히 배치하기 위한 레인 할당:\n\n```typescript\ninterface Event {\n  id: string\n  start: number\n  end: number\n}\ninterface PositionedEvent extends Event {\n  lane: number\n  totalLanes: number\n}\n\nfunction calculatePositions(events: Event[]): PositionedEvent[] {\n  // 1. 시작 시간 순 정렬\n  const sorted = [...events].sort((a, b) => a.start - b.start || a.end - b.end)\n\n  // 2. 겹침 그룹 찾기 + 레인 할당\n  const result: PositionedEvent[] = []\n  let group: Event[] = []\n  let groupEnd = 0\n\n  sorted.forEach((event) => {\n    if (group.length === 0 || event.start < groupEnd) {\n      group.push(event)\n      groupEnd = Math.max(groupEnd, event.end)\n    } else {\n      processGroup(group, result)\n      group = [event]\n      groupEnd = event.end\n    }\n  })\n  if (group.length) processGroup(group, result)\n\n  return result\n}\n\nfunction processGroup(group: Event[], result: PositionedEvent[]) {\n  const activeLanes: number[] = []\n\n  group.forEach((event) => {\n    // 사용 가능한 가장 낮은 레인 찾기\n    let lane = activeLanes.findIndex((end) => end <= event.start)\n    if (lane === -1) lane = activeLanes.length\n\n    activeLanes[lane] = event.end\n    result.push({ ...event, lane, totalLanes: 0 })\n  })\n\n  // totalLanes 업데이트\n  const maxLane =\n    Math.max(...result.slice(-group.length).map((e) => e.lane)) + 1\n  result.slice(-group.length).forEach((e) => (e.totalLanes = maxLane))\n}\n\n// CSS 적용: left = lane/totalLanes * 100%, width = 100%/totalLanes\n```\n\n- [FullCalendar](https://github.com/fullcalendar/fullcalendar)\n- [Interval Scheduling](https://en.wikipedia.org/wiki/Interval_scheduling)"},{"i":"509","k":[],"c":"2025-12-28","m":"2026-01-07","b":"RSC에서 날짜 처리: 쿠키 기반 타임존\n\n문제: Date 객체 직렬화, 서버/클라이언트 타임존 불일치, 하이드레이션 FOUT\n\n해결: 서버에서 타임존 적용해서 렌더링\n\n```javascript\n// middleware.js - 타임존 감지\nexport function middleware(request) {\n  const timezone =\n    request.cookies.get('user-timezone')?.value ||\n    request.geo?.timezone || // Vercel\n    'UTC'\n\n  const response = NextResponse.next()\n  response.headers.set('x-user-timezone', timezone)\n  return response\n}\n\n// 서버 컴포넌트\nconst getUserTimezone = cache(() => {\n  return headers().get('x-user-timezone') || 'UTC'\n})\n\n// 클라이언트 - 타임존 자동 감지 후 쿠키 저장\nuseEffect(() => {\n  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone\n  document.cookie = `user-timezone=${tz}; path=/; max-age=31536000`\n}, [])\n```\n\n첫 방문은 인프라 추정값 사용, 이후 정확한 타임존 적용. FOUT 없음.\n\n---\n\n대안: `useSyncExternalStore` (client component 전용)\n\n```tsx\n'use client'\n\nconst timezoneStore = {\n  getSnapshot: () => Intl.DateTimeFormat().resolvedOptions().timeZone,\n  getServerSnapshot: () => 'UTC',\n  subscribe: () => () => {},\n}\n\nfunction useTimezone() {\n  return useSyncExternalStore(\n    timezoneStore.subscribe,\n    timezoneStore.getSnapshot,\n    timezoneStore.getServerSnapshot\n  )\n}\n```\n\n서버: UTC → 클라이언트: 실제 타임존. 하이드레이션 에러 없음, 대신 FOUT 발생."},{"i":"508","k":[],"c":"2025-12-28","m":"2025-12-28","b":"대용량 리스트에서 selected 아이템 조회 최적화\n\nO(n×m) → O(n+m)로 개선: Map으로 인덱싱\n\n```typescript\n// 기존: 매번 find (느림)\nselected.map((key) => items.find((item) => item.key === key))\n\n// 개선: Map 인덱싱 (빠름)\nconst itemsMap = new Map(items.map((item) => [item.key, item]))\nselected.map((key) => itemsMap.get(key)).filter(Boolean)\n```\n\nReact에서 백그라운드 인덱싱:\n\n```typescript\nfunction useItemsIndex(items: Item[]) {\n  const [map, setMap] = useState(new Map())\n  const [ready, setReady] = useState(false)\n\n  useEffect(() => {\n    // 청크 단위로 처리하여 UI 블로킹 방지\n    const newMap = new Map(items.map((item) => [item.key, item]))\n    setMap(newMap)\n    setReady(true)\n  }, [items])\n\n  return { map, ready }\n}\n```\n\n10만개 이상이면 Web Worker 고려."},{"i":"507","k":[],"c":"2025-12-28","m":"2025-12-28","b":"Tumblr 테마 Vue → Web Components 마이그레이션\n\nNPF 데이터는 `<script type=\"application/json\" data-npf>`에 저장. 컴포넌트에서 `closest`로 포스트 컨테이너 찾아서 참조 (React Context 패턴과 유사).\n\n```html\n<div id=\"{PostID}\" data-type=\"{PostType}\">\n  {block:Text}\n  <script type=\"application/json\" data-npf>\n    {NPF}\n  </script>\n  <tumblr-npf-media></tumblr-npf-media>\n  <tumblr-npf-text></tumblr-npf-text>\n  {/block:Text}\n</div>\n```\n\n```javascript\n// 데이터 참조\nconst post = this.closest('[id][data-type]')\nconst npf = JSON.parse(post.querySelector('script[data-npf]').textContent)\n\n// 렌더링 - createElement 사용 (Tumblr 템플릿 ${} 충돌 회피)\nconst img = document.createElement('img')\nimg.src = imageURL\nthis.appendChild(img)\n```\n\n제약: `{NPF}`는 Text 포스트에서만 사용 가능. Photo는 기존 Tumblr 변수 사용."},{"i":"506","k":[],"c":"2025-12-26","m":"2025-12-26","b":"React에서 iframe 내부에 컴포넌트 렌더링\n\n```tsx\n// react-frame-component 사용 (권장)\nimport Frame from 'react-frame-component'\n;<Frame head={<style>{`body { margin: 0; }`}</style>}>\n  <MyComponent />\n</Frame>\n\n// 직접 구현: createPortal + contentDocument\nfunction IframeRenderer({ children }) {\n  const iframeRef = useRef<HTMLIFrameElement>(null)\n  const [mountNode, setMountNode] = useState<HTMLElement | null>(null)\n  useEffect(() => {\n    const iframe = iframeRef.current\n    const handleLoad = () => setMountNode(iframe?.contentDocument?.body ?? null)\n    iframe?.addEventListener('load', handleLoad)\n    if (iframe?.contentDocument?.readyState === 'complete') handleLoad()\n    return () => iframe?.removeEventListener('load', handleLoad)\n  }, [])\n  return (\n    <>\n      <iframe ref={iframeRef} />\n      {mountNode && createPortal(children, mountNode)}\n    </>\n  )\n}\n```\n\n> [!WARNING]\n> iframe 내부는 부모 CSS 미적용 (스타일 별도 주입 필요), 이벤트 버블링 안 됨. 단순 스타일 격리 목적이면 Shadow DOM 고려."},{"i":"505","k":[],"c":"2025-12-26","m":"2025-12-26","b":"Jest setupFiles vs setupFilesAfterEnv - 실행 시점이 다르다.\n\n- **setupFiles**: 테스트 프레임워크 설치 **전**. Jest 전역 객체 없음. 환경 변수, 폴리필용.\n- **setupFilesAfterEnv**: 테스트 프레임워크 설치 **후**. `jest.setTimeout()`, 커스텀 matcher, 전역 `beforeEach`용.\n\n`process.env`는 setupFiles에서. 모듈이 import 시점에 환경 변수를 읽기 때문에 setupFilesAfterEnv에서 설정하면 이미 늦다.\n\n```js\n// jest.config.js\n{ setupFiles: ['./jest.env.js'], setupFilesAfterEnv: ['./jest.setup.js'] }\n\n// jest.env.js - 환경 변수\nprocess.env.API_URL = 'http://test-api.example.com'\n\n// jest.setup.js - Jest API 활용\nimport '@testing-library/jest-dom'\nbeforeEach(() => { jest.clearAllMocks() })\n```"},{"i":"503","k":[],"c":"2025-12-26","m":"2025-12-26","b":"yalc - 로컬 Node 모듈을 다른 프로젝트에서 바로 테스트. npm link보다 문제 적음 (파일 복사 방식).\n\n```bash\nnpm i -g yalc\n\n# 패키지에서\nyalc publish          # ~/.yalc에 저장\nyalc push             # 연결된 모든 프로젝트에 반영\nyalc publish --push   # 둘 다\n\n# 앱에서\nyalc add my-module\nyalc remove my-module && npm install  # 정리\n```\n\nwatch 모드: `\"dev\": \"tsup src/index.ts --watch --onSuccess 'yalc push'\"`\n\n`.gitignore`: `.yalc`, `yalc.lock`"},{"i":"501","k":[],"c":"2025-12-26","m":"2025-12-26","b":"tsconfig 핵심: target, lib, module\n\n```\nTypeScript (.ts)\n    ↓\nlib: 타입 체크 시 뭘 알고 있나? (Promise, Map 등)\n    ↓\ntarget: 문법을 얼마나 낮출 건가? (ES2020 → ?. 그대로, ES2019 → 삼항연산자로)\n    ↓\nmodule: import/export를 뭘로? (CommonJS → require, ESNext → import)\n    ↓\nJavaScript (.js)\n```\n\ntarget vs lib 분리 이유: 문법(syntax)과 API(runtime)는 다름\n\n- `?.` → target이 변환 (문법)\n- `Promise` → 폴리필이 해결 (API)\n\n```json\n{\n  \"target\": \"ES2019\", // 하위호환 (optional chaining 이전)\n  \"module\": \"ESNext\", // 트리쉐이킹 가능\n  \"lib\": [\"ES2020\", \"DOM\"], // 타입은 넉넉하게\n  \"moduleResolution\": \"Node\"\n}\n```\n\n> [!CAUTION]\n> TS 버전 올리면서 target 그대로 두면 Webpack4 같은 구형 번들러에서 파싱 실패할 수 있음."},{"i":"500","k":[],"c":"2025-12-26","m":"2025-12-26","b":"MDIR 스타일 인터페이스 개발 - SQLite DB를 탐색하는 도구\n\n1. [Neovim Telescope](https://github.com/nvim-telescope/telescope.nvim) 순수 Neovim 솔루션. `:StoryBrowse`, `:StorySearch`\n2. [VS Code 확장](https://code.visualstudio.com/api) Activity Bar + Tree View + Quick Pick\n3. [Bun Single-file Executable](https://bun.sh/docs/bundler/executables) `--compile`로 DB + 웹서버 + 프론트엔드 단일 파일 (~100MB)"},{"i":"499","k":[],"c":"2025-12-26","m":"2025-12-26","b":"TypeScript 중첩 객체 타입 부분 수정\n\n```typescript\ninterface O {\n  actions: { a: string; b: number }\n}\n\n// 중첩 속성 Optional로 변경\ntype MakeNestedOptional<T, K extends keyof T, OK extends keyof T[K]> = Omit<\n  T,\n  K\n> & {\n  [P in K]: Omit<T[K], OK> & Partial<Pick<T[K], OK>>\n}\ntype Result = MakeNestedOptional<O, 'actions', 'b'> // { actions: { a: string; b?: number } }\n\n// 중첩 속성 타입 오버라이드\ntype OverrideNested<T, K extends keyof T, Override> = Omit<T, K> & {\n  [P in K]: Omit<T[K], keyof Override> & Override\n}\ntype Result2 = OverrideNested<O, 'actions', { b: boolean }> // { actions: { a: string; b: boolean } }\n```\n\n한두 군데만 쓸 거면 그냥 손으로 타입 작성이 더 명확함. 유틸리티 타입은 반복 사용할 때만 가치.\n\n```typescript\n// 라이브러리 객체 변이 주의\n// ❌ info.actions.onDownload = undefined\n// ✅ const modifiedInfo = { ...info, actions: { ...info.actions, onDownload: undefined } }\n```"},{"i":"498","k":[],"c":"2025-12-26","m":"2025-12-26","b":"증기 계란 조리기: 계란이 많을수록 물을 적게 넣는 이유\n\n```\n계란 1개 (완숙): 117ml\n계란 6개 (완숙): 96ml\n```\n\n증기 조리 방식은 물의 양 = 조리 시간. 계란이 많으면:\n\n- 서로 열을 공유/유지 (\"집단 난방\" 효과)\n- 증기가 좁은 공간에 집중되어 효율 ↑\n- 열 손실 표면적이 상대적으로 ↓\n\n물에 삶는 것과 달리, 증기 조리는 배치 밀도에 따라 열 효율이 크게 달라짐."},{"i":"497","k":[],"c":"2025-12-26","m":"2025-12-26","b":"styled-components에서 `&-header` 같은 BEM 스타일 자식 선택자를 HTML에서 참조하는 방법? **없다.**\n\n```tsx\n// ❌ 해시된 클래스명과 매칭 안됨\nconst Container = styled.div`\n  &-header { color: red; }\n`\n\n// ✅ 방법 1: 일반 클래스 선택자\nconst Container = styled.div`\n  .header { color: red; }\n`\n<Container><div className=\"header\">Header</div></Container>\n\n// ✅ 방법 2: Styled 컴포넌트 변수로 선언 (추천)\nconst Header = styled.div`color: red;`\nconst Container = styled.div`\n  ${Header} { margin-bottom: 20px; }\n`\n```"},{"i":"496","k":[],"c":"2025-12-21","m":"2026-01-27","b":"React Children API - 가능하지만 비추천\n\n> [!WARNING]\n>\n> 암묵적 의존성, 타입 안전성 부족, 매 렌더 트리 순회, 예측 불가능\n\n## 재귀 순회\n\n```tsx\nfunction traverseReactNode(children: ReactNode, callback, typeToMatch?) {\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return\n    if (child.type === Fragment) {\n      traverseReactNode(child.props.children, callback, typeToMatch)\n      return\n    }\n    if (child.type === typeToMatch) callback(child)\n    if (child.props?.children) {\n      traverseReactNode(child.props.children, callback, typeToMatch)\n    }\n  })\n}\n```\n\n## 동적 래핑\n\n```tsx\nconst renderChildren = (children) => {\n  const elements = React.Children.toArray(children)\n  const hasLink = elements.some(\n    (el) => React.isValidElement(el) && el.props.url\n  )\n  return hasLink ? children : <ul>{children}</ul>\n}\n// toArray는 string, number도 포함 → isValidElement 체크 필수\n```\n\n## 대안: Compound Component\n\n```tsx\n// ❌ 마법처럼 동작 (예측 불가)\n<Tabs>{/* 어디에 넣든 Tab 찾아줌 */}</Tabs>\n\n// ✅ Compound Component\n<Tabs.Root>\n  <Tabs.List>\n    <Tabs.Trigger value=\"a\">A</Tabs.Trigger>\n  </Tabs.List>\n  <Tabs.Content value=\"a\">Content</Tabs.Content>\n</Tabs.Root>\n```\n\nReact 팀도 2021년부터 Children API 사용 권장하지 않음: \"Using Children is uncommon and can lead to fragile code\"\n\n역사적 배경: 2013년엔 Context API도 없었음. \"선언형\"이라면서 Children API로 명령형 트리 순회 제공하는 이중성.\n\n---\n\n- [react-children-utilities](https://github.com/fernandopasik/react-children-utilities) - deepMap, deepFind, deepFilter"},{"i":"495","k":["react","concurrent"],"c":"2025-12-21","m":"2025-12-21","b":"React 19 Concurrent 훅 - `useTransition`, `useOptimistic` vs React Query\n\n**useTransition**\n\n```tsx\nconst [isPending, startTransition] = useTransition()\n\nfunction handleFilter(value: string) {\n  startTransition(async () => {\n    const data = await fetchData(value)\n    setResults(data)\n  })\n}\n```\n\n- `startTransition`으로 감싼 state 업데이트는 **비긴급(non-urgent)**으로 처리\n- 급한 업데이트(타이핑, 클릭 피드백)를 먼저 처리하고, transition 작업은 뒤로 미룸\n- `isPending`으로 로딩 상태 확인, 기존 UI 유지하면서 백그라운드에서 새 UI 준비\n\n**useOptimistic**\n\n```tsx\nconst [optimisticItems, addOptimistic] = useOptimistic(\n  items,\n  (current, newItem) => [...current, newItem]\n)\n\nasync function handleAdd(item: Item) {\n  addOptimistic(item) // 즉시 UI 반영\n  await saveToServer(item) // 실패하면 자동 rollback\n}\n```\n\n- 서버 응답 전에 UI 먼저 업데이트, 실패 시 자동 복구\n- 좋아요 버튼, 장바구니 추가 같은 인터랙션에 적합\n\n---\n\n**현실: Query가 이미 너무 편함**\n\n```tsx\nconst { data, isPending } = useQuery({\n  queryKey: ['items', filter],\n  queryFn: () => fetchItems(filter),\n})\n\nuseMutation({\n  mutationFn: addItem,\n  onMutate: async (newItem) => {\n    const previous = queryClient.getQueryData(['items'])\n    queryClient.setQueryData(['items'], (old) => [...old, newItem])\n    return { previous }\n  },\n  onError: (err, _, context) => {\n    queryClient.setQueryData(['items'], context.previous)\n  },\n})\n```\n\n캐싱, 리페치, devtools, stale-while-revalidate까지 한 방에 해결. 팀에서 이미 쓰고 있으면 \"굳이?\" 됨.\n\n**useTransition이 의미 있는 지점** - 무거운 클라이언트 연산, Next.js Server Actions 조합\n\n```tsx\n<form action={(formData) => {\n  startTransition(async () => {\n    await createItem(formData)\n    router.refresh()\n  })\n}}>\n```"},{"i":"492","k":["sqlite","serverless","turso"],"c":"2025-04-06","m":"2025-04-06","b":"Serverless 환경에서 SQLite 사용 불가 → **Turso** 도입\n\nVercel 같은 플랫폼에서 SQLite 공식 미지원. 읽기 전용 데이터 파일도 최근 환경에서 에러 발생.\n\n> [!TIP]\n> [Turso](https://turso.tech/) - LibSQL 기반 SQLite-compatible serverless DB. 기존 쿼리 그대로 사용 가능.\n\nJSON 기반 대안:\n- [AlaSQL](https://github.com/AlaSQL/alasql) - SQL 문법으로 JSON 쿼리\n- [JSONata](https://github.com/jsonata-js/jsonata) - 선언적 JSON 질의"},{"i":"491","k":[],"c":"2025-04-01","m":"2026-07-27","b":"`llms.txt`는 웹사이트나 애플리케이션이 자신이 사용하는 LLM(대규모 언어 모델) 및 관련 설정에 대해 명시적으로 문서화할 수 있는 포맷이다.\n\n아래 링크들은 `llms.txt` 포맷이 실제로 어떻게 사용되고 있는지 참고한 자료들. 각 사이트는 자신들의 문서를 `llms.txt`에 구조적으로 명시하고 있다.\n\n---\n\n- [llms-txt](https://llmstxt.org/)\n- [effect.website](https://effect.website/llms.txt)\n- [hono.dev](https://hono.dev/llms.txt)"},{"i":"490","k":["visibility-state"],"c":"2025-03-25","m":"2025-03-25","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/nkzw-tech/use-visibility-state/\">\n  use-visibility-state\n</QuoteLink>\n\n```ts\nconst isVisible = document.visibilityState === 'visible'\nconst isHidden = document.visibilityState === 'hidden'\n\ndocument.addEventListener('visibilitychange', onChange)\n```\n\n---\n\n```ts\n/**\n * `visibilitychange` 이벤트는 정상적인 탭 전환 시에는 잘 작동하지만, 시스템 슬립, 화면 잠금, 또는 브라우저가 백그라운드에서 복귀할 때는 누락될 수 있다.\n * 그래서 수동으로 처리되는 부분이 필요\n */\ndocument.addEventListener('mousemove', setVisible)\ndocument.addEventListener('keydown', setVisible)\n```\n\n---\n\n```ts\nconst [isVisible, setIsVisible] = useState(true)\n\nuseEffect(() => {\n  const onChange = () => {\n    const newState = document.visibilityState !== 'hidden'\n\n    if (newState !== isVisible) {\n      setIsVisible(newState)\n    }\n  }\n\n  // addEventListener\n\n  return () => {\n    // removeEventListener\n  }\n}, [isVisible])\n```\n\n---\n\n- [`document.visibilityState`](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState)"},{"i":"488","k":["esm"],"c":"2025-03-08","m":"2025-03-08","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://stackblitz.com/edit/stackblitz-starters-qebq7z3z?file=index.html\">\n  ES Module Shims 2.0\n</QuoteLink>\n\n---\n\n- [Announcing ES Module Shims 2.0](https://guybedford.com/es-module-shims-2.0)\n- [ESM&gt;CDN](https://esm.sh/)\n- [Running React 19 From a CDN and using esm.sh](https://peterkellner.net/2024/05/10/running-react-19-from-a-cdn-and-using-esm.sh/)"},{"i":"485","k":["parallel","serial"],"c":"2025-03-05","m":"2025-03-05","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://stackblitz.com/edit/vitest-dev-vitest-jtqzu5sy?file=test%2Fbasic.test.ts\">\n  TaskRunner Test\n</QuoteLink>\n\n```ts\ntype Task = (callback: (result: string) => void) => void\n\ntype TasksCallback = (results: string[]) => void\n\nclass TaskRunner {\n  protected tasks: Task[] = []\n\n  protected results: Array<string> = []\n\n  addTask(task: Task) {\n    this.tasks.push(task)\n  }\n}\n\nclass ParallelTaskRunner extends TaskRunner {\n  run(callback: TasksCallback) {\n    const totalTasks = this.tasks.length\n\n    this.tasks.forEach((task) => {\n      task((result) => {\n        this.results.push(result)\n\n        if (this.results.size === totalTasks) {\n          callback([...this.results])\n        }\n      })\n    })\n  }\n}\n\nclass SerialTaskRunner extends TaskRunner {\n  index = 0\n\n  run(callback: TasksCallback) {\n    const executeTask = () => {\n      if (this.index >= this.tasks.length) {\n        callback([...this.results])\n        return\n      }\n\n      this.tasks[this.index]((result) => {\n        this.results.push(result)\n\n        this.index++\n\n        executeTask()\n      })\n    }\n\n    executeTask()\n  }\n}\n```\n\n- [Node.js script to run multiple commands in parallel · Muffin Man](https://muffinman.io/blog/node-script-to-run-multiple-commands-in-parallel/)[^485-1]\n\n---\n\n[^485-1]: [concurrently](https://github.com/open-cli-tools/concurrently)"},{"i":"484","k":["store","redux","pattern"],"c":"2025-03-05","m":"2026-08-02","b":"Redux 스타일 Store 직접 구현\n\n```ts\ntype Reducer<S, A> = (state: S, action: A) => S\ntype Listener<S> = (state: S) => void\n\ninterface Store<S, A> {\n  getState: () => S\n  subscribe: (listener: Listener<S>) => () => void\n  dispatch: (action: A) => A\n}\n\nfunction createStore<S, A>(\n  reducer: Reducer<S, A>,\n  preloadedState: S\n): Store<S, A> {\n  let currentState: S = preloadedState\n  let listeners: Listener<S>[] = []\n\n  function getState(): S {\n    return currentState\n  }\n\n  function subscribe(listener: Listener<S>): () => void {\n    listener(currentState)\n\n    listeners.push(listener)\n\n    return function unsubscribe() {\n      listeners = listeners.filter((l) => l !== listener)\n    }\n  }\n\n  function dispatch(action: A): A {\n    currentState = reducer(currentState, action)\n\n    listeners.forEach((listener) => {\n      listener(currentState)\n    })\n\n    return action\n  }\n\n  return {\n    subscribe,\n    getState,\n    dispatch,\n  }\n}\n\ntype Item = {\n  body: string\n}\n\ntype State = {\n  items: Item[]\n}\n\ntype Action = {\n  type: 'ADD'\n  payload: Item\n}\n\nconst store = createStore<State, Action>(\n  (state, action) => {\n    switch (action.type) {\n      case 'ADD':\n        return {\n          ...state,\n          items: state.items.concat(action.payload),\n        }\n      default:\n        return state\n    }\n  },\n  {\n    items: [],\n  }\n)\n\nstore.subscribe((state) => {\n  console.log(state.items)\n})\n\nstore.dispatch({\n  type: 'ADD',\n  payload: {\n    body: 'hello',\n  },\n})\n```"},{"i":"483","k":["typescript","Exclude"],"c":"2025-03-05","m":"2025-03-05","b":"```tsx\nimport * as React from 'react'\n\nexport type NonEmptyString<T> = Exclude<T, ''>\n\ntype Props<T> = {\n  src: NonEmptyString<T>\n}\n\nfunction Image<T extends string>({ src }: Props<T>) {\n  return <img src={src} alt=\"\" />\n}\n\nfunction App() {\n  return (\n    <>\n      {/* @ts-expect-error */}\n      <Image src=\"\" />\n\n      <Image src=\"ab\" />\n    </>\n  )\n}\n```"},{"i":"482","k":["typescript","union"],"c":"2025-03-05","m":"2025-03-05","b":"```tsx\nimport * as React from 'react'\nimport { IsEqual } from 'type-plus'\n\ntype ModalProps =\n  | {\n      variant: 'no-title'\n    }\n  | {\n      variant: 'title'\n      title: string\n    }\n\nexport const Modal = (props: ModalProps) => {\n  if (props.variant === 'no-title') {\n    return <div>No title</div>\n  } else {\n    type Test = IsEqual<\n      typeof props,\n      {\n        variant: 'title'\n        title: string\n      }\n    >\n\n    return <div>Title: {props.title}</div>\n  }\n}\n\nexport const Test = () => {\n  return (\n    <div>\n      <Modal variant=\"title\" title=\"Hello\" />\n      <Modal variant=\"no-title\" />\n\n      {/* @ts-expect-error */}\n      <Modal />\n      <Modal\n        variant=\"no-title\"\n        // @ts-expect-error\n        title=\"Oh dear\"\n      />\n    </div>\n  )\n}\n```"},{"i":"481","k":["typescript"],"c":"2025-03-05","m":"2025-03-05","b":"```ts\nimport * as React from 'react'\n\ntype Prettify<T> = {\n  [K in keyof T]: T[K]\n} & {}\n\ntype T = React.ComponentProps<'div'>\n\ntype P = Prettify<T>\n```"},{"i":"480","k":["typescript","generic","react"],"c":"2025-03-05","m":"2025-03-05","b":"React 제네릭 컴포넌트 패턴 - `<T extends Record<string, any>>`로 row/item 타입 추론\n\n```tsx\nimport * as React from 'react'\n\ntype Props<TRow> = {\n  rows: TRow[]\n  renderRow: (row: TRow, index: number) => React.ReactNode\n}\n\nconst Table = <TRow extends Record<string, any>>({\n  rows,\n  renderRow,\n}: Props<TRow>) => {\n  return (\n    <table>\n      <tbody>{rows.map((row, index) => renderRow(row, index))}</tbody>\n    </table>\n  )\n}\n\nfunction App() {\n  return (\n    <Table\n      rows={[{ name: 'lee' }]}\n      renderRow={(row, index) => (\n        <tr key={index}>\n          <td>{row.name}</td>\n        </tr>\n      )}\n    />\n  )\n}\n```\n\n```tsx\nimport * as React from 'react'\nimport { UseComboboxProps, useCombobox } from 'downshift'\n\nfunction Combobox<T extends Record<string, any>>(\n  props: UseComboboxProps<T> & {\n    renderItem: (item: T) => React.ReactNode\n  }\n) {\n  const combobox = useCombobox(props)\n\n  return (\n    <div>\n      <input\n        placeholder=\"구성원을 검색해주세요.\"\n        {...combobox.getInputProps()}\n      />\n      <div {...combobox.getMenuProps()}>\n        {combobox.isOpen &&\n          props.items.map((item, index) => (\n            <div key={item.id} {...combobox.getItemProps({ item, index })}>\n              {props.renderItem(item)}\n            </div>\n          ))}\n      </div>\n    </div>\n  )\n}\n\nfunction App() {\n  return (\n    <Combobox\n      items={[{ id: 1, name: 'eunsoo' }]}\n      itemToString={(item) => `${item?.id}`}\n      renderItem={(item) => {\n        return <div>{item.name}</div>\n      }}\n      onInputValueChange={({ inputValue }) => {\n        console.log(inputValue)\n      }}\n    />\n  )\n}\n```"},{"i":"477","k":["emoji"],"c":"2025-03-05","m":"2025-03-05","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/ealush/emoji-picker-react/blob/master/src/dataUtils/parseNativeEmoji.ts#L1\">\n  parseNativeEmoji.ts\n</QuoteLink>\n\n```ts\nexport function parseNativeEmoji(unified: string): string {\n  return unified\n    .split('-')\n    .map((hex) => String.fromCodePoint(parseInt(hex, 16)))\n    .join('')\n}\n```\n\n- `unified` 문자열을 `-`로 분리하여 개별 유니코드 코드 포인트를 얻는다.\n- 각 16진수 코드 포인트를 정수로 변환하고, 그에 해당하는 캐릭터를 반환한다.\n- 최종적으로 변환된 캐릭터들을 연결하여 하나의 문자열로 생성한다.\n\n---\n\n- [GitHub - iamcal/emoji-data: Easy to parse data and spritesheets for emoji](https://github.com/iamcal/emoji-data)"},{"i":"462","k":["typescript","verbatim-module-syntax"],"c":"2025-03-04","m":"2025-03-04","b":"```ts\nimport type { A } from 'a'\n```\n\n---\n\n- [GitHub - privatenumber/fix-verbatim-module-syntax: CLI tool to auto-fix verbatimModuleSyntax errors](https://github.com/privatenumber/fix-verbatim-module-syntax)\n- [TypeScript: TSConfig Reference - Docs on every TSConfig option](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax)\n- [Exploring TypeScript 5 features: Smaller, simpler, faster - LogRocket Blog](https://blog.logrocket.com/exploring-typescript-5-features-smaller-simpler-faster/#verbatimmodulesyntax)"},{"i":"461","k":["form-data","File"],"c":"2025-03-04","m":"2025-03-04","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://developer.mozilla.org/ko/docs/Web/API/File\">\n  File\n</QuoteLink>\n\n```ts\nconst emptyCsvFile = new File([''], 'default.csv', { type: 'text/csv' })\nconst emptyPngFile = new File([''], 'default.png', { type: 'image/png' })\n\nformData.append('product_coupons', emptyCsvFile)\nformData.append('product_imgs', emptyPngFile)\n\nconsole.assert(emptyCsvFile.size === 0)\n```\n\n빈 파일을 FormData에 첨부하여 전송하는 방법\n\n- `File` 객체를 사용하여 빈 파일을 생성할 수 있다.\n- 파일의 `MIME` 타입을 지정하여 타입에 맞는 빈 파일을 만들 수 있다.\n- 생성된 빈 파일을 `FormData` 객체에 추가하여 전송할 수 있다.\n\n이는 서버에 빈 형태의 파일을 전송할 때 유용하며, API 테스트 및 디폴트 값 설정에 활용될 수 있다."},{"i":"431","k":["ocr"],"c":"2025-03-03","m":"2025-03-03","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://simonwillison.net/2024/Mar/30/ocr-pdfs-images/\">\n  Running OCR against PDFs and images directly in your browser\n</QuoteLink>\n\n브라우저에서 PDF 및 이미지 파일에 대한 OCR 실행\n\n- [PDF.js](https://mozilla.github.io/pdf.js/)를 사용하여 PDF에서 이미지를 추출\n- [Tesseract OCR](https://github.com/tesseract-ocr/tesseract)로 추출된 이미지에서 텍스트를 인식\n- 직접 브라우저 상에서 OCR 작업을 실행"},{"i":"410","k":["next","next-sitemap"],"c":"2025-02-14","m":"2025-02-14","b":"```ts\ngetServerSideProps\n  |> getServerSideSitemapLegacy\n  |> withXMLResponseLegacy\n  |>\n    res.setHeader('Content-Type', 'text/xml')\n    res.write(content)\n    res.end()\n```\n\nhttps://github.com/iamvishnusankar/next-sitemap/blob/master/packages/next-sitemap/src/ssr/response.ts\n\nSSR에서 XML 형식의 사이트맵을 생성하기."},{"i":"386","k":["react"],"c":"2025-02-04","m":"2025-02-04","b":"```ts\nimport * as React from 'react'\n\ntype ContainerProps = {\n  children: typeof Body\n}\n\ntype BodyProps = {\n  id: string\n}\n\nfunction Container({ children }: ContainerProps) {\n  const id = '1234'\n\n  return <>{children({ id })}</>\n}\n\nexport function Body({ id }: BodyProps) {\n  return <>{id}</>\n}\n\nexport function Page() {\n  return <Container>{Body}</Container>\n}\n```\n\n컨테이너/프레젠테이션 패턴을 활용한 React 컴포넌트 구조.\n\n- 컨테이너 컴포넌트(`Container`)는 상태를 관리하고 데이터를 하위 컴포넌트에 전달함.\n- 프레젠테이션 컴포넌트(`Body`)는 데이터를 받아서 UI를 렌더링함.\n- 이 구조는 테스트를 쉽게 하고, 컴포넌트의 역할을 명확하게 분리함."},{"i":"385","k":["fp"],"c":"2025-02-02","m":"2025-02-02","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://janhesters.com/blog/unleash-javascripts-potential-with-functional-programming\">\nUnleash JavaScript's Potential with Functional Programming\n</QuoteLink>\n\n```ts\n/**\n * - 인자 수 확인: `curry` 함수는 전달된 함수 `fn`의 인자 수를 확인\n * - 인자 수가 충분한 경우: 만약 `args`의 길이가 `fn`의 인자 수 이상이면, `fn`을 호출하고 결과를 반환\n * - 인자 수가 부족한 경우: 그렇지 않으면 추가 인자(`moreArgs`)를 받을 수 있는 새 함수를 반환. 이 함수는 기존 인자(`args`)와 새로운 인자(`moreArgs`)를 합쳐 다시 `curry(fn)`을 호출하여 최종적으로 인자가 충분할 때까지 이 과정을 반복\n */\nconst curry =\n  (fn: Function) =>\n  (...args: any[]) =>\n    args.length >= fn.length\n      ? fn(...args)\n      : (...moreArgs: any[]) => curry(fn)(...args, ...moreArgs)\n\n/**\n * - `...fns: Function[]` 여러 개의 함수를 인자로 받는다\n * - 내부에서 `reduce` 메서드를 사용하여 함수 배열을 순회 초기값으로 `x`를 사용하며, 각 함수 `fn`을 차례로 호출하여 결과를 다음 함수로 전달\n */\nconst pipe =\n  (...fns: Function[]) =>\n  (x: any) =>\n    fns.reduce((acc, fn) => fn(acc), x)\n\n/**\n * - `...fns`: 여러 개의 함수를 매개변수로 받음\n * - `(x)`: 초기값 `x`를 인자로 받아 결과를 리턴\n * - `fns.reduceRight(...)`: 배열의 오른쪽부터 왼쪽으로 각 함수를 적용합니다. `y`는 이전 함수의 결과이며, `f`는 현재 함수\n * - `f(y)`: 현재 함수 `f`를 이전 함수의 결과 `y`에 적용\n */\nconst compose =\n  (...fns) =>\n  (x) =>\n    fns.reduceRight((y, f) => f(y), x)\n```\n\n---\n\n1. **함수 조합(Composition)**: 작은 함수들을 조합하여 새로운 함수를 만들며, 예를 들어 `compose(f, g, h)`는 `f(g(h(x)))`로 실행\n2. **`compose`**: 오른쪽에서 왼쪽으로 함수를 실행하며, `compose(square, double)(3)`은 36을 반환\n3. **`pipe`**: 왼쪽에서 오른쪽으로 함수를 실행하여, `pipe(double, square)(3)`의 결과는 36\n4. **커링(Currying)**: `f(a, b, c)`를 `f(a)(b)(c)` 형태로 변환하고, 예를 들어 `add(1)(2)(3)`의 결과는 6\n5. **부분 적용(Partial Application)**: 일부 인자만 미리 적용하여 새로운 함수를 만들 수 있으며, `const double = multiply(2, _)`로 정의\n6. **포인트-프리 스타일**: 변수를 사용하지 않고 함수를 조합하여 작성하며, 예를 들면 `compose(square, double)`와 같은 형태\n7. **데이터 마지막 원칙(Data Last)**: 데이터를 마지막 인자로 배치하여 조합성을 높이는데, 예를 들어 `const halve = divideDataLast(2)`와 같이 사용\n\n---\n\n- [Higher-Order Functions](https://eloquentjavascript.net/05_higher_order.html)\n- [How to compose JavaScript functions that take multiple parameters (the epic guide)](https://jrsinclair.com/articles/2024/how-to-compose-functions-that-take-multiple-parameters-epic-guide/)\n- [JavaScript function composition: What’s the big deal?](https://jrsinclair.com/articles/2022/javascript-function-composition-whats-the-big-deal/)"},{"i":"383","k":["mp4","file-reader","hdlr","ffmpeg"],"c":"2025-01-31","m":"2025-01-31","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://techblog.lycorp.co.jp/ko/check-mp4-file-has-audio-using-filereader-in-front-end\">\n프런트엔드 영역에서 FileReader를 이용해 MP4 파일 내 오디오 존재 여부 확인하기\n</QuoteLink>\n\n- 프런트엔드(클라이언트)에서 MP4 파일의 오디오 존재 여부를 확인하기\n- 브라우저 API 사용 시 호환성 문제 발생 (Chrome, Safari, Firefox 각각 다른 API 사용)\n  - MP4 파일의 구조를 분석하는 방법으로 방향 전환\n    - [hdlr](https://developer.apple.com/documentation/quicktime-file-format/metadata_handler_atom) 아톰에서 오디오 존재 여부를 나타내는 `Component subtype` 필드가 `soun`일 경우 오디오가 존재할 것임.\n    - MP4 파일의 특정 바이트를 선택적으로 요청하기 위해 [Range](https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Range) 필드 사용.\n    - 다양한 파일 크기에 따라 'soun'의 예상 위치를 조사하고, 적절한 범위 값 설정.\n- [FileReader API](https://developer.mozilla.org/en-US/docs/Web/API/FileReader)를 사용하여 서버에서 응답받은 바이너리 데이터를 읽고, 이를 분석해 오디오 여부 확인.\n  - 오디오 정보가 발견되지 않으면 추가 데이터 요청하여 확인 범위를 증가시켜 반복.\n\n---\n\n```ts\nawait ffmpeg.writeFile('input.mp4', await fetchFile(file))\nawait ffmpeg.ffprobe(['-i', 'input.mp4', '-show_streams', '-o', 'output.txt'])\n\nconst data = await ffmpeg.readFile('output.txt')\n```\n\n...하지만 [ffmpeg](https://www.ffmpeg.org/)의 중요성을 깨달았다."},{"i":"382","k":["dsl","permissions","typescript"],"c":"2025-01-29","m":"2025-01-29","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://www.figma.com/blog/how-we-rolled-out-our-own-permissions-dsl-at-figma/\">\nHow we built a custom permissions DSL at Figma\n</QuoteLink>\n\nFigma의 권한 관리 DSL - JSON 직렬화 가능한 DSL로 정책 표현, TypeScript 기반 평가 엔진 구현.\n\n기존 문제: 불필요한 복잡성, 계층적 권한 비효율, DB 부하, 여러 진실 소스\n\n```ts\ntype ExpressionDef = BinaryExpressionDef | OrExpressionDef | AndExpressionDef\n\n// 바이너리 표현식: [필드, 연산자, 값]\nconst binaryExpression = ['file.id', '<>', null] satisfies ExpressionDef\n\n// AND/OR 조합\nconst andExpression = {\n  and: [\n    ['file.id', '<>', null],\n    ['team.permission', '=', 'open'],\n  ],\n} satisfies ExpressionDef\n```"},{"i":"381","k":["class","pattern","typescript"],"c":"2025-01-28","m":"2025-01-28","b":"`static create` 패턴 - 생성자 대신 정적 메서드로 객체 생성\n\n언제 사용?\n- 생성 로직이 복잡하거나 유효성 검사 필요\n- 생성 실패 시 null/Result 반환 (생성자는 항상 인스턴스 반환)\n- 팩토리 패턴, 싱글톤\n\n```typescript\nclass User {\n  private constructor(private readonly name: string) {}\n\n  // 유효성 검사 + 실패 시 null 반환\n  static create(name: string): User | null {\n    if (!name || name.length < 3) return null\n    return new User(name)\n  }\n}\n\n// 팩토리 패턴\nclass Shape {\n  static create(type: 'circle' | 'rect', size: number): Shape {\n    return type === 'circle' ? new Circle(size) : new Rectangle(size)\n  }\n}\n\n// 싱글톤\nclass Config {\n  private static instance: Config\n  private constructor() {}\n  static create() {\n    return Config.instance ??= new Config()\n  }\n}\n```\n\n> [!TIP]\n> 단순 초기화만 필요하면 일반 생성자가 더 직관적. 복잡한 생성 로직에만 사용."},{"i":"380","k":["type"],"c":"2025-01-28","m":"2025-01-28","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://blog.jez.io/intro-elim/\">\nEvery type is defined by its intro and elim forms\n</QuoteLink>\n\n_Every type is defined by its intro and elim forms_\n\n- **Intro forms**: 타입의 인스턴스를 어떻게 \"생성\"하는지 정의.\n- **Elim forms**: 생성된 타입 인스턴스를 어떻게 \"사용\"하거나 \"해체\"할지 정의.\n\n타입을 정의할 때, 생성과 사용의 명확한 경계를 설정해서 **Intro/Elim 설계를 명시적으로 표현하기**\n\n```ts\nclass Rectangle {\n  private constructor(public width: number, public height: number) {}\n\n  static create(width: number, height: number) {\n    if (width <= 0 || height <= 0) {\n      return null\n    }\n\n    return new Rectangle(width, height)\n  }\n\n  getArea() {\n    return this.width * this.height\n  }\n}\n\nconst rect = Rectangle.create(10, 20)\n\nif (rect) {\n  console.log(rect.getArea())\n}\n```\n\n---\n\n_Types are not their elim forms_\n\n- `interface`와 `class`를 통해 **Intro/Elim 모두를 명시적으로 정의**\n- 팩토리 메서드 같은 패턴을 활용해 생성 방식을 추상화\n\n```ts\ninterface Shape {\n  getArea(): number\n}\n\nclass Circle implements Shape {\n  constructor(private radius: number) {}\n\n  getArea() {\n    return Math.PI * this.radius ** 2\n  }\n}\n\nclass Rectangle implements Shape {\n  constructor(private width: number, private height: number) {}\n\n  getArea() {\n    return this.width * this.height\n  }\n}\n\nfunction createShape(type: 'circle' | 'rectangle', ...args: number[]) {\n  if (type === 'circle' && args.length === 1) {\n    return new Circle(args[0])\n  }\n\n  if (type === 'rectangle' && args.length === 2) {\n    return new Rectangle(args[0], args[1])\n  }\n\n  return null\n}\n\nconst shape = createShape('circle', 10)\n\nif (shape) {\n  console.log(shape.getArea())\n}\n```"},{"i":"377","k":["github-actions","monorepo"],"c":"2025-01-19","m":"2025-01-19","b":"- https://stackoverflow.com/questions/58136102/deploy-individual-services-from-a-monorepo-using-github-actions\n- https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#example-including-paths\n\n최소한 하나의 경로가 `paths` 필터의 패턴과 일치하면 워크플로가 실행\n\n```yaml\non:\n  push:\n    paths:\n      - '**.js'\n```"},{"i":"376","k":["typescript","json"],"c":"2025-01-19","m":"2025-01-19","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/sindresorhus/type-fest/blob/49605b9770a344bac28ce8504045541dc0bdbd4b/source/basic.d.ts#L38-L68\">\n  basic.d.ts\n</QuoteLink>\n\n```ts\ntype JsonPrimitive = string | number | boolean | null\ntype JsonObject = { [Key in string]: JsonValue } & {\n  [Key in string]?: JsonValue | undefined\n}\ntype JsonArray = JsonValue[] | readonly JsonValue[]\ntype JsonValue = JsonPrimitive | JsonObject | JsonArray\n```\n\n예전에 JSON 타입 정의가 필요해서 찾아봤던 내용\n\n- `JsonObject`: 문자열 키와 JsonValue 타입의 값을 가진 JSON 객체를 정의\n- `JsonArray`: JsonValue 타입의 요소를 포함하는 JSON 배열을 정의\n- `JsonPrimitive`: 문자열, 숫자, 불린, 또는 null과 같은 유효한 JSON 기본 값을 정의\n- `JsonValue`: 유효한 JSON 값을 나타내며, JsonPrimitive, JsonObject, 또는 JsonArray로 구성\n\n---\n\n- https://github.com/sindresorhus/type-fest/blob/main/source/jsonify.d.ts"},{"i":"375","k":["active-element","dom","focus"],"c":"2025-01-18","m":"2026-08-02","b":"`document.activeElement` — 현재 포커스된 요소\n\n```javascript\nconst el = document.activeElement\n\nif (el.tagName === 'INPUT') console.log(el.value)\n\n// 모달 열릴 때 닫기 버튼으로 포커스 이동\ndocument.querySelector('.modal .close-button').focus()\n```\n\n- [MDN - document.activeElement](https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement)"},{"i":"374","k":["test","jest"],"c":"2025-01-18","m":"2026-08-02","b":"https://www.emgoto.com/jest-partial-match/\n\n`objectContaining`·`arrayContaining` 은 `toEqual`·`toHaveBeenCalledWith` 안에 중첩해서 쓴다.\n\n```typescript\ntest('호출 인자 부분 매칭', () => {\n  const mockFunction = jest.fn()\n\n  mockFunction({ id: 1, name: 'Alice', tags: ['developer', 'designer'] })\n\n  expect(mockFunction).toHaveBeenCalledWith(\n    expect.objectContaining({\n      name: 'Alice',\n      tags: expect.arrayContaining(['designer']),\n    })\n  )\n})\n\ntest('배열 안 객체 부분 매칭', () => {\n  const receivedArray = [\n    { id: 1, name: 'Alice' },\n    { id: 2, name: 'Bob' },\n  ]\n\n  expect(receivedArray).toEqual(\n    expect.arrayContaining([\n      expect.objectContaining({ name: 'Alice' }),\n      expect.objectContaining({ id: 2 }),\n    ])\n  )\n})\n```"},{"i":"373","k":["router"],"c":"2025-01-18","m":"2025-01-18","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://programmingarehard.com/2025/01/13/maybe-dont-navigate-1.html/\">\nMaybe don't navigate(-1) in Remix/React Router\n</QuoteLink>\n\n- `navigate(-1)`의 위험성: 브라우저 히스토리에서 이전 위치로 이동, 앱 내부 네비게이션과 혼란을 초래할 수 있음\n- 대신 `Link` 컴포넌트의 [`state`](https://api.reactrouter.com/v7/interfaces/react_router.LinkProps.html#state) 속성을 활용하여 안전하게 앱 내에서의 \"Back\" 네비게이션 구현 가능\n- 사용자에게 현재 URL을 반환하는 커스텀 훅 `useCurrentURL` 구현 및 재사용의 용이성을 제공하는 `useBackNavigation` 훅 정의\n\n```tsx\nfunction PreserveStateLink(props) {\n  const location = useLocation()\n  const currentURL = location.pathname + location.search\n\n  return (\n    <Link state={{ back: currentURL }} {...props}>\n      {children}\n    </Link>\n  )\n}\n\nfunction BackLink() {\n  const navigate = useNavigate()\n  const location = useLocation()\n\n  const handleClick: LinkProps['onClick'] = (e) => {\n    const back = location.state?.back\n\n    if (back) {\n      e.preventDefault()\n\n      navigate(back)\n    }\n  }\n\n  return (\n    <Link to=\"/todos\" onClick={handleBack}>\n      Back\n    </Link>\n  )\n}\n```"},{"i":"371","k":["css"],"c":"2025-01-17","m":"2025-01-17","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://github.com/css-modules/css-modules/blob/master/docs/values-variables.md\">\nExporting values variables\n</QuoteLink>\n\n```css\n@value b from \"./b.module.css\";\n\n.root {\n  color: aquamarine;\n}\n\n.root :global(.b) {\n  text-decoration: line-through;\n}\n```\n\n\nCSS 모듈에서 변수를 값으로 내보내고 사용하는 방법\n\n- PostCSS와 `postcss-modules-values` 플러그인을 사용하여 CSS 모듈 내에서 변수 값 내보내기 지원\n- 색상 변수를 정의하는 파일 생성\n  - 변수 선언: `@value` 구문 사용\n- 다른 CSS 모듈 파일에서 해당 변수를 가져와서 사용\n  - 변수 가져오기 및 CSS 클래스에 적용"},{"i":"368","k":["url"],"c":"2025-01-16","m":"2025-01-16","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://developer.mozilla.org/en-US/docs/Web/API/URL/parse_static\">\nURL: parse() static method\n</QuoteLink>\n\nURL.parse() 메서드를 활용하여 URL 객체를 생성하고 처리하는 방법\n\n- `URL.parse(url)` 메서드는 주어진 URL에 따라 새 URL 객체를 생성\n- 유효하지 않은 URL 값이 주어질 경우 `null`을 반환\n- 두 번째 파라미터 base는 상대 URL을 해석하기 위한 기준 URL로 사용되며, 이를 통해 URL의 경로가 올바르게 조정됨\n- URL 객체나 다른 문자열을 파라미터로 사용할 수 있으며, 내부에서 문자열로 변환됨\n\n```ts\ndescribe('null 입력에 대한 URL API 동작 테스트', () => {\n  test('new URL(null)은 TypeError를 발생시킨다', () => {\n    expect(() => new URL(null)).toThrow(TypeError)\n  })\n\n  test('URL.canParse(null)은 false를 반환한다', () => {\n    expect(URL.canParse(null)).toBe(false)\n  })\n\n  test('URL.parse(null)은 null을 반환한다', () => {\n    expect(() => URL.parse(null)).toBe(null)\n  })\n})\n```"},{"i":"367","k":["zod","search-params"],"c":"2025-01-16","m":"2025-01-16","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://www.jacobparis.com/content/zod-search-params-remix\">\nMerge search params with Zod in Remix\n</QuoteLink>\n\n- 검색 매개변수별로 데이터 유효성 검증\n- `useSearchParams`와 `Zod`를 활용해 모든 검색 매개변수를 병합하고 유효성 검증\n\n```ts\nimport { useMemo } from 'react'\nimport { useSearchParams } from 'react-router-dom'\nimport { z } from 'zod'\n\nconst CombinedSchema = PaginationSchema.merge(FilterSchema)\n  .merge(SortSchema)\n  .merge(SearchSchema)\n  .partial()\n\nexport function useSearchParamsWithSchema() {\n  const [searchParams, setSearchParams] = useSearchParams()\n\n  const parsedParams = useMemo(() => {\n    // searchParams를 객체로 변환\n    const paramsObject = Object.fromEntries(searchParams.entries())\n    // 결합된 스키마로 유효성 검사 및 파싱\n    const result = CombinedSchema.safeParse(paramsObject)\n\n    if (result.success) {\n      return result.data\n    }\n\n    console.error(result.error)\n\n    return {}\n  }, [searchParams])\n\n  /**\n   * 새로운 검색 매개변수로 업데이트하는 함수입니다.\n   *\n   * 1. 현재 검색 매개변수의 복사본을 생성합니다.\n   * 2. 새로운 매개변수를 순회하며 값을 설정하거나 삭제합니다.\n   * 3. 최종적으로 업데이트된 매개변수를 state에 설정합니다.\n   */\n  const updateSearchParams = (newParams: Record<string, string>) => {\n    const updatedParams = new URLSearchParams(searchParams)\n\n    Object.entries(newParams).forEach(([key, value]) => {\n      if (value) {\n        // 업데이트된 매개변수로 상태 갱신\n        updatedParams.set(key, value)\n      } else {\n        // 값이 없으면 삭제\n        updatedParams.delete(key)\n      }\n    })\n\n    // 업데이트된 매개변수로 상태 갱신\n    setSearchParams(updatedParams)\n  }\n\n  return {\n    parsedParams,\n    updateSearchParams,\n  }\n}\n```"},{"i":"360","k":["abort-controller"],"c":"2025-01-12","m":"2025-01-12","b":"취소 가능한 `fetch` 요청을 생성하는 `createCancelableFetch` 함수 구현\n\n````ts\n/**\n * 취소 가능한 fetch 요청\n *\n * @param url - 요청 URL\n * @param options - fetch 옵션\n * @returns {run, cancel} - run: 요청 실행 함수, cancel: 요청 취소 함수\n *\n * @example\n *\n * ```ts\n * const { run, cancel } = createCancelableFetch('/api/data')\n *\n * run()\n *   .then((data) => console.log('Fetched data:', data))\n *   .catch((err) => console.error('Error or canceled:', err))\n *\n * cancel()\n * ```\n */\nfunction createCancelableFetch(url: string, options: RequestInit = {}) {\n  const abortController = new AbortController()\n\n  const run = async () => {\n    const response = await fetch(url, {\n      ...options,\n      signal: abortController.signal,\n    })\n\n    if (!response.ok) {\n      throw new Error(`HTTP error! status: ${response.status}`)\n    }\n\n    return response.json()\n  }\n\n  const cancel = () => abortController.abort()\n\n  return {\n    run,\n    cancel,\n  }\n}\n````\n\n- `AbortController`를 사용하여 요청을 중단할 수 있는 기능 제공\n- `run` 함수로 `fetch` 요청을 실행하고, `cancel` 함수로 요청을 취소할 수 있음\n- 요청 실패 시 오류를 처리하고 예외를 발생시킴\n\n---\n\n- [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)\n- [Don't Sleep on AbortController](https://kettanaito.com/blog/dont-sleep-on-abort-controller)"},{"i":"358","k":["field-sizing","input","textarea","select"],"c":"2025-01-12","m":"2025-01-12","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://developer.chrome.com/docs/css-ui/css-field-sizing?&hl=ko\">\nCSS 필드 크기 조정\n</QuoteLink>\n\n\n\n> `field-sizing`를 사용하면 콘텐츠를 기반으로 크기 조절을 사용 설정하는 데 CSS 한 줄이 필요합니다. 이 콘텐츠 기반 크기 조절 스타일은 `textarea` 외에도 다른 요소에도 적용됩니다.\n\n<textarea style={{ padding: '10px', fieldSizing: 'content' }}>{`textarea, select, input {\n\\tfield-sizing: content;\n}`}</textarea>"},{"i":"357","k":["error","tuple"],"c":"2025-01-12","m":"2025-01-12","b":"- https://github.com/arthurfiorette/proposal-safe-assignment-operator\n- https://github.com/arthurfiorette/tuple-it\n\n---\n\n비동기 호출의 결과와 오류를 튜플 형식으로 반환\n\n```ts\nconst [error, data] = await tuple(someAsyncFunction())\n// 성공: [null, 결과값]\n// 실패: [error] 또는 [new TupleItError(error)]\n```\n\n오류 처리 간소화 - 단일 체크로 오류 관리 가능"},{"i":"354","k":["react"],"c":"2025-01-10","m":"2025-01-10","b":"import { Blockquote } from '@components/Blockquote'\n\n<Blockquote\n  cite={{\n    url: 'https://www.jameskerr.blog/posts/use-state-object/',\n    title: 'useStateObject: A Simple, Convenient API Around useState',\n  }}\n>\n  `useStateObject`는 React의 `useState`를 확장한 가벼운 래퍼로, 객체 상태 관리를\n  간편하게 할 수 있도록 설계되었습니다.\n</Blockquote>\n\n---\n\n```ts\nexport type StateObject<T extends object> = T & {\n  set: React.Dispatch<React.SetStateAction<T>>\n  setItem: <K extends keyof T>(key: K, value: T[K]) => void\n  merge: (newState: Partial<T>) => void\n  reset: () => void\n}\n```\n\n---\n\n그렇다면 `Map`과 `Set`도 시도해보기\n\n```ts\nfunction useStateMap<K, V>(init: Iterable<[K, V]> = []) {\n  const [map, setMap] = useState(new Map<K, V>(init))\n\n  const update = useCallback(\n    (updater: (currentMap: Map<K, V>) => void) => {\n      setMap((prev) => {\n        const newMap = new Map(prev)\n        updater(newMap)\n        return newMap\n      })\n    },\n    [setMap]\n  )\n\n  return {\n    map,\n    set: (key: K, value: V) => update((m) => m.set(key, value)),\n    delete: (key: K) => update((m) => m.delete(key)),\n    clear: () => setMap(new Map()),\n    has: (key: K) => map.has(key),\n    get: (key: K) => map.get(key),\n    entries: () => Array.from(map.entries()),\n    size: map.size,\n  }\n}\n```\n\n```ts\nfunction useStateSet<T>(init: Iterable<T> = []) {\n  const [set, setSet] = useState(new Set<T>(init))\n\n  const update = useCallback(\n    (updater: (currentSet: Set<T>) => void) => {\n      setSet((prev) => {\n        const newSet = new Set(prev)\n        updater(newSet)\n        return newSet\n      })\n    },\n    [setSet]\n  )\n\n  return {\n    set,\n    add: (value: T) => update((s) => s.add(value)),\n    delete: (value: T) => update((s) => s.delete(value)),\n    has: (value: T) => set.has(value),\n    clear: () => setSet(new Set()),\n    entries: () => Array.from(set),\n    size: set.size,\n  }\n}\n```\n\n---\n\n- [Map](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Map)\n- [Set](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Set)"},{"i":"352","k":["thisisunsafe"],"c":"2025-01-06","m":"2026-09-01","b":"로컬·테스트 서버에서 크롬 인증서 경고를 강제로 통과한다.\n\n**언제**: **이 연결은 비공개로 설정되지 않았습니다** 화면에 `NET::ERR_CERT_AUTHORITY_INVALID`·`NET::ERR_CERT_COMMON_NAME_INVALID` 가 뜨고, 고급 → 안전하지 않음(계속)이 없거나 매번 누르기 번거로울 때.\n\n**절차**\n\n1. 경고 화면 아무 곳이나 클릭한다 — 페이지에 포커스를 준다\n2. 입력창 없이 그대로 `thisisunsafe` 를 타이핑한다\n\n입력창이 없는 게 정상이다. 경고 페이지의 keypress 핸들러가 한 글자씩 받아 `BYPASS_SEQUENCE` 와 대조하고, 다 맞으면 `CMD_PROCEED` 를 보낸다 — \"안전하지 않음(계속)\"을 누른 것과 같은 명령이다.\n\n**확인**: 마지막 글자를 치는 순간 넘어간다. 안 넘어가면 포커스가 주소창에 있는 것이다 — 1번부터."},{"i":"351","k":["use-sync-external-store","react"],"c":"2025-01-06","m":"2025-01-06","b":"`useSyncExternalStore` 활용 가능한 부분들\n\n- [onLine](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine)\n- [matchMedia](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia)\n- [resize_event](https://developer.mozilla.org/ko/docs/Web/API/Window/resize_event)\n- [scroll_event](https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event)\n- [localStorage](https://developer.mozilla.org/ko/docs/Web/API/Window/localStorage)\n\n---\n\n- https://ko.react.dev/reference/react/useSyncExternalStore\n- https://julesblom.com/writing/usesyncexternalstore\n- https://thisweekinreact.com/articles/useSyncExternalStore-the-underrated-react-api\n- https://www.56kode.com/posts/using-usesyncexternalstore-with-localstorage"},{"i":"350","k":["typescript"],"c":"2025-01-01","m":"2025-01-01","b":"템플릿 리터럴 타입을 활용하여 타입 정의하기\n\n````tsx\nimport * as React from 'react'\n\ntype RenderPropNames = 'Title' | 'Content' | 'Actions'\n\ntype RenderProps = {\n  [K in RenderPropNames as `render${K}`]: () => React.ReactNode\n}\n\ntype Props = RenderProps\n\n/**\n * @example\n *\n * ```tsx\n * <DialogComponent\n *   renderTitle={() => <h2>Title</h2>}\n *   renderContent={() => <p>Content</p>}\n *   renderActions={() => (\n *     <div>\n *       <button onClick={handleClose}>Close</button>\n *       <button onClick={handleSubmit}>Submit</button>\n *     </div>\n *   )}\n * />\n * ```\n */\nfunction Dialog({\n  renderTitle,\n  renderContent,\n  renderActions,\n}: Props) => {\n  return (\n    <div data-scope=\"root\">\n      <div data-part=\"content\">\n        {renderTitle()}\n        {renderContent()}\n      </div>\n      <div data-part=\"actions\">{renderActions()}</div>\n    </div>\n  )\n}\n````"},{"i":"349","k":["dialog","ux"],"c":"2025-01-01","m":"2025-01-01","b":"대화 상자(Dialog) 유형\n\n**Modal** - 상호작용 차단\n\n- System Modal: 닫기 전까지 다른 작업 불가\n- Application Modal: 프로그램 일시 중단\n- Document Modal: 부모 창만 차단 (macOS 시트)\n\n**Modeless** - 상호작용 허용 (예: 툴바)\n\n모달은 워크플로우 방해 → 비필수 작업에는 모델리스 선호\n\n313.md 참고"},{"i":"347","k":["vitest","jest","testing","mock"],"c":"2025-01-01","m":"2025-01-01","b":"Vitest/Jest Fake Timers로 시간 제어\n\n```javascript\ndescribe('time-dependent tests', () => {\n  beforeEach(() => {\n    vi.useFakeTimers() // jest.useFakeTimers('modern')\n  })\n\n  afterEach(() => {\n    vi.useRealTimers() // jest.useRealTimers()\n  })\n\n  it('특정 시간에 동작 확인', () => {\n    vi.setSystemTime(new Date(2000, 1, 1, 13)) // 13시 설정\n    expect(purchase()).toEqual({ message: 'Success' })\n  })\n\n  it('영업시간 외 동작', () => {\n    vi.setSystemTime(new Date(2000, 1, 1, 19)) // 19시 설정\n    expect(purchase()).toEqual({ message: 'Error' })\n  })\n})\n```\n\n`@sinonjs/fake-timers` 기반. Date, setTimeout 등 모킹."},{"i":"345","k":["typescript"],"c":"2025-01-01","m":"2025-01-01","b":"두 개의 오버로딩된 메서드에서 각각의 반환 타입을 명시적으로 추출하기\n\n```ts\ntype Year = {\n  year(): number\n  year(u: string): string\n}\n\ntype ReturnTo<T, R> = T extends R ? R : never\n\ntype GetYear = ReturnTo<Year['year'], () => number>\ntype SetYear = ReturnTo<Year['year'], (u: string) => string>\n```"},{"i":"344","k":["javascript","algorithm","dfs"],"c":"2025-01-01","m":"2025-01-01","b":"DFS (깊이 우선 탐색)\n\n```javascript\n// 재귀 방식\nfunction dfs(graph, node, visited = new Set()) {\n  visited.add(node)\n  console.log(node)\n  graph[node].forEach((neighbor) => {\n    if (!visited.has(neighbor)) dfs(graph, neighbor, visited)\n  })\n}\n\n// 스택 방식\nfunction dfsStack(graph, startNode) {\n  const stack = [startNode]\n  const visited = new Set()\n\n  while (stack.length > 0) {\n    const node = stack.pop()\n\n    if (!visited.has(node)) {\n      console.log(node)\n      visited.add(node)\n      graph[node]\n        .slice()\n        .reverse()\n        .forEach((neighbor) => {\n          if (!visited.has(neighbor)) stack.push(neighbor)\n        })\n    }\n  }\n}\n\nconst graph = {\n  0: [1, 2],\n  1: [0, 3, 4],\n  2: [0, 5],\n  3: [1],\n  4: [1],\n  5: [2],\n}\ndfs(graph, 0) // 0, 1, 3, 4, 2, 5\n```"},{"i":"343","k":["javascript","delete"],"c":"2025-01-01","m":"2025-01-01","b":"JavaScript delete 연산자\n\n```js\nconst obj = { name: 'Alice', age: 25 }\ndelete obj.age // true\n\n// 존재하지 않는 속성 삭제도 true\ndelete obj.city // true\n\n// configurable: false는 삭제 불가\nconst locked = Object.defineProperty({}, 'readOnly', {\n  value: 'I cannot be deleted',\n  configurable: false,\n})\ndelete locked.readOnly // false\n\n// 전역 변수 삭제 불가\nlet globalVar = 'exists'\ndelete globalVar // false\n\n// 배열 요소 삭제 (hole 생성)\nlet arr = [1, 2, 3]\ndelete arr[1] // [1, <empty>, 3]\n```\n\n- 설정 가능한(configurable) 속성에만 사용\n- 배열은 `splice` 권장"},{"i":"342","k":["sheets"],"c":"2025-01-01","m":"2025-01-01","b":"- 문제: 스프레드시트 데이터가 커서 내용 확인이 어려움.\n- 해결: 구글 드라이브에 스프레드시트 데이터를 CSV 파일로 업로드하여 확인.\n\n```js\n/**\n * 현재 활성 스프레드시트의 첫 번째 시트 데이터를 CSV 파일로 저장하고,\n * 구글 드라이브에 업로드합니다.\n * 생성된 파일의 URL은 콘솔에 로그로 출력됩니다.\n */\nfunction saveSpreadsheetDataAsCSV() {\n  /** 현재 활성 스프레드시트 */\n  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]\n  /** 시트의 모든 데이터의 2차원 배열 */\n  const data = sheet.getDataRange().getValues()\n  /** CSV 데이터 */\n  const csv = data.map((row) => row.join(',')).join('\\n')\n  /** 파일명 */\n  const fileName = 'spreadsheet_data.csv'\n  /** 구글 드라이브에 업로드 완료된 CSV 파일 */\n  const file = DriveApp.createFile(fileName, csv, MimeType.CSV)\n\n  console.log('파일이 생성되었습니다: ' + file.getUrl())\n}\n```"},{"i":"341","k":["css","color","rgba","hex"],"c":"2025-01-01","m":"2025-01-01","b":"```ts\nclass ColorManipulator {\n  private baseColor: string\n  private targetColor: string\n\n  constructor(baseColor: string, targetColor: string) {\n    this.baseColor = baseColor\n    this.targetColor = targetColor\n  }\n\n  private hexToRgb(hex: string) {\n    const bigint = parseInt(hex.slice(1), 16)\n\n    return {\n      r: (bigint >> 16) & 255,\n      g: (bigint >> 8) & 255,\n      b: bigint & 255,\n    }\n  }\n\n  public calculateOpacity() {\n    const target = this.hexToRgb(this.targetColor)\n    const baseRG = this.hexToRgb(this.baseColor)\n    const opacities = [\n      (target.r - baseRG.r) / (255 - baseRG.r),\n      (target.g - baseRG.g) / (255 - baseRG.g),\n      (target.b - baseRG.b) / (255 - baseRG.b),\n    ]\n    const averageOpacity =\n      opacities.reduce((sum, value) => sum + value, 0) / opacities.length\n\n    return averageOpacity\n  }\n\n  public getCssRGBA() {\n    const opacity = this.calculateOpacity()\n\n    return `rgba(0, 0, 0, ${opacity.toFixed(2)})`\n  }\n}\n\nconst manipulator = new ColorManipulator('#000000', '#D1D7DE')\nconst opacity = manipulator.calculateOpacity()\nconst cssRGBA = manipulator.getCssRGBA()\n```"},{"i":"340","k":["sheets"],"c":"2025-01-01","m":"2026-07-27","b":"구글 스프레드시트에서 중복 값에 스타일 주기 — 서식 > 조건부 서식 > 맞춤 수식에 넣는다.\n\n```sh\n=COUNTIF(A:A, A:A) > 1\n```"},{"i":"337","k":["apps-script"],"c":"2025-01-01","m":"2025-01-01","b":"Google Spreadsheet 팝업\n\n```javascript\nfunction showAlert() {\n  const ui = SpreadsheetApp.getUi()\n  ui.alert('팝업 창', '표시할 텍스트', ui.ButtonSet.OK)\n}\n\n// 메뉴에 추가\nfunction onOpen() {\n  SpreadsheetApp.getUi()\n    .createMenu('팝업 메뉴')\n    .addItem('팝업 띄우기', 'showAlert')\n    .addToUi()\n}\n```"},{"i":"336","k":["javascript","regex"],"c":"2025-01-01","m":"2025-01-01","b":"특수 기호 제거\n\n```javascript\nfunction removeSpecialCharacters(input) {\n  return input.replace(/[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]+/g, '')\n}\n\nremoveSpecialCharacters('Hello_World123! 안녕하세요?')\n// \"HelloWorld123 안녕하세요\"\n```"},{"i":"335","k":["react"],"c":"2025-01-01","m":"2025-01-01","b":"조건부 렌더링을 한다고 했을때 예전에는 주로 B로 처리했던 것 같은데 디버깅 때문에 고생해서 그런지 생각이 바뀌었다.\n\n```tsx\nfunction renderA() {\n  return <Item isPacked={true} name=\"Space suit\" />\n}\n\nfunction renderB() {\n  return <>{isPacked ? <Item name=\"Space suit\" /> : null}</>\n}\n```\n\n---\n\n조건부 렌더링을 보다 간결하게 표현하기 위해 `If`, `Then`, 그리고 `Else` 컴포넌트 개념을 차용하는 방법. 이 컴포넌트는 `If`에서 `condition` prop을 통해 조건을 받아들이고, 자식으로 `Then`과 `Else`를 받아 각각의 내용을 렌더링하도록 한다.\n\n```tsx\ntype Props = {\n  /** 렌더링할 조건 */\n  condition: boolean\n  /** 자식 컴포넌트 (Then, Else 포함) */\n  children: React.ReactNode\n}\n\n/**\n * If 컴포넌트는 조건부 렌더링을 위한 컴포넌트\n */\nfunction If({ condition, children }: Props) {\n  let thenChild = null\n  let elseChild = null\n\n  React.Children.forEach(children, (child) => {\n    if (!React.isValidElement(child)) return\n    if (child.type === Then) thenChild = child\n    if (child.type === Else) elseChild = child\n  })\n\n  return condition ? thenChild : elseChild\n}\n\n/**\n * Then, Else 컴포넌트는 조건이 참 또는 거짓일 때 렌더링되는 콘텐츠를 포함\n */\nconst Then = ({ children }: React.PropsWithChildren) => <>{children}</>\nconst Else = ({ children }: React.PropsWithChildren) => <>{children}</>\n```"},{"i":"334","k":["mongodb","ttl"],"c":"2025-01-01","m":"2025-01-01","b":"MongoDB TTL 인덱스 (자동 문서 삭제)\n\n```javascript\n// 데이터 삽입\ndb.collection.insertOne({\n  name: 'Temporary',\n  expireAt: new Date('2024-11-30T00:00:00Z'),\n})\n\n// TTL 인덱스 생성 (expireAt 시간 기준 삭제)\ndb.collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })\n```\n\n- 60초 간격 백그라운드 체크로 삭제 (즉시 아님)\n- `expireAt`은 ISODate 형식 필수\n- 활용: 세션 만료, 캐시 관리, 임시 데이터\n\nSQLite는 TTL 미지원 → 트리거 또는 외부 스케줄러로 구현"},{"i":"333","k":["oauth","jwt"],"c":"2025-01-01","m":"2025-01-01","b":"Google Auth ID Token 클레임\n\n| 클레임 | 의미 |\n|--------|------|\n| `sub` | 사용자 고유 식별자 (user_id로 사용 가능) |\n| `jti` | JWT 토큰 고유 ID (토큰 무효화/추적용) |\n| `iss` | 토큰 발급자 (`accounts.google.com`) |\n| `aud` | 대상 애플리케이션 (Client ID) |\n| `iat` | 발급 시각 |\n| `exp` | 만료 시각 |\n\n`sub`를 user_id로 사용 시 고려사항:\n- Google 계정 삭제 후 재생성 시 새 `sub` 할당됨\n- 여러 소셜 로그인 지원 시 별도 user_id + google_sub 매핑 권장"},{"i":"332","k":["sqlite","database-design"],"c":"2025-01-01","m":"2025-01-01","b":"SQLite 게시물 업데이트 내역 저장\n\n```sql\nCREATE TABLE posts (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  title TEXT NOT NULL,\n  content TEXT NOT NULL,\n  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE post_history (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  post_id INTEGER NOT NULL,\n  title TEXT,\n  content TEXT,\n  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n  FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE\n);\n\n-- 업데이트 전 내역 저장\nINSERT INTO post_history (post_id, title, content, updated_at)\nSELECT id, title, content, updated_at FROM posts WHERE id = 1;\n\n-- 게시물 업데이트\nUPDATE posts SET title = '새 제목', updated_at = CURRENT_TIMESTAMP WHERE id = 1;\n```\n\nposts : post_history = 1 : N 관계"},{"i":"331","k":["sqlite","json"],"c":"2025-01-01","m":"2025-01-01","b":"SQLite JSON 태그 빈도 정렬\n\n```sql\n-- 태그별 빈도 계산\nSELECT json_each.value AS tag, COUNT(*) AS frequency\nFROM example, json_each(example.tags)\nGROUP BY json_each.value\nORDER BY frequency DESC, tag ASC;\n\n-- 빈도순 정렬된 태그를 JSON 배열로\nSELECT json_group_array(tag) AS tags_by_frequency\nFROM (\n  SELECT json_each.value AS tag\n  FROM example, json_each(example.tags)\n  GROUP BY json_each.value\n  ORDER BY COUNT(*) DESC, tag ASC\n);\n```"},{"i":"330","k":["skia-canvas","pdf"],"c":"2025-01-01","m":"2025-01-01","b":"빈 PDF 파일이 필요해서 찾아본 방법들\n\n```js\nimport { createCanvas } from 'skia-canvas'\nimport { writeFile } from 'node:fs/promises'\n\n/**\n * A4 크기의 빈 PDF 파일을 생성합니다.\n *\n * @param {string} filename - 생성할 PDF 파일의 이름입니다.\n * @returns {Promise<void>}\n */\nasync function createEmptyPDF(filename) {\n  // A4 크기 (595x842 포인트)\n  const width = 595\n  const height = 842\n\n  // PDF 형식의 캔버스를 생성합니다\n  const canvas = createCanvas(width, height, 'pdf')\n  const ctx = canvas.getContext('2d')\n\n  // 아무 내용도 그리지 않고 현재 상태를 저장합니다\n  ctx.save()\n\n  // 캔버스를 버퍼로 변환하여 PDF로 저장합니다\n  const buffer = await canvas.toBuffer()\n  await writeFile(filename, buffer)\n  console.log(`${filename} 파일이 생성되었습니다!`)\n}\n\ncreateEmptyPDF('empty_skia.pdf')\n```\n\n---\n\n```sh\n# Ghostscript를 사용한 빈 PDF 생성\ngs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=empty.pdf -c \"[/PageSize [595 842]] setpagedevice\" -f /dev/null\n\n# ImageMagick의 `convert` 명령어로 빈 PDF 생성\nconvert xc:white -page A4 empty.pdf\n\n# `touch` 명령어와 PDF 헤더 직접 작성\necho -e \"%PDF-1.4\\n1 0 obj\\n<<>>\\nendobj\\nxref\\n0 1\\n0000000000 65535 f \\ntrailer\\n<<>>\\nstartxref\\n9\\n%%EOF\" > empty.pdf\n```"},{"i":"329","k":["apps-script"],"c":"2024-12-31","m":"2026-07-27","b":"Apps Script로 캘린더 예약을 만들 때 쓰는 건 결국 API 두 개다.\n\n`Calendar.Freebusy.query`로 바쁜 일정을 받아 근무 요일·근무 시간과 겹치는 슬롯을 빼면 예약 가능한 슬롯이 남는다. 예약은 `CalendarApp.getCalendarById`로 이벤트를 만들고 `sendInvites: true`로 초대 메일까지 보낸다.\n\nFreebusy는 슬롯을 계산할 때와 실제로 예약할 때 두 번 조회한다 — 그 사이에 일정이 생길 수 있으니.\n\n---\n\n- https://github.com/rbbydotdev/someday"},{"i":"328","k":["react","testing","context"],"c":"2024-12-31","m":"2024-12-31","b":"AuthContext 테스트 패턴\n\n```tsx\n// localStorage 함수 테스트\nvi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {\n  if (key === 'auth_token') return 'test_token'\n  return null\n})\n\n// AuthProvider 상태 테스트\nconst TestComponent = () => {\n  const { token, login, logout } = useAuth()\n\n  return (\n    <div>\n      <p data-testid=\"token\">{token || 'null'}</p>\n      <button onClick={() => login('test_token')}>Login</button>\n      <button onClick={logout}>Logout</button>\n    </div>\n  )\n}\n\n// useAuth 훅 테스트\nit('AuthProvider 외부에서 호출하면 에러', () => {\n  expect(() => renderHook(() => useAuth())).toThrowError()\n})\n\nit('AuthProvider 내부에서 정상 동작', () => {\n  const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>\n  const { result } = renderHook(() => useAuth(), { wrapper })\n  expect(result.current).toHaveProperty('token', null)\n})\n```"},{"i":"327","k":["test","mocking","timers"],"c":"2024-12-31","m":"2026-07-27","b":"타이머를 제어하기 위해 `vi.useFakeTimers`와 `vi.advanceTimersByTime`을 사용하기.\n\n```tsx\ndescribe('Countdown 컴포넌트', () => {\n  beforeEach(() => {\n    vi.useFakeTimers()\n  })\n\n  afterEach(() => {\n    vi.useRealTimers()\n  })\n\n  test('타이머가 동작하며 시간을 업데이트한다.', () => {\n    const endTime = new Date(Date.now() + 60000).toISOString()\n    const renderChild = vi.fn(({ targetRef }) => (\n      <div ref={targetRef} data-testid=\"countdown\" />\n    ))\n\n    render(<Countdown end_at={endTime}>{renderChild}</Countdown>)\n\n    expect(screen.getByTestId('countdown')).toBeEmptyDOMElement()\n\n    act(() => {\n      vi.advanceTimersByTime(10000)\n    })\n\n    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:50')\n\n    act(() => {\n      vi.advanceTimersByTime(50000)\n    })\n\n    expect(screen.getByTestId('countdown')).toHaveTextContent('00:00:00')\n  })\n})\n```\n\n---\n\n- https://vitest.dev/guide/mocking\n- https://vitest.dev/api/vi.html#fake-timers"},{"i":"326","k":["react","testing","formdata"],"c":"2024-12-31","m":"2024-12-31","b":"폼 제출 시 FormData 테스트\n\n```tsx\ntest('폼 제출 시 formData 확인', () => {\n  const handleSubmit = jest.fn((e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault()\n    const formData = new FormData(e.target as HTMLFormElement)\n    const data = Object.fromEntries(formData.entries())\n    expect(data).toEqual({ username: 'testuser', password: 'password' })\n  })\n\n  render(<LoginForm onSubmit={handleSubmit} />)\n\n  fireEvent.change(screen.getByPlaceholderText('아이디'), {\n    target: { value: 'testuser' },\n  })\n  fireEvent.change(screen.getByPlaceholderText('비밀번호'), {\n    target: { value: 'password' },\n  })\n  fireEvent.click(screen.getByRole('button', { name: '로그인' }))\n\n  expect(handleSubmit).toHaveBeenCalled()\n})\n```"},{"i":"325","k":["javascript","formatting"],"c":"2024-12-31","m":"2024-12-31","b":"거리 만km 포맷팅\n\n```typescript\nfunction formatToManKm(distance: number): string {\n  const manKm = (distance / 10000).toFixed(1)\n\n  return `${manKm}만km`\n}\n\nformatToManKm(104335) // \"10.4만km\"\n```"},{"i":"324","k":["accessibility","aria"],"c":"2024-12-31","m":"2024-12-31","b":"ARIA 속성 적용 패턴\n\n```tsx\n// region + labelledby\n<div role=\"region\" aria-labelledby={titleId}>\n  <h2 id={titleId}>제목</h2>\n</div>\n\n// list + listitem\n<div role=\"list\" aria-labelledby=\"list-title\">\n  <div role=\"listitem\" aria-labelledby={`${label}-label ${label}-value`}>\n    <span id={`${label}-label`}>{label}</span>\n    <span id={`${label}-value`}>{value}</span>\n  </div>\n</div>\n```\n\n- `aria-labelledby` - 연결된 요소의 텍스트를 레이블로\n- `aria-describedby` - 추가 설명이 필요할 때\n- 중복 제거: aria-label과 화면 텍스트 중복 시 aria-labelledby 사용"},{"i":"323","k":["javascript","regex","formatting"],"c":"2024-12-31","m":"2024-12-31","b":"전화번호 포맷팅\n\n```javascript\nfunction formatPhoneNumber(phoneNumber) {\n  const cleaned = phoneNumber.replace(/\\D/g, '')\n\n  if (cleaned.length === 11) {\n    return cleaned.replace(/(\\d{3})(\\d{4})(\\d{4})/, '$1-$2-$3')\n  } else if (cleaned.length === 10 && cleaned.startsWith('02')) {\n    return cleaned.replace(/(\\d{2})(\\d{3})(\\d{4})/, '$1-$2-$3')\n  } else if (cleaned.length === 10) {\n    return cleaned.replace(/(\\d{3})(\\d{3})(\\d{4})/, '$1-$2-$3')\n  } else if (cleaned.length === 9 && cleaned.startsWith('02')) {\n    return cleaned.replace(/(\\d{2})(\\d{3})(\\d{3})/, '$1-$2-$3')\n  }\n  return 'Invalid phone number'\n}\n\nformatPhoneNumber('01012341234') // 010-1234-1234\nformatPhoneNumber('021234567') // 02-123-4567\n```"},{"i":"322","k":["javascript","localstorage","map"],"c":"2024-12-31","m":"2024-12-31","b":"Map으로 localStorage 래핑\n\n```javascript\nclass LocalStorageMap {\n  constructor(storageKey) {\n    this.storageKey = storageKey\n    this.map = this.loadFromStorage()\n  }\n\n  loadFromStorage() {\n    const data = localStorage.getItem(this.storageKey)\n    return data ? new Map(JSON.parse(data)) : new Map()\n  }\n\n  saveToStorage() {\n    localStorage.setItem(this.storageKey, JSON.stringify([...this.map]))\n  }\n\n  set(key, value) {\n    this.map.set(key, value)\n    this.saveToStorage()\n  }\n\n  get(key) {\n    return this.map.get(key)\n  }\n\n  delete(key) {\n    const result = this.map.delete(key)\n    this.saveToStorage()\n    return result\n  }\n\n  clear() {\n    this.map.clear()\n    localStorage.removeItem(this.storageKey)\n  }\n}\n```\n\n> [!CAUTION]\n>\n> - localStorage는 문자열만 저장 → JSON 직렬화 필수\n> - 용량 제한 5~10MB 주의"},{"i":"321","k":["status","ternary"],"c":"2024-12-31","m":"2024-12-31","b":"```ts\nconst color =\n  (isUnchecked && 'palette.label.alternative') ||\n  (isChecked && 'palette.primary.normal') ||\n  'palette.label.normal'\n```\n\n중첩 삼항/논리 연산자는 간결하지만 상태가 늘어나면 유지보수 어려움.\n\n핵심: **`status` 모델을 어떻게 정의하고 참조할 것인가?**\n\n```ts\nclass StatusManager {\n  static Status = {\n    UNCHECKED: 'UNCHECKED',\n    CHECKED: 'CHECKED',\n    DEFAULT: 'DEFAULT',\n  } as const\n\n  static getColor(status: keyof typeof this.Status) {\n    switch (status) {\n      case this.Status.UNCHECKED:\n        return 'palette.label.alternative'\n      case this.Status.CHECKED:\n        return 'palette.primary.normal'\n      default:\n        return 'palette.label.normal'\n    }\n  }\n}\n\nconst color = StatusManager.getColor(StatusManager.Status.CHECKED)\n```\n\n---\n\n- [Expression of type string can&#x27;t be used to index type X | Total TypeScript](https://www.totaltypescript.com/concepts/type-string-cannot-be-used-to-index-type)"},{"i":"320","k":["variable"],"c":"2024-12-31","m":"2024-12-31","b":"변수의 **prefix**는 역할과 의미를 명확히 하기 위해 사용된다.\n\n```js\n/**\n * 초기값을 나타내며, 상태나 값이 처음 설정될 때 사용된다.\n * 적합한 상황: 프로그램이 시작되거나 객체가 처음 생성될 때의 값을 정의한다.\n */\nconst initialCount = 0 // 카운터의 초기값\nconst initialState = { loggedIn: false, user: null } // 초기 상태\nconst initialPosition = { x: 0, y: 0 } // 초기 좌표\n\n/**\n * 기본값으로 널리 사용되는 표준 값이다.\n * 적합한 상황: 값이 없을 경우 사용할 기본값을 정의한다.\n */\nconst defaultTheme = 'light' // 기본 테마\nconst defaultUser = { name: 'Guest', role: 'viewer' } // 기본 사용자\nconst defaultPageSize = 20 // 페이지당 기본 항목 수\n\n/**\n * 기준값 또는 다른 값의 참조점이 되는 값이다.\n * 적합한 상황: 값을 계산하거나 파생할 때 기준이 되는 값을 정의한다.\n */\nconst baseSalary = 3000 // 기준 급여\nconst baseUrl = 'https://api.example.com' // API의 기준 URL\nconst baseColor = '#FFFFFF' // 기준 색상\n\n/**\n * 변경되기 전 원래 상태 또는 초기 상태를 강조한다.\n * 적합한 상황: 데이터를 변경하기 전에 원래 값을 유지해야 할 때 사용한다.\n */\nconst originalText = 'Hello World' // 변경 전 텍스트\nconst originalSettings = { theme: 'dark', notifications: true } // 원래 설정값\nconst originalImage = image.clone() // 원본 이미지 복사\n```\n\n- `initial*` vs `default*` - initial은 초기 상태, 재설정 가능성 적음. default는 기본값, 교체 가능.\n- `base*` vs `original*` - base는 기준값(비교/계산용). original은 원래 상태 보존/복구용."},{"i":"319","k":["mermaid","state","react"],"c":"2024-09-17","m":"2024-09-17","b":"import { Blockquote } from '@components/Blockquote'\n\n<Blockquote\n  cite={{\n    url: 'https://massimilianomirra.com/notes/expressive-components-in-vanilla-react-part-1-type-states',\n    title: 'Expressive components in vanilla React, part 1: TypeStates',\n  }}\n>\n  이 패턴이 \"상태 머신\"처럼 들린다면, 그리 놀랄 일도 아닙니다. 결국, 선택의\n  문제는 상태 머신을 구축할지 말지가 아니라, 그것을 암시적으로 구축할지\n  명시적으로 구축할지에 달려 있습니다.\n</Blockquote>\n\n```mermaid\nstateDiagram-v2\n  [*] --> Mounting\n\n  Mounting --> AwaitingEmailInput\n  Mounting --> AwaitingCodeInput\n\n  AwaitingEmailInput --> SubmittingEmail\n\n  SubmittingEmail --> AwaitingCodeInput\n  SubmittingEmail --> AwaitingEmailInput\n\n  AwaitingCodeInput --> SubmittingCode\n\n  SubmittingCode --> Success\n  SubmittingCode --> AwaitingCodeInput\n\n  Success --> [*]\n```\n\n![](https://mermaid.ink/svg/pako:eNqFkktPhDAUhf8KuUsDZJjyKF2YGHVh4mycneKiQmUah5aUVh0J_93ymHFmDMqioed89_bkti3ksmBAoNFUsxtOS0Ur732ZCcd-BVcs11wK5_5hVMb16eLZ8bxLZyWN0FyUmRj1_X4wrz4o7ze3FeXbO1Eb_Qd0bVNMzEj9rh74tXmpuD4Ye_pMnmv9H3oc9TTGoclZil4_nsypM8F5zppm1p8dwVQ4QHbi4ELFlE1Y2OtqeyQDvWEVy4DY34Kqtwwy0VmOGi3XO5ED0cowF0xd_NwukFe6baxaUwGkhU8gXoCwH0RpmIQ4wmGyiFzYAQlj7CeLECNklyBNUdy58CWlbRH4SRJEcbBEC4QjZGuHfo-DOR7KCq6lWo3Pa3hlLihpys0UoPsGYNnNCQ)"},{"i":"318","k":["blob","script"],"c":"2024-09-16","m":"2024-09-16","b":"import { Blockquote } from '@components/Blockquote'\n\n<Blockquote \n  cite={{\n    url: \"https://violentmonkey.github.io/posts/inject-scripts-with-blob-urls/\",\n    title: \"Inject scripts with Blob URLs\"\n  }}\n>\nBlob URL 방식은 Firefox 58+에서 CSP 제한이 있는 페이지에서도 스크립트를 주입할 수 있는 방법입니다.\n이 방식을 사용할 때, 비동기성 때문에 발생할 수 있는 문제를 방지하려면 초기화 코드가 준비된 후 스크립트를 전달해야 합니다.\n</Blockquote>\n\n```ts\nconst b = new Blob([script], { type: 'text/javascript' })\nconst u = URL.createObjectURL(b)\nconst s = document.createElement('script')\ns.src = u\ndocument.body.appendChild(s)\ndocument.body.removeChild(s)\nURL.revokeObjectURL(u)\n```"},{"i":"317","k":["test","vitest","matchers","zod"],"c":"2024-09-15","m":"2024-09-15","b":"[vitest](https://vitest.dev/)에서 사용자 지정 assertion을 추가하여 Zod 스키마와 Response 객체를 비교하기\n\n```ts\nimport { expect } from 'vitest'\nimport type { ZodTypeAny } from 'zod'\n\nexpect.extend({\n  /**\n   * @param received 테스트할 Response 객체\n   * @param schema 검증할 Zod 스키마\n   */\n  async toMatchSchema(received: Response, schema: ZodTypeAny) {\n    const response = await received.json()\n    const result = await schema.safeParseAsync(response)\n\n    return {\n      message: () => '',\n      pass: result.success,\n    } satisfies ExpectationResult\n  },\n})\n```\n\n`vitest.d.ts` 파일에서 CustomMatchers 인터페이스를 확장하여 TypeScript와의 통합성을 유지.\n\n```ts\nimport type { ZodTypeAny } from 'zod'\n\ninterface CustomMatchers<R = unknown> {\n  toMatchSchema(schema: ZodTypeAny): Promise<R>\n}\n```\n\n`todoResponse`의 응답 데이터가 `todoSchema`에 정의된 Zod 스키마와 일치하는지 확인한다.\n\n```ts\ntest('todo', async () => {\n  expect(todoResponse.ok).toBeTruthy()\n  expect(todoResponse).toMatchSchema(todoSchema)\n})\n```\n\n---\n\n- [Extending Matchers | Guide | Vitest](https://vitest.dev/guide/extending-matchers)\n- [Expect · Jest](https://jestjs.io/docs/expect#expectextendmatchers)"},{"i":"315","k":["date","test"],"c":"2024-09-03","m":"2024-09-03","b":"import { transformerMetaHighlight } from '@shikijs/transformers'\nimport { Code } from 'astro:components'\n\nvitest와 jest에서 가짜 타이머를 사용하는 방법. 내부적으로는 [@sinonjs/fake-timers](https://github.com/sinonjs/fake-timers)를 사용.\nvitest와 jest 모두 시스템 시간을 조작할 수 있도록 하여 테스트가 일관되게 실행되도록 하고 다양한 시간과 날짜를 시뮬레이션하여 다양한 조건에서 코드가 예상대로 작동하는지 확인할 수 있음.\n\n<Code\n  code={`describe('isSameDate', () => {\n    beforeEach(() => {\n      vi.useFakeTimers()\n      vi.setSystemTime('2024-07-20')\n    })\n\n    afterEach(() => {\n      vi.useRealTimers()\n    })\n\n    it('현재 날짜와 동일한 날짜를 전달하면 true를 반환한다', () => {\n      expect(isSameDate('2024-07-20')).toBe(true)\n    })\n\n    it('현재 날짜와 다른 날짜를 전달하면 false를 반환한다', () => {\n      expect(isSameDate('2024-07-19')).toBe(false)\n      expect(isSameDate('2024-07-21')).toBe(false)\n    })\n})`}\n  lang=\"tsx\"\n  transformers={[transformerMetaHighlight()]}\n  meta=\"{3,4,8}\"\n/>\n\n---\n\n- https://jestjs.io/blog/2020/05/05/jest-26#new-fake-timers\n- https://vitest.dev/guide/mocking#dates"},{"i":"314","k":["date","random","test","flaky"],"c":"2024-09-01","m":"2024-01-09","b":"Flaky 테스트 방지 - `Date`, `Math.random` 같은 비순수 함수를 props로 주입\n\n- `new Date()`, `Math.random()` 같은 비순수 함수는 매번 다른 결과 → 테스트 불안정\n- 해결: 기본 매개변수로 주입하면 프로덕션 동작 유지 + 테스트에서 제어 가능\n\n```js\nfunction Date({ date = new Date() }) {\n  const [date, setDate] = React.useState(date)\n\n  return (\n    <input\n      type=\"date\"\n      onChange={(e) => setDate(e.target.value)}\n      defaultValue={date}\n    />\n  )\n}\n\nfunction Random({ randomizer = Math.random }) {\n  const [state, setState] = React.useState(randomizer())\n\n  return <div>{state}</div>\n}\n```\n\n테스트:\n\n```tsx\ndescribe('Date Component', () => {\n  it('should update state on date change', () => {\n    render(<Date date={new Date('2023-01-01')} />)\n\n    const input = screen.getByRole('textbox')\n\n    expect(input.value).toBe('2023-01-01')\n  })\n})\n\ndescribe('Random Component', () => {\n  it('should render a random number', () => {\n    const mockRandomizer = () => 0.5\n\n    render(<Random randomizer={mockRandomizer} />)\n\n    const div = screen.getByText('0.5')\n\n    expect(div).toBeInTheDocument()\n  })\n})\n```"},{"i":"313","k":["dialog"],"c":"2024-09-01","m":"2024-09-01","b":"> 대화 상자(Dialog Box)는 사용자에게 정보를 전달하고 응답을 요청하는 그래픽 제어 요소이다.\n> [Dialog box](https://en.wikipedia.org/wiki/Dialog_box)\n\n**Modal** - 대화 상자를 연 소프트웨어와의 상호작용을 차단\n\n- System Modal - 닫기 전까지 다른 작업 불가. 과거 단일 작업 시스템에서 사용.\n- Application Modal - 프로그램 일시 중단. 워크플로우 방해로 비판받음.\n- Document Modal - 부모 창만 차단. macOS 시트 형태.\n\n**Modeless** - 소프트웨어의 다른 부분과 상호작용 허용. 대화 상자가 열려 있어도 작업 가능. 툴바가 예시.\n\n**고려 사항:**\n\n- 모달의 문제점 - 사용자 흐름 방해. 습관적으로 *확인*을 누르게 되어 작업 손실 가능.\n- 경고 대신 실행 취소 - 경고는 한계가 있음. 실행 취소(Undo) 기능이 더 효과적.[^313-1]\n- *경고를 사용하는 대신, 실행 취소를 제공하라*\n\n---\n\n[^313-1]: https://alistapart.com/article/neveruseawarning/"},{"i":"312","k":["excel","active-x-object","node"],"c":"2024-08-19","m":"2026-07-27","b":"[Excel View](https://jsr.io/@timepp/ev)[^312-1] 라이브러리를 보던 중, Excel과 Node의 상호작용에 대한 궁금증이 생겼다. 아무래도 웹개발을 하다보니 어플리케이션과 통신할 수 있는 부분에 대해서 전혀 생각을 안했었다는 걸 깨닫고 이러한 부분을 보완하기 위해 Excel과의 통신 방식을 찾아봄.\n\n우선, 위 라이브러리 코드를 통해 `ActiveXObject('Excel.Application')`[^312-2]로 Excel 객체를 생성하고, 이를 통해 Excel의 다양한 기능에 접근할 수 있다는 것을 확인했다. 그리고 [node-activex](https://github.com/durs/node-activex)의 문서에서 링크를 통해 추가적인 정보들을 확인할 수 있었는데 아무래도 자주 보던 영역이 아니라 일단 확인만 하는 단계에서 멈춤.\n\n이 접근을 통해, Excel과 상호작용하는 방법에 대해 실마리를 찾을 수 있었음. 그러나 모든 과정이 순조롭지만은 않은게. `ActiveXObject`를 사용하는 부분에서 개념적 이해가 부족했고, 그밖에 Excel의 [객체모델](https://learn.microsoft.com/en-us/office/vba/api/overview/excel/object-model)에 대해서도 배경지식이 많이 부족하다는 걸 알게됨.\n\n이번 경험을 통해, 웹 개발자가 어플리케이션 레벨의 개념들도 이해하면 좋겠다는 생각을 하게 됨. 나중에 기회가 되면 살펴보고 일단 view 기능을 스프레드시트로 구현해 봐야겠다.\n\n---\n\n[^312-1]: 활성 셀의 행 전체를 별도 창에 띄워서 좌우 스크롤 없이 보게 해주는 도구\n[^312-2]: https://github.com/timepp/excelview/blob/master/excel.js#L70"},{"i":"311","k":["react"],"c":"2024-08-13","m":"2026-09-01","b":"import {\n  transformerNotationFocus,\n  transformerMetaHighlight,\n} from '@shikijs/transformers'\nimport { Code } from 'astro:components'\nimport { code } from './311.code'\n\n> Avoiding premature abstraction with Unstyled React Components ([buildui.com](https://buildui.com/posts/avoiding-premature-abstraction-with-unstyled-react-components))\n\nReact 컴포넌트를 작성할 때, 불필요한 추상화를 피하고 컴포넌트의 유연성을 유지하는 방법. 특히 스타일이 없는 컴포넌트를 통해 어떻게 컴포넌트의 기능에 집중할 수 있는지를 설명.\n\n<Code\n  code={code}\n  lang=\"tsx\"\n  transformers={[transformerMetaHighlight()]}\n  meta=\"{}\"\n/>\n\n이 패턴은 컴포넌트를 작성할 때 불필요한 스타일링이나 구조를 미리 정의하지 않고, 각 컴포넌트가 자신의 역할에 충실할 수 있도록 도와줍니다. 이를 통해 코드의 유연성을 유지하고, 필요에 따라 컴포넌트를 확장하거나 수정할 수 있는 여지를 남겨두게 됩니다."},{"i":"310","t":"React Router의 Link 컴포넌트 테스트하기","k":["test","react-router"],"c":"2024-08-06","m":"2026-07-27","b":"React 애플리케이션에서 라우팅은 중요한 부분. 특히, 사용자가 링크를 클릭할 때 적절한 페이지로 이동하는지 테스트하는 것은 중요. 다음은 [React Router](https://reactrouter.com/en/main)의 `<Link />` 컴포넌트를 테스트하는 코드.\n\n```tsx\ndescribe('Link', () => {\n  it('이동', () => {\n    const Home = () => <Link to=\"/about\">About</Link>\n    const About = () => <h1>About</h1>\n\n    const { getByRole } = render(\n      <MemoryRouter initialEntries={['/']}>\n        <Routes>\n          <Route path=\"/\" element={<Home />} />\n          <Route path=\"/about\" element={<About />} />\n        </Routes>\n      </MemoryRouter>\n    )\n\n    fireEvent.click(getByRole('link'))\n\n    expect(screen.getByRole('heading')).toHaveTextContent('About')\n  })\n})\n```\n\n가끔 `<Link />`도 테스트해야 할 때가 있다. 위 코드는 사용자가 \"About\" 링크를 클릭하면 \"About\" 페이지로 이동하는지 테스트. fireEvent.click을 사용해 링크 클릭 이벤트를 발생시키고, screen.getByRole을 통해 헤딩 요소의 텍스트가 'About'인지 확인. 이 테스트를 통해 라우팅이 제대로 동작하는지 확인할 수 있음.\n\n---\n\n- https://github.com/remix-run/react-router/blob/main/packages/react-router/__tests__/dom/link-click-test.tsx"},{"i":"309","k":["enzyme","test","react-testing-library"],"c":"2024-07-11","m":"2026-07-27","b":"최근에 [Enzyme](https://github.com/enzymejs/enzyme)을 제거하고 [React Testing Library](https://github.com/testing-library/react-testing-library)로 교체하는 작업을 진행했습니다. [JSX](https://facebook.github.io/jsx/) 영역은 별 문제 없이 진행되었으나, state나 props를 다루는 구현 부분에서 약간의 애매함이 있었습니다. 개인적으로는 기존 접근 방식이 나쁘지 않았다고 생각했기 때문에, 새로운 접근 방식으로 전환하는 데 주저하게 되었습니다.\n\n결론적으로, state와 props의 JSON 결과물을 렌더링하고 [getByTestId](https://testing-library.com/docs/queries/bytestid/)를 사용하여 이를 참조하는 방식으로 테스트를 진행하기로 했습니다. 다음은 그 예시입니다:\n\n```tsx\nrender() {\n  return <>\n    {process.env.NODE_ENV === 'test' && (\n      <pre data-testid=\"ThumbnailDebug\">\n        {JSON.stringify({\n          props: this.props,\n          state: this.state,\n        })}\n      </pre>\n    )}\n  </>\n}\n```\n\n이 방법을 통해 Enzyme을 제거한다는 목표를 달성했기 때문에, 어느 정도는 해결된 것처럼 보이며, 추후 더 나은 테스트 코드를 작성하기 위한 고민을 할 수 있을 것 같습니다.\n\n---\n\n- https://slack.engineering/balancing-old-tricks-with-new-feats-ai-powered-conversion-from-enzyme-to-react-testing-library-at-slack/"},{"i":"307","k":["react","suspense"],"c":"2024-06-24","m":"2026-08-19","b":"```ts\nif (!data) {\n  throw fetch()\n}\n```\n\nSuspense는 던져진 promise를 캐치해서 resolve될 때까지 fallback을 렌더하고, resolve되면 다시 렌더한다. 던지는 건 예외와 같은 메커니즘이다.\n\n---\n\n- https://github.com/facebook/react/blob/eb8feb71096eec5c885b2a4c7d8d030d3622f265/packages/react/src/ReactLazy.js#L222\n- https://github.com/TanStack/query/blob/b8e35598ef24855a2792414faea5721d833fa61e/packages/react-query/src/suspense.ts#L61\n- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/throw\n- https://jser.pro/ddir/rie?reactVersion=18.3.1&codeKey=ud62nsxll29yy0dzba8"},{"i":"306","k":["nextjs"],"c":"2024-06-24","m":"2024-08-07","b":"Next.js Route Loader & Mini CSS Extract Plugin - prefetch와 CSS chunk 에러 처리\n\n**prefetchViaDom** - [route-loader.ts](https://github.com/vercel/next.js/blob/16cf88e569552fe5060f1d28a657b749b967528d/packages/next/src/client/route-loader.ts#L121-L150)\n\n```typescript\nfunction prefetchViaDom(\n  href: string,\n  as: string,\n  link?: HTMLLinkElement\n): Promise<any> {\n  return new Promise<void>((resolve, reject) => {\n    const selector = `\n      link[rel=\"prefetch\"][href^=\"${href}\"],\n      link[rel=\"preload\"][href^=\"${href}\"],\n      script[src^=\"${href}\"]`\n    if (document.querySelector(selector)) {\n      return resolve()\n    }\n\n    link = document.createElement('link')\n\n    // The order of property assignment here is intentional:\n    if (as) link!.as = as\n    link!.rel = `prefetch`\n    link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!\n    link!.onload = resolve as any\n    link!.onerror = () =>\n      reject(markAssetError(new Error(`Failed to prefetch: ${href}`)))\n\n    // `href` should always be last:\n    link!.href = href\n\n    document.head.appendChild(link)\n  })\n}\n```\n\n- 이미 prefetch/preload된 리소스면 스킵\n- `<link rel=\"prefetch\">` 생성 후 head에 추가\n- `href`는 항상 마지막에 설정 (브라우저 요청 타이밍 제어)\n\n**CSS Chunk Load Error** - [Mini CSS Extract Plugin](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/3df97b62778fc4586c5198b9f3a447dc65979529/src/index.js#L947C19-L958C22)\n\n```javascript\n Template.indent([\n  \"var errorType = event && event.type;\",\n  \"var realHref = event && event.target && event.target.href || fullhref;\",\n  'var err = new Error(\"Loading CSS chunk \" + chunkId + \" failed.\\\\n(\" + errorType + \": \" + realHref + \")\");',\n  'err.name = \"ChunkLoadError\";',\n  // TODO remove `code` in the future major release to align with webpack\n  'err.code = \"CSS_CHUNK_LOAD_FAILED\";',\n  \"err.type = errorType;\",\n  \"err.request = realHref;\",\n  \"if (linkTag.parentNode) linkTag.parentNode.removeChild(linkTag)\",\n  \"reject(err);\",\n]),\n```\n\n- `ChunkLoadError` 커스텀 에러 생성\n- 실패한 `<link>` 태그 DOM에서 제거 후 reject"},{"i":"305","k":["google-sheets","google-cloud"],"c":"2024-06-22","m":"2026-09-01","b":"[Google Sheets API](https://developers.google.com/sheets/api/guides/concepts)를 쓰려면 GCP 설정을 먼저 하고 [google-spreadsheet](https://github.com/theoephraim/node-google-spreadsheet)로 조작한다.\n\n**순서를 기억하려고 적어둔 것이다.** 각 단계의 방법이 아니라 밟는 차례가 내용이다.\n\n1. 서비스 계정 설정\n2. [Google Cloud](https://console.cloud.google.com/) 프로젝트 생성\n3. Google Sheets API 활성화\n4. API 자격 증명 생성\n5. 서비스 계정 생성\n6. 서비스 계정 키 생성\n7. 환경 변수 설정\n\n---\n\n- [How To Use Google Sheets as a Database With React via Next.js](https://thenewstack.io/how-to-use-google-sheets-as-a-database-with-react-and-ssr/)"},{"i":"304","t":"자연어 날짜 처리와 접근성 높은 데이트피커 - chrono와 inclusive-dates","k":["dates","chrono","datepicker","inclusive-dates"],"c":"2024-06-02","m":"2024-08-09","b":"[chrono](https://github.com/wanasit/chrono)는 다양한 형식의 날짜/시간을 처리하고 주어진 텍스트에서 정보를 추출할 수 있도록 설계된 자연어 날짜 파서.\n\n- \"Today\", \"Tomorrow\", \"Yesterday\", \"Last Friday\" 등의 상대적 날짜 처리\n- \"17 August 2013 - 19 August 2013\"와 같은 날짜 범위 처리\n- \"This Friday from 13:00 - 16.00\"와 같은 시간 포함 날짜 처리\n- \"5 days ago\", \"2 weeks from now\"와 같은 상대적 시간 표현 처리\n- \"Sat Aug 17 2013 18:40:39 GMT+0900 (JST)\"와 같은 표준 날짜 형식 처리\n- \"2014-11-30T08:15:30-05:30\"와 같은 ISO 8601 형식 처리\n\n`parse()`[^304-1][^304-2][^304-3]\n\n```ts\nparse(text: string, referenceDate?: ParsingReference | Date, option?: ParsingOption): ParsedResult[] {\n  // 1. 파싱 컨텍스트 생성\n  // 2. 모든 파서를 실행하고 결과 수집\n  // 3. 결과를 인덱스 기준으로 정렬\n  // 4. 모든 리파이너를 적용하여 결과 개선\n  // 5. 최종 결과 반환\n}\n```\n\n[inclusive-dates](https://github.com/fymmot/inclusive-dates)는 자연어 입력을 지원하는 사용자 친화적이고 완전히 접근 가능한 데이트피커. 내부적으로 chrono를 사용하여 자연어 날짜 입력을 처리. 이는 두 라이브러리의 장점을 결합한 좋은 예시.\n\n```tsx\nconst parsedDate = await chronoParseDate(text, {\n  locale: this.locale.slice(0, 2),\n  minDate: this.minDate,\n  maxDate: this.minDate,\n  referenceDate: removeTimezoneOffset(new Date(this.referenceDate)),\n  ...chronoOptions,\n})\n```\n\n1. chrono의 강력한 자연어 날짜 파싱 능력을 활용.\n2. inclusive-dates는 이를 사용자 친화적이고 접근성 높은 UI 컴포넌트로 구현.\n\n이러한 조합을 통해, 개발자들은 사용자에게 직관적이고 유연한 날짜 입력 방식을 제공하면서도 접근성과 사용성을 높일 수 있음.\n\n---\n\n[^304-1]: [executeParser](https://github.com/wanasit/chrono/blob/master/src/chrono.ts#L83)\n[^304-2]: [sort](https://github.com/wanasit/chrono/blob/master/src/chrono.ts#L87)\n[^304-3]: [refine](https://github.com/wanasit/chrono/blob/master/src/chrono.ts#L92)"},{"i":"303","t":"Grid View","k":[],"c":"2024-06-02","m":"2024-08-12","b":"> [grid view](<(https://en.wikipedia.org/wiki/Grid_view)>)(또는 datagrid)는 데이터의 표 형식 보기를 제공하는 그래픽 제어 요소입니다.\n\n일반적으로 다음 중 일부 또는 전부를 지원합니다.\n\n- 열 머리글을 클릭하여 그리드의 정렬 순서 변경\n- 열 머리글을 끌어서 크기 및 순서 변경\n- 보기 데이터의 제자리 편집\n- 행과 열 구분 기호 및 행 배경색 번갈아 지정하기\n\n---\n\n- https://github.com/adazzle/react-data-grid\n- https://github.com/glideapps/glide-data-grid\n- https://github.com/finos/perspective\n- https://github.com/teableio/teable"},{"i":"302","k":[],"c":"2024-05-16","m":"2024-05-27","b":"- [Cornerstone.js](https://github.com/cornerstonejs/cornerstone) HTML5 canvas를 지원하는 웹 브라우저에서 가벼운 의료 이미지 표시에 사용되는 JavaScript 라이브러리\n- [OpenSeadragon](https://github.com/openseadragon/openseadragon) 데스크톱 및 모바일에서 사용 가능한 고해상도 줌 가능한 이미지를 위한 순수 JavaScript 뷰어\n- [dicomParser](https://github.com/cornerstonejs/dicomParser) DICOM 파일을 처리하여 의료 이미지 데이터를 JavaScript 객체로 변환하는 라이브러리\n- [AMI Medical Imaging (AMI)](https://github.com/FNNDSC/ami) 웹에서 의료 영상을 효과적으로 표시하고 주석을 추가하는 데 사용되는 JavaScript 라이브러리\n- [OHIF Medical Imaging Viewer](https://github.com/OHIF/Viewers)\n- [itk-wasm](https://github.com/InsightSoftwareConsortium/itk-wasm) 의료 영상 처리를 위한 JavaScript 라이브러리\n- [Brainchop](https://github.com/neuroneural/brainchop)\n\n---\n\n예전에 관련 회사 기술 블로그 보다가 생각나서 찾아본 라이브러리 리스트. 대용량, 의학용 이미지 포멧을 다루기 위한 도구들."},{"i":"301","k":["astro","node","memory"],"c":"2024-04-21","m":"2024-06-06","b":"[Astro](https://astro.build/)를 사용한 프로젝트에서 빌드를 하는데 _JavaScript heap out of memory_ 에러가 발생했다.\n쉽게 해결하자면 [`NODE_OPTIONS=--max_old_space_size`(in megabytes)](https://nodejs.org/docs/latest-v20.x/api/cli.html#--max-old-space-sizesize-in-megabytes) 설정해서 우회할수는 있겠지만 정리가 필요해서 메모를 남겨본다.\n일단 원인은 파일사이즈가 크다는 점, 그리고 카테고리(국가)별 데이터가 많다는 점인데 개선할만한 부분은 두가지 정도인 듯.\n\n- 가능한 전처리해서 데이터를 다시 생성해서 참조\n- [`Astro.glob`](https://docs.astro.build/en/guides/imports/#astroglob)을 사용해서 조건부로 데이터를 가져오기\n\n예상치 못한 에러였는데 역시 트레이드오프는 존재하기 마련이다.\n\n---\n\n- https://www.stevefenton.co.uk/blog/2023/07/astro-javascript-heap-out-of-memory/"},{"i":"300","k":["pkg","node","nexe"],"c":"2024-04-07","m":"2024-04-07","b":"로컬에서만 실행 해야하는 프로젝트가 있길래 아무래도 사전설정 같은 귀찮은 문제가 있으니 [pkg](https://github.com/vercel/pkg)를 사용하면 좋을 것 같아서 들어가봤는데 개발이 중단 되었다.\n\n노드 21버젼에서 해당 기능이 지원되는데 재미있는 시도들이 많이 나왔으면 좋겠다.\n\n---\n\n- https://nodejs.org/api/single-executable-applications.html\n- https://github.com/nexe/nexe"},{"i":"297","k":["ui","ark-ui","component"],"c":"2023-10-07","m":"2024-03-22","b":"```css\n[data-scope='slider'][data-part='thumb']\n\n[data-scope='slider'][data-part='track']\n\n[data-scope='slider'][data-part='control']\n```\n\n> Ark UI의 각 컴포넌트 파트는 `data-scope` 및 `data-part` 속성으로 지정됩니다. `data-scope` 속성은 컴포넌트를 식별하고, `data-part` 속성은 컴포넌트의 개별 부분을 지정합니다.\n\n[ark-ui](https://ark-ui.com)에서 사용하는 방식인데 적용해볼만한 컨셉이라고 생각한다.\n\n---\n\n- https://ark-ui.com/docs/overview/getting-started"},{"i":"294","k":["proxy","debug"],"c":"2023-09-04","m":"2026-08-02","b":"요청 본문은 한 번만 읽을 수 있다. 누가 미리 읽었는지는 읽기 메서드를 Proxy 로 감싸서 찾는다.\n\n```js\nconst bodyReadingMethods = ['arrayBuffer', 'blob', 'formData', 'text', 'json']\n\nbodyReadingMethods.forEach((methodName) => {\n  request[methodName] = new Proxy(request[methodName], {\n    apply(...args) {\n      console.trace(`Premature \"request.${methodName}\" call!`)\n      return Reflect.apply(...args)\n    },\n  })\n})\n```\n\nhttps://redd.one/blog/debugging-like-a-pro-xy"},{"i":"292","k":["webpack","esm"],"c":"2023-08-28","m":"2024-03-22","b":"```json\nconfigureWebpack: {\n    module: {\n      rules: [\n        {\n          test: /\\.mjs$/,\n          include: /node_modules/,\n          type: 'javascript/auto',\n        },\n      ],\n    },\n  },\n```"},{"i":"289","k":["modal","sheet","bottom-sheet"],"c":"2023-08-07","m":"2024-03-22","b":"Bottom Sheet 라이브러리 비교\n\n**react-modal-sheet** 추천:\n- 활발한 유지보수 (v5 최근 릴리즈)\n- Framer Motion 기반, Compound component 패턴\n- `avoidKeyboard`, `disableDismiss` 등 유용한 옵션\n\n```tsx\nimport { Sheet } from 'react-modal-sheet'\n\n<Sheet isOpen={isOpen} onClose={() => setOpen(false)}>\n  <Sheet.Container>\n    <Sheet.Header />\n    <Sheet.Content>콘텐츠</Sheet.Content>\n  </Sheet.Container>\n  <Sheet.Backdrop />\n</Sheet>\n```\n\n> [!WARNING]\n> react-spring-bottom-sheet는 3년간 업데이트 없음. 신규 프로젝트에선 피할 것.\n\n---\n\n- [react-modal-sheet](https://github.com/Temzasse/react-modal-sheet)\n- [vaul](https://github.com/emilkowalski/vaul) - Radix 기반 대안\n- [Material Design - Bottom Sheet](https://m3.material.io/components/bottom-sheets)\n- [Apple HIG - Sheet](https://developer.apple.com/design/human-interface-guidelines/sheets)"},{"i":"287","k":["error"],"c":"2023-07-03","m":"2024-03-22","b":"```ts\nclass CustomError extends Error {\n  name = 'CustomError'\n\n  constructor(message?: string) {\n    super(message)\n\n    Object.setPrototypeOf(this, CustomError.prototype)\n  }\n}\n\ntry {\n  throw new CustomError('This is a custom error message.')\n} catch (error) {\n  if (error instanceof CustomError) {\n    console.log('CustomError occurred:', error.message)\n    console.log('Error name:', error.name)\n  } else {\n    console.log('An error occurred:', error)\n  }\n}\n```\n\n- https://github.com/Alex-D/check-disk-space/blob/main/src/errors/invalidPathError.ts\n- https://github.com/adriengibrat/ts-custom-error/blob/main/src/custom-error.ts\n- [📘 타입스크립트 커스텀 Error 처리하기](https://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%BB%A4%EC%8A%A4%ED%85%80-Error-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0)\n- [Handling errors like a pro in TypeScript | by Kolby Sisk | Udacity Eng & Data](https://engineering.udacity.com/handling-errors-like-a-pro-in-typescript-d7a314ad4991)"},{"i":"281","k":["debug"],"c":"2023-06-18","m":"2024-03-22","b":"```ts\nenum LogLevel {\n  DEBUG = 'debug',\n  INFO = 'info',\n  WARN = 'warn',\n  ERROR = 'error',\n}\n\ntype Message = string\n\nclass Logger {\n  private level: LogLevel\n\n  constructor(level: LogLevel = LogLevel.DEBUG) {\n    this.level = level\n  }\n\n  private log(level: LogLevel, message: Message) {\n    if (this.level === LogLevel.DEBUG || level !== LogLevel.DEBUG) {\n      const label = level.toUpperCase()\n\n      console.log(`[${label}] ${message}`)\n    }\n  }\n\n  /**\n   * - 개발 혹은 테스트 단계\n   * - 운영 환경에서는 남기고 싶지 않은 로그 메세지\n   */\n  public debug(message: Message) {\n    this.log(LogLevel.DEBUG, message)\n  }\n\n  /**\n   * - 정상 작동에 대한 정보\n   * - 시스템을 파악하는데 유익한 정보\n   */\n  public info(message: Message) {\n    this.log(LogLevel.INFO, message)\n  }\n\n  /**\n   * - 잠재적으로 문제가 될 수 있는 상황\n   * - 언제든 발생할 수 있는 일반적인 문제 상황\n   * - 사용자에게 노출되는 메세지에 상세한 가이드가 필요\n   */\n  public warn(message: Message) {\n    this.log(LogLevel.WARN, message)\n  }\n\n  /**\n   * - 심각한 오류나 예외 상황\n   * - 즉시 조치가 필요할때\n   */\n  public error(message: Message) {\n    this.log(LogLevel.ERROR, message)\n  }\n}\n```\n\n---\n\n[1. 효율적으로 로그 모니터링하기 - 로그 레벨 구분하기](https://jojoldu.tistory.com/712)"},{"i":"280","k":["zod"],"c":"2023-06-14","m":"2024-03-22","b":"```ts\nimport z from 'zod'\n\nconst envSchema = z.object({\n  REACT_APP_FEATURE_VAC_ASK: z.string(),\n  REACT_APP_FEATURE_RECORDS: z.string(),\n  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),\n})\n\nconst windowSchema = z.object({\n   SOMETHING_COOL: z.string()\n})\n\nexport const ENV = envSchema.parse(process.env)\nexport const WINDOW = windowSchema.parse(window)\n```\n\n---\n\n- [Using Zod to safely read env variables by sergiodxa](https://sergiodxa.com/articles/using-zod-to-safely-read-env-variables)\n- [GitHub - lostfictions/znv: Type-safe environment parsing and validation for Node.js with Zod schemas](https://github.com/lostfictions/znv)\n- https://twitter.com/mattpocockuk/status/1615110808219918352?s=20"},{"i":"279","k":["css","ui"],"c":"2023-06-13","m":"2024-03-22","b":"```ts\ntype Props = {\n  popover: 'auto' | 'manual'\n  popovertarget: string\n  popovertargetaction: 'hide' | 'show' | 'toggle'\n}\n\ntype State = {\n  hasBackdrop: boolean\n  isPopoverOpen: boolean\n}\n\ntype Methods = {\n  hidePopover: () => void\n  showPopover: () => void\n  togglePopover: () => void\n}\n\ntype Events = {\n  beforetoggle: () => void\n  toggle: () => void\n}\n```\n\n---\n\n- https://developer.chrome.com/blog/introducing-popover-api/\n- https://developer.mozilla.org/en-US/docs/Web/API/Popover_API"},{"i":"278","k":["zod","error"],"c":"2023-06-13","m":"2026-08-02","b":"zodios는 `ZodError`를 `ZodiosError.cause`에 담아 던진다 — `instanceof z.ZodError`로는 안 잡힌다.\n\n```ts\nif (err instanceof ZodiosError && err.cause instanceof ZodError) {\n  console.log(fromZodError(err.cause).toString())\n}\n```\n\n- [isErrorFromAlias not finding errors when uri have undefined params · Issue #402 · ecyrbe/zodios](https://github.com/ecyrbe/zodios/issues/402)\n- https://www.jacobparis.com/content/type-safe-env"},{"i":"277","k":["openapi","zod"],"c":"2023-06-09","m":"2026-08-02","b":"Swagger 2.0 스펙은 바로 못 먹인다. OpenAPI 3으로 바꾼 다음 zod 클라이언트를 만든다.\n\n```json\n{\n  \"scripts\": {\n    \"convert\": \"swagger2openapi ./spec.json -o ./spec.yaml\",\n    \"zod\": \"openapi-zod-client -a \\\"./spec.yaml\\\" -o \\\"./spec.ts\\\"\"\n  }\n}\n```"},{"i":"276","k":["datadog","sourcemaps"],"c":"2023-06-09","m":"2026-08-02","b":"올린 뒤 배포물에서 지운다 — Datadog은 이미 받았고 브라우저에는 노출되지 않는다.\n\n```sh\nVERSION=$(git log --pretty=format:'%h' -n 1)\n\nyarn datadog-ci sourcemaps upload ./build \\\n  --service \"$SERVICE\" \\\n  --minified-path-prefix \"$MINIFIED_PATH_PREFIX\" \\\n  --release-version \"$VERSION\"\n\nrm ./build/static/js/*.map\n```"},{"i":"275","k":["javascript","typescript","ts-pattern"],"c":"2023-05-29","m":"2024-03-22","b":"```ts\nimport { match } from 'ts-pattern'\n\ntype Format = 'webp' | 'jpg'\n\ntype Params = {\n  id: string\n  quality: keyof typeof QUALITY_MAP\n  format: Format\n}\n\nconst QUALITY_MAP = {\n  player_background: '0',\n  video_frames_start: '1',\n  video_frames_middle: '2',\n  video_frames_end: '3',\n  lowest_quality: 'default',\n  medium_quality: 'mqdefault',\n  high_quality: 'hqdefault',\n  standard_quality: 'sddefault',\n  unscaled_resolution: 'maxresdefault',\n}\n\nconst BASE_URL = 'https://i.ytimg.com'\n\nconst VI = (format: Format) =>\n  match(format)\n    .with('jpg', () => 'vi')\n    .otherwise(() => ['vi', format].join('_'))\n\nexport function getThumbnail({ id, quality, format }: Params) {\n  return [BASE_URL, VI(format), id, QUALITY_MAP[quality]]\n    .join('/')\n    .concat(`.${format}`)\n}\n```\n\n---\n\n- https://christianheilmann.com/2023/01/11/getting-all-the-video-thumbnails-from-a-youtube-video/"},{"i":"274","k":["terms","버스-팩터"],"c":"2023-05-28","m":"2026-07-27","b":"버스 팩터 — 한꺼번에 빠지면 프로젝트가 멈추는 최소 인원 수. 1이면 그 사람이 곧 단일 장애점.\n\n- [Bus factor - Wikipedia](https://en.wikipedia.org/wiki/Bus_factor)"},{"i":"272","k":["git","stacking","sapling"],"c":"2023-05-20","m":"2026-07-27","b":"스택 = 서로 의존하는 PR의 연쇄. 기능 브랜치는 main에서 분기하지만 스택은 부모 PR에서 분기한다 — 그래서 검토를 기다리는 동안 다음 조각을 이어서 작업할 수 있다.\n\n병합은 스택 아래부터. 하나가 병합되면 그 위 PR의 base를 갱신해야 한다. git 기본 기능만으로도 되지만 리베이스가 계속 따라붙어서, Graphite·Sapling 같은 도구가 그 부분을 자동화한다.\n\n- [Stacking | Graphite](https://graphite.dev/stacking)\n- [코드 리뷰의 또 다른 접근 방법: Pull Requests vs. Stacked Changes | 인프콘 2022 - YouTube](https://youtu.be/XRZPkYnWa48)\n- [Sapling](https://sapling-scm.com/)\n- [The stacking workflow](https://stacking.dev/)\n- [Stacked Diffs(Stacked PR)](https://sungjk.github.io/2024/03/17/stacked-diffs.html)"},{"i":"271","k":["json","diff","deep-object-diff","json-diff","microdiff"],"c":"2023-05-18","m":"2026-07-27","b":"- [deep-object-diff](https://github.com/mattphillips/deep-object-diff)\n- [json-diff](https://github.com/andreyvit/json-diff)\n- [microdiff](https://github.com/AsyncBanana/microdiff)"},{"i":"270","k":["react","ui","confirm","react-confirm","react-confirm-alert"],"c":"2023-05-14","m":"2026-09-01","b":"```jsx\nconst root = createRoot(document.querySelector('#confirm-root'))\n\nroot.render(<Confirm />)\n```\n\nconfirm ui를 만들다보면 `window.confirm`을 호출하는 방식으로 사용하는게 가장 좋은 방법인데 (안그러면 `state`로 관리해야하고 결국 이건 무의미한 코드의 반복이다.)\n이걸 react로 구현하려면 결국 render를 사용해야함. *react-confirm*, *react-confirm-alert* 둘다 소스를 보면 비슷한 방식으로 접근한다.\n\n- https://github.com/haradakunihiko/react-confirm/tree/master\n- https://github.com/GA-MO/react-confirm-alert/tree/master"},{"i":"269","k":["ui","react","zoom"],"c":"2023-05-14","m":"2026-07-27","b":"모바일 웹뷰에서 특정 영역을 *zoom*할수 있게 해달라는 요청이 있어서 적용한 내역. 정확히 기억은 안나는데 *react-prismazoom*를 선택했다.\n\n---\n\n그 외 비슷한\n\n- [react-quick-pinch-zoom](https://github.com/retyui/react-quick-pinch-zoom)\n- [react-zoom-pan-pinch](https://prc5.github.io/react-zoom-pan-pinch)\n- [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom)\n- [react-image-zooom](https://mario-duarte.github.io/react-image-zooom)\n- [use-pan-and-zoom](https://github.com/wouterraateland/use-pan-and-zoom)"},{"i":"268","k":["performance","tachyon","prefetch","prerender"],"c":"2023-05-01","m":"2026-07-27","b":"prefetch는 내려받기만 하고, prerender는 내려받은 뒤 렌더링까지 시작한다.\n\nTachyon은 `<a>`에 커서가 50ms 이상 머물면 `<link rel=\"prerender\">`를 넣어주는 라이브러리.\n\n- https://fasterthanlight.net/\n- https://developer.chrome.com/blog/prerender-pages/\n- https://developer.mozilla.org/en-US/docs/Glossary/Prefetch\n- https://developer.mozilla.org/en-US/docs/Glossary/Prerender"},{"i":"267","k":["prompt","llm"],"c":"2023-05-01","m":"2026-07-27","b":"원칙 — 명확하고 구체적인 지시, 그리고 모델에게 생각할 시간 주기.\n\n전술 — 구분자로 입력 경계 표시(```, \"\"\", < >, `<tag> </tag>`, `:`), 구조화된 출력 요청(JSON·HTML), 조건 충족 여부를 모델에게 확인시키기, few-shot 으로 스타일 고정.\n\n---\n\nhttps://www.deeplearning.ai/short-courses/chatgpt-prompt-engineering-for-developers/"},{"i":"257","k":["web-api","ambient-light-sensor"],"c":"2023-03-24","m":"2024-03-22","b":"```js\nasync function main({ onActivate, onError, onReading, onDenied }) {\n  const result = await navigator.permissions.query({\n    name: 'ambient-light-sensor',\n  })\n\n  if (result.state === 'denied') {\n    onDenied()\n    return\n  }\n\n  const ambientLightSensor = new AmbientLightSensor({ frequency: 20 })\n  \n  ambientLightSensor.addEventListener('activate', onActivate)\n  ambientLightSensor.addEventListener('error', onError)\n  ambientLightSensor.addEventListener('reading', () => {\n    const ISO = 100\n    const C = 250\n\n    const EV = Math.round(Math.log2((ambientLightSensor.illuminance * ISO) / C))\n\n    onReading(EV)\n  })\n\n  ambientLightSensor.start()\n}\n```\n\n---\n\n- [AmbientLightSensor - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/AmbientLightSensor)\n- [Ambient Light Sensor](https://www.w3.org/TR/ambient-light/)\n- [Sensors for the web - Chrome Developers](https://developer.chrome.com/articles/generic-sensor/)[^257-1]\n- [Ambient Light Sensor | Can I use... Support tables for HTML5, CSS3, etc](https://caniuse.com/ambient-light)\n\n---\n\n[^257-1]: 주변광 센서 인터페이스가 포함된 일반 센서 API를 사용하는 방법을 설명"},{"i":"255","k":["typescript","jsx","parser"],"c":"2023-03-19","m":"2026-07-27","b":"```js\nconst { Parser } = require('acorn')\nconst JSXParser = Parser.extend(require('acorn-jsx')())\n\nconst isReactComponent = Boolean(\n  JSON.stringify(\n    JSXParser.parse(fileContent, {\n      sourceType: 'module',\n      ecmaVersion: 'latest',\n    })\n  ).includes('JSXIdentifier')\n)\n```\n\n---\n\n- https://github.com/vitejs/vite/discussions/3448\n- https://github.com/jyash97/cra-to-vite/blob/master/src/index.js#L40-L43"},{"i":"252","k":["javascript","immer","Map","Set"],"c":"2023-03-13","m":"2024-03-22","b":"Immer가 생성하는 맵과 세트는 인위적으로 불변으로 만들어집니다. 즉, 프로듀서 외부에서 세트, 클리어 등과 같은 변경 메서드를 시도할 때 예외(throw an exception)가 발생합니다.\n\n```js\ntest('Map and Set', () => {\n  const baseMap = new Map();\n\n  const nextBaseMap = create(baseMap, (draft) => {\n    draft.set('a', 1);\n  });\n\n  expect(nextBaseMap).toMatchInlineSnapshot(`\n    Map {\n      \"a\" => 1,\n    }\n  `);\n});\n```\n\n---\n\n- [Map and Set | Immer](https://immerjs.github.io/immer/map-set/)"},{"i":"249","k":["npm"],"c":"2023-02-27","m":"2024-03-22","b":"npm을 사용하여 패키지의 여러 버전을 설치하려면 npm 설치 명령 뒤에 패키지 이름과 설치하려는 버전 번호를 사용하면 됩니다. 이 명령을 다른 버전 번호로 반복하여 여러 버전의 패키지를 설치할 수 있습니다.\n\n```shell\nyarn add react-tooltip-5@npm:react-tooltip@5.8.3\n```\n\n```json\n{\n  \"react-tooltip-5\": \"npm:react-tooltip@5.8.3\"\n}\n```\n\n---\n\n- [how to install multiple versions of package using npm - Stack Overflow](https://stackoverflow.com/questions/26414587/how-to-install-multiple-versions-of-package-using-npm)\n- [How to install multiple versions of a npm package at the same time/project - DEV Community](https://dev.to/3imed_jaberi/how-to-install-multiple-versions-of-a-npm-package-at-the-same-time-project-2a29)"},{"i":"247","k":["wcag","ux"],"c":"2023-02-20","m":"2026-07-27","b":"- [Human Interface Guidelines - Human Interface Guidelines - Design - Apple Developer](https://developer.apple.com/design/human-interface-guidelines/)\n- [All WCAG 2.1 Techniques | WAI | W3C](https://www.w3.org/WAI/WCAG21/Techniques/)\n- [Close, Exit, Cancel: How To End User Interactions Well | CSS-Tricks](https://css-tricks.com/close-exit-cancel-how-to-end-user-interactions-well)"},{"i":"243","k":["font"],"c":"2022-12-29","m":"2026-08-19","b":"```js\nasync function loader() {\n  const family = 'Material Symbols Outlined'\n  const source =\n    'url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v75/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsLjBuVY.woff2)'\n  const fontFace = new FontFace(family, source)\n\n  document.fonts.add(fontFace)\n  await fontFace.load()\n}\n```\n\n---\n\n- 참고: [CSS Font Loading API](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Font_Loading_API)"},{"i":"238","k":["db"],"c":"2022-12-11","m":"2024-03-22","b":"- [Textdb](https://textdb.dev/)[^238-1]\n- [JavascriptDB](https://javascriptdb.com/docs)[^238-2]\n\n---\n\n[^238-1]: 일반 텍스트 파일을 데이터 소스로 사용하여 간단한 웹 애플리케이션을 구축하고 배포하기 위한 플랫폼. 백엔드 서버나 데이터베이스 없이 배포 가능. 텍스트 파일에서 데이터를 읽고 쓰고 검색하는 데 사용할 수 있는 간단한 REST API를 제공.\n[^238-2]: JavaScript 애플리케이션용 경량 데이터베이스 엔진. 브라우저에서 데이터를 저장, 쿼리 및 조작하기 위한 간단하고 직관적인 API를 제공하며 챗봇, 검색 엔진 및 게임을 포함한 다양한 유형의 애플리케이션을 구축하는 데 사용 가능. 이 데이터베이스는 오프라인에서 작동하도록 설계되어 PWA(Progressive Web Application) 및 기타 유형의 클라이언트측 애플리케이션을 구축하는 데 이상적."},{"i":"237","k":["ffmpeg"],"c":"2022-12-11","m":"2024-03-22","b":"```shell\n# 비디오 자르기\nffmpeg -i INPUT.mp4 -ss 00:00:00 -to 00:01:00 -c:v copy -c:a copy OUTPUT.mp4\n```\n\n---\n\n- [Cutting the videos based on start and end time using ffmpeg - Stack Overflow](https://stackoverflow.com/questions/18444194/cutting-the-videos-based-on-start-and-end-time-using-ffmpeg)"},{"i":"235","k":["shell","node","env"],"c":"2022-12-11","m":"2026-07-27","b":"```sh\n# 빌드 시 timestamp를 참조하기 위한 방법\ntimestamp=$(date +%s) # 1670759329\n```\n\n```sh\n# 하지만 현실에서는 `TURBO_HASH`값을 참조하는 방식을 `.env`에서 사용\nREACT_APP_VERSION=1.0.0-${TURBO_HASH}\n```\n\n---\n\n- [Create timestamp variable in bash script - Stack Overflow](https://stackoverflow.com/questions/17066250/create-timestamp-variable-in-bash-script)[^235-1]\n- [dotenv-expand](https://github.com/motdotla/dotenv-expand#expand)[^235-2]\n\n---\n\n[^235-1]: 스크립트에서 현재 타임스탬프를 특정 형식의 변수로 만드는 방법에 대한 질문. 특정 형식 문자열과 함께 날짜 명령을 사용하여 타임스탬프를 만들고 변수에 저장하도록 하는 답변들...\n[^235-2]: dotenv-expand는 `.env` 파일에서 환경 변수를 확장하기 위한 모듈입니다. 중첩 변수 확장 및 셸 명령 실행과 같은 더 많은 고급 기능을 사용 가능."},{"i":"234","k":["node","webpack"],"c":"2022-12-11","m":"2024-03-22","b":"```bash\n# `System limit for number of file watchers reached`\n\necho fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p\n```\n\n---\n\n- [Watch and WatchOptions | webpack](https://webpack.js.org/configuration/watch/#not-enough-watchers)"},{"i":"233","k":["ui","design-system","headless"],"c":"2022-12-05","m":"2026-07-27","b":"- 공통 컴포넌트\n  - `Tabs` 만들면서 이건 어렵다는 결론\n- 대안\n  - [react-spectrum](https://react-spectrum.adobe.com/react-spectrum/index.html)\n    - [Consider upgrading to a component-based API](https://github.com/adobe/react-spectrum/discussions/2368)\n    - [react-aria-components](https://github.com/devongovett/react-aria-components)[^233-1]\n  - [radix-ui](https://www.radix-ui.com/)\n    - [shadcn-ui](https://ui.shadcn.com/)\n    - [Origin UI](https://originui.com/)\n    - [Building Low Level Components the Radix Way](https://alexkondov.com/building-low-level-components-the-radix-way/)\n  - [Headless UI](https://headlessui.com/)\n  - [Reach UI](https://reach.tech/)\n  - [Ariakit](https://ariakit.org/)\n  - [Base UI](https://base-ui.com/)\n  - [JollyUI](https://www.jollyui.dev/)\n\n---\n\n> 좋은 시스템일수록 실제 서비스에 적용하기는 더 어렵다는 문제가 발생. 결국 필요한건 _headless_ 인데 생각보다 많지 않고 어떤 선택이 좋은 선택인가에 대한 조심스러운 부분이 있다.\n\n---\n\n[^233-1]: [참고](https://reactspectrum.blob.core.windows.net/reactspectrum/cee46dbc35a111b8e4ba2da28da542783de00a44/docs/react-aria/Button.html)"},{"i":"232","k":["editor","input"],"c":"2022-12-05","m":"2026-07-27","b":"- [react-mentions](https://www.npmjs.com/package/react-mentions)[^232-1]\n- [tribute](https://github.com/zurb/tribute)[^232-2]\n- [react-input-trigger](https://github.com/abinavseelan/react-input-trigger)[^232-3]\n- [react-contenteditable](https://github.com/lovasoa/react-contenteditable)[^232-4]\n- [draft-js](https://github.com/facebook/draft-js)[^232-5]\n\n---\n\n[^232-1]: `textarea`에서 특정 키입력이 발생할 경우 액션(멘션)을 발생시키는 기능\n[^232-2]: 옜날에는 어떻게 한거지? 궁금해서 찾아봤습니다\n[^232-3]: 입력필드에서 문자 트리거\n[^232-4]: [contenteditable](https://developer.mozilla.org/ko/docs/Web/HTML/Global_attributes/contenteditable) 컴포넌트\n[^232-5]: 의식의 흐름 속 갑자기 생각나버림"},{"i":"231","k":["monorepo"],"c":"2022-11-28","m":"2024-03-22","b":"```yaml\nversion: 1\napplications:\n  - frontend:\n      phases:\n        preBuild:\n          commands:\n            # AWS Amplify 에서 모노레포 구조를 사용 할 경우 root 레벨로 올라가서 install\n            - cd ../../\n            - echo \"$PWD\"\n            - yarn install\n        build:\n          commands:\n            - echo \"$PWD\"\n            # 현재 root로 이동한 상태이므로 $AMPLIFY_MONOREPO_APP_ROOT를 바로 참조하도록 설정\n            - if [ $NODE_ENV_VARIABLES = \".env.development\" ]; then cat \"./$AMPLIFY_MONOREPO_APP_ROOT/$NODE_ENV_VARIABLES\" > \"./$AMPLIFY_MONOREPO_APP_ROOT/.env.production\"; fi\n            - yarn run \"build:$AMPLIFY_MONOREPO_APP\"\n      artifacts:\n        baseDirectory: build\n        files:\n          - '**/*'\n      cache:\n        paths:\n          - node_modules/**/*\n    appRoot: apps/app\n```\n\n---\n\n- [Monorepo build settings - AWS Amplify Hosting](https://docs.aws.amazon.com/amplify/latest/userguide/monorepo-configuration.html)"},{"i":"229","k":["create-react-app","node"],"c":"2022-11-21","m":"2024-03-22","b":"```sh\n# .env\nREACT_APP_VERSION=0.0.0-${TURBO_HASH}\n```\n\nREACT_APP_VERSION을 자동으로 입력할 방법을 찾다가 결국 hash값을 추가했다.\n\n---\n\n- [Adding Custom Environment Variables | Create React App](https://create-react-app.dev/docs/adding-custom-environment-variables/#expanding-environment-variables-in-env)\n- [GitHub - motdotla/dotenv-expand: Variable expansion for dotenv. Expand variables already on your machine for use in your .env file.](https://github.com/motdotla/dotenv-expand)\n- [Create timestamp variable in bash script - Stack Overflow](https://stackoverflow.com/questions/17066250/create-timestamp-variable-in-bash-script)"},{"i":"228","k":["create-react-app","deprecated"],"c":"2022-11-14","m":"2024-03-22","b":"```sh\nyarn add -D react-app-rewired customize-cra\n```\n\n```json\n// package.json\n\"scripts\": {\n  \"start\": \"react-app-rewired start\",\n  \"build\": \"react-app-rewired build\",\n  \"test\": \"react-app-rewired test\"\n},\n```\n\n```json\n// .babelrc\n{\n  \"plugins\": [\n    [\"@babel/plugin-proposal-decorators\", { \"version\": \"legacy\" }],\n    [\"@babel/plugin-proposal-class-properties\", { \"loose\": true }]\n  ]\n}\n```\n\n```js\n// config-overrides.js\nconst { useBabelRc, override } = require('customize-cra')\n\nmodule.exports = override(useBabelRc())\n```\n\n---\n\n- [Simplest Way to Install Babel Plugins in Create React App - DEV Community 👩‍💻👨‍💻](https://dev.to/ansonh/simplest-way-to-install-babel-plugins-in-create-react-app-7i5)\n\n---\n\n> [CRACO](https://craco.js.org/)로 구성하는게 지금은 맞는 것 같다."},{"i":"227","k":["pagination"],"c":"2022-11-07","m":"2024-03-22","b":"```ts\ninterface PaginationProps {\n  /** 현재 페이지 번호 */\n  currentPage: number;\n  /** 전체 페이지 수 */\n  totalPages: number;\n  /** 페이지 변경 시 호출되는 콜백 함수 */\n  onPageChange: (pageNumber: number) => void;\n  /** 이전/다음 페이지 링크 표시 여부 (선택적) */\n  showPreviousNext?: boolean;\n  /** 페이지 번호 링크 표시 여부 (선택적) */\n  showPageNumbers?: boolean;\n  /** 페이지당 항목 수 (선택적) */\n  pageSize?: number;\n}\n```\n\n- [cbcruk/react-flat-pagination](https://github.com/cbcruk/react-flat-pagination)\n- [mayankshubham/react-pagination](https://github.com/mayankshubham/react-pagination)\n- [react-component/pagination](https://github.com/react-component/pagination)\n- [wwwaiser/react-js-pagination](https://github.com/wwwaiser/react-js-pagination)\n- [material-ui/react-pagination/](https://mui.com/material-ui/react-pagination/)\n- [Pagination - NuxtLabs UI](https://ui.nuxtlabs.com/navigation/pagination)\n- [AdeleD/react-paginate](https://github.com/AdeleD/react-paginate)\n  - [How to Implement Pagination in React](https://hygraph.com/blog/react-pagination)\n\n---\n\n> 하지만 너무 많은 기능이 필요없기 때문에 최대한 단순한 컴포넌트를 만들었다."},{"i":"223","k":["mobx","async","action"],"c":"2022-10-16","m":"2024-03-22","b":"MobX 비동기 action 처리 - `action`은 현재 스택에만 적용됨. await/then 이후 상태 변경은 별도 action 필요.\n\n```javascript\n// ❌ then 콜백은 action 범위 밖\n@action fetchProjects() {\n  fetchSomething().then(projects => {\n    this.data = projects  // 에러!\n  })\n}\n\n// ✅ 방법 1: runInAction\n@action async fetchProjects() {\n  const data = await fetchSomething()\n  runInAction(() => {\n    this.data = data\n  })\n}\n\n// ✅ 방법 2: flow (권장) - await 대신 yield\nfetchProjects = flow(function* () {\n  this.state = \"pending\"\n  try {\n    this.data = yield fetchSomething()  // 자동으로 action 래핑\n    this.state = \"done\"\n  } catch (e) {\n    this.state = \"error\"\n  }\n})\n```\n\n> [!TIP]\n> `flow`는 async/await와 동일하게 작동하면서 수동 action 래핑 불필요. 취소도 가능 (`cancel()`).\n\n- [MobX Actions 문서](https://github.com/mobxjs/mobx/blob/mobx4and5/docs/best/actions.md)"},{"i":"222","k":[],"c":"2022-10-16","m":"2024-03-22","b":"- [Choose Boring Technology](https://boringtechnology.club/)[^222-1][^222-2]\n\n```md\n- 프로젝트에 새롭고 반짝이는 기술보다는 지루한 기술을 선택하는 것이 좋습니다.\n- 흥미로운 기술은 프로젝트에 불필요한 위험을 초래할 수 있습니다.\n- 이미 확립되고 입증된 기술이 성공적인 결과로 이어질 가능성이 더 높습니다.\n- 신기술을 둘러싼 과대 광고는 오해의 소지가 있습니다.\n- 신기술의 수명을 예측하기는 어렵습니다.\n- 지루한 기술은 화려하지 않을 수 있지만 안정적이고 신뢰할 수 있으며 예측 가능합니다.\n- 최신 기술 유행보다는 비즈니스 문제 해결에 집중하세요.\n- 사람과 프로세스에 투자하면 더 나은 결과를 얻을 수 있습니다.\n- 지루한 기술을 선택하는 것은 보수적이거나 위험을 피하기 위한 것이 아니라 프로젝트의 장기적인 성공을 우선시하는 정보에 입각한 결정을 내리기 위한 것입니다.\n```\n\n---\n\n[^222-1]: [Dan McKinley :: Choose Boring Technology](https://mcfunley.com/choose-boring-technology)\n[^222-2]: 항상 최신의 최첨단 솔루션을 쫓기보다는 확립되고 신뢰할 수 있는 기술을 사용하는 것을 지지하는 글. 특히 장기 프로젝트의 경우 기술 선택에서 안정성과 예측 가능성의 중요성을 강조."},{"i":"221","k":["code-review"],"c":"2022-10-16","m":"2026-07-27","b":"- [피드백 사다리: Netlify에서 코드 리뷰를 인코딩하는 방법](https://www.netlify.com/blog/2020/03/05/feedback-ladders-how-we-encode-code-reviews-at-netlify/)[^221-2]\n- [기본적으로 코드 리뷰 없음](https://www.raycast.com/blog/no-code-reviews-by-default/)[^221-3]\n- [Post-Commit Reviews](https://copyconstruct.medium.com/post-commit-reviews-b4cc2163ac7a)\n\n---\n\n[^221-2]: Feedback Ladder: ⛰ 산(Mountain) / 차단 및 즉각적인 조치 필요, 🧗‍♀️ 볼더(Boulder) / 블로킹, ⚪️ 자갈(Pebble) / 비차단하지만 향후 조치가 필요함, ⏳ 모래(Sand) / 논블로킹이지만 향후 고려 필요, 🌫 먼지(Dust) / 비차단, \"받거나 놔두세요\"\n[^221-3]: 다른 사람들의 상황이 자신에게 적용되는지 자문해볼 필요가 있다."},{"i":"218","k":["bdd","test"],"c":"2022-10-16","m":"2024-03-22","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n<QuoteLink url=\"https://school.cucumber.io/courses/take/bdd-with-cucumber-javascript\">\n  BDD with Cucumber\n</QuoteLink>\n\nBDD는 무엇을 의미합니까?\n\n> BDD는 시스템의 원하는 동작을 협력적으로 지정하는 접근 방식입니다. 행동의 일부가 합의될 때마다 우리는 그 행동을 구현할 코드의 개발을 \"추진\"하기 위해 해당 사양을 사용합니다.\n\nBDD의 세 가지 관행은 무엇이며 스토리에 어떤 순서로 적용합니까?\n\n> 우리는 스토리에 필요한 행동의 범위를 공동으로 발견 하는 것으로 시작합니다. 일단 우리가 행동에 동의하면 우리는 비즈니스가 읽을 수 있는 언어로 사양을 공식화 합니다. 마지막으로 공식화된 사양을 자동화 하여 시스템이 실제로 예상대로 작동하는지 확인합니다.\n\nCucumber와 BDD는 어떤 관련이 있습니까?\n\n> Cucumber는 문서를 이해하고 자동화된 테스트로 변환하는 도구입니다.\n>\n> BDD는 세 가지 방식으로 구성된 협업적 접근 방식입니다. BDD 실무자는 Cucumber를 사용하여 문서를 자동화할 수 있습니다.\n\n\"살아있는 문서\"의 특별한 점은 무엇입니까?\n\n> 문서가 애플리케이션의 동작과 동기화되지 않을 때 자동으로 알려 주기 때문에 \"살아있는 문서\"라고 부릅니다. 그것이 특별한 점입니다.\n>\n> 완료에 대한 정의의 일부로 이를 검토할 수 있지만 자동으로 유효성이 검사되지 않더라도 작성한 모든 사양 문서에 대해서도 마찬가지입니다.\n>\n> 그것은 자동화된 테스트에 의해 생성 되지 않습니다 - 여전히 작성해야 합니다! 자동화된 테스트는 귀하가 작성한 내용이 사실인지 아닌지를 알려줍니다.\n>\n> 이를 위한 변경 제어 프로세스가 있을 수 있습니다. 설명하는 코드와 함께 소스 제어에 유지하는 것이 좋습니다. 그러나 다시 말해서 특별한 것은 아닙니다. Word 문서에 대해 놀랍도록 투명한 변경 제어 프로세스를 가질 수 있지만 여전히 완전히 구식이고 잘못된 것일 수 있습니다."},{"i":"214","k":["image"],"c":"2022-10-16","m":"2024-03-22","b":"- [OG Image Generation – Vercel Docs](https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation)\n  - [GitHub - vercel/satori: Enlightened library to convert HTML and CSS to SVG](https://github.com/vercel/satori#documentation)\n  - [Vercel OG Image Playground](https://og-playground.vercel.app/)\n\n---\n\n[커스텀 폰트](https://vercel.com/docs/concepts/functions/edge-functions/og-image-examples#using-a-custom-font) 사용 시, 배포 중 **Provided Edge Function is too large** 에러가 발생할 경우 로컬 파일로 사용하지말고 `fetch`로 해당 폰트를 가져온다.[^214-1][^214-2]\n\n---\n\n[^214-1]: https://github.com/cbcruk/oz/commit/0cb90b558cca8bf97bfcc0b36914447845131ab7#diff-ea54c769db531913b015e15682e5b3f4364de740cc7d6c1cb2c8ee2a9ce77379L9-R14\n[^214-2]: https://vercel.com/docs/concepts/functions/edge-functions/limitations#code-size-limit"},{"i":"213","k":["package","shell","npm","node"],"c":"2022-10-12","m":"2024-03-22","b":"```json\n// 현재 실행된 stage 값을 참조하기\n// 하지만 스크립트를 따로 작성하는게 더 효율적으로 보인다.\n\n{\n  \"scripts\": {\n    \"dev:hello\": \"echo ${npm_lifecycle_event//dev:/}\"\n  }\n}\n```\n\n---\n\n- [yarnpkg - Is there a way to get the name of the npm script passed to the command specified by that script? - Stack Overflow](https://stackoverflow.com/questions/62865856/is-there-a-way-to-get-the-name-of-the-npm-script-passed-to-the-command-specified)[^213-1]\n- [scripts | npm Docs](https://docs.npmjs.com/cli/v8/using-npm/scripts)[^213-2]\n\n---\n\n[^213-1]: npm 스크립트의 이름을 얻는 방법에 대한 질문과 `npm_lifecycle_event` 환경 변수를 사용하는 방법으로 제안\n[^213-2]: package.json 파일에서 스크립트를 사용하는 방법에 대한 개요, 기본 제공 환경 변수 목록도 포함"},{"i":"210","k":["npm","node"],"c":"2022-09-29","m":"2024-03-22","b":"npm 레지스트리에서 특정 패키지에 대한 메타데이터 및 기타 정보를 볼 수 있습니다. 이 명령으로 패키지의 최신 버전, 패키지의 종속성, 작성자 및 라이선스 정보, 기타 세부 정보를 확인할 수 있습니다.\n\n```shell\n# 이전 버전 리스트를 확인하고 싶을때\nnpm view cowsay versions\n\n# 각 버전이 게시된 시간을 확인\nnpm view cowsay time\n```\n\n---\n\n- [npm-view | npm Docs](https://docs.npmjs.com/cli/v6/commands/npm-view)\n- [Find the version of an installed npm package | Sentry](https://sentry.io/answers/version-of-installed-npm-package/)[^210-1]\n\n---\n\n[^210-1]: 설치된 npm 패키지의 버전을 확인하는 방법에 대한 가이드"},{"i":"209","k":["aws"],"c":"2022-09-26","m":"2024-03-22","b":"[Clear CloudFront Cache with AWS CLI | bobbyhadz](https://bobbyhadz.com/blog/aws-cli-clear-cloudfront-cache#clearing-cloudfront-cache-for-the-entire-distribution)\n\n```js\n/**\n * CloudFront 배포에 대한 캐시를 무효화하려면 경로와 함께 명령을 실행합니다\n * \n * @param {object} params\n * @param {string} params.distributionId\n * @param {string} params.paths\n * @returns {CreateInvalidationResult}\n */\nfunction createInvalidation({\n  distributionId,\n  paths = `\"/*\"`\n}) {\n  const result = $`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`\n  return result\n}\n```"},{"i":"208","k":["aws"],"c":"2022-09-25","m":"2024-03-22","b":"Lambda@Edge (Node) 함수 작성 시 npm module을 사용하는 방법. **esbuild**를 사용하면 편하다.\n\n```json\n{\n  \"scripts\": {\n    \"build\": \"esbuild --bundle --minify --platform=node --target=node12 --outdir=build main.js\",\n    \"export\": \"cd build && zip main.js.zip main.js\"\n  }\n}\n```\n\n---\n\n- [amazon web services - How to load npm modules in AWS Lambda? - Stack Overflow](https://stackoverflow.com/questions/34437900/how-to-load-npm-modules-in-aws-lambda)\n- [함수를 사용하여 엣지에서 사용자 지정 - Amazon CloudFront](https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html)"},{"i":"207","k":["extensions"],"c":"2022-09-24","m":"2024-03-22","b":"- [Check whether user has a Chrome extension installed](https://stackoverflow.com/questions/6293498/check-whether-user-has-a-chrome-extension-installed) 특정 확장도구가 설치되어 있는지 판별하는 방법\n\n```sh\n# 크롬 익스텐션이 설치되는 경로는 다음과 같다\n/Users/USERNAME/Library/Application Support/Google/Chrome/Default/Extensions\n```\n\n```js\nfunction isInstalled(extensionId) {\n  return new Promise((resolve, reject) => {\n    const img = new Image()\n\n    img.src = `chrome-extension://${extensionId}/icon-128.png` // 해당 리소스가 `web_accessible_resources`에 선언되어 있는지 확인이 필요하다.\n    img.onload = () => {\n      resolve(true)\n    }\n    img.onerror = () => {\n      reject()\n    }\n  })\n}\n```"},{"i":"203","k":["react"],"c":"2022-09-23","m":"2024-03-22","b":"- [React + Webpack: ChunkLoadError: Loading chunk X failed. | by Raphaël Léger | Medium](https://raphael-leger.medium.com/react-webpack-chunkloaderror-loading-chunk-x-failed-ac385bd110e0)\n- [How to fix ChunkLoadError in your ReactJS application - Codemzy's Blog](https://www.codemzy.com/blog/fix-chunkloaderror-react)\n\n---\n\n`lazy`로 `import`했을 경우 *ChunkLoadError*가 발생하는데 이럴 경우 어떻게 대응할 수 있는지 정리해둔 글들."},{"i":"202","k":["git"],"c":"2022-11-03","m":"2024-03-22","b":"```sh\ngit remote add {alias} {url}\ngit fetch {alias}\ngit merge --allow-unrelated-histories {alias}/{branch}\n```\n\n---\n\n- [[git] 서로 다른 repository 합치기 — Falcon](https://m-falcon.tistory.com/522)\n- [How do you merge two Git repositories? - Stack Overflow](https://stackoverflow.com/questions/1425892/how-do-you-merge-two-git-repositories)\n- [How to merge git repositories preserving git history - JDriven Blog](https://blog.jdriven.com/2021/04/how-to-merge-multiple-git-repositories/)\n- [Git - git-mv Documentation](https://git-scm.com/docs/git-mv)\n- [Merging two GitHub repositories without losing commit history - Mozilla Hacks - the Web developer blog](https://hacks.mozilla.org/2022/08/merging-two-github-repositories-without-losing-commit-history/)"},{"i":"200","k":["react"],"c":"2022-09-23","m":"2024-03-22","b":"```js\n// cannot be used as a JSX component\n```\n\n`@types/react`, `@types/react-dom`에 대한 참조가 잘못되면서 발생하는 이슈\n\n---\n\n- [【React, TypeScript】「cannot be used as a JSX component...」エラー - Qiita](https://qiita.com/ayaka105/items/2cee20f99bf45aadffda)\n- [javascript - 'Component' cannot be used as a JSX component. Nextjs - Stack Overflow](https://stackoverflow.com/questions/71974065/component-cannot-be-used-as-a-jsx-component-nextjs)\n- [Component cannot be used as a JSX component in React | bobbyhadz](https://bobbyhadz.com/blog/react-cannot-be-used-as-a-jsx-component)"},{"i":"199","k":["storybook","chromatic"],"c":"2022-09-23","m":"2024-03-22","b":"[chromatic](https://www.chromatic.com/)\n\n- [Chromatic Storybook - Secure you project token in Next.js](https://www.sabbaticaldev.co.uk/post/chromatic-storybook)[^199-1]\n\n---\n\n[^199-1]: cli를 사용할 경우 `CHROMATIC_PROJECT_TOKEN`을 `.env`에서 관리하면 된다"},{"i":"197","k":["vite"],"c":"2022-09-19","m":"2024-03-22","b":"- [https://twitter.com/youyuxi/status/1362050255009816577](https://twitter.com/youyuxi/status/1362050255009816577)[^197-1]\n- [How to use `.js` instead of `.jsx`](https://github.com/vitejs/vite/discussions/3448)[^197-2]\n  - [How do you move multiple files in git? - Stack Overflow](https://stackoverflow.com/questions/2212857/how-do-you-move-multiple-files-in-git)\n\n```shell\n# 아마도 git을 사용하고 있을테니까 `git mv`를 사용해서 변경해주자\nfind src -type f | grep \"\\.[jt]s$\" | xargs -n1 grep -HE \"^[^*\\n]*(<\\/?[a-zA-Z]*>)[\\s\\w]*$\" | cut -d: -f1 | uniq | awk '{print \"git mv \"$1\" \"$1\"x\"}' | sh\n```\n\n---\n\n[^197-1]: Vite가 JSX 처리를 위해 .jsx 확장자를 요구하는 이유는 대부분의 경우 일반 .js 파일이 브라우저에서 작동하기 위해 전체 AST 변환이 필요하지 않아야 하기 때문입니다. .js 파일에서 JSX를 허용한다는 것은 제공되는 모든 파일이 JSX를 포함하는 경우에 대비하여 전체 AST 처리되어야 함을 의미합니다.\n[^197-2]: [`.jsx`](https://github.com/vitejs/vite/discussions/3448#discussioncomment-1859773) 또는 [`.js`](https://github.com/vitejs/vite/discussions/3448#discussioncomment-749919)를 사용해야할 경우에 대한 답변들이 소개되어 있다."},{"i":"193","k":["react","hack","dangerously-set-inner-html"],"c":"2022-09-18","m":"2024-03-22","b":"```jsx\n<div\n  dangerouslySetInnerHTML={{\n    __html: `\n      <img src=\"http://unsplash.it/100/100?random\" onload=\"console.log('you got hacked');\" />\n    `,\n  }}\n/>\n```\n\n가끔 아주 가끔 이상한 일을 해야할때가 있는데 그럴때는 이렇게 하면 된다.\n\n---\n\n- [How to inject scripts using dangerouslysetinnerhtml on Client-Side Rendering? | by SunCommander | Medium](https://suncommander.medium.com/how-to-inject-scripts-using-dangerouslysetinnerhtml-on-client-side-rendering-973037cc06b7)"},{"i":"191","k":["javascript","bug"],"c":"2022-09-18","m":"2024-03-22","b":"```js\nconst obj = {\n  a: 1,\n  b: 2,\n}\n\nconsole.log(obj[['a']]) // 1\nconsole.log(obj[['b']]) // 2\n```\n\n이게 되네 🤔"},{"i":"189","k":["git"],"c":"2022-06-18","m":"2024-03-22","b":"```sh\n# .gitignore가 규칙이 적용되지 않을때\ngit rm -rf --cached .\ngit add .\n```\n\n---\n\n- https://stackoverflow.com/questions/25436312/gitignore-not-working"},{"i":"187","k":["blob"],"c":"2022-05-29","m":"2024-03-22","b":"[blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#parameters)\n\n```js\nnew Blob(array, { type : 'text/html' })\n```\n\n파일 다운로드 생성이 잘 안되서 확인해보니 `type` 지정을 안했다."},{"i":"183","k":["photoshop"],"c":"2022-04-09","m":"2024-03-22","b":"[GitHub - meltingice/psd.js: A Photoshop PSD file parser for NodeJS and browsers](https://github.com/meltingice/psd.js)\n\npsd 파서. 이를 통해 관리 가능한 트리 구조에서 Photoshop 문서로 작업하고 다음과 같은 중요한 데이터를 찾을 수 있습니다.\n\n- 문서 구조\n- 문서 크기\n- 레이어/폴더 크기 + 위치 지정\n- 레이어/폴더 이름\n- 레이어/폴더 가시성 및 불투명도\n- 글꼴 데이터( psd-enginedata 를 통해 )\n  - 텍스트 영역 내용\n  - 글꼴 이름, 크기 및 색상\n- 색상 모드 및 비트 심도\n- 벡터 마스크 데이터\n- 병합된 이미지 데이터\n- 레이어 구성 요소"},{"i":"173","k":["font"],"c":"2022-04-09","m":"2026-07-27","b":"- [A Variable Fonts Primer](https://variablefonts.io/)\n- [Google Fonts](https://fonts.google.com/specimen/Hahmlet?vfonly=true&subset=korean#standard-styles)\n- [Getting the Most Out of Variable Fonts on Google Fonts | CSS-Tricks](https://css-tricks.com/getting-the-most-out-of-variable-fonts-on-google-fonts/)\n- [Variable Fonts for Developers](https://variablefonts.dev/)\n- [Using CSS Custom Properties to Adjust Variable Font Weights in Dark Mode | CSS-Tricks](https://css-tricks.com/using-css-custom-properties-to-adjust-variable-font-weights-in-dark-mode/)[^173-1]\n\n---\n\n[^173-1]: 다크모드에서 weight가 시각적으로 차이가 날수 있기때문에 그럴경우 변수로 분리해서 대응하는 방법"},{"i":"172","k":["font"],"c":"2022-04-09","m":"2024-03-22","b":"- [5 steps to faster web fonts /// Iain Bean](https://iainbean.com/posts/2021/5-steps-to-faster-web-fonts/)\n- [How to Stop Lighthouse Complaining About Render Blocking Google Fonts | Codeboosh](https://codeboosh.com/how-to-stop-lighthouse-complaining-about-render-blocking-google-fonts/)\n- [A Comprehensive Guide to Font Loading Strategies—zachleat.com](https://www.zachleat.com/web/comprehensive-webfonts/)\n- [How to avoid layout shifts caused by web fonts – Simon Hearne](https://simonhearne.com/2021/layout-shifts-webfonts/)\n- [The Fastest Google Fonts – CSS Wizardry – Web Performance Optimisation](https://csswizardry.com/2020/05/the-fastest-google-fonts/)\n- [Time to Say Goodbye to Google Fonts](https://wicki.io/posts/2020-11-goodbye-google-fonts/)\n- [The Best Font Loading Strategies and How to Execute Them | CSS-Tricks](https://css-tricks.com/the-best-font-loading-strategies-and-how-to-execute-them/)\n\n요약하자면\n\n- woff2\n- font-display로 레이아웃 이슈 최소화\n- preload\n- self (feat. [google webfonts helper](https://google-webfonts-helper.herokuapp.com/fonts))\n\n몇몇 권장사항은 한글 폰트에서는 사실상 불가능한 부분이라 가능한 부분들을 [적용](https://github.com/cbcruk/zitat/commit/8587c7f9669ad5b97facec9c4b2c918d7cb300ef)하면 된다."},{"i":"168","k":["autofill","html"],"c":"2022-04-09","m":"2024-03-22","b":"자동완성 기능은 편리할수도 있지만 비활성화가 필요한 경우도 있다. 하지만 chrome에서 무시되는 경우가 있는데...이게 거의  된다 지금은 안된다...되는 것 같다...이렇게 변질이 되는 것 같다.\n\n- [html - Disabling Chrome Autofill - Stack Overflow](https://stackoverflow.com/questions/15738259/disabling-chrome-autofill)[^168-1]\n- [Disabling autofill in Chrome | Codementor](https://www.codementor.io/@leonardofaria/disabling-autofill-in-chrome-zec47xcui)[^168-2]\n- [How to turn off form autocompletion - Web security | MDN](https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion)\n- [The Autofill Dark Pattern — Smashing Magazine](https://www.smashingmagazine.com/2021/10/autofill-dark-pattern/)\n\n---\n\n[^168-1]: 가끔 들어가보면 여전히 진행중이다...\n[^168-2]: 글쓴이는 결국 안되서 스크립트로 focus될때 `readonly` 속성으로 제어했다고 한다."},{"i":"167","k":["input","otp"],"c":"2022-04-09","m":"2024-03-22","b":"`autocomplete=\"one-time-code\"` 사용자가 SMS 메시지를 수신 할 때마다, 운영 체제는 SMS에서 OTP 구문을 분석하고 키보드는 OTP를 제안합니다. iOS, iPadOS 및 macOS의 Safari 12 이상에서만 작동하지만 해당 플랫폼에서 SMS OTP 환경을 쉽게 개선할 수 있는 방법이므로 사용하는 것이 좋습니다.\n\n```html\n<input \n  type=\"text\"\n  inputmode=\"numeric\"\n  autocomplete=\"one-time-code\"\n  pattern=\"\\d{6}\"\n  required\n/>\n```\n\n```js\nconst otp = await navigator.credentials.get({\n  otp: {\n    transport: ['sms']\n  }\n})\n\ninput.value = otp.code\n```\n\n참고\n\n- [SMS OTP form best practices](https://web.dev/sms-otp-form/)\n- [Apple Developer Documentation](https://developer.apple.com/documentation/security/password_autofill/enabling_password_autofill_on_an_html_input_element)\n- [HTML attributes to improve your users' two factor authentication experience](https://www.twilio.com/blog/html-attributes-two-factor-authentication-autocomplete)"},{"i":"166","k":["input","css"],"c":"2022-04-09","m":"2024-03-22","b":"```css\n::-webkit-input-placeholder /* for (Chrome/Safari/Opera) */\n:-ms-input-placeholder /* for IE. */\n::-ms-input-placeholder /* for Edge (also supports webkit prefix) */\n\n::-ms-clear {}\n::-ms-reveal {}\n```\n\n---\n\n- [On Edge Browser, how to change input placeholder text color using CSS?](https://stackoverflow.com/questions/32096102/on-edge-browser-how-to-change-input-placeholder-text-color-using-css)"},{"i":"164","k":["youtube-dl","zsh"],"c":"2022-04-09","m":"2024-03-22","b":"스트리밍 비디오 다운로드 할 경우 403 에러가 발생할때 해당 옵션으로 우회한다.[^164-1]\n\n```sh\nyoutube-dl --referer \"URL\" --user-agent \"UA\"\n```\n\n```sh\ndmRef() {\n  youtube-dl $1 --referer $1 --user-agent \"UA\"\n}\n```\n\n---\n\n[^164-1]: https://www.reddit.com/r/youtubedl/comments/g342nr/masterm3u8_http_error_403_forbidden_youtubedl_or/"},{"i":"163","k":["youtube-dl"],"c":"2022-04-09","m":"2024-03-22","b":"youtube-dl 업데이트가 안되면서 다운로드 속도가 굉장히 저조하다. 그래서 [yt-dlp](https://github.com/yt-dlp/yt-dlp)로 일단 변경해서 사용 중.\n\n---\n\n여러 개 주소를 한꺼번에 받으려면 주소를 txt 파일로 저장하고 `-a(-a, --batch-file FILE)` 옵션을 추가하면 된다.\n\n```sh\nyoutube-dl -a list.txt\n```\n\n---\n\n가끔 중간에 에러가 발생하는 경우도 있는데 이럴 경우 ignore로 방어 코드를 설정하고 실행시키면 잘 된다.\n\n```sh\nyoutube-dl -i, --ignore-errors\n```\n\n---\n\n자막을 다운로드해야 된다면 --sub-lang 옵션을 추가한다. 자동 자막이 필요하다면 `--write-auto-sub`\n\n```sh\nyoutube-dl --write-sub --sub-lang ko\nyoutube-dl --write-auto-sub\n```\n\n---\n\nplaylist를 다운 받을때 index도 파일에 포함시키고 싶을 경우[^163-1]\n\n```sh\n-o '%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s'\n```\n\n---\n\n기본적으로 평소 사용하는 alias 설정\n\n```sh\nalias dm=\"youtube-dl\"\nalias da=\"youtube-dl -x --audio-format mp3\"\n```\n\n---\n\n[^163-1]: https://github.com/ytdl-org/youtube-dl#output-template"},{"i":"162","k":["youtube-dl","ffmpeg","brew"],"c":"2022-04-09","m":"2024-03-22","b":"brew로 ffmpeg 설치할때[^162-1]\n\n```sh\nbrew install ffmpeg $(brew options ffmpeg | grep -vE '\\s' | grep -- --with-' | tr '\\n' ' ')\n```\n\n---\n\n[^162-1]: [brew install ffmpeg with all options](https://gist.github.com/Piasy/b5dfd5c048eb69d1b91719988c0325d8)"},{"i":"161","k":["test"],"c":"2022-04-09","m":"2024-03-22","b":"connect가 실행된 컴포넌트를 (enzyme) `mount`로 테스트한 경우 실패 케이스가 발생해서 찾아본 내용들 인 것 같은데 정확하게 기억이 안남. `shallow`로 변경했더니 이번에는 ref를 못쓰는 문제도 있었다고 하고...\n여담이지만 컴포넌트를 명확하게 정의하고 분리해서 테스팅 스트레스를 줄이는 게 중요합니다.\n\n- [Enzyme mount test failing with redux store state update](https://stackoverflow.com/questions/58010552/enzyme-mount-test-failing-with-redux-store-state-update)\n- [httpsWhy I Always Use Shallow Rendering](https://hackernoon.com/why-i-always-use-shallow-rendering-a3a50da60942)"},{"i":"160","k":["optimize","charles"],"c":"2022-04-09","m":"2026-08-19","b":"> [!WARNING]\n> **Google Optimize는 2023년 9월 30일 종료됐다**[^optimize-sunset] — 아래 Optimize 링크 둘은 살아 있지 않다.\n> 남는 건 서비스가 아니라 교훈이다: **리다이렉트에서 query를 갈아끼우면 그 query에 실려 있던 측정 정보가 사라진다.**\n\n[Google Optimize](https://optimize.google.com/)에서 [Redirect 테스트](https://support.google.com/optimize/answer/6361119?hl=ko&ref_topic=6197696)를 진행했는데 기능 구현은 문제없는데 세션수가 안 잡히는 문제가 발생했다. \n\n이번에 문제되었던 부분은 query 처리를 인지하지 못했던 점이었다. Optimize에서 redirect 처리가 이루어질 때 `query`에 정보(예. `utm_expid`)들이 추가되는데 리다이렉트 url 변경하는 부분이 있었고 optimize에서 참조되어야 할 query를 날려버리면서 측정이 불가능했던 것. 결국 `replace` 실행 코드를 이전 query를 assign 시켜주도록 변경해서 해결.\n\n여담이지만 [Charles](https://www.charlesproxy.com/)를 사용하고 있어서 배포 없이 테스트 가능했던 점도 같이 메모.\n\n[^optimize-sunset]: [Google Optimize sunset](https://support.google.com/optimize/answer/12979939)"},{"i":"159","k":["javascript","date"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nimport isAfter from 'date-fns/isAfter';\n\nisAfter(new Date(), new Date(DATE))\n```\n\n날짜 비교 할 일이 있어서 별 생각 없이 `new Date`를 때렸는데 safari에서 안되는 문제가 발견되었다. 콘솔을 확인해보니 _yyyy-MM-dd HH:mm:ss_ 해당 형태의 포멧 에서는 안된다. 평소에 `new Date` 보다는 moment나 date-fns같은 라이브러리를 당연하게 써오다 보니 몰랐다. 그런데 또 다른 생각을 해보자면 저런 문제가 있기 때문에 더 적극적으로 라이브러리를 사용해야 한다는 게 함정.\n\n```js\nimport isAfter from 'date-fns/isAfter';\nimport format from 'date-fns/format';\n\nisAfter(new Date(), format(DATE))\n```\n\n- [Invalid date in safari](https://stackoverflow.com/questions/4310953/invalid-date-in-safari)\n- [Safari Javascript Date() NaN Issue (yyyy-MM-dd HH:mm:ss)](https://stackoverflow.com/questions/21883699/safari-javascript-date-nan-issue-yyyy-mm-dd-hhmmss)"},{"i":"152","k":["live2d"],"c":"2022-04-09","m":"2024-03-22","b":"live2d 구현이 필요할 경우 pixi+plugin을 사용하면 된다\n\n- https://codepen.io/guansss\n- [샘플 데이터 모음 - Live2D Cubism](https://www.live2d.com/ko/download/sample-data/)"},{"i":"149","k":["test","bdd"],"c":"2022-04-09","m":"2026-07-27","b":"```md\n**title**: 반품 및 교환은 재고로 이동합니다.\n\n**as a** 가게 주인으로서,\n**I want** 반품 또는 교환 시 상품을 재고에 다시 추가하고 싶습니다.\n**so that** 인벤토리를 추적할 수 있습니다.\n\n**Scenario 1:** 환불을 위해 반품된 항목은 인벤토리에 추가되어야 합니다.\n**Given** 고객이 이전에 나에게서 검은색 스웨터를 구매했다는 점을 감안할 때\n**and** 인벤토리에 검은색 스웨터 3개가 있습니다.\n**when** 환불을 위해 검은색 스웨터를 반환할 때\n**then** 그러면 인벤토리에 4개의 검은색 스웨터가 있어야 합니다.\n```\n\nFeature > [...Scenario] > [...Step]\n\n`Given` 초기 상태 — 보통 과거에 일어난 일. `When` 이벤트나 동작. `Then` 예상 결과. 단계를 이을 때 `And`·`But`.\n\n```md\nScenario: 항목 검색 후 결제 페이지로 이동\nGiven 사용자가 Greencart 방문 페이지에 있다\nWhen 사용자가 야채<이름>를 검색했을 때\nAnd 장바구니에 항목이 추가된다\nAnd 사용자는 구매를 위해 Checkout 페이지로 이동한다\nThen 선택한 <이름> 항목이 체크아웃 페이지에 표시된다\n```"},{"i":"148","k":["mock","test"],"c":"2022-04-09","m":"2024-03-22","b":"테스트 코드에서 mock으로 처리하는 경우[^148-1]\n\n- [Mocking React custom hook with Jest - Stack Overflow](https://stackoverflow.com/questions/65270255/mocking-react-custom-hook-with-jest)\n- [mrbenhowl/mocking-firebase-initializeApp-and-firebase-auth-using-jest](https://github.com/mrbenhowl/mocking-firebase-initializeApp-and-firebase-auth-using-jest)\n- [Avoid Nesting when you're Testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing)\n\n---\n\n[^148-1]: 그래서 내가 내린 결론은 저렇게 까지는 테스트할 필요가 없고 오히려 애매하게 결합된 컴포넌트들을 분리해서 관리하는게 맞을 것 같다는 생각을 해봤다."},{"i":"147","k":["test"],"c":"2022-04-09","m":"2024-03-22","b":"테스트 하다가 특정 사이즈 파일이 필요한 경우. 순수하게 사이즈 관련된 내용만 테스트가 가능하다.[^147-1]\n\n```bash\nmkfile 100m some100mfile.pdf\n```\n\n---\n\n[^147-1]: [How to create large PDF files (10MB, 50MB, 100MB, 200MB, 500MB, 1GB, etc.) for testing purposes? - Stack Overflow](https://stackoverflow.com/questions/5092518/how-to-create-large-pdf-files-10mb-50mb-100mb-200mb-500mb-1gb-etc-for-t)"},{"i":"146","k":["test","cypress"],"c":"2022-04-09","m":"2024-03-22","b":"```sh\n# 개발할때 편하다\nyarn add -D @types/cypress\n```\n\n```js\n// react-devtools 같은 확장도구가 필요할 경우\n\n// /plugins/index.js\nconst path = require('path')\n\nmodule.exports = (on, _config) => {\n  on('before:browser:launch', (browser, launchOptions) => {\n    if (browser.family === 'chromium') {\n      const extensionFolder = path.resolve(__dirname, '..', '..', '4.7.0_1')\n\n      launchOptions.args.push(`--load-extension=${extensionFolder}`)\n\n      return launchOptions\n    }\n  })\n}\n\n// /support/commands.js\nCypress.on('window:before:load', (win) => {\n  win.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.top.__REACT_DEVTOOLS_GLOBAL_HOOK__\n})\n```\n\n```js\n// 파일업로드 기능 테스트\nCypress.Commands.add(\n  'uploadFile',\n  { prevSubject: true },\n  (subject, fileName) => {\n    cy.fixture(fileName).then((content) => {\n      const el = subject[0]\n      const testFile = new File([content], fileName)\n      const dataTransfer = new DataTransfer()\n\n      dataTransfer.items.add(testFile)\n      el.files = dataTransfer.files\n      cy.wrap(subject).trigger('change', { force: true })\n    })\n  }\n)\n```\n\n```js\n// 에러때문에 테스트가 끊길 경우\nCypress.on('uncaught:exception', (err, runnable) => {\n  console.log(err)\n  return false\n})\n```"},{"i":"145","k":["test"],"c":"2022-04-09","m":"2024-03-22","b":"📌 테스트 전반에 대한 가이드 및 베스트 프랙티스\n\n- [이 가이드가 당신의 테스트 기술을 한 단계 끌어 올리는 이유](https://github.com/goldbergyoni/javascript-testing-best-practices/blob/master/readme.kr.md)\n- [테스트 함정과 해결 방법](https://www.smashingmagazine.com/2021/07/frontend-testing-pitfalls/)\n- [GitHub - mhevery/guide-to-testable-code](https://github.com/mhevery/guide-to-testable-code)\n- [Good Code, Testable Code | Epic Web Dev](https://www.epicweb.dev/good-code-testable-code)\n- [The True Purpose of Testing | Epic Web Dev](https://www.epicweb.dev/the-true-purpose-of-testing)\n\n---\n\n🛠 Cypress 관련 리소스\n\n- [Cypress Tips and Tricks | Better world by better software](https://glebbahmutov.com/blog/cypress-tips-and-tricks/?hss_channel=tw-2774638535)\n- [cypress-example-recipes/examples at master · cypress-io/cypress-example-recipes · GitHub](https://github.com/cypress-io/cypress-example-recipes/tree/master/examples)\n\n---\n\n🔎 React 테스트 관련 자료\n\n- [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)\n- [Best Practices for Writing Tests with React Testing Library | ClarityDev blog](https://claritydev.net/blog/improving-react-testing-library-tests)\n- [Avoid Nesting when you're Testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing)\n\n---\n\n📏 테스트 기법 및 접근법\n\n- [How to get started with property-based testing in JavaScript using fast-check](https://jrsinclair.com/articles/2021/how-to-get-started-with-property-based-testing-in-javascript-with-fast-check/)\n- [Inverse Assertions | Epic Web Dev](https://www.epicweb.dev/inverse-assertions)\n- [Cram tests: a hidden gem of dune | sancho.dev](https://sancho.dev/blog/cram-tests-a-hidden-gem-of-dune)\n\n---\n\n🔧 테스트 환경 및 도구\n\n- [Testing Playground](https://testing-playground.com/)\n- [Testing Types | Guide | Vitest](https://vitest.dev/guide/testing-types.html)\n- [Testing Types in TypeScript &#8211; Frontend Masters Boost](https://frontendmasters.com/blog/testing-types-in-typescript/)\n\n---\n\n🏗 테스트 아키텍처 및 설계\n\n- [Dependency Injection in React - full guide with examples | Code Driven Development](https://codedrivendevelopment.com/posts/dependency-injection-in-react)\n- [Building a JavaScript Testing Framework | Christoph Nakazawa](https://cpojer.net/posts/building-a-javascript-testing-framework)"},{"i":"143","k":["html"],"c":"2022-04-09","m":"2024-03-22","b":"html\n\n`<head />`\n\n- [🤯 HEAD - A simple guide to HTML `<head>` elements](https://htmlhead.dev/)\n\n구조화된 데이터\n\n- [Using Structured Data to Enhance Search Engine Optimization | CSS-Tricks](https://css-tricks.com/using-structured-data-to-enhance-search-engine-optimization/)\n- [Understand How Structured Data Works | Google Search Central](https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data)\n\n`lang`\n\n- [The lang attribute: browsers telling lies, telling sweet little lies - Manuel Matuzović](https://www.matuzo.at/blog/lang-attribute/)\n\n`favicon`\n\n- [How to Favicon in 2021: Six files that fit most needs — Martian Chronicles, Evil Martians’ team blog](https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs)\n- [How to Create a Favicon That Changes Automatically | CSS-Tricks](https://css-tricks.com/how-to-create-a-favicon-that-changes-automatically/)\n- [Emojis as Favicons | CSS-Tricks](https://css-tricks.com/emojis-as-favicons/)\n- [We Analyzed 425,909 Favicons • iconmap.io](https://iconmap.io/blog)[^143-1]\n\n`Microdata`\n\n- [Getting Started - schema.org](https://schema.org/docs/gs.html)\n\n`attributes`\n\n- [HTML attributes vs DOM properties - JakeArchibald.com](https://jakearchibald.com/2024/attributes-vs-properties/)\n\n---\n\n- [HTML for People](https://htmlforpeople.com/)\n\n---\n\n[^143-1]: 다양한 관점에서 파비콘을 분석한 글"},{"i":"134","k":["select","css"],"c":"2022-04-09","m":"2024-03-22","b":"[Under-Engineered Select Menus | Adrian Roselli](https://adrianroselli.com/2021/03/under-engineered-select-menus.html)\n\n- `font`, `letter-spacing`, `word-spacing` 상속\n- `appearance` 화살표 수정\n- 상태(focus, required, invalid)에 따른 스타일 추가\n\n---\n\n- [How to Build an Accessible Custom Dropdown Select Element](https://www.freecodecamp.org/news/how-to-build-an-accessible-custom-dropdown-select-element/#dropdown-keyboard-interaction)\n- [Custom and Accessible Select Menus Aren&#39;t Easy: Part One – Chris Henrick](https://clhenrick.io/accessible-select-element/)"},{"i":"133","k":["select","css","ui"],"c":"2022-04-09","m":"2024-03-22","b":"[Building a multi-select component](https://web.dev/building-a-multi-select-component/)\n\n다중 선택 UI를 구현하기위해서 checkbox, select 두가지 방법으로 작업하는 방식을 소개하고 있다. 그외 선택된 상태값을 얻기위한 `counter()` 함수, 모바일 체크를 위한 미디어쿼리도 알려주고 있다.\n\n```css\naside {\n  counter-reset: filters;\n\n  & :checked {\n    counter-increment: filters;\n  }\n\n  &::after {\n    content: counter(filters);\n  }\n}\n```\n\n```css\n@media (pointer: coarse) {\n  //\n}\n```\n\n---\n\n- [React-ComboBox-Select-Only-with-Tippy - Codesandbox](https://codesandbox.io/p/sandbox/react-combobox-select-only-with-tippy-58p0v1)\n- [Combobox Pattern | APG | WAI | W3C](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#examples)"},{"i":"132","k":["html","select"],"c":"2022-04-09","m":"2024-03-22","b":"디자인 된 `<select />`에 `placeholder` 개념이 있어서 어떻게 하면 좋을지 찾아봤다. 간략하게 설명하자면 선택이 불가능하게 `disabled` 추가하고 `hidden`으로 숨김 마지막으로 `selected`로 디폴트값을 처리하면 완성.\n\n```js\nfunction Select({ placeholder, children }) {\n  return (\n    <select>\n      <option value=\"\" disabled hidden selected>\n        {placeholder}\n      </option>\n      {children}\n    </select>\n  )\n}\n```\n\n---\n\n- [How do I make a placeholder for a 'select' box?](https://stackoverflow.com/questions/5805059/how-do-i-make-a-placeholder-for-a-select-box)"},{"i":"128","k":["extensions"],"c":"2022-04-09","m":"2024-03-22","b":"매니페스트 V3로 마이그레이션[^128-1]\n\n- host_permissions으로 분리\n- action으로 통합(두군데 수정이 필요하다)\n\n[^128-1]: https://github.com/cbcruk/webext/commit/cbb36355871fb36d5f6c21752aae3400d2a97abf#diff-0f354ef5fd807996fa3f5a7a83ceb74025aa8f85862e321b8938988031e99711L3-L25"},{"i":"124","k":["json"],"c":"2022-04-09","m":"2024-03-22","b":"[TIL — The power of JSON.stringify replacer parameter | pawelgrzybek.com](https://pawelgrzybek.com/til-the-power-of-json-stringify-replacer-parameter/)\n\n```js\nJSON.stringify(dude, (key, value) =>\n  value instanceof Set ? [...value] : value\n);\nJSON.stringify(dude, null, \"🍆\");\n```"},{"i":"122","k":["angle","math"],"c":"2022-04-09","m":"2024-03-22","b":"- [Tilted Angles in Sass - SitePoint](https://www.sitepoint.com/tilted-angles-in-sass/)\n- [Inverse trigonometric functions with Sass – TheSassWay.com](http://thesassway.com/advanced/inverse-trigonometric-functions-with-sass)\n\n```js\nfunction getTiltedHeight(angle) {\n  const a = 100;\n  const A = 90 - angle;\n  \n  const c = a / Math.sin(Math.PI * A / 180);\n  const b = Math.sqrt(Math.pow(c, 2) - Math.pow(a, 2));\n  \n  return `${Math.abs(b)}%`;\n}\n```"},{"i":"108","k":["css","ellipsis","table","text-overflow","layout"],"c":"2022-04-09","m":"2026-09-01","b":"텍스트 ellipsis는 **컨텍스트마다 패턴이 다르다.**\n\n**언제** — 먼저 어느 컨텍스트인지 가른다.\n\n| 컨텍스트 | 어디로 |\n|---|---|\n| `display: table-cell` | 아래 절차 |\n| flexbox | 맨 아래 참고 링크 |\n| 여러 줄(multiline) | 맨 아래 참고 링크 |\n\n## table cell\n\n**절차**\n\n1. 표에 `table-layout: fixed`를 준다.\n2. 셀에 `max-width: 0`과 `overflow: hidden` 셋을 함께 준다.\n\n```css\ntable {\n  table-layout: fixed;\n  width: 100%;\n}\n\ncol:nth-child(1) { width: 30%; }\ncol:nth-child(2) { width: 40%; }\ncol:nth-child(3) { width: 30%; }\n\ntd {\n  max-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n```\n\n**확인**: 긴 텍스트가 든 셀이 표를 옆으로 밀어내지 않고 `…`로 잘리는가.\n\n**함정** — `table-layout: fixed`와 `max-width: 0`은 **반드시 함께여야 한다.** `overflow` 셋만 주면 아무 효과가 없다.\n\n```css\n/* ❌ 아무 효과 없음 */\ntd {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n```\n\n`display: table-cell`은 width 제약 없이 content 크기에 맞게 늘어나서, `overflow`가 동작할 명시적 width bound가 없기 때문이다. 그리고 `max-width: 0`이 실제 0px이 되는 게 아니다 — `table-layout: fixed` 안에서 **\"이 셀은 content 기반으로 너비를 주장하지 않는다\"는 시그널**로 작동해, colgroup/th에서 받은 너비 안에서 overflow가 동작한다.\n\n### 컬럼별 선택적 적용\n\n```css\ntd:not(.col-action) {\n  max-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\ntd.col-action {\n  white-space: nowrap;\n}\n```\n\n### 셀 안에 복합 요소\n\n버튼/배지 등이 텍스트와 함께 있을 때 inner wrapper에 위임.\n\n```css\ntd {\n  max-width: 0;\n}\n\ntd > div {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  overflow: hidden;\n}\n\ntd > div > span {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  min-width: 0;\n}\n```\n\n### React 컴포넌트 패턴\n\nTanStack Table `size` 옵션과 조합.\n\n```tsx\nconst columns = [\n  columnHelper.accessor('name', {\n    size: 200,\n    cell: ({ getValue }) => (\n      <span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>\n        {getValue()}\n      </span>\n    ),\n  }),\n]\n\n<table style={{ tableLayout: 'fixed', width: '100%' }}>\n  <colgroup>\n    {table.getFlatHeaders().map(header => (\n      <col key={header.id} style={{ width: header.getSize() }} />\n    ))}\n  </colgroup>\n  ...\n</table>\n```\n\n재사용 셀 래퍼:\n\n```tsx\nfunction EllipsisCell({ children }: { children: React.ReactNode }) {\n  return (\n    <td style={{ maxWidth: 0 }}>\n      <div style={{\n        overflow: 'hidden',\n        textOverflow: 'ellipsis',\n        whiteSpace: 'nowrap',\n      }}>\n        {children}\n      </div>\n    </td>\n  )\n}\n```\n\n## flexbox / multiline 참고\n\n- [Using Flexbox and text ellipsis together · Leonardo Faria](https://leonardofaria.net/2020/07/18/using-flexbox-and-text-ellipsis-together/)[^108-1]\n- [Multiline truncated text with \"show more\" button (with just CSS) - Paul Bakaus' blog](https://paulbakaus.com/tutorials/css/multiline-truncated-text-with-show-more-button-with-just-css/)[^108-2]\n- [Recreating MDN's Truncated Text Effect](https://css-tricks.com/recreating-mdns-truncated-text-effect)\n\n---\n\n[^108-1]: flexbox | 파일명에 ellipsis 효과 적용, 단 파일 확장자는 제외.\n[^108-2]: `:truncated` 개념이 없기 때문에 ResizeObserver와 영역값을 체크해서 구현."},{"i":"107","k":["css","layout shift","bold"],"c":"2022-04-09","m":"2024-03-22","b":"인라인 요소에 `bold` 스타일이 적용될 경우 레이아웃 시프팅 현상이 발생하기 때문에 해당 이슈를 해결하는 방법들.\n\n- [html - Inline elements shifting when made bold on hover - Stack Overflow](https://stackoverflow.com/a/20249560)[^107-1]\n- [Bold on Hover... Without the Layout Shift | CSS-Tricks](https://css-tricks.com/bold-on-hover-without-the-layout-shift/)[^107-2]\n\n---\n\n[^107-1]: `text-shadow`로 우회하는 방법이 주 해결방법으로 올라왔다.\n[^107-2]: `content` 속성과, `grid` 레이아웃을 이용한 방법."},{"i":"106","k":["css"],"c":"2022-04-09","m":"2026-07-27","b":"최신 CSS 기능 및 트렌드\n\n- [Things You Can Do With CSS Today — Smashing Magazine](https://www.smashingmagazine.com/2021/02/things-you-can-do-with-css-today/)\n- [The Future of CSS: Cascade Layers (CSS @layer)](https://www.bram.us/2021/09/15/the-future-of-css-cascade-layers-css-at-layer/)\n- [The Undeniable Utility Of CSS :has • Josh W. Comeau](https://www.joshwcomeau.com/css/has/)\n- [An Interactive Guide to CSS Container Queries](https://ishadeed.com/article/css-container-query-guide/)\n- [컨테이너 쿼리 사용 방법 | web.dev](https://web.dev/blog/how-to-use-container-queries-now?hl=ko)\n- [What if you used Container Units for everything?](https://frontendmasters.com/blog/what-if-you-used-container-units-for-everything/)\n\n레이아웃과 반응형 디자인\n\n- [Fluid Sizing Instead Of Multiple Media Queries? — Smashing Magazine](https://www.smashingmagazine.com/2022/08/fluid-sizing-multiple-media-queries/)\n- [Complex conditional width using flex-basis with clamp](https://every-layout.dev/blog/sidebar-flex-basis-clamp/)\n- [Using grid to split a table cell](https://www.peterkrautzberger.org/0213/)\n- [TablesNG — Improvements to `<table>` rendering in Chromium](https://www.bram.us/2021/06/21/tablesng-improvements-to-table-rendering-in-chromium/)\n\nCSS 애니메이션 및 효과\n\n- [A CSS-only, animated, wrapping underline.](https://nickymeuleman.netlify.app/blog/css-animated-wrapping-underline/)\n- [Cubic Bézier: from math to motion](https://blog.maximeheckel.com/posts/cubic-bezier-from-math-to-motion/)\n- [Zero Trickery Custom Radios and Checkboxes - CSS-Tricks](https://css-tricks.com/zero-trickery-custom-radios-and-checkboxes/)\n- [CSS One-Liners to Improve (Almost) Every Project](https://alvaromontoro.com/blog/68055/ten-css-one-liners-for-almost-every-project)\n- [6 CSS Snippets Every Front-End Developer Should Know In 2025](https://nerdy.dev/6-css-snippets-every-front-end-developer-should-know-in-2025)\n\n텍스트 및 타이포그래피\n\n- [Using CSS to Set Text Inside a Circle](https://css-tricks.com/using-css-to-set-text-inside-a-circle/)\n- [The Trickery it Takes to Create eBook-Like Text Columns](https://css-tricks.com/the-trickery-it-takes-to-create-ebook-like-text-columns/)\n\n스타일 가이드 및 접근성\n\n- [Naming Variables In CSS](https://jwdallas.com/posts/namingcssvariables/)\n- [Margin considered harmful](https://mxstbr.com/thoughts/margin/)\n- [Defensive CSS - Ahmad Shadeed](https://ishadeed.com/article/defensive-css/)\n- [The wasted potential of CSS attribute selectors](https://elisehe.in/2022/10/16/attribute-selectors)\n\nCSS와 JavaScript의 조합\n\n- [Constructable Stylesheets: seamless reusable styles](https://developers.google.com/web/updates/2019/02/constructable-stylesheets)\n- [Replace JavaScript Dialogs With New HTML Dialog | CSS-Tricks](https://css-tricks.com/replace-javascript-dialogs-html-dialog-element/)\n- [How to prevent scrolling the page on iOS Safari 15](https://pqina.nl/blog/how-to-prevent-scrolling-the-page-on-ios-safari/)\n\nCSS 아키텍처 및 모듈화\n\n- [What is Modular CSS?](https://spaceninja.com/2018/09/17/what-is-modular-css/)\n- [Panda CSS - The Origin Story](https://www.adebayosegun.com/blog/panda-css-the-origin-story)\n\n`background`\n\n- [CSS Background Patterns by MagicPattern](https://www.magicpattern.design/tools/css-backgrounds)"},{"i":"105","k":["git"],"c":"2022-04-09","m":"2024-03-22","b":"[discard](https://stackoverflow.com/questions/52704/how-do-i-discard-unstaged-changes-in-git)[^105-1]\n\n```sh\ngit checkout -- <file>\n\ngit restore <file>\n\ngit reset --hard\n```\n\n리모트 브랜치 가져오기\n\n```sh\ngit remote update\n```\n\n---\n\n[^105-1]: 명령어로 discard를 하려면"},{"i":"101","k":["git"],"c":"2022-04-09","m":"2024-03-22","b":"```sh\n# 마지막 커밋 해시\ngit log --pretty=format:'%h' -n 1\n```\n\n```sh\n# 유저정보\ngit config user.name\ngit config user.email\n```"},{"i":"97","k":["google-apps-script","slack"],"c":"2022-04-09","m":"2026-09-01","b":"[Using Slack Slash Commands to Send Data from Slack into Google Sheets](https://davidwalsh.name/using-slack-slash-commands-to-send-data-from-slack-into-google-sheets)\n\n**순서를 기억하려고 적어둔 것이다.** 방법은 위 링크에 있다.\n\n1. 사람들이 추천한 책을 기록한다\n2. 슬랙 -> 구글시트\n3. 슬랙 커맨드 설정 `/book` + 구글앱스 스크립트 url 연결[^97-1]\n4. POST로 전송 받은 데이터값을 기반으로 데이터 처리 완료\n\n---\n\n[^97-1]: [꿀벌개발일지 :: 구글 앱스 스크립트에서 비동기 작업 추가하기](https://ohgyun.com/806) doPost에서 비동기 처리"},{"i":"96","k":["brew"],"c":"2022-04-09","m":"2024-03-22","b":"homebrew, brew[^96-1]\n\n```shell\nbrew bundle dump\nbrew bundle\n```\n\n- [My 2021 New Mac Setup - DEV Community](https://dev.to/swyx/my-2021-new-mac-setup-1b1)\n- [Homebrew 로 Mac 한 방에 셋업하기](https://blog.gangnamunni.com/post/brew_cask_mas/)\n\n---\n\n[^96-1]: https://gist.github.com/cbcruk/b417d085163bb7d7244dd1815885b8cb"},{"i":"95","k":["tools","filezilla","ftp"],"c":"2022-04-09","m":"2024-03-22","b":"1. [파일질라 서버](https://filezilla-project.org/download.php?type=server)를 설치\n2. Edit->Users->Add / 사용자 추가\n3. 제어판->시스템 및 보안->Windows Defender 방화벽->허용되는 앱 / (설정변경|다른 앱 허용) 파일질라 서버 추가"},{"i":"93","k":["puppeteer","image"],"c":"2022-04-09","m":"2024-03-22","b":"이미지 저장[^93-1]\n\n- 페이지에서 JavaScript를 통해 이미지 추출\n  - 캔버스에서 이미지 추출\n  - 서버에서 이미지 가져오기\n- DevTools 프로토콜을 사용하여 이미지 추출\n\n[^93-1]: [Saving Images from a Headless Browser](https://intoli.com/blog/saving-images/)\n\n```js\nconst tree = await page._client.send('Page.getResourceTree')\n\nfor (const resource of tree.frameTree.resources) {\n  const { content } = await page._client.send(\n    'Page.getResourceContent',\n    { frameId: String(page.mainFrame()._id), url: resource.url },\n  )\n  const contentBuffer = Buffer.from(content, 'base64')\n}\n```\n\n```js\npage.on('response', async (response) => {\n  const url = response.url()\n  const buffer = await response.buffer()\n})\n```"},{"i":"92","k":["puppeteer","headers"],"c":"2022-04-09","m":"2024-03-22","b":"`setExtraHTTPHeaders` 호출하기\n```js\nawait page.setExtraHTTPHeaders(headers)\n```\n\n인터셉트로 가로채기\n```js\nawait page.setRequestInterception(true)\n\npage.on('request', (request) => {\n  const headers = {\n    ...request.headers()\n  }\n\n  interceptedRequest({ headers })\n})\n```"},{"i":"91","k":["puppeteer","cookies"],"c":"2022-04-09","m":"2024-03-22","b":"httpOnly, secure 플래그 쿠키값이 필요한 경우 CDPSession 실행 후 클라이언트와 [통신](https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-getAllCookies)한다.\n```js\nconst client = await page.target().createCDPSession()\nconst data = await client.send('Network.getAllCookies')\n```\n\n로컬에 해당 쿠키정보를 임시로 저장해서 재활용하고 만료 시 갱신하는 방법을 사용.\n```js\nfs.writeFileSync('cookies.json', JSON.stringify(data))\n\nconst { cookies } = JSON.parse(fs.readFileSync('cookies.json', 'utf8'))\nawait page.setCookie(...cookies)\n```"},{"i":"87","k":["scroll","css"],"c":"2022-04-09","m":"2024-03-22","b":"hash 링크로 연결될 경우 스크롤위치가 최상단으로 위치하기 때문에 문제(헤더가 고정일 경우)가 있을수도 있어서 `scroll-margin-top`으로 제어가 가능한 부분을 설명하고 있다.\n\n- [Add scroll margin to all elements which can be targeted - Piccalilli](https://piccalil.li/quick-tip/add-scroll-margin-to-all-elements-which-can-be-targeted/)[^87-1]\n- [Fixed Headers and Jump Links? The Solution is scroll-margin-top | CSS-Tricks](https://css-tricks.com/fixed-headers-and-jump-links-the-solution-is-scroll-margin-top/)\n- [Prevent content from being hidden underneath a fixed header by using scroll-margin-top – Bram.us](https://www.bram.us/2020/03/01/prevent-content-from-being-hidden-underneath-a-fixed-header-by-using-scroll-margin-top/)\n\n---\n\n[^87-1]: `2ex` 유닛을 사용하여 선택한 글꼴의 x 높이의 상대적인 크기로 설정."},{"i":"85","k":["google-apps-script","geolocation","new-geocoder"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nfunction getReverseGeocodeData() {\n  const response = Maps.newGeocoder()\n    .setLanguage('ko')\n    .reverseGeocode(40.758577, -73.984464)\n\n  return response.results.map((result) => {\n    return {\n      formatted_address: result.formatted_address,\n      lat: result.geometry.location.lat,\n      lng: result.geometry.location.lng,\n    }\n  })\n}\n```\n\n- [`reverseGeocode(latitude, longitude)`](<https://developers.google.com/apps-script/reference/maps/geocoder#reverseGeocode(Number,Number)>)\n- [`setLanguage(language)`](https://developers.google.com/apps-script/reference/maps/geocoder#setlanguagelanguage)"},{"i":"84","k":["api"],"c":"2022-04-09","m":"2024-03-22","b":"[Cool Cats: The Coolest NFT's on the Blockchain!](https://www.coolcatsnft.com/gallery)\n\n```js\nconst imageSrc = `https://s3.amazonaws.com/api.coolcatsnft.com/thumbnails/${ID}_thumbnail.png`\nconst query = {\n  sortBy: 'token_id_asc',\n  limit: 48,\n  page: 1,\n  face: 'angry',\n  hats: 'admiral',\n  shirt: 'astro',\n  tier: 'cool_1',\n}\n\nfetch(\n  'https://prod-api.coolcatsnft.com/cat?sortBy=token_id_asc&limit=48&page=1&tier=${tier}'\n)\n```\n\n- **face** angry, angry cute, angry scar, beard brown, beard pirate, beard tan, derp, ditto, dizzy, double face, face face, glasses, glasses funny, glossy, grin, happy, happy cute, hearts, mononoke, mummy, ninja black, ninja blue, ninja red, owo, pixel, rich, shocked, smirk, stunned, sunglasses blue, sunglasses cool, sunglasses cowboy, sunglasses heart, sunglasses pixel, sunglasses squad, sunglasses yellow, three eyes, tired, tvface 404, tvface bobross, tvface nosignal, tvface xp, unamused, uwu, wink, zombie\n- **hat** admiral, admiral pink, afro black, afro brown, afro rainbow unicorn, antlers, apple, arrowhead, astro, astro cheeks, astro fishbowl, beanie black, beanie blue, beanie orange, beanie red, beret black, beret green, beret pink, beret red, bow, bucket hat blue, bucket hat green, bucket hat tan, bucket hat white, candle, costume dragon, costume frog, costume gorilla, cowboy black, cowboy brown, crown black, crown fire, crown gold, cupcake, deepsea bronze, deepsea orange, dutch, flower blue, flower pink, flower red, goggles seaweed, halo, halo fire, hat black, hat skull, hat visor blue, hat visor yellow, hat white, headband blue, headband red, helm army, helm biker, helm bronze, helm silver, horns, knight black, knight blue, knight red, mohawk green, mohawk purple, mohawk red, mullet blonde, mullet brown, ninja black, ninja blue, ninja red, nurse, piercings, pirate black, pirate red, prince, sunhat black, sunhat tan, sunhat white, sushi, top hat, tvhead grey, tvhead purple, tvhead white, unicorn horn, visor green, visor purple, wreath, wreath flowers\n- **shirt** astro, astro black, astro orange, bandana green, bandana purple, bandana red, baseball blue, baseball red, buttondown black flannel, buttondown blue flannel, buttondown green, buttondown red flannel, buttondown tan, chain, combat black, combat green, costume dragon, costume frog, costume gorilla, costume hotdog, cowboy black, cowboy brown, deepsea bronze, deepsea orange, epaulette black, epaulette red, epaulette white, gown black, gown purple, gown white, hoodie black, hoodie purple, hoodie red, knight, knight black, knight leather, labcoat, lederhosen, monk, mononoke, ninja black, ninja blue, ninja red, nurse, overalls blue, overalls flannel, overalls pink, overalls red, overalls yellow, pirate black, pirate red, punk, robe blue, robe king, robe red, robe white, shirt bowtie, shirt white, shirt yellow, sweater black, sweater green chain, sweater orange, sweater pink, tanktop orange, tanktop pink, tanktop sailor black, tanktop sailor blue, tanktop sailor red, tanktop tattoo, tanktop white, tiger, toga, tshirt blue, tshirt green, tshirt metal, tshirt pink, tshirt red, tshirt white, tshirt yellow, viking brown, viking navy, wetsuit, winter blue, winter red, work blue, work red\n- **tier** cool_1, cool_2, wild_1, wild_2, classy_1, classy_2, exotic_1, exotic_2"},{"i":"83","k":["api","github"],"c":"2022-04-09","m":"2026-07-27","b":"- [Objects - GitHub Docs](https://docs.github.com/en/graphql/reference/objects)[^83-1]\n  - [repository](https://docs.github.com/en/graphql/reference/objects#repository)\n  - [issue](https://docs.github.com/en/graphql/reference/objects#issue)\n- [GitHub Next | Flat Data](https://githubnext.com/projects/flat-data#part-iii-visualizing-our-data-for-easy-sharing-optional)\n  - [GitHub - githubocto/flat-demo-google-sheets: A Flat Data GitHub Action demo repo](https://github.com/githubocto/flat-demo-google-sheets)\n  - [GitHub - mathiasbynens/covid-19-vaccinations-germany: Historical data on COVID-19 vaccination doses administered in Germany, per state.](https://github.com/mathiasbynens/covid-19-vaccinations-germany)\n- [GitHub Code Search (Preview)](https://cs.github.com/)[^83-3]\n- [Writing workflows - GitHub Docs](https://docs.github.com/en/actions/learn-github-actions)\n  - [Skipping workflow runs - GitHub Docs](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/skipping-workflow-runs)\n  - [Create and Publish a GitHub Action in Typescript - ULTIMATE 2023 GUIDE | Leonardo Montini](https://leonardomontini.dev/typescript-github-action)\n  - [Publishing a simple client-side JavaScript package to npm with GitHub Actions | Simon Willison’s TILs](https://til.simonwillison.net/npm/npm-publish-github-actions)\n\n---\n\n[^83-1]: GitHub GraphQL API를 통해 쿼리할 수 있는 다양한 개체에 대한 개요.\n[^83-3]: https://grep.app/"},{"i":"79","k":["api","notion","docs"],"c":"2022-04-09","m":"2024-03-22","b":"- [Use the Notion API to Create a Quiz with JavaScript - SitePoint](https://www.sitepoint.com/notion-api-javascript-quiz/)\n- [Collecting Email Signups With the Notion API | CSS-Tricks](https://css-tricks.com/collecting-email-signups-with-the-notion-api/)\n\n---\n\n1. [통합(integration)](https://www.notion.com/my-integrations)을 생성합니다.[^79-1]\n2. 통합과 데이터베이스 공유\n  2.1 `•••` -> `Add connections`\n\n---\n\n[^79-1]: https://developers.notion.com/docs"},{"i":"78","k":["cse","api"],"c":"2022-04-09","m":"2026-07-27","b":"> 검색기능을 정말 간단하게 구현하고 싶을때:\n> 프로그래밍 가능한 검색 엔진을 사용하면 웹사이트, 블로그 또는 웹사이트 모음에 대한 검색 엔진을 만들 수 있습니다. 웹 페이지와 이미지를 모두 검색하도록 엔진을 구성할 수 있습니다. 순위를 미세 조정하고 자신의 프로모션을 추가하고 검색 결과의 모양과 느낌을 사용자 지정할 수 있습니다. 엔진을 Google 애드센스 계정에 연결하여 검색으로 수익을 창출할 수 있습니다.\n\n---\n\n- [Method: cse.siterestrict.list](https://developers.google.com/custom-search/v1/reference/rest/v1/cse.siterestrict/list)"},{"i":"77","k":["api"],"c":"2022-04-09","m":"2024-03-22","b":"- [혜택](https://content.zigzag.kr/benefit)은 웹뷰로 존재함\n- GraphQL을 사용 중\n\n```js\nuseSWR(\n  `{\n    pages {\n      user_group\n      highlight_color\n      page_list {\n        type\n        page_id\n        page_name\n        is_new\n      }\n    }\n  }`,\n  (query) => request('https://api.zigzag.kr/api/2/graphql', query)\n)\n```"},{"i":"75","k":["zsh","dotfiles","shell"],"c":"2022-04-09","m":"2024-03-22","b":"```sh\n# /home/USERNAME/.zshrc\n\nHOME=\"/mnt/c/Users/cbcru\"\nDL=\"$HOME/Downloads\"\n\nif [[ $PWD == $HOME ]]; then\n    cd $DL\nfi\n```"},{"i":"74","k":["ffmpeg","video"],"c":"2022-04-09","m":"2026-07-27","b":"```shell\nextractFrames() {\n  ffmpeg -i $1 -vf fps=30 output_frame_%d.png\n}\n\n# $1 = frames로 사용될 파일들\n# $2 = output 파일\n# Example: concatFrames ./frame_%5d.png output.webm\nconcatFrames() {\n  ffmpeg -framerate 30 -i $1 -c:v libvpx-vp9 -pix_fmt yuva420p $2\n}\n\n# $1 = frame로 사용될 파일\n# $2 = output 파일\n# Example: frameToVideo frame.jpg output.mp4\nframeToVideo() {\n  ffmpeg -loop 1 -i $1 -c:v libx264 -t 10 -pix_fmt yuv420p $2\n}\n\n# $1 = 인코딩할 영상 video.mp4\n# $2 = output 파일명\nencodingVideo() {\n  ffmpeg -an -i $1 -vcodec libx264 -pix_fmt yuv420p -profile:v baseline -level 3 \"${$2}.mp4\"\n  ffmpeg -i \"${$2}.mp4\" -vcodec libvpx-vp9 -b:v 1M -acodec libvorbis \"${$2}.webm\"\n}\n```\n\n---\n\n- [Encoding Video\n](https://gist.github.com/glen-cheney/278e13915894821e1d6f)"},{"i":"72","k":["local"],"c":"2022-04-09","m":"2024-03-22","b":"서버/프론트 구분이 없는 환경일 경우 옜날에는 이렇게 세팅해서 개발\n\n```js\nconst bs = require('browser-sync').create()\n\nbs.init({\n  port: 8081,\n  https: false,\n  proxy: 'http://localhost:8080',\n  serveStatic: [\n    {\n      route: '/resources',\n      dir: 'src/main/webapp/resources',\n    },\n  ],\n  files: 'src/main/webapp/resources/**/*',\n  open: false,\n})\n```"},{"i":"71","k":["redux","debug"],"c":"2022-04-09","m":"2026-08-02","b":"redux 에서 trace 가 필요하면 redux-devtools 의 [trace 설정](https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/Features/Trace.md)을 켠다. 다만 메모리 릭 가능성이 있어서 상시로 켜두긴 어렵다 — 우회한다면 무식하지만 의심 지점에 [`console.trace()`](https://developer.mozilla.org/ko/docs/Web/API/Console/trace)를 직접 넣는다."},{"i":"70","k":["fetch","service-worker","mock","msw"],"c":"2022-04-09","m":"2024-03-22","b":"서비스워커로 [fetch](https://developer.mozilla.org/ko/docs/Web/API/FetchEvent)를 감지해서 해당 기능을 구현한다는 내용.\n개인적으로는 mock은 간단하게 구현 가능할 것 같은데 이미 같은 기능의 잘 만들어진 라이브러리들이 있으니까 아이디어 정도로 생각하면 될 것 같다.\n\n```js\naddEventListener('fetch', e => { \n  // e.request\n  // e.respondWith\n})\n```\n\n- [How To Run a Proxy Server Inside Your Browser | by Danny Moerkerke | Better Programming](https://betterprogramming.pub/how-to-run-a-proxy-server-inside-your-browser-8b96ea2ef1ea)\n- [MSW – Seamless API mocking library for browser and Node | Mock Service Worker](https://mswjs.io/)"},{"i":"69","k":["local","charles","debug"],"c":"2022-04-09","m":"2024-03-22","b":"charles\n\n- [The Android Emulator and Charles Proxy: A Love Story | by Mark Dappollone | Medium](https://medium.com/@daptronic/the-android-emulator-and-charles-proxy-a-love-story-595c23484e02)\n- [Is it possible to rewrite a status code with Charles Proxy? - Stack Overflow](https://stackoverflow.com/questions/14360125/is-it-possible-to-rewrite-a-status-code-with-charles-proxy)\n\nfiddler\n\n- [Web Performance Calendar » Fiddler AutoResponder](https://calendar.perfplanet.com/2019/fiddler-autoresponder/)[^69-1]\n\nmitmproxy\n\n- [mitmproxy로 iOS 기기의 네트워크 트래픽 살펴보기 :: Outsider's Dev Story](https://blog.outsider.ne.kr/1514)\n- [Android nougat 이상 emulator에서 mitmproxy 사용하기 | by Jungwook Park | kjcoop | Medium](https://medium.com/kjcoop/android-nougat-%EC%9D%B4%EC%83%81-emulator%EC%97%90%EC%84%9C-mitmproxy-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-98003008f78d)\n\n---\n\n[^69-1]: AutoResponder기능을 어떻게 활용 가능한지 알려주는 내용. [delay](https://docs.telerik.com/fiddler/knowledge-base/autoresponder#delay) 추가로 응답 시간 조절이 가능하므로 font, script 로딩 시간을 늘려서 시각적으로 확인이 가능하다."},{"i":"68","k":["local","debug","tunneling","localtunnel","ngrok","cloudflared"],"c":"2022-04-09","m":"2025-01-11","b":"[localtunnel](https://github.com/localtunnel/localtunnel)은 쉽게 테스트하고 공유할 수 있도록 로컬 호스트를 공개합니다! 다른 사람들이 변경 사항을 테스트하도록 하기 위해 DNS를 엉망으로 만들거나 배포할 필요가 없습니다.\n\n```js\napp.listen(PORT, async () => {\n  const tunnel = await localtunnel({\n    port: PORT,\n    subdomain: name,\n  })\n```\n\n> 하지만 너무 느려서 ngrok 쓰는게 현실적일수도 있겠다. -20220917\n\n- [awesome-tunneling](https://github.com/anderspitman/awesome-tunneling)\n- [cloudflared](https://github.com/cloudflare/cloudflared)\n- [ngrok](https://ngrok.com/)\n- [localxpose](https://localxpose.io/)\n\n---\n\n- [How to test webhooks on localhost](https://liveblocks.io/docs/guides/how-to-test-webhooks-on-localhost)[^68-1]\n\n---\n\n[^68-1]: 로컬 환경에서 웹훅 테스트를 위해, API 엔드포인트를 만들고 **localtunnel**이나 **ngrok**을 사용하기."},{"i":"66","k":["node","monorepo","yarn"],"c":"2022-04-09","m":"2024-03-22","b":"- [Monorepo Explained](https://monorepo.tools/)\n- [Monorepos are changing how teams build software – Vercel](https://vercel.com/blog/monorepos-are-changing-how-teams-build-software)\n\n```sh\n# 특정 패키지에 모듈을 설치해야한다면\nyarn workspace <workspace_name> <command>\n```"},{"i":"65","k":["node","npm","package"],"c":"2022-04-09","m":"2024-03-22","b":"```json\n// package.json 파일에 로컬 경로를 지정하는 방법. 파일 시스템에 있는 패키지 디렉터리를 사용할 수 있음.\n\n{\n  \"dependencies\": {\n    \"bar\": \"file:../foo/bar\"\n  }\n}\n```\n\n---\n\n- [package.json | npm Docs](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#local-paths)"},{"i":"64","k":["node","xlsx","excel"],"c":"2022-04-09","m":"2024-03-22","b":"CSV와는 다르다! CSV와는!\n\n```js\nconst fs = require('fs')\nconst XLSX = require('xlsx')\n\nconst buf = fs.readFileSync('developer_labels_20181221.xlsx')\nconst workbook = XLSX.read(buf, { type: 'buffer' })\n\nconst roa = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], {\n  header: 1,\n})\n\nconst result = roa\n  .slice(1)\n  .map((item) => item.slice(1, 4))\n  .filter((item) => item.some(Boolean))\n  .reduce(\n    (p, n) => {\n      const [key, en, ko] = n\n      const newKey = key.toLowerCase().replace(/\\-/g, '_')\n\n      p['en'][newKey] = en\n      p['ko'][newKey] = ko\n\n      return p\n    },\n    { en: {}, ko: {} }\n  )\n\nfs.writeFileSync('en.json', JSON.stringify(result.en), 'utf8')\nfs.writeFileSync('ko.json', JSON.stringify(result.ko), 'utf8')\n```\n\n---\n\n- [sheetjs](https://docs.sheetjs.com/docs/)"},{"i":"63","k":["node","image","imagemin"],"c":"2022-04-09","m":"2026-07-27","b":"- 이미지 압축 기본 설정값 참조: mozjpeg(75), pngquant([75, 85])[^63-1]\n\n---\n\n[^63-1]: https://github.com/antonreshetov/image-optimizer/blob/master/src/main/store/module/app.ts#L33-L53"},{"i":"62","k":["node","yarn","package"],"c":"2022-04-09","m":"2024-03-22","b":"- [yarn upgrade-interactive | Yarn](https://classic.yarnpkg.com/lang/en/docs/cli/upgrade-interactive/#toc-yarn-upgrade-interactive)[^62-1]\n- [Find newer versions of package dependencies than what your package.json allows](https://github.com/raineorshine/npm-check-updates)[^62-2]\n\n---\n\n[^62-1]: 이 명령은 업그레이드를 수행하기 전에 오래된 패키지를 표시하여 사용자가 업그레이드할 패키지를 선택할 수 있도록 합니다.\n[^62-2]: [npm-check-updates](https://github.com/raineorshine/npm-check-updates)는 지정된 버전을 무시하고 [package.json](https://docs.npmjs.com/files/package.json/) 종속성을 최신 버전으로 업그레이드합니다 ."},{"i":"60","k":["node","sharp","image"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nimport sharp from 'sharp'\nimport fg from 'fast-glob'\n\nconst entries = await fg('./**/*.png')\n\nfor (const entry of entries) {\n  const trimmedBuffer = await sharp(entry).trim().toBuffer()\n  const trimmedImage = sharp(trimmedBuffer)\n  const trimmedMetadata = await trimmedImage.metadata()\n\n  trimmedImage\n    .resize({\n      width: Math.round(trimmedMetadata.width / 2),\n      height: Math.round(trimmedMetadata.height / 2),\n    })\n    .png()\n    .toFile('output.png')\n}\n```\n\noffset(top, left)값, 이미지 사이즈 설정으로 crop 구현이 가능하다\n\n```js\nsharp('img.png')\n  .extract({\n    left: 50,\n    top: 50, \n    width: 200, \n    height: 400\n  })\n```\n\n---\n\n- [resize](https://sharp.pixelplumbing.com/api-resize#resize)\n- [extract](https://sharp.pixelplumbing.com/api-resize#extract)"},{"i":"59","k":["node","buffer","fs","fetch"],"c":"2022-04-09","m":"2024-03-22","b":"이미지 다운로드 구현. 이미지 응답값을 buffer로 변환해서 파일쓰기로 저장한다.\n\n```js\nconst fs = require('fs')\nconst util = require('util')\nconst fetch = require('node-fetch')\n\nconst writeFile = util.promisify(fs.writeFile)\nconst mkdir = util.promisify(fs.mkdir)\n\nconst FOLDER_PATH = 'FOLDER_PATH'\n\nasync function download({ url }) {\n  const response = await fetch(url)\n  const buffer = Buffer.from(await response.arrayBuffer())\n\n  if (!fs.existsSync(FOLDER_PATH)) {\n    await mkdir(FOLDER_PATH)\n  }\n\n  await writeFile(url, buffer)\n}\n```"},{"i":"58","k":["esm","node"],"c":"2022-04-09","m":"2026-07-27","b":"[imagemin](https://github.com/imagemin/imagemin)을 사용하려고 하는데 [module](https://nodejs.org/api/esm.html)로 라이브러리가 업데이트 되어서 찾아본 내용. 혼란스러운 부분도 있지만 성숙해지는 과정이라고 본다.\n\n- [Pure ESM package · GitHub](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)\n- [Publish ESM and CJS in a single package](https://antfu.me/posts/publish-esm-and-cjs)[^58-1]\n\n---\n\n[^58-1]: [esm](https://nodejs.org/api/esm.html#modules-ecmascript-modules) 배경과 [tsup](https://tsup.egoist.dev/), [unbuild](https://github.com/unjs/unbuild) 라는 두개의 해결방법을 소개해주고 있다."},{"i":"57","k":["node"],"c":"2022-04-09","m":"2026-07-27","b":"import QuoteLink from '@components/QuoteLink.astro'\n\n- [Node.js Child Processes: Everything you need to know](https://www.freecodecamp.org/news/node-js-child-processes-everything-you-need-to-know-e69498fe970a/)[^57-1]\n- [The definitive Node.js handbook](https://www.freecodecamp.org/news/the-definitive-node-js-handbook-6912378afc6e/)\n- [Tao of Node - Design, Architecture & Best Practices](https://alexkondov.com/tao-of-node/)\n- [Useful Built-in Node.js APIs](https://www.sitepoint.com/useful-built-in-node-js-apis/)\n\n---\n\n<QuoteLink url=\"https://github.com/sindresorhus/execa\">Execa</QuoteLink>\n\n- [Execa 9: our biggest release](https://medium.com/@ehmicky/execa-9-release-d0d5daaa097f)\n\n---\n\n<QuoteLink url=\"https://github.com/ds300/patch-package\">\n  patch-package\n</QuoteLink>\n\n- [GitHub - ds300/patch-package: Fix broken node modules instantly 🏃🏽‍♀️💨](https://github.com/ds300/patch-package)\n\n---\n\n<QuoteLink url=\"https://pm2.keymetrics.io/\">PM2</QuoteLink>\n\n- [[NODE] 📚 PM2 모듈 사용법 - 클러스터 / 무중단 서비스](https://inpa.tistory.com/entry/node-%F0%9F%93%9A-PM2-%EB%AA%A8%EB%93%88-%EC%82%AC%EC%9A%A9%EB%B2%95-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EC%84%9C%EB%B9%84%EC%8A%A4)\n- [PM2를 활용한 Node.js 무중단 서비스하기](https://engineering.linecorp.com/ko/blog/pm2-nodejs)\n\n---\n\n`glob`\n\n- [GitHub - isaacs/node-glob](https://github.com/isaacs/node-glob)\n- [GitHub - mrmlnc/fast-glob](https://github.com/mrmlnc/fast-glob)\n- [GitHub - sindresorhus/globby](https://github.com/sindresorhus/globby)\n- [GitHub - SuperchupuDev/tinyglobby](https://github.com/SuperchupuDev/tinyglobby)\n\n---\n\n[^57-1]: spawn(), exec(), execFile(), fork()"},{"i":"56","k":["promise","javascript"],"c":"2022-04-09","m":"2026-07-27","b":"reject는 `Error` 객체로 한다 — 문자열로 거부하면 스택 트레이스가 남지 않는다.[^56-1]\n\n```js\n// ❌\nPromise.reject('An error occurred');\n\n// ✅\nPromise.reject(new Error('An error occurred'));\n```\n\n---\n\n[^56-1]: [14 Linting Rules To Help You Write Asynchronous Code in JavaScript - Maxim Orlov](https://maximorlov.com/linting-rules-for-asynchronous-code-in-javascript/)"},{"i":"55","k":["fetch","formdata"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nconst formData = new FormData();\nconst single = document.querySelector('input[type=\"file\"]');\nconst multiple = document.querySelector('input[type=\"file\"][multiple]');\n\nformData.append('single', single.files[0]);\n\n[...multiple].forEach((file, index) => {\n  formData.append(`multiple_${index}`, file);\n})\n\nfetch('https://example.com/posts', {\n  method: 'POST',\n  body: formData,\n})\n```"},{"i":"53","k":["fetch","preflight","simple-requests"],"c":"2022-04-09","m":"2024-03-22","b":"- [조건](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests)에 따라서 브라우저에서 자동으로 발생[^53-1]\n- OPTIONS 메서드로 요청\n- cloudfront에 OPTIONS 메서드 설정이 안되어 있다면 실패[^53-2]\n\n---\n\n[^53-1]: [단순 요청(Simple requests)](https://developer.mozilla.org/ko/docs/Web/HTTP/CORS#%EB%8B%A8%EC%88%9C_%EC%9A%94%EC%B2%ADsimple_requests)\n[^53-2]: [CloudFront 배포의 캐시 동작이 HTTP 요청에 대한 OPTIONS 메서드를 허용함](https://aws.amazon.com/ko/premiumsupport/knowledge-center/no-access-control-allow-origin-error/)"},{"i":"52","k":["fetch","onprogress"],"c":"2022-04-09","m":"2024-03-22","b":"- [XMLHttpRequestEventTarget.onprogress](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestEventTarget/onprogress)[^52-1]\n- [ReadableStream - Web API | MDN](https://developer.mozilla.org/ko/docs/Web/API/ReadableStream)[^52-2]\n- [Request Config - onUploadProgress](https://github.com/axios/axios#request-config)[^52-3]\n\n---\n\n[^52-1]: XMLHttpRequest가 완전히 성공할때까지 정보를 주기적으로 호출하는 함수\n[^52-2]: `fetch` 에서는  `ReadableStream`, `Response` 조합으로 구현 가능\n[^52-3]: axios 에서는 `onUploadProgress` 콜백(`progressEvent`)으로 구현"},{"i":"51","k":["tailwindcss","create-react-app"],"c":"2022-04-09","m":"2024-03-22","b":"```json\n{\n  \"tailwindcss\": \"tailwindcss -i ./src/index.css -o ./src/tailwind.css\",\n  \"start\": \"concurrently \\\"yarn tailwindcss --watch\\\"\",\n  \"prebuild\": \"yarn tailwindcss --minify\",\n}\n```\n\ncreate-react-app 구형버젼(+eject)에서 설치할 경우 연관된 부분이 많아서 차라리 cli를 사용하는게 편한 상황. 그런데 [`concurrently`](https://github.com/open-cli-tools/concurrently#readme)[^51-1]로 프로세스를 동시에 실행시켜야 되는 부분이 있다.\n\n---\n\n[^51-1]: https://tailwindcss.com/docs/tailwind-cli"},{"i":"49","k":["next","static"],"c":"2022-04-09","m":"2024-03-22","b":"[Advanced Features: Static HTML Export | Next.js](https://nextjs.org/docs/advanced-features/static-html-export)\n\n```shell\nnext export\n```\n\n앱의 HTML 버전을 빌드. `.out` 디렉토리에 빌드된 페이지 파일을 복사합니다."},{"i":"48","k":["react","next","monorepo","hygen","husky","dependabot"],"c":"2022-04-09","m":"2026-08-19","b":"[How To Maintain A Large Next.js Application — Smashing Magazine](https://www.smashingmagazine.com/2021/11/maintain-large-nextjs-application/)\n\n- TypeScript 사용\n- Lerna , Nx , Rush , Turborepo , yarn workspaces를 사용하여 Mono-Repo 구조 사용\n- Hygen과 같은 코드 생성기를 사용하여 상용구 코드 생성\n- Redux 툴킷을 통해 하위 상용구와 함께 Redux와 같이 잘 설정된 패턴 사용\n- 비동기 데이터를 가져오기 위해 React 쿼리 또는 SWR 사용\n- Husky와 함께 Commitizen 및 Semantic Release 사용\n- UI 구성 요소 시각화를 위해 스토리북 사용\n- 처음부터 유지 관리 가능한 테스트 작성\n- Dependabot을 사용하여 자동으로 패키지 업데이트\n- [Production Checklist | Next.js](https://nextjs.org/docs/app/guides/production-checklist)"},{"i":"47","k":["state","ui"],"c":"2022-04-09","m":"2024-03-22","b":"[No, disabling a button is not app logic. - DEV Community](https://dev.to/davidkpiano/no-disabling-a-button-is-not-app-logic-598i)\n\n```md\n- \"idle\" 아무 것도 아직 일어나지 않았다.\n- \"loading\" 진행중\n- \"success\" 성공적\n- \"failure\" 오류가 발생했음\n```"},{"i":"46","k":["composition","react"],"c":"2022-04-09","m":"2026-07-27","b":"- [Using Composition in React to Avoid \"Prop Drilling\"](https://youtu.be/3XaXKiXtNjw)[^46-1]\n- [Merrick Christensen - Headless User Interface Components](https://www.merrickchristensen.com/articles/headless-user-interface-components/)[^46-2]\n- [Headless Component: a pattern for composing React UIs](https://martinfowler.com/articles/headless-component.html)\n\n---\n\n[^46-1]: React에서 컴포지션의 개념과 \"Prop Drilling\"을 피하기 위해 React에서 사용하는 방법을 설명.\n[^46-2]: Headless UI 컴포넌트의 개념을 소개. 헤드리스 UI 컴포넌트가 무엇인지, 기존 UI 컴포넌트와 어떻게 다른지 설명하고, 다양한 프로그래밍 언어와 프레임워크에서 헤드리스 UI 컴포넌트를 구현하는 방법에 대한 예제."},{"i":"38","k":["optimize"],"c":"2022-04-09","m":"2024-03-22","b":"[google-optimize](https://github.com/jaredLunde/react-hook/tree/master/packages/google-optimize)\n\n- [#L19-L24](https://github.com/jaredLunde/react-hook/blob/master/packages/google-optimize/src/index.tsx#L19-L24)[^38-1]\n- [#L46](https://github.com/jaredLunde/react-hook/blob/master/packages/google-optimize/src/index.tsx#L46)[^38-2]\n- [#L8](https://github.com/jaredLunde/react-hook/blob/master/packages/google-optimize/src/index.tsx#L8)[^38-3]\n\n---\n\n[^38-1]: `setTimeout`, `clearTimeout` 으로 최적화\n[^38-2]: `gtag` 에서도 `remove: true`값 전달로 해제\n[^38-3]: `variants`를 컴포넌트로 전달 받아서 리턴"},{"i":"36","k":["react","use-reducer"],"c":"2022-04-09","m":"2024-03-22","b":"```js\nfunction toggleReducer(state, action) {\n  switch (action.type) {\n    default:\n      return state\n  }\n}\n\nfunction useToggle({ reducer = toggleReducer } = {}) {\n  const [state, dispatch] = useReducer(reducer, {})\n  return { state, dispatch }\n}\n\nexport function Component() {\n  useToggle({\n    reducer(currentState, action) {\n      console.log(currentState, action)\n    },\n  })\n}\n```\n\n`useReducer`를 이용한 커스텀훅 사용에 대한 간단한 예시. 생각해보니 reducer를 전달해서 재사용하는 방법은 잘 생각못했는데 응용할 수 있을 것 같다.\n\n---\n\n- [The State Reducer Pattern with React Hooks](https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks)[^36-1]\n\n---\n\n[^36-1]: 리듀서를 사용하여 예측 가능하고 테스트 가능한 방식으로 상태 업데이트 및 작업을 캡슐화하는 방법과 State Reducer 패턴을 사용하여 이를 사용하는 구성 요소에서 상태 업데이트를 추상화하여 해당 구성 요소가 특정 기능에 더 집중하도록 만드는 방법을 설명."},{"i":"30","k":["gsap","draggable","360","ui"],"c":"2022-04-09","m":"2024-03-22","b":"360 이미지를 드래그해서 회전시키는 기능을 개발할때 3d 리소스를 제공 받을 수 있다면 three.js 같은 라이브러리를 사용해서 쉽게(?) 구현이 가능하다. three.js가 초반 진입 장벽이 높은 것 같지만 단순히 모델 리소스를 보여주는 정도는 배경지식이나 기본지식이 없더라도 어느정도 예제 코드들을 본다면 구현이 가능하다고 본다. 물론 제대로 하고 싶다면 배경지식과 three.js 자체에 대한 학습이 필요.\n\n그런데 부득이하게 여러장으로 된 이미지만 제공 받을 수 있다면 직접 구현해야한다. 그런데 생각보다 드래그 기능을 처음부터 구현한다는게 보통의 개발자들에게는 불편한게 사실이므로 이미 존재하는 플러그인이나 라이브러리를 사용하는게 현실적이다.\n\n그래서 찾아본 gsap의 [Draggable](https://greensock.com/draggable/). 그런데 문서를 보면 다이얼 같이 (뭐라 표현하는게 적당한지 모르겠지만) 직접적으로 드래그 하는 관점의 설명들이 대부분이다. 그런데 360 이미지 같은 경우는 직접적으로 드래그 라기보다는 액션을 빌린 동작이어서 처음에 약간 혼란스러웠는데 좀 더 찾아보니 proxy의 개념을 빌려서 설명된 부분이 있었고 저런식으로 하면 쉽게 [구현](https://codepen.io/eunsoolee/pen/BaZwxoO)이 가능하다.\n\n참고\n\n- [Advanced tutorial: CubeDial, a 3D Carousel made with GSAP - Blog - GreenSock](https://greensock.com/cube-dial-tutorial)\n- https://codepen.io/GreenSock/pen/nHpec/\n- https://codepen.io/GreenSock/pen/LVEXrB\n- https://codepen.io/jamiejefferson/pen/kFhKE/\n\n반대 방향으로 진행시 계산법\n\n- https://codepen.io/GreenSock/pen/b99ec2f21dcd53ecd19c004744b0b6c1"},{"i":"29","k":["calendar","date"],"c":"2022-11-05","m":"2024-03-22","b":"```js\nimport {\n  addDays,\n  addMonths,\n  eachDayOfInterval,\n  eachWeekOfInterval,\n  startOfMonth,\n} from 'date-fns'\n\nconst startOfMonthDate = startOfMonth(new Date())\nconst matrix = eachWeekOfInterval({\n  start: startOfMonthDate,\n  end: addMonths(startOfMonthDate, 1),\n}).map((weekDay) => {\n  const startDate = new Date(weekDay)\n\n  return eachDayOfInterval({\n    start: startDate,\n    end: addDays(startDate, 6),\n  })\n})\n```\n\n- matrix 구현\n  - [bclinkinbeard/calendar-matrix](https://github.com/bclinkinbeard/calendar-matrix)\n  - [Calendar Matrix (date-fns, ES6)](https://gist.github.com/miljan-aleksic/bd70452a3f0cd6a11545db9f6ab57df6)\n- 튜토리얼\n  - [How to Make a Monthly Calendar With Real Data | CSS-Tricks](https://css-tricks.com/how-to-make-a-monthly-calendar-with-real-data)\n  - [Let's Make a Vue-Powered Monthly Calendar | CSS-Tricks](https://css-tricks.com/lets-make-a-vue-powered-monthly-calendar)"},{"i":"28","k":["calendar","ui","date"],"c":"2022-04-09","m":"2026-09-01","b":"**어느 캘린더 라이브러리를 쓸까.**\n\n| 상황                   | 추천                   |\n| ---------------------- | ---------------------- |\n| 완전 커스텀 UI 필요    | react-aria + 직접 구현 |\n| 빠르게 기본 기능 필요  | react-big-calendar     |\n| 모던한 DX, 가벼움 중시 | Schedule-X             |\n| 엔터프라이즈급 기능    | FullCalendar           |\n\n- 컴포넌트[^28-2]\n  - calendar\n    - [react-calendar](https://github.com/wojtekmaj/react-calendar/tree/fe83e095f0b744ddaf09bb909bf15d9e45054809)\n    - [react-big-calendar](https://github.com/jquense/react-big-calendar)\n    - [fullcalendar](https://github.com/fullcalendar/fullcalendar)\n    - [schedule-x](https://github.com/schedule-x/schedule-x)\n  - date-picker\n    - [gpbl/react-day-picker](https://github.com/gpbl/react-day-picker)\n    - [dayzed](https://github.com/deseretdigital/dayzed)\n    - [react-datepicker](https://github.com/Hacker0x01/react-datepicker/)\n  - headless\n    - [use-lilius](https://github.com/its-danny/use-lilius)\n    - [h6s](https://github.com/h6s-dev/h6s/tree/main/packages/calendar)\n- 그외\n  - [you-dont-need/You-Dont-Need-Momentjs](https://github.com/you-dont-need/You-Dont-Need-Momentjs)[^28-1]\n\n**라이브러리 특징:**\n\n- **react-aria** - Adobe Spectrum 팀의 headless. 스타일 없이 로직과 접근성만. `useCalendar`, `useDatePicker` 훅 기반. ARIA 구현 꼼꼼. 일정 표시는 직접 구현.\n- **Schedule-X** - 최신. React/Vue/Angular 어댑터. 드래그/리사이즈 지원. 월/주/일 뷰. 가벼움. 레퍼런스 적음.\n- **FullCalendar** - 가장 오래됨. 플러그인 아키텍처. 타임라인, 리소스 뷰 등 고급 기능. 일부 유료, 번들 큼.\n- **react-big-calendar** - 순수 React. Google Calendar 스타일. moment/date-fns/dayjs 선택 가능. 타입 지원 아쉬움.\n\n---\n\n[^28-1]: 네이티브로 날짜 계산(?)을 구현하려면 참고\n[^28-2]: headless 모드가 있으면 좋을 것 같은데 찾기 어렵다. 이런게 있다...정도로만 생각하자. 실제 갖다 써보면 뭔말인지 알 수 있을거다."},{"i":"27","k":["video","ffmpeg","canvas"],"c":"2022-04-09","m":"2025-01-12","b":"- [How to use transparent videos on the web in 2021 - Rotato](https://www.rotato.app/read/transparent-videos-for-the-web)\n- [How to make HEVC, H265 and VP9 videos with an alpha channel for the web | Kit Cross](https://kitcross.net/hevc-web-video-alpha-channel/)\n- [Alpha Masking with FFMPEG | Curio Museum](https://curiosalon.github.io/blog/ffmpeg-alpha-masking/)[^27-1]\n\n```sh\nffmpeg -framerate 25 -i image_%1d.png -c:v libvpx-vp9 -pix_fmt yuva420p output.webm\n```\n\n단점\n\n- 두벌로 인코딩 작업을 해야한다. MacOS가 아닐경우 번거로운 부분이 존재한다.\n- 브라우저 지원이 애매하게 걸친 부분이 존재한다.\n- (개인적인 느낌) 사이즈가 커졌을 경우 프레임 드랍이 있다.\n\n...그래서 어차피 안되는거 새로운 도전을 해보고 싶어서 크로마키 효과를 떠올렸다. sharp로 이미지 배경을 green 컬러로 채우고 그 이미지들을 합쳐서 동영상으로 변환. 그리고 canvas에 그리고 색상을 추출해서 green값을 alpha값으로 변환하면 완벽하지 않을까 싶었는데 겹치는 영역을 전혀 생각못했다. 이부분은 뭔가 특정 알고리즘이 있는 것 같은데 그냥 단순히 근사치[^27-5]로 적용했을때 결과물이 완벽하지는 않다.\n\n- [캔버스(canvas)를 이용한 비디오 조작하기 - Web API | MDN](https://developer.mozilla.org/ko/docs/Web/API/Canvas_API/Manipulating_video_using_canvas)[^27-2]\n- [Green Screen in the Browser With HTML Canvas](https://www.kapwing.com/blog/green-screen-in-browser/)[^27-3]\n- [Canvas: Do cool stuff with video in the browser | Mux blog](https://mux.com/blog/canvas-adding-filters-and-more-to-video-using-just-a-browser/)[^27-4]\n\n---\n\n[^27-1]: 여기서는 좀 더 나아가서 ffmpeg으로 드로잉 하는 부분까지 알려주고 있다.\n[^27-2]: 기본 배경지식을 설명하고 있다. video->canvas\n[^27-3]: hsv로 색상값을 체크하는 방법을 알려주고 있다. 그리고 해당 연산은 worker로 따로 분리해서 처리.\n[^27-4]: canvas 에서 색상값을 체크해서 필터 기능을 구현하고 머신러닝으로 모션탐지 기능구현이 가능하다는 예제를 보여준다.\n[^27-5]: [javascript - How to accurately filter RGB value for chroma-key effect - Stack Overflow](https://stackoverflow.com/questions/38419980/how-to-accurately-filter-rgb-value-for-chroma-key-effect)"},{"i":"24","k":["globe","webgl"],"c":"2022-04-09","m":"2026-07-27","b":"- [To design and develop an interactive globe](https://stripe.com/blog/globe)[^24-1]\n- [How we built the GitHub globe | The GitHub Blog](https://github.blog/2020-12-21-how-we-built-the-github-globe/)[^24-2]\n- [Gatsby Serverless Functions And The International Space Station — Smashing Magazine](https://www.smashingmagazine.com/2021/07/gatsby-serverless-functions-international-space-station/)[^24-3]\n- [GitHub - shuding/cobe: 5kB WebGL globe lib.](https://github.com/shuding/cobe)\n\n---\n\n[^24-1]: WebGL + [Three.js](https://threejs.org/), 데이터는 [Mapbox](https://www.mapbox.com/)\n[^24-2]: WebGL + [d3-geo](https://github.com/d3/d3-geo)\n[^24-3]: ISS 위치 추적 — Three.js + [satellite.js](https://github.com/shashwatak/satellite-js)"},{"i":"23","k":["raspberry-pi"],"c":"2022-04-09","m":"2026-07-27","b":"- [Raspberry Pi 'WeatherClock' shows you the hour's forecast - Raspberry Pi](https://www.raspberrypi.org/blog/raspberry-pi-weatherclock-shows-you-the-hours-forecast/)[^23-1]\n- [Show Dev: RaspberryPi Weather ⛈ fuelled by Netlify functions and Preact - DEV Community](https://dev.to/moigonzalez/show-dev-raspberrypi-weather-fuelled-by-netlify-functions-and-preact-bne)[^23-2]\n- https://weather-mu.vercel.app/\n\n---\n\n[^23-1]: Raspberry Pi Zero W + 1.3인치 OLED, [OpenWeatherMap API](https://openweathermap.org/api) 를 Python 스크립트로\n[^23-2]: [Preact](https://preactjs.com/) + [Netlify Functions](https://www.netlify.com/products/functions/)"},{"i":"22","k":["throttle","debounce","lodash","event","ui"],"c":"2022-04-09","m":"2024-03-22","b":"[Debouncing and Throttling Explained Through Examples | CSS-Tricks](https://css-tricks.com/debouncing-throttling-explained-examples/)[^22-1]\n\n- throttle: 함수가 호출되는 속도를 제한 (일정시간 동안)\n- debounce: 추가 호출 없이 일정 시간이 경과할 때까지 함수 실행을 지연하거나 제한 (마지막 이벤트만)\n\n---\n\n[^22-1]: 검색 입력, 스크롤 이벤트, 크기 조정 이벤트 등 디바운싱과 스로틀링이 유용할 수 있는 사례들"},{"i":"21","k":["ui","builder","dnd"],"c":"2022-04-09","m":"2026-07-27","b":"- [Builder.io - Drag and drop experience builder - Builder.io](https://www.builder.io/)[^21-1]\n- [Blocks UI](https://blocks-ui.com/)[^21-2]\n- [Craft.js](https://craft.js.org/)[^21-3]\n- [Pagedraw — Effortlessly turn mockups into functional UI code](https://pagedraw.io/)[^21-4]\n- [OpenChakra](https://openchakra.app/)[^21-5]\n- [GitHub - chriskitson/react-drag-drop-layout-builder: Drag and drop (DnD) UI layout builder using React and ImmutableJS](https://github.com/chriskitson/react-drag-drop-layout-builder)[^21-6]\n- [Unlayer Embed Plugin | Email and Page Editor for SaaS](https://unlayer.com/embed)\n- [Puck](https://puckeditor.com/)\n\n---\n\n[^21-1]: 랜딩 페이지, 마케팅 사이트, 전자상거래 스토어를 만들 수 있는 드래그 앤 드롭 빌더\n[^21-2]: 직관적인 UI로 복잡한 반응형 레이아웃, 프로토타입 및 랜딩 페이지를 제작\n[^21-3]: React 기반 비주얼 에디터\n[^21-4]: 반응형 디자인을 만들고 자동으로 코드를 생성\n[^21-5]: 실시간 피드백을 통해 UI 컴포넌트를 빌드할 수 있는 React 기반 비주얼 에디터\n[^21-6]: React 및 ImmutableJS를 사용하여 반응형 UI 레이아웃을 만들기 위한 오픈 소스 드래그 앤 드롭 레이아웃 빌더입니다."},{"i":"19","k":["ui","calculator","math"],"c":"2022-04-09","m":"2026-07-27","b":"자연어로 계산을 쓰는 계산기들.\n\n- [The creation of Soulver. How two teenagers made a new kind of… | by Zac Cohan | Soulver | Medium](https://medium.com/soulver/the-creation-of-soulver-454dee1e2fd1)\n- [Designing calculator apps](https://fvsch.com/calculators)\n- [Numi. Beautiful calculator app for Mac.](https://numi.app/)\n- [Parsify Desktop](https://parsify.app/)\n- [Math Notepad](https://mathnotepad.com/)[^19-5]\n\n---\n\n[^19-5]: [math.js](https://mathjs.org/)"},{"i":"15","k":["typescript","function"],"c":"2022-04-09","m":"2026-07-27","b":"```ts\ninterface MathFn {\n  (a: number, b: number): number\n}\nconst sum: MathFn = (a, b) => a + b\n```\n\n- [TypeScript Function Syntaxes](https://kentcdodds.com/blog/typescript-function-syntaxes) [(번역)](https://ui.toast.com/weekly-pick/ko_20210521)[^15-1]\n- [Typing functions in TypeScript](https://2ality.com/2020/04/typing-functions-typescript.html)[^15-2]\n\n---\n\n[^15-1]: function declarations, function expressions, arrow functions, methods등 TypeScript에서 함수를 선언하는 다양한 방법들.\n[^15-2]: TypeScript에서 함수를 입력하는 방법에 대한 종합적인 가이드"},{"i":"11","k":["javascript","date","temporal","timezone"],"c":"2022-04-09","m":"2024-03-22","b":"- [Temporal documentation](https://tc39.es/proposal-temporal/docs/)[^11-1]\n  - [Temporal Cookbook](https://tc39.es/proposal-temporal/docs/cookbook.html)\n- [Is It Time for the JavaScript Temporal API?](https://blog.openreplay.com/is-it-time-for-the-javascript-temporal-api)[^11-2]\n- [JS Dates Are About to Be Fixed | TimeTime](https://docs.timetime.in/blog/js-dates-finally-fixed/)\n- [Using Intl.RelativeTimeFormat for Localized Relative Timings](https://www.raymondcamden.com/2024/03/07/using-intlrelativetimeformat-for-localized-relative-timings)\n- [Everything You Need to Know About Date in JavaScript | CSS-Tricks](https://css-tricks.com/everything-you-need-to-know-about-date-in-javascript/)[^11-3]\n- [자바스크립트에서 타임존 다루기 (1) : NHN Cloud Meetup](https://meetup.toast.com/posts/125)[^11-4]\n- [자바스크립트에서 타임존 다루기 (2) : NHN Cloud Meetup](https://meetup.toast.com/posts/130)\n\n---\n\n[^11-1]: 날짜 및 시간 작업을 위한 Temporal API 공식 문서\n[^11-2]: 날짜 및 시간을 사용하여 작업할 때의 문제를 논의하고 솔루션으로 Temporal API를 제안하는 블로그 게시물\n[^11-3]: Date 개체, 날짜 형식, 시간대 및 날짜 라이브러리를 포함하여 JavaScript에서 날짜 작업에 대한 포괄적인 설명\n[^11-4]: 타임존 작업의 기본 사항을 설명"},{"i":"8","k":["javascript","array"],"c":"2022-04-09","m":"2024-03-22","b":"[Quick tip: reusable Array search predicates - JASON Format](https://jasonformat.com/reusable-array-search-predicates/)\n\n```js\narr.filter(callback(element[, index[, array]])[, thisArg])\n```\n\n배열 메서드에서 2번째 인자 `thisArg`에 참조값을 전달해서 재사용 가능한 함수를 만드는 트릭. 단 성능 이슈가 있으므로 주의해야 한다."},{"i":"7","k":["plugin","javascript"],"c":"2022-04-09","m":"2024-03-22","b":"[Designing a JavaScript Plugin System | CSS-Tricks](https://css-tricks.com/designing-a-javascript-plugin-system/)[^7-1]\n\n---\n\n[^7-1]: 플러그인은 라이브러리와 프레임워크의 공통 기능이며 개발자가 안전하고 확장 가능한 방식으로 기능을 추가할 수 있도록 한다. 그래서 추가 유지 관리 부담이 없다."},{"i":"6","k":["ast","parser","unified","css","javascript"],"c":"2022-04-09","m":"2024-03-22","b":"- [How to Modify Nodes in an Abstract Syntax Tree | CSS-Tricks](https://css-tricks.com/how-to-modify-nodes-in-an-abstract-syntax-tree/)[^6-1]\n- [AST for JavaScript developers. TL;DR This article is my talk for… | by Bohdan Liashenko | ITNEXT](https://itnext.io/ast-for-javascript-developers-3e79aeb08343)[^6-2]\n- [GitHub - NV/CSSOM: Unmaintained! ⚠️ CSS Object Model implemented in pure JavaScript. Also, a CSS parser.](https://github.com/NV/CSSOM)[^6-3]\n- [GitHub - csstree/csstree: A tool set for CSS including fast detailed parser, walker, generator and lexer based on W3C specs and browser implementations](https://github.com/csstree/csstree)\n\n---\n\n[^6-1]: markdown을 [unified](https://unifiedjs.com/)를 이용해서 파싱하고 html로 변환. (플러그인 기능을 추가해서 `img`->`figcaption` 기능 추가)\n[^6-2]: AST가 무엇이며 일반 코드에서 어떻게 구축 하는지에 대한 설명과 기반으로 하는 사용 사례와 프로젝트 소개\n[^6-3]: css 데이터 조작이 필요한 [경우](https://yy644.csb.app/)가 있어서 찾아봤다. 예를들어 특정 속성값만 추출해서 유닛값은 제거 한다든가."},{"i":"5","k":["javascript","class"],"c":"2022-04-09","m":"2024-03-22","b":"- [The Complete Guide to JavaScript Classes](https://dmitripavlutin.com/javascript-classes-complete-guide/)[^5-1]\n- [The Flavors of Object-Oriented Programming (in JavaScript) | CSS-Tricks](https://css-tricks.com/the-flavors-of-object-oriented-programming-in-javascript/)[^5-2]\n\n---\n\n[^5-1]: 클래스에 대한 구문, 메서드, 속성, 상속 등에 대한 포괄적인 설명\n[^5-2]: 클래스, 프로토타입 및 팩토리 함수 사용과 같이 객체 지향 프로그래밍을 구현하는 다양한 접근 방식을 설명과 각 접근 방식의 예와 장단점들"},{"i":"4","k":["blob","javascript","file","axios"],"c":"2022-04-09","m":"2024-03-22","b":"- 캔버스로 동영상 프레임을 캡쳐한다. \n- 텍스트를 입력 받는다. 해당 텍스트를 서버에 보내고 음성파일을 응답받는다. \n  - `Blob`데이터는 `URL.createObjectURL()`로 변환해서 `img`, `audio` 태그에 연결한다.\n\n---\n\n- [[JS] Blob와 Blob URL - 코드쓰는사람](https://taegon.kim/archives/5078)\n- [Handle Blobs requests with Axios the right way | by Marios Fakiolas | Medium](https://medium.com/@fakiolinho/handle-blobs-requests-with-axios-the-right-way-bb905bdb1c04)\n- [Blob(블랍) 이해하기 | HEROPY](https://heropy.blog/2019/02/28/blob/)\n- [File API 정리하기. 최근 FileReader 를 사용할 일이 생겨 File API 를 정리… | by 박성룡 ( Andrew park ) | Medium](https://pks2974.medium.com/file-api-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0-729fa6a3a0ba)"},{"i":"1","k":["javascript"],"c":"2022-04-09","m":"2024-03-22","b":"- [Rethinking the JavaScript ternary operator](https://jrsinclair.com/articles/2021/rethinking-the-javascript-ternary-operator/)[^1-1]\n- [Break a forEach Loop with JavaScript](https://davidwalsh.name/javascript-array-tricks-3)[^1-2]\n- [You Can Label a JavaScript `if` Statement | CSS-Tricks](https://css-tricks.com/you-can-label-a-javascript-if-statement/)[^1-3]\n- [Refactoring optional chaining into a large codebase: lessons learned – Lea Verou](https://lea.verou.me/2020/06/refactoring-optional-chaining-into-a-large-codebase-lessons-learned/)[^1-4]\n- [[HTML5] 꼼꼼히 살펴보는 SCRIPT 엘리먼트 - 코드쓰는사람](https://taegon.kim/archives/6804)[^1-6]\n- [Single Page Applications using Rust](https://www.sheshbabu.com/posts/rust-wasm-yew-single-page-application/)[^1-5]\n- [Fast and maintainable patterns for fetching from a database – Sophie Alpert](https://sophiebits.com/2020/01/01/fast-maintainable-db-patterns.html)[^1-7]\n- [Deep-copying in JavaScript using structuredClone](https://web.dev/structured-clone/)[^1-8]\n- [Holistic Review of TC39 \"Dataflow\" Proposals — Tab Completion](https://www.xanthir.com/b5Gd0)[^1-9]\n\n---\n\n[^1-1]: __삼항연산자__(ternary)의 어두운면과 우려를 나타내는 글\n[^1-2]: `forEach` 루프 `break`하는 트릭. 참조하는 배열의 length값을 0으로.\n[^1-3]: 조건문에서 [`label`](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/label)을 지정해서 해당 블록으로 `break`가 가능하다는걸 보여주는 내용. 실제 적용할만한 사례는 거의 없고 글쓴이는 while+switch문에서 break를 좀 더 효율적으로 사용한 예를 보여주고 있다.\n[^1-4]: [optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining) 문법으로 리팩토링 하면서...변경 가능한 부분(삼항연산자, 배열, 기능탐지)과 주의사항(값 할당, 잘못된 위치에 표기), 조심할 부분(null/undefined, 연산 순서와 순위, return 항상 호출됨)으로 구분해서 정리한 글. 커밋을 보면 실제로 어떻게 작업했는지 확인도 가능하다.\n[^1-5]: rust로 작성하고 wasm으로 컴파일해서 클라이언트 프로그램을 작성하는 방법을 자세하게 설명하고 있다. rust에 관심이 있거나 하다면 볼만한 글.\n[^1-6]: `<script />` 태그와 속성값에 대한 정리\n[^1-7]: 종속성 구조를 파악해서 최적의 병렬화로 빠르게 데이터 가져오기\n[^1-8]: `structuredClone()`\n[^1-9]: pipe, flow 제안(초안)사항을 검토하는 관점에서 보는 시각"}]