티스토리 뷰

Lambda 함수 최적화 과정에서 배운 Python 동시성 개념 정리

📚 배경

API 크롤링 Lambda 함수가 너무 오래 걸려서 최적화가 필요했다. 여러 데이터를 순차적으로 수집하는데 10분 이상 소요되었고, 이를 병렬 처리로 개선하려고 했다.

그런데 멀티스레딩, 멀티프로세싱, 비동기라는 용어가 나오면서 혼란스러웠다. 각각 언제 사용해야 하는지, 어떤 차이가 있는지 정리해보았다.


🔑 핵심 개념: I/O Bound vs CPU Bound

프로그램의 병목 지점이 어디인지에 따라 최적화 방법이 달라진다.

CPU Bound (CPU 집약적)

정의: CPU 연산이 병목인 작업

예시:

  • 이미지/비디오 처리 (리사이징, 필터링)
  • 데이터 암호화/복호화
  • 복잡한 수학 연산 (머신러닝 학습)
  • 대용량 데이터 변환

특징:

# CPU가 계속 일함 
for i in range(10_000_000):
    result = i ** 2  # CPU 연산

I/O Bound (I/O 집약적)

정의: 네트워크/디스크 대기 시간이 병목인 작업

예시:

  • HTTP API 요청
  • 데이터베이스 쿼리
  • 파일 읽기/쓰기
  • S3 업로드/다운로드

특징:

# CPU는 놀고 네트워크 응답만 기다림
response = requests.get("https://api.example.com")  # 대기...

방법 1: 멀티스레딩 (Threading)

개념

하나의 프로세스 안에서 여러 스레드가 동시에 실행되는 것처럼 보이게 하는 기법

from concurrent.futures import ThreadPoolExecutor

def fetch_data(url):
    response = requests.get(url)  # I/O 대기
    return response.json()

urls = ["https://api1.com", "https://api2.com", "https://api3.com"]

# 3개 URL을 동시에 요청
with ThreadPoolExecutor(max_workers=3) as executor:
    results = executor.map(fetch_data, urls)

장점

I/O Bound 작업에 최적

  • 네트워크 대기 중에는 다른 스레드가 실행됨
  • API 요청, DB 쿼리, 파일 I/O 등에 효과적

메모리 효율적

  • 같은 프로세스 내에서 메모리 공유
  • 프로세스보다 가벼움

구현 간단

  • ThreadPoolExecutor 사용하면 쉽게 적용 가능

단점

CPU Bound 작업에는 비효율

  • Python GIL(Global Interpreter Lock) 때문
  • 한 번에 하나의 스레드만 Python 코드 실행 가능

스레드 안전성 고려 필요

  • 공유 변수 접근 시 Lock 필요

GIL(Global Interpreter Lock)이란?

Python 인터프리터는 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 제한한다.

# CPU 연산: GIL 때문에 병렬 처리 안 됨
def cpu_task():
    total = 0
    for i in range(10_000_000):
        total += i ** 2  # GIL 잡고 실행
    return total

# 멀티스레딩으로 실행해도 순차 실행과 비슷한 속도
with ThreadPoolExecutor(max_workers=4) as executor:
    results = executor.map(cpu_task, range(4))

하지만 I/O 작업은 GIL 문제 없음!

# I/O 작업: 네트워크 대기 중에는 GIL 해제됨
def io_task(url):
    response = requests.get(url)  # GIL 해제 → 다른 스레드 실행 가능
    return response.text

# 멀티스레딩으로 실행하면 훨씬 빠름
with ThreadPoolExecutor(max_workers=4) as executor:
    results = executor.map(io_task, urls)

방법 2: 멀티프로세싱 (Multiprocessing)

개념

여러 프로세스를 생성하여 각각 독립적인 Python 인터프리터에서 실행

from concurrent.futures import ProcessPoolExecutor

def cpu_intensive_task(n):
    total = 0
    for i in range(n):
        total += i ** 2
    return total

numbers = [10_000_000, 10_000_000, 10_000_000, 10_000_000]

# 4개 프로세스가 진짜 병렬로 실행
with ProcessPoolExecutor(max_workers=4) as executor:
    results = executor.map(cpu_intensive_task, numbers)

장점

CPU Bound 작업에 최적

  • 각 프로세스가 독립적인 GIL 보유
  • 진짜 병렬 처리 가능 (멀티코어 활용)

완전한 격리

  • 프로세스 간 메모리 공유 안 함 → 안전

단점

메모리 오버헤드

  • 각 프로세스가 독립적인 메모리 공간 필요
  • Lambda 같은 메모리 제한 환경에서 부담

프로세스 생성 비용

  • 프로세스 생성/종료에 시간 소요
  • 스레드보다 무거움

I/O Bound 작업에는 과도함

  • 네트워크 대기가 병목이면 멀티스레딩으로 충분

방법 3: 비동기 (Asyncio)

개념

단일 스레드에서 여러 작업을 번갈아가며 실행 (협력적 멀티태스킹)

import asyncio
import aiohttp

