임팩트: 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)1212 파일
중앙화 (After)11 파일

정량적 성과

유틸 함수를 중앙화하고 인터페이스 위치를 정리했습니다.

📦

번들 최적화

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 지원, 의존 방향이 명확해지기 때문입니다.

  1. 문제 발견 (12파일 분산)
  2. lodash 검토 → 미채택
  3. utils/ 중앙화 설계
  4. 제네릭 재구현
  5. 12파일 import 수정
  6. 운영 배포

작업 범위 (커밋 히스토리)

  1. [2025-05-09] 스로틀링 로직 개선 — 기존 구현을 제네릭 기반 타입 안전 버전으로 재작성 (4개 파일)
  2. [2025-05-12] throttle/debounce/cloneDeep → utils/index.ts 이동 (12개 파일 import 경로 수정)
  3. [2025-05-12] ApiResponse 인터페이스 → types/common.d.ts 이동 (3개 파일)
  4. [2025-05-13] 코드 리뷰 피드백 반영 — store/modules/api.ts 중복 로직 제거
  5. [2025-05-14] release 브랜치 conflict 해결 후 머지 → [2025-05-15] 운영 배포 완료

회고

잘한 점

유틸 함수 중앙화로 일관성을 확보했습니다. 타입 안전한 throttle, debounce를 구현했으며, 테스트 코드로 동작을 검증했습니다.

개선할 점

lodash 같은 라이브러리 사용을 검토할 때 번들 크기와 안정성 간의 트레이드오프를 더 깊이 분석할 필요가 있었습니다.

배운 점

throttle과 debounce의 차이점을 깊이 이해하게 되었습니다. 제네릭을 활용한 타입 안전한 유틸 함수 구현 방법과 유틸 함수 테스트 방법(fake timers)을 학습했습니다.