R

ReflexKR

AI의 일기장Reflex 공식 문서

심야 주방의 동시성

·

강의 개요

1

첫날 밤의 줄: 동시성과 병렬성

2

요리사를 더 뽑다: threading 기초

3

칠판의 숫자가 틀렸다: 경쟁 상태와 Lock

4

화구는 셋뿐: 동기화 도구

5

칼과 도마: 데드락

6

주문서 꽂이: queue

7

출장 요리사 파견: concurrent.futures

8

반죽은 왜 안 빨라질까: GIL과 병렬

9

혼자 도는 홀 매니저: asyncio 첫걸음

10

단체 예약 취소: 구조화된 동시성

11

멈춰 선 홀: 블로킹과 섞어 쓰기

12

평론가의 밤: 종합

심야 주방의 동시성
12교시

강의 시간 9:10–10:00

평론가의 밤: 종합

오늘의 주문서

#12 · 열두째 밤

평론가의 밤

위기

창가 자리의 손님이 명함을 내민다. 미식 평론가. 오늘 밤 열두 접시 코스를 정식으로 주문한다. 3·7·11번째 접시는 처음 한 번 유난히 오래 걸린다.

목표

열두 접시를 모두 내되, 화구는 셋까지만 쓰고, 3초 안에 끝낸다.

심야 식당 동시식당

금요일 밤 열 시. 약속대로 창가 자리의 손님이 들어옵니다. 늘 혼자, 늘 국수 한 그릇, 늘 손목시계와 작은 수첩. 오늘은 자리에 앉자마자 명함 한 장을 테이블에 올려 둡니다. 미식 평론가.

"지난 몇 주 동안 이 주방을 봤어요. 오늘은 제대로 주문할게요. 코스 열두 접시로." 평론가가 수첩을 펴고 손목시계를 풉니다.

주방이 조용해집니다. 민호는 칼을 떨어뜨리고, 수아는 주문서 꽂이를 다시 정리하고, 탁 씨는 그저 화구를 닦습니다. 루가 주문서를 들고 들어옵니다. 윤서연 셰프가 목에 건 타이머를 0으로 돌립니다. "주방은 프로그램이다. 오늘은 그동안 만든 걸 전부 돌린다."

12-1. 어떤 도구를 꺼낼까 (8분)

열한 번의 밤 동안 도구가 많이 쌓였습니다. 손님이 들어올 때 먼저 묻는 질문은 하나입니다. "이 일은 기다리는 일인가, 손이 바쁜 일인가?"

상황꺼낼 도구주방으로 치면교시
기다리는 일이 아주 많다(연결 수백~수만)asyncio (gather, TaskGroup)혼자 도는 홀 매니저 루9~10
기다리는 일인데 동기 라이브러리뿐, 수십 개threading, ThreadPoolExecutor요리사 여럿, 출장 요리사2, 7
계산이 많다(CPU)ProcessPoolExecutor, multiprocessing (또는 3.14t의 스레드)분점 주방8
asyncio 안의 오래된 블로킹 코드asyncio.to_thread오븐 앞에 따로 선 사람11
asyncio 안의 무거운 계산loop.run_in_executor(프로세스 풀, ...)분점에 반죽 맡기기11
여럿이 한 값을 고친다Lock, 또는 큐로 주인을 하나로칠판 앞 잠금, 주문서 꽂이3, 6
동시에 n개까지만Semaphore(n)화구 셋4, 11
같이 끝나야 하고, 시간을 넘기면 안 된다TaskGroup, asyncio.timeout단체 예약10

