본문으로 건너뛰기

이벤트 처리에서 옵저버와 이벤트 핸들러의 차이를 딥다이브 해보자!

개요

question
옵저버와 이벤트 핸들러의 차이가 뭘까?

최근 옵저버와 이벤트 핸들러의 차이에 대해서 크게 신경쓰지 않다가, 최근에 크게 배우게 되는 일이 있었습니다.

다만, 모호한 부분이 있었어서 이 참에 다시금 정리하고자 글을 작성하게 되었습니다.

들어가기 전에..

한번쯤 떠올려보면 어떨까합니다. 이벤트 핸들러는 많이 사용해보셨을 것으로 예상되는데요.

그렇다면, 옵저버는 언제 사용해보셨나요?

  • 무한 스크롤을 구현할 때 쓰는 InterSection Observer?
  • 화면의 사이즈 조절을 감지할 때 쓰는 Resize Observer?

그렇다면, 여러분들은 이런 옵저버들을 왜 쓰셨나요?

Event Handler를 사용했을 때와 달리 어떤 장점이 있으셨나요?

한번쯤 떠올리고 다이브해봅시다!

옵저버(Observer)란 무엇인가?

여러분 옵저버란 무엇일까요?

혹시 어디서 들어보시진 않으셨나요?

스타크래프트 옵저버
스타크래프트 옵저버

스타크래프트에서 들어보셨다면.. 얼추 비슷하게 다가가셨습니다 ㅎㅎ.

옵저버는 말 그대로 "관찰자(Observer)"입니다.

정확히는 "관찰자 패턴(Observer Pattern)"에 기반한 메커니즘이지요.

그러면 여기서 무엇을 관찰할까요?

정답은 특정 대상의 상태 변화를 관찰합니다.

특정 대상의 상태 변화를 지속적으로 관찰하고, 변화가 감지되면 미리 등록된 콜백 함수를 실행하는 방식으로 동작하는게 옵저버입니다.

옵저버의 핵심 특징은 비동기적이고 지속적인 관찰입니다.

한 번 등록하면 조건이 충족될 때마다 자동으로 콜백이 실행되며, 개발자가 직접 상태를 확인할 필요가 없죠.

마치 보안 카메라가 움직임을 감지하면 자동으로 멈추는 것과 비슷합니다.

한번 IntersectionObserver를 예로 들어볼까요?

javascript
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('요소가 화면에 나타났습니다!');
// 여기서 실행되는 콜백은 브랑줘의 최적화된 타이밍에 따라 실행됩니다.
}
});
});

observer.observe(document.querySelector('.target'));

위 코드를 많이들 사용하셨을거라고 생각되어요. 특정 요소가 화면 상에 감지되면, 콜백을 실행시키는 코드입니다.

여기서 다음 부분이 보이시나요?

javascript
observer.observe();

옵저버에게 어떤 것을 관찰하라고 시키는 겁니다. 여기서는 선택된 요소의 상태 변화를 감지하는 거라고 볼 수 잇겠네요.

IntersectionObserver는 DOM 요소가 뷰포트나 다른 요소와 교차하는 상황을 관찰합니다.

스크롤을 하거나, 요소의 위치가 변경될 때마다 자동으로 교차 상태를 확인하고, 조건에 맞으면 콜백을 실행하는 방식이죠.

이해가 안되어도 괜찮습니다. 이런게 있다 정도만 봐둡시다 ㅎㅎ.

이벤트 핸들러(Event Handler)란 무엇인가?

이벤트 핸들러는 이벤트 기반 프로그래밍의 핵심 요소입니다.

사용자의 클릭, 키보드 입력, 마우스 움직임 등 특정 이벤트가 발생했을 때 즉시 반응하여 콜백 함수를 실행합니다.

여기서의 핵심은 "즉시" 입니다. 이에 주목해주세요.

이벤트 핸들러의 특징은 즉각적이고 반응적인 실행입니다.

이벤트가 발생하는 순간 바로 콜백이 실행되며, 이벤트의 세부 정보(어떤 키를 눌렀는지, 어디를 클릭했는지 등)를 콜백 함수에 전달합니다.

javascript
// 이벤트 핸들러 예제
document.addEventListener('click', (event) => {
console.log('클릭 이벤트 발생!', event.target);
});

이벤트 핸들러에 대해서는 잘 알 것 같아서 간단히만 언급하고 넘어가겠습니다 ㅎㅎ.

이벤트 핸들러와 옵저버의 차이

옵저버 vs 이벤트 핸들러 콜백 실행 방식 비교

구분옵저버 (Observer)이벤트 핸들러 (Event Handler)
실행 타이밍브라우저 최적화 주기에 따라 실행
렌더링 프레임과 동기화
이벤트 발생 즉시 실행
사용자 액션과 직접 연결
실행 빈도브라우저가 조절 (throttling 내장)
중복 호출 자동 최적화
이벤트 발생 횟수와 1:1 대응
빠른 연속 이벤트 시 과도한 호출 가능
콜백 큐 처리Intersection Observer Task Queue
낮은 우선순위로 처리
Event Loop의 Task Queue
높은 우선순위로 즉시 처리
메인 스레드 블로킹논블로킹 방식
메인 스레드 성능에 미치는 영향 최소
블로킹 가능성
복잡한 콜백 시 UI 응답성 저하
배치 처리여러 변화를 배치로 묶어서 처리
entries 배열로 전달
개별 이벤트마다 별도 처리
각 이벤트는 독립적
브라우저 최적화브라우저 엔진 수준에서 최적화
네이티브 구현의 성능 이점
JavaScript 엔진 수준에서 처리
개발자가 직접 최적화 필요
리소스 사용량낮은 CPU 사용률
GPU 가속 활용 가능
높은 CPU 사용률
특히 고빈도 이벤트에서 부담
예측 가능성실행 시점 예측 어려움
브라우저 내부 스케줄링에 의존
실행 시점 예측 가능
이벤트 발생과 동시에 실행
디버깅 용이성비동기적 특성으로 디버깅 복잡
콜백 실행 시점 추적 어려움
동기적 실행으로 디버깅 용이
이벤트-콜백 관계 명확
메모리 관리WeakRef 패턴 사용
자동 가비지 컬렉션 지원
명시적 메모리 관리 필요
리스너 해제 직접 처리
에러 처리에러 발생 시 전체 관찰 중단 없음
개별 entry 단위로 에러 격리
에러 발생 시 해당 이벤트만 영향
다른 이벤트 처리에 영향 없음
크로스 브라우저 동작표준 스펙 준수
브라우저별 최적화 차이 존재
오래된 표준으로 일관성 높음
브라우저별 차이 최소

간단하게 요약해보면 위와 같습니다.

감이 잡히시나요? 아직 모호할 것 같아서, 아래 성능 특성 비교를 통해 더 자세히 알아보겠습니다.

성능 특성 비교

조금 더 쉽게 보기 위해서 코드로 살펴볼까요?

javascript
// 최적화된 배치 처리 예제
const observer = new IntersectionObserver((entries) => {
// 여러 요소의 변화를 한 번에 처리
entries.forEach((entry) => {
// 브라우저가 최적화된 타이밍에 실행
console.log(`${entry.target.id}: ${entry.isIntersecting}`);
});
},
{
// 브라우저 최적화 옵션
rootMargin: '50px',
threshold: [0, 0.5, 1],
}
);

어때요? 감이 좀 오시나요? 안오셨다고 해도 괜찮습니다 ㅎㅎ.

이제부터 본격적으로 하나씩 탐구해봅시다.

이벤트 핸들러 딥다이브

우선 이벤트 핸들러부터 살펴봅시다.

이벤트 핸들러의 본질적 특성

이벤트 핸들러를 이해하기 위해서는 먼저 이벤트 기반 프로그래밍의 철학을 파악해야 합니다.

이는 무언가가 일어나면 즉시 반응한다반응형 프로그래밍의 핵심 개념입니다.

마치 문지기가 문을 두드리면 즉시 문을 열어주는 것처럼, 이벤트 핸들러는 특정 신호(이벤트)를 받으면 미리 정해진 행동(콜백)을 즉시 수행합니다.

이때, "즉시"이라는 개념이 매우 중요합니다. 사용자가 버튼을 클릭하는 순간, 브라우저는 그 클릭을 감지하고 등록된 이벤트 핸들러를 찾아서 바로 실행합니다.

이 과정에서 지연이나 최적화를 위한 대기 시간은 없습니다.

javascript
//  이벤트 핸들러의 기본 구조
button.addEventListener('click', (event) => {
// 클릭이 발생한 바로 그 순간에 이 코드가 실행됨.
console.log('버튼이 클릭되었습니다!', event);

// event 객체에는 클릭에 대한 모든 정보가 담겨 있음.
console.log('클릭 위치: ', event.clientX, event.clientY);
console.log('클릭된 요소: ', event.target);
});
잠깐! 반응형 프로그래밍이란?

**반응형 프로그래밍(Reactive Programming)**은 **데이터 흐름(data stream)**과 **변경 사항 전파(propagation of change)**에 중점을 둔 프로그래밍 패러다임입니다.

쉽게 말해, **'어떤 데이터가 변경되면, 그 변경에 반응해서 관련된 부분들이 자동으로 업데이트되는 방식'**으로 프로그래밍하는 것이라고 생각할 수 있습니다.

반응형 프로그래밍은 다음과 같은 핵심 개념을 갖습니다.

1. 데이터 스트림 (Data Streams / Observables)

  • 시간이 지남에 따라 순차적으로 발생하는 데이터의 연속적인 흐름입니다.
  • 예: 사용자 클릭 이벤트, 키보드 입력, HTTP 요청, 센서 데이터, 주식 가격 변동 등
  • 반응형 프로그래밍에서는 이런 데이터들을 **"옵저버블(Observable)"**이라는 형태로 다룹니다.

2. 옵저버 (Observer / Subscriber)

  • 데이터 스트림을 구독(subscribe)하여 스트림에서 발생하는 데이터나 이벤트를 받아 처리하는 주체입니다.
  • 스트림에서 새로운 데이터가 발생하거나, 에러가 발생하거나, 스트림이 완료되면 옵저버에게 알림이 갑니다.

3. 연산자 (Operators)

  • 데이터 스트림을 변환, 필터링, 결합하는 등의 다양한 작업을 수행하는 함수입니다.
  • 예: map (데이터 변환), filter (조건에 맞는 데이터만 통과), merge (여러 스트림 결합), debounce (특정 시간 동안 이벤트가 없을 때만 발생) 등
  • 연산자를 통해 복잡한 비동기 로직을 선언적으로 깔끔하게 표현할 수 있습니다.

4. 비동기 (Asynchronous) 및 논블로킹 (Non-blocking)

  • 반응형 프로그래밍은 비동기적인 데이터 처리에 매우 적합합니다.
  • 데이터가 언제 올지 모르는 상황에서도 프로그램이 멈추지 않고 다른 작업을 계속할 수 있게 해줍니다.

사실 이렇게까지 말하면 이해하기 어려울 수 있어서, 한 가지 예를 들어볼게요.

