캡슐화와 다형성: 데이터 보호와 인터페이스 일관성

Encapsulation and Polymorphism in Python OOP

캡슐화(Encapsulation)는 객체 내부 데이터를 외부로부터 보호하고 메소드를 통해서만 접근하도록 강제하여 데이터 무결성을 보장한다. 다형성(Polymorphism)은 동일한 인터페이스가 객체 타입에 따라 다르게 동작하게 하여 확장성을 높인다. 이 글에서는 접근 제어자, private 속성, getter/setter, @property 패턴, 메소드 오버라이딩, 덕 타이핑을 다루고, 은행 계좌 관리 시스템 실전 프로젝트로 모든 개념을 통합한다.

Engineering
Python
저자

Kwangmin Kim

공개

2023년 07월 02일

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.speedpublic이므로
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)

PipelineStandardScaler의 내부 구현을 전혀 모른다.
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 원칙을 어떻게 적용할 것인가

원칙 적용 전략
상속 BankAccountSavingsAccount, 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:
        """
        계좌 정보 출력 형식.
        자식 클래스마다 출력 형식이 다르므로 추상 메소드로 강제.
        """
        pass

BankAccountABC를 상속받아 추상 클래스가 되는 순간,
BankAccount()로 직접 인스턴스를 생성하려 하면 TypeError가 발생한다.
이 설계 의도는 “계좌는 반드시 예금계좌나 입출금계좌 중 하나여야 한다”는
비즈니스 규칙을 코드 수준에서 강제하는 것이다.

__balanceprivate으로 설정하고 @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 리스트에는 SavingsAccountCheckingAccount
섞여서 들어있을 수 있다.
하지만 둘 다 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의 핵심 개념을 연결해서 보면 하나의 흐름으로 이어진다:

개념 역할
클래스 객체를 생성하기 위한 설계도, 속성과 메소드의 정의
객체 클래스로부터 생성된 독립적인 실체
생성자 인스턴스 생성 시 자동 호출되어 멤버 변수를 초기화
상속 기존 클래스를 기반으로 새로운 클래스를 정의, 코드 재사용
추상화 공통점을 추출해 상위 개념으로 정의, 설계 계약 강제
캡슐화 데이터를 보호하고 메소드를 통해서만 접근 허용
다형성 동일한 인터페이스가 객체 타입에 따라 다르게 동작

이 개념들이 함께 작동할 때 예측 가능하고, 변경하기 쉽고, 확장하기 쉬운 코드가 만들어진다.

Subscribe

Enjoy this blog? Get notified of new posts by email: