Skip to content
This page has been auto-translated and may contain errors.View in English

함수

프로그램이 커지다 보면 같은 로직을 여러 곳에 반복해서 작성하게 됩니다. 함수를 사용하면 로직을 한 번만 작성하고 이름을 붙여서 어디서든 사용할 수 있습니다. 한 곳만 고치면 그 함수를 호출하는 모든 곳에 수정 사항이 자동으로 반영됩니다.

함수는 코드 재사용과 추상화의 핵심 단위입니다. 동작을 하나로 묶어 이름을 붙이고, 명확한 인터페이스(매개변수와 반환값)를 정의해서 어디서든 호출할 수 있게 만듭니다. 이름을 잘 지은 함수는 그 자체로 문서 역할도 합니다. validate_email()이라는 이름만 봐도 본문을 읽지 않고도 그 코드 블록이 무엇을 하는지 알 수 있습니다.

파이썬에서 함수는 **일급 객체(first-class object)**입니다. 다른 값들처럼 자유롭게 주고받을 수 있는 값이기 때문에, 변수에 할당하거나 리스트나 딕셔너리에 저장하고, 다른 함수에 인자로 넘기거나 함수에서 반환할 수도 있습니다. def는 이 객체를 만들어서 현재 스코프(그 이름이 보이는 코드 영역)의 한 이름에 묶어줍니다. 함수를 값으로 다룰 수 있다는 이 특성 덕분에 sorted(key=...)나 작은 플러그인 테이블 같은 패턴이 가능해지므로, 일찍 익숙해질수록 좋습니다.

python
def greet(name):
    return f"Hello, {name}!"

print(greet("민지"))   # "Hello, 민지!"
print(greet("준호"))     # "Hello, 준호!"

한 번만 작성하고, 어디서든 사용하고, 한 곳에서 고치세요.

함수 정의하기

def 키워드로 함수 정의를 시작하고, 그 뒤에 이름, 괄호, 콜론, 들여쓰기된 본문이 이어집니다. 함수는 호출하기 전까지는 아무 일도 하지 않습니다. def로 정의한 다음, 이름과 ()로 호출하세요.

def는 함수 객체를 만들어서 현재 스코프의 주어진 이름에 묶어주는 문장입니다. 본문은 정의 시점에 실행되지 않고, 함수가 호출될 때만 실행됩니다. return 문이 없는 함수는 암묵적으로 None을 반환합니다.

def는 함수 객체를 만들어서 현재 스코프의 이름에 묶어줍니다. 파이썬이 def를 읽을 때 본문이 실행되는 것이 아니라, 함수를 호출할 때만 실행됩니다. 이 구분은 실제로 중요한 의미를 갖습니다. 함수 본문 안의 오타(예를 들어 존재하지 않는 이름)는 그 함수가 실제로 호출되기 전까지는 드러나지 않습니다. 그래서 한 번도 호출되지 않은 함수는 NameError를 숨기고 있다가, 누군가 실제로 호출하는 날 프로덕션에서야 문제가 드러날 수 있습니다.

python
def say_hello():
    print("Hello!")

say_hello()   # 함수를 호출합니다
Juno함수 정의하기def name():를 쓰고 그 아래에 본문을 들여쓰기 하세요. name()으로 호출하기 전까지는 아무 일도 일어나지 않습니다, def는 그저 준비만 해두는 것뿐이니까요. 그리고 return을 빼먹으면 함수는 조용히 None을 돌려주는데, 저도 초반에 이것 때문에 여러 번 당했습니다.
Juno함수 정의하기def는 함수를 만들어 이름에 묶어주지만, 본문은 호출할 때만 실행됩니다. return이 없으면 함수는 None을 반환합니다. 이유 없이 결과가 비어 있을 때는 이 점을 떠올려보세요.
Juno함수 정의하기 본문은 def 시점에 실행되지 않고 함수를 호출할 때만 실행되므로, 안에 잘못된 이름이 있어도 실제로 호출되기 전까지는 눈에 띄지 않을 수 있습니다. return이 없는 함수는 None을 반환하는데, 호출한 쪽에서 값을 기대하고 있다면 이것도 조용한 버그가 됩니다.

매개변수와 인자

매개변수는 함수가 기대하는 입력값입니다. 괄호 안에 나열합니다. 함수를 호출할 때 전달하는 값들은 순서대로 매개변수와 매칭됩니다.

매개변수는 함수의 인터페이스를 정의합니다. 인자는 호출 시점에 전달되는 실제 값입니다. 위치 인자는 위치로 매칭되고, 키워드 인자는 이름으로 매칭됩니다. 기본값을 지정하면 매개변수를 선택적으로 만들 수 있습니다.

