fastapi 을 이용한 web app 을 개발하면서, 어떻게 에러처리를 할 지 고민하면서 읽은 글을 정리해보았다.
꼭 python 에 국한할 필요없이, general 한 에러처리 방법론을 배울 수 있었으며, 반드시 이렇게 해야한다는 내용이 아니라 상황에 따라 적절한 에러 핸들링을 위한 의사결정을 돕는 글이기에, 정리해서 두고두고 보려고 정리해보았다. [원문]
python error handling 방식에는 크게 2가지 방법이 있다.
첫번째는 LBYL(Look Before you Leap) 방식이고, 두번째 방식은 EAFP(Easier to Ask Forgiveness than Permission) 방식이다.
1. Look Before You Leap (LBYL)
돌다리는 두들겨 보고 건너라는 의미로, action을 취하기 전에는 먼저 check 해보라. 라는 의미이다.
if os.path.exists(file_path):
os.remove(file_path)
else:
print(f"Error: file {file_path} does not exist!")
그런데 이런 상황에서 file remove 시에 일어날 수 있는 오류가 file이 존재하지 않는 경우만 있을까?
다음 에러 상황도 존재할 수 있다.
1. file_path 가 file이 아니라 directory 일 수 있다.
2. 현재 remove 를 시도하는 user와는 다른 user만 권한을 가진 file 일 수 있다.
3. file 이 read-only 권한만을 가지고 있을 수 있다.
4. file 이 저장된 disk 가 read-only volume 으로 mount 된 걸 수 있다.
5. flie이 다른 process 에 의해 lock 되었을 수 있다. (microsoft Windows에서 자주 일어나는 일임.)
이 방식에선 이런 모든 상황을 모두 확인해야하는 걸까? 그렇다.
LBYL 방식을 따르면, 모든 에러 상황을 알고 있어야 하기 때문에 곤란하다.
이 방식의 또 다른 문제점은 race condition 이 존재한다는 점이다.
실패 상황을 확인했다고 해도, 실행 시점과는 (아주 약간이라도) 시간 간격이 존재하고, 그 사이에 상황이 변할 수 있다는 것이다.
2. Easier to Ask Forgiveness than Permission
용서가 허락보다 쉽다. 라는 의미이다. 허락보단 일단 저질러! 라는 것은 다른 데서도 많이 적용되는 말인데 프로그래밍에도 통해서 재미있었다.
try:
os.remove(file_path)
except OSError as error:
print(f"Error deleting file: {error}")
remove 실행은 냅다 저질러 버리고, 예외상황이 발생하면 그 상황을 캐치한다.
이제 중요해지는 것은 except 구문에서 무엇을 할 것인가이다. 놓치는 exception 은 layer 를 타고 올라가서 application crash 를 일으키기 마련이다. (서버가 동작하지 않아 app이 응답하지 않을 것. )
먼저 file 삭제와 관련된 에러는 모두 OSError의 하위 클래스이기 때문에 OSError 를 사용하는 것이 가능하지만,
다른 경우에는 document, source code를 직접 확인하는 것이 필요하다.
아무 exception 도 발생하지 않도록, Exception (모든 exception 의 상위 클래스) 을 catch 하지 않느냐는 물음에는, 그건 좋지 않은 방식이라고 할 수 있다. (딱 한가지 상황을 제외하고. 잠시 뒤에 등장한다. )
문제는, 함수를 호출할 때마다 모든 예외를 캐치하고 조용히 시킨다면, 일어나지 않았어야 하는 예외를 놓치게 되고, 고쳐야 하는 소스코드의 버그를 확인할 수 없게 된다.
따라서, 아무 Exception 을 캐치하는 것은 좋지 않은 방법이며, 가장 작은 단위의 exception class 를 이용하는 것이 좋다.
이렇게 전통적인 에러 핸들링 지식은 아주 유용하다고 보긴 어렵다.
왜냐면 실제로 어떻게 할지는 여전히 잘 모르기 때문이다.
이번에는 에러 핸들링 방식으로 분류하는 대신에, error 그 자체로 완전히 다르게 바라봐보자.
이때 error 는 두가지 로 해볼 수 있다.
'new error' : 막 발생시킨 에러.
'bubbled-up error' : 호출한 함수 아래편에서 위로 타고 올라온 에러.
함수에서 에러가 나면, 그 함수를 호출했던 caller 가 그 exception 을 try-catch 구문으로 받을 지 말지 선택할 기회를 가진다. caller 가 그 에러를 잡지 않는다면, 그 caller 다음의 caller 단으로 올라가게 되고, 특정 code 가 catch 할 때까지 이 과정은 계속된다.
만약 마지막까지 catch 되지 않는다면, python 이 application 을 interrupt 하고, error 가 어디서 나타났는지 stack trace 에 나타나게 된다.
- Recoverable VS Non-Recoverable Errors
- error 가 new error 이든 bubbled-up error 이든, 그게 극복이 가능한지, 아닌지도 판단해야 한다.
- 극복 가능한 에러는 그걸 다루는 code 가 계속 진행되기 전에 조치를 취할 수 있다는 것을 의미한다.
- 예를 들어, 한 코드가 파일을 삭제하려고 시도하는데, 파일이 존재하지 않는 다는 것을 알게 되면, 그 에러를 무시하고 지나가면 된다. 이 경우는 극복가능하다.
에러를 category 로 나누어, 4가지 case에 대해 논해보자.
Type 1: Handling New Recoverable Errors
새롭게 발생한 에러 & 극복가능한 에러
def add_song_to_database(song):
# ...
if song.year is None:
song.year = 'Unknown'
# ...
현재 애플리케이션 상태에서 문제가 있거나 실수를 찾았는데 고칠 방법이 있다면 error 를 내뱉지 않고 계속 가도 된다.
LBYL 방식으로, Database 에 year 속성이 not null 인데 현재 값이 None 이라면, 직접 year 에 'Unknown' 이라는 값을 붙여주는 식이다. (year가 str 이라는 조건이라고 가정.)
Type 2: Handling Bubbled-Up Recoverable Errors
아래에서 올라온 에러 & 극복가능한 에러
다음과 같이 해볼 수 있다.
def add_song_to_database(song):
# ...
try:
artist = get_artist_from_database(song.artist)
except NotFound:
artist = add_artist_to_database(song.artist)
# ...
EAFP 방식에서 error 를 catch 하고, 극복을 위한 일을 수헹하는 방식이다.
함수가 database에서 artist 를 받고자 하는데 실패한다면, EAFP 방식으로 임의의 artist를 database에 추가함으로써 에러를 극복한다.
call stack (이 함수를 호출했던 함수, 그 함수를 호출한 함수들의 stack ) 에서는 에러가 있었다는 것을 모르고, bubbling up은 멈춘다.
Type 3: Handling New Non-recoverable Errors
새롭게 생긴 에러 & 극복 불가한 에러
3번째 경우는 좀 더 흥미롭다.
어떻게 처리해야 할지 모르는 경우이고 계속 진행할 수 없는 경우이다. 유일하게 합리적인 action 은 현재 함수를 멈추고 call stack 에 알림을 보는 것이다. 그 caller 가 어떻게 할지 알 것이라는 희망을 가지고.
Python 이 선호하는 방식으로 caller 에게 알리는 방식은 Exception 을 raise 하는 방식이다.
이 전략을 잘 동작한다. 왜냐면 회복 불가능한 에러의 흥미로운 속성떄문이다. 보통 많은 경우, 회복 불가능한 에러는 결국엔 회복이 가능해지는데, 꽤나 높은 call stack 까지 접근한 경우 그러하다. 따라서 error 는 회복가능한 level 에 이를 때까지 bubble up 하게 된다. 그건 type 2 error 가 될 것이고, 그럼 우리는 어떻게 조치를 취할 지 알게 된다.
add_song_to_database () 함수를 다시 살펴보자.
song 의 year 가 없는 경우 ‘Unknown’ 으로 하자는 판단을 내렸었다. 그러나 만약 곡이 이름이 없다면, 이 수준에서 뭘 할 수 있을지 생각하기가 어렵고, 이건 회복 불가능한 에러가 된다 .
def add_song_to_database(song):
# ...
if song.name is None:
raise ValueError('The song must have a name')
# ...
그래서 에러를 발생시킨다.
이걸 어떻게 처리하냐는 개인적인 취향과 application 에 따라 다르다. 많은 에러들은 커버가 되겠지만 built-in Exception 에 맞지 않는다면, custom exception subclass 를 만들어도 된다.
여기서 중요하게 알아야 하는 점은 raise keyword 가 함수를 인터럽트 시킨다는 점이다. 어차피 이 에러가 회복될 수 없기 때문에, 함수가 해야하는 나머지 부분은 어차피 실행될 수 없게 된다. exception을 raising 하게 되면 현재 함수를 인터럽트 시키고, 가장 가까운 caller 부터 시작해서 error 를 bubble up 되는 것이 시작된다. 상위의 어떤 코드가 exception 을 catch 하도록 판단하지 않는 이상 bubble up은 멈추지 않는다.
Type 4: Handling Bubbled-Up Non-Recoverable Errors
아래에서 올라온 에러 & 극복 불가능한 에러인 경우
함수를 호출했는데, 함수에서 에러가 발생했는데, 어떻게 할 지 모르는 경우.
bubbled-up 에러인데도 불구하고 다룰 수 없는 에러라면?
이 경우에는 아무것도 하지 않는다!
def new_song():
song = get_song_from_user()
add_song_to_database(song)
- new_song() 함수 내에서 호출하는 두 함수가 둘다 실패하고 exception 을 raise 할 수 있다고 치자. 이 함수로 인해서 문제가 될 수 있는 경우의 예시들을 생각해보자.
- 유저가 ctrl + c 를 눌러서 get_song_from_user() 에서 input 을 기다리고 있다가 멈추는 경우
- 둘 중 한 함수 내에서 database 가 cloud issue로 offline 이 되는 경우. 모든 쿼리와 commit 이 실패하게 되는 경우.
- 이런 에러들을 회복할 수 없다면, 그 에러들을 catch 하는 것은 의미가 없다. 아무것도 하지 않는 것이 곧 할 수 있는 가장 유익한 것이 된다. 왜냐면 그걸로 인해서 exception 이 bubble up 되기 때문이다. 결과적으로 exception 은 code 가 회복 절차를 거칠 수 있는 위치의 level 까지 다다르게 될 것이고, 그 시점에서는 2번 유형의 error 가 되어서 잘 다뤄질 것이다.
이런 상황이 드문 상황일 거라고 생각할 것이다. 하지만 그렇지 않다. 사실 에러 핸들링을 하지 않는 함수들을 많이 만들고, 좀 더 높은 level 의 함수에서 처리하도록 하는 것이 클린하고 유지가능한 코드를 작성하는 좋은 전략이다.
add_song() 함수가 적어도 error message 를 보내는 것이 낫다고 생각할지도 모른다. 근데 console 에서 출력을 할 수 있을까? 이 application 이 GUI 라면? 아니면 HTTP 에러를 반환해야할까? [seperation of concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) 는 그렇게 하지 말라고 이야기 한다.
다시한번 정리하면, 함수 호출 시 아무것도 하지 않는 것은 error 가 무시된다는 뜻이 아니라, 그 에러가 bubble up 되어서 application 의 특정 파트, 더 많은 문맥을 가진 부분이 적절하게 처리할 수 있게 될 것이다.
Catching All Exceptions
그런데 만약 에러 처리를 하지 않고 bubble up 되게 만든다면, application crash 가 나지 않을까? 이건 유효한 걱정이고, 쉬운 솔루션을 가지고 있다.
exception 이 python layer 까지 올 수 없도록 만들어야 할 것이다. 그리고 그건 가장 높은 수준에서 try / except block 을 추가하는 것이다.
import sys
def my_cli()
# ...
if __name__ == '__main__':
try:
my_cli()
except Exception as error:
print(f"Unexpected error: {error}")
sys.exit(1)
가장 높은 level 에서 에러를 회복가능하게 둔다. 그러한 에러는 error 로그를 찍고 exit code 1로 exit 하는 방식이다.
shell 혹은 부모 프로세스에 application 이 실패했다고 말하게 될 것이다.
이 로직과 함께라면, application 은 실패시 어떻게 exit 할 지 알게 되고, 내부적으로는 쉽게 error 가 bubble up 되게 만들 수 있다.
모든 exception 을 다 잡는 게 bad practice 라는 걸 알아야 한다. 하지만 python 수준까지 error 가 올라오는 것은 막아야만 program 이 멈추지 않기 때문에, 유일하게 모든 exception 을 잡는 것이 허용되는 위치이다. (나중에 등장한다고 했던 부분이 이 부분!)
이렇게 high-level catch-all exception 블록은 사실 대부분의 application framework에서 공통적으로 사용되는 패턴이다.
2가지 example 이 있다:
1. Flask web framework
1. Flask 는 각각의 request 를 application 의 seperate run 으로 본다. top 레이어에서는 full_dispatch_request() 함수가 있고 모든 exception 을 여기서 잡는다.
try:
request_started.send(self, _async_wrapper=self.ensure_sync)
rv = self.preprocess_request()
if rv is None:
rv = self.dispatch_request()
except Exception as e:
rv = self.handle_user_exception(e)
return self.finalize_request(rv)
2. Tkinter GUI toolkit (python library 의 일부)
1. Tkinter 는 각 application event handler 를 application 의 separate little run 으로 본다. 그리고 GUI 를 crash 하게 만드는 faulty application handler 를 방지하기 위해, handler 를 호출할 때마다 일반적인 catch-all exception block을 추가한다. (링크)
def __call__(self, *args):
"""Apply first function SUBST to arguments, than FUNC."""
try:
if self.subst:
args = self.subst(*args)
return self.func(*args)
except SystemExit:
raise
except:
self.widget._report_exception()
```
이 코드에서 Tkinter가 SystemExit exception 을 허용하고 bubble 하게 만들지만 다른 건 모두 catch 한다는 걸 볼 수 있다.
예시 코드도 살펴보자.
Flask-SQLAlchemy extension 을 사용하는 database application 이라고 가정하자.
일단 나쁜 예시이다.
# NOTE: this is an example of how NOT to do exception handling!
@app.route('/songs/<id>', methods=['PUT'])
def update_song(id):
# ...
try:
db.session.add(song)
db.session.commit()
except SQLAlchemyError:
current_app.logger.error('failed to update song %s, %s', song.name, e)
try:
db.session.rollback()
except SQLAlchemyError as e:
current_app.logger.error('error rolling back failed update song, %s', e)
return 'Internal Service Error', 500
return '', 204
route 는 song 을 database 에 저장하고자 한다. 그리고 database 에러를 catch 하고자 한다. 이 database 에러는 모두 SQLAlchemyErorr 의 하위 클래스이다.
에러가 발생하게 되면, log 에 추가 설명적인 메시지를 적고, database session 에 롤백한다. 하지만 당연하게도, 롤백 작업은 가끔 실패하기도 한다. 따라서, 두번째 exception catching block 이 존재하게 된다. 다 하고나면 server error 가 발생했다는 500 에러를 보낸다.
모든 endpoint 에서 database 에 write 하려는 코드에서 이 패턴이 반복된다.
이건 매우 안좋은 solution이다.
첫번째로, 이 함수에서 rollback error 를 처리하기 위해 할 수 있는 것이 없다. rollback error 가 났는데, database 가 문제가 생긴거라면, rollback error 가 났다는 log는 아무런 도움이 되지 않는다.
둘째로, commit 이 실패할 때 error message 를 logging 하는 것은 처음엔 도움이 되겠지만, 정보가 부족하다. 특히 나중에 무슨 일이 일어난 건지 알고 싶을 때 큰 도움이 되는 stack trace 가 없기 때문이다.
마지막으로, logger.error 가 아니라 logger.exception 을 사용해야 한다. 그래야 stack trace와 함께 error message 가 출력되기 때문이다.
그런데 그것보다 더 잘 할 수도 있다.
4번 유형의 error 로 만들자. 아무것도 하지 않는 접근 방식이다.
@app.route('/songs/<id>', methods=['PUT'])
def update_song(id):
# ...
db.session.add(song)
db.session.commit()
return '', 204
이게 왜 작동할까? Flask는 모든 에러를 잡기 때문에, error 를 catch 하지 못했다고 해서 applicaiton 이 crash 되지 않는다.
대신 Flask는 error message와 stack trace를 보여줄 것이다. 의도치 않은 서버 에러가 나타나서 500 error 라고 보여줄 것이다.
추가적으로 Flask-SQLAlchemy extension 은 Flask 의 exception handling mechanism 와 결합되어서 database error 가 나면 session 을 rollback 해준다.
정말로 route 에서 할 것이 없었던 것이다!
database error 에 대한 recovery process는 대부분의 애플리케이션에서 같다.
따라서 framework 가 그런 건 처리하게 하고, application code는 더 간단한 로직으로 이득을 볼 수 있게 할 수 있다.
Errors in Production vs Errors in Development
application call stack의 가장 상위 층으로 error handling logic을 옮기는 것의 장점은 가독성과 유지보수성이 좋은 코드를 작성할 수 있다는 점이었다.
development 환경에서, application crashing , 그리고 stack trace 가 보이는 것은 아무 문제가 없다. 사실 그건 좋은 일이다. 왜냐면 error 와 bug 가 나타나고 수정되길 바라기 때문이다. 그런데 production 이라면 그런 에러는 큰 문제가 될 것이다.
import sys
mode = os.environ.get("APP_MODE", "production")
def my_cli()
# ...
if __name__ == '__main__':
try:
my_cli()
except Exception as error:
if mode == "development":
raise # in dev mode we let the app crash!
else:
print(f"Unexpected error: {error}")
sys.exit(1)
그럴 때는 환경에 따라 예외를 어떻게 처리할 지 볼 수 있다.
추가적으로, 5가지 general 한 error 처리 방식 Best practice 에 대한 내용은 다음과 같다.
Best Practices
1. generic catch-all 방식 대신, 특정 Exception 타입에 대한 처리를 하자.
- 에러를 서로 구분하고, 정확한 에러메시지를 처리할 수 있게 된다.
- 이에 따라 이슈 파악과 그 이슈에 대한 해결이 효율적으로 된다.
2. implment error logging
- logging 모듈을 이용해서 중요한 정보를 포착할 수 있도록 하자. (timestamp, error detail, stack trace)
3. Define Custom Exception class
- 커스텀 예외 클래스를 만듦으로써 에러를 캡슐화하고, 가독성을 높이고 error handling을 더 원활히 할 수 있다.
4. Handle Exceptions Gracefully
- application crash, user 혼동을 막기 위해선 try-except 구문을 사용하는 것이 좋다.
5. Use Finally for Cleanup Tasks
- 예외 상황이든 정상 상황이든 항상 사용할 구문으로 finally 구문을 사용하는 것이 좋다.
Best Practice 와 함께, 에러를 4가지 종류로 체계화하여 분류하고 어떤 조치를 취할 수 있을 지 정리해보고 나니, 앞으로 코드를 작성할 때 error 를 어떻게 처리할 지 감이 잡혔고, 실제 개발에도 많은 도움이 되었다.
에러를 극복할 수 있는지, 할 수 있다면 어떻게 극복할 것인지 스스로 정의하는 것으로 에러를 처리할 방법을 의사결정할 수 있게 되었다.
마지막으로 application 의 crash 를 방지하기 위해 가장 최상위에서 catch 하는 방식도 보았다. 또 주의해야할 것은 보안성이다. 마지막에 글에서 주의를 주었듯 개발환경과 프로덕션 환경을 분리해서, 이 에러 역시 사용자에게 보여주지 않도 하여 지나치게 많은 정보가 사용자쪽에 노출되지 않되 backend 에서만 디버깅 정보로 활용하도록 해야할 것 같다.