이번 장은 비동기 API를 사용하는 코드를 쉽게 작성하게 하는 Promise 클래스와 async/await 구문에 대해 살펴보자
7-1 비동기 콜백 함수
이번 장은 노드제이에스가 제공하는 readFile과 같은 비동기 API를 예로 들고 있기 때문에 tsconfig.json 파일에 별도의 설정이 필요하다.
// tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es2015",
"moduleResolution": "node",
"outDir": "dist",
"baseUrl": ".",
"sourceMap": true,
"downlevelIteration": true, // 이 부분을 true로
"noImplicitAny": false,
"paths": { "*": ["node_modules/*"] }
},
"include": ["src/**/*"]
}
동기와 비동기 API
Node.js에서는 파일 시스템과 관련된 기능을 모아둔 fs 패키지를 제공한다. fs 패키지는 같은 기능을 동기와 비동기 버전으로 나누어 제공한다. 파일을 읽는 기능은 동기 버전인 readFileSync와 비동기 버전인 readFile로 제공한다.
먼저, src 디렉터리에 test.ts파일을 만들고 다음처럼 작성하자. test.ts 파일은 package.json 파일을 읽어 화면에 출력하는데, 이를 동기와 비동기 방식으로 구현한 예를 보여준다.
import { readFileSync, readFile } from 'fs'
// package.json 파일을 동기 방식으로 읽는 예
console.log('read package.json using synchronous api...')
const buffer: Buffer = readFileSync('./package.json')
console.log(buffer.toString())
// package.json 파일을 비동기 방식으로 읽는 예
readFile('./package.json', (error: Error, buffer: Buffer) => {
console.log('read package.json using asynchronous api...')
console.log(buffer.toString())
})
// Promise 와 async/await 구문을 사용한 예
const readFilePromise = (filename: string): Promise<string> =>
new Promise<string>((resolve, reject) => {
readFile(filename, (error: Error, buffer: Buffer) => {
if (error) reject(error)
else resolve(buffer.toString())
})
});
(async () => {
const content = await readFilePromise('./package.json')
console.log('read package.json using Promise and async/await...')
console.log(content)
})()
운영체제가 제공하는 서비스를 API라고 한다. API는 타입스크립트와 같은 프로그래밍 언어의 함수 형태로 제공된다. 그런데 API 함수는 일반 함수와 달리 하드디스크에 저장된 파일을 읽는 등 실행 시 물리적인 시간이 소요된다.
따라서 파일 내용을 모두 읽을 때까지 프로그램의 동작을 잠시 멈추는 동기 방식의 API와 프로그램의 동작을 멈추지 않는 대신 결과를 콜백 함수로 얻게 하는 비동기 방식의 API를 제공한다. 즉, 방식만 다를 뿐 똑같은 기능을 제공한다.
비동기 API의 콜백 함수를 특별히 비동기 콜백 함수라고 하낟. 일반 함수와 달리 API의 물리적인 동작 결과를 수신하는 목적으로만 사용된다.
readFileSync와 readFile API
Node.js에서 파일 읽기는 readFileSync라는 이름의 API를 사용해서 구현한다. readFileSync는 파일을 읽어서 Buffer라는 타입으로 전달해 준다.
import {readFileSync} from "fs"
readFileSync(path:string): Buffer
Buffer 는 Node.js가 제공하는 클래스로 바이너리 데이터를 저장하는 기능을 수행한다. Buffer의 데이터를 문자열로 만들려고 할 때는 Buffer의 toString 메서드를 사용하면 된다. API 이름이 “xxxSync”인 것은 모두 동기 방식으로 동작한다.
다음 코드는 디렉터리의 package.json 파일을 readFileSync를 사용해 바이너리 데이터로 읽은 다음, Buffer의 toString 메서드를 사용해 화면에 출력한다.
import {readFileSync} from 'fs'
const buffer: Buffer = readFileSync('./package.json') // package.json 파일의 바이너리 내용
const content: string = buffer.toString()
console.log(content) // package.json 파일의 텍스트 내용
readFileSync('.') // Error: EISDIR: illegal operation on a directory, read
Node.js는 동기 버전인 readFileSync 이외에도 비동기 버전인 readFile도 제공한다.
import {readFile} from "fs"
readFile(파일 경로, 콜백함수:(error: Error, buffer: Buffer)=> void)
동기 버전과 달리 예외가 발생하면 이 예외를 콜백 함수의 첫 번째 매개변수에 전달해 준다.
import {readFile} from 'fs'
readFile('./package.json', (err: Error, buffer: Buffer) => {
if(err) throw err
else {
const content: string = buffer.toString()
console.log(content)
}
})
이 코드는 비동기 방식으로 동작하기 때문에 멈추지 않고 바로 다음 코드를 실행한다.
단일 스레드와 비동기 API
자바스크립트는 단일 스레드로 동작하므로 될 수 있으면 readFileSync와 같은 동기 API를 사용하지 말아야 한다.
타입스크립트 코드에서 동기 API가 실행되면, 운영체제는 동기 API의 작업 결과를 함수의 반환값으로 돌려주어야 한다. 이 때문에 운영체제는 동기 API가 실행된 코드를 일시적으로 멈춘 다음, 또 다른 스레드에서 실제 작업을 실행해 결과를 얻으면 그때서야 비로소 잠시 멈췄던 동기 API를 실행하면서 결과값을 반환한다.
동기 API의 이러한 동작 방식은 코드를 작성하기 쉽게 해주지만 프로그램의 반응성을 떨어뜨린다. 만약, 동기 API를 호출하는 자바스크립트 코드가 웹 서버에서 실행되면, 단일 스레드로 동작하는 자바스크립트의 물리적인 특성상 웹 서버는 동기 API가 결과값을 반환할 때까지 일시적으로 멈춘다. 또한, 웹 브라우저에서 이 웹 서버로 접속이 안 되는 현상이 발생한다.
타입스크립트는 이처럼 단일 스레드에서 동작하므로 코드를 작성할 때 항상 비동기 방식으로 동작하는 API를 사용해 프로그램의 반응성을 훼손하지 말아야 한다.
콜백 지옥
콜백 함수 또한 또 다른 비동기 API를 호출하는 코드를 만들 때 코드가 매우 복잡해진다.
import {readFile} from 'fs'
readFile('./package.json', (err: Error, buffer: Buffer) => {
if(err) throw err
else {
const content: string = buffer.toString()
console.log(content)
readFile('./tsconfig.json', (err: Error, buffer: Buffer) => {
if(err) throw err
else {
const content: string = buffer.toString()
console.log(content)
}
})
}
})
이처럼 복잡한 형태로 얽힌 콜백 구조를 콜백 지옥이라고 한다.
7-2 Promise 이해하기
Promise 클래스를 사용하려면 일단 new 연산자를 적용해 프로미스 객체를 만들어야 한다. 그리고 new 연산자로 프로미스 객체를 만들 때 다음처럼 콜백 함수를 제공해야 한다.
const promise = new Promise(콜백 함수)
여기서 Promise 함수는 resolve와 reject라는 두 개의 매개변수를 가진다.
타입스크립트에서 Promise는 제네릭 클래스 형태로 사용한다.
const numPromise: Promise<number> = new Promise<number>(콜백 함수)
resolve와 reject 함수
Promise 코드를 작성해 보자.
import { readFile } from 'fs'
export const readFilePromise = (filename: string): Promise<string> =>
new Promise<string>((resolve: (value: string) => void, reject: (error: Error) => void) => {
readFile(filename, (err: Error, buffer: Buffer) => {
if (err) reject(err)
else resolve(buffer.toString())
})
})
코드를 보면 에러가 발생할 때는 reject(err) 함수를 호출하고, 없으면 파일 내용이 담긴 buffer 를 이용해 resolve(buffer) 함수를 호출한다.
이제 다음 코드는 readFilePromise 함수가 반환하는 Promise 타입 객체의 then, catch, finally 메서드를 메서드 체인 형태로 사용한다.
import { readFilePromise } from './readFilePromise'
readFilePromise('./package.json')
.then((content: string) => {
console.log(content)
return readFilePromise('./tsconfig.json')
})
.then((content: string) => {
console.log(content)
return readFilePromise('.')
})
.catch((err: Error) => console.log(err.message))
.finally(() => console.log('프로그램 종료'))
Promise.reslove 메서드
Promise 클래스는 resolve라는 클래스 메서드를 제공한다. Promise.resolve 형태로 호출하면 항상 값은 then 메서드에서 얻을 수 있다.
Promise.resolve(1).then(value => console.log(value)) // 1
Promise.resolve('hello').then(value => console.log(value)) // hello
Promise.resolve([1, 2, 3]).then(value => console.log(value)) // [ 1 2 3 ]
Promise.resolve({ name: 'Jack', age: 32 }).then(value => console.log(value)) // { name: 'Jack', age: 32 }
Promise.reject 메서드
이 메서드를 호출하면 에러 타입 객체는 항상 catch 메서드의 콜백 함수에서 얻을 수 있다.
Promise.reject(new Error("에러 발생"))
.catch((err: Error) => console.log("error:", err.message)) // error: 에러 발생
then-체인
Promise의 then 인스턴스 메서드를 호출할 때 사용한 콜백 함수는 값을 반환할 수 있다. 이 then에서 반환된 값은 또 다른 then 메서드를 호출해 값을 수신할 수 있다. Promise 객체에 then 메서드를 여러 번 호출하는 코드 형태를 then-체인이라고 한다.
Promise.resolve(1)
.then((value: number) => {
console.log(value) // 1
return Promise.resolve(true)
})
.then((value: boolean) => {
console.log(value) // true
return [1, 2, 3]
})
.then((value: number[]) => {
console.log(value) // [ 1, 2, 3 ]
return { name: "jack", age: 32}
})
.then((value: {name: string, age: number}) => {
console.log(value) // { name: 'jack', age: 32 }
})
Promise.all 메서드
const getAllResolvedResult = <T>(promises: Promise<T>[]) => Promise.all(promises)
getAllResolvedResult<any>([Promise.resolve(true), Promise.resolve("hello")])
.then(result => console.log(result)) // [ true, "hello" ]
getAllResolvedResult<any>([Promise.reject(new Error("error")), Promise.resolve(1)])
.then(result => console.log(result))
.catch(error => console.log('error:', error.message)) // error: error
Promise.all 메서드는 Promise 객체들을 배열 형태로 받아, 모든 객체를 대상으로 해소된 값들의 배열로 만들어 준다. Promise.all 메서드는 이런 내용으로 구성된 또 다른 Promise 객체를 반환하므로 해소된 값들의 배열은 then 메서드를 호출해서 얻어야 한다.만약 reject 객체가 발생하면 더 기다리지 않고 해당 거절값을 담은 객체를 반환한다.
Promise.race 메서드
Array 클래스는 배열의 내용 중 하나라도 조건을 만족하면 true를 반환하는 some이라는 인스턴스 메서드를 제공한다.
const isAnyTrue = (values: boolean[]) => values.some((value => value == true))
console.log(
isAnyTrue([false, true, false]), // true
isAnyTrue([false, false, false]), // false
)
Promise.race 클래스 메서드는 배열에 담긴 프로미스 객체 중 하나라도 해소되면 이 값을 담은 Promise.resolve 객체를 반환한다.
7-3 async와 await 구문
비동기 프로그래밍 코드를 비약적으로 간결하게 구현할 수 있는 async와 await 구문이 제공되기 시작한 이후 자바스크립트를 포함해 많은 프로그래밍 언어가 이 구문을 차용했다.
const test = async() => {
const value = await Promise.resolve(1)
console.log(value) // 1
}
await 키워드
await 키워드는 피연산자의 값을 반환해 준다. 그런데 만일 피연산자가 Promsie객체이면 then 메서드를 호출해 얻은 값을 반환해 준다.
let value = await Promise 객체 혹은 값
async 함수 수정자
await 키워드는 항상 async라는 이름의 함수 수정자가 있는 함수 몸통에서만 사용할 수 있다.
const test1 = async() => {
await Promise ...
}
다음 코드로 async 함수를 구현한 예를 보자
export const test1 = async ()=> {
let value = await 1
console.log(value) // 1
value = await Promise.resolve(1)
console.log(value) // 1
}
async 함수의 두 가지 성질
- 일반 함수처럼 사용할 수 있다.
- Promise 객체로 사용할 수 있다.
async 함수가 반환하는 값의 의미
async 함수는 값을 반환할 수 있다. 이때 반환값은 Promsie 형태로 변환되므로 다음처럼 then 메서드를 호출해 async 함수의 반환값을 얻어야 한다.
const asyncReturn = async() => {
return [1, 2, 3]
}
asyncReturn()
.then(value => console.log(value)) // [1, 2, 3]
async 함수의 예외처리
async 함수에서 다음처럼 예외가 발생하면 프로그램이 비정상으로 종료된다.
const asyncException = async() =>{
throw new Error("error")
}
asyncException // 예외 발생
이 상황을 막으려면 다음처럼 단순히 함수 호출 방식이 아닌, 반환하는 Promise 객체의 catch 메서드를 호출하는 형태로 작성해야 한다.
const asyncException = async () => {
throw new Error("error")
}
asyncException()
.catch(err => console.log('error:', err.message)) // error: error
async 함수와 Promise.all
다음 코드는 package.json과 tsconfig.json 두 파일의 내용을 async 함수와 Promise.all, readFilePromise를 사용해 화면에 출력한다.
import { readFilePromise } from './readFilePromise' // 07-2절의 readFilePromise.ts입니다.
const readFilesAll = async (filenames: string[]) => {
return await Promise.all(filenames.map(filename => readFilePromise(filename)))
}
readFilesAll(['./package.json', './tsconfig.json'])
.then(([packageJson, tsconfigJson]: string[]) => {
console.log('<package.json>: ', packageJson)
console.log('<tsconfig.json>: ', tsconfigJson)
})
.catch(err => console.log('error:', err.message))
readFilesAll 함수는 매개변수 filenames에 담긴 string[] 타입 배열에 map 메서드를 적용해 Promise[] 타입 객체로 전환한다. 이를 다시 Promsie.all 메서드를 사용해 단일 Promise 객체로 만든다. 이 결과로 만들어진 객체에 await 구문을 적용해 해소된 결과값을 반환한다.
readFilesAll 함수를 Promise 객체로 취급해 then과 catch 메서드를 then-체인 형태로 연결한다. 이로써 두 파일의 내용을 출력함과 동시에 예외가 발생하더라도 프로그램이 비정상으로 종료하지 않도록 한다.
'Books > Do It 타입스크립트 프로그래밍' 카테고리의 다른 글
8장 람다 라이브러리 (0) | 2023.02.12 |
---|---|
8장 함수 조합의 원리와 응용 (0) | 2023.01.04 |
6장 반복기와 생성기 (0) | 2022.12.12 |
5장 배열과 튜플 (0) | 2022.12.11 |
4장 함수와 메서드 (0) | 2022.11.29 |