LabHub

블로그

함수형 프로그래밍의 실용 가치 심화 가이드 — Monad, Functor, 순수 함수, Haskell, Erlang/Elixir, Gleam, Effect-TS, Phoenix LiveView까지 (2025)

한국어English日本語

TL;DR — 함수형 프로그래밍은 '수학자의 취미'가 아니라 동시성과 분산의 시대의 필수 사고 방식이 됐다. React Hooks는 함수형 상태 관리, Redux는 reduce, RxJS는 Observable Monad, Swift/Rust/Kotlin의 Option/Result는 Maybe/Either Monad, Erlang/Elixir는 99.9999999%(nine nines) 가용성을 순수 함수 + 액터 모델로 달성한다. 2024-2025년 실무에서는 Effect-TS(TypeScript), Gleam(BEAM 위 타입 기반 언어), Elixir + Phoenix LiveView, Roc(Elm 후계자), Unison(분산 순수 언어) 같은 새로운 세대가 부상했다. 이 글은 '부작용 관리'라는 함수형의 본질이 왜 클라우드 시대에 더 중요해졌는지, Monad를 범주론 없이 어떻게 이해하는지, 그리고 현대 언어들이 함수형으로부터 무엇을 배웠는지를 추적한다.

왜 지금 다시 함수형인가

함수형 프로그래밍(FP)은 1958년 LISP로 시작해 1980-90년대 Haskell, ML 계열로 꽃 피웠지만, 산업계에서는 '학계의 언어' 취급을 받아왔다. 그런데 2010년대 중반부터 상황이 바뀌었다:

왜 지금? 3가지 이유:

  1. 동시성 — Cloud 시대의 수만 동시 요청 + 락/상태 공유의 지옥 → 불변성과 메시지 패싱이 해법
  2. 신뢰성 — 분산 시스템의 부분 실패(partial failure) → "let it crash" 철학의 재평가
  3. 타입 안전성 — 함수형의 대수적 타입(sum types, pattern matching)이 버그 감소에 결정적

순수 함수 — 모든 것의 출발점

순수 함수(Pure Function): 같은 입력 → 항상 같은 출력, 부작용 없음.

// 순수
function add(a, b) { return a + b }
function double(arr) { return arr.map(x => x * 2) }

// 불순 (부작용)
let counter = 0
function incCounter() { counter++; return counter }  // 외부 상태 변경

function logAndAdd(a, b) { 
  console.log(a, b)  // I/O 부작용
  return a + b 
}

function fetchUser(id) {
  return fetch(`/api/users/${id}`)  // 네트워크 부작용
}

순수 함수의 실전 가치

  1. 테스트 용이 — 입력만 주면 재현 가능, mock 불필요
  2. 병렬화 가능 — 공유 상태가 없어 스레드 안전 기본
  3. 메모이제이션 가능 — 같은 입력은 캐시 가능 (React.memo, useMemo)
  4. 추론 가능 — 코드를 읽는 사람이 "전역 상태 변경이 없다"고 믿을 수 있음
  5. 재구성 가능 — 조합, 합성(compose)이 예측 가능

참조 투명성(Referential Transparency): 함수 호출을 그 결과값으로 치환해도 프로그램 의미가 변하지 않음. 순수 함수의 다른 이름.

부작용은 사라지지 않는다

현실의 프로그램은 I/O, DB, 네트워크 없이 못 돈다. 함수형의 핵심 통찰은 부작용을 없애는 게 아니라 경계에 모으고 타입으로 표현하는 것.

불변성 — 변경을 금지하는 설계

// 가변 (Mutable)
const arr = [1, 2, 3]
arr.push(4)  // arr 자체 변경
const obj = { a: 1 }
obj.a = 2  // obj 자체 변경

// 불변 (Immutable)
const arr2 = [1, 2, 3]
const arr3 = [...arr2, 4]  // 새 배열
const obj2 = { a: 1 }
const obj3 = { ...obj2, a: 2 }  // 새 객체

