본문 바로가기
스터디/Clean Code

Ch3. 함수

by 헤콩 2023. 2. 24.
반응형
함수는 프로그램의 가장 기본적인 단위이다.
이번 3장에서는 함수를 잘 만드는 법을 소개한다.

누군가 내가 짠 함수 코드를 보았을 때, 너무 길어서, 너무 복잡해서, 함수 이름을 이해하지 못해서, 중복되는 코드가 너무 많아서 등의 이유로 긴 시간동안 이해하지 못한다면 이 글을 읽고 함수를 짜는 방식을 고쳐보는 건 어떨까?

 

목차

더보기

목차

1. 작게 만들어라

2. 한 가지만 해라

3. 함수 당 추상화 수준은 하나로

4. Switch 문

5. 서술적인 이름을 사용하라

6. 함수 인수

7. 부수 효과를 일으키지 마라

8. 명령과 조회를 분리하라

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

10. 반복하지 마라

11. 구조적 프로그래밍

12. 함수를 어떻게 짜죠?


1. 작게 만들어라

✅함수는 가능한 한 작게 만들어야 합니다.

✅각 함수들은 명백하게 하나의 이야기를 표현해야합니다.

 

- 블록과 들여쓰기

if문/else문/while문 등에 들어가는 블록은 한 줄이어야 합니다.

블록 안에서 호출하는 함수 이름을 적절히만 짓는다면, 코드를 이해하기는 한 층 더 쉬워집니다.

즉, 함수에서 들여쓰기 수준은 1단이나 2단을 넘어서면 안됩니다.

그래야 함수는 읽고 이해하기 쉬워집니다.

함수를 최대한 작게

 

2. 한 가지만 해라

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

함수 내에서 의미 있는 이름으로 다른 함수를 추출할 수 있다면 그 함수는 여러 작업을 하는 셈입니다.

우리가 함수를 만드는 이유는 큰 개념을 추상화 수준에서 여러 단계로 나눠 수행하기 위해서 만들기 때문에, 하나의 함수는 한 가지 역할만 수행해야 합니다. 하나의 함수에서 여러 역할을 수행하면 안됩니다.

 

3. 함수 하나당 추상화 수준은 하나로

함수가 확실히 '한 가지' 작업만 하기 위해서는,

함수 안의 모든 문장의 추상화 수준이 동일해야 합니다. 그렇다면 추상화 수준이 무엇일까요?

 

보통 추상화 수준이라고 하면, 구체적일 수록 추상화 수준이 낮다고 합니다.

만약 많은 조건을 거쳐서 실행되는 어떠한 함수가 있다면 그건 추상화 수준이 낮은 거겠죠?

 

마찬가지로 A함수를 실행하다가 B함수를 실행하고, B함수를 실행하다가 C함수를 실행하고, C함수를 실행하다가 D함수를 실행하는 상황이 있다고 하면, B함수는 추상화 수준이 높거나 중간이고, D함수의 경우 추상화 수준이 낮다고 할 수 있습니다.

 

그럼 왜 함수 내의 모든 문장의 추상화 수준이 동일해야 하는 걸까요?

한 함수 내에서 추상화 수준이 다른 문장이 여럿 존재한다면, 코드를 읽는 사람이 헷갈리기 때문입니다.

그리고 특정 표현이 근본 개념인지 아니면 세부사항인지 구분하기 어렵기 때문입니다.

 

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

코드는 위에서 아래로 이야기처럼 읽혀야 좋습니다.

위에서 아래로 읽으면 함수 추상화 수준이 한 단계씩 낮아지기 때문입니다.

 

추상화 수준이 하나인 함수를 구현하기란 쉽지 않지만, 그래도 매우 중요한 규칙입니다.

 

 

4. Switch 문

사실 아직 나는 이 부분을 잘 이해하지 못했다.

하지만 SRP와 OCP를 지키고, 여러 역할을 분배해서 처리하는 것이 다른 사람이 읽기 쉬운 코드로 한 걸음 다가갈 수 있는 단계라는 것을 말하고 있다고 느꼈다.

switch문은 작게 만들기 어렵습니다. '한 가지' 작업만 하는 switch문을 만들기 어렵기 때문입니다.

본질적으로 switch문은 N가지를 처리하기 때문에 switch를 사용하게 되는 상황을 만난다면,

다형성(polymorphism)을 이용한 저차원 클래스에 숨기고 절대로 반복하지 않는 방법이 있습니다.

 

먼저, 아래 코드에 어떤 문제가 있는지 볼까요?

 

1. 함수가 길다.

2. 새 직원 유형을 추가하면 더 길어진다.

