Computer Engineering/Django

장고는 DB connection을 어떻게 관리할까?

jordan.bae 2023. 12. 25. 17:03

! 이 글은 Django 5.0을 기준으로 작성됐습니다. 내부 코드를 분석한 부분은 저의 해석으로 잘못 이해한 부분이 있을 수 있습니다.

 

DB Connection in Django

Django를 사용하다가 다른 Framework를 사용하면 귀찮은 부분 중 하나가 명시적으로 DB Connection을 관리하는 부분입니다

예를 들어, Flask를 사용하여 SQLAlchemy를 적용할 때는 다음과 같이 데이터베이스 연결을 생성하고 관리해야 합니다.

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///example.db'
db = SQLAlchemy(app)

# 사용자 추가 API
@app.route('/user', methods=['POST'])
def add_user():
    data = request.get_json()
    new_user = User(name=data['name'])
    db.session.add(new_user)
    db.session.commit()
    return jsonify({'message': 'User added successfully'}), 201

이 코드에서 볼 수 있듯이, SQLAlchemy를 사용하는 경우 명시적으로 데이터베이스 세션을 생성하고, 작업을 수행한 후에는 세션을 커밋하고 종료해야 합니다. 이는 개발자에게 데이터베이스 연결 관리에 대한 명확한 통제를 제공하지만, 동시에 추가적인 코드 작성이 필요하다는 단점이 있습니다.

반면, Django에서는 데이터베이스 연결 생성과 관리가 내부적으로 자동으로 이루어집니다. Django의 ORM을 사용할 때 QuerySet을 실행하면, Django는 자동으로 데이터베이스 연결을 생성하고, HTTP 요청이 종료될 때 Old Connection(이 부분은 밑에서 다시 설명드리겠습니다.)이 종료됩니다. 이로 인해 개발자는 데이터베이스 연결 생성과 관리에 대해 걱정할 필요가 없어집니다. 

이렇게 Django가 데이터베이스 연결을 자동으로 관리하는 방식은 개발의 편의성을 크게 높여주지만, 한편으로는 이 과정이 실제로 어떻게 동작하는지에 대한 궁금증을 유발하기도 합니다. Django의 데이터베이스 연결 관리 시스템은 어떻게 구성되어 있으며, 이것이 어떻게 효율적으로 작동하는지에 대해 자세히 알아보는 것은 Django를 더 깊이 이해하는 데 도움이 될 것입니다! 이번 글에서는 Django가 내부적으로 DB Connection을 어떻게 생성하고 언제 종료하는지 Internal code를 살펴보고, DB connection을 재사용해서 애플리케이션의 성능을 높이기 위한 설정도 공유합니다.

이제 본격적으로 Django의 DB Connection이 어떻게 생성되는지 알아보시죠!

 

Django Connection 생성에 대한 오해

Django의 Connection은 정확하게 되는 워커 스레드 내에서 Connection이 연결되지 않은 상황에서 첫 번째 쿼리가 실행될 때 연결됩니다. 간혹, Django문서에 아래와 같이 적혀 있어서 HTTP Request마다 연결된다고 오해하는 분들이 많이 계신데 정확하게는 HTTP Request가 왔을 때 생성되지는 않고 Request의 Handler(View)에서 QuerySet에 의해 SQL이 실행될 때 생성됩니다. (즉, Middleware나 Handler에 쿼리를 실행시키는 코드가 없다면 Connection을 생성하지 않습니다.)

Persistent connections avoid the overhead of reestablishing a connection to the database in each HTTP request. They’re controlled by the CONN_MAX_AGE parameter which defines the maximum lifetime of a connection. It can be set independently for each database.

! 물론 위에 언급한 것처럼 view안에 직접적으로 쿼리가 없더라도 인증에 DB조회가 필요하거나 ATOMIC_REQUESTS를 True로 설정한다면 request마다 transaction 생성을 위해서 DB connection이 생성될 수 있습니다. 

 

Django에서 DB Connection의 생성

네, 다시 한번 말하면 Connection이 연결되지 않은 상황에서 첫 번째 쿼리가 실행될 때 DB Connection이 연결됩니다. 하나의 예시로 QuerySet의 결과 값이 DB Select 결과 값을 객체로 return이 필요할 때 실행이 돼서 SQL을 실행할 때 connection이 없으면 생성됩니다.

코드를 통해 확인해 보시죠!

len(User.objects.all()) 이 view 코드 안에 있다고 가정하겠습니다.

 

1. 위 코드를 QuerySet class의 __len__() 매직메서드를 호출하고 해당 메서드 내에서 _fetch_all() 메서드를 호출하고 self._iterable_class(=ModlIterable)을 호출합니다.

