프론트엔드 개발을 하며 검색창 자동완성이나 탭 전환 메뉴, 좋아요 버튼 같은 기능을 만들다 보면 사용자가 버튼을 마구 연타하거나 키보드를 빠르게 칠 때가 있습니다.

과거 프로젝트에서 사용자가 탭 버튼을 'A ➔ B ➔ C'로 빠르게 연속 클릭했을 때 황당한 버그를 마주쳤습니다. 분명 사용자는 마지막에 'C 탭'을 눌렀는데, 네트워크 응답 지연으로 인해 가장 늦게 도착한 'A 탭'의 데이터가 화면을 덮어씌워 버리는 '경쟁 상태(Race Condition)'가 발생한 것입니다.

이로 인해 화면에는 잘못된 데이터가 표시되었고, 서버에는 이미 쓸모없어진 이전 탭들의 불필요한 API 요청이 그대로 쌓여 리소스를 낭비하고 있었습니다.

오늘은 브라우저의 표준 내장 API인 AbortController를 활용하여 이전 비동기 요청을 깔끔하게 취소하고 경쟁 상태를 방지했던 노하우를 공유해 보려고 합니다.

1. 내가 마주친 문제: "왜 마지막으로 클릭한 결과가 안 나올까?" (경쟁 상태)

네트워크 요청은 항상 보낸 순서대로 도착한다는 보장이 없습니다.

  • 1번 요청 (A 탭 클릭, 응답 시간 1.5초)
  • 2번 요청 (B 탭 클릭, 응답 시간 0.2초)

사용자가 A를 누르고 곧바로 B를 눌렀을 때, B의 응답이 먼저 화면에 반영되었다가 1초 뒤 뒤늦게 도착한 A의 응답이 화면을 덮어버리는 끔찍한 UX 참사가 발생합니다.

이 문제를 해결하려면 "새로운 요청이 발생하면, 아직 끝나지 않은 이전 요청은 브라우저 차원에서 즉시 취소(Abort)"해야 합니다.

2. 해결책: 브라우저 표준 AbortController란?

AbortController는 웹 브라우저에 내장된 객체로, 하나 이상의 웹 요청(예: fetch API)을 원하는 시점에 중단할 수 있는 신호(Signal)를 제공합니다.

  • controller.signal: 통신 함수(fetch)에 전달하여 취소 신호를 수신할 수 있게 연결합니다.
  • controller.abort(): 이 메서드를 호출하면 연결된 네트워크 요청이 브라우저 수준에서 즉시 취소됩니다.

3. 실무 적용 1: fetch에 취소 기능 적용하기

새로운 요청을 시작하기 직전, 이전 컨트롤러의 abort()를 실행해 이전 통신을 취소하도록 작성했습니다.

TypeScript
 
// ⭕ AbortController를 활용한 안전한 API 호출 함수
let previousController: AbortController | null = null;

async function fetchSearchResults(query: string) {
  // 1. 이미 진행 중인 이전 요청이 있다면 취소 신호 발송!
  if (previousController) {
    previousController.abort();
  }

  // 2. 새로운 요청을 위한 컨트롤러 생성
  previousController = new AbortController();
  const { signal } = previousController;

  try {
    const response = await fetch(`https://api.example.com/search?q=${query}`, {
      signal, // 👈 fetch 두 번째 옵션에 signal 전달!
    });
    const data = await response.json();
    return data;
  } catch (error: any) {
    // 취소로 인한 중단인 경우 에러 처리에서 안전하게 무시
    if (error.name === 'AbortError') {
      console.log('이전 요청이 안전하게 취소되었습니다.');
    } else {
      console.error('실제 네트워크 에러 발생:', error);
    }
  }
}

이렇게 하면 사용자가 검색어를 빠르게 연속 입력하더라도, 마지막 입력값에 대한 응답만 정상 처리되고 앞선 요청들은 네트워크 레벨에서 모두 취소됩니다.

new AbortController() 및 fetch(url, { signal }) 코드

4. 실무 적용 2: React useEffect 클린업(Clean-up)에서 요청 취소하기

React 컴포넌트 안에서 데이터를 불러올 때는 useEffect의 뒷정리(Clean-up) 함수에서 controller.abort()를 호출하는 것이 정석 패턴입니다.

컴포넌트가 언마운트되거나 의존성 변수가 변경될 때 이전 요청을 자동으로 끊어주어 메모리 누수(Memory Leak)를 완벽히 막아줍니다.

TypeScript
 
import { useState, useEffect } from 'react';

