본문 바로가기
Back-End/Book

[Clean Code] 3~4장. 함수 & 주석

by 7533ymh 2022. 9. 17.

03장. 함수

프로그램의 가장 기본적인 단위가 함수다.
이 장은 의도를 분명히 표현하는 함수를 어떻게 구현할 지, 함수에 어떤 속성을 부여해야 처음 읽은 사람이 프로글매 내부를 직관적으로 파악할 수 있는 지 등 읽기 쉽고 이해하기 쉬운 함수를 만드는 법에 대해 소개한다.

작게 만들어라!

첫번째 규칙은 함수를 작게 만드는 것이다.
그렇다면 얼마나 작게 만들어야 할까?
if/else 문, while문 등에 들어가는 블록은 한 줄이어야 한다. 그러면 바깥을 감싸는 함수가 작아질 뿐 아니라, 블록 안에서 호출하는 함수 이름을 적절히 짓는다면 코드를 이해하기도 쉬워진다.
즉, 중첩 구조가 생길만큼 함수가 커져서는 안된다. 그러므로 함수에서 들여쓰기 수준은 1단이나 2단을 넘어서면 안 된다.

한 가지만 해라!

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

한 가지가 무엇일까?

  1. 페이지가 테스트 페이지인지 판단한다.
  2. 그렇다면 설정 페이지와 해제 페이지를 넣는다.
  3. 페이지를 HTML로 렌더링한다.
    위와 같은 함수가 있다면 한 가지만 하는 것일까? 세 가지를 하는 것일까?
    위에서 말한 세 단계는 지정된 함수 이름 아래에서 추상화 수준이 하나다. 즉, 페이지가 테스트 페이지이든 아니든 페이지를 HTML로 렌더링한다는 것이다.

지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 한다.
세 단계는 더 이상 줄이기란 불가능하다. 아래 예시를 보자.

// 1
public static String renderPageWithSetupsAndTeardowns(
    PageData pageData, boolean isSuite) throws Exception {
    if(isTestPAge(pageData))
        includeSetupAndTeardownPages(pageData, isSuite);
    return pageData.getHtml();
}


// 2
public static String renderPageWithSetupsAndTeardowns(
    PageData pageData, boolean isSuite) throws Exception {
    includeSetupAndTeardownPagesIfTestPage(pageData, isSuite);
    return pageData.getHtml();
}

1번의 함수를 2번의 함수로 만든다고 해도 똑같은 내용을 다르게 표현할 뿐 추상화 수준은 바꾸지지 않는다.
따라서, 단순히 다른 표현이 아닌 의미 있는 이름으로 다른 함수를 추출할 수 있다면 그 함수는 여러 작업을 한다고 볼 수 있다.

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

함수가 확실히 한 가지 작업만 하려면 함수 내 모든 문장의 추상화 수준이 동일해야 한다.
예를 들어 getHtml()은 추상화 수준이 높은 반면 String pagePathName = PathParser.render(pagepath);와 같은 코드는 추상화 수준이 중간이다. 또한 .append("\n");와 같은 코드는 추상화 수준이 낮다.
한 함수내에 추상화 수준을 섞으면 코드를 읽는 사람이 헷갈린다. 이렇게 특정 표현이 근본 개념인지 아니면 세부사항인지 구분하기 어려워 지면 사람들이 함수에 세부사항을 점점 더 추가할 위험성이 있다.

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

코드는 위에서 아래로 이야기처럼 읽혀야 좋다.
한 함수 다음에는 추상화 수준이 한 단계 낮은 함수가 와야 한다. 즉, 위에서 아래로 프로그램을 읽으면 함수 추상화 수준이 한 번에 한 단계씩 낮아져야 한다.
핵심은 한 가지만 하는 함수이다. 내려가기 규칙처럼 이야기처럼 코드를 구현하면 추상화 수준을 일관되게 유지하기가 쉬워진다.

Switch 문

본질적으로 switch문을 N가지를 처리하기 때문에 완전히 피할 순 없다. 하지만 각 switch문을 다형성을 이용하여 저차원 클래스에 숨기고 절대로 반복하지 않는 방법이 있다.

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를 위반한다. 새로운 직원 타입이 추가되면 새로운 임금 계산 로직을 위해 코드를 변경해야 한다.
  5. 유사한 함수가 계속 파생될 수 있다. 이러한 직원의 타입에 따른 코드는 다른 곳에서 중첩될 수 있다.

