문서 읽는 데 54분 · F4

F-4: 비동기 기초 — 기다리는 틈에 다른 일 하기

목차 29
전체 22강 중 20강 · 파이썬 기초
난이도 · 입문

ℹ️프로그래밍 경험이 없어도 시작할 수 있는 파이썬 입문 — ‘개발 입문’을 먼저 들으면 더 수월해요. AI·데이터 분석으로 이어지는 출발점이에요.

안녕하세요, 홍순구 튜터입니다. 지난 시간엔 값의 모양으로 가지를 치는 match/case, 검사하면서 담는 walrus(:=), 그리고 파이썬 3.14의 친절한 에러 메시지까지 익혔어요. 이걸로 모던 파이썬의 봉우리를 거의 다 넘었죠.

지난 시간을 닫으면서 제가 한 가지 예고를 했어요. F-2에서 만든 피드 제너레이터, 게시물을 하나씩 흘려보냈잖아요. 그런데 게시물 열 개를 불러오는데 하나당 1초씩 걸린다면? 차례로 기다리면 10초예요. 오늘은 그 10초를 1초로 줄이는 도구, 비동기(async)를 만나요.

비동기는 어렵게 느껴지지만 핵심은 한 문장이에요. "기다리는 동안 멍하니 있지 말고, 그 틈에 다른 일을 출발시키자." 카페에서 주문을 받은 직원이 커피 한 잔을 내리는 1분 동안 가만히 서 있지 않고 다음 손님 주문을 받는 모습, 딱 그거예요. 오늘은 그 멘탈모델부터 차근차근 익혀볼게요.

텍스트
오늘의 여정
  동기 vs 비동기       기다리는 시간을 버리느냐, 활용하느냐
  첫 코루틴            async def / await / asyncio.run
  동시에 출발          gather · TaskGroup 으로 여러 개를 한꺼번에
  async with/for       with·제너레이터의 비동기 버전 맛보기
  GIL & 동시성 3갈래   asyncio · threading · multiprocessing 조감

💡 오늘 수업의 핵심 — "기다리는 틈에 다른 일을 출발시킨다"

비동기는 "기다리는 시간"을 활용해 여러 일을 거의 동시에 진행해요. async def로 코루틴을 만들고, await로 기다릴 지점을 표시하고, gatherTaskGroup으로 여러 개를 한꺼번에 출발시키면 — 차례로 10초 걸릴 일이 1초로 줄어요.

🎯 학습 목표

  • 동기와 비동기의 차이를 "기다리는 시간을 어떻게 쓰는가"로 이해해요.
  • async def/await/asyncio.run()으로 첫 코루틴을 만들고 실행해요.
  • asyncio.gather()asyncio.TaskGroup()(3.11+)으로 여러 일을 동시에 처리해요.
  • async with/async for를 맛보고, GIL과 동시성 3갈래(asyncio·threading·multiprocessing)를 조감해요.

Step 1: "왜 비동기인가 — 기다리는 시간이 아깝다"

먼저 비동기가 필요한지부터 그림으로 잡아볼게요. 우리가 지금까지 짠 코드는 전부 동기(synchronous) 방식이었어요. 한 줄이 끝나야 다음 줄로 넘어가는, 우리에게 익숙한 그 방식이요.

게시물 하나를 서버에서 받아오는 데 1초가 걸린다고 해봐요. 세 개를 받으면 어떻게 될까요?

Python
# src/modern/f4_sync_vs_async.py
import time


def fetch_post_sync(n):
    time.sleep(1)  # 서버 응답을 1초 동안 '그냥' 기다린다 (그 사이 다른 일 못 함)
    return f"게시물 {n}번"


def load_feed_sync(count):
    start = time.perf_counter()
    posts = []
    for n in range(1, count + 1):
        posts.append(fetch_post_sync(n))  # 하나 끝나야 다음 — 차례차례
    elapsed = time.perf_counter() - start
    return posts, round(elapsed, 1)

time.sleep(1)은 "1초 동안 아무것도 하지 말고 멈춰라"예요. 서버 응답을 기다리는 상황을 흉내 낸 거죠. time.perf_counter()는 시간을 재는 초시계라고 보면 돼요(time은 파이썬에 기본으로 들어 있어요).

실행하면 이렇게 나와요.

Python
posts, elapsed = load_feed_sync(3)
print(posts)
print(f"동기로 3개 불러오기: 약 {elapsed}초")
# ['게시물 1번', '게시물 2번', '게시물 3번']
# 동기로 3개 불러오기: 약 3.0초

세 개를 받는 데 약 3초. 1초 + 1초 + 1초죠. 문제는 그 3초 중 대부분이 그냥 기다리는 시간이라는 거예요. 우리 컴퓨터는 서버 응답을 기다리며 손 놓고 멍하니 있어요. 그림으로 보면 이래요.

텍스트
동기: 하나 끝나야 다음 — 기다리는 틈을 버린다
  게시물1  [▓▓▓▓ 기다림]
  게시물2                [▓▓▓▓ 기다림]
  게시물3                              [▓▓▓▓ 기다림]
  시간     0초─────1초─────2초─────3초    총 약 3초

만약 첫 번째 게시물을 기다리는 1초 동안 두 번째, 세 번째도 같이 출발시킬 수 있다면? 셋이 동시에 1초씩 기다리고 끝나니 전체는 약 1초가 돼요. 이게 비동기의 그림이에요.

텍스트
비동기: 기다리는 틈에 다음을 출발시킨다
  게시물1  [▓▓▓▓ 기다림]
  게시물2  [▓▓▓▓ 기다림]
  게시물3  [▓▓▓▓ 기다림]
  시간     0초─────1초                   총 약 1초

오늘 우리가 할 일은 아래 그림(동기)을 위 그림(비동기)으로 바꾸는 거예요.

💡 한 줄 정리

동기는 한 작업이 끝나야 다음으로 넘어가서, 기다리는 시간을 버려요. 비동기는 기다리는 틈에 다른 작업을 출발시켜, 1초씩 걸리는 일 세 개를 약 1초에 끝내요. 핵심은 "기다리는 시간을 활용한다"예요.

🙋 학생 질문 — "튜터님, 그럼 모든 코드를 비동기로 짜면 무조건 빨라지나요?"

아니에요. 비동기가 진짜 빛나는 건 기다리는 시간이 있는 일이에요. 서버 응답 기다리기, 파일 읽기, 네트워크 요청 같은 거요. 이런 걸 I/O 작업이라고 불러요(Input/Output, 바깥과 주고받는 일).