예: 엑셀 스프레드시트

  • A1 셀에 10을 입력하고, B1 셀에 20을 입력합니다.
  • C1 셀에 =A1+B1을 입력하면, C1 셀은 A1과 B1의 합계가 자동으로 계산되어서, 30이 표시됩니다.
  • 만약 A1 셀의 값을 15로 바꾸면, C1 셀의 값은 자동으로 35로 변경됩니다.
  • 여기서 C1 셀은 A1 셀과 B1 셀의 변경에 반응하여 자동으로 업데이트되는 것입니다.

왜 사용할까요? (장점)

  1. 향상된 응답성: UI 이벤트 처리나 네트워크 요청 같은 비동기 작업을 효율적으로 처리하여 사용자 인터페이스가 멈추는 현상을 줄일 수 있습니다.
  2. 간결한 비동기 코드: 콜백 지옥(callback hell)이나 복잡한 프로미스 체인보다 선언적이고 읽기 쉬운 코드로 비동기 로직을 작성할 수 있습니다.
  3. 유연한 에러 처리: 데이터 스트림 내에서 발생하는 에러를 중앙에서 효과적으로 처리할 수 있습니다.
  4. 자원 효율성: 필요한 만큼만 자원을 사용하고, 이벤트 기반으로 동작하여 시스템 부하를 줄일 수 있습니다.

어려운 점 (단점)

  1. 학습 곡선: 새로운 개념(옵저버블, 연산자 등)과 "데이터 흐름"으로 생각하는 방식에 익숙해지는 데 시간이 걸릴 수 있습니다.
  2. 디버깅: 데이터가 여러 연산자를 거치며 변환되기 때문에, 문제가 발생했을 때 원인을 추적하기 어려울 수 있습니다.
  3. 과도한 사용: 간단한 작업에 반응형 프로그래밍을 도입하면 오히려 코드가 복잡해질 수 있습니다.

주요 사용 사례

  • UI 개발: 사용자 인터랙션(클릭, 입력 등) 처리, 실시간 데이터 바인딩 (예: React, Angular, Vue.js 내부적으로 또는 RxJS와 함께 사용)
  • 백엔드 서비스: 동시성 높은 요청 처리, 실시간 데이터 스트리밍 (예: Spring WebFlux, Akka Streams)
  • 실시간 데이터 처리: 주식 시세, IoT 센서 데이터, 알림 시스템
  • 이벤트 기반 아키텍처: 마이크로서비스 간의 비동기 통신

대표적인 라이브러리/프레임워크

  • RxJS (JavaScript)가 대표적입니다.

이벤트 루프와의 관계

이벤트 핸들러의 실행 방식을 이해하기 위해서는, JavaScript이벤트 루프(Event Loop)를 이해해야 합니다.

이는 단일 스레드인 JavaScript가 비동기 작업을 어떻게 처리하는지를 보여주는 핵심 메커니즘입니다.

사용자가 클릭을 하면, 브라우저는 즉시 그 이벤트를 태스크 큐(Task Queue)에 넣습니다.

이벤트 루프는 현재 실행 중인 코드가 끝나면 즉시 태스크 큐에서 이벤트를 꺼내서 해당 핸들러를 실행합니다.

이 과정에서 우선순위가 높기 때문에, 다른 작업들보다 먼저 처리됩니다.

javascript
console.log('1. 코드 시작');

button.addEventListener('click', () => {
console.log('3. 클릭 핸들러 실행'); // 클릭하면 즉시 실행
});

setTimeout(() => {
console.log('4. 타이머 콜백'); // 0ms 후에도 클릭 핸들러보다 늦게 실행될 수 있음
}, 0);

console.log('2. 코드 끝');
// 만약 여기서 클릭이 발생하면, 출력 순서는 1 → 2 → 3 → 4가 됩니다
이벤트 루프란?

이벤트 루프는 JavaScript의 비동기 작업을 처리하는 핵심 메커니즘입니다.

그 중요성과 별개로, 많은 개발자들이 헷갈려하는 부분이기도 합니다. 이를 제대로 이해하면 JavaScript가 어떻게 비동기 작업을 처리하는지, 그리고 왜 이벤트 핸들러가 그런 방식으로 동작하는지 명확하게 알 수 있습니다. 같이 한번 살펴봅시다.

싱글 스레드의 역설

먼저 JavaScript의 근본적인 특성을 이해해야 합니다. JavaScript는 싱글 스레드 언어입니다. 이는 한번에 하나의 작업만 수행할 수 있다는 의미이기도 합니다.

그런데 웹 브라우저에서는 동시에 여러 일이 일어나는 것처럼 보입니다. 예를 들어, 사용자가 버튼을 클릭하는 동안에도 애니메이션이 계속 실행되고, 네트워크 요청이 처리되고, 타이머가 동작합니다.

어떻게 이런 일이 가능한 걸까요?

여기서 중요한 점은 JavaScript 엔진과 브라우저를 구분해서 생각해야 한다는 점입니다.

JavaScript 엔진은 단일 스레드로 작동하지만, 브라우저는 여러 스레드를 사용하여 다양한 작업을 동시에 처리합니다. 브라우저의 다른 스레드들이 네트워크 요청을 처리하고, 타이머를 관리하고, 사용자 입력을 감지합니다. 그리고 이 모든 것을 JavaScript의 단일 스레드와 연결해주는 것이 바로 이벤트 루프입니다.

이벤트 루프의 구조

이벤트 루프를 이해하기 위해서는 몇 가지 핵심 구성 요소를 알아야 합니다. 먼저 **콜 스택(Call Stack)**이 있습니다.

이는 현재 실행 중인 함수들이 쌓이는 곳입니다. JavaScript는 함수를 실행할 때 이 스택에 함수를 Push하고, 함수가 끝나면 Pop합니다.

javascript
function first() {
console.log('첫 번째 함수 시작');
second(); // 여기서 second 함수가 콜 스택에 쌓입니다.
console.log('첫 번째 함수 끝');
}

function second() {
console.log('두 번째 함수 실행');
// 여기서 second 함수가 콜 스택에서 제거됩니다.
}

first(); // 여기서 first 함수가 콜 스택에 쌓입니다.

콜 스택이 비어있을 때만 이벤트 루프가 다음 작업을 가져올 수 있습니다. 이는 매우 중요한 개념으로, 만약 어떤 함수가 오래 실행되어 콜 스택을 점유하고 있다면, 다른 모든 작업들은 기다려야 합니다.

태스크 큐와 마이크로태스크 큐

이벤트 루프에는 실제로 여러 개의 큐가 있습니다. 가장 중요한 것은 **태스크 큐(Task Queue)**와 **마이크로태스크 큐(Microtask Queue)**입니다.

참고로 **태스크 큐(Task Queue)**는 **매크로 태스크 큐(Macro Task Queue)**라고도 부릅니다. 매크로의 의미는 **"크다, 넓다, 전체적이다"**라는 게 있으며, 마이크로의 의미는 **"작다, 미세하다, 세부적이다"**라는 게 있습니다. 뒤에서 설명하겠지만, 작기 때문에 먼저 실행된다고 가볍게 느낌만 잡고 이 둘의 차이를 이해해봅시다.

태스크 큐에는 사용자 이벤트, 타이머 콜백, 네트워크 응답 등이 들어갑니다. 반면 마이크로태스크 큐에는 Promisethen 콜백이나 queueMicrotask로 등록된 작업들이 들어갑니다.

이벤트 루프는 항상 마이크로태스크 큐를 먼저 비운 다음에 태스크 큐를 처리합니다.

javascript
console.log('1. 동기 코드 시작');

// 태스크 큐에 들어갈 작업
setTimeout(() => {
console.log('4. setTimeout 콜백');
}, 0);

// 마이크로태스크 큐에 들어갈 작업
Promise.resolve().then(() => {
console.log('3. Promise 콜백');
});

console.log('2. 동기 코드 끝');

// 실행 순서: 1 -> 2 -> 3 -> 4
// 마이크로태스크가 태스크보다 우선순위가 높아서 이렇게 실행됩니다.

이렇게 나타나는 모습이 왜 중요할까요? 사용자가 버튼을 클릭했을 때, 그 클릭 이벤트는 테스크 큐에 들어갑니다. 하지만, 만약 그 순간에 Promise 체인이 실행되고 있다면, 모든 Promise 콜백들이 먼저 처리된 후에야 클릭 이벤트 핸들러가 실행됩니다.

실제 동작 과정 시뮬레이션

이벤트 루프의 동작을 구체적인 예시로 살펴보겠습니다. 사용자가 버튼을 클릭하는 상황을 단계별로 분석해보면 이해가 더 쉬워집니다.

javascript
button.addEventListener('click', function handleClick(event) {
console.log('클릭 처리 시작');

// 비동기 작업 추가
setTimeout(() => {
console.log('타이머 완료');
}, 0);

Promise.resolve().then(() => {
console.log('Promise 완료');
});

console.log('클릭 처리 끝');
});

// 다른 동기 코드도 있다고 가정
function heavyTask() {
for (let i = 0; i < 1000000000; i++) {
// 무거운 작업
}
console.log('무거운 작업 완료');
}

사용자가 버튼을 클릭하면 어떤 일이 일어날까요? 브라우저의 이벤트 감지 스레드가 클릭을 감지하고, 즉시 handleClick 함수를 태스크 큐에 넣습니다.

하지만 이 함수가 바로 실행되는 것은 아닙니다. 콜 스택이 비어있어야만 실행됩니다. 만약 이 순간에 heavyTask 함수가 실행 중이라면, 그 함수가 완전히 끝날 때까지 클릭 이벤트 핸들러는 기다려야 합니다. 이것이 바로 **"JavaScript가 블로킹된다"**는 의미입니다. 사용자는 버튼을 클릭했지만, 실제로 반응이 나타나기까지 시간이 걸린다는 의미입니다.

렌더링과의 관계

이벤트 루프를 이해할 때 놓치기 쉬운 중요한 부분이 렌더링입니다. 브라우저가 화면을 업데이트하는 작업도 이벤트 루프와 연관되어 있습니다.

일반적으로 브라우저는 초당 60프레임을 목표로 하므로, 약 16.67ms마다 화면을 다시 그려야 합니다. 렌더링 작업은 태스크와 마이크로태스크 사이에서 특별한 위치를 차지합니다.

마이크로태스크가 모두 처리된 후, 다음 태스크를 처리하기 전에 렌더링이 일어날 수 있습니다. 하지만 이는 브라우저가 결정하는 것이고, 매번 렌더링이 일어나는 것은 아닙니다.

javascript
button.addEventListener('click', () => {
// DOM을 변경하는 작업
element.style.backgroundColor = 'red';

// 이 Promise는 렌더링 전에 실행됩니다.
Promise.resolve().then(() => {
element.style.backgroundColor = 'blue';
});

// 이 setTimeout은 렌더링 후에 실행될 수 있습니다.
setTimeout(() => {
element.style.backgroundColor = 'green';
}, 0);
});

이 예시에서 사용자가 보는 색상은 무엇일까요? 빨간색이 보이지 않고 파란색이 먼저 보일 가능성이 높습니다. 왜냐하면 마이크로태스크가 렌더링보다 먼저 실행되기 때문입니다.

성능에 미치는 영향

이벤트 루프의 동작 방식을 이해하면, 왜 특정 코드가 성능 문제를 일으키는지 알 수 있습니다. 예를 들어, 이벤트 핸들러 안에서 오래 걸리는 작업을 수행하면 전체 페이지가 멈춘 것처럼 보일 수 있습니다.

