많은 JavaScript 코드가 이렇게 생겼던 때를 기억하십니까?
router.put('/some-url', (req, res) => {
fs.readFile(filePath(req), (err, file) => {
if (err) res.status(500).send()
else {
parse(file, (err, parsed) => {
if (err) res.status(500).send()
else db.insert(parsed, err => {
if (err) res.status(500).send()
else res.status(200).send()
})
})
}
})
})
그 당시를 기억하지 못할 만큼 운이 좋은 사람들에게 이것은 명백한 이유로 콜백 지옥이라고 불렀습니다. 다행히도 우리는 계속 진행했으며 요즘에는 동등한 코드가 다음과 같이 보일 가능성이 높습니다.
router.put('/some-url', async (req, res) => {
try {
const file = await fs.readFile(filePath(req));
const value = await parse(file);
await db.insert(value);
response.status(200).send();
} catch {
response.status(500).send();
}
})
물론 async / await 및 Promises가 있으므로 당시 JS의 구문 기능이 부족한 콜백 지옥 시대를 비난하고 계속 진행하기 쉽습니다. 그러나 나는 되돌아보고, 핵심 문제를 분석하고, 어떻게 해결되었는지, 그리고 그 모든 것에서 무엇을 배울 수 있는지에 대한 가치가 있다고 생각합니다.
문제
위의 지옥 같은 예의 전체 구조를 다시 살펴 보겠습니다.
doX(args, (err, res) => {
if (err) { ... }
else {
doY(args, (err, res) => {
if (err) { ... }
...
})
}
})
여기서 눈에 띄는 문제는 화면에 표시되는 내용의 대부분이 실제로 중요하지 않은 항목에만 사용된다는 것입니다.
doX(args /*, (err, res) => {
if (err) { ... }
else {*/
doY(args /*, (err, res) => {
if (err) { ... } */
...
/*}*/)
/*}*/
/*}*/)
비교를 위해 이것은 현대의 지옥이 아닌 버전과 동등한 구조입니다.
/* try { */
/*await*/ doX(args)
/*await*/ doY(args)
...
/*} catch { }*/
두 버전의 주석 처리 된 비트는 동일한 것을 나타냅니다. doX () 및 doY ()는 비동기 함수이며 일부 오류가 있을 수 있습니다. 하지만 지옥 같은 버전에서는 이러한 부가 노트를 위해 훨씬 더 많은 공간을 소비해야 하므로 코드를 훨씬 덜 읽을 수 있게 됩니다.
? 추가 구문 없이도 상용구를 자르고 코드를 더 읽기 쉬운 형식으로 재구성 할 수 있습니다. 역사적으로 말하면 Promise 라이브러리의 형태로 일어난 일입니다 (그러면 표준화 되고 약간의 구문 지원에 더 많은 사랑을 받았습니다).
doX(args)
.then(() => doY(args))
.then(() => ...)
.catch(() => { ... })
doX(args)
/*.then(() =>*/doY(args)/*)*/
/*.then(() =>*/.../*)*/
/*.catch(() => { ... })*/
이 코드와 지옥 코드의 중요한 차이점은 지옥 코드에서는 중요한 항목과 상용구 항목이 매우 얽혀있는 반면, 프라미스 라이브러리에서는 깔끔하게 분리되어있어 상용구의 양이 많은 경우에도 코드를 더 쉽게 읽을 수 있습니다. 거의 같다:
// without promises:
doX(args/*, (err, res) => { ... }*/)
// with promises:
doX(args)/*.then(() => { ... })*/
// with async/await:
/*await*/ doX(args)
Promises는 또한 비동기 프로그래밍 인체 공학에 도움이 되는 다른 중요한 기능을 제공합니다. 가장 중요한 것은 다음과 같습니다.
그러나 이러한 특성은 유익하지만 앞서 언급 한 분리만큼 중요하지 않다고 생각합니다. 이를 설명하기 위해 분리 만 수행하고 다른 작업은 수행하지 않는 실험적 promise 라이브러리를 만들고 어떻게 작동하는지 살펴 보겠습니다.
실험
그래서 처음에는 다음과 같은 함수로 시작했습니다.
doX(args, (err, res) => {...})
여기서 콜백은 기본 상용구 (그리고 지옥의 이름)이므로, 가장 쉬운 분리는 doX ()의 인수 목록에서 꺼내고 대신 지연된 함수에 넣는 것입니다.
doX(args)((err, res) => {...})
☝️ 이것은 기본적으로 doX가 구현되는 방법의 변경 사항입니다.
function doX(args, callback) {
// do stuff
// maybe do more
callback(undefined, 42)
}
이에:
function doX(args) {
// do stuff
return callback => {
// maybe do more
callback(undefined, 42)
}
}
즉, 우리는 다음과 같이 규칙을 변경했습니다.
마지막 인수로 콜백을 수락
에:
콜백을 인수로 받아들이는 함수를 반환
우리의 분리 규칙은 여전히 같은 양의 상용구를 가지고 있기 때문에 그 자체로는 많은 도움이 되지 않은 것 같습니다. 그러나 그것은 우리가 상용구를 제거하는 데 도움이 되는 단순한 유틸리티의 문을 열었습니다. 이를 확인하기 위해 먼저 pipe() 유틸리티를 소개하겠습니다.
function pipe(...cbs) {
let res = cbs[0];
for (let i = 1; i < cbs.length; i++) res = cbs[i](res);
return res;
}
간단히 말해서 다음과 같습니다.
pipe(a, b, c, d)
다음과 같습니다.
let x = a
x = b(x)
x = c(x)
x = d(x)
그리 멀지 않은 미래에 pipe()는 JavaScript 자체에 통합 될 수도 있습니다.
a |> b |> c |> d
어쨌든 pipe() 연산자를 사용하면 수동으로 콜백을 작성하지 않고도 (새로운 규칙) doX() (기억하는 것은 표준 콜백을 받는 함수 임)에 의해 반환 된 함수를 깔끔하게 변환 할 수 있습니다. 예를 들어 다음과 같이 then() 유틸리티를 만들 수 있습니다.
export function then(f) {
return src => {
src((err, res) => {
if (!err) f(res)
})
return src
}
}
이러한 유틸리티를 사용하면 비동기 코드가 다음과 같이 변환됩니다.
doX(args)((err, res) => { ... })
이에:
pipe(
doX(args),
then(() => { ... })
)
또는 더 나은 (파이프 라인 운영자 통합) :
doX(args) |> then(() => { ... })
표준 약속 라이브러리와 매우 비슷합니다.
doX(args).then(() => { ... })
간단한 catch() 유틸리티를 만들 수도 있습니다.
function catch(f) {
return src => {
src((err) => {
if (err) f(err)
})
return src
}
}
다음과 같은 비동기 코드를 제공합니다.
doX(args)
|> then(() => doY(args))
|> then(() => ...)
|> catch(() => { ... })
doX(args)
/*|> then(() =>*/ doY(args) /*)*/
/*|> then(() =>*/ ... /*)*/
/*|> catch(() => { ... })*/
거의 노력하지 않고 promise 라이브러리만큼 간결합니다. 더 좋은 점은,이 방법은 우리에게 확장 성을 제공합니다. 우리는 설정된 Promise 객체에 묶여 있지 않고 훨씬 더 광범위한 유틸리티 함수를 생성 / 사용할 수 있습니다.
function map(f) {
return src => cb => src((err, res) => {
if (err) cb(err, undefined)
else cb(undefined, f(res))
})
}
function delay(n) {
return src => cb => src((err, res) => {
if (err) cb(err, undefined)
else setTimeout(() => cb(undefined, res), n)
})
}
약간 거칠어지기 시작하십시오.
doX(args)
|> then(() => doY(args))
|> map(yRes => yRes * 2)
|> delay(200)
|> then(console.log)
실제 사례
그래서 그것은 우리가 promise 라이브러리에서 제공하는 것과 동일한 편리함을 제공하는 유틸리티와 라이브러리를 만들 수 있게 해주는 단순한 규칙 변경으로 보입니다 (그리고 async / await 구문과 거의 유사합니다). 더 나은 관점을 얻기 위해 실제 사례를 살펴 보겠습니다. 이 목적을 위해 (대부분 호기심에서) 실험용 라이브러리를 구현하여 온라인 놀이터를 만들었습니다.
먼저, 가장 지옥 같은 버전에서 다음과 같이 보였던 원래 예제를 살펴 보겠습니다.
router.put('/some-url', (req, res) => {
fs.readFile(filePath(req), (err, file) => {
if (err) res.status(500).send()
else {
parse(file, (err, parsed) => {
if (err) res.status(500).send()
else db.insert(parsed, err => {
if (err) res.status(500).send()
else res.status(200).send()
})
})
}
})
})
최신 JavaScript 버전은 다음과 같습니다.
router.put('/some-url', async (req, res) => {
try {
const file = await fs.readFile(filePath(req));
const value = await parse(file);
await db.insert(value);
response.status(200).send();
} catch {
response.status(500).send();
}
})
새로운 콜백 규칙 코드는 다음과 같습니다.
router.put('/some-url', (req, res) => {
fs.readFile(filePath(req))
|> map(parse)
|> flatten
|> map(db.insert)
|> flatten
|> then(() => res.status(200).send())
|> catch(() => res.status(500).send())
})
컨벤션은 우리를 async / await의 편리함과 매우 가깝게 만듭니다. 그래도 약간의 뉘앙스가 있습니다. flatten 유틸리티가 중간에 두 번 사용 되었습니까? Promise와 달리 콜백은 연결하는 동안 평평하지 않기 때문입니다. parse ()도 비동기라고 가정했습니다. 즉, promise-ish도 반환합니다. 그런 다음 map (parse)는 readFile ()의 결과를 새로운 promise-ish에 매핑합니다. 이 약속은 db.insert ()에 전달되기 전에 해결 된 값으로 평면화 되어야 합니다. async / await 코드에서 이것은 parse () 전에 await 키워드에 의해 수행되며 여기서는 flatten 유틸리티로 수행해야 합니다.
추신, flatten() 유틸리티는 본질적으로 매우 단순합니다.
function flatten(src) {
return cb => src((err, res) => {
if (err) cb(err, undefined)
else res((err, res) => {
if (err) cb(err, undefined)
else cb(undefined, res)
})
})
}
또 다른 예를 살펴 보겠습니다. 여기에서는 PokéAPI에서 일부 Pokémon 정보를 가져와 해당 능력을 기록하려고 합니다.
fetch('https://pokeapi.co/api/v2/pokemon/ditto')
|> map(res => res.json())
|> flatten
|> then(res => console.log(res.abilities))
async(() => {
let res = await fetch('https://pokeapi.co/api/v2/pokemon/ditto')
res = await res.json()
console.log(res.abilities)
})()
결론
요약하자면, 콜백 지옥을 초래하는 주요 문제인 것 같습니다.
우리의 작은 실험에 따르면, 두 번째 문제를 가장 간단한 방법 (다른 변경 없이 상용구 코드와 중요한 코드를 분리하는 것)으로 해결하는 것이 매우 중요했습니다.이를 통해 상용구 코드를 작은 유틸리티 함수로 묶고 상용구 코드의 비율을 줄일 수 있었습니다. 중요한 코드는 언어 자체에 새로운 구문을 추가하는 것과 같이 손이 많이 가는 솔루션 만큼 편리합니다.
이 개념은 특히 중요합니다. 제거 할 수 없는 추악한 구현 세부 정보와 상용구가 있을 수 있지만 항상 함께 번들로 묶어 실제 중요한 코드와 분리 할 수 있으며 가장 간단한 방법으로도 이 작업을 수행 할 수 있습니다. 지옥 같은 상황을 하늘의 상황으로
오늘날 우리가 직면하고 있는 다른 유사한 문제에도 동일한 방법론을 적용 할 수 있다는 점도 주목할 만합니다. (대부분) 비동기 함수의 문제를 해결했지만 비동기 스트림 (비동기 함수와 비슷하지만 하나가 아닌 무한한 출력이 많을 수 있음)과 같은 새로운 구조가 계속해서 도구 상자에 들어가 유사한 문제 해결을 요구합니다.
https://dev.to/loreanvictor/from-callback-hell-to-callback-heaven-4i0c
등록된 댓글이 없습니다.