모던 파이썬 기초 #5 함수 — 인자 패턴

7 분 소요

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

가장 단순한 함수 #

기본
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

이 성질이 구 강좌의 데코레이터클로저의 토대입니다. 모던 시리즈에서는 **다음 시리즈(파이썬 중급)**에서 데코레이터를 다시 다룹니다.

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[[인자타입들], 반환타입] 형태입니다. 인자 타입을 리스트로 적는 게 처음에 어색합니다.

docstring — 문서 문자열 #

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

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

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

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

정리 #

이번 글에서 다룬 모든 인자 패턴:

  • 기본값 — 가변 객체(list/dict)는 절대 기본값으로 두지 말고 None + 함수 안에서 생성
  • positional vs keyword 호출 — keyword 호출이 의도를 잘 드러냄
  • *args — 가변 위치 인자, tuple로 받음
  • **kwargs — 가변 키워드 인자, dict로 받음
  • 호출 측 *seq, **dict — 펼쳐서 넘기기
  • *, 뒤는 keyword-only — 불리언 플래그 여럿일 때 호출 가독성 강제
  • /, 앞은 positional-only — 매개변수 이름의 자유를 보존
  • lambda는 콜백 용도, 변수 대입은 안 함
  • nonlocal로 바깥 스코프 변수 수정
  • 함수는 일급 객체 — Callable[[...], ...]로 타입 힌트
  • docstring은 """..."""로 첫 줄

다음 글(#6 에러와 예외 처리)에서는 **try/except/else/finally**와 사용자 정의 예외, 그리고 Python 3.11에서 들어온 except* (exception group) 까지 다룹니다.

X