Agile 완벽 마스터

Agile의 핵심 개념과 실전 활용법

JavaScript중급
8시간
4개 항목
학습 진행률0 / 4 (0%)

학습 항목

1. JavaScript
초급
Agile 성능 최적화 완벽 가이드
퀴즈튜토리얼
2. JavaScript
초급
Agile|실무|활용|팁|개발자
퀴즈튜토리얼
3. JavaScript
중급
Scrum 실전 프로젝트 완벽 가이드
퀴즈튜토리얼
4. TypeScript
중급
Scrum|베스트|프랙티스|애자일|개발
퀴즈튜토리얼
1 / 4

이미지 로딩 중...

Agile 성능 최적화 완벽 가이드 - 슬라이드 1/3

Agile 성능 최적화 완벽 가이드

애자일 개발 환경에서 코드 성능을 최적화하는 실전 기법을 배웁니다. 반복적인 스프린트 내에서 성능 병목을 빠르게 찾아내고, 측정 기반으로 개선하며, 지속적으로 모니터링하는 방법을 다룹니다.


목차

  1. 성능 측정의 시작 - Performance API 활용
  2. 함수 메모이제이션으로 반복 계산 제거
  3. 디바운싱으로 과도한 이벤트 호출 방지
  4. 가상 스크롤링으로 대용량 리스트 렌더링 최적화
  5. 웹 워커로 무거운 작업을 백그라운드로 이동
  6. 코드 스플리팅으로 초기 로딩 최적화
  7. 이미지 최적화로 페이지 로딩 가속화
  8. 캐싱 전략으로 반복 요청 제거

1. 성능 측정의 시작 - Performance API 활용

시작하며

여러분이 스프린트 마지막 날, QA팀으로부터 "페이지가 너무 느려요"라는 피드백을 받은 적 있나요? 막연히 "어디가 느린 거지?"라고 고민하며 코드를 이리저리 뜯어보던 경험 말이에요.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 감으로 최적화를 시도하다 보면 정작 병목 지점이 아닌 곳을 고치느라 시간만 낭비하게 되죠.

더 심각한 건, 최적화했다고 생각했는데 실제로는 성능이 나아지지 않았거나 오히려 나빠지는 경우도 있습니다. 바로 이럴 때 필요한 것이 Performance API입니다.

브라우저가 제공하는 정밀한 측정 도구로, 코드의 실행 시간을 마이크로초 단위까지 정확하게 측정할 수 있습니다.

개요

간단히 말해서, Performance API는 웹 브라우저에 내장된 성능 측정 도구로, 코드 실행 시간을 밀리초 단위로 정확하게 측정할 수 있게 해줍니다. 애자일 환경에서는 매 스프린트마다 새로운 기능이 추가되고 코드가 변경됩니다.

이때 각 변경 사항이 성능에 미치는 영향을 즉시 파악할 수 있어야 합니다. 예를 들어, 대용량 데이터를 처리하는 함수를 작성했을 때, 데이터가 100개일 때와 10,000개일 때의 성능 차이를 정확히 측정할 수 있습니다.

기존에는 console.time()이나 Date.now()를 사용했다면, Performance API는 더 정밀하고 표준화된 방법을 제공합니다. 특히 브라우저의 내부 동작까지 추적할 수 있어 네트워크 요청, 렌더링 시간 등도 함께 분석할 수 있습니다.

이 API의 핵심 특징은 첫째, 고해상도 타이머로 마이크로초 단위 측정이 가능하고, 둘째, 표준 API라서 모든 모던 브라우저에서 동일하게 동작하며, 셋째, 개발자 도구와 연동되어 시각적으로 분석할 수 있다는 점입니다. 이러한 특징들이 데이터 기반의 최적화 결정을 가능하게 합니다.

코드 예제

// 성능 측정 시작 마커 설정
performance.mark('data-processing-start');

// 실제 처리할 데이터 생성
const largeDataSet = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  value: Math.random() * 1000
}));

// 데이터 처리 로직
const processedData = largeDataSet
  .filter(item => item.value > 500)
  .map(item => ({ ...item, processed: true }))
  .sort((a, b) => b.value - a.value);

// 성능 측정 종료 마커 설정
performance.mark('data-processing-end');

// 측정 구간 생성 및 결과 확인
performance.measure('data-processing', 'data-processing-start', 'data-processing-end');
const measurements = performance.getEntriesByName('data-processing');
console.log(`처리 시간: ${measurements[0].duration.toFixed(2)}ms`);

설명

이것이 하는 일: 위 코드는 대용량 데이터를 필터링, 변환, 정렬하는 과정의 성능을 측정합니다. 실제 운영 환경에서 사용자가 체감할 수 있는 처리 시간을 정확히 파악하는 것이 목적입니다.

첫 번째로, performance.mark()를 사용해 측정 시작 지점을 표시합니다. 이는 브라우저의 고해상도 타이머에 특정 시점을 기록하는 것으로, 나중에 이 지점을 기준으로 시간을 계산할 수 있습니다.

마치 스톱워치의 시작 버튼을 누르는 것과 같습니다. 그 다음으로, 실제 측정하려는 작업을 수행합니다.

이 예제에서는 10,000개의 데이터를 생성하고, filter로 절반을 걸러내고, map으로 변환하고, sort로 정렬하는 전형적인 데이터 처리 파이프라인을 실행합니다. 이 과정에서 브라우저는 내부적으로 각 작업의 실행 시간을 추적합니다.

마지막으로, 두 번째 mark()로 종료 지점을 표시하고, measure()를 호출해 시작과 끝 사이의 실제 경과 시간을 계산합니다. getEntriesByName()으로 측정 결과를 가져오면 duration 속성에 밀리초 단위의 정확한 실행 시간이 담겨 있습니다.

이 값을 로그로 출력하거나 모니터링 시스템에 전송할 수 있습니다. 여러분이 이 코드를 사용하면 "이 함수가 느린 것 같아"라는 막연한 추측 대신 "이 함수는 평균 23.45ms가 걸려"라는 구체적인 데이터를 얻을 수 있습니다.

스프린트 리뷰에서 "성능을 30% 개선했습니다"라고 수치로 보고할 수 있고, 성능 회귀를 조기에 발견하여 배포 전에 수정할 수 있습니다.

실전 팁

💡 여러 측정 구간을 동시에 추적할 수 있습니다. 각 mark에 고유한 이름을 부여하면 함수 내부의 세부 단계별 시간도 측정 가능합니다.

💡 측정이 끝난 후에는 performance.clearMarks()와 performance.clearMeasures()로 메모리를 정리하세요. 특히 SPA에서는 측정 데이터가 계속 쌓일 수 있습니다.

💡 프로덕션 환경에서는 환경 변수로 측정 기능을 제어하세요. 개발 중에만 활성화하면 불필요한 오버헤드를 방지할 수 있습니다.