# db/models/query.py
class QuerySet(AltersData):
    """Represent a lazy database lookup for a set of objects."""

    def __init__(self, model=None, query=None, using=None, hints=None):
        self.model = model
        self._db = using
        self._hints = hints or {}
        self._query = query or sql.Query(self.model)
        self._result_cache = None
        self._sticky_filter = False
        self._for_write = False
        self._prefetch_related_lookups = ()
        self._prefetch_done = False
        self._known_related_objects = {}  # {rel_field: {pk: rel_obj}}
        # _fetch_all() 에서 return하는 속성
        self._iterable_class = ModelIterable
        self._fields = None
        self._defer_next_filter = False
        self._deferred_filter = None
    ....
    def __len__(self):
        self._fetch_all()
        return len(self._result_cache)
    
    def _fetch_all(self):
        if self._result_cache is None:
            self._result_cache = list(self._iterable_class(self))
        if self._prefetch_related_lookups and not self._prefetch_done:
            self._prefetch_related_objects()

 

2. self._fetch_all()은 ModelIterable 클래스의 __iter__ 매직메서드를 호출하게 되는데 이 부분이 SQL을 생성하고 실제 실행하게 됩니다.

# db/models/query.py
class ModelIterable(BaseIterable):
    """Iterable that yields a model instance for each row."""

    def __iter__(self):
        queryset = self.queryset
        db = queryset.db
        compiler = queryset.query.get_compiler(using=db)
        # Execute the query. This will also fill compiler.select, klass_info,
        # and annotations.
        results = compiler.execute_sql(
            chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size
        )

 

3. compiler.execute_sql() 안에 BaseDatabaseWrapper.cursor()를 호출하고 이 내부에서 BaseDatabaseWrapper.connect()를 호출합니다. 이 부분이 실질적으로 DB Connection을 생성하게 되는 부분입니다. 이 부분은 조금 자세히 살펴보겠습니다.

- excute_sql() 안에서 호출하는 BaseDatabaseWrapper.cursor() 메서드는 데이터베이스에 쿼리를 실행하기 위해 커서를 생성하는 곳입니다. 이 메서드는 ensure_connection을 호출합니다.

def execute_sql(
        self, result_type=MULTI, chunked_fetch=False, chunk_size=GET_ITERATOR_CHUNK_SIZE
    ):
        """
		...
        """
        result_type = result_type or NO_RESULTS
        try:
            sql, params = self.as_sql()
            if not sql:
                raise EmptyResultSet
        except EmptyResultSet:
            if result_type == MULTI:
                return iter([])
            else:
                return
        if chunked_fetch:
            cursor = self.connection.chunked_cursor()
        else:
            cursor = self.connection.cursor()
       .....

- BaseDatabaseWrapper.ensure_connection() 메서드는 현재 데이터베이스 연결이 유효한지 확인하고, 연결이 없거나 닫혀 있으면 connect를 호출합니다. 이 부분을 잘 기억해 주세요!!


class BaseDatabaseWrapper:
    @async_unsafe
    def cursor(self):
        """Create a cursor, opening a connection if necessary."""
        return self._cursor()

    def _cursor(self, name=None):
        self.close_if_health_check_failed()
        self.ensure_connection()
        with self.wrap_database_errors:
            return self._prepare_cursor(self.create_cursor(name))
   ...
   @async_unsafe
    def ensure_connection(self):
        """Guarantee that a connection to the database is established."""
        if self.connection is None:
            with self.wrap_database_errors:
                self.connect()

- BaseDatabaseWrapper.connect() 메서드는 실제 데이터베이스 연결을 수립합니다.

class BaseDatabaseWrapper:
   ...
		@async_unsafe
    def connect(self):
        """Connect to the database. Assume that the connection is closed."""
        # Check for invalid configurations.
        self.check_settings()
        # In case the previous connection was closed while in an atomic block
        self.in_atomic_block = False
        self.savepoint_ids = []
        self.atomic_blocks = []
        self.needs_rollback = False
        # Reset parameters defining when to close/health-check the connection.
        self.health_check_enabled = self.settings_dict["CONN_HEALTH_CHECKS"]
        max_age = self.settings_dict["CONN_MAX_AGE"]
        self.close_at = None if max_age is None else time.monotonic() + max_age
        self.closed_in_transaction = False
        self.errors_occurred = False
        # New connections are healthy.
        self.health_check_done = True
        # Establish the connection
        conn_params = self.get_connection_params()
        self.connection = self.get_new_connection(conn_params)
        self.set_autocommit(self.settings_dict["AUTOCOMMIT"])
        self.init_connection_state()
        connection_created.send(sender=self.__class__, connection=self)

        self.run_on_commit = []

조금 더 들어가 보면 각 DB(MySQL, Postgres 등) Wrapper클래스에서 구현한 get_new_connection에서 DB connection이 생성됩니다.

