접근 제어
Python은 Java나 C++처럼 private, protected, public 키워드를 제공하지 않습니다. 대신 네이밍 컨벤션과 name mangling이라는 메커니즘으로 접근 제어를 구현합니다. Python의 철학은 "We're all consenting adults here(우리는 모두 동의하는 성인입니다)"입니다 — 개발자를 신뢰하고 관례를 따르도록 합니다.
Public: 기본 접근 수준
접두사 없이 선언된 속성과 메서드는 public입니다. 어디서든 자유롭게 접근할 수 있습니다.
class BankAccount:
def __init__(self, owner: str, balance: float):
self.owner = owner # public 속성
self.balance = balance # public 속성
def deposit(self, amount: float) -> None: # public 메서드
self.balance += amount
def get_info(self) -> str: # public 메서드
return f"{self.owner}: {self.balance}원"
acc = BankAccount("김철수", 100_000)
print(acc.owner) # 김철수 — 직접 접근 가능
print(acc.balance) # 100000 — 직접 접근 가능
acc.balance = 999 # 직접 수정도 가능 (의도치 않은 수정 위험!)
acc.deposit(50_000)
print(acc.get_info())
_protected: 관례적 비공개
단일 밑줄(_)로 시작하는 속성/메서드는 protected입니다. 실제로는 아무 제한이 없지만, "이 속성은 내부용이니 외부에서 직접 사용하지 마세요"라는 신호입니다.
class DataProcessor:
def __init__(self, data: list):
self.data = data # public
self._processed = False # protected — 내부 상태
self._cache: dict = {} # protected — 캐시
def process(self) -> list:
"""public API — 외부에서 사용"""
if self._is_already_processed():
return self._cache.get("result", [])
result = self._do_processing()
self._cache["result"] = result
self._processed = True
return result
def _do_processing(self) -> list:
"""protected — 내부 구현 세부사항"""
return [x * 2 for x in self.data]
def _is_already_processed(self) -> bool:
"""protected — 내부 헬퍼 메서드"""
return self._processed
dp = DataProcessor([1, 2, 3, 4, 5])
print(dp.process()) # [2, 4, 6, 8, 10]
# _로 시작하는 속성에도 접근은 가능하지만 "하지 않는 것이 관례"
print(dp._processed) # True — 가능하지만 권장하지 않음
dp._processed = False # 가능하지만 하지 말 것!
서브클래스에서의 _protected 활용
class Animal:
def __init__(self, name: str, sound: str):
self.name = name
self._sound = sound # 서브클래스에서 접근 가능한 protected
def _make_sound(self) -> str:
"""서브클래스가 오버라이드할 수 있는 protected 메서드"""
return self._sound
def speak(self) -> str:
return f"{self.name}: {self._make_sound()}"
class Dog(Animal):
def __init__(self, name: str):
super().__init__(name, "왈왈")
def _make_sound(self) -> str: # protected 메서드 오버라이드
return f"{self._sound}! {self._sound}!"
d = Dog("바둑이")
print(d.speak()) # 바둑이: 왈왈! 왈왈!
__private: Name Mangling
이중 밑줄(__)로 시작하는 속성/메서드는 Python이 name mangling을 적용하여 외부에서 직접 접근하기 어렵게 만듭니다. __attr는 _ClassName__attr로 이름이 변경됩니다.
class SecureAccount:
def __init__(self, owner: str, password: str, balance: float):
self.owner = owner # public
self.__password = password # private — name mangling 적용
self.__balance = balance # private
def verify_password(self, password: str) -> bool:
return self.__password == password
def get_balance(self, password: str) -> float:
if not self.verify_password(password):
raise PermissionError("비밀번호가 틀렸습니다.")
return self.__balance
def deposit(self, amount: float, password: str) -> None:
if not self.verify_password(password):
raise PermissionError("비밀번호가 틀렸습니다.")
self.__balance += amount
acc = SecureAccount("김철수", "secret123", 1_000_000)
# __password에 직접 접근 시도
try:
print(acc.__password) # AttributeError!
except AttributeError as e:
print(f"접근 불가: {e}")
# 실제로는 name mangling으로 저장됨
print(acc._SecureAccount__password) # secret123 (우회 가능하지만 하지 말 것)
print(acc._SecureAccount__balance) # 1000000
# 올바른 접근 방법
print(acc.get_balance("secret123")) # 1000000
# 인스턴스의 실제 속성 이름 확인
print(acc.__dict__)
# {'owner': '김철수', '_SecureAccount__password': 'secret123', '_SecureAccount__balance': 1000000}
Name Mangling 동작 원리
class Parent:
def __init__(self):
self.__private = "부모의 private" # _Parent__private
self._protected = "부모의 protected"
def parent_method(self) -> str:
return self.__private # 클래스 내부에서는 정상 접근
class Child(Parent):
def __init__(self):
super().__init__()
self.__private = "자식의 private" # _Child__private (다른 속성!)
def child_method(self) -> str:
return self.__private # 자식의 private에 접근
child = Child()
print(child.parent_method()) # 부모의 private
print(child.child_method()) # 자식의 private
# name mangling 결과 확인
print(child.__dict__)
# {'_Parent__private': '부모의 private',
# '_protected': '부모의 protected',
# '_Child__private': '자식의 private'}
# 핵심: __private은 상속 시 이름이 겹치지 않도록 보장함
Name Mangling이 필요한 경우
class Validator:
"""유효성 검사기 기반 클래스"""
def __init__(self):
self.__errors: list[str] = [] # 서브클래스가 실수로 덮어쓰지 못하도록
def _add_error(self, msg: str) -> None:
"""서브클래스에서 호출 가능한 protected 메서드"""
self.__errors.append(msg)
def is_valid(self) -> bool:
return len(self.__errors) == 0
def get_errors(self) -> list[str]:
return list(self.__errors)
class EmailValidator(Validator):
def validate(self, email: str) -> bool:
self.__errors = [] # 이건 _EmailValidator__errors — 별도 속성!
if "@" not in email:
self._add_error("@ 기호가 없습니다.")
if "." not in email.split("@")[-1]:
self._add_error("도메인이 유효하지 않습니다.")
return self.is_valid()
v = EmailValidator()
print(v.validate("invalid")) # False
print(v.get_errors()) # ['@ 기호가 없습니다.', '도메인이 유효하지 않습니다.']
print(v.validate("user@example.com")) # True
__slots__: 속성 제한 및 메모리 최적화
__slots__를 클래스에 정의하면 인스턴스에 허용되는 속성을 명시적으로 제한하고, __dict__ 대신 고정 크기 배열을 사용하여 메모리를 절약합니다.
import sys
class PointWithDict:
"""일반 클래스: __dict__ 사용"""
def __init__(self, x: float, y: float):
self.x = x
self.y = y
class PointWithSlots:
"""__slots__ 사용: __dict__ 없음"""
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
# 메모리 비교
p1 = PointWithDict(1.0, 2.0)
p2 = PointWithSlots(1.0, 2.0)
print(f"__dict__ 사용: {sys.getsizeof(p1.__dict__)} bytes (dict 오버헤드)")
print(f"__slots__ 사용: __dict__ 없음")
# __dict__ vs __slots__
print(hasattr(p1, "__dict__")) # True
print(hasattr(p2, "__dict__")) # False
# __slots__ 클래스는 정의된 속성만 허용
try:
p2.z = 3.0 # AttributeError!
except AttributeError as e:
print(f"오류: {e}")
# 올바른 접근
p2.x = 10.0
print(p2.x) # 10.0
대규모 객체에서 __slots__의 효과
import sys
import time
class RecordWithDict:
def __init__(self, id: int, name: str, value: float):
self.id = id
self.name = name
self.value = value
class RecordWithSlots:
__slots__ = ("id", "name", "value")
def __init__(self, id: int, name: str, value: float):
self.id = id
self.name = name
self.value = value
N = 100_000
# __dict__ 버전
start = time.perf_counter()
records_dict = [RecordWithDict(i, f"item_{i}", float(i)) for i in range(N)]
time_dict = time.perf_counter() - start
mem_dict = sum(sys.getsizeof(r) + sys.getsizeof(r.__dict__) for r in records_dict)
# __slots__ 버전
start = time.perf_counter()
records_slots = [RecordWithSlots(i, f"item_{i}", float(i)) for i in range(N)]
time_slots = time.perf_counter() - start
mem_slots = sum(sys.getsizeof(r) for r in records_slots)
print(f"__dict__ 생성 시간: {time_dict:.4f}s, 메모리: {mem_dict / 1024:.1f} KB")
print(f"__slots__ 생성 시간: {time_slots:.4f}s, 메모리: {mem_slots / 1024:.1f} KB")
print(f"메모리 절약: {(1 - mem_slots / mem_dict) * 100:.1f}%")
__slots__와 상속
class Base:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
class Extended(Base):
__slots__ = ("z",) # 부모의 슬롯 + 자식의 슬롯
def __init__(self, x, y, z):
super().__init__(x, y)
self.z = z
e = Extended(1, 2, 3)
print(e.x, e.y, e.z) # 1 2 3
# 주의: 부모 클래스에 __slots__가 없으면 __dict__도 생성됨
class NoSlotParent:
pass # __dict__ 있음
class SlotChild(NoSlotParent):
__slots__ = ("value",)
obj = SlotChild()
obj.value = 1
obj.extra = "이건 __dict__에 저장됨" # 부모가 __dict__ 허용하므로 가능
Python의 접근 제어 설계 철학
Python 커뮤니티의 격언: "We're all consenting adults here"
# Python의 접근 제어는 강제가 아닌 신뢰
# 올바른 설계 원칙:
# 1. public API는 명확하게
class Config:
def __init__(self):
self.name = "app" # public: 외부에서 읽고 쓸 수 있음
self._internal = {} # protected: 내부용, 서브클래스 접근 가능
self.__secret = "xyz" # private: 이 클래스만 사용, 상속 시 이름 충돌 방지
# 2. __all__ 로 모듈 레벨 public API 명시
# (클래스가 아닌 모듈에서 from module import * 할 때 제어)
# 3. @property를 사용하여 제어된 접근 제공
class Temperature:
def __init__(self, celsius: float):
self._celsius = celsius # protected, @property로 제어된 접근 제공
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("절대 영도 이하는 불가")
self._celsius = value
# 4. __slots__는 성능이 중요한 경우에만
# 일반적인 클래스에는 필요 없음
접근 수준 요약
| 표기 | 이름 | 실제 제한 | 사용 목적 |
|---|---|---|---|
attr | Public | 없음 | 외부 API |
_attr | Protected | 관례적 (실제 없음) | 내부용, 서브클래스 허용 |
__attr | Private | Name mangling | 이름 충돌 방지, 강한 캡슐화 |
__attr__ | Dunder | Python 예약 | 매직 메서드/속성 |
고수 팁
1. _ 하나로 임시 변수나 "무시" 표시
# 반복문에서 변수 무시
for _ in range(5):
print("Hello!")
# 언패킹에서 불필요한 값 무시
first, *_, last = [1, 2, 3, 4, 5]
print(first, last) # 1 5
# REPL에서 마지막 결과
# _ 는 REPL에서 마지막 표현식의 결과를 담음
2. __all__로 모듈의 public API 명시
# mymodule.py
__all__ = ["PublicClass", "public_function"]
class PublicClass:
pass
class _InternalClass: # from mymodule import * 시 제외됨
pass
def public_function():
pass
def _internal_function(): # 제외됨
pass
3. 접근 제어 패턴 선택 기준
# 언제 무엇을 쓸까?
class MyClass:
def __init__(self):
# 외부 API라면 → public
self.public_data = []
# 내부 구현이지만 서브클래스가 필요하다면 → _protected
self._internal_state = {}
# 서브클래스도 접근하면 안 되는 중요 데이터 → __private
self.__sensitive_data = "secret"
# 하지만 실제로는 @property + _protected 조합이 가장 Python스럽다
self._temperature = 0.0
@property
def temperature(self) -> float:
return self._temperature
@temperature.setter
def temperature(self, value: float) -> None:
# 유효성 검사 후 설정
if value < -273.15:
raise ValueError("유효하지 않은 온도")
self._temperature = value
정리
- public: 접두사 없음, 외부 API
- _protected: 관례적 비공개, 서브클래스에서 사용 가능
- __private: name mangling 적용, 강한 캡슐화 및 이름 충돌 방지
__slots__: 허용 속성 제한 + 메모리 최적화, 대규모 인스턴스에 유용- Python의 철학: 강제 보다는 신뢰와 관례를 따른다