김우진
동기와 비동기, 블로킹과 논블로킹은 처음 배우면 유독 헷갈리는 개념입니다.
보통 다음과 같이 한꺼번에 등장하기 때문입니다.
특히 동기 = 블로킹, 비동기 = 논블로킹처럼 외워버리기 쉽습니다.
하지만 둘은 같은 개념이 아닙니다. 애초에 서로 다른 질문에 답하는 두 개의 축입니다.
이 차이를 스타크래프트의 테란과 프로토스 건설 방식으로 살펴보겠습니다.
스타크래프트에는 여러 종족이 있는데, 이 글에서는 테란과 프로토스만 알면 됩니다.
두 종족 모두 일꾼 유닛을 이용해 건물을 짓습니다.
문제는 건물을 짓는 방식이 다릅니다.

테란의 일꾼은 SCV입니다.
SCV에게 건물을 지으라고 하면 건물이 완성될 때까지 그 자리에 붙어서 건설합니다.
SCV → 건설 시작 → 건설 중 → 건설 중 → 완성 → 다시 일할 수 있음
건설 중인 SCV에게 다른 자원을 캐거나, 다른 건물을 지으라고 할 수 없습니다.

프로토스의 일꾼은 프로브(Probe)입니다.
프로브는 조금 다릅니다.
건물 소환을 시작한 뒤 바로 자리를 떠날 수 있습니다.
프로브 → 건설 시작
↓
바로 자유로워짐 → 미네랄 캐러 감
건물 → 혼자 건설 중 → 완성
프로브는 건물이 완성될 때까지 옆에 붙어 있을 필요가 없습니다.
이 차이 하나만 알아도 블로킹과 논블로킹은 거의 설명이 끝납니다.
블로킹과 논블로킹이 묻는 질문은 간단합니다.
작업을 요청한 주체가 그 작업 때문에 붙잡히는가?
스타크래프트식으로 바꾸면 더 쉽습니다.
건물을 짓기 시작한 일꾼을 바로 다른 곳에 쓸 수 있는가?
SCV에게 배럭을 지으라고 명령합니다.
SCV
│
├─ 배럭 건설 시작
├─ 건설 중
├─ 건설 중
└─ 완성
↓
다시 사용 가능
배럭이 완성되기 전까지 SCV는 다른 일을 할 수 없습니다.
이런 상태를 Blocking이라고 생각할 수 있습니다.
프로그래밍에서도 비슷합니다.
result = request()
print(result)
request()가 오래 걸리는 작업이고, 작업이 끝날 때까지 호출한 실행 흐름이 그 자리에서 기다려야 한다면 블로킹입니다.
요청한 작업이 끝날 때까지 제어권을 돌려받지 못하는 것입니다.
이번에는 프로브에게 게이트웨이를 지으라고 합니다.
프로브
│
├─ 게이트웨이 소환 시작
└─ 바로 자유로워짐
↓
미네랄 채취
게이트웨이
└─ 건설 중...
건물은 아직 완성되지 않았습니다.
그런데 프로브는 이미 다른 일을 할 수 있습니다.
이것이 Non-blocking의 핵심입니다.
작업이 끝났느냐가 중요한 것이 아닙니다.
작업을 요청한 뒤 제어권을 바로 돌려받았느냐
가 중요합니다.
가장 큰 장점은 단순하다는 것입니다.
건설 시작
→ 기다림
→ 건설 완료
→ 다음 작업
실행 순서가 눈에 잘 보입니다.
프로그래밍에서도 마찬가지입니다.
user = get_user()
orders = get_orders(user)
print(orders)
위에서 아래로 읽으면 됩니다.
코드를 작성하기도 쉽고, 디버깅하기도 쉽습니다. 작업 사이의 순서가 중요한 경우에도 다루기 편합니다.
문제는 기다리는 동안 아무것도 못 할 수 있다는 것입니다.
SCV가 60초 동안 건물을 짓는다면 그 60초 동안 미네랄을 캘 수 없습니다.
컴퓨터에서도 네트워크 요청이나 파일 읽기처럼 실제 계산보다 기다리는 시간이 훨씬 긴 작업이 많습니다.
요청 전송
↓
서버 응답 기다리는 중...
↓
응답 도착
기다리는 동안 실행 주체가 계속 붙잡혀 있다면 자원을 효율적으로 사용하기 어렵습니다.
프로브는 건설을 시작해 놓고 바로 미네랄을 캐러 갈 수 있습니다.
건설 시작
→ 미네랄 채취
→ 다른 건물 소환
→ 정찰
컴퓨터도 마찬가지입니다.
어떤 작업이 완료되기를 기다리는 동안 다른 일을 처리할 수 있다면 같은 자원으로 더 많은 작업을 처리할 수 있습니다.
특히 네트워크, 데이터베이스, 파일 입출력처럼 대기 시간이 많은 프로그램에서 큰 장점이 있습니다.
대신 상태가 복잡해집니다.
게이트웨이 건설 중
넥서스 건설 중
업그레이드 진행 중
로보틱스 건설 중
여러 작업이 동시에 진행될 수 있기 때문에 각각의 작업이 현재 어떤 상태인지 관리해야 합니다.
단순히 위에서 아래로 코드를 읽는 것만으로 전체 실행 흐름을 파악하기 어려워질 수도 있습니다.
여기에서 많이 헷갈립니다.
프로브가 건물을 짓고 바로 빠져나왔다고 해보겠습니다.
프로브가 다른 일을 할 수 있으니 논블로킹이라는 것은 알겠습니다.
그런데 한 가지 문제가 남아 있습니다.
그래서 건물이 다 지어졌다는 사실은 어떻게 알까요?
이 질문이 동기와 비동기의 영역입니다.
블로킹과 논블로킹은 작업을 시킨 직후을 보고 있습니다.
반면 동기와 비동기는 작업의 완료를 어떻게 이어받을 것인가를 보고 있습니다.
건물 건설을 시작했다고 해보겠습니다.
프로브는 이미 미네랄을 캐러 갔습니다.
그런데 플레이어가 다음과 같이 계속 확인합니다.
나: 건물 건설 완성됐나?
게임: 아직입니다.
나: 지금은?
게임: 아직입니다.
나: 지금은?
게임: 완성됐습니다.
작업의 결과가 필요한 쪽에서 직접 완료 여부를 확인하고 결과를 이어받습니다.
이런 흐름을 동기적으로 생각할 수 있습니다.
예를 들어 다음과 같은 구조입니다.
start_job()
while not is_finished():
do_something_else()
result = get_result()
작업 자체는 나를 붙잡지 않을 수도 있습니다.
그래도 결과가 필요한 쪽에서 계속 작업의 상태를 확인하고 있습니다.
이런 방식은 논블로킹이면서 동기적인 형태가 될 수 있습니다.
이번에는 다르게 해보겠습니다.
건물 건설을 시작한 뒤 그냥 다른 일을 합니다.
게이트웨이 건설 시작
나는 병력 생산
나는 전투
나는 멀티 관리
...
[건물 건설 완료]
게임이 건설 완료 시점을 알려줍니다. (실제로 스타크래프트에 이런 기능은 없습니다...)
나는 그때 다시 해당 건물과 관련된 일을 처리하면 됩니다.
이것이 Asynchronous,비동기의핵심입니다.
프로그래밍에서는 Callback, Promise, Future, 이벤트 같은 방식으로 자주 나타납니다.
buildGateway().then(() => {
console.log("건물 건설 완성");
});
manageArmy();
buildGateway()를 시작한 시점과 게이트웨이 완성 결과를 처리하는 시점이 분리되어 있습니다.
즉 비동기에서는
요청한 순간과 결과를 처리하는 순간이 꼭 붙어 있을 필요가 없습니다.
가장 큰 장점은 역시 흐름이 단순하다는 것입니다.
A 완료
↓
B 실행
↓
B 완료
↓
C 실행
작업 사이의 순서를 이해하기 쉽습니다.
A의 결과로 B를 실행하고, B의 결과로 C를 실행해야 하는 프로그램이라면 이런 구조가 편합니다.
에러가 발생했을 때도 어느 과정에서 문제가 생겼는지 추적하기 쉽습니다.
반대로 완료를 기다려야 하는 작업이 많아질수록 비효율적일 수 있습니다.
특히 서로 독립적인 작업이라면 굳이 하나씩 처리할 이유가 없습니다.
게이트웨이 완성 기다림
↓
완성
↓
넥서스 완성 기다림
↓
완성
↓
업그레이드 기다림
세 작업이 서로 관계가 없다면 동시에 진행할 수도 있습니다.
동기적인 구조만 고집하면 이런 기다림이 계속 쌓일 수 있습니다.
(자바에서는 별도로 비동기 실행을 구성하지 않으면 일반적인 코드는 순차적으로 실행됩니다. 작업 간 의존성과 대기 시간을 보고 비동기화할 지점을 정하는 것이 처리량을 늘리는데 도움이 돼요)
비동기 방식에서는 여러 작업을 진행해 놓고 완료되는 순서대로 결과를 처리할 수 있습니다.
게이트웨이 건설 시작
넥서스 건설 시작
업그레이드 시작
나는 전투 중
[업그레이드 완료]
[게이트웨이 완료]
[넥서스 완료]
각 작업을 하나씩 기다릴 필요가 없습니다.
그래서 많은 요청을 동시에 처리해야 하는 서버나, 네트워크 작업이 많은 프로그램에서 자주 사용됩니다.
세상 일이 항상 시작한 순서대로 끝나지는 않습니다.
A 시작
B 시작
C 시작
C 완료
A 완료
B 실패
이렇게 되면 고민할 것이 많아집니다.
비동기 구조가 강력한 만큼 제어 흐름과 상태 관리도 몇배로 어려워지고, 구조가 복잡해집니다.
여기까지 보면 차이가 조금 선명해집니다.
블로킹과 논블로킹은 다음을 묻습니다.
"일을시킨뒤 내가 바로 다시 움직일 수 있는가?"
동기와 비동기는 다음을 묻습니다.
"그일의완료를 어떻게 이어받는가?"
스타크래프트식으로 정리하면 이렇습니다.
| 구분 | 질문 | 스타크래프트식 표현 |
|---|---|---|
| Blocking | 작업을 요청한 주체가 붙잡히는가? | SCV가 건물이 완성될 때까지 붙어 있음 |
| Non-blocking | 작업을 요청한 주체가 바로 돌아오는가? | 프로브가 소환만 시작하고 떠남 |
| Synchronous | 결과를 필요한 쪽에서 흐름에 맞춰 확인하는가? | 내가 건물 완성 여부를 확인함 |
| Asynchronous | 결과가 준비되면 나중에 전달받는가? | 건설 완료 알림을 받고 처리함 |
중요한 것은 서로 비교하는 기준이 다르다는 점입니다.
두 개의 축이므로 이론적으로는 네 가지 조합을 생각할 수 있습니다.
가장 단순한 형태입니다.
SCV에게 배럭 건설 명령
↓
SCV가 계속 건설
↓
배럭 완성
↓
다음 작업
작업을 시킨 주체도 기다리고 있고, 결과 역시 그 흐름 안에서 바로 이어집니다.
구현하기 쉽지만 기다리는 시간이 길어지면 비효율적입니다.
프로브에게 건설을 시킨 뒤 프로브는 바로 돌아옵니다.
대신 완료 여부는 필요한 쪽에서 직접 확인합니다.
프로브 → 건설 시작 → 바로 복귀
나 → 완성됐나?
→ 아직
나 → 완성됐나?
→ 아직
나 → 완성됐나?
→ 완료
작업을 요청한 주체는 붙잡히지 않지만, 결과 확인은 직접 하고 있습니다.
상태를 반복적으로 확인하는 Polling을 떠올리면 이해하기 쉽습니다.
아마 실제 프로그래밍에서 가장 익숙한 조합입니다.
프로브 → 건설 시작 → 바로 복귀
나는 다른 일 수행
...
[게이트웨이 건설 완료]
호출한 쪽은 바로 제어권을 돌려받고, 작업이 완료되면 나중에 결과를 처리합니다.
Node.js의 이벤트 기반 I/O 같은 구조를 공부할 때 자주 만나게 됩니다.
이 조합이 존재한다는 점 때문에 두 개념을 같은 축으로 보면 안 됩니다.
비동기 작업이라고 해서 그 과정의 모든 API가 반드시 논블로킹인 것은 아닙니다.
어떤 비동기 작업을 등록하거나 결과를 전달받는 과정에서 호출자가 잠시 블로킹될 수도 있습니다.
다만 입문 단계에서는 이 조합을 억지로 스타크래프트 상황에 끼워 맞추기보다,
비동기와 논블로킹은 서로 다른 성질이기 때문에 반드시 함께 나타나는 것은 아니다
정도로 이해해도 충분합니다.
한두 개의 작업만 처리한다면 사실 블로킹과 동기 방식도 충분합니다.
스타크래프트에서 SCV 하나만 관리한다고 생각해보겠습니다.
배럭 건설
→ 완료
→ 미네랄 채취
→ 완료
→ 다음 건물 건설
조금 느려도 관리하기는 쉽습니다.
하지만 실제 게임은 그렇지 않습니다.
후반에는 동시에 여러 일을 해야 합니다.
병력 생산
업그레이드
멀티 건설
정찰
수비
견제
전투
일꾼 생산
컴퓨터 프로그램도 비슷합니다.
서버에는 한 명이 아니라 많은 사용자가 동시에 요청을 보냅니다.
사용자 A → DB 조회 중
사용자 B → 외부 API 호출 중
사용자 C → 파일 읽는 중
사용자 D → DB 쓰기 중
이때 사용자 A의 데이터베이스 네트워크 응답을 기다리느라 서버 전체가 멀뚱멀뚱 가만히 있다면 문제가 됩니다.
그래서 컴퓨터는 기다리는 동안 다른 일을 할 방법을 고민하게 됩니다.
그 과정에서 논블로킹 I/O, 비동기 프로그래밍, 이벤트 루프 같은 개념들이 등장하게된 것입니다.
네 단어를 외워야하는 것이 아닙니다. 단어가 갖는 의미가 중요하진 않습니다.
이 질문 두 개만 기억하면 됩니다.
일을 시킨 녀석을 바로 다시 쓸 수 있는가?
그 일이 끝났다는 사실을 어떻게 알게 되는가?
한 줄로 줄이면 다음과 같습니다.
Blocking / Non-blocking은 "지금 내가 묶이는가"의 문제이고,
Synchronous / Asynchronous는 "나중에 결과를 어떻게 이어받는가"의 문제입니다.
이 둘을 분리해서 이해하면 thread, network I/O, event loop, java NIO, node.js 같은 내용을 공부할 때에 훨씬 덜 헷갈릴 수 있습니다.