JaeWon's Devlog
article thumbnail
Published 2023. 10. 14. 16:51
[클린코드] 3장. 함수 Study/CleanCode
반응형

클린코드(CleanCode)를 읽고 간략하게 정리한 글입니다.


3장. 함수

1. 작게 만들어라

- 함수를 만드는 첫째 규칙은 "작게" 이고, 둘째 규칙은 "더 작게" 이다.

- 함수가 작을수록 좋다는 증거나 자료를 제시하기는 쉽진 않지만, 짧을수록 각 함수가 명백하고 하나의 동작을 한다는 것은 확실하다.

  • 나쁜 예시
public void lotto() {

        int[] lottoNumbers = new int[6];
        Random random = new Random();

        // 랜덤번호 생성
        for (int i = 0; i < lottoNumbers.length; i++) {
            lottoNumbers[i] = random.nextInt(45) + 1;
            // 중복번호 제거
            for(int j = 0; j < i; j++) {
                if(lottoNumbers[i] == lottoNumbers[j]) {
                    i--;
                    break;
                }
            }
        }

        // 오름차순 정렬
        for(int i = 0; i < lottoNumbers.length; i++) {
            for(int j = i + 1; j < lottoNumbers.length; j++) {
                if(lottoNumbers[i] > lottoNumbers[j]) {
                    int temp = lottoNumbers[i];
                    lottoNumbers[i] = lottoNumbers[j];
                    lottoNumbers[j] = temp;
                }
            }
        }

        // 로또번호 출력
        System.out.println("로또 번호 : " + Arrays.toString(lottoNumbers));
    }

- 위 코드를 살펴보면 그나마 간단한 코드다 보니 로또 번호를 만들어 정렬하여 출력한다는 것을 알 수는 있을 것이다.

- 그렇지만, 조금 복잡한 로직과 길이가 길어진다면 이해하기 어려울 것이다.

  • 좋은 예시
public void lotto() {

    int[] lottoNumbers = createLottoNumber();

    lottoNumbers = sortNumbers(lottoNumbers);

    // 로또번호 출력
    System.out.println("로또 번호 : " + Arrays.toString(lottoNumbers));
}

private int[] createLottoNumber() {

    int[] lottoNumbers = new int[6];
    Random random = new Random();

    // 랜덤번호 생성
    for (int i = 0; i < lottoNumbers.length; i++) {
        lottoNumbers[i] = random.nextInt(45) + 1;
        // 중복번호 제거
        for(int j = 0; j < i; j++) {
            if(lottoNumbers[i] == lottoNumbers[j]) {
                i--;
                break;
            }
        }
    }

    return lottoNumbers;
}

private int[] sortNumbers(int[] lottoNumbers) {
    // 오름차순 정렬
    for(int i = 0; i < lottoNumbers.length; i++) {
        for(int j = i + 1; j < lottoNumbers.length; j++) {
            if(lottoNumbers[i] > lottoNumbers[j]) {
                int temp = lottoNumbers[i];
                lottoNumbers[i] = lottoNumbers[j];
                lottoNumbers[j] = temp;
            }
        }
    }

    return lottoNumbers;
}

- 리팩토링 한 코드를 보면 lotto() 메서드 길이가 확 줄어들었고, 이외 메서드도 나누면서 작게 분리가 되었다.

- 그리고 각 메서드가 어떠한 행위를 하고, 하나의 동작을 한다는 것도 확인할 수 있다.

1-1. 함수의 길이와 가로 글자 수

- 최근에는 한 화면에 글꼴을 조절하면 가로 150자 세로 100줄도 들어간다.

- 그렇기에 가로 150자를 넘어서는 좋지 않다.

반드시 한 화면에 들어가야 한다.

- 함수는 100줄을 넘어서는 안된다.

1-2. 블록과 들여 쓰기

- if / else 문, while 문, for 문 등에 들어가는 블록은 한 줄이어야 한다.

- 블록 안에서 호출하는 함수 이름을 적절히 짓는다면, 코드를 이해하기도 쉬워진다.

- 중첩 구조가 생길 만큼 함수가 커져서는 안 된다.

- 함수에서 들여 쓰기(indent) 수준은 2단계를 넘어서는 안된다.

2. 한 가지만 해라!


함수는 한 가지를 해야 한다. 그 한 가지를 잘해야 한다. 그 한 가지만을 해야 한다.

- "한 가지"란? 추상적인 수준의 하나의 작업을 의미한다.

- 예를 들어, 물 마시기라는 작업은 아래의 과정을 거치지만 추상적 개념으로는 하나의 작업으로 볼 수 있다.

  1. 물 마실 컵을 든다.
  2. 입으로 컵을 가져간다.
  3. 물을 마신다.