3. 한 가지 역할만 가져야 한다는 SRP(Single Responsibility Principle)를 위반한다.

4. 새 직원 유형을 추가할 때마다 코드를 변경해야 하기 때문에 OCP(Open Closed Principle)을 위반한다.

/* 메인 함수 */
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 :
            throw new InvalidEmployeeType(e.type);
    }
    
}

 

그렇다면 이것을 어떻게 고칠 수 있을까요?

바로 switch문을 추상 팩토리(ABSTRACT FACTORY)에 숨기는 것입니다.

그리고 아무에게도 보여주지 않는 것입니다.

/* 추상 클래스 */
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 EmployeeFactoryImpl implements EmployeeFactory {
    /* 메인 함수 */
    public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
    
        switch(r.type) {
            case COMMISSIONED :
                return new CommissionedEmployee(r);
            
            case HOURLY :
                return new HourlyEmployee(r);
                
            case SALARIED :
                return new SalariedEmployee(r);
            
            default :
                throw new InvalidEmployeeType(r.type);
        }
    
    }
}

 

 

5. 서술적인 이름을 사용하라

서술적인 이름을 사용하면 개발자 머릿속에서도 설계가 뚜렷해지므로 코드를 개선하기 쉬워집니다.

 

코드를 읽으면서 짐작되는 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드라고 할 수 있습니다.

함수가 작고 단순할 수록 서술적인 이름을 고르기도 쉬워집니다.

 

길고 서술적인 이름이 짧고 어려운 이름이나 길고 서술적인 주석보다 좋습니다.

이름을 붙일 때는 일관성이 있어야 하며, 한 모듈 내에서의 함수 이름은 같은 문구, 명사, 동사를 사용합니다.

 

 

6. 함수 인수

*인수(argument) : 호출자가 함수에 전달한 값 (매개변수로 넘겨준 값)

함수 인수는 가능한 한 적어야 합니다. 0개면 훨씬 좋습니다. 적어도 인수가 3개 이상 넘어가면 안됩니다.

왜 인수가 적을 수록 좋을까요? 인수는 개념을 이해하기 어렵게 만듭니다.

 

아래의 예시를 보면,

StringBuffer를 함수 내에서 새로 생성하는 경우에는 그냥 새로 생성하는 것으로 보면 됩니다.

하지만 StringBuffer를 인수로 받아 사용하는 경우, 그 StringBuffer가 이 함수의 인수가 될 때까지 어떤 개념을 가지고 왔는지를 살펴봐야 하므로 더 복잡해집니다.

StringBuffer stringbuffer;

/* 1 */
public void addWord() {
    stringbuffer = new StringBuffer();
    stringbuffer.append("abc");
}

/* 2 */
public void addWord(StringBuffer sb) {
    sb.append("abc");
}

이런 면에서, 가능한 한 인수를 줄이는 것(할 수 있다면 인수를 없애는 것)이 좋은 코드를 만드는 하나의 방법입니다.

 

또한, 테스트 관점에서 보면 인수는 더 어렵습니다.

갖가지 인수들의 조합으로 함수를 검증하는 테스트 코드를 작성하려면 복잡하기 때문입니다.

 

- 많이 쓰는 단항 형식

함수에 인수 하나 이상을 쓰는 이유는 크게 두 가지로 나눌 수 있습니다.

    (1) 인수에 질문을 던지는 경우 => Boolean isFileExist(String filename)

    (2) 인수를 뭔가로 변환해 결과를 반환하는 경우 => InputStream fileOpen(String filename)

    (3) (드물지만) 함수 형식의 이벤트까지 포함하는 경우

 

위의 3가지 경우가 아니라면 단항 함수는 가급적 피하는 것이 좋습니다.

또한 함수의 인수로 StringBuffer를 넘기는 것처럼 변환 함수(뭔가를 변환해 반환하는 함수)의 인수로 출력인수(StringBuffer같은 인수들)를 사용하면 혼란을 일으킬 수 있습니다.

 

- 플래그 인수

플래그 인수는 최대한 쓰지 않는 것이 좋습니다.

플래그 인수를 사용한다는 말은,

플래그 인수가 true인지 false인지에 따라 역할이 달라진다는 말과 같으므로 하나의  함수 안에서 둘 이상의 역할을 하게 된다는 말과 같기 때문입니다.

 

- 이항 함수 / 삼항 함수

인수가 2, 3개인 함수는 1, 2개인 함수보다 이해하기 어렵습니다. 인수가 많을수록 하나 하나의 인수가 어떤 역할인지, 어떤 개념을 가지고 있는지 이해해야 하기 때문입니다. 그래서 add(int a, int b) 와 같이 꼭 필요한 경우가 아니라면 이항 함수보다는 단항 함수로 만드는게 보기도 이해하기도 좋습니다.

 

