함수는 프로그램을 읽는 가장 작은 단위다. 3장은 “좋은 함수란 무엇인가”를 규칙으로 풀어낸다. 규칙 하나하나는 결국 한 방향을 가리킨다 — 함수를 읽는 사람이 세부에 빠지지 않고 의도를 먼저 보게 하라는 것.

함수는 작을수록 읽기 쉽다

함수는 작게 만들수록 의도가 잘 보인다. 짧은 함수는 이름과 본문이 서로를 설명하고, 변경할 때 영향 범위를 파악하기 쉽다. 특히 들여쓰기가 깊어지는 함수는 읽는 사람이 조건과 반복의 맥락을 계속 기억해야 하므로 빠르게 피로해진다. 그래서 책은 극단적으로 말한다 — 중첩 구조가 생길 만큼 함수가 커져서는 안 되고, 들여쓰기 수준은 1단이나 2단을 넘지 않아야 한다.

이 규칙의 실천이 “블록은 한 줄”이다. if, else, while 블록 안은 함수 호출 한 줄이 이상적이고, 그 호출된 함수가 서술적인 이름을 가지면 바깥 함수는 문서처럼 읽힌다.

// before — 블록 안에 로직이 쌓여 들여쓰기가 깊다
if (isTestPage(pageData)) {
    WikiPage testPage = pageData.getWikiPage();
    StringBuffer newPageContent = new StringBuffer();
    includeSetupPages(testPage, newPageContent, isSuite);
    newPageContent.append(pageData.getContent());
    includeTeardownPages(testPage, newPageContent, isSuite);
    pageData.setContent(newPageContent.toString());
}
 
// after — 블록은 한 줄, 이름이 세부를 대신 말한다
if (isTestPage(pageData))
    includeSetupAndTeardownPages(pageData, isSuite);

한 가지만, 한 추상화 수준에서

좋은 함수는 이름으로 약속한 일을 한다. 더 정확히는 “한 가지”만 한다. 여기서 “한 가지”의 판정 기준이 중요하다 — 함수 안의 모든 문장이 같은 추상화 수준에 있으면 그 함수는 한 가지 일을 하는 것이다. 정책을 설명하는 문장과 세부 문자열을 조립하는 문장이 한 함수에 섞이면 추상화 수준이 뒤엉킨 것이고, 읽는 흐름이 끊긴다.

이 방식은 책에서 말하는 “내려가기 규칙”과 연결된다. 코드를 위에서 아래로 읽을 때 한 번에 한 단계씩 추상화 수준이 낮아지면, 독자는 필요한 만큼만 세부로 내려가며 읽을 수 있다.

판단 기준: 함수에서 의미 있는 이름으로 다른 함수를 새로 추출할 수 있다면, 그 함수는 아직 여러 일을 하고 있는 것이다. 더는 추출해도 같은 말을 반복(재서술)할 뿐이라면 한 가지만 하는 상태다.

flowchart TD
  F["함수를 본다"] --> Q1{"의미 있는 이름으로<br/>일부를 추출할 수 있나?"}
  Q1 -->|그렇다| E["추출한다 → 다시 검사"]
  E --> Q1
  Q1 -->|추출해도 재서술뿐| Q2{"문장들이 같은<br/>추상화 수준인가?"}
  Q2 -->|아니오| L["섞인 저수준 문장을<br/>아래 함수로 내린다"]
  L --> Q1
  Q2 -->|예| Done["한 가지만 하는 함수"]

서술적인 이름과 인수 줄이기

함수 이름은 길어도 괜찮다. 짧지만 모호한 이름보다 길어도 정확한 이름이 낫다. testableHtml보다 includeSetupAndTeardownPages가 무엇을 하는지 더 잘 말한다면 후자가 낫다. 함수가 하는 일을 동사와 명사로 자연스럽게 표현하고, 모듈 안에서는 같은 개념에 같은 단어를 쓴다.

인수는 적을수록 이해하기 쉽다. 이상적인 인수 개수는 0개(무항)이고, 1개(단항), 2개(이항) 순으로 좋다. 3개(삼항)부터는 순서와 의미를 기억해야 하고 테스트 조합도 늘어나므로 특별한 이유가 필요하다.

// before — 인수 셋, 순서를 외워야 하고 조합 테스트가 늘어난다
Circle makeCircle(double x, double y, double radius);
makeCircle(10.0, 7.5, 3.0);
 
// after — 개념적으로 묶이는 인수는 객체로 (인수 객체 도입)
Circle makeCircle(Point center, double radius);
makeCircle(new Point(10.0, 7.5), 3.0);

판단 기준: 인수 여럿이 늘 함께 다닌다면(x, y처럼) 그것은 이름을 가질 만한 개념이라는 신호다. 객체로 묶으면 인수도 줄고 개념도 드러난다.

플래그 인수는 함수를 둘로 쪼갠다는 뜻

boolean 플래그 인수는 함수가 두 가지 일을 한다고 대놓고 광고하는 것이다. render(true)를 호출하는 쪽은 true가 무슨 뜻인지 함수 정의를 열어봐야 안다.

