비동기적 프로그래밍

Grace·2022년 6월 22일
0

JavaScript

목록 보기
7/11
post-thumbnail

사용자의 행동은 전적으로 비동기적입니다. 사용자가 언제 클릭할지, 터치할지, 또는 타이핑할지 전혀 알 수 없습니다. 하지만 비동기적 실행이 사용자 입력 하나 때문에 필요한 건 아닙니다. 사실 자바스크립트의 본성 때문에 비동기적 프로그래밍이 필요합니다.

자바스크립트 애플리케이션은 단일 스레드에서 동작합니다. 즉, 자바스크립트는 한 번에 한 가지 일만 할 수 있습니다. 멀티코어를 장착한 대부분의 최신 컴퓨터는 한 번에 여러 가지 일을 할 수 있고, 싱글코어 컴퓨터도 매우 빨라서 작업 A를 잠시 하고, 작업 B를 잠시 하고, 작업 C를 잠시 하는 식으로 멀티태스킹을 흉내 낼 수 있습니다. 사용자가 보기에는 세 가지 작업이 동시에 일어나는 것처럼 보입니다. 실제로 멀티코어에서 수행하지 않았더라도 말입니다.

자바스크립트가 싱글 스레드라는 얘기를 듣고 할 수 있는 일이 제한된다고 느낄지도 모르지만, 사실 멀티스레드 프로그래밍에서 겪어야 하는 정말 골치 아픈 문제를 신경 쓰지 않아도 된다는 장점도 있습니다. 물론 대가가 있습니다. 부드럽게 동작하는 소프트웨어를 만들기 위해서는 사용자의 입력뿐만 아니라 여러 문제를 비동기적 관점에서 생각해야 합니다. 비동기적 관점에서 생각하는 건 처음에는 어려울 수 있습니다. 특히, 일반적으로 동기적 실행을 하는 언어를 사용했었다면 더 어렵게 느껴질 수 있습니다.

자바스크립트에는 매우 일찍부터 비동기적 실행 메커리즘이 존재했지만, 자바스크립트의 인기가 높아지고 자바스크립트로 만드는 소프트웨어가 점점 복잡해짐에 따라 비동기적 프로그래밍에 필요한 장치들이 추가되었습니다. 자바스크립트의 비동기적 프로그래밍에는 뚜렷이 구분되는 세 가지 패러다임이 있습니다. 처음에는 콜백이 있었고, 프로미스가 뒤를 이었으며 마지막은 제너레이터입니다. 제너레이터가 콜백이나 프로미스보다 모든 면에서 더 좋다면 제너레이터에 대해서만 공부하고 나머지는 과거의 유산으로 치워 둘 수도 있겠지만, 그렇게 간단한 문제는 아닙니다. 제너레이터를 비동기적으로 사용하려면 프로미스나 특수한 콜백과 합께 사용해야 합니다. 프로미스 역시 콜백에 의존합니다. 콜백은 제너레이터나 프로미스 외에도 이벤트 처리 등에 유용하게 쓸 수 잇습니다.

사용자 입력 외에, 비동기적 테크닉을 사용해야 하는 경우는 크게 세 가지가 있습니다.

  • Ajax 호출을 비롯한 네트워크 요청
  • 파일을 읽고 쓰는 등의 파일시스템 작업
  • 의도적으로 시간 지연을 사용하는 기능(알림 등)

콜백

콜백은 자바스크립트에서 가장 오래된 비동기적 매커니즘입니다. 우리는 사용자 입력과 타임 아웃을 처리하면서 이미 콜백을 사용했습니다. 콜백은 간단히 말해 나중에 호출할 함수입니다. 콜백 함수 자체에는 특별한 것이 전혀 없습니다. 콜백 함수도 일반적인 자바스크립트 함수일 뿐입니다. 콜백 함수는 일반적으로 다른 함수에 넘기거나 객체의 프로퍼티로 사용합니다. 드물게는 배열에 넣어서 쓸 때도 있습니다. 항상 그런 건 아니지만, 콜백은 보통 익명 함수로 사용합니다.

먼저 setTimeout을 사용하는 단순한 예제로 시작합니다. setTimeout은 콜백의 실행을 지정된 밀리초만큼 지연하는 내장 함수입니다.

console.log("Before timeout: " + new Date())
function f(){
  console.log("After timeout: " + new Date())}

setTimeout(f, 60*1000) // 1분
console.log("I happen after setTimeout!")
console.log("Me too!")

