[개발 도서 공부 - 📗 Clean Code] 3장: 함수

Hyunjoon Choi·2023년 10월 15일
0
post-thumbnail

작게 만들어라!

함수를 만드는 첫째 규칙은 '작게!'다. 함수를 만드는 둘째 규칙은 '더 작게!'다.

잘 알려져 있는 내용이다. 책에서는 함수가 작을수록 더 좋다는 증거나 자료를 제시하기엔 어려우나, 자신의 경험을 통해 작은 함수가 더 좋다고 확신한다고 한다.

들여쓰기 (indent)에 관하여

다음과 같은 코드를 접한 적이 있을 것이다.

이 함수에서 들여쓰기가 너무 많기 때문에 (보통 들여쓰기는 if, for, while 등의 코드로 생긴다.), 실제로 내부 메서드가 실행되기까지의 조건을 일일히 다 기억하고 있어야 하는 부담이 있기에 위 코드는 좋지 않은 코드라고 생각된다. 따라서 저자는 함수에서 들여쓰기 수준은 1단이나 2단을 넘어서면 안 된다고 말하며 (그래야 읽고 이해하기 쉽다), 우아한테크코스의 미션에서도 들여쓰기를 제한하는 이유이기도 할 것이다.

한 가지만 해라!

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

함수가 '한 가지'만 하는지 판단하는 방법이 하나 더 있다. 단순히 다른 표현이 아니라 의미 있는 이름으로 다른 함수를 추출할 수 있다면 그 함수는 여러 작업을 하는 셈이다.

개인적인 생각은 함수가 작고 한 가지만을 해야, 변경 요청이 들어올 때 그에 파생되는 변경이 가능한 적게 전파되기 때문인 것 같다. 그만큼 핵심이고 중요한 내용이라고 생각한다.

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

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

추상화 수준이 동일해야 한다니.. 가장 지키기 어려운 규칙으로 보였다.

처음에는 추상화 수준을 뜻하는 게 어떤 뜻인지가 이해되지 않았었는데, 책의 세부 내용을 보니 이해되기 시작했다.

getHtml()은 추상화 수준이 아주 높다. 반면, String pagePathName = PathParser.render(pagepath);는 추상화 수준이 중간이다. 그리고 .append("\n")와 같은 코드는 추상화 수준이 아주 낮다. 한 함수 내에 추상화 수준을 섞으면 코드를 읽는 사람이 헷갈린다. 특정 표현이 근본 개념인지 아니면 세부사항인지 구분하기 어려운 탓이다.

즉, 메서드를 얼마나 감추고 있는지, 얼마나 구체적으로 작업하고 있는지 등을 기준으로 추상화 수준을 나눌 수 있다. 이 규칙이 지키기 어렵다고 생각된 이유는, 인자가 필요 없는 함수에서 내부적으로 변수를 취한 뒤 그 변수를 내부의 또다른 함수를 호출할 때 인수로 사용하는 등의 작업을 진행한 경우가 많았기 때문이었다.

"진짜 이렇게까지 할 수 있나?" 하고 뒷내용을 봤더니 다음과 같이 진짜 추상화에 따라 나뉘어서 매우 놀랐다.

// 높은 추상화
private void includeSetupAndTeardownPages() throws Exception {
    includeSetupPages();
    includePageContent();
    includeTeardownPages();
    updatePageContent();
}

// 중간 추상화
private void include(String pageName, String arg) throws Exception {
    WikiPage inheritedPage = findInheritedPage(pageName);
    if (inheritedPage != null) {
         String pagePathName = getPathNameForPage(inheritedPage);
         buildIncludeDirective(pagePathName, arg);
    }
}

내려가기 규칙

코드는 위에서 아래로 이야기처럼 읽혀야 좋다. 한 함수 다음에는 추상화 수준이 한 단계 낮은 함수가 온다.

저자는 읽을 때 추상화가 내려가야 이야기처럼 읽힐 수 있다고 한다.

하지만 이 부분은 함수의 위치와 관련된 부분이다보니, 이전에 세운 규칙과 충돌될 수 있는 것 아닌가?라는 의문이 들 줄 알았지만 다시 확인해보니 아니었다. ^^...

