
FP랑 OOP, 에러랑 상태를 이렇게 다르게 본다고?
개발을 하다 보면 이런 고민이 생깁니다.
“에러는 어떻게 다루는 게 깔끔하지?”,
“상태는 어디에 두는 게 맞을까?”,
“API 호출 같은 부수 효과는 함수형 코드 안에서 어떻게 처리하지?”
저 역시 프로젝트를 진행하면서 함수형 프로그래밍(FP)과 객체지향 프로그래밍(OOP)을 오가며 이런 문제들을 반복해서 마주하게 되었습니다.
겉으로는 문법의 차이 같지만, 조금 더 들여다보면 ‘코드를 바라보는 철학’ 자체가 다르다는 걸 깨닫게 됩니다.
예를 들어 같은 문제를 마주해도, 두 패러다임은 접근 방식부터 다릅니다.
FP에서는 함수의 입력과 출력만 바라보며 ‘데이터의 흐름’을 설계하고,
OOP에서는 객체 간의 관계와 ‘책임의 분리’를 고민하게 되죠.
둘 다 올바른 접근이지만, 생각하는 축이 다르다 보니
같은 기능을 구현해도 코드의 형태나 버그가 발생하는 지점이 완전히 달라집니다.
그래서 저는 자연스럽게 이런 질문으로 이어졌습니다.
FP는 데이터를 어떻게 다뤄서 예측 가능한 걸까?
OOP는 책임을 어떻게 나누길래 확장이 쉬운 걸까?
1. 에러 처리 — “예측 가능한 실패”와 “위임된 예외”
에러를 어떻게 다루느냐는 각 패러다임의 ‘사고 방식’을 가장 잘 드러냅니다.
함수형: 실패를 타입으로 다룬다
FP는 함수가 실패할 수도 있다는 사실을 타입으로 명시합니다.
즉, 성공(Right)과 실패(Left) 모두를 반환 타입에서 표현하죠.
import { Either, left, right, fold } from 'fp-ts/Either'
const safeDivide = (a: number, b: number): Either<Error, number> =>
b === 0 ? left(new Error('Division by zero')) : right(a / b)
fold<Error, number, void>(
(error) => console.error('FP Error:', error.message),
(result) => console.log('FP Success:', result)
)(safeDivide(10, 0))
함수형에서는 예외를 숨기지 않습니다. 실패를 데이터로 다루기 때문에, 호출자는 성공과 실패를 모두 처리해야 합니다. 그 덕분에 런타임 예외가 줄고, 함수의 동작을 예측 가능한 형태로 모델링할 수 있습니다.
객체지향: 실패 처리 책임을 분리한다
반면 OOP에서는 예외를 던지고(throw), 호출자가 try/catch로 처리합니다.
const divide = (a: number, b: number): number => {
if (b === 0) throw new Error('Division by zero')
return a / b
}
try {
const result = divide(10, 0)
console.log('OOP Success:', result)
} catch (error: any) {
console.error('OOP Error:', error.message)
}
이 방식은 직관적이고, 에러 처리 책임을 계층별로 나눌 수 있습니다.
비즈니스 로직은 예외만 던지고, 상위 계층(컨트롤러, 미들웨어)에서 일괄 처리하죠.
이 덕분에 새로운 에러 타입을 추가하거나 처리 방식을 바꿀 때, 비즈니스 로직을 건드리지 않아도 됩니다.
FP는 "에러를 구조 안으로 끌어들여 명시"하려 하고,
OOP는 "에러 처리 책임을 계층별로 분리"하려 합니다.
2. 상태 관리 — “불변 데이터”와 “캡슐화된 객체”
다음 차이는 ‘상태’를 어디서, 어떻게 다루는가입니다.
함수형: 불변 상태와 순수 함수
FP는 “같은 입력에는 같은 결과”라는 순수 함수 원칙을 지킵니다. 데이터를 직접 바꾸지 않고, 새로운 값을 반환하죠.
const numbers = [1, 2, 3, 4, 5]
const result = numbers
.filter((n) => n % 2 === 0)
.map((n) => n * 10)
.reduce((sum, n) => sum + n, 0)
console.log(result) // 60
상태가 불변이기 때문에, 코드의 흐름이 명확하고 테스트가 쉽습니다. 한 번 만들어진 값은 어디서도 바뀌지 않기 때문이죠.
객체지향: 상태와 행동을 하나의 책임으로 묶는다
OOP는 상태를 캡슐화하고, 객체가 그 상태를 책임지도록 설계합니다.
class NumberOps {
constructor(private value: number) {}
increment(): this {
this.value += 1
return this
}
double(): this {
this.value *= 2
return this
}
getValue(): number {
return this.value
}
}
console.log(new NumberOps(3).increment().double().getValue()) // 8
이 접근의 강점은 상태 변경 로직을 한 곳에 모을 수 있다는 점입니다.
나중에 상태 검증 로직을 추가하거나 변경 방식을 바꿔도, 외부 코드는 영향받지 않죠.
새로운 연산이 필요하면 메서드만 추가하면 되고, 기존 코드는 그대로 유지됩니다.
FP는 "데이터의 흐름"을 설계하고,
OOP는 "데이터의 책임자"를 설계합니다.
3. 부수 효과 — “순수함과 현실의 타협”
함수형의 순수성은 현실의 코드에서는 쉽게 깨집니다. 왜냐하면 대부분의 애플리케이션은 I/O에 의존하기 때문입니다. API 요청, 파일 읽기, DB 접근 등은 모두 부수 효과를 동반합니다.
FP는 이 불가피한 부수 효과를 없애려 하지 않습니다. 대신 명시적으로 감싸서 제어 가능한 형태로 모델링합니다. TaskEither 같은 타입이 그 예시입니다.
import { TaskEither, tryCatch } from 'fp-ts/TaskEither'
interface Todo {
id: number
title: string
}
const fetchTodo = (id: number): TaskEither<Error, Todo> =>
tryCatch(
() =>
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then((res) => {
if (!res.ok) throw new Error('Network error')
return res.json()
}),
(reason) => new Error(String(reason))
)
이렇게 하면 함수의 타입만 봐도 “이 함수는 네트워크 I/O를 포함한다”는 사실을 알 수 있습니다. 즉, FP는 부수 효과를 코드의 ‘표면’으로 드러내는 철학을 가집니다.
그리고 이 사고가 발전하면, 자연스럽게 ‘모나드’라는 개념으로 이어집니다. 모나드는 “부수 효과를 제어 가능한 흐름으로 만드는 도구”이자, FP의 예측 가능성 철학이 가장 응축된 형태입니다.
4. 모나드 — “부수 효과를 다루는 추상화”
모나드는 종종 어렵게 설명되지만, 본질은 단순합니다.
“값을 안전하게 감싸고, 그 안에서 연산을 연결할 수 있게 해주는 구조”
쉽게 말하면, ‘안전한 데이터 흐름’을 위한 상자입니다.
class Maybe<T> {
constructor(private value: T | null) {}
static of<T>(value: T): Maybe<T> {
return new Maybe(value)
}
map<U>(fn: (v: T) => U): Maybe<U> {
if (this.value === null || this.value === undefined) return new Maybe<U>(null)
return new Maybe<U>(fn(this.value))
}
getValue(): T | null {
return this.value
}
}
const result = Maybe.of(5)
.map((n) => n + 1)
.map((n) => n * 2)
console.log(result.getValue()) // 12
모나드는 값이 없거나 에러가 있어도 안전하게 연산을 이어갈 수 있게 합니다. 즉, 예측 가능성을 유지하면서 현실적인 부수 효과를 제어하는 패턴이죠. 이건 FP의 핵심 원칙인 “명시적 제어”를 가장 잘 구현한 도구입니다.
5. 결론 — "패러다임의 싸움이 아니라, 관점의 차이"
처음 던진 질문으로 돌아가 보겠습니다.
FP는 데이터를 어떻게 다뤄서 예측 가능한 걸까?
FP는 불변성과 명시성으로 데이터를 다룹니다.Either로 감싼 에러, TaskEither로 감싼 부수 효과—모두 타입에 드러나기 때문에,
함수의 시그니처만 봐도 동작을 예측할 수 있죠.
모나드는 이런 철학의 극대화입니다. 부수 효과를 감추지 않고, 제어 가능한 흐름으로 모델링하는 도구.
OOP는 책임을 어떻게 나누길래 확장이 쉬운 걸까?
OOP는 데이터와 행동을 하나의 책임 단위로 묶고, 인터페이스로 경계를 나눕니다.
에러 처리는 계층별로 분리하고, 상태 변경 로직은 객체 내부에 캡슐화하죠.
이 덕분에 새로운 기능을 추가하거나 기존 로직을 변경할 때,
인터페이스만 지키면 다른 부분을 건드리지 않아도 됩니다.
FP와 OOP는 종종 "무엇이 더 낫냐"의 문제로 이야기되지만, 사실은 동일한 문제를 서로 다른 언어로 푸는 사고법입니다.
- FP는 불변성과 명시성으로 예측 가능한 시스템을 만들고,
- OOP는 책임 분리와 캡슐화로 확장 가능한 구조를 만듭니다.
결국 중요한 건 "패러다임"이 아니라, 에러·상태·부수 효과를 어떤 사고방식으로 다루느냐입니다.
FP든 OOP든, 각자의 철학을 이해하고 상황에 맞게 섞어 쓸 수 있다면 복잡도와 안정성 사이의 균형을 잡을 수 있을 겁니다.
그리고 이건 단순히 코드 스타일의 문제가 아니라, 개발자가 세상을 모델링하는 방식의 차이라고 생각합니다.
FP는 '데이터가 어떻게 흘러야 하는가'를 설계하고,
OOP는 '그 데이터를 누가 책임질 것인가'를 설계하죠.
저는 요즘 이 둘을 경쟁 관계가 아니라 서로를 보완하는 관점의 프레임으로 바라보려 합니다.
코드는 결국 문제를 푸는 언어니까요.
중요한 건 어떤 철학으로 접근하든, 그 철학이 코드 안에서 일관되게 살아있는가—그 한 가지뿐입니다.