TypeScript 제네릭 기본값 - <T extends Type = DefaultValue>

type SelectProps<T extends 'single' | 'multiple' = 'multiple'> = {
  mode: T
  value: T extends 'single' ? string : string[]
}

const a: SelectProps = { mode: 'multiple', value: ['a', 'b'] } // 기본값 사용
const b: SelectProps<'single'> = { mode: 'single', value: 'a' } // 명시적 지정

// 여러 매개변수에 각각 기본값
type ApiResponse<
  TData = any,
  TStatus extends 'loading' | 'success' | 'error' = 'loading'
> = {
  data: TStatus extends 'success' ? TData : null
  status: TStatus
}

가장 자주 사용되는 케이스를 기본값으로 설정하면 제네릭을 항상 명시하는 번거로움 줄어듦.

#516

Vitest 모킹: vi.mocked() vs vi.hoisted()

vi.mocked()는 타입만 제공, 실제 모킹 구현체는 별도로 필요.

// ❌ mockImplementation이 undefined
const mockUseSize = vi.mocked(useSize)

// ✅ vi.hoisted() 사용 (추천)
const mockUseSize = vi.hoisted(() => vi.fn())

vi.mock('ahooks', () => ({ useSize: mockUseSize }))

beforeEach(() => {
  mockUseSize.mockImplementation(() => ({ width: 100, height: 20 }))
})

DOM 속성 모킹 (scrollWidth/clientWidth):

beforeEach(() => {
  Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
    configurable: true,
    get() {
      return 150
    },
  })
})

afterEach(() => {
  // 원래 속성 복원
})

DOM 측정이 복잡하면 Playwright/Cypress로 통합 테스트 고려.

#515

ResizeObserver Mock (Vitest, ES Module 환경)

vi.hoisted()로 모킹 함수 미리 선언 후 vi.mock()에서 사용:

import { vi } from 'vitest'

const { mockResizeObserver, MockResizeObserver } = vi.hoisted(() => {
  let observers: { callback: ResizeObserverCallback; observer: any }[] = []

  const MockResizeObserver = vi.fn().mockImplementation((callback) => {
    const observer = {
      observe: vi.fn(),
      unobserve: vi.fn(),
      disconnect: vi.fn(),
    }
    observers.push({ callback, observer })
    return observer
  })

  return {
    MockResizeObserver,
    mockResizeObserver: {
      triggerResize: (entries: ResizeObserverEntry[], index = 0) => {
        observers[index]?.callback(entries, observers[index].observer)
      },
      reset: () => {
        observers = []
      },
    },
  }
})

vi.mock('resize-observer-polyfill', () => ({ default: MockResizeObserver }))

// 테스트에서 사용
beforeEach(() => mockResizeObserver.reset())

it('should handle resize', () => {
  const mockEntry = {
    target: document.createElement('div'),
    contentRect: { width: 100 },
  }
  mockResizeObserver.triggerResize([mockEntry])
})

핵심: ES Module에서는 vi.hoisted() 필수. 여러 observer는 배열로 추적.

#514

dayjs로 특정 날짜가 포함된 주의 모든 날짜 가져오기

import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
dayjs.extend(isoWeek)

// startOf('week') → 일요일 시작
// startOf('isoWeek') → 월요일 시작 (캘린더 UI에 주로 사용)
function getWeekDays(date, iso = true) {
  const start = dayjs(date).startOf(iso ? 'isoWeek' : 'week')
  return Array.from({ length: 7 }, (_, i) =>
    start.add(i, 'day').format('YYYY-MM-DD')
  )
}

getWeekDays('2025-10-28') // ['2025-10-27', ..., '2025-11-02']
#513

JavaScript 부동소수점 오차 - 0.1 + 0.2 = 0.30000000000000004

IEEE 754 64비트 부동소수점 표준. 0.1은 이진법으로 무한 반복(0.0001100110011...)이라 근사치 저장됨.

// 해결책
;(1.1 * 10 + 0.1 * 10) / 10 // 정수 변환
parseFloat((1.1 + 0.1).toFixed(1)) // toFixed
Math.abs(1.1 + 0.1 - 1.2) < Number.EPSILON // 비교 시
// 정밀 계산: decimal.js, big.js, bignumber.js

JS만의 문제 아님. Python, Java, C++ 등 IEEE 754 사용하는 모든 언어에서 동일.

#512

Drizzle ORM에서 IN 절 사용

import { inArray } from 'drizzle-orm'

const authorIds = [1, 2, 3]

// SELECT * FROM authors WHERE id IN (1, 2, 3)
const authors = await db
  .select()
  .from(schema.authors)
  .where(inArray(schema.authors.id, authorIds))

// 빈 배열 처리
const result =
  authorIds.length > 0
    ? await db
        .select()
        .from(schema.authors)
        .where(inArray(schema.authors.id, authorIds))
    : []
#511

캘린더 이벤트 겹침 처리 알고리즘

겹치는 이벤트들을 나란히 배치하기 위한 레인 할당:

interface Event {
  id: string
  start: number
  end: number
}
interface PositionedEvent extends Event {
  lane: number
  totalLanes: number
}

function calculatePositions(events: Event[]): PositionedEvent[] {
  // 1. 시작 시간 순 정렬
  const sorted = [...events].sort((a, b) => a.start - b.start || a.end - b.end)

  // 2. 겹침 그룹 찾기 + 레인 할당
  const result: PositionedEvent[] = []
  let group: Event[] = []
  let groupEnd = 0

  sorted.forEach((event) => {
    if (group.length === 0 || event.start < groupEnd) {
      group.push(event)
      groupEnd = Math.max(groupEnd, event.end)
    } else {
      processGroup(group, result)
      group = [event]
      groupEnd = event.end
    }
  })
  if (group.length) processGroup(group, result)

  return result
}

function processGroup(group: Event[], result: PositionedEvent[]) {
  const activeLanes: number[] = []

  group.forEach((event) => {
    // 사용 가능한 가장 낮은 레인 찾기
    let lane = activeLanes.findIndex((end) => end <= event.start)
    if (lane === -1) lane = activeLanes.length

    activeLanes[lane] = event.end
    result.push({ ...event, lane, totalLanes: 0 })
  })

  // totalLanes 업데이트
  const maxLane =
    Math.max(...result.slice(-group.length).map((e) => e.lane)) + 1
  result.slice(-group.length).forEach((e) => (e.totalLanes = maxLane))
}

// CSS 적용: left = lane/totalLanes * 100%, width = 100%/totalLanes
#510

RSC에서 날짜 처리: 쿠키 기반 타임존

문제: Date 객체 직렬화, 서버/클라이언트 타임존 불일치, 하이드레이션 FOUT

해결: 서버에서 타임존 적용해서 렌더링

// middleware.js - 타임존 감지
export function middleware(request) {
  const timezone =
    request.cookies.get('user-timezone')?.value ||
    request.geo?.timezone || // Vercel
    'UTC'

  const response = NextResponse.next()
  response.headers.set('x-user-timezone', timezone)
  return response
}

// 서버 컴포넌트
const getUserTimezone = cache(() => {
  return headers().get('x-user-timezone') || 'UTC'
})

// 클라이언트 - 타임존 자동 감지 후 쿠키 저장
useEffect(() => {
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
  document.cookie = `user-timezone=${tz}; path=/; max-age=31536000`
}, [])

첫 방문은 인프라 추정값 사용, 이후 정확한 타임존 적용. FOUT 없음.


대안: useSyncExternalStore (client component 전용)

'use client'

const timezoneStore = {
  getSnapshot: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
  getServerSnapshot: () => 'UTC',
  subscribe: () => () => {},
}

function useTimezone() {
  return useSyncExternalStore(
    timezoneStore.subscribe,
    timezoneStore.getSnapshot,
    timezoneStore.getServerSnapshot
  )
}

서버: UTC → 클라이언트: 실제 타임존. 하이드레이션 에러 없음, 대신 FOUT 발생.

#509

대용량 리스트에서 selected 아이템 조회 최적화

O(n×m) → O(n+m)로 개선: Map으로 인덱싱

// 기존: 매번 find (느림)
selected.map((key) => items.find((item) => item.key === key))

// 개선: Map 인덱싱 (빠름)
const itemsMap = new Map(items.map((item) => [item.key, item]))
selected.map((key) => itemsMap.get(key)).filter(Boolean)

React에서 백그라운드 인덱싱:

function useItemsIndex(items: Item[]) {
  const [map, setMap] = useState(new Map())
  const [ready, setReady] = useState(false)

  useEffect(() => {
    // 청크 단위로 처리하여 UI 블로킹 방지
    const newMap = new Map(items.map((item) => [item.key, item]))
    setMap(newMap)
    setReady(true)
  }, [items])

  return { map, ready }
}

10만개 이상이면 Web Worker 고려.

#508

Tumblr 테마 Vue → Web Components 마이그레이션

NPF 데이터는 <script type="application/json" data-npf>에 저장. 컴포넌트에서 closest로 포스트 컨테이너 찾아서 참조 (React Context 패턴과 유사).

<div id="{PostID}" data-type="{PostType}">
  {block:Text}
  <script type="application/json" data-npf>
    {NPF}
  </script>
  <tumblr-npf-media></tumblr-npf-media>
  <tumblr-npf-text></tumblr-npf-text>
  {/block:Text}
</div>
// 데이터 참조
const post = this.closest('[id][data-type]')
const npf = JSON.parse(post.querySelector('script[data-npf]').textContent)

// 렌더링 - createElement 사용 (Tumblr 템플릿 ${} 충돌 회피)
const img = document.createElement('img')
img.src = imageURL
this.appendChild(img)

제약: {NPF}는 Text 포스트에서만 사용 가능. Photo는 기존 Tumblr 변수 사용.

#507
25 중 3페이지