파이썬 GIL 실측: 스레드·프로세스·asyncio 언제 무엇을

CPU 작업과 I/O 대기를 스레드·프로세스·asyncio 로 워커 1~8개 실측. 맥 spawn 에서 죽는 이유, Lock 으로 고치는 경쟁 상태, 이벤트 루프를 510ms 멈춘 블로킹 호출과 판단표까지.

시리즈: 파이썬 강좌 5/6 (고급). 함수·제너레이터·데코레이터는 익숙하고, 이제 "여러 일을 동시에" 시켜야 하는 분을 위한 글입니다. 다 읽으면 스레드·프로세스·asyncio 중 무엇을 쓸지 카더라가 아니라 실측값으로 고를 수 있습니다.

크롤러를 빨리 돌리려고 threading 을 붙였는데 하나도 안 빨라졌거나, 사내 배치를 multiprocessing 으로 바꿨더니 리눅스 서버에서는 되는데 맥북에서는 에러가 나거나, FastAPI 서버가 가끔 0.5초씩 멈추는 문제를 겪고 있다면 이 글이 그 답입니다. 결론부터 말하면 기다리는 일이 많으면 스레드, 계산이 무거우면 프로세스, 기다리는 일이 수천 개면 asyncio 입니다. 그리고 asyncio 는 블로킹 호출 하나가 섞이는 순간 전부 망가집니다.

실행 환경은 Apple Silicon Mac(M1 Pro, 성능 코어 6 + 효율 코어 2)입니다. 시간 측정은 모두 Python 3.9 로 했습니다. asyncio.TaskGroup, except* 처럼 3.11 이 필요한 기능은 Python 3.11 로 돌렸지만, 이 빌드는 Rosetta 2 변환 위에서 돌기 때문에 속도 비교에는 쓰지 않았습니다. 측정하는 동안 다른 작업도 돌고 있어서 숫자는 5회 중앙값입니다.

GIL 을 한 문단으로

CPython 에는 GIL(Global Interpreter Lock)이 있어서 파이썬 바이트코드를 실행하는 스레드는 한 번에 하나뿐입니다. I/O 를 기다리는 스레드(소켓, 파일, time.sleep)는 락을 내려놓기 때문에 기다리는 동안 다른 스레드가 돌 수 있습니다. 순수 파이썬 루프를 도는 스레드는 락을 쥐고 있고, 인터프리터가 몇 밀리초마다 "양보해 달라"고 요청할 뿐입니다.

>>> import sys; sys.getswitchinterval()
0.005

이 5ms 가 전환 간격입니다. 스레드는 번갈아 돌 뿐, 파이썬 코드를 병렬로 실행하지 않습니다. 프로세스는 각자 인터프리터와 GIL 을 따로 가지므로 진짜 병렬로 돌지만, 프로세스를 띄우는 비용과 인자·결과를 pickle 해서 주고받는 비용이 듭니다.

판단도. 먼저 무엇이 느린지 본다. 순수 파이썬으로 계산 중이면 프로세스 풀, 전체가 0.1초 정도도 안 되면 순차 실행 그대로. I/O 를 기다리는 중이면 동시에 기다리는 수와 라이브러리가 비동기인지 확인한다. 수십~수백 개이고 블로킹 라이브러리면 max_workers 를 지정한 스레드 풀. 수천 개이고 비동기 라이브러리가 갖춰져 있으면 asyncio, 남은 블로킹 호출은 asyncio.to_thread 로 넘긴다.
스레드·프로세스·asyncio 고르는 법. 어떤 API 가 최신인지가 아니라 프로그램이 무엇을 기다리는지에서 출발한다.

CPU 작업: 워커 1/2/4/8개로 스레드와 프로세스 비교

같은 양의 작업 8개를 준비했습니다. 각각 300만 번 도는 순수 파이썬 루프입니다. 순차 실행, ThreadPoolExecutor, ProcessPoolExecutor 세 가지로 돌렸고, 풀 생성도 측정 구간에 넣었습니다. 즉 프로세스를 띄우는 시간까지 숫자에 들어 있습니다.

# cpu_bound.py
import time, sys, multiprocessing as mp
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def work(n):
    total = 0
    for i in range(n):
        total += i * i % 7
    return total

