포스트

[Python 100일 챌린지] Day 67 - 함수형 프로그래밍 심화

[Python 100일 챌린지] Day 67 - 함수형 프로그래밍 심화

functools는 함수형 프로그래밍의 보물 상자예요! 🎁✨

어제 배운 map, filter, reduce는 시작일 뿐이에요! functools 모듈에는 partial로 함수를 미리 설정하고, lru_cache로 성능을 몇백 배 높이고, singledispatch로 함수를 타입별로 분기할 수 있는 강력한 도구들이 가득해요! Django, Flask 같은 프레임워크도 이런 기법을 활발히 사용하고 있답니다. 😊

오늘 배울 내용들은 실무에서 코드를 획기적으로 개선할 수 있는 비법이에요!

(40분 완독 ⭐⭐⭐)

🎯 오늘의 학습 목표

📚 사전 지식


🎯 학습 목표 1: functools 모듈 마스터하기

한 줄 설명

functools = 함수를 더 똑똑하게 만드는 도구 상자 🧰✨

함수에 슈퍼파워를 더해주는 파이썬 내장 모듈이에요!

1.1 partial: 부분 적용 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from functools import partial

# 기본 함수
def power(base, exponent):
    return base ** exponent

# 지수를 고정한 함수 생성
square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))  # 25
print(cube(5))    # 125

# 실전: 로깅 함수
def log(level, message):
    print(f"[{level}] {message}")

info = partial(log, "INFO")
error = partial(log, "ERROR")

info("프로그램 시작")   # [INFO] 프로그램 시작
error("오류 발생")      # [ERROR] 오류 발생

💡 실생활 비유: partial은 설정이 저장된 리모컨 같아요! TV 리모컨에 “넷플릭스 버튼”을 누르면 자동으로 HDMI 3번으로 전환되는 것처럼, 자주 쓰는 설정을 미리 저장해두는 거예요!

partial이 왜 유용할까요? 🤔

  • 같은 인자를 반복해서 전달할 필요 없어요
  • 함수를 특정 용도로 맞춤 제작할 수 있어요
  • 코드가 훨씬 깔끔해져요!

1.2 wraps: 함수 메타데이터 보존

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from functools import wraps

def my_decorator(func):
    @wraps(func)  # 원본 함수의 정보 보존
    def wrapper(*args, **kwargs):
        """래퍼 함수"""
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def greet(name):
    """인사하는 함수"""
    return f"Hello, {name}!"

print(greet.__name__)  # greet (wraps 없으면 wrapper)
print(greet.__doc__)   # 인사하는 함수

💡 핵심: Day 61에서 배운 것처럼, 데코레이터 만들 때는 항상 @wraps(func)를 사용하세요! 그래야 원본 함수의 이름과 문서가 보존돼요!

1.3 reduce: 누적 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from functools import reduce

# 합계
numbers = [1, 2, 3, 4, 5]
total = reduce(lambda x, y: x + y, numbers)
print(total)  # 15

# 곱셈
product = reduce(lambda x, y: x * y, numbers)
print(product)  # 120

# 실전: 딕셔너리 병합
dicts = [
    {'a': 1, 'b': 2},
    {'b': 3, 'c': 4},
    {'c': 5, 'd': 6}
]

merged = reduce(lambda acc, d: {**acc, **d}, dicts, {})
print(merged)  # {'a': 1, 'b': 3, 'c': 5, 'd': 6}

💡 실생활 비유: reduce는 여러 사람의 의견을 하나로 모으는 회의 같아요! 처음엔 첫 번째 의견으로 시작해서, 두 번째 의견을 합치고, 세 번째 의견을 합치고… 결국 하나의 결론이 나오죠!

1.4 total_ordering: 비교 연산자 자동 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from functools import total_ordering

@total_ordering
class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def __eq__(self, other):
        return self.grade == other.grade

    def __lt__(self, other):
        return self.grade < other.grade

# __eq__와 __lt__만 정의하면
# __le__, __gt__, __ge__ 자동 생성!

alice = Student("Alice", 85)
bob = Student("Bob", 90)

print(alice < bob)   # True
print(alice <= bob)  # True
print(alice > bob)   # False
print(alice >= bob)  # False

💡 실생활 비유: total_ordering은 자동 완성 기능 같아요! “크다”와 “같다”만 정의하면, “작다”, “크거나 같다”, “작거나 같다”를 자동으로 만들어줘요!

왜 유용할까요? 🌟

  • 비교 연산자를 일일이 다 만들 필요 없어요
  • 코드가 훨씬 간결해져요
  • 실수로 비교 로직이 엇갈릴 위험이 없어요!

🎯 학습 목표 2: 파셜 함수와 커링

한 줄 설명

파셜 = 함수의 일부 인자를 미리 채워두기 🍕

피자 주문할 때 “치즈 피자, 라지 사이즈”는 항상 고정하고 토핑만 바꾸는 것처럼!

2.1 파셜 함수 심화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from functools import partial

# 복잡한 함수
def greet(greeting, name, punctuation):
    return f"{greeting}, {name}{punctuation}"

# 다양한 파셜 함수 생성
hello = partial(greet, "Hello")
hello_name = partial(hello, punctuation="!")

print(hello("Alice", "!"))      # Hello, Alice!
print(hello_name("Bob"))        # Hello, Bob!

# 실전: API 요청 함수
def api_request(base_url, endpoint, method, data=None):
    return f"{method} {base_url}/{endpoint}"

# 특정 API 서버에 대한 함수들
my_api = partial(api_request, "https://api.example.com")
get_users = partial(my_api, "users", "GET")
create_user = partial(my_api, "users", "POST")

print(get_users())           # GET https://api.example.com/users
print(create_user(data={}))  # POST https://api.example.com/users

실무에서 이렇게 써요! 💼

  • API 클라이언트에서 base URL 고정해서 사용
  • 로깅 함수에서 로그 레벨 고정
  • 이벤트 핸들러에서 특정 설정 고정

💡 핵심: partial은 함수를 재사용 가능한 작은 조각들로 만들어요! DRY(Don’t Repeat Yourself) 원칙의 완벽한 구현이에요!

2.2 커링 (Currying)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 커링: 여러 인자를 받는 함수를 하나씩 받는 함수로 변환

def curry(func):
    """함수를 커리된 버전으로 변환"""
    def curried(*args):
        if len(args) >= func.__code__.co_argcount:
            return func(*args)
        return lambda *more_args: curried(*(args + more_args))
    return curried

# 일반 함수
def add_three(a, b, c):
    return a + b + c

# 커리된 버전
curried_add = curry(add_three)

print(curried_add(1)(2)(3))  # 6
print(curried_add(1, 2)(3))  # 6
print(curried_add(1)(2, 3))  # 6

💡 실생활 비유: 커링은 양파 껍질 벗기기 같아요! 한 번에 하나씩 인자를 받아서, 층층이 함수를 만들어가는 거예요. Haskell 같은 함수형 언어에서 아주 흔하게 쓰여요!

커링이 뭐예요? 🤔

  • 여러 인자를 한 번에 받는 대신, 하나씩 받아요
  • 부분 적용이 더 자연스러워져요
  • 함수 조합이 쉬워져요!

🎯 학습 목표 3: 메모이제이션과 캐싱

한 줄 설명

메모이제이션 = 계산 결과를 저장해두고 재사용 💾✨

같은 계산을 반복하지 말고, 한 번 계산한 건 저장해뒀다 꺼내 쓰는 거예요!

3.1 lru_cache: 자동 캐싱

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
from functools import lru_cache

# 캐싱 없이
def fibonacci_slow(n):
    """느린 피보나치 (지수 시간)"""
    if n < 2:
        return n
    return fibonacci_slow(n - 1) + fibonacci_slow(n - 2)

# 캐싱 적용
@lru_cache(maxsize=None)
def fibonacci_fast(n):
    """빠른 피보나치 (선형 시간)"""
    if n < 2:
        return n
    return fibonacci_fast(n - 1) + fibonacci_fast(n - 2)

