본문 바로가기

JavaScript

자바스크립트 완벽 가이드 | 비동기 자바스크립트

과학 시뮬레이션과 머신 러닝 모델 같은 컴퓨터 프로그램은 계산이 목적이기 때문에 결과를 얻을 때까지 멈추지 않고 실행된다. 그 밖의 프로그램은 배부분 비동기적으로 실행된다. 

 

비동기적이라는 말은 데이터가 들어오거나 어떤 이벤트가 일어날 때까지 계산을 멈추고 대기하는 일이 잦다는 뜻이다. 웹 브라우저의 자바스크립트 프로그램은 일반적으로 이벤트 주도적이다.  즉, 프로그램이 뭔가 실행하기 전에 사용자가 뭔가 클릭하거나 탭하기를 기다린다. 자바스크립트를 활용하면 이런 비동기 프로그래밍이 필요할 때가 많다. 

 

ES6에서 도입한 프라미스는 비동기 동작의 아직 사용할 수 없는 결과를 나타내는 객체이다.

 

키워드 async와 await는 ES2017에 도입했는데, 프라미스 기반 코드를 마치 동기적인 코드처럼 작성할 수 있게 해서 비동기 프로그래밍을 단순화하는 새 문법을 제공한다. 마지막으로 ES2018에서는 비동기 이터레이터와 for/await 루프를 도입해서 동기적인 것처럼 보이는 단순한 루프에서 비동기 이벤트 스트림을 다룰 수 있게한다.

 

자바스크립트에서 비동기 코드를 다루는 강력한 기능을 제공하면서도 정작 그 코어에는 비동기적인 부분이 전혀 없다는 점은 아이러니하다. 따라서 프라미스, async, await, for/await을 이해하기 위해서는 먼저 클라이언트 사이드, 서버 사이드 자바스크립트를 보면서 웹 브라우저와 노드의 비동기적 기능에 대해 이해해야한다.

 

콜백과 비동기 프로그래밍

자바스크립트에서 가장 기본적인 비동기 프로그래밍은 콜백을 통해 이루어진다. 콜백은 다른 함수에 전달하는 함수이다. 콜백을 전달받은 함수는 어떤 조건을 만족하거나 어떤 (비동기)이벤트가 일어나면 여러분이 제공한 함수를 호출(콜백)한다. 전달한 콜백함수를 호출할 때는 조건이나 이벤트에 대한 정보를 제공하며 때때로 함수 인자를 통해 세부 사항을 추가로 제공하기도 한다.

 

클라이언트 사이드 자바스크립트 프로그램은 거의 대부분 이벤트 주도적이다. 이들은 미리 지정된 계산을 실행하기보다는 사용자가 뭔가 하기를 기다렸다가 그 행동에 반응한다. 웹 브라우저는 사용자가 키를 누르고, 마우스를 움직이고, 마우스 버튼을 클릭하고, 터치 스크린을 터치할 때 이벤트를 일으킨다. 이벤트 주도 자바스크립트 프로그램은 지정된 컨텍스트에 지정된 타입의 이벤트를 처리할 콜백 함수를 등록하고, 웹 브라우저는 지정된 이벤트가 일어날 때마다 함수를 호출한다. 이런 콜백함수를 이벤트 핸들러, 이벤트 리스너라고 부르며 addEventListener()를 통해 등록한다. 

 

네트워크 요청 역시 자바스크립트 프로그래밍의 대표적인 비동기 유형중 하나이다. 브라우저에서 실행되는 다음과 같은 코드로 웹 서버에서 데이터를 가져올 수 있다.

 

function getCurrentVersionNumber(versionCallback) {
  let request = new XMLHttpRequest()
  request.open("GET", "https://example.com/version.json")
  request.send()

  request.onload = function () {
    if (request.status === 200) {
      // HTTP 상태가 OK이라면 버전 번호를 가져와서 콜백을 호출한다.
      let currentVersion = parseFloat(request.responseText)
      versionCallback(null, currentVersion)
    } else {
      versionCallback(this.response.statusText, null)
    }
  }
  // 네트워크 에러가 생겼을 때 호출할 다른 콜백을 등록합니다.
  request.onerror = request.ontimeout = function (e) {
    versionCallback(e.type, null)
  }
}

 