💡 Chrome DevTools의 Performance 탭에서 User Timing 섹션을 확인하면 여러분이 설정한 mark와 measure가 시각적으로 표시됩니다.

💡 측정 결과를 로컬 스토리지나 분석 서비스로 전송하여 실제 사용자 환경에서의 성능 데이터를 수집하세요. 개발 환경과 실제 환경의 성능 차이를 파악할 수 있습니다.


2. 함수 메모이제이션으로 반복 계산 제거

시작하며

여러분이 대시보드 화면을 만들 때, 같은 데이터로 통계를 계산하는 함수가 렌더링할 때마다 실행되어 화면이 버벅거리는 경험을 해보셨나요? 사용자가 필터를 조금만 바꿔도 전체 통계가 다시 계산되는 상황 말이에요.

이런 문제는 React나 Vue 같은 프레임워크에서 특히 흔합니다. 컴포넌트가 리렌더링될 때마다 동일한 입력값으로 동일한 계산을 반복하게 되죠.

사용자 경험은 나빠지고, CPU는 불필요한 작업으로 과열되며, 배터리는 빠르게 소모됩니다. 바로 이럴 때 필요한 것이 메모이제이션(Memoization)입니다.

한 번 계산한 결과를 캐시에 저장해두고, 같은 입력이 들어오면 계산을 건너뛰고 저장된 결과를 즉시 반환하는 최적화 기법입니다.

개요

간단히 말해서, 메모이제이션은 함수의 입력값과 출력값을 기억해두었다가, 같은 입력이 다시 들어오면 계산을 생략하고 저장된 결과를 반환하는 기법입니다. 애자일 스프린트에서 새로운 기능을 빠르게 추가하다 보면 성능 최적화를 나중으로 미루기 쉽습니다.

하지만 메모이제이션은 코드 구조를 크게 바꾸지 않고도 적용할 수 있어 리팩토링 부담이 적습니다. 예를 들어, 복잡한 데이터 변환 로직이나 비용이 큰 계산 함수를 메모이제이션하면 즉시 성능 향상을 체감할 수 있습니다.

기존에는 계산 결과를 수동으로 변수에 저장하고 조건문으로 캐시를 확인했다면, 메모이제이션 함수를 사용하면 이 모든 과정이 자동화됩니다. 코드는 더 깔끔해지고 실수할 여지도 줄어듭니다.

이 기법의 핵심 특징은 첫째, 순수 함수(같은 입력에 항상 같은 출력)에만 적용 가능하고, 둘째, 메모리와 CPU 사이의 트레이드오프를 고려해야 하며, 셋째, 입력값이 복잡한 객체일 때는 비교 로직이 중요하다는 점입니다. 이러한 특징들을 이해하면 적재적소에 메모이제이션을 활용할 수 있습니다.

코드 예제

// 메모이제이션 헬퍼 함수
function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      console.log('캐시에서 반환');
      return cache.get(key);
    }
    console.log('새로 계산');
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// 비용이 큰 통계 계산 함수
const calculateStatistics = memoize((data) => {
  return {
    total: data.reduce((sum, val) => sum + val, 0),
    average: data.reduce((sum, val) => sum + val, 0) / data.length,
    max: Math.max(...data),
    min: Math.min(...data)
  };
});

// 동일한 데이터로 여러 번 호출
const testData = [10, 20, 30, 40, 50];
console.log(calculateStatistics(testData)); // 새로 계산
console.log(calculateStatistics(testData)); // 캐시에서 반환 (즉시 반환!)

설명

이것이 하는 일: 위 코드는 범용 메모이제이션 함수를 구현하고, 비용이 큰 통계 계산 함수에 적용하여 반복 계산을 제거합니다. 실제 애플리케이션에서 렌더링 성능을 개선하는 핵심 패턴입니다.

첫 번째로, memoize 함수는 고차 함수(Higher-Order Function) 패턴을 사용합니다. 원본 함수를 받아서 캐시 기능이 추가된 새로운 함수를 반환하죠.

Map 객체를 클로저로 캡처하여 호출 간에 캐시를 유지합니다. JSON.stringify로 인자를 문자열 키로 변환하는 것은 간단하지만 효과적인 캐시 키 생성 방법입니다.

그 다음으로, 캐시 확인 로직이 실행됩니다. cache.has()로 이전에 같은 인자로 호출된 적이 있는지 확인하고, 있다면 cache.get()으로 저장된 결과를 즉시 반환합니다.

이 과정은 밀리초도 걸리지 않아 사용자는 거의 지연을 느끼지 못합니다. 없다면 원본 함수를 실행하고 결과를 캐시에 저장합니다.

마지막으로, 실제 사용 예시에서 확인할 수 있듯이 첫 번째 호출은 "새로 계산"을 출력하며 실제 계산을 수행하지만, 두 번째 호출은 "캐시에서 반환"을 출력하며 즉시 결과를 돌려줍니다. 만약 통계 계산에 100ms가 걸린다면, 두 번째 호출은 100ms를 절약하는 셈입니다.

여러분이 이 코드를 사용하면 React의 컴포넌트 리렌더링 최적화, API 응답 캐싱, 복잡한 데이터 변환 로직 등 다양한 곳에 적용할 수 있습니다. 특히 사용자 인터랙션이 빈번한 UI에서 효과가 뛰어나며, 코드 변경 없이 단순히 함수를 래핑하기만 하면 되므로 기존 프로젝트에도 쉽게 적용할 수 있습니다.

실전 팁

💡 모든 함수를 메모이제이션하지 마세요. 계산 비용이 낮거나 입력값이 매번 다른 함수는 오히려 메모리만 낭비합니다. 프로파일링으로 병목을 찾아 선택적으로 적용하세요.

💡 캐시 크기를 제한하는 LRU(Least Recently Used) 캐시를 구현하면 메모리 누수를 방지할 수 있습니다. 특히 장시간 실행되는 애플리케이션에서 필수입니다.

💡 객체나 배열을 인자로 받는 함수는 JSON.stringify 대신 해시 함수나 얕은 비교를 사용하는 것이 더 효율적일 수 있습니다.

💡 React에서는 useMemo와 useCallback 훅이 내장 메모이제이션을 제공합니다. 커스텀 함수보다 이들을 우선 사용하세요.

💡 개발 환경에서는 캐시 히트율을 로깅하여 메모이제이션이 실제로 효과가 있는지 확인하세요. 히트율이 낮다면 메모이제이션을 제거하는 것이 나을 수 있습니다.


3. 디바운싱으로 과도한 이벤트 호출 방지

시작하며

여러분이 검색 창에 자동완성 기능을 구현할 때, 사용자가 타이핑할 때마다 API를 호출해서 서버가 과부하되고 비용이 폭증한 경험이 있나요? "React"를 검색하려고 'R', 'Re', 'Rea', 'Reac', 'React' 총 5번의 API 호출이 발생하는 상황 말이에요.

