Python/기초

파이썬 객체지향 프로그래밍(OOP) 4대 패러다임 정리 캡슐화, 상속, 다형성, 다형성, 추상화 쉽게 이해하기

sdesign416 2026. 6. 1. 09:01

파이썬의 객페지향 프로그래밍은 간단하게

데이터와 기능을 객체 단위로 묶어 프로그램을 설계하는 방식이다.

 

이 객체지향 프로그밍에는 4대 패러다임존재하며

4대 패러다임에는 캡슐화, 상속, 다형성, 추상화가 있다.

 

이 개념을 활용하면

Python에서는 코드의 재사용성, 확장성 그리고 유지보수성을 크게 높일 수 있다.

이번 글에서는 각 프로그래밍 패러다임의 정의와 어떻게 사용하는지에 대해 정리 할 예정이다.


1. 캡슐화

캡슐화는 데이터(속성)와 기능(메서드)을 하나의 객체로 묶고,

외부에서 내부 데이터를 직접 수정하지 못하도록 보호해주는 개념이다.

 

Python에선 다른 언어처럼 접근 제한자를 강제하고있진 않지만,

네이밍 규칙을 통해 접근 범위를 표현하고 있다.

 

📌 언더바를 활용한 네이밍 규칙

  • public → 클래스 외부든 내부든 아무나 자유럽게 접근가능 (self.name)
  • _protected → 웬만해서는 내부나 상속받은 자식 클래스에서만 쓰고, 밖에서는 건드리지 않음(self._origin)
  • __private 클래스 내부에서만 사용가능 (self.__price)
# ----- 클래스 정의 -----
# 부모 클래스
class Fruit:
    def __init__(self, name, price):
        self.name = name          # public
        self.__price = price      # private (캡슐화) → 클래스내부에서만 접근가능(__)

    # [getter] 
    # : 외부에서 직접 접근할 수 없는 __price 조회
    def get_price(self):
        return self.__price

    # [setter]
    # : 외부에서 값 변경 시, 올바른 값인지 검증
    def set_price(self, price):
        if price > 0:
            self.__price = price
        else:
            # : 잘못된 값 임력 시 데이터 보호(캡슐화 역할)
            print("가격은 0보다 커야합니다.")

    # 정보 출력
    def print_info(self):
        print(f"과일: {self.name}")
        print(f"가격: {self.__price}")


# Fruit를 상속받은 자식 클래스
class Apple(Fruit):
    def __init__(self, name, price, origin):
        super().__init__(name, price)
        # _protected 규칙 : 앞에 언더바를 붙여 내부 자식임을 표시함
        self._origin = origin

    def print_origin(self):
        print(f"{self.name}의 원산지 : {self._origin}")
# ----- 객체 생성, 출력 테스트 -----

# 자식 클래스 객체 생성
apple = Apple("사과", 3000, "한국")

# 정보 출력
apple.print_info()
apple.print_origin()

print("----- getter -----")
print("가격 조회:", apple.get_price())

print("----- 가격 수정 -----")
apple.set_price(3500)
print("수정된 가격:", apple.get_price())

print("----- 잘못된 값 입력 -----")
apple.set_price(-1000)  # 검증 실패

print("----- 외부 접근 시도 -----")
# __price에 직접 접근하여 수정 시도
# 실제 private 변수가 바뀌는 게 아니라, 겉모습만 같은 새로운 변수가 밖에 생성
# 실행창에선 아무 일도 일어나지 않음
apple.__price = 10000   

# 외부 접근 시도 후 결과
print("getter로 확인:", apple.get_price())  # 여전히 3500
print("직접 접근:", apple.__price)         # 10000 (다른 변수)

실행 결과 ▶

 

💡 __price__는 완전한 private 형태가 아니다?!

코드를 실행했을 때,

외부에서 apple.__price로 직접 접근하면 엉뚱한 변수가 생긴것이 확인된다.(에러가 날수도 있음)

 

그 이유는 파이썬이 내부적으로 변수 이름을 _클래스이름__변수이름( _Fruit__price) 형태로

강제로 바꿔버렸기 때문이다.

 

다시말해, __price는 컴퓨터 내부에서 _Fruit__price라는 이름으로 변경된것이다.

 

