R

ReflexKR

AI의 일기장Reflex 공식 문서

심야 주방의 동시성

·

강의 개요

1

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

2

요리사를 더 뽑다: threading 기초

3

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

4

화구는 셋뿐: 동기화 도구

5

칼과 도마: 데드락

6

주문서 꽂이: queue

7

출장 요리사 파견: concurrent.futures

8

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

9

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

10

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

11

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

12

평론가의 밤: 종합

심야 주방의 동시성
11교시

강의 시간 8:20–9:10

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

오늘의 주문서

#11 · 열한째 밤

멈춰 선 홀

위기

중고로 들여온 낡은 오븐은 문을 닫으면 다 구울 때까지 손잡이를 놓아주지 않는다. 루가 그 앞에 서 있는 동안 홀 전체가 멈춘다.

목표

오븐이 굽는 동안에도 다른 테이블은 0.3초 만에 서빙한다.

심야 식당 동시식당

밤 열한 시. 3번 테이블이 그라탱을 시킵니다. 그라탱은 셰프가 중고로 들여온 낡은 오븐에서만 구울 수 있습니다. 이 오븐은 문을 닫으면 다 구울 때까지 손잡이가 잠기고, 누군가 손잡이를 잡고 서 있어야 합니다.

루가 오븐 앞에 섭니다. 그 순간 홀이 멈춥니다. 물 달라는 1번 테이블도, 계산하려는 5번 테이블도 루가 돌아오기만 기다립니다.

탁 씨가 혀를 찹니다. "오븐 앞에 서 있는 건 일하는 게 아니야. 그냥 서 있는 거지." 셰프가 고개를 끄덕입니다. "루는 기다리면 안 돼. 기다리는 건 다른 사람한테 맡겨."

11-1. 루가 멈추면 홀이 멈춘다 (7분)

asyncio의 이벤트 루프는 한 스레드에서 돕니다. 코루틴이 await에서 양보해야 루프가 다른 태스크로 넘어갑니다. 그런데 await를 모르는 함수, 예를 들어 time.sleep이나 오래된 동기 라이브러리를 부르면 루프는 그 함수가 끝날 때까지 꼼짝 못 합니다. 이런 호출을 블로킹 호출이라고 부릅니다.

import asyncio, time

start = time.perf_counter()

async def lu_rounds():                 # 루는 0.1초마다 홀을 한 바퀴 돈다
    for _ in range(5):
        print(f"round {time.perf_counter() - start:.1f}s")
        await asyncio.sleep(0.1)

def old_oven():                        # await를 모르는 오래된 함수
    time.sleep(0.35)
    return "bread"

async def bake():
    await asyncio.sleep(0.15)
    print("oven", old_oven())          # 여기서 루프 전체가 0.35초 멈춘다

async def main():
    await asyncio.gather(lu_rounds(), bake())

asyncio.run(main())
# round 0.0s
# round 0.1s
# oven bread
# round 0.5s
# round 0.6s
# round 0.7s
  • 0.2, 0.3, 0.4초의 순회가 통째로 사라졌습니다. 루가 오븐 앞에 서 있던 0.15~0.5초 동안 아무 태스크도 돌지 못했습니다.
  • 블로킹 호출은 그 코루틴만 늦추는 게 아닙니다. 루프 위의 모든 태스크를 함께 멈춥니다. 스레드 여럿과 가장 다른 점입니다.
  • 흔한 범인: time.sleep, requests.get 같은 동기 네트워크 라이브러리, 큰 파일 읽기, 무거운 계산.

아래 타임라인에서 느린 테이블 하나가 낡은 오븐(time.sleep, 0.6초)을 씁니다. 다른 테이블은 0.1초씩 쪼개서 asyncio.sleep으로 기다립니다.

주방 타임라인

주문

6

느린 테이블

1

to_thread로 맡기기

요리

기다림

취소

실패

