성능 최적화 (Performance Optimization)

성능 최적화 (Performance Optimization)

1. 성능 측정 기초

최적화 전에 반드시 측정하세요. "추측하지 말고 측정하라."

timeit 모듈

import timeit

# 문자열로 코드 측정
time = timeit.timeit(
    'sum(range(1000))',
    number=10000
)
print(f"실행 시간: {time:.4f}초")

# 함수 측정
def my_sum():
    return sum(range(1000))

time = timeit.timeit(my_sum, number=10000)
print(f"실행 시간: {time:.4f}초")

IPython/Jupyter에서

# 한 줄 측정
%timeit sum(range(1000))

# 셀 전체 측정
%%timeit
total = 0
for i in range(1000):
    total += i

시간 측정 데코레이터

import time
from functools import wraps

def timing(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__}: {end - start:.4f}초")
        return result
    return wrapper

@timing
def slow_function():
    time.sleep(1)
    return "완료"

slow_function()  # slow_function: 1.0012초

2. 프로파일링

cProfile

import cProfile
import pstats

def expensive_function():
    total = 0
    for i in range(10000):
        total += sum(range(100))
    return total

# 프로파일링
profiler = cProfile.Profile()
profiler.enable()

expensive_function()

profiler.disable()

# 결과 출력
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative')
stats.print_stats(10)  # 상위 10개

명령줄에서 실행

# 기본 프로파일링
python -m cProfile my_script.py

# 결과 정렬
python -m cProfile -s cumulative my_script.py

# 파일로 저장
python -m cProfile -o output.prof my_script.py

프로파일 결과 분석

import pstats

# 저장된 프로파일 로드
stats = pstats.Stats('output.prof')

# 정렬 기준
# 'calls': 호출 횟수
# 'time': 내부 시간
# 'cumulative': 누적 시간
stats.sort_stats('cumulative')
stats.print_stats(20)

# 특정 함수만 보기
stats.print_stats('my_function')

line_profiler (라인별 분석)

pip install line_profiler
# my_script.py
@profile  # 데코레이터 추가
def slow_function():
    result = []
    for i in range(10000):
        result.append(i ** 2)
    return result

slow_function()
kernprof -l -v my_script.py

3. 메모리 프로파일링

memory_profiler

pip install memory_profiler
from memory_profiler import profile

@profile
def memory_hungry():
    big_list = [i for i in range(1000000)]
    del big_list
    small_list = [i for i in range(1000)]
    return small_list

memory_hungry()

출력:

Line #    Mem usage    Increment  Occurrences   Line Contents
     3     38.5 MiB     38.5 MiB           1   @profile
     4                                         def memory_hungry():
     5     76.8 MiB     38.3 MiB           1       big_list = [i for i in range(1000000)]
     6     38.5 MiB    -38.3 MiB           1       del big_list
     7     38.5 MiB      0.0 MiB           1       small_list = [i for i in range(1000)]
     8     38.5 MiB      0.0 MiB           1       return small_list

sys.getsizeof()

import sys

# 객체 크기 확인
print(sys.getsizeof([]))         # 56 (빈 리스트)
print(sys.getsizeof([1, 2, 3]))  # 88
print(sys.getsizeof({}))         # 64 (빈 딕셔너리)
print(sys.getsizeof("hello"))    # 54

# 중첩 객체는 포함하지 않음
nested = [[1, 2, 3] for _ in range(10)]
print(sys.getsizeof(nested))  # 외부 리스트만

tracemalloc (메모리 추적)

import tracemalloc

tracemalloc.start()

# 메모리 사용 코드
big_list = [i ** 2 for i in range(100000)]

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

print("[ Top 10 메모리 사용 ]")
for stat in top_stats[:10]:
    print(stat)

tracemalloc.stop()

4. 데이터 구조 선택

리스트 vs 튜플

import sys

# 튜플이 더 작고 빠름
lst = [1, 2, 3, 4, 5]
tup = (1, 2, 3, 4, 5)

print(sys.getsizeof(lst))  # 104
print(sys.getsizeof(tup))  # 80

# 생성 속도
%timeit [1, 2, 3, 4, 5]  # 느림
%timeit (1, 2, 3, 4, 5)  # 빠름 (상수)

리스트 vs 집합 (멤버십 테스트)

import timeit

data_list = list(range(10000))
data_set = set(range(10000))

# 리스트: O(n)
print(timeit.timeit('9999 in data_list', globals=globals(), number=10000))
# 약 0.8초

# 집합: O(1)
print(timeit.timeit('9999 in data_set', globals=globals(), number=10000))
# 약 0.001초

딕셔너리 vs 리스트 (검색)

# 이름으로 검색할 때
users_list = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 25},
    # ... 10000개
]

users_dict = {
    "Alice": {"age": 30},
    "Bob": {"age": 25},
    # ... 10000개
}

# 리스트: O(n)
def find_in_list(name):
    for user in users_list:
        if user["name"] == name:
            return user

