1 캡슐화와 다형성
| 개념 | 정의 |
|---|---|
| 캡슐화 | 객체 내부를 외부로부터 보호 |
| 다형성 | 서로 다른 객체가 동일한 인터페이스로 다르게 동작 |
1.1 캡슐화란 무엇인가
- 캡슐화(Encapsulation)는 객체의 데이터(속성)와 그 데이터를 다루는 메소드를 하나로 묶고, 내부 구현을 외부로부터 숨기는 원칙이다.
- 캡슐: 내부 구조를 외부에서 직접 건드리지 못하도록 캡슐 안에 가두는 것이다.
1.1.1 왜 이것이 필요한가?
- 데이터를 외부에서 직접 변경할 수 있으면 데이터의 무결성(data integrity) 을 보장할 수 없기 때문이다.
- 문제 상황 예시: 자동차 속도
- 현실에서 자동차 속도는 음수가 될 수 없고,
- 갑자기 0에서 300으로 순간이동하듯 바뀔 수 없다.
- 그런데 캡슐화 없이
car.speed = -100또는car.speed = 300을 코드 어디서든 실행할 수 있다면, 비즈니스 규칙이 코드 수준에서 전혀 강제되지 않는다.
- 버그는 데이터가 잘못된 상태가 된 순간이 아니라 그 잘못된 데이터가 사용되는 순간에 발견된다.
- 그 사이의 거리가 멀수록 디버깅은 어려워진다.
- 현실에서 자동차 속도는 음수가 될 수 없고,
- 캡슐화는 이 문제를 해결한다.
- 속성을 외부에서 직접 변경하지 못하게 막고,
- 메소드를 통해서만 변경할 수 있도록 강제한다.
- 메소드 안에서 유효성 검증 로직을 실행하므로 잘못된 데이터가 객체 안으로 들어오는 것 자체를 차단한다.
1.2 접근 제어자: public, protected, private
접근 제어자(access modifier) = 캡슐화를 구현하는 기술적 도구
| 제어자 | 접근 범위 | 설명 |
|---|---|---|
| public | 내부 + 외부 모두 | 제한 없이 읽기/쓰기 가능 |
| protected | 내부 + 자식 클래스 | 외부 접근 비권장 |
| private | 내부만 | 외부 접근 절대 불가 |
참고: Java/C++은 언어 수준에서 강제, private 속성 외부 접근 시 컴파일 오류
1.3 Python의 캡슐화: 관례 기반 접근 제어
Python은 접근 제어를 언어 수준에서 강제하지 않음
- 철학: “We are all consenting adults here”
- 강제 대신 명명 규칙(naming convention) 으로 의도 표현
| 제어자 | 문법 | 동작 | 비고 |
|---|---|---|---|
| public | self.name |
제한 없음 | 기본값 |
| protected | self._balance |
기술적 차단 없음 | “건드리지 말라”는 신호 |
| private | self.__secret |
이름 맹글링 적용 | _ClassName__secret으로 변환 |
self.__secret = "internal_data" # private: 외부 접근 차단
# 외부에서 접근 시도
obj.__secret # AttributeError
obj._ClassName__secret # 이름 맹글링 우회 (가능하지만 절대 하면 안 됨)이름 맹글링 우회는 기술적으로 가능하지만
Python 커뮤니티 관례상 매우 나쁜 코드로 간주됨
1.4 잘못된 캡슐화 사례와 올바른 설계
PPT의 Car 클래스 예시를 통해
잘못된 캡슐화가 어떤 문제를 일으키는지 살펴본다.
# 잘못된 설계: 캡슐화가 불완전한 사례
class Car:
def __init__(self) -> None:
self.speed = 0 # public 속성: 외부에서 직접 변경 가능
def drive(self, speed) -> int:
if speed < 0:
print(f"잘못된 속도입니다. {self.speed} km/h 를 유지합니다.")
return self.speed
self.speed = speed
if speed == 0:
print("정지 중입니다.")
else:
print(f"지금 속도는 {self.speed} km/h 입니다.")
redcar = Car()
redcar.drive(0) # 정지 중입니다.
redcar.drive(10) # 지금 속도는 10 km/h 입니다.
redcar.drive(-10) # 잘못된 속도입니다. 10 km/h 를 유지합니다.
# 문제: drive() 메소드의 유효성 검증을 완전히 우회
redcar.speed = -10 # 아무런 검증 없이 음수 속도가 설정됨drive() 메소드는 음수 속도에 대한 유효성 검증을 포함하고 있다.
그런데 self.speed가 public이므로
redcar.speed = -10처럼 외부에서 직접 접근하면
이 검증이 완전히 무력화된다.
메소드로 보호하려는 노력이 허사가 되는 것이다.
올바른 설계는 다음과 같다.
# 올바른 설계: private 속성과 메소드를 통한 접근 제어
class Car:
def __init__(self) -> None:
self.__speed = 0 # private: 외부 직접 접근 차단
def drive(self, speed) -> int:
if speed < 0:
print(f"잘못된 속도입니다. {self.__speed} km/h 를 유지합니다.")
return self.__speed
self.__speed = speed
if speed == 0:
print("정지 중입니다.")
else:
print(f"지금 속도는 {self.__speed} km/h 입니다.")
def get_speed(self): # getter: 읽기 전용 접근 제공
return self.__speed
redcar = Car()
redcar.drive(10) # 지금 속도는 10 km/h 입니다.
redcar.drive(-10) # 잘못된 속도입니다. 10 km/h 를 유지합니다.
redcar.__speed = -10 # AttributeError: 직접 접근 차단
print(redcar.get_speed()) # 10: getter를 통한 읽기만 허용self.__speed로 선언함으로써
외부에서 직접 speed를 변경하는 경로가 완전히 차단된다.
속도를 변경하려면 반드시 drive() 메소드를 거쳐야 하고,
이 메소드 안에서 유효성 검증이 항상 실행된다.
데이터의 무결성이 코드 구조 자체에 의해 보장된다.
1.5 getter와 setter: 제어된 접근 제공
getter/setter 패턴 = private 속성을 숨기되 통제된 읽기/쓰기 경로 제공
class Person:
def __init__(self, name, age):
self.__name = name
self.__age = age
def get_age(self): # getter: 읽기 제공
return self.__age
def set_age(self, new_age): # setter: 유효성 검증 후 쓰기 제공
if new_age > 0:
self.__age = new_age
else:
raise ValueError("나이는 양수여야 합니다.")Python에서는 @property 데코레이터를 사용해
getter/setter를 더 Pythonic하게 구현할 수 있다.
class Person:
def __init__(self, name, age):
self.__name = name
self.__age = age
@property
def age(self): # getter: person.age로 접근
return self.__age
@age.setter
def age(self, new_age): # setter: person.age = 31로 설정
if new_age > 0:
self.__age = new_age
else:
raise ValueError("나이는 양수여야 합니다.")
person = Person("Robert", 30)
print(person.age) # 30: getter 호출
person.age = 31 # setter 호출, 유효성 검증 실행
person.age = -1 # ValueError 발생@property의 장점:
- 외부에서는
person.age처럼 일반 속성 접근으로 보임 - 실제로는 메소드가 실행됨
- 인터페이스 단순 + 내부 제어 유지
1.6 다형성이란 무엇인가
다형성(Polymorphism) = “여러(poly) 가지 형태(morph)”
- 정의: 동일한 인터페이스(메소드 이름)가 객체 타입에 따라 다르게 동작
- 장점: 구체적 객체 타입을 몰라도 일관된 방식으로 객체 처리 가능
다형성 없는 설계 (문제점):
# 다형성 없는 설계: 타입을 직접 확인해야 함
def print_area(shape):
if isinstance(shape, Circle):
print(3.14 * shape.radius * shape.radius)
elif isinstance(shape, Square):
print(shape.side * shape.side)
elif isinstance(shape, Triangle):
print(0.5 * shape.base * shape.height)
# 새로운 도형이 추가될 때마다 여기에 elif를 추가해야 함문제점: 새 도형 추가 시 print_area() 함수 수정 필요
→ 개방-폐쇄 원칙(OCP) 위반: “확장에는 열려있고, 수정에는 닫혀있어야”
다형성 적용 설계:
class Shape:
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self): # 오버라이딩: Circle만의 넓이 계산
return 3.14 * self.radius * self.radius
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self): # 오버라이딩: Square만의 넓이 계산
return self.side * self.side
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self): # 오버라이딩: Triangle만의 넓이 계산
return 0.5 * self.base * self.height
# 다형성 활용: 타입에 관계없이 동일한 인터페이스로 처리
shapes = [Circle(3), Square(4), Triangle(3, 5)]
for shape in shapes:
print(f"Area of {shape.__class__.__name__} is {shape.area()}")
# Area of Circle is 28.26
# Area of Square is 16
# Area of Triangle is 7.5핵심 포인트:
shape.area()호출 시Circle/Square/Triangle구분 필요 없음- 각 객체가 자신의 타입에 맞는 계산을 알아서 수행
Pentagon(오각형) 추가 시 →area()메소드만 구현하면 됨- 반복문 코드는 수정 불필요 → 확장성 향상
1.7 다형성의 두 가지 구현 방식
| 방식 | 설명 | 특징 |
|---|---|---|
| 메소드 오버라이딩 | 자식 클래스가 부모 메소드를 같은 이름으로 재정의 | 상속 관계 필요 |
| 덕 타이핑 | “오리처럼 걷고 오리처럼 운다면 오리” | 상속 관계 없이도 동작 |
class Dog:
def sound(self):
print("Woof!")
class Cat:
def sound(self):
print("Meow!")
class Duck:
def sound(self):
print("Quack!")
# Dog, Cat, Duck은 서로 상속 관계가 없음
# 하지만 모두 sound()를 가지므로 동일하게 다룰 수 있음
animals = [Dog(), Cat(), Duck()]
for animal in animals:
animal.sound()
# Woof!
# Meow!
# Quack!정적 vs 동적 타입 언어:
- Java/C++: 다형성에 공통 부모 클래스/인터페이스 필수
- Python: 동적 타입 언어라 덕 타이핑 가능
- 단, 필요한 메소드를 문서/타입 힌트로 명확히 표현해야 의도 전달
1.8 캡슐화와 다형성의 상호작용
독립적 개념이지만 함께 사용 시 시너지 발생:
| 개념 | 역할 |
|---|---|
| 캡슐화 | 객체 내부 구현을 숨김 |
| 다형성 | 동일한 인터페이스로 다른 구현 호출 |
결합 효과: “인터페이스만 알고 구현은 몰라도 된다”
Scikit-learn Pipeline 예시:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipeline = Pipeline([
('scaler', StandardScaler()),
('model', LogisticRegression())
])
pipeline.fit(X_train, y_train)
pipeline.predict(X_test)Pipeline은 StandardScaler의 내부 구현을 전혀 모른다.
LogisticRegression이 어떻게 학습하는지도 모른다.
단지 각 단계가 fit()과 transform() 또는 predict()를
가지고 있다는 것만 안다.
내부 구현은 각 클래스 안에 캡슐화되어 있고,
동일한 인터페이스를 통한 다형성으로 Pipeline이 이들을 일관되게 다룬다.
LogisticRegression 대신 RandomForestClassifier로 바꿔도
Pipeline 코드는 한 글자도 수정하지 않아도 된다.
2 실전 프로젝트: 은행 계좌 관리 시스템
지금까지 학습한 모든 OOP 원칙을 은행 계좌 관리 시스템에 통합
2.1 요구사항 분석: 무엇을 만들 것인가
계좌 공통 (BankAccount):
- 계좌번호: 8자리 숫자 랜덤 생성
- 초기 입금액/소유주명: 계좌 개설 시 전달
- 기능:
withdraw(),deposit() - 출금: 잔액 초과 불가, 초과 시
ValueError - 출금 성공 시 현재 잔액 반환
예금계좌 (SavingsAccount):
- 계좌 개설 시 출금 제한 상태
- 이자율: 계좌 생성 시 결정
- 출금 제한 상태에서 출금 시도 시
AttributeError release_block(): 출금 제한 해제 +잔액 × 이자율이자 자동 입금- 출력:
[예금/{계좌번호}] 잔액 ${잔액}, 이율 {이자율}%, 출금 제한 여부: {True/False}
입출금계좌 (CheckingAccount):
- 출금한도: 속성으로 보유, 기본값 $500
- 계좌 생성 시 다른 값으로 초기 설정 가능
- 중도 변경 가능 (
set_limit()) - 출금한도 초과 시
ValueError - 출력:
[입출금/{계좌번호}] 잔액 ${잔액}, 출금한도 ${출금한도}
은행 고객 (BankCustomer):
- 속성: 고객명, 보유 계좌 리스트, 보유 현금
- 기능:
add_account(),list_accounts(),add_cash(),deduct_cash(),show_assets()
2.2 클래스 설계 전략: OOP 원칙을 어떻게 적용할 것인가
| 원칙 | 적용 전략 |
|---|---|
| 상속 | BankAccount ← SavingsAccount, CheckingAccount (is-a 관계) |
| 캡슐화 | __balance (private), _withdrawal_blocked (protected) |
| 다형성 | __str__(), withdraw() 각 자식 클래스에서 오버라이딩 |
| 추상화 | BankAccount를 ABC로, __str__() 추상 메소드 |
2.3 전체 클래스 다이어그램
구현 전에 클래스 간 관계를 정리하면 다음과 같다.
BankAccount (ABC, 추상 클래스)
├── __account_number: str (private)
├── __balance: float (private)
├── owner: str (public)
├── deposit(amount): float
├── withdraw(amount): float
└── __str__(): str (abstractmethod)
SavingsAccount(BankAccount)
├── __interest_rate: float (private)
├── _withdrawal_blocked: bool (protected)
├── release_block(): None
├── withdraw(amount): float (override)
└── __str__(): str (override)
CheckingAccount(BankAccount)
├── __withdrawal_limit: float (private)
├── set_limit(limit): None
├── withdraw(amount): float (override)
└── __str__(): str (override)
BankCustomer
├── name: str
├── __cash: float (private)
├── __accounts: list (private)
├── add_account(account): None
├── list_accounts(): None
├── add_cash(amount): None
├── deduct_cash(amount): None
└── show_assets(): None
2.4 구현: BankAccount 부모 클래스
import random
from abc import ABC, abstractmethod
class BankAccount(ABC):
"""
모든 계좌 유형의 공통 속성과 기능을 정의하는 추상 기반 클래스.
직접 인스턴스화 불가. SavingsAccount, CheckingAccount에서 상속받아 사용.
"""
def __init__(self, owner: str, initial_amount: float) -> None:
# 계좌번호: 8자리 랜덤 숫자, 외부 변경 불가 (private)
self.__account_number = str(random.randint(10000000, 99999999))
# 잔액: 직접 변경 불가 (private), deposit/withdraw 메소드를 통해서만 변경
self.__balance = initial_amount
# 소유주: 변경이 필요한 경우가 있으므로 public
self.owner = owner
@property
def account_number(self) -> str:
"""계좌번호 읽기 전용 접근 제공."""
return self.__account_number
@property
def balance(self) -> float:
"""잔액 읽기 전용 접근 제공. 외부에서 직접 수정 불가."""
return self.__balance
def deposit(self, amount: float) -> float:
"""
입금 처리.
입금액이 0 이하이면 ValueError 발생.
성공 시 현재 잔액 반환.
"""
if amount <= 0:
raise ValueError("입금액은 0보다 커야 합니다.")
self.__balance += amount
return self.__balance
def withdraw(self, amount: float) -> float:
"""
출금 처리. 잔액 초과 출금 시 ValueError 발생.
자식 클래스에서 추가 제약 조건과 함께 오버라이딩됨.
"""
if amount > self.__balance:
raise ValueError(
f"잔액 부족: 현재 잔액은 ${self.__balance}입니다."
)
self.__balance -= amount
return self.__balance
@abstractmethod
def __str__(self) -> str:
"""
계좌 정보 출력 형식.
자식 클래스마다 출력 형식이 다르므로 추상 메소드로 강제.
"""
passBankAccount가 ABC를 상속받아 추상 클래스가 되는 순간,
BankAccount()로 직접 인스턴스를 생성하려 하면 TypeError가 발생한다.
이 설계 의도는 “계좌는 반드시 예금계좌나 입출금계좌 중 하나여야 한다”는
비즈니스 규칙을 코드 수준에서 강제하는 것이다.
__balance를 private으로 설정하고 @property로 읽기 전용 접근만 제공하는 것은
잔액이 deposit()과 withdraw()를 통해서만 변경되어야 한다는
불변 규칙을 코드 구조로 표현한 것이다.
2.5 구현: SavingsAccount 예금계좌
class SavingsAccount(BankAccount):
"""
예금계좌 클래스.
개설 시 출금이 제한되며, release_block() 호출 시
이자가 지급되고 출금 제한이 해제됨.
"""
def __init__(
self,
owner: str,
initial_amount: float,
interest_rate: float
) -> None:
# 부모 클래스 생성자 호출: 계좌번호, 잔액, 소유주 초기화
super().__init__(owner, initial_amount)
# 이자율: 외부에서 임의 변경 불가 (private)
self.__interest_rate = interest_rate
# 출금 제한: 개설 시 True (제한 상태)
# protected: 자식 클래스에서는 접근 가능하나 외부에서는 비권장
self._withdrawal_blocked = True
def release_block(self) -> None:
"""
출금 제한 해제.
해제 시 현재 잔액에 이자율을 곱한 이자가 자동 입금됨.
이미 해제된 상태에서 재호출 시 안내 메시지 출력.
"""
if not self._withdrawal_blocked:
print("이미 출금 제한이 해제된 계좌입니다.")
return
interest = self.balance * self.__interest_rate
self.deposit(interest) # 부모 클래스의 deposit() 사용
self._withdrawal_blocked = False
print(
f"출금 제한 해제 완료. "
f"이자 ${interest:.2f} 입금. "
f"현재 잔액: ${self.balance:.2f}"
)
def withdraw(self, amount: float) -> float:
"""
출금 처리. 출금 제한 상태에서는 AttributeError 발생.
제한 해제 후에는 부모 클래스의 잔액 초과 검증도 실행됨.
"""
if self._withdrawal_blocked:
raise AttributeError(
"출금 제한 계좌입니다. release_block()을 먼저 호출하세요."
)
# 부모 클래스의 withdraw() 호출: 잔액 초과 검증 포함
return super().withdraw(amount)
def __str__(self) -> str:
return (
f"[예금/{self.account_number}] "
f"잔액 ${self.balance:.2f}, "
f"이율 {self.__interest_rate * 100:.1f}%, "
f"출금 제한 여부: {self._withdrawal_blocked}"
)SavingsAccount.withdraw()에서 super().withdraw(amount)를 호출하는 부분이
상속과 오버라이딩의 협력 방식을 잘 보여준다.
SavingsAccount는 출금 제한이라는 자신만의 검증을 먼저 수행하고,
통과하면 부모 클래스의 잔액 초과 검증에 위임한다.
검증 로직이 중복 없이 각 클래스의 책임에 따라 분리되어 있다.
2.6 구현: CheckingAccount 입출금계좌
class CheckingAccount(BankAccount):
"""
입출금계좌 클래스.
출금한도를 가지며 기본값은 $500.
한도 초과 출금 시 ValueError 발생.
"""
def __init__(
self,
owner: str,
initial_amount: float,
withdrawal_limit: float = 500
) -> None:
super().__init__(owner, initial_amount)
# 출금한도: set_limit()을 통해서만 변경 가능 (private)
self.__withdrawal_limit = withdrawal_limit
def set_limit(self, new_limit: float) -> None:
"""출금한도 변경. 0 이하의 한도는 허용하지 않음."""
if new_limit <= 0:
raise ValueError("출금한도는 0보다 커야 합니다.")
self.__withdrawal_limit = new_limit
print(f"출금한도가 ${self.__withdrawal_limit:.2f}로 변경되었습니다.")
def withdraw(self, amount: float) -> float:
"""
출금 처리. 출금한도 초과 시 ValueError 발생.
한도 검증 통과 후 부모 클래스의 잔액 초과 검증 실행.
"""
if amount > self.__withdrawal_limit:
raise ValueError(
f"출금한도 초과: 현재 한도는 ${self.__withdrawal_limit:.2f}입니다."
)
return super().withdraw(amount)
def __str__(self) -> str:
return (
f"[입출금/{self.account_number}] "
f"잔액 ${self.balance:.2f}, "
f"출금한도 ${self.__withdrawal_limit:.2f}"
)CheckingAccount의 생성자에서 withdrawal_limit: float = 500처럼
기본값을 설정한 것은 요구사항의 “기본값 $500, 필요 시 별도 설정 가능”을
Python의 기본 매개변수(default parameter)로 자연스럽게 표현한 것이다.
2.7 구현: BankCustomer 은행 고객
class BankCustomer:
"""
은행 고객 클래스.
현금과 계좌 목록을 관리하며 자산 현황을 조회할 수 있음.
"""
def __init__(self, name: str, initial_cash: float = 0) -> None:
self.name = name
self.__cash = initial_cash # private: deduct/add 메소드로만 변경
self.__accounts = [] # private: add_account()로만 추가
@property
def cash(self) -> float:
return self.__cash
def add_account(self, account: BankAccount) -> None:
"""보유 계좌 목록에 새로운 계좌 추가."""
self.__accounts.append(account)
print(f"계좌 추가 완료: {account}")
def list_accounts(self) -> None:
"""보유 계좌 목록 출력."""
if not self.__accounts:
print("보유 계좌 없음.")
return
print(f"\n{self.name}의 계좌 목록:")
for account in self.__accounts:
print(f" {account}")
def add_cash(self, amount: float) -> None:
"""현금 추가."""
if amount <= 0:
raise ValueError("추가할 현금은 0보다 커야 합니다.")
self.__cash += amount
def deduct_cash(self, amount: float) -> None:
"""현금 차감. 보유 현금 초과 차감 불가."""
if amount > self.__cash:
raise ValueError(
f"현금 부족: 현재 보유 현금은 ${self.__cash:.2f}입니다."
)
self.__cash -= amount
def show_assets(self) -> None:
"""보유 현금과 모든 계좌 정보 출력."""
print(f"\n{'='*50}")
print(f"{self.name}의 자산 현황")
print(f"{'='*50}")
print(f"보유 현금: ${self.__cash:.2f}")
self.list_accounts()
total = self.__cash + sum(
account.balance for account in self.__accounts
)
print(f"\n총 자산: ${total:.2f}")
print(f"{'='*50}\n")show_assets()에서 sum(account.balance for account in self.__accounts)는
다형성이 조용히 작동하는 지점이다.
__accounts 리스트에는 SavingsAccount와 CheckingAccount가
섞여서 들어있을 수 있다.
하지만 둘 다 BankAccount를 상속받으므로 balance 속성을 가진다.
타입을 확인하는 코드 없이 일관되게 처리된다.
2.8 시나리오 1: 예금 만기 후 자금 이동
print("\n" + "="*60)
print("시나리오 1: 고객 A의 예금 만기 및 자금 이동")
print("="*60)
# 고객 A 생성: 초기 현금 $1000
customer_a = BankCustomer("고객 A", initial_cash=1000)
# 입출금계좌 개설: $200 저축
checking = CheckingAccount("고객 A", initial_amount=200)
customer_a.deduct_cash(200) # 현금 $200 차감
customer_a.add_account(checking)
# 예금계좌 개설: $800 저축, 이자율 5%
savings = SavingsAccount("고객 A", initial_amount=800, interest_rate=0.05)
customer_a.deduct_cash(800) # 현금 $800 차감
customer_a.add_account(savings)
print(f"\n계좌 개설 직후 현금: ${customer_a.cash:.2f}")
# 현금: $0 (1000 - 200 - 800)
# 예금 만기 처리: 출금 제한 해제 + 이자 지급
print("\n--- 예금 만기 처리 ---")
savings.release_block()
# 이자: $800 * 0.05 = $40 → 잔액 $840
# 예금계좌에서 $400 출금 후 입출금계좌로 입금
print("\n--- 자금 이동 ---")
withdrawn = savings.withdraw(400)
print(f"예금계좌 출금 $400 완료. 예금 잔액: ${withdrawn:.2f}")
checking.deposit(400)
print(f"입출금계좌 입금 $400 완료. 입출금 잔액: ${checking.balance:.2f}")
# 최종 자산 현황 출력
customer_a.show_assets()
# 예상 출력:
# 예금계좌: $840 - $400 = $440
# 입출금계좌: $200 + $400 = $600
# 현금: $0
# 총 자산: $1040 ($40 이자 수익)시나리오 1에서 주목할 설계 포인트는
release_block() 내부에서 이자 계산과 입금이 자동으로 처리된다는 것이다.
외부 코드는 savings.release_block() 한 줄만 호출하면 되고,
이자 계산 공식(balance * interest_rate)이 어떻게 되는지 알 필요가 없다.
이것이 캡슐화가 “구현을 숨기고 인터페이스만 노출한다”는 말의 실제 의미다.
2.9 시나리오 2: 출금한도 초과 및 한도 변경
print("\n" + "="*60)
print("시나리오 2: 고객 B의 출금한도 초과 및 한도 변경")
print("="*60)
# 고객 B 생성: 초기 현금 $900
customer_b = BankCustomer("고객 B", initial_cash=900)
# 입출금계좌 개설: $800 저축, 기본 출금한도 $500 적용
checking_b = CheckingAccount("고객 B", initial_amount=800)
customer_b.deduct_cash(800)
customer_b.add_account(checking_b)
# 예금계좌 개설: $100 저축, 이자율 6%
savings_b = SavingsAccount("고객 B", initial_amount=100, interest_rate=0.06)
customer_b.deduct_cash(100)
customer_b.add_account(savings_b)
print(f"\n계좌 개설 직후 현금: ${customer_b.cash:.2f}")
# 현금: $0 (900 - 800 - 100)
# 출금한도 초과 시도: $800 출금 (한도 $500 초과)
print("\n--- 출금한도 초과 시도 ---")
try:
checking_b.withdraw(800)
except ValueError as e:
print(f"출금 실패: {e}")
# 출금 실패: 출금한도 초과: 현재 한도는 $500.00입니다.
# 출금한도 $800으로 변경 후 재시도
print("\n--- 출금한도 변경 후 재시도 ---")
checking_b.set_limit(800)
withdrawn_b = checking_b.withdraw(800)
customer_b.add_cash(withdrawn_b + 800)
# 출금액을 현금으로 수령
# (withdrawn_b는 출금 후 잔액 $0, 실제 수령액은 $800)
customer_b.add_cash(800) # 출금한 $800을 현금으로 추가
print(f"출금 성공. 입출금계좌 잔액: ${checking_b.balance:.2f}")
# 최종 자산 현황 출력
customer_b.show_assets()
# 예상 출력:
# 입출금계좌: $0
# 예금계좌: $100 (출금 제한 상태)
# 현금: $800
# 총 자산: $900시나리오 2에서 try-except 블록으로 ValueError를 잡는 패턴은
실무 코드에서 매우 중요한 방식이다.
예외를 발생시키는 것과 처리하는 것을 분리하는 것이 핵심이다.
CheckingAccount.withdraw()는 한도 초과 시 예외를 발생시키는 책임만 가진다.
예외를 어떻게 처리할지는 이 메소드를 호출하는 쪽의 책임이다.
이 분리가 코드의 재사용성을 높인다.
2.10 OOP 원칙 적용 결과 분석
| 원칙 | 활용 방식 | 효과 |
|---|---|---|
| 상속 | super().withdraw() 재사용 |
잔액 초과 검증 중복 제거, 수정 시 한 곳만 변경 |
| 추상화 | BankAccount를 ABC, __str__() 추상 메소드 |
직접 인스턴스화 차단, 출력 형식 구현 강제 |
| 캡슐화 | __balance, __interest_rate 등 private |
외부 직접 변경 차단, 데이터 무결성 보장 |
| 다형성 | show_assets()에서 동일 인터페이스 처리 |
새 계좌 유형 추가 시 코드 수정 불필요 |
2.11 실무 관점에서의 설계 시사점
| 판단 지점 | 선택 기준 |
|---|---|
| 상속 vs 합성 | is-a → 상속, has-a → 합성 (BankCustomer가 __accounts 포함) |
| 예외 타입 선택 | 값 오류 → ValueError, 속성 접근 불가 → AttributeError |
| 타입 힌트 | 실무 필수, IDE 자동완성 + 정적 분석(mypy) + 메소드 계약 명시 |
2.12 마무리
OOP 4대 원칙의 협력:
- 상속: 공통 로직 재사용
- 추상화: 계약 강제
- 캡슐화: 데이터 보호
- 다형성: 확장성 확보
이 네 가지가 함께 작동할 때 “변경하기 쉽고, 확장하기 쉽고, 이해하기 쉬운” 코드가 만들어진다.
이 네 가지가 함께 작동할 때 비로소
“변경하기 쉽고, 확장하기 쉽고, 이해하기 쉬운” 코드가 만들어진다.
OOP는 문법이 아니라 복잡한 현실을 코드로 모델링하는
사고 방식이라는 점을 이 프로젝트가 구체적으로 보여준다.
3 정리
이 글에서 다룬 OOP의 핵심 개념을 연결해서 보면 하나의 흐름으로 이어진다:
| 개념 | 역할 |
|---|---|
| 클래스 | 객체를 생성하기 위한 설계도, 속성과 메소드의 정의 |
| 객체 | 클래스로부터 생성된 독립적인 실체 |
| 생성자 | 인스턴스 생성 시 자동 호출되어 멤버 변수를 초기화 |
| 상속 | 기존 클래스를 기반으로 새로운 클래스를 정의, 코드 재사용 |
| 추상화 | 공통점을 추출해 상위 개념으로 정의, 설계 계약 강제 |
| 캡슐화 | 데이터를 보호하고 메소드를 통해서만 접근 허용 |
| 다형성 | 동일한 인터페이스가 객체 타입에 따라 다르게 동작 |
이 개념들이 함께 작동할 때 예측 가능하고, 변경하기 쉽고, 확장하기 쉬운 코드가 만들어진다.