고르는 순서는 이렇습니다.

  1. 동시성이 정말 필요한가? 순서대로 해도 충분히 빠르면 그대로 둡니다. 1교시에서 본 대로 동시성은 공짜가 아닙니다.
  2. 기다리는 일인가, 계산인가? 기다리는 일이면 asyncio나 스레드, 계산이면 프로세스(또는 3.14t)입니다. GIL이 있는 빌드에서 계산을 스레드로 나눠도 빨라지지 않습니다(8교시).
  3. 이미 쓰는 라이브러리가 async를 지원하는가? 지원하면 asyncio로, 아니면 스레드로 시작하거나 asyncio 안에서 to_thread로 감쌉니다.
  4. 무엇을 함께 쓰는가? 공유 상태가 있으면 잠금이나 큐, 자원 수에 한계가 있으면 세마포어, 함께 끝나야 하면 TaskGroup을 붙입니다.

12-2. 평론가의 코스 처리기 (14분)

평론가의 주문서는 이렇습니다. 접시는 열두 개, 한 접시에 0.5초. 그런데 3·7·11번째 접시는 처음 한 번은 재료가 덜 풀려 세 배(1.5초)가 걸립니다. 다시 하면 0.5초에 됩니다. 화구는 셋뿐입니다.

필요한 부품은 세 가지입니다. 화구 수를 지키는 세마포어, 한 접시가 늘어지지 않게 하는 시간 제한, 끊긴 접시를 다시 하는 재시도. 이것을 TaskGroup 하나에 담습니다.

import asyncio, time

SLOW = {3, 7, 11}                      # 처음 한 번은 세 배 걸리는 접시

async def plate(n, attempt):
    await asyncio.sleep(0.5 * (3 if n in SLOW and attempt == 0 else 1))

async def order(n, stoves, limit_s, retries):
    for attempt in range(retries + 1):
        async with stoves:             # 화구를 잡은 동안만 요리한다
            try:
                async with asyncio.timeout(limit_s):
                    await plate(n, attempt)
                return True
            except TimeoutError:
                pass                   # 화구를 내려놓고 줄 끝으로 가서 다시
    return False

async def service(stoves_n, limit_s, retries):
    stoves = asyncio.Semaphore(stoves_n)
    start = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(order(n, stoves, limit_s, retries)) for n in range(1, 13)]
    served = sum(t.result() for t in tasks)
    print(f"stoves={stoves_n} limit={limit_s} retries={retries}: "
          f"served {served}/12, {time.perf_counter() - start:.1f}s")

asyncio.run(service(3, None, 0))       # 시간 제한 없음
asyncio.run(service(3, 0.6, 0))        # 제한만
asyncio.run(service(3, 0.6, 1))        # 제한 + 재시도 한 번
# stoves=3 limit=None retries=0: served 12/12, 3.5s
# stoves=3 limit=0.6 retries=0: served 9/12, 2.2s
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s
# (걸린 시간은 실행마다 0.1초쯤 다르다. 첫 줄은 3.5s나 3.6s)
  • asyncio.timeout(None)은 제한 없음입니다. 그래서 같은 함수로 "제한 없음"과 "0.6초 제한"을 함께 돌릴 수 있습니다.
  • 제한이 없으면 느린 접시 셋이 1.5초씩 화구를 붙잡아, 열두 접시가 모두 나가도 3.5초 남짓 걸립니다.
  • 제한만 걸면 느린 접시가 0.6초에 잘려 화구가 빨리 풀립니다. 대신 그 세 접시는 영영 나가지 못합니다.
  • 제한과 재시도를 함께 걸면, 잘린 접시가 화구를 내려놓고 줄 끝에 다시 섭니다. 두 번째에는 0.5초에 끝나 열두 접시가 모두 나갑니다.

설계에서 짚을 점이 두 가지 있습니다.

  • 재시도할 때 화구를 내려놓습니다. async with stoves가 for 안에 있어서, 잘린 접시는 화구를 풀고 다시 줄을 섭니다. 화구를 쥔 채 재시도하면 그사이 다른 접시가 놉니다.
  • 실패를 예외로 올리지 않고 False로 돌려줍니다. TaskGroup에서 예외가 나면 나머지가 모두 취소됩니다(10교시). 단체 예약이라면 그게 맞지만, 코스는 한 접시가 실패해도 나머지는 내야 합니다. 같은 이유로 asyncio.gather로 모아도 됩니다.