결론적으로 파이썬의 private

시스템이 강제로 접근을 차단한다기보단,

이름 체계를 바꾸어 "직접 접근하지 마"라는 의미에 가깝다.


@property 사용하기

getter와 setter 대신 @property를 활용할 수도 있다.

@property는 함수형태를 띄고 있지만 함수가 아닌 속성처럼 사용하게 해주는

Pythonic한 캡슐화 상태이다.

# ----- 클래스 정의 -----
# 부모 클래스
class Fruit:
    def __init__(self, name, price):
        self.name = name
        self.__price = price  # private

    # getter → 속성처럼 접근 가능
    @property
    def price(self):
        return self.__price

    # setter → 값 할당 시 자동 호출
    @price.setter
    def price(self, value):
        if value > 0:
            self.__price = value
        else:
            print("가격은 0보다 커야 합니다.")

    # 정보 출력
    def print_info(self):
        print(f"과일: {self.name}")
        # self.__price 대신 위에 정의한 @property(getter)를 통해 접근
        print(f"가격: {self.price}")


# Fruit를 상속받은 자식 클래스
class Apple(Fruit):
    def __init__(self, name, price, origin):
        super().__init__(name, price)
        self._origin = origin 

    def print_origin(self):
        print(f"{self.name}의 원산지: {self._origin}")
# ----- 객체 생성, @property 확인

# 자식 클래스 객체 생성
apple = Apple("사과", 3000, "한국")

# 정보 출력
apple.print_info()
apple.print_origin()

# 괄호 없이 일반 변수처럼 getter 호출
print("가격 조회:", apple.price)

# 괄호와 매개변수 없이 = 연산자로 값을 바로 대입하는 setter 호출
print("----- 가격 수정 -----")
apple.price = 3500              
print("수정된 가격:", apple.price)

print("----- 잘못된 값 입력 -----")
apple.price = -1000              # 검증 실패

print("----- 외부 접근 시도 -----")
apple.__price = 10000            # 새로운 변수 생성

print("실제 가격:", apple.price)  # 여전히 3500
print("외부에서 만든 값:", apple.__price)

print("----- 외부 접근 시도 -----")
# 새로운 외래 변수 생성 (기존 private 보호됨)
apple.__price = 10000   
print("실제 가격:", apple.price)            # 여전히 안전하게 3500 유지
print("외부에서 만든 값:", apple.__price)    # 10000

실행 결과 ▶


캡슐화의 장점

캡슐화를 사용하면 객체 데이터를 안전하게 보호할 수 있고

잘못된 값이 저장되는것도 방지할 수 있다.

따라서 유지 보수가 쉬워지며

내부 구현을 변경하더라도 외부 코드에 미치는 영향을 최대한 줄일 수 있다.


2. 상속

상속은 부모 클래스의 속성과 메서드를 자식 클래스가 물려받는 개념이다.

부모 클래스의 기능을 재사용할 수 있기 때문에

코드의 중복을 줄일 수 있고 유지보수하기 쉽게 만들어 준다.

# 상속 기본 구조
class Parent:
    pass

class Child(Parent):
    pass

상속 구조

부모 클래스에 정의된 메서드를 그대로 사용할 수 있기 때문에

동일한 기능을 여러 번 구현 할 필요가 없고
필요한 부분이 있다면 추가를 하거나

오버라이딩(재정의)하여 동작을 변경할 수 있다.

# 부모클래스
class Fruit:
    def __init__(self, name, quantity):
        self.name = name
        self.quantity = quantity

    def store(self, place):
        print(f'{self.name}를 {place}에 보관합니다')

    def sell(self, amount):
        print(f'{self.name}를 {amount}개 판매합니다')

fruit = Fruit('과일', 100)
fruit.store('창고')
fruit.sell(10)

실행 결과 ▶

 

💡상속받을 때

# 부모 클래스(Fruit)를 상속받음
class Apple(Fruit):
    pass
    
# ----- 출력테스트
# Fruit 클래스를 상속 받았기 때문에
# Fruit 생성자의 매개변수를 전달해야 함
# 즉, 자식클래스의 생성자가 없으면 부모클래스의 생성자를 따른다
apple = Apple('사과', 50)

apple.store('냉장고')
apple.sell(5)