호출 시점에 위치 인자는 왼쪽에서 오른쪽 순서로 바인딩되고, 키워드 인자는 이름으로 바인딩됩니다. 같은 매개변수를 두 가지 방식으로 동시에 전달하거나 파이썬이 어디에 배치할지 알 수 없는 경우, 본문 안이 아니라 호출하는 시점에 TypeError가 발생합니다. 파이썬은 시그니처를 제한하는 방법도 제공합니다. 단독으로 쓴 *는 그 뒤의 모든 매개변수를 키워드 전용으로 만들고(호출하는 쪽에서 이름을 반드시 지정해야 함), /는 그 앞의 모든 매개변수를 위치 전용으로 만듭니다. 불리언 플래그나 매개변수가 많은 시그니처에서는 키워드 전용을 활용하세요. 잘못된 위치에 놓인 True는 문제없이 컴파일되지만 버그를 그대로 배포하는 대표적인 실수이기 때문입니다.

python
def greet(name, greeting):
    print(f"{greeting}, {name}!")

greet("민지", "Hello")    # "Hello, 민지!"
greet("준호", "Hi")         # "Hi, 준호!"

**매개변수(parameter)**는 함수 정의에 나오는 이름입니다. **인자(argument)**는 호출할 때 실제로 전달하는 값입니다. 실무에서는 이 두 용어를 섞어서 쓰는 경우가 많고, 이 구분이 중요해지는 건 주로 문서를 읽을 때입니다.

Juno매개변수와 인자 매개변수는 괄호 안에 나열하는 이름이고, 인자는 호출할 때 실제로 건네는 값입니다. 파이썬은 왼쪽에서 오른쪽 순서로 나란히 맞춰주기 때문에, 전달한 순서 그대로 자리에 들어갑니다.
Juno매개변수와 인자 매개변수는 정의에 나오는 이름이고, 인자는 호출할 때의 값입니다. 위치 인자는 왼쪽에서 오른쪽으로 바인딩되므로 위치 자체가 계약인 셈입니다. 두 용어는 흔히 섞어 쓰지만, 그 차이는 문서를 읽을 때만 중요해집니다.
Juno매개변수와 인자 위치 인자는 왼쪽에서 오른쪽으로, 키워드 인자는 이름으로 바인딩되며, 충돌이 생기면 호출하는 시점에 바로 TypeError가 발생합니다. 단독 *는 호출하는 쪽이 그 뒤의 매개변수 이름을 반드시 지정하도록 강제하는데, True/False 플래그가 호출부에서 그저 짐작으로 넘어가는 함수라면 이게 저렴하고 확실한 해결책입니다.

기본값

매개변수에는 기본값을 지정할 수 있습니다. 호출하는 쪽에서 해당 인자를 넘기지 않으면 기본값이 사용됩니다. 기본값이 있는 매개변수는 기본값이 없는 매개변수 뒤에 와야 합니다.

기본값을 지정하면 매개변수를 선택적으로 만들 수 있습니다. 기본값은 매 호출마다가 아니라 정의 시점에 딱 한 번 평가됩니다. 이 점은 변경 가능한(mutable) 기본값을 쓸 때 문제가 됩니다. def f(items=[])는 모든 호출이 같은 리스트를 공유하게 됩니다. 해결책은 기본값으로 None을 쓰고, 함수 본문 안에서 리스트를 새로 만드는 것입니다.

발목을 잡는 세부사항이 하나 있습니다. 기본값은 def가 실행될 때 딱 한 번 만들어지고, 그 기본값을 사용하는 모든 호출이 이를 공유합니다. 0이나 "Hello"처럼 변경 불가능한(immutable, 제자리에서 바꿀 수 없는) 기본값이라면 문제가 없습니다. 하지만 리스트나 딕셔너리처럼 변경 가능한(mutable)(제자리에서 바꿀 수 있는) 기본값이라면 함정이 됩니다. 기본값으로 넘어가는 모든 호출이 같은 객체를 재사용하기 때문입니다:

python
def add_item(item, items=[]):   # 리스트는 한 번만 만들어져서 계속 공유됩니다
    items.append(item)
    return items

add_item("a")   # ['a']
add_item("b")   # ['a', 'b']  <- 새로 만든 리스트가 아닙니다

해결책은 표준적인 방식입니다. 기본값으로 None을 두고, 본문 안에서 새 컨테이너를 만드세요.

python
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
python
def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("민지")           # "Hello, 민지!"
greet("민지", "Hi")     # "Hi, 민지!"

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

Juno기본값 매개변수에 기본값을 주면 선택적이 되어서 호출하는 쪽에서 생략할 수 있습니다. 기본값이 있는 매개변수는 필수 매개변수 뒤에 와야 하는데, 이 순서는 파이썬이 강제하는 규칙입니다. 매번 같은 값을 반복해서 쓰지 않아도 됩니다.
Juno기본값 기본값은 매개변수를 선택적으로 만들고, 필수 매개변수 뒤에 와야 합니다. 주의할 점은 기본값이 정의 시점에 딱 한 번 평가된다는 것이므로, [] 같은 변경 가능한 값은 절대 쓰지 마세요. 대신 None을 기본값으로 두고 본문 안에서 리스트를 새로 만드세요.
Juno기본값 기본값은 def 시점에 딱 한 번 만들어져서 호출 사이에 공유되므로, items=[]는 호출을 거치면서 조용히 값이 누적됩니다. None을 기본값으로 두고 본문에서 새로 만드세요. 이 실수는 실제 코드 리뷰에서 다른 어떤 파이썬 실수보다도 자주 등장합니다.