실무에서는 재시도 사이에 await asyncio.sleep(0.05 * 2 ** attempt)처럼 조금씩 길게 쉬는 경우가 많습니다. 모두가 동시에 다시 몰려드는 것을 막기 위해서입니다(5교시의 라이브락과 같은 이유).

아래 타임라인은 서버에서 같은 처리기를 돌립니다. 줄 하나가 접시 하나이고, 시간 제한에 잘린 시도는 회색, 재시도는 r1, r2가 붙습니다. 처음 설정은 일부러 목표를 맞추지 못하게 해 두었습니다.

주방 타임라인

주문

6

화구

2

시간 제한(초)

끔

재시도

0

요리

기다림

취소

실패

평론가의 열두 접시. 화구, 시간 제한, 재시도를 바꿔 '서빙 12 · 실패 0, 화구 3 이하, 3초 안에'를 맞춰 보세요. 처음 설정으로는 한 접시도 나가지 않습니다.
  • 처음 설정(화구 2, 시간 제한 0.4초, 재시도 0)으로 돌리면 약 2.4초 만에 서빙 0 · 실패 12가 나옵니다. 보통 접시도 0.5초가 걸리는데 제한이 0.4초라, 모든 접시가 잘립니다.
  • 화구를 3으로 늘리고 재시도만 2로 올리면 더 나빠집니다. 같은 0.4초 제한으로 세 번 잘릴 뿐이라 여전히 서빙 0이고, 시간만 약 4.9초로 늘어납니다. 재시도는 시간 제한이 잘못된 것을 고쳐 주지 않습니다. 제한을 보통 접시와 똑같은 0.5초로 맞춰도 아슬아슬하게 잘려 모두 실패합니다.
  • 시간 제한을 끄면 모두 서빙되지만, 화구 3에서도 3.5초 남짓 걸립니다. 화구를 4로 늘려도 약 3.0초라 아슬아슬하고, 무엇보다 화구는 셋뿐입니다.
  • 화면은 실험을 5초 남짓까지만 기다립니다. 화구 2에 재시도 2처럼 오래 걸리는 설정은 중간에 끊겨 보일 수 있으니, 설정을 바꿔 다시 돌리세요.

셰프의 한마디

시간 제한은 '보통 접시보다는 길게, 망친 접시보다는 짧게'야. 보통 접시까지 자르면 재시도를 아무리 붙여도 소용없어. 숫자를 정하기 전에 보통 걸리는 시간부터 재.

12-3. 한 주방에 모두 (8분)

평론가의 코스에는 반죽(계산), 낡은 오븐의 빵(블로킹), 화구 요리(기다림)가 함께 있습니다. 각각을 맞는 사람에게 맡기고, 홀의 루가 모두 기다립니다. 프로세스를 쓰므로 파일로 저장해 실행합니다.

import asyncio, threading, time
from concurrent.futures import ProcessPoolExecutor

def knead(n):                          # 반죽: 손이 바쁜 일 → 분점 주방(프로세스)
    return sum(i * i for i in range(n)) % 97

tally_lock = threading.Lock()          # 오븐 스레드와 루프가 함께 고치는 장부
tally = {"plates": 0}

def count_plate():
    with tally_lock:                   # 잠금 안에는 await도, 긴 일도 없다
        tally["plates"] += 1

def old_oven(item):                    # 낡은 오븐: 블로킹 → 스레드
    time.sleep(0.4)
    count_plate()
    return f"{item} baked"

async def stove_dish(n, stoves):       # 화구 요리: 세마포어
    async with stoves:
        await asyncio.sleep(0.2)
    count_plate()
    return f"#{n}"

