본문 바로가기
개발 팁/클린 코드

클린 코드 정리 3 - 함수

by 시리언 2022. 9. 22.

3장에서는 좋은 함수를 작성하기 위한 여러 원칙들을 알아봅시다.

좋은 함수를 위한 원칙

작게 만들어라

함수를 만드는 가장 중요한 규칙은 작게 만드는 것이다. 최대한 함수를 작게 만들어야 한다.

들여 쓰기가 가능하게 블록을 만들어라: 이는 곧 if/else/while 등에 들어가는 블록이 한 줄이어야 한다는 의미이다. 대개 그 한 줄은 다른 함수를 호출한다. 그러면 바깥쪽 함수가 작아지며, 블록 안에서 호출하는 함수의 이름이 적절하면 이해가 매우 쉬워진다. 중첩 구조가 생길 만큼 함수가 커져서는 안 되고, 함수의 들여 쓰기 수준은 최대 2단이다.

한 가지만 해라

함수는 한 가지만 해야 하며, 그 한 가지를 잘 해야 한다. 지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 한다고 할 수 있다.

함수 내 섹션

함수 내에서 섹션이 명확하게 구분된다는 것은 한 함수가 여러 작업을 한다는 증거이다. 한 가지 작업만 하는 함수는 자연스럽게 섹션으로 나누기 어렵다.

함수의 추상화 수준은 하나만

함수가 한 가지의 역할만 수행하기 위해서는, 함수 내 모든 문장의 추상화 수준이 동일해야 한다.(SLA: Single Level of Abstraction) 또한 추상화 수준을 섞으면 어떤 코드가 근본 개념인지 세부사항인지 구분하기 어렵다.

 

참고하면 좋을 글(리액트 컴포넌트 예제)

https://medium.com/trabe/coding-react-components-single-level-of-abstraction-e60f25676235

 

Coding React components: Single Level of Abstraction

Learn how to make your React components easier to understand by following the Single Level of Abstraction principle.

medium.com

 

const placeOrder = ({ order }) => {
  // 고수준  
  validateAvailability(order);
  
  // 저수준
  const total = order.items.reduce(
    (item, totalAcc) =>
      totalAcc + item.unitPrice * item.units,
    0,
  );

  // 중간 수준
  const invoiceInfo = getInvoiceInfo(order);
  const request = new PaymentService.Request({
    total,
    invoiceInfo,
  });
  const response = PaymentService.pay(request);
  
  sendInvoice(response.invoice);
  
  // 고수준
  shipOrder(order);
};

 

위의 코드의 경우 한 함수 내에서 고수준, 중간 수준, 저수준 추상화가 혼재되어 있어 어떤 코드가 메인이고 어떤 코드가 세부 사항인지 파악하기가 어렵다. 

아래 코드처럼 한 함수 내에서는 같은 추상화 수준을 유지하여야 한다.

 

const getTotal = order =>
  order.items.reduce(
    (item, totalAcc) =>
      totalAcc + item.unitPrice * item.units,
    0,
  );

const pay = (total, invoiceInfo) => {
  const request = new PaymentService.Request({
    total,
    invoiceInfo,
  });
  const response = PaymentService.pay(request);

  sendInvoice(response.invoice);
};

const payOrder = order => {
  const total = getTotal(order);
  const invoiceInfo = getInvoiceInfo(order);
  
  pay(total, invoiceInfo);
};

const placeOrder = ({ order }) => {
  validateAvailability(order);
  payOrder(order);
  shipOrder(order);
};

 

내려가기 규칙

코드는 위에서 아래로 이야기처럼 읽혀야 한다. 추상화 수준이 높은 함수로 시작해서 낮은 함수로 흐름이 이동해야 한다.

추상화 수준을 하나로 유지하기란 쉽지 않다. 팁을 준다면 위에서 아래로 TO(~하려면) 문단을 읽어내려가듯이 코드를 구현해 보자. 예를 들면 아래와 같다.

TO 설정 페이지와 해제 페이지를 포함하려면, 설정 페이지를 포함하고, 테스트 페이지를 포함하고,
해제 페이지를 포함한다.
	TO 설정 페이지를 포함하려면, 슈트이면 슈트 설정 페이지를 포함한 후 일반 설정 페이지를 포함한다.
		TO 슈트 설정 페이지를 포함하려면, 부모 계층에서 "SuiteSetup" 페이지를 찾는다.
			TO 부모 계층을 검색하려면, ...

Switch 문

Switch 문(또는 if/else가 여럿 이어지는 구문)은 본질적으로 N가지 작업을 하므로 한 가지만 한다는 원칙을 지키기 힘들다. 하지만 switch 문을 완전히 피하는 것은 불가능하고, 다형성을 통해 저차원 클래스에 숨기고 반복하지 않을 수 있다.

 