이런 문제는 이벤트 핸들러를 직접 연결했을 때 항상 발생합니다. 사용자의 입력은 밀리초 단위로 연속적으로 발생하지만, 실제로 의미 있는 것은 사용자가 타이핑을 멈춘 순간입니다.

불필요한 네트워크 요청은 서버 비용을 증가시키고, 사용자는 느린 응답 때문에 불편을 겪습니다. 바로 이럴 때 필요한 것이 디바운싱(Debouncing)입니다.

연속적으로 발생하는 이벤트를 그룹화하여, 마지막 이벤트 이후 일정 시간이 지난 후에만 실제 작업을 수행하도록 지연시키는 기법입니다.

개요

간단히 말해서, 디바운싱은 연속적으로 발생하는 이벤트 중 마지막 이벤트만 처리하도록 하는 기법으로, 사용자가 행동을 멈출 때까지 기다렸다가 한 번만 실행합니다. 실무에서는 검색 자동완성, 윈도우 리사이즈, 스크롤 이벤트, 폼 검증 등 빈번하게 발생하는 이벤트를 다룰 때 필수적입니다.

예를 들어, 사용자가 검색어를 입력할 때 300ms 동안 추가 입력이 없을 때만 API를 호출하면, 불필요한 요청을 90% 이상 줄일 수 있습니다. 기존에는 setTimeout을 수동으로 관리하고 clearTimeout으로 이전 타이머를 취소하는 복잡한 코드를 작성했다면, 재사용 가능한 디바운스 함수를 만들어두면 어디서든 간단히 적용할 수 있습니다.

이 기법의 핵심 특징은 첫째, 이벤트 빈도를 획기적으로 줄여 서버 부하와 비용을 절감하고, 둘째, 사용자 경험을 해치지 않으면서 성능을 개선하며, 셋째, 지연 시간을 조절하여 응답성과 효율성의 균형을 맞출 수 있다는 점입니다. 이러한 특징들이 현대 웹 애플리케이션에서 디바운싱을 필수 기법으로 만듭니다.

코드 예제