테이블 6개, 3번이 낡은 오븐을 씁니다. 주황 막대(오븐)가 도는 동안 다른 테이블의 막대가 함께 늘어나거나 시작조차 못 합니다. 'to_thread로 맡기기'를 켜면 모두 다시 겹칩니다.
  • 스위치를 끈 채로 돌리면 약 0.9초가 걸립니다. T1과 T2는 0.3초짜리인데 약 0.76초까지 늘어나고, T4~T6은 오븐이 끝난 0.6초에야 시작합니다. 루가 그 테이블에 아직 가 보지도 못한 것입니다.
  • 느린 테이블을 1번으로 바꾸면 나머지 다섯이 모두 0.6초 뒤에야 시작합니다.
  • 스위치를 켜면 오븐이 스레드로 넘어가, 다른 테이블은 약 0.3초에 끝나고 전체도 오븐 시간인 약 0.6초로 줄어듭니다.

11-2. 디버그 모드로 범인 찾기 (6분)

큰 프로그램에서는 어느 코루틴이 루프를 붙잡았는지 알기 어렵습니다. asyncio의 디버그 모드는 한 번에 오래 실행된 콜백과 태스크를 로그로 알려 줍니다.

import asyncio, logging, time

logging.basicConfig(level=logging.WARNING, format="%(name)s %(levelname)s: %(message)s")

def old_oven():
    time.sleep(0.3)

async def bake():
    old_oven()

async def main():
    await asyncio.create_task(bake(), name="T3-oven")

asyncio.run(main(), debug=True)
# 예: asyncio WARNING: Executing <Task finished name='T3-oven' coro=<bake() done, defined at ...oven.py:8>
#     result=None created at ...\asyncio\tasks.py:395> took 0.301 seconds
  • asyncio.run(main(), debug=True)로 켭니다. 환경 변수 PYTHONASYNCIODEBUG=1이나 python -X dev로도 켤 수 있습니다.
  • 경고 한 줄에 태스크 이름(T3-oven), 코루틴이 정의된 파일과 줄, 붙잡은 시간(took 0.301 seconds)이 나옵니다. 태스크에 name=을 붙여 두면 범인을 찾기 쉽습니다.
  • 메시지는 asyncio 로거의 WARNING으로 나옵니다. 경로는 실행하는 PC마다 다릅니다.

기준 시간은 loop.slow_callback_duration이고 기본값은 0.1초입니다. 더 예민하게 잡고 싶으면 줄입니다.

import asyncio, time

async def main():
    loop = asyncio.get_running_loop()
    print(loop.get_debug(), loop.slow_callback_duration)
    loop.slow_callback_duration = 0.05     # 0.05초만 넘어도 알려 줘
    time.sleep(0.07)

asyncio.run(main(), debug=True)
# True 0.1
# 예: Executing <Task finished name='Task-1' coro=<main() done, defined at ...> result=None created at ...> took 0.070 seconds

디버그 모드는 이 밖에도 await 없이 버려진 코루틴이 어디서 만들어졌는지 알려 주고, 다른 스레드에서 루프를 잘못 건드리면 오류를 냅니다. 개발할 때 켜 두고, 운영에서는 끕니다(느려집니다).

11-3. 기다리는 건 스레드에게: to_thread와 run_in_executor (8분)

낡은 오븐을 고칠 수 없다면, 오븐 앞에 설 사람을 따로 둡니다. asyncio.to_thread()(3.9)는 블로킹 함수를 다른 스레드에서 돌리고, 루프에는 기다릴 수 있는 객체를 돌려줍니다.

import asyncio, time

start = time.perf_counter()

async def lu_rounds():
    for _ in range(5):
        print(f"round {time.perf_counter() - start:.1f}s")
        await asyncio.sleep(0.1)

def old_oven(item, *, minutes):
    time.sleep(0.35)
    return f"{item} {minutes}m"

async def bake():
    await asyncio.sleep(0.15)
    bread = await asyncio.to_thread(old_oven, "bread", minutes=40)
    print("oven", bread)

async def main():
    await asyncio.gather(lu_rounds(), bake())

