포스트

JavaScript Callback 완벽 가이드 - 비동기 프로그래밍의 시작

JavaScript Callback 함수의 모든 것. 동기/비동기 콜백의 차이부터 콜백 지옥 해결법까지, 비동기 프로그래밍의 기초를 완벽하게 이해합니다.

JavaScript Callback 완벽 가이드 - 비동기 프로그래밍의 시작

들어가며

Callback(콜백)은 JavaScript 비동기 프로그래밍의 가장 기초적인 패턴입니다.

setTimeout, 이벤트 리스너, AJAX 요청 등 모든 비동기 작업은 콜백으로 시작되었습니다. 콜백을 이해하지 못하면 Promise, async/await도 제대로 이해할 수 없습니다.

JavaScript는 싱글 스레드지만 콜백 덕분에 비동기 작업을 처리할 수 있습니다!

동기 vs 비동기

동기(Synchronous)

동기는 코드가 작성된 순서대로 한 줄씩 실행되는 방식입니다.

1
2
3
4
5
6
7
8
console.log('1');
console.log('2');
console.log('3');

// 출력:
// 1
// 2
// 3

동작 방식:

1
코드 1 실행 → 완료 대기 → 코드 2 실행 → 완료 대기 → 코드 3 실행

동기의 문제점

1
2
3
4
5
6
7
8
9
10
11
console.log('작업 시작');

// 5초 걸리는 무거운 작업 (예시)
for (let i = 0; i < 5000000000; i++) {
  // 무거운 연산...
}

console.log('작업 완료');
console.log('다음 작업');

// 문제: 5초 동안 브라우저가 멈춤 (UI 차단)

⚠️ 문제: 무거운 작업이 끝날 때까지 다음 코드가 실행되지 않습니다!

비동기(Asynchronous)

비동기는 작업을 시작하고 완료를 기다리지 않고 다음 코드를 실행하는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
console.log('1');

setTimeout(() => {
  console.log('2');
}, 1000);

console.log('3');

// 출력:
// 1
// 3
// 2 (1초 후)

동작 방식:

1
코드 1 실행 → setTimeout 등록 → 코드 3 실행 → (1초 후) 콜백 실행

비교표

구분동기비동기
실행 순서코드 작성 순서대로완료 시점이 다를 수 있음
대기작업 완료까지 대기대기하지 않음
UI 차단차단 가능차단 없음
예시일반 코드, 반복문setTimeout, fetch, 이벤트

콜백 함수란?

콜백 함수다른 함수의 인자로 전달되는 함수입니다.

기본 개념

1
2
3
4
5
6
7
8
9
10
11
12
// greet는 콜백 함수
function greet(name) {
  console.log(`Hello, ${name}!`);
}

// processUserInput은 콜백을 받는 함수
function processUserInput(callback) {
  const name = 'Alice';
  callback(name);  // 콜백 실행
}

processUserInput(greet);  // 'Hello, Alice!'

인라인 콜백

1
2
3
4
5
6
7
8
processUserInput(function(name) {
  console.log(`Hi, ${name}!`);
});

// 또는 화살표 함수
processUserInput(name => {
  console.log(`Hey, ${name}!`);
});

💡 핵심: 콜백은 함수를 값처럼 전달하여 나중에 실행하는 패턴입니다!

콜백의 두 가지 유형

1. Synchronous Callback (동기 콜백)

즉시 실행되는 콜백입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 배열 메서드들은 동기 콜백 사용
const numbers = [1, 2, 3, 4, 5];

console.log('시작');

numbers.forEach(num => {
  console.log(num);
});

console.log('');

// 출력:
// 시작
// 1
// 2
// 3
// 4
// 5
// 끝

다른 예시

1
2
3
4
5
6
7
8
// map, filter, reduce도 동기 콜백
const doubled = numbers.map(num => num * 2);
const evens = numbers.filter(num => num % 2 === 0);
const sum = numbers.reduce((acc, num) => acc + num, 0);

console.log(doubled);  // [2, 4, 6, 8, 10]
console.log(evens);    // [2, 4]
console.log(sum);      // 15