JOBS = [3_000_000] * 8   # 8 equal jobs

def timed(label, fn):
    t = time.perf_counter()
    res = fn()
    print(f"{label:<22}{time.perf_counter() - t:6.2f} s")
    return res

if __name__ == "__main__":
    print(sys.version.split()[0], "start method:", mp.get_start_method())
    expected = timed("sequential", lambda: [work(n) for n in JOBS])
    for w in (1, 2, 4, 8):
        with ThreadPoolExecutor(max_workers=w) as ex:
            assert timed(f"threads  x{w}", lambda: list(ex.map(work, JOBS))) == expected
    for w in (1, 2, 4, 8):
        with ProcessPoolExecutor(max_workers=w) as ex:
            assert timed(f"processes x{w}", lambda: list(ex.map(work, JOBS))) == expected

출력 첫 줄은 3.9.6 start method: spawn 입니다. 5회 중앙값:

방식 1개 2개 4개 8개
순차 1.70초 — — —
스레드 1.73초 1.81초 1.80초 1.85초
프로세스 1.77초 0.92초 0.52초 0.47초
실행 시간(초) 막대그래프. 순차 1.70. 스레드 1·2·4·8개: 1.73, 1.81, 1.80, 1.85. 프로세스 1·2·4·8개: 1.77, 0.92, 0.52, 0.47.
같은 양의 순수 파이썬 작업 8개(각 300만 번 루프). 5회 중앙값, Python 3.9, Apple M1 Pro(성능 6 + 효율 2코어), 풀 시작 시간 포함. 스레드는 순차보다 빨라지지 않았고 프로세스는 빨라졌다.

요약 한 줄로는 알 수 없는 것이 세 가지 있습니다.

  1. 스레드는 한 번도 순차보다 빠르지 않았습니다. 개수를 늘리면 오히려 조금 느려집니다. GIL 을 주고받는 데 시간을 쓰기 때문입니다. 편차도 커서, 스레드 4개짜리 한 회는 2.84초가 걸렸습니다.
  2. 프로세스는 워커 4개까지 거의 비례해서 빨라지다가 거기서 멈췄습니다. 4 → 8 은 0.52초 → 0.47초뿐입니다. 이 맥은 성능 코어 6개와 느린 효율 코어 2개 구성이라, 작업 8개를 돌리면 마지막 작업이 느린 코어를 기다리게 됩니다.
  3. 프로세스 풀에는 고정비가 있습니다. spawn 으로 워커 8개를 띄워 아무것도 안 하는 작업을 넘기는 데만 중앙값 0.099초 가 걸렸습니다. 전체 50ms 짜리 작업이라면 프로세스 풀이 오히려 느립니다.

맥 함정: spawn 과 if __name__ == "__main__"

리눅스의 파이썬(3.13 까지)은 fork 로 자식 프로세스를 만듭니다. 자식은 부모 메모리의 복사본입니다. macOS 는 Python 3.8 부터 기본값이 spawn 이라, 자식이 새 인터프리터로 시작해서 work 를 찾으려고 메인 모듈을 다시 import 합니다. 모듈 최상위에 쓴 코드가 자식마다 한 번씩 다시 실행된다는 뜻입니다.

가장 흔한 실수가 가드 없는 스크립트입니다.

# noguard.py — 흔한 실수
from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

with ProcessPoolExecutor(max_workers=2) as ex:
    print(list(ex.map(square, range(5))))

Python 3.9 / macOS 에서 실제로 일어나는 일(출력 125줄 중 발췌):

RuntimeError:
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.

        This probably means that you are not using fork to start your
        child processes and you have forgotten to use the proper idiom
        in the main module:

            if __name__ == '__main__':
                freeze_support()
                ...
Traceback (most recent call last):
  File "~/demo/noguard.py", line 8, in <module>
    print(list(ex.map(square, range(5))))
  ...
concurrent.futures.process.BrokenProcessPool: A process in the process pool was terminated abruptly while the future was running or pending.