asyncio.run(main())
# round 0.0s
# round 0.1s
# round 0.2s
# round 0.3s
# round 0.4s
# oven bread 40m
  • 루는 0.1초마다 빠짐없이 홀을 돕니다. 오븐은 스레드가 지키고, 루는 결과만 await로 받습니다.
  • to_thread(함수, *args, **kwargs)는 인자를 그대로 넘깁니다. 7교시의 스레드 풀(루프의 기본 executor)에서 돕니다.
  • 스레드이니 GIL 규칙(8교시)은 그대로입니다. 기다리는 블로킹 코드에는 잘 듣지만, 계산이 무거운 일은 빨라지지 않습니다.

계산이 무거운 일은 분점 주방, 즉 프로세스 풀로 보냅니다. asyncio.get_running_loop()로 지금 도는 루프를 얻고, loop.run_in_executor(executor, 함수, *args)를 씁니다. 프로세스를 쓰므로 파일로 저장해 실행합니다.

import asyncio
from concurrent.futures import ProcessPoolExecutor

def knead(n):                          # 손이 바쁜 일(CPU)
    return sum(i * i for i in range(n))

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor(max_workers=2) as pool:
        doughs = await asyncio.gather(
            loop.run_in_executor(pool, knead, 2_000_000),
            loop.run_in_executor(pool, knead, 3_000_000),
        )
    print(doughs)

if __name__ == "__main__":
    asyncio.run(main())
# [2666664666667000000, 8999995500000500000]
  • run_in_executor의 첫 인자에 None을 주면 기본 스레드 풀을 씁니다. to_thread가 바로 그 일을 편하게 해 주는 함수입니다.
  • run_in_executor는 키워드 인자를 받지 않습니다. 필요하면 functools.partial(knead, n=...)로 감싸 넘깁니다.
  • 프로세스 풀이니 8교시의 규칙도 그대로입니다. if __name__ == "__main__":이 필요하고, 넘기는 함수와 값은 pickle 될 수 있어야 합니다.

셰프의 한마디

async def 안에서 이름에 async도 await도 안 붙은 함수를 부를 땐 한 번 멈춰서 물어봐. '이거 오래 걸려?' 0.1초라도 걸리면 to_thread야. 루를 오븐 앞에 세우지 마.

11-4. asyncio의 동기화 도구 (8분)

4교시와 6교시의 도구에는 asyncio판이 따로 있습니다. 이름은 같지만, 기다릴 때 스레드를 멈추는 대신 await로 양보합니다.

threadingasyncio쓰는 법
threading.Lockasyncio.Lockasync with lock:
threading.Semaphoreasyncio.Semaphoreasync with sem:
threading.Eventasyncio.Eventawait event.wait()
queue.Queueasyncio.Queueawait q.put(x), await q.get()

세마포어는 "동시 연결 수 제한"에 가장 많이 씁니다. 납품 업체에 전화를 다섯 통 걸어야 하는데, 업체는 동시에 두 통까지만 받습니다.

import asyncio, time

start = time.perf_counter()
supplier = asyncio.Semaphore(2)        # 납품 업체 전화는 동시에 두 통까지

async def call(n):
    async with supplier:
        print(f"call #{n} at {time.perf_counter() - start:.1f}s")
        await asyncio.sleep(0.2)

async def main():
    await asyncio.gather(*(call(n) for n in range(1, 6)))

asyncio.run(main())
# call #1 at 0.0s
# call #2 at 0.0s
# call #3 at 0.2s
# call #4 at 0.2s
# call #5 at 0.4s

asyncio.Queue는 6교시의 주문서 꽂이와 같은 모양입니다. maxsize, task_done(), join()도 똑같이 있습니다.

import asyncio

async def hall(q):
    for n in range(1, 6):
        await q.put(f"#{n}")           # 꽂이가 가득 차면 여기서 기다린다
    print("hall done")

async def cook(name, q, done):
    while True:
        ticket = await q.get()
        await asyncio.sleep(0.1)
        done.append((name, ticket))
        q.task_done()

async def main():
    q = asyncio.Queue(maxsize=2)
    done = []
    cooks = [asyncio.create_task(cook(c, q, done)) for c in ("A", "B")]
    await hall(q)
    await q.join()                     # 꽂힌 주문이 모두 task_done 될 때까지
    for c in cooks:
        c.cancel()
    print(len(done), sorted(t for _, t in done))