클라이언트 사이드 자바스크립트 코드는 XMLHttpRequest 클래스와 콜백 함수를 사용해 HTTP 요청을 보내고 서버의 응답을 비동기적으로 처리할 수 있습니다.  위에서 정의한 getCurrentVersionNumber() 함수는 HTTP 요청을 보내고, 서버의 응답을 받거나 타임아웃 또는 기타 에러로 요청이 실패했을 때 호출할 이벤트 핸들러를 등록합니다.

 

서버사이드 자바스크립트 환경인 노드는 비동기적으로 만들어져 있으며 많은 API가 콜백과 이벤트를 사용합니다. 예를 들어 파일 콘텐츠를 읽는 기본 API도 비동기적이며 파일 콘텐츠를 읽으면 콜백 함수를 호출합니다. 

 

const fs = require("fs")
let options = {}

fs.readFile("config.json", "utf-8", (err, text) => {
  if (err) {
    console.warn("Could not read config file:", err)
  } else {
    Object.assign(options, JSON.parse(text))
  }

  startProgram(options)
})

 

노드의 fs.readFile() 함수는 매개변수를 두개 받는 콜백을 마지막 인자로 받습니다. fs.readFile()은 지정된 파일을 비동기적으로 읽고 콜백을 호출합니다. 파일을 성공적으로 읽었다면 파일 콘텐츠를 두 번째 콜백 인자로 전달합니다. 

 

노드에는 이벤트 기반 API도 다양합니다. 다음 함수는 노드에서 URL에 HTTP 요청을 보내는 방법입니다. 이 함수에는 이벤트 리스너로 처리하는 비동기 코드 계층이 두개 있습니다. 노드는 addEventListener() 대신 on() 메서드를 사용해 이벤트를 등록합니다. 

 

const https = require("https")
// URL의 텍스트 콘텐츠를 읽고 비동기적으로 콜백에 전달합니다.
function getText(url, callback) {
  // URL에 HTTP GET 요청을 시작합니다.
  request = https.get(url)
  // 응답 이벤트를 처리할 함수를 등록합니다.
  request.on("response", (response) => {
    // 응답 이벤트가 있다는 것은 응답 헤더를 받았다는 의미입니다.
    let httpStatus = response.statusCode
    // HTTP 응답의 바디는 아직 받지 못했으므로
    // 바디를 받았을 때 호출할 이벤트 핸들러를 등록합니다.
    response.setEncoding("utf-8")
    let body = ""

    // 바디의 텍스트 덩어리를 사용할 수 있게 되면 이 이벤트 핸들러를 호출합니다.
    response.on("data", (chunk) => {
      body += chunk
    })

    // 응답이 완료되면 이 이벤트 핸들러를 호출합니다.
    response.on("end", () => {
      if (httpStatus == 200) {
        callback(null, body)
      } else {
        callback(httpStatus, null)
      }
    })
  })
  // 저수준 네트워크 에러를 처리할 이벤트 핸들러도 등록합니다.
  request.on("error", (err) => {
    callback(err, null)
  })
}

 

프라미스

클라이언트 사이드와 서버 사이드 자바스크립트 환경의 콜백과 이벤트 기반 비동기 프로그래밍을 예제로 살펴봤으니 이제 비동기 프로그래밍을 단순화하도록 설계된 코어 기능인 프라미스를 소개할 차례입니다.

 

프라미스는 비동기 작업의 결과를 나타내는 객체입니다. 결과가 준비됐을 수도 있고 준비되지 않았을 수도 있는데, 프라미스는 API를 이를 의도적으로 막연하게 표현합니다. 프라미스의 값을 동기적으로 가져올 수 있는 방법은 존재하지 않습니다. 값이 준비됐을 때 콜백함수를 호출하도록 프라미스에 요청할 수 있을 뿐 입니다. 앞의 getText() 함수와 같은 비동기 API를 프라미스 기반으로 만들면서 콜백인자를 생략하고 대신 프라미스 객체를 반환하도록 만들고 싶다고 합시다. 호출자가 이 프라미스 객체에 콜백을 등록하면 콜백은 비동기 작업이 끝났을 때 호출됩니다. 

 