클래스를 정의하는 표준 자바 관례에 따르면, 가장 먼저 변수 목록이 나온다. 정적 (static) 공개 (public) 상수가 있다면 맨 처음에 나온다. 다음으로 정적 비공개 (private) 변수가 나오며, 이어서 비공개 인스턴스 변수가 나온다. 공개 변수가 필요한 경우는 거의 없다.
변수 목록 다음에는 공개 함수가 나온다. 비공개 함수는 자신을 호출하는 공개 함수 직후에 넣는다. 즉, 추상화 단계가 순차적으로 내려간다. 그래서 프로그램은 신문 기사처럼 읽힌다.

천천히 다시 읽어보니 이해가 되었다. 비공개 함수가 존재하는 이유는 그 클래스에서만 사용되는 특별한 함수이기 때문이다. 그리고 보통은 공개 함수를 보조해주는 보조 함수로써 작동한다. 따라서, 자신을 호출하는 외부 함수의 추상화는 다른 클래스, 함수와의 소통을 위해 추상화 수준이 높더라도, 비공개 함수의 추상화는 같거나 더 낮아지는게 (즉, 더 구체적으로) 올바른 것이다. 나는 이러한 원리가 선물 포장지를 뜯어보는 것과 비슷하다고 생각된다.

다음과 같은 경우가 생길 수 있음에 유의하자.

private String render(boolean isSuite) throws Exception {
    this.isSuite = isSuite;
    if (isTestPage())
    includeSetupAndTearDownPages();
    return pageData.getHtml();
}

private boolean isTestPage() throws Exception {
    return pageData.hasAttribute("Test");
}

위의 render(boolean isSuite)와 그 안의 내용을 비교해 보면, 인자가 있다가 없어지므로 추상화가 오히려 높아진 것 아닌가? 라는 의문이 들었다.

따라서, 이것은 내 개인적인 추측이지만 저자가 말한 함수 내 모든 문장인자 부분을 제외한 부분을 언급하는 것 같다는 생각이 들었다. 그래야만 추상화 수준이 높은 외부 함수에서 추상화 수준이 낮은 비공개 함수를 호출하는 게 문맥상 가능하기 때문이다.

다음과 같이 작성해보도록 하자!

공개_함수() {
    // 인자를 정의하는 로직
    비공개_함수(인자);
}

비공개_함수(인자) { ... } // "비공개 함수는 자신을 호출하는 공개 함수 직후에 넣는다"는 규칙을 따른다.

다른_공개_함수() { ... } // 다른 공개 함수는 위의 공개 함수와 관련된 비공개 함수들이 끝난 뒤 정의한다.

Switch 문

switch 문은 작게 만들기 어렵다. case 분기가 단 두 개인 switch 문도 내 취향에는 너무 길며, 단일 블록이나 함수를 선호한다. 또한 '한 가지' 작업만 하는 switch 문도 만들기 어렵다. 본질적으로 switch 문은 N가지를 처리한다.

위의 사진에서 보듯이 우아한테크코스에서도 switch를 사용하지 않도록 하는 이유를 알았다. 그것은 클린 코드 원칙 상 함수는 한 가지만 집중해야 하기 때문이다. switch 문은 기본적으로 여러 case들에 대한 작업을 하기 위한 것이기 때문에, 클린 코드에 부적합하다고 하는 것이다.

해결법: 추상 팩토리 만들기

switch를 해결하는 방법은 추상 팩토리 (abstract factory)를 사용하는 것이다. 그러나 완벽히 switch를 없애는 것은 아니며, 다형성을 활용하여 조금 더 낫게 한 방식이다.

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;
}

// Employee의 구체 타입에 따라 적절한 Employee 구현체를 생성하도록 한다.
public class EmployeeFactoryImpl implements EmployeeFactory {
    public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
        switch(r.type) {
             case COMMISSIONED:
                 return new CommisionedEmployee(r);
             case HOURLY:
                 return new HourlyEmployee(r);
             default:
                 throw new InvalidEmployeeType(r.type);
        }
    }
}

그런데 switch-case를 아예 사용하지 않도록 컨벤션이 정해져있다면, 이렇게 사용하지 않고 어떻게 해결할 수 있을까? 이 부분은 다른 사람들과 토의해보고 싶다. 결국 이 경우에도 따지고 보면 switch-case를 사용하며, 들여쓰기가 3 이상이기 때문이다.

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

이름이 길어도 괜찮다. 겁먹을 필요없다. 길고 서술적인 이름이 짧고 어려운 이름보다 좋다. 길고 서술적인 이름이 길고 서술적인 주석보다 좋다.

이름 정의에 관한 부분은 전 내용을 참고하자.

