👶 TypeScript

프로미스로 정상 회복하기

개발자 린다씨 2023. 1. 31. 18:40
반응형

프로미스로 정상 회복하기

먼저 Promise로 파일에 내용을 추가하고 결과를 다시 읽어오는 예를 살펴보겠습니다.

function appendReadPromise(path: string, data: string): Promise<string>{
    return appendReadPromise(path, data)
        .then(()=>readPromise(path))
        .catch(error=>console.error(error))
}

이 코드는 원하는 일을 완수하는 데 필요한 비동기 작업들을 직관적인 체인(chain) 하나로 엮은 결과, 콜백 피라미드는 전혀 등장하지 않는다는 점에 주목합니다.

 

한 작업이 성공하면 다른 작업을 실행하며, 그중 하나가 실패하면 catch 절로 직행합니다.

 

같은 기능을 콜백으로 구현하려면 아래처럼 해야 합니다.

import { appendFile, readFile } from "fs";

function appendRead(
    path: string,
    data: string,
    callback: (error: Error | null, result: string | null) => void
){
    appendFile(path, data, error => {
        if(error){
            return callback(error, null)
        }
        readFile(path, (error, result) => {
            if(error){
                return callback(error, null)
            }
            callback(null, result)
        })
    })
}

이제부터 이 기능을 제공하는 Promise API를 설계할 것입니다.

 

가볍게 시작해 보겠습니다.

class Promise {
}

이어서 new Promise는 실행자(executor)라고 부르는 함수를 인수로 받으며, Promise 구현에서 resolve 함수와 reject 함수를 인수로 건네 이 함수를 호출할 것입니다.

type Executor = (
    resolve: Function,
    reject: Function
) => void
class Promise {
    constructor(f: Executor){}
}

resolve와 reject는 어떻게 동작할까요?

 

다음 코드를 보면서 fs.readFile 같은 콜백 기반의 NodeJS API를 Promise 기반의 API에서 어떻게 수동으로 감쌀 수 있는지 생각해 봅시다.

import { readFile } from "fs";

readFile(path, (error, result) => {
    // 블라 블라
})

Promise 구현에서 이 API를 감싸면 다음과 같은 모습이 됩니다.

import { readFile } from "fs"

function readFilePromise(path: string): Promise<string> {
    return new Promise((resolve, reject) => {
        readFile(path, (error, result)=>{
            if(error){
                reject(error)
            } else {
                resolve(result)
            }
        })
    })
}

resolve의 매개변수 타입은 어떤 API를 사용하는지에 따라 달라지며, reject의 매개변수 타입은 항상 Error 유형이 됩니다.

 

구현으로 돌아와서 안전하지 않았던 Function 타입을 더 구체적인 타입으로 교체해 개선해 보겠습니다.

type Executor<T, E extends Error> = (
    resolve: (result: T) => void,
    reject: (error: E) => void
) => void

Promise만 보고도 Promise가 어떤 타입으로 해석(resolve)될지를 알고자 하므로 Promise를 제네릭으로 만들고 그 생성자에서 자신의 타입 매개변수들을 Executor 타입에 전달할 것입니다.

type Executor<T, E extends Error> = (
    resolve: (result: T) => void,
    reject: (error: E) => void
) => void

class Promise<T, E extends Error> {
    constructor(f: Executor<T, E>){}
}

Promise의 생성자 API를 정의했고 어떤 타입을 다룰 것인지도 이해했습니다.

 

이제 API 연쇄에 관해 생각해 보겠습니다.

 

Promise를 통해 연이어 실행하면서 결과를 전달하고 예외를 잡게끔 하고 싶은 연산자들은 무엇인가요?

 

맨 위에 등장한 코드의 then과 catch가 이 연산에 해당합니다.

 

Promise 타입에 이들을 추가하겠습니다.

type Executor<T, E extends Error> = (
    resolve: (result: T) => void,
    reject: (error: E) => void
) => void

class Promise<T, E extends Error> {
    constructor(f: Executor<T, E>){}
    then<U, F extends Error>(g: (result: T) => Promise<U, F>): Promise<U, F>
    catch<U, F extends Error>(g: (error: E) => Promise<U, F>): Promise<U, F>
}

이러면 이 then과 catch를 이용해 Promise 여러 개를 연쇄적으로 호출할 수 있습니다.

 

then은 성공한 Promise의 결과를 새 Promise로 매핑하며, catch는 reject 시 에러를 새 Promise로 매핑합니다.

 

다음으로 then을 활용한 모습을 살펴보겠습니다.

let a: () => Promise<string, TypeError> = // ... 블라 블라
let b: (s: string) => Promise<number, never> = // ... 블라 블라
let c: () => Promise<boolean, RangeError> = // ... 블라 블라

a()
.then(b)
.catch(e => c()) // b는 에러가 아니므로 a가 에러일 때 호출됨
.then(result => console.info('됨!', result))
.catch(e => console.error('에러!', e))

타입 b의 두 번째 인수 타입은 never이므로 b는 절대 에러를 던지지 않음을 의미하며 첫 번째 catch 구문은 a가 에러일 때만 호출됩니다.

 

하지만 Promise를 이용하면 a가 에러를 던질 수 있지만 b는 그렇지 않을 것이라는 사실을 신경 쓸 필요가 없습니다.

 

a가 성공하면 Promise를 b로 매핑하고, 그렇지 않으면 첫 번째 catch 구문을 실행하면서 Promise를 c로 매핑하기 때문입니다.

 

기존의 try/catch 구문의 동작을 흉내 낸 것으로 마치 동기식 동작에 적용되는 try/catch를 비동기 동작에 적용하는 것과 같은 효과를 제공합니다.

Promise 상태 머신

Promise가 실제 예외를 던지는 상황도 처리해야 합니다.

 

then과 catch를 구현할 때 코드를 try/catch로 감싸고 catch 구문에서 거절하는 식으로 처리하면 됩니다.

 

구체적인 의미는 다음과 같습니다.

  1. 모든 Promise는 거절될 수 있는 위험이 있으며, 정적으로 이를 확인할 수 없습니다.
  2. Promise가 거부되었다고 항상 Error인 것은 아닙니다. TypeScript는 어쩔 수 없이 JavaScript의 동작을 상속받는데, JavaScript는 throw로 모든 것을 던질 수 있기 때문입니다. 따라서 거부된 결과가 Error의 서브 타입이라고 간주할 수 없습니다.

이를 감안하여, 에러 타입을 지정하지 않아도 되게끔 Promise 타입을 조금 풀어줍니다.

type Executor<T> = (
    resolve: (result: T) => void,
    reject: (error: unknown) => void
) => void

class Promise<T> {
    constructor(f: Executor<T>){}
    then<U>(g: (result: T) => Promise<U>): Promise<U>{
        // 블라 블라
    }
    catch<U>(g: (error: unknown) => Promise<U>): Promise<U>{
        // 블라 블라
    }
}

이렇게 Promise 인터페이스를 완성했습니다:)

반응형