책 리뷰

[클린 코드] 요약 및 정리

Rudtjs 2023. 3. 25. 21:25

[의미 있는 이름]

  • 다른 사람이 봐도 알아볼 수 있게 이름을 지으라.
  • 서로 흡사한 이름을 사용하여 않도록 주의한다.
  • 검색하기 쉬운 이름을 사용하자

[함수]

1. 함수는 하나의 역할만 해야 한다. 

  • 지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 하는 것이다.
public Money calculatePay(Employee e) throws InvalidEmployeeType {
  switch(e.type) {
    case COMMISSIONED:
      return calculateCommisionedPay(e);
    case HOURLY:
      return calculateHourlyPay(e);
    case SALARIED:
      return calculateSalariedPay(e);
    default:
      throw new InvalidEmployeeType(e.type);
  }
  
}

위 코드에는 많은 문제점을 내포한다.

 

  1. 함수가 길다.
  2. 한 가지 작업만 수행하지 않는다.
  3. SRP를 위반한다. 새로운 직원 타입이 추가되어도 임금을 계산하는 함수를 변경해야 한다.
  4. OCP를 위반한다. 새로운 직원 타입이 추가되면 새로운 임금 계산 로직을 위하 코드를 변경해야 한다. 

이 문제를 해결하기 위해서는 Employee를 추상클래스로 만들고 직원 타입의 따른 함수를 호출해 주면 된다.

public abstract clas Employee {
    public abstract boolean isPayday();
    public abstract Money calculatePay();
    public abstract void deliverPay(Money pay);
}
 
//
 
public interface EmployeeFactory {
    public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
 
//
 
public class EmployeeFactoryImpl implements EmployeeFactory {
    public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
        switch (r.type) {
        case COMMISSIONED:
            return new CommissionedEmployee(r);
        case HOURLY:
            return new HourlyEmployee(r);
        case SALARIED:
            return new SalariedEmployee(r);
        default:
            throw new InvalidEmployeeType(r.type);
    }
}

2. 서술적인 이름 사용

  • 길고 서술적인 이름이 짧고 어려운 이름보다 좋고 길고 서술적인 주석보다 좋다.
  • 일관성 있게 이름을 붙이자. 모듈 내에서 함수 이름은 같은 문구, 명사, 동사를 사용

 

3. 명령과 조회는 분리해라

  • 함수는 뭔가를 수행하거나 뭔가에 답을 하거나 둘 중 하나만 해야 한다.
// 여기서 set이 무엇을 의미하는지 모호하다.
public boolean set(String attribute, String value);

if(set("username", "unclebob")) {}

이게 무엇을 의미하는지 알기 힘들다. 그렇기 때문에 함수를 분리하여 다음과 같이 작성하자.

if (attributeExists("username")) {
    setAttribute("username", "unclebob");
}

 

4. 오류보다는 예외처리를 이용하자

