Devin.KR
로그인

파이썬 함수 - 가변 기본값 함정과 args kwargs 스코프 (파이썬 초급 5단원)

개발자 조회 1

이 단원에서 배우는 것

4단원에서 재고를 돌며 발주 대상을 고르는 코드를 썼다. 그 판단을 판매 화면에서도, 야간 배치에서도, 사장님 보고서에서도 써야 한다면 어떻게 할까. 복사해 붙이면 세 벌이 되고, "재고 2권 이하"를 "3권 이하"로 바꾸는 날 세 곳을 전부 고쳐야 한다. 그리고 반드시 한 곳을 빼먹는다. 이번 단원은 그 코드에 이름을 붙여 한 곳에만 두는 방법을 다룬다. 아울러 파이썬 초급의 최대 함정인 가변 기본값 인자를 정면으로 본다.

  • def로 함수를 만들고 값을 return으로 돌려받는다.
  • 기본값·키워드 인자·*args·**kwargs로 호출 방식을 설계한다.
  • 지역 변수와 전역 변수의 경계를 알고, 가변 기본값 함정을 피한다.

왜 필요한가

함수의 목적은 "코드 재사용"이라고들 하는데, 그건 절반이다. 실무에서 함수가 하는 더 중요한 일은 이름을 붙여 읽는 사람을 해방시키는 것이다. 다음 두 코드를 비교해 보자.

if stock == 0 or (stock <= 2 and not discontinued):
    ...
if needs_reorder(stock, discontinued):
    ...

아래 코드를 읽는 사람은 발주 기준이 정확히 뭔지 몰라도 흐름을 따라갈 수 있다. 기준이 궁금할 때만 needs_reorder를 열어 보면 된다. 조건이 바뀌면 고칠 곳은 그 함수 하나뿐이다. 그리고 함수로 떼어 놓아야 테스트를 쓸 수 있다. 1단원에서 말했듯 파이썬에는 빌드 검사가 없어 사람이 검증해야 하는데, 검증의 최소 단위가 함수다. 화면 출력 코드 안에 판단이 엉겨 있으면 그 판단만 따로 시험할 방법이 없다.

문법과 예제

기본 형태

def needs_reorder(stock, threshold=2):
    """재고가 기준 이하이면 True 를 돌려준다."""
    return stock <= threshold

print(needs_reorder(0))      # True
print(needs_reorder(3))      # False
print(needs_reorder(3, 5))   # True   ← 기준을 5로 바꿔 부른다

def 이름(인자): 다음 줄부터 들여쓴 블록이 함수의 몸통이다. 첫 줄의 문자열은 독스트링이라 하며, 이 함수가 무엇을 하는지 적는 자리다. 주석과 달리 실행 중에도 남아 있어서 help(needs_reorder)로 읽을 수 있다.

return은 값을 돌려주면서 함수를 즉시 끝낸다. return이 없으면 파이썬이 None을 돌려준다. 오류가 아니다. 그래서 return을 빠뜨린 함수의 결과를 쓰면 엉뚱한 곳에서 NoneType 오류가 난다.

def sell(title):
    print(title, "판매")     # return 이 없다

result = sell("파이썬 개론")
print(result)                # None

인자 넘기는 세 가지 방법

def discount(price, rate=0.1):
    return round(price * (1 - rate))

discount(28000)              # 위치로: price=28000, rate 는 기본값 0.1
discount(28000, 0.3)         # 위치로 둘 다
discount(28000, rate=0.3)    # 이름으로: 읽는 사람이 0.3 이 뭔지 안다
discount(rate=0.3, price=28000)   # 이름을 쓰면 순서는 상관없다

기본값이 있는 인자는 없는 인자보다 뒤에 와야 한다. 순서가 반대면 함수를 정의하는 시점에 SyntaxError가 난다.

실무에서 키워드 인자가 특히 중요한 경우가 있다. 호출부에 벌거벗은 True가 나올 때다.

save_order(order, True, False)         # 이 True 와 False 가 뭔가
save_order(order, urgent=True, gift_wrap=False)   # 읽힌다

아예 이름으로만 부르도록 강제할 수도 있다. 인자 목록 가운데에 홀로 선 *를 두면, 그 뒤의 인자는 반드시 이름을 붙여야 한다.

def discount(price, rate=0.1, *, round_to=100):
    return round(price * (1 - rate) / round_to) * round_to

print(discount(28000))                          # 25200
print(discount(28000, rate=0.3, round_to=1000)) # 20000
print(discount(28000, 0.3, 1000))
# TypeError: discount() takes from 1 to 2 positional arguments but 3 were given

옵션이 많은 함수에서 호출부가 f(a, 3, None, True, 100)처럼 되는 것을 막아 준다.

개수가 정해지지 않은 인자

*args는 남는 위치 인자를 튜플로, **kwargs는 남는 키워드 인자를 딕셔너리로 모은다. 이름은 관례이고 별표가 문법이다.

def order_report(*titles, **options):
    print(titles)    # ('파이썬 개론', '리팩터링')
    print(options)   # {'urgent': True, 'memo': '주말 전'}

order_report("파이썬 개론", "리팩터링", urgent=True, memo="주말 전")

반대로 이미 가진 리스트나 딕셔너리를 풀어서 넘길 때도 별표를 쓴다.

titles = ["파이썬 개론", "리팩터링"]
order_report(*titles)                 # 원소 두 개를 각각의 인자로 넘긴다

opts = {"urgent": True, "memo": "주말 전"}
order_report("클린 코드", **opts)     # 키를 인자 이름으로 풀어 넘긴다

초급 단계에서 *args를 직접 만들 일은 많지 않다. 다만 남이 만든 함수 설명에 계속 나오므로 읽을 줄은 알아야 한다.

값 여러 개 돌려주기

파이썬은 튜플 덕분에 반환값이 여러 개인 것처럼 쓸 수 있다. 3단원의 언패킹이 여기서 쓰인다.

def summarize(inventory):
    kinds = len(inventory)
    total = sum(inventory.values())
    return kinds, total          # 사실은 튜플 하나를 돌려준다

kinds, total = summarize({"파이썬 개론": 3, "SQL 첫걸음": 12})
print(kinds, total)              # 2 15

스코프 — 함수 안의 이름은 함수 것이다

함수 안에서 만든 변수는 함수가 끝나면 사라진다. 밖에서는 보이지 않는다. 반대로 함수 안에서 바깥 변수를 읽는 것은 된다.

TAX_RATE = 0.1          # 전역. 대문자는 '바꾸지 않는 값'이라는 관례다

def with_tax(price):
    return round(price * (1 + TAX_RATE))   # 읽기는 문제없다

print(with_tax(28000))  # 30800

문제는 바깥 변수에 대입하려 할 때다.

total_sales = 0

def sell(price):
    total_sales = total_sales + price     # 이 줄에서 죽는다

sell(28000)
# UnboundLocalError: cannot access local variable 'total_sales'
# where it is not associated with a value

파이썬은 함수 본문을 훑어 대입되는 이름은 전부 지역 변수로 미리 확정한다. total_sales = ...라고 쓴 순간 이 함수 안의 total_sales는 바깥 것과 무관한 새 지역 변수가 되고, 오른쪽에서 그 지역 변수를 읽으려니 아직 값이 없어 오류가 난다. global total_sales를 선언하면 동작은 하지만, 어디서 값이 바뀌는지 추적할 수 없어지므로 권하지 않는다. 답은 값을 돌려받는 것이다.

def sell(total_sales, price):
    return total_sales + price

total_sales = 0
total_sales = sell(total_sales, 28000)
print(total_sales)      # 28000

참고로 위 오류 메시지는 파이썬 3.11에서 다듬어진 문구다. 3.10 이하에서는 local variable 'total_sales' referenced before assignment로 나온다. 같은 오류다.

실무에서 자주 틀리는 것

1. 기본값에 리스트나 딕셔너리를 쓴다

파이썬을 몇 년 쓴 사람도 한 번은 당하는 함정이다. 장바구니에 책을 담는 함수를 이렇게 만들었다고 하자.

def add_to_cart(title, cart=[]):
    cart.append(title)
    return cart

print(add_to_cart("파이썬 개론"))    # ['파이썬 개론']
print(add_to_cart("리팩터링"))       # ['파이썬 개론', '리팩터링']   ← ?
print(add_to_cart("SQL 첫걸음"))     # ['파이썬 개론', '리팩터링', 'SQL 첫걸음']

손님이 세 명인데 장바구니가 하나다. 웹 서비스라면 A 손님의 장바구니에 B 손님의 책이 들어가는 사고다.

원인은 이렇다. 기본값은 함수를 부를 때가 아니라 def 줄을 읽을 때 딱 한 번 만들어진다. 그리고 그 리스트 하나를 함수가 평생 재사용한다. 3단원에서 본 "대입은 복사가 아니다"의 연장이다. 직접 확인할 수도 있다.

print(add_to_cart.__defaults__)
# (['파이썬 개론', '리팩터링', 'SQL 첫걸음'],)   ← 기본값 자체가 자라 있다

해결책은 정해져 있다. 기본값 자리에는 None을 두고 함수 안에서 만든다.

def add_to_cart(title, cart=None):
    if cart is None:
        cart = []
    cart.append(title)
    return cart

규칙으로 외우면 된다. 기본값에는 숫자·문자열·True/False·None·튜플만 쓴다. 리스트·딕셔너리·셋이 기본값에 보이면 버그다. 여기서 if not cart:가 아니라 if cart is None:인 이유는 2단원에서 봤다. 빈 리스트를 일부러 넘긴 경우와 아무것도 안 넘긴 경우를 구분해야 한다.

2. 넘겨받은 리스트를 함수 안에서 바꾼다

def sort_titles(titles):
    titles.sort()          # 넘겨준 사람의 리스트를 직접 뒤집어 놓는다
    return titles

my_list = ["파이썬 개론", "리팩터링"]
sorted_list = sort_titles(my_list)
print(my_list)   # ['리팩터링', '파이썬 개론']  ← 넘겨준 원본이 정렬된 상태로 바뀌었다

함수에 리스트를 넘기면 3단원의 b = a와 똑같은 일이 벌어진다. 사본이 아니라 같은 리스트다. 부르는 쪽은 "정렬된 사본을 받았다"고 생각하는데 원본까지 바뀌어 있다. 인자를 바꿀 거면 함수 이름으로 알리고(sort_titles_in_place), 아니면 사본을 만든다.

def sort_titles(titles):
    return sorted(titles)   # 원본은 그대로, 새 리스트를 돌려준다

3. 함수가 하는 일이 두 개 이상이다

def check_and_print_and_order(inventory):   # 이름이 이미 신호다
    ...

판단하고, 화면에 찍고, 발주까지 하는 함수는 테스트할 수 없다. 결과를 확인하려면 화면 출력을 가로채야 하기 때문이다. 값을 계산해 돌려주는 함수화면에 찍는 코드를 나눈다. 함수 이름에 and가 들어가면 둘로 쪼갤 때다.

4. 인자 이름을 바꾸고 호출부를 안 고친다

discount(price, rate)ratediscount_rate로 바꾸면, 이름으로 부르던 discount(28000, rate=0.3)은 전부 TypeError: discount() got an unexpected keyword argument 'rate'가 된다. 컴파일 검사가 없으니 그 줄이 실행될 때까지 아무도 모른다. 공개된 함수의 인자 이름은 함수 이름만큼이나 바꾸기 어려운 약속이라고 생각하고 처음에 잘 짓는다.

스스로 확인하기

  1. 다음 함수를 두 번 호출하면 각각 무엇이 출력되는가? 그리고 어떻게 고쳐야 하는가?
    def log_sale(title, history={}):
        history[title] = history.get(title, 0) + 1
        return history
    
    print(log_sale("파이썬 개론"))
    print(log_sale("파이썬 개론"))
  2. 재고 딕셔너리를 받아 발주 대상 제목 리스트를 돌려주는 함수 pick_reorder(inventory, threshold=2)를 만든다. 4단원 코드를 함수로 옮기면 된다.
  3. 다음 코드가 None을 출력하는 이유는?
    def total_price(prices):
        total = sum(prices)
    
    print(total_price([28000, 22000]))

정답

  1. 첫 번째는 {'파이썬 개론': 1}, 두 번째는 {'파이썬 개론': 2}다. 기본값 딕셔너리가 def 줄에서 한 번만 만들어져 호출 사이에 계속 남기 때문이다. 리스트든 딕셔너리든 똑같다. history=None으로 두고 함수 안에서 if history is None: history = {}로 만든다.
  2. def pick_reorder(inventory, threshold=2):
        """재고가 기준 이하인 책 제목을 리스트로 돌려준다."""
        reorder = []
        for title, stock in inventory.items():
            if stock <= threshold:
                reorder.append(title)
        return reorder
    
    inv = {"파이썬 개론": 3, "리팩터링": 0, "클린 코드": 1}
    print(pick_reorder(inv))      # ['리팩터링', '클린 코드']
    print(pick_reorder(inv, 5))   # ['파이썬 개론', '리팩터링', '클린 코드']
    기본값 2는 정수이므로 가변 기본값 문제가 없다. 화면 출력은 함수 밖으로 뺐다는 점도 확인한다.
  3. 합계를 total에 담기만 하고 return하지 않았다. return이 없는 함수는 None을 돌려주고, 이때 오류는 나지 않는다. total은 지역 변수라 함수 밖에서 볼 수도 없다. return sum(prices) 한 줄이면 된다.

다음 단원에서는 지금까지 print(title, stock)로 대충 찍던 출력을 제대로 만든다. 금액에 천 단위 콤마를 넣고 열을 맞춘 영수증을 f-string 으로 찍고, 한글이 깨지는 원인인 인코딩을 다룬다.