반대로 계산만 빡세게 하는 일(예: 숫자 100만 개를 더하기)은 기다리는 틈이 없어요. CPU가 쉬지 않고 일하니까요. 이런 건 비동기로 바꿔도 안 빨라져요. 오히려 코드만 복잡해지죠. 그래서 "기다림이 많은 일이면 비동기, 계산이 많은 일이면 비동기 말고 다른 방법"이라고 기억해두면 좋아요. 그 "다른 방법"은 Step 7에서 살짝 살펴봐요.


Step 2: "첫 코루틴 — async def / await / asyncio.run"

이제 동기 함수를 비동기로 바꾸는 첫걸음이에요. 새 키워드 세 개가 나와요. 천천히 하나씩 볼게요.

Python
# src/modern/f4_first_coroutine.py
import asyncio


async def greet():
    print("안녕하세요, 비동기 세계에 오신 걸 환영해요")
    await asyncio.sleep(1)  # 1초 '기다리지만' 멈춰있지 않다 — 그 틈을 양보한다
    print("1초 뒤에 이 줄이 찍혀요")
    return "끝"

함수 앞에 async를 붙였어요. async def로 만든 함수를 코루틴(coroutine)이라고 불러요. "협력하며 양보하는 함수"라는 뜻이에요. 보통 함수와 딱 하나가 달라요. 부른다고 바로 실행되지 않아요.

Python
coro = greet()       # 아직 실행 안 됨 — 코루틴 객체만 만들어진다
print(type(coro))    # <class 'coroutine'>

greet()라고 괄호를 붙여 불렀는데도 인사말이 안 찍혀요. 대신 "코루틴 객체"라는 게 손에 쥐어져요. 요리로 치면 레시피만 받은 셈이에요. 아직 불을 안 켰죠. 이 레시피를 진짜로 굽는 시동키가 asyncio.run()이에요.

Python
result = asyncio.run(coro)  # 이제 진짜 실행 — 시동을 건다
print(result)               # 끝

asyncio.run(coro)를 부르는 순간 코루틴이 진짜로 돌아가요. 전체를 실행하면 이렇게 나와요.

Python
# <class 'coroutine'>
# 안녕하세요, 비동기 세계에 오신 걸 환영해요
# 1초 뒤에 이 줄이 찍혀요
# 끝

중간에 await asyncio.sleep(1)이 있죠. asyncio.sleep(1)은 "1초 기다려라"인데, Step 1의 time.sleep과 결정적으로 달라요. time.sleep은 그냥 멈추지만, asyncio.sleep은 "1초 기다리는 동안 다른 코루틴이 일하도록 양보"해요. 그 양보 지점을 표시하는 게 바로 await예요. "여기서 잠깐 기다릴게, 그동안 다른 일 해도 돼"라는 신호죠.

지금은 코루틴이 하나뿐이라 양보해도 할 일이 없어서 그냥 1초 기다려요. 진짜 위력은 코루틴이 여러 개일 때 나와요. 그건 Step 4에서 봐요.

💡 한 줄 정리

async def로 만든 함수가 코루틴이에요. 부르면 바로 실행되지 않고 코루틴 객체만 생기고, asyncio.run()으로 시동을 걸어야 진짜 돌아가요. await는 "여기서 기다리되 그 틈을 양보한다"는 표시예요.

🙋 학생 질문 — "튜터님, await는 아무 데서나 쓸 수 있나요?"

awaitasync def 안에서만 쓸 수 있어요. 보통 함수(def) 안에서 await를 쓰면 에러가 나요. 양보라는 건 비동기 세계 안에서만 통하는 약속이거든요.

그리고 await 뒤에는 아무거나 오는 게 아니라 "기다릴 수 있는 것"(코루틴 같은)만 와요. await asyncio.sleep(1), await fetch_post(...)처럼요. 지금은 "코루틴을 기다릴 땐 앞에 await를 붙인다" 정도로 기억하면 충분해요. 깊은 규칙은 아직 몰라도 괜찮아요.


Step 3: "함정 — async라고 다 빠른 건 아니다"

여기서 비동기 학습에서 가장 많이 걸려 넘어지는 함정을 짚고 갈게요. "함수를 async def로 바꾸고 await만 붙이면 빨라지겠지?" — 아니에요. 직접 확인해봐요.

Python
# src/modern/f4_sequential_trap.py
import asyncio
import time


async def fetch_post(n):
    await asyncio.sleep(1)  # 게시물 하나 받아오는 데 1초
    return f"게시물 {n}번"


async def load_sequential(count):
    start = time.perf_counter()
    posts = []
    for n in range(1, count + 1):
        posts.append(await fetch_post(n))  # 여기서 끝까지 기다린 뒤에야 다음으로
    elapsed = time.perf_counter() - start
    return posts, round(elapsed, 1)

fetch_post는 어엿한 코루틴이에요. load_sequentialasync def고요. 그런데 실행하면?

Python
posts, elapsed = asyncio.run(load_sequential(3))
print(posts)
print(f"async 지만 순차로 불러오기: 약 {elapsed}초")
# ['게시물 1번', '게시물 2번', '게시물 3번']
# async 지만 순차로 불러오기: 약 3.0초

여전히 약 3초예요! 동기랑 똑같죠. 왜 그럴까요?

범인은 posts.append(await fetch_post(n)) 줄이에요. await를 만나면 "그 코루틴이 끝날 때까지 기다린 뒤" 다음 줄로 가요. for 루프 안에서 매번 끝까지 기다리니, 결국 하나 끝나고 다음, 하나 끝나고 다음 — 차례차례예요. 그림으로 보면 Step 1의 동기 그림과 똑같아요.

텍스트
async 라도 await 를 차례로 하면:
  게시물1  [▓▓▓▓ await 끝까지 기다림]
  게시물2                          [▓▓▓▓ 그제서야 출발]
  게시물3                                            [▓▓▓▓ 또 그제서야]
  시간     0초─────1초─────2초─────3초    총 약 3초 (동기와 똑같다!)

await는 "기다림"이지 "동시 출발"이 아니에요. 셋을 동시에 출발시키려면 별도의 도구가 필요해요. 그게 다음 Step의 gather예요. 이 함정을 한번 겪어두면 비동기의 절반은 이해한 거예요.

💡 한 줄 정리

async def로 바꾸고 await만 차례로 붙이면 여전히 순차 실행이라 빨라지지 않아요. await는 "끝날 때까지 기다림"이지 "동시 출발"이 아니에요. 동시에 출발시키려면 gather 같은 도구가 필요해요.

🙋 학생 질문 — "튜터님, 그럼 await는 왜 쓰는 거예요? 느려지기만 하잖아요."

좋은 질문이에요. await 자체가 느린 게 아니라, 여러 개를 동시에 묶지 않고 하나씩 따로 기다린 게 문제예요.

await는 꼭 필요해요. 코루틴의 결과를 받으려면 반드시 기다려야 하니까요. 핵심은 무엇을 await하느냐예요. 코루틴 하나하나를 차례로 await하면 순차가 되고, 여러 코루틴을 "한 덩어리로 묶은 것"을 await하면 동시가 돼요. 다음 Step에서 gather로 셋을 한 덩어리로 묶어서 한 번에 await하면, 똑같은 await인데 약 1초로 줄어드는 걸 보게 될 거예요.


Step 4: "동시에 출발 — asyncio.gather"

드디어 10초를 1초로 줄이는 도구예요. asyncio.gather()는 여러 코루틴을 한꺼번에 출발시키고, 전부 끝나면 결과를 리스트로 모아줘요. 이름 그대로 "그러모은다(gather)"예요.

Python
# src/modern/f4_gather.py
import asyncio
import time


async def fetch_post(n):
    await asyncio.sleep(1)
    return f"게시물 {n}번"


async def load_concurrent(count):
    start = time.perf_counter()
    posts = await asyncio.gather(*(fetch_post(n) for n in range(1, count + 1)))
    elapsed = time.perf_counter() - start
    return list(posts), round(elapsed, 1)

Step 3과 비교하면 for 루프가 사라지고 한 줄로 바뀌었어요. asyncio.gather(코루틴1, 코루틴2, 코루틴3)처럼 코루틴들을 넘기면 셋을 동시에 출발시켜요. 여기선 *(fetch_post(n) for n in range(1, count + 1))로 코루틴 세 개를 만들어 풀어 넣었어요(B-3에서 본 제너레이터 표현식에 *로 펼치기를 더한 거예요).

핵심은 await가 딱 한 번, 묶음 전체에 걸린다는 거예요. 셋을 한 덩어리로 묶어 한 번에 기다리니, 셋이 동시에 1초씩 기다리고 함께 끝나요.

Python
posts, elapsed = asyncio.run(load_concurrent(3))
print(posts)
print(f"gather 로 3개 동시에: 약 {elapsed}초")
# ['게시물 1번', '게시물 2번', '게시물 3번']
# gather 로 3개 동시에: 약 1.0초

약 1초! 같은 일인데 3초가 1초가 됐어요. 게시물이 열 개여도 마찬가지로 약 1초예요. 이게 비동기의 진짜 위력이에요.

한 가지 더. 결과 리스트의 순서를 보세요. ['게시물 1번', '게시물 2번', '게시물 3번'] — 넘긴 순서 그대로예요. 동시에 출발했으니 누가 먼저 끝날지는 알 수 없지만, gather넘긴 순서대로 결과를 정리해줘요. 그래서 결과를 안심하고 받아 쓸 수 있어요.

💡 한 줄 정리

asyncio.gather(코루틴들)은 여러 코루틴을 동시에 출발시키고 전부 끝나면 결과를 리스트로 모아줘요. await가 묶음 전체에 한 번 걸려서, 1초짜리 세 개가 약 1초에 끝나요. 결과는 넘긴 순서대로 정리돼요.

🙋 학생 질문 — "튜터님, 동시에 출발한다는데 진짜로 동시에 여러 줄이 실행되는 건가요?"

엄밀히는 "진짜 동시"는 아니에요. 일꾼(파이썬)은 한 명이고, 한 번에 한 줄씩만 실행해요. 다만 그 일꾼이 아주 부지런해서, 게시물1이 await asyncio.sleep으로 "기다림"에 들어가는 순간 곧장 게시물2를 출발시키고, 게시물2도 기다림에 들어가면 게시물3을 출발시켜요.

결국 셋 다 "기다리는 상태"로 거의 같이 들어가고, 1초 뒤 거의 같이 깨어나요. 한 명의 일꾼이 빈틈없이 왔다 갔다 한 덕분에 마치 동시에 처리된 것처럼 보이는 거예요. 이런 방식을 "동시성(concurrency)"이라고 불러요. "여럿이 진짜 한꺼번에(병렬, parallelism)"와는 살짝 달라요. 그 차이는 Step 7에서 정리해요.


Step 5: "동시 실행의 현대 표준 — asyncio.TaskGroup"

gather도 훌륭하지만, 요즘 파이썬에서 권장하는 더 단정한 도구가 있어요. 파이썬 3.11부터 들어온 asyncio.TaskGroup이에요. 여러 일을 동시에 돌리는 "현대 표준"이라고 보면 돼요.

Python
# src/modern/f4_taskgroup.py
import asyncio
import time


async def fetch_post(n):
    await asyncio.sleep(1)
    return f"게시물 {n}번"


async def load_with_taskgroup(count):
    start = time.perf_counter()
    tasks = []
    async with asyncio.TaskGroup() as tg:
        for n in range(1, count + 1):
            tasks.append(tg.create_task(fetch_post(n)))  # 등록만 — 출발은 동시에
    # 블록을 벗어나면 모든 task 가 끝나 있다
    posts = [task.result() for task in tasks]
    elapsed = time.perf_counter() - start
    return posts, round(elapsed, 1)

async with asyncio.TaskGroup() as tg: 블록을 열고, 그 안에서 tg.create_task(코루틴)으로 할 일을 하나씩 등록해요. create_task는 "이 일을 시작 목록에 올려둬"라는 뜻이에요. 등록된 일들은 동시에 출발해요.

가장 깔끔한 점은 블록을 벗어나는 순간 등록된 모든 일이 끝나 있다는 거예요. 우리가 따로 "다 끝날 때까지 기다려"라고 적지 않아도, async with 블록이 알아서 모두 기다려줘요. 끝난 결과는 task.result()로 하나씩 꺼내요.

Python
posts, elapsed = asyncio.run(load_with_taskgroup(3))
print(posts)
print(f"TaskGroup 으로 3개 동시에: 약 {elapsed}초")
# ['게시물 1번', '게시물 2번', '게시물 3번']
# TaskGroup 으로 3개 동시에: 약 1.0초

결과는 gather와 똑같이 약 1초예요. 그럼 gather랑 뭐가 다를까요? 가장 큰 차이는 하나가 실패했을 때예요. TaskGroup은 묶인 일 중 하나가 에러를 내면, 나머지 일들을 깔끔하게 멈추고(취소하고) 정리해줘요. 여럿을 돌리다 하나가 터졌을 때 나머지가 어정쩡하게 남지 않아서 안전하죠. 그래서 새 코드라면 TaskGroup을 먼저 떠올리면 좋아요.

(여기 쓰인 async with는 바로 다음 Step에서 자세히 봐요. 지금은 "TaskGroup을 여는 문법" 정도로 받아들이면 돼요.)