- 인수 객체

인수가 2~3개를 넘어가야하는 불가피한 상황이 발생했을 때는, 여러 인수를 객체로 만들어서 하나의 객체를 받는 단항 인수로 바꾸는 게 좋습니다.

마찬가지로 인수가 여러 개일 때보다 이해하는 속도가 빠를 뿐더러, 여러 인수들을 객체를 만들면서 객체 이름에 개념을 부여하기 때문에 더 이해하기 쉬워집니다.

 

- 인수 목록

때로는 인수가 가변적인 함수도 필요합니다. 가변함수라고 해서 인수의 개수가 늘어나는 것이 아니라 가변인수는 하나의 인수로 볼 수 있습니다.

예를 들어 String.format()의 경우, String.format("%s worked %.2f hours", name, hours)라는 게 있다고 해봅시다.

 

그렇다면 이것은 삼항 함수라고 볼 수 있을까요?

실제로는 String.format(String format, Object... args)의 형식을 따르기 때문에 이항 함수라고 할 수 있습니다.

 

- 동사와 키워드

단항 함수는 함수 이름과 인수 이름이 동사/명사 쌍을 이뤄야 이해를 돕습니다.

예를 들면, writeField(name)이 있습니다.

 

그리고 함수 이름에 키워드(인수이름)을 넣는다면, 함수 인수의 순서를 한 번 더 읽을 필요가 없어집니다.

예를 들어, assertEquals(Expected, Actual)보다 assertExpectedEqualsActual(Edpected, Actual)이 함수이름으로 한 번에 파악하기 쉽습니다.

 

 

7. 부수 효과를 일으키지 마라

어떤 함수에서 한 가지 일을 하기로 해놓고, 사실 내부적으로는 뭔가 전혀 다른 일을 처리한다면 매우 혼란스러울 것입니다. 이를 '부수 효과(Side Effect)'라고 합니다.

 

즉, 함수이름에 명시된 역할 이외의 부수 역할을 부여하지 말아야 합니다.

 

- 출력 인수

일반적으로 우리는 함수 인수를 입력으로 인식합니다.

그래서 최대한 출력타입의 인수는 피해야 합니다.

이전에는 출력타입의 인수를 써야하는 상황이 발생하는게 불가피했을지 몰라도 객체지향 언어가 나온 이후부터는 함수 내에서 상태를 변경해야한다면, 출력 인수를 사용하는 것이 아니라 객체 자체 상태를 변경하는 방식을 선택하게 되었습니다.

즉, 예를 들어 appendFooter(StringBuffer report)보다 report.appendFooter()가 훨씬 나은 방식입니다.

 

 

8. 명령과 조회를 분리하라

함수는 뭔가를 실행하거나(명령) 뭔가에 답하거나(조회) 둘 중 하나만 해야합니다.

명령 과 조회 둘 다의 역할을 가지게 되면 코드는 이상해질 수 밖에 없습니다.

 

예를 들어, public boolean set(String attribute, String value)라는 함수가 있다고 해봅시다.

함수만 보았을 때, 우리는 이 함수가 함수 내의 attribute라는 속성을 찾아 value로 바꾸고 성공하면 true를 실패하면 false를 반환한다는 것을 알 수 있습니다.

여기까지만 보았을 때는 이게 왜 이상하지? 할 수 있지만,

이 함수를 활용하는 다음 코드를 보면 코드가 이상해진 다는 것을 알 수 있습니다.

 

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

 

독자 입장에서 이 코드를 읽어보면 다양한 해석이 가능해서 혼란해질 수 있습니다.

    "username"이 "Jane"으로 설정되어 있는지 확인하는 코드인가?

    "username"을 "Jane"으로 설정하는 코드인가?

'set'이라는 단어가 동사인지 형용사인지 분간하기 어려운 코드가 되기 때문입니다.

 

그래서 다음과 같이 명령과 조회의 역할을 분리하는 것이 좋은 방법입니다.

 

if (attributeExists("username")) {

        setAttribute("username", "Jane");

        ...

}

 

 

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

오류 코드를 사용하게 되면, 오류가 발생할 때마다 처리해줘야 하는 번거로움이 발생하고 그만큼 코드도 복잡해집니다.

하지만 예외를 사용하게 되면 오류 처리 코드가 원래 코드에서 분리되어 한결 깔끔해 지는 것을 볼 수 있습니다.

오류 코드 예외

 

- try/catch 블록 뽑아내기

