본문 바로가기

개발자 강화/개발 독서

[Clean Code 2판] 1-7: Clean Functions

 

어느새 7장!


Part1. Code

Chapter7. Clean Functions

 

옛날 옛적... 함수 호출이 당연하지 않았던 시절이 있었어요

 

복-붙 시대(1940)

- 같은 작업이 필요할 때마다 노트에서 코드를 직접 베껴 씀

 

호출의 등장(1950)

- 일부 루틴이 범용적으로 유용하다는 것을 발견했고, 이를 subroutine으로 불렀음

- '저기에 있는 서브루틴 작업을 실행하도 돌아와'라는 명령어가 추가됨

- 초기에는 CALL 명령어가 느렸음. 프로그래머들은 '차라리 복사하는 게 빠르다면서 꺼렸음'

 

FORTAN, PL/1의 등장

- 컴퓨터가 빨라지며, 호출 비용을 무시할 수 있는 수준이 됨

- 인자를 받고 값을 반환하는 현대적인 함수가 탄생함

 

함수는 수십 년에 걸쳐 진화해서 살아남은 프로그램 구성의 핵심 단위임

함수를 잘 작성하는 것은 정말 중요함


Small!

작게 만들어라!

 

저자의 60년 경력에서 깨달은 것은...

함수는 작을수록 좋다는 것이다.

 

그는 한 함수를 작성하는 길이를 3천 줄->300줄->30줄로 줄여왔다.

그리고, 최근에는 12줄 이하로 작성하고 있다.

 

1999년에 Kent Beck과 함께한 경험

켄트 벡은 TDD(Test Driven Development)의 창시자이다.

1. 코드를 작성하기 전 테스트 케이스를 먼저 만든다

2. 이를 통과하는 최소한의 코드를 작성한다.

3. 이 코드를 개선하는 작업을 반복하며 리펙토링한다

 

저자는 켄트 벡의 집에 방문해 Sparkle이라는 프로그램을 함께 만들었다.

- 마우스를 움직이면 반짝이가 커서에서 떨어짐

- 신데렐라 요정 대모의 마법 지팡이 효과 같은 느낌

(우리나라에서도 미니홈피 유행하던 시절 이런 거 유행했던 듯)

 

당시, 저자는 TDD에 회의적인 입장이었다.

그러나, 테스트를 하나씩 통과시키며 코드를 작성할 때 '각 함수들이 정말 작다'는 사실에 충격을 받았다고 한다.

 

당시 20줄을 꽉채우는 함수에 익숙했던 저자에게

2~4줄이면서,

잘 명명된 이름을 가지고,

한 가지 일만 하면서,

다음 함수로 자연스럽게 이어지는 함수를 작성하는 경험은 충격적이었다고 함

 

 

Well-Written Prose

잘 쓰인 산문처럼

 

함수를 아주 작게 만들면, 코드가 잘 쓰인 산문처럼 자연스럽게 읽힌다

if (employee.shouldBePaidToday())
    employee.pay();

 

예를 들면, 이 코드는 영어 문장처럼 읽힌다.

'만약 직원이 오늘 급여를 받아야 한다면, 직원에게 급여를 지급한다'

 

또한, 함수를 작게 만들면 들여쓰기가 1~2단을 넘지 않아서 쉽게 읽고 이해할 수 있게 만든다.

 

 

One Level of Abstraction per Function (함수당 추상화 수준은 하나로)

 

함수 내부에 추상화 수준이 섞여있으면 혼란을 유발한다.

 

claude가 정리해준 예시는 다음과 같다.

// 모든 줄이 같은 추상화 수준
function 주문처리() {
    주문검증();      // 높은 수준
    결제진행();      // 높은 수준  
    배송요청();      // 높은 수준
    알림발송();      // 높은 수준
}

// 함수 내부에 서로 다른 추상화 수준이 섞인 경우
function 주문처리() {
    주문검증();                           // 높은 수준
    if (card.number.length !== 16) {...}  // 낮은 수준
    배송요청();                           // 높은 수준
    smtp.send(email, template);           // 낮은 수준
}

 

claude가 요리 레시피로 비유해준 것도 좀 웃겼는데, 아래와 같다.

// 추상화 레벨이 섞임
1. 파스타를 만든다
2. 물 분자가 100°C에서 기화하기 시작하면
3. 면을 넣는다
4. 나트륨 클로라이드를 5g 첨가한다
5. 소스를 만든다
6. 토마토의 세포벽을 파괴하여 리코펜을 추출한다

