목차
5 장

함수 — 인자 패턴

기본값, *args/**kwargs, positional-only(/), keyword-only(*)까지 함수 시그니처를 표현력 있게 적는 모든 도구를 정리합니다.

4장 컬렉션과 컴프리헨션에서 데이터 다루는 도구를 봤다면, 본 챕터는 그 데이터를 받아서 처리하는 함수의 시그니처 표현법입니다. 파이썬 함수는 인자 표기법이 풍부합니다. 장점이지만 처음에는 헷갈릴 수 있는 부분이기도 합니다. 한곳에 모아 정리합니다.

본 챕터의 인자 표기는 2부의 12장 데코레이터 패턴과 3부의 20장 typing 고급 — Variance, ParamSpec, Self, overload에서 다시 만납니다. ParamSpec 같은 고급 타입 도구가 결국 본 챕터의 *args / **kwargs 모양을 타입 시스템에 그대로 옮겨놓은 것이라, 본 챕터 끝낼 때 표기 패턴을 분명히 잡아두는 게 뒤에 도움이 됩니다.

가장 단순한 함수 #

기본
def greet(name: str) -> None:
    print(f"hi, {name}!")

greet("커티스")    # hi, 커티스!

def 키워드, 콜론 후 들여쓰기. 2장에서 본 타입 힌트 (: str, -> None)를 매 함수에 적는 것이 모던 파이썬의 원칙이고 본 책도 그 원칙을 따릅니다.

return과 다중 반환 #

다중 반환은 사실 tuple
def split_name(full: str) -> tuple[str, str]:
    first, last = full.split(" ", 1)
    return first, last

first, last = split_name("커티스 김")
print(first, last)   # 커티스 김

return a, b는 사실 **return (a, b)**입니다. 받는 쪽에서 unpack으로 풉니다. JavaScript의 객체 분해와 비슷한 효용을 tuple로 합니다.

return이 없으면 함수는 None을 반환 합니다. 명시적으로 적든, 안 적든 결과는 같습니다.

기본값 #

기본값
def greet(name: str, greeting: str = "hi") -> None:
    print(f"{greeting}, {name}!")

greet("커티스")               # hi, 커티스!
greet("커티스", "안녕하세요")  # 안녕하세요, 커티스!

기본값이 있는 매개변수는 기본값이 없는 것 뒤에 와야 합니다.

✗ 안 됨
def bad(x: int = 0, y: int) -> int: ...
# SyntaxError: non-default argument follows default argument

함정 — 가변 기본값 #

이건 파이썬에서 가장 유명한 함정입니다.

🚫 절대 이렇게 쓰지 마세요
def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to(1))   # [1]
print(append_to(2))   # [1, 2]   ← ?!
print(append_to(3))   # [1, 2, 3]

기본값 []함수가 정의될 때 한 번만 만들어지고, 모든 호출에 같은 객체를 공유합니다. 호출할 때마다 새 빈 리스트가 생기는 게 아닙니다.

해결: None을 기본값으로 두고, 함수 안에서 새로 만듭니다.

✅ 올바른 패턴
def append_to(item, target: list[int] | None = None):
    if target is None:
        target = []
    target.append(item)
    return target

이 패턴은 자주 보게 됩니다. 익혀 두면 편합니다.

Positional과 Keyword 호출 #

호출할 때 인자를 두 가지 방법으로 넘길 수 있습니다.

두 가지 호출 방식
def create_user(name: str, age: int, role: str = "member") -> dict:
    return {"name": name, "age": age, "role": role}

# positional — 위치로
create_user("커티스", 30)
create_user("커티스", 30, "admin")

# keyword — 이름으로
create_user(name="커티스", age=30)
create_user(age=30, name="커티스")    # 순서 바꿔도 OK

# 섞어 쓰기 (positional 먼저)
create_user("커티스", age=30, role="admin")

호출 코드만 봐도 의도가 보이게 하려면 keyword 호출이 강력합니다. create_user("커티스", 30, True, False, "admin") 같은 시그니처는 읽기 어려운데, create_user(name="커티스", age=30, is_active=True, is_admin=False, role="admin")은 한눈에 들어옵니다.

*args — 가변 위치 인자 #

매개변수 앞에 *를 붙이면 “남은 모든 위치 인자를 tuple로 받음"이라는 의미입니다.

*args
def add(*nums: int) -> int:
    total = 0
    for n in nums:
        total += n
    return total

print(add(1, 2, 3))           # 6
print(add(1, 2, 3, 4, 5))     # 15
print(add())                   # 0

이름은 관례상 args 지만, *nums처럼 의미 있는 이름을 적어도 됩니다.

풀어서 넘기기 #

*호출하는 쪽에서도 쓸 수 있습니다. 시퀀스를 풀어서 위치 인자로 넘깁니다.

호출 측의 *
def add(a: int, b: int, c: int) -> int:
    return a + b + c

nums = [1, 2, 3]
print(add(*nums))   # 1, 2, 3 으로 풀려서 들어감 → 6

**kwargs — 가변 키워드 인자 #

**를 붙이면 “남은 모든 키워드 인자를 dict로 받음”.

**kwargs
def configure(**options: str) -> None:
    for key, value in options.items():
        print(f"{key} = {value}")

configure(host="localhost", port="5432", db="myapp")
# host = localhost
# port = 5432
# db = myapp

풀어서 넘기기 #

호출 측의 **는 dict를 풀어서 keyword 인자로 넘깁니다.

호출 측의 **
def create_user(name: str, age: int, role: str) -> dict:
    return {"name": name, "age": age, "role": role}

data = {"name": "커티스", "age": 30, "role": "admin"}
print(create_user(**data))

API 응답을 함수 인자로 그대로 흘려보내는 경우에 자주 씁니다.

다 합친 형태 #

모든 표기 한곳에
def fn(a: int, b: int, *args: int, **kwargs: str) -> None:
    print(a, b, args, kwargs)

fn(1, 2, 3, 4, 5, name="커티스", role="admin")
# 1 2 (3, 4, 5) {'name': '커티스', 'role': 'admin'}

순서: **일반 인자 → *args → 일반 인자(keyword-only) → **kwargs**입니다.

Keyword-only — * 단독 사용 #

*만 따로 두면 그 뒤의 매개변수는 반드시 keyword로만 받게 됩니다.

keyword-only
def create(name: str, *, role: str = "member", active: bool = True) -> dict:
    return {"name": name, "role": role, "active": active}

create("커티스")                              # OK
create("커티스", role="admin")                # OK
create("커티스", "admin")                     # ✗ TypeError
# create() takes 1 positional argument but 2 were given

호출 측이 create("커티스", "admin", True) 같은 모양을 못 쓰게 막아줍니다. 불리언 플래그가 여러 개일 때 호출이 의미 있게 보이도록 강제할 수 있습니다.

Positional-only — / 단독 사용 (3.8+) #

반대로 / 앞은 위치로만 받게 강제합니다.

positional-only
def power(base: float, exp: float = 2, /) -> float:
    return base ** exp

power(3)               # 9
power(2, 3)            # 8
power(base=3)          # ✗ TypeError — base 는 keyword 못 씀

언제 쓰나? 매개변수 이름을 미래에 바꿀 자유를 남겨두고 싶을 때. 호출 측이 이름으로 묶이지 않으면 라이브러리 메인테이너가 이름만 리네이밍해도 호출 측 코드가 안 깨집니다. 빌트인 함수(abs(x), len(seq))가 대부분 이런 형태입니다.

셋 다 함께 #

positional-only / 일반 / keyword-only
def f(pos1: int, pos2: int, /, normal: int, *, kw1: int, kw2: int) -> None:
    print(pos1, pos2, normal, kw1, kw2)

# pos1, pos2 → 위치만
# normal    → 위치 또는 keyword
# kw1, kw2  → keyword 만

f(1, 2, 3, kw1=4, kw2=5)        # OK
f(1, 2, normal=3, kw1=4, kw2=5) # OK
f(pos1=1, pos2=2, ...)          # ✗ pos1, pos2 는 keyword 불가
f(1, 2, 3, 4, 5)                # ✗ kw1, kw2 는 위치 불가

읽는 법:

  • / = positional-only
  • /* 사이 = 일반
  • * = keyword-only

람다 — 한 줄 함수 #

lambda는 익명 함수입니다. 한 표현식만 가질 수 있습니다.

lambda
square = lambda x: x ** 2
print(square(5))    # 25

# sorted 의 key 같은 콜백 역할에 자주 씀
people = [("커티스", 30), ("스미스", 25), ("존", 40)]
people.sort(key=lambda p: p[1])
print(people)
# [('스미스', 25), ('커티스', 30), ('존', 40)]

람다를 변수에 대입해서 쓰는 패턴(square = lambda x: ...)은 권장되지 않습니다. def square(x): ...로 적으세요. 람다는 이름이 필요 없는 경우에만 쓰는 것이 관례입니다.

nonlocalglobal — 스코프 잠깐 #

함수 안에서 바깥 변수를 수정 하려면 명시가 필요합니다.

nonlocal
def outer():
    count = 0

    def inner():
        nonlocal count    # outer 의 count 를 수정한다고 선언
        count += 1

    inner()
    inner()
    print(count)   # 2

outer()

nonlocal 없이 count += 1 하면 새 로컬 변수가 inner 안에 만들어져 outer의 것은 안 바뀝니다. (정확히는 UnboundLocalError가 납니다 — 읽기 전에 쓰기를 하기 때문.)

전역 변수를 함수 안에서 수정할 때는 global을 씁니다. 하지만 가능하면 글로벌 변수 자체를 안 쓰는 쪽이 낫습니다.

함수도 일급 객체 #

함수는 변수에 담을 수 있고, 인자로 넘길 수 있고, 반환할 수도 있습니다.

함수가 값
def add(a: int, b: int) -> int:
    return a + b

op = add           # 함수를 변수에 담음
print(op(2, 3))    # 5

def apply(fn, x: int, y: int) -> int:
    return fn(x, y)

print(apply(add, 2, 3))   # 5

이 성질이 12장 데코레이터 패턴의 토대입니다. 함수를 받아 함수를 반환하는 한 줄 함수가 데코레이터의 본질입니다.

Callable 타입 — 함수를 받는 함수의 타입 #

함수를 인자로 받을 때 그 시그니처를 적고 싶으면 Callable을 씁니다.

Callable
from collections.abc import Callable

def apply(fn: Callable[[int, int], int], x: int, y: int) -> int:
    return fn(x, y)

apply(lambda a, b: a + b, 1, 2)   # 3

Callable[[인자타입들], 반환타입] 형태입니다. 인자 타입을 리스트로 적는 게 처음에 어색합니다.

Callable의 인자 시그니처 자체를 타입 변수로 다루는 ParamSpec은 20장 typing 고급에서 다룹니다.

docstring — 문서 문자열 #

함수 첫 줄의 문자열은 docstring이 됩니다.

docstring
def add(a: int, b: int) -> int:
    """두 정수를 더해 반환한다."""
    return a + b

print(add.__doc__)
# 두 정수를 더해 반환한다.
help(add)

타입 힌트가 “무엇을 받고 무엇을 주는가"를 채워주니, docstring은 **“왜 / 어떻게 쓰는가”**를 적는 부분입니다. 짧고 단호한 한 줄이 가장 좋습니다.

연습문제 #

  1. 가변 기본값 함정을 직접 재현해 보세요. def collect(item, bucket=[]): 시그니처의 함수에 같은 인자로 3번 호출 후 결과를 확인하고, 같은 함수를 bucket: list | None = None + 안에서 새 리스트 생성으로 고친 뒤 같은 호출을 다시 해 보세요.
  2. def request(url: str, *, method: str = "GET", timeout: float = 5.0, retries: int = 0) -> str: 시그니처를 작성하세요. method, timeout, retries는 keyword-only로 강제됩니다. request("https://example.com", "POST") 호출이 실패하는 것과 request("https://example.com", method="POST")가 동작하는 것을 직접 확인합니다.
  3. from collections.abc import Callable을 import 하고, def apply_n_times(fn: Callable[[int], int], x: int, n: int) -> int:를 작성하세요. fnxn 번 적용한 결과를 반환합니다. apply_n_times(lambda x: x + 1, 0, 5) → 5, apply_n_times(lambda x: x * 2, 1, 10) → 1024가 나오는지 확인합니다.

한 줄 요약: 함수의 인자 표기는 일반 / 기본값 / *args / **kwargs / keyword-only(*) / positional-only(/)의 조합이다. 가변 기본값은 None + 함수 안 생성, 불리언 여럿이면 keyword-only 강제. 호출 측의 *seq / **dict는 펼치기. 함수는 값이라 Callable[[...], ...]로 타입 힌트.

다음 챕터 #

다음 6장 에러와 예외 처리에서는 **try/except/else/finally**와 사용자 정의 예외, 그리고 Python 3.11에서 들어온 except* (exception group)까지 다룹니다. 본 챕터의 함수가 던지는 예외를 호출 측이 어떻게 받는지가 6장의 시작입니다.

X