일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 알고리즘
- 데이터사이언스
- Boostcamp AI
- 부스트캠프
- Coursera
- AI Mathematics
- 클린코드
- 코딩테스트
- 데이터과학
- programmers
- 코테
- 프로그래머스
- 깨끗한 코드
- softeer
- Java
- 코세라
- Data Science
- 오블완
- string
- 문자열
- 티스토리챌린지
- 클린코드 파이썬
- Clean Code
- 파이썬
- Python
- IBM
- data science methodology
- 데이터 사이언스
- 자바
- 소프티어
- Today
- Total
떼닝로그
[7장] 오류 처리 본문
오류 처리는 중요하다. 하지만 오류 처리로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.
오류 코드보다 예외를 사용하라
- 예외를 지원하지 않는 언어는 오류를 처리하고 보고하는 방법이 제한적이었다. 아래의 코드를 보자.
class DeviceController():
...
def sendShutDown(self):
self.handle = self.getHandle(self.DEV1)
# 디바이스 상태 점검
if self.handle != DeviceHandle.INVALID:
# 레코드 필드에 디바이스 상태를 저장한다.
self.retrieveDeviceRecord(self.handle)
# 디바이스가 일시정지 상태가 아니라면 종료한다.
if self.record.getStatus() != DEVICE_SUSPENDED:
self.pauseDevice(self.handle)
self.clearDeviceWorkQueue(self.handle)
self.closeDevice(self.handle)
else:
logging.warn("Device suspended. Unable to shut down")
else:
logging.warn("Invalid handle for : " + str(DEV1))
- 위와 같은 방법을 사용하면 함수를 호출한 즉시 오류를 확인해야 하기 때문에 호출자 코드가 복잡해진다.
- 호출자 코드를 더 깔끔하게 사용하기 위해서는 오류가 발생하면 예외를 던져야 한다. 그래야 논리가 오류 처리 코드와 뒤섞이지 않는다.
class DeviceController():
def sendShutDown(self):
try:
self.tryToShutDown()
except DeviceShutDownError e:
logging.warn(e)
def tryToShutDown(self):
self.handle = self.getHandle(DEV1)
self.record = self.retrieveDeviceRecord(self.handle)
self.pauseDevice(self.handle)
self.clearDeviceWorkQueue(self.handle)
self.closeDevice(self.handle)
def getHandle(self, id):
raise DeviceShutDownError("Invalid handle for : " + str(id))
- 코드가 보기 좋아졌고, 품질도 좋아졌다.
- 디바이스를 종료하는 알고리즘과, 오류를 처리하는 알고리즘을 분리했기 때문.
Try-Catch-Finally 문부터 작성하라
- try-catch-finally문에서 try 블록에 들어가는 코드를 실행하면 어느 시점에서든 실행이 중단된 후 catch 블록으로 넘어갈 수 있다.
- try 블록은 트랜잭션과 비슷하다. 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 하기 때문에
- 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫다.
- try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.
# 파일이 없으면 예외를 던지는지 알아보는 단위 테스트
def retrieveSelectionShouldThrownOnInvalidFileName():
try:
sectionStore.retrieveSection("invalid - file")
except expected = StorageException.class e:
pass
- 위의 코드는 예외를 던지지 않으므로 단위 테스트는 실패한다. 아래는 예외를 던진다.
def retrieveSection(sectionName):
try:
stream = FileIinputStream(sectionName)
except Exception e:
raise StorageException("retrieval error", e)
return list(RecordedGrip)
- 코드가 예외를 던지므로 리팩터링이 가능하다.
- catch 블록에서 예외 유형을 좁혀 실제로 FileInputStream 생성자가 던지는 FileNotFoundException을 잡아낸다.
def retrieveSection(sectionName):
try:
stream = FileInputStream(sectionName)
stream.close()
except FileNotFoundException e:
raise StorageException("retrieval error", e)
return list(RecordedGrip)
- try-catch 구조로 범위를 정의했으므로, TDD를 사용해 필요한 나머지 논리를 추가할 수 있다.
- 먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다.
- 그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로 범위 내에서 트랜잭션 본질을 유지하기 쉬워진다.
미확인(unchecked) 예외를 사용하라
- 안정적인 소프트웨어를 제작하는 요소로 확인된 예외가 반드시 필요하지는 않는다.
- 확인된 오류가 치르는 비용에 상응하는 이익을 제공하는지 (철저히) 따져봐야 한다.
- 확인된 예외는 OCP(Open Closed Principle)를 위반한다.
- 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다.
- 모듈과 관련된 코드가 전혀 바뀌지 않았더라도 (선언부가 바뀌었으므로) 모듈을 다시 빌드한 다음 배포해야 한다.
- 오류를 원거리에서 처리하기 위해 예외를 사용한다는 사실을 감안한다면 확인된 예외가 캡슐화를 꺠버리는 현상은 참으로 유감스럽다.
- 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 크다.
예외에 의미를 제공하라
- 예외를 던질 때는 전후 상황을 충분히 덧붙인다. 그래야 오류가 발생한 원인과 위치를 찾기 쉬워진다.
- 오류 메시지에 정보를 담아 예외와 함게 던진다.
- 애플리케이션이 로깅 기능을 사용한다면 catch(python의 except) 블록에서 오류를 기록하도록 충분한 정보를 넘겨준다.
호출자를 고려해 예외 클래스를 정의하라
- 오류가 발생한 위치로 분류가 가능하다.
- 애플리케이션에서 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.
- 아래 코드는 오류를 형편없이 분류한 사례다. 외부 라이브러리가 던질 예외를 모두 잡아낸다.
port = ACMEPort(12)
try:
port.open()
except DeviceResponseException e:
reportPortError(e)
logging.warn("Device response exception", e)
except ATM1212UnlockedException e:
reportPortError(e)
logging.warn("Unlock exception", e)
except GMXError e:
reportPortError(e)
logging.warn("Device response exception")
finally:
pass
- 대다수 상황에서 우리가 오류를 처리하는 방식은 오류를 기록하고, 프로그램을 계속 수행해도 좋은지 확인하는 방식으로 일정하다.
- 위 경우는 예외 유형과 무관하게 예외에 대응하는 방식이 거의 동일하다. 그래서 코드를 간결하게 고치기가 아주 쉽다.
class LocalPort():
def __init__(self):
self.innerPort = ""
def LocalPort(self, portNumber):
self.innerPort = ACMEPort(portNumber)
def open(self):
try:
self.innerPort.open()
except DeviceResponseException e:
raise PortDeviceFailure(e)
except ATM1212UnlockedException e:
raise PortDeviceFailure(e)
except GMXError e:
raise PortDeviceFailure(e)
- LocalPort 클래스처럼 ACMEPort를 감싸는 클래스는 매우 유용하다. 실제로 외부 API를 사용할 때는 감싸기 기법이 최선이다.
- 외부 API를 감싸면 외부 라이브러리와 프로그램 사이에서 의존성이 크게 줄어들고, 나중에 다른 라이브러리도 갈아타도 비용이 적고, 감싸기 클래스에서 외부 API를 호출하는 대신 테스트 코드를 넣어주는 방법으로 프로그램을 테스트하기도 쉬워진다.
- 감싸기 기법을 사용하면 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다. 프로그램이 사용하기 편리한 API를 정의하면 그만이기 때문.
- 예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우에 예외 클래스가 하나만 있어도 충분한 코드가 있다.
- 한 예외는 잡아내고 다른 예외는 무시해도 괜찮은 경우라면 여러 예외 클래스를 사용한다.
정상 흐름을 정의하라
- 비즈니스 논리와 오류 처리가 잘 분리된 코드를 사용하면서 깨끗하고 간결한 알고리즘으로 보이기 시작하게 되면 오류 감지가 프로그램 언저리로 밀려나게 된다.
- 외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리하는 방식이 일반적이지만, 때로는 중단이 적합하지 않은 때도 있다.
- 아래는 비용 청구 애플리케이션에서 총계를 계산하는 허술한(ㅠㅠ) 코드이다.
try:
expenses = expenseReportDA0.getMeals(employee.getID())
m_total += expenses.getTotal()
except MealExpensesNotFound e:
m_total += getMealPerDiem()
- 위의 코드는 특수 상황을 처리하고 있기 때문에 예외가 논리를 따라가게 어렵게 만든다.
expenses = expenseReportDA0getMeals(employee.getID())
m_total += expenses.getTotal()
- 위의 코드는 ExpenseReportDA0를 고쳐 언제나 MealExpense 객체를 반환하게 함으로써 가능해진다.
class PerDiemMealExpenses(MealExpenses):
def getTotal():
# 기본값으로 일일 기본 식비를 반환한다.
- 이와 같은 것들을 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는, 특수 사례 패턴(SPECIAL CASE PATTERN)이라 부른다.
- 그러면 클래스나 객체가 예외적인 상황을 캡슐화해서 처리하므로 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다.
NULL을 반환하지 마라
- NULL을 반환하는 습관을 저지르는 바람에 오류를 유발하기도 한다.
def registerItem(item:Item):
if item != NULL:
registry = persistentStore.getItemRegistry()
if registry != NULL:
existing = registry.getItem(item.getId())
if existing.getBillingPeriod().hasRetailOwner():
existing.register(item)
- 위 코드는 나쁜 코드다. NULL을 반환하는 코드는 일거리를 늘릴 뿐만 아니라 호출자에게 문제를 떠넘긴다. 누구 하나라도 NULL확인을 빼먹는다면 애플리케이션이 통제 불능에 빠질지도 모른다.
- 위 코드는 NULL 확인이 누락된 문제라 말하기 쉽지만, 실상은 NULL 확인이 너무 많아 문제다.
- 메서드에서 NULL을 반환하고픈 유혹이 든다면 그 대신 예외를 던지거나 특수 사례 객체를 반환한다.
- 많은 경우에 특수 사례 객체가 손쉬운 해결책이다.
employees = getEmployees()
if employees != NULL:
for e in employees:
totalPay += e.getPay()
- 위에서 getEmployees가 NULL을 반환하는 대신 빈 리스트를 반환한다면 코드가 훨씬 깔끔해질 수 있을 것이다.
employees = getEmployees()
for e in employees:
totalPay += e.getPay()
- 자바의 Collections.emptyList()를 사용함으로써 코드가 깔끔해지도록 할 수 있고, NullPointerException을 피할 수도 있다.
NULL을 전달하지 마라
- 정상적인 인수로 NULL을 기대하는 API가 아니라면 메서드로 NULL을 전달하는 코드는 최대한 피한다.
- 아래는 두 지점 사이의 거리를 계산하는 간단한 메서드다.
class MetricsCalculator:
def xProjection(self, p1, p2):
return (p2.x - p1.x) * 1.5
- 누군가 인수로 NULL을 전달하면 당연히 NullPointerException이 발생한다.
calculator.xProjection(NULL, Point(12, 13))
- 이를 해결하기 위해 새로운 예외 유형을 만들어 던지는 방법이 있다.
class MetricsCalculator:
def xProjection(self, p1, p2):
if p1 == NULL or p2 == NULL:
raise InvalidArgumentException("Invalid argument for MetricsCalculator.xProjection")
return (p2.x - p1.x) * 1.5
- 위 코드는 NullPointerException보다는 낫지만 InvalidArgumentException을 잡아내는 처리기가 필요하다.
- 아래는 assert문을 사용하는 방법이다.
class MetricsCalculator:
def xProjection(self, p1, p2):
assert p1 != NULL, "p1 should not be NULL"
assert p2 != NULL, "p2 should not be NULL"
return (p2.x - p1.x) * 1.5
- 문서화가 잘 되어 있어 코드 읽기는 편하지만 문제를 해결하지는 못한다. 누군가 NULL을 전달하면 여전히 실행 오류가 발생한다.
- 대다수 프로그래밍 언어는 호출자가 실수로 넘기는 NULL을 적절히 처리하는 방식이 없다.
- 인수로 NULL이 넘어가면 코드에 문제가 있다는 것을 알리는 정책을 따르면 그만큼 부주의한 실수를 저지를 확률도 작아진다.
결론
- 깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다.
- 오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 깨끗한 코드를 작성할 수 있다.
- 오류 처리를 프로그램 논리와 분리하면 독립적인 추론이 가능해지며 코드 유지보수성도 크게 높아진다.
'개발로그 > Clean Code' 카테고리의 다른 글
[9장] 단위 테스트 (1) | 2023.01.01 |
---|---|
[8장] 경계 (0) | 2022.12.26 |
[6장] 객체와 자료 구조 (0) | 2022.12.09 |
[5장] 형식 맞추기 (0) | 2022.12.02 |
[4장] 주석 (0) | 2022.11.16 |