[Clean Code] Ⅶ. 오류 처리

Clean Code 3판을 읽고 정리한 글입니다

Ⅶ. 오류 처리

클린 코드와 오류처리의 상관관계

  • 오류의 가능성은 늘 언제나 존재
  • 오류 정정의 책임은 프로그래머가 가지고 있음
  • 여기저기 흩어진 오류 처리 코드 → 실제 코드 로직 파악 어렵게 만듬

오류 코드 보다 예외 사용

이전 프로그래밍 언어 → Exception 제공 x

  • 개발자들이 에러 상태나 flag를 set해야 함
  • 에러코드를 리턴, 호출하는 측에서 예외 처리해줘야 함
    안좋은 예 오류 플래그, 호출자에게 오류 코드 반환 등
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    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());
    }
    }
    ...
    }
  • 위 코드 단점
    • 호출자 코드 복잡 : 호출 리턴 받은 즉시 오류 확인 필요
    • 불행히 이 단계를 잊어버리기 쉬움
  • Exception 사용하면?
    • 깔끔해짐
    • 겉보기만 아름다워지는 것이 아님
    • 실제 로직과 예외처리 부분이 나누어짐
    • 필요 부분에 집중
      오류 발견시 예외를 던지는 코드
      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
      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블록은 트랜잭션과 비슷
  • try가 어떻든 catch에서는 프로그램 상태를 일관성 있게 유지
  • 코드의 scope 정의 가능
    예시
    TEST 코드
    1
    2
    3
    4
    5
    //예외를 기대하는 단위 테스트 코드
    @Test(expected = StorageException.class)
    public void retrieveSectionShouldThrowOnInvalidFileName() {
    sectionStore.retrieveSection("invalid - file");
    }
1. 구현코드1 : 예외를 던지지 않으므로 실패
1
2
3
4
public List<RecordedGrip> retrieveSection(String sectionName) {
// dummy return until we have a real implementation
return new ArrayList<RecordedGrip>();
}
예외를 던지는 구현 코드로 테스트 성공
1
2
3
4
5
6
7
8
  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 블럭에서 예외 유형 좁히기
1
2
3
4
5
6
7
8
9
10
// Exception의 범위를 FileNotFoundException으로 줄여 잡아낸다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (FileNotFoundException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
  • try catch구조로 범위 정의 → TDD를 사용해 나머지 논리 추가
  • 나머지 논리는 FileInputStream생성과 close()사이에 넣는다
  • 먼저 강제로 예외를 일으키는 테스트 케이스를 작성 후 테스트를 통과하게 코드를 작성하는 방법 권장
  • 자연스럽게 try블록의 트랜잭션 부터 구현하게됨 → 범위 내에서 트랜잭션 본일 유지가 쉬워짐

unchecked 예외를 사용하라

논쟁은 끝났다 : unchecked 사용하라!

checked Execption vs Unchecked Exception

  • 가장 명확한 기준 : 꼭 처리를 해야 하는 부분
  • 확인된 예외 : 반드시 try/catch로 감싸거나 throw로 처리해야 한다

이전엔는 확인된 예외를 좋은 아이디어로 생각

  • 확인된 예외 : checked exception
  • 북구될 가능성이 있는 문제상황으로 봄
  • 컴파일단에서 확인이 가능하고 복구를 시도해 보는 것이 일차적 관심상
  • 메서드 선언시 메서드가 반환할 예외 모두 열거
  • 메서드 반환 예외는 메서드 유형의 일부
  • 코드가 메서드를 사용하는 방식이 메서드 선언과 일치하지 않으면 아예 컴파일도 불가

현재는 확인된 예외가 반드시 필요하지 않는다

  • c#, c++, 파이썬, 루비 : 확인된 예외가 없지만 안정적인 sw만드는데 문리가 없음