async def main():
    loop = asyncio.get_running_loop()
    stoves = asyncio.Semaphore(3)
    with ProcessPoolExecutor(max_workers=2) as dough_room:
        async with asyncio.timeout(5):         # 코스 전체의 마감
            results = await asyncio.gather(
                loop.run_in_executor(dough_room, knead, 3_000_000),
                asyncio.to_thread(old_oven, "bread"),
                *(stove_dish(n, stoves) for n in range(1, 7)),
            )
    print(results)
    print(tally)

if __name__ == "__main__":
    asyncio.run(main())
# [56, 'bread baked', '#1', '#2', '#3', '#4', '#5', '#6']
# {'plates': 7}
일맡은 사람도구
홀 전체, 기다리기루asyncio.run, gather
반죽(계산)분점 주방run_in_executor + ProcessPoolExecutor
낡은 오븐(블로킹)오븐 앞 요리사asyncio.to_thread
화구 요리화구 셋asyncio.Semaphore(3)
장부(공유 상태)칠판 잠금threading.Lock (스레드와 루프가 함께 쓰므로)
마감셰프의 타이머asyncio.timeout(5)

장부는 오븐 스레드와 루프 양쪽에서 고치므로 threading.Lock을 썼습니다. 루프 쪽에서 이 잠금을 잡아도 괜찮은 이유는, 잠금 안에 await가 없고 숫자 하나만 고치고 바로 풀기 때문입니다. 11교시에서 본 사고는 잠금을 쥔 채 await로 양보할 때 납니다.

12-4. 동시성 버그 점검 목록 (6분)

평론가 앞에서 주방이 멈추지 않도록, 영업 전에 이 여섯 가지를 확인합니다.

점검증상처방교시
공유 상태숫자가 가끔 틀린다읽기~쓰기를 Lock으로 묶거나, 큐로 주인을 하나로3, 11
잠금 순서가끔 영원히 멈춘다모두 같은 순서로 잠근다, acquire(timeout=)5
블로킹 호출asyncio가 통째로 느리다to_thread, debug=True로 찾기11
삼킨 취소시간 제한과 TaskGroup이 안 듣는다CancelledError는 뒤정리 후 다시 raise10
참조 없는 태스크백그라운드 일이 가끔 사라진다태스크를 모아 붙잡거나 TaskGroup9
끝나지 않는 큐join()이 돌아오지 않는다가져간 만큼 task_done(), 종료 신호(None, shutdown())6, 11

"참조 없는 태스크"의 처방은 이렇습니다. 태스크를 집합에 넣어 붙잡아 두고, 끝나면 스스로 빠지게 합니다.

import asyncio

running = set()                        # 태스크를 붙잡아 두는 곳

async def wipe(table):
    await asyncio.sleep(0.05)
    print("wiped", table)

async def main():
    for t in ("T1", "T2"):
        task = asyncio.create_task(wipe(t))
        running.add(task)
        task.add_done_callback(running.discard)   # 끝나면 스스로 빠진다
    await asyncio.sleep(0.1)
    print(len(running))

asyncio.run(main())
# wiped T1
# wiped T2
# 0

"끝나지 않는 큐"는 task_done()을 하나라도 빠뜨리면 생깁니다. 의심될 때는 join()에 시간 제한을 걸어 확인합니다.

import asyncio

async def cook(q):
    while True:
        await q.get()
        await asyncio.sleep(0.01)
        # q.task_done()  ← 잊어버렸다

async def main():
    q = asyncio.Queue()
    for n in range(3):
        q.put_nowait(n)
    worker = asyncio.create_task(cook(q))
    try:
        async with asyncio.timeout(0.3):
            await q.join()
    except TimeoutError:
        print("join never finished:", q.qsize(), "in queue")
    worker.cancel()

asyncio.run(main())
# join never finished: 0 in queue

큐는 비었는데(0 in queue) join()은 끝나지 않습니다. 꺼낸 주문서 세 장이 "다 했다"는 표시를 받지 못했기 때문입니다.

12-5. 열한 밤의 주방 다시 돌리기 (4분)

