함수

함수를 잘 만드는법을 살펴본다. 함수에 어떤 속성을 부여해야 처음 읽는 사람이 프로그램 내부를 직관적으로 파악할 수 있을까?

작게 만들어라

작은 함수가 좋다. 각 함수는 명백히 하나의 이야기를 표현해야 한다.

블록과 들여쓰기

블록 구조를 갖는 코드는 한줄 이어야 한다. 중첩 구조가 생길만큼 함수가 커져서는 안된다. 들여쓰기 수준을 1단이나 2단 정도로 유지한다.

한가지만 해라

함수는 한 가지를 해야 한다. 그 한 가지를 잘 해야 한다. 그 한 가지만을 해야 한다. 함수를 판단할때 지정된 함수 이름 아래에서 추상화 수준을 판단한다.

함수를 만드는 이유는 큰 개념을 여러 단계로 나눠 수행하기 위해서이다. 추상화 수준이 둘 이상이라면 더 축소 가능할 수 있다. 따라서 한 가지만 하는지 판단하는 방법이 하나 더 있다. 단순히 다른 표현이 아니라 의미 있는 이름으로 다른 함수를 추출할 수 있다면 그 함수는 여러 작업을 하는 셈이다.

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

함수가 확실히 한 가지 작업만 하려면 함수 내 모든 문장의 추상화 수준이 동일해야 한다. 한 함수 내의 추상화 수준을 섞으면 코드를 읽는 사람이 근본 개념인지 세부사항인지 구분하기 어려워 진다. 이렇게 뒤섞기 시작하면 점점더 엉망이 된다.

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

프로그램을 위에서 아래로 읽으면서 함수 추상화 수준이 한다계씩 낮아지게 작성해야 한다. TO 문단을 읽듯이 프로그램이 읽혀야 한다는 것인데 각 TO 문단은 이어지는 아래 단계의 TO 문단(~ 하려면) 을 참고한다.

ex )

  • TO 설정 페이지와 해제 페이지를 포함하려면, 설정 페이지를 포함하고, 테스트 페이지 내용을 포함하고, 해제 페이지를 포함한다.
    • TO 설정 페이지를 포함하려면 , 슈트이면 슈트 설정 페이지를 포함한 후 일반 설정 페이지를 포함한다.
    • TO 슈트 설정 페이지를 포함하려면, 부모 계층에서 “SuiteSetUp” 페이지를 찾아 include 문과 페이지 경로를 추가한다.
    • TO 부모 계층을 검색하려면 …

위에서 아래로 TO 문단을 읽어내려 가듯이 코드를 구현하면 추상화 수준을 일관되게 유지하기 쉬워진다.

Switch 문

Switch 문은 기본적으로 N가지를 처리하기 때문에 한가지 일만 하기 어렵다. 하지만 Switch 문을 사용하지 않을 수 도 없다. 하지만 각 switch 문을 저차원 클래스에 숨기고 절대 반복하지 않은 방법이 있다.

직원 유형에 따라 다른 값을 계산해 반환하는 함수이다. 좋지 않은 예시 : ❌

1
2
3
4
5
6
7
8
9
10
11
12
function calculatePay(employee: Employee) {
switch (employee.type) {
case 'COMMISSIONED':
return calculateCommissionedPay(employee);
case 'HOURLY':
return calculateHourlyPay(employee);
case 'SALARIED':
return calculateSalariedPay(employee);
default:
throw new Error(employee.type);
}
}

다음과 같은 문제가 있다.

  1. 함수가 길다. 새 직원 유형을 추가하면 더 길어진다.
  2. 한가지 작업만 수행하지 않는다.
  3. SRP(Single Responsibility Principle) 단일 책임 원칙을 위반한다.
  4. OCP(Open Closed Principle): 개방 폐쇄 원칙을 위반한다. ( 소프트웨어 개체는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.) → 새 직원을 추가할때마다 코드를 변경해야 하기 때문이다.
  5. 가장 큰 문제는 위 함수와 구조가 동일한 함수가 무한정 존재한다는 사실이다.

이문제를 해결하기 위해 switch 을 추상 팩토리(ABSTRACT FACTORY) 에 숨긴다. Switch 문을 통해 적절한 Employee 파생 클래스의 인스턴스를 생성한다. 여러가지 함수는 Employee 인터페이스를 거쳐 호출되게 한다. 그러면 다형성으로 인해 실제 파생 클래스의 함수가 실행된다.

좋은 예 : ✅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
abstract class Employee {
abstract isPayday(): boolean;
abstract calculatePay(): Money;
abstract deleverPay(pay: Money): void;
}

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

