() translation by (you can also view the original English article)
이 튜토리얼에서는 전체 시스템 관점에서 파이썬으로 오류 상황을 처리하는 방법을 배우겠습니다. 오류 처리는 설계의 중요한 측면이며 최하위 수준(때로는 하드웨어)에서 최종 사용자까지 이어집니다. 처음부터 일관된 전략을 세우지 않으면 시스템이 불안정해지고 사용자 경험이 형편없어질 것이며, 디버깅 및 문제 해결에 많은 어려움이 따를 것입니다.
성공의 열쇠는 이처럼 맞물려 있는 모든 측면을 인식하고, 그것등를 명시적으로 고려하며, 각 부분을 해결하는 솔루션을 만드는 것입니다.
상태 코드 vs. 예외
주요 오류 처리 모델로 상태 코드와 예외가 있습니다. 상태 코드는 모든 프로그래밍 언어에서 사용할 수 있습니다. 예외는 언어/런타임 지원이 필요합니다.
파이썬에서는 예외를 지원합니다. 파이썬 및 파이썬 표준 라이브러리에서는 IO 오류, 0으로 나누기, 범위를 벗어나는 인덱싱과 같은 여러 예외적인 상황을 비롯해 반복이 끝남(숨겨져 있긴 하지만)과 같은 그리 예외적이지 않은 몇 가지 상황을 보고하기 위해 예외를 자유롭게 사용합니다. 대부분의 라이브러리에서도 여기에 따라 예외를 발생시킵니다.
즉, 여러분이 작성한 코드에서 파이썬 및 라이브러리에서 발생시킨 예외를 처리해야 할 것이며, 필요 시 여러분도 코드에서 예외를 발생시키고 상태 코드에 의존하지 않을 수 있습니다.
간단한 예제
파이썬 예외 및 오류 처리의 모범 사례를 자세히 살펴보기에 앞서 몇 가지 실제 예외 처리를 살펴보겠습니다.
1 |
def f(): |
2 |
|
3 |
return 4 / 0 |
4 |
|
5 |
|
6 |
|
7 |
def g(): |
8 |
|
9 |
raise Exception("Don't call us. We'll call you") |
10 |
|
11 |
|
12 |
|
13 |
def h(): |
14 |
|
15 |
try: |
16 |
|
17 |
f() |
18 |
|
19 |
except Exception as e: |
20 |
|
21 |
print(e) |
22 |
|
23 |
try: |
24 |
|
25 |
g() |
26 |
|
27 |
except Exception as e: |
28 |
|
29 |
print(e) |
다음은 h()
를 호출할 때의 출력 결과입니다.
1 |
h()
|
2 |
|
3 |
division by zero |
4 |
|
5 |
Don't call us. We'll call you
|
파이썬 예외
파이썬 예외는 클래스 계층 구조로 구성된 객체입니다.
다음은 전체 계층 구조입니다.
1 |
BaseException |
2 |
|
3 |
+-- SystemExit |
4 |
|
5 |
+-- KeyboardInterrupt |
6 |
|
7 |
+-- GeneratorExit |
8 |
|
9 |
+-- Exception |
10 |
|
11 |
+-- StopIteration |
12 |
|
13 |
+-- StandardError |
14 |
|
15 |
| +-- BufferError |
16 |
|
17 |
| +-- ArithmeticError |
18 |
|
19 |
| | +-- FloatingPointError |
20 |
|
21 |
| | +-- OverflowError |
22 |
|
23 |
| | +-- ZeroDivisionError |
24 |
|
25 |
| +-- AssertionError |
26 |
|
27 |
| +-- AttributeError |
28 |
|
29 |
| +-- EnvironmentError |
30 |
|
31 |
| | +-- IOError |
32 |
|
33 |
| | +-- OSError |
34 |
|
35 |
| | +-- WindowsError (Windows) |
36 |
|
37 |
| | +-- VMSError (VMS) |
38 |
|
39 |
| +-- EOFError |
40 |
|
41 |
| +-- ImportError |
42 |
|
43 |
| +-- LookupError |
44 |
|
45 |
| | +-- IndexError |
46 |
|
47 |
| | +-- KeyError |
48 |
|
49 |
| +-- MemoryError |
50 |
|
51 |
| +-- NameError |
52 |
|
53 |
| | +-- UnboundLocalError |
54 |
|
55 |
| +-- ReferenceError |
56 |
|
57 |
| +-- RuntimeError |
58 |
|
59 |
| | +-- NotImplementedError |
60 |
|
61 |
| +-- SyntaxError |
62 |
|
63 |
| | +-- IndentationError |
64 |
|
65 |
| | +-- TabError |
66 |
|
67 |
| +-- SystemError |
68 |
|
69 |
| +-- TypeError |
70 |
|
71 |
| +-- ValueError |
72 |
|
73 |
| +-- UnicodeError |
74 |
|
75 |
| +-- UnicodeDecodeError |
76 |
|
77 |
| +-- UnicodeEncodeError |
78 |
|
79 |
| +-- UnicodeTranslateError |
80 |
|
81 |
+-- Warning |
82 |
|
83 |
+-- DeprecationWarning |
84 |
|
85 |
+-- PendingDeprecationWarning |
86 |
|
87 |
+-- RuntimeWarning |
88 |
|
89 |
+-- SyntaxWarning |
90 |
|
91 |
+-- UserWarning |
92 |
|
93 |
+-- FutureWarning |
94 |
|
95 |
+-- ImportWarning |
96 |
|
97 |
+-- UnicodeWarning |
98 |
|
99 |
+-- BytesWarning |
100 |
보다시피 SystemExit
, KeyboardInterrupt
, GeneratorExit
와 같이 BaseException
에서 직접 파생된 몇 가지 특별한 예외가 있습니다. 그리고 StopIteration
, StandardError
, Warning
의 기반 클래스인 Exception
클래스가 있습니다. 모든 표준 오류는 StandardError
에서 파생됩니다.
예외를 발생시키거나 호출한 어떤 함수에서 예외를 발생시키면 정상적인 코드 흐름이 종료되고 적절한 예외 처리기(exception handler)를 만날 때까지 예외가 호출 스택으로 전파되기 시작합니다. 예외를 처리할 수 있는 예외 처리기가 없으면 프로세스(또는 좀 더 정확하게는 현재 스레드)가 처리되지 않은 예외 메시지와 함께 종료됩니다.
예외 발생시키기
예외를 발생시키는 것은 매우 쉽습니다. Exception
클래스의 하위 클래스인 객체를 발생시키려면 raise
키워드를 사용하기만 하면 됩니다. 이 경우 Exception
인스턴스 자체나 표준 예외(예: RuntimeError
) 중 하나, 또는 여러분이 직접 파생한 Exception
의 하위 클래스 중 하나가 만들어질 수 있습니다. 다음은 모든 경우를 보여주는 예제 코드입니다.
1 |
# Raise an instance of the Exception class itself
|
2 |
|
3 |
raise Exception('Ummm... something is wrong') |
4 |
|
5 |
|
6 |
|
7 |
# Raise an instance of the RuntimeError class
|
8 |
|
9 |
raise RuntimeError('Ummm... something is wrong') |
10 |
|
11 |
|
12 |
|
13 |
# Raise a custom subclass of Exception that keeps the timestamp the exception was created
|
14 |
|
15 |
from datetime import datetime |
16 |
|
17 |
|
18 |
|
19 |
class SuperError(Exception): |
20 |
|
21 |
def __init__(self, message): |
22 |
|
23 |
Exception.__init__(message) |
24 |
|
25 |
self.when = datetime.now() |
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 |
raise SuperError('Ummm... something is wrong') |
예외 잡기
예제에서 보셨듯이 except
절을 이용해 예외를 잡습니다. 예외를 잡을 때는 세 가지 선택지가 있습니다.
- 조용히 삼킨다(예외를 처리하고 계속 실행).
- 로깅과 같은 작업을 수행하지만 높은 수준의 처리를 위해 동일한 예외를 다시 발생시킨다.
- 원래의 예외 대신 다른 예외를 발생시킨다.
조용히 삼킨다
예외를 처리하는 방법을 알고 있고 완전히 복구할 수 있으면 예외를 삼켜야 합니다.
예를 들어, 다양한 형식(JSON, YAML)의 입력 파일을 받는 경우 다양한 파서를 사용해 파싱을 시도할 수 있습니다. JSON 파서에서 파일이 유효한 JSON 파일이 아니라는 예외를 발생시키면 이를 삼킨 다음 YAML 파서로 시도해 봅니다. YAML 파서에서도 실패한다면 예외를 밖으로 전파합니다.
1 |
import json |
2 |
|
3 |
import yaml |
4 |
|
5 |
|
6 |
|
7 |
def parse_file(filename): |
8 |
|
9 |
try: |
10 |
|
11 |
return json.load(open(filename)) |
12 |
|
13 |
except json.JSONDecodeError |
14 |
|
15 |
return yaml.load(open(filename)) |
참고로 다른 예외(예: 파일을 찾을 수 없거나 읽기 권한이 없음)는 전파되더라도 특정 except 절에서 포착되지 않을 것입니다. 이 경우 JSON 인코딩 문제로 JSON 파싱에 실패한 경우에만 YAML 파싱을 시도하는 것이 좋습니다.
모든 예외를 처리하려면 except Exception
을 사용하면 됩니다. 다음 예제를 봅시다.
1 |
def print_exception_type(func, *args, **kwargs): |
2 |
|
3 |
try: |
4 |
|
5 |
return func(*args, **kwargs) |
6 |
|
7 |
except Exception as e: |
8 |
|
9 |
print type(e) |
참고로 여기서는 as e
를 추가해서 except 절에서 사용 가능한 이름인 e
에 예외 객체를 바인딩합니다.
동일한 예외를 다시 발생시킨다
예외를 다시 발생시키려면 처리기 안에서 인수 없이 raise
만 추가하면 됩니다. 이렇게 하면 예외를 국부적으로 처리할 수 있지만 여전히 상위 레벨에서도 해당 예외를 처리하게 됩니다. 여기서 invoke_function()
함수는 예외 타입을 콘솔에 출력한 다음 예외를 다시 발생시킵니다.
1 |
def invoke_function(func, *args, **kwargs): |
2 |
|
3 |
try: |
4 |
|
5 |
return func(*args, **kwargs) |
6 |
|
7 |
except Exception as e: |
8 |
|
9 |
print type(e) |
10 |
|
11 |
raise
|
다른 예외를 발생시킨다
다른 예외를 발생시키고자 하는 경우도 여럿 있습니다. 때때로 여러 하위 수준 예외를 상위 수준 코드에서 균일하게 처리되는 단 하나의 범주로 그룹화하고 싶을 때가 있습니다. 다른 경우로는 예외를 사용자 레벨로 변환하고 애플리케이션에 특화된 컨텍스트를 제공해야 할 때가 있습니다.
finally 절
간혹 어딘가에 예외가 발생하더라도 정리 코드가 실행되게 만들고 싶을 때가 있습니다. 예를 들어, 작업이 완료되면 데이터베이스 연결을 닫고 싶을 수 있습니다. 다음은 잘못된 방법입니다.
1 |
def fetch_some_data(): |
2 |
|
3 |
db = open_db_connection() |
4 |
|
5 |
query(db) |
6 |
|
7 |
close_db_Connection(db) |
query()
함수에서 예외를 발생시키면 close_db_connection()
에 대한 호출은 결코 실행되지 않고 DB 연결은 열린 채로 유지됩니다. finally
절은 모든 예외 처리기가 실행된 후에 항상 실행됩니다. 올바르게 처리하는 방법은 다음과 같습니다.
1 |
def fetch_some_data(): |
2 |
|
3 |
db = None |
4 |
|
5 |
try: |
6 |
|
7 |
db = open_db_connection() |
8 |
|
9 |
query(db) |
10 |
|
11 |
finally: |
12 |
|
13 |
if db is not None: |
14 |
|
15 |
close_db_connection(db) |
open_db_connection()
을 호출했을 때 연결을 반환하지 않거나 예외 자체가 발생하지 않을 수 있습니다. 이 경우 DB 연결을 닫을 필요가 없습니다.
finally
를 사용할 때는 예외를 발생시키지 않도록 주의해야 합니다. 새로 발생한 예외가 원래 예외를 숨길 것이기 때문입니다.
컨텍스트 관리자
컨텍스트 관리자(context manager)는 파일이나 DB 연결과 같은 리소스를 자동으로 실행되는 정리 코드로 포장하는 메커니즘을 제공합니다(심지어 예외가 발생한 경우에도). try-finally 블록 대신 with
문을 사용하면 됩니다. 다음은 파일을 사용하는 예제입니다.
1 |
def process_file(filename): |
2 |
|
3 |
with open(filename) as f: |
4 |
|
5 |
process(f.read()) |
이제 process()
에서 예외를 발생시키더라도 예외가 처리됐는지 여부와 관계 없이 with
블록의 스코프가 종료될 때 즉시 파일이 적절히 닫힙니다.
로깅
로깅은 평범하지 않고 오래 실행되는 시스템에서 자주 볼 수 있는 요구사항입니다. 로깅은 일반적인 방식, 즉 예외를 기록하고 호출자에게 오류 메시지를 반환하는 식으로 모든 예외를 처리할 수 있는 웹 애플리케이션에서 특히 유용합니다.
로깅할 때는 예외 타입, 오류 메시지, 스택트레이스를 기록하는 것이 유용합니다. 이 모든 정보는 sys.exc_info
객체를 통해 접근할 수 있지만 예외 처리기에서 logger.exception()
메서드를 사용하면 파이썬 로깅 시스템이 모든 관련 정보를 추출합니다.
다음은 제가 권장하는 모범 사례입니다.
1 |
import logging |
2 |
|
3 |
logger = logging.getLogger() |
4 |
|
5 |
|
6 |
|
7 |
def f(): |
8 |
|
9 |
try: |
10 |
|
11 |
flaky_func() |
12 |
|
13 |
except Exception: |
14 |
|
15 |
logger.exception() |
16 |
|
17 |
raise
|
이 패턴을 따른다면(올바르게 로깅을 설정했다고 가정하면) 어떤 일이 일어나더라도 로그에 무엇이 잘못됐는지 기록되어 문제를 해결할 수 있을 것입니다.
예외를 다시 발생시키는 경우 동일한 예외를 다른 수준에서 반복해서 기록하지 않게 합니다. 이것은 낭비에 불과하고 혼동을 야기할 수 있으며, 실제로 한 인스턴스가 여러 번 기록될 때 동일한 문제가 여러 번 발생했다고 생각하게 만들 수 있습니다.
가장 간단한 방법은 모든 예외를 전파하고(예외를 자신 있게 처리하고 삼킬 수 있는 경우가 아니라면) 애플리케이션/시스템의 최상위 수준에서 로깅을 수행하는 것입니다.
Sentry
로깅은 기능입니다. 가장 일반적인 구현은 로그 파일을 사용하는 것입니다. 그러나 수백, 수천 또는 그 이상의 서버를 운용하는 대규모 분산 시스템의 경우 로깅이 항상 최상의 솔루션이라고 할 수는 없습니다.
전체 인프라에서 예외를 추적하기 위해서는 sentry 같은 서비스가 굉장히 유용합니다. sentry는 모든 예외 보고서를 중앙집중화하고 스택트레이스를 비롯해 각 스택 프레임의 상태(예외가 발생한 시점의 변수 값)를 추가합니다. 또한 대시보드와 보고서가 포함된 굉장히 훌륭한 인터페이스를 비롯해 여러 프로젝트별로 메시지를 분류하는 수단을 제공합니다. sentry는 오픈소스이므로 여러분이 직접 서버를 운영하거나 호스팅된 버전을 구독할 수 있습니다.
일시적인 오류 처리하기
어떤 오류는 일시적으로 일어납니다(특히 분산 시스템을 다룰 때). 문제의 조짐이 보이자마자 걷잡을 수 없이 망가지는 시스템은 그리 유용하지 않습니다.
여러분이 작성한 코드에서 응답하지 않는 특정 원격 시스템에 접근하는 경우 전통적인 해결책은 타임아웃(timeout)이지만 모든 시스템이 타임아웃을 사용하도록 설계되지 않은 경우도 있습니다. 조건이 바뀔 경우 타임아웃을 측정하기가 언제나 쉬운 것은 아닙니다.
또 다른 방법은 빠르게 실패한 다음 다시 시도하는 것입니다. 이 경우 대상이 신속하게 반응하는 경우 수면 상태에서 많은 시간을 소비할 필요 없어 즉시 반응할 수 있다는 이점이 있습니다. 그러나 실패한 경우 정말로 접근할 수 없다고 판단하고 예외를 발생시킬 때까지 여러 번 재시도할 수 있습니다. 다음 절에서는 여러분을 대신해서 이러한 작업을 할 수 있는 데코레이터를 소개하겠습니다.
유용한 데코레이터
오류 처리에 유용한 두 개의 데코레이터로 예외를 기록한 다음 다시 발생시키는 @log_error
와 함수 호출을 여러 번 재시도하는 @retry
데코레이터가 있습니다.
오류 로거
다음은 간단한 구현입니다. 이 데코레이터는 로거 객체를 대상으로 except를 수행합니다. 함수를 장식하고 해당 함수가 호출되면 try-except 절에서 호출을 래핑한 후 예외가 발생하면 이를 기록하고 최종적으로 예외를 다시 발생시킵니다.
1 |
def log_error(logger) |
2 |
|
3 |
def decorated(f): |
4 |
|
5 |
@functools.wraps(f) |
6 |
|
7 |
def wrapped(*args, **kwargs): |
8 |
|
9 |
try: |
10 |
|
11 |
return f(*args, **kwargs) |
12 |
|
13 |
except Exception as e: |
14 |
|
15 |
if logger: |
16 |
|
17 |
logger.exception(e) |
18 |
|
19 |
raise
|
20 |
|
21 |
return wrapped |
22 |
|
23 |
return decorated |
다음은 이 데코레이터를 사용하는 방법입니다.
1 |
import logging |
2 |
|
3 |
logger = logging.getLogger() |
4 |
|
5 |
|
6 |
|
7 |
@log_error(logger) |
8 |
|
9 |
def f(): |
10 |
|
11 |
raise Exception('I am exceptional') |
재시도기
다음은 아주 훌륭한 @retry 데코레이터 구현입니다.
1 |
import time |
2 |
|
3 |
import math |
4 |
|
5 |
|
6 |
|
7 |
# Retry decorator with exponential backoff
|
8 |
|
9 |
def retry(tries, delay=3, backoff=2): |
10 |
|
11 |
'''Retries a function or method until it returns True.
|
12 |
|
13 |
|
14 |
|
15 |
delay sets the initial delay in seconds, and backoff sets the factor by which
|
16 |
|
17 |
the delay should lengthen after each failure. backoff must be greater than 1,
|
18 |
|
19 |
or else it isn't really a backoff. tries must be at least 0, and delay
|
20 |
|
21 |
greater than 0.'''
|
22 |
|
23 |
|
24 |
|
25 |
if backoff <= 1: |
26 |
|
27 |
raise ValueError("backoff must be greater than 1") |
28 |
|
29 |
|
30 |
|
31 |
tries = math.floor(tries) |
32 |
|
33 |
if tries < 0: |
34 |
|
35 |
raise ValueError("tries must be 0 or greater") |
36 |
|
37 |
|
38 |
|
39 |
if delay <= 0: |
40 |
|
41 |
raise ValueError("delay must be greater than 0") |
42 |
|
43 |
|
44 |
|
45 |
def deco_retry(f): |
46 |
|
47 |
def f_retry(*args, **kwargs): |
48 |
|
49 |
mtries, mdelay = tries, delay # make mutable |
50 |
|
51 |
|
52 |
|
53 |
rv = f(*args, **kwargs) # first attempt |
54 |
|
55 |
while mtries > 0: |
56 |
|
57 |
if rv is True: # Done on success |
58 |
|
59 |
return True |
60 |
|
61 |
|
62 |
|
63 |
mtries -= 1 # consume an attempt |
64 |
|
65 |
time.sleep(mdelay) # wait... |
66 |
|
67 |
mdelay *= backoff # make future wait longer |
68 |
|
69 |
|
70 |
|
71 |
rv = f(*args, **kwargs) # Try again |
72 |
|
73 |
|
74 |
|
75 |
return False # Ran out of tries :-( |
76 |
|
77 |
|
78 |
|
79 |
return f_retry # true decorator -> decorated function |
80 |
|
81 |
return deco_retry # @retry(arg[, ...]) -> true decorator |
결론
오류 처리는 사용자와 개발자 모두에게 중요합니다. 파이썬은 언어 및 표준 라이브러리에서 예외 기반 오류 처리를 아주 훌륭하게 지원합니다. 모범 사례를 부지런히 따라 하다 보면 예외 처리라는 자주 무시되는 측면을 정복할 수 있을 것입니다.