이를 해결하기 위해서는 Employee를 추상클래스로 만들고, 직원 타입에 따른 하위 클래스를 선언하도록 한다. 이 과정에서 하위 객체를 생성하기 위한 switch 문은 불가피하다. 하지만 유사한 함수마다 분기해주는 것을 처리할 수 있으며 위의 문제점을 상당수 해결할 수 있다.

public abstract class Employee {
    public abstract boolean isPayday(); 
    public abstract Money calculatePay();
    public abstract void deliverPay(Money pay);
}

public class EmployeeFactory {
    public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
        switch (r.type) {
            case COMMISSIONED:
                return new CommissionedEmployee(r);
            case HOURLY:
                return HourlyEmployee(r);
            case SALARIED:
                return SalariedEmployee(r);
            default:
                throw new InvalidEmployeeType(r.type);
        }
    }
}

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

좋은 이름이 주는 가치는 아무리 강조해도 지나치치 않는다. 코드를 일긍면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 그 자체가 클린 코드인 것이다.
그렇기 위해 한가지만 하는 작은 함수에 좋은 이름을 붙인다면 절반은 성공한 것이다. 함수가 작고 단순할 수록 서술적인 이름을 고르기도 쉬워진다.

서술적인 이름을 위해 길어져도 괜찮다. 함수 이름을 정할 때는 여러 단어가 쉽게 읽히는 명명법을 사용한 후 여러 단어를 사용해 함수 기능을 잘 표현하는 이름을 선택한다.
서술적인 이름을 사용하면 개발자 머릿속에서도 설계가 뚜렷해지므로 코드를 개선하기 쉬워진다.

또한, 이름을 붙일 때는 일관성이 있어야 한다. 즉, 모듈 내에서 함수 이름은 같은 문구, 명사, 동사를 사용한다. includeSetupAndTeardownPages, includeSetupPages, includeSuiteSetupPage, includeSetupPage 와 같이 말이다.

함수 인수

함수에서 이상적인 인수 개수는 무항이며 순서대로 단항, 이항이다. 삼항는 가능한 피하는 편이 좋으며 다항은 특별한 이유가 필요하지만 사용하지 않는 것이 좋다.
왜냐하면 includeSetupPageInfo(Stringbuffer newPageContent)보다 includeSetupPage()가 이해하기 더 쉽기 때문이다. includeSetupPageInfo(Stringbuffer newPageContent)는 함수 이름과 인수 사이에 추상화 수준이 다르다. 그렇기에 코드를 읽는 사람이 별로 중요하지 않은 세부사항 즉, StringBuffer를 알아야 하기 때문이다.

테스트 관점에서 보면 인수는 더 어려워 진다. 갖가지 인수 조합으로 함수를 검증하는 테스트 케이스를 작성한다고 하면 인수가 많아질 수록 테스트하기가 상당히 부담스러워진다.
그러니 최선은 입력 인수가 없는 경우이며, 차선은 입력 인수가 1개가 되도록 구현하자.

많이 쓰는 단항 형식

함수에 인수 1개를 넘기는 이유는 두 가지이다.

  1. 인수에 질문을 던지는 경우 (boolean fileExists("MyFile"))
  2. 인수를 뭔가로 변환해 결과를 반화하는 경우 (InputStream fileOpen("MyFile"))
    이들 두 경우는 독자가 당연하게 받아들이며 함수 이름을 지을 때 분명히 구분해야 한다. 또한 언제나 일관적인 방식으로 두 형식을 사용한다.

드물지만 유용한 단항 함수 형식은 이벤트이다. 이벤트 함수는 입력 인수만 있을뿐 출력 인수는 없다. 그러므로 이벤트라는 사실이 코드에 명확히 드러나야 하며 이름과 문맥을 주의해서 선택해야 한다.

위에서 설명한 경우가 아니라면 단항 함수는 가급적 피하도록 하자. 출력 인수를 사용하는 것이 아닌 반환하여 변환 함수 형식을 따르도록 하자.

플래그 인수

플래그 인수를 사용한다는 것 자체가 함수가 한꺼번에 여러 가지를 처리한다는 것이다.
render(boolean isSuite)보단 renderForSuite()와 renderForSingleTest()로 함수를 나누자.

이항 함수