class EmployeeFactoryImpl implements EmployeeFactory {
makeEmployee(record: EmployeeRecord): Employee {
switch (record.type) {
case 'COMMISSIONED':
return new CommissionedEmployee(record);
case 'HOURLY':
return new HourlEmployee(record);
case 'SALARIED':
return new SalariedEmployy(record);
default:
throw new Error(record.type);
}
}
}

Siwtch 문을 다형적 객체를 생성하는 코드로 숨겨 버린다.

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

길고 서술적인 이름이 길고 서술적인 주석보다 좋다. 함수 이름만 보고 동작을 예측할수 있다면 좋은 코드라고 할수 있다. 단 이름을 붙일 때는 일관성이 있어야 한다. 모듈 내에서 함수 이름은 같은 문구, 명사 동사를 사용한다. includeSetupAndTeardownPages, IncludeSetupPages, includeSuiteSetuppage, includeSetupPage 등은 좋은 예이다.

함수 인수

함수의 인수는 적을수록 좋다. 보통 2개 정도까진 괜찮고, 4개 이상부터는 특별한 이유가 필요하다. 인수가 있는것보다 인수가 없는것이 코드를 읽는사람이 이해하기 쉽다. 인수가 많아지면 테스트도 어렵다. 2개이상의 인수를 받는 함수를 테스트 하는것은 테스트를 어렵게 만든다. 최선은 입력 인수가 없는 경우이며, 차선은 입력 인수가 1개뿐인 경우이다.

많이 쓰는 단항 형식

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

  1. 인수에 질문을 던지는 경우이다. fileExists("myfile"): boolean
  2. 인수를 뭔가로 변환해 결과를 반환하는 경우 parseInt("2"): number

이 두경우는 독자가 당여하게 받아들인다.

플래그 인수

플래그 인수는 좋지 않다. 플래그 인수에 따라서 동작이 달라진 다는 의미가 함수에서 두가지를 한다는 의미이기 때문이다. 함수를 분리하는 것이 좋다.

redux 관련해서 작성했던 좋지 않은 예 : ❌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 export function fetchThunk<T, I extends {[key: string]: InitialState<T>}>(
fetch: AsyncThunk<T, string, {}>,
key: keyof I,
keep: boolean, // flag 인수
handler?: (state: I, action: PayloadAction<T>) => void, // handler fulfilled 이후에
) {
return {
[fetch.pending.type]: (state: I) => {
state[key].loading = true;
state[key].data = keep ? state[key].data : null; // flag 인수에 사용
state[key].error = null;
},
[fetch.fulfilled.type]: (state: I, action: PayloadAction<T>) => {
state[key].loading = false;
state[key].data = action.payload;
handler && handler(state, action);
},
[fetch.rejected.type]: (
state: I,
action: ReturnType<typeof fetch.rejected>,
) => {
state[key].loading = false;
state[key].error = action.error;
},
};
}

keep : 이라는 flag 인수를 두어서 데이터를 유지할지 선택하였다.함수를 분리하고 이름도 더 길게 서술하도록 한다.

handler 를 받아서 fulfilled 이후에 실행할 함수를 콜백으로 받았는데 이또한 handler가 있는 함수와 핸들러가 없는 함수로 분리한다.

좋은 예 : ✅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
export function fetchDataKeepingPrev<T, I extends {[key: string]: InitialState<T>}>(
fetch: AsyncThunk<T, string, {}>,
key: keyof I,
) {
return {
[fetch.pending.type]: (state: I) => {
state[key].loading = true;
state[key].data = state[key].data // data kepp
state[key].error = null;
},
[fetch.fulfilled.type]: (state: I, action: PayloadAction<T>) => {
state[key].loading = false;
state[key].data = action.payload;
},
[fetch.rejected.type]: (
state: I,
action: ReturnType<typeof fetch.rejected>,
) => {
state[key].loading = false;
state[key].error = action.error;
},
};
}

export function fetchData<T, I extends {[key: string]: InitialState<T>}>(
fetch: AsyncThunk<T, string, {}>,
key: keyof I,
) {
return {
[fetch.pending.type]: (state: I) => {
state[key].loading = true;
state[key].data = null // null
state[key].error = null;
},
[fetch.fulfilled.type]: (state: I, action: PayloadAction<T>) => {
state[key].loading = false;
state[key].data = action.payload;
},
[fetch.rejected.type]: (
state: I,
action: ReturnType<typeof fetch.rejected>,
) => {
state[key].loading = false;
state[key].error = action.error;
},
};
}