- 지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 하는 것이다.

- 단순히 다른 표현이 아니라 의미 있는 이름으로 다른 함수를 추출할 수 있다면 해당 함수는 여러 작업을 하는 것이다.

2-1. 함수 내 섹션

- 함수를 여러 섹션으로 나눌 수 있다면 그 함수는 여러 작업을 한다는 증거이다.

3. 함수 당 추상화 수준은 하나로!

- 함수가 확실히 "한 가지" 작업만 하려면 함수 내 모든 문장의 추상화 수준이 동일해야 한다.

- 한 함수 내에서 추상화 수준이 섞인다면 코드를 읽는 사람은 헷갈린다.

  • 특정 표현이 근본 개념인지 아니면 세부사항인지 구분하기 어렵기 때문이다.
  • 근본 개념과 세부사항이 뒤섞이기 시작하면, 깨어진 창문처럼 사람들이 함수에 세부사항을 점점 더 추가하게 된다.

3-1. 위에서 아래로 코드 읽기: 내려가기 규칙

- 코드는 위에서 아래로 읽혀야 좋다.

- 한 함수 다음에는 추상화 수준이 한 단계 낮은 함수가 온다.

4. Switch 문

- switch 문은 본질적으로 N가지 일을 처리하기 때문에 위에서 말한 규칙대로 작게 만들기 어렵다.

- 각 switch 문을 저 차원 클래스에 숨기고 절대로 반복하지 않는 방법이 있다. 그것은 바로 다형성을 이용하는 방법이다.

  • 나쁜 예시
public Money calculatePay(Employee e) throws InvalidEmployeeType {
    switch (e.type) {
        case COMMISSIONED:
            return calculateCommissionedPay(e); 
        case HOURLY:
            return calculateHourlyPay(e); 
        case SALARIED:
            return calculateSalariedPay(e); 
        default:
            throw new InvalidEmployeeType(e.type); 
    }
}

- 위 함수는 함수가 길며, 한 가지 작업만 수행하지 않고, SRP(Single Responsibility Principle)를 위반하고, OCP(Open Closeed Principle)를 위반한다.

- 즉, 새로운 직원 유형이 추가될 때마다 코드를 변경해야 하고 구조가 동일한 함수가 무한정 존재할 수 있는 문제가 있다.

  • 좋은 예시
public abstract class 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 {
    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);
        }
    }
}

- 위와 같이 리팩토링이 된다면, 신규 직원이 추가되더라도 우리는 EmplyeeFactoryImpl 클래스에서 직원 타입만 추가해 주면 될 것이다.

5. 서술적인 이름을 사용하라!

- 코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드라고 해도 된다.

- 한 가지만 하는 작은 함수에 좋은 이름을 붙인다면 이 원칙을 달성하에 있어 이미 절반은 성공한 것이다.

- 함수가 작고 단순할수록 서술적인 이름을 고르기도 쉬워진다.

- 이름이 길어도 괜찮다. 길고 서술적인 이름이 짧고 어려운 이름보다는 낫다.

- 서술적인 이름을 사용하면 개발자 머릿속에서도 설계가 뚜렷해지므로 코드를 개선하기 쉬워진다.

6. 함수 인수

- 이상적인 인수는 0개이다. 다음은 1개이고, 3개 이상은 피해야 한다.

- 테스트 관점에서 보면 인수는 어렵다.

  • 갖가지 인수 조합으로 함수를 검증하는 테스트 케이스를 작성한다면 많이 복잡하고 시간이 오래 걸린다.
  • 인수가 0개라면 간단하다.

- 출력 인수는 입력 인수보다 이해하기 어렵다.

  • 흔히 우리는 함수에다 인수로 입력을 넘기고 반환값으로 출력을 받는다는 개념에 익숙하다.
  • 대개 함수에서 인수로 결과를 받으리라 기대하지 않는다.

6-1. 많이 쓰는 단항 형식

- 함수에 인수를 1개를 넘기는 이유로 가장 흔한 경우는 아래 2가지이다.

  • 인수에 질문을 던지는 경우
boolean fileExists("MyFile");
  • 인수를 뭔가로 변환해 결과를 반환하는 경우
InputStream fileOpen("Myfile")

- 함수에 인수 1개를 넘기지만 출력 인수가 없는 경우(이벤트)

- 이벤트 함수라는 사실이 코드에 명확하게 드러나야 하며 그렇지 않다면 단항함수는 가급적 피해야 한다.

passwordAttemptFailedNtimes(int attempts)

