값에 대해

함수로 협업하는 가장 좋은 방법은 인자와 리턴값으로만 소통하는 것이다. 순수하게 인자와 리턴값에만 의존하면 누가 만든 함수든 어떤 라이브러리로 만든 함수든 조립할 수 있게 된다. 이때 함수에서 다루는 값으로 자바스크립트의 기본 객체를 사용하거나 아주 보편적으로 약속된 객체만 사용하면 함수들 간의 조합성이 더욱 좋아진다.

여기서 말하는 기본 객체란 Array, Object, String, Number, Boolean, Null, Undefined 등의 자바스크립트 기본 객체를 말하며, 브라우저의 DOM 같은 것들도 범주 안에 들어올 수 있다. 어떤 프로퍼티와 어떤 메서드를 가지고 있는지 충분히 약속되고 보장된 값들을 말한다. 약속된 스펙을 가진 값들만 사용하는 함수들은 언제나 어떤 환경에서나 사용하기 편하다.

순수 함수

순수 함수와 부수 효과

순수 함수는 동일한 인자가 들어오면 항상 동일한 값을 리턴하는 함수다. 메서드가 자신이 가진 내부의 상태에 따라 다른 결과를 만든다면, 순수 함수는 들어온 인자와 상수만 사용하여 항상 동일한 결과를 리턴한다.
또 하나 중요한 특징이 있는데, 바로 외부의 상태를 변경하지 않는다는 점이다. 함수에게 들어온 인자를 포함하여, 외부와 공유되고 있는 값 중 함수가 참조할 수 있는 어떤 값도 변경하지 않는 것을 말한다. 함수가 외부 상태를 변경하면, 외부 상태와 연관이 있는 다른 부분에도 영향이 있고 이것을 부수 효과(Side effect)라고 한다.

부수 효과 문제는 특히 동시성이 생길 때 더욱 취약하다. 브라우저나 Node.js는 다양한 작업을 동시에 처리한다. 이렇게 동시성이 생기는 상황에서는 여러 곳에서 공유되도 있는 값이 변경되는 것은 위험하다. 부수 효과는 단지 동시성에서만의 이슈가 아니다. 예를 들면 사용자가 오랫동안 인터랙션을 해서 상태를 지속적으로 관리해야 하는 웹 페이지나 앱의 코드들에서 부수 효과 문제가 생기는 경우가 많다.

순수 함수의 정의를 아는 것보다 중요한 점은 여기에 담긴 목적과 전략이다. 순수 함수에 담긴 전략은 그 이름처럼 간단 명료하다. 상태 변화를 최소화하고, 다음 단계로 넘어갈 때마다 새로운 값으로 출발하는 식으로 코딩하는 것이다. 이렇게 하면 문제가 쉬워진다. 문제가 단순해지면 해결책 역시 쉬워지고 오류를 만들 가능성도 줄어든다. 작은 순수 함수들을 모아 만든 소프트웨어는 유지 보수와 기획 변경에 유연하게 대응한다.

순수 함수와 순수 함수가 아닌 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 순수 함수
function add(a, b) {
return a + b;
}

// 순수 함수가 아닌 함수
function add2(obj, value) {
obj.value = obj.value + value;
return obj.value;
}

// 작은 차이지만 순수 함수
function add3(obj, value) {
return obj.value + value;
}

// 작은 차이지만 순수 함수 2
function add4(obj, value) {
return { value: obj.value + value };
}

add는 인자를 받아 새로운 값을 리턴했고 add2는 obj의 상태를 변경한다. add는 인자가 같으면 항상 결과가 같고 부수 효과가 없다. add2는 obj.value의 상태에 따라 결과가 달라진다. 이런 점 자체가 문제를 만들지는 않지만 만일 obj.value를 사용하는 코드가 add2 외에 다른 곳에도 있다면 반드시 obj.value가 변경될 수 있다는 점과 변경될 시점 등을 정확히 인지하고 제어해야 할 것이다.

add3과 add4는 작은 차이가 있지만 순수 함수다. obj.value를 참조만 하고 있기 때문이다. 순수 함수를 만들기 위해 항상 모든 값을 새로 만들어야 하는 것은 아니다. 조회 자체는 부수 효과를 일으키지 않는다.