한 번 더 정리를 해보면 저희가 만든 QuerySet이 SQL을 실행시킬 때 connection이 없으면 그때 생성됩니다. 이제 저희는 어떻게 Django에서 DB 커넥션이 생성되는지 이해했습니다. 이제 다음으로 그럼 언제 DB Connection이 언제 닫히는지 체크해 보겠습니다.

 

DB connection 언제 닫힐까?

우리는 Django에서 실제 SQL이 실행될 때 Thread내의 Local객체에 Connection이 없을 때 생성되는 것을 확인했습니다. 그럼 DB Connection은 언제 닫힐까요?

Django 개발자가 명시적으로 Connection을 닫지 않는다면 Django에서는 HTTP Request를 시작할 때와 끝날 때 Old Connection을 종료합니다.

django의 db/__init__. py를 살펴보면 아래와 같은 코드가 있습니다.

# For backwards compatibility. Prefer connections['default'] instead.
connection = ConnectionProxy(connections, DEFAULT_DB_ALIAS)


......

# Register an event to reset transaction state and close connections past
# their lifetime.
def close_old_connections(**kwargs):
    for conn in connections.all(initialized_only=True):
        conn.close_if_unusable_or_obsolete()


signals.request_started.connect(close_old_connections)
signals.request_finished.connect(close_old_connections)

Django는 HTTP request요청이 들어와서 view 핸들러 시작 전과 후에 close_old_connections 함수를 실행시킵니다.

close_old_connections함수는 생성되어 있는 db connection 중에 Old connection을 닫아주는 역할을 합니다. 자세히 보시면 아래와 같은 경우 connection을 종료합니다. 즉, old connection은 아래와 같은 케이스에 속한다고 생각하면 됩니다.

  • 현재 session의 autocommit 설정이 글로벌 설정과 다른 경우
  • 에러가 발생했고 is_usable이 False인 경우
  • self.close_at 이 존재하고 현재 시간보다 작은 경우 (이 부분은 뒤 섹션에 자세히 다룰 것입니다.)
# db/backends/base/base.py

class BaseDatabaseWrapper:
   ...  
   def close_if_unusable_or_obsolete(self):
        """
        Close the current connection if unrecoverable errors have occurred
        or if it outlived its maximum age.
        """
        if self.connection is not None:
            self.health_check_done = False
            # If the application didn't restore the original autocommit setting,
            # don't take chances, drop the connection.
            if self.get_autocommit() != self.settings_dict["AUTOCOMMIT"]:
                self.close()
                return

            # If an exception other than DataError or IntegrityError occurred
            # since the last commit / rollback, check if the connection works.
            if self.errors_occurred:
                if self.is_usable():
                    self.errors_occurred = False
                    self.health_check_done = True
                else:
                    self.close()
                    return

            if self.close_at is not None and time.monotonic() >= self.close_at:
                self.close()
                return

위에서 살펴본 것처럼 Django는 http request시작과 끝에 오래된 connection을 종료해서 이미 닫힌 connection이 재사용되는 것을 방지합니다.

 

CONN_MAX_AGE 설정으로 DB Connection 재사용하기!

위에 내용을 정리해 보면 Django를 SQL을 실행할 때 Connection이 없으면 생성합니다. 우리는 이 부분을 BaseDatabaseWrapper.ensure_connection()에서 확인했었습니다.

그렇다면 Django에서 DB Connection은 있으면 재사용하기 때문에 Connection을 닫지 않으면 재사용할 수 있다는 것을 알 수 있습니다.

  • 결론: 우리는 connection을 닫는 부분을 조정해서 DB Connection을 재사용할 수 있다.

다시 한번 connection을 생성하고 old connection을 닫는 코드를 살펴보면 아래와 같습니다.

self.close_at 이 현재 시간보다 크면 우리는 DB Connection을 재사용할 수 있는데 self.close_at 은 현재 시각에 CONN_MAX_AGE을 더해 줍니다! 즉, 우리는 CONN_MAX_AGE 설정을 통해 우리가 원하는 DB Connection을 재사용할 수 있습니다.

class BaseDatabaseWrapper:
    @async_unsafe
    def connect(self):
        """Connect to the database. Assume that the connection is closed."""
        # Check for invalid configurations.
        self.check_settings()
        # In case the previous connection was closed while in an atomic block
        self.in_atomic_block = False
        self.savepoint_ids = []
        self.atomic_blocks = []
        self.needs_rollback = False
        # Reset parameters defining when to close/health-check the connection.
        self.health_check_enabled = self.settings_dict["CONN_HEALTH_CHECKS"]
				# 이 부분을 잘 봐주세요!
        max_age = self.settings_dict["CONN_MAX_AGE"]
        self.close_at = None if max_age is None else time.monotonic() + max_age

   def close_if_unusable_or_obsolete(self):
        """
        Close the current connection if unrecoverable errors have occurred
        or if it outlived its maximum age.
        """
        if self.connection is not None:
            self.health_check_done = False
            # If the application didn't restore the original autocommit setting,
            # don't take chances, drop the connection.
            if self.get_autocommit() != self.settings_dict["AUTOCOMMIT"]:
                self.close()
                return

            # If an exception other than DataError or IntegrityError occurred
            # since the last commit / rollback, check if the connection works.
            if self.errors_occurred:
                if self.is_usable():
                    self.errors_occurred = False
                    self.health_check_done = True
                else:
                    self.close()
                    return

            if self.close_at is not None and time.monotonic() >= self.close_at:
                self.close()
                return