import time

# 비교
start = time.time()
print(fibonacci_slow(30))  # 832040
print(f"시간: {time.time() - start:.4f}")  # ~0.2초

start = time.time()
print(fibonacci_fast(30))  # 832040
print(f"시간: {time.time() - start:.4f}")  # ~0.0001초

# 캐시 정보 확인
print(fibonacci_fast.cache_info())

💡 실생활 비유: lru_cache는 전화번호 최근 통화 목록 같아요! 자주 전화하는 사람은 목록에 저장되어 있어서 다시 번호를 찾을 필요 없죠. LRU는 “Least Recently Used”로, 오래 안 쓴 건 지워요!

lru_cache의 마법! 🎩✨

  • 피보나치(30) 계산: 캐시 없으면 0.2초 → 캐시 있으면 0.0001초!
  • 수백 배 빨라져요!
  • 복잡한 계산, API 호출, 데이터베이스 쿼리 등에 최고예요!

3.2 캐시 관리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from functools import lru_cache

@lru_cache(maxsize=128)
def expensive_calculation(n):
    """비용이 큰 계산"""
    print(f"계산 중: {n}")
    return n ** 2

# 첫 호출: 계산 수행
print(expensive_calculation(5))  # 계산 중: 5 \n 25

# 두 번째 호출: 캐시에서 가져옴
print(expensive_calculation(5))  # 25 (출력 없음)

