본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

Python 언어 기본: 객체와 가변성, 함수, 데코레이터, 클래스, 타입 힌트, import

작성 2026. 11. 1.추가
목차18개 항목
출처와 참고

fru1tworld/cs-basic 모의 면접 질문 목록의 Python 질문이 묻는 개념을, Python 언어 레퍼런스(데이터 모델, 실행 모델, import 시스템)와 PEP로 확인해 정리한 노트다. 「비공개 속성과 이름 맹글링」 절의 예제는 GitHub 저장소 「Grokking the Object Oriented Design Interview」를 바탕으로 보강했다.

제너레이터·MRO·메모리 관리·GIL과 동시성·성능은 Python 언어 동작에, 가상 환경과 패키지 관리 도구는 pyenv로 Python 환경 잡기에 있다.

이름과 객체

Python의 모든 값은 객체이고, 변수는 객체에 붙인 이름(참조)이다. 객체는 정체성(id()), 타입, 값을 가진다. a = b는 객체를 복사하지 않고 같은 객체에 이름을 하나 더 붙인다. 함수 인자도 같은 방식으로 넘어간다(공유에 의한 호출, 프로그래밍 언어 의미론).

구분타입특징
불변(immutable)int, float, bool, str, bytes, tuple, frozenset, None값을 바꾸는 연산이 새 객체를 만든다. 해시 가능해 dict 키·set 원소가 될 수 있다
가변(mutable)list, dict, set, bytearray, 대부분의 사용자 클래스 인스턴스제자리에서 바뀌어, 같은 객체를 가리키는 모든 이름에서 변화가 보인다. dict 키로 쓸 수 없다
a = [1, 2]; b = a; b.append(3)   # a도 [1, 2, 3]
s = "hi"; t = s; t += "!"        # 새 str이 t에 묶인다. s는 "hi"
  • tuple은 불변이지만 안에 list가 있으면 그 리스트는 바뀐다. 이런 튜플은 해시할 수 없다.
  • +=는 가변 객체에서는 제자리 변경(__iadd__), 불변 객체에서는 새 객체 대입이다.
  • ==는 값이 같은지(__eq__), is는 같은 객체인지(정체성)를 본다. None 비교는 is None으로 한다. CPython은 -5~256 정수와 일부 문자열을 미리 만들어 재사용(인터닝)하므로 그 범위에서 is가 우연히 참이 되지만, 이는 구현 세부라 값 비교에 is를 쓰면 안 된다.

얕은 복사와 깊은 복사

방법새로 만드는 것안쪽 객체
대입 b = a없음. 같은 객체공유
얕은 복사 copy.copy(a), a[:], list(a), a.copy(), {**d}바깥 컨테이너만 새로공유한다
깊은 복사 copy.deepcopy(a)바깥과 안쪽을 재귀적으로 모두 새로공유하지 않는다
import copy
a = [[1, 2], [3]]
s = copy.copy(a);     s[0].append(9)   # a[0]도 [1, 2, 9]
d = copy.deepcopy(a); d[0].append(7)   # a는 그대로
  • deepcopy는 이미 복사한 객체를 메모 딕셔너리에 기록해 순환 참조와 같은 객체를 두 번 가리키는 구조를 그대로 보존한다.
  • 불변 객체는 복사해도 같은 객체를 돌려준다(바꿀 수 없으니 공유해도 안전하다).
  • 클래스는 __copy__·__deepcopy__로 복사 방식을 정할 수 있다. [[0] * 3] * 3이 같은 리스트 세 개를 가리키는 것도 얕은 복사 문제다. [[0] * 3 for _ in range(3)]로 만든다.

함수

인자의 종류와 *args, **kwargs

def f(a, b=2, /, c=3, *args, d, e=5, **kwargs): ...
#     위치 전용  일반  가변 위치  키워드 전용  가변 키워드
표기받는 것
*args남는 위치 인자를 튜플로
**kwargs남는 키워드 인자를 딕셔너리로
/ 앞 (3.8+)위치로만 넘길 수 있는 매개변수. 이름을 바꿔도 호출자가 깨지지 않는다
* 또는 *args 뒤키워드로만 넘길 수 있는 매개변수. sorted(xs, key=..., reverse=True)