순수 함수로 프로그래밍을 한다면 add 같은 작은 기능의 함수만 만들어지는 게 아닌가 하는 생각이 들 수 있다. 클래스나 객체처럼 풍부한 기능을 가진 모듈을 만들 수 없을 것만 같을 수 있다. 하지만 인자로 함수를 사용하거나 고차 함수를 이용한 함수 조합을 통해 순수 함수의 조건을 따르면서 보다 풍부한 변화를 불러오는 함수들을 만들 수 있다.

변경 최소화와 불변 객체

직접 변경하는 대신, 변경된 새로운 값을 만드는 전략

대부분 이미 확인한 함수들이지만 이번에는 값과 값을 변경해 나가는 것에 초점을 두고 설명하고자 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var users1 = [
{ name: "ID", age: 32 },
{ name: "HA", age: 25 },
{ name: "BJ", age: 32 },
{ name: "PJ", age: 28 },
{ name: "JE", age: 27 },
];

var comparator = function (a, b) {
if (a.age < b.age) return -1;
if (a.age > b.age) return 1;
return 0;
};

var sortedUsers1 = users1.sort(comparator) // 1

console.log(users1 === sortedUsers1) // 2 true

console.log(_.pluck(sortedUsers1, 'age')); // 3 [25, 27, 28, 32, 32]

console.log(_.pluck(users1, 'age')); // 4 [25, 27, 28, 32, 32]

users1을 나이순으로 정렬하는 예제다. Array.prototype.sort는 자기 자신을 정렬하는 함수다. 2의 결과가 true라는 것은 둘이 완전히 같은 객체라는 뜻이다. 동일한 값을 가진 객체가 아닌 완전히 같은 객체라는 것이다. 1의 .sort() 메서드는 자기 자신을 바꾸고 자기 자신을 리턴한다. 3 _.pluck 를 통해 age 값만 꺼내보면 둘다 동일하게 정렬이 된 것을 확인할 수 있는데, users1과 sortedUsers1이 완전히 같은 값이어서 동일한 결과가 출력된 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var users2 = [
{ name: "ID", age: 32 },
{ name: "HA", age: 25 },
{ name: "BJ", age: 32 },
{ name: "PJ", age: 28 },
{ name: "JE", age: 27 },
];

var sortedUsers2 = _.sortBy(users2, 'age') // 1

console.log(users2 === sortedUsers2) // 2 false

console.log(_.pluck(sortedUsers2, 'age')) // 3 [25, 27, 28, 32, 32]

console.log(_.pluck(users2, 'age')) // 4 [32, 25, 32, 28, 27]

console.log(users2[1] === sortedUsers2[0]); // 5 true

이번에는 _.sortBy 함수를 이용하여 정렬을 해 보았다. 2를 확인해 보면 false가 나온다. 1에서 리턴된 sortedUser2는 새로운 객체다. 3,4 를 확인해 보면 sortedUsers2는 나이순으로 정렬이 되었는데, users2 는 원본 그대로임을 알 수 있다. 다른 곳에 users2의 순서에 의존하여 동작하고 있는 코드가 있다면, sortedUsers2 처럼 새로운 객체를 만들어 정렬을 하는 방식이 부수 효과가 없고 유리하다.

sortedUser2는 새로운 값이다. 그렇다면 배열 안의 값들도 새로운 값일까? 서로 다르게 정렬이 되어 있지만, 두 배열 안에 있는 모든 값은 새로운 값이 아닌 기존의 값이다. 항상 배열 내의 모든 값을 새롭게 만든다면 메모리 사용량이 매우 높아질 것이다. _.sortBy는 내부의 값은 기존의 값을 그대로 활용하면서 배열만 새로 만들어 정렬한다.

Undescore.js의 콘셉트 중에는 이러한 중요한 전략이 있다. 이 전략을 잘 따르면 부수 효과를 줄이면서도 메모리 사용량 증가는 최소화하는 좋은 함수들을 만들 수 있다. 그것은 바로 그함수가 변경할 영역에 대해서만 새 값을 만드는 전략이다. 예를 들어 자신의 역할이 정렬이라면 정렬과 연관 있는 부분만 새 값으로 만들고 나머지 값들은 재활용하는 식이다. 이 전략을 대부분의 함수적 함수에 적용된다.

1
2
3
4
5
6
7
var rejectedUsers2 = _.reject(users2, function (user) { return user.age < 30; });
console.log(rejectedUsers2);
// [{name: "ID", age: 32}, {name: "BJ", age: 32}]

console.log(rejectedUsers2 === users2) // false
console.log(rejectedUsers2.length, users2.length); // 2 5
console.log(rejectedUsers2[0] === users2[0]) // true