try/catch는 일반적으로 코드 구조에 혼란을 일으키고, 정상 동작과 오류 동작을 뒤섞기 때문에 try/catch 블록을 별도 함수로 뽑아내는 것이 좋습니다.

// 메인 함수
public void delete(Page page) {
    try {
        deletePageAndAllReference(page);
    } catch(Exception e) {
        logError(e);
    }
}

// try문 안에서 수행되는 함수
private void deletePageAndAllReference(Page page) throws Exception {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
}

// catch문에서 실행되는 함수
private void logError(Exception e) {
    logger.log(e.getMessage());
}

이렇게 정상 동작과 오류 처리 동작을 분리하면 코드를 이해하고 수정하기 쉬워집니다.

 

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

함수는 '한 가지' 작업만 해야합니다. 오류처리도 '한 가지' 작업이기 때문에 오류를 처리하는 함수는 오류만 처리해야합니다.

 

- Error.java 의존성 자석(magnet)

오류 코드를 반환하는 것은 클래스든 열거형 변수든 어디선가 오류 코드를 반환한다는 말입니다.

public enum Error {
    OK,
    INVALID,
    NO_SEARCH,
    LOCKED;
}

위와 같은 클래스를 의존성 자석이라고 할 수 있습니다.

오류 처리하는 작업에서 오류 코드를 반환하는 위와 같은 클래스를 import하여 사용하게 되면,

이후 오류 코드를 수정하거나 추가/삭제 작업을 했을 때 이 클래스를 사용하는 모든 클래스에 영향을 끼치게 됩니다.

 

하지만 오류 코드 대신 예외를 사용한다면 재컴파일/재배치 없이도 (Exception클래스에서 파생된)새 예외 클래스를 추가할 수 있습니다.

 

즉, 오류 코드 대신 예외를 사용하자!

 

 

10. 반복하지 마라

깨끗한 코드를 만들기 위한 가장 중요한 것이 바로 '중복을 없애는 것'이다.

중복되는 코드를 사용하게 되면 코드가 길어질 뿐만 아니라 이후 알고리즘이 변하게 되었을 때 중복되는 모든 부분을 고쳐야 하는 상황이 발생합니다. 따라서 어느 한 곳을 놓쳐 고치지 못했을 경우 오류가 발생할 확률도 그만큼 늘어납니다. 그래서 객체 지향 프로그래밍에서는 코드를 부모 클래스로 몰아서 중복을 없앱니다.

 

 

11. 구조적 프로그래밍

모든 함수와 함수 내 모든 블록에는 입구와 출구가 하나만 존재해야 한다.
- 에츠허르 다익스트라(Edsger Dijkstra)

입구와 출구가 하나만 존재해야 한다니, 이게 무슨 말일까요?

함수 안에서 return 문은 하나여야 한다는 말과 같으며,

루프 안에서 break나 continue를 사용해서는 안되고 goto절대로 사용하면 안된다는 말입니다.

*goto : CPU가 코드의 다른 지점으로 점프하도록 하는 제어 흐름 명령문(java에는 존재하지 않는다)

 

그래도, 작은 함수에서는 break나 continue의 사용은 사용해도 괜찮습니다.

하지만 goto는 큰 함수에서만 의미가 있으므로 작은함수에서는 절대로 사용하지 않아야 합니다.

 

 

12. 함수를 어떻게 짜죠?

소프트웨어를 짜는 행위는 여느 글짓기와 비슷합니다. 글을 처음부터 깔끔하게 잘 쓰는 사람은 없습니다.

우리가 글을 쓸 때는 먼저 생각을 기록한 후 읽기 좋게 다듬고 문장을 고치고 문단을 정리합니다.

 

함수도 마찬가지로, 처음에는 길고 복잡하게 쓸 수 있습니다. 이름도 즉흥적이고 코드가 중복될 수 있습니다.

하지만 계속해서 코드를 다듬고, 함수를 분리하고, 이름을 바꾸고, 중복을 제거해간다면 최종적으로 이 책에서 말하는 깔끔한 함수를 얻어낼 수 있습니다.

 

 

 

 

3장을 마치며

코드를 짜는 것이야기를 써내려 가는 것과 같다.
이름에는 동사와 명사가 어우러지며 개념의 흐름이 담겨져야 하고,
코드는 위에서 아래로 물 흐르듯 읽혀야 한다.
함수는 한 가지 역할만 수행해야 하며, 중복된 코드는 피해야 한다.

처음부터 함수를 깔끔하게 짜는 사람은 없다. 계속해서 다듬고 정리해나가자.
반응형

'스터디 > Clean Code' 카테고리의 다른 글

Ch4. 주석  (0) 2023.02.24
Ch2. 의미 있는 이름  (0) 2023.02.24

댓글