확인된 예외(checked exception)의 비용

  1. 위에서 말했듯이 모든 중간 단계 메소드에 해당 exception을 throw 해야 함
  2. OCP 위반
  • 메서드에서 확인된 예외를 던졌는데 catch 블록이 3단계 위라면
  • 그 사이 메서드 모두 선언부에 해당 예외를 정의해야함 (throw 절)
  • 하위 단계 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야함(새로운 예외 추가or throw절 추가등)
  • (코드가 전혀 변경이 없어도) 선언부가 바뀌므로 모듈 다시 빌드하고 배포해야함
  • throws 경로의 모든 함수가 최하위 함수의 예외(하위 세부)를 알아야 함 → 캡슐화가 꺠진다
  • 오류를 원거리에서 처리하기 위해 예외를 사용했는데 예외가 캡슐화를 깨버리는 아이러니

때로는 유용

  • 아주 중요한 라이브러리라면 모든 예외를 잡아야함
  • 일반적 어플리케이션? 의존성 비용>>>>>> 예외로 얻는 이득

예외에 메시지를 담아라 - 예외에 의미있는 정보를 담기

예외를 던질때 전주 상황을 충분히 덧붙여라

  • 오류가 발생한 원인/위치 찾기가 쉬어짐
  • 자바는 모든 예외에 호출 스택을 제공 → 실패 코드 의도 파악을 위해선 해당 정보로 부족
  • 오류 메시지에 정보를 담아 예외와 함께 던져야함
    • 실패한 연산 이름, 실패 유형도 언급
    • 로깅 사용시 catch 블럭에서 오류룰 기록하도록 충분한 정보를 넘겨준다

호출자를 고려해서 예외 클래스 정의

  • 오류 분류방법은 수없이 많음 : 오류 발생한 위치, 유형등
  • 프로그래머 가장 큰 관심사 : 오류를 잡아내는 방법
    형편 없는 오류 분류 예시
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 외부 라이브러리가 던질 예외를 모두 잡음
    // 중복도 심함
    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 {
    ...
    }
    일반적인 상황에서 우리가 오류를 처리하는 방식
    → (오류 원인과 무관하게) 비교적 일정
  1. 오류를 기록한다
  2. 프로그램을 계속 수행해도 좋은지 확인한다

위의 코드 의 경우
→ 예외 대응 방식이 예외 유형과 무관하게 거의 동일

  • 호출하는 라이브러리 api를 감싸면서 예외 유형 하나를 반환하도록
LocalPort → ACMEPort Wrapper class
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
// 호출부 - Exception 처리가 들어나지 않도록 수행 가능
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
...
}
//---------------------------------------------
//ACMEPort가 던지는 예외를 잡아 변환하는 wrapper class
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);
}
}
...
}

wrapping의 유용성

  • 실제로 외부 api 사용시 wrapping 기법이 최선
  • 외부 api wrapping → 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어든다
  • 나중에 다른 라이브러리로 갈아타도 비용이 적음
  • wrapping 클래스에서 외부 api 호출 대신 테스트 코드 넣어주는 방법으로 테스트도 쉬어짐
  • wrapping 사용하면 특정 업체가 api를 설계한 방식에 발목 잡히지 않음
  • 위의 예제에선 port 디바이스 실패를 표현하는 예외 유형 하나 정의 → 프로그램이 깨끗해짐

예외 클래스 수

  • 예외 클래스가 하나만 있어도 충분한 코드 → 예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우
  • 한 예외 잡아내고 다른 예외 무시해도 괜찮다면? 여러 예외 클래스 사용

정상 흐름을 정의하라

  • 앞절의 지침 충실히 → 비지니스와 오류처리가 잘 분리된 코드
  • 오류 감지가 프로그램 언저리로 밀려나게 됨
    • 외부 API를 감싸 독자적 예외 던짐
    • 코드위에 처리기를 정의해 중단된 계산을 처리
    • 대부분은 좋은 처리 방식이지만 이런 중단이 적합하지 않을 때도 있음