6-2. 플래그 인수

- 플래그 인수를 넘기는 관례는 함수가 한꺼번에 여러 가지를 처리한다고 대놓고 이야기하는 것이라 좋지 않다.

6-3. 이항 함수

- 단항 함수보다 이해하기가 어렵다.

- Point 클래스의 new Point(x, y)와 같은 경우에는 적절하다.(x, y는 자연적인 순서를 가진다)

- 이항 함수가 무조건 나쁘다는 말은 아니다. 불가피한 경우도 생기지만 그만큼 위험이 따른다는 사실을 인지하고 가능하면 단항 함수로 바꾸도록 노력해야 한다.

6-4. 삼항 함수

- 인수가 3개인 함수는 인수가 2개인 함수보다 훨씬 더 이해하기 어렵다.

- 위험도가 2배 이상 늘어나며, 삼항 함수를 만들 때는 신중히 고려해야 한다.

6-5. 인수 객체

- 인수가 2-3개가 필요하다면 일부를 독자적인 클래스 변수로 선언을 생각해 본다.

- 객체를 생성해 인수를 줄이는 방법이 눈속임이라 여길지 모르겠지만 그렇지 않다. 오히려 개념을 표현할 수 있게 해 준다.

// bad
Circle makeCircle(double x, double y, double radius);

// good
Circle makeCircle(Point center, double radius);

6-6. 인수 목록

- 때로는 인수 개수가 가변적인 함수도 필요하다.(ex: String.format)

- 가변 인수 정보를 동등하게 취급하면 List 형 인수 하나로 취급할 수 있다.

public String format(String format, Object... args);

6-7. 동사와 키워드

- 단항 함수는 함수와 인수가 동사/명사 쌍을 이뤄야 한다.(ex: write(name) -> writeFeild(name))

- 함수 이름에 키워드를 추가하면 인수 순서를 기억할 필요가 없어진다.(ex: assertEquals -> assertExpectedEqualsActual(expected, actual))

7. 부수 효과를 일으키지 마라!(사이드 이펙트)

- 사이드 이펙트는 거짓말이다.

- 함수에서 한 가지를 하겠다고 약속하고는 남모래 다른 짓을 하는 것이므로, 한 함수에서는 딱 한 가지만 수행해야 한다.

- 예를 들면 아래와 같다.

  • Session.initialize(); 메서드는 부수 효과(사이드 이펙트)이다.
  • 이러한 사이드 이펙트는 시간적 결합을 초래한다. 세션을 지울 수 있는 특정 상황에서만 호출해야 한다.
  • 따라서 checkPassword 메서드 이름은 checkPasswordAndInitializeSession이라는 이름이 맞지만 이는 함수가 "한 가지"만 한다는 규칙을 위반하고 있다.
public class UserValidator {
    private Cryptographer cryptographer;
 
    public boolean checkPassword(String userName, String password) {
        User user = UserGateway.findByName(userName);
        if (user != User.NULL) {
            String codedPhrase = user.getPhraseEncodedByPassword();
            String phrase = cryptographer.decrypt(codedPhrase, password);
            if ("Valid Password".equals(phrase)) {
                Session.initialize();
                return true;
            }
        }
        return false;
    }
}

7-1. 출력 인수

- 일반적으로 출력 인수는 피해야 한다.

- 함수에서 상태를 변경해야 한다면 함수가 속한 객체 상태를 변경하는 방식을 선택해야 한다.

- 객체 지향 언어에서는 출력 인수를 사용할 필요가 거의 없다. 출력 인수로 사용하려고 설계된 변수가 this 이기 때문이다.

- 즉, 아래 함수는 report, appendFooter()로 호출하는 방식이 올바르다.

// Bad
appendFooter(report);

// Good
report.appendFooter();

8. 명령과 조회를 분리하라!

- 함수는 무엇인가를 수행하거나 무엇인가에 답하거나 둘 중 하나만 해야 한다.

- 객체 상태를 변경하거나 아니면 객체 정보를 반환하거나 둘 중 하나만 해야 한다. 둘 다 하면 혼란을 초래한다.

- 예를 들어, public boolean set(String attribute, String value) 같은 경우에는 속성 값 성공 시 true를 반환하는 괴상한 코드이다.

// Bad
if (set("username", "unclebob"))...

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

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

- 명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반한다.

- 자칫하면 if 문에서 명령을 표현식으로 사용하기 쉬운 탓이다.

if (deletePage(page)) === E_OK)

- 위 코드는 동사/형용사 혼란을 일으키지 않는 대신 여러 단계로 중첩되는 코드를 야기한다.