javascript
button.addEventListener('click', () => {
// 이 작업이 100ms가 걸린다면?
for (let i = 0; i < 1000000000; i++) {
// 무거운 작업, 복잡한 계산
}
// 이 100ms 동안 다른 모든 이벤트들이 기다려야 합니다.
// 애니메이션도 멈추고, 다른 클릭도 반응하지 않습니다.
});

이런 문제를 해결하는 방법 중 하나는 작업을 작은 단위로 나누어서 처리하는 것입니다. 각 단위 사이에 다른 작업들이 실행될 수 있는 여지를 주는 것입니다.

javascript
// 개선된 코드
button.addEventListener('click', () => {
let i = 0;
const total = 100000000;

function processChunk() {
// 작은 단위로 처리
for (let count = 0; count < 1000 && i < total; count++, i++) {
// 복잡한 계산
}

if (i < total) {
// 다음 처리를 위해 태스크 큐에 등록
setTimeout(processChunk, 0);
}
}

processChunk();
});

이제 이벤트 루프가 어떻게 작동하는지 기본적인 이해를 갖추었습니다. 이 지식을 바탕으로 생각해보면, 왜 IntersectionObserver 같은 옵저버 패턴이 등장했는지 이해할 수 있을 거예요! 옵저버는 이런 이벤트 루프의 한계를 극복하기 위해 다른 접근 방식을 사용합니다.

높은 실행 빈도의 문제점

이벤트 핸들러의 "즉시 실행" 특성은 때로는 성능 문제를 일으킵니다.

특히 고빈도 이벤트에서 이 문제가 두드러집니다.

스크롤이나 마우스 움직임 같은 이벤트는 초당 수십 번에서 수백 번까지 발생할 수 있는데, 매번 콜백이 실행되면 브라우저가 버거워할 수 있습니다.

javascript
// 문제가 되는 코드 예시
window.addEventListener('scroll', (event) => {
// 스크롤할 때마다 매번 실행됩니다 (초당 100번 이상 가능)
console.log('스크롤 위치:', window.scrollY);

// 복잡한 계산이나 DOM 조작이 있다면?
const elements = document.querySelectorAll('.animate-on-scroll');
elements.forEach((el) => {
// 이런 작업이 초당 100번 실행되면 성능 문제 발생
checkAndAnimate(el);
});
});

이 문제를 해결하기 위해 개발자들은 쓰로틀링(Throttling)이나 디바운싱(Debouncing) 같은 기법을 사용합니다.

하지만 이는 이벤트 핸들러 자체의 한계를 보여주는 것이기도 합니다.

javascript
// 수동으로 최적화한 이벤트 핸들러
let isThrottled = false;

window.addEventListener('scroll', (event) => {
if (isThrottled) return; // 이미 처리 중이면 무시

isThrottled = true;

// requestAnimationFrame을 사용해서 다음 렌더링 시점에 실행
requestAnimationFrame(() => {
console.log('최적화된 스크롤 처리:', window.scrollY);
isThrottled = false;
});
});


동기적 실행의 장단점

이벤트 핸들러는 동기적으로 실행됩니다. 이는 이벤트가 발생하면 다른 모든 JavaScript 코드의 실행을 잠시 멈추고 핸들러를 먼저 처리한다는 의미입니다.

이런 특성은 양날의 검과 같습니다.

장점으로는 예측 가능성이 있습니다. 사용자가 버튼을 클릭하면 정확히 그 순간에 핸들러가 실행되므로, 사용자 인터페이스의 반응성이 좋아집니다.

또한 디버깅할 때도 이벤트 발생과 핸들러 실행 사이의 인과 관계가 명확하게 보입니다.

javascript
button.addEventListener('click', (event) => {
// 클릭한 바로 그 순간에 실행되므로
// 사용자는 즉각적인 피드백을 받을 수 있습니다
button.style.backgroundColor = 'red';
button.textContent = '클릭됨!';

// 이 모든 것이 클릭과 동시에 일어납니다
});

하지만 단점도 있습니다. 핸들러 안에서 오래 걸리는 작업을 수행하면, 전체 페이지가 멈춘 것처럼 보일 수 있습니다. 이는 사용자 경험을 크게 해치는 요소입니다.

javascript
button.addEventListener('click', (event) => {
// 이런 코드는 피해야 합니다
for (let i = 0; i < 1000000; i++) {
// 복잡한 계산
Math.sqrt(i);
}
// 이 루프가 끝날 때까지 페이지 전체가 멈춥니다
});

이벤트 핸들러의 이런 특성들을 이해하면, 왜 특정 상황에서는 옵저버가 더 나은 선택인지 알 수 있게 됩니다. 옵저버는 이런 문제들을 브라우저 수준에서 해결하기 위해 설계된 다른 접근 방법이기도 합니다.

동기적 실행이란?

좀 더 자세히 들어가기 전에 "동기적 실행"이 무엇인지 명확히 이해해봅시다.

보통 동기적 실행이라고 하면, 코드가 한 줄 씩 순서대로 실행되는 것을 생각합니다. 그러나, 실제로는 단순히 "순서대로 실행된다."는 의미가 아닙니다.

더 명확히 말하면, "한 번에 하나의 작업만 수행하며, 현재 작업이 완전히 끝날 때까지 다른 모든 작업이 기다린다"는 의미입니다.

한번 생각해봅시다. 여러분이 책을 읽고 있는데, 누군가 말을 걸었다고 가정해봐요. 여러분은 책 읽기를 완전히 멈추고 그 사람의 말에 집중한 다음, 대화가 끝나면 다시 책을 읽기 시작할 거에요.

이것이 바로 동기적 실행의 방식입니다. 두 가지 일을 동시에 하지 않고, 하나씩 완전히 처리하는 방식. 인 것이지요.

javascript
console.log('작업 1 시작');

button.addEventListener('click', (event) => {
console.log('클릭 이벤트 처리 시작');

// 이 코드가 실행되는 동안 다른 모든 JavaScript 코드는 기다립니다
let result = 0;
for (let i = 0; i < 1000; i++) {
result += Math.random();
}

console.log('클릭 이벤트 처리 완료');
// 여기서 비로소 다른 코드들이 실행될 수 있습니다
});

console.log('작업 1 완료');

이 예시에서 사용자가 버튼을 클릭하면, 클릭 이벤트 핸들러가 완전히 끝날 때까지 다른 모든 JavaScript 작업들이 멈춥니다.

다른 클릭, 타이머 콜백, 네트워크 응답 처리 등 모든 것이 기다려야 하는 것이죠.

예측 가능성의 진정한 의미

동기적 실행의 가장 큰 장점은 예측 가능성입니다. 하지만 이것이 단순히 "실행 순서를 예측할 수 있다"는 의미는 아닙니다.

더 중요한 것은 상태의 일관성을 보장한다는 점이에요.

예를 들어, 사용자가 "저장" 버튼을 클릭했다고 생각해봐요. 이 순간에 여러 가지 작업이 필요할 수 있어요.

폼 데이터를 검증하고, UI를 업데이트하고, 서버에 요청을 보내야 할 수도 있습니다. 동기적 실행에서는 이런 작업들이 원자적으로(atomically) 처리됩니다.

원자적(atomically)으로 처리된다는 게 무슨 말일까?

**"원자적으로 처리된다"**는 것은 **"더 이상 나눌 수 없는 하나의 완전한 단위로 처리된다"**는 의미입니다.

마치 원자(atom)가 화학 반응의 기본 단위인 것처럼(요즘 이론에서는 더 작게 쪼갤 수 있지만 어쨌든), 프로그래밍에서 원자적 연산은 "전부 성공하거나 전부 실패하는" 하나의 단위로 취급합니다.

좀 더 구체적으로 설명해볼게요. 위 코드에서 클릭 이벤트 핸들러가 실행될 때, JavaScript 엔진은 다음과 같이 동작합니다.

원자적 처리의 핵심 특징

  1. 중단 불가능성: 이벤트 핸들러 함수가 한 번 시작되면, 다른 동기적 코드가 중간에 끼어들 수 없습니다. 사용자가 아무리 빠르게 버튼을 연속으로 클릭해도, 첫 번째 클릭의 처리가 완전히 끝날 때까지 두 번째 클릭은 대기합니다.
  2. 상태 일관성 보장: 버튼을 비활성화하고, 텍스트를 변경하고, 폼을 검증하는 모든 과정이 하나의 "덩어리"로 처리됩니다. 사용자는 "버튼은 비활성화됐는데 텍스트는 아직 '저장'으로 남아있는" 같은 어중간한 상태를 볼 수 없습니다.

실생활 비유로 이해해보기

은행에서 돈을 이체하는 상황을 생각해보세요. A 계좌에서 10만원을 빼고 B 계좌에 10만원을 넣는 작업이 있다면, 이 두 작업은 반드시 원자적으로 처리되어야 합니다.

만약 A 계좌에서 돈은 빠졌는데 B 계좌에 입금이 되지 않는다면 큰 문제가 될 거예요. 따라서 은행 시스템은 "둘 다 성공하거나 둘 다 실패하도록" 보장합니다.

JavaScript에서 원자성이 깨지는 경우

javascript
// 이렇게 비동기 작업이 섞이면 원자성이 깨집니다
saveButton.addEventListener('click', async (event) => {
saveButton.disabled = true;

// 여기서 await을 만나면 함수 실행이 일시정지됩니다
const result = await validateFormAsync();

// 이 시점에서 다른 코드가 실행될 수 있습니다!
// 예를 들어, 다른 클릭 이벤트가 발생할 수 있어요

if (result.isValid) {
saveButton.textContent = '저장 중...';
}
});

위 코드에서는 await 때문에 함수 실행이 중간에 멈추므로, 사용자가 빠르게 클릭했을 때 예상치 못한 상태가 발생할 수 있어요.

이해를 돕기 위해서 질문 하나 드려볼게요.

만약 여러분이 온라인 쇼핑몰에서 "주문하기" 버튼을 눌렀을 때, 재고 확인, 결제 처리, 주문 상태 업데이트가 각각 따로따로 처리된다면 어떤 문제가 생길 수 있을까요?

이런 상황을 생각해보시면 원자적 처리의 중요성을 더 잘 이해할 수 있을 거예요!

원자적 접근 마지막 예제에서의 해결 법

위의 "원자적(atomically)으로 처리된다는 게 무슨 말일까?"에 이어지는 내용입니다. 마지막 예제에서, 비동기 작업으로 인해 원자성이 깨지는 문제를 제시했어요. 이에 대한 해결책도 궁금할 것 같아서 추가로 설명해드릴게요.

1단계: 상태 관리로 중복 실행 방지하기

가장 기본적인 접근법은 플래그 변수를 사용해서 작업이 진행 중인지 추적하는 것이에요.

javascript
let isSaving = false; // 현재 저장 작업이 진행 중인지 추적하는 플래그