자식 프로세스가 import 도중에 with ProcessPoolExecutor(...) 줄을 실행하고, 자기 풀을 만들려다 죽습니다. 부모 쪽에서는 풀이 깨진 것으로 보입니다. 고치는 법: 프로세스를 띄우는 코드는 전부 if __name__ == "__main__": 아래에 두고, 워커 함수는 모듈 최상위에 둡니다(람다나 중첩 함수는 pickle 이 안 돼서 자식에게 넘길 수 없습니다). 운영 리눅스 서버에서 잘 돌던 코드가 개발자 맥북에서만 죽는다는 비대칭이 이 함정을 귀찮게 만드는 부분입니다.

참고로 Python 3.14 에서는 리눅스 기본값이 fork 에서 forkserver 로 바뀝니다. 3.14 는 이번에 돌리지 않았지만, 가드를 써 두면 어떤 시작 방식에서도 올바르게 동작합니다.

I/O 대기: 100개·1,000개에서 스레드와 asyncio 비교

이번에는 "기다리는" 작업입니다. 네트워크 호출 대신 100ms 슬립을 썼습니다(외부 네트워크는 전혀 쓰지 않았습니다). 스레드에서는 time.sleep, 코루틴에서는 asyncio.sleep 입니다.

# io_bound.py
import asyncio, time
from concurrent.futures import ThreadPoolExecutor

DELAY = 0.1  # each "request" waits 100 ms

def fetch_blocking(i):
    time.sleep(DELAY)
    return i

async def fetch_async(i):
    await asyncio.sleep(DELAY)
    return i

def with_threads(n, workers):
    with ThreadPoolExecutor(max_workers=workers) as ex:
        return list(ex.map(fetch_blocking, range(n)))

async def with_asyncio(n):
    return await asyncio.gather(*(fetch_async(i) for i in range(n)))

def timed(label, fn):
    t = time.perf_counter()
    fn()
    print(f"{label:<34}{time.perf_counter() - t:7.3f} s")

if __name__ == "__main__":
    for n in (100, 1000):
        print(f"--- {n} tasks ---")
        timed("threads, default max_workers", lambda: with_threads(n, None))
        timed(f"threads, max_workers={n}", lambda: with_threads(n, n))
        timed("asyncio.gather", lambda: asyncio.run(with_asyncio(n)))
100ms 작업 수 순차 스레드(기본 풀) 스레드(작업당 1개) asyncio
100 10.355초 0.932초 0.113초 0.103초
1,000 (약 100초, 실행 안 함) 8.684초 0.210초 0.110초
10,000 — — 0.703초, 최대 RSS 90MB 0.196초, 최대 RSS 39MB
각각 100밀리초를 기다리는 작업 1,000개의 실행 시간 막대그래프. 기본 12워커 스레드 풀: 8.684초. 1,000워커 스레드 풀: 0.210초. asyncio.gather: 0.110초.
100ms 짜리 가상 요청 1,000건, Python 3.9. ThreadPoolExecutor 의 기본 워커 수는 이 Mac 에서 min(32, cpu_count + 4) = 12 뿐이라 약 84번에 나눠 실행됐다.

의외인 건 두 번째 열입니다. max_workers 를 생략한 ThreadPoolExecutor() 는 min(32, os.cpu_count() + 4) 개, 이 맥에서는 12개 만 만듭니다. 1,000개를 12개씩 처리하면 약 84번 × 100ms 라서 8.7초입니다. 기본값은 이것저것 섞인 작업용이지, 소켓 1,000개를 동시에 기다리는 용도가 아닙니다. 작업당 스레드 1개로 하면 100개에서는 asyncio 와 거의 같고, 1,000개에서도 충분히 빨랐으며, 10,000개가 되자 asyncio 의 두 배가 넘는 메모리(resource.getrusage 의 최대 RSS, 각각 별도 프로세스에서 측정)와 3.6배의 시간을 썼습니다. 늘어난 시간은 OS 스레드 10,000개를 만들고 전환하는 비용입니다.

실측에서 얻은 결론은 이렇습니다. 동시에 수백 개 정도까지는 크기를 지정한 스레드 풀로 충분하고, 블로킹 라이브러리(requests 등)도 그대로 쓸 수 있습니다. asyncio 가 이기는 건 수천 개 규모부터이고, 그것도 경로 위의 라이브러리가 전부 비동기일 때뿐입니다.

경쟁 상태: 값이 사라지는 카운터

