
객체지향 프로그래밍(OOP) 4대 특징
캡슐화, 상속, 다형성, 추상화. 레고 로봇을 만들며 이해하는 객체지향의 핵심.

캡슐화, 상속, 다형성, 추상화. 레고 로봇을 만들며 이해하는 객체지향의 핵심.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

OOP를 제대로 이해하지 못한 채 코드를 짜다가 스파게티 코드를 양산했던 기억이 있다. "객체지향이 뭐냐"는 질문에 "캡슐화, 상속, 다형성, 추상화요"라고 답하긴 했는데, 솔직히 그게 실제 프로덕션 코드에서 어떻게 작동하는지 감이 없었다. 그냥 외운 것들이었다.
스타트업을 운영하면서 코드가 쌓이고, 코드베이스가 거대해지니까 문제가 보이기 시작했다. 어디 하나 수정하면 예상치 못한 곳이 터지고, 새로운 기능 추가가 점점 두려워졌다. "이게 OOP의 부재 때문이었구나"라고 뒤늦게 깨달았다.
결국 이거였다. OOP는 이론으로만 남은 지식이 아니라, 코드를 오래 유지보수하기 위한 생존 전략이었다. 이 글은 그 깨달음을 나 스스로를 위해 정리해본 노트다.
내 서비스의 결제 시스템 코드가 있었다. 처음엔 간단했다. 카드 결제만 지원하면 됐으니까. 그런데 고객들이 요구했다. "계좌이체도 되나요?", "페이팔은요?", "나중에 암호화폐 결제도 추가해주세요."
기존 코드는 이랬다:
function processPayment(method: string, amount: number) {
if (method === 'card') {
// 카드 결제 로직 50줄
} else if (method === 'bank') {
// 계좌이체 로직 60줄
} else if (method === 'paypal') {
// 페이팔 로직 70줄
}
// ... 계속 늘어남
}
이 함수는 200줄을 넘어섰고, 새로운 결제 수단을 추가할 때마다 기존 코드를 건드려야 했다. 버그가 숨어들기 딱 좋은 구조였다. 그때 CTO 출신 멘토가 말했다. "이게 OOP 없이 짜면 생기는 전형적인 문제야. 4대 특징을 제대로 적용해봐."
그래서 나는 다시 공부했다. 이번엔 암기가 아니라, 내 코드를 고치기 위해서.
처음엔 "캡슐화가 왜 필요하지?"라는 의문이 들었다. 변수를 public으로 놔두면 접근도 편하고, 코드도 짧아지는데 굳이 private으로 막고 getter/setter를 만드는 게 번거로워 보였다.
우리 서비스의 유저 데이터를 다루는 코드가 문제였다. User 객체의 balance(잔액) 필드가 public이었는데, 코드 여기저기서 직접 수정하고 있었다:
user.balance = user.balance - 1000; // A 파일
user.balance -= 500; // B 파일
user.balance = user.balance - amount; // C 파일
어느 날 음수 잔액 버그가 발생했다. 누군가 잔액 검증 없이 차감했던 것이다. 문제는 어디서 그런 코드를 썼는지 찾기가 지옥이었다는 점이다. 100개 파일을 뒤져야 했다.
이때 캡슐화가 와닿았다. "아, 잔액 수정을 한 곳에서만 통제해야 하는구나."
class User {
private _balance: number; // 외부 직접 접근 차단
constructor(initialBalance: number) {
this._balance = initialBalance;
}
public getBalance(): number {
return this._balance;
}
public withdraw(amount: number): boolean {
if (amount <= 0) {
throw new Error('인출 금액은 양수여야 합니다');
}
if (this._balance < amount) {
return false; // 잔액 부족
}
this._balance -= amount;
this.logTransaction('withdraw', amount); // 로그도 자동 기록
return true;
}
public deposit(amount: number): void {
if (amount <= 0) {
throw new Error('입금 금액은 양수여야 합니다');
}
this._balance += amount;
this.logTransaction('deposit', amount);
}
private logTransaction(type: string, amount: number): void {
console.log(`[${new Date().toISOString()}] ${type}: ${amount}`);
}
}
이제 잔액 수정은 withdraw()와 deposit()만 통한다. 검증 로직도 한 곳에 있고, 버그가 생기면 여기만 보면 된다. 이게 캡슐화의 핵심이었다. 데이터와 그 데이터를 다루는 로직을 한 곳에 묶어서, 외부에선 안전한 인터페이스로만 접근하게 만드는 거였다.
감기약 캡슐 비유도 좋지만, 나는 은행 금고가 더 와닿았다. 금고에 돈이 있는데, 아무나 들어가서 가져가면 안 된다. 반드시 창구 직원(public method)을 통해서만 입출금해야 한다. 창구 직원은 신분증 확인하고, 잔액 확인하고, 로그 남긴다. 그게 캡슐화였다.
혼자 코드를 관리하면서 캡슐화의 진가를 느꼈다. User 클래스를 잘못 쓸 수 없게 만들어놨으니, 나중에 내가 다시 코드를 열어도 실수할 일이 없어졌다. 컴파일러가 알아서 막아준다.
상속은 직관적으로 이해됐다. "부모 클래스의 기능을 자식이 물려받는다." 그런데 실제로 쓰려니 의문이 생겼다. "언제까지 상속 트리를 뻗어야 하지?", "깊이 5단계 상속은 괜찮나?"
알림(Notification) 시스템을 만들 때였다. 처음엔 이메일 알림만 있었다:
class EmailNotification {
send(message: string) {
console.log(`Sending email: ${message}`);
this.logToDatabase('email', message);
}
logToDatabase(type: string, message: string) {
// DB 저장 로직
}
}
그런데 SMS 알림, 푸시 알림, 슬랙 알림이 추가되면서 문제가 보였다. 모든 알림은 "메시지 전송 + DB 로깅"이라는 공통 패턴이 있었다. 이걸 복사-붙여넣기 하고 있었다.
이때 상속이 답이었다:
class Notification {
protected logToDatabase(type: string, message: string): void {
console.log(`[DB] ${type}: ${message} saved at ${new Date()}`);
}
send(message: string): void {
// 자식 클래스에서 override 할 것
throw new Error('send() must be implemented by subclass');
}
}
class EmailNotification extends Notification {
send(message: string): void {
console.log(`📧 Email: ${message}`);
this.logToDatabase('email', message);
}
}
class SMSNotification extends Notification {
send(message: string): void {
console.log(`📱 SMS: ${message}`);
this.logToDatabase('sms', message);
}
}
class SlackNotification extends Notification {
send(message: string): void {
console.log(`💬 Slack: ${message}`);
this.logToDatabase('slack', message);
}
}
logToDatabase()는 한 번만 구현하고, 각 자식 클래스는 자기만의 send() 로직만 구현했다. 이게 상속이 빛나는 순간이었다. 공통 기능은 부모에 두고, 차이점만 자식에서 구현하는 거였다.
그런데 프로젝트가 커지면서 상속 지옥을 경험했다:
Notification (할아버지)
└─ PushNotification (아버지)
└─ AndroidPushNotification (아들)
└─ SamsungPushNotification (손자)
4단계 상속이 되니까, SamsungPushNotification을 수정하려면 위 3개 클래스를 다 이해해야 했다. 유지보수 악몽이었다.
멘토가 말했다. "Favor composition over inheritance." 상속은 2~3단계까지만 쓰고, 그 이상은 조합(Composition)을 고려하라는 조언이었다. 나중에 디자인 패턴을 공부하면서 이 말이 얼마나 중요한지 알게 됐다.
부모에게서 키, 눈 색깔을 물려받지만(상속), 성격은 교육과 환경으로 만들어진다(조합). 코드도 마찬가지였다. 기본 기능은 상속으로 받되, 복잡한 행동은 다른 객체를 조합해서 만드는 게 나았다.
다형성이 제일 어려웠다. "같은 메서드 이름인데 다르게 동작한다"는 건 알겠는데, 그게 왜 중요한지 몰랐다. "그냥 메서드 이름을 다르게 지으면 안 되나?"라고 생각했다.
결제 시스템을 리팩토링하면서 다형성의 위력을 체감했다. 앞서 말한 if-else 지옥 코드를 이렇게 바꿨다:
// 인터페이스 정의 (추상화)
interface PaymentMethod {
process(amount: number): boolean;
getTransactionFee(amount: number): number;
}
// 각 결제 수단을 클래스로 분리
class CardPayment implements PaymentMethod {
process(amount: number): boolean {
console.log(`💳 Processing card payment: ${amount}`);
// 카드 결제 로직
return true;
}
getTransactionFee(amount: number): number {
return amount * 0.03; // 3% 수수료
}
}
class BankTransfer implements PaymentMethod {
process(amount: number): boolean {
console.log(`🏦 Processing bank transfer: ${amount}`);
// 계좌이체 로직
return true;
}
getTransactionFee(amount: number): number {
return 1.5; // 고정 수수료
}
}
class CryptoPayment implements PaymentMethod {
process(amount: number): boolean {
console.log(`₿ Processing crypto payment: ${amount}`);
// 암호화폐 결제 로직
return true;
}
getTransactionFee(amount: number): number {
return amount * 0.01; // 1% 수수료
}
}
// 결제 처리 시스템
class PaymentProcessor {
executePayment(method: PaymentMethod, amount: number): void {
const fee = method.getTransactionFee(amount);
const total = amount + fee;
console.log(`Total amount with fee: ${total.toFixed(2)}`);
const success = method.process(total);
if (success) {
console.log('✅ Payment successful');
} else {
console.log('❌ Payment failed');
}
}
}
// 사용 예시
const processor = new PaymentProcessor();
processor.executePayment(new CardPayment(), 100);
processor.executePayment(new BankTransfer(), 100);
processor.executePayment(new CryptoPayment(), 100);
핵심은 PaymentProcessor가 어떤 결제 수단이 들어올지 몰라도 된다는 점이었다. PaymentMethod 인터페이스만 구현했다면, 무엇이든 처리할 수 있다. 새로운 결제 수단을 추가할 때 PaymentProcessor를 건드릴 필요가 없다.
이게 다형성의 마법이었다. "같은 인터페이스, 다른 구현체". 이렇게 하니까 코드가 확장에는 열려있고, 수정에는 닫혀있게 됐다 (SOLID 원칙 중 O, Open-Closed Principle).
다형성엔 두 가지가 있다:
오버로딩(Overloading): 같은 이름, 다른 파라미터
class Calculator {
add(a: number, b: number): number {
return a + b;
}
add(a: number, b: number, c: number): number {
return a + b + c;
}
}
오버라이딩(Override): 부모 메서드를 자식이 재정의
class Animal {
makeSound(): void {
console.log('Some sound');
}
}
class Dog extends Animal {
makeSound(): void {
console.log('Woof!');
}
}
class Cat extends Animal {
makeSound(): void {
console.log('Meow!');
}
}
실제로 더 많이 쓰는 건 오버라이딩이었다. 다형성의 핵심은 런타임에 어떤 객체가 올지 몰라도, 같은 메서드를 호출할 수 있다는 유연함이었다.
로봇 모드, 차 모드, 비행기 모드가 있는 트랜스포머. 모두 Transform()이라는 명령을 받지만, 각자 다른 형태로 변신한다. 코드도 마찬가지였다. process()라는 같은 명령을 내려도, 카드 결제는 카드사 API를 호출하고, 암호화폐는 블록체인에 트랜잭션을 올린다. 명령어는 하나, 동작은 다양하다.
추상화는 개념적으로 가장 어려웠다. "중요한 것만 남기고 나머진 숨긴다"는 설명을 들어도, 뭘 남기고 뭘 숨겨야 하는지 기준이 모호했다.
데이터베이스 연결 코드를 작성할 때 추상화를 이해했다. 우리 서비스는 개발 환경에선 SQLite, 프로덕션에선 PostgreSQL을 썼다. 처음엔 이랬다:
// 데이터베이스별 코드가 비즈니스 로직에 침투
function getUser(id: string) {
if (process.env.NODE_ENV === 'production') {
// PostgreSQL 쿼리
const result = pgClient.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0];
} else {
// SQLite 쿼리
const result = sqliteDb.prepare('SELECT * FROM users WHERE id = ?').get(id);
return result;
}
}
비즈니스 로직이 데이터베이스 구현에 묶여있었다. DB를 바꾸면 모든 코드를 수정해야 했다.
추상화를 적용했다:
// 추상 인터페이스
interface Database {
query(sql: string, params: any[]): Promise<any>;
close(): void;
}
// PostgreSQL 구현체
class PostgresDatabase implements Database {
private client: any;
async query(sql: string, params: any[]): Promise<any> {
const result = await this.client.query(sql, params);
return result.rows;
}
close(): void {
this.client.end();
}
}
// SQLite 구현체
class SQLiteDatabase implements Database {
private db: any;
async query(sql: string, params: any[]): Promise<any> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err: any, rows: any) => {
if (err) reject(err);
else resolve(rows);
});
});
}
close(): void {
this.db.close();
}
}
// 비즈니스 로직은 추상화된 인터페이스만 사용
class UserService {
constructor(private db: Database) {}
async getUser(id: string): Promise<any> {
const users = await this.db.query(
'SELECT * FROM users WHERE id = ?',
[id]
);
return users[0];
}
}
// 환경에 따라 구현체만 교체
const db = process.env.NODE_ENV === 'production'
? new PostgresDatabase()
: new SQLiteDatabase();
const userService = new UserService(db);
UserService는 Database 인터페이스만 알고, 실제 구현체(Postgres인지 SQLite인지)는 모른다. 이게 추상화였다. 복잡한 DB 연결 로직은 감추고, "쿼리 실행"이라는 본질만 남기는 거였다.
TypeScript에선 둘 다 추상화에 쓰인다:
인터페이스: 순수한 계약(Contract). 구현 코드 없음.
interface Logger {
log(message: string): void;
error(message: string): void;
}
추상 클래스: 일부 구현을 제공할 수 있음.
abstract class BaseLogger {
abstract log(message: string): void; // 구현 강제
protected formatMessage(message: string): string {
return `[${new Date().toISOString()}] ${message}`;
}
}
class ConsoleLogger extends BaseLogger {
log(message: string): void {
console.log(this.formatMessage(message));
}
}
나는 주로 인터페이스를 쓴다. TypeScript에선 다중 인터페이스 구현이 가능하지만, 다중 상속은 안 되니까.
자동차를 운전할 때 우리는 핸들, 액셀, 브레이크만 안다. 엔진 내부의 연소 과정, 트랜스미션의 기어 변속 메커니즘은 몰라도 된다. 추상화가 "운전"이라는 핵심 인터페이스만 남긴 거였다. 코드도 마찬가지다. save(), load()만 알면 되고, 파일 시스템인지 S3인지는 몰라도 된다.
OOP 4대 특징을 공부하다 보니 SOLID 원칙과 자연스럽게 연결됐다:
결국 OOP 4대 특징은 SOLID 원칙을 실현하기 위한 도구였다. 이 연결고리를 이해하니 "왜 이렇게 코드를 짜야 하는지"가 명확해졌다.
우리 서비스의 주문 처리 시스템을 OOP 원칙으로 리팩토링한 예시다.
리팩토링 전 (절차적 코드):
def process_order(order_data):
# 검증
if not order_data.get('items'):
return {'error': 'No items'}
# 재고 확인
for item in order_data['items']:
stock = db.query('SELECT stock FROM products WHERE id = ?', [item['id']])
if stock < item['quantity']:
return {'error': 'Out of stock'}
# 가격 계산
total = 0
for item in order_data['items']:
price = db.query('SELECT price FROM products WHERE id = ?', [item['id']])
total += price * item['quantity']
# 결제 처리
if order_data['payment_method'] == 'card':
# 카드 결제 로직
pass
elif order_data['payment_method'] == 'bank':
# 계좌이체 로직
pass
# 배송 처리
# ...
return {'success': True}
리팩토링 후 (OOP):
from abc import ABC, abstractmethod
from typing import List
# 추상화
class PaymentMethod(ABC):
@abstractmethod
def process(self, amount: float) -> bool:
pass
# 다형성
class CardPayment(PaymentMethod):
def process(self, amount: float) -> bool:
print(f"Processing card payment: ${amount}")
return True
class BankTransfer(PaymentMethod):
def process(self, amount: float) -> bool:
print(f"Processing bank transfer: ${amount}")
return True
# 캡슐화
class Order:
def __init__(self, items: List[dict]):
self._items = items # private
self._total = 0.0
self._status = 'pending'
def validate(self) -> bool:
if not self._items:
raise ValueError('Order has no items')
return True
def calculate_total(self) -> float:
self._total = sum(item['price'] * item['quantity'] for item in self._items)
return self._total
def get_total(self) -> float:
return self._total
def complete(self):
self._status = 'completed'
# 상속
class ExpressOrder(Order):
def calculate_total(self) -> float:
base_total = super().calculate_total()
express_fee = 10.0
self._total = base_total + express_fee
return self._total
# 메인 로직
class OrderProcessor:
def __init__(self, payment_method: PaymentMethod):
self.payment = payment_method # 의존성 주입
def process(self, order: Order) -> dict:
order.validate()
total = order.calculate_total()
if self.payment.process(total):
order.complete()
return {'success': True, 'total': total}
else:
return {'success': False, 'error': 'Payment failed'}
# 사용
order = Order([{'price': 100, 'quantity': 2}])
processor = OrderProcessor(CardPayment())
result = processor.process(order)
리팩토링 후에는:
OOP 4대 특징은 서로 독립적이지 않다. 캡슐화가 데이터를 보호하고, 상속이 공통 기능을 재사용하고, 다형성이 유연한 확장을 가능케 하고, 추상화가 복잡도를 낮춘다. 이 넷이 함께 작동할 때 유지보수 가능하고, 확장 가능하고, 읽기 쉬운 코드가 탄생한다.
비CS 전공자로서 OOP를 처음 배울 때는 "왜 이렇게 복잡하게 짜야 해?"라고 불평했다. 하지만 코드베이스가 커지고, 기능이 쌓이고, 서비스를 1년 넘게 운영하면서 깨달았다. OOP는 미래의 나를 위한 투자였다.
지금은 새로운 기능을 추가할 때 설렌다. 기존 코드를 안 건드려도 되니까. 버그가 생겨도 당황하지 않는다. 어디를 봐야 할지 알고 있으니까. 이게 OOP가 준 자신감이었다.
이 글을 미래의 내가 다시 읽을 때, "그래, 이렇게 이해했었지"라고 고개를 끄덕이길 바란다. 그리고 혹시 이 글을 읽는 다른 누군가가 있다면, OOP가 이론으로만 남은 지식이 아니라 코드를 오래 살아남게 하는 생존 기술이라는 걸 받아들였으면 한다.