키워드 인자

함수를 호출할 때 인자에 이름을 붙일 수 있습니다. 이렇게 하면 특히 매개변수가 많은 함수를 호출할 때 가독성이 좋아지고, 원하는 순서대로 전달할 수 있습니다.

키워드 인자를 쓰면 함수 호출 자체가 스스로를 설명합니다. 위치 인자와 키워드 인자를 섞어 쓸 수도 있는데, 위치 인자가 먼저 와야 합니다. 불리언 플래그가 있거나 비슷한 타입의 매개변수가 많은 함수라면, 키워드 인자를 쓰면 인자를 잘못된 순서로 넘기는 조용한 실수를 막을 수 있습니다.

키워드 인자는 이름으로 바인딩되므로 읽기 좋고, 위치를 외울 필요도 없습니다. 호출부의 규칙은 위치 인자가 먼저 오고 키워드 인자가 그 뒤에 오며, 같은 매개변수 이름을 두 번 지정하면 TypeError가 발생한다는 것입니다. 반대로 시도해볼 만한 설계는, 자신이 만드는 시그니처에 단독 *를 넣어서 호출하는 쪽이 매개변수 이름을 반드시 지정하게 하는 것입니다(def connect(host, *, timeout=30)). 이건 비용이 전혀 들지 않으면서, 불명확한 connect("db", 30)을 나중에 누가 중간에 매개변수를 추가해도 여전히 잘 작동하는 connect("db", timeout=30)으로 바꿔줍니다.

python
def describe_player(name, score, level):
    print(f"{name} | Score: {score} | Level: {level}")

describe_player("민지", 87, 5)                        # 위치 인자
describe_player(name="민지", level=5, score=87)       # 키워드 인자, 순서 무관
describe_player("민지", level=5, score=87)            # 혼합: 위치 인자가 먼저
Juno키워드 인자 호출할 때 인자에 이름을 붙이면(score=87) 그 호출 자체가 설명이 되고, 순서도 원하는 대로 정할 수 있습니다. 규칙은 딱 하나, 위치 인자는 이름 붙인 인자보다 먼저 와야 한다는 것뿐입니다. 매개변수가 많은 함수에서 특히 유용합니다.
Juno키워드 인자 인자에 이름을 붙이면 호출이 스스로를 설명하고 순서에서도 자유로워지지만, 위치 인자는 반드시 먼저 와야 합니다. 시그니처가 넓거나 불리언 플래그가 있다면 키워드 인자를 우선하세요. 그러면 누구도 호출을 읽으려고 위치를 세지 않아도 됩니다.
Juno키워드 인자 키워드 인자는 이름으로 바인딩되며 위치 인자 뒤에 와야 합니다. 진짜 지렛대는 자신의 시그니처입니다. 단독 *는 호출하는 쪽이 그 뒤에 오는 인자에 이름을 붙이도록 강제하므로, 누군가 나중에 중간에 새 매개변수를 끼워 넣어도 호출은 여전히 올바르게 읽힙니다.

반환값

return은 호출한 쪽으로 값을 돌려줍니다. return이 없으면 함수는 None을 돌려줍니다. return이 실행되는 순간 함수는 즉시 종료됩니다. 그 블록 안에서 그 뒤에 있는 코드는 실행되지 않습니다.

return은 함수를 종료하고 값을 호출한 쪽에 전달합니다. 명시적인 return이 없는 함수는 암묵적으로 None을 반환합니다. return은 함수 본문 어디에나 올 수 있고 여러 번 쓸 수도 있는데, 가장 먼저 도달한 return이 함수를 끝냅니다. 그래서 가드 절(guard clause)처럼 일찍 반환하는 패턴이 유용합니다.

return은 값을 호출한 쪽에 건네고 그 자리에서 함수를 끝내며, return 없이 끝까지 실행이 흘러간 함수는 None을 반환합니다. return을 여러 번 쓰는 것은 나쁜 냄새가 아니라 좋은 스타일입니다. 실패나 예외적인 상황을 위에서 먼저 return으로 처리하는 "가드 절(guard clause)" 패턴은 본문 전체를 하나의 큰 if로 감싸는 것보다 훨씬 읽기 좋습니다. 놀라기 전에 알아둘 미묘한 점 하나는, try 블록 안의 return도 그에 대응하는 finally 블록을 먼저 실행한다는 것입니다. 그래서 일찍 반환하더라도 finally의 정리 작업은 실행됩니다.

python
def add(a, b):
    return a + b

result = add(3, 4)   # result = 7
print(result)

return은 함수를 즉시 종료시킵니다. 그 블록 안에서 그 뒤에 있는 코드는 실행되지 않습니다.

