본문 바로가기

SW LAB/Algorithm

Clean Code : (3) 함수

 프롬스의 SWDEVLAB 

함수

 프로그래밍 초창기에는 시스템을 루틴과 하위 루틴으로 나누었습니다. 포트란과 PL/1 시절에는 시스템을 프로그램, 하위 프로그램으로 나누었습니다. 지금은? 함수만 살아남았습니다. 어떤 프로그램이든 가장 기본적인 단위가 함수입니다. 이번 포스팅을 하며 함수를 잘 작성하는 방법을 숙지합니다.

 

작게 만들어라

 함수를 만드는 첫째 규칙은 작게! 입니다. 함수는 만드는 두번째 규칙은 더 작게! 입니다. 이 규칙은 근거를 대기 곤란하고, 작을수록 더 좋다는 증거나 자료를 제시하기도 어렵습니다. 저자는 40년간 개발을 하며, 1000줄의 함수, 100~200줄의 함수, 20~30줄의 함수를 모두 작성하는 경험을 했지만 작은 함수가 좋다고 확신하고 있습니다.

 80년대에는 함수가 한 화면을 넘어가면 안된다고 말했습니다. 이 때는 가로 80자의 세로 24줄였습니다. 오늘날 좋은 모니터는 가로 150자에 세로 100줄도 들어갑니다.

 그렇다면 얼마나 짧아야할까? 켄드 백에 방문하여 Sparkle이라는 자바/스윙 프로그램을 보았던 로버트('이 책의 저자다')는 모든 함수가 2~4줄로 이루어진 것을 보았습니다. 각 함수가 이야기를 표현했고, 너무나도 명백했습니다. 2~4줄이라.. 이게 가능한 것일까요 ?

 

블록과 들여쓰기

 if문 / else문 / while 문 등에 들어가는 블록은 한 줄이어야 합니다. 그러면 바깥을 감싸는 함수(enclosing function)가 작아질 뿐 아니라, 블록 안에서 호출하는 함수 이름을 적절히 짓는다면, 코드를 이해하기도 쉬워집니다.

 이 말은 중첩 구조가 생길만큼 함수가 커져서는 안되고, 함수에서 들여쓰기 수준은 1단이나 2단을 넘어서면 안됩니다. 그래야 함수를 읽기 쉬워집니다.

 아래 예제에서 if문 안에 한 줄이 있고, 함수 내에는 들여쓰기가 1단까지만 되도록 작성된 것을 볼 수 있습니다.

public static String renderPageWithSetupAndTeardowns(
  PageData pageData, boolean isSuite) throws Exception {
  if(isTestPage(pageData))
    includeSetupAndTeardownPages(pageData, isSuite);
  return pageData.getHtml();
}

 

한 가지만 해라!

 다음 문구는 지난 30여년 동안 여러 가지 다양한 표현으로 프로그래머들에게 주어진 충고입니다.

 

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

 

 이 충고에서 문제점은 그 한가지가 무엇인지 알게 어렵다는 것입니다. 지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 합니다. 우리가 함수는 만드는 이유는 큰 개념을 다음 추상화 수준에서 여러 단계로 나눠 수행하기 위해서입니다. 즉, 함수는 한가지를 수행하며, 상위 함수에서는 추상화된 함수 여러개를 사용한다!

 함수가 한 가지만 하는지 판단하는 방법이 하나 더 있습니다. 의미 있는 이름으로 다른 함수를 추출할 수 있다면, 그 함수는 여러 작업을 하는 셈입니다.

 

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

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

getHtml() 은 추상화 수준이 아주 높습니다.

String pagePathName = PathParser.render(pagePath); 는 추상화 수준이 중간입니다.

.append("\n") 와 같은 코드는 수준화 수준이 아주 낮습니다.

 이처럼 추상화 수준이 다른 코드가 섞여 있으면 보고 읽기가 무척 어렵습니다.

 

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

 코드는 위에서 아래로 이야기처럼 읽혀야 좋습니다. 한 함수 다음에는 추상화 수준이 한 단계 낮은 함수가 옵니다. 즉 위에서부터 코드를 읽어갈 때, 추상화 수준이 한 단계식 낮아집니다. 저자는 이를 내려가기 규칙이라 부릅니다.

 하지만 추상화 수준이 하나인 함수를 구현하기란 쉽지 않습니다. 핵심은 짧으면서도 한 가지만 하는 함수입니다. 내려가기 규칙을 적용하며 코드를 구현하면 추상화 수준을 일관되게 유지하는 것이 쉬워집니다.

 