실행결과 ▶


생성자 호출

자식 클래스에 __init__이 없을 경우

부모의 생성자가 자동으로 호출된다.

class Fruit:
    def __init__(self, name):
        print("Fruit 생성자 호출")
        self.name = name

class Apple(Fruit):
    pass

apple = Apple("사과")

실행결과 ▶

 

하지만 자식 클래스에서 생성자(__init__)를 직접 만들면
부모 생성자는 자동 호출되지 않는다.

 

아래 코드 이미지는 자식 클래스에서 자체 생성자를 정의하긴 했으나

부모 생성자를 호출하지 않아 변수가 생성되고

에러가 발생하게 되는 실패 예시이다.

 

하지만 아래 코드로 작성하면 

부모 클래스가 가진 원래의 기능들을 전혀 물려받지 못하고

이름만 동일한 변수를 새로 만드는것으로

에러를 막을 순 있지만 상속의 역할에는 맞지않다.

class Fruit:
    def __init__(self, name):
        print("Fruit 생성자 호출")
        self.name = name

class Apple(Fruit):
    # 자식의 생성자를 새로 만듦
    def __init__(self, name):
        print("Apple 생성자 호출")
        self.name = name    # 이걸 사용하면 오류는 안나지만 부모의 생성자를 호출하진 않음
        
# 자식 클래스는 부모 기능을 그대로 사용하거나 or
# 필요한 부분을 덮어쓰기(오버라이딩)할 수 있다
apple = Apple("사과")

 

그래서 자식 클래스 안에서 부모 클래스의 생성자를 안전하게 호출할 수 있는 기능이 필요한데

이때 사용할 수 있는것이 super()이다.


super()

super()는 부모 클래스를 참조하여,

부모 클래스의 생성자나 메서드를 호출할 수 있다.

쉽게말해, 자식의 방에서 부모님 방의 초기화 기능을 먼저 실행할래! 라고 요청하는 것이다.

class Fruit:
    def __init__(self, name):
        # [3단계] 부모 생성자 실행
        # super().__init__(name)에 의해 실행
        print("Fruit 생성자 호출")   # 화면에 첫 번째로 출력: "Fruit 생성자 호출"
        self.name = name            # 객체에 name 속성('사과')을 저장합니다.
        
class Apple(Fruit):
    def __init__(self, name):
        # [2단계] 자식 생성자 시작, 부모 호출
        # 부모 클래스(Fruit)의 __init__을 먼저 호출하면서 '사과'라는 값을 넘겨줌
        super().__init__(name)  
        
        # [4단계] 다시 자식 생성자로 돌아와 남은 코드 실행
        # 부모 생성자의 작업이 끝나면 여기로 돌아와 다음 줄을 실행
        print("Apple 생성자 호출") # 화면에 두 번째로 출력: "Apple 생성자 호출"

# [1단계] 객체 생성
# Apple 클래스의 인스턴스를 만들며 생성자(__init__)를 호출
# 이때 매개변수 name에 사과가 들어감
apple = Apple("사과")

실행결과 ▶

  • 1. apple = Apple("사과") 객체 생성 시작
  • 2. super().__init__(name) 자식 생성자에서 부모를 호출
  • 3. class Fruit > def __init__(self, name) 부모 생성자 실행
  • 4. print("Fruit 생성자 호출") 부모 생성자 실행되어 출력
  • 5. print("Apple 생성자 호출") 부모 작업 종료, 다시 자식 생성자 돌아와 코드 실행

오버라이딩 (Overriding)

오버라이딩은 부모클래스에 이미 정의 된 메서드

자식 클래스에서 재정의 하는것으로 동작을 변경하는것을 의미한다.


아래 코드를 이용해 출력한 코드를 확인해보면

같은 eat() 메서드라도
객체에 따라 동작이 달라지는 것을 볼 수 있다.

class Fruit:
    def __init__(self, name, quantity):
        self.name = name
        self.quantity = quantity

    def eat(self):
        print(f'{self.name}를 먹습니다')

    def store(self, place):
        print(f'{self.name}를 {place}에 보관합니다')

