Python & AI Tutorials Logo
Python 프로그래밍

28. with 문과 컨텍스트 관리자(Context Manager)

27장에서는 파일을 읽고 쓰는 과정에서 이미 with 문을 사용해 보았습니다. 이를 통해 파일을 열고 난 뒤 close()를 직접 호출하지 않아도 안전하게 작업할 수 있다는 점을 경험했을 것입니다. 하지만 그때는 with왜 사용하는지, 그리고 어떤 원리와 패턴을 담고 있는지까지는 깊이 다루지 않았습니다.

이 장에서는 한 걸음 물러서서 with 문이 해결하려는 문제가 무엇인지 살펴봅니다. 수동으로 자원을 관리할 때 어떤 위험이 있는지, 그리고 컨텍스트 매니저가 어떻게 이러한 문제를 구조적으로 해결하는지를 배웁니다. 또한 with 문이 파일 처리에만 국한되지 않는다는 점을 다양한 예제를 통해 확인하고, 내부 동작 방식에 대해서는 개념적인 수준에서 이해해 봅니다.

28.1) 컨텍스트 관리자를 개념적으로 이해하기

컨텍스트 관리자(context manager)는 코드에서 특정 컨텍스트에 들어가고 나갈 때 어떤 일이 일어나야 하는지를 정의하는 객체입니다. 방에 들어가고 나오는 것에 비유해 보세요. 들어갈 때는 불을 켜고, 나갈 때는 불을 끕니다. 방 안에 있는 동안 무슨 일이 일어나든 상관없이 말이죠.

28.1.1) 리소스 관리 문제

많은 프로그래밍 작업은 리소스를 획득하고, 사용한 뒤, 해제하는 과정을 포함합니다:

python
# 파일을 열면 리소스(파일 핸들)를 획득합니다
file = open("data.txt", "r")
content = file.read()
# 파일 사용 중...
file.close()  # 리소스를 해제합니다

이 패턴은 자주 등장합니다:

  • 파일 열기 및 닫기
  • 동시성 프로그래밍에서 락(lock) 획득 및 해제
  • 데이터베이스 연결 열기 및 닫기
  • 메모리 버퍼 할당 및 해제

문제는 뭔가 잘못되더라도 리소스가 항상 해제되도록 보장하는 것입니다.

28.1.2) 어떤 객체가 컨텍스트 관리자인가

컨텍스트 관리자는 두 개의 특수 메서드를 구현한 모든 객체입니다:

  1. __enter__(): 컨텍스트에 진입할 때(with 블록 시작 시) 호출됩니다
  2. __exit__(): 컨텍스트에서 나갈 때(with 블록 끝에서, 오류가 발생해도) 호출됩니다

컨텍스트 관리자를 사용하기 위해 이 메서드들을 직접 구현할 필요는 없습니다. 파일 객체 같은 Python 내장 타입은 이미 이를 갖고 있습니다. 이 개념을 이해하면 언제 컨텍스트 관리자를 다루고 있는지 알아차리는 데 도움이 됩니다.

python
# 파일 객체는 컨텍스트 관리자입니다
# __enter__ 및 __exit__ 메서드를 가지고 있습니다
file = open("example.txt", "r")
print(hasattr(file, "__enter__"))  # Output: True
print(hasattr(file, "__exit__"))   # Output: True
file.close()

28.1.3) 기본 패턴: 설정, 사용, 정리

컨텍스트 관리자는 3단계 패턴을 따릅니다:

컨텍스트 진입

설정: enter 호출

리소스 사용

컨텍스트 종료

정리: exit 호출

리소스 해제

설정 단계(Setup Phase): 리소스 획득(파일 열기, 데이터베이스 연결, 락 획득 등)

사용 단계(Use Phase): 리소스 사용(파일 읽기/쓰기, 데이터베이스 질의, 공유 데이터 접근 등)

정리 단계(Teardown Phase): 리소스 해제(파일 닫기, 데이터베이스 연결 해제, 락 해제 등)

핵심 통찰: 정리 단계는 항상 실행됩니다. 사용 단계에서 어떤 일이 일어나든 상관없이 말이죠.

28.2) 수동 리소스 관리가 위험한 이유

with 문을 배우기 전에, 왜 수동 리소스 관리가 실패해서 문제를 일으킬 수 있는지 이해해 봅시다.

28.2.1) 닫는 것을 잊어버림

가장 흔한 실수는 단순히 리소스를 닫는 것을 잊는 것입니다:

python
# 구성 파일을 읽는 중
config_file = open("config.txt", "r")
settings = config_file.read()
# 이런! 파일을 닫는 것을 잊었습니다
# 파일 핸들이 열린 채로 남습니다