export default function UserDetail({ userId }: { userId: number }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then((data) => setData(data))
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    // 💡 컴포넌트가 사라지거나 userId가 바뀌면 이전 요청 즉시 취소!
    return () => {
      controller.abort();
    };
  }, [userId]);

  return
{data ? JSON.stringify(data) : '로딩 중...'}
;
}

연속 요청시 이전 요청이 취소됨

5. 결론 및 느낀 점

이전에는 비동기 통신 에러가 나면 무조건 백엔드나 네트워크 망 문제라고만 생각했습니다.

하지만 프론트엔드 단에서 AbortController를 통해 불필요한 요청을 선제적으로 취소해 주는 것만으로도, 예기치 않은 버그(경쟁 상태)를 원천 차단하고 서버 트래픽 비용을 획기적으로 줄일 수 있었습니다.

검색창 실시간 자동완성이나 탭 전환 컴포넌트를 만들고 계신다면, 지금 바로 AbortController를 도입해 안전하고 매끄러운 통신 환경을 구축해 보세요!

웹 사이트를 개발하다가 사용자가 실수로 브라우저 새로고침(F5)을 누르거나 페이지를 이동했을 때, 기껏 작성 중이던 폼 데이터나 다크 모드 설정값이 초기화되어 날아가는 문제를 겪어보신 적 있으신가요?

저 역시 초기 프로젝트에서 모든 상태를 React useState로만 관리했다가, 사용자가 브라우저를 껐다 켜거나 새로고침할 때마다 설정이 리셋되는 피드백을 받았습니다.

서버 데이터베이스(DB)에 일일이 저장하기에는 너무 자잘한 프론트엔드 설정값들(다크 모드 테마, 장바구니 임시 데이터, 팝업 '오늘 하루 보지 않기' 등)을 어디에 보관해야 할지 고민하다가 브라우저 저장소(Web Storage API)를 본격적으로 활용하게 되었습니다.

오늘은 브라우저의 대표적인 3대 저장소인 LocalStorage, SessionStorage, Cookie의 차이점과 실무에서 안전하게 활용했던 노하우를 공유해 보려고 합니다.

1. 3대 브라우저 저장소 핵심 비교: "어디에 무엇을 저장해야 할까?"

웹 브라우저가 제공하는 저장소는 수명(Lifecycle)과 용도에 따라 명확히 나뉩니다.

  • LocalStorage (로컬 스토리지): 브라우저 창을 닫아도 데이터가 영구적으로 유지됩니다. (용량 약 5MB) ➔ 다크 모드 테마, 사용자 맞춤 UI 설정에 적합
  • SessionStorage (세션 스토리지): 현재 탭(세션)이 열려 있는 동안에만 유지되며, 탭을 닫으면 즉시 삭제됩니다. ➔ 일회성 폼 입력 중간 저장, 결제 진행 단계 임시 데이터에 적합
  • Cookie (쿠키): 만료일을 설정할 수 있고 서버 요청 시 자동으로 헤더에 포함됩니다. (용량 약 4KB) ➔ 로그인 인증 토큰(HttpOnly) 등에 주로 사용

2. 실무 적용 1: LocalStorage로 다크 모드(Theme) 설정 영구 저장하기

사용자가 라이트 모드/다크 모드를 선택했을 때, 브라우저를 닫고 며칠 뒤에 다시 들어와도 그 설정을 기억하도록 localStorage를 적용했습니다.

TypeScript
 
// ⭕ 로컬스토리지에 테마 저장 및 불러오기 함수
const THEME_KEY = 'app_user_theme';

// 1. 테마 저장하기
export const saveTheme = (theme: 'light' | 'dark') => {
  try {
    localStorage.setItem(THEME_KEY, theme);
  } catch (error) {
    console.error('스토리지 저장 실패:', error);
  }
};

// 2. 테마 불러오기 (기본값: 'dark')
export const getSavedTheme = (): 'light' | 'dark' => {
  if (typeof window === 'undefined') return 'dark';
  const saved = localStorage.getItem(THEME_KEY);
  return saved === 'light' ? 'light' : 'dark';
};

3. 실무 적용 2: 객체(Object)나 배열 다룰 때 JSON.stringify 필수 사용

초보 시절 가장 많이 범했던 실수는 객체 데이터를 그대로 localStorage.setItem('user', { name: 'dev' })로 저장했던 것입니다.

브라우저 스토리지는 문자열(String)만 저장할 수 있기 때문에, 객체를 그냥 넣으면 [object Object]라는 깨진 문자열로 변환되어 저장됩니다.

JavaScript
 
const userProfile = { id: 1, name: '김개발', role: 'frontend' };

// ❌ 잘못된 저장 방식: [object Object]로 저장됨
localStorage.setItem('user', userProfile);