호출할 때는 반대로 f(*lst, **dct)가 시퀀스와 딕셔너리를 인자로 펼친다. 데코레이터·래퍼가 받은 인자를 그대로 넘길 때 (*args, **kwargs)를 쓴다. 기본값은 정의 시점에 한 번만 만들어지므로 가변 기본값(def f(a=[]))은 쓰지 않는다(Python 언어 동작의 누수 패턴).

스코프와 클로저, nonlocal

이름은 LEGB 순서로 찾는다: Local(함수 안) → Enclosing(감싼 함수) → Global(모듈) → Built-in.

def counter():
    count = 0
    def inc():
        nonlocal count      # 없으면 count += 1이 inc의 지역 변수로 취급돼 UnboundLocalError
        count += 1
        return count
    return inc
 
c = counter(); c(); c()     # 2
  • 함수 안에서 이름에 대입하면 그 이름은 그 함수 전체에서 지역 변수가 된다. 바깥 변수를 읽기만 하면 선언이 필요 없지만, 다시 묶으려면 감싼 함수의 변수는 nonlocal, 모듈 변수는 global로 선언한다.
  • inc는 count라는 변수(셀)를 캡처한 클로저다. 값을 복사하지 않으므로 inc.__closure__의 셀 내용이 호출마다 바뀐다.
  • 반복문에서 만든 람다가 모두 마지막 값을 보는 함정([lambda: i for i in range(3)])도 변수 캡처 때문이다. lambda i=i: i처럼 기본값으로 값을 묶는다.

람다

lambda x: x * 2는 식(expression) 하나만 본문으로 갖는 이름 없는 함수다.

제약결과
문(statement)을 쓸 수 없다대입(3.8+의 := 제외), return, try, for, 여러 줄 본문이 안 된다
이름이 <lambda>로 나온다스택 트레이스와 디버깅이 불편하다
어노테이션·독스트링이 없다타입 힌트를 달 수 없다

sorted(key=lambda u: u.age)처럼 짧은 일회용 함수에만 쓰고, 변수에 담을 함수는 def로 쓴다(PEP 8). 클로저 규칙은 def와 같다.

데코레이터

데코레이터는 함수(또는 클래스)를 받아 새 함수를 돌려주는 호출 가능 객체다. @deco는 정의 직후 f = deco(f)를 실행하는 문법이다. 함수가 일급 객체이고 클로저가 있기 때문에 가능하다.

import functools, time
 
def timed(func):
    @functools.wraps(func)                 # __name__, __doc__, 시그니처 정보를 원래 함수에서 복사
    def wrapper(*args, **kwargs):
        t = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(func.__name__, time.perf_counter() - t)
    return wrapper
 
def retry(times):                          # 인자를 받는 데코레이터: 데코레이터를 돌려주는 함수
    def deco(func):
        @functools.wraps(func)
        def wrapper(*a, **kw):
            for i in range(times):
                try: return func(*a, **kw)
                except Exception:
                    if i == times - 1: raise
        return wrapper
    return deco
 
@retry(3)
@timed            # 아래부터 적용: f = retry(3)(timed(f))
def fetch(url): ...
  • 데코레이터 코드는 모듈을 import할 때(정의 시점) 한 번 실행되고, wrapper는 호출할 때마다 실행된다.
  • 표준 데코레이터: @property, @staticmethod, @classmethod, @functools.cache·lru_cache, @dataclass, @contextlib.contextmanager.
  • JavaScript의 같은 패턴은 함수 데코레이터에 있다.

컴프리헨션과 제너레이터 식

비교리스트 컴프리헨션 [x*x for x in xs]제너레이터 식 (x*x for x in xs)
결과원소를 모두 담은 list값을 하나씩 만드는 제너레이터(이터레이터)
평가즉시 전부 계산지연. 요청할 때마다 하나
메모리원소 수에 비례크기와 무관하게 일정
재사용여러 번 순회, 인덱싱, len()한 번 순회하면 소진. 인덱싱 불가
맞는 곳결과를 여러 번 쓰거나 크기가 작을 때큰 데이터, 바로 집계(sum(x*x for x in xs)), 파이프라인

집합 {…}, 딕셔너리 {k: v for …} 컴프리헨션도 있다. Python 3에서 컴프리헨션은 자기 스코프를 가져 반복 변수가 바깥으로 새지 않는다. 제너레이터의 동작은 Python 언어 동작에 있다.