// 디바운스 헬퍼 함수
function debounce(func, delay) {
  let timeoutId;
  return function(...args) {
    // 이전 타이머가 있다면 취소
    clearTimeout(timeoutId);
    // 새로운 타이머 설정
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// 검색 API 호출 함수 (시뮬레이션)
function searchAPI(query) {
  console.log(`API 호출: "${query}"`);
  // 실제로는 fetch('/api/search?q=' + query) 같은 코드가 들어감
}

// 디바운스 적용
const debouncedSearch = debounce(searchAPI, 300);

// 사용 예시 (input 이벤트 핸들러)
const searchInput = document.querySelector('#search');
searchInput?.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

설명

이것이 하는 일: 위 코드는 범용 디바운스 함수를 구현하고 검색 입력에 적용하여, 사용자가 타이핑을 멈춘 후 300ms가 지났을 때만 API를 호출합니다. 이를 통해 불필요한 네트워크 요청을 대폭 줄입니다.

첫 번째로, debounce 함수는 클로저를 활용하여 timeoutId 변수를 보존합니다. 이 변수는 현재 예약된 타이머의 ID를 저장하며, 새로운 이벤트가 발생할 때마다 이전 타이머를 취소하는 데 사용됩니다.

반환되는 함수는 원본 함수를 감싸는 래퍼로, 실제 이벤트 핸들러로 등록됩니다. 그 다음으로, 사용자가 입력할 때마다 래퍼 함수가 호출됩니다.

먼저 clearTimeout으로 이전에 예약된 타이머가 있다면 취소합니다. 이것이 핵심입니다.

사용자가 'R', 'Re', 'Rea'를 입력하는 동안 각 입력마다 이전 타이머를 취소하고 새로운 타이머를 시작하므로, 실제로는 아무 작업도 실행되지 않습니다. 마지막으로, 사용자가 타이핑을 멈추고 300ms가 경과하면 setTimeout의 콜백이 실행되어 비로소 searchAPI가 호출됩니다.

만약 사용자가 'React'를 1초 안에 입력했다면, API 호출은 딱 한 번만 발생합니다. 디바운싱이 없었다면 5번 발생했을 요청이 1번으로 줄어드는 것이죠.

여러분이 이 코드를 사용하면 검색 기능뿐만 아니라 자동 저장, 폼 검증, 윈도우 리사이즈 핸들러 등 다양한 곳에 적용할 수 있습니다. 서버 비용이 줄어들고, 사용자는 더 빠른 응답을 경험하며, 브라우저의 메인 스레드 부담도 감소합니다.

특히 유료 API를 사용하는 경우 비용 절감 효과가 즉시 나타납니다.

실전 팁

💡 지연 시간은 용도에 따라 조절하세요. 검색은 300ms, 자동 저장은 1000ms, 윈도우 리사이즈는 150ms가 일반적으로 적합합니다.

💡 디바운스된 함수는 컴포넌트 외부나 useCallback/useMemo 안에서 생성하세요. 렌더링마다 새로 생성하면 디바운싱이 제대로 작동하지 않습니다.

💡 즉시 실행이 필요한 경우 leading 옵션을 추가하여 첫 번째 호출은 즉시 실행하고 이후 호출을 디바운스할 수 있습니다.

💡 컴포넌트 언마운트 시 clearTimeout을 호출하여 메모리 누수를 방지하세요. React에서는 useEffect의 cleanup 함수를 활용합니다.

💡 lodash의 _.debounce는 cancel 메서드를 제공하여 수동으로 예약된 호출을 취소할 수 있습니다. 복잡한 시나리오에서는 라이브러리 사용을 고려하세요.


4. 가상 스크롤링으로 대용량 리스트 렌더링 최적화

시작하며

여러분이 수천 개의 상품 목록이나 게시글을 화면에 표시해야 할 때, 페이지가 로딩되지 않거나 스크롤이 끊기는 경험을 해보셨나요? 10,000개의 DOM 노드를 한 번에 렌더링하려니 브라우저가 버벅거리고, 사용자는 답답해하며 페이지를 떠나버리는 상황 말이에요.

이런 문제는 전통적인 리스트 렌더링 방식의 근본적인 한계입니다. 모든 아이템을 DOM에 렌더링하면 메모리 사용량이 폭증하고, 브라우저의 레이아웃 계산과 페인팅 비용이 기하급수적으로 증가합니다.

무한 스크롤을 추가해도 아이템이 누적되면 결국 같은 문제에 직면하게 됩니다. 바로 이럴 때 필요한 것이 가상 스크롤링(Virtual Scrolling)입니다.

화면에 보이는 영역의 아이템만 실제로 렌더링하고, 스크롤 위치에 따라 동적으로 DOM을 재사용하여 수만 개의 아이템도 부드럽게 표시할 수 있게 합니다.

개요

간단히 말해서, 가상 스크롤링은 전체 리스트 중 뷰포트에 보이는 아이템만 DOM에 렌더링하고, 사용자가 스크롤하면 보이지 않는 아이템을 제거하고 새로 보일 아이템을 추가하는 기법입니다. 대용량 데이터를 다루는 어드민 페이지, 채팅 애플리케이션, 피드 기반 소셜 미디어 등에서 필수적입니다.

예를 들어, 100,000개의 상품 목록이 있어도 실제로는 10-20개만 DOM에 존재하므로 초기 로딩이 빠르고 메모리 사용량도 일정하게 유지됩니다. 기존에는 페이지네이션으로 데이터를 나누어 로딩했다면, 가상 스크롤링은 자연스러운 무한 스크롤 경험을 제공하면서도 성능 문제를 완전히 해결합니다.

사용자는 끊김 없이 콘텐츠를 탐색할 수 있고, 개발자는 복잡한 페이지 상태 관리에서 해방됩니다. 이 기법의 핵심 특징은 첫째, DOM 노드 수를 뷰포트 크기에 비례하도록 제한하여 렌더링 성능을 일정하게 유지하고, 둘째, 스크롤 위치에 따라 콘텐츠를 동적으로 교체하여 무한에 가까운 리스트를 지원하며, 셋째, 아이템의 높이가 일정하거나 동적이어도 모두 처리할 수 있다는 점입니다.

이러한 특징들이 현대적인 사용자 경험의 기반이 됩니다.

코드 예제

// 간단한 가상 스크롤 구현
class VirtualScroll {
  constructor(container, itemHeight, totalItems, renderItem) {
    this.container = container;
    this.itemHeight = itemHeight;
    this.totalItems = totalItems;
    this.renderItem = renderItem;
    this.viewportHeight = container.clientHeight;
    this.visibleCount = Math.ceil(this.viewportHeight / itemHeight) + 2; // 버퍼 추가

    // 전체 높이를 표현하는 스페이서
    this.spacer = document.createElement('div');
    this.spacer.style.height = `${totalItems * itemHeight}px`;
    this.container.appendChild(this.spacer);

    // 실제 콘텐츠 컨테이너
    this.content = document.createElement('div');
    this.content.style.position = 'absolute';
    this.content.style.top = '0';
    this.content.style.width = '100%';
    this.container.appendChild(this.content);

    this.container.addEventListener('scroll', () => this.update());
    this.update();
  }

  update() {
    const scrollTop = this.container.scrollTop;
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    const endIndex = Math.min(startIndex + this.visibleCount, this.totalItems);

    // 콘텐츠 위치 조정
    this.content.style.transform = `translateY(${startIndex * this.itemHeight}px)`;

    // 보이는 아이템만 렌더링
    this.content.innerHTML = '';
    for (let i = startIndex; i < endIndex; i++) {
      const item = this.renderItem(i);
      this.content.appendChild(item);
    }
  }
}

// 사용 예시
const container = document.querySelector('#list-container');
new VirtualScroll(container, 50, 10000, (index) => {
  const div = document.createElement('div');
  div.textContent = `Item ${index}`;
  div.style.height = '50px';
  return div;
});

설명

이것이 하는 일: 위 코드는 재사용 가능한 가상 스크롤 클래스를 구현하여, 10,000개의 아이템을 실제로는 10-20개의 DOM 노드만으로 표현합니다. 스크롤 이벤트에 반응하여 동적으로 콘텐츠를 교체합니다.

첫 번째로, 생성자에서 전체 리스트의 높이를 시뮬레이션하는 spacer 엘리먼트를 만듭니다. 이것이 스크롤바를 올바르게 표시하게 합니다.

만약 10,000개 아이템 × 50px = 500,000px 높이가 되어, 사용자는 실제로 10,000개의 아이템이 있는 것처럼 스크롤할 수 있습니다. 하지만 실제 DOM에는 보이는 아이템만 존재합니다.

그 다음으로, update() 메서드가 스크롤 이벤트마다 호출됩니다. scrollTop을 itemHeight로 나누어 현재 뷰포트의 첫 번째 아이템 인덱스를 계산하고, visibleCount를 더해 마지막 인덱스를 구합니다.

이렇게 계산된 범위의 아이템만 실제로 renderItem 콜백으로 생성하여 DOM에 추가합니다. 마지막으로, transform: translateY()로 content 컨테이너의 위치를 조정합니다.

예를 들어 사용자가 100번째 아이템까지 스크롤했다면, content를 5000px 아래로 이동시켜 마치 그 위에 100개의 아이템이 있는 것처럼 보이게 합니다. 실제로는 100번째부터 120번째까지 20개의 DOM 노드만 존재하지만 말이죠.

여러분이 이 코드를 사용하면 데이터 크기에 관계없이 일정한 성능을 유지할 수 있습니다. 100개든 100만 개든 렌더링 비용은 동일합니다.

React에서는 react-window, Vue에서는 vue-virtual-scroller 같은 라이브러리가 이 패턴을 구현하고 있으며, 동적 높이, 그리드 레이아웃 등 더 복잡한 시나리오도 지원합니다.

실전 팁

💡 아이템 높이가 동적이라면 각 아이템의 높이를 측정하여 배열에 저장하고, 누적 합으로 위치를 계산하세요. 복잡하지만 정확한 스크롤 경험을 제공합니다.

💡 버퍼 아이템(visibleCount + 2)을 추가하여 빠른 스크롤에서도 흰 화면이 보이지 않도록 하세요. 트레이드오프는 약간의 추가 렌더링입니다.

💡 스크롤 이벤트에 디바운싱이나 쓰로틀링을 적용하지 마세요. 가상 스크롤은 즉각적인 업데이트가 필요하며, 이미 최적화되어 있습니다.

💡 React에서는 react-window의 FixedSizeList나 VariableSizeList를 사용하면 직접 구현할 필요 없이 프로덕션 레벨의 가상 스크롤을 적용할 수 있습니다.

💡 이미지가 포함된 리스트라면 Intersection Observer로 레이지 로딩을 결합하여 네트워크 비용도 절약하세요. 가상 스크롤로 DOM을 줄이고, 레이지 로딩으로 이미지 로딩을 최적화하는 이중 전략입니다.


5. 웹 워커로 무거운 작업을 백그라운드로 이동

시작하며

여러분이 대용량 CSV 파일을 파싱하거나 복잡한 이미지 처리를 할 때, 브라우저 전체가 멈춰버려서 사용자가 아무것도 할 수 없게 된 경험이 있나요? "응답 없음" 대화상자가 뜨거나 화면이 완전히 프리징되는 상황 말이에요.

이런 문제는 JavaScript의 싱글 스레드 특성 때문입니다. 메인 스레드가 무거운 계산에 몰두하면 UI 렌더링, 이벤트 처리, 애니메이션이 모두 멈춥니다.

사용자는 앱이 크래시된 줄 알고 창을 닫아버릴 수 있습니다. 바로 이럴 때 필요한 것이 웹 워커(Web Worker)입니다.

별도의 백그라운드 스레드에서 무거운 작업을 실행하여 메인 스레드를 자유롭게 유지하고, 사용자는 작업이 진행되는 동안에도 앱을 계속 사용할 수 있습니다.

개요

간단히 말해서, 웹 워커는 메인 스레드와 독립적으로 실행되는 백그라운드 스레드로, CPU 집약적인 작업을 처리하는 동안 UI가 응답성을 유지하도록 합니다. 대용량 데이터 처리, 암호화/복호화, 이미지 조작, 복잡한 수학 계산 등 실행 시간이 긴 작업에 필수적입니다.

예를 들어, 10MB 크기의 JSON 파일을 파싱하고 변환하는 작업을 워커로 이동하면 사용자는 파싱이 완료될 때까지 다른 페이지를 탐색하거나 폼을 작성할 수 있습니다. 기존에는 작업을 작은 청크로 나누고 setTimeout으로 메인 스레드에 틈을 주는 복잡한 코드를 작성했다면, 웹 워커는 진정한 멀티스레딩을 제공합니다.

작업을 완전히 분리하여 메인 스레드는 100% UI에만 집중할 수 있습니다. 이 기법의 핵심 특징은 첫째, 진짜 병렬 처리로 멀티코어 CPU를 활용할 수 있고, 둘째, 메시지 패싱으로 워커와 메인 스레드가 안전하게 통신하며, 셋째, DOM 접근은 불가능하지만 대부분의 JavaScript API는 사용 가능하다는 점입니다.

이러한 특징들을 이해하면 적절한 작업을 워커로 분리할 수 있습니다.

코드 예제

// worker.js (별도 파일)
self.addEventListener('message', (e) => {
  const { data, operation } = e.data;

  if (operation === 'processData') {
    // 무거운 데이터 처리 작업
    const result = data.map(item => ({
      ...item,
      processed: true,
      hash: calculateHash(item.value)
    })).filter(item => item.hash > 500);

    // 결과를 메인 스레드로 전송
    self.postMessage({ status: 'complete', result });
  }
});

function calculateHash(value) {
  // 의도적으로 무거운 계산
  let hash = 0;
  for (let i = 0; i < 1000000; i++) {
    hash += value * Math.random();
  }
  return hash;
}

// main.js (메인 스레드)
const worker = new Worker('worker.js');

// 워커로 데이터 전송
const largeDataset = Array.from({ length: 1000 }, (_, i) => ({ id: i, value: Math.random() * 1000 }));
worker.postMessage({ operation: 'processData', data: largeDataset });

// 워커로부터 결과 수신
worker.addEventListener('message', (e) => {
  console.log('처리 완료:', e.data.result);
  // UI 업데이트 등 후속 작업
});

// 에러 처리
worker.addEventListener('error', (e) => {
  console.error('워커 에러:', e.message);
});

설명

이것이 하는 일: 위 코드는 웹 워커를 생성하여 CPU 집약적인 데이터 처리를 백그라운드에서 실행하고, 메인 스레드는 UI 응답성을 유지합니다. 실제로는 암호화, 압축, 대용량 파싱 등에 활용됩니다.

첫 번째로, worker.js 파일에서 self.addEventListener('message')로 메인 스레드의 메시지를 받습니다. self는 워커의 전역 스코프를 나타내며, 메인 스레드의 window와 유사하지만 DOM 접근은 불가능합니다.

전송된 데이터는 자동으로 구조화 복제(structured clone)되어 안전하게 복사됩니다. 그 다음으로, 워커 내부에서 실제 무거운 작업이 실행됩니다.

이 예제에서는 각 아이템마다 100만 번의 반복 계산을 수행하는 calculateHash 함수를 호출합니다. 이 작업이 메인 스레드에서 실행된다면 몇 초간 화면이 멈출 것이지만, 워커에서 실행되므로 사용자는 여전히 버튼을 클릭하고 스크롤할 수 있습니다.

마지막으로, 작업이 완료되면 self.postMessage로 결과를 메인 스레드로 전송합니다. 메인 스레드는 worker.addEventListener('message')로 이를 수신하여 UI를 업데이트하거나 다음 작업을 시작할 수 있습니다.

에러가 발생하면 error 이벤트로 처리하여 앱이 크래시되는 것을 방지합니다. 여러분이 이 코드를 사용하면 사용자 경험이 극적으로 개선됩니다.

대용량 파일 처리 중에도 앱은 부드럽게 동작하고, 사용자는 작업이 백그라운드에서 진행되는 동안 다른 기능을 사용할 수 있습니다. 특히 멀티코어 CPU에서는 진정한 병렬 처리로 작업 시간도 단축됩니다.

실전 팁

💡 워커는 생성 비용이 있으므로 한 번 생성한 워커를 재사용하세요. 워커 풀 패턴을 사용하면 여러 작업을 효율적으로 처리할 수 있습니다.

💡 큰 데이터를 전송할 때는 Transferable Objects를 사용하여 복사 대신 소유권을 이전하면 성능이 대폭 향상됩니다. ArrayBuffer가 대표적입니다.

💡 워커에서는 DOM, window, document에 접근할 수 없습니다. fetch, IndexedDB, setTimeout 등 대부분의 API는 사용 가능하니 워커 내에서 네트워크 요청도 할 수 있습니다.

💡 디버깅이 어려우므로 Chrome DevTools의 Sources > Threads 탭을 활용하여 워커 코드를 디버그하세요. console.log도 정상 작동합니다.

💡 Webpack이나 Vite 같은 번들러를 사용한다면 worker-loader나 내장 워커 지원을 활용하여 워커 파일을 자동으로 번들링하세요. 별도 파일 관리의 번거로움을 줄일 수 있습니다.


6. 코드 스플리팅으로 초기 로딩 최적화

시작하며

여러분이 애플리케이션을 배포했는데 사용자가 "첫 화면이 너무 오래 걸려요"라고 불평하는 경험을 해보셨나요? 번들 파일이 5MB를 넘어가고, 모바일 사용자는 네트워크 다운로드만 10초 이상 기다려야 하는 상황 말이에요.

이런 문제는 모든 코드를 하나의 번들로 묶었을 때 발생합니다. 사용자가 로그인 페이지에 접속했는데 어드민 페이지, 설정 페이지, 보고서 생성 로직까지 모두 다운로드해야 합니다.

대부분은 당장 필요 없는 코드인데 말이죠. 바로 이럴 때 필요한 것이 코드 스플리팅(Code Splitting)입니다.

애플리케이션을 여러 개의 작은 청크로 나누고, 사용자가 실제로 필요로 할 때만 해당 코드를 로딩하여 초기 로딩 시간을 획기적으로 단축합니다.

개요

간단히 말해서, 코드 스플리팅은 하나의 큰 번들을 여러 개의 작은 청크로 분할하여, 초기에는 필수 코드만 로드하고 나머지는 필요할 때 동적으로 로딩하는 기법입니다. 모던 SPA(Single Page Application)에서는 필수입니다.

특히 애자일 스프린트마다 새로운 기능이 추가되면서 번들 크기가 계속 커지는 상황에서 더욱 중요합니다. 예를 들어, 로그인 페이지와 대시보드를 별도 청크로 분리하면 로그인 페이지는 50KB만 로드하고, 사용자가 로그인에 성공한 후에 대시보드 코드 500KB를 로드할 수 있습니다.

기존에는 수동으로 스크립트 태그를 동적으로 생성하는 복잡한 방법을 사용했다면, 현대 번들러(Webpack, Vite)는 동적 import()만으로 자동으로 코드를 분할하고 최적화합니다. 이 기법의 핵심 특징은 첫째, 초기 번들 크기를 극적으로 줄여 Time to Interactive를 개선하고, 둘째, 라우트 기반 또는 컴포넌트 기반으로 유연하게 분할할 수 있으며, 셋째, 자동으로 프리페칭과 프리로딩을 적용할 수 있다는 점입니다.

이러한 특징들이 현대 웹 애플리케이션의 성능 기준을 높입니다.

코드 예제

// 기존 방식 (전체 번들에 포함)
// import HeavyComponent from './HeavyComponent';

// 코드 스플리팅 적용 (동적 import)
import React, { lazy, Suspense } from 'react';

// 컴포넌트를 별도 청크로 분리
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const AdminPanel = lazy(() => import('./AdminPanel'));
const ReportGenerator = lazy(() => import('./ReportGenerator'));

function App() {
  const [showAdmin, setShowAdmin] = React.useState(false);

  return (
    <div>
      <h1>메인 페이지</h1>

      {/* Suspense로 로딩 상태 처리 */}
      <Suspense fallback={<div>로딩 중...</div>}>
        <HeavyComponent />

        {showAdmin && <AdminPanel />}
      </Suspense>

      <button onClick={() => setShowAdmin(true)}>
        어드민 패널 열기
      </button>
    </div>
  );
}

// Vanilla JS에서도 사용 가능
async function loadModule() {
  const module = await import('./heavy-calculation.js');
  const result = module.calculate(100);
  console.log(result);
}

설명

이것이 하는 일: 위 코드는 React 애플리케이션에서 무거운 컴포넌트들을 별도 청크로 분리하여, 사용자가 실제로 필요로 할 때만 네트워크에서 다운로드하도록 합니다. 이를 통해 초기 로딩 시간을 크게 단축합니다.

첫 번째로, React.lazy()는 동적 import()를 컴포넌트 로딩에 적용하는 React의 내장 기능입니다. import('./HeavyComponent')는 Promise를 반환하며, 번들러(Webpack, Vite)가 이를 감지하여 자동으로 별도 파일(예: HeavyComponent.chunk.js)로 분리합니다.

초기 번들에는 이 컴포넌트의 코드가 포함되지 않습니다. 그 다음으로, Suspense 컴포넌트가 로딩 상태를 우아하게 처리합니다.

HeavyComponent가 아직 로드되지 않았다면 fallback prop의 JSX(로딩 스피너 등)를 표시하고, 로드가 완료되면 자동으로 실제 컴포넌트로 교체합니다. 사용자는 빈 화면 대신 "로딩 중..." 메시지를 보며 무엇인가 진행되고 있음을 알 수 있습니다.

마지막으로, 조건부 렌더링과 결합하면 더욱 강력합니다. AdminPanel은 showAdmin이 true일 때만 로드됩니다.

즉, 일반 사용자는 어드민 관련 코드를 전혀 다운로드하지 않으며, 어드민 사용자만 버튼을 클릭했을 때 해당 청크를 로드합니다. Vanilla JavaScript에서도 await import()를 사용하여 모듈을 동적으로 로드할 수 있습니다.

여러분이 이 코드를 사용하면 번들 크기를 50% 이상 줄일 수 있습니다. 특히 모바일 사용자와 느린 네트워크 환경에서 효과가 극대화됩니다.

Lighthouse 점수가 향상되고, 사용자 이탈률이 감소하며, SEO에도 긍정적인 영향을 미칩니다. 라우터와 결합하면 페이지별 자동 코드 스플리팅도 구현할 수 있습니다.

실전 팁

💡 라우트 기반 코드 스플리팅을 우선 적용하세요. React Router의 lazy routes나 Next.js의 자동 코드 스플리팅을 활용하면 페이지별로 자동 분할됩니다.

💡 너무 작은 컴포넌트를 분리하면 오히려 HTTP 요청이 늘어나 성능이 나빠질 수 있습니다. 최소 20-30KB 이상의 청크를 목표로 하세요.

💡 <link rel="prefetch"><link rel="preload">를 사용하여 사용자가 클릭하기 전에 미리 청크를 로드할 수 있습니다. Webpack의 magic comments로 쉽게 적용 가능합니다.

💡 에러 바운더리를 설정하여 청크 로드 실패 시 사용자에게 재시도 옵션을 제공하세요. 특히 배포 중 이전 버전의 청크가 삭제되는 경우를 대비합니다.

💡 번들 분석 도구(webpack-bundle-analyzer, vite-plugin-visualizer)로 청크 크기를 시각화하여 최적화 지점을 찾으세요. 예상치 못한 거대한 의존성을 발견할 수 있습니다.


7. 이미지 최적화로 페이지 로딩 가속화

시작하며

여러분이 웹사이트를 만들었는데 Lighthouse에서 "이미지를 최적화하면 3초를 절약할 수 있습니다"라는 경고를 본 적 있나요? 5MB짜리 고해상도 사진을 썸네일로 사용해서 모바일 사용자가 몇 초씩 기다려야 하는 상황 말이에요.

이런 문제는 디자이너나 마케터가 제공한 원본 이미지를 그대로 사용할 때 발생합니다. 4K 해상도 이미지를 300x200 픽셀로 표시하면서 전체 파일을 다운로드하게 만들죠.

이미지는 종종 페이지 전체 용량의 70% 이상을 차지하며, 최적화하지 않으면 사용자 경험과 SEO 모두에 악영향을 미칩니다. 바로 이럴 때 필요한 것이 이미지 최적화입니다.

적절한 포맷 선택, 반응형 이미지, 레이지 로딩, 압축 등을 조합하여 이미지 용량을 80% 이상 줄이면서도 시각적 품질은 유지합니다.

개요

간단히 말해서, 이미지 최적화는 적절한 크기, 포맷, 압축률을 선택하고, 필요할 때만 로드하여 페이지 성능을 극적으로 개선하는 기법입니다. 실무에서는 거의 모든 웹 프로젝트에 필수적입니다.

특히 이커머스, 블로그, 포트폴리오처럼 이미지가 많은 사이트에서 더욱 중요합니다. 예를 들어, 상품 리스트에 100개의 이미지가 있다면, 각 이미지를 1MB에서 100KB로 줄이면 총 90MB를 절약할 수 있습니다.

기존에는 Photoshop에서 수동으로 이미지를 리사이즈하고 압축했다면, 현대 도구(Sharp, ImageMagick, Next.js Image)는 자동으로 여러 크기와 포맷을 생성하고 최적의 것을 제공합니다. 이 기법의 핵심 특징은 첫째, WebP/AVIF 같은 현대 포맷으로 파일 크기를 50% 이상 줄일 수 있고, 둘째, srcset과 sizes로 디바이스에 맞는 해상도를 제공하며, 셋째, 레이지 로딩으로 초기 로딩을 가속화할 수 있다는 점입니다.

이러한 특징들이 웹 성능 최적화의 가장 효과적인 방법 중 하나로 만듭니다.

코드 예제

<!-- 반응형 이미지 with 레이지 로딩 -->
<picture>
  <!-- 현대 브라우저용 WebP -->
  <source
    srcset="
      /images/product-small.webp 300w,
      /images/product-medium.webp 600w,
      /images/product-large.webp 1200w
    "
    sizes="(max-width: 600px) 300px, (max-width: 1200px) 600px, 1200px"
    type="image/webp"
  />

  <!-- 폴백용 JPEG -->
  <source
    srcset="
      /images/product-small.jpg 300w,
      /images/product-medium.jpg 600w,
      /images/product-large.jpg 1200w
    "
    sizes="(max-width: 600px) 300px, (max-width: 1200px) 600px, 1200px"
    type="image/jpeg"
  />

  <!-- 기본 이미지 -->
  <img
    src="/images/product-medium.jpg"
    alt="상품 이미지"
    loading="lazy"
    width="600"
    height="400"
  />
</picture>

<!-- JavaScript로 동적 이미지 최적화 -->
<script>
  // Intersection Observer로 고급 레이지 로딩
  const imageObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.add('loaded');
        imageObserver.unobserve(img);
      }
    });
  }, { rootMargin: '50px' }); // 50px 전에 미리 로드

  document.querySelectorAll('img[data-src]').forEach(img => {
    imageObserver.observe(img);
  });
