콘텐츠로 이동

일급 함수, 고차 함수, Copy-on-Write

함수형 프로그래밍에서 함수는 단순한 코드 블록이 아니라 으로 다뤄집니다. 이 글에서는 일급 함수, 고차 함수의 개념과 Copy-on-Write 패턴을 통한 실전적인 불변성 유지 방법을 알아봅니다.

일급 함수란 함수를 다른 값처럼 다룰 수 있다는 의미입니다. 변수에 저장하거나, 배열에 넣거나, 다른 함수의 인자로 전달할 수 있습니다.

// 함수를 배열에 저장
const funcs = [() => 1, () => 2];
console.log(funcs[0]()); // 1
// 함수를 인자로 전달
function mapFunc(arr, callback) {
const newArr = [];
for (const elem of arr) {
newArr.push(callback(elem));
}
return newArr;
}
const result = mapFunc([1, 2, 3], (num) => num + 1);
// [2, 3, 4]

고차 함수는 함수를 인자로 전달받거나, 함수를 리턴하는 함수입니다.

function doTwice(func) {
func();
func();
}
doTwice(() => console.log("hi"));
// "hi"
// "hi"

React에서 고차 컴포넌트(Higher-Order Component)는 고차 함수의 대표적인 활용 사례입니다.

export default function withAuthentication(WrappedComponent) {
const AuthenticatedComponent = (props) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
(async function () {
try {
await fetch("/login");
setIsAuthenticated(true);
} catch {
setError(true);
}
})();
}, []);
return (
<>
{isAuthenticated ? (
<WrappedComponent {...props} />
) : (
<div>Loading...</div>
)}
</>
);
};
return AuthenticatedComponent;
}

컴포넌트 위에 로그인을 확인하는 컴포넌트를 레이어처럼 한 겹 더 올리는 느낌입니다. 여러 컴포넌트에서 로그인 상태를 조회하는 로직이 필요할 때 유용하지만, 요즘은 Custom Hook으로 대체하는 추세입니다.

Arity란 함수가 취하는 인수의 수를 의미합니다. 함수 합성에서 인수가 다른 함수들을 조합할 때 문제가 됩니다.

const user = getUser(users, "henry");
const user1 = updateScore(cloneObj(user), 30);
const user2 = updateTries(cloneObj(user1));
const newArr = storeUser(users, user2);
// 각 함수마다 인수가 다르므로 pipe 함수를 바로 사용할 수 없음
const updateUser = pipe(/* ??? */);

bind()apply(), call()과 달리 함수를 바로 실행하지 않고 새로운 함수를 생성합니다.

const partGetUser = getUser.bind(null, users);
const user = partGetUser("henry");
const partUpdateScore30 = updateScore.bind(null, 30);
const updateUser = pipe(
partGetUser,
cloneObj,
partUpdateScore30,
updateTries
)("henry");

**부분 적용(Partial Application)**이란 함수의 일부 인자만 적용되고, 아직 모든 인자가 적용되지 않은 상태의 함수입니다. 일부 인자가 함수의 클로저 스코프에 고정된 상태로 유지됩니다.

onClick={handler.bind(null, e.target)}으로 사용할 경우 e.target은 이벤트 발생 시점이 아니라 컴포넌트 렌더링 시점에 평가됩니다. 이벤트 핸들러에서는 onClick={(e) => handler(e)}를 사용해야 합니다.

함수형 프로그래밍에서 가장 중요한 실천은 액션에서 계산을 분리하는 것입니다.

  • 액션, 계산, 데이터로 나눠서 고민
  • 액션에서 최대한 계산을 분리할 것
  • 계산에서는 데이터를 분리
  • 계산은 더 작은 계산과 데이터로 나누고 연결할 수 있다