길고 서술적인 이름으로 작성해야 하는 이유는, 코드를 읽으면서 짐작했던 기능을 수행할 수 있도록 정의하기 때문이다.

함수 인수

함수에서 이상적인 인수 개수는 0개(무항)다. 다음은 1개(단항)고, 다음은 2개(이항)다. 3개(삼항)는 가능한 피하는 편이 좋다. 4개 이상(다항)은 특별한 이유가 필요하다. 특별한 이유가 있어도 사용하면 안 된다.

되돌아보면, 인수를 3개 정도 사용한 함수들이 살짝 있었던 것 같다. 위의 추상화에 대해 공부하면서 인수가 적을수록 추상화가 높아진다는 것은 새롭게 알게 되었지만, 부수적인 효과가 뭐가 있는지 알아보자.

인수가 있다면 독자는 인수를 해석해야 한다.

예시로 StringBuffer를 활용하여 글 내용을 덧붙이는 코드가 있다고 해 보자. 만약 이것을 함수 인자에 매번 넣어줬다면 (ex: printResult(stringBuffer)), 변수 stringBuffer가 무엇의 역할을 하는지 매번 파악해야 한다. (간단한 예시라 금방 파악하겠지만 다른 케이스들에서는 훨씬 복잡할 것이다. 물론 인수가 1개인 경우는 차선으로 사용해도 된다고 나온다.)

세부사항에 관여한다.

별로 중요하지 않은 StringBuffer에 대해 알게 되는 문제가 발생한다. 추상화를 훼손하는 행위라고 생각한다. 그냥 printResult()로 해도 의미가 전달될 것이다!

테스트하기 어렵다.

인수가 많으면 테스트하기 어렵다. 해당 함수에 필요한 인수에게 값을 다 저장해야 하는 문제가 있기 때문이다.

동사와 키워드

  • 단항 함수는 함수와 인수가 동사/명사 쌍을 이루도록 한다. (ex: write(name))
  • 함수 이름에 키워드를 넣을 수도 있다. (ex: assertExpectedEqualsActual(expected, actual) 그러면 인수 순서를 기억하지 않아도 된다.

부수 효과를 일으키지 마라!

부수 효과 (side effect)란 부가적인 영향을 뜻한다.

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;
        }
    }
}

위 함수는 세션이 초기화해도 괜찮은 상황에서만 쓸 수 있다. 만약 그렇지 않은 상황이라면 checkPassword라는 이름 하에 세션이 바뀌어진 것이므로 어디에서 문제가 발생했는지 조기에 파악하기 어렵다! 따라서 함수가 한 가지만 한다는 규칙을 위반하면서 checkPasswordAndInitializeSession 으로 변경하거나, 함수의 분리를 통해 리팩터링 해야 할 것이다.

명령과 조회를 분리하라!

함수는 뭔가를 수행하거나 뭔가에 답하거나 둘 중 하나만 해야 한다. 둘 다 하면 안 된다. 객체 상태를 변경하거나 객체 정보를 반환하거나 둘 중 하나다.

아마 가장 지켜지지 않았던 게 아닌가 싶다.

public boolean set(String attribute, String value);

위 코드를 다음과 같이 해 보면 문맥상 이상하다.

if (set("username", "unclebob")) {...}
  • unclebob으로 username이 설정되어 있는지
  • unclebob으로 username으로 설정하는 건지

둘 중에 어떤 의미인지 헷갈리기 쉽다!

if 안에 들어있으면 set 단어는 형용사로 느껴져, 동사로 의도했더라도 다르게 해석될 수 있다. 아예 다음과 같이 수정하자.

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

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

명령 함수에서 오류 코드를 반환하는 것은 명령과 조회를 분리하는 것을 미묘하게 위반할 수 있다. 동사/형용사 혼란은 없지만, 예외가 일어나지 않았을 경우를 초래한다.

if (deletePage(page) == E_OK) {
    if (registry.deleteReference(page.name) == E_OK) {
        ...
    } else {
        logger.log("deleteReference from registry failed");
    }
} else {
    logger.log("delete failed");
    return E_ERROR;
}

더 길어진다면 위의 스트리트 파이터 코드처럼 될 것이다. 다음과 같이 변경한다.

try {
    deletePage(page);
    registry.deleteReference(page.name);
} catch (Exception e) { // 물론 이렇게 전체적인 Exception을 던지는 것 또한 정확한 원인을 명시하지 않기에 지양해야 한다.
    logger.log(e.getMessage());
}