</script>

설명

이것이 하는 일: 위 코드는 HTML 표준 기능과 JavaScript API를 활용하여 이미지를 최적화하고, 사용자의 디바이스와 네트워크 상황에 맞는 최적의 이미지를 제공합니다. 첫 번째로, picture 태그와 source 태그는 브라우저가 지원하는 포맷과 화면 크기에 따라 최적의 이미지를 선택하도록 합니다.

최신 Chrome은 WebP를 선택하여 파일 크기를 50% 줄이고, 구형 브라우저는 JPEG 폴백을 사용합니다. srcset의 300w, 600w, 1200w는 이미지의 실제 너비를 의미하며, sizes 속성은 뷰포트 크기별로 이미지가 차지할 공간을 브라우저에 알려줍니다.

그 다음으로, loading="lazy" 속성은 브라우저 네이티브 레이지 로딩을 활성화합니다. 이미지가 뷰포트에 가까워질 때까지 로딩을 지연시켜 초기 페이지 로드 시 대역폭을 절약합니다.

width와 height 속성을 명시하면 브라우저가 레이아웃을 미리 계산하여 CLS(Cumulative Layout Shift)를 방지합니다. 마지막으로, Intersection Observer를 사용한 JavaScript 코드는 더 세밀한 제어를 제공합니다.