지난 밤들의 주방을 한 실습대에 모았습니다. 같은 주문 여덟 개를 방식만 바꿔 돌려 보세요. 요리사와 화구 슬라이더는 그 값을 쓰는 방식에서만 효과가 있습니다.

주방 타임라인

주문

6

요리사

3

화구

2

요리

기다림

취소

실패

주문 8개, 각 0.3초. 혼자 순서대로, 요리사 여럿, 화구 제한, 출장 요리사, gather를 차례로 돌려 막대 모양과 걸린 시간을 비교해 보세요.
  • 혼자 순서대로: 한 줄로 약 2.4초(1교시).
  • 요리사 4명: 네 줄이 겹쳐 약 0.6초(2교시). 화구를 2개로 제한하면 노란 기다림이 생기고 약 1.2초(4교시).
  • 출장 요리사: 뒤 번호일수록 짧은 주문이라 돌아온 순서가 뒤섞이고, 약 0.4초(7교시).
  • gather: 테이블 여덟 개가 모두 겹쳐 약 0.3초(9교시). 요리사 수와 상관없이, 루 혼자서.

영업 마감 (10분)

평론가의 코스를 맞춘 설정은 화구 3, 시간 제한 0.6초, 재시도 1입니다. 서버의 실습대 엔진으로 직접 돌려 확인한 값입니다.

화구시간 제한재시도서빙 · 실패걸린 시간목표
20.4초00 · 12약 2.4초실패
3끔012 · 0약 3.5~3.6초시간 초과
4끔012 · 0약 3.0초화구 초과
30.4초20 · 12약 4.9초실패
30.6초09 · 3약 2.2초3접시 실패
30.6초112 · 0약 2.7초성공
# 12-2의 service 함수로
asyncio.run(service(3, 0.6, 1))
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s
  • 0.6초 제한은 보통 접시(0.5초)는 통과시키고, 처음 늘어진 접시(1.5초)만 0.6초에 자릅니다.
  • 잘린 세 접시는 화구를 내려놓고 줄 끝에서 한 번 더 시도해 0.5초에 끝납니다.
  • 시간 제한을 0.55초로 조금 더 줄여도 목표를 맞춥니다(약 2.65초). 0.7초로 늘리면 약 2.9초로 3초에 바짝 붙고, 0.8초면 3초를 넘습니다.

열두 번째 접시가 나가고 2.7초. 셰프의 타이머가 멈춥니다. 평론가가 수첩에 몇 줄을 적고, 국수 그릇을 비운 뒤 일어섭니다. 계산대 앞에서 셰프에게 수첩의 한 장을 찢어 건넵니다.

"열두 접시가 한 번도 식지 않고 나왔어요. 기다림은 있었지만, 멍하니 기다리는 사람은 없었어요. 또 올게요. 이번엔 손님으로."

평론가가 나가고, 주방에 불이 하나씩 꺼집니다. 셰프가 앞치마 주머니에서 마지막 레시피 카드를 꺼내 수셰프에게 건넵니다. "열두 밤이었어. 내일 밤 열 시에도 문은 열려. 이번엔 네가 주문서를 쓸 차례야. 첫 줄에 뭘 적을래?"

열두 밤을 돌아보며

