객체지향 vs 함수형 프로그래밍: 설계 방식의 차이와 선택 기준
이 글의 핵심
같은 기능을 클래스로 짤지 함수 조합으로 짤지는 취향이 아니라 상태를 어디에 두고 변경을 어떻게 다룰지의 문제입니다. 두 패러다임의 장단점을 비교표로 정리하고, 도메인 모델은 객체로, 데이터 변환은 함수형으로 나누는 식의 혼합 설계가 실무에서 자연스러운 이유를 코드로 보여 줍니다.
들어가며: 프로그래밍 패러다임이란?
프로그래밍 패러다임은 “코드를 어떻게 구조화하고 문제를 어떻게 접근할 것인가”에 대한 철학입니다.
두 패러다임을 가르는 핵심 질문은 “상태를 어디에 두고, 누가 바꿀 수 있는가”입니다. 객체지향은 상태를 객체 안에 숨기고 메서드를 통해서만 바꾸게 해 변경 경로를 통제하고, 함수형은 상태 변경 자체를 줄이고 값을 받아 새 값을 돌려주는 함수들의 조합으로 프로그램을 만듭니다. 어느 한쪽이 옳다기보다 다루는 문제의 성격에 따라 유리한 쪽이 달라지므로, 이 글은 같은 문제를 두 방식으로 풀어 보며 그 차이를 비교합니다.
객체지향 프로그래밍 (OOP)
OOP의 핵심 개념
객체지향 프로그래밍은 데이터와 그 데이터를 처리하는 메서드를 하나의 객체(Object)로 묶는 방식입니다. OOP의 4대 원칙:
graph TB
A[OOP 4대 원칙] --> B[캡슐화 Encapsulation]
A --> C[상속 Inheritance]
A --> D[다형성 Polymorphism]
A --> E[추상화 Abstraction]
B --> B1[데이터 은닉]
B --> B2[접근 제어]
C --> C1[코드 재사용]
C --> C2[계층 구조]
D --> D1[오버라이딩]
D --> D2[인터페이스]
E --> E1[복잡도 감소]
E --> E2[핵심만 노출]
OOP 예제 (C++)
#include <iostream>
#include <string>
#include <vector>
// 캡슐화: 데이터와 메서드를 하나로
class BankAccount {
private:
std::string owner;
double balance;
public:
// 생성자
BankAccount(const std::string& name, double initial)
: owner(name), balance(initial) {}
// 메서드
void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
bool withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return true;
}
return false;
}
double getBalance() const {
return balance;
}
std::string getOwner() const {
return owner;
}
};
// 상속: 기존 클래스 확장
class SavingsAccount : public BankAccount {
private:
double interestRate;
public:
SavingsAccount(const std::string& name, double initial, double rate)
: BankAccount(name, initial), interestRate(rate) {}
void applyInterest() {
double interest = getBalance() * interestRate;
deposit(interest);
}
};
int main() {
BankAccount account("Alice", 1000.0);
account.deposit(500.0);
account.withdraw(200.0);
std::cout << account.getBalance() << std::endl; // 1300
SavingsAccount savings("Bob", 1000.0, 0.05);
savings.applyInterest();
std::cout << savings.getBalance() << std::endl; // 1050
return 0;
}
OOP 예제 (Python)
# 캡슐화
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance # 이름 맹글링(_BankAccount__balance)으로 외부 접근을 어렵게 함
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
return True
return False
def get_balance(self):
return self.__balance
# 상속
class SavingsAccount(BankAccount):
def __init__(self, owner, balance, interest_rate):
super().__init__(owner, balance)
self.interest_rate = interest_rate
def apply_interest(self):
interest = self.get_balance() * self.interest_rate
self.deposit(interest)
# 사용
account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(account.get_balance()) # 1300
savings = SavingsAccount("Bob", 1000, 0.05)
savings.apply_interest()
print(savings.get_balance()) # 1050
두 예제에서 캡슐화가 실제로 하는 일은 balance를 바꾸는 경로를 deposit과 withdraw 두 곳으로 제한하는 것입니다. 잔액이 음수가 되면 안 된다는 규칙(불변식)을 이 두 메서드에서만 지키면 되므로, 코드가 수십만 줄로 커져도 “어디서 잔액이 이상해졌나”를 찾을 범위가 좁아집니다. 다만 C++와 Python의 강도는 다릅니다. C++의 private는 컴파일러가 강제하지만, Python의 __balance는 _BankAccount__balance로 이름을 바꿔 줄 뿐이라 마음먹으면 외부에서 접근할 수 있고, Python 커뮤니티에서는 보통 밑줄 하나(_balance)로 “내부용”이라는 의도만 표시하는 편을 선호합니다.
상속 예제의 SavingsAccount는 부모의 private 멤버에 직접 접근하지 않고 getBalance()와 deposit()을 거쳐 이자를 더합니다. 이렇게 해야 부모가 나중에 deposit에 로깅이나 한도 검사를 추가해도 자식이 그 규칙을 자동으로 따르게 됩니다. 또 예제는 설명을 위해 double로 금액을 다뤘지만, 실제 금융 코드에서는 0.1 같은 값을 정확히 표현하지 못하는 부동소수점 오차 때문에 정수(원 단위나 센트 단위)나 십진수 타입을 씁니다.
OOP의 장단점
장점:
-
캡슐화: 데이터 은닉, 접근 제어
-
재사용성: 상속으로 코드 재사용
-
유지보수: 모듈화로 변경 영향 최소화
-
직관적: 현실 세계 모델링 단점:
-
복잡도: 과도한 상속 계층
-
상태 관리: 가변 상태로 인한 버그
-
테스트 어려움: 의존성 많으면 목 객체 필요
함수형 프로그래밍 (FP)
FP의 핵심 개념
함수형 프로그래밍은 순수 함수(Pure Function)와 불변성(Immutability)을 강조하는 패러다임입니다. FP의 핵심 원칙:
graph TB
A[함수형 프로그래밍] --> B[순수 함수]
A --> C[불변성]
A --> D[고차 함수]
A --> E[함수 합성]
B --> B1[부작용 없음]
B --> B2[같은 입력 → 같은 출력]
C --> C1[상태 변경 금지]
C --> C2[새 값 생성]
D --> D1[함수를 인자로]
D --> D2[함수를 반환]
E --> E1[작은 함수 조합]
E --> E2[파이프라인]
FP 예제 (JavaScript)
// 순수 함수: 부작용 없음, 같은 입력 → 같은 출력
function add(a, b) {
return a + b;
}
// 비순수 함수: 외부 상태 변경
let total = 0;
function addToTotal(x) {
total += x; // 부작용!
return total;
}
// 불변성: 원본 변경 없이 새 값 생성
const arr = [1, 2, 3];
// 나쁜 예: 원본 변경
arr.push(4); // arr이 [1, 2, 3, 4]로 변경됨
// 좋은 예: 새 배열 생성
const newArr = [...arr, 4]; // arr은 그대로, newArr은 [1, 2, 3, 4]
// 고차 함수: 함수를 인자로 받거나 반환
function map(arr, fn) {
const result = [];
for (const item of arr) {
result.push(fn(item));
}
return result;
}
const doubled = map([1, 2, 3], x => x * 2); // [2, 4, 6]
// 함수 합성
const compose = (f, g) => x => f(g(x));
const addOne = x => x + 1;
const double = x => x * 2;
const addOneThenDouble = compose(double, addOne);
console.log(addOneThenDouble(5)); // (5 + 1) * 2 = 12
FP 예제 (Python)
from functools import reduce
# 순수 함수
def add(a, b):
return a + b
# 고차 함수: map, filter, reduce
numbers = [1, 2, 3, 4, 5]
# map: 각 요소에 함수 적용
doubled = list(map(lambda x: x * 2, numbers))
print(doubled) # [2, 4, 6, 8, 10]
# filter: 조건에 맞는 요소만 선택
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4]
# reduce: 누적 연산
total = reduce(lambda acc, x: acc + x, numbers, 0)
print(total) # 15
# 불변성: 원본 변경 없이 새 값 생성
original = [1, 2, 3]
new_list = original + [4] # 새 리스트 생성
print(original) # [1, 2, 3] (변경 안 됨)
print(new_list) # [1, 2, 3, 4]
# 함수 합성
def compose(f, g):
return lambda x: f(g(x))
add_one = lambda x: x + 1
double = lambda x: x * 2
add_one_then_double = compose(double, add_one)
print(add_one_then_double(5)) # 12
FP 예제 (C++)
#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
#include <functional>
// 순수 함수
int add(int a, int b) {
return a + b;
}
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
// map: transform
std::vector<int> doubled(numbers.size());
std::transform(numbers.begin(), numbers.end(), doubled.begin(),
[](int x) { return x * 2; });
// doubled: {2, 4, 6, 8, 10}
// filter: copy_if
std::vector<int> evens;
std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(evens),
[](int x) { return x % 2 == 0; });
// evens: {2, 4}
// reduce: accumulate
int total = std::accumulate(numbers.begin(), numbers.end(), 0);
std::cout << total << std::endl; // 15
// 함수 합성
auto add_one = [](int x) { return x + 1; };
auto double_it = [](int x) { return x * 2; };
auto compose = [](auto f, auto g) {
return [=](auto x) { return f(g(x)); };
};
auto add_one_then_double = compose(double_it, add_one);
std::cout << add_one_then_double(5) << std::endl; // 12
return 0;
}
세 언어의 예제는 모양이 비슷하지만 “불변성”의 강도는 다릅니다. JavaScript의 const arr는 변수 arr가 다른 배열을 가리키지 못하게 할 뿐, 배열 내용은 arr.push(4)로 얼마든지 바뀝니다. 처음 함수형 스타일을 적용할 때 흔히 하는 오해가 이것이며, 내용까지 고정하려면 Object.freeze(얕은 동결)나 Immutable.js·Immer 같은 라이브러리가 필요합니다. Python도 리스트는 가변이라 튜플이나 frozenset, @dataclass(frozen=True)를 써야 불변이 보장되고, C++는 const로 컴파일러가 수정 자체를 막아 줍니다.
“불변성은 느리다”는 단점도 상황을 나눠 봐야 합니다. 원소 하나를 추가할 때마다 [...arr, x]로 전체를 복사하면 루프 안에서 O(n²)이 되므로, 수만 개 원소를 한 줄씩 쌓는 코드에서는 실제로 체감됩니다. 함수형 언어들은 변경되지 않은 부분을 공유하는 영속 자료구조로 이 비용을 줄이고, 실무 JavaScript에서는 함수 내부에서만 지역 배열을 가변으로 만들고 결과만 불변으로 내보내는 절충을 많이 씁니다. 바깥에서 보기에 입력과 출력만으로 동작한다면, 함수 안에서 지역 변수를 바꾸는 것은 순수성을 해치지 않습니다.
FP의 장단점
장점:
-
테스트 용이: 순수 함수는 입출력만 테스트
-
병렬화 쉬움: 부작용 없어 동시 실행 안전
-
버그 감소: 불변성으로 예측 가능
-
합성 가능: 작은 함수를 조합 단점:
-
학습 곡선: 개념이 추상적
-
성능 오버헤드: 불변성 유지 비용
-
가독성: 과도한 함수 합성 시 읽기 어려움
비교 분석
OOP vs FP 비교표
| 특징 | 객체지향 (OOP) | 함수형 (FP) |
|---|---|---|
| 핵심 개념 | 객체, 클래스, 상속 | 순수 함수, 불변성 |
| 상태 관리 | 가변 상태 (Mutable) | 불변 상태 (Immutable) |
| 데이터와 동작 | 함께 묶음 (캡슐화) | 분리 |
| 코드 재사용 | 상속, 다형성 | 함수 합성 |
| 부작용 | 허용 | 최소화 |
| 테스트 | 목 객체 필요 | 입출력만 테스트 |
| 병렬화 | 어려움 (상태 공유) | 쉬움 (불변성) |
같은 문제, 다른 접근
문제: 학생 목록에서 성적이 80점 이상인 학생의 이름을 추출 OOP 방식 (Java):
class Student {
private String name;
private int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
public String getName() { return name; }
public int getScore() { return score; }
public boolean isPassing() { return score >= 80; }
}
class StudentService {
public List<String> getPassingStudentNames(List<Student> students) {
List<String> result = new ArrayList<>();
for (Student student : students) {
if (student.isPassing()) {
result.add(student.getName());
}
}
return result;
}
}
// 사용
List<Student> students = Arrays.asList(
new Student("Alice", 85),
new Student("Bob", 75),
new Student("Charlie", 90)
);
StudentService service = new StudentService();
List<String> passing = service.getPassingStudentNames(students);
// ["Alice", "Charlie"]
FP 방식 (JavaScript):
// 데이터와 함수 분리
const students = [
{ name: 'Alice', score: 85 },
{ name: 'Bob', score: 75 },
{ name: 'Charlie', score: 90 }
];
// 순수 함수들
const isPassing = student => student.score >= 80;
const getName = student => student.name;
// 함수 합성
const passingNames = students
.filter(isPassing)
.map(getName);
console.log(passingNames); // ['Alice', 'Charlie']
FP 방식 (Python):
students = [
{'name': 'Alice', 'score': 85},
{'name': 'Bob', 'score': 75},
{'name': 'Charlie', 'score': 90}
]
# 함수형 스타일
passing_names = [
s['name'] for s in students if s['score'] >= 80
]
print(passing_names) # ['Alice', 'Charlie']
# 또는 map/filter 사용
passing_names = list(map(
lambda s: s['name'],
filter(lambda s: s['score'] >= 80, students)
))
코드 비교
OOP:
- 명시적 클래스 정의
- 메서드로 동작 캡슐화
- 상태를 객체 내부에 보관 FP:
- 데이터와 함수 분리
- 함수 체이닝 (filter → map)
- 불변 데이터
실무 적용
멀티 패러다임 접근
현대 프로그래밍은 두 패러다임을 혼합합니다. 예: React (JavaScript)
// OOP: 클래스 컴포넌트 (과거)
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
increment = () => {
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.increment}>+1</button>
</div>
);
}
}
// FP: 함수 컴포넌트 + Hooks (현재)
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
트렌드: React는 클래스 컴포넌트에서 함수 컴포넌트로 전환했습니다. 다만 Hooks를 쓴 함수 컴포넌트가 “순수 함수”인 것은 아닙니다. useState는 React가 컴포넌트 바깥에 보관하는 상태이고, 함수는 렌더링마다 그 상태의 스냅샷을 받아 화면을 계산할 뿐입니다. 이 차이를 모르면 흔히 겪는 버그가 오래된 클로저(stale closure) 입니다. 위 increment의 setCount(count + 1)은 해당 렌더링 시점의 count를 캡처하므로, 짧은 시간에 여러 번 호출되거나 setTimeout 안에서 호출되면 증가가 한 번만 반영될 수 있습니다. 이전 값에 기반한 갱신은 setCount(c => c + 1)처럼 함수형 업데이트로 쓰는 것이 안전합니다. 결국 React의 전환도 “상태를 없앤” 것이 아니라 상태를 프레임워크 한곳으로 모으고 렌더링 로직을 함수로 분리한 것이며, 이 글이 말하는 혼합 설계의 대표적인 사례입니다.
언어별 패러다임 지원
| 언어 | OOP | FP | 권장 스타일 |
|---|---|---|---|
| C++ | ✅ | ⚠️ (제한적) | OOP 중심, FP 요소 활용 |
| Python | ✅ | ✅ | 멀티 패러다임 |
| Java | ✅ | ⚠️ (Java 8+) | OOP 중심, Stream API |
| JavaScript | ✅ | ✅ | FP 선호 (React, Vue) |
| Rust | ⚠️ | ✅ | FP 중심, trait로 다형성 |
| Haskell | ❌ | ✅ | 순수 FP |
실무 선택 기준
OOP를 선택하는 경우:
-
복잡한 상태 관리 (게임, GUI 앱)
-
명확한 계층 구조 (조직도, 권한 시스템)
-
팀이 OOP에 익숙함 FP를 선택하는 경우:
-
데이터 변환 파이프라인 (ETL, 스트림 처리)
-
병렬 처리 (맵리듀스, 분산 시스템)
-
테스트 중요 (금융, 의료) 혼합 접근:
-
데이터 모델은 OOP (클래스)
-
비즈니스 로직은 FP (순수 함수)
-
예: Django에서 모델은 클래스로 정의하고, 계산 로직은 모델이나 요청 객체에 의존하지 않는 함수로 분리
제가 혼합 설계를 권하는 가장 실용적인 이유는 테스트입니다. 할인 금액 계산이 Order 클래스 메서드 안에서 DB 조회, 현재 시각, 사용자 세션을 모두 건드리면 테스트마다 목 객체를 여러 개 준비해야 합니다. 반면 calculate_discount(items, coupon, now)처럼 필요한 값을 모두 인자로 받는 함수로 떼어 내면, 입력과 기대 출력만으로 수십 개의 경계 사례를 빠르게 검증할 수 있습니다. 클래스는 그 함수를 호출하고 결과를 저장하는 얇은 껍데기로 남기는 방식이며, “Functional Core, Imperative Shell”이라는 이름으로 알려진 구조입니다. 반대로 모든 것을 함수로 만들겠다고 설정값과 의존성을 매번 인자로 넘기다 보면 인자가 대여섯 개씩 늘어나는데, 이럴 때는 관련 의존성을 객체로 묶는 편이 오히려 읽기 쉽습니다.
정리
핵심 요약
객체지향 (OOP):
-
데이터와 메서드를 객체로 묶음
-
캡슐화, 상속, 다형성, 추상화
-
상태 관리에 강함 함수형 (FP):
-
순수 함수와 불변성 강조
-
부작용 최소화
-
테스트와 병렬화에 유리 실무 권장:
-
두 패러다임을 적절히 혼합
-
상황에 맞는 도구 선택
-
팀 컨벤션 따르기
다음 단계
각 패러다임의 자세한 사용법은 아래 글을 참고하세요:
자주 묻는 질문 (FAQ)
Q. 한 프로젝트에서 OOP와 함수형을 섞어 써도 되나요?
A. 대부분의 현대 코드베이스가 실제로 섞어서 씁니다. 흔한 방식은 데이터 모델은 클래스로 표현하고, 비즈니스 로직은 입력과 출력만으로 동작하는 순수 함수로 작성하는 것입니다. React가 클래스 컴포넌트에서 함수 컴포넌트와 Hooks로 옮겨 간 것처럼, 상태를 한곳에 모으고 변환 로직을 함수로 분리하면 테스트하기 쉬워집니다.
같이 보면 좋은 글
- Python 클래스
- JavaScript 디자인 패턴 | 싱글톤, 팩토리, 옵저버 패턴
- 프로그래밍 언어별 흔한 에러 해결 가이드 | C++, Python, Java, JavaScript