(독수리 타법을 사용하지 않는 한) 콘솔에서 이 코드를 실행하면 다음과 같은 결과를 보게 될 겁니다.

Before timeout: Tue Mar 21 2017 20:04:26 GMT+0900 (KST)
I happen after setTimeout!
Me too!
After timeout: Tue Mar 21 2017 20:05:26 GMT+0900 (KST)

비동기적 실행의 가장 큰 목적, 가장 중요한 요점은 어떤 것도 차단하지 않는다는 것입니다. 자바스크립트는 싱글 스레드를 사용하므로, 우리가 컴퓨터에 60초 동안 대기한 후 코드를 실행하라고 지시한다면, 그리고 그 실행이 동기적으로 이루어진다면 꽤 골치 아픈 일이 벌어집니다. 프로그램이 멈추고, 사용자 입력을 받아들이지 않고, 화면도 업데이트 하지 않을 겁니다. 이런 일을 한 번씩은 겪어봤을 테고, 절대 유쾌한 경험은 아닐 겁니다. 비동기적 테크닉은 프로그램이 이런 식으로 멈추는 일을 막아줍니다.

setTimeout(function(){
  console.log("After timeout: " + new Date())
}, 60*1000)

setTimeout에는 문법적인 불편함이 하나 있습니다. 지연 시간을 정하는 숫자 매개변수가 마지막 매개변수이기 때문에 익명 함수를 사용할 때, 특히 그 함수가 길다면 시간 매개변수를 찾기 어렵거나 익명 함수의 일부분처럼 보일 때가 있습니다. 게다가 setTimeoutsetInterval은 대부분 익명 함수와 함께 사용합니다. 지연 매개변수는 마지막 행에 쓴다는 원칙을 세워 두면 이런 혼란을 피할 수 있습니다.

setInterval과 clearInterval

setTimeout은 콜백 함수를 한 번만 실행하고 멈추지만, setInterval은 콜백을 정해진 주기마다 호출하며 clearInterval을 사용할 때까지 멈추지 않습니다.

const start = new Date()
let i=0
const intervalId = setInterval(function(){
  let now new Date()
  if(now.getMinutes() !== start.getMinutes() || ++i > 10) return clearInterval(intervalId)
  console.log(`$(i}: ${now}`)
}, 5*1000)

스코프와 비동기적 실행

비동기적 실행에서 혼란스럽고 에러도 자주 일어나는 부분은 스코프와 클로저가 비동기적 실행에 영향을 미치는 부분입니다. 함수를 호출하면 항상 클로저가 만들어집니다. 매개변수를 포함해 함수 안에서 만든 변수는 모두 무언가가 자신에 접근할 수 있는 한 계속 존재합니다.

function countdown(){
  let i
  console.log("Countdown:")
  for(i=5; i>=0; i--){
    setTimeout(function(){
      console.log(i===0 ? "GO!" : i)
    }, (5-i)*1000)
  }
}
countdown()

코드를 보면 5에서부터 카운트다운 할 것처럼 보입니다. 하지만 결과는 -1이 여섯 번 반복될 뿐이고, "GO!"는 나타나지 않습니다. 이 예제를 처음 봤을 때는 var를 사용했습니다. 이번에는 let을 사용하긴 했지만, 변수를 for 루프 밖에서 선언했으므로 같은 문제가 벌어집니다. for 루프가 실행을 마치고 i의 값이 -1이 된 다음에서야 콜백이 실행되기 시작합니다. 문제는, 콜백이 실행될 때 i의 값은 이미 -1이란 겁니다.

스코프와 비동기적 실행이 어떻게 연관되는지 이해하는 것이 중요합니다. countdown을 호출하면 변수 i가 들어있는 클로저가 만들어집니다. for 루프 안에서 만드는 콜백은 모두 i에 접근 할 수 있고, 그들이 접근하는 i는 똑같은 i입니다.

이 예제에서 눈여겨 볼 것이 하나 더 있습니다. for 루프 안에서 i를 두 가지 방법으로 사용했습니다. i를 써서 타임아웃을 계산하는 (5-i)*1000 부분은 예상대로 동작합니다. 첫 번째 타임 아웃은 0, 두 번째 타임아웃은 1000, 세 번째 타임아웃은 2000입니다. 이 계산이 예상대로 동작한 것은 동기적으로 실행됐기 때문입니다. 사실 setTimeout을 호출하는 것 역시 동기적입니다. setTimeout을 동기적으로 호출해야만 콜백을 언제 호출할지 계산할 수 있습니다. 비동기적인 부분은 setTimeout에 전달된 함수이고, 문제는 여기서부터 복잡해집니다.