컨텍스트 매니저

컨텍스트 매니저는 with 블록에 들어갈 때와 나올 때의 동작을 정의한 객체다. 블록이 정상으로 끝나든 예외로 끝나든 정리 코드가 실행된다. Java의 try-with-resources, C++의 RAII와 같은 목적이다.

with open("a.txt") as f:          # f = open(...).__enter__()
    data = f.read()
# 블록을 나가면 __exit__(exc_type, exc, tb)가 불린다. 예외가 있으면 그 정보가 넘어온다
메서드역할
__enter__(self)자원을 얻고, as 뒤 이름에 묶일 값을 돌려준다
__exit__(self, exc_type, exc, tb)자원을 정리한다. 참을 돌려주면 예외를 삼킨다(보통은 None을 돌려 예외를 그대로 전파)

contextlib.contextmanager로 제너레이터 하나로 쓸 수 있다. yield 앞이 진입, 뒤가 정리다. 파일, 락(with lock:), DB 트랜잭션, 임시 디렉터리, decimal 정밀도 변경처럼 「바꿨다가 반드시 되돌리는」 일에 쓴다. 비동기 버전은 async with와 __aenter__·__aexit__다.

from contextlib import contextmanager
 
@contextmanager
def timer():
    t = time.perf_counter()
    try:
        yield
    finally:
        print(time.perf_counter() - t)

클래스

클래스 변수와 인스턴스 변수

구분정의 위치저장 위치공유
클래스 변수클래스 본문클래스의 __dict__모든 인스턴스가 공유
인스턴스 변수메서드 안 self.x = …각 인스턴스의 __dict__인스턴스마다 따로
class Dog:
    kind = "canine"        # 클래스 변수
    tricks = []            # 함정: 가변 클래스 변수는 모든 인스턴스가 같은 리스트를 쓴다
    def __init__(self, name):
        self.name = name   # 인스턴스 변수

d.kind는 인스턴스 __dict__에서 먼저 찾고, 없으면 클래스(그리고 MRO)에서 찾는다. d.kind = "x"로 대입하면 인스턴스 변수가 새로 생겨 클래스 변수를 가릴 뿐 클래스 변수는 그대로다. 반면 d.tricks.append(...)는 대입이 아니라 공유된 리스트를 고친다. 인스턴스마다 다른 가변 값은 __init__에서 만든다. @classmethod는 첫 인자로 클래스(cls)를 받아 대안 생성자(from_json)에, @staticmethod는 아무것도 받지 않아 네임스페이스로 묶은 함수에 쓴다.

비공개 속성과 이름 맹글링

Python에는 private 키워드가 없다. 밑줄 하나(_x)는 「내부용」이라는 관례일 뿐이고, 밑줄 둘(__x)로 시작하는 속성은 클래스 안에서 _클래스이름__x로 이름이 바뀐다(이름 맹글링, name mangling). 하위 클래스의 같은 이름과 충돌하지 않게 하려는 장치이지 접근을 완전히 막지는 않는다.

class Product:
    def __init__(self):
        self.__maxprice = 900            # 실제 이름은 _Product__maxprice
    def sell(self):
        print(self.__maxprice)
    def set_max_price(self, price):
        self.__maxprice = price
 
p = Product()
p.__maxprice = 1000   # 바깥에서는 맹글링이 일어나지 않아 새 속성 __maxprice가 생길 뿐이다
p.sell()              # 900
p.set_max_price(1000) # 공개 메서드를 거쳐야 바뀐다
p.sell()              # 1000

객체 상태를 공개 메서드로만 바꾸게 하는 캡슐화를 Python에서는 이 관례와 맹글링, property로 흉내 낸다.

매직 메서드

매직 메서드(던더 메서드, __x__)는 언어 문법과 내장 함수가 호출하는 훅이다. 구현하면 사용자 클래스가 내장 타입처럼 동작한다.