2. Asynchronous Callback (비동기 콜백)

나중에 실행되는 콜백입니다.

1
2
3
4
5
6
7
8
9
10
11
12
console.log('시작');

setTimeout(() => {
  console.log('타임아웃 콜백');
}, 1000);

console.log('');

// 출력:
// 시작
// 끝
// 타임아웃 콜백 (1초 후)

다양한 비동기 콜백 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. 타이머
setTimeout(() => {
  console.log('1초 후 실행');
}, 1000);

setInterval(() => {
  console.log('1초마다 실행');
}, 1000);

// 2. 이벤트 리스너
button.addEventListener('click', () => {
  console.log('버튼 클릭됨');
});

// 3. AJAX (fetch 이전 방식)
$.ajax({
  url: '/api/users',
  success: function(data) {
    console.log('데이터 받음:', data);
  },
  error: function(error) {
    console.error('에러 발생:', error);
  }
});

비교표

구분Synchronous CallbackAsynchronous Callback
실행 시점즉시 실행나중에 실행
코드 흐름순차적비순차적
예시forEach, map, filtersetTimeout, 이벤트, AJAX

실전 활용 예제

1. 배열 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 17 }
];

// 성인 사용자의 이름만 추출
function getAdultNames(users, callback) {
  const adults = users.filter(user => user.age >= 18);
  const names = adults.map(user => user.name);
  callback(names);
}

getAdultNames(users, (names) => {
  console.log('성인 사용자:', names);
  // ['Alice', 'Bob']
});

2. 데이터 로딩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function loadData(callback) {
  console.log('데이터 로딩 시작...');

  setTimeout(() => {
    const data = { id: 1, name: 'Sample Data' };
    callback(data);
  }, 2000);
}

loadData((data) => {
  console.log('데이터 로드 완료:', data);
});

console.log('다른 작업 계속...');

// 출력:
// 데이터 로딩 시작...
// 다른 작업 계속...
// (2초 후) 데이터 로드 완료: { id: 1, name: 'Sample Data' }

3. 순차 실행

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function step1(callback) {
  setTimeout(() => {
    console.log('Step 1 완료');
    callback();
  }, 1000);
}

function step2(callback) {
  setTimeout(() => {
    console.log('Step 2 완료');
    callback();
  }, 1000);
}

function step3(callback) {
  setTimeout(() => {
    console.log('Step 3 완료');
    callback();
  }, 1000);
}

// 순차 실행
step1(() => {
  step2(() => {
    step3(() => {
      console.log('모든 작업 완료!');
    });
  });
});

콜백 지옥 (Callback Hell)

여러 비동기 작업을 순차적으로 실행할 때 콜백 안에 콜백을 계속 중첩하면 코드가 복잡해집니다.

콜백 지옥의 예

1
2
3
4
5
6
7
8
9
10
11
// 사용자 정보 → 포스트 → 댓글 순서로 가져오기
getUser(userId, (user) => {
  getUserPosts(user.id, (posts) => {
    getPostComments(posts[0].id, (comments) => {
      getCommentAuthor(comments[0].authorId, (author) => {
        console.log('댓글 작성자:', author);
        // 😱 4단계 중첩!
      });
    });
  });
});

실제 예시: 로그인 → 데이터 로드 → 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
function login(email, password, successCallback, errorCallback) {
  setTimeout(() => {
    if (email && password) {
      successCallback({ userId: 1, email: email });
    } else {
      errorCallback(new Error('로그인 실패'));
    }
  }, 1000);
}

function getUserData(userId, successCallback, errorCallback) {
  setTimeout(() => {
    if (userId) {
      successCallback({ name: 'Alice', roles: ['admin'] });
    } else {
      errorCallback(new Error('사용자 데이터 로드 실패'));
    }
  }, 1000);
}

function getPurchaseHistory(userId, successCallback, errorCallback) {
  setTimeout(() => {
    if (userId) {
      successCallback([{ item: 'Book', price: 20 }]);
    } else {
      errorCallback(new Error('구매 내역 로드 실패'));
    }
  }, 1000);
}