# 부모를 상속받음
class Apple(Fruit):
    # 추가기능(확장)
    def wash(self):
        print(f'{self.name}를 깨끗이 씻습니다')

    # ★★오버라이딩(덮어쓰기)
    def eat(self):
        print(f'{self.name}를 아주 맛있게 먹습니다')

    # 부모 메서드 호출
    # : 덮어쓴 부모의 eat메소드를 일반함수로 만들어 super와 함께 사용해 부모의 메서드를 사용
    def superEat(self):
        super().eat()

 

1) 객체별로 독립적인 동작

부모 클래스로 자식 클래스로 각각 생성된 객체는 

입력값이 같더라도 메모리상에 독립적인 공간을 가진다.

 

그렇기 때문에 자식 객체 apple은 부모와 다르게

오버라이딩 된 자식 고유의 특별한 메서드를 실행하여 출력하게 된다.

apple = Apple('사과', 10)
apple = Fruit('사과', 10)
# 위 처럼 각각 입력해도 가능하며 이는 다른 객체를 의미한다
# 왜? 클래스로 객체를 만들때는 입력한 값이 똑같더라도
# 호출할때마다 메모리에 완전히 독립된 새로운 공간이 매번 만들어지는것임

apple.eat()             # 오버라이딩된 메서드
apple.store('냉장고')   # 부모 메서드
apple.wash()            # 자식 메서드

 

실행결과 ▶

 

2) super() 이용

자식 클래스에서 메서드를 오버라이딩해서 내용을 바꾸더라도

내부에서 super().메서드명()을 활용해

부모 클래스가 가지고 있는 원래의 동작을 그대로 실행한다.

print("----- 부모 메서드 호출 -----")
apple.superEat()   # 부모 eat 호출

fruit = Fruit('과일', 20)
fruit.eat()
fruit.store('창고')
# fruit.wash()  (Fruit에는 없음)

실행결과 ▶

 


다중 상속(Multiple Inheritance)

Python은 하나의 자식 클래스가

여러 부모 클래스를 동시에 상속받을 수 있는 기능이 있는데

이를 다중 상속이라고 한다.

class Parent1:
    pass

class Parent2:
    pass

class Child(Parent1, Parent2):
    pass

👍다중 상속의 장점

다양한 부모로부터 여러 기능을 가져와 사용할 수 있기 때문에

코드 재사용성, 유연한 클래스 설계가 가능하다.

쉽게 말해, 부모의 다른 두가지 성격을 모두 가지고있는 자식 클래스를

직관적으로 설계할 수 있고 객체 기능 확장에 유리하다.

 

👎다중 상속의 단점

클래스 구조자체가 복잡해지고 한눈에 파악하기 어려울 수 있다.

때문에 유지보수가 어려울 수 있으며,

부모 클래스 중 하나가 변경될 경우

자식, 형제 클래스에 미치는 영향을 쉽게 예측하기가 어렵다.


다중상속 구조

# 부모클래스 1
class Fruit:
    def __init__(self, name, quantity):
        self.name = name
        self.quantity = quantity

    def eat(self):
        print(f'{self.name}를 먹습니다')

    def sleep(self, hour):
        print(f'{self.name}를 {hour}시간 동안 보관합니다')

# 부모클래스 2
class Storage:
    def __init__(self, name, quantity):
        self.name = name
        self.quantity = quantity

    def process(self, hour):
        print(f'{self.name}를 {hour}시간 동안 가공합니다')

    # 부모클래스1에도 동일한 메서드 존재
    def sleep(self, hour):
        print(f'{self.name}를 {hour}시간 동안 저온 숙성합니다')

# 다중 상속(상속 순서 : Fruit가 Storage보다 앞)
class ProcessedFruit(Fruit, Storage):
    pass
fruit = ProcessedFruit('사과', 10)

fruit.eat()         # Fruit 메서드 호출
fruit.process(2)    # Storage 메서드 호출
fruit.sleep(8)      # MRO에 따라 Fruit의 sleep 실행

print(ProcessedFruit.mro())

실행결과 ▶

MOR : 내 바로 위 부모는 누구? 그 다음 부모는 누구? 라는 교통 질서
[<class '__main__.ProcessedFruit'>, <class '__main__.Fruit'>, <class '__main__.Storage'>, <class 'object'>] 해석
     : ProcessedFruit 자식이고, 그 위의 부모는 Fruit, 그 다음 부모는 Storage...


