DjangoORM에서 SQL Driver 지정해 Query & Pandas DataFrame 얻어내기

DjangoORM에서 SQL Driver 지정해 Query & Pandas DataFrame 얻어내기

들어가며

장고의 매력적인 기능 중 하나는 ORM을 통해 SQL을 직접 작성하지 않아도 된다는 점입니다. 즉, 우리가 파이썬 코드를 작성하면 모델 매니저와 SQL Driver를 거쳐 실제로 SQL문으로 만들어주는 일을 장고가 대신해줍니다.

그리고 장고가 DB를 바라보는 방법은 settings.py파일 내 DATABASE설정 통합니다. 그리고 default로 설정된 데이터베이스를 참고해 ORM을 제공합니다. 하지만 이점은 장고 프로젝트 하나에서 여러 데이터베이스를 바라보며 사용할 경우 문제가 발생합니다.

만약 단순하게 모델의 특정 클래스만을 특정 데이터베이스를 바라보게 하려면 다음과 같이 settings.py를 작성할 수 있습니다.

장고의 데이터베이스가 아래와 같이 defaultanotherdb로 두개가 있다고 가정해 봅시다. default는 MySQL을, anotherdb는 MSSQL을 사용합니다. (SQL 문법이 비슷하지만 약간 다릅니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql', # MYSQL
'NAME': 'MYSQLDB',
'HOST': 'localhost',
'USER': 'dbuser',
'PASSWORD': 'dbpassword',
'OPTIONS': {
'sql_mode': 'STRICT_TRANS_TABLES',
},
},
'anotherdb': {
'ENGINE': 'sql_server.pyodbc', # MSSQL
'HOST': '1.23.4.56',
'USER': 'anotheruser',
'PASSWORD': 'anotherpassword',
}
}

이와 같은 경우 모델 클래스별로 다른 DB를 사용하도록 커스텀 데이터베이스 라우터를 만들어 줄 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# settings.py 내
class DatabaseRouter:
def db_for_read(self, model, **hints):
return getattr(model, "_DATABASE", None)

def db_for_write(self, model, **hints):
return getattr(model, "_DATABASE", None)

def allow_relation(self, obj1, obj2, **hints):
db_list = ('default')
return obj1._state.db in db_list and obj2._state.db in db_list

def allow_migrate(self, db, app_label, model_name=None, **hints):
if db == 'default':
return True
else:
return False

DATABASE_ROUTERS = [
DatabaseRouter(),
]

그리고 models.py파일에서는 아래와 같이 _DATABASE속성을 넣어주는 방법으로 라우터를 이용할 수 있습니다.

1
2
3
4
5
6
# someapp/models.py
class Post(models.Model):
_DATABASE = 'anotherdb' # 없으면 자동으로 'default' Fallback

title = models.CharField(max_length=200)
content = models.TextField()

이후 아래와 같이 Post 모델의 모델매니저를 통해 액세스 할 경우 anotherdb를 이용하게 됩니다.

1
queryset = Post.objects.all() # anotherdb로 연결

문제

하지만 문제가 발생하는 부분이 있습니다. 만약 Queryset을 통해 실제 동작하는 query와 params를 알아내 pandas에서 SQL Query를 읽어 DataFrame 객체로 바꾸는 경우에는 아래와 같이 queryset.query로 쿼리에 접근하게 됩니다.

1
2
3
4
5
6
7
import pandas as pd
from django.db import connections

queryset = Post.objects.all() # QuerySet
query, params = queryset.query.sql_with_params()
# df는 Pandas의 DataFrame가 된다.
df = pd.read_sql_query(query, connections['anotherdb'], params=params)

위 코드에서 Post 모델의 속성중 _DATABASE를 통해 커스텀 데이터베이스 라우터로 anotherdb를 바라보도록 만들어주었지만 실제 쿼리를 출력해 볼 경우 MSSQL 쿼리 대신 MySQL 쿼리로 나오는 것을 볼 수 있습니다. 따라서 동작시 에러가 발생합니다.

해결법

쿼리셋을 만들고 query 객체에 접근한 뒤 .sql_with_params() 대신 .as_sql()메소드를 이용해 compiler옵션에 해당 데이터베이스의 SQLCompiler 클래스를 직접 전달해주거나 혹은 문자열로 경로를 지정해준 뒤, connection에 실제 사용할 데이터베이스 명칭(DATABASES의 키값, 없으면 default가 됩니다.)을 넣어줍니다.

1
2
query, params = queryset.query.as_sql(compiler='sql_server.pyodbc.compiler.SQLCompiler', connection=connections['anotherdb'])
df = pd.read_sql_query(query, connections['anotherdb'], params=params)

이제 df에 정상적으로 데이터가 들어온 DataFrame 객체가 만들어집니다.

편하게 씁시다, 함수 만들기

매번 저렇게 커넥션을 처리해주는 것도 사실 귀찮은 일입니다. 그래서 간단하게 to_df라는 함수를 만들어 세가지 인자를 넣으면 처리할 수 있도록 해줍시다.

  • queryset: 장고의 QuerySet 혹은 raw SQL(str)
  • using: settings.py에 등록한 DB이름(‘default’, ‘anotherdb’등)
  • compiler: 해당 DB의 SQLCompiler, import를 통해 가져온 실제 SQLCompiler 클래스 혹은 해당 경로의 문자열
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import pandas as df
from django.db import connections
from django.core.exceptions import EmptyResultSet

def to_df(queryset, using=None, compiler=None):
try:
if type(queryset) == str: # SQL이 문자열로 그대로 들어올 경우
query = queryset
params = None
else:
if using: # 어떤 DB를 사용할지 지정한다면..
con = connections[using]
else:
con = connections['default']
if compiler: # 어떤 SQLCompiler를 사용할지 지정한다면..
query, params = queryset.query.as_sql(compiler=compiler, connection=con)
else:
query, params = queryset.query.sql_with_params()
except EmptyResultSet: # 만약 쿼리셋의 결과가 비어있다면 빈 DataFrame 반환
return pd.DataFrame()
if using: # 어떤 DB를 사용할지 지정했다면 해당 DB connection 이용
df = pd.read_sql_query(query, connections[using], params=params)
else:
df = pd.read_sql_query(query, connection, params=params)
return df
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×