// ❌ 콜백 지옥!
login(
  'alice@example.com',
  'password123',
  (user) => {
    console.log('로그인 성공:', user);
    getUserData(
      user.userId,
      (userData) => {
        console.log('사용자 데이터:', userData);
        getPurchaseHistory(
          user.userId,
          (purchases) => {
            console.log('구매 내역:', purchases);
            // 더 깊은 중첩 계속...
          },
          (error) => {
            console.error('구매 내역 에러:', error);
          }
        );
      },
      (error) => {
        console.error('사용자 데이터 에러:', error);
      }
    );
  },
  (error) => {
    console.error('로그인 에러:', error);
  }
);

콜백 지옥의 문제점

문제설명
가독성 저하코드가 오른쪽으로 계속 들여쓰기됨
유지보수 어려움수정 시 전체 구조 파악 필요
에러 처리 복잡각 단계마다 에러 핸들러 필요
디버깅 어려움어디서 에러 발생했는지 추적 힘듦

⚠️ Pyramid of Doom: 콜백 지옥을 “파멸의 피라미드”라고도 부릅니다!

콜백 지옥 해결 방법

1. 함수 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ✅ 개선: 함수를 분리하여 평탄하게
function onLoginSuccess(user) {
  console.log('로그인 성공:', user);
  getUserData(user.userId, onGetUserDataSuccess, onError);
}

function onGetUserDataSuccess(userData) {
  console.log('사용자 데이터:', userData);
  // 다음 작업...
}

function onError(error) {
  console.error('에러 발생:', error);
}

login('alice@example.com', 'password123', onLoginSuccess, onError);

2. Promise 사용 (권장)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// ✅ Promise로 개선
function loginPromise(email, password) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (email && password) {
        resolve({ userId: 1, email: email });
      } else {
        reject(new Error('로그인 실패'));
      }
    }, 1000);
  });
}

function getUserDataPromise(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId) {
        resolve({ name: 'Alice', roles: ['admin'] });
      } else {
        reject(new Error('사용자 데이터 로드 실패'));
      }
    }, 1000);
  });
}

// Promise 체이닝
loginPromise('alice@example.com', 'password123')
  .then(user => {
    console.log('로그인 성공:', user);
    return getUserDataPromise(user.userId);
  })
  .then(userData => {
    console.log('사용자 데이터:', userData);
  })
  .catch(error => {
    console.error('에러:', error);
  });

3. async/await 사용 (최고 권장)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ✅ async/await로 개선 (가장 깔끔!)
async function processUserLogin() {
  try {
    const user = await loginPromise('alice@example.com', 'password123');
    console.log('로그인 성공:', user);

    const userData = await getUserDataPromise(user.userId);
    console.log('사용자 데이터:', userData);

    const purchases = await getPurchaseHistoryPromise(user.userId);
    console.log('구매 내역:', purchases);
  } catch (error) {
    console.error('에러:', error);
  }
}

processUserLogin();

에러 처리 패턴

Node.js 스타일: Error-first Callback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function readFile(filename, callback) {
  // 비동기 작업 시뮬레이션
  setTimeout(() => {
    const error = null;  // 에러가 없으면 null
    const data = 'File content';

    // 첫 번째 인자는 에러, 두 번째는 데이터
    callback(error, data);
  }, 1000);
}

// 사용
readFile('test.txt', (error, data) => {
  if (error) {
    console.error('에러 발생:', error);
    return;
  }
  console.log('데이터:', data);
});

성공/실패 콜백 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fetchData(successCallback, errorCallback) {
  setTimeout(() => {
    const success = Math.random() > 0.5;

    if (success) {
      successCallback({ data: 'Success!' });
    } else {
      errorCallback(new Error('Failed!'));
    }
  }, 1000);
}

// 사용
fetchData(
  (data) => console.log('성공:', data),
  (error) => console.error('실패:', error)
);

실전 패턴

1. 병렬 실행 후 결과 수집

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function parallelTasks(tasks, callback) {
  let completed = 0;
  const results = [];

  tasks.forEach((task, index) => {
    task((result) => {
      results[index] = result;
      completed++;

      if (completed === tasks.length) {
        callback(results);
      }
    });
  });
}