왜 불변이 중요한가

  1. 변경 추적 용이 — React의 "prev === next" 비교가 O(1)
  2. 동시성 안전 — 여러 스레드가 동시에 읽어도 문제없음
  3. 시간 여행 디버깅 — Redux DevTools가 가능한 이유
  4. Undo/Redo 간단 — 과거 상태를 그냥 유지
  5. Memoization 쉬움 — 같은 참조 = 같은 값 (일반적으로)

Persistent Data Structure — 불변의 성능 비용 해결

순진한 불변은 매 변경마다 전체 복사 → O(n). Persistent Data Structure는 구조 공유(structural sharing)로 O(log n) 달성.

// Immer 사용 예
import { produce } from 'immer'

const state = { users: [{ id: 1, name: 'Alice' }] }
const nextState = produce(state, draft => {
  draft.users.push({ id: 2, name: 'Bob' })
  draft.users[0].name = 'Alice Updated'
})
// state는 불변 유지, nextState는 새 객체

고차 함수 — 함수를 값으로

// map, filter, reduce는 고차 함수
[1, 2, 3].map(x => x * 2)           // [2, 4, 6]
[1, 2, 3].filter(x => x > 1)        // [2, 3]
[1, 2, 3].reduce((acc, x) => acc + x, 0)  // 6

// 함수 합성 (compose)
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x)
const toString = x => x.toString()
const exclaim = s => s + '!'
const shout = s => s.toUpperCase()

const greet = compose(shout, exclaim, toString)
greet(42)  // '42!'

// Curry — 부분 적용
const curry = (fn) => {
  return function curried(...args) {
    if (args.length >= fn.length) return fn(...args)
    return (...more) => curried(...args, ...more)
  }
}

const add = curry((a, b, c) => a + b + c)
add(1)(2)(3)  // 6
add(1, 2)(3)  // 6

파이프라인 — 좌에서 우로

// |> 연산자 (TC39 Stage 2 제안)
const result = users
  |> (xs => xs.filter(u => u.active))
  |> (xs => xs.map(u => u.email))
  |> (xs => xs.join(', '))

// 현재 가능한 패턴 — pipe 함수
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x)

const result = pipe(
  filter(u => u.active),
  map(u => u.email),
  xs => xs.join(', ')
)(users)

Elixir는 |> 연산자가 언어 내장:

users
|> Enum.filter(& &1.active)
|> Enum.map(& &1.email)
|> Enum.join(", ")

Option / Maybe — null 없는 세계

Tony Hoare가 null을 "billion-dollar mistake"라 부른 이유: NullPointerException. 함수형 언어는 Option 타입으로 '값 없음'을 타입으로 표현.

// Rust
fn find_user(id: u64) -> Option<User> {
    if id == 0 { None } else { Some(User { id, name: "Alice".into() }) }
}

match find_user(42) {
    Some(user) => println!("{}", user.name),
    None => println!("not found"),
}

// 연쇄
find_user(42)
    .map(|u| u.name)
    .unwrap_or("anonymous".into())
// TypeScript + fp-ts
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'

const findUser = (id: number): O.Option<User> =>
  id === 0 ? O.none : O.some({ id, name: 'Alice' })

const result = pipe(
  findUser(42),
  O.map(u => u.name),
  O.getOrElse(() => 'anonymous')
)
-- Haskell
findUser :: Int -> Maybe User
findUser 0 = Nothing
findUser id = Just User { userId = id, userName = "Alice" }

main = do
  case findUser 42 of
    Just u -> putStrLn (userName u)
    Nothing -> putStrLn "not found"

Result / Either — 실패를 타입으로

예외는 함수 시그니처에 드러나지 않아 '보이지 않는 제어 흐름'을 만든다. Result / Either는 성공/실패를 타입으로 표현.

// Rust
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 { Err("division by zero".into()) }
    else { Ok(a / b) }
}

let result = divide(10.0, 2.0)?;   // ? 연산자로 에러 자동 전파
// Effect-TS (2024)
import { Effect } from 'effect'

const divide = (a: number, b: number): Effect.Effect<never, string, number> =>
  b === 0 
    ? Effect.fail("division by zero")
    : Effect.succeed(a / b)

const program = Effect.gen(function* () {
  const x = yield* divide(10, 2)
  const y = yield* divide(x, 0)  // 실패
  return x + y
})

