- 지금까지는 어떤 입력을 받아 이를 처리하고 완료하는 단계를 순서대로 수행하는 동기 프로그래밍을 다뤘다.
- 하지만 실제로는 네트워크 요청을 보내고, 데이터 베이스 및 파일 시스템과 상호작용하는 동작을 수행해야 하므로 비동기 API(콜백, 프로미스, 스트림)를 사용하게 된다.
- 자바스크립트는 이러한 비동기 작업을 처리할 때 멀티 스레드를 지원하는 자바나 C++와 다르게 스레드 하나로 비동기 작업을 처리한다.
- 자바스크립트는 이벤트 루프 기반의 동시성 모델을 이용해 멀티스레드 기반 프로그래밍에서 공통적으로 나타나는 문제점을 해결한다. (동기화된 데이터 타입의 오버헤드 등)
타입스크립트와 비동기 프로그래밍
- 비동기 프로그래밍은 코드를 한 줄 씩 따라가며 이해할 수 있는 구조가 아니기 때문에 이해가 어렵다.
- 타입스크립트는 비동기 프로그램을 더 잘 이해할 수 있는 도구를 제공한다.
- 타입을 이용하면 비동기 작업을 추적할 수 있다.
- async/awiat 를 이용하면 비동기 프로그래밍을 동기 프로그래밍과 비슷한 관점에서 이해할 수 있다
- 멀티스레드 프로그램에서 엄격한 메시지 전달 프로토콜을 지정하도록 할 수 있다.
1. 자바스크립트의 이벤트 루프
setTimeout(() => console.log("A"), 1)
setTimeout(() => console.log("B"), 2)
console.log('C')
// C -> A -> B
비동기 API인 setTimeout를 사용하면 결과가 코드 순서대로 출력되지 않는다. 어떻게 동작하는 걸까?
- 먼저 메인 자바스크립트 스레드는 setTimeout이라는 비동기 API를 호출한다.
- 비동기 작업이 완료되면 플랫폼(브라우저)는 태스크를 이벤트 큐에 추가한다. 비동기 연산 결과를 메인 스레드로 전달한다. 태스크에는 호출 자체와 관련된 메타 정보와 메인 스레드에 연결된 콜백 함수의 참조가 들어있다
- 메인 스레드의 콜 스택이 비면(동기 코드가 모두 실행되면) 이벤트 큐에 남아 있는 태스크가 있는지 확인한다. 대기 중인 태스크가 있으면 플랫폼은 해당 태스크를 콜 스택에 넣고 실행한다.
- 실행 후 제어는 메인 스레드 함수로 반환된다.
- 이후 메인 스레드의 함수 호출이 끝나고 콜 스택이 다시 비면 플랫폼은 다시 이벤트 큐에 대기 중인 태스크가 있는지 확인한다.
- 콜 스택과 이벤트 큐가 모두 비고, 모든 비동기 네이티브 API 호출이 완료될 때까지 이 과정을 반복한다.
2. 콜백 사용하기
- 비동기 자바스크립트 프로그램의 기본 단위는 콜백(callback)이다.
- 콜백은 다른 함수에 인수 형태로 전달되는 함수를 뜻한다.
- Nodejs 네이티브 API는 콜백의 첫 번째 매개변수로 에러 또는 null을, 두 번째 매개변수로 결과 또는 null 타입을 가져야 한다.
function readFile(
path: string,
options: { encoding: string, flag?: string },
callback: (err:Error | null, data: string | null) => void
): void
- 예를 들어, 아파치 접근 로그를 읽고 쓰는 Nodejs 프로그램을 살펴보자.
- readFile과 appendFile은 비동기로 동작하는 함수이다. 따라서 API 호출 순서는 파일시스템에서 실행할 동작 순서를 결정하지 않는다.
import * as fs from 'fs'
// 아파치 서버의 접근 로그에서 데이터 읽기
fs.readFile(
'/var/log/apache2/access_log',
{ encoding: 'utf8' },
(error, data) => {
if (error) {
console.error(error)
return
}
console.log(data)
}
)
// 동시에 같은 접근 로그에 기록하기
fs.appendFile(
'/var/log/apache2/access_log',
'New accesss log entry',
error => {
if (error) {
console.error(error)
}
}
)
- 비동기를 처리하기 위해 콜백을 사용하면 여러 가지 문제점이 생긴다.
- 타입만으로는 함수가 비동기인지 알 수 없다.
- 콜백 방식은 연달아 수행되는 작업을 표현하기 어렵다. (콜백 피라미드, 콜백 헬)
// 콜백호출이 중첩되면서 코드가 깊어지는 문제/패턴 : 콜백 지옥(=멸망의 피라미드)
async((err1, res1) => {
if (res1) {
async2(res1, (err2, res2) => {
if (res2) {
async3(res2, (err3, res3) => {
// ...
})
}
})
}
})
이런 경우, 각 동작을 독립적인 함수로 만들어 사용하는 것이 좋다.
- 간단한 비동기 작업에는 콜백을 사용한다.
- 하지만 비동기 작업이 여러 개로 늘어나면 문제가 금방 복잡해지는 것을 알 수 있다.
3. 콜백 대신 프로미스 사용하기
- 콜백의 복잡성을 해결하기 위해 비동기 작업을 추상화하여 서로 조합하거나 연결하는 일을 하는 Promise를 사용할 수 있다.
- 사용 형식은 아래와 같다. 필요한 비동기 작업을 체인 형식으로 묶어 콜백 헬이 발생하지 않는다.
// 프로미스 사용하기
// 파일에 내용을 추가하고 결과를 다시 읽어오는 프로미스
function appendAndReadPromise(path: string, data: string): Promise<string> {
return appendPromise(path, data)
.then(() => readPromise(path))
.catch(error => console.error(error))
}
- 해당 기능(then, catch로 프로미스를 체이닝)을 제공하는 Promise API를 설계해보자.
// Promise는 실행자(executor)를 함수를 인수로 받고,
// Promise 구현에서 resolve, reject를 해당 함수의 인수로 전달하면서 호출한다.
type Executor = (resolve: Function, reject: Function) => void;
class Promise {
constructor(f: Executor) {}
}
// Promise 구현
import { readFile } from "fs";
// Promise를 반환하는 Promise
function readFilePromise(path: string): Promise<string> {
return new Promise((resolve, reject) => {
readFile(path, (error, result) => {
// 에러가 발생하면 error를 인수로 하는 reject 함수 실행
if (error) {
reject(error);
} else {
// 그렇지 않으면 result를 인수로 하는 resolve 함수 실행
resolve(result);
}
});
});
}
- resolve의 매개변수 타입은 API에 따라 달라진다. 현재는 result 타입이 매개변수 타입이 된다. 그리고 reject의 매개변수 타입은 항상 Error 타입이 된다.
- 이에 따라 실행자(Executor)의 인수인 resolve, reject 함수의 타입을 조금 더 구체화해보면 아래와 같다.
type Executor<T, E extends Error> = (
resolve: (result: T) => void, // 성공 시
reject: (error: E) => void // 실패 시
) => void
- Executor에서 받은 resolve, reject 함수의 매개변수 타입(T, E)를 기반으로 Promise를 제네릭으로 만들고, 그 생성자에서 자신의 타입 매개변수들을 Executor 타입에 전달할 것이다.
- Promise를 체이닝 형식으로 사용하려면 then과 catch 메서드가 필요하다. then은 성공한 Promise의 결과를 새 Promise로 매핑하고, catch는 거부 시 에러를 새 Promise로 매핑한다.
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> { return new Promise(() => {}) }
catch<U, F extends Error>(g: (error: E) => Promise<U, F>): Promise<U, F> { return new Promise(() => {}) }
}
- then을 아래처럼 활용할 수 있다.
let a: () => Promise<string, TypeError> = () => { return new Promise(() => {}) }
let b: (s: string) => Promise<number, never> = () => { return new Promise(() => {}) }
let c: () => Promise<boolean, RangeError> = () => { return new Promise(() => {}) }
a()
.then(b)
.catch((e) => c()) // b는 에러를 던지지 않으므로(never) a에서 에러 발생 시
.then((result) => console.log("Done", result)) // b 또는 c가 성공할 시
.catch((e) => console.error("Error ", e)); // c에서 에러가 발생할 시
- Promise가 실제 예외를 던지는 상황도 처리해야 한다. then과 catch를 구현할 때 코드를 try/catch로 감싸고 catch 구문에서 거절하는 식으로 처리하면 된다.
- 모든 Promise는 거절될 수 있는 위험이 있고, 정적으로 이를 확인할 수 없다.
- Promise가 거부되었다고 항상 Error인 것은 아니다. 타입스크립트는 자바스크립트를 상속받는데 자바스크립트는 throw로 에러 뿐 아니라 문자열, 함수, Promise 등을 던질 수 있기 때문이다.
- 이를 감안해 에러 타입을 지정하지 않아도 되도록 Promise 타입을 조금 느슨하게 풀어준다.
type Executor<T> = (
resolve: (result: T) => void,
reject: (error: unknown) => void
) => void
class Promise<T> {
constructor(f: Executor) {}
then<U>(g: (result: T) => Promise<U>): Promise<U> {}
catch<U>(g: (error: unknown) => Promise<U>): Promise<U> {}
}
4. async와 await
5. 비동기 스트림
- 비동기 스트림은 모두 여러 개의 데이터로 이루어지며, 각각의 데이터를 미래의 어떤 시점에 받게 된다.
- 예를 들어, 파일시스템에서 파일의 일부를 읽거나 폼을 작성할 때 여러 키를 입력하는 경우 등이 있다.
- 이런 상황은 NodeJS의 Event Emmiter 같은 이벤트 방출기를 이용하거나 RxJS 같은 리액티브 프로그래밍 라이브러리를 이용해 설계할 수 있다. 두 방식의 차이는 콜백과 프로미스 관계와 비슷하다. 이벤트는 빠르고 가볍지만 리액티브 프로그래밍 라이브러리는 강력하며 이벤트 스트림을 조합하고 연결하는 기능을 제공한다.
이벤트 방출기
이벤트 방출기는 채널로 이벤트를 방출하고 채널에서 발생하는 이벤트를 리스닝하는 API를 제공한다.
interface Emitter{
// 이벤트 방출
emit(channel: string, value: unknown): void
// 이벤트가 방출되었을 때 어떤 작업을 수행
on(channel: string, f: (value: unknown) => void): void
}
on API는 이벤트가 방출되었을 때 수행할 작업을 명시한다. 이 API의 콜백 인수 타입은 클라이언트가 방출하는 채널에 따라 달라질 수 있다.
이를 오버로드된 타입으로 표현할 수 있지만, { 이벤트명 : 콜백함수 인수 }과 같이 매핑된 타입을 이용하면 더 깔끔하다.
이벤트 이름과 인수를 하나의 형태로 따로 빼내고, 리스너와 방출기를 생성하는 이 패턴은 실무의 타입스크립트에서 자주 볼 수 있다.
type Events = {
ready: void
erorr: Error
reconnecting: { attempt: number, delay: number }
}
type RedisClient = {
// 리스너
on<E extends keyof Events>(
event: E,
f: (arg: Events[E]) => void
): void
// 방출기
emit<E extends keyof Events>(
event: E,
arg: Events[E]
):
이렇게 방출기의 타입을 지정하면 타입을 잘못 사용하거나 빼먹는 실수를 방지할 수 있다. 또한 코드 편집기가 리스닝할 수 있는 이벤트와 이벤트의 콜백 매개변수 타입을 제시해주므로 다른 개발자에게 코드가 하는 일을 설명하는 문서화 역할도 제공한다.
'FrontEnd > TypeScript' 카테고리의 다른 글
[TypeScript] React-hook-form과 제네릭 사용하기 (36) | 2024.02.04 |
---|---|
[TypeScript] 상수 선언에 왜 enum 대신 as const를 사용했나요? (0) | 2023.11.14 |
[TypeScript] 에러 처리 (0) | 2023.06.05 |
[TypeScript] 가변성(슈퍼타입/서브타입 파악하기) & 할당성 & 타입 넓히기 (0) | 2023.06.03 |
[TypeScript] 함수의 타입 (0) | 2023.06.03 |
- 지금까지는 어떤 입력을 받아 이를 처리하고 완료하는 단계를 순서대로 수행하는 동기 프로그래밍을 다뤘다.
- 하지만 실제로는 네트워크 요청을 보내고, 데이터 베이스 및 파일 시스템과 상호작용하는 동작을 수행해야 하므로 비동기 API(콜백, 프로미스, 스트림)를 사용하게 된다.
- 자바스크립트는 이러한 비동기 작업을 처리할 때 멀티 스레드를 지원하는 자바나 C++와 다르게 스레드 하나로 비동기 작업을 처리한다.
- 자바스크립트는 이벤트 루프 기반의 동시성 모델을 이용해 멀티스레드 기반 프로그래밍에서 공통적으로 나타나는 문제점을 해결한다. (동기화된 데이터 타입의 오버헤드 등)
타입스크립트와 비동기 프로그래밍
- 비동기 프로그래밍은 코드를 한 줄 씩 따라가며 이해할 수 있는 구조가 아니기 때문에 이해가 어렵다.
- 타입스크립트는 비동기 프로그램을 더 잘 이해할 수 있는 도구를 제공한다.
- 타입을 이용하면 비동기 작업을 추적할 수 있다.
- async/awiat 를 이용하면 비동기 프로그래밍을 동기 프로그래밍과 비슷한 관점에서 이해할 수 있다
- 멀티스레드 프로그램에서 엄격한 메시지 전달 프로토콜을 지정하도록 할 수 있다.
1. 자바스크립트의 이벤트 루프
setTimeout(() => console.log("A"), 1)
setTimeout(() => console.log("B"), 2)
console.log('C')
// C -> A -> B
비동기 API인 setTimeout를 사용하면 결과가 코드 순서대로 출력되지 않는다. 어떻게 동작하는 걸까?
- 먼저 메인 자바스크립트 스레드는 setTimeout이라는 비동기 API를 호출한다.
- 비동기 작업이 완료되면 플랫폼(브라우저)는 태스크를 이벤트 큐에 추가한다. 비동기 연산 결과를 메인 스레드로 전달한다. 태스크에는 호출 자체와 관련된 메타 정보와 메인 스레드에 연결된 콜백 함수의 참조가 들어있다
- 메인 스레드의 콜 스택이 비면(동기 코드가 모두 실행되면) 이벤트 큐에 남아 있는 태스크가 있는지 확인한다. 대기 중인 태스크가 있으면 플랫폼은 해당 태스크를 콜 스택에 넣고 실행한다.
- 실행 후 제어는 메인 스레드 함수로 반환된다.
- 이후 메인 스레드의 함수 호출이 끝나고 콜 스택이 다시 비면 플랫폼은 다시 이벤트 큐에 대기 중인 태스크가 있는지 확인한다.
- 콜 스택과 이벤트 큐가 모두 비고, 모든 비동기 네이티브 API 호출이 완료될 때까지 이 과정을 반복한다.
2. 콜백 사용하기
- 비동기 자바스크립트 프로그램의 기본 단위는 콜백(callback)이다.
- 콜백은 다른 함수에 인수 형태로 전달되는 함수를 뜻한다.
- Nodejs 네이티브 API는 콜백의 첫 번째 매개변수로 에러 또는 null을, 두 번째 매개변수로 결과 또는 null 타입을 가져야 한다.
function readFile(
path: string,
options: { encoding: string, flag?: string },
callback: (err:Error | null, data: string | null) => void
): void
- 예를 들어, 아파치 접근 로그를 읽고 쓰는 Nodejs 프로그램을 살펴보자.
- readFile과 appendFile은 비동기로 동작하는 함수이다. 따라서 API 호출 순서는 파일시스템에서 실행할 동작 순서를 결정하지 않는다.
import * as fs from 'fs'
// 아파치 서버의 접근 로그에서 데이터 읽기
fs.readFile(
'/var/log/apache2/access_log',
{ encoding: 'utf8' },
(error, data) => {
if (error) {
console.error(error)
return
}
console.log(data)
}
)
// 동시에 같은 접근 로그에 기록하기
fs.appendFile(
'/var/log/apache2/access_log',
'New accesss log entry',
error => {
if (error) {
console.error(error)
}
}
)
- 비동기를 처리하기 위해 콜백을 사용하면 여러 가지 문제점이 생긴다.
- 타입만으로는 함수가 비동기인지 알 수 없다.
- 콜백 방식은 연달아 수행되는 작업을 표현하기 어렵다. (콜백 피라미드, 콜백 헬)
// 콜백호출이 중첩되면서 코드가 깊어지는 문제/패턴 : 콜백 지옥(=멸망의 피라미드)
async((err1, res1) => {
if (res1) {
async2(res1, (err2, res2) => {
if (res2) {
async3(res2, (err3, res3) => {
// ...
})
}
})
}
})
이런 경우, 각 동작을 독립적인 함수로 만들어 사용하는 것이 좋다.
- 간단한 비동기 작업에는 콜백을 사용한다.
- 하지만 비동기 작업이 여러 개로 늘어나면 문제가 금방 복잡해지는 것을 알 수 있다.
3. 콜백 대신 프로미스 사용하기
- 콜백의 복잡성을 해결하기 위해 비동기 작업을 추상화하여 서로 조합하거나 연결하는 일을 하는 Promise를 사용할 수 있다.
- 사용 형식은 아래와 같다. 필요한 비동기 작업을 체인 형식으로 묶어 콜백 헬이 발생하지 않는다.
// 프로미스 사용하기
// 파일에 내용을 추가하고 결과를 다시 읽어오는 프로미스
function appendAndReadPromise(path: string, data: string): Promise<string> {
return appendPromise(path, data)
.then(() => readPromise(path))
.catch(error => console.error(error))
}
- 해당 기능(then, catch로 프로미스를 체이닝)을 제공하는 Promise API를 설계해보자.
// Promise는 실행자(executor)를 함수를 인수로 받고,
// Promise 구현에서 resolve, reject를 해당 함수의 인수로 전달하면서 호출한다.
type Executor = (resolve: Function, reject: Function) => void;
class Promise {
constructor(f: Executor) {}
}
// Promise 구현
import { readFile } from "fs";
// Promise를 반환하는 Promise
function readFilePromise(path: string): Promise<string> {
return new Promise((resolve, reject) => {
readFile(path, (error, result) => {
// 에러가 발생하면 error를 인수로 하는 reject 함수 실행
if (error) {
reject(error);
} else {
// 그렇지 않으면 result를 인수로 하는 resolve 함수 실행
resolve(result);
}
});
});
}
- resolve의 매개변수 타입은 API에 따라 달라진다. 현재는 result 타입이 매개변수 타입이 된다. 그리고 reject의 매개변수 타입은 항상 Error 타입이 된다.
- 이에 따라 실행자(Executor)의 인수인 resolve, reject 함수의 타입을 조금 더 구체화해보면 아래와 같다.
type Executor<T, E extends Error> = (
resolve: (result: T) => void, // 성공 시
reject: (error: E) => void // 실패 시
) => void
- Executor에서 받은 resolve, reject 함수의 매개변수 타입(T, E)를 기반으로 Promise를 제네릭으로 만들고, 그 생성자에서 자신의 타입 매개변수들을 Executor 타입에 전달할 것이다.
- Promise를 체이닝 형식으로 사용하려면 then과 catch 메서드가 필요하다. then은 성공한 Promise의 결과를 새 Promise로 매핑하고, catch는 거부 시 에러를 새 Promise로 매핑한다.
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> { return new Promise(() => {}) }
catch<U, F extends Error>(g: (error: E) => Promise<U, F>): Promise<U, F> { return new Promise(() => {}) }
}
- then을 아래처럼 활용할 수 있다.
let a: () => Promise<string, TypeError> = () => { return new Promise(() => {}) }
let b: (s: string) => Promise<number, never> = () => { return new Promise(() => {}) }
let c: () => Promise<boolean, RangeError> = () => { return new Promise(() => {}) }
a()
.then(b)
.catch((e) => c()) // b는 에러를 던지지 않으므로(never) a에서 에러 발생 시
.then((result) => console.log("Done", result)) // b 또는 c가 성공할 시
.catch((e) => console.error("Error ", e)); // c에서 에러가 발생할 시
- Promise가 실제 예외를 던지는 상황도 처리해야 한다. then과 catch를 구현할 때 코드를 try/catch로 감싸고 catch 구문에서 거절하는 식으로 처리하면 된다.
- 모든 Promise는 거절될 수 있는 위험이 있고, 정적으로 이를 확인할 수 없다.
- Promise가 거부되었다고 항상 Error인 것은 아니다. 타입스크립트는 자바스크립트를 상속받는데 자바스크립트는 throw로 에러 뿐 아니라 문자열, 함수, Promise 등을 던질 수 있기 때문이다.
- 이를 감안해 에러 타입을 지정하지 않아도 되도록 Promise 타입을 조금 느슨하게 풀어준다.
type Executor<T> = (
resolve: (result: T) => void,
reject: (error: unknown) => void
) => void
class Promise<T> {
constructor(f: Executor) {}
then<U>(g: (result: T) => Promise<U>): Promise<U> {}
catch<U>(g: (error: unknown) => Promise<U>): Promise<U> {}
}
4. async와 await
5. 비동기 스트림
- 비동기 스트림은 모두 여러 개의 데이터로 이루어지며, 각각의 데이터를 미래의 어떤 시점에 받게 된다.
- 예를 들어, 파일시스템에서 파일의 일부를 읽거나 폼을 작성할 때 여러 키를 입력하는 경우 등이 있다.
- 이런 상황은 NodeJS의 Event Emmiter 같은 이벤트 방출기를 이용하거나 RxJS 같은 리액티브 프로그래밍 라이브러리를 이용해 설계할 수 있다. 두 방식의 차이는 콜백과 프로미스 관계와 비슷하다. 이벤트는 빠르고 가볍지만 리액티브 프로그래밍 라이브러리는 강력하며 이벤트 스트림을 조합하고 연결하는 기능을 제공한다.
이벤트 방출기
이벤트 방출기는 채널로 이벤트를 방출하고 채널에서 발생하는 이벤트를 리스닝하는 API를 제공한다.
interface Emitter{
// 이벤트 방출
emit(channel: string, value: unknown): void
// 이벤트가 방출되었을 때 어떤 작업을 수행
on(channel: string, f: (value: unknown) => void): void
}
on API는 이벤트가 방출되었을 때 수행할 작업을 명시한다. 이 API의 콜백 인수 타입은 클라이언트가 방출하는 채널에 따라 달라질 수 있다.
이를 오버로드된 타입으로 표현할 수 있지만, { 이벤트명 : 콜백함수 인수 }과 같이 매핑된 타입을 이용하면 더 깔끔하다.
이벤트 이름과 인수를 하나의 형태로 따로 빼내고, 리스너와 방출기를 생성하는 이 패턴은 실무의 타입스크립트에서 자주 볼 수 있다.
type Events = {
ready: void
erorr: Error
reconnecting: { attempt: number, delay: number }
}
type RedisClient = {
// 리스너
on<E extends keyof Events>(
event: E,
f: (arg: Events[E]) => void
): void
// 방출기
emit<E extends keyof Events>(
event: E,
arg: Events[E]
):
이렇게 방출기의 타입을 지정하면 타입을 잘못 사용하거나 빼먹는 실수를 방지할 수 있다. 또한 코드 편집기가 리스닝할 수 있는 이벤트와 이벤트의 콜백 매개변수 타입을 제시해주므로 다른 개발자에게 코드가 하는 일을 설명하는 문서화 역할도 제공한다.
'FrontEnd > TypeScript' 카테고리의 다른 글
[TypeScript] React-hook-form과 제네릭 사용하기 (36) | 2024.02.04 |
---|---|
[TypeScript] 상수 선언에 왜 enum 대신 as const를 사용했나요? (0) | 2023.11.14 |
[TypeScript] 에러 처리 (0) | 2023.06.05 |
[TypeScript] 가변성(슈퍼타입/서브타입 파악하기) & 할당성 & 타입 넓히기 (0) | 2023.06.03 |
[TypeScript] 함수의 타입 (0) | 2023.06.03 |