따라서 프라미스를 아주 단순하게 말한다면 콜백을 사용하는 새로운 방법이라고 할 수 있습니다.

 

콜백 기반 비동기 프로그래밍의 심각한 문제는 콜백 안에 콜백이, 그 안에 또 콜백이 이어지는 형태가 계속되면 너무 심하게 들여쓰기 되어 코드를 읽기 어려워지는, 소위 콜백 헬이 발생한다는 것입니다. 프라미스는 이런 중첩된 콜백을 좀 더 선형에 가까운 프라미스 체인으로 바꿔주므로 읽기 쉽고 이해하기 쉽습니다. 콜백의 다른 문제는 에러 처리가 어렵다는 점입니다. 비동기 함수 (또는 비동기적으로 호출된 콜백)에서 예외가 일어나면 이 예외를 비동기 동작의 최초 실행자에 전달할 방법이 없습니다. 이것은 비동기 프로그래밍의 기본적인 문제이며 예외 처리를 어렵게 만듭니다. 콜백 인자와 반환값을 통해 에러를 세심하게 추적하고 전달할 수 있긴 하지만, 번거로운 일이고 제대로 하기도 어렵습니다. 프라미스는 에러를 처리하는 방법을 표준화하고 프라미스 체인을 통해 에러를 정확히 전달하는 방법을 제공합니다.

 

프라미스는 비동기 작업하나가 앞으로 어떤 결과를 보일지 나타낸다. 반복되는 비동기 작업을 나타낼 수는 없다. 프라미스로 setTimeout을 대신할 수는 있지만 setInterval()함수를 대신할 수는 없다. 마찬가지로 콜백이 한번만 호출되는 XML-HttpRequest객체의 load 이벤트 핸들러는 프라미스로 대신할 수 있지만 Html 버튼의 클릭이벤트 핸들러에는 일반적으로 프라미스를 사용하지 않는다. 사용자가 한번 클릭하면 그 이후에는 대응할 수 없기 때문이다. 

 

자바스크립트 코어에 프라미스가 포함되면서 웹 브라우저에서 프라미스 기반 API를 지원하기 시작했다. 앞에서 비동기 HTTP 요청을 보내고 HTTP 응답 바디를 지정된 콜백 함수에 문자열로 전달하는 getText() 함수를 만들었다.  이 함수를 변형해서 JSON인 HTTP 응답 바디를 분석하고 콜백 인자를 받는 대신 프라미스를 반환하는 getJSON() 함수를 만든다고 합시다. 

 

getJSON(url).then(jsonData => {
	// JSON 값을 받아 분석하면 비동기적으로 호출될 콜백함수입니다.
});

 

getJSON은 URL에 비동기 HTTP 요청을 보내고 응답을 대기하면서 프라미스 객체를 반환한다. 프라미스 객체에는 then() 인스턴스 메서드가 있다. 콜백 함수를 getJSON에 직접 전달하지 않고 then() 메서드에 전달한다. HTTP 응답이 도착하면 응답 바디를 JSON으로 분석하고 분석된 값을 then()에 전달한 함수에 전달합니다.

 

then() 메서드는 클라이언트 사이드 자바스크립트에서 이벤트 핸들러를 등록할 때 사용하는 addEventListener() 메서드와 비슷한 콜백 등록메서드라고 생각해도 된다. 프라미스 객체에서 then() 메서드를 여러번 호출하면 각 콜백은 비동기 작업이 완료될 때 호출된다. 하지만 대부분의 이벤트 리스너와 달리 프라미스는 단 한 가지 작업일 뿐이며 then()에 등록된 각 함수는 단 한번만 호출됩니다. 설령 then()을 호출할 때 비동기 작업이 이미 완료된 상태라고 하더라도 then()에 전달된 함수는 비동기적으로 호출됩니다. 

 

단순한 문법적 측면에서 본다면 then() 메서드는 프라미스의 독특한 특징입니다. 프라미스 객체를 변수에 할당하는 중간 단계를 거치지 않고 프라미스를 반환하는 함수 호출에 .then()을 직접 이어 붙이는 형태로 주로 사용합니다. 프라미스를 반환하는 함수, 프라미스 결과를 사용하는 함수 모두 이름을 동사형태로 짓는 관행이 있으며 이를 따르면 코드를 읽기가 매우 쉬워집니다. 

 