💡 한 줄 정리

async with asyncio.TaskGroup() as tg: 안에서 tg.create_task()로 일을 등록하면 동시에 출발하고, 블록을 벗어날 때 모두 끝나 있어요. 결과는 task.result()로 꺼내요. 하나가 실패하면 나머지를 정리해줘서 gather보다 안전한 현대 표준이에요.

🙋 학생 질문 — "튜터님, 그럼 gather는 이제 안 써도 되나요?"

그렇진 않아요. 둘 다 현역이에요. 결과만 간단히 모으면 되는 짧은 상황이라면 gather 한 줄이 더 가볍고 편해요.

TaskGroup은 일이 여러 개고, "하나라도 실패하면 나머지도 깔끔히 멈춰야" 하는 상황에서 빛나요. 그래서 "간단히 모을 땐 gather, 안전하게 묶어 관리할 땐 TaskGroup"으로 나눠 기억하면 좋아요. 둘 다 익혀두면 상황에 맞게 고를 수 있어요.


Step 6: "with와 제너레이터의 비동기 버전 — async with / async for 맛보기"

지금까지 쓴 async with를 비롯해, 비동기에는 우리가 이미 아는 문법의 "비동기 버전"이 있어요. 가볍게 맛만 봐요.

F-2에서 with의 정체인 컨텍스트 매니저(FeedSession)를 만들었던 것, 그리고 yield로 값을 하나씩 흘려보내는 제너레이터를 만들었던 것 기억나시죠? 그 둘에 각각 비동기 버전이 있어요.

Python
# src/modern/f4_async_with_for.py
import asyncio


class FeedSession:
    """F-2 의 FeedSession(컨텍스트 매니저)의 비동기 버전."""

    async def __aenter__(self):
        print("피드 세션 열기 (서버 연결...)")
        await asyncio.sleep(0.1)
        return self

    async def __aexit__(self, exc_type, exc, tb):
        await asyncio.sleep(0.1)
        print("피드 세션 닫기 (연결 정리)")


async def stream_posts(usernames):
    """게시물을 하나씩, 비동기로 흘려보내는 async 제너레이터 (async def + yield)."""
    for username in usernames:
        await asyncio.sleep(0.3)  # 한 줄 받아오는 시간
        yield f"@{username} 님의 새 게시물"

두 가지가 보여요.

첫째, FeedSession비동기 컨텍스트 매니저예요. F-2에선 __enter__/__exit__였는데, 여기선 앞에 a가 붙은 __aenter__/__aexit__예요(a = async). 열고 닫을 때 await가 끼어들 수 있다는 뜻이에요. 서버에 연결하고 정리하는 데 시간이 걸리는 상황을 흉내 냈죠.

둘째, stream_posts비동기 제너레이터예요. async def 안에 yield가 같이 있어요. 한 줄을 흘려보낼 때마다 await로 잠깐 기다려요(서버에서 한 줄씩 받아오는 느낌).

이 둘을 쓸 때는 앞에 async를 붙여요.

Python
async def main():
    async with FeedSession():  # 열 때/닫을 때 await 가 끼는 with
        async for post in stream_posts(["minji", "jaehoon", "seungwoo"]):  # 한 줄씩 await
            print(post)

평범한 withasync with로, 평범한 forasync for로 바뀌었어요. 실행하면 이렇게 흘러가요.

Python
# 피드 세션 열기 (서버 연결...)
# @minji 님의 새 게시물
# @jaehoon 님의 새 게시물
# @seungwoo 님의 새 게시물
# 피드 세션 닫기 (연결 정리)

세션을 열고, 게시물을 한 줄씩 비동기로 받아 출력하고, 마지막에 세션을 닫아요. 우리가 아는 with/forasync만 붙은, 친숙한 모습이죠. 깊은 활용은 다음 과목들에서 만나니, 오늘은 "비동기에도 withfor가 있다" 정도면 충분해요.

💡 한 줄 정리

async with는 열고 닫을 때 await가 끼는 컨텍스트 매니저(__aenter__/__aexit__)고, async for는 한 항목씩 await하며 도는 비동기 제너레이터(async def + yield) 반복이에요. 우리가 아는 with/for의 비동기 버전이에요.

🙋 학생 질문 — "튜터님, 그냥 for랑 async for는 언제 갈라 쓰나요?"

기준은 간단해요. 한 항목을 꺼낼 때마다 기다림(await)이 필요하면 async for, 아니면 그냥 for예요.

리스트나 보통 제너레이터처럼 이미 메모리에 있는 걸 도는 건 기다릴 게 없으니 for로 충분해요. 반대로 "서버에서 한 줄씩 받아오며" 도는 것처럼 매번 기다림이 끼면 async for가 필요하죠. async with도 마찬가지예요. 열고 닫는 데 기다림이 필요하면 async with예요. 지금은 "비동기 안에서 기다림이 끼는 with·for엔 async를 붙인다"만 기억하면 돼요.


Step 7: "GIL과 동시성 3갈래 — 큰 그림 조감"

오늘의 마지막 개념이에요. 코드 없이 큰 그림만 잡고 가요. 비동기를 이야기할 때 꼭 따라오는 단어가 GIL이거든요.

파이썬에는 GIL(Global Interpreter Lock, 전역 인터프리터 잠금)이라는 빗장이 있어요. 한 번에 파이썬 코드를 딱 한 줄만 실행하게 막는 잠금이에요. 일꾼이 여러 명 있어도, 이 빗장 때문에 파이썬 코드는 한 번에 한 명만 실행할 수 있어요. 그래서 "여러 일꾼을 둬서 계산을 진짜 병렬로 나누기"가 파이썬에선 잘 안 돼요.

그런데 우리가 오늘 본 asyncio는 왜 빨라졌을까요? 비밀은 asyncio가 기다리는 시간(I/O)을 활용했기 때문이에요. 기다리는 동안엔 파이썬 코드를 실행하지 않으니 GIL과 부딪히지 않아요. 즉 asyncio는 "계산을 나누는" 게 아니라 "기다림을 겹치는" 방식이라, GIL이 있어도 효과를 봐요. 그래서 서버 응답·파일·네트워크처럼 기다림이 많은 일에 딱이에요.

그럼 계산이 많은 일은요? 동시성을 얻는 길은 사실 세 갈래예요.

텍스트
동시성을 얻는 세 갈래
  asyncio          한 일꾼이 '기다리는 틈'에 다른 일        I/O(기다림) 많은 일에 강함
  threading        여러 일꾼, 단 GIL 때문에 한 번에 하나씩  I/O엔 도움, 계산 병렬은 약함
  multiprocessing  여러 프로세스(일꾼 + 각자 작업대)        CPU(계산) 많은 일에 강함