총계를 계산하는 허술한 코드
1
2
3
4
5
6
7
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal(); //식비 비용 청구 → 청구 식비를 총계에 더함
} catch(MealExpensesNotFound e) {
m_total += getMealPerDiem(); //식비 비용 청구 안하면 → 일일 기본 식비를 총계에 더함
}
// 예외가 논리를 따라가기 어렵게 만듬
특수상황을 처리할 필요가 없으면 코드가 더 간결해진다.. 다음처럼 가능한가?
1
2
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();

정답 : 가능함

  • ExpenseReportDAO가 언제나 MealExpense 객체 반환하도록 고침
  • 청구 식비가 없으면 일일 기본 식비를 반환하는 MealExpense 객체 반환
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class PerDiemMealExpenses implements MealExpenses {
    public int getTotal() {
    // return the per diem default
    }
    }

    // ex??
    public class ExpenseReportDAO {
    ...
    public MealExpenses getMeals(int employeeId) {
    MealExpenses expenses;
    try {
    expenses = expenseReportDAO.getMeals(employee.getID());
    } catch(MealExpensesNotFound e) {
    expenses = new PerDiemMealExpenses();
    }

    return expenses;
    }
    ...
    }
  • 특수 사례 패턴
    • 클래스를 만들거나 객체를 조작해서 특수 사례를 처리하는 방식
    • 객체나 클래스가 예외적인 상황을 캡슐화해서 처리
    • 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다

null을 반환하지 마라

  • null확인이 가득한 코드 → 나쁜 코드

    • null 반환 코드는 일거리를 늘리며 호출자에게 문제를 넘김
    • 한명이라도 null 체크 빼먹으면 위험
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      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);
      }
      }
      }
      }
      // - peristentStore가 null인 경우 예외처리 안됨
      // - 안된 것도 찾기가 힘든
  • 메서드에서 null을 반환하고 픈 유혹이 든다면? → 예외를 던지거나 특수 사례 객체를 반환하라

  • 사용하려는 외부 api가 null 반환? → 감싸기 기법 사용 → 예외 던지거나 특수 사례 객체 반환하는 방식 고려

  • 많은 경우 특수 사례 객체가 손쉬운 해결책

    예시
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 1. null 반환
    List<Employee> employees = getEmployees();
    if (employees != null) {
    for(Employee e : employees) {
    totalPay += e.getPay();
    }
    }
    // 2. null 체크 없애고 getEmployees에서 빈 리스트 반환
    List<Employee> employees = getEmployees();
    for(Employee e : employees) {
    totalPay += e.getPay();
    }

    public List<Employee> getEmployees() {
    if( .. there are no employees .. )
    return Collections.emptyList();
    }
    }
    // 코드도 깔끔해지고 nullPointerException 발생도 줄어든다 - 안정화

null 전달하지마라

  • 메서드 null 반환보다 메서드 null 전달이 더 나쁨
  • 정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null 전달하는 코드는 최대한 피한다
  • 일반적으로 대다수의 프로그래밍 언어들은 파라미터로 들어온 null에 대해 적절한 방법을 제공하지 못한다.
  • 가장 이성적인 해법은 null을 파라미터로 받지 못하게 하는 것이다.
null이 전달되면 NullPointerException 발생!
1
2
3
4
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
return (p2.x – p1.x) * 1.5;
}

어떻게 고치는가?

1
2
3
4
5
6
7
8
9
// 1. 새로운 예외 유형을 만들어 던지기
// NullPointerException은 안나지만 InvalidArgumentException를 잡아내는 처리기 필요
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;
}
1
2
3
4
5
6
7
8
9
10
11
// 2. assert문 사용
// 문서화가 잘되어 코드 가독성은 좋으나 문제 해결은 여전함
// null 전달시 실행오류 여전함
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을 넘기지 못하도록 금지하는 정책이 함리적
  • 인수로 null이 넘어오면 코드에 문제가 있는 것