// 사용 예
const tasks = [
  (cb) => setTimeout(() => cb('Task 1'), 1000),
  (cb) => setTimeout(() => cb('Task 2'), 500),
  (cb) => setTimeout(() => cb('Task 3'), 1500)
];

parallelTasks(tasks, (results) => {
  console.log('모든 작업 완료:', results);
  // ['Task 1', 'Task 2', 'Task 3']
});

2. 순차 실행 헬퍼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function series(tasks, finalCallback) {
  let index = 0;
  const results = [];

  function next() {
    if (index >= tasks.length) {
      finalCallback(results);
      return;
    }

    tasks[index]((result) => {
      results.push(result);
      index++;
      next();
    });
  }

  next();
}

// 사용 예
const sequentialTasks = [
  (cb) => setTimeout(() => { console.log('Step 1'); cb('Result 1'); }, 1000),
  (cb) => setTimeout(() => { console.log('Step 2'); cb('Result 2'); }, 1000),
  (cb) => setTimeout(() => { console.log('Step 3'); cb('Result 3'); }, 1000)
];

series(sequentialTasks, (results) => {
  console.log('순차 작업 완료:', results);
});

3. 재시도 로직

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function retry(task, maxAttempts, callback) {
  let attempts = 0;

  function attempt() {
    attempts++;
    task(
      (result) => {
        callback(null, result);
      },
      (error) => {
        if (attempts < maxAttempts) {
          console.log(`재시도 ${attempts}/${maxAttempts}...`);
          setTimeout(attempt, 1000);
        } else {
          callback(error);
        }
      }
    );
  }

  attempt();
}

// 사용 예
function unreliableTask(success, failure) {
  const shouldSucceed = Math.random() > 0.7;
  setTimeout(() => {
    if (shouldSucceed) {
      success('Success!');
    } else {
      failure(new Error('Failed'));
    }
  }, 500);
}

retry(unreliableTask, 3, (error, result) => {
  if (error) {
    console.error('최종 실패:', error);
  } else {
    console.log('성공:', result);
  }
});

콜백 vs Promise vs async/await

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 1. 콜백
fetchUser(userId, (error, user) => {
  if (error) {
    handleError(error);
  } else {
    fetchPosts(user.id, (error, posts) => {
      if (error) {
        handleError(error);
      } else {
        console.log(posts);
      }
    });
  }
});

// 2. Promise
fetchUserPromise(userId)
  .then(user => fetchPostsPromise(user.id))
  .then(posts => console.log(posts))
  .catch(error => handleError(error));

// 3. async/await
async function getUserPosts(userId) {
  try {
    const user = await fetchUserPromise(userId);
    const posts = await fetchPostsPromise(user.id);
    console.log(posts);
  } catch (error) {
    handleError(error);
  }
}
패턴가독성에러 처리학습 곡선권장도
Callback⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Promise⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
async/await⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

핵심 정리

콜백의 종류

  1. Synchronous Callback: 즉시 실행 (forEach, map, filter)
  2. Asynchronous Callback: 나중에 실행 (setTimeout, 이벤트, AJAX)

콜백 지옥 해결

  1. 함수 분리: 중첩 줄이기
  2. Promise: 체이닝으로 평탄하게
  3. async/await: 동기 코드처럼 작성

Best Practices

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ✅ 좋은 예
function fetchData(callback) {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => callback(null, data))
    .catch(error => callback(error));
}

// ❌ 나쁜 예: 콜백 지옥
fetchA((a) => {
  fetchB(a, (b) => {
    fetchC(b, (c) => {
      // 너무 깊은 중첩...
    });
  });
});

마이그레이션 가이드

1
2
3
4
레거시 콜백 코드가 있다면
├─ 새 코드는 Promise/async-await 사용
├─ 기존 콜백 → Promise 래퍼 작성
└─ 점진적으로 리팩토링

콜백은 JavaScript 비동기의 기초입니다. 콜백을 이해해야 Promise와 async/await도 완벽하게 이해할 수 있습니다!

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.