오늘 배운 asyncio는 기다림이 많은 일에, multiprocessing은 계산이 많은 일에 어울려요. "기다림이 많으면 asyncio, 계산이 많으면 multiprocessing" 한 줄로 기억해두면 돼요(threading은 그 사이에서 I/O를 돕는 도구예요).

⚠️ 한 가지 짚을 게 있어요. 파이썬 3.14부터 GIL을 끌 수 있는 특별한 빌드가 공식 지원(실험 딱지를 뗌)되기 시작했어요. 그렇다고 "GIL이 사라졌다"는 건 아니에요. 그 빌드는 따로 골라 설치해야 하는 선택 사항이고, 우리가 쓰는 보통 파이썬은 여전히 GIL을 유지해요. 앞으로 몇 년에 걸쳐 천천히 바뀔 이야기라, 지금은 "GIL을 끄는 길이 열리기 시작했지만 기본은 아직 GIL" 정도로 알아두면 충분해요.

💡 한 줄 정리

GIL은 파이썬 코드를 한 번에 한 줄만 실행하게 하는 빗장이라, 계산을 진짜 병렬로 나누긴 어려워요. 그래도 asyncio는 "기다림을 겹치는" 방식이라 GIL과 무관하게 I/O 작업에서 빨라져요. 동시성 3갈래(asyncio·threading·multiprocessing) 중 기다림엔 asyncio, 계산엔 multiprocessing이 어울려요.

🙋 학생 질문 — "튜터님, GIL은 왜 있는 거예요? 없으면 더 좋은 거 아닌가요?"

GIL이 있으면 좋은 점도 있어요. 빗장 덕분에 파이썬 내부가 한 번에 하나씩만 손대니, 여러 일꾼이 같은 데이터를 동시에 건드려 꼬이는 사고가 줄어들어요. 파이썬 구현이 단순하고 안정적으로 유지되는 데 큰 몫을 했죠.

대신 계산을 여러 일꾼으로 진짜 병렬화하기 어렵다는 단점이 있어요. 그래서 오랫동안 "GIL을 없애자"는 논의가 있었고, 3.14에서 드디어 끌 수 있는 길이 열리기 시작한 거예요. 다만 기존 코드와의 호환·안정성 때문에 천천히 가고 있어요. 지금 단계에선 "GIL은 안정성을 위한 빗장이고, 그 대가로 계산 병렬이 약하다" 정도만 이해하면 충분해요.


Step 8: "종합 — 우리 피드를 동시 로드로"

오늘 배운 걸 모아 F-2의 피드를 비동기로 끌어올려 볼게요. F-2에서 만든 feed.py의 제너레이터는 그대로 두고, "여러 회원의 게시물을 동시에 불러오는" 비동기 함수를 더해요.

Python
# src/instagram/feed.py
import asyncio
import time


def feed_stream(usernames: list[str]):
    """회원 이름마다 피드 한 줄을 하나씩 만들어 흘려보낸다 (F-2 동기 제너레이터)."""
    for username in usernames:
        yield f"@{username} 님의 새 게시물이 올라왔어요"

위는 F-2에서 만든 동기 제너레이터예요(그대로 살아 있어요). 여기에 아래 두 비동기 함수를 더해요.

Python
async def fetch_post(username: str) -> str:
    """한 회원의 최신 게시물을 서버에서 받아온다 (1초 걸린다고 가정)."""
    await asyncio.sleep(1)
    return f"@{username} 님의 새 게시물이 올라왔어요"


async def load_feed(usernames: list[str]) -> list[str]:
    """여러 회원의 게시물을 동시에 불러온다 — TaskGroup 으로 모두 끝날 때까지."""
    tasks = []
    async with asyncio.TaskGroup() as tg:
        for username in usernames:
            tasks.append(tg.create_task(fetch_post(username)))
    return [task.result() for task in tasks]

오늘 배운 게 다 들어 있죠. async def로 코루틴을 만들고(fetch_post), TaskGroup으로 여러 명을 동시에 출발시키고(load_feed), 블록을 벗어날 때 모두 끝나면 task.result()로 결과를 모아요. F-1에서 익힌 타입 힌트(list[str]·-> str)도 도메인 파일답게 그대로 달아줬어요.

실행하면 F-2의 동기 제너레이터와 F-4의 비동기 동시 로드가 나란히 보여요.

Python
# F-2 동기 제너레이터 — 하나씩
# @minji 님의 새 게시물이 올라왔어요
# @jaehoon 님의 새 게시물이 올라왔어요
# F-4 비동기 — 여러 명을 동시에
# @minji 님의 새 게시물이 올라왔어요
# @jaehoon 님의 새 게시물이 올라왔어요
# @seungwoo 님의 새 게시물이 올라왔어요
# 3 명의 게시물을 동시에 불러오는 데 약 1.0초

세 명을 각자 1초씩 걸려 불러왔는데 전체는 약 1초예요. F-3 마무리에서 예고한 "열 개를 거의 동시에 불러오는 마법", 우리 피드에서 진짜로 이뤄졌어요. 이 코드가 검증해둔 동작은 코드베이스의 테스트가 함께 확인해두고 있어요(순서 보존·동시 실행).

🌟 이 패턴(여러 요청을 TaskGroup으로 동시에 묶기)은 실무에서 외부 서버 여러 곳에 한꺼번에 요청을 보낼 때 그대로 쓰여요. 오늘 만든 게 장난감이 아니라 실전 골격이라는 뜻이에요.

💡 한 줄 정리

F-2의 피드 제너레이터는 그대로 두고, async def fetch_post + TaskGroup으로 load_feed를 더하면 여러 회원 게시물을 약 1초에 동시 로드해요. 오늘 배운 코루틴·동시 출발·결과 모으기가 한 함수에 모두 담겨요.

🙋 학생 질문 — "튜터님, 진짜 인스타라면 asyncio.sleep 자리에 뭐가 들어가나요?"

asyncio.sleep(1)은 "1초 걸리는 일"을 흉내 낸 가짜예요. 진짜 서비스라면 그 자리에 네트워크 요청이 들어가요. 다른 서버에 "이 회원의 최신 게시물 주세요"라고 보내고 답을 기다리는 일이요.

그 네트워크 요청도 await로 기다릴 수 있는 비동기 형태로 보내요. 그러면 오늘 배운 그대로, 여러 회원의 요청을 TaskGroup으로 동시에 보내고 한꺼번에 답을 받을 수 있어요. 그 비동기 네트워크 도구는 다음 과목들에서 만나니, 지금은 "asyncio.sleep 자리에 진짜 네트워크 요청이 들어간다"만 알면 충분해요.