// 추상화 레벨이 일정함
파스타 만들기:
  1. 면을 삶는다
  2. 소스를 만든다
  3. 면과 소스를 섞는다

면 삶기:
  1. 물을 끓인다
  2. 소금을 넣는다
  3. 면을 8분간 삶는다

 

결국 이랬다 저랬다 하면 코드를 읽기 어렵기 때문에 추상화 수준을 일치시키라는 뜻


Reading Code from Top to Bottom: The Stepdown Rule

코드는 위에서 아래로 읽히는 이야기처럼 읽혀야 한다.

 

모든 함수 다음에는 다음 추상화 수준의 함수들이 와야한다.

한 번에 한 단계씩 추상화 수준을 내려가며 읽을 수 있어야 한다.

 

claude가 만든 예시로 보자면 아래와 같다.

// 1단계
function 주문처리() {
    결제하기();
    커피만들기();
    전달하기();
}

// 2단계 
function 커피만들기() {
    에스프레소추출();
    우유데우기();
    섞기();
}

// 3단계
function 에스프레소추출() {
    원두갈기();
    물붓기();
}

 

위에서 아래로 순서대로 읽으면, 한 단계씩 구체적으로 내려가는 것을 알 수 있다

주문처리: 결제하고, 커피를 만들고, 전달하는군

커피만들기: 에스프레소를 추출하고, 우유를 데우고, 섞는군

에스프레소 추출: 원두를 갈고, 물 붓는군

 

고수준 함수가 저수준 함수로 분해되어 함수 호출의 계층 구조를 만든다.

70-80년대에는 이를 functional decomposition(기능 분해)라고 했다.

 

하지만, 처음부터 완벽하게 분리된 코드를 작성할 수는 없다.

일단 작동하는 코드를 쓰고, 그 다음에 정리하라.

 

작동하는 코드를 만든 후, 추상화 수준별로 함수를 추출하면 된다. (리팩토링)

여기에서 사람마다 초점을 어디에 맞출지 생각이 다를 것 같은데,

내 생각엔 일단 짜고... "정리해라"에 강조된 어조가 느껴짐 ㅋㅋㅋ

 

Entanglement

 

존 오스터하우트의 반론! (대충 '이의있음!' 짤)

함수를 작게 쪼개면 Entanglement(얽힘) 문제가 생길 수 있다!

 

함수 A를 이해하려면, 함수 B를 봐야 하고,

함수 B를 이해하려면 함수 A를 봐야 하는 상황

 

저자의 입장은 다음과 같다.

함수들이 한 단계씩 추상화 수준을 내려가고,

저수준 함수들이 고수준 함수 뒤에 위치하면 약간의 얽힘은 용납해도 된다.

 

하지만, 존 오스터하우트는 다음과 같다.

얽힘이 있다면, 두 개의 얽힌 함수를 하나로 합쳐야 한다.

 

무엇이 정답임을 알려주기 보다는, 저자의 반대 입장에는 이런 의견도 있다는 걸 알려주는 듯 하다.

 


Switch Statements

작은 switch 문을 만들기는 어렵다.

한 가지 작업만 하는 switch 문을 만들기도 어렵다.

본질적으로 switch문은 항상 N가지를 처리한다.

(그것이... 스위치문이니까... 끄덕)

 

claude가 생성해준 예시를 보면 아래와 같다.

// 결제 금액 계산
function calculateFee(payment: Payment): number {
    switch (payment.type) {
    case "card":
        return payment.amount * 0.03;  // 3% 수수료
    case "cash":
        return 0;
    case "point":
        return payment.amount * 0.01;  // 1% 수수료
    }
}

// 결제 처리
function processPayment(payment: Payment): void {
    switch (payment.type) {
    case "card":
        callCardAPI(payment);
        break;
    case "cash":
        openCashDrawer();
        break;
    case "point":
        deductPoints(payment);
        break;
    }
}

// 영수증 출력
function printReceipt(payment: Payment): string {
    switch (payment.type) {
    case "card":
        return `카드 결제: ${payment.cardNumber}`;
    case "cash":
        return `현금 결제`;
    case "point":
        return `포인트 결제: ${payment.pointsUsed}P`;
    }
}

 

이 코드는 4가지 문제점을 가지고 있다.