인수가 2개인 함수는 인수가 1개인 함수보다 이해하기 어렵다.
물론 좌표 등과 같이 인수 2개가 한 값을 표현하는 요소이며 자연적인 순서가 있을때는 적절하다.
하지만 그 외에는 헷갈리기 쉽다. 예를 들어 assertEquals(expected, actual)와 같이 자연적인 순서가 아니여서 헷갈리기 때문에 실수를 많이 한다.

그렇기에 이항 함수가 무조건 나쁘지는 않지만 그만큼 위험이 따른다는 사실을 이해하고 가능하면 단항 함수로 바꾸도록 애써야 한다.

// AS-IS
writeField(outputStream, name);

// TO-BE
// 1. writedFiled 메서드를 outputStream 클래스 구성원으로 
outputStream.writeField(name);

// 2. outputStream을 현재 클래스의 인스턴스 변수로
private OutputStream outputStream

writeField(name); // 해당 함수에서 outputStream을 사용

// 3. 새로운 클래스 생성 
class FieldWriter {
    private OutputStream outputStream

    public write(String name) {
        ...
    }
}

삼항 함수

마찬가지로 인수가 3개인 함수는 인수가 2개인 함수보다 훨씬 더 이해하기 어렵다.
그렇기에 삼항 함수를 만들때는 신중히 고려해야 한다.

인수 객체

인수가 2~3개 필요하다면 일부를 독자적인 클래스 변수로 선언할 가능성이 있다.

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

Circle makeCircle(Point center, double radius);

x,y를 point라는 개념을 표현하게 만들 수 있다.

인수 목록

String.format 메서드처럼 인수 개수가 가변적인 함수도 필요할 때가 있다.
사실 이는 논리적으로 이항 함수라고 볼 수 있다.
그렇기에 예시처럼 가변 인수를 취하는 함수는 단항, 이항, 삼항 함수로 취급할 수 있다. 하지만 이를 넘어서는 인수를 사용할 경우 앞서 말했듯이 문제가 있다.

void monad(Integer... args);
void dyad(String name, Integer... args);
void triad(String name, int count, Integer... args);

동사와 키워드

함수의 의도나 인수의 순서와 의도를 제대로 표현하려면 좋은 함수 이름이 필수이다.
그렇기에 단항 함수는 함수와 인수가 동사/명사 쌍을 이뤄야 한다.

write(name) : 이름을 쓴다

writeField(name) : 이름이 필드이며 그것을 쓴다.

다른 방식은 함수 이름에 키워드를 추가하는 형식이다. 즉, 함수 이름에 인수 이름을 넣으므로 인수 순서를 기억할 필요가 없어진다.

// AS-IS
assertEquals(expected, actual)

// TO-BE
assertExpectedEqualsActual(expected, actual)

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

부수 효과는 결국 함수에서 한 가지 일만 하는 것이 아니라 여러 일을 하는 것과 같다.
때로는 클래스 변수를 수정하거나 함수로 넘어온 인수나 시스템 전역 변수를 수정하는 일과 같이 말이다.
예를 들어, checkPassword(String userName, String password)와 같이 이름과 암호를 확인하는 메서드가 있으며 두 인수가 올바르면 true를 반환하고 아니면 false를 반환한다고 가정해보자. 그런데 암호가 일치할 때 세션을 초기화하는 Session.initialize()와 같은 메서드를 호출한다면 어떻게 될까? 함수 이름인 checkPassword만 보고는 세션 정보를 초기화한다는 정보가 드러나지 않게된다. 즉, 사용자 인증을 위해 사용했다가 기존 세션 정보를 지워버릴 위험이 있게 된다.

이런 부수 효과가 시간적인 결합을 초래하게 된다. 즉, 해당 함수는 세션을 초기화해도 괜찮은 경우에만 호출이 가능하다. 그렇기에 checkPassword라는 이름보단 checkPasswordAndInitializeSession이라는 이름이 더 좋다. (함수가 한 가지만 한다는 규칙은 위반하게 된다.)

출력 인수

일반적으로 우리는 인수를 함수 입력으로 해석하기 때문에 출력 인수를 피해야 한다.
함수에서 상태를 변경해야 한다면 함수가 속한 객체 상태를 변경하는 방식을 택해야 한다.
객체지향언어에서는 출력 인수를 사용할 필요가 없이 this를 사용하면 된다.