// 사용자의 프로필을 표시하는 함수
function displayUserProfile(profile) { // 생략 }
// 위 함수를 프라미스와 함께 사용하는 방법입니다. 이 코드는 영어 문장과 거의 비슷해 보입니다.
getJSON("/api/user/profile").then(displayUserProfile)

 

비동기 작업, 특히 네트워크와 관련된 작업은 매우 다양한 원인으로 실패하기 때문에 불가피하게 발생할 에러를 처리하려면 코드를 빈틈없이 작성해야 합니다. 프라미스에서는 then() 메서드에 두 번째 함수를 전달해 에러를 처리할 수 있습니다. 

 

getJSON("/api/user/profile").then(displayUserProfile, handleProfileError)

 

프라미스는 프라미스 객체가 생성된 이후에 일어날 비동기 작업의 결과를 나타냅니다. 프라미스 객체가 반환된 후에 동작이 이루어지므로 이 동작이 값을 반환할지 아니면, 캐치할 수 있는 예외를 일으킬지 미리 알 수는 없습니다. then()에 전달하는 함수가 대안을 제시합니다. 동기적 작업은 정상적으로 수행하는 경우 호출자에게 결과를 반환합니다. 프라미스 기반 비동기 작업은 정상적으로 완료되면 then()의 첫번째 인자인 함수에 그 결과를 전달합니다.

 

동기적 작업에서는 뭔가 잘못되면, 처리할  catch 절을 만날 때까지 콜 스택을 거슬러 올라가서 전달될 예외를 일으킵니다. 비동기 작업에서는 호출자가 스택에 존재하지 않으므로 호출자에게 예외를 전달할 간단한 방법은 존재하지 않습니다. 

 

프라미스 기반 비동기 작업은 예외를 then()의 두번째 인자인 함수에 전달합니다.(예외는 일반적으로 예외 객체의 일종이지만 꼭 그래야하는 것은 아닙니다.)  앞의 예제의 getJSON()이 정상적으로 실행됐다면 그 결과는 displayUserProfile()에 전달됩니다. 사용자가 로그인하지 않았거나, 서버가 다운됐거나, 사용자의 인터넷 연결이 끊겼거나, 요청이 타임아웃에 걸렸거나, 기타 이유로 에러가 일어나면 getJSON()은 Error 객체를 handleProfileError()에 전달합니다. 현실적으로 then()에는 두가지 함수를 전달하는 경우는 별로 없습니다. 프라미스를 사용할 때는 더 널리 쓰이는, 더 좋은 에러 처리 방법이 있습니다. 이를 이해하려면 먼저 getJSON()은 정상적으로 완료됐지만 displayUserProfile()에서 에러가 일어날 때 어떤 일이 벌어질지 생각해 봐야 합니다. 콜백함수는 getJSON()이 완료될 때 비동기적으로 호출되므로 예외를 일으켜도 별 의미가 없습니다. 예외를 처리할 코드가 콜 스택에 존재하지 않기 때문입니다. 

 

다음과 같이 에러 처리 코드를 만드는 경우가 더 흔합니다.

 

getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileError)

 

이 코들르 사용하면 getJSON()의 일반적인 결과가 displayUserProfile()에 전달되는 것은 그대로이지만, getJSON()이나 displayUserProfile()의 에러, displayUserProfile()에서 일어난 예외는 handleProfileError()에 전달됩니다. 여기쓴 catch() 메서드는 then()을 호출하면서 첫번째 인자로 null을 두번째 인자로 지정된 에러 핸들러를 전달하는 형태를 짧게 줄인 것뿐입니다. 

 

프라미스 용어

사람과 사람 사이에 약속을 말할 때 약속을 '지켰다', '어겼다' 같은 표현을 씁니다. 자바스크립트 프라미스에서는 '이행하다(fulfill)', 거부하다(reject)'라는 용어를 씁니다. 프라미스의 then() 메서드를 호출하면서 콜백 함수 두개를 전달했다고 합시다. 첫번째 콜백이 호출되면 그 프라미스는 이행됬다고 합니다. 두 번째 콜백이 호출되면 그 프라미스는 거부됐다고 합니다. 프라미스가 이행되지도, 거부되지도 않았다면 대기(pending)중인 겁니다. 이행 또는 거부된 프라미스는 완료(settled)됐다고 합니다. 프라미스는 이행되는 동시에 거부될 수 없습니다. 일단 완료된 프라미스는 절대 이행이나 거부 상태로 바뀔 수 없습니다. 

 