Juno반환값return은 함수를 호출한 쪽에 값을 건네주고, 그 자리에서 함수가 멈추면서 그 뒤의 코드는 건너뜁니다. return을 빼면 None이 돌아옵니다. print는 값을 화면에 보여주는 것이고 return은 그 값을 다시 쓸 수 있게 돌려주는 것인데, 저도 이 차이를 체감하기까지 시간이 좀 걸렸습니다.
Juno반환값return은 값을 돌려주면서 즉시 종료되고, return이 없으면 None이 됩니다. 여러 번 쓸 수도 있는데, 예외적인 경우를 위에서 먼저 return으로 처리하는 것이 대체로 모든 걸 하나의 큰 if로 감싸는 것보다 깔끔하게 읽힙니다.
Juno반환값return은 그 자리에서 종료시키고, 끝까지 흘러가면 None이 됩니다. 깊은 중첩 대신 가드 절로서의 조기 반환을 활용하세요. 그리고 try 안의 returnfinally 블록을 여전히 실행한다는 점을 기억하세요. 조기 종료되어도 그 안의 정리 작업은 실행됩니다.

여러 값 반환하기

파이썬에서는 값을 쉼표로 구분해서 여러 개를 한꺼번에 반환할 수 있습니다. 호출한 쪽은 이를 튜플로 받아서 한 줄에서 여러 개의 이름으로 풀어낼 수 있습니다.

쉼표로 여러 값을 반환하면 튜플로 묶입니다. 호출한 쪽은 대응하는 이름으로 이를 풀어냅니다. 자연스럽게 두 개 이상의 결과를 만들어내는 함수에서 파이썬답게 쓰이는 방식입니다. 특별한 기능이 아니라, 튜플을 묶고(packing) 풀어내는(unpacking) 것일 뿐입니다.

return a, b는 값을 튜플로 묶고, 호출한 쪽은 low, high = f()로 이를 풀어냅니다. 결과가 두세 개 정도면 이렇게 읽어도 깔끔합니다. 그 이상으로 넘어가면 위치가 오히려 부담이 됩니다. 세 번째 항목이 개수였는지 평균이었는지 함수를 호출하는 사람 중 누구도 기억하지 못하고, 순서가 바뀌면 조용한 버그가 됩니다. 이 지점에 이르면 이름이 있는 것을 반환하세요. NamedTuple이나 @dataclass를 써서 호출하는 쪽이 result[2] 대신 result.average를 쓸 수 있게 하는 겁니다. 어느 쪽이든 반환값을 tuple[int, str]처럼 명시해두면 타입 검사기가 잘못된 개수로 풀어내는 호출을 잡아낼 수 있습니다.

python
def min_max(numbers):
    return min(numbers), max(numbers)

low, high = min_max([3, 7, 1, 9, 4])
print(low, high)   # 1 9

low, high = ... 구문은 **언패킹(unpacking)**입니다. 파이썬은 반환된 각 값을 대응하는 이름에 할당합니다.

Juno여러 값 반환하기 값을 쉼표로 구분하면 함수는 이를 튜플로 묶어서 함께 반환합니다. 호출하는 쪽은 low, high = min_max(...)처럼 한 줄에서 값마다 이름 하나씩 받아낼 수 있습니다. 함수가 자연스럽게 두 개 이상의 답을 만들어낼 때 유용합니다.
Juno여러 값 반환하기 쉼표는 값들을 튜플로 묶고, 호출하는 쪽은 대응하는 이름으로 이를 풀어냅니다. 특별한 기능이 아니라 그냥 튜플을 묶고 푸는 것뿐입니다. 결과가 두세 개면 깔끔하지만, 그 이상이면 덜 그렇습니다.
Juno여러 값 반환하기 쉼표로 반환하면 튜플로 묶이는데, 두세 개의 값이라면 괜찮습니다. 그 이상이 되면 위치가 아무도 읽을 수 없는 함정이 되므로, NamedTuple이나 @dataclass를 반환해서 호출하는 쪽이 인덱스를 세는 대신 result.average를 쓰게 하세요.

스코프

함수 안에서 만든 변수는 그 함수 안에서만 존재합니다. 밖에서는 볼 수 없습니다. 모든 함수 밖에서 정의한 변수는 어디서나 보이지만, 명시적으로 선언하지 않으면 함수 안에서 그 값을 바꿀 수는 없습니다.

함수 안에서 만들어진 이름은 **로컬(local)**입니다. 그 안에서만 존재하고 함수가 반환되면 사라집니다. 파일의 최상위 레벨에서 정의된 이름은 **전역(global)**입니다. 함수 안에서 전역 변수를 읽는 것은 특별한 절차 없이 잘 작동하지만, 값을 할당하려면 global name이 필요합니다. 그렇지 않으면 파이썬은 그 할당을 전역 변수를 가리는 새로운 로컬 변수를 만드는 것으로 취급합니다. 실무에서는 global이 필요한 경우가 거의 없습니다. 값을 인자로 넘기고 return으로 결과를 돌려주면, 함수의 효과가 호출부에서 계속 눈에 보이게 유지됩니다.

파이썬은 이름을 찾을 때 LEGB 규칙을 따르며, 네 곳을 순서대로 확인합니다. Local(이 함수), Enclosing(이 함수를 감싸는 바깥 함수), Global(모듈), 그리고 Built-in(len 같은 이름들)입니다. 읽기는 이 체인을 따라가지만, 별도로 지정하지 않으면 할당은 항상 로컬 변수를 만듭니다. 이 비대칭성이 버그의 원인이 됩니다. global count 선언 없이 전역 변수에 대해 count += 1을 하면 UnboundLocalError가 발생하는데, 이 할당이 함수 전체에서 count를 로컬로 표시해버리기 때문에, 읽기가 실행되는 시점에는 아직 로컬 값이 존재하지 않기 때문입니다. global name은 할당을 다시 모듈 레벨로 돌리고, nonlocal name은 대신 가장 가까운 바깥 함수를 대상으로 삼습니다. 이것이 바로 클로저가 자신을 만든 함수의 변수를 갱신하는 방식입니다. 둘 다 나쁜 냄새로 여기세요. 바깥의 상태를 건드려서 바꾸는 함수는 입력을 받아 출력을 돌려주는 함수보다 테스트하기 어렵습니다.

Juno스코프 함수 안에서 만든 변수는 그 안에서만 존재하고, 바깥에서는 볼 수 없습니다. 안에서 바깥 변수를 읽을 수는 있지만, 값을 바꾸려면 먼저 global 선언이 필요합니다. 이건 거의 쓸 일이 없습니다. 값을 넘기고 결과를 반환받으면 함수가 따라가기 쉬운 상태로 남습니다.
Juno스코프 함수 안에서 만든 이름은 로컬이고 함수가 반환되면 사라집니다. 전역 변수를 읽는 건 자유롭지만, 값을 쓰려면 global name이 필요합니다. 그렇지 않으면 파이썬은 그 변수를 가리는 로컬 변수를 만듭니다. 인자로 값을 받고 return으로 돌려주는 방식을 선호하세요. 그러면 함수의 효과가 호출부에서 계속 보입니다.
Juno스코프 이름을 찾을 때는 LEGB 규칙이 적용되지만, 할당은 global이나 nonlocal을 선언하지 않으면 항상 로컬 변수를 만듭니다. 그래서 전역 변수에 대한 count += 1UnboundLocalError를 던지는 것입니다. 이 두 키워드는 모두 나쁜 냄새입니다. 바깥의 상태를 바꾸는 함수는 입력을 받아 출력을 돌려주는 함수보다 테스트하기 어렵습니다.
python
def calculate():
    result = 42   # 이 함수 안에서만 유효한 로컬 변수
    return result

calculate()
print(result)   # NameError, 여기서는 result가 존재하지 않습니다
python
count = 0

def increment():
    global count    # 전역 변수를 수정하겠다고 선언합니다
    count += 1

increment()
print(count)   # 1

global은 최후의 수단으로만 써야 합니다. 코드를 이해하기 어렵게 만듭니다. 값을 넘기고 다시 반환받는 방식을 선호하세요. 스코프는 변수와 타입 챕터에서 다룬, 할당이 이름을 값에 묶는 방식을 그대로 기반으로 합니다.

*args와 **kwargs

함수가 몇 개의 인자를 받을지 미리 알 수 없는 경우가 있습니다. *args는 임의 개수의 위치 인자를 튜플로 모아줍니다. **kwargs는 임의 개수의 키워드 인자를 딕셔너리로 모아줍니다. argskwargs라는 이름 자체는 관례일 뿐이고, 중요한 건 ***입니다.

*args는 남는 위치 인자를 튜플로 모아줍니다. **kwargs는 남는 키워드 인자를 딕셔너리로 모아줍니다. 둘 다 일반 매개변수와 함께 쓸 수 있습니다. 순서는 일반 매개변수, *args, 키워드 전용 매개변수, **kwargs 순입니다. 인자를 다른 함수로 그대로 전달하는 래퍼 함수에서 유용합니다.

*args는 남는 위치 인자를 tuple로 모으고, **kwargs는 남는 키워드 인자를 dict로 모읍니다. 호출부에서는 정반대 방향으로도 동작합니다. func(*some_list)는 리스트를 위치 인자로 풀어서 펼치고, func(**some_dict)는 딕셔너리를 키워드 인자로 펼칩니다. 이 대칭성 덕분에 그대로 전달하는 래퍼가 가능해집니다. def timed(*args, **kwargs): return inner(*args, **kwargs)는 매개변수 하나도 이름 붙이지 않고 받은 것을 그대로 넘겨줍니다. 유용하긴 하지만 읽기 좋은 시그니처를 포기하는 대가를 치릅니다. 모든 것을 *args, **kwargs로 삼켜버리는 래퍼는 호출하는 쪽이나 타입 검사기에 무엇을 받아들이는지 아무것도 보여주지 않습니다. 그러니 실제로 임의의 인자를 그대로 전달해야 할 때만 쓰고, 평범한 함수의 기본 형태로는 쓰지 마세요.

python
def total(*args):
    return sum(args)

total(1, 2, 3)          # 6
total(1, 2, 3, 4, 5)   # 15
python
def display(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

display(name="민지", score=87, level=5)

일반 매개변수와 함께 섞어서 쓸 수도 있습니다. 일반 매개변수가 먼저 옵니다:

python
def describe(title, *tags, **metadata):
    print(f"{title} | tags: {tags} | meta: {metadata}")

describe("Python intro", "beginner", "python", author="민지", year=2024)
Juno*args와 **kwargs*args는 얼마든지 많은 추가 위치 인자를 튜플로 모아주고, **kwargs는 이름 붙은 추가 인자를 딕셔너리로 모아줍니다. argskwargs라는 단어 자체는 관례일 뿐이고, 실제로 일하는 건 ***입니다. 몇 개가 들어올지 미리 말할 수 없을 때 유용합니다.
Juno*args와 **kwargs*args는 추가 위치 인자를 튜플로 모으고, **kwargs는 추가 키워드 인자를 딕셔너리로 모읍니다. 시그니처에서의 순서는 일반 매개변수, *args, 키워드 전용, **kwargs 순입니다. 인자를 곧바로 다른 함수로 전달하는 래퍼에서 가장 유용합니다.
Juno*args와 **kwargs***는 시그니처에서는 모으고 호출부에서는 펼칩니다. 이 덕분에 래퍼가 inner(*args, **kwargs)로 모든 것을 그대로 전달할 수 있는 것입니다. 대가는 호출하는 쪽에 아무것도 알려주지 않는 시그니처라는 점이니, 진짜로 그대로 전달해야 하는 경우에만 쓰고 평범한 함수에는 쓰지 마세요.

독스트링

독스트링(docstring)은 함수가 무엇을 하는지 설명하는, 함수 맨 위에 쓰는 문자열입니다. 파이썬 에디터와 도구들은 함수 호출 위에 마우스를 올렸을 때 이 내용을 도움말로 보여줍니다. 삼중 인용부호를 쓰고, 단순한 함수라면 한 줄로 작성하세요.

독스트링은 함수 본문의 첫 부분에 오는, 삼중 인용부호로 감싼 문자열입니다. 도구들이 이를 읽어들이는데, help(func)가 이를 출력해주고 에디터는 호출 위에 마우스를 올렸을 때 보여줍니다. 그래서 함수가 무엇을 하는지 잊어버렸을 때 정확히 도움이 됩니다. 관례상 요약 한 줄을 먼저 쓰고, 필요하면 빈 줄 뒤에 더 자세한 설명을 덧붙입니다. 여러 곳에서 호출되는 함수라면 반드시 써야 하고, 이름만 봐도 모든 게 명확한 일회성 헬퍼라면 없어도 괜찮습니다.

독스트링은 함수, 클래스, 모듈 본문의 첫 문장이며, 함수가 무엇을 하고 무엇을 반환하는지를 말해야 합니다. 독자가 이미 볼 수 있는 매개변수 목록을 그대로 되풀이하는 것이 아니라요. 독스트링이 진짜 값어치를 발휘하는 건 시그니처만으로는 보이지 않는 계약, 즉 예외적인 경우, 무엇을 발생시키는지, 빈 입력이 들어오면 어떻게 되는지를 설명할 때입니다. 시그니처의 타입 힌트가 이미 타입을 알려주고 있다면 형식적인 매개변수별 설명은 생략하세요. 그 정보는 힌트에 속해야 하고, 시간이 지나면서 어긋날 수 있는 산문에 중복으로 담을 필요가 없습니다. 목표는 여섯 달 전에 쓴 호출을 새벽 두 시에 디버깅하면서 읽고 싶은 바로 그런 한 줄입니다.

python
def normalise(value, min_val, max_val):
    """알려진 최소값과 최대값을 기준으로 값을 0-1 범위로 스케일링합니다."""
    return (value - min_val) / (max_val - min_val)
python
def build_url(base, version, resource, *, secure=True):
    """
    API 엔드포인트 URL을 만듭니다.

    완전한 형태의 URL 문자열을 반환합니다. secure가 False이면
    URL은 https 대신 http를 사용합니다.
    """
    scheme = "https" if secure else "http"
    base = base.replace("https://", "").replace("http://", "")
    return f"{scheme}://{base}/{version}/{resource}"

이름과 시그니처만으로 목적이 분명하지 않은 함수에는 독스트링을 작성하세요.

Juno독스트링 독스트링은 함수 맨 위에 삼중 인용부호로 감싸서 쓰는, 그 함수가 무엇을 하는지 설명하는 문자열입니다. 나중에 함수 위에 마우스를 올렸을 때 에디터가 이를 보여주는데, 그때 있어서 다행이라고 느끼게 됩니다. 이름과 입력만으로 목적이 명확하지 않을 때는 항상 작성하세요.
Juno독스트링 본문의 첫 줄에 삼중 인용부호로 씁니다. help()와 에디터가 마우스 오버 시 이를 보여줍니다. 대부분의 함수는 요약 한 줄로 충분하고, 동작이 복잡할 때만 아래에 세부 설명을 덧붙이세요. 여러 곳에서 호출되는 함수라면 필수이고, 이름만으로 모든 게 설명되는 한 줄짜리 함수라면 생략해도 됩니다.
Juno독스트링 함수가 무엇을 하고, 무엇을 반환하고, 예외적인 경우가 무엇인지 말하세요. 독자가 이미 볼 수 있는 매개변수 목록을 다시 늘어놓지 말고요. 타입은 타입 힌트가 담당하게 해서 같은 정보를 두 번 관리하지 마세요. 반년 전에 쓴 호출을 새벽 두 시에 디버깅할 때 보고 싶은 바로 그런 한 줄을 목표로 하세요.

타입 힌트

타입 힌트를 쓰면 함수가 어떤 타입을 받고 반환하는지 표시할 수 있습니다. 파이썬은 실행 시점에 이를 강제하지 않지만, 에디터는 실행하기 전에 이를 이용해 실수를 잡아냅니다. 콜론 앞의 ->는 반환 타입을 지정합니다.

타입 힌트는 도구가 검증할 수 있는 문서입니다. 에디터와 타입 검사기(mypy, pyright)는 이를 이용해 실행 전에 타입 불일치를 잡아냅니다. 표준 파이썬에서는 실행 시점에 아무런 영향도 주지 않습니다. 반환값이 없는 함수에는 -> None이 올바른 표기입니다. 제네릭 컨테이너에는 list[int], dict[str, int]를 사용합니다(Python 3.9 이상).

타입 힌트는 실행 시점에 아무것도 바꾸지 않습니다. 파이썬은 힌트가 있든 없든 똑같이 실행됩니다. 그 가치는 프로그램 바깥의 계층에 있습니다. 타입 검사기(mypy나 pyright)가 실행 전에 코드를 읽고, 에디터가 타이핑하는 동안 잘못된 호출을 표시해주는 것이죠. 그래서 거짓을 말하는 힌트는 힌트가 없는 것보다 더 나쁩니다. 검사기가 그걸 그대로 믿기 때문입니다. 최신 파이썬에서는 typing을 import하지 않고도 list[int], dict[str, int], str | None을 그대로 쓸 수 있습니다. 아무것도 반환하지 않는 함수는 -> None으로 표시하세요. 이는 그 함수를 식(x = log(...))으로 호출하는 것이 실수라는 신호도 됩니다. 이 방식은 여러 곳에서 호출되는 함수에서 특히 값어치를 발휘합니다. 검사기가 잘못된 타입을 넘기는 호출자를 잡아내므로, 실행 시점에 세 프레임 깊이에서 알아보기 힘든 실패로 드러나는 일을 막아줍니다.

python
def greet(name: str, score: int) -> str:
    return f"{name} scored {score}"
python
def log(message: str) -> None:
    print(f"[LOG] {message}")
python
def top_scores(scores: list[int], n: int) -> list[int]:
    return sorted(scores, reverse=True)[:n]

타입 힌트는 필수는 아니지만, 여러 곳에서 호출되는 함수라면 가치가 있습니다. 도구가 검증할 수 있는 문서인 셈입니다.

Juno타입 힌트 타입 힌트는 함수가 무엇을 받고 무엇을 돌려주는지 표시합니다. name: str이나 반환값에 대한 -> str처럼요. 파이썬은 실행할 때 이를 강제하지 않지만, 에디터는 이를 읽고 실행하기 전에 경고해줍니다. 도구가 검사할 수 있는 메모라고 생각하면 됩니다.
Juno타입 힌트 힌트는 타입 검사기가 검증할 수 있는 문서이고, 실행 시점에는 아무 영향도 없습니다. 함수가 아무것도 반환하지 않을 때는 -> None이 올바른 표기이고, list[int]dict[str, int]는 최신 파이썬에서는 typing import 없이도 동작합니다. 여러 곳에서 호출되는 함수라면 그만한 가치가 있습니다.
Juno타입 힌트 힌트는 실행 시점에 아무것도 하지 않으며, 그 가치는 검사기와 에디터가 이를 읽어들이는 데 있습니다. 그래서 거짓을 말하는 힌트는 없는 것보다 더 나쁩니다. str | Nonelist[int]를 그대로 쓰고, 반환값이 없으면 -> None을 쓰세요. 잘 잡아내는 건 세 프레임 깊이에서 실패하기 전에 타입이 잘못된 호출자를 미리 잡아내는 것입니다.

값으로서의 함수

파이썬에서 함수는 문자열이나 숫자처럼 하나의 값입니다. 변수에 할당할 수도 있고 다른 함수에 넘길 수도 있습니다. sorted()key= 함수를 받을 수 있는 것도 이런 이유입니다.

함수는 일급 객체입니다. 타입(function)을 가지고 있고, 변수나 컬렉션에 저장할 수 있으며, 인자로 넘기거나 값으로 반환할 수도 있습니다. 이것이 sorted(key=...), map(), filter() 같은 고차 함수(higher-order function)의 기반이 됩니다.

함수는 참조로 전달되는 객체이며 결코 복사되지 않으므로, 다른 함수에 넘기는 비용은 매우 저렴합니다. 이 특성에서 자라난 패턴이 **클로저(closure)**입니다. 다른 함수 안에서 정의되었지만 바깥 함수가 반환된 이후에도 그 함수의 변수에 계속 접근할 수 있는 함수를 말합니다. 이렇게 하면 즉석에서 설정된 함수를 만들 수 있습니다. 예를 들어 make_multiplier(3)은 3을 붙잡아둔 채 3을 곱하는 함수를 반환합니다. 실제 코드에서 주의할 점은 두 가지입니다. 클로저는 캡처 시점의 값이 아니라 변수 자체를 붙잡는데, 이 때문에 반복문 안에서 클로저를 만드는 사람들이 걸려듭니다(모두 반복문의 마지막 값을 보게 됩니다). 그리고 반환된 함수를 여러 겹 쌓으면 호출 경로를 추적하기 어려워지므로, 명확하게 읽히는 곳에서만 클로저를 쓰고 상태가 늘어나면 클래스를 쓰세요.

python
def double(x):
    return x * 2

def apply(func, value):
    return func(value)

apply(double, 5)   # 10

함수를 인자로 넘기는 방식은 sorted(), map(), filter()에서 끊임없이 등장합니다. 람다와 컴프리헨션 챕터에서도 이를 다시 만나게 됩니다.

Juno값으로서의 함수 함수는 문자열이나 숫자처럼 하나의 값이라서, 변수에 저장하고 다른 함수에 넘길 수 있습니다. 바로 이 덕분에 sorted()key=로 함수를 넘길 수 있는 것이죠. 처음에는 낯설었는데, 한번 이해하고 나니 여기저기서 계속 보이더군요.
Juno값으로서의 함수 함수는 일급 객체입니다. 할당하고, 리스트에 저장하고, 넘기고, 반환할 수 있습니다. 이것이 sorted(key=...), map(), filter()의 원동력이고, 나중에 연결하게 될 모든 콜백의 원동력이기도 합니다.
Juno값으로서의 함수 함수를 넘기는 것은 참조이지 복사가 아니며, 클로저는 안쪽 함수가 바깥 함수가 반환된 후에도 그 변수에 계속 접근할 수 있게 해줍니다. 값이 아니라 변수 자체를 붙잡기 때문에, 반복문 안에서 클로저를 만들면 매번 문제가 생깁니다. 붙잡는 상태가 늘어나면 클래스로 표현하는 것이 더 읽기 좋습니다.

실전 예제

함께 동작하는 두 함수입니다. letter_grade는 점수를 등급 문자로 바꾸고, summarise는 리스트에 있는 모든 점수에 대해 이를 호출합니다:

python
def letter_grade(score: int) -> str:
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "F"

def summarise(scores: list[int]) -> None:
    total = sum(scores)
    avg = total / len(scores)
    grades = []
    for s in scores:
        grades.append(letter_grade(s))
    print(f"Average: {avg:.1f}")
    print(f"Grades: {', '.join(grades)}")

summarise([87, 92, 74, 65, 91])

로그 포매터와 이를 사용하는 파일 처리기입니다. dry_run 기본 매개변수는 명시적으로 해제하지 않는 한 부수 효과를 막아줍니다:

python
def format_log(level: str, message: str) -> str:
    return f"[{level.upper():5}] {message}"

def process_file(path: str, dry_run: bool = True) -> bool:
    print(format_log("info", f"Processing {path}"))
    if dry_run:
        print(format_log("info", "Dry run, no changes made"))
        return True
    return True

process_file("report.csv")
process_file("report.csv", dry_run=False)

값 하나를 정규화하는 함수와, 이를 바탕으로 만든 컬럼 정규화 함수입니다. 타입 힌트와 독스트링이 포함되어 있습니다. 컬럼 함수는 범위를 한 번만 계산하고 각 항목에 대해 스칼라 함수를 재사용합니다:

python
def normalise(value: float, min_val: float, max_val: float) -> float:
    """알려진 최소값과 최대값을 기준으로 값을 0-1 범위로 스케일링합니다."""
    if max_val == min_val:
        return 0.0
    return (value - min_val) / (max_val - min_val)

def normalise_column(values: list[float]) -> list[float]:
    """컬럼 전체 값을 정규화합니다."""
    lo, hi = min(values), max(values)
    return [normalise(v, lo, hi) for v in values]

raw = [10.0, 25.0, 5.0, 40.0, 15.0]
print(normalise_column(raw))

여기서 타입 힌트는 두 가지 역할을 합니다. 함수가 무엇을 기대하는지 문서화하고, 문자열 리스트를 잘못 넘기는 호출자를 타입 검사기가 잡아낼 수 있게 해줍니다.