saveButton.addEventListener('click', async (event) => {
// 이미 저장 중이라면 새로운 요청을 무시합니다
if (isSaving) {
console.log('이미 저장 중입니다...');
return;
}

// 저장 작업 시작을 표시
isSaving = true;
saveButton.disabled = true;
saveButton.textContent = '저장 중...';

try {
// 비동기 작업들을 순차적으로 처리
const validationResult = await validateFormAsync();

if (!validationResult.isValid) {
showErrorMessage(validationResult.errors);
return; // finally 블록에서 정리 작업이 실행됩니다
}

const saveResult = await sendDataToServer(validationResult.data);
showSuccessMessage('저장이 완료되었습니다!');
} catch (error) {
showErrorMessage('저장 중 오류가 발생했습니다: ' + error.message);
} finally {
// 성공이든 실패든 항상 상태를 원래대로 복구
isSaving = false;
saveButton.disabled = false;
saveButton.textContent = '저장';
}
});

여기서 finally 블록의 역할이 정말 중요해요. 성공하든 실패하든 항상 상태를 깔끔하게 정리해주기 때문이죠.

2단계: 더 정교한 상태 관리 - Promise 기반 접근법

하지만 위 방법도 한계가 있어요. 만약 사용자가 정말 빠르게 클릭해서 첫 번째 요청이 아직 isSaving = true를 설정하기 전에 두 번째 클릭이 발생한다면 어떻게 될까요? 이런 경우를 **레이스 컨디션(race condition - 경쟁 상태)**이라고 합니다. 더 안전한 방법은 진행 중인 Promise 자체를 추적하는 것이에요.

javascript
let currentSaveOperation = null; // 현재 진행 중인 저장 작업을 추적

saveButton.addEventListener('click', async (event) => {
// 이미 진행 중인 작업이 있다면, 그 작업을 기다립니다
if (currentSaveOperation) {
console.log('이전 저장 작업을 기다리는 중...');
await currentSaveOperation; // 이전 작업이 끝날 때까지 대기
return;
}

// 새로운 저장 작업을 시작하고 Promise를 저장합니다
currentSaveOperation = performSaveOperation();

try {
await currentSaveOperation;
} finally {
currentSaveOperation = null; // 작업 완료 후 정리
}
});

async function performSaveOperation() {
// 실제 저장 로직을 별도 함수로 분리
saveButton.disabled = true;
saveButton.textContent = '저장 중...';

try {
const validationResult = await validateFormAsync();

if (!validationResult.isValid) {
showErrorMessage(validationResult.errors);
return;
}

await sendDataToServer(validationResult.data);
showSuccessMessage('저장 완료!');
} catch (error) {
showErrorMessage('저장 실패: ' + error.message);
} finally {
saveButton.disabled = false;
saveButton.textContent = '저장';
}
}

다만, 이렇게만 설명하면 조금 부족하게 느껴질 수 있어서 추가로 설명드려볼게요. 1단계의 문제점을 파악하셨나요? 다음과 같은 시나리오를 상상해봅시다.

javascript
let isSaving = false;

saveButton.addEventListener('click', async (event) => {
if (isSaving) return; // 체크 시점

isSaving = true; // 설정 시점 (여기서 문제 발생 가능)
// ... 나머지 코드
});

사용자가 정말 빠르게 클릭했을 때, 다음과 같은 일이 발생할 수 있어요.

  • 시점 1: 첫 번째 클릭 → if (isSaving) 체크 → false이므로 통과
  • 시점 2: 두 번째 클릭 → if (isSaving) 체크 → 아직 false이므로 통과
  • 시점 3: 첫 번째 클릭 → isSaving = true 실행
  • 시점 4: 두 번째 클릭 → isSaving = true 실행

결과적으로 두 개의 저장 작업이 동시에 실행되게 됩니다. 이것이 바로 레이스 컨디션입니다. 두 개의 "경주자"가 동시에 결승선을 향해 달려가는 상황과 비슷하죠.

Promise 기반 접근법이 더 안전한 이유

Promise 기반 접근법은 이 문제를 근본적으로 다른 방식으로 해결합니다. 핵심 아이디어는 "현재 진행 중인 작업 자체"를 추적하는 것입니다.

javascript
let currentSaveOperation = null; // 진행 중인 Promise를 직접 저장

saveButton.addEventListener('click', async (event) => {
// 현재 진행 중인 작업이 있는지 확인
if (currentSaveOperation) {
console.log('이전 저장 작업을 기다리는 중...');
await currentSaveOperation; // 기존 작업 완료를 기다림
return; // 새로운 작업은 시작하지 않음
}

// 새로운 작업 시작
currentSaveOperation = performSaveOperation();

try {
await currentSaveOperation;
} finally {
currentSaveOperation = null; // 작업 완료 후 정리
}
});

단계별 실행 과정 분석

이제 이 코드가 어떻게 실행되는지 단계별로 살펴볼게요.

첫 번째 클릭이 발생했을 때:

  • currentSaveOperationnull이므로 if 조건을 통과
  • performSaveOperation() 함수를 호출하고 반환된 Promise를 currentSaveOperation에 저장
  • await currentSaveOperation으로 작업 완료를 기다림
  • 작업이 완료되면 finally 블록에서 currentSaveOperation = null로 정리

두 번째 클릭이 첫 번째 작업 진행 중에 발생했을 때:

  • currentSaveOperation이 여전히 Promise 객체이므로 if 조건에 해당
  • await currentSaveOperation으로 기존 작업의 완료를 기다림
  • 기존 작업이 완료되면 return으로 함수 종료 (새로운 작업은 시작하지 않음)

왜 이 방법이 더 안전한가?

Promise 기반 접근법의 핵심 장점은 원자적 상태 변경입니다. currentSaveOperation = performSaveOperation() 이 한 줄의 코드는 중간에 끊어질 수 없는 원자적 연산입니다. 함수를 호출하고 그 결과를 변수에 할당하는 것이 하나의 완전한 작업으로 처리되기 때문입니다.

반면 플래그 기반 방법에서는 "체크하고 → 설정하는" 두 단계의 작업이 있었고, 이 두 단계 사이에 다른 클릭이 끼어들 수 있었습니다.

performSaveOperation 함수의 역할

performSaveOperation 함수를 별도로 분리한 이유도 중요합니다:

javascript
async function performSaveOperation() {
// UI 상태 변경 (동기적 처리)
saveButton.disabled = true;
saveButton.textContent = '저장 중...';

try {
// 실제 비동기 작업들
const validationResult = await validateFormAsync();

if (!validationResult.isValid) {
showErrorMessage(validationResult.errors);
return; // 조기 종료 시에도 finally가 실행됨
}

await sendDataToServer(validationResult.data);
showSuccessMessage('저장 완료!');
} catch (error) {
showErrorMessage('저장 실패: ' + error.message);
} finally {
// 성공/실패 관계없이 UI 상태 복구
saveButton.disabled = false;
saveButton.textContent = '저장';
}
}

이 함수는 "하나의 완전한 저장 작업"을 캡슐화합니다. 성공하든 실패하든, 조기 종료되든 상관없이 finally 블록에서 UI 상태를 항상 깔끔하게 정리해줍니다.

실제 동작 시뮬레이션

사용자가 빠르게 세 번 클릭한다고 가정해봅시다:

  • 클릭 1: 새로운 저장 작업 시작 → performSaveOperation() 실행 중
  • 클릭 2: 기존 작업 대기 → 첫 번째 작업이 끝날 때까지 기다림 → 완료 후 함수 종료
  • 클릭 3: 기존 작업 대기 → 첫 번째 작업이 끝날 때까지 기다림 → 완료 후 함수 종료

결과적으로 실제 저장 작업은 한 번만 실행되고, 나머지 클릭들은 그 작업의 완료를 기다린 후 조용히 종료됩니다. 이렇게 Promise 기반 접근법은 레이스 컨디션을 원천적으로 방지하면서도, 사용자에게는 "클릭이 무시되지 않았다"는 느낌을 줄 수 있습니다. 두 번째, 세 번째 클릭도 첫 번째 작업의 완료를 기다리기 때문입니다.

이 방법의 핵심은 "상태를 체크하고 변경하는" 방식이 아니라, "현재 진행 중인 작업 자체를 추적하는" 방식으로 접근을 바꾼 것입니다. 이해가 되시나요?

3단계: 더 나은 사용자 경험을 위한 큐잉 시스템

때로는 사용자의 여러 요청을 완전히 무시하는 것보다, 순서대로 처리해주는 것이 더 나을 수 있어요. 이런 경우에는 선입 선출의 원칙을 가진 큐를 사용하기도 해요.

javascript
const saveQueue = []; // 대기 중인 저장 요청들을 저장하는 큐
let isProcessingQueue = false;

saveButton.addEventListener('click', (event) => {
// 요청을 큐에 추가
const saveRequest = {
formData: getCurrentFormData(),
timestamp: Date.now(),
};

saveQueue.push(saveRequest);

// 큐 처리 시작 (이미 처리 중이라면 아무것도 하지 않음)
processSaveQueue();
});

async function processSaveQueue() {
// 이미 큐를 처리하고 있다면 중복 실행 방지
if (isProcessingQueue) return;

isProcessingQueue = true;

while (saveQueue.length > 0) {
const request = saveQueue.shift(); // 큐에서 가장 오래된 요청 가져오기

try {
await processSingleSaveRequest(request);
} catch (error) {
console.error('저장 요청 처리 실패:', error);
}
}

isProcessingQueue = false;
}

어떤 방법을 선택해야 할까요?

각 방법의 특징을 이해하고 상황에 맞게 선택하는 것이 중요해요.

플래그 기반 방법은 가장 간단하고 이해하기 쉬워요. 대부분의 일반적인 상황에서 충분히 효과적이며, 특히 사용자가 실수로 여러번 클릭하는 것을 방지하고 싶을 때 적합해요.

Promise 기반 방법은 레이스 컨디션까지 고려한 더 안전한 접근 방법이에요. 복잡한 비동기 로직이 있거나 정확성이 매우 중요한 상황에서 사용하면 좋아요.

큐잉 시스템은 사용자의 모든 요청을 소중히 여기고 순서대로 처리하고 싶을 때 사용해요. 다만 구현이 복잡하므로 정말 필요한 경우에만 사용하는 것이 좋아요.

앞선 문제 2번 예제에서 Promise가 어떤 역할을 하는지 살펴보기

"원자적 접근 마지막 예제에서의 해결법"의 2번 항목에 이어지는 내용입니다.

정말 좋은 질문입니다! Promise가 "대기"를 만들어내는 핵심 원리를 이해하려면, Promise가 본질적으로 무엇인지부터 차근차근 살펴봐야 합니다.

Promise는 "미래의 결과에 대한 약속"입니다

Promise를 이해하는 가장 좋은 방법은 실생활의 비유로 시작하는 것입니다. 여러분이 온라인으로 피자를 주문했다고 생각해보세요. 주문을 하는 순간, 가게에서는 여러분에게 "주문번호 12345"를 줍니다. 이 주문번호는 무엇을 의미할까요?

이 주문번호는 "아직 완성되지 않았지만, 언젠가는 피자가 될 것"에 대한 약속입니다. 피자가 아직 만들어지지 않았지만, 여러분은 이 번호를 들고 "내 피자는 어떻게 되고 있나요?"라고 물어볼 수 있습니다. 가게에서는 "아직 만드는 중입니다" 또는 "완성되었습니다" 또는 "죄송하지만 재료가 떨어져서 취소되었습니다"라고 답할 것입니다.