Python은 결국 프로그램이 끝날 때 파일을 닫지만, 파일을 열린 채로 두면 문제가 생길 수 있습니다:

  • 리소스 고갈(resource exhaustion): 운영체제는 열 수 있는 파일 수에 제한이 있습니다
  • 파일 잠금(file locking): 다른 프로그램이 해당 파일에 접근하지 못할 수 있습니다
  • 데이터 손실(data loss): 버퍼링된 쓰기가 디스크로 플러시되지 않을 수 있습니다

28.2.2) 오류가 정리를 막음

리소스를 닫는 것을 기억하더라도, 오류가 정리 코드가 실행되는 것을 막을 수 있습니다:

python
# 파일을 처리하려고 시도
data_file = open("data.txt", "r")
content = data_file.read()
result = process_data(content)  # 여기서 오류가 발생하면?
data_file.close()  # process_data()가 실패하면 이 줄은 실행되지 않습니다!

process_data()가 예외(exception)를 발생시키면, 프로그램은 곧바로 오류 처리로 점프하면서 close() 호출을 건너뜁니다. 파일은 무기한 열린 채로 남습니다.

28.2.3) 여러 종료 지점

여러 개의 return 문이 있는 함수는 정리를 더 어렵게 만듭니다:

python
def read_first_valid_line(filename):
    file = open(filename, "r")
    
    for line in file:
        line = line.strip()
        if line and not line.startswith("#"):
            # 유효한 줄을 찾았지만 파일은 아직 열려 있습니다!
            return line
    
    file.close()  # 유효한 줄을 찾지 못한 경우에만 도달합니다
    return None

이 함수는 유효한 줄을 찾으면 일찍 반환하면서 파일을 열린 채로 둡니다. 모든 return 문 앞에 file.close()를 추가해야 하는데, 잊기 쉽고 유지보수도 어렵습니다.

28.2.4) 복잡한 오류 처리

정리를 보장하기 위해 try-except-finally를 사용하려고 할 수도 있습니다:

python
# 오류를 올바르게 처리하려고 시도
file = None
try:
    file = open("data.txt", "r")
    content = file.read()
    result = process_data(content)
except FileNotFoundError:
    print("File not found")
except ValueError:
    print("Invalid data format")
finally:
    if file is not None:
        file.close()

이 방법은 동작하지만 장황하고 오류가 나기 쉽습니다. 다음을 해야 합니다:

  • try 블록 전에 변수를 초기화하기
  • 닫기 전에 리소스가 성공적으로 획득되었는지 확인하기
  • finally 블록을 포함하는 것을 잊지 않기
  • 모든 리소스에 대해 이 패턴을 반복하기

28.2.5) 현실 세계에서의 영향

이 문제들은 이론에만 그치지 않습니다. 수천 개의 파일을 처리하는 프로그램을 생각해 보세요:

python
# WARNING: 리소스 누수 - 데모용입니다
# PROBLEM: 파일이 전혀 닫히지 않습니다
def process_many_files(filenames):
    results = []
    for filename in filenames:
        file = open(filename, "r")  # 파일을 엽니다
        data = file.read()
        results.append(analyze(data))
        # MISTAKE: 파일을 닫지 않습니다
    return results
 
# 1000개의 파일을 처리한 뒤에는 1000개의 파일 핸들이 열려 있게 됩니다!
# 결국 OS는 더 많은 파일을 열기를 거부합니다

여러 번 반복한 뒤의 출력:

OSError: [Errno 24] Too many open files: 'file_1001.txt'

시스템의 파일 핸들 제한을 소진했기 때문에 프로그램이 크래시합니다. 이것이 리소스 누수(resource leak)입니다. 리소스는 획득되지만 해제되지 않습니다.

28.3) 파일을 넘어 with 사용하기

with 문은 파일뿐 아니라 어떤 컨텍스트 관리자와도 함께 작동합니다. 이제 우리가 확인한 문제들을 어떻게 해결하는지 살펴보고, 다양한 컨텍스트에서 사용되는 모습을 확인해 봅시다.

28.3.1) 기본 with 문 문법

with 문은 간단한 구조를 가집니다:

python
with expression as variable:
    # 리소스를 사용하는 코드 블록
    # with 문 아래로 들여쓰기됩니다
# 여기서 리소스가 자동으로 해제됩니다

expression은 컨텍스트 관리자 객체로 평가되어야 합니다. as variable 부분은 선택 사항이지만 보통 포함합니다. 리소스를 참조할 수 있는 이름을 제공하기 때문입니다.

28.3.2) 파일 작업에 with 사용하기

with 문이 파일 처리를 어떻게 바꾸는지 확인해 봅시다:

python
# 수동 방식(위험함)
file = open("data.txt", "r")
content = file.read()
file.close()
 
# with 문 방식(안전함)
with open("data.txt", "r") as file:
    content = file.read()
# 오류가 발생하더라도 여기서 파일이 자동으로 닫힙니다

with 블록이 끝날 때(코드가 정상 종료하든 예외를 일으키든) 파일이 닫히는 것이 보장됩니다.

28.3.3) 여러 컨텍스트 관리자

단일 with 문에서 여러 리소스를 관리할 수 있습니다:

python
# 한 파일에서 읽고 다른 파일에 쓰기
with open("input.txt", "r") as input_file, open("output.txt", "w") as output_file:
    for line in input_file:
        processed = line.upper()
        output_file.write(processed)
# 여기서 두 파일 모두 자동으로 닫힙니다

이는 with 문을 중첩하는 것과 동일하지만 더 간결합니다:

python
# 중첩된 with 문(동일하지만 더 장황함)
with open("input.txt", "r") as input_file:
    with open("output.txt", "w") as output_file:
        for line in input_file:
            processed = line.upper()
            output_file.write(processed)

두 방식 모두 처리 중 오류가 발생하더라도 두 파일이 제대로 닫히도록 보장합니다.

28.3.4) 압축 파일 다루기

Python의 gzip 모듈은 압축 파일을 읽고 쓰기 위한 컨텍스트 관리자를 제공합니다:

python
import gzip
 
# 압축된 데이터 쓰기
with gzip.open("data.txt.gz", "wt") as compressed_file:
    compressed_file.write("This text will be compressed\n")
    compressed_file.write("Saving space on disk\n")
# 파일이 자동으로 닫히고 압축이 완료됩니다
 
# 압축된 데이터 읽기
with gzip.open("data.txt.gz", "rt") as compressed_file:
    content = compressed_file.read()
    print(content)

Output:

This text will be compressed
Saving space on disk

with 문은 압축 파일이 올바르게 마무리되도록 보장합니다. 이는 압축에 있어 매우 중요합니다. 압축이 불완전하면 파일이 손상될 수 있습니다.

28.3.5) 디렉토리를 임시로 변경하기

현재 작업 디렉토리를 임시로 변경해야 할 때, 수동으로 관리하면 위험할 수 있습니다:

python
import os
 
# 현재 디렉토리
print(f"시작 위치: {os.getcwd()}")
 
# 수동으로 디렉토리 변경 (위험함)
original_dir = os.getcwd()
os.chdir("/tmp")
print(f"현재 위치: {os.getcwd()}")
process_files()  # 여기서 에러가 발생하면 원래 디렉토리로 돌아가지 못할 수 있음
os.chdir(original_dir)

process_files()에서 예외가 발생하면, 프로그램은 절대 원래 디렉토리로 돌아가지 못하며, 이후 코드에서 예상치 못한 동작이 발생할 수 있습니다.

Python 3.11은 원래 디렉토리로 돌아가는 것을 보장하는 컨텍스트 매니저 contextlib.chdir()를 도입했습니다:

python
import os
from contextlib import chdir
 
print(f"시작 위치: {os.getcwd()}")
 
# 컨텍스트 매니저 사용 (안전함)
with chdir("/tmp"):
    print(f"임시 위치: {os.getcwd()}")
    process_files()  # 여기서 에러가 발생해도 원래 디렉토리로 돌아감
    
print(f"복귀 위치: {os.getcwd()}")
# 자동으로 원래 디렉토리로 복귀됨

디렉토리 변경은 with 블록이 끝날 때 자동으로 되돌려지며, 코드가 정상적으로 완료되든 예외가 발생하든 상관없이 동작합니다.

28.3.6) 동시성 프로그래밍을 위한 스레드 락

동시성 프로그래밍(고급 주제에서 다룹니다)에서 락(lock)은 컨텍스트 관리자입니다:

python
# 개념적 예시(스레딩은 고급 주제에서 배웁니다)
import threading
 
lock = threading.Lock()
 
# 수동 락 관리(위험함)
lock.acquire()
# 임계 구역 - 여기서 오류가 발생하면?
lock.release()  # 실행되지 않을 수 있습니다
 
# with 문(안전함)
with lock:
    # 임계 구역
    # 오류가 발생하더라도 락이 자동으로 해제됩니다
    pass

28.4) 내부에서의 with 문 동작 방식(개념적 설명만)

with 문이 내부적으로 어떻게 동작하는지 이해하면 그 강력함을 더 잘 느끼고, 언제 컨텍스트 관리자를 쓰고 있는지도 알아볼 수 있습니다. 이 절은 개념적 개요를 제공합니다. 여러분이 직접 이 세부 사항을 구현할 필요는 없습니다.

28.4.1) 두 개의 특수 메서드

모든 컨텍스트 관리자는 Python이 자동으로 호출하는 두 개의 특수 메서드를 구현합니다:

__enter__(self): with 블록이 시작될 때 호출됩니다

  • 설정 작업을 수행합니다(파일 열기, 락 획득 등)
  • as 뒤의 변수에 할당될 리소스 객체를 반환합니다
  • as 절이 없다면 반환값은 무시됩니다

__exit__(self, exc_type, exc_value, traceback): with 블록이 끝날 때 호출됩니다

  • 정리 작업을 수행합니다(파일 닫기, 락 해제 등)
  • 발생한 예외에 대한 정보를 받습니다
  • 예외가 발생했더라도 항상 호출됩니다
  • True를 반환하여 예외를 억제할 수 있습니다(드문 경우입니다)

28.4.2) Python이 with 문을 실행하는 방식

Python이 with 문을 실행할 때 어떤 일이 일어나는지 추적해 봅시다:

python
with open("data.txt", "r") as file:
    content = file.read()
    print(content)

단계별 실행은 다음과 같습니다:

파일 객체Python 인터프리터여러분의 코드파일 객체Python 인터프리터여러분의 코드with 문 실행__enter__() 호출파일 객체 반환'file' 변수에 할당file.read() 호출content 반환content 출력with 블록 종료__exit__() 호출파일 닫기None 반환실행 계속

1단계: Python은 open("data.txt", "r")를 평가하여 파일 객체를 생성합니다

2단계: Python은 파일 객체의 __enter__() 메서드를 호출합니다

3단계: __enter__()는 파일 객체 자신을 반환하며, 이 값이 file에 할당됩니다

4단계: Python은 들여쓰기된 코드 블록을 실행합니다

5단계: 블록이 끝나면(정상 종료 또는 예외로 인해) Python은 __exit__()를 호출합니다

6단계: __exit__()는 파일을 닫고 정리를 수행합니다

7단계: 예외가 발생했다면, Python은 정리 후 그 예외를 다시 발생시킵니다

28.4.3) 컨텍스트 관리자에서의 예외 처리

with 블록 내부에서 예외가 발생하면, Python은 그 정보를 __exit__()에 전달합니다:

python
# 오류가 발생할 때 무슨 일이 일어나는지
try:
    with open("data.txt", "r") as file:
        content = file.read()
        result = int(content)  # Might raise ValueError
        print(result)
except ValueError as e:
    print(f"Invalid data: {e}")
# except 블록이 실행되기 전에 파일이 닫힙니다

ValueError가 발생할 때의 실행 흐름:

with 블록 진입

enter 호출

실행: content = file.read

실행: result = int content

ValueError 발생

예외 정보와 함께 exit 호출

파일 닫기

ValueError 재발생

except 블록이 이를 잡음

핵심 포인트: 예외가 전파되기 전에 __exit__()가 호출됩니다. 그래서 오류가 발생하더라도 정리가 보장됩니다.

28.4.4) 간단한 멘탈 모델

with 문을 하나의 보장으로 생각해 보세요:

python
with resource_manager as resource:
    # 리소스 사용
    pass
# Python이 정리가 완료되었음을 보장합니다

블록 안에서 어떤 일이 일어나든(정상 종료, return 문, 예외, 심지어 시스템 오류까지) Python은 정리하기 위해 __exit__()를 호출합니다. 이 보장이 with를 강력하게 만들며, 리소스를 다룰 때마다 with를 사용해야 하는 이유입니다.


이 장의 핵심 정리:

  • 컨텍스트 관리자(context manager)는 리소스를 위한 설정과 정리 작업을 정의합니다
  • 수동 리소스 관리는 정리 누락, 오류, 여러 종료 지점 때문에 위험합니다
  • with 문(with statement)은 오류가 발생하더라도 정리가 이뤄지도록 보장합니다
  • 정리가 필요한 파일 및 다른 모든 리소스에 with를 사용하세요
  • 여러 리소스를 하나의 with 문에서 관리할 수 있습니다
  • 내부적으로 with__enter__()__exit__() 메서드를 자동으로 호출합니다
  • __exit__()는 항상 실행되며, 리소스가 적절히 해제되도록 보장합니다

with 문은 리소스 관리를 오류가 나기 쉬운 수동 작업에서 자동적이고 신뢰할 수 있는 정리로 바꿔줍니다. 파일, 데이터베이스 연결, 락, 또는 적절한 정리가 필요한 다른 모든 리소스를 다룰 때마다 사용하세요. 여러분의 코드는 더 안전하고, 더 깔끔하며, 더 전문적으로 보일 것입니다.

© 2025. Primesoft Co., Ltd.
support@primesoft.ai