Clean Code & Refactoring

[Clean Code] 3장. 함수 : Functions

유자맛바나나 2021. 12. 12. 00:10

 

[Clean Code 시리즈 포스팅]

[Clean Code] 1장. 깨끗한 코드: Clean Code

[Clean Code] 2장. 의미 있는 이름 : Meaningful Names

[Clean Code] 3장. 함수 : Functions (Now)

[Clean Code] 4장. 주석 : Comments

[Clean Code] 5장. 형식 맞추기 : Formatting

[Clean Code] 6장. 객체와 자료구조 : Objects and Data Structures

[Clean Code] 9장. 단위 테스트 : Unit Tests

 

 

❑ 작게, 그리고 더 작게 만들기

  • 함수를 만드는 첫째 규칙은 '작게'다. 함수를 만드는 둘째 규칙은 '더 작게'다. 저자는 10줄도 길다고 표현한다.
  • if / else / while 문 안에 들어가는 블록은 가급적 한 줄이어야 한다. 블록에서 호출하는 함수 이름을 적절히 짓는다면 코드를 이해하기 쉬워진다.
  • 함수에서 들여쓰기 수준은 1단이나 2단을 넘어서면 안된다.

 

 한 가지만 하기

  • 다음은 지난 30여년 동안 여러 가지 다양한 표현으로 프로그머들에게 주어진 충고다
    "함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다."
  • '한 가지'의 판단 기준1) 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 한다고 볼 수 있다.
    예) 아래 함수는 conditionCheck, execute 두 가지 일을 한 다고 볼 수 있지만, 함수 이름 아래 추상화 수준이 한 단계이므로 한 가지 일을 한다고 볼 수 있다. 만약, isCondtionPass()의 함수 내용이 아래 함수에 작성되어 있거나, execute의 상세 내용이 작성되어 있다면 더 낮은 단계의 추상화 수준이 적용된 것으로 볼 수 있다.
public boolean executeAfterConditionPass(Request request){    
    if(isConditionPass(request)) {
    	execute(request);
        return true;
    }
    return false;
}
  • '한 가지'의 판단 기준2) 함수 내의 코드를 의미 있는 다른 이름으로 함수를 추출할 수 있다면 그 함수는 여러 작업을 하고 있는 것이다.

 

함수 당 추상화 수준은 하나가 되어야 한다

  • 함수가 확실히 '한 가지' 작업만 하려면 함수 내 모든 문장의 추상화 수준이 동일해야 한다. 추상화 수준이 하나인 함수를 구현하는것은 어려운 일이지만 매우 중요한 규칙이다.
  • 추상화 수준이란, 코드가 수행하는 로직의 detail이라고 보면된다. 아래 controlCar 함수에서 drive일 경우 drive 로직이 담긴 drive()를 호출하지만, backward일 경우 backward 로직이 나열되어 있다. 즉, drive 일 때 보다 backward 일 때 수행되는 코드의 로직이 더 detail하다고 볼 수 있고, "drive의 추상화 수준이 더 높다"라고 말할 수 있다.
public int controlCar(int cmd) {
    if(isDrive(cmd)){
    	drive();
    } else if(isBackward(cmd)){
    	if(isGearStickBackward()) {
            ...
        }
    }
}

 

Switch문을 쓸 경우 팩토리 메서드 패턴을 사용하기

  • switch문은 작게 만들기 어렵다. 본질적으로 switch문은 N가지를 처리하기 때문에 '한 가지' 작업만 하는 switch문을 만들기도 어렵다.
  • switch문을 완전히 피할 방법은 없다. 하지만 각 switch 문을 저차원 클래스에 숨기고 절대 반복하지 않는 방법은 있다.

 

서술적인 이름을 사용하기

  • 좋은 이름이 주는 가치는 아무리 강조해도 지나치지 않는다. 함수가 작고 단순할수록 서술적인 이름을 고르기도 쉬워진다.
  • 이름이 길어도 괜찮다. 겁먹을 필요 없다. 길고 서술적인 이름이 짧고 어려운 이름보다 좋다. 길고 서술적인 '이름'이 길고 서술적인 '주석'보다 좋다.
  • 이름을 정하느라 시간을 들여도 좋다. 이런저런 이름을 넣어 코드를 읽어보면 더 좋다.
  • 서술적인 이름을 사용하면 개발자 머릿속에서도 설계가 뚜렷해지므로 코드를 개선하기 쉬워진다. 

 

