Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

 

오류 처리 코드는 나쁘다.

오류 처리 코드가 있다는 자체가 함수는 여러 가지 일을 하고 있다는 증거다.

더구나 오류 처리 코드가 함수 안에 여기저기 흩어져 있다면,

함수 본래 목적을 파악하기 어려워 진다.

 

오류 처리 자체는 매우 중요하다.

다만, 오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워지면 안된다.

 

오류 코드보다 예외를 사용하라

옛날 언어는 예외를 지원하지 못했다.

이로 인해 오류 처리 방법이 제한적이 였다.

 

대게 플래그 값을 설정해 오류 코드를 반환하는 방법이 주류였다.

 

public class DeviceController {
  private static final String DEV1 = null;
  private static final boolean DEVICE_SUSPENDED = false;
  private Recode recode;
  private Logger logger;


  public void sendShutDown() {
    DeviceHandle handle = getHandle(DEV1);
    //디바이스 생태 점검
    if (handle != DeviceHandle.INVALID) {
      //레코드 필드에 디바이스 상태 저장
      retrieveDeviceRecord(handle);
      //디바이스가 일시정지 상태가 아니라면 종료
      if (recode.getStatus() != DEVICE_SUSPENDED) {
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        pauseDevice(handle);
      } else {
        logger.log(Level.INFO, "Device suspended. Unable to shutdown");
      }
    } else {
      logger.log(Level.INFO, "Invalid handle for: " + DEV1.toString());
    }
  }

 

public class DeviceController {
  private static final String DEV1 = null;
  private static final boolean DEVICE_SUSPENDED = false;
  private Recode recode;
  private Logger logger;
  public void sendShutDown() {
    try {
      tryToShutdown();
    } catch (DeviceShutDownError e) {
      logger.log(Level.INFO, e);
    }
  }
  private void tryToShutdown() throws DeviceShutDownError {
    DeviceHandle handle = getHandle(DEV1);
      retrieveDeviceRecord(handle);
      pauseDevice(handle);
      clearDeviceWorkQueue(handle);
      pauseDevice(handle);
  }
  private void clearDeviceWorkQueue(DeviceHandle handle)  throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
  }
  private void pauseDevice(DeviceHandle handle)  throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
  }
  private void retrieveDeviceRecord(DeviceHandle handle)  throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
  }
  private DeviceHandle getHandle(String dev12) throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
    return null;
  }
}

핵심 로직과 오류 처리 로직이 완전히 분리되면서, 코드 품질이 좋아지고, 코드가 클린해졌다.

 

Try-Catch-Finally 문부터 작성하라

에외가 발생할 코드를 때는 try-catch-finally 문으로 시작한다.

그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태 정의를 하기 쉬워진다.

 

try 블록에 코드는 실행 어느 시점에서든 중단 catch 블록으로 넘어 있다.

catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다.(예외 발생 프로그램을 정상 동작하도록)

 

예외를 기대하는 단위 테스트

  @Test(expected = StorageException.class)
  public void retrieveSectionShouldThrowOnInvalidFileName() {
    sectionStore.retrieveSection("invlid - file");
  }

 

예외를 던지지 않아 실패

  public List<RecordedGrip> retrieveSection(String sectionName) {
    //실제로 구현할 때까지 비어 있는 더미를 반환한다.
    return new ArrayList<RecordedGrip>();
  }

예외를 던지도록 코드 구성

  public List<RecordedGrip> retrieveSection(String sectionName)
      throws StorageException {
    try {//sectionName 잘못된 파일 경로 일부러 넣음
      FileInputStream stream = new FileInputStream(sectionName);
    } catch (Exception e) {
      throw new StorageException("retrieval error", e);
    }
    return new ArrayList<RecordedGrip>();
  }

 

미확인(unchecked)예외를 사용하라

확인된(Checked) 예외 특징

해당 메서드 호출 발생 가능한 모든 예외를 catch

, try-catch 강제한다.

 

가지 사실를 중심으로 장단점이 발생한다.

 

근본적으로 확인된 예외는 OCP 위반한다.

해당 메서드 호출 catch 현재 코드에서 3단계 위에 있다면,

현재 예외를 던지는 코드에서 최초 호출 메서드까지 모든 메서드는 throws 해당 예외를 선언해야 한다.

이는 캡슐화가 깨짐을 의미한다. 하위 메서드가 해당 예외를 던진 다는 사실을 상위 메서드가 알게 됐기 때문이다.

 

하위 단계 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다.