프라미스는 단순히 어떤 비동기 코드가 끝났을 때 콜백을 실행하도록 등록하는 추상적인 방법이 아닌, 그 비동기 코드의 결과값입니다. 비동기 코드가 정상적으로 실행되고 프라미스가 이행됐다면 그 결과는 코드의 반환 값입니다. 비동기 코드가 정상적으로 완료되지 않고 프라미스가 거부됐다면 그 결과는 동기적으로 실행했을 때 일으켰을 Error 객체나 기타 값입니다. 완료된 프라미스에는 항상 연관된 값이 있으며 그 값은 바뀌지 않습니다. 프라미스가 이행됐다면 그 값은 then()의 첫번째 인자로 등록된 콜백 함수에 전달되는 반환 값입니다. 프라미스가 거부됐다면 그 값은 catch()에 등록되었거나 then()의 두번째 인자인 콜백함수에 전달되는 일종의 에러입니다. 

 

프라미스의 resolve된 상태를 이행된 상태나 완료된 상태로 혼동하기 쉽지만 엄밀히 말해서 이들은 같은 상태가 아닙니다. 해석된 상태를 이해하는 것이 프라미스를 깊게 이해하는데 매우 중요합니다.

 

프라미스의 가장 중요한 장점중 하나는 비동기 작업 시퀀스를 then()의 체인으로 이어서 콜백 헬을 방지한다는 점입니다. 다음은 가상의 프라미스 체인입니다.

 

fetch(documnetURL)
.then(response => response.json())
.then(document => {
	return render(document);
}

 

fetch()는 URL을 받고 프라미스를 반환합니다. 그 프라미스는 HTTP 응답이 도착하기 시작하여 HTTP 상태와 헤더를 읽으면 이행됩니다.

 

fetch("/api/user/profile").then(response => {
	// 프라미스가 해석되면 상태와 헤더가 존재합니다.
	if (response.ok && response.headers.get("Content-Type") === "application/json"){
		// 아직 응답 바디는 받지 못했습니다.
	}
})

 

fetch()가 반환하는 프라미스가 이행되면 프라미스는 then() 메서드에 전달한 함수에 응답 객체를 전달합니다. 이 응답 객체는 요청 상태와 헤더에 접근을 허용하며, 응답 바디에 각각 텍스트와 JSON 형태로 접근할 수 있는 text()와 json() 메서드도 가지고 있습니다.

프라미스가 이행되긴 했지만 응답 바디는 아직 도착하지 않았을 수도 있습니다. 따라서 응답 바디에 접근하는 text()와 json() 메서드 역시 프라미스를 반환합니다.

 

fetch("/api/user/profile").then(response => {
	response.json().then(profile => { // JSON으로 분석된 바디를 요청합니다.
		// 응답 바디를 받으면 자동으로 JSON으로 전달하고 이 함수에 전달합니다.
		displayUserProfile(profile)
	})
})

 

에러 처리

비동기 프로그래밍에서는 에러 처리가 중요합니다. 동기적 코드에서는 에러 처리 코드가 없으면 예외가 발생하고 스택 추적을 통해 어디서 무엇이 잘못됐는지 파악할 수 있습니다.

비동기 코드에서는 처리하지 않은 예외가 아무런 경고 없이 사라질 때가 많고 에러도 조용히 일어날 때가 많으므로 디버그하기 무척 어렵습니다.

 

프라미스의 .catch() 메서드는 비동기 코드의 한계를 보완하는 대안입니다. 동기적 코드에서는 뭔가 잘못됐을 때 예외가 catch 블록을 만날 때까지 ‘콜 스택을 따라 올라간다(bubbling up the call stack)’고 합니다. 프라미스의 비동기 체인에서는 '.catch를 만날 때까지 에러가 체인을 따라 내려간다.(trickling down the chain)') 고 표현할 수 있습니다.

 

