비동기 병렬 처리과 동시성 제어 핵심 개념
Node.js 환경에서 Promise, async/await 기반의 병렬 처리와 동시성 제어 개념과 패턴을 실무 관점에서 정리한 설명
목차
들어가기
비동기 병렬 처리와 동시성 제어는 서버 성능과 안정성에 직접적인 영향을 준다. 특히 Node.js처럼 단일 스레드 이벤트 루프 기반 환경에서는 요청을 효율적으로 처리하기 위한 전략이 필요하다. 이 글은 Promise, async/await를 중심으로 동작 원리와 현실적인 제어 패턴을 쉽게 설명한다.
비동기와 동시성의 차이
비동기 개념
비동기는 작업을 시작하고 결과를 기다리지 않은 채 다음 코드를 진행하는 방식이다. I/O 중심 작업에서 효율을 내는 핵심이다. Node.js의 콜백, Promise, async/await 모두 비동기 처리를 다루는 도구다.
동시성 개념
동시성(concurrency)은 여러 작업을 겉보기상 동시에 처리하는 능력이다. 단일 스레드에서도 이벤트 루프를 통해 동시성이 구현된다. 반면 병렬성(parallelism)은 실제로 여러 CPU 코어에서 동시에 실행되는 것을 뜻한다.
Promise 기반 흐름 제어
기본 패턴
Promise는 비동기 작업의 완료와 실패를 표현한다. then과 catch를 연결해 순차적 흐름을 만들 수 있으나 복잡한 흐름에서는 가독성이 떨어질 수 있다.
const fetchData = url => new Promise((resolve, reject) => {
// 가상의 비동기 작업
setTimeout(() => resolve('data from ' + url), 1000)
})
Promise.all([
fetchData('/a'),
fetchData('/b'),
fetchData('/c')
]).then(results => {
console.log(results) // 병렬로 실행된 결과를 한 번에 받음
}).catch(err => console.error(err))
Promise.all과 한계
Promise.all은 모든 작업을 병렬로 시작하고 모두 완료될 때까지 기다린다. 장점은 간단하고 빠르다는 점이다. 단점은 하나의 실패가 전체를 중단시키고, 동시에 너무 많은 요청을 보낼 경우 자원 고갈을 초래할 수 있다는 점이다.
async/await 패턴
가독성과 에러 처리
async/await는 Promise 기반을 더 읽기 쉬운 동기 코드 스타일로 표현한다. try/catch로 에러를 처리할 수 있어 예외 흐름을 명확히 관리하기 좋다.
async function loadAll() {
try {
const a = await fetchData('/a') // 순차 실행
const b = await fetchData('/b')
return [a, b]
} catch (err) {
console.error(err)
}
}
병렬로 실행하기
await을 연달아 쓰면 순차 실행이 된다. 병렬로 시작하려면 Promise를 미리 만들고 Promise.all로 대기해야 한다.
async function loadParallel() {
const pa = fetchData('/a')
const pb = fetchData('/b')
// 두 Promise를 동시에 시작한 뒤 결과를 기다림
const [a, b] = await Promise.all([pa, pb])
return [a, b]
}
동시성 제어 전략
왜 제어가 필요한가
외부 API 호출이나 DB 연결을 동시에 대량으로 실행하면 연결 수 초과, 쓰로틀링, 메모리 및 네트워크 병목이 발생한다. 따라서 동시성 한계를 설정해 안정적으로 처리하는 것이 필요하다.
주요 패턴
- 배치 처리: 작업을 일정 크기 묶음으로 나눠 순차적으로 처리
- 세마포어/토큰 버킷: 동시 실행 개수를 제한하는 기법
- 작업 큐: 요청을 큐에 넣고 워커가 하나씩 처리
간단한 동시성 제한 구현
아래 예시는 최대 동시 실행 수를 제한하는 간단한 limiter이다. 작업 함수 목록을 받아 병렬 실행을 제한하면서 처리한다.
async function runWithLimit(tasks, limit) {
const results = []
const executing = []
for (const task of tasks) {
const p = Promise.resolve().then(() => task())
results.push(p)
if (limit
현업 적용 사례
대표적 사용 예는 외부 API 동시 호출 제한, 파일 업로드 처리, DB 마이그레이션 처리 등이다. 우선 요구 처리량과 외부 시스템의 제한을 파악한 뒤 적절한 동시성 값을 설정하는 것이 중요하다. 또한 지수 백오프와 재시도 로직을 함께 두면 실패 복구에 도움이 된다.
체크리스트
- 작업별 예상 지연 시간과 리소스 소비 파악
- 초기 동시성 값 설정 후 모니터링으로 조정
- 타임아웃과 재시도 정책 적용
- 프로덕션에서는 라이브 메트릭 기반 자동 조정 고려
마무리
Promise와 async/await는 비동기 로직을 안전하고 읽기 좋게 만든다. 그러나 병렬로 무작정 실행하면 시스템이 불안정해질 수 있다. 동시성 한계를 두고 배치, 큐, 세마포어 같은 패턴을 적용하면 안정성과 성능을 모두 얻을 수 있다. 마지막으로, 실제 서비스에서는 관찰과 조정이 필수적이다.