Vitest 모킹: vi.mocked() vs vi.hoisted()
vi.mocked()는 타입만 제공, 실제 모킹 구현체는 별도로 필요.1
// ❌ 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로 통합 테스트 고려.
Footnotes
-
vi.mocked - Vitest: “Type helper for TypeScript. Just returns the object that was passed.” ↩
ResizeObserver Mock (Vitest, ES Module 환경)
vi.mock() 은 파일 맨 위로 끌어올려져 바깥 변수를 못 본다 — 그래서 vi.hoisted() 로 모킹 함수를 미리 선언해 넘긴다.1
import { vi } from 'vitest'
const { mockResizeObserver, MockResizeObserver } = vi.hoisted(() => {
let observers: { callback: ResizeObserverCallback; observer: any }[] = []
// new 로 불리므로 화살표 함수가 아니라 function — 화살표면 "is not a constructor"
const MockResizeObserver = vi.fn().mockImplementation(function (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는 배열로 추적.
Footnotes
-
vi.mock - Vitest: “The call to
vi.mockis hoisted, so it doesn’t matter where you call it. … If you need to reference some variables outside of its scope, you can define them insidevi.hoistedand reference them insidevi.mock.” / vi.spyOn: “If you provide an arrow function, you will get<anonymous> is not a constructorerror when the mock is called.” ↩
dayjs로 특정 날짜가 포함된 주의 모든 날짜 가져오기 — week 는 로케일을 따르고 isoWeek 는 ISO 8601(월요일 시작)을 따른다.1
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
dayjs.extend(isoWeek)
// startOf('week') → 로케일의 주 시작 (기본 en·ko 는 일요일, en-gb 는 월요일)
// 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']
Footnotes
-
StartOf - Day.js:
week— “the first day of this week, 00:00 (locale aware)” /isoWeek— “the first day of this week according to ISO 8601, 00:00” ↩
JavaScript 부동소수점 오차 - 0.1 + 0.2 = 0.30000000000000004
IEEE 754 64비트 부동소수점 표준1. 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 * 1.2 // 비교 시 (오차 × 크기)
// 정밀 계산: decimal.js, big.js, bignumber.js
Number.EPSILON 은 1 근처에서만 맞는 허용 오차다 — 1.1 + 0.1 - 1.2 는 정확히 EPSILON 이라 < 비교가 false 가 된다.2
JS만의 문제 아님. Python, Java, C++ 등 IEEE 754 사용하는 모든 언어에서 동일.
Footnotes
-
Number - MDN: “The JavaScript Number type is a double-precision 64-bit binary format IEEE 754 value, like double in Java or C#.” ↩
-
Number.EPSILON - MDN: “Important takeaway: do not simply use Number.EPSILON as a threshold for equality testing. Use a threshold that is appropriate for the magnitude and accuracy of the numbers you are comparing.” ↩
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))
// 빈 배열 처리 — inArray 는 빈 배열이면 `false` 조건을 내서 에러는 없다. 쿼리 왕복을 아끼려고 앞에서 끊는다
const result =
authorIds.length > 0
? await db
.select()
.from(schema.authors)
.where(inArray(schema.authors.id, authorIds))
: []
inArray소스 (drizzle-orm):if (values.length === 0) { return sql`false`; }
캘린더 이벤트 겹침 처리 알고리즘
겹치는 이벤트들을 나란히 배치하기 위한 레인 할당: 시작 순으로 보며 비어 있는 가장 낮은 레인에 넣으면, 레인 수가 동시에 겹치는 최대 개수와 같아진다(구간 그래프의 탐욕 색칠).1
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
Footnotes
-
Interval graph - Wikipedia: “An optimal graph coloring of the interval graph represents an assignment of resources that covers all of the requests with as few resources as possible; it can be found in polynomial time by a greedy coloring algorithm that colors the intervals in sorted order by their left endpoints.” ↩
RSC에서 날짜 처리: 쿠키 기반 타임존
문제: Date 객체 직렬화, 서버/클라이언트 타임존 불일치, 하이드레이션 FOUT
해결: 서버에서 타임존 적용해서 렌더링
// proxy.js (Next.js 16 — 예전 middleware.js) - 타임존 감지
export function proxy(request) {
const timezone =
request.cookies.get('user-timezone')?.value ||
request.headers.get('x-vercel-ip-timezone') || // Vercel. request.geo 는 15 에서 제거
'UTC'
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-timezone', timezone)
// 응답 헤더가 아니라 요청 헤더로 넘겨야 서버 컴포넌트의 headers() 가 읽는다
return NextResponse.next({ request: { headers: requestHeaders } })
}
// 서버 컴포넌트
const getUserTimezone = cache(async () => {
return (await headers()).get('x-user-timezone') || 'UTC' // 15 부터 async
})
// 클라이언트 - 타임존 자동 감지 후 쿠키 저장
useEffect(() => {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
document.cookie = `user-timezone=${tz}; path=/; max-age=31536000`
}, [])
첫 방문은 인프라 추정값 사용, 이후 정확한 타임존 적용. FOUT 없음.123
대안: 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 발생 — getServerSnapshot 은 서버 렌더와 하이드레이션 때만 쓰이고, 그 뒤 getSnapshot 값으로 다시 그린다.4
Footnotes
-
proxy.js – Next.js: “The
middlewarefile convention is deprecated and has been renamed toproxy.” / “NextResponse.next({ request: { headers: requestHeaders } })to makerequestHeadersavailable upstream” ↩ -
How to upgrade to version 15 – Next.js: “The
geoandipproperties onNextRequesthave been removed as these values are provided by your hosting provider.” / “Previously synchronous Request-time APIs that rely on request information are now asynchronous” ↩ -
Request headers – Vercel: “The name of the time zone for the location of the requester’s public IP address in ICANN Time Zone Database name format such as
America/Chicago.” ↩ -
useSyncExternalStore – React: “optional getServerSnapshot: A function that returns the initial snapshot of the data in the store. It will be used only during server rendering and during hydration of server-rendered content on the client.” ↩
대용량 리스트에서 selected 아이템 조회 최적화
O(n×m) → O(n+m)로 개선: Map으로 인덱싱 — Map 조회는 명세상 원소 수에 대해 선형보다 빠르다.1
// 기존: 매번 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(() => {
// 렌더 뒤 effect 에서 한 번에 만든다 (청크로 나누지 않는다)
const newMap = new Map(items.map((item) => [item.key, item]))
setMap(newMap)
setReady(true)
}, [items])
return { map, ready }
}
10만개 이상이면 Web Worker 고려.
Footnotes
React에서 iframe 내부에 컴포넌트 렌더링
// react-frame-component 사용 (권장)
import Frame from 'react-frame-component'
;<Frame head={<style>{`body { margin: 0; }`}</style>}>
<MyComponent />
</Frame>
// 직접 구현: createPortal + contentDocument
function IframeRenderer({ children }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [mountNode, setMountNode] = useState<HTMLElement | null>(null)
useEffect(() => {
const iframe = iframeRef.current
const handleLoad = () => setMountNode(iframe?.contentDocument?.body ?? null)
iframe?.addEventListener('load', handleLoad)
if (iframe?.contentDocument?.readyState === 'complete') handleLoad()
return () => iframe?.removeEventListener('load', handleLoad)
}, [])
return (
<>
<iframe ref={iframeRef} />
{mountNode && createPortal(children, mountNode)}
</>
)
}
iframe 내부는 부모 CSS 미적용 (스타일 별도 주입 필요), DOM 이벤트는 iframe 밖으로 버블링 안 됨 — 단 React 핸들러는 portal 이라 React 트리를 따라 부모로 올라온다1. 단순 스타일 격리 목적이면 Shadow DOM 고려.
Footnotes
-
createPortal – React: “Events from portals propagate according to the React tree rather than the DOM tree.” ↩
Jest setupFiles vs setupFilesAfterEnv - 실행 시점이 다르다.
- setupFiles: 테스트 프레임워크 설치 전.
expect·beforeEach가 아직 없다(jest객체는 있다). 환경 변수, 폴리필용. - setupFilesAfterEnv: 테스트 프레임워크 설치 후.
jest.setTimeout(), 커스텀 matcher, 전역beforeEach용.
process.env는 setupFiles에 둔다(용도 구분). 다만 둘 다 테스트 코드보다 먼저 돌아서, setupFilesAfterEnv에서 설정해도 테스트 파일이 import 하는 모듈에는 보인다.1
// jest.config.js
{ setupFiles: ['./jest.env.js'], setupFilesAfterEnv: ['./jest.setup.js'] }
// jest.env.js - 환경 변수
process.env.API_URL = 'http://test-api.example.com'
// jest.setup.js - Jest API 활용
import '@testing-library/jest-dom'
beforeEach(() => { jest.clearAllMocks() })
Footnotes
-
Configuring Jest — setupFilesAfterEnv: “Since setupFiles executes before the test framework is installed in the environment, this script file presents you the opportunity of running some code immediately after the test framework has been installed in the environment but before the test code itself.” ↩