try/catch를 뽑아내라

try/catch 블록은 원래 추하다. 모든 오류를 처리하는 부분과 정상 동작 부분을 나누자.

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

private void logError(Exception e) {
    logger.log(e.getMessage());
}

오류 처리도 하나의 작업이기에, 오류를 처리하는 함수는 오류만 처리하도록 할 수 있다.

Enum 대신 예외를 사용할 때의 장점

오류 코드를 정의할 때 보통 Enum 타입으로 정의할 수 있다.

public enum Error {
    OK,
    INVALID,
    ...
}

만약 Enum을 통해 표현한다면, Error enum이 변할 경우 이것을 사용하는 클래스 전부를 다시 컴파일하고 재배치해야 한다. 그러나 오류 코드 대신 예외를 사용하면 새 예외는 Exception 클래스에서 파생되기에, 재컴파일/재배치 없이도 새 예외 클래스를 추가할 수 있다.

이 부분은 쉽게 와닿지 않는다. 한번 직접 해 보는 게 나을 것 같다!

반복하지 마라!

중복은 소프트웨어에서 모든 악의 근원이다. 많은 원칙과 기법이 중복을 없애거나 제어할 목적으로 나왔다.

만약 함수의 내용을 풀어서 전부 쓰일 때 마다 작성한다면 알고리즘이 변할 경우 전부 손봐야 하고, 한 곳이라도 빠뜨리면 오류가 발생할 확률도 높다. 따라서 함수화하여 반복을 없애보자.

구조적 프로그래밍

구조적 프로그래밍은 에르허츠 데이크스트라 (Edsger Dijkstra)의 원칙이다. 모든 함수와 함수 내 모든 블록에 입구와 출구가 하나씩만 존재하는 것인데, 따라서 루프 안에서 break, continue 등을 절대 사용하면 안 된다.

하지만 함수를 작게 만든다면 구조적 프로그래밍의 이점을 별로 취할 수 없기에, return, break, continue를 사용하는 게 더 나을 때가 있기도 하다.

함수를 어떻게 짜죠?

소프트웨어를 짜는 행위는 여느 글짓기와 비슷하다... 내가 함수를 짤 때도 마찬가지다. 처음에는 길고 복잡하다. 들여쓰기 단계도 많고 중복된 루프도 많다. 인수 목록도 아주 길다. 이름은 즉흥적이고 코드는 중복된다. 하지만 나는 그 서투른 코드를 빠짐없이 테스트하는 단위 테스트 케이스도 만든다.
최종적으로는 이 장에서 설명한 규칙을 따르는 함수가 얻어진다. 처음부터 탁 짜내지 않는다. 그게 가능한 사람은 없으리라.

나는 그동안 코드를 엄청 잘 짜는 사람들은 한 번에 클린 코드답게 바로 바로 작성하는 줄 알았다. 하지만 이 부분을 읽고 나서 생각이 달라졌다.

왜 소프트웨어를 짜는 행위는 글짓기와 비슷하다고 생각하고 있었으면서도, 바로 바로 고급스러운 코드를 작성한다고 생각했던 것일까? 흔히 유명한 작가가 책을 집필할 때는 초안이 매우 더럽지만 점차 개선해나가 마침내 깔끔한 글을 작성하기 마련이다. (물론 실력이 오를수록 초안 자체가 더 좋아질 것이다.)

즉 이러한 많은 규칙을 처음부터 설계하기보다는, 점차 고쳐나가면서 위 규칙들을 적용해야겠다고 생각이 들었다. (적어도 지금 수준에서는!) (물론 르블랑의 법칙이 있지만.. 그만큼 흐지부지되지 않도록 주의하자)

결론

이름 작성법도 그렇지만, 함수 생성에 관련해서도 매우 까다로움을 알게 되었다. 인수와 관련해서는 그렇다면 인수가 많은 생성자 코드도 위반될려나..? 라는 생각이 들었는데, 이 부분은 어떻게 생각하는지 다른 사람들의 의견이 궁금하기도 하고, 이론을 어떻게 실제 코드에 적용할 수 있을까? 내가 이렇게까지 할 수 있을까? 라는 생각이 들기도 한다. 이러한 이론을 실생활에 적용할 수 있도록 더 노력해보자.

profile
개발을 좋아하는 워커홀릭

0개의 댓글