JavaScript에서 함수를 작성할 때 동기(sync)와 비동기(async) 중 하나를 선택해야 합니다.
이 두 가지를 마치 '색상'처럼 구분하는데, 바로 여기서 문제가 시작됩니다.
비동기 함수는 동기 함수와 다른 비동기 함수를 모두 호출할 수 있지만, 동기 함수는 비동기 함수를 직접 호출할 수 없습니다. 동기 함수에서 비동기 함수를 호출하려면 자신도 비동기로 바뀌어야 합니다.
이러한 제약은 코드베이스 전체에 '색상'을 전파하게 만듭니다. 특히 깊은 로직에 있는 함수가 비동기로 변경되면, 그 함수를 호출하는 모든 상위 함수들도 비동기로 변경해야 하는 연쇄 효과(async inflection)가 발생합니다.
// 동기 함수
function processData(data) {
const result = transformData(data);
return result;
}
// 동기 함수가 다른 동기 함수 호출 (문제 없음)
function transformData(data) {
return data.toUpperCase();
}
// 이제 transformData가 비동기 함수로 변경된다면?
async function transformData(data) {
// 외부 API 호출 등의 비동기 작업
const response = await fetch('https://api.example.com', { body: data });
return response.text();
}
// 문제 발생! 동기 함수인 processData에서 비동기 함수 transformData를 호출할 수 없음
// 아래 코드는 작동하지 않음
function processData(data) {
// 에러: await은 async 함수 내에서만 사용 가능
const result = await transformData(data);
return result;
}
// 해결책: processData도 비동기 함수로 변경해야 함
async function processData(data) {
const result = await transformData(data);
return result;
}
// 그러나 이제 processData를 호출하는 모든 함수도 비동기로 변경해야 함
// 이런 식으로 '비동기 전염'이 코드베이스 전체로 퍼짐
이런 변경은 특히 큰 프로젝트나 라이브러리에서 상당한 리팩토링을 필요로 하며, 호환성 문제도 발생시킬 수 있습니다.
널리 사용되는 find-up 같은 라이브러리는 findUp과 findUpSync 두 가지 API를 제공합니다. 코드를 살펴보면 동일한 로직을 두 번 구현하고 있음을 알 수 있습니다. 이런 중복은 유지보수를 어렵게 만들고 번들 크기를 증가시킵니다.
// 일반적인 라이브러리 구현 방식
// findUp.js
// 비동기 버전
export async function findUpfindUpSync(filename, options) {
const directory = await getDirectory(options);
const filePath = await searchFile(directory, filename);
return filePath;
}
// 동기 버전 - 동일한 로직을 중복 구현
export function findUpSync(filename, options) {
const directory = getDirectorySync(options);
const filePath = searchFileSync(directory, filename);
return filePath;
}
// 이 라이브러리를 사용하는 다른 라이브러리도 두 가지 API를 모두 제공해야 함
export async function readNearestPkg() {
const pkgPath = await findUp('package.json');
const content = await readFile(pkgPath);
return JSON.parse(content);
}
export function readNearestPkgSync() {
const pkgPath = findUpSync('package.json');
const content = readFileSync(pkgPath);
return JSON.parse(content);
}
여기서 핵심 문제는 동일한 로직을 두 번 구현해야 한다는 것입니다. 기본적으로 같은 일을 하는 함수인데, 하나는 비동기 방식으로, 다른 하나는 동기 방식으로 구현해야 합니다. 이것은 코드 중복을 초래하고 유지보수를 어렵게 만듭니다.
더 큰 문제는 이 라이브러리를 사용하는 다른 라이브러리도 두 가지 버전의 API를 제공해야 한다는 점입니다. 이런 패턴이 계속 이어지면 전체 생태계에 걸쳐 중복 코드가 증가하게 됩니다.
마크다운 컴파일러 같은 플러그인 시스템을 구현한다고 생각해봅시다. 동기 함수로 설계하면 모든 플러그인 훅도 동기여야 하고, 비동기 훅을 지원하려면 메인 함수도 비동기가 되어야 합니다. 이는 모든 사용자가 비동기로 처리해야 함을 의미합니다.
// 동기 방식의 플러그인 시스템
export interface Plugin {
preprocess: (markdown: string) => string
transform: (ast: AST) => AST
postprocess: (html: string) => string
}
export function markdownToHtml(markdown, plugins) {
// 모든 플러그인 훅은 동기적으로만 작동 가능
for (const plugin of plugins) {
markdown = plugin.preprocess(markdown)
}
let ast = parse(markdown)
for (const plugin of plugins) {
ast = plugin.transform(ast)
}
let html = render(ast)
for (const plugin of plugins) {
html = plugin.postprocess(html)
}
return html
}
// 비동기 플러그인 지원을 위해 전체 API를 비동기로 변경해야 함
export interface AsyncPlugin {
preprocess: (markdown: string) => string | Promise<string>
transform: (ast: AST) => AST | Promise<AST>
postprocess: (html: string) => string | Promise<string>
}
export async function markdownToHtmlAsync(markdown, plugins) {
// 모든 사용자가 비동기 처리를 해야 함, 심지어 모든 플러그인이 동기적이어도
for (const plugin of plugins) {
markdown = await plugin.preprocess(markdown)
}
let ast = parse(markdown)
for (const plugin of plugins) {
ast = await plugin.transform(ast)
}
let html = render(ast)
for (const plugin of plugins) {
html = await plugin.postprocess(html)
}
return html
}
이 코드에서의 핵심 문제는:
그렇다면 색상 문제와 분리된 로직을 만들고, 호출자가 색상을 결정하게 할 수는 없을까요? Anthony Fu와 SXZZ는 Logan Smyth의 gensync에서 영감을 받아 'Quansync'라는 패키지를 만들었습니다.
Quansync라는 이름은 양자역학에서 따왔습니다. 입자가 관찰되기 전까지 여러 상태에 동시에 존재할 수 있다는 중첩 개념처럼, 함수도 사용 맥락에 따라 동기 또는 비동기로 결정될 수 있다는 아이디어입니다.
Quansync를 사용하면 findUp 라이브러리를 다음과 같이 재구현할 수 있습니다:
import { quansync } from 'quansync'
// 단일 구현으로 동기 및 비동기 모두 지원
export const findUp = quansync(function* (filename, options) {
const directory = yield* getDirectory(options); // === getDirectory.sync(options)
const filePath = yield* searchFile(directory, filename); // === await getDirectory.async(options)
return filePath;
})
// 사용 방법:
// 동기적 사용
const path1 = findUp.sync('package.json');
// 비동기적 사용
const path2 = await findUp.async('package.json');
// 또는 비동기 함수처럼 사용
const path3 = await findUp('package.json');
마찬가지로, 마크다운 컴파일러는 다음과 같이 구현할 수 있습니다:
import { quansync } from 'quansync'
export interface QuanPlugin {
preprocess: (markdown: string) => string | Promise<string>
transform: (ast: AST) => AST | Promise<AST>
postprocess: (html: string) => string | Promise<string>
}
// 단일 구현으로 동기 및 비동기 플러그인 모두 지원
export const markdownToHtml = quansync(function* (markdown, plugins) {
for (const plugin of plugins) {
markdown = yield* plugin.preprocess(markdown)
}
let ast = parse(markdown)
for (const plugin of plugins) {
ast = yield* plugin.transform(ast)
}
let html = render(ast)
for (const plugin of plugins) {
html = yield* plugin.postprocess(html)
}
return html
})
// 사용 방법:
// 동기적 사용 (모든 플러그인이 동기적인 경우)
const html1 = markdownToHtml.sync(markdown, syncPlugins);
// 비동기적 사용 (일부 플러그인이 비동기적인 경우)
const html2 = await markdownToHtml.async(markdown, mixedPlugins);
Quansync를 사용하는 방법은 크게 세 가지입니다:
래퍼 API:
import fs from 'node:fs'
import { quansync } from 'quansync'
export const readFile = quansync({
sync: filepath => fs.readFileSync(filepath),
async: filepath => fs.promises.readFile(filepath),
})
// 동기적 사용
const content1 = readFile.sync('package.json')
// 비동기적 사용
const content2 = await readFile.async('package.json')
// 비동기 함수처럼 사용
const content3 = await readFile('package.json')
제너레이터 API:
제너레이터를 사용하면 다른 quansync 함수를 활용해 새로운 quansync 함수를 만들 수 있습니다:
```jsx
export const readJSON = quansync(function* (filepath) {
const content = yield* readFile(filepath)
return JSON.parse(content)
})
// readFile.sync가 내부적으로 사용됨
const pkg1 = readJSON.sync('package.json')
// readFile.async가 내부적으로 사용됨
const pkg2 = await readJSON.async('package.json')
```
빌드 타임 매크로:
function와 yield 구문이 복잡하게 느껴진다면, unplugin-quansync라는 빌드 타임 매크로를 사용해 일반적인 async/await 구문으로 작성할 수 있습니다:
```jsx
import { quansync } from 'quansync/macro'
export const readJSON = quansync(async (filepath) => {
const content = await readFile(filepath)
return JSON.parse(content)
})
export const readJSONSync = readJSON.sync
```
Quansync의 마법은 JavaScript의 제너레이터 기능을 활용합니다. 제너레이터 함수 내에서는 yield 키워드를 사용해 실행을 일시 중지하고 값을 반환할 수 있습니다. 이는 로직을 여러 '청크'로 나누어, 호출자가 다음 청크 실행 시기를 제어할 수 있게 합니다.
비동기 컨텍스트에서는 비동기 작업이 완료될 때까지 기다렸다가 실행을 재개하고, 동기 컨텍스트에서는 다음 청크가 즉시 실행됩니다. 이렇게 하면 색상 문제를 호출자에게 맡기고, 함수가 동기적으로 실행될지 비동기적으로 실행될지를 결정할 수 있게 됩니다.
성능에 민감한 상황에서는 비동기나 quansync 사용을 피하는 것이 좋을 수 있습니다. Promise는 자연스럽게 마이크로태스크로 틱을 지연시키고, yield도 일정한 오버헤드(M1 Max에서 약 120ns)를 발생시킵니다.
Quansync는 함수 컬러링 문제를 완전히 해결하지는 않지만, 동기와 비동기 코드를 관리하는 새로운 관점을 제공합니다. 이는 '보라색' 함수라는 새로운 개념을 도입하여 빨간색(동기)과 파란색(비동기)을 혼합합니다. quansync 함수는 필요에 따라 동기 또는 비동기로 '붕괴'될 수 있어, 여러분의 '무색' 로직이 특정 작업에 의한 색상 전파 문제를 피할 수 있게 합니다.