비동기와 Callback, Promise, Async/await

    다음 책들을 읽으며 비동기에 대하여 정리한 내용입니다.

    • You don’t know JS - 비동기와 성능 (카일 심슨)
    • 자바스크립트는 왜 그 모양일까 (더글라스 크락포드)

    비동기

    순차적(동기적) 프로그래밍

    입출력을 블록 방식으로 처리한다.

    프로그램이 파일을 읽거나 네트워크에서 데이터를 가져오게 하려면, 데이터를 다 가져올 때까지 프로그램은 실행을 멈춘다.

    포트란 같은 언어에서는 카드 리더에서 데이터를 전부 읽어올 때까지 할 일이 없었기 때문에 이 방식이 합리적이었다. 오늘날 대부분의 프로그래밍 언어 역시 포트란의 I/O 모델을 구현하여 사용하고 있다.

    동기적인 코드는 현재 실행 중인 코드가 완료된 후에야 다음 코드를 실행하는 방식이다.

    비동기 프로그래밍

    자바스크립트는 탄생 목적 자체가 사용자와의 상호작용 이었기 때문에 더 나은 모델인 비동기 프로그래밍을 따른다. 즉, 다른 언어보다 순차적 모델으로부터 영향을 덜 받는다.

    다른 컴퓨터와 상호 작용하면서 동시에 여러 가지 일을 할 수 있는 동시성 프로그래밍(concurrent) 이 필요해졌다. 서로 다른 유형의 동시성은 각각 서로 다른 책임을 지는 특별한 프로세스들의 협업을 가능하게 한다. 각 프로세스가 멍청하게 동작하면, 그 대가는 치명적이다.

    프로그램에서 지금 에 해당하는 부분과 나중 에 해당하는 부분 사이의 관계(간극)가 바로 비동기 프로그래밍의 핵심이다. 프로그램의 어느 부분은 지금 실행되고, 다른 부분은 나중에 실행되면서 발생하는 프로그램이 실제로 작동하지 않는 지금나중 사이의 간극에 관한 문제다.

    • 이러한 간극의 유형으로는 사용자 입력 대기, DB/파일 시스템의 정보 조회, 네트워크를 경유한 데이터 송신 후 응답 대기, 일정 시간 동안 반복적인 작업(애니메이션 등) 수행이 있다.

    비동기적인 코드는 현재 실행 중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어간다.

    • 나중지금 의 직후가 아니다.

      • 지금 당장 끝낼 수 없는 작업은 비동기적으로 처리되므로, 프로그램을 중단 (blocking)하지 않는다.
      • 지금 부터 나중 까지 기다리는 가장 간단한 방법은 콜백함수 를 이용하는 것이다.

    동기적인 AJAX 요청은 왜 하면 안되는가?

    • 기술적으로는 가능하나, 브라우저 UI를 얼어붙게 할 뿐 아니라, 사용자와의 상호 작용이 완전히 마비될 수 있으므로 해서는 안 된다.

    병렬 스레딩(Parallel Threading)

    • 병렬은 동시에 일어나는 일들과 연관되며, 병렬 스레딩 은 오래된 동시성 기법 중 하나이다.

      • 스레드는 실제 혹은 가상의 CPU 로서, 스레드들은 하나의 프로세스 메모리를 공유하며 동시에 실행된다.

    정확히 똑같은 시점에 두 스레드가 실행되면 어떤 일들이 벌어질까?

    여러 스레드로 인한 동시성 기법은 읽기-수정-쓰기코드 경쟁을 보여주므로, 제멋대로인 결과값을 보여주며 코드가 제대로 작동하지 않을 수도 있다. - 인터럽션/ 인터리빙

    • 이러한 문제는 무작위성에 영향을 받아 에러가 발생하는 상황을 재현하는 것 자체가 거의 불가능할 수 있다.(타입검사x, 테스트x)

    여러 스레드 간 경쟁으로 발생할 수 있는 위험성은 상호 배제(mutual exclusion) 로 줄일 수 있다.

    상호 배제는 메모리의 임계 구역을 잠그고, 스레드를 차단하고, 서로 경쟁하는 코드 실행을 막는 것으로 이루어진다.

    • 다만, 이러한 작업 또한 비용이 비싸고 데드락 같은 문제가 발생하면 막기도, 재현하기도, 고치기도 힘들다.

      • 한 스레드에 예외가 발생하면 그 스레드의 스택을 되감는다. 그러면 그 스레드의 상태는 연관된 다른 스레드의 상태들과 일치하지 않게 되고, 연달아 다른 스레드에 문제를 일으킬 수도 있다. 이 때문에, 예외와 실패가 발생하면 동작이 멈춘다.

    싱글 스레드인 자바스크립트

    반면, 자바스크립트와 같은 싱글 스레드 환경에서는 스레드 간섭이 일어나지 않으므로 스레드 큐에 저수준 작업의 원소가 쌓여 있어도 별문제 없다

    자바스크립트는 싱글 스레드이므로 비결정성의 수준은 문제가 되지 않는다. 그러나 비동기이므로 항상 결정적인 것도 아니다.

    자바스크립트는 싱글 스레드로서 한 번에 하나의 이벤트만 처리하므로, 여러 이벤트가 정확히 같은 시각에 실행되는 일은 결코 있을 수 없다.

    • 잡 큐(Job Queue)

      • ES6부터 이벤트 루프 큐에 새롭게 도입된 개념이다. 잡 큐 에 추가되는 이벤트는 나중에 처리할 작업이지만, 다른 어떤 작업들보다 우선하여 바로 처리하게 된다.
      • 즉, 이벤트 루프 큐 는 롤러코스터 타고 난 후 한 번 더 타려고 할 때 대기열 제일 뒤에서 기다리는 것이고, 잡 큐 는 다시 탈 때 대기열 제일 앞에서 곧바로 다시 타는 것과 같다.

    시스템 수준의 언어인지, 애플리케이션 수준의 언어인지 정하고 언어가 설계됐어야 한다

    • 자바 등 비슷한 다른 많은 언어는 시스템 수준애플리케이션 수준 의 언어를 모두 가정하고 애플리케이션에서 스레드 를 사용하도록 했다는 점이 문제이다.(스레드들로 인한 위와 같은 문제점이 있음)
    • 다만, 운영체제 에서는 스레드 가 필요악이다.
    • 그러나 애플리케이션 에서는 스레드들은 불필요하고 그냥 악이다.

    자바스크립트는 동시성 구현 시 이런 식으로 스레드를 쓰진 않고 더 나은 방법을 사용한다

    비동기 방식(eventual function, asynchronous function)스레드를 쓰지 않고도 많은 일을 처리할 수 있게 해준다.

    • 어떻게?

      • 콜백 함수(=이벤트 핸들러)와 프로세싱 루프 (=이벤트 루프, 메시지 루프) 를 통해서
    • 콜백 함수는 기대하는 일이 향후에 일어날 때 호출되는 함수이다.

      • 콜백 함수는 시작하거나 특정 활동을 지켜보는 함수에 인자로 전달된다.
    • 프로세싱 루프 (=이벤트 루프, 메시지 루프)

      • 이벤트 루프는 큐에서 가장 높은 우선순위를 가지는 이벤트 혹은 메시지를 가져와서 해당 이벤트나 메시지를 처리하도록 등록된 콜백 함수를 호출해준다. 그리고 콜백 함수가 작업을 완료하면 반환한다.
      • 따라서 콜백 함수는 메모리 잠금이나 상호 배제가 필요 없다. 또한 방해받지 않으므로 경쟁이 일어날 일도 없다(← 멀티 스레드의 문제점이 없다는 뜻)
      • 자바스크립트 프로그램과 메인 스레드 간에 통신하는 방법이 바로 가 된다. 상호 배제가 이 한 가지 접점( ) 에서만 사용되므로 비동기 시스템을 일반적으로 더 효율적이고 신뢰할 수 있다.
    • 자바스크립트는 이러한 모델을 통해, 아주 탄력적 (에러가 발생해도 계속 작동한다는 뜻인 듯) 일 수 있다.

      • 웹 애플리케이션은 많은 상호작용 또는 개발자의 미숙함으로 인해 예외와 실패가 자주 발생할 수 있는데, 스레드 상태 값이 대부분 스택이 아닌 해당 함수의 클로저에 저장되어 있다. 그래서 프로그램에서 예외와 실패가 일어날지라도 동작이 계속 진행될 수 있다.

    자바스크립트에는 사실 비동기란 개념이 없다

    자바스크립트 엔진은 (호스팅 환경, 즉 브라우저가) 요청하면 프로그램을 주어진 시점에 한 덩이씩 묵묵히 실행할 뿐이다.

    • 자바스크립트 엔진은 혼자서는 안 되고 반드시 호스팅 환경(브라우저, Node.js 등) 에서 실행된다.

      • 호스팅 환경의 이벤트 루프 는 여러 프로그램 덩이를 시간에 따라 매 순간 한 번씩 실행시킨다.
      • 호스팅 환경은 달라도 자바스크립트 엔진의 스레드(싱글 스레드) 는 공통이다.
    • 즉, 자바스크립트 엔진은 시간이란 관념은 없고, 임의의 코드 조각을 시시각각 받아 처리하는 실행기(싱글 스레드에서)일 뿐이다. 이벤트 를 스케줄링 하는 일은 엔진을 감싸는 호스팅 환경의 몫이다.
    • 비동기 모델의 규칙 - 턴(turn)의 법칙

      • “기다리지 말라. 블록하지 말라. 빨리 끝내라” - 함수는 절대 메인 스레드를 블록해서는 안 된다.
      • 턴의 법칙을 위반하면, 높은 성능의 비동기 시스템이 아주 낮은 성능을 보일 수 있다.
      • 턴의 법칙을 위반하는 함수는 수정되거나 별도의 프로세스로 격리되어야만 한다.
    • 프로세스는 스레드와 비슷하지만 메모리를 공유하지 않는다.

      • 따라서 콜백 함수를 다른 프로세스로 격리해서 따로 작업을 하게 만들고, 작업이 끝나면 해당 프로세스가 메시지를 보내도록 하는 것도 좋은 방법이다. (결국 비동기 가능)
      • (참고) 자바스크립트는 프로세스에 대해서 알지 못한다. 프로세스는 자바스크립트가 동작하는 시스템(브라우저 등)에서 제공되는 서비스이다.

    Callback

    콜백 함수는 자바스크립트에서 비동기성을 표현하고 관리하는 가장 기본적인 비동기 패턴이다

    • 콜백 함수는 프로그램의 연속성을 Wrapping 또는 Encapsulation한 장치다.
    • 콜백 함수는 다른 코드(함수 또는 메서드)에게 인자로 넘겨줌으로써 그 제어권도 함께 위임한 함수이다.

      • 콜백 함수를 위임받은 코드는 자체적인 내부 로직에 의해 이 콜백 함수를 적절한 시점에 실행할 것이다.

    그런데 콜백 함수 이후 왜 여러 비동기 패턴이 등장했는가?

    콜백의 단점 1. 다수의 콜백은 인간에게 부자연스럽다

    콜백은 비동기 흐름을 비선형적, 비순차적 인 방향으로 나타내나, 인간의 두뇌는 선형적 , 순차적 , 중단적 인 방식으로 생각한다는 차이가 있어, 다수의 콜백은 인간에게 부자연스럽다.

    추가 설명하자면, 사람은 주어진 순간에 한 가지만 생각할 수 있다. 사람의 두뇌는 순차적 으로 처리하므로 마치 싱글 스레드 방식의 이벤트 루프 큐처럼 작동한다(자바스크립트 엔진처럼).

    • 따라서 다수의 콜백이 사용된 비동기 흐름은 인간의 두뇌로서는 이해하기 어려워, 관리하기 힘들다. 이처럼 추론하기 곤란한 코드는 악성 버그를 품은 나쁜 코드가 될 수 있다.

    콜백의 단점 2. 콜백의 더 큰 문제는 ‘제어의 역전’ 으로 인한 ‘믿음성 문제’이다

    콜백을 넘기는 AJAX()는 개발자가 작성하거나 직접 제어할 수 있는 함수가 아니라 서드파티가 제공한 유틸리티인 경우가 대부분이다. 내가 작성한 프로그램인데도 실행 흐름은 서드 파티에 의존해야 하는 이런 상황을 제어의 역전이라 한다.

    이렇게 무턱대고 믿으면, 콜백을 넘겨받은 서드파티에서 콜백 호출 시 오류가 날만한 믿음성 문제는 다음과 같이 많다.(서드파티라서 개발자가 직접 제어하기 어려울 수 있고, 해결하더라도 비대해진 관용 코드가 전체 프로젝트를 짓누를 수 있다)

    • (추적이 끝나기도 전에) 콜백을 너무 일찍 부른다.
    • (아예 호출하지 않거나) 콜백을 너무 늦게 부른다.
    • 콜백을 너무 적게 또는 너무 많이 부른다.
    • 필요한 환경/인자를 정상적으로 콜백에 전달하지 못한다.
    • 일어날지 모를 에러/예외를 무시한다.

    이러한 콜백의 문제점으로 인해 더욱 정교하고 역량 있는 비동기 패턴(프로미스 등)이 절실했다.

    // 아래 코드의 함수들이 모두 비동기 코드가 아니라면
    
    doA(function() {
    	doB()
    
    	doC(function() {
    		doD()
    	})
    
    	doE()
    })
    
    doF()
    
    // A->B->C->D->E->F 처럼 순차적으로 실행된다.
    // 그런데 아래 코드의 함수 중 doA와 doC가 비동기 코드라면
    
    doA(function() {
    	doB()
    
    	doC(function() {
    		doD()
    	})
    
    	doE()
    })
    
    doF()
    
    // A->F->B->C->E->D 처럼 실행되는데, 흐름이 뒤섞여 코드를 따라가는 것이 불편하다.

    프로미스

    콜백에서 일어났던 제어의 역전을 ‘되역전’ (=제어권을 호출부로 되돌려놓는 것)시키는 방법이 프로미스이다

    • 즉, 프로그램의 진행을 다른 파트에 넘겨주지 않고도 개발자가 언제 작업이 끝날지 알 수 있고, 그 다음에 무슨 일을 해야 할지 스스로 결정할 수 있는 방법이다. 이러한 체계가 프로미스이다.
    • 자바스크립트/DOM 플랫폼에 추가된 최신 비동기 API 대부분이 모두 프로미스에 기반을 두고 개발됐다.

    프로미스는 약속과 같다. 그리고 그 약속의 미랫값은 ‘성공’ 아니면 ‘실패’이다

    • 즉, 프로미스는 Fulfillment(이룸) 아니면 Rejection(실패) 으로 Resolve(귀결) 된다.
    • then(이룸 함수, 버림 함수) 함수는 이룸 함수를 첫 번째 인자로, 버림 함수를 두 번째 인자로 각각 넘겨 받는다.
    • new Promise() 생성자의 매개변수에 전달되는 함수는 resolve(이룸 함수), reject(버림 함수)라고 이름 붙인 인자 2개를 받는다.

      • 그런데 첫 번째 인자는 이룸 전용이므로, resolve라는 용어보다는 fulfill이 의미에 더 적합하다고 보인다. resolve(귀결)이라는 용어는 명확하게 “결과는 이룸 아니면 버림이다” 는 맥락으로 쓰일 때 그 의미가 더 분명하기 대문이다. 이러한 맥락에서 then() 에 제공되는 콜백들이 ES6에서 onFulfilled() , enRejected() 라고 명명됐는데 이는 아주 적확한 용어다.

    지금과 나중을 모두 일관적으로 다루려면, 둘 다 ‘나중’으로 만들어 모든 작업을 ‘비동기화’하면 된다는 아이디어에서 시작한다

    프로미스는 시간 의존적인 상태를 외부로부터 캡슐화하기 때문에 프로미스 자체는 시간 독립적 이고, 그래서 타이밍 또는 내부 결괏값에 상관없이 예측 가능한 방향으로 구성할 수 있다.

    • 즉, 프로미스는 미랫값을 캡슐화하고 조합할 수 있게 해주는 손쉬운 반복 장치다.

    Thenable 덕 타이핑 - 어떤 값이 진짜 프로미스인지 알 수 있을까?

    • new Promise() 구문으로 생성된 프로미스는 p instanceof Promise 로 확인하면 될 것 같다.

      • 그러나 이것만으로는 부족하다. 외부 라이브러리 중에는 ES6의 Promise가 아닌 고유한 방식으로 구현한 프로미스를 사용할 수도 있다.
    • 진짜 프로미스는 then() 메서드를 가진, Thenable 객체 또는 함수를 정의하여 판별하는 것으로 규정됐다. 즉, Thenable에 해당하는 값은 무조건 프로미스 규격에 맞다고 간주하는 것이다.

      • 이처럼 어떤 값의 타입을 그 형태를 보고 짐작하는 타입 체크를 덕 타이핑이라 한다.
    • 이러한 방식이 유용할 때도 있지만, 프로미스가 아닌 객체를 프로미스로 오판할 경우 Thenable 덕 타이핑은 독이 될 수 있음을 유의히자.
    if (
    	p !== null &&
    	(typeof p === "object" || typeof p === "function") &&
    	typeof p.then === "function"
    ) {
    	// Thenable이다 -> 프로미스로 간주한다.
    } else {
    	// Thenable이 아니다 -> 프로미스가 아니다.
    }

    프로미스는 콜백의 믿음성 문제를 해결할 수 있다 - 연쇄 흐름

    프로미스 시퀀스가 각 단계마다 진짜 비동기적으로 작동하게 만드는 핵심은 Promise.resolve()에 넘긴 값이 어떤 최종값이 아닌 Promise/Thenable일 때 Promise.resolve()의 작동 로직이다.

    • Promise.resolve()는 진짜 프로미스를 받으면 도로 뱉어내며, Thenable을 받으면 일단 한번 풀어보고 아니면 원하는 값이 나올 때까지(resolve될 때까지?) 재귀적으로 계속 풀어본다.

      • 프로미스의 귀결(resolve)은 그다음 단계로의 진행을 신호한다고 볼 수 있다.
      • 이런 방식으로 원하는 개수만큼 비동기 단계로 구성된 시퀀스를 만들어, 필요하다면 각 단계별로 그다음 단계로 진행을 미룰 수 있다.
    const p = Promise.resolve(21)
    
    p.then(v => {
    	console.log(v) // 21
    
    	return new Promise((resolve, reject) => {
    		setTimeout(() => {
    			resolve(v * 2) // 결과값이 '42'로 resolve된다.
    		}, 1000)
    	})
    }).then(v => {
    	console.log(v) // 42 (이전 단계에서 1초 후 실행된다)
    })

    프로미스의 에러 처리 문제점 - 에러가 조용히 묻히기 쉬운 구조다

    try…catch 는 동기적으로만 사용 가능하므로 프로미스와 같은 비동기 코드 패턴에서는 무용지물이다.

    • 프로미스는 에러 처리 패턴으로 분산-콜백 스타일을 통해 이룸/버림 각각의 콜백을 지정하여 에러를 처리한다. 그러나 이런 방식으로 에러를 잡지 못하는 문제가 있다.
    const p = Promise.resolve(42)
    
    p.then(
    	function fulfilled(msg) {
    		console.log(msg.toLowerCase()) // 문법오류가 발생한다.
    	},
    	function rejected(err) {
    		console.log("에러", err)
    		// 그러나 위에서 발생한 에러에 대한 처리가 실행되지 않는다.
    	}
    )

    왜냐하면 위 코드에서 에러 처리기(rejected)의 소속은 프로미스(p)이고, 이미 p는 42 값으로 귀결(resolve)된 상태이기 때문이다. 귀결된 p는 불변값이므로 에러 알림은 오직 p.then()이 반환한 프로미스만이 가능한데, p.then() 내부에서는 p.then()이 반환한 프로미스를 포착할 방법이 없다. 이처럼 에러가 조용히 묻혀버리기 너무 쉬운 구조다.

    • 에러가 묻히지 않도록, 프로미스 연쇄 끝에 catch()를 쓰는 방법이 있다. 그러나 여기서 문제점은 catch의 에러 핸들링 함수에서 에러가 난다면 이 에러를 잡기 위해, 그 catch뒤에 또 catch를 하나 더 붙여야 하는데, 이런 방식이면 catch를 계속 붙여야 한다는 문제가 있다. catch()가 반환한 프로미스에 대한 처리를 어떻게 해야 하는가?
    const p = Promise.resolve(42)
    
    p.then(
    	function fulfilled(msg) {
    		console.log(msg.toLowerCase()) // 문법오류가 발생한다.
    	},
    	function rejected(err) {
    		console.log("에러", err)
    		// 그러나 위에서 발생한 에러에 대한 처리가 실행되지 않는다.
    	}
    ).catch(err => {
    	console.log("catch에서 에러 잡음", err)
    })

    폐기된 프로미스는 어떻게 될까?

    폐기된 프로미스는 취소가 안 되고 외부적인 불변성에 관한 믿음을 유지하기 위해 그냥 조용히 묻힌다.

    • 폐기된 프로미스에서 예약된 자원을 다시 놓아주거나 이 자원에 있을지 모를 사이드 이펙트를 취소할 방법으로서 finally() 를 사용하여 cleanup할 수 있다.

    Promise 문법

    • new Promise() 생성자

      • 이 생성자는 항상 new와 함께 사용하며 동기적으로(즉시) 호출할 콜백 함수를 전달해야 한다.
      • 이 함수에는 귀결 처리할 콜백 2개(resolve(), reject())를 넘긴다.
    • Promise.resolve()

      • 이미 이루어진 프로미스를 생성한다. Thenable 값을 재귀적으로 풀어보고 그 최종 귀결 값이 마침내 반환되는 프로미스에 해당한다.
      • Promise.resolve()에 진짜 프로미스를 넣으면 아무일도 하지 않는다(즉, 오버헤드가 전혀 없다).
    • Promise.reject()

      • 이미 버려진 프로미스를 생성한다. Promise.resolve()와 본질적으로 동등하다.
    • then()

      • 이룸 콜백과 버림 콜백이라는 2개의 인자를 받는다. 새 프로미스를 만들어 반환한다.
    • catch()

      • 버림 콜백 하나만 받는다. 새 프로미스를 만들어 반환한다.
    • Promise.all([])

      • 2개 이상의 단계가 동시에(병렬로) 진행되는 패턴이다. 여기서 반환된 메인 프로미스는 자신의 하위 프로미스들이 모두 fulfil되어야 fulfil될 수 있다. 단 한 개의 프로미스라도 reject되면 다른 프로미스 결과도 무효가 된다.
    • Promise.race([])

      • 하나라도 fulfil 프로미스 있을 경우 fulfil되며, 하나라도 reject되는 프로미스가 있으면 reject된다.

    Async/Await

    기존의 비동기 처리 방식인 Callback 함수의 단점을 보완하기 위해 Promise를 사용했지만, 코드가 장황해지는 단점이 있었다.

    • Promise를 더욱 쉽게 사용할 수 있도록 ES2017에서 도입된 비동기 처리 방식이 async & await며, 이를 사용할 경우 코드가 간결해진다.
    • async & await는 에러처리 시 try catch를 사용할 수 있다.
    async function 함수명() {
    	await 비동기_처리_메서드_명()
    }

    참고

    • You don’t know JS - 비동기와 성능 (카일 심슨)
    • 자바스크립트는 왜 그 모양일까 (더글라스 크락포드)

    Written by@Marco

    GitHub