배열 내부의 값 중 특정 조건의 값들을 제외하는 _.reject 같은 함수도 배열 내부의 값들을 지우는 것이 아니라 값들이 지워진 새로운 배열을 만드는 것이다. _.reject도 결국 같은 전략을 따른 것이다. _.reject 함수의 역할은 값을 제외하는 것이고 달라지는 영역은 배열이기에 배열을 새로 만드는 것이다.

users2에서 30세 미만인 사람들을 제외했다. 더 정확히 말하면 30세 미만인 사람들이 제외된 새로운 배열을 만들어 리턴했다. rejectedUsers2는 users2가 아니며, users2.length도 그대로이고, 배열 내부의 값들도 기존의 값 그대로다. users2를 다루면서 새로 정렬하고 배열 내부의 값도 지웠지만, users2는 원래 상태 그대로이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//  1
console.log(
_.pluck(_.reject(users2, function (user) { return user.age >= 30; }), 'name')
)
// ["HA", "PJ", "JE"]

// 2
console.log(
_.pluck(users2, 'name')
)
// ["ID", "HA", "BJ", "PJ", "JE"]

// 3
console.log(users2)
// 원본 그대로

1 users에서는 _.reject로 30세 이상의 user를 제외한 새 배열을 만든 후, _.pluck을 통해 이름만 추출한 새 배열을 만들어 로그를 남겼다. _.reject를 이용해 users2의 상태를 변경하지 않으면서 필터링을 했고, _.pluck 를 통해 역시 원본 소스들을 건들지 않고 name이 추출된 새로운 배열을 만들었다. 따라서 원본 데이터에는 아무런 영향을 끼치지 않았다. 그 덕분에 2에서는 어려움 없이 원본에 있는 모든 이름을 출력할 수 있다. 2가 실행된 후에도 3 users는 역시 변경되지 않는다.

1
2
3
4
5
6
7
8
9
var b1 = [1, 2, 3, 4, 5];
var b2 = _.initial(b1, 2); // 뒤에서 2개 제거한 새로운 배열 리턴
console.log(b1 === b2, b1, b2);
// false (5) [1, 2, 3, 4, 5] (3) [1, 2, 3]

var b3 = _.without(b1, 1, 5); // 1과 5를 제거한 새로운 배열 리턴
var b4 = _.without(b3, 2); // 2를 제거한 새로운 배열 리턴
console.log(b1 === b3, b3 === b4, b3, b4);
// false false (3) [2, 3, 4] (2) [3, 4]

맨 마지막에 b4를 만들 때, b3 에서 2를 제거했지만 b3에는 여전히 2가 남아있다.

_.clone 으로 복사하기

_.clone은 배열이나 객체를 받아 복사하는 함수다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var product1 = {
name: "AB 반팔티",
price: 10000,
sizes: ["M", "L", "XL"],
colors: ["Black", "White", "Blue"]
};

var product2 = _.clone(product1);
console.log(product2);
// {
// name: "AB 반팔티",
// price: 10000,
// sizes: ["M", "L", "XL"],
// colors: ["Black", "White", "Blue"]
// }

console.log(product1 === product2); // false

product2.name = "ABCD 반팔티";
console.log(product1.name, product2.name);
// AB 반팔티 ABCD 반팔티

product1을 _.clone 함수를 통해 복사했다. 동일한 내용이 들어 있는 새로운 객체가 리턴되어, 출력해 보면 내용은 같지만 비교하면 false가 나온다. product2.name 을 변경해도 product1에는 영향을 끼치지 않는다. product2를 마음 편히 다룰 수 있다.
그런데 _.clone을 정확히 다루려면 _.clone이 객체를 복하는 범위에 대해 제대로 알아야 한다. _.clone이 객체를 복사하는 것은 맞지만 객체 내부의 모든 값들까지 복사하는 것은 아니다.

1
2
3
4
5
6
product2.sizes.push("2XL");
console.log(product2.sizes);
// ["M", "L", "XL", "2XL"]
console.log(product1.sizes);
// ["M", "L", "XL", "2XL"]
console.log(product1.sizes === product2.sizes); // true

_.clone은 동일한 key들을 가진 새로운 객체를 만들면서 각 key에 기존의 key에 해당하는 value를 할당한다. 때문에 내부의 값이 객체라면 같은 객체를 바라보게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_.clone = function (obj) {
var cloned = _.isArray(obj) ? [] : {};
var keys = _.keys(obj);
_.each(keys, function (key) {
cloned[key] = obj[key]; // Array일때는 key가 숫자
});
return cloned;
}