export function fetchDataKeepingPrevWithHandler<T, I extends {[key: string]: InitialState<T>}>(
fetch: AsyncThunk<T, string, {}>,
key: keyof I,
handler?: (state: I, action: PayloadAction<T>) => void,
) {
return {
[fetch.pending.type]: (state: I) => {
state[key].loading = true;
state[key].data = state[key].data // data kepp
state[key].error = null;
},
[fetch.fulfilled.type]: (state: I, action: PayloadAction<T>) => {
state[key].loading = false;
state[key].data = action.payload;
},
[fetch.rejected.type]: (
state: I,
action: ReturnType<typeof fetch.rejected>,
) => {
state[key].loading = false;
state[key].error = action.error;
},
};
}

export function fetchDataWithHandler<T, I extends {[key: string]: InitialState<T>}>(
fetch: AsyncThunk<T, string, {}>,
key: keyof I,
handler?: (state: I, action: PayloadAction<T>) => void,
) {
return {
[fetch.pending.type]: (state: I) => {
state[key].loading = true;
state[key].data = null // null
state[key].error = null;
},
[fetch.fulfilled.type]: (state: I, action: PayloadAction<T>) => {
state[key].loading = false;
state[key].data = action.payload;
},
[fetch.rejected.type]: (
state: I,
action: ReturnType<typeof fetch.rejected>,
) => {
state[key].loading = false;
state[key].error = action.error;
},
};
}

그런데 이렇게 작성하니 인수를 줄이기 위해서 중복된 함수가 너무 많아진 느낌이다. 함수를 작성하는 쪽에 수고로움과 사용하는 쪽에서에 편리함 중에 선택해야 하는 문제 같다.

추가적으로 인수를 더 줄이기 위해서 FetchThunkData 라는 클래스를 만들어 key, fetch, keep 정도는 생성자에서 받아올 수 있을거 같다. 이렇게 하면 인스턴스에 이름으로 데이터를 유지할지를 명시하면 사용하는쪽에서 헷갈리지도 않고 함수도 중복되게 적지 않아도 될거 같다.

개인적으로 생각한 예 : ❇️ FetchThunkData class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
type FetchThunkDataConstructor<T, S> = {
fetch: AsyncThunk<T, string, {}>;
keep: boolean;
key: string;
handler?: (state: S, action: PayloadAction<T>) => void;
};

export class FetchThunkData<T, S extends {[key: string]: InitialState<T>}> {
private fetch: AsyncThunk<T, string, {}>;
private keep: boolean;
private key: string;
private handler?: (state: S, action: PayloadAction<T>) => void;

constructor({fetch, keep, key, handler}: FetchThunkDataConstructor<T, S>) {
this.fetch = fetch;
this.keep = keep;
this.key = key;
this.handler = handler;
}

private pendingReducer = (state: S) => {
state[this.key].data = this.keep ? state[this.key].data : null;
state[this.key].loading = true;
state[this.key].error = null;
};

private fuflledReducer = (state: S, action: PayloadAction<T>) => {
state[this.key].loading = false;
state[this.key].data = action.payload;
this.handler && this.handler(state, action);
};

private rejectedReducer = (
state: S,
action: ReturnType<typeof this.fetch.rejected>,
) => {
state[this.key].loading = false;
state[this.key].error = action.error;
};

getFetchThunkReducer() {
return {
[this.fetch.pending.type]: this.pendingReducer,
[this.fetch.fulfilled.type]: this.fuflledReducer,
[this.fetch.rejected.type]: this.rejectedReducer,
};
}
}

사용하는 부분 :

1
2
3
4
5
6
7
8
9
10
11
12
const fetchThunkKeepDataNoHandler = new FetchThunkData({
fetch: fetchChallenges,
keep: true,
key: 'challenges',
});

const challengesSlice = createSlice({
name: 'challenges',
initialState,
reducers: {},
extraReducers: fetchThunkKeepDataNoHandler.getFetchThunkReducer(),
});

fetchThunkKeepDataNoHandler 라른 인스턴스에 이름이 있기 때문에 data를 유지하하고 handler 가 없는 reducer를 반환함을 알 수 있다.

또한, 생성자에서 필요한 인수를 받았기 때문에 getFetchThunkReducer 에서는 인수를 받지 않아도 된다.

이항 함수

이항 함수는 함수에 인자로 2개를 받는 함수이다. 2개의 인수가 한개의 값을 표현하기에 적합하거나, 자연적인 순서가 있는것이 아니라면 인자로 2개를 받는것은 1개를 받는것보다 이해하기 어렵다 (자연스러운 경우 ? new Point(x, y) 같은 경우, 인자 2개가 하나의 표인트를 표현하는것과 순서도 자연스럽다)