결론

  • 클린코드는 읽기도 좋지만 안정성도 좋야야 하며 상충하는 목표가 아님
  • 오류처리를 프로그램 로직과 분리해서 독자적 사안으로 고려 → 튼튼한 클린코드 작성 가능
  • 독립적 추론 가능 , 코드 유지보수성 올라감

실무 예외 처리 패턴

제로베이스에서 한달 한권의 내용

1. getOrElse

  • 예외 대신 기본 값 리턴
    1. null이 아닌 기본값 : 위의 employee 예제
    2. 도메인에 맞는 기본 값 : 아래 설명
나쁜 예
1
2
3
4
5
6
7
8
9
UserLevel userLevel = null;
try {
User user = user.Repository.findByUserId(userId);
userLevel = user.getUserLevel();
} catch (UserNotfoundException e) {
userLevel = UserLevel.BASIC;
}
// 호출부에서 예외 처리를 통해 userLevel 값을 처리한다
// 코드를 계속 읽어나가면서 논리적인 흐름이 끊긴다
좋은 예
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 호출부 - 단순해짐
UserLevel userLevel = userService.getuserLevelOrDefault(userId);
//////////////////////////////////////////////////

public class UserService {
public static final UserLevel USER_BASIC_LEVEL = UsrLevel.BASIC;

public UserLevel getUserLevelOrdefault(Long userId) {
try {
User user = userRepository.findByUserId(userId);
return user.getUserLevel();
} catch(UserNotFoundException e) {
return USER_BASIC_LEVEL;
}
}
}
  • 예외처리를 데이터 제공 쪽 처리 → 호출부가 심플, 단순해짐
  • 코드 리딩시 논리적 흐름이 끊기지 않음
  • 도메인에 맞는 default 값을 도메인 서비스에서 관리

getOrElseThrow

기본값이 없으면 null대신 예외를 던진다

나쁜 예
1
2
3
4
User user = userRepository.findByUseId(userId);
if (user != null) {
// user를 이용한 처리
}
  • user를 사용하는 쪽에서 매번 체크를 해야한다.
  • 가독성 뿐만 아니라 안정성도 떨어진다.
  • null 체크가 빠진 부분이 발생할 수 있다.
좋은 예
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 호출부
User user = user.Service.getUserOrElseThrow(userId);
//////////////////////////////////

public class UserService {
public static final UserLevel USER_BASIC_LEVEL = UsrLevel.BASIC;

public User getUserOrElseThrow(Long userId) {
User user = userRepository.findByUserId(userId);
if (user == null) {
throw new IllegalArgumentException("User is not found. userId = " + userId)
}
return user;
}
}
}
  • 기본 값이 없는 경우임
  • 기본 값이 없으면 null 체크 → 데이터가 없으면 예외 던짐
  • 호출부에서 매번 null 체크가 필요 없음
  • 호출부의 가독성 올라감

실무에선 보통 자신의 예외를 정의

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
public class MyProjectException extends RuntimeException {
private MyErrorCode errorCode;
private String errorMessage;

public MyProjectException(MyErrorCode errorCode) {
//
}

public MyProjectException(MyErrorCode errorCode, String errorMessage) {
//
}
}

public enum MyErrorCode {
private String defaultErrorMessage;

INVALID_REQUEST("잘못된 요청입니다."),
DUPLICATED_REQUEST("기존 요청과 중복되어 처리할 수 없습니다."),
// ...
INTERNAL_SERVER_ERROR("처리 중 에러가 발생했습니다.");
}

//호출부 - 클라이언트가 아닌 내부 null 처리 부분
if (request.getUserName() == null) {
throw new MyProjectException(ErrorCode.INVALID_REQUEST, "userName is null");
}

장점

  • 에러 로그에서 stacktrace할 때 자신의 예외라는 것을 바로 인지 가능
  • 다른 라이브러리 에러와 섞이지 않음 : 다른 IllegalArgumentException과 분리
  • 우리 도메인에서 발생한 에러 종류를 나열 가능

Related POST

공유하기