1. 새 결제 수단을 추가하면 함수 크기가 커진다.

2. 한 함수가 여러 결제 수단의 수수료를 처리한다.

3. SRP 위반: 카드 수수료 변경, 포인트 수수료 변경 등 변경 이유가 여러 개

4. OCP 위반: 새 결제 수단 추가 시 코드 수정

 

또, 새 결제 수단을 추가할 경우 calculateFee(), processPayment(), printReceipt() 세 곳 모두 수정해야 한다.

 

이럴 땐 switch 문을 딱 한 곳에 두고, 다형성 객체를 생성하는 데만 사용하는 방법을 추천한다.

 

// 인터페이스 정의
interface PaymentMethod {
    calculateFee(): number;
    process(): void;
    getReceipt(): string;
}

// 각 결제 수단이 자신의 로직을 담당
class CardPayment implements PaymentMethod {
    constructor(private amount: number, private cardNumber: string) {}
    
    calculateFee(): number {
        return this.amount * 0.03;
    }
    
    process(): void {
        callCardAPI(this);
    }
    
    getReceipt(): string {
        return `카드 결제: ${this.cardNumber}`;
    }
}

class CashPayment implements PaymentMethod {
    constructor(private amount: number) {}
    
    calculateFee(): number {
        return 0;
    }
    
    process(): void {
        openCashDrawer();
    }
    
    getReceipt(): string {
        return `현금 결제`;
    }
}

class PointPayment implements PaymentMethod {
    constructor(private amount: number, private pointsUsed: number) {}
    
    calculateFee(): number {
        return this.amount * 0.01;
    }
    
    process(): void {
        deductPoints(this);
    }
    
    getReceipt(): string {
        return `포인트 결제: ${this.pointsUsed}P`;
    }
}

 

다형성 객체를 생성한다.

 

class PaymentFactory {
    static create(type: string, data: PaymentData): PaymentMethod {
        switch (type) {
        case "card":
            return new CardPayment(data.amount, data.cardNumber);
        case "cash":
            return new CashPayment(data.amount);
        case "point":
            return new PointPayment(data.amount, data.pointsUsed);
        default:
            throw new Error(`Unknown payment type: ${type}`);
        }
    }
}

 

switch문은 abstract factory에 묻어둔다

 

function handlePayment(type: string, data: PaymentData) {
    const payment = PaymentFactory.create(type, data);
    
    const fee = payment.calculateFee();
    payment.process();
    const receipt = payment.getReceipt();
    
    return { fee, receipt };
}

 

사용처에서는 switch문이 직접 보이지 않는다

 

// 1. 새 클래스만 추가
class BankTransfer implements PaymentMethod {
    constructor(private amount: number, private bankAccount: string) {}
    
    calculateFee(): number {
        return 500;  // 고정 수수료
    }
    
    process(): void {
        callBankAPI(this);
    }
    
    getReceipt(): string {
        return `계좌이체: ${this.bankAccount}`;
    }
}

// 2. 팩토리에 한 줄 추가
case "bank":
    return new BankTransfer(data.amount, data.bankAccount);

새 결제수단인 계좌이체를 추가한다고 하면,

기존 코드 handlePayment는 수정하지 않고 새 클래스와 팩토리 한 줄 추가만으로 대응이 가능하다.

정리하자면, 저자는 switch문은 다음 조건을 모두 만족할 때만 사용해야 한다고 말한다.

1. 단 한 번만 나타난다

2. 구체적인 모듈에 위치한다

3. 다형성 객체를 생성하는데 사용된다

4. 인터페이스 뒤에 숨겨져 나머지 시스템이 볼 수 없다.

 


Clean Functions: A Deeper Look

깨끗한 함수는 다섯가지 속성을 가져야 한다.

1. Nameable(이름 붙일 수 있는)

2. Insulated(격리되어 있는)

3. Homogenous(균질한)

4. Contextual(맥락적인)

5. Pure(순수한)

 

각 속성에 대해 더 자세히 알아보자

 

1. Contextual(맥락적인)

언어와 문법에 관계없이, 모든 함수는 맥락 안에 산다.

그리고, 그 맥락을 식별하고, 생성하고, 관리하는 것은 프로그래머의 일이다.

 

- 어떤 함수가 외부에 노출되어야 하는가? -> public

- 어떤 함수가 내부에 숨겨져야 하는가? -> private

