최신 JavaScript에서 비동기 작업 작성
게시 됨: 2022-03-10JavaScript에는 프로그래밍 언어로서 두 가지 주요 특성이 있으며 둘 다 코드가 어떻게 작동하는지 이해하는 데 중요합니다. 첫 번째는 동기적 특성입니다. 즉, 코드가 거의 읽을 때마다 한 줄씩 실행되고 두 번째로 단일 스레드 이므로 한 번에 하나의 명령만 실행됩니다.
언어가 발전함에 따라 비동기 실행을 허용하는 새로운 아티팩트가 장면에 나타났습니다. 개발자들은 더 복잡한 알고리즘과 데이터 흐름을 해결하면서 다양한 접근 방식을 시도했고, 이로 인해 주변에 새로운 인터페이스와 패턴이 등장했습니다.
동기 실행과 관찰자 패턴
소개에서 언급했듯이 JavaScript는 대부분 한 줄씩 작성한 코드를 실행합니다. 언어는 처음 몇 년 동안 이 규칙에 대한 예외가 있었지만 HTTP 요청, DOM 이벤트 및 시간 간격과 같은 몇 가지이고 이미 알고 있을 수도 있습니다.
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
예를 들어 요소 클릭과 같은 이벤트 리스너를 추가하고 사용자가 이 상호작용을 트리거하면 JavaScript 엔진은 이벤트 리스너 콜백에 대한 작업을 대기열에 넣지만 현재 스택에 있는 작업은 계속 실행합니다. 거기에 존재하는 호출이 끝나면 이제 리스너의 콜백을 실행합니다.
이 동작은 웹 개발자를 위한 비동기 실행에 액세스하는 첫 번째 아티팩트인 네트워크 요청 및 타이머에서 발생하는 것과 유사합니다.
이것들은 JavaScript에서 일반적인 동기 실행의 예외이지만 언어가 여전히 단일 스레드이고 tak을 대기열에 넣고 비동기적으로 실행한 다음 기본 스레드로 돌아갈 수 있지만 코드의 한 부분만 실행할 수 있음을 이해하는 것이 중요합니다. 한 번에.
예를 들어 네트워크 요청을 확인해 보겠습니다.
var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();
서버가 돌아오면 onreadystatechange
에 할당된 메서드에 대한 작업이 대기열에 추가됩니다(코드 실행은 기본 스레드에서 계속됨).
참고 : JavaScript 엔진이 작업을 대기열에 넣고 실행 스레드를 처리하는 방법을 설명하는 것은 다루기 복잡한 주제이며 아마도 자체 기사를 쓸 가치가 있을 것입니다. 그래도 "도대체 이벤트 루프가 뭐야?"를 보는 것이 좋습니다. 더 나은 이해를 돕기 위해 Phillip Roberts가 작성했습니다.
언급된 각각의 경우에 우리는 외부 이벤트에 대응하고 있습니다. 특정 시간 간격, 사용자 작업 또는 서버 응답에 도달했습니다. 우리는 그 자체로 비동기 작업을 생성할 수 없었고, 항상 우리의 손이 닿지 않는 곳에서 일어나는 일들을 관찰 했습니다.
이것이 이러한 방식의 코드를 관찰자 패턴 이라고 하는 이유이며, 이 경우 addEventListener
인터페이스로 더 잘 표현됩니다. 곧 이 패턴을 노출하는 이벤트 이미터 라이브러리 또는 프레임워크가 번성했습니다.
Node.js와 이벤트 이미터
좋은 예는 페이지 자체를 "비동기 이벤트 기반 JavaScript 런타임"으로 설명하는 Node.js이므로 이벤트 이미터와 콜백은 일급 시민이었습니다. 심지어 EventEmitter
생성자가 이미 구현되어 있었습니다.
const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');
이것은 비동기식 실행을 위한 이동식 접근 방식일 뿐만 아니라 해당 생태계의 핵심 패턴 및 규칙입니다. Node.js는 웹 외부에서도 다른 환경에서 JavaScript를 작성하는 새로운 시대를 열었습니다. 결과적으로 새 디렉토리를 만들거나 파일을 쓰는 것과 같은 다른 비동기 상황이 가능했습니다.
const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })
콜백은 첫 번째 인수로 error
를 수신하고 응답 데이터가 예상되는 경우 두 번째 인수로 전달되는 것을 알 수 있습니다. 이를 오류 우선 콜백 패턴 이라고 하며, 이는 작성자와 기여자가 자체 패키지 및 라이브러리에 채택한 규칙이 되었습니다.
약속과 끝없는 콜백 체인
웹 개발이 해결해야 할 더 복잡한 문제에 직면함에 따라 더 나은 비동기 아티팩트에 대한 필요성이 나타났습니다. 마지막 코드 조각을 보면 작업 수가 증가함에 따라 잘 확장되지 않는 반복적인 콜백 체인을 볼 수 있습니다.
예를 들어 파일 읽기 및 스타일 사전 처리라는 두 단계만 추가해 보겠습니다.
const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })
우리는 우리가 작성하는 프로그램이 더 복잡해짐에 따라 다중 콜백 체인과 반복되는 오류 처리로 인해 사람의 눈으로 코드를 따라가기가 더 어려워지는 것을 볼 수 있습니다.
프라미스, 래퍼 및 체인 패턴
Promises
는 JavaScript 언어의 새로운 추가 사항으로 처음 발표되었을 때 많은 관심을 받지 못했습니다. 다른 언어가 수십 년 전에 유사한 구현을 했기 때문에 새로운 개념이 아닙니다. 사실, 그것들은 등장 이후로 내가 작업한 대부분의 프로젝트의 의미와 구조를 많이 바꾸는 것으로 밝혀졌습니다.
Promises
는 개발자가 비동기식 코드를 작성할 수 있는 내장 솔루션을 도입했을 뿐만 아니라 fetch
와 같은 웹 사양의 이후 새로운 기능의 구성 기반 역할을 하는 웹 개발의 새로운 단계를 열었습니다.
콜백 접근 방식에서 약속 기반 방식으로 메소드를 마이그레이션하는 것이 프로젝트(예: 라이브러리 및 브라우저)에서 점점 더 일반적이 되었고 Node.js조차도 천천히 마이그레이션하기 시작했습니다.
예를 들어 Node의 readFile
메서드를 래핑해 보겠습니다.
const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }
여기서 우리는 Promise 생성자 내부에서 실행하고 메서드 결과가 성공하면 resolve
를 호출하고 오류 객체가 정의되면 reject
하여 콜백을 가립니다.
메서드가 Promise
개체를 반환할 때 then
에 함수를 전달하여 성공적인 해결을 따를 수 있습니다. 해당 인수는 약속이 해결된 값(이 경우 data
)입니다.
메서드 중에 오류가 발생하면 catch
함수가 호출됩니다(있는 경우).
참고 : Promises가 어떻게 작동하는지 더 깊이 이해해야 하는 경우 Google 웹 개발 블로그에 쓴 Jake Archibald의 "JavaScript Promises: An Introduction" 기사를 추천합니다.
이제 우리는 이러한 새로운 방법을 사용하고 콜백 체인을 피할 수 있습니다.
asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))
비동기 작업을 생성하는 기본 방법과 가능한 결과를 추적하기 위한 명확한 인터페이스를 통해 업계는 옵저버 패턴에서 벗어날 수 있었습니다. 약속 기반 코드는 읽을 수 없고 오류가 발생하기 쉬운 코드를 해결하는 것처럼 보였습니다.
더 나은 구문 강조 표시 또는 더 명확한 오류 메시지는 코딩하는 동안 도움이 되므로 추론하기 쉬운 코드를 읽는 개발자는 더 쉽게 예측할 수 있으며 실행 경로에 대한 더 나은 그림을 통해 가능한 함정을 더 쉽게 포착할 수 있습니다.
Promises
채택은 커뮤니티에서 매우 광범위했기 때문에 Node.js는 fs.promises
에서 파일 작업을 가져오는 것과 같은 Promise 개체를 반환하기 위해 I/O 메서드의 내장 버전을 빠르게 릴리스했습니다.
오류 우선 콜백 패턴을 따르는 모든 함수를 래핑하고 이를 Promise 기반 함수로 변환하는 promisify
유틸리티도 제공했습니다.
그러나 Promise는 모든 경우에 도움이 됩니까?
Promises로 작성된 스타일 전처리 작업을 다시 상상해 봅시다.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))
현재 catch
에 의존하고 있기 때문에 특히 오류 처리와 관련된 코드의 중복성이 명확하게 감소했지만 Promise는 작업 연결과 직접적으로 관련된 명확한 코드 들여쓰기를 전달하는 데 어떻게든 실패했습니다.
이것은 실제로 readFile
이 호출된 후 첫 번째 then
문에서 달성됩니다. 이 줄 이후에 발생하는 일은 먼저 디렉터리를 만들고 나중에 파일에 결과를 기록할 수 있는 새 범위를 만들어야 한다는 것입니다. 이로 인해 들여쓰기 리듬 이 깨져 명령 순서를 언뜻 보기에 쉽게 결정하지 못합니다.
이 문제를 해결하는 방법은 이를 처리하고 메서드의 올바른 연결을 허용하는 사용자 지정 메서드를 미리 굽는 것입니다. 우리는 원한다.
참고 : 이것은 예제 프로그램이며 우리는 일부 방법을 제어하고 있으며 모두 업계 관습을 따르지만 항상 그런 것은 아닙니다. 더 복잡한 연결이나 다른 모양의 라이브러리 도입으로 코드 스타일이 쉽게 깨질 수 있습니다.
다행스럽게도 JavaScript 커뮤니티는 다른 언어 구문에서 다시 배웠고 비동기 작업 연결이 동기 코드만큼 유쾌하거나 간단하지 않은 경우에 많은 도움이 되는 표기법을 추가했습니다.
비동기 및 대기
Promise
는 실행 시 확인되지 않은 값으로 정의되며 Promise
의 인스턴스를 생성하는 것은 이 아티팩트의 명시적 호출입니다.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))
async 메서드 내에서 await
예약어를 사용하여 실행을 계속하기 전에 Promise
의 해결을 결정할 수 있습니다.
이 구문을 사용하여 코드 스니펫을 다시 살펴보겠습니다.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()
참고 : 오늘날에는 async 함수의 범위 밖에서 await
를 사용할 수 없기 때문에 모든 코드를 메서드로 이동해야 했습니다 .
async 메서드가 await
문을 찾을 때마다 진행 중인 값이나 약속이 해결될 때까지 실행이 중지됩니다.
async/await 표기법을 사용하면 비동기식 실행에도 불구하고 코드가 마치 동기식 인 것처럼 보입니다. 이는 우리 개발자가 보고 추론하는 데 더 익숙합니다.
오류 처리는 어떻습니까? 이를 위해 우리는 언어로 오랫동안 존재해 온 명령문을 사용합니다. try
및 catch
.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()
프로세스에서 발생한 모든 오류는 catch
문 내부의 코드에 의해 처리될 것임을 확신합니다. 우리는 오류 처리를 처리하는 중심적인 장소가 있지만 이제 더 읽기 쉽고 따를 수 있는 코드가 있습니다.
값을 반환하는 결과적인 작업을 갖는 것은 코드 리듬을 깨뜨리지 않는 mkdir
과 같은 변수에 저장할 필요가 없습니다. 또한 이후 단계에서 result
값에 액세스하기 위해 새 범위를 만들 필요가 없습니다.
Promise는 최신 브라우저와 최신 버전의 Node.js 모두에서 사용할 수 있는 JavaScript에서 async/await 표기법을 활성화하는 데 필요한 언어에 도입된 기본 아티팩트라고 말하는 것이 안전합니다.
참고 : 최근 JSConf에서 Node의 창시자이자 첫 번째 기여자인 Ryan Dahl 은 초기 개발 에서 Promise를 고수하지 않은 것을 후회 했습니다. 왜냐하면 Node의 목표는 Observer 패턴이 더 나은 이벤트 기반 서버와 파일 관리를 만드는 것이었기 때문입니다.
결론
웹 개발 세계에 Promises가 도입되면서 코드에서 작업을 대기열에 넣는 방식과 코드 실행에 대한 추론 방식, 라이브러리 및 패키지 작성 방식이 변경되었습니다.
그러나 콜백 체인에서 벗어나는 것은 해결하기 더 어렵습니다. 메서드를 전달해야 하는 것은 관찰자 패턴과 주요 공급업체에서 채택한 접근 방식에 몇 년 동안 익숙해진 then
생각의 기차에서 벗어나는 데 도움이 되지 않았다고 생각합니다. Node.js와 같은 커뮤니티에서
Nolan Lawson이 Promise 연결의 잘못된 사용에 대한 그의 훌륭한 기사에서 말했듯이 오래된 콜백 습관은 죽습니다 ! 그는 나중에 이러한 함정 중 일부를 피하는 방법을 설명합니다.
비동기 작업을 생성하는 자연스러운 방법을 허용하는 중간 단계로 Promise가 필요했지만 더 나은 코드 패턴으로 나아가는 데 많은 도움이 되지 않았습니다. 때로는 실제로 더 적응 가능하고 개선된 언어 구문이 필요합니다.
JavaScript를 사용하여 더 복잡한 퍼즐을 풀려고 할 때 더 성숙한 언어가 필요하다는 것을 알게 되었고 이전에 웹에서 본 적이 없는 아키텍처와 패턴을 실험합니다.
"
우리는 항상 웹 외부에서 JavaScript 거버넌스를 확장하고 더 복잡한 퍼즐을 풀기 위해 노력하기 때문에 ECMAScript 사양이 몇 년 후에 어떻게 보일지 모릅니다.
이러한 퍼즐 중 일부가 더 간단한 프로그램으로 바뀌기 위해 언어에서 정확히 무엇을 필요로 하는지 지금은 말하기 어렵지만 웹과 JavaScript 자체가 과제와 새로운 환경에 적응하려고 노력하면서 상황을 움직이는 방식에 만족합니다. 10년 전 내가 브라우저에서 코드를 작성하기 시작했을 때보다 지금 JavaScript가 더 비동기식 친화적인 곳 이라고 생각합니다.
추가 읽기
- "JavaScript 약속: 소개", Jake Archibald
- Bluebird 라이브러리 문서 인 "Promise Anti-Patterns"
- 놀란 로슨 "약속 문제가 있다"