함수 인수(Parameter)에 대하여

  • 인수는 개념을 이해하기 어렵게 만든다.
  • 함수에서 이상적인 인수 개수는 0개(무항)다. 다음은 1개(단항)고, 다음은 2개(이항)다.
  • 3개(삼항)는 가능한 피하는 편이 좋다. 4개 이상(다항)은 사용하면 안된다.
  • 최선은 입력 인수(input parameter)가 없는 경우이며, 차선은 입력 인수가 1개뿐인 경우다.
  • 많이 쓰는 단항 형식
    • 인수에 질문을 던지는 경우
      예) fileExists("MyFile")
    • 인수를 변환해 결과를 반환하는 경우
      예) convertTsvToJson("MyFile.tsv")
    • 시스템 상태를 바꾸는 이벤트 함수. 이벤트 함수는 입력 인수만 있고, 출력 인수는 없다.
      예) passwordAttemptFailedNtimes(int attemps)
  • 플래그 인수
    • 플래그 인수는 추하다. 함수로 Bool 값을 넘기는 관례는 끔찍하다. 함수가 한꺼번에 여러 가지를 처리한다고 대놓고 쓴 것이나 마찬가지기 때문이다.(플래그가 참이면 ~를, 거짓이면 ~를 하는 것이므로 여러 가지를 처리)
    • 플래그 인수를 사용해야할 경우 true/false에 따른 로직을 수행하는 두 가지 함수로 나눠야 마땅하다.
  • 이항 함수, 삼항 함수
    • 일반적으로 인수가 2개인 함수는 인수가 1개인 함수보다 이해하기 어렵다. 이항 함수가 무조건 나쁜 것은 아니다. 인수가 2개일 수 밖에 없는 불가피한 경우도 있다.
    • 인수가 3개인 삼항 함수는 이항 함수보다 훨씬 더 이해하기 어렵다.
    • 저자가 인수가 늘어날수록 문제가 생긴다고 보는 이유는 function(param1, param2)에서 아래와 같은 문제가 생길 수 있다고 보기 때문이다.
      1. 주춤: function 이름만으로 의도를 파악할 수 없어 주춤하고,
      2. 순서: param1과 param2의 순서가 바뀔 수 있으며,
      3. 무시: 유지보수 과정에서 param1, param2를 무시
    • 인수의 순서는 최근 큰 문제가 되지 않는 듯 하다. Java는 Intellij와 같은 IDE에서 보완해주고 있으며 python은 함수를 call할 때 fundtion(param1="a", param2="b") 또는 fundtion(param2="b", param1="a")와 같이 작성할 수 있기 때문이다.
  • 인수 객체
    • 인수가 2~3개 필요하다면 일부를 독자적인 클래스 변수로 선언할 수 있는지 살펴본다.
      예) makeCircle(double x, double y, double radius)
       → makeCircle(Point center, double radius)
    • 객체를 생성해 인수를 줄이는 방법이 눈속임이라 여겨질지 모르지만 그렇지 않다. 변수를 묶어 넘기려면 이름을 붙여야 하므로 결국 개념을 표현한 것이기 때문이다.
  • 동사와 키워드
    • 단항 함수의 이름은 의미가 이어지도록 함수와 인수가 동사/명사 쌍을 이루도록 작성한다.
      예) write(name)
    • 함수 이름에 인수 이름을 넣는 것도 하나의 방법이다.
      예) asserEquals(expected, actual) 보다 asserExpectedEqualsActual(expected, actual)이 더 좋다.

 

부수 효과를 일으키지 말기(함수 이름으로 명시된 기능 외 다른 기능이 작동되는 것)

  • 예를 들어 checkPassword() 메서드 안에서 Session을 초기화하는 기능이 들어갈 경우 혼란을 야기할 수 있다.

 

명령과 조회를 분리하기

  • 함수는 뭔가를 '수행하거나', 뭔가에 '답하거나' 둘 중 하나만 해야 한다. 둘 다 하면 혼란을 초래한다.
  • 즉, 객체 상태를 변경하거나(명령), 아니면 객체 정보를 반환하거나(조회) 둘 중 하나다. 
  • 예시
    • [분리 전]
      changeMachineState에서 machine의 상태를 변경(명령)하고, 변경되었다는 정보까지 반환(조회)했다. 이렇게 하니 client에서 if문 안에 객체 상태를 변경하는 함수를 작성하는 이상한 코드를 작성하게 된다.
    • [분리 후]
      changeMachineState에서는 machine의 상태만 변경하고, Machine이 Run 상태인지 정보를 반환하는 validState 함수로 분리했다.

