뒤로가기
이벤트 루프를 몰라서 생긴 버그 3개

February 27, 2022

frontendjavascript

이벤트 루프를 "이해한다"와 "체감한다"는 완전히 다른 일이다. Call Stack이 비면 Task Queue에서 꺼내온다, Microtask가 Macrotask보다 먼저 실행된다. 이런 건 면접 준비하면서 외웠다. 근데 그걸 외운 상태에서도 버그를 만들었다. 세 번이나.

한 웹 개발 교육자가 이벤트 루프를 시각적으로 설명하는 시리즈를 만든 적이 있다. Call Stack에 함수가 쌓이고 빠지는 걸 애니메이션으로 보여주는 방식이었는데, 그걸 보고 "아 이제 완전히 이해했다"고 생각했다. 착각이었다. 시각화를 보고 이해한 건 메커니즘이었고, 내가 부족했던 건 "그래서 이게 내 코드에서 어떻게 터지는가"에 대한 감각이었다.

Julia Evans라는 개발자가 기술 글을 쓸 때 "지식의 빈틈을 채우는 것"에 집중한다고 한 적이 있다. 포괄적인 설명이 아니라, 사람들이 실제로 헷갈리는 지점을 정확히 짚는 방식. 이 글도 그 접근을 따라가려고 한다. 이벤트 루프의 전체 구조를 처음부터 설명하는 대신, 내가 실제로 만난 버그 3개를 통해 "아, 이래서 이벤트 루프를 알아야 하는구나"를 보여주려 한다.

버그 1: 폼이 두 번 제출됐다#

입사 후 3개월쯤 됐을 때 일이다. 결제 페이지에서 "주문하기" 버튼을 누르면 가끔 주문이 두 번 들어간다는 CS가 올라왔다. 재현이 잘 안 됐는데, 특정 조건에서만 발생했다. 네트워크가 느린 환경에서 버튼을 빠르게 두 번 누르면 생기는 문제였다.

코드는 대충 이런 구조였다.

typescript
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async () => {
  if (isSubmitting) return;
  setIsSubmitting(true);

  await submitOrder(orderData);
  setIsSubmitting(false);
};

얼핏 보면 문제가 없다. isSubmittingtrue면 early return하니까 중복 제출을 막을 수 있을 것 같다. 근데 실제로는 막지 못했다.

문제는 setIsSubmitting(true) 다음 줄에서 바로 isSubmittingtrue가 되지 않는다는 거다. React의 상태 업데이트는 동기적으로 즉시 반영되지 않는다. setState를 호출하면 React는 그 업데이트를 내부 큐에 넣고, 현재 실행 중인 이벤트 핸들러가 끝난 뒤에 배치로 처리한다.

이벤트 루프 관점에서 보면 이렇다.

text
첫 번째 클릭 이벤트 → Call Stack
  handleSubmit() 실행
  isSubmitting은 아직 false (이전 렌더의 값)
  setIsSubmitting(true) → React 내부 큐에 등록
  submitOrder() 시작 → Web API로 위임
  handleSubmit()이 Call Stack에서 빠짐

두 번째 클릭 이벤트 → 아직 리렌더링이 안 됐다!
  handleSubmit() 실행
  isSubmitting은 여전히 false ← 여기가 문제
  setIsSubmitting(true) → 또 큐에 등록
  submitOrder() 또 시작

두 번째 클릭 이벤트가 Call Stack에 올라갈 때, 아직 React의 리렌더링이 일어나지 않은 상태다. 리렌더링은 상태 업데이트가 배치된 후, 이벤트 루프의 다음 턴에서 발생한다. 두 클릭이 같은 이벤트 루프 턴에서, 혹은 리렌더링 전에 연달아 처리되면 둘 다 isSubmitting === false를 본다.

해결은 React 상태가 아니라 ref를 쓰는 거였다.

typescript
const isSubmittingRef = useRef(false);

const handleSubmit = async () => {
  if (isSubmittingRef.current) return;
  isSubmittingRef.current = true;

  try {
    await submitOrder(orderData);
  } finally {
    isSubmittingRef.current = false;
  }
};

useRef는 리렌더링과 무관하게 값이 즉시 변경된다. isSubmittingRef.current = true가 실행되는 순간 바로 true가 되기 때문에, 두 번째 클릭 핸들러가 실행될 때 이미 true를 본다. React의 배치 업데이트 타이밍을 이벤트 루프와 연결해서 이해하지 못하면 절대 찾을 수 없는 버그다.

이 경험 이후로 "클릭 핸들러에서 상태로 중복 방지"를 쓸 때마다 한 번 더 생각하게 됐다. 상태가 반영되는 시점은 내가 코드를 쓰는 시점이 아니라 React가 결정한다.