ES2018 이후 프라미스 객체에는 finally 절과 비슷한 목적을 가진 .finally() 메서드가 생겼습니다. 프라미스 체인에 .finally()를 추가하면 호출한 프라미스가 완료될 때 .finally()가 호출됩니다. 이 콜백은 프라미스가 이행되거나 거부될 때 호출되며 아무 인자도 받지 않으므로 콜백 안에서 프라미스가 이행됐는지 거부됐는지 알 수는 없습니다. 하지만 프라미스의 이행 여부와 관계없이 파일이나 네트워크 연결을 닫는 것과 같은 정리 작업을 해야한다면 .finally() 콜백이 이상적입니다. .then(), .catch()와 마찬가지로 .finally()도 새 프라미스 객체를 반환합니다. .finally() 콜백의 반환값은 일반적으로 무시되며, finally()가 반환하는 프라미스는 보통 .finally()가 호출된 프라미스가 해석/거부된 값과 같은 값으로 해석/거부됩니다. 하지만 .finally() 콜백이 예외를 일으키면, .finally()가 반환하는 프라미스는 그 예외와 함께 거부됩니다. 

 

연속된 메서드 체이닝에서 전의 promise가 reject되면 다음 메서드에 에러 핸들러를 지정하지 않았을 시에 연속으로 거부됩니다. 

 

때때로 여러개의 비동기 작업을 병렬로 실행해야할 때도 있습니다. Promise.all()이 프라미스의 병렬 실행을 담당합니다. Promise.all()은 프라미스 객체의 배열을 받고 프라미스를 반환합니다. 입력 프라미스 중 하나라도 거부되면 반환된 프라미스 역시 거부됩니다. 입력 프라미스가 모두가 이행되면 전체 프라미스는 각 입력 프라미스 값으로 이루어진 배열로 이행됩니다. 배열 요소 중 일부가 프라미스가 아니라면 그 값은 이미 이행된 것으로 간주하고 결과 배열에 그대로 복사합니다. 입력 프라미스 중 하나라도 거부되면 Promise.all()이 반환하는 프라미스 역시 거부됩니다. 결과 프라미스는 첫번째로 거부되는 프라미스가 생기는 즉시, 나머지 프라미스가 아직 대기중이어도 거부됩니다. 

 

ES2020에는 Promise.all()과 마찬가지로 프라미스 배열을 받아 프라미스를 반환하는 Promise.allSettled()를 도입했습니다. Promise.allSettled()는 반환된 프라미스를 절대 거부하지 않으며 입력 프라미스 전체가 완료되기 전에는 이행되지 않습니다. 이 프라미스는 객체 배열로 해석되며 각 객체는 입력 프라미스입니다. Promise.race()는 입력 배열에서 처음으로 이행/거부되는 프라미스와 함께 이행/거부되는 프라미스를 반환합니다. (또는, 입력 배열에 프라미스가 아닌 값이 있다면 그런 값중 첫번째를 반환합니다.)

 

동기적인 값을 기반으로 하는 프라미스

함수 작업 자체에는 비동기 작업이 전혀 없는데도 프라미스를 반환하게 해야할 때도 있습니다. 이럴 때는 정적 메서드 Promise.resolve(),Promise.reject()를 사용하십시오. Promise.resolve()는 인자 하나만 받고 즉시, 그러나 비동기적으로 그 값으로 이행되는 프라미스를 반환합니다. 마찬가지로, Promise.reject()도 인자 하나만 받고 그 이유로 거부되는 프라미스를 반환합니다. 

 

프라미스의 해석과 이행은 다릅니다. Promise.resolve()를 호출할 때는 일반적으로 이행 값을 전달해서 그 값으로 즉시 이행되는 프라미스 객체를 만듭니다. 자주 있는 일은 아니지만 계산은 동기적으로 하고 값은 Promise.resolve()를 써서 비동기적으로 반환하는 프라미스 기반 함수를 만들 수 있습니다. 

'JavaScript' 카테고리의 다른 글

Understanding Repaint and Reflow in JavaScript  (0) 2023.03.05
변수의 유효범위와 클로저  (0) 2023.02.27
자바스크립트 완벽 가이드 | 객체  (0) 2022.06.30
closure  (0) 2022.06.02
prototype의 명확한 이해  (0) 2022.05.31