- 오류 코드를 반환하면 호출자는 오류 코드를 곧바로 처리해야 한다는 문제가 발생한다.

- try/catch를 사용하면 오류 처리 코드가 원래 코드에서 분리되므로 코드가 깔끔해진다.

try {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
    logger.log(e.getMessage());
}

9-1. try/catch 블록 뽑아내기

- 위에서는 try/catch를 사용하여 수정하였지만, try/catch 블록은 코드 구조에 혼란을 일으키며, 정상 동작과 오류 처리 동작을 뒤섞는다.

- 그렇기에 try/catch 블록도 별도 함수로 뽑아내어 사용하는 것이 좋다.

// Bad
if (deletePage(page) == E_OK) {
    if (registry.deleteReference(page.name) == E_OK) {
        if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
            logger.log("page deleted");
        } else {
            logger.log("configKey not deleted");
        }
    } else {
        logger.log("deleteReference from registry failed"); 
    } 
} else {
    logger.log("delete failed"); return E_ERROR;
}

// Good
try {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
    logError(e);
}

// Better
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());
}

9-2. 오류 처리도 한 가지 작업이다.

- 함수는 "한 가지" 작업만 해야 한다.

- 오류 처리도 "한 가지" 작업에 속한다.

- 즉, 오류를 처리하는 함수는 오류만 처리해야만 한다.

- 함수에 try 키워드가 있다면 try 문으로 시작해 catch/finally 문으로 끝나야만 한다.

9-3. Error.java 의존성 자석

- 오류 코드를 반환한다는 이야기는, 클래스든 열거형 변수든, 어디선가 오류 코드를 정의한다는 뜻이다.

- 새 오류 코드를 추가하거나 변경할 때 관련 클래스 전부를 다시 컴파일하고 다시 배치해야 한다.

- 그러므로 예외를 사용하는 것이 더 안전하다.

public enum Error {
    OK,
    INVALID,
    NO_SUCH,
    LOCKED,
    OUT_OF_RESOURCES,
    WAITING_FOR_EVENT;
}

- 오류 코드 대신 예외를 사용하면 새 예외는 Exception 클래스에서 파생된다.

- 그렇기에 재컴파일/재배치 없이도 새 예외 클래스를 추가할 수 있게 된다.

10. 반복하지 마라!

- 중복은 모든 소프트웨어에서 모든 악의 근원이므로 중복을 없애도록 노력해야만 한다.

- 중복은 코드 길이가 늘어날 뿐만 아니라 알고리즘이 변한다면 사용하는 모든 곳을 고쳐야 하는 일이 생긴다.

- 구조적 프로그래밍, AOP, COP 모두 어떤 면에서는 중복 제거 전략이다.

11. 구조적 프로그래밍

- 구조적 프로그래밍은 모든 함수와 함수 내 모든 블록에 입구와 출구가 하나만 존재해야 하는 것이다.

- 즉, 함수의 return 문이 하나여야 한다.

- 루프 안에서 break 나 continue를 사용해선 안되며 goto 는 절대 사용해선 안된다.

- 함수가 작을 때는 간혹 return, break, continue를 사용해도 괜찮다.

- 때로는 단일 일/출구 규칙보다 의도를 표현하기 쉬워진다.

12. 함수를 어떻게 짜죠?

- 소프트웨어를 짜는 행위는 글 짓기와 비슷하다.

- 논문이나 기사를 작성할 때는 먼저 생각을 기록한 후 읽기 좋게 다듬는다.

- 함수도 마찬가지로 처음에는 길고 복잡하다. 들여 쓰기 단계도 많고 중복된 루프도 많고 인수 목록도 길 것이다. 이름은 즉흥적이고 코드는 중복된다 그 코드를 테스트하는 단위 테스트 코드를 만들고 코드를 다듬고, 함수를 만들고, 이름을 바꾸고, 중복을 제거해야 한다.

- 위와 같은 행위를 함으로써 최종적으로 이 장에서 설명한 규칙을 따르는 함수가 얻어진다.

- 처음부터 짜내는 건 어렵다.

13. 결론

- 함수는 언어에서 동사며, 클래스는 명사이다.

- 마스터 프로그래머는 시스템을 구현할 프로그램이 아니라 풀어갈 이야기로 여긴다.

- 여기서 설명한 규칙을 따른다면 길이가 짧고, 이름이 좋고, 체계가 잡힌 함수가 나올 것이다.

- 하지만 진짜 목표는 시스템이라는 이야기를 풀어가는 데 있다는 사실을 명심해야 한다.

반응형
profile

JaeWon's Devlog

@Wonol

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!