마무리

오늘은 모던 파이썬의 마지막 봉우리, 비동기를 넘었어요. "기다리는 틈에 다른 일을 출발시킨다"는 한 문장에서 출발해, 코루틴을 만들고 여러 개를 동시에 묶어 약 3초 걸릴 일을 약 1초로 줄였죠. F-3에서 예고한 "동시 로드의 마법"을 우리 피드에서 직접 확인했고요.

오늘 배운 핵심 세 가지

  • 💡 하나동기 vs 비동기. 동기는 한 작업이 끝나야 다음으로 가서 기다리는 시간을 버려요. 비동기는 그 틈에 다른 작업을 출발시켜요. async def로 만든 코루틴은 부르면 객체만 생기고, asyncio.run()으로 시동을 걸어야 돌아가요. await는 "기다리되 그 틈을 양보한다"는 표시고요.
  • 💡 동시 출발. await만 차례로 붙이면 여전히 순차라 안 빨라져요(가장 흔한 함정). 여러 코루틴을 asyncio.gather()로 묶어 한 번에 await하거나, asyncio.TaskGroup()(3.11+) 블록으로 등록하면 동시에 출발해 약 1초에 끝나요. 안전한 현대 표준은 TaskGroup이에요.
  • 💡 큰 그림. async with/async for는 우리가 아는 with/for의 비동기 버전이에요. 그리고 GIL은 파이썬 코드를 한 줄씩만 실행하게 하지만, asyncio는 "기다림을 겹치는" 방식이라 I/O 작업에서 빨라져요. 동시성 3갈래 중 기다림엔 asyncio, 계산엔 multiprocessing이 어울려요.

다음 시간 예고

이걸로 모던 파이썬(F) 카테고리를 모두 마쳤어요! 타입 힌트·제너레이터·데코레이터·패턴 매칭·비동기까지, 문법의 봉우리를 다 넘은 셈이에요. 이제 마지막 카테고리만 남았어요.

다음 시간엔 G-1 유용한 표준 라이브러리를 둘러봐요. 파이썬은 "배터리 포함"이라, 설치 없이 바로 쓸 수 있는 도구가 한가득이에요. 인기 해시태그를 세는 도구, 날짜·시간을 다루는 도구, 글에서 원하는 패턴을 찾아내는 도구 같은 걸 골라 익혀요. 그리고 이어지는 G-2에선 지금까지 만든 코드가 "진짜 잘 도는지"를 자동으로 확인하는 방법까지 익히며 과정을 닫아요. 문법을 다 배웠으니, 이제 그 문법으로 만든 도구상자를 채울 차례예요.


과제

오늘 배운 비동기를 직접 손으로 굴려볼 차례예요. 막히면 해당 Step을 다시 펼쳐 보세요. 각 함수는 asyncio.run()으로 실행해 확인하면 돼요.

[기초] 두 가지 알림을 동시에 보내기

회원에게 알림 두 개(이메일, 푸시)를 동시에 보내는 send_notifications를 만들어 보세요.

Python
import asyncio


async def send_email(to):
    await asyncio.sleep(1)
    return f"{to} 에게 이메일 전송 완료"


async def send_push(to):
    await asyncio.sleep(1)
    return f"{to} 에게 푸시 전송 완료"


async def send_notifications(to):
    # 여기를 채워 보세요 — 둘을 동시에 보내고 결과를 리스트로 돌려주세요
    ...
  • send_emailsend_push동시에 출발시켜 결과 두 개를 리스트로 돌려주세요.
  • 차례로 await하면 약 2초, 동시에 출발시키면 약 1초예요. 1초가 나오게 만들어 보세요.
  • 힌트: Step 4의 asyncio.gather()를 떠올리세요. await asyncio.gather(send_email(to), send_push(to)) 한 줄이면 돼요.

[응용] TaskGroup으로 여러 회원 프로필 동시 로드하기

회원 이름 목록을 받아 각자의 프로필을 동시에 불러오는 load_profiles를 만들어 보세요.

Python
import asyncio


async def fetch_profile(username):
    await asyncio.sleep(1)
    return f"@{username} 프로필"


async def load_profiles(usernames):
    # 여기를 채워 보세요 — TaskGroup 으로 동시에 불러오세요
    ...
  • asyncio.TaskGroup() 블록 안에서 회원마다 tg.create_task(fetch_profile(...))로 등록하세요.
  • 블록을 벗어난 뒤 task.result()로 결과를 모아 리스트로 돌려주세요.
  • 회원이 다섯 명이어도 약 1초에 끝나야 해요. 힌트: Step 5와 Step 8의 load_feed를 거의 그대로 따라 하면 돼요.

[심화] async 제너레이터로 라이브 피드 만들기

게시물을 한 줄씩 비동기로 흘려보내는 async 제너레이터 live_feed와, 그걸 async for로 받아 출력하는 함수를 만들어 보세요.

  • live_feed(usernames)는 회원마다 await asyncio.sleep(0.2)yield f"@{이름} 님의 라이브"를 흘려보내는 async 제너레이터여야 해요(async def + yield).
  • 이를 async for로 한 줄씩 받아 출력하는 코루틴을 만들고 asyncio.run()으로 실행하세요.
  • 힌트: Step 6의 stream_postsasync for 부분을 떠올리세요. async for line in live_feed([...]): 처럼 쓰면 돼요.

생각해볼 주제

1. 비동기는 언제 빛나고, 언제 의미가 없을까

오늘 본 것처럼 비동기는 1초짜리 일 세 개를 약 1초로 줄였어요. 그런데 이 "1초"는 사실 기다리는 시간이었죠(asyncio.sleep). 만약 게시물 하나를 만드는 데 "기다림"이 아니라 "빡센 계산"이 1초 걸린다면, 비동기로 묶어도 약 3초 그대로일 거예요. 왜 그럴까요? 일꾼이 한 명이라 계산을 겹칠 수는 없다는 Step 7의 GIL 이야기와 연결 지어, "기다림이 많은 일"과 "계산이 많은 일"이 비동기에서 왜 정반대로 갈리는지 생각해 보세요.

2. gather와 TaskGroup, 어느 쪽을 고를까

둘 다 여러 코루틴을 동시에 돌리는데, gather는 한 줄로 간단하고 TaskGroup은 하나가 실패하면 나머지를 정리해줘요. 게시물 열 개를 불러오다 세 번째에서 에러가 났다고 해봐요. gather로 묶었을 때와 TaskGroup으로 묶었을 때, 나머지 일곱 개의 운명이 어떻게 달라질지 상상해 보세요. "그냥 다 받아오면 그만"인 상황과 "하나라도 실패하면 전부 취소하고 다시"인 상황에서 각각 무엇을 고르는 게 자연스러울지 따져 보세요.

