본문 바로가기
C.W.K.
Stream
Lesson 01 of 05 · published

콜백 — Node 비동기의 출발점

~12 min · async, callbacks, history

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"프로미스와 async/await가 자리 잡기 전, Node의 비동기 API는 콜백을 중심으로 자랐어. 지금도 그 흔적이 표준 라이브러리와 이벤트 API 곳곳에 남아 있어."

오류 우선 콜백이 생긴 이유

초기 Node에는 언어 차원의 프로미스가 없었어. 파일 읽기를 시작한 뒤 결과를 돌려받으려면 fs.readFile(path, callback)처럼 완료 시 실행할 함수를 함께 넘겼지. Node가 정착시킨 관례는 오류 우선 콜백이야. callback(err, result)에서 err가 있으면 실패이고, 없으면 나머지 인수가 결과야.

이 모양은 libuv의 비동기 I/O와 잘 맞았어. 작업을 시작한 호출 스택은 먼저 끝나고, I/O가 완료되면 런타임이 등록해 둔 함수를 호출해. 콜백은 “완료되면 알려 줘”라는 계약을 JavaScript 함수 하나로 표현한 셈이야.

중첩보다 더 까다로운 계약

여러 비동기 작업을 순서대로 잇기 시작하면 콜백이 안쪽으로 깊어져. 단계마다 오류 분기와 들여쓰기가 늘어나서 성공 경로와 실패 경로를 한눈에 보기 어려워지지.

fs.readFile('a.json', (err, raw) => {
  if (err) return done(err);
  parse(raw, (err, data) => {
    if (err) return done(err);
    transform(data, (err, out) => {
      if (err) return done(err);
      fs.writeFile('b.json', out, done);
    });
  });
});

이른바 “콜백 지옥”은 모양만의 문제가 아니야. 콜백은 성공이나 실패 중 정확히 한 번 호출되어야 해. 두 번 부르면 후속 작업이 중복되고, 한 번도 부르지 않으면 흐름이 멈춰. 어떤 구현은 즉시 콜백을 부르고 어떤 구현은 나중에 부르는 현상도 있었는데, 실행 순서를 예측하기 어려운 이 문제를 흔히 Zalgo라고 불렀어. 프로미스는 완료 상태를 한 번만 정하고 후속 처리를 항상 비동기적으로 이어서 이런 계약을 더 분명하게 만들었지.

오래된 API를 프로미스로 잇기

util.promisify는 표준 오류 우선 콜백 API를 프로미스 세계로 이어 주는 다리야.
import fs from 'node:fs';
import { promisify } from 'node:util';

const readFile = promisify(fs.readFile);
const text = await readFile('a.json', 'utf8');
다만 모든 콜백 함수를 무조건 감쌀 수 있는 건 아니야. 성공 값이 여러 개이거나 this에 의존하거나 오류 우선 규약을 따르지 않는 함수라면 전용 래퍼가 필요해. 표준 라이브러리가 node:fs/promises처럼 프로미스 API를 직접 제공한다면 그쪽을 먼저 고르면 돼.

콜백이 지금도 알맞은 자리

콜백은 사라진 문법이 아니야. HTTP 요청, EventEmitter 이벤트, 스트림 알림처럼 여러 번 일어나는 사건을 구독할 때는 여전히 자연스러워. createServer(handler)의 처리 함수는 요청이 올 때마다 호출되고, emitter.on(name, listener)의 리스너도 같은 이벤트가 발생할 때마다 실행돼.

프로미스 하나는 한 번 끝나는 작업을 표현해. 반복되는 값에는 이벤트 리스너나 비동기 이터레이터 같은 다른 모양이 더 잘 맞아. 중요한 건 새 문법을 무조건 고르는 게 아니라, 한 번의 완료인지 이어지는 사건인지 구분하는 거야.

Pippa의 고백

처음에는 콜백을 낡은 코드에서만 만나는 유물로 봤어. 그런데 net.createServer, fs.watch, process.on을 나란히 놓고 보니 공통점이 선명했어. 모두 결과 하나를 돌려주는 함수가 아니라 사건이 생길 때마다 실행할 관심사를 등록하는 API였지. 오래됐다는 이유만으로 나쁜 모양은 아니야. 표현하려는 시간의 모양과 맞지 않을 때 문제가 되는 거야.

Code

한 번의 파일 읽기를 표현하는 세 가지 방식·javascript
// 오류 우선 콜백 방식
import fs from 'node:fs';

fs.readFile('a.json', 'utf8', (err, raw) => {
  if (err) {
    console.error('read failed:', err);
    return;
  }
  try {
    const data = JSON.parse(raw);
    console.log('got:', data);
  } catch (error) {
    console.error('parse failed:', error);
  }
});

// 오래된 API를 프로미스로 잇기
import { promisify } from 'node:util';
const readFile = promisify(fs.readFile);
const raw = await readFile('a.json', 'utf8');
console.log(JSON.parse(raw));

// 표준 라이브러리가 제공한다면 전용 프로미스 API를 우선한다
import { readFile as readFileP } from 'node:fs/promises';
console.log(JSON.parse(await readFileP('a.json', 'utf8')));
반복되는 사건을 구독하는 콜백·javascript
// 여러 번 일어나는 사건에는 콜백이 자연스럽다
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  // 요청이 들어올 때마다 다시 실행된다
  res.end(`hi from ${req.url}`);
});
server.listen(3000);

import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
bus.on('user.signup', (user) => {
  // 가입 이벤트가 생길 때마다 실행된다
  emailWelcome(user);
});

External links

Exercise

오류 우선 콜백 API 하나를 골라 같은 작업을 세 번 구현해 봐. 먼저 new Promise로 직접 감싸고, 다음에는 util.promisify를 쓰고, 마지막에는 node:fs/promisesnode:dns/promises의 전용 API를 사용해. 각 버전에서 오류가 어디로 전달되고 완료가 몇 번 확정되는지 비교해.
Hint
직접 만든 래퍼에서는 콜백의 err가 있으면 reject, 없으면 resolve를 호출해. promisify는 표준 오류 우선 규약을 대신 처리하지만, this가 필요한 메서드나 성공 인수가 여러 개인 API에는 별도 처리가 필요할 수 있어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.