- 이 경계를 어디에 그을 것인가? -> public과 private의 구분으로 만들어지는 경계

 

2. Nameable (이름 붙일 수 있는)

함수의 이름은 맥락 내에서 적절히 설명적이고, 적절히 편리해야 한다.

 

 

2-1. Descriptive(설명적인)

모든 함수는 그 함수가 무엇을 하는지 설명하는 이름을 가지고 있어야 한다.

이름이 행동을 설명하므로, 동사/동사구/암시적 동사로 구성되어야 한다.

 

설명은 함수 내 코드보다 약간 더 추상적이어야 한다. (함수 이름이 너무 구구절절이면 또 곤란혀)

public static double addAtoBandDivideBy2(double a, double b) {
  return (a+b)/2.0;
}

 

이런 건 ㄴㄴ... average처럼 좀 추상화한 암시적 동사를 쓰는 게 낫다

 

파생 클래스의 메서드는 기반 클래스보다 더 상세한 기능을 가지고 있다.

SalesRecipts 클래스에서 유래한 파생 클래스는 addSalesReceipt라는 이름을 가진다.

보다 상세한 맥락을 가지고 있는 경우에는 당연히 더 많은 단어를 써서 설명해야 한다.

 

이름을 길게 만드는 것을 두려워하지 마라.

길고 설명적인 이름이 길고 설명적인 주석보다 낫다.

 

이름을 선택하는 데 시간을 쏟는 것을 두려워하지 마라.

가능한 한 설명적인 이름을 찾을 때까지 여러 이름을 코드에 집어넣고 읽어봐라!

(어차피 단어 선택해서 전체 바꾸기 쓰면 금방 바꾸니까 이것저것 넣어서 코드 읽어보고 최선을 찾으래요)

 

2-2. Convenient(편리한)

함수의 이름은 짧고 기억하기 쉬워야 한다.

하지만, 짧은 이름이 항상 설명적인 것은 아니라 타협점을 찾는 것이 필요하다!

 

함수 이름의 길이는 사용되는 범위의 크기에 반비례 해야 한다.

자주 쓰임 -> 간단한 이름. 거기서만 쓰임 -> 상세하게 설명함.

이건 4장에서 많이 이야기했던 그 내용...

 

3. Insulated(격리된)

The word complex means more than one strand

'복잡함'의 의미는 하나 이상의 가닥을 의미한다.

 

함수의 입력-출력을 한 개의 가닥이라고 생각하자.

그럼 파라미터가 늘어날수록, 그 가닥의 개수가 많아지고 함수가 복잡해진다.

 

파리미터가 없는 함수는 호출하기 쉽고(props drilling도 필요 없고 그냥 호출하면 됨)

class 내부에서 필요한 데이터를 이미 가지고 있다.

 

저자는 허용하능한 params의 max를 3개라고 말함.

만약 3개를 넘어간다면? 이걸 낱개로 전달하는 것보다 객체 형태로 캡슐화하는 것을 제안함.

 

낱개로 전달하는 claude의 예시는

function createUser(
    name: string,
    email: string,
    age: number,
    address: string,
    phone: string
) {
    // ...
}

// 호출할 때
createUser("홍길동", "hong@example.com", 30, "서울시", "010-1234-5678");

 

객체로 전달하는 예시는

interface UserData {
    name: string;
    email: string;
    age: number;
    address: string;
    phone: string;
}

function createUser(userData: UserData) {
    // ...
}

// 호출할 때
createUser({
    name: "홍길동",
    email: "hong@example.com",
    age: 30,
    address: "서울시",
    phone: "010-1234-5678"
});

 

이거 읽으면서 신기했던 건... 내 평소 코딩 습관이

코드 줄바꿈으로 늘어지는 게 못생겨서 낱개로 props 쪼개는거 싫어했고,

웬만하면 함수 내부에서 호출해서 props drilling 끊거나

data 객체를 통째로 넘기는 식으로 코드를 짰었다

 

저자가 그런 방식을 제안해서 좀 신기했다

본능이 클린코드 ㄷㄷ

 

4. Homogenous(균질한)

이건 이번 장(7장)의 Reading Code from Top to Bottom: The Stepdown Rule 챕터에서 말한 내용과 같다.

함수 내부는 모두 동일한 추상화 수준에 있어야 하고, 함수 이름보다 한 단계 아래 수준의 추상화를 가지고 있어야 한다.

 