function calculatePay(e: Employee) {
    switch (e.type) {
        case COMMISSIONED:
            return calculateCommissionedPay(e);
        case HOURLY:
            return calculateHourlyPay(e);
        case SALARIED:
            return calculateSalariedPay(e);
        default:
            throw new InvalidEmployeeType(e.type);
    }
}

 

위는 직원 유형에 따라 다른 값을 계산해 반환한다. 위 함수의 문제점은,

  1. 함수가 길고, 새 직원 유형을 추가할 때마다 길어진다.
  2. '한 가지' 작업만 수행하지 않는다.
  3. SRP(단일 책임 원칙: 어떤 클래스를 변경해야 할 이유는 오직 하나 뿐이어야 한다.)을 위반한다. 코드를 변경할 이유가 여럿이다. (메서드 레벨에서 SRP가 안지켜진 대표적인 예시가 분기 처리를 위한 if문이 많은 경우임)
  4. OCP(개방 폐쇄 원칙: 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다.)를 위반한다. 새 직원 유형을 추가할 때마다 코드를 변경해야 한다.

가장 큰 문제는 이런 형태의 코드들이 무한히 존재할 수 있다는 것이다. 예를 들어 isPayDay나 deliverPay 등의 함수가 위와 똑같은 구조로 나타날 수 있다.

추상 팩토리 패턴을 이용해 이 문제를 해결할 수 있다.

 

const COMMISSIONED = "commissioned";
const HOURLY = "hourly";
const SALARIED = "salaried";

abstract class Employee {
  public abstract isPayday(): boolean;
  public abstract calculatePay(): number;
}

class EmployeeRecord {
  public type: string;
  constructor(type: string) {
    this.type = type;
  }
}

interface EmployeeFactory {
  makeEmployee(r: EmployeeRecord): Employee;
}

class EmployeeFactoryImpl implements EmployeeFactory {
  makeEmployee(r: EmployeeRecord): Employee {
    switch (r.type) {
      case COMMISSIONED:
        return new CommissionedEmployee();
      case HOURLY:
        return new HourlyEmployee();
      case SALARIED:
        return new SalariedEmployee();
      default:
        return new DefaultEmployee();
    }
  }
}

class CommissionedEmployee extends Employee {
  isPayday(): boolean {
    return true;
  }
  calculatePay(): number {
    return 125;
  }
}
class HourlyEmployee extends Employee {
  isPayday(): boolean {
    return true;
  }
  calculatePay(): number {
    return 100;
  }
}
class SalariedEmployee extends Employee {
  isPayday(): boolean {
    return true;
  }
  calculatePay(): number {
    return 75;
  }
}

class DefaultEmployee extends Employee {
  isPayday(): boolean {
    return true;
  }
  calculatePay(): number {
    return 75;
  }
}

let factory: EmployeeFactoryImpl = new EmployeeFactoryImpl();
let employee: Employee = factory.makeEmployee(new EmployeeRecord(COMMISSIONED));
console.log(employee.calculatePay());

 

새 클래스가 추가되면, 팩토리 구현체에만 추가하면 된다.

서술적인 이름을 사용하라

함수 이름이 길어도 좋으니, 충분히 함수의 기능을 설명할 수 있는 이름을 선택하자. 서술적인 이름을 선택하면 개발자 머릿속에서도 설계가 뚜렷해져 코드 개선이 쉬워진다.

또한 이름을 붙일 때는 일관성을 가져야 한다. 모듈 내의 함수 이름은 같은 문구, 명사와 동사를 사용한다.

함수 인자

함수의 이상적인 인자 수는 0개이다. 그 다음은 1개, 2개 순이고, 3개부터는 피하는 것이 좋다. 다항(4개 이상)은 특별한 이유가 필요하다.

테스트의 관점에서 보더라도 인자가 많아질수록 테스트가 복잡해지고 부담스러워진다.

많이 쓰는 단항 형식

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

인자로 질문을 던지는 경우

boolean fileExists(String filename)

 

인자를 무언가로 변환해 결과를 반환하는 경우

InputStream openFile(String filename)

 

플래그 인자

플래그 인자는 함수가 한꺼번에 여러 역할을 하는 것을 나타내므로 좋지 않다. 특히 함수로 bool 값을 넘기는 경우가 많은데, 함수를 두 개로 분리할 수 있다면 분리하자.

이항 함수

이항 함수는 일반적으로 단항 함수보다 이해하기 어렵다. 대신 이항 함수가 적절한 경우는 두 값이 하나의 무언가를 표현하고, 자연적인 순서가 있는 경우이다.

 

Point p = new Point(0, 0);

 

이항 함수가 무조건 나쁜 것은 아니지만, 단항 함수로 표현 가능한 경우에는 형태를 바꾸는 것을 고려하자. 클래스로 하나를 추출하는 것이 대표적인 방법이다.

 

writeField(outputStream, name)