이 문제는 즉시 호출하는 함수 표현식(IIFE)으로 해결했고, 좀 더 간단하게 i를 for 루프 선언부에서 선언하는 방식으로도 해결할 수 있었습니다.

function countdown(){
  console.log("Countdown:")
  for(let i=5; i>=0; i--){
    setTimeout(function(){
      console.log(i===0?"GO!":i)
    }, (5-i)*1000)
  }
}
countdown()
}

여기서 주의할 부분은 콜백이 어느 스코프에서 선언됐느냐입니다. 콜백은 자신을 선언한 스코프(클로저)에 있는 것에 접근할 수 있습니다. 따라서 i의 값은 콜백이 실제 실행되는 순간마다 다를 수 있습니다. 이 원친은 콜백뿐만 아니라 모든 비동기적 테크닉에 적용됩니다.

오류 우선 콜백

노드가 점점 인기를 얻어가던 시기에 오류 우선 콜백이라는 패턴이 생겼습니다. 콜백을 사용하면 예외 처리가 어려워지므로, 콜백과 관련된 에러를 처리할 방법의 표준이 필요했습니다. 이에 따라 나타난 패턴이 콜백의 첫 번째 매개변수에 에러 객체를 쓰자는 것이었습니다. 에러가 null이나 undefined이면 에러가 없는 것입니다.

오류 우선 콜백을 다룰 때 가장 먼저 생각할 것은 에러 매개변수를 체크하고 그에 맞게 반응하는 겁니다. 노드에서 파을 콘텐츠를 읽는다고 할 때, 오류 우선 콜백을 사용한다면 다음과 같은 코드를 쓰게 됩니다.

const fs = require('fs')

const fname = 'may_or_may_not_exist.txt'
fs.redFile(fname, function(err, data){
  if(err) return console.error(`error reading file ${fname}: ${err.message}`)
  console.log(`${fname} contents: ${data}`)
})

콜백에서 가장 먼저 하는 일은 err이 참 같은 값인지 확인하는 겁니다. err이 참 같은 값이라면 파일을 읽는 데 문제가 있다는 뜻이므로 콘솔에 오류를 보고하고 즉시 빠져나옵니다(console.error는 어떤 값으로 평가되는지 않지만, 반환값을 사용할 필요가 있는 것도 아니므로 문 하나로 결합했습니다). 오류 우선 콜백을 사용할 때 가장 많이 벌어지는 실수는 아마 이 부분일 겁니다. 에러 객체를 체크해야 한다는 사실을 기억하고, 아마 로그를 남기기도 하곘지만, 빠져나와야 한다는 사실은 잊는 사람이 많습니다. 콜백을 사용하는 함수는 대개 콜백이 성공적이라고 가정하고 만들어집니다. 그런데 콜백이 실패했으니, 빠져나가지 않으면 오류를 예약하는 것이나 다름없습니다. 물론, 콜백을 만들 때 실패하는 경우도 염두에 두고 만들었다면 에러를 기록하기만 하고 계속 진행해도 됩니다.

프로미스를 사용하지 않으면 오류 우선 콜백은 노드 개발의 표준이나 다름없습니다. 콜백을 사용하는 인터페이스를 만들 떄는 오류 우선 콜백을 사용하길 강력히 권합니다.

콜백 헬

콜백을 사용해 비동기적으로 실행할 수 있긴 하지만, 현실적인 단점이 있습니다. 한 번에 여러가지를 기다려야 한다면 콜백을 관리하기가 상당히 어려워집니다. 노드 앱을 만든다고 합시다. 이 앱은 세 가지 파일의 콘텐츠를 읽고, 60초가 지난 다음 이들을 결합해 네 번쨰 파일에 기록합니다.

const fs = require('fs')

fs.readFile('a.txt', funtion(err, dataA){
   	if(err) console.error(err)
	fs.readFile('b.txt', function(err, dataB){
      if(err) console.error(err)
      fs.readFile('c.txt', function(err, dataC){
        if(err) console.error(err)
        setTimeout(function(){
          fs.writeFile('d.txt', dataA+dataB+dataC,function(err){
            if(err) console.error(err)
          })
        }, 60*1000)
      })
   })
})

이런 코드를 콜백 헬이라 부릅니다.