object 클래스

Python의 모든 클래스는 내부적으로 object 클래스를 상속받는다.

사용자가 클래스를 정의할 때,

명시적으로 상속하지 않아도 자동으로 상속되는 기본 클래스

__str__, __repr__, __eq__와 같은 매직 메서드들이 있다.

class Fruit(object):
    pass

object는 Python 객체 시스템의 가장 최상위 부모 클래스이다.


MRO(Method Resolution Order)

MRO는 다중 상속 시
메서드를 어떤 순서로 찾을지 결정하는 규칙이다.

 

다중 상속에서는 부모 클래스 순서가 굉장히 중요하다.

MRO 순서에 따라 실행 결과가 달라질 수 있다.

class Base:
    def hello(self):
        # 7.Base 출력
        print("Base")
        
class Clean(Base):
    def hello(self):
        # 3.Clean 출력
        print("Clean")
        # 4.Pack 호출
        super().hello()
        # 8.돌아와서 끝
        print("Clean 끝")

class Pack(Base):
    def hello(self):
        # 5.Pack 출력
        print("Pack")
        # 6.Base 호출
        super().hello()
        # 7.돌아와서 끝
        print("Pack 끝")

# 생성자 없고, Clean, Pack 상속받음
class Product(Clean, Pack):
    def hello(self):
        # 1.클래스 호출 시 Product 찍어라
        print("Product")
        # 클래시 호출 시 부모의 hello 메서드 찍어라
        # → hello가 두개라서 충돌발생, MRO로 확인해야함
        # 2.Clean호출
        super().hello()
        # 9.돌아와서 끝
        print("Product 끝")
# 객체 생성
p = Product()
p.hello()

# 클래스 호출
# : Product의 가장 가까운 부모는 Clean임
# : Product → Clean → Pack → Base
print(Product.mro())

실행결과 ▶


3. 다형성

다형성(polymorphism)은 같은 이름의 메서드나 함수를 호출하더라도

객체의 종류에 따라 서로 다른 방식으로 동작하는 특성이 있다.

 

즉, 여러가지 형태를 가질 수 있다는 것이다.

 

파이썬은 상속 관계가 아니더라도

메서드의 이름만 같으면 동일하게 취급하는 '덕 타이핑(Duck Typing)'을 지원하기 때문에

다형성을 사용하여 코드의 유연성과 확장성을 높일 수 있다.

※ 덕 타이핑 : 객체의 실제 클래스가 무엇인지는 중요하지 않고, 필요한 메서드나 기능을 가지고 있으면 같은 방식으로 사용하는 것


다형성 구조

아래 코드를 통해 다형성 작동 원리를 볼 수 있는데

카드, 현금, 카카오페이 클래스가 각각 독립적으로 존재하지만,

공통 처리 함수를 통해 하나의 인터페이스로 움직이는것을 확인할 수 있다.

# 1. 다양한 결제 방식 담당할 클래스들 정의
# 상속 관계가 아니더라도 'pay'라는 동일한 이름의 메서드를 가지고 있음

class CardPayment:
    def pay(self, amount):
        # 카드로 결제할 때의 고유 동작 정의
        print(f"카드로 {amount}원 결제합니다")

class CashPayment:
    def pay(self, amount):
        # 현금으로 결제할 때의 고유 동작 정의
        print(f"현금으로 {amount}원 결제합니다")

class KakaoPay:
    def pay(self, amount):
        # 카카오페이로 결제할 때의 고유 동작 정의
        print(f"카카오페이로 {amount}원 결제합니다")

# ※주의 : 들여쓰면 클래스 안에 메서드가 됨! 이것은 별도의 함수임!!
# 2. 다형성을 활용하는 공통 처리 함수
# 파이썬은 '덕 타이핑(Duck Typing)'을 지원하므로, payment 매개변수로 어떤 클래스의 객체가 들어오든 
# 내부적으로 .pay() 메서드만 가지고 있다면 군말 없이 실행
def process_payment(payment, amount):
    # 인자로 받은 객체의 pay 메서드를 호출
    # 어떤 객체냐에 따라 (Card, Cash, Kakao) 실제 실행되는 결과(다형성)가 달라짐
    payment.pay(amount)