3. GIL을 끄는 미래가 오면 무엇이 달라질까

Step 7에서 파이썬 3.14가 GIL을 끄는 길을 열기 시작했다고 했죠. 만약 언젠가 GIL이 기본으로 꺼진다면, 동시성 3갈래(asyncio·threading·multiprocessing) 중 무엇의 입지가 달라질까요? 특히 "계산이 많은 일"을 다루는 방식이 어떻게 바뀔 수 있을지 생각해 보세요. 반대로, GIL이 꺼져도 asyncio가 여전히 필요한 이유(기다림을 겹치는 일은 그대로 남는다)도 함께 짚어 보면 좋아요.

✅ 예시 답안정답 보기

과제와 생각해볼 주제의 예시답안이에요. 먼저 스스로 손으로 짠 뒤에 펼쳐 보세요. 비동기 코드는 직접 asyncio.run()으로 굴려 "약 1초"가 정말 나오는지 눈으로 확인하면 훨씬 오래 남아요.


🎯 [과제 1 예시답안] 두 가지 알림을 동시에 보내기

채점 포인트

항목 배점 확인
asyncio.gather()로 두 코루틴을 동시에 출발시키는가 ★★★
결과 두 개를 리스트로 돌려주는가 ★★☆
차례로 await하지 않아 약 1초에 끝나는가 ★★★

풀이 예시

Python
import asyncio


async def send_email(to):
    await asyncio.sleep(1)
    return f"{to} 에게 이메일 전송 완료"


async def send_push(to):
    await asyncio.sleep(1)
    return f"{to} 에게 푸시 전송 완료"


async def send_notifications(to):
    # gather 로 둘을 동시에 출발 — 약 2초가 아니라 약 1초에 끝난다
    results = await asyncio.gather(send_email(to), send_push(to))
    return list(results)
Python
import time

start = time.perf_counter()
results = asyncio.run(send_notifications("@minji"))
elapsed = round(time.perf_counter() - start, 1)
print(results)
print(f"두 알림 동시 전송: 약 {elapsed}초")
# ['@minji 에게 이메일 전송 완료', '@minji 에게 푸시 전송 완료']
# 두 알림 동시 전송: 약 1.0초

핵심은 await asyncio.gather(send_email(to), send_push(to)) 한 줄이에요. 두 코루틴을 묶어 한 번에 await하니 둘이 동시에 1초씩 기다리고 함께 끝나, 전체가 약 1초예요.

✅ 두 코루틴을 gather에 함께 넘겼는가 — 그래야 동시에 출발해요. ❌ await send_email(to) 다음 줄에 await send_push(to)로 따로 적으면 — Step 3의 함정대로 약 2초가 돼요.

💡 튜터의 한마디: "서로 상관없는 일 여러 개를 한꺼번에"가 비동기가 자주 쓰이는 상황이에요. 이메일·푸시·로그 기록처럼 서로 기다릴 필요 없는 일들은 gather로 묶으면 시간을 그만큼 아낄 수 있어요.

🎯 [과제 2 예시답안] TaskGroup으로 여러 회원 프로필 동시 로드하기

채점 포인트

항목 배점 확인
async with asyncio.TaskGroup() as tg: 블록을 여는가 ★★★
tg.create_task()로 회원마다 일을 등록하는가 ★★★
블록을 벗어난 뒤 task.result()로 결과를 모으는가 ★★★
회원이 다섯이어도 약 1초에 끝나는가 ★★☆

풀이 예시

Python
import asyncio


async def fetch_profile(username):
    await asyncio.sleep(1)
    return f"@{username} 프로필"


async def load_profiles(usernames):
    tasks = []
    async with asyncio.TaskGroup() as tg:
        for username in usernames:
            tasks.append(tg.create_task(fetch_profile(username)))
    return [task.result() for task in tasks]
Python
import time

start = time.perf_counter()
profiles = asyncio.run(load_profiles(["minji", "jaehoon", "seungwoo", "yuna", "doyun"]))
elapsed = round(time.perf_counter() - start, 1)
print(profiles)
print(f"5 명 프로필 동시 로드: 약 {elapsed}초")
# ['@minji 프로필', '@jaehoon 프로필', '@seungwoo 프로필', '@yuna 프로필', '@doyun 프로필']
# 5 명 프로필 동시 로드: 약 1.0초

Step 8의 load_feed와 거의 똑같아요. create_task로 다섯 명을 모두 등록하면 동시에 출발하고, async with 블록을 벗어나는 순간 다섯이 모두 끝나 있어요. 그래서 다섯 명이어도 약 1초죠.

tasks 리스트에 create_task 결과를 모아두었는가 — 그래야 나중에 .result()로 꺼낼 수 있어요. ❌ 블록 안에서 await tg.create_task(...)처럼 바로 await하면 — 등록하자마자 끝까지 기다려 순차가 돼요. create_task는 await 없이 등록만 해요.

💡 튜터의 한마디: TaskGroup은 "여러 일을 묶어 안전하게 관리"할 때 써요. 결과를 따로 모으고 싶으면 create_task가 돌려준 task를 리스트에 담아두고, 블록을 나온 뒤 .result()로 꺼내는 흐름을 기억해두면 두루 쓸 수 있어요.

🎯 [과제 3 예시답안] async 제너레이터로 라이브 피드 만들기

채점 포인트

항목 배점 확인
async def + yield로 async 제너레이터를 만드는가 ★★★
한 줄마다 await asyncio.sleep(0.2)로 기다리는가 ★★☆
async for로 한 줄씩 받아 출력하는가 ★★★

풀이 예시

Python
import asyncio


async def live_feed(usernames):
    for username in usernames:
        await asyncio.sleep(0.2)
        yield f"@{username} 님의 라이브"


async def show_live_feed(usernames):
    lines = []
    async for line in live_feed(usernames):
        print(line)
        lines.append(line)
    return lines
Python
asyncio.run(show_live_feed(["minji", "jaehoon", "seungwoo"]))
# @minji 님의 라이브
# @jaehoon 님의 라이브
# @seungwoo 님의 라이브

live_feedasync def 안에 yield가 있어 async 제너레이터예요. 한 줄을 흘려보낼 때마다 await로 잠깐 기다리죠. 이런 비동기 제너레이터는 평범한 for로 못 돌고 async for로 받아야 해요. 그래서 show_live_feedasync for line in live_feed(...)로 한 줄씩 받아 출력해요.

live_feedasync defyield가 함께 있는가 — 이 둘이 모이면 async 제너레이터예요. ❌ for line in live_feed(...)처럼 그냥 for로 돌면 — async 제너레이터는 평범한 for로 못 돌아 에러가 나요. async for가 필요해요.