Promise도 정확히 이와 같습니다. Promise는 "아직 완성되지 않았지만, 언젠가는 결과가 나올 것"에 대한 약속을 나타내는 객체입니다.

Promise의 세 가지 상태

Promise는 항상 다음 세 가지 상태 중 하나에 있습니다:

  • Pending (대기 상태): 아직 작업이 완료되지 않은 상태입니다. 피자로 비유하면 "아직 만드는 중"인 상태입니다.
  • Fulfilled (이행 상태): 작업이 성공적으로 완료된 상태입니다. "피자가 완성되었습니다"와 같은 상태입니다.
  • Rejected (거부 상태): 작업이 실패한 상태입니다. "재료가 떨어져서 피자를 만들 수 없습니다"와 같은 상태입니다.

await가 "대기"를 만드는 원리

이제 핵심인 await의 동작 원리를 살펴보겠습니다. await는 한국어로 번역하면 "기다리다"라는 뜻이고, 실제로도 그 역할을 합니다.

javascript
async function example() {
console.log('1. 작업 시작');

const result = await someAsyncOperation(); // 여기서 "대기" 발생

console.log('2. 작업 완료:', result);
}

위 코드에서 await someAsyncOperation() 줄에서 무슨 일이 일어나는지 단계별로 살펴보겠습니다:

  1. 호출 및 Promise 반환: someAsyncOperation()이 호출되어 Promise 객체가 반환됩니다. 이 시점에서 실제 작업은 아직 완료되지 않았을 수 있습니다.
  2. 상태 확인 및 일시정지: await 키워드가 이 Promise의 상태를 확인합니다. 만약 Promise가 아직 Pending 상태라면, JavaScript 엔진은 "이 함수의 실행을 여기서 일시정지하겠습니다"라고 결정합니다.
  3. 다른 작업 처리: 함수의 실행이 일시정지되는 동안, JavaScript 엔진은 다른 작업들을 처리할 수 있습니다. 다른 이벤트 핸들러, 다른 함수 호출 등이 실행될 수 있습니다.
  4. 실행 재개: Promise의 상태가 Fulfilled 또는 Rejected로 변경되면, JavaScript 엔진은 일시정지된 함수를 다시 "깨워서" 다음 줄부터 실행을 재개합니다.

우리 예제에서 대기가 발생하는 이유

이제 우리의 저장 버튼 예제로 돌아가 봅시다:

javascript
saveButton.addEventListener('click', async (event) => {
if (currentSaveOperation) {
console.log('이전 저장 작업을 기다리는 중...');
await currentSaveOperation; // 여기서 대기 발생!
return;
}

currentSaveOperation = performSaveOperation();
// ... 나머지 코드
});

첫 번째 클릭이 발생했을 때, currentSaveOperation에는 performSaveOperation()이 반환한 Promise가 저장됩니다. 이 Promise는 아직 Pending 상태입니다 (저장 작업이 아직 완료되지 않았으니까요).

두 번째 클릭이 발생했을 때, if (currentSaveOperation) 조건이 참이 되어 await currentSaveOperation 줄이 실행됩니다. 이 시점에서 JavaScript 엔진은 "첫 번째 클릭의 저장 작업이 완료될 때까지 두 번째 클릭 핸들러의 실행을 일시정지하겠습니다"라고 결정합니다.

실제 동작 과정을 시간 순서로 살펴보기

구체적인 시나리오를 통해 이해해봅시다:

  • 시간 0초: 사용자가 첫 번째 클릭
    • currentSaveOperation = performSaveOperation() 실행
    • performSaveOperation()이 Promise를 반환하고, 이 Promise는 Pending 상태
    • 서버에 데이터 전송 시작 (예상 소요시간: 3초)
  • 시간 1초: 사용자가 두 번째 클릭
    • if (currentSaveOperation) 조건이 참 (아직 첫 번째 작업이 진행 중)
    • await currentSaveOperation 실행
    • 두 번째 클릭 핸들러의 실행이 일시정지됨
  • 시간 3초: 첫 번째 저장 작업 완료
    • 서버에서 응답이 도착하여 첫 번째 Promise가 Fulfilled 상태로 변경
    • JavaScript 엔진이 일시정지된 두 번째 클릭 핸들러를 깨움
    • 두 번째 클릭 핸들러가 return 문을 실행하며 종료

Promise가 없다면 어떻게 될까요?

Promise와 await가 없던 시절에는 콜백 함수를 사용했습니다:

javascript
// Promise 없이 콜백으로 처리한다면...
saveButton.addEventListener('click', (event) => {
if (isSaving) return;

isSaving = true;

sendDataToServer(formData, function (error, result) {
// 이 콜백은 언젠가 실행될 것입니다
if (error) {
showErrorMessage(error);
} else {
showSuccessMessage(result);
}
isSaving = false;
});

// 이 줄은 서버 응답을 기다리지 않고 즉시 실행됩니다!
console.log('저장 요청을 보냈습니다');
});

콜백 방식에서는 "대기"라는 개념이 명확하지 않습니다. 함수는 콜백을 등록하고 즉시 종료되며, 나중에 콜백이 호출될 뿐입니다.

Promise의 진짜 힘: 조합 가능성

Promise의 진정한 장점은 여러 비동기 작업을 조합할 수 있다는 점입니다:

javascript
async function complexSaveOperation() {
// 여러 비동기 작업을 순차적으로 대기
const validation = await validateForm();
const userData = await getCurrentUser();
const saveResult = await sendToServer(validation, userData);
const logResult = await logActivity(saveResult);

return logResult;
}

await는 이전 작업이 완료될 때까지 기다린 후 다음 작업을 진행합니다. 이것이 바로 Promise와 await가 제공하는 "순차적 대기"의 힘입니다.

이제 Promise가 어떻게 "대기"를 만들어내는지 이해가 되시나요? Promise는 단순히 미래의 결과를 나타내는 객체이고, await는 그 결과가 준비될 때까지 함수의 실행을 일시정지시키는 키워드입니다. 이 두 개념이 결합되어 우리가 말하는 "대기" 현상이 만들어지는 것입니다.

이 개념들은 처음에는 추상적으로 느껴질 수 있지만, 한 번 이해하고 나면 매우 강력한 도구가 될거에요!

만약 유저가 1번 작업 중 3번째 4번째 클릭을 한다면?

실제로 사용자가 1번 작업 진행 중에 3번, 4번 클릭을 한다면 문제가 발생할 수 있습니다.

상황을 차근차근 분석해보겠습니다. 사용자가 빠르게 네 번 클릭한다고 가정해봅시다.

시간 순서별 분석

첫 번째 클릭이 발생하면 currentSaveOperation에 새로운 Promise가 저장되고 실제 저장 작업이 시작됩니다. 이때 currentSaveOperation은 Pending 상태의 Promise를 가리키고 있습니다.

두 번째 클릭이 발생하면 currentSaveOperation이 존재하므로 await currentSaveOperation에서 대기하게 됩니다. 이 두 번째 클릭 핸들러는 첫 번째 작업이 완료될 때까지 일시정지 상태에 들어갑니다.

여기서 중요한 점은 세 번째와 네 번째 클릭이 발생할 때입니다. 이들도 모두 동일한 currentSaveOperation Promise를 기다리게 됩니다. 문제는 이 모든 대기 중인 클릭 핸들러들이 첫 번째 작업이 완료되는 순간 거의 동시에 깨어난다는 것입니다.

동시 깨어남 문제

첫 번째 저장 작업이 완료되면 currentSaveOperationnull로 설정됩니다. 하지만 이미 대기 중이던 여러 개의 클릭 핸들러들은 각자 await currentSaveOperation 다음 줄부터 실행을 재개합니다. 이때 각 핸들러는 return 문을 만나서 종료되지만, 만약 코드가 조금 다르게 작성되어 있다면 심각한 문제가 발생할 수 있습니다.

예를 들어, 다음과 같은 코드를 생각해보세요.

javascript
saveButton.addEventListener('click', async (event) => {
if (currentSaveOperation) {
console.log('이전 저장 작업을 기다리는 중...');
await currentSaveOperation; // 여러 핸들러가 여기서 대기

// 만약 여기에 추가 로직이 있다면?
if (!currentSaveOperation) {
// 모든 핸들러가 동시에 이 조건을 만족할 수 있음
showMessage('대기가 끝났습니다. 다시 시도해보세요.');
}
return;
}

currentSaveOperation = performSaveOperation();
// ... 나머지 코드
});

이 경우 여러 핸들러가 동시에 깨어나서 동일한 메시지를 여러 번 표시할 수 있습니다.

더 근본적인 문제: 사용자 경험

더 중요한 문제는 사용자 경험 측면입니다. 사용자가 세 번째, 네 번째 클릭을 했을 때 이들 모두가 첫 번째 작업의 완료를 기다린다는 것은 직관적이지 않습니다. 사용자 입장에서는 "두 번째 클릭도 무시되고, 세 번째 클릭도 무시되길" 기대할 가능성이 높습니다.

개선된 해결책: 카운터 기반 접근법

이런 문제를 해결하기 위해서는 좀 더 정교한 접근이 필요합니다. 단순히 Promise의 존재 여부만 확인하는 것이 아니라, 각 요청에 고유한 식별자를 부여하는 방법을 고려해볼 수 있습니다.

javascript
let saveOperationId = 0; // 각 저장 작업에 고유 ID 부여
let currentSaveOperation = null;

saveButton.addEventListener('click', async (event) => {
// 현재 진행 중인 작업이 있다면 완전히 무시
if (currentSaveOperation) {
console.log('저장 작업이 이미 진행 중입니다. 요청을 무시합니다.');
return; // await 없이 즉시 종료
}

// 새로운 작업 시작
const thisOperationId = ++saveOperationId; // 고유 ID 생성
currentSaveOperation = performSaveOperation(thisOperationId);

try {
await currentSaveOperation;
} catch (error) {
console.error('저장 실패:', error);
} finally {
// 이 작업이 아직 최신 작업인지 확인
if (saveOperationId === thisOperationId) {
currentSaveOperation = null;
}
}
});

또 다른 접근법: 디바운싱(Debouncing)

때로는 아예 다른 관점에서 접근하는 것이 더 나을 수 있습니다. 사용자의 연속된 클릭을 하나의 의도로 해석하고, 마지막 클릭 이후 일정 시간이 지나면 작업을 실행하는 디바운싱 기법을 사용할 수도 있습니다.

javascript
let saveTimeout = null;

saveButton.addEventListener('click', (event) => {
// 이전 타이머가 있다면 취소
if (saveTimeout) {
clearTimeout(saveTimeout);
}

// 새로운 타이머 설정 (300ms 후 실행)
saveTimeout = setTimeout(async () => {
saveTimeout = null;
await performSaveOperation();
}, 300);

// 사용자에게 즉각적인 피드백 제공
showMessage('저장 예정... (연속 클릭시 마지막 클릭만 처리됩니다)');
});

어떤 방법을 선택해야 할까요?

결국 선택은 사용자 경험과 애플리케이션의 요구사항에 따라 달라집니다. 은행 송금 같은 중요한 작업이라면 완전히 무시하는 방식이 안전할 것이고, 일반적인 폼 저장이라면 디바운싱이 더 사용자 친화적일 수 있습니다.