이는 영향 받은 모듈은 기능은 변경된 없으나, 메서드에 예외를 선언한 때문에 재컴파일이 필요해진다.(영향도 문제는 대규모 시스템만 해당 )

 

예외에 의미를 제공하라

예외발생 호출 스택을 제공하지만, 이로는 부족하다.

예외 메시지에 전후 사정을 명확히 기입해 예외가 발생했는지 입력한다.

로그, 예외 메시지

 

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

오류를 분류하는 방법은 많다.

오류 발생 위치 분류, 유형 분류(디바이스 실패, 네트워크 실패 …)

 

오류를 정의할 가장 중요한 것은 오류를 catch 하는 방법이 돼야 한다.

안좋은 예

외부 라이브러리라고 가정, 외부 라이브러리가 던지는 모든 예외를 catch한다

    ACMPort port = new ACMPort(12);
    try {
      port.open();      
    } catch (DeviceResponseException e) {
      reportPortError(e);
      logger.log(Level.INFO, "Device response exception");
    } catch (ATM1212UnlockedException e) {
      reportPortError(e);
      logger.log(Level.INFO, "Device response exception");
    } catch (GMXError e) {
      reportPortError(e);
      logger.log(Level.INFO, "Device response exception");
    } finally {
      //.............
    }

 

  void exam() {
    LocalPort port = new LocalPort(12);
    try {
      port.open();
    } catch (PortDeviceFailure e) {
      reportPortError(e);
      logger.log(Level.INFO, e.getMessage());
    } finally {
      //.............
    }
  }

호출하는 외부 라이브러리 API 감싸 예외 유형 하나로 통합했다.

 

public class LocalPort {
  private final ACMPort innerPort;
  public LocalPort(int i) {
    innerPort = new ACMPort(i);
  }
  public void open() {
    try {
      innerPort.open();      
    } catch (
        DeviceResponseException |
        ATM1212UnlockedException |
        GMXError e) {
      throw new PortDeviceFailure(e);
    }
  }
}

LocalPort 단순한 래퍼 클래스다.

만약 인터페이스 였다면, implements 구현해 다형성을 이용했을

 

실제로 외부 API 사용할 때는 감싸기 기법이 좋다

 

외부API 감싸면 외부 라이브러리와 우리 프로그램 사이 의존성이 크게 줄어든다.

나중에 다른 라이브러리로 갈아타도 비용이 적다.

 

또한 외부 API 호출하는 래퍼클래스의 코드에 테스트 코드를 넣어 주는 방법으로 테스트 용이성도 크게 증가한다.

 

특정한 외부 API 대한 사용 방법을 우리 프로그램이 사용하기 편하게 API 재구축 있다.

 

예외 클래스는 대부분 하나로 충분한 코드가 많다.

예외 클래스에 포함된 정보로 오류를 구분해도 충분한 경우(ex_Message에만 입력한 값만 으로 충분하다)

예외를 분류해서 catch 필요가 있는 경우만 여러 예외를 사용한다.

 

정상 흐름을 정의하라

외부 API 감싸고, 독자적인 예외를 던지고, 독자적인 예외를 잡아 처리하는 것은 대부분 좋은 방식이다.

때로는 중단이 적합지 않을 때도 있다.

 

    try {
      //청구한 식비가 있으면 총계에 더한다.
      MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
      m_total += expenses.getTotal();
    } catch (MealExpensesNotFound e) {
      //청구한 게 없다면, 기본형 식비를 더한다.
      m_total += getMealPerDiem();
    }

청구한 식비가 없다면, MealExpensesNotFound 예외가 발생해,

예외를 잡아 기본형 식비를 더한다.

 

괜찮긴 하지만, 불필요한 예외처리 흐름을 보이고 있다.

코드 흐름 : 식비 계산 => 예외 발생하면 기본형 식비 한다.

 

    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();

위와 같이 개선하면 코드 흐름이 간결해진다.

코드 흐름 : 식비 계산

 

public class PerDiemMealExpenses implements MealExpenses{
  @Override
  public int getTotal() {
    //기본형 식대
    return 0;
  }
}
public class ExpenseReportDAO {


  public MealExpenses getMeals(String id) {
    //청구한 식비가 없다면, 기본형 클래스를 반환한다.
    //이런 방식을 특수 사례 패턴 이라한다.
    MealExpenses meal = find(id);
    if(meal == null) {
      return new PerDiemMealExpenses();
    }
    return meal;
  }
  private MealExpenses find(String id) {
    return null;
  }
}