// before — 플래그가 함수 안에서 흐름을 가른다
public String render(boolean isSuite) {
    if (isSuite) { /* 스위트용 렌더링 */ }
    else         { /* 단일 페이지 렌더링 */ }
    ...
}
render(true);
 
// after — 갈래마다 이름을 준다
public String renderForSuite() { ... }
public String renderForSinglePage() { ... }
renderForSuite();

함정: 플래그가 여러 갈래를 만든다고 무조건 함수를 나누기 전에, 애초에 그 분기가 서로 다른 일인지 확인한다. 같은 일의 사소한 옵션이라면 오히려 전략(strategy)이나 다형성으로 흡수하는 편이 나을 수 있다.

명령과 조회를 분리하기

함수는 상태를 바꾸거나(명령), 값을 알려주거나(조회) 둘 중 하나여야 한다. 둘을 동시에 하면 호출하는 쪽이 혼란스럽다.

// before — set 하면서 성공 여부를 반환한다. if 문의 의미가 모호하다
if (set("username", "unclebob")) { ... }
// "username이 unclebob으로 설정돼 있으면"인가, "설정에 성공하면"인가?
 
// after — 조회와 명령을 나눈다
if (attributeExists("username")) {
    setAttribute("username", "unclebob");
    ...
}

같은 정신이 오류 처리에도 적용된다. 오류 코드를 반환하면 호출자가 즉시 분기 처리를 해야 해 명령과 조회가 다시 섞인다. 예외를 던지면 정상 흐름과 오류 흐름이 갈라진다. 그리고 try/catch가 본문 한가운데 섞이면 다시 흐름이 뒤엉키므로, 오류 처리도 “한 가지 일”로 보고 함수로 분리한다.

// after — try/catch 블록 안은 위임 한 줄, 오류 처리도 한 가지 일
public void delete(Page page) {
    try {
        deletePageAndAllReferences(page);
    } catch (Exception e) {
        logError(e);
    }
}

반복하지 않기

함수를 잘 나누는 큰 이유 하나는 중복 제거다. 같은 조건, 같은 계산, 같은 흐름이 반복되면 수정할 지점이 늘고 실수 가능성도 커진다. 중복을 제거하면 코드 양만 주는 게 아니라 개념이 한 곳에 모인다. 책은 중복을 “모든 소프트웨어 악의 근원”이라고까지 부른다 — 구조적 프로그래밍, 객체지향, 관점지향이 모두 어떤 형태의 중복 제거로 볼 수 있다는 것이다.

다듬어 가는 과정 따라가기

규칙을 한꺼번에 적용해 완성형 함수를 처음부터 쓰는 사람은 없다. 책의 저자도 처음엔 길고 복잡한 함수를 쓰고, 테스트로 묶어둔 뒤 다듬는다. 아래는 테스트 페이지에 설정·해제 페이지를 끼워 넣는 함수를, 추상화 수준 분리 → 이름 부여·중복 제거 → 인수 축소의 순서로 다듬는 과정이다. Next를 누르면 이전 단계 대비 바뀐 줄이 강조된다.

Refactoring Step 원본 — 추상화 수준이 뒤섞인 한 덩어리
1/4
public String render(PageData pageData, boolean isSuite) {    StringBuilder b = new StringBuilder();    if (isTestPage(pageData)) {        WikiPage setup = inheritedPage("SetUp", pageData.page());        if (setup != null) {            String path = PathParser.render(fullPath(setup));            b.append("!include -setup .").append(path).append("\n");        }    }    b.append(pageData.getContent());    if (isTestPage(pageData)) {        WikiPage teardown = inheritedPage("TearDown", pageData.page());        if (teardown != null) {            String path = PathParser.render(fullPath(teardown));            b.append("!include -teardown .").append(path).append("\n");        }    }    pageData.setContent(b.toString());    return pageData.getHtml();}

마지막 단계가 이 장의 핵심을 압축한다. pageDatanewContent를 계속 인수로 넘겨 다니던 것을 필드로 올리자, include의 인수가 넷에서 둘로 줄었다. 함께 다니던 데이터가 이름을 가진 객체(SetupTeardownIncluder)가 되면서 인수 축소와 응집이 동시에 일어난다. 작게, 한 가지만, 인수는 적게 — 세 규칙이 한 클래스로 수렴한다.

정리

  • 함수는 작게, 블록은 한 줄, 들여쓰기는 1~2단. 커지면 그 안에서 이름 있는 함수를 뽑을 수 있다는 뜻이다.
  • “한 가지”의 기준은 추상화 수준이다. 의미 있는 이름으로 더 추출되면 아직 여러 일을 한다.
  • 인수는 0 → 1 → 2 순으로 좋다. 늘 함께 다니는 인수는 객체로 묶는다. 플래그 인수는 함수를 둘로 쪼갤 신호다.
  • 명령과 조회를 나누고, 오류 처리도 한 가지 일로 보고 분리한다.
  • 중복은 악의 근원이다. 함수 분리의 절반은 중복 제거를 위한 것이다.

다음장으로 4장