분류메서드불리는 때
생성__new__(cls) / __init__(self)객체를 만든다(불변 타입 상속, 싱글턴에 쓴다) / 만든 객체를 초기화한다
표현__repr__ / __str__repr()·디버거·컨테이너 안 출력: 개발자용, 가능하면 다시 만들 수 있는 모양 / str()·print(): 사용자용. __str__이 없으면 __repr__을 쓴다
비교·해시__eq__, __lt__, __hash__==, <, dict 키. __eq__만 정의하면 __hash__가 None이 돼 해시할 수 없다
컨테이너__len__, __getitem__, __contains__, __iter__len(), obj[k], in, for
연산자__add__, __radd__, __iadd__+, 왼쪽이 처리 못할 때의 +, +=
호출·속성__call__, __getattr__, __getattribute__obj(), 없는 속성 접근, 모든 속성 접근
문맥__enter__, __exit__with

@dataclass는 필드 선언에서 __init__·__repr__·__eq__(옵션으로 __hash__, 순서 비교, frozen)를 만들어 준다.

프로퍼티와 디스크립터

프로퍼티(property) 는 속성 접근을 메서드 호출로 바꾼다. 처음에는 공개 속성으로 두었다가 검증이 필요해지면 호출 코드를 바꾸지 않고 프로퍼티로 바꿀 수 있어, Java식 getter·setter를 미리 만들 필요가 없다.

class Account:
    def __init__(self, balance): self.balance = balance   # setter를 거친다
    @property
    def balance(self): return self._balance
    @balance.setter
    def balance(self, v):
        if v < 0: raise ValueError("음수 불가")
        self._balance = v

디스크립터(descriptor) 는 __get__·__set__·__delete__ 중 하나를 가진 객체를 클래스 속성으로 두어, 그 속성의 접근을 가로채는 프로토콜이다. property, 메서드(함수 객체의 __get__이 self를 묶은 바운드 메서드를 만든다), classmethod, staticmethod, Django 모델 필드, SQLAlchemy 컬럼이 모두 디스크립터다.

종류정의한 메서드인스턴스 __dict__와의 우선순위
데이터 디스크립터__set__ 또는 __delete__디스크립터가 이긴다(property)
비데이터 디스크립터__get__만인스턴스 __dict__가 이긴다(메서드, functools.cached_property)

obj.x의 탐색 순서는 ① 타입의 데이터 디스크립터 → ② 인스턴스 __dict__ → ③ 타입의 비데이터 디스크립터·일반 클래스 속성 → ④ __getattr__이다. 같은 검증을 여러 속성에 재사용하려면 프로퍼티를 반복하지 않고 디스크립터 클래스 하나를 만든다(__set_name__으로 속성 이름을 받는다).

추상 클래스(abc.ABC)는 추상 클래스와 인터페이스에, 다중 상속과 MRO, 덕 타이핑은 Python 언어 동작에 있다.

타입 힌트와 정적 검사

타입 힌트(PEP 484)는 변수·매개변수·반환값에 타입을 적는 주석 문법이고, 인터프리터는 실행 중에 이를 검사하지 않는다. 검사는 mypy, pyright 같은 정적 타입 검사기가 실행 전에 한다. 힌트가 없는 코드와 섞어 쓸 수 있는 점진적 타이핑(gradual typing) 이며, 힌트가 없는 부분은 Any로 취급한다.

def first[T](xs: list[T]) -> T | None:      # 3.12+ 제네릭 문법, 3.10+ X | Y 유니언
    return xs[0] if xs else None
 
type UserId = int                           # 3.12+ 타입 별칭
도구역할
typing.Protocol구조적 타입. 상속 없이 메서드 모양으로 호환
TypedDict, dataclass딕셔너리 모양, 레코드
Literal, Final, TypeGuard값 제한, 재대입 금지, 타입 좁히기
mypy --strict, pyright힌트 누락·Any 사용까지 오류로 잡는다
pydantic, beartype힌트를 읽어 실행 중에 검증한다(API 입력 검증)
  • 힌트는 IDE 자동완성, 리팩터링 안정성, 문서 역할을 한다. 실행 속도에는 영향이 없다.
  • Python 3.14부터 어노테이션은 정의 시점이 아니라 처음 읽을 때 평가된다(PEP 649). 아직 정의되지 않은 클래스를 따옴표 없이 참조할 수 있고 import 비용이 준다. 이전 버전에서는 from __future__ import annotations로 문자열로 저장했다.
  • 타입 시스템의 일반 개념(구조적·명목적 타입, 변성, 제네릭)은 타입 시스템에 있다.

모듈과 import