asyncio.run(main())
# hall done
# 5 ['#1', '#2', '#3', '#4', '#5']

루프는 한 스레드라서 경쟁 상태가 없을 것 같지만, 읽기와 쓰기 사이에 await가 있으면 3교시의 칠판 사고가 그대로 납니다.

import asyncio

async def main():
    board = {"count": 0}

    async def cook():
        for _ in range(100):
            v = board["count"]
            await asyncio.sleep(0)     # 여기서 다른 태스크로 넘어간다
            board["count"] = v + 1

    await asyncio.gather(*(cook() for _ in range(4)))
    print(board["count"])

asyncio.run(main())
# 100

400이어야 할 칠판이 100입니다. 네 태스크가 같은 값을 읽고, 양보했다가, 같은 값을 씁니다. asyncio.Lock으로 그 구간을 묶고, asyncio.Event로 "오픈!" 신호를 기다리게 하면 이렇습니다.

import asyncio

async def main():
    lock = asyncio.Lock()
    opened = asyncio.Event()
    board = {"count": 0}

    async def cook():
        await opened.wait()            # "오픈!"까지 기다린다
        for _ in range(100):
            async with lock:           # 읽기와 쓰기 사이에 await가 있으면 잠금이 필요하다
                v = board["count"]
                await asyncio.sleep(0)
                board["count"] = v + 1

    tasks = [asyncio.create_task(cook()) for _ in range(4)]
    await asyncio.sleep(0.1)
    opened.set()
    await asyncio.gather(*tasks)
    print(board["count"])

asyncio.run(main())
# 400

두 세계의 도구를 섞으면 안 됩니다. 코루틴에서 threading.Lock을 기다리면 루프가 통째로 멈춥니다. 잠금을 쥔 태스크가 돌아와 풀어 줄 기회도 사라집니다.

import asyncio, threading, time

lock = threading.Lock()                # 스레드용 잠금을 코루틴에서 쓰면?
start = time.perf_counter()

async def cook(n):
    if not lock.acquire(timeout=0.2):  # 기다리는 동안 루프 전체가 멈춘다
        print(f"#{n} gave up at {time.perf_counter() - start:.1f}s")
        return
    try:
        await asyncio.sleep(0.1)       # 잠근 채로 양보한다
        print(f"#{n} done")
    finally:
        lock.release()

async def main():
    await asyncio.gather(cook(1), cook(2))

asyncio.run(main())
# #2 gave up at 0.2s
# #1 done

timeout이 없었다면 #2는 영원히 기다리고, #1은 영영 깨어나지 못합니다. 반대로 asyncio의 도구는 스레드 안전하지 않습니다. 다른 스레드에서 asyncio.Queue에 넣으면 안 됩니다. 스레드에서 루프로 일을 넘기는 방법은 11-6에서 봅니다.

11-5. async with, async for, 비동기 제너레이터 (7분)

with와 for에도 기다리는 판이 있습니다.

  • async with: 들어갈 때와 나올 때 await가 필요한 자원(__aenter__, __aexit__). 잠금, 세마포어, 네트워크 연결.
  • async for: 다음 값을 받으려면 기다려야 하는 반복(__aiter__, __anext__). 하나씩 도착하는 주문서, 스트리밍 응답.
  • 비동기 제너레이터: async def 안에 yield를 쓰면 async for로 도는 반복자가 됩니다.
import asyncio
from contextlib import asynccontextmanager

async def tickets(n):                  # 비동기 제너레이터: async def + yield
    for i in range(1, n + 1):
        await asyncio.sleep(0.05)      # 다음 주문서가 올 때까지 기다린다
        yield f"#{i}"

@asynccontextmanager
async def stove(name):                 # async with 로 쓰는 화구
    await asyncio.sleep(0.01)
    print(f"{name} on")
    try:
        yield name
    finally:
        print(f"{name} off")

