오류 처리 로직을 생각없이 짜다보면 곳곳에 오류 처리 코드가 흩어져있던 경험이 있을거에요. 그리고 이런 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일 파악이 힘들어질 수도 있고 가독성에 문제가 생기기도 해요. 그래서 어떻게 오류를 깔끔하게 처리할지 고민해보다가 바빠서 지나쳐 가기도 해요.
이 장에서는 깨끗하고 튼튼한 코드에 한걸음 다가가기 위해 우하하게 오류를 처리하는 기법과 고려 사항을 소개해요.
옛날에는 예외를 지원하지 않는 프로그래밍 언어가 더 많았다고 해요. 그래서 오류를 처리하고 보고하는 방법이 제한적이었어요. 오류 플래그를 설정하거나, 호출자에게 오류 코드를 반환하는 방법이 전부였어요. 그래서 아래의 코드 예시처럼 처리했어요.
public class DeviceController { ... public void sendShutDown() { DeviceHandle handle = getHandle(DEV1); // Check the state of the device if (handle != DeviceHandle.INVALID) { // Save the device status to the record field retrieveDeviceRecord(handle); // If not suspended, shut down 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()); ... } ...}이 코드가 예외를 던지는 코드를 사용한 예시에요. 디바이스를 종료하는 알고리즘과 오류를 처리하는 알고리즘을 분리했기 때문에 뒤섞였던 개념을 독립적으로 살펴보고 이해할 수 있어요. 어떄요 훨씬 깔끔해지고 일기 편해졌나요?
try-catch-finally로 프로그램 안에 범위를 정의할 수 있어요. try 블록에 들어가는 코드를 실행하면 어느 시점에서든 실행이 중단된 후 catch 블록으로 넘어가요. 그래서 마치 트랜잭션과 비슷해요. try 블록으로 무슨 일이 생겨도 catch 블록으로 프로그램 상태를 일관성 있게 유지할 수 있어요.
그래서 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 좋다고 해요. 이제 단위 테스트 작성으로 코드를 작성하는 예시로 try-catch-finally부터 작성하는 것을 보여드릴게요.
@Test(expected = StorageException.class)public void retrieveSectionShouldThrowOnInvalidFileName() { sectionStore.retrieveSection("invalid - file");}위 코드는 파일이 없으면 예외를 던지는지 알아보는 단위 테스트에요. 이제 이 코드에 맞춰 구현해봐요. 우선 테스트에 실패하는 코드로 구현해봐요.
public List<RecordedGrip> retrieveSection(String sectionName) { // 구현 전 더미 리턴 return new ArrayList<RecordedGrip>();}이 코드는 예외를 던지지 않기 때문에 단위 테스트에 실패해요. 이제 잘못된 파일에 접근하도록 구현하고 예외를 던지게 변경해봐요.
public List<RecordedGrip> retrieveSection(String sectionName) { try { FileInputStream stream = new FileInputStream(sectionName) } catch (Exception e) { throw new StorageException("retrieval error", e); } return new ArrayList<RecordedGrip>(); }위 코드는 예외를 던져서 이제 테스트가 성공해요. 그리고 여기에 좀만 더 리팩터링 한다면 catch문에서 Exception을 FileNotFoundException으로 예외 유형을 좁히면 좋아요. 아무튼 이제 위 코드에서 try-catch 구조로 범위를 정의했으니 TDD를 사용해 필요한 나머지 논리를 추가하면되요. 그리고 나머지 논리는 오류나 예외가 전혀 발생하지 않음을 가정해야겠죠.
위와같이 강제로 예외를 일으키는 테스트 케이스를 작성하고 테스트를 통과하게 코드를 작성하는 방법을 저자가 권해요.(역시 TDD 선구자) 이렇게 하면 try 블록의 트랜잭션 범위부터 구현하게 되므로 범위 내에서 트랜잭션 본질을 유지하기 쉬워져요.
옛날에 자바 프로그래머들은 확인된 예외의 장단점을 놓고 논쟁을 벌여왔어요. 처음에는 확인된 예외가 멋진 아이디어라 생각했어요. 왜냐하면 안정적인 소프트웨어를 제작하는 요소로 좋다고 생각했기 때문이에요. 하지만 결국 안정적인 소프트웨어를 제작하는 요소로 확인된 예외가 반드시 필요하지 않음이 확실해졌어요. C++, C#, 파이썬같은 언어들이 확인된 예외를 지원하지 않아도 안정적인 소프트웨어를 제작할 수 있었다는 것에서 증명되었어요.
그래서 우리는 확인된 예외(CheckedException)에 치르는 비용이 그 만큼의 이익을 주는지 따져봐야해요.
우리가 특정된 예외를 사용하면 무슨 비용이 들까요? 바로 OCP 위반이에요. 어떤 메서드에서 예외를 던지는데, 3단계나 위에 있는 함수의 catch 블록에서 예외를 잡는다고 해봐요. 그러면 그사이의 모든 메서드들은 java에서 throw SomeException 절을 사용하거나 catch문으로 예외를 처리해요. 예외 하나 바꾸려면 그사이의 모든 메서드 선언부를 고치거나 catch문으로 처리하는 경우가 생긴다는 소리에요. 모듈과 관련된 코드가 전혀 바뀌지 않아도 선언부를 변경하면서 다시 빌드하고 배포해야하는 일이 생긴는 거죠.
이렇게 확인된 예외는 캡슐화를 깨버리는 단점을 보여줘요. 그래서 적절히 사용해야해요. 예를들면 아주 중요한 라이브러리에는 튼튼하게 확인된 예외를 사용하고, 일반적인 애플리케이션이면 의존성이 생기는 비용이 이익보다 크지않기 때문에 미확인 예외를 사용하는게 좋을 수 있어요.
예외를 던질 때 전후 상황을 충분히 덧붙이면 좋아요. 자바는 예외에 호출 스택을 제공하긴 하지만 실패한 코드의 의도를 파악하기엔 호출 스택만으론 부족해요. 그래서 오류 메시지에 정보를 담아 예외와 함께 던지면 좋아요.
오류를 분류하는 방법은 아주 많아요. 오류가 발생한 위치(오류 발생 컴포넌트)로 분류가 가능하기도 하고, 유형(네트워크, 디바이스)으로도 분류가 가능해요.
하지만 여기서 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 해요. (적절한 혹은 알맞은 방법을 생각해야해요.)
다음은 오류를 형편없이 분류한 사례에요. try-catch 문으로 외부 라이브러리가 던질 예외를 모두 잡아 처리하는 코드에요.
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 { … }위 코드는 중복이 심한것도 있지만, 예외에 대응하는 방식이 예외 유형과 무관하게 거의 동일해요. errorReport를하고 logging를 하니 이 코드를 고치기 쉬워요. Wrapper 클래스를 만들어 외부 라이브러리가 던지는 예외를 구현하는 프로그램이 사용하기 쉬운 방식으로 처리하게 만들면 되요.
LocalPort port = new LocalPort(12); try { port.open(); } catch (PortDeviceFailure e) { // ACMEPort를 LocalPort라는 Wrapper 클래스로 예외 달리 던지게 바꾸기. reportError(e); logger.log(e.getMessage(), e); } finally { … }public class LocalPort { // Wrapper Class 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의 Exception만 처리하면 되기 때문에 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어들어요. 그리고 다른 라이브러리로 갈아타더라도 LocalPort 인터페이스에 맞추니까 비용도 적고, 테스트하기도 쉬워져요.
결론은 주어진 외부 라이브러리 API를 그냥 따라가지 않고, 실패하는 예외 유형을 하나로 정의해서 프로그램을 더 깨끗하게 만들 수 있다는 거에요. 그러면 보통 어느 경우에 이런식으로 처리하는게 좋을까요? 바로 예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우가 대표적이에요. 만약 한 예외는 잡아내고 다른 예외는 뭇기해도 괜찮으면 여러 예외 클래스를 사용해야 겠죠.
앞에 말한 지침들을 따르면 비즈니스 논리와 오류 처리가 잘 분리된 코드가 나와요. 하지만 그러다 보면 오류 감지가 프로그램 언저리로 밀려나요. 외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리해요. 보통은 좋은 처리 방식이지만, 때로는 중단이 적합하지 않기도 해요.
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();이렇게 말이죠. ExpenseReportDAO를 고쳐서 언제나 MealExpenses 객체에 상황에 맞는 가격을 담아서 전달해주는 방식으로 고치는 거에요. 이런걸 특수 사례 패턴(Special Case Pattern)이라고 불러요. 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는거에요. 이러면 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어져요.
오류 처리를 논하는 장이라면 우리가 흔히 저지르는 바람에 오류를 유발하는 행위도 언급해야해요. 그 중 첫번째가 null을 반환하는 습관이에요. 다음 예를 봐요.
public void registerItem(Item item) { if (item != null) { ItemRegistry registry = persistentStore.getItemRegistry(); if (registry != null) { Item existing = registry.getItem(item.getID()); if (existing.getBillingPeriod().hasRetailOwner()) { existing.register(item); } } } }위 코드가 나쁘지 않다고 느낄 수 있어요. 하지만 null을 반환하는 코드는 일거리를 늘리고 호출자에게 문제를 떠넘겨요. 누구 하나라도 null을 빼먹으면 애플리케이션이 통제 불능이 될 수도 있어요. 위 코드에서 둘째 행에 null 확인이 빠진 것 눈치 채셨나요? persistentStore가 null이라면 NullPointerException이 일어날 수 있어요. 그러면 해당 에러를 대체 누가 어떻게 처리해야 할까요?
위 코드의 문제는 null 확인의 누락이 아니라 null 확인이 너무 많은게 문제에요. 메서드에서 null 반환의 유혹이 생기면 대신 예외를 던지거나 특수 사례 객체를 반환하는 것을 고려해보세요. 다음 예시로 개선하는 방법을 봐요.
List<Employee> employees = getEmployees();if (employees != null) { for(Employee e : employees) { totalPay += e.getPay(); }}위 코드는 getEmployees 함수가 직원이 없으면 null을 반환하기 때문에 null 체크를 해요. 이제 이를 개선해서 null 확인을 없애봐요.
List<Employee> employees = getEmployees();for(Employee e : employees) { totalPay += e.getPay();}Public List<Employee> getEmployees() { if( .. 직원이 없다면 .. ) return Collections.emptyList();}이렇게 코드를 변경하면 코드도 깔끔해지고 NullPointerException 발생 가능성도 줄어들거에요.
메서드로 null을 전달하는 방식이 훨씬 나빠요. 정상적인 인수로 null을 전달하는 API가 아니라면 null을 전달하는 코드는 최대한 피하는게 좋아요. 다음 예제로 그 이유를 살펴봐요. 두 지점 사이의 거리를 계산하는 메서드에요.
public class MetricsCalculator { public double xProjection(Point p1, Point p2) { return (p2.x – p1.x) * 1.5; } …}위 코드에 null을 전달하면 당연히 NullPointerException이 발생할거에요. 그러면 어떻게 고쳐야할까요?
public class MetricsCalculator { public double xProjection(Point p1, Point p2) { if (p1 == null || p2 == null) { throw InvalidArgumentException( "Invalid argument for MetricsCalculator.xProjection"); } return (p2.x – p1.x) * 1.5; }}위 코드는 괜찮나요? NullPointerException보다는 조금 낫겠지만, InvalidArgumentException을 처리해야 하는데 어떻게 처리해야할까요?
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; }}assert를 사용하는 위 코드는 괜찮나요? 문서화가 잘 되어 코드 읽기는 편하지만 문제를 해결하진 못하고 있어요. 누군가 null을 전달하면 여전히 실행 오류가 발생해요.
대다수 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없어요. 그래서 애초에 null을 인수로 넘기는 것을 금지하는 정책이 합리적이라고 저자가 주장해요. 그래서 null이 들어오는 것이 정상이 아니라면 null을 인수로 넘기는 것 자체를 막는 정책으로 실수를 저지를 확률을 줄이는게 좋아요.
Clean Code는 읽기도 좋아야하고, 안정성도 높아야해요. 오류 처리를 프로그램 논리와 분리하면 튼튼하고 깨끗한 코드가 나올 수 있어요. 그리고 분리함으로써 독립적인 추론도 가능해지고 코드 유지보수성이 좋아져요.
저는 프론트엔드 개발자인데 비동기적으로 발생한 오류에대해 React LifeCycle에 반영하여 처리하는 것을 여러번 고민해 봤어요. 그리고 비지니스 로직에서 발생하는 오류에 대해서도 View에 반영하는 것에 대한 고민도 많이 해봤고요. 결론은 프론트엔드에서는 클린하게 에러 처리하기가 힘들다는 거였어요. API 에서 JWT 토큰 만료 에러는 View에 에러가 전파되지 않고 바로 재발급 하는 로직을 짜야하고요. 어떤 오류는 Global하게 에러를 던져야할 수도 있고요. 아니면 Toast 메시지로 경고를 표시 해줘야할 수도 있고요. 아니면 Error Boudnary와 에러 컴포넌트로 에러난 로직의 retry가 가능하도록 재공해야할 수도 있어요. 하지만 이런 몇몇 예외만 잘 처리한다면 충분히 깔끔하게 에러 처리가 가능하다고 생각해요.
이렇게 에러 처리 깔끔하게 하는 방법에 대해 이야기해 봤어요. 좀 더 생각을 가지고 튼튼한 코드 만들 준비 되셨나요?