// 실행 시 Effect.runPromise
Effect.runPromiseExit(program).then(console.log)
// { _tag: 'Failure', cause: { ... 'division by zero' ... } }

Monad — 도대체 뭔가

Monad는 함수형의 악명 높은 개념. 범주론에서 비롯했지만, 실무 관점에서는 '연쇄 가능한 wrapping 타입'으로 이해하면 충분.

정의 (실용적)

Monad는 다음 두 연산을 제공하는 타입:

  1. of(x) — 값 x를 Monad로 감싸기 (unit, pure, return)
  2. flatMap(f) — Monad 안의 값에 함수 f 적용, f는 Monad 반환 (bind, >>=)
// Maybe Monad (의사 코드)
type Maybe<T> = { tag: 'some', value: T } | { tag: 'none' }

const of = <T>(x: T): Maybe<T> => ({ tag: 'some', value: x })

const flatMap = <A, B>(m: Maybe<A>, f: (a: A) => Maybe<B>): Maybe<B> =>
  m.tag === 'some' ? f(m.value) : m

// 사용
const parseInt = (s: string): Maybe<number> => {
  const n = Number(s)
  return isNaN(n) ? { tag: 'none' } : { tag: 'some', value: n }
}

const addTen = (n: number): Maybe<number> => of(n + 10)

const result = flatMap(parseInt("42"), addTen)  // { tag: 'some', value: 52 }
const result2 = flatMap(parseInt("abc"), addTen)  // { tag: 'none' }

왜 Monad인가

Monad는 부작용을 타입으로 표현하고 순차 처리하는 도구. 다양한 '부작용'이 같은 인터페이스를 따르게 만든다:

Monad Laws (실전은 안 외워도 되지만 이해는 있어야)

  1. Left Identity: of(x).flatMap(f) === f(x)
  2. Right Identity: m.flatMap(of) === m
  3. Associativity: m.flatMap(f).flatMap(g) === m.flatMap(x => f(x).flatMap(g))

이 법칙이 지켜져야 '연쇄 연산'이 기대대로 동작.

do-notation — Monad 체인을 쉽게

Haskell:

-- flatMap 체인을 do-notation으로
main = do
  line <- getLine
  let n = read line :: Int
  print (n * 2)

-- 탈당한(풀어쓴) 형태
main = getLine >>= \line -> 
         let n = read line :: Int in
         print (n * 2)

TypeScript (Effect-TS):

const program = Effect.gen(function* () {
  const user = yield* fetchUser(1)
  const posts = yield* fetchPosts(user.id)
  return { user, posts }
})

JavaScript async/await도 Promise Monad의 do-notation이다!

Functor, Applicative, Monad — 3층 구조

함수형 타입 클래스(type class)의 계층:

// Functor — Maybe<number> + (number => string) → Maybe<string>
map(Some(42), x => x.toString())  // Some("42")

// Applicative — Maybe<number> + Maybe<number> + ((a, b) => c) → Maybe<c>
ap(Some((a: number) => (b: number) => a + b), Some(1), Some(2))  // Some(3)

// Monad — Maybe<number> + (number => Maybe<string>) → Maybe<string>
flatMap(Some(42), x => x > 0 ? Some(x.toString()) : None)

Haskell — "수학자의 언어"의 교훈

Haskell은 1990년에 탄생한 pure, lazy, statically-typed 함수형 언어. 실무에는 니치하지만, 현대 언어들에 준 영향은 거대하다.

Haskell이 만든 혁신

-- 피보나치 무한 리스트 (지연 평가)
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
take 10 fibs  -- [0,1,1,2,3,5,8,13,21,34]

-- 타입 클래스 정의
class Eq a where
  (==) :: a -> a -> Bool

instance Eq Int where
  x == y = primEqInt x y

-- ADT
data Shape = Circle Double | Rectangle Double Double | Triangle Double Double Double

area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
area (Triangle a b c) = sqrt (s * (s-a) * (s-b) * (s-c))
  where s = (a + b + c) / 2

Haskell의 실무 사용처