즉, CONN_MAX_AGE를 설정해서 Connection의 최대 수명을 DB 마다 정의할 수 있습니다. CONN_MAX_AGE를 설정하면 각 요청의 시작에서, Django는 연결이 최대 수명에 도달했으면 연결을 닫습니다.

CONN_MAX_AGE를 설정하는 이유는 데이터베이스가 일정 시간 후에 비활성 연결을 종료할 수 있기 때문입니다. (MySQL 기준 wait_timeout) 데이터베이스가 연결을 종료할 때 Django에서는 이 부분을 알고 있지 못하다 Query를 실행하게 되면 Lost Connection이 발생할 수 있습니다. 이때 데이터베이스 서버에 의해 종료된 연결을 Django가 사용하려고 하지 않도록 하려면 DB의 wait_timeout보다 CONN_MAX_AGE를 낮은 값으로 설정해야 합니다. (하지만, 이 문제는 매우 낮은 트래픽의 사이트에서만 영향을 미칠 수 있고, 이는 워커 스레드당 최대 한 번의 요청에 영향을 미치게 됩니다.)

MySQL wait_timeout MySQL 클라이언트 연결을 얼마 동안 유지할지를 정의합니다. wait_timeout 값이 지정된 시간(초 단위) 동안 어떤 클라이언트도 서버에 요청을 보내지 않으면, 서버는 해당 클라이언트 연결을 종료합니다.

 

 

기타: DB connection을 생성하는 게 왜 리소스가 많이 드나요?

데이터베이스 연결을 생성하는 것이 리소스를 많이 소모하는 이유는 여러 가지가 있습니다. 처음 커넥션을 맺을 때 3 way hand shake과정이 필요할 뿐만 DB에서 여러 복잡한 단계를 포함하기 때문입니다. 주요 내용은 아래와 같습니다.

  1. 네트워크 통신: 데이터베이스 서버와 클라이언트 사이의 네트워크 연결을 설정하는 것 자체가 리소스를 소모합니다. 이는 특히 데이터베이스 서버가 원격에 위치한 경우 더욱 그렇습니다.
  2. 인증 및 권한 부여: 연결 과정에는 사용자 인증, 권한 부여 및 보안 설정이 포함됩니다. 이는 사용자의 자격 증명을 검증하고, 적절한 권한을 부여하는 과정을 포함합니다.
  3. 세션 설정: 데이터베이스 연결이 설정되면, 서버는 각 연결에 대한 세션을 생성하고 관리해야 합니다. 이 과정에는 세션 상태를 초기화하고, 트랜잭션 설정, 캐시, 기타 많은 구성 요소를 설정하는 것이 포함됩니다.
  4. 리소스 할당: 데이터베이스는 각 연결에 대해 메모리, 프로세스, 그리고 필요한 경우 캐시 리소스를 할당합니다. 이러한 리소스 할당은 데이터베이스 서버의 성능에 영향을 줄 수 있습니다.
  5. 오버헤드 관리: 많은 동시 연결이 발생하면 데이터베이스 서버의 리소스 관리 오버헤드가 증가합니다. 이는 서버의 CPU와 메모리 사용량을 증가시킬 수 있으며, 이로 인해 시스템의 전반적인 성능에 영향을 줄 수 있습니다.

큰 규모의 서비스에서는 주로 더 효율적인 Connection 관리를 위해 ProxySQL, pgBouncer와 같은 DB Pooling 소프트웨어를 사용합니다.

 

마무리

이 번 글에서는 Django에서 DB Connection이 내부적으로 어떻게 관리되는지 살펴봤습니다. 혹시, 내용 중에 제가 잘 못 이해하고 있는 부분이 있으면 편하게 댓글이나 이메일로 알려주세요. 요즘 Django관련 글이나 사용 사례들이 예전보다 조금 줄어드는 것 같아서 개인적으로 아쉬운데요. Django관련해서 도움이 될만한 포스팅을 종종 올려보도록 하겠습니다. 

 

같이 읽으면 좋은 글

- Django에서 DB Transaction 사용하기 시리즈

- Django 오픈소스에 기여 하기

반응형