outputStream.writeField(name)

 

인자 객체

인자가 2-3개 필요한 경우, 일부를 독자적인 클래스로 바꿀 수 있다면 바꿔 본다.

 

Circle makeCircle(double x, double y, double radius)
// x와 y를 묶어 하나의 클래스로 만듦
Circle makeCircle(Point center, double radius)

 

인자 목록

인자 개수가 가변적인 경우, spread operator(...)의 도움을 받자.

 

void monad(Integer... args);

 

동사와 키워드

동사나 키워드를 통해 함수의 이름을 지어 의도를 표현하도록 한다.

단항 함수같은 경우 함수와 인수가 동사/명사 쌍을 이루게 한다.

 

write(name);
// 더 좋은 예시
writeField(name);

 

필요에 따라 함수 이름에 인자를 넣을 수도 있다.

 

assertEquals(expected, actual)
assertExpectedEqualsActual(expected, actual)

 

부수 효과를 일으키지 마라

부수 효과는 '함수에서 한 가지만 한다'는 원칙을 위반한다. 또한 시간적 결합이나 순서 종속성을 초래하기도 한다.

시간적 결합: 한 메서드를 반드시 다른 메서드보다 먼저 호출되어야 하는 경우를 말한다.

출력 인자: 일반적으로 개발자들은 인자를 '입력'으로 생각한다. 따라서 인자를 출력 값으로 사용하지 말아야 한다.

 

appendFooter(StringBuffer report)

 

위의 함수는 인자에 바닥글을 첨부하는 함수로, 출력 인자(report)를 입력으로 받는다. 이를 알기 위해서는 함수 선언부를 직접 확인하여야 한다. OOP가 생기기 전에는 불가피하게 출력 인자를 사용하는 경우가 있었지만, OOP의 this를 통해 출력 인자를 사용하지 않을 수 있다.

 

report.appendFooter()

 

명령과 조회를 분리하라

함수는 뭔가를 수행하거나, 뭔가에 답하거나 둘 중 하나만 해야 한다. 예를 들어 클래스 안의 메서드는 객체 상태를 변경하면서 객체 정보를 반환하면 안 된다.

 

public boolean set(String attribute, String value)

 

위의 함수는 attribute 속성을 찾아 value로 설정(상태의 변경)하고 성공하면 true, 실패하면 false를 반환(정보의 반환)한다.

아래 함수는 username을 unclebob으로 설정하는 것인지(상태의 변경), username이 unclebob인지 확인하는 것인지(정보의 반환) 분간이 어렵다.

 

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

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

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

 

if (deletePage(page) == E_OK)

 

위 코드는 if 문에서 명령을 표현식으로 사용하였고, 여러 단계로 중첩되는 코드를 야기할 수 있다.

 

if( ... ==E_OK) {
	if( ... == E_OK) {
		if (... == E_OK)
		else
	else
else

 

오류 코드 대신 예외를 사용하면 오류 처리 코드가 원래 코드에서 분리된다.

 

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

 

try-catch 블록 뽑기

try/catch 블록은 정상 동작과 오류 처리 동작을 뒤섞어 코드 구조에 혼란을 일으킨다. 별도 함수로 뽑아내자.

 

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

 

오류 코드도 한 가지 작업이다

위처럼 오류를 처리하는 함수는 오류만 처리해야 마땅하다.

반복하지 마라

DRY 원칙(Don't Repeat Yourself). 중복은 소프트웨어에서 가장 큰 문제이다. 많은 원칙과 기법들이 중복을 없애거나 제거하기 위해 등장했다. 중복되는 코드들을 부모 클래스로 몰거나, 하위 루틴들로 옮기자.

구조적 프로그래밍

다익스트라의 구조적 프로그래밍 원칙에 따르면, 모든 함수와 함수 내 모든 블록에 입구와 출구는 하나만 존재해야 한다. 즉, 함수의 return 문은 하나만 존재해야 한다. 또한 루프 내에서 break, continue를 사용해서는 안 되며 goto를 사용해서는 안 된다.

하지만 구조적 프로그래밍은 함수가 아주 클 때만 이익이 되므로, 함수를 작게 만든다면 return, break, continue를 사용해도 좋다.(때로는 사용하는 쪽이 이득이다.) goto 문은 큰 함수에서만 유용하므로 작은 함수에서는 피하자.

그래서 어떻게 짜는가?

초안은 목표한 기능을 완벽하게 수행하는 코드를 짠다. 그리고 이 서투른 코드를 빠짐없이 테스트하는 단위 테스트 케이스도 만든다. 그다음 코드를 다듬는 과정을 거치는데, 이 와중에도 단위 테스트를 항상 통과할 수 있어야 한다. 다양한 개선 과정을 반복하여 최종의 코드를 얻는다. 처음부터 완벽하게 짤 수는 없다.

댓글