콘텐츠로 건너뛰기
Home » with 구문과 컨텍스트 매니저

with 구문과 컨텍스트 매니저

파일이나 소켓과 같은 특정한 리소스에 액세스하는 경우에 일반적으로 해당 리소스를 열면서 제어를 위한 핸들을 얻고, 리소스의 회수를 위해서 모든 작업이 완료된 경우에는 반드시 해당 핸들을 닫는 식의 흐름이 필요하다.

하지만 리소스의 열기와 닫기 사이에는 예측하지 못한 변수가 발생할 수 있다. 특정한 조건에 의해서나 예외가 발생하는 등의 이유로 리소스의 정리를 못한채로 루틴이 끝나거나 프로그램이 종료되는 경우가 발생할 수 있다.

따라서 이런 경우에는  try/except/finally 구문을 통해서 흐름의 끝에서 리소스를 정리하고 루틴을 떠나는 형태로 코드를 작성하는 방법도 있지만, 이러한 방법 역시 예외가 발생하는 케이스와 탈출 조건을 만족하는 케이스에 대해서 각각 리소스를 정리하는 코드가 중복으로 들어가야 하는 경우가 있다.

파이썬의 컨텍스트 매니저는 이러한 리소스의 액세스를 with 라는 구문을 통해서 특정 블럭 내의 동작으로 제한하고, 블럭을 나가는 경우에 그 사유가 어떻게 되든간에 리소스의 해제 처리를 자동으로 하는 것을 보장해준다.

파일 액세스의 예

텍스트 파일을 하나 열어서 구구단 내용을 기록하는 예제를 작성해보자.

f = open('mytextfile.txt', 'w', encoding='utf8')
for x in range(2, 10):
  for y in range(2, 10):
    f.write("{:02d} x {:02d} = {:02d}".format(x, y, x*y) + '\n')
  f.write("\n"*2)
f.close()

우리가 주의깊게 봐야 하는 부분은 맨 마지막 줄로, 열었던 파일은 다시 닫아야만 다른 프로세스에서 액세스할 수 있다.1 물론 현대의 OS들은 특정한 파일을 사용하던 핸들의 소유 프로세스가 종료되면 자동으로 해당 리소스를 회수할 수 있기는 하지만, 프로그램의 실행 수명이 긴 상황에서 이처럼 파일 핸들을 닫지 않으면 다른 프로세스에서 해당 파일을 액세스하지 못하는 경우가 생길 수 있다.

with 문에서 처리하기

파이썬의 파일 핸들러는 내부적으로 ContextManager 프로토콜을 따르도록 설계되어 있다. 따라서 다음과 같이 with 구문을 통해서 액세스할 수 있다.

with open('mytextfile.txt', 'r', encoding='utf8') as f:
  with open('copiedfile.txt', 'w', encoding='utf8') as g:
    for line in f:
      print(line)
      g.write(line + '\n')

위 코드는 두 개의 파일을 각각 읽기 모드와 쓰기 모드로 연 다음, 원본 파일에서 한 줄씩 읽어 화면에 출력하고 해당 라인을 대상 파일에 써서 복사하는 작업을 수행한다.

이 코드는 open() ~ FileHandle.close()에 이르는 구간이 with 구문에 의한 블록으로 감싸지고 명시적으로 열었던 파일 핸들을 닫는 코드를 작성하지 않고 있다.  이는 open() 함수를 통해서 생성되는 파일 핸들이 컨텍스트 매니저 프로토콜을 따르고 있기 때문이다. 컨텍스트 매니저 객체들은 with 문과 함께 사용되었을 때 다음과 같은 동작을 처리할 수 있다.

  1. 객체가 생성된 후 with 블럭에 진입하면서 미리 지정된 특정한 작업을 수행한다.
  2. with 블럭을 떠나는 시점에 미리 지정된 특정한 작업을 수행한다.

보통의 경우에는 시작 지점에는 특정한 동작을 수행하는 일이 별로 없을 것이다. 파일 객체의 경우에 with 블럭을 떠나는 시점에 자신을 닫는 동작을 수행하도록 미리 정해져 있고, 따라서 with 문 내에서 예외가 발생해서 실행이 중단되거나, 함수 내에서 리턴하는 동작을 만나더라도 파일 객체는 닫기를 수행하는 기회를 보장받게 된다.

또한 with 문을 쓸 때의 장점은 위의 예에서 보듯이 with 블럭 자체가 중첩이 가능하다는 점이다. 따라서 어느 한 지점에서 문제가 발생하여 모든 블럭을 빠져나와야 하는 상황에서도 가장 하위의 블럭에서부터 파일을 순차적으로 닫고 안전하게 리소스를 회수할 수 있게 된다. 또한 제어 로직에서의 전후관계를 따로 추적하지 않아도 되어 그만큼 코드가 간결해지는 장점도 있다.

컨텍스트 매니저