모듈은 .py 파일 하나, 패키지는 모듈을 담은 디렉터리다. import x는 다음 순서로 동작한다.

  1. sys.modules(이미 import한 모듈 캐시)에 있으면 그것을 쓴다. 모듈 코드는 프로세스에서 한 번만 실행된다. 모듈 수준 객체가 사실상 싱글턴인 이유다.
  2. 없으면 sys.path(스크립트 디렉터리, PYTHONPATH, 표준 라이브러리, site-packages)를 차례로 찾아 파인더·로더가 모듈 객체를 만든다.
  3. 모듈 코드를 위에서 아래로 실행해 그 네임스페이스를 채우고, 현재 네임스페이스에 이름을 묶는다.
형태묶는 이름
import pkg.modpkg (속성으로 pkg.mod 접근)
from pkg.mod import ff. import 시점의 객체를 묶으므로 나중에 pkg.mod.f를 바꿔도 반영되지 않는다(테스트 목킹 시 주의)
from . import sibling현재 패키지 기준 상대 import. 패키지 안에서만 된다
from mod import *__all__에 적힌 이름(없으면 _로 시작하지 않는 이름). 출처가 흐려져 쓰지 않는다

__init__.py의 역할:

  • 디렉터리를 일반 패키지(regular package) 로 만든다. 패키지를 import하면 __init__.py가 먼저 실행된다.
  • 패키지 초기화 코드, 하위 모듈의 공개 API 재노출(from .core import Client), __all__ 정의를 둔다.
  • Python 3.3부터 __init__.py가 없는 디렉터리도 네임스페이스 패키지(PEP 420)로 import된다. 여러 경로에 흩어진 디렉터리를 한 패키지로 합칠 수 있지만, 일반 패키지보다 탐색이 느리고 실수로 엉뚱한 디렉터리가 패키지가 될 수 있어 보통은 __init__.py를 둔다.

if __name__ == "__main__":은 파일이 직접 실행될 때만 돌 코드를 가른다. import되면 __name__이 모듈 이름이 된다. 패키지 안 모듈은 python -m pkg.mod로 실행해야 상대 import가 동작한다. 두 모듈이 서로를 최상위에서 import하면 반쯤 초기화된 모듈을 보게 되므로(순환 import), 공통 부분을 떼어 내거나 함수 안에서 import한다.

Python 2와 3

Python 2는 2020년 1월 1일 지원이 끝났다. 3은 하위 호환을 깨고 언어의 오래된 불일치를 정리했다.

항목Python 2Python 3
문자열str은 바이트, 유니코드는 u""str은 유니코드, 바이트는 bytes. 둘을 섞으면 오류
print문(statement)함수 print()
정수 나눗셈3 / 2 == 13 / 2 == 1.5, 몫은 //
정수int와 long 구분int 하나(임의 정밀도)
range, map, zip, dict.keys()리스트를 만든다지연 객체·뷰를 돌려준다
예외except E, e:except E as e:, 예외 연쇄(raise … from …)
클래스옛 스타일 클래스와 새 스타일 클래스모두 새 스타일(object 상속), C3 MRO
그 밖-키워드 전용 인자, nonlocal, f-문자열(3.6), async/await(3.5), 타입 힌트

정리

  • 변수는 객체에 붙인 이름이다. 가변 객체는 공유되는 모든 이름에서 변화가 보이고, is는 정체성, ==는 값 비교다.
  • 얕은 복사는 바깥만, 깊은 복사는 안쪽까지 새로 만든다.
  • *args·**kwargs는 남는 위치·키워드 인자를 모으고, 바깥 변수를 다시 묶으려면 nonlocal·global이 필요하다.
  • 데코레이터는 f = deco(f)이고 functools.wraps로 원래 정보를 보존한다. 컨텍스트 매니저는 __enter__·__exit__로 정리를 보장한다.
  • 가변 클래스 변수는 모든 인스턴스가 공유한다. 프로퍼티와 메서드는 디스크립터 프로토콜로 동작한다.
  • 타입 힌트는 실행 중에 검사되지 않고 정적 검사기가 읽는다. 모듈은 sys.modules에 캐시돼 한 번만 실행된다.

관련 노트: Python 언어 동작, pyenv로 Python 환경 잡기, 추상 클래스와 인터페이스, 타입 시스템, 프로그래밍 언어 의미론, 함수 데코레이터

원본 기록10

댓글

GitHub 계정으로 댓글을 남길 수 있어요.