| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 열반스쿨기초반
- 프로그래머스 기초
- 기초
- l로 만들기
- 월부닷컴
- 열기반
- 노마드코더
- 풀스택gpt
- Python
- 배열 만들기6
- 파이썬
- 프로그래머스 코딩테스트 기초
- 특별한 이차원 배열 1
- 클린코드
- 프로그래머스
- 코딩테스트
- 배열 만들기3
- 프로그래머스 입문
- 월급쟁이부자들
- 문자열이 몇 번 등장하는지 세기
- 부동산강의
- 세 개의 구분자
- 월부
- 개발자북클럽
- 첫번째로 나오는 음수
- 주사위 게임1
- 0 떼기
- 특정 문자열로 끝나는 가장 긴 문자열 찾기
- 노개북
- 무작위로 k개의 수 뽑기
- Today
- Total
해쟈스토리
방어적 프로그래밍 본문
예외는 대개 호출자에게 잘못을 알려주는 것이다. 예외는 캡슐화를 약화시키기 때문에 신중하게 사용해야한다. 함수에 예외가 많을수록 호출자는 함수에 대해 더 많은 것을 알라야만 한다. 그리고 함수가 너무 많은 예외를 발생시킨다는 것은 문맥에서 자유롭지 않다는 것을 의미한다. 왜냐하면 호출할 때마다 발생 가능한 부작용을 염두에 두고 문맥을 유지해야하기 때문이다.
이것은 함수가 응집력이 약하고 너무 많은 책임을 가지고 있다는 것을 의미할 수도 있다. 만약 함수에서 너무 많은 예외를 발생시켜야 한다면 여러개의 작은 기능으로 나눌 수 있는지 검토해보자
<파이썬 클린코드 2nd edition에서>
위 내용이 잘 이해가 안됬다. 그래서 code를 통해 살펴보기로 했다.
# 개선 전: 많은 예외를 발생시키는 함수
def process_user_data(user_id, data):
if not isinstance(user_id, int):
raise TypeError("User ID must be an integer")
if user_id <= 0:
raise ValueError("User ID must be positive")
if not data:
raise ValueError("Data cannot be empty")
if 'name' not in data:
raise KeyError("Name is required in data")
if 'email' not in data:
raise KeyError("Email is required in data")
if not isinstance(data['name'], str):
raise TypeError("Name must be a string")
if '@' not in data['email']:
raise ValueError("Invalid email format")
# 데이터 처리 로직
print(f"Processing data for user {user_id}: {data}")
# 사용 예시
try:
process_user_data(1, {'name': 'John', 'email': 'john@example.com'})
except (TypeError, ValueError, KeyError) as e:
print(f"Error: {e}")
위 코드를 봤을때 process_user_data를 A라는 개발자가 개발했다고 생각해보자.
B라는 개발자는 사용예시처럼 A개발의 함수를 가져다 쓰게 된다.
프로그램이 예기치않게 종료되는 것을 막기 위해 (trace...어쩌구 나오는 거...) 에러상황을 판단하고 플로우에 의해 종료를 하거나(컨트롤 없이 종료되는 부분을 막는) 프로그램 실행을 유지하는 로직을 넣어야한다.
그래서 B라는 개발자는 try except 구문을 사용해야한다. 왜냐하면 호출하기로한 함수 process_user_data가 raise 구문을 쓰고 있기 때문에 종료가 되어버린다. 그래서 종료를 막고 try로 에러를 잡았을때 컨트롤해서 종료하고 싶을 수 있다.
어쨌든 이런식으로 설계가 되어 있다보니 호출자는 excep 문에 호출 함수가 어떤 에러를 발생하는지 다 알아야 기입할 수 있다. 결국 이 점때문에 캡슐화가 약해진다. (호출자가 함수 내부를 알아야하니까)
또한 위 함수는 변수의 유효성을 검사하고 처리하는 두가지 책임을 가지게 된다.
def validate_user_data(user_id, data):
# 내부적으로 복잡한 유효성 검사 로직을 수행
if not isinstance(user_id, int) or user_id <= 0:
return False
if not data or 'name' not in data or 'email' not in data:
return False
if not isinstance(data['name'], str) or '@' not in data['email']:
return False
return True
def process_user_data(user_id, data):
if not validate_user_data(user_id, data):
print("Invalid user data")
return False
# 유효한 데이터 처리 로직
print(f"Processing data for user {user_id}: {data}")
return True
# 호출자의 코드
def use_process_user_data(user_id, data):
if process_user_data(user_id, data):
print("Data processed successfully")
else:
print("Failed to process data")
# 사용 예시
use_process_user_data(1, {'name': 'John', 'email': 'john@example.com'})
use_process_user_data(-1, {'name': 'Invalid', 'email': 'invalid'})
그래서 위와 같이 함수의 return 값으로 판단할 수 있도록 변경하는 것이 좋다.
호출자는 그냥 data를 넘기기만 한다. 그러면 내부 로직에 의해서 유효한 값이 아니면 에러를 보여주고 조기 종료될 것이고, 정상적인 값이면 로직이 실행된다.
또한 A개발자 입장에서도 코드가 깔끔해지는데, 유효값을 계산하는 로직이 분리되고 함수명을 통해 proccess_user_data함수만 봐도 흐름을 읽을 수 있게되어 유지보수 측면에서도 좋아진다.
그리고 만약 data의 유효로직이 추가가 될 경우, 이전 예제에서는 A개발자가 수정을 하면 B개발자도 try except문에 에러 를 캐치를 로직을 추가해야되서 이중 작업이 된다. 위의 예제로 되었다면 B개발자 입장에서는 코드를 손댈 부분이 없게 된다.
- 예외를 사용하는 방식:
- 장점: 예외적인 상황을 명확히 구분할 수 있다
- 단점: try-except 블록이 필요하며, 여러 예외를 처리해야 할 경우 코드가 복잡해질 수 있다
- 반환 값을 확인하는 방식:
- 장점: 코드가 더 단순하고 직관적입니다. 예외 처리 로직이 필요 없다.
- 단점: 정상적인 반환 값과 오류 메시지를 구분하기 위해 튜플 등을 사용해야 할 수 있다.
결론적으로, 어떤 방식을 선택할지는 상황에 따라 다르다.
- 정말 예외적인 상황(파일을 찾을 수 없음, 네트워크 연결 실패 등)에는 예외를 사용하는 것이 적합할 수 있다.
- 일반적인 오류 상황(잘못된 입력 형식, 유효하지 않은 데이터 등)에는 반환 값을 확인하는 방식이 더 간단하고 효과적일 수 있다.
그래서 책에 있는 코드로 다시 살펴보면,
class DataTransport:
"""다양한 수준의 예외를 처리하는 예"""
_RETRY_BACKOFF: int = 5
_RETRY_TIMES: int = 3
def __init__(self, connector:Connect) -> None:
self._connector = connector
self.connection = None
def deliver_event(self, event:Event):
try:
self.connect()
data = event.decode()
self.send(data)
except ConnectionError as e:
logger.error("%r 이벤트에 잘못된 데이터 포함: %s", event, e)
raise
def connect(self):
for _ in range(self._RETRY_TIMES):
try:
self.connection = self._connector.connect()
except ConnectionError as e:
logger.info("%s: 새로운 커넥션 시도 %is", e, self._RETRY_BACKOFF)
time.sleep(self._RETRY_BACKOFF)
else:
return self.connection
raise ConnectionError(f"연결실패 재시도 횟수 {self._RETRY_TIMES} times")
def send(self, data:bytes):
return self.connection.send(data)
위 내용은 앞서 본 내용과 같은 맥락인데, deliver_event 함수 입장에서 보면 self.connect()를 시도하고 data를 decode하고 send하면 되는데 연결이 됬는지를 확인하고 있다. 이는 불필요한 작업이 된다.
그래서 deliver_event에서는 try 문을 없애야한다.
class DataTransport:
"""다양한 수준의 예외를 처리하는 예"""
_RETRY_BACKOFF: int = 5
_RETRY_TIMES: int = 3
def init(self, connector:Connect) -> None:
self._connector = connector
self.connection = None
def dedliver_event(self, event:Event):
self.connection = connect_with_retry(self._connector, self._RETRY_TIMES, self._RETRY_BACKOFF)
self.send(data)
def connect(self):
for _ in range(self._RETRY_TIMES):
try:
self.connection = self._connector.connect()
except ConnectionError as e:
logger.info("%s: 새로운 커넥션 시도 %is", e, self._RETRY_BACKOFF)
time.sleep(self._RETRY_BACKOFF)
else:
return self.connection
raise ConnectionError(f"연결실패 재시도 횟수 {self._RETRY_TIMES} times")
def send(self, event:Event):
try:
return self.connection.send(event.decode())
except ValueError as e:
logger.error("%r 잘못된 데이터 포함: %s", event, e)
raise
책에서는 위와 같이 바꿀 수 있다고 되어 있다. 처음에 self.connection.send되어 있길래 DataTransport 클래스 내에 이런
메서드가 있었나 싶었는데 Connect 객체에서 가지고 있을것이다 라는 전제다. (ex. MySQL connector....에는 send가 없지만)
그리고 위 코드를 보면 connect_with_retry에 의해서 에러가 발생했을 때 raise 구문에 의해 프로그램이 종료되는데 앞에서 이야기한 논리에 따르면 반환값을 확인하는 방식이 더 나은데 왜 이렇게 만들었을까???

