내가 생각하는 선언적인 코드 - 함수형 로직에 객체지향 한 스푼 얹기
여러분은 ‘선언적인 코드’라고 하면 어떤 코드가 떠오르시나요? ‘선언적인 코드’라는 말은 듣는 사람에 따라 머릿속에 그려지는 내용이 서로 다른 것 같습니다. 모든 코드가 그렇지만 딱 하나 ‘이런 코드가 선언적인 코드야’라는 정답은 없는 것 같습니다.

여러분은 ‘선언적인 코드’라고 하면 어떤 코드가 떠오르시나요? ‘선언적인 코드’라는 말은 듣는 사람에 따라 머릿속에 그려지는 내용이 서로 다른 것 같습니다. 모든 코드가 그렇지만 딱 하나 ‘이런 코드가 선언적인 코드야’라는 정답은 없는 것 같습니다.
이번 아티클에서는 제가 생각하는 선언적인 코드는 무엇인 지 간단한 리액트 코드 예시와 함께 이야기 해보고자 합니다.
함수형 프로그래밍
‘선언적인 코드’ 하면 함수형 프로그래밍이 가장 먼저 떠오릅니다. 왜일까요? 여러 이유가 있겠지만 제가 생각하는 이유는, 함수형 프로그래밍 패러다임으로 코드를 작성하면 ‘선언적인 코드’가 필요로 하는 여러 조건을 자연스럽게 충족할 수 있기 때문입니다.
선언적인 코드의 조건
선언적인 코드가 필요로 하는 조건에는 어떤 것들이 있을까요?
선언적인 코드를 작성한다는 것은 더 적은 코드로 더 많은 의미를 나타내는 것이라고 생각합니다. 예를 들어 객체에서 특정 요소를 필터링 하는 로직은 아래와 같이 작성할 수 있습니다.
const filteredObject = {};
const keys = Object.keys(rawObject);
for(const key of keys) {
const value = rawObject[key];
if(/* 만약 제외할 요소라면 */) continue;
filteredObject[key] = value;
}const filteredObject = {};
const keys = Object.keys(rawObject);
for(const key of keys) {
const value = rawObject[key];
if(/* 만약 제외할 요소라면 */) continue;
filteredObject[key] = value;
}위 코드는 직접적으로 ‘객체를 필터링 한다’라는 것을 의미적으로 드러내지는 않습니다. ‘객체의 키 목록을 추출해서 각 키를 순회하며 요소 값을 구하고 …. ‘ 하다보니 객체가 필터링 되었을 뿐입니다.
위 로직을 함수로 추상화 한다면 코드 자체에 의미를 좀 더 많이 담을 수 있지 않을까요?
// omit은 '생략하다'라는 의미를 가지는 단어입니다.
const filteredObject = omitBy(rawObject, (value, key) => {
// 만약 제외할 요소라면 true를, 그렇지 않다면 false를 반환한다
});
// 혹은 더 간단하게 아래와 같이 작성할수도 있습니다.
const filteredObject = omitBy(rawObject, isHasOddValue);// omit은 '생략하다'라는 의미를 가지는 단어입니다.
const filteredObject = omitBy(rawObject, (value, key) => {
// 만약 제외할 요소라면 true를, 그렇지 않다면 false를 반환한다
});
// 혹은 더 간단하게 아래와 같이 작성할수도 있습니다.
const filteredObject = omitBy(rawObject, isHasOddValue);위 예시에서 omitBy라는 함수는 더 적은 코드로 기존 코드와 동일한 일을 할 수 있게 했을 뿐만 아니라, 더 많은 의미를 담고 있는 코드라고 할 수 있습니다. 그리고 이런 코드를 선언적인 코드라고 부를 수 있다고 생각합니다.
순수 함수
함수형 프로그래밍에는 ‘순수 함수’라는 중요한 개념이 있습니다. 순수 함수는 외부의 상태를 변경하지 않고, 같은 input에 대해 항상 같은 output을 반환하는 함수를 의미하는 개념입니다.
아래 예시에서, pure 함수는 순수 함수라고 볼 수 있고, notPureA, notPureB는 순수 함수가 아니라고 볼 수 있습니다.
// pure(1, 2)는 항상 3이라는 값을 반환합니다.
function pure(a, b) {
return a + b;
}
let c = 0;
// c 값에 따라 notPureA(1, 2)는 호출할 때마다 서로 다른 값을 반환합니다.
function notPureA(a, b) {
return a + b + c;
}
// notPureB(1, 2)는 항상 같은 값을 반환하지만, 함수를 호출할 때마다 c의 값이 변경됩니다.
function notPureB(a, b) {
c++;
return a + b;
}// pure(1, 2)는 항상 3이라는 값을 반환합니다.
function pure(a, b) {
return a + b;
}
let c = 0;
// c 값에 따라 notPureA(1, 2)는 호출할 때마다 서로 다른 값을 반환합니다.
function notPureA(a, b) {
return a + b + c;
}
// notPureB(1, 2)는 항상 같은 값을 반환하지만, 함수를 호출할 때마다 c의 값이 변경됩니다.
function notPureB(a, b) {
c++;
return a + b;
}순수 함수는 달리 말하면, ‘사이드 이펙트가 없는 함수’라고 볼 수 있습니다. 그리고 사이드 이펙트가 없는 함수는 더 여러 곳에서 사용될 수 있으며, 코드의 해석이 쉬워집니다. 예를 들어, 코드 내에 비순수 함수가 많이 사용되었다면 우리는 코드를 이리저리 추적해야 합니다. 그러나 함수의 이름이 역할에 맞게 잘 작성되어 있고, 함수가 순수하다는 보장이 있다면, 우리는 모든 함수를 다 까볼 필요가 없습니다. 마치 글을 읽듯 코드를 읽을 수 있게 됩니다.
순수 함수를 많이 작성하면 우리는 더 적은 코드로 더 많은 의미를 담을 수 있습니다. 그런데 이 말이 진실이라고 해서 ‘더 적은 코드로 더 많은 의미를 담을 수 있게 하기 위해서는 순수 함수를 많이 작성해야 한다’라는 말도 진실이라고 볼 수 있을까요?
객체지향 프로그래밍
객체지향 프로그래밍 패러다임에서는 객체를 중심으로 코드를 작성합니다. 가령, 아래와 같은 형태입니다.
cart.addItem(cartItem);
cartItem.updateQuantity(prev => prev + 1);
cart.getItems();cart.addItem(cartItem);
cartItem.updateQuantity(prev => prev + 1);
cart.getItems();위 코드에서 addItem, updateQuantity, getItems는 순수함수는 아닙니다. 함수 외부의 값(cart, cartItem)을 변경시킬 수 있고, 호출될 때마다 서로 다른 값을 반환할 수 있기 때문입니다.
그러나 위 코드는 선언적이라고 할 수 있습니다. ‘카트에 아이템을 추가한다’, ‘특정 카트 아이템의 수량을 증가시킨다’, ‘카트 아이템 목록을 불러온다’라는 의미가 명확하게 드러나고, (코드가 잘 작성 되어 있다면) 함수들을 호출했을 때 어떤 일이 벌어질 지 예측할 수 있기 때문입니다. 또한, addItem, updateQuantity, getItems라는 함수들이 ‘cart’, 혹은 ‘cartItem’이라는 특정 ‘상태’에 종속되어 있다는 것도 명확합니다. 오히려 이런 상태 - 메소드 간의 연결 관계를 나타내기 위해서는 순수 함수를 작성하는 편보다는 상태에 대한 메소드를 작성하는 편이 더 유리할 수 있습니다.
함수형과 객체지향, 선언적 사고의 균형
함수형 프로그래밍은 데이터의 흐름과 변환을 중심으로 사고하게 만들고, 객체지향 프로그래밍은 상태와 행동의 관계를 중심으로 사고하게 만듭니다. 이 두 패러다임은 서로 다른 철학을 가지고 있지만, “선언적으로 코드를 작성한다”는 관점에서는 놀라울 정도로 잘 어우러질 수 있습니다.
현대 프론트엔드 개발 환경에서는 대부분 컴포넌트 기반의 개발 방식을 채택하고 있습니다. React, Vue, Svelte 등 어떤 프레임워크를 사용하든, 우리는 “상태를 어떤 방식으로 선언적으로 다룰 것인가?”라는 문제와 마주하게 됩니다. 이때 함수형 사고만으로는 부족할 때가 많습니다.
예를 들어, React 컴포넌트 내부에서 상태와 그 상태를 다루는 로직은 하나의 작은 객체로 볼 수 있습니다. 이 객체가 스스로의 상태를 관리하고, 그 상태에 맞는 동작을 수행하도록 만드는 것은 객체지향의 철학과 맞닿아 있습니다.
function CartList() {
const cart = useCart();
return (
<>
<ul>
{cart.items.map(cartItem => (
<li key={cartItem.id}>
<CartItem
value={cartItem}
onDecrease={() => cartItem.updateQuantity(prev => prev - 1)}
onIncrease={() => cartItem.updateQuantity(prev => prev + 1)}
/>
</li>
)}
</ul>
<Button onClick={() => cart.addItem(initialCartItem)}>추가</Button>
</>
);
}function CartList() {
const cart = useCart();
return (
<>
<ul>
{cart.items.map(cartItem => (
<li key={cartItem.id}>
<CartItem
value={cartItem}
onDecrease={() => cartItem.updateQuantity(prev => prev - 1)}
onIncrease={() => cartItem.updateQuantity(prev => prev + 1)}
/>
</li>
)}
</ul>
<Button onClick={() => cart.addItem(initialCartItem)}>추가</Button>
</>
);
}따라서 선언적인 코드를 작성하기 위해서는 함수형 패러다임과 객체지향 패러다임의 장점을 적절히 섞는 것이 더 유리할 수도 있습니다.
함수형 패러다임이 코드를 의미적으로 간결하게 만들어 준다면, 객체지향 패러다임은 그 의미를 맥락 안에서 지속적으로 유지할 수 있게 만들어 줍니다.
결국 중요한 것은 패러다임이 아니라 의도를 드러내는 코드입니다.
함수형 로직에 객체지향의 한 스푼을 얹는 것, 그 안에서 우리는 더욱 자연스럽게 선언적인 코드를 완성해 나갈 수 있습니다.