7장 오류 처리
1. 📌 핵심 개념 정리
✅ 요약하기
1. 오류 코드보다 예외를 사용하라
- 오류 플래그 설정이나 오류 코드 반환 방식은 호출자 코드를 복잡하게 만들고, 함수 호출 직후 오류를 확인해야 하므로 가독성이 떨어집니다.
- 예외를 던지는 방식은 오류 처리 로직과 핵심 동작 로직을 분리하여 호출자 코드를 더 깔끔하게 만들어 코드 품질을 향상시킵니다.
- 개선 전:
public class DeviceController { ... public void sendShutDown() { DeviceHandle handle = getHandle(DEV1); if (handle != DeviceHandle.INVALID) { retrieveDeviceRecord(handle); if (record.getStatus() != DEVICE_SUSPENDED) { pauseDevice(handle); clearDeviceWorkQueue(handle); closeDevice(handle); } else { logger.log("Device suspended. Unable to shut down"); } } else { logger.log("Invalid handle for: " + DEV1.toString()); } } ... }
- 개선 후:
public class DeviceController { ... public void sendShutDown() { try { tryToShutDown(); } catch (DeviceShutDownError e) { logger.log(e); } } private void tryToShutDown() throws DeviceShutDownError { DeviceHandle handle = getHandle(DEV1); DeviceRecord record = retrieveDeviceRecord(handle); pauseDevice(handle); clearDeviceWorkQueue(handle); closeDevice(handle); } private DeviceHandle getHandle(DeviceID id) { ... throw new DeviceShutDownError("Invalid handle for: " + id.toString()); ... } ... }
2. Try-Catch-Finally 문부터 작성하라
- 예외는 프로그램 내에 범위를 정의하는 역할을 하며,
try
블록은 트랜잭션과 유사하게 동작합니다. catch
블록은try
블록에서 어떤 일이 발생하더라도 프로그램 상태를 일관성 있게 유지해야 합니다.- 예외가 발생할 가능성이 있는 코드를 작성할 때는
try-catch-finally
문부터 작성하여 호출자가 기대하는 상태를 정의하기 쉽게 만드는 것이 좋습니다. - 강제로 예외를 일으키는 테스트 케이스를 먼저 작성한 후 테스트를 통과하도록 코드를 작성하는 TDD 방식을 권장합니다. 이는
try
블록의 트랜잭션 범위를 먼저 구현하여 범위 내에서 트랜잭션의 본질을 유지하는 데 도움이 됩니다. - 개선 전:
@Test(expected = StorageException.class) public void retrieveSectionShouldThrowOnInvalidFileName() { sectionStore.retrieveSection("invalid - file"); } public List retrieveSection(String sectionName) { return new ArrayList(); }
- 개선 후:
public List retrieveSection(String sectionName) { try { FileInputStream stream = new FileInputStream(sectionName); stream.close(); } catch (FileNotFoundException e) { throw new StorageException("retrieval error", e); } return new ArrayList(); }
3. 미확인 예외를 사용하라
- 과거에는 확인된 예외가 안정적인 소프트웨어 제작에 필수적이라고 여겨졌고, 메서드 선언 시 던질 수 있는 모든 예외를 명시하고 이를 처리하도록 강제했습니다.
- 하지만 현재는 확인된 예외가 반드시 필요한 것은 아니라는 사실이 분명해졌으며, C#, C++, 파이썬, 루비 등 확인된 예외를 지원하지 않는 언어에서도 안정적인 소프트웨어 개발이 가능합니다.
- 확인된 예외는 OCP(Open Closed Principle)를 위반할 수 있습니다. 하위 단계의 메서드에서 확인된 예외를 던질 때, 상위 단계의 메서드들이 해당 예외를 선언부에 추가해야 하므로, 하위 코드 변경이 상위 코드까지 영향을 미치는 의존성 문제가 발생합니다. 일반적인 애플리케이션에서는 이러한 의존성으로 인한 비용이 이점보다 클 수 있습니다.
- 미확인 예외 (
RuntimeException
상속 예외)는 예외 처리를 강제하지 않아 이러한 의존성 문제를 완화합니다. 예시로는NullPointerException
,ArithmeticException
,IndexOutOfBoundsException
등이 있습니다. - 중요한 라이브러리를 작성하는 경우에는 모든 예외를 잡아 처리하는 확인된 예외가 유용할 수 있습니다. 그러나 일반적인 애플리케이션에서는 미확인 예외를 사용하는 것이 더 유연하고 유지보수성이 높을 수 있습니다.
4. 예외에 의미를 제공하라
- 예외를 던질 때는 오류가 발생한 원인과 위치를 쉽게 파악할 수 있도록 전후 상황을 충분히 덧붙여야 합니다.
- 자바는 호출 스택을 제공하지만, 실패한 코드의 의도를 파악하기에는 부족할 수 있습니다.
- 오류 메시지에 실패한 연산 이름과 실패 유형 등의 정보를 담아 예외와 함께 던져야 합니다.
- 애플리케이션이 로깅 기능을 사용한다면,
catch
블록에서 오류를 기록할 때 충분한 정보를 넘겨주어 문제 해결에 도움을 줍니다.
5. 호출자를 고려해 예외 클래스를 정의하라
- 애플리케이션에서 오류를 정의할 때 가장 중요한 고려 사항은 오류를 어떻게 잡아낼 것인가입니다.
- 오류는 발생 위치, 발생 컴포넌트, 발생 유형 (예: 디바이스 실패, 네트워크 실패, 프로그래밍 오류, 애플리케이션 오류) 등으로 분류할 수 있습니다.
- 외부 API를 사용할 때는 감싸기(wrapper) 기법이 최선입니다. 이는 외부 라이브러리와 프로그램 사이의 의존성을 크게 줄여주고, 나중에 다른 라이브러리로 교체하는 비용을 절감하며, 테스트 코드 작성을 용이하게 합니다. 또한, 특정 업체의 API 설계 방식에 종속되지 않고 프로그램에 더 편리한 API를 정의할 수 있습니다.
- 대부분의 경우 예외 클래스 하나로 충분한 코드가 많습니다. 예외 클래스에 포함된 정보로 오류를 구분할 수 있는 경우입니다. 하지만, 특정 예외는 잡아내고 다른 예외는 무시해야 하는 경우에는 여러 예외 클래스를 사용하는 것이 적절합니다.
- 개선 전:
ACMEPort port = new ACMEPort(12); try { port.open(); } catch (DeviceResponseException e) { reportPortError(e); logger.log("Device response exception", e); } catch (ATM1212UnlockedException e) { reportPortError(e); logger.log("Unlock exception", e); } catch (GMXError e) { reportPortError(e); logger.log("Device response exception"); } finally { ... }
- 개선 후:
public class LocalPort { private ACMEPort innerPort; public LocalPort(int portNumber) { innerPort = new ACMEPort(portNumber); } public void open() { try { innerPort.open(); } catch (DeviceResponseException e) { throw new PortDeviceFailure(e); } catch (ATM1212UnlockedException e) { throw new PortDeviceFailure(e); } catch (GMXError e) { throw new PortDeviceFailure(e); } } ... } LocalPort port = new LocalPort(12); try { port.open(); } catch (PortDeviceFailure e) { reportError(e); logger.log(e.getMessage(), e); } finally { ... }
6. 정상 흐름을 정의하라
- 예외 처리를 통해 중단된 계산을 처리하는 것은 일반적이지만, 때로는 중단이 적합하지 않은 상황도 있습니다.
- 특수 사례 패턴은 클래스를 만들거나 객체를 조작하여 예외적인 상황을 처리하는 방식입니다. 이를 통해 클라이언트 코드가 예외적인 상황을 명시적으로 처리할 필요가 없어지고, 예외적인 상황이 클래스나 객체 내부에 캡슐화되어 처리됩니다.
- 개선 전:
try { MealExpenses expenses = expenseReportDAO.getMeals(employee.getID()); m_total += expenses.getTotal(); } catch(MealExpensesNotFound e) { m_total += getMealPerDiem(); }
- 개선 후:
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID()); m_total += expenses.getTotal(); ... public class PerDiemMealExpenses implements MealExpenses { public int getTotal() { // return the per diem default } }
ExpenseReportDAO
를 수정하여 청구된 식비가 없으면 일일 기본 식비를 반환하는MealExpense
객체를 반환하도록 변경하여 예외 처리를 없앴습니다.
7. null을 반환하지 마라
null
을 반환하는 코드는 호출자에게 추가적인null
확인 의무를 부과하여 일거리를 늘리고,NullPointerException
발생 가능성을 높여 애플리케이션을 불안정하게 만듭니다. 누구라도null
확인을 누락하면 예기치 않은 오류가 발생할 수 있습니다.- 메서드에서
null
을 반환하고 싶다면, 그 대신 예외를 던지거나 특수 사례 객체를 반환하는 것을 고려해야 합니다. - 많은 경우에 **특수 사례 객체 (예: 빈 리스트)**가 더 나은 해결책입니다.
- 개선 전:
public void registerItem(Item item) { if (item != null) { ItemRegistry registry = peristentStore.getItemRegistry(); if (registry != null) { Item existing = registry.getItem(item.getID()); if (existing.getBillingPeriod().hasRetailOwner()) { existing.register(item); } } } } List employees = getEmployees(); if (employees != null) { for(Employee e : employees) { totalPay += e.getPay(); } }
- 개선 후:
public List getEmployees() { if( .. there are no employees .. ) return Collections.emptyList(); } List employees = getEmployees(); for(Employee e : employees) { totalPay += e.getPay(); }
8. null을 전달하지 마라
- 메서드로
null
을 전달하는 것은null
을 반환하는 것보다 더 나쁜 습관입니다. 정상적인 인수로null
을 기대하는 API가 아니라면, 메서드로null
을 전달하는 코드는 최대한 피해야 합니다. - 해결 방법으로는 다음과 같은 것들이 있습니다:
- 새로운 예외 유형을 만들어 던지는 방법:
public class MetricsCalculator { public double xProjection(Point p1, Point p2) { if(p1 == null || p2 == null){ throw new IllegalArgumentException("Invalid argument for MetricsCalculator.xProjection"); } return (p2.x - p1.x) * 1.5; } }
assert
문을 사용하는 방법:assert
문은 코드의 특정 조건이 참인지 확인하는 데 사용되며, 디버깅 목적으로 유용합니다. 하지만 런타임 시 비활성화될 수 있으므로, 중요한 유효성 검사에는 예외를 사용하는 것이 더 적절합니다.public class MetricsCalculator { public double xProjection(Point p1, Point 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
을 넘기지 못하도록 금지하는 정책이 합리적입니다.
2. 🤔 이해가 어려운 부분
🔍 질문하기
1. 미확인 예외를 사용하라
- 어려웠던 부분: 확인된 예외의 장점에도 불구하고 왜 미확인 예외를 사용하는 것이 더 권장되는지, OCP 위반이 구체적으로 어떤 문제를 야기하는지 이해하기 어려웠습니다.
- 이해한 점:
- 확인된 예외는 컴파일 타임에 반드시 처리해야 하는 예외로,
try-catch
로 처리하거나throws
키워드로 호출자에게 위임해야 합니다.RuntimeException
을 상속하지 않는IOException
,SQLException
,FileNotFoundException
,InterruptedException
등이 있습니다. - 미확인 예외는
RuntimeException
을 상속받는 예외로, 예외 처리를 강제하지 않아도 컴파일 에러가 발생하지 않지만 실행 중에 오류가 발생할 수 있습니다. 잘못된 코드 로직으로 인해 발생하는 경우가 많으며,NullPointerException
,ArrayIndexOutOfBoundsException
,ArithmeticException
등이 있습니다. - OCP 위반: 하위 함수에서 새로운 확인된 예외가 발생하면, 그 상위 함수들 모두의 선언부에 해당 예외를 추가하거나 처리해야 하므로 코드 변경의 범위가 넓어지고 캡슐화가 깨지는 문제가 발생합니다. 반면, 미확인 예외는 이러한 연쇄적인 코드 수정을 유발하지 않아 OCP를 준수하는 데 도움이 됩니다.
- 확인된 예외는 컴파일 타임에 반드시 처리해야 하는 예외로,
2. 정상 흐름을 정의하라
- 어려웠던 부분: 특수 사례 패턴이 예외 처리 코드 없이 자연스럽게 동작하도록 하는 설계 패턴이라는 것은 이해했지만, 구체적으로 어떤 방식으로 예외적인 상황을 캡슐화하여 처리하는지 명확하게 와닿지 않았습니다.
- 이해한 점: 특수 사례 패턴은 예외적인 상황을 나타내는 별도의 클래스나 객체를 만들어서 클라이언트 코드에서 예외 발생 여부를 확인하거나 처리하는 대신, 해당 특수 사례 객체의 메서드를 호출하여 미리 정의된 기본 동작을 수행하도록 합니다. 예를 들어, 데이터를 찾지 못했을 경우
null
대신 기본 값을 가진 특수 객체를 반환하여NullPointerException
을 방지하고, 예외 처리 로직 없이 정상적인 흐름을 이어갈 수 있도록 합니다.
3. 예외 클래스 활용
- 어려웠던 부분: 예시 코드를 보았지만, 예외 클래스 감싸기(wrapper)의 장점이 실질적으로 어떻게 도움이 되는지 명확하게 이해하기 어려웠습니다.
- 궁금한 점: 어디서, 무엇이 장점인지 구체적으로 알고 싶습니다.
- 이해한 점:
- 외부 라이브러리와의 의존성 줄이기:
LocalPort
와 같이 중간에 감싸는 클래스를 두면,ACMEPort
라는 외부 라이브러리를 직접 사용하는 대신LocalPort
를 통해 간접적으로 사용하게 됩니다. 따라서ACMEPort
가 변경되더라도LocalPort
의 내부 코드만 수정하면 되므로, 전체 코드에 미치는 영향을 줄일 수 있습니다. - 다른 라이브러리로 교체 용이: 만약
ACMEPort
대신NewPort
라는 다른 라이브러리로 교체해야 할 경우,LocalPort
내부에서만NewPort
를 사용하도록 수정하면 됩니다.ACMEPort
를 직접 사용한 모든 부분을 수정할 필요가 없어 유지보수 비용을 절감할 수 있습니다. - 예외 처리 통합: 기존에는
ACMEPort
를 사용하는 모든 곳에서 각각 예외 처리를 해야 했지만,LocalPort
에서ACMEPort
가 던질 수 있는 다양한 예외들을 잡아PortDeviceFailure
와 같은 하나의 의미 있는 예외로 변환하여 던지도록 통합할 수 있습니다. 이를 통해 호출자 입장에서는 더 간단하고 일관된 방식으로 오류를 처리할 수 있습니다. 또한,LocalPort
내부에서 공통적인 예외 처리 (예: 로깅)를 수행한 후 래핑하여 던질 수도 있습니다. - 테스트 용이:
LocalPort
를 사용하면ACMEPort
의 실제 구현 대신 테스트용 가짜 객체(Mock Object 또는 Fake Object)를 만들어LocalPort
를 쉽게 테스트할 수 있습니다. 예를 들어,FakePort
클래스를 만들어open()
메서드의 동작을 테스트 목적에 맞게 구현할 수 있습니다.
- 외부 라이브러리와의 의존성 줄이기:
3. 📚 참고 사항
📢 논의하기
1. 관련 자료 공유
2. 논의하고 싶은 주제:
- 주제: 예외 처리를 할 때
try-catch
문을 사용하여 직접 처리하는 방법과throw
키워드를 사용하여 예외를 상위로 전달하는 방법이 있습니다. 각각 어떤 상황에서 사용하는 것이 적절할까요? - 설명: 예외 발생 상황에 따라 적절한 처리 방식을 선택하는 것은 중요합니다. 예외를 잡아서 현재 블록에서 처리하는 것이 나은 경우와, 예외를 더 상위 레벨로 던져서 호출한 쪽에서 처리하도록 하는 것이 더 적절한 경우가 있을 것 같습니다. 이러한 결정 기준에 대해 논의해보고 싶습니다.
No Comments