// ⭕ 올바른 저장 방식: JSON 문자열로 직렬화 후 저장
localStorage.setItem('user', JSON.stringify(userProfile));

// ⭕ 꺼내올 때: JSON.parse로 다시 자바스크립트 객체로 변환
const savedUser = JSON.parse(localStorage.getItem('user'));
console.log(savedUser.name); // '김개발' 정상 접근 가능!

localStorage.setItem 및 JSON.stringify

4. 실무 적용 3: 크롬 개발자 도구(Application 탭)로 데이터 확인하기

스토리지에 데이터가 제대로 들어갔는지 확인하려면 브라우저 개발자 도구의 Application 탭을 활용하면 됩니다.

  1. F12 개발자 도구 열기
  2. 상단 메뉴 중 Application (애플리케이션) 클릭
  3. 좌측 메뉴 Storage > Local Storage 클릭 후 내 도메인 선택

그러면 내가 코드로 저장한 Key와 Value가 실시간으로 표 형태로 직관적이게 표시되는 것을 확인할 수 있습니다.

local storage저장 확인

5. 결론 및 주의할 점

웹 스토리지는 사용하기 정말 편리하지만, 보안상 한 가지 중요한 원칙이 있습니다.

  • LocalStorage에는 절대 민감한 정보(비밀번호, 개인정보, 중요한 API 시크릿 키)를 저장하지 말 것!

XSS(Cross-Site Scripting) 공격에 노출될 경우 자바스크립트 코드로 쉽게 탈취될 수 있기 때문입니다. 따라서 UI 설정값, 캐시성 데이터, 비인증 임시 상태 위주로 활용하고, 중요한 인증 토큰은 안전한 HttpOnly Cookie를 활용하는 것이 모범 사례입니다.

이번에는 지금까지 다룬 주제들(Next.js 최적화, WebP/AVIF, 배열 메서드, TS 타입 에러, VS Code 확장, useEffect, Git 잔디, CORS)과 완전히 겹치지 않으면서 프론트엔드 최적화의 핵심이자 검색 노출(SEO) 점수에도 매우 유리한 주제로 준비했습니다!

주제는 [Web] 스크롤 이벤트 버벅거림 해결기: 디바운스(Debounce)와 스로틀(Throttle) 실무 적용법입니다. 생생한 개발 경험담과 고민 서사, 본문, 그리고 사진 첨부 가이드 1, 2번용 코드 및 눈속임 테스트 팁까지 한 번에 담아 드립니다.

[Web] 스크롤 한 번에 함수가 500번 실행된다고? 디바운스와 스로틀로 브라우저 렉 잡은 후기

웹 사이트에 무한 스크롤(Infinite Scroll)이나 상단 고정 헤더(Sticky Header), 실시간 검색창 자동완성 기능을 구현하다가 화면이 뚝뚝 끊기는 버벅거림(Jank) 현상을 겪어보신 적 있으신가요?

저 역시 예전 프로젝트에서 스크롤 이벤트(window.addEventListener('scroll', ...))를 붙여두고 아무 생각 없이 개발자 도구 콘솔을 열었다가 경악을 금치 못했습니다. 마우스 휠을 단 1초 굴렸을 뿐인데 이벤트 핸들러가 무려 300~500번 넘게 폭주하며 호출되고 있었던 것입니다.

이로 인해 모바일 기기나 저사양 PC에서는 스크롤이 심하게 버벅거렸고, 브라우저 성능 탭에서는 프레임 드랍(Frame Drop) 경고가 쏟아졌습니다.

오늘은 과도한 이벤트 호출로 인한 브라우저 과부하를 막고 성능을 극적으로 개선해 준 디바운스(Debounce)와 스로틀(Throttle)의 차이점 및 실무 적용 노하우를 공유해 보려고 합니다.

1. 내가 마주친 문제: "이벤트 폭주로 인한 브라우저 과부하"

웹 브라우저의 scroll, resize, mousemove 같은 이벤트는 사용자가 조작하는 동안 밀리초(ms) 단위로 매우 빈번하게 발생합니다.

JavaScript
 
// ❌ 휠 한 번에 수백 번 실행되어 성능을 갉아먹는 단순 스크롤 리스너
window.addEventListener('scroll', () => {
  console.log('현재 스크롤 위치 계산 중...'); // 1초에 수백 번 실행!
  // 만약 여기에 무거운 DOM 조작이나 API 호출이 들어있다면 브라우저는 바로 렉 발생
});

이벤트가 발생할 때마다 복잡한 연산이나 리렌더링, API 요청이 일어난다면 브라우저는 렌더 트리를 다시 계산(Reflow / Repaint)하느라 화면을 매끄럽게 그리지 못하게 됩니다.