rootMargin: '50px'는 이미지가 뷰포트에 도달하기 50px 전에 미리 로딩을 시작하여, 사용자가 스크롤했을 때 이미지가 이미 준비되어 있게 합니다. 이는 native lazy loading보다 더 부드러운 경험을 제공하며, 로딩 애니메이션이나 블러 효과 같은 고급 기능도 추가할 수 있습니다.

여러분이 이 코드를 사용하면 페이지 로딩 시간이 30-50% 단축되고, 모바일 데이터 사용량이 대폭 줄어듭니다. Lighthouse 성능 점수가 향상되고, 구글 검색 순위에도 긍정적인 영향을 미칩니다.

특히 느린 네트워크 환경의 사용자에게 훨씬 나은 경험을 제공할 수 있습니다.

실전 팁

💡 빌드 타임에 Sharp나 ImageMagick으로 여러 크기의 이미지를 자동 생성하세요. Next.js Image나 Gatsby Image는 이를 자동화합니다.

💡 WebP 외에 AVIF도 고려하세요. 파일 크기가 WebP보다 20% 더 작지만 인코딩 시간이 길고 브라우저 지원이 제한적입니다.

💡 LCP(Largest Contentful Paint)에 해당하는 히어로 이미지는 레이지 로딩하지 마세요. 오히려 preload로 우선순위를 높이는 것이 좋습니다.