var obj1 = { a: 1, b: 2, c: { d: 3 } };
var obj2 = _.clone(obj1);
obj2.b = 22;

console.log(obj2);
// { a: 1, b: 22, c: { d: 3 } };
console.log(obj1);
// { a: 1, b: 2, c: { d: 3 } };

console.log(obj1 === obj2); // false
console.log(obj1.c === obj2.c); // true

obj2.c.d = 33;
console.log(obj1.c.d) // 33 obj1도 같이 변경

그렇다면 객체 안의 객체를 변경하고 싶은 경우에는 어떻게 해야 원본에 영향을 주지 않으면서 값을 변경할 수 있을까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var product1 = {
name: "AB 반팔티",
price: 10000,
sizes: ["M", "L", "XL"],
colors: ["Black", "White", "Blue"]
};

var product2 = _.clone(product1);
product2.sizes = _.clone(product2.sizes); // 내부도 clone 후 push를 하거나
product2.sizes.push("2XL")
console.log(product2.sizes);
// ["M", "L", "XL", "2XL"]
console.log(product1.sizes);
// ["M", "L", "XL"]
console.log(product1.sizes === product2.sizes); // false

product2.colors = product2.colors.concat("Yellow") // 아니면 concat으로 한번에
console.log(product2.colors);
// ["Black", "White", "Blue", "Yellow"]
console.log(product1.colors);
// ["Black", "White", "Blue"]
console.log(product1.colors === product2.colors); // false

위와 같이 하면 된다. 어차피 내부의 값도 복사하는 식으로 값을 다뤄야 한다면 왜 굳이 객체의 첫 번째 깊이만 복사하느냐고 생각할 수 있다. 값 복사 후 항상 내부의 모든 값을 변경할 것이 아니기에, 객체 내부의 객체는 공유하는 것이 메모리 사용에 유리하고, 복사 수행 처리 시간이라는 측면에서도 이점이 많다.

_.extend, _.defaults로 복사하기

_.extend나 _.defaults를 이용하면 값 복사와 변경을 좀 더 간결하게 할 수 있다.

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
var product1 = {
name: "AB 반팔티",
price: 10000,
sizes: ["M", "L", "XL"],
colors: ["Black", "White", "Blue"]
};

// 1
var product2 = _.extend({}, product1, {
name: "AB 긴팔티",
price: 15000
});

// 2
var product3 = _.defaults({
name: "AB 후드티",
price: 15000
}, product1);

console.log(product2);
// {
// name: "AB 긴팔티",
// price: 15000,
// sizes: ["M", "L", "XL"],
// colors: ["Black", "White", "Blue"]
// };
console.log(product3);
// {
// name: "AB 후드티",
// price: 15000,
// sizes: ["M", "L", "XL"],
// colors: ["Black", "White", "Blue"]
// };

// 3
var product4 = _.extend({}, product3, {
colors: product3.colors.concat("Purple")
});
var product5 = _.defaults({
colors: product4.colors.concat("Red")
}, product4);

console.log(product3.colors);
// ["Black", "White", "Blue"]
console.log(product4.colors);
// ["Black", "White", "Blue", "Purple"]
console.log(product5.colors);
// ["Black", "White", "Blue", "Purple", "Red"]
  1. product2는 값 복사를 위해 새로운 객체인 {} 를 _.extend의 첫 번째 인자로 넣었다.
  2. 어차피 {name: “AB 후드티”, price: 12000}도 새 객체이므로 product3처럼 _.defaults를 이용하는 것이 객체를 적게 생성해서 더 효율적이다. _.extend의 경우, 왼쪽 객체에 없는 key/value는 확장하고, 왼쪽 객체에 있던 key/value는 덮어 쓴다. _.defaults는 왼쪽에 없는 key/value만 확장한다.
  3. 1, 2를 보면 _.clone 없이 복사와 변경을 동시에 하여 간결해졌지만, colors 처럼 깊은 값을 변경해야 할 경우에는 직접 다뤄줘야 한다. _.extend와 _.defaults 역시 변경이 필요 없는 값은 이전의 값을 공유한다.

참조: 함수형 자바스크립트 프로그래밍

댓글

You need to set client_id and slot_id to show this AD unit. Please set it in _config.yml.