이 문제를 해결하기 위해 이벤트 실행 주기를 제어하는 디바운스와 스로틀 기법을 적용했습니다.

2. 해결책 1: 마지막 입력 후 딱 한 번만 실행하는 '디바운스(Debounce)'

디바운스(Debounce)는 연이어 호출되는 이벤트 중 마지막 이벤트가 발생하고 일정 시간이 지난 후에 딱 한 번만 콜백 함수를 실행하는 기술입니다.

  • 가장 적합한 곳: 검색창 자동완성(실시간 검색 API 호출), 입력 폼 유효성 검사, 브라우저 창 크기 조절(resize) 완료 시점 감지.
JavaScript
 
// ⭕ 디바운스 유틸 함수 직접 구현
function debounce(func, delay = 300) {
  let timerId = null;

  return (...args) => {
    // 이전 타이머가 남아있다면 취소하고 새 타이머 시작
    if (timerId) clearTimeout(timerId);
    
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// 실시간 검색창 입력 이벤트에 적용
const handleSearch = debounce((query) => {
  console.log(`서버 API 요청 날리기: ${query}`);
}, 500);

사용자가 '타이핑을 완전히 멈췄을 때' 비로소 API를 1번만 호출하므로, 불필요한 서버 트래픽을 90% 이상 아낄 수 있었습니다.

useDebounce 훅

3. 해결책 2: 정해진 주기마다 주기적으로 실행하는 '스로틀(Throttle)'

스로틀(Throttle)은 이벤트가 수없이 발생하더라도, 설정한 시간 주기(예: 200ms)마다 최대 1번만 실행되도록 제한(Throttling)을 거는 기술입니다.

  • 가장 적합한 곳: 무한 스크롤 페이지 바닥 감지, 스크롤 애니메이션 진행률 계산, 마우스 드래그 이벤트.
JavaScript
 
// ⭕ 스로틀 유틸 함수 직접 구현
function throttle(func, limit = 200) {
  let inThrottle = false;

  return (...args) => {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      
      // 설정한 시간이 지난 후에야 다음 실행을 허용
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

// 스크롤 이벤트에 스로틀 적용
window.addEventListener(
  'scroll',
  throttle(() => {
    console.log('200ms마다 안정적으로 스크롤 위치 계산');
  }, 200)
);

스로틀을 적용하니 초당 수백 번 발생하던 스크롤 이벤트가 초당 단 5번(200ms 기준)으로 제한되면서, 부드러운 60fps 프레임을 유지할 수 있게 되었습니다.

4. 직접 비교해 본 디바운스 vs 스로틀 핵심 요약

구분 디바운스 (Debounce) 스로틀 (Throttle)
작동 원리 연속된 이벤트가 끝나고 일정 시간 후 1회 실행 연속된 이벤트 중 일정한 주기마다 1회씩 실행
핵심 목적 마지막 최종 결과물만 필요할 때 중간 진행 상태를 지속적으로 확인해야 할 때
추천 활용처 실시간 검색창 입력, Resize 창 크기 변경 무한 스크롤, 스크롤 위치 감지, 드래그

 

[Throttle] 스크롤 이벤트 실행

5. 결론 및 느낀 점

이전에는 브라우저 버벅임이 발생하면 무조건 React의 useMemo나 복잡한 리팩토링부터 고민했었습니다.

하지만 이벤트 제어 기법인 디바운스와 스로틀을 적재적소에 배치하는 것만으로도 브라우저 CPU 점유율을 대폭 낮추고 체감 렌더링 성능을 극대화할 수 있었습니다.

사용자 입력이나 스크롤 이벤트가 잦은 프론트엔드 프로젝트를 개발 중이시라면, 오늘 꼭 불필요한 이벤트 호출이 폭주하고 있지는 않은지 점검해 보시길 강력히 추천합니다!

리액트(React)로 프로젝트를 진행하며 API 데이터를 불러오거나 컴포넌트 생명주기(Lifecycle)를 다룰 때 가장 먼저 접하게 되는 훅(Hook)이 바로 useEffect입니다.

처음 useEffect를 배울 때는 "그냥 컴포넌트가 화면에 나타날 때 실행하고 싶은 코드를 넣어두는 함수" 정도로 가볍게 생각하곤 했습니다. 하지만 프로젝트 규모가 커지면서 어느 날 브라우저 탭이 갑자기 렉을 먹고 먹통이 되거나, 개발자 도구 콘솔에 수백 개의 API 요청 로그가 쏟아지는 '무한 리렌더링(Infinite Loop)' 참사를 겪게 되었습니다.

오늘은 제가 초보 시절 useEffect를 오용하면서 겪었던 무한 리렌더링의 원인과, 이를 안전하고 올바르게 다루기 위한 의존성 배열(Dependency Array) 관리 노하우를 공유해 보려고 합니다.

1. 내가 마주친 문제: "브라우저 콘솔창이 터져나간 이유"

특정 유저 정보를 서버에서 받아와 상태(State)에 저장하는 흔한 로직을 작성하던 중이었습니다.

별생각 없이 useEffect 내부에서 데이터를 받아와 setState를 호출했는데, 페이지에 진입하자마자 브라우저가 느려지더니 서버로 동일한 API 요청이 초당 수십 번씩 계속 날아가는 현상이 발생했습니다.

TypeScript
 
// ❌ 무한 리렌더링을 유발했던 과거의 위험한 코드
import { useState, useEffect } from 'react';

export default function UserProfile() {
  const [user, setUser] = useState(null);

  // 🚨 두 번째 인자인 의존성 배열(Dependency Array)을 빠뜨림!
  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => {
        setUser(data); // 상태 변경 -> 컴포넌트 리렌더링 -> useEffect 재실행 -> 무한 반복!
      });
  });

  return <div>{user?.name}</div>;
}

💡 무한 루프가 발생한 원인

useEffect의 두 번째 인자인 의존성 배열([])을 완전히 생략해 버리면, 리액트는 컴포넌트가 리렌더링 될 때마다 useEffect 내부 콜백 함수를 매번 실행합니다.

useEffect 내부에서 setUser로 상태를 바꾸면 ➔ 리액트는 상태가 바뀌었으니 컴포넌트를 리렌더링하고 ➔ 리렌더링 되었으니 useEffect가 또 실행되어 setUser를 호출하는 지옥의 악순환이 만들어진 것입니다.

2. 해결책 1: 빈 의존성 배열([])로 '마운트 시 1회 실행' 보장하기

이 문제를 가장 쉽게 해결하는 방법은 useEffect 두 번째 인자로 빈 배열([])을 넘겨주는 것입니다.

TypeScript
 
// ⭕ 빈 배열 []을 넣어 컴포넌트 마운트 시 최초 1회만 실행되도록 수정
useEffect(() => {
  fetch('/api/user')
    .then((res) => res.json())
    .then((data) => setUser(data));
}, []); // 👈 빈 의존성 배열 추가!

이렇게 설정하면 컴포넌트가 화면에 처음 그려질 때(Mount) 딱 한 번만 API 요청을 수행하므로 안전합니다.

useEffect 두 번째 인자로 빈 배열 [] 이 들어간 코드

3. 해결책 2: 객체/함수를 의존성 배열에 넣을 때 발생하는 함정 피하기

빈 배열을 넣는 것만으로 모든 문제가 해결되지는 않았습니다. 특정 조건(예: 검색어 ID 변경)에 따라 데이터를 다시 불러와야 할 때는 의존성 배열에 변수를 넣어주어야 합니다.

이때 객체(Object)나 함수(Function)를 의존성 배열에 그대로 넣으면 다시 무한 루프가 터지는 함정에 빠질 수 있습니다.

TypeScript
 
// ❌ 객체를 참조 타입으로 넣어 무한 루프가 발생하는 잘못된 예시
export default function SearchList({ options }) { // options가 객체인 경우
  const [list, setList] = useState([]);

  useEffect(() => {
    fetchData(options).then((data) => setList(data));
  }, [options]); // 🚨 부모가 리렌더링될 때마다 options 객체의 참조값이 새로 바뀌어 계속 실행됨!
}

자바스크립트에서 객체나 함수는 리렌더링될 때마다 메모리 주소(참조값)가 매번 새로 생성됩니다. 따라서 내용물이 똑같아도 리액트는 "의존성 값이 바뀌었네?" 하고 useEffect를 또 실행해 버립니다.

💡 해결 방법 (원시 값 전달 또는 useCallback / useMemo 활용)

  • 객체 전체 대신 객체 내부의 원시 값(options.id 등)을 의존성 배열에 넣기
  • 함수를 의존성 배열에 넣어야 할 경우 useCallback으로 감싸 메모이제이션하기
TypeScript
 
// ⭕ 객체 전체가 아닌 원시 값(id)을 의존성 배열에 넣어 안전하게 관리
useEffect(() => {
  fetchData(options.id).then((data) => setList(data));
}, [options.id]); // 👈 객체의 속성값(number, string)을 지정!

의존성 배열 관련 코드

4. 정리 및 깨달은 점

useEffect는 단순히 코드를 실행해 주는 부수 효과 도구가 아닙니다. "컴포넌트의 상태 및 렌더링 주기와 외부 시스템을 동기화하는 도구"입니다.

  1. useEffect 내부에서 상태(state)를 변경한다면 의존성 배열이 제대로 설정되어 있는지 필수 점검할 것
  2. 의존성 배열에 참조 타입(객체, 배열, 함수)을 넣을 때는 참조값 변경에 유의할 것

이 두 가지만 명확히 기억해 두셔도 무한 리렌더링으로 브라우저가 다운되는 불상사를 완벽히 예방할 수 있습니다!

프런트엔드 개발을 처음 시작하고 자바스크립트(JavaScript)로 데이터를 다룰 때, 제 코드의 90%는 for문이나 forEach문으로 가득 차 있었습니다.

백엔드 API에서 받아온 리스트 데이터를 화면에 뿌려주거나, 특정 조건의 아이템만 걸러내고, 총합을 계산할 때도 항상 습관적으로 빈 배열을 하나 선언해 두고 for문을 돌리곤 했습니다.

동작은 잘 되었지만, 코드가 수십 줄로 길어지면서 변수가 오염되거나 가독성이 뚝 떨어지는 문제를 경험했습니다. 그러다 리액트(React)를 본격적으로 다루면서 map, filter, reduce 같은 고차 배열 함수의 진짜 위력을 깨닫게 되었습니다.

오늘은 제가 실무 및 프로젝트 코딩 중 for문 지옥에서 탈출해 배열 메서드 3대장을 실무에서 깔끔하게 활용하게 된 경험과 작성 팁을 정돈해 보려고 합니다.

1. map: 기존 배열을 변형해 새로운 UI 데이터 만들기

처음 리액트로 목록(List) 컴포넌트를 만들 때 map을 만나고 상당한 충격을 받았습니다.

기존에는 빈 배열을 만들어서 push()로 일일이 담아야 했던 작업이, map을 사용하니 원본 배열을 훼손하지 않으면서(불변성 유지) 가공된 새로운 배열을 반환해 주는 깔끔한 코드로 변했습니다.

JavaScript
 
// ❌ 옛날에 작성했던 지저분한 for문 방식
const rawUsers = [
  { id: 1, name: '김철수', age: 24 },
  { id: 2, name: '이영희', age: 29 },
];

const userNames = [];
for (let i = 0; i < rawUsers.length; i++) {
  userNames.push(rawUsers[i].name);
}

// ⭕ map을 활용해 단 한 줄로 깔끔하게 처리한 코드
const userNamesWithMap = rawUsers.map((user) => user.name);
// 결과: ['김철수', '이영희']

💡 실무 팁: 리액트 JSX 렌더링에서의 활용

리액트에서는 API로 받아온 객체 배열을 화면의 HTML 요소로 1:1 변환할 때 map이 필수적입니다. 고유한 key값만 잘 챙겨주면 선언적인 유려한 코드를 작성할 수 있습니다.

2. filter: 검색창과 조건별 필터링 기능 구현하기

쇼핑몰 카테고리 필터나 검색창 기능을 만들 때 가장 효자 역할을 해준 메서드가 바로 filter입니다.

조건식의 결과가 true인 요소들만 뽑아서 새로운 배열로 만들어주기 때문에, complex한 조건문 없이도 깔끔한 상태 업데이트가 가능해졌습니다.

JavaScript
 
const products = [
  { id: 1, name: '개발자 키보드', price: 150000, inStock: true },
  { id: 2, name: '버티컬 마우스', price: 75000, inStock: false },
  { id: 3, name: '모니터', price: 350000, inStock: true },
];

// ⭕ 재고가 있고(inStock), 가격이 20만원 이하인 상품만 필터링
const availableProducts = products.filter(
  (product) => product.inStock && product.price <= 200000
);
// 결과: [{ id: 1, name: '키보드', ... }]

체이닝 코드

3. reduce: 장바구니 총금액 계산과 데이터 그룹화의 최종병기

처음 reduce를 접했을 때는 (accumulator, currentValue) => ... 라는 생소한 문법 때문에 괜히 어렵게 느껴져 피하곤 했습니다.

하지만 장바구니에 담긴 상품들의 총결제 금액 계산이나 특정 기준에 따른 객체 데이터 그룹화를 처리할 때 reduce만큼 완벽한 도구가 없다는 것을 깨달았습니다.

JavaScript
 
const cartItems = [
  { name: '맥북 케이스', price: 35000, count: 2 },
  { name: 'C타입 젠더', price: 12000, count: 1 },
  { name: '장패드', price: 18000, count: 3 },
];

// ⭕ 장바구니 총금액을 한 번에 구하는 reduce
const totalPrice = cartItems.reduce((acc, item) => {
  return acc + item.price * item.count;
}, 0); // 초기값(initialValue)을 0으로 설정!

console.log(totalPrice); // 출력: 136000

💡 실무 팁: 초기값(0 또는 {})을 꼭 넣어주기

reduce를 사용할 때 가장 흔하게 범하는 실수가 두 번째 인자인 초기값을 빼먹는 것입니다. 빈 배열에서 초기값 없이 reduce를 호출하면 런타임 에러(TypeError)가 터질 수 있으므로, 습관적으로 초기값을 명시해 주는 것이 안전합니다.

4. 메서드 체이닝(Chaining)으로 완성하는 단단한 코드

map, filter, reduce의 진짜 매력은 이들을 사슬처럼 이어 붙이는 '메서드 체이닝'이 가능하다는 점입니다.

JavaScript
 
// ⭕ 재고 있는 상품 중 10만원 이상인 것들의 '총 가격'만 계산하기
const totalHighPriceInStock = products
  .filter((item) => item.inStock) // 1. 재고 있는 것만 추출
  .filter((item) => item.price >= 100000) // 2. 10만원 이상만 추출
  .reduce((acc, item) => acc + item.price, 0); // 3. 합계 계산

for문으로 작성했으면 20줄이 넘어갔을 로직이, 함수형 메서드를 이어 붙이니 단 몇 줄만으로 읽기 편한 코드가 되었습니다.

코드의 실행 결과

5. 회고 및 정리

for문 위주의 명령형 프로그래밍 방식에서 벗어나 자바스크립트의 고차 배열 함수들을 익히면서, 제 코드는 훨씬 선언적(Declarative)이고 가독성이 높은 코드로 발전할 수 있었습니다.

단순히 동작하는 코드를 넘어서 '읽기 편하고 유지보수하기 쉬운 코드'를 고민 중이신 자바스크립트 초보 개발자분들이라면, 오늘 당장 map, filter, reduce 체이닝 연습부터 시작해 보시길 권해드립니다!

리액트(React) 생태계에서 성능 최적화는 언제나 뜨거운 감자입니다. 특히 useMemo와 useCallback은 리액트 개발자라면 반드시 마주하게 되는 도구이지만, 동시에 가장 오용되기 쉬운 도구이기도 합니다. 많은 개발자가 "성능에 좋겠지"라는 막연한 추측으로 모든 함수와 연산에 이 훅들을 적용하곤 합니다.

하지만 리액트 팀의 댄 아브라모프(Dan Abramov)를 비롯한 수많은 시니어 엔지니어들은 "섣부른 최적화(Premature Optimization)는 만악의 근원"이라고 경고합니다. 오늘은 useMemo와 useCallback의 내부 메커니즘을 낱낱이 파헤치고, 실무에서 성능 이득을 정량적으로 측정하여 적용하는 기준을 제시하겠습니다.


1. 메모이제이션(Memoization)의 배신: 공짜 점심은 없다

우선 우리가 사용하는 이 훅들이 공짜가 아니라는 점을 명확히 인지해야 합니다. useMemo나 useCallback을 호출할 때마다 리액트 내부에서는 다음과 같은 비용이 발생합니다.

1.1 메모리 비용 (Memory Overhead)

메모이제이션은 기본적으로 '메모리를 써서 시간을 사는' 행위입니다. 이전 계산 결과나 함수 참조를 메모리에 유지해야 하므로, 앱의 전체적인 메모리 사용량이 늘어납니다. 아주 미미해 보일 수 있지만, 수백 개의 컴포넌트에서 무분별하게 사용될 경우 가비지 컬렉션(GC)에 부담을 줄 수 있습니다.

1.2 비교 비용 (Comparison Overhead)

리액트는 매 렌더링마다 의존성 배열(deps)의 요소들을 하나씩 꺼내 이전 값과 비교하는 얕은 비교(Shallow Compare)를 수행합니다.

  • 만약 연산 자체가 매우 단순하다면(예: 두 숫자의 합), 그 연산을 다시 하는 것보다 의존성 배열을 순회하며 비교하는 비용이 더 클 수 있습니다.

2. useMemo: 무거운 연산의 기준과 참조 동일성

useMemo는 특정 연산의 결괏값을 저장합니다. 이 훅의 적용 기준은 크게 두 가지로 나뉩니다.

2.1 연산의 복잡도 (Computational Expense)

가장 흔한 오해는 모든 배열 처리(filter, map, reduce)에 useMemo를 써야 한다는 생각입니다. 하지만 자바스크립트 엔진은 생각보다 훨씬 빠릅니다.

성능 측정 기법: performance.now() 어떤 연산이 useMemo를 쓸 만큼 무거운지 알고 싶다면, 브라우저 콘솔에서 직접 측정해 보세요.

JavaScript
 
const startTime = performance.now();
doSomethingExpensive(data);
const endTime = performance.now();
console.log(`연산 소요 시간: ${endTime - startTime}ms`);

일반적으로 1ms 이상 소요되는 연산은 useMemo를 고려할 만한 '유의미한' 비용으로 간주합니다. 만약 0.1ms 미만의 연산이라면 useMemo를 사용하는 것이 오히려 손해일 가능성이 높습니다.

2.2 참조 동일성 유지 (Referential Identity)

연산 속도보다 실무에서 더 중요한 이유는 참조값 고정입니다. 리액트에서 객체({})나 배열([])은 내용이 같아도 매번 새로운 참조를 가집니다.

  • 이 값이 useEffect의 의존성 배열에 들어간다면? -> 무한 루프나 불필요한 이펙트 실행의 원인이 됩니다.
  • 이 값이 React.memo로 감싸진 자식 컴포넌트의 Props로 전달된다면? -> 자식은 내용이 변하지 않았음에도 리렌더링 됩니다.

이런 경우, 연산의 복잡도와 상관없이 참조를 일관되게 유지하기 위해 useMemo를 반드시 사용해야 합니다.


3. useCallback: 함수 리렌더링과 자식 컴포넌트의 관계

useCallback은 함수 참조를 고정합니다. 많은 초보자가 "함수 생성을 방지해서 성능을 높인다"라고 생각하지만, 자바스크립트에서 함수를 새로 생성하는 비용은 현대 브라우저에서 거의 무시해도 될 수준입니다.

3.1 React.memo와의 환상적인 조합

useCallback이 효과를 발휘하는 유일한 시점은 자식 컴포넌트가 리렌더링을 방어하고 있을 때입니다.

JavaScript
 
// 자식 컴포넌트가 memo로 최적화되어 있음
const ExpensiveList = React.memo(({ onItemClick }) => {
  console.log("목록 리렌더링 중...");
  return (
    <ul>
      {/* 복잡한 리스트 아이템들 */}
    </ul>
  );
});

const Parent = () => {
  const [text, setText] = useState("");

  // 이 함수가 useCallback으로 감싸져 있지 않다면,
  // 부모가 글자를 입력할 때마다 참조가 바뀌어 ExpensiveList가 리렌더링됨
  const handleClick = useCallback((id) => {
    console.log(id);
  }, []); // 의존성이 없으므로 단 한 번만 생성됨

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <ExpensiveList onItemClick={handleClick} />
    </>
  );
};