# 캐시 정보
print(expensive_calculation.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

# 캐시 지우기
expensive_calculation.cache_clear()
print(expensive_calculation(5))  # 계산 중: 5 \n 25

3.3 커스텀 메모이제이션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def memoize(func):
    """커스텀 메모이제이션 데코레이터"""
    cache = {}

    def wrapper(*args):
        if args not in cache:
            print(f"캐시 미스: {args}")
            cache[args] = func(*args)
        else:
            print(f"캐시 히트: {args}")
        return cache[args]

    wrapper.cache = cache
    return wrapper

@memoize
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(factorial(5))  # 120
print(f"캐시 크기: {len(factorial.cache)}")
print(factorial.cache)

💡 캐시 팁: maxsize=None으로 설정하면 무제한 캐시! 하지만 메모리를 많이 쓸 수 있으니 주의하세요!


🎯 학습 목표 4: 고급 함수형 패턴

한 줄 설명

함수 조합 = 레고 블록처럼 함수를 연결하기 🧱🔗

작은 함수들을 파이프로 연결해서 복잡한 작업을 만들어요!

4.1 함수 파이프라인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from functools import reduce

def pipe(*functions):
    """함수 파이프라인 (왼쪽에서 오른쪽)"""
    return lambda x: reduce(lambda v, f: f(v), functions, x)

# 함수들
add_10 = lambda x: x + 10
multiply_2 = lambda x: x * 2
square = lambda x: x ** 2

# 파이프라인 구성
process = pipe(add_10, multiply_2, square)

print(process(5))  # ((5 + 10) * 2) ** 2 = 900

💡 실생활 비유: 파이프라인은 공장 생산 라인 같아요! 원자재가 들어가서 → 가공하고 → 조립하고 → 포장해서 → 완제품이 나오는 것처럼, 데이터가 함수들을 거쳐 변환돼요!

파이프라인이 왜 좋을까요? 🌟

  • 코드가 읽기 쉬워요 (왼쪽에서 오른쪽으로)
  • 각 단계가 명확해요
  • 재사용하기 쉬워요!

4.2 모나드 패턴 (Maybe Monad)

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
class Maybe:
    """값이 있을 수도, 없을 수도 있는 컨테이너"""
    def __init__(self, value):
        self.value = value

    def map(self, func):
        """값이 있으면 함수 적용"""
        if self.value is None:
            return Maybe(None)
        return Maybe(func(self.value))

    def get_or_else(self, default):
        """값이 없으면 기본값 반환"""
        return self.value if self.value is not None else default

# 사용
result = (Maybe(5)
    .map(lambda x: x * 2)
    .map(lambda x: x + 10)
    .get_or_else(0))

print(result)  # 20

# None 처리
result = (Maybe(None)
    .map(lambda x: x * 2)
    .map(lambda x: x + 10)
    .get_or_else(0))

print(result)  # 0

💡 Maybe Monad: 함수형 프로그래밍의 고급 패턴이에요! None 값을 안전하게 처리할 수 있어요. 자바의 Optional, 스칼라의 Option과 같은 개념이랍니다!

4.3 실전: 데이터 검증 파이프라인

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from functools import reduce

def validate_email(email):
    """이메일 검증"""
    if '@' not in email:
        return None, "이메일에 @가 없습니다"
    if '.' not in email.split('@')[1]:
        return None, "도메인이 유효하지 않습니다"
    return email, None

def validate_age(age):
    """나이 검증"""
    if age < 0:
        return None, "나이는 0 이상이어야 합니다"
    if age > 150:
        return None, "나이가 너무 큽니다"
    return age, None

def validate_name(name):
    """이름 검증"""
    if len(name) < 2:
        return None, "이름은 2글자 이상이어야 합니다"
    return name, None

# 검증 파이프라인
def validate_user(data):
    """사용자 데이터 검증"""
    validators = [
        ('email', validate_email),
        ('age', validate_age),
        ('name', validate_name)
    ]

    errors = []
    for field, validator in validators:
        value, error = validator(data.get(field))
        if error:
            errors.append(f"{field}: {error}")

    return (data, None) if not errors else (None, errors)

# 테스트
user1 = {'email': '[email protected]', 'age': 25, 'name': 'Alice'}
data, error = validate_user(user1)
print(f"유효: {data is not None}")  # True

user2 = {'email': 'invalid', 'age': 200, 'name': 'A'}
data, errors = validate_user(user2)
print(f"오류: {errors}")

4.4 실전: 함수 조합

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
from functools import reduce

class Composer:
    """함수 조합 유틸리티"""
    def __init__(self, *functions):
        self.functions = functions

    def __call__(self, arg):
        return reduce(lambda v, f: f(v), self.functions, arg)

    def __rshift__(self, other):
        """>> 연산자로 파이프라인 구성"""
        if isinstance(other, Composer):
            return Composer(*self.functions, *other.functions)
        return Composer(*self.functions, other)

# 사용
add_10 = lambda x: x + 10
multiply_2 = lambda x: x * 2
square = lambda x: x ** 2

# 방법 1
pipeline = Composer(add_10, multiply_2, square)
print(pipeline(5))  # 900

# 방법 2: >> 연산자
pipeline = Composer(add_10) >> multiply_2 >> square
print(pipeline(5))  # 900

실무에서 이렇게 써요! 💼

  • 사용자 입력 검증 (회원가입, 로그인)
  • API 요청 데이터 검증
  • 설정 파일 검증

💡 오늘의 핵심 요약

  1. functools.partial - 인자 미리 채우기 🍕
    1
    2
    
    from functools import partial
    square = partial(pow, exp=2)
    
  2. functools.lru_cache - 자동 캐싱으로 성능 폭발! ⚡
    1
    2
    3
    
    @lru_cache(maxsize=128)
    def expensive_func(n):
        return n ** 2
    
  3. functools.reduce - 여러 값을 하나로 합치기 📉
    1
    
    total = reduce(lambda x, y: x + y, numbers)
    
  4. 함수형 패턴:
    • 🔗 파이프라인: 함수를 연결해요
    • 🧅 커링: 인자를 하나씩 받아요
    • 💾 메모이제이션: 결과를 저장해요
    • 🧱 함수 조합: 작은 함수를 조립해요

🧪 연습 문제

문제 1: 캐싱된 API 호출 함수

API 호출 결과를 캐싱하는 함수를 작성하세요. 같은 user_id로 두 번 호출하면 두 번째는 캐시에서 가져와야 해요!

1
2
3
4
5
6
7
# 여기에 코드 작성
def fetch_user_data(user_id):
    pass

# 테스트
print(fetch_user_data(1))  # API 호출
print(fetch_user_data(1))  # 캐시에서 (빠름!)
💡 힌트

단계별 힌트:

  1. @lru_cache 데코레이터 사용하기
  2. maxsize=100 정도로 설정하기
  3. time.sleep(0.5)로 API 호출 시뮬레이션
  4. cache_info()로 캐시 통계 확인하기

핵심 키워드: lru_cache, maxsize, cache_info()

정답 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from functools import lru_cache
import time

@lru_cache(maxsize=100)
def fetch_user_data(user_id):
    """사용자 데이터 가져오기 (캐싱됨)"""
    print(f"🔄 API 호출: user_id={user_id}")
    time.sleep(0.5)  # API 호출 시뮬레이션
    return {
        'id': user_id,
        'name': f'User {user_id}',
        'email': f'user{user_id}@example.com'
    }

# 테스트
start = time.time()
print(fetch_user_data(1))  # API 호출
print(fetch_user_data(1))  # 캐시에서 가져옴 (빠름)
print(f"⏱️ 총 시간: {time.time() - start:.4f}")

print(f"📊 캐시 정보:", fetch_user_data.cache_info())

출력:

1
2
3
4
5
🔄 API 호출: user_id=1
{'id': 1, 'name': 'User 1', 'email': '[email protected]'}
{'id': 1, 'name': 'User 1', 'email': '[email protected]'}
⏱️ 총 시간: 0.5021초
📊 캐시 정보: CacheInfo(hits=1, misses=1, maxsize=100, currsize=1)

설명:

  • 첫 호출: API 호출 (0.5초)
  • 두 번째 호출: 캐시에서 가져옴 (즉시!)
  • hits=1: 캐시 적중 1회
  • misses=1: 캐시 미스 1회

문제 2: 함수 파이프라인 만들기

pipe 함수를 사용해서 “10 더하고 → 2배 곱하고 → 제곱하기” 파이프라인을 만드세요!

💡 힌트

단계별 힌트:

  1. reduce를 사용해서 함수들을 순차적으로 적용
  2. 람다 함수 3개 만들기 (add_10, multiply_2, square)
  3. pipe(*functions) 형태로 조합하기

핵심 키워드: reduce, lambda, 함수 조합

정답 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from functools import reduce

def pipe(*functions):
    """함수 파이프라인 (왼쪽에서 오른쪽)"""
    return lambda x: reduce(lambda v, f: f(v), functions, x)

# 함수들 정의
add_10 = lambda x: x + 10
multiply_2 = lambda x: x * 2
square = lambda x: x ** 2

# 파이프라인 구성
process = pipe(add_10, multiply_2, square)

print(process(5))  # ((5 + 10) * 2) ** 2 = 900

계산 과정:

  1. 5 → add_10 → 15
  2. 15 → multiply_2 → 30
  3. 30 → square → 900

📝 오늘 배운 내용 정리

  1. functools.partial: 함수의 인자를 미리 채워서 새로운 함수를 만들어요
  2. functools.lru_cache: 함수 결과를 자동으로 캐싱해서 성능을 획기적으로 높여요
  3. 커링: 여러 인자를 한 번에 받는 대신 하나씩 받는 함수로 변환해요
  4. 함수 파이프라인: 작은 함수들을 연결해서 복잡한 작업을 처리해요
  5. 메모이제이션: 한 번 계산한 결과를 저장해서 재사용해요

🔗 관련 자료


📚 이전 학습

Day 66: 함수형 프로그래밍 기초 ⭐⭐⭐

어제는 map, filter, reduce로 함수형 프로그래밍의 기초를 배웠어요!

📚 다음 학습

Day 68: 고급 컬렉션 (collections 모듈) ⭐⭐⭐

내일은 Counter, defaultdict, deque 등 파이썬의 강력한 컬렉션 타입들을 배워요!


“작은 함수들을 조합하면 놀라운 일을 할 수 있어요!” 🚀

Day 67/100 Phase 7: 고급 파이썬 개념 #100DaysOfPython
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.