💡 CDN의 이미지 최적화 기능(Cloudinary, Cloudflare Images)을 활용하면 별도 빌드 과정 없이 URL 파라미터로 크기와 포맷을 제어할 수 있습니다.

💡 블러 업(blur-up) 기법으로 초저해상도 이미지를 먼저 보여주고 고해상도로 교체하면 체감 성능이 크게 향상됩니다. CSS transition으로 부드러운 전환 효과도 추가하세요.


8. 캐싱 전략으로 반복 요청 제거

시작하며

여러분이 API를 호출하는 앱을 만들었는데, 같은 데이터를 몇 초 간격으로 반복해서 요청해서 서버 비용이 폭증하고 속도도 느린 경험을 해보셨나요? 사용자가 페이지를 새로고침할 때마다 변하지 않는 설정 데이터를 다시 가져오는 상황 말이에요.

이런 문제는 캐싱 없이 모든 요청을 서버로 보낼 때 발생합니다. 네트워크 지연은 항상 존재하고, 서버 처리 시간도 필요하며, API 비용도 누적됩니다.

특히 SPA에서는 컴포넌트 리렌더링마다 같은 데이터를 요청하는 경우가 흔합니다. 바로 이럴 때 필요한 것이 캐싱 전략입니다.

클라이언트 사이드에서 응답을 저장해두고, 유효 기간 내에는 서버 요청 없이 캐시에서 즉시 반환하여 속도와 비용을 모두 개선합니다.

개요