const fs= require('fs')
function readSketchyFile(){
  try{
    fs.readFile('does_not_exist.txt', function(err, data){
      if(err) throw err
    })
  }catch(err){
    console.log('warning: minor issue occured, program continuing')
  }
}
readSketchyFile()

이 코드는 얼핏 타당해 보이고, 예외 처리도 수행하는 방어적인 코드처럼 보입니다. 동작하지 않는다는 것만 빼면 말입니다. 예외 처리가 의도적으로 동작하지 않는 이유는 try...catch 블록은 같은 함수 안에서만 동작하기 때문입니다. try...catch 블록은 readSketchFile 함수 안에 있지만, 정작 예외는 fs.readFile이 콜백으로 호출하는 익명 함수 안에서 일어났습니다.
또한, 콜백이 우연히 두 번 호출되거나, 아예 호출되지 않는 경우를 방지하는 안전장치도 없습니다. 콜백이 정확히 한 번만 호출될 것을 가정하고 코드를 작성한다면, 슬프게도 자바스크립트는 그것을 보장하지 않습니다.

해결할 수 없는 문제는 아닙니다. 하지만 비동기적 코드가 늘어나면 늘어날수록 버그가 없고 관리하기 쉬운 코드를 작성하기가 어려워집니다. 그래서 프로미스가 등장했습니다.

프로미스

프로미스는 콜백의 단점을 해결하려는 시도 속에서 만들어졌습니다. 프로미스는 간혹 번거롭게 느껴질 수 있지만, 일반적으로 안전하고 관리하기 쉬운 코드를 만들 수 있게 됩니다.
프로미스가 콜백을 대체하는 것은 아닙니다. 사실 프로미스에서도 콜백을 사용합니다. 프로미스는 콜백을 예측 가능한 패턴으로 사용할 수 있게 하며, 프로미스 없기 콜백만 사용했을 때 나타날 수 있는 이상한 현상이나 찾기 힘든 버그를 상당 수 해결합니다.

프로미스의 기본 개념은 간단합니다. 프로미스 기반 비동기적 함수를 호출하면 그 함수는 Promise 인스턴스를 반환합니다. 프로미스는 성공하거나, 실패하거나 단 두 가지 뿐입니다. 프로미스는 성공 혹은 실패 둘 중 하나만 일어난다고 확신할 수 있습니다. 성공한 프로미스가 나중에 실패하는 일 같은 건 없습니다. 또한, 성공이든 실패든 단 한 번만 일어납니다. 프로미스가 성공하거나 실패하면 그 프로미스는 결정됐다고 합니다.

프로미스는 객체이므로 어디든 전달할 수 있다는 점도 콜백에 비해 간편한 장점입니다. 비동기적 처리를 여기서 하지 않고 다른 함수에서 처리하게 하고 싶다면 프로미스를 넘기기만 하면 됩니다.

프로미스 만들기

프로미스는 쉽게 만들 수 있습니다. resolve(성공)dhk reject(실패) 콜백이 있는 함수로 새 Promise 인스턴스를 만들기만 하면 됩니다. countdown 함수를 고쳐 봅시다. 매개변수를 받게 만들어서 5초 카운트다운에 매이지 않게 하고, 카운트다운이 끝나면 프로미스를 반환하게 하겠습니다.

function countdown(seconds){
  return new Promise(function(resolve, reject){
    for(let i=seconds; i>=0; i--){
      setTimeout(function(){
        if(i>0) console.log(i+'...')
        else resolve(console.log("GO!")
      }, (seconds-i)*1000)
    }
  })
}

이대로라면 별로 좋은 함수는 아닙니다. 너무 장환한 데다가, 콘솔을 아예 쓰지 않기를 원할 수도 있습니다. 웹 페이지에서 카운트다운이 끝나면 페이지 요소를 업데이트하는 목적에 쓰기도 별로 알맞지 않아 보입니다. 하지만 이제 시작일 뿐이고, 프로미스를 어떻게 만드는지는 잘 드러나 있습니다. resolve와 reject는 함수입니다. resolve를 여러 번 호출하면 프로미스의 프로미스 같은 걸 만들 수 있지 않을까 하는 생각이 들 수도 있겠지만, resolve나 reject를 여러 번 호출하든, 섞어서 호출하든 결과는 같습니다. 첫 번째로 호출한 것만 의미가 있습니다. 프로미스는 성공 또는 실패를 나타낼 뿐입니다.

프로미스 사용

countdown(5).then(
  function(){
    console.log("countdown conpleted successfully")
  },
  function(err){
    console.log("countdown experienced an error: " + err.message)
  }
)

반환된 프로미스를 변수에 할당하지 않고 then 핸들러로 바로 호출했습니다.then 핸들러는 성공 콜백과 에러 콜백을 받습니다. 경우의 수는 단 두가지뿐입니다. 성공 콜백이 실행되거나, 에러 콜백이 실행되거나 입니다. 프로미스는 catch 핸들러도 지원하므로 핸들러를 둘로 나눠서 써도 됩니다.

const p = countdown(5)
p.then(function(){
  console.log("countdown completed sucessfully")
})
p.catch(function(err){
  console.log("countdown.experienced an error: " + err.messgae)
})

카운트다운을 하다가 13을 만나면 에러를 만들도록 하겠습니다.

function countdown(seconds){
  return new Promise(function(resolve, reject){
    for(let i=seconds; i>=0; i--){
      setTimeout(function(){
        if(i===13) return reject(new Error("Oh my god"))
        if(i>0) console.log(i+'...')
        else resolve(console.log("GO!"))
      }, (seconds-i)*1000)
    }
  })
}

13 이상의 숫자를 사용하면 13에서 에러가 일어납니다. 하지만 콘솔에는 12부터 다시 카운트를 기록합니다. reject나 resolve가 멈추지는 않습니다. 이들은 그저 프로미스의 상태를 관리할 뿐입니다.
일반적으로 함수가 성공이든 실패든 결정됐다면 멈춰야 하는데 countdown 함수는 실패한 후에도 계속 진행합니다. 필요한 것은 카운트다운을 컨트롤 할 수 있는 기능입니다.
프로미스는 비동기적 작업이 성공 또는 실패하도록 확정하는, 매우 안전하고 잘 정의된 매커니즘을 제공하지만 현재는 진행 상황을 전혀 알려주지 않습니다. 즉 프로미스는 완료되거나 파기될 뿐, '50% 진행됐다'라는 개념은 아예 없는겁니다.

이벤트

이벤트는 자바스크립트에서 자주 사용됩니다. 이벤트의 개념은 간단합니다. 이벤트가 일어나면 이벤트 발생을 담당하는 개체에서 이벤트가 일어났음을 알립니다. 필요한 이벤트는 모두 주시할 수 있습니다. 어떻게 이벤트를 주시할까요? 물론 콜백을 통해서입니다. 이벤트 시스템을 직접 만드는 것도 별로 어려운 일은 아니지만, 노드에는 이미 이벤트를 지원하는 모듈 EventEmitter가 내장돼 있습니다. 이 모듈을 써서 countdown 함수를 개선해 봅시다. EventEmitter는 countdown 같은 함수와 함께 사용해도 되지만, 원래는 클래스와 함께 사용하도록 설계됐습니다.

const EventEmitter = require('events').EventEmitter

class Countdown extends EventEmitter{
  constructor(seconds, superstitious){
    super()
    this.seconds = seconds
    this.superstitious = !!superstitious
  }
  go(){
    const countdown = this
    return enw Promise(function(resolve, reject){
      for(let i=countdown.seconds; i>=0; i--){
        setTimeout(function(){
          if(countdown.superstitious&&i===13) return reject(new Error("Oh my god"))
          countdown.emit('tick', i)
          if(i===0) resolve()
        }, (countdown.seconds-i)*1000)
      }
    })
  }
}

EventEmitter를 상속하는 클래스는 이벤트를 발생시킬 수 있습니다. 실제로 카운트다운을 시작하고 프로미스를 반환하는 부분은 go 메서드입니다. go 메서드 안에서 가장 먼저 한 일은 countdownthis를 할당한 겁니다. 카운트다운이 얼마나 남았는지 알기 위해서는 this 값을 알아야 하고, 13인지 아닌지 역시 콜백 안에서 알아야 합니다. this는 특별한 변수이고 콜백 안에서는 값이 달라집니다. 따라서 this의 현재 값을 다른 변수에 저장해야 프로미스 안에서 쓸 수 있습니다.
가장 중요한 부분은 countdown.emit('tick', i)입니다. 이 부분에서 tick 이벤트를 발생시키고, 필요하다면 프로그램의 다른 부분에서 이 이벤트를 주시할 수 있습니다.

const c = new Countdown(5)

c.on('tick', function(i){
  if(i>0) console.log(i+'...')
})
c.go().then(function(){
  console.log("GO!")
}).catch(function(err){
  cosnole.error(err.message)
})

EvnetEmitter의 on 메서드가 이벤트를 주시하는 부분입니다. 이 예제에서는 tick 이벤트 전체에 콜백을 등록했습니다. tick이 0이 아니면 출력한 다음 카운트다운을 시작하는 go를 호출합니다. 카운트다운이 끝나면 GO!를 출력합니다.
처음 만들었던 countdown 함수보다 훨씬 복잡한 것은 사실이지만, 그만큼 기능이 늘어났습니다. 이제 카운트다운을 어떻게 활용할지 마음대로 바꿀 수 있고, 카운트 다운이 끝났을 때 완료되는 프로미스도 생겼습니다.
하지만 Countdown 인스턴스가 13에 도달했을 때 프로미스를 파기했는데도 카운트다운이 계속 진행되는 문제가 있습니다.

const c = new Countdown(15, true).on('tick', function(i){
  if(i>0) console.log(i+'...')
})

c.go().then(function(){
  console.log('GO!')
}).catch(function(err){
  console.error(err.message)
})

여전히 모든 카운트가 출력되며 0이 될 때까지 진행합니다. 이 문제를 해결하기 어려운건 타임아웃이 이미 모두 만들어졌기 때문입니다. 이 문제를 해결하려면 더 진행할 수 없다는 사실을 아는 즉시 대기 중인 타임아웃을 모두 취소하면 됩니다.

const EventEmitter = require('events').EventEmitter

class Countdown extends EventEmitter {
  constructor(seconds, superstitious){
    super()
    this.seconds = seconds
    this.superstitious = !!superstitious
  }
  go(){
    const countdown = this
    const timeoutIds = []
    return new Promise(function(resolve, reject){
      for(let i=countdown.seconds; i>=0; i--) {
        timeoutIds.push(setTimeout(function() {
          if(countdown.superstitious && i===13) {
            timeoutIds.forEach(clearTimeout)
            return reject(new Error("Oh my god"))
          }
          countdown.emit('tick', i)
          if(i===0) resolve()
        }, (countdown.seconds-i)*1000))
      }
    })
  }
}

프로미스 체인

프로미스에는 체인으로 연결할 수 있다는 장점이 있습니다. 즉, 프로미스가 완료되면 다른 프로미스를 반환하는 함수를 즉시 호출할 수 있습니다. launch 함수를 만들어 카운트다운이 끝나면 실행되게 해 봅시다.

functoion launch() {
  return new Promise(function(resolve, reject) {
    console.log("Lift off!")
    setTimeout(function() {
      resolve("In orbit!")
    }, 2*1000)
  })
}

이 함수를 카운트다운에 쉽게 묶을 수 있습니다.

const c = new Countdown(5).on('tick', i => console.log(i+'...'))

c.go().then(launch).then(function(msg) {
  console.log(msg)
}).catch(function(err) {
  console.error("Houston, we have a problem...")
})

프로미스 체인을 사용하면 모든 단계에서 에러를 캐치할 필요는 없스빈다. 체인 어디에서든 체러가 발생하면 체인 전체가 멈추고 catch 핸들러가 동작합니다. 카운트다운을 15초로 바꾸고 13에서 멈추도록 실행해보면 launch는 실행되지 않습니다.

결정되지 않는 프로미스 방지

프로미스는 비동기적 코드를 단순화하고 콜백이 두 번 이상 실행되는 문제를 방지합니다. 하지만 resolve나 reject를 호출하는 걸 잊어서 프로미스가 결정되지 않는 문제까지 자동으로 해결하지는 못합니다. 에러가 일어나지 않으므로 이런 실수를 찾기 매우 어렵습니다.
결정되지 않은 프로미스를 방지하는 방법은 프로미스에 타임아웃을 거는 겁니다. 충분한 시간이 지났는데도 프로미스가 결정되지 않으면 자동으로 실패하게 만들 수 있습니다.

function launch() {
  return new Promise(function(resolve, reject) {
    if(Math.random() < 0.5) return
    console.log("Lift off!")
    setTimeout(function() {
      resolve("In orbit!")
    }, 2*1000)
  })
}

이 코드는 reject를 호출하지 않는데다가, 심지어 콘솔에 기록하지도 않습니다. 열 번 시도하면 그 중 다섯은 영문도 모른 채 실패합니다.

function addTimeout(fn, timeout) {
  if(timeout===undefined) timeout = 1000
  return function(...args) {
    return new Promise(function(resolve, reject) {
      const tid = setTimeout(reject, timeout, new Error("promise timed out"))
      fn(...args).then(function(...args) {
        clearTimeout(tid)
        resolve(...args)
      }).catch(function(...args) {
        clearTimeout(tid)
        reject(...args)
      })
    })
  }
}

프로미스에 타임아웃을 걸기 위해서는 함수를 반환하는 함수가 필요한데, 쉽지 않습니다. 이 함수를 당장 이해하지 못해도 괜찮습니다. 프로미스를 반환하는 어떤 함수에든 타임아웃을 걸 수 있습니다.

c.go().then(addTimeout(launch, 11*1000)).then(function(msg) {
  console.log(msg)
}).catch(function(err) {
  console.error("Houston, we have a problem: " + err.message)
})

이제 launch 함수에 문제가 있더라도 프로미스 체인은 항상 결정됩니다.

제너레이터

제너레이터는 함수와 호출자 사이의 양방향 통신을 가능하게 합니다. 제너레이터는 원래 동기적인 성격을 가졌지만, 프로미스와 결합하면 비동기 코드를 효율적으로 관리할 수 있습니다.
동기적인 코드에 비해 비동기 코드는 만들기가 어렵습니다. 어려운 문제를 해결해야할 때 우리는 대개 동기적으로 생각합니다. 하지만 이렇게 하면 성능 문제가 있습니다. 비동기 코드는 성능 문제를 해결하기 위해 등장했습니다. 제너레이터를 사용하면 비동기 코드의 난해함은 젖혀놓고 성능 개선을 누리는 일정 부분이 가능해집니다.

dataA = read contents of 'a.txt'
dataB = read contents of 'b.txt'
dataC = read contents of 'c.txt'
wait 60 seconds
write dataA + dataB + dataC to 'd.txt'

제너레이터를 사용하면 이런 생각과 비슷한 코드를 작성할 수 있습니다. 가장 먼저 할 일은 노드의 오류 우선 콜백을 프로미스로 바꾸는 겁니다. 이 기능을 nfcall(Node function call) 함수로 만들겠습니다.

function nfcall(f, ...args) {
  return new Promise(function(resolve, reject) {
    f.call(null, ...args, function(err, ...args) {
      if(err) return reject(err)
      resolve(args.length<2 ? args[0] : args)
    })
  })
}

이제 콜백을 받는 노드 스타일 메서드를 모두 프로미스로 바꿀 수 있습니다. setTimeout을 써야 하는데, setTimeout은 노드보다 먼저 나왔고 오류 우선 콜백의 패턴으로 따르지 않습니다. 그러니 같은 기능을 가진 ptimeout(promise timeout) 함수를 새로 만듭니다.

function ptimeout(delay) {
  return new Promise(function(resolve, reject) {
    setTimeout(resolve, delay)
  })
}

다음에 필요한 것은 제너레이터 실행기입니다. 제너레이터는 원래 동기적입니다. 하지만 제너레이터는 호출자와 통신할 수 있으므로 제너레이터와의 통신을 관리하고 비동기적 호출을 처리하는 함수를 만들 수 있습니다. 이런 역할을 할 함수 grun(generator run)을 만들겠습니다.

function grun(g) {
  const it = g()
  (function iterate(val) {
    const x = it next(val)
    if(!x.done) {
      if(x.value instanceof Promise) {
        x.value.then(iterate).catch(err => it.throw(err))
      } else {
        setTimeout(iterate, 0, x.value)
      }
    }
  })()
}

grun은 기초적인 제너레이터 재귀 실행기입니다. grun에 제너레이터 함수를 넘기면 해당 함수가 실행됩니다. yield로 값을 넘긴 제너레이터는 이터레이터에서 next를 호출할 때까지 기다립니다. grun은 그 과정을 재귀적으로 반복합니다. 이터레이터에서 프로미스를 반환하면 grun은 프로미스가 완료될 때까지 기다린 다음 이터레이터 실행을 재개합니다. 이터레이터가 값을 반환하면 이터레이터 실행을 즉시 재개합니다. grun에서 iterate를 바로 호출하지 않고 setTimeout을 거진 이유는 효율성 떄문입니다. 자바스크립트 엔진은 재귀 호출을 비동기적으로 실행할 때 메모리를 좀 더 빨리 회수합니다.
nfcall은 과거의 방법인 노드 오류 우선 콜백을 현재의 방법인 프로미스에 적응시키고, grun은 미래의 기능을 현재로 가져옵니다.

function*theFutureIsNow() {
  const dataA = yield nfcall(fs.readFile, 'a.txt')
  const dataB = yield nfcall(fs.readFile, 'b.txt')
  const dataC = yiled nfcall(fs.readFile, 'c.txt')
  yield ptimeout(60*1000)
  yield nfcall(fs.writeFile, 'd.txt', dataA+dataB+dataC)
}

콜백 헬보다는 훨씬 낫고, 프로미스를 하나만 쓸 때보다 훨씬 단순합니다. 사람이 생각하는 것과 거의 같은 방법으로 동작합니다. 실행 또한 간단합니다.

grun(theFutureIsNow)

1보 전진과 2보 후퇴?

"그냥 세 개의 파일을 동시에 읽으면 더 효율적이지 않나?"라고 생각하는 분들도 있을 겁니다. 그 질문에 대한 답은 문제에 따라, 자바스크립트 엔진에 따라, 운영체제에 따라, 파일시스템에 따라 크게 다를 수 있습니다. 하지만 세 파일을 읽는 순서는 상관이 없다는 것, 그리고 설령 세 파일을 동시에 읽었다 한들 과연 효율적일지는 의문스럽다는 점을 상기합시다. theFutureIsNow 함수를 이런식으로 만든 것은, 이 방법이 이해하기 쉽고 단순해보였기 때문입니다.

Promise에는 all 메서드가 있습니다. 이 메서드는 배열로 받은 프로미스가 모두 완료될 때 완료되며, 가능하다면 비동기적 코드를 동시에 실행합니다. theFutureIsNow 함수가 Promise.all을 사용하도록 수정하기만 하면 됩니다.

function*theFutureIsNow() {
  const data = yield Promise.all([
    nfcall(fs.readFile, 'a.txt'), 
    nfcall(fs.readFile, 'b.txt'),
    nfcall(fs.readFile, 'c.txt'),
   ])
  yield ptimeout(60*1000)
  yield nfcall(fs.writeFile, 'd.txt', data[0]+data[1]+data[2])
}

Promise.all이 반환하는 프로미스에는 매개변수로 주어진 각 프로미스의 완료 값이 배열에 들어있었던 순서대로 들어있습니다. c.txta.txt보다 먼저 읽더라도 data[0]에는 a.txt의 내용이, data[2]에는 c.txt의 내용이 들어 있습니다.
Promise.all은 편리한 도구이고 알아두면 좋지만, 이 섹션에서 가장 중요한 것은 Promise.all이 아닙니다. 이 섹션에서 가장 중요한 것은 어떤 부분을 동시에 실행할 수 있고 어떤 부분은 동시에 실행할 수 없는지를 판단하는 것이여야 합니다. 어떤 부분을 동시에 실행할 수 있고 어떤 부분은 동시에 실행할 수 없는지를 판단하는 것은 문제에 따라 다릅니다.

제너레이터 실행기와 예외 처리

제너레이터 실행기를 쓰면 try/catch를 써서 예외 처리를 할 수 있다는 것도 중요한 장점입니다. 콜백이나 프로미스를 사용하면 예외 처리가 쉽지 않습니다. 콜백에서 일으킨 예외는 그 콜백 밖에서 캐치할 수 없습니다. 제너레이터 실행기는 비동기적으로 실행하면서도 동기적인 동작 방식을 유지하므로 try/catch문과 함께 쓸 수 있습니다.

function*theFutureIsNow(){
  let data
  try{
    data = yield Promise.all([
      nfcall(fs.readFile, 'a.txt'),
      nfcall(fs.readFile, 'b.txt'),
      nfcall(fs.readFile, 'c.txt'),
     ])
  } catch(err){
    console.error("Unable to read one or more input files: " + err.message)
    throw err;
  }
 yield ptimeout(60*1000);
 try{
   yield nfcall(fs.writeFile, 'd.txt', data[0]+data[1]+data[2])
 } catch(err){
   console.error("Unable to write output file: " + err.message)
   throw err
 }
}

try/catch는 예외 처리에서 널리 사용되고 다들 잘 이해하는 구조이니, 아직 동기적인 처리가 더 익숙하다면 예외 처리에 try/catch를 사용하는 것도 좋습니다.

profile
기술블로그 이전:: https://meercat.tistory.com/

0개의 댓글