Switch 문

 switch 문은 작게 만들기 어렵습니다. 당연히 if/else가 여럿 이어지는 구문도 해당됩니다. 또한 한가지 작업만 하는 switch 문도 만들기 어렵습니다. 본질적으로 switch 문은 N가지를 처리하기 때문입니다. 불행하게도 switch 문을 완전히 피할 방법은 없습니다. 하지만 각 switch 문을 저차원 클래스에 숨기고 절대로 반복하지 않는 방법은 있습니다. 다형성(polymorphism)을 이용합니다.

 

다음 코드를 살펴봅시다.

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:
      return new InvalidEmployeeType(e.type);
  }
}

 

 위 함수에는 몇 가지 문제가 있습니다.

첫째, 함수가 너무 깁니다. 새 직원유형을 추가하면더 길어집니다.

둘째, 한 가지 작업만 수행하지 않습니다.

셋째, SRP(Single Responsibility Principle)을 위반합니다. 코드를 변경할 이유가 여럿있기 때문입니다.

넷째, OCP(Open Close 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 EmployeeImpl implements EmployeeFactory {
  public Employee makeEmployee(EmployeeRecord r) throws InvaliEmployeeType {
    switch(r.type) {
      case COMISSIONED:
        return new CommissionedEmployee(r);
      case HOURLY:
        return new HourlyEmployee(r);
      case SALARIED:
        return SalariedEmployee(r);
      default:
        throw new InvalidEmployeeType(r.type);
    }
  }
}

 

switch 문을 사용하여 추상 팩토리에 꽁꽁 숨깁니다. 아무에게도 보여주지 않습니다. 팩토리는 switch 문을 사용해 적절한 Employee 파생 클래스의 인스턴스를 생성합니다. 그러면 다형성으로 인해 실제 파생 클래스의 함수가 실행됩니다.

 

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

좋은 이름이 주는 가치는 아무리 강조해도 지나치지 않습니다.

 

"코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드라 불러도 되겠다." 

 

 한 가지만하는 작은 함수 이름에 좋은 이름을 붙인다면 이런 원칙을 달성함에 있어 이미 절반은 성공한 것입니다. 이름이 길어도 좋습니다. 길고 서술적인 이름이 길고 서술적인 주석보다 좋습니다. 이름을 붙일때는 일관성이 있어야 합니다. 모듈 내에서 함수 이름은 같은 문구, 명사, 동사를 사용합니다.

 includeSetupAndTeardownPages

 includeSetupPages

 includeSuiteSetupPage

 includeSetupPage

등 과 같은 것이좋은 예 입니다.

 

함수 인수

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

 인수가 있다면, 이야기처럼 읽어 내려갈 때 매번 그 인수를 이해하고 함수 내에서 해석을 해야 합니다. 테스트 관점에서는 어떨까요? 테스트 케이스를 작성할 때 더욱 어려울 것입니다.

 출력 인수는 입력 인수보다 어렵습니다. 흔히 우리는 인수로 입력을 넘기고 반환값으로 출력을 받는다는 개념에 익숙합니다. 대개 함수에서 인수로 결과를 받으리라 기대하지 않습니다. 그래서 출력 인수는 독자가 허둥지둥 코드를 재차 확인하게 만듭니다.

 최선은 입력 인수가 없는 경우이고, 차선은 입력 인수가 1개뿐인 경우입니다. SetupTeardownIncluder.render(pageData)는 이해하기 아주 쉽습니다. pageData 객체 내용을 렌더링하겠다는 뜻입니다.

 

많이 쓰는 단항 형식

 함수에 인수 1개를 넘기는 이유로 가장 흔한 경우는 두 가지입니다.

 하나는 인수에 질문을 던지는 경우입니다. boolean fileExists("MyFile")이 좋은 예입니다. 다른 하나는 인수를 뭔가로 변환해 결과를 반환하는 경우입니다. InputStream fileOpen("MyFile")은 파일 이름을 InputStream으로 변환합니다. 함수 이름을 지을 때는 두 경우를 명확히 구분하고 일관성을 유지해야합니다.

 다소 드물게 사용하지만 그래도 아주 유용한 단항 함수 형식이 이벤트입니다. 이벤트 함수는 입력 인수만 있습니다. 출력 인수는 없습니다. 함수 호출로 시스템의 상태를 바꾸는 역할을 합니다. 이벤트 함수는 조심히 사용해야 하고, 코드에 명확히 드러나야 합니다.

 위 사항 외에는 가급적 피하도록 합니다. 예를 들어, void includeSetupPageInto(StringBuffer pageText)는 피합니다. 입력 인수를 변환하는 함수라면, 변환 결과를 반환값으로 돌려주어야 합니다.

 

플래그 인수

플래그 인수는 정말 좋지 않습니다. 함수로 부울 값을 넘기는 관례는 정말 끔찍합니다. 왜냐면, 함수가 한꺼번에 여러 가지를 처리한다고 대놓고 공표하는 셈이기 때문이죠.

 

이항 함수

 인수가 2개인 함수는 인수가 1개인 함수보다 이해하기 어렵습니다. 예를 들어, writeField(name)는 writeField(outputStream, name)보다 이해하기 쉽습니다. 둘다 의미는 명확하지만, 전자가 더 쉽게 읽히고 더 빨리 이해됩니다.

 이항 함수도 적절한 경우가 있습니다. Point p = new Point(0, 0)가 좋은 예 입니다. 직교 좌표계 점은 일반적으로 인수 2개를 취합니다. 코드가 new Point(0) 이었다면 혼란스러웠을 것입니다. 위 두개의 인자는 자연적인 순서가 있고 한 값을 의미하는 것을 알 수 있습니다.

 assertEquals(expected, actual)에도 문제가 있습니다. expected에 actual 값을 집어넣는 실수가 얼마나 많았습니까? 두 인수에 자연적인 순서는 없습니다. 인위적으로 기억해야 합니다.

 

삼항 함수

 인수가 3개인 함수는 인수가 2개인 함수보다 훨씬 더 이해하기 어렵습니다. 그래서 더 신중히 고려해야합니다. 예를 들어 assertEquals(message, expected, actual)이라는 함수를 볼 때, 순서를 고려해야하고 메시지를 이해하는게 주춤하게 됩니다.

 

인수 객체

 인수가 여러 개일 경우 클래스 변수로 선언하는 것도 방법입니다. 단, 이 클래스에 명확한 개념이 표현될 때입니다. 다음 코드에서 Point 객체는 명확한 개념을 갖고있는 예입니다.

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

 

인수 목록

 때로는 인수 개수가 가변적인 함수도 있습니다. String.format이 대표적인 예입니다.

String.format("%s worked %.2f hours.", name, hours);

 

 선언부를 살펴보면 사실은 이항 함수라는 것이 드러납니다. 이 개념으로 볼 때 3항이 넘어가는 가변적인 함수도 권장하지 않습니다.

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

 

동사와 키워드

 함수의 의도나 인수의 순서와 의도를 제대로 표현하려면 좋은 함수 이름이 필수입니다. 단항 함수는 함수와 인수가 동사/명사 쌍을 이뤄야합니다. 예를 들어 write(name)은 누구나 이해할 수 있을 것입니다.

 

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

 부수 효과는 거짓말입니다. 함수에서 한 가지를 하겠다고 약속하고선 남몰래 다른 짓을 하기 때문입니다. 이 경우 시간적인 결합(temporal coupling)이나 순서 종속성(order dependency)를 초래합니다. 다음 코드를 살펴봅니다. userName과 passWord 두 인수를 확인해서 올바르면 true 아니면 false를 반환합니다. 그러나 부수효과가 있습니다.

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

 

 여기서 발생하는 부수효과는 Session.initialize() 입니다. 함수 이름만 보고서는 세션을 초기화하는지 알 수 없기 때문입니다. 그래서 사용자는 함수 호출 후 기존 세션 정보를 지워버릴 수 있습니다.

 이런 부수효과가 시간적인 결합을 초래하는 것입니다. 즉, checkPassword는 특정 상황에서만 호출이 가능합니다 세션을 초기화해도 괜찮은 경우만 호출해야 하지요. 함수 이름이 checkPasswordAndInitializeSession 이 훨씬 낫습니다.

 

출력 인수

 일반적으로 우리는 인수를 함수 입력으로 해석합니다. 예를 들어 appendFooter(s) 함수를 봅시다. 이 함수는 s를 바닥글로 첨부할까? 아니면 s에 바닥글을 첨부할까? s는 입력이리까? 출력일까 ? 함수 선언부를 보면 분명해집니다.

public void appendFooter(StringBuffer report)

 

인수 s가 출력인수라는 사실이 확실해졌습니다. 함수 행위를 찾아보는 행위는 코드를 보다가 주춤하는 행위와 동급입니다. 즉, 출력인수는 피해야 합니다. 따라서 다음과 같이 고쳐야합니다.

report.appendFooter()

 

명령과 조회를 분리하라

 함수는 뭔가를 수행하거나 뭔가에 답하거나 둘 중 하나만 해야 합니다. 둘 다 하면 안됩니다. 다음 코드를 살펴봅시다.

public boolean set(String attribute, String value);

 

이 함수는 attribute 속성을 찾아 값을 value로 설정한 후 성공하면 true, 실패하면 false를 반환합니다. 그래서 다음과 같이 괴상한 코드가 나옵니다.

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

 

위 코드를 읽는 독자는 "username" 속성이 "unclebob"으로 설정되어 있다면... 이라고 오해할 수 있다. 따라서 다음과 같이 개선된 코드가 읽기 좋은 코드입니다.

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

 

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

 명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반합니다. 자칫하면 if 문에서 명령을 표현식으로 사용하기 쉬운 탓입니다.

 

다음 코드를 봅시다.

if (deletePage(page) == E_OK) {
  if (registry.deleteRefererence(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;
}

 

오류 코드 대신에 다음과 같이 작성하면 코드가 깔끔해집니다.

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

 

Try/Catch 블록 뽑아내기

 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);
  deleteRefererence(page.name);
  configKeys.deleteKey(page.name.makeKey();
}

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

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

 함수는 한 가지 작업만 해야 합니다. 오류 처리도 한 가지 작업에 속합니다. 오류 코드를 반환한다는 이야기는 클래스든 열거형 변수든 어디선가 오류 코드를 정의한다는 뜻입니다. 예를 들어, Enum 으로 다음과 정의해서 사용할 수 있습니다.

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

 

 위와 같은 클래스는 의존성 자석(Magnet) 입니다. 다른 클래스에서 Error enum을 사용하게 되는데, 이 클래스 내용이 변경된다면 이 클래스를 사용하는 모든 클래스를 다시 컴파일하고 배치해야합니다. 그래서 Error 클래스 변경이 어려워집니다. 즉, 오류 코드 대신에 Exception 클래스를 활용하는 것이 좋습니다. 새 예외는 Exception 클래스에서 파생시키도록 합니다. 재컴파일/재배치 없이도 새 예외 클래스를 추가할 수 있을 것입니다.

 

구조적 프로그래밍

 어떤 프로그래머는 에츠허르 데이크스트라(Edsger Dijkstra)의 구조적 프로그래밍 원칙을 따릅니다. 데이크스트라는 모든 함수와 함수 내 블록에 입구와 출루가 하나만 존재해야 한다고 말합니다. return문이 한 개이어야 하고, break, continue는 사용해서는 안되고, goto는 절대로 해서는 안됩니다.

 구조적 프로그래밍의 목표와 규율은 공감하지만 함수가 아장 클 때만 상당한 이익을 제공합니다. 그러므로 함수를 작게 만든다면, return, break, continue을 여러차례 사용해도 괜찮습니다. 반면 goto는 작은 함수에서도 피해야합니다.

 

함수를 어떻게 짜죠?

 소프트웨어를 짜는 행위는 여느 글짓기와 비슷합니다. 논문이나 기사를 작성할 때 먼저 생각을 기록하고 읽게 좋게 다듬습니다. 초안은 대개 서투르고 어수선하므로 원하는 대로 읽힐 때까지 다듬고 문장을 고치고 문단을 정리합니다.

 함수를 짤 때도 마찬가지 입니다. 처음에는 길고 복잡합니다. 들여쓰기 단계도 많고 중복된 루프도 많습니다. 인수 목록도 아주 길고, 이름도 증흑적입니다. 코드도 중복되고 말이죠. 하지만 저자는 그 서투른 코드를 빠짐없이 테스트하는 단위 테스트 케이스를 만듭니다.

 그런 다음 코드를 다듬고, 함수를 만들고, 이름을 바꾸고, 중복을 제거합니다. 메서드를 줄이고 순서도 바꿉니다. 때로는 전체 클래스를 쪼개기도 합니다. 이 와중에도 코드는 항상 단위 테스트를 통과합니다. 처음부터 완전하게 짤 수는 없고, 그런 사람도 없을 것입니다.

 

결론

 모든 시스템은 특정 응용 분야 시스템을 기술할 목적으로 프로그래머가 설계한 도메인 특화 언어(Domain Specific Language, DSL)로 만들어집니다. 함수는 그 언어에서 동사며, 클래스는 명사입니다. 이는 훨씬 더 오래된 진실이며, 프로그래밍의 기술은 언제나 언어 설계의 기술임을 말합니다.

 대가(Master) 프로그래머는 시스템을 구현할 프로그램이 아니라 이야기로 여깁니다. 프로그래밍 언어라는 수단을 사용해 더 풍부하고 좀 더 표현력이 강한 언어를 만들어 이야기를 풀어갑니다. 시스템에서 발생하는 모든 동작을 설명하는 함수 계층이 바로 그 언어에 속합니다.

 이 장은 함수를 잘 만드는 기교를 소개했습니다. 여기서 배운 규칙을 따른다면, 길이가 짧고, 이름이 좋고, 체계가 잡힌 함수가 나올 것입니다. 하지만 진짜 목표는 시스템이라는 이야기를 풀어가는 데 있다는 사실을 명심하기 바라빈다. 이야기를 잘 풀어나갈 수 있어야 쉬워진다는 사실을 기억하기 바랍니다.

 

내용 요약

소제목이 많아서, 간단히 요약해보도록 합니다.

 

  • 작게 만들어라
    함수를 최대한 작게 만들고, 들여쓰기 수준도 1단이나 2단까지만 해라.
  • 한 가지만 해라!
    한 가지만 수행하는 함수를 만들어라.
  • 함수 당 추상화 수준은 하나로!
    함수 내 문장들의 추상화 수준을 동일하게 하라. 내려가기 규칙이 적용되도록 해라.
  • Switch 문
    Switch문은 추상화 팩토리를 사용하여 숨기도록 해라.
  • 서술적인 이름을 사용하라
    모듈 내 함수 이름에 동일한 문구, 명사, 동사를 사용해서 읽기 좋게 서술적으로 표현해라.
  • 함수인수
    인수는 적을수록 좋다. 최대 3개를 넘기지 말아라. 때로는 함수 이름과 인수를 이용하여 표현하도록 하라.
  • 부수 효과를 일으키지 마라!
    한 가지만 수행하는 함수 내에 부수효과를 일으키지 말아라.
  • 명령과 조회를 분리하라
    명령을 수행하는 함수와 조회를 수행하는 함수를 분리하라. 하나의 함수 내에 명령과 조회 모두 구현하지 말아라.
  • 오류 코드보다 예외를 사용하라!
    오류 코드를 사용하는 것 보다는 Exception을 이용하여 예외를 늘려가라.
  • 구조적 프로그래밍
    함수를 작게 만든다면 return, break, continue을 여러차례 써도 괜찮다. 데이스크트라의 구조적 프로그래밍 방식을 따르지 않아도 된다.
  • 함수를 어떻게 짜죠 ?
    글짓기와 비슷하다. 처음에는 서툴수 있지만, 단위 테스트를 수행하며 규칙을 더 준수하는 함수가 되도록 계속 변경해라.
  • 결론
    여기서 배운 규칙을 적용하면.. 길이가 짧고, 이름이 좋고, 체계가 잡힌 함수가 나올 것이다. 시스템이라는 이야기를 풀어가는 목표를 잊지 말아라.

 

관련글

이전 글 : (2) 의미있는 이름
다음 글 : (4) 주석

 

내용이 도움이 되셨으면 공감 버튼 꼬옥 눌러주세요
본문을 퍼가실 경우 댓글을 달아주세요