서론: 왜 지금 함수형 프로그래밍인가?
소프트웨어 개발의 세계는 끊임없이 진화합니다. 멀티코어 프로세서는 이제 표준이 되었고, 분산 시스템과 마이크로서비스 아키텍처는 거대한 데이터를 처리하는 핵심 전략으로 자리 잡았습니다. 이러한 복잡성의 증가는 우리가 코드를 작성하고 사고하는 방식에 근본적인 변화를 요구합니다. 수십, 수백 개의 스레드가 동시에 공유된 데이터에 접근하려 할 때, 혹은 예측 불가능한 네트워크 지연 속에서 데이터의 일관성을 유지해야 할 때, 전통적인 명령형 프로그래밍 방식은 한계에 부딪히기 시작합니다. 바로 이 지점에서 함수형 프로그래밍(Functional Programming, FP)이 강력한 대안으로 떠오릅니다.
많은 개발자들이 함수형 프로그래밍을 Haskell이나 Lisp과 같은 특정 언어에 국한된 학문적 개념으로 오해하곤 합니다. 하지만 함수형 프로그래밍은 특정 언어의 전유물이 아니라, 소프트웨어의 복잡성을 제어하기 위한 사고의 패러다임입니다. 그 핵심 철학은 '어떻게' 할 것인지를 나열하는 것이 아니라 '무엇'을 할 것인지를 선언하는 데 있으며, 이를 위해 계산(computation)을 수학적 함수의 평가로 취급합니다. 이 패러다임의 중심에는 '부수 효과(Side Effect)'를 최소화하려는 노력이 있으며, 이를 달성하기 위한 두 가지 핵심 기둥이 바로 순수 함수(Pure Functions)와 불변성(Immutability)입니다.
이 글에서는 함수형 프로그래밍의 깊은 세계로 들어가, 순수 함수와 불변성이 어떻게 코드의 예측 가능성을 높이고, 테스트를 용이하게 하며, 동시성 문제를 우아하게 해결하는지 구체적인 예시와 함께 탐험할 것입니다. 이는 단순히 새로운 코딩 스타일을 배우는 것을 넘어, 버그가 적고 유지보수가 용이하며 변화에 유연하게 대처할 수 있는 견고한 소프트웨어를 구축하는 근본적인 지혜를 얻는 과정이 될 것입니다.
모든 문제의 근원: 상태 변경과 부수 효과
함수형 프로그래밍의 가치를 제대로 이해하려면, 먼저 우리가 일상적으로 작성하는 코드에 숨어있는 문제의 근원을 파악해야 합니다. 그 주범은 바로 '변경 가능한 상태(Mutable State)'와 그로 인해 발생하는 '부수 효과(Side Effect)'입니다.
변경 가능한 상태가 초래하는 혼돈
객체 지향 프로그래밍(OOP)을 포함한 많은 명령형 프로그래밍 패러다임에서, 데이터는 객체의 속성(property)으로 캡슐화되며, 이 객체의 상태는 메서드 호출을 통해 수시로 변경됩니다. 이는 직관적으로 보일 수 있지만, 시스템의 규모가 커질수록 심각한 문제를 야기합니다.
다음의 간단한 JavaScript 예시를 보겠습니다.
// 쇼핑 카트를 나타내는 객체
let cart = {
items: ['사과', '바나나'],
totalPrice: 1500,
};
// 카트에 아이템을 추가하는 함수. 직접 cart 객체를 수정한다.
function addItemToCart(item, price) {
cart.items.push(item);
cart.totalPrice += price;
console.log(`${item}이(가) 카트에 추가되었습니다.`);
// ... 어딘가에 이 변경사항을 저장하는 API 호출이 있을 수도 있다.
// saveCartToServer(cart);
}
// 할인을 적용하는 함수. 역시 직접 cart 객체를 수정한다.
function applyDiscount(discountRate) {
cart.totalPrice *= (1 - discountRate);
}
addItemToCart('오렌지', 1000);
applyDiscount(0.1);
console.log(cart);
// 예상 결과와 실제 결과가 다를 수 있는 지점들이 존재한다.
이 코드는 간단해 보이지만 여러 잠재적 위험을 내포하고 있습니다. cart라는 전역적인 상태가 존재하며, addItemToCart와 applyDiscount 함수는 이 공유된 상태를 직접 수정합니다. 만약 여러 다른 모듈이나 비동기 작업이 동시에 이 cart 객체에 접근하고 수정하려 한다면 어떻게 될까요? 예를 들어, 사용자가 아이템을 추가하는 동시에 관리자 시스템에서 프로모션 할인을 적용하는 상황을 상상해 보세요. 어떤 함수가 먼저 실행되느냐에 따라 최종 totalPrice는 완전히 달라질 수 있습니다. 이를 '경쟁 조건(Race Condition)'이라고 부르며, 디버깅하기 가장 까다로운 버그 중 하나입니다.
상태 변경은 시간의 흐름에 따라 시스템을 복잡하게 만듭니다. 코드의 특정 지점에서 cart의 상태가 어떠할지 예측하려면, 그 이전에 어떤 함수들이 어떤 순서로 cart를 수정했는지 그 히스토리를 모두 추적해야 합니다. 이는 코드의 인과 관계를 파악하기 어렵게 만들고, 결국 소프트웨어의 신뢰도를 떨어뜨립니다.
부수 효과(Side Effect)란 무엇인가?
부수 효과는 함수가 자신의 스코프(scope)를 벗어나 외부 세계와 상호작용하거나 외부의 상태를 변경할 때 발생합니다. 즉, 함수가 입력값을 받아 결과값을 반환하는 핵심적인 계산 외에 부수적인 일을 하는 모든 행위를 말합니다.
일반적인 부수 효과의 예시는 다음과 같습니다.
- 전역 변수나 정적 변수를 수정하는 행위
- 함수 외부의 변수나 객체의 상태를 변경하는 행위 (위
cart예시처럼) - 파일 시스템에 파일을 쓰거나 읽는 I/O 작업
- 데이터베이스에 데이터를 저장하거나 조회하는 작업
- 네트워크를 통해 API를 호출하는 행위
- 콘솔이나 화면에 무언가를 출력하는 행위
- DOM을 직접 조작하는 행위
물론 애플리케이션이 유용한 작업을 하려면 부수 효과는 필수적입니다. 데이터베이스에 정보를 저장하지 않거나 화면에 아무것도 표시하지 않는 프로그램은 의미가 없습니다. 함수형 프로그래밍은 부수 효과를 완전히 없애자는 것이 아닙니다. 대신, 부수 효과를 통제하고, 격리하며, 최소화하여 예측 불가능성을 시스템의 핵심 로직에서 분리하자는 철학을 가지고 있습니다.
부수 효과가 있는 함수(불순한 함수, Impure Function)는 다음과 같은 문제점을 가집니다.
- 예측 불가능성: 동일한 입력에 대해 항상 동일한 결과를 보장하지 않습니다. 네트워크 상태나 데이터베이스의 값에 따라 결과가 달라질 수 있습니다.
- 테스트의 어려움: 함수를 테스트하기 위해 데이터베이스, 파일 시스템, 네트워크 등 거대한 외부 환경을 흉내 내거나(mocking) 설정해야 합니다. 이는 테스트 코드를 복잡하고 깨지기 쉽게 만듭니다.
- 낮은 조합성: 부수 효과가 있는 함수들은 서로에게 숨겨진 의존성을 가질 수 있습니다. 이들을 조합하여 새로운 기능을 만들 때 예기치 않은 상호작용이 발생할 수 있습니다.
이제, 함수형 프로그래밍이 이러한 혼돈을 어떻게 질서로 바꾸는지, 그 핵심 무기인 '순수 함수'와 '불변성'을 통해 알아보겠습니다.
첫 번째 기둥: 순수 함수 (Pure Functions)
함수형 프로그래밍의 심장은 바로 순수 함수입니다. 순수 함수는 수학에서의 함수 개념과 매우 유사하며, 코드의 예측 가능성과 안정성을 극대화하는 열쇠입니다. 순수 함수가 되기 위해서는 다음 두 가지 조건을 반드시 만족해야 합니다.
- 동일한 입력에 대해서는 항상 동일한 출력을 반환한다. (참조 투명성, Referential Transparency)
- 어떠한 관찰 가능한 부수 효과(Side Effect)도 일으키지 않는다.
순수 함수의 구체적 이해
말로만 들으면 추상적으로 느껴질 수 있습니다. 실제 코드를 통해 순수한 함수와 그렇지 않은 함수를 비교해 보겠습니다.
순수한 함수의 예시
// 두 숫자를 더하는 매우 간단하고 순수한 함수
function add(a, b) {
return a + b;
}
// 언제, 어디서, 누가 호출하든 add(2, 3)은 항상 5를 반환한다.
console.log(add(2, 3)); // 5
console.log(add(2, 3)); // 5
// 사용자 객체에서 전체 이름을 생성하는 순수 함수
function getFullName(user) {
return `${user.firstName} ${user.lastName}`;
}
const user1 = { firstName: 'Grace', lastName: 'Hopper' };
console.log(getFullName(user1)); // "Grace Hopper"
// 이 함수는 입력으로 받은 user 객체를 절대 수정하지 않는다.
위의 add 함수와 getFullName 함수는 완벽한 순수 함수입니다. add 함수는 오직 입력받은 인자 a와 b에만 의존하며, 그 외의 어떤 외부 요인(전역 변수, 시간, 랜덤 값 등)에도 영향을 받지 않습니다. getFullName 역시 입력받은 user 객체의 내용을 읽기만 할 뿐, 절대로 수정하지 않습니다. 따라서 이 함수들의 결과는 100% 예측 가능합니다.
불순한(Impure) 함수의 예시
반면, 다음 함수들은 순수하지 않습니다.
// 예시 1: 전역 변수에 의존하고 수정하는 함수
let minimumAge = 18;
function isAdult(age) {
return age >= minimumAge; // 외부 변수 minimumAge에 의존한다.
}
function setMinimumAge(newAge) {
minimumAge = newAge; // 외부 상태를 변경하는 부수 효과를 일으킨다!
}
console.log(isAdult(20)); // true
setMinimumAge(21);
console.log(isAdult(20)); // false
// 동일한 입력 isAdult(20)에 대해 다른 결과를 반환한다. 순수하지 않다.
// 예시 2: 부수 효과(console.log)를 포함하는 함수
function addAndLog(a, b) {
console.log(`Adding ${a} and ${b}`); // 콘솔 출력이라는 부수 효과 발생
return a + b;
}
// 예시 3: 예측 불가능한 결과를 반환하는 함수
function getRandomNumber() {
return Math.random(); // 호출할 때마다 다른 값을 반환한다. 순수하지 않다.
}
// 예시 4: 외부 API에 의존하는 함수
async function fetchUser(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`); // 네트워크 I/O라는 부수 효과
return await response.json();
}
isAdult 함수는 외부 변수인 minimumAge에 의존하기 때문에, minimumAge의 값에 따라 동일한 입력에 대해서도 다른 결과를 내놓습니다. setMinimumAge는 외부 상태를 직접 변경하므로 명백한 부수 효과를 가집니다. addAndLog는 계산 외에 콘솔에 로그를 남기는 부수적인 일을 합니다. getRandomNumber나 fetchUser처럼 결과가 외부 요인(시간, 네트워크 상태)에 따라 달라지는 함수들도 모두 불순한 함수입니다.
순수 함수가 주는 강력한 이점들
그렇다면 왜 우리는 코드를 순수 함수로 채우기 위해 노력해야 할까요? 그 이유는 순수 함수가 제공하는 실질적인 이점들이 매우 강력하기 때문입니다.
1. 예측 가능성과 코드 추론의 용이성
순수 함수는 블랙박스와 같습니다. 우리는 함수의 내부 구현을 몰라도, 입력과 출력만 알면 그 동작을 완벽하게 이해할 수 있습니다. "이 함수에 이 값을 넣으면, 저 값이 나온다"는 사실이 항상 보장되기 때문에, 복잡한 로직을 따라가며 머릿속으로 상태를 추적할 필요가 없습니다. 이는 버그 발생 가능성을 극적으로 줄여주고, 코드를 읽고 이해하는 인지적 부담을 크게 덜어줍니다.
2. 테스트의 용이성
순수 함수는 테스트하기 가장 쉬운 코드 단위입니다. 데이터베이스 연결, 네트워크 모킹, 복잡한 환경 설정이 전혀 필요 없습니다. 그저 몇 가지 대표적인 입력값을 넣고, 기대하는 출력값이 나오는지만 확인하면 됩니다.
// 순수 함수 getFullName을 테스트하는 것은 매우 간단하다.
test('getFullName should return the full name string', () => {
const user = { firstName: 'Alan', lastName: 'Turing' };
expect(getFullName(user)).toBe('Alan Turing');
});
// 반면, 부수 효과가 있는 함수를 테스트하려면 복잡한 설정이 필요하다.
// 예를 들어, 데이터베이스에 의존하는 함수를 테스트하려면
// 1. 테스트용 데이터베이스를 설정하거나
// 2. 데이터베이스 접근 로직을 모킹(mocking)해야 한다.
// 이는 테스트 코드를 복잡하게 만들고, 실제 환경과의 차이로 인해 테스트의 신뢰도를 떨어뜨릴 수 있다.
3. 조합성(Composability)
순수 함수는 레고 블록과 같습니다. 각 블록(함수)은 독립적이며 예측 가능하기 때문에, 이들을 자유롭게 조립하여 더 크고 복잡한 기능을 만들 수 있습니다. 부수 효과가 있는 함수들은 숨겨진 의존성 때문에 서로 얽혀있어 재사용하거나 조합하기 어렵지만, 순수 함수는 그러한 걱정 없이 안전하게 조합할 수 있습니다. (이는 '함수 합성'이라는 주제에서 더 자세히 다루겠습니다.)
4. 동시성(Concurrency) 프로그래밍의 단순화
순수 함수는 공유된 상태를 변경하지 않으므로, 여러 스레드에서 동시에 호출되어도 아무런 문제가 없습니다. 경쟁 조건(Race Condition)이나 교착 상태(Deadlock)와 같은 동시성 프로그래밍의 골치 아픈 문제들은 대부분 '공유된 변경 가능한 상태' 때문에 발생합니다. 순수 함수를 사용하면 이러한 문제의 근원을 제거할 수 있어, 병렬 처리를 훨씬 안전하고 간단하게 구현할 수 있습니다.
5. 캐싱을 통한 성능 최적화 (메모이제이션)
순수 함수는 동일한 입력에 대해 항상 동일한 출력을 반환하는 특성(참조 투명성)을 가집니다. 이 성질을 이용하면 함수의 결과를 캐싱하여 성능을 향상시킬 수 있습니다. 이를 메모이제이션(Memoization)이라고 합니다. 한 번 계산된 입력값에 대한 결과는 어딘가에 저장해두고, 다음에 동일한 입력으로 함수가 호출되면 다시 계산하는 대신 저장된 값을 즉시 반환하는 것입니다. 이는 계산 비용이 비싼 함수에 특히 유용합니다.
// 메모이제이션을 구현하는 간단한 고차 함수
function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
if (cache[key]) {
console.log('Fetching from cache...');
return cache[key];
} else {
console.log('Calculating result...');
const result = fn(...args);
cache[key] = result;
return result;
}
}
}
// 계산 비용이 비싼 순수 함수 (피보나치 수열)
function slowFibonacci(n) {
if (n < 2) return n;
return slowFibonacci(n - 2) + slowFibonacci(n - 1);
}
const memoizedFib = memoize(slowFibonacci);
console.time('first call');
console.log(memoizedFib(40));
console.timeEnd('first call'); // 예: first call: 1.2s
console.time('second call');
console.log(memoizedFib(40)); // 캐시된 결과를 즉시 반환
console.timeEnd('second call'); // 예: second call: 0.1ms
이처럼 순수 함수는 단순히 코드를 깔끔하게 만드는 것을 넘어, 소프트웨어의 설계 전반에 걸쳐 강력하고 긍정적인 영향을 미칩니다.
두 번째 기둥: 불변성 (Immutability)
순수 함수가 '행위'의 예측 가능성을 보장한다면, 불변성은 '데이터'의 예측 가능성을 보장합니다. 불변성은 이름 그대로, 일단 생성된 데이터는 결코 변경될 수 없다는 원칙입니다. 만약 데이터를 수정해야 한다면, 원본을 변경하는 대신 수정된 내용으로 새로운 데이터를 생성해야 합니다.
이는 마치 회계 장부와 같습니다. 회계사는 이미 기록된 거래 내역을 지우개로 지우고 수정하지 않습니다. 대신, 새로운 줄에 정정 거래를 추가하여 잔액을 조정합니다. 이렇게 하면 모든 변경 이력이 남게 되어 추적과 감사가 용이해집니다. 불변성도 이와 비슷한 철학을 공유합니다.
가변성(Mutability) vs. 불변성(Immutability)
우리가 흔히 사용하는 대부분의 객체와 배열은 기본적으로 '가변적(mutable)'입니다. 즉, 생성된 후에 그 내용을 바꿀 수 있습니다.
가변적인 데이터 처리 방식의 문제점
// 사용자의 권한 목록
const user = {
name: 'Alice',
permissions: ['read'],
};
// 관리자 권한을 부여하는 함수
function grantAdminAccess(userObject) {
// 이런! 원본 객체를 직접 수정해버렸다.
userObject.permissions.push('write');
userObject.permissions.push('delete');
return userObject;
}
// 현재 사용자에게 관리자 권한을 부여
const adminUser = grantAdminAccess(user);
console.log(adminUser.permissions); // ['read', 'write', 'delete']
// 그런데 원본 user 객체도 함께 변경되어 버렸다!
console.log(user.permissions); // ['read', 'write', 'delete']
// user와 adminUser는 같은 객체를 참조하고 있기 때문이다.
// 이로 인해 user 객체를 사용하던 다른 코드에서 예기치 않은 버그가 발생할 수 있다.
// 예를 들어, "읽기 권한만 있는지 확인"하는 로직이 오작동하게 된다.
위 예시에서 grantAdminAccess 함수는 user 객체를 직접 수정하는 부수 효과를 일으킵니다. 이 때문에 user와 adminUser는 동일한 객체를 가리키게 되고, 한쪽의 변경이 다른 쪽에 영향을 미치는 예상치 못한 결과를 낳았습니다. 이런 종류의 버그는 원인을 찾기가 매우 어렵습니다. 데이터가 언제, 어디서, 누구에 의해 변경되었는지 추적하기가 거의 불가능하기 때문입니다.
불변성을 적용한 데이터 처리 방식
이제 동일한 로직을 불변성을 유지하며 작성해 보겠습니다. 원본을 수정하는 대신, 변경 사항이 적용된 새로운 복사본을 만듭니다.
const user = {
name: 'Alice',
permissions: ['read'],
};
// 불변성을 유지하며 관리자 권한을 부여하는 함수
function grantAdminAccessImmutable(userObject) {
// 전개 구문(...)을 사용하여 새로운 객체와 배열을 생성한다.
const newPermissions = [...userObject.permissions, 'write', 'delete'];
const newUser = {
...userObject, // 기존 userObject의 속성을 얕게 복사
permissions: newPermissions, // permissions 속성은 새로운 배열로 교체
};
return newUser;
}
const adminUser = grantAdminAccessImmutable(user);
console.log(adminUser.permissions); // ['read', 'write', 'delete']
// 원본 user 객체는 전혀 영향을 받지 않았다!
console.log(user.permissions); // ['read']
// user와 adminUser는 서로 다른 객체를 참조한다.
console.log(user === adminUser); // false
console.log(user.permissions === adminUser.permissions); // false
grantAdminAccessImmutable 함수는 입력으로 받은 userObject를 절대 건드리지 않습니다. 대신, JavaScript의 전개 구문(spread syntax)을 사용하여 기존 객체의 속성을 복사하고 필요한 부분만 새로운 값으로 교체한 새로운 객체를 반환합니다. 그 결과, 원본 user 객체는 원래 상태 그대로 안전하게 보존되며, 우리는 변경 전과 후의 상태를 모두 명확하게 가질 수 있게 됩니다.
불변성이 제공하는 혜택
데이터를 불변으로 유지하는 것은 단순히 원본을 보호하는 것 이상의 많은 장점을 제공합니다.
1. 예측 가능하고 안정적인 상태 관리
애플리케이션의 상태가 불변 데이터 구조로 관리될 때, 우리는 더 이상 "데이터가 어디선가 예기치 않게 변경되면 어떡하지?"라는 걱정을 할 필요가 없습니다. 데이터의 흐름은 단방향으로 명확해집니다. 상태를 변경하려면 명시적으로 새로운 상태를 생성하는 함수를 호출해야 하며, 이 과정은 추적이 용이합니다. 이는 Redux와 같은 상태 관리 라이브러리의 핵심 원리이기도 합니다.
2. 동시성 환경에서의 안전성
불변 데이터는 읽기 전용(read-only)이므로 여러 스레드에서 동시에 접근해도 아무런 문제가 없습니다. 데이터를 변경할 수 없기 때문에 락(Lock)이나 뮤텍스(Mutex)와 같은 복잡한 동기화 메커니즘이 필요 없습니다. 이는 멀티코어 환경의 성능을 최대한 활용할 수 있는 안전하고 간단한 방법을 제공합니다.
3. 손쉬운 변경 추적 (Change Detection)
가변적인 객체 두 개가 변경되었는지 확인하려면, 객체의 모든 속성을 재귀적으로 비교하는 '깊은 비교(deep comparison)'를 해야 합니다. 이는 계산 비용이 매우 비쌉니다. 하지만 불변 객체를 사용할 경우, 변경이 있었다면 반드시 새로운 객체가 생성되었을 것입니다. 따라서 두 객체가 같은지 확인하려면 단순히 참조(메모리 주소)만 비교하면 됩니다. (objA === objB). 이는 매우 빠르고 효율적입니다. React와 같은 UI 라이브러리들이 불변성을 활용하여 언제 컴포넌트를 다시 렌더링할지 효율적으로 결정하는 원리이기도 합니다.
4. 시간 여행 디버깅 (Time-travel Debugging)
모든 상태 변경이 새로운 상태 객체를 생성하는 방식으로 이루어지므로, 우리는 애플리케이션 상태의 모든 버전을 스냅샷처럼 저장할 수 있습니다. 이를 통해 버그가 발생했을 때, 마치 비디오를 되감기 하듯 상태를 이전 시점으로 돌려보며 언제, 어떤 액션에 의해 문제가 발생했는지 쉽게 파악할 수 있습니다. Redux DevTools가 제공하는 강력한 디버깅 기능이 바로 이 원리에 기반합니다.
성능에 대한 우려와 구조적 공유 (Structural Sharing)
불변성에 대해 흔히 제기되는 우려 중 하나는 "데이터를 변경할 때마다 전체를 복사하면 메모리와 성능에 부담이 되지 않을까?"라는 점입니다. 만약 수만 개의 아이템을 가진 배열에 아이템 하나를 추가하기 위해 전체 배열을 복사한다면 비효율적일 것입니다.
이 문제를 해결하기 위해 함수형 프로그래밍 라이브러리(예: Immer, Immutable.js)들은 '구조적 공유(Structural Sharing)' 또는 '지속성 데이터 구조(Persistent Data Structure)'라는 영리한 기술을 사용합니다. 데이터의 변경이 발생했을 때, 전체를 복사하는 것이 아니라 변경된 부분에 대한 새로운 노드만 생성하고, 변경되지 않은 부분은 이전 버전의 데이터 구조와 메모리를 공유(참조)하는 방식입니다.
이를 통해 메모리 사용량을 최소화하고 성능을 최적화하면서도 불변성의 모든 이점을 누릴 수 있습니다. JavaScript의 전개 구문을 사용한 얕은 복사도 일종의 간단한 구조적 공유로 볼 수 있습니다.
함수형 프로그래밍의 실천: 고차 함수와 함수 합성
순수 함수와 불변성의 개념을 이해했다면, 이제 이것들을 어떻게 엮어서 실용적인 코드를 작성하는지 알아볼 차례입니다. 함수형 프로그래밍에서는 '어떻게'를 지시하는 for, while 같은 제어문 대신, '무엇을' 원하는지 선언하는 고차 함수(Higher-Order Functions)와 함수 합성(Function Composition)을 적극적으로 활용합니다.
고차 함수 (Higher-Order Functions)
고차 함수는 다음 두 조건 중 하나 이상을 만족하는 함수를 말합니다.
- 다른 함수를 인자(argument)로 받는다.
- 함수를 결과로 반환한다.
JavaScript에서 배열과 함께 흔히 사용되는 map, filter, reduce가 대표적인 고차 함수입니다. 이 함수들은 우리가 '무엇을' 하고 싶은지에 대한 로직(함수)을 인자로 받아, 반복의 '어떻게'에 대한 세부 사항을 추상화해줍니다.
명령형 vs. 선언형
숫자 배열에서 짝수만 골라내어 각 숫자를 제곱한 새로운 배열을 만드는 예시를 보겠습니다.
명령형 방식 (for 루프 사용)
const numbers = [1, 2, 3, 4, 5, 6];
const result = [];
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) { // 조건 확인
result.push(numbers[i] * numbers[i]); // 변형 및 추가
}
}
console.log(result); // [4, 16, 36]
이 코드는 "루프를 돌면서, 인덱스 i를 만들고, 배열의 길이를 확인하고, 각 요소를 꺼내서, 짝수인지 검사하고, 맞으면 제곱해서, 새로운 배열 result에 넣어라" 라고 컴퓨터에게 하나하나 명령합니다. 중간 상태(i, result)를 직접 관리해야 하고, 로직이 복잡해질수록 실수가 발생하기 쉽습니다.
선언형 방식 (고차 함수 사용)
const numbers = [1, 2, 3, 4, 5, 6];
const result = numbers
.filter(num => num % 2 === 0) // "짝수인 숫자만 걸러내 줘 (filter)"
.map(num => num * num); // "그리고 각 숫자를 제곱해 줘 (map)"
console.log(result); // [4, 16, 36]
이 코드는 훨씬 간결하고 명확합니다. 우리는 "어떻게" 반복할지 신경 쓸 필요 없이, "무엇을" 원하는지만 선언합니다. filter와 map에 전달된 콜백 함수들은 순수 함수이므로, 전체 로직의 이해가 쉽고 예측 가능합니다. 또한, 중간 결과를 저장하기 위한 임시 변수가 필요 없어 코드가 더 깔끔해집니다.
함수 합성 (Function Composition)
함수 합성은 마치 수학의 f(g(x))처럼, 여러 함수를 하나로 조합하여 새로운 함수를 만드는 기술입니다. 작고 재사용 가능한 순수 함수들을 만든 뒤, 이들을 파이프라인처럼 연결하여 복잡한 작업을 수행하는 것입니다.
예를 들어, 어떤 문자열을 받아 양 끝의 공백을 제거하고, 모두 대문자로 바꾼 뒤, 마지막에 느낌표를 붙이는 작업을 상상해 봅시다.
// 작고, 순수하며, 재사용 가능한 함수들
const trim = str => str.trim();
const toUpperCase = str => str.toUpperCase();
const addExclamation = str => `${str}!`;
// 함수 합성을 통해 새로운 함수를 만든다.
const shout = str => addExclamation(toUpperCase(trim(str)));
const message = " hello world ";
const result = shout(message);
console.log(result); // "HELLO WORLD!"
이렇게 함수를 합성하면 데이터가 함수 파이프라인을 따라 흐르는 명확한 흐름을 만들 수 있습니다. Lodash/fp나 Ramda와 같은 함수형 라이브러리들은 이런 합성을 더 우아하게 만들어주는 pipe나 compose 유틸리티 함수를 제공합니다.
// Ramda 라이브러리를 사용한 예시
// pipe는 왼쪽에서 오른쪽으로 함수를 순서대로 실행한다.
const shoutWithPipe = R.pipe(
trim,
toUpperCase,
addExclamation
);
const result = shoutWithPipe(" hello world "); // "HELLO WORLD!"
함수 합성은 소프트웨어의 복잡성을 관리하는 매우 강력한 도구입니다. 거대한 하나의 함수를 만드는 대신, 작고 테스트하기 쉬운 함수들로 분해하고 이들을 조합함으로써 유지보수성과 재사용성을 극대화할 수 있습니다.
현실 세계와의 타협: 부수 효과 관리하기
지금까지 순수 함수와 불변성의 이상적인 세계에 대해 이야기했지만, 현실의 애플리케이션은 데이터베이스에 접속하고, 네트워크 요청을 보내고, 화면에 무언가를 그리는 등 부수 효과 없이는 존재할 수 없습니다. 그렇다면 함수형 프로그래밍은 어떻게 이 현실의 문제를 다룰까요?
핵심 전략은 "순수한(pure) 심장, 불순한(impure) 경계" 모델입니다. 즉, 애플리케이션의 핵심 비즈니스 로직은 가능한 한 순수 함수들의 조합으로 구성하고, 피할 수 없는 부수 효과들은 시스템의 가장자리(경계)로 밀어내어 격리하는 것입니다.
부수 효과를 분리하는 기법
데이터를 가져와서 가공한 뒤 화면에 표시하는 간단한 시나리오를 생각해 봅시다.
통합된(나쁜) 방식:
async function displayUserGreeting() {
// 1. 부수 효과: 네트워크 요청
const response = await fetch('https://api.example.com/users/1');
const user = await response.json();
// 2. 순수한 로직: 데이터 가공
const greeting = `Hello, ${user.name.toUpperCase()}!`;
// 3. 부수 효과: DOM 조작
document.getElementById('greeting').innerText = greeting;
}
이 함수는 네트워크 요청, 데이터 가공, DOM 조작이라는 세 가지 책임이 뒤섞여 있습니다. 테스트하기 매우 어렵고 재사용도 불가능합니다.
분리된(좋은) 방식:
// 1. 순수한 로직: 사용자 객체를 받아 인사말 문자열을 생성한다.
// 테스트하기 매우 쉽다.
function generateGreeting(user) {
return `Hello, ${user.name.toUpperCase()}!`;
}
// 2. 불순한(impure) 경계: 부수 효과들을 처리하는 부분
async function main() {
try {
// 부수 효과: 네트워크 요청
const response = await fetch('https://api.example.com/users/1');
const user = await response.json();
// 순수한 함수 호출
const greetingText = generateGreeting(user);
// 부수 효과: DOM 조작
document.getElementById('greeting').innerText = greetingText;
} catch (error) {
console.error('Failed to display greeting:', error);
}
}
// 애플리케이션 실행
main();
두 번째 방식에서는 핵심 로직인 generateGreeting 함수를 순수 함수로 분리했습니다. 이 함수는 이제 어떤 환경에서든 재사용할 수 있고, 입력값만으로 쉽게 테스트할 수 있습니다. 실제 부수 효과(fetch, DOM 조작)는 main이라는 경계 함수 안에서만 일어나도록 격리되었습니다. 이처럼 순수한 코드와 불순한 코드를 명확히 분리하는 것만으로도 코드의 품질은 극적으로 향상됩니다.
더 나아가, Haskell이나 PureScript 같은 순수 함수형 언어에서는 모나드(Monad), 펑터(Functor)와 같은 고급 개념을 사용하여 부수 효과 자체를 '값'으로 다루어 시스템 전체의 순수성을 유지하기도 합니다. 이는 부수 효과의 실행을 최대한 지연시키고, 그 실행 계획을 순수하게 조합할 수 있도록 하여 예측 가능성을 한 단계 더 끌어올리는 정교한 기법입니다.
결론: 함수형 사고로의 전환
함수형 프로그래밍은 단순히 코딩 기술의 집합이 아닙니다. 그것은 복잡성을 정복하고, 예측 가능하며, 유지보수하기 쉬운 소프트웨어를 만들기 위한 강력한 사고방식입니다. 그 핵심에는 상태의 변경과 부수 효과를 최소화하려는 노력이 있으며, 순수 함수와 불변성은 이를 달성하기 위한 가장 중요한 두 기둥입니다.
오늘 탐험한 내용을 요약하면 다음과 같습니다.
- 부수 효과와 가변 상태는 코드의 예측을 어렵게 만들고, 테스트를 복잡하게 하며, 동시성 환경에서 수많은 버그의 원인이 됩니다.
- 순수 함수는 동일한 입력에 항상 동일한 출력을 보장하고 부수 효과가 없어, 코드의 예측 가능성, 테스트 용이성, 조합성을 극대화합니다.
- 불변성은 데이터를 변경 불가능하게 만들어 상태 변화를 명확하고 안전하게 추적할 수 있도록 돕고, 동시성 문제를 해결하며, 효율적인 변경 감지를 가능하게 합니다.
- 고차 함수와 함수 합성은 이러한 순수 함수들을 레고 블록처럼 조립하여, 간결하고 선언적인 방식으로 복잡한 로직을 구축하는 실용적인 방법을 제공합니다.
모든 코드를 함수형으로 작성해야 하는 것은 아닙니다. 객체 지향 프로그래밍과 함수형 프로그래밍은 서로 배타적인 관계가 아니며, 많은 현대 언어(JavaScript, Python, C#, Java 등)들은 두 패러다임의 장점을 모두 취할 수 있도록 지원합니다. 중요한 것은 문제의 성격에 맞게 적절한 도구를 사용하는 것입니다.
지금 당장 여러분의 코드베이스에 함수형 사고를 적용해 볼 수 있습니다. 거대한 for 루프를 map이나 filter로 바꿔보거나, 함수 내에서 외부 객체를 직접 수정하는 대신 새로운 객체를 반환하도록 리팩토링하는 작은 시도부터 시작해 보세요. 이러한 작은 변화들이 모여, 여러분의 코드를 더욱 견고하고, 우아하며, 미래의 변화에 유연하게 대처할 수 있는 강력한 자산으로 만들어 줄 것입니다.
0 개의 댓글:
Post a Comment