  • 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 위반한다.
  • 오류 코드를 반환하면 호출자는 오류 코드를 곧바로 처리해야 하는 문제 발생한다.

try cath문을 이용하여 예외를 처리하면 코드를 이해하고 수정하기 쉬워진다.

public void delete(Page page) {
    try {
        deletePageAndAllReferences(page);
    } 
    catch (Exception e) {
        logError(e);
    }
}
 
private void deletePageAndAllReferences(Page page) throws Exception {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
}
 
private void logError(Exception e) {
    logger.log(e.getMessage());
}

 

[주석]

코드로 의도를 표현하라

// 직원에게 복지 혜택을 받을 자격이 있는지 검사한다.
if ((employee.flags & HOURLY_FLAG) &&
	(employee.age > 65))
    

if (employee.isEligibleForFullBenefits())

분명 코드만으로 의도를 설명하기 어려운 경우가 존재한다. 하지만 위의 코드처럼 대다수의 경우는 몇 초의 시간만 투자한다면 코드로 의도를 표현할 수 있다. 주석으로 달려는 설명을 함수로 만들어 표현해도 충분하다.

 

정보를 제공하는 주석

// 4~32자의 비밀번호는 4개 중 3개 이상 필요합니다(대문자
// 및 소문자, 숫자 및 특수 문자) 및 최대
// 2개의 동일한 연속 문자.
private static final String PASSWORD_REGEX = ^(?:(?=.*\d)(?=.*[A-Z])(?=.*[a-z])|(?=.*\d)(?=.*[^A-Za-z0-9]) + 
    (?=.*[a-z])|(?=.*[^A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z])|(?=.*\d)(?=.*[A-Z]) + 
    (?=.*[^A-Za-z0-9]))(?!.*(.)\1{2,})[A-Za-z0-9!~<>,;:_=?*+#.”&§%°()\|\[\]\-\$\^\@\/] +
    {8,32}$

Pattern passwordRegex = Pattern.compike(PASSWORD_REGEX);

비밀번호를 만들 때에 필요한 조건 정보가 담긴 주석이다. 정규식을 하나하나 직접 알아내려면 꽤나 복잡할 것이다. 이런 때에 주석은 유용하다.

 

의미를 명로하게 밝히는 주석

public void testCompareTo() throw Exception{

    assertTrue(a.compareTo(a) == 0); // a == a
    assertTrue(a.compareTo(b) != 0); // a != b
    assertTrue(aba.compareTo(ab) == 0); // ab == ab
    assertTrue(a.compareTo(b) == -1); // a < a
    assertTrue(aa.compareTo(ab) == -1); // aa < ab
    assertTrue(ba.compareTo(bb) == -1); // ba < bb
    assertTrue(b.compareTo(a) == 1); // b > a
    assertTrue(ab.compareTo(aa) == 1); // ab > aa
    assertTrue(bb.compareTo(ba) == 1); // bb > ba
}

compareTo()에서 문자열을 비교할 때 기준값과 비교대상이 동일한 경우(0), 기준값이 비교대상보다 작을 경우(-1), 기준값이 비교대상보다 클 경우(1)를 반환한다. 이 부분에 대해 명료하게 알 수 없기에 주석을 사용하여 의미를 명료하게 밝힐 수 있다. 그러나 가장 최선인 주석을 달지 않고 해결할 더 나은 방법이 있는지 생각해 보고 주석을 달도록 주의하자.

 

주석으로 설명할 수 있다면 괜찮다고 생각하였는데 주석 없이도 함수나 변수 이름으로도 설명할 수 있다는 것을 알았다.

주석이 달려있어도 누군가가 수정하려면 코드를 읽어야 하는데 코드 자체가 나쁘다면 주석이 별 도움이 되지 않기 때문이다. 

 

 

[예외 처리]

 

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

 

과거에는 예외를 지원하지 않는 프로그래밍 언어가 많았다고 한다. 때문에 오류를 처리하고 보고하는 방법이 상당히 제한적이었다. 직접 if문을 사용하여 오류 플래그를 만들고 오류코드를 직접 반환하는 방법이 전부였다.

하지만 예외문이 등장하고 오류를 쉽게 처리할 수 있게 되었다. 코드를 통해 살펴보자.

 

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 ......");
        ....
    }
    
    ...
}

예외를 던지고 한 번에 처리해주는 것만으로도 코드가 많이 깨끗해졌다. 단순히 보기 좋아진 것뿐만 아니라 sendShutDown()에서 오류를 처리하고 tryToShutDown()에서 디바이스를 종료하여 뒤섞인 두 개념이 분리되어 코드의 품질도 더 나아졌다. 

 

null을 반환하지 마라

우리가 오류를 유발하는 습관 중 하나는 null을 반환하는 것이다. 평소 if문을 통해 null을 반환해 왔다면 문제가 없을 것이라고 생각할 수 있다. 그러나 어디선가 하나라도 null 확인을 빼먹는다면 NullPointerException이 발생하며 애플리케이션이 통제 불능에 빠질 가능성이 생긴다. 코드를 통해 살펴보자.

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

위의 코드는 null을 반환한다. 하지만 반드시 null을 반환할 필요가 없다. getEmployees()를 변경해 빈 리스트를 반환한다면 코드가 훨씬 깔끔해질 것이다. 다행히도 자바에는 Collections.empltyList() 읽기 전용 빈 리스트를 반환해 줄 수 있다.