하지만, 이렇게 정리하다 보면 코드가 길어져 보일 수 있다.

(단계별로 쪼개고 정리하면 원래 작동하던 코드보다 더 길어질 수 있음)

하지만 처음 보는 사람에게든 훨씬 더 가독성 좋은 코드가 된다.

 

함수를 작게 쪼개서 코드가 길어지고 성능 타령 하는 사람들에게:

읽기 쉬운 코드를 만들 수 있는데, 지금 50나노세컨드 차이가 중요하냐??

 

어차피 당신이 지금 안하면 다른 누군가가 이후에 이것을 유지보수해야 할 것이고,

당신이 소중히 여긴 50나노세컨드보다 그들의 시간이 더 소중하다.

.

.

라고 저자 아조씨가 꾸짖으시네요~

(매번 느끼는 건데 아조씨 극딜 진짜 살벌하게 넣음)

 

5. Pure(순수한)

순수 함수의 행동은 인수 외에는 아무것도 의존하지 않고, 그 실행이 다른 함수의 행동을 변경하지 않는다.

 

순수 함수의 장점 5가지

1. 캐싱이 가능하다

- 같은 입력 -> 같은 출력이므로, 결과를 저장해두고 재사용할 수 있다.

2. 동시성 안전

- 시스템 상태를 변경하지 않으므로 경쟁 조건에 덜 취약하다.

3. 조합 용이

- 외부 의존성이 없어서 다른 함수와 자유롭게 조합할 수 있다.

4. 테스트 용이

- 특별한 설정 없이 입력만 주면 테스트 가능

5. 분산 용이

- 어느 프로세서에 실행해도 결과가 같다

 

순수 함수는 어떻게 만들까? 어떤 변수의 값도 변경하지 않으면 된다.

public static double average(double... ns) {
    return Arrays.stream(ns).sum() / ns.length;
}

 

파라미터 ns로만 결과가 결정된다. 순수함수!

 

private int count = 0;

public int increment() {
    count++;          // 외부 상태 변경!
    return count;
}

외부 변수 count에 의존한다.

호출할 때마다 결과가 달라진다. 순수함수 아님!

 

호오 두 달 전에 수빈님께서 pr 코멘트로 알려주셨던 내용이다

 

결론은!

순수성은 함수의 외부적 특성이지, 내부적 특성이 아니다.

함수 내부가 불순하든 뭐든 외부 관찰자에게 숨겨져있는 한 그 함수는 순수함수임

(내부가 불순,,,ㅋㅋㅋ)

 

Partial Purity(부분적 순수성)

순수성은 관찰된 품질이지, 본질적인 품질이 아니다.

 

void openAndDo(파일명, 작업) {
    파일 열기
    작업 수행
    파일 닫기   // ← 원상복구!
}

 

claude로 내용을 추상화 시킨 함수인데, 대충 이런 파일 열고 쓰고 닫는 함수가 있다고 치자.

만약 관찰 대상이 '파일이 열려있는가?'라면 순수함수이다. 파일을 열고 다시 닫았으니까 원상태로 복구함

하지만, 관찰 대상이 '파일 내용'이라면? 순수함수가 아니다. 파일 내용이 변형되었다.

 

관찰 대상에 따라 속성이 바뀌는게 무슨 슈뢰딩거의 고양이? 같고 과학같고 좋네요

사실 컴퓨터 과학도 과학이긴 해


Conclusion

로버트 마틴 왈... 깨-끗한 함수는 다음과 같다.

 

1. 작다 (2-4줄이 이상적)

2. 한 가지를 한다 (내부에 한 가지 추상화 레벨을 가지고, 하나의 책임만 진다)

3. 산문처럼 읽힌다 (위에서-아래로 자연스럽게)

4. 잘 정의된 맥락 내에 존재한다 (적절한 경계)

5. 서로 격리되어 있다 (파라미터 3개 이하)

6. 좋은 이름이 붙여져 있다 (설명적인 이름)

7. 순수하다 (외부에서 볼 때 side effect이 없다. 같은 input, 같은 output)

 

 


연말연초 가족과 함께 따뜻한 시간을 보내고 왔더니

감이 좀 떨어졌네욘! 다시 감 찾아오겠음!

공부는 멈추지 않아! 킵고잉!

 

작성: 2025.01.06. 00:28

728x90