async def main():
    async with stove("S1"):
        async for t in tickets(3):
            print("cook", t)
    print([t async for t in tickets(2)])

asyncio.run(main())
# S1 on
# cook #1
# cook #2
# cook #3
# S1 off
# ['#1', '#2']

contextlib.asynccontextmanager는 yield 하나로 async with용 자원을 만들어 줍니다. 리스트 내포에도 async for를 쓸 수 있습니다(코루틴 안에서만).

7교시의 as_completed도 asyncio판이 있습니다. 3.13부터는 async for로 돌 수 있고, 이때 처음에 넣은 태스크 그대로를 끝난 순서대로 돌려줍니다.

import asyncio

async def dish(n, sec):
    await asyncio.sleep(sec)
    return f"#{n}"

async def main():
    tasks = [asyncio.create_task(dish(n, s)) for n, s in [(1, 0.3), (2, 0.1), (3, 0.2)]]
    async for t in asyncio.as_completed(tasks):      # 3.13부터
        print(t in tasks, await t)

asyncio.run(main())
# True #2
# True #3
# True #1

예전처럼 for f in asyncio.as_completed(tasks): await f로 써도 되지만, 그때 받는 f는 원래 태스크가 아니라 새 객체입니다. 어느 주문이 끝났는지 알고 싶다면 async for 쪽이 편합니다.

11-6. 루프 밖에서 일 던지기, 돌고 있는 태스크 보기 (4분)

예약 전화를 받는 직원이 다른 스레드에서 일한다고 합시다. 그 스레드가 루의 일정에 일을 끼워 넣을 때는 asyncio.run_coroutine_threadsafe(코루틴, loop)를 씁니다. 돌려받는 것은 7교시의 concurrent.futures.Future라서, 스레드에서 result(timeout=)으로 기다릴 수 있습니다.

import asyncio

async def seat(table):
    await asyncio.sleep(0.05)
    return f"{table} seated"

def phone_line(loop):                  # 루프 밖의 스레드(예약 전화)
    fut = asyncio.run_coroutine_threadsafe(seat("T7"), loop)
    print(fut.result(timeout=1))       # concurrent.futures.Future

async def main():
    loop = asyncio.get_running_loop()
    await asyncio.to_thread(phone_line, loop)

asyncio.run(main())
# T7 seated

eager 태스크(3.12): 보통 create_task는 태스크를 일정에 올려 둘 뿐, 다음 차례가 올 때까지 실행하지 않습니다. asyncio.eager_task_factory를 쓰면 만드는 즉시 첫 await까지 실행합니다. await 없이 바로 끝나는 코루틴이라면 일정에 오르지도 않고 그 자리에서 끝납니다.

import asyncio

async def greet(n):
    print(f"greet T{n}")               # 첫 await 전까지
    await asyncio.sleep(0)
    return n

async def main(eager):
    if eager:
        asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
    t = asyncio.create_task(greet(1))
    print("created, done =", t.done())
    await t

asyncio.run(main(False))
asyncio.run(main(True))
# created, done = False
# greet T1
# greet T1
# created, done = False

돌고 있는 태스크 보기(3.14): 실행 중인 asyncio 프로그램의 프로세스 번호(PID)를 알면, 다른 터미널에서 python -m asyncio ps <PID>로 태스크 목록을, python -m asyncio pstree <PID>로 누가 누구를 기다리는지 나무 모양으로 볼 수 있습니다. 예를 들어 hall 태스크가 TaskGroup으로 T1, T2를 기다리는 중이라면 이렇게 나옵니다(일부 열과 경로 생략).

task name   coroutine stack                                   awaiter name
Task-1      main
hall        TaskGroup._aexit -> TaskGroup.__aexit__ -> hall   Task-1
T1          sleep -> table                                    hall
T2          sleep -> table                                    hall

"홀이 멈췄는데 루가 어디 서 있는지 모르겠다"는 순간에 쓰는 도구입니다. 태스크에 name=을 붙여 두면 목록이 훨씬 읽기 쉬워집니다.

영업 마감 (10분)

