Python 강좌 : 제 48강 - 컨텍스트 매니저
컨텍스트 매니저(Context Manager)
컨텍스트 매니저(Context Manager)란 with 문과 함께 사용되어 코드 블록에 진입할 때와 빠져나올 때 수행할 작업을 정의하는 객체입니다.
파일, 네트워크 연결, 데이터베이스 연결, 잠금(Lock)처럼 사용한 후에 반드시 정리해야 하는 자원을 다룰 때 주로 활용합니다.
컨텍스트 매니저를 사용하면 코드 블록이 정상적으로 끝나거나 도중에 예외가 발생하더라도 정리 작업이 항상 실행됩니다.
즉, try-finally 구문으로 작성해야 하는 자원 정리 코드를 간결하고 안전하게 작성할 수 있습니다.
이번 강좌에서는 먼저 with 없이 자원을 다룰 때 무엇이 새는지 확인하고, with 문의 사용법과 내부 프로토콜(__enter__, __exit__), 예외를 전달하거나 억제하는 방법, 그리고 contextlib 모듈의 contextmanager, suppress, ExitStack, closing, chdir를 차례대로 다룹니다.
컨텍스트 매니저가 필요한 이유
opened = []
def save(text):
f = open("log.txt", "w", encoding="utf-8")
opened.append(f)
f.write(text)
number = int(text)
f.close()
return number
try:
save("abc")
except ValueError as e:
print("오류 :", e)
print(opened[0].closed)- 결과
- 오류 : invalid literal for int() with base 10: ‘abc’
False
with 없이 파일을 열고 close()로 직접 닫는 코드입니다. 예제에서는 파일 객체를 확인하기 위해 opened 리스트에 저장해 두었습니다.
정상적인 흐름이라면 f.close()가 실행되지만, 그 앞의 int(text)에서 ValueError가 발생하면 함수가 그 자리에서 중단되므로 f.close()까지 도달하지 못합니다.
그 결과 마지막 줄의 closed 속성이 False, 즉 파일이 열린 채로 남습니다. 이를 자원 누수(Resource Leak)라 합니다.
열린 파일이 남아 있으면 다음과 같은 문제가 생길 수 있습니다.
- 파일에 쓴 내용은 버퍼에 머물러 있다가 닫을 때 디스크에 기록되므로, 프로그램이 비정상 종료되면 내용이 기록되지 않을 수 있습니다.
- 운영 체제가 한 프로세스에 허용하는 열린 파일의 수에는 한계가 있으므로, 반복문 안에서 누수가 쌓이면 더 이상 파일을 열 수 없게 됩니다.
- Windows에서는 열린 파일을 다른 프로그램이 삭제하거나 이동하지 못하는 경우가 있습니다.
이 문제는 파일에만 해당하지 않습니다. 네트워크 연결, 데이터베이스 연결, 쓰레드의 잠금(Lock)도 해제하지 않으면 연결이 고갈되거나 다른 쓰레드가 영원히 기다리게 됩니다.
opened = []
def save(text):
f = open("log.txt", "w", encoding="utf-8")
opened.append(f)
try:
f.write(text)
return int(text)
finally:
f.close()
def save_with(text):
with open("log.txt", "w", encoding="utf-8") as f:
opened.append(f)
f.write(text)
return int(text)
for func in (save, save_with):
try:
func("abc")
except ValueError as e:
print("오류 :", e)
print([f.closed for f in opened])- 결과
- 오류 : invalid literal for int() with base 10: ‘abc’
오류 : invalid literal for int() with base 10: ‘abc’
[True, True]
같은 오류가 발생해도 이번에는 두 파일 모두 closed가 True입니다.
save()는 try-finally 구문을 사용했습니다. finally 블록은 예외가 발생하든, return으로 빠져나가든 반드시 실행되므로 파일이 항상 닫힙니다. 하지만 자원을 사용할 때마다 try, finally, close()를 빠짐없이 작성해야 하고, 자원이 두세 개로 늘어나면 들여쓰기가 깊어집니다.
save_with()는 같은 동작을 with 문으로 작성했습니다. 정리 코드를 호출하는 쪽이 아니라 자원 객체 자신이 알고 있으므로, 사용하는 쪽은 with 한 줄만 작성하면 됩니다.
즉, 컨텍스트 매니저는 try-finally의 획득 → 사용 → 정리 패턴을 객체 안에 묶어 재사용할 수 있게 만든 것입니다.
with 문
with open("example.txt", "w", encoding="utf-8") as f:
f.write("컨텍스트 매니저")
print(f.closed)
print(f.closed)
with open("example.txt", encoding="utf-8") as f:
print(f.read())- 결과
- False
True
컨텍스트 매니저
with 컨텍스트 매니저 as 변수:의 형태로 사용합니다.
open() 함수가 반환하는 파일 객체는 컨텍스트 매니저이므로, with 문과 함께 사용할 수 있습니다.
with 블록 내부에서는 파일이 열려 있으며, 블록을 빠져나오면 자동으로 파일이 닫힙니다.
그러므로 close() 메서드를 직접 호출하지 않아도 되며, 블록 내부에서 예외가 발생하더라도 파일이 닫힙니다.
as 뒤의 변수에는 컨텍스트 매니저의 __enter__() 메서드가 반환한 값이 할당됩니다. as 변수는 필요하지 않다면 생략할 수 있습니다.
결과의 첫 줄 False는 with 블록 안에서 확인한 값이므로 파일이 열려 있는 상태입니다. 두 번째 줄 True는 블록을 빠져나온 뒤 확인한 값으로, close()를 호출하지 않았는데도 파일이 닫혀 있습니다. 마지막 줄은 다시 읽기 모드로 열어 앞에서 쓴 내용을 읽은 결과입니다.
with 블록의 범위는 들여쓰기로 결정됩니다. 들여쓰기가 끝나는 지점이 자원이 정리되는 지점이므로, 코드만 보고도 파일이 언제까지 열려 있는지 알 수 있습니다.
-
Tip : 파일 읽기와 쓰기는 제 25강 - 파일 읽기 & 쓰기에서 확인할 수 있습니다.
-
Tip :
with블록이 끝나도 변수f는 사라지지 않습니다. 다만 파일이 닫혔으므로 읽거나 쓸 수 없습니다.
컨텍스트 매니저 프로토콜
with 문이 파일을 자동으로 닫을 수 있는 것은 파일 객체가 정해진 두 메서드를 가지고 있기 때문입니다. 이처럼 특정 문법과 함께 동작하기 위해 객체가 구현해야 하는 메서드의 약속을 프로토콜(Protocol)이라 합니다.
두 메서드만 구현하면 직접 만든 클래스도 with 문과 함께 사용할 수 있습니다.
class Resource:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"{self.name} 열기")
return self
def __exit__(self, exc_type, exc_value, traceback):
print(f"{self.name} 닫기")
return False
with Resource("데이터베이스") as r:
print(f"{r.name} 사용 중")
print("with 문 종료")- 결과
- 데이터베이스 열기
데이터베이스 사용 중
데이터베이스 닫기
with 문 종료
__enter__()와 __exit__() 메서드를 구현한 클래스는 컨텍스트 매니저로 사용할 수 있습니다.
__enter__(self) 메서드는 with 블록에 진입할 때 호출되며, 반환값은 as 뒤의 변수에 할당됩니다.
__exit__(self, exc_type, exc_value, traceback) 메서드는 with 블록을 빠져나올 때 호출됩니다.
with 문은 대략 다음과 같은 순서로 실행됩니다.
- 컨텍스트 매니저 객체를 생성합니다.
__enter__()메서드를 호출하고, 반환값을as뒤의 변수에 할당합니다.with블록의 코드를 실행합니다.- 블록이 끝나거나 예외가 발생하면
__exit__()메서드를 호출합니다.
- Tip :
__enter__()에서self를 반환하는 경우가 많지만, 파일 객체나 연결 객체처럼 다른 객체를 반환할 수도 있습니다.
class Resource:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"{self.name} 열기")
return self
def __exit__(self, exc_type, exc_value, traceback):
print(f"{self.name} 닫기")
return False
manager = Resource("데이터베이스")
r = manager.__enter__()
try:
print(f"{r.name} 사용 중")
except BaseException as e:
if not manager.__exit__(type(e), e, e.__traceback__):
raise
else:
manager.__exit__(None, None, None)
print("with 문 종료")- 결과
- 데이터베이스 열기
데이터베이스 사용 중
데이터베이스 닫기
with 문 종료
앞의 with 문을 with 없이 같은 순서로 풀어 쓴 코드입니다. 결과가 앞의 예제와 완전히 같습니다.
블록에서 예외가 발생하면 except 쪽에서 예외 정보를 담아 __exit__()를 호출하고, 그 반환값이 거짓이면 raise로 예외를 다시 발생시킵니다. 예외가 없으면 else 쪽에서 세 인수 모두 None으로 __exit__()를 호출합니다.
즉, with 문은 이 try-except-else 구조를 매번 작성하지 않도록 인터프리터가 대신 실행해 주는 문법입니다. 다음 섹션의 예외 전달과 예외 억제는 이 구조의 if not ...: raise 부분에서 결정됩니다.
class Resource:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"{self.name} 열기")
def __exit__(self, exc_type, exc_value, traceback):
print(f"{self.name} 닫기")
try:
with Resource("데이터베이스") as r:
print(r.name)
except AttributeError as e:
print("AttributeError :", e)- 결과
- 데이터베이스 열기
데이터베이스 닫기
AttributeError : ‘NoneType’ object has no attribute ‘name’
__enter__()에서 return self를 빠뜨리는 것은 자주 하는 실수입니다.
반환값이 없으면 None이 반환되므로 as r의 r에 None이 할당되고, r.name에서 AttributeError가 발생합니다.
이때에도 __exit__()는 실행되어 데이터베이스 닫기가 출력된 것을 확인할 수 있습니다. 블록 안에서 무슨 일이 일어나든 정리 작업은 실행된다는 컨텍스트 매니저의 성질이 그대로 적용됩니다.
예외 전달
with 블록 안에서 예외가 발생했을 때 컨텍스트 매니저는 두 가지 일을 해야 합니다. 하나는 자원을 정리하는 것이고, 다른 하나는 그 예외를 바깥으로 알릴지 말지 결정하는 것입니다.
대부분의 컨텍스트 매니저는 정리만 하고 예외는 그대로 전달합니다. 예외를 삼켜 버리면 호출한 쪽은 작업이 실패했다는 사실을 알 수 없기 때문입니다.
class Resource:
def __enter__(self):
print("열기")
return self
def __exit__(self, exc_type, exc_value, traceback):
print("닫기")
print(exc_type)
print(exc_value)
return False
try:
with Resource():
print("작업 시작")
raise ValueError("잘못된 값")
print("작업 종료")
except ValueError as e:
print("외부에서 처리 :", e)- 결과
- 열기
작업 시작
닫기
<class ‘ValueError’>
잘못된 값
외부에서 처리 : 잘못된 값
with 블록에서 예외가 발생하면, 블록의 나머지 코드는 실행되지 않고 즉시 __exit__() 메서드가 호출됩니다.
__exit__() 메서드의 세 매개변수에는 발생한 예외의 정보가 전달됩니다.
| 매개변수 | 설명 |
|---|---|
| exc_type | 예외의 클래스 |
| exc_value | 예외 인스턴스 |
| traceback | 트레이스백 객체 |
예외 없이 블록이 정상적으로 끝난 경우에는 세 매개변수 모두 None이 전달됩니다.
__exit__() 메서드가 False나 None처럼 거짓으로 평가되는 값을 반환하면, 정리 작업을 수행한 후 예외가 그대로 외부로 전달됩니다.
그러므로 위의 예제에서는 __exit__()가 실행된 후, 외부의 try-except 구문에서 예외를 처리합니다.
결과를 순서대로 보면, 열기와 작업 시작이 출력된 뒤 raise에서 예외가 발생하므로 작업 종료는 출력되지 않습니다. 곧바로 __exit__()가 호출되어 닫기와 함께 예외 클래스(<class 'ValueError'>)와 예외 메시지(잘못된 값)가 출력됩니다. 마지막으로 __exit__()가 False를 반환했으므로 예외가 바깥의 except까지 전달되어 외부에서 처리가 출력됩니다.
__exit__()에서 예외 정보를 받을 수 있으므로, 예외가 발생했을 때만 트랜잭션을 되돌리고(rollback) 정상 종료일 때는 확정하는(commit) 식으로 상황에 따라 다른 정리 작업을 할 수 있습니다.
- Tip :
__exit__()에서 아무것도 반환하지 않으면None이 반환되므로, 예외는 외부로 전달됩니다.
예외 억제
class IgnoreError:
def __init__(self, *exceptions):
self.exceptions = exceptions
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is not None and issubclass(exc_type, self.exceptions):
print(f"{exc_type.__name__} 억제 : {exc_value}")
return True
return False
with IgnoreError(ZeroDivisionError):
print(1 / 0)
print("실행되지 않음")
print("다음 코드 실행")
try:
with IgnoreError(ZeroDivisionError):
int("abc")
except ValueError as e:
print("억제되지 않음 :", e)- 결과
- ZeroDivisionError 억제 : division by zero
다음 코드 실행
억제되지 않음 : invalid literal for int() with base 10: ‘abc’
__exit__() 메서드가 True처럼 참으로 평가되는 값을 반환하면, 발생한 예외가 억제되어 외부로 전달되지 않습니다.
예외가 억제되면 with 문 다음의 코드부터 정상적으로 실행됩니다. 단, 예외가 발생한 지점 이후의 블록 코드는 실행되지 않습니다.
IgnoreError 클래스는 지정한 예외만 억제하고, 그 외의 예외는 False를 반환해 그대로 전달합니다.
첫 번째 with 문에서는 1 / 0이 ZeroDivisionError를 발생시키고, issubclass() 검사를 통과해 True가 반환되므로 예외가 사라집니다. 그래서 실행되지 않음은 건너뛰고 다음 코드 실행이 출력됩니다.
두 번째 with 문에서는 ValueError가 발생했지만 억제 대상이 아니므로 False가 반환되고, 바깥의 except ValueError가 예외를 처리합니다.
issubclass()를 사용했으므로 지정한 예외의 하위 클래스도 함께 억제됩니다. 예를 들어 IgnoreError(ArithmeticError)는 ZeroDivisionError도 억제합니다.
- Tip : 모든 예외를 무조건 억제하면 오류를 발견하기 어려워지므로, 억제할 예외를 명확하게 지정합니다.
class AlwaysTrue:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return True
def divide(a, b):
with AlwaysTrue():
return a / b
print(divide(10, 2))
print(divide(10, 0))- 결과
- 5.0
None
__exit__()가 조건 없이 True를 반환하면 어떤 일이 생기는지 보여 주는 예제입니다.
divide(10, 0)에서 ZeroDivisionError가 발생했지만 억제되었고, return 문이 실행되지 못한 채 함수가 끝나서 오류 없이 None이 반환되었습니다.
호출한 쪽은 나눗셈이 실패했다는 사실을 전혀 알 수 없고, 나중에 None을 계산에 사용하는 다른 위치에서 엉뚱한 오류가 발생하게 됩니다. 예외 억제는 무시해도 안전한 예외를 명확하게 지정할 때만 사용합니다.
contextlib.contextmanager
클래스로 컨텍스트 매니저를 만들려면 __enter__()와 __exit__()를 나누어 작성해야 하므로, 간단한 정리 작업에도 코드가 길어집니다. 또한 진입 코드와 정리 코드가 서로 다른 메서드에 떨어져 있어, 짝이 맞는지 한눈에 보기 어렵습니다.
contextlib 모듈은 함수 하나로 컨텍스트 매니저를 만드는 방법을 제공합니다.
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>")
try:
yield name.upper()
finally:
print(f"</{name}>")
with tag("div") as value:
print(value)
try:
with tag("span"):
raise RuntimeError("오류 발생")
except RuntimeError as e:
print("오류 :", e)- 결과
- <div>
DIV
</div>
<span>
</span>
오류 : 오류 발생
contextlib.contextmanager 데코레이터를 사용하면 클래스를 정의하지 않고 생성자(Generator) 함수로 컨텍스트 매니저를 작성할 수 있습니다.
yield 이전의 코드는 __enter__(), yield 이후의 코드는 __exit__()의 역할을 합니다.
yield로 반환한 값은 as 뒤의 변수에 할당됩니다.
with 블록에서 예외가 발생하면, 생성자 함수 내부의 yield 위치에서 해당 예외가 다시 발생합니다.
그러므로 예외가 발생해도 정리 코드가 실행되도록 yield를 try-finally 구문으로 감싸야 합니다.
-
Tip :
try-finally없이 작성하면 예외가 발생했을 때yield이후의 정리 코드가 실행되지 않습니다. -
Tip : 생성자는 제 42강 - 생성자, 데코레이터는 제 47강 - 데코레이터에서 확인할 수 있습니다.
동작 원리를 단계별로 보면 다음과 같습니다.
tag("div")를 호출하면 함수 본문이 바로 실행되지 않고, 생성자를 감싼 컨텍스트 매니저 객체가 만들어집니다.with문이 진입하면 내부적으로next()가 호출되어 생성자가 첫 번째yield까지 실행됩니다. 이때<div>가 출력되고,yield한 값"DIV"가value에 할당됩니다.- 생성자는
yield위치에서 일시 중지된 상태로with블록이 끝나기를 기다립니다. - 블록이 정상적으로 끝나면 생성자를 다시 실행해
yield다음 코드인finally블록이 실행되고,</div>가 출력됩니다. - 블록에서 예외가 발생하면 생성자의
yield위치에서 그 예외가 다시 발생합니다.finally가 정리 코드를 실행한 뒤 예외는 바깥으로 전달되므로,</span>다음에오류 : 오류 발생이 출력됩니다.
생성자가 yield에서 멈췄다가 다시 이어서 실행되는 성질을, 진입과 종료라는 두 시점에 그대로 대응시킨 것입니다.
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>")
yield
print(f"</{name}>")
try:
with tag("span"):
raise RuntimeError("오류 발생")
except RuntimeError as e:
print("오류 :", e)- 결과
- <span>
오류 : 오류 발생
try-finally를 생략했을 때의 결과입니다.
예외가 yield 위치에서 다시 발생하면 생성자 함수가 그 자리에서 종료되므로, yield 다음 줄의 </span>이 출력되지 않습니다.
예외가 없을 때는 정상적으로 동작하기 때문에 테스트에서 놓치기 쉬운 실수입니다. 정리 코드가 있다면 항상 try-finally로 감쌉니다.
from contextlib import contextmanager
@contextmanager
def ignore(*exceptions):
try:
yield
except exceptions as e:
print(f"{type(e).__name__} 억제 : {e}")
with ignore(KeyError):
data = {}
print(data["key"])
print("다음 코드 실행")- 결과
- KeyError 억제 : ‘key’
다음 코드 실행
생성자 함수 내부에서 yield를 try-except 구문으로 감싸 예외를 처리하면, 해당 예외는 억제됩니다.
except 블록에서 예외를 다시 발생시키지 않으면, __exit__()가 True를 반환한 것과 같은 효과가 됩니다.
data["key"]에서 발생한 KeyError가 yield 위치에서 다시 발생하고, 생성자 안의 except가 이를 처리했으므로 예외가 사라집니다. 그 결과 억제 메시지 다음에 다음 코드 실행이 출력됩니다.
앞의 IgnoreError 클래스와 같은 기능을 절반 정도의 코드로 작성한 것입니다. 상태를 여러 메서드에서 공유해야 하는 복잡한 경우가 아니라면 @contextmanager가 더 읽기 쉽습니다.
- Tip :
@contextmanager함수에서yield는 정확히 한 번 실행되어야 합니다.yield를 실행하지 않거나 두 번 실행하면RuntimeError가 발생합니다.
contextlib.suppress
import os
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("not_exist.txt")
print("실행되지 않음")
print("파일이 없어도 오류가 발생하지 않음")
with suppress(KeyError, IndexError):
[][0]
print("완료")- 결과
- 파일이 없어도 오류가 발생하지 않음
완료
contextlib.suppress(*예외)는 지정한 예외를 억제하는 컨텍스트 매니저입니다.
앞서 직접 구현한 IgnoreError 클래스와 같은 기능을 제공합니다.
위의 코드는 다음의 try-except 구문과 같은 의미입니다.
import os
try:
os.remove("not_exist.txt")
except FileNotFoundError:
pass예외를 무시해도 되는 상황을 간결하게 표현할 수 있습니다.
결과를 보면 not_exist.txt가 없어 os.remove()에서 FileNotFoundError가 발생했지만 억제되었고, 바로 다음 줄의 실행되지 않음은 출력되지 않았습니다. 두 번째 with 문처럼 여러 예외를 한 번에 지정할 수도 있으며, [][0]의 IndexError가 억제되어 완료가 출력됩니다.
try-except-pass와 기능은 같지만, suppress라는 이름 자체가 “이 예외는 의도적으로 무시한다”는 의도를 드러냅니다. 반대로 예외가 발생했을 때 로그를 남기거나 다른 값을 사용해야 한다면 try-except를 사용합니다.
- Tip : 예외가 발생한 지점 이후의 블록 코드는 실행되지 않으므로, 예외가 발생할 수 있는 코드만 블록에 포함합니다.
contextlib.ExitStack
with 문은 사용할 컨텍스트 매니저를 코드에 미리 적어 두어야 합니다. 하지만 사용자가 선택한 파일 목록처럼 개수가 실행 중에 정해지는 경우에는 with 문을 몇 개 써야 할지 알 수 없습니다.
ExitStack은 정리 작업을 스택에 하나씩 쌓아 두었다가, 블록을 빠져나올 때 한꺼번에 정리합니다.
from contextlib import ExitStack
names = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
files = [stack.enter_context(open(name, "w", encoding="utf-8")) for name in names]
for i, f in enumerate(files):
f.write(str(i))
stack.callback(print, "콜백 실행")
print([f.closed for f in files])
print([f.closed for f in files])- 결과
- [False, False, False]
콜백 실행
[True, True, True]
contextlib.ExitStack은 여러 개의 컨텍스트 매니저를 하나로 묶어 관리하는 컨텍스트 매니저입니다.
열어야 하는 파일의 개수처럼 사용할 컨텍스트 매니저의 개수를 실행 중에 결정해야 하는 경우에 유용합니다.
enter_context(컨텍스트 매니저) 메서드는 컨텍스트 매니저의 __enter__()를 호출해 반환값을 돌려주고, __exit__()를 스택에 등록합니다.
callback(함수, *인수) 메서드는 with 블록을 빠져나올 때 호출할 함수를 등록합니다.
ExitStack의 with 블록을 빠져나오면, 등록된 정리 작업이 등록한 순서의 역순(LIFO)으로 실행됩니다.
그러므로 마지막에 등록한 콜백이 먼저 실행된 후, 파일이 역순으로 닫힙니다.
결과의 첫 줄은 블록 안에서 확인한 값으로 세 파일이 모두 열려 있습니다. 블록을 빠져나오면서 콜백 실행이 출력되고 세 파일이 닫히므로, 마지막 줄은 모두 True입니다.
역순으로 정리하는 이유는 나중에 연 자원이 먼저 연 자원에 의존하는 경우가 많기 때문입니다. 예를 들어 데이터베이스 연결을 먼저 열고 그 연결로 커서를 열었다면, 커서를 먼저 닫고 연결을 닫아야 합니다.
ExitStack을 사용하지 않고 리스트에 파일을 열어 두면, 세 번째 파일을 여는 도중 오류가 발생했을 때 이미 연 두 파일을 닫을 방법이 없습니다. ExitStack은 enter_context()가 성공한 자원만 등록하므로 이 경우에도 이미 연 자원만 정확하게 정리합니다.
- Tip :
pop_all()메서드를 사용하면 등록된 정리 작업을 새로운ExitStack으로 옮겨, 현재with블록이 끝나도 정리 작업이 실행되지 않게 할 수 있습니다.
contextlib.closing
오래된 라이브러리나 직접 만든 클래스 중에는 close() 메서드는 있지만 컨텍스트 매니저 프로토콜은 구현하지 않은 객체가 있습니다. 이런 객체를 with 문에 그대로 사용하면 TypeError가 발생하므로, closing()으로 감싸서 사용합니다.
from contextlib import closing
class Connection:
def query(self):
return "결과"
def close(self):
print("연결 종료")
with closing(Connection()) as conn:
print(conn.query())- 결과
- 결과
연결 종료
contextlib.closing(객체)는 with 블록을 빠져나올 때 객체의 close() 메서드를 호출하는 컨텍스트 매니저입니다.
__enter__()와 __exit__()를 구현하지 않았지만 close() 메서드를 가진 객체를 with 문과 함께 사용할 수 있게 만듭니다.
as 뒤의 변수에는 전달한 객체가 그대로 할당됩니다.
conn.query()의 결과인 결과가 먼저 출력되고, 블록을 빠져나오면서 close()가 호출되어 연결 종료가 출력됩니다.
closing()은 앞에서 다룬 @contextmanager로 다음과 같이 직접 만들 수 있을 정도로 단순한 도구입니다.
from contextlib import contextmanager
@contextmanager
def my_closing(thing):
try:
yield thing
finally:
thing.close()
class Connection:
def close(self):
print("연결 종료")
with my_closing(Connection()) as conn:
print(type(conn).__name__)- 결과
- Connection
연결 종료
- Tip : 이미 컨텍스트 매니저 프로토콜을 구현한 객체에는
closing()을 사용할 필요가 없습니다.
여러 컨텍스트 매니저 사용하기
파일 하나를 읽어 다른 파일에 쓰는 것처럼, 여러 자원을 동시에 사용해야 하는 경우가 많습니다. with 문을 중첩하면 들여쓰기가 자원 개수만큼 깊어지므로, 하나의 with 문에 여러 컨텍스트 매니저를 나열할 수 있습니다.
with (
open("a.txt", encoding="utf-8") as a,
open("b.txt", encoding="utf-8") as b,
open("c.txt", encoding="utf-8") as c,
):
print(a.read(), b.read(), c.read())- 결과
- 0 1 2
하나의 with 문에서 쉼표(,)로 구분해 여러 개의 컨텍스트 매니저를 사용할 수 있습니다.
컨텍스트 매니저는 왼쪽부터 순서대로 진입하며, 빠져나올 때에는 역순으로 정리됩니다.
Python 3.10 이상부터는 여러 컨텍스트 매니저를 소괄호로 묶어 여러 줄로 작성하는 문법을 공식적으로 지원합니다.
이전 버전에서는 한 줄로 길게 작성하거나, 줄 끝에 역슬래시(\)를 사용해 줄을 이어야 했습니다.
소괄호로 묶을 때에는 마지막 항목 뒤에 쉼표(,)를 붙일 수 있습니다.
-
Tip : 위의 예제는 앞의
ExitStack예제에서 생성한a.txt,b.txt,c.txt파일을 읽습니다. -
Tip : 비동기 컨텍스트 매니저를 사용하는
async with문은 제 51강에서 다룹니다.
여러 컨텍스트 매니저를 나열한 with 문은 중첩한 with 문과 같은 의미입니다. 즉, a를 연 상태에서 b를 열고, b를 연 상태에서 c를 엽니다.
그러므로 중간의 b.txt를 여는 데 실패하면, 이미 열린 a.txt만 닫히고 c.txt는 열리지 않습니다. 정리는 성공적으로 진입한 컨텍스트 매니저에 대해서만 역순으로 실행됩니다.
개수가 고정되어 있다면 이 문법을, 개수가 실행 중에 결정된다면 ExitStack을 사용합니다.
실용 예제
import time
from contextlib import contextmanager
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield
finally:
print(f"{label} : {time.perf_counter() - start:.4f}s")
with timer("리스트 생성"):
data = [i ** 2 for i in range(10 ** 6)]
with timer("합계 계산"):
total = sum(data)
print(total)- 결과
- 리스트 생성 : 0.1466s
합계 계산 : 0.0479s
333332833333500000
컨텍스트 매니저는 자원 정리뿐만 아니라 “블록 앞뒤로 무언가를 한다”는 모든 상황에 사용할 수 있습니다.
제 47강 - 데코레이터의 실행 시간 측정 데코레이터는 함수 전체를 측정했지만, 컨텍스트 매니저는 함수 안의 원하는 코드 구간만 측정할 수 있습니다.
try-finally로 감쌌으므로 블록에서 예외가 발생해도 걸린 시간이 출력됩니다. 실행 시간은 실행 환경과 실행할 때마다 달라집니다.
- Tip : 데코레이터는 함수 단위, 컨텍스트 매니저는 코드 블록 단위로 앞뒤 작업을 덧붙인다고 구분하면 쉽습니다.
import os
from contextlib import chdir
os.makedirs("sub", exist_ok=True)
start = os.getcwd()
with chdir("sub"):
print(os.path.basename(os.getcwd()))
print(os.getcwd() == start)- 결과
- sub
True
Python 3.11 이상부터 contextlib.chdir(경로)로 블록 안에서만 현재 작업 디렉터리를 변경할 수 있습니다.
블록 안에서는 sub 폴더가 작업 디렉터리가 되고, 블록을 빠져나오면 원래 디렉터리로 자동으로 돌아오므로 마지막 줄이 True입니다.
os.chdir()로 직접 바꾸면 되돌리는 코드를 잊거나 도중에 예외가 발생해 프로그램 전체의 작업 디렉터리가 바뀐 채로 남는 문제가 생기는데, chdir은 이를 컨텍스트 매니저로 해결합니다.
- Tip : 작업 디렉터리는 프로세스 전체에 적용되므로, 여러 쓰레드에서 동시에
chdir을 사용하지 않습니다.
정리
| 이름 | 설명 | 반환값/특징 |
|---|---|---|
| with 문 | 블록 진입·종료 시 작업을 자동 실행 | 예외가 발생해도 정리 작업 실행 |
__enter__(self) |
블록에 진입할 때 호출 | 반환값이 as 변수에 할당 |
__exit__(self, exc_type, exc_value, traceback) |
블록을 빠져나올 때 호출 | 참을 반환하면 예외 억제 |
| contextlib.contextmanager | 생성자 함수로 컨텍스트 매니저 작성 | yield 앞은 진입, 뒤는 정리 (try-finally 필수) |
| contextlib.suppress | 지정한 예외를 억제 | try-except-pass와 같은 의미 |
| contextlib.ExitStack | 개수가 정해지지 않은 컨텍스트 매니저를 관리 | 등록 역순(LIFO)으로 정리 |
| contextlib.closing | close()를 가진 객체를 컨텍스트 매니저로 변환 |
블록 종료 시 close() 호출 |
| contextlib.chdir | 블록 안에서만 작업 디렉터리 변경 | Python 3.11 이상 |
컨텍스트 매니저의 핵심은 획득과 정리를 한 쌍으로 묶는 것입니다. 자원을 여는 코드를 작성했다면, 정리 코드를 따로 기억하는 대신 with 문으로 묶을 수 있는지 먼저 확인합니다.
댓글 남기기