GIL 이 있다고 코드가 스레드 안전해지지는 않습니다. counter += 1 은 여러 바이트코드(3.9 기준 LOAD_GLOBAL, LOAD_CONST, INPLACE_ADD, STORE_GLOBAL)로 나뉘고, 읽기와 쓰기 사이에 전환이 일어나면 증가 하나가 사라집니다.

# race.py
import sys, threading

N, THREADS = 1_000_000, 4
counter = 0
lock = threading.Lock()

def unsafe():
    global counter
    for _ in range(N):
        counter += 1          # read, add, write: three steps

def safe():
    global counter
    for _ in range(N):
        with lock:
            counter += 1

def run(target):
    global counter
    counter = 0
    ts = [threading.Thread(target=target) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    return counter

if __name__ == "__main__":
    print(sys.version.split()[0], "expected", N * THREADS)
    for i in range(3):
        print("unsafe:", run(unsafe))
    print("locked:", run(safe))
3.9.6 expected 4000000
unsafe: 1492419
unsafe: 2606045
unsafe: 2137778
locked: 4000000

절반 넘는 증가가 사라졌고, 결과도 매번 다릅니다. 여기서부터가 경력자도 걸리는 부분입니다. 같은 파일을 Python 3.11 로 돌리면 세 번 모두 4000000 이 나왔습니다. 새 인터프리터는 스레드 전환을 확인하는 지점이 적어서, 이 두 줄짜리 루프가 우연히 갱신 도중에 끊기지 않은 것입니다. 그래도 안전한 게 아닙니다. 읽기와 쓰기 사이에 함수 호출 하나만 넣으면 3.11 에서도 값이 사라집니다.

def plus_one(x):
    return x + 1
...
        counter = plus_one(counter)   # 읽기와 쓰기 사이에 함수 호출
3.9.6 1919199 of 4000000
3.11.4 2616204 of 4000000

"3.11 에서 해 보니 괜찮던데요"는 아무것도 보장하지 않습니다. 고치는 방법은 Lock 입니다. 대신 비용이 있어서, 3.9 에서 스레드 4개 × 100만 번 증가가 락 없이 0.23~0.26초, 락을 걸면 1.00~1.14초였습니다. 실제 코드에서는 락을 거는 단위를 크게 잡거나, 스레드마다 카운터를 따로 두고 마지막에 합치는 편이 낫습니다.

asyncio 함정: 코루틴 안의 블로킹 호출

asyncio 는 모든 코루틴을 스레드 하나에서 돌립니다. 어느 코루틴이 블로킹 함수를 부르면, 그 함수가 돌아올 때까지 다른 건 아무것도 돌지 않습니다. 멈춘 시간을 재려고 10ms 마다 깨어나고 싶은 "심장 박동" 태스크를 돌리면서, 핸들러가 0.5초를 세 가지 방법으로 기다리는 동안 가장 긴 공백을 기록했습니다.

# stall.py
import asyncio, time

async def heartbeat(stop, gaps):
    last = time.perf_counter()
    while not stop.is_set():
        await asyncio.sleep(0.01)            # wants to wake every 10 ms
        now = time.perf_counter()
        gaps.append(now - last)
        last = now

async def handler(mode):
    if mode == "time.sleep":
        time.sleep(0.5)                      # blocks the whole event loop
    elif mode == "asyncio.sleep":
        await asyncio.sleep(0.5)
    elif mode == "to_thread":
        await asyncio.to_thread(time.sleep, 0.5)   # 3.9+

async def main(mode):
    stop, gaps = asyncio.Event(), []
    hb = asyncio.create_task(heartbeat(stop, gaps))
    await asyncio.sleep(0.05)
    t = time.perf_counter()
    await handler(mode)
    took = time.perf_counter() - t
    stop.set(); await hb
    print(f"{mode:<14} handler {took:.3f} s | heartbeat max gap {max(gaps)*1000:6.1f} ms, ticks {len(gaps)}")

if __name__ == "__main__":
    for mode in ("time.sleep", "asyncio.sleep", "to_thread"):
        asyncio.run(main(mode))
time.sleep     handler 0.505 s | heartbeat max gap  510.1 ms, ticks 5
asyncio.sleep  handler 0.501 s | heartbeat max gap   11.4 ms, ticks 51
to_thread      handler 0.508 s | heartbeat max gap   30.7 ms, ticks 46

핸들러 자체는 셋 다 0.5초라서, 핸들러 시간만 재는 테스트로는 문제가 안 보입니다. 심장 박동은 정직합니다. time.sleep 일 때 루프 위의 다른 태스크가 전부 510ms 동안 멈췄습니다. 웹 서버라면 그동안 다른 사용자의 요청도 다 멈춘다는 뜻입니다. requests.get, 동기 DB 드라이버, 느린 디스크의 open().read(), CPU 를 꽉 쓰는 루프도 여기서는 time.sleep 과 똑같이 행동합니다.

실제 프로젝트에서 이런 곳을 찾으려면 디버그 모드를 켭니다. asyncio.run(handler(), debug=True) 로 안에 0.3초 time.sleep 을 넣었더니 Python 3.9 가 이렇게 기록했습니다.

Executing <Task finished name='Task-1' coro=<handler() done, defined at ~/demo/debugwarn.py:2> result=None created at .../asyncio/base_events.py:621> took 0.306 seconds

loop.slow_callback_duration(기본 0.1초)보다 오래 걸린 콜백이 보고됩니다. 비슷한 실수로 코루틴을 await 없이 부르는 경우가 있습니다. 아무것도 실행되지 않고 RuntimeWarning: coroutine 'f' was never awaited 경고만 남습니다.

asyncio.TaskGroup(3.11+): 실패하면 같이 멈추기

asyncio.gather 에는 불편한 기본 동작이 있습니다. 태스크 하나가 실패하면 예외는 돌아오지만, 나머지 태스크는 뒤에서 계속 돕니다. Python 3.9 출력:

  a done
caught: b: bad response after 0.20 s
  c done
main exits

에러 처리가 끝난 뒤에 c 가 완료됐습니다. 3.11 에 추가된 asyncio.TaskGroup 은 하나가 실패하면 형제 태스크를 취소하고 ExceptionGroup 을 던집니다. 받을 때는 새 문법 except* 를 씁니다.

# taskgroup.py — Python 3.11+ 필요
import asyncio, sys, time

async def fetch(name, delay, fail=False):
    try:
        await asyncio.sleep(delay)
        if fail:
            raise ValueError(f"{name}: bad response")
        print(f"  {name} done")
        return name
    except asyncio.CancelledError:
        print(f"  {name} cancelled")
        raise

async def main():
    t = time.perf_counter()
    try:
        async with asyncio.TaskGroup() as tg:
            a = tg.create_task(fetch("a", 0.1))
            b = tg.create_task(fetch("b", 0.2, fail=True))
            c = tg.create_task(fetch("c", 1.0))
    except* ValueError as eg:
        print("caught:", [str(e) for e in eg.exceptions])
    print(f"elapsed {time.perf_counter() - t:.2f} s, a={a.result()!r}, c.cancelled()={c.cancelled()}")

print(sys.version.split()[0])
asyncio.run(main())
3.11.4
  a done
  c cancelled
caught: ['b: bad response']
elapsed 0.20 s, a='a', c.cancelled()=True

같은 파일을 Python 3.9 로 돌리면 except* 가 새 문법이라 컴파일 단계에서 멈춥니다.

  File "~/demo/taskgroup.py", line 22
    except* ValueError as eg:
          ^
SyntaxError: invalid syntax

except* 를 빼도 AttributeError: module 'asyncio' has no attribute 'TaskGroup' 이 납니다. 3.9·3.10 에서도 돌아야 한다면 gather 를 쓰고 남은 태스크는 직접 취소하세요.

판단표

상황 쓸 것 근거(위 실측)
순수 파이썬 계산, 몇 초 이상 ProcessPoolExecutor 워커 4개로 1.70초 → 0.52초. 스레드는 효과 없음
계산 전체가 0.1초 미만 순차 실행 그대로 워커 8개를 띄우는 것만 0.099초
NumPy·hashlib·zlib 등 GIL 을 푸는 C 구현 안의 계산 스레드도 효과가 있을 수 있음 그 호출 동안은 GIL 이 풀림(라이브러리마다 확인)
수십~수백 개의 블로킹 I/O(requests, DB 드라이버) ThreadPoolExecutor(max_workers=N) 100개에 0.113초, asyncio 와 거의 같음. 기본값은 여기서 12개
수천 개 동시 연결, 라이브러리도 전부 비동기 asyncio 10,000개에 0.196초·39MB, 스레드는 0.703초·90MB
비동기 코드 안의 블로킹 호출 하나 asyncio.to_thread(3.9+) 루프 공백 510ms → 30.7ms
스레드끼리 공유하는 가변 상태 Lock, 또는 스레드별 결과를 마지막에 합산 락 없이 증가의 35~63% 가 사라짐
같이 실패해야 하는 여러 태스크 3.11+ 는 TaskGroup, 그 전은 gather + 수동 취소 0.20초에 형제 태스크 취소

솔직한 한계와 버전 메모

  • 시간 측정은 Python 3.9.6 에서만 했습니다. Python 3.11.4 는 TaskGroup/except* 와 경쟁 상태 데모를 돌리는 데 썼지만, Rosetta 2 위의 x86_64 빌드라 그 속도는 네이티브 3.11 에 대해 아무것도 말해 주지 않습니다.
  • 프리 스레드 파이썬(3.13+)은 설명만 했고 실행하지 않았습니다. PEP 703 으로 GIL 없는 빌드(python3.13t)가 선택 사항으로 들어왔고, 3.13 에서는 실험적 기능이었습니다. 실행 중에 sys._is_gil_enabled() 로 어느 모드인지 확인할 수 있습니다. 그 빌드라면 위의 CPU 작업 스레드 표가 크게 달라질 것이고, race.py 의 경쟁 상태는 전환 간격에 가려지지 않으니 오히려 더 심각해집니다. 이번 환경에는 그 빌드가 없었고 설치하지도 않았습니다.
  • asyncio.to_thread 는 3.9 부터, TaskGroup 과 except* 는 3.11 부터입니다(3.9 에서 위의 SyntaxError, AttributeError 확인).
  • 리눅스 기본값이 forkserver 가 되는 건 Python 3.14 부터이며, 3.14 는 돌리지 않았습니다.
  • 환경: Apple M1 Pro, macOS, 다른 작업 실행 중. 5회 중앙값입니다. 절대값은 환경마다 다르니 비율을 보세요.

이전: 4강 데코레이터와 컨텍스트 매니저 · 다음: 6강 프로파일링과 최적화

관련: 이터레이터·제너레이터와 메모리(3강) · 자바 가상 스레드 실측 · VB.NET Async/Await

자주 묻는 질문

CPU 를 쓰는 작업은 파이썬 스레드로 빨라지나요?

일반(GIL 이 있는) 빌드에서는 빨라지지 않습니다. 순수 파이썬 작업 8개가 순차 실행으로 1.70초, 스레드 1·2·4·8개로 1.73~1.85초였습니다(Python 3.9, Apple M1 Pro). 같은 작업을 프로세스 풀로 돌리면 워커 2개에서 0.92초, 8개에서 0.47초였습니다.

맥에서 multiprocessing 이 'An attempt has been made to start a new process before the current process has finished its bootstrapping phase' 로 죽습니다.

macOS 는 자식 프로세스를 spawn 방식으로 띄우고, 자식이 메인 모듈을 다시 import 합니다. if __name__ == '__main__': 가드가 없으면 자식도 풀을 만들려다 RuntimeError 가 나고 부모는 BrokenProcessPool 을 받습니다. 풀을 만드는 코드를 가드 안으로 옮기면 됩니다.

asyncio 서버가 요청 하나 때문에 통째로 멈추는 이유는 뭔가요?

코루틴 안에서 time.sleep 이나 requests.get 같은 블로킹 호출을 하면 이벤트 루프 전체가 멈춥니다. 0.5초짜리 time.sleep 하나로, 10ms 마다 돌고 싶은 태스크에 510ms 공백이 생겼습니다. await 가능한 버전을 쓰거나 asyncio.to_thread 로 스레드에 넘기세요.

  • #Python
  • #파이썬
  • #GIL
  • #threading
  • #multiprocessing
  • #asyncio
  • #동시성
  • #고급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