Haskell은 '배우기 어렵지만 쓰면 좋은 언어'의 표본. 성능과 안정성이 필요한 좁은 도메인에서 여전히 강력.

Erlang/Elixir — "99.9999999%" 가용성

1986년 Ericsson이 전화 교환기용으로 만든 Erlang. 2012년 José Valim이 만든 Elixir는 Erlang VM(BEAM) 위에 Ruby 문법을 얹은 언어.

BEAM의 4가지 초능력

  1. Lightweight Process — 각 프로세스 300-400 바이트, 수백만 개 동시 실행 가능
  2. Preemptive Scheduler — 멀티코어에 자동 분산, GC 개별 프로세스
  3. Message Passing — shared memory 없음, 메시지로만 통신 (액터 모델)
  4. Hot Code Swap — 런타임에 코드 교체, 다운타임 없이 업그레이드

"Let It Crash" 철학

defmodule Worker do
  use GenServer

  def handle_call(:dangerous_op, _from, state) do
    # 이 연산이 실패하면...
    result = dangerous_call()
    {:reply, result, state}
  end
end

# Supervisor가 크래시 감지 → 자동 재시작
defmodule MyApp.Supervisor do
  use Supervisor

  def init(_) do
    children = [
      {Worker, []},
      # 1분에 3번 이상 죽으면 최상위로 escalate
    ]
    Supervisor.init(children, strategy: :one_for_one, max_restarts: 3, max_seconds: 60)
  end
end

원칙: 방어적 코드를 쓰지 말고, 실패하게 두고, Supervisor가 재시작. 이것이 OTP(Open Telecom Platform)의 핵심.

Phoenix + LiveView — "WebSocket으로 React 없이 실시간 UI"

defmodule MyAppWeb.CounterLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  def handle_event("inc", _value, socket) do
    {:noreply, assign(socket, count: socket.assigns.count + 1)}
  end

  def render(assigns) do
    ~H"""
    <div>
      <p>Count: <%= @count %></p>
      <button phx-click="inc">+1</button>
    </div>
    """
  end
end

서버가 WebSocket으로 DOM diff를 푸시. React 없이 SPA 수준 UX. Fly.io, Supabase Realtime, Discord가 대규모로 운용.

실사용 사례

Gleam — 2024년 1.0, BEAM 위 타입 언어

Elixir가 동적 타입이라면, Gleam(Louis Pilfold, 2016-)은 정적 타입 + BEAM. 2024년 3월 1.0 릴리스.

import gleam/io

pub type Shape {
  Circle(radius: Float)
  Square(side: Float)
}

pub fn area(shape: Shape) -> Float {
  case shape {
    Circle(r) -> 3.14159 *. r *. r
    Square(s) -> s *. s
  }
}

pub fn main() {
  let c = Circle(radius: 5.0)
  io.debug(area(c))
}

특징:

2024-2025년 BEAM 진영의 뜨거운 언어.

Clojure, F#, Scala, OCaml — 하이브리드 함수형

Clojure