async def fetch_data(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = ["https://api1.com", "https://api2.com", "https://api3.com"]

    async with aiohttp.ClientSession() as session:
        tasks = [fetch_data(session, url) for url in urls]
        results = await asyncio.gather(*tasks)  # 동시 실행

    return results

# 실행
asyncio.run(main())

장점

I/O Bound 작업에 매우 효율적

  • 수천 개의 동시 연결 처리 가능
  • 멀티스레딩보다 오버헤드 적음

메모리 효율

  • 단일 스레드에서 실행
  • 스레드/프로세스 생성 비용 없음

세밀한 제어

  • 동시 실행 수, 타임아웃 등 제어 가능

단점

코드 복잡도 증가

  • async/await 문법 학습 필요
  • 기존 동기 라이브러리 사용 불가 (aiohttp, aioboto3 등 필요)

CPU Bound 작업에는 부적합

  • 단일 스레드라서 CPU 연산 시 블로킹

디버깅 어려움

  • 비동기 코드는 스택 트레이스 추적이 복잡

📊 비교표

항목 멀티스레딩 멀티프로세싱 비동기
적합한 작업 I/O Bound CPU Bound I/O Bound
GIL 영향 있음 (I/O는 괜찮음) 없음 (독립 프로세스) 있음 (단일 스레드)
메모리 사용 낮음 높음 매우 낮음
생성 비용 낮음 높음 매우 낮음
동시 처리 수 수십 개 CPU 코어 수 수천 개
구현 복잡도 낮음 낮음 높음
예시 API 요청, DB 쿼리 이미지 처리, 암호화 웹 크롤링, 웹소켓

실전 예시: API 크롤링 최적화

문제 상황

# 순차 처리: 10개 × 각 20개 API 요청 = 200개 요청
# 각 요청 0.5초 → 총 100초 소요
for artist in artists:  # 10개
    for schedule_id in schedule_ids:  # 20개
        response = requests.get(f"https://api.com/{schedule_id}")
        process(response)

해결 방법 1: 멀티스레딩 (데이터 단위 병렬화)

from concurrent.futures import ThreadPoolExecutor

def process_artist(artist):
    for schedule_id in artist.schedule_ids:
        response = requests.get(f"https://api.com/{schedule_id}")
        process(response)

# 4개 동시에 처리
with ThreadPoolExecutor(max_workers=4) as executor:
    executor.map(process_artist, artists)

# 예상 시간: 100초 → 25초 (4배 개선)

장점: 안전하고 구현 간단

해결 방법 2: 비동기 (HTTP 요청 단위 병렬화)

import asyncio
import aiohttp

async def fetch_schedule(session, schedule_id):
    url = f"https://api.com/{schedule_id}"
    async with session.get(url) as response:
        return await response.json()

async def process_artist(session, artist):
    tasks = [fetch_schedule(session, sid) for sid in artist.schedule_ids]
    results = await asyncio.gather(*tasks)
    return results

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [process_artist(session, artist) for artist in artists]
        await asyncio.gather(*tasks)

asyncio.run(main())

# 예상 시간: 100초 → 1-2초 (50-100배 개선)

주의: Rate Limit 위험! 동시 요청 수 제어 필요

해결 방법 3: 비동기 + Rate Limiting (추천)

from asyncio import Semaphore

async def fetch_with_limit(session, schedule_id, semaphore):
    async with semaphore:  # 동시 요청 수 제한
        url = f"https://api.com/{schedule_id}"
        async with session.get(url) as response:
            return await response.json()

async def main():
    semaphore = Semaphore(10)  # 최대 10개만 동시 실행

    async with aiohttp.ClientSession() as session:
        tasks = []
        for artist in artists:
            for sid in artist.schedule_ids:
                task = fetch_with_limit(session, sid, semaphore)
                tasks.append(task)

        results = await asyncio.gather(*tasks)

# 예상 시간: 100초 → 10초 (10배 개선, 안전함)

🤔 어떤 방법을 선택해야 할까?

플로우차트

작업 유형이 뭐야?
│
├─ CPU 연산이 많음 (이미지 처리, 암호화 등)
│  └─> 멀티프로세싱 (ProcessPoolExecutor)
│
└─ 네트워크/파일 I/O가 많음 (API, DB, S3 등)
   │
   ├─ 구현 간단하게 하고 싶음
   │  └─> 멀티스레딩 (ThreadPoolExecutor)
   │
   └─ 최대 성능 필요 (수백~수천 개 동시 처리)
      └─> 비동기 (asyncio + aiohttp)

내 케이스: Lambda API 크롤링

선택: 멀티스레딩 (ThreadPoolExecutor)

이유:

  1. I/O Bound: HTTP 요청이 병목
  2. 안전성: Rate Limit 회피 (아티스트 단위 병렬화)
  3. 간단함: 기존 코드 최소 수정
  4. 충분한 성능: 60-70% 개선 예상

나중에 고려: 비동기 (더 빠른 속도 필요 시)


💡 핵심 정리

  1. I/O Bound 작업 (API, DB, 파일)

    • 멀티스레딩 또는 비동기 사용
    • GIL은 I/O 대기 중에는 문제 없음
  2. CPU Bound 작업 (연산, 변환)

    • 멀티프로세싱 사용
    • GIL 우회하여 진짜 병렬 처리
  3. Lambda 환경

    • 메모리 제한 고려 → 멀티스레딩 선호
    • 프로세스 생성 비용 고려
  4. Rate Limiting

    • 비동기 사용 시 반드시 동시 요청 수 제어
    • Semaphore 또는 asyncio.Queue 활용

📚 참고 자료

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함