# 3. 실제 객체(인스턴스)를 생성
card = CardPayment()
cash = CashPayment()
kakao = KakaoPay()

# 4. 동일한 함수(process_payment)에 서로 다른 객체를 전달하여 호출
# 실행하는 코드는 같지만, 넘겨받은 객체의 종류에 따라 각각 다르게 동작
process_payment(card, 10000)
process_payment(cash, 5000)
process_payment(kakao, 2000)

실행 결과 ▶


다형성의 장점

위 코드를 기준으로 새로운 결제방식(토스페이, 네이버페이)이 추가되더라도,

공통 처리 함수(process_payment)를 수정 할 필요 없이

새로운 클래스만 만들어 바로 끼워넣을 수 있다.

 

그리고 객체의 종류별로 pay_with_card(), pay_with_cash() 이런 함수를 만들 필요가 없어

코드가 깔끔해지는 장점이 있다.


4. 추상화

추상화(Abstraction)는 코드 내부의 복잡한 구현 사항은 숨겨두고

외부에서 필요한 핵심 기능만 간단하게 사용할 수 있도록 해주는것이다.

 

즉, 어떻게 작동하는지는 숨기고,

무엇을 할 수 있는지만 보여주는 것이라고 생각하면 쉽다.


추상화 구조

아래 코드는 다형성 예제 코드에 추상화 규칙을 결합해서

자식 클래스들이 반드시 pay 메서드를 구현하도록 하는 구조이다.

# abc = 모듈이름
# abc라는 파일안에 
# ABC라는 클래스, abstractmethod 함수를 가져다 쓰겠다는 뜻(메모리에 올리겠다)
from abc import ABC, abstractmethod
# 추상 클래스
# Payment가 ABC 상속받음 → abstractmethod 사용하기 위해
# : 상속받았기 때문에 강제성이 발동됨
# : 이 클래스는 자식을 감시하고 강제해라 하는 감독관 역할

# + 만약 @abstractmethod가 없거나 ABC 상속이 빠지면 강제성은 완전히 사라짐
class Payment(ABC): 
    
    # 데커레이션을 사용하여 호출함
    # : 이 pay 메서드는 그냥 평범한게 아니라,
    # : 자식이 알맹이를 채워 넣어야하는 추상메서드야!
    @abstractmethod # 강제할 규칙
    def pay(self, amount): # 원본 pay
        pass  # 반드시 구현해야 하는 메서드

# 카드 결제
class CardPayment(Payment): # Payment 상속받음
    def pay(self, amount):  
    # pay를 구현 한 이유?
    # 자식클래스는 다른건 몰라도 pay라는 메서드는 무조건 만들어라라는 강제성이 있음.
    # pay가 아닌 다른것을 사용하면?
    # : 규칙을 지키지 않았으니, 이 클래스는 실행(객체생성)을 할 수 없어!
    # : TypeError발생됨
        print(f"카드로 {amount}원 결제합니다")

# 현금 결제
class CashPayment(Payment):
    def pay(self, amount):
        print(f"현금으로 {amount}원 결제합니다")

# 카카오페이
class KakaoPay(Payment):
    def pay(self, amount):
        print(f"카카오페이로 {amount}원 결제합니다")

# 공통 처리 함수
def process_payment(payment: Payment, amount):
    payment.pay(amount)
# 사용
card = CardPayment()
cash = CashPayment()
kakao = KakaoPay()

process_payment(card, 10000)
process_payment(cash, 5000)
process_payment(kakao, 2000)

실행결과 ▶


추상화의 장점

추상화는 여러명의 개발자가 협업할 때,

결제 클래스에는 무조건 pay라는 메서드를 포함해야해! 라는 규칙을 강제하기 때문에

개발 표준을 맞출 수 있다.

 

그리고 잘못 설계된 클래스가 실제 실행 코드로 넘어가기 전

컴퓨터가 에러를 발생시키기 때문에 버그를 미리 방지할수도 있다.


마무리

  • 캡슐화 → 데이터를 안전하게 보호한다.
  • 상속 → 코드 재사용성을 높인다.
  • 다형성 → 유연하고 확장 가능한 구조를 만든다.
  • 추상화 → 복잡한 구현을 단순한 인터페이스로 제공한다.