(defn add [a b] (+ a b))
(->> [1 2 3 4 5]
     (filter odd?)
     (map #(* % %))
     (reduce +))  ; 35

F#

Scala

OCaml

React, Redux, RxJS — JavaScript의 함수형 상속

React의 핵심 아이디어는 UI = f(state). 순수 함수로 UI를 모델링.

// React 컴포넌트는 pure function of props
function Greeting({ name }) {
  return <h1>Hello, {name}</h1>
}

// useState는 immutable update
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

Redux는 (state, action) => state 정확히 reduce:

function reducer(state, action) {
  switch (action.type) {
    case 'INC': return { ...state, count: state.count + 1 }
    case 'DEC': return { ...state, count: state.count - 1 }
    default: return state
  }
}

RxJS는 Observable Monad — 시간에 따른 값의 스트림:

const click$ = fromEvent(button, 'click').pipe(
  map(e => e.clientX),
  filter(x => x > 100),
  debounceTime(300)
)

click$.subscribe(x => console.log(x))

Effect-TS — TypeScript의 Haskell

2024년 1.0 릴리스. Scala ZIO의 TypeScript 판.

import { Effect, pipe, Layer } from 'effect'

// Effect<R, E, A>
// R: 필요한 서비스(dependency)
// E: 에러 타입
// A: 성공 값

interface Logger {
  log: (msg: string) => Effect.Effect<never, never, void>
}
const Logger = Context.Tag<Logger>()

const program = Effect.gen(function* () {
  const logger = yield* Logger
  yield* logger.log("starting...")
  const user = yield* fetchUser(1)
  yield* logger.log(`got user ${user.name}`)
  return user
})

// 실행 시 Layer 주입
const LoggerLive = Layer.succeed(Logger, {
  log: (msg) => Effect.sync(() => console.log(msg))
})

Effect.runPromise(pipe(program, Effect.provide(LoggerLive)))

특징:

동시성 모델들

Actor 모델 (Erlang/Elixir/Akka)

CSP (Go, Clojure core.async)

Software Transactional Memory (STM)

Promise/Future (JavaScript, Rust, Python, Swift)

2024-2025 주목할 함수형 언어

Roc (Richard Feldman, Elm 후계자)

Unison

Koka (Daan Leijen, Microsoft)

Lean 4

10가지 흔한 안티패턴

  1. Monad 지옥 — 모든 것을 Monad로 감싸 가독성 파괴. 경계에서만 써라.
  2. Pure purity — I/O 전부 회피하려다 비실용적. 부작용 경계에 모아라.
  3. reduce 남발 — 복잡한 reducer는 가독성 파괴. 여러 개의 map/filter로 쪼개라.
  4. Lazy evaluation 오남용 — 디버그 어려움, 메모리 누수.
  5. Curry 강요 — JS/TS에서 모든 함수를 curry하면 IDE 타입 힌트 망가짐.
  6. Immutable 강박 — 로컬 변수는 가변이어도 됨. 경계만 불변 유지.
  7. 고계 함수 체이닝 길이 과도 — 10단계 .map.filter.reduce는 명령형이 더 읽힘.
  8. Monad transformer stack — 4-5층 되면 Effect-TS/Koka 같은 대안 고려.
  9. Actor 모델 오해 — 모든 것을 Actor로 만들면 오버엔지니어링. CPU 집약 작업은 데이터 지향.
  10. Dynamic FP 착각 — 타입 없이 FP 하면 Monad 체이닝이 지옥. 타입 있는 언어에서 시작.

실전 학습 체크리스트 (2025)

  1. map/filter/reduce 체인을 자유자재로 — 모든 언어에서
  2. Option/Result 타입 사용 — Rust, Swift, Kotlin부터
  3. Pattern Matching + ADT — TS discriminated union, Rust enum
  4. 불변 데이터 구조 선택 — Immer, Immutable.js, Clojure, Scala
  5. Promise chain ≒ Monad 이해
  6. RxJS 기본 operators — map, filter, mergeMap, switchMap
  7. React Hooks의 함수형 본질 — useMemo, useCallback = memoization
  8. Actor 또는 CSP 패턴 — Elixir GenServer 또는 Go channel
  9. Effect-TS 또는 fp-ts — 복잡한 TS 앱에서 실험
  10. 하스켈 단기 탐험 — 2주 튜토리얼, 사고 방식 확장
  11. Elixir + Phoenix LiveView — 실시간 UI 경험
  12. Monad 법칙 이해(암기 말고 직관)

다음 글 예고 — "Kubernetes는 왜 이렇게 복잡한가" — 클러스터 운영의 진짜 비용, Service Mesh, GitOps, Platform Engineering 심화

함수형 프로그래밍이 '개별 코드의 신뢰성'을 높인다면, Kubernetes와 Cloud Native는 '시스템 전체의 신뢰성'을 다룬다. 2014년 Google이 내부 Borg 경험을 오픈소스로 공개한 이후, K8s는 사실상 클라우드의 운영 체제가 됐다. 하지만 그 복잡도는 악명 높다.

다음 글에서는:

을 다룬다. 'Kubernetes는 정말 필요한가'라는 본질적 질문부터 실전 운영 레시피까지, 클라우드 네이티브 세계의 전 지형도를 정리한다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다