컨텍스트 매니저는 with 구문에 쓰일 수 있는 객체의 타입이며, (파이썬의 문법 상으로는 명시적으로 지원하지는 않지만) context manager2 프로토콜을 준수한다. 컨텍스트 매니저는 다음 두 개의 메소드를 정의하고 있는 것으로 간주한다.

  • __enter__(self) : with 문에 진입하는 시점에 자동으로 호출된다.
  • __exit__(self, type, value, traceback) : with 문이 끝나기 직전에 자동으로 호출된다.

__exit__() 메소드가 받는 세 개의 인자는 해당 객체와 연관된 컨텍스트 내에서 예외가 발생되었을 때, 해당 예외에 관한 정보이다. 예외없이 with 구문이 종료되었다면 이 세 인자는 모두 None이 될 것이다.

간단한 예시 – 자동으로 닫히는 DB 연결

간단한 예시를 만들어보자. DB의 이름을 주면 해당 데이터베이스 파일을 열고, 특정한 메소드 호출로 데이터를 쿼리-반환한 후에 자동으로 DB 연결을 닫아주는 클래스를 만들어 볼 것이다.

import sqlite3 as sql
class Connector:
  def __init__(self, db_path):
    self.db = sql.connect(db_path)
    self.cursor = None
  def __enter__(self):
    ## 초기화가 끝나고 with 블럭에 진입할 때 호출된다.
    ## 대부분은 하는일이 없는데, 그렇다고 구현 안하면 안되니 pass 라도 적어주자.
    if self.db is not None:
      self.cursor = self.db.cursor()
    else:
      ## 아직 컨텍스트 블럭에 진입 전이므로, 여기서 발생하는 예외는 밖으로 던져진다.
      raise IOError("Cannot access DB File")
  def __exit__(self, e_type, e_value, tb):
    ## with 블럭이 끝나면 커서 및 DB 연결을 닫는다.
    ## 사실 이 영역은 예외가 발생했을 때 처리하는 것도 담당해야 한다.
    print("Closing Database...")
    self.cursor.close()
    self.db.close()
  def getContacts(name):
    query = """SELECT * FROM Contacts WHERE name = ? ;"""
    self.cursor.execute(query, (name,))
    return [dict(x) for x in self.cursor.fetchall()]
with Connector('some_db_file.sql') as db:
  for contact in db.getContacts('Joe'):
    print(contact['email'])

좋은 예는 아니지만,  동작을 이렇게 할 수 있다는 예로서 보였다. with 문의 시작과 끝의 직전에 어떤 일을 처리할 수 있는 기회를 주는 셈이다.(이거 어디선가 많이 본 기능 같은데)

데코레이터를 통해 컨텍스트 매니징하기

컨텍스트 매니저는 with 블럭의 처음과 끝에 부가적인 장식을 더하는 것으로 간주할 수 있고, 이는 함수로 따지면 데코레이터랑 비슷한 개념이라고 볼 수 있다. 그리고 파이썬에는 contextlib 모듈이 있어서 실제로 이와 관련된 기능을 제공한다. 따라서 위와 같이 클래스를 작성하기가 번거로운 경우에는 @contextmanager 데코레이터를 이용해서 보다 손쉽게 처리하는 방법도 있다.

적용 방법은 이렇다. 먼저 컨텍스트 매니징 함수 자체는 제너레이터 함수여야 한다. (with 문과 함께 호출되었을 때 컨텍스트 내에 있을 어떤 객체를 리턴해야 하므로) 이 제너레이터 함수는 with 구문 내에서 사용될 객체를 말한다.  제너레이터는 yield 문까지 실행된 후에 with 구문 블럭을 실행한다. 블럭의 실행이 종료되면 제너레이터가 끝까지 실행된다.

파이썬 공식 문서에서는 다음과 같은 코드를 예제로 알려준다. (썩 좋은 예는 아니지만…)

from contextlib import contextmanager
@contextmanager
def tag(name):
    print("<%s>" % name)
    yield ## yield 문이 with 구문 블럭이 실행되는 범위라고 생각하면 된다.
    print("</%s>" % name)
with tag('h1'):
    print("hello")
# 출력은 아래와 같다.
# <h1>
# hello
# </h1>

제너레이터가 닫히는 시점에 GeneratorExit 예외가 발생하므로 컨텍스트매니저 함수 내에서 try ... finally 구문을 쓰는 것이 더 일반적이다.  다음은 위에서 작성한 BD 연결 클래스를 @contenxtmanager 데코레이터를 이용해서 다시 작성한 버전이다.

@contextmanager
def database(dbname, autocommit=False):
  try:
    conn = sql.connect(dbname)
    c = conn.cursor()
    c.row_factory = sql.Row
    yield c
  finally:
    if autocommit:
      conn.commit()
      c.close()
      conn.close()
with database('contacts.db') as c:
  c.execute('select * from contacts')
  contacts = [dict(row) for row in c.fetchall()]
  ...

  1. 물론 보통 읽기 전용으로 연 경우에는 잠기지 않는다. 문제가 되는 것은 주로 쓰기 모드로 파일을 열었을 때이다. 
  2. 컨텍스트 매니저는 PEP343에 의해 제안되었으며, 파이썬 2.5에서 도입되었다.