// AS-IS
public void appendFooter(StringBuffer report)

// TO-BE
report.appendFooter()

명령과 조회를 분리하라!

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

// AS-IS
public boolean set(String attribute, String value);
// 이름이 attribute인 속성을 찾아 값을 value로 설정한 후 성공여부를 반환

if(set("username","unclebob")) ... 
// "username"이 "unclebob"으로 설정되어 있는지 확인하는 건지 "username"을 "unclebob"으로 설정하는 것인지 모호하게 된다.

// TO-BE
if(attributeExists("username")) {  // 조회
    setAttritube("username", "unclebob"); // 명령
    ...
}

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

명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반한다.
자칫하면 if(deletePage(page == E_OK) 와 같이 if문에서 명령을 표현식으로 사용하기 쉽다.

if (deletePage(page) == E_OK) {
    if(registry.deleteReference(page.name) == E_OK) {
        if(configKeys.deleteKey(page.name.makeKey()) == E_OK) {
            ...
        } else {
            오류 처리
        }
    } else {
        오류 처리 
    }
} else {
    오류처리
}

위와 같이 오류 코드를 반환하면 호출자는 오류 코드를 곧바로 처리해야 해서 여러 단계로 중첩되는 코드를 야기하게 된다.
그러므로 오류 코드 대신 예외를 사용하여 오류 처리 코드가 원래 코드에서 분리되도록 하자.

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

Try/Catch 블록 뽑아내기

try/catch 블록은 코드 구조에 혼란을 일으키며 정상 동작과 오류 처리 동작을 뒤썩기 때문에 별도 함수로 뽑아내는 편이 좋다.

public void delete(Page page) {
    try{
        deletePageAndAllReferences(page);
    } catch (Exception e) {
        logError(e);
    }
}

public void deletePageAndAllReferences(Page page) throws Excpetion {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
}

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

앞서 본 예제를 delete함수를 통해 모든 오류를 처리하도록 하였다. 그로 인해 정상 동작과 오류 처리 동작을 분리되었고 코드를 이해하고 수정하기 쉬워졌다.

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

오류 처리도 한 가지 작업이다. 그러므로 위의 예제처럼 오류를 처리하는 함수는 오류만 처리해야 마땅하다.
즉, 함수에 키워드 try가 있다면 해당 함수는 try문으로 시작해 catch/finally문으로 끝나야 한다. 다른 작업을 하면 안된다.

Error.java 의존성 자석

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

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

위와 같은 클래스는 의존성 자석이다. 즉, 다른 클래스에서 해당 Error Enum을 import해 사용해야 하므로 만약 Error Enum이 변경된다면 해당 오류 코드를 사용하는 모든 클래스를 다시 컴파일하고 배치해야 한다.
그러므로 Error 클래스 변경이 어려워지게 된다. 이를 피하기 위해 새 오류 코드를 추가하는 대신 기존 오류 코드를 재사용하는 문제점이 발생하기도 한다.

그렇기 때문에 오류 코드 대신 예외를 사용한다. 새 예외는 Exception 클래스에서 파생되기 때문에 재컴파일/재배치 없이도 새 예외 클래스를 추가할 수 있다. 이는 OCP원칙을 지키는 예이기도 하다.

반복하지 마라!

중복은 문제이다. 코드 길이가 늘어날 뿐 아니라 변경이 필요하다면 모든 곳을 변경해야 ㅎ나다. 또한, 어느 한곳이라도 빠뜨린다면 오류가 발생할 확률도 높아지게 된다.

중복을 없애면 모듈 가독성이 크게 높아지게 된다.
그래서 많은 원칙과 기법이 중복을 없애거나 제어할 목적으로 나왔다. 중복을 제거할 목적으로 관계형 데이터베이스에 정규 형식을 만들거나 객체 지향 프로그래밍에서는 코드를 부모 클래스로 몰아 중복을 없앤다. 구조적 프로그래밍, AOP, COP 모두 어떤 면에서 중복 제거 전략이다.

구조적 프로그래밍

모든 함수와 함수 내 모든 블에 입구와 출구가 하나만 존재해야 한다고 말한다. 즉, 함수는 retrun문이 하나여야 하며 루프 안에서 break나 continue를 사용하면 안되고 goto는 절대로 사용하지 않아야 한다.
하지만 함수가 작다면 위의 명령어들을 여러 차례 사용해도 상관없다. 하지만 goto문은 작은 함수에서도 피해야 한다.

함수는 어떻게 짜죠?

코드를 짜는 행위는 마치 글짓기와 비슷하다. 논문이나 기사를 작성할 때 먼저 생각을 기록한 후 일긱 좋게 다듬게 된다. 초안이 대게 서투르고 어수선하더라도 원하는 대로 읽힐 때까지 말을 다듬고 문장을 고치고 문단을 정리하게 된다.

코드도 마찬가지이다.
처음에는 길고 복잡하게 짠다. 들여쓰기 단계도 많고 중복된 루프도 많을 것이고 인수 목록도 아주 길 것이다. 이름도 즉흥적이고 코드는 중복된다. 하지만 해당 코드를 빠짐없이 테스트하는 단위 테스트도 만든다.
그런 다음에 코드를 다듬고 ,함수를 만들고, 이름을 바꾸고, 중복을 제거한다. 메서드를 줄이고 순서를 바꾼다. 때로는 전체 클래스 쪼개기도 한다. 이 와중에도 코드는 항상 단위 테스트를 통과하게 된다.
이런 과정을 거치며 클린 코드가 완성되게 된다.

결론

대가 프로그래머는 시스템을 구현할 프로그램이 아니라 풀어갈 이야기로 여긴다. 여기에 프로그래밍 언어라는 수단을 사용해 좀 더 풍부하고 좀 더 표현력이 강한 언어를 만들어 이야기를 풀어나가게 된다. 시스템에서 발생하는 모든 동작을 설명하는 함수 계층이 바로 그 언어에 속한다.
고로 우리들의 목표는 시스템이라는 이야기를 풀어가는 데 있다는 사실이다. 우리가 작성하는 함수가 분명하고 정확한 언어로 깔끔하게 같이 ㅁ자아떨어져야 이야기를 풀어가기가 쉬워진다는 사실을 명심하자.

04장. 주석

나쁜 코드에 주석을 달지 마라. 새로 짜라.

잘 달린 주석은 그 어떤 정보보다 유용하지만 대부분의 주석은 코드를 이해하기 어렵게 만든다.
사실상 주석은 기껏해야 필요악인 것이다. 만약 코드 자체로 의도를 표현할 수 있다면 주석은 전혀 필요 없을 것이다.
결국 대부분의 주석은 코드로 의도를 표현하지 못해 즉, 실패를 만회하기 위해 주석을 사용하게 된다.
그러므로 주석이 필요한 상황에 처하게 되면 곰곰이 생각해봐야한다. 상황을 역전해 코드로 의도를 표현할 방법을 먼저 찾아봐야 한다.

그렇다면 주석을 멀리해야 하는 이유가 뭘까? 코드와 마찬가지로 주석도 유지보수대상에 포함되기 때문이다. 코드는 항상 변화하고 진화하게 되는 데 주석은 언제나 코드를 따라가지 못한다.

결론적으로 우리가 지향해야하는 점은 코드를 깔끔하게 정리하고 의도가 들어나도록 표현력을 강화해 주석이 필요 없는 방향으로 가야한다는 것이다. 그러므로 우리는 간혹 주석이 필요하더라도 가능한 줄이도록 꾸준히 노력해야 한다.

주석은 나쁜 코드를 보완하지 못한다.

코드에 주석을 추가하는 일반적인 이유는 코드 품질이 나쁘기 때문이다. 짜고 보니 이해 하기 어려운 코드여서 주석을 달아야지라고 생각하는 데 그보다는 코드를 정리하는 쪽을 선택해야 한다. 표현력이 풍부하고 깔끔하고 주석이 거의 없는 코드가 더 좋기 때문이다.

코드로 의도를 표현하라!

클린 코드의 핵심이 되는 문장인 것 같다.
코드만으로 의도를 설명하기 어렵다고 생각할 수 있지만 대다수는 코드로 의도를 표현할 수 있다.

//AS-IS

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

//TO-BE
if (employee.isEligibleForFullBenefits())

위의 예시처럼 조금만 더 생각해도 주석없이 코드로 의도를 표현할 수 있게 된다. 많은 경우 주석으로 달려는 설명을 함수로 만들어 표현해도 충분하게 된다.

좋은 주석

이때까지 주석은 나쁘다고 하였지만 어떤 주석은 필요하거나 유익하다. 몇 가지를 알아보자.

법적인 주석

저작권 정보와 소유권 정보 등 법적인 이유로 특정 주석을 넣으라고 명시하기도 한다.

정보를 제공하는 주석

기본적인 정보를 주석으로 제공하면 편리하다.

// 추상 메서드가 반환할 값을 설명한다. 

// 테스트 중인 Responder 인스턴스를 반환한다. 
protected abstract Responder responderInstance();

// 함수 이름을 바꾸면 주석을 달 필요가 없어진다. 
protected abstract Responder responderBeingTested();
// kk:mm:ss EEE, MMM, dd, yyyy형식이다. 
Pattern timeMatcher = Pattern.compile(
    "\\d*: ....
)

위의 timeMatcher 코드는 시각과 날짜를 변환하는 클래스로 분리하면 더 깔끔해지며 주석이 필요 없어지게 된다.

의도를 설명하는 주석

때때로 주석은 의도까지 설명한다.

public void testConcurrentAddWidgets() throws Exception {
    WidgetBuilder widgetBuilder = 
        new WidgetBuilder(new Class[](BoldWidget.class));
    ... 

    // 스레드를 대량 생성하는 방법으로 어떻게든 경쟁 조건을 만들려 시도한다. 
    for(int i = 0; i<25000; i++) {
        WidgetBuilderThread widgetBuilderThread = 
            new WidgetBuilderThread(widgetBuilder, text, parent, failFlag);
        ...
    }
}

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

우리는 앞써 인수나 반환값 자체를 명확하게 만들어야 한다는 것을 배웠다. 하지만 표준 라이브러리나 변경하지 못하는 코드라면 어떻게 해야할까? 이럴 때 주석을 통해 의미를 명료하게 밝히면 좋다.

public void testCompareTo() throws Exception {
    Number a = new Number(1);
    Number b = new Number(2);

    assertThat(a.compareTo(a) == 0).isTrue(); // a == a
    assertThat(a.compareTo(b) != 0).isTrue(); // a != b 
    asserTThat(a.compareTo(b) == -1).isTrue() // a < b 
}

하지만 이 경우에도 주석이 올바른지 검증하기 쉽지 않기 때문에 더 나은 방법이 없는 지 먼저 고민하고 정확히 달도록 주의해야 한다.

결과를 경고하는 주석

다른 개발자에게 결과를 경고할 목적으로 주석을 사용한다.

public static SimpleDateFormat makeStandardHttpDateFormat() {
    //SimpleDateFormat은 스레드에 안전하지 못하다. 
    //따라서 각 인스턴스를 독립적으로 생성해야 한다. 
    SimpleDateFormat df = new SimpleDateFormat(("EEE, dd MMM yyyy HH:mm:ss z"));
    df.setTimeZone(TimeZone.getTimeZone("GMT")); 
    return df;
}

위의 주석으로 인해 정적 초기화 함수를 사용하려던 개발자가 실수를 면하게 된다.

TODO 주석

앞으로 할 일을 //TODO주석으로 남겨두면 편하다.

// TODO-MdM 현재 필요하지 않다. 
// 체크아웃 모델을 도입하면 함수가 필요 없다. 
protected VersionInfo makeVersion() throws Exception {
    return null;
}

TODO 주석은 프로그래머가 필요하다 여기지만 당장 구현하기 어려운 ㅇ업무를 기술한다.
하지만 어떤 용도로 사용하든 시스템에 나쁜 코드를 남겨 놓는 핑계가 되어서는 안된다. 그러니 주기적으로 TODO 주석을 점검해 정리해야 한다.
참고로, 프로젝트 진행 시에 TODO 주석을 통해 메서드위에 해야할 일을 적어두었는 데 인텔리제이에서 자동으로 인식해주기 때문에 효율성이 매우 높았다. 아마 해당 주석은 많이 쓰지 않을까 싶다.

중요성을 강조하는 주석

String listItemContent = match.group(3).trim(); 
// 여기서 trim은 정말 중요하다. trim 함수는 문자열에서 시작 공백을 제거한다. 
// 문자열에 시작 공백이 있으면 다른 문자열로 인식되기 때문이다. 
... 

(주석이야기와 연관은 없지만 trim의 경우 시작 공백 뿐 아니라 끝 공백도 제거해준다.)

공개 API에서 Javadocs

공개 API를 구현한다면 반드시 훌륭한 Javadocs를 작성한다. 하지만 이도 주석이기 때문에 주의해서 작성하자.

나쁜 주석

앞써 좋은 주석에 대해 살펴보았지만 이를 제외하고 대부분의 주석은 나쁜 주석에 해당하게 된다. 일반적으로 주석은 허술한 코드를 지탱하거나, 엉성한 코드를 변명하거나 등 코드가 의도를 표현하지 못해 발생하게 된다.

주절거리는 주석

주석을 달기로 결정했다면 충분한 시간을 들여 최고의 주석을 달아야 한다.

public void loadProperties() {
    try {
        String properitesPath = ...
    } catch (IOException e) {
        // 속성 파일이 없다면 기본값을 모두 메모리로 읽어 들였따는 의미다.
    }
}

여기서 주석은 무슨 뜻인지 파악이 되는가? 누가 모든 값을 읽어들이는 지, 언제 읽어들이는 지 등 정보가 없기 때문에 답을 알아내려면 다른 코드를 뒤져보는 수밖에 없다. 설명하기 위해 주석을 달았는 데 다른 모듈까지 뒤져야 한다면 제대로 된 주석이 아니다.

같은 이야기를 중복하는 주석

// this.closed가 true일 때 반환되는 유틸리티 메서드다. 
// 타임아웃에 도달하면 예외를 던진다. 
public synchronized void waitForClose(final long timeoutMills) throws Exception { 
    if(!closed) {
        wait(timeoutMills);
        if(!closed) {
            throw new Exception("...");
        }
    }
}

위의 주석은 무슨 목적으로 달아놨을까? 주석이 코드보다 더 많은 정보를 제공하지 못한다. 코드보다 읽기 쉽지 않고 부정확해 독자가 함수를 대충 이해하고 넘어가게 만든다.

다른 예시도 한번 보자.

public abstract class ContrainerBase ... {
    /** 
     * 이 컴포넌트의 프로세스 지연값 
     */ 
     protected int backgroundProcessorDelay = -1;

    /** 
     * 이 컴포넌트를 지원하기 위한 생명주기 이벤트
     */ 
     protected LifeCycleSupport lifeCycle = new LifecycleSupport(this);

    ...
}

해당 코드는 중복된 JavaDocs가 매우 많아 지저분하고 정신 없게 만든다.
나쁜 주석이니 제거해주자!!

오해할 여지가 있는 주석

위의 waitForClose코드를 다시 보자
this.closed가 true일 때 반환되는 유틸리티 메서드다.라고 되어 있는 데 사실 this.closed가 true로 변하는 순간에 메서드는 반환되지 않는다. 즉, this.closed가 true여야 메서드는 반환된다는 것이다. 아니면 타임아웃을 기다렸다가 더 확인한 후 true가 아니라면 예외를 던지게 된다.
이러한 주석에 담긴 살짝 잘못된 정보로 인해 다른 프로그래머가 경솔하게 함수를 호출할지도 모르니 주의해야 한다.

의무적으로 다는 주석

아무 가치가 없는 주석이며 오히려 코드만 헷갈리게 만든다.

이력을 기록하는 주석

예전에는 소스 코드 관리 시스템이 없었기 때문에 사용하긴 했지만 이제는 혼란만 가중할 뿐이니 완전히 제거해야 한다.

있으나 마나 한 주석

/**
 * 기본 생성자
 **/
protected AnnualDateRule() {
}

/**
 * 월 중 일자 
 **/
private int dayOfMonth; 

// AS-IS 
private void startSending() {
    try {
        doSending();
    } catch (SocketException e) {
        // 정상. 누군가 요청을 멈췄다. (좋은 주석)
    } catch (Exception e) {
        try {
            response.add .... 
        } catch (Exception e1) {
            // 이게 뭐야! (나쁜 주석)
        }
    }
}

//TO-BE 
private void startSending() {
    try {
        doSending();
    } catch (SocketException e) {
        // 정상. 누군가 요청을 멈췄다. (좋은 주석)
    } catch (Exception e) {
        addExceptionAndCloseResponse(e);
    }
}

private void addExceptionAndCloseResponse(Exception e) {
    try {
            response.add .... 
        } catch (Exception Ignored) {
        }
}

무서운 잡음

JavaDocs도 잡음이다. 꼭 필요한 경우에만 제공하자.

함수나 변수로 표현할 수 있다면 주석을 달지 마라

//AS-IS
// 전역 목록 <smodule>에 속하는 모듈이 우리가 속한 하위 시스템에 의존하는가? 
if (smodule.getDependSubsystems().contains(subSysMod.getSusSystem()))

//TO-BE 
List<String> moduleDependees = smodule.getDependSubsystems(); 
String ourSubSystem = susSysMod.getSubSystem(); 
if(moduleDependees.contains(outSubSystem))

예전부터 코드를 짤 때 get을 AS-IS처럼 한번에 써 코드량을 줄일 지 TO-BE처럼 순차적으로 적어주는 게 좋은 코드는 잘 몰랐는 데 해당 예시를 보고 바로 이해가 된 것 같다.
내가 기준을 못잡은 이유는 TO-BE처럼 순차적으로 적어줬지만 의미 있는 이름을 쓰지 않아 의도를 파악하지 못하도록 했었기 때문인 것 같다.

위치를 표시하는 주석

// Actions ///////////////////////

배너와 같이 표시되는 주석으로 유용한 경우가 있긴 하지만 반드시 필요한 경우가 아니라면 사용하지 않는다.

닫는 괄호에 다는 주석

닫는 괄호에 주석을 달아야겠다는 생각이 든다면 대신에 함수를 줄이려 시도하자!

try{
    while((line = in.readLine() != null)) {
        ...
    } // while
    ... 
} // try 

공로를 돌리거나 저자를 표시하는 주석

/* 환이 추가함 */

요즘에는 소스 코드 관리 시스템이 있기 때문에 사용 하지 않는다.

주석으로 처리한 코드

주석으로 처리한 코드는 다른 사람이 봤을 때 지우기를 주저한다. 이유가 있어서 남겨놓은 건지, 중요한 건지 모르기 때문이다.
앞써 말했듯이 소스 코드 관리 시스템이 코드를 기억해주니 삭제해주자.

HTML 주석

주석에 HTML 태그를 삽입해야 하는 책임은 프로그래머가 아닌 도구가 져야한다.

전역 정보

주석을 달아야 한다면 근처에 있는 코드만 기술하라

/**
 * 적합성 테스트가 동작하는 포트: 기본값은 <b>8082<b>,
 *
 * @param fitnessPort
 */
 public void setFitnessPort(int fitnessPort) {
     this.fitnessPort = fitnessPort;
 }

위의 주석에는 기본 포트 정보를 기술하지만 함수를 보면 포트 기본값을 전혀 통제하지 못하게 된다. 즉, 해당 주석은 아래 함수가 아닌 시스템 어딘가에 있는 다른 함수를 설명하고 있다.
포트 기본값을 설정하는 코드가 변하게 되면 해당 주석도 변경해야 한다.

너무 많은 정보

관련 없는 정보를 늘어놓지 마라

모호한 관계

주석과 주석이 설명하는 코드는 둘 사이 관계가 명백해야 한다.

/**
 * 모든 픽셀을 담을 만큼 충분한 배열로 시작한다 (여기에 필터 바이트를 더한다.)
 * 그리고 헤더 정보를 위해 200바이트를 더한다.
 */
 this.pngBytes = new Byte[((this.width + 1) * this.height * 3) + 200]

위의 주석에서 말한 필터 바이트는 무엇일까? 알 수 없다.
주석을 다는 목적은 코드만으로 설명이 부족하기 때문인데 다시 설명을 요구하고 있다.

함수 헤더

주석으로 헤더를 추가하기 보단 짧고 한 가지만 수행하며 이름을 잘 붙인 함수를 만들도록 하자.

비공개 코드에서 Javadocs

공개 하지 않을 코드라면 Javadocs는 쓸모가 없다.

결론

좋은 주석도 있지만 대부분 나쁜 주석에 해당한다.
이전까지 주석은 필요하지 않을까? 라고 생각을 했는데 해당 장을 읽으면서 주석도 유지보수 대상이라는 점과 코드로 의도를 파악하는 것이 중요하다는 점이 크게 와닿으며 180도 생각이 변화하게 되었다.
코드를 짜면서 주석을 써야할 것 같은데? 라는 생각이 들면 코드가 잘못된 것으로 인식하고 정리하는 습관을 가져야 겠다.

댓글