List<Employee> employees = getEmployees();
    for (Employee e : employees) {
        totalPay += e.getPay();
    }
    
// 직원이 없다면 빈 리스트를 반환
public List<Employee> getEmployees(){
	if (.. 직원이 없다면 ..) {
        return Collections.emptyList();
    }
}

위와 같이 코드를 변경한다면 코드도 깔끔해질 뿐만 아니라 NullPointerException이 발생할 가능성도 줄어든다.

 

 

[경계]

오픈 소스, 외부 코드를 내 코드에서 호출하는 부분을 경계라고 한다.

우리는 이 외부 코드들을 우리 방식대로 만들어야 한다. 해당 장에서는  소프트웨어 간의 경계를 깔끔하게 처리하는 기법과 기교를 알려준다.

 

외부코드 사용하기

아래 코드와 같이 Map은 아주 다양한 인터페이스로 수많은 기능을 제공한다. Map이 제공하는 기능성과 유연성은 유용하지만 다음과 같은 위험성이 있다.

Map<String, Sensor> sensors = SensorsFactory().get(); // 외부 라이브러리
 
Sensor s = sensors.get(sensorId);

1. Map에서 제공해주는 메서드로 값을 조작할 수 있는 문제가 발생한다.

2. Map을 여기저기 제공한다 가정했을 때 인터페이스가 변경된다면 수정할 코드의 양이 많아진다.

 

이 문제를 처리할려면

public class Sensors {
    private Map sensors = new HashMap();
    
    public Sensor getById(String id) {
        return (Sensor) sensors.get(id);
    }
    
    .....
}

경계 인터페이스인 Map을 Sensors 클래스 안으로 숨기는 방법이다. 따라서 Map 인터페이스가 변하더라도 나머지 프로그램에는 영향을 끼치지 않는다. Sensors 클래스 안에서 객체 유형을 관리하고 변환하기 때문이다. 또한 getById()처럼 프로그램에 필요한 인터페이스만 제공하도록 제한할 수 있다. 덕분에 코드는 이해하기 쉽지만 오용하기 어렵게 됐다.

 

[단위 테스트]

TDD 법칙 3가지

  • 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
  • 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  • 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

 

깨끗한 테스트 코드

깨끗한 테스트 코드를 만들려면 가독성이 제일 중요하다.

 

도메인에 특화된 테스트 언어

시스템 특화 api를 사용하는 대신 api 위에다 함수와 유틸리티를 구현한 후 그 함수와 유틸리티를 사용하면 테스트 코드를 짜기도 읽기도 쉬워진다. 테스트 api를 구현에 도메인 특화 언어(DSL)를 만들면 테스트 코드 짜기가 쉬워진다.

 

이중표준

실제 환경과 테스트 환경은 요구사항이 판이하게 다르다. 실제 환경에서는 안되지만 테스트 환경에는 문제없는 방식이 있다. 성능보다는 가독성에 투자하자.

 

테스트 당 aseert 하나

assert 문이 단 하나일 경우 결론이 하나이기 때문에 코드를 이해하기 쉽다.

 

정리

테스트 코드를 작성할 때는 독립, 반복, 자가 검증, 가독성이 중요하다.

 

[설계 품질을 높여주는 4가지 규칙]

  • 모든 테스트 실행
    • 테스트가 쉬운 코드를 작성하다 보면 SRP를 준수하고, 더 낮은 결합도를 갖는 설계를 얻을 수 있다.
  • 중복 제거
    • 깔끔한 시스템을 만들기 위해 단 몇 줄이라도 중복을 제거해야 한다.
  • 프로그래머의 의도를 표현
    • 좋은 이름, 작은 클래스와 메소드의 크기, 표준 명칭, 단위 테스트 작성 등을 통해 이를 달성할 수 있다.
  • 클래스와 메소드의 수를 최소로 하기
    • 클래스와 메소드를 작게 유지함으로써 시스템 크기 역시 작게 유지할 수 있다.