만약 ExpensiveList가 React.memo로 감싸져 있지 않다면, 부모가 렌더링 될 때 자식도 어차피 렌더링 되므로 useCallback은 메모리만 낭비하는 꼴이 됩니다.


4. 실무 트러블슈팅: 최적화가 오히려 성능을 해치는 신호

블로그 독자들에게 실질적인 도움을 주기 위해, 최적화 훅을 제거해야 할 때의 신호를 정리해 봅니다.

  1. 의존성 배열이 너무 빈번하게 바뀜: deps에 포함된 값이 렌더링마다 바뀐다면 메모이제이션은 작동하지 않고 비교 연산만 추가될 뿐입니다.
  2. 기본형 데이터 타입에 사용: 문자열, 숫자, 불리언은 값을 직접 비교하므로 참조 고정이 필요 없습니다.
  3. 컴포넌트 하위 계층이 단순함: 자식 컴포넌트들이 매우 가볍다면, 리렌더링 되는 비용이 최적화 로직을 타는 비용보다 저렴합니다.

5. 정량적 측정: React Profiler 활용하기

"느낌적인 느낌"으로 코드를 짜지 마세요. 리액트 개발자 도구의 Profiler 탭은 가장 강력한 무기입니다.

  1. Record 버튼을 누르고 서비스의 주요 동작(클릭, 입력 등)을 수행합니다.
  2. Flamegraph 차트를 확인합니다.
  3. "Why did this render?" 섹션을 통해 특정 컴포넌트가 리렌더링 된 이유가 단순히 Props의 참조값 변화 때문인지 확인합니다.

만약 참조값 변화가 원인이라면, 그때가 바로 useMemo나 useCallback을 투입할 "골든 타임"입니다.


6. 결론: 유연하고 영리한 최적화 전략

프런트엔드 성능 최적화는 기술이라기보다 '트레이드오프(Trade-off)'에 가깝습니다. 가독성을 희생하고 메모리를 더 써서 렌더링 성능을 얻을 것인가, 아니면 약간의 렌더링을 허용하고 깔끔한 코드를 유지할 것인가의 선택이죠.

+ Recent posts