Python
Python은 간결하고 읽기 쉬운 문법, 동적 타입 체계, 자동 메모리 관리와 광범위한 표준 라이브러리를 제공하는 범용 프로그래밍 언어이다. 명령형·객체 지향·함수형 프로그래밍을 비롯한 여러 프로그래밍 방식을 지원하며, 운영체제 자동화와 서버 애플리케이션부터 과학 계산, 데이터 분석,...
| 분류 | 범용 프로그래밍 언어, 고급 프로그래밍 언어, 스크립트 언어 |
|---|---|
| 패러다임 | 명령형 프로그래밍, 객체 지향 프로그래밍, 함수형 프로그래밍, 구조적 프로그래밍 |
| 설계자 | 귀도 반 로섬 |
| 최초 개발 기관 | CWI |
| 최초 공개 | 1991년 |
| 선행 언어 | ABC (프로그래밍 언어) |
| 타입 체계 | 동적 타입, 강한 타입, 선택적 정적 타입 검사 |
| 메모리 관리 | 자동 메모리 관리 |
| 표준 구현체 | CPython |
| 관리 단체 | Python Software Foundation |
| 주요 라이선스 | Python Software Foundation License Version 2 |
| 파일 확장자 | .py, .pyw, .pyi |
Python은 간결하고 읽기 쉬운 문법, 동적 타입 체계, 자동 메모리 관리와 광범위한 표준 라이브러리를 제공하는 범용 프로그래밍 언어이다. 명령형·객체 지향·함수형 프로그래밍을 비롯한 여러 프로그래밍 방식을 지원하며, 운영체제 자동화와 서버 애플리케이션부터 과학 계산, 데이터 분석, 인공지능, 교육과 소프트웨어 도구 개발에 이르기까지 다양한 분야에서 사용된다.
Python은 1980년대 말 네덜란드의 수학·컴퓨터 과학 연구 기관인 CWI에서 귀도 반 로섬이 개발하기 시작했으며, 1991년에 처음 공개되었다. 언어의 초기 설계는 CWI에서 개발된 교육용 프로그래밍 언어 ABC의 영향을 받았고, ABC의 간결성과 가독성을 유지하면서 운영체제와 외부 라이브러리에 접근할 수 있는 실용적인 확장 언어를 목표로 했다.[1]
Python 문법에서는 들여쓰기가 단순한 서식이 아니라 코드 블록의 구조를 결정한다. 중괄호나 별도의 블록 종료 키워드 대신 동일한 수준의 들여쓰기로 문장의 소속 관계를 표현하며, 이러한 구조는 프로그램의 시각적 형태와 실제 실행 구조가 일치하도록 만든다.[2]
Python 프로그램에서 숫자, 문자열, 함수, 클래스와 모듈을 포함한 대부분의 값은 객체로 표현된다. 변수는 값을 직접 저장하는 고정된 공간이라기보다 객체에 연결되는 이름으로 동작하며, 객체마다 정체성, 타입과 값이 존재한다. 이러한 객체 모델은 내장 자료형뿐 아니라 연산자 오버로딩, 반복, 속성 접근과 컨텍스트 관리 같은 언어 기능에도 공통으로 적용된다.[3]
Python은 실행 중 객체의 타입을 판단하는 동적 타입 언어이지만, 함수와 변수에 타입 정보를 기록하는 타입 힌트도 지원한다. 타입 힌트는 기본적으로 프로그램의 실행 의미를 강제로 변경하지 않으며, 정적 분석기와 개발 도구가 오류 가능성을 검사하고 자동 완성이나 리팩터링 정보를 제공하는 데 사용된다. Python의 표준 타입 힌트 체계는 PEP 484를 중심으로 도입된 뒤 제네릭, 프로토콜, 구조적 타입과 새로운 타입 매개변수 문법 등으로 확장되었다.[4]
Python이라는 언어와 특정 구현체는 구분된다. 가장 널리 사용되는 기준 구현체는 C로 작성된 CPython이며, Python 소스 코드를 바이트코드 형태로 컴파일한 뒤 가상 머신에서 실행한다. 이 밖에도 JIT 컴파일을 사용하는 PyPy, Java 환경을 대상으로 하는 Jython, .NET 환경을 대상으로 하는 IronPython 등 서로 다른 실행 환경과 목적을 가진 구현체가 존재한다.[5]
Python은 언어 명세와 구현체만으로 이루어지지 않는다. 내장 함수와 표준 라이브러리, 패키지 설치 도구, PyPI, 개발 도구와 분야별 프레임워크가 하나의 생태계를 구성한다. 특히 외부 패키지를 통해 웹 개발, 수치 계산, 데이터 과학, 머신러닝, 자동화와 시스템 관리 등 언어 자체의 표준 라이브러리보다 넓은 영역으로 기능을 확장할 수 있다.[6][7]
Python의 언어 변경과 표준화 과정에서는 PEP라고 불리는 Python Enhancement Proposal 문서가 사용된다. PEP는 새로운 언어 기능과 표준 라이브러리 기능, 개발 절차, 배포 정책과 거버넌스에 관한 제안을 기록하며, 논의와 승인 여부뿐 아니라 변경의 배경과 설계 근거를 보존한다.[8]
현재 Python과 주요 기반 시설은 비영리 단체인 Python Software Foundation의 지원을 받는다. Python Software Foundation은 Python 언어와 공동체의 발전을 지원하고, 지식 재산과 기반 시설을 관리하며, PyCon과 지원 사업 등을 운영한다.[9]
역사
탄생과 ABC의 영향
Python은 1980년대 말 네덜란드 암스테르담의 수학·컴퓨터 과학 연구 기관인 CWI에서 귀도 반 로섬이 개발하기 시작했다. 반 로섬은 CWI의 ABC 언어 개발 집단에서 인터프리터 구현과 언어 설계에 참여했으며, 이 과정에서 얻은 경험을 Python의 초기 설계에 반영했다. 들여쓰기로 문장 블록을 구분하는 방식과 목록·문자열·매핑과 같은 고수준 자료형을 언어의 기본 요소로 제공하는 설계는 ABC에서 직접적인 영향을 받았다.[10]
ABC는 비전문가도 쉽게 사용할 수 있는 대화형 프로그래밍 환경을 목표로 했지만, 운영체제 기능이나 외부 라이브러리와 연동하기 어렵고 언어 구현을 확장하기도 쉽지 않았다. 반 로섬은 ABC의 간결한 문법과 높은 수준의 자료형은 유지하면서도, C로 작성된 모듈을 연결하고 운영체제 기능에 직접 접근할 수 있는 확장 가능한 언어가 필요하다고 판단했다.
당시 반 로섬은 CWI의 Amoeba 분산 운영체제 개발에 참여하고 있었다. Amoeba의 시스템 관리 작업은 C 프로그램으로 작성하기에는 번거로웠고, Bourne shell 스크립트에서는 Amoeba의 시스템 호출 인터페이스를 충분히 이용하기 어려웠다. 이에 따라 그는 ABC와 유사한 간결한 문법을 가지면서 Amoeba의 시스템 호출과 연결할 수 있는 스크립트 언어를 구상했다. 다만 특정 운영체제에만 종속된 언어가 아니라 다른 시스템에서도 사용할 수 있는 범용적이고 확장 가능한 언어로 설계하는 방향을 선택했다.[11]
예외 처리의 개념과 일부 문법은 Modula-3의 영향을 받았다. 반 로섬은 Amoeba 개발 과정에서 오류 처리가 프로그램 구조에 미치는 중요성을 경험했으며, 오류를 단순한 반환값으로 처리하는 대신 언어 차원에서 예외를 전달하고 처리할 수 있도록 설계했다. 이와 함께 모듈, 함수, 클래스와 내장 자료형을 하나의 대화형 실행 환경에서 사용할 수 있도록 초기 언어 구조를 구성했다.
반 로섬은 1989년 12월 크리스마스 휴가 기간에 Python 인터프리터 구현을 시작했다. 초기 작업은 개인적인 실험 프로젝트에 가까웠지만, 이듬해 Amoeba 개발 과정에서 실제로 사용되기 시작했고 동료들의 피드백을 반영하면서 언어 기능이 확장되었다.[12]
언어 이름은 뱀에서 직접 유래한 것이 아니라 영국의 코미디 집단 Monty Python과 텔레비전 프로그램 《Monty Python's Flying Circus》에서 따왔다. 반 로섬은 짧고 독특하며 약간은 신비로운 이름을 원했고, 당시 읽고 있던 Monty Python 대본에서 이름을 선택했다.[13]
최초 공개와 초기 버전
Python은 약 1년 이상의 개발을 거쳐 1991년 2월 유즈넷의 alt.sources 뉴스그룹에 처음 공개되었다. 최초 공개 계열인 Python 0.9.0에는 이미 함수, 예외 처리, 클래스와 상속, 모듈, 문자열, 목록과 사전 등의 핵심 기능이 포함되어 있었다. 이러한 구성은 Python이 처음부터 단순한 명령어 처리기보다는 비교적 완전한 범용 프로그래밍 언어를 지향했음을 보여준다.[14]
초기 구현은 대부분 C로 작성되었으며, 소스 코드를 중간 형태의 바이트코드로 변환한 뒤 가상 머신에서 실행하는 구조를 사용했다. 이 구현은 이후 CPython으로 불리게 되었고 Python 언어의 기준 구현체로 발전했다. Python은 대화형 인터프리터를 제공하면서도 소스 파일을 모듈 단위로 구성하고, C 확장 모듈을 통해 운영체제와 기존 라이브러리에 접근할 수 있도록 했다.
1994년 1월 26일 Python 1.0이 공개되었다. Python 1.0은 초기 실험 버전에서 발전한 첫 번째 정식 주 버전으로, 함수형 프로그래밍에 활용할 수 있는 lambda, map(), filter(), reduce() 등의 기능을 포함했다.[15]
초기 Python 공동체는 유즈넷의 comp.lang.python과 전자우편 목록을 중심으로 성장했다. 외부 개발자들이 이식성 개선, 확장 모듈, 문서와 표준 라이브러리에 기여하면서 Python은 CWI 내부 프로젝트에서 공개적인 오픈 소스 언어로 변화했다. 반 로섬은 주요 설계 결정을 담당했지만, 실제 구현과 생태계는 점차 공동체의 기여로 확장되었다.
CNRI 시기와 Python 1.x
1995년 반 로섬은 미국 버지니아주 레스턴의 CNRI로 자리를 옮겼으며, Python 개발도 CNRI에서 계속되었다. CWI에서 마지막으로 공개된 버전은 Python 1.2였고, Python 1.3부터 Python 1.5.2까지는 CNRI에서 개발·배포되었다.[16]
Python 1.x 시기에는 언어 기능뿐 아니라 모듈과 패키지를 이용해 프로그램을 구성하는 방식이 정착되었다. Python 1.4와 1.5를 거치며 키워드 인자, 이름 맹글링, 정규 표현식 모듈, 패키지 지원과 표준 예외 클래스 등이 도입되거나 정비되었다. 특히 Python 1.5에서는 디렉터리와 __init__.py 파일을 이용하는 패키지 구조가 공식적으로 지원되면서 대규모 라이브러리를 계층적으로 구성할 수 있게 되었다.[17]
Python 1.5는 1998년 2월 17일 공개되었고, 안정화 버전인 Python 1.5.2는 1999년 4월 30일 공개되었다. Python 1.5.2는 이후 상당 기간 널리 사용되었으며, 2.0으로 넘어가기 전 Python 생태계의 주요 기반 버전이 되었다.[18]
1990년대 후반에는 Python을 이용한 웹 프로그래밍, 시스템 관리, 그래픽 사용자 인터페이스, 과학 계산과 교육용 소프트웨어가 증가했다. 표준 라이브러리와 외부 모듈의 범위도 확대되었으며, Python 코드를 배포하고 설치하기 위한 Distutils 개발도 진행되었다. 이 시기 Python은 단순한 Amoeba용 도구에서 여러 운영체제에서 사용할 수 있는 독립적인 범용 언어로 자리 잡았다.
BeOpen PythonLabs와 Python 2.0
2000년 5월 반 로섬과 Python 핵심 개발진은 CNRI를 떠나 BeOpen.com으로 이동하고 BeOpen PythonLabs 팀을 구성했다. CNRI에서 진행되었던 개발 결과를 정리하기 위해 Python 1.6이 별도로 준비되었으며, Python 1.6 최종판은 2000년 9월 5일 공개되었다.[19]
Python 1.6에는 Unicode 지원과 문자열 처리 개선 등 Python 2.0으로 이어지는 기능이 포함되었다. 그러나 CNRI가 적용한 Python 1.6 라이선스에는 다른 자유 소프트웨어 라이선스와 결합할 때 문제가 될 수 있는 조항이 포함되어 있었고, 이후 Python 1.6.1과 후속 라이선스에서 호환성 문제가 조정되었다.
Python 2.0은 2000년 10월 16일 공개되었다. Python 2.0에는 리스트 컴프리헨션, 순환 참조를 처리할 수 있는 가비지 컬렉터, Unicode 지원과 확장된 표준 라이브러리가 포함되었다. 개발 과정에서는 공개 소스 관리와 버그 추적 기반 시설이 도입되었고, 핵심 개발진 외부의 기여를 수용하는 공동 개발 방식이 더욱 강화되었다.[20][21]
2000년 10월 PythonLabs 팀은 Digital Creations로 이동했다. 이 회사는 이후 Zope Corporation으로 이름을 변경했다. Zope는 Python으로 개발된 초기 웹 애플리케이션 서버 가운데 하나였으며, Python의 웹 개발 생태계가 성장하는 데 중요한 역할을 했다.
Python Software Foundation의 설립
Python 프로젝트가 여러 기관을 거치면서 개발됨에 따라 저작권, 상표, 라이선스와 기반 시설을 장기적으로 관리할 독립 조직의 필요성이 커졌다. 이에 따라 2001년 미국 델라웨어주에 비영리 조직인 Python Software Foundation이 설립되었다.[22]
Python Software Foundation은 Python 관련 지식 재산을 보유하고 언어와 공동체의 발전을 지원하는 역할을 맡았다. Python 2.1부터 주요 저작권은 PSF가 관리하게 되었으며, Python 배포판은 Python Software Foundation License를 중심으로 배포되었다. PSF는 이후 Python 공식 웹사이트와 개발 기반 시설, 상표, 재정 지원, 공동체 행사와 개발 지원 사업을 관리하게 되었다.[23]
독립 재단의 설립은 Python이 특정 회사나 연구 기관의 프로젝트에서 벗어나, 공개 공동체가 지속적으로 개발하는 언어로 전환되었음을 의미했다. 기업과 개인 개발자는 서로 다른 조직에 소속된 상태에서도 Python 핵심 개발과 라이브러리 생태계에 참여할 수 있게 되었다.
Python 2 계열의 발전
Python 2 계열은 2000년대 동안 언어와 표준 라이브러리의 기반을 크게 확장했다. Python 2.2에서는 기존의 내장 타입과 사용자가 정의한 클래스를 보다 일관된 객체 모델로 통합하는 작업이 진행되었으며, 새 스타일 클래스와 프로퍼티, 정적 메서드와 클래스 메서드 등이 도입되었다. 이 변화는 Python의 객체 모델을 확장하고 메타프로그래밍 기능을 발전시키는 기반이 되었다.
Python 2.3과 2.4에서는 집합 자료형, 데코레이터, 제너레이터 표현식과 표준 라이브러리 개선이 이어졌다. Python 2.5에서는 with 문과 컨텍스트 관리자, 조건 표현식이 추가되었으며, Python 2.6은 Python 3으로 이전하기 위한 여러 호환 기능을 제공했다.
Python 2.7은 2010년 7월 3일 공개되었고 Python 2 계열의 마지막 기능 버전이 되었다. Python 3으로의 이전에 시간이 필요한 프로젝트를 지원하기 위해 Python 2.7의 유지보수 기간은 당초 계획보다 연장되었다. 공식 지원은 2020년 1월 1일 종료되었으며, 마지막 배포판인 Python 2.7.18은 2020년 4월 20일 공개되었다. Python 2.8은 개발되지 않았다.[24]
PEP와 언어 개발 절차
Python 공동체가 확대되면서 언어 변경을 공개적으로 제안하고 기록하는 절차도 필요해졌다. 2000년부터 Python Enhancement Proposal, 즉 PEP 제도가 사용되기 시작했다. PEP는 새로운 언어 기능, 표준 라이브러리, 개발 절차, 패키징 표준과 거버넌스 변경을 설명하는 설계 문서이다.[25]
PEP에는 제안의 배경, 기술적 명세, 호환성, 대안과 결정 결과가 기록된다. 제안이 받아들여지지 않더라도 논의 과정과 거부 이유가 보존되므로, Python의 설계가 어떤 선택을 통해 형성되었는지 추적할 수 있다.
초기에는 반 로섬이 언어 설계의 최종 결정을 내리는 역할을 맡았으며, Python 공동체에서는 그를 BDFL이라는 명칭으로 불렀다. 다만 실제 기능 개발과 검토는 PEP 작성자, 핵심 개발자와 관련 분야의 기여자들이 함께 수행했고, 특정 영역의 결정 권한을 다른 개발자에게 위임하기도 했다.
Python 3의 설계와 공개
Python 2가 장기간 발전하면서 초기 설계와의 호환성을 유지하기 위해 남겨 둔 기능과 일관되지 않은 동작이 누적되었다. 기존 프로그램과의 호환성을 계속 유지한 상태에서는 이러한 문제를 근본적으로 수정하기 어려웠기 때문에, 개발진은 하위 호환성을 일부 포기하고 언어를 정리하는 새 주 버전을 계획했다. 이 프로젝트는 Python 3000 또는 Py3k라고 불렸다.
Python 3의 개발 원칙은 PEP 3000을 비롯한 여러 PEP에 기록되었다. Python 3은 완전히 새로운 언어를 만드는 것보다 Python의 기존 설계를 유지하면서 중복되거나 오래된 기능을 제거하고, 문자열과 반복 처리, 정수 연산, 예외 처리와 표준 라이브러리 구조를 일관되게 만드는 것을 목표로 했다.[26]
Python 3.0은 2008년 12월 3일 공개되었다. Python 3.0은 Python 역사상 처음으로 의도적으로 이전 주 버전과 완전한 하위 호환성을 제공하지 않은 배포판이었다. print는 문장에서 함수로 변경되었고, 문자열은 기본적으로 Unicode를 표현하도록 정리되었다. 정수 나눗셈과 반복자 동작이 일관되게 변경되었으며, 중복된 구문과 오래된 기능이 제거되고 표준 라이브러리의 여러 모듈이 재구성되었다.[27][28]
Python 2 코드의 이전을 지원하기 위해 2to3 변환 도구와 Python 2.6·2.7의 호환 기능이 제공되었다. 그러나 문자열과 바이트 처리, 외부 라이브러리, 확장 모듈과 패키징 도구까지 함께 변경해야 했기 때문에 대규모 프로젝트의 이전에는 긴 시간이 필요했다. 그 결과 Python 2와 Python 3은 약 10년 이상 병행 사용되었다.
초기 Python 3 버전은 Python 2 생태계의 라이브러리를 모두 사용할 수 없었기 때문에 채택 속도가 제한적이었다. 이후 주요 웹 프레임워크, 과학 계산 라이브러리, 패키징 도구와 운영체제 배포판이 Python 3을 지원하면서 Python 3이 기본 계열로 자리 잡았다.
현대 Python 3의 발전
Python 3 계열은 초기의 호환성 단절 이후에는 기존 프로그램의 안정성을 중시하면서 점진적으로 발전했다. Python 3.3에서는 venv와 yield from, Python 3.4에서는 asyncio, enum과 pathlib, Python 3.5에서는 타입 힌트와 async·await 문법이 도입되었다. 이 기능들은 각각 개발 환경 격리, 비동기 프로그래밍과 정적 분석 생태계의 기반이 되었다.
Python 3.6에서는 포맷 문자열 리터럴인 f-string과 변수 주석이 추가되었고, Python 3.7에서는 데이터 클래스를 비롯한 기능이 도입되었다. Python 3.8은 대입 표현식과 위치 전용 매개변수를 추가했으며, Python 3.9부터는 PEP 602에 따라 기능 버전을 매년 한 차례 공개하는 주기가 적용되었다.[29]
Python 3.10은 구조적 패턴 매칭을 도입했고, Python 3.11은 인터프리터 실행 성능과 오류 메시지를 크게 개선했다. Python 3.12 이후에는 인터프리터 내부 구조, 타입 매개변수 문법, 디버깅과 프로파일링, 전역 인터프리터 잠금과 관련된 실행 모델을 개선하는 작업이 계속되었다.
현대 Python 개발에서는 PEP 387의 하위 호환성 정책에 따라 기존 기능을 즉시 제거하기보다 폐기 예정 경고와 전환 기간을 제공하는 방식이 사용된다. Python 3의 초기 전환 과정에서 발생한 생태계 분할을 반복하지 않도록, 호환되지 않는 변경은 명확한 필요성과 이전 경로를 갖추도록 요구된다.[30]
거버넌스 체계의 변화
귀도 반 로섬은 Python 탄생 이후 오랫동안 언어 설계의 최종 결정권자 역할을 맡았다. 그러나 Python 공동체와 PEP의 규모가 커지면서 한 사람이 모든 주요 설계 논쟁을 최종 조정하는 구조의 부담도 증가했다.
2018년 7월 반 로섬은 BDFL 역할에서 물러났다. 이후 핵심 개발자들은 여러 거버넌스 모델을 PEP로 제안하고 투표를 진행했다. 2018년 12월 핵심 개발자 투표를 통해 PEP 8016의 운영위원회 모델이 채택되었다.[31]
운영위원회는 핵심 개발자들이 선출하는 5명의 위원으로 구성된다. 위원회는 Python 언어와 CPython의 품질과 안정성을 유지하고, PEP의 승인 절차와 개발 정책을 관리하며, 합의가 이루어지지 않는 사안에 대해 최종 결정을 내릴 수 있다. 다만 가능한 한 직접 권한을 행사하기보다 공개적인 논의와 합의, 다른 담당자에게 위임하는 방식을 우선하도록 설계되었다.
운영위원회 모델의 공식 규칙은 현재 PEP 13에서 관리된다. 새로운 운영위원회는 정기적으로 선출되며, Python의 의사 결정 구조는 한 명의 창시자에게 집중된 형태에서 핵심 개발자 공동체가 대표자를 선출하는 형태로 전환되었다.[32]
이러한 변화 이후에도 Python은 언어 사양, 기준 구현체, 표준 라이브러리와 패키징 생태계를 서로 연계하여 개발하는 구조를 유지하고 있다. 언어와 CPython의 기술적 결정은 PEP와 핵심 개발 절차를 통해 이루어지며, Python Software Foundation은 지식 재산, 재정, 기반 시설과 국제 공동체를 지원한다.
설계 철학과 특징
Python은 프로그램의 동작을 가능한 한 명확하게 드러내면서도, 자주 사용하는 작업을 짧고 읽기 쉬운 코드로 표현하는 것을 중심으로 설계되었다. 언어의 문법과 표준 라이브러리는 서로 다른 기능을 무작정 추가하기보다, 일반적인 문제를 일관된 방식으로 해결하고 코드의 의도를 사람이 쉽게 파악할 수 있도록 구성되는 경향을 가진다.
Python의 설계 철학은 언어의 특정 문법 규칙만을 의미하지 않는다. 들여쓰기 기반 블록 구조, 동적 타입, 객체 중심 데이터 모델, 반복 프로토콜, 예외 처리, 모듈과 패키지 체계, 자동 메모리 관리와 표준 라이브러리가 함께 Python의 프로그래밍 모델을 구성한다. 이 구조는 작은 스크립트를 빠르게 작성하는 용도와 여러 모듈로 이루어진 대규모 프로그램을 개발하는 용도를 모두 지원하도록 발전했다.
가독성과 명시성
Python은 소스 코드가 작성자뿐 아니라 다른 개발자에게도 쉽게 읽혀야 한다는 원칙을 중요하게 여긴다. 이러한 방향은 PEP 20에 수록된 Python의 선에서 “가독성은 중요하다”, “암시적인 것보다 명시적인 것이 낫다”, “복잡한 것보다 단순한 것이 낫다”라는 문장으로 표현된다.[33]
가독성은 단순히 코드 길이를 줄이는 것과 같지 않다. Python에서는 한 줄로 압축할 수 있더라도 여러 단계의 처리를 지나치게 중첩하거나 부수 효과를 숨기는 표현은 관용적인 코드로 평가되지 않을 수 있다. 반대로 언어가 제공하는 컴프리헨션, 다중 대입, 반복자와 컨텍스트 관리자처럼 의미가 널리 정착된 표현은 코드의 목적을 더 직접적으로 보여줄 수 있다.
Python의 이름 바인딩과 함수 호출 문법도 명시성을 지원한다. 함수 호출에서 키워드 인자를 사용하면 각 값의 역할을 호출 위치에 나타낼 수 있고, 언패킹 문법은 값의 구조를 직접 표현한다. 예외 처리에서는 발생 가능한 오류를 반환값의 특수한 상태로 숨기기보다 예외 객체로 전달하며, 호출자가 필요한 위치에서 이를 명시적으로 처리할 수 있다.
PEP 8은 Python 코드의 공식적인 스타일 지침으로, 코드 배치와 이름 규칙, 들여쓰기와 공백 사용을 정리한다. 그러나 PEP 8은 모든 상황에서 기계적으로 동일한 형식을 강제하기보다 프로젝트와 모듈 내부의 일관성, 기존 코드와의 호환성 및 실제 가독성을 우선하도록 설명한다.[34]
들여쓰기를 이용한 블록 구조
Python에서는 들여쓰기가 조건문, 반복문, 함수와 클래스 등의 코드 블록을 구분하는 문법 요소로 사용된다. 다른 여러 언어가 중괄호나 begin·end 같은 키워드로 블록 범위를 표시하는 것과 달리, Python은 같은 깊이의 문장을 동일한 블록에 속한 것으로 해석한다.
def classify(value):
if value > 0:
return "positive"
if value < 0:
return "negative"
return "zero"
들여쓰기 기반 구조는 프로그램의 시각적인 배치와 구문 구조가 서로 달라지는 상황을 줄인다. 중괄호를 사용하는 언어에서는 들여쓰기가 실제 블록 구조와 달라도 문법적으로 허용될 수 있지만, Python에서는 들여쓰기가 잘못되면 다른 블록으로 해석되거나 구문 오류가 발생한다.
귀도 반 로섬은 들여쓰기를 문법에 포함한 이유로 코드의 수평·수직 공간을 효율적으로 사용하고, 별도의 블록 구분 기호 없이도 프로그램 구조를 명확히 드러낼 수 있다는 점을 들었다. 또한 일반적인 코드가 어차피 들여쓰기를 사용하므로, 이를 문법 구조와 일치시키는 것이 가독성에 도움이 된다고 설명했다.[35]
들여쓰기의 깊이는 공백의 개수로 결정된다. 언어 자체가 항상 네 칸을 강제하는 것은 아니지만, Python 공동체와 PEP 8에서는 각 단계에 공백 네 칸을 사용하는 방식을 표준적인 스타일로 권장한다.
간결한 문법과 높은 표현력
Python은 자주 사용하는 자료 처리와 제어 흐름을 비교적 적은 문법 요소로 표현할 수 있도록 설계되었다. 목록, 튜플, 사전과 집합은 언어 차원의 리터럴을 제공하며, 다중 대입과 언패킹을 통해 여러 값을 한 번에 분해하거나 교환할 수 있다.
left, right = right, left
name, extension = filename.rsplit(".", 1)
컴프리헨션은 반복과 조건을 이용해 새로운 컬렉션을 생성하는 과정을 하나의 표현식으로 나타낸다. 이는 단순한 변환과 필터링을 반복문보다 직접적으로 표현할 수 있게 한다.
squares = [number * number for number in values if number >= 0]
다만 Python의 간결성은 가능한 모든 처리를 짧은 표현식으로 만드는 것을 목표로 하지 않는다. 지나치게 중첩된 컴프리헨션이나 여러 부수 효과를 한 문장에 결합한 코드는 실행할 수 있더라도 읽기 어려워질 수 있다. Python의 관용적 스타일에서는 코드 길이보다 처리 흐름과 의도가 명확하게 드러나는지가 더 중요하게 취급된다.
Python은 문법 요소를 다른 기능과 결합할 수 있도록 설계한다. 예를 들어 반복 프로토콜을 구현한 객체는 for 문, 컴프리헨션, sum()과 min() 같은 내장 함수, 튜플 언패킹 등에서 공통으로 사용할 수 있다. 개별 기능마다 별도의 반복 방식을 제공하지 않고 하나의 프로토콜을 여러 문법과 라이브러리가 공유하는 구조이다.
실용성을 우선하는 설계
Python은 일관성과 단순성을 중요하게 여기지만, 순수한 이론적 모델보다 실제 프로그램 작성과 기존 코드의 사용성을 함께 고려한다. PEP 20은 이를 “실용성은 순수성보다 우선한다”라는 원칙으로 표현한다.[36]
이 원칙에 따라 Python에는 서로 다른 프로그래밍 방식에서 유래한 기능이 함께 존재한다. 클래스와 상속을 이용할 수 있지만 모든 코드를 클래스에 넣을 필요는 없으며, 함수형 프로그래밍에서 사용하는 고차 함수와 지연 반복 도구를 제공하면서도 순수 함수형 언어의 제약을 적용하지 않는다. 필요에 따라 상태 변경, 예외, 반복문과 객체를 함께 사용할 수 있다.
언어의 일관성을 높이기 위한 변경도 기존 생태계와의 비용을 함께 고려한다. Python 3에서는 오래된 설계 문제를 정리하기 위해 의도적인 호환성 단절이 이루어졌지만, 현대 Python에서는 기능을 제거하기 전에 폐기 예정 경고와 이전 기간을 제공하는 방식을 주로 사용한다.
Python은 특정 사용 방식만을 절대적인 형태로 강제하지 않는다. 여러 방법이 존재할 수 있지만, 일반적으로 이해하기 쉽고 표준 라이브러리와 잘 결합되는 대표적인 방식이 자연스럽게 선택되도록 언어와 문서, 공동체 관습이 함께 작동한다.
다중 패러다임 지원
Python은 하나의 프로그래밍 패러다임에만 속하지 않는 다중 패러다임 프로그래밍 언어이다. 기본적인 문장과 함수, 가변 상태를 이용하는 명령형·절차형 프로그래밍을 지원하며, 클래스와 객체를 이용한 객체 지향 프로그래밍도 제공한다. 함수가 일급 객체이므로 고차 함수, 클로저와 데코레이터를 비롯한 함수형 프로그래밍 기법도 사용할 수 있다.
def apply(values, operation):
return [operation(value) for value in values]
result = apply([1, 2, 3], lambda value: value * 2)
Python의 객체 지향 모델은 클래스 상속, 다중 상속, 메서드 오버라이딩과 메타클래스를 지원한다. 그러나 Java처럼 모든 실행 코드를 클래스 안에 배치하도록 요구하지 않으며, 모듈 수준의 함수와 변수도 일반적인 프로그램 구성 요소로 사용한다.
함수형 기능 역시 언어 전체를 순수 함수와 불변 자료구조에 맞추는 형태가 아니다. map(), filter(), functools, itertools와 같은 기능을 제공하지만, 단순한 변환에서는 컴프리헨션이나 일반 반복문이 더 읽기 쉬운 방식으로 사용되기도 한다. 표준 라이브러리의 itertools는 여러 반복자 구성 요소를 조합해 메모리 효율적인 처리 흐름을 구성할 수 있도록 한다.[37]
Python에서는 문제와 프로그램 규모에 따라 여러 패러다임을 함께 사용할 수 있다. 데이터 모델은 객체 지향적이지만, 실제 프로그램 구조는 모듈과 함수 중심으로 구성할 수 있고, 특정 처리 과정만 반복자나 선언적인 표현으로 구현할 수도 있다.
통일된 객체 모델
Python 프로그램의 모든 데이터는 객체 또는 객체 사이의 관계로 표현된다. 숫자와 문자열, 컬렉션뿐 아니라 함수, 클래스, 모듈과 코드 자체도 객체로 취급된다. 모든 객체는 정체성, 타입과 값을 가지며, 이름은 이러한 객체에 바인딩된다.[38]
def greet(name):
return f"Hello, {name}"
handler = greet
message = handler("Python")
함수도 객체이므로 변수에 저장하거나 다른 함수에 인자로 전달하고 반환할 수 있다. 클래스 또한 객체이며, 기본적으로 호출하면 인스턴스를 생성한다. 이러한 구조는 데코레이터, 메타클래스와 런타임 코드 생성 같은 기능의 기반이 된다.
Python의 언어 연산은 객체 모델에 정의된 특수 메서드를 통해 확장된다. 예를 들어 + 연산은 객체의 __add__() 계열 메서드와 연결되고, len()은 __len__(), 반복은 __iter__()와 __next__(), with 문은 컨텍스트 관리자 프로토콜과 연결된다.
이 구조를 통해 사용자가 정의한 객체도 내장 자료형과 동일한 문법 구조에 참여할 수 있다. 특정 클래스가 반복 프로토콜을 구현하면 for 문에서 사용할 수 있고, 컨텍스트 관리자 프로토콜을 구현하면 with 문에서 사용할 수 있다. Python에서는 이런 동작을 흔히 데이터 모델 또는 Python 프로토콜의 구현으로 설명한다.
동적 타입과 이름 바인딩
Python은 객체가 타입을 가지고 이름은 객체를 참조하는 동적 타입 언어이다. 변수 선언 시 고정된 타입을 지정할 필요가 없으며, 같은 이름이 실행 과정에서 서로 다른 타입의 객체에 다시 바인딩될 수 있다.
value = 10
value = "ten"
이 구조에서 타입 검사는 주로 연산이 실행되는 시점에 이루어진다. 객체가 요청된 연산을 지원하면 구체적인 클래스 계층과 관계없이 사용할 수 있으며, 이러한 작성 방식을 덕 타이핑이라고 부른다.
def write_message(stream, message):
stream.write(message)
write_message()는 stream이 반드시 특정 클래스의 인스턴스인지 먼저 확인하지 않는다. 대신 실행 시점에 write() 메서드를 제공하는 객체라면 파일, 메모리 버퍼나 사용자 정의 객체를 사용할 수 있다.
Python의 동적 타입 체계는 타입 정보가 존재하지 않는다는 뜻이 아니다. 모든 객체에는 런타임 타입이 있으며, type()과 isinstance() 같은 기능으로 이를 검사할 수 있다. 또한 타입 힌트를 통해 함수 인자와 반환값, 변수와 클래스 속성의 예상 타입을 기록할 수 있다. 타입 힌트는 기본적으로 런타임의 이름 바인딩 방식을 바꾸지 않으며, 정적 분석기와 개발 도구가 프로그램을 검사할 수 있도록 추가 정보를 제공한다.
자동 메모리 관리
Python은 객체의 메모리 할당과 해제를 실행 환경에서 자동으로 관리한다. 개발자는 일반적인 Python 코드에서 객체를 직접 해제하지 않으며, 더 이상 도달할 수 없는 객체는 구현체의 메모리 관리 체계에 따라 회수된다.
기준 구현체인 CPython은 주로 참조 횟수를 이용해 객체 수명을 관리하고, 서로를 참조하는 객체 집합처럼 참조 횟수만으로 정리할 수 없는 순환 참조를 별도의 가비지 컬렉터로 처리한다. 다만 참조 횟수의 구체적인 사용 방식과 객체가 제거되는 정확한 시점은 Python 언어 자체보다 구현체의 특성에 해당한다.
Python의 메모리 관리자는 Python 객체와 내부 자료구조가 저장되는 전용 힙을 관리한다. 이 과정에는 객체 할당, 재사용, 캐싱과 가비지 컬렉션을 비롯한 여러 구성 요소가 관여한다.[39]
자동 메모리 관리는 일반 객체의 수명을 단순화하지만, 파일과 네트워크 연결, 잠금처럼 사용 후 즉시 반환해야 하는 외부 자원까지 가비지 컬렉션에 의존하는 방식은 권장되지 않는다. 이러한 자원은 with 문과 컨텍스트 관리자를 사용해 블록 종료 시 정리 작업이 실행되도록 구성한다.
예외 중심의 오류 처리
Python은 실행 과정의 비정상 상태를 예외 객체로 표현한다. 함수가 모든 실패를 별도의 오류 코드로 반환하는 대신, 정상적인 반환 흐름과 오류 전달 흐름을 분리할 수 있다.
try:
value = int(text)
except ValueError:
value = 0
예외가 현재 실행 위치에서 처리되지 않으면 호출 스택의 상위 영역으로 전파된다. 호출자는 처리할 수 있는 예외만 선택적으로 잡을 수 있고, 나머지는 더 높은 수준의 코드로 전달할 수 있다.
Python에서는 객체가 필요한 동작을 지원한다고 예상하고 먼저 연산을 수행한 뒤, 실패할 경우 예외를 처리하는 방식이 자주 사용된다. 이는 사전에 가능한 모든 조건과 타입을 확인하는 방식보다 실제 동작을 직접 요청한다는 점에서 동적 타입과 덕 타이핑에 잘 맞는다.
그러나 예외를 일반적인 분기 처리의 대체물로 무조건 사용하는 것은 아니다. 실패가 예외적인 상황인지, 조건 확인이 더 읽기 쉬운지, 연산과 검사 사이에 상태가 바뀔 가능성이 있는지를 고려해 구조를 선택한다.
반복자와 프로토콜 중심 설계
Python의 여러 기능은 특정 상위 클래스의 상속보다 객체가 제공하는 동작의 집합인 프로토콜을 중심으로 구성된다. 반복 가능한 객체는 반복자 프로토콜을 구현하며, for 문과 컴프리헨션, tuple()과 sum()을 비롯한 여러 내장 기능에서 공통으로 사용된다.
for item in collection:
process(item)
for 문은 컬렉션의 내부 저장 구조를 직접 알 필요 없이 반복 프로토콜을 통해 값을 차례로 얻는다. 사용자 정의 클래스도 같은 프로토콜을 구현하면 내장 컬렉션처럼 반복할 수 있다.
컨텍스트 관리자, 비동기 반복자, 숫자 연산, 길이 조회, 포함 관계 검사와 속성 접근도 각각의 프로토콜을 통해 확장된다. 이러한 방식은 내장 문법을 특정 클래스에 고정하지 않고, 필요한 동작을 제공하는 다양한 객체가 참여하도록 만든다.
프로토콜 중심 설계는 덕 타이핑과 연결된다. 객체의 명목상 타입보다 실제로 지원하는 연산이 중요하며, 서로 상속 관계가 없는 클래스도 같은 프로토콜을 구현하면 동일한 코드에서 사용할 수 있다.
모듈성과 점진적인 확장
Python 프로그램은 모듈과 패키지를 기본 단위로 구성된다. 작은 프로그램은 하나의 소스 파일에서 시작할 수 있고, 규모가 커지면 기능을 여러 모듈과 패키지로 분리할 수 있다. Python 소스 파일 자체가 모듈로 사용되므로 작은 스크립트와 재사용 가능한 라이브러리 사이의 구조적 차이가 크지 않다.
모듈은 각자의 전역 이름 공간을 가지며, import 문을 통해 다른 모듈이 공개한 객체를 사용할 수 있다. 패키지는 관련 모듈을 계층적으로 묶어 이름 충돌을 줄이고 프로그램 구조를 표현한다.
Python은 대화형 인터프리터, 스크립트 실행, 모듈 가져오기와 애플리케이션 구성을 같은 언어와 객체 모델 위에서 지원한다. 대화형 환경에서 시험한 함수나 표현을 소스 파일로 옮기고, 이후 패키지의 일부로 확장하는 점진적인 개발 흐름을 사용할 수 있다.
확장성은 다른 언어와의 연동에도 적용된다. Python 모듈은 Python 자체로 작성할 수 있을 뿐 아니라 C API와 외부 함수 인터페이스, 별도의 바인딩 도구를 통해 C, C++, Rust와 다른 언어로 구현된 라이브러리를 연결할 수 있다. 이를 통해 상위 수준의 제어와 조합은 Python으로 작성하고, 성능이나 시스템 접근이 중요한 부분은 다른 언어로 구현하는 구조를 만들 수 있다.
배터리 포함 표준 라이브러리
Python은 언어와 함께 광범위한 표준 라이브러리를 제공하는 배터리 포함 철학을 따른다. 표준 라이브러리에는 파일과 디렉터리 처리, 자료구조, 날짜와 시간, 정규 표현식, 데이터 직렬화, 압축, 네트워크 프로토콜, 동시성, 테스트와 디버깅을 비롯한 기능이 포함된다.[40]
표준 라이브러리의 목적은 모든 분야의 기능을 자체적으로 포함하는 것이 아니라, 일반적인 프로그램 작성에 필요한 기반 기능을 별도의 외부 의존성 없이 사용할 수 있게 하는 것이다. 운영체제 인터페이스, 파일 경로 처리, JSON과 CSV, HTTP 클라이언트, SQLite 데이터베이스, 단위 테스트와 로깅 같은 기능을 Python 설치 환경에서 바로 사용할 수 있다.
from pathlib import Path
import json
config = json.loads(Path("config.json").read_text(encoding="utf-8"))
Python 언어 참조는 문법과 실행 의미를 정의하고, 표준 라이브러리는 실제 프로그램을 구성하는 공통 기능을 제공한다. 이 둘은 구분되지만 함께 Python의 기본 개발 환경을 형성한다.[41]
표준 라이브러리는 안정성과 호환성을 중요하게 관리하지만, 모든 모듈을 영구적으로 유지하지는 않는다. 사용이 중단되었거나 현대 환경에 맞지 않는 기능은 폐기 절차를 거쳐 제거될 수 있으며, 전문 분야나 빠르게 변화하는 기능은 PyPI의 외부 패키지 생태계에서 발전한다.
하나의 명확한 방법
Python의 선에는 문제를 해결하는 “하나의 명확한 방법”이 존재하는 것이 바람직하다는 원칙이 포함된다. 이는 모든 문제에 단 하나의 문법만 허용한다는 의미가 아니라, 비슷한 목적의 기능이 불필요하게 중복되어 사용자가 여러 방식 사이에서 혼란을 겪는 상황을 줄이려는 방향을 뜻한다.
Python은 반복, 자원 관리, 문자열 포매팅과 파일 경로 처리처럼 반복적으로 등장하는 문제에 대해 표준적이고 널리 이해되는 방식을 발전시켜 왔다. 예를 들어 반복 가능한 객체는 for 문과 반복자 프로토콜을 사용하고, 자원 정리는 with 문, 문자열 내부 값 삽입은 현대 코드에서 주로 f-string을 사용한다.
그러나 언어가 오랜 기간 발전하면서 같은 목적을 가진 과거 방식과 새로운 방식이 함께 남기도 한다. 기존 프로그램과의 호환성을 유지해야 하므로 오래된 기능이 즉시 제거되지 않으며, 새로운 방식이 충분히 정착한 뒤 폐기 절차를 거쳐 정리될 수 있다.
따라서 하나의 명확한 방법이라는 철학은 문법적 제한이라기보다 표준 라이브러리, 문서와 공동체 관습을 통해 대표적인 해결 방식을 형성하는 설계 방향에 가깝다.
Pythonic 코드와 관용적 표현
Pythonic은 Python의 언어 기능과 관습을 자연스럽게 활용하여 작성된 코드를 가리키는 표현이다. 단순히 Python 문법으로 실행되는 코드가 아니라, 언어의 반복 모델, 객체 모델, 표준 라이브러리와 일반적인 스타일을 활용해 의도를 직접적으로 나타내는 코드에 사용된다.
예를 들어 인덱스를 직접 증가시키면서 컬렉션에 접근하는 대신 객체를 직접 반복하고, 인덱스가 필요하면 enumerate()를 사용한다.
for index, item in enumerate(items):
print(index, item)
두 컬렉션을 같은 위치끼리 처리할 때는 직접 인덱스를 관리하기보다 zip()을 사용할 수 있다.
for name, score in zip(names, scores):
print(name, score)
파일과 같은 자원은 수동으로 열고 모든 제어 흐름에서 닫기보다 컨텍스트 관리자를 이용한다.
with open("data.txt", encoding="utf-8") as file:
contents = file.read()
Pythonic 코드가 항상 가장 짧거나 가장 빠른 코드를 의미하는 것은 아니다. 언어와 표준 라이브러리가 제공하는 공통 표현을 사용해 다른 Python 개발자가 동작을 쉽게 이해하고, 예외 처리와 자원 수명 같은 경계 조건까지 자연스럽게 다룰 수 있는 코드에 가깝다.
관용적인 표현은 고정된 규칙이 아니라 Python의 발전에 따라 달라질 수 있다. 과거에 일반적이었던 작성 방식이 새로운 문법이나 라이브러리 기능의 도입으로 대체될 수 있으며, 대상 Python 버전과 프로젝트의 호환성 정책에 따라 적절한 방식도 달라진다.
접근성과 전문성의 결합
Python은 간결한 문법과 대화형 실행 환경 덕분에 프로그래밍 입문에 널리 사용된다. 변수 선언과 프로그램 진입점을 위한 복잡한 상용구가 적고, 문자열과 컬렉션을 초기 단계부터 직접 다룰 수 있어 작은 프로그램을 빠르게 실행할 수 있다. Python 공식 사이트도 깨끗한 문법과 들여쓰기 구조가 초보자의 학습을 돕는 특성으로 설명한다.[42]
name = input("Name: ")
print(f"Hello, {name}")
동시에 Python은 디스크립터, 메타클래스, 비동기 프로그래밍, 네이티브 확장, 메모리 뷰와 사용자 정의 가져오기 시스템처럼 복잡한 소프트웨어를 구성하는 기능도 제공한다. 초보자가 언어 전체를 먼저 익히지 않아도 기본 문법으로 프로그램을 시작할 수 있고, 필요한 시점에 더 깊은 기능을 단계적으로 사용할 수 있다.
이러한 점진적 복잡성은 Python 생태계의 주요 특징이다. 간단한 자동화 스크립트와 교육용 프로그램, 대규모 웹 서비스, 데이터 처리 시스템과 과학 계산 도구가 같은 언어를 사용하지만, 각 분야에서 요구하는 언어 기능과 라이브러리의 깊이는 서로 다르다.
Python의 접근성은 내부 구조가 단순하다는 뜻은 아니다. 언어 참조의 객체 모델, 디스크립터와 메서드 결정 순서, 가져오기 시스템과 비동기 실행 의미는 상당히 정교하다. Python은 이러한 복잡성을 일상적인 프로그램 작성에서 필요한 만큼만 드러내는 방향으로 설계되었다.
문법과 프로그램 구조
Python 프로그램은 문자로 작성된 소스 코드를 토큰으로 나누는 어휘 분석, 토큰의 배열을 문법 구조로 해석하는 구문 분석과 각 문장 및 표현식을 실행하는 과정으로 처리된다. Python 언어 참조는 구문과 핵심 실행 의미를 정의하며, 실제 프로그램은 표현식, 단순문, 복합문, 함수와 클래스 정의 및 모듈 구조의 조합으로 구성된다.[43]
Python의 구문은 불필요한 구분 기호를 줄이고 줄바꿈과 들여쓰기로 프로그램 구조를 드러내도록 설계되었다. 일반적으로 하나의 논리적 줄에는 하나의 단순문을 작성하며, 콜론으로 시작한 복합문의 본문은 한 단계 깊은 들여쓰기로 나타낸다.
temperature = 22
if temperature >= 20:
message = "warm"
else:
message = "cold"
print(message)
여기서 대입과 함수 호출은 단순문이고, if와 else로 구성된 조건문은 내부에 다른 문장을 포함하는 복합문이다. 들여쓰기된 두 대입문은 각각 해당 조건 절의 본문에 속한다.
소스 코드와 문자 인코딩
Python 3 소스 코드는 Unicode 문자로 해석되며, 소스 파일의 기본 인코딩은 UTF-8이다. 다른 인코딩을 사용할 때는 파일의 첫째 줄이나 둘째 줄에 인코딩 선언을 작성할 수 있다.[44]
# -*- coding: utf-8 -*-
message = "안녕하세요"
현대 Python 프로젝트에서는 대부분 UTF-8을 그대로 사용하므로 별도의 인코딩 선언이 필요하지 않다. UTF-8이 아닌 인코딩을 선언하려면 Python이 인식할 수 있는 코덱 이름을 사용해야 하며, 선언된 방식으로 파일을 디코딩할 수 없으면 SyntaxError가 발생한다.
소스 코드의 줄 끝은 운영체제에 따라 LF, CRLF 또는 CR 형식일 수 있지만, Python은 이를 논리적인 줄 종료로 정규화한다. 문자열 리터럴 안에 포함된 줄바꿈은 문자열 표기 규칙에 따라 별도로 처리된다.
실행 파일로 직접 사용하는 스크립트에서는 첫 줄에 운영체제가 해석할 shebang을 둘 수 있다.
#!/usr/bin/env python3
print("Hello")
shebang은 Python 언어 문법 자체가 아니라 유닉스 계열 운영체제가 실행할 인터프리터를 선택하는 방식이다. Python에서는 # 뒤의 내용이 주석이므로 첫 줄 전체가 일반 주석으로 처리된다.
물리적 줄과 논리적 줄
소스 파일에 실제로 존재하는 각각의 줄을 물리적 줄이라고 한다. Python 파서는 하나 이상의 물리적 줄을 결합하여 하나의 논리적 줄로 처리할 수 있다.
괄호, 대괄호 또는 중괄호 안에서는 줄을 명시적인 연결 문자 없이 나눌 수 있다.
result = calculate_total(
base_price,
shipping_cost,
discount,
)
컬렉션 리터럴과 컴프리헨션에서도 같은 방식으로 줄을 나눌 수 있다.
permissions = {
"read",
"write",
"execute",
}
괄호 안의 암시적 줄 연결은 문자열이나 함수 호출, 복잡한 표현식을 여러 줄로 배치할 때 일반적으로 사용된다. 마지막 항목 뒤에 쉼표를 남겨 두면 항목을 추가하거나 순서를 변경할 때 수정 범위를 줄일 수 있다.
백슬래시를 물리적 줄 끝에 작성하여 다음 줄과 명시적으로 연결할 수도 있다.
total = first_value + second_value + \
third_value
그러나 백슬래시 뒤에는 주석이나 다른 문자를 둘 수 없고, 코드 수정 과정에서 쉽게 깨질 수 있으므로 가능한 경우 괄호를 이용한 암시적 연결이 선호된다.
일반적인 단순문은 논리적 줄의 끝에서 종료된다. 세미콜론을 사용하면 하나의 논리적 줄에 여러 단순문을 작성할 수 있다.
left = 10; right = 20
이 형식은 문법적으로 허용되지만, 각 문장을 별도의 줄에 작성하는 방식이 일반적으로 더 읽기 쉽다. 복합문의 본문을 세미콜론으로 연결한 한 줄 형태도 일부 구문에서 가능하지만, 여러 단계의 실행 구조를 나타낼 때는 들여쓰기된 블록을 사용하는 것이 보통이다.
들여쓰기와 코드 블록
Python의 들여쓰기는 단순한 서식이 아니라 토큰으로 처리되는 문법 요소이다. 어휘 분석기는 논리적 줄의 시작 부분에 있는 공백 깊이를 비교하여 INDENT와 DEDENT 토큰을 생성하고, 파서는 이를 이용해 문장 블록의 시작과 끝을 판단한다.[45]
if authenticated:
load_profile()
show_dashboard()
write_log()
load_profile()과 show_dashboard()는 같은 깊이로 들여쓰기되어 if 문의 본문에 속한다. write_log()는 들여쓰기가 원래 수준으로 돌아왔으므로 조건문 밖에서 실행된다.
한 블록 안에서는 같은 들여쓰기 깊이를 일관되게 사용해야 한다. 탭과 공백을 혼합하여 들여쓰기의 의미가 모호해지면 TabError가 발생할 수 있다. PEP 8에서는 각 들여쓰기 단계에 공백 네 칸을 사용하는 방식을 권장한다.[46]
복합문은 일반적으로 머리 부분의 끝에 콜론을 작성하고, 다음 줄부터 들여쓰기된 suite를 배치한다. suite는 하나 이상의 문장으로 이루어진 블록을 뜻한다.
while queue:
item = queue.pop()
process(item)
아직 구현하지 않은 빈 블록이 필요하면 아무 동작도 하지 않는 pass 문을 사용할 수 있다.
class PendingImplementation:
pass
주석과 문서 문자열
#부터 물리적 줄 끝까지의 내용은 주석으로 처리된다. 문자열 리터럴 내부의 #은 주석을 시작하지 않는다.
timeout = 30 # 초 단위 제한 시간
pattern = "#[0-9a-fA-F]{6}"
주석은 프로그램 실행에 직접 참여하지 않으며, 코드의 목적이나 구현상의 이유를 설명하는 데 사용된다. 코드가 무엇을 하는지 그대로 반복하기보다 해당 선택의 배경과 주의할 조건을 기록하는 방식이 유용하다.
모듈, 함수, 클래스 또는 메서드 본문의 첫 번째 문장이 문자열 리터럴이면 해당 문자열은 문서 문자열로 취급된다. 문서 문자열은 객체의 __doc__ 속성을 통해 조회할 수 있으며, help()와 문서 생성 도구가 이를 활용한다.[47]
def normalize_name(name):
"""이름의 앞뒤 공백을 제거하고 소문자로 변환한다."""
return name.strip().lower()
문서 문자열은 주석과 달리 런타임 객체에 연결될 수 있는 문자열이다. 여러 줄의 설명이 필요할 때는 삼중 따옴표 문자열을 주로 사용한다.
def connect(address, timeout=30):
"""
지정한 주소에 연결한다.
연결에 성공하면 세션 객체를 반환한다.
"""
...
식별자와 키워드
식별자는 변수, 함수, 클래스, 모듈과 속성 등에 이름을 부여하는 데 사용된다. Python 식별자는 Unicode 문자를 지원하며, 첫 글자에는 문자 또는 밑줄을 사용할 수 있고 이후에는 문자, 숫자와 밑줄을 사용할 수 있다.[48]
사용자_이름 = "Luna"
version_3 = "3.14"
_internal_value = 10
식별자는 대소문자를 구분하므로 value, Value와 VALUE는 서로 다른 이름이다. 다만 패키지와 외부 도구의 호환성, 협업과 검색 편의를 위해 소스 코드의 공개 식별자에는 일반적으로 ASCII 기반 영어 이름을 사용한다.
if, for, class, return과 같은 예약 키워드는 일반 식별자로 사용할 수 없다. 현재 실행 환경의 키워드는 keyword 표준 라이브러리 모듈을 통해 확인할 수 있다.
import keyword
print(keyword.kwlist)
Python에는 항상 키워드로 취급되는 일반 키워드 외에 특정 문법 위치에서만 특별한 의미를 가지는 소프트 키워드도 있다. 예를 들어 match와 case는 구조적 패턴 매칭 구문 안에서는 문법 요소이지만, 다른 위치에서는 일반 이름으로 사용할 수 있다.
match = "exact"
case = 10
밑줄로 시작하고 끝나는 이름은 Python 내부 프로토콜이나 특별한 의미에 사용되는 경우가 많다. __init__, __iter__, __name__ 같은 이름은 언어와 표준 라이브러리가 정의한 특수 이름이다. 사용자가 임의의 새로운 이중 밑줄 이름 체계를 만드는 것보다 이미 정의된 의미에 맞춰 사용하는 방식이 권장된다.
리터럴
리터럴은 소스 코드에서 상수 값을 직접 나타내는 표기이다. Python의 어휘 문법은 문자열, 바이트열과 숫자 리터럴을 정의하며, None, True, False와 ...도 고정된 값을 나타낸다.[49]
정수는 10진수 외에 2진수, 8진수와 16진수로 작성할 수 있다.
decimal = 255
binary = 0b11111111
octal = 0o377
hexadecimal = 0xff
숫자 사이의 밑줄은 값을 바꾸지 않으면서 자릿수를 구분하는 데 사용할 수 있다.
population = 51_000_000
mask = 0b1111_0000
부동소수점 수는 소수점이나 지수 표기를 사용할 수 있다.
ratio = 0.125
distance = 1.5e6
복소수 리터럴은 허수부에 j 또는 J를 붙여 표현한다.
position = 3 + 4j
문자열은 작은따옴표나 큰따옴표로 작성할 수 있으며, 삼중 따옴표를 사용하면 여러 물리적 줄에 걸친 문자열을 만들 수 있다.
single = 'Python'
double = "Python"
multiline = """첫 번째 줄
두 번째 줄"""
문자열 접두사로 원시 문자열, 바이트 문자열과 포맷 문자열 등을 표현한다.
path_pattern = r"C:\Users\name"
payload = b"\x50\x79"
name = "Python"
message = f"Language: {name}"
원시 문자열은 대부분의 역슬래시 이스케이프를 문자 그대로 유지하지만, 문자열을 닫는 따옴표와 마지막 역슬래시에는 여전히 문법적 제약이 있다. 바이트 문자열은 Unicode 문자열이 아니라 정수 바이트의 배열을 나타낸다.
목록, 튜플, 사전과 집합은 엄밀히는 어휘 단계의 단일 토큰이 아니라 여러 구문 요소로 만들어지는 표시 표현이다. 그러나 소스 코드에서 값을 직접 구성한다는 점에서 일반적으로 컬렉션 리터럴이라고 부른다.
numbers = [1, 2, 3]
point = (10, 20)
options = {"debug": True, "port": 8080}
permissions = {"read", "write"}
빈 중괄호 {}는 빈 사전을 만들며, 빈 집합은 set()으로 생성한다.
표현식
표현식은 평가되어 값을 만드는 구문이다. 리터럴, 이름 참조, 속성 접근, 인덱싱, 함수 호출과 연산자 적용은 모두 표현식에 해당한다.[50]
value
user.name
items[index]
calculate_total(price, tax)
left + right
여러 표현식은 연산자와 호출 문법을 통해 더 큰 표현식으로 결합할 수 있다.
total = sum(item.price * item.quantity for item in order.items)
이 표현식에는 속성 접근, 곱셈, 생성기 표현식과 함수 호출이 함께 사용된다. 각 하위 표현식은 정해진 평가 순서와 연산자 우선순위에 따라 처리된다.
Python에서는 모든 언어 구성 요소가 표현식인 것은 아니다. while, for, try, 함수와 클래스 정의는 값을 만드는 표현식이 아니라 문장이다. 일반 대입 역시 문장으로 분류되므로 다른 표현식 내부에 직접 넣을 수 없다.
result = value = calculate()
위 코드는 연쇄 대입문이며 value = calculate() 전체가 값을 반환하는 표현식으로 포함된 것이 아니다. 별도의 대입 표현식인 :=는 제한된 위치에서 값을 이름에 바인딩하면서 해당 값을 표현식의 결과로 사용할 수 있다.
while chunk := stream.read(4096):
process(chunk)
대입 표현식은 반복 조건이나 컴프리헨션에서 계산 결과를 재사용할 때 유용하지만, 바인딩과 다른 연산을 지나치게 결합하면 흐름을 읽기 어려워질 수 있다.
이름·속성·구독과 슬라이싱
이름 표현식은 현재 실행 범위에서 이름에 바인딩된 객체를 조회한다.
count
process
Configuration
속성 참조는 점 문법으로 객체의 속성을 조회한다.
user.name
document.title
service.start
속성 조회는 객체의 타입과 데이터 모델에 따라 __getattribute__(), 디스크립터와 __getattr__() 등의 동작에 연결될 수 있다. 따라서 점 문법은 단순한 구조체 필드 접근뿐 아니라 계산된 프로퍼티와 동적으로 제공되는 속성에도 사용된다.
대괄호를 이용한 구독은 컬렉션이나 사용자 정의 객체에서 항목을 조회한다.
items[0]
settings["theme"]
matrix[row, column]
정수 인덱스와 문자열 키뿐 아니라 객체가 지원하는 다양한 키를 전달할 수 있다. 구독 연산은 객체의 __getitem__() 프로토콜과 연결된다.
슬라이스는 시작, 끝과 간격을 이용해 범위의 일부를 지정한다.
values[1:5]
values[:3]
values[::2]
values[::-1]
시작 위치는 포함되고 끝 위치는 포함되지 않는다. 시퀀스 객체는 슬라이스를 받아 새로운 시퀀스를 반환하거나, 사용자 정의 의미로 해석할 수 있다.
연산자와 평가 순서
Python은 산술, 비교, 논리, 비트와 포함 관계 연산자를 제공한다. 연산자는 정해진 우선순위에 따라 결합되며, 괄호로 평가 순서를 명확하게 지정할 수 있다.
result = base + width * height
explicit = (base + width) * height
거듭제곱은 **, 나눗셈은 /, 내림 나눗셈은 //, 나머지는 %로 표현한다.
power = 2 ** 10
ratio = 7 / 2
quotient = 7 // 2
remainder = 7 % 2
/는 피연산자가 정수여도 일반적으로 부동소수점 결과를 반환한다. //는 결과를 음의 무한대 방향으로 내림하므로 음수에서는 단순히 소수 부분을 버리는 것과 결과가 다를 수 있다.
비교 연산은 연속해서 작성할 수 있다.
if 0 <= index < len(items):
process(items[index])
이 표현식은 0 <= index와 index < len(items)를 논리적으로 결합하지만, 가운데의 index는 한 번만 평가된다.
and와 or는 피연산자를 반드시 불리언으로 변환해 반환하지 않고, 평가를 결정한 원래 객체를 반환한다.
display_name = nickname or username
nickname이 참으로 평가되면 그 객체가 반환되고, 거짓이면 username이 평가되어 반환된다. and와 or는 필요한 피연산자만 평가하는 단락 평가를 수행한다.
객체의 동일성을 비교할 때는 is와 is not을 사용하며, 값의 동등성을 비교할 때는 ==와 !=을 사용한다.
if result is None:
handle_missing()
if current_value == expected_value:
handle_match()
in과 not in은 컨테이너에 값이 포함되어 있는지 검사한다.
if "admin" in roles:
grant_access()
함수 호출과 인자 전달
함수 호출은 호출 가능한 객체 뒤에 괄호를 붙여 표현한다. 괄호 안에는 위치 인자, 키워드 인자와 언패킹된 인자를 전달할 수 있다.
connect("localhost", 8080)
connect(host="localhost", port=8080)
위치 인자는 작성된 순서대로 매개변수에 대응하고, 키워드 인자는 매개변수 이름을 지정한다. 일반적으로 위치 인자를 키워드 인자보다 앞에 작성해야 한다.
반복 가능한 객체 앞에 *를 붙이면 위치 인자로 펼칠 수 있고, 매핑 앞에 **를 붙이면 키워드 인자로 펼칠 수 있다.
arguments = ["localhost", 8080]
options = {"timeout": 30, "secure": True}
connect(*arguments, **options)
함수 호출식은 함수가 반환한 객체를 값으로 가진다. 함수가 명시적으로 값을 반환하지 않으면 None을 반환한다.
호출 대상은 일반 함수에 한정되지 않는다. 클래스, 인스턴스 또는 __call__() 프로토콜을 구현한 객체도 호출할 수 있다.
parser = Parser()
result = parser(source)
단순문
단순문은 하나의 논리적 줄 안에서 구성되는 문장이다. 표현식문, 대입문, assert, pass, del, return, yield, raise, break, continue, import, global, nonlocal과 타입 별칭문 등이 단순문에 포함된다.[51]
표현식을 하나의 문장으로 작성하면 표현식문이 된다.
print("Hello")
service.start()
스크립트 실행에서는 평가 결과가 자동으로 표시되지 않지만, 대화형 인터프리터에서는 None이 아닌 표현식문의 결과가 화면에 표시될 수 있다.
대입문은 표현식의 결과를 하나 이상의 대상에 바인딩한다.
count = 10
left = right = 0
x, y = point
연쇄 대입에서는 오른쪽 표현식을 한 번 평가한 뒤 여러 대상에 같은 객체를 바인딩한다. 언패킹 대입에서는 오른쪽 반복 가능 객체의 요소를 왼쪽 대상 구조에 맞춰 분배한다.
first, *middle, last = values
증강 대입은 기존 값에 연산한 결과를 다시 대상에 저장한다.
count += 1
items *= 2
객체와 연산에 따라 증강 대입은 기존 객체를 제자리에서 변경하거나 새로운 객체를 만들어 다시 바인딩할 수 있다.
return은 함수 실행을 종료하고 호출자에게 값을 돌려준다.
def add(left, right):
return left + right
raise는 예외를 발생시키며, break는 가장 가까운 반복문을 종료하고 continue는 다음 반복으로 이동한다.
for item in items:
if item is None:
continue
if item == stop_marker:
break
process(item)
global과 nonlocal은 함수 안의 이름 바인딩이 어느 범위의 이름을 대상으로 하는지 지정한다. 이들은 실행 중 동적으로 범위를 변경하는 함수가 아니라, 해당 코드 블록의 이름 해석에 영향을 주는 선언이다.
대입 대상과 언패킹
Python 대입문의 왼쪽에는 단순한 이름뿐 아니라 속성, 구독, 슬라이스와 중첩된 대상 목록을 사용할 수 있다.
user.name = "Luna"
settings["theme"] = "dark"
items[1:3] = replacements
이름 대입은 현재 이름 공간의 바인딩을 변경한다. 속성 대입은 객체의 속성 설정 프로토콜을 호출하며, 구독과 슬라이스 대입은 대상 객체의 항목 변경 프로토콜을 사용한다. 따라서 문법이 비슷해도 실제 동작은 대상 종류에 따라 다르다.
시퀀스 언패킹은 오른쪽의 반복 가능한 객체에서 값을 얻어 왼쪽의 대상에 배치한다.
width, height = dimensions
red, green, blue = color
별표 대상은 남은 여러 값을 목록으로 수집한다.
head, *body, tail = values
중첩된 구조도 사용할 수 있다.
name, (x, y) = record
왼쪽 구조와 오른쪽에서 제공되는 값의 개수가 맞지 않으면 ValueError가 발생한다. 별표 대상은 하나의 대입 대상 목록에 하나만 둘 수 있다.
조건문
if 문은 조건의 참과 거짓에 따라 실행할 블록을 선택한다. 첫 조건이 거짓이면 elif 절을 순서대로 검사하고, 모두 거짓이면 선택적인 else 절을 실행한다.[52]
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"
조건에는 불리언 객체뿐 아니라 참·거짓으로 평가할 수 있는 모든 객체를 사용할 수 있다. None, False, 수치형의 0과 비어 있는 문자열 및 컬렉션은 일반적으로 거짓으로 평가된다. 그 밖의 대부분 객체는 참으로 평가되지만, 사용자 정의 클래스는 __bool__() 또는 __len__()을 통해 동작을 정의할 수 있다.
간단한 값 선택은 조건 표현식으로 작성할 수 있다.
status = "adult" if age >= 18 else "minor"
조건 표현식은 값을 만드는 표현식이므로 함수 인자나 대입문의 오른쪽에 포함할 수 있다. 여러 조건을 중첩하면 읽기 어려워질 수 있으므로 복잡한 분기에는 일반 if 문이 더 적합하다.
반복문
while 문은 조건이 참으로 평가되는 동안 본문을 반복한다.
while retries > 0:
if connect():
break
retries -= 1
for 문은 반복 가능한 객체에서 요소를 순서대로 얻어 대상에 바인딩하고 본문을 실행한다.
for item in items:
process(item)
반복 대상은 목록이나 튜플에 한정되지 않으며, 반복자 프로토콜을 제공하는 모든 객체를 사용할 수 있다. 문자열, 파일, 사전, 제너레이터와 사용자 정의 객체도 for 문의 대상이 될 수 있다.
대상 위치에 언패킹 구조를 사용할 수 있다.
for name, score in results:
print(name, score)
range()는 일정한 정수 범위를 반복할 때 자주 사용된다.
for index in range(10):
print(index)
Python의 while과 for 문에는 선택적인 else 절을 붙일 수 있다. 반복문이 break 없이 정상적으로 끝났을 때 else 절이 실행된다.
for item in items:
if item.id == target_id:
found = item
break
else:
found = None
for 문의 else는 반복이 한 번도 실행되지 않았을 때만 사용하는 구조가 아니라, 반복이 고갈될 때까지 완료되었는지를 나타낸다. 예외나 return으로 반복문을 벗어난 경우에도 실행되지 않는다.
구조적 패턴 매칭
match 문은 하나의 주제 값을 여러 패턴과 순서대로 비교하고, 처음 성공한 case 블록을 실행한다. Python 3.10에서 도입되었으며 단순한 값 비교뿐 아니라 시퀀스, 매핑과 객체의 구조를 분해할 수 있다.[53]
match command:
case "start":
start_service()
case "stop":
stop_service()
case _:
report_unknown_command()
_는 일반적으로 어떤 값과도 일치하면서 값을 이름에 저장하지 않는 와일드카드 패턴으로 사용된다.
시퀀스 패턴은 값의 구조를 검사하고 동시에 요소를 이름에 바인딩할 수 있다.
match point:
case (0, 0):
location = "origin"
case (x, 0):
location = f"x-axis at {x}"
case (0, y):
location = f"y-axis at {y}"
case (x, y):
location = f"point at {x}, {y}"
매핑 패턴은 필요한 키가 존재하는지 검사하고 대응하는 값을 추출한다.
match message:
case {"type": "text", "content": content}:
handle_text(content)
case {"type": "image", "url": url}:
handle_image(url)
클래스 패턴은 객체의 타입과 지정한 속성 구조를 함께 검사할 수 있다.
match event:
case MouseEvent(x=x, y=y, button="left"):
handle_left_click(x, y)
case KeyEvent(key="Escape"):
close_window()
case 뒤에 if 가드를 추가하면 패턴이 일치한 뒤 추가 조건을 검사한다.
match value:
case number if number < 0:
category = "negative"
case number if number > 0:
category = "positive"
case _:
category = "zero"
패턴 안의 이름은 기존 변수의 값을 비교하는 것이 아니라, 대부분 새 값을 캡처하는 대상으로 동작한다. 특정 상수와 비교하려면 리터럴 패턴이나 점으로 한정된 값 패턴을 사용한다. 이러한 차이 때문에 match 문은 단순히 여러 if 문을 짧게 표현하는 구문이 아니라 데이터 구조를 검사하고 분해하는 독립적인 기능이다.[54]
컴프리헨션
컴프리헨션은 반복 가능한 객체를 변환하거나 필터링하여 새로운 컬렉션을 만드는 표현식이다. 목록, 집합과 사전 컴프리헨션을 제공한다.
squares = [number ** 2 for number in values]
unique_names = {name.lower() for name in names}
name_lengths = {name: len(name) for name in names}
하나 이상의 for 절과 if 절을 조합할 수 있다.
coordinates = [
(x, y)
for x in range(width)
for y in range(height)
if is_available(x, y)
]
절의 실행 순서는 일반 반복문으로 작성했을 때의 왼쪽에서 오른쪽 순서와 대응한다.
coordinates = []
for x in range(width):
for y in range(height):
if is_available(x, y):
coordinates.append((x, y))
목록 컴프리헨션은 결과 목록 전체를 즉시 생성한다. 대괄호 대신 괄호를 사용한 생성기 표현식은 값을 요청받을 때마다 계산하는 반복자를 만든다.
total = sum(number ** 2 for number in values)
생성기 표현식은 전체 중간 목록이 필요하지 않은 합산이나 검색, 큰 입력 처리에서 메모리 사용을 줄일 수 있다. 반대로 결과를 여러 번 순회하거나 인덱스로 접근해야 한다면 목록과 같은 구체적인 컬렉션이 적합할 수 있다.[55]
컴프리헨션은 단순한 변환과 필터링을 직접 표현하는 데 유용하지만, 여러 단계의 조건과 부수 효과가 결합되면 일반 반복문보다 이해하기 어려워질 수 있다. 외부 상태를 변경하는 작업보다 새로운 값을 구성하는 작업에 주로 사용한다.
예외 처리 구문
try 문은 실행 중 발생한 예외를 처리하거나, 실행 결과와 관계없이 정리 작업을 수행하는 복합문이다.
try:
value = int(text)
except ValueError:
value = 0
except 절은 지정한 예외 타입과 일치하는 예외를 처리한다. 일치하는 처리기가 없으면 예외는 바깥쪽 실행 문맥으로 계속 전달된다.[56]
여러 예외를 각각 처리할 수 있다.
try:
response = request(url)
except TimeoutError:
retry_later()
except ConnectionError as error:
report_connection_failure(error)
else 절은 try 본문에서 예외가 발생하지 않았을 때 실행되며, finally 절은 정상 종료, 예외, return, break와 관계없이 정리 단계에서 실행된다.
resource = acquire()
try:
use(resource)
finally:
release(resource)
except에서 예외를 잡은 뒤 원래 예외를 다시 발생시키려면 인자 없는 raise를 사용할 수 있다. 다른 예외를 원인으로 연결하려면 raise ... from ... 구문을 사용한다.
try:
config = parse_config(source)
except ValueError as error:
raise ConfigurationError("설정을 해석할 수 없다") from error
with 문과 자원 범위
with 문은 컨텍스트 관리자를 사용해 블록 진입과 종료 처리를 구조화한다. 파일, 잠금, 데이터베이스 트랜잭션과 임시 상태처럼 사용 전후에 정해진 처리가 필요한 객체에 사용한다.
with open("data.txt", encoding="utf-8") as file:
contents = file.read()
컨텍스트 관리자의 __enter__() 동작은 블록 진입 시 실행되고, __exit__() 동작은 블록에서 빠져나올 때 실행된다. 종료 처리는 정상 실행뿐 아니라 예외가 발생하거나 return으로 벗어나는 경우에도 수행된다.[57]
여러 컨텍스트 관리자를 하나의 with 문에서 사용할 수 있다.
with (
open("source.txt", encoding="utf-8") as source,
open("target.txt", "w", encoding="utf-8") as target,
):
target.write(source.read())
with 문은 단순히 try와 finally를 짧게 쓰는 구문을 넘어, 자원의 획득과 반환 규칙을 객체 자체에 캡슐화하는 구조를 제공한다.
함수와 클래스 정의
함수 정의와 클래스 정의는 실행 가능한 복합문이다. 인터프리터가 def 문을 실행하면 함수 객체를 만들고 지정한 이름에 바인딩한다.
def calculate_area(width, height):
return width * height
함수 본문은 정의 시점에 즉시 실행되지 않으며, 함수가 호출될 때 새로운 실행 프레임에서 실행된다. 기본 인자 표현식은 함수가 호출될 때마다가 아니라 def 문이 실행되는 시점에 평가된다.
def append_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
클래스 정의문이 실행되면 새로운 이름 공간에서 클래스 본문을 실행한 뒤, 그 결과를 바탕으로 클래스 객체를 생성한다.
class Rectangle:
unit = "px"
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
클래스 본문에는 메서드 정의뿐 아니라 일반 대입, 조건문과 함수 호출도 작성할 수 있다. 클래스 본문에서 만들어진 이름은 클래스의 속성 이름 공간을 구성한다.
함수와 클래스 정의 앞에는 데코레이터를 둘 수 있다.
@register_handler("message")
def handle_message(message):
process(message)
데코레이터 표현식은 정의 시점에 평가되며, 생성된 함수나 클래스 객체를 받아 다른 객체로 변환하거나 등록할 수 있다.
모듈과 최상위 코드
Python 소스 파일은 일반적으로 하나의 모듈로 실행된다. 모듈의 최상위 문장은 파일의 처음부터 순서대로 실행되며, 그 과정에서 생성된 이름은 모듈의 전역 이름 공간에 저장된다.
DEFAULT_PORT = 8080
def start_server(port=DEFAULT_PORT):
...
print("module loaded")
이 모듈을 가져오면 함수 정의뿐 아니라 최상위의 print() 호출도 실행된다. 라이브러리 모듈에서는 가져오기만 해도 불필요한 작업이나 사용자 상호작용이 발생하지 않도록 최상위 실행 코드를 제한하는 것이 일반적이다.
모듈이 스크립트로 직접 실행되면 __name__에 "__main__"이 바인딩된다. 다른 모듈에서 가져오면 __name__에는 모듈의 정규 이름이 들어간다.
def main():
start_application()
if __name__ == "__main__":
main()
이 구조를 사용하면 함수와 클래스는 다른 모듈에서 재사용하면서, 파일을 직접 실행할 때만 프로그램 진입 코드를 수행할 수 있다.
Python 인터프리터는 파일뿐 아니라 표준 입력, -c로 전달된 문자열, 대화형 입력과 가져온 모듈 등 여러 형태로 최상위 코드를 받을 수 있다.[58]
프로그램의 실행 순서
모듈의 최상위 코드는 기본적으로 위에서 아래로 실행된다. 함수와 클래스 정의문도 이 순서에서 객체를 생성하고 이름에 바인딩하는 실행 문장이다.
print("before")
def greet():
print("inside")
print("after")
greet()
이 프로그램은 "before", "after", "inside" 순서로 출력한다. 함수 본문은 def가 처리될 때 실행되지 않고 실제 호출 시 실행되기 때문이다.
조건문과 반복문, 예외 처리, 함수 호출은 이 기본 순서를 변경한다. return, break, continue, raise는 현재 실행 중인 흐름을 다른 위치로 이동시키며, 제너레이터의 yield와 코루틴의 await는 실행을 일시 중단했다가 이후 이어서 실행할 수 있게 한다.
이름은 사용되기 전에 해당 실행 경로에서 바인딩되어 있어야 한다. 소스 파일의 아래쪽에 정의된 함수라도 현재 함수가 실제로 호출되기 전에 정의문이 실행되었다면 사용할 수 있다.
def main():
helper()
def helper():
print("helper")
main()
반면 정의가 실행되기 전에 호출하면 이름을 찾을 수 없다.
main()
def main():
print("main")
Python의 프로그램 구조는 선언만을 모아 먼저 처리하는 방식보다, 모듈 자체를 하나의 실행 가능한 코드 블록으로 취급하는 방식에 가깝다. 이 특성은 조건부 정의, 동적 등록과 모듈 초기화를 가능하게 하지만, 가져오기 시 발생하는 부수 효과와 이름의 정의 순서를 함께 고려해야 한다.
문법과 객체 모델의 연결
Python의 문법 요소는 내부적으로 객체가 제공하는 프로토콜과 연결되는 경우가 많다. for 문은 반복자 프로토콜, with 문은 컨텍스트 관리자 프로토콜, 연산자는 수치 및 비교 특수 메서드, 구독은 항목 접근 프로토콜을 사용한다.
for item in container:
process(item)
위 구문은 개념적으로 container에서 반복자를 얻고, 반복자의 다음 값을 요청하는 과정으로 이루어진다.
iterator = iter(container)
while True:
try:
item = next(iterator)
except StopIteration:
break
process(item)
실제 언어 구현은 이를 그대로 소스 코드로 변환하지 않지만, 두 구조는 반복자 프로토콜이라는 동일한 실행 모델을 공유한다.
마찬가지로 다음 연산은 객체의 특수 메서드에 연결된다.
result = left + right
length = len(container)
value = container[key]
각각 __add__() 계열 연산, __len__()과 __getitem__() 프로토콜이 관여한다. 이로 인해 사용자 정의 클래스도 Python의 내장 문법에 자연스럽게 참여할 수 있다.
Python 문법은 단순한 표기법과 객체 모델이 분리되어 있는 것이 아니라, 문장과 표현식이 객체의 공통 프로토콜을 호출하도록 구성되어 있다. 이 관계는 내장 자료형과 사용자 정의 객체가 같은 제어문, 연산자와 함수에 사용될 수 있게 하는 기반이다.
데이터 모델과 객체
Python에서 객체는 데이터를 표현하는 기본 단위이다. 숫자와 문자열, 컬렉션뿐 아니라 함수, 클래스, 모듈, 코드와 실행 프레임도 객체로 표현된다. 프로그램의 상태는 객체와 객체 사이의 관계, 그리고 이름이 객체에 바인딩된 구조로 구성된다.[59]
객체가 어떤 연산을 지원하는지는 객체의 타입과 데이터 모델에 의해 결정된다. +와 같은 연산자, for 반복, 함수 호출, 속성 접근, 구독, 문자열 변환과 with 문은 각각 객체가 제공하는 특수 메서드 및 프로토콜에 연결된다. 사용자 정의 클래스도 이러한 프로토콜을 구현하면 내장 객체와 같은 문법에 참여할 수 있다.
class Counter:
def __init__(self, limit):
self.limit = limit
def __iter__(self):
return iter(range(self.limit))
for number in Counter(3):
print(number)
Counter는 목록이나 튜플이 아니지만 __iter__()를 구현하므로 반복 가능한 객체로 동작한다. Python의 데이터 모델은 특정 내장 클래스만 문법에 사용할 수 있도록 고정하기보다, 객체가 필요한 동작을 제공하는지를 중심으로 구성된다.
객체의 정체성·타입·값
모든 Python 객체는 정체성, 타입과 값을 가진다. 이 세 요소는 객체를 설명하는 기본 속성이다.[60]
객체의 정체성은 다른 객체와 구별되는 고유한 특성이다. 객체가 생성된 뒤에는 정체성이 바뀌지 않으며, is 연산자는 두 표현식이 같은 객체를 가리키는지 비교한다. id()는 객체의 정체성을 나타내는 정수를 반환한다.
first = []
second = []
alias = first
print(first is second)
print(first is alias)
first와 second는 내용이 같은 빈 목록이지만 별도로 생성된 객체이므로 first is second는 거짓이다. alias는 first와 같은 객체에 바인딩되므로 first is alias는 참이다.
CPython에서는 현재 id()가 객체가 저장된 메모리 주소와 연결되지만, 이는 CPython의 구현 세부 사항이다. Python 언어는 id()가 객체의 수명 동안 고유하고 변하지 않는 정체성 값을 제공한다는 점만 보장한다.
객체의 타입은 객체가 가질 수 있는 값의 범위와 지원하는 연산을 결정한다. type()은 객체의 타입 객체를 반환한다.
value = 42
print(type(value))
print(isinstance(value, int))
객체의 타입 역시 객체 생성 후에는 일반적으로 바뀌지 않는다. 이름이 다른 타입의 객체에 다시 바인딩될 수는 있지만, 기존 객체 자체의 타입이 변경되는 것은 아니다.
value = 42
value = "forty-two"
첫 번째 대입에서 value는 정수 객체를 가리키고, 두 번째 대입에서는 문자열 객체를 가리킨다. 정수 객체가 문자열 객체로 변환된 것이 아니라 이름의 바인딩 대상이 바뀐 것이다.
객체의 값은 객체가 나타내는 데이터와 상태이다. 값의 변경 가능 여부는 객체의 타입에 따라 결정된다.
이름과 객체의 바인딩
Python 변수는 값을 담는 고정된 타입의 저장 상자라기보다, 객체와 연결되는 이름으로 이해할 수 있다. 대입문은 오른쪽 표현식이 만든 객체를 왼쪽 이름에 바인딩한다.
items = [1, 2, 3]
이 코드는 목록 객체를 생성하고 items라는 이름을 해당 객체에 연결한다. 다른 이름을 같은 객체에 바인딩할 수도 있다.
first = [1, 2]
second = first
second.append(3)
print(first)
first와 second는 같은 목록 객체를 가리키므로 second를 통해 객체를 변경하면 first를 통해서도 변경된 값이 보인다.
이러한 관계를 별칭이라고 한다. 가변 객체를 여러 이름이나 컨테이너가 공유할 때는 한 위치에서의 변경이 다른 위치에서도 관찰될 수 있다.
settings = {"theme": "dark"}
profiles = [settings, settings]
profiles[0]["theme"] = "light"
print(profiles[1]["theme"])
두 목록 원소는 같은 사전 객체를 참조하므로 한 원소를 통해 변경한 결과가 다른 원소에서도 나타난다.
대입은 기본적으로 객체를 복사하지 않는다. 별도의 객체가 필요하면 객체 종류에 맞는 복사 방법을 사용해야 한다.
original = [1, 2, 3]
copied = original.copy()
copied.append(4)
동등성과 동일성
Python은 객체의 값이 같은지 비교하는 동등성과 같은 객체인지 비교하는 동일성을 구분한다.
left = [1, 2]
right = [1, 2]
print(left == right)
print(left is right)
==는 객체가 정의한 값 비교 연산을 수행하므로 두 목록의 원소가 같으면 참이다. is는 정체성을 비교하므로 별도로 생성된 두 목록에서는 거짓이다.
일반적인 값 비교에는 ==를 사용하고, 객체의 정체성을 확인해야 할 때만 is를 사용한다. 특히 None은 하나만 존재하는 싱글턴 객체이므로 다음과 같이 비교한다.
if result is None:
handle_missing_value()
일부 정수와 문자열 객체는 구현체의 최적화에 의해 재사용될 수 있으므로, 같은 리터럴이 우연히 같은 정체성을 가질 수 있다.
first = 100
second = 100
이 경우 first is second의 결과에 의존해서는 안 된다. 불변 객체의 재사용 여부는 구현체와 실행 상황에 따라 달라질 수 있으며 값 비교에는 항상 ==를 사용해야 한다.
사용자 정의 클래스의 기본 동등성 비교는 일반적으로 객체 정체성을 기반으로 한다. 값 기반 비교가 필요하면 __eq__()를 구현할 수 있다.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
NotImplemented는 비교 대상의 타입을 현재 메서드가 처리하지 못한다는 사실을 인터프리터에 알린다. 이는 일반적인 실패를 나타내는 None이나 예외와는 다른 특수 값이다.
가변 객체와 불변 객체
객체의 값이 생성 후 변경될 수 있으면 가변 객체, 변경될 수 없으면 불변 객체라고 한다. 객체의 가변성은 타입에 의해 결정된다.[61]
정수, 부동소수점 수, 복소수, 문자열, 바이트열과 튜플은 대표적인 불변 객체이다. 목록, 사전, 집합과 대부분의 사용자 정의 클래스 인스턴스는 가변 객체이다.
number = 10
number += 1
정수 객체 자체의 값이 10에서 11로 변경되는 것이 아니다. 10 + 1의 결과인 새로운 정수 객체가 만들어지고 number가 그 객체에 다시 바인딩된다.
문자열도 불변이다.
message = "Python"
message += " language"
이 연산은 기존 문자열 객체를 수정하는 대신 새로운 문자열 값을 만들고 이름을 다시 바인딩한다.
목록은 가변이므로 객체 자체의 원소 구성을 변경할 수 있다.
numbers = [1, 2]
numbers.append(3)
numbers[0] = 10
가변성과 이름 재바인딩은 서로 다른 동작이다.
items = [1, 2]
alias = items
items = [3, 4]
마지막 대입은 기존 목록을 수정하지 않는다. items를 새 목록 객체에 바인딩하며 alias는 계속 기존 [1, 2] 목록을 가리킨다.
items = [1, 2]
alias = items
items.append(3)
이 경우에는 같은 목록 객체를 직접 변경하므로 alias에서도 [1, 2, 3]이 보인다.
불변 컨테이너와 내부 가변성
튜플과 같은 불변 컨테이너는 생성 후 자신이 직접 참조하는 객체의 구성을 변경할 수 없다. 그러나 내부에 가변 객체가 들어 있으면 그 내부 객체의 상태는 변경될 수 있다.
record = ("user", ["read", "write"])
record[1].append("execute")
튜플의 두 번째 원소가 다른 객체로 교체된 것은 아니다. 튜플은 여전히 같은 목록 객체를 참조하지만, 그 목록의 내부 값이 변경되었다.
따라서 불변 컨테이너라는 표현은 내부에 포함된 모든 상태가 영원히 동일하다는 뜻이 아니다. 컨테이너가 직접 보유한 참조 구조를 변경할 수 없다는 의미에 가깝다.
record[1] = ["admin"]
이 코드는 튜플 원소 자체를 교체하려 하므로 TypeError를 발생시킨다.
불변 객체가 해시 가능한지도 내부 구성에 따라 달라질 수 있다. 가변 목록을 포함한 튜플은 전체 값에 안정적인 해시를 제공할 수 없으므로 사전 키로 사용할 수 없다.
key = ("user", ["read", "write"])
이 객체를 hash()에 전달하면 목록이 해시 가능하지 않기 때문에 오류가 발생한다.
표준 타입 계층
Python은 여러 내장 타입을 제공하며, 이들은 데이터 모델의 공통 규칙 아래에서 서로 다른 값을 표현한다.[62]
특수 싱글턴 객체에는 None, NotImplemented와 Ellipsis가 있다.
None은 값의 부재나 명시적인 결과가 없음을 나타내는 데 사용된다. 함수가 return 없이 종료되면 None을 반환한다.
def log_message(message):
print(message)
result = log_message("started")
NotImplemented는 주로 이항 연산과 비교 특수 메서드에서 현재 피연산자 조합을 처리하지 않는다는 의미로 반환한다.
def __eq__(self, other):
if not isinstance(other, type(self)):
return NotImplemented
return self.value == other.value
Ellipsis는 ...로 표현되며 다차원 슬라이싱과 타입 표기, 구현 예정 코드 등의 문맥에서 사용된다.
def pending():
...
수치형 계층에는 bool, int, float와 complex가 포함된다. bool은 int의 하위 타입이며 False와 True는 각각 대부분의 수치 문맥에서 0과 1처럼 동작한다.
시퀀스는 순서가 있는 유한한 값의 집합을 나타낸다. 문자열, 튜플과 bytes는 불변 시퀀스이며, 목록과 bytearray는 가변 시퀀스이다.
집합 타입에는 가변인 set과 불변이며 해시 가능한 frozenset이 있다. 매핑의 대표적인 내장 타입은 dict이며, 키와 값의 관계를 저장한다.
호출 가능한 타입에는 사용자 정의 함수, 내장 함수, 메서드, 클래스와 __call__()을 구현한 인스턴스가 포함된다.
모듈, 클래스, 코드 객체, 프레임, 트레이스백과 슬라이스도 모두 독립된 객체 타입이다. 이러한 구조로 인해 Python 프로그램은 자신의 함수나 클래스, 실행 상태를 런타임에 조사할 수 있다.
컨테이너와 객체 관계
다른 객체에 대한 참조를 포함하는 객체를 컨테이너라고 한다. 목록, 튜플, 사전, 집합과 사용자 정의 인스턴스는 다른 객체를 포함하거나 참조할 수 있다.
user = {
"name": "Luna",
"permissions": ["read", "write"],
}
이 사전 객체는 문자열 객체와 목록 객체에 대한 참조를 보유한다. 목록은 다시 여러 문자열 객체를 참조한다.
Python 컨테이너는 일반적으로 다른 객체 자체를 내부에 복제해 저장하기보다 객체에 대한 참조를 보유한다. 같은 객체가 여러 컨테이너에 동시에 포함될 수 있다.
shared = {"enabled": True}
first = [shared]
second = {"config": shared}
first[0]과 second["config"]는 같은 사전 객체이다.
print(first[0] is second["config"])
컨테이너의 얕은 복사는 바깥 컨테이너만 새로 만들고 내부 객체에 대한 참조는 그대로 공유한다.
original = [[1, 2], [3, 4]]
copied = original.copy()
copied[0].append(5)
original과 copied는 서로 다른 바깥 목록이지만 첫 번째 내부 목록을 공유하므로 변경이 양쪽에서 보인다.
전체 객체 관계를 재귀적으로 복사하려면 copy.deepcopy()를 사용할 수 있다. 다만 모든 객체가 의미 있는 깊은 복사를 지원하는 것은 아니며, 파일·모듈·함수나 외부 자원을 나타내는 객체에는 그대로 적용하기 적합하지 않을 수 있다.
해시 가능성
해시 가능한 객체는 객체의 수명 동안 변하지 않는 해시값을 제공하며, 다른 객체와 동등하다고 비교되면 같은 해시값을 가져야 한다. 해시 가능한 객체는 사전의 키나 집합의 원소로 사용할 수 있다.
coordinates = {
(10, 20): "spawn",
(30, 40): "exit",
}
정수, 문자열과 해시 가능한 원소만 포함한 튜플은 일반적으로 해시 가능하다. 목록, 사전과 집합처럼 값이 변경될 수 있는 내장 객체는 해시 가능하지 않다.
lookup = {
[1, 2]: "value",
}
목록은 사전 키로 사용할 수 없으므로 이 코드는 TypeError를 발생시킨다. 객체가 사전에 삽입된 뒤 값이 바뀌어 해시값까지 달라지면 원래 저장 위치를 안정적으로 찾을 수 없기 때문이다.
사용자 정의 클래스는 __hash__()와 __eq__()의 관계를 고려해야 한다. 값 기반 __eq__()를 정의하면서 객체를 가변으로 유지한다면 일반적으로 해시를 제공하지 않는 편이 적합하다.
class User:
__hash__ = None
def __init__(self, name):
self.name = name
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.name == other.name
불변 값 객체라면 동등한 객체가 같은 해시를 반환하도록 구현할 수 있다.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
이 경우 x와 y가 객체를 사전 키로 사용하는 동안 변경되지 않는다는 설계가 함께 필요하다.
함수도 객체라는 특성
Python 함수는 함수 정의문이 실행될 때 만들어지는 객체이다. 함수 객체는 변수에 저장하거나 컨테이너에 넣고, 다른 함수에 인자로 전달하거나 반환할 수 있다.
def greet(name):
return f"Hello, {name}"
operation = greet
print(operation("Python"))
함수 객체에는 이름, 문서 문자열, 기본 인자, 주석, 전역 이름 공간, 클로저와 컴파일된 코드 객체에 대한 정보가 연결된다.
print(greet.__name__)
print(greet.__doc__)
print(greet.__code__)
함수에는 사용자 정의 속성을 연결할 수도 있다.
def handler(message):
return message
handler.priority = 10
함수가 클래스 속성으로 저장되고 인스턴스를 통해 조회되면 디스크립터 동작에 의해 바인딩된 메서드 객체가 만들어진다.
class Greeter:
def greet(self, name):
return f"Hello, {name}"
instance = Greeter()
method = instance.greet
method는 원래 함수와 인스턴스를 함께 묶은 바인딩된 메서드이다.
print(method.__self__ is instance)
print(method.__func__ is Greeter.greet)
instance.greet("Python") 호출은 개념적으로 Greeter.greet(instance, "Python")과 대응한다.
클래스도 객체라는 특성
클래스는 객체를 생성하는 설계도이면서 그 자체로도 객체이다. 클래스 정의문이 실행되면 클래스 본문을 별도의 이름 공간에서 실행한 뒤 클래스 객체가 생성되고 이름에 바인딩된다.
class User:
role = "member"
def __init__(self, name):
self.name = name
User는 클래스 객체이며 type(User)를 통해 그 타입을 확인할 수 있다.
print(type(User))
일반적인 사용자 정의 클래스의 타입은 type이다. 클래스 호출은 기본적으로 새 인스턴스를 생성하고 초기화한다.
user = User("Luna")
클래스 객체도 변수에 저장하고 함수에 전달하거나 컬렉션에 넣을 수 있다.
model_types = {
"user": User,
}
model = model_types["user"]("Luna")
클래스의 속성은 인스턴스가 공유하는 기본 동작과 데이터를 제공한다. 인스턴스 속성과 클래스 속성은 서로 다른 이름 공간에 저장된다.
class Account:
category = "standard"
def __init__(self, owner):
self.owner = owner
category는 클래스 속성이고 owner는 각 인스턴스에 저장되는 속성이다.
인스턴스와 속성 이름 공간
일반적인 사용자 정의 인스턴스는 각자의 속성 이름 공간을 가진다. 기본적으로 이 이름 공간은 __dict__를 통해 확인할 수 있다.
class Device:
def __init__(self, name, enabled):
self.name = name
self.enabled = enabled
device = Device("sensor", True)
print(device.__dict__)
속성에 값을 대입하면 일반적으로 인스턴스 이름 공간에 항목이 추가되거나 변경된다.
device.status = "ready"
속성 조회는 단순히 인스턴스 사전만 확인하는 동작이 아니다. 인스턴스의 클래스와 상위 클래스, 디스크립터 및 사용자 정의 속성 접근 메서드가 함께 관여한다.
print(device.name)
일반적인 조회는 개념적으로 다음 순서의 영향을 받는다.
- 클래스 계층에서 데이터 디스크립터를 찾는다.
- 인스턴스의 속성 이름 공간을 확인한다.
- 클래스 계층의 일반 속성이나 비데이터 디스크립터를 찾는다.
- 찾지 못하면
__getattr__()을 호출할 수 있다.
정확한 동작은 __getattribute__()와 디스크립터 프로토콜에 의해 구현된다.
속성 접근의 사용자 정의
사용자 정의 클래스는 특수 메서드를 통해 속성 조회, 설정과 삭제 동작을 변경할 수 있다.
__getattribute__()는 대부분의 인스턴스 속성 조회에서 호출된다.
class LoggedObject:
def __getattribute__(self, name):
print(f"read: {name}")
return super().__getattribute__(name)
__getattribute__() 안에서 다시 self.name과 같은 일반 속성 접근을 사용하면 재귀 호출이 발생할 수 있으므로 super().__getattribute__() 또는 object.__getattribute__()를 통해 기본 동작을 호출한다.
__getattr__()은 일반적인 조회 과정에서 속성을 찾지 못했을 때 호출된다.
class Configuration:
def __getattr__(self, name):
return None
이 구조는 동적 속성이나 위임 객체를 구현할 때 사용할 수 있다. 다만 오타까지 조용히 값으로 처리하면 오류 발견이 어려워질 수 있으므로, 처리할 수 없는 이름에는 AttributeError를 발생시키는 것이 중요하다.
__setattr__()은 속성 대입을 제어한다.
class PositiveValue:
def __setattr__(self, name, value):
if name == "value" and value < 0:
raise ValueError("value must be positive")
super().__setattr__(name, value)
__delattr__()은 속성 삭제를 사용자 정의한다.
이러한 특수 메서드는 객체의 모든 속성 접근에 개입하므로 검증, 프록시, 지연 로딩과 불변 객체 구현 등에 활용할 수 있다.
디스크립터
디스크립터는 다른 객체의 속성으로 저장되어 속성 조회·대입·삭제 동작을 제어하는 객체이다. __get__(), __set__() 또는 __delete__() 가운데 하나 이상을 정의한 객체가 디스크립터 프로토콜에 참여한다.[63]
class Positive:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self.name]
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.name} must be positive")
instance.__dict__[self.name] = value
디스크립터를 클래스 속성으로 배치하면 여러 인스턴스의 속성 동작을 공통으로 관리할 수 있다.
class Product:
price = Positive()
quantity = Positive()
def __init__(self, price, quantity):
self.price = price
self.quantity = quantity
product.price = 10은 단순히 인스턴스 사전에 값을 저장하는 대신 Positive.__set__()을 호출한다.
디스크립터는 Python 객체 모델의 핵심 구성 요소이며, 함수의 메서드 바인딩, property, classmethod, staticmethod와 일부 슬롯 구현도 디스크립터 프로토콜을 활용한다.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
property는 접근자 메서드를 일반 속성과 같은 문법으로 노출하는 데이터 디스크립터이다.
데이터 디스크립터와 비데이터 디스크립터
__set__()이나 __delete__()을 정의한 디스크립터를 일반적으로 데이터 디스크립터라고 한다. __get__()만 정의한 객체는 비데이터 디스크립터이다.
데이터 디스크립터는 일반적인 인스턴스 속성보다 우선해서 조회된다. 따라서 인스턴스의 __dict__에 같은 이름이 있어도 데이터 디스크립터의 __get__()이 사용될 수 있다.
비데이터 디스크립터는 인스턴스 속성보다 낮은 우선순위를 가진다. 클래스에 정의된 일반 함수는 비데이터 디스크립터이므로, 인스턴스 속성에 같은 이름을 넣으면 가려질 수 있다.
class Service:
def run(self):
return "class method"
service = Service()
service.run = lambda: "instance value"
print(service.run())
반면 property처럼 데이터 디스크립터로 구현된 속성은 인스턴스 사전에 같은 이름을 직접 저장하는 방식으로 쉽게 우회되지 않는다.
이 우선순위는 메서드, 프로퍼티, ORM 필드와 검증 속성 등이 일관되게 동작하는 기반이다.
__slots__
일반적인 인스턴스는 동적인 속성 저장을 위해 __dict__를 가진다. 클래스에서 __slots__를 정의하면 허용할 인스턴스 속성 이름을 명시하고, 기본 인스턴스 사전을 생략할 수 있다.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
정의되지 않은 속성을 추가하려 하면 오류가 발생한다.
point = Point(10, 20)
point.color = "red"
__slots__는 인스턴스 수가 매우 많을 때 메모리 사용을 줄이고 속성 이름의 실수를 제한하는 데 활용할 수 있다. 슬롯 속성은 클래스에 생성되는 디스크립터를 통해 관리된다.
__slots__가 객체를 완전히 불변으로 만드는 것은 아니다. 정의된 슬롯의 값은 계속 변경할 수 있으며, 하위 클래스가 __dict__를 다시 제공할 수도 있다.
약한 참조를 지원하려면 필요한 경우 __weakref__를 슬롯에 포함해야 한다. 다중 상속과 클래스 레이아웃에도 제약이 있으므로 단순한 성능 최적화 수단으로 무조건 적용하기보다 객체 설계를 함께 고려해야 한다.
특수 메서드
이중 밑줄로 시작하고 끝나는 이름 가운데 Python 데이터 모델이 특별한 의미를 부여한 메서드를 특수 메서드라고 한다. 특수 메서드는 객체가 언어 문법과 내장 함수에 어떻게 반응하는지를 정의한다.[64]
객체 생성과 초기화에는 __new__()와 __init__()이 관여한다.
class User:
def __new__(cls, *args, **kwargs):
instance = super().__new__(cls)
return instance
def __init__(self, name):
self.name = name
__new__()는 새 인스턴스를 생성해 반환하고, __init__()은 이미 생성된 인스턴스의 초기 상태를 설정한다. 불변 타입의 하위 클래스를 구현하거나 인스턴스 생성을 제어할 때는 __new__()가 중요하다.
문자열 표현에는 __repr__(), __str__()와 __format__()이 사용된다.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __str__(self):
return f"({self.x}, {self.y})"
repr()은 주로 개발과 디버깅에 적합한 명확한 표현을 요청하고, str()은 사용자에게 보여줄 읽기 쉬운 표현을 요청한다.
불리언 평가에는 __bool__() 또는 __len__()이 사용된다.
class Queue:
def __init__(self):
self.items = []
def __bool__(self):
return bool(self.items)
호출 동작은 __call__()로 구현한다.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
이 객체는 함수처럼 호출할 수 있다.
double = Multiplier(2)
print(double(10))
연산자 오버로딩
수치 연산과 비교 연산은 특수 메서드를 통해 사용자 정의할 수 있다.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(
self.x + other.x,
self.y + other.y,
)
left + right는 왼쪽 객체의 __add__()와 필요에 따라 오른쪽 객체의 반사 연산인 __radd__()에 연결된다.
first = Vector(1, 2)
second = Vector(3, 4)
result = first + second
증강 대입에는 __iadd__()와 같은 제자리 연산 메서드가 사용될 수 있다. 메서드가 없거나 지원하지 않으면 일반 이항 연산과 재바인딩으로 처리될 수 있다.
비교 연산에는 __eq__(), __ne__(), __lt__(), __le__(), __gt__()와 __ge__()가 사용된다. 처리할 수 없는 상대 타입에는 NotImplemented를 반환하여 반대쪽 연산이나 기본 처리 기회를 제공한다.
객체의 연산자 의미는 해당 타입의 개념과 자연스럽게 대응하도록 설계하는 것이 중요하다. 단순히 문법을 짧게 만들기 위해 예상하기 어려운 의미를 부여하면 코드의 가독성이 낮아질 수 있다.
컨테이너 프로토콜
사용자 정의 객체는 특수 메서드를 구현해 시퀀스나 매핑과 유사하게 동작할 수 있다.
__len__()은 객체의 길이를 제공한다.
class Collection:
def __init__(self, values):
self.values = list(values)
def __len__(self):
return len(self.values)
__getitem__()은 구독 연산을 처리한다.
class Collection:
def __init__(self, values):
self.values = list(values)
def __getitem__(self, index):
return self.values[index]
__setitem__()과 __delitem__()은 항목 대입과 삭제를 지원한다.
def __setitem__(self, index, value):
self.values[index] = value
def __delitem__(self, index):
del self.values[index]
포함 관계 검사는 __contains__()로 사용자 정의할 수 있다.
def __contains__(self, value):
return value in self.values
객체가 __contains__()을 제공하지 않으면 Python은 반복이나 구독을 이용한 대체 동작을 시도할 수 있다.
컨테이너의 동작을 구현할 때는 지원하는 개념을 명확히 구분해야 한다. 순서와 인덱스를 가진 시퀀스인지, 임의의 키를 사용하는 매핑인지에 따라 기대되는 메서드와 예외가 달라진다.
반복자 프로토콜
반복 가능한 객체는 __iter__()를 통해 반복자를 반환한다. 반복자는 __next__()를 통해 다음 값을 반환하며, 더 이상 값이 없으면 StopIteration을 발생시킨다.
class CountDown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
이 객체는 for 문과 list() 등 반복 가능한 객체를 받는 기능에 사용할 수 있다.
for number in CountDown(3):
print(number)
컬렉션 객체와 반복자 객체를 분리하면 같은 컬렉션을 여러 번 독립적으로 반복할 수 있다.
class RangeCollection:
def __init__(self, stop):
self.stop = stop
def __iter__(self):
return iter(range(self.stop))
제너레이터 함수는 반복자 프로토콜을 더 간결하게 구현하는 방법을 제공한다.
def count_down(start):
while start > 0:
yield start
start -= 1
컨텍스트 관리자 프로토콜
with 문에 사용되는 객체는 컨텍스트 관리자 프로토콜을 구현한다. 동기 컨텍스트 관리자는 __enter__()와 __exit__()을 제공한다.
class ManagedResource:
def __enter__(self):
self.open()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
with ManagedResource() as resource:
resource.use()
__exit__()은 블록에서 예외가 발생했는지에 대한 정보를 받는다. 참을 반환하면 해당 예외를 처리한 것으로 간주해 전파를 중단할 수 있으며, 거짓이나 None을 반환하면 예외가 계속 전달된다.
비동기 컨텍스트 관리자는 __aenter__()와 __aexit__()을 구현하고 async with에서 사용된다.
이 프로토콜은 파일, 잠금, 트랜잭션과 임시 상태처럼 반드시 정리해야 하는 자원을 객체의 생명 주기와 구조적으로 연결한다.
객체 생성과 초기화
클래스 호출을 통한 인스턴스 생성에는 일반적으로 메타클래스의 호출 동작, 클래스의 __new__()와 __init__()이 관여한다.
instance = User("Luna")
개념적으로는 __new__()가 인스턴스를 생성한 뒤, 반환된 객체가 해당 클래스의 인스턴스라면 __init__()이 호출된다.
__new__()는 클래스 메서드처럼 클래스 객체를 첫 인자로 받고 새 객체를 반환한다.
class Identifier(str):
def __new__(cls, value):
normalized = value.strip().lower()
return super().__new__(cls, normalized)
문자열은 불변이므로 값의 초기화는 이미 객체가 생성된 뒤 실행되는 __init__()보다 __new__()에서 수행한다.
__init__()은 객체를 반환해서는 안 되며 기존 인스턴스의 상태를 설정한다.
class Account:
def __init__(self, owner):
self.owner = owner
self.balance = 0
팩토리나 캐싱을 구현하기 위해 __new__()가 기존 객체나 다른 타입의 객체를 반환할 수도 있다. 이 경우 일반적인 초기화 흐름이 달라질 수 있으므로 클래스 사용자에게 예측 가능한 의미를 유지해야 한다.
클래스 생성과 메타클래스
클래스 객체를 생성하는 객체를 메타클래스라고 한다. 일반적인 Python 클래스의 메타클래스는 type이다.
class Service:
pass
print(type(Service) is type)
클래스 정의문이 실행되면 기본 클래스와 메타클래스를 결정하고, 클래스 이름 공간을 준비한 뒤 클래스 본문을 실행하고 클래스 객체를 생성한다.[65]
사용자 정의 메타클래스는 클래스 생성 과정을 검증하거나 변경할 수 있다.
class RegisteredType(type):
registry = {}
def __new__(mcls, name, bases, namespace):
cls = super().__new__(mcls, name, bases, namespace)
mcls.registry[name] = cls
return cls
class Plugin(metaclass=RegisteredType):
pass
메타클래스는 프레임워크의 모델 선언, 자동 등록, 인터페이스 검사와 클래스 수준 코드 생성 등에 활용될 수 있다. 다만 클래스 데코레이터나 __init_subclass__()로 해결할 수 있는 작업보다 구조가 복잡하므로 클래스 생성 전체를 제어해야 할 때 주로 사용한다.
__init_subclass__()는 하위 클래스가 생성될 때 상위 클래스에 알림을 제공한다.
class Plugin:
plugins = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.plugins.append(cls)
이 방식은 단순한 하위 클래스 등록에 별도의 메타클래스를 만들지 않아도 되는 선택지를 제공한다.
메서드 결정 순서
클래스가 여러 상위 클래스를 상속하면 Python은 메서드 결정 순서, 즉 MRO에 따라 속성과 메서드를 검색한다.
class Root:
pass
class Left(Root):
pass
class Right(Root):
pass
class Child(Left, Right):
pass
클래스의 MRO는 __mro__ 또는 mro()를 통해 확인할 수 있다.
print(Child.__mro__)
Python은 C3 선형화 알고리즘을 사용해 지역적인 상속 순서, 기존 상위 클래스 순서와 일관되는 MRO를 구성한다.
super()는 단순히 직접 부모 클래스를 의미하지 않고 현재 클래스 다음에 위치하는 MRO 항목으로 위임한다.
class Base:
def initialize(self):
print("Base")
class Feature(Base):
def initialize(self):
print("Feature")
super().initialize()
협력적 다중 상속에서는 각 클래스가 super()를 사용하고 호환되는 메서드 시그니처를 유지해야 MRO 전체가 이어질 수 있다.
객체 표현과 포매팅
객체는 여러 종류의 문자열 표현을 제공할 수 있다.
__repr__()은 객체를 명확하게 식별하고 디버깅에 사용할 표현을 반환한다.
class User:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"User(name={self.name!r})"
__str__()은 사용자에게 표시할 읽기 쉬운 표현을 반환한다.
def __str__(self):
return self.name
__format__()은 format()과 f-string의 형식 지정자 처리를 담당한다.
class FileSize:
def __init__(self, bytes_count):
self.bytes_count = bytes_count
def __format__(self, specification):
if specification == "kb":
return f"{self.bytes_count / 1024:.2f} KiB"
return str(self.bytes_count)
size = FileSize(4096)
print(f"{size:kb}")
바이트 표현이 필요한 객체는 __bytes__()를 구현할 수 있다.
이 표현 메서드는 객체의 내부 상태를 이해하고 로그, 오류 메시지와 사용자 인터페이스에 표시하는 방식을 정의한다.
복사와 직렬화
Python의 대입은 객체를 복제하지 않으므로 독립적인 객체 사본이 필요한 경우 복사 프로토콜이나 객체별 생성 방식을 사용한다.
copy.copy()는 얕은 복사를 수행하며 __copy__()로 사용자 정의할 수 있다. copy.deepcopy()는 객체 그래프를 재귀적으로 복사하며 __deepcopy__()로 동작을 제어할 수 있다.
import copy
original = {
"values": [1, 2, 3],
}
shallow = copy.copy(original)
deep = copy.deepcopy(original)
얕은 복사는 내부 목록을 공유하지만 깊은 복사는 일반적으로 내부 목록도 새로 만든다.
직렬화는 객체 상태를 저장하거나 전송 가능한 표현으로 변환하는 과정이다. pickle은 Python 객체 구조를 저장할 수 있으며 클래스는 __reduce__(), __getstate__()와 __setstate__() 등을 통해 직렬화 동작을 제어할 수 있다.
pickle 데이터는 객체 생성과 코드 실행을 유발할 수 있으므로 신뢰할 수 없는 입력을 역직렬화하는 용도로 사용하지 않는다. JSON처럼 제한된 데이터 모델을 사용하는 형식은 외부 시스템과 데이터를 교환하는 데 더 적합할 수 있다.
약한 참조
일반적인 참조는 객체가 살아 있도록 유지하는 강한 참조이다. 약한 참조는 객체를 가리키지만 객체의 수명을 연장하지 않는다.
import weakref
class Resource:
pass
resource = Resource()
reference = weakref.ref(resource)
print(reference() is resource)
원래 객체에 대한 강한 참조가 모두 사라지고 객체가 정리되면 약한 참조는 None을 반환한다.
del resource
print(reference())
약한 참조는 캐시, 객체 등록소, 관찰자 목록처럼 객체가 다른 곳에서 사용되는 동안만 부가 정보를 유지해야 하는 구조에 활용된다.
weakref.WeakKeyDictionary와 WeakValueDictionary는 키 또는 값에 대한 약한 참조를 사용하는 매핑을 제공한다. 모든 객체가 약한 참조를 지원하는 것은 아니며, 사용자 정의 슬롯 클래스에서는 __weakref__ 지원을 명시해야 할 수 있다.
객체 수명과 도달 가능성
Python 프로그램에서 객체를 직접 파괴하는 문장은 없다. 객체가 실행 중인 프로그램에서 더 이상 도달할 수 없게 되면 구현체가 이를 회수할 수 있다.[66]
items = [1, 2, 3]
items = None
첫 번째 목록을 가리키는 다른 참조가 없다면 해당 객체는 더 이상 도달할 수 없는 상태가 된다.
del 문은 객체 자체를 직접 삭제하는 것이 아니라 이름, 속성이나 컨테이너 항목의 바인딩을 제거한다.
value = object()
del value
다른 이름이 같은 객체를 참조하고 있다면 객체는 계속 살아 있다.
first = object()
second = first
del first
second가 여전히 객체를 참조하므로 객체는 도달 가능한 상태이다.
Python 언어는 도달할 수 없는 객체가 즉시 회수되는 시점을 보장하지 않는다. 구현체는 가비지 컬렉션을 지연하거나 서로 다른 방식으로 수행할 수 있다.
파일, 네트워크 연결, 창이나 잠금처럼 외부 자원을 보유한 객체는 가비지 컬렉션 시점에 의존하지 않고 명시적으로 닫아야 한다.
with open("data.txt", encoding="utf-8") as file:
contents = file.read()
with 문은 정상 종료와 예외 발생 모두에서 정리 동작을 구조적으로 실행한다.
CPython의 참조 계수
기준 구현체인 CPython은 객체 수명 관리의 주요 수단으로 참조 계수를 사용한다. 객체를 가리키는 강한 참조가 추가되면 참조 계수가 증가하고, 참조가 제거되면 감소한다. 마지막 강한 참조가 사라져 참조 계수가 0이 되면 일반적으로 객체의 해제 절차가 시작된다.[67]
first = []
second = first
del first
del second
이 코드에서 두 이름은 같은 목록에 대한 참조를 가진다. 각 이름의 제거가 객체에 대한 강한 참조를 줄인다.
참조 계수는 Python 언어 사양이 아니라 CPython의 구현 방식이다. PyPy와 다른 구현체는 객체 수명을 다른 가비지 컬렉션 전략으로 관리할 수 있다. 따라서 파일 닫기나 잠금 해제와 같은 동작을 CPython의 즉각적인 참조 계수 감소에 의존해서는 안 된다.
CPython의 C API에서는 새 참조, 빌린 참조와 참조 소유권 이전을 구분한다. 확장 모듈은 이러한 규칙을 지키며 참조 계수를 관리해야 한다. Python 코드에서는 이 작업이 인터프리터 내부에서 자동으로 처리된다.
순환 참조와 가비지 컬렉션
참조 계수만으로는 서로를 참조하는 객체 집합을 정리하지 못할 수 있다.
first = []
second = []
first.append(second)
second.append(first)
외부에서 first와 second를 더 이상 참조하지 않더라도 두 목록은 서로를 참조하므로 각 객체의 참조 계수가 0이 되지 않는다.
CPython은 이러한 순환 구조를 감지하기 위해 참조 계수를 보완하는 순환 가비지 컬렉터를 제공한다. gc 모듈은 수집 실행, 활성화 상태와 디버깅 정보를 제어하거나 조회하는 인터페이스를 제공한다.[68]
import gc
collected = gc.collect()
print(collected)
일반적인 프로그램에서는 직접 gc.collect()를 호출할 필요가 없다. 수집기는 자동으로 동작하며 명시적 호출은 디버깅, 측정이나 특수한 메모리 관리 상황에서 사용한다.
참조 순환이 언제 수집되는지는 즉시 보장되지 않는다. 디버거, 트레이싱 기능, 예외의 트레이스백과 전역 캐시가 객체를 계속 참조하여 예상보다 오래 유지할 수도 있다.
종료 처리와 __del__()
클래스는 __del__() 메서드를 정의하여 객체가 최종화될 때 실행할 동작을 제공할 수 있다.
class TrackedObject:
def __del__(self):
print("finalized")
그러나 __del__()이 정확히 언제 호출되는지는 보장되지 않으며, 인터프리터 종료 과정이나 복잡한 참조 관계에서는 예상과 다른 환경에서 실행될 수 있다.
__del__() 안에서 객체를 다시 전역 변수나 외부 컨테이너에 연결하면 객체가 되살아날 수도 있다. 종료 순서가 정해지지 않은 다른 객체나 모듈 전역 상태에 의존하는 코드 역시 안정적이지 않을 수 있다.
따라서 외부 자원 반환과 중요한 저장 작업은 __del__()에만 의존하지 않고 컨텍스트 관리자, 명시적인 close() 메서드 또는 try·finally로 처리한다.
resource = acquire_resource()
try:
use_resource(resource)
finally:
resource.close()
필요한 정리 콜백을 객체 수명에 연결하면서 __del__()의 제약을 피하려면 weakref.finalize를 사용할 수 있다.
객체 부활
객체의 종료 처리 과정에서 해당 객체에 대한 새 참조를 외부에 저장하면 객체가 다시 도달 가능한 상태가 될 수 있다. 이를 객체 부활이라고 한다.
saved = None
class Resurrectable:
def __del__(self):
global saved
saved = self
객체 부활은 최종화와 가비지 컬렉션의 흐름을 복잡하게 만들며, 객체가 일부 종료 처리를 수행한 뒤 다시 사용되는 상태를 만들 수 있다.
일반적인 애플리케이션 설계에서는 객체 부활에 의존하지 않는다. 객체 수명과 자원 반환이 명시적으로 관리되는 구조가 더 예측 가능하다.
코드·프레임·트레이스백 객체
Python은 실행 중인 코드와 실행 상태도 객체로 표현한다.
함수의 __code__ 속성은 컴파일된 함수 본문을 나타내는 코드 객체를 참조한다.
def add(left, right):
return left + right
code = add.__code__
print(code.co_name)
print(code.co_argcount)
코드 객체는 바이트코드, 상수, 지역 이름과 소스 위치 등 실행에 필요한 정보를 포함하며 불변 객체로 취급된다.
함수가 실행될 때는 프레임 객체가 생성된다. 프레임은 현재 실행 중인 코드 객체, 지역·전역 이름 공간과 실행 위치 등의 상태를 나타낸다.
예외가 전파되면 트레이스백 객체가 생성되어 예외가 지나온 프레임과 코드 위치를 연결한다.
try:
1 / 0
except ZeroDivisionError as error:
traceback = error.__traceback__
트레이스백은 프레임을 참조하고 프레임은 지역 객체를 참조할 수 있으므로, 예외 객체나 트레이스백을 장기간 저장하면 관련 객체의 수명이 길어질 수 있다.
이러한 내부 객체는 디버거, 프로파일러, 테스트 도구와 검사 도구에서 활용된다.
리플렉션과 검사
Python 객체는 런타임에 자신의 타입, 속성 및 일부 정의 정보를 조사할 수 있다. 이를 리플렉션 또는 인트로스펙션이라고 한다.
print(type(value))
print(dir(value))
print(hasattr(value, "name"))
print(getattr(value, "name", None))
dir()은 객체에서 사용할 수 있는 속성 이름의 목록을 구성한다. getattr()과 setattr()은 문자열 이름을 이용해 속성을 조회하거나 설정한다.
field_name = "enabled"
setattr(device, field_name, True)
value = getattr(device, field_name)
callable()은 객체가 호출 가능한지 검사하고, isinstance()와 issubclass()는 타입과 상속 관계를 확인한다.
inspect 표준 라이브러리는 함수 시그니처, 소스 위치, 클래스와 실행 프레임을 검사하는 고수준 기능을 제공한다.
리플렉션은 프레임워크, 직렬화, 의존성 주입, 테스트와 개발 도구에 유용하지만, 비공개 속성과 구현 세부에 과도하게 의존하면 버전 변경과 구현체 차이에 취약해질 수 있다.
데이터 모델의 확장성
Python 데이터 모델의 핵심은 내장 문법과 사용자 정의 객체가 동일한 프로토콜을 공유한다는 점이다.
사용자 정의 객체는 다음과 같은 역할에 참여할 수 있다.
__iter__()를 구현해 반복 가능한 객체가 된다.__getitem__()을 구현해 구독과 슬라이싱을 지원한다.__enter__()와__exit__()을 구현해with문에 사용된다.__call__()을 구현해 함수처럼 호출된다.- 산술 특수 메서드를 구현해 연산자에 참여한다.
__await__()을 구현해 기다릴 수 있는 객체가 된다.- 디스크립터 프로토콜을 구현해 속성 접근을 제어한다.
__class_getitem__()을 구현해 제네릭 표기와 클래스 구독을 지원한다.
이 구조는 새로운 문법을 계속 추가하지 않고도 기존 문법을 새로운 타입에 적용할 수 있게 한다. 배열, 텐서, 데이터베이스 질의 객체와 경로 객체 등은 Python의 공통 연산과 프로토콜을 각자의 의미에 맞게 구현할 수 있다.
다만 특수 메서드는 단순한 이름 규칙이 아니라 언어가 정의한 계약이다. 반환값, 예외와 다른 피연산자 처리 방식이 해당 프로토콜의 기대와 맞아야 내장 객체 및 다른 라이브러리와 일관되게 동작한다.
Python의 객체 모델은 동적 타입과 메타프로그래밍을 가능하게 하는 기반인 동시에, 함수·클래스·모듈·실행 상태를 같은 객체 개념 아래 통합한다. 이로 인해 작은 사용자 정의 클래스부터 프레임워크와 언어 도구에 이르기까지 동일한 데이터 모델을 단계적으로 활용할 수 있다.
타입 체계
Python은 객체가 런타임 타입을 가지며 이름은 객체에 동적으로 바인딩되는 동적 타입 언어이다. 변수나 함수 매개변수의 타입을 미리 선언하지 않아도 프로그램을 실행할 수 있으며, 연산이 수행되는 시점에 해당 객체가 필요한 동작을 지원하는지 검사한다.
value = 10
value = "Python"
value = [1, 2, 3]
위 코드에서 value라는 이름은 실행 과정에서 정수, 문자열과 목록 객체에 차례로 바인딩된다. 기존 객체의 타입이 바뀌는 것이 아니라 이름이 서로 다른 타입의 객체를 가리키도록 재바인딩되는 것이다.
Python은 이 동적 실행 모델 위에 선택적인 정적 타입 체계를 제공한다. 함수, 변수, 클래스와 타입 별칭에 타입 정보를 기록할 수 있으며, 타입 검사기와 통합 개발 환경은 프로그램을 실행하기 전에 이러한 정보를 분석한다. 타입 표기는 기본적으로 Python 인터프리터가 값의 대입이나 함수 호출을 강제로 제한하는 문법이 아니다.[69]
def greet(name: str) -> str:
return f"Hello, {name}"
name: str은 name에 문자열이 전달될 것으로 예상한다는 정적 타입 정보이고, -> str은 함수가 문자열을 반환할 것으로 예상한다는 뜻이다. 일반적인 Python 런타임은 이 표기를 보고 인자를 자동으로 검사하지 않는다.
Python의 타입 체계는 다음 두 층을 함께 가진다.
- 런타임에는 모든 객체가 실제 타입을 가진다.
- 개발 과정에서는 선택적으로 정적 타입 정보를 추가할 수 있다.
이 구조는 동적 코드와 정적으로 분석되는 코드를 같은 언어와 프로젝트 안에서 함께 사용할 수 있게 한다. Python 타입 시스템 명세에서는 이를 정적 타입과 동적 타입을 세밀한 단위로 혼합할 수 있는 점진적 타입 체계로 설명한다.[70]
동적 타입
Python에서 타입은 변수 이름이 아니라 객체에 속한다. 객체의 타입은 해당 객체가 지원하는 연산, 값의 해석 방식과 데이터 모델 동작을 결정한다.
number = 10
text = "10"
print(type(number))
print(type(text))
number와 text는 이름 자체에 타입이 선언된 것이 아니라 각각 int와 str 객체를 참조한다.
대입문은 일반적으로 새로운 타입을 선언하지 않고 이름과 객체 사이의 바인딩을 만든다.
result = calculate()
result가 가리키는 객체의 타입은 calculate()가 실제로 반환한 객체에 따라 결정된다. 같은 함수도 실행 조건에 따라 서로 다른 타입의 객체를 반환할 수 있다.
def parse(value):
if value.isdigit():
return int(value)
return value
이 함수는 입력에 따라 int 또는 str 객체를 반환한다. 정적 타입 정보를 추가한다면 이를 합집합 타입으로 표현할 수 있다.
def parse(value: str) -> int | str:
if value.isdigit():
return int(value)
return value
동적 타입은 프로그램의 구조가 고정되지 않는다는 뜻은 아니다. 모든 객체는 런타임에 명확한 타입을 가지며, 해당 타입이 제공하지 않는 연산을 수행하면 예외가 발생한다.
value = "Python"
result = value + 10
문자열과 정수 사이의 덧셈이 정의되어 있지 않으므로 이 코드는 TypeError를 발생시킨다.
Python은 암시적으로 모든 값을 임의의 다른 타입으로 변환하여 연산하지 않는다. 필요한 변환은 일반적으로 명시적으로 수행한다.
value = "10"
result = int(value) + 5
강한 타입
Python은 일반적으로 강한 타입 언어로 분류된다. 타입이 서로 다른 객체 사이의 연산이 의미상 정의되지 않았다면, 인터프리터는 임의의 자동 변환으로 연산을 계속하기보다 예외를 발생시킨다.
print("5" + 5)
문자열 "5"를 자동으로 정수로 변환하거나 정수 5를 자동으로 문자열로 변환하지 않으므로 TypeError가 발생한다.
반면 같은 연산자가 여러 타입에서 서로 다른 의미를 가질 수는 있다.
print(10 + 20)
print("Py" + "thon")
print([1, 2] + [3, 4])
+는 정수에서는 산술 덧셈, 문자열과 목록에서는 연결 연산으로 동작한다. 이는 피연산자의 타입이 정의한 연산자 프로토콜에 따른 다형성이지, 타입 구분이 사라지는 것은 아니다.
수치형 사이에는 언어가 정의한 변환 규칙이 존재한다.
result = 10 + 2.5
이 경우 정수와 부동소수점 수는 공통 수치 연산 규칙에 따라 계산된다. 이러한 변환은 관련 없는 타입을 임의로 섞는 것이 아니라 수치형 계층에 정의된 동작이다.
강한 타입이라는 표현에는 하나의 완전한 형식적 정의가 존재하지 않으므로, Python 문서에서는 일반적으로 동적 타입과 객체별 연산 규칙을 직접 설명한다. Python에서는 객체의 실제 타입과 해당 타입이 제공하는 프로토콜이 실행 가능성을 결정한다.
런타임 타입 검사
type()은 객체의 직접적인 타입 객체를 반환한다.
value = 10
print(type(value))
특정 타입이나 그 하위 타입의 인스턴스인지 검사할 때는 isinstance()를 사용할 수 있다.
if isinstance(value, int):
print(value + 1)
여러 타입 가운데 하나인지 검사할 수도 있다.
if isinstance(value, int | float):
print("numeric")
상속 관계는 issubclass()로 검사한다.
print(issubclass(bool, int))
type(value) is SomeType은 정확히 해당 타입인지만 검사하지만, isinstance(value, SomeType)은 하위 클래스 관계와 일부 추상 기반 클래스의 등록 관계도 고려할 수 있다.
class Base:
pass
class Derived(Base):
pass
instance = Derived()
print(type(instance) is Base)
print(isinstance(instance, Base))
다형성을 허용하는 일반적인 코드에서는 직접적인 타입 일치보다 isinstance()나 객체가 제공하는 동작을 사용하는 방식이 적합할 수 있다.
런타임 타입 검사는 외부 입력 검증, 역직렬화, API 경계와 여러 표현을 받는 함수 등에 사용할 수 있다. 그러나 모든 함수 호출에서 구체 타입을 먼저 검사하면 Python의 덕 타이핑과 확장성을 제한할 수 있다.
덕 타이핑
Python에서는 객체의 명목상 클래스보다 실제로 필요한 동작을 제공하는지가 중요하게 취급된다. 객체가 특정 메서드나 프로토콜을 지원한다면 서로 상속 관계가 없어도 같은 코드에서 사용할 수 있다. 이를 덕 타이핑이라고 한다.
def save(destination, data):
destination.write(data)
save()는 destination의 구체적인 클래스를 검사하지 않는다. write() 메서드를 제공하고 전달된 데이터를 처리할 수 있는 객체라면 파일, 메모리 버퍼, 네트워크 스트림이나 사용자 정의 객체를 사용할 수 있다.
from io import StringIO
buffer = StringIO()
save(buffer, "Python")
덕 타이핑은 Python의 프로토콜 중심 데이터 모델과 연결된다. 반복 가능한 객체는 반드시 하나의 공통 구현 클래스를 상속할 필요 없이 __iter__() 또는 시퀀스 방식의 반복 동작을 제공하면 for 문에서 사용할 수 있다.
def first_item(values):
for value in values:
return value
raise ValueError("empty iterable")
이 함수는 목록뿐 아니라 튜플, 집합, 문자열, 제너레이터와 사용자 정의 반복 가능 객체를 받을 수 있다.
덕 타이핑은 객체의 타입을 전혀 고려하지 않는다는 뜻이 아니다. 필요한 연산과 그 의미가 인터페이스 역할을 하며, 객체가 해당 계약을 만족하지 않으면 실행 중 예외가 발생한다.
명목적 서브타이핑
클래스 상속을 통해 타입 관계를 정의하는 방식을 명목적 서브타이핑이라고 한다. 하위 클래스는 상위 클래스와 명시적인 상속 관계를 가진다.
class Animal:
def speak(self) -> str:
raise NotImplementedError
class Dog(Animal):
def speak(self) -> str:
return "woof"
Dog는 Animal을 명시적으로 상속하므로 Dog의 인스턴스는 Animal이 요구되는 위치에서 사용할 수 있다.
def announce(animal: Animal) -> None:
print(animal.speak())
announce(Dog())
명목적 타입 관계는 클래스 선언에 기록되므로 타입 검사기와 런타임의 isinstance()가 공통으로 이해할 수 있다.
추상 기반 클래스는 명목적 인터페이스를 정의하는 데 사용할 수 있다.
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def load(self, key: str) -> bytes:
raise NotImplementedError
하위 클래스는 추상 메서드를 구현해야 정상적으로 인스턴스를 생성할 수 있다.
일부 추상 기반 클래스는 직접 상속하지 않은 클래스를 가상 하위 클래스로 등록하거나 특정 특수 메서드의 존재에 따라 관계를 판단할 수 있다. 그러나 이러한 런타임 관계와 정적 타입 검사기의 판단이 항상 완전히 같지는 않다.
구조적 서브타이핑
Python의 정적 타입 체계는 Protocol을 통해 구조적 서브타이핑을 표현할 수 있다. 특정 클래스를 명시적으로 상속하지 않아도 프로토콜이 요구하는 멤버를 제공하면 해당 타입과 호환되는 것으로 판단한다.[71]
from typing import Protocol
class Writable(Protocol):
def write(self, data: str) -> int:
...
Writable을 직접 상속하지 않은 클래스도 호환되는 write() 메서드를 제공하면 정적 타입 검사에서 사용할 수 있다.
class MemoryWriter:
def __init__(self) -> None:
self.contents = ""
def write(self, data: str) -> int:
self.contents += data
return len(data)
def emit(writer: Writable, text: str) -> None:
writer.write(text)
MemoryWriter는 Writable을 상속하지 않지만 필요한 메서드 시그니처를 제공하므로 구조적으로 호환된다.
프로토콜은 동적 덕 타이핑의 관습을 정적 타입으로 설명할 수 있게 한다. 라이브러리는 구현체가 특정 상위 클래스를 상속하도록 강제하지 않으면서 필요한 동작만 명시할 수 있다.
프로토콜에는 속성과 메서드, 프로퍼티와 클래스 메서드 등을 선언할 수 있다.
class Named(Protocol):
name: str
def rename(self, name: str) -> None:
...
@runtime_checkable을 적용하면 일부 프로토콜을 isinstance() 검사에 사용할 수 있다.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None:
...
다만 런타임 프로토콜 검사는 일반적으로 멤버의 존재 여부를 중심으로 하며, 정적 타입 검사처럼 세부 시그니처까지 완전히 검증하지 않는다.
점진적 타입
Python의 정적 타입 체계는 타입 표기를 프로그램 전체에 한 번에 적용하도록 요구하지 않는다. 필요한 모듈, 함수나 변수부터 선택적으로 추가할 수 있다.
def typed_add(left: int, right: int) -> int:
return left + right
def dynamic_process(value):
return value.transform()
첫 번째 함수는 매개변수와 반환 타입을 명시하지만 두 번째 함수는 정적 타입을 지정하지 않았다. 두 형태는 같은 모듈에서 함께 사용할 수 있다.
타입 표기가 없는 부분은 타입 검사기의 설정과 문맥에 따라 동적인 영역으로 취급될 수 있다. 이를 통해 기존의 대규모 Python 코드베이스에도 타입 정보를 단계적으로 도입할 수 있다.
점진적 타입은 정적으로 검사되는 코드와 동적으로 처리되는 코드 사이에 경계가 생긴다는 뜻이기도 하다. 동적 영역에서 잘못된 타입의 값이 들어오면 정적 분석만으로 모든 오류를 막을 수 없다.
def dynamic_source():
return "not an integer"
def double(value: int) -> int:
return value * 2
result = double(dynamic_source())
동적 함수의 반환 타입을 검사기가 Any로 취급하면 이 호출을 허용할 수 있다. 실제 결과는 문자열 반복이 되어 정수 반환이라는 의도와 달라질 수 있다.
따라서 점진적 타입을 사용할 때는 외부 입력, 동적 라이브러리와 정적 영역이 만나는 경계의 타입을 명확히 표현하는 것이 중요하다.
타입 힌트
타입 힌트는 소스 코드에 예상 타입을 기록하는 표준 문법과 규칙이다. PEP 484는 함수 주석을 정적 분석과 리팩터링, 코드 완성과 같은 도구에서 사용할 수 있도록 표준 타입 표기 체계를 제안했다.[72]
함수 매개변수에는 이름 뒤의 콜론으로 타입을 표기하고, 반환 타입에는 ->를 사용한다.
def calculate_total(
price: float,
quantity: int,
) -> float:
return price * quantity
변수 주석은 이름 뒤에 타입을 작성한다.
username: str = "Luna"
retry_count: int = 3
enabled: bool
변수 주석 문법은 PEP 526에서 표준화되었다.[73]
주석과 초기값은 별개이다.
result: int
이 코드는 result가 정수로 사용될 것이라는 주석을 만들지만 값을 바인딩하지 않는다. 값을 대입하기 전에 result를 읽으면 이름 오류가 발생할 수 있다.
클래스 속성과 인스턴스 속성도 표기할 수 있다.
from typing import ClassVar
class User:
species: ClassVar[str] = "human"
name: str
age: int
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
ClassVar는 클래스 수준에서 공유되는 속성임을 타입 검사기에 알린다.
타입 표기의 런타임 의미
Python 런타임은 일반적으로 타입 주석을 자동으로 강제하지 않는다.
def square(value: int) -> int:
return value * value
square("Python")
이 호출은 타입 주석과 맞지 않지만 인터프리터가 주석만을 근거로 호출을 차단하지 않는다. 실제 연산이 가능한지에 따라 실행 결과가 결정된다.
함수 주석은 일반적으로 함수 객체의 __annotations__와 관련된 인터페이스를 통해 조회할 수 있다. 모듈과 클래스의 변수 주석도 주석 정보로 저장될 수 있다.
def convert(value: str) -> int:
return int(value)
print(convert.__annotations__)
타입 주석은 런타임 프레임워크에서도 활용될 수 있다. 데이터 검증, 직렬화, 명령행 인터페이스 생성, 의존성 주입과 웹 프레임워크가 주석을 읽어 자체 동작을 구현할 수 있다. 이 경우의 검증과 변환은 Python 언어가 자동으로 제공하는 것이 아니라 해당 라이브러리가 정의한 동작이다.
Python 3.14에는 주석을 다양한 형식으로 안전하게 조사하기 위한 annotationlib 모듈이 추가되었다.[74]
주석의 평가 방식은 Python 버전과 설정에 따라 달라질 수 있으므로, 런타임에서 주석을 직접 읽는 라이브러리는 공식 검사 인터페이스를 사용하는 것이 적합하다.
내장 제네릭 표기
컬렉션이 포함하는 원소의 타입은 대괄호를 이용한 제네릭 타입 표기로 나타낼 수 있다.
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {
"Alice": 90,
"Bob": 85,
}
coordinates: tuple[int, int] = (10, 20)
list[str]은 문자열 원소로 이루어진 목록, dict[str, int]는 문자열 키와 정수 값으로 이루어진 사전을 나타낸다.
Python 3.9부터 list, dict, tuple, set과 여러 표준 컬렉션 클래스 자체를 제네릭 타입 표기에 사용할 수 있다. 이 구조는 PEP 585에서 도입되었다.[75]
이전 버전에서는 typing.List, typing.Dict와 같은 이름이 주로 사용되었다.
from typing import Dict, List
names: List[str]
scores: Dict[str, int]
현대 Python 코드에서는 지원 버전이 허용한다면 내장 제네릭 문법을 주로 사용한다.
길이와 원소 위치가 정해진 튜플은 각 위치의 타입을 나열한다.
record: tuple[str, int, bool]
같은 타입의 원소를 임의 개수 포함하는 튜플은 다음과 같이 표기한다.
values: tuple[int, ...]
빈 튜플의 타입은 tuple[()]으로 나타낼 수 있다.[76]
합집합 타입과 선택적 값
값이 여러 타입 가운데 하나일 수 있으면 합집합 타입을 사용한다.
def parse(value: str) -> int | float:
if "." in value:
return float(value)
return int(value)
int | float은 값이 정수 또는 부동소수점 수일 수 있음을 나타낸다. |를 이용한 합집합 표기는 Python 3.10에서 도입되었다.[77]
이전 표기는 typing.Union을 사용한다.
from typing import Union
result: Union[int, float]
값이 특정 타입 또는 None일 수 있다면 다음과 같이 표현한다.
def find_user(user_id: int) -> User | None:
...
이전에는 Optional[User]도 사용되었다.
from typing import Optional
def find_user(user_id: int) -> Optional[User]:
...
Optional[T]는 매개변수가 생략 가능하다는 뜻이 아니라 T | None과 같은 타입을 뜻한다. 기본 인자가 존재하는지 여부와 값으로 None을 허용하는지는 서로 다른 개념이다.
def connect(timeout: int = 30) -> None:
...
timeout은 호출에서 생략할 수 있지만 함수 내부에서 예상되는 타입은 int이다.
def connect(timeout: int | None = None) -> None:
...
이 경우에는 생략할 수도 있고 명시적으로 None을 전달할 수도 있다.
Any
Any는 정적 타입이 알려지지 않았거나 동적으로 취급되는 값을 나타낸다. 모든 타입의 값은 Any에 대입할 수 있고, Any 값도 정적 검사에서 다른 타입이 필요한 위치에 전달할 수 있다.[78]
from typing import Any
value: Any = load_dynamic_value()
value.run()
value + 10
result: str = value
타입 검사기는 일반적으로 Any에 대한 임의의 속성 접근과 연산을 허용한다. 이는 동적 코드와 정적 코드의 연결에 유용하지만, 해당 영역에서 타입 오류를 정적으로 찾는 능력은 줄어든다.
주석이 없는 함수의 매개변수나 타입 인자를 생략한 일부 제네릭은 설정에 따라 Any로 처리될 수 있다.
def process(value):
return value.execute()
Any와 object는 다르다.
from typing import Any
dynamic_value: Any
unknown_value: object
Any에는 임의의 연산을 적용할 수 있지만, object는 모든 Python 객체가 될 수 있다는 사실만 나타낸다. object 값에서 특정 메서드를 사용하려면 타입 검사나 다른 방식으로 타입을 좁혀야 한다.
def stringify(value: object) -> str:
return str(value)
object는 타입 안전성을 유지하면서 모든 객체를 받는 인터페이스에 사용할 수 있다.
Never와 NoReturn
정상적으로 값을 반환하지 않는 함수는 Never 또는 과거의 NoReturn으로 표현할 수 있다.
from typing import Never
def fail(message: str) -> Never:
raise RuntimeError(message)
Never는 가능한 값이 하나도 없는 바닥 타입을 나타낸다. 함수가 항상 예외를 발생시키거나 프로그램을 종료하는 경우 반환 타입으로 사용할 수 있다.
도달할 수 없는 분기를 검사할 때도 사용할 수 있다.
from typing import assert_never
def handle_status(status: Status) -> None:
match status:
case Status.READY:
start()
case Status.STOPPED:
stop()
case _ as unreachable:
assert_never(unreachable)
열거형이나 합집합에 새로운 경우가 추가되면 정적 타입 검사기가 처리되지 않은 분기를 찾는 데 활용할 수 있다.
리터럴 타입
Literal은 특정 타입 전체가 아니라 하나 이상의 구체적인 값을 타입으로 표현한다.[79]
from typing import Literal
Mode = Literal["read", "write", "append"]
def open_resource(mode: Mode) -> None:
...
호출자는 지정된 문자열 가운데 하나를 전달해야 한다.
open_resource("read")
리터럴 타입은 인자 값에 따라 반환 타입이 달라지는 API, 설정 값과 상태 표현에 유용하다.
from typing import Literal, overload
@overload
def decode(data: bytes, as_text: Literal[True]) -> str:
...
@overload
def decode(data: bytes, as_text: Literal[False]) -> bytes:
...
리터럴 값은 해당 일반 타입의 하위 타입으로 취급된다. 예를 들어 Literal[3]은 int의 더 구체적인 형태이다.
타입 별칭
복잡한 타입 표현에 이름을 부여하면 타입 별칭을 만들 수 있다.
type UserId = int
type Headers = dict[str, str]
type Result = str | bytes | None
Python 3.12부터 type 문을 이용해 타입 별칭을 명시적으로 선언할 수 있다. 이 문법은 PEP 695에서 도입되었다.[80]
이전 버전에서는 일반 대입과 TypeAlias를 사용했다.
from typing import TypeAlias
Headers: TypeAlias = dict[str, str]
타입 별칭은 새로운 런타임 클래스를 생성하지 않는다. UserId가 int의 별칭이라면 정적 타입에서도 기본적으로 같은 타입으로 취급된다.
서로 다른 의미의 값을 정적으로 구별하고 싶다면 NewType을 사용할 수 있다.
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def load_user(user_id: UserId) -> User:
...
UserId와 OrderId는 정적 검사에서 구분되지만 런타임에는 기본 값을 가볍게 감싸는 형태로 동작한다. 실제 런타임 서브클래스와 동일한 개념은 아니다.
제네릭
제네릭은 하나의 클래스나 함수가 여러 타입에 대해 동작하면서 입력과 출력 사이의 타입 관계를 보존하도록 표현한다.
def first[T](values: list[T]) -> T:
return values[0]
T는 호출 시 실제 원소 타입으로 대체되는 타입 매개변수이다.
name = first(["Alice", "Bob"])
number = first([10, 20])
타입 검사기는 첫 번째 호출의 반환 타입을 str, 두 번째 호출의 반환 타입을 int로 추론할 수 있다.
제네릭 클래스도 정의할 수 있다.
class Box[T]:
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
text_box = Box("Python")
number_box = Box(10)
Python 3.12 이전의 일반적인 문법은 TypeVar와 Generic을 사용한다.
from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
새 타입 매개변수 문법과 기존 문법은 지원 대상 Python 버전과 라이브러리 호환성에 따라 선택할 수 있다.
제한된 타입 변수와 상한
타입 변수에 허용할 타입을 제한할 수 있다.
def add[T: (int, float)](left: T, right: T) -> T:
return left + right
이 형태의 타입 변수는 지정된 제약 타입 가운데 하나로 결정된다.
상한을 사용하면 특정 타입의 하위 타입만 허용할 수 있다.
from collections.abc import Sized
def choose_longer[T: Sized](left: T, right: T) -> T:
if len(left) >= len(right):
return left
return right
T는 Sized 프로토콜을 만족하는 구체적인 타입으로 결정되며 반환 타입도 그 구체 타입을 유지한다.
제약과 상한은 서로 다르다. 제약은 나열된 후보 타입 가운데 하나로 타입 변수를 해석하고, 상한은 지정한 타입의 임의 하위 타입을 허용한다.
분산
제네릭 타입 사이의 서브타입 관계가 타입 인자의 서브타입 관계를 어떻게 따르는지를 분산이라고 한다.
읽기 전용 값을 제공하는 컨테이너는 공변으로 설계할 수 있다. Dog가 Animal의 하위 타입일 때 Producer[Dog]를 Producer[Animal]로 사용할 수 있는 관계이다.
class Producer[T]:
def __init__(self, value: T) -> None:
self._value = value
def get(self) -> T:
return self._value
값을 소비하는 인터페이스는 반공변 관계가 적합할 수 있다. Animal을 처리할 수 있는 소비자는 Dog만 필요한 위치에서도 사용할 수 있다.
값을 읽고 쓰는 가변 컨테이너는 일반적으로 무공변이다. list[Dog]를 list[Animal]로 허용하면 다른 동물 객체를 삽입하여 원래 목록의 타입 가정을 깨뜨릴 수 있다.
dogs: list[Dog]
animals: list[Animal] = dogs
animals.append(Cat())
따라서 정적 타입 체계에서는 list[Dog]를 list[Animal]의 하위 타입으로 취급하지 않는다.
Python의 현대 타입 매개변수 문법에서는 타입 검사기가 사용 위치를 바탕으로 분산을 추론할 수 있으며, 기존 TypeVar 문법에서는 공변·반공변 여부를 명시할 수 있다.
호출 가능 객체
함수와 호출 가능한 객체의 타입은 Callable로 표현할 수 있다.
from collections.abc import Callable
Operation = Callable[[int, int], int]
def apply(
operation: Operation,
left: int,
right: int,
) -> int:
return operation(left, right)
Callable[[int, int], int]은 정수 인자 두 개를 받고 정수를 반환하는 호출 가능 객체를 나타낸다.
def add(left: int, right: int) -> int:
return left + right
result = apply(add, 10, 20)
단순한 Callable 표기는 키워드 전용 매개변수, 오버로드와 복잡한 호출 규칙을 충분히 표현하지 못할 수 있다. 이런 경우 호출 메서드를 가진 프로토콜을 사용할 수 있다.
from typing import Protocol
class Formatter(Protocol):
def __call__(
self,
value: int,
*,
prefix: str = "",
) -> str:
...
매개변수 명세 변수
데코레이터처럼 다른 함수의 호출 시그니처를 보존해야 할 때는 ParamSpec을 사용할 수 있다.
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(
function: Callable[P, R],
) -> Callable[P, R]:
def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
print(function.__name__)
return function(*args, **kwargs)
return wrapper
P는 위치 인자와 키워드 인자를 포함한 함수의 전체 매개변수 구조를 나타낸다. 따라서 장식된 함수의 인자 타입과 반환 타입을 보존할 수 있다.
Python 3.12 이상의 타입 매개변수 문법에서는 다음과 같이 선언할 수 있다.
def logged[**P, R](
function: Callable[P, R],
) -> Callable[P, R]:
...
매개변수 명세 변수의 현대 문법과 동작은 Python 타입 명세의 제네릭 규칙에 정의되어 있다.[81]
가변 길이 제네릭
TypeVarTuple은 임의 개수의 타입 매개변수를 표현한다. 이는 배열이나 텐서의 차원처럼 길이가 고정되지 않은 타입 구조를 나타내는 데 사용할 수 있다.[82]
from typing import TypeVarTuple
Shape = TypeVarTuple("Shape")
class Array[*Shape]:
...
가변 길이 제네릭은 하나의 타입 변수로 원소 타입만 표현하는 것을 넘어, 여러 차원의 구조나 가변 길이 튜플의 타입 관계를 보존할 수 있게 한다.
일반적인 애플리케이션 코드보다 수치 계산, 프레임워크와 고급 라이브러리 타입 선언에서 주로 활용된다.
Self
현재 클래스 또는 실제 하위 클래스 타입을 반환하는 메서드는 Self로 표현할 수 있다.[83]
from typing import Self
class Builder:
def reset(self) -> Self:
return self
하위 클래스에서 이 메서드를 호출하면 반환 타입도 해당 하위 클래스 타입으로 유지된다.
class QueryBuilder(Builder):
def filter(self, condition: str) -> Self:
return self
Self는 체이닝 메서드, 대체 생성자와 자신과 같은 타입을 인자로 받는 메서드에 사용할 수 있다.
class Node:
@classmethod
def create(cls) -> Self:
return cls()
이전에는 바인딩된 TypeVar를 사용해 같은 관계를 표현했지만 Self가 더 직접적인 표기를 제공한다.
오버로드
하나의 함수가 인자 타입이나 리터럴 값에 따라 서로 다른 반환 타입을 가지면 @overload로 여러 정적 호출 시그니처를 설명할 수 있다.[84]
from typing import overload
@overload
def fetch(identifier: int) -> User:
...
@overload
def fetch(identifier: str) -> list[User]:
...
def fetch(identifier: int | str) -> User | list[User]:
if isinstance(identifier, int):
return load_user(identifier)
return search_users(identifier)
오버로드 선언은 타입 검사기를 위한 것이며, 실제 런타임 구현은 마지막에 하나만 정의한다. Python은 같은 이름의 함수 정의를 런타임 인자 타입에 따라 자동 선택하지 않는다.
def process(value: int) -> int:
return value + 1
def process(value: str) -> str:
return value.upper()
이 코드는 첫 번째 함수를 오버로드로 유지하지 않고 두 번째 정의가 같은 이름을 다시 바인딩한다.
TypedDict
TypedDict는 런타임에는 일반 사전이지만, 특정 문자열 키와 값 타입을 가진 구조로 정적 분석할 수 있게 한다.[85]
from typing import TypedDict
class UserData(TypedDict):
name: str
age: int
active: bool
user: UserData = {
"name": "Luna",
"age": 20,
"active": True,
}
UserData의 값은 일반 dict 객체이므로 속성 접근이 아니라 키 구독을 사용한다.
print(user["name"])
선택적인 키를 지정할 수도 있다.
from typing import NotRequired, TypedDict
class Configuration(TypedDict):
host: str
port: int
token: NotRequired[str]
TypedDict는 JSON 객체, 설정 데이터와 외부 API 응답처럼 사전 형태를 유지하면서 필드 구조를 정적으로 표현하는 데 적합하다.
런타임 데이터 검증이나 자동 변환을 제공하는 것은 아니므로 외부 입력에는 별도의 검증이 필요하다.
명명된 구조와 데이터 클래스
고정된 필드를 가진 데이터는 일반 클래스, NamedTuple, 데이터 클래스와 TypedDict 등 여러 형태로 표현할 수 있다.
NamedTuple은 위치 기반 튜플이면서 이름이 있는 필드를 제공한다.
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
point = Point(10.0, 20.0)
print(point.x)
print(point[0])
데이터 클래스는 일반 클래스 인스턴스에 필드 선언을 사용하고 초기화, 표현과 비교 메서드 등을 자동 생성한다.
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
타입 주석은 데이터 클래스가 필드를 판별하는 데도 사용되지만, 기본적으로 런타임 타입 검증을 수행하지는 않는다.
프레임워크가 데이터 클래스와 유사한 변환을 수행한다는 사실을 타입 검사기에 알리기 위해 dataclass_transform을 사용할 수 있다.[86]
Final과 불변 의도
Final은 이름이 한 번 바인딩된 뒤 다시 대입되지 않아야 한다는 의도를 정적 타입 검사기에 전달한다.
from typing import Final
MAX_CONNECTIONS: Final = 100
MAX_CONNECTIONS = 200
타입 검사기는 재대입을 오류로 보고할 수 있지만 Python 런타임이 자동으로 막는 것은 아니다.
클래스에서 오버라이드해서는 안 되는 메서드나 클래스를 나타낼 때는 @final을 사용할 수 있다.
from typing import final
class Base:
@final
def identifier(self) -> str:
return "base"
Final은 참조 대상 객체 자체의 깊은 불변성을 보장하지 않는다.
VALUES: Final = [1, 2, 3]
VALUES.append(4)
이 코드는 이름을 다른 목록에 재바인딩하지 않고 기존 목록을 변경한다. 객체 자체의 불변성이 필요하면 불변 타입이나 별도의 인터페이스를 사용해야 한다.
클래스 속성과 인스턴스 속성
타입 검사기는 클래스 속성과 인스턴스 속성을 구분할 수 있다.
from typing import ClassVar
class Connection:
protocol: ClassVar[str] = "TCP"
address: str
def __init__(self, address: str) -> None:
self.address = address
protocol은 클래스 전체에 속하는 값이고 address는 각 인스턴스에 속하는 값이다.
클래스 본문에 주석이 있는 이름이 항상 ClassVar인 것은 아니다. 데이터 클래스와 일반 타입 검사에서 주석은 인스턴스 속성 선언으로 사용될 수 있으며, 명시적인 클래스 변수에는 ClassVar를 사용할 수 있다.
타입 좁히기
타입 검사기는 조건문과 런타임 검사를 이용해 넓은 타입을 더 구체적인 타입으로 좁힐 수 있다.
def normalize(value: str | bytes) -> str:
if isinstance(value, bytes):
return value.decode("utf-8")
return value
isinstance() 분기 안에서 value는 bytes, 그 이후에는 str로 좁혀질 수 있다.
None 검사도 타입 좁히기에 사용된다.
def length(value: str | None) -> int:
if value is None:
return 0
return len(value)
구조적 패턴 매칭, issubclass(), 리터럴 비교와 일부 불리언 조건도 타입을 좁힐 수 있다.
복잡한 사용자 정의 검사 함수에는 TypeGuard 또는 더 정밀한 타입 좁히기 기능을 사용할 수 있다.
from typing import TypeGuard
def is_string_list(
values: list[object],
) -> TypeGuard[list[str]]:
return all(isinstance(value, str) for value in values)
def join_values(values: list[object]) -> str:
if is_string_list(values):
return ", ".join(values)
raise TypeError("strings required")
Python 타입 체계는 타입 좁히기 규칙을 계속 확장하고 있으며, 타입 검사기 간의 공통 의미는 공식 Typing Specification에서 관리된다.
타입 단언
개발자가 검사기보다 구체적인 타입 정보를 알고 있다면 cast()로 정적 타입을 단언할 수 있다.
from typing import cast
value = load_value()
user = cast(User, value)
cast()는 런타임에 값을 변환하거나 검사하지 않으며 일반적으로 입력값을 그대로 반환한다. 실제 값이 User가 아니어도 cast() 자체는 예외를 발생시키지 않는다.
따라서 cast()는 외부 계약이나 앞선 검증으로 타입이 보장되지만 검사기가 이를 추론하지 못하는 경우에 사용한다. 타입 오류를 단순히 숨기는 용도로 남용하면 정적 분석의 신뢰성이 낮아진다.
assert를 이용하면 런타임 검증과 타입 좁히기를 함께 수행할 수 있다.
value = load_value()
assert isinstance(value, User)
value.save()
다만 최적화 옵션에서 assert 문이 제거될 수 있으므로 외부 입력 검증과 반드시 실행되어야 하는 조건 검사에는 명시적인 분기와 예외를 사용해야 한다.
전방 참조와 순환 관계
타입 표기에서 아직 정의되지 않은 클래스나 서로를 참조하는 클래스를 나타내야 할 수 있다.
class Node:
def __init__(self, parent: "Node | None") -> None:
self.parent = parent
문자열로 작성된 주석은 전방 참조를 표현할 수 있다.
현대 Python의 주석 평가 모델과 from __future__ import annotations 사용 여부에 따라 주석이 저장되고 평가되는 방식이 달라질 수 있다.
from __future__ import annotations
class Node:
def __init__(self, parent: Node | None) -> None:
self.parent = parent
정적 타입 검사기는 이러한 참조를 분석할 수 있지만, 런타임 프레임워크가 주석을 읽을 때는 주석 평가 시점과 이름 공간을 고려해야 한다.
타입 검사에만 필요한 가져오기는 TYPE_CHECKING 조건을 이용해 런타임 순환 가져오기를 피할 수 있다.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from project.user import User
def process(user: User) -> None:
...
typing.TYPE_CHECKING은 정적 타입 검사 과정에서는 참으로 간주되고 일반 런타임에서는 거짓이다.
타입 스텁
타입 정보를 소스 구현과 분리하려면 .pyi 확장자의 타입 스텁 파일을 사용할 수 있다. 스텁은 함수와 클래스의 인터페이스를 선언하지만 실제 구현 코드는 포함하지 않는다.[87]
def load_user(user_id: int) -> User: ...
def save_user(user: User) -> None: ...
타입 스텁은 다음과 같은 경우에 사용된다.
- C와 C++로 작성된 확장 모듈에 타입 정보를 제공한다.
- 기존 구현을 수정하지 않고 타입 정보를 추가한다.
- 공개 인터페이스와 내부 구현을 분리한다.
- Python 버전별 인터페이스 차이를 설명한다.
타입 검사기는 일반적으로 대응하는 .pyi 파일이 있으면 정적 분석에서 이를 우선 사용한다.
표준 라이브러리와 여러 외부 패키지의 스텁은 typeshed 프로젝트에서 관리된다. 패키지는 자체 배포본에 py.typed 표시와 인라인 타입 정보를 포함하거나 별도 스텁 패키지를 제공할 수 있다.
타입 검사 도구
Python 인터프리터 자체는 일반적인 정적 타입 검사를 수행하지 않는다. 별도의 타입 검사기나 개발 도구가 소스 코드와 타입 정보를 분석한다.
대표적인 기능은 다음과 같다.
- 함수 호출 인자와 반환 타입 검사
- 잘못된 속성 접근 탐지
None처리 누락 탐지- 제네릭 타입 관계 분석
- 처리되지 않은 합집합 분기 검사
- 오버라이딩 시그니처 호환성 검사
- 코드 완성과 리팩터링 지원
타입 검사기마다 엄격성 설정, 타입 추론 범위와 일부 새 기능의 지원 시점이 다를 수 있다. Python 타입 시스템의 공통 표준은 과거에는 여러 PEP에 분산되어 있었으며, 현재는 공식 Typing Specification으로 통합해 관리한다.[88]
타입 시스템의 변경 절차와 명세 관리는 PEP 729에 따라 Typing Council과 관련 공동체가 담당한다.[89]
새 타입 기능은 Python의 특정 릴리스에 포함되기 전에 typing_extensions 패키지를 통해 이전 버전에서 제공되기도 한다. 공식 typing 문서도 새로운 타입 기능의 역이식 수단으로 typing_extensions를 안내한다.[90]
런타임 검증과 정적 타입 검사의 차이
정적 타입 검사기는 프로그램을 실행하지 않고 가능한 타입 흐름을 분석한다. 런타임 검증은 실제 입력값을 실행 중에 검사한다.
def set_age(age: int) -> None:
if age < 0:
raise ValueError("age must not be negative")
정적 타입의 int는 문자열이나 목록이 전달되는 실수를 찾는 데 도움을 주지만, 정수가 음수인지까지 자동으로 보장하지 않는다. 값의 범위와 업무 규칙은 런타임 검증이 필요하다.
외부 JSON 데이터가 타입 주석과 같은 구조를 가진다고 가정할 수도 없다.
class UserData(TypedDict):
name: str
age: int
네트워크에서 받은 사전이 실제로 name과 age를 올바른 타입으로 포함하는지는 역직렬화나 검증 단계에서 확인해야 한다.
정적 타입은 개발 중의 오류 탐지와 인터페이스 문서화에 강점이 있고, 런타임 검증은 신뢰할 수 없는 입력과 동적으로 변하는 상태를 확인한다. 실제 프로그램에서는 두 방식을 함께 사용할 수 있다.
타입 힌트와 성능
타입 힌트를 추가한다고 일반적인 CPython 코드가 자동으로 기계어에 컴파일되거나 실행 속도가 빨라지는 것은 아니다. 타입 주석은 기본적으로 정적 분석과 도구를 위한 메타데이터이다.
def add(left: int, right: int) -> int:
return left + right
인터프리터는 일반적으로 이 함수의 int 주석을 이용해 정수 전용 명령으로 자동 최적화하지 않는다.
다만 외부 컴파일러, JIT 구현체나 코드 생성 도구가 타입 정보를 별도로 활용할 수는 있다. 이는 Python 언어가 모든 구현체에 요구하는 기본 동작이 아니라 해당 도구의 기능이다.
타입 힌트는 실행 성능보다 다음 목적에 주로 사용된다.
- 함수와 클래스의 계약 표현
- 오류의 조기 발견
- 코드 탐색과 자동 완성
- 안전한 리팩터링
- 대규모 코드베이스의 경계 명확화
- 라이브러리 사용자에게 인터페이스 제공
런타임 타입과 정적 타입의 차이
Python의 런타임 클래스와 정적 타입은 밀접하지만 완전히 같은 개념은 아니다.
int, str, User 같은 클래스는 런타임 객체이면서 타입 표현에도 사용할 수 있다.
value: int = 10
print(int)
print(type(int))
반면 Any, Never, Literal, Protocol, 합집합과 일부 제네릭 표현은 정적 타입 체계에서 특별한 의미를 가진다. 이들을 일반 클래스와 같은 방식으로 인스턴스 생성이나 런타임 검사에 사용할 수 없는 경우가 있다.
from typing import Any
value = Any()
Any는 일반 인스턴스를 만드는 클래스가 아니라 타입 검사기에 동적 타입을 알리는 특수 타입 형태이다.
매개변수화된 제네릭 역시 정적 분석과 런타임 검사에서 차이가 있다.
values: list[int] = [1, 2, 3]
list[int]은 원소 타입을 정적으로 표현하지만 일반적인 목록 객체는 런타임에 각 원소가 정수인지 자동으로 강제하지 않는다.
values.append("Python")
인터프리터는 이를 허용하지만 타입 검사기는 오류로 보고할 수 있다.
또한 매개변수화된 제네릭을 그대로 isinstance()의 두 번째 인자로 사용할 수 없는 경우가 있다.
isinstance(values, list[int])
런타임에서는 list인지 확인할 수 있지만 각 원소 타입 검증은 별도의 로직이 필요하다.
타입 추론
정적 타입 검사기는 모든 타입을 명시하지 않아도 대입값과 반환식, 조건 분기를 바탕으로 일부 타입을 추론한다.
name = "Python"
count = 10
enabled = True
타입 검사기는 각각 str, int와 bool로 추론할 수 있다.
컬렉션도 원소를 바탕으로 추론할 수 있다.
numbers = [1, 2, 3]
일반적으로 list[int]로 추론할 수 있다.
빈 컬렉션이나 복잡한 분기에서는 명시적인 타입이 필요할 수 있다.
users: list[User] = []
함수의 공개 인터페이스에는 타입을 명시하고, 지역 변수는 추론에 맡기는 방식이 일반적이다. 명시 범위는 프로젝트의 엄격성, 코드 규모와 도구 설정에 따라 조정할 수 있다.
추론 결과는 검사기와 설정에 따라 일부 차이가 날 수 있으므로, 공개 API나 중요한 타입 관계에는 명시적인 주석이 더 안정적인 계약을 제공한다.
오버라이딩과 치환 가능성
하위 클래스가 상위 클래스의 메서드를 오버라이드할 때는 상위 타입을 기대하는 코드에서 계속 사용할 수 있어야 한다.
class Parser:
def parse(self, source: str) -> object:
...
class JsonParser(Parser):
def parse(self, source: str) -> dict[str, object]:
...
하위 클래스가 더 구체적인 반환 타입을 제공하는 것은 일반적으로 허용될 수 있다.
반면 매개변수 타입을 지나치게 좁히면 상위 타입이 허용한 호출을 하위 타입이 처리하지 못하게 된다.
class Parser:
def parse(self, source: str) -> object:
...
class NarrowParser(Parser):
def parse(self, source: JsonString) -> object:
...
Parser를 받는 코드가 일반 문자열을 전달할 수 있다고 기대한다면 NarrowParser는 이를 만족하지 못할 수 있다.
타입 검사기는 이러한 메서드 시그니처의 호환성을 검사해 상속 구조에서 발생하는 계약 위반을 찾을 수 있다.
타입 체계의 활용 범위
Python의 정적 타입 체계는 모든 프로젝트에 동일한 수준으로 적용해야 하는 의무적인 체계가 아니다. 짧은 개인 스크립트는 주석 없이 충분할 수 있고, 여러 팀과 모듈이 연결되는 대규모 프로젝트에서는 공개 경계의 타입 정보가 큰 가치를 제공할 수 있다.
타입 표기가 특히 유용한 영역은 다음과 같다.
- 공개 라이브러리 API
- 네트워크와 저장소 계층의 데이터 경계
- 복잡한 제네릭 컬렉션
None가능성이 있는 값- 콜백과 이벤트 시스템
- 비동기 함수와 작업 결과
- 여러 구현체를 받는 프로토콜
- 대규모 리팩터링이 자주 이루어지는 코드
동적으로 생성되는 속성, 메타프로그래밍과 런타임 패치가 많은 코드는 정적 타입으로 완전히 표현하기 어려울 수 있다. 이러한 영역에서는 프로토콜, 플러그인, 스텁, Any와 사용자 정의 타입 검사기 확장 등을 조합할 수 있다.
Python의 타입 체계는 동적 실행 모델을 제거하거나 별도의 정적 Python 언어를 만드는 것이 아니라, 기존 언어 위에 분석 가능한 계약을 선택적으로 추가하는 방향으로 발전했다. 타입 정보를 사용하지 않는 코드도 계속 실행할 수 있으며, 필요한 영역에는 정교한 제네릭과 구조적 타입을 적용할 수 있다.
타입 명세의 발전
Python의 타입 힌트는 Python 3.5의 PEP 484를 중심으로 표준화되기 시작했다. 이후 변수 주석, 구조적 프로토콜, 내장 제네릭, 합집합 연산자, 매개변수 명세 변수, 가변 길이 제네릭, Self와 새로운 타입 매개변수 문법 등이 단계적으로 추가되었다.[91]
초기의 타입 규칙은 개별 PEP의 명세 부분에 분산되어 있었다. PEP 729가 받아들여진 뒤에는 이 내용을 통합한 공식 Python Typing Specification이 타입 시스템의 현재 규칙을 관리한다.[92]
PEP는 특정 기능이 도입된 역사와 설계 근거를 보존하지만, 이후 수정된 현재 규칙은 최신 Typing Specification을 기준으로 확인해야 한다. 타입 검사기와 라이브러리 개발자는 이 명세와 호환성 테스트를 바탕으로 공통 동작을 구현한다.
Python의 타입 체계는 언어의 동적 특성을 유지하면서도 더 많은 프로그램 구조를 정적으로 설명하는 방향으로 발전하고 있다. 모든 런타임 동작을 정적으로 완전히 증명하기보다, 실제 Python 코드에서 자주 사용되는 패턴을 표현하고 오류 가능성을 줄이는 실용적인 점진적 타입 체계를 목표로 한다.
함수와 실행 범위
Python에서 함수는 하나의 작업을 수행하는 코드 블록인 동시에 런타임에 생성되는 객체이다. def 문이 실행되면 함수 본문이 즉시 실행되는 것이 아니라, 해당 본문을 실행할 코드 객체와 전역 이름 공간 등의 정보를 가진 함수 객체가 생성되어 현재 이름 공간의 이름에 바인딩된다. 함수 본문은 이후 함수 객체가 호출될 때마다 새로운 실행 프레임에서 실행된다.[93]
def greet(name):
return f"Hello, {name}"
위 코드를 실행하면 greet라는 이름에 함수 객체가 바인딩된다. 이 시점에는 return 문이 실행되지 않는다.
message = greet("Python")
호출이 이루어지면 인자가 매개변수에 바인딩되고 함수 본문이 실행된다. return에 도달하면 함수 실행이 종료되고 결과가 호출자에게 전달된다.
Python 함수는 변수에 저장하거나 다른 함수의 인자로 전달하고, 반환값으로 돌려주거나 컬렉션에 포함할 수 있는 일급 객체이다.
def add(left, right):
return left + right
operation = add
result = operation(10, 20)
함수 객체와 함수가 반환하는 값은 구분된다. add는 함수 객체를 가리키고 add(10, 20)은 그 함수를 호출한 결과를 나타낸다.
함수 정의
함수는 def 키워드와 함수 이름, 매개변수 목록 및 들여쓰기된 본문으로 정의한다.
def calculate_area(width, height):
area = width * height
return area
함수 정의문 자체는 실행 가능한 복합문이다. 인터프리터가 정의문에 도달해야 함수 객체가 만들어지고 이름에 바인딩된다.
if feature_enabled:
def process(value):
return enable_feature(value)
else:
def process(value):
return value
이 코드는 조건에 따라 서로 다른 함수 객체를 생성하여 같은 process 이름에 바인딩한다.
함수 본문은 정의 시점에 실행되지 않으므로 다음 코드에서 "defined"가 먼저 출력되고 "called"는 함수가 실제로 호출될 때 출력된다.
def run():
print("called")
print("defined")
run()
함수 이름은 일반적인 이름 바인딩 규칙을 따르므로 새로운 함수 정의가 기존 이름을 다시 바인딩할 수 있다.
def convert(value):
return str(value)
def convert(value):
return int(value)
두 번째 정의가 실행된 뒤 convert는 두 번째 함수 객체를 가리킨다. Python은 같은 이름을 가진 두 구현을 인자 타입에 따라 자동으로 선택하는 런타임 함수 오버로딩을 제공하지 않는다.
함수 객체
사용자 정의 함수 객체에는 함수의 정의와 실행에 관한 여러 정보가 연결된다.
def multiply(left, right=1):
"""두 값을 곱한다."""
return left * right
대표적인 함수 속성은 다음과 같다.
print(multiply.__name__)
print(multiply.__qualname__)
print(multiply.__doc__)
print(multiply.__defaults__)
print(multiply.__annotations__)
print(multiply.__code__)
print(multiply.__globals__)
__name__은 함수의 단순 이름, __qualname__은 중첩 관계가 포함된 한정 이름을 제공한다. __code__는 컴파일된 함수 본문을 나타내는 코드 객체이며, __globals__는 함수가 정의될 때 연결된 전역 이름 공간이다.
함수 객체에는 사용자 정의 속성을 추가할 수도 있다.
def handler(message):
return message
handler.priority = 10
handler.category = "network"
이 특성은 등록 시스템이나 간단한 메타데이터 연결에 활용할 수 있다. 공개 함수 인터페이스의 구조적인 메타데이터는 데코레이터나 별도의 객체로 관리하는 방식도 사용할 수 있다.
매개변수와 인자
함수 정의에 작성된 이름을 매개변수, 함수를 호출할 때 전달한 실제 값을 인자라고 한다.
def subtract(left, right):
return left - right
result = subtract(10, 3)
left와 right는 매개변수이고 10과 3은 인자이다.
함수 호출이 시작되면 호출 규칙에 따라 각 인자가 매개변수에 바인딩된다. 필요한 매개변수에 값이 전달되지 않거나 같은 매개변수에 값이 중복 전달되면 TypeError가 발생한다.
def connect(host, port):
...
connect("localhost")
connect("localhost", host="example.com", port=8080)
첫 번째 호출은 port가 빠졌고, 두 번째 호출은 host에 위치 인자와 키워드 인자를 모두 전달하므로 오류가 발생한다.
Python 함수의 매개변수는 다음 종류로 나뉜다.
- 위치 전용 매개변수
- 위치 또는 키워드 매개변수
- 가변 위치 매개변수
- 키워드 전용 매개변수
- 가변 키워드 매개변수
하나의 함수 정의에서 이들은 정해진 순서로 배치된다.
def function(
positional_only,
/,
positional_or_keyword,
*args,
keyword_only,
**kwargs,
):
...
위치 인자
위치 인자는 호출문에 작성된 순서에 따라 매개변수에 대응한다.
def divide(dividend, divisor):
return dividend / divisor
result = divide(10, 2)
첫 번째 인자 10은 dividend, 두 번째 인자 2는 divisor에 바인딩된다.
위치 인자의 순서는 함수 의미에 직접 영향을 줄 수 있다.
divide(10, 2)
divide(2, 10)
두 호출은 같은 값들을 사용하지만 매개변수 대응이 달라 결과도 달라진다.
짧고 의미가 명확한 매개변수는 위치 인자로 전달하기 적합하다. 인자의 역할이 호출문에서 분명하지 않거나 선택 항목이 많다면 키워드 인자를 사용할 수 있다.
키워드 인자
키워드 인자는 매개변수=값 형태로 매개변수 이름을 지정하여 전달한다.
def create_window(width, height, title):
...
create_window(
width=1280,
height=720,
title="Application",
)
키워드 인자는 정의문의 매개변수 순서와 다른 순서로 작성할 수 있다.
create_window(
title="Application",
height=720,
width=1280,
)
위치 인자와 키워드 인자를 함께 사용할 때는 일반적으로 위치 인자를 먼저 작성한다.
create_window(
1280,
720,
title="Application",
)
키워드 인자를 사용하면 호출부에서 값의 의미를 직접 표시할 수 있으며, 선택 매개변수의 순서를 기억해야 하는 부담을 줄일 수 있다.
다만 키워드로 호출할 수 있는 매개변수 이름은 공개 API의 일부가 된다. 라이브러리에서 해당 이름을 변경하면 기존 키워드 호출이 깨질 수 있다. 이름을 외부 계약에 포함하고 싶지 않다면 위치 전용 매개변수를 사용할 수 있다.
위치 전용 매개변수
매개변수 목록에서 / 앞에 있는 매개변수는 위치 인자로만 전달할 수 있다.
def ratio(value, total, /):
return value / total
ratio(10, 20)
다음 호출은 허용되지 않는다.
ratio(value=10, total=20)
위치 전용 매개변수는 매개변수 이름을 공개 API로 고정하지 않으면서 호출 순서만 계약으로 제공할 수 있다. 내부 구현에서 이름이 변경되어도 위치 기반 호출에는 영향을 주지 않는다.
또한 가변 키워드 인자에 같은 이름의 키를 받을 수 있게 한다.
def create_mapping(name, /, **values):
return {
"name": name,
**values,
}
result = create_mapping(
"configuration",
name="visible field",
)
첫 번째 "configuration"은 위치 전용 name 매개변수에 들어가고, 키워드 name="visible field"은 values 사전에 포함된다.
Python 문법에서 위치 전용 매개변수를 표현하는 / 표기는 PEP 570을 통해 도입되었다.[94]
위치 또는 키워드 매개변수
/와 * 사이에 있는 일반 매개변수는 위치 인자와 키워드 인자 모두로 전달할 수 있다.
def resize(width, height):
...
resize(1280, 720)
resize(width=1280, height=720)
resize(1280, height=720)
별도의 구분 기호가 없는 일반적인 함수 매개변수는 이 종류에 해당한다.
이 방식은 호출자에게 유연성을 제공하지만 매개변수 이름이 키워드 호출에 사용될 수 있으므로 이름 변경은 공개 API 호환성에 영향을 줄 수 있다.
키워드 전용 매개변수
매개변수 목록에서 * 뒤에 있는 매개변수는 반드시 키워드로 전달해야 한다.
def connect(host, *, timeout=30, secure=False):
...
connect(
"example.com",
timeout=10,
secure=True,
)
다음과 같이 위치 인자로 전달할 수 없다.
connect("example.com", 10, True)
키워드 전용 매개변수는 불리언 플래그나 여러 선택 항목처럼 위치만으로 의미를 이해하기 어려운 인자에 유용하다.
def copy_file(
source,
destination,
*,
overwrite=False,
preserve_metadata=True,
):
...
copy_file(
"source.txt",
"target.txt",
overwrite=True,
)
호출부에서 True가 어떤 설정을 의미하는지 명확하게 드러난다.
가변 위치 매개변수가 없어도 단독 *를 사용하여 이후 매개변수를 키워드 전용으로 만들 수 있다. 키워드 전용 매개변수 문법은 PEP 3102에서 도입되었다.[95]
기본 인자
매개변수에 기본값을 지정하면 호출자가 해당 인자를 생략할 수 있다.
def greet(name, greeting="Hello"):
return f"{greeting}, {name}"
greet("Python")
greet("Python", "Welcome")
greet("Python", greeting="Welcome")
기본값이 없는 위치 매개변수 뒤에는 일반적으로 기본값이 있는 매개변수를 배치한다.
def request(url, timeout=30, retries=3):
...
기본 인자 표현식은 함수가 호출될 때마다가 아니라 함수 정의문이 실행될 때 한 번 평가된다.[96]
default_timeout = 30
def connect(timeout=default_timeout):
return timeout
default_timeout = 60
connect()의 기본값은 정의 시점에 평가된 30이다. 이후 default_timeout을 변경해도 이미 저장된 기본값은 바뀌지 않는다.
함수의 위치 매개변수 기본값은 __defaults__, 키워드 전용 기본값은 __kwdefaults__를 통해 확인할 수 있다.
print(connect.__defaults__)
가변 객체를 기본값으로 사용할 때
목록이나 사전과 같은 가변 객체를 기본값으로 사용하면 같은 객체가 여러 호출에서 공유된다.
def append_item(item, items=[]):
items.append(item)
return items
print(append_item("first"))
print(append_item("second"))
두 번째 호출은 새 목록을 만들지 않고 첫 번째 호출에서 사용한 기본 목록을 다시 사용한다.
각 호출마다 새로운 객체가 필요하다면 None과 같은 불변 센티널 값을 기본값으로 사용하고 함수 내부에서 객체를 생성할 수 있다.
def append_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
가변 기본값이 항상 잘못된 것은 아니다. 호출 사이에 의도적으로 상태를 공유하는 캐시 등의 구조에 사용할 수도 있지만, 그 동작이 함수 계약에 명확하게 드러나야 한다.
가변 위치 인자
매개변수 이름 앞에 *를 붙이면 남은 위치 인자를 튜플로 받을 수 있다.
def total(*values):
return sum(values)
total(1, 2, 3, 4)
함수 내부에서 values는 튜플이다.
def inspect_arguments(*values):
print(type(values))
print(values)
일반 매개변수와 함께 사용할 수도 있다.
def log(level, *messages):
for message in messages:
print(level, message)
log(
"INFO",
"server started",
"waiting for connections",
)
가변 위치 매개변수 뒤의 일반 매개변수는 키워드 전용 매개변수가 된다.
def collect(*values, unique=False):
if unique:
return set(values)
return list(values)
가변 키워드 인자
매개변수 이름 앞에 **를 붙이면 명시적으로 대응되지 않은 키워드 인자를 사전으로 받을 수 있다.
def create_user(name, **attributes):
return {
"name": name,
**attributes,
}
user = create_user(
"Luna",
age=20,
active=True,
)
함수 내부의 attributes는 문자열 키를 가진 사전이다.
가변 키워드 인자는 확장 가능한 옵션, 다른 함수로 전달할 설정과 메타데이터를 받을 때 사용할 수 있다.
def traced_call(function, *args, **kwargs):
print(function.__name__)
return function(*args, **kwargs)
**kwargs를 무분별하게 사용하면 함수가 실제로 지원하는 인자를 시그니처만으로 알기 어려워질 수 있다. 고정된 공개 옵션은 명시적인 키워드 전용 매개변수로 선언하고, 실제로 확장 가능한 부분에 가변 키워드 인자를 사용하는 구조를 선택할 수 있다.
호출 시 언패킹
호출할 때 반복 가능한 객체 앞에 *를 붙이면 원소를 위치 인자로 펼칠 수 있다.
arguments = [10, 20]
result = pow(*arguments)
이는 다음 호출과 대응한다.
result = pow(10, 20)
매핑 앞에 **를 붙이면 키와 값을 키워드 인자로 펼칠 수 있다.
options = {
"host": "example.com",
"timeout": 10,
}
connect(**options)
명시적인 인자와 언패킹을 함께 사용할 수도 있다.
position = (10, 20)
style = {
"color": "red",
"visible": True,
}
draw(*position, layer=2, **style)
같은 매개변수에 중복된 값이 전달되거나 **로 펼친 매핑의 키가 문자열이 아니면 호출 오류가 발생한다.
반환값
return 문은 현재 함수 실행을 종료하고 호출자에게 값을 반환한다.
def square(value):
return value * value
함수가 return 문 없이 끝나거나 값이 없는 return을 실행하면 None을 반환한다.
def log_message(message):
print(message)
result = log_message("started")
print(result is None)
Python 함수는 문법적으로 하나의 객체를 반환한다. 여러 값을 쉼표로 나열하면 튜플이 만들어져 반환된다.
def divide_with_remainder(value, divisor):
return value // divisor, value % divisor
quotient, remainder = divide_with_remainder(10, 3)
이는 개념적으로 다음과 같다.
return (
value // divisor,
value % divisor,
)
return은 중첩된 함수가 아니라 현재 실행 중인 가장 가까운 함수만 종료한다.
함수 호출과 실행 프레임
함수가 호출되면 해당 호출을 위한 실행 프레임이 생성된다. 프레임은 함수의 지역 이름 공간, 코드 객체, 현재 실행 위치, 전역 이름 공간과 이전 실행 프레임에 대한 정보를 포함한다.[97]
def calculate(left, right):
result = left + right
return result
calculate(10, 20)을 호출하면 left, right와 result가 해당 호출 프레임의 지역 이름 공간에 존재한다.
같은 함수를 여러 번 호출하면 각 호출은 독립적인 지역 상태를 가진다.
def counter(value):
value += 1
return value
first = counter(10)
second = counter(100)
재귀 호출에서도 각 호출은 자신의 프레임과 지역 이름을 가진다.
def factorial(number):
if number <= 1:
return 1
return number * factorial(number - 1)
재귀 깊이가 지나치게 커지면 구현체의 재귀 제한에 도달해 RecursionError가 발생할 수 있다. Python은 일반적으로 꼬리 재귀를 자동으로 반복문으로 변환하지 않는다.
코드 블록과 이름 공간
Python의 실행 모델에서 모듈, 함수 본문과 클래스 정의 본문 등은 각각 코드 블록을 형성한다. 이름이 바인딩되고 조회되는 규칙은 코드 블록의 종류에 따라 달라진다.
모듈의 최상위 이름은 모듈의 전역 이름 공간에 저장된다.
application_name = "TechPedia"
함수 안에서 바인딩된 이름은 기본적으로 해당 함수 호출의 지역 이름이다.
def build_message():
message = "Hello"
return message
클래스 본문은 클래스 이름 공간에서 실행되며, 본문에서 바인딩한 이름은 생성되는 클래스의 속성이 된다.
class Configuration:
timeout = 30
retries = 3
Python에서 이름 공간은 이름과 객체 사이의 매핑이다. 서로 다른 이름 공간은 같은 이름을 서로 다른 객체에 바인딩할 수 있다.
value = "module"
def function():
value = "function"
return value
함수 안의 value와 모듈의 value는 서로 다른 이름 공간에 존재한다.
이름 바인딩
다음과 같은 문법은 이름을 객체에 바인딩할 수 있다.
- 대입문
- 함수와 클래스 정의
- 함수 매개변수
for문의 대상with ... as의 대상except ... as의 대상import문- 구조적 패턴 매칭의 캡처 패턴
- 대입 표현식
value = 10
for item in items:
...
with open(path) as file:
...
import pathlib
이름을 삭제하는 del은 바인딩을 제거하지만 객체 자체를 직접 파괴하지 않는다.
value = object()
alias = value
del value
alias가 계속 객체를 참조하므로 객체는 여전히 도달 가능하다.
함수 코드 블록 안에서 이름에 바인딩하는 문장이 하나라도 존재하면, 별도의 global이나 nonlocal 선언이 없는 한 해당 이름은 함수 전체에서 지역 이름으로 분류된다.
value = 10
def show():
print(value)
value = 20
value = 20이 있으므로 value는 함수 전체에서 지역 이름으로 판단된다. 대입 전에 print(value)가 지역 값을 읽으려 하므로 UnboundLocalError가 발생한다.
이는 실행이 대입문에 도달했는지와 관계없이 코드 블록을 컴파일하는 과정에서 이름의 범위가 결정되기 때문이다.
이름 탐색과 LEGB
함수 안에서 이름을 읽을 때 Python은 일반적으로 다음 순서로 바인딩을 찾는다.
- 현재 함수의 지역 범위
- 바깥쪽 중첩 함수의 범위
- 모듈의 전역 범위
- 내장 이름 범위
이 순서를 흔히 LEGB라고 부른다.
name = "global"
def outer():
name = "enclosing"
def inner():
name = "local"
return name
return inner()
inner()의 name은 현재 지역 범위에서 발견되므로 "local"을 반환한다.
name = "global"
def outer():
name = "enclosing"
def inner():
return name
return inner()
inner()에 지역 name이 없으므로 바깥 함수의 "enclosing"을 사용한다.
name = "global"
def show():
return name
함수와 바깥 함수 범위에 이름이 없으면 모듈 전역의 "global"을 사용한다.
내장 함수 len, print와 range 등은 마지막 내장 이름 범위에서 찾는다.
length = len(values)
전역 범위에 len이라는 이름을 새로 바인딩하면 내장 이름을 가릴 수 있다.
len = 10
이후 len(values)는 내장 함수가 아니라 정수 객체를 호출하려 하므로 오류가 발생한다.
지역 범위
함수 매개변수와 함수 안에서 바인딩되는 이름은 기본적으로 지역 범위에 속한다.
def calculate(price, quantity):
total = price * quantity
return total
price, quantity와 total은 각 함수 호출의 지역 이름이다.
함수가 종료되면 지역 이름 공간은 더 이상 일반적인 이름 조회 대상이 아니지만, 클로저나 프레임을 참조하는 객체가 있으면 관련 객체들이 계속 유지될 수 있다.
함수 안의 locals()는 현재 지역 이름 바인딩을 나타내는 매핑을 제공한다.
def inspect_local(value):
doubled = value * 2
print(locals())
locals()가 반환한 매핑을 직접 변경하여 실제 지역 변수의 값을 안정적으로 바꾸는 방식에는 의존하지 않는다. 지역 변수 저장 방식과 매핑 동기화는 실행 문맥과 구현체에 따라 제약을 가진다.
전역 범위
모듈 최상위에서 바인딩한 이름은 해당 모듈의 전역 이름 공간에 속한다.
default_timeout = 30
def connect():
return default_timeout
함수는 정의될 때 자신이 속한 모듈의 전역 이름 공간에 대한 참조를 가진다. 따라서 함수가 다른 모듈이나 객체를 통해 호출되더라도 전역 이름은 함수가 정의된 모듈을 기준으로 조회한다.
print(connect.__globals__ is globals())
globals()는 현재 모듈의 전역 이름 공간을 나타내는 사전을 반환한다.
모듈마다 독립된 전역 이름 공간이 존재한다. Python 전체에서 하나만 존재하는 프로그램 공통 전역 범위를 뜻하지 않는다.
global 문
함수 안에서 모듈 전역 이름을 다시 바인딩하려면 global 문을 사용할 수 있다.
counter = 0
def increment():
global counter
counter += 1
global counter는 이 함수 코드 블록에서 counter에 대한 바인딩이 지역 이름이 아니라 모듈 전역 이름을 대상으로 한다고 선언한다.
전역 객체가 가변 객체라면 객체 내부를 변경하는 데 global 선언이 필요하지 않다.
items = []
def add_item(item):
items.append(item)
items.append()는 items 이름을 새 객체에 다시 바인딩하지 않고 기존 목록 객체를 변경한다.
다음 코드는 이름을 다시 바인딩하므로 global이 필요하다.
items = []
def reset_items():
global items
items = []
global은 전역 변수를 생성하는 실행 함수가 아니라 해당 코드 블록의 이름 해석 방식을 결정하는 선언이다.
중첩 함수 범위
함수 안에서 다른 함수를 정의하면 안쪽 함수는 바깥 함수의 지역 이름을 참조할 수 있다.
def outer():
message = "Hello"
def inner():
return message
return inner()
inner()의 지역 범위에 message가 없으므로 바깥 함수 outer()의 범위에서 찾는다.
Python의 정적 중첩 범위는 PEP 227을 통해 도입되었으며, 함수 안에 정의된 함수가 바깥 함수의 이름을 자연스럽게 참조할 수 있게 한다.[98]
클래스 본문은 일반적인 중첩 함수 범위와 다르게 동작한다. 메서드 본문에서 한정하지 않은 이름을 조회할 때 클래스 이름 공간이 바깥 함수 범위처럼 자동으로 검색되지는 않는다.
class Configuration:
timeout = 30
def get_timeout(self):
return timeout
timeout은 메서드의 지역·중첩·전역 범위에서 검색되며 클래스 속성을 자동으로 뜻하지 않는다. 클래스 속성은 self.timeout이나 Configuration.timeout처럼 속성으로 접근한다.
nonlocal 문
중첩 함수에서 바깥 함수 범위의 이름을 다시 바인딩하려면 nonlocal 문을 사용한다.
def create_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
nonlocal count는 count가 현재 함수의 지역 이름이 아니라 가장 가까운 바깥 함수 범위의 기존 바인딩을 사용한다고 선언한다.
counter = create_counter()
print(counter())
print(counter())
호출할 때마다 같은 바깥 count 바인딩이 갱신되어 1, 2가 반환된다.
nonlocal은 모듈 전역 이름을 대상으로 하지 않으며, 바깥 함수 범위에 이미 존재하는 이름을 가리켜야 한다.
def outer():
def inner():
nonlocal missing
바깥 함수 범위에 missing 바인딩이 없으므로 구문 오류가 발생한다.
클로저
중첩 함수가 자신이 정의된 바깥 함수의 이름을 참조하고, 바깥 함수가 종료된 뒤에도 해당 바인딩을 유지하는 구조를 클로저라고 한다.
def make_multiplier(factor):
def multiply(value):
return value * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10))
print(triple(10))
make_multiplier() 호출이 끝났어도 반환된 함수는 각각 자신이 생성될 때의 factor 바인딩을 유지한다.
클로저는 단순히 바깥 값을 복사하여 함수 안에 넣는 것과 다르다. 중첩 함수는 바깥 범위의 셀 바인딩을 참조한다.
함수 객체의 __closure__에서 클로저 셀 정보를 확인할 수 있다.
print(double.__closure__)
클로저는 다음과 같은 구조에 활용된다.
- 함수 팩토리
- 콜백 상태 유지
- 데코레이터
- 지연 실행
- 간단한 캡슐화
- 부분 적용과 구성
복잡한 상태와 여러 연산을 함께 관리해야 할 때는 상태를 가진 클래스가 더 명확할 수도 있다.
지연 바인딩
클로저에서 자유 변수는 일반적으로 중첩 함수가 생성된 순간의 값이 아니라, 함수가 호출될 때 해당 바인딩의 현재 값을 조회한다.
functions = []
for number in range(3):
functions.append(
lambda: number
)
반복이 끝난 뒤 각 함수를 호출하면 모두 마지막 number 값을 반환한다.
print([function() for function in functions])
각 반복 시점의 값을 고정하려면 기본 인자가 정의 시점에 평가되는 특성을 활용할 수 있다.
functions = []
for number in range(3):
functions.append(
lambda number=number: number
)
또는 별도의 함수 팩토리를 사용할 수 있다.
def capture(value):
def read():
return value
return read
functions = [
capture(number)
for number in range(3)
]
익명 함수
lambda 표현식은 이름 없는 짧은 함수 객체를 생성한다.
square = lambda value: value * value
이는 다음 함수 정의와 비슷한 역할을 한다.
def square(value):
return value * value
lambda의 본문은 하나의 표현식으로 제한되며, 해당 표현식의 결과가 자동으로 반환된다.
sorted_users = sorted(
users,
key=lambda user: user.name,
)
짧은 변환이나 정렬 키, 간단한 콜백에 사용할 수 있다. 여러 단계의 처리, 예외 처리, 문서 문자열이나 타입 설명이 필요하면 일반 def 함수가 더 적합하다.
lambda도 일반 함수 객체이며 자신이 정의된 범위의 이름을 캡처할 수 있다.
def make_offset(offset):
return lambda value: value + offset
일급 객체로서의 함수
함수는 다른 객체와 마찬가지로 이름에 바인딩하고 컨테이너에 저장할 수 있다.
def start():
return "started"
def stop():
return "stopped"
commands = {
"start": start,
"stop": stop,
}
result = commands["start"]()
함수를 다른 함수에 전달할 수도 있다.
def apply(operation, value):
return operation(value)
result = apply(abs, -10)
함수를 반환하면 실행 전략이나 일부 설정이 적용된 새 함수를 만들 수 있다.
def choose_operation(mode):
if mode == "upper":
return str.upper
return str.lower
이러한 특성은 콜백, 이벤트 처리, 함수형 데이터 처리와 의존성 주입 등의 기반이 된다.
고차 함수
함수를 인자로 받거나 함수로 반환하는 함수를 고차 함수라고 한다.
def repeat(operation, value, count):
for _ in range(count):
value = operation(value)
return value
result = repeat(
lambda value: value * 2,
1,
4,
)
Python의 내장 함수와 표준 라이브러리에도 고차 함수가 존재한다.
sorted_items = sorted(
items,
key=lambda item: item.priority,
)
map()과 filter()는 함수를 받아 반복 가능한 객체를 변환하거나 필터링한다.
numbers = [1, 2, 3, 4]
doubled = map(
lambda value: value * 2,
numbers,
)
단순한 변환과 필터링에는 컴프리헨션이 더 직접적으로 읽힐 수 있다.
doubled = [
value * 2
for value in numbers
]
고차 함수와 컴프리헨션 가운데 어떤 형태를 사용할지는 처리의 의미, 함수 재사용 여부와 가독성에 따라 선택할 수 있다.
데코레이터
데코레이터는 함수나 클래스를 정의한 직후 다른 호출 가능한 객체에 전달하고 그 반환값을 원래 이름에 바인딩하는 문법이다.
@decorator
def function():
...
이는 개념적으로 다음 코드와 대응한다.
def function():
...
function = decorator(function)
데코레이터 표현식은 함수 본문이 호출될 때가 아니라 함수가 정의될 때 평가된다.[99]
def logged(function):
def wrapper(*args, **kwargs):
print(f"calling {function.__name__}")
return function(*args, **kwargs)
return wrapper
@logged
def add(left, right):
return left + right
add 이름에는 원래 함수가 아니라 logged()가 반환한 wrapper 함수가 바인딩된다.
여러 데코레이터는 아래쪽부터 적용된다.
@first
@second
def function():
...
이는 다음과 대응한다.
function = first(
second(function)
)
인자를 받는 데코레이터는 데코레이터를 만드는 함수로 구성할 수 있다.
def retry(count):
def decorate(function):
def wrapper(*args, **kwargs):
last_error = None
for _ in range(count):
try:
return function(*args, **kwargs)
except Exception as error:
last_error = error
raise last_error
return wrapper
return decorate
@retry(3)
def request_data():
...
함수 메타데이터 보존
래퍼 함수로 원래 함수를 감싸면 함수 이름과 문서 문자열 등의 메타데이터가 래퍼의 정보로 대체될 수 있다.
def logged(function):
def wrapper(*args, **kwargs):
return function(*args, **kwargs)
return wrapper
functools.wraps()는 원래 함수의 주요 메타데이터를 래퍼에 복사하고 __wrapped__ 연결을 제공한다.
from functools import wraps
def logged(function):
@wraps(function)
def wrapper(*args, **kwargs):
print(function.__name__)
return function(*args, **kwargs)
return wrapper
이는 문서 도구, 디버거, inspect.signature()와 다른 데코레이터가 원래 함수 정보를 추적할 수 있게 한다.[100]
재귀 함수
함수는 자신의 이름이나 다른 함수 경로를 통해 자신을 다시 호출할 수 있다.
def factorial(number):
if number <= 1:
return 1
return number * factorial(number - 1)
재귀 함수에는 일반적으로 재귀를 끝내는 기본 조건이 필요하다. 종료 조건이 없거나 입력이 그 조건에 도달하지 않으면 재귀 제한까지 호출이 계속된다.
트리 탐색과 재귀적으로 정의된 자료구조에는 재귀가 자연스러울 수 있다.
def walk(node):
yield node
for child in node.children:
yield from walk(child)
입력 깊이가 크거나 반복 상태를 명시적으로 관리해야 한다면 스택과 반복문으로 같은 구조를 구현할 수 있다.
Python 구현체는 호출 스택 보호를 위해 최대 재귀 깊이를 제한한다. 재귀 제한은 sys.getrecursionlimit()과 sys.setrecursionlimit()로 조회하거나 조정할 수 있지만, 지나치게 높이면 인터프리터가 사용하는 네이티브 스택에 부담을 줄 수 있다.[101]
제너레이터 함수
함수 본문에 yield가 사용되면 해당 정의는 일반 함수가 아니라 제너레이터 함수를 만든다.
def count_up(limit):
number = 0
while number < limit:
yield number
number += 1
제너레이터 함수를 호출하면 본문이 즉시 끝까지 실행되지 않고 제너레이터 객체가 반환된다.
generator = count_up(3)
반복자가 다음 값을 요청하면 함수 실행이 시작되거나 이전 yield 지점부터 재개된다.
print(next(generator))
print(next(generator))
yield는 값을 호출자에게 전달하면서 함수의 실행 프레임과 지역 상태를 보존한다. 다음 요청이 오면 중단된 위치 다음부터 실행한다.
함수가 정상적으로 종료되면 제너레이터는 StopIteration을 통해 반복 종료를 알린다.
for number in count_up(3):
print(number)
제너레이터는 전체 결과를 한꺼번에 메모리에 만들지 않고 값을 순차적으로 생산할 수 있다. 제너레이터의 세부 실행 모델은 별도의 반복·생성·비동기 처리 문단에서 다룬다.
코루틴 함수
async def로 정의한 함수는 코루틴 함수이다.
async def fetch_data():
return await request()
코루틴 함수를 호출하면 함수 본문을 즉시 완료하는 대신 코루틴 객체를 반환한다.
coroutine = fetch_data()
코루틴의 실행 결과를 얻으려면 이벤트 루프나 다른 코루틴에서 await해야 한다.
result = await fetch_data()
일반 함수와 코루틴 함수는 모두 호출 가능한 함수 객체지만 호출 결과와 실행 방식이 다르다. 코루틴 함수의 구체적인 일시 중단과 이벤트 루프 관계는 비동기 처리 문단에서 다룬다.
함수 주석
함수 매개변수와 반환값에는 주석을 작성할 수 있다.
def add(
left: int,
right: int,
) -> int:
return left + right
함수 주석은 기본적으로 인터프리터가 호출을 강제 검사하는 규칙이 아니다. 타입 검사기, 문서 도구와 런타임 프레임워크가 메타데이터로 활용할 수 있다.
주석 정보는 함수의 관련 인터페이스를 통해 검사할 수 있다.
print(add.__annotations__)
기본값과 타입 주석은 서로 독립적이다.
def connect(
timeout: int = 30,
) -> None:
...
int는 예상 타입을 나타내고 30은 인자를 생략했을 때 사용할 실제 기본값이다.
함수 시그니처
함수 시그니처는 호출 가능한 객체가 받는 매개변수의 이름, 순서, 종류, 기본값과 주석 등의 구조를 나타낸다.
def request(
url: str,
/,
method: str = "GET",
*,
timeout: float = 30.0,
) -> bytes:
...
이 함수의 시그니처에는 다음 정보가 포함된다.
url은 위치 전용이다.method는 위치 또는 키워드로 전달할 수 있다.timeout은 키워드 전용이다.method와timeout에는 기본값이 있다.- 매개변수와 반환값에 타입 주석이 있다.
inspect.signature()를 통해 함수 시그니처를 객체로 조사할 수 있다.
import inspect
signature = inspect.signature(request)
print(signature)
프레임워크는 시그니처를 이용해 명령행 인자, 웹 요청, 의존성이나 이벤트 데이터를 함수 매개변수에 연결할 수 있다.
데코레이터가 함수 시그니처를 숨기지 않도록 functools.wraps()와 적절한 타입 표기를 사용할 수 있다.
메서드와 함수 바인딩
클래스 본문에 정의한 함수는 클래스 속성으로 저장된다.
class Greeter:
def greet(self, name):
return f"Hello, {name}"
클래스에서 직접 조회하면 함수 객체를 얻는다.
function = Greeter.greet
인스턴스를 통해 조회하면 함수의 디스크립터 동작에 의해 인스턴스와 함수가 연결된 바인딩된 메서드가 만들어진다.
greeter = Greeter()
method = greeter.greet
method("Python")
이 호출은 개념적으로 다음 호출과 대응한다.
Greeter.greet(
greeter,
"Python",
)
self는 Python이 강제하는 예약 키워드가 아니라 인스턴스 매개변수에 사용하는 관례적인 이름이다. 바인딩 과정에서는 첫 번째 매개변수의 실제 이름과 관계없이 인스턴스가 첫 위치 인자로 전달된다.
클래스 메서드와 정적 메서드는 각각 classmethod와 staticmethod 디스크립터를 통해 다른 바인딩 방식을 제공한다.
내장 함수와 사용자 정의 함수
Python의 호출 가능한 객체에는 Python 소스에서 정의한 사용자 함수뿐 아니라 C 등의 언어로 구현된 내장 함수도 포함된다.
print(len([1, 2, 3]))
사용자 정의 함수와 내장 함수는 모두 호출 문법을 사용하지만 내부 표현과 제공하는 속성에는 차이가 있다.
print(type(len))
print(type(lambda: None))
일부 내장 함수는 위치 전용 매개변수를 사용하며, 구현상의 이유로 사용자 정의 함수와 다른 내부 호출 경로를 가질 수 있다. 그러나 가능한 경우 inspect.signature()와 공식 문서에서 공개 시그니처를 제공한다.
사용자는 구체적인 구현 방식보다 함수의 공개 시그니처와 반환값, 예외와 부수 효과를 기준으로 호출한다.
함수의 부수 효과
함수는 반환값을 계산하는 것 외에 외부 상태를 변경하는 부수 효과를 가질 수 있다.
def add_user(users, user):
users.append(user)
이 함수는 전달받은 목록 객체를 변경한다.
users = []
add_user(users, "Luna")
파일 쓰기, 네트워크 요청, 로그 출력, 전역 상태 변경과 데이터베이스 갱신도 부수 효과에 해당한다.
def save_message(path, message):
with open(path, "w", encoding="utf-8") as file:
file.write(message)
부수 효과가 있는 함수와 값을 계산해 반환하는 함수를 구분하면 테스트와 조합이 쉬워질 수 있다.
def format_message(user):
return f"Hello, {user.name}"
def send_message(connection, message):
connection.send(message)
Python이 순수 함수 사용을 강제하지는 않으며, 프로그램의 목적에 따라 상태 변경과 순수 계산을 함께 사용한다.
함수 인자 전달 모델
Python의 인자 전달은 객체가 매개변수 이름에 바인딩되는 방식으로 이루어진다. 객체 자체를 항상 복사하여 전달하지도 않고, 호출자의 변수 이름 자체를 직접 전달하지도 않는다.
def append_value(items):
items.append(3)
values = [1, 2]
append_value(values)
매개변수 items와 호출자의 values는 같은 목록 객체를 참조한다. 함수가 그 객체를 변경하면 호출자도 변경을 관찰한다.
def replace(items):
items = [10, 20]
values = [1, 2]
replace(values)
함수 안의 items를 새 목록에 다시 바인딩해도 호출자의 values 바인딩은 바뀌지 않는다.
불변 객체에서도 같은 바인딩 모델이 적용된다.
def increment(value):
value += 1
return value
number = 10
result = increment(number)
정수는 불변이므로 value += 1은 새 정수 객체를 만들고 지역 이름 value를 다시 바인딩한다. 호출자의 number는 계속 기존 정수 객체를 가리킨다.
이 방식은 객체 공유와 이름 재바인딩을 함께 이해해야 하며, 흔히 객체 참조에 의한 전달 또는 공유에 의한 호출이라고 설명된다.
실행 범위와 객체 수명
지역 이름의 범위와 객체의 수명은 같은 개념이 아니다.
def create_list():
items = [1, 2, 3]
return items
함수가 종료되면 지역 이름 items는 더 이상 해당 함수 프레임의 일반 실행 범위에서 사용되지 않는다. 그러나 반환된 목록 객체는 호출자가 참조하므로 계속 존재한다.
result = create_list()
반대로 이름이 범위 안에 있더라도 다른 객체로 다시 바인딩하면 이전 객체에 대한 참조가 사라질 수 있다.
클로저는 함수가 종료된 뒤에도 바깥 함수의 일부 바인딩을 유지할 수 있다.
def make_reader():
value = [1, 2, 3]
def read():
return value
return read
반환된 read 함수가 value를 참조하므로 목록 객체와 클로저 셀이 계속 유지된다.
동적 이름 조회와 리플렉션
globals()와 locals()는 현재 실행 문맥의 이름 공간을 매핑 형태로 제공한다.
print(globals())
getattr()과 setattr()은 문자열로 속성 이름을 다룰 수 있다.
method_name = "start"
method = getattr(service, method_name)
method()
문자열을 전역이나 지역 이름으로 직접 평가하는 eval()과 exec()도 제공되지만, 외부 입력을 그대로 전달하면 임의 코드 실행으로 이어질 수 있다.
result = eval("10 + 20")
동적인 함수 등록과 호출은 일반적으로 명시적인 사전이나 객체 속성을 이용해 구성할 수 있다.
commands = {
"start": start,
"stop": stop,
}
command = commands[user_choice]
command()
이 구조는 허용된 함수 집합을 명확하게 제한하면서도 런타임 선택을 제공한다.
함수 설계와 인터페이스
Python 함수의 공개 인터페이스에는 다음 요소가 포함될 수 있다.
- 함수 이름과 모듈 위치
- 매개변수 이름과 종류
- 기본값
- 타입 주석
- 반환값
- 발생 가능한 예외
- 입력 객체에 대한 변경 여부
- 외부 상태에 미치는 영향
짧은 함수가 항상 좋은 함수인 것은 아니며, 한 함수가 하나의 명확한 책임과 예측 가능한 계약을 가지는지가 중요하다.
매개변수 종류를 이용하면 호출 방식을 의도에 맞게 제한할 수 있다.
def open_connection(
address,
/,
*,
timeout=30,
secure=True,
):
...
address는 핵심 입력으로 위치에 따라 전달하고, 선택 설정은 이름을 드러내도록 키워드 전용으로 제한한다.
여러 관련 상태와 동작이 함께 움직인다면 함수 여러 개와 공유 가변 상태보다 클래스로 묶는 구조가 적합할 수 있다. 반대로 상태를 유지할 필요가 없는 변환과 연산은 독립 함수로 표현하면 재사용과 테스트가 쉬워질 수 있다.
Python의 함수 체계는 간단한 프로시저에서 클로저, 데코레이터, 제너레이터와 코루틴에 이르기까지 같은 함수 객체와 실행 범위 모델 위에서 확장된다. 이름 바인딩과 실행 프레임, 매개변수 대응 규칙을 이해하면 이러한 고급 기능도 별개의 예외적인 문법이 아니라 함수 실행 모델의 연장으로 다룰 수 있다.
객체 지향 프로그래밍
Python은 클래스와 객체를 이용해 상태와 동작을 하나의 단위로 구성하는 객체 지향 프로그래밍을 지원한다. 클래스는 인스턴스가 공유할 속성과 메서드를 정의하며, 클래스 객체를 호출하면 해당 클래스의 인스턴스를 생성할 수 있다.
Python의 객체 지향 기능은 언어의 나머지 부분과 분리된 별도의 체계가 아니다. 클래스, 인스턴스, 메서드와 상속도 Python의 공통 객체 모델과 이름 바인딩 규칙 위에서 동작한다. 클래스 자체도 객체이고, 클래스 본문은 실행 가능한 코드 블록이며, 메서드는 클래스 이름 공간에 저장된 함수 객체이다.
class User:
def __init__(self, name):
self.name = name
def greet(self):
return f"Hello, {self.name}"
user = User("Luna")
message = user.greet()
User는 클래스 객체이고 user는 User의 인스턴스이다. name은 인스턴스마다 개별적으로 보유하는 상태이며 greet()는 그 상태를 이용하는 메서드이다.
Python 클래스는 단일 상속과 다중 상속, 메서드 재정의, 연산자 오버로딩, 추상 기반 클래스, 프로퍼티, 디스크립터와 메타클래스를 지원한다. 그러나 모든 프로그램을 클래스 중심으로 구성하도록 요구하지는 않는다. 모듈 수준 함수, 독립적인 자료형과 클래스 기반 구조를 문제에 맞게 함께 사용할 수 있다.[102]
클래스 정의
클래스는 class 문으로 정의한다.
class Rectangle:
pass
클래스 본문은 클래스 정의문이 실행될 때 별도의 이름 공간에서 위에서 아래로 실행된다. 본문 실행이 끝나면 해당 이름 공간을 바탕으로 클래스 객체가 만들어지고 Rectangle이라는 이름에 바인딩된다.
class Rectangle:
unit = "px"
dimensions = 2
print("class body executed")
unit과 dimensions는 클래스 이름 공간에 저장된다. print() 호출은 인스턴스를 만들 때가 아니라 클래스 정의문이 실행될 때 수행된다.
클래스 본문에는 대입과 함수 정의뿐 아니라 조건문, 반복문과 함수 호출도 작성할 수 있다.
debug_mode = True
class Service:
if debug_mode:
logging_enabled = True
else:
logging_enabled = False
클래스 본문에서 정의한 함수는 클래스 속성으로 저장되며, 인스턴스를 통해 조회될 때 메서드로 바인딩된다.
class Greeter:
def greet(self, name):
return f"Hello, {name}"
클래스 정의는 함수를 정의하는 템플릿 선언만이 아니라 실제로 클래스 객체를 생성하는 실행 과정이다. 이 특성은 클래스 데코레이터, 조건부 클래스 구성과 메타클래스 기반 확장을 가능하게 한다.
클래스와 인스턴스
클래스는 여러 인스턴스가 공유하는 구조와 동작을 정의한다. 인스턴스는 클래스 객체를 호출하여 생성한다.
class User:
def __init__(self, name):
self.name = name
first = User("Alice")
second = User("Bob")
first와 second는 서로 다른 인스턴스이며 각각 독립적인 name 속성을 가진다.
print(first.name)
print(second.name)
print(first is second)
인스턴스의 클래스는 type()이나 __class__를 통해 확인할 수 있다.
print(type(first))
print(first.__class__ is User)
isinstance()는 객체가 특정 클래스 또는 그 하위 클래스의 인스턴스인지 검사한다.
print(isinstance(first, User))
클래스 객체 역시 type의 인스턴스이다.
print(type(User))
일반적인 사용자 정의 클래스의 메타클래스는 type이다. 따라서 Python 객체 모델에서는 인스턴스와 클래스가 서로 완전히 분리된 범주라기보다, 클래스도 다른 객체를 생성하는 역할을 가진 객체로 취급된다.
인스턴스 생성과 초기화
클래스를 호출하면 일반적으로 __new__()와 __init__()이 순서대로 관여한다.
user = User("Luna")
__new__()는 새 인스턴스를 생성하고 반환하며, __init__()은 생성된 인스턴스의 초기 상태를 설정한다.
class User:
def __new__(cls, name):
instance = super().__new__(cls)
return instance
def __init__(self, name):
self.name = name
대부분의 일반 클래스는 __new__()를 직접 정의하지 않고 __init__()만 구현한다.
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
__init__()은 이미 생성된 객체를 초기화하는 메서드이므로 값을 반환해서는 안 된다.
불변 내장 타입을 상속하여 값을 변경해야 하는 경우에는 객체가 생성된 뒤 실행되는 __init__()보다 __new__()를 사용한다.
class NormalizedString(str):
def __new__(cls, value):
value = value.strip().lower()
return super().__new__(cls, value)
클래스 호출의 전체 과정은 메타클래스의 __call__()에 의해 제어된다. 일반적인 type.__call__()은 __new__()를 호출한 뒤 적절한 객체가 반환되면 __init__()을 호출한다.
self
인스턴스 메서드의 첫 번째 매개변수에는 호출 대상 인스턴스가 전달된다. 이 매개변수에는 관례적으로 self라는 이름을 사용한다.
class Counter:
def increment(self):
self.value += 1
self는 예약 키워드가 아니므로 다른 이름을 사용할 수도 있지만, Python 코드에서는 self를 사용하는 관례가 확립되어 있다.
counter.increment()
인스턴스를 통한 위 호출은 개념적으로 다음 호출과 대응한다.
Counter.increment(counter)
self.value는 현재 메서드를 호출한 인스턴스의 속성을 조회하거나 설정한다. 메서드 안에서 단순히 value라고 작성하면 인스턴스 속성이 아니라 지역·중첩·전역 이름 탐색 규칙이 적용된다.
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
self를 명시적으로 작성하는 구조는 메서드 호출에 사용되는 객체와 속성 접근을 코드에 직접 드러낸다.
인스턴스 속성
인스턴스마다 독립적으로 저장되는 값을 인스턴스 속성이라고 한다. 일반적으로 __init__()이나 다른 인스턴스 메서드에서 self.attribute 형태로 만든다.
class Player:
def __init__(self, name, health):
self.name = name
self.health = health
first = Player("Alice", 100)
second = Player("Bob", 80)
first.health와 second.health는 서로 독립적이다.
first.health -= 10
print(first.health)
print(second.health)
일반적인 사용자 정의 클래스의 인스턴스 속성은 __dict__에 저장된다.
print(first.__dict__)
Python 클래스는 기본적으로 클래스 정의에 선언되지 않은 새 속성도 실행 중 추가할 수 있다.
first.level = 5
이러한 동적 속성은 유연하지만, 오타로 새로운 속성이 만들어질 수도 있다.
first.heath = 0
health가 아니라 heath라는 별도 속성이 생성된다. __slots__, 프로퍼티, 데이터 클래스, 타입 검사기와 명시적인 초기화 구조를 통해 이런 실수를 줄일 수 있다.
클래스 속성
클래스 본문에서 직접 바인딩한 이름은 클래스 속성이 된다.
class Player:
species = "human"
def __init__(self, name):
self.name = name
species는 모든 인스턴스가 클래스에서 공유한다.
first = Player("Alice")
second = Player("Bob")
print(first.species)
print(second.species)
인스턴스를 통해 클래스 속성을 읽을 수 있지만, 인스턴스에 같은 이름을 대입하면 해당 인스턴스 속성이 클래스 속성을 가린다.
first.species = "android"
print(first.species)
print(second.species)
print(Player.species)
first.species는 인스턴스 속성이 되었으며 클래스의 Player.species 값은 바뀌지 않았다.
클래스 자체를 통해 속성을 변경하면 별도의 인스턴스 속성으로 가리지 않은 모든 인스턴스에서 새 값이 보인다.
Player.species = "modified human"
클래스 속성은 상수, 기본 설정, 인스턴스 전체가 공유하는 정보와 클래스 수준 등록소 등에 활용할 수 있다.
가변 클래스 속성
목록이나 사전과 같은 가변 객체를 클래스 속성으로 두면 모든 인스턴스가 같은 객체를 공유한다.
class Team:
members = []
def __init__(self, name):
self.name = name
def add_member(self, member):
self.members.append(member)
first = Team("First")
second = Team("Second")
first.add_member("Alice")
print(second.members)
members는 각 인스턴스의 독립적인 목록이 아니라 클래스에서 공유하는 하나의 목록이다.
인스턴스마다 별도의 컬렉션이 필요하다면 __init__()에서 생성해야 한다.
class Team:
def __init__(self, name):
self.name = name
self.members = []
가변 클래스 속성이 항상 부적절한 것은 아니다. 인스턴스 전체가 의도적으로 공유하는 캐시나 등록소라면 사용할 수 있지만, 인스턴스별 상태와 명확히 구분해야 한다.
속성 조회
instance.attribute 형태의 속성 조회는 단순히 인스턴스 내부 사전만 확인하지 않는다. 데이터 모델에 정의된 속성 검색 순서에 따라 인스턴스, 클래스 계층과 디스크립터가 함께 관여한다.[103]
일반적인 인스턴스 속성 조회는 다음 요소의 영향을 받는다.
- 클래스 계층의 데이터 디스크립터
- 인스턴스의 속성 이름 공간
- 클래스와 상위 클래스의 일반 속성 또는 비데이터 디스크립터
__getattr__()을 통한 대체 조회
class Base:
value = "class"
instance = Base()
instance.value = "instance"
instance.value는 인스턴스 이름 공간의 값을 반환한다.
del instance.value
인스턴스 속성을 제거하면 다시 클래스 속성 "class"이 조회된다.
클래스 속성 검색은 클래스의 메서드 결정 순서를 따라 상위 클래스까지 진행된다.
메서드
클래스 본문에 정의된 함수가 인스턴스를 통해 조회되면 바인딩된 메서드 객체로 변환된다.
class Greeter:
def greet(self, name):
return f"Hello, {name}"
greeter = Greeter()
method = greeter.greet
바인딩된 메서드는 원래 함수와 대상 인스턴스를 함께 보유한다.
print(method.__self__ is greeter)
print(method.__func__ is Greeter.greet)
호출 시 바인딩된 인스턴스가 첫 번째 인자로 자동 전달된다.
method("Python")
클래스에서 직접 함수 속성을 조회하면 인스턴스가 바인딩되지 않는다.
function = Greeter.greet
function(greeter, "Python")
이 메서드 바인딩은 클래스에 저장된 사용자 정의 함수가 디스크립터 프로토콜을 구현하기 때문에 발생한다. Python 데이터 모델은 클래스 속성에서 함수 객체를 찾으면 인스턴스와 연결된 메서드 객체로 변환한다.[104]
인스턴스 메서드
인스턴스 메서드는 인스턴스의 상태를 읽거나 변경하는 일반적인 메서드이다.
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("insufficient balance")
self.balance -= amount
인스턴스 메서드는 첫 매개변수로 self를 받고, 클래스와 인스턴스 속성에 접근할 수 있다.
account = BankAccount()
account.deposit(100)
account.withdraw(40)
메서드는 반드시 인스턴스 상태를 변경할 필요는 없다. 상태를 읽어 계산한 결과만 반환할 수도 있다.
def can_withdraw(self, amount):
return amount <= self.balance
클래스와 관계없는 독립적인 작업을 단순히 같은 이름 공간에 넣기 위해 인스턴스 메서드로 만들면 불필요한 결합이 생길 수 있다. 인스턴스나 클래스 상태가 필요하지 않은 기능은 모듈 함수 또는 정적 메서드로 구성할 수 있다.
클래스 메서드
@classmethod를 적용한 메서드는 인스턴스 대신 클래스 객체가 첫 번째 인자로 바인딩된다. 첫 매개변수에는 관례적으로 cls를 사용한다.
class User:
def __init__(self, name):
self.name = name
@classmethod
def anonymous(cls):
return cls("Anonymous")
user = User.anonymous()
cls를 이용하므로 하위 클래스에서 호출하면 해당 하위 클래스의 인스턴스를 만들 수 있다.
class Admin(User):
pass
admin = Admin.anonymous()
print(type(admin))
클래스 메서드는 다음 용도로 자주 사용한다.
- 대체 생성자
- 클래스 수준 설정 접근
- 상속을 고려한 팩토리
- 클래스 단위 등록과 조회
외부 데이터를 해석하는 대체 생성자를 만들 수 있다.
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, value):
year, month, day = map(int, value.split("-"))
return cls(year, month, day)
date = Date.from_string("2026-07-18")
정적 메서드
@staticmethod를 적용한 메서드는 인스턴스나 클래스가 자동으로 전달되지 않는다.
class Temperature:
@staticmethod
def celsius_to_fahrenheit(value):
return value * 9 / 5 + 32
result = Temperature.celsius_to_fahrenheit(20)
정적 메서드는 일반 함수와 비슷하게 동작하지만 클래스 이름 공간에 배치된다. 클래스의 개념과 밀접하게 관련되어 있지만 인스턴스나 클래스 상태를 사용할 필요가 없는 연산에 사용할 수 있다.
모듈 수준 함수와 정적 메서드 중 어느 쪽을 사용할지는 해당 기능이 클래스의 공개 개념에 속하는지에 따라 결정할 수 있다. 클래스 내부에 있다는 이유만으로 자동으로 캡슐화가 강화되는 것은 아니다.
캡슐화
캡슐화는 객체의 내부 상태와 구현 세부를 하나의 인터페이스 뒤에 구성하는 방식이다. Python에서는 클래스와 메서드를 이용해 상태 변경 규칙을 한곳에 모을 수 있다.
class BankAccount:
def __init__(self, balance=0):
self._balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
self._balance += amount
def get_balance(self):
return self._balance
호출자는 내부 _balance 값의 구체적인 저장 방식을 알지 않고 공개 메서드를 통해 작업할 수 있다.
Python은 다른 일부 언어처럼 모든 속성에 대해 강제되는 private, protected, public 접근 지정자를 제공하지 않는다. 대신 이름 규칙, 프로퍼티, 디스크립터와 모듈 인터페이스를 이용해 공개 범위를 표현한다.
캡슐화의 목적은 외부 접근을 완전히 차단하는 것보다, 객체의 정상적인 사용 경로와 안정적인 공개 계약을 명확히 하는 데 있다.
공개 속성과 비공개 관례
밑줄 없이 시작하는 이름은 일반적으로 공개 인터페이스로 간주한다.
class User:
def save(self):
...
밑줄 하나로 시작하는 이름은 비공개 구현 세부라는 관례를 나타낸다.
class User:
def __init__(self):
self._cache = {}
def _load_cache(self):
...
이 규칙은 인터프리터가 외부 접근을 막는 기능이 아니다.
user._cache
기술적으로 접근할 수 있지만 클래스 외부 코드가 해당 이름에 의존하면 구현 변경에 취약할 수 있다.
모듈 수준에서도 밑줄로 시작하는 이름은 일반적으로 비공개로 취급되며, from module import *의 기본 가져오기 대상에서 제외된다.
이름 맹글링
클래스 본문에서 밑줄 두 개로 시작하고 두 개로 끝나지 않는 이름은 클래스 이름을 포함하는 형태로 변환된다.
class User:
def __init__(self):
self.__token = "secret"
__token은 내부적으로 _User__token과 같은 이름으로 맹글링된다.
user = User()
print(user._User__token)
이름 맹글링은 보안이나 완전한 접근 차단을 제공하지 않는다. 주된 목적은 하위 클래스가 우연히 같은 내부 이름을 정의하여 상위 클래스의 속성을 덮어쓰는 상황을 줄이는 것이다.[105]
class Base:
def __init__(self):
self.__state = "base"
class Derived(Base):
def __init__(self):
super().__init__()
self.__state = "derived"
두 클래스의 __state는 각각 _Base__state와 _Derived__state로 변환되어 충돌하지 않는다.
외부 사용자가 반드시 접근하지 못해야 하는 민감 정보 보호 기능으로 이름 맹글링을 해석해서는 안 된다.
프로퍼티
property는 메서드 기반 접근 로직을 일반 속성 문법으로 노출한다.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
temperature = Temperature(20)
print(temperature.celsius)
설정자를 추가하면 대입 시 검증이나 변환을 수행할 수 있다.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
temperature.celsius = 25
삭제자를 정의할 수도 있다.
@celsius.deleter
def celsius(self):
del self._celsius
프로퍼티를 사용하면 처음에는 단순 공개 속성이었던 인터페이스를 나중에 메서드 기반 구현으로 변경하면서 호출부의 object.attribute 형식을 유지할 수 있다.
모든 속성에 기계적으로 접근자 메서드를 만들 필요는 없다. 단순히 값을 저장하고 읽는 속성은 공개 속성으로 제공하고, 검증이나 계산이 필요한 시점에 프로퍼티를 적용할 수 있다.
계산된 속성
프로퍼티는 저장된 값을 그대로 반환하지 않고 다른 상태에서 계산된 값을 제공할 수 있다.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
rectangle = Rectangle(10, 20)
print(rectangle.area)
area는 별도로 저장되지 않으며 조회할 때 계산된다.
계산 비용이 크거나 입출력을 유발하는 작업을 프로퍼티로 숨기면 일반 속성 조회보다 훨씬 무거운 동작이라는 사실이 드러나지 않을 수 있다. 이런 경우 명시적인 메서드가 더 적합할 수 있다.
프로퍼티는 일반적으로 다음과 같은 동작에 사용한다.
- 값 검증
- 내부 표현 변환
- 읽기 전용 속성
- 계산된 값
- 기존 공개 속성과의 호환성 유지
상속
상속은 기존 클래스의 속성과 메서드를 바탕으로 새로운 클래스를 정의하는 기능이다.
class Animal:
def speak(self):
return "sound"
class Dog(Animal):
pass
Dog는 Animal을 상속하므로 Animal의 메서드를 사용할 수 있다.
dog = Dog()
print(dog.speak())
상속 관계는 issubclass()와 isinstance()에 반영된다.
print(issubclass(Dog, Animal))
print(isinstance(dog, Animal))
하위 클래스는 상위 클래스의 공개 계약을 더 구체적인 형태로 구현할 수 있다. 상속은 단순 코드 재사용뿐 아니라 하위 클래스 객체를 상위 클래스가 요구되는 위치에서 사용할 수 있게 하는 타입 관계를 나타낸다.
메서드 재정의
하위 클래스는 상위 클래스와 같은 이름의 메서드를 정의하여 동작을 재정의할 수 있다.
class Animal:
def speak(self):
return "sound"
class Dog(Animal):
def speak(self):
return "woof"
animal = Dog()
print(animal.speak())
실제 객체의 클래스에 따라 실행할 메서드가 결정되므로 같은 상위 타입을 사용하는 코드에서 서로 다른 동작을 제공할 수 있다.
class Cat(Animal):
def speak(self):
return "meow"
def announce(animal):
print(animal.speak())
announce(Dog())
announce(Cat())
하위 클래스 메서드는 상위 클래스 메서드를 직접 호출하거나 super()를 사용해 다음 구현으로 위임할 수 있다.
class LoggedService:
def start(self):
print("service started")
class NetworkService(LoggedService):
def start(self):
configure_network()
super().start()
다형성
다형성은 같은 인터페이스를 통해 서로 다른 타입의 객체를 사용할 수 있는 특성이다. Python에서는 상속뿐 아니라 덕 타이핑과 프로토콜을 통해 다형성을 구성할 수 있다.
def render(shape):
return shape.draw()
shape가 특정 상위 클래스를 반드시 상속하지 않아도 draw() 메서드를 제공하면 사용할 수 있다.
class Circle:
def draw(self):
return "circle"
class Rectangle:
def draw(self):
return "rectangle"
render(Circle())
render(Rectangle())
명시적인 공통 기반 클래스가 필요한 경우 상속이나 추상 기반 클래스를 사용할 수 있다. 구현 사이의 결합을 줄이고 필요한 동작만 요구하려면 덕 타이핑이나 정적 Protocol을 선택할 수 있다.
Python의 객체 지향 다형성은 메서드 호출에만 한정되지 않는다. 특수 메서드를 구현하면 연산자, 반복, 구독과 컨텍스트 관리자 같은 언어 문법에도 참여할 수 있다.
super()
super()는 메서드 결정 순서에서 현재 클래스 다음에 위치한 클래스에 속성 조회를 위임한다.
class Base:
def initialize(self):
print("Base")
class Derived(Base):
def initialize(self):
print("Derived")
super().initialize()
단일 상속에서는 흔히 직접 상위 클래스 메서드를 호출하는 기능처럼 보인다. 그러나 super()는 특정 부모 클래스를 고정적으로 의미하지 않으며, 다중 상속에서는 현재 클래스의 MRO 다음 항목으로 호출을 전달한다.
class Base:
def process(self):
print("Base")
class Left(Base):
def process(self):
print("Left")
super().process()
class Right(Base):
def process(self):
print("Right")
super().process()
class Child(Left, Right):
def process(self):
print("Child")
super().process()
Child().process()는 MRO를 따라 Child, Left, Right, Base 순서로 이어질 수 있다.
협력적 다중 상속에서는 모든 클래스가 super()를 사용하고 서로 호환되는 메서드 시그니처를 유지해야 호출 체인이 정상적으로 연결된다.
단일 상속
단일 상속은 하나의 직접 상위 클래스를 지정한다.
class Vehicle:
def move(self):
...
class Car(Vehicle):
def open_trunk(self):
...
하위 클래스는 상위 클래스의 기능을 물려받으면서 새로운 기능을 추가하거나 기존 동작을 재정의할 수 있다.
단일 상속은 계층 구조가 비교적 명확하지만, 모든 코드 재사용 관계가 의미 있는 타입 관계를 나타내는 것은 아니다. 단순히 기능을 재사용하기 위해 상속하면 하위 클래스가 상위 클래스의 전체 계약과 내부 구조에 강하게 결합될 수 있다.
객체가 상위 타입의 한 종류라고 자연스럽게 볼 수 있고, 상위 타입이 기대되는 위치에서 같은 의미로 사용할 수 있을 때 상속 관계가 잘 맞는다.
다중 상속
Python 클래스는 여러 직접 상위 클래스를 상속할 수 있다.
class Flyable:
def fly(self):
...
class Swimmable:
def swim(self):
...
class Duck(Flyable, Swimmable):
pass
Duck은 두 상위 클래스의 메서드를 사용할 수 있다.
duck = Duck()
duck.fly()
duck.swim()
다중 상속은 여러 독립적인 기능을 조합하거나 믹스인을 적용할 때 사용할 수 있다. 그러나 여러 상위 클래스가 같은 메서드 이름을 제공하거나 공통 조상을 공유하면 어떤 구현을 선택할지 결정해야 한다.
Python은 메서드 결정 순서를 선형화하여 각 클래스의 검색 순서를 정한다. 공식 자습서는 이 순서가 각 클래스 선언의 왼쪽에서 오른쪽 우선순위를 보존하고, 같은 상위 클래스가 여러 번 처리되지 않도록 하며, 하위 클래스 추가로 기존 부모의 상대적 순서가 뒤집히지 않는 단조성을 유지한다고 설명한다.[106]
메서드 결정 순서
메서드 결정 순서인 MRO는 속성과 메서드를 어떤 클래스 순서로 검색할지 정의한다.
class Root:
pass
class Left(Root):
pass
class Right(Root):
pass
class Child(Left, Right):
pass
print(Child.__mro__)
MRO는 클래스 자신에서 시작하여 상위 클래스와 object까지 이어지는 튜플이다.
print(Child.mro())
Python은 C3 선형화 알고리즘을 이용해 MRO를 구성한다. 이 알고리즘은 다음 조건을 만족하도록 설계되었다.
- 클래스 정의에 적힌 직접 상위 클래스 순서를 보존한다.
- 각 상위 클래스의 기존 MRO와 일관성을 유지한다.
- 하나의 클래스가 여러 경로에 있어도 한 번만 배치한다.
- 상속을 확장해도 기존 부모 클래스의 상대적 우선순위를 뒤집지 않는다.
일관된 MRO를 만들 수 없는 상속 구조는 클래스 정의 시 TypeError를 발생시킨다.
클래스의 mro() 메서드는 메타클래스에서 재정의할 수 있으며, 계산된 결과는 클래스의 __mro__에 저장된다.[107]
다이아몬드 상속
다이아몬드 상속은 하나의 공통 조상으로 이어지는 여러 상속 경로가 존재하는 구조이다.
class Root:
def initialize(self):
print("Root")
class Left(Root):
def initialize(self):
print("Left")
super().initialize()
class Right(Root):
def initialize(self):
print("Right")
super().initialize()
class Child(Left, Right):
def initialize(self):
print("Child")
super().initialize()
Child().initialize()
MRO가 Child, Left, Right, Root, object 순서라면 각 구현은 한 번씩 호출된다.
특정 부모 클래스를 직접 호출하면 협력적 호출 체인이 깨지거나 공통 조상이 여러 번 호출될 수 있다.
class Left(Root):
def initialize(self):
Root.initialize(self)
다중 상속에서 확장 가능한 구조를 만들려면 직접 부모 이름보다 super()와 호환 가능한 호출 계약을 사용하는 방식이 적합하다.
믹스인
믹스인은 완전한 독립 객체보다는 다른 클래스에 특정 기능을 추가하기 위한 작은 클래스이다.
class JsonSerializableMixin:
def to_json(self):
return json.dumps(self.__dict__)
class User(JsonSerializableMixin):
def __init__(self, name):
self.name = name
user = User("Luna")
print(user.to_json())
믹스인은 일반적으로 다음 특성을 가진다.
- 하나의 제한된 기능을 제공한다.
- 독립적으로 인스턴스화할 필요가 적다.
- 다른 클래스와 조합될 것을 전제로 한다.
- 자체 상태가 적거나 명확하다.
- MRO와
super()호출 규칙을 고려한다.
여러 믹스인을 결합할 수 있다.
class TimestampMixin:
def touch(self):
self.updated_at = datetime.now()
class LogMixin:
def log(self, message):
print(message)
class Document(
TimestampMixin,
LogMixin,
):
pass
믹스인이 너무 많은 내부 상태나 초기화 요구를 가지면 상속 조합이 복잡해질 수 있다. 이런 기능은 별도 객체로 구성하는 방법도 있다.
협력적 다중 상속
여러 클래스의 같은 메서드가 MRO를 따라 모두 실행되어야 한다면 협력적 다중 상속 구조를 사용할 수 있다.
class Base:
def __init__(self, **kwargs):
super().__init__()
class NamedMixin:
def __init__(self, *, name, **kwargs):
self.name = name
super().__init__(**kwargs)
class EnabledMixin:
def __init__(self, *, enabled=True, **kwargs):
self.enabled = enabled
super().__init__(**kwargs)
class Service(
NamedMixin,
EnabledMixin,
Base,
):
pass
service = Service(
name="network",
enabled=True,
)
각 클래스는 자신이 처리할 키워드만 제거하고 나머지를 super()에 전달한다. 최종적으로 모든 인자가 처리되어야 한다.
이 구조에서는 상위 클래스 이름을 직접 지정하기보다 MRO의 다음 구현에 위임해야 한다. 클래스마다 메서드 시그니처와 반환 계약도 서로 호환되어야 한다.
협력적 다중 상속은 강력하지만 설계 규칙을 공유해야 하므로, 단순한 프로그램에서는 구성이나 단일 상속이 더 명확할 수 있다.
추상 기반 클래스
추상 기반 클래스는 구현 클래스가 제공해야 할 공통 인터페이스를 명시적으로 정의한다. Python 표준 라이브러리의 abc 모듈은 추상 기반 클래스를 작성하기 위한 ABC와 abstractmethod를 제공한다.[108]
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def load(self, key):
...
@abstractmethod
def save(self, key, value):
...
추상 메서드를 구현하지 않은 하위 클래스는 일반적으로 인스턴스를 생성할 수 없다.
class MemoryStorage(Storage):
pass
storage = MemoryStorage()
필수 메서드를 구현하면 구체 클래스가 된다.
class MemoryStorage(Storage):
def __init__(self):
self.values = {}
def load(self, key):
return self.values[key]
def save(self, key, value):
self.values[key] = value
추상 기반 클래스는 덕 타이핑을 대체하기보다, 단순한 hasattr() 검사로 정확히 표현하기 어려운 인터페이스와 명목적인 타입 관계가 필요할 때 이를 보완한다.[109]
추상 메서드
@abstractmethod를 적용한 메서드는 하위 클래스가 구현해야 할 연산을 나타낸다.
class Shape(ABC):
@abstractmethod
def area(self):
...
추상 메서드에도 기본 구현을 넣을 수 있다.
class Serializer(ABC):
@abstractmethod
def serialize(self):
return {
"type": type(self).__name__,
}
하위 클래스는 메서드를 재정의하면서 super()를 통해 기본 구현을 활용할 수 있다.
추상 프로퍼티와 클래스 메서드도 정의할 수 있다.
class Resource(ABC):
@property
@abstractmethod
def identifier(self):
...
@classmethod
@abstractmethod
def create_default(cls):
...
데코레이터 순서는 abstractmethod가 함수 객체에 가장 안쪽으로 적용되도록 구성한다.
가상 하위 클래스
추상 기반 클래스는 실제로 상속하지 않은 클래스를 가상 하위 클래스로 등록할 수 있다.
from abc import ABC
class Plugin(ABC):
pass
class ExternalPlugin:
pass
Plugin.register(ExternalPlugin)
print(issubclass(ExternalPlugin, Plugin))
print(isinstance(ExternalPlugin(), Plugin))
등록은 isinstance()와 issubclass() 관계에 반영되지만, 추상 기반 클래스의 메서드를 실제 상속시키거나 MRO에 추가하지는 않는다.
print(Plugin in ExternalPlugin.__mro__)
추상 기반 클래스는 __subclasshook__()을 통해 특정 메서드의 존재 등으로 구조적인 하위 클래스 여부를 판단할 수도 있다.
이 런타임 구조적 판단은 정적 타입 시스템의 Protocol과 목적이 일부 겹치지만 동일한 기능은 아니다. Protocol은 주로 정적 타입 검사에서 구조적 서브타이핑을 표현한다.
표준 추상 기반 클래스
Python 표준 라이브러리는 여러 공통 인터페이스를 추상 기반 클래스로 제공한다.
collections.abc에는 다음과 같은 컨테이너 인터페이스가 있다.
IterableIteratorSequenceMutableSequenceMappingMutableMappingSetCallable
from collections.abc import Mapping
def print_values(values):
if not isinstance(values, Mapping):
raise TypeError("mapping required")
for key, value in values.items():
print(key, value)
숫자 계층은 numbers, 입출력 계층은 io, 가져오기 시스템은 importlib.abc 등에 추상 기반 클래스를 제공한다.
표준 ABC는 직접 상속해 구현할 수도 있고, 일부 인터페이스는 핵심 메서드를 구현하면 나머지 믹스인 메서드를 제공한다. 다만 각 ABC가 요구하는 의미적 계약은 단순히 메서드 이름만 맞추는 것보다 넓을 수 있다.
데이터 클래스
dataclasses 모듈의 @dataclass 데코레이터는 데이터를 저장하는 클래스에 반복적으로 작성되는 특수 메서드를 자동 생성한다.[110]
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
위 클래스에는 필드 정의를 바탕으로 __init__()과 __repr__() 등의 메서드가 생성된다.
point = Point(10.0, 20.0)
print(point)
데이터 클래스는 별도의 새로운 객체 체계를 만드는 것이 아니라 일반 Python 클래스 생성 과정과 클래스 데코레이터를 이용한다. 따라서 상속, 메서드 정의, 프로퍼티와 다른 클래스 기능을 그대로 함께 사용할 수 있다.
@dataclass
class Rectangle:
width: float
height: float
def area(self):
return self.width * self.height
데이터 클래스 필드
데이터 클래스는 클래스 변수 주석을 바탕으로 필드를 식별한다.
@dataclass
class User:
name: str
age: int
active: bool = True
기본값이 없는 필드는 생성자에서 필수 인자가 되고, 기본값이 있는 필드는 선택 인자가 된다.
user = User(
name="Luna",
age=20,
)
field()를 사용하면 필드별 동작을 세밀하게 설정할 수 있다.
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list[str] = field(
default_factory=list,
)
default_factory는 각 인스턴스에 새로운 가변 객체를 생성한다. 단순히 members: list[str] = []처럼 가변 기본값을 공유하는 문제를 피할 수 있다.
필드를 표현, 비교, 초기화에서 제외할 수도 있다.
@dataclass
class Session:
token: str = field(repr=False)
cache: dict = field(
default_factory=dict,
compare=False,
)
데이터 클래스가 생성하는 메서드
@dataclass는 설정에 따라 다음 특수 메서드를 생성할 수 있다.
__init__()__repr__()__eq__()- 순서 비교 메서드
__hash__()
@dataclass(
order=True,
frozen=True,
)
class Version:
major: int
minor: int
patch: int
order=True는 필드 순서를 바탕으로 비교 메서드를 생성한다. frozen=True는 일반적인 속성 대입을 차단하여 불변 객체와 유사한 인터페이스를 제공한다.
version = Version(3, 14, 0)
version.major = 4
frozen=True가 객체 내부의 모든 상태를 깊게 불변으로 만드는 것은 아니다. 필드가 가변 객체라면 그 내부 상태는 변경될 수 있다.
@dataclass(frozen=True)
class Container:
values: list[int]
container = Container([1, 2])
container.values.append(3)
__post_init__()
데이터 클래스의 자동 생성된 __init__()이 필드를 바인딩한 뒤 추가 초기화가 필요하면 __post_init__()을 사용할 수 있다.
@dataclass
class Rectangle:
width: float
height: float
def __post_init__(self):
if self.width < 0 or self.height < 0:
raise ValueError(
"dimensions must be positive"
)
계산된 내부 필드를 설정할 수도 있다.
@dataclass
class User:
first_name: str
last_name: str
full_name: str = field(init=False)
def __post_init__(self):
self.full_name = (
f"{self.first_name} {self.last_name}"
)
InitVar를 사용하면 생성자에는 받지만 최종 필드로 저장하지 않는 초기화 전용 값을 정의할 수 있다.
상속과 데이터 클래스
데이터 클래스도 일반 클래스이므로 상속할 수 있다.
@dataclass
class Entity:
identifier: int
@dataclass
class User(Entity):
name: str
하위 데이터 클래스는 상위 데이터 클래스의 필드를 포함하여 자동 생성 메서드를 구성한다.
필드 기본값의 순서와 다중 상속에서는 제약이 발생할 수 있다. 기본값이 있는 상위 필드 뒤에 기본값이 없는 하위 필드를 배치하면 생성자 매개변수 규칙과 충돌할 수 있다.
데이터 클래스는 단순 데이터 객체에 유용하지만, 복잡한 생명 주기나 동적 속성, 세밀한 생성 제어가 필요한 클래스는 명시적인 일반 클래스로 구현하는 편이 더 분명할 수 있다.
구성
구성은 한 객체가 다른 객체를 속성으로 보유하여 기능을 조합하는 방식이다.
class Engine:
def start(self):
return "engine started"
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
return self.engine.start()
car = Car(Engine())
car.start()
Car는 Engine의 하위 타입이 아니라 엔진 객체를 포함하고 그 기능을 사용한다.
구성은 각 구성 요소를 독립적으로 교체하고 테스트할 수 있게 한다.
class ElectricEngine:
def start(self):
return "electric engine started"
car = Car(ElectricEngine())
상속은 타입 관계와 동작 확장을 나타내고, 구성은 객체 사이의 협력 관계를 나타낸다. 두 방식은 배타적이지 않으며 하나의 클래스에서 함께 사용할 수 있다.
상속과 구성의 선택
객체가 다른 타입의 한 종류로 자연스럽게 취급되고 상위 타입의 계약을 그대로 만족한다면 상속을 사용할 수 있다.
class Shape:
def area(self):
raise NotImplementedError
class Circle(Shape):
...
한 객체가 다른 기능을 사용하거나 소유하지만 같은 타입으로 볼 수 없다면 구성이 더 자연스러울 수 있다.
class Application:
def __init__(
self,
logger,
database,
renderer,
):
self.logger = logger
self.database = database
self.renderer = renderer
구성은 다음 특성을 제공한다.
- 런타임에 구현체 교체 가능
- 상속 계층에 대한 결합 감소
- 구성 요소 독립 테스트
- 필요한 인터페이스만 노출
- 다중 상속 없이 기능 조합
상속은 다음 상황에서 유용할 수 있다.
- 명확한 상위·하위 타입 관계
- 공통 인터페이스와 기본 구현
- 프레임워크가 상속 기반 확장점을 요구함
- 다형적인 객체 집합을 구성함
- 믹스인처럼 제한된 기능을 조합함
Python에서는 덕 타이핑과 프로토콜을 이용해 구성 객체도 공통 인터페이스를 자연스럽게 제공할 수 있다.
위임
위임은 객체가 받은 작업을 내부의 다른 객체에 전달하는 방식이다.
class LoggingStorage:
def __init__(self, storage):
self.storage = storage
def load(self, key):
print(f"load: {key}")
return self.storage.load(key)
def save(self, key, value):
print(f"save: {key}")
return self.storage.save(key, value)
이 구조는 상속 없이 기존 객체의 동작을 감싸거나 확장할 수 있다.
속성 접근 전체를 내부 객체로 위임할 수도 있다.
class Proxy:
def __init__(self, target):
self._target = target
def __getattr__(self, name):
return getattr(self._target, name)
__getattr__()은 일반적인 속성 조회에서 이름을 찾지 못했을 때 호출된다. 모든 속성 동작을 자동 위임하면 공개 인터페이스가 불분명해질 수 있으므로, 안정적인 API에는 주요 메서드를 명시적으로 정의하는 방식도 사용할 수 있다.
의존성 주입
클래스가 필요한 협력 객체를 내부에서 직접 생성하지 않고 생성자나 메서드 인자로 받으면 구현체를 교체하기 쉬워진다.
class UserService:
def __init__(self, repository):
self.repository = repository
def find_user(self, user_id):
return self.repository.find(user_id)
service = UserService(
DatabaseUserRepository(),
)
테스트에서는 메모리 구현체나 대체 객체를 전달할 수 있다.
service = UserService(
InMemoryUserRepository(),
)
Python의 덕 타이핑에서는 협력 객체가 특정 클래스의 인스턴스인지보다 필요한 메서드를 제공하는지가 중요할 수 있다. 정적 타입을 적용한다면 추상 기반 클래스나 Protocol로 계약을 표현할 수 있다.
의존성 주입은 구성 관계를 명시적으로 드러내고 클래스가 전역 상태나 구체 구현에 직접 의존하는 범위를 줄인다.
연산자 오버로딩
사용자 정의 클래스는 특수 메서드를 구현하여 연산자와 내장 함수에 참여할 수 있다.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(
self.x + other.x,
self.y + other.y,
)
first = Vector(1, 2)
second = Vector(3, 4)
result = first + second
객체 표현과 비교도 특수 메서드로 정의할 수 있다.
class Vector:
def __repr__(self):
return (
f"Vector(x={self.x!r}, "
f"y={self.y!r})"
)
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return (
self.x == other.x
and self.y == other.y
)
연산자 오버로딩은 타입의 개념에 자연스럽게 대응할 때 코드의 표현력을 높인다. 예상하기 어려운 의미를 기존 연산자에 부여하면 인터페이스 이해가 어려워질 수 있다.
특수 메서드와 객체 지향 인터페이스
Python 클래스는 특수 메서드를 통해 언어의 여러 프로토콜을 구현할 수 있다.
class Collection:
def __init__(self, values):
self.values = list(values)
def __len__(self):
return len(self.values)
def __iter__(self):
return iter(self.values)
def __contains__(self, value):
return value in self.values
collection = Collection([1, 2, 3])
print(len(collection))
print(2 in collection)
for value in collection:
print(value)
다른 주요 프로토콜은 다음과 같다.
__getitem__()과__setitem__()— 구독__enter__()과__exit__()— 컨텍스트 관리자__call__()— 호출 가능한 객체__hash__()과__eq__()— 해시와 동등성__bool__()— 불리언 평가__format__()— 문자열 포매팅__await__()— 기다릴 수 있는 객체
이러한 프로토콜을 통해 사용자 정의 객체가 내장 타입과 같은 문법 구조에 참여한다.
호출 가능한 객체
클래스에 __call__()을 구현하면 인스턴스를 함수처럼 호출할 수 있다.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
result = double(10)
호출 가능한 객체는 함수와 비슷한 인터페이스를 제공하면서도 상태를 유지할 수 있다.
class CallCounter:
def __init__(self, function):
self.function = function
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.function(*args, **kwargs)
상태를 가진 전략, 콜백, 검증기와 변환기 등에 활용할 수 있다. 간단한 상태는 클로저로도 구현할 수 있으며, 여러 메서드와 명시적인 상태 구조가 필요하면 클래스가 더 적합할 수 있다.
클래스 데코레이터
클래스 정의 앞에 데코레이터를 적용하면 생성된 클래스 객체를 변환하거나 등록할 수 있다.
def register(cls):
registry[cls.__name__] = cls
return cls
@register
class Plugin:
pass
이는 개념적으로 다음과 대응한다.
class Plugin:
pass
Plugin = register(Plugin)
데이터 클래스도 클래스 데코레이터를 이용한 대표적인 기능이다.
@dataclass
class Point:
x: float
y: float
클래스 데코레이터는 다음 용도로 활용할 수 있다.
- 자동 등록
- 메서드나 속성 추가
- 설정 검증
- 불변성 또는 직렬화 지원
- 프레임워크 메타데이터 처리
클래스를 완전히 다른 객체로 교체할 수도 있지만, 클래스라는 기대를 유지하지 않으면 도구와 사용자에게 혼란을 줄 수 있다.
__init_subclass__()
상위 클래스는 __init_subclass__()를 구현하여 하위 클래스가 생성될 때 실행할 동작을 정의할 수 있다.
class Plugin:
registry = {}
def __init_subclass__(
cls,
*,
name=None,
**kwargs,
):
super().__init_subclass__(**kwargs)
plugin_name = name or cls.__name__
Plugin.registry[plugin_name] = cls
class AudioPlugin(
Plugin,
name="audio",
):
pass
이 기능은 단순한 하위 클래스 등록과 검증을 위해 별도의 메타클래스를 만들 필요를 줄인다.
상위 클래스는 하위 클래스 정의에 전달된 키워드를 처리하고, 남은 인자를 super().__init_subclass__()에 전달하여 협력적 상속을 유지할 수 있다.
메타클래스
메타클래스는 클래스 객체를 생성하는 클래스이다. 일반적인 클래스의 메타클래스는 type이다.
class Service:
pass
print(type(Service) is type)
사용자 정의 메타클래스는 클래스 생성 과정을 제어할 수 있다.
class RegisteredType(type):
registry = {}
def __new__(
mcls,
name,
bases,
namespace,
):
cls = super().__new__(
mcls,
name,
bases,
namespace,
)
mcls.registry[name] = cls
return cls
class Plugin(
metaclass=RegisteredType,
):
pass
메타클래스는 클래스 이름 공간 준비, 클래스 생성, 속성 변환, 등록과 검증에 개입할 수 있다.
Python의 클래스 생성 과정은 대략 다음 단계로 이루어진다.
- 상위 클래스와 메타클래스를 결정한다.
- 클래스 이름 공간을 준비한다.
- 클래스 본문을 실행한다.
- 메타클래스를 호출하여 클래스 객체를 만든다.
- 클래스 데코레이터를 적용한다.
메타클래스는 프레임워크와 선언적 API에 활용되지만, 클래스 데코레이터나 __init_subclass__()로 충분한 기능에는 더 단순한 구조를 사용할 수 있다.
객체 지향과 동적 타입
Python의 클래스는 동적 타입 체계 위에서 동작한다. 인스턴스 속성은 런타임에 추가하거나 교체할 수 있고, 메서드 역시 클래스 객체의 속성이므로 다시 바인딩할 수 있다.
class Service:
def run(self):
return "default"
def alternative_run(self):
return "alternative"
Service.run = alternative_run
기존 인스턴스도 변경된 클래스 메서드를 조회한다.
service = Service()
print(service.run())
특정 인스턴스에만 호출 가능한 객체를 속성으로 넣을 수도 있지만, 일반 함수의 자동 메서드 바인딩은 클래스 속성 조회에서 발생한다는 차이가 있다.
service.run = lambda: "instance"
이 동적 특성은 테스트, 플러그인과 프레임워크 확장에 활용할 수 있다. 동시에 실행 중 임의 패치가 많아지면 코드의 실제 동작과 정의 위치를 추적하기 어려워질 수 있다.
몽키 패치
실행 중 기존 클래스나 모듈의 속성을 교체하는 방식을 흔히 몽키 패치라고 한다.
original_request = Client.request
def patched_request(self, url):
print(f"request: {url}")
return original_request(self, url)
Client.request = patched_request
테스트에서 외부 통신을 대체하거나 호환성 문제를 임시 조정하는 데 사용할 수 있다.
def fake_request(self, url):
return FakeResponse()
Client.request = fake_request
그러나 변경 범위가 전역이고 정의한 위치와 실행 동작이 멀어질 수 있다. 가능하다면 의존성 주입, 서브클래싱, 래퍼와 명시적인 플러그인 인터페이스를 사용할 수 있다.
불변 객체 설계
클래스 인스턴스를 불변 값처럼 설계할 수 있다. 데이터 클래스에서는 frozen=True를 사용할 수 있다.
@dataclass(frozen=True)
class Point:
x: float
y: float
일반 클래스에서는 속성 설정을 제어하거나 __slots__와 프로퍼티를 조합할 수 있다.
class Identifier:
__slots__ = ("_value",)
def __init__(self, value):
object.__setattr__(
self,
"_value",
value,
)
@property
def value(self):
return self._value
def __setattr__(self, name, value):
raise AttributeError(
"immutable object"
)
불변 객체는 값 기반 동등성, 해시와 공유에 유리할 수 있다. 다만 내부에서 참조하는 객체까지 가변이라면 깊은 불변성이 자동으로 보장되지는 않는다.
값 객체
값 객체는 정체성보다 보유한 값으로 의미가 결정되는 객체이다.
@dataclass(frozen=True)
class Coordinate:
x: int
y: int
동일한 좌표를 가진 두 객체는 값 기준으로 동등하다고 볼 수 있다.
first = Coordinate(10, 20)
second = Coordinate(10, 20)
print(first == second)
print(first is second)
값 객체는 일반적으로 다음 특성을 가진다.
- 값 기반 동등성
- 변경 불가능하거나 제한된 상태 변경
- 생성 시 유효성 검증
- 예측 가능한 표현
- 안정적인 해시 가능성
돈, 좌표, 날짜 범위, 식별자와 설정 값 등을 표현할 수 있다.
엔티티 객체
엔티티는 현재 속성값이 모두 같더라도 서로 다른 정체성을 가진 객체로 취급될 수 있다.
class User:
def __init__(
self,
user_id,
name,
):
self.user_id = user_id
self.name = name
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.user_id == other.user_id
사용자 이름이 바뀌어도 같은 식별자를 가진 객체는 같은 사용자를 나타낼 수 있다.
값 객체와 엔티티의 구분은 Python 언어 자체가 정하는 것이 아니라 응용 프로그램의 객체 모델에서 선택하는 설계이다.
객체 상태와 유효성
클래스는 객체가 허용하는 상태와 상태 전환을 메서드로 제한할 수 있다.
class Order:
def __init__(self):
self._status = "pending"
def pay(self):
if self._status != "pending":
raise RuntimeError(
"order cannot be paid"
)
self._status = "paid"
def ship(self):
if self._status != "paid":
raise RuntimeError(
"order must be paid"
)
self._status = "shipped"
외부 코드가 상태 문자열을 임의로 변경하는 대신 공개 메서드를 통해 유효한 전환만 수행하도록 구성한다.
order = Order()
order.pay()
order.ship()
단순 데이터 묶음에는 데이터 클래스가 적합할 수 있고, 상태 전환과 업무 규칙이 중요한 객체에는 명시적인 메서드 중심 설계가 적합할 수 있다.
예외와 클래스 인터페이스
메서드는 수행할 수 없는 작업을 예외로 나타낼 수 있다.
class Stack:
def __init__(self):
self._values = []
def pop(self):
if not self._values:
raise IndexError(
"pop from empty stack"
)
return self._values.pop()
클래스별 예외 계층을 정의할 수도 있다.
class StorageError(Exception):
pass
class ItemNotFoundError(
StorageError,
):
pass
공개 클래스 인터페이스에는 정상 반환값뿐 아니라 발생할 수 있는 예외와 상태 변경도 포함된다.
문서화와 표현
클래스와 메서드에는 문서 문자열을 작성할 수 있다.
class Connection:
"""원격 서버 연결을 관리한다."""
def connect(self):
"""서버에 연결한다."""
...
__repr__()은 개발과 디버깅에 적합한 객체 표현을 제공한다.
class User:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"User(name={self.name!r})"
__str__()은 사용자에게 표시할 읽기 쉬운 표현을 제공할 수 있다.
def __str__(self):
return self.name
객체 표현에는 비밀번호, 토큰과 개인정보 같은 민감한 내부 상태를 포함하지 않도록 설계할 수 있다.
객체 지향 설계와 모듈
Python의 객체 지향 구조는 모듈 및 패키지와 함께 사용된다. 클래스 하나가 모든 기능을 보유하기보다 관련 클래스와 함수를 모듈 단위로 구성할 수 있다.
project/
├── models.py
├── services.py
├── repositories.py
└── exceptions.py
클래스의 공개 인터페이스와 모듈의 공개 인터페이스는 함께 프로그램의 경계를 형성한다.
모든 기능을 하나의 거대한 클래스에 넣으면 객체 내부 상태와 책임이 과도하게 결합될 수 있다. 독립적인 계산은 함수, 상태와 동작이 결합된 개념은 클래스, 여러 객체의 협력은 서비스나 모듈로 나누는 방식이 가능하다.
Python은 하나의 파일에 여러 클래스를 둘 수 있으며 클래스마다 별도 파일을 요구하지 않는다. 클래스의 수보다 관련성, 변경 단위와 공개 인터페이스를 기준으로 모듈 구조를 선택할 수 있다.
객체 지향과 절차형 코드의 결합
Python 프로그램에서는 객체 지향 코드와 절차형·함수형 코드를 함께 사용할 수 있다.
@dataclass
class Image:
width: int
height: int
pixels: bytes
def calculate_size(image):
return image.width * image.height
def save_image(image, path):
...
데이터 구조는 클래스로 표현하고, 독립적인 처리는 모듈 함수로 구성할 수 있다.
반대로 객체의 상태와 밀접한 동작은 메서드로 둘 수 있다.
@dataclass
class Image:
width: int
height: int
pixels: bytes
def size(self):
return self.width * self.height
어느 쪽이 더 적합한지는 동작이 객체의 핵심 책임인지, 여러 타입에 적용되는 독립 알고리즘인지에 따라 달라진다.
객체 지향 구조의 활용
Python의 클래스는 다음과 같은 구조를 표현하는 데 활용된다.
- 상태를 가진 응용 프로그램 구성 요소
- 데이터 모델과 값 객체
- 사용자 인터페이스 위젯
- 데이터베이스 모델
- 네트워크 연결과 프로토콜
- 플러그인과 확장 시스템
- 반복자와 컨텍스트 관리자
- 테스트 대체 객체
- 프레임워크의 선언적 구성
- 외부 자원 수명 관리
작은 단일 작업 함수나 단순한 자료 변환까지 모두 클래스로 만들 필요는 없다. 여러 값이 하나의 개념을 이루고, 상태와 동작을 함께 유지하거나 다형적인 인터페이스가 필요할 때 클래스 구조가 특히 유용하다.
Python의 객체 지향 프로그래밍은 강제된 클래스 중심 모델보다, 함수·모듈·프로토콜과 클래스를 조합하는 형태에 가깝다. 상속은 명확한 타입 관계와 확장 지점에 사용하고, 구성과 위임은 독립적인 객체의 협력에 사용할 수 있다. 데이터 클래스와 추상 기반 클래스, 디스크립터 및 특수 메서드는 이러한 기본 모델을 다양한 수준으로 확장한다.
반복·생성·비동기 처리
Python은 여러 값을 순차적으로 처리하기 위한 반복자 프로토콜, 실행 상태를 보존하면서 값을 생산하는 제너레이터, 일시 중단 가능한 코루틴과 비동기 반복자를 제공한다. 이 기능들은 서로 분리된 독립 체계가 아니라 객체의 실행을 중단하고 이후 다시 이어가는 공통 모델 위에서 발전했다.
일반 반복에서는 호출자가 반복자에게 다음 값을 동기적으로 요청한다. 제너레이터에서는 함수가 yield 지점에서 실행 상태를 보존한 채 값을 반환하며, 다음 요청이 오면 중단한 위치부터 실행을 이어간다. 비동기 처리에서는 코루틴이 await에서 실행을 양보하고, 기다리던 작업이 완료되면 다시 실행된다.
for item in items:
process(item)
def generate_items():
yield 1
yield 2
yield 3
async def load_data():
return await request_data()
반복자와 제너레이터는 값의 흐름을 지연시키고, 코루틴과 비동기 반복자는 대기 시간이 있는 작업 사이에서 실행 흐름을 전환한다. 다만 비동기 실행이 자동으로 여러 CPU 코어에서 코드를 병렬 실행한다는 뜻은 아니다. asyncio의 일반적인 실행 모델은 이벤트 루프가 협력적으로 여러 작업을 전환하는 동시성 모델이다.[111]
반복 가능한 객체
for 문에 사용할 수 있는 객체를 반복 가능한 객체 또는 이터러블이라고 한다. 반복 가능한 객체는 일반적으로 __iter__()를 구현하여 반복자 객체를 반환한다.
class Collection:
def __init__(self, values):
self.values = list(values)
def __iter__(self):
return iter(self.values)
collection = Collection([1, 2, 3])
for value in collection:
print(value)
목록, 튜플, 문자열, 사전, 집합, 파일과 제너레이터 등은 반복 가능한 객체이다.
for character in "Python":
print(character)
반복 가능한 객체는 반복자와 같을 수도 있고, 매번 새로운 반복자를 만드는 컨테이너일 수도 있다.
values = [1, 2, 3]
first_iterator = iter(values)
second_iterator = iter(values)
print(first_iterator is second_iterator)
목록은 반복 가능한 컨테이너이며 iter()를 호출할 때마다 독립적인 목록 반복자가 만들어진다. 따라서 같은 목록을 여러 번 반복할 수 있다.
반복자
반복자는 값의 순서를 따라 현재 위치를 유지하는 객체이다. 반복자는 __iter__()와 __next__()를 구현한다.[112]
class CountDown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
iterator = CountDown(3)
print(next(iterator))
print(next(iterator))
print(next(iterator))
더 이상 반환할 값이 없으면 __next__()는 StopIteration을 발생시킨다.
next(iterator)
반복자의 __iter__()는 일반적으로 자기 자신을 반환한다.
print(iter(iterator) is iterator)
반복자는 이미 소비한 값을 자동으로 처음부터 다시 제공하지 않는다. 반복이 끝난 반복자를 다시 순회하면 보통 아무 값도 나오지 않는다.
iterator = iter([1, 2, 3])
print(list(iterator))
print(list(iterator))
첫 번째 list()가 반복자를 모두 소비하므로 두 번째 결과는 빈 목록이다.
for 문의 실행 모델
for 문은 반복 대상에서 반복자를 얻고, 반복자에서 값을 하나씩 요청한다.
for item in collection:
process(item)
개념적으로는 다음과 같은 흐름에 대응한다.
iterator = iter(collection)
while True:
try:
item = next(iterator)
except StopIteration:
break
process(item)
실제 인터프리터가 반드시 위의 Python 코드를 그대로 실행하는 것은 아니지만, 반복자 프로토콜의 의미는 이와 같다.
for 문은 반복 과정에서 인덱스를 직접 관리하지 않는다. 객체가 __iter__()를 통해 어떤 값을 어떤 순서로 제공할지 결정한다.
class ReverseCollection:
def __init__(self, values):
self.values = list(values)
def __iter__(self):
return reversed(self.values)
for value in ReverseCollection([1, 2, 3]):
print(value)
사용자 정의 객체는 내부 저장 구조와 관계없이 원하는 반복 의미를 제공할 수 있다.
반복 종료
반복자의 정상적인 종료는 StopIteration 예외로 표시된다. for 문과 대부분의 반복 소비 함수는 이 예외를 내부에서 처리하여 반복을 종료한다.
iterator = iter([1])
print(next(iterator))
try:
next(iterator)
except StopIteration:
print("finished")
next()에는 반복 종료 시 반환할 기본값을 지정할 수 있다.
iterator = iter([])
value = next(
iterator,
None,
)
기본값이 제공되면 반복이 끝났을 때 StopIteration 대신 해당 값을 반환한다.
제너레이터 함수 내부에서 직접 StopIteration을 발생시키는 방식은 일반적인 제너레이터 종료 방법이 아니다. 제너레이터는 함수 본문이 끝나거나 return을 실행하면 종료한다. PEP 479에 따라 제너레이터 내부에서 의도하지 않게 밖으로 나온 StopIteration은 RuntimeError로 변환된다.[113]
반복 소비 함수
여러 내장 함수와 표준 라이브러리 기능은 반복 가능한 객체를 입력으로 받는다.
values = [3, 1, 4, 2]
total = sum(values)
smallest = min(values)
largest = max(values)
ordered = sorted(values)
list(), tuple(), set()은 반복 가능한 객체에서 컬렉션을 생성한다.
numbers = range(5)
as_list = list(numbers)
as_tuple = tuple(numbers)
as_set = set(numbers)
any()와 all()은 필요할 때까지만 반복자를 소비한다.
has_positive = any(
value > 0
for value in values
)
any()는 참인 값을 발견하면 나머지 값을 요청하지 않는다. all()은 거짓인 값을 발견하면 반복을 중단한다.
in 연산도 객체가 별도의 포함 관계 프로토콜을 제공하지 않으면 반복을 이용해 값을 찾을 수 있다.
지연 평가
반복자는 전체 결과를 미리 만들지 않고 값이 요청될 때 하나씩 계산할 수 있다. 이를 지연 평가라고 한다.
def numbers():
print("generate 1")
yield 1
print("generate 2")
yield 2
iterator = numbers()
제너레이터 객체를 만드는 것만으로는 함수 본문이 실행되지 않는다.
first = next(iterator)
첫 값을 요청할 때 함수가 실행되어 첫 yield까지 진행한다.
지연 평가는 다음과 같은 경우에 유용하다.
- 입력의 전체 크기가 매우 큼
- 결과가 무한히 이어질 수 있음
- 일부 값만 필요함
- 처리 단계를 스트리밍 방식으로 연결함
- 중간 컬렉션 생성을 줄임
def natural_numbers():
number = 1
while True:
yield number
number += 1
무한 제너레이터는 전체 목록으로 만들 수 없지만 필요한 만큼만 소비할 수 있다.
from itertools import islice
first_ten = list(
islice(
natural_numbers(),
10,
)
)
제너레이터 함수
함수 본문에 yield 표현식이 포함되면 해당 함수는 제너레이터 함수가 된다. 제너레이터 함수를 호출하면 일반적인 반환값 대신 제너레이터 객체를 반환한다.[114]
def count_up(limit):
number = 0
while number < limit:
yield number
number += 1
generator = count_up(3)
호출 시점에는 함수 본문이 즉시 실행되지 않는다. next()나 for 문이 다음 값을 요청할 때 실행이 시작된다.
print(next(generator))
함수는 첫 번째 yield에서 값을 외부로 전달하고 실행을 중단한다. 지역 변수, 명령 위치와 예외 처리 상태는 제너레이터 객체 안에 보존된다.
print(next(generator))
다음 요청이 오면 이전 yield 직후부터 실행을 계속한다.
제너레이터는 PEP 255에서 도입되었으며, 반복자 구현에 필요한 상태 관리와 __next__() 코드를 함수 문법으로 표현할 수 있게 했다.[115]
yield 표현식
yield는 값을 호출자에게 전달하면서 현재 실행을 일시 중단한다.
def sequence():
yield 10
yield 20
yield 30
yield는 return과 달리 함수 실행을 완전히 끝내지 않는다.
generator = sequence()
print(next(generator))
print(next(generator))
print(next(generator))
각 요청 사이에 함수의 실행 상태가 유지된다.
yield는 표현식이므로 외부에서 전달된 값을 받을 수도 있다.
def receiver():
value = yield "ready"
yield f"received: {value}"
첫 번째 yield는 "ready"를 외부로 반환한다. 이후 send()로 값을 전달하면 해당 값이 yield 표현식의 결과가 된다.
generator = receiver()
print(next(generator))
print(generator.send("message"))
제너레이터를 처음 시작할 때는 아직 yield 지점에 도달하지 않았으므로 send(None)이나 next()를 사용해야 한다.
제너레이터 상태
제너레이터 객체는 다음과 같은 상태를 가질 수 있다.
- 아직 실행을 시작하지 않음
- 현재 실행 중
yield에서 중단됨- 실행을 완료함
inspect.getgeneratorstate()로 상태를 확인할 수 있다.
import inspect
generator = sequence()
print(
inspect.getgeneratorstate(
generator
)
)
제너레이터가 중단된 동안에는 실행 프레임이 유지된다.
print(generator.gi_frame)
print(generator.gi_code)
gi_frame은 현재 제너레이터 프레임, gi_code는 제너레이터 함수의 코드 객체를 가리킨다.
제너레이터가 완전히 끝나면 프레임 참조가 정리될 수 있다.
제너레이터의 return
제너레이터 함수에서 값 없는 return은 반복을 종료한다.
def limited():
yield 1
return
yield 2
값을 가진 return은 StopIteration 예외의 value에 종료값을 담는다.
def operation():
yield "working"
return "completed"
generator = operation()
print(next(generator))
try:
next(generator)
except StopIteration as stop:
print(stop.value)
일반적인 for 문은 이 종료값을 사용하지 않는다. yield from으로 다른 제너레이터에 실행을 위임할 때는 하위 제너레이터의 반환값을 받을 수 있다.
send()
제너레이터의 send()는 실행을 재개하면서 현재 중단된 yield 표현식에 값을 전달한다.
def accumulator():
total = 0
while True:
value = yield total
if value is None:
return total
total += value
generator = accumulator()
print(next(generator))
print(generator.send(10))
print(generator.send(5))
각 send() 값은 value = yield total에서 yield 표현식의 결과로 바인딩된다.
send()를 지원하는 확장 제너레이터 모델은 PEP 342에서 도입되었으며, 제너레이터를 단순한 값 생산자뿐 아니라 양방향 통신이 가능한 코루틴 형태로 사용할 수 있게 했다.[116]
네이티브 async·await가 도입된 이후 일반적인 비동기 애플리케이션에서는 생성기 기반 코루틴보다 네이티브 코루틴을 사용한다. 그러나 send()는 제너레이터 실행 모델과 일부 스트리밍 처리에서 여전히 사용할 수 있다.
throw()
throw()는 제너레이터가 중단된 위치에서 예외를 발생시킨다.
def worker():
try:
while True:
value = yield
process(value)
except ValueError:
recover()
generator = worker()
next(generator)
generator.throw(
ValueError(
"invalid value"
)
)
제너레이터 내부에서 해당 예외를 처리할 수 있다. 처리하지 않으면 예외가 throw() 호출자에게 전달된다.
throw()는 외부 제어자가 제너레이터의 현재 실행 위치로 오류나 취소 신호를 전달하는 수단이다.
close()
close()는 제너레이터의 중단 위치에서 GeneratorExit를 발생시켜 종료를 요청한다.
def resource_stream():
resource = acquire_resource()
try:
while True:
yield resource.read()
finally:
resource.close()
generator = resource_stream()
next(generator)
generator.close()
finally 블록은 제너레이터가 닫힐 때 정리 작업을 수행할 수 있다.
제너레이터가 GeneratorExit를 받은 뒤 다시 값을 yield하면 RuntimeError가 발생한다. 종료 요청을 무시하고 계속 값을 생산하는 동작은 허용되지 않는다.
제너레이터가 사용 중인 외부 자원의 즉각적인 정리가 중요하다면 소비자가 명시적으로 close()하거나 컨텍스트 관리자와 결합된 구조를 사용하는 것이 적합하다.
yield from
yield from은 다른 반복 가능한 객체나 제너레이터에 값 생산을 위임한다.
def combined():
yield from [1, 2, 3]
yield from [4, 5]
이는 단순한 값 전달에서는 다음 반복문과 비슷하다.
def combined():
for value in [1, 2, 3]:
yield value
for value in [4, 5]:
yield value
그러나 yield from은 값만 전달하지 않는다. send(), throw(), close()와 하위 제너레이터의 반환값도 위임 체계에 포함한다.[117]
def child():
yield 1
yield 2
return "child completed"
def parent():
result = yield from child()
yield result
print(list(parent()))
하위 제너레이터의 return 값은 부모 제너레이터의 yield from 표현식 결과가 된다.
yield from은 재귀적 자료구조 순회에도 사용할 수 있다.
def walk(node):
yield node
for child in node.children:
yield from walk(child)
제너레이터 표현식
제너레이터 표현식은 컴프리헨션과 유사한 문법으로 제너레이터를 생성한다.
squares = (
number * number
for number in values
)
목록 컴프리헨션은 결과 전체를 즉시 목록으로 만들지만, 제너레이터 표현식은 값을 요청할 때 계산한다.
list_result = [
number * number
for number in values
]
generator_result = (
number * number
for number in values
)
함수의 유일한 인자로 전달할 때는 제너레이터 표현식의 바깥 괄호를 추가로 쓰지 않아도 된다.
total = sum(
number * number
for number in values
)
제너레이터 표현식은 한 번 소비하면 다시 처음부터 사용할 수 없다.
generator = (
number * 2
for number in range(3)
)
print(list(generator))
print(list(generator))
복잡한 제어 흐름, 예외 처리나 여러 yield 지점이 필요하면 제너레이터 함수가 더 명확하다.
반복 처리 파이프라인
여러 제너레이터를 연결하면 중간 결과 전체를 메모리에 저장하지 않는 처리 파이프라인을 구성할 수 있다.
def read_lines(file):
for line in file:
yield line.rstrip("\n")
def remove_empty(lines):
for line in lines:
if line:
yield line
def normalize(lines):
for line in lines:
yield line.strip().lower()
with open(
"data.txt",
encoding="utf-8",
) as file:
lines = read_lines(file)
lines = remove_empty(lines)
lines = normalize(lines)
for line in lines:
process(line)
각 단계는 다음 값이 요청될 때 필요한 만큼만 실행된다.
제너레이터 표현식을 조합할 수도 있다.
lines = (
line.rstrip("\n")
for line in file
)
lines = (
line
for line in lines
if line
)
lines = (
line.lower()
for line in lines
)
지연 파이프라인에서는 실제 오류가 파이프라인을 구성하는 시점이 아니라 값을 소비하는 시점에 발생할 수 있다는 점을 고려해야 한다.
itertools
itertools 표준 라이브러리는 반복자를 생성하고 조합하는 도구를 제공한다.[118]
무한 반복자를 만들 수 있다.
from itertools import count
for number in count(1):
if number > 5:
break
print(number)
여러 반복자를 연결할 수 있다.
from itertools import chain
values = chain(
first_values,
second_values,
)
조건이 참인 동안 값을 가져오거나 조건을 만족하지 않는 초기 값을 건너뛸 수 있다.
from itertools import (
dropwhile,
takewhile,
)
조합과 순열도 지연 생성한다.
from itertools import combinations
pairs = combinations(
values,
2,
)
itertools의 결과는 대부분 반복자이므로 필요할 때만 값을 생산한다.
반복 중 컬렉션 변경
가변 컬렉션을 반복하는 동안 같은 컬렉션의 구조를 변경하면 원소를 건너뛰거나 반복 의미가 달라질 수 있다.
values = [1, 2, 3, 4]
for value in values:
if value % 2 == 0:
values.remove(value)
목록의 원소 제거로 인덱스 위치가 바뀌므로 의도하지 않은 결과가 발생할 수 있다.
새 컬렉션을 만들 수 있다.
values = [
value
for value in values
if value % 2 != 0
]
또는 복사본을 순회하면서 원본을 변경할 수 있다.
for key in list(mapping):
if should_remove(key):
del mapping[key]
사전은 반복 중 크기가 바뀌면 RuntimeError를 발생시킬 수 있다. 값만 변경하는 것과 키를 추가하거나 삭제하는 것은 서로 다른 영향을 가진다.
동기 실행과 비동기 실행
동기 함수는 호출되면 반환하거나 예외를 발생시킬 때까지 현재 호출 흐름에서 실행된다.
def load_data():
response = request()
return response.data
request()가 오래 걸리면 현재 실행 흐름은 그 작업이 끝날 때까지 기다린다.
비동기 함수는 대기 가능한 작업에서 실행을 일시 중단하고 다른 작업이 진행되도록 할 수 있다.
async def load_data():
response = await request()
return response.data
await는 현재 코루틴이 결과를 기다리는 동안 실행 제어를 이벤트 루프에 돌려줄 수 있다. 이벤트 루프는 준비된 다른 작업을 실행하고, 기다리던 작업이 완료되면 코루틴을 다시 진행한다.
비동기 처리는 네트워크, 소켓, 타이머와 여러 동시 연결처럼 대기 시간이 긴 입출력 중심 작업에 적합하다. CPU 계산 자체를 async def로 감싼다고 자동으로 병렬화되지는 않는다.
네이티브 코루틴
async def로 정의한 함수는 네이티브 코루틴 함수이다.
async def fetch_user(
user_id,
):
response = await request_user(
user_id
)
return response.data
코루틴 함수를 호출하면 본문을 즉시 끝까지 실행하지 않고 코루틴 객체를 반환한다.
coroutine = fetch_user(10)
코루틴 객체의 실행 결과를 얻으려면 이벤트 루프에서 실행하거나 다른 코루틴이 await해야 한다.
user = await fetch_user(10)
네이티브 코루틴과 async·await 문법은 PEP 492에서 도입되었다. 이는 제너레이터 기반 코루틴과 비동기 함수를 문법 및 객체 종류 차원에서 명확히 구분했다.[119]
코루틴 객체
코루틴 함수 호출 결과는 코루틴 객체이다.
async def calculate():
return 10
coroutine = calculate()
코루틴 객체를 만들기만 하고 실행하지 않으면 함수 본문은 완료되지 않는다.
result = await coroutine
일반적으로 하나의 코루틴 객체는 한 번만 기다릴 수 있다. 이미 완료된 같은 코루틴 객체를 다시 await하면 오류가 발생할 수 있다.
coroutine = calculate()
first = await coroutine
second = await coroutine
같은 코루틴 함수를 다시 실행하려면 새로운 코루틴 객체를 생성한다.
first = await calculate()
second = await calculate()
코루틴 객체를 생성한 뒤 실행하거나 닫지 않고 버리면 코루틴이 기다려지지 않았다는 런타임 경고가 발생할 수 있다.
대기 가능 객체
await의 피연산자가 될 수 있는 객체를 대기 가능 객체 또는 awaitable이라고 한다.
주요 대기 가능 객체는 다음과 같다.
- 네이티브 코루틴 객체
asyncio.Taskasyncio.Future__await__()을 구현한 객체
result = await awaitable
사용자 정의 객체도 __await__()을 구현할 수 있다.
class ImmediateValue:
def __init__(self, value):
self.value = value
def __await__(self):
async def resolve():
return self.value
return resolve().__await__()
value = await ImmediateValue(10)
일반 애플리케이션에서는 직접 __await__()을 구현하기보다 코루틴, Task와 Future를 주로 사용한다. 사용자 정의 대기 가능 객체는 비동기 라이브러리와 프레임워크의 저수준 추상화에 활용된다.
await
await는 대기 가능 객체의 완료를 기다리며 현재 코루틴의 실행을 일시 중단할 수 있다.
async def main():
result = await load_data()
print(result)
기다리는 대상이 이미 완료되어 있으면 중단 없이 결과를 즉시 받을 수도 있다. 아직 완료되지 않았다면 현재 Task는 대기 상태가 되고 이벤트 루프가 다른 작업을 실행할 수 있다.
await는 일반적으로 async def 함수 안에서 사용한다.
def regular_function():
result = await load_data()
일반 함수 안에서는 구문 오류가 발생한다.
await는 스레드를 물리적으로 정지시키는 운영체제 대기 명령과 동일하지 않다. 비동기 라이브러리가 대기 상태를 이벤트 루프에 등록하고 코루틴이 제어를 양보하도록 구성한다.
이벤트 루프
이벤트 루프는 실행 가능한 비동기 작업과 콜백을 스케줄링하고, 입출력 이벤트와 타이머를 감시한다. 작업이 await에서 대기하면 이벤트 루프는 실행할 수 있는 다른 작업을 선택한다.[120]
일반적인 애플리케이션은 asyncio.run()으로 최상위 코루틴을 실행할 수 있다.
import asyncio
async def main():
result = await load_data()
print(result)
asyncio.run(main())
asyncio.run()은 이벤트 루프를 만들고 전달된 코루틴을 완료할 때까지 실행한 뒤 비동기 제너레이터와 실행기 자원을 정리하고 이벤트 루프를 닫는다.
일반적으로 하나의 프로그램 진입점에서 한 번 사용한다. 이미 실행 중인 이벤트 루프 안에서는 다시 asyncio.run()을 호출하지 않고 기존 루프에서 await를 사용한다.
협력적 스케줄링
asyncio 이벤트 루프는 협력적 스케줄링을 사용한다. 한 시점에 하나의 Task가 Python 코드를 실행하며, Task가 대기하거나 실행을 양보할 때 다른 Task가 실행될 수 있다.[121]
async def first():
print("first: start")
await asyncio.sleep(1)
print("first: end")
async def second():
print("second: start")
await asyncio.sleep(1)
print("second: end")
두 코루틴을 Task로 만들어 함께 실행할 수 있다.
async def main():
first_task = asyncio.create_task(
first()
)
second_task = asyncio.create_task(
second()
)
await first_task
await second_task
각 Task가 asyncio.sleep()에서 대기하는 동안 다른 Task가 실행될 수 있다.
코루틴이 오랫동안 await 없이 CPU 계산을 수행하면 이벤트 루프에 제어를 돌려주지 않으므로 다른 Task가 실행되지 못한다.
async def blocking_work():
while expensive_condition():
calculate()
비동기 코드에서도 실행을 양보하는 지점과 블로킹 작업을 관리해야 한다.
Task
Task는 코루틴을 이벤트 루프에서 독립적으로 스케줄링하는 객체이다.
task = asyncio.create_task(
fetch_user(10)
)
코루틴을 직접 await하면 호출한 코루틴이 해당 작업이 끝날 때까지 기다린다.
user = await fetch_user(10)
Task로 만들면 작업을 스케줄링한 뒤 다른 처리를 진행할 수 있다.
task = asyncio.create_task(
fetch_user(10)
)
prepare_interface()
user = await task
Task 자체도 대기 가능 객체이다.
Task의 결과를 기다리지 않고 참조마저 잃으면 작업의 예외를 처리하지 못하거나 의도보다 일찍 정리될 수 있으므로, 실행 중인 Task를 적절히 보관하고 완료를 기다리는 구조가 필요하다.
여러 작업의 동시 실행
asyncio.gather()는 여러 대기 가능 객체를 동시에 스케줄링하고 입력 순서에 대응하는 결과를 모은다.
async def main():
users = await asyncio.gather(
fetch_user(1),
fetch_user(2),
fetch_user(3),
)
각 요청은 다른 요청이 완료된 뒤 시작되는 것이 아니라 함께 진행될 수 있다.
순차 실행은 다음과 같다.
first = await fetch_user(1)
second = await fetch_user(2)
third = await fetch_user(3)
첫 요청이 끝난 뒤 두 번째 요청을 시작한다.
gather()에서 작업 하나가 예외를 발생시킬 때 다른 작업과 예외를 처리하는 방식은 옵션과 호출 구조에 따라 달라진다. 서로 수명과 실패가 연결된 작업 묶음에는 TaskGroup을 사용할 수 있다.
TaskGroup
asyncio.TaskGroup은 관련된 여러 Task를 구조적으로 생성하고 모두 완료될 때까지 관리하는 비동기 컨텍스트 관리자이다.
async def main():
async with asyncio.TaskGroup() as group:
first = group.create_task(
fetch_user(1)
)
second = group.create_task(
fetch_user(2)
)
컨텍스트를 벗어날 때 그룹 안의 모든 작업이 완료된다.
작업 하나가 처리되지 않은 예외로 실패하면 그룹은 일반적으로 다른 관련 작업을 취소하고 예외들을 구조화하여 전달한다.
async with asyncio.TaskGroup() as group:
group.create_task(load_users())
group.create_task(load_posts())
group.create_task(load_comments())
TaskGroup은 작업의 생성 위치와 대기 범위를 하나의 블록으로 묶어, 별도로 생성된 Task의 수명과 예외를 놓치는 상황을 줄인다.[122]
Future
Future는 아직 완료되지 않았지만 이후 결과나 예외를 갖게 될 작업의 상태를 나타내는 저수준 대기 가능 객체이다.
loop = asyncio.get_running_loop()
future = loop.create_future()
Future에 결과를 설정할 수 있다.
future.set_result(
"completed"
)
기다리는 코루틴은 결과가 설정될 때까지 중단된다.
result = await future
Task는 코루틴 실행을 관리하는 Future의 한 종류로 동작한다. 일반 애플리케이션에서는 코루틴과 Task를 주로 사용하며, Future는 콜백 기반 코드나 저수준 비동기 API를 await 체계와 연결할 때 활용된다.[123]
비동기 대기
asyncio.sleep()은 현재 Task를 일정 시간 동안 대기시키면서 이벤트 루프에 제어를 돌려준다.
await asyncio.sleep(1)
일반적인 time.sleep()은 호출한 운영체제 스레드를 블로킹한다.
import time
time.sleep(1)
이벤트 루프 스레드에서 time.sleep()을 호출하면 그 시간 동안 다른 Task도 진행할 수 없다.
비동기 코드에서는 해당 라이브러리가 제공하는 비동기 대기 API를 사용해야 한다. 동기 라이브러리 함수 앞에 await를 붙인다고 비동기 함수가 되지는 않는다.
블로킹 작업
파일 시스템, 동기 네트워크 라이브러리, CPU 계산과 time.sleep() 같은 작업은 이벤트 루프를 블로킹할 수 있다.
async def load_file():
with open(
"large.bin",
"rb",
) as file:
return file.read()
작업이 오래 걸리면 다른 Task의 실행도 지연될 수 있다.
블로킹 함수를 별도 스레드에서 실행하려면 asyncio.to_thread()를 사용할 수 있다.
async def load_file():
return await asyncio.to_thread(
read_large_file,
"large.bin",
)
이는 이벤트 루프 스레드를 블로킹하지 않도록 작업을 다른 스레드로 넘긴다.
CPU 집약적 작업에는 프로세스 풀이나 별도의 네이티브 코드, 병렬 처리 구조를 사용할 수 있다. 스레드와 프로세스의 구체적인 선택은 작업 특성과 Python 구현체에 따라 달라진다.
취소
Task의 cancel()은 작업에 취소를 요청한다.
task = asyncio.create_task(
long_operation()
)
task.cancel()
Task가 다음 중단 지점에 도달하면 일반적으로 asyncio.CancelledError가 발생한다.
async def long_operation():
try:
await wait_for_data()
finally:
release_resource()
finally 블록을 이용해 취소 중에도 자원을 정리할 수 있다.
취소는 즉시 강제로 코드를 종료하는 동작이라기보다 예외를 통해 협력적으로 전달되는 요청이다. 코루틴이 취소 예외를 잡고 무시하거나 오랫동안 await하지 않으면 취소 완료가 지연될 수 있다.
CancelledError를 처리한 뒤 작업을 실제로 계속할 의도가 없다면 일반적으로 다시 발생시켜 취소 상태가 상위 구조에 전달되도록 한다.
async def operation():
try:
await perform()
except asyncio.CancelledError:
cleanup()
raise
시간 제한
asyncio.timeout()은 비동기 작업에 제한 시간을 적용하는 비동기 컨텍스트 관리자이다.
async with asyncio.timeout(5):
result = await load_data()
제한 시간을 넘으면 현재 작업에 취소가 전달되고 컨텍스트 밖에서는 시간 초과 예외로 처리할 수 있다.
특정 대기 가능 객체에는 asyncio.wait_for()를 사용할 수도 있다.
result = await asyncio.wait_for(
load_data(),
timeout=5,
)
시간 제한을 적용할 때는 내부 코루틴이 취소를 올바르게 처리하고 자원을 정리하는지 고려해야 한다.
경쟁 조건
이벤트 루프에서 한 시점에 하나의 Task만 Python 코드를 실행하더라도 공유 상태를 다루는 코드에 경쟁 조건이 발생할 수 있다. await 지점에서 다른 Task로 전환되며, 이후 상태가 달라질 수 있기 때문이다.
async def increment():
global counter
current = counter
await asyncio.sleep(0)
counter = current + 1
여러 Task가 같은 값을 읽은 뒤 각각 증가시키면 일부 증가가 사라질 수 있다.
비동기 잠금을 사용할 수 있다.
lock = asyncio.Lock()
async def increment():
global counter
async with lock:
counter += 1
asyncio.Lock은 이벤트 루프용 협력적 동기화 도구이다. 운영체제 스레드 사이의 동기화에 사용하는 threading.Lock과 사용 환경이 다르다.
가능하면 공유 가변 상태를 줄이고 메시지 전달, Queue나 작업 소유권 분리를 이용해 경쟁 조건의 범위를 줄일 수 있다.
비동기 반복자
비동기 반복자는 값을 얻는 과정에서 대기할 수 있는 반복자이다. __aiter__()와 __anext__()를 구현한다.[124]
class AsyncCounter:
def __init__(self, limit):
self.current = 0
self.limit = limit
def __aiter__(self):
return self
async def __anext__(self):
if self.current >= self.limit:
raise StopAsyncIteration
await asyncio.sleep(0.1)
value = self.current
self.current += 1
return value
비동기 반복은 async for로 소비한다.
async for value in AsyncCounter(3):
print(value)
__anext__()는 다음 값을 직접 반환하는 대신 대기 가능 객체를 반환하며, 결과가 준비될 때까지 코루틴을 중단할 수 있다.
반복 종료는 StopAsyncIteration으로 나타낸다.
async for
async for는 비동기 반복자에서 값을 하나씩 기다리며 가져온다.
async for message in connection:
process(message)
개념적으로는 다음 흐름에 대응한다.
iterator = connection.__aiter__()
while True:
try:
message = await iterator.__anext__()
except StopAsyncIteration:
break
process(message)
실제 인터프리터 구현은 다를 수 있지만, 각 다음 값의 획득이 await 가능한 과정이라는 점이 핵심이다.
async for는 일반적으로 async def 함수 안에서 사용한다.
네트워크 스트림, 비동기 데이터베이스 결과, 페이지 단위 API와 이벤트 스트림을 순차적으로 처리할 때 활용할 수 있다.
비동기 제너레이터
async def 함수 안에 yield가 포함되면 비동기 제너레이터 함수가 된다.
async def stream_messages():
while True:
message = await receive_message()
yield message
비동기 제너레이터 함수를 호출하면 비동기 제너레이터 객체를 반환한다.
stream = stream_messages()
값은 async for를 통해 소비할 수 있다.
async for message in stream_messages():
process(message)
비동기 제너레이터는 값을 계산하거나 가져오는 과정에서 await할 수 있고, 준비된 값을 yield로 하나씩 외부에 제공할 수 있다.
async def read_pages(client):
page = 1
while True:
response = await client.fetch_page(
page
)
if not response.items:
break
for item in response.items:
yield item
page += 1
비동기 제너레이터는 PEP 525에서 도입되었다.[125]
비동기 제너레이터의 제어
비동기 제너레이터는 동기 제너레이터와 대응하는 비동기 메서드를 제공한다.
__anext__()— 다음 값을 요청함asend()— 값을 전달하며 실행을 재개함athrow()— 예외를 전달함aclose()— 종료를 요청함
generator = stream_messages()
message = await anext(generator)
anext()에는 기본값을 지정할 수 있다.
value = await anext(
generator,
None,
)
비동기 제너레이터를 명시적으로 닫을 수 있다.
await generator.aclose()
비동기 제너레이터가 파일, 연결이나 트랜잭션을 보유한다면 종료 시 finally 블록에서 정리할 수 있다.
async def transaction_stream():
transaction = await begin_transaction()
try:
yield transaction
finally:
await transaction.close()
비동기 컴프리헨션
비동기 반복 가능한 객체를 컴프리헨션에서 사용할 수 있다.
results = [
item
async for item in stream
]
비동기 조건이나 변환을 포함할 수도 있다.
results = [
await transform(item)
async for item in stream
if item.enabled
]
비동기 제너레이터 표현식도 만들 수 있다.
generator = (
await transform(item)
async for item in stream
)
비동기 컴프리헨션은 PEP 530에서 도입되었다.[126]
결과 전체가 필요한 경우에는 목록 컴프리헨션을 사용할 수 있지만, 입력이 매우 크거나 끝이 없는 스트림이라면 비동기 제너레이터 표현식이나 async for로 순차 소비하는 것이 적합하다.
비동기 컨텍스트 관리자
비동기 컨텍스트 관리자는 블록 진입과 종료 과정에서 대기할 수 있는 객체이다. __aenter__()와 __aexit__()을 구현한다.
class AsyncConnection:
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(
self,
exc_type,
exc_value,
traceback,
):
await self.close()
async with AsyncConnection() as connection:
await connection.send(
"message"
)
async with는 진입과 종료 메서드를 await한다.[127]
비동기 연결, 세션, 잠금과 트랜잭션처럼 자원 획득과 반환 자체가 비동기 작업인 경우 사용한다.
여러 비동기 컨텍스트 관리자를 함께 사용할 수도 있다.
async with (
open_session() as session,
session.transaction() as transaction,
):
await transaction.execute()
비동기 Queue
asyncio.Queue는 비동기 생산자와 소비자 사이에서 값을 전달할 수 있다.
queue = asyncio.Queue()
생산자는 값을 넣는다.
async def producer():
for item in items:
await queue.put(item)
await queue.put(None)
소비자는 값을 기다린다.
async def consumer():
while True:
item = await queue.get()
try:
if item is None:
return
await process(item)
finally:
queue.task_done()
Queue가 비어 있으면 get()은 새 값이 들어올 때까지 현재 Task를 중단한다. 크기 제한이 있는 Queue가 가득 차면 put()도 공간이 생길 때까지 기다릴 수 있다.
생산 속도와 소비 속도가 다를 때 Queue의 최대 크기를 제한하면 처리 압력을 상위 단계로 전달하는 백프레셔 구조를 만들 수 있다.
비동기 이터레이션과 백프레셔
비동기 반복에서는 소비자가 다음 값을 요청할 때 생산자가 값을 준비한다. 생산자가 모든 결과를 미리 만들어 버퍼에 쌓을 필요가 없다.
async for chunk in response.iter_chunks():
await write_chunk(chunk)
각 청크를 처리한 뒤 다음 청크를 요청하므로 소비 속도에 맞춰 데이터 흐름을 조절할 수 있다.
다만 실제 네트워크 라이브러리와 운영체제 버퍼에는 별도의 내부 버퍼가 존재할 수 있다. 비동기 반복 문법만으로 시스템 전체의 메모리 사용량이 자동으로 제한되는 것은 아니다.
작업을 무제한으로 Task로 생성하면 입력은 지연 반복되더라도 동시에 실행 중인 작업 수가 계속 증가할 수 있다.
tasks = [
asyncio.create_task(
process(item)
)
async for item in stream
]
세마포어, 제한된 Queue나 고정된 수의 소비자 Task를 사용해 동시 실행량을 제한할 수 있다.
세마포어
asyncio.Semaphore는 동시에 특정 구역에 들어갈 수 있는 Task 수를 제한한다.
semaphore = asyncio.Semaphore(10)
async def limited_request(url):
async with semaphore:
return await request(url)
여러 요청을 Task로 실행하더라도 실제 네트워크 요청은 한 번에 최대 10개만 진행된다.
results = await asyncio.gather(
*(
limited_request(url)
for url in urls
)
)
외부 API의 연결 제한, 데이터베이스 풀 크기와 메모리 사용량을 고려해 동시성을 조절할 수 있다.
비동기 잠금과 조건 변수
asyncio는 같은 이벤트 루프의 Task 사이에서 사용할 동기화 도구를 제공한다.
Lock은 임계 구역을 한 Task씩 실행하게 한다.
lock = asyncio.Lock()
async with lock:
update_shared_state()
Event는 특정 상태가 준비되었다는 신호를 여러 Task에 전달한다.
ready = asyncio.Event()
async def waiter():
await ready.wait()
start_work()
ready.set()
Condition은 잠금과 상태 조건 대기를 결합하고, Semaphore는 동시에 접근 가능한 수를 제한한다.
이 도구들은 운영체제 스레드용 동기화 객체와 목적이 비슷하지만 이벤트 루프의 협력적 대기를 사용한다.
비동기 예외
코루틴에서 발생한 예외는 해당 코루틴을 기다리는 호출자에게 전달된다.
async def load():
raise ValueError(
"invalid response"
)
async def main():
try:
await load()
except ValueError as error:
handle(error)
Task 안에서 발생한 예외도 Task를 await할 때 전달된다.
task = asyncio.create_task(
load()
)
await task
Task의 결과를 기다리지 않으면 예외가 적절한 호출 흐름에서 처리되지 않을 수 있다.
여러 관련 작업이 동시에 실패하면 TaskGroup이 ExceptionGroup 형태로 여러 예외를 전달할 수 있다.
try:
async with asyncio.TaskGroup() as group:
group.create_task(first())
group.create_task(second())
except* NetworkError as errors:
handle_network_errors(
errors
)
비동기 자원 정리
비동기 작업은 취소, 예외와 조기 반환을 고려하여 자원을 정리해야 한다.
async def use_connection():
connection = await connect()
try:
await connection.run()
finally:
await connection.close()
비동기 컨텍스트 관리자로 정리 규칙을 캡슐화할 수 있다.
async with connect() as connection:
await connection.run()
비동기 제너레이터가 아직 완료되지 않은 상태로 버려지면 finally에 포함된 비동기 정리가 필요할 수 있다. 이벤트 루프는 비동기 제너레이터 종료를 관리하는 훅과 종료 절차를 제공한다.
프로그램의 최상위 종료 시에는 실행 중인 Task, 비동기 제너레이터와 실행기 자원을 정리해야 한다. asyncio.run()은 일반적인 최상위 실행에서 이러한 종료 절차를 수행한다.
동기 반복자와 비동기 반복자의 차이
동기 반복자는 next()가 반환될 때까지 호출 흐름을 점유한다.
value = next(iterator)
비동기 반복자는 다음 값을 준비하는 동안 실행을 양보할 수 있다.
value = await anext(
async_iterator
)
동기 반복은 다음과 같은 경우에 적합하다.
- 메모리 안의 컬렉션
- 빠른 계산 결과
- 동기 파일 처리
- 별도의 대기가 없는 변환
비동기 반복은 다음과 같은 경우에 적합하다.
- 네트워크 스트림
- 비동기 데이터베이스 결과
- 메시지 큐
- 이벤트 스트림
- 페이지 단위 원격 API
- 각 값 준비에 비동기 대기가 필요함
일반 반복 가능한 객체는 async for에서 자동으로 비동기 객체가 되지 않으며, 비동기 반복자도 일반 for에서 직접 소비할 수 없다.
제너레이터와 비동기 제너레이터의 차이
일반 제너레이터는 yield 사이에서 동기 코드를 실행한다.
def read_values():
for value in source:
yield transform(value)
비동기 제너레이터는 값 준비 과정에서 await할 수 있다.
async def read_values():
while True:
value = await source.read()
if value is None:
return
yield transform(value)
일반 제너레이터는 for, next()와 yield from으로 처리한다. 비동기 제너레이터는 async for, anext()와 비동기 제너레이터 메서드로 처리한다.
현재 일반적인 비동기 제너레이터 안에서는 동기 제너레이터의 yield from을 그대로 사용할 수 없으며, 다른 비동기 반복자를 위임하려면 async for로 값을 전달하는 구조를 사용할 수 있다.
async def delegate(stream):
async for item in stream:
yield item
코루틴과 제너레이터의 차이
제너레이터 함수와 코루틴 함수는 모두 실행 상태를 중단하고 재개할 수 있지만, 목적과 프로토콜이 다르다.
제너레이터는 주로 여러 값을 순차적으로 생산한다.
def generate():
yield 1
yield 2
코루틴은 비동기 작업의 완료 결과를 반환한다.
async def fetch():
result = await request()
return result
제너레이터 객체는 반복자이며 next()로 값을 요청한다. 코루틴 객체는 대기 가능 객체이며 await로 결과를 기다린다.
async def 안에 yield가 있으면 일반 코루틴이 아니라 비동기 제너레이터가 된다. 비동기 제너레이터에서는 값을 가진 return을 사용할 수 없다.
동시성·병렬성·비동기의 구분
동시성은 여러 작업이 같은 기간에 진행되는 구조를 뜻한다. 이벤트 루프가 작업 사이를 전환하면 한 스레드에서도 동시성을 구현할 수 있다.
병렬성은 여러 작업이 실제 같은 시각에 서로 다른 실행 자원에서 수행되는 것을 뜻한다. 여러 CPU 코어나 프로세스, 일부 네이티브 스레드 실행이 이에 해당할 수 있다.
비동기 처리는 작업 완료를 현재 실행 흐름에서 계속 블로킹하며 기다리지 않고, 완료 시점에 다시 실행을 이어갈 수 있는 구조이다.
asyncio는 주로 비동기 입출력과 협력적 동시성을 제공한다. CPU 집약적 계산을 여러 코어에서 실행하려면 프로세스 기반 병렬 처리나 적절한 네이티브 구현을 사용할 수 있다.
비동기, 스레드와 프로세스는 서로 배타적이지 않다. 이벤트 루프가 대부분의 입출력을 관리하면서 일부 블로킹 작업을 스레드 풀에 보내고 CPU 작업을 프로세스 풀에 전달하는 혼합 구조도 가능하다.
비동기 코드와 스레드
이벤트 루프는 일반적으로 특정 운영체제 스레드에서 실행된다. 다른 스레드에서 이벤트 루프에 작업을 전달할 때는 스레드 안전한 인터페이스를 사용해야 한다.
loop.call_soon_threadsafe(
callback,
argument,
)
다른 스레드에서 코루틴을 이벤트 루프에 제출할 수 있다.
future = asyncio.run_coroutine_threadsafe(
coroutine(),
loop,
)
일반적인 asyncio 객체 대부분은 스레드 안전하지 않으므로 임의의 스레드에서 직접 조작하지 않는다.
asyncio.to_thread()는 현재 컨텍스트를 전달하면서 동기 함수를 별도 스레드에서 실행하는 고수준 인터페이스이다.
비동기 코드의 실행 진입점
비동기 프로그램은 일반적으로 동기 최상위 코드에서 asyncio.run()으로 시작한다.
import asyncio
async def main():
await start_server()
if __name__ == "__main__":
asyncio.run(main())
main() 호출은 코루틴 객체를 만들고, asyncio.run()이 이를 이벤트 루프에서 실행한다.
라이브러리는 일반적으로 내부에서 임의로 asyncio.run()을 호출하기보다 코루틴 함수를 제공하여 호출자가 자신의 이벤트 루프에서 await할 수 있도록 한다.
async def load_configuration():
...
응용 프로그램이 이벤트 루프의 소유권과 종료 시점을 관리하면 여러 비동기 라이브러리를 같은 실행 환경에서 조합하기 쉽다.
반복과 비동기 처리의 타입 표기
동기 반복 가능한 객체는 Iterable, 반복자는 Iterator로 표기할 수 있다.
from collections.abc import (
Iterable,
Iterator,
)
def normalize(
values: Iterable[str],
) -> Iterator[str]:
for value in values:
yield value.strip().lower()
값만 생산하는 제너레이터는 Iterator[T]로 표현할 수 있다. send()와 반환값까지 설명하려면 Generator를 사용할 수 있다.
from collections.abc import Generator
def accumulator() -> Generator[
int,
int,
int,
]:
total = 0
while True:
value = yield total
if value < 0:
return total
total += value
제네릭 인자는 차례대로 생산하는 값, send()로 받는 값과 최종 반환값을 나타낸다.
비동기 반복 가능한 객체는 AsyncIterable, 비동기 반복자는 AsyncIterator로 표기한다.
from collections.abc import (
AsyncIterator,
)
async def stream() -> AsyncIterator[
bytes
]:
while chunk := await read_chunk():
yield chunk
일반 코루틴 함수의 반환 주석은 await한 뒤 얻는 결과 타입을 나타낸다.
async def load_user(
user_id: int,
) -> User:
...
호출 자체는 코루틴 객체를 반환하지만, 타입 표기는 일반적으로 await load_user()의 결과가 User임을 표현한다.
디버깅
비동기 프로그램에서는 작업 전환으로 인해 호출 흐름을 추적하기 어려울 수 있다. asyncio는 디버그 모드와 Task 검사 기능을 제공한다.
asyncio.run(
main(),
debug=True,
)
실행 중인 모든 Task를 확인할 수 있다.
tasks = asyncio.all_tasks()
현재 Task를 가져올 수 있다.
task = asyncio.current_task()
Task에는 이름을 지정할 수 있다.
task = asyncio.create_task(
worker(),
name="data-worker",
)
비동기 코드에서 발생하는 일반적인 문제는 다음과 같다.
- 코루틴을 만들고
await하지 않음 - Task 결과와 예외를 회수하지 않음
- 이벤트 루프에서 블로킹 함수를 호출함
- 무제한으로 Task를 생성함
- 취소 예외를 부적절하게 삼킴
- 비동기 자원을 닫지 않음
- 공유 상태를
await사이에서 잘못 관리함
반복·생성·비동기 모델의 관계
Python의 반복과 비동기 모델은 객체에 정의된 프로토콜을 통해 확장된다.
동기 반복은 다음 관계를 가진다.
반복 가능한 객체
→ `__iter__()`
→ 반복자
→ `__next__()`
→ 값 또는 `StopIteration`
제너레이터는 반복자 프로토콜을 함수 문법으로 구현한다.
제너레이터 함수 호출
→ 제너레이터 객체
→ `next()` 또는 `send()`
→ `yield`까지 실행
→ 상태 보존
→ 다음 요청에서 재개
비동기 실행은 대기 가능 객체와 이벤트 루프를 이용한다.
코루틴 함수 호출
→ 코루틴 객체
→ Task로 스케줄링하거나 `await`
→ 대기 지점에서 실행 양보
→ 다른 Task 실행
→ 작업 완료 후 재개
비동기 반복은 반복자와 대기 가능 객체를 결합한다.
비동기 반복 가능한 객체
→ `__aiter__()`
→ 비동기 반복자
→ `await __anext__()`
→ 값 또는 `StopAsyncIteration`
이 구조에서 반복자, 제너레이터, 코루틴과 비동기 제너레이터는 모두 실행 흐름을 단계적으로 진행한다. 차이는 다음 값을 동기적으로 요청하는지, 함수 상태를 yield로 보존하는지, 외부 작업을 await하며 이벤트 루프에 실행을 양보하는지에 있다.
Python은 이러한 기능을 이용해 메모리 효율적인 데이터 처리, 스트리밍, 대규모 네트워크 동시성, 이벤트 기반 애플리케이션과 비동기 서비스 구조를 같은 객체 및 함수 모델 위에서 구성할 수 있다.
예외와 자원 관리
Python은 프로그램 실행 중 발생한 오류와 비정상적인 상태를 예외 객체로 표현한다. 예외가 발생하면 현재의 정상적인 실행 흐름이 중단되고, 호출 스택을 거슬러 올라가면서 해당 예외를 처리할 수 있는 except 절을 찾는다. 적절한 처리기가 발견되면 그 위치에서 복구나 대체 동작을 수행할 수 있고, 처리되지 않으면 프로그램의 최상위 실행 환경까지 전파되어 트레이스백과 함께 보고된다.[128]
def divide(left, right):
return left / right
result = divide(10, 0)
위 코드는 0으로 나누기를 시도하므로 ZeroDivisionError를 발생시킨다. 이 예외를 처리하지 않으면 인터프리터는 예외가 발생하기까지의 함수 호출 경로와 소스 위치를 트레이스백으로 출력한다.
Python의 예외 처리는 오류 코드를 반환하는 방식과 달리 정상적인 반환값과 실패 흐름을 분리한다.
def load_user(user_id):
user = database.find(user_id)
if user is None:
raise UserNotFoundError(user_id)
return user
정상적인 경우에는 사용자 객체를 반환하고, 사용자를 찾을 수 없는 경우에는 별도의 예외 흐름을 사용한다. 호출자는 실제로 처리할 수 있는 위치에서 해당 예외를 잡을 수 있다.
try:
user = load_user(user_id)
except UserNotFoundError:
show_missing_user_page()
예외 처리는 모든 오류를 무시하고 실행을 계속하기 위한 기능이 아니다. 처리할 수 있는 예외만 적절한 수준에서 잡고, 처리할 수 없는 예외는 상위 호출자에게 전달하는 것이 기본적인 구조이다.
문법 오류와 예외
Python에서 프로그램을 실행할 수 없는 문제는 크게 문법 오류와 실행 중 예외로 구분할 수 있다.
문법 오류는 소스 코드가 Python 문법에 맞지 않아 파서가 프로그램 구조를 만들 수 없을 때 발생한다.
if value > 10
print(value)
조건식 뒤에 콜론이 없으므로 SyntaxError가 발생한다. 문법 오류가 있는 모듈은 정상적으로 컴파일하거나 실행할 수 없다.
들여쓰기 구조가 잘못된 경우에는 IndentationError 또는 TabError가 발생할 수 있다.
def greet():
print("Hello")
예외는 문법적으로 유효한 프로그램을 실행하는 과정에서 발생한다.
number = int("Python")
소스 코드는 문법적으로 올바르지만 문자열을 정수로 변환할 수 없으므로 ValueError가 발생한다.
이름을 찾지 못하면 NameError, 객체가 지원하지 않는 속성에 접근하면 AttributeError, 연산에 적합하지 않은 타입을 사용하면 TypeError가 발생할 수 있다.
print(missing_name)
"Python".append("!")
10 + "20"
문법 오류는 일반적으로 프로그램을 실행하기 전에 수정해야 한다. 실행 중 예외는 입력값, 외부 환경과 프로그램 상태에 따라 정상적인 프로그램에서도 발생할 수 있으며, 필요한 경우 예외 처리 구조로 대응한다.[129]
예외 객체
Python에서 예외도 일반 객체이다. 예외 클래스의 인스턴스가 발생하며, 객체에는 오류 메시지와 추가 정보가 포함될 수 있다.
error = ValueError(
"age must be positive"
)
raise error
예외 클래스를 직접 raise할 수도 있다.
raise ValueError
이 경우 인터프리터가 인자 없는 예외 인스턴스를 생성한다. 오류의 구체적인 의미를 전달하려면 일반적으로 메시지나 관련 데이터를 포함한 인스턴스를 발생시킨다.
raise ValueError(
f"invalid age: {age}"
)
예외를 잡으면 as를 이용해 예외 객체를 이름에 바인딩할 수 있다.
try:
age = int(text)
except ValueError as error:
print(type(error))
print(str(error))
print(error.args)
기본 예외 객체의 args에는 생성자에 전달된 인자들이 튜플로 저장된다. 일부 내장 예외는 파일 이름, 잘못된 키, 인덱스나 운영체제 오류 번호처럼 추가 속성을 제공한다.
예외 계층
모든 내장 예외의 공통 기반 클래스는 BaseException이다. 일반적인 프로그램 오류를 나타내는 대부분의 예외는 그 하위 클래스인 Exception에서 파생된다.[130]
대표적인 구조는 다음과 같다.
BaseException
├── BaseExceptionGroup
├── GeneratorExit
├── KeyboardInterrupt
├── SystemExit
└── Exception
├── ArithmeticError
├── AssertionError
├── AttributeError
├── EOFError
├── ImportError
├── LookupError
├── NameError
├── OSError
├── RuntimeError
├── StopIteration
├── SyntaxError
├── TypeError
└── ValueError
KeyboardInterrupt, SystemExit와 GeneratorExit은 일반적인 애플리케이션 오류와 성격이 다르므로 Exception의 하위 클래스가 아니다.
다음 처리기는 대부분의 일반적인 프로그램 예외를 잡지만 KeyboardInterrupt와 SystemExit은 잡지 않는다.
try:
run_application()
except Exception as error:
report_error(error)
반면 인자 없는 except는 BaseException 계층 전체를 잡는다.
try:
run_application()
except:
pass
이 구조는 사용자의 중단 요청과 정상적인 프로그램 종료까지 삼킬 수 있으므로 일반적으로 피한다. 꼭 최상위 정리나 로깅이 필요하다면 예외를 다시 발생시키는 방식이 필요할 수 있다.
try:
run_application()
except BaseException:
cleanup()
raise
주요 내장 예외
TypeError는 연산이나 함수가 받은 객체의 타입이 지원되지 않거나 호출 규칙이 맞지 않을 때 발생한다.
len(10)
def connect(host, port):
...
connect("localhost")
값의 타입은 허용되지만 구체적인 값이 유효하지 않은 경우에는 ValueError를 사용한다.
int("not a number")
import math
math.sqrt(-1)
LookupError는 인덱스와 키 조회 오류의 기반 클래스이다. 목록 범위를 벗어나면 IndexError, 사전에 없는 키를 직접 조회하면 KeyError가 발생한다.
values = [1, 2]
print(values[5])
configuration = {}
print(configuration["host"])
존재하지 않는 이름에는 NameError, 함수의 지역 이름이 바인딩되기 전에 사용되면 그 하위 클래스인 UnboundLocalError가 발생할 수 있다.
value = 10
def show():
print(value)
value = 20
존재하지 않는 객체 속성에는 AttributeError가 발생한다.
object_without_name.name
파일과 소켓, 운영체제 자원의 실패는 주로 OSError 계층으로 표현된다. FileNotFoundError, PermissionError, ConnectionError와 TimeoutError 등이 이 계층에 포함된다.
with open(
"missing.txt",
encoding="utf-8",
) as file:
contents = file.read()
RuntimeError는 더 구체적인 예외로 표현되지 않는 런타임 오류에 사용된다. 반복자 종료의 StopIteration, 비동기 반복 종료의 StopAsyncIteration과 재귀 한계의 RecursionError도 런타임 실행 모델과 관련된 예외이다.
예외 발생
raise 문은 예외를 명시적으로 발생시킨다.[131]
if age < 0:
raise ValueError(
"age must not be negative"
)
예외에는 문제를 이해하고 처리하는 데 필요한 정보를 포함할 수 있다.
if balance < amount:
raise InsufficientFundsError(
account_id=account.identifier,
balance=balance,
requested=amount,
)
이미 처리 중인 예외를 그대로 다시 발생시키려면 인자 없는 raise를 사용한다.
try:
load_configuration()
except ConfigurationError:
log_failure()
raise
raise error도 예외를 발생시키지만, 현재 처리 중인 예외를 그대로 전파하려는 경우에는 인자 없는 raise가 원래 트레이스백 흐름을 유지하는 데 적합하다.
예외는 가능한 한 오류가 발견된 위치 가까이에서 발생시킨다.
def set_port(port):
if not 1 <= port <= 65535:
raise ValueError(
f"invalid port: {port}"
)
self.port = port
잘못된 상태를 더 깊은 코드까지 전달한 뒤 우연한 다른 예외가 발생하게 하는 것보다, 실제 계약을 위반한 지점에서 구체적인 예외를 발생시키는 편이 원인을 명확하게 한다.
try와 except
try 문은 예외가 발생할 수 있는 코드와 처리기를 연결한다.
try:
value = int(text)
except ValueError:
value = 0
try 블록을 실행하다 예외가 발생하면 남은 문장은 실행되지 않고 except 절을 위에서부터 검사한다.
try:
value = int(text)
result = 100 / value
save(result)
except ValueError:
handle_invalid_number()
except ZeroDivisionError:
handle_zero()
예외 객체의 클래스가 except에 지정된 클래스와 같거나 그 실제 기반 클래스의 하위 타입이면 일치한다. 첫 번째로 일치한 처리기만 실행된다.[132]
따라서 구체적인 예외를 일반적인 예외보다 앞에 배치한다.
try:
operation()
except FileNotFoundError:
handle_missing_file()
except OSError:
handle_other_os_error()
순서를 반대로 하면 FileNotFoundError도 먼저 나오는 OSError 처리기에 잡혀 구체 처리기가 실행되지 않는다.
여러 예외 처리
여러 예외에 같은 처리를 적용하려면 예외 타입의 튜플을 사용할 수 있다.
try:
value = load_value()
except (
ValueError,
TypeError,
):
use_default()
Python 3.14부터 as 절이 없는 경우에는 여러 예외 타입을 둘러싼 괄호를 생략할 수도 있다.
try:
value = load_value()
except ValueError, TypeError:
use_default()
그러나 기존 Python 버전과의 호환성을 유지해야 하는 코드에서는 튜플 형식을 계속 사용할 수 있다.[133]
예외 객체가 필요하면 as를 사용한다.
try:
value = int(text)
except ValueError as error:
log.warning(
"invalid value: %s",
error,
)
이때 여러 타입을 함께 잡으려면 괄호로 묶는다.
try:
operation()
except (
ValueError,
TypeError,
) as error:
report(error)
처리 범위 최소화
try 블록은 실제로 해당 예외가 발생할 수 있는 코드만 포함하도록 좁게 구성하는 것이 좋다.
try:
value = int(text)
except ValueError:
value = 0
result = calculate(value)
save(result)
다음처럼 넓은 영역을 감싸면 calculate()나 save()에서 우연히 발생한 ValueError까지 입력 변환 오류로 잘못 처리할 수 있다.
try:
value = int(text)
result = calculate(value)
save(result)
except ValueError:
value = 0
예외 처리기는 어떤 작업에서 발생한 어떤 실패를 처리하는지 명확해야 한다. 처리 범위가 좁을수록 관련 없는 버그를 숨길 가능성이 줄어든다.
else 절
try 문의 else 절은 try 블록이 예외 없이 완료되었을 때 실행된다.
try:
value = int(text)
except ValueError:
handle_invalid_input()
else:
process(value)
process()를 try 블록 밖이 아니라 else에 두면, process()에서 발생한 ValueError가 입력 변환 오류 처리기에 잘못 잡히지 않는다.
반환값을 받는 작업과 그 결과를 처리하는 작업을 구분할 수 있다.
try:
file = open(
path,
encoding="utf-8",
)
except OSError as error:
report_open_failure(error)
else:
with file:
contents = file.read()
else는 반드시 필요한 문법은 아니지만, 예외를 예상하는 작업과 성공 후 실행할 코드를 분명히 나눌 수 있다.
finally 절
finally 절은 try 문을 어떤 방식으로 빠져나오든 정리 동작을 수행한다.
resource = acquire_resource()
try:
use_resource(resource)
finally:
release_resource(resource)
정상 실행, 처리되지 않은 예외, return, break나 continue가 발생해도 finally는 실행된다.
def read_value():
resource = acquire_resource()
try:
return resource.read()
finally:
resource.close()
반환할 값이 계산된 뒤에도 함수가 실제로 끝나기 전에 resource.close()가 실행된다.
finally 안에서 새 예외가 발생하면 원래의 예외와 연결되어 전파될 수 있다.
try:
operation()
finally:
cleanup()
operation()과 cleanup()이 모두 실패하면 정리 과정의 예외가 현재 전파되는 예외가 되고, 원래 예외는 문맥으로 연결된다.
finally에서의 제어 흐름
finally 블록 안에서 return, break나 continue로 블록 밖으로 빠져나가면 처리 중이던 예외나 이전 반환값이 사라질 수 있다.
def operation():
try:
raise ValueError(
"original failure"
)
finally:
return "completed"
이 함수는 ValueError를 전파하지 않고 "completed"를 반환한다. 오류를 의도치 않게 숨길 가능성이 크다.
Python 3.14에서는 finally 블록을 빠져나가는 return, break 또는 continue에 컴파일러가 SyntaxWarning을 발생시킨다.[134]
정리 블록은 자원을 반환하고 상태를 복원하는 작업에 집중하고, 함수 반환과 반복 제어는 finally 밖에서 수행하는 것이 적합하다.
def operation():
try:
result = calculate()
finally:
cleanup()
return result
예외 전파
현재 try 문에서 일치하는 처리기를 찾지 못하면 예외는 바깥 코드 블록과 호출 스택으로 전파된다.
def parse(text):
return int(text)
def load():
return parse("invalid")
def main():
try:
load()
except ValueError:
print("invalid input")
ValueError는 parse()에서 발생하지만 load()를 지나 main()의 처리기에서 잡힌다.
예외는 호출자가 더 많은 문맥을 가지고 복구할 수 있는 상위 계층에서 처리할 수 있다. 낮은 수준의 함수가 실제 복구 방법을 모른다면 단순히 예외를 전파하거나 더 적합한 도메인 예외로 변환할 수 있다.
def load_configuration(path):
try:
return read_configuration(path)
except OSError as error:
raise ConfigurationError(
f"cannot load {path}"
) from error
예외 다시 발생시키기
예외를 기록하거나 부분적인 정리를 한 뒤 같은 예외를 상위 호출자에게 전달하려면 인자 없는 raise를 사용한다.
try:
send_request()
except NetworkError:
metrics.increment(
"network_failures"
)
raise
이 처리기는 예외를 해결한 것이 아니라 관찰한 뒤 다시 전달한다.
예외를 잡고 아무 작업도 하지 않으면 오류가 완전히 사라진다.
try:
send_request()
except NetworkError:
pass
정말로 실패를 무시해도 되는지, 기본값이나 로그가 필요한지, 시스템 상태가 일관된지를 고려해야 한다.
예외 연결
예외를 처리하는 도중 다른 예외가 발생하면 Python은 두 예외의 관계를 자동으로 기록한다.
try:
int("invalid")
except ValueError:
raise RuntimeError(
"configuration failed"
)
최종 트레이스백에는 원래 ValueError를 처리하던 중 RuntimeError가 발생했다는 문맥이 표시된다. 원래 예외는 새 예외의 __context__에 연결된다.
명시적인 원인을 지정하려면 raise ... from ...을 사용한다.
try:
data = json.loads(source)
except json.JSONDecodeError as error:
raise ConfigurationError(
"invalid configuration format"
) from error
이 경우 원래 예외는 __cause__에 연결되며, 트레이스백은 직접적인 원인 관계를 나타낸다. 예외 연결 기능은 PEP 3134에서 도입되었다.[135]
저수준 구현 세부를 사용자에게 노출할 필요가 없으면 from None으로 기본 트레이스백의 문맥 표시를 억제할 수 있다.
try:
return mapping[key]
except KeyError:
raise AttributeError(key) from None
원래 문맥 정보가 객체에서 완전히 삭제되는 것은 아니지만 기본 예외 출력에서는 숨겨진다.
트레이스백
처리되지 않은 예외에는 예외가 전파된 실행 프레임의 연속인 트레이스백이 연결된다.
try:
operation()
except Exception as error:
traceback = error.__traceback__
트레이스백은 오류가 발생한 함수와 소스 위치를 추적하는 데 사용된다.
traceback 표준 라이브러리는 예외와 스택 정보를 출력하거나 문자열로 변환하는 기능을 제공한다.
import traceback
try:
operation()
except Exception:
traceback.print_exc()
로그 시스템에서는 예외 객체의 문자열만 기록하기보다 트레이스백 전체를 함께 남기는 것이 원인 분석에 유용하다.
try:
operation()
except Exception:
logger.exception(
"operation failed"
)
logger.exception()은 현재 처리 중인 예외 정보와 트레이스백을 기록한다.
트레이스백은 실행 프레임을 참조하고 프레임은 지역 객체를 참조할 수 있다. 예외 객체와 트레이스백을 장기간 저장하면 관련 객체의 수명이 예상보다 길어질 수 있다.
예외 메모
예외 객체의 add_note()를 사용하면 기존 예외에 추가 설명을 붙일 수 있다.
try:
process_record(record)
except ValueError as error:
error.add_note(
f"record id: {record.identifier}"
)
raise
예외 메모는 트레이스백에 함께 표시되며, 예외 클래스나 원래 메시지를 바꾸지 않고 상위 계층의 문맥을 추가할 수 있다.
try:
load_plugin(plugin_name)
except PluginError as error:
error.add_note(
f"plugin: {plugin_name}"
)
error.add_note(
f"configuration: {path}"
)
raise
이 기능은 PEP 678에서 도입되었다.[136]
예외를 새로운 타입으로 변환해야 하는 경우에는 예외 연결이 적합하고, 같은 예외를 유지하면서 진단 정보를 보충하려면 메모가 적합할 수 있다.
사용자 정의 예외
프로그램은 Exception 또는 그 하위 클래스를 상속하여 자체 예외를 정의할 수 있다.
class ApplicationError(Exception):
pass
도메인별 하위 예외를 구성할 수 있다.
class ConfigurationError(
ApplicationError
):
pass
class AuthenticationError(
ApplicationError
):
pass
class ResourceNotFoundError(
ApplicationError
):
pass
호출자는 특정 오류만 처리하거나 전체 애플리케이션 오류를 함께 처리할 수 있다.
try:
start_application()
except ConfigurationError:
show_configuration_help()
except ApplicationError as error:
show_application_error(error)
사용자 정의 예외는 일반적으로 단순한 클래스이며, 필요한 진단 데이터를 속성으로 저장할 수 있다.
class InsufficientFundsError(
ApplicationError
):
def __init__(
self,
account_id,
balance,
requested,
):
self.account_id = account_id
self.balance = balance
self.requested = requested
super().__init__(
f"account {account_id} has "
f"{balance}, requested "
f"{requested}"
)
호출자는 문자열을 다시 해석하지 않고 구조화된 속성을 사용할 수 있다.
except InsufficientFundsError as error:
show_balance(error.balance)
예외 클래스 이름은 일반적으로 Error로 끝낸다.
예외 계층 설계
라이브러리나 응용 프로그램은 공개 예외의 공통 기반 클래스를 제공할 수 있다.
class StorageError(Exception):
pass
class ConnectionStorageError(
StorageError
):
pass
class ItemNotFoundError(
StorageError
):
pass
이 구조는 사용자가 처리 수준을 선택할 수 있게 한다.
try:
storage.load(key)
except ItemNotFoundError:
use_default()
except StorageError:
abort_storage_operation()
예외를 지나치게 세분화하면 사용자가 어떤 예외를 처리해야 하는지 복잡해질 수 있다. 반대로 모든 실패를 하나의 예외로 표현하면 복구 가능한 경우와 치명적인 경우를 구분하기 어렵다.
예외 계층은 호출자가 서로 다른 대응을 해야 하는 실패를 중심으로 설계할 수 있다.
검사와 예외의 선택
연산 전에 조건을 검사할 수도 있고, 먼저 연산한 뒤 실패 예외를 처리할 수도 있다.
if key in mapping:
value = mapping[key]
else:
value = default
try:
value = mapping[key]
except KeyError:
value = default
두 방식 모두 가능하지만 의미와 환경이 다를 수 있다.
연산과 검사 사이에 상태가 변경될 수 있는 파일 시스템이나 동시 실행 환경에서는 사전 검사 후 실제 연산이 실패할 수 있다.
if path.exists():
contents = path.read_text()
파일은 검사 뒤 읽기 전에 삭제될 수 있다. 실제 연산의 예외를 처리하는 것이 필요하다.
try:
contents = path.read_text()
except FileNotFoundError:
contents = default
반면 조건이 정상적인 분기의 일부이고 검사 자체가 명확하다면 if가 더 읽기 쉬울 수 있다. 예외를 모든 제어 흐름의 대체물로 사용할 필요는 없다.
예외를 숨기지 않기
다음과 같이 광범위한 예외를 잡고 무시하면 프로그래밍 오류까지 숨길 수 있다.
try:
process()
except Exception:
pass
최소한 처리할 예외를 구체적으로 지정한다.
try:
configuration = load_configuration()
except FileNotFoundError:
configuration = default_configuration()
오류를 기록해야 한다면 트레이스백을 함께 남긴다.
try:
process()
except ProcessingError:
logger.exception(
"processing failed"
)
raise
예외를 기본값으로 변환하는 경우에도 정말 복구 가능한 상황인지 구분해야 한다.
try:
timeout = int(
environment["TIMEOUT"]
)
except KeyError:
timeout = 30
except ValueError as error:
raise ConfigurationError(
"TIMEOUT must be an integer"
) from error
환경 변수가 없는 경우에는 기본값을 사용할 수 있지만 잘못된 값까지 조용히 기본값으로 바꾸면 설정 오류를 발견하기 어려워진다.
assert
assert 문은 개발자가 참이라고 예상하는 내부 조건을 검사한다.
assert total >= 0
조건이 거짓이면 AssertionError가 발생한다.
assert user is not None, (
"user must be loaded"
)
assert는 프로그램의 내부 불변 조건과 개발 중 오류 탐지에 사용할 수 있다. 사용자 입력이나 외부 요청처럼 정상적으로 잘못될 수 있는 값의 검증에는 명시적인 조건과 예외가 적합하다.
if age < 0:
raise ValueError(
"age must not be negative"
)
Python 최적화 옵션에서는 assert 문이 제거될 수 있으므로 반드시 실행되어야 하는 보안 검사, 권한 검사와 데이터 검증에 의존해서는 안 된다.
여러 예외의 동시 전달
동시 작업이나 구조화된 처리에서는 서로 독립적인 여러 작업이 동시에 실패할 수 있다. ExceptionGroup은 여러 예외를 하나의 예외 객체로 묶어 전파한다.
raise ExceptionGroup(
"multiple failures",
[
ValueError(
"invalid value"
),
OSError(
"storage failure"
),
],
)
except*는 그룹 안에서 특정 타입에 해당하는 예외들을 선택적으로 처리한다.
try:
run_parallel_operations()
except* ValueError as group:
handle_value_errors(group)
except* OSError as group:
handle_os_errors(group)
일반 except와 달리 여러 except* 절이 같은 예외 그룹의 서로 다른 부분을 각각 처리할 수 있다. 처리되지 않은 예외들은 다시 그룹으로 결합되어 전파된다.
ExceptionGroup과 except*는 PEP 654에서 도입되었으며, 여러 관련 Task를 관리하는 asyncio.TaskGroup 등의 구조에서 활용된다.[137]
ExceptionGroup의 구조
예외 그룹은 다른 예외 그룹을 포함할 수 있는 트리 구조를 가진다.
group = ExceptionGroup(
"application failures",
[
ValueError(
"invalid input"
),
ExceptionGroup(
"storage failures",
[
FileNotFoundError(
"config"
),
PermissionError(
"cache"
),
],
),
],
)
except* OSError는 중첩 구조 안에 있는 FileNotFoundError와 PermissionError를 원래의 그룹 구조를 보존한 채 선택할 수 있다.
try:
raise group
except* OSError as errors:
handle_storage_errors(
errors
)
ExceptionGroup은 포함된 모든 예외가 Exception의 하위 클래스일 때 사용한다. KeyboardInterrupt 같은 BaseException 직접 하위 타입을 포함할 수 있는 일반형에는 BaseExceptionGroup이 사용된다.
예외와 비동기 취소
비동기 작업의 취소도 예외를 통해 전달된다.
async def worker():
try:
await perform_work()
finally:
await release_resources()
Task가 취소되면 중단 지점에서 asyncio.CancelledError가 발생하며 finally를 통해 자원을 정리할 수 있다.
취소 예외를 처리해야 한다면 정리 후 다시 발생시키는 것이 일반적이다.
async def worker():
try:
await perform_work()
except asyncio.CancelledError:
await rollback()
raise
취소를 무조건 삼키면 TaskGroup, 제한 시간과 상위 코루틴이 작업의 실제 상태를 판단하기 어려워질 수 있다.
자원 관리
메모리 객체는 Python 실행 환경이 자동으로 관리하지만 파일, 소켓, 잠금, 데이터베이스 트랜잭션과 운영체제 핸들 같은 외부 자원은 사용 후 명확히 반환해야 한다.
file = open(
"data.txt",
encoding="utf-8",
)
contents = file.read()
file.close()
이 구조에서는 read()가 예외를 발생시키면 close()가 실행되지 않을 수 있다.
file = open(
"data.txt",
encoding="utf-8",
)
try:
contents = file.read()
finally:
file.close()
try·finally를 사용하면 정상 실행과 예외 발생 모두에서 파일을 닫을 수 있다.
Python은 이러한 반복적인 자원 획득과 반환 구조를 캡슐화하기 위해 컨텍스트 관리자와 with 문을 제공한다.
with 문
with 문은 컨텍스트 관리자가 정의한 진입과 종료 동작으로 코드 블록을 감싼다.
with open(
"data.txt",
encoding="utf-8",
) as file:
contents = file.read()
블록에 들어가기 전에 파일을 열고, 블록을 벗어날 때 파일을 닫는다. 본문에서 예외가 발생하거나 return으로 빠져나가더라도 종료 처리가 수행된다.
with 문은 공통적인 try·finally 패턴을 객체에 캡슐화한다.[138]
개념적으로 다음 구조와 비슷하다.
manager = expression
value = manager.__enter__()
try:
target = value
body()
except BaseException as error:
suppress = manager.__exit__(
type(error),
error,
error.__traceback__,
)
if not suppress:
raise
else:
manager.__exit__(
None,
None,
None,
)
실제 실행 의미에는 특수 메서드 조회와 예외 처리에 관한 세부 규칙이 더 포함되지만, 진입과 종료가 한 쌍을 이룬다는 점이 핵심이다.
컨텍스트 관리자
동기 컨텍스트 관리자는 __enter__()와 __exit__()을 구현한다.[139]
class ManagedConnection:
def __init__(self, address):
self.address = address
self.connection = None
def __enter__(self):
self.connection = connect(
self.address
)
return self.connection
def __exit__(
self,
exc_type,
exc_value,
traceback,
):
self.connection.close()
with ManagedConnection(
"example.com"
) as connection:
connection.send(
b"message"
)
__enter__()의 반환값이 as 뒤의 대상에 바인딩된다. 반드시 컨텍스트 관리자 자신을 반환할 필요는 없다.
__exit__()은 블록에서 발생한 예외의 타입, 객체와 트레이스백을 받는다. 정상적으로 종료되면 세 인자가 모두 None이다.
예외 억제
__exit__()이 참으로 평가되는 값을 반환하면 블록에서 발생한 예외를 처리한 것으로 간주하고 전파를 막는다.
class IgnoreValueError:
def __enter__(self):
return self
def __exit__(
self,
exc_type,
exc_value,
traceback,
):
return exc_type is ValueError
with IgnoreValueError():
raise ValueError(
"ignored"
)
다른 타입의 예외는 계속 전파된다.
with IgnoreValueError():
raise TypeError(
"not ignored"
)
자원 정리만 수행하고 예외를 억제하지 않으려면 False나 None을 반환한다.
예외 억제는 오류를 의도적으로 정상 흐름으로 변환하는 명확한 경우에만 사용해야 한다. 단순한 정리용 컨텍스트 관리자가 예외를 무조건 숨기면 원인을 찾기 어려워진다.
여러 컨텍스트 관리자
하나의 with 문에서 여러 컨텍스트 관리자를 사용할 수 있다.
with (
open(
"source.txt",
encoding="utf-8",
) as source,
open(
"target.txt",
"w",
encoding="utf-8",
) as target,
):
target.write(
source.read()
)
진입은 왼쪽에서 오른쪽 순서로 이루어지고 종료는 반대 순서로 이루어진다.
개념적으로 중첩된 with와 같다.
with open(
"source.txt",
encoding="utf-8",
) as source:
with open(
"target.txt",
"w",
encoding="utf-8",
) as target:
target.write(
source.read()
)
뒤쪽 컨텍스트의 진입에 실패해도 이미 진입한 앞쪽 컨텍스트는 정상적으로 종료 처리된다.
잠금과 컨텍스트 관리자
잠금은 대표적인 컨텍스트 관리자이다.
with lock:
update_shared_state()
이는 개념적으로 잠금을 획득하고 finally에서 해제하는 구조를 캡슐화한다.
lock.acquire()
try:
update_shared_state()
finally:
lock.release()
예외가 발생하더라도 잠금을 반환하므로 다른 실행 흐름이 영구히 대기하는 상황을 줄인다.
데이터베이스 연결이나 트랜잭션도 컨텍스트 관리자 인터페이스를 제공할 수 있다.
with database.transaction():
update_account()
create_record()
정상 종료에는 커밋하고 예외 발생 시 롤백하도록 구현할 수 있다.
contextlib.contextmanager
contextlib.contextmanager 데코레이터를 사용하면 제너레이터 함수 문법으로 컨텍스트 관리자를 만들 수 있다.
from contextlib import (
contextmanager,
)
@contextmanager
def managed_connection(address):
connection = connect(address)
try:
yield connection
finally:
connection.close()
with managed_connection(
"example.com"
) as connection:
connection.send(
b"message"
)
yield 앞의 코드는 __enter__() 단계에 대응하고, yield한 값은 as 대상에 전달된다. yield 뒤의 코드는 종료 단계에 대응한다.[140]
본문에서 예외가 발생하면 해당 예외가 제너레이터의 yield 위치에서 다시 발생한다. 컨텍스트 관리자 함수는 이를 처리하거나 finally에서 정리한 뒤 전파할 수 있다.
@contextmanager
def transaction(database):
transaction = database.begin()
try:
yield transaction
except Exception:
transaction.rollback()
raise
else:
transaction.commit()
예외를 잡은 뒤 다시 발생시키지 않으면 컨텍스트 관리자가 해당 예외를 억제한 것으로 처리될 수 있으므로 주의해야 한다.
closing()
contextlib.closing()은 close() 메서드를 가진 객체를 컨텍스트 관리자로 감싼다.
from contextlib import closing
from urllib.request import urlopen
with closing(
urlopen(
"https://www.python.org"
)
) as response:
data = response.read()
객체 자체가 이미 컨텍스트 관리자라면 직접 with에 사용하는 것이 적합하다. closing()은 컨텍스트 관리자 프로토콜은 없지만 명시적인 close()가 필요한 외부 객체에 사용할 수 있다.
nullcontext()
선택적으로 컨텍스트 관리자를 사용할 때 nullcontext()는 아무 특별한 진입·종료 동작 없이 전달된 값을 반환한다.
from contextlib import nullcontext
manager = (
open(
path,
encoding="utf-8",
)
if path is not None
else nullcontext(
default_stream
)
)
with manager as stream:
process(stream)
조건에 따라 별도의 with 문을 중복 작성하지 않고 같은 블록 구조를 유지할 수 있다.
suppress()
contextlib.suppress()는 지정한 예외를 의도적으로 무시하는 작은 범위의 컨텍스트 관리자이다.
from contextlib import suppress
with suppress(
FileNotFoundError
):
path.unlink()
파일이 이미 없는 경우를 정상적인 결과로 취급할 때 사용할 수 있다.
이는 다음 구조와 비슷하다.
try:
path.unlink()
except FileNotFoundError:
pass
suppress()의 범위는 가능한 한 좁게 유지한다. 여러 작업을 함께 넣으면 예상하지 않은 위치의 같은 예외까지 숨길 수 있다.
ExitStack
획득할 자원의 수가 실행 중에 결정되거나 선택적인 컨텍스트 관리자를 조합해야 할 때 contextlib.ExitStack을 사용할 수 있다.[141]
from contextlib import ExitStack
with ExitStack() as stack:
files = [
stack.enter_context(
open(
path,
encoding="utf-8",
)
)
for path in paths
]
process(files)
모든 파일은 스택이 닫힐 때 역순으로 닫힌다. 중간 파일을 여는 과정에서 예외가 발생해도 이미 열린 파일들은 정리된다.
컨텍스트 관리자가 아닌 임의의 정리 콜백도 등록할 수 있다.
with ExitStack() as stack:
resource = acquire_resource()
stack.callback(
release_resource,
resource,
)
use(resource)
ExitStack은 다음과 같은 구조에 유용하다.
- 동적으로 정해지는 자원 수
- 조건부 자원 획득
- 서로 다른 종류의 정리 동작
- 여러 획득 단계 중간의 실패
- 정리 책임을 다른 범위로 이전하는 구조
자원 획득 실패
자원을 여러 단계로 획득할 때 뒤쪽 단계가 실패하면 앞서 획득한 자원을 반환해야 한다.
first = acquire_first()
try:
second = acquire_second()
except Exception:
release_first(first)
raise
컨텍스트 관리자나 ExitStack을 사용하면 이런 부분 실패의 정리를 구조적으로 관리할 수 있다.
with ExitStack() as stack:
first = acquire_first()
stack.callback(
release_first,
first,
)
second = acquire_second()
stack.callback(
release_second,
second,
)
use(first, second)
각 자원을 성공적으로 얻은 직후 정리 동작을 등록하므로 이후 어느 단계에서 실패해도 이미 획득한 자원이 정리된다.
소유권과 자원 수명
함수가 전달받은 자원을 닫아야 하는지, 호출자가 관리해야 하는지를 인터페이스에서 명확히 해야 한다.
def read_configuration(stream):
return json.load(stream)
이 함수가 호출자가 전달한 stream을 임의로 닫으면 호출자가 이후 사용할 수 없다. 일반적으로 전달받은 자원의 소유권은 호출자에게 유지하고, 함수가 직접 생성한 자원은 함수가 정리하는 구조가 예측하기 쉽다.
def read_configuration_file(path):
with open(
path,
encoding="utf-8",
) as stream:
return json.load(stream)
함수가 열린 자원을 반환한다면 호출자가 닫을 책임을 이해할 수 있도록 문서화하거나 컨텍스트 관리자를 반환하는 방식을 사용할 수 있다.
비동기 컨텍스트 관리자
비동기 자원의 획득과 반환 과정에서 await가 필요하면 __aenter__()와 __aexit__()을 구현하는 비동기 컨텍스트 관리자를 사용한다.
class AsyncConnection:
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(
self,
exc_type,
exc_value,
traceback,
):
await self.close()
async with AsyncConnection() as connection:
await connection.send(
b"message"
)
async with는 일반적으로 async def 함수 안에서 사용한다. 진입과 종료 메서드가 반환한 대기 가능 객체를 기다린다.
비동기 잠금과 트랜잭션, 네트워크 세션 등이 이 구조를 사용할 수 있다.
async with lock:
await update_state()
async with database.transaction():
await update_records()
asynccontextmanager
contextlib.asynccontextmanager를 사용하면 비동기 제너레이터 문법으로 비동기 컨텍스트 관리자를 구현할 수 있다.
from contextlib import (
asynccontextmanager,
)
@asynccontextmanager
async def managed_session():
session = await open_session()
try:
yield session
finally:
await session.close()
async with managed_session() as session:
await session.request()
yield 앞에서는 비동기 자원을 획득하고, yield 뒤에서는 비동기 정리를 수행한다.
예외에 따라 커밋과 롤백을 나눌 수 있다.
@asynccontextmanager
async def transaction(database):
transaction = await database.begin()
try:
yield transaction
except Exception:
await transaction.rollback()
raise
else:
await transaction.commit()
AsyncExitStack
contextlib.AsyncExitStack은 동기·비동기 컨텍스트 관리자와 비동기 정리 콜백을 동적으로 조합한다.
from contextlib import (
AsyncExitStack,
)
async with AsyncExitStack() as stack:
session = await stack.enter_async_context(
open_session()
)
file = stack.enter_context(
open(
"data.txt",
encoding="utf-8",
)
)
await process(
session,
file,
)
비동기 자원과 동기 자원을 같은 수명 범위에서 관리해야 하는 코드에 사용할 수 있다.
정리 동작은 등록의 역순으로 실행되며, 비동기 종료 메서드는 await된다.
파이널라이저에 의존하지 않기
객체는 __del__()이나 약한 참조 파이널라이저를 통해 최종화 동작을 가질 수 있지만, 외부 자원의 중요한 반환을 이 시점에만 의존해서는 안 된다.
class FileWrapper:
def __del__(self):
self.file.close()
객체가 정확히 언제 정리될지는 Python 구현체, 참조 순환과 실행 종료 상태에 따라 달라질 수 있다. CPython에서는 참조 계수 때문에 즉시 정리되는 것처럼 보이는 경우가 많지만 이는 모든 Python 구현체의 언어 보장이 아니다.
파일과 잠금, 트랜잭션처럼 반환 시점이 중요한 자원은 with, 명시적인 close()와 try·finally를 사용한다.
with FileWrapper(path) as file:
process(file)
파이널라이저는 명시적 정리가 누락된 경우의 보조 수단으로 사용할 수 있지만 정상적인 자원 관리 경로를 대체하지 않는다.
종료 처리
프로그램 정상 종료 시 실행할 간단한 콜백에는 atexit 모듈을 사용할 수 있다.
import atexit
def save_state():
...
atexit.register(save_state)
그러나 프로세스의 강제 종료, 치명적인 인터프리터 오류나 일부 운영체제 수준 종료에서는 실행되지 않을 수 있다. 또한 장기간 실행되는 프로그램에서 자원을 사용 직후 반환하는 용도로는 적합하지 않다.
자원은 가능한 한 필요한 가장 좁은 범위에서 획득하고 반환한다.
def process_file(path):
with open(
path,
encoding="utf-8",
) as file:
return transform(
file.read()
)
이 구조는 함수가 끝난 뒤 파일이 계속 열려 있는 시간을 줄인다.
예외 안전성
예외가 발생하더라도 객체와 외부 시스템이 일관된 상태를 유지하도록 설계하는 것을 예외 안전성이라고 할 수 있다.
def transfer(
source,
destination,
amount,
):
source.withdraw(amount)
destination.deposit(amount)
두 번째 작업이 실패하면 출금만 적용된 상태가 될 수 있다. 트랜잭션이나 롤백 구조가 필요하다.
with database.transaction():
source.withdraw(amount)
destination.deposit(amount)
메모리 객체에서도 상태 변경 순서를 고려할 수 있다.
def update(self, source):
parsed = parse(source)
validated = validate(parsed)
self.data = validated
모든 계산과 검증을 먼저 성공시킨 뒤 마지막에 객체 상태를 변경하면 중간 예외로 객체가 부분 갱신되는 상황을 줄일 수 있다.
롤백
여러 상태 변경을 하나의 작업으로 취급해야 한다면 실패 시 이전 상태로 되돌리는 롤백이 필요할 수 있다.
old_value = object.value
try:
object.value = new_value
update_external_system()
except Exception:
object.value = old_value
raise
더 복잡한 작업은 명시적인 트랜잭션 객체나 컨텍스트 관리자로 캡슐화할 수 있다.
with model.transaction():
model.update_first()
model.update_second()
컨텍스트 관리자는 본문에서 예외가 발생했는지를 __exit__() 인자로 확인하고 커밋이나 롤백을 선택할 수 있다.
class Transaction:
def __enter__(self):
self.begin()
return self
def __exit__(
self,
exc_type,
exc_value,
traceback,
):
if exc_type is None:
self.commit()
else:
self.rollback()
return False
False를 반환하므로 롤백 후에도 원래 예외는 호출자에게 전파된다.
정리 중 발생한 예외
자원 정리 자체도 실패할 수 있다.
try:
use_resource()
finally:
resource.close()
use_resource()와 close()가 모두 예외를 발생시키면 정리 예외가 현재 전파되는 예외가 되고 원래 오류는 예외 문맥으로 남는다.
정리 오류를 무조건 무시할지, 원래 오류와 함께 보고할지는 자원의 특성과 프로그램 요구에 따라 결정해야 한다.
try:
use_resource()
finally:
try:
resource.close()
except CloseError:
logger.exception(
"failed to close resource"
)
이 구조는 정리 오류를 로그로 남기지만 원래 예외가 없는 정상 경로에서도 CloseError를 숨긴다. 데이터 손실 가능성이 있는 정리 실패라면 상위 호출자에게 전달하는 것이 적합할 수 있다.
여러 자원의 정리에서 각각 오류가 발생할 수 있는 경우 ExceptionGroup으로 여러 실패를 함께 표현하는 구조도 가능하다.
경고
즉시 실행을 중단할 필요는 없지만 사용자에게 잠재적인 문제를 알릴 때는 예외 대신 경고를 사용할 수 있다.
import warnings
warnings.warn(
"old_api() is deprecated",
DeprecationWarning,
stacklevel=2,
)
경고는 필터 설정에 따라 표시하거나 무시하고, 예외로 변환할 수 있다.
warnings.simplefilter(
"error",
DeprecationWarning,
)
라이브러리 API 폐기, 의심스러운 사용과 호환성 문제처럼 프로그램을 즉시 중단하지 않아도 되는 상황에 적합하다.
잘못된 입력이나 작업 실패처럼 호출자가 대응해야 하는 상태는 예외로 표현하는 편이 적합하다.
예외 메시지
예외 메시지는 무엇이 실패했는지와 관련된 값을 구체적으로 설명해야 한다.
raise ValueError(
f"port must be between "
f"1 and 65535, got {port}"
)
단순히 "invalid value"만 표시하면 원인을 찾기 어렵다.
민감 정보는 메시지와 트레이스백에 포함하지 않아야 한다.
raise AuthenticationError(
"authentication failed"
)
비밀번호와 전체 인증 토큰을 예외 메시지에 넣으면 로그나 사용자 화면에 노출될 수 있다.
호출자가 구조적인 값을 사용해야 한다면 메시지를 파싱하게 하지 않고 예외 속성으로 제공한다.
class InvalidPortError(
ValueError
):
def __init__(self, port):
self.port = port
super().__init__(
f"invalid port: {port}"
)
API 경계의 예외 변환
저수준 라이브러리의 예외를 그대로 노출하면 상위 계층이 내부 구현에 강하게 결합될 수 있다.
def load_user(user_id):
try:
return database.query(
user_id
)
except DatabaseRowMissing as error:
raise UserNotFoundError(
user_id
) from error
저장소 구현이 바뀌어도 서비스 사용자는 UserNotFoundError라는 도메인 계약을 유지할 수 있다.
다만 모든 예외를 무조건 하나의 도메인 예외로 감싸면 프로그래밍 오류와 복구 불가능한 문제까지 숨길 수 있다.
try:
return database.query(user_id)
except Exception as error:
raise UserNotFoundError(
user_id
) from error
연결 실패나 코드 버그까지 사용자가 없다는 오류로 잘못 변환될 수 있다. 의미가 명확히 대응되는 예외만 변환한다.
재시도
일시적인 네트워크 오류처럼 같은 작업을 다시 시도하면 성공할 수 있는 예외가 있다.
for attempt in range(3):
try:
return request()
except TemporaryNetworkError:
if attempt == 2:
raise
sleep(
calculate_delay(
attempt
)
)
모든 예외를 재시도해서는 안 된다. 잘못된 인증 정보나 유효하지 않은 요청은 반복해도 해결되지 않을 수 있다.
재시도할 작업이 멱등성을 가지는지도 고려해야 한다. 결제나 데이터 생성 요청을 무조건 반복하면 작업이 중복될 수 있다.
예외 처리와 재시도 정책은 오류 타입, 작업의 의미, 최대 횟수, 지연과 취소를 함께 고려한다.
최상위 예외 처리
응용 프로그램의 최상위 계층에서는 처리되지 않은 예외를 기록하고 사용자에게 적절한 오류를 표시할 수 있다.
def main():
try:
run_application()
except ConfigurationError as error:
print(
f"configuration error: "
f"{error}"
)
return 2
except ApplicationError:
logger.exception(
"application failed"
)
return 1
return 0
최상위 처리기는 프로그램을 계속 실행하기 위한 무조건적인 예외 삼키기가 아니라 종료 코드, 로그와 사용자 메시지를 정리하는 마지막 경계가 될 수 있다.
서버 프로그램에서는 요청 하나의 예외를 요청 경계에서 처리하여 다른 요청을 계속 받을 수 있다. 그러나 전역 상태가 손상된 오류라면 프로세스를 재시작하는 편이 안전할 수도 있다.
예외 처리의 비용과 용도
예외가 발생하지 않는 일반적인 try 블록은 Python의 정상적인 제어 구조로 사용할 수 있다. 그러나 예외를 매우 빈번한 반복의 정상 결과로 사용하는 것은 실제 예외 생성과 트레이스백 비용을 발생시킬 수 있다.
성능보다 먼저 코드의 의미와 정확성을 고려해야 한다.
try:
value = mapping[key]
except KeyError:
value = default
키가 없는 상황이 드물다면 자연스러운 구조이다. 대부분의 키가 없고 예외가 계속 발생한다면 get()이 의도를 더 잘 표현할 수 있다.
value = mapping.get(
key,
default,
)
예외는 비정상 상태를 표현하고 호출 스택을 넘어 전달하는 데 유용하며, 단순한 조건 분기와 컬렉션 API가 더 직접적인 경우에는 해당 기능을 사용할 수 있다.
예외와 자원 관리의 관계
예외가 발생하면 현재 코드 블록의 나머지 문장이 건너뛰어지므로 자원 반환을 일반적인 마지막 줄에만 배치해서는 안 된다.
resource = acquire_resource()
use_resource(resource)
release_resource(resource)
use_resource()가 실패하면 마지막 줄이 실행되지 않는다.
try·finally는 어떤 실행 경로에서도 정리를 수행한다.
resource = acquire_resource()
try:
use_resource(resource)
finally:
release_resource(resource)
컨텍스트 관리자는 이 구조를 재사용 가능한 객체 계약으로 만든다.
with managed_resource() as resource:
use_resource(resource)
예외 처리는 실패를 전달하고 복구하는 체계이며, 컨텍스트 관리는 정상 종료와 실패 모두에서 실행 환경을 정리하는 체계이다. 두 기능을 함께 사용하면 파일, 연결, 잠금과 트랜잭션을 예측 가능한 범위 안에서 관리할 수 있다.
설계 원칙
Python 프로그램의 예외와 자원 관리는 다음과 같은 방향으로 구성할 수 있다.
- 실제로 처리할 수 있는 예외만 잡는다.
- 구체적인 예외를 일반적인 예외보다 먼저 처리한다.
try블록은 예상한 오류가 발생하는 코드로 좁힌다.- 처리할 수 없는 예외는 다시 발생시키거나 그대로 전파한다.
- 저수준 오류를 변환할 때는
raise ... from ...으로 원인을 보존한다. - 추가 문맥만 필요하면 예외 메모를 사용할 수 있다.
- 외부 자원은
with,async with또는try·finally로 정리한다. - 가비지 컬렉션과
__del__()의 실행 시점에 중요한 자원 반환을 의존하지 않는다. - 정리 코드에서 원래 예외를 의도치 않게 숨기지 않는다.
finally안에서 블록 밖으로 나가는return,break와continue를 피한다.- 여러 독립 작업의 실패에는
ExceptionGroup과except*를 사용할 수 있다. - 사용자 정의 예외는 호출자가 대응해야 하는 의미를 중심으로 계층화한다.
- 예외 메시지에는 진단에 필요한 문맥을 포함하되 민감 정보는 제외한다.
Python의 예외 체계는 오류를 단순한 문자열이나 반환값으로 처리하지 않고 타입과 트레이스백, 원인 관계를 가진 객체로 표현한다. 컨텍스트 관리자와 finally는 예외가 발생한 실행 경로에서도 자원과 상태를 정리할 수 있게 한다. 이 구조를 통해 정상 처리와 실패 처리를 분리하면서도, 파일·네트워크·잠금·트랜잭션과 같은 외부 자원의 수명을 코드 블록에 명시적으로 연결할 수 있다.
모듈과 패키지
Python 프로젝트는 기능을 여러 소스 파일과 디렉터리로 나누고, 이들을 가져오기 가능한 이름 계층으로 구성하는 방식으로 개발된다. 작은 프로그램은 하나의 실행 파일에서 시작할 수 있지만, 기능이 늘어나면 데이터 모델, 외부 입출력, 업무 규칙, 사용자 인터페이스와 실행 진입점 등을 서로 다른 모듈로 분리한다.
project/
├── pyproject.toml
├── README.md
├── src/
│ └── application/
│ ├── __init__.py
│ ├── __main__.py
│ ├── models.py
│ ├── services.py
│ ├── storage.py
│ └── cli.py
└── tests/
├── test_models.py
└── test_services.py
이 구조에서 application은 가져오기 패키지이고, 그 아래의 각 .py 파일은 패키지를 구성하는 하위 모듈이다. 소스 코드는 역할에 따라 분리되며, 다른 모듈의 공개 이름을 import하여 조합한다.
from application.models import User
from application.storage import UserRepository
모듈과 패키지의 분리는 단순히 파일 크기를 줄이기 위한 작업이 아니다. 이름 충돌을 줄이고, 의존 관계와 공개 인터페이스를 드러내며, 서로 다른 기능을 독립적으로 검사하고 교체할 수 있게 하는 프로그램 구조화 수단이다.
기능에 따른 모듈 분리
프로젝트를 여러 모듈로 나눌 때는 코드의 형식보다 책임과 변경 이유를 기준으로 구성할 수 있다.
application/
├── models.py
├── repositories.py
├── services.py
├── exceptions.py
└── cli.py
models.py는 프로그램의 데이터 구조, repositories.py는 저장소 접근, services.py는 여러 구성 요소를 조합한 업무 처리, exceptions.py는 공개 예외 계층, cli.py는 명령행 인터페이스를 담당할 수 있다.
# models.py
from dataclasses import dataclass
@dataclass
class User:
identifier: int
name: str
# repositories.py
from application.models import User
class UserRepository:
def find(self, identifier: int) -> User | None:
...
# services.py
from application.exceptions import UserNotFoundError
from application.repositories import UserRepository
class UserService:
def __init__(
self,
repository: UserRepository,
) -> None:
self.repository = repository
def load_user(
self,
identifier: int,
):
user = self.repository.find(
identifier
)
if user is None:
raise UserNotFoundError(
identifier
)
return user
이와 같은 분리는 각 파일에 특정 종류의 코드만 넣어야 한다는 절대적인 규칙은 아니다. 서로 함께 변경되는 코드가 지나치게 여러 파일에 흩어지면 오히려 탐색이 어려워질 수 있다. 관련성이 높은 기능은 같은 모듈에 유지하고, 독립적인 책임과 재사용 경계가 분명해질 때 분리할 수 있다.
패키지 계층
기능이 많아지면 하나의 패키지 아래에 하위 패키지를 구성할 수 있다.
application/
├── __init__.py
├── domain/
│ ├── __init__.py
│ ├── models.py
│ └── services.py
├── infrastructure/
│ ├── __init__.py
│ ├── database.py
│ └── network.py
└── interfaces/
├── __init__.py
├── cli.py
└── web.py
하위 패키지는 점으로 구분된 가져오기 이름을 가진다.
from application.domain.models import User
from application.infrastructure.database import Database
패키지 계층은 디렉터리 구조를 그대로 노출하기 위한 목적보다, 기능 사이의 논리적인 소속과 공개 경계를 표현하는 데 사용한다.
지나치게 깊은 계층은 짧은 기능에 긴 가져오기 경로를 요구하고 코드를 여러 디렉터리로 흩어 놓을 수 있다.
from application.core.domain.users.models.user import User
프로젝트 규모에 비해 계층이 깊다면 관련 모듈을 더 가까운 위치로 통합할 수 있다.
from application.users import User
패키지 구조는 조직도나 기술 계층을 기계적으로 복제하기보다, 개발자가 기능을 찾고 변경하는 흐름을 기준으로 결정할 수 있다.
일반 패키지
일반 패키지는 보통 __init__.py 파일이 있는 디렉터리로 구성된다. 패키지를 가져오면 __init__.py의 코드가 실행되고 그 안에서 정의되거나 가져온 이름이 패키지 이름 공간에 바인딩된다.[142]
application/
├── __init__.py
├── models.py
└── services.py
__init__.py는 비어 있을 수도 있다.
# application/__init__.py
패키지의 주요 공개 인터페이스를 다시 내보낼 수도 있다.
# application/__init__.py
from application.models import User
from application.services import UserService
__all__ = [
"User",
"UserService",
]
사용자는 내부 파일 위치를 직접 알지 않고 패키지에서 주요 객체를 가져올 수 있다.
from application import User
from application import UserService
내부 모듈을 재배치하더라도 패키지의 공개 가져오기 경로를 유지하면 사용자 코드의 변경을 줄일 수 있다.
__init__.py의 역할
__init__.py는 패키지가 처음 가져와질 때 실행되므로 다음 작업에 사용할 수 있다.
- 패키지의 공개 이름 다시 내보내기
- 버전과 기본 메타데이터 제공
- 하위 모듈 사이의 간단한 인터페이스 정리
- 패키지 수준 상수 정의
- 필요한 초기 등록 작업
from application.models import User
from application.version import __version__
__all__ = [
"User",
"__version__",
]
그러나 __init__.py에서 무거운 입출력이나 복잡한 초기화를 수행하면 패키지를 가져오기만 해도 예상하지 않은 작업이 발생한다.
# 피하는 편이 좋은 구조
database = connect_to_database()
configuration = download_configuration()
이러한 작업은 응용 프로그램의 명시적인 시작 함수나 팩토리에서 수행할 수 있다.
def create_application():
configuration = load_configuration()
database = connect_to_database(
configuration.database
)
return Application(
configuration,
database,
)
__init__.py는 패키지의 경계와 공개 인터페이스를 정리하되, 가져오기 자체를 무거운 실행 단계로 만들지 않는 방향으로 구성할 수 있다.
네임스페이스 패키지
Python은 __init__.py가 없는 디렉터리들을 하나의 패키지 이름 아래 결합하는 네임스페이스 패키지를 지원한다.[143]
distribution-a/
└── src/
└── company/
└── analytics/
└── report.py
distribution-b/
└── src/
└── company/
└── network/
└── client.py
두 배포 프로젝트가 같은 환경에 설치되면 다음과 같은 공통 이름 계층을 제공할 수 있다.
from company.analytics import report
from company.network import client
네임스페이스 패키지는 하나의 큰 조직이나 플러그인 생태계가 여러 독립 배포 패키지에 기능을 나누어 제공할 때 유용하다.
일반적인 단일 프로젝트에서는 __init__.py가 있는 일반 패키지가 더 명확할 수 있다. 네임스페이스 패키지는 여러 배포 단위를 하나의 가져오기 이름 아래 결합해야 하는 경우에 선택할 수 있다.
공개 인터페이스
패키지 내부의 모든 모듈과 이름을 외부 사용자가 직접 이용하도록 만들 필요는 없다. 외부에서 안정적으로 사용할 이름과 내부 구현 세부를 구분할 수 있다.
application/
├── __init__.py
├── api.py
├── _internal.py
└── _compat.py
밑줄로 시작하는 모듈과 이름은 일반적으로 비공개 구현이라는 관례를 나타낸다.
from application._internal import build_cache
기술적으로 가져올 수 있지만 외부 코드가 이에 의존하면 내부 구조 변경에 취약해질 수 있다.
공개 인터페이스는 __init__.py나 별도의 api.py에서 모아 제공할 수 있다.
# application/api.py
from application.models import User
from application.services import UserService
__all__ = [
"User",
"UserService",
]
from application.api import User
공개 경계를 명확히 하면 내부 모듈을 분리하거나 통합해도 외부 사용자의 가져오기 경로를 유지할 수 있다.
__all__
모듈이나 패키지에서 __all__을 정의하면 from module import *에서 내보낼 이름을 지정할 수 있다.
__all__ = [
"User",
"UserService",
]
그러나 __all__은 이름에 대한 완전한 접근 제한 장치가 아니다. 목록에 없는 이름도 정확한 이름을 지정하면 가져올 수 있다.
from application import _internal_name
__all__은 공개 인터페이스를 문서화하고 와일드카드 가져오기의 동작을 제어하는 수단에 가깝다.
일반적인 애플리케이션 코드에서는 와일드카드 가져오기보다 필요한 이름을 명시하는 방식이 의존 관계를 더 분명하게 보여준다.
from application import User
from application import UserService
절대 가져오기와 상대 가져오기
패키지 내부 코드는 최상위 패키지 이름에서 시작하는 절대 가져오기를 사용할 수 있다.
from application.models import User
from application.storage import Repository
같은 패키지 내부의 상대적인 위치를 기준으로 상대 가져오기를 사용할 수도 있다.
from .models import User
from .storage import Repository
하위 패키지에서 상위 패키지의 모듈을 가져올 수 있다.
from ..exceptions import ApplicationError
절대 가져오기는 전체 의존 경로가 명시적이고 파일을 다른 패키지로 옮겼을 때 의미가 쉽게 드러난다. 상대 가져오기는 패키지 이름 변경에 덜 민감하고 같은 하위 패키지 안의 긴 공통 경로를 줄일 수 있다.
하나의 프로젝트 안에서는 일관된 규칙을 선택하되, 패키지 외부의 모듈과 내부 모듈을 혼동하지 않도록 구성한다.
가져오기와 이름 바인딩
가져오기 문은 모듈을 찾고 초기화한 뒤 현재 이름 공간에 이름을 바인딩한다.[144]
import application.services
이 호출 뒤 현재 이름 공간에는 일반적으로 최상위 이름인 application이 바인딩된다.
application.services.start()
from 형식은 가져온 모듈이나 패키지의 특정 이름을 현재 이름 공간에 직접 바인딩한다.
from application.services import start
start()
별칭을 사용할 수도 있다.
from application.services import (
start as start_application,
)
별칭은 긴 이름을 줄이거나 같은 이름의 객체가 충돌하는 상황에 사용할 수 있다.
가져오기 시 실행
Python 모듈의 최상위 코드는 처음 가져와질 때 실행된다.
# configuration.py
print("configuration imported")
DEFAULT_PORT = 8080
import configuration
모듈 가져오기는 단순히 함수와 클래스 선언을 읽는 작업이 아니다. 대입, 함수 호출, 조건문과 등록 코드 등 최상위에 있는 모든 문장이 실행될 수 있다.
handlers = {}
def register(name):
def decorate(function):
handlers[name] = function
return function
return decorate
@register("start")
def start_handler():
...
이 모듈을 가져오면 데코레이터가 실행되어 핸들러가 등록된다.
가져오기 시 실행되는 코드가 많으면 다음 문제가 생길 수 있다.
- 프로그램 시작 지연
- 테스트 중 예기치 않은 외부 연결
- 가져오기 순서에 따른 동작 변화
- 순환 가져오기 문제 확대
- 설정이 준비되기 전 초기화 수행
최상위에는 클래스와 함수 정의, 가벼운 상수와 명시적인 등록 정도를 두고, 무거운 실행은 별도의 시작 단계에서 수행하는 구조를 사용할 수 있다.
모듈 캐시
가져온 모듈 객체는 일반적으로 sys.modules에 저장된다. 같은 이름의 모듈을 다시 가져오면 보통 소스 파일을 처음부터 다시 실행하지 않고 기존 모듈 객체를 사용한다.[145]
import application.configuration
import application.configuration
일반적인 실행에서는 초기화 코드가 한 번만 수행된다.
이 특성 때문에 모듈 수준 객체는 프로그램 안에서 공유 상태처럼 사용될 수 있다.
# registry.py
handlers = {}
from application import registry
registry.handlers["start"] = handler
다른 모듈에서 같은 application.registry를 가져오면 동일한 모듈 객체와 사전을 참조한다.
모듈 전역 상태는 간단한 등록소나 상수에는 유용하지만, 테스트 격리와 여러 애플리케이션 인스턴스 구성이 필요한 경우에는 상태를 명시적인 객체로 관리하는 편이 적합할 수 있다.
순환 가져오기
두 모듈이 서로를 최상위에서 가져오면 순환 가져오기가 발생한다.
# users.py
from application.groups import Group
class User:
group: Group
# groups.py
from application.users import User
class Group:
members: list[User]
한 모듈이 아직 최상위 코드를 모두 실행하지 않은 상태에서 다른 모듈이 그 이름을 요구하면 부분적으로 초기화된 모듈 오류가 발생할 수 있다.
순환 가져오기는 다음 방법으로 완화하거나 제거할 수 있다.
- 공통 타입을 별도 모듈로 이동
- 서로 의존하는 책임을 하나의 모듈로 통합
- 호출 시점의 지역 가져오기 사용
- 타입 검사 전용 가져오기에
TYPE_CHECKING사용 - 인터페이스나 프로토콜에 의존
- 상위 계층에서 객체를 구성하여 주입
타입 주석만을 위한 순환 의존은 다음과 같이 분리할 수 있다.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from application.groups import Group
class User:
def __init__(
self,
group: "Group",
) -> None:
self.group = group
지역 가져오기는 가져오기 시점을 늦출 수 있다.
def create_group():
from application.groups import Group
return Group()
그러나 순환 관계 자체가 설계상의 과도한 결합을 나타낸다면 가져오기 위치만 옮기기보다 모듈 책임을 다시 조정하는 편이 적합하다.
응용 프로그램 진입점
라이브러리 코드와 프로그램 실행 코드는 분리할 수 있다.
# application/main.py
def main() -> int:
application = create_application()
application.run()
return 0
직접 실행 여부를 검사하는 코드를 둘 수 있다.
if __name__ == "__main__":
raise SystemExit(main())
그러나 패키지를 실행할 수 있게 만들 때는 __main__.py를 사용할 수 있다.
application/
├── __init__.py
├── __main__.py
└── main.py
# application/__main__.py
from application.main import main
raise SystemExit(main())
다음 명령으로 패키지를 실행할 수 있다.
python -m application
실행 진입점은 인자 해석과 응용 프로그램 생성만 담당하고, 실제 기능은 다른 모듈의 함수와 클래스에 위임할 수 있다. 이렇게 하면 프로그램 기능을 가져와 테스트하거나 다른 인터페이스에서 재사용하기 쉬워진다.
라이브러리 패키지와 응용 프로그램 패키지
라이브러리와 응용 프로그램은 모두 패키지 구조를 사용할 수 있지만 개발 목적에는 차이가 있다.
라이브러리는 외부 프로젝트가 가져와 사용할 공개 인터페이스와 하위 호환성이 중요하다.
from image_tools import resize
응용 프로그램은 최종 실행 결과를 만드는 내부 구성과 실행 진입점이 중요하다.
python -m server
라이브러리에서는 공개 이름, 예외, 타입 주석과 배포 메타데이터를 안정적으로 관리한다. 응용 프로그램에서는 내부 모듈을 더 자유롭게 변경할 수 있지만, 설정·데이터베이스·웹 서버와 같은 구성 요소의 의존 관계를 명확히 유지할 필요가 있다.
하나의 프로젝트가 라이브러리 기능과 실행 프로그램을 함께 제공할 수도 있다.
src/
└── image_tools/
├── __init__.py
├── processing.py
├── cli.py
└── __main__.py
프로젝트 루트와 가져오기 패키지
프로젝트 디렉터리의 이름과 Python에서 가져오는 패키지 이름은 반드시 같을 필요가 없다.
image-tools/
├── pyproject.toml
└── src/
└── image_tools/
├── __init__.py
└── processing.py
배포 프로젝트 이름은 image-tools, 가져오기 이름은 image_tools가 될 수 있다.
import image_tools
Python 패키징에서는 다음 개념을 구분해야 한다.
- 가져오기 패키지: Python 코드에서
import하는 이름 계층 - 배포 패키지: 설치 도구와 패키지 색인이 설치·버전 관리하는 배포 단위
하나의 배포 패키지가 여러 가져오기 패키지를 제공할 수도 있고, 네임스페이스 패키지는 여러 배포 패키지에 나뉠 수도 있다.
평면 레이아웃
평면 레이아웃에서는 가져오기 패키지를 프로젝트 루트에 직접 둔다.
project/
├── pyproject.toml
├── README.md
├── application/
│ ├── __init__.py
│ └── services.py
└── tests/
이 구조에서는 프로젝트 루트에서 Python을 실행하면 아직 설치하지 않은 로컬 application 디렉터리를 바로 가져올 수 있다.
import application
작은 응용 프로그램이나 간단한 프로젝트에서는 구조가 직접적이고 개발 시작이 쉽다.
그러나 프로젝트 루트에는 빌드 설정, 도구 스크립트와 테스트 파일도 함께 있으므로 패키지 검색 설정이 잘못되면 배포할 필요가 없는 파일이 포함될 수 있다. 또한 테스트가 실제 설치된 패키지가 아니라 작업 디렉터리의 소스를 우연히 가져올 수 있다.
src 레이아웃
src 레이아웃에서는 가져오기 패키지를 프로젝트 루트 아래의 별도 src 디렉터리에 둔다.
project/
├── pyproject.toml
├── README.md
├── src/
│ └── application/
│ ├── __init__.py
│ └── services.py
└── tests/
프로젝트 루트 자체에는 가져오기 가능한 application 패키지가 없으므로, 일반적으로 프로젝트를 설치한 뒤 사용한다.
python -m pip install -e .
편집 가능 설치를 사용하면 소스 코드를 수정할 때마다 다시 일반 설치하지 않고 개발할 수 있다.
src 레이아웃은 테스트가 작업 디렉터리의 우연한 소스 경로가 아니라 설치된 패키지를 가져오도록 유도한다. 또한 프로젝트 루트의 README.md, 도구 스크립트와 설정 파일이 가져오기 패키지로 잘못 포함되는 가능성을 줄인다.[146]
반면 실행 전에 설치 단계가 필요하므로 단순한 일회성 스크립트에는 불필요할 수 있다. 프로젝트의 배포 여부, 검사 방식과 규모에 따라 평면 레이아웃과 src 레이아웃을 선택할 수 있다.
테스트 구조
테스트는 패키지 소스와 분리된 디렉터리에 둘 수 있다.
project/
├── src/
│ └── application/
│ ├── models.py
│ └── services.py
└── tests/
├── test_models.py
└── test_services.py
테스트는 공개 가져오기 경로를 통해 코드를 사용할 수 있다.
from application.models import User
from application.services import UserService
소스 모듈의 내부 구현을 직접 불러오는 테스트가 많으면 모듈 재구성에 따라 테스트가 대량으로 깨질 수 있다. 공개 인터페이스를 검사하는 테스트와 내부 단위 테스트의 범위를 구분할 수 있다.
테스트 파일과 패키지 소스에 같은 모듈 이름을 사용하면 가져오기 경로 충돌이 발생할 수 있으므로 고유한 패키지 이름과 일관된 실행 환경을 유지한다.
개발 중 편집 가능 설치
편집 가능 설치는 프로젝트를 설치된 배포 패키지처럼 등록하면서 실제 코드는 개발 디렉터리의 소스를 참조하게 한다.
python -m pip install -e .
이후 소스 코드를 변경하면 다시 배포 파일을 만들거나 일반 설치를 반복하지 않고 변경 사항을 사용할 수 있다.
편집 가능 설치는 다음 개발 흐름에 적합하다.
- 격리된 가상 환경을 만든다.
- 프로젝트를 편집 가능 모드로 설치한다.
- 테스트와 정적 분석을 실행한다.
- 소스 코드를 수정한다.
- 배포 전 일반 빌드와 설치 환경도 검사한다.
편집 가능 설치가 실제 wheel 설치와 완전히 동일한 파일 구성을 보장하지는 않을 수 있다. 최종 배포 전에 빌드한 wheel을 깨끗한 환경에 설치해 검사하는 과정이 필요하다.
pyproject.toml
현대 Python 프로젝트에서는 프로젝트 루트의 pyproject.toml에 빌드 시스템, 프로젝트 메타데이터와 도구 설정을 기록할 수 있다. 공식 명세는 [build-system], [project], [tool] 테이블을 정의한다.[147]
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "application"
version = "1.0.0"
description = "Example Python application"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.28",
]
[build-system]은 프로젝트를 빌드하는 데 사용할 백엔드와 빌드 의존성을 선언한다. 공식 작성 지침에서는 이 테이블의 사용을 강하게 권장한다.[148]
[project]에는 이름, 버전, 설명, Python 버전 요구 사항, 의존성과 실행 진입점 같은 배포 메타데이터를 기록한다.
[project]
name = "image-tools"
version = "1.2.0"
requires-python = ">=3.11"
dependencies = [
"pillow>=11",
]
[tool] 아래에는 포매터, 린터, 타입 검사기와 테스트 도구 등 각 도구의 설정을 저장할 수 있다.
[tool.pyright]
pythonVersion = "3.12"
[tool.pytest.ini_options]
testpaths = ["tests"]
pyproject.toml은 모든 Python 도구가 반드시 사용해야 하는 하나의 통합 설정 형식을 뜻하지 않는다. 각 도구가 지원하는 설정을 자신의 [tool.<name>] 테이블에서 관리할 수 있는 공통 위치를 제공한다.
빌드 백엔드
Python 패키징 표준은 프로젝트를 빌드하는 도구의 인터페이스와 실제 빌드 구현을 분리한다. pyproject.toml의 [build-system]은 사용할 빌드 백엔드를 지정한다.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
다른 백엔드를 사용할 수도 있다.
[build-system]
requires = ["flit_core>=3.11,<4"]
build-backend = "flit_core.buildapi"
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
프로젝트의 Python 소스 구조는 비슷하게 유지하면서도 메타데이터 처리와 파일 선택, 확장 모듈 빌드 방식은 백엔드가 담당한다.
특정 빌드 도구에 강하게 의존하는 설정은 해당 도구의 [tool] 영역에 기록될 수 있다.
의존성 구분
패키지가 실행에 필요한 외부 라이브러리는 프로젝트 의존성으로 선언한다.
[project]
dependencies = [
"httpx>=0.28",
"platformdirs>=4",
]
특정 기능에서만 필요한 선택 의존성을 정의할 수 있다.
[project.optional-dependencies]
database = [
"sqlalchemy>=2",
]
development = [
"pytest>=8",
"mypy>=1",
]
선택 의존성은 다음처럼 설치할 수 있다.
python -m pip install ".[database]"
애플리케이션의 완전히 재현 가능한 환경을 위한 잠금 파일이나 배포 환경 목록과, 재사용 라이브러리의 호환 범위를 나타내는 의존성 선언은 목적이 다르다.
라이브러리는 지나치게 정확한 단일 버전만 강제하기보다 호환 가능한 범위를 제공할 수 있고, 최종 응용 프로그램은 배포 환경에서 실제 사용할 버전을 별도로 고정할 수 있다.
명령행 실행 진입점
배포 패키지는 설치 시 생성할 명령행 프로그램을 [project.scripts]에 선언할 수 있다.
[project.scripts]
techpedia-tool = "application.cli:main"
패키지를 설치하면 환경에 techpedia-tool 명령이 만들어지고, 실행 시 application.cli 모듈의 main 함수가 호출된다.
# application/cli.py
def main() -> int:
arguments = parse_arguments()
run(arguments)
return 0
실행 함수는 반환값을 운영체제 종료 코드로 사용할 수 있도록 정수나 None을 반환하는 구조로 만들 수 있다.
명령행 인자 해석, 로깅 설정과 애플리케이션 생성을 진입점에 두고, 핵심 기능은 별도 모듈의 함수와 클래스에 유지하면 테스트하기 쉽다.
플러그인 진입점
배포 패키지는 다른 프로그램이 검색할 수 있는 플러그인 진입점을 제공할 수 있다.
[project.entry-points."application.plugins"]
json = "application_json:JsonPlugin"
yaml = "application_yaml:YamlPlugin"
호스트 프로그램은 설치된 배포판의 메타데이터를 조사하여 플러그인을 찾고 필요한 시점에 가져올 수 있다.
이 방식은 호스트 패키지가 모든 플러그인을 직접 의존하거나 하드코딩하지 않고, 별도 배포 패키지가 기능을 추가할 수 있게 한다.
플러그인의 가져오기 이름, 진입점 그룹과 인터페이스는 공개 계약이므로 안정적으로 관리해야 한다.
패키지 데이터
Python 소스가 아닌 설정 템플릿, 정적 데이터와 타입 스텁 등을 배포 패키지에 포함할 수 있다.
application/
├── __init__.py
├── templates/
│ └── default.html
└── data/
└── defaults.json
설치된 패키지의 파일 위치를 현재 작업 디렉터리 기준의 상대 경로로 가정해서는 안 된다.
from importlib.resources import files
template = (
files("application")
/ "templates"
/ "default.html"
)
contents = template.read_text(
encoding="utf-8"
)
importlib.resources를 사용하면 패키지가 일반 디렉터리, 압축된 자원 또는 다른 로더를 통해 제공되는 경우에도 패키지 자원을 다룰 수 있다.
배포 파일에 자원이 실제로 포함되도록 빌드 백엔드의 파일 선택 설정도 함께 구성해야 한다.
타입 정보 배포
라이브러리는 소스 코드 안에 타입 주석을 포함하거나 .pyi 스텁 파일을 함께 배포할 수 있다.
application/
├── __init__.py
├── client.py
├── client.pyi
└── py.typed
인라인 타입 정보를 제공하는 패키지는 py.typed 표시 파일을 포함하여 타입 검사기에 해당 배포가 타입 정보를 제공한다는 사실을 알릴 수 있다.
타입 정보도 배포 파일에 포함되어야 하므로 소스 저장소에 존재하는 것만으로는 충분하지 않다. 빌드 결과인 wheel에 필요한 .pyi와 py.typed가 포함되었는지 검사할 수 있다.
개발 도구 설정
모듈 구조는 포매터, 린터, 타입 검사기와 테스트 도구의 검사 단위가 된다.
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["application"]
[tool.mypy]
packages = ["application"]
strict = true
도구가 검사할 패키지와 Python 버전, 제외 경로를 프로젝트 구조와 일치시켜야 한다.
src 레이아웃을 사용하는데 도구가 프로젝트 루트만 검사하거나, 테스트가 설치되지 않은 로컬 파일을 가져오면 개발 환경과 실제 배포 환경의 동작이 달라질 수 있다.
도구 설정은 소스 구조를 임의로 보정하는 수단보다, 실제로 설치되고 실행되는 패키지 경계를 검사하도록 구성하는 편이 적합하다.
모듈 단위 테스트
모듈을 독립적으로 검사하려면 외부 시스템과 전역 상태에 대한 의존을 줄일 수 있다.
# services.py
class UserService:
def __init__(self, repository):
self.repository = repository
테스트에서는 메모리 구현체를 전달한다.
class MemoryRepository:
def __init__(self, users):
self.users = users
def find(self, identifier):
return self.users.get(
identifier
)
def test_load_user():
repository = MemoryRepository(
{
1: User(
1,
"Luna",
),
}
)
service = UserService(
repository
)
assert service.load_user(1).name == "Luna"
모듈을 가져오는 순간 데이터베이스에 연결하거나 환경 설정을 확정하면 테스트마다 가져오기 순서와 전역 상태를 관리해야 한다. 초기화 코드를 팩토리나 진입점으로 이동하면 모듈을 가져오는 것과 응용 프로그램을 시작하는 것을 분리할 수 있다.
설정과 모듈
설정을 모듈 전역에서 즉시 읽으면 가져오기 시점에 값이 고정될 수 있다.
# configuration.py
import os
DATABASE_URL = os.environ[
"DATABASE_URL"
]
환경 변수가 없으면 이 모듈을 가져오기만 해도 예외가 발생한다.
명시적인 설정 로딩 함수나 객체를 사용할 수 있다.
from dataclasses import dataclass
import os
@dataclass(frozen=True)
class Configuration:
database_url: str
def load_configuration():
return Configuration(
database_url=os.environ[
"DATABASE_URL"
]
)
응용 프로그램 진입점에서 설정을 불러오고 필요한 객체에 전달한다.
def create_application():
configuration = (
load_configuration()
)
return Application(
configuration
)
이 구조는 서로 다른 설정으로 여러 인스턴스를 만들고 테스트에서 값을 직접 주입하기 쉽게 한다.
모듈의 응집도
하나의 모듈 안의 코드가 서로 밀접한 목적을 공유하면 응집도가 높다고 볼 수 있다.
users/
├── models.py
├── repository.py
└── service.py
프로젝트가 작다면 사용자 기능을 하나의 모듈에 함께 둘 수도 있다.
application/
└── users.py
@dataclass
class User:
...
class UserRepository:
...
class UserService:
...
코드가 많지 않은데 미리 여러 파일로 나누면 한 기능을 이해하기 위해 계속 파일 사이를 이동해야 한다. 반대로 하나의 모듈이 서로 관계없는 수십 개의 기능을 포함하면 변경 영향과 이름 탐색이 어려워진다.
모듈 분리는 줄 수보다 다음 기준을 고려할 수 있다.
- 함께 변경되는가
- 같은 공개 인터페이스를 형성하는가
- 독립적인 검사가 가능한가
- 다른 구현으로 교체될 수 있는가
- 외부 의존성이 다른가
- 가져오기 방향이 명확해지는가
의존 방향
패키지 계층은 단순한 파일 배치가 아니라 모듈 사이의 의존 방향을 표현할 수 있다.
interfaces
↓
application services
↓
domain models
외부 데이터베이스 구현은 도메인 서비스가 구체 저장소 모듈을 직접 생성하는 대신 인터페이스를 구현하고, 최상위 구성 코드가 둘을 연결할 수 있다.
# domain/services.py
class UserService:
def __init__(self, repository):
self.repository = repository
# infrastructure/database.py
class DatabaseUserRepository:
...
# main.py
repository = DatabaseUserRepository(
database
)
service = UserService(
repository
)
이렇게 하면 핵심 모듈이 명령행 인터페이스, 웹 프레임워크나 특정 데이터베이스 패키지를 직접 가져오지 않아도 된다.
모든 프로젝트에 엄격한 계층 구조가 필요한 것은 아니지만, 순환 가져오기와 광범위한 결합이 반복된다면 의존 방향을 다시 검토할 수 있다.
재내보내기와 호환성
패키지 내부 구조를 변경하면서 기존 가져오기 경로를 유지하기 위해 이름을 다시 내보낼 수 있다.
기존 구조가 다음과 같다고 가정한다.
from application.models import User
User를 하위 패키지로 이동하더라도 models.py에서 다시 내보낼 수 있다.
# application/models.py
from application.domain.users import User
__all__ = [
"User",
]
기존 사용자 코드는 계속 동작한다.
from application.models import User
재내보내기는 공개 API를 안정화하는 데 유용하지만, 같은 객체가 너무 많은 경로에서 노출되면 공식 경로가 불분명해질 수 있다. 문서와 __all__에서 권장 경로를 명확히 할 수 있다.
지연 가져오기
일부 모듈은 가져오기 비용이 크거나 선택 의존성을 필요로 할 수 있다. 필요한 함수 안에서 가져오기를 수행하면 사용 시점까지 지연할 수 있다.
def render_image(image):
from PIL import Image
...
선택 기능이 사용되지 않으면 해당 외부 패키지를 가져오지 않는다.
def export_pdf(document):
try:
import reportlab
except ImportError as error:
raise OptionalDependencyError(
"PDF support requires "
"the pdf extra"
) from error
그러나 일반 의존성을 모두 함수 내부에서 가져오면 의존 관계를 찾기 어려워지고 오류가 실행 중 늦게 나타날 수 있다. 순환 의존 해결, 선택 기능과 무거운 초기화처럼 명확한 이유가 있는 경우에 제한적으로 사용할 수 있다.
동적 가져오기
가져올 모듈 이름이 실행 중 결정되면 importlib.import_module()을 사용할 수 있다.
from importlib import import_module
module = import_module(
"application.plugins.json"
)
importlib는 Python의 가져오기 체계를 프로그램에서 이용하고 확장하기 위한 인터페이스를 제공한다.[149]
설정으로 플러그인 이름을 선택할 수 있다.
def load_plugin(name):
module = import_module(
f"application.plugins.{name}"
)
return module.create_plugin()
외부 입력을 그대로 모듈 이름에 결합하면 임의 모듈을 불러올 수 있으므로, 허용 목록이나 등록된 진입점을 사용하는 방식이 더 안전할 수 있다.
가져오기 경로
Python은 모듈을 찾을 때 가져오기 경로와 메타 경로 탐색기를 사용한다. 파일 시스템 기반 가져오기에서는 sys.path의 위치를 검색할 수 있다.[150]
sys.path에는 실행 방식, 환경 설정, 표준 라이브러리와 설치된 패키지 위치 등이 반영된다.
소스 코드에서 임의로 sys.path를 수정하여 프로젝트 구조 문제를 우회할 수 있다.
import sys
sys.path.append(
"../src"
)
그러나 실행 위치에 따라 결과가 달라지고 실제 설치 환경과 다른 경로가 사용될 수 있다. 프로젝트를 정상적인 배포 패키지로 설치하거나 python -m을 사용하여 올바른 패키지 문맥에서 실행하는 방식이 더 안정적이다.
직접 파일 실행과 모듈 실행
패키지 안의 파일을 경로로 직접 실행하면 패키지 문맥이 올바르게 설정되지 않아 상대 가져오기가 실패할 수 있다.
python application/tools/report.py
다음과 같은 상대 가져오기가 있다면 문제가 발생할 수 있다.
from ..models import Report
프로젝트 루트에서 모듈 이름으로 실행할 수 있다.
python -m application.tools.report
-m 실행은 가져오기 시스템을 통해 모듈을 찾고 패키지 이름과 __package__ 등의 문맥을 설정한다.
패키지 내부의 실행 가능 모듈은 직접 파일 경로보다 python -m package.module 형식을 기준으로 개발할 수 있다.
배포 파일 검사
소스 저장소가 정상적으로 동작해도 빌드 결과에 필요한 모듈이나 데이터가 빠질 수 있다. 배포 전에는 소스 배포판과 wheel을 만들고 내용을 검사할 수 있다.
python -m build
일반적으로 다음 형식의 파일이 생성된다.
dist/
├── application-1.0.0.tar.gz
└── application-1.0.0-py3-none-any.whl
wheel은 설치용 빌드 배포 형식이며, 소스 배포판은 프로젝트를 다시 빌드할 수 있는 소스 묶음이다.[151]
깨끗한 가상 환경에 wheel을 설치하고 다음 항목을 검사할 수 있다.
- 공개 모듈이 정상적으로 가져와지는가
- 패키지 데이터가 포함되었는가
- 타입 스텁과
py.typed가 포함되었는가 - 명령행 진입점이 생성되었는가
- 선언된 의존성만으로 실행되는가
- 작업 디렉터리 밖에서도 동작하는가
모듈 구조의 발전
프로젝트 초기에는 단순한 구조로 시작할 수 있다.
application/
├── __init__.py
└── main.py
기능이 추가되면 관련 코드를 분리한다.
application/
├── __init__.py
├── main.py
├── models.py
└── storage.py
도메인이 많아지면 기능별 하위 패키지를 만들 수 있다.
application/
├── __init__.py
├── users/
│ ├── __init__.py
│ ├── models.py
│ └── services.py
└── documents/
├── __init__.py
├── models.py
└── services.py
처음부터 미래의 모든 확장을 예상하여 깊은 계층을 만들기보다, 실제로 독립적인 책임과 변경 경계가 생길 때 구조를 확장할 수 있다.
반대로 오래된 패키지에서 너무 세분화된 모듈이 항상 함께 변경된다면 다시 통합할 수 있다. 모듈과 패키지 구조는 한 번 정한 뒤 고정되는 것이 아니라 프로그램의 기능과 공개 API가 발전하면서 함께 조정되는 설계 요소이다.
개발 과정
모듈과 패키지로 구성된 Python 프로젝트의 일반적인 개발 흐름은 다음과 같이 이루어질 수 있다.
- 프로그램의 실행 진입점과 핵심 기능을 구분한다.
- 서로 밀접한 상태와 동작을 하나의 모듈에 둔다.
- 독립적인 책임과 외부 의존성이 생기면 모듈이나 하위 패키지로 분리한다.
- 패키지의 공개 가져오기 경로를 정한다.
- 가져오기만으로 무거운 초기화가 발생하지 않게 한다.
- 가상 환경에서 프로젝트를 설치한다.
- 공개 패키지 경로를 이용해 테스트와 타입 검사를 수행한다.
- 배포 메타데이터와 의존성을
pyproject.toml에 기록한다. - 소스 배포판과 wheel을 빌드한다.
- 깨끗한 환경에 wheel을 설치해 최종 동작을 검사한다.
Python의 모듈과 패키지는 단순히 코드를 여러 파일로 나누는 문법이 아니다. 개발 과정에서 기능의 소속, 공개 API, 의존 방향, 초기화 시점, 테스트 경계와 배포 단위를 함께 표현한다. 작은 프로젝트에서는 얕고 직접적인 구조가 적합할 수 있고, 여러 기능과 배포 단위를 가진 프로젝트에서는 하위 패키지, 네임스페이스 패키지와 명시적인 빌드 설정을 이용해 구조를 확장할 수 있다.
실행과 구현
Python은 소스 코드를 읽어 즉시 한 줄씩 직접 수행하는 단순한 명령어 처리기로만 동작하지 않는다. 일반적인 Python 구현체는 소스 코드를 어휘·구문 분석하고 실행 가능한 내부 표현으로 변환한 뒤, 해당 구현체의 실행 엔진에서 프로그램을 수행한다. 다만 내부 표현과 최적화 방식, 객체 관리, 스레드 모델과 네이티브 코드 연동 방식은 구현체마다 다를 수 있다.
Python 언어 참조는 이름 바인딩, 표현식 평가, 함수 호출, 예외 전파와 객체 프로토콜 같은 프로그램의 실행 의미를 정의한다. 반면 바이트코드 명령의 종류, 객체의 메모리 배치, 참조 계수, 전역 인터프리터 잠금과 JIT 컴파일 여부는 특정 구현체의 세부 사항이다.
가장 널리 사용되고 Python 개발의 기준이 되는 구현체는 CPython이다. CPython의 인터프리터 핵심은 주로 C로 작성되며, 표준 라이브러리는 Python 모듈과 C 확장 모듈의 조합으로 구성된다.[152]
일반적인 CPython 실행 과정은 다음과 같이 정리할 수 있다.
Python 소스 코드
→ 토큰화와 구문 분석
→ 추상 구문 트리
→ 심볼과 실행 범위 분석
→ 코드 객체와 바이트코드 생성
→ 인터프리터 프레임에서 실행
→ Python 객체에 연산 적용
이 과정은 실행 전에 프로그램 전체를 네이티브 기계어 실행 파일로 변환하는 전통적인 사전 컴파일 언어와 다르다. 동시에 소스 문자열을 매 연산마다 직접 해석하는 구조도 아니다. CPython은 소스 코드를 내부 바이트코드로 컴파일한 뒤 이를 인터프리터에서 실행한다.
언어와 구현체의 구분
Python은 언어의 문법, 실행 의미, 표준 라이브러리와 생태계를 포괄하는 이름이다. CPython은 그 언어를 구현한 구체적인 인터프리터이자 기준 구현체이다.
다음 코드는 Python 언어 차원의 의미를 가진다.
values = [1, 2, 3]
for value in values:
print(value)
목록 객체가 반복 가능하고 for 문이 반복자 프로토콜을 이용한다는 것은 Python 프로그램이 기대할 수 있는 언어 동작이다. 그러나 이 코드를 어떤 바이트코드 명령으로 표현하고, 목록 객체를 메모리에 어떻게 배치하며, 반복 명령을 어떻게 최적화하는지는 구현체가 결정한다.
CPython 전용 세부 사항에 의존하는 코드는 다른 Python 구현체나 다른 CPython 버전에서 동작하지 않을 수 있다.
import dis
dis.dis(function)
dis는 CPython 바이트코드를 분석하는 도구이며, 공식 문서는 바이트코드가 CPython 인터프리터의 구현 세부 사항이라고 명시한다. 명령은 Python 버전 사이에도 추가·삭제·변경될 수 있으므로 안정적인 프로그램 저장 형식이나 구현체 공통 인터페이스로 취급해서는 안 된다.[153]
실행 방식
Python 코드는 여러 방식으로 실행할 수 있다.
스크립트 파일을 직접 실행할 수 있다.
python application.py
패키지나 모듈을 가져오기 시스템을 통해 실행할 수 있다.
python -m application
짧은 소스 코드를 명령행 인자로 전달할 수 있다.
python -c "print('Hello')"
표준 입력이나 대화형 인터프리터에서 코드를 실행할 수도 있다.
python
각 실행 방식은 최상위 코드의 이름과 가져오기 경로, sys.argv, __name__, __package__ 등의 초기 상태에 차이를 만들 수 있다. 패키지 내부 코드는 파일 경로로 직접 실행하기보다 python -m package.module 형식을 사용하면 가져오기 문맥을 유지할 수 있다.
Python 코드를 다른 프로그램 안에 내장하여 실행할 수도 있다. CPython은 C API를 통해 인터프리터를 초기화하고 Python 코드를 평가하거나 Python 객체와 상호작용하는 인터페이스를 제공한다.
소스 코드 분석
CPython은 소스 파일을 먼저 Unicode 텍스트로 디코딩하고 토큰으로 분해한다. 토큰에는 이름, 키워드, 리터럴, 연산자, 줄바꿈과 들여쓰기 정보 등이 포함된다.
def add(left, right):
return left + right
파서는 이 토큰 배열이 Python 문법에 맞는지 검사하고 프로그램의 계층 구조를 만든다. 문법에 맞지 않으면 실행 코드가 만들어지기 전에 SyntaxError가 발생한다.
if enabled
start()
구문 분석 결과는 추상 구문 트리인 AST로 표현할 수 있다. ast 표준 라이브러리는 Python 소스를 파싱하고 AST 객체를 조사하거나 변환하는 기능을 제공한다.
import ast
tree = ast.parse(
"result = left + right"
)
print(
ast.dump(
tree,
indent=4,
)
)
AST는 소스의 괄호와 공백을 그대로 보존하는 문서 트리가 아니라, 대입·호출·연산·조건문과 같은 문법적 의미를 나타내는 구조이다.
컴파일 단계
CPython에서 컴파일은 반드시 네이티브 실행 파일을 만든다는 뜻이 아니다. Python 소스나 AST를 코드 객체와 바이트코드로 변환하는 과정도 컴파일이라고 한다.
내장 compile() 함수로 소스 코드를 코드 객체로 변환할 수 있다.
source = """
result = 10 + 20
print(result)
"""
code = compile(
source,
"<example>",
"exec",
)
생성된 코드 객체는 exec()로 실행할 수 있다.
exec(code)
표현식용 코드 객체는 eval()로 평가할 수 있다.
code = compile(
"10 + 20",
"<expression>",
"eval",
)
result = eval(code)
외부에서 받은 문자열을 eval()이나 exec()에 그대로 전달하면 임의 코드가 실행될 수 있으므로 신뢰할 수 없는 입력에는 사용하지 않는다.
컴파일 과정에서는 이름이 지역 변수인지, 자유 변수인지, 전역 이름인지 등을 분석하고 상수, 중첩 함수와 예외 처리 구조를 코드 객체에 기록한다.
코드 객체
코드 객체는 아직 특정 호출의 지역 상태와 결합되지 않은 실행 가능한 코드 단위를 나타낸다. 모듈 본문, 함수 본문, 컴프리헨션과 중첩 함수는 각각 코드 객체를 가질 수 있다.
def add(left, right):
return left + right
code = add.__code__
코드 객체에는 다음과 같은 정보가 포함될 수 있다.
- 바이트코드 명령
- 상수
- 지역 변수 이름
- 자유 변수와 셀 변수
- 함수 매개변수 정보
- 파일 이름과 함수 이름
- 소스 위치 정보
- 실행 특성을 나타내는 플래그
print(code.co_name)
print(code.co_filename)
print(code.co_varnames)
print(code.co_consts)
코드 객체는 실행 가능한 코드의 표현이지만 함수 객체와는 다르다. 함수 객체는 코드 객체에 전역 이름 공간, 기본 인자, 주석과 클로저 등의 실행 문맥을 결합한다.
print(add.__code__)
print(add.__globals__)
print(add.__defaults__)
print(add.__closure__)
CPython C API 문서는 코드 객체를 실행 코드의 저수준 표현이자 CPython 구현 세부로 설명한다. 내부 구조는 버전 사이에 변경될 수 있다.[154]
바이트코드
CPython은 코드 객체의 실행 명령을 바이트코드 형태로 표현한다. 바이트코드는 CPython 인터프리터가 이해하는 중간 명령 집합이다.
def add(left, right):
return left + right
dis를 사용하면 현재 CPython 버전에서 생성된 명령을 확인할 수 있다.
import dis
dis.dis(add)
출력에는 지역 변수를 스택에 올리는 명령, 이항 연산을 수행하는 명령과 값을 반환하는 명령 등이 나타날 수 있다.
바이트코드는 CPU가 직접 실행하는 x86이나 ARM 기계어가 아니다. CPython의 평가 루프가 각 명령을 읽고 관련된 C 구현과 Python 객체 연산을 수행한다.
바이트코드의 구체적인 명령 이름과 배치는 안정적인 언어 사양이 아니다. 동일한 Python 소스라도 다음 조건에 따라 다른 바이트코드가 생성될 수 있다.
- CPython 버전
- 최적화 옵션
- 디버깅과 계측 설정
- 인터프리터 빌드 구성
- 실행 중 이루어진 명령 특수화
따라서 프로그램의 정상 동작을 특정 바이트코드 배열에 의존시키기보다 언어 참조에 정의된 동작을 기준으로 작성한다.
평가 스택
CPython의 바이트코드 인터프리터는 많은 연산에서 프레임에 연결된 평가 스택을 사용한다.
다음 표현식을 예로 들 수 있다.
result = left + right
개념적으로 인터프리터는 다음 단계를 수행한다.
left 객체를 스택에 올림
right 객체를 스택에 올림
두 객체에 덧셈 연산 적용
결과 객체를 스택에 올림
result 이름에 결과를 저장
실제 명령과 내부 최적화 방식은 버전에 따라 다를 수 있다. Python의 +가 단순한 CPU 정수 덧셈으로 고정되어 있지 않기 때문에, 피연산자의 타입과 특수 메서드에 따라 문자열 연결, 목록 연결이나 사용자 정의 연산으로 이어질 수 있다.
class Vector:
def __add__(self, other):
return Vector(
self.x + other.x,
self.y + other.y,
)
인터프리터는 Python 데이터 모델에 따라 실제 객체에 맞는 연산을 선택한다.
실행 프레임
모듈이나 함수의 코드가 실행되면 해당 실행 상태를 나타내는 프레임이 사용된다. 프레임에는 현재 코드 객체, 실행 위치, 지역·전역 이름 공간, 내장 이름과 평가 상태 등이 연결된다.
def calculate(left, right):
total = left + right
return total
함수가 호출될 때마다 독립적인 프레임이 생성되므로 같은 함수를 동시에 또는 재귀적으로 호출해도 각 호출의 지역 변수가 구분된다.
def factorial(number):
if number <= 1:
return 1
return (
number
* factorial(number - 1)
)
각 재귀 호출은 서로 다른 number 바인딩과 실행 위치를 가진다.
실행 중인 프레임은 sys._getframe()이나 검사 도구를 통해 접근할 수 있지만, 이러한 저수준 기능에 의존하면 구현체 이식성과 최적화 가능성이 낮아질 수 있다.
import sys
frame = sys._getframe()
print(frame.f_code)
print(frame.f_locals)
print(frame.f_globals)
디버거, 프로파일러와 트레이싱 도구는 프레임과 코드 위치를 이용해 프로그램 실행을 조사한다.
함수 호출
함수 호출은 단순히 소스 코드의 함수 본문으로 이동하는 작업만은 아니다. 호출 가능한 객체를 판별하고, 위치·키워드 인자를 매개변수에 대응시키며, 새 프레임과 지역 상태를 준비한 뒤 코드 객체를 실행한다.
result = function(
value,
timeout=30,
)
호출 대상은 사용자 정의 함수에 한정되지 않는다.
- 내장 함수
- 클래스 객체
- 바인딩된 메서드
- C 확장 함수
__call__()을 구현한 인스턴스
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
CPython은 호출 오버헤드를 줄이기 위해 내부 호출 규약을 최적화할 수 있지만, 사용자는 공개 함수 시그니처와 Python 데이터 모델을 기준으로 코드를 작성한다.
모듈 실행과 바이트코드 캐시
모듈을 가져오면 CPython은 소스를 컴파일하여 코드 객체를 만들고 모듈 이름 공간에서 실행한다. 다시 가져올 때의 컴파일 비용을 줄이기 위해 컴파일된 결과를 .pyc 파일로 저장할 수 있다.
application/
├── module.py
└── __pycache__/
└── module.cpython-314.pyc
캐시 파일 이름에는 구현체와 바이트코드 형식을 구분하는 태그가 포함된다. 이를 통해 서로 다른 Python 버전의 캐시가 같은 소스 디렉터리에 공존할 수 있다.[155]
.pyc 파일은 소스를 네이티브 실행 파일로 변환한 결과가 아니다. CPython이 다시 읽을 수 있도록 코드 객체와 관련 정보를 직렬화한 바이트코드 캐시이다.
캐시는 주로 모듈을 불러올 때의 컴파일 시간을 줄인다. 프로그램의 실제 함수 실행 속도를 자동으로 높이는 것은 아니다.
소스가 변경되었거나 캐시가 현재 인터프리터와 맞지 않으면 다시 컴파일한다. 캐시를 삭제해도 원본 소스가 있으면 필요할 때 재생성할 수 있다.
인터프리터 초기화
CPython 프로세스는 사용자 코드가 실행되기 전에 런타임과 기본 모듈, 가져오기 경로와 표준 스트림 등을 초기화한다.
초기 상태에는 다음 요소가 포함될 수 있다.
- 인터프리터와 런타임 상태
- 기본 내장 타입
- 내장 이름 공간
sys와builtins- 가져오기 시스템
- 표준 입력·출력·오류 스트림
- 명령행 인자
- 모듈 검색 경로
- 인코딩과 로캘 관련 상태
시작 과정에서는 환경 변수, 명령행 옵션, 가상 환경과 설치 구성이 반영될 수 있다.
import sys
print(sys.executable)
print(sys.prefix)
print(sys.path)
print(sys.argv)
site 모듈은 일반적인 시작 과정에서 설치별 사이트 패키지 경로와 사용자 지정 기능을 처리할 수 있다. -S 옵션을 사용하면 자동 site 가져오기를 생략할 수 있다.
기준 구현체 CPython
CPython은 Python 프로젝트가 개발하는 기준 인터프리터이다. 새로운 언어 기능은 일반적으로 PEP 논의와 언어 사양 작업을 거쳐 CPython에 구현되며, 다른 구현체는 자신의 실행 환경에 맞게 같은 언어 동작을 구현한다.
CPython 저장소는 대체로 다음 요소를 포함한다.
Parser와 컴파일러
바이트코드 인터프리터
객체와 내장 타입 구현
메모리 관리자와 가비지 컬렉터
Python 및 C 표준 라이브러리
C API
플랫폼별 실행·빌드 코드
테스트와 개발 도구
인터프리터 핵심과 여러 내장 타입은 C로 구현된다. 표준 라이브러리의 상당 부분은 Python으로 작성되며, 성능이나 운영체제 연동이 필요한 부분에는 C 확장 모듈이 사용된다.
CPython은 Python 언어의 유일한 가능한 구현 방식은 아니다. 소프트웨어가 CPython의 C API, 참조 계수나 바이트코드에 직접 의존할수록 다른 구현체로 이동하기 어려워질 수 있다.
객체 표현
CPython의 모든 Python 객체는 내부적으로 타입과 수명 관리에 필요한 공통 정보를 가진 C 구조를 기반으로 한다. 구체 타입은 여기에 자신의 데이터를 추가한다.
개념적으로 객체에는 다음 요소가 필요하다.
객체의 타입
객체 수명 관리 정보
타입별 실제 값과 상태
정수 객체는 정수 값을, 목록 객체는 다른 객체에 대한 참조 배열을, 함수 객체는 코드 객체와 전역 이름 공간 등을 보유한다.
사용자가 작성하는 Python 코드에서는 이러한 내부 구조를 직접 다루지 않는다.
value = 10
items = [value]
인터프리터와 확장 모듈은 C API를 통해 Python 객체를 생성하고 참조하며 연산할 수 있다.
메모리 관리
Python 언어는 더 이상 도달할 수 없는 객체를 구현체가 회수할 수 있다고 정의하지만, 구체적인 회수 방식과 시점은 구현체마다 다를 수 있다.
CPython은 전통적으로 참조 계수를 객체 수명 관리의 주요 수단으로 사용한다. 객체에 대한 강한 참조가 추가되거나 제거될 때 참조 계수가 변경되고, 일반적으로 계수가 0이 되면 객체를 해제할 수 있다.
first = []
second = first
del first
del second
두 이름이 제거되고 다른 참조가 없다면 목록 객체를 정리할 수 있다.
서로를 참조하는 객체처럼 참조 계수만으로 회수할 수 없는 순환 구조를 처리하기 위해 순환 가비지 컬렉터가 함께 사용된다.
first = []
second = []
first.append(second)
second.append(first)
CPython의 즉각적인 참조 계수 동작을 파일 닫기와 같은 중요한 자원 관리에 의존해서는 안 된다. 다른 구현체는 추적식 가비지 컬렉터 등을 사용할 수 있으며, CPython에서도 순환 참조와 종료 상태에 따라 정리 시점이 달라질 수 있다.
메모리 할당자
CPython은 Python 객체를 위한 메모리 관리 계층을 제공한다. 작은 객체 할당의 비용을 줄이기 위해 운영체제의 일반 할당 함수를 매번 직접 호출하지 않고 자체 할당자와 메모리 영역을 사용할 수 있다.
C 확장 모듈은 메모리의 목적에 맞는 CPython 메모리 API를 사용해야 한다. 서로 다른 할당 체계에서 얻은 메모리를 잘못된 함수로 해제하면 충돌이나 메모리 손상이 발생할 수 있다.
일반 Python 코드에서는 객체 생성과 해제를 인터프리터가 관리한다.
values = [
Item(index)
for index in range(1000)
]
메모리 최적화가 필요한 경우 먼저 객체 수와 자료구조, 캐시와 참조 관계를 측정한다. 내부 할당자 동작에 의존한 미세 최적화는 CPython 버전과 빌드 구성에 따라 달라질 수 있다.
전역 인터프리터 잠금
일반적인 GIL 활성 CPython 빌드에서는 전역 인터프리터 잠금, 즉 GIL이 한 시점에 하나의 스레드만 Python 객체와 인터프리터 상태를 직접 실행하도록 제한한다.
from threading import Thread
first = Thread(
target=calculate,
)
second = Thread(
target=calculate,
)
두 스레드가 존재하더라도 순수 Python 바이트코드의 CPU 작업이 일반적인 GIL 활성 빌드에서 여러 코어에 완전히 병렬로 실행된다고 가정할 수 없다.
GIL은 CPython 객체 모델과 참조 계수, C 확장 생태계의 구현을 단순화해 왔다. 입출력 대기나 일부 네이티브 연산에서는 확장 모듈이 GIL을 해제하여 다른 스레드가 실행될 수 있다.
from concurrent.futures import (
ThreadPoolExecutor,
)
with ThreadPoolExecutor() as executor:
results = list(
executor.map(
download,
urls,
)
)
네트워크 대기 중심 작업은 스레드로 동시성을 얻을 수 있다. 여러 CPU 코어를 이용한 순수 Python 계산에는 프로세스, 네이티브 라이브러리 또는 자유 스레딩 빌드 등을 고려할 수 있다.
자유 스레딩 빌드
CPython 3.13부터 GIL을 비활성화할 수 있는 별도의 자유 스레딩 빌드가 제공되며, Python 3.14에서는 지원되는 빌드 선택지로 계속 발전하고 있다. 자유 스레딩 실행에서는 여러 스레드가 서로 다른 CPU 코어에서 Python 코드를 병렬로 수행할 수 있다.[156]
소스 빌드에서는 다음 구성 옵션을 사용할 수 있다.
./configure --disable-gil
자유 스레딩 지원 빌드에서는 실행 시 GIL 사용 여부를 설정할 수 있는 옵션이 제공될 수 있다.
python -X gil=0 application.py
자유 스레딩이 모든 다중 스레드 프로그램을 자동으로 빠르게 만드는 것은 아니다. 작업 분할, 잠금 경쟁, 공유 객체와 네이티브 확장 지원 여부에 따라 성능이 달라진다.
내장 컬렉션은 현재 구현에서 동시 변경으로 인한 내부 손상을 막기 위한 잠금을 사용하지만, 이를 응용 프로그램의 동기화 계약으로 의존해서는 안 된다. 공유 상태의 논리적 일관성에는 threading.Lock 등의 명시적인 동기화 수단을 사용한다.
from threading import Lock
lock = Lock()
def increment():
global counter
with lock:
counter += 1
일부 C 확장 모듈이 자유 스레딩을 지원하지 않으면 해당 모듈을 불러올 때 GIL이 다시 활성화될 수 있다. 따라서 실제 사용 환경에서는 필요한 외부 패키지의 호환성을 확인해야 한다.
인터프리터 특수화
CPython 3.11부터는 자주 실행되는 바이트코드를 실제 객체 타입과 값의 패턴에 맞게 바꾸는 특수화 적응형 인터프리터가 사용된다.
처음에는 일반적인 연산 명령으로 실행한다.
def add_values(left, right):
return left + right
같은 코드가 반복 실행되면서 피연산자가 계속 정수라는 사실이 관찰되면, 인터프리터는 해당 위치를 더 빠른 정수 연산 경로로 특수화할 수 있다. 이후 타입 패턴이 달라지면 일반 명령으로 돌아가거나 다른 형태로 다시 특수화할 수 있다.
PEP 659는 이러한 과정을 quickening과 적응형 명령으로 설명한다. 특수화는 매우 작은 코드 구역에서 빠르게 적용되고, 잘못된 추측에도 낮은 비용으로 대응하도록 설계되었다.[157]
특수화는 Python 언어의 동적 타입 특성을 제거하지 않는다.
print(
add_values(
10,
20,
)
)
print(
add_values(
"Py",
"thon",
)
)
두 호출은 여전히 언어가 정의한 서로 다른 덧셈 의미를 가진다. 인터프리터는 관찰된 실행 패턴을 이용해 빠른 경로를 선택하되, 프로그램 의미가 바뀌면 이에 맞게 적응한다.
인라인 캐시
속성 접근, 전역 이름 조회와 함수 호출에는 타입과 이름 공간을 반복해서 검사하는 비용이 발생할 수 있다. CPython의 특수화 인터프리터는 명령 주변의 인라인 캐시에 이전 실행에서 얻은 정보를 저장하여 같은 형태의 연산을 빠르게 처리할 수 있다.
def read_name(user):
return user.name
user가 반복해서 같은 클래스의 인스턴스이고 클래스 구조가 바뀌지 않는다면 속성 조회 경로를 캐시할 수 있다.
dis는 현재 실행 중인 특수화 상태나 인라인 캐시를 조사하는 옵션을 제공한다.
import dis
dis.dis(
read_name,
adaptive=True,
show_caches=True,
)
이 출력은 성능 분석과 인터프리터 연구에는 유용하지만 특정 캐시 형태나 특수화 명령에 의존하는 응용 프로그램 코드를 작성해서는 안 된다.
슈퍼 명령과 실행 최적화
인터프리터는 자주 연속해서 나타나는 여러 짧은 바이트코드 연산을 결합한 슈퍼 명령을 사용할 수 있다.
def add_local_values(
first,
second,
):
return first + second
지역 변수 두 개를 연속해서 불러오는 작업처럼 반복적인 명령 조합을 하나의 내부 명령으로 처리하면 명령 디스패치 횟수를 줄일 수 있다.
이러한 최적화는 소스 코드에 새로운 의미를 추가하지 않는다. 개발자는 정상적인 Python 코드를 작성하고, 인터프리터가 실행 중 적합한 형태로 최적화한다.
실험적 JIT 컴파일러
CPython은 Python 3.13부터 선택적으로 빌드할 수 있는 실험적 JIT 컴파일러를 포함한다. JIT는 자주 실행되는 내부 명령 흐름을 실행 중 네이티브 기계어로 변환하여 인터프리터 디스패치 비용을 줄이는 것을 목표로 한다.[158]
소스 빌드에서는 구성 옵션으로 JIT 지원을 포함할 수 있다.
./configure \
--enable-experimental-jit
빌드 형태에 따라 환경 변수로 실행 중 활성화할 수 있다.
PYTHON_JIT=1 \
python application.py
Python 3.14의 JIT는 여전히 실험적 기능이므로 모든 플랫폼과 빌드에 기본 활성화되는 일반 실행 방식으로 간주해서는 안 된다. 지원 환경과 성능 특성은 버전 사이에 바뀔 수 있다.
JIT가 활성화되어도 Python의 동적 의미와 객체 모델은 유지된다. 생성된 네이티브 코드는 타입과 전역 상태에 대한 가정을 검사하며, 가정이 더 이상 맞지 않으면 인터프리터나 다른 실행 경로로 돌아갈 수 있다.
짧게 실행되고 종료되는 프로그램은 컴파일 비용을 회수할 시간이 부족할 수 있다. 반복적으로 실행되는 계산이나 장시간 실행되는 서비스에서 더 큰 효과를 얻을 가능성이 있지만, 실제 성능은 측정해야 한다.
꼬리 호출 인터프리터
Python 3.14의 CPython 빌드에는 지원되는 컴파일러 환경에서 실험적인 꼬리 호출 방식의 인터프리터 실행 루프를 선택할 수 있는 구성도 추가되었다.
여기서 꼬리 호출 인터프리터는 Python 함수의 꼬리 재귀를 자동으로 제거하는 언어 기능을 뜻하지 않는다. 바이트코드 명령 처리기 사이의 내부 C 호출 방식을 변경하는 인터프리터 구현 기법이다.
따라서 다음 함수가 무제한 재귀로 최적화되는 것은 아니다.
def factorial(
number,
result=1,
):
if number <= 1:
return result
return factorial(
number - 1,
result * number,
)
Python의 프레임과 트레이스백 의미는 유지되며 재귀 제한도 적용된다. 인터프리터 내부의 이름과 사용자 수준의 꼬리 호출 최적화를 혼동하지 않아야 한다.
네이티브 확장 모듈
CPython은 C와 C++ 등의 언어로 작성된 확장 모듈을 불러올 수 있다. 확장 모듈은 Python 객체와 함수를 제공하면서 운영체제 API나 기존 네이티브 라이브러리, 고성능 계산 코드를 연결한다.
import native_module
result = native_module.calculate(
values
)
확장 모듈은 플랫폼별 공유 라이브러리 형식으로 빌드된다.
native_module.so
native_module.pyd
C 확장은 Python/C API를 통해 다음 작업을 수행할 수 있다.
- Python 객체 생성과 검사
- 함수와 타입 정의
- 예외 발생
- Python 함수 호출
- 버퍼와 메모리 접근
- 인터프리터 모듈 등록
- GIL 및 스레드 상태 관리
네이티브 확장은 높은 성능과 시스템 연동을 제공하지만 메모리 오류, 잘못된 참조 계수와 데이터 경쟁이 인터프리터 충돌로 이어질 수 있다. 순수 Python 예외처럼 안전하게 격리되지 않을 수 있으므로 더 엄격한 검증이 필요하다.
C API와 구현 결합
CPython의 일반 C API는 내부 객체 구조와 참조 계수 모델에 밀접하게 연결된 기능을 포함한다. 이를 사용하는 확장 모듈은 CPython 버전과 ABI, 자유 스레딩 여부에 맞는 별도 빌드가 필요할 수 있다.
PyObject *value =
PyLong_FromLong(10);
C API에서는 참조 소유권을 구분해야 한다.
- 새 참조
- 빌린 참조
- 참조 소유권 이전
참조 규칙을 지키지 않으면 메모리 누수나 이미 해제된 객체 접근이 발생할 수 있다.
Python 코드에서는 인터프리터가 이 작업을 자동으로 처리하지만, C 확장 개발자는 API별 소유권 규칙을 확인해야 한다.
제한된 API와 안정 ABI
CPython은 여러 Python 3 버전에서 바이너리 호환성을 유지하기 위한 제한된 API와 안정 ABI를 제공한다. 확장 모듈이 내부 구조 대신 이 범위의 API만 사용하면 지원되는 후속 CPython 버전에서도 같은 바이너리를 사용할 수 있다.
배포 wheel에서는 안정 ABI를 나타내는 abi3 태그를 사용할 수 있다.
package-1.0.0-cp39-abi3-linux_x86_64.whl
안정 ABI는 모든 CPython 내부 기능을 노출하지 않으며, 성능이나 최신 API 사용을 위해 버전별 일반 API를 선택하는 프로젝트도 있다.
자유 스레딩 빌드는 객체와 스레드 안전성 요구가 다르므로 기존 GIL 활성 ABI와 별도의 호환성 문제가 있다. 확장 모듈은 자신이 사용하는 CPython 버전과 빌드 형태를 명확히 지원해야 한다.
외부 함수 인터페이스
직접 C 확장 모듈을 작성하지 않고 공유 라이브러리의 함수를 호출하는 외부 함수 인터페이스를 사용할 수도 있다.
표준 라이브러리의 ctypes는 C 호환 공유 라이브러리 호출 기능을 제공한다.
from ctypes import CDLL
library = CDLL(
"./library.so"
)
cffi와 같은 외부 도구는 C 선언을 바탕으로 네이티브 라이브러리를 연결할 수 있다.
외부 함수 인터페이스는 빌드 확장 코드를 줄일 수 있지만 잘못된 인자 타입, 포인터와 객체 수명 관리로 프로세스가 충돌할 수 있다. 호출 대상의 ABI와 메모리 소유권을 정확히 따라야 한다.
구현체 확인
현재 실행 중인 Python 구현체와 버전은 sys와 platform을 통해 확인할 수 있다.
import platform
import sys
print(
platform.python_implementation()
)
print(
sys.implementation
)
print(
sys.version
)
CPython 전용 기능이 필요한 경우 구현체를 검사할 수 있다.
import sys
if sys.implementation.name == "cpython":
use_cpython_feature()
그러나 가능하면 구현체 이름보다 필요한 기능의 존재 여부를 검사하는 방식이 더 유연하다.
if hasattr(
sys,
"_is_gil_enabled",
):
...
구현체 검사만으로 모든 버전과 빌드 차이를 설명할 수 없으므로 버전, 플랫폼과 실제 API 지원도 함께 고려해야 한다.
PyPy
PyPy는 Python 언어를 구현하는 독립적인 인터프리터로, 장시간 반복 실행되는 동적 코드를 최적화하는 JIT 컴파일러를 주요 특징으로 한다. PyPy 공식 자료에 따르면 현재 Python 3 계열과 많은 순수 Python 코드 및 표준 라이브러리를 지원하며, C 확장 호환 계층도 제공한다.[159]
PyPy의 JIT는 실행 중 자주 사용되는 경로를 분석하고 최적화된 기계어로 컴파일한다. 반복문과 함수 호출이 많은 순수 Python 프로그램에서는 CPython보다 빠를 수 있다.
total = 0
for number in range(
10_000_000
):
total += transform(
number
)
그러나 실행 시간이 짧으면 JIT 준비 비용을 회수하지 못할 수 있다. CPython C API에 크게 의존하는 확장 모듈은 PyPy에서 호환성이나 성능 문제가 있을 수 있다. PyPy는 CFFI와 HPy처럼 구현체 독립성을 높이는 인터페이스의 사용을 권장한다.[160]
PyPy는 CPython의 참조 계수와 동일한 객체 정리 시점을 보장하지 않는다. 파일과 외부 자원은 항상 컨텍스트 관리자나 명시적인 종료 메서드로 관리해야 한다.
Jython
Jython은 Python을 JVM 위에 구현하고 Java 클래스와 라이브러리에 직접 접근할 수 있도록 설계된 구현체이다.
from java.util import ArrayList
values = ArrayList()
values.add("Python")
Jython은 Java 응용 프로그램에 스크립트 기능을 내장하거나 Python 코드에서 Java 생태계를 사용하는 데 활용될 수 있다.
공식 프로젝트의 현재 안정 계열은 Python 2.7 호환 계열이며, Python 3 구현은 별도로 개발 중이다.[161]
따라서 현대 Python 3 문법과 라이브러리를 전제로 하는 프로젝트가 Jython에서 그대로 동작한다고 가정해서는 안 된다. 대상 Jython 버전이 지원하는 언어 수준과 Java 통합 방식을 확인해야 한다.
IronPython
IronPython은 Python을 .NET 위에 구현한 오픈 소스 구현체이다. Python 코드에서 .NET 어셈블리와 타입을 사용하고, .NET 응용 프로그램에서 Python 실행 환경을 내장할 수 있다.
import clr
clr.AddReference(
"System.Windows.Forms"
)
IronPython은 .NET 타입을 Python 객체 모델과 연결하며 Python 및 .NET 라이브러리를 함께 사용할 수 있다.[162]
IronPython의 Python 3 지원 버전은 CPython의 최신 버전과 항상 일치하지 않는다. CPython 전용 C 확장 모듈은 그대로 사용할 수 없는 경우가 있으며, .NET 통합에 맞는 라이브러리나 순수 Python 구현이 필요할 수 있다.
구현체별 차이
서로 다른 구현체는 같은 Python 언어를 목표로 하지만 다음 영역에서 차이가 날 수 있다.
- 지원하는 Python 언어 버전
- 바이트코드와 내부 실행 형식
- 가비지 컬렉션과 객체 정리 시점
- 스레드와 병렬 실행 모델
- C API 및 네이티브 확장 호환성
- 시작 시간과 장시간 실행 성능
- JVM·.NET 등 호스트 플랫폼 통합
- 디버거와 프로파일러 지원
- 표준 라이브러리의 플랫폼 의존 모듈
- 구현체 전용 기능
다음과 같은 코드는 CPython의 즉각적인 객체 종료에 우연히 의존할 수 있다.
file = open(
"data.txt",
encoding="utf-8",
)
contents = file.read()
file = None
CPython에서 참조가 즉시 사라져 파일이 닫히는 것처럼 보일 수 있지만, 다른 구현체에서는 가비지 컬렉션이 나중에 실행될 수 있다.
이식 가능한 코드는 명시적인 자원 범위를 사용한다.
with open(
"data.txt",
encoding="utf-8",
) as file:
contents = file.read()
구현체 독립적인 코드
여러 Python 구현체에서 동작할 가능성을 높이려면 언어 참조와 공개 표준 라이브러리에 정의된 기능을 중심으로 작성한다.
- CPython 바이트코드 형식에 의존하지 않는다.
- 참조 계수에 따른 즉각적인 객체 해제를 가정하지 않는다.
id()가 메모리 주소라고 가정하지 않는다.- 비공개
sys와 내부 C API 사용을 제한한다. - 네이티브 확장에는 지원 구현체와 플랫폼을 명시한다.
- 구현체별 동작을 사용하는 경우 대체 경로를 제공한다.
- 필요한 구현체에서 실제 테스트를 수행한다.
다음처럼 기능 검사를 사용할 수 있다.
try:
import optional_module
except ImportError:
optional_module = None
타입과 연산의 의미는 구체적인 내부 표현보다 공개 프로토콜을 기준으로 사용한다.
def write_data(
stream,
data,
):
stream.write(data)
최적화와 측정
Python 구현체는 버전마다 새로운 최적화를 추가할 수 있으므로 오래된 성능 상식을 현재 버전에 그대로 적용해서는 안 된다.
성능 개선 과정은 다음과 같이 진행할 수 있다.
실제 작업 측정
→ 느린 구간 식별
→ 알고리즘과 자료구조 검토
→ 불필요한 객체·입출력 제거
→ 표준 라이브러리와 네이티브 연산 활용
→ 구현체 또는 병렬 실행 방식 검토
→ 다시 측정
timeit은 작은 코드 조각을 반복 측정할 수 있다.
from timeit import timeit
duration = timeit(
"sum(range(1000))",
number=10_000,
)
cProfile은 함수 호출 중심 프로파일을 수집한다.
python -m cProfile \
application.py
바이트코드를 직접 조작하거나 구현 세부에 맞춘 복잡한 코드를 작성하기 전에 알고리즘, 데이터 이동량과 외부 입출력을 먼저 검토한다.
특수화 인터프리터와 JIT는 일반적인 관용적 Python 코드를 대상으로 발전한다. 특정 최적화 명령을 유도하기 위해 코드를 부자연스럽게 변형하면 버전 변경에 취약해질 수 있다.
시작 성능과 장시간 실행 성능
Python 구현체의 성능은 하나의 숫자로 비교하기 어렵다.
짧은 명령행 도구에서는 다음 요소가 중요할 수 있다.
- 인터프리터 시작 시간
- 가져오는 모듈 수
- 패키지 초기화 비용
- 명령행 인자 해석 시간
장시간 실행되는 서버나 계산 프로그램에서는 다음 요소가 중요할 수 있다.
- 반복 코드의 최적화
- 메모리 사용과 가비지 컬렉션
- 네이티브 라이브러리 호출
- 동시 요청 처리
- JIT 준비 비용과 최종 성능
PyPy의 JIT는 장시간 실행되는 순수 Python 코드에 유리할 수 있지만 시작과 준비 비용이 있다. CPython은 C 확장 생태계와 시작 특성, 구현 호환성에서 강점을 가질 수 있다.
실제 배포 환경의 입력과 외부 라이브러리를 이용해 측정해야 한다.
배포와 실행 환경
Python 프로그램은 일반적으로 대상 시스템에 설치된 인터프리터와 패키지 환경에서 실행된다.
Python 실행 파일
표준 라이브러리
응용 프로그램 패키지
외부 의존성
네이티브 공유 라이브러리
설정과 데이터 파일
가상 환경은 프로젝트별로 Python 실행 파일과 설치 패키지 경로를 분리한다.
python -m venv .venv
.venv/bin/python \
-m application
Windows에서는 실행 경로가 다를 수 있다.
프로그램을 독립 실행 파일이나 응용 프로그램 번들처럼 배포하는 도구도 있다. 이러한 도구는 일반적으로 Python 코드를 C나 기계어로 완전히 변환한다고 단정할 수 없으며, 인터프리터와 바이트코드, 네이티브 확장 및 자원을 묶어 배포할 수도 있다.
배포 형식의 실제 동작은 사용한 도구에 따라 확인해야 한다.
임베딩
CPython은 다른 C나 C++ 응용 프로그램에 Python 실행 환경을 내장할 수 있다.
호스트 프로그램 시작
→ CPython 런타임 초기화
→ Python 경로와 설정 구성
→ 모듈 또는 코드 실행
→ Python 객체와 데이터 교환
→ 인터프리터 종료
게임 엔진, 디지털 콘텐츠 제작 도구, 데이터베이스와 자동화 응용 프로그램은 Python을 스크립트 언어로 내장할 수 있다.
임베딩 프로그램은 인터프리터 초기화 순서, 스레드 상태, 모듈 검색 경로와 종료 과정을 직접 관리해야 한다. 여러 독립 실행 환경이 필요한 경우 서브인터프리터를 사용할 수 있지만 모든 C 확장 모듈이 서브인터프리터 격리를 지원하는 것은 아니다.
서브인터프리터
하나의 프로세스 안에 여러 Python 인터프리터 상태를 만들 수 있다. 각 서브인터프리터는 별도의 모듈 상태와 전역 이름 공간을 가질 수 있다.
하나의 프로세스
├── 주 인터프리터
├── 서브인터프리터 A
└── 서브인터프리터 B
그러나 프로세스의 네이티브 자원과 일부 C 수준 전역 상태는 공유될 수 있다. 확장 모듈이 모듈 상태를 전역 정적 변수에 저장하면 여러 인터프리터에서 안전하지 않을 수 있다.
현대 CPython의 확장 모듈 설계는 모듈별 상태와 다단계 초기화를 통해 인터프리터 사이의 격리를 높이는 방향으로 발전하고 있다.
플랫폼 지원
CPython은 운영체제, CPU와 컴파일러에 맞게 포팅된다. 같은 Python 소스라도 플랫폼별로 다음 요소가 달라질 수 있다.
- 파일 경로와 파일 시스템
- 프로세스와 신호
- 소켓 기능
- 동적 라이브러리 형식
- 사용 가능한 표준 라이브러리 모듈
- 네이티브 확장 바이너리
- 정수 크기와 ABI 관련 세부
- 이벤트 루프 구현
- 빌드 옵션
Python 언어 수준의 정수는 임의 정밀도를 제공하지만, C 확장과 운영체제 API에는 고정 크기 정수가 사용될 수 있다.
플랫폼별 기능은 os, sys, platform과 sysconfig 등을 통해 확인할 수 있다.
import platform
import sys
print(sys.platform)
print(platform.machine())
print(platform.system())
플랫폼 이름을 직접 비교하기보다 필요한 기능을 검사하거나 표준 라이브러리의 추상화 기능을 사용하는 편이 적합할 수 있다.
실행 환경 검사
현재 인터프리터의 실행 특성은 다음과 같이 확인할 수 있다.
import sys
import sysconfig
print(sys.version)
print(sys.implementation)
print(sys.abiflags)
print(
sysconfig.get_config_var(
"Py_GIL_DISABLED"
)
)
sys.implementation은 구현체의 이름과 버전, 캐시 태그 등의 정보를 제공한다.
print(
sys.implementation.name
)
print(
sys.implementation.cache_tag
)
환경 정보는 진단 로그와 호환성 검사에 유용하다. 그러나 지원되지 않는 환경에서 임의로 실행을 계속하기보다 설치 메타데이터의 Python 버전과 플랫폼 요구 사항을 정확히 선언하는 것이 우선이다.
디버깅과 모니터링
Python 실행기는 디버거와 프로파일러가 코드 실행을 관찰할 수 있는 인터페이스를 제공한다.
전통적인 추적 기능은 함수 호출, 줄 실행, 반환과 예외 이벤트를 콜백으로 전달할 수 있다.
import sys
def trace(
frame,
event,
argument,
):
print(
event,
frame.f_code.co_name,
)
return trace
sys.settrace(trace)
추적은 모든 관련 실행 이벤트에 개입하므로 상당한 오버헤드를 만들 수 있다.
현대 CPython에는 더 낮은 오버헤드로 선택적인 실행 이벤트를 관찰하기 위한 sys.monitoring 인터페이스도 제공된다. 디버거, 커버리지와 프로파일링 도구는 실행 위치와 코드 객체 정보를 이용한다.
계측이 활성화되면 일부 인터프리터 특수화가 제한되거나 실행 성능이 달라질 수 있다. 측정 대상에 맞는 도구와 설정을 선택해야 한다.
구현 세부 의존의 활용
CPython 세부 기능을 사용하는 것 자체가 항상 잘못된 것은 아니다. 다음과 같은 도구에는 구현 내부 정보가 필요할 수 있다.
- 디버거
- 프로파일러
- 커버리지 도구
- 바이트코드 분석기
- 프레임워크 런타임
- 네이티브 확장
- 보안 분석 도구
- 인터프리터 연구
이 경우 지원하는 CPython 버전을 명시하고 내부 구조가 바뀔 때 대응해야 한다.
if sys.version_info[:2] != (
3,
14,
):
raise RuntimeError(
"CPython 3.14 required"
)
일반 라이브러리와 응용 프로그램은 가능한 한 공개 언어 및 라이브러리 인터페이스를 사용하는 편이 유지보수에 유리하다.
실행 모델의 의미
Python 프로그램은 소스 코드를 바이트코드로 바꿔 순서대로 실행하는 것만으로 설명되지 않는다. 실제 실행에는 다음 요소가 함께 관여한다.
- 객체의 동적 타입과 프로토콜
- 이름 공간과 실행 범위
- 함수 호출과 프레임
- 예외 전파
- 반복자와 코루틴의 실행 중단
- 가져오기와 모듈 캐시
- 메모리 및 객체 수명 관리
- 인터프리터 특수화
- 스레드와 이벤트 루프
- 네이티브 확장
result = object.method(
argument
)
이 짧은 표현식에서도 이름 조회, 속성 검색, 디스크립터에 의한 메서드 바인딩, 인자 대응, 새 프레임 또는 네이티브 호출, 반환값 생성과 예외 처리가 이루어질 수 있다.
Python은 이러한 복잡성을 공통 객체 모델과 문법 뒤에 감춘다. 사용자는 같은 소스 코드를 여러 플랫폼과 구현체에서 실행할 수 있지만, 성능과 자원 관리, 네이티브 호환성이 중요한 영역에서는 실행하는 구현체의 특성도 함께 고려해야 한다.
CPython은 바이트코드 인터프리터와 C 확장 생태계를 중심으로 발전해 왔으며, 적응형 특수화, 실험적 JIT와 자유 스레딩을 통해 실행 방식을 확장하고 있다. PyPy, Jython과 IronPython은 각각 JIT 최적화, JVM과 .NET 통합이라는 다른 실행 환경을 제공한다. 이들 구현체는 Python의 언어 의미를 공유하면서도 내부 실행 기술과 호환 범위에서 차이를 가진다.
표준 라이브러리
Python은 인터프리터와 함께 광범위한 표준 라이브러리를 제공한다. 표준 라이브러리는 언어의 문법과 핵심 실행 의미를 정의하는 언어 참조와 구분되며, 실제 프로그램을 작성할 때 필요한 내장 함수, 자료형, 예외와 여러 모듈을 포함한다.[163]
Python의 표준 라이브러리는 흔히 배터리 포함이라는 표현으로 설명된다. 이는 기본적인 프로그램을 작성할 때 자주 필요한 기능을 별도의 외부 패키지 없이 사용할 수 있도록 제공한다는 의미이다.[164]
표준 라이브러리에는 대략 다음과 같은 기능이 포함된다.
- 문자열, 숫자, 날짜와 시간 처리
- 목록 이외의 전문 자료구조와 반복 도구
- 파일·디렉터리와 운영체제 기능
- 경로와 임시 파일 관리
- JSON, CSV와 다양한 데이터 형식 처리
- 압축과 보관 파일 처리
- 정규 표현식
- 네트워크 통신과 인터넷 프로토콜
- 동시성, 병렬 처리와 비동기 입출력
- 프로세스와 스레드 관리
- 데이터베이스 접근
- 로깅, 테스트, 디버깅과 프로파일링
- 명령행 인자 처리
- 객체 복사, 직렬화와 리플렉션
- Python 구문, 바이트코드와 가져오기 체계 조사
from pathlib import Path
import json
configuration = json.loads(
Path("config.json").read_text(
encoding="utf-8"
)
)
위 코드는 pathlib과 json을 이용해 외부 패키지 없이 파일 경로와 JSON 데이터를 처리한다.
표준 라이브러리는 Python으로 작성된 모듈과 CPython 등의 구현체에 내장된 네이티브 모듈로 구성된다. 일부 모듈은 운영체제별 기능을 공통 인터페이스로 추상화하여 서로 다른 플랫폼에서 비슷한 코드 구조를 사용할 수 있게 한다.[165]
from pathlib import Path
data_path = (
Path.home()
/ "application"
/ "data.json"
)
pathlib은 운영체제마다 다른 경로 구분 방식의 상당 부분을 객체 기반 인터페이스로 처리한다.
표준 라이브러리의 모든 모듈이 모든 Python 구현체와 플랫폼에서 동일하게 제공되는 것은 아니다. 운영체제 기능이나 선택적인 외부 라이브러리에 의존하는 모듈은 특정 환경에서 사용할 수 없거나 일부 기능만 제공할 수 있다. Python이 운영체제의 여러 패키지로 분리되어 배포되는 경우에는 선택적인 표준 라이브러리 구성 요소를 별도로 설치해야 할 수도 있다.[166]
표준 라이브러리 모듈은 Python 버전과 함께 발전한다. 새로운 기능이 추가되거나 기존 인터페이스가 개선될 수 있으며, 오래되었거나 유지하기 어려운 모듈은 폐기 예정 단계를 거쳐 제거될 수 있다. 따라서 지원하는 Python 버전에 따라 사용할 수 있는 모듈과 기능이 달라질 수 있다.
표준 라이브러리는 안정성과 이식성을 중시하지만, 모든 전문 분야와 빠르게 변화하는 기술을 포함하는 것을 목표로 하지는 않는다. 웹 프레임워크, 과학 계산, 머신러닝과 특정 데이터베이스 클라이언트처럼 독립적인 발전 주기가 필요한 기능은 주로 외부 패키지 생태계에서 제공된다.
import sqlite3
connection = sqlite3.connect(
"application.db"
)
SQLite처럼 일반적인 프로그램 기반 기능은 표준 라이브러리에 포함되지만, 특정 데이터베이스 서버와 고수준 객체 관계 매핑 기능은 외부 패키지를 사용하는 경우가 많다.
프로젝트에서는 먼저 표준 라이브러리가 필요한 기능을 제공하는지 확인하고, 요구 사항을 충족하지 못하거나 더 전문적인 기능이 필요할 때 외부 패키지를 도입할 수 있다. 표준 라이브러리를 사용하면 외부 의존성 수를 줄일 수 있지만, 항상 외부 패키지보다 기능이나 성능이 우수하다는 의미는 아니다.
각 모듈의 기능과 사용법, 플랫폼별 가용성 및 버전 변화는 Python 표준 라이브러리 문서에서 별도로 다룬다.
동시성과 병렬 처리
Python은 하나의 프로그램에서 여러 작업을 함께 진행하기 위해 스레드, 프로세스, 이벤트 루프와 다중 인터프리터를 지원한다. 각 방식은 작업을 실행하는 단위와 메모리 공유 방식, 실행 오버헤드, 오류 격리와 CPU 병렬 실행 가능성에서 차이가 있다.
동시성은 여러 작업이 같은 시간 구간에 걸쳐 진행되는 구조를 뜻한다. 작업이 실제로 같은 순간에 실행되지 않더라도 한 작업이 입출력을 기다리는 동안 다른 작업을 진행하면 동시성이 성립한다.
병렬성은 둘 이상의 작업이 서로 다른 CPU 코어나 실행 장치에서 실제로 동시에 수행되는 것을 뜻한다. 동시 프로그램이 항상 병렬로 실행되는 것은 아니며, 병렬 프로그램은 여러 작업을 동시에 관리한다는 점에서 동시성도 포함한다.
동시성
→ 여러 작업의 진행 시간을 겹침
→ 하나의 스레드에서도 구현 가능
병렬성
→ 여러 작업을 실제로 동시에 실행
→ 여러 CPU 코어·프로세스·인터프리터 등이 필요
Python에서 사용할 실행 방식은 대체로 다음과 같이 구분할 수 있다.
입출력 대기가 많은 작업
→ asyncio 또는 스레드
CPU 계산이 많은 작업
→ 프로세스, 다중 인터프리터,
자유 스레딩 빌드 또는 네이티브 연산
상태와 주소 공간의 강한 격리가 필요한 작업
→ 프로세스
공유 메모리를 쉽게 사용해야 하는 작업
→ 스레드
매우 많은 네트워크 연결을 다루는 작업
→ asyncio
Python 표준 라이브러리는 작업 특성에 따라 threading, multiprocessing, asyncio, concurrent.futures와 concurrent.interpreters 등을 제공한다.[167]
작업의 종류
동시 실행 방식을 선택할 때는 작업이 대부분의 시간을 어디에 사용하는지 먼저 구분할 수 있다.
입출력 중심 작업은 파일, 네트워크, 데이터베이스와 외부 서비스의 응답을 기다리는 시간이 길다.
def download(url):
with urlopen(url) as response:
return response.read()
이 함수의 실행 시간 대부분이 네트워크 응답 대기라면, 여러 요청을 스레드나 비동기 입출력로 겹쳐 처리할 수 있다.
CPU 중심 작업은 계산 자체가 실행 시간의 대부분을 차지한다.
def calculate(limit):
total = 0
for number in range(limit):
total += transform(number)
return total
일반적인 GIL 활성 CPython에서는 여러 스레드가 이와 같은 순수 Python 계산을 실행해도 한 시점에 하나의 스레드만 Python 바이트코드를 실행한다. CPU 코어를 병렬로 사용하려면 프로세스나 독립 인터프리터, 자유 스레딩 빌드 또는 GIL을 해제하는 네이티브 라이브러리를 사용할 수 있다.[168]
실제 프로그램은 입출력과 계산을 함께 수행하는 경우가 많다.
네트워크에서 데이터 수신
→ 압축 해제
→ 데이터 변환
→ 데이터베이스 저장
각 단계의 비용을 측정하고, 네트워크 대기는 비동기 처리로 겹치며 계산 단계는 프로세스 풀로 전달하는 혼합 구조도 사용할 수 있다.
스레드
스레드는 하나의 프로세스 안에서 실행되는 독립적인 제어 흐름이다. 같은 프로세스의 스레드는 모듈, 전역 변수와 대부분의 객체를 공유한다.
import threading
def worker(name):
print(f"start: {name}")
perform_work()
print(f"end: {name}")
first = threading.Thread(
target=worker,
args=("first",),
)
second = threading.Thread(
target=worker,
args=("second",),
)
first.start()
second.start()
first.join()
second.join()
start()는 새 스레드에서 대상 함수를 실행하고, join()은 해당 스레드가 끝날 때까지 호출한 스레드를 기다리게 한다.
threading 모듈은 같은 프로세스 안에서 여러 스레드를 생성하고 관리한다. 스레드는 메모리를 공유하므로 데이터를 주고받기 쉽고 프로세스보다 생성 비용이 작은 편이다. 특히 파일과 네트워크 요청처럼 대기 시간이 긴 입출력 작업에 적합하다.[169]
스레드의 공유 메모리
같은 프로세스의 스레드는 동일한 객체에 접근할 수 있다.
results = []
def worker(value):
result = calculate(value)
results.append(result)
여러 스레드가 results 목록을 공유한다. 별도의 직렬화나 프로세스 간 통신 없이 객체를 전달할 수 있다는 점은 스레드의 주요 장점이다.
그러나 공유 메모리는 한 스레드의 변경이 다른 스레드에서 즉시 관찰될 수 있다는 뜻이기도 하다.
configuration = {
"enabled": False,
}
def enable():
configuration["enabled"] = True
여러 스레드가 같은 객체를 읽고 변경하면 실행 순서에 따라 프로그램 결과가 달라질 수 있다. 공유 가능한 것과 안전하게 공유할 수 있는 것은 서로 다른 개념이다.
GIL
일반적인 CPython 빌드에는 전역 인터프리터 잠금, 즉 GIL이 존재한다. GIL 활성 빌드에서는 한 시점에 하나의 스레드만 Python 객체에 접근하며 Python 바이트코드를 실행할 수 있다.[170]
from threading import Thread
first = Thread(
target=cpu_intensive_work,
)
second = Thread(
target=cpu_intensive_work,
)
두 스레드를 실행하더라도 순수 Python CPU 계산이 두 코어에서 완전히 병렬로 실행된다고 가정할 수 없다.
GIL은 운영체제 스레드 자체를 하나만 허용하는 기능이 아니다. 여러 스레드는 존재하며 다음과 같은 상황에서는 실행이 겹칠 수 있다.
- 한 스레드가 파일이나 네트워크 입출력을 기다림
- C 확장 모듈이 긴 연산 동안 GIL을 해제함
- 운영체제 호출이 대기 상태로 들어감
- 다른 스레드가 Python 코드를 실행할 차례를 받음
따라서 GIL이 활성화된 CPython에서도 스레드는 입출력 동시성에 유용하다.
from concurrent.futures import (
ThreadPoolExecutor,
)
with ThreadPoolExecutor(
max_workers=8,
) as executor:
results = executor.map(
download,
urls,
)
네트워크 요청이 대기하는 동안 다른 스레드가 다른 요청을 진행할 수 있다.
GIL이 동기화를 대신하지 않는 이유
GIL은 응용 프로그램의 데이터 일관성을 보장하는 잠금이 아니다.
counter = 0
def increment():
global counter
counter += 1
counter += 1은 개념적으로 다음과 같은 여러 단계로 이루어질 수 있다.
counter 읽기
→ 1을 더함
→ 결과를 counter에 다시 저장
두 스레드가 이 단계 사이에서 전환되면 한쪽 증가 결과가 사라질 수 있다. GIL 활성 빌드에서도 복합적인 읽기·수정·쓰기 작업은 명시적인 동기화가 필요하다.
from threading import Lock
counter = 0
counter_lock = Lock()
def increment():
global counter
with counter_lock:
counter += 1
또한 CPython의 특정 내장 연산이 현재 구현에서 원자적으로 보이더라도 이를 일반적인 동시성 계약으로 의존해서는 안 된다. Python 버전, 구현체와 자유 스레딩 여부에 따라 내부 실행 방식이 달라질 수 있다.
자유 스레딩
CPython 3.13부터는 GIL을 비활성화할 수 있는 별도의 자유 스레딩 빌드가 제공된다. 자유 스레딩 실행에서는 여러 스레드가 여러 CPU 코어에서 Python 코드를 병렬로 실행할 수 있다.[171]
import sys
if hasattr(
sys,
"_is_gil_enabled",
):
print(
sys._is_gil_enabled()
)
빌드의 자유 스레딩 지원 여부는 sysconfig로 확인할 수 있다.
import sysconfig
supports_free_threading = (
sysconfig.get_config_var(
"Py_GIL_DISABLED"
)
== 1
)
소스에서 CPython을 빌드할 때는 다음 옵션을 사용할 수 있다.
./configure --disable-gil
자유 스레딩 빌드는 실행 시 GIL을 다시 활성화할 수도 있다.
python -X gil=1 application.py
일부 C 확장 모듈이 자유 스레딩을 지원하지 않으면 해당 모듈을 가져올 때 GIL이 자동으로 다시 활성화될 수 있다.
자유 스레딩은 다음 코드가 여러 코어에서 병렬로 실행될 가능성을 제공한다.
from concurrent.futures import (
ThreadPoolExecutor,
)
with ThreadPoolExecutor() as executor:
results = executor.map(
calculate,
workloads,
)
그러나 실제 성능은 다음 요소에 따라 달라진다.
- 작업 크기와 스레드 생성 비용
- 공유 객체에 대한 잠금 경쟁
- 메모리 대역폭
- 사용 중인 C 확장 모듈의 지원
- 단일 스레드 성능 변화
- 가비지 컬렉션과 객체 할당량
자유 스레딩이 모든 스레드 프로그램을 자동으로 빠르게 만들지는 않는다. 병렬 실행 가능한 독립 작업이 충분하고 공유 상태 경쟁이 적어야 효과를 얻기 쉽다.
자유 스레딩과 내장 객체
자유 스레딩 CPython은 목록, 사전과 집합 등의 내장 타입이 동시에 변경될 때 내부 메모리가 손상되지 않도록 내부 잠금을 사용한다.
shared = []
def append_value(value):
shared.append(value)
내부 잠금은 인터프리터 충돌을 막기 위한 구현 안전성을 제공하지만, 여러 연산을 하나의 논리적 작업으로 묶어 주지는 않는다.
if key not in cache:
cache[key] = calculate(key)
두 스레드가 동시에 키를 검사하면 calculate()가 두 번 실행될 수 있다. 한 번만 계산되어야 한다면 명시적인 잠금이 필요하다.
with cache_lock:
if key not in cache:
cache[key] = calculate(key)
응용 프로그램의 불변 조건과 상태 전환은 자유 스레딩 여부와 관계없이 프로그램 수준에서 동기화해야 한다.
스레드 수명
Thread 객체는 일반적으로 생성, 시작과 종료 대기 단계를 가진다.
thread = threading.Thread(
target=worker,
)
thread.start()
thread.join()
하나의 Thread 객체는 한 번만 시작할 수 있다.
스레드 함수에서 처리되지 않은 예외가 발생하면 해당 스레드는 종료된다. 기본적으로 예외 정보는 threading.excepthook()을 통해 처리된다.
def handle_thread_error(args):
logger.error(
"thread failed",
exc_info=(
args.exc_type,
args.exc_value,
args.exc_traceback,
),
)
threading.excepthook = (
handle_thread_error
)
작업 스레드의 실패를 주 스레드에 전달해야 한다면 Future, Queue 또는 공유된 오류 상태를 사용할 수 있다.
데몬 스레드
스레드는 데몬 스레드로 설정할 수 있다.
thread = threading.Thread(
target=background_worker,
daemon=True,
)
데몬 스레드만 남으면 Python 프로세스가 종료될 수 있다. 데몬 스레드는 정상적인 정리 절차 없이 중단될 수 있으므로 파일 저장, 트랜잭션 완료와 중요한 자원 정리를 맡기기에는 적합하지 않다.
정상적으로 종료해야 하는 작업은 비데몬 스레드로 실행하고 명시적인 종료 신호와 join()을 사용하는 구조가 적합하다.
stop_event = threading.Event()
def worker():
while not stop_event.is_set():
process_next_item()
thread = threading.Thread(
target=worker,
)
thread.start()
stop_event.set()
thread.join()
잠금
threading.Lock은 한 시점에 하나의 스레드만 특정 임계 구역에 들어가도록 한다.
lock = threading.Lock()
with lock:
update_shared_state()
이는 다음 구조와 비슷하다.
lock.acquire()
try:
update_shared_state()
finally:
lock.release()
with를 사용하면 예외가 발생해도 잠금이 해제된다.
잠금으로 보호되는 코드 영역은 가능한 한 짧게 유지할 수 있다.
result = calculate_without_lock()
with lock:
shared_results.append(result)
계산 전체를 잠금 안에서 실행하는 것보다 공유 상태를 실제로 변경하는 짧은 구간만 보호하면 다른 스레드의 대기 시간을 줄일 수 있다.
재진입 잠금
threading.RLock은 같은 스레드가 여러 번 획득할 수 있는 재진입 잠금이다.
lock = threading.RLock()
def outer():
with lock:
inner()
def inner():
with lock:
update_state()
일반 Lock이라면 outer()가 잠금을 보유한 상태에서 inner()가 같은 잠금을 다시 기다려 교착 상태에 빠질 수 있다.
RLock은 획득 횟수를 추적하며 같은 수만큼 해제되어야 다른 스레드가 잠금을 얻을 수 있다.
재진입 잠금은 중첩된 공개 메서드가 같은 객체 상태를 보호하는 경우 등에 유용하다. 불필요하게 사용하면 잠금 구조가 복잡해지고 설계상의 순환 호출을 숨길 수 있다.
조건 변수
threading.Condition은 특정 상태가 만족될 때까지 스레드가 기다리게 한다.
condition = threading.Condition()
items = []
def consumer():
with condition:
while not items:
condition.wait()
item = items.pop()
생산자는 상태를 변경한 뒤 기다리는 스레드에 알린다.
def producer(item):
with condition:
items.append(item)
condition.notify()
wait()는 기다리는 동안 조건 변수에 연결된 잠금을 해제하고, 깨어날 때 다시 획득한다.
깨어났다고 해서 조건이 반드시 만족된다는 보장은 없으므로 일반적으로 while로 조건을 다시 검사한다.
with condition:
while not ready:
condition.wait()
wait_for()를 사용할 수도 있다.
with condition:
condition.wait_for(
lambda: ready
)
이벤트
threading.Event는 하나의 상태 플래그를 여러 스레드에 알린다.
ready = threading.Event()
def worker():
ready.wait()
start_processing()
다른 스레드가 플래그를 설정한다.
prepare_resources()
ready.set()
플래그를 다시 거짓으로 만들 수 있다.
ready.clear()
Event는 시작 신호, 종료 요청과 초기화 완료 알림처럼 복잡한 값 전달이 필요하지 않은 상태 신호에 적합하다.
세마포어
세마포어는 동시에 자원을 사용할 수 있는 스레드 수를 제한한다.
semaphore = threading.Semaphore(
10
)
def request(url):
with semaphore:
return send_request(url)
스레드를 수백 개 만들더라도 실제 외부 연결은 동시에 최대 10개만 열리게 할 수 있다.
BoundedSemaphore는 초기값보다 더 많이 해제하는 오류를 감지할 수 있다.
semaphore = (
threading.BoundedSemaphore(10)
)
세마포어는 데이터베이스 연결 수, 파일 핸들, 외부 API 요청량과 메모리 집약적인 작업 수를 제한하는 데 사용할 수 있다.
장벽
threading.Barrier는 정해진 수의 스레드가 모두 특정 지점에 도착할 때까지 기다리게 한다.
barrier = threading.Barrier(3)
def worker():
prepare()
barrier.wait()
execute_phase()
세 스레드가 모두 wait()에 도달하면 함께 다음 단계로 진행한다.
여러 스레드가 단계별 계산을 수행하고 각 단계 사이에서 동기화해야 하는 경우 사용할 수 있다. 일부 스레드가 실패하거나 도착하지 않으면 장벽이 깨질 수 있으므로 시간 제한과 예외 처리를 고려해야 한다.
스레드 안전 Queue
queue.Queue는 여러 생산자와 소비자 스레드가 안전하게 데이터를 교환할 수 있는 동기화된 Queue이다.[172]
from queue import Queue
work_queue = Queue()
def worker():
while True:
item = work_queue.get()
try:
if item is None:
return
process(item)
finally:
work_queue.task_done()
생산자는 작업을 넣는다.
for item in items:
work_queue.put(item)
완료를 기다릴 수 있다.
work_queue.join()
Queue를 사용하면 여러 스레드가 하나의 공유 목록을 직접 검사하고 잠그는 코드보다 생산자와 소비자의 역할을 명확하게 분리할 수 있다.
크기 제한을 사용하면 생산 속도가 소비 속도보다 지나치게 빠를 때 생산자를 대기시킬 수 있다.
work_queue = Queue(
maxsize=100
)
스레드별 상태
threading.local()은 각 스레드가 독립적인 값을 가지는 스레드 지역 저장소를 제공한다.
thread_state = threading.local()
def worker():
thread_state.connection = (
create_connection()
)
use(
thread_state.connection
)
다른 스레드의 thread_state.connection은 별도의 값이다.
스레드별 데이터베이스 연결이나 요청 문맥에 사용할 수 있지만, 값의 생성과 정리 시점이 숨겨질 수 있다. 가능한 경우 함수 인자와 명시적인 객체로 상태를 전달하는 방식이 더 명확할 수 있다.
비동기 Task까지 이어지는 실행 문맥에는 contextvars를 사용할 수 있다.
from contextvars import (
ContextVar,
)
request_id = ContextVar(
"request_id"
)
Context Variable은 스레드 지역 저장소와 달리 비동기 Task의 문맥 전환도 지원한다.
교착 상태
둘 이상의 실행 흐름이 서로 상대방이 가진 자원을 기다리며 영원히 진행하지 못하는 상태를 교착 상태라고 한다.
first_lock = Lock()
second_lock = Lock()
def first():
with first_lock:
with second_lock:
...
def second():
with second_lock:
with first_lock:
...
first()가 첫 번째 잠금을 보유하고 second()가 두 번째 잠금을 보유하면 서로 나머지 잠금을 기다릴 수 있다.
잠금 획득 순서를 프로그램 전체에서 일관되게 유지하면 위험을 줄일 수 있다.
def first():
with first_lock:
with second_lock:
...
def second():
with first_lock:
with second_lock:
...
다른 방안은 다음과 같다.
- 잠금 수를 줄임
- Queue를 이용한 메시지 전달
- 시간 제한이 있는 잠금 획득
- 공유 상태의 소유자를 하나로 제한
- 임계 구역에서 다른 함수나 Future를 기다리지 않음
ThreadPoolExecutor에서도 작업이 같은 풀의 다른 Future를 기다리면 사용 가능한 worker 수에 따라 교착 상태가 발생할 수 있다.[173]
라이브락과 기아
교착 상태가 아니더라도 작업이 실질적으로 진행되지 못할 수 있다.
라이브락은 여러 작업이 서로 충돌을 피하려고 상태를 계속 바꾸지만 실제 작업은 완료하지 못하는 상황이다.
작업 A가 양보
→ 작업 B도 양보
→ 작업 A가 다시 양보
→ 반복
기아는 특정 작업이 잠금이나 실행 시간을 지속적으로 얻지 못하는 상태이다. 짧은 작업이 계속 먼저 처리되거나 한 스레드가 잠금을 지나치게 오래 보유하면 발생할 수 있다.
잠금 공정성은 모든 동기화 객체에서 강하게 보장되지 않는다. 작업 크기 제한, Queue 정책과 명시적인 스케줄링으로 특정 작업이 무기한 대기하지 않도록 설계할 수 있다.
프로세스
프로세스는 독립된 주소 공간과 Python 인터프리터 상태를 가진 실행 단위이다. multiprocessing은 threading과 비슷한 API로 여러 프로세스를 생성하고 작업을 병렬로 실행할 수 있게 한다.[174]
from multiprocessing import (
Process,
)
def worker(value):
print(calculate(value))
if __name__ == "__main__":
process = Process(
target=worker,
args=(10,),
)
process.start()
process.join()
각 프로세스는 독립된 메모리 공간을 가지므로 한 프로세스의 일반 Python 객체 변경이 다른 프로세스에 직접 반영되지 않는다.
values = []
def worker():
values.append(1)
자식 프로세스에서 values를 변경해도 부모 프로세스의 목록이 자동으로 변경되는 것은 아니다.
프로세스는 일반적인 GIL 활성 CPython에서도 서로 독립적으로 Python 코드를 실행하므로 여러 CPU 코어를 사용할 수 있다.
프로세스의 장단점
프로세스 방식은 다음과 같은 장점을 가진다.
- GIL 활성 빌드에서도 CPU 병렬 실행 가능
- 주소 공간이 분리되어 오류 격리가 강함
- 한 worker의 전역 상태 변경이 다른 worker에 직접 영향을 주지 않음
- 특정 worker를 종료하거나 교체할 수 있음
반면 다음 비용이 있다.
- 프로세스 생성 비용
- 독립 인터프리터와 모듈로 인한 메모리 사용
- 객체 직렬화와 데이터 전송 비용
- 공유 상태 관리의 복잡성
- 플랫폼별 시작 방식 차이
- 디버깅과 로그 통합의 어려움
작업 하나가 매우 짧으면 계산 시간보다 프로세스에 작업을 전달하고 결과를 돌려받는 비용이 더 클 수 있다. 작은 작업을 묶어 전달하거나 충분히 큰 단위로 분할할 수 있다.
프로세스 시작 방식
multiprocessing은 플랫폼과 설정에 따라 여러 프로세스 시작 방식을 제공한다.
spawn은 새로운 Python 인터프리터를 시작하고 필요한 모듈을 다시 가져온다.
context = (
multiprocessing.get_context(
"spawn"
)
)
부모의 불필요한 상태를 물려받지 않아 비교적 예측 가능하지만 시작 비용이 크다.
fork는 POSIX에서 부모 프로세스의 메모리 상태를 복제하여 자식을 시작한다.
context = (
multiprocessing.get_context(
"fork"
)
)
빠르게 시작할 수 있지만 부모가 여러 스레드를 실행하고 있거나 네이티브 라이브러리가 복잡한 상태를 가진 경우 안전하지 않을 수 있다.
forkserver는 별도의 서버 프로세스가 자식 프로세스를 fork한다.
context = (
multiprocessing.get_context(
"forkserver"
)
)
Python 3.14에서는 ProcessPoolExecutor의 기본 시작 방식이 fork에서 벗어났으며, fork가 필요하면 명시적으로 컨텍스트를 전달해야 한다.[175]
from concurrent.futures import (
ProcessPoolExecutor,
)
import multiprocessing
context = (
multiprocessing.get_context(
"fork"
)
)
with ProcessPoolExecutor(
mp_context=context,
) as executor:
...
라이브러리가 전역 시작 방식을 임의로 변경하기보다 사용자가 컨텍스트를 전달할 수 있게 하는 편이 다른 라이브러리와의 충돌을 줄인다.
주 모듈 보호
spawn과 forkserver에서는 자식 프로세스가 주 모듈을 다시 가져올 수 있다. 프로세스 생성 코드를 모듈 최상위에 두면 자식이 다시 프로세스를 만드는 재귀적인 실행이 발생할 수 있다.
if __name__ == "__main__":
main()
def main():
with ProcessPoolExecutor() as executor:
results = executor.map(
calculate,
values,
)
worker가 사용할 함수는 일반적으로 가져오기 가능한 모듈 최상위에 정의한다.
def calculate(value):
return value * value
중첩 함수, Lambda와 대화형 환경에만 존재하는 함수는 프로세스 worker로 전달할 때 직렬화되지 않을 수 있다.
프로세스 간 데이터 전달
프로세스는 일반 메모리를 공유하지 않으므로 Queue, Pipe, 공유 메모리와 Manager 등을 사용해 통신한다.
from multiprocessing import (
Process,
Queue,
)
def worker(queue):
result = calculate()
queue.put(result)
if __name__ == "__main__":
queue = Queue()
process = Process(
target=worker,
args=(queue,),
)
process.start()
result = queue.get()
process.join()
Queue를 통해 전달되는 일반 Python 객체는 대체로 pickle로 직렬화된다.
큰 객체를 자주 전송하면 직렬화와 복사 비용이 커질 수 있다. 필요한 데이터만 전달하거나 공유 메모리를 사용할 수 있다.
공유 메모리
multiprocessing.shared_memory는 여러 프로세스가 같은 메모리 블록에 직접 접근할 수 있게 한다.[176]
from multiprocessing import (
shared_memory,
)
memory = shared_memory.SharedMemory(
create=True,
size=1024,
)
공유 메모리는 바이트 버퍼를 제공한다.
buffer = memory.buf
buffer[:4] = b"data"
다른 프로세스는 이름으로 같은 영역에 연결할 수 있다.
other = shared_memory.SharedMemory(
name=memory.name,
)
사용 후 연결을 닫고 소유 프로세스에서 공유 메모리 자원을 제거한다.
other.close()
memory.close()
memory.unlink()
공유 메모리는 대용량 배열을 프로세스마다 복사하는 비용을 줄일 수 있지만 다음을 직접 관리해야 한다.
- 데이터 배치와 타입 해석
- 읽기·쓰기 동기화
- 객체 수명
- 자원 해제
- 부분 갱신의 일관성
공유 메모리를 사용한다고 데이터 경쟁이 사라지는 것은 아니다. 여러 프로세스가 같은 영역을 변경하면 프로세스용 Lock이나 Semaphore 등의 동기화가 필요하다.
Manager
multiprocessing.Manager는 별도의 서버 프로세스가 관리하는 객체를 프록시를 통해 여러 프로세스가 사용할 수 있게 한다.
from multiprocessing import (
Manager,
)
with Manager() as manager:
shared_list = manager.list()
shared_dict = manager.dict()
일반 목록과 비슷한 인터페이스를 제공하지만 각 연산에는 프로세스 간 통신이 필요할 수 있다.
shared_list.append(value)
작은 제어 정보와 편리한 공유 상태에는 유용하지만 대량 데이터와 빈번한 연산에는 오버헤드가 커질 수 있다.
프로세스 동기화
multiprocessing은 프로세스 사이에서 사용할 수 있는 Lock, RLock, Semaphore, Event, Condition과 Barrier를 제공한다.
from multiprocessing import (
Lock,
Value,
)
counter = Value(
"i",
0,
)
lock = Lock()
def increment():
with lock:
counter.value += 1
스레드용 threading.Lock과 모양은 비슷하지만 프로세스 사이에서 동작할 수 있는 별도의 동기화 객체이다.
프로세스 간 공유 상태가 많아지면 통신과 잠금 구조가 복잡해질 수 있다. 가능한 경우 입력을 worker별로 나누고 결과만 모으는 메시지 전달 방식을 사용하면 설계를 단순화할 수 있다.
프로세스 Pool
multiprocessing.Pool은 여러 입력에 같은 함수를 적용하는 데이터 병렬 작업을 지원한다.
from multiprocessing import Pool
def square(value):
return value * value
if __name__ == "__main__":
with Pool(4) as pool:
results = pool.map(
square,
values,
)
map()은 입력 순서에 대응하는 결과를 반환한다. 작업이 완료되는 순서대로 처리하려면 imap_unordered() 등을 사용할 수 있다.
Pool은 worker 프로세스를 재사용하므로 작업마다 프로세스를 새로 만드는 비용을 줄인다.
복잡한 작업 제출과 Future 기반 인터페이스에는 ProcessPoolExecutor가 더 일관된 고수준 API를 제공한다.
Executor와 Future
concurrent.futures는 스레드, 프로세스와 인터프리터 풀을 비슷한 인터페이스로 사용할 수 있게 한다.
from concurrent.futures import (
ThreadPoolExecutor,
)
with ThreadPoolExecutor() as executor:
future = executor.submit(
calculate,
value,
)
result = future.result()
Executor는 작업 실행 환경을 관리하고 Future는 나중에 완료될 작업의 상태와 결과를 나타낸다.[177]
다음 실행기를 제공한다.
ThreadPoolExecutorProcessPoolExecutorInterpreterPoolExecutor
같은 작업 제출 코드를 유지하면서 작업 특성에 맞는 실행기를 선택할 수 있다.
executor.submit(
function,
*args,
**kwargs,
)
Future 상태
Future는 작업이 대기 중인지, 실행 중인지, 완료되었는지, 취소되었는지에 대한 상태를 가진다.
future.cancel()
future.cancelled()
future.running()
future.done()
결과를 요청할 수 있다.
result = future.result(
timeout=5,
)
작업이 예외를 발생시켰다면 result()에서 해당 예외가 다시 발생한다.
try:
result = future.result()
except CalculationError as error:
handle(error)
완료 콜백을 등록할 수도 있다.
future.add_done_callback(
handle_completion
)
콜백은 결과나 예외를 직접 확인해야 한다.
as_completed()
여러 Future를 제출하고 완료되는 순서대로 처리할 수 있다.
from concurrent.futures import (
as_completed,
)
future_to_item = {
executor.submit(
process,
item,
): item
for item in items
}
for future in as_completed(
future_to_item
):
item = future_to_item[future]
try:
result = future.result()
except Exception as error:
handle_failure(
item,
error,
)
else:
handle_result(
item,
result,
)
작업 시간이 서로 다를 때 입력 순서대로 기다리는 것보다 먼저 완료된 결과를 즉시 처리할 수 있다.
ThreadPoolExecutor
ThreadPoolExecutor는 정해진 수의 worker 스레드를 재사용한다.
from concurrent.futures import (
ThreadPoolExecutor,
)
with ThreadPoolExecutor(
max_workers=8,
thread_name_prefix="download",
) as executor:
results = executor.map(
download,
urls,
)
주로 다음 작업에 적합하다.
- 동기 네트워크 라이브러리 호출
- 여러 파일의 동시 읽기와 쓰기
- 블로킹 데이터베이스 드라이버
- GIL을 해제하는 네이티브 라이브러리
- 자유 스레딩 빌드의 독립적인 CPU 작업
하나의 풀 안의 작업이 같은 풀에 새 작업을 제출하고 그 결과를 동기적으로 기다리면 worker 수가 부족해 교착 상태가 발생할 수 있다.
def outer():
future = executor.submit(
inner
)
return future.result()
worker 하나뿐인 풀에서 outer()가 실행 중이면 inner()가 실행될 worker가 남지 않는다.
ProcessPoolExecutor
ProcessPoolExecutor는 여러 프로세스를 사용하여 작업을 실행한다.
from concurrent.futures import (
ProcessPoolExecutor,
)
if __name__ == "__main__":
with ProcessPoolExecutor() as executor:
results = executor.map(
calculate,
workloads,
)
GIL 활성 CPython에서 순수 Python CPU 계산을 여러 코어에 분산할 때 사용할 수 있다.
작업 함수, 인자와 반환값은 일반적으로 직렬화할 수 있어야 한다. 모듈, 열린 파일, 스레드 잠금과 일부 네이티브 객체는 그대로 전달할 수 없다.
프로세스 하나가 비정상적으로 종료되면 남은 작업에서 BrokenProcessPool이 발생할 수 있다.
Python 3.14에서는 실행기의 worker를 즉시 종료하기 위한 terminate_workers()와 kill_workers()도 제공된다. 정상적인 정리와 결과 회수가 가능한 상황에서는 일반 shutdown()을 우선 사용할 수 있다.
다중 인터프리터
하나의 프로세스 안에는 서로 격리된 여러 Python 인터프리터를 둘 수 있다. 각 인터프리터는 독립적인 모듈 상태, sys, builtins와 __main__을 가진다.
하나의 운영체제 프로세스
├── 인터프리터 A
├── 인터프리터 B
└── 인터프리터 C
CPython의 격리된 인터프리터는 각자 GIL을 가질 수 있으므로, 일반적인 GIL 활성 빌드에서도 서로 다른 인터프리터의 Python 코드가 여러 CPU 코어에서 병렬로 실행될 수 있다.[178]
Python 3.14에는 다중 인터프리터를 관리하는 concurrent.interpreters가 표준 라이브러리에 추가되었다.[179]
다중 인터프리터는 프로세스보다 가벼운 격리와 스레드보다 강한 상태 분리를 제공하는 중간적인 실행 모델로 볼 수 있다.
InterpreterPoolExecutor
Python 3.14의 InterpreterPoolExecutor는 worker 스레드마다 독립적인 인터프리터를 사용한다.[180]
from concurrent.futures import (
InterpreterPoolExecutor,
)
with InterpreterPoolExecutor() as executor:
results = executor.map(
calculate,
workloads,
)
각 worker는 별도의 인터프리터 상태와 GIL을 가지므로 여러 코어에서 Python 코드를 병렬 실행할 수 있다.
프로세스와 달리 하나의 운영체제 프로세스 안에 존재하지만, 스레드처럼 임의의 가변 Python 객체를 직접 공유할 수는 없다.
각 인터프리터는 같은 모듈을 별도로 가져온다.
인터프리터 A의 sys 모듈
≠ 인터프리터 B의 sys 모듈
인터프리터 A의 __main__
≠ 인터프리터 B의 __main__
불변 객체 일부는 안전하게 공유될 수 있지만, 일반적인 가변 객체는 복사하거나 직렬화하여 전달해야 한다.
InterpreterPoolExecutor는 초기화 함수와 작업 인자를 pickle로 직렬화하여 worker 인터프리터로 전달할 수 있다. 따라서 프로세스 풀과 비슷하게 직렬화 가능한 작업 경계를 설계해야 한다.
인터프리터와 프로세스 비교
다중 인터프리터는 프로세스에 비해 운영체제 자원 사용과 생성 비용을 줄일 가능성이 있다. 하나의 프로세스이므로 프로세스 관리와 배포 구조도 단순해질 수 있다.
그러나 다음 제약이 있다.
- 가변 Python 객체를 직접 공유할 수 없음
- 일부 C 확장 모듈이 다중 인터프리터를 지원하지 않을 수 있음
- 인터프리터별로 모듈을 따로 가져와야 함
- 통신 인터페이스가 프로세스 생태계보다 덜 성숙할 수 있음
- 하나의 네이티브 프로세스 장애가 모든 인터프리터에 영향을 줄 수 있음
프로세스는 주소 공간까지 분리되므로 메모리 오류와 네이티브 충돌의 격리가 더 강하다. 다중 인터프리터는 하나의 프로세스 안에서 Python 런타임 상태를 격리한다.
asyncio
asyncio는 async와 await 문법을 사용하여 하나의 이벤트 루프에서 많은 작업을 협력적으로 전환한다.[181]
async def handle_client(
reader,
writer,
):
request = await reader.read(
4096
)
response = await process_request(
request
)
writer.write(response)
await writer.drain()
하나의 이벤트 루프는 수많은 네트워크 연결을 관리할 수 있다. 작업이 await에서 입출력을 기다리면 다른 작업이 실행된다.
일반적인 단일 이벤트 루프에서는 한 시점에 하나의 Task가 Python 코드를 실행하므로 공유 상태에 대한 동시 접근을 추론하기가 스레드보다 쉬울 수 있다.
그러나 await를 기준으로 다른 Task가 실행될 수 있으므로 경쟁 조건이 완전히 사라지는 것은 아니다.
current = counter
await asyncio.sleep(0)
counter = current + 1
여러 Task가 같은 값을 읽으면 증가가 사라질 수 있다. asyncio.Lock이나 메시지 Queue를 사용할 수 있다.
비동기 처리와 병렬 처리
asyncio 자체의 주된 목적은 입출력 동시성이다. async def로 함수를 정의한다고 CPU 계산이 여러 코어에서 병렬 실행되는 것은 아니다.
async def calculate():
return expensive_calculation()
expensive_calculation()이 오랫동안 await 없이 실행되면 이벤트 루프가 다른 Task를 실행하지 못한다.
블로킹 작업을 스레드로 전달할 수 있다.
result = await asyncio.to_thread(
blocking_function,
argument,
)
프로세스 풀을 사용할 수도 있다.
loop = (
asyncio.get_running_loop()
)
result = await loop.run_in_executor(
process_pool,
calculate,
workload,
)
비동기 네트워크 처리와 CPU 병렬 처리를 조합할 수 있다.
asyncio로 요청 수신
→ ProcessPoolExecutor에서 계산
→ asyncio로 응답 전송
자유 스레딩과 asyncio
Python 3.14의 asyncio는 자유 스레딩 환경을 공식적으로 지원한다. 각 스레드가 자신의 이벤트 루프를 가지면 여러 이벤트 루프의 Python 처리 코드가 서로 다른 코어에서 병렬로 실행될 수 있다.[182]
스레드 A
└── 이벤트 루프 A
스레드 B
└── 이벤트 루프 B
다만 하나의 이벤트 루프를 여러 스레드가 함께 실행하도록 설계하지 않는다. 일반적으로 다음 규칙을 유지한다.
- 스레드마다 독립적인 이벤트 루프 사용
- 한 스레드의 Task나 Future를 다른 스레드에서 직접 조작하지 않음
- 다른 스레드에서 작업을 전달할 때 스레드 안전 API 사용
loop.call_soon_threadsafe()사용asyncio.run_coroutine_threadsafe()사용
하나의 이벤트 루프만으로 충분한 입출력 처리량을 얻을 수 있다면 여러 루프를 만들 필요는 없다. 요청별 계산량이 커져 단일 코어가 병목이 될 때 자유 스레딩과 여러 이벤트 루프 구성을 검토할 수 있다.
작업 취소
동시 작업은 프로그램 종료, 시간 제한과 상위 작업 실패에 따라 취소될 수 있어야 한다.
Future는 실행 전 작업의 취소를 요청할 수 있다.
future.cancel()
이미 실행 중인 스레드 작업은 Future 취소만으로 강제 종료되지 않을 수 있다. Python은 일반 스레드를 안전하게 외부에서 강제 종료하는 표준 기능을 제공하지 않는다.
스레드 작업은 협력적인 종료 플래그를 확인할 수 있다.
stop_event = threading.Event()
def worker():
while not stop_event.is_set():
process_next()
프로세스는 terminate()나 kill()로 외부 종료가 가능하지만, 정리 코드가 실행되지 않고 잠금과 Queue가 손상될 수 있다. 가능하면 종료 신호를 전달하고 정상적으로 끝나게 한 뒤, 응답하지 않는 경우 강제 종료를 사용할 수 있다.
비동기 Task는 CancelledError를 이용해 협력적으로 취소된다.
시간 제한
잠금 획득, 스레드 종료 대기와 Future 결과에는 시간 제한을 둘 수 있다.
acquired = lock.acquire(
timeout=5
)
thread.join(
timeout=5
)
result = future.result(
timeout=5
)
시간 제한이 발생했다고 작업 자체가 자동으로 종료되는 것은 아니다.
try:
result = future.result(
timeout=5
)
except TimeoutError:
future.cancel()
Future가 이미 실행 중이라면 취소가 실패할 수 있다. 작업 함수가 자체적으로 시간 제한과 종료 신호를 지원하도록 설계할 수 있다.
공유 상태 최소화
동시성 오류의 상당수는 여러 작업이 같은 가변 상태를 공유하면서 발생한다.
shared_state = {
"count": 0,
"items": [],
}
공유 상태를 줄이는 방법은 다음과 같다.
- 작업별 입력을 복사하여 전달
- 불변 객체 사용
- 결과만 호출자에게 반환
- Queue를 통한 메시지 전달
- 상태 소유자를 하나의 스레드나 Task로 제한
- 프로세스별 독립 상태 사용
- 변경을 짧은 임계 구역에 모음
def worker(input_data):
local_result = calculate(
input_data
)
result_queue.put(
local_result
)
각 worker가 독립적으로 계산하고 결과만 Queue로 전달하면 공유 사전을 여러 worker가 직접 변경하는 것보다 추론하기 쉽다.
불변 데이터
불변 객체는 생성 후 값이 바뀌지 않으므로 여러 실행 흐름에서 공유하기 상대적으로 쉽다.
from dataclasses import (
dataclass,
)
@dataclass(frozen=True)
class TaskInput:
identifier: int
source: str
불변 객체를 공유하더라도 그 안에 가변 객체가 포함되어 있다면 깊은 불변성이 보장되지 않는다.
@dataclass(frozen=True)
class Configuration:
values: list[str]
values 목록은 계속 변경할 수 있다. 튜플과 불변 값 객체를 사용하거나 복사 정책을 명확히 할 수 있다.
메시지 전달
공유 메모리 대신 Queue나 Channel을 통해 메시지를 전달하면 작업 사이의 상호작용을 명시할 수 있다.
생산자
→ 작업 Queue
→ worker
→ 결과 Queue
→ 수집자
스레드에서는 queue.Queue, 프로세스에서는 multiprocessing.Queue, 비동기 Task에서는 asyncio.Queue를 사용할 수 있다.
async def producer(queue):
for item in items:
await queue.put(item)
async def consumer(queue):
while True:
item = await queue.get()
try:
await process(item)
finally:
queue.task_done()
메시지 전달은 데이터 복사나 직렬화 비용이 있을 수 있지만, 누가 상태를 변경할 수 있는지 명확하게 제한한다.
작업 분할
병렬 처리에서 작업을 지나치게 작게 나누면 제출, 직렬화, 스케줄링과 결과 수집 비용이 커진다.
executor.map(
process_one_number,
millions_of_numbers,
)
여러 값을 하나의 묶음으로 처리할 수 있다.
def process_chunk(values):
return [
transform(value)
for value in values
]
입력 전체
→ 적절한 크기의 청크로 분할
→ worker에 전달
→ 결과 병합
작업이 너무 크면 일부 worker만 오래 실행되어 부하가 고르게 분산되지 않을 수 있다. 작업 크기와 worker 수는 실제 입력으로 측정해 조정해야 한다.
데이터 지역성
병렬 계산에서 데이터 전송 비용은 계산 시간만큼 중요할 수 있다.
1초 계산
+ 5초 직렬화·복사
= 병렬화로 성능 저하
큰 데이터를 각 작업마다 프로세스에 반복해서 전달하기보다 다음 방식을 사용할 수 있다.
- worker 초기화 시 한 번 로딩
- 파일이나 데이터베이스에서 worker가 직접 읽기
- 공유 메모리 사용
- 계산을 데이터가 있는 위치로 이동
- 전달하는 데이터 구조 단순화
- 큰 객체 대신 식별자와 범위 전달
네이티브 수치 계산 라이브러리는 내부 배열 메모리를 효율적으로 공유하거나 GIL을 해제하여 Python 수준의 프로세스 분할보다 나은 성능을 제공할 수 있다.
결과 순서
병렬 작업은 입력 순서와 다른 순서로 완료될 수 있다.
results = executor.map(
process,
items,
)
map()은 일반적으로 입력 순서에 대응해 결과를 제공한다. 앞쪽 작업이 오래 걸리면 뒤쪽 작업이 이미 끝났어도 결과 처리가 기다릴 수 있다.
for future in as_completed(
futures
):
handle(
future.result()
)
as_completed()는 완료 순서대로 결과를 처리한다.
결과 순서가 중요한지, 빠르게 끝난 결과를 즉시 사용해야 하는지에 따라 방식을 선택할 수 있다.
오류 처리
동시 작업의 예외는 작업을 실행한 worker와 결과를 회수하는 호출자가 서로 다른 실행 흐름에 있을 수 있다.
Thread 또는 Process를 직접 사용할 때는 Queue로 예외 정보를 전달할 수 있다.
Executor에서는 Future가 예외를 보관한다.
future = executor.submit(
process,
item,
)
try:
result = future.result()
except ProcessingError as error:
handle(error)
여러 작업 가운데 하나가 실패했을 때 정책을 결정해야 한다.
- 해당 작업만 실패 처리
- 남은 작업 모두 취소
- 일정 횟수 재시도
- 기본값 사용
- 전체 작업 중단
- 부분 결과 반환
프로세스나 인터프리터 경계를 넘는 예외는 직렬화 가능한 형태로 전달되어야 하며, 원래 worker의 전체 실행 상태를 그대로 공유하지는 않는다.
재시도
일시적인 네트워크 오류처럼 다시 수행하면 성공할 수 있는 작업은 재시도할 수 있다.
def execute_with_retry(item):
for attempt in range(3):
try:
return process(item)
except TemporaryError:
if attempt == 2:
raise
sleep(
retry_delay(attempt)
)
작업이 멱등적인지 확인해야 한다. 파일 생성, 결제와 외부 상태 변경을 무조건 재시도하면 중복 처리가 발생할 수 있다.
여러 worker가 동시에 재시도하면 실패한 외부 서비스에 더 많은 부하를 줄 수 있다. 지수 백오프, 무작위 지연과 전체 동시 요청 제한을 함께 사용할 수 있다.
백프레셔
생산자가 소비자보다 빠르면 작업이 메모리에 계속 쌓일 수 있다.
for item in infinite_source():
executor.submit(
process,
item,
)
Future가 무제한으로 생성될 수 있다.
크기가 제한된 Queue를 사용하면 소비자가 따라오지 못할 때 생산자를 대기시킬 수 있다.
queue = Queue(
maxsize=100
)
동시에 유지할 Future 수를 제한할 수도 있다.
최대 100개 작업 제출
→ 완료된 만큼 새 작업 추가
백프레셔는 메모리 사용을 제한하고 외부 시스템에 전달되는 부하를 조절한다.
CPU 개수
사용 가능한 CPU 개수는 os.process_cpu_count()로 확인할 수 있다.
import os
workers = (
os.process_cpu_count()
or 1
)
물리 CPU 코어 수, 논리 CPU 수와 현재 프로세스에 실제 허용된 CPU 수는 서로 다를 수 있다. 컨테이너와 작업 스케줄러 환경에서는 시스템 전체 CPU보다 적은 CPU만 사용할 수 있다.
worker 수를 CPU 수와 항상 같게 설정해야 하는 것은 아니다.
- CPU 중심 프로세스는 CPU 수 부근에서 시작
- 입출력 중심 스레드는 더 많은 worker 사용 가능
- 메모리 사용이 큰 작업은 worker 수 감소
- 외부 서비스 제한에 맞춰 동시성 조정
- 네이티브 라이브러리의 자체 스레드 수 고려
수치 라이브러리가 내부적으로 여러 스레드를 사용한다면 프로세스마다 다시 많은 스레드를 생성하여 CPU가 과도하게 분할될 수 있다.
스레드와 프로세스 혼합
프로세스마다 여러 스레드를 사용할 수 있다.
프로세스 A
├── 스레드 1
└── 스레드 2
프로세스 B
├── 스레드 1
└── 스레드 2
각 프로세스가 CPU 계산을 독립적으로 수행하면서 내부 스레드가 파일이나 네트워크 입출력을 처리할 수 있다.
그러나 worker 수가 곱해질 수 있다.
프로세스 8개
× 스레드 16개
= 최대 128개 실행 흐름
문맥 전환, 메모리, 연결 수와 잠금 경쟁이 커질 수 있으므로 전체 시스템 수준의 동시성 수를 계산해야 한다.
여러 스레드가 실행 중인 프로세스에서 fork를 사용하면 자식에는 fork를 호출한 스레드만 남고 다른 스레드가 보유하던 잠금 상태가 복제될 수 있다. 현대 Python에서 fork가 기본 시작 방식에서 제외되는 방향은 이러한 위험과 관련이 있다.
네이티브 라이브러리의 병렬성
여러 과학 계산과 영상 처리 라이브러리는 계산을 C, C++, Fortran이나 다른 네이티브 코드로 수행한다. 이러한 라이브러리는 연산 중 GIL을 해제하거나 내부적으로 여러 스레드를 사용할 수 있다.
result = native_library.calculate(
array
)
GIL 활성 CPython에서도 다른 Python 스레드가 실행되거나 네이티브 계산이 여러 코어를 사용할 수 있다.
라이브러리마다 GIL 해제 범위와 스레드 안전성이 다르므로 공식 문서를 확인해야 한다. Python 수준에서 다시 ThreadPool이나 ProcessPool을 적용하면 내부 병렬성과 중첩되어 성능이 낮아질 수도 있다.
이식성
동시성 코드는 운영체제와 Python 구현체에 따라 다르게 동작할 수 있다.
- Windows는
fork를 제공하지 않음 - 모바일과 WebAssembly에서는
multiprocessing을 사용할 수 없을 수 있음 - PyPy의 가비지 컬렉션과 GIL 특성이 CPython과 다를 수 있음
- 자유 스레딩 CPython은 확장 모듈 지원 여부가 다름
- 프로세스 시작 방식의 기본값이 Python 버전마다 달라질 수 있음
- 운영체제별 신호와 프로세스 종료 방식이 다름
프로세스 기반 코드는 대상 플랫폼에서 직접 검사하고, 시작 방식을 암묵적인 기본값에만 의존하지 않도록 할 수 있다.
테스트
동시성 오류는 실행 순서와 시스템 부하에 따라 드물게 나타날 수 있다. 한 번의 성공적인 실행만으로 안전성을 판단하기 어렵다.
다음 조건을 검사할 수 있다.
- worker 수가 1인 경우
- worker 수가 매우 많은 경우
- 입력이 없는 경우
- 작업 중 예외가 발생하는 경우
- 취소와 시간 제한
- 프로그램 종료 중 작업이 남아 있는 경우
- Queue가 가득 찬 경우
- worker가 비정상 종료한 경우
- 자유 스레딩과 GIL 활성 빌드
- 서로 다른 프로세스 시작 방식
공유 상태의 결과가 항상 일정한지 반복 실행할 수 있다.
for _ in range(1000):
assert run_concurrent_test() == (
expected
)
하지만 반복 테스트만으로 데이터 경쟁의 부재를 증명할 수는 없다. 코드의 동기화 계약과 상태 소유 관계를 명확히 설계해야 한다.
디버깅
스레드에는 이름을 지정하여 로그에서 구분할 수 있다.
thread = threading.Thread(
target=worker,
name="storage-worker",
)
ThreadPool에도 접두사를 지정할 수 있다.
executor = ThreadPoolExecutor(
thread_name_prefix="download",
)
로그에는 프로세스와 스레드 정보를 포함할 수 있다.
logging.basicConfig(
format=(
"%(processName)s "
"%(threadName)s "
"%(message)s"
)
)
교착 상태를 분석할 때는 각 스레드의 스택과 보유 잠금, Queue 상태를 확인한다. faulthandler는 실행 중인 스레드의 스택을 출력하는 데 사용할 수 있다.
import faulthandler
faulthandler.dump_traceback()
프로세스 worker의 로그는 여러 프로세스에서 동시에 출력되어 섞일 수 있다. Queue 기반 로깅으로 하나의 수집 프로세스에서 로그를 기록할 수 있다.
성능 측정
동시성을 추가하면 항상 성능이 향상되는 것은 아니다.
전체 실행 시간에는 다음 비용이 포함된다.
작업 분할
+ worker 생성
+ 데이터 전달
+ 실행 대기
+ 잠금 경쟁
+ 계산
+ 결과 병합
+ worker 종료
작업 자체가 짧다면 순차 실행이 더 빠를 수 있다.
results = [
calculate(item)
for item in items
]
병렬화 전후를 실제 입력으로 측정한다.
from time import perf_counter
start = perf_counter()
run_sequential()
sequential_time = (
perf_counter() - start
)
start = perf_counter()
run_parallel()
parallel_time = (
perf_counter() - start
)
평균 처리량뿐 아니라 개별 작업의 지연 시간, 메모리 사용량, 오류율과 외부 서비스 부하도 함께 확인할 수 있다.
실행 방식 선택
스레드는 다음 상황에 적합하다.
- 기존 동기 API가 입출력을 블로킹함
- 같은 프로세스의 객체를 쉽게 공유해야 함
- 작업 생성 비용을 낮게 유지해야 함
- 네이티브 코드가 GIL을 해제함
- 자유 스레딩 빌드에서 CPU 작업을 병렬화함
프로세스는 다음 상황에 적합하다.
- GIL 활성 CPython의 순수 Python CPU 계산
- 주소 공간 격리가 필요함
- worker의 강제 종료가 필요함
- 작업별 전역 상태를 분리해야 함
- 네이티브 충돌을 어느 정도 격리해야 함
asyncio는 다음 상황에 적합하다.
- 매우 많은 네트워크 연결
- 비동기 라이브러리를 사용하는 서버
- 작업 대부분이 입출력 대기
- Task 전환 위치를
await로 명확히 관리하고 싶음 - 스레드 수를 크게 늘리지 않고 동시성을 얻고 싶음
다중 인터프리터는 다음 상황에 적합할 수 있다.
- 하나의 프로세스에서 여러 코어를 사용하고 싶음
- worker 사이 가변 상태 공유가 필요하지 않음
- 프로세스보다 가벼운 격리를 원함
- 사용 중인 확장 모듈이 다중 인터프리터를 지원함
- 직렬화 가능한 독립 작업을 실행함
하나의 방법을 프로그램 전체에 적용할 필요는 없다.
asyncio
→ 네트워크 연결 관리
ThreadPoolExecutor
→ 블로킹 파일 API
ProcessPoolExecutor
→ CPU 계산
Queue
→ 단계 사이 결과 전달
설계 원칙
Python의 동시성과 병렬 처리는 다음과 같은 원칙으로 구성할 수 있다.
- 작업이 입출력 중심인지 CPU 중심인지 먼저 측정한다.
- 공유 가변 상태를 가능한 한 줄인다.
- 공유해야 하는 상태에는 명시적인 동기화 계약을 둔다.
- GIL을 응용 프로그램 잠금으로 간주하지 않는다.
- 내장 객체의 현재 원자성에 의존하지 않는다.
- 잠금 획득 순서를 일관되게 유지한다.
- 잠금을 보유한 채 외부 입출력이나 다른 Future를 오래 기다리지 않는다.
- 스레드와 Task는 협력적인 종료 방법을 제공한다.
- 프로세스 작업 함수와 인자는 직렬화 가능하게 구성한다.
- 프로세스 생성 코드는 주 모듈 보호 아래에 둔다.
- 작업을 지나치게 잘게 나누지 않는다.
- Queue와 동시 작업 수를 제한하여 백프레셔를 적용한다.
- worker 예외와 취소를 반드시 회수한다.
- 실제 배포 플랫폼과 Python 빌드에서 검사한다.
- 병렬화 전후의 속도와 메모리 사용량을 측정한다.
Python은 스레드, 프로세스, 코루틴과 다중 인터프리터를 하나의 단일한 병렬 실행 방식으로 통합하지 않는다. 각 모델은 서로 다른 공유 상태와 격리 수준을 가지며, 프로그램은 작업 특성에 맞는 모델을 선택하거나 여러 방식을 조합한다.
일반적인 GIL 활성 CPython에서 스레드는 주로 입출력 동시성에 사용되고, 프로세스와 독립 인터프리터는 CPU 병렬성을 제공한다. 자유 스레딩 CPython에서는 스레드도 순수 Python 코드를 여러 코어에서 병렬 실행할 수 있지만, 공유 상태와 확장 모듈의 스레드 안전성을 더 직접적으로 관리해야 한다. 이러한 실행 모델의 차이를 이해하면 단순히 worker 수를 늘리는 것보다 작업 분할, 데이터 이동, 동기화와 실패 처리를 함께 고려한 구조를 설계할 수 있다.
C 및 다른 언어와의 연동
Python은 다른 프로그래밍 언어로 작성된 라이브러리를 호출하거나, 반대로 다른 언어의 응용 프로그램 안에 Python 인터프리터를 내장할 수 있다. 이러한 연동은 기존 시스템 라이브러리의 재사용, 계산 성능 향상, 운영체제 기능 접근, 다른 런타임 생태계와의 통합 및 Python용 고수준 API 제공에 활용된다.
가장 기본적인 연동 기준은 CPython이 제공하는 Python/C API이다. CPython 확장 모듈은 C에서 Python 객체를 생성하고 함수와 클래스를 노출하며, 기존 C 라이브러리를 Python 모듈로 감쌀 수 있다. C++·Rust·Fortran과 여러 다른 언어용 도구도 내부적으로 Python/C API나 C ABI를 사용해 CPython과 연결되는 경우가 많다.[183]
Python 코드
→ Python 함수와 클래스 형태의 공개 API
→ 언어별 바인딩 계층
→ C ABI 또는 Python/C API
→ C·C++·Rust·Fortran 등의 실제 구현
반대 방향의 연동도 가능하다.
C·C++·Rust 등의 응용 프로그램
→ Python 인터프리터 초기화
→ Python 모듈 또는 스크립트 실행
→ Python 함수 호출
→ 결과를 호스트 언어 객체로 변환
Python에서 다른 언어를 사용하는 방식은 크게 다음과 같이 나뉜다.
- CPython 확장 모듈을 작성한다.
- 기존 공유 라이브러리를
ctypes나 CFFI로 호출한다. - C++·Rust·Fortran용 바인딩 생성 도구를 사용한다.
- JVM이나 .NET 런타임을 같은 프로세스에 시작한다.
- 별도 프로세스와 RPC 또는 명령행 인터페이스로 통신한다.
- 다른 응용 프로그램 안에 Python 인터프리터를 내장한다.
어떤 방식을 선택할지는 호출 성능, 객체 변환 비용, 빌드 복잡성, 대상 Python 구현체, ABI 안정성, 오류 격리와 배포 플랫폼에 따라 달라진다.
Python API와 네이티브 구현의 분리
네이티브 확장 모듈을 개발할 때는 Python 사용자가 보는 공개 인터페이스와 내부 구현 언어를 분리하는 것이 중요하다.
예를 들어 Python 사용자에게는 다음과 같은 API를 제공할 수 있다.
from image_core import Image
from image_core import resize
image = Image.load("input.png")
result = resize(
image,
width=1280,
height=720,
interpolation="linear",
)
result.save("output.png")
내부 구현은 C나 C++로 작성되더라도 다음 요소는 Python 관례에 맞춰 설계할 수 있다.
- 함수와 클래스 이름
- 위치 및 키워드 인자
- 기본값
- Python 자료형 사용
- 반복자와 컨텍스트 관리자 지원
- Python 예외 발생
- 타입 주석과 스텁 제공
- 문서 문자열
bytes,memoryview와 Buffer Protocol 지원
네이티브 함수의 원래 인터페이스를 그대로 노출하면 Python 사용자가 포인터, 메모리 크기와 오류 코드 등을 직접 관리해야 할 수 있다.
int image_resize(
const unsigned char *source,
size_t source_size,
unsigned char **output,
size_t *output_size,
int width,
int height
);
이를 Python에서는 다음과 같은 형태로 감쌀 수 있다.
def resize(
source: bytes,
width: int,
height: int,
) -> bytes:
...
바인딩 계층은 다음 작업을 담당한다.
Python bytes 검사
→ C 포인터와 길이 추출
→ 네이티브 함수 호출
→ 오류 코드 검사
→ 결과 메모리를 Python bytes로 복사하거나 소유권 이전
→ 네이티브 메모리 해제
→ Python 객체 반환
Python API는 내부 구현의 메모리 관리와 ABI를 숨기고, Python 사용자가 익숙한 객체와 예외 중심 인터페이스를 제공하는 역할을 한다.
C 확장 모듈
CPython 확장 모듈은 Python이 동적 라이브러리로 불러올 수 있는 네이티브 모듈이다. C 소스에서 Python.h를 포함하고 Python/C API를 사용해 함수와 타입을 정의한다.[184]
간단한 덧셈 함수를 제공하는 C 확장은 다음과 같이 작성할 수 있다.
#define PY_SSIZE_T_CLEAN
#include <Python.h>
static PyObject *
native_add(
PyObject *self,
PyObject *args
)
{
long left;
long right;
if (!PyArg_ParseTuple(
args,
"ll",
&left,
&right
)) {
return NULL;
}
return PyLong_FromLong(
left + right
);
}
static PyMethodDef module_methods[] = {
{
"add",
native_add,
METH_VARARGS,
PyDoc_STR(
"add(left, right) -> int"
)
},
{
NULL,
NULL,
0,
NULL
}
};
static struct PyModuleDef module_definition = {
PyModuleDef_HEAD_INIT,
"native_math",
"Native arithmetic functions.",
-1,
module_methods
};
PyMODINIT_FUNC
PyInit_native_math(void)
{
return PyModule_Create(
&module_definition
);
}
컴파일된 공유 라이브러리의 이름과 초기화 함수 이름이 일치해야 한다.
native_math.c
→ native_math.cpython-314-x86_64-linux-gnu.so
→ PyInit_native_math()
Python에서는 일반 모듈과 같은 방식으로 가져온다.
import native_math
result = native_math.add(
10,
20,
)
확장 모듈은 Python 함수뿐 아니라 새로운 내장형 객체와 메서드, 반복자, 예외와 모듈 상수를 정의할 수 있다.
모듈 초기화
Python이 확장 모듈을 가져오면 이름에 대응하는 초기화 함수를 호출한다.
PyMODINIT_FUNC
PyInit_native_math(void)
{
return PyModule_Create(
&module_definition
);
}
native_math라는 모듈은 PyInit_native_math()를 내보내야 한다.
모듈 이름이 일치하지 않으면 다음과 같은 가져오기 오류가 발생할 수 있다.
ImportError:
dynamic module does not define
module export function
(PyInit_native_math)
현대 확장 모듈은 다단계 초기화를 사용할 수 있다. 다단계 초기화는 모듈 객체 생성과 실행을 분리하고, 모듈별 상태를 지원하며, 다중 인터프리터와의 호환성을 높인다.[185]
전역 정적 변수에 상태를 저장하는 대신 모듈 인스턴스마다 상태를 둘 수 있다.
typedef struct {
PyObject *error_type;
long call_count;
} NativeMathState;
이 방식은 서브인터프리터마다 독립된 모듈 상태를 유지하고 모듈 정리 시 Python 객체 참조를 올바르게 해제하는 데 도움이 된다.
인자 변환
C 확장 함수는 Python 인자를 C 값으로 변환해야 한다.
long value;
if (!PyArg_ParseTuple(
args,
"l",
&value
)) {
return NULL;
}
문자열과 버퍼를 받을 수도 있다.
const char *text;
Py_ssize_t length;
if (!PyArg_ParseTuple(
args,
"s#",
&text,
&length
)) {
return NULL;
}
키워드 인자를 지원하려면 METH_VARARGS | METH_KEYWORDS와 키워드 파싱 함수를 사용할 수 있다.
static PyObject *
resize_image(
PyObject *self,
PyObject *args,
PyObject *kwargs
)
{
PyObject *image;
int width;
int height;
const char *method = "linear";
static char *keywords[] = {
"image",
"width",
"height",
"method",
NULL
};
if (!PyArg_ParseTupleAndKeywords(
args,
kwargs,
"Oii|s",
keywords,
&image,
&width,
&height,
&method
)) {
return NULL;
}
...
}
네이티브 API가 받는 형식과 Python API가 받는 형식이 반드시 일치할 필요는 없다. 바인딩 계층은 Python 객체를 검사하고 적절한 C 구조로 변환한다.
반환값 생성
C 함수는 성공 시 새로운 Python 객체에 대한 포인터를 반환한다.
return PyLong_FromLong(
result
);
문자열은 다음과 같이 만들 수 있다.
return PyUnicode_FromString(
text
);
여러 값을 튜플로 반환할 수 있다.
return Py_BuildValue(
"(ii)",
width,
height
);
이미 존재하는 Python 객체를 반환할 때는 참조 소유권을 확인해야 한다.
Py_INCREF(Py_None);
return Py_None;
현대 API에서는 Py_RETURN_NONE과 같은 매크로를 사용할 수 있다.
Py_RETURN_NONE;
반환 객체 생성에 실패하면 Python 예외가 설정되고 NULL이 반환될 수 있다. 모든 객체 생성 결과가 항상 성공한다고 가정해서는 안 된다.
참조 계수
CPython C API에서는 Python 객체의 참조 소유권을 직접 관리해야 한다.
API가 반환하는 객체 참조는 일반적으로 다음과 같이 구분된다.
- 새 참조: 호출자가 소유하며 사용 후
Py_DECREF()해야 한다. - 빌린 참조: 다른 객체가 소유하며 별도의 참조를 확보하지 않으면 보유할 수 없다.
- 소유권 이전: 호출한 API가 전달된 참조를 가져간다.
PyObject *value =
PyLong_FromLong(10);
if (value == NULL) {
return NULL;
}
/* value 사용 */
Py_DECREF(value);
오류 경로에서도 이미 생성한 객체를 해제해야 한다.
PyObject *first =
create_first();
if (first == NULL) {
return NULL;
}
PyObject *second =
create_second();
if (second == NULL) {
Py_DECREF(first);
return NULL;
}
참조 계수를 지나치게 감소시키면 이미 해제된 객체를 접근할 수 있고, 감소시키지 않으면 메모리 누수가 발생한다.
C 확장 개발에서는 정상 경로뿐 아니라 모든 오류 경로의 소유권을 함께 검토해야 한다.
오류 처리
Python C 함수는 일반적으로 오류가 발생했을 때 Python 예외를 설정하고 NULL을 반환한다.
if (value < 0) {
PyErr_SetString(
PyExc_ValueError,
"value must not be negative"
);
return NULL;
}
운영체제 오류를 현재 errno에서 만들 수 있다.
if (file == NULL) {
return PyErr_SetFromErrno(
PyExc_OSError
);
}
사용자 정의 예외를 모듈에 등록할 수도 있다.
state->error_type =
PyErr_NewException(
"native_math.Error",
NULL,
NULL
);
네이티브 오류 코드를 Python 예외 계층으로 변환할 수 있다.
NATIVE_ERROR_INVALID_ARGUMENT
→ ValueError
NATIVE_ERROR_NOT_FOUND
→ FileNotFoundError 또는 사용자 정의 NotFoundError
NATIVE_ERROR_OUT_OF_MEMORY
→ MemoryError
NATIVE_ERROR_IO
→ OSError
C 함수가 실패했는데 예외를 설정하지 않고 NULL을 반환하거나, 예외가 설정된 상태에서 정상 객체를 반환하면 SystemError가 발생할 수 있다.
C 구조체를 Python 클래스로 노출하기
C 구조체를 Python 객체 타입으로 감쌀 수 있다.
typedef struct {
PyObject_HEAD
double x;
double y;
} PointObject;
Python 객체의 공통 헤더 뒤에 네이티브 필드를 저장한다.
초기화 함수를 정의할 수 있다.
static int
Point_init(
PointObject *self,
PyObject *args,
PyObject *kwargs
)
{
static char *keywords[] = {
"x",
"y",
NULL
};
if (!PyArg_ParseTupleAndKeywords(
args,
kwargs,
"dd",
keywords,
&self->x,
&self->y
)) {
return -1;
}
return 0;
}
메서드를 제공할 수 있다.
static PyObject *
Point_length(
PointObject *self,
PyObject *Py_UNUSED(ignored)
)
{
double result = sqrt(
self->x * self->x
+ self->y * self->y
);
return PyFloat_FromDouble(
result
);
}
Python에서는 일반 클래스처럼 사용할 수 있다.
from geometry_native import Point
point = Point(
x=3.0,
y=4.0,
)
print(point.length())
타입 구현은 객체 할당과 해제, 가비지 컬렉션 방문, 속성, 메서드와 특수 프로토콜을 올바르게 정의해야 하므로 단순 함수 바인딩보다 복잡하다.
Python 객체를 보유하는 네이티브 타입
네이티브 객체가 다른 Python 객체를 속성으로 보유하면 참조 계수와 순환 가비지 컬렉션을 지원해야 한다.
typedef struct {
PyObject_HEAD
PyObject *name;
PyObject *metadata;
} ResourceObject;
객체를 정리할 때 보유한 참조를 해제한다.
static void
Resource_dealloc(
ResourceObject *self
)
{
Py_XDECREF(self->name);
Py_XDECREF(self->metadata);
Py_TYPE(self)->tp_free(
(PyObject *)self
);
}
다른 Python 객체와 순환 관계를 만들 수 있다면 tp_traverse와 tp_clear를 구현하고 가비지 컬렉션 추적 타입으로 등록해야 한다.
이 규칙을 생략하면 참조 순환이 수집되지 않거나 종료 과정에서 잘못된 객체 접근이 발생할 수 있다.
GIL과 C 코드
GIL 활성 CPython에서 Python/C API를 호출하려면 일반적으로 현재 스레드가 GIL과 적절한 Python 스레드 상태를 보유해야 한다.
오래 실행되는 네이티브 계산이나 블로킹 입출력에서 Python 객체에 접근하지 않는 동안 GIL을 해제할 수 있다.
Py_BEGIN_ALLOW_THREADS
result = native_calculation(
input,
input_size
);
Py_END_ALLOW_THREADS
GIL을 해제한 구간에서는 대부분의 Python/C API를 호출하거나 Python 객체의 참조 계수를 변경해서는 안 된다.
Python 인자를 C 데이터로 변환
→ GIL 해제
→ 네이티브 계산
→ GIL 다시 획득
→ 결과를 Python 객체로 생성
이 구조를 사용하면 다른 Python 스레드가 네이티브 계산 중 실행될 수 있다.
네이티브 라이브러리가 콜백으로 Python 함수를 호출한다면 GIL을 다시 확보하고 올바른 인터프리터 상태에 연결해야 한다.
자유 스레딩 확장
자유 스레딩 CPython에서는 여러 스레드가 동시에 Python 코드를 실행할 수 있다. 확장 모듈이 자유 스레딩을 지원한다고 선언하려면 내부 자료구조와 전역 상태가 실제로 스레드 안전해야 한다.
GIL이 항상 존재한다는 가정 아래 작성된 코드는 자유 스레딩 환경에서 데이터 경쟁을 일으킬 수 있다.
static long global_counter = 0;
global_counter++;
다음 요소를 검토해야 한다.
- 정적 전역 가변 상태
- Python 객체에 대한 동시 접근
- 참조 계수 API
- 외부 C 라이브러리의 스레드 안전성
- 콜백과 재진입
- 객체별 잠금
- 모듈별 상태
- 다중 인터프리터 지원
확장 모듈이 자유 스레딩을 지원하지 않는다면 가져올 때 GIL이 다시 활성화될 수 있다. 지원 여부는 사용자에게 명확히 표시해야 한다.
Buffer Protocol
대량의 수치 데이터나 이미지 데이터를 연동할 때 Python 객체를 매번 복사하면 비용이 커질 수 있다. Buffer Protocol은 연속 또는 다차원 메모리를 객체 사이에 공유하는 표준 인터페이스이다.
bytes, bytearray, memoryview, 배열과 여러 수치 라이브러리 객체가 Buffer Protocol을 지원할 수 있다.
buffer = memoryview(
bytearray(1024)
)
C 확장에서는 PyObject_GetBuffer()로 버퍼 정보를 얻을 수 있다.
Py_buffer view;
if (PyObject_GetBuffer(
object,
&view,
PyBUF_CONTIG_RO
) < 0) {
return NULL;
}
/* view.buf와 view.len 사용 */
PyBuffer_Release(
&view
);
Buffer Protocol은 다음 정보를 표현할 수 있다.
- 메모리 주소
- 전체 길이
- 읽기 전용 여부
- 원소 형식
- 차원 수
- 각 차원의 크기
- stride
- 하위 오프셋
가능한 경우 복사 없이 네이티브 코드가 기존 Python 객체의 메모리를 읽거나 변경할 수 있다. 다만 버퍼를 사용하는 동안 원본 객체의 수명과 크기가 유지되어야 한다.
배열 생태계와 연동
과학 계산과 머신러닝 확장은 일반 Python 목록보다 연속 메모리 배열을 주고받는 경우가 많다.
result = native_filter(
image_array
)
바인딩은 다음 항목을 검사해야 한다.
자료형
차원 수
shape
stride
메모리 연속성
읽기·쓰기 가능 여부
장치 위치
소유권과 수명
NumPy C API를 사용하면 NumPy 배열을 직접 생성하고 검사할 수 있다. 그러나 NumPy에 대한 컴파일 의존성과 API 호환성을 관리해야 한다.
Buffer Protocol이나 DLPack과 같은 교환 규약을 사용하면 특정 배열 라이브러리에 대한 결합을 줄일 수 있다. GPU 데이터와 여러 배열 프레임워크 사이에서는 장치와 스트림 동기화까지 고려해야 한다.
ctypes
ctypes는 CPython 확장 모듈을 직접 작성하지 않고 C ABI를 제공하는 공유 라이브러리를 Python에서 호출하는 표준 라이브러리이다.
int add(
int left,
int right
);
공유 라이브러리로 빌드한다.
cc -shared \
-fPIC \
native.c \
-o libnative.so
Python에서 불러온다.
from ctypes import CDLL
from ctypes import c_int
library = CDLL(
"./libnative.so"
)
library.add.argtypes = [
c_int,
c_int,
]
library.add.restype = c_int
result = library.add(
10,
20,
)
ctypes는 다음 기능을 제공한다.
- C 기본 타입
- 포인터
- 배열
- 구조체와 공용체
- 함수 포인터
- 콜백
- 공유 라이브러리 로딩
그러나 함수 시그니처를 잘못 선언해도 Python 수준에서 완전히 검증되지 않으며, 잘못된 포인터나 메모리 접근은 인터프리터 충돌로 이어질 수 있다.
ctypes는 다음 상황에 적합하다.
- 기존 C ABI 공유 라이브러리가 있음
- 노출할 함수 수가 많지 않음
- 별도의 컴파일된 Python 확장을 만들고 싶지 않음
- Python 객체 타입을 새로 만들 필요가 없음
복잡한 C++ 클래스나 정교한 Python 객체 변환에는 다른 바인딩 도구가 더 적합할 수 있다.
CFFI
CFFI는 C 선언을 기반으로 Python과 C 사이의 인터페이스를 작성하는 외부 프로젝트이다. C 헤더와 유사한 선언을 제공하고 기존 공유 라이브러리를 불러오거나 별도의 확장 모듈을 빌드할 수 있다.
from cffi import FFI
ffi = FFI()
ffi.cdef(
"""
int add(
int left,
int right
);
"""
)
library = ffi.dlopen(
"./libnative.so"
)
result = library.add(
10,
20,
)
CFFI는 크게 다음 방식으로 사용할 수 있다.
- ABI 모드: 이미 존재하는 공유 라이브러리를 실행 중 불러온다.
- API 모드: C 소스를 컴파일하고 Python 확장 모듈을 만든다.
ABI 모드는 빠르게 연동할 수 있지만 C 전처리기 매크로, 인라인 함수와 실제 ABI 차이를 개발자가 관리해야 한다. API 모드는 빌드 과정이 필요하지만 컴파일러가 선언과 헤더의 일치를 검사할 수 있다.
C 확장과 ctypes·CFFI의 선택
직접 Python/C API 확장을 작성하면 Python 객체 모델에 가장 깊게 통합할 수 있다.
Python/C API
→ 새 Python 타입
→ 특수 메서드
→ Buffer Protocol
→ 세밀한 객체 수명 제어
→ 높은 구현 복잡성
ctypes와 CFFI는 C ABI를 중심으로 연결한다.
ctypes·CFFI
→ 기존 공유 라이브러리 호출
→ 비교적 적은 바인딩 코드
→ Python 객체 통합 범위는 제한적
기존 C 라이브러리를 장기적으로 여러 언어에서 사용할 계획이라면 핵심 기능을 안정적인 C ABI로 만들고 Python 바인딩을 별도 계층으로 두는 방식이 유용하다.
핵심 라이브러리
├── C ABI
├── Python 바인딩
├── Rust 바인딩
├── Java 바인딩
└── .NET 바인딩
C++ 연동
C++ 코드는 Python/C API를 직접 사용할 수 있지만 클래스, 템플릿, RAII와 예외를 수동으로 Python 객체 모델에 연결하려면 많은 코드가 필요하다.
class Vector {
public:
Vector(
double x,
double y
);
double length() const;
private:
double x_;
double y_;
};
직접 C API로 감쌀 수도 있지만 일반적으로 pybind11, nanobind, Boost.Python, SWIG와 같은 바인딩 도구를 사용한다.
C++ 예외는 Python 호출 경계를 넘어 그대로 전파할 수 없다. 바인딩 계층에서 Python 예외로 변환해야 한다.
std::invalid_argument
→ ValueError
std::out_of_range
→ IndexError
std::bad_alloc
→ MemoryError
사용자 정의 C++ 예외
→ 사용자 정의 Python 예외
C++ 객체의 소유권은 std::unique_ptr, std::shared_ptr와 Python 객체 수명 사이에서 명확하게 정해야 한다.
pybind11
pybind11은 C++11 이상의 타입과 함수, 클래스를 Python에 노출하기 위한 헤더 전용 라이브러리이다. Python/C API의 반복적인 변환 코드를 C++ 템플릿으로 추상화한다.[186]
#include <pybind11/pybind11.h>
namespace py = pybind11;
int add(
int left,
int right
)
{
return left + right;
}
PYBIND11_MODULE(
native_math,
module
)
{
module.doc() =
"Native math functions";
module.def(
"add",
&add,
py::arg("left"),
py::arg("right")
);
}
PYBIND11_MODULE()은 Python이 모듈을 가져올 때 호출할 초기화 함수를 생성한다.[187]
C++ 클래스를 노출할 수 있다.
py::class_<Vector>(
module,
"Vector"
)
.def(
py::init<double, double>(),
py::arg("x"),
py::arg("y")
)
.def(
"length",
&Vector::length
);
Python에서는 일반 클래스처럼 사용한다.
from native_math import Vector
vector = Vector(
3.0,
4.0,
)
print(vector.length())
pybind11은 여러 표준 C++ 타입을 Python 타입으로 자동 변환한다.
std::string
↔ str 또는 bytes
std::vector
↔ list
std::map
↔ dict
std::optional
↔ 값 또는 None
std::function
↔ 호출 가능한 Python 객체
자동 변환은 편리하지만 큰 컨테이너를 복사할 수 있다. 대량 데이터에는 Buffer Protocol, NumPy 배열 바인딩이나 직접 메모리 뷰를 사용할 수 있다.
pybind11과 소유권
C++ 객체를 Python에 노출할 때 객체를 누가 소유하는지 정해야 한다.
py::class_<
Resource,
std::shared_ptr<Resource>
>(
module,
"Resource"
);
반환값 정책을 지정할 수 있다.
module.def(
"get_global_resource",
&get_global_resource,
py::return_value_policy::reference
);
잘못된 반환 정책은 다음 문제를 만들 수 있다.
- 이미 해제된 C++ 객체를 Python이 참조함
- Python과 C++이 같은 객체를 두 번 해제함
- 객체가 영구히 해제되지 않음
- 부모보다 자식 래퍼가 오래 생존함
객체 관계를 유지하려면 keep_alive 정책 등을 사용할 수 있다. 바인딩의 소유권 규칙은 C++ 함수의 실제 반환 의미와 일치해야 한다.
pybind11과 GIL
C++에서 Python 객체나 Python 콜백을 사용하려면 GIL 상태를 관리해야 한다.
오래 실행되는 C++ 함수가 Python 객체에 접근하지 않는다면 GIL을 해제할 수 있다.
module.def(
"calculate",
&calculate,
py::call_guard<
py::gil_scoped_release
>()
);
C++ worker 스레드에서 Python 함수를 호출할 때는 GIL을 획득한다.
void invoke_callback(
py::function callback
)
{
py::gil_scoped_acquire acquire;
callback();
}
자유 스레딩 빌드를 지원한다고 선언할 때는 모듈 자체의 전역 상태와 C++ 라이브러리도 스레드 안전해야 한다. pybind11은 자유 스레딩용 모듈 태그를 제공하지만, 태그를 추가하는 것만으로 사용자 코드가 자동으로 안전해지는 것은 아니다.[188]
pybind11 빌드
CMake에서는 pybind11_add_module()을 사용할 수 있다.[189]
cmake_minimum_required(
VERSION 3.15
)
project(native_math)
find_package(
pybind11 REQUIRED
)
pybind11_add_module(
native_math
src/native_math.cpp
)
target_compile_features(
native_math
PRIVATE
cxx_std_20
)
빌드 결과는 Python 확장 모듈 형식의 공유 라이브러리이다.
Python 패키징과 통합할 때는 scikit-build-core 등의 빌드 백엔드를 사용해 CMake와 wheel 생성을 연결할 수 있다.
[build-system]
requires = [
"scikit-build-core",
"pybind11",
]
build-backend = "scikit_build_core.build"
Cython
Cython은 Python과 유사한 문법에 정적 C 타입과 C 함수 선언을 추가하고, 이를 C 또는 C++ 소스로 변환한 뒤 CPython 확장 모듈로 컴파일한다.[190]
cpdef long sum_values(
long[:] values
):
cdef:
Py_ssize_t index
long total = 0
for index in range(
values.shape[0]
):
total += values[index]
return total
Cython은 다음 용도로 사용할 수 있다.
- Python 코드의 계산 구간 최적화
- C 라이브러리 호출
- C 구조체와 포인터 사용
- C++ 클래스 연동
- Python 확장 타입 작성
- NumPy 배열과 typed memoryview 처리
Cython 소스는 .pyx로 작성하거나 유효한 Python 문법에 Cython 주석을 추가하는 순수 Python 모드를 사용할 수 있다.[191]
Cython에서 C 호출하기
기존 C 헤더의 선언을 작성할 수 있다.
cdef extern from "native_math.h":
int native_add(
int left,
int right
)
Python에 공개하는 함수를 만든다.
def add(
int left,
int right
):
return native_add(
left,
right
)
Cython 컴파일러는 Python 인자를 C 정수로 변환하고 결과를 Python 정수로 감싸는 코드를 생성한다.
C 구조체도 선언할 수 있다.
cdef extern from "geometry.h":
ctypedef struct Point:
double x
double y
double point_length(
Point point
)
Cython은 생성된 C 코드의 빌드와 링크 과정이 필요하다.[192]
Cython의 함수 종류
Cython은 Python과 C 호출 경계를 구분한다.
def python_function(value):
...
def는 Python에서 호출 가능한 일반 함수이다.
cdef int c_function(
int value
):
...
cdef 함수는 Cython 또는 C 수준에서 호출하며 일반 Python 코드에 직접 노출되지 않는다.
cpdef int shared_function(
int value
):
...
cpdef는 Python 호출용 래퍼와 C 수준 진입점을 함께 생성한다.
성능이 중요한 내부 루프는 C 타입과 cdef 함수로 작성하고, 외부에는 Python 친화적인 def 또는 cpdef API를 제공할 수 있다.
Cython과 GIL
Cython 코드도 Python 객체를 조작할 때는 GIL이 필요하다. 순수 C 데이터만 사용하는 구간은 nogil로 실행할 수 있다.
cdef double calculate(
const double[:] values
) noexcept nogil:
...
호출부에서 GIL을 해제할 수 있다.
with nogil:
result = calculate(
values
)
nogil 구간에서는 일반 Python 객체 생성, 속성 접근과 Python 예외 처리를 자유롭게 사용할 수 없다.
Cython은 C 루프를 병렬화하기 위한 기능도 제공하지만 네이티브 스레드 안전성과 데이터 경쟁을 직접 고려해야 한다.
SWIG
SWIG는 C와 C++ 헤더를 분석해 여러 언어용 바인딩 코드를 생성하는 도구이다. 하나의 인터페이스 정의에서 Python뿐 아니라 Java, C#과 다른 언어의 바인딩을 만들 수 있다.
%module native_math
%{
#include "native_math.h"
%}
%include "native_math.h"
SWIG는 기존의 대규모 C·C++ API를 여러 언어에 노출할 때 유용하다.
자동 생성 결과가 항상 각 언어의 관례에 맞는 고수준 API가 되는 것은 아니다. typemap과 사용자 정의 래퍼를 이용해 포인터, 배열, 문자열과 소유권을 조정해야 할 수 있다.
Python 전용의 현대적인 C++ API를 설계할 때는 pybind11과 같은 도구가 더 직접적일 수 있고, 여러 언어에 같은 C++ 라이브러리를 노출해야 한다면 SWIG가 적합할 수 있다.
Rust 연동
Rust에서는 PyO3를 사용해 CPython 확장 모듈을 작성하거나 Rust 응용 프로그램에 Python을 내장할 수 있다.[193]
use pyo3::prelude::*;
#[pyfunction]
fn add(
left: i64,
right: i64,
) -> i64 {
left + right
}
#[pymodule]
fn native_math(
module: &Bound<'_, PyModule>,
) -> PyResult<()> {
module.add_function(
wrap_pyfunction!(
add,
module
)?
)?;
Ok(())
}
Python에서는 일반 확장 모듈로 가져온다.
from native_math import add
result = add(
10,
20,
)
PyO3는 Rust 타입과 Python 객체 사이의 변환, Python 예외, 함수·클래스 노출과 GIL 또는 Python 실행 문맥을 추상화한다.
Rust 클래스를 Python에 노출하기
Rust 구조체에 #[pyclass]를 적용할 수 있다.
use pyo3::prelude::*;
#[pyclass]
struct Counter {
value: i64,
}
#[pymethods]
impl Counter {
#[new]
fn new(
value: i64,
) -> Self {
Self {
value,
}
}
fn increment(
&mut self,
) {
self.value += 1;
}
#[getter]
fn value(
&self,
) -> i64 {
self.value
}
}
모듈에 타입을 등록한다.
module.add_class::<Counter>()?;
Python에서는 다음과 같이 사용한다.
from native_math import Counter
counter = Counter(10)
counter.increment()
print(counter.value)
Rust의 소유권과 대여 규칙은 PyO3 래퍼 내부에서 Python 객체 수명과 조정된다. 그러나 Python 콜백이나 내부 가변성, 다중 스레드 공유는 별도의 동기화와 수명 설계가 필요하다.
Rust 오류 변환
Rust 함수는 PyResult<T>를 반환하여 Python 예외를 전달할 수 있다.
use pyo3::exceptions::PyValueError;
#[pyfunction]
fn square_root(
value: f64,
) -> PyResult<f64> {
if value < 0.0 {
return Err(
PyValueError::new_err(
"value must not be negative"
)
);
}
Ok(value.sqrt())
}
사용자 정의 Rust 오류를 Python 예외로 변환하는 계층을 만들 수 있다.
Rust Result<T, Error>
→ PyResult<T>
→ Python 예외 객체
panic이 Python 호출 경계를 넘어 프로세스를 비정상 종료하지 않도록 오류를 Result로 표현하는 것이 중요하다.
maturin
maturin은 Rust 기반 Python 모듈을 빌드하고 wheel로 패키징하며 게시할 수 있는 도구이다.[194]
프로젝트를 만들 수 있다.
maturin new \
--bindings pyo3 \
native-math
개발 환경에 설치한다.
maturin develop
wheel을 빌드한다.
maturin build \
--release
PyPI에 게시할 수도 있다.
maturin publish
pyproject.toml에서 maturin을 빌드 백엔드로 사용할 수 있다.
[build-system]
requires = [
"maturin>=1.9,<2",
]
build-backend = "maturin"
Python 소스와 Rust 확장을 하나의 혼합 패키지로 구성할 수도 있다.
project/
├── pyproject.toml
├── Cargo.toml
├── src/
│ └── lib.rs
└── python/
└── package_name/
├── __init__.py
└── api.py
Python 계층에서 사용하기 쉬운 API와 타입 주석을 제공하고, 계산 핵심은 Rust 확장으로 구현할 수 있다.
Java 연동
Python과 Java의 연동은 크게 두 방향으로 나뉜다.
CPython에서 JVM을 시작하고 Java 클래스 사용
→ JPype 등의 브리지
JVM 위에서 Python 언어 구현 실행
→ Jython
프로세스를 분리하고 통신
→ HTTP·gRPC·메시지 Queue
JPype와 같은 브리지는 CPython 프로세스 안에서 JVM을 시작하고 Java 클래스와 객체를 Python에서 사용할 수 있게 한다.
import jpype
import jpype.imports
jpype.startJVM(
classpath=[
"application.jar",
]
)
from com.example import Calculator
calculator = Calculator()
result = calculator.add(
10,
20,
)
Python 객체와 Java 객체는 서로 다른 런타임에서 관리되며, 문자열·배열·컬렉션과 예외 변환 규칙을 고려해야 한다.
JVM은 일반적으로 프로세스에서 한 번 시작되며 종료 후 같은 프로세스에서 다시 시작하는 기능이 제한될 수 있다. 클래스 경로와 JVM 옵션도 시작 전에 설정해야 한다.
Java API를 Python에 맞게 감싸기
Java 클래스 구조를 그대로 Python에 노출하면 Java식 getter, 오버로드와 컬렉션이 Python 사용자에게 불편할 수 있다.
java_user.getName()
java_user.setName(
"Luna"
)
Python 래퍼를 추가할 수 있다.
class User:
def __init__(
self,
java_user,
):
self._java_user = java_user
@property
def name(self) -> str:
return (
self._java_user.getName()
)
@name.setter
def name(
self,
value: str,
) -> None:
self._java_user.setName(
value
)
Java 예외도 Python 패키지의 공개 예외로 변환할 수 있다.
try:
result = java_service.load(
identifier
)
except JavaNotFoundException as error:
raise UserNotFoundError(
identifier
) from error
이렇게 하면 Python 사용자는 내부 구현이 Java인지 알 필요 없이 일관된 API를 사용할 수 있다.
Jython
Jython은 Python 코드를 JVM에서 실행하고 Java 클래스와 자연스럽게 연동하는 Python 구현체이다.
from java.util import ArrayList
values = ArrayList()
values.add("Python")
Jython에서는 Python 객체가 JVM 객체 모델과 연결되며 Java 스레드와 라이브러리를 직접 이용할 수 있다.
다만 Jython의 언어 버전 지원은 최신 CPython과 다를 수 있으며, CPython용 C 확장 모듈과 wheel을 그대로 사용할 수 없는 경우가 많다. 프로젝트가 NumPy나 CPython C API에 의존한다면 Jython 사용 가능성을 별도로 검토해야 한다.
Java 통합이 목적이지만 현대 CPython 생태계도 필요하다면 JPype, 프로세스 간 통신 또는 Java 서비스를 별도로 운영하는 방식을 사용할 수 있다.
.NET 연동
Python과 .NET 연동에도 두 가지 대표 방향이 있다.
CPython에서 .NET 런타임과 어셈블리 사용
→ pythonnet
.NET 위에서 Python 구현 실행
→ IronPython
pythonnet을 사용하면 CPython에서 .NET 어셈블리를 불러오고 타입을 사용할 수 있다.
import clr
clr.AddReference(
"System.Windows.Forms"
)
from System.Windows.Forms import (
MessageBox,
)
MessageBox.Show(
"Hello from Python"
)
사용자 정의 어셈블리도 참조할 수 있다.
clr.AddReference(
"Application.Core"
)
from Application.Core import (
Processor,
)
Python과 .NET 사이에서 문자열, 숫자, 배열, 제네릭, delegate와 예외가 변환된다.
.NET API 래핑
.NET API에는 메서드 오버로드, 프로퍼티, 이벤트와 delegate가 존재한다.
processor.Completed += (
on_completed
)
Python 패키지는 내부 .NET 객체를 감싸 고수준 인터페이스를 제공할 수 있다.
class Processor:
def __init__(self) -> None:
self._native = (
DotNetProcessor()
)
def process(
self,
data: bytes,
) -> bytes:
try:
return bytes(
self._native.Process(
data
)
)
except DotNetProcessingException as error:
raise ProcessingError(
str(error)
) from error
.NET 객체가 Python 콜백을 장기간 보유하면 delegate와 Python 객체의 수명이 연결된다. 이벤트 구독을 해제하지 않으면 Python 객체가 예상보다 오래 유지될 수 있다.
IronPython
IronPython은 .NET 위에서 실행되는 Python 구현체이다.
import clr
clr.AddReference(
"System.Xml"
)
.NET 응용 프로그램에서 Python을 스크립트 언어로 사용하거나 Python 코드가 .NET 객체를 직접 조작하는 환경에 적합하다.
CPython의 최신 언어 기능과 네이티브 확장 생태계가 모두 동일하게 지원되는 것은 아니다. CPython용 .pyd 확장과 NumPy 중심 패키지는 호환되지 않을 수 있다.
.NET 통합이 핵심이고 사용하는 Python 코드가 순수 Python 또는 .NET 친화적 라이브러리로 구성된다면 IronPython을 고려할 수 있다.
Go 연동
Go 코드를 Python에서 사용하는 가장 안정적인 일반 구조는 Go 기능을 C ABI 공유 라이브러리나 별도 서비스로 제공하고 Python에서 호출하는 방식이다.
Go는 c-shared 빌드 모드를 이용해 C에서 호출할 수 있는 공유 라이브러리와 헤더를 만들 수 있다.
package main
/*
#include <stdint.h>
*/
import "C"
//export Add
func Add(
left C.longlong,
right C.longlong,
) C.longlong {
return left + right
}
func main() {
}
공유 라이브러리로 빌드한다.
go build \
-buildmode=c-shared \
-o libnative.so
Python에서는 ctypes나 CFFI로 호출할 수 있다.
from ctypes import CDLL
from ctypes import c_longlong
library = CDLL(
"./libnative.so"
)
library.Add.argtypes = [
c_longlong,
c_longlong,
]
library.Add.restype = (
c_longlong
)
이 방식에서는 Python이 Go 런타임을 포함한 공유 라이브러리를 불러온다.
Go API 설계
C ABI 경계에서는 다음과 같은 단순한 타입을 사용하는 것이 좋다.
- 고정 크기 정수
- 부동소수점 수
- 포인터와 길이
- 명시적인 구조체
- 오류 코드
- 불투명 핸들
Go 문자열이나 slice를 C ABI 밖으로 그대로 노출하기보다 포인터와 길이 또는 복사된 C 메모리로 변환한다.
typedef struct {
const unsigned char *data;
size_t length;
} ByteView;
Go 객체를 Python이 직접 소유하도록 하지 않고 정수 핸들로 관리할 수 있다.
Python
→ create_resource()
→ uint64 핸들 반환
→ 핸들로 함수 호출
→ destroy_resource(handle)
Go 가비지 컬렉터가 관리하는 포인터를 C나 Python이 장기간 보유하는 데에는 제한이 있으므로 cgo 포인터 규칙을 따라야 한다.
gopy
gopy는 Go 패키지에서 CPython 확장 모듈과 래퍼 코드를 생성하는 프로젝트이다.[195]
gopy build \
-output=out \
-vm=python3 \
example.com/native
생성 결과에는 Go 공유 라이브러리, C 바인딩, Python 래퍼와 빌드 파일이 포함될 수 있다.
Go는 C·C++·Rust에 비해 CPython 확장 생태계에서 널리 사용되는 주된 구현 언어는 아니다. Go 런타임, cgo와 CPython의 스레드·콜백·수명 관리가 함께 작동하므로 배포와 디버깅이 복잡할 수 있다.
Go 코드가 독립적인 서버 기능이라면 같은 프로세스에 넣기보다 gRPC나 HTTP로 분리하는 것이 더 단순할 수 있다.
Fortran 연동
Fortran은 수치 계산과 과학 코드에서 오래 사용되어 왔으며, NumPy의 F2PY는 Fortran 함수와 서브루틴을 Python에서 호출할 수 있는 확장 모듈로 감싼다.[196]
Fortran 함수는 다음과 같이 작성할 수 있다.
subroutine add_arrays(
first,
second,
result,
size
)
integer, intent(in) :: size
real(8), intent(in) ::
first(size)
real(8), intent(in) ::
second(size)
real(8), intent(out) ::
result(size)
integer :: index
do index = 1, size
result(index) =
first(index)
+ second(index)
end do
end subroutine
F2PY로 확장 모듈을 생성한다.
python -m numpy.f2py \
-c \
native.f90 \
-m native_fortran
Python에서 호출한다.
import numpy as np
import native_fortran
first = np.array(
[1.0, 2.0, 3.0]
)
second = np.array(
[4.0, 5.0, 6.0]
)
result = (
native_fortran.add_arrays(
first,
second,
)
)
F2PY는 Fortran 인자 선언과 의도를 바탕으로 NumPy 배열과 스칼라를 변환한다.[197]
Fortran 배열과 메모리 배치
Fortran 배열은 열 우선 메모리 배치를 기본으로 하고, C와 일반적인 NumPy 배열은 행 우선 배치를 사용할 수 있다.
C order
→ 마지막 차원의 인덱스가 빠르게 변함
Fortran order
→ 첫 번째 차원의 인덱스가 빠르게 변함
NumPy 배열은 두 배치를 모두 표현할 수 있다.
array = np.asfortranarray(
values
)
바인딩에서 배열이 요구하는 자료형과 연속성을 만족하지 않으면 임시 복사본이 만들어질 수 있다. 호출이 빠르더라도 대형 배열 복사 비용이 전체 성능을 지배할 수 있으므로 실제 메모리 배치와 변환을 측정해야 한다.
Fortran 코드가 오래된 전역 상태나 COMMON 블록을 사용하면 다중 스레드 호출의 안전성도 별도로 확인해야 한다.
다른 언어와 C ABI
직접적인 Python 전용 바인딩 도구가 없는 언어도 C ABI를 내보낼 수 있다면 Python과 연결할 수 있다.
언어별 구현
→ C ABI 공유 라이브러리
→ ctypes·CFFI 또는 C 확장
→ Python API
이 방식은 다음 언어에 적용할 수 있다.
- Zig
- D
- Nim
- Swift
- Objective-C
- Pascal 계열
- C ABI를 생성할 수 있는 기타 시스템 언어
C ABI는 클래스, 제네릭, 예외와 가비지 컬렉션 객체를 직접 표현하기 어렵다. 따라서 경계를 단순한 함수와 불투명 핸들, 명시적인 메모리 소유권으로 설계할 수 있다.
프로세스 간 연동
같은 프로세스에 여러 런타임을 넣는 것이 항상 최선은 아니다. Java·.NET·Go·Julia·R 등의 프로그램을 별도 프로세스로 실행하고 Python과 통신할 수 있다.
Python 프로세스
↔ 표준 입출력
↔ 다른 언어 프로그램
Python 서비스
↔ HTTP 또는 gRPC
↔ Java·Go·Rust 서비스
Python worker
↔ 메시지 Queue
↔ 다른 언어 worker
프로세스를 분리하면 다음 장점이 있다.
- 런타임 충돌 격리
- 독립 배포와 재시작
- 서로 다른 운영체제나 장치에서 실행
- 언어별 패키지 관리 분리
- 네이티브 충돌이 Python 프로세스를 직접 종료하지 않음
반면 직렬화, 네트워크와 프로세스 관리 비용이 발생한다.
짧고 빈번한 함수 호출에는 같은 프로세스의 바인딩이 적합하고, 큰 작업 단위와 독립적인 서비스에는 프로세스 간 통신이 적합할 수 있다.
Python 임베딩
C나 C++ 응용 프로그램 안에 Python 인터프리터를 내장하면 Python을 스크립트와 확장 언어로 사용할 수 있다.[198]
간단한 임베딩 코드는 다음과 같다.
#include <Python.h>
int
main(
int argc,
char **argv
)
{
Py_Initialize();
PyRun_SimpleString(
"print('Hello from embedded Python')"
);
if (Py_FinalizeEx() < 0) {
return 120;
}
return 0;
}
실제 응용 프로그램에서는 초기화 전에 Python 경로, 프로그램 이름, 환경 격리와 실행 옵션을 설정할 수 있다.
호스트 응용 프로그램 시작
→ Python 설정 구성
→ 인터프리터 초기화
→ 내장 모듈 등록
→ 사용자 스크립트 로드
→ Python 함수 호출
→ 오류와 트레이스백 처리
→ 인터프리터 종료
게임 엔진, 3D 제작 도구, 데이터베이스와 자동화 프로그램에서 Python 임베딩을 사용할 수 있다.
Python 함수 호출
임베딩 응용 프로그램은 모듈을 가져오고 함수를 찾은 뒤 호출할 수 있다.
PyObject *module =
PyImport_ImportModule(
"application"
);
if (module == NULL) {
PyErr_Print();
return 1;
}
PyObject *function =
PyObject_GetAttrString(
module,
"run"
);
if (function == NULL
|| !PyCallable_Check(function)) {
PyErr_Print();
Py_XDECREF(function);
Py_DECREF(module);
return 1;
}
PyObject *arguments =
Py_BuildValue(
"(i)",
10
);
PyObject *result =
PyObject_CallObject(
function,
arguments
);
반환값을 C 타입으로 변환한다.
long value =
PyLong_AsLong(
result
);
if (PyErr_Occurred()) {
PyErr_Print();
}
모든 객체 참조와 오류 경로를 정리해야 한다.
Python 내장 모듈 등록
호스트 응용 프로그램의 C 함수를 내장 Python 모듈로 제공할 수 있다.
PyImport_AppendInittab(
"engine",
PyInit_engine
);
인터프리터 초기화 전에 등록한다.
Python 스크립트에서는 일반 모듈처럼 가져온다.
import engine
entity = engine.create_entity()
engine.set_position(
entity,
10,
20,
)
이 구조는 호스트 응용 프로그램의 기능을 Python 스크립트 API로 노출한다.
공개 API는 내부 C++ 객체 포인터를 직접 보여주기보다 안전한 핸들 또는 Python 래퍼 객체를 사용할 수 있다.
C++에서 Python 임베딩
pybind11은 C++ 응용 프로그램의 Python 임베딩도 지원한다.[199]
#include <pybind11/embed.h>
namespace py = pybind11;
int main()
{
py::scoped_interpreter guard{};
py::exec(
R"(
print(
"Hello from embedded Python"
)
)"
);
return 0;
}
CMake에서는 임베딩 대상에 연결한다.
find_package(
pybind11 REQUIRED
)
add_executable(
application
src/main.cpp
)
target_link_libraries(
application
PRIVATE
pybind11::embed
)
C++ 객체를 내장 모듈로 노출하고 Python 스크립트가 이를 조작하게 할 수 있다.
콜백
네이티브 코드가 Python 함수를 저장하고 나중에 호출할 수 있다.
Python에서는 콜백을 등록한다.
def on_complete(
result,
):
print(result)
native.set_callback(
on_complete
)
C 확장은 호출 가능한 객체인지 검사하고 참조를 보유한다.
if (!PyCallable_Check(
callback
)) {
PyErr_SetString(
PyExc_TypeError,
"callback must be callable"
);
return NULL;
}
Py_INCREF(callback);
Py_XSETREF(
state->callback,
callback
);
나중에 호출한다.
PyObject *result =
PyObject_CallOneArg(
state->callback,
value
);
콜백은 다음 문제를 고려해야 한다.
- Python 객체 수명
- 호출 스레드
- GIL 또는 자유 스레딩 문맥
- Python 예외 처리
- 재진입
- 응용 프로그램 종료 중 콜백
- 콜백 해제와 순환 참조
네이티브 worker 스레드에서 발생한 이벤트를 바로 Python으로 호출하기보다 스레드 안전 Queue에 넣고 Python 실행 스레드가 처리하도록 할 수도 있다.
메모리 소유권
언어 경계를 지나는 데이터는 누가 할당하고 누가 해제하는지 명확해야 한다.
C에서 malloc()
→ Python이 사용
→ C의 free 함수로 해제
Python의 메모리 할당 함수와 C 표준 malloc()을 서로 바꾸어 해제해서는 안 된다.
PyMem_Malloc()
→ PyMem_Free()
malloc()
→ free()
new
→ delete
new[]
→ delete[]
라이브러리가 반환한 메모리는 해당 라이브러리가 제공하는 해제 함수를 사용하도록 설계할 수 있다.
unsigned char *
native_create_data(
size_t *size
);
void
native_free_data(
unsigned char *data
);
Python 바인딩은 결과를 Python 객체로 복사한 뒤 네이티브 메모리를 해제할 수 있다.
네이티브 메모리 생성
→ Python bytes로 복사
→ 네이티브 메모리 해제
복사를 피하려면 Python 객체가 네이티브 메모리의 소유자를 참조하고, 객체가 정리될 때 올바른 해제 함수를 호출하도록 캡슐화해야 한다.
문자열과 인코딩
Python의 str은 Unicode 문자열이고 bytes는 바이트열이다. C의 char *가 문자 데이터인지 임의 바이트인지 구분해야 한다.
텍스트 API는 인코딩을 명확하게 정할 수 있다.
Python str
→ UTF-8 인코딩
→ C char *
파일이나 네트워크의 원시 데이터는 bytes로 받는 것이 적합할 수 있다.
def decode_image(
data: bytes,
) -> Image:
...
C 문자열에 포함된 NUL 바이트와 길이를 처리하려면 길이 없는 char *만으로 충분하지 않을 수 있다.
const char *data;
Py_ssize_t length;
반환 문자열의 소유권과 유효 기간도 확인해야 한다. 임시 C 문자열 포인터를 Python 객체에 복사하지 않고 반환하면 함수 종료 뒤 유효하지 않을 수 있다.
구조체와 ABI
C 구조체를 언어 경계에 그대로 노출하면 컴파일러, 운영체제와 CPU에 따라 정렬과 크기가 달라질 수 있다.
typedef struct {
int type;
double value;
} Result;
다음 요소가 ABI에 영향을 줄 수 있다.
- 필드 정렬
- 패딩
- 정수 크기
- 엔디언
- 컴파일러 옵션
- 호출 규약
- 구조체 반환 방식
공개 C ABI에서는 고정 크기 정수와 명시적인 버전, 크기 필드를 사용할 수 있다.
typedef struct {
uint32_t struct_size;
uint32_t api_version;
int32_t type;
double value;
} ResultV1;
구조체 내부를 숨기고 불투명 포인터만 제공하는 방식은 ABI 변경을 쉽게 한다.
typedef struct NativeContext
NativeContext;
NativeContext *
native_context_create(void);
void
native_context_destroy(
NativeContext *context
);
Python/C API와 ABI
CPython의 일반 C API 전체가 모든 Python 버전 사이에서 바이너리 호환되는 것은 아니다. 확장 모듈 wheel은 보통 특정 Python 구현체, 버전, ABI와 플랫폼 태그를 가진다.
package-1.0.0
-cp314
-cp314
-manylinux_2_28_x86_64.whl
cp314는 CPython 3.14용 빌드를 나타낼 수 있다.
제한된 API와 Stable ABI를 사용하면 여러 CPython 3 버전에서 같은 바이너리를 사용할 수 있다.[200]
#define Py_LIMITED_API 0x030A0000
#include <Python.h>
이 확장은 지원하는 최소 API 이후의 일반 GIL 활성 CPython에서 abi3 wheel로 배포할 수 있다.
package-1.0.0
-cp310
-abi3
-manylinux_2_28_x86_64.whl
제한된 API는 일부 내부 구조와 최신 저수준 최적화 기능을 사용할 수 없다는 대가가 있다. 성능과 기능 요구에 따라 버전별 API와 Stable ABI 가운데 선택한다.
자유 스레딩 빌드는 별도의 ABI 조건을 가지므로 일반 abi3와 동일하게 취급해서는 안 된다.
HPy
HPy는 CPython 내부 객체 구조와 참조 계수에 대한 직접 의존을 줄이고, CPython·PyPy·GraalPy 등 여러 구현체에서 확장 모듈을 지원하기 위한 대체 네이티브 API이다.
전통적 C API는 PyObject *와 CPython 참조 계수 모델에 밀접하게 연결된다.
HPy는 핸들 기반 API를 사용해 구현체가 객체 표현과 가비지 컬렉션을 더 자유롭게 선택할 수 있게 한다.
전통적 Python/C API
→ CPython 내부와 강한 결합
HPy
→ 불투명 핸들
→ 구현체 독립성 향상
여러 Python 구현체를 지원해야 하는 신규 확장에서는 HPy 지원 여부를 검토할 수 있다.
Python 패키지 구조
네이티브 확장은 Python 패키지 안에 내부 모듈로 배치하고, Python 계층에서 공개 API를 제공할 수 있다.
project/
├── pyproject.toml
├── src/
│ └── image_core/
│ ├── __init__.py
│ ├── api.py
│ ├── _native.pyi
│ └── _native.so
├── native/
│ ├── image.c
│ └── image.h
└── tests/
사용자는 내부 _native 모듈을 직접 사용하지 않는다.
# image_core/api.py
from image_core import _native
def resize(
image: Image,
width: int,
height: int,
) -> Image:
if width <= 0:
raise ValueError(
"width must be positive"
)
if height <= 0:
raise ValueError(
"height must be positive"
)
return _native.resize(
image,
width,
height,
)
이 구조는 다음 이점을 제공한다.
- 입력 검증을 Python에서 수행
- 네이티브 API를 비공개로 유지
- 순수 Python 대체 구현 제공
- 타입 주석과 문서 작성 용이
- 네이티브 모듈 교체 가능
- 플랫폼별 구현 차이 숨김
대체 구현
네이티브 확장을 사용할 수 없는 플랫폼에 순수 Python 구현을 제공할 수 있다.
try:
from image_core import (
_native as _backend
)
except ImportError:
from image_core import (
_python as _backend
)
사용자에게 어떤 backend가 사용 중인지 진단 기능을 제공할 수 있다.
def backend_name() -> str:
return _backend.__name__
네이티브 backend와 Python backend는 같은 공개 테스트를 통과해야 한다.
def test_resize_backend(
backend,
):
...
대체 구현은 설치 가능 플랫폼을 넓히지만 기능과 수치 결과가 완전히 같도록 유지하는 추가 비용이 있다.
타입 스텁
네이티브 확장 모듈은 Python 소스에서 함수 정의를 볼 수 없으므로 .pyi 스텁을 제공할 수 있다.
# _native.pyi
class Image:
@classmethod
def load(
cls,
path: str,
) -> Image:
...
def save(
self,
path: str,
) -> None:
...
def resize(
image: Image,
width: int,
height: int,
interpolation: str = "linear",
) -> Image:
...
py.typed를 포함해 패키지가 타입 정보를 제공한다는 사실을 표시할 수 있다.
image_core/
├── __init__.py
├── _native.so
├── _native.pyi
└── py.typed
PyO3와 일부 도구는 바인딩 정보에서 스텁 생성을 지원한다.[201]
스텁은 실제 네이티브 함수의 인자, 기본값과 예외 의미를 정확하게 반영해야 한다.
문서 문자열과 시그니처
네이티브 함수도 Python 사용자가 help()와 IDE에서 확인할 문서 문자열을 제공해야 한다.
PyDoc_STRVAR(
resize_doc,
"resize(image, width, height, "
"interpolation='linear')\n"
"--\n"
"\n"
"Resize an image."
);
pybind11에서는 인자 이름과 문서를 함께 등록할 수 있다.
module.def(
"resize",
&resize,
py::arg("image"),
py::arg("width"),
py::arg("height"),
py::arg("interpolation")
= "linear",
"Resize an image."
);
Python API의 실제 호출 시그니처와 문서·스텁이 다르면 사용자와 도구가 잘못된 정보를 얻게 된다. 자동 생성과 테스트를 통해 일치 여부를 검사할 수 있다.
pyproject.toml
네이티브 확장 패키지도 pyproject.toml에서 빌드 백엔드와 의존성을 선언한다.[202]
setuptools 기반 C 확장은 다음과 같은 빌드 요구를 가질 수 있다.
[build-system]
requires = [
"setuptools>=77",
"wheel",
]
build-backend = "setuptools.build_meta"
CMake와 pybind11에는 scikit-build-core를 사용할 수 있다.
[build-system]
requires = [
"scikit-build-core",
"pybind11",
]
build-backend = "scikit_build_core.build"
Cython 프로젝트는 빌드 환경에 Cython을 선언할 수 있다.
[build-system]
requires = [
"setuptools>=77",
"Cython>=3",
]
build-backend = "setuptools.build_meta"
Rust 프로젝트는 maturin을 사용할 수 있다.
[build-system]
requires = [
"maturin>=1.9,<2",
]
build-backend = "maturin"
최종 사용자는 빌드 도구를 미리 설치하지 않아도 빌드 frontend가 격리된 환경에 필요한 의존성을 설치할 수 있다.
소스 배포판과 wheel
Python 패키지 색인은 주로 소스 배포판과 wheel을 제공한다.[203]
소스 배포판
→ package-1.0.0.tar.gz
wheel
→ package-1.0.0-cp314-cp314-platform.whl
순수 Python wheel은 여러 플랫폼에서 같은 파일을 사용할 수 있다.
package-1.0.0-py3-none-any.whl
네이티브 확장이 포함된 wheel은 Python ABI와 운영체제, CPU 아키텍처에 종속될 수 있다.
Linux x86-64 wheel
macOS ARM64 wheel
Windows x86-64 wheel
CPython 3.13 wheel
CPython 3.14 wheel
자유 스레딩 CPython wheel
wheel 형식은 설치 시 로컬 컴파일 없이 파일을 적절한 위치에 배치할 수 있는 바이너리 배포 형식이다.[204]
플랫폼 호환성 태그
wheel 파일명에는 호환 가능한 Python, ABI와 플랫폼 태그가 들어간다.[205]
distribution-version
-python_tag
-abi_tag
-platform_tag.whl
예시는 다음과 같다.
native_math-1.0.0
-cp314
-cp314
-win_amd64.whl
native_math-1.0.0
-cp310
-abi3
-manylinux_2_28_x86_64.whl
호환 태그가 맞지 않으면 설치 도구는 해당 wheel을 사용하지 않고 다른 wheel이나 소스 배포판을 찾는다.
Linux wheel
Linux 바이너리는 배포판마다 시스템 라이브러리 버전이 다를 수 있다. manylinux 정책은 일정한 glibc 기반 호환성을 나타낸다.
manylinux_2_28_x86_64
외부 공유 라이브러리를 wheel에 포함하거나 허용된 시스템 라이브러리에 의존하도록 구성할 수 있다. auditwheel과 같은 도구는 Linux wheel의 외부 의존성을 검사하고 필요한 라이브러리를 포함하도록 수정할 수 있다.
musl 기반 시스템에는 musllinux 태그가 사용될 수 있다.
하나의 Linux 시스템에서 빌드된 .so를 모든 Linux 환경에 그대로 배포할 수 있다고 가정해서는 안 된다.
macOS와 Windows wheel
macOS wheel은 지원하는 macOS 최소 버전과 CPU 아키텍처를 태그에 포함한다.
macosx_11_0_arm64
Intel과 Apple Silicon을 함께 포함하는 universal2 wheel을 만들 수도 있다.
Windows 확장은 일반적으로 .pyd 파일이며 Python을 빌드한 컴파일러와 호환되는 ABI를 사용해야 한다. 네이티브 DLL 의존성도 wheel과 설치 환경에서 찾을 수 있어야 한다.
_native.cp314-win_amd64.pyd
dependency.dll
DLL 검색 경로와 런타임 라이브러리 버전 문제는 Python 모듈을 찾지 못한 것처럼 보이는 ImportError를 만들 수 있다.
자동 빌드
여러 Python 버전과 플랫폼용 wheel은 CI에서 자동으로 만들 수 있다.
Linux x86-64
Linux ARM64
macOS x86-64
macOS ARM64
Windows x86-64
×
CPython 3.10
CPython 3.11
CPython 3.12
CPython 3.13
CPython 3.14
각 빌드에서 다음을 검사할 수 있다.
- wheel 생성
- 깨끗한 환경에 wheel 설치
- 확장 모듈 가져오기
- 단위 테스트
- 타입 스텁 포함 확인
- 공유 라이브러리 의존성 검사
- wheel 메타데이터 검사
cibuildwheel과 같은 도구는 여러 CI 환경에서 Python wheel 빌드를 자동화하는 데 사용될 수 있다.
외부 라이브러리 포함
네이티브 확장이 별도의 C·C++ 라이브러리에 의존할 때 배포 전략을 선택해야 한다.
정적 링크
→ wheel 내부 확장에 코드 포함
동적 링크 후 라이브러리 포함
→ wheel에 공유 라이브러리 함께 배포
시스템 라이브러리 의존
→ 사용자 시스템이 별도 제공
정적 링크는 설치를 단순화할 수 있지만 파일 크기와 라이선스 조건에 영향을 준다.
공유 라이브러리를 포함하면 wheel 수정 도구와 런타임 검색 경로를 구성해야 한다.
시스템 라이브러리에 의존하면 wheel 이식성이 낮아지고 사용자가 운영체제 패키지를 별도로 설치해야 할 수 있다.
의존 라이브러리의 라이선스와 재배포 조건도 확인해야 한다.
개발 모드
네이티브 확장 개발에서는 편집 가능 설치와 증분 빌드를 사용할 수 있다.
python -m pip install -e .
Rust에서는 다음 명령을 사용할 수 있다.
maturin develop
CMake 기반 프로젝트는 빌드 디렉터리를 유지하며 변경된 C++ 파일만 다시 컴파일할 수 있다.
cmake \
--build build \
--parallel
네이티브 코드를 변경하면 일반 Python 파일과 달리 다시 컴파일해야 한다. 개발용 Debug 빌드와 배포용 Release 빌드를 구분할 수 있다.
디버깅
Python과 네이티브 코드가 함께 실행되면 Python 디버거와 네이티브 디버거를 함께 사용할 수 있다.
Python 호출 스택
→ 확장 함수 진입
→ C·C++·Rust 호출 스택
→ 운영체제 또는 라이브러리 호출
네이티브 충돌은 Python 예외를 발생시키지 않고 프로세스를 종료할 수 있다.
대표적인 문제는 다음과 같다.
- 잘못된 참조 계수
- 이미 해제된 객체 접근
- 버퍼 범위 초과
- 잘못된 함수 포인터
- ABI 불일치
- GIL 없이 Python API 호출
- C++ 예외가 C 경계를 넘음
- Rust panic 처리 누락
- 공유 라이브러리 누락
- 데이터 경쟁
Debug 심볼과 AddressSanitizer, UndefinedBehaviorSanitizer, ThreadSanitizer 및 Valgrind 등을 사용할 수 있다. Python Debug 빌드는 참조 계수와 메모리 문제를 더 쉽게 발견하도록 추가 검사를 제공할 수 있다.
테스트
네이티브 확장은 Python 공개 API와 저수준 구현을 함께 검사해야 한다.
def test_add():
assert native_math.add(
10,
20,
) == 30
잘못된 입력의 예외도 검사한다.
def test_negative_size():
with pytest.raises(
ValueError
):
native.resize(
image,
-1,
10,
)
다음 항목을 포함할 수 있다.
- 경계값
- 빈 입력
- 매우 큰 입력
- 잘못된 타입
- 메모리 부족 경로
- Python 콜백 예외
- 여러 스레드 호출
- 반복적인 생성과 해제
- 서브인터프리터
- 자유 스레딩 빌드
- 여러 플랫폼과 CPU
- wheel 설치 후 실행
- 순수 Python 대체 구현과 결과 비교
퍼징을 이용해 C API에 다양한 잘못된 입력을 전달하고 충돌과 메모리 오류를 찾을 수도 있다.
성능 측정
네이티브 언어로 작성했다는 사실만으로 전체 Python API가 자동으로 빨라지는 것은 아니다.
호출마다 다음 비용이 발생할 수 있다.
Python 함수 호출
→ 인자 검사
→ 객체 변환
→ 데이터 복사
→ 네이티브 함수 호출
→ 결과 변환
→ Python 객체 생성
네이티브 함수가 매우 작은 작업만 수행하면 호출과 변환 비용이 계산 시간보다 클 수 있다.
for value in values:
result = native.square(
value
)
전체 배열을 한 번에 전달하는 API가 더 효율적일 수 있다.
results = native.square_all(
values
)
성능을 높이려면 언어 경계를 덜 자주 넘고 한 번의 호출에서 충분한 작업을 처리하도록 API를 설계할 수 있다.
대량 데이터는 복사 횟수와 메모리 배치도 측정해야 한다.
안정적인 연동 계층
장기간 유지할 연동 계층은 다음과 같은 구조를 사용할 수 있다.
순수 핵심 라이브러리
→ 안정적인 C ABI
언어별 얇은 네이티브 바인딩
→ Python/C API
→ JNI
→ .NET P/Invoke
→ Rust FFI
언어별 고수준 API
→ Python 관례
→ Java 관례
→ .NET 관례
C ABI는 내부 클래스와 템플릿 구현을 숨긴다. Python 계층은 오류 코드와 포인터를 Python 객체와 예외로 변환한다.
이 구조는 내부 구현을 C++나 Rust로 변경하더라도 공개 C ABI와 Python API를 유지할 수 있게 한다.
반대로 Python만 지원하는 프로젝트라면 중간 C ABI 없이 pybind11이나 PyO3로 직접 노출하는 방식이 더 간단할 수 있다.
연동 방식 선택
직접 Python/C API를 사용하는 방식은 다음 경우에 적합하다.
- CPython 객체 모델에 깊게 통합해야 함
- 새로운 Python 타입과 프로토콜을 구현함
- 의존성 없이 저수준 제어가 필요함
- CPython 내부와 성능을 세밀하게 조정함
ctypes 또는 CFFI는 다음 경우에 적합하다.
- 기존 C ABI 라이브러리가 있음
- 함수 중심 API임
- Python 확장 컴파일을 최소화하고 싶음
- 객체 모델 통합이 복잡하지 않음
pybind11은 다음 경우에 적합하다.
- 현대 C++ 클래스와 함수를 노출함
- C++ 템플릿과 RAII를 활용함
- Python 전용의 자연스러운 C++ 바인딩이 필요함
Cython은 다음 경우에 적합하다.
- Python에 가까운 문법으로 C 확장을 개발함
- 기존 Python 계산 코드를 점진적으로 최적화함
- C·C++ 라이브러리와 배열을 함께 사용함
PyO3와 maturin은 다음 경우에 적합하다.
- Rust로 안전한 네이티브 확장을 작성함
- Cargo 생태계와 Python 패키징을 연결함
- Rust 라이브러리를 Python API로 제공함
F2PY는 다음 경우에 적합하다.
- 기존 Fortran 수치 코드를 NumPy 배열 API로 노출함
- Fortran 서브루틴과 함수의 래퍼를 자동 생성함
JVM·.NET 브리지는 다음 경우에 적합하다.
- 기존 Java 또는 .NET 라이브러리를 같은 프로세스에서 사용함
- 해당 런타임 객체와 API에 직접 접근해야 함
프로세스 간 통신은 다음 경우에 적합하다.
- 런타임과 장애를 강하게 격리해야 함
- 다른 언어 구성 요소를 독립 배포함
- 호출 단위가 비교적 큼
- 네트워크 서비스 구조가 이미 존재함
설계 원칙
Python과 다른 언어의 연동은 다음과 같은 원칙으로 구성할 수 있다.
- Python 사용자에게는 Python다운 함수, 클래스와 예외를 제공한다.
- 네이티브 포인터와 오류 코드를 공개 API에 그대로 노출하지 않는다.
- 언어 경계의 메모리 소유권과 해제 함수를 명확히 한다.
- 문자열의 인코딩과 바이트 데이터의 차이를 구분한다.
- 대량 데이터에는 Buffer Protocol과 배열 교환 규약을 검토한다.
- 작은 함수를 반복 호출하기보다 작업을 묶어 경계 통과 횟수를 줄인다.
- Python 예외와 네이티브 오류의 변환 규칙을 일관되게 유지한다.
- 네이티브 코드가 Python 객체에 접근하지 않는 긴 작업에서는 GIL 해제를 검토한다.
- 자유 스레딩과 다중 인터프리터 지원 여부를 명시한다.
- 공개 Python API와 내부 확장 모듈을 분리한다.
- 네이티브 모듈에
.pyi스텁과 문서 문자열을 제공한다. - 실제 wheel을 깨끗한 환경에 설치해 검사한다.
- Python 버전, ABI, 운영체제와 CPU별 빌드를 자동화한다.
- 네이티브 충돌과 메모리 오류를 Python 테스트만으로 충분히 찾을 수 있다고 가정하지 않는다.
- 확장 모듈이 필요한 성능 향상을 실제 입력으로 측정한다.
Python의 외부 언어 연동은 C API 하나에 한정되지 않는다. C ABI는 여러 언어가 공유할 수 있는 가장 일반적인 경계이며, pybind11·Cython·PyO3·F2PY 등의 도구는 각 언어의 타입과 개발 방식을 Python 객체 모델에 연결한다. JVM과 .NET 브리지는 다른 관리형 런타임을 같은 프로세스에서 사용할 수 있게 하고, 프로세스 간 통신은 런타임을 격리하면서 언어 생태계를 연결한다.
이러한 연동 계층을 통해 계산 핵심과 기존 라이브러리는 C·C++·Rust·Fortran 등으로 구현하면서, 최종 사용자는 Python 패키지를 설치하고 일반 Python 함수와 클래스 형태로 기능을 사용할 수 있다.
패키징과 생태계
Python의 패키징 생태계는 다른 개발자가 작성한 라이브러리와 응용 프로그램을 검색·설치하고, 프로젝트의 의존성을 관리하며, 자신이 개발한 코드를 배포하는 체계로 이루어진다. 공개 패키지 저장소인 PyPI, 패키지 설치 도구인 pip, 프로젝트 메타데이터를 기록하는 pyproject.toml, 배포 형식인 wheel과 소스 배포판이 중심적인 표준을 형성한다.
프로젝트 소스
→ `pyproject.toml`에 메타데이터와 빌드 설정 기록
→ 빌드 백엔드가 배포 파일 생성
→ PyPI 또는 사설 패키지 색인에 게시
→ pip 등의 설치 도구가 배포 파일 검색
→ 의존성 해결
→ 가상 환경이나 대상 실행 환경에 설치
Python 패키징에는 하나의 도구가 모든 작업을 독점적으로 담당하지 않는다. 저장소, 설치 frontend, 빌드 frontend, 빌드 backend, 환경 관리자와 프로젝트 관리자가 서로 분리되어 있으며, 사용자는 필요한 도구를 조합하거나 이들을 통합한 고수준 프로젝트 관리 도구를 사용할 수 있다.
패키지 색인
→ PyPI, 사설 색인
설치 frontend
→ pip, uv
빌드 frontend
→ build, uv build
빌드 backend
→ setuptools, Hatchling, Flit,
PDM Backend, maturin,
scikit-build-core, meson-python
프로젝트·의존성 관리자
→ uv, Poetry, PDM, Hatch
응용 프로그램 설치
→ pipx, uv tool
다언어 환경 관리자
→ Conda, Mamba, Pixi
이러한 도구들은 모두 같은 기능을 제공하는 직접적인 경쟁 관계는 아니다. 예를 들어 PyPI는 패키지를 저장하고 제공하는 서비스이고, pip는 저장소에서 패키지를 받아 설치하는 도구이다. Hatchling은 배포 파일을 만드는 빌드 backend이며, Hatch는 환경·빌드·게시를 함께 관리하는 프로젝트 관리 도구이다.
배포 프로젝트와 가져오기 패키지
Python 코드에서 사용하는 가져오기 이름과 패키지 색인에서 설치하는 배포 프로젝트 이름은 서로 다른 개념이다.
python -m pip install beautifulsoup4
설치 후에는 다른 이름으로 가져온다.
from bs4 import BeautifulSoup
beautifulsoup4는 배포 프로젝트 이름이고 bs4는 가져오기 패키지 이름이다.
하나의 배포 프로젝트가 여러 가져오기 패키지와 모듈을 제공할 수도 있다.
배포 프로젝트
└── 가져오기 패키지와 모듈
├── package
├── package.subpackage
└── helper_module
반대로 네임스페이스 패키지는 하나의 가져오기 이름 계층이 여러 배포 프로젝트에 나뉘어 제공될 수 있다.
이 구분 때문에 설치 명령에 사용하는 이름을 그대로 import 문에 사용할 수 있다고 가정해서는 안 된다. 프로젝트 문서에서 공식 가져오기 경로를 확인해야 한다.
PyPI
PyPI는 Python용 소프트웨어를 배포하는 공식 공개 패키지 저장소이다. 프로젝트 관리자는 wheel과 소스 배포판을 PyPI에 게시하며, 사용자는 웹 사이트나 설치 도구를 통해 프로젝트와 릴리스를 찾을 수 있다.[206]
PyPI의 기본 구조는 다음과 같다.
프로젝트
├── 릴리스 1.0.0
│ ├── 소스 배포판
│ ├── Linux wheel
│ ├── macOS wheel
│ └── Windows wheel
└── 릴리스 1.1.0
├── 소스 배포판
└── 여러 wheel
하나의 프로젝트에는 여러 버전의 릴리스가 존재하고, 각 릴리스에는 운영체제와 Python 버전 등에 따른 여러 배포 파일이 포함될 수 있다.[207]
PyPI에 등록된 프로젝트 이름은 대소문자와 일부 구분 문자가 정규화되어 비교된다.
example-project
example_project
example.project
이와 같은 이름은 패키지 색인에서 같은 정규화 이름으로 취급될 수 있다. 새 프로젝트를 게시하려면 충돌하지 않는 배포 이름을 선택해야 한다.
PyPI와 패키지 신뢰
PyPI가 공식 패키지 저장소라는 사실은 등록된 모든 프로젝트의 품질과 안전성을 Python 프로젝트가 보증한다는 뜻은 아니다. 누구나 계정을 만들고 프로젝트를 게시할 수 있으므로 설치 전에 다음 사항을 확인할 수 있다.
- 프로젝트 이름이 정확한가
- 공식 저장소와 문서가 연결되어 있는가
- 최근에도 유지보수되는가
- 라이선스가 적절한가
- 필요한 Python 버전을 지원하는가
- 릴리스와 변경 기록이 합리적인가
- 알려진 보안 문제가 있는가
- 의존성 수와 설치 스크립트가 예상 범위인가
유명 프로젝트와 비슷한 이름을 사용하는 타이포스쿼팅 패키지가 게시될 수 있으므로 설치 명령을 직접 입력할 때 이름을 주의해야 한다.
python -m pip install requests
프로젝트의 공식 문서나 저장소에서 제공하는 설치 명령을 사용하는 편이 안전하다.
PyPI는 프로젝트 상태를 active, archived, quarantined 등으로 표시할 수 있다. 보관된 프로젝트는 더 이상 적극적으로 유지되지 않음을 나타낼 수 있고, 격리된 프로젝트는 보안 또는 정책상의 이유로 일반 사용이 제한될 수 있다.[208]
TestPyPI
TestPyPI는 실제 PyPI와 분리된 테스트용 패키지 색인이다. 패키지 게시 설정과 배포 파일을 시험할 때 사용할 수 있다.
python -m twine upload \
--repository testpypi \
dist/*
테스트 색인에서 설치할 수 있다.
python -m pip install \
--index-url \
https://test.pypi.org/simple/ \
example-package
TestPyPI의 계정과 프로젝트는 실제 PyPI와 별도로 관리된다. TestPyPI에는 실제 의존 패키지가 모두 존재하지 않을 수 있으므로 의존성 설치 시험에는 추가 색인 설정이 필요할 수 있다.
실제 릴리스 이름과 버전은 PyPI에 게시한 뒤 일반적으로 같은 파일을 덮어쓸 수 없으므로, 정식 게시 전에 배포 결과를 충분히 검사해야 한다.
사설 패키지 색인
조직 내부 라이브러리나 공개할 수 없는 패키지는 사설 패키지 색인에 배포할 수 있다.
개발자
→ 사설 패키지 색인
→ 내부 라이브러리 설치
외부 공개 패키지
→ PyPI
pip는 다른 색인을 기본 저장소로 사용할 수 있다.
python -m pip install \
--index-url \
https://packages.example.com/simple/ \
internal-library
PyPI를 보조 색인으로 지정할 수도 있다.
python -m pip install \
--index-url \
https://packages.example.com/simple/ \
--extra-index-url \
https://pypi.org/simple/ \
internal-library
여러 색인을 함께 사용할 때 내부 패키지와 같은 이름을 가진 공개 패키지가 선택되는 dependency confusion 문제가 발생할 수 있다. 내부 전용 이름 공간, 색인 우선순위와 설치 정책을 명확하게 관리해야 한다.
사설 저장소에는 다음과 같은 구현을 사용할 수 있다.
- 조직의 패키지 저장소 서비스
- 클라우드 기반 artifact registry
- devpi
- pypiserver
- Artifactory
- Nexus Repository
- GitHub Packages 등의 배포 서비스
pip
pip는 Python 패키지를 설치하는 표준적인 도구이다. PyPI를 비롯한 패키지 색인, 로컬 디렉터리, wheel, 소스 배포판과 버전 관리 저장소 등에서 배포 프로젝트를 설치할 수 있다.[209]
python -m pip install requests
특정 버전을 설치할 수 있다.
python -m pip install \
"requests==2.32.5"
버전 범위를 지정할 수 있다.
python -m pip install \
"requests>=2.30,<3"
선택 의존성인 extra를 설치할 수 있다.
python -m pip install \
"package[database]"
로컬 프로젝트를 설치할 수 있다.
python -m pip install .
개발 중인 프로젝트는 편집 가능 모드로 설치할 수 있다.
python -m pip install -e .
pip는 설치 도구이며 Python 인터프리터 버전 자체를 설치하거나 프로젝트의 모든 개발 작업을 통합 관리하는 도구는 아니다. 가상 환경 생성, Python 설치와 lock file 생성 등에는 다른 도구를 함께 사용할 수 있다.
python -m pip
실행 파일 이름인 pip를 직접 호출하면 의도하지 않은 Python 설치에 연결된 pip가 실행될 수 있다.
pip install package
현재 선택한 인터프리터의 pip를 실행하려면 다음 형식을 사용할 수 있다.
python -m pip install package
Windows의 Python Launcher를 사용할 수도 있다.
py -3.14 -m pip install package
이 방식은 패키지를 설치할 대상 Python 실행 파일을 더 명확하게 지정한다.
/path/to/venv/bin/python \
-m pip install package
의존성 해결
배포 프로젝트는 자신의 실행 의존성과 Python 버전 요구 사항을 메타데이터에 선언한다.
[project]
dependencies = [
"httpx>=0.28,<1",
"platformdirs>=4",
]
pip는 요청한 프로젝트와 그 하위 의존성의 버전 조건을 함께 만족하는 배포 조합을 찾는다.
application
├── library-a >= 2
│ └── common >= 1,<3
└── library-b >= 4
└── common >= 2,<4
해결 가능 범위
→ common >= 2,<3
서로 충돌하는 조건만 존재하면 설치가 실패한다.
library-a
→ common < 2
library-b
→ common >= 3
패키지 해결기는 가능한 버전 조합을 탐색하며, 프로젝트 수와 버전 조건이 많아지면 역추적 과정에 시간이 걸릴 수 있다.
배포 라이브러리는 실제 호환 범위를 표현하고, 최종 응용 프로그램은 검증된 구체 버전을 별도의 lock file이나 constraints로 고정할 수 있다.
요구 사항 파일
pip는 요구 사항 파일에서 설치할 프로젝트를 읽을 수 있다.
# requirements.txt
httpx==0.28.1
platformdirs==4.3.8
python -m pip install \
-r requirements.txt
요구 사항 파일은 프로젝트 메타데이터 표준 자체라기보다 pip가 처리하는 설치 입력 형식이다.
다른 요구 사항 파일을 포함할 수 있다.
-r base.txt
pytest==8.4.1
constraints 파일은 패키지를 직접 설치 대상으로 추가하지 않고, 해결 가능한 버전을 제한한다.
# constraints.txt
urllib3==2.5.0
python -m pip install \
-c constraints.txt \
application-package
pip freeze는 현재 환경에 설치된 배포 프로젝트와 버전을 출력한다.
python -m pip freeze \
> requirements.txt
이 결과는 환경의 현재 상태를 기록하지만, 최상위 의존성과 전이 의존성, 개발 도구가 모두 섞일 수 있다. 프로젝트 의존성의 원본 선언으로 사용하기보다는 환경 재현이나 진단 목적으로 사용할 수 있다.
pyproject.toml
현대 Python 프로젝트는 pyproject.toml에 빌드 시스템과 프로젝트 메타데이터를 기록한다.[210]
[build-system]
requires = [
"hatchling",
]
build-backend = "hatchling.build"
[project]
name = "example-package"
version = "1.0.0"
description = "Example package"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.28",
]
주요 테이블은 다음과 같다.
[build-system]
→ 빌드 backend와 빌드 의존성
[project]
→ 표준 프로젝트 메타데이터
[tool.<도구 이름>]
→ 도구별 확장 설정
[project]에는 다음과 같은 정보를 기록할 수 있다.
- 배포 프로젝트 이름
- 버전
- 설명
- README
- Python 버전 요구 사항
- 라이선스
- 작성자와 유지관리자
- 분류자
- 실행 의존성
- 선택 의존성
- 명령행 진입점
- 플러그인 진입점
- 프로젝트 URL
도구가 표준 [project] 메타데이터를 지원하면 빌드 backend를 교체하더라도 핵심 정보의 형식을 유지할 수 있다.
선택 의존성
특정 기능에만 필요한 의존성은 optional dependency group으로 선언할 수 있다.
[project.optional-dependencies]
database = [
"sqlalchemy>=2",
]
image = [
"pillow>=11",
]
all = [
"sqlalchemy>=2",
"pillow>=11",
]
사용자는 필요한 기능만 설치한다.
python -m pip install \
"example-package[database]"
여러 extra를 함께 설치할 수 있다.
python -m pip install \
"example-package[database,image]"
extra는 배포 패키지의 공개 인터페이스 일부이다. 이름을 변경하거나 제거하면 사용자 설치 명령과 의존 프로젝트에 영향을 줄 수 있다.
개발·테스트용 의존성을 배포 extra로 제공하는 프로젝트도 있지만, 프로젝트 관리자 고유의 dependency group이나 표준화된 별도 그룹을 사용하는 방식도 있다.
환경 marker
운영체제, Python 버전과 구현체에 따라 의존성을 다르게 선언할 수 있다.
[project]
dependencies = [
"colorama>=0.4; sys_platform == 'win32'",
"tomli>=2; python_version < '3.11'",
]
CPU 아키텍처나 구현체도 조건으로 사용할 수 있다.
dependencies = [
"package; platform_machine == 'x86_64'",
"package; implementation_name == 'cpython'",
]
marker는 설치 환경에서 평가된다. 지원하지 않는 환경의 의존성을 불필요하게 설치하지 않게 하지만, 조건 조합이 많아지면 각 플랫폼의 실제 해결 결과를 모두 검사해야 한다.
소스 배포판
소스 배포판인 sdist는 프로젝트를 빌드하는 데 필요한 소스와 메타데이터를 묶은 .tar.gz 형식의 배포 파일이다.[211]
example_package-1.0.0.tar.gz
사용 환경에 적합한 wheel이 없으면 설치 도구가 소스 배포판을 내려받아 로컬에서 wheel을 빌드할 수 있다.
sdist 다운로드
→ 격리된 빌드 환경 생성
→ 빌드 의존성 설치
→ wheel 빌드
→ wheel 설치
순수 Python 프로젝트는 비교적 쉽게 빌드할 수 있지만, 네이티브 확장이 있는 프로젝트는 컴파일러와 시스템 헤더, 외부 라이브러리가 필요할 수 있다.
소스 저장소에 존재하는 모든 파일이 자동으로 sdist에 포함되는 것은 아니다. 빌드 결과에 필요한 소스, 타입 정보, 라이선스와 데이터 파일이 실제로 포함되는지 검사해야 한다.
wheel
wheel은 Python 패키지의 빌드된 배포 형식이다.
example_package-1.0.0-py3-none-any.whl
순수 Python wheel의 py3-none-any 태그는 Python 3에서 사용할 수 있고 특정 ABI나 플랫폼에 종속되지 않음을 나타낸다.
네이티브 확장이 포함되면 플랫폼별 wheel이 필요할 수 있다.
example_package-1.0.0
-cp314
-cp314
-manylinux_2_28_x86_64.whl
wheel 설치는 일반적으로 소스를 실행 환경에서 다시 컴파일하지 않고 파일을 배치하고 메타데이터를 등록한다. 이 때문에 설치 속도와 재현성이 높아질 수 있다.
프로젝트가 지원하는 Python 버전과 운영체제에 맞는 wheel을 제공하지 않으면 사용자가 직접 컴파일해야 하거나 설치에 실패할 수 있다.
빌드 frontend
빌드 frontend는 pyproject.toml의 [build-system]을 읽고 격리된 환경에서 빌드 backend를 실행한다.
PyPA의 build 프로젝트를 사용할 수 있다.
python -m build
일반적으로 다음 파일이 생성된다.
dist/
├── example_package-1.0.0.tar.gz
└── example_package-1.0.0-py3-none-any.whl
uv도 배포 파일 빌드를 지원한다.
uv build
빌드 frontend는 프로젝트의 실제 패키지 파일 선택과 메타데이터 생성을 직접 구현하지 않고 backend에 요청한다.
빌드 backend
빌드 backend는 소스 트리에서 sdist와 wheel을 생성한다.
대표적인 backend에는 다음이 있다.
- setuptools
- Hatchling
- Flit Core
- PDM Backend
- maturin
- scikit-build-core
- meson-python
프로젝트는 [build-system]에서 사용할 backend를 선언한다.
[build-system]
requires = [
"setuptools>=77",
]
build-backend = "setuptools.build_meta"
backend에 따라 Python 패키지 검색, 포함할 파일, 동적 버전, 네이티브 빌드와 확장 설정 방식이 달라진다.
setuptools
setuptools는 오랫동안 Python 패키징에서 널리 사용된 빌드 도구이며, 순수 Python 패키지와 C·C++ 확장 모듈을 모두 지원한다.
[build-system]
requires = [
"setuptools>=77",
]
build-backend = "setuptools.build_meta"
현대 프로젝트는 setup.py에 메타데이터를 직접 실행 코드로 작성하기보다 pyproject.toml과 선언적 설정을 사용할 수 있다.
기존 프로젝트에는 다음 파일이 남아 있을 수 있다.
setup.py
setup.cfg
MANIFEST.in
setup.py 파일이 존재한다고 해서 이를 직접 실행하는 방식이 권장되는 것은 아니다.
# 오래된 직접 실행 방식
python setup.py install
대신 표준 frontend를 사용한다.
python -m pip install .
python -m build
setuptools는 유연하고 기존 생태계와의 호환성이 넓지만, 단순한 순수 Python 프로젝트에는 더 작은 backend를 선택할 수도 있다.
Hatchling
Hatchling은 Hatch 프로젝트에서 사용하는 표준 기반 빌드 backend이다.
[build-system]
requires = [
"hatchling",
]
build-backend = "hatchling.build"
순수 Python 프로젝트의 wheel·sdist 생성, 버전 관리 hook과 파일 선택 기능을 제공한다.
Hatch를 프로젝트 관리자로 사용하지 않고 Hatchling만 독립적인 backend로 사용할 수도 있다.
Hatch
→ 프로젝트·환경 관리자
Hatchling
→ 빌드 backend
이 둘을 같은 도구로 혼동하지 않아야 한다.
Flit
Flit은 비교적 단순한 순수 Python 패키지를 배포하기 위한 도구와 빌드 backend를 제공한다.
[build-system]
requires = [
"flit_core>=3.11,<4",
]
build-backend = "flit_core.buildapi"
Python 모듈이나 패키지의 메타데이터를 바탕으로 wheel과 sdist를 만들 수 있다.
복잡한 네이티브 빌드나 세밀한 파일 생성 과정이 필요한 프로젝트보다, 일반적인 순수 Python 라이브러리에 적합하다.
PDM Backend
PDM Backend는 PDM 프로젝트와 함께 개발되는 PEP 표준 기반 빌드 backend이다.
[build-system]
requires = [
"pdm-backend",
]
build-backend = "pdm.backend"
PDM을 의존성 관리자로 사용하면서 다른 backend를 선택할 수도 있고, PDM Backend만 사용하면서 프로젝트 관리는 다른 도구로 수행할 수도 있다.
PDM은 프로젝트 초기화 과정에서 setuptools, Flit, Hatchling, maturin과 PDM Backend 등 여러 backend를 선택할 수 있게 한다.[212]
네이티브 빌드 backend
Rust 확장에는 maturin을 사용할 수 있다.
[build-system]
requires = [
"maturin>=1.9,<2",
]
build-backend = "maturin"
CMake 기반 C·C++ 확장에는 scikit-build-core를 사용할 수 있다.
[build-system]
requires = [
"scikit-build-core",
"pybind11",
]
build-backend = "scikit_build_core.build"
Meson 기반 네이티브 프로젝트에는 meson-python을 사용할 수 있다.
[build-system]
requires = [
"meson-python",
]
build-backend = "mesonpy"
이러한 backend는 Python 패키징 frontend와 각 언어의 빌드 시스템을 연결하여 플랫폼별 wheel을 생성한다.
게시
빌드한 배포 파일은 Twine으로 PyPI에 게시할 수 있다.
python -m twine upload \
dist/*
사용자 이름과 비밀번호 대신 PyPI API token을 사용할 수 있다.
자동화된 CI 게시에는 PyPI의 Trusted Publishing을 사용할 수 있다. 이는 GitHub Actions 등 신뢰된 CI 환경이 OpenID Connect를 통해 단기 인증을 얻도록 하여 장기 API token을 저장할 필요를 줄인다.
게시 전에는 다음을 검사할 수 있다.
python -m twine check \
dist/*
검사 항목에는 README 렌더링과 메타데이터의 기본적인 유효성이 포함된다.
릴리스를 게시한 뒤 같은 프로젝트 이름과 버전의 파일을 임의로 교체할 수 없으므로 버전과 배포 결과를 확인해야 한다.
버전
Python 배포 프로젝트의 버전은 PEP 440 규칙에 따라 비교된다.
1.0.0
1.1.0
2.0.0
시험판과 개발판을 표현할 수 있다.
2.0.0a1
2.0.0b1
2.0.0rc1
2.0.0.dev1
후속 수정 릴리스를 표현할 수 있다.
1.0.0.post1
프로젝트가 사용하는 마케팅 버전이나 Git tag가 Python 패키징 버전 규칙과 다르면 빌드 backend가 정규화할 수 있다.
버전은 pyproject.toml에 직접 기록할 수 있다.
[project]
version = "1.4.0"
소스나 버전 관리 tag에서 동적으로 가져올 수도 있다.
[project]
dynamic = [
"version",
]
동적 버전은 backend별 설정이 필요하며, sdist에서 다시 wheel을 만들 때도 같은 버전이 재현되어야 한다.
가상 환경
가상 환경은 프로젝트별로 설치된 패키지를 분리한다.
python -m venv .venv
POSIX 셸에서는 다음과 같이 활성화할 수 있다.
source .venv/bin/activate
Windows PowerShell에서는 다음 형식을 사용할 수 있다.
.venv\Scripts\Activate.ps1
활성화는 명령 검색 경로를 변경하는 편의 기능이며 필수는 아니다.
.venv/bin/python \
-m pip install .
가상 환경은 Python 패키지 의존성을 분리하지만 운영체제 라이브러리, GPU driver와 외부 서비스까지 격리하지는 않는다.
프로젝트마다 별도 환경을 사용하면 서로 다른 버전 요구 사항의 충돌과 시스템 Python 오염을 줄일 수 있다.
lock file
프로젝트 메타데이터의 의존성 선언은 일반적으로 허용 가능한 버전 범위를 표현한다.
dependencies = [
"httpx>=0.28,<1",
]
lock file은 특정 환경에서 실제 선택된 버전과 배포 파일을 더 구체적으로 기록한다.
직접 의존성 조건
→ 해결기 실행
→ 전이 의존성 포함
→ 정확한 버전과 artifact 선택
→ lock file 저장
lock file의 목적은 여러 개발자와 CI, 배포 환경에서 같은 의존성 해결 결과를 재현하는 것이다.
Poetry, PDM, uv, Pipenv, Pixi와 Conda 계열 도구는 각각 자신의 lock 형식을 사용해 왔다.
poetry.lock
pdm.lock
uv.lock
Pipfile.lock
pixi.lock
conda-lock.yml
서로 다른 lock file은 같은 프로젝트에서 직접 호환되지 않을 수 있다.
pylock.toml
PEP 751은 Python 환경의 재현 가능한 설치를 위한 표준 lock file 형식인 pylock.toml을 정의했다.[213]
pylock.toml
pylock.production.toml
pylock.test.toml
파일 이름은 pylock으로 시작하고 .toml로 끝나는 형식을 사용한다.
pylock.toml은 다음과 같은 정보를 기록할 수 있다.
- 패키지 이름과 버전
- wheel과 sdist artifact
- 파일 hash
- 환경 marker
- 직접·전이 의존성
- 설치 가능한 그룹
- 패키지 색인과 직접 URL 정보
표준 형식의 목적은 하나의 도구에서 생성한 lock 결과를 다른 호환 설치 도구에서도 사용할 수 있게 하는 것이다. 다만 표준이 새로 도입된 만큼 모든 프로젝트 관리자와 설치 도구가 동일한 범위로 지원하는 것은 아니며, 실제 지원 상태를 확인해야 한다.
uv
uv는 Astral이 Rust로 개발하는 Python 패키지 및 프로젝트 관리 도구이다. 패키지 설치, 의존성 해결과 lock, 가상 환경, Python 버전 설치, 스크립트 실행, 도구 설치, 프로젝트 빌드와 게시 등을 하나의 명령행 도구에서 제공한다.[214]
프로젝트를 만들 수 있다.
uv init application
의존성을 추가할 수 있다.
uv add httpx
개발 의존성을 추가할 수 있다.
uv add \
--dev \
pytest
프로젝트 환경을 동기화한다.
uv sync
프로젝트 명령을 실행한다.
uv run pytest
Python 자체를 설치할 수도 있다.
uv python install 3.14
Python 명령행 도구를 격리하여 설치할 수 있다.
uv tool install ruff
일회성 환경에서 실행할 수 있다.
uvx ruff check .
배포 파일을 만들고 게시할 수도 있다.
uv build
uv publish
uv는 기존 pip 명령과 유사한 저수준 인터페이스도 제공한다.
uv pip install requests
uv pip 명령은 빠른 패키지 설치 인터페이스를 제공하지만, uv 프로젝트의 uv add·uv sync와 같은 선언적 프로젝트 흐름과는 목적이 다르다.
uv.lock
uv 프로젝트는 해결된 의존성 상태를 uv.lock에 기록한다.
project/
├── pyproject.toml
└── uv.lock
pyproject.toml에는 사용자가 선언한 범위가 들어가고 uv.lock에는 실제 선택된 전이 의존성과 배포 정보가 들어간다.
uv lock
현재 lock 상태와 프로젝트 선언을 기준으로 환경을 동기화한다.
uv sync \
--frozen
--frozen은 lock file을 다시 갱신하지 않고 기존 해결 결과를 사용하도록 한다.
uv는 여러 Python 버전과 운영체제 조건을 하나의 범용 lock file에 표현할 수 있다. 실제 설치 환경에서는 marker와 지원 태그에 맞는 artifact를 선택한다.
uv 작업 공간
여러 Python 프로젝트를 하나의 저장소에서 관리하는 workspace를 구성할 수 있다.
repository/
├── pyproject.toml
├── uv.lock
└── packages/
├── core/
│ └── pyproject.toml
└── cli/
└── pyproject.toml
workspace 구성원 사이의 로컬 의존성을 연결하고 하나의 lock file로 해결할 수 있다.
[tool.uv.workspace]
members = [
"packages/*",
]
모노레포에서 여러 라이브러리와 응용 프로그램이 같은 의존성 해결 결과를 공유할 때 사용할 수 있다.
uv 빌드 backend
uv는 패키지 관리 기능뿐 아니라 자체 빌드 backend도 제공한다.
[build-system]
requires = [
"uv_build",
]
build-backend = "uv_build"
uv build backend는 일반적인 순수 Python 패키지 빌드를 대상으로 한다.[215]
복잡한 네이티브 확장이나 기존 backend의 특수 기능이 필요한 프로젝트는 setuptools, maturin이나 scikit-build-core 등을 계속 사용할 수 있다.
uv 프로젝트 관리자를 사용한다고 반드시 uv build backend를 사용해야 하는 것은 아니다.
uv
→ 의존성·환경·명령 관리
Hatchling
→ 프로젝트 빌드
이와 같은 조합도 가능하다.
Poetry
Poetry는 Python 프로젝트의 의존성 해결, 가상 환경, lock file, 빌드와 게시를 통합하는 프로젝트 관리 도구이다.[216]
poetry new application
의존성을 추가할 수 있다.
poetry add httpx
개발 그룹에 추가할 수 있다.
poetry add \
--group dev \
pytest
프로젝트 환경에서 명령을 실행한다.
poetry run pytest
배포 파일을 빌드한다.
poetry build
Poetry는 해결 결과를 poetry.lock에 기록한다.
project/
├── pyproject.toml
└── poetry.lock
Poetry는 초기에는 [tool.poetry] 중심의 독자적인 메타데이터 형식을 사용했지만, 현대 버전에서는 표준 [project] 메타데이터와의 연계도 발전하고 있다. 기존 프로젝트와 새 프로젝트의 설정 형식이 다를 수 있으므로 대상 Poetry 버전의 문서를 확인해야 한다.
Poetry는 라이브러리와 응용 프로그램 양쪽에서 사용되지만, 라이브러리의 lock file은 일반적으로 해당 라이브러리를 설치하는 외부 사용자의 의존성을 강제로 고정하지 않는다. 배포 메타데이터에는 호환 가능한 의존성 범위가 기록된다.
PDM
PDM은 PEP 표준을 중심으로 설계된 Python 프로젝트 및 의존성 관리자이다. pyproject.toml 기반 프로젝트 생성, 의존성 해결, lock, 가상 환경, 스크립트, 빌드와 게시를 지원한다.[217]
pdm init
의존성을 추가한다.
pdm add httpx
개발 그룹에 추가할 수 있다.
pdm add \
-dG test \
pytest
환경을 설치 상태와 동기화한다.
pdm sync
프로젝트 명령을 실행한다.
pdm run pytest
배포 파일을 만들고 PyPI에 게시할 수 있다.
pdm publish
PDM은 pdm.lock에 해결 결과를 저장한다.
PDM은 특정 빌드 backend를 강제하지 않으며 PDM Backend, Hatchling, setuptools, Flit와 maturin 등을 선택할 수 있다.[218]
Hatch
Hatch는 PyPA에서 관리되는 확장 가능한 Python 프로젝트 관리자이다. 프로젝트 생성, 환경 관리, 스크립트 실행, 버전 관리, 빌드와 게시 기능을 제공한다.[219]
프로젝트를 만들 수 있다.
hatch new application
환경에서 명령을 실행한다.
hatch run pytest
여러 환경을 정의할 수 있다.
[tool.hatch.envs.test]
dependencies = [
"pytest",
]
hatch run test:pytest
Hatch 환경은 테스트, 문서 생성, 포매팅과 린트처럼 프로젝트 작업별로 격리된 실행 환경을 제공한다.[220]
Hatch는 기본적으로 Hatchling과 잘 통합되지만, 프로젝트 관리와 빌드 backend의 역할은 구분된다.
Hatch는 범용 dependency lock을 중심으로 하는 Poetry·PDM·uv와는 접근 방식이 다르며, 여러 행렬 환경과 빌드·릴리스 작업 관리에 특히 활용될 수 있다.
Pipenv
Pipenv는 Pipfile과 Pipfile.lock을 이용해 가상 환경과 의존성을 관리하는 도구이다.
pipenv install httpx
개발 의존성을 추가할 수 있다.
pipenv install \
--dev \
pytest
프로젝트 환경에서 실행한다.
pipenv run pytest
Pipenv는 pip와 가상 환경을 하나의 작업 흐름으로 묶어 널리 사용되었으며 현재도 유지되는 프로젝트이다. 다만 pyproject.toml을 중심으로 한 현대 패키징 도구와 달리 자체 Pipfile 형식을 사용한다.
기존 Pipenv 프로젝트를 유지하거나 Pipfile 기반 작업 흐름이 필요한 경우 사용할 수 있다. 새 프로젝트에서는 uv, PDM, Poetry 등의 방식과 비교하여 선택할 수 있다.
pip-tools
pip-tools는 pip과 requirements 파일 기반 작업 흐름에 재현 가능한 해결 결과를 추가하는 도구이다.
최상위 의존성을 requirements.in에 기록한다.
httpx>=0.28
sqlalchemy>=2
pip-compile로 고정된 파일을 만든다.
pip-compile \
requirements.in
출력은 전이 의존성을 포함한다.
anyio==4.9.0
certifi==2025.4.26
httpcore==1.0.9
httpx==0.28.1
...
환경을 정확히 동기화할 수 있다.
pip-sync \
requirements.txt
pip-tools는 전체 프로젝트 관리자보다 pip와 requirements 파일을 유지하면서 의존성 해결과 동기화만 보강하려는 프로젝트에 적합하다.
pyproject.toml의 의존성을 입력으로 사용하는 흐름도 지원할 수 있다.
pipx
pipx는 Python으로 작성된 명령행 응용 프로그램을 각각 독립된 가상 환경에 설치하고 실행 명령만 사용자의 PATH에 노출한다.[221]
pipx install poetry
각 응용 프로그램은 다른 패키지와 충돌하지 않는 별도 환경에 설치된다.
pipx 환경
├── poetry 전용 환경
├── hatch 전용 환경
└── httpie 전용 환경
일회성 환경에서 응용 프로그램을 실행할 수 있다.
pipx run cowsay Hello
pip는 현재 활성 환경에 라이브러리와 응용 프로그램을 모두 설치하지만, pipx는 최종 사용자가 실행하는 응용 프로그램 설치에 초점을 맞춘다.
다음과 같은 개발 도구를 설치할 수 있다.
pipx install black
pipx install mypy
pipx install twine
프로젝트의 실행 의존성은 pipx가 아니라 프로젝트 가상 환경이나 프로젝트 관리 도구로 설치한다.
최근 pipx는 PEP 751 기반 pylock.toml을 이용한 응용 프로그램 환경 고정 기능도 제공한다.[222]
Conda
Conda는 Python 패키지뿐 아니라 C·C++·Fortran 라이브러리, 실행 파일과 시스템 수준 의존성을 함께 관리하는 범용 패키지 및 환경 관리자이다.
conda create \
-n analysis \
python=3.14 \
numpy \
scipy
conda activate analysis
Conda 패키지는 PyPI wheel과 다른 형식과 저장소 체계를 사용한다.
PyPI 생태계
→ pip
→ wheel·sdist
Conda 생태계
→ conda
→ Conda 패키지
→ channel
과학 계산, GPU, 네이티브 라이브러리와 여러 언어의 도구를 하나의 환경에서 관리할 때 유용하다.
Conda 환경 안에서도 pip를 사용할 수 있지만, Conda와 pip가 같은 파일이나 라이브러리를 서로 다르게 관리하지 않도록 설치 순서와 환경 정책을 주의해야 한다. 일반적으로 가능한 패키지를 Conda로 먼저 설치하고, Conda에 없는 Python 패키지를 마지막에 pip로 설치하는 방식을 사용할 수 있다.
Mamba와 Micromamba
Mamba는 Conda 패키지 형식과 저장소를 사용하는 고성능 패키지 관리자이다. Conda와 비슷한 명령 체계를 제공하면서 빠른 의존성 해결과 설치를 목표로 한다.
mamba create \
-n analysis \
python=3.14 \
numpy
Micromamba는 별도의 기본 Python 설치를 요구하지 않는 작은 독립 실행형 도구이다.
micromamba create \
-n analysis \
python=3.14
컨테이너와 CI에서 Conda 환경을 빠르게 구성할 때 사용될 수 있다.
이 도구들은 PyPI 중심 프로젝트 관리자라기보다 Conda 생태계의 패키지 및 환경 관리자이다.
Pixi
Pixi는 Prefix.dev가 Rust로 개발하는 다중 언어 패키지 및 workflow 관리자이다. Conda 생태계를 기반으로 하며 Python·C++·Fortran·R·CUDA 등의 의존성과 PyPI 패키지를 하나의 프로젝트 환경에서 관리할 수 있다.[223]
프로젝트를 만들 수 있다.
pixi init application
Python과 패키지를 추가할 수 있다.
pixi add python
pixi add numpy
PyPI 의존성을 추가할 수도 있다.
pixi add \
--pypi \
httpx
프로젝트 작업을 정의한다.
[tasks]
test = "pytest"
pixi run test
Pixi는 해결 결과를 pixi.lock에 기록한다.
project/
├── pixi.toml 또는 pyproject.toml
└── pixi.lock
Pixi는 Python만이 아니라 컴파일러, C 라이브러리와 GPU runtime까지 같은 환경에 포함해야 하는 과학 계산·로봇공학·머신러닝 프로젝트에서 특히 활용될 수 있다. PyPI 패키지는 내부적으로 별도의 Python 패키지 해결 계층과 함께 관리된다.
Rye
Rye는 Python 설치, pyproject.toml 프로젝트, 의존성 lock, 가상 환경, 명령행 도구와 배포 기능을 통합하려던 프로젝트이다.
rye init application
rye add httpx
rye sync
Rye는 Python 프로젝트 관리 도구의 통합 방향에 영향을 주었지만, 2025년 2월 이후 개발이 중단되었다. 같은 유지관리 계열은 기존 사용자에게 후속 프로젝트인 uv로 이전할 것을 권장한다.[224]
Rye
→ 더 이상 신규 개발 및 보안 업데이트 없음
uv
→ 후속 프로젝트
→ Rye 기능 대부분을 포함하며 계속 개발
새 프로젝트에서 Rye를 선택하기보다 uv나 다른 활발히 유지되는 도구를 사용해야 한다. 기존 Rye 프로젝트는 lock과 설정의 차이를 확인하면서 uv로 이전할 수 있다.
다른 소규모 도구
Python 패키징 생태계에는 특정 기능에 초점을 맞춘 여러 도구가 존재한다.
pipdeptree는 설치된 패키지의 의존성 트리를 출력한다.
pipdeptree
pip-audit은 설치 환경이나 요구 사항 파일의 알려진 취약점을 Python Packaging Advisory Database와 비교한다.
pip-audit
deptry는 선언된 의존성과 실제 import 사용 관계를 검사하여 누락되거나 불필요한 의존성을 찾는다.
deptry .
tox는 여러 Python 버전과 의존성 조합에서 테스트 명령을 실행한다.
[tox]
env_list = py311, py312, py313, py314
nox는 Python 함수로 테스트 session을 정의한다.
import nox
@nox.session
def tests(session):
session.install(
".",
"pytest",
)
session.run(
"pytest",
)
towncrier는 작은 변경 기록 조각을 모아 릴리스 변경 기록을 생성한다.
bump-my-version과 유사한 도구는 여러 파일의 프로젝트 버전을 함께 갱신한다.
cibuildwheel은 CI에서 여러 Python 버전과 플랫폼용 네이티브 wheel을 만든다.
이러한 도구는 프로젝트 관리자를 대체하기보다 패키징과 릴리스 과정의 특정 부분을 보완한다.
의존성 그룹
프로젝트는 실행 의존성 이외에 테스트, 문서와 개발 도구를 별도 그룹으로 관리할 수 있다.
기본 의존성
→ 사용자가 패키지를 실행하는 데 필요
test 그룹
→ pytest, coverage
docs 그룹
→ Sphinx, MkDocs
lint 그룹
→ Ruff, mypy
각 프로젝트 관리자는 그룹을 표현하는 방식이 다를 수 있다.
[dependency-groups]
test = [
"pytest>=8",
"coverage>=7",
]
docs = [
"sphinx>=8",
]
표준 dependency groups 명세를 지원하는 도구가 늘고 있지만, 모든 도구가 같은 설정을 해석한다고 가정해서는 안 된다.
배포 대상 사용자가 설치해야 하는 선택 기능은 [project.optional-dependencies]에 두고, 프로젝트 개발자만 사용하는 도구는 dependency group에 두는 방식으로 구분할 수 있다.
응용 프로그램과 라이브러리의 차이
라이브러리는 여러 외부 프로젝트가 자신의 의존성 해결에 포함하므로 지나치게 정확한 버전 고정을 피하고 실제 호환 범위를 선언할 수 있다.
[project]
dependencies = [
"httpx>=0.27,<1",
]
최종 응용 프로그램은 검증한 정확한 해결 결과를 lock file에 기록할 수 있다.
라이브러리
→ 호환 가능한 버전 범위
→ 외부 사용자의 해결기에 참여
응용 프로그램
→ 정확한 lock 결과
→ 배포 환경 재현
라이브러리 저장소에도 테스트와 개발을 위한 lock file을 둘 수 있지만, 게시된 라이브러리의 일반 사용자는 배포 메타데이터에 기록된 범위를 기준으로 자신의 환경을 해결한다.
명령행 응용 프로그램 배포
Python 패키지는 설치 시 명령행 실행 파일을 생성하는 진입점을 선언할 수 있다.
[project.scripts]
techpedia = "techpedia.cli:main"
설치 후 다음 명령을 사용할 수 있다.
techpedia
실제 실행 파일은 활성 Python 환경의 entry point wrapper이며 지정된 Python 함수를 호출한다.
# techpedia/cli.py
def main() -> int:
...
명령행 응용 프로그램은 일반 프로젝트 환경에 설치하거나 pipx, uv tool로 격리해 설치할 수 있다.
pipx install techpedia
uv tool install techpedia
플러그인 생태계
Python 패키지는 entry point metadata를 이용해 다른 응용 프로그램의 플러그인을 제공할 수 있다.
[project.entry-points."techpedia.plugins"]
markdown = "techpedia_markdown:MarkdownPlugin"
호스트 응용 프로그램은 설치된 배포 메타데이터를 조사해 플러그인을 찾는다.
from importlib.metadata import (
entry_points,
)
plugins = entry_points(
group="techpedia.plugins"
)
이 구조를 사용하면 플러그인 패키지가 호스트 응용 프로그램과 별도로 PyPI에 배포될 수 있다.
호스트 패키지
├── 기본 기능
└── 플러그인 검색 인터페이스
독립 배포 플러그인
├── 데이터베이스 plugin
├── renderer plugin
└── exporter plugin
플러그인 API의 버전과 호환 범위는 별도로 관리해야 한다.
패키지 데이터
Python 소스 이외의 템플릿, 스키마, 정적 파일과 타입 스텁을 배포 파일에 포함할 수 있다.
package/
├── __init__.py
├── py.typed
├── schema.json
└── templates/
└── page.html
설치된 패키지 자원은 importlib.resources로 읽을 수 있다.
from importlib.resources import files
schema = (
files("package")
.joinpath("schema.json")
.read_text(
encoding="utf-8"
)
)
작업 디렉터리나 소스 저장소 경로를 기준으로 파일을 찾으면 wheel 설치 후 경로가 달라질 수 있다.
빌드한 wheel과 sdist에 필요한 데이터가 실제로 포함되었는지 검사해야 한다.
타입 정보 배포
타입 주석을 포함한 패키지는 py.typed 표시 파일을 배포할 수 있다.
package/
├── __init__.py
└── py.typed
별도의 스텁 패키지를 배포할 수도 있다.
types-library
→ 원본 library용 `.pyi` 제공
네이티브 확장은 구현 함수의 Python 소스가 없으므로 .pyi 파일을 함께 제공하는 경우가 많다.
package/
├── _native.so
├── _native.pyi
└── py.typed
타입 정보 파일도 wheel과 sdist의 일부이므로 빌드 설정에 포함해야 한다.
라이선스와 메타데이터
배포 프로젝트는 라이선스 표현과 라이선스 파일을 메타데이터에 포함할 수 있다.
[project]
license = "MIT"
license-files = [
"LICENSE",
]
외부 의존성이 있는 프로젝트는 자신의 라이선스뿐 아니라 포함하거나 정적으로 연결한 네이티브 라이브러리의 재배포 조건도 확인해야 한다.
분류자는 프로젝트의 개발 상태, 대상 사용자와 지원 환경을 설명한다.
classifiers = [
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3",
"Typing :: Typed",
]
분류자는 검색과 정보 제공에 사용되지만 실제 호환성을 강제하지 않는다. requires-python, 환경 marker와 wheel 태그가 설치 도구의 호환성 판단에 더 직접적으로 사용된다.
공급망 보안
패키지는 설치 과정에서 빌드 backend와 여러 빌드 의존성을 실행할 수 있다. 소스 배포판을 로컬에서 빌드하면 프로젝트가 제공한 빌드 코드가 실행된다.
신뢰할 수 없는 패키지를 주 사용자 환경이나 시스템 Python에 직접 설치하지 않는 것이 좋다.
python -m venv \
temporary-environment
의존성 파일에 hash를 기록하면 내려받은 artifact가 예상한 파일인지 확인할 수 있다.
package==1.0.0 \
--hash=sha256:...
CI와 배포 환경에서는 다음을 적용할 수 있다.
- 정확한 프로젝트 이름 사용
- lock file 검토
- artifact hash 검증
- 사설 index와 공개 index 정책 분리
- 최소 권한의 build worker 사용
- 취약점 데이터베이스 검사
- 게시 계정의 다중 인증
- API token 대신 Trusted Publishing 사용
- 릴리스 서명과 provenance 정보 확인
- 의존성 갱신을 자동화하되 검토 후 병합
PyPI 패키지를 설치한다는 것은 해당 배포 파일과 빌드 과정을 실행 환경에서 신뢰한다는 의미가 될 수 있다.
환경 재현
재현 가능한 환경을 만들려면 Python 버전과 의존성 버전뿐 아니라 플랫폼과 외부 네이티브 요소도 고려해야 한다.
Python 버전
패키지 lock
운영체제와 CPU
네이티브 공유 라이브러리
환경 변수
외부 서비스
GPU driver와 runtime
같은 requirements.txt를 사용해도 운영체제에 따라 다른 wheel이나 전이 의존성이 선택될 수 있다.
범용 lock file은 여러 환경의 조건을 함께 기록하거나 특정 배포 대상별 lock file을 만들 수 있다.
컨테이너 image와 운영체제 패키지 lock을 함께 사용하면 Python 외부 의존성까지 더 넓게 고정할 수 있다.
업데이트
의존성을 영구히 고정하면 새로운 보안 수정과 호환성 개선을 받지 못할 수 있다. 반대로 매번 최신 버전을 제한 없이 설치하면 예기치 않은 변경이 배포에 들어갈 수 있다.
일반적인 갱신 흐름은 다음과 같다.
의존성 조건 검토
→ lock file 갱신
→ 자동 테스트
→ 변경 기록과 보안 정보 확인
→ staging 배포
→ production 반영
응용 프로그램의 lock file은 정기적으로 갱신하고 테스트할 수 있다.
라이브러리는 지원하는 최소·최대 버전을 실제 테스트 matrix에 포함할 수 있다.
최소 지원 의존성
최신 지원 의존성
여러 Python 버전
자동화 도구가 pull request를 만들도록 하되, 해결 결과와 전이 의존성 변화도 함께 검토할 수 있다.
도구 선택
기본적인 표준 흐름만 필요한 프로젝트는 다음 조합을 사용할 수 있다.
venv
+ pip
+ pyproject.toml
+ build
+ Twine
빠른 통합 프로젝트 관리가 필요하면 uv를 사용할 수 있다.
uv
→ Python 설치
→ 프로젝트 생성
→ 의존성 lock
→ 환경 동기화
→ 도구 실행
→ 빌드와 게시
오랫동안 사용된 독립적인 프로젝트 관리 작업 흐름과 세밀한 dependency group 관리가 필요하면 Poetry나 PDM을 선택할 수 있다.
여러 환경과 작업 행렬, 확장 가능한 build workflow가 중요하면 Hatch를 사용할 수 있다.
requirements 파일과 pip 중심 흐름을 유지하려면 pip-tools를 사용할 수 있다.
Python 명령행 응용 프로그램을 전역적으로 사용하되 의존성을 격리하려면 pipx나 uv tool을 사용할 수 있다.
Python뿐 아니라 C·C++·Fortran·CUDA 등 시스템 수준 패키지를 함께 관리해야 한다면 Conda, Mamba 또는 Pixi를 고려할 수 있다.
도구를 선택할 때는 다음을 확인할 수 있다.
- 표준
pyproject.toml지원 범위 - lock file의 목적과 이식성
- Python 버전 관리 기능
- 가상 환경 관리 방식
- workspace 지원
- 사설 index와 인증
- 네이티브 패키지 지원
- CI와 컨테이너 통합
- 유지보수 상태와 릴리스 주기
- 다른 도구로 이전 가능한 표준 메타데이터 사용 여부
생태계의 변화
Python 패키징은 setup.py와 수동 requirements 파일 중심의 초기 구조에서 표준화된 frontend·backend 분리와 pyproject.toml 중심 구조로 발전했다.
과거
→ `setup.py`
→ backend 직접 실행
→ 도구별 메타데이터
→ 환경 상태를 `pip freeze`로 기록
현재
→ `pyproject.toml`
→ 표준 빌드 interface
→ wheel과 sdist
→ 격리된 빌드 환경
→ 프로젝트 관리자와 lock file
→ Trusted Publishing
uv와 Pixi처럼 Rust로 작성된 신생 도구는 의존성 해결과 설치 성능, Python 설치와 workspace를 통합하는 방향을 보여준다. PEP 751의 pylock.toml은 프로젝트 관리자별로 분리되어 있던 lock file 영역에도 상호 운용 가능한 표준을 도입하려는 시도이다.
반면 Rye처럼 활발히 주목받았지만 후속 프로젝트에 통합되어 개발이 종료된 도구도 있다. 따라서 도구의 현재 유지보수 상태와 이전 경로를 확인해야 한다.
Python의 패키징 생태계는 하나의 패키지 관리자만으로 구성되지 않는다. PyPI는 공개 배포 기반을 제공하고, pip는 표준적인 설치 경로를 제공하며, 여러 build backend와 프로젝트 관리자가 그 위에서 서로 다른 개발 흐름을 구성한다. 사용자는 프로젝트 규모, 배포 대상과 재현성 요구에 따라 표준 도구를 조합하거나 uv·Poetry·PDM·Hatch·Pixi 등의 통합 도구를 선택할 수 있다.
개발 도구
Python 개발 생태계에는 소스 코드 편집과 디버깅을 제공하는 통합 개발 환경, 대화형 실행 환경, 정적 분석기, 테스트 자동화 도구, 성능 분석기와 응용 프로그램 배포 도구가 폭넓게 존재한다. Python 자체에도 IDLE, pdb, profile, cProfile, timeit, trace, venv와 zipapp 등이 포함되지만, 실제 프로젝트에서는 여러 외부 도구를 목적에 따라 조합하는 경우가 많다.
소스 코드 작성
→ IDE 또는 편집기
코드 검사
→ formatter·linter·type checker
동작 검증
→ test runner·다중 환경 자동화
실행 분석
→ debugger·CPU profiler·memory profiler
사용자 배포
→ wheel·zipapp·실행 파일·native application bundle
패키지 설치와 환경 관리는 pip, uv, Poetry, PDM과 Conda 등이 담당한다. 이러한 도구의 구체적인 역할은 앞의 패키징과 생태계에서 다루며, 여기서는 실제 코드를 작성·실행·검사하고 최종 응용 프로그램 형태로 만드는 도구를 중심으로 설명한다.
IDE와 편집기
Python은 특정 IDE를 요구하지 않는다. 단순한 텍스트 편집기에서도 개발할 수 있지만, IDE는 인터프리터 선택, 코드 완성, 리팩터링, 디버깅, 테스트 실행과 가상 환경 관리를 하나의 화면에 통합한다.
PyCharm은 JetBrains가 개발하는 Python 전용 IDE이다. 코드 탐색과 리팩터링, 디버거, 테스트 실행기, 데이터베이스 도구, Jupyter Notebook, 웹 프레임워크와 원격 개발 기능을 통합한다. Django·Flask·FastAPI와 데이터 과학 도구를 IDE 안에서 직접 다룰 수 있으며, SSH 서버·WSL·개발 컨테이너에서 실행되는 프로젝트를 원격으로 열고 검사할 수도 있다.[225]
Visual Studio Code는 범용 편집기이지만 Python Extension과 Pylance, Python Debugger 확장을 설치하면 Python 개발 환경으로 사용할 수 있다. 인터프리터 선택, 코드 완성, 타입 정보, 테스트 검색, Jupyter Notebook과 debugpy 기반 디버깅을 제공한다.[226]
Spyder는 과학 계산과 데이터 분석에 초점을 맞춘 Python IDE이다. 소스 편집기, IPython Console, 변수 탐색기, 그래프와 배열 확인 기능을 한 화면에 제공한다. MATLAB과 비슷하게 코드를 실행하면서 현재 이름 공간의 배열·DataFrame과 변수를 조사하는 작업 흐름에 적합하다.[227]
Thonny는 Python 학습자와 교육 환경을 대상으로 하는 비교적 단순한 IDE이다. Python이 포함된 설치판을 제공하며, 표현식 평가 과정과 함수 호출 단계를 시각적으로 보여 주는 디버거를 갖춘다. MicroPython 장치와 연결하여 micro:bit, Raspberry Pi Pico와 여러 마이크로컨트롤러를 개발할 때도 사용된다.[228]
Python 표준 배포판에 포함되는 IDLE은 Tk 기반의 간단한 편집기와 대화형 Shell, 기본 디버거를 제공한다. 별도 IDE를 설치하지 않고 작은 코드와 학습 예제를 실행하기에 적합하지만 대규모 프로젝트 관리 기능은 제한적이다.
Vim·Neovim·Emacs·Sublime Text·Zed와 여러 범용 편집기도 Language Server Protocol, Debug Adapter Protocol과 외부 formatter를 조합하여 Python IDE와 유사하게 구성할 수 있다.
편집기
├── 언어 서버: Pyright·Pylance·python-lsp-server
├── formatter와 linter: Ruff·Black
├── debugger: debugpy
├── test runner: pytest
└── 환경 선택: venv·uv·Conda
이 방식은 도구를 세밀하게 선택할 수 있지만 각 구성 요소와 단축키, 실행 환경을 사용자가 직접 구성해야 한다.
언어 서버
언어 서버는 편집기와 분리된 프로세스에서 소스 코드를 분석하고 코드 완성, 정의 이동, 참조 검색, 이름 변경과 오류 진단을 제공한다.
Pyright는 Microsoft가 개발한 Python 정적 타입 검사기이자 언어 분석 엔진이다. 대규모 Python 코드베이스를 빠르게 분석하도록 설계되었으며, 명령행 도구와 편집기 통합에 사용된다.[229]
Pylance는 Visual Studio Code의 Python 언어 지원에 사용되는 확장으로 Pyright의 분석 엔진을 기반으로 한다.
python-lsp-server는 Language Server Protocol을 구현하는 독립적인 Python 언어 서버이다. 여러 plugin을 통해 Ruff, Jedi, formatter와 다른 분석 도구를 연결할 수 있으며 특정 편집기에 종속되지 않는다.
Jedi는 Python 소스 코드의 정적 분석과 자동 완성 기능을 제공하는 라이브러리이다. 여러 편집기와 IPython에서 이름 완성, 함수 시그니처와 정의 위치를 찾는 데 사용되어 왔다.
언어 서버가 표시하는 오류는 Python 인터프리터가 실제로 실행한 결과와 동일하지 않을 수 있다. 동적 속성 생성, monkey patching, C 확장 모듈과 runtime metaprogramming은 정적으로 완전히 분석하기 어렵기 때문이다.
대화형 인터프리터
Python의 기본 대화형 인터프리터는 문장을 입력하고 즉시 결과를 확인할 수 있다.
>>> value = 10
>>> value * 2
20
IPython은 기본 대화형 인터프리터를 확장하여 향상된 자동 완성, 객체 검사, 명령 기록, shell 명령 호출, magic command와 rich display 기능을 제공한다.
In [1]: values = [1, 2, 3]
In [2]: values?
실행 시간을 측정할 수 있다.
In [3]: %timeit sum(values)
현재 코드나 파일을 디버거에서 실행할 수도 있다.
In [4]: %run -d application.py
IPython은 독립적인 terminal shell로 사용할 수도 있고 Jupyter kernel의 기반으로도 사용된다.
Jupyter
Jupyter는 코드, Markdown, 수식, 표와 그래프를 셀 단위로 작성하고 실행하는 Notebook 환경을 제공한다.
Notebook 문서
├── 설명 셀
├── Python 코드 셀
├── 실행 결과
├── 표와 그래프
└── 대화형 위젯
JupyterLab은 Notebook, 파일 탐색기, terminal, 텍스트 편집기와 debugger를 브라우저 기반 작업 공간에 통합한다. 데이터 분석, 과학 계산, 머신러닝 실험과 교육 자료 작성에 널리 사용된다.
Notebook은 셀을 문서 순서와 다르게 실행할 수 있으므로 화면에 보이는 소스와 현재 kernel 상태가 일치하지 않을 수 있다.
셀 3 실행
→ 셀 1 수정
→ 셀 2만 다시 실행
→ 화면의 코드와 메모리 상태가 달라짐
결과의 재현성이 중요하면 kernel을 다시 시작하고 모든 셀을 처음부터 순서대로 실행해 확인해야 한다. Notebook을 자동으로 실행·검사하는 nbclient, nbconvert, Papermill과 pytest plugin도 사용할 수 있다.
marimo
marimo는 반응형 실행 모델을 사용하는 비교적 새로운 Python Notebook 프로젝트이다. 셀에서 정의된 이름과 다른 셀의 참조 관계를 분석하고, 값이 변경되면 이에 의존하는 셀을 자동으로 다시 실행하거나 오래된 상태임을 표시한다.[230]
import marimo
app = marimo.App()
@app.cell
def _():
import marimo as mo
return mo,
@app.cell
def _(mo):
count = mo.ui.slider(
start=1,
stop=100,
value=10,
)
return count,
@app.cell
def _(count):
result = count.value ** 2
return result,
marimo Notebook은 일반적인 .ipynb JSON 문서가 아니라 실행 가능한 .py 파일로 저장된다. 따라서 Git diff와 코드 검토가 비교적 쉽고, Notebook을 일반 Python script나 웹 응용 프로그램으로 실행할 수 있다.
marimo edit notebook.py
Notebook을 대화형 응용 프로그램으로 실행할 수 있다.
marimo run notebook.py
marimo는 데이터 분석뿐 아니라 내부 dashboard, 데이터 입력 도구와 작은 웹 응용 프로그램을 Notebook 코드에서 만드는 용도로도 사용될 수 있다. 다만 Jupyter의 방대한 extension 및 kernel 생태계와 완전히 같은 범위를 제공하는 것은 아니다.
테스트 도구
Python 표준 라이브러리에는 unittest가 포함되지만 외부 프로젝트에서는 pytest가 널리 사용된다.
def add(
left: int,
right: int,
) -> int:
return left + right
def test_add() -> None:
assert add(10, 20) == 30
pytest는 일반 assert 문의 값을 분석하여 실패 원인을 표시하고 fixture, parameterization, plugin과 test discovery 기능을 제공한다.
import pytest
@pytest.mark.parametrize(
("left", "right", "expected"),
[
(1, 2, 3),
(10, 20, 30),
(-1, 1, 0),
],
)
def test_add(
left,
right,
expected,
):
assert add(left, right) == expected
Hypothesis는 property-based testing 도구이다. 개발자가 구체적인 입력 예시만 나열하는 대신 입력의 범위와 성질을 선언하면 다양한 값을 자동으로 생성하고 실패를 일으키는 최소 사례를 찾는다.
from hypothesis import given
from hypothesis import strategies as st
@given(
st.integers(),
st.integers(),
)
def test_add_is_commutative(
left,
right,
):
assert (
left + right
== right + left
)
tox와 Nox는 여러 Python 버전과 의존성 조합에서 테스트·lint·문서 빌드 명령을 반복 실행한다. Nox는 설정을 noxfile.py라는 일반 Python 코드로 작성한다.[231]
import nox
@nox.session(
python=[
"3.12",
"3.13",
"3.14",
]
)
def tests(session):
session.install(
".",
"pytest",
)
session.run(
"pytest",
)
라이브러리가 여러 Python 버전을 지원한다면 개발자의 현재 환경에서 한 번 테스트하는 것만으로는 충분하지 않다. tox·Nox와 CI를 결합하여 지원 행렬 전체를 검사할 수 있다.
코드 형식과 정적 검사
Black은 Python 코드를 일관된 형식으로 변환하는 opinionated formatter이다. 코드 스타일을 세밀하게 협상하기보다 하나의 형식에 맞추어 팀 내 차이를 줄이는 데 초점을 둔다.
Ruff는 Rust로 작성된 빠른 Python linter와 formatter이다. 여러 Flake8 plugin, import 정렬, 코드 현대화와 일부 보안 검사 기능을 하나의 실행 파일에 통합하며 ruff format으로 코드 형식도 정리할 수 있다.[232]
ruff check .
ruff format .
자동으로 수정 가능한 문제를 고칠 수 있다.
ruff check \
--fix \
.
프로젝트 설정은 pyproject.toml에 기록할 수 있다.
[tool.ruff]
target-version = "py314"
line-length = 88
[tool.ruff.lint]
select = [
"E",
"F",
"I",
"UP",
"B",
]
mypy와 Pyright는 타입 주석을 기반으로 실행 전에 타입 오류를 검사한다.
def greet(
name: str,
) -> str:
return "Hello " + name
greet(10)
Python 인터프리터는 함수가 실행될 때까지 이 호출을 허용하지만 정적 타입 검사기는 int가 str 매개변수에 전달되었다는 오류를 보고할 수 있다.
타입 검사기는 서로 다른 추론 방식과 엄격성 옵션을 가지므로 모든 도구가 항상 같은 결과를 내는 것은 아니다. 하나의 프로젝트에서는 공식적으로 지원할 검사기와 설정을 정하는 것이 좋다.
새롭게 등장한 타입 검사기
Python 타입 검사 생태계에는 mypy와 Pyright 외에도 여러 실험적·신생 프로젝트가 등장하고 있다.
BasedPyright는 Pyright의 fork로 더 엄격한 기본 설정과 추가 진단을 제공한다. Pyright 설정 및 언어 서버 생태계와 상당 부분 호환된다.
Astral의 ty는 Rust로 작성되는 고성능 Python 타입 검사기 및 언어 분석 프로젝트이다. Ruff와 uv를 개발하는 계열의 도구로, 빠른 분석과 편집기 통합을 목표로 개발되고 있다. 신생 도구이므로 지원하는 타입 체계의 범위와 진단 결과는 버전에 따라 빠르게 변할 수 있다.
Meta가 공개한 Pyrefly도 대규모 Python 코드베이스를 위한 빠른 타입 검사기와 언어 서버를 목표로 한다. 이러한 도구는 Python의 표준 타입 사양을 따르려 하지만 mypy·Pyright와 완전하게 같은 결과를 보장하지는 않는다.
새로운 타입 검사기는 성능과 새로운 추론 방식을 제공할 수 있지만, 기존 plugin과 framework 지원, 오류 안정성 및 편집기 통합을 확인한 뒤 도입하는 것이 좋다.
Git hook 자동화
pre-commit은 Git commit 전에 실행할 formatter·linter·파일 검사를 프로젝트 설정으로 관리하는 framework이다. 이름과 달리 commit 전 hook뿐 아니라 여러 Git hook 단계를 지원하며 Python 이외 언어의 도구도 격리하여 실행할 수 있다.[233]
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.0
hooks:
- id: ruff-check
args:
- --fix
- id: ruff-format
hook을 설치한다.
pre-commit install
저장소 전체에 실행할 수 있다.
pre-commit run \
--all-files
pre-commit은 개발자가 commit하기 전에 일반적인 오류를 발견하지만, 사용자가 hook을 건너뛸 수도 있으므로 CI에서도 같은 검사를 실행해야 한다.
디버거
표준 라이브러리의 pdb는 명령행 기반 Python 디버거이다. 중단점, 한 줄 실행, 함수 진입, 호출 스택과 변수 검사를 제공한다.[234]
코드에서 직접 중단할 수 있다.
def calculate(value):
breakpoint()
return value * 2
breakpoint()는 기본적으로 pdb를 실행하지만 환경 설정을 통해 다른 debugger hook으로 변경할 수 있다.
명령행에서 script를 디버거로 실행할 수 있다.
python -m pdb \
application.py
Python 3.14의 pdb는 실행 중인 Python 프로세스에 PID를 이용하여 연결하는 기능도 제공한다.
python -m pdb \
-p 12345
debugpy는 Debug Adapter Protocol을 사용하는 Python debugger 구현체이다. Visual Studio Code와 여러 IDE·편집기에서 breakpoints, step execution, variable inspection과 원격 debugging을 제공하는 기반으로 사용된다.[235]
프로그램이 debugger 연결을 기다리게 할 수 있다.
import debugpy
debugpy.listen(
("0.0.0.0", 5678)
)
debugpy.wait_for_client()
container, 원격 서버와 embedded Python 환경을 IDE에서 디버깅할 때 사용할 수 있다. 외부 네트워크에 debugging port를 그대로 공개하면 임의 코드 실행 위험이 생길 수 있으므로 접근을 제한해야 한다.
실행 흐름 관찰
watchfiles는 파일 시스템 변경을 감시하는 Python 및 Rust 기반 도구이다. 소스 파일이 변경될 때 개발 서버나 명령을 자동으로 다시 실행하는 데 사용할 수 있다.
watchfiles \
"python -m application" \
src
여러 웹 framework의 개발 server는 비슷한 자동 reload 기능을 내장한다.
IceCream은 값과 표현식 이름, 호출 위치를 함께 출력하는 debugging 보조 library이다.
from icecream import ic
result = calculate(value)
ic(result)
일반 print()보다 어느 표현식에서 나온 값인지 쉽게 구분할 수 있지만 production code에서는 logging framework와 debugger를 사용하는 편이 적합하다.
Rich의 traceback handler는 지역 변수와 색상화된 source context를 포함한 traceback을 출력할 수 있다.
from rich.traceback import install
install(
show_locals=True
)
이러한 도구는 오류를 보기 쉽게 만들지만 예외 처리와 logging 정책 자체를 대신하지는 않는다.
CPU 성능 분석
Python 표준 라이브러리에는 timeit, profile과 cProfile이 포함된다.
python -m cProfile \
application.py
cProfile은 함수 호출 단위의 실행 시간과 호출 횟수를 기록한다. 결과는 pstats나 그래프 도구로 분석할 수 있다.
py-spy는 Rust로 작성된 sampling profiler이다. 대상 Python 프로그램의 소스 코드를 수정하지 않고 실행 중인 프로세스에 연결하여 stack sample을 수집할 수 있다.[236]
실행 중인 프로세스의 현재 stack을 볼 수 있다.
py-spy dump \
--pid 12345
실시간으로 함수별 시간을 표시할 수 있다.
py-spy top \
--pid 12345
flame graph를 기록할 수 있다.
py-spy record \
-o profile.svg \
-- \
python application.py
py-spy는 production에서 실행 중인 프로세스의 CPU 병목을 비교적 낮은 간섭으로 조사할 때 유용하다. 운영체제의 다른 프로세스 메모리를 읽기 위한 권한이 필요할 수 있다.
Scalene은 Python code, native code와 system 호출에 사용된 시간을 구분하고 CPU·memory·GPU 사용을 line 단위로 분석하는 profiler이다. Python과 NumPy 같은 native library 사이의 data copy 비용도 분석할 수 있다.
python -m scalene \
application.py
순수 Python code가 느린지, 호출하는 C·C++ library가 시간을 사용하는지 구분하고 싶을 때 유용하다.
Austin은 Python 실행을 sampling하는 또 다른 profiler로, 별도 process에서 실행 중인 Python 프로그램을 조사하고 여러 visualization frontend와 연결할 수 있다.
메모리 분석
tracemalloc은 Python 표준 라이브러리에 포함된 memory allocation 추적 도구이다. Python allocator를 통해 만들어진 객체의 allocation 위치와 snapshot 차이를 조사할 수 있다.
import tracemalloc
tracemalloc.start()
run_application()
snapshot = (
tracemalloc.take_snapshot()
)
Memray는 Bloomberg가 공개한 Python memory profiler이다. Python code뿐 아니라 선택적으로 native extension의 allocation도 추적하고 flame graph, table, tree와 live terminal view를 생성할 수 있다.[237]
프로그램을 추적한다.
memray run \
application.py
결과에서 flame graph를 만든다.
memray flamegraph \
memray-output.bin
실행 중 memory 사용을 대화형으로 확인할 수도 있다.
memray run \
--live \
application.py
Memray는 memory leak, peak memory 사용과 반복적인 대형 allocation의 원인을 찾는 데 유용하다. allocation을 세밀하게 기록하므로 일반 실행보다 overhead와 결과 파일 크기가 커질 수 있다.
objgraph는 Python 객체의 수와 참조 관계를 조사하는 도구이다. 특정 클래스의 인스턴스가 계속 증가하거나 객체가 예상하지 못한 참조 때문에 해제되지 않을 때 사용할 수 있다.
Pympler는 객체 크기, heap summary와 instance tracking을 제공한다.
CLI 개발 보조 도구
Python의 argparse는 표준적인 command-line argument parser이지만, 외부 생태계에는 더 선언적인 CLI framework가 존재한다.
Click은 decorator를 이용해 command와 option을 정의한다.
import click
@click.command()
@click.option(
"--count",
default=1,
)
@click.argument("name")
def hello(
count,
name,
):
for _ in range(count):
click.echo(
f"Hello {name}"
)
Typer는 Python 타입 주석을 이용하여 Click 기반 CLI를 생성한다.[238]
import typer
app = typer.Typer()
@app.command()
def greet(
name: str,
count: int = 1,
) -> None:
for _ in range(count):
print(
f"Hello {name}"
)
if __name__ == "__main__":
app()
타입 주석에서 인자 형식과 도움말의 기본 구조를 만들며 shell completion도 제공할 수 있다.
Cyclopts와 Tyro도 함수 시그니처와 dataclass, 타입 주석을 바탕으로 CLI를 생성하는 비교적 새로운 도구이다. 복잡한 configuration 객체를 command-line argument로 연결할 때 사용될 수 있다.
terminal 출력과 TUI
Rich는 terminal에 색상, table, progress bar, syntax highlighting, Markdown과 tree를 출력하는 library이다.
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(
"Name",
"Status",
)
table.add_row(
"server",
"running",
)
console.print(table)
단순한 ANSI escape sequence를 직접 작성하지 않고 운영체제와 terminal 기능에 맞추어 표현할 수 있다.
Textual은 Rich 계열에서 개발된 terminal user interface framework이다. widget, layout, event, reactive state와 CSS 형태의 style을 이용하여 terminal 안에서 GUI와 유사한 응용 프로그램을 만들 수 있으며, 일부 실행 방식에서는 web browser에서도 표시할 수 있다.[239]
from textual.app import App
from textual.widgets import (
Button,
Footer,
Header,
)
class ExampleApp(App):
def compose(self):
yield Header()
yield Button(
"Run",
id="run",
)
yield Footer()
ExampleApp().run()
Textual은 다음과 같은 응용 프로그램에 사용할 수 있다.
- server 관리 dashboard
- database browser
- log viewer
- build 도구 frontend
- SSH 환경에서 실행하는 관리 프로그램
- terminal 기반 editor와 monitor
운영체제 GUI toolkit 없이 terminal에서 풍부한 interface를 제공할 수 있지만, 일반 desktop GUI의 accessibility·system tray·native window 기능이 필요한 경우에는 Qt·Tk·GTK 등의 toolkit이 적합하다.
Prompt Toolkit은 대화형 command-line application을 위한 입력 편집, completion, key binding과 terminal layout 기능을 제공한다. IPython과 여러 REPL·database shell의 기반으로도 사용된다.
GUI framework
Python 표준 라이브러리의 tkinter는 Tk를 이용해 간단한 desktop GUI를 만들 수 있다.
import tkinter as tk
window = tk.Tk()
window.title("Application")
button = tk.Button(
window,
text="Run",
)
button.pack()
window.mainloop()
PySide와 PyQt는 Qt framework의 Python binding이다. widget 기반 desktop application, Qt Quick, network, multimedia와 여러 platform 기능을 이용할 수 있다. PySide는 Qt Company가 제공하는 공식 Python binding이며 LGPL과 commercial license 체계로 제공된다.
wxPython은 wxWidgets를 Python에서 사용하며 각 운영체제의 native widget을 중심으로 GUI를 구성한다.
Kivy는 touch interface와 graphics에 초점을 맞춘 cross-platform application framework이다. desktop뿐 아니라 Android와 iOS application을 대상으로 사용할 수 있다.
Toga는 BeeWare 프로젝트의 GUI toolkit이다. 하나의 Python API를 각 platform의 native widget에 연결하며 Briefcase와 함께 desktop·mobile application을 개발하는 흐름을 제공한다.
Flet은 Python code에서 Flutter 기반 UI를 구성하고 desktop·web·mobile 대상으로 실행하는 framework이다. Python process와 Flutter frontend가 통신하는 구조를 사용한다.
NiceGUI, Streamlit, Gradio와 Panel은 web frontend code를 직접 많이 작성하지 않고 Python에서 web UI와 dashboard를 구성할 수 있게 한다.
desktop native UI
→ PySide·PyQt·wxPython·Toga
graphics·touch 중심
→ Kivy
terminal UI
→ Textual
data dashboard
→ Streamlit·Panel
간단한 ML demo
→ Gradio
Python 중심 web application UI
→ NiceGUI·Flet
이들은 일반적인 범용 web backend framework와 목적이 다르며 특정 종류의 응용 프로그램을 빠르게 만드는 고수준 도구이다.
문서 생성
Sphinx는 Python project의 API와 설명 문서를 만드는 전통적인 문서 생성기이다. reStructuredText와 Markdown을 처리하고 autodoc을 통해 module·class·function의 docstring에서 API 문서를 만들 수 있다.
def resize(
image: Image,
width: int,
height: int,
) -> Image:
"""이미지를 지정한 크기로 변경한다."""
MkDocs는 Markdown 파일을 기반으로 static documentation site를 만든다. Material for MkDocs theme과 함께 사용되는 경우가 많다.
MkDocstrings는 Python 객체의 docstring과 type signature를 MkDocs 문서에 포함한다.
Pdoc은 비교적 적은 설정으로 Python package의 API 문서를 생성한다.
Jupyter Book과 MyST는 Notebook, Markdown과 실행 가능한 code를 결합하여 기술 서적·강의 자료와 계산 문서를 만드는 데 사용된다.
문서의 code example이 실제로 동작하는지 검사하려면 doctest, pytest doctest, Sphinx doctest와 Notebook 실행 검사를 사용할 수 있다.
개발 서버와 작업 실행기
Honcho는 Procfile 형식으로 여러 process를 함께 실행한다.
web: python -m application.web
worker: python -m application.worker
scheduler: python -m application.scheduler
honcho start
웹 server, background worker와 scheduler를 local development에서 함께 실행할 때 사용할 수 있다.
Invoke는 Python 함수로 build·release·administration task를 정의한다.
from invoke import task
@task
def test(context):
context.run(
"pytest"
)
invoke test
Fabric은 Invoke의 task model에 SSH 기반 remote execution 기능을 결합한다. 여러 server에 command를 실행하고 application을 배포하는 자동화에 사용될 수 있다.
Taskipy, Poe the Poet와 PDM·Hatch·uv의 script 기능은 pyproject.toml에 project command를 정의한다.
[tool.poe.tasks]
test = "pytest"
lint = "ruff check ."
format = "ruff format ."
make, Just, Task와 같은 언어 독립적인 task runner를 Python project에 사용하는 것도 가능하다.
실행 파일 배포의 의미
Python program을 실행 파일로 만든다는 표현은 여러 서로 다른 방식을 포함한다.
Python source와 dependency를 묶음
→ PyInstaller·cx_Freeze
Python program을 compile하고 묶음
→ Nuitka
Python interpreter를 application에 내장
→ PyOxidizer
운영체제별 native application project와 installer 생성
→ Briefcase
Python이 설치된 환경에서 실행할 단일 archive 생성
→ zipapp·Shiv·PEX
모든 도구가 Python code를 일반적인 C·C++ program과 같은 완전한 native machine code로 바꾸는 것은 아니다. 일부는 Python interpreter와 bytecode, library와 data file을 하나의 directory나 executable archive에 묶는다.
실행 파일이 만들어져도 일반적으로 build한 운영체제와 CPU architecture에 종속된다. Windows에서 만든 .exe를 그대로 Linux에서 실행할 수 있는 것은 아니며, 각 대상 platform에서 별도 build와 test가 필요하다.
zipapp
Python 표준 라이브러리의 zipapp은 __main__.py가 포함된 Python source directory를 실행 가능한 ZIP archive로 만든다.
application/
├── __main__.py
├── cli.py
└── services.py
archive를 생성한다.
python -m zipapp \
application \
-o application.pyz
실행한다.
python application.pyz
shebang을 넣으면 POSIX 환경에서 직접 실행할 수도 있다.
python -m zipapp \
application \
-p "/usr/bin/env python3" \
-o application
zipapp은 Python interpreter를 archive 안에 포함하지 않는다. 대상 시스템에 호환되는 Python과 필요한 외부 library가 설치되어 있어야 한다. 또한 native extension module은 일반적으로 ZIP 내부에서 직접 load할 수 없으므로 별도 배치가 필요하다.
Shiv와 PEX
Shiv는 application code와 Python dependency를 하나의 실행 가능한 zipapp에 포함한다. PEP 441의 zipapp 구조에 bootstrap runtime을 추가하고 실행할 때 dependency를 cache directory에 풀어 사용한다.[240]
console script를 묶을 수 있다.
shiv \
-c application \
-o application.pyz \
.
Shiv archive에는 dependency가 포함되지만 Python interpreter 자체는 포함되지 않는다. 대상 환경에 호환되는 Python interpreter가 필요하다.
PEX도 Python EXecutable archive를 만드는 도구이다. dependency resolution과 interpreter constraint, 여러 platform 대상 build 및 repository 방식의 실행 환경을 제공한다. 대규모 Python service와 command-line application을 하나의 artifact로 배포하는 데 사용되어 왔다.
Shiv·PEX artifact
├── Python code
├── Python dependency
├── bootstrap
└── metadata
대상 system
└── 호환 Python interpreter 필요
이 방식은 container image보다 작고 간단한 단일 artifact를 만들 수 있지만, Python interpreter와 native library 호환성은 배포 환경에 남는다.
PyInstaller
PyInstaller는 Python script와 interpreter, import된 module, native library와 data file을 분석하여 독립 실행형 bundle을 만든다. 사용자는 대상 시스템에 Python을 별도로 설치하지 않고 application을 실행할 수 있다.[241]
하나의 directory bundle을 만들 수 있다.
pyinstaller \
application.py
하나의 실행 파일 형태로 만들 수 있다.
pyinstaller \
--onefile \
application.py
GUI application에서 console window를 숨길 수 있다.
pyinstaller \
--onefile \
--windowed \
application.py
PyInstaller는 import를 정적으로 분석하지만 실행 중 문자열로 module을 가져오거나 plugin을 검색하는 동적 import를 완전히 찾지 못할 수 있다.
module = importlib.import_module(
plugin_name
)
이 경우 hidden import와 hook을 설정해야 할 수 있다.
pyinstaller \
--hidden-import \
application.plugins.json \
application.py
복잡한 설정은 .spec 파일에 기록한다.
analysis = Analysis(
["application.py"],
datas=[
(
"assets",
"assets",
),
],
hiddenimports=[
"application.plugins.json",
],
)
PyInstaller는 cross-compiler가 아니다. Windows용 application은 일반적으로 Windows에서, macOS용 bundle은 macOS에서 별도로 만든다.[242]
--onefile bundle은 실행 시 내부 file을 임시 directory에 풀기 때문에 one-directory 방식보다 시작이 느릴 수 있다. 또한 bundle에 포함된 .pyc는 원본 source와 동일하지 않지만 역분석이 불가능한 보안 형식은 아니다.
cx_Freeze
cx_Freeze도 Python program과 interpreter 및 dependency를 독립 실행형 application으로 묶는다. setuptools·pyproject.toml 기반 build와 Windows MSI, macOS application bundle 등의 target을 지원한다.
from cx_Freeze import (
Executable,
setup,
)
setup(
name="Application",
executables=[
Executable(
"application.py"
),
],
)
PyInstaller와 목적이 비슷하지만 build 설정 방식, package hook과 platform 지원 세부가 다르다. 특정 GUI framework나 dependency가 어느 도구에서 더 안정적으로 처리되는지 실제 application으로 검사하여 선택할 수 있다.
Nuitka
Nuitka는 Python program을 C 수준의 program으로 변환한 뒤 C compiler를 이용하여 binary를 만드는 Python compiler이다. 일반 실행 가속용 extension module, standalone directory와 onefile executable 형태를 지원한다.[243]
일반 program을 compile한다.
python -m nuitka \
application.py
독립적인 application directory를 만든다.
python -m nuitka \
--standalone \
application.py
단일 파일 형태를 만들 수 있다.
python -m nuitka \
--standalone \
--onefile \
application.py
Nuitka는 단순히 source file을 archive에 넣는 것과 달리 Python code를 generated C와 runtime 구조로 compile한다. 그러나 Python의 동적 객체 모델과 CPython runtime 의미를 유지하므로 임의의 Python program이 정적인 C program처럼 바뀐다고 이해해서는 안 된다.
Nuitka가 항상 program을 크게 빠르게 만드는 것도 아니다. 객체 생성, dictionary 조회와 외부 Python library 호출이 주된 program은 향상 폭이 작을 수 있으며, 시작 시간과 binary 크기가 늘어날 수 있다.
plugin을 사용해 Qt, Tk, multiprocessing와 여러 package의 data 및 native library를 처리한다.
python -m nuitka \
--standalone \
--onefile \
--enable-plugin=pyside6 \
application.py
Nuitka 역시 target operating system과 architecture별로 build해야 한다.
Briefcase
Briefcase는 BeeWare 프로젝트의 application packaging 도구이다. Python project를 단순한 실행 파일 하나로 묶기보다 각 platform의 일반적인 native application project와 설치 형식으로 변환한다.[244]
지원하는 출력에는 다음이 포함된다.
macOS
→ `.app` application bundle
Windows
→ MSI installer
Linux
→ native package
iOS
→ Xcode project
Android
→ Gradle project
Web
→ PyScript 기반 static application
project를 생성하고 개발 mode로 실행할 수 있다.
briefcase new
briefcase dev
target application을 만든다.
briefcase create
briefcase build
briefcase run
배포 가능한 package를 생성한다.
briefcase package
Briefcase는 operating system의 정상적인 application metadata, icon, installer와 code signing 흐름을 활용한다. desktop뿐 아니라 mobile application을 대상으로 한다는 점에서 PyInstaller와 범위가 다르다.
mobile platform에서는 일반 desktop용 wheel을 그대로 사용할 수 없을 수 있다. dependency가 iOS나 Android용 native wheel을 제공하지 않으면 해당 package를 포함하기 어렵다.
PyOxidizer
PyOxidizer는 Rust로 작성된 Python application packaging 및 embedding 도구이다. Python interpreter와 standard library, application module과 resource를 하나의 executable에 포함할 수 있다.[245]
일반적인 bundle 도구는 module을 file system에 배치하지만 PyOxidizer는 Python resource를 executable 내부 memory에서 가져오도록 구성할 수 있다.
단일 executable
├── Python interpreter
├── standard library
├── application module
├── Python dependency
└── data resource
이 방식은 self-contained executable과 빠른 import 시작을 제공할 수 있다.[246]
PyOxidizer는 세밀한 embedding과 memory resource 정책을 제공하지만 설정과 native dependency 처리 난도가 높다. 일부 Python package는 module이 실제 file system에 존재한다고 가정하거나 dynamic library를 일반 경로에서 찾기 때문에 별도 조정이 필요할 수 있다.
일반적인 desktop application을 빠르게 묶는 목적에는 PyInstaller가 더 단순할 수 있고, Rust host program에 Python을 깊게 내장하거나 단일 binary 실행 환경을 세밀하게 제어하려는 경우 PyOxidizer를 검토할 수 있다.
PyApp
PyApp은 Rust로 작성된 작은 application bootstrapper이다. 이미 PyPI나 package index에 게시된 Python command-line application을 지정하면 필요한 Python distribution과 package를 준비하고 실행하는 wrapper binary를 만들 수 있다.
작은 launcher executable
→ 지정된 Python version 확보
→ Python package 설치
→ console entry point 실행
PyInstaller처럼 application source와 모든 dependency를 build 시점에 하나의 대형 실행 파일로 묶는 방식과 다르다. Python package를 일반적인 package 생태계에 유지하면서 사용자에게 별도 launcher를 제공하려는 경우 사용할 수 있다.
실행 파일 도구 선택
일반적인 desktop·CLI application을 빠르게 묶으려면 PyInstaller나 cx_Freeze가 적합할 수 있다.
목표
→ 사용자가 Python을 설치하지 않아도 실행
주요 선택
→ PyInstaller·cx_Freeze
Python code의 compile과 일부 실행 최적화를 함께 원하면 Nuitka를 검토할 수 있다.
목표
→ compile된 program
→ standalone 또는 onefile
주요 선택
→ Nuitka
운영체제별 installer와 mobile application project까지 만들려면 Briefcase가 적합할 수 있다.
목표
→ native application packaging
→ desktop·mobile·web target
주요 선택
→ Briefcase
Python interpreter가 이미 있는 server나 내부 배포 환경에서 하나의 application artifact만 필요하면 zipapp, Shiv나 PEX를 사용할 수 있다.
목표
→ Python code와 dependency를 단일 archive로 배포
주요 선택
→ zipapp·Shiv·PEX
Python interpreter와 resource를 executable 안에 직접 내장하고 세밀하게 제어하려면 PyOxidizer를 검토할 수 있다.
어떤 도구를 선택하든 다음 항목을 실제 target system에서 검사해야 한다.
- dynamic import
- plugin discovery
- data file
- certificate와 timezone database
- native extension
- multiprocessing
- child process
- working directory
- icon과 application metadata
- code signing
- antivirus 오진
- startup time
- update 방식
- crash report와 logging 경로
코드 보호와 실행 파일
Python application을 실행 파일로 만든다고 source code가 완전히 보호되는 것은 아니다.
PyInstaller는 일반적으로 Python bytecode와 interpreter를 bundle한다. Nuitka는 generated C와 native binary를 만들지만 Python object와 module 구조의 흔적이 남을 수 있다. archive와 executable에서 문자열, resource와 embedded module을 추출하는 것도 가능할 수 있다.
실행 파일 제작
≠ source code의 완전한 비공개
≠ copy 방지
≠ 안전한 secret 저장소
API key와 encryption key를 실행 파일 안에 고정하면 사용자가 이를 추출할 수 있다. 중요한 credential은 server 측에 보관하거나 operating system credential storage를 사용해야 한다.
실행 파일 제작은 배포 편의를 위한 것이며 강력한 code protection mechanism으로 간주해서는 안 된다.
개발 환경의 조합
작은 Python library는 다음과 같은 단순한 구성을 사용할 수 있다.
편집기
→ PyCharm 또는 VS Code
환경
→ venv 또는 uv
검사
→ Ruff + Pyright
테스트
→ pytest
다중 버전
→ Nox
문서
→ MkDocs 또는 Sphinx
배포
→ wheel + PyPI
data 분석 project는 다음과 같은 구성을 사용할 수 있다.
대화형 환경
→ JupyterLab 또는 marimo
IDE
→ Spyder·PyCharm·VS Code
환경
→ Conda·Pixi·uv
검사
→ Ruff + type checker
profiling
→ Scalene·Memray
desktop application은 다음과 같이 구성할 수 있다.
GUI
→ PySide·Toga·Kivy
테스트
→ pytest
실행 파일
→ PyInstaller·Nuitka
native installer
→ Briefcase
배포 자동화
→ CI의 platform별 build
terminal application은 다음과 같은 조합을 사용할 수 있다.
CLI
→ Typer·Click
출력
→ Rich
TUI
→ Textual
단일 archive
→ Shiv·PEX
독립 실행 파일
→ PyInstaller·Nuitka
모든 도구를 한 프로젝트에 넣을 필요는 없다. 프로젝트 규모와 배포 대상에 맞지 않는 도구를 과도하게 추가하면 설정 파일, plugin과 CI 실행 시간이 늘어나고 실제 개발보다 도구 유지에 더 많은 비용이 들 수 있다.
Python 개발 도구 생태계의 특징은 하나의 공식 toolchain이 전체 과정을 강제하지 않는다는 점이다. IDE와 편집기, 정적 분석기, test runner, profiler와 application bundler를 독립적으로 선택할 수 있으며, 각각은 Python의 module·type annotation·debug protocol과 packaging 표준을 통해 연결된다. 이러한 유연성은 다양한 개발 환경을 가능하게 하지만 프로젝트가 공식적으로 사용하는 도구와 설정을 명확히 기록해야 재현 가능한 개발 흐름을 유지할 수 있다.
주요 생태계
Python은 하나의 특정 분야에 특화된 언어라기보다, 여러 분야에서 독립적으로 성장한 라이브러리와 프레임워크 생태계를 연결하는 공통 언어로 사용된다. 웹 서비스, 데이터 분석, 과학 계산, 인공지능, 자동화, 교육과 임베디드 시스템 등은 서로 다른 실행 환경과 요구 사항을 가지지만 Python의 패키지 체계와 객체 모델, 네이티브 언어 연동 기능을 공유한다.[247]
Python
├── 웹과 API
├── 데이터 분석과 시각화
├── 과학·공학 계산
├── 머신러닝과 인공지능
├── 데이터 처리와 workflow
├── 자동화와 운영
├── 네트워크와 수집
├── desktop·terminal 응용 프로그램
├── 미디어와 제작 도구
├── 교육
└── 임베디드 시스템
각 분야는 하나의 대표 프레임워크만으로 구성되지 않는다. 핵심 라이브러리, 실행 서버, 데이터 형식, 개발 도구와 배포 환경이 결합되어 하나의 생태계를 형성한다.
웹 개발과 API
Python은 서버 측 웹 애플리케이션, REST API, 비동기 네트워크 서비스와 관리 도구를 개발하는 데 널리 사용된다.
Django는 데이터베이스 기반 웹 애플리케이션에 필요한 객체 관계 매핑, URL 라우팅, 템플릿, 폼, 인증과 관리자 인터페이스 등을 함께 제공하는 종합적인 웹 프레임워크이다.[248]
Django
→ 데이터 모델
→ ORM
→ 관리자 화면
→ 인증과 세션
→ template 기반 웹 application
Flask는 작은 핵심 위에 필요한 extension을 선택해 조합하는 비교적 가벼운 프레임워크이다. Bottle과 Pyramid도 작은 서비스나 구성 선택권이 필요한 웹 애플리케이션에서 사용된다.
FastAPI는 Python 타입 주석과 Pydantic을 이용해 요청·응답 데이터를 검증하고 OpenAPI 및 JSON Schema 기반 API 문서를 자동으로 생성한다. 비동기 함수와 ASGI를 지원하여 HTTP API와 마이크로서비스 개발에 사용된다.[249]
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def read_user(
user_id: int,
):
return {
"user_id": user_id,
}
Starlette는 ASGI 기반 웹 서비스의 저수준 기능을 제공하며 FastAPI의 기반 구성 요소로도 사용된다. Litestar, Sanic, Tornado와 aiohttp 등도 비동기 웹 및 네트워크 애플리케이션을 제공한다.
운영 환경에서는 Uvicorn·Hypercorn 같은 ASGI 서버, Gunicorn 등의 프로세스 관리자, Nginx와 데이터베이스·캐시·작업 Queue가 함께 사용될 수 있다.
데이터 분석
Python의 데이터 분석 생태계는 표 형태 데이터의 정리, 변환, 통계 분석, 시각화와 보고서 작성을 중심으로 발전했다.
NumPy는 다차원 배열과 벡터화된 수치 연산을 제공하며 데이터 분석과 과학 계산 생태계의 공통 기반으로 사용된다.
pandas는 Series와 DataFrame을 중심으로 CSV, Excel, SQL과 여러 표 형식의 데이터를 읽고 정리·결합·집계하는 기능을 제공한다. 실무 데이터의 정제와 분석을 위한 고수준 구성 요소를 목표로 한다.[250]
import pandas as pd
data = pd.read_csv(
"sales.csv"
)
summary = (
data.groupby("category")
["amount"]
.sum()
)
Matplotlib은 Python의 기본적인 2차원 시각화 기반을 제공한다. Seaborn은 통계 그래프를 고수준 API로 구성하고, Plotly·Bokeh·Altair는 대화형 또는 선언적 시각화를 제공한다.
Polars는 Rust 기반의 열 지향 DataFrame 라이브러리이며, DuckDB는 Python 환경에서 분석용 SQL 데이터베이스로 활용된다. Apache Arrow와 PyArrow는 서로 다른 데이터 시스템 사이에서 열 지향 메모리 형식과 파일 형식을 공유하는 기반을 제공한다.
Jupyter와 IPython은 데이터 탐색, 그래프 작성과 분석 기록을 대화형으로 수행하는 환경으로 함께 사용된다.
과학·공학 계산
과학 계산 생태계는 수치 해석, 통계, 최적화, 신호 처리, 영상 처리와 분야별 연구 도구를 포함한다.
SciPy 생태계는 Python, NumPy와 SciPy를 중심으로 수학·과학·공학 계산을 위한 여러 공개 소프트웨어가 결합된 체계이다. SciPy 라이브러리는 최적화, 적분, 선형대수, 신호 처리, 통계와 여러 수치 알고리즘을 제공한다.[251]
NumPy
→ 배열과 기본 수치 연산
SciPy
→ 수치 알고리즘
Matplotlib
→ 결과 시각화
pandas
→ 관측·실험 데이터 처리
Jupyter
→ 대화형 연구 환경
SymPy는 기호 수학을 제공하여 대수식 전개, 미분, 적분과 방정식 풀이를 수행한다. statsmodels는 통계 모델과 검정 기능을 제공하고, scikit-image는 영상 처리, NetworkX는 그래프와 네트워크 분석에 사용된다.
천문학의 Astropy, 생물정보학의 Biopython, 지리정보의 GeoPandas·Rasterio·Shapely와 기후·해양 데이터의 xarray처럼 분야별 패키지도 독립적인 생태계를 형성한다.
고성능 계산이 필요한 핵심 부분은 C, C++, Fortran, Rust와 GPU 코드로 구현하고 Python을 데이터 준비와 실행 제어, 결과 분석 인터페이스로 사용하는 경우가 많다.
머신러닝과 인공지능
Python은 데이터 준비부터 모델 학습, 평가, 배포까지 연결되는 머신러닝 생태계의 주요 언어로 사용된다.
scikit-learn은 분류, 회귀, 군집화, 차원 축소, 전처리, 모델 선택과 평가 등 전통적인 머신러닝 알고리즘을 일관된 estimator 인터페이스로 제공한다.[252]
from sklearn.ensemble import (
RandomForestClassifier,
)
model = RandomForestClassifier()
model.fit(
training_data,
training_labels,
)
predictions = model.predict(
test_data
)
PyTorch는 CPU·GPU tensor 연산, 자동 미분과 신경망 구성 기능을 제공하는 딥러닝 프레임워크이다. 연구용 모델 실험부터 분산 학습과 배포까지 지원하는 라이브러리와 도구 생태계를 가진다.[253]
TensorFlow와 Keras도 신경망 모델의 학습 및 배포에 사용된다. JAX는 NumPy와 유사한 배열 API에 자동 미분, 벡터화와 accelerator compilation을 결합한다.
Hugging Face 생태계의 Transformers·Datasets·Tokenizers는 자연어 처리, 음성, 영상과 생성형 인공지능 모델을 불러오고 학습·추론하는 데 사용된다.
데이터 준비
→ NumPy·pandas·Polars
전통적 머신러닝
→ scikit-learn·XGBoost·LightGBM
딥러닝
→ PyTorch·TensorFlow·JAX
사전 학습 모델
→ Transformers
실험 추적
→ MLflow·Weights & Biases
모델 제공
→ FastAPI·BentoML·Ray Serve
Python이 모델 계산 전체를 직접 수행하는 것은 아니다. 여러 프레임워크는 C++·CUDA·ROCm과 전용 accelerator runtime으로 핵심 연산을 수행하고 Python을 모델 정의와 실행 제어 인터페이스로 사용한다.
대규모 데이터와 분산 계산
메모리 한계를 넘는 데이터와 여러 컴퓨터를 이용한 계산에는 분산 데이터 처리 도구가 사용된다.
PySpark는 Apache Spark의 Python API로, 분산된 DataFrame과 SQL 처리, streaming과 머신러닝 기능을 Python에서 사용하게 한다.
Dask는 NumPy, pandas와 비슷한 인터페이스로 배열·DataFrame과 작업 그래프를 여러 thread, process 또는 cluster에서 실행한다.
Ray는 분산 task와 actor, 데이터 처리, 학습 및 모델 제공 기능을 Python API로 구성한다.
단일 machine 분석
→ pandas·Polars·DuckDB
단일 machine 병렬·out-of-core
→ Dask
cluster batch 처리
→ PySpark
범용 분산 task와 actor
→ Ray
이들 프레임워크는 Python 함수를 원격 worker에 전달할 수 있지만 실제 데이터 직렬화, 전송, 작업 크기와 cluster 자원 관리가 성능에 큰 영향을 준다.
데이터 파이프라인과 workflow
Python은 데이터를 수집하고 변환한 뒤 저장·학습·배포 작업을 일정에 따라 실행하는 workflow 정의 언어로도 사용된다.
Apache Airflow는 batch 중심 workflow를 개발하고 예약하며 실행 상태를 감시하는 플랫폼이다. workflow는 DAG와 Task로 표현되며 Python 코드로 작성된다.[254]
from airflow.sdk import dag
from airflow.sdk import task
@dag(
schedule="@daily",
)
def pipeline():
@task
def extract():
...
@task
def transform(data):
...
transform(
extract()
)
pipeline()
Prefect와 Dagster도 Python 함수와 데이터 자산을 중심으로 workflow를 구성하고 관찰·재시도·배포하는 기능을 제공한다. Luigi는 batch task 의존성을 정의하는 도구로 사용되어 왔다.
머신러닝 중심 환경에서는 Kubeflow Pipelines, Metaflow와 Flyte 등이 학습 작업과 데이터 처리, container 실행을 연결한다.
이러한 시스템은 Python 코드를 직접 실행하는 것뿐 아니라 SQL, container, Spark 작업, cloud service와 다른 언어의 프로그램을 조정하는 역할을 한다.
자동화와 시스템 관리
Python은 파일 처리, 운영체제 명령, 서버 관리, cloud API 호출과 반복적인 업무 절차를 자동화하는 데 사용된다.
작은 자동화는 표준 라이브러리와 Requests, Click·Typer 같은 도구를 조합해 script나 CLI로 작성할 수 있다.
from pathlib import Path
for path in Path("logs").glob(
"*.log"
):
archive(path)
Ansible은 시스템과 네트워크 장비의 상태를 선언하고 자동화하는 도구이다. 사람이 읽기 쉬운 playbook을 사용하며, 기능을 확장하는 module과 plugin의 상당 부분이 Python으로 작성된다.[255]
Fabric은 SSH를 이용한 원격 명령 실행과 배포 자동화를 제공한다. Invoke는 Python 함수로 로컬 작업을 정의한다.
Boto3, Azure SDK for Python과 Google Cloud Client Libraries는 각 cloud platform의 자원을 Python 코드에서 생성·조회·변경하는 데 사용된다.
작은 로컬 자동화
→ pathlib·subprocess·Requests
서버와 장비 구성
→ Ansible
원격 명령과 배포
→ Fabric
cloud 자원 관리
→ 각 cloud SDK
browser 자동화
→ Playwright·Selenium
Python은 CI script, build system, test runner와 release automation의 구현 언어로도 자주 사용된다.
네트워크와 웹 데이터 수집
Python은 HTTP client, HTML 분석, browser 자동화와 비동기 network 통신을 위한 생태계를 가진다.
Requests와 HTTPX는 HTTP client API를 제공한다. HTTPX는 동기 및 비동기 호출과 HTTP/2 등을 지원한다.
Beautiful Soup과 lxml은 HTML·XML 문서를 분석하고 필요한 요소를 추출하는 데 사용된다.
Scrapy는 crawler, request scheduler, item pipeline과 확장 체계를 갖춘 웹 수집 framework이다.
Playwright와 Selenium은 실제 browser를 제어하여 JavaScript로 동작하는 웹 application을 자동화한다.
단순 HTTP 요청
→ Requests·HTTPX
HTML parsing
→ Beautiful Soup·lxml
대규모 crawler
→ Scrapy
실제 browser 자동화
→ Playwright·Selenium
Twisted, asyncio, Trio와 AnyIO는 network server와 비동기 통신 프로그램의 기반으로 사용된다.
웹 수집과 자동화에서는 대상 서비스의 이용 약관, 접근 제한, 저작권, 개인정보와 요청 부하를 고려해야 한다.
데이터베이스와 저장소
Python은 관계형 데이터베이스, 문서 데이터베이스, key-value 저장소와 검색 엔진을 연결하는 driver와 추상화 계층을 제공한다.
표준 라이브러리에는 SQLite 인터페이스인 sqlite3가 포함된다. PostgreSQL에는 psycopg, MySQL에는 여러 connector, Microsoft SQL Server에는 pyodbc와 관련 driver가 사용된다.
SQLAlchemy는 여러 관계형 데이터베이스를 위한 SQL toolkit과 ORM을 제공한다.
from sqlalchemy import (
select,
)
statement = select(User).where(
User.enabled
)
Django ORM과 SQLModel도 Python 객체와 관계형 데이터 모델을 연결한다.
Redis, MongoDB, Elasticsearch, OpenSearch와 여러 cloud database는 각각 Python client library를 제공한다.
database 생태계는 web framework, 데이터 분석, workflow와 automation 도구의 공통 기반으로 사용된다.
데스크톱과 사용자 인터페이스
Python은 데스크톱 GUI, 데이터 시각화 도구, 관리 프로그램과 terminal interface를 개발하는 데 사용된다.
tkinter는 표준 라이브러리에 포함되는 Tk 기반 GUI interface이다. PySide와 PyQt는 Qt를, wxPython은 wxWidgets를 Python에서 사용할 수 있게 한다.
Kivy는 touch와 graphics 중심의 cross-platform application에 사용되고, Toga는 운영체제별 native widget을 목표로 한다.
Textual은 terminal 안에서 widget과 layout을 사용하는 TUI application을 만든다. Rich는 table, progress bar와 색상화된 terminal 출력을 제공한다.
Streamlit, Gradio, Dash, Panel과 NiceGUI는 Python 코드만으로 dashboard, 데이터 application과 간단한 web interface를 빠르게 구성하는 데 사용된다.
일반 desktop GUI
→ PySide·PyQt·wxPython
간단한 기본 GUI
→ tkinter
touch·mobile 지향
→ Kivy·Toga
terminal application
→ Rich·Textual
데이터·AI web UI
→ Streamlit·Gradio·Dash·Panel
영상·그래픽과 콘텐츠 제작
Python은 영상 처리, 3차원 제작, 시각 효과와 디지털 콘텐츠 제작 도구의 자동화 언어로 사용된다.
Pillow는 이미지 읽기·변환·합성과 기본적인 영상 처리를 제공한다. OpenCV의 Python binding은 computer vision, 영상 분석과 camera 처리에 사용된다.
Blender는 Python API를 통해 scene, object, material, animation, rendering과 editor 기능을 제어할 수 있다. add-on과 제작 pipeline 도구도 Python으로 개발할 수 있다.
Maya, Houdini, Nuke와 여러 디지털 콘텐츠 제작 프로그램도 Python scripting API를 제공한다.
이미지 처리
→ Pillow
computer vision
→ OpenCV·scikit-image
3D 제작 자동화
→ Blender Python API
영상·VFX pipeline
→ Maya·Houdini·Nuke의 Python API
Python은 고성능 renderer나 game engine의 핵심 runtime보다 asset 변환, build pipeline, editor extension과 반복 작업 자동화에 더 자주 사용된다.
Pygame, Arcade, Panda3D와 Ren'Py는 2차원 game, 교육용 game, 시각화와 visual novel 개발에 사용된다.
테스트와 품질 관리
Python에는 독립적인 테스트 및 코드 품질 도구 생태계도 형성되어 있다.
pytest는 단위·통합 테스트와 plugin 체계를 제공한다. Hypothesis는 property-based testing을, coverage.py는 code coverage 측정을 제공한다.
Ruff·Flake8·Pylint는 source code를 검사하고, Black·Ruff formatter·YAPF는 code format을 자동화한다. mypy·Pyright와 여러 신생 type checker는 타입 주석을 기반으로 정적 분석을 수행한다.
테스트
→ pytest·unittest
입력 자동 생성
→ Hypothesis
coverage
→ coverage.py
lint와 format
→ Ruff·Black·Pylint
type 검사
→ mypy·Pyright
Bandit은 Python source에서 보안상 의심되는 패턴을 찾고, pip-audit은 알려진 패키지 취약점을 검사한다.
이 도구들은 Python application 자체의 기능을 제공하기보다 다른 Python project의 개발과 배포 품질을 지원하는 생태계를 형성한다.
보안과 디지털 포렌식
Python은 보안 분석, network 검사, 암호화, malware 분석과 digital forensics에서도 사용된다.
cryptography는 현대적인 암호 알고리즘과 저수준 primitive를 제공한다. PyCryptodome도 여러 암호 기능을 제공한다.
Scapy는 packet을 생성·분석하고 network protocol 실험과 보안 검사에 활용된다.
Volatility는 memory forensics framework이며, pwntools는 exploit 개발과 binary 상호작용을 지원한다.
YARA rule 처리, log 분석, threat intelligence API와 자동화된 incident response에도 Python library가 사용된다.
이 분야에서 Python은 packet 처리와 분석 흐름을 표현하는 상위 계층으로 사용되며, 암호 연산과 packet capture 같은 핵심 기능은 native library와 운영체제 기능을 이용하는 경우가 많다.
교육과 학습
Python은 비교적 간결한 문법과 즉시 실행할 수 있는 환경 때문에 입문 programming과 computer science 교육에 널리 사용된다. Python.org도 교육을 Python의 주요 활용 영역 가운데 하나로 분류한다.[256]
IDLE, Thonny, Jupyter Notebook과 online execution environment는 학습자가 code를 짧게 실행하고 결과를 확인하게 한다.
Turtle은 도형과 이동 명령으로 programming 기초를 가르치며, Pygame은 간단한 game project에 사용된다.
from turtle import forward
from turtle import right
for _ in range(4):
forward(100)
right(90)
교육에서는 Python 자체뿐 아니라 알고리즘, 데이터 분석, web development, robotics와 인공지능 수업의 공통 실습 언어로도 사용된다.
임베디드 시스템과 하드웨어
일반적인 CPython은 메모리와 운영체제 자원이 충분한 컴퓨터를 대상으로 하지만, Python 계열 구현체와 hardware library는 microcontroller와 single-board computer에서도 사용된다.
MicroPython은 Python 3의 일부와 축소된 표준 라이브러리를 작은 microcontroller와 제한된 환경에서 실행하도록 최적화한 구현체이다.[257]
from machine import Pin
from time import sleep
led = Pin(
25,
Pin.OUT,
)
while True:
led.toggle()
sleep(0.5)
CircuitPython은 교육과 전자 제작에 초점을 맞춘 MicroPython 계열 구현체로, 여러 sensor·display와 주변 장치 library를 제공한다.
Raspberry Pi와 일반 Linux 기반 single-board computer에서는 CPython과 GPIO·camera·robotics library를 사용할 수 있다.
Robot Operating System의 Python client, drone SDK와 실험 장비 제어 library도 Python API를 제공한다.
microcontroller 환경에서는 전체 CPython 표준 라이브러리와 일반 PyPI package를 그대로 사용할 수 없으며, memory·실행 속도·실시간성의 제약을 고려해야 한다.
기업 업무와 특화 애플리케이션
Python은 회계, ERP, 전자상거래, 문서 처리와 사내 관리 시스템에도 사용된다.
Odoo는 회계, 판매, 재고, 생산과 인사 등의 기업 업무 application을 제공하며 Python 기반 server와 module 확장 체계를 사용한다. Tryton도 기업 application 개발을 위한 platform을 제공한다.
문서와 사무 자동화에는 openpyxl, XlsxWriter, python-docx, ReportLab과 PDF 처리 library가 사용된다.
Excel 처리
→ openpyxl·XlsxWriter
Word 문서
→ python-docx
PDF 생성
→ ReportLab
ERP와 업무 application
→ Odoo·Tryton
Python은 특정 산업용 완제품뿐 아니라 기존 업무 시스템을 연결하고 데이터를 변환하며 보고서를 생성하는 접착 언어로 자주 사용된다.
생태계 사이의 결합
실제 Python 프로젝트는 하나의 생태계만 사용하지 않는 경우가 많다.
머신러닝 서비스는 다음 요소를 함께 사용할 수 있다.
pandas
→ 데이터 정리
scikit-learn·PyTorch
→ 모델 학습
MLflow
→ 실험과 모델 관리
FastAPI
→ inference API
Airflow
→ 정기 학습 workflow
Prometheus client
→ 운영 지표
데이터 dashboard는 다음과 같이 구성할 수 있다.
database
→ SQLAlchemy
데이터 처리
→ pandas·Polars
시각화
→ Plotly
사용자 interface
→ Dash·Streamlit
배포
→ container·cloud platform
자동화 시스템은 Ansible, cloud SDK, Requests, database driver와 CLI framework를 함께 사용할 수 있다.
Python 생태계의 강점은 특정 분야의 모든 연산을 Python으로 직접 구현하는 데만 있지 않다. 서로 다른 언어와 runtime으로 구현된 library, database, cloud service와 전문 도구를 비교적 일관된 Python 인터페이스로 연결할 수 있다는 점에 있다.
각 생태계는 서로 다른 버전 정책, 성능 특성, license와 배포 조건을 가지므로 프로젝트 목적에 맞는 대표 도구를 선택해야 한다. 비슷한 기능의 framework를 지나치게 많이 함께 사용하기보다 핵심 interface와 데이터 형식을 정하고, 실제로 필요한 범위에서 생태계를 조합하는 것이 적합하다.
활용 분야
Python은 웹 서비스와 데이터 분석, 과학 연구, 인공지능, 자동화, 교육과 업무용 소프트웨어 등 다양한 분야에 활용되는 범용 프로그래밍 언어이다. 빠르게 기능을 구현할 수 있는 문법과 광범위한 라이브러리 생태계, C·C++·Fortran·Rust 및 여러 외부 시스템과의 연동 능력을 바탕으로 독립적인 응용 프로그램부터 대규모 서비스의 일부 구성 요소까지 폭넓게 사용된다.[258]
Python이 모든 분야의 핵심 연산을 직접 담당하는 것은 아니다. 성능이나 실시간성이 중요한 부분은 네이티브 코드와 전문 런타임이 처리하고, Python은 데이터 준비, 작업 조정, 사용자 인터페이스, 자동화와 고수준 API를 담당하는 경우가 많다. 이 때문에 Python은 완성된 제품을 구현하는 언어이면서 동시에 서로 다른 기술을 연결하는 접착 언어로도 사용된다.
웹 서비스와 인터넷 응용 프로그램
Python은 웹 사이트, 기업용 웹 애플리케이션, REST API, 실시간 서비스와 마이크로서비스의 서버 측 개발에 사용된다. 사용자 인증, 데이터베이스 접근, 콘텐츠 관리, 결제 처리, 관리자 화면과 외부 서비스 연동 같은 기능을 구현할 수 있다.
대규모 웹 애플리케이션에서는 Django와 같은 종합 프레임워크가 사용되며, 비교적 작은 서비스와 API에는 Flask, FastAPI, Starlette, Litestar와 같은 프레임워크가 활용된다. 비동기 네트워크 처리가 필요한 환경에서는 asyncio와 ASGI 기반 서버를 함께 사용할 수 있다.
Python은 특히 제품의 초기 버전을 빠르게 개발하고 요구 사항 변화에 맞추어 기능을 자주 수정해야 하는 서비스에 적합하다. 다만 매우 높은 요청 처리량이나 짧은 지연 시간이 중요한 일부 구성 요소는 다른 언어의 서비스나 네이티브 확장으로 분리할 수 있다.
데이터 분석과 의사 결정
Python은 기업과 연구 기관에서 수집한 데이터를 정리하고 분석하며 시각화하는 데 널리 사용된다. 판매 기록, 사용자 행동, 금융 거래, 생산량, 센서 측정값과 실험 결과처럼 표 또는 배열 형태로 표현되는 데이터를 처리할 수 있다.
데이터 분석가는 Python을 이용해 다음과 같은 업무를 수행한다.
- 여러 파일과 데이터베이스에서 데이터 수집
- 누락되거나 잘못된 값 정리
- 데이터 결합과 형식 변환
- 통계량과 업무 지표 계산
- 그래프와 대시보드 생성
- 반복되는 보고서 자동화
- 분석 결과를 웹 서비스나 문서로 제공
이 분야에서는 NumPy, pandas, Polars, DuckDB, Matplotlib, Plotly와 Jupyter 등이 함께 사용된다. Python은 데이터 처리 과정 전체를 하나의 언어로 연결할 수 있으므로 일회성 분석에서 정기적인 운영 보고 시스템까지 같은 기술을 확장해 사용할 수 있다.
과학 연구와 공학
Python은 물리학, 화학, 생물학, 천문학, 기상학, 지리정보, 의학, 재료공학과 여러 공학 분야의 계산 및 연구 도구로 사용된다. 수치 해석, 통계 처리, 최적화, 시뮬레이션, 신호 처리와 실험 장비 제어 등을 수행할 수 있다.
연구자는 Python으로 실험 데이터를 정리하고 알고리즘을 시험하며 결과를 시각화할 수 있다. 계산량이 많은 부분은 NumPy와 SciPy를 비롯한 네이티브 기반 라이브러리나 기존 C·C++·Fortran 프로그램에 맡기고, Python에서 전체 계산 절차를 구성하는 방식이 일반적이다.[259]
Python은 분야별 전문 패키지가 풍부하여 연구자가 저수준 수치 알고리즘을 처음부터 다시 구현하지 않고 연구 문제에 집중할 수 있게 한다. 반면 장기간 유지해야 하는 연구 프로그램에서는 패키지 버전, 실행 환경과 데이터 처리 절차를 함께 기록해야 결과를 재현할 수 있다.
머신러닝과 인공지능
Python은 머신러닝, 딥러닝, 자연어 처리, 컴퓨터 비전과 생성형 인공지능 분야의 주요 개발 언어로 사용된다. 데이터 전처리, 모델 구성, 학습, 평가, 실험 관리와 추론 서비스 개발을 하나의 생태계에서 수행할 수 있다.
전통적인 통계 기반 머신러닝에는 scikit-learn, 딥러닝에는 PyTorch, TensorFlow와 JAX 등이 사용된다. 사전 학습 모델과 데이터셋을 활용하는 생태계도 Python 인터페이스를 중심으로 발전해 왔다.
실제 행렬 연산과 GPU 계산은 C++, CUDA, ROCm과 전용 컴파일러가 처리하는 경우가 많다. Python은 이들 연산을 조합하고 모델 구조와 학습 과정을 표현하는 고수준 언어로 사용된다.
모델을 실제 서비스에 적용할 때는 데이터 수집, 학습 작업 예약, 모델 버전 관리, API 제공, 성능 감시와 재학습 과정도 함께 구축해야 한다. 따라서 인공지능 프로젝트에서 Python은 모델 코드뿐 아니라 주변 운영 체계에도 사용된다.
데이터 공학과 분산 처리
기업이 처리하는 데이터의 양이 한 컴퓨터의 메모리와 저장 장치 범위를 넘으면 Python은 분산 데이터 처리 시스템의 제어 및 사용자 인터페이스로 사용된다.
PySpark, Dask와 Ray 등을 이용해 대규모 데이터를 여러 프로세스나 컴퓨터에서 처리할 수 있다. Airflow, Dagster, Prefect와 같은 도구에서는 데이터 수집·변환·학습·배포 작업의 의존 관계와 실행 일정을 Python으로 정의한다.
Python은 다음과 같은 데이터 처리 흐름에 활용된다.
- 여러 원천에서 정기적으로 데이터 수집
- 데이터 정제와 형식 통일
- 데이터 웨어하우스와 데이터 레이크 적재
- 분석용 테이블 생성
- 머신러닝 학습 데이터 준비
- 실패한 작업의 재시도와 실행 상태 감시
Python 함수가 분산 환경에서 실행되더라도 데이터 전송, 직렬화와 작업 분할 비용이 존재한다. 따라서 대규모 처리에서는 단순히 Python 코드를 병렬로 실행하는 것보다 데이터의 위치와 작업 단위를 함께 설계해야 한다.
업무 자동화
Python은 사람이 반복적으로 수행하던 파일 정리, 자료 변환, 웹 요청, 문서 작성과 데이터 입력 작업을 자동화하는 데 사용된다.
작은 스크립트만으로도 여러 파일의 이름을 변경하거나, 스프레드시트의 내용을 정리하고, 웹 API에서 데이터를 내려받아 보고서를 생성할 수 있다. 이러한 스크립트는 이후 명령행 프로그램, 예약 작업이나 내부 웹 도구로 확장될 수 있다.
업무 자동화는 전문 개발자뿐 아니라 연구자, 데이터 분석가, 시스템 관리자와 일반 사무직에서도 활용된다. 다만 개인이 만든 임시 스크립트가 중요한 업무 절차에 사용되기 시작하면 오류 처리, 로그, 접근 권한, 테스트와 유지관리 책임을 갖춘 정식 프로그램으로 발전시킬 필요가 있다.
시스템 관리와 DevOps
Python은 서버 관리, 배포 자동화, 클라우드 자원 제어, 감시, 테스트와 빌드 과정에 사용된다. 운영체제 명령과 파일 시스템, 네트워크 API를 다루기 쉬우며 주요 클라우드 서비스와 인프라 도구가 Python SDK를 제공한다.
대표적인 활용은 다음과 같다.
- 서버와 네트워크 장비 구성
- 응용 프로그램 배포
- 클라우드 인스턴스와 저장소 관리
- 로그와 운영 지표 수집
- 백업과 복구 자동화
- CI 작업과 릴리스 절차 구현
- 장애 대응 도구와 관리용 CLI 개발
Ansible의 확장 모듈, 클라우드 SDK와 여러 자동화 도구가 Python을 사용한다. 그러나 운영 환경을 변경하는 스크립트는 잘못 실행될 경우 여러 시스템에 영향을 줄 수 있으므로 멱등성, 권한 제한, 실행 전 검증과 변경 기록이 중요하다.
소프트웨어 개발 지원
Python은 최종 제품의 실행 언어가 아니더라도 다른 소프트웨어를 개발하는 과정에서 사용될 수 있다. 빌드 스크립트, 코드 생성기, 테스트 실행기, 파일 변환기, 배포 도구와 개발 환경 관리 프로그램을 만드는 데 적합하다.[260]
C·C++ 프로젝트에서는 Python으로 다음과 같은 보조 도구를 만들 수 있다.
- 소스 코드와 설정 파일 생성
- 자산과 리소스 변환
- 테스트 데이터 작성
- 컴파일 결과 검사
- 패키지와 설치 프로그램 제작
- 여러 플랫폼의 빌드 자동화
- API 문서 생성
Python의 비교적 높은 개발 생산성은 실행 빈도가 낮고 개발자만 사용하는 도구를 빠르게 구현하는 데 유리하다. 이 때문에 제품의 핵심은 다른 언어로 작성되더라도 개발 도구와 제작 파이프라인에서는 Python이 사용되는 경우가 많다.
금융과 경제 분석
Python은 은행, 자산운용, 보험, 핀테크와 경제 연구에서 데이터 분석, 위험 계산, 거래 전략 연구와 업무 자동화에 사용된다.
시장 데이터와 거래 기록을 정리하고 통계 모델을 적용하거나, 포트폴리오 위험과 수익률을 계산하며, 보고서와 규제 자료를 생성할 수 있다. 머신러닝을 이용한 이상 거래 탐지, 신용 위험 분석과 수요 예측에도 활용된다.
실제 거래와 결제 시스템에서는 정확성, 감사 가능성, 보안과 지연 시간이 중요하다. 따라서 Python은 분석과 전략 개발에서 널리 사용되지만, 초저지연 거래나 핵심 결제 처리의 일부는 C++, Java, Rust와 전용 시스템으로 구현될 수 있다.
의료와 생명과학
Python은 의료 데이터 처리, 생물정보학, 의학 영상, 신약 연구와 임상 연구 지원에 활용된다. 유전체 데이터 분석, 의료 영상 분류, 생체 신호 처리, 논문 데이터 정리와 연구용 모델 개발 등에 사용될 수 있다.
의료 환경에서는 일반적인 소프트웨어 정확성 외에도 환자 개인정보 보호, 데이터 익명화, 규제 준수, 모델의 검증 가능성과 오류 책임이 중요하다. 연구용 Python 코드가 실제 의료 의사 결정 시스템에 적용되려면 별도의 품질 관리와 검증 절차가 필요하다.
제조업과 산업 자동화
제조업에서는 생산 설비와 센서에서 데이터를 수집하고, 품질을 분석하며, 장비 상태와 생산 일정을 관리하는 데 Python이 사용된다.
대표적인 활용은 다음과 같다.
- 생산량과 불량률 분석
- 예지 정비 모델 개발
- 기계 시각 기반 품질 검사
- 실험 및 측정 장비 제어
- 로봇과 자동화 장비의 상위 제어
- 제조 실행 시스템과 외부 업무 시스템 연결
- 시뮬레이션 결과와 실제 생산 데이터 비교
하드 실시간 제어와 안전 기능은 PLC, 마이크로컨트롤러, C·C++와 실시간 운영체제가 담당하는 경우가 많다. Python은 이러한 장치의 상위 제어, 설정, 시험과 데이터 분석에 더 적합하다.
로봇공학과 하드웨어 제어
Python은 로봇의 동작 계획, 센서 데이터 처리, 컴퓨터 비전, 실험 자동화와 프로토타입 제작에 활용된다. Robot Operating System의 Python 클라이언트, 드론과 카메라 SDK, 직렬 통신과 GPIO 라이브러리 등을 사용할 수 있다.
Raspberry Pi와 같은 Linux 기반 소형 컴퓨터에서는 일반적인 CPython을 사용할 수 있다. 제한된 마이크로컨트롤러에서는 MicroPython과 CircuitPython이 교육, 전자 제작과 간단한 장치 제어에 사용된다.
Python은 빠르게 제어 로직을 시험하고 센서와 알고리즘을 연결하기에 유리하지만, 엄격한 실행 시간 보장이 필요한 제어 루프에는 적합하지 않을 수 있다. 이러한 부분은 펌웨어나 실시간 구성 요소로 분리할 수 있다.
네트워크와 보안
Python은 네트워크 서비스 개발, 프로토콜 실험, 패킷 분석, 보안 자동화와 디지털 포렌식에 사용된다.
네트워크 관리자는 장비 설정과 상태 수집을 자동화하고, 보안 분석가는 로그와 패킷을 분석하거나 여러 보안 서비스의 API를 연결할 수 있다. 취약점 진단 도구, 사고 대응 스크립트, 악성코드 분석 보조 도구와 메모리 포렌식 프레임워크도 Python으로 개발된다.
암호 연산과 패킷 캡처 같은 저수준 기능은 네이티브 라이브러리와 운영체제 기능을 사용하는 경우가 많다. Python은 분석 절차와 자동화 흐름을 구성하는 상위 계층으로 사용된다.
보안 도구를 개발할 때는 분석 대상의 허가 범위, 민감 정보 처리, 패키지 공급망과 외부 입력으로부터의 코드 실행 위험을 함께 고려해야 한다.
데스크톱과 명령행 프로그램
Python은 업무용 데스크톱 응용 프로그램, 관리 도구, 데이터 변환기, 설치 도구와 명령행 프로그램을 개발하는 데 사용된다.
Qt, Tk, wxWidgets와 여러 GUI 프레임워크를 통해 데스크톱 인터페이스를 만들 수 있으며, Click과 Typer 같은 도구를 이용해 명령행 프로그램을 구성할 수 있다.
Python 응용 프로그램은 사용자의 시스템에 Python 환경과 패키지를 설치하는 방식으로 배포할 수도 있고, PyInstaller·Nuitka·Briefcase 등의 도구를 이용해 인터프리터와 의존성을 포함한 실행 파일 또는 애플리케이션 번들로 배포할 수도 있다.
데스크톱 응용 프로그램에서는 설치 크기, 시작 시간, 운영체제 통합과 코드 배포 방식이 중요하다. 크고 복잡한 네이티브 사용자 인터페이스가 필요한 제품에서는 Python을 전체 응용 프로그램보다 내부 로직이나 자동화 계층에 사용하는 경우도 있다.
그래픽스와 디지털 콘텐츠 제작
Python은 영화, 애니메이션, 시각 효과, 3차원 모델링과 게임 콘텐츠 제작 과정의 자동화에 널리 사용된다. Blender, Maya, Houdini, Nuke와 여러 제작 도구가 Python API와 플러그인 체계를 제공한다.
Python으로 다음과 같은 작업을 자동화할 수 있다.
- 여러 장면과 자산의 일괄 처리
- 파일 형식 변환
- 모델과 텍스처 검사
- 렌더링 작업 제출
- 제작 단계와 버전 관리
- 사용자 정의 편집 도구 개발
- 외부 자산 관리 시스템과 연동
실시간 렌더링과 물리 계산은 주로 C++과 GPU 코드가 담당하며, Python은 제작자의 작업을 지원하는 도구와 파이프라인에서 중요한 역할을 한다. Python.org의 활용 사례에도 영화 시각 효과 제작 환경에서의 사용이 소개되어 있다.[261]
게임 개발
Python은 게임 프로토타입, 교육용 게임, 2차원 게임과 제작 도구에 사용된다. Pygame, Arcade, Panda3D와 Ren'Py 같은 프레임워크를 통해 게임이나 시각적 응용 프로그램을 만들 수 있다.
상용 게임의 고성능 런타임 전체를 Python으로 구현하는 경우는 상대적으로 드물지만 다음 분야에서는 널리 활용될 수 있다.
- 게임플레이 아이디어와 알고리즘 시험
- 레벨 및 자산 제작 도구
- 빌드와 패키징 자동화
- 서버 운영 도구
- 데이터 분석과 밸런스 조정
- 인공지능 연구
- 게임 엔진의 내장 스크립트
게임 엔진에 Python 인터프리터를 내장하거나 외부 Python 도구와 통신하는 구조도 가능하다. CPython은 다른 응용 프로그램에 인터프리터를 내장하여 일부 기능에서 Python 코드를 실행할 수 있는 공식 인터페이스를 제공한다.[262]
기업용 업무 시스템
Python은 ERP, CRM, 전자상거래, 재고 관리, 보고 시스템과 사내 협업 도구를 개발하는 데 사용된다. Odoo와 Tryton 같은 기업용 애플리케이션 플랫폼도 Python을 기반으로 한다.[263]
기업 환경에서는 새로운 시스템을 만드는 것뿐 아니라 기존 데이터베이스, 문서 시스템, 외부 API와 오래된 업무 시스템을 연결하는 작업이 중요하다. Python은 여러 데이터 형식과 프로토콜을 다룰 수 있어 시스템 통합과 점진적인 현대화에 활용된다.
업무 시스템은 장기간 운영되고 여러 부서의 핵심 절차에 영향을 주므로 빠른 개발뿐 아니라 데이터 마이그레이션, 접근 통제, 감사 기록과 하위 호환성을 함께 관리해야 한다.
정부와 공공 분야
정부 기관과 공공 조직에서는 공개 데이터 처리, 연구, 행정 자동화, 통계 분석과 시민 대상 웹 서비스에 Python을 사용할 수 있다. Python.org의 활용 사례에는 공공 전력 보고 자동화, 공개 데이터 활용과 항공 교통 관리 관련 사례도 소개되어 있다.[264]
공공 분야에서는 소프트웨어의 장기 유지보수, 접근성, 데이터 공개 형식, 개인정보 보호와 조달 환경이 중요하다. Python의 공개 생태계와 다양한 플랫폼 지원은 이러한 시스템을 구축하는 선택지 중 하나가 된다.
교육
Python은 프로그래밍 입문, 컴퓨터 과학, 데이터 분석, 과학 실습과 인공지능 교육에 사용된다. 비교적 간결한 문법과 대화형 실행 환경을 갖추어 학습자가 알고리즘과 문제 해결 과정에 집중하기 쉽다.[265]
초급 교육에서는 IDLE, Thonny, Turtle과 간단한 게임 라이브러리를 사용할 수 있다. 대학과 전문 교육에서는 Jupyter, NumPy, pandas, 머신러닝 프레임워크와 분야별 연구 패키지가 활용된다.
Python은 교육용 언어에만 머물지 않고 같은 문법과 생태계를 실제 연구와 산업에서도 사용할 수 있다는 장점이 있다. 다만 동적 타입과 간단한 문법 뒤에 객체 모델, 패키징, 실행 환경과 성능 특성이 존재하므로 고급 교육에서는 이러한 요소도 함께 다룰 필요가 있다.
언어 내장과 확장 기능
Python은 다른 응용 프로그램의 확장 및 자동화 언어로 내장될 수 있다. 호스트 프로그램이 Python 인터프리터를 초기화하고 사용자 스크립트나 플러그인을 실행하며, 자체 기능을 Python 모듈로 제공할 수 있다.[266]
이 방식은 다음과 같은 프로그램에 활용될 수 있다.
- 그래픽과 콘텐츠 제작 프로그램
- 과학 및 공학 소프트웨어
- 게임 엔진과 시뮬레이터
- 데이터베이스와 서버 제품
- 자동화 가능한 업무용 응용 프로그램
- 사용자 정의 플러그인을 지원하는 도구
Python을 내장하면 사용자가 프로그램을 다시 컴파일하지 않고 동작을 자동화하거나 확장할 수 있다. 반면 스크립트가 호스트 프로그램의 내부 기능에 접근하므로 권한, 오류 격리, API 안정성과 인터프리터 버전 관리가 필요하다.
프로토타입과 연구 개발
Python은 새로운 제품, 알고리즘과 서비스의 가능성을 빠르게 검증하는 프로토타입 개발에 적합하다. 비교적 적은 코드로 파일 처리, 네트워크 통신, 데이터베이스, 사용자 인터페이스와 수치 계산을 연결할 수 있기 때문이다.
프로토타입에서 검증된 구조는 다음과 같은 방향으로 발전할 수 있다.
- Python 코드를 정식 서비스로 개선
- 성능이 필요한 일부만 네이티브 코드로 교체
- 계산 작업을 별도 서비스로 분리
- 명령행 도구를 웹이나 데스크톱 응용 프로그램으로 확장
- 연구 코드를 테스트와 패키징을 갖춘 라이브러리로 전환
프로토타입의 빠른 개발 속도가 장기적인 설계와 품질 관리를 대신하지는 않는다. 실제 제품으로 발전할 때는 타입과 인터페이스, 오류 처리, 보안, 테스트, 배포와 운영 구조를 다시 정리해야 한다.
적용상의 한계
Python은 다양한 분야에 사용되지만 모든 작업에 같은 수준으로 적합하지는 않다.
다음과 같은 분야에서는 Python만으로 핵심을 구현하기 어려울 수 있다.
- 엄격한 하드 실시간 제어
- 메모리와 저장 공간이 매우 제한된 장치
- 운영체제 커널과 저수준 장치 드라이버
- 극도로 낮은 지연 시간을 요구하는 처리
- 네이티브 실행 파일 크기가 중요한 환경
- Python 런타임을 허용하지 않는 플랫폼
- 긴 시간 동안 GIL을 유지하는 순수 Python CPU 계산
이러한 경우 Python을 완전히 제외하기보다 C·C++·Rust·Fortran, GPU 커널, 데이터베이스와 외부 서비스에 핵심 처리를 맡기고 Python을 제어 및 통합 계층으로 사용할 수 있다.
Python의 실제 활용 범위는 언어 문법만으로 결정되지 않는다. 사용할 수 있는 라이브러리, 대상 플랫폼, 성능 요구, 배포 방식, 개발 인력과 기존 시스템의 기술 구성이 함께 영향을 미친다. Python은 이러한 조건이 맞는 분야에서 빠른 개발과 기술 간 연동을 제공하며, 필요할 경우 다른 언어 및 실행 환경과 역할을 나누어 사용된다.
언어 설계와 표준화
Python의 문법과 실행 의미는 Python 언어 참조, 채택된 Python Enhancement Proposal과 표준 라이브러리 문서를 통해 정의된다. Python은 ISO, IEC나 ECMA가 발행하는 독립적인 국제 표준을 중심으로 관리되는 언어가 아니며, Python 프로젝트의 공개 개발 절차와 기준 구현체인 CPython을 중심으로 발전한다.
Python 언어 참조는 어휘 구조, 데이터 모델, 실행 모델, 표현식과 문장의 의미를 설명한다. 다만 완전한 수학적 형식 명세를 제공하는 것은 아니며, 구현체마다 달라질 수 있는 내부 동작을 언어 자체의 규칙과 구분한다. CPython 특유의 제한이나 동작을 언급할 때는 일반적으로 구현 세부 사항임을 표시한다.[267]
Python의 설계와 표준화 체계는 대략 다음 요소로 이루어진다.
Python 언어 참조
→ 문법과 실행 의미
Python Enhancement Proposal
→ 언어·라이브러리·개발 절차의 변경 제안과 결정 기록
CPython
→ 기준 구현체와 표준 배포판
Steering Council
→ 언어 개발의 최종 거버넌스
Core team과 기여자
→ 구현·검토·문서화·유지보수
Python Software Foundation
→ 법인·재정·상표·공동체 기반 지원
이 구조에서 하나의 문서나 구현체만이 모든 규칙을 독립적으로 결정하지 않는다. 언어 참조와 채택된 PEP가 공개 계약을 형성하고, CPython과 다른 구현체가 이를 구현하며, 실제 구현 과정에서 발견된 문제는 다시 문서와 사양에 반영된다.
언어 참조
Python 언어 참조는 Python 프로그램의 문법과 핵심 실행 의미를 설명하는 중심 문서이다.
주요 범위에는 다음이 포함된다.
- 어휘 분석과 들여쓰기
- 이름과 객체
- 데이터 모델
- 실행 범위와 이름 확인
- 표현식 평가
- 단순문과 복합문
- 함수와 클래스 정의
- 예외 처리
- 가져오기 체계
- 최상위 실행 환경
언어 참조는 자습서나 사용 안내서와 목적이 다르다. 자습서는 일반적인 사용법을 설명하지만, 언어 참조는 특정 구문과 객체 프로토콜이 어떤 의미를 가지는지를 가능한 한 정확하게 기술한다.
자습서
→ 언어를 배우고 사용하는 방법
언어 참조
→ 문법과 실행 의미
표준 라이브러리 문서
→ 제공되는 모듈과 API
개발자 안내서
→ CPython에 기여하고 구현을 변경하는 방법
Python 문서에서는 언어 수준의 보장과 CPython 구현 세부 사항을 구분한다. 예를 들어 객체가 더 이상 사용되지 않을 때 정리될 수 있다는 점은 공통적인 언어 동작이지만, 참조 계수로 인해 특정 객체가 즉시 해제되는 동작은 CPython에 종속될 수 있다.
기준 구현체
CPython은 Python 프로젝트가 직접 개발하는 기준 구현체이며, 가장 널리 사용되는 Python 구현체이다. 새로운 문법과 표준 라이브러리 기능은 일반적으로 CPython에서 먼저 구현되고 공식 문서와 함께 배포된다.
기준 구현체는 언어 사양과 밀접한 관계를 가지지만, CPython의 모든 동작이 자동으로 Python 언어의 규칙이 되는 것은 아니다.
Python 언어 규칙
→ 다른 구현체도 따라야 하는 공개 의미
CPython 구현 세부
→ 바이트코드
→ 참조 계수
→ 내부 객체 구조
→ 최적화 방식
→ 비공개 C API
다른 구현체는 같은 언어 의미를 다른 방식으로 구현할 수 있다. PyPy는 JIT 컴파일러와 다른 가비지 컬렉션 방식을 사용할 수 있고, Jython과 IronPython은 각각 JVM과 .NET 객체 모델을 활용할 수 있다.
언어 참조만으로 해석이 명확하지 않은 영역에서는 CPython의 동작이 현실적인 기준으로 사용되기도 한다. 그러나 그러한 동작을 공식적인 언어 보장으로 만들려면 문서화와 설계 결정이 뒤따라야 한다.
Python Enhancement Proposal
Python Enhancement Proposal, 줄여서 PEP는 Python 공동체에 정보를 제공하거나 언어, 표준 라이브러리, 개발 과정과 운영 체계의 변경을 제안하는 설계 문서이다. PEP에는 제안의 기술적 사양과 필요성, 호환성, 대안과 채택 결과 등이 기록된다.[268]
PEP는 대체로 다음 유형으로 나뉜다.
Standards Track
→ 언어 기능이나 상호 운용 규격의 변경
Informational
→ 설계 지침이나 공동체 정보 제공
Process
→ 개발 절차와 거버넌스 변경
새로운 문법이나 언어 의미를 도입하는 제안은 일반적으로 Standards Track PEP로 작성된다. 연간 릴리스 주기, 거버넌스와 PEP 절차 자체는 Process PEP에서 다룬다.
모든 변경에 PEP가 필요한 것은 아니다. 명백한 버그 수정, 문서 개선과 작은 구현 변경은 일반적인 issue와 pull request 절차로 처리할 수 있다. 반면 언어 사용자에게 장기적인 영향을 주거나 여러 구현체와 도구가 따라야 하는 기능은 PEP가 필요할 가능성이 높다.
PEP의 구성
언어 기능을 제안하는 PEP에는 일반적으로 다음 내용이 포함된다.
- 제안의 개요
- 동기와 해결하려는 문제
- 문법과 실행 의미
- 사용 예시
- 하위 호환성
- 보안과 성능 영향
- 교육과 문서화 영향
- 거부된 대안
- 참조 구현
- 논의와 결정 기록
PEP는 단순히 새로운 기능의 장점을 홍보하는 문서가 아니다. 기존 방식으로 해결할 수 없는 이유와 언어 복잡성 증가, 구현 비용, 생태계 영향까지 검토해야 한다.
Python 개발자 안내서는 언어 변경이 기존 사용자, 교육 자료, 도구와 앞으로 Python을 배울 모든 사람에게 영향을 주기 때문에 신중하게 다루어야 한다고 설명한다.[269]
제안과 논의
Python의 새로운 기능은 보통 아이디어 논의, PEP 작성, 구현 실험, 검토와 최종 결정의 과정을 거친다.
아이디어 제시
→ 기존 해결책과 필요성 검토
→ 공개 토론
→ PEP 초안 작성
→ PEP 번호 배정
→ 사양과 참조 구현 검토
→ 승인·거부·철회
→ CPython 구현과 문서 반영
현재의 주요 공개 논의 공간으로는 Python Discourse의 관련 범주와 CPython issue 및 pull request가 사용된다. 언어 설계자는 새로운 구문을 제안하기 전에 기존 라이브러리나 일반 함수로 문제를 해결할 수 있는지 검토한다.
PEP가 작성되었다고 해서 제안이 채택된 것은 아니다. PEP는 다음과 같은 상태를 가질 수 있다.
- Draft
- Accepted
- Rejected
- Withdrawn
- Final
- Active
- Deferred
- Superseded
- Provisional
거부된 PEP도 삭제되지 않는다. 거부 이유와 논의 기록은 같은 제안이 반복되거나 이후 상황이 바뀔 때 참고할 수 있는 역사적 자료로 남는다.
최종 결정
Python의 언어 거버넌스는 Steering Council을 중심으로 구성된다. Steering Council은 다섯 명으로 이루어지며 Core team의 선거로 선출된다. Python 프로젝트에 관한 폭넓은 권한을 가지지만, 가능한 한 직접 모든 결정을 내리기보다 전문가와 기존 절차에 결정을 위임하는 것을 원칙으로 한다.[270]
Steering Council은 다음과 같은 역할을 수행할 수 있다.
- PEP 승인과 거부
- PEP 결정 권한 위임
- 언어 개발 절차 설정
- 프로젝트 품질과 안정성 유지
- 릴리스 차단 문제 판단
- 프로젝트 자산과 개발 기반 관리
- Core team 구성과 권한 관련 최종 결정
실제 PEP 결정은 Steering Council이 직접 내릴 수도 있고, 특정 분야의 PEP-Delegate나 전문 위원회에 위임할 수도 있다. 타입 시스템, 패키징과 같은 전문 영역은 해당 분야의 유지관리자와 공동체가 사양을 발전시키며, 최종 거버넌스는 Python 전체 체계와 연결된다.
Core team
Python Core team은 CPython 저장소와 관련 프로젝트를 유지관리하는 핵심 개발자 집단이다. Core team 구성원은 코드 검토와 병합, 설계 논의, 릴리스, 문서와 개발 기반 유지보수 등에 참여한다.
Core team이 모든 변경을 직접 작성하는 것은 아니다. 일반 기여자도 issue, pull request, 문서 수정, 테스트와 PEP 논의에 참여할 수 있다. 검토와 기여를 지속하면서 프로젝트의 신뢰를 얻은 개발자는 Core team 구성원으로 추천될 수 있다.
Steering Council은 Core team 위에서 모든 코드를 직접 지시하는 관리 조직이라기보다, 공동체가 합의에 도달하지 못하거나 전체적인 방향 결정이 필요한 경우 최종 책임을 지는 거버넌스 기구에 가깝다.
Python Software Foundation
PSF는 Python 언어와 공동체를 지원하는 비영리 법인이다. Python 관련 지식과 오픈 소스 기술의 발전을 지원하고, 상표·재정·행사·기반 시설과 공동체 사업을 관리한다.
PSF와 Steering Council의 역할은 구분된다.
Steering Council
→ Python 언어와 핵심 개발의 기술적 거버넌스
PSF
→ 법인 운영
→ 상표와 재정
→ 기반 시설과 행사 지원
→ 공동체 지원
PSF가 일상적인 문법 설계를 직접 결정하거나, Steering Council이 PSF의 모든 법인 업무를 수행하는 구조는 아니다. 두 조직은 서로 협력하지만 별도의 책임과 권한을 가진다.
설계 원칙
Python은 하나의 짧은 공식 설계 규칙만으로 발전하지 않는다. 다만 역사적으로 다음과 같은 방향이 반복적으로 강조되어 왔다.
- 가독성을 중시한다.
- 명시적인 표현을 선호한다.
- 일반적인 작업을 간결하게 표현한다.
- 복잡한 기능도 서로 조합 가능한 객체와 프로토콜로 제공한다.
- 기존 코드와 생태계의 안정성을 고려한다.
- 특수한 경우보다 일반적인 사용 사례를 우선한다.
- 하나의 기능을 추가할 때 전체 언어의 일관성을 검토한다.
PEP 20의 The Zen of Python은 Python 공동체에서 중요하게 여겨지는 설계 태도를 간결한 문장으로 표현한다. 그러나 이는 정식 문법 사양이나 모든 설계 결정을 자동으로 판정하는 규칙은 아니다.
서로 다른 원칙이 충돌할 수 있기 때문에 실제 설계에서는 구체적인 사용 사례, 구현 가능성, 학습 비용과 하위 호환성을 함께 검토한다.
문법 변경
새 문법은 일반 함수나 라이브러리 기능보다 신중하게 도입된다. 문법은 파서, 코드 생성기와 인터프리터뿐 아니라 다음 생태계 전체에 영향을 주기 때문이다.
- 코드 formatter
- linter
- 타입 검사기
- IDE와 언어 서버
- 구문 강조기
- debugger
- 문서 생성기
- 대체 Python 구현체
- 코드 변환 및 분석 도구
- 교육 자료
새 문법은 기존 코드와 모호하게 해석되지 않아야 하며, 사람이 읽었을 때 의미를 예측할 수 있어야 한다. 또한 기능을 사용하지 않는 일반 사용자에게도 언어 복잡성으로 남기 때문에 제한된 사용 사례만을 위한 문법 추가는 받아들여지기 어렵다.
문법이 채택되면 문법 정의, AST, 코드 객체, 실행 의미와 오류 메시지까지 함께 구현하고 문서화해야 한다.
표준 라이브러리 변경
표준 라이브러리는 Python 배포판과 함께 제공되므로 일반 외부 패키지보다 긴 안정성과 유지보수 책임을 가진다.
새 모듈이나 API를 추가할 때는 다음을 검토한다.
- 충분히 일반적인 기능인가
- 여러 플랫폼에서 유지할 수 있는가
- API가 장기적으로 안정적인가
- 외부 패키지로 발전시키는 편이 적합한가
- 필요한 유지관리자가 존재하는가
- 보안과 운영체제 의존성이 적절한가
모든 유용한 패키지가 표준 라이브러리에 들어가는 것은 아니다. 빠르게 변화하는 기술이나 전문 분야의 기능은 PyPI의 독립 프로젝트로 유지하는 편이 더 적합할 수 있다.
일부 새로운 표준 라이브러리 API는 provisional 상태로 도입될 수 있다. provisional API는 실제 사용 경험을 수집하면서 일반적인 하위 호환성 보장보다 비교적 유연하게 수정할 수 있다. 안정화되면 일반 API가 되고, 충분한 사용 가능성을 증명하지 못하면 제거될 수 있다.
하위 호환성
Python 프로젝트는 기존 프로그램과 라이브러리가 새 Python 버전에서도 계속 동작하도록 하위 호환성을 중요하게 다룬다. 공개 API와 동작을 변경할 때는 PEP 387의 하위 호환성 정책을 따른다.[271]
호환성 검토 대상에는 다음이 포함된다.
- Python 문법과 실행 의미
- 표준 라이브러리의 공개 API
- C API와 ABI
- 명령행 인터페이스
- 파일과 데이터 형식
- 오류와 경고
- 문서화된 부작용
- 널리 사용되는 사실상의 동작
문서화되지 않은 구현 세부라고 해서 언제나 자유롭게 변경할 수 있는 것은 아니다. 많은 프로젝트가 의존하고 있고 변경에 따른 피해가 크다면 사실상의 호환성도 고려할 수 있다.
반대로 단순한 버그가 오랫동안 존재했다고 해서 그 동작이 영구적인 언어 보장이 되는 것은 아니다. 수정의 영향과 기존 의존 정도를 검토하여 점진적으로 변경할 수 있다.
폐기 예정 기능
기능을 제거하거나 비호환적으로 변경할 때는 일반적으로 먼저 폐기 예정 상태를 알리고 사용자에게 이전 기간을 제공한다.
기존 기능 유지
→ 문서에서 폐기 예정 표시
→ 경고 또는 정적 진단 제공
→ 대체 기능 안내
→ 여러 기능 릴리스 동안 전환 기간
→ 이후 버전에서 제거
PEP 387은 일반적인 비호환 변경에 최소 두 개의 기능 릴리스에 걸친 폐기 기간을 요구한다. Steering Council이 특별한 이유로 예외를 승인할 수 있지만, 일반적인 변경에서는 충분한 전환 기간을 제공하는 것이 원칙이다.
실행 중에는 DeprecationWarning 등의 경고를 사용할 수 있다. 타입 시스템에서는 warnings.deprecated()로 API의 폐기 상태를 표시하여 타입 검사기와 IDE가 사용자에게 알릴 수도 있다.
단순히 새 기능 사용을 권장하지만 제거 계획이 없는 경우에는 soft deprecation으로 문서화할 수 있다. 이 경우 기존 기능은 유지되며 적극적인 신규 사용만 권장되지 않는다.
미래 기능
일부 비호환적인 언어 변경은 __future__ 문을 통해 새 동작을 미리 선택할 수 있게 한다.
from __future__ import annotations
__future__ 기능은 다음 목적을 가질 수 있다.
- 새 문법을 이전 버전의 파서가 인식하게 함
- 향후 기본값이 될 실행 의미를 미리 활성화함
- 라이브러리와 사용자가 이전 기간 동안 새 동작을 시험하게 함
- 코드가 어떤 의미를 기대하는지 명시함
모든 새 기능이 __future__를 사용하는 것은 아니다. 기존 코드의 의미를 바꾸지 않는 기능은 일반적인 버전 추가로 도입될 수 있다.
연간 릴리스 주기
Python은 PEP 602에 따라 매년 새로운 기능 버전을 공개하는 일정을 사용한다. 새로운 3.X.0 기능 릴리스는 일반적으로 매년 10월에 공개된다.[272]
하나의 기능 버전은 대략 다음 단계를 거친다.
개발 단계
→ 새로운 기능과 변경을 병합
Alpha
→ 새 기능 개발 계속
→ 초기 사용자 시험
Beta
→ 기능 동결
→ 버그 수정과 호환성 검증
Release Candidate
→ 최종 릴리스 후보
Final
→ 정식 기능 릴리스
Bugfix
→ 버그와 안정성 수정
Security
→ 보안 수정 중심
End of Life
→ 공식 지원 종료
Beta 1 이후에는 일반적으로 새로운 기능을 추가하지 않고 릴리스 안정화에 집중한다. 심각한 문제가 발견되지 않는 한 Release Candidate의 동작은 최종 릴리스와 가깝게 유지된다.
지원 기간
Python 기능 버전은 정식 공개 후 일정 기간 동안 버그 수정과 보안 지원을 받는다.
Python 3.13부터 적용되는 일반적인 구조는 다음과 같다.
정식 릴리스 후 약 2년
→ 전체 버그 수정 지원
→ 정기적인 bugfix 릴리스
이후 약 3년
→ 보안 수정
→ 필요에 따른 source-only 릴리스
총 약 5년 후
→ 지원 종료
각 버전의 구체적인 일정과 릴리스 관리자는 해당 버전의 Release Schedule PEP에 기록된다. 릴리스 관리자는 일정 조정, 릴리스 후보와 최종 배포, 유지보수 branch 관리에 책임을 가진다.
Python의 연간 릴리스는 새로운 기능을 비교적 빠르게 전달하지만, 라이브러리와 응용 프로그램은 여러 Python 버전을 동시에 지원해야 할 수 있다. 따라서 새로운 기능 도입 속도와 최소 지원 버전 정책을 함께 관리해야 한다.
버전 번호
Python 3의 기능 릴리스는 3.X 형태로 구분된다.
3.14.0
→ 새로운 기능이 포함된 최초 정식 릴리스
3.14.1
→ 같은 기능 계열의 bugfix 릴리스
3.14.2
→ 추가 bugfix 릴리스
마이크로 버전은 원칙적으로 호환성을 유지하면서 버그와 보안 문제를 수정한다. 새로운 문법이나 일반적인 새 기능은 다음 기능 버전에서 제공한다.
시험판은 다음과 같이 표시된다.
3.15.0a1
→ Alpha 1
3.15.0b1
→ Beta 1
3.15.0rc1
→ Release Candidate 1
시험판은 라이브러리와 도구 개발자가 호환성을 미리 확인하기 위한 용도로 중요하다. 운영 환경에서는 해당 시험판의 안정성과 지원 범위를 검토해야 한다.
별도의 장기 지원 버전
Python 프로젝트는 특정 기능 버전을 공식적인 장기 지원 릴리스로 지정하지 않는다. 각 기능 버전은 정해진 지원 주기에 따라 유지된다.
운영체제 배포판과 기업 공급자는 특정 Python 버전을 자체적으로 더 오랫동안 지원할 수 있다.
Python 프로젝트의 공식 지원 기간
≠ 운영체제 공급자의 지원 기간
≠ 상용 배포판의 장기 유지 기간
공급자가 보안 패치를 이전 버전에 backport하더라도 해당 배포판의 Python이 공식 최신 CPython과 같은 기능을 제공한다는 뜻은 아니다.
다중 구현체와 호환성
Python은 CPython 외에도 여러 구현체를 가질 수 있으므로 언어 설계는 가능한 한 특정 내부 구조에 종속되지 않아야 한다.
새로운 기능은 다음을 고려한다.
- 다른 가비지 컬렉션 방식
- JIT 컴파일
- JVM과 .NET 객체 모델
- 다른 스레드 모델
- 제한된 플랫폼
- 네이티브 확장 호환성
- 구현 가능한 성능 특성
CPython에서 쉽게 구현할 수 있다는 사실만으로 언어 기능이 채택되는 것은 아니다. 반대로 다른 구현체에서 구현 비용이 있다는 이유만으로 모든 기능이 거부되는 것도 아니다. 기능의 가치와 구현 복잡성을 전체 생태계 관점에서 판단한다.
구현체는 CPython의 내부 바이트코드를 그대로 사용할 의무가 없다. 동일한 소스 프로그램이 언어가 보장하는 범위 안에서 같은 결과와 관찰 가능한 동작을 제공하는 것이 중요하다.
C API의 안정성
CPython의 C API는 확장 모듈과 임베딩 응용 프로그램이 Python 런타임을 사용할 수 있게 한다. 그러나 모든 C API가 같은 안정성 수준을 갖는 것은 아니다.
대체로 다음 계층을 구분할 수 있다.
일반 C API
→ 특정 CPython 기능 버전에 맞춰 빌드
Limited API
→ 내부 구조에 대한 접근 제한
Stable ABI
→ 여러 Python 3 버전에서 바이너리 호환 목표
Unstable API
→ 고급 도구와 특수한 구현 목적
→ 마이너 버전 사이 변경 가능
Internal API
→ CPython 자체 구현용
→ 외부 사용 비권장
Python 언어 수준의 하위 호환성과 C 바이너리 호환성은 별도의 문제이다. 순수 Python 코드가 계속 동작하더라도 네이티브 확장 모듈은 새 Python 버전에 맞춰 다시 빌드해야 할 수 있다.
Stable ABI는 여러 CPython 기능 버전에서 같은 바이너리를 사용할 수 있게 하지만, 사용할 수 있는 API 범위와 최적화 방식에 제약이 있다.
타입 시스템 사양
Python의 타입 주석은 처음에는 PEP 484와 이후 여러 PEP에서 규정되었다. 기능이 확대되면서 타입 시스템의 규범적 내용을 별도의 Python Typing Specification으로 통합하는 작업이 진행되었다.[273]
타입 시스템 사양은 다음 주체가 공통으로 따를 의미를 정의한다.
- 타입 검사기
- IDE와 언어 서버
- 라이브러리 작성자
- 타입 스텁 작성자
- 코드 생성 및 분석 도구
Python의 타입 주석은 주로 정적 분석을 위한 점진적 타입 체계를 제공한다. Python 자체는 동적 타입 언어로 유지되며 타입 주석을 모든 코드에 의무화하는 것을 목표로 하지 않는다.[274]
Python 실행 의미
→ Python 언어 참조
정적 타입 의미
→ Python Typing Specification
실제 검사와 진단
→ mypy·Pyright·기타 타입 검사기
타입 검사기의 구체적인 오류 메시지나 모든 추론 전략이 사양에 포함되는 것은 아니다. 구현체별 차이가 존재할 수 있지만, 공개 타입 의미에 대해서는 상호 운용성을 높이는 방향으로 발전한다.
패키징 표준
Python 패키징은 언어 문법과 별도의 표준 영역을 가진다. 프로젝트 메타데이터, wheel, 의존성 표기, 패키지 색인 API와 lock file 형식 등은 Packaging PEP와 Python Packaging User Guide의 명세를 통해 관리된다.
패키징 표준은 pip, uv, Poetry, PDM, 빌드 backend와 패키지 색인 같은 여러 독립 도구가 상호 운용할 수 있게 한다.
패키징 명세
→ 공통 파일 형식과 인터페이스
도구 구현
→ pip·uv·build backend·package index
프로젝트 선택
→ 필요한 도구 조합
패키징 도구 하나의 동작이 곧 Python 언어 사양이 되는 것은 아니다. 특정 도구의 독자 기능과 여러 도구가 따라야 하는 상호 운용 표준을 구분해야 한다.
문서의 규범성
Python 관련 문서는 모두 같은 규범적 지위를 가지지 않는다.
언어 참조
→ 언어 문법과 실행 의미의 중심 문서
채택된 Standards Track PEP
→ 특정 기능과 표준의 설계 기록
표준 라이브러리 문서
→ 공개 API 계약
Typing·Packaging 사양
→ 전문 영역의 상호 운용 규칙
자습서와 HOWTO
→ 사용법과 권장 사례
CPython 개발자 안내서
→ 기준 구현체의 개발 절차
PEP가 Final 상태가 되었다고 해서 사용자가 항상 원본 PEP만을 참고해야 하는 것은 아니다. 기능이 배포된 뒤에는 최신 언어 참조와 라이브러리 문서가 실제 사용을 위한 중심 문서가 된다.
PEP는 해당 기능이 왜 도입되었고 어떤 대안이 검토되었는지 이해하는 역사적·설계적 자료로 남는다.
사실상의 표준
Python 생태계에는 공식 언어 사양은 아니지만 사실상의 표준으로 널리 사용되는 관례도 존재한다.
대표적으로 다음을 들 수 있다.
- PEP 8 기반 코드 스타일
pyproject.toml중심 프로젝트 구성- PyPI를 통한 공개 패키지 배포
- wheel 기반 바이너리 설치
pytest의 테스트 관례- 타입 스텁과
py.typed - ASGI와 WSGI 같은 생태계 인터페이스
사실상의 표준은 사용자가 많고 여러 도구가 지원한다는 점에서 영향력이 크지만, Python 언어 자체의 문법 규칙과는 구분된다. 특정 프레임워크나 도구의 동작을 Python 전체의 보장으로 오해해서는 안 된다.
설계의 보수성과 변화
Python은 지속적으로 새로운 기능을 도입하지만 기존 언어를 무제한으로 확장하는 것을 목표로 하지 않는다. 기능 하나를 추가하면 구현, 문서, 교육, 도구와 유지보수 비용이 장기간 남기 때문이다.
새로운 기능은 다음 질문을 통과해야 한다.
- 일반적인 문제를 해결하는가
- 기존 방식보다 충분히 명확한가
- 별도 라이브러리로 해결하기 어려운가
- 언어의 기존 개념과 일관되는가
- 학습 비용과 복잡성 증가를 정당화하는가
- 다른 구현체와 도구가 지원할 수 있는가
- 기존 코드의 의미를 손상하지 않는가
- 장기적으로 유지할 담당자가 있는가
이러한 보수성 때문에 일부 제안은 오랜 논의를 거치거나 거부된다. 반대로 실제 사용 경험과 구현 기술이 발전하면 과거에 거부된 아이디어와 비슷한 기능이 다른 형태로 다시 제안될 수도 있다.
발전 방식
Python의 언어 발전은 하나의 중앙 설계자가 모든 기능을 결정하는 방식에서 공개 공동체 거버넌스로 변화했다. 초기에는 귀도 반 로섬이 언어의 최종 결정권자인 Benevolent Dictator For Life, 즉 BDFL 역할을 맡았다. 2018년 그가 최종 결정 역할에서 물러난 뒤 여러 거버넌스 제안이 논의되었고, Steering Council 모델이 선택되어 PEP 13으로 정착되었다.
현재 Python은 공개 PEP, 토론, Core team 검토, Steering Council과 위임된 전문가의 결정을 통해 발전한다. 이 과정은 단순한 다수결만으로 이루어지지 않으며, 기술적 타당성, 공동체 합의, 구현 경험과 장기 유지 가능성을 함께 고려한다.
Python의 표준화는 국제 표준 문서 하나를 고정해 두는 방식보다 살아 있는 언어 참조, PEP 기록, 기준 구현체와 여러 전문 사양을 지속적으로 동기화하는 방식에 가깝다. 이 구조는 언어를 매년 발전시킬 수 있게 하면서도 하위 호환성 정책과 단계적인 폐기 절차를 통해 기존 생태계의 안정성을 유지하려 한다.
다른 프로그래밍 언어와의 관계
Python은 하나의 프로그래밍 언어를 그대로 계승하여 만들어진 언어가 아니다. 교육용 언어인 ABC에서 가독성과 고수준 자료형의 영향을 받았고, Modula-3에서 예외 처리의 구문과 의미를 참고했으며, C 및 UNIX 환경과 쉽게 결합할 수 있는 확장 가능한 언어로 설계되었다.[275]
이후 Python은 객체 지향, 함수형 프로그래밍, 반복자, 제너레이터, 비동기 처리, 구조적 패턴 일치와 점진적 타입 검사 등 여러 언어 계통에서 발전한 개념을 Python의 기존 객체 모델과 문법에 맞게 받아들였다.
직접적인 설계 기반
→ ABC
→ Modula-3
→ C와 UNIX 환경
공유하거나 받아들인 개념
→ Lisp 계열의 함수형 처리
→ CLU·Sather·Icon 계열의 반복자와 generator
→ ML·Haskell·Erlang 계열의 pattern matching
→ 정적 타입 언어의 generic과 type annotation
비슷한 활용 위치
→ Perl·Ruby·JavaScript
상호 보완하는 언어
→ C·C++·Rust·Fortran
→ Java·C#
→ R·Julia
Python은 다른 언어의 기능을 받아들이더라도 원래 언어의 실행 모델을 그대로 복제하지 않는다. 기능이 Python의 동적 객체 모델, 들여쓰기 중심 문법, 예외 체계와 기존 코드에 자연스럽게 결합될 수 있는지가 중요하게 고려된다.
ABC
ABC는 Python에 가장 직접적인 영향을 준 선행 언어이다. 귀도 반 로섬은 CWI에서 ABC 구현에 참여하면서 언어 설계 경험을 얻었으며, Python의 들여쓰기를 이용한 문장 그룹화와 매우 높은 수준의 기본 자료형은 이 경험에서 영향을 받았다고 설명했다.[276]
ABC는 전문 개발자가 아닌 사용자를 대상으로 명령을 간결하고 읽기 쉽게 표현하는 교육용 언어였다. Python도 코드의 시각적 구조와 읽기 쉬운 기본 문법을 중시한다는 점에서 ABC의 방향을 이어받았다.
그러나 Python은 ABC의 단순한 후속 버전이 아니다. ABC는 언어와 구현을 새로운 저수준 기능으로 확장하기 어려웠으며, 운영체제와 외부 라이브러리를 연결하는 범용 언어로 사용하기에는 제약이 있었다. Python은 이러한 한계를 보완하기 위해 모듈, 예외, 클래스와 C 확장 인터페이스를 포함하는 개방적인 구조로 설계되었다.[277]
ABC에서 이어받은 방향
→ 읽기 쉬운 문법
→ 들여쓰기 기반 블록
→ 고수준 자료형
→ 대화형 사용
Python에서 강화한 요소
→ 모듈과 패키지
→ 외부 언어 확장
→ 운영체제 접근
→ 객체 지향 프로그래밍
→ 범용 응용 프로그램 개발
Python은 ABC의 교육적 가독성을 유지하면서 UNIX와 C 개발 환경에서 실제 시스템을 구성할 수 있는 언어를 목표로 했다고 볼 수 있다.
Modula-3
Modula-3는 모듈성과 타입 안전성, 동시성 및 예외 처리를 중시한 시스템 프로그래밍 언어이다. 귀도 반 로섬은 Python의 예외 처리 구문과 의미가 Modula-3에서 유래했으며, 그 밖의 일부 기능도 영향을 받았다고 설명했다.[278]
두 언어 모두 오류를 특별한 반환값만으로 표현하지 않고 호출 스택을 따라 전달되는 예외로 처리한다. Python은 이를 동적 객체 모델과 결합하여 예외도 클래스와 객체로 표현한다.
Modula-3는 정적 타입과 컴파일 시 검사를 중심으로 하는 반면 Python은 실행 중 객체의 타입을 판별하는 동적 타입 언어이다. 따라서 Python이 Modula-3의 전체 언어 구조를 계승한 것은 아니며, 일부 설계 개념을 더 유연한 실행 환경에 맞게 수용했다.
C
C는 Python의 직접적인 문법적 선행 언어라기보다 Python의 구현과 확장 생태계를 형성한 기반 언어이다. 기준 구현체인 CPython의 핵심은 C로 작성되며, 운영체제 기능과 여러 표준 라이브러리 구성 요소도 C 인터페이스를 통해 제공된다.
Python과 C는 서로 다른 추상화 수준을 담당한다.
Python
→ 자동 메모리 관리
→ 동적 타입
→ 고수준 자료구조
→ 빠른 응용 프로그램 개발
C
→ 수동 메모리 관리
→ 고정된 자료 표현
→ 운영체제와 하드웨어 접근
→ 작은 runtime과 세밀한 성능 제어
Python은 C보다 함수와 자료구조를 간결하게 조합할 수 있지만, 객체 생성과 동적 디스패치에 추가 비용이 발생한다. C는 높은 제어력과 예측 가능한 자료 표현을 제공하지만 문자열·컨테이너·오류 처리와 자원 관리를 개발자가 더 직접 구현해야 한다.
Python 생태계에서는 성능이 중요한 계산이나 운영체제 연동을 C로 구현하고, Python에서 사용하기 쉬운 함수와 클래스로 제공하는 구조가 널리 사용된다. NumPy, 데이터베이스 드라이버와 여러 암호·압축 라이브러리가 이러한 역할 분담을 활용한다.
Python은 C와 비슷한 연산자와 일부 문장 구조를 사용하지만 다음과 같은 차이가 있다.
- Python의 블록은 중괄호가 아니라 들여쓰기로 표현된다.
- 변수 선언에 고정된 자료형을 지정하지 않는다.
- 포인터 연산을 일반 언어 기능으로 제공하지 않는다.
- 메모리를 명시적으로 해제하지 않는다.
- 배열과 문자열의 범위를 객체가 관리한다.
- 오류는 주로 정수 코드보다 예외로 전달된다.
- 정수는 일반적으로 고정 폭을 넘어서 확장될 수 있다.
Python은 C를 대체하는 저수준 시스템 언어로 설계되기보다, C로 구현된 시스템과 라이브러리를 조합하는 상위 언어로 발전했다.
C++
C++는 C의 저수준 제어에 클래스, 템플릿, RAII와 범용 프로그래밍 기능을 추가한다. Python과 C++는 모두 객체 지향 및 여러 패러다임의 프로그램을 작성할 수 있지만, 객체와 타입을 다루는 방식이 크게 다르다.
C++의 타입은 대부분 컴파일 시점에 결정되며 템플릿은 구체 타입에 맞는 코드를 생성한다. Python에서는 이름이 실행 중 객체에 바인딩되고 같은 함수가 프로토콜을 만족하는 여러 객체를 처리할 수 있다.
C++ generic programming
→ template 인스턴스화
→ compile-time type 검사
→ 자료형별 최적화 가능
Python generic programming
→ 공통 protocol 사용
→ runtime dispatch
→ duck typing
C++는 대형 데스크톱 응용 프로그램, 게임 엔진, 실시간 그래픽스와 성능 중심 라이브러리의 핵심 구현에 적합하다. Python은 그러한 C++ 시스템의 스크립트, 자동화, 테스트, 자산 파이프라인과 사용자 확장 기능에 사용될 수 있다.
pybind11, Cython과 Python/C API 등을 이용하면 C++ 클래스와 함수를 Python 객체처럼 제공할 수 있다. 이때 C++ 내부의 세밀한 템플릿·포인터 구조를 그대로 노출하기보다 Python의 예외, 반복자와 컨텍스트 관리 관례에 맞는 API를 제공하는 방식이 일반적이다.
Lisp 계열
Lisp 계열 언어는 함수 자체를 값으로 다루는 고차 함수, 재귀, 동적 언어 실행과 대화형 개발 문화의 발전에 큰 영향을 미쳤다. Python도 함수 객체, Lambda, 클로저, 고차 함수와 대화형 인터프리터를 제공하므로 함수형 방식의 일부를 사용할 수 있다.
Python 표준 라이브러리의 functools와 itertools, 반복자 및 컴프리헨션은 데이터를 함수형으로 변환하는 작업을 지원한다. 공식 Functional Programming HOWTO도 Python에서 함수형 스타일을 구현하는 방법을 별도로 설명한다.[279]
그러나 Python은 Lisp처럼 언어 전체를 함수 호출과 목록 구조로 통일하지 않는다.
Lisp 계열
→ 코드와 데이터의 동형성
→ macro 중심의 언어 확장
→ 재귀와 함수 합성 강조
Python
→ 문장과 표현식 구분
→ 제한적인 Lambda
→ iterator와 명령형 반복 병행
→ macro 체계를 기본 제공하지 않음
Python은 함수형 프로그래밍을 지원하지만 순수 함수형 언어는 아니다. 객체의 상태 변경, 명령형 반복문과 예외를 일반적인 언어 기능으로 사용한다.
ML·Haskell 계열
ML, Haskell과 관련 함수형 언어는 대수적 자료형, 타입 추론, 패턴 일치와 컴프리헨션 등의 발전에 영향을 미쳤다.
Python의 목록 컴프리헨션은 map()과 filter() 또는 명시적인 반복문으로 작성하던 목록 생성을 간결하게 표현한다. 이후 집합·사전 컴프리헨션과 제너레이터 표현식으로 확장되었다.[280][281]
형태는 Haskell 등의 컴프리헨션과 유사하지만 Python의 기존 for와 if 문장이 나타나는 순서에 맞게 설계되었다.
Python 3.10에서 도입된 구조적 패턴 일치도 Haskell, ML, Erlang, Scala와 여러 현대 언어에 존재하던 개념과 관계가 있다. Python의 설계 문서는 Haskell·Erlang·Scala·Elixir·Ruby 등 여러 언어의 패턴 일치 기능을 비교 대상으로 언급한다.[282]
그러나 Python의 match는 대수적 자료형만을 위한 기능이 아니다. 다음과 같은 Python 객체를 분해할 수 있도록 설계되었다.
- 시퀀스
- 매핑
- 클래스 인스턴스
- 리터럴
- 열거형 및 상수
- 중첩된 Python 자료구조
ML과 Haskell에서는 정적 타입 검사기가 패턴의 완전성을 검사할 수 있지만, Python의 패턴 일치는 동적 객체를 대상으로 실행된다. Python의 타입 검사기가 일부 분석을 제공할 수는 있어도 언어 실행 자체가 정적 완전성 검사를 요구하지는 않는다.
CLU·Sather·Icon
Python의 반복자와 제너레이터는 반복 상태를 직접 객체로 관리하거나 콜백을 사용하는 문제를 해결한다.
PEP 255는 다른 고수준 언어의 관련 기능으로 Sather의 반복자와 CLU의 반복자, 모든 표현식을 제너레이터로 볼 수 있는 Icon을 언급했다.[283]
Python은 함수 본문 안의 yield를 사용해 실행 상태를 중단하고 다시 이어 갈 수 있게 했다. 이 기능은 Python의 기존 함수, 반복자 프로토콜과 for 문에 결합되었다.
CLU·Sather
→ 반복을 추상화하는 iterator 개념
Icon
→ generator 중심 표현식
Python
→ `yield` 함수
→ iterator protocol
→ `for` 문과 자연스럽게 결합
이후 yield from, 제너레이터 표현식, 코루틴과 비동기 제너레이터로 개념이 확장되었다. Python의 제너레이터는 독립된 언어 체계라기보다 기존 함수가 특정 실행 방식을 갖도록 하는 형태이다.
Perl
Perl과 Python은 모두 UNIX 환경의 스크립팅, 문자열·파일 처리, 시스템 관리와 빠른 응용 프로그램 개발에 사용되어 왔다.
Perl은 정규 표현식, 텍스트 변환과 여러 문맥에 따라 동작이 달라지는 간결한 표현을 강하게 지원한다. Python은 같은 작업을 수행할 수 있지만, 가능한 한 코드의 구조와 연산을 명시적으로 표현하고 일반 문법과 라이브러리로 기능을 분리하는 경향이 강하다.
Perl
→ 텍스트 처리 중심의 강력한 내장 문법
→ 여러 축약 표현
→ 문맥에 따른 동작
Python
→ 일반 자료형과 객체 protocol 중심
→ 정규 표현식을 library로 제공
→ 가독성과 명시성 강조
두 언어 모두 CPAN과 PyPI라는 대규모 패키지 저장소를 중심으로 생태계를 확장했다. 다만 현대 Python은 웹, 과학 계산, 데이터 분석과 머신러닝 분야에서 더 넓은 공통 플랫폼으로 자리 잡았고, Perl은 기존 시스템 관리·텍스트 처리 및 장기간 운영되는 소프트웨어에서 계속 사용된다.
Python은 Perl의 직접적인 후계 언어는 아니지만, 많은 시스템 스크립트와 자동화 영역에서 Perl이 담당하던 역할의 일부를 이어받았다.
Ruby
Ruby와 Python은 동적 타입, 자동 메모리 관리, 객체 지향 프로그래밍, 예외 처리와 대화형 개발을 지원하는 범용 언어이다. 두 언어 모두 절차형으로 시작해 객체 지향 설계로 점진적으로 이동할 수 있다. Ruby 공식 문서도 Python과 Ruby가 이러한 공통점을 가진다고 설명한다.[284]
Ruby는 Smalltalk와 Lisp의 영향을 받아 거의 모든 값을 객체로 취급하고, 블록·메서드 호출과 metaprogramming을 유연하게 제공한다. Python도 모든 값을 객체로 다루지만, 특별한 동작을 __name__ 형태의 명시적인 특수 메서드로 표현하며 문법적 암시성을 비교적 제한한다.
Ruby
→ block 중심 iteration
→ open class와 metaprogramming
→ 여러 표현의 생략 허용
Python
→ iterator protocol과 comprehension
→ decorator와 명시적인 special method
→ 비교적 고정된 문장 구조
Ruby의 주요 웹 생태계는 Ruby on Rails를 중심으로 발전했다. Python에서는 Django, Flask, FastAPI 등 서로 다른 규모와 설계 방향의 프레임워크가 병존한다.
두 언어는 유사한 분야에서 비교되지만 문법 스타일과 객체 모델의 세부 설계가 다르다. Ruby는 표현의 유연성과 개발자의 즐거움을 강조하는 반면 Python은 한눈에 구조를 파악하기 쉬운 명시적인 코드를 더 강하게 선호한다.
Java
Java와 Python은 범용 응용 프로그램, 서버, 교육과 기업 시스템에서 사용된다. Java는 정적 타입, JVM 바이트코드와 클래스 중심 설계를 사용하고, Python은 동적 객체와 구현체별 실행 환경을 사용한다.
Java
→ 정적 명목 타입
→ JVM
→ compile-time 검사
→ interface와 class 선언
→ 비교적 명시적인 배포 구조
Python
→ 동적 타입
→ CPython 등 여러 구현체
→ runtime protocol
→ duck typing
→ 빠른 대화형 개발
Java에서는 변수와 메서드의 자료형을 일반적으로 명시하고 컴파일 시 검사한다. Python에서는 같은 함수가 특정 메서드나 연산을 제공하는 여러 객체를 처리할 수 있다. 현대 Python의 타입 주석은 Java와 비슷한 정적 분석 정보를 제공할 수 있지만 실행 시 동적 타입 모델은 유지된다.
Java는 장기간 운영되는 대형 서비스, Android의 역사적 생태계와 JVM 기반 기업 시스템에서 강점을 가진다. Python은 개발 속도, 자동화, 데이터 처리와 과학·인공지능 생태계에서 강점을 가진다.
Jython을 사용하면 Python 문법으로 Java 클래스와 JVM 생태계를 사용할 수 있지만 최신 CPython 언어 및 네이티브 패키지와의 호환 범위는 별도로 고려해야 한다.
C#
C#은 .NET 환경의 정적 타입 범용 언어이며, 객체 지향과 제네릭, LINQ, 비동기 처리 및 pattern matching을 제공한다. Python과 C#은 높은 수준의 응용 프로그램과 웹 API, 자동화 및 데이터 처리에 사용할 수 있지만 주된 실행 환경이 다르다.
C#은 compiler와 .NET runtime의 정적 타입 정보를 이용하여 IDE 리팩터링, generic 최적화와 배포 검사를 제공한다. Python은 실행 중의 동적 변경과 간결한 대화형 개발을 허용한다.
LINQ의 query 표현과 Python의 comprehension은 컬렉션을 선언적으로 변환한다는 점에서 비슷한 역할을 한다. C#의 async·await 문법과 Python의 async·await도 비동기 작업을 순차적인 코드 형태로 표현하지만, Task와 coroutine의 구체적인 타입 및 scheduler는 서로 다르다.
IronPython은 Python을 .NET 위에서 실행하며 .NET 타입을 직접 사용할 수 있게 한다. CPython에서는 pythonnet과 프로세스 간 통신을 통해 .NET 시스템과 결합할 수 있다.
JavaScript
JavaScript와 Python은 모두 동적 타입 언어이며 함수, 객체와 자동 메모리 관리를 제공한다. 문법이 비교적 접근하기 쉽고 웹 개발 교육에서 자주 사용되지만, 처음 설계된 실행 환경은 다르다.
JavaScript는 웹 브라우저의 기본 프로그래밍 언어이며 Node.js를 통해 서버와 도구 개발에도 사용된다. Python은 운영체제의 일반 응용 프로그램, 서버, 데이터 분석과 과학 계산을 중심으로 성장했다.
JavaScript
→ browser와 web platform
→ prototype 기반 객체
→ event loop 중심 비동기 생태계
→ npm
Python
→ 범용 운영체제 환경
→ class와 protocol 중심 객체
→ 동기 API와 asyncio 병존
→ PyPI
JavaScript 객체는 prototype chain을 중심으로 상속과 속성 검색을 수행한다. Python 객체는 클래스, MRO와 디스크립터 프로토콜을 중심으로 동작한다.
JavaScript의 Promise와 Python의 coroutine은 모두 비동기 결과를 표현할 수 있지만 동일한 객체는 아니다. JavaScript에서는 비동기 API와 이벤트 루프가 웹 플랫폼의 기본 요소이며, Python에서는 동기 라이브러리와 asyncio 기반 비동기 라이브러리가 함께 존재한다.
웹 애플리케이션에서는 Python이 서버와 데이터 처리, JavaScript가 브라우저 사용자 인터페이스를 담당하는 조합이 흔하다.
TypeScript
TypeScript는 JavaScript에 정적 타입 검사와 언어 도구를 추가하고 JavaScript로 변환되는 언어이다. Python의 타입 주석 및 정적 타입 검사 생태계와 비교되는 경우가 많다.
두 체계 모두 기존 동적 언어에 점진적 타입 검사를 도입한다.
TypeScript
→ JavaScript의 정적 상위 집합
→ TypeScript compiler가 JavaScript 생성
→ type 정보는 일반적으로 runtime에서 제거
Python type hint
→ 기존 Python 문법 안의 annotation
→ 별도 type checker가 검사
→ Python interpreter는 동적으로 실행
Python의 타입 주석은 mypy, Pyright와 IDE가 검사하지만 일반적으로 코드 실행을 막지 않는다. TypeScript는 compiler가 type 검사와 JavaScript 출력을 함께 담당하지만 설정에 따라 오류가 있어도 출력을 생성할 수 있다.
TypeScript는 구조적 타입을 널리 사용한다. Python의 Protocol도 객체가 어떤 멤버를 제공하는지를 기준으로 구조적 부분 타입을 표현할 수 있다.
두 언어 모두 동적 생태계와의 호환성을 유지하기 위해 완전한 정적 타입 안전성보다 점진적인 도입을 목표로 한다.
Go
Go와 Python은 간결한 문법, 자동 메모리 관리와 실용적인 개발 도구를 강조하지만, 실행 및 동시성 모델은 크게 다르다.
Go는 정적 타입 언어이며 네이티브 실행 파일로 컴파일된다. goroutine과 channel을 언어 및 runtime의 핵심 동시성 기능으로 제공한다.
Python은 여러 종류의 동시성 모델을 제공한다.
Go
→ goroutine
→ channel
→ native thread scheduler
→ 단일 실행 파일 중심 배포
Python
→ thread
→ process
→ asyncio coroutine
→ 다중 interpreter
→ interpreter와 package 환경
Go는 네트워크 서버, 클라우드 기반 시설, 명령행 도구와 하나의 실행 파일 배포가 중요한 시스템에 자주 사용된다. Python은 데이터 처리, 자동화, 인공지능과 빠른 서비스 개발에서 강점을 가진다.
Python 서비스가 데이터 분석과 모델 추론을 담당하고, Go 서비스가 고동시성 네트워크 gateway나 운영 도구를 담당하는 식으로 함께 사용되기도 한다.
Rust
Rust와 Python은 안전성과 생산성을 서로 다른 수준에서 추구한다. Rust는 소유권과 대여 검사를 통해 가비지 컬렉터 없이 메모리 안전성을 제공하며, Python은 자동 메모리 관리와 동적 객체를 통해 메모리 세부 사항을 감춘다.
Rust
→ 정적 타입
→ ownership과 borrowing
→ native compilation
→ low-level control
→ `Result` 기반 오류 처리
Python
→ 동적 타입
→ garbage collection
→ 고수준 객체
→ 빠른 개발
→ exception 기반 오류 처리
Rust의 pattern matching은 열거형과 구조체의 정적 타입 정보에 밀접하게 연결된다. Python의 구조적 패턴 일치는 실행 중 시퀀스, 매핑과 클래스 객체를 검사한다. Python의 pattern matching 설계 문서에서도 Rust는 비교 대상 언어 가운데 하나로 언급된다.[285]
Rust는 Python 확장 모듈과 개발 도구의 구현 언어로도 빠르게 활용되고 있다. uv, Ruff, Polars와 여러 tokenizer·parser 프로젝트는 성능과 메모리 안전성이 필요한 핵심을 Rust로 작성하고 Python API를 제공한다.
Python은 사용자가 쉽게 접근하는 고수준 인터페이스를 담당하고 Rust는 계산 및 시스템 구현을 담당하는 조합을 만들 수 있다. PyO3와 maturin은 이러한 패키지를 Python 생태계에 배포하는 대표적인 도구이다.
Swift와 Kotlin
Swift와 Kotlin은 기존 플랫폼에서 더 안전하고 간결한 응용 프로그램 개발을 제공하기 위해 만들어진 현대 정적 타입 언어이다.
Swift는 Apple 플랫폼과 네이티브 응용 프로그램 개발을 중심으로 하며, 값 타입, 프로토콜, optional과 자동 참조 계수를 제공한다. Kotlin은 JVM과 Android 생태계를 중심으로 Java와의 상호 운용성, null 안전성과 coroutine을 제공한다.
두 언어 모두 Python보다 정적 타입과 플랫폼 통합이 강하다. Python은 특정 모바일 또는 데스크톱 플랫폼의 공식 응용 프로그램 언어가 아니며, 데이터 처리와 자동화 및 서버 측 기능에서 더 일반적으로 사용된다.
Python의 Protocol과 Swift의 protocol, Python의 coroutine과 Kotlin coroutine은 이름이나 역할이 유사할 수 있지만 구체적인 타입 체계와 runtime 계약은 다르다.
모바일 응용 프로그램에서는 Swift나 Kotlin이 사용자 인터페이스와 플랫폼 기능을 담당하고, Python 서비스가 서버·데이터 분석·인공지능 기능을 제공하는 구조가 흔하다.
R
R과 Python은 통계 분석, 데이터 시각화와 데이터 과학에서 함께 사용된다.
R은 통계 계산과 데이터 분석을 주목적으로 설계되었고, vector 연산, formula 문법과 통계 모델링 패키지를 중심으로 발전했다. Python은 범용 언어로 시작했지만 NumPy, pandas, SciPy, scikit-learn과 시각화 도구를 통해 데이터 과학 분야로 확장되었다.
R
→ 통계 분석과 모델링 중심
→ vector와 data frame 중심 언어 설계
→ CRAN
Python
→ 범용 프로그래밍
→ 데이터·웹·자동화·AI를 같은 언어로 연결
→ PyPI
통계 연구와 특정 분석 패키지에서는 R이 강점을 가질 수 있다. 분석 결과를 웹 서비스, 자동화 도구나 일반 소프트웨어 시스템에 통합하는 작업에서는 Python의 범용 생태계가 유리할 수 있다.
두 언어는 경쟁 관계로만 사용되지 않는다. rpy2, notebook, 파일 형식과 별도 서비스 등을 통해 Python 작업 흐름에서 R 분석을 호출하거나 그 반대로 연결할 수 있다.
Julia
Julia는 과학·수치 계산을 위해 설계된 고성능 동적 언어이다. Python과 비슷하게 대화형 작업과 간결한 고수준 코드를 제공하지만, 다중 디스패치와 JIT 컴파일을 언어 설계의 중심에 둔다.
Python의 일반 함수 호출은 객체 메서드와 연산자 프로토콜 또는 제한적인 단일 디스패치를 주로 사용한다. Julia는 모든 인자의 타입 조합을 기준으로 함수 구현을 선택하는 다중 디스패치를 광범위하게 사용한다.
Python
→ 객체 protocol과 method 중심
→ CPython에서는 interpreter와 선택적 JIT
→ 성능 핵심은 native extension에 위임하는 경우가 많음
Julia
→ multiple dispatch
→ JIT compilation
→ 고수준 수치 코드를 직접 최적화하는 방향
Julia는 고성능 수치 알고리즘을 하나의 언어로 작성하려는 목적이 강하다. Python은 기존 C·C++·Fortran 생태계를 감싸는 방식으로 과학 계산 생태계를 성장시켰다.
Python은 더 넓은 범용 패키지와 머신러닝 생태계를 가지고 있으며, Julia는 언어 자체에서 수치 계산과 generic 성능을 통합하려는 특징을 가진다.
MATLAB
MATLAB과 Python은 행렬 계산, 신호 처리, 수치 시뮬레이션, 그래프와 공학 교육에서 비교된다.
MATLAB은 행렬 중심의 전용 언어와 통합 개발 환경, 분야별 상용 toolbox를 제공한다. Python에서는 NumPy, SciPy, Matplotlib, Jupyter와 분야별 패키지를 조합하여 유사한 작업을 수행한다.
Python은 오픈 소스 범용 언어이므로 웹, 자동화, 데이터베이스와 일반 응용 프로그램 개발을 같은 환경에서 수행할 수 있다. MATLAB은 MathWorks 제품 및 Simulink와의 일관된 통합, 검증된 산업용 toolbox에서 강점을 가진다.
기존 MATLAB 코드와 Python을 함께 사용하는 공식 연동 도구 및 파일 형식을 활용할 수 있으며, 연구 기관에서는 단계적으로 Python 생태계로 옮기거나 두 환경을 병행하기도 한다.
셸 스크립트
Python은 Bash, PowerShell과 같은 셸 스크립트 언어와 자동화 영역에서 겹친다.
셸은 프로그램 실행, 파이프 연결, 파일 이동과 짧은 시스템 작업을 매우 간결하게 표현한다. Python은 복잡한 자료구조, 오류 처리, 테스트와 플랫폼 독립성이 필요한 자동화에서 유리하다.
셸 스크립트에 적합한 경우
→ 짧은 명령 조합
→ pipeline
→ 운영체제 도구 중심 작업
Python에 적합한 경우
→ 복잡한 조건과 데이터
→ 외부 API
→ 구조화된 오류 처리
→ 단위 테스트
→ 여러 운영체제 지원
Python에서도 subprocess를 통해 외부 명령을 실행할 수 있지만, 셸의 문자열 명령을 그대로 조립하면 인용과 보안 문제가 생길 수 있다. 가능하면 Python 라이브러리와 인자 목록을 사용해 명시적으로 실행한다.
간단한 셸 스크립트가 점차 커지면서 상태와 복잡한 오류 처리가 늘어나면 Python 프로그램으로 전환하는 경우가 많다.
정적 타입 언어와의 관계
Python은 동적 타입 언어이지만 현대 Python은 타입 주석과 별도의 정적 분석 사양을 제공한다. 이는 Java, C#, Rust, Go와 같은 정적 타입 언어의 장점을 일부 개발 과정에 도입한다.
PEP 484는 Python 코드에 표준 타입 표기법을 제공하여 정적 분석과 리팩터링, IDE 지원을 개선하는 것을 주된 목적으로 삼았다.[286]
전통적인 정적 타입 언어
→ compiler가 type 검사
→ 오류가 build를 차단
→ 실행 code 생성에 type 활용
Python type annotation
→ 별도 검사기가 분석
→ interpreter는 annotation 없이도 실행 가능
→ 점진적으로 도입 가능
이 구조는 기존 동적 Python 코드와의 호환성을 유지하면서 대규모 코드베이스의 문서화와 도구 지원을 개선한다.
그러나 Python 타입 체계가 정적 언어와 완전히 같아지는 것은 아니다. runtime monkey patching, 동적 import와 metaclass 등은 정적 분석을 어렵게 하며, 일부 프로그램은 의도적으로 타입 검사를 사용하지 않을 수 있다.
Python이 다른 언어에 미친 영향
Python의 문법과 개발 문화는 후대 언어와 도구 설계에도 영향을 주었다. 특히 다음 요소가 널리 참고되었다.
- 들여쓰기를 문법적 블록으로 사용하는 방식
- 컴프리헨션
- 간결한 반복자와 제너레이터
- 읽기 쉬운 표준 라이브러리 API
- 대화형 실행과 스크립팅
- 배터리 포함형 기본 배포
- 점진적 타입 주석
- 객체 프로토콜을 통한 확장
모든 유사한 기능이 Python에서 직접 유래했다고 단정할 수는 없다. 컴프리헨션, 제너레이터와 패턴 일치 같은 개념은 Python 이전의 여러 언어에도 존재했고, 현대 언어들은 서로의 경험을 참고하며 비슷한 문제를 독립적으로 해결한다.
Python의 가장 큰 영향은 개별 문법보다 가독성이 높은 고수준 언어가 교육용 스크립트부터 웹 서비스, 과학 계산과 인공지능까지 하나의 생태계로 확장될 수 있음을 보여 준 데 있다.
언어 선택과 역할 분담
Python과 다른 언어의 관계는 하나를 선택하면 다른 하나를 완전히 배제해야 하는 경쟁 관계만으로 설명하기 어렵다.
실제 시스템은 여러 언어로 구성될 수 있다.
Python
→ 데이터 처리
→ 자동화
→ 고수준 API
→ 모델과 업무 로직
C·C++·Rust
→ 성능 중심 library
→ runtime과 system 구성 요소
Java·C#
→ 기업 platform과 장기 실행 service
JavaScript·TypeScript
→ browser와 frontend
R·Julia·Fortran
→ 통계와 수치 계산의 전문 기능
셸
→ 배포 및 운영 명령 연결
Python은 외부 언어와 시스템을 연결하는 기능이 강하기 때문에, 가장 성능이 중요한 모든 부분을 Python으로 다시 작성하기보다 기존 구현을 Python 인터페이스에서 재사용하는 방향으로 발전했다.
어떤 언어가 더 우수한지를 일반적으로 결정할 수는 없다. 다음 조건에 따라 적합한 언어가 달라진다.
- 대상 플랫폼
- 실행 속도와 지연 시간
- 메모리 및 실행 파일 크기
- 실시간성
- 기존 라이브러리와 시스템
- 개발 팀의 경험
- 정적 검사의 필요성
- 배포 방식
- 장기간의 유지보수
- 사용자 확장과 자동화 요구
Python은 빠른 개발, 가독성, 방대한 라이브러리와 외부 시스템 통합이 중요한 분야에서 강점을 가진다. 반면 엄격한 실시간 처리, 제한된 장치, 운영체제 커널이나 작은 네이티브 바이너리가 필요한 영역에서는 다른 언어가 핵심 구현을 담당할 수 있다.
Python의 발전은 다른 언어의 개념을 무분별하게 모으는 방식이 아니라, Python의 기존 문법과 객체 모델 안에서 충분히 일반적이고 읽기 쉬운 형태로 다시 설계하는 과정으로 이루어졌다. 이 때문에 Python은 여러 언어 계통과 기능을 공유하면서도 독자적인 문법과 개발 문화를 유지한다.
영향
Python은 처음부터 세계적으로 널리 사용되는 주류 언어였던 것은 아니다. 1990년대에는 UNIX 환경의 개발자와 연구자, 시스템 통합 담당자 및 일부 전문 소프트웨어 기업을 중심으로 알려졌으며, 당시의 C·C++·Perl·Tcl·Java 같은 언어와 비교하면 사용 기반이 작았다.
그러나 Python은 읽기 쉬운 문법, 빠른 개발 속도, C로 작성된 기존 라이브러리를 연결할 수 있는 확장성, 운영체제 사이의 이식성과 공개 개발 공동체를 바탕으로 점차 활용 범위를 넓혔다. 이후 웹 프레임워크, 과학 계산 라이브러리, 패키지 저장소, 교육 환경과 인공지능 프레임워크가 차례로 성장하면서 특정 분야의 스크립트 언어를 넘어 현대 소프트웨어 개발의 주요 언어 가운데 하나가 되었다.
Python의 영향은 단순히 사용자가 많아졌다는 데 그치지 않는다. Python은 프로그래밍 교육의 입문 언어, 과학자와 분석가가 직접 사용하는 연구 언어, 네이티브 라이브러리의 고수준 인터페이스, 자동화와 시스템 통합을 위한 접착 언어, 인공지능 연구와 제품 개발의 공통 환경으로 자리 잡았다.
초기의 위치
Python은 1991년 처음 공개되었지만, 초기에는 소수의 인터넷 및 UNIX 개발 공동체를 중심으로 전파되었다. 당시 개인용 컴퓨터와 기업 개발 환경에서는 C·C++, Visual Basic, Pascal과 이후의 Java가 널리 사용되었고, UNIX 스크립팅에서는 셸과 Perl이 강한 위치를 차지하고 있었다.
Python은 이들과 정면으로 경쟁하는 시스템 구현 언어나 상용 응용 프로그램 개발 환경으로 시작하지 않았다. 대신 다음과 같은 간격을 메우는 언어로 주목받았다.
셸 스크립트보다 구조화된 프로그램 작성
→ Python
C·C++보다 빠른 기능 구현
→ Python
기존 C 라이브러리와 운영체제 기능 연결
→ Python
대화형 실험과 자동화
→ Python
작은 스크립트에서 시작한 코드를 모듈과 클래스가 있는 비교적 큰 프로그램으로 확장할 수 있다는 점은 셸 스크립트와 비교되는 장점이었다. 동시에 CPython의 C 확장 인터페이스를 통해 성능이 필요한 기능은 네이티브 코드로 구현할 수 있었기 때문에, 단순한 교육용 언어에 머무르지 않고 실제 제작 환경으로 들어갈 수 있었다.
초기 Python은 오늘날처럼 웹·데이터 과학·인공지능을 대표하는 언어가 아니라, 시스템을 자동화하고 여러 프로그램을 연결하며 빠르게 내부 도구를 만드는 실용적인 언어로 성장했다.
대중화 이전의 전문 분야
Python이 일반 개발자와 교육 현장에 널리 알려지기 전부터 일부 분야에서는 실제 제작과 운영에 사용되고 있었다.
대표적인 초기 활용 분야는 다음과 같다.
- UNIX 시스템 관리와 파일 처리
- 소프트웨어 빌드 및 테스트 자동화
- 네트워크 도구와 서버 관리
- 그래픽스 및 시각 효과 제작 파이프라인
- 과학 계산과 수치 실험
- 기업 내부 업무 도구
- C·C++ 프로그램의 확장 및 스크립팅
- 웹 애플리케이션과 콘텐츠 관리 시스템
시각 효과 제작사 ILM은 1996년부터 복잡해지는 영화 제작 파이프라인을 관리하기 위해 Python을 도입했다. 당시 ILM은 UNIX 셸 스크립트를 사용하고 있었지만, 컴퓨터 그래픽스 작업 규모와 소프트웨어 구성 요소가 증가하면서 더 구조적이고 확장 가능한 도구가 필요해졌다.[287]
ILM의 사례는 Python이 대중적인 웹 언어나 교육 언어가 되기 전부터 다음 역할에 적합했음을 보여 준다.
여러 운영체제와 프로그램 연결
→ 제작 파이프라인 구성
반복 작업 자동화
→ 장면·자산·렌더링 처리
비개발자와 기술 담당자가 함께 사용
→ 비교적 읽기 쉬운 스크립트
성능 중심 프로그램 유지
→ 기존 C·C++ 및 그래픽스 도구와 연동
이와 비슷하게 과학자와 공학자들은 Python의 대화형 실행과 확장성을 이용해 기존 Fortran·C 수치 라이브러리를 감싸기 시작했다. 이러한 초기 사용자들은 Python이 대중화되기 전부터 언어의 실용성과 라이브러리 기반 발전 가능성을 증명했다.
오픈 소스 공동체의 형성
Python의 초기 성장은 대규모 기업의 단일한 마케팅이나 독점적인 개발 환경보다 인터넷 기반 오픈 소스 공동체에 의해 이루어졌다.
사용자는 메일링 리스트와 뉴스그룹에서 언어 기능과 라이브러리를 논의했고, 여러 운영체제로 Python을 이식했으며, 문서·도구·확장 모듈을 공유했다. 언어 사용자가 동시에 라이브러리 작성자와 핵심 개발 기여자가 되는 구조가 형성되었다.
Python 공동체의 발전은 다음과 같은 기반을 만들었다.
- 공개된 소스 코드
- 무료 상업적 이용이 가능한 라이선스
- 여러 운영체제 지원
- 온라인 문서와 자습서
- 사용자 그룹과 학술·개발자 회의
- 공개적인 언어 개선 제안
- 제삼자 패키지의 공유
- 초보자와 전문 개발자가 함께 참여하는 문화
2001년 설립된 PSF은 Python의 지식재산과 상표, 공동체 기반 시설 및 행사를 지원하는 법적·재정적 기반을 제공했다. 현재 PSF는 Python.org, PyPI와 공식 문서 등 Python 공동체가 사용하는 주요 서비스를 지원한다.[288]
이러한 공동체 기반은 특정 기업의 제품 전략이 바뀌더라도 Python 언어와 생태계가 독립적으로 지속될 수 있게 했다.
1990년대 후반의 실용 언어 정착
1990년대 후반 Python은 여전히 전체 프로그래밍 시장의 주류는 아니었지만, 단순한 실험 언어를 넘어 실제 업무에 사용할 수 있는 언어로 인정받기 시작했다.
당시 Python이 선택된 주요 이유는 다음과 같다.
- Perl보다 비교적 일정하고 읽기 쉬운 문법
- 셸 스크립트보다 큰 프로그램을 구성하기 쉬운 모듈과 클래스
- C와 C++보다 짧은 개발 시간
- Windows와 여러 UNIX 계열 운영체제 지원
- 자동 메모리 관리
- 네트워크·파일·문자열 처리를 포함한 표준 라이브러리
- C 확장 및 임베딩 기능
- 소스 코드를 바로 수정하고 시험할 수 있는 대화형 환경
이 시기의 Python은 최종 소비자에게 직접 보이는 제품보다 기업과 연구 기관 내부의 자동화 및 도구 개발에서 먼저 신뢰를 얻었다. 개발자가 짧은 기간에 도구를 만들고 이후 필요에 따라 확장할 수 있다는 점은 변화가 잦은 내부 업무에 특히 적합했다.
웹 개발을 통한 확장
1990년대 후반과 2000년대 초반에는 인터넷 서비스와 웹 애플리케이션이 빠르게 성장했다. Python은 CGI 프로그램, 웹 서버, 콘텐츠 관리와 애플리케이션 프레임워크에 사용되면서 일반 소프트웨어 개발자에게 더 널리 알려졌다.
Zope는 Python 기반의 초기 웹 애플리케이션 서버와 콘텐츠 관리 환경으로 사용되었고, 이후 여러 웹 프레임워크가 등장했다.
2000년대 중반에는 Django가 공개되면서 Python이 데이터베이스 기반 웹 애플리케이션을 빠르게 구축할 수 있는 언어로 크게 주목받았다. Flask와 이후의 여러 경량 프레임워크는 작은 서비스와 API 개발에도 Python의 사용을 확대했다.
웹 생태계의 성장은 다음과 같은 영향을 주었다.
내부 자동화 언어
→ 외부 사용자가 이용하는 웹 서비스 개발 언어
개별 스크립트
→ 장기간 운영되는 서버 응용 프로그램
소규모 사용자 공동체
→ 스타트업과 웹 개발자 유입
Ruby on Rails, PHP와 Java 웹 프레임워크가 강한 경쟁 환경을 형성했지만, Python은 문법의 접근성, 다양한 프레임워크 선택지와 기존 시스템 연동 능력을 통해 일정한 위치를 확보했다.
과학 계산 생태계의 성장
Python의 장기적인 영향에서 가장 중요한 변화 가운데 하나는 과학 계산과 데이터 분석 생태계의 형성이다.
초기 과학 계산 사용자는 Python 자체의 실행 속도보다 Python이 네이티브 수치 라이브러리를 연결하는 방식에 주목했다. 배열 연산과 계산 핵심은 C·C++·Fortran으로 수행하고, Python에서는 계산 절차와 데이터 분석을 표현하는 구조가 발전했다.
NumPy는 여러 초기 배열 프로젝트를 통합하면서 과학 계산을 위한 공통 배열 기반을 제공했다. SciPy, Matplotlib, IPython과 분야별 패키지는 그 위에서 성장했다.
이 구조는 과학자에게 다음과 같은 변화를 가져왔다.
별도의 저수준 프로그램 작성
→ Python에서 기존 수치 라이브러리 호출
계산과 그래프 도구 분리
→ 하나의 대화형 환경에서 분석
전용 상용 환경 의존
→ 공개 Python 패키지로 연구 도구 공유
개인 연구 스크립트
→ 재사용 가능한 오픈 소스 라이브러리
Python은 수치 계산 전용 언어는 아니었지만, 범용 언어이면서 과학 계산 패키지를 쉽게 결합할 수 있다는 점이 강점이 되었다. 연구 결과를 웹 서비스, 데이터베이스, 자동화 프로그램 및 일반 소프트웨어와 같은 언어로 연결할 수 있었기 때문이다.
패키지 생태계의 영향
Python의 대중화에는 언어 자체만큼이나 제삼자 패키지를 쉽게 검색하고 설치할 수 있는 생태계가 중요한 역할을 했다.
PyPI와 패키징 도구가 성장하면서 사용자는 필요한 기능을 직접 처음부터 구현하지 않고 공개 패키지에서 찾을 수 있게 되었다.
문제 발견
→ PyPI에서 패키지 검색
→ pip로 설치
→ Python API로 조합
이 과정은 Python을 특정 분야의 언어로 제한하지 않았다. 웹, 과학, 문서 처리, 네트워크, GUI, 데이터베이스와 자동화 패키지가 같은 저장소와 설치 체계를 공유하면서 서로 다른 사용자 집단이 하나의 생태계에 모였다.
Python 공식 소개도 언어가 여러 시스템과 잘 결합하고, 배우기 쉬우며, 공개적으로 사용할 수 있다는 점을 주요 특성으로 내세운다.[289]
패키지 생태계가 커질수록 Python을 선택할 이유도 커졌고, 사용자가 많아질수록 다시 새로운 패키지가 만들어지는 네트워크 효과가 형성되었다.
Python 2의 전성기
2000년에 Python 2.0이 공개된 뒤 Python 2 계열은 2000년대와 2010년대 초반까지 Python 생태계의 중심이 되었다.
이 시기에 다음과 같은 영역이 크게 발전했다.
- 웹 프레임워크
- 시스템 관리와 DevOps
- 과학 계산
- 데스크톱 도구
- 기업 내부 시스템
- 테스트와 빌드 자동화
- 클라우드 및 인터넷 기업의 백엔드
- 교육용 프로그래밍
Python 2.7은 오랫동안 많은 운영체제와 기업 시스템의 기본 Python으로 사용되었다. 여러 Linux 배포판과 UNIX 계열 시스템에 Python이 기본 설치되거나 시스템 도구의 의존성으로 포함되면서, 별도로 설치하지 않아도 사용할 수 있는 환경이 늘어났다.
Python 공식 초보자 안내에서도 여러 Linux와 UNIX 배포판에 Python이 포함되어 있다는 점을 설명한다.[290]
운영체제에 기본 포함된 Python은 시스템 관리자와 학생, 연구자가 언어를 쉽게 접하게 했지만, 동시에 운영체제 내부 도구가 특정 Python 버전에 의존하는 문제도 만들었다.
Python 3 전환의 영향
2008년 공개된 Python 3은 Unicode와 언어 일관성, 오래된 설계 문제를 개선하기 위한 비호환적 개정이었다. 그러나 Python 2 프로그램과 모든 라이브러리가 자동으로 호환되지 않았기 때문에 생태계는 오랫동안 두 계열로 나뉘었다.
이 전환은 Python의 성장 과정에서 큰 부담이었다.
Python 2
→ 기존 사용자와 방대한 라이브러리
Python 3
→ 개선된 언어 설계와 미래 개발
결과
→ 라이브러리의 이중 지원
→ 문서와 교육 자료의 혼재
→ 기업의 이전 비용
→ 신규 사용자의 버전 혼란
일부 조직은 Python 3 지원이 충분해질 때까지 이전을 미뤘고, 이 기간에 Python이 분열되었다는 비판도 제기되었다.
반면 주요 프레임워크와 과학 라이브러리, 운영체제 배포판이 Python 3을 지원하고 Python 2 지원을 단계적으로 종료하면서 생태계는 다시 통합되었다. Python 2.7은 2020년 공식 지원이 종료되었고, 이후 Python 3이 사실상 유일한 현대 Python 계열로 자리 잡았다.
비호환 전환은 단기적으로 생태계에 비용을 만들었지만, 장기적으로는 Unicode, 반복자, 정수 처리와 표준 라이브러리의 일관성을 개선하고 이후 언어 기능을 발전시킬 기반을 마련했다.
교육 언어로의 확산
Python은 처음부터 어린이와 초보자를 위한 언어만을 목표로 하지는 않았지만, 읽기 쉬운 문법과 짧은 초기 프로그램, 대화형 실행 환경 때문에 교육 분야에서 빠르게 확산되었다.
교육 기관은 다음 이유로 Python을 채택했다.
- 선언과 기호가 비교적 적은 문법
- 들여쓰기를 통한 시각적으로 명확한 블록
- 설치 후 바로 사용할 수 있는 대화형 인터프리터
- 문자열·목록·사전과 큰 정수의 기본 지원
- 알고리즘과 문제 해결에 집중하기 쉬운 표현
- 같은 언어를 고급 과정과 실제 산업에서도 계속 사용 가능
- 무료 도구와 교육 자료
- 과학·웹·게임·인공지능으로 이어지는 확장성
Python은 초급 문법을 배운 뒤 별도의 언어로 이동하지 않아도 데이터 분석, 웹 개발과 머신러닝 프로젝트로 이어질 수 있었다. 이는 교육용으로 단순하지만 산업 활용이 제한적인 일부 언어와 구별되는 특징이었다.
대학의 컴퓨터 과학 입문 과정, 데이터 과학 수업, 온라인 교육 서비스와 학교의 프로그래밍 교육에서 Python의 채택이 늘어났다. Raspberry Pi, MicroPython과 CircuitPython도 Python 계열 문법을 전자 제작과 하드웨어 교육으로 확장했다.
데이터 과학을 통한 대중화
Python이 일반 대중과 비전통적인 개발 직군에 널리 퍼진 결정적인 계기는 2010년대의 데이터 과학 성장이다.
이전까지 프로그래밍 언어의 주요 사용자는 전문 소프트웨어 개발자였지만, Python은 다음 사용자 집단으로 빠르게 확산되었다.
- 데이터 분석가
- 통계 연구자
- 자연과학자
- 금융 분석가
- 머신러닝 연구자
- 의료·생명과학 연구자
- 대학원생과 학생
- 업무 자동화를 수행하는 비전문 개발자
pandas와 scikit-learn, Jupyter Notebook의 보급은 Python을 일반 애플리케이션 개발 언어가 아닌 데이터 작업의 공통 인터페이스로 만들었다.
Jupyter Notebook은 코드, 설명, 그래프와 결과를 하나의 문서에 담을 수 있어 연구와 교육에서 특히 큰 영향을 미쳤다. GitHub의 2024년 자료에 따르면 Jupyter Notebook을 포함하는 공개 저장소는 2016년 이후 크게 증가하여 150만 개를 넘었으며, 이는 전통적인 소프트웨어 개발자가 아닌 연구자와 분석가의 오픈 소스 참여 확대와 연결된다.[291]
Python이 데이터 과학에서 성공한 이유는 단순히 pandas나 NumPy 하나가 존재했기 때문이 아니다. 데이터 수집, 정제, 통계, 시각화, 머신러닝, 웹 API와 자동화를 하나의 언어에서 연결할 수 있었기 때문이다.
머신러닝과 딥러닝의 영향
2010년대 중반 이후 머신러닝과 딥러닝의 급성장은 Python 대중화를 더욱 가속했다.
scikit-learn은 전통적인 머신러닝 알고리즘에 일관된 인터페이스를 제공했고, TensorFlow·PyTorch·Keras와 이후 JAX는 신경망 및 가속기 계산을 Python에서 구성할 수 있게 했다.
실제 고성능 연산은 C++, CUDA와 전문 컴파일러에서 수행되었지만, 연구자는 Python 코드로 모델을 정의하고 학습하며 결과를 분석할 수 있었다.
Python
→ 모델 구조와 실험 표현
→ 데이터 처리
→ 학습 과정 제어
→ 결과 분석
네이티브 실행 환경
→ CPU 벡터 연산
→ GPU 계산
→ 분산 통신
→ compiler 최적화
이 역할 분담은 Python의 상대적으로 느린 인터프리터 실행 속도가 인공지능 분야의 지배적인 언어가 되는 것을 막지 못하게 했다. 핵심 연산은 네이티브 라이브러리에 위임하면서 사용자가 접하는 API와 연구 환경은 Python으로 통일할 수 있었기 때문이다.
딥러닝 연구 논문과 공개 구현이 Python 프레임워크를 중심으로 배포되면서, 새로운 기술을 배우려는 사용자도 Python을 먼저 학습하게 되었다. 이후 생성형 인공지능과 대규모 언어 모델의 확산은 Python의 위치를 더욱 강화했다.
대중화 시기
Python이 언제 대중화되었는지는 사용하는 지표와 분야에 따라 다르게 볼 수 있다.
1990년대
→ 소수 개발자·연구자·제작 도구 분야의 실용 언어
2000년대
→ 웹, 시스템 관리, 오픈 소스와 과학 계산에서 확산
2010년대 초반
→ 교육·스타트업·데이터 분석 분야에서 주류 진입
2010년대 후반
→ 가장 빠르게 성장하는 주요 언어 가운데 하나
2020년대
→ 인공지능·데이터 과학을 중심으로 최상위권 정착
전문 소프트웨어 개발 분야에서는 2000년대부터 이미 널리 알려졌지만, 개발자 이외의 연구자·분석가·학생까지 포함한 대규모 대중화는 2010년대에 이루어졌다고 보는 것이 적절하다.
Stack Overflow의 2017년 조사에서는 Python이 여러 해에 걸쳐 꾸준히 성장한 언어로 나타났다.[292]
2018년 조사에서는 Python이 C#을 넘어섰으며, 그 전해에는 PHP를 넘어선 것으로 나타났다. Stack Overflow는 당시 Python을 가장 빠르게 성장하는 주요 프로그래밍 언어라고 평가했다.[293]
2019년에는 Python이 다시 순위가 상승하여 Java를 앞섰고, 가장 빠르게 성장하는 주요 언어라는 평가를 유지했다.[294]
이 흐름을 기준으로 보면 Python이 전문 분야에서 오랫동안 기반을 쌓은 뒤, 2010년대 중후반에 일반 개발자와 학습자 사이에서 명확한 대중적 주류 언어로 올라섰다고 정리할 수 있다.
대중화의 원인
Python의 대중화는 하나의 기능보다 여러 조건이 동시에 맞물린 결과이다.
접근하기 쉬운 문법
Python은 초보자가 첫 프로그램을 작성하는 데 필요한 문법 요소가 비교적 적다. 동시에 함수, 클래스, 제너레이터, 비동기 처리와 메타프로그래밍 등 복잡한 소프트웨어를 만들 수 있는 기능도 제공한다.
이러한 낮은 진입 장벽과 높은 확장 범위는 교육과 산업 채택을 동시에 가능하게 했다.
높은 개발 생산성
동적 타입과 고수준 자료구조, 자동 메모리 관리 및 표준 라이브러리는 같은 기능을 비교적 적은 코드로 구현하게 한다.
실행 속도가 가장 중요한 시스템에는 적합하지 않을 수 있지만, 개발 시간과 변경 비용이 중요한 웹 서비스·자동화·분석·도구 개발에서는 큰 이점이 되었다.
네이티브 언어와의 연동
Python은 C와 C++ 및 Fortran 라이브러리를 감싸기 쉬웠다. 이 때문에 이미 검증된 고성능 코드를 버리지 않고 사용자 인터페이스만 Python으로 제공할 수 있었다.
Python이 과학·인공지능 생태계에서 성공한 핵심 이유 가운데 하나도 이 구조이다.
광범위한 패키지 생태계
PyPI와 pip를 통해 거의 모든 일반적인 분야의 라이브러리를 설치할 수 있게 되면서 Python은 새로운 프로젝트를 빠르게 시작할 수 있는 언어가 되었다.
패키지 생태계의 크기는 다시 사용자 유입을 늘리고, 사용자 증가는 더 많은 패키지 개발로 이어졌다.
여러 분야를 연결하는 범용성
Python은 웹 전용 언어나 통계 전용 언어가 아니다. 데이터 분석 결과를 웹 서비스로 만들거나, 머신러닝 모델을 자동화 파이프라인에 연결하고, 운영체제 작업과 데이터베이스를 함께 처리할 수 있다.
하나의 조직이 여러 분야에서 같은 언어와 개발자를 활용할 수 있다는 점은 기업 도입에도 유리했다.
교육과 온라인 학습
대학, 학교, 온라인 강의와 프로그래밍 입문 자료가 Python을 채택하면서 새로운 개발자 세대가 Python을 첫 언어로 배우기 시작했다.
교육을 통해 유입된 사용자는 이후 데이터 과학, 웹, 자동화와 인공지능 분야로 이동하면서 다시 생태계를 확대했다.
오픈 소스와 무료 이용
Python과 주요 생태계는 공개 라이선스로 제공되며 개인·교육·연구·상업 환경에서 사용할 수 있다. 특정 운영체제나 유료 개발 도구에 종속되지 않는다는 점은 전 세계적인 확산에 중요했다.
기업과 연구 기관의 채택
기업이 Python을 실제 서비스와 제작 도구에 사용하면서 언어가 학습·실험용에만 적합하다는 인식이 약화되었다.
기업의 채택은 일자리와 교육 수요를 만들었고, 개발자와 라이브러리 유지관리자에게 생태계에 투자할 동기를 제공했다.
소프트웨어 개발 문화에 미친 영향
Python은 가독성과 명시성을 언어의 핵심 가치로 강조함으로써 프로그래밍 언어와 개발 문화에 영향을 주었다.
Python 이전에도 읽기 쉬운 언어와 고수준 언어는 존재했지만, Python의 성공은 다음 관점을 널리 확산시켰다.
- 소스 코드의 가독성은 부가적인 미관이 아니라 유지보수 비용과 관련된다.
- 들여쓰기를 모든 개발자가 동일하게 사용하도록 강제할 수 있다.
- 간결함과 난해한 축약은 같은 개념이 아니다.
- 동적 언어에서도 대규모 라이브러리와 장기 운영 시스템을 만들 수 있다.
- 대화형 개발과 정식 소프트웨어 개발이 같은 언어 안에서 공존할 수 있다.
- 네이티브 성능과 고수준 생산성을 하나의 언어가 직접 모두 제공할 필요는 없다.
- 사용하기 쉬운 API가 저수준 구현의 복잡성을 숨길 수 있다.
Python의 스타일 가이드와 The Zen of Python은 언어 공동체가 단순히 문법만 공유하는 것이 아니라 코드 작성의 가치와 관례를 공유하는 사례로 알려졌다.
교육에 미친 영향
Python은 프로그래밍 교육의 목표를 문법 암기에서 문제 해결과 알고리즘 표현으로 옮기는 데 영향을 주었다.
C나 Java를 입문 언어로 사용할 때는 자료형 선언, 컴파일, 포인터 또는 클래스 구조를 비교적 이른 단계에 설명해야 할 수 있다. Python은 이러한 요소를 늦추고 조건문, 반복, 함수, 자료구조와 알고리즘을 먼저 다룰 수 있게 한다.
이는 모든 교육에서 Python이 가장 적합하다는 뜻은 아니다. 메모리 구조, 정적 타입, 컴파일과 시스템 동작을 배우려면 다른 언어도 필요하다. 그러나 Python은 프로그래밍의 첫 진입 장벽을 낮추고, 비전공자가 자신의 분야에 계산과 자동화를 적용할 수 있게 했다.
Python을 통한 교육 확대는 프로그래머의 범위도 넓혔다.
전문 소프트웨어 개발자
+ 과학자
+ 분석가
+ 학생
+ 교사
+ 디자이너
+ 시스템 관리자
+ 업무 자동화 사용자
이러한 사용자 집단의 확대는 Python 생태계가 전통적인 소프트웨어 개발만으로 설명되지 않는 이유가 되었다.
과학과 연구에 미친 영향
Python은 연구자가 알고리즘의 사용자에 머무르지 않고 직접 소프트웨어를 수정하고 공유할 수 있게 했다.
상용 수치 환경과 분야별 독립 프로그램 대신, Python 패키지로 연구 도구를 공개하고 다른 분야의 라이브러리를 결합할 수 있게 되었다.
연구 과정에서 다음 요소가 같은 환경에 들어왔다.
- 데이터 전처리
- 수치 계산
- 통계 분석
- 시각화
- 기계학습
- 논문용 결과 생성
- 웹 기반 공유
- 계산 자동화
이러한 통합은 재현 가능한 연구와 공개 소프트웨어의 확산에 기여했지만, Notebook의 실행 순서, 패키지 버전과 외부 데이터가 제대로 관리되지 않으면 재현성이 낮아질 수 있다는 새로운 문제도 만들었다.
인공지능 발전에 미친 영향
Python은 현대 인공지능 알고리즘 자체를 처음 만들어 낸 언어는 아니지만, 연구 결과가 널리 구현되고 공유되는 표준적인 사용자 인터페이스가 되었다.
새 논문이나 모델이 발표되면 Python 패키지와 Notebook 예제가 함께 공개되는 방식이 일반화되었다. 연구자는 같은 언어로 데이터를 준비하고 모델을 학습하며 결과를 시각화할 수 있었다.
Python의 인공지능 생태계는 다음과 같은 순환을 만들었다.
연구자가 Python 사용
→ framework와 모델 공개
→ 교육 자료가 Python을 기준으로 작성
→ 신규 사용자가 Python 학습
→ 기업이 Python 인력을 채용
→ 생태계 투자와 도구 증가
생성형 인공지능의 급성장은 이 순환을 더욱 강화했다. 2024년 GitHub의 Octoverse에서는 Python이 GitHub에서 가장 많이 사용된 언어로 집계되었으며, GitHub는 이러한 성장을 인공지능·데이터 과학 활동과 Jupyter Notebook 사용 증가에 연결했다.[295]
다만 GitHub 언어 순위는 저장소 활동을 측정하는 특정 지표이며, 모든 개발 환경이나 언어 사용량을 완전하게 대표하지는 않는다. 2025년에는 GitHub 기여자 수 기준으로 TypeScript가 Python을 앞섰지만, Python은 여전히 인공지능과 데이터 과학에서 큰 사용자 기반을 유지했다.[296]
오픈 소스 생태계에 미친 영향
Python은 오픈 소스 프로젝트가 전문 소프트웨어와 산업용 도구의 기반이 될 수 있음을 보여 주었다.
NumPy, Django, pytest, Jupyter, pandas, PyTorch와 수많은 프로젝트는 독립적인 유지관리자와 기업, 연구 기관의 협력을 통해 발전했다.
Python 생태계는 다음과 같은 개발 모델을 널리 보여 준다.
공개 패키지
→ 개인과 연구자가 사용
→ 기업이 제품에 도입
→ 기업 개발자가 다시 기여
→ 유지관리 기반 확대
반면 널리 사용되는 핵심 패키지가 소수의 자원봉사 유지관리자에게 의존하는 문제도 드러났다. Python의 영향력이 커지면서 패키지 공급망 보안, 유지관리자 지원, 지속 가능한 후원과 거버넌스가 중요한 문제로 떠올랐다.
다른 언어와 도구에 미친 영향
Python의 성공은 다른 언어와 개발 도구의 설계에도 영향을 주었다.
여러 언어와 플랫폼은 다음과 같은 요소를 도입하거나 강화했다.
- 간결한 스크립트 실행 환경
- 대화형 REPL과 Notebook
- 컴프리헨션과 제너레이터
- 가독성을 중시하는 문법
- 패키지 색인과 간단한 설치 명령
- 점진적 타입 검사
- 네이티브 확장과 고수준 API의 분리
- 데이터 과학을 위한 Python 호환 인터페이스
Python 문법과 직접 호환되지 않는 언어도 Python 사용자가 익숙한 배열, DataFrame과 머신러닝 API를 제공하려는 경우가 많다.
Rust로 작성된 uv, Ruff와 Polars 같은 도구는 Python 생태계를 대체하기보다 Python 사용 경험과 성능을 개선한다. 이는 Python이 다른 구현 언어 위에 대규모 도구 생태계를 형성하는 플랫폼 역할을 한다는 점을 보여 준다.
접착 언어의 위상
Python의 중요한 영향 가운데 하나는 접착 언어라는 역할을 주류 개발 방식으로 만든 것이다.
전통적으로 프로그램은 하나의 주된 언어로 작성되는 것으로 설명되곤 했지만, 현대 시스템은 여러 언어와 서비스를 조합한다.
Python
→ 전체 실행 흐름과 API
C·C++·Rust
→ 고성능 핵심
Fortran
→ 기존 수치 계산
CUDA·가속기 runtime
→ GPU 연산
JavaScript·TypeScript
→ 브라우저 interface
SQL
→ 데이터 처리
Python은 서로 다른 구현을 하나의 사용하기 쉬운 인터페이스로 연결한다. 이 방식은 Python 자체의 실행 성능 한계를 보완하면서도 생산성과 생태계의 장점을 유지한다.
기업 개발에 미친 영향
Python은 스타트업과 대기업 모두에서 빠른 서비스 개발, 내부 자동화와 데이터 작업의 공통 언어로 사용되었다.
초기에는 내부 도구나 프로토타입에 도입된 뒤 다음과 같이 확대되는 경우가 많았다.
작은 자동화 스크립트
→ 팀 내부 도구
→ 서비스 backend
→ 공통 library
→ 데이터와 AI platform
하나의 조직 안에서 웹 개발자, 데이터 분석가, 머신러닝 연구자와 운영 담당자가 같은 언어와 패키지를 사용할 수 있다는 점은 협업 비용을 줄였다.
반면 대규모 Python 시스템에서는 동적 타입, import 구조, 의존성 관리와 실행 성능을 체계적으로 관리해야 했다. 이러한 요구는 타입 주석, 정적 분석기, 빠른 패키지 관리자, 고성능 interpreter와 네이티브 확장 도구의 발전을 촉진했다.
현대적 위치
2020년대의 Python은 더 이상 특정 스크립팅 분야의 보조 언어로 분류하기 어렵다. 웹 서비스, 데이터 공학, 과학 계산, 인공지능, 자동화, 교육과 제작 도구에서 주요 언어로 사용되며, 수많은 전문 분야의 공통 인터페이스를 제공한다.
GitHub의 2022년 자료에서는 400만 명이 넘는 개발자가 Python을 사용했고 전년 대비 사용이 22% 이상 증가한 것으로 나타났다.[297]
2024년에는 GitHub의 해당 측정 방식에서 Python이 가장 많이 사용된 언어가 되었으며, 2025년에는 TypeScript에 이어 두 번째를 차지했다. 2025년에도 Python 기여자 수는 전년 대비 크게 증가했고, 인공지능과 데이터 과학에서는 지배적인 위치를 유지했다.[298]
이러한 순위는 측정 방법에 따라 달라질 수 있지만, Python이 세계적으로 가장 널리 사용되는 프로그래밍 언어 집단에 속한다는 점은 분명하다.
성공의 누적 구조
Python의 대중화는 단순히 문법이 쉬워서 일어난 결과로 축소하기 어렵다. 여러 단계의 성공이 누적되었다.
읽기 쉬운 언어
→ 초기 사용자 유입
C 확장과 이식성
→ 실제 산업 도입
오픈 소스 공동체
→ 라이브러리 성장
웹 프레임워크
→ 응용 프로그램 개발 확대
과학 계산
→ 연구자 유입
교육 채택
→ 신규 사용자 확대
데이터 과학과 AI
→ 폭발적 수요
기업과 클라우드 채택
→ 일자리와 투자 증가
어느 한 요소라도 부족했다면 Python이 현재의 범용 생태계로 성장하기 어려웠을 수 있다. 언어의 접근성, 네이티브 연동, 패키지 생태계와 공동체가 서로 강화하면서 장기적인 성장을 만들었다.
영향의 한계와 부작용
Python의 대중화는 새로운 문제도 만들었다.
- Python 2와 3의 장기간 분열
- 패키지와 가상 환경 관리의 복잡성
- 분야마다 서로 다른 도구 체계
- 순수 Python CPU 성능의 한계
- 네이티브 wheel과 플랫폼 호환성 문제
- PyPI 공급망 보안
- 유지관리되지 않는 패키지 증가
- 초보자가 검증되지 않은 코드를 쉽게 배포하는 문제
- Notebook 기반 연구의 재현성 문제
- 인기 있는 핵심 프로젝트의 유지관리 부담
또한 Python의 높은 접근성은 프로그램 구조와 배포, 보안 및 성능을 충분히 이해하지 않은 상태에서도 빠르게 시스템을 만들 수 있게 한다. 초기 개발은 빠르지만 프로그램이 커진 뒤 설계 부채가 드러날 수 있다.
이러한 문제는 Python의 영향력이 작기 때문에 발생한 것이 아니라, 서로 다른 배경의 사용자가 광범위한 분야에서 같은 언어를 사용하게 된 결과이기도 하다.
종합적 영향
Python은 처음에는 C와 UNIX 도구 사이의 간격을 메우고 빠르게 스크립트를 작성하기 위한 비교적 작은 언어 공동체에서 출발했다. 대중화되기 전에는 시각 효과 제작, 시스템 자동화, 과학 계산과 기업 내부 도구처럼 변화가 잦고 여러 시스템을 연결해야 하는 분야에서 먼저 가치를 인정받았다.
2000년대에는 웹 개발과 오픈 소스 패키지 생태계가 사용 기반을 넓혔고, 2010년대에는 교육·데이터 과학·머신러닝이 전문 개발자 이외의 사용자까지 대규모로 유입시켰다. 특히 2010년대 중후반부터는 개발자 조사에서 가장 빠르게 성장하는 주요 언어로 나타났으며, 2020년대에는 인공지능의 핵심 사용자 언어로 자리 잡았다.
Python의 가장 큰 영향은 특정 종류의 프로그램을 가장 빠르게 실행하는 데 있지 않다. 서로 다른 분야의 전문 라이브러리와 시스템을 읽기 쉬운 하나의 언어로 연결하고, 개발자가 아닌 연구자와 분석가도 직접 프로그램을 작성하게 했으며, 학습용 코드에서 산업용 서비스까지 같은 생태계 안에서 발전할 수 있게 했다는 데 있다.
장점과 한계
Python은 읽기 쉬운 문법과 높은 개발 생산성, 광범위한 라이브러리 생태계 및 다른 언어와의 연동 능력을 갖춘 범용 프로그래밍 언어이다. 작은 자동화 스크립트부터 웹 서비스, 과학 계산, 데이터 분석과 인공지능까지 같은 언어와 패키지 체계를 사용할 수 있다는 점이 주요 장점이다.
반면 Python의 동적 객체 모델과 인터프리터 중심 실행 방식은 실행 성능, 메모리 사용량과 정적 검증에서 비용을 발생시킨다. 또한 Python 프로그램의 실제 동작은 인터프리터뿐 아니라 외부 패키지, 네이티브 라이브러리와 실행 환경에 크게 의존하므로 배포와 재현 환경을 별도로 관리해야 한다.
Python의 장점과 한계는 서로 완전히 분리되어 있지 않다. 동적 타입은 빠른 개발과 유연한 API를 가능하게 하지만 일부 오류를 실행 시점까지 발견하지 못하게 한다. 광범위한 패키지 생태계는 개발 시간을 줄이지만 의존성 관리와 공급망 위험을 증가시킨다. 다른 언어와 쉽게 연동할 수 있다는 점은 성능 한계를 보완하지만 네이티브 바이너리와 플랫폼별 배포를 복잡하게 만들 수 있다.
가독성과 비교적 낮은 진입 장벽
Python은 들여쓰기를 문법적인 블록 구조로 사용하며, 일반적인 작업을 비교적 적은 문법 요소로 표현한다. 함수, 조건문, 반복문과 기본 자료구조를 배우는 데 필요한 선언이 많지 않아 프로그래밍 입문과 비전문 개발자의 자동화에 적합하다.
Python의 문법은 코드를 짧게 만드는 것만을 목표로 하지 않는다. 같은 블록 안의 문장이 시각적으로 정렬되고, 이름과 연산이 비교적 명시적으로 드러나도록 설계되었다. Python 공식 문서도 단순하고 일관된 문법과 큰 표준 라이브러리를 교육상의 장점으로 설명한다.[299]
그러나 문법을 배우기 쉽다는 사실이 Python 전체를 단순하게 만든다는 뜻은 아니다. 실제 대규모 프로그램을 개발하려면 다음 요소를 이해해야 한다.
- 객체 모델과 이름 바인딩
- 가변성과 참조 공유
- 디스크립터와 메서드 바인딩
- 가져오기 및 패키지 구조
- 예외와 자원 수명
- 동시성과 비동기 처리
- 타입 주석과 정적 분석
- 패키징 및 배포 환경
초기 진입 장벽은 낮지만 언어의 동적 특성과 생태계까지 능숙하게 다루려면 상당한 학습이 필요하다.
높은 개발 생산성
Python은 자동 메모리 관리, 내장 컨테이너, 예외 처리, 반복자와 광범위한 라이브러리를 제공한다. 개발자는 문자열, 파일, 네트워크, JSON과 데이터베이스 같은 일반 기능을 저수준에서 다시 구현하지 않고 응용 프로그램의 실제 기능에 집중할 수 있다.
소스 코드를 수정한 뒤 긴 전체 컴파일 과정 없이 바로 실행하고 시험할 수 있다는 점도 탐색적 개발과 프로토타입 제작에 유리하다. 대화형 인터프리터와 Notebook을 사용하면 계산 과정과 데이터를 단계적으로 확인할 수 있다.
이러한 생산성은 다음 분야에서 특히 가치가 크다.
- 요구 사항이 자주 바뀌는 서비스
- 연구와 알고리즘 실험
- 내부 업무 도구
- 데이터 처리 및 보고서 자동화
- 테스트와 개발 도구
- 외부 시스템 통합
- 제품의 초기 프로토타입
반면 빠르게 작성된 코드가 자동으로 유지보수하기 좋은 코드는 아니다. 동적 객체와 전역 상태, 암묵적인 자료 구조를 과도하게 사용하면 프로그램이 커진 뒤 인터페이스와 데이터 흐름을 파악하기 어려워질 수 있다.
초기 개발 속도만을 기준으로 설계를 생략하면 이후 다음 비용이 증가할 수 있다.
- 테스트 추가
- 타입 오류 수정
- 모듈 분리
- 의존성 정리
- 동시성 오류 분석
- 배포 환경 재현
- 성능 병목의 재구현
Python의 생산성을 장기적으로 유지하려면 명확한 모듈 경계, 테스트, 타입 주석과 자동화된 품질 검사가 필요하다.
광범위한 표준 라이브러리
Python은 문자열과 파일 처리, 운영체제 기능, 네트워크 프로토콜, 데이터 형식, 동시성, 테스트, 로깅과 프로파일링 등을 다루는 큰 표준 라이브러리를 제공한다.[300]
작은 프로그램은 외부 의존성 없이도 상당한 기능을 구현할 수 있다. 표준 라이브러리는 Python 버전과 함께 배포되므로 별도의 패키지와 버전 조합을 선택해야 하는 부담도 줄인다.
그러나 표준 라이브러리가 모든 분야에서 가장 현대적이거나 전문적인 API를 제공하는 것은 아니다. 빠르게 변화하는 웹, 데이터 과학, 클라우드, 데이터베이스와 인공지능 분야는 대부분 외부 패키지에 의존한다.
표준 라이브러리의 일부 오래된 모듈은 현대적인 외부 라이브러리보다 사용하기 불편하거나 기능이 제한적일 수 있다. 또한 표준 라이브러리에 들어간 API는 장기적인 호환성을 유지해야 하므로 빠른 설계 변경이 어렵다.
방대한 외부 패키지 생태계
PyPI에는 다양한 분야의 제삼자 패키지가 공개되어 있다. 개발자는 웹 프레임워크, 데이터 분석, 머신러닝, GUI, 문서 처리, 데이터베이스와 운영 자동화 기능을 설치하여 조합할 수 있다.[301]
이 생태계는 Python의 가장 큰 장점 가운데 하나이다. 특정 문제를 해결하는 검증된 패키지가 이미 존재한다면 개발 시간과 구현 위험을 크게 줄일 수 있다.
그러나 PyPI가 모든 패키지의 품질과 안전성을 보증하는 것은 아니다. 프로젝트마다 다음 요소가 다르다.
- 유지보수 상태
- 코드 품질
- 라이선스
- 문서 수준
- Python 버전 지원
- 보안 대응
- 네이티브 바이너리 제공 범위
- 의존성 수
- 하위 호환성 정책
유명 프로젝트와 비슷한 이름의 악성 패키지나 유지보수가 중단된 패키지를 설치할 위험도 있다. 조직에서는 의존성 검토, 버전 고정, 취약점 검사와 사설 패키지 정책을 운영할 필요가 있다.
패키지가 많다는 것은 선택지가 많다는 장점과 함께, 비슷한 기능의 여러 도구 가운데 장기적으로 유지할 조합을 결정해야 한다는 부담도 만든다.
다양한 활용 분야
Python은 특정 운영체제나 산업에만 종속되지 않으며 웹, 과학, 교육, 자동화와 기업 업무 등 여러 분야에서 사용된다.[302]
하나의 프로젝트에서도 데이터 수집, 분석, 머신러닝, 웹 API와 관리용 명령행 도구를 같은 언어로 구현할 수 있다. 조직 내부의 여러 직군이 공통 언어와 패키지를 공유할 수 있다는 점도 협업에 유리하다.
그러나 범용성은 각 분야의 가장 특화된 언어와 도구를 항상 능가한다는 의미가 아니다.
- 브라우저 사용자 인터페이스에는 JavaScript와 TypeScript가 기본 환경이다.
- 운영체제 커널과 장치 드라이버에는 C와 Rust 등이 더 적합하다.
- 초저지연 시스템에는 C++와 전용 런타임이 사용될 수 있다.
- 모바일 플랫폼의 네이티브 앱에는 Swift와 Kotlin이 더 강하게 통합된다.
- 하드 실시간 제어에는 Python의 일반 실행 모델이 적합하지 않다.
Python은 모든 구성 요소를 단독으로 구현하기보다 다른 언어와 역할을 나누는 방식으로 범용성을 확보한다.
다른 언어와의 높은 연동성
CPython은 C API와 임베딩 인터페이스를 제공하며, C·C++·Rust·Fortran 및 다른 런타임의 라이브러리를 Python 모듈로 연결할 수 있다.
이 구조는 Python의 생산성과 네이티브 코드의 성능을 결합한다. NumPy, 머신러닝 프레임워크와 여러 데이터 처리 패키지는 실제 핵심 연산을 네이티브 코드에서 수행하고 Python을 고수준 API로 사용한다.
Python은 기존 소프트웨어 자산을 다시 구현하지 않고 감싸서 사용할 수 있으므로 접착 언어로 적합하다.
반면 언어 경계는 다음과 같은 복잡성을 만든다.
- 객체와 자료형 변환
- 메모리 소유권
- 참조 계수
- 문자열 인코딩
- 네이티브 예외와 Python 예외 변환
- GIL 및 스레드 상태
- 운영체제별 ABI
- 바이너리 패키지 배포
- 네이티브 충돌과 메모리 오류
순수 Python 예외는 일반적으로 해당 작업 수준에서 처리할 수 있지만, 잘못 작성된 네이티브 확장은 Python 프로세스 전체를 충돌시킬 수 있다.
이식성
Python과 많은 순수 Python 패키지는 Windows, macOS, Linux와 여러 UNIX 계열 환경에서 사용할 수 있다. 운영체제별 차이를 표준 라이브러리와 외부 패키지가 추상화하므로 같은 소스 코드가 여러 플랫폼에서 동작할 수 있다.
그러나 완전한 이식성이 자동으로 보장되는 것은 아니다.
다음 요소는 플랫폼마다 달라질 수 있다.
- 파일 경로와 권한
- 프로세스와 신호
- 콘솔 및 문자 인코딩
- 동적 라이브러리 형식
- 네이티브 확장 지원
- 프로세스 시작 방식
- 이벤트 루프 구현
- 사용 가능한 표준 라이브러리 모듈
- 패키지 wheel 제공 여부
순수 Python 코드도 운영체제의 명령, 파일 시스템 특성과 특정 로캘에 의존하면 이식성이 낮아진다. 여러 플랫폼을 지원하려면 각각의 실제 환경과 CI에서 검사해야 한다.
대화형 개발과 탐색
Python은 대화형 인터프리터, IPython, Jupyter와 여러 Notebook 환경을 통해 값을 즉시 확인하고 코드를 단계적으로 실행할 수 있다.
이 방식은 다음 작업에 적합하다.
- API 탐색
- 데이터 확인
- 수치 실험
- 알고리즘 개발
- 시각화
- 교육과 설명
- 오류 원인 조사
그러나 대화형 환경은 실행 순서와 숨겨진 상태에 의존할 수 있다. Notebook의 셀을 화면 순서와 다르게 실행하면 현재 메모리 상태가 문서의 소스와 일치하지 않을 수 있다.
대화형 실험을 정식 프로그램으로 발전시킬 때는 다음 작업이 필요하다.
- 순서에 의존하지 않는 모듈로 분리
- 입력과 출력 명시
- 테스트 작성
- 패키지 버전 기록
- 외부 데이터 경로 관리
- 재시작 후 전체 실행 검증
동적 타입의 유연성
Python에서는 변수가 특정 고정 자료형을 선언하지 않고 실행 중 객체에 바인딩된다. 함수는 명목적인 클래스보다 필요한 연산과 프로토콜을 제공하는 객체를 받을 수 있다.
이러한 duck typing은 서로 다른 객체를 유연하게 조합하고 작은 인터페이스를 빠르게 설계하는 데 유리하다.
동적 타입은 다음 상황에서 생산성을 높일 수 있다.
- 짧은 스크립트
- 데이터 구조가 자주 변하는 초기 개발
- 플러그인과 확장 구조
- 대화형 실험
- 여러 객체가 같은 프로토콜을 구현하는 API
반면 타입 불일치가 실행되기 전까지 발견되지 않을 수 있다.
사용 빈도가 낮은 오류 경로, 특정 외부 입력과 배포 환경에서만 잘못된 타입이 전달되면 개발 중 발견되지 않을 수 있다.
Python은 타입 주석과 mypy·Pyright 같은 정적 분석기를 통해 이러한 위험을 줄일 수 있다. 그러나 타입 주석은 일반적으로 실행 시 강제되지 않으며, 동적 코드와 네이티브 확장을 완전히 분석할 수는 없다.
Python 공식 설계 문서는 Python이 동적 타입을 사용한다는 점과 정적 타입 언어의 컴파일 시 정보가 제공하는 차이를 구분한다.[303]
타입 주석의 점진적 도입
Python은 기존 동적 코드를 유지하면서 필요한 부분부터 타입 정보를 추가할 수 있다.
이는 다음과 같은 장점을 제공한다.
- 함수 인터페이스 문서화
- IDE 자동 완성
- 리팩터링 지원
- 실행 전 오류 탐지
- 대규모 코드베이스의 의존 관계 명확화
- 라이브러리 사용법 전달
그러나 점진적 타입 체계에는 한계가 있다.
- 모든 코드가 타입 검사를 사용하는 것은 아니다.
- 검사기마다 추론과 진단에 차이가 있을 수 있다.
- 일부 라이브러리는 타입 정보가 부족하다.
- 동적 속성 생성과 monkey patching은 분석이 어렵다.
- 타입을 지나치게 복잡하게 설계하면 코드 가독성이 낮아질 수 있다.
- 타입 검사를 통과해도 런타임 오류는 발생할 수 있다.
타입 주석은 테스트와 실행 검증을 대체하지 않으며, 프로젝트 규모와 공개 API에 맞는 수준으로 사용하는 것이 적합하다.
실행 성능
일반적인 CPython은 소스 코드를 바이트코드로 컴파일한 뒤 동적 객체 연산을 인터프리터에서 수행한다. 정수 연산, 속성 조회와 함수 호출에는 객체 타입 확인과 참조 관리 등의 비용이 들어간다.
따라서 같은 알고리즘을 최적화된 C·C++·Rust 등의 네이티브 코드로 구현한 경우보다 순수 Python 반복문이 느릴 수 있다.
성능 차이가 특히 커질 수 있는 작업은 다음과 같다.
- 매우 많은 작은 수치 연산
- 객체를 반복적으로 생성하는 내부 루프
- 픽셀과 신호의 원소별 처리
- 압축과 암호의 저수준 연산
- 짧은 함수의 매우 빈번한 호출
- 엄격한 지연 시간 제한이 있는 처리
Python은 다음 방식으로 성능 한계를 보완한다.
- 더 적합한 알고리즘과 자료구조 사용
- NumPy 등 벡터화된 네이티브 라이브러리 사용
- C·C++·Rust·Fortran 확장 사용
- Cython과 JIT 도구 사용
- 작업을 프로세스 또는 별도 서비스로 분리
- PyPy 같은 다른 구현체 사용
- CPython의 특수화와 선택적인 JIT 활용
네이티브 라이브러리를 사용하면 전체 응용 프로그램에서 Python 코드가 차지하는 실행 시간이 작아질 수 있다. 이 때문에 Python은 언어 자체의 순수 실행 속도보다 외부 고성능 구현을 조합하는 구조로 성공했다.
그러나 성능이 중요하지 않은 프로그램까지 네이티브 코드로 옮기면 빌드, 디버깅과 배포 비용만 증가할 수 있다. 실제 병목을 측정한 뒤 최적화해야 한다.
메모리 사용량
Python 객체는 실제 값 이외에도 타입, 참조 관리와 객체 상태를 위한 메타데이터를 가진다. 작은 정수나 짧은 문자열을 개별 Python 객체로 많이 보관하면 연속된 네이티브 배열보다 훨씬 많은 메모리를 사용할 수 있다.
목록도 원소 값을 직접 연속 저장하기보다 일반적으로 객체 참조를 보관한다. 수백만 개의 숫자와 작은 객체를 다루는 작업에서는 객체 오버헤드와 메모리 지역성이 성능에 영향을 줄 수 있다.
이러한 작업에는 다음 방식을 사용할 수 있다.
array와memoryview- NumPy와 Apache Arrow 기반 배열
__slots__- 생성기와 iterator를 이용한 스트리밍
- 작은 작업 단위의 처리
- 데이터베이스 및 외부 저장소 사용
- 불필요한 객체 복사 제거
메모리 문제는 언어의 가비지 컬렉션이 모든 객체를 자동으로 즉시 줄여 준다고 해결되지 않는다. 객체에 대한 참조가 남아 있거나 allocator가 해제된 메모리를 재사용 목적으로 보유하면 프로세스의 메모리 사용량이 바로 감소하지 않을 수 있다.
GIL과 CPU 병렬성
일반적인 GIL 활성 CPython에서는 한 시점에 하나의 스레드만 Python 바이트코드를 실행한다. 이 때문에 여러 스레드를 생성해도 순수 Python CPU 계산이 여러 코어에서 완전하게 병렬 실행되지 않는다.
스레드는 파일, 네트워크와 데이터베이스 대기가 많은 작업에는 여전히 유용하다. 네이티브 확장이 계산 중 GIL을 해제하는 경우에도 병렬 실행이 가능하다.
CPU 중심 작업에서는 다음 방식을 사용할 수 있다.
- 여러 프로세스
- 다중 인터프리터
- GIL을 해제하는 네이티브 라이브러리
- 자유 스레딩 CPython
- 별도의 작업 서비스
- 다른 Python 구현체
CPython 3.13부터 GIL을 비활성화할 수 있는 자유 스레딩 빌드가 제공된다. 이를 사용하면 여러 스레드가 여러 CPU 코어에서 Python 코드를 실행할 수 있다.[304]
다만 자유 스레딩은 기존 한계를 비용 없이 제거하지 않는다.
- 일부 확장 모듈이 아직 지원하지 않을 수 있다.
- 지원하지 않는 확장을 가져오면 GIL이 다시 활성화될 수 있다.
- 공유 가변 상태에는 명시적인 동기화가 필요하다.
- 단일 스레드 실행에 추가 비용이 발생할 수 있다.
- 일반 빌드보다 메모리 사용량이 늘어날 수 있다.
- 일부 객체와 디버깅 기능에 별도 제약이 존재한다.
Python 3.14 공식 문서에서는 자유 스레딩 빌드의 단일 스레드 성능 오버헤드가 작업과 하드웨어에 따라 달라지며, 일부 측정 환경에서 평균 약 1%에서 8% 범위로 나타난다고 설명한다.[305]
따라서 자유 스레딩은 병렬 작업을 명확히 분할할 수 있는 프로그램에 유용하지만, 모든 프로그램의 기본 성능을 자동으로 높이는 기능은 아니다.
비동기 생태계의 분리
Python은 동기 API와 asyncio 기반 비동기 API를 모두 제공한다. 비동기 처리는 많은 네트워크 연결과 입출력 작업을 적은 스레드로 처리하는 데 유리하다.
그러나 동기 함수와 비동기 함수는 호출 방식이 다르다. 라이브러리 선택과 프로그램 구조도 이에 영향을 받는다.
다음과 같은 문제가 발생할 수 있다.
- 동기 라이브러리가 이벤트 루프를 차단함
- 같은 기능의 동기·비동기 클라이언트를 따로 선택해야 함
- 함수 전체에
async와await가 전파됨 - 이벤트 루프와 스레드 사이의 경계 관리
- 취소와 시간 제한 처리의 복잡성
- 비동기 객체를 다른 스레드에서 잘못 사용함
비동기 처리가 필요하지 않은 작은 프로그램까지 asyncio로 작성하면 구조만 복잡해질 수 있다. 작업의 동시 연결 수와 입출력 특성을 기준으로 선택해야 한다.
배포의 복잡성
Python 소스 자체는 비교적 이식성이 높지만 최종 사용자에게 응용 프로그램을 배포하는 과정은 단순하지 않을 수 있다.
일반적인 Python 프로그램에는 다음 요소가 필요하다.
- 적절한 Python 구현체와 버전
- 표준 라이브러리
- 외부 Python 패키지
- 플랫폼별 네이티브 확장
- 외부 공유 라이브러리
- 설정과 데이터 파일
- 실행 진입점
- 환경 변수와 인증 정보
라이브러리는 wheel과 소스 배포판으로 배포할 수 있다. wheel은 컴파일된 구성 요소를 포함할 수 있도록 만들어진 표준 배포 형식이며, 네이티브 확장은 Python 버전·ABI·운영체제와 CPU별 파일이 필요할 수 있다.[306]
최종 사용자용 데스크톱 프로그램은 Python이 없는 시스템에서도 실행할 수 있도록 인터프리터와 패키지를 묶어야 할 수 있다. PyInstaller, Nuitka와 Briefcase 등의 도구가 이를 지원하지만 실행 파일 크기, 시작 시간, 동적 import와 플랫폼별 빌드 문제가 남는다.
C나 Go의 간단한 프로그램처럼 하나의 작은 네이티브 실행 파일을 모든 환경에 동일하게 배포하기는 상대적으로 어렵다.
패키징 도구의 다양성
Python 패키징은 저장소, 설치 frontend, 빌드 frontend, backend와 환경 관리자의 역할이 분리되어 있다. 이 구조는 새로운 도구와 빌드 방식을 허용하지만 초보자에게는 복잡하게 느껴질 수 있다.
Python Packaging User Guide도 빌드 backend와 프로젝트 관리 도구가 여러 종류 존재하며, 모든 작업에 하나의 도구를 일괄 추천하지 않는다고 설명한다.[307]
프로젝트에 따라 다음 조합이 달라질 수 있다.
venv와pip- uv
- Poetry
- PDM
- Hatch
- Conda 또는 Pixi
- setuptools
- Hatchling
- Flit
- maturin
- scikit-build-core
도구 선택의 자유는 장점이지만, 서로 다른 프로젝트에서 명령과 lock file 및 환경 구조가 달라질 수 있다. 표준 pyproject.toml이 공통 기반을 제공하더라도 모든 도구 기능이 완전히 호환되는 것은 아니다.
의존성과 환경 재현
Python 프로젝트는 실행할 인터프리터와 외부 패키지의 구체적인 조합에 영향을 받는다. 같은 소스 코드도 Python 버전, 운영체제와 설치된 패키지 버전에 따라 다르게 동작할 수 있다.
가상 환경은 프로젝트별 패키지를 분리하지만 다음 요소까지 모두 격리하지는 않는다.
- 운영체제 라이브러리
- 컴파일러
- GPU driver
- 외부 데이터베이스
- 환경 변수
- 시스템 로캘
- 네트워크 서비스
Python Packaging User Guide는 가상 환경을 각 응용 프로그램에 독립된 패키지 집합을 설치할 수 있는 격리 환경으로 정의한다.[308]
재현성이 중요한 프로젝트에서는 Python 버전, lock file, 외부 네이티브 의존성, 데이터와 설정을 함께 기록해야 한다.
시작 시간과 실행 파일 크기
Python 프로그램은 인터프리터 초기화, 모듈 검색과 가져오기 과정을 거쳐 실행된다. 많은 패키지를 가져오는 명령행 도구는 실제 작업이 짧아도 시작 시간이 크게 느껴질 수 있다.
응용 프로그램 번들에 Python 인터프리터와 표준 라이브러리, 외부 패키지를 포함하면 파일 크기가 커질 수 있다. 간단한 기능의 프로그램도 수십 메가바이트 이상의 배포 파일이 될 수 있다.
다음 환경에서는 이러한 특성이 단점이 될 수 있다.
- 매우 짧게 반복 실행되는 명령
- 작은 시스템 유틸리티
- 저장 공간이 제한된 장치
- 빠른 시작이 중요한 서버리스 작업
- 다운로드 크기가 중요한 배포
필요한 모듈의 지연 import, 불필요한 의존성 제거와 장기 실행 프로세스 사용 등으로 일부 비용을 줄일 수 있다.
제한된 장치와 실시간 시스템
일반적인 CPython은 운영체제와 충분한 메모리를 갖춘 시스템을 전제로 한다. 작은 마이크로컨트롤러, 운영체제 커널과 엄격한 실시간 제어에는 적합하지 않다.
MicroPython과 CircuitPython은 제한된 장치에서 Python 계열 문법을 제공하지만 CPython의 전체 표준 라이브러리와 PyPI 생태계를 그대로 지원하지 않는다.
실행 시간과 메모리 사용량을 엄격하게 보장해야 하는 환경에서는 다음 언어와 기술이 더 적합할 수 있다.
- C
- C++
- Rust
- 어셈블리어
- 실시간 운영체제
- PLC 언어
- 전용 하드웨어 기술 언어
Python은 이러한 시스템의 설정, 시험, 상위 제어와 데이터 분석에 사용할 수 있다.
모바일 및 브라우저 플랫폼
Python은 데스크톱·서버·연구 환경에서 강하지만 브라우저와 주요 모바일 플랫폼의 기본 언어는 아니다.
브라우저에서는 JavaScript와 WebAssembly가 기본 실행 환경이며, 모바일에서는 Swift·Objective-C와 Kotlin·Java가 공식 플랫폼 도구와 가장 강하게 통합된다.
Python으로 모바일·웹 응용 프로그램을 만들 수 있는 프레임워크가 존재하지만 다음 제약이 있을 수 있다.
- 네이티브 패키지 호환성
- 앱 크기
- 플랫폼별 UI 품질
- 공식 SDK의 Python 지원 부족
- 앱 심사와 빌드 도구 통합
- 백그라운드 실행과 권한 처리
- 디버깅 및 성능 분석 도구
따라서 Python은 모바일 앱의 서버와 인공지능 기능을 담당하고, 클라이언트는 플랫폼의 주된 언어로 개발하는 역할 분담이 흔하다.
오류가 실행 시점에 나타날 가능성
Python은 구문 오류와 일부 이름 범위 문제를 컴파일 단계에서 발견하지만, 객체가 실제로 필요한 속성과 연산을 제공하는지는 대부분 실행 시 확인한다.
테스트되지 않은 경로에서는 다음 오류가 늦게 발견될 수 있다.
- 존재하지 않는 속성
- 잘못된 인자 타입
- 예상과 다른 반환값
None처리 누락- 잘못된 키와 자료 구조
- 선택적 패키지 누락
- 특정 플랫폼 전용 기능 사용
정적 타입 검사, lint와 테스트가 이러한 위험을 줄이지만 Python의 동적 동작을 전부 사전에 증명하지는 못한다.
대규모 Python 프로젝트에서는 빠른 초기 개발이라는 장점을 유지하면서 실행 시 오류 위험을 줄이기 위해 자동화된 테스트와 타입 검사를 개발 과정에 포함하는 경우가 많다.
실행 환경의 동적 변경
Python은 모듈과 클래스, 함수를 실행 중 변경할 수 있다. decorator, monkey patching, metaclass와 동적 import는 프레임워크와 테스트 도구에 강력한 확장성을 제공한다.
그러나 지나친 동적 변경은 코드의 실제 동작을 소스만 보고 이해하기 어렵게 만든다.
다음 문제가 발생할 수 있다.
- IDE가 정의와 참조를 찾지 못함
- 타입 검사기가 구조를 추론하지 못함
- import 순서에 따라 동작이 달라짐
- 테스트와 운영 환경의 patch 상태가 다름
- 공개되지 않은 내부 구현에 의존함
- 프레임워크 upgrade에 취약해짐
동적 기능은 명확한 확장 지점과 제한된 범위에서 사용할 때 가장 유용하다.
보안과 공급망
Python 자체의 동적 실행 기능과 패키지 설치 과정은 보안상 주의가 필요하다.
eval(), exec(), pickle과 동적 import 등에 신뢰할 수 없는 데이터를 전달하면 임의 코드 실행으로 이어질 수 있다. 소스 배포판을 설치할 때는 빌드 backend와 관련 코드가 실행될 수 있다.
PyPI 패키지는 개발자의 환경에서 코드로 실행되므로 다음 대책을 적용할 수 있다.
- 정확한 패키지 이름 확인
- lock file과 hash 사용
- 취약점 검사
- 의존성 최소화
- 유지관리 상태와 공식 저장소 확인
- 격리된 빌드 및 실행 환경
- 사설 패키지 색인 정책
- 최소 권한으로 자동화 실행
- 신뢰할 수 없는 직렬화 데이터 거부
큰 생태계는 검증된 라이브러리를 제공하는 동시에 공격 가능한 공급망의 범위도 넓힌다.
버전 전환과 호환성
Python은 하위 호환성을 중요하게 다루지만 모든 오래된 코드를 영구히 유지하지는 않는다. 폐기된 API와 오래된 모듈은 경고 기간을 거쳐 제거될 수 있다.
Python 2에서 Python 3으로의 전환은 언어 설계를 개선했지만 오랜 기간 생태계를 분열시키고 대규모 이전 비용을 발생시킨 사례이다.
현대 Python은 연간 기능 릴리스 주기를 사용하므로 새로운 기능과 최적화가 빠르게 제공된다. 반면 라이브러리와 기업은 여러 Python 버전을 동시에 지원해야 할 수 있다.
새로운 버전을 도입할 때는 다음을 확인해야 한다.
- 외부 패키지 지원
- 네이티브 wheel 제공
- 폐기 예정 API
- 타입 검사 결과
- 성능 변화
- 빌드와 배포 도구 지원
구현체 차이
Python에는 CPython 외에 PyPy, Jython, IronPython과 분야별 구현체가 존재한다. 그러나 실제 생태계의 상당 부분은 CPython 동작과 C API를 기준으로 개발된다.
다른 구현체를 사용할 때 다음 차이가 발생할 수 있다.
- 지원하는 Python 버전
- 네이티브 확장 호환성
- 가비지 컬렉션 시점
- JIT 성능
- 시작 시간
- 스레드 모델
- JVM 또는 .NET 통합
- 표준 라이브러리 일부 기능
언어 수준에서는 이식 가능한 Python 코드라도 사용 중인 패키지가 CPython 전용 네이티브 확장에 의존하면 다른 구현체에서 실행하기 어려울 수 있다.
장점과 한계의 균형
Python은 다음 조건에서 특히 강점을 가진다.
- 개발과 변경 속도가 중요함
- 다양한 라이브러리를 조합해야 함
- 데이터와 외부 시스템을 연결해야 함
- 연구와 탐색적 실행이 필요함
- 여러 직군이 같은 언어를 사용함
- 성능 핵심을 네이티브 라이브러리에 위임할 수 있음
- 자동화와 내부 도구를 빠르게 개발해야 함
- 교육과 실제 산업의 연속성이 중요함
다음 조건에서는 Python만으로 전체 시스템을 구현하기보다 다른 언어와 역할을 나누는 편이 적합할 수 있다.
- 엄격한 하드 실시간성
- 극도로 낮은 지연 시간
- 메모리와 저장 공간이 매우 제한됨
- 작은 네이티브 실행 파일이 필요함
- 브라우저나 모바일 플랫폼의 깊은 통합
- 운영체제 커널과 드라이버
- 대규모 원소별 CPU 계산
- 네이티브 런타임을 허용하지 않는 환경
Python의 강점은 모든 영역에서 가장 빠르거나 가장 작은 언어라는 데 있지 않다. 비교적 읽기 쉬운 고수준 코드로 여러 분야의 라이브러리와 시스템을 연결하고, 필요한 부분만 다른 언어와 실행 환경에 맡길 수 있다는 점에 있다.
관련 문서
- Python 3 문서: History and License ↩
- Python 3 문서: Design and History FAQ ↩
- Python 언어 참조: Data model ↩
- PEP 484: Type Hints ↩
- Python 언어 참조: Alternate Implementations ↩
- Python.org: Applications for Python ↩
- Python Package Index ↩
- PEP 0: Index of Python Enhancement Proposals ↩
- Python Software Foundation: Mission ↩
- Python 문서: General Python FAQ — Why was Python created in the first place? ↩
- Python 문서: General Python FAQ — Why was Python created in the first place? ↩
- Guido van Rossum: Foreword for Programming Python ↩
- Python 문서: General Python FAQ — Why is it called Python? ↩
- Python 문서: General Python FAQ — Why was Python created in the first place? ↩
- Python Archives: Python 1.0.0 release ↩
- Python 문서: History and License ↩
- Python.org: What's new in Python 1.5 and beyond ↩
- Python.org: Python documentation by version ↩
- PEP 160: Python 1.6 Release Schedule ↩
- Python.org: Python 2.0 ↩
- Python 문서: What's New in Python 2.0 ↩
- Python Software Foundation: Executive Summary ↩
- Python 문서: History and License ↩
- PEP 373: Python 2.7 Release Schedule ↩
- PEP 0: Index of Python Enhancement Proposals ↩
- PEP 3000: Python 3000 ↩
- Python.org: Python 3.0 Release ↩
- Python 문서: What's New in Python 3.0 ↩
- PEP 602: Annual Release Cycle for Python ↩
- PEP 387: Backwards Compatibility Policy ↩
- PEP 8016: The Steering Council Model ↩
- PEP 13: Python Language Governance ↩
- PEP 20: The Zen of Python ↩
- PEP 8: Style Guide for Python Code ↩
- Python 문서: Design and History FAQ — Why does Python use indentation for grouping of statements? ↩
- PEP 20: The Zen of Python ↩
- Python 표준 라이브러리: itertools — Functions creating iterators for efficient looping ↩
- Python 언어 참조: Data model ↩
- Python/C API: Memory Management ↩
- Python 자습서: Brief Tour of the Standard Library — Batteries Included ↩
- Python 표준 라이브러리 ↩
- Python.org: Python For Beginners ↩
- Python 언어 참조 ↩
- Python 언어 참조: Encoding declarations ↩
- Python 언어 참조: Indentation ↩
- PEP 8: Indentation ↩
- PEP 257: Docstring Conventions ↩
- Python 언어 참조: Identifiers and keywords ↩
- Python 언어 참조: Literals ↩
- Python 언어 참조: Expressions ↩
- Python 언어 참조: Simple statements ↩
- Python 언어 참조: The if statement ↩
- PEP 634: Structural Pattern Matching — Specification ↩
- PEP 636: Structural Pattern Matching — Tutorial ↩
- Python Functional Programming HOWTO: Generator expressions and list comprehensions ↩
- Python 자습서: Errors and Exceptions ↩
- Python 언어 참조: The with statement ↩
- Python 언어 참조: Top-level components ↩
- Python 언어 참조: Data model ↩
- Python 언어 참조: Objects, values and types ↩
- Python 언어 참조: Objects, values and types ↩
- Python 언어 참조: The standard type hierarchy ↩
- Python Descriptor Guide ↩
- Python 언어 참조: Special method names ↩
- Python 언어 참조: Customizing class creation ↩
- Python 언어 참조: Objects, values and types ↩
- Python/C API: Introduction — Reference Counts ↩
- Python 표준 라이브러리: gc — Garbage Collector interface ↩
- Python 표준 라이브러리: typing — Support for type hints ↩
- Python Typing Specification: Type system concepts ↩
- Python Typing Specification: Protocols ↩
- PEP 484: Type Hints ↩
- PEP 526: Syntax for Variable Annotations ↩
- Python 표준 라이브러리: annotationlib ↩
- PEP 585: Type Hinting Generics In Standard Collections ↩
- Python Typing Specification: Tuples ↩
- PEP 604: Allow writing union types as X | Y ↩
- Python Typing Specification: Special types — Any ↩
- Python Typing Specification: Literal types ↩
- PEP 695: Type Parameter Syntax ↩
- Python Typing Specification: Generics ↩
- PEP 646: Variadic Generics ↩
- PEP 673: Self Type ↩
- Python Typing Specification: Overloads ↩
- Python Typing Specification: Typed dictionaries ↩
- Python Typing Specification: Dataclasses ↩
- Python Typing Specification: Distributing type information ↩
- Python Typing Specification ↩
- PEP 729: Typing governance process ↩
- Python 표준 라이브러리: typing ↩
- Python Enhancement Proposals: Typing PEPs ↩
- Python Typing Specification: About this specification ↩
- Python 언어 참조: Function definitions ↩
- PEP 570: Python Positional-Only Parameters ↩
- PEP 3102: Keyword-Only Arguments ↩
- Python 자습서: Default Argument Values ↩
- Python 언어 참조: Execution model ↩
- PEP 227: Statically Nested Scopes ↩
- Python 언어 참조: Function definitions ↩
- Python 표준 라이브러리: functools.wraps ↩
- Python 표준 라이브러리: sys.getrecursionlimit ↩
- Python 자습서: Classes ↩
- Python 언어 참조: Class instances ↩
- Python 언어 참조: User-defined methods ↩
- Python 자습서: Private Variables ↩
- Python 자습서: Multiple Inheritance ↩
- Python 언어 참조: Classes ↩
- Python 표준 라이브러리: abc — Abstract Base Classes ↩
- Python 용어집: abstract base class ↩
- Python 표준 라이브러리: dataclasses — Data Classes ↩
- Python 표준 라이브러리: Coroutines and Tasks ↩
- Python 언어 참조: Iterator types ↩
- PEP 479: Change StopIteration handling inside generators ↩
- Python 언어 참조: Yield expressions ↩
- PEP 255: Simple Generators ↩
- PEP 342: Coroutines via Enhanced Generators ↩
- PEP 380: Syntax for Delegating to a Subgenerator ↩
- Python 표준 라이브러리: itertools ↩
- PEP 492: Coroutines with async and await syntax ↩
- Python 표준 라이브러리: asyncio — Asynchronous I/O ↩
- Python 표준 라이브러리: Task Object ↩
- Python 표준 라이브러리: Task Groups ↩
- Python 표준 라이브러리: Futures ↩
- Python 언어 참조: Asynchronous iterators ↩
- PEP 525: Asynchronous Generators ↩
- PEP 530: Asynchronous Comprehensions ↩
- Python 언어 참조: The async with statement ↩
- Python 자습서: Errors and Exceptions ↩
- Python 자습서: Syntax Errors ↩
- Python 표준 라이브러리: Built-in Exceptions ↩
- Python 언어 참조: The raise statement ↩
- Python 언어 참조: The try statement ↩
- PEP 758: Allow except and except* expressions without parentheses ↩
- PEP 765: Disallow return/break/continue that exit a finally block ↩
- PEP 3134: Exception Chaining and Embedded Tracebacks ↩
- PEP 678: Enriching Exceptions with Notes ↩
- PEP 654: Exception Groups and except* ↩
- Python 언어 참조: The with statement ↩
- Python 언어 참조: With Statement Context Managers ↩
- Python 표준 라이브러리: contextlib.contextmanager ↩
- Python 표준 라이브러리: contextlib.ExitStack ↩
- Python 언어 참조: Regular packages ↩
- Python 언어 참조: Namespace packages ↩
- Python 언어 참조: The import system ↩
- Python 언어 참조: The module cache ↩
- Python Packaging User Guide: src layout vs flat layout ↩
- Python Packaging User Guide: pyproject.toml specification ↩
- Python Packaging User Guide: Writing your pyproject.toml ↩
- Python 표준 라이브러리: importlib ↩
- Python 언어 참조: Searching ↩
- Python Packaging User Guide: Package Formats ↩
- Python Developer's Guide ↩
- Python 표준 라이브러리: dis — Disassembler for Python bytecode ↩
- Python/C API: Code Objects ↩
- Python 자습서: Compiled Python files ↩
- Python HOWTO: Python support for free threading ↩
- PEP 659: Specializing Adaptive Interpreter ↩
- PEP 744: JIT Compilation ↩
- PyPy Features ↩
- PyPy: Python compatibility ↩
- Jython ↩
- IronPython ↩
- Python 표준 라이브러리 ↩
- Python 자습서: Brief Tour of the Standard Library ↩
- Python 표준 라이브러리 소개 ↩
- Python 표준 라이브러리 ↩
- Python 표준 라이브러리: Concurrent Execution ↩
- Python 용어집: parallel processing ↩
- Python 표준 라이브러리: threading — Thread-based parallelism ↩
- Python/C API: Thread states and the global interpreter lock ↩
- Python HOWTO: Python support for free threading ↩
- Python 표준 라이브러리: queue — A synchronized queue class ↩
- Python 표준 라이브러리: ThreadPoolExecutor ↩
- Python 표준 라이브러리: multiprocessing — Process-based parallelism ↩
- Python 표준 라이브러리: ProcessPoolExecutor ↩
- Python 표준 라이브러리: multiprocessing.shared_memory ↩
- Python 표준 라이브러리: concurrent.futures ↩
- Python/C API: Multiple interpreters in a Python process ↩
- Python 표준 라이브러리: concurrent.interpreters ↩
- Python 표준 라이브러리: InterpreterPoolExecutor ↩
- Python 표준 라이브러리: asyncio — Asynchronous I/O ↩
- Python 표준 라이브러리: asyncio and free-threaded Python ↩
- Python/C API Reference Manual ↩
- Extending Python with C or C++ ↩
- Python/C API: Module Objects ↩
- pybind11 documentation ↩
- pybind11: First steps ↩
- pybind11: Free-threading support ↩
- pybind11: Build systems ↩
- Cython ↩
- Cython: Basic Tutorial ↩
- Cython: Source Files and Compilation ↩
- PyO3 User Guide ↩
- PyO3: Building and distribution ↩
- gopy ↩
- NumPy: F2PY user guide ↩
- NumPy: Using F2PY bindings in Python ↩
- Embedding Python in Another Application ↩
- pybind11: Embedding the interpreter ↩
- Python/C API: C API Stability ↩
- PyO3: Type stub generation ↩
- Python Packaging User Guide: Writing pyproject.toml ↩
- Python Packaging User Guide: Package Formats ↩
- Binary distribution format ↩
- Platform Compatibility Tags ↩
- PyPI 사용자 문서 ↩
- PyPI 도움말 ↩
- PyPI 프로젝트 상태 ↩
- pip 문서 ↩
- Python Packaging User Guide: pyproject.toml 작성 ↩
- Python Packaging User Guide: Package Formats ↩
- PDM 빌드 설정 ↩
- Python Packaging User Guide: pylock.toml 명세 ↩
- uv 문서 ↩
- uv build backend ↩
- Poetry 저장소 ↩
- PDM 문서 ↩
- PDM 빌드 설정 ↩
- Hatch 문서 ↩
- Hatch 환경 관리 ↩
- pipx 문서 ↩
- pipx 프로젝트 범위 ↩
- Pixi 문서 ↩
- Rye에서 uv로 이전 ↩
- PyCharm 기능 ↩
- Visual Studio Code의 Python 지원 ↩
- Spyder 문서 ↩
- Thonny ↩
- Pyright ↩
- marimo 문서 ↩
- Nox 문서 ↩
- Ruff 문서 ↩
- pre-commit ↩
- Python 표준 라이브러리: pdb ↩
- debugpy ↩
- py-spy ↩
- Memray ↩
- Typer ↩
- Textual ↩
- Shiv 문서 ↩
- PyInstaller의 동작 방식 ↩
- PyInstaller 사용법 ↩
- Nuitka 사용자 설명서 ↩
- Briefcase ↩
- PyOxidizer 개요 ↩
- PyOxidizer resource 포함 방식 ↩
- Python.org: Applications for Python ↩
- Django: Django at a glance ↩
- FastAPI: Features ↩
- pandas: About ↩
- SciPy.org: Scientific computing tools for Python ↩
- scikit-learn ↩
- PyTorch ↩
- Apache Airflow ↩
- Ansible: Introduction ↩
- Python.org: About Python ↩
- MicroPython ↩
- Python.org: Applications for Python ↩
- Python.org: Scientific and Numeric ↩
- Python.org: Software Development ↩
- Python Success Stories: Industrial Light & Magic ↩
- Python 문서: 다른 응용 프로그램에 Python 내장하기 ↩
- Python.org: Business Applications ↩
- Python.org: Python Success Stories ↩
- Python.org: Education ↩
- Python 문서: Extending and Embedding the Python Interpreter ↩
- Python 언어 참조: Introduction ↩
- PEP 1: PEP Purpose and Guidelines ↩
- Python Developer's Guide: Changing Python ↩
- PEP 13: Python Language Governance ↩
- PEP 387: Backwards Compatibility Policy ↩
- PEP 602: Annual Release Cycle for Python ↩
- Python Typing Specification ↩
- The Python Type System ↩
- Python FAQ: Why was Python created in the first place? ↩
- Python FAQ: Why was Python created in the first place? ↩
- Guido van Rossum: Foreword for Programming Python ↩
- Python FAQ: Why was Python created in the first place? ↩
- Python HOWTO: Functional Programming ↩
- PEP 202: List Comprehensions ↩
- PEP 289: Generator Expressions ↩
- PEP 635: Structural Pattern Matching — Motivation and Rationale ↩
- PEP 255: Simple Generators ↩
- Ruby FAQ: How does Ruby compare with Python? ↩
- PEP 622: Structural Pattern Matching ↩
- PEP 484: Type Hints ↩
- Python Success Stories: Industrial Light & Magic ↩
- Python Software Foundation ↩
- Python.org: About Python ↩
- Python For Beginners ↩
- GitHub Octoverse 2024 ↩
- Stack Overflow Developer Survey 2017 ↩
- Stack Overflow Developer Survey 2018 ↩
- Stack Overflow Developer Survey 2019 ↩
- GitHub Octoverse 2024 ↩
- GitHub Octoverse 2025 ↩
- GitHub: Why Python keeps growing ↩
- GitHub Octoverse 2025 ↩
- Python FAQ: Python은 초급 프로그래밍에 적합한가 ↩
- Python FAQ: Python으로 무엇을 할 수 있는가 ↩
- Python.org: About Python ↩
- Python.org: Applications for Python ↩
- Python Design and History FAQ ↩
- Python 문서: 자유 스레딩 지원 ↩
- Python 문서: 자유 스레딩의 알려진 제한 ↩
- Python Packaging User Guide: Python 패키징 개요 ↩
- Python Packaging User Guide: 도구 권장 사항 ↩
- Python Packaging User Guide: Virtual Environment ↩