버그 2: 스크롤 애니메이션이 버벅거렸다#

두 번째 버그는 좀 더 미묘했다. 상품 리스트 페이지에서 무한 스크롤을 구현했는데, 스크롤할 때 페이지가 끊기듯 멈추는 현상이 있었다. 데이터 로딩 때문인 줄 알았는데 아니었다. 이미 로드된 데이터를 화면에 뿌리는 과정 자체가 문제였다.

원인은 스크롤 이벤트 핸들러 안에서 너무 많은 동기 작업을 하고 있었다는 거다.

typescript
window.addEventListener('scroll', () => {
  // 1. 스크롤 위치 계산
  const scrollTop = document.documentElement.scrollTop;
  const scrollHeight = document.documentElement.scrollHeight;
  const clientHeight = document.documentElement.clientHeight;

  // 2. 각 상품 카드의 가시성 체크 (DOM 읽기)
  productCards.forEach(card => {
    const rect = card.getBoundingClientRect();
    if (rect.top < clientHeight) {
      card.classList.add('visible');
    }
  });

  // 3. 헤더 스타일 변경 (DOM 쓰기)
  if (scrollTop > 100) {
    header.classList.add('scrolled');
  } else {
    header.classList.remove('scrolled');
  }

  // 4. 하단 도달 시 다음 페이지 로드
  if (scrollTop + clientHeight >= scrollHeight - 200) {
    loadNextPage();
  }
});

스크롤 이벤트는 초당 수십 번 발생한다. 이벤트가 발생할 때마다 이 핸들러 전체가 Call Stack에 올라가서 동기적으로 실행된다. getBoundingClientRect()는 강제 리플로우를 유발하고, 그 다음에 classList.add로 DOM을 변경하면 또 리플로우가 필요해진다. 이걸 상품 카드 개수만큼 반복한다.

이벤트 루프의 규칙을 떠올려보면, 렌더링은 Microtask Queue가 비고, Task Queue에서 작업을 하나 처리한 다음에 발생한다. 근데 스크롤 핸들러가 Call Stack에서 오래 머물면 브라우저가 렌더링할 타이밍을 잡지 못한다. 16ms(60fps 기준) 안에 핸들러가 끝나야 프레임이 안 밀리는데, DOM을 수십 번 읽고 쓰는 작업이 16ms 안에 끝날 리가 없다.

해결은 두 가지를 조합했다.

typescript
// 1. 가시성 체크는 IntersectionObserver로 분리
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
    }
  });
}, { threshold: 0.1 });

productCards.forEach(card => observer.observe(card));

// 2. 나머지 작업은 requestAnimationFrame으로 스케줄링
let ticking = false;

window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      updateHeaderStyle();
      checkInfiniteScroll();
      ticking = false;
    });
    ticking = true;
  }
});

IntersectionObserver는 브라우저가 내부적으로 최적화된 타이밍에 콜백을 호출한다. 매 스크롤 이벤트마다 getBoundingClientRect()를 부르는 것과는 차원이 다르다.

requestAnimationFrame은 브라우저의 렌더링 사이클에 맞춰 실행된다. 이벤트 루프에서 렌더링 단계 직전에 rAF 콜백이 실행되기 때문에, DOM 변경을 여기서 하면 불필요한 리플로우 없이 다음 프레임에 반영된다. ticking 플래그로 프레임당 한 번만 실행되게 막는 것도 중요하다.

이 버그를 잡으면서 "이벤트 루프에서 렌더링이 언제 발생하는가"를 처음으로 진지하게 생각했다. Call Stack이 비고 → Microtask Queue를 비우고 → 렌더링이 필요하면 하고 → Task Queue에서 하나 꺼냄. 이 순서에서 "렌더링이 필요하면"이라는 조건이 핵심이다. Call Stack을 오래 점유하면 렌더링 기회 자체가 사라진다.

버그 3: API 응답이 꼬여서 이전 검색 결과가 표시됐다#

세 번째 버그가 제일 교훈이 컸다. 검색 페이지에서 사용자가 타이핑할 때마다 API를 호출하는 기능이었다. "React"를 검색하려고 "R", "Re", "Rea", "Reac", "React"를 순서대로 타이핑하면, 각 입력마다 API 호출이 나간다.

문제는 API 응답이 요청 순서대로 오지 않는다는 거다.

text
요청 순서: "R" → "Re" → "Rea" → "Reac" → "React"
응답 순서: "R" → "Rea" → "React" → "Re" → "Reac"
          (네트워크 상황에 따라 뒤죽박죽)

마지막으로 화면에 표시되는 건 가장 마지막에 도착한 "Reac"의 결과다. 사용자는 "React"를 검색했는데 "Reac"의 결과가 보이는 거다. 이게 stale data 문제다.

