서버 동시성 모델과 I/O 병목¶
한 줄 요약: 서버가 하는 일은 "기다리는 일(I/O)"과 "계산하는 일(CPU)"로 나뉜다. Node·FastAPI 같은 이벤트 루프 서버는 기다리는 일에 강하고 오래 계산하는 일에 약하다. 서버가 느릴 때는 언어를 의심하기 전에 시간이 어디서 쓰이는지부터 쪼개 봐야 한다.
📍 만난 상황¶
- 2026-11-10 — 재난 대시보드 아키텍처 설명을 준비하면서 백엔드 기초를 물었다. 3번 물음 → 그날 일지
- 13:25 Next.js로 어디까지 할 수 있는지 설명을 들었다. 표에 "오래 걸리는 작업(5만 행 넘는 CSV)은 작업 큐 + 워커로"라는 줄이 있었다. 그걸 보고 "Node가 단일 스레드라서 그런가?"라고 물었다. → 절반만 맞다는 답을 들었다.
- 13:33 데이터가 늘면 생길 문제 표(동기화 지연, OFFSET 페이지네이션 등)를 받았다. 이어서 "FastAPI로 바꾸면 이 문제들이 사라지나?"라고 물었다. → 대부분 그대로 남는다.
- 13:34 문제별 비교표를 보고도 감이 오지 않았다. "백엔드와 인프라가 부족하니 자세히"를 부탁했다. → 웨이터 비유와 요청 하나의 시간표를 받았다.
🧩 쉽게 말하면¶
식당 웨이터 한 명을 떠올리자. 웨이터는 주문을 받아 주방에 넘긴다. 요리가 나오는 동안에는 다른 테이블 주문을 받는다.
주방을 기다리는 동안 손이 비니까, 웨이터 한 명이 테이블 수십 개를 맡을 수 있다.
그런데 웨이터가 직접 요리를 시작하면 그동안 다른 테이블은 아무도 못 돌본다.
이 구분을 모르면 처방이 엉뚱해진다. "식당이 느리니 웨이터를 바꾸자"(언어 교체)라고 하는 식이다. 진짜 원인은 주방이 느린 것일 수 있다.
📖 제대로 알기¶
일의 두 종류¶
| 종류 | 시간이 쓰이는 곳 | 예 |
|---|---|---|
| I/O 바운드 | 다른 곳의 응답을 기다림 | DB 조회, 외부 API 호출, 파일 읽기 |
| CPU 바운드 | 서버가 직접 계산 | 이미지 처리, 압축, 좌표 수십만 개 변환 |
- I/O(Input/Output: 입출력. 서버 바깥과 데이터를 주고받는 일)
- 바운드(bound: 묶여 있다). "I/O 바운드"는 속도가 I/O에 묶여 있다는 뜻이다.
- 병목(bottleneck: 병의 좁은 목). 전체 속도를 정하는 가장 느린 구간이다.
스레드와 이벤트 루프¶
- 스레드(thread): 코드를 한 줄씩 실행하는 일꾼 하나다.
- 동시성(concurrency): 여러 일을 번갈아 진행하는 것이다. 일꾼 하나로도 된다.
- 병렬성(parallelism): 여러 일을 정말 같은 순간에 하는 것이다. CPU 코어가 여럿 있어야 한다.
- 이벤트 루프(event loop): 할 일 목록을 계속 돌면서 준비된 일을 하나씩 처리하는 반복문이다. 테이블을 순회하는 웨이터와 같다.
- 논블로킹 I/O(non-blocking: 기다리느라 멈추지 않음): 요청을 보내 놓고 바로 다음 일로 넘어간다. 응답이 오면 알림을 받는다.
서버의 대표적인 동시성 모델¶
| 모델 | 방식 | 예 |
|---|---|---|
| 요청마다 스레드 | 요청 하나에 일꾼 하나 | 전통적인 Java 서버(Spring MVC) |
| 이벤트 루프 | 일꾼 하나가 번갈아 처리 | Node.js, FastAPI(uvicorn) |
| 여러 프로세스 | 같은 서버를 여러 벌 띄움 | gunicorn 워커, 쿠버네티스 파드 여러 개 |
실제로는 섞어 쓴다. 이벤트 루프 서버도 프로세스나 파드를 여러 개 띄운다.
Node와 Python의 "하나"¶
- Node: JavaScript를 실행하는 스레드가 하나다. 기다리는 일은 운영체제와 libuv(Node 안에서 I/O를 맡는 라이브러리)의 스레드 풀에 넘긴다.
- Python: GIL(Global Interpreter Lock: 한 프로세스 안에서 한 번에 한 스레드만 Python 코드를 실행하게 하는 잠금)이 있다. 스레드를 늘려도 계산은 한 번에 하나씩이다. I/O를 기다리는 동안에는 GIL을 내려놓는다.
- 그래서 둘은 "기다리는 일엔 강하고, 무거운 계산은 따로 빼야 하는" 같은 계열이다.
⚙️ 어떻게 동작하나¶
요청 A(CSV 받기)와 요청 B(미리보기)가 동시에 온 경우다.
- 이벤트 루프가 A를 받는다. SQL을 만들어 플랫폼에 보낸다. 응답을 기다리며 서 있지 않는다.
- 루프는 바로 B를 받는다. B의 SQL도 보내 놓는다.
- A의 첫 1,000행이 도착했다는 알림이 온다. 루프가 CSV로 바꿔 흘려보낸다(몇 ms).
- A가 다음 1,000행을 요청하고 다시 기다린다. 그사이 B의 응답이 와서 B를 마무리한다.
- 이렇게 기다리는 틈마다 다른 요청을 끼워 넣는다.
sequenceDiagram
participant A as 요청 A
participant L as 이벤트 루프
participant P as 플랫폼 DB
participant B as 요청 B
A->>L: CSV 받기
L->>P: A의 SQL 보냄
B->>L: 미리보기
L->>P: B의 SQL 보냄
P-->>L: A의 1,000행
Note over L: CSV로 변환 몇 ms
P-->>L: B의 결과
L-->>B: 미리보기 응답
기다림은 플랫폼 쪽에서 일어난다. 루프는 그 틈에 B를 처리한다.
왜 이렇게 설계했나. 웹 서버 시간의 대부분은 기다림이다. 기다리는 동안 스레드를 하나씩 붙잡아 두면 메모리가 많이 든다. 일꾼 하나가 번갈아 하면 적은 자원으로 많은 연결을 버틴다.
그 대가. 루프가 계산 하나를 오래 붙잡으면 그동안 모든 요청이 멈춘다. 일꾼이 하나뿐이라서다.
🧪 예시¶
-
예시 1 · 일상에 빗대어: 웨이터 한 명, 테이블 100개가 있다. 주문 받기는 10초, 요리는 10분이 걸린다. 웨이터는 대부분 손이 비어 있다. 식당이 느린 이유는 주방이다. 웨이터를 더 빠른 사람으로 바꿔도 손님은 여전히 10분을 기다린다. 반대로 웨이터가 한 테이블 앞에서 5분간 케이크를 직접 자르면, 나머지 99개 테이블이 5분 동안 멈춘다.
-
예시 2 · 실제로는: 대시보드 미리보기 요청 하나의 대략적인 시간표다.
① 브라우저 → 서버 전송 수십 ms
② 로그인 확인, PG 읽기 몇 ms
③ SQL 만들기 1 ms 미만 ← 우리 코드의 계산
④ manager → sqlway → ClickHouse 수백 ms~수 초 ← 대부분
⑤ JSON으로 돌려주기 몇 ms
루프를 막는 코드와 막지 않는 코드는 이렇게 다르다.
// ✅ 1,000행씩 받아 바로 흘려보냄: 대부분 await(기다림)
for await (const rows of iterateRows(sql)) {
stream.write(rows.map(toCsvLine).join(""));
}
// ❌ 100만 행을 한 번에 문자열로: 끝날 때까지 서버 전체가 멈춤
const csv = allRows.map(toCsvLine).join("");
await로 기다리는 동안 루프는 다른 요청을 처리한다.
② 1,000행 변환은 아주 짧아서 다른 요청이 거의 막히지 않는다.
③ 100만 행 변환은 루프를 수 초 동안 붙잡는다. 이런 일은 worker_threads(Node에서 JS를 별도 스레드로 돌리는 기능)나 별도 서비스로 뺀다.
⚖️ 비교 · 흔한 오해¶
| 상황 | Node | FastAPI(uvicorn) |
|---|---|---|
| DB·API 기다림 | 강함 | 강함 |
| 무거운 계산 | 루프가 막힘 → 워커로 | 루프가 막힘 + GIL → 프로세스로 |
| 사람이 몰림 | 파드 늘리기 | 파드·프로세스 늘리기 |
오해 1. "Node는 단일 스레드라서 큰 작업에 약하다." "단일"이 "한 번에 한 요청만"처럼 들려서 생기는 오해다. 실제로는 기다리는 동안 다른 요청을 처리한다. 약한 건 오래 계산하는 일뿐이다.
큰 CSV에 작업 큐가 필요한 진짜 이유는 따로 있다. 요청 하나를 몇 분씩 붙잡는 것 자체가 위험해서다. 중간 장치의 시간 제한(보통 60초 안팎), 배포 때 끊김, 이어 받기 불가 같은 문제다.
오해 2. "FastAPI로 바꾸면 해결된다." "Python은 데이터 처리에 강하다"는 말과 섞이기 쉽다. Python의 강점은 pandas·geopandas 같은 라이브러리이지 서버 처리 방식이 아니다.
동기화 지연, OFFSET 페이지네이션, 1,000행 제한은 데이터를 다루는 방식의 문제다. FastAPI에서도 같은 방식으로 짜면 같은 곳에서 터진다.
오해 3. "async를 붙이면 빨라진다."
async는 기다리는 동안 자리를 양보하게 할 뿐이다. 기다림 자체를 줄이지는 않는다. FastAPI의 async def 안에서 동기 함수(time.sleep, requests)를 부르면 오히려 루프 전체가 멈춘다.
🛠️ 실무에서는¶
느려졌을 때 먼저 할 일은 시간 쪼개기다. - [ ] 요청 하나의 시간을 구간별(네트워크·우리 코드·DB)로 재 봤나 - [ ] 가장 긴 구간이 DB라면: 쿼리·인덱스·파티션부터 본다. 언어 교체는 효과가 없다 - [ ] 우리 코드가 길다면: 큰 배열을 한 번에 만들거나 정렬·압축하는 곳이 있나 - [ ] 요청이 수십 초 넘게 이어지나: 작업 큐로 뺄 대상이다 - [ ] 사람이 몰려서 느린가: 파드 수(replicas)를 늘린다(수평 확장)
먼저 볼 지표
- 이벤트 루프 지연: Node는 perf_hooks.monitorEventLoopDelay()로 잰다. 값이 커지면 누군가 루프를 붙잡고 있다.
- CPU 사용률: CPU는 한가한데 느리다면 거의 확실히 기다림(I/O) 문제다.
❓ 자주 묻는 질문¶
- Q. Node 서버 하나가 동시에 몇 명까지 받나? A. 기다리는 일 위주면 수백~수천 연결도 버틴다. 보통은 서버보다 뒤쪽 DB·플랫폼이 먼저 한계에 닿는다.
- Q. CPU 코어가 4개인데 Node는 1개만 쓰나? A. JS 실행은 프로세스당 1개다. 그래서 프로세스나 파드를 여러 개 띄워 코어를 나눠 쓴다.
- Q. Java·Go 같은 멀티스레드 언어라면 다른가? A. 무거운 계산을 여러 코어에 나누기는 쉽다. 하지만 DB를 기다리는 시간은 똑같다. I/O 병목은 언어로 풀리지 않는다.
- Q. 무거운 계산이 정말 생기면?
A. Node는
worker_threads, Python은 별도 프로세스(ProcessPoolExecutor)나 작업 큐 워커로 뺀다.
🔭 더 깊이 보기¶
- 이벤트 루프 지연은 어떻게 재고, 몇 ms부터 문제인가? — "막혔다"를 숫자로 판단하는 첫걸음이라서.
- 커넥션 풀(DB 연결을 미리 열어 두고 돌려 쓰는 것)은 크기를 어떻게 정하나? — 서버를 늘리면 DB 연결도 늘어 다음 병목이 되기 때문에.
- 파드를 여러 개 띄우면 세션·파일 같은 상태는 어디에 두나? — 수평 확장하는 순간 바로 생기는 문제라서.
- 관련 주제: 작업 큐와 백그라운드 워커 · 데이터 규모 증가와 확장성 · 프론트엔드와 백엔드 서버 구조
📚 참고 자료¶
- 동시성과 async / await (FastAPI 한국어 문서) —
async def와def를 언제 쓰는지, 햄버거 가게 비유 · 링크 - asyncio — 비동기 I/O (Python 한국어 문서) — 이벤트 루프와 코루틴의 공식 설명 · 링크
- Don't Block the Event Loop (or the Worker Pool) (Node.js 공식 문서, 영문) — 어떤 코드가 루프를 막는지, 어떻게 피하는지 · 링크
🗓️ 이력¶
- 2026-11-10: 작업 큐가 단일 스레드 때문인지, FastAPI로 바꾸면 해결되는지, 자세한 설명까지 3번 물음 → 일지