- Published on
백엔드 개발자를 위한 함수형 프로그래밍 가이드 2025: 불변성, 모나드, 합성, 부수효과 관리
- Authors

- Name
- Youngju Kim
- @fjvbn20031
목차
1. 왜 백엔드 개발자에게 함수형 프로그래밍이 필요한가
함수형 프로그래밍(FP)은 학술적 호기심이 아니라, 현대 백엔드 개발의 실전 무기이다.
백엔드에서 FP가 빛나는 이유:
- 테스트 용이성 — 순수 함수는 입력만 주면 출력을 검증할 수 있다. 모킹이 불필요하다
- 동시성 안전 — 불변 데이터는 락 없이 여러 스레드에서 안전하게 공유된다
- 유지보수성 — 작은 함수의 합성은 코드를 읽기 쉽고 변경하기 쉽게 만든다
- 에러 처리 — Either/Result 모나드는 예외 없이 안전한 에러 처리를 가능하게 한다
- 데이터 파이프라인 — map/filter/reduce로 데이터 변환을 선언적으로 표현한다
FP는 OOP를 대체하는 것이 아니다. 두 패러다임을 상황에 맞게 조합하는 것이 현대적인 접근이다.
함수형 프로그래밍 핵심 개념
기초
├── 순수 함수 (Pure Function)
├── 불변성 (Immutability)
├── 일급 함수 (First-class Function)
└── 고차 함수 (Higher-order Function)
합성
├── 함수 합성 (Function Composition)
├── 커링 (Currying)
├── 부분 적용 (Partial Application)
└── 포인트-프리 스타일 (Point-free)
타입과 패턴
├── 대수적 데이터 타입 (ADT)
├── 패턴 매칭 (Pattern Matching)
├── 렌즈 (Lens)
└── 옵틱 (Optic)
모나드와 컨테이너
├── Functor (map)
├── Applicative (ap)
├── Monad (flatMap/bind)
├── Maybe/Option (null 안전)
├── Either/Result (에러 처리)
└── IO (부수효과 격리)
2. 순수 함수 (Pure Functions)
2.1 정의
순수 함수는 두 가지 조건을 만족한다:
- 같은 입력에 항상 같은 출력 (결정적, deterministic)
- 부수효과가 없음 (외부 상태를 변경하지 않음)
// 순수 함수
function add(a: number, b: number): number {
return a + b; // 항상 같은 결과, 부수효과 없음
}
function formatPrice(amount: number, currency: string): string {
return `${currency} ${amount.toFixed(2)}`;
}
// 비순수 함수 — 외부 상태에 의존
let taxRate = 0.1;
function calculateTax(amount: number): number {
return amount * taxRate; // taxRate가 바뀌면 결과가 달라짐
}
// 비순수 함수 — 부수효과
function saveUser(user: User): void {
database.save(user); // 외부 상태 변경
console.log("User saved"); // I/O 부수효과
analytics.track("signup"); // 외부 서비스 호출
}
2.2 참조 투명성 (Referential Transparency)
순수 함수 호출을 그 결과값으로 대체해도 프로그램의 의미가 변하지 않는다. 이것이 참조 투명성이다.
// 참조 투명
const x = add(3, 4); // 7
const y = add(3, 4); // 7
// add(3, 4)를 모두 7로 대체해도 프로그램이 동일하게 동작
// 참조 불투명
const now1 = Date.now(); // 1713000000000
const now2 = Date.now(); // 1713000000001
// Date.now()를 특정 값으로 대체할 수 없음 — 매번 다른 결과
2.3 메모이제이션 (Memoization)
순수 함수는 같은 입력에 같은 출력을 보장하므로, 결과를 캐시할 수 있다.
// TypeScript Memoization
function memoize<Args extends unknown[], R>(
fn: (...args: Args) => R,
): (...args: Args) => R {
const cache = new Map<string, R>();
return (...args: Args): R => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
// 비용이 큰 순수 함수
function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const memoFib = memoize(fibonacci);
console.log(memoFib(40)); // 첫 호출: 느림
console.log(memoFib(40)); // 두 번째: 즉시 (캐시)
# Python Memoization — functools.lru_cache
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# 또는 직접 구현
def memoize(func):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
3. 불변성 (Immutability)
3.1 왜 불변성이 중요한가
가변 상태는 버그의 주범이다. 데이터가 언제, 어디서, 누구에 의해 변경되는지 추적하기 어렵다.
// 가변 — 위험
const users = [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }];
function birthday(user: { name: string; age: number }) {
user.age += 1; // 원본을 직접 변경!
return user;
}
birthday(users[0]);
// users[0].age가 31로 변경됨 — 예상치 못한 부수효과
// 불변 — 안전
function birthdayImmutable(user: { name: string; age: number }) {
return { ...user, age: user.age + 1 }; // 새 객체 생성
}
const olderAlice = birthdayImmutable(users[0]);
// users[0].age는 여전히 30 — 원본 보존
3.2 TypeScript에서의 불변성
// Readonly 유틸리티 타입
interface Config {
readonly host: string;
readonly port: number;
readonly features: ReadonlyArray<string>;
}
const config: Config = {
host: "localhost",
port: 8080,
features: ["auth", "logging"],
};
// config.port = 9090; // 컴파일 에러!
// config.features.push("cache"); // 컴파일 에러!
// DeepReadonly — 깊은 불변성
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// 불변 업데이트 패턴
interface State {
user: { name: string; preferences: { theme: string; lang: string } };
items: Array<{ id: number; name: string }>;
}
function updateTheme(state: State, newTheme: string): State {
return {
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: newTheme,
},
},
};
}
3.3 구조적 공유 (Structural Sharing)
불변 데이터는 매번 전체를 복사하는 것이 아니다. 변경되지 않은 부분은 참조를 공유한다.
// Immer.js — 불변 업데이트를 가변처럼 쉽게
import { produce } from "immer";
interface AppState {
users: Array<{
id: string;
name: string;
settings: { notifications: boolean; theme: string };
}>;
metadata: { lastUpdated: string; version: number };
}
const initialState: AppState = {
users: [
{ id: "1", name: "Alice", settings: { notifications: true, theme: "light" } },
{ id: "2", name: "Bob", settings: { notifications: false, theme: "dark" } },
],
metadata: { lastUpdated: "2025-01-01", version: 1 },
};
// Immer를 사용한 불변 업데이트
const newState = produce(initialState, (draft) => {
const alice = draft.users.find((u) => u.id === "1");
if (alice) {
alice.settings.theme = "dark";
}
draft.metadata.lastUpdated = "2025-04-14";
draft.metadata.version += 1;
});
// initialState는 변경되지 않음
// newState.users[1] === initialState.users[1] // true (구조적 공유)
// newState.users[0] !== initialState.users[0] // true (변경된 부분만 새로 생성)
3.4 Python에서의 불변성
# Python — frozen dataclass
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class User:
name: str
email: str
age: int
alice = User(name="Alice", email="alice@example.com", age=30)
# alice.age = 31 # FrozenInstanceError!
# 업데이트: replace() 사용
older_alice = replace(alice, age=31)
print(alice.age) # 30 (원본 보존)
print(older_alice.age) # 31 (새 객체)
# NamedTuple — 불변 튜플
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
z: float = 0.0
p1 = Point(1.0, 2.0)
p2 = p1._replace(x=3.0) # 새 Point 생성
# frozenset — 불변 집합
tags = frozenset(["python", "fp", "backend"])
# tags.add("new") # AttributeError!
new_tags = tags | frozenset(["new"]) # 새 frozenset 생성
3.5 Rust에서의 불변성
// Rust — 기본이 불변
fn main() {
let x = 5;
// x = 6; // 컴파일 에러! 변수는 기본적으로 불변
let mut y = 5; // mut 키워드로 명시적 가변
y = 6; // OK
// 소유권(Ownership) + 불변성 = 동시성 안전
let data = vec![1, 2, 3];
let sum = calculate_sum(&data); // 불변 참조 — 여러 스레드에서 동시 읽기 가능
println!("Sum: {sum}");
}
fn calculate_sum(numbers: &[i32]) -> i32 {
numbers.iter().sum()
}
4. 고차 함수 (Higher-Order Functions)
고차 함수는 함수를 인자로 받거나 함수를 반환하는 함수이다.
4.1 map / filter / reduce
// TypeScript — 데이터 변환 파이프라인
interface Transaction {
id: string;
amount: number;
type: "credit" | "debit";
category: string;
date: string;
}
const transactions: Transaction[] = [
{ id: "1", amount: 100, type: "credit", category: "salary", date: "2025-04-01" },
{ id: "2", amount: 50, type: "debit", category: "food", date: "2025-04-02" },
{ id: "3", amount: 200, type: "credit", category: "freelance", date: "2025-04-03" },
{ id: "4", amount: 30, type: "debit", category: "transport", date: "2025-04-04" },
{ id: "5", amount: 80, type: "debit", category: "food", date: "2025-04-05" },
];
// 선언적 데이터 처리
const totalFoodExpense = transactions
.filter((t) => t.type === "debit")
.filter((t) => t.category === "food")
.map((t) => t.amount)
.reduce((sum, amount) => sum + amount, 0);
console.log(`Total food expense: ${totalFoodExpense}`); // 130
// 카테고리별 집계
const byCategory = transactions
.filter((t) => t.type === "debit")
.reduce<Record<string, number>>((acc, t) => {
acc[t.category] = (acc[t.category] || 0) + t.amount;
return acc;
}, {});
console.log(byCategory); // { food: 130, transport: 30 }
4.2 함수를 반환하는 함수
// 유효성 검사 함수 팩토리
type Validator<T> = (value: T) => string | null;
function minLength(min: number): Validator<string> {
return (value: string) =>
value.length >= min ? null : `Must be at least ${min} characters`;
}
function maxLength(max: number): Validator<string> {
return (value: string) =>
value.length <= max ? null : `Must be at most ${max} characters`;
}
function matches(pattern: RegExp, message: string): Validator<string> {
return (value: string) =>
pattern.test(value) ? null : message;
}
function compose<T>(...validators: Validator<T>[]): Validator<T> {
return (value: T) => {
for (const validator of validators) {
const error = validator(value);
if (error) return error;
}
return null;
};
}
// 사용
const validatePassword = compose(
minLength(8),
maxLength(128),
matches(/[A-Z]/, "Must contain uppercase letter"),
matches(/[0-9]/, "Must contain a number"),
matches(/[!@#$%^&*]/, "Must contain special character"),
);
console.log(validatePassword("weak")); // "Must be at least 8 characters"
console.log(validatePassword("Strong1!x")); // null (유효)
# Python — 고차 함수
from typing import Callable, TypeVar
T = TypeVar("T")
def compose(*funcs: Callable) -> Callable:
"""오른쪽에서 왼쪽으로 함수 합성"""
def composed(x):
result = x
for f in reversed(funcs):
result = f(result)
return result
return composed
def pipe(*funcs: Callable) -> Callable:
"""왼쪽에서 오른쪽으로 함수 합성"""
def piped(x):
result = x
for f in funcs:
result = f(result)
return result
return piped
# 사용
def double(x: int) -> int: return x * 2
def add_ten(x: int) -> int: return x + 10
def to_string(x: int) -> str: return f"Result: {x}"
transform = pipe(double, add_ten, to_string)
print(transform(5)) # "Result: 20"
5. 함수 합성 (Function Composition)
5.1 pipe와 compose
작은 함수를 연결하여 복잡한 로직을 구성하는 것이 함수형 프로그래밍의 핵심이다.
// TypeScript — pipe 유틸리티
function pipe<A, B>(a: A, ab: (a: A) => B): B;
function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D;
function pipe(initial: unknown, ...fns: Function[]): unknown {
return fns.reduce((acc, fn) => fn(acc), initial);
}
// 데이터 변환 파이프라인
interface RawUser {
first_name: string;
last_name: string;
email_address: string;
birth_date: string;
}
interface ProcessedUser {
fullName: string;
email: string;
age: number;
isAdult: boolean;
}
const normalizeNames = (user: RawUser) => ({
...user,
first_name: user.first_name.trim().toLowerCase(),
last_name: user.last_name.trim().toLowerCase(),
});
const buildFullName = (user: RawUser) => ({
fullName: `${user.first_name} ${user.last_name}`,
email: user.email_address.toLowerCase(),
birthDate: user.birth_date,
});
const calculateAge = (user: { fullName: string; email: string; birthDate: string }) => {
const birth = new Date(user.birthDate);
const age = Math.floor(
(Date.now() - birth.getTime()) / (365.25 * 24 * 60 * 60 * 1000),
);
return { fullName: user.fullName, email: user.email, age };
};
const addAdultFlag = (user: { fullName: string; email: string; age: number }): ProcessedUser => ({
...user,
isAdult: user.age >= 18,
});
// 파이프라인 실행
const rawUser: RawUser = {
first_name: " JOHN ",
last_name: " DOE ",
email_address: "John.Doe@Example.COM",
birth_date: "1990-05-15",
};
const processed = pipe(
rawUser,
normalizeNames,
buildFullName,
calculateAge,
addAdultFlag,
);
// { fullName: "john doe", email: "john.doe@example.com", age: 35, isAdult: true }
5.2 포인트-프리 스타일 (Point-free Style)
함수를 정의할 때 인자를 명시적으로 적지 않는 스타일이다.
// 포인트풀 (명시적 인자)
const doubledEvens1 = (numbers: number[]) =>
numbers.filter((n) => n % 2 === 0).map((n) => n * 2);
// 포인트프리에 가까운 스타일
const isEven = (n: number) => n % 2 === 0;
const double = (n: number) => n * 2;
const doubledEvens2 = (numbers: number[]) =>
numbers.filter(isEven).map(double);
// 완전한 포인트프리 (유틸리티 필요)
const filterBy = <T>(predicate: (item: T) => boolean) =>
(items: T[]) => items.filter(predicate);
const mapBy = <T, U>(transform: (item: T) => U) =>
(items: T[]) => items.map(transform);
const doubledEvens3 = pipe(
filterBy<number>(isEven),
mapBy(double),
);
// doubledEvens3은 number[] => number[] 타입의 함수
6. 커링과 부분 적용 (Currying and Partial Application)
6.1 커링 (Currying)
여러 인자를 받는 함수를 한 번에 하나의 인자만 받는 함수 체인으로 변환한다.
// TypeScript Currying
// 일반 함수
function addNormal(a: number, b: number, c: number): number {
return a + b + c;
}
// 커링된 함수
function addCurried(a: number): (b: number) => (c: number) => number {
return (b: number) => (c: number) => a + b + c;
}
const add5 = addCurried(5); // (b) => (c) => 5 + b + c
const add5and3 = add5(3); // (c) => 5 + 3 + c
const result = add5and3(2); // 10
// 자동 커링 유틸리티
function curry<A, B, C, R>(fn: (a: A, b: B, c: C) => R): (a: A) => (b: B) => (c: C) => R {
return (a: A) => (b: B) => (c: C) => fn(a, b, c);
}
// 실전 예시: API 요청 빌더
const request = curry(
(method: string, baseUrl: string, path: string) =>
fetch(`${baseUrl}${path}`, { method }),
);
const get = request("GET");
const apiGet = get("https://api.example.com");
// 재사용 가능한 특화 함수
await apiGet("/users");
await apiGet("/products");
await apiGet("/orders");
6.2 부분 적용 (Partial Application)
함수의 일부 인자를 미리 고정하여 새 함수를 만든다.
# Python — functools.partial
from functools import partial
def log(level: str, module: str, message: str) -> str:
return f"[{level}] [{module}] {message}"
# 부분 적용
error_log = partial(log, "ERROR")
auth_error = partial(error_log, "AUTH")
db_error = partial(error_log, "DB")
print(auth_error("Invalid token")) # [ERROR] [AUTH] Invalid token
print(db_error("Connection timeout")) # [ERROR] [DB] Connection timeout
# 실전: 데이터 변환 파이프라인
def multiply(factor: float, value: float) -> float:
return value * factor
def add(offset: float, value: float) -> float:
return value + offset
def format_currency(symbol: str, value: float) -> str:
return f"{symbol}{value:,.2f}"
# 특화된 변환 함수
to_usd = partial(format_currency, "$")
to_eur = partial(format_currency, "EUR ")
apply_tax = partial(multiply, 1.1)
apply_discount = partial(multiply, 0.85)
# 파이프라인
price = 100.0
final = to_usd(apply_tax(apply_discount(price)))
print(final) # $93.50
7. 대수적 데이터 타입 (Algebraic Data Types)
7.1 Sum 타입 (합 타입) — 유니온/열거형
여러 가능한 형태 중 하나를 가지는 타입이다.
// TypeScript — Discriminated Union (태그드 유니온)
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
// 결과 타입
type ApiResult<T> =
| { status: "success"; data: T; timestamp: number }
| { status: "error"; error: string; code: number }
| { status: "loading" };
function handleResult<T>(result: ApiResult<T>): string {
switch (result.status) {
case "success":
return `Data received: ${JSON.stringify(result.data)}`;
case "error":
return `Error ${result.code}: ${result.error}`;
case "loading":
return "Loading...";
}
}
# Python — match (3.10+)
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Triangle:
base: float
height: float
Shape = Circle | Rectangle | Triangle
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
return w * h
case Triangle(base=b, height=h):
return b * h / 2
print(area(Circle(5))) # 78.54
print(area(Rectangle(4, 6))) # 24.0
// Rust — enum (진정한 대수적 데이터 타입)
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { base, height } => base * height / 2.0,
}
}
8. Option/Maybe 모나드 — null 안전
8.1 null의 문제
null/undefined 체크를 빠뜨리면 런타임 에러가 발생한다. Tony Hoare는 null을 "10억 달러짜리 실수"라고 불렀다.
// 위험: null 체크 없이 접근
function getUserCity(userId: string): string {
const user = findUser(userId); // null일 수 있음
const address = user.address; // null이면 크래시!
return address.city; // 또 크래시!
}
// 방어적 코딩 — 지저분한 null 체크
function getUserCityDefensive(userId: string): string | null {
const user = findUser(userId);
if (!user) return null;
const address = user.address;
if (!address) return null;
return address.city || null;
}
8.2 Maybe/Option 모나드
// TypeScript — Maybe 모나드 구현
class Maybe<T> {
private constructor(private readonly value: T | null) {}
static of<T>(value: T | null | undefined): Maybe<T> {
return new Maybe(value ?? null);
}
static none<T>(): Maybe<T> {
return new Maybe<T>(null);
}
isNone(): boolean {
return this.value === null;
}
isSome(): boolean {
return this.value !== null;
}
map<U>(fn: (value: T) => U): Maybe<U> {
if (this.isNone()) return Maybe.none<U>();
return Maybe.of(fn(this.value!));
}
flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
if (this.isNone()) return Maybe.none<U>();
return fn(this.value!);
}
getOrElse(defaultValue: T): T {
return this.isNone() ? defaultValue : this.value!;
}
match<U>(patterns: { some: (value: T) => U; none: () => U }): U {
return this.isNone() ? patterns.none() : patterns.some(this.value!);
}
}
// 사용 — 깔끔한 null 안전 체이닝
interface User {
name: string;
address?: { city?: string; zipCode?: string };
}
function findUser(id: string): Maybe<User> {
const users: Record<string, User> = {
"1": { name: "Alice", address: { city: "Seoul", zipCode: "06000" } },
"2": { name: "Bob" },
};
return Maybe.of(users[id] || null);
}
const city = findUser("1")
.flatMap((user) => Maybe.of(user.address))
.map((address) => address.city)
.getOrElse("Unknown");
console.log(city); // "Seoul"
const unknownCity = findUser("999")
.flatMap((user) => Maybe.of(user.address))
.map((address) => address.city)
.getOrElse("Unknown");
console.log(unknownCity); // "Unknown" — 에러 없음!
// Rust — Option은 언어에 내장
fn find_user(id: &str) -> Option<User> {
// ...
}
fn get_user_city(id: &str) -> String {
find_user(id)
.and_then(|user| user.address)
.map(|addr| addr.city)
.unwrap_or_else(|| "Unknown".to_string())
}
// ? 연산자로 더 간결하게
fn get_user_city_short(id: &str) -> Option<String> {
let user = find_user(id)?;
let address = user.address?;
Some(address.city)
}
9. Either/Result 모나드 — 예외 없는 에러 처리
9.1 예외(Exception)의 문제
예외는 코드 흐름을 예측 불가능하게 만든다. 어떤 함수가 어떤 예외를 던지는지 타입 시스템이 보장하지 못한다.
9.2 Either/Result 구현
// TypeScript — Either 모나드
type Either<L, R> = Left<L> | Right<R>;
class Left<L> {
readonly _tag = "Left" as const;
constructor(readonly value: L) {}
map<R2>(_fn: (value: never) => R2): Either<L, R2> {
return this as unknown as Either<L, R2>;
}
flatMap<R2>(_fn: (value: never) => Either<L, R2>): Either<L, R2> {
return this as unknown as Either<L, R2>;
}
getOrElse<R>(defaultValue: R): R {
return defaultValue;
}
match<T>(patterns: { left: (value: L) => T; right: (value: never) => T }): T {
return patterns.left(this.value);
}
}
class Right<R> {
readonly _tag = "Right" as const;
constructor(readonly value: R) {}
map<R2>(fn: (value: R) => R2): Either<never, R2> {
return new Right(fn(this.value));
}
flatMap<L, R2>(fn: (value: R) => Either<L, R2>): Either<L, R2> {
return fn(this.value);
}
getOrElse(_defaultValue: R): R {
return this.value;
}
match<T>(patterns: { left: (value: never) => T; right: (value: R) => T }): T {
return patterns.right(this.value);
}
}
// 헬퍼 함수
const left = <L>(value: L): Either<L, never> => new Left(value);
const right = <R>(value: R): Either<never, R> => new Right(value);
// 사용: 사용자 등록 파이프라인
interface RegistrationError {
field: string;
message: string;
}
interface ValidatedUser {
email: string;
password: string;
name: string;
}
function validateEmail(email: string): Either<RegistrationError, string> {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email)
? right(email.toLowerCase())
: left({ field: "email", message: "Invalid email format" });
}
function validatePassword(password: string): Either<RegistrationError, string> {
if (password.length < 8) {
return left({ field: "password", message: "Password too short" });
}
return right(password);
}
function validateName(name: string): Either<RegistrationError, string> {
if (name.trim().length === 0) {
return left({ field: "name", message: "Name is required" });
}
return right(name.trim());
}
function registerUser(
email: string,
password: string,
name: string,
): Either<RegistrationError, ValidatedUser> {
return validateEmail(email).flatMap((validEmail) =>
validatePassword(password).flatMap((validPassword) =>
validateName(name).map((validName) => ({
email: validEmail,
password: validPassword,
name: validName,
})),
),
);
}
// 사용
const result1 = registerUser("alice@example.com", "StrongPass1!", "Alice");
result1.match({
left: (err) => console.log(`Error in ${err.field}: ${err.message}`),
right: (user) => console.log(`Registered: ${user.email}`),
});
// "Registered: alice@example.com"
const result2 = registerUser("invalid", "short", "");
result2.match({
left: (err) => console.log(`Error in ${err.field}: ${err.message}`),
right: (user) => console.log(`Registered: ${user.email}`),
});
// "Error in email: Invalid email format"
// Rust — Result는 언어에 내장
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
ParseError(ParseIntError),
ValidationError(String),
DatabaseError(String),
}
fn parse_age(input: &str) -> Result<u32, AppError> {
let age: u32 = input
.parse()
.map_err(AppError::ParseError)?;
if age > 150 {
return Err(AppError::ValidationError(
"Age must be 150 or less".to_string()
));
}
Ok(age)
}
// ? 연산자로 에러 전파
fn process_user_input(name: &str, age_str: &str) -> Result<String, AppError> {
let age = parse_age(age_str)?;
Ok(format!("{name} is {age} years old"))
}
10. IO 모나드 — 부수효과 격리
10.1 부수효과의 문제
순수 함수만으로는 실제 프로그램을 만들 수 없다. 데이터베이스 읽기, HTTP 요청, 파일 쓰기 등 부수효과가 반드시 필요하다. IO 모나드는 부수효과를 설명하는 것과 실행하는 것을 분리한다.
// TypeScript — IO 모나드 (간략화)
class IO<T> {
constructor(private readonly effect: () => T) {}
static of<T>(value: T): IO<T> {
return new IO(() => value);
}
static from<T>(effect: () => T): IO<T> {
return new IO(effect);
}
map<U>(fn: (value: T) => U): IO<U> {
return new IO(() => fn(this.effect()));
}
flatMap<U>(fn: (value: T) => IO<U>): IO<U> {
return new IO(() => fn(this.effect()).run());
}
run(): T {
return this.effect();
}
}
// 부수효과를 IO로 감싸기
const readEnv = (key: string): IO<string | undefined> =>
IO.from(() => process.env[key]);
const log = (message: string): IO<void> =>
IO.from(() => console.log(message));
const getCurrentTime = (): IO<Date> =>
IO.from(() => new Date());
// IO 합성 — 아직 실행되지 않음!
const program = readEnv("DATABASE_URL")
.flatMap((url) =>
url
? log(`Connecting to database: ${url}`).map(() => url)
: IO.of("postgresql://localhost:5432/default"),
)
.flatMap((url) =>
getCurrentTime().flatMap((time) =>
log(`[${time.toISOString()}] Using database: ${url}`).map(() => url),
),
);
// 여기서 비로소 실행!
const dbUrl = program.run();
11. Functor와 Applicative
11.1 Functor — map을 가진 컨테이너
Functor는 map 연산을 제공하는 컨테이너이다. Maybe, Either, Array, Promise 모두 Functor이다.
// 모든 Functor의 공통점: map으로 내부 값을 변환
// Array.map
[1, 2, 3].map((x) => x * 2); // [2, 4, 6]
// Maybe.map
Maybe.of(5).map((x) => x * 2); // Maybe(10)
Maybe.none().map((x) => x * 2); // Maybe(None)
// Promise는 then이 map 역할
Promise.resolve(5).then((x) => x * 2); // Promise(10)
// Functor 법칙:
// 1. Identity: fa.map(x => x) === fa
// 2. Composition: fa.map(f).map(g) === fa.map(x => g(f(x)))
11.2 Applicative — 여러 Functor 결합
// Maybe Applicative — 여러 Maybe 값을 결합
function liftA2<A, B, C>(
fn: (a: A, b: B) => C,
ma: Maybe<A>,
mb: Maybe<B>,
): Maybe<C> {
return ma.flatMap((a) => mb.map((b) => fn(a, b)));
}
const price = Maybe.of(100);
const quantity = Maybe.of(5);
const total = liftA2((p, q) => p * q, price, quantity);
console.log(total.getOrElse(0)); // 500
// 하나라도 None이면 결과도 None
const noPrice = Maybe.none<number>();
const result = liftA2((p, q) => p * q, noPrice, quantity);
console.log(result.getOrElse(0)); // 0
12. 패턴 매칭 (Pattern Matching)
12.1 TypeScript Discriminated Unions
// TypeScript — 패턴 매칭 시뮬레이션
type HttpResponse =
| { status: 200; data: unknown }
| { status: 404; path: string }
| { status: 500; error: Error }
| { status: 401; realm: string };
function handleResponse(response: HttpResponse): string {
switch (response.status) {
case 200:
return `Success: ${JSON.stringify(response.data)}`;
case 404:
return `Not found: ${response.path}`;
case 500:
return `Server error: ${response.error.message}`;
case 401:
return `Unauthorized: realm=${response.realm}`;
}
}
// 완전성 검사 — exhaustive check
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function handleResponseExhaustive(response: HttpResponse): string {
switch (response.status) {
case 200: return "ok";
case 404: return "not found";
case 500: return "error";
case 401: return "unauthorized";
default: return assertNever(response);
// 새로운 status를 추가하면 여기서 컴파일 에러 발생!
}
}
12.2 Python match 문
# Python 3.10+ — 구조적 패턴 매칭
from dataclasses import dataclass
@dataclass
class Success:
data: dict
@dataclass
class NotFound:
path: str
@dataclass
class ServerError:
message: str
type Response = Success | NotFound | ServerError
def handle_response(response: Response) -> str:
match response:
case Success(data=d):
return f"Success: {d}"
case NotFound(path=p):
return f"Not found: {p}"
case ServerError(message=m):
return f"Error: {m}"
# 중첩 패턴 매칭
def process_command(command: dict) -> str:
match command:
case {"action": "create", "entity": "user", "data": {"name": name}}:
return f"Creating user: {name}"
case {"action": "delete", "entity": entity, "id": id_val}:
return f"Deleting {entity} with id {id_val}"
case {"action": action}:
return f"Unknown action: {action}"
case _:
return "Invalid command"
// Rust — 강력한 패턴 매칭
enum Command {
Quit,
Echo(String),
Move { x: i32, y: i32 },
ChangeColor(u8, u8, u8),
}
fn process_command(cmd: Command) -> String {
match cmd {
Command::Quit => "Goodbye!".to_string(),
Command::Echo(msg) => msg,
Command::Move { x, y } => format!("Moving to ({x}, {y})"),
Command::ChangeColor(r, g, b) => format!("Color: rgb({r}, {g}, {b})"),
}
}
// 가드 조건
fn classify_number(n: i32) -> &'static str {
match n {
0 => "zero",
n if n > 0 => "positive",
_ => "negative",
}
}
13. 렌즈 (Lenses) — 불변 중첩 업데이트
깊이 중첩된 불변 데이터를 우아하게 업데이트하기 위한 도구이다.
// TypeScript — 간단한 Lens 구현
interface Lens<S, A> {
get: (source: S) => A;
set: (value: A, source: S) => S;
}
function lens<S, A>(
get: (source: S) => A,
set: (value: A, source: S) => S,
): Lens<S, A> {
return { get, set };
}
function over<S, A>(l: Lens<S, A>, fn: (a: A) => A, source: S): S {
return l.set(fn(l.get(source)), source);
}
function compose<S, A, B>(outer: Lens<S, A>, inner: Lens<A, B>): Lens<S, B> {
return {
get: (source: S) => inner.get(outer.get(source)),
set: (value: B, source: S) => {
const outerValue = outer.get(source);
const newOuter = inner.set(value, outerValue);
return outer.set(newOuter, source);
},
};
}
// 사용
interface Company {
name: string;
ceo: { name: string; address: { city: string; country: string } };
}
const ceoLens = lens<Company, Company["ceo"]>(
(c) => c.ceo,
(ceo, c) => ({ ...c, ceo }),
);
const addressLens = lens<Company["ceo"], Company["ceo"]["address"]>(
(ceo) => ceo.address,
(address, ceo) => ({ ...ceo, address }),
);
const cityLens = lens<Company["ceo"]["address"], string>(
(addr) => addr.city,
(city, addr) => ({ ...addr, city }),
);
// 렌즈 합성
const ceoCityLens = compose(compose(ceoLens, addressLens), cityLens);
const company: Company = {
name: "TechCorp",
ceo: { name: "Alice", address: { city: "Seoul", country: "Korea" } },
};
const updated = ceoCityLens.set("Tokyo", company);
// company.ceo.address.city는 여전히 "Seoul"
// updated.ceo.address.city는 "Tokyo"
14. 언어별 FP 생태계
14.1 TypeScript — fp-ts / Effect
// fp-ts 스타일 예시 (개념)
import { pipe } from "fp-ts/function";
import * as O from "fp-ts/Option";
import * as E from "fp-ts/Either";
import * as A from "fp-ts/Array";
// Option 사용
const getUserName = (id: string): O.Option<string> =>
id === "1" ? O.some("Alice") : O.none;
const greeting = pipe(
getUserName("1"),
O.map((name) => `Hello, ${name}!`),
O.getOrElse(() => "Hello, Guest!"),
);
// Either로 에러 처리
const parseNumber = (input: string): E.Either<string, number> => {
const n = Number(input);
return isNaN(n) ? E.left(`Invalid number: ${input}`) : E.right(n);
};
const result = pipe(
parseNumber("42"),
E.map((n) => n * 2),
E.map((n) => `Result: ${n}`),
E.getOrElse((err) => `Error: ${err}`),
);
14.2 Python — returns 라이브러리
# returns 스타일 (개념)
from returns.result import Result, Success, Failure
from returns.maybe import Maybe, Some, Nothing
from returns.pipeline import pipe
# Result 사용
def safe_divide(a: float, b: float) -> Result[float, str]:
if b == 0:
return Failure("Division by zero")
return Success(a / b)
result = safe_divide(10, 3)
print(result) # Success(3.333...)
result = safe_divide(10, 0)
print(result) # Failure('Division by zero')
# Maybe 사용
def find_item(items: list, key: str) -> Maybe[dict]:
for item in items:
if item.get("key") == key:
return Some(item)
return Nothing
14.3 Rust — 기본 내장
// Rust는 FP가 언어에 내장
fn main() {
// Iterator는 lazy + composable
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let result: Vec<i32> = numbers
.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.collect();
println!("{:?}", result); // [4, 16, 36, 64, 100]
// Option과 Result 체이닝
let config = std::env::var("APP_PORT")
.ok() // Result -> Option
.and_then(|s| s.parse::<u16>().ok()) // Option 체이닝
.unwrap_or(8080); // 기본값
println!("Port: {config}");
// Closure + 고차 함수
let multiply_by = |factor: i32| {
move |x: i32| x * factor
};
let double = multiply_by(2);
let triple = multiply_by(3);
println!("{}", double(5)); // 10
println!("{}", triple(5)); // 15
}
15. FP vs OOP — 실용적 비교
관점 FP OOP
────────────────────────────────────────────────────────
데이터와 행위 분리 (데이터 + 함수) 결합 (객체 = 데이터 + 메서드)
상태 관리 불변 데이터 변환 객체 내부 상태 변경
다형성 패턴 매칭 / 타입 클래스 상속 / 인터페이스
코드 재사용 함수 합성 상속 / 믹스인
에러 처리 Either/Result 반환 예외 던지기
동시성 불변성으로 자연스러움 락/동기화 필요
실전 가이드:
- 데이터 변환 위주: FP (파이프라인, map/filter/reduce)
- 상태 머신, 엔티티 위주: OOP (캡슐화, 다형성)
- 가장 좋은 접근: 두 패러다임의 장점을 조합
16. 실전 백엔드 예시
16.1 데이터 파이프라인
// TypeScript — 함수형 데이터 파이프라인
interface RawEvent {
timestamp: string;
userId: string;
action: string;
metadata: Record<string, unknown>;
}
interface ProcessedEvent {
date: string;
userId: string;
action: string;
isValid: boolean;
}
// 각 단계가 순수 함수
const parseTimestamp = (event: RawEvent) => ({
...event,
date: new Date(event.timestamp).toISOString().split("T")[0],
});
const normalizeAction = (event: RawEvent & { date: string }) => ({
...event,
action: event.action.toLowerCase().trim(),
});
const validateEvent = (event: RawEvent & { date: string }) => ({
userId: event.userId,
date: event.date,
action: event.action,
isValid: Boolean(event.userId && event.action && event.date),
});
// 파이프라인
function processEvents(events: RawEvent[]): ProcessedEvent[] {
return events
.map(parseTimestamp)
.map(normalizeAction)
.map(validateEvent)
.filter((e) => e.isValid);
}
16.2 API 핸들러
// 함수형 API 핸들러
type Handler<T> = (req: Request) => Promise<Either<AppError, T>>;
interface AppError {
code: number;
message: string;
}
const parseBody = <T>(req: Request): Either<AppError, T> => {
try {
const body = JSON.parse(req.body as string) as T;
return right(body);
} catch {
return left({ code: 400, message: "Invalid JSON body" });
}
};
const validateCreateUser = (data: unknown): Either<AppError, CreateUserDto> => {
const dto = data as CreateUserDto;
if (!dto.email) return left({ code: 400, message: "Email is required" });
if (!dto.name) return left({ code: 400, message: "Name is required" });
return right(dto);
};
// 핸들러 합성
const createUserHandler: Handler<User> = async (req) => {
return parseBody<CreateUserDto>(req)
.flatMap(validateCreateUser)
.flatMap((dto) => {
// DB 저장은 실제로는 비동기 Either 필요
const user = { id: "new_id", ...dto };
return right(user);
});
};
16.3 유효성 검사 파이프라인
# Python — 함수형 유효성 검사
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class ValidationError:
field: str
message: str
@dataclass(frozen=True)
class ValidationResult:
errors: tuple[ValidationError, ...]
is_valid: bool
@staticmethod
def success() -> "ValidationResult":
return ValidationResult(errors=(), is_valid=True)
@staticmethod
def failure(field: str, message: str) -> "ValidationResult":
return ValidationResult(
errors=(ValidationError(field, message),),
is_valid=False,
)
def combine(self, other: "ValidationResult") -> "ValidationResult":
if self.is_valid and other.is_valid:
return ValidationResult.success()
return ValidationResult(
errors=self.errors + other.errors,
is_valid=False,
)
# 유효성 검사 함수 타입
Validator = Callable[[dict], ValidationResult]
def required(field: str) -> Validator:
def validate(data: dict) -> ValidationResult:
value = data.get(field)
if not value or (isinstance(value, str) and not value.strip()):
return ValidationResult.failure(field, f"{field} is required")
return ValidationResult.success()
return validate
def min_length(field: str, length: int) -> Validator:
def validate(data: dict) -> ValidationResult:
value = data.get(field, "")
if len(str(value)) < length:
return ValidationResult.failure(
field, f"{field} must be at least {length} characters"
)
return ValidationResult.success()
return validate
def email_format(field: str) -> Validator:
def validate(data: dict) -> ValidationResult:
import re
value = data.get(field, "")
pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$'
if not re.match(pattern, str(value)):
return ValidationResult.failure(field, f"{field} must be a valid email")
return ValidationResult.success()
return validate
# 검증기 합성
def validate_all(*validators: Validator) -> Validator:
def validate(data: dict) -> ValidationResult:
result = ValidationResult.success()
for validator in validators:
result = result.combine(validator(data))
return result
return validate
# 사용
validate_user = validate_all(
required("name"),
required("email"),
email_format("email"),
required("password"),
min_length("password", 8),
)
data = {"name": "", "email": "invalid", "password": "short"}
result = validate_user(data)
for error in result.errors:
print(f"{error.field}: {error.message}")
# name: name is required
# email: email must be a valid email
# password: password must be at least 8 characters
17. 퀴즈
Q1. 순수 함수의 두 가지 조건은?
- 결정적(Deterministic): 같은 입력에 항상 같은 출력을 반환한다.
- 부수효과 없음(No Side Effects): 외부 상태를 변경하지 않고, 외부 상태에 의존하지 않는다(시간, 난수, I/O, 전역 변수 등).
이 두 조건을 만족하면 참조 투명성(Referential Transparency)이 보장되어 메모이제이션, 병렬 실행, 지연 평가가 안전하게 가능하다.
Q2. Maybe 모나드와 Either 모나드의 차이점은?
Maybe(Option)는 값이 있거나 없는 두 가지 경우만 표현한다. 값이 없는 이유를 알 수 없다.
Either(Result)는 성공 값 또는 에러 값을 표현한다. Left에 에러 정보를 담아 실패 원인을 전달할 수 있다. 에러 처리가 필요한 경우 Either, 단순히 값 유무만 확인할 때 Maybe를 사용한다.
Q3. 커링(Currying)과 부분 적용(Partial Application)의 차이는?
커링: f(a, b, c)를 f(a)(b)(c) 형태로 변환. 인자를 항상 하나씩만 받는 함수 체인이 된다.
부분 적용: f(a, b, c)에서 일부 인자만 고정하여 g(c) = f(fixed_a, fixed_b, c)를 만든다. 한 번에 여러 인자를 고정할 수 있다.
커링은 부분 적용의 특수한 형태(한 번에 하나씩)이다.
Q4. 구조적 공유(Structural Sharing)란?
불변 데이터 구조에서 데이터를 업데이트할 때, 변경되지 않은 부분은 이전 버전과 같은 메모리를 참조(공유)하는 기법이다. 이를 통해 불변 업데이트의 메모리 사용량과 복사 비용을 최소화한다. Immer.js, Immutable.js, Clojure의 persistent data structure 등이 이 방법을 사용한다.
Q5. FP와 OOP 중 하나를 선택해야 한다면?
선택하지 않아도 된다. 현대적인 접근은 두 패러다임을 상황에 맞게 조합하는 것이다.
- 데이터 변환, 유효성 검사, 비즈니스 규칙: FP 스타일 (순수 함수, 파이프라인, Either)
- 상태가 있는 엔티티, 외부 시스템 상호작용: OOP 스타일 (캡슐화, 인터페이스)
- 에러 처리: Either/Result (FP), try-catch와 혼합 가능
- 동시성: 불변 데이터 (FP) 선호
TypeScript, Python, Kotlin, Scala, Rust 모두 두 패러다임을 지원한다.
18. 참고 자료
- Hughes, J. — "Why Functional Programming Matters" (1989)
- Milewski, B. — Category Theory for Programmers — https://bartoszmilewski.com/
- fp-ts Documentation — https://gcanti.github.io/fp-ts/
- Effect Documentation — https://effect.website/
- Python returns — https://returns.readthedocs.io/
- Rust Book — Functional Features — https://doc.rust-lang.org/book/ch13-00-functional-features.html
- Wlaschin, S. — Domain Modeling Made Functional
- Frisby, B. — Professor Frisby's Mostly Adequate Guide to FP — https://mostly-adequate.gitbook.io/
- Haskell Wiki — Monad — https://wiki.haskell.org/Monad
- Martin, R.C. — Functional Design: Principles, Patterns, and Practices
- TypeScript Handbook — Discriminated Unions — https://www.typescriptlang.org/docs/handbook/2/narrowing.html
- Immer.js — https://immerjs.github.io/immer/
- toolz — https://toolz.readthedocs.io/