간단히 말해서, 캐싱은 네트워크 응답이나 계산 결과를 로컬에 저장해두었다가, 같은 요청이 발생하면 서버 왕복 없이 즉시 반환하는 기법입니다. 실무에서는 API 응답, 정적 자산, 계산 결과 등 거의 모든 데이터에 적용할 수 있습니다.

예를 들어, 사용자 프로필 정보는 자주 변하지 않으므로 5분간 캐시하면 같은 세션 내에서 수십 번의 API 호출을 한 번으로 줄일 수 있습니다. 기존에는 변수에 데이터를 저장하는 단순한 방법을 사용했다면, 현대 캐싱 라이브러리(SWR, React Query)는 자동 갱신, 백그라운드 재검증, 캐시 무효화 등 고급 기능을 제공합니다.

이 기법의 핵심 특징은 첫째, TTL(Time To Live)로 캐시 유효 기간을 제어할 수 있고, 둘째, 메모리 캐시와 영구 캐시(LocalStorage, IndexedDB)를 상황에 맞게 선택하며, 셋째, stale-while-revalidate 패턴으로 신선한 데이터와 빠른 응답을 동시에 달성할 수 있다는 점입니다. 이러한 특징들이 현대 웹 애플리케이션의 반응성을 크게 향상시킵니다.

코드 예제

// 간단한 메모리 캐시 구현
class APICache {
  constructor(ttl = 5 * 60 * 1000) { // 기본 5분
    this.cache = new Map();
    this.ttl = ttl;
  }

  // 캐시 키 생성
  createKey(url, params) {
    return `${url}?${JSON.stringify(params)}`;
  }

  // 캐시에서 가져오기
  get(url, params = {}) {
    const key = this.createKey(url, params);
    const cached = this.cache.get(key);

    if (!cached) return null;

    // TTL 확인
    if (Date.now() - cached.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }

    console.log('캐시 히트:', key);
    return cached.data;
  }

  // 캐시에 저장
  set(url, params, data) {
    const key = this.createKey(url, params);
    this.cache.set(key, {
      data,
      timestamp: Date.now()
    });
  }

  // 특정 URL의 캐시 무효화
  invalidate(url) {
    for (const key of this.cache.keys()) {
      if (key.startsWith(url)) {
        this.cache.delete(key);
      }
    }
  }
}

// 사용 예시
const cache = new APICache(5 * 60 * 1000); // 5분 TTL

async function fetchUser(userId) {
  // 캐시 확인
  const cached = cache.get('/api/user', { userId });
  if (cached) return cached;

  // 캐시 미스 - 실제 API 호출
  console.log('API 호출:', userId);
  const response = await fetch(`/api/user/${userId}`);
  const data = await response.json();

  // 캐시에 저장
  cache.set('/api/user', { userId }, data);
  return data;
}

// 여러 번 호출해도 실제로는 한 번만 요청
fetchUser(1).then(console.log); // API 호출
fetchUser(1).then(console.log); // 캐시 히트
setTimeout(() => fetchUser(1).then(console.log), 1000); // 캐시 히트

설명

이것이 하는 일: 위 코드는 재사용 가능한 API 캐시 클래스를 구현하여, 동일한 요청이 반복될 때 네트워크 호출을 생략하고 저장된 응답을 즉시 반환합니다. 실제 애플리케이션에서 응답 시간을 밀리초 단위로 단축합니다.

첫 번째로, APICache 클래스는 Map 객체를 사용하여 캐시를 메모리에 저장합니다. createKey 메서드는 URL과 파라미터를 조합하여 고유한 캐시 키를 생성합니다.

예를 들어 /api/user?{"userId":1}/api/user?{"userId":2}는 서로 다른 캐시 항목으로 관리됩니다. 이렇게 하면 같은 엔드포인트라도 파라미터가 다르면 별도로 캐시할 수 있습니다.

그 다음으로, get 메서드가 캐시를 조회합니다. 먼저 해당 키가 존재하는지 확인하고, 존재한다면 타임스탬프를 확인하여 TTL이 만료되었는지 검사합니다.

만료되지 않았다면 저장된 데이터를 즉시 반환하고, 만료되었다면 캐시를 삭제하고 null을 반환하여 새로운 요청이 필요함을 알립니다. 이 과정은 1ms도 걸리지 않아 네트워크 요청(보통 100-500ms)과 비교할 수 없을 정도로 빠릅니다.

마지막으로, fetchUser 함수에서 실제로 캐시를 활용합니다. 함수를 호출하면 먼저 캐시를 확인하고, 캐시 히트면 즉시 반환하여 네트워크 요청을 건너뜁니다.

캐시 미스면 실제 API를 호출하고 응답을 캐시에 저장합니다. 같은 userId로 여러 번 호출해도 첫 번째 호출 이후 5분간은 모두 캐시에서 처리되어 서버 부하가 줄어듭니다.

여러분이 이 코드를 사용하면 API 호출 횟수를 70-90% 줄일 수 있습니다. 사용자는 더 빠른 응답을 경험하고, 서버 비용이 절감되며, 네트워크 실패에도 더 강건한 앱을 만들 수 있습니다.

React Query나 SWR 같은 라이브러리는 이 패턴을 더욱 발전시켜 자동 백그라운드 갱신, 낙관적 업데이트 등도 제공합니다.

실전 팁

💡 민감한 데이터는 메모리 캐시만 사용하고, 정적 데이터는 LocalStorage나 IndexedDB에 저장하여 새로고침 후에도 재사용하세요.

💡 캐시 무효화 전략을 명확히 하세요. 데이터가 변경되는 뮤테이션(POST, PUT, DELETE) 후에는 관련 캐시를 즉시 무효화해야 합니다.

💡 stale-while-revalidate 패턴을 구현하면 캐시를 즉시 반환하면서 동시에 백그라운드에서 갱신하여 신선한 데이터와 빠른 응답을 동시에 달성할 수 있습니다.

💡 캐시 크기를 제한하는 LRU(Least Recently Used) 정책을 추가하여 메모리 사용량을 제어하세요. 특히 장시간 실행되는 SPA에서 중요합니다.

💡 개발 환경에서는 캐시를 비활성화하거나 짧은 TTL을 사용하세요. 캐시 때문에 최신 API 변경사항이 반영되지 않아 혼란스러울 수 있습니다. 이상으로 "Agile 성능 최적화 완벽 가이드" 카드 뉴스를 작성했습니다! 각 개념은 실무에서 즉시 활용할 수 있는 상세한 설명과 코드 예제를 포함하고 있습니다. 초급 개발자도 이해하기 쉽게 친근한 톤으로 작성했으며, 애자일 환경에서 성능 최적화를 점진적으로 적용할 수 있도록 구성했습니다.


#JavaScript#Performance#Agile#Optimization#Monitoring
Agile 완벽 마스터 | CodeDeck | CodeDeck