예외를 자체적으로 처리하는 것은 여러가지를 의미할 수 있다. 가장 간단한 예로 단지 예외 상황을 로깅할 수 있다(logger.exception 또는 logger.error를 사용하여 발생한 일의 전체 컨텍스트를 제공해야한다) 다른 방법으로는 기본값을 반환하는 것이다. 여기서 말하는 기본값은 오류를 발견하기 전이 아니라 오직 오류를 발견한 뒤에만 사용하는 값이다. 또는 기존 오류와 가른 새로운 예외를 발생시킬 수도 있다
<파이썬 클린코드 2nd edition에서>
예외 처리에는 다양한 방법이 존재한다.
- 예외 로깅:
- 가장 간단한 방법으로, 예외가 발생했을 때 이를 기록하는 것이다.
- logger.exception 또는 logger.error를 사용하여 예외의 전체 상황(컨텍스트)을 기록한다.
- 이는 나중에 문제를 분석하고 해결하는 데 도움이 된다.
import logging
logging.basicConfig(level=logging.ERROR)
logger = logging.getLogger(__name__)
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
logger.error(f"나누기 오류 발생: {a}를 0으로 나누려고 했습니다.")
return None
return result
print(divide(10, 2)) # 정상적으로 5 출력
print(divide(10, 0)) # None 출력, 로그에 오류 메시지 기록
- 기본값 반환:
- 예외가 발생했을 때 미리 정해둔 기본값을 반환하는 방법이다.
- 여기서 중요한 점은 이 기본값이 정상적인 상황에서 사용되는 값이 아니라, 오직 오류가 발생했을 때만 사용되는 특별한 값이라는 것이다.
- 예를 들어, 데이터를 가져오는 함수에서 오류가 발생하면 빈 리스트를 반환할 수 있다.
def get_user_data(user_id):
try:
# 데이터베이스에서 사용자 정보를 가져오는 코드라고 가정
# 여기서 오류가 발생할 수 있습니다.
user_data = database.fetch_user(user_id)
except DatabaseError:
# 오류 발생 시 빈 딕셔너리 반환
return {}
return user_data
# 사용 예
data = get_user_data(123)
if data:
print("사용자 정보:", data)
else:
print("사용자 정보를 가져오는 데 실패했습니다.")
- 새로운 예외 발생:
- 원래 발생한 예외를 잡은 후, 이를 다른 종류의 예외로 변환하여 다시 발생시키는 방법이다.
- 이는 보통 더 구체적이거나 해당 상황에 더 적합한 예외를 사용하고자 할 때 활용된다.
class DataValidationError(Exception):
pass
def process_data(data):
try:
# 데이터 처리 코드
result = complex_data_processing(data)
except ValueError as e:
# ValueError를 잡아서 우리가 정의한 DataValidationError로 변환
raise DataValidationError(f"데이터 검증 실패: {str(e)}")
return result
# 사용 예
try:
processed_data = process_data(some_data)
except DataValidationError as e:
print(f"오류 발생: {e}")
# 여기서 DataValidationError에 대한 특별한 처리를 할 수 있습니다.
명시적으로 오류를 무시하려면 contextlib.suppress 함수를 이용하는 것이 좋다.
주로 예외가 발생해도 프로그램의 실행에 영향을 주지 않을 때 사용한다.
기본사용법
from contextlib import suppress
with suppress(예외1, 예외2, ...):
# 예외가 발생할 수 있는 코드
예제
from contextlib import suppress
import os
# 파일 삭제 예제
def delete_file(filename):
with suppress(FileNotFoundError):
os.remove(filename)
# 사용
delete_file("존재하지_않는_파일.txt") # 오류 없이 실행됨
delete_file("실제_존재하는_파일.txt") # 파일이 존재하면 삭제됨
위 예제는 파일이 없으면 오류를 발생시키지 않는다. 그냥 무시하고 넘어간다.
그러면 try except는 언제 사용하고, contextlib.supress는 언제 사용되는 걸까?
contextlib.suppress 사용이 좋은 경우:
- 특정 예외를 단순히 무시하고 싶을 때
- 코드를 더 간결하게 만들고 싶을 때
- 예외 처리에 추가 로직이 필요 없을 때
- 의도적으로 예외를 무시한다는 것을 명확히 표현하고 싶을 때
from contextlib import suppress
import os
with suppress(FileNotFoundError):
os.remove('some_file.txt')
try-except 사용이 좋은 경우:
- 예외 발생 시 추가적인 처리가 필요할 때
- 여러 종류의 예외를 다르게 처리해야 할 때
- 예외 정보를 로깅하거나 사용자에게 알려야 할 때
- 예외 발생 여부에 따라 다른 동작을 해야 할 때
try:
with open('some_file.txt', 'r') as file:
content = file.read()
except FileNotFoundError:
print("파일을 찾을 수 없습니다.")
content = ""
except PermissionError:
print("파일을 읽을 권한이 없습니다.")
raise
여기까지만 봤을때 둘의 차이점을 잘 모르겠었다.
둘의 차이점은 아래와 같다.
- suppress는 예외를 완전히 무시한다. 추가 작업을 수행할 수 없다.
- try-except는 예외 발생 시 추가 작업을 수행할 수 있다(로깅, 사용자에게 알림 등).
raise <e> from <original_exception> 의 사용.
파이썬의 예외 연결 기능이다. 이 기능을 통해 예외의 원인을 더 명확하게 표현할 수 있다.
try:
# 일부 코드
except SomeException as original_exception:
raise NewException("새로운 오류 메시지") from original_exception
- 목적:
- 원래 발생한 예외(original_exception)를 새로운 예외(NewException)의 직접적인 원인으로 지정한다.
- 이를 통해 예외의 발생 경로를 추적할 수 있게 된다.
- 동작 방식:
- 새로운 예외가 발생할 때, 원래의 예외가 __cause__ 속성에 저장된다.
- 예외 트레이스백에 두 예외의 정보가 모두 표시된다.
class DatabaseError(Exception):
pass
class DataProcessingError(Exception):
pass
def fetch_data():
# 데이터베이스 연결 오류를 시뮬레이션
raise DatabaseError("데이터베이스 연결 실패")
def process_user_data(user_id):
try:
data = fetch_data()
# 데이터 처리 로직...
except DatabaseError as original_error:
raise DataProcessingError(f"사용자 ID {user_id}의 데이터 처리 실패") from original_error
# 사용
try:
process_user_data(12345)
except DataProcessingError as e:
print(f"오류 발생: {e}")
if e.__cause__:
print(f"원인: {e.__cause__}")
이 예제에서 발생하는 일:
- fetch_data() 함수에서 DatabaseError가 발생한다.
- process_user_data() 함수는 이 오류를 잡아서 DataProcessingError로 변환한다.
- 새로운 DataProcessingError는 원래의 DatabaseError를 직접적인 원인으로 가진다.
출력 결과는 다음과 같다:
오류 발생: 사용자 ID 12345의 데이터 처리 실패
원인: 데이터베이스 연결 실패
장점:
- 오류의 맥락을 보존한다. 상위 레벨의 오류(DataProcessingError)와 그 원인(DatabaseError)을 모두 볼 수 있다.
- 디버깅이 쉬워진다. 문제의 근본 원인을 쉽게 추적할 수 있다.
- 코드의 추상화 레벨을 유지하면서도 상세한 오류 정보를 제공할 수 있다.
__debug__ 변수에 대해