Promise 기반 접근법도 완벽하지 않으며, 실제 상황에서는 더 세심한 고려가 필요하다는 것을 보여주는 좋은 예시입니다.

javascript
saveButton.addEventListener('click', (event) => {
// 이 모든 작업이 중간에 끊어지지 않고 완전히 처리됩니다

// 1단계: 버튼 비활성화 (사용자가 중복 클릭하지 못하도록)
saveButton.disabled = true;
saveButton.textContent = '저장 중...';

// 2단계: 폼 데이터 검증
const formData = validateForm();
if (!formData.isValid) {
// 에러 상태 표시
showErrorMessage(formData.errors);
saveButton.disabled = false;
saveButton.textContent = '저장';
return; // 여기서 함수가 완전히 종료됩니다
}

// 3단계: UI 상태 업데이트
updateUIForSaving();

// 4단계: 비동기 작업 시작 (이것만 별도 처리)
sendDataToServer(formData.data).then(handleSuccess).catch(handleError);

// 이 모든 과정이 중간에 다른 클릭 이벤트에 의해 방해받지 않습니다
});

만약 이 과정이 중간에 끊어질 수 있다면 어떻게 될까요?

사용자가 빠르게 여러 번 클릭했을 때, 폼 검증은 완료됐는데 UI 업데이트는 되지 않은 상태가 발생할 수 있습니다. 이런 중간 상태는 버그의 원인이 되기 쉽습니다.

즉각적 피드백의 심리적 효과

동기적 실행이 제공하는 즉각적 피드백은 단순히 기술적인 이점을 넘어서 사용자 경험의 핵심입니다.

인간의 뇌는 행동과 결과 사이의 지연을 매우 민감하게 감지합니다.

연구에 따르면, 100밀리초 이하의 응답 시간에서는 사용자가 "즉각적"이라고 느끼고, 1초를 넘어가면 "느리다"고 인식합니다.

javascript
// 즉각적 피드백의 좋은 예
button.addEventListener('click', (event) => {
// 클릭과 동시에 즉시 실행되는 시각적 피드백
button.classList.add('clicked'); // CSS 애니메이션 시작
button.style.transform = 'scale(0.95)'; // 버튼이 눌린 느낌

// 사용자는 이 변화를 즉시 볼 수 있습니다
// 이것이 "반응성 있는" 인터페이스의 핵심입니다

// 실제 작업은 별도로 처리
setTimeout(() => {
performActualTask();
button.classList.remove('clicked');
button.style.transform = '';
}, 0);
});

이런 즉각적 피드백은 사용자가 인터페이스를 "살아있는" 것으로 느끼게 만듭니다.

버튼을 누르면 즉시 반응하고, 마우스를 올리면 바로 하이라이트되는 것들이 모두 동기적 실행의 이점입니다.

디버깅에서의 명확한 인과관계

개발자 입장에서 동기적 실행의 가장 큰 장점 중 하나는 디버깅의 용이성입니다.

이벤트가 발생하고 핸들러가 실행되는 과정이 명확하고 예측 가능하기 때문에, 문제가 발생했을 때 원인을 찾기가 상대적으로 쉽습니다.

javascript
// 디버깅하기 쉬운 이벤트 핸들러
button.addEventListener('click', (event) => {
console.log('클릭 이벤트 시작'); // 1. 이것이 먼저 출력됩니다

const data = processData(); // 2. 이 함수가 완전히 실행됩니다
console.log('데이터 처리 완료:', data); // 3. 그 다음에 이것이 출력됩니다

updateUI(data); // 4. 마지막으로 UI가 업데이트됩니다
console.log('UI 업데이트 완료'); // 5. 모든 과정이 순서대로 진행됩니다
});

// 만약 문제가 발생한다면, 콘솔 로그를 보고 정확히 어느 단계에서
// 문제가 생겼는지 쉽게 파악할 수 있습니다

이와 대조적으로, 비동기적으로 처리되는 작업들은 실행 순서가 예측하기 어렵고, 디버깅할 때 훨씬 복잡한 상황을 만들어냅니다.

메인 스레드 블로킹의 실제 영향

하지만 동기적 실행의 단점도 명확합니다. 가장 심각한 문제는 메인 스레드 블로킹입니다. 이를 구체적으로 이해해보겠습니다.

JavaScript는 UI 스레드와 같은 스레드에서 실행됩니다. 즉, JavaScript 코드가 실행되는 동안에는 화면 렌더링, 사용자 입력 처리, 애니메이션 등 모든 UI 관련 작업이 멈춥니다. 이것이 바로 "블로킹"의 의미입니다.

javascript
// 문제가 되는 코드의 실제 예시
searchButton.addEventListener('click', (event) => {
const searchTerm = searchInput.value;

// 이 작업이 500ms가 걸린다고 가정해봅시다
const results = performComplexSearch(searchTerm);

// 이 500ms 동안 다음과 같은 일들이 일어납니다:
// 1. 마우스 커서가 멈춘 것처럼 보입니다
// 2. 다른 버튼을 클릭해도 반응하지 않습니다
// 3. 스크롤이 부드럽지 않거나 멈출 수 있습니다
// 4. CSS 애니메이션이 끊어집니다
// 5. 전체 페이지가 "얼어붙은" 것처럼 보입니다

displayResults(results);
});

function performComplexSearch(term) {
// 복잡한 검색 로직 시뮬레이션
let results = [];
for (let i = 0; i < 1000000; i++) {
if (Math.random() < 0.1) {
results.push(`결과 ${i}: ${term}`);
}
}
return results;
}

이런 상황에서 사용자는 "웹사이트가 느리다" 또는 "버그가 있다"고 느끼게 됩니다. 특히 모바일 기기에서는 이런 문제가 더욱 두드러집니다.

성능 문제의 누적 효과

메인 스레드 블로킹은 단순히 해당 순간만의 문제가 아닙니다.

누적 효과가 있습니다. 브라우저는 초당 60프레임을 목표로 하는데, 이는 16.67밀리초마다 화면을 다시 그려야 한다는 의미입니다.

만약 이벤트 핸들러가 이 시간을 넘어서 실행된다면, 프레임 드랍이 발생합니다.

javascript
// 프레임 드랍을 일으키는 예시
canvas.addEventListener('mousemove', (event) => {
// 마우스 움직임마다 복잡한 계산 수행
for (let i = 0; i < 100000; i++) {
// 각 계산이 0.5ms씩 걸린다면, 총 50ms가 소요됩니다
complexCalculation(event.clientX, event.clientY);
}

// 50ms는 약 3프레임에 해당하는 시간입니다
// 마우스를 조금만 빨리 움직여도 애니메이션이 끊어져 보입니다
updateCanvas(event);
});

이런 문제는 특히 상호작용이 많은 웹 애플리케이션에서 심각한 사용자 경험 저하를 일으킵니다. 게임, 그래픽 에디터, 실시간 차트 등에서는 이런 성능 문제가 치명적일 수 있습니다.

해결 전략들

동기적 실행의 단점을 극복하기 위해 개발자들은 여러 전략을 사용합니다. 가장 기본적인 것은 작업 분할입니다.

javascript
// 작업을 분할하여 처리하는 개선된 버전
searchButton.addEventListener('click', async (event) => {
const searchTerm = searchInput.value;

// 즉각적인 피드백 제공
showLoadingSpinner();
searchButton.disabled = true;

// 작업을 작은 단위로 분할
const results = await performComplexSearchAsync(searchTerm);

hideLoadingSpinner();
searchButton.disabled = false;
displayResults(results);
});

async function performComplexSearchAsync(term) {
const results = [];
const batchSize = 10000; // 한 번에 처리할 양

for (let start = 0; start < 1000000; start += batchSize) {
// 작은 배치 처리
for (let i = start; i < Math.min(start + batchSize, 1000000); i++) {
if (Math.random() < 0.1) {
results.push(`결과 ${i}: ${term}`);
}
}

// 다른 작업들이 실행될 수 있도록 양보
await new Promise((resolve) => setTimeout(resolve, 0));
}

return results;
}

이런 방식으로 동기적 실행의 장점은 유지하면서 단점을 최소화할 수 있습니다. 하지만 이것도 근본적인 해결책은 아닙니다.

이런 한계 때문에 IntersectionObserver 같은 새로운 API들이 등장한 것입니다.

옵저버 패턴의 딥다이브

이제 이벤트 핸들러의 동기적 실행 방식을 깊이 이해했으니, 옵저버의 세계로 들어가 보겠습니다.

앞에서 이미 옵저버 패턴이 무엇인가에 대해서 보았지만, 좀 더 깊게 들어가는 세션이라고 봐주시면 될 것 같아요.

옵저버를 이해하는 것은 마치 전혀 다른 사고 방식을 배우는 것과 같습니다. 이벤트 핸들러가 "무언가 일어나면 즉시 반응하라"는 명령형 접근이라면, 옵저버는 "무언가를 지켜보다가 변화가 있으면 알려달라"는 선언형 접근입니다.

옵저버를 이해하기 위해서는 먼저 그 철학적 기초를 파악해야 합니다.

이벤트 핸들러에서는 개발자가 능동적으로 "언제 무엇을 확인할지"를 결정합니다.

예를 들어 스크롤 이벤트를 듣고, 그 순간마다 요소의 위치를 확인하는 식입니다.

하지만 옵저버에서는 "무엇을 관찰할지"만 정의하고, "언제 확인할지"는 브라우저에게 맡깁니다.

이는 마치 경비원과 CCTV의 차이와 같습니다.

경비원(이벤트 핸들러)은 순찰을 돌면서 직접 상황을 확인해야 하지만, CCTV(옵저버)는 설치만 해두면 자동으로 감시하다가 문제가 생기면 알려줍니다.

CCTV가 더 효율적인 이유는 24시간 지속적으로 관찰할 수 있고, 여러 곳을 동시에 감시할 수 있으며, 실제로 문제가 생겼을 때만 알림을 보내기 때문입니다.

javascript
// 이벤트 핸들러 방식 (경비원처럼 직접 확인)
window.addEventListener('scroll', () => {
// 스크롤할 때마다 수동으로 확인해야 함
const element = document.querySelector('.target');
const rect = element.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;

if (isVisible) {
console.log('요소가 보입니다!');
}
});

// 옵저버 방식 (CCTV처럼 자동 감시)
const observer = new IntersectionObserver((entries) => {
// 브라우저가 자동으로 감시하다가 변화가 있을 때만 알려줌
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('요소가 보입니다!');
}
});
});

observer.observe(document.querySelector('.target'));

브라우저 최적화 주기와의 동기화

옵저버의 가장 중요한 특징 중 하나는 브라우저의 렌더링 주기와 동기화된다는 점입니다.

이는 매우 중요한 개념인데, 이를 이해하려면 브라우저가 어떻게 화면을 그리는지 알아야 합니다.

브라우저는 일반적으로 초당 60프레임으로 화면을 업데이트합니다.

이는 약 16.67밀리초마다 다음과 같은 과정을 거친다는 의미입니다.

먼저 JavaScript 실행, 스타일 계산, 레이아웃 계산, 페인트, 컴포지트 순서로 진행됩니다. IntersectionObserver의 콜백은 이 렌더링 파이프라인의 특정 시점에서 실행됩니다.

javascript
// 브라우저의 렌더링 주기를 시각적으로 보여주는 예제
const observer = new IntersectionObserver((entries) => {
console.log('옵저버 콜백 실행 시간:', performance.now());

entries.forEach((entry) => {
if (entry.isIntersecting) {
// 이 시점은 브라우저가 레이아웃을 계산한 직후입니다
// 따라서 entry.boundingClientRect는 정확한 값을 가집니다
console.log('정확한 위치 정보:', entry.boundingClientRect);
}
});
});

// 비교를 위한 requestAnimationFrame
function checkWithRAF() {
console.log('RAF 콜백 실행 시간:', performance.now());

// 이 시점에서는 아직 레이아웃이 확정되지 않았을 수 있습니다
const rect = element.getBoundingClientRect();
console.log('RAF에서의 위치 정보:', rect);

requestAnimationFrame(checkWithRAF);
}

requestAnimationFrame(checkWithRAF);

이런 동기화 덕분에 옵저버는 정확한 정보를 제공할 수 있습니다.

브라우저가 이미 모든 계산을 마친 후에 콜백을 실행하기 때문에, 개발자가 받는 정보는 신뢰할 수 있는 최신 상태입니다.

배치 처리의 효율성

옵저버가 이벤트 핸들러보다 효율적인 또 다른 이유는 배치 처리 방식입니다.

여러 요소의 상태가 동시에 변경되어도, 옵저버는 이를 하나의 콜백 호출로 묶어서 처리합니다. 이는 성능상 큰 이점을 제공합니다.

생각해보세요. 페이지에 100개의 이미지가 있고, 사용자가 빠르게 스크롤한다면 어떻게 될까요?

이벤트 핸들러 방식에서는 스크롤 이벤트가 발생할 때마다 100개 이미지의 위치를 모두 확인해야 합니다.

하지만 옵저버 방식에서는 브라우저가 "이번 프레임에서 변화가 있었던 이미지들"만 모아서 한 번에 알려줍니다.

javascript
// 배치 처리의 효과를 보여주는 예제
const images = document.querySelectorAll('.lazy-image');

// 각 이미지마다 옵저버 등록
const imageObserver = new IntersectionObserver(
(entries) => {
// entries 배열에는 이번 프레임에서 변화가 있었던 모든 이미지가 들어있습니다
console.log(`이번에 처리할 이미지 수: ${entries.length}`);

entries.forEach((entry) => {
if (entry.isIntersecting) {
// 이미지가 보이기 시작했을 때의 처리
const img = entry.target;
img.src = img.dataset.src; // 실제 이미지 로딩

// 더 이상 관찰할 필요가 없으므로 해제
imageObserver.unobserve(img);
}
});
},
{
// 이미지가 뷰포트에 들어오기 100px 전부터 로딩 시작
rootMargin: '100px',
}
);

// 모든 이미지를 관찰 대상으로 등록
images.forEach((img) => imageObserver.observe(img));

이 예제에서 주목할 점은 사용자가 아무리 빠르게 스크롤해도, 각 렌더링 프레임마다 최대 한 번의 콜백만 실행된다는 것입니다.

만약 한 프레임에서 10개의 이미지가 동시에 화면에 나타났다면, 10번의 개별 호출 대신 10개 요소가 담긴 배열과 함께 한 번의 콜백이 실행됩니다.

논블로킹 실행의 의미

옵저버의 또 다른 핵심 특징은 논블로킹 실행입니다.

이는 단순히 "메인 스레드를 블록하지 않는다"는 의미를 넘어서, 더 근본적인 실행 철학의 차이를 보여줍니다.

이벤트 핸들러에서는 핸들러 함수가 실행되는 동안 다른 모든 작업이 기다려야 합니다.

하지만 옵저버에서는 콜백 함수 자체는 여전히 메인 스레드에서 실행되지만, 관찰 작업 자체는 브라우저의 다른 시스템에서 처리됩니다.

이는 관찰 대상이 아무리 많아도 JavaScript 실행에는 거의 영향을 주지 않는다는 의미입니다.

javascript
// 옵저버의 논블로킹 특성을 보여주는 예제
const heavyWorkObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 이 콜백 함수 자체는 메인 스레드에서 실행됩니다
// 하지만 여기 도달하기까지의 모든 관찰 작업은 논블로킹이었습니다
console.log('요소가 보입니다:', entry.target.id);

// 만약 여기서 무거운 작업을 한다면 여전히 블로킹됩니다
// 하지만 관찰 자체는 이런 무거운 작업에 영향받지 않습니다
}
});
});

// 1000개의 요소를 관찰해도 JavaScript 성능에는 거의 영향이 없습니다
for (let i = 0; i < 1000; i++) {
const element = document.createElement('div');
element.id = `element-${i}`;
document.body.appendChild(element);
heavyWorkObserver.observe(element); // 이 작업은 논블로킹입니다
}

// 다른 JavaScript 작업들은 계속 정상적으로 실행됩니다
setInterval(() => {
console.log('다른 작업이 정상적으로 실행됩니다:', Date.now());
}, 1000);

브라우저 엔진 수준의 최적화

옵저버가 강력한 이유 중 하나는 브라우저 엔진 수준에서 최적화되어 있다는 점입니다.

이는 JavaScript로는 구현하기 어려운 수준의 최적화를 제공합니다.

예를 들어, 브라우저는 GPU를 활용하여 변환 행렬 계산을 가속화할 수 있습니다.

또한 브라우저는 실제로 화면에 보이지 않는 요소들에 대해서는 불필요한 계산을 생략할 수 있습니다.

이런 최적화들은 JavaScript 수준에서는 접근할 수 없는 브라우저 내부의 정보를 활용합니다.

javascript
// 브라우저 엔진 최적화의 이점을 보여주는 예제
const performanceObserver = new IntersectionObserver(
(entries) => {
const startTime = performance.now();

entries.forEach((entry) => {
// 브라우저가 이미 계산해둔 정확한 정보를 즉시 사용할 수 있습니다
const rect = entry.boundingClientRect;
const isVisible = entry.isIntersecting;
const ratio = entry.intersectionRatio;

// 이 모든 정보가 이미 최적화된 방식으로 계산되어 있습니다
console.log(`요소 ${entry.target.id}: 가시성 ${ratio * 100}%`);
});

const endTime = performance.now();
console.log(`처리 시간: ${endTime - startTime}ms`);
},
{
// 브라우저는 이 임계값들을 효율적으로 처리합니다
threshold: [0, 0.25, 0.5, 0.75, 1.0],
}
);

// 비교를 위한 수동 계산 방식
function manualIntersectionCheck() {
const startTime = performance.now();

elements.forEach((element) => {
// 이런 계산들을 JavaScript에서 직접 해야 합니다
const rect = element.getBoundingClientRect();
const viewHeight = window.innerHeight;
const viewWidth = window.innerWidth;

// 교차 영역 계산 (브라우저가 내부적으로 하는 일을 수동으로)
const intersectionTop = Math.max(rect.top, 0);
const intersectionBottom = Math.min(rect.bottom, viewHeight);
const intersectionLeft = Math.max(rect.left, 0);
const intersectionRight = Math.min(rect.right, viewWidth);

// 이런 계산들이 매번 반복됩니다
});

const endTime = performance.now();
console.log(`수동 계산 시간: ${endTime - startTime}ms`);
}

메모리 관리의 자동화

옵저버는 메모리 관리 측면에서도 이벤트 핸들러와 다른 접근을 합니다.

옵저버는 WeakRef 패턴을 내부적으로 사용하여, 관찰 대상 요소가 DOM에서 제거되면 자동으로 해당 관찰을 정리합니다.

이는 메모리 누수를 방지하는 데 큰 도움이 됩니다.

javascript
// 메모리 관리의 차이를 보여주는 예제
function createDynamicContent() {
const container = document.createElement('div');

// 이벤트 핸들러 방식 - 수동 정리 필요
const handleScroll = () => {
// 스크롤 핸들러가 container를 참조합니다
checkElementVisibility(container);
};

window.addEventListener('scroll', handleScroll);

// 컨테이너를 DOM에 추가
document.body.appendChild(container);

// 나중에 제거할 때 - 수동으로 정리해야 함
setTimeout(() => {
document.body.removeChild(container);
// 이벤트 리스너를 제거하지 않으면 메모리 누수 발생
window.removeEventListener('scroll', handleScroll);
}, 5000);
}

function createDynamicContentWithObserver() {
const container = document.createElement('div');

// 옵저버 방식 - 자동 정리
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// 옵저버가 container를 약한 참조로 관리합니다
console.log('컨테이너 상태:', entry.isIntersecting);
});
});

observer.observe(container);
document.body.appendChild(container);

// 나중에 제거할 때 - 자동으로 정리됨
setTimeout(() => {
document.body.removeChild(container);
// container가 DOM에서 제거되면 옵저버도 자동으로 정리됩니다
// 명시적으로 observer.unobserve()를 호출할 필요가 없습니다
}, 5000);
}

지금까지 옵저버의 기본적인 특성들을 살펴봤습니다.

이제 이벤트 핸들러와의 차이점이 더 명확하게 보이시나요?

브라우저 렌더링 파이프라인과 옵저버

브라우저 렌더링 패스에서 옵저버가 언제 실행되는 지를 한번 살펴봅시다.

렌더링 패스에서의 실행 시점을 이해하는 건, 오케스트라에서 각 악기가 언제 연주되는지를 아는 것과 같아요.

각 옵저버는 브라우저의 렌더링 파이프라인에서 서로 다른 시점에서 실행되며, 이 시점을 이해하면 왜 특정 옵저버가 특정 작업에 적합한 지 알 수 있게 됩니다.

브라우저 렌더링 파이프라인의 이해

브라우저가 한 프레임을 렌더링하는 과정을 차근차근 살펴볼게요.

이 과정을 이해해야 각 옵저버가 어느 단계에서 실행되는지 명확하게 파악할 수 있어요.

브라우저의 렌더링 파이프라인은 일반적으로 다음과 같은 순서로 실행됩니다.

먼저 JavaScript 실행 단계에서 사용자 코드와 이벤트 핸들러가 실행되빈다.

다음으로 스타일 계산 단게에서 CSS 규칙을 적용하여 각 요소의 최종 스타일을 결정합니다.

그 후 레이아웃 단계에서 각 요소의 정확한 위치와 크기를 계산해요.

페인트 단계에서는 실제 픽셀 데이터를 생성하고, 마지막으로 컴포지트 단게에서 여러 레이러를 합성하여 최종 화면을 만듭니다.

각 옵저버는 이 파이프라인의 서로 다른 지점에서 실행되며, 이는 그들이 접근할 수 있는 정보와 성능 특성을 결정

합니다.

생각해봅시다. 레이아웃이 계산되기 전에 실행되는 옵저버는 정확한 위치 정보를 얻을 수 없지만, 레이아웃 후에 실행되는 옵저버는 정확한 정보를 제공할 수 있습니다.

IntersectionObserver: 레이아웃 후 실행

IntersectionObserver는 가장 널리 알려진 옵저버로, 레이아웃 계산이 완료된 후에 실행됩니다.

이는 매우 중요한 특성인데, 요소의 정확한 위치와 크기 정보가 필요하기 때문입니다.