// Before - 전역 상태를 직접 변경하는 함수들
let currentUser = 0;
const users = [
{ name: "james", score: 30, tries: 1 },
{ name: "mary", score: 110, tries: 4 },
{ name: "henry", score: 80, tries: 3 },
];
const updateScore = (newAmt) => (users[currentUser].score += newAmt);
const updateTries = () => users[currentUser].tries++;
// After - 순수 함수로 분리
const getScore = (arr, name) => {
const targetObj = arr.find(
(user) => user.name.toLowerCase() === name.toLowerCase()
);
return [name, targetObj.score];
};
const updateScore = (arr, amt) => {
const newAmt = arr[1] + amt;
return [arr[0], newAmt];
};
const updateTries = (arr) => {
const newTries = arr[1] + 1;
return [arr[0], newTries];
};
// 부수효과가 있는 함수는 별도로 분리
const recordData = (arr, prop) => {
users.forEach((val, index, array) => {
if (val.name.toLowerCase() === arr[0].toLowerCase()) {
array[index][prop] = arr[1];
}
});
};

핵심 배움:

  • 기본 동작을 하는 함수부터 만든다
  • 해당 함수의 반환값을 이용해 점점 고차 함수로 기능을 확장한다
  • 부수효과를 다루는 함수를 따로 분리해서 점점 더 큰 함수로 확장한다

Copy-on-Write는 데이터를 변경할 때 복사본을 만들어 변경하고 반환하는 패턴입니다.

// Before - 원본 배열을 직접 변경
let mailing_list = [];
function add_contact(email) {
mailing_list.push(email);
}
// After - Copy-on-Write 적용
const add_elem = (list, email) => {
const listCopy = list.slice();
listCopy.push(email);
return listCopy;
};
const submit_form_handler = (event) => {
const form = event.target;
const email = form.elements["email"].value;
mailing_list = add_elem(mailing_list, email);
};

쓰기를 하면서 읽기도 하는 동작은 분리합니다:

// 읽기
function first_element(array) {
return array[0];
}
// 쓰기 - Copy-on-Write 적용
function drop_first(array) {
const array_copy = array.slice();
array_copy.shift();
return array_copy;
}
// 두 값을 모두 반환
function shift(array) {
return {
first: first_element(array),
array: drop_first(array),
};
}
function last_element(array) {
return array[array.length - 1];
}
function drop_last(array) {
const newArr = array.slice();
newArr.pop();
return newArr;
}
function pop_fake(array) {
return {
last: last_element(array),
array: drop_last(array),
};
}
function push(array, elem) {
return [...array, elem];
}
function add_contact(mailing_list, email) {
return push(mailing_list, email);
}

Object.assign({}, obj) 또는 스프레드 연산자를 사용합니다.

function objectSet(object, key, value) {
const newObject = Object.assign({}, object);
newObject[key] = value;
return newObject;
}
function setPrice(item, new_price) {
return objectSet(item, "price", new_price);
}
function setQuantity(item, new_quantity) {
return objectSet(item, "quantity", new_quantity);
}
function objectDelete(object, key) {
const newObj = Object.assign({}, object);
delete newObj[key];
return newObj;
}
// Before - 원본을 직접 변경
function setQuantityByName(cart, name, quantity) {
for (var i = 0; i < cart.length; i++) {
if (cart[i].name === name) {
cart[i].quantity = quantity;
}
}
}
// After - Copy-on-Write 적용
function setQuantityByName(cart, name, quantity) {
const cart_copy = [...cart];
for (let i = 0; i < cart_copy.length; i++) {
if (cart_copy[i].name === name) {
cart_copy[i] = { ...cart_copy[i], quantity };
break;
}
}
return cart_copy;
}

배열뿐만 아니라 내부 객체까지 새로운 객체로 만들어야 합니다.

  • 일급 함수는 값처럼 다룰 수 있는 함수로, 함수형 프로그래밍의 기반입니다
  • 고차 함수는 함수를 인자로 받거나 반환하여 재사용성을 높입니다
  • Copy-on-Write는 데이터를 변경할 때 항상 복사본을 만들어 불변성을 보장합니다
  • 액션에서 계산을 분리하고, 부수효과가 있는 코드를 명확히 격리하는 것이 핵심입니다

다음 글에서는 Currying과 함수 합성을 통해 함수들을 조합하는 방법을 알아보겠습니다.