위와 같은 방법을 특수 사례 패턴이라 한다.

마틴 파울러 리팩터링에 자세히 소개되어 있다.

 

객체 지향에서 상속을 활용하는 일반적인 가지 방법,

하나, 기본형 동작을 정의 특수 사례를 상속으로 뺀다.

대부분이 기본형 동작으로 충분( 많이 사용)

, 기본형 동작없이(인터페이스 처럼) 모든 사례를 서브 클래스로 뺀다.

다양한 경우의

 

null 반환하지 마라

  //수 많은 null을 체크하는 더러운 코드
  void registerItem(Item item) {
    if(item != null) {
      ItemRegistry registry = peristenStore.getItemRegistry();
      if(registry != null) {
        Item existing = registry.getItem(item.getID());
        if(existing.getBillingPeriod().hasRetailOwner()) {
          existing.register(item);
        }
      }
    }
  }

null 반환하는 것은 호출자에게 null관련 문제를 떠넘기는 좋은 코드다.

메서드를 호출하는 모든 코드 중에서 하나라도 null 체크를 빼먹으면, 애플리케이션 오류가 발생할 있다.

코드 기준으로 peristenStore null 체크 로직을 빼먹었다.

그리고 null 체크가 중첩일 NullPointerException 발생하면, 찾기도 힘들다.

 

null 반환하는 것보다 예외를 던지거나 특수 사례 객체를 반환해야 한다.

 

외부 API null 반환한다면, 래퍼 클래스로 감싸 예외를 던지거나 특수 사례 객체를 반환한다.

 

일반적으론 특수 사례 객체가 좋은 대책이다.

   List<Employee> employees = getEmployees();
    if(employees != null) {
      for(Employee e : employees) {
        totalPay += e.getPay();
      }
    }

 

public class Exam02 {
  void exam() {
    int totalPay = 0;
    List<Employee> employees = getEmployees();
    //null을 체크할 필요가 없다.
    for(Employee e : employees) {
      totalPay += e.getPay();
    }
  }
  private List<Employee> getEmployees() {
    boolean result = false; //직원이 없다고 가정
    if(result) {
      return Collections.emptyList();
    }
    //정상적으로 반환했다고 가정
    return null;
  }
}

자바의 경우 읽기 전용 리스트라는 특수 사례 객체가 존재한다.

Collections.emptyList()

 

null 전달하지 마라

null반환 보다 나쁜 방식

//두 지점 사이 거리 계산
public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) {
    return (p2.x - p1.x) * 1.5;
  }
}
  void exam() {
    MetricsCalculator calculator = new MetricsCalculator();
    calculator.xProjection(null, new Point(12, 13));
  }

누군가 호출할 null 입력해, NullPointerException 발생할 있다.

 

public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) throws InvalidArgumentException {
    if(p1 == null || p1 == null) {
      throw new InvalidArgumentException("MetricsCalculator.xProjection에 비정상 인자");
    }
    return (p2.x - p1.x) * 1.5;
  }
}
    try {
      calculator.xProjection(null, new Point(12, 13));
    } catch (InvalidArgumentException e) {
      e.printStackTrace();
    }

NullPointerException 반환하는 대신 새로운 예외 유형을 만들어 던지고 있다.

이로 인해 호출부에서 try - catch 문이 필요해졌다.

 

//개선 시도2
public class MetricsCalculator {
  public double xProjection(Point p1, Point p2)  {
    assert p1 != null : "p1 null이면 안됩니다.";
    assert p2 != null : "p2 null이면 안됩니다.";
    return (p2.x - p1.x) * 1.5;
  }
}

assert 문을 사용하는 방법이 있다.

근데 실제 assert 문을 활성화해서 사용하는 경우가 있는지는 모르겠다.

 

안타깝게도 내가 null 반환하지는 않을 있지만, 누군가의 실수로 입력으로 들어올 있는 null 막을 방법은 없다. 프로젝트 정책적으로 강력하게 null인수를 사용하지 못하도록 해야 한다. (그래도...실수는…)

 

결론

깨끗한 코드는 읽기도 좋고, 무엇보다 안정성도 높아야 한다.

오류 처리 로직과 비즈니스 로직을 분리하면, 깨끗하고 튼튼한 코드를 작성할 있다.

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

9장 단위 테스트  (1) 2024.01.08
8장 경계  (1) 2024.01.01
6장 객체와 자료 구조  (1) 2023.12.18
5장 형식 맞추기  (0) 2023.12.11
4장 주석  (1) 2023.12.04

+ Recent posts