javascript
// IntersectionObserver의 실행 시점을 보여주는 예제
const intersectionObserver = new IntersectionObserver((entries) => {
console.log('IntersectionObserver 실행 시점:', performance.now());

entries.forEach((entry) => {
// 이 시점에서는 레이아웃이 이미 계산되어 있습니다
// 따라서 정확한 위치 정보를 얻을 수 있습니다
console.log('정확한 경계 사각형:', entry.boundingClientRect);
console.log('교차 비율:', entry.intersectionRatio);

// 이 정보들은 브라우저가 레이아웃 단계에서 계산한 값들입니다
if (entry.isIntersecting) {
// 실제로 요소가 보이는 상태에서만 실행됩니다
entry.target.classList.add('visible');
}
});
});

// 레이아웃을 변경하는 작업을 해보겠습니다
const element = document.querySelector('.target');
element.style.transform = 'translateX(100px)'; // 레이아웃 변경

// IntersectionObserver는 이 변경이 완전히 반영된 후에 실행됩니다
intersectionObserver.observe(element);

IntersectionObserver가 레이아웃 후에 실행된다는 것은 성능상 큰 의미가 있습니다.

만약 JavaScript에서 직접 getBoundingClientRect()를 호출한다면, 브라우저는 즉시 레이아웃을 강제로 계산해야 합니다.

하지만 IntersectionObserver는 브라우저가 자연스럽게 레이아웃을 계산한 후에 실행되므로, 불필요한 레이아웃 재계산을 방지합니다.

ResizeObserver: 레이아웃 직후 실행

ResizeObserver는 요소의 크기 변화를 감지하는 옵저버입니다.

이는 IntersectionObserver와 비슷한 시점에 실행되지만, 약간 더 이른 시점에서 실행될 수 있습니다.

ResizeObserver는 레이아웃 계산 단계와 매우 밀접하게 연결되어 있기 때문입니다.

javascript
// ResizeObserver의 실행 시점과 특성을 보여주는 예제
const resizeObserver = new ResizeObserver((entries) => {
console.log('ResizeObserver 실행 시점:', performance.now());

entries.forEach((entry) => {
// ResizeObserver는 레이아웃 계산과 거의 동시에 실행됩니다
const { width, height } = entry.contentRect;
console.log(`새로운 크기: ${width}x${height}`);

// 크기 변화에 따른 즉각적인 반응이 가능합니다
if (width > 500) {
entry.target.classList.add('large');
} else {
entry.target.classList.remove('large');
}

// border-box 크기도 확인할 수 있습니다
const borderBoxSize = entry.borderBoxSize[0];
console.log(`경계 상자 크기: ${borderBoxSize.inlineSize}x${borderBoxSize.blockSize}`);
});
});

// 반응형 컴포넌트를 만들어보겠습니다
const responsiveElement = document.querySelector('.responsive');
resizeObserver.observe(responsiveElement);

// 크기를 동적으로 변경해보겠습니다
responsiveElement.style.width = '600px';
// ResizeObserver는 이 변경을 즉시 감지하고 콜백을 실행합니다

ResizeObserver의 흥미로운 특성 중 하나는 무한 루프를 방지하는 메커니즘이 있다는 것입니다.

만약 ResizeObserver 콜백 내에서 관찰 대상의 크기를 변경한다면, 브라우저는 이를 감지하고 적절히 처리합니다.

이는 복잡한 반응형 레이아웃을 안전하게 구현할 수 있게 해줍니다.

MutationObserver: DOM 변경 즉시 실행

MutationObserver는 다른 옵저버들과는 실행 시점이 완전히 다릅니다.

이는 DOM 구조의 변화를 감지하므로, 렌더링 파이프라인의 가장 초기 단계에서 실행됩니다.

정확히는 DOM 변경이 일어난 직후, 하지만 스타일 계산이나 레이아웃 계산이 시작되기 전에 실행됩니다.

javascript
// MutationObserver의 실행 시점을 보여주는 예제
const mutationObserver = new MutationObserver((mutations) => {
console.log('MutationObserver 실행 시점:', performance.now());

mutations.forEach((mutation) => {
console.log('변경 유형:', mutation.type);

if (mutation.type === 'childList') {
// 자식 요소가 추가되거나 제거되었습니다
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('추가된 요소:', node.tagName);
// 이 시점에서는 아직 스타일이나 레이아웃이 계산되지 않았습니다
// 따라서 DOM 구조에 대한 즉각적인 반응만 가능합니다
}
});
}

if (mutation.type === 'attributes') {
// 속성이 변경되었습니다
console.log(`${mutation.attributeName} 속성이 변경됨`);

// 이 시점에서 스타일 재계산을 트리거할 수 있습니다
if (mutation.attributeName === 'class') {
// 클래스 변경에 대한 즉각적인 처리
handleClassChange(mutation.target);
}
}
});
});

// DOM 변경을 감지하도록 설정합니다
const targetNode = document.querySelector('.observed-container');
mutationObserver.observe(targetNode, {
childList: true, // 자식 요소 추가/제거 감지
attributes: true, // 속성 변경 감지
subtree: true, // 하위 트리의 변경도 감지
attributeOldValue: true, // 이전 속성값도 기록
});

// DOM을 변경해보겠습니다
const newElement = document.createElement('div');
newElement.textContent = '새로운 요소';
targetNode.appendChild(newElement); // MutationObserver가 즉시 이를 감지합니다

MutationObserver가 초기 단계에서 실행된다는 것은 중요한 의미가 있습니다.

이는 DOM 구조 변경에 대한 즉각적인 반응을 가능하게 하지만, 동시에 아직 최종적인 레이아웃 정보는 사용할 수 없다는 의미이기도 합니다.

따라서 MutationObserver는 주로 DOM 구조 자체를 관리하는 용도로 사용됩니다.

PerformanceObserver: 성능 측정 전문

PerformanceObserver는 브라우저의 성능 메트릭을 관찰하는 특별한 옵저버입니다.

이는 렌더링 파이프라인의 여러 지점에서 수집된 성능 데이터를 제공하므로, 다른 옵저버들과는 완전히 다른 실행 패턴을 가집니다.

javascript
// PerformanceObserver의 다양한 메트릭을 관찰하는 예제
const performanceObserver = new PerformanceObserver((list) => {
console.log('PerformanceObserver 실행 시점:', performance.now());

const entries = list.getEntries();
entries.forEach((entry) => {
console.log(`성능 항목 유형: ${entry.entryType}`);

if (entry.entryType === 'paint') {
// 페인트 이벤트 (first-paint, first-contentful-paint)
console.log(`${entry.name}: ${entry.startTime}ms`);

// 이 정보는 브라우저가 실제로 화면을 그린 후에 제공됩니다
if (entry.name === 'first-contentful-paint') {
console.log('사용자가 첫 번째 콘텐츠를 볼 수 있게 되었습니다');
}
}

if (entry.entryType === 'layout-shift') {
// 레이아웃 시프트 감지 (CLS 측정에 중요)
console.log(`레이아웃 시프트 점수: ${entry.value}`);

// 예상치 못한 레이아웃 변경을 감지했습니다
if (entry.value > 0.1) {
console.warn('큰 레이아웃 시프트가 발생했습니다!');
// 문제가 되는 요소들을 확인할 수 있습니다
entry.sources.forEach((source) => {
console.log('문제 요소:', source.node);
});
}
}

if (entry.entryType === 'largest-contentful-paint') {
// 가장 큰 콘텐츠가 렌더링된 시점
console.log(`LCP: ${entry.startTime}ms`);
console.log('LCP 요소:', entry.element);
}
});
});

// 다양한 성능 메트릭을 관찰합니다
performanceObserver.observe({
entryTypes: ['paint', 'layout-shift', 'largest-contentful-paint'],
});

// 특정 작업의 성능을 측정해보겠습니다
performance.mark('heavy-task-start');

// 무거운 작업 시뮬레이션
setTimeout(() => {
for (let i = 0; i < 100000; i++) {
// 복잡한 계산
}

performance.mark('heavy-task-end');
performance.measure('heavy-task-duration', 'heavy-task-start', 'heavy-task-end');

// 이 측정 결과도 PerformanceObserver에서 확인할 수 있습니다
}, 0);

PerformanceObserver는 웹 애플리케이션의 성능을 실시간으로 모니터링하는 데 매우 유용합니다.

특히 Core Web Vitals 같은 사용자 경험 메트릭을 측정할 때 필수적인 도구입니다.

옵저버들의 실행 순서와 상호작용

여러 옵저버가 동시에 활성화되어 있을 때, 브라우저는 어떤 순서로 이들을 실행할까요?

이는 각 옵저버가 렌더링 파이프라인의 어느 지점에서 실행되는지와 직접적으로 관련이 있습니다.

javascript
// 여러 옵저버의 실행 순서를 관찰하는 종합 예제
const testElement = document.querySelector('.test-element');

// 1. MutationObserver - DOM 변경 즉시
const mutationObs = new MutationObserver((mutations) => {
console.log('1. MutationObserver 실행 - DOM 변경 감지');
});

// 2. ResizeObserver - 레이아웃 계산 중/직후
const resizeObs = new ResizeObserver((entries) => {
console.log('2. ResizeObserver 실행 - 크기 변경 감지');
});

// 3. IntersectionObserver - 레이아웃 계산 완료 후
const intersectionObs = new IntersectionObserver((entries) => {
console.log('3. IntersectionObserver 실행 - 교차 상태 확인');
});

// 4. PerformanceObserver - 다양한 시점
const perfObs = new PerformanceObserver((list) => {
console.log('4. PerformanceObserver 실행 - 성능 메트릭 수집');
});

// 모든 옵저버를 활성화합니다
mutationObs.observe(testElement, { attributes: true });
resizeObs.observe(testElement);
intersectionObs.observe(testElement);
perfObs.observe({ entryTypes: ['measure'] });

// 변경을 트리거하여 실행 순서를 확인해보겠습니다
console.log('변경 시작');
performance.mark('change-start');

// DOM 속성 변경 (MutationObserver 트리거)
testElement.setAttribute('data-test', 'changed');

// 크기 변경 (ResizeObserver 트리거)
testElement.style.width = '300px';

// 위치 변경 (IntersectionObserver 트리거 가능)
testElement.style.transform = 'translateX(50px)';

performance.mark('change-end');
performance.measure('total-change', 'change-start', 'change-end');

console.log('변경 완료 - 옵저버들이 순서대로 실행됩니다');

이 예제를 실행하면, 옵저버들이 브라우저의 렌더링 파이프라인 순서에 따라 실행되는 것을 확인할 수 있습니다.

이런 실행 순서를 이해하면, 복잡한 상호작용이 있는 애플리케이션에서도 예측 가능한 동작을 구현할 수 있습니다.

정리

글이 꽤나 길었네요.

이번 주제를 통해서, 이벤트 핸들러와 옵저버의 차이, 그리고, 브라우저 렌더링 파이프라인에서 옵저버가 어떤 타이밍에 실행되는지 등을 자세히 알 수 있었길 바랍니다.

궁금한 점은 댓글로 남겨주시면 확인되는대로 빠르게 답장 남길게요!

긴 글 읽어주셔서 감사합니다. 🙇