콜백, 프로미스, async / await

·2022년 4월 26일
1

JavaScript

목록 보기
4/4

주의

이 글은 이벤트 루프와 마이크로태스크 작동 순서를 이해하고 보길 권한다. 필요하다면 이 포스팅을 참조하자.

머리말

옛날에는 비동기를 콜백 함수로 처리했다.

비동기 처리는 단순하지 않다.

A라는 처리 결과에 대한...
	B라는 처리 결과에 대한...
		C라는 처리 결과에 대한...
			D라는 처리 결과에 대해...
		코드를 작성하면...
	콜백 지옥이...
탄생한다...
doSomething(function(result) {
  doSomethingElse(result, function(newResult) {
    doThirdThing(newResult, function(finalResult) {
      console.log('Got the final result: ' + finalResult);
	// 에러용 failureCallback 콜백 함수를 여러 차례 붙이는 모습.
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

콜백 지옥의 문제점.

  • 나쁜 가독성
  • 실행 순서 보장 X
    • 자원을 받아오는 시간이 1000ms, 자원을 활용하는 콜백 함수를 실행하는 코드까지 도달하는데 10ms 걸린다고 하면?
  • 종속성
    • 콜백 함수들이 얽히고설키면 하나 고칠 때 여럿 고친다.
  • 에러 처리 난해
    • 에러 처리를 꼬박꼬박 붙여줘야 하지, 피곤하다.

이런 문제를 해결하기 위해 Promise가 도입됐다는 사실은 유명하다.
그러면 프로미스가 도입되기 전에는 다들 무조건 저런 괴로운 코드를 썼을까?
그렇진 않다. 코드가 피라미드 형태로 쌓이지 않도록 여러 노력을 했다.

document.querySelector('form').onsubmit = formSubmit

function formSubmit (submitEvent) {
  var name = document.querySelector('input').value
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, postResponse)
}

function postResponse (err, response, body) {
  var statusMessage = document.querySelector('.status')
  if (err) return statusMessage.value = err
  statusMessage.value = body
}

잘게 나누고,

module.exports.submit = formSubmit

function formSubmit (submitEvent) {
  var name = document.querySelector('input').value
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, postResponse)
}

function postResponse (err, response, body) {
  var statusMessage = document.querySelector('.status')
  if (err) return statusMessage.value = err
  statusMessage.value = body
}

모듈화도 하고,

 var fs = require('fs')

 fs.readFile('/Does/not/exist', handleFile)

 function handleFile (error, file) {
   if (error) return console.error('Uhoh, there was an error', error)
   // otherwise, continue on and use `file` in your code
 }

콜백마다 단일 에러 처리를 붙이는 등의 노력을 했다.

코드는 작성자만 알면 안 된다. 다른 개발자 눈에도 흐름이 잘 들어와야 한다.
그래서 자세히 몰라도 되는 부분은 모듈화 시켜서 다른 데에 저장하고, 요점만 남기곤 했다.
이러한 방식은 클린 코드를 위해서라도 참고하면 좋다.

그러나 위의 노력도 근본적인 고뇌를 말끔히 해소시키진 못했는데, 어차피 코드량은 많아지고 신경 써야할 부분은 많았다.

【Promise】

프로미스는 선언 당시 확정 못한 값에 대한 중개인(proxy)으로서 성공, 실패에 대한 처리를 할 수 있습니다. 일반 메소드처럼 최종적인 값을 반환하진 않고 나중에 주겠다는 프로미스(약속)을 반환합니다.

위에서 failureCallback을 계속 사용했던 코드를 then으로 적용한 사례를 보자.

doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
  console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);

훨씬 나아졌다. 가독성도 좋아졌고, 에러 처리도 한 번만 해주면 된다.

MDN에서 소개하는 프로미스의 이점.

  • then( ) 내부의 콜백 함수는 이벤트 루프가 콜 스택 작업을 마치기 전까지는 절대 호출되지 않습니다.
  • then( )을 여러 번 써서 콜백 함수를 계속 추가할 수 있습니다. 각 콜백 함수는 순서대로 실행됩니다.

프로미스는 어떻게 작성할까?

A Promise object is created using the new keyword and its constructor. This constructor takes a function, called the "executor function", as its parameter.
프로미스 객체는 new 지정어와 생성자로 만듭니다. 생성자는 "실행자 함수"를 매개변수로 사용합니다.

기본적으론 new Promise() 문법으로 프로미스를 생성한다. 이 때 내부에 작성하는 게 실행자 함수다.

Promise(executor: (resolve: (value: any) => void, reject: (reason?: any) => void) => void): Promise
프로미스 초기화용 콜백 함수.
실행자 함수는 매개 변수를 두 개 가집니다
1. 결과값 or 다른 프로미스 객체를 return하는 resolve 메소드.
2. 실패 이유나 에러를 표시하는 reject 메소드.
새 프로미스를 생성합니다.

// 프로미스를 만드는 예시
const promise1 = new Promise((resolve, reject) => {
    resolve('foo');
});

어떤 때에 프로미스를 만들까?

The Promise constructor is primarily used to wrap functions that do not already support promises.
프로미스 지원을 안 하는 함수를 감쌀 때 씁니다.

프로미스의 상태와 운명

프로미스는 3가지 상태(state)와 2가지 운명(fate)를 가진다.
번외로 확정(settled)이라는 표현을 쓴다.

  • 상태
    • fulfilled : 성공 (then 콜백을 당장 작업 큐에 집어넣을 수 있는 상태)
    • rejected : 실패 (catch 콜백을 당장 작업 큐에 집어넣을 수 있는 상태)
    • pending : 대기 (성공도 실패도 아닌 상태)
  • 운명
    • resolved : 해결 (성공, 실패를 다루는 함수를 사용함)
    • unresolved : 미해결 (해결을 안 함)

We say that a promise is settled if it is not pending, i.e. if it is either fulfilled or rejected. Being settled is not a state, just a linguistic convenience.
대기중(pending)만 아니면 확정입니다. 확정은 상태(state)를 뜻하는 건 아닙니다. 편의상 표현일 뿐입니다.

전개

  1. 코드를 실행하고 pending 프로미스를 초기화한다.
  2. 프로미스가 확정(성공 or 실패)된다.
  3. 성공하면 then에서 비동기 실행, 실패하면 catch에서 에러 처리.
  4. 새로운 pending 프로미스 객체를 초기화한다.
  5. 추가 작업시 위의 알고리즘을 반복한다.

미해결 !== 미확정

An unresolved promise is always in the pending state. A resolved promise may be pending, fulfilled or rejected.
해결 안 된 프로미스는 '계속' 대기 상태입니다. 해결된 프로미스는 대기, 성공, 실패 중 하나입니다.

The promise object will become "resolved" when either of the functions resolutionFunc or rejectionFunc are invoked.
Note that if you call resolutionFunc or rejectionFunc and pass another Promise object as an argument, you can say that it is "resolved", but still cannot be said to be "settled".
프로미스는 성공용 함수, 실패용 함수를 호출하면 "해결(resolved)"됩니다.
성공용, 실패용 함수에 또다른 프로미스를 전달해도 "해결"됩니다. 하지만 '아직은' 확정됐다고 말할 수 없습니다.

  • 예시 1
let p0 = new Promise((resolve, reject) => resolve(101010));
let p1 = new Promise((resolve, reject) => resolve(p0));
  • 예시 2
let p0 = new Promise((resolve, reject) => {
  resolve(123);
});

let p1 = p0.then((result) => {
  return result + 1;
});

// pending
console.log(p1);

// fulfilled
setTimeout(() => {
  console.log(p1);
}, 10);

예시 1, 예시 2에서 p1은 해결했어도 처음엔 pending 상태다.
예시 2의 실행 순서는 아래와 같다.

  1. p0 프로미스 생성
  2. console.log(p1)
  3. p0.then( )
  4. setTimeout

비동기 작업은 값이 언제 반환될지 정확하게 알 수 없다.
resolve가 정상적으로 처리됐다 !== 곧바로 성공 상태로 전환했다.
성공 상태의 정의가 'then을 바로 실행할 수 있는 상태'라는 점을 생각해보면

  • 성공 상태로 확정 -> 코드 실행 (X)
  • 코드를 먼저 실행 -> 성공 상태로 확정 (O)

이라고 정리할 수 있다.

성공한 프로미스에 대한 설명. 프로미스가 대기중이면 성공에 대한 코드를 실행 후 프로미스 결과를 할당한다. 마지막으로 상태를 성공으로 전환한다.

then은 웬만하면 '해결'로 처리한다.

성공으로 처리하는 함수가 꼭 resolve만 있지는 않다.
new Promiseresolve, reject를 호출하면 해결되지만 then웬만하면 해결된다.

let p0 = new Promise((resolve, reject) => {
  resolve(123);
});

let p1 = p0
  .then((result) => result + 100)
  .then((result) => result - 23)
  .then();

// pending
console.log(p1);

// fulfilled Promise { 200 }
setTimeout(() => {
  console.log(p1);
}, 100);

다른 프로미스를 반환하면 확정이 늦는다.

let p0 = new Promise((resolve, reject) => {
  resolve(123);
});

let p1 = new Promise((resolve, reject) => {
  resolve(p0);
});

setTimeout(() => {
  console.log("task queue");
}, 0);

p1.then((result) => {
  console.log("then callback");
});

queueMicrotask(() => {
  console.log("queueMicrotask");
});

console.log("call stack");  

위 코드에서 p1은 이미 확정된 프로미스 p0을 할당받는다. p1도 바로 확정이 될 것 같고, p1.then의 콜백도 queueMicrotask보다 먼저 실행될 것 같다.

하지만 미세한 차이로 queueMicrotask가 먼저 실행이 되고, 그 다음에 p1이 받아지면 then 콜백이 실행하고 확정한다. 결과값으로 다른 프로미스를 넣으면 확정이 조금 늦어진다. 그 증거로 resolve(p0)를 resolve(1)로 바꾸면 실행 순서가 역전되는 모습을 볼 수 있다.

성공했지만 undefined인 프로미스

let p0 = new Promise((resolve, reject) => {
  resolve(123);
});

let p1 = p0.then((result) => {
  setTimeout(() => {
    console.log("this callback returns undefined, not 404");
    return 404;
  }, 1000);
});

console.log(p1);

setTimeout(() => {
  console.log("Hi", p1);
}, 100);

setTimeout(() => {
  console.log("Hello?", p1);
}, 3000);

위의 코드에서 p1은 setTimeout()의 의도대로 404 프로미스를 return하지 않고 undefined로 해결된 프로미스를 return한다.

then 콜백 함수는 실행을 했으면 프로미스를 return한다. (then 콜백 내부에 중첩된 콜백 함수는 상관없다.) 마땅히 return 문이 없으면 undefined를 결과로 가진 프로미스를 초기화하고 성공으로 전환한다.

애초에 then'캐싱된 값을 순서대로, 빨리 이행하는' 컨셉이다.
fetchaxios.get이든 new Promise든 자원을 가져오는 시간은 걸릴 수밖에 없다.
하지만 then은 가져온 자원으로 최대한 빨리 처리만 하면 되는 메소드다. then에서 새 프로미스를 느긋하게 만들 이유는 없다. 의도대로 프로미스를 return하고 싶으면 정상적으로 작성하자.

【async / await】

async / await로도 프로미스를 다룰 수 있는데 특징은 다음과 같다.

  • 좀 더 간결해진 가독성
  • then / catch 대신 try / catch 구문 사용
  • 에러가 발생한 위치를 구체적으로 알려줌 (편한 디버깅)
  • 프로미스 지옥 해소

개인적으로 async / await의 가장 큰 장점은 프로미스 지옥의 해소 + 가독성 간결화라고 본다.
프로미스 지옥은 프로미스 안티 패턴 중 하나로서, 아래 안티 패턴 목차에서 소개하겠다.

async 함수

asyncPromise를 생성하고 돌려주는 역할을 한다.

async function foo() {
   return 1
}

function foo() {
   return Promise.resolve(1)
}

둘은 매우 흡사하다. 둘다 성공한 프로미스(1)을 반환한다.
차이점이라면 동치는 아니다. 아래 코드가 그걸 보여준다.

const p = new Promise((res, rej) => {
  res(1);
})

async function asyncReturn() {
  return p;
}

function basicReturn() {
  return Promise.resolve(p);
}

console.log(p === basicReturn()); // true
console.log(p === asyncReturn()); // false

이런 부분은 사실 잘 몰라도 된다.
async 함수 내부에선 여러 가지 일을 할 수 있지만 이러니저러니 해도 핵심은 해결된 프로미스 반환이다.

await 예약어

awaitasync 내부에서만 유효한 예약어(reserved word)로 async 함수가 아닌 곳에선 쓸 일이 없'었'다. 하지만 2019년 즈음부터 top-level await 개념이 도입됐다. 전역 await는 자원 초기화, 동적 의존성 연결 등에 활용한다.

async function foo() {
   await 1
}
  
function foo() {
   return Promise.resolve(1).then(() => undefined)
}

async 내부에서 await를 쓰면 저렇게 처리된다.
조금만 더 보자.

let p0 = new Promise((resolve, reject) => {
  resolve("promise");
});

async function createPromise() {
  return "async";
}

async function runPromise() {
  console.log("sync");
  let p1 = await createPromise();
  // 아래부턴 then의 콜백과 똑같이 microtask로 작동
  console.log(p1);
}

queueMicrotask(() => {
  console.log("microtask");
});
runPromise();
p0.then((result) => console.log(result));
  • await createPromise()를 실행하면 p1에는 PromiseResult값이 할당된다. await를 빼고 실행하면 프로미스 자체가 통째로 p1에 할당된다.
  • 프로미스는 변수에 result 값을 따로 저장해서 쓰기보단 then으로 result값을 전달받아서 처리한다.
queueMicrotask(() => {
	console.log("queueMicrotask");
});

async function callLog() {
	console.log(2);
}

async function callLog2() {
	console.log(3);
}

async function callLog3() {
	console.log(4);
}

const func = async () => {
	console.log(1);
	await callLog();
  // --- then 콜백처럼 적용 ---
	await callLog2();
  // --- then 콜백처럼 또 적용 ---
	await callLog3();
};

console.log("stack");

func();
queueMicrotask(() => {
	console.log("queueMicrotask2");
});
queueMicrotask(() => {
	console.log("queueMicrotask");
});

async function callLog() {
	console.log(2);
}

async function callLog2() {
	console.log(3);
}

async function callLog3() {
	console.log(4);
}

const func = async () => {
	console.log(1);
	await callLog();
  // --- then 콜백 적용 ---
	callLog2();
	callLog3();
};

console.log("stack");

func();
queueMicrotask(() => {
	console.log("queueMicrotask2");
});

await를 여러 개 쓴다는 말은 프로미스로 치면 프로미스 체인을 많이 쓴다는 뜻이다.
위 코드 2개는 await callLog()를 통해 비동기 작업을 시작하고, 아래부터 then으로 처리되도록 했다. callLog2, callLog3을 await로 호출하느냐 마냐의 차이 뿐이다.

  • 위의 callLog2, callLog3은 계속해서 비동기 작업을 추가하므로 콜 스택에 하나씩 들어가고, 다시 microtask queue로 이동하길 반복해서 처리된다.
  • 아래의 callLog2, callLog3은 추가적인 비동기 작업이 없으므로 한 번에 콜스택이 다 처리한다. 개발자 도구를 켜고 실행해보면 출력 순서가 다른 것을 확인할 수 있다.
queueMicrotask(() => {
  console.log("queueMicrotask");
});

let p0 = new Promise((resolve, reject) => {
  console.log("create new Promise");
  resolve("promise");
});

p0.then((result) => {
  console.log("chaining 1");
  console.log("chaining 2");
});

queueMicrotask(() => {
  console.log("queueMicrotask2");
});
queueMicrotask(() => {
  console.log("queueMicrotask");
});

let p0 = new Promise((resolve, reject) => {
  console.log("create new Promise");
  resolve("promise");
});

p0.then((result) => {
  console.log("chaining 1");
  return result;
}).then((result) => {
  console.log("chaining 2");
  return result;
});
  
queueMicrotask(() => {
  console.log("queueMicrotask2");
});

프로미스로 코드를 짠다면 위와 같은 상황이다.

top-level await

console.log("call stack");

queueMicrotask(() => {
  console.log("queueMicrotask");
});

let p0 = await fetch("https://velog.io/");

console.log("I'm in async!");

console.log(p0);

top-level await를 쓰면 그 부분만 특별하게 비동기로 처리되는 건 아니고, 자원을 다 받아오면 async처럼 아래가 전부 microtask로 처리된다.

【안티 패턴】

안티 패턴은 이름 그대로 '하지 말아야할 패턴'이다.
비동기는 언제, 어떤 게 반환될 지 대체로 기대할 뿐, 모든 게 확실하지는 않다.

데이터를 요청했는데 엉뚱한 데이터가 오거나, 깨진 데이터가 오거나, 뭔지 모를 게 오거나, 이상한 에러가 나거나...어떤 결과가 올 지는 누구도 모른다. 그렇기 때문에 비동기를 할 땐 코드를 잘 짜고, 에러 처리라던지, 마무리도 확실히 해야 한다.

미해결된(unresolved) 프로미스

const p0 = new Promise((resolve, reject) => null)
const p1 = Promise.resolve(17)
p0.then((result) => p1)
  .then((value) => value + 1)
  .then((value) => console.log(value))

일반적으로, resolvereject로 생성된 프로미스 값은 캐싱된다. then은 그 캐시를 활용하는 메소드다. 위 코드는 언뜻 보면 잘 처리될 것처럼 보인다. 하지만 실행 자체가 안 된다.

then<TResult1 = T, TResult2 = never>(
  onfulfilled?: ((value: T) => 
  TResult1 | PromiseLike<TResult1>) | undefined | null, 
  onrejected?: ((reason: any) => 
  TResult2 | PromiseLike<TResult2>) | undefined | null): 
  Promise<TResult1 | TResult2>;

catch는 실패한 프로미스일 때 콜백 함수를 실행하고, then은 확정된(성공, 실패가 완료된) 프로미스에 대해서만 콜백 함수를 실행한다.

미해결에 대한 처리는 아예 기술하지 않았다.

성공, 실패에 대한 리액션만 있다.

let p1 = new Promise((resolve, reject) => {
  console.log("해결 안 된 프로미스");
  return 123;
});
p1.then((result) => {
  console.log(result);
})
  .catch((err) => {
    console.log(err);
  })
  .finally(() => {
    console.log(123);
  });
// 위 코드는 처음 생성 당시 콘솔로그만 찍고 어떤 것도 실행하지 않는다.

await는 어떨까?

await도 돌아가는 원리는 똑같기 때문에 같은 조건을 주면 같은 반응을 보인다.

const resolvedPromise = new Promise((resolve, reject) => resolve("good"));

async function createUnresolvedPromise() {
  return new Promise((resolve, reject) => null);
}

async function notWorking() {
  let p0 = await createUnresolvedPromise();
  // 실행할 수 없음
  p0 = resolvedPromise;
  console.log(`this promise is ${p0}`);
}

notWorking();

위 코드는 콘솔로그를 실행 못한다.
createUnresolvedPromise 함수는 '해결을 안 한 프로미스'를 return했다. 이 코드를 바꾸지 않는 한 영원히 실행될 수 없다.

async function createPendingPromise() {
  console.log(123);
}

async function logPromise() {
  let p0 = await createPendingPromise();
  console.log(p0);
}

logPromise(); // undefined

근데 너무 당연한 현상인 게, '데이터를 받아온 다음에 순서대로 처리하도록 짠 코드'인데 데이터가 안 받아진 상황에서 실행이 되면 프로미스 쓰는 의미가 하나도 없다. 그건 그냥 콜백이랑 다를 게 없다.

해결은 했지만 네트워크 처리가 오래 걸려서 미확정인 시간이 너무 길다던지, 코드를 잘못 짜서 프로미스가 미해결된 채로 있으면 이에 대한 처리를 해야 사용자 경험으로도 좋다. 이는 Promise.racesetTimeout을 활용해서 타임아웃으로 처리하기도 한다.

// 타임아웃 처리 예시
let p0 = new Promise((resolve, reject) => {
  console.log("p0 작업중...");
  setTimeout(() => {
    resolve(1);
  }, 4000);
});

let p1 = new Promise((resolve, reject) => {
  console.log("p1 작업중...");
  setTimeout(() => {
    resolve(2);
  }, 3000);
});

let p2 = new Promise((resolve) => {
  setTimeout(() => {
    resolve("시간 초과");
  }, 2000);
});

Promise.race([p0, p1, p2]).then((value) => {
  if (value === "시간 초과") {
    throw new Error("대기 시간이 너무 깁니다!");
  }
});

부실한 에러 처리

then으로 에러 처리를 할 수는 있다. 하지만 권장하진 않는다.

then에서는 두 개의 콜백을 작성할 수 있는데 첫 번째는 성공에 대한 코드, 두 번째 콜백은 실패에 대한 이유(reason)를 작성한다.

promise1
  .then(
    (value) => {
      console.log(value);
    },
    (reason) => {
      console.log(reason);
    }
  )
  .catch((reason) => {
    console.log(reason, 123123);
  });

then이 에러를 처리하고 catch는 작동하지 않는다.
하지만 catch로 에러 및 실패를 처리하는 게 좋은데, 크게 두 가지 이유가 있다.

  1. 가독성.
  2. then(reason)은 then(value)에서 발생하는 에러를 처리 못하는 반면 catch는 그것까지 처리한다.

Promise에는 [[PromiseIsHandled]]라는 필드가 있다.
성패 여부를 표시하는 boolean인데 주로 처리가 안 된 실패를 추적할 때 쓴다고 한다.
이걸 이용해서 catch가 에러 처리를 하지 않을까 싶다.

결론 : catch로 에러 처리하자.

그럼 catch만 쓰면 되는데 then은 존재할 의의가 있을까?
있다. React에선 catch 대신 then으로 error를 처리해야 할 때가 있다.

프로미스 지옥

loadSomething()
.then(something => {
  loadAnotherthing()
  .then(another => {
    DoSomethingOnThem(something, another);
  });
});

콜백 지옥이 다가 아니라 프로미스 지옥도 있다.
위 코드의 경우 둘을 중첩시키지 말고 선형적으로 시행하는 게 낫다.
Promise.all이 그걸 돕는다.

Promise.all([loadSomething(), loadAnotherThing()])
    .then(values => {
        DoSomethingOnThem(values...);
});

Promise 관련 메소드.

  • Promise.all : 모든 프로미스가 '성공'하면 배열 반환. 하나라도 실패하면 에러 처리.
  • Promise.allSettled : 모든 프로미스가 '확정'되면 배열 반환.
  • Promise.race : 가장 빨리 '확정'된 프로미스 반환.
  • Promise.any : 가장 빨리 '성공'한 프로미스 반환.

Promise.all, Promise.allSettled는 배열에 미해결된 프로미스가 하나라도 있으면 아무 실행도 안 한다. (raceany는 성공, 확정된 프로미스 하나만 반환하면 되어서 상관없다.)

function promised() {
  return new Promise(resolve => {
    getOtherPromise().then(result => {
      getAnotherPromise(result).then(result2 => {
        resolve(result2);
      });
    });
  });
}

위 코드는 프로미스를 시행할 때 다른 프로미스가 필요하기 때문에 중첩할 수밖에 없다.
이럴 때에는 async / await가 유용하다.

async function promised() {
   const result =  await getOtherPromise();
   const result2 = await getAnotherPromise(result);
   return result2;
}

가독성이 훨씬 좋아졌다.

참조

profile
모르는 것 투성이

0개의 댓글