프로그래밍 인터페이스란? 서로 다른 객체를 같은 방식으로 사용하는 원리
인터페이스란? 기능의 약속과 구현을 분리하는 방법#
인터페이스는 어떤 객체를 사용하려면 그 객체가 무슨 기능을 어떤 형태로 제공해야 하는지 정한 약속입니다. 기능을 이용하는 코드는 약속된 사용법에 맞춰 호출하고, 실제 처리 방식은 각 객체가 구현합니다.
예를 들어 쇼핑몰은 카드 결제와 계좌 이체를 모두 지원할 수 있습니다. 두 결제 방식은 내부 절차가 다르지만, 주문을 처리하는 코드는 공통으로 “이 금액의 결제를 요청하고 결과를 받는다”라는 기능이 필요합니다. 이 공통 약속을 정해 두면 새로운 결제 방식을 추가할 때 주문 처리 로직을 덜 바꾸고도 연결할 수 있습니다.
이 글에서는 Python으로 인터페이스의 개념을 설명합니다. Python에는 Java의 interface처럼 독립된 전용 키워드는 없지만, 추상 기본 클래스나 프로토콜을 이용해 기능의 약속을 표현할 수 있습니다.
인터페이스를 이해하는 핵심 질문#
인터페이스를 설계할 때는 다음 두 질문을 구분해야 합니다.
- 무엇을 제공하는가? — 기능의 이름, 필요한 입력, 반환 결과, 사용 규칙
- 어떻게 처리하는가? — 내부 계산, 외부 서비스 호출, 데이터 저장 방식
결제 예제라면 “결제 금액을 받아 성공 여부를 반환한다”가 사용할 쪽에서 필요한 약속입니다. 카드사와 통신할지, 시험용으로 성공을 반환할지는 구현마다 다를 수 있습니다.
다만 메서드 이름과 매개변수만 같다고 약속이 완성되지는 않습니다. 금액의 단위, 실패를 알리는 방법, 중복 결제를 어떻게 다룰지처럼 동작의 의미도 실제 협업에서는 합의해야 합니다.
상속만으로 해결하기 어려운 관계#
앞서 살펴본 상속은 “전사는 캐릭터의 한 종류다”처럼 대상 사이에 자연스러운 종류 관계가 있을 때 이해하기 쉽습니다.
하지만 기사와 수정 구슬을 생각해 보겠습니다. 둘 다 공격을 받을 수는 있습니다. 그렇다고 수정 구슬을 캐릭터의 한 종류로 만드는 것은 어색합니다.
두 대상의 공통점은 정체가 아니라 제공하는 기능입니다.
| 대상 | 무엇인가? | 공통으로 할 수 있는 일 |
|---|---|---|
| 기사 | 캐릭터 | 피해를 받을 수 있다 |
| 수정 구슬 | 게임 속 물체 | 피해를 받을 수 있다 |
“공격받을 수 있다”라는 약속만 따로 표현하면, 기사는 자신의 체력을 줄이고 수정 구슬은 자신의 내구도를 줄이도록 만들 수 있습니다. 공격하는 코드는 상대가 어떤 종류인지 몰라도 같은 기능을 호출할 수 있습니다.
‘USB 포트’ 비유의 유용한 부분과 한계#
인터페이스는 흔히 USB 포트에 비유합니다. 연결 규칙이 정해져 있으면 사용자는 장치 내부 회로를 몰라도 정해진 방식으로 장치와 상호작용할 수 있다는 뜻입니다.
다만 USB 커넥터 모양만 같다고 모든 장치가 동일한 기능을 제공하지는 않습니다. 키보드와 저장 장치는 역할이 다릅니다. 소프트웨어 인터페이스도 마찬가지입니다. 형식뿐 아니라 호출했을 때 기대하는 동작까지 맞아야 교체 가능한 부품이 됩니다.
Python에서 인터페이스를 표현하는 첫 번째 방법: 추상 기본 클래스#
Python의 abc 모듈에는 추상 기본 클래스와 추상 메서드를 정의하는 도구가 있습니다. 이를 이용해 “이 기능을 제공해야 한다”는 약속을 명시할 수 있습니다.
공격받을 수 있는 대상 정의하기#
from abc import ABC, abstractmethod
class Attackable(ABC):
@abstractmethod
def take_damage(self, amount):
"""피해량을 받아 상태를 변경한다."""Attackable은 피해를 받는 동작의 이름과 입력을 정합니다. 이 코드만으로 피해 처리 방식이 결정되지는 않습니다.
이제 기사와 수정 구슬이 각자의 방식으로 메서드를 구현합니다.
class Knight(Attackable):
def __init__(self, name, hp):
self.name = name
self.hp = hp
def take_damage(self, amount):
if amount <= 0:
raise ValueError("피해량은 0보다 커야 합니다.")
self.hp = max(0, self.hp - amount)
print(f"기사 {self.name}: 남은 체력 {self.hp}")
class MagicCrystal(Attackable):
def __init__(self, durability):
self.durability = durability
def take_damage(self, amount):
if amount <= 0:
raise ValueError("피해량은 0보다 커야 합니다.")
reduced = amount * 2
self.durability = max(0, self.durability - reduced)
print(f"수정 구슬: 남은 내구도 {self.durability}")같은 피해량 10을 전달해 보겠습니다.
targets = [
Knight("아서스", 100),
MagicCrystal(50),
]
for target in targets:
target.take_damage(10)실행 결과:
기사 아서스: 남은 체력 90
수정 구슬: 남은 내구도 30반복문은 대상이 기사인지 수정 구슬인지 확인하지 않습니다. 각 객체가 take_damage(10)에 자기 방식으로 응답합니다. 이는 인터페이스에 따른 사용과 다형성이 함께 작동하는 예입니다.
추상 메서드를 구현하지 않으면 어떻게 될까?#
class Tree(Attackable):
pass
tree = Tree()Tree는 Attackable을 상속한다고 선언했지만 take_damage()를 구현하지 않았습니다. 따라서 이 예제에서 Tree()로 인스턴스를 만들려 하면 TypeError가 발생합니다.
반면 다음 코드는 실행할 수 있습니다.
class OrdinaryTree:
def photosynthesize(self):
print("광합성을 합니다.")
tree = OrdinaryTree()
targets.append(tree) # 리스트에 추가하는 행위 자체는 가능하다.Python의 일반적인 list는 추가되는 객체가 Attackable인지 자동 검사하지 않습니다. 위 목록을 순회하며 tree.take_damage(10)을 호출할 때 해당 메서드가 없어 오류가 납니다.
원문에는 공격받을 수 없는 객체를 목록에 넣는 순간 오류가 난다는 설명이 있었지만, 일반 Python 리스트에서는 그렇지 않습니다. 어떤 시점에 계약을 검사하는지 구분해야 장애를 정확히 이해할 수 있습니다.
인터페이스가 협업에 도움이 되는 이유#
여러 팀이 하나의 쇼핑몰을 만든다고 가정해 보겠습니다.
- 주문 팀은 결제 기능을 호출해야 합니다.
- 카드 결제 팀은 카드사 연동을 구현합니다.
- 계좌 이체 팀은 계좌 이체 절차를 구현합니다.
아무 약속 없이 개발하면 팀마다 메서드 이름과 반환 방식이 달라질 수 있습니다.
card.pay_by_card(50_000)
bank.transfer_money(50_000)주문 팀은 결제 방식별 사용법을 모두 알아야 합니다. 결제 방식이 추가되면 주문 처리 코드에도 별도 분기가 생기기 쉽습니다.
먼저 공통으로 필요한 기능을 정의해 보겠습니다.
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def process_payment(self, amount):
"""
amount: 원 단위의 양의 정수
반환값: 결제 성공이면 True, 실패하면 False
"""이제 카드 결제와 계좌 이체 구현체는 같은 호출 형태를 제공해야 합니다.
class CardPaymentGateway(PaymentGateway):
def process_payment(self, amount):
if amount <= 0:
raise ValueError("결제 금액은 0보다 커야 합니다.")
print(f"카드 결제 요청: {amount}원")
return True
class BankTransferGateway(PaymentGateway):
def process_payment(self, amount):
if amount <= 0:
raise ValueError("결제 금액은 0보다 커야 합니다.")
print(f"계좌 이체 결제 요청: {amount}원")
return True이 코드는 동작 구조를 보여주기 위한 예제입니다. True를 반환한다고 실제 카드 승인이나 계좌 이체가 완료되는 것은 아닙니다.
주문 처리 함수는 결제 방식의 내부 구현을 호출하지 않고 공통 메서드만 사용합니다.
def execute_purchase(item_price, payment_gateway: PaymentGateway):
if item_price <= 0:
raise ValueError("상품 가격은 0보다 커야 합니다.")
is_success = payment_gateway.process_payment(item_price)
if is_success:
print("구매를 완료했습니다.")
else:
print("결제를 완료하지 못했습니다.")execute_purchase(50_000, CardPaymentGateway())
execute_purchase(50_000, BankTransferGateway())결제 방식을 바꿀 때 execute_purchase()의 내부를 수정하지 않습니다. 어떤 구현체를 전달할지만 바뀝니다.
Python의 타입 힌트가 하는 일#
def execute_purchase(item_price, payment_gateway: PaymentGateway):여기서 PaymentGateway는 매개변수에 기대하는 타입을 나타냅니다. 읽는 사람과 정적 타입 검사 도구에는 유용하지만, 이 타입 힌트만으로 Python이 호출 시 모든 인자를 자동 차단하지는 않습니다.
예를 들어 전혀 다른 객체를 전달해도 함수에 들어가는 순간 곧바로 타입 검사 오류가 발생하는 것은 아닙니다. 이후 process_payment()를 호출했을 때 그 메서드가 없으면 오류가 납니다. 필요한 경우 정적 타입 검사나 별도의 실행 시점 검증을 함께 사용합니다.
구현이 완성되기 전에 주문 로직을 개발하기#
인터페이스가 정해져 있다면 실제 카드사 연동이 아직 없어도 시험용 구현체로 주문 흐름을 개발할 수 있습니다.
class TestPaymentGateway(PaymentGateway):
def __init__(self, should_succeed):
self.should_succeed = should_succeed
self.requested_amounts = []
def process_payment(self, amount):
self.requested_amounts.append(amount)
return self.should_succeed성공 상황과 실패 상황을 각각 확인할 수 있습니다.
success_gateway = TestPaymentGateway(should_succeed=True)
execute_purchase(50_000, success_gateway)
print(success_gateway.requested_amounts) # [50000]failure_gateway = TestPaymentGateway(should_succeed=False)
execute_purchase(50_000, failure_gateway)
print(failure_gateway.requested_amounts) # [50000]시험용 객체는 실제 외부 서비스를 호출하지 않습니다. 결제를 요청하는 쪽에서 금액을 올바르게 전달했는지, 성공과 실패에 각각 어떻게 반응하는지 확인하는 데 사용합니다.
이렇게 구현을 바꿔 끼울 수 있는 이유는 주문 로직이 카드사의 구체적인 메서드 이름이 아니라 PaymentGateway의 약속에 맞춰 작성되었기 때문입니다.
느슨한 결합이란 무엇인가?#
두 코드가 서로에 대해 알아야 할 내용이 많을수록 한쪽 변경이 다른 쪽에 미치는 영향도 커집니다. 이를 강한 결합이라고 부릅니다.
def execute_purchase(item_price, payment_method):
if payment_method == "card":
gateway = CardPaymentGateway()
elif payment_method == "bank":
gateway = BankTransferGateway()
else:
raise ValueError("지원하지 않는 결제 방식입니다.")
return gateway.process_payment(item_price)이 함수는 결제 객체를 직접 만들고, 결제 방식별 클래스를 알고, 종류를 선택합니다. 새로운 결제 방식이 생기면 이 함수도 수정해야 합니다.
결제 객체를 밖에서 전달받으면 주문 함수가 알아야 할 내용이 줄어듭니다.
def execute_purchase(item_price, payment_gateway: PaymentGateway):
return payment_gateway.process_payment(item_price)gateway = CardPaymentGateway()
is_success = execute_purchase(50_000, gateway)이를 의존성 주입의 한 형태라고 합니다. 필요한 부품을 함수 내부에서 고정해 만들지 않고, 호출하는 곳에서 전달하는 것입니다.
느슨한 결합은 두 코드가 아무 관계도 없다는 뜻이 아닙니다. 주문 함수는 여전히 process_payment()라는 약속에 의존합니다. 다만 카드 결제와 계좌 이체의 내부 구현까지 알 필요가 없어진 것입니다.
“구현을 바꾸면 운영 중에도 자동으로 교체된다”는 뜻은 아니다#
같은 인터페이스를 따르더라도 실제 서비스에서 결제사를 바꾸려면 설정, 인증 정보, 반환 결과 해석, 장애 대응, 데이터 정합성 등을 확인해야 합니다.
인터페이스는 호출하는 코드가 변경의 영향을 적게 받도록 돕는 설계 수단입니다. 외부 서비스 교체에 필요한 운영 작업까지 자동으로 해결해 주지는 않습니다.
Python에서는 Protocol도 사용할 수 있다#
추상 기본 클래스 방식은 구현 클래스가 명시적으로 PaymentGateway를 상속합니다. Python에서는 typing.Protocol을 사용해 필요한 메서드의 형태를 표현할 수도 있습니다.
from typing import Protocol
class Notifier(Protocol):
def send(self, message: str) -> None:
...알림을 보내는 클래스들은 Notifier를 상속하지 않아도 send() 메서드를 제공할 수 있습니다.
class EmailNotifier:
def send(self, message: str) -> None:
print(f"이메일: {message}")
class ConsoleNotifier:
def send(self, message: str) -> None:
print(f"화면: {message}")def announce(notifier: Notifier, message: str) -> None:
notifier.send(message)
announce(EmailNotifier(), "점검이 시작됩니다.")
announce(ConsoleNotifier(), "점검이 시작됩니다.")정적 타입 검사 도구는 Notifier에 필요한 메서드 형태가 있는지 검사하는 데 도움을 줄 수 있습니다. 일반적인 Python 실행 과정에서 이 타입 힌트만으로 계약이 자동 강제되는 것은 아닙니다.
| 방법 | 구현 클래스가 명시적으로 상속하는가? | 주된 특징 |
|---|---|---|
추상 기본 클래스 (ABC) |
그렇다 | 필수 추상 메서드를 구현하지 않으면 해당 클래스를 인스턴스화할 수 없다 |
프로토콜 (Protocol) |
반드시 그럴 필요는 없다 | 필요한 메서드 형태를 기준으로 타입을 표현한다 |
입문 단계에서는 먼저 필요한 기능을 약속하고 여러 구현에서 동일하게 사용한다는 원리를 이해하면 됩니다. ABC와 Protocol 중 무엇을 선택할지는 프로젝트에서 원하는 계약의 표현 방식에 따라 결정할 수 있습니다.
인터페이스를 설계할 때 주의할 점#
메서드 이름만 같게 만들지 않기#
두 결제 구현체에 process_payment()가 있어도 하나는 원 단위를 받고 다른 하나는 달러 단위를 받는다면 안전하게 교체할 수 없습니다.
gateway.process_payment(50_000)이 50_000이 무엇을 뜻하는지, 성공과 실패를 어떤 값으로 반환하는지까지 약속해야 합니다. 실제 결제 시스템이라면 단순한 True·False만으로는 부족할 수 있습니다. 승인 식별자, 실패 사유, 재시도 가능 여부 등이 필요하다면 반환 형식을 설계해야 합니다.
서로 다른 책임을 하나에 몰아넣지 않기#
모든 구현체가 결제, 환불, 영수증 발송, 고객 조회를 반드시 제공하도록 요구하면 일부 구현에는 불필요한 기능까지 들어갑니다. 사용 목적이 다른 기능은 적절히 나누어 정의하는 편이 이해하기 쉽습니다.
모든 클래스에 인터페이스를 만들 필요는 없다#
구현이 하나뿐이고 교체하거나 여러 곳에서 공통으로 사용할 이유가 없다면 인터페이스가 설계만 복잡하게 만들 수 있습니다. 다음과 같은 필요가 있을 때 검토하면 좋습니다.
- 같은 기능을 제공하는 구현체가 여러 개 있다.
- 외부 서비스나 저장 방식을 시험용 객체로 대체해야 한다.
- 여러 팀이 먼저 호출 규칙을 합의하고 개발해야 한다.
- 사용하는 코드가 특정 구현의 세부사항을 지나치게 많이 알고 있다.
인터페이스의 목적은 파일이나 클래스의 수를 늘리는 것이 아니라, 서로 협력해야 하는 코드 사이의 약속을 분명히 하는 것입니다.
직접 확인해 보기#
다음 코드를 실행하기 전에 결과를 예상해 보세요.
from abc import ABC, abstractmethod
class MessageSender(ABC):
@abstractmethod
def send(self, text):
pass
class EmailSender(MessageSender):
def send(self, text):
return f"이메일 발송: {text}"
class SmsSender(MessageSender):
def send(self, text):
return f"문자 발송: {text}"
def deliver_notice(sender: MessageSender, notice):
print(sender.send(notice))
deliver_notice(EmailSender(), "예약이 완료되었습니다.")
deliver_notice(SmsSender(), "예약이 완료되었습니다.")실행 결과:
이메일 발송: 예약이 완료되었습니다.
문자 발송: 예약이 완료되었습니다.deliver_notice()는 이메일과 문자 중 어느 방식인지 검사하지 않습니다. MessageSender에 정한 send()를 호출하고, 결과는 전달받은 객체의 구현에 따라 달라집니다.
연습으로 ConsoleSender를 추가해 보세요. deliver_notice()를 고치지 않고 화면 출력 기능을 연결할 수 있다면 인터페이스를 통한 교체가 어떻게 이루어지는지 이해한 것입니다.
정리#
인터페이스는 함께 사용할 객체들이 제공해야 할 기능의 약속을 정합니다. 사용하는 코드는 그 약속에 맞춰 호출하고, 각 구현체는 내부 작업을 자신에게 맞게 처리합니다.
Python에서는 추상 기본 클래스로 필수 구현을 명시하거나, 프로토콜로 필요한 메서드 형태를 표현할 수 있습니다. 두 방식 모두 사용할 수 있지만 실행 시점에 무엇이 검사되는지는 다릅니다.
좋은 인터페이스를 설계하려면 “메서드 이름을 통일했는가?”에서 멈추지 말고 어떤 입력을 받고, 무엇을 반환하며, 호출한 쪽이 어떤 동작을 기대할 수 있는가까지 정해야 합니다. 그 약속이 분명할수록 여러 사람이 만든 구현을 함께 사용하고 새로운 구현으로 교체하기 쉬워집니다.