임팩트: hooks/common.ts에 산재한 throttle·debounce·cloneDeep을 utils/index.ts로 중앙화하고, 제네릭 기반 타입 안전 구현으로 재작성. ApiResponse 인터페이스도 types/common.d.ts로 이동하여 관심사 분리 완성. 12개 파일 의존관계 정리 + 코드 리뷰 피드백 반영까지 완료.
면접 훅: 12개 파일에 흩어진 유틸 함수들을 타입 안전한 중앙 모듈로 통합하고, lodash 대신 자체 구현을 선택해 번들 최적화와 유지보수성을 동시에 확보한 경험입니다.
SCRUM-13170 | 커밋 수: 8개 | 기간: 2025.03 ~ 2025.05
문제상황
스로틀링 로직이 여러 파일에 분산되어 있었고, 유틸 함수의 위치가 일관되지 않았습니다.
Before (hooks/common.ts에 혼재)
장점
- 한 파일에 모여 있어 찾기 쉬움
고려할 점
- hooks와 유틸 책임 혼재
- 12개 파일 의존 방향 예측 불가
- any 타입 사용
After (utils/index.ts 중앙화)
장점
- 관심사 분리
- 제네릭 타입 안전
- import 경로 일관성
고려할 점
- 초기 12개 파일 import 경로 수정 필요
주요 개선사항
throttle, debounce, cloneDeep 함수를 util/index.ts로 이동했습니다. ApiResponse 인터페이스를 common.d.ts로 이동하고, 스로틀링 로직을 개선했습니다.
| 항목 | 비교 | 값 (파일) |
|---|---|---|
| 분산 (Before) | 12 파일 | |
| 중앙화 (After) | 1 파일 |
정량적 성과
유틸 함수를 중앙화하고 인터페이스 위치를 정리했습니다.
번들 최적화
lodash 미채택으로 불필요 의존성 제거
타입 안전
제네릭 기반 재구현
관심사 분리
hooks/utils/types 책임 명확화
비즈니스 임팩트
코드 구조가 개선되어 유지보수성이 향상되었습니다.
기술적 구현 상세
AS-IS
기존에는 유틸 함수들이 여러 파일에 분산되어 있었습니다.
// 기존 구조 (더미 예시)
// hooks/common.ts에 throttle 함수
// utils/helper.ts에 debounce 함수
// stores/api/index.ts에 cloneDeep 함수
문제점:
유틸 함수의 위치가 일치하지 않아 import 경로에 혼란이 있었으며, 중복 구현의 가능성도 존재했습니다.
TO-BE
모든 유틸 함수를 utils/index.ts로 중앙화했습니다.
핵심 구현
1. throttle 함수
// utils/index.ts
export const throttle = <T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
const now = Date.now();
const remaining = delay - (now - lastCall);
if (remaining <= 0) {
// 즉시 실행
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastCall = now;
fn(...args);
} else if (!timeoutId) {
// 남은 시간 후 실행
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
fn(...args);
}, remaining);
}
};
};
2. debounce 함수
// utils/index.ts
export const debounce = <T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
};
3. cloneDeep 함수
// utils/index.ts
export const cloneDeep = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => cloneDeep(item)) as unknown as T;
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as unknown as T;
}
const cloned = {} as T;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = cloneDeep(obj[key]);
}
}
return cloned;
};
4. ApiResponse 인터페이스 이동
// types/common.d.ts
export interface ApiResponse<T = unknown> {
success: boolean;
data: T;
message?: string;
errorCode?: string;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
page: number;
perPage: number;
totalCount: number;
hasMore: boolean;
}
5. 컴포넌트에서 사용
<!-- views/product/FlyerList.vue -->
<script setup lang="ts">
import { throttle } from '@/utils';
const handleScroll = throttle(() => {
// 스크롤 처리 로직
checkAndLoadMore();
}, 200);
onMounted(() => {
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
});
</script>
<!-- components/search/SearchInput.vue -->
<script setup lang="ts">
import { debounce } from '@/utils';
const searchQuery = ref('');
const debouncedSearch = debounce((query: string) => {
// 검색 API 호출
searchStore.search(query);
}, 300);
watch(searchQuery, (newQuery) => {
debouncedSearch(newQuery);
});
</script>
6. 유틸 함수 테스트
// tests/unit/utils/index.test.ts
describe('throttle', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('지정된 시간 내에 한 번만 실행된다', () => {
// Given
const fn = jest.fn();
const throttled = throttle(fn, 100);
// When
throttled();
throttled();
throttled();
// Then
expect(fn).toHaveBeenCalledTimes(1);
});
it('지정된 시간이 지나면 다시 실행된다', () => {
// Given
const fn = jest.fn();
const throttled = throttle(fn, 100);
// When
throttled();
jest.advanceTimersByTime(100);
throttled();
// Then
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('마지막 호출 후 지정된 시간이 지나면 실행된다', () => {
// Given
const fn = jest.fn();
const debounced = debounce(fn, 100);
// When
debounced();
debounced();
debounced();
jest.advanceTimersByTime(100);
// Then
expect(fn).toHaveBeenCalledTimes(1);
});
});
describe('cloneDeep', () => {
it('객체를 깊은 복사한다', () => {
// Given
const original = { a: 1, b: { c: 2 } };
// When
const cloned = cloneDeep(original);
cloned.b.c = 3;
// Then
expect(original.b.c).toBe(2);
expect(cloned.b.c).toBe(3);
});
});
배경 & 동기
문제 상황
throttle, debounce, cloneDeep이 hooks/common.ts에 정의되어 있었습니다. "hooks"라는 이름과 맞지 않는 범용 유틸이 섞여 있어 찾기 어려웠습니다. 12개 파일(컴포넌트, 스토어, 뷰)에서 이 함수들을 import하고 있었는데, 의존 방향이 hooks에서 전체로 퍼져 있어 hooks 변경 시 영향 범위를 예측할 수 없었습니다. 기존 스로틀링 구현이 타입 안전하지 않았으며(any 사용, 제네릭 미적용), store 2벌 구조(src/store/ + src/stores/) 양쪽에서 동일 유틸을 다르게 import하고 있어 일관성이 없었습니다.
판단 근거
lodash 도입을 검토했으나 미채택했습니다. throttle/debounce/cloneDeep 3개만 필요한데 lodash 전체(또는 lodash-es)를 추가하면 번들 오버헤드가 불가피했기 때문입니다. tree-shaking을 해도 각 함수 내부 의존이 깊어 실제 절감 효과가 제한적이었습니다.
자체 구현을 선택했습니다. 프로젝트에서 사용하는 케이스가 단순(스크롤 throttle, 검색 debounce, store state clone)하여 엣지케이스 커버가 불필요했고, 20줄 내외 구현으로 충분했습니다.
배치 위치는 utils/index.ts를 선택했습니다. hooks/는 Vue Composition API composable 전용으로 책임을 한정하고, 프레임워크 무관 순수 함수는 utils/로 분리하여 관심사 분리 원칙을 적용했습니다.
ApiResponse 인터페이스도 hooks/common.ts에 있었으므로 types/common.d.ts로 이동했습니다. 타입 정의는 types/ 디렉토리에 모아야 auto-import, IDE 지원, 의존 방향이 명확해지기 때문입니다.
- 문제 발견 (12파일 분산)
- lodash 검토 → 미채택
- utils/ 중앙화 설계
- 제네릭 재구현
- 12파일 import 수정
- 운영 배포
작업 범위 (커밋 히스토리)
- [2025-05-09] 스로틀링 로직 개선 — 기존 구현을 제네릭 기반 타입 안전 버전으로 재작성 (4개 파일)
- [2025-05-12] throttle/debounce/cloneDeep → utils/index.ts 이동 (12개 파일 import 경로 수정)
- [2025-05-12] ApiResponse 인터페이스 → types/common.d.ts 이동 (3개 파일)
- [2025-05-13] 코드 리뷰 피드백 반영 — store/modules/api.ts 중복 로직 제거
- [2025-05-14] release 브랜치 conflict 해결 후 머지 → [2025-05-15] 운영 배포 완료
회고
잘한 점
유틸 함수 중앙화로 일관성을 확보했습니다. 타입 안전한 throttle, debounce를 구현했으며, 테스트 코드로 동작을 검증했습니다.
개선할 점
lodash 같은 라이브러리 사용을 검토할 때 번들 크기와 안정성 간의 트레이드오프를 더 깊이 분석할 필요가 있었습니다.
배운 점
throttle과 debounce의 차이점을 깊이 이해하게 되었습니다. 제네릭을 활용한 타입 안전한 유틸 함수 구현 방법과 유틸 함수 테스트 방법(fake timers)을 학습했습니다.