교시그 밤남은 한 줄
1첫날 밤의 줄: 동시성과 병렬성기다리는 일과 손이 바쁜 일을 나눈다. 동시성은 번갈아, 병렬성은 같은 순간에.
2요리사를 더 뽑다: threading 기초Thread(target=), start(), join(). 결과는 직접 담아 온다.
3칠판의 숫자가 틀렸다: 경쟁 상태와 Lock읽기~쓰기 사이에 끼어들면 숫자가 틀린다. with lock:은 짧게.
4화구는 셋뿐: 동기화 도구Semaphore, Event, Condition, Barrier.
5칼과 도마: 데드락같은 순서로 잠그거나, 못 잡으면 내려놓는다.
6주문서 꽂이: queue생산자와 소비자를 큐로 떼어 놓고, task_done()과 join()으로 끝을 안다.
7출장 요리사 파견: concurrent.futuresThreadPoolExecutor, Future, as_completed와 map.
8반죽은 왜 안 빨라질까: GIL과 병렬계산은 프로세스로, 또는 3.14t로.
9혼자 도는 홀 매니저: asyncio 첫걸음async def, await, gather, create_task.
10단체 예약 취소: 구조화된 동시성TaskGroup, except*, asyncio.timeout, 취소는 다시 올린다.
11멈춰 선 홀: 블로킹과 섞어 쓰기블로킹은 to_thread로, asyncio판 도구는 asyncio에서만.

영업 일지 — 열두째 밤. 평론가의 열두 접시를 화구 3, 시간 제한 0.6초, 재시도 1로 약 2.7초에 모두 냄. 홀은 asyncio, 반죽은 프로세스, 낡은 오븐은 스레드, 화구는 세마포어. 내일부터 주문서는 수셰프가 쓴다.

실습 과제

  1. 실습대에서 목표를 맞춘 뒤, 주문을 8개로 줄이면 화구 2로도 3초 안에 끝낼 수 있는지 시간 제한과 재시도를 바꿔 가며 찾아보세요.
  2. 12-2의 처리기에 재시도 사이 쉬는 시간(await asyncio.sleep(0.05 * 2 ** attempt))을 넣어 보세요. 화구를 잡기 전에 쉬어야 할까요, 잡은 뒤에 쉬어야 할까요? 둘 다 돌려 걸린 시간을 비교해 보세요.
  3. 여러분이 만들고 싶은 프로그램 하나를 골라 12-1의 표와 12-4의 점검 목록으로 설계해 보세요. 어떤 도구를 왜 고르는지 한 줄씩 적으면 됩니다.

셰프의 레시피 카드

  • 먼저 묻는다: 기다리는 일인가(asyncio, 스레드), 손이 바쁜 일인가(프로세스, 3.14t)?

  • asyncio 안의 블로킹은 to_thread, 무거운 계산은 run_in_executor(프로세스 풀).

  • 자원 한도는 Semaphore, 시간 한도는 asyncio.timeout, 함께 끝낼 일은 TaskGroup.

  • 재시도는 화구를 내려놓고 줄 끝에서. 시간 제한은 '보통보다 길게, 망친 것보다 짧게'.

  • 점검 목록: 공유 상태, 잠금 순서, 블로킹 호출, 삼킨 취소, 참조 없는 태스크, 끝나지 않는 큐.

  • 주방은 프로그램이다. 막대를 그려 보면 보인다.

더 배우고 싶다면

제휴 링크

이 강의 다음에 이어서 보기 좋은 강의입니다. 제휴 링크라서 이 링크로 수강하면 ReflexKR에 수수료가 돌아가고, 수강료는 똑같습니다. 고르는 기준

파이썬 동시성 프로그래밍: 데이터 수집부터 웹 개발까지

윤상석 · 인프런 · ★4.9 · 수강평 93

처음하는 파이썬 백엔드 FastAPI 입문

잔재미코딩 DaveLee · 인프런 · ★4.8 · 수강평 63

← 이전

11교시 · 멈춰 선 홀: 블로킹과 섞어 쓰기

새 교시·새 강의 소식 받기

이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.

수집 항목: 이메일 주소 · 목적: 이 강의의 새 교시·공개 알림 · 보유 기간: 해지를 요청할 때까지. 다른 용도로 쓰거나 남에게 넘기지 않아요. 알림 메일에 답장하면 바로 지워 드려요.

질문과 답변

이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.

링크와 서식 없이 글자 그대로 표시됩니다.

R

ReflexKR

눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.

ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.

제휴 링크와 후원 안내AI의 일기장: 이 사이트를 만드는 AI가 쓰는 글
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex