Python 함수·이름 범위·클래스·예외·안전한 입력
Python 함수의 인자와 기본값, 지역·전역 이름, 클래스와 인스턴스, 예외 처리와 입력 변환을 이해한다. 이름을 새 객체에 연결하는 것과 공유 객체를 변경하는 것을 구분한다.
Python 함수의 인자와 기본값, 지역·전역 이름, 클래스와 인스턴스, 예외 처리와 입력 변환을 이해한다. 이름을 새 객체에 연결하는 것과 공유 객체를 변경하는 것을 구분한다.
그림으로 확인하기

함수 객체와 호출 계약
함수 정의문은 함수 객체를 만들고 이름에 연결한다
def 문이 실행되면 함수 객체가 만들어지고 함수 이름이 그 객체에 연결된다. 함수도 다른 객체처럼 변수에 저장하고, 컨테이너에 넣고, 다른 함수의 인자로 전달하고, 반환할 수 있다.
def double(value):
return value * 2
operation = double
print(operation(5)) # 10
print(double is operation) # True
operation은 함수의 복사본이 아니라 같은 함수 객체를 가리키는 다른 이름이다. 괄호가 없는 double은 함수 객체 자체이고, double(5)는 그 객체를 호출한 결과다.
함수 본문 끝까지 return을 만나지 않거나 식이 없는 return을 실행하면 None을 반환한다.
def record(message):
print(message)
result = record("saved")
print(result) # None
쉼표로 여러 값을 반환하는 것처럼 보이는 코드는 실제로 튜플 하나를 반환한다.
def bounds(values):
return min(values), max(values)
result = bounds([3, 1, 8])
print(result) # (1, 8)
print(type(result).__name__) # tuple
인자와 매개변수
다음 정의에서 price, quantity, discount는 매개변수다. 호출식의 1000, 3, discount=100은 인자다.
def total(price, quantity, discount=0):
return price * quantity - discount
print(total(1000, 3, discount=100)) # 2900
호출이 시작되기 전에 인자식이 평가되고 그 결과가 매개변수에 연결된다. 필요한 인자가 빠졌거나, 허용되지 않은 키워드를 썼거나, 같은 매개변수에 값을 두 번 전달하면 함수 본문에 들어가기 전에 TypeError가 발생한다.
def add(a, b):
return a + b
# add(1) # TypeError: b에 대응하는 인자 누락
# add(1, 2, 3) # TypeError: 위치 인자가 너무 많음
# add(1, a=2) # TypeError: a에 값을 중복 전달
# add(left=1, b=2) # TypeError: 예상하지 않은 키워드 left
가변 개수 인자: *args와 **kwargs
*args는 다른 매개변수에 연결되지 않은 위치 인자를 튜플로 모은다. **kwargs는 남은 키워드 인자를 딕셔너리로 모은다. 이름 args와 kwargs는 관례이며 별표 개수가 문법의 의미를 결정한다.
def inspect_call(first, *args, **kwargs):
print(first)
print(args)
print(kwargs)
inspect_call(10, 20, 30, unit="ms", enabled=True)
10
(20, 30)
{'unit': 'ms', 'enabled': True}
*args는 리스트가 아니라 튜플이다. **kwargs는 키워드 이름을 키로 가지는 딕셔너리이며, 호출에서 제공된 키워드 순서를 보존한다.
매개변수 목록에서 *args 뒤에 오는 일반 매개변수는 키워드 전용이다.
def average(*values, round_to=2):
result = sum(values) / len(values)
return round(result, round_to)
print(average(10, 20, 25, round_to=1)) # 18.3
기본 인자 값의 평가 시점
기본 인자 값은 함수가 호출될 때마다 계산되지 않는다. 함수 정의문이 실행되는 순간 한 번 평가되어 함수 객체에 보관된다.
rate = 10
def charge(amount, fee=rate):
return amount + fee
rate = 50
print(charge(100)) # 110
print(charge(100, 50)) # 150
fee의 기본값은 정의 시점의 rate 값 10이다. 이후 전역 이름 rate를 50으로 다시 연결해도 저장된 기본값은 바뀌지 않는다.
가변 객체를 기본값으로 두면 여러 호출이 같은 객체를 공유한다.
def append_item(value, bucket=[]):
bucket.append(value)
return bucket
print(append_item("A")) # ['A']
print(append_item("B")) # ['A', 'B']
호출마다 독립된 리스트가 필요하면 변경 불가능한 표식값을 기본값으로 두고 함수 안에서 새 객체를 만든다.
def append_item(value, bucket=None):
if bucket is None:
bucket = []
bucket.append(value)
return bucket
print(append_item("A")) # ['A']
print(append_item("B")) # ['B']
전달된 빈 리스트와 인자가 생략된 경우를 구분해야 하므로 if not bucket: 대신 if bucket is None:을 사용한다. if not bucket:은 호출자가 의도적으로 전달한 빈 리스트도 새 리스트로 바꿀 수 있다.
람다와 함수의 반환
lambda는 식 하나의 결과를 반환하는 짧은 함수를 만드는 표현식이다. 예를 들어 double = lambda x: x * 2이면 double(3)은 6이다. 여러 문장·복잡한 분기가 필요하면 일반 def 함수를 사용한다. 함수가 return 없이 끝나면 None을 반환한다.
이름 공간과 범위
이름 공간과 범위는 같은 말이 아니다
이름 공간(namespace)은 이름과 객체의 연결을 보관하는 구조다. 모듈, 함수 호출, 클래스는 각각 이름 공간을 만들 수 있다. 범위(scope)는 소스 코드에서 특정 이름 공간의 이름을 한정하지 않고 직접 찾을 수 있는 영역이다.
다음과 같이 같은 이름이 여러 범위에 존재할 수 있다.
value = "global"
def outer():
value = "enclosing"
def inner():
value = "local"
return value
return inner()
print(outer()) # local
print(value) # global
일반적인 이름 검색 순서: LEGB
함수 안에서 한정되지 않은 이름을 읽을 때는 다음 순서를 기준으로 찾는다.
- Local: 현재 함수의 지역 범위
- Enclosing: 가장 가까운 바깥 함수부터 차례로 탐색하는 비지역 범위
- Global: 함수가 정의된 모듈의 전역 범위
- Built-in:
len,print,ValueError등이 있는 내장 범위
label = "module"
def outer():
label = "outer"
def inner():
return label
return inner()
print(outer()) # outer
함수의 전역 범위는 호출한 위치가 아니라 그 함수가 정의된 모듈이다. 다른 모듈에서 별명으로 호출해도 함수가 참조하는 전역 이름 공간은 정의된 모듈을 기준으로 한다.
내장 이름도 일반 이름이므로 같은 범위에서 가릴 수 있다.
len = 3
print(len) # 3
# print(len([1, 2])) # TypeError: 내장 함수 len을 정수 이름이 가림
내장 이름을 변수명으로 다시 쓰는 문법은 허용되지만, 이후 원래 함수를 사용할 수 없어 혼동을 일으킨다.
대입이 있으면 지역 이름으로 결정될 수 있다
함수 본문 어디에서든 이름을 결합하는 문장이 있으면, global이나 nonlocal 선언이 없는 한 그 이름은 보통 함수 전체에서 지역 이름으로 결정된다. 대입문보다 앞에서 읽더라도 전역 이름으로 자동 전환되지 않는다.
count = 10
def wrong():
print(count)
count = 20
# wrong() # UnboundLocalError
wrong()의 count는 대입문 때문에 지역 이름으로 결정된다. 첫 번째 print(count) 시점에는 아직 지역값이 연결되지 않았으므로 UnboundLocalError가 발생한다. 이 예외는 NameError의 하위 클래스다.
현재 값을 읽은 뒤 새 값을 다시 연결하는 증강 대입도 대입이다.
score = 1
def increase():
score += 1
return score
# increase() # UnboundLocalError
global과 nonlocal
global은 현재 블록에서 지정한 이름을 모듈 전역 이름으로 취급하게 한다.
status = "ready"
def stop():
global status
status = "stopped"
stop()
print(status) # stopped
nonlocal은 가장 가까운 바깥 함수 범위에서 이미 연결된 이름을 다시 연결하게 한다.
def make_counter():
count = 0
def next_value():
nonlocal count
count += 1
return count
return next_value
counter = make_counter()
print(counter(), counter(), counter()) # 1 2 3
| 구문 | 다시 연결하는 대상 | 대상이 미리 존재해야 하는가? |
|---|---|---|
| 일반 대입 | 현재 지역 범위 | 아니요 |
global name | 현재 모듈의 전역 범위 | 없어도 새 전역 이름을 만들 수 있음 |
nonlocal name | 가장 가까운 바깥 함수 범위 | 예. 없으면 SyntaxError |
nonlocal은 모듈 전역을 대상으로 하지 않는다. 또한 클래스 범위의 이름을 메서드에서 nonlocal로 지정하는 용도도 아니다.
이름 재연결과 가변 객체 변경
global과 nonlocal이 필요한 핵심 상황은 바깥 이름을 다른 객체에 다시 연결할 때다. 바깥 이름이 가리키는 가변 객체의 내부만 변경할 때는 이름 자체를 다시 연결하지 않으므로 선언이 필요하지 않을 수 있다.
def make_collector():
items = []
count = 0
def add(value):
nonlocal count
items.append(value) # items가 가리키는 리스트 내부 변경
count += 1 # count 이름을 새 정수에 다시 연결
return count, list(items)
return add
collect = make_collector()
print(collect("A")) # (1, ['A'])
print(collect("B")) # (2, ['A', 'B'])
정수는 변경 불가능하므로 count += 1은 새 정수를 만든 뒤 count를 다시 연결한다. 리스트의 append는 items 이름을 그대로 둔 채 같은 리스트의 내부를 변경한다.
클래스·인스턴스·메서드 결합
클래스 정의와 인스턴스 생성
클래스 정의문이 실행되면 새 이름 공간에서 클래스 본문이 실행되고, 그 결과를 사용해 클래스 객체가 만들어진다.
print("before")
class Product:
category = "book"
print("class body")
print("after")
before
class body
after
클래스 본문은 인스턴스를 만들 때마다 반복되는 것이 아니라 클래스 정의문을 실행할 때 실행된다.
클래스 객체를 호출하면 일반적으로 __new__가 인스턴스를 만들고 __init__가 이미 만들어진 인스턴스를 초기화한다. __init__는 생성자를 대신해 새 객체를 반환하는 메서드가 아니며 None 이외의 값을 명시적으로 반환하면 TypeError가 발생한다.
class User:
def __init__(self, name):
self.name = name
user = User("Kim")
print(user.name) # Kim
기본적인 객체 생성 흐름은 다음과 같이 읽는다.
- 클래스 객체를 호출한다.
__new__가 새 인스턴스를 확보해 반환한다.- 반환된 객체가 해당 클래스의 인스턴스이면
__init__가 초기 상태를 설정한다. - 초기화된 인스턴스가 호출 결과가 된다.
시험 수준에서는 보통 __init__에서 self.속성 = 값이 실행되는 순서와 결과를 추적하면 된다.
인스턴스 속성과 클래스 속성
인스턴스 속성은 특정 객체의 상태이고, 클래스 속성은 클래스 수준의 기본값이나 공유 상태다.
class Employee:
company = "Baeum"
def __init__(self, name):
self.name = name
first = Employee("A")
second = Employee("B")
print(first.company, second.company) # Baeum Baeum
print(first.name, second.name) # A B
기본적인 사용자 정의 속성 조회에서는 먼저 인스턴스의 속성을 확인하고, 없으면 클래스와 상위 클래스의 속성을 찾는다.
class Device:
mode = "auto"
first = Device()
second = Device()
first.mode = "manual"
print(first.mode) # manual: 인스턴스 속성
print(second.mode) # auto: 클래스 속성 조회
print(Device.mode) # auto
del first.mode
print(first.mode) # auto: 인스턴스 속성 삭제 뒤 클래스 값이 다시 보임
first.mode = "manual"은 보통 Device.mode를 바꾸지 않고 first에 같은 이름의 인스턴스 속성을 만든다. 클래스 값을 바꾸려면 Device.mode = ...처럼 클래스 객체에 대입해야 한다.
속성 조회는 프로퍼티와 디스크립터 같은 기능으로 조정될 수 있다. 그러나 기본 출력 추적 문제에서는 인스턴스 속성이 클래스 속성을 가리는지부터 확인한다.
가변 클래스 속성의 공유
가변 객체를 클래스 속성으로 두면 모든 인스턴스가 같은 객체를 기본적으로 공유한다.
class WrongCart:
items = []
def add(self, item):
self.items.append(item)
first = WrongCart()
second = WrongCart()
first.add("A")
second.add("B")
print(first.items) # ['A', 'B']
print(second.items) # ['A', 'B']
self.items.append는 인스턴스 속성을 새로 만들지 않고 조회된 클래스 리스트를 변경한다. 객체별 목록이 필요하면 __init__에서 새 리스트를 만든다.
class Cart:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
first = Cart()
second = Cart()
first.add("A")
second.add("B")
print(first.items) # ['A']
print(second.items) # ['B']
클래스 속성은 항상 잘못된 것이 아니다. 상수 성격의 기본값, 클래스 전체의 개수, 모든 인스턴스가 의도적으로 공유할 정책 등에 사용할 수 있다. 핵심은 공유가 의도된 상태인지 판단하는 것이다.
상속과 메서드 재정의의 기본
하위 클래스는 상위 클래스의 기능을 이어받고 같은 이름의 메서드를 재정의할 수 있다. 다음은 단일 상속의 예이다.
class Parent:
def name(self):
return "parent"
class Child(Parent):
def name(self):
return "child"
p = Child()
print(p.name())
print(isinstance(p, Parent))
출력:
child
True
메서드를 인스턴스로 호출하면 해당 객체가 첫 매개변수로 전달된다. 관례적으로 그 매개변수를 self라고 쓴다. 클래스에서 공유하는 속성과 각 인스턴스가 따로 가진 속성을 구분해야 한다.
예외 발생·처리·전파·정리
예외는 형 계층으로 구분한다
실행 중 해결할 수 없는 상황이 생기면 예외 객체가 발생한다. 처리기를 찾지 못하면 호출 스택을 따라 바깥으로 전파되고, 최종적으로 처리되지 않으면 traceback과 함께 프로그램의 현재 실행이 중단된다.
대표적인 내장 예외는 다음과 같이 구분한다.
| 예외 | 대표 상황 |
|---|---|
ValueError | 자료형은 맞지만 값의 형식·범위가 부적절함 |
TypeError | 연산이나 호출에 허용되지 않는 자료형·인자 형태 사용 |
IndexError | 시퀀스의 유효 범위를 벗어난 인덱스 접근 |
KeyError | 딕셔너리에 없는 키를 d[key]로 조회 |
NameError | 찾을 수 없는 이름 참조 |
UnboundLocalError | 지역 이름으로 결정됐지만 값이 연결되기 전에 참조 |
ZeroDivisionError | 0으로 나눔 |
OSError | 파일·장치·운영체제 자원 처리 실패 |
예외 계층의 최상위에는 BaseException이 있고, 일반 애플리케이션 오류는 대부분 Exception 아래에 있다. KeyboardInterrupt, SystemExit, GeneratorExit는 BaseException의 하위지만 Exception의 하위는 아니다.
따라서 일반적인 오류 처리에서는 필요한 구체 예외를 먼저 잡고, 예상 가능한 애플리케이션 오류의 마지막 경계에서만 Exception을 고려한다. 아무 형도 적지 않는 except:는 BaseException 계열까지 잡으므로 종료·중단 신호를 의도치 않게 가릴 수 있다.
try와 except의 실행 순서
try:
value = int("12")
except ValueError:
print("invalid")
else:
print(value)
finally:
print("done")
12
done
실행 순서는 다음과 같다.
try절을 실행한다.- 예외가 없으면 모든
except절을 건너뛴다. - 예외가 있으면 남은
try문장을 건너뛰고 위에서부터 처음 일치하는except하나를 실행한다. try가 예외 없이 끝났다면else절을 실행한다.finally가 있으면 마지막에 실행한다.- 처리되지 않은 예외가 있다면
finally가 끝난 뒤 바깥으로 다시 전파된다.
여러 except 중에서는 첫 번째로 일치한 처리기 하나만 실행된다. 상위 예외를 먼저 두면 그 아래의 구체 예외 처리기가 도달 불가능해질 수 있다.
class InputError(Exception):
pass
class AgeError(InputError):
pass
try:
raise AgeError("out of range")
except AgeError:
print("age")
except InputError:
print("input")
출력은 age다. 순서를 뒤집어 except InputError를 먼저 두면 AgeError도 상위 형과 일치하므로 첫 처리기에서 잡힌다.
여러 예외에 같은 처리가 필요하면 튜플로 묶을 수 있다.
try:
value = int(None)
except (TypeError, ValueError):
print("cannot convert")
else는 보호 범위를 좁힌다
else는 try 절이 예외 없이 끝났을 때 실행한다. 성공 뒤 실행할 코드를 try에 전부 넣지 않고 else로 옮기면, 의도하지 않은 다른 예외까지 같은 except가 잡는 범위를 줄일 수 있다.
def reciprocal(text):
try:
value = float(text)
except ValueError:
return "not a number"
else:
return 1 / value
print(reciprocal("4")) # 0.25
이 코드에서 float(text)의 ValueError만 해당 처리기가 다룬다. else에서 발생한 ZeroDivisionError는 같은 try 문의 앞선 except ValueError가 잡지 않는다.
# reciprocal("0") # ZeroDivisionError
else는 except가 실행된 뒤 무조건 실행되는 구문이 아니다. try가 정상 완료된 경우에만 실행된다.
finally는 결과와 무관한 마무리를 수행한다
finally는 try의 성공 여부, 예외 처리 여부, return·break·continue 같은 제어 이동과 관계없이 해당 try 문을 빠져나가기 직전에 실행된다. 주된 목적은 파일, 잠금, 연결 같은 자원을 정리하는 것이다.
def process():
try:
print("work")
return "result"
finally:
print("cleanup")
print(process())
work
cleanup
result
finally 안에서 새 예외를 발생시키거나 return으로 흐름을 바꾸면 원래의 예외나 반환값을 가릴 수 있다. 따라서 정리 코드에서 원래 결과를 덮는 제어 이동은 피한다.
# 다음 패턴은 원래 예외나 반환값을 숨길 수 있으므로 피한다.
# def bad():
# try:
# return 1
# finally:
# return 2
finally가 있다고 해서 운영체제 강제 종료, 프로세스 즉시 종료, 전원 차단까지 반드시 처리할 수 있는 것은 아니다. 언어가 정상적으로 제어를 이전하는 경로에서 실행된다고 이해한다.
예외 발생과 파일 사용
raise ValueError("message")처럼 raise로 예외를 직접 발생시킬 수 있다. except 안의 인자 없는 raise는 현재 처리 중인 예외를 다시 전달한다. 처리할 책임이 없는 예외를 무조건 잡아 정상값으로 바꾸지 않는다.
파일은 open()으로 열며 읽기 r, 쓰기 w, 덧붙이기 a 등의 모드를 사용한다. w는 기존 파일 내용을 덮어쓸 수 있으므로 모드의 의미를 구분한다. with open(...) as f: 구문은 블록을 벗어날 때 파일이 닫히도록 돕는다.
입력은 문자열에서 필요한 자료형으로 변환한다
input()의 반환값은 문자열이다. input()으로 읽은 "12"는 정수 12와 다르므로 계산하려면 int() 등의 변환이 필요하다. 입력 자료가 변환 가능한지와 업무상 허용된 범위인지도 각각 확인해야 한다.
def parse_score(text):
try:
score = int(text)
except ValueError:
return "형식 오류"
if not 0 <= score <= 100:
return "범위 오류"
return score
print(parse_score("80"))
print(parse_score("120"))
print(parse_score("abc"))
출력:
80
범위 오류
형식 오류
eval()과 exec()는 단순한 자료형 변환 함수가 아니라 코드를 실행하는 기능이므로 비신뢰 입력을 그대로 넣지 않는다. 사용자에게 상세 내부 경로나 비밀값이 포함된 오류를 그대로 보여 주지 않는다.