assert 문
어설션 문장에 설명이 포함된 오류 메시지를 작성하여 나중에 디버깅하고 수정할 수 있도록 해야한다. 앞의 예제 코드가 나쁜 또 다른 중요한 이유는 AssetionError를 처리하는 것 외에도 어설면 문장이 함수라는 것이다. 함수 호출은 부작용을 가질 수 있으며 항상 반복 가능하지는 앖다(사실 confition.holds()를 다시 호출했을 때 같은 결과가 나올지도 모른다.) 또한 디버거를 사용해 해당 라인에서 중지하여 오류 결과를 편리하게 볼 수 없으며, 다시 함수를 호출한다 하더라도 잘못된 값이었는지 알 수 없다. 보다 나은 방법은 코드를 줄이고 유용한 정보를 추가하는 것이다.
<파이썬 클린코드 2nd edition> 에서
이와 함께 제시된 예시는 아래와 같다
try:
assert condition.holds()
except AssertionError:
alternative_procedure()
- 어설션 메시지의 중요성:
- 어설션에 설명적인 오류 메시지를 포함해야 한다. 이는 나중에 디버깅할 때 매우 유용하다
- 문제점 1: AssertionError 처리
- 코드에서 AssertionError를 잡아 대체 절차를 실행하고 있다. 이는 어설션의 본래 목적(프로그램의 올바른 동작 보장)에 어긋난다.
- 문제점 2: 함수 호출 사용
- condition.holds()는 함수 호출이다. 이는 다음과 같은 문제를 야기할 수 있다: a. 부작용: 함수가 프로그램의 상태를 변경할 수 있다. b. 비일관성: 같은 함수를 다시 호출했을 때 다른 결과가 나올 수 있다. c. 디버깅 어려움: 디버거로 해당 라인에서 중지해도 실제 값을 직접 볼 수 없다.
- 개선된 방법:
- 코드를 간결하게 만들고, 더 많은 정보를 제공하는 것이 좋다.
value = condition.holds()
assert value, f"Condition failed: expected True but got {value}"
이 개선된 버전의 장점:
- 함수 호출 결과를 변수에 저장하여 부작용 및 비일관성 문제를 방지한다.
- 실제 값을 오류 메시지에 포함시켜 더 많은 정보를 제공한다.
- AssertionError를 처리하지 않아 어설션의 본래 목적을 유지한다.
- 디버거로 중단점을 설정했을 때 value의 실제 값을 쉽게 확인할 수 있다.
이렇게 하면 코드의 의도가 더 명확해지고, 디버깅이 용이해지며, 어설션의 본래 목적(프로그램의 정확성 확인)에 더 부합하게 된다.
'파이썬 > 클린코드' 카테고리의 다른 글
| 클린코드 책 읽기 day1 (0) | 2024.08.24 |
|---|---|
| 결합도 (0) | 2024.08.19 |
| dataclasses 모듈 (1) | 2024.06.17 |
| Property (0) | 2024.06.14 |
| 파이썬에서의 밑줄 (2) | 2024.06.14 |