3번 테이블의 그라탱만 낡은 오븐에 넣고, 오븐 앞에는 스레드를 세웁니다. 스위치 하나(fix)로 비교합니다.

import asyncio, time

def old_oven(sec):                     # 손댈 수 없는 오래된 라이브러리
    time.sleep(sec)
    return "gratin"

async def table(n, start, fix):
    if n == 3:
        dish = await asyncio.to_thread(old_oven, 0.6) if fix else old_oven(0.6)
    else:
        await asyncio.sleep(0.3)
        dish = "noodle"
    return f"T{n} {dish} {time.perf_counter() - start:.1f}s"

async def service(fix):
    start = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(table(n, start, fix)) for n in range(1, 7)]
    print([t.result() for t in tasks])

asyncio.run(service(fix=False))
asyncio.run(service(fix=True))
# ['T1 noodle 0.6s', 'T2 noodle 0.6s', 'T3 gratin 0.6s', 'T4 noodle 0.9s', 'T5 noodle 0.9s', 'T6 noodle 0.9s']
# ['T1 noodle 0.3s', 'T2 noodle 0.3s', 'T3 gratin 0.6s', 'T4 noodle 0.3s', 'T5 noodle 0.3s', 'T6 noodle 0.3s']
  • 루가 오븐 앞에 직접 서면, 0.3초짜리 국수가 하나도 0.3초에 나가지 못합니다. T1과 T2는 오븐 전에 냄비를 올려 두었지만 0.6초에야 서빙되고, T4~T6은 오븐이 끝난 뒤에야 냄비를 올려 0.9초에 나갑니다. 타임라인에서 본 모양 그대로입니다.
  • to_thread로 맡기면 국수는 0.3초, 그라탱은 오븐 시간인 0.6초에 나갑니다. 오늘 밤의 목표입니다.

마감 후 셰프가 주방 벽의 레시피 카드를 하나씩 손가락으로 짚습니다. 스레드, 잠금, 큐, 풀, 프로세스, 루프, TaskGroup, to_thread. "도구가 이만큼 쌓였어. 그런데 손님이 문을 열고 들어올 때, 어느 도구를 먼저 꺼낼지는 어떻게 정하지?"

영업 일지 — 열한째 밤. 낡은 오븐을 기다리느라 홀이 0.6초씩 멈추는 문제를 디버그 모드로 찾아냄. 오븐은 to_thread로 스레드에 맡기고, 납품 업체 전화는 asyncio.Semaphore로 두 통까지 묶음.

실습 과제

  1. 타임라인에서 느린 테이블을 1부터 6까지 바꿔 보세요. 느린 테이블보다 앞 번호와 뒤 번호의 막대 모양이 어떻게 다른지, 왜 그런지 설명해 보세요.
  2. 11-1의 첫 예제를 asyncio.run(main(), debug=True)로 돌려, 경고 메시지에 어느 태스크가 몇 초를 붙잡았다고 나오는지 확인해 보세요.
  3. 11-4의 세마포어 예제를 asyncio.Semaphore(3)으로 바꾸면 call #5가 몇 초에 시작할지 먼저 계산해 보고 돌려 보세요.

셰프의 레시피 카드

  • 블로킹 호출(time.sleep, 동기 라이브러리)은 루프 위의 모든 태스크를 멈춘다.

  • 범인 찾기: asyncio.run(main(), debug=True), loop.slow_callback_duration(기본 0.1초).

  • 기다리는 블로킹 코드는 await asyncio.to_thread(f, *args). 무거운 계산은 loop.run_in_executor(ProcessPoolExecutor(), f, x).

  • asyncio판 도구: Lock, Semaphore, Event, Queue — async with / await로 쓴다. threading용과 섞지 않는다.

  • async with, async for, 비동기 제너레이터(async def + yield). 3.13부터 async for t in asyncio.as_completed(tasks).

  • 스레드에서 루프로: run_coroutine_threadsafe(coro, loop). 들여다보기: python -m asyncio ps/pstree <PID>(3.14).

← 이전

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

다음 →

12교시 · 평론가의 밤: 종합

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

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