💡 튜터의 한마디: "한 항목을 꺼낼 때마다 기다림이 끼는 흐름"에 async 제너레이터가 어울려요. 서버에서 실시간으로 한 줄씩 흘러오는 데이터를 받을 때 딱이고, 받는 쪽은 늘 async for로 맞춰요.


생각해볼 주제 예시답안

🤔 [생각해볼 주제 1] 비동기는 언제 빛나고, 언제 의미가 없을까

문제 상황 요약

오늘 비동기는 1초짜리 일 세 개를 약 1초로 줄였어요. 그런데 그 1초가 "빡센 계산"이었다면 비동기로 묶어도 약 3초 그대로예요. 왜 기다림과 계산이 정반대로 갈릴까요?

튜터의 가이드 및 해설

핵심은 "비동기가 줄이는 건 시간이 아니라 낭비되던 기다림"이라는 점이에요.

asyncio.sleep(1)은 "1초 동안 아무것도 안 하고 기다림"이에요. 그래서 게시물1이 기다리는 동안 일꾼(파이썬)이 한가해요. 그 한가한 틈에 게시물2, 게시물3을 출발시키니, 셋의 기다림이 겹쳐서 전체가 약 1초가 돼요. 기다림이 많을수록(서버 응답·파일·네트워크) 겹칠 틈이 많아 효과가 커요.

반대로 "빡센 계산"은 그 1초 내내 일꾼이 쉬지 않고 일해요. 한가한 틈이 없으니 겹칠 게 없죠. Step 7의 GIL을 떠올리면 더 분명해요. 파이썬은 한 번에 코드 한 줄만 실행하니, 계산 세 개를 비동기로 묶어도 일꾼 한 명이 결국 차례로 다 해내야 해요. 그래서 약 3초 그대로예요.

그래서 기준이 이렇게 갈려요. 기다림이 많은 일(I/O)이면 asyncio가 빛나고, 계산이 많은 일(CPU)이면 asyncio는 의미가 없어요. 후자는 Step 7에서 본 multiprocessing처럼 일꾼(작업대)을 진짜로 늘리는 방법이 어울려요.

💡 핵심을 한마디로

"비동기는 일을 빨리 하는 게 아니라, 기다리는 시간을 겹쳐서 버리지 않는 거예요. 그래서 기다림이 많은 일엔 빛나고, 쉴 틈 없는 계산엔 효과가 없어요."

🤔 [생각해볼 주제 2] gather와 TaskGroup, 어느 쪽을 고를까

문제 상황 요약

게시물 열 개를 동시에 불러오다 세 번째에서 에러가 났어요. gather로 묶었을 때와 TaskGroup으로 묶었을 때, 나머지 일곱 개의 운명이 어떻게 달라질까요?

튜터의 가이드 및 해설

가장 큰 차이는 "하나가 실패했을 때 나머지를 어떻게 다루느냐"예요.

TaskGroup은 묶인 일 중 하나가 에러를 내면, 아직 끝나지 않은 나머지 일들을 깔끔하게 멈추고(취소하고) 에러를 올려보내요. "이 묶음은 다 같이 성공해야 의미가 있다"는 상황에 맞아요. 예를 들어 주문 처리에서 결제·재고차감·배송등록을 동시에 하는데 하나라도 실패하면 나머지도 되돌려야 하죠. 이럴 땐 TaskGroup이 안전망이 돼요.

gather는 기본적으로 하나가 실패하면 그 에러를 올려보내되, 나머지를 자동으로 멈춰주진 않아요(끝까지 받고 싶으면 실패도 결과로 모으는 선택지도 있어요). "여러 곳에서 그러모으되, 일부 실패해도 받을 수 있는 만큼 받자"는 느슨한 상황에 어울려요. 예를 들어 여러 추천 서버에서 결과를 모으는데 한 곳이 죽어도 나머지로 화면을 채울 수 있다면 gather가 가벼워요.

그래서 "다 같이 성공해야 하면 TaskGroup, 일부 실패를 견뎌도 되면 gather"로 나눠 생각하면 좋아요. 새 코드에서 안전을 먼저 챙기고 싶다면 TaskGroup을 기본으로 두는 게 요즘 흐름이에요.

💡 핵심을 한마디로

"TaskGroup은 '하나라도 실패하면 다 같이 정리', gather는 '받을 수 있는 만큼 그러모으기'예요. 전부-아니면-전무 묶음엔 TaskGroup, 느슨한 모으기엔 gather를 골라요."

🤔 [생각해볼 주제 3] GIL을 끄는 미래가 오면 무엇이 달라질까

문제 상황 요약

파이썬 3.14가 GIL을 끄는 길을 열기 시작했어요. 언젠가 GIL이 기본으로 꺼진다면 동시성 3갈래(asyncio·threading·multiprocessing) 중 무엇의 입지가 달라질까요? 그래도 asyncio가 여전히 필요한 이유는요?

튜터의 가이드 및 해설

가장 크게 달라질 건 threading의 입지예요. 지금은 GIL 때문에 일꾼을 여럿 둬도 계산을 진짜 병렬로 못 나눠서, threading은 주로 I/O를 돕는 정도였어요. 만약 GIL이 꺼지면 여러 일꾼이 진짜로 동시에 계산할 수 있게 되니, "계산이 많은 일"을 threading으로도 병렬화할 길이 열려요. 지금은 그런 일을 multiprocessing(프로세스를 따로 띄우기)으로 무겁게 풀었는데, 더 가벼운 선택지가 생기는 셈이죠.

그렇다고 asyncio가 필요 없어지진 않아요. asyncio가 푸는 문제는 "계산을 나누기"가 아니라 "기다림을 겹치기"거든요. GIL이 꺼지든 말든, 서버 응답을 기다리는 그 시간 자체는 그대로 남아요. 그 기다림의 틈에 다른 일을 출발시키는 일은 여전히 asyncio가 가장 깔끔하게 해내요. 일꾼을 늘린다고 "기다림"이 줄어드는 건 아니니까요.

그래서 미래 그림은 "계산 병렬의 선택지가 넓어지고(threading의 부활 가능성), 기다림을 겹치는 asyncio는 그대로 자기 자리를 지킨다"예요. 둘은 푸는 문제가 달라서 경쟁이 아니라 역할 분담이에요.

💡 핵심을 한마디로

"GIL이 꺼지면 계산 병렬(threading·multiprocessing)의 판이 달라져요. 하지만 asyncio는 '기다림을 겹치는' 다른 문제를 풀기에, GIL과 상관없이 계속 필요해요."

전체 목록 파이썬 기초