인수가 2개인 경우가 반드시 나쁜 경우는 아니지만 줄일수 있도록 해야한다. 예를들어서 writeField(outpurStream, name) 인자에 순서를 기억하기 어렵기 때문에 writeField(name) 으로 줄이면 좋다. 이럴 경우에는 OutputStream 이라는 클래스를 만들고 멤버 변수로 outputStrem 을 갖고, writeField(name) 메서드를 구현하여 outputStream.writeField(name) 같이 호출할 수 있다.

삼항 함수

인수가 3개인 함수는 인수가 2개인 함수보다 훨씬 더 이해하기 어렵다. 삼항 함수를 만들 때는 신중히 고려해야 한다.

인수 객체

인수가 2-3 개 필요하다면 일부로 독자적인 클래스 변수로 선언할 가능성을 짚어봐야 한다.

좋지 않은 예 : ❌

makeCircle(x: number, y: number, radius: number)

좋은 예 : ✅

makeCircle(center: Point, radius: number)

인수로 객체를 넘기는 것이 눈속임 이라 생각할 수 있지만, 객체에는 이름을 담기 때문에 결국엔 어떤 개념을 전달하게 된다.그것이 코드를 읽는 사람들에게 이해하는데 더 큰 도움을 준다.

자바스크립트에서 함수에 인수로 객체를 받으면 인수에 개념을 전달할수 있게됨으로 인수를 객체로 전달하도록 하는것이 좋다.

인수 목록

때로는 인수 개수가 가변적인 함수도 필요하다. 가변적인 인수를 갖는 함수는 사실상 2개의 인수를 받는 이항함수로 생각할 수 있다. function sumFunction(arg1, ...argList)

동사와 키워드

함수의 의도나 인수의 순서와 의도를 제대로 표현하려면 좋은 함수 이름이 필요하다. write(name) 함수는 곧바로 이해가 되는데 이름이 무엇이든 쓴다는 뜻으로 생각할 수 있기 때문이다. 더 나은 이름으로는 wirteField(name) 과 같이 사용할 수 있다.

또는, 함수 이름에 키워드를 추가하는 방식을 사용할 수 있다. 이렇게 하면 인수에 순서를 기억할 필요 없이 함수에 이름으로 알 수 있게 된다.

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

함수에서 부수효과가 일어나면 한가이 일을 하겠다고 한 함수가 사실은 그렇지 못한 것이 된다.

안 좋은 예시 : ❌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class UserValidator {
private cryptographer: Cryptographer;

constructor(cryptographer: Cryptographer) {
this.cryptographer = cryptographer;
}

checkPassword(userName: string, password: string): boolean {
const user: User = UserGatewayImpl.findByName(userName);
if (user !== null) {
const codePhrase = user.getPhraseEncodedByPassword();
const phrase = this.cryptographer.decrypt(codePhrase, password);
if (phrase === 'Valid Password') {
Session.initialize();
return true;
}
}
return false;
}
}

checkPassword 가 패스워드를 확인해서 boolean 값을 반환한다고 생각하지만 실제로 안에서는 세션을 초기화 하는 일까지 하고 있는 것이다. 이럴경우 이름을 checkPasswordAndSessionIntialize 라고 바꾸는 것이 좋다. 그래도 합수가 한가지 일을 하게 한다는 원칙은 어긋나지만

부수효과 피하기

사이드 이펙트를 일으키지 않기 위해서는 객체를 그대로 사용하는 것이 아니라 객체를 복사해서 새로운 객체를 만들고 변경하는 방법을 사용해야 한다. 또한 전역 변수를 변경시켜 전역 변수를 사용하는 다른 함수에 영향을 주면 안된다.

명령과 조회를 분리하라

함수는 뭔가를 수행하거 답하거나 둘 중 하나만 해야한다. 둘다 하면 혼란을 초래한다.

좋지 않은 예시 : ❌

1
2
3
4
5
6
7
8
function set(attribute: string, value: string): boolean {
//...
return true
}

if (set('name', 'koo')) {

}

작성한 함수는 객체에 속성에 지정한 value 로 설정하면 true 를 실패하면 false를 반환하는 함수이다. 그러나 if 문 안에 들어가면 해석이 다양해진다. 개발자는 동사로서 설정하라고 판단하고 함수를 작성했지만 사용하는 쪽에서는 설정되어 있다면으로 해석할 수 있다. 함수 선언부를 확인하고 나서야 객체에 설정하는 함수구나 생각하기 쉽상이다. 따라서 설정 되어 있다면을 함수로 따로 분리하는 것이 현명하다.

좋은 예시 : ✅

1
2
3
if (attrubuteExist('name')) {
setAttribute('name', 'koo')
}

값을 설정하는 함수, 반환하는 함수를 분리하면 읽는 쪽에서 코드를 더 쉽게 이해할 수 있다.