분리 전

public boolean changeMachineState(Machine machine, String state){
    if(isPossibleChangeState()){
        machine.state = state;
        return true;
    } else {
        return false;
    }
}

public void client(){
    if(changeMachineState(machine, "run")){
        ...
    }
}

분리 후

public void changeMachineState(String state){
    if(isPossibleChangeState()){
        machine.state = state;;
    }
}

public boolean validState(String state){
    return machine.getState().equals(state);
}

public void client(){
    if(isRun()){
        ...
    }
}

 

오류 코드보다 Exception을 사용하기

  • 명령 함수에서 오류 코드를 반환할 경우 명령/조회 방식을 위반한다. 자칫하면 if 문에서 명령을 표현식으로 사용하기 쉽기 때문이다.
  • Refectoring1과 같이 오류 코드를 사용할 때보다 Exception을 사용할 경우 훨씬 깔끔해진다.
  • 다만, 저자는 "Try/Catch 블록은 원래 추하다"라고 할만큼 Try/Catch의 구조에 문제가 있다고 표현한다. 정상 동작에 대한 내용(try 블록)과 오류 동작에 대한 내용(catch 블록)이 하나의 Try/Catch 안에 담기도록 유도하기 때문이다. 이는 '한 가지' 일만 하는 것이 아니다.
    따라서 Refectoring2와 같이 정상 동작만을 기술한 turnOnAllPartsOfMachine과 오류 동작만을 기술한 logError 함수로 분리한다.
  • 예시

오류 코드 사용

public void executeMachine(Machine machine){
    if(turnOnMachine(machine) == OK){
        if(executeEngine(machine) == OK){
            if(injectFuel() == OK){
                logger.log("machine start")
            } else {
                logger.log("inject fuel failed")
            }
        } else {
            logger.log("execute engine failed")
        }
    } else {
        logger.log("execute machine failed")
    }
}

Refactoring1: Exception 사용 

public void executeMachine(Machine machine){    
    try {
        turnOnMachine(machine)
        executeEngine(machine)
        injectFuel(machine)
    } catch(Exception e) {
        logger.log(e.getMessage());
    }
}

Refactoring2: Try/Catch에서 정상 동작과 오류 동작을 분리

public void executeMachine(Machine machine){    
    try {
        turnOnAllPartsOfMachine(machine)
    } catch(Exception e) {
        logError(e);
    }
}

// 정상 동작 함수
public void turnOnAllPartsOfMachine(Machine machine) throws Exception{    
    turnOnMachine(machine)
    executeEngine(machine)
    injectFuel(machine)
}

// 오류 동작 함수
public void logError(Exception e){
    logger.log(e.getMessage());
}
  • 오류 코드를 반환하는 것은 아래 Error 클래스와 같이 오류 코드를 정의한다는 듯이다. 이는 다른 클래스에서 Error enum을 사용하므로 의존성이 생기는 것이고, Error enum이 변한다면 의존하는 클래스에 변경이 생길 가능성이 높고 다시 컴파일해야 한다.
  • 따라서 점차 Error 클래스를 변경하기 어려워지며, 새로운 에러코드를 추가하는 대신 기존 에러코드를 사용하고자 한다.
  • 위의 Refactoring 예시와 같이 예외(Exception)를 사용한다면 Exception 클래스 안에서만 변경점이 발생하므로 의존성을 낮추고 쉽게 변경할 수 있다.

Error 클래스

public enum Error {
    LACK_OF_FUEL,
    LOW_BATTERY,
    ...
}

 

프로그램 작성은 글쓰기와 같다

  • 프로그램을 작성하는 것은 글짓기와 같다.
  • 초안은 길고 복잡하며, 중복도 많고 이름도 적합하지 않을 수 있다. 하지만 그럼에도 Unit Test는 작성해야 한다.
  • 그런 다음 Refactoring(퇴고)을 진행한다. 추상화 수준을 맞추기 위해 함수를 쪼개고, 적절한 이름을 짓고, 중복을 제거한다. 그런 와중에도 코드는 항상 단위 테스트를 통과해야 한다.
  • 저자는 책에서 "시스템은 구현할 프로그램이 아니라 풀어갈 이야기"라고 표현한다. 그리고 위에서 나열한 함수를 잘 작성하는 방법은 그 이야기를 수월하게 풀어가기 위한 방법중 한 가지로 소개한다.

 

❑ Reference

클린 코드 | 로버트 C.마틴 | 인사이트