# 딕셔너리: O(1)
def find_in_dict(name):
    return users_dict.get(name)

시간 복잡도 정리

연산 list dict set
인덱스 접근 O(1) - -
검색 (in) O(n) O(1) O(1)
삽입 (끝) O(1) O(1) O(1)
삽입 (중간) O(n) - -
삭제 O(n) O(1) O(1)

5. 문자열 최적화

문자열 연결

# 나쁜 예: O(n²)
result = ""
for i in range(10000):
    result += str(i)

# 좋은 예: O(n)
result = "".join(str(i) for i in range(10000))

# 더 좋은 예 (리스트 사용)
parts = []
for i in range(10000):
    parts.append(str(i))
result = "".join(parts)

f-string vs format vs %

name = "Alice"
age = 30

# f-string (가장 빠름, Python 3.6+)
s = f"Name: {name}, Age: {age}"

# format (중간)
s = "Name: {}, Age: {}".format(name, age)

# % 포맷 (느림)
s = "Name: %s, Age: %d" % (name, age)

문자열 인터닝

# 파이썬은 짧은 문자열을 자동으로 인터닝
a = "hello"
b = "hello"
print(a is b)  # True

# 긴 문자열
a = "hello world" * 100
b = "hello world" * 100
print(a is b)  # False

# 수동 인터닝
import sys
a = sys.intern("hello world" * 100)
b = sys.intern("hello world" * 100)
print(a is b)  # True

6. 루프 최적화

지역 변수 사용

import math

# 느림: 매번 전역 조회
def slow():
    result = []
    for i in range(10000):
        result.append(math.sqrt(i))
    return result

# 빠름: 지역 변수로 캐싱
def fast():
    result = []
    sqrt = math.sqrt  # 지역 변수로
    append = result.append  # 지역 변수로
    for i in range(10000):
        append(sqrt(i))
    return result

리스트 컴프리헨션

# for 루프
result = []
for i in range(10000):
    result.append(i ** 2)

# 리스트 컴프리헨션 (더 빠름)
result = [i ** 2 for i in range(10000)]

# map (비슷한 속도)
result = list(map(lambda x: x ** 2, range(10000)))

불필요한 작업 제거

# 나쁜 예: 매번 len() 호출
for i in range(len(my_list)):
    if i < len(my_list) - 1:
        pass

# 좋은 예: 미리 계산
length = len(my_list)
for i in range(length):
    if i < length - 1:
        pass

# 더 좋은 예: enumerate 사용
for i, item in enumerate(my_list):
    pass

7. 함수 최적화

메모이제이션

from functools import lru_cache

# 메모이제이션 없이 (느림)
def fib_slow(n):
    if n < 2:
        return n
    return fib_slow(n - 1) + fib_slow(n - 2)

# 메모이제이션 사용 (빠름)
@lru_cache(maxsize=None)
def fib_fast(n):
    if n < 2:
        return n
    return fib_fast(n - 1) + fib_fast(n - 2)

# fib_slow(35) → 수 초
# fib_fast(35) → 즉시

제너레이터 사용

# 메모리 많이 사용
def get_squares_list(n):
    return [i ** 2 for i in range(n)]

# 메모리 효율적
def get_squares_gen(n):
    for i in range(n):
        yield i ** 2

# 사용
for square in get_squares_gen(1000000):
    if square > 100:
        break

내장 함수 활용

# 직접 구현 (느림)
def my_sum(numbers):
    total = 0
    for n in numbers:
        total += n
    return total

# 내장 함수 (빠름, C로 구현)
total = sum(numbers)

# 다른 예시
max_val = max(numbers)      # 최댓값
min_val = min(numbers)      # 최솟값
sorted_nums = sorted(numbers)  # 정렬
any_positive = any(n > 0 for n in numbers)  # 조건 검사

8. slots 최적화

클래스 인스턴스의 메모리 사용량을 줄입니다.

import sys

# 일반 클래스
class PointRegular:
    def __init__(self, x, y):
        self.x = x
        self.y = y

# __slots__ 사용
class PointSlots:
    __slots__ = ['x', 'y']

    def __init__(self, x, y):
        self.x = x
        self.y = y

# 메모리 비교
p1 = PointRegular(1, 2)
p2 = PointSlots(1, 2)

print(sys.getsizeof(p1.__dict__))  # 104 (딕셔너리 크기)
# PointSlots는 __dict__ 없음

# 많은 인스턴스에서 차이 큼
regular_points = [PointRegular(i, i) for i in range(100000)]
slots_points = [PointSlots(i, i) for i in range(100000)]

slots 주의사항

class Point:
    __slots__ = ['x', 'y']

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)

# 동적 속성 추가 불가
# p.z = 3  # AttributeError

# 상속 시 주의
class Point3D(Point):
    __slots__ = ['z']  # 추가 슬롯만 정의

    def __init__(self, x, y, z):
        super().__init__(x, y)
        self.z = z

9. 병렬 처리

multiprocessing (CPU 바운드)

from multiprocessing import Pool
import time