처음에는 이렇게 짰다.

typescript
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);

useEffect(() => {
  const fetchResults = async () => {
    const data = await searchAPI(query);
    setResults(data);
  };

  if (query) {
    fetchResults();
  }
}, [query]);

searchAPI(query)는 Promise를 반환한다. Promise의 .then (여기서는 await 이후 코드)은 Microtask Queue에 들어간다. 네트워크 응답이 올 때마다 해당 Promise가 resolve되고, setResults(data)가 Microtask로 실행된다.

문제는 각 useEffect의 클로저가 서로 독립적이라는 거다. "Re"의 응답이 "React"의 응답보다 늦게 오면, "React" 결과가 먼저 화면에 뜨고 그 다음에 "Re" 결과가 덮어쓴다. 이벤트 루프는 도착한 순서대로 Microtask를 처리할 뿐, "이건 오래된 요청이니까 무시해야 해"라는 판단을 하지 않는다.

해결 방법은 useEffect의 cleanup에서 요청을 무효화하는 거다.

typescript
useEffect(() => {
  let cancelled = false;

  const fetchResults = async () => {
    const data = await searchAPI(query);
    if (!cancelled) {
      setResults(data);
    }
  };

  if (query) {
    fetchResults();
  }

  return () => {
    cancelled = true;
  };
}, [query]);

query가 바뀌면 이전 effect의 cleanup이 실행되면서 cancelled = true가 된다. 이전 요청의 응답이 뒤늦게 와도 cancelledtrue이므로 setResults가 실행되지 않는다.

더 확실한 방법은 AbortController를 쓰는 거다.

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

  const fetchResults = async () => {
    try {
      const data = await searchAPI(query, {
        signal: controller.signal,
      });
      setResults(data);
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        // 의도된 취소. 무시.
      } else {
        throw err;
      }
    }
  };

  if (query) {
    fetchResults();
  }

  return () => {
    controller.abort();
  };
}, [query]);

AbortController는 cleanup 실행 시점에서 아예 네트워크 요청 자체를 취소한다. 응답을 무시하는 것보다 한 단계 더 나은 방법이다. 불필요한 네트워크 트래픽도 줄인다.

이 버그는 이벤트 루프를 "비동기 작업의 완료 시점은 예측할 수 없다"는 관점에서 이해해야 풀린다. Promise가 resolve되는 순서는 네트워크 상황에 달려 있고, 이벤트 루프는 도착한 순서대로 처리할 뿐이다. 코드 작성자가 직접 "이 응답은 더 이상 유효하지 않다"는 로직을 넣어야 한다.

세 가지 버그에서 배운 것#

세 버그의 공통점이 있다. 전부 "코드가 실행되는 시점"에 대한 잘못된 가정 때문에 발생했다.

  • 버그 1: setState 호출 직후에 상태가 바뀌었을 거라는 가정
  • 버그 2: 이벤트 핸들러가 충분히 빠르게 끝날 거라는 가정
  • 버그 3: API 응답이 요청 순서대로 올 거라는 가정

이벤트 루프를 공부할 때 console.log의 출력 순서를 맞추는 퀴즈가 많이 나온다. "1, 4, 3, 2" 같은 거. 그것도 의미 있지만, 실무에서 이벤트 루프가 중요한 진짜 이유는 비동기 코드의 실행 시점이 내가 생각한 것과 다를 수 있다는 걸 알려주기 때문이다.

이벤트 루프의 전체 구조를 암기하는 것보다, 이 세 가지를 기억하는 게 더 실용적이라고 생각한다.

첫째, 상태 업데이트는 즉시 반영되지 않는다. React든 Vue든 상태 업데이트는 배치된다. 업데이트 직후에 그 값을 읽으면 이전 값을 볼 수 있다. 동기적으로 즉시 반영되어야 하는 플래그는 ref를 쓴다.

둘째, Call Stack을 오래 점유하면 화면이 멈춘다. 이벤트 루프가 렌더링할 틈을 주지 않기 때문이다. 무거운 작업은 requestAnimationFrame, requestIdleCallback, 또는 Web Worker로 분산해야 한다.

셋째, 비동기 작업의 완료 순서는 보장되지 않는다. Promise의 resolve 순서는 네트워크 상황에 달려 있다. 여러 비동기 작업이 경쟁하는 상황에서는 반드시 "오래된 결과를 무시하는" 로직이 필요하다.

이벤트 루프를 이론으로 아는 건 시작일 뿐이다. 버그를 만나고, 원인을 추적하고, "아, 이게 이벤트 루프 때문이었구나"라고 연결하는 순간이 진짜 이해의 시작이다. 나는 세 번의 버그가 필요했다. 운이 좋으면 이 글을 읽고 한 번 정도는 아낄 수 있을지도 모른다.