들어가며
함수형 프로그래밍을 처음 접하면 모나드, 펑터, 모노이드, 커링 같은 개념이 먼저 눈에 들어옵니다. 수학적 정의를 읽고, 타입 시그니처를 해석하고, 하스켈 코드 예제를 들여다보다 보면 자연스럽게 드는 생각이 있습니다. "그래서 이걸 내 React 코드에 어떻게 쓰라는 건데?" 이런 경험이 반복되면 함수형 프로그래밍은 학술적이고 막연한 것, 실무와는 거리가 먼 것이라는 인상만 남게 됩니다.
하지만 함수형 프로그래밍의 핵심은 그보다 훨씬 단순한 곳에 있습니다. Grokking Simplicity는 모나드 대신 하나의 질문에서 출발합니다. "이 코드는 어떤 종류의 일을 하고 있는가?"
장바구니에 상품을 추가하는 함수를 떠올려 보겠습니다. 처음에는 배열에 아이템을 넣는 것이 전부였습니다. 그런데 기획자가 "분석 이벤트도 보내야 합니다"라고 합니다. 그 다음 주에는 "새로고침해도 장바구니가 유지되어야 합니다"가 추가됩니다. 한 달 후에는 "재고가 부족하면 대기 목록에 넣어주세요"까지 들어옵니다. 어느새 하나의 함수 안에 가격 계산, API 호출, 로컬 스토리지 저장, 토스트 메시지 표시가 뒤섞여 있고, 이 함수를 테스트하려면 네트워크, 브라우저 스토리지, UI 알림 시스템을 전부 모킹해야 합니다.
문제의 본질은 코드의 양이 아닙니다. 이 함수가 본질적으로 다른 종류의 일을 섞어서 하고 있다는 점이 문제입니다. 데이터를 변환하는 코드, 외부 세계와 소통하는 코드, 단순히 사실을 표현하는 코드가 하나의 함수 안에 뒤섞여 있고, 이 세 가지를 구분하는 것이야말로 코드 품질을 결정하는 가장 근본적인 축입니다.
UI = f(state): 이미 시작된 함수형 사고
흥미로운 점은, React를 사용하는 프론트엔드 개발자라면 이미 함수형 프로그래밍의 핵심 원리 위에서 코드를 작성하고 있다는 사실입니다. React 컴포넌트의 본질은 UI = f(state)라는 수학적 함수 모델에 기반합니다. 같은 props를 전달하면 같은 UI가 반환되고, 컴포넌트 자체는 외부 상태를 변경하지 않습니다.
// React 컴포넌트는 이미 순수 함수의 형태입니다
function PriceDisplay({ basePrice, quantity, discountRate }: PriceProps) {
const total = basePrice * quantity * (1 - discountRate);
return <span className="price">{formatCurrency(total)}</span>;
}이 컴포넌트는 완벽하게 예측 가능합니다. basePrice가 10000이고 quantity가 2이며 discountRate가 0.1이면, 결과는 언제나 18,000원입니다. 테스트하기 쉽고, 재사용하기 쉬우며, 다른 컴포넌트에 영향을 주지 않습니다.
그런데 같은 프로젝트 안에서, 이런 코드가 바로 옆에 공존합니다.
function useCheckout() {
const [cart, setCart] = useState<CartItem[]>([]);
const addItem = (item: Product) => {
const newCart = [...cart, { ...item, quantity: 1 }];
setCart(newCart); // 상태 변경
analytics.track("add_to_cart", item); // 외부 서비스 호출
localStorage.setItem("cart", JSON.stringify(newCart)); // 브라우저 스토리지 쓰기
toast.success(`${item.name} 추가됨`); // UI 사이드 이펙트
};
return { cart, addItem };
}PriceDisplay와 addItem은 같은 React 프로젝트에 속하지만, 성격이 완전히 다릅니다. 전자는 입력이 같으면 결과가 같고, 후자는 호출할 때마다 네트워크 요청이 나가고 브라우저 상태가 바뀝니다. React는 뷰 레이어에 함수형 도구를 제공하지만, 비즈니스 로직과 사이드 이펙트를 어떻게 다룰지는 개발자에게 맡깁니다. 이 빈자리를 체계적으로 채우는 것이 함수형 프로그래밍의 역할입니다.
명령형과 선언형: 패러다임의 차이
함수형 사고의 출발점은 "어떻게"가 아닌 "무엇을" 기술하는 습관입니다. 명령형 코드는 컴퓨터가 수행할 단계를 하나씩 지시하기 때문에, 코드를 읽는 사람이 머릿속에서 실행을 시뮬레이션해야 합니다. 반면 선언형 코드는 원하는 결과를 묘사하므로, 코드가 곧 명세서가 됩니다.
// 할인 적용된 상품만 골라서 총액을 구하는 로직
const items = getCartItems();
let total = 0;
for (let i = 0; i < items.length; i++) {
if (items[i].discounted) {
const price = items[i].price * items[i].quantity;
const discountedPrice = price * (1 - items[i].discountRate);
total += discountedPrice;
}
}두 코드의 결과는 동일하지만, 읽는 경험이 다릅니다. 명령형 버전에서는 i의 변화와 total의 누적을 추적해야 하며, 중간에 조건 분기가 어떤 역할을 하는지 파악하려면 전체 루프를 읽어야 합니다. 선언형 버전에서는 filter → map → reduce라는 세 단어만으로 데이터의 흐름이 드러납니다. "할인 상품을 고르고, 가격을 계산하고, 합산한다"는 의도가 코드 구조 자체에 반영되어 있는 셈입니다.
이러한 차이는 JSX에서도 동일하게 나타납니다. document.createElement로 DOM을 조작하는 명령형 코드 대신, React에서는 원하는 UI의 모습을 선언하기만 하면 됩니다. 선언형 접근이 프론트엔드에서 주류가 된 이유는 단순히 코드가 짧아져서가 아니라, 의도를 더 명확하게 전달할 수 있기 때문입니다.
Data, Calculation, Action: 코드를 분류하는 프레임워크
선언형 코드가 "무엇을"에 집중한다면, 다음 단계는 그 "무엇을"의 성격을 구분하는 것입니다. 앞서 언급한 단순한 질문이 바로 이 구분의 출발점이며, 그 답이 Data, Calculation, Action(DCA) 프레임워크입니다. 모든 코드는 다음 세 가지 중 하나로 분류됩니다.
Data는 이벤트에 대한 사실을 기록한 것으로, 사용자 객체나 API 응답, 설정 값처럼 그 자체로는 아무 일도 하지 않는 불변의 값을 가리킵니다. TypeScript에서 interface나 type으로 정의하는 구조, 혹은 상수 객체가 여기에 해당합니다.
Calculation은 입력으로부터 출력을 계산하는 코드로, 언제 몇 번 실행하든 같은 입력에 같은 결과를 반환하며 외부 세계에 아무런 영향을 미치지 않습니다. 가격 계산, 배열 필터링, 유효성 검증 같은 함수가 여기에 속하며, 수학에서 말하는 "함수"의 정의 — 그리고 함수형 프로그래밍에서 "순수 함수"라 부르는 것 — 와 정확히 일치합니다.
Action은 실행 시점이나 횟수에 따라 결과가 달라지는 코드입니다. 네트워크 요청은 서버 상태에 따라 응답이 다르고, localStorage.setItem은 브라우저 상태를 변경하며, console.log조차 호출할 때마다 콘솔에 새 줄을 남기므로 Action에 해당합니다. 이들의 공통점은 외부 세계와의 상호작용이 포함된다는 것이며, 바로 이 특성이 코드를 예측 불가능하게 만드는 근본 원인이 됩니다.
이 세 가지 분류가 강력한 이유는, 코드의 복잡성이 어디서 오는지를 정확히 짚어주기 때문입니다. Data는 복잡성을 만들지 않습니다. Calculation도 예측 가능하므로 관리하기 수월합니다. 복잡성의 대부분은 Action에서 발생합니다. 따라서 Action의 비율을 줄이고 Calculation의 비율을 높이는 것이 코드 품질을 향상시키는 가장 직접적인 전략이 됩니다.
프론트엔드 코드를 DCA로 분류하기
이커머스 상품 페이지를 구현한다고 가정해봅시다. 상품 목록을 불러오고, 카테고리로 필터링하며, 장바구니에 담고, 사용자 행동을 분석하는 기능이 필요합니다. 이 기능에 포함되는 코드를 DCA로 분류하면 다음과 같습니다.
| 코드 | 분류 | 이유 |
|---|---|---|
Product 인터페이스 정의 | Data | 구조를 서술할 뿐, 실행되지 않음 |
| 카테고리별 할인율 매핑 객체 | Data | 불변 사실의 기록 |
filterByCategory(products, category) | Calculation | 입력이 같으면 결과가 항상 같음 |
sortByPrice(products, order) | Calculation | 원본을 변경하지 않는 순수 변환 |
calculateDiscount(price, coupon) | Calculation | 외부 상태에 의존하지 않는 산술 연산 |
validateCoupon(coupon, rules) | Calculation | 규칙에 따른 판정, 부수 효과 없음 |
fetchProducts(category) | Action | 네트워크 요청 — 서버 상태에 따라 결과가 다름 |
analytics.track("view", product) | Action | 외부 분석 시스템에 이벤트를 전송함 |
localStorage.setItem("cart", data) | Action | 브라우저의 영속 저장소를 변경함 |
이 표를 보면 한 가지 사실이 드러납니다. Calculation으로 분류된 코드들은 어떤 맥락에서든 재사용할 수 있습니다. calculateDiscount는 장바구니 페이지, 결제 페이지, 주문 확인 이메일 어디서든 동일하게 동작합니다. 반면 Action으로 분류된 코드들은 실행 환경에 묶여 있어, 테스트할 때 반드시 외부 의존성을 모킹해야 합니다.
실무에서 자주 발생하는 문제는 Calculation이 Action 안에 묻혀 있는 경우입니다. 앞서 본 addItem 함수가 정확히 그 예입니다. 새 장바구니를 만드는 계산([...cart, { ...item, quantity: 1 }])은 본질적으로 Calculation이지만, 상태 변경과 API 호출에 둘러싸여 있기 때문에 함수 전체가 Action이 되어버립니다. 이 Calculation을 Action 바깥으로 꺼내는 것이 함수형 리팩토링의 출발점입니다.
왜 Calculation을 최대화해야 하는가
Calculation과 Action의 비율이 왜 중요한지, 세 가지 관점에서 살펴보겠습니다.
테스트 용이성
Calculation을 테스트하는 것은 단순합니다. 입력을 넣고 출력을 확인하면 됩니다. 테스트 환경에 대한 가정이 필요 없으며, 셋업 코드도 거의 없습니다.
// Calculation 테스트: 입력과 출력만 검증하면 됩니다
describe("calculateDiscount", () => {
it("퍼센트 쿠폰을 올바르게 적용합니다", () => {
const coupon = { type: "percent" as const, value: 10 };
expect(calculateDiscount(10000, coupon)).toBe(9000);
});
it("정액 쿠폰의 할인이 상품 가격을 초과하면 0을 반환합니다", () => {
const coupon = { type: "fixed" as const, value: 15000 };
expect(calculateDiscount(10000, coupon)).toBe(0);
});
});같은 도메인의 Action을 테스트하려면 상황이 달라집니다. 네트워크, 스토리지, 분석 서비스를 모두 모킹해야 하고, 비동기 흐름을 추적해야 합니다.
// Action 테스트: 외부 의존성을 모두 준비해야 합니다
describe("addItemToCart", () => {
const mockTrack = vi.fn();
const mockSetItem = vi.fn();
beforeEach(() => {
vi.spyOn(analytics, "track").mockImplementation(mockTrack);
vi.spyOn(Storage.prototype, "setItem").mockImplementation(mockSetItem);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("장바구니에 상품을 추가하고 부수 효과를 실행합니다", () => {
const item = { id: "1", name: "셔츠", price: 29000 };
addItemToCart(item);
expect(mockTrack).toHaveBeenCalledWith("add_to_cart", item);
expect(mockSetItem).toHaveBeenCalled();
});
});Calculation 테스트는 코드 두 줄이면 충분하지만, Action 테스트는 환경 구성에만 열 줄 이상이 필요합니다. 테스트가 복잡할수록 작성을 미루게 되고, 작성하더라도 깨지기 쉬워집니다. Calculation의 비율이 높은 코드베이스는 자연스럽게 테스트 커버리지가 올라가는 선순환이 만들어집니다.
재사용성
Calculation은 실행 환경에 대한 가정이 없으므로 어디서든 동작합니다. 서버 사이드 렌더링에서도, 웹 워커에서도, 테스트 러너에서도 결과가 동일합니다. 반면 Action은 특정 환경에 결합되어 있습니다. localStorage에 의존하는 코드는 서버에서 실행할 수 없고, fetch를 호출하는 코드는 네트워크 없이 동작하지 않습니다.
디버깅 용이성
Calculation에서 버그가 발생하면 원인은 로직 자체에 있습니다. 입력값과 기대 출력만 비교하면 문제를 좁힐 수 있습니다. Action에서 버그가 발생하면 원인이 타이밍일 수도 있고, 네트워크 상태일 수도 있으며, 다른 Action과의 실행 순서 때문일 수도 있습니다. 디버깅해야 할 변수의 수 자체가 다릅니다.
실용적 판별법
함수를 테스트하기 위해 셋업 코드가 3줄 이상 필요하다면, 그 함수 안에 Action으로 남겨야 할 부분과 Calculation으로 추출할 수 있는 부분이 섞여 있을 가능성이 높습니다.
정리
핵심 정리
- React는 이미 함수형 원리 위에 서 있습니다. 컴포넌트는 순수 함수이고, JSX는 선언적이며, 상태 업데이트는 불변성을 전제합니다. 하지만 비즈니스 로직과 사이드 이펙트를 어떻게 조직할지는 개발자의 몫입니다.
- 모든 코드는 Data, Calculation, Action으로 분류됩니다. 이 세 범주는 코드의 본질적 성격을 구분하는 가장 실용적인 프레임워크이며, 어떤 코드베이스에나 즉시 적용할 수 있습니다.
- Calculation을 최대화하고, Action을 최소화하는 것이 핵심 전략입니다. Calculation은 테스트하기 쉽고, 재사용 가능하며, 디버깅이 용이합니다. Action 안에 묻힌 Calculation을 추출하는 것이 리팩토링의 출발점이며, 이 습관 하나가 코드베이스 전체의 품질을 바꿉니다.