def cpu_bound_task(n):
    """CPU 집약적 작업"""
    return sum(i * i for i in range(n))

if __name__ == '__main__':
    numbers = [10000000] * 4

    # 순차 실행
    start = time.time()
    results = [cpu_bound_task(n) for n in numbers]
    print(f"순차: {time.time() - start:.2f}초")

    # 병렬 실행
    start = time.time()
    with Pool(4) as pool:
        results = pool.map(cpu_bound_task, numbers)
    print(f"병렬: {time.time() - start:.2f}초")

concurrent.futures

from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import time

def task(n):
    return sum(range(n))

# ProcessPoolExecutor (CPU 바운드)
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(task, [10000000] * 4))

# ThreadPoolExecutor (I/O 바운드)
with ThreadPoolExecutor(max_workers=4) as executor:
    futures = [executor.submit(task, n) for n in [10000000] * 4]
    results = [f.result() for f in futures]

asyncio (I/O 바운드)

import asyncio
import aiohttp

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

async def main():
    urls = ["http://example.com"] * 10

    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

    return results

# asyncio.run(main())

10. NumPy 활용

과학 계산에서 성능 향상을 제공합니다.

기본 비교

import numpy as np

# 순수 파이썬
def python_sum_squares(n):
    return sum(i ** 2 for i in range(n))

# NumPy
def numpy_sum_squares(n):
    arr = np.arange(n)
    return np.sum(arr ** 2)

# NumPy가 훨씬 빠름
%timeit python_sum_squares(1000000)  # ~100ms
%timeit numpy_sum_squares(1000000)   # ~2ms

벡터화 연산

import numpy as np

# 파이썬 루프
def normalize_python(data):
    result = []
    mean = sum(data) / len(data)
    std = (sum((x - mean) ** 2 for x in data) / len(data)) ** 0.5
    for x in data:
        result.append((x - mean) / std)
    return result

# NumPy 벡터화
def normalize_numpy(data):
    arr = np.array(data)
    return (arr - arr.mean()) / arr.std()

11. Cython 소개

파이썬을 C로 컴파일하여 성능을 높입니다.

간단한 예제

# fib.pyx
def fib_py(int n):
    cdef int a = 0
    cdef int b = 1
    cdef int i
    for i in range(n):
        a, b = b, a + b
    return a

빌드

# setup.py
from setuptools import setup
from Cython.Build import cythonize

setup(
    ext_modules=cythonize("fib.pyx")
)
python setup.py build_ext --inplace

12. 최적화 체크리스트

일반 원칙

  1. 측정 먼저: 추측하지 말고 프로파일링
  2. 병목 찾기: 전체의 20%가 80% 시간 소모
  3. 알고리즘 개선: 자료구조와 알고리즘이 가장 중요
  4. 가독성 유지: 과도한 최적화는 금물

빠른 승리 (Quick Wins)

항목 방법
멤버십 테스트 list → set
문자열 연결 + → join()
루프 for → 컴프리헨션
함수 호출 지역 변수 캐싱
반복 계산 @lru_cache
많은 객체 slots

상황별 선택

상황 해결책
CPU 바운드 multiprocessing, NumPy, Cython
I/O 바운드 asyncio, threading
메모리 부족 제너레이터, slots
반복 계산 메모이제이션

13. 요약

도구 용도
timeit 실행 시간 측정
cProfile 함수 단위 프로파일링
line_profiler 라인 단위 프로파일링
memory_profiler 메모리 프로파일링
tracemalloc 메모리 추적
최적화 기법 효과
적절한 자료구조 O(n) → O(1)
리스트 컴프리헨션 루프보다 빠름
지역 변수 캐싱 조회 비용 감소
메모이제이션 중복 계산 제거
slots 메모리 절약
병렬 처리 CPU 활용 극대화

14. 연습 문제

연습 1: 프로파일링

느린 코드를 프로파일링하고 병목을 찾아 개선하세요.

def slow_function(n):
    result = ""
    for i in range(n):
        if i in [j for j in range(i)]:
            result += str(i)
    return result

연습 2: 메모리 최적화

대용량 CSV 파일을 메모리 효율적으로 처리하는 함수를 작성하세요.

연습 3: 병렬 처리

CPU 바운드 작업을 병렬화하여 성능을 개선하세요.


마무리

이 가이드에서 파이썬 고급 문법을 학습했습니다. 실제 프로젝트에 적용하면서 경험을 쌓아가세요!

학습 완료 체크리스트

  • [ ] 타입 힌팅으로 코드 품질 향상
  • [ ] 데코레이터로 코드 재사용
  • [ ] 컨텍스트 매니저로 리소스 관리
  • [ ] 제너레이터로 메모리 효율화
  • [ ] 클로저로 상태 캡슐화
  • [ ] 메타클래스로 클래스 커스터마이징
  • [ ] 디스크립터로 속성 제어
  • [ ] asyncio로 비동기 처리
  • [ ] 함수형 패턴 활용
  • [ ] 성능 측정 및 최적화
to navigate between lessons