프론트엔드/Javascript

비동기 - 프로미스 체이닝

syleemomo 2024. 3. 15. 16:44
728x90

 

* 프로미스 체이닝 

"비동기 - 콜백" 수업에서 스크립트를 순차적으로 불러오는 비동기 작업이 여러개인 경우에 콜백기반 프로그래밍을 사용하였다. 이때 콜백지옥(callback hell)과 같이 코드가 복잡해지는 문제가 발생하였다. 프로미스 체이닝을 이용하면 이러한 문제를 간단하게 해결할 수 있다. 

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000) 

}).then(function(result) { 

  alert(result) // 1
  return result * 2

}).then(function(result) { 

  alert(result) // 2
  return result * 2

}).then(function(result) {

  alert(result) // 4
  return result * 2

})

프로미스 체이닝은 비동기 작업이 종료된 다음, 결과값이 then 메서드의 체인(사슬)을 통해 전달된다. 첫번째 then 메서드의 입력으로 전달된 result 값은 resolve(1)로 전달된 값이다. 두번째 then 메서드의 입력으로 전달된 result 값은 첫번째 then 메서드에서 반환된 값이다. 세번째 then 메서드의 입력으로 전달된 result 값은 두번째 then 메서드에서 반환된 값이다.

프로미스 체이닝

결과값(result)이 위의 그림과 같이 then 메서드의 체인을 따라 전달되므로 alert 창에는 1, 2, 4가 순서대로 출력된다. 

new Promise(function(resolve, reject) {

}).then(function() { 

})

프로미스 체이닝이 가능한 이유는 위와 같이 promise.then 을 호출하면 프로미스 객체가 반환되기 때문이다. 반환된 프로미스 객체에는 당연히 then 메서드를 사용할 수 있다. 

 

* 프로미스 디스패치(다중전송)

const promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve(1), 1000)
})

promise.then(function(result) {
  alert(result) // 1
  return result * 2
})

promise.then(function(result) {
  alert(result) // 1
  return result * 2
})

promise.then(function(result) {
  alert(result) // 1
  return result * 2
})

해당 코드는 프로미스 객체는 하나인데 여기에 등록된 구독자(then)는 여러개이다. 이렇게 하면 setTimeout 에서 수행한 비동기 작업의 결과값이 모든 구독자(then)에 동시에 전달된다. 즉, 각각의 then 메서드는 결과값을 독립적으로 처리한다. 

프로미스 디스패치

동일한 프로미스 객체에 등록된 then 메서드는 동일한 결과값을 전송받는다. 따라서 위 예제를 실행하면 모두 1이 출력된다. 하지만 실무에서는 주로 프로미스 체이닝을 사용한다. 

 

* then 메서드에서 프로미스 반환하기 

then 메서드에서도 프로미스 객체를 반환하는 경우가 있다. 이렇게 되면 다음에 이어지는 then 메서드는 프로미스 객체가 처리될때까지 기다리다가 처리가 완료되면(resolve 혹은 reject 함수 호출시) 결과를 전달받는다. 

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000)

}).then(function(result) {

  alert(result) // 1

  return new Promise((resolve, reject) => { 
    setTimeout(() => resolve(result * 2), 2000)
  })

}).then(function(result) { 

  alert(result) // 2

  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 3000)
  })

}).then(function(result) {

  alert(result) // 4

})

해당 코드는 다음의 순서로 동작한다. 

1. 프로미스 객체가 생성되면서 동시에 setTimeout 이 메모리에 등록된다. 
2. 1초후에 resolve(1) 을 호출하면서 첫번째 then 메서드가 실행된다. 
3. 첫번째 then 메서드는 프로미스 객체를 생성하면서 동시에 setTimeout 을 메모리에 등록한다.
4. 2초후에 resolve(2) 을 호출하면서 두번째 then 메서드가 실행된다. 
5. 두번째 then 메서드는 프로미스 객체를 생성하면서 동시에 setTimeout 을 메모리에 등록한다.
6. 3초 후에 resolve(4) 을 호출하면서 세번째 then 메서드가 실행된다. 
7. 세번째 then 메서드는 결과값을 출력한다.

이처럼 then 메서드에서 프로미스 객체를 반환하면 순차적으로 비동기 작업을 수행할 수 있다. 

 

* loadScript 함수에 프로미스 체이닝 적용하기 

loadScript("script.js")
  .then(function(script) {
    return loadScript("main.js")
  })
  .then(function(script) {
    return loadScript("build.js")
  })
  .then(function(script) {
    // 불러온 스크립트 안에 정의된 함수를 호출해
    // 실제로 스크립트들이 정상적으로 로드되었는지 확인합니다.
    loadend()
    executeMain()
    buildSomething()
  })

해당 코드는 프로미스 체이닝을 사용하여 순차적으로 외부 스크립트 파일을 로딩하고, 마지막 then 메서드에서 스크립트에 있는 함수들을 전부 실행한다. 

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    let script = document.createElement('script')
    script.src = src

    script.onload = () => resolve(script)
    script.onerror = () => reject(new Error(`${src}를 불러오는 도중에 에러가 발생함`))

    document.head.append(script)
  })
}

loadScript("script.js")
  .then(function(script) {
    return loadScript("main.js")
  })
  .then(function(script) {
    return loadScript("build.js")
  })
  .then(function(script) {
    // 불러온 스크립트 안에 정의된 함수를 호출해
    // 실제로 스크립트들이 정상적으로 로드되었는지 확인합니다.
    loadend()
    executeMain()
    buildSomething()
  })

app.js 파일을 위와 같이 수정하자!

맨 마지막 then 메서드 내부에서는 모든 스크립트 파일을 불러온 시점이므로 스크립트 안의 함수들을 전부 사용할 수 있다. loadScript 함수를 호출할때마다 프로미스 객체가 반환되며, 이어지는 다음 then 메서드는 스크립트 로딩이 끝났을때 실행된다. 즉, resolve 함수를 호출할때 다음 then 메서드가 실행된다. 그러므로 순차적으로 스크립트 파일을 불러오게 된다.

function loadend(){
    console.log("스크립트 로딩이 완료되었습니다!")
}

script.js 파일은 위와 같다.

function executeMain(){
    console.log('메인 프로그램 로딩이 완료되었습니다!')
}

main.js 파일은 위와 같다.

function buildSomething(){
    console.log('빌드 파일의 로딩이 완료되었습니다!')
}

build.js 파일은 위와 같다.

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    let script = document.createElement('script')
    script.src = src

    script.onload = () => resolve(script)
    script.onerror = () => reject(new Error(`${src}를 불러오는 도중에 에러가 발생함`))

    document.head.append(script)
  })
}

loadScript("script.js")
  .then(script => loadScript("main.js"))
  .then(script => loadScript("build.js"))
  .then(script => {
    // 불러온 스크립트 안에 정의된 함수를 호출해
    // 실제로 스크립트들이 정상적으로 로드되었는지 확인합니다.
    loadend()
    executeMain()
    buildSomething()
  })

app.js 파일을 화살표 함수를 사용하여 수정하면 코드가 더 간결해진다. 체인에 더 많은 비동기 작업을 추가하더라도 코드가 오른쪽으로 길어지지 않고, 아래쪽으로만 증가하므로 콜백지옥(callback hell)에 빠지지 않는다. 

프로미스 체이닝 결과

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    let script = document.createElement('script')
    script.src = src

    script.onload = () => resolve(script)
    script.onerror = () => reject(new Error(`${src}를 불러오는 도중에 에러가 발생함`))

    document.head.append(script)
  })
}

loadScript("script.js").then(script1 => {
  loadScript("main.js").then(script2 => {
    loadScript("build.js").then(script3 => {
      // 여기서 script1, script2, script3에 정의된 함수를 사용할 수 있습니다.
      loadend()
      executeMain()
      buildSomething()
      console.log(script1, script2, script3)
    })
  })
})

해당 코드와 같이 loadScript 함수에 then 을 바로 붙일수도 있다. 이렇게 then 을 바로 붙여도 동일하게 동작한다. 하지만 한가지 문제점은 콜백지옥처럼 코드가 오른쪽으로 길어진다. 하지만 장점도 있다. 중첩함수에서는 외부 스코프에 접근할 수 있기 때문이다. 가장 깊은 곳에 위치한 중첩 함수는 script1, script2, script3 변수에 모두 접근할 수 있다. 

loadScript 함수에 바로 then 메서드를 붙인 경우

 

 

* 프로미스와 호환가능한 자체 객체 만들기 - thenable 

then 메서드는 프로미스 객체가 아닌 thenable 이라 불리는 객체를 반환하기도 한다. 아래 코드와 같이 then 이라는 메서드를 가진 객체는 모두 thenable 객체라고 부르는데, 해당 객체는 프로미스와 같은 방식으로 처리된다. 

thenable 객체에 대한 아이디어는 서드파티 라이브러리가 '프로미스와 호환 가능한' 자체 객체를 구현하려는 시도에서 등장하였다. thenable 객체에는 프로미스 객체와 차별화된 자체 확장 메서드가 구현되어 있겠지만 then 메서드를 포함하고 있기 때문에 네이티브 프로미스 객체와도 호환 가능하다.

function Thenable(num) {
  this.num = num
}
Thenable.prototype.then = function(resolve, reject){
  // 1초 후 this.num*2와 함께 이행됨
  setTimeout(() => resolve(this.num * 2), 1000)
}

new Promise(resolve => resolve(1))
  .then(result => {
    return new Thenable(result)
  })
  .then(alert) // 1초 후 2를 보여줌

해당 코드는 다음의 순서로 동작한다.

1. 첫번째 then 메소드에서 thenable 객체를 반환하고, 결과값 1을 멤버변수에 저장한다.
2. thenable 객체에 then 메서드가 있으면 해당 메서드를 호출한다. 
3. then 메서드는 resolve, reject 함수를 인자로 받아서 둘 중 하나가 호출될때까지 기다린다.
4. 1초 후에 resolve(2)가 호출된다. 
5. 두번째 then 메서드가 호출되면서 결과값 2가 화면에 출력된다.

이런 방식으로 구현하면 Promise 를 상속받지 않고도 나만의 프로미스 객체를 만들고, 프로미스 체이닝에 연결하여 사용할 수 있다. 

 

* fetch 함수를 사용하여 네트워크 요청하기 

프론트엔드에서는 네트워크 요청시 프로미스를 자주 사용한다. 네트워크 요청시 아래와 같이 fetch 함수를 사용한다.

const promise = fetch(url)

해당 코드를 실행하면 url 주소로 네트워크 요청을 보내고 프로미스 객체를 반환한다. 원격서버가 응답을 보내면 프로미스 객체의 상태(state) 는 이행(fulfilled)이 된다. 

{"name": "Violet-Bora-Lee", "isAdmin": true}

 user.json 파일을 생성하고 위와 같이 작성하자!

fetch('user.json')
  // 원격 서버가 응답하면 .then 아래 코드가 실행됩니다.
  .then(function(response) {
    // response.text()는 응답 텍스트 전체가 다운로드되면
    // 응답 텍스트로 새로운 이행 프라미스를 만들고, 이를 반환합니다.
    return response.text()
  })
  .then(function(text) {
    // 원격서버에서 받아온 파일의 내용
    alert(text) // {"name": "Violet-Bora-Lee", "isAdmin": true}
  })

app.js 파일을 위와 같이 작성하자!

해당 코드는 fetch 함수를 이용하여 user.json 주소로 네트워크 요청을 보낸다. 원격서버가 응답을 보내면 첫번째 then 메서드가 실행된다. 그런데 이때 response 객체는 응답이 브라우저에 도달하기는 하였지만, 응답 데이터를 전부 불러온것은 아니기 때문에 전체 데이터를 조회하려면 response.text() 또는 response.json() 메서드를 호출하여 새로운 프로미스 객체를 반환해준다. 새로운 프로미스 객체는 브라우저가 전체 응답 데이터를 완전히 조회할 수 있을때 이행되어 두번째 then 메서드를 실행한다. 

fetch('user.json')
  // 원격 서버가 응답하면 .then 아래 코드가 실행됩니다.
  .then(function(response) {
    // response.text()는 응답 텍스트 전체가 다운로드되면
    // 응답 텍스트로 새로운 이행 프라미스를 만들고, 이를 반환합니다.
    return response.json()
  })
  .then(function(text) {
    // 원격에서 받아온 파일의 내용
    alert(text) // {"name": "Violet-Bora-Lee", "isAdmin": true}
  })

이때 response.text() 는 문자열을 반환하므로 코드에서 사용하기 어렵다. 위와 같이 response.json() 을 사용하면 원격서버에서 전송받은 데이터를 JSON 으로 파싱하므로 객체 형태로 변환하여 사용할 수 있다.

fetch('user.json')
  // 원격 서버가 응답하면 .then 아래 코드가 실행됩니다.
  .then(response => response.json())
  .then(user => alert(user.name)) // {"name": "Violet-Bora-Lee", "isAdmin": true}

 화살표 함수를 사용하면 코드가 훨씬더 간결해진다. 

fetch('user.json')
  // 원격 서버가 응답하면 .then 아래 코드가 실행됩니다.
  .then(response => response.json())
  // GitHub에 요청을 보냅니다.
  .then(user => fetch(`https://api.github.com/users/${user.name}`)) // {"name": "Violet-Bora-Lee", "isAdmin": true}
  // 응답받은 내용을 json 형태로 불러옵니다.
  .then(response => response.json())
  // 3초간 아바타 이미지(githubUser.avatar_url)를 보여줍니다.
  .then(githubUser => {
    const img = document.createElement('img')
    img.src = githubUser.avatar_url
    img.className = "promise-avatar-example"
    document.body.append(img)

    setTimeout(() => img.remove(), 3000) 
  })

불러온 사용자 정보를 가지고 무언가 더 해보도록 하자!

해당 코드는 불러온 사용자 정보를 이용하여 Github 에 다시 네트워크 요청을 보낸다. Github 는 사용자 정보를 이용하여 해당 사용자의 프로필 정보를 조회하고, 응답을 보내준다. 

fetch('user.json')
  // 원격 서버가 응답하면 .then 아래 코드가 실행됩니다.
  .then(response => response.json())
  // GitHub에 요청을 보냅니다.
  .then(user => fetch(`https://api.github.com/users/${user.name}`)) // {"name": "Violet-Bora-Lee", "isAdmin": true}
  // 응답받은 내용을 json 형태로 불러옵니다.
  .then(response => response.json())
  // 3초간 아바타 이미지(githubUser.avatar_url)를 보여줍니다.
  .then(githubUser => new Promise(function(resolve, reject){
    const img = document.createElement('img')
    img.src = githubUser.avatar_url
    img.className = "promise-avatar-example"
    document.body.append(img)

    setTimeout(() => {
      img.remove()
      resolve(githubUser)
    }, 3000) 
  }))
  // 3초 후 동작함
  .then(githubUser => alert(`${githubUser.name}의 이미지를 성공적으로 출력하였습니다.`));

 만약 3초후에 아바타가 사라지고 나서 무언가를 더 하고 싶으면 어떻게 해야 할까?

 해당 코드는 아바타가 사라진 직후에 이행된(resolve 호출) 프로미스 객체를 반환하여 이어지는 다음 then 메서드에서 추가적인 작업을 진행할 수 있도록 하고 있다. 이와 같이 비동기 동작은 항상 프로미스 객체를 반환하여 다음 작업으로 확장할 수 있게 작성해주는 것이 좋다. 이렇게 구현해두면 나중에 프로미스 체인의 확장이 필요한 경우에 손쉽게 확장할 수 있다.

function loadJson(url) {
  return fetch(url)
    .then(response => response.json())
}
function loadGithubUser(name) {
  return fetch(`https://api.github.com/users/${name}`)
    .then(response => response.json())
}
function showAvatar(githubUser) {
  return new Promise(function(resolve, reject) {
    const img = document.createElement('img')
    img.src = githubUser.avatar_url
    img.className = "promise-avatar-example"
    document.body.append(img)

    setTimeout(() => {
      img.remove()
      resolve(githubUser)
    }, 3000)
  })
}

// 함수를 이용하여 다시 동일 작업 수행
loadJson('user.json')
  .then(user => loadGithubUser(user.name))
  .then(showAvatar)
  .then(githubUser => alert(`Finished showing ${githubUser.name}`))

해당 코드는 재사용 가능한 함수 단위로 분리하여 다시 작성한 것이다. 

 

* 프로미스 체이닝 요약

프로미스 체이닝

then/catch/finally 메서드가 프로미스 객체를 반환하면 나머지 체인은 프로미스 객체가 처리될때까지 기다린다. 다시말해, 프로미스 객체가 resolve 혹은 reject 함수를 호출할때까지 기다린다. 처리가 완료되면 프로미스 객체의 result 값이 다음에 이어지는 체인으로 전달된다. 

 

* 연습과제 1

첫번째 사진의 로딩이 완료된 후 1초 후에 첫번째 사진을 화면에서 숨긴다. 첫번째 사진이 사라지면서, 두번째 사진을 로딩한다. 두번째 사진의 로딩이 완료된 후 1초 후에 두번째 사진을 화면에서 숨긴다. 두번째 사진이 사라지면서, 경고창에 두번째 사진이 사라졌다는 문구를 띄운다. (프로미스 체이닝을 사용하세요!)

728x90

'프론트엔드 > Javascript' 카테고리의 다른 글

프로토타입 상속 참고자료  (0) 2024.04.20
비동기 - async, await  (0) 2024.03.18
비동기 - 프로미스 (Promise)  (0) 2024.03.14
비동기 - 콜백  (0) 2024.03.13
프로토타입 상속  (0) 2024.03.08