SQLAlchemy는 파이썬 개발자들이 데이터베이스와 상호작용할 때 강력하고 유연한 ORM(Object Relational Mapper) 기능을 제공합니다. 특히 복잡한 비즈니스 로직을 모델 계층에서 처리하기 위해 column_property나 hybrid_property 같은 기능을 활용하는 경우가 많습니다. 하지만 이러한 계산된 컬럼(calculated columns)을 포함하여 집계(aggregation) 쿼리를 수행할 때, 예상치 못한 방식으로 결과 컬럼명(별칭, alias)이 부여되어 개발자를 당황하게 만드는 경우가 있습니다. 이 글에서는 SQLAlchemy에서 집계 쿼리 시 컬럼 별칭이 의도와 다르게 지정되는 원인을 분석하고, 모든 유형의 컬럼(일반, column_property, hybrid_property)에 대해 일관되고 명확한 컬럼명을 얻는 방법을 상세히 설명합니다.
1. 에러 발생 상황
문제는 SQLAlchemy 모델에서 일반 컬럼, column_property, hybrid_property를 정의하고, 이들을 대상으로 SUM()과 같은 집계 함수를 사용할 때 발생합니다. 다음은 예시 테이블 TOne과 집계 메서드 get_aggregated 코드입니다.
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, column_property
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.declarative import declarative_base
from decimal import Decimal # Decimal 타입 임포트 추가
Base = declarative_base() # Base 클래스 정의 추가
class TOne(Base):
__tablename__ = 'TOne'
RecordID: Mapped[int] = mapped_column(primary_key=True)
Amt1: Mapped[Decimal] = mapped_column()
Amt2: Mapped[Decimal] = mapped_column()
calc1 = column_property(Amt1 - Amt2)
@hybrid_property
def calc2(self):
return self.Amt1 + self.Amt2
@calc2.expression
def calc2(cls):
return cls.Amt1 + cls.Amt2
@classmethod
def get_aggregated(cls, session, *columns):
columns_to_select = [
sa.func.sum(c)
for c in columns
]
return session.execute(
sa.select(*columns_to_select)
).one()
# 예시 데이터 및 세션 설정 (본문 소스에는 없지만 이해를 위해 추가)
# from sqlalchemy import create_engine
# from sqlalchemy.orm import sessionmaker
# engine = create_engine('sqlite:///:memory:')
# Base.metadata.create_all(engine)
# Session = sessionmaker(bind=engine)
# session = Session()
# session.add_all([
# TOne(Amt1=Decimal('10'), Amt2=Decimal('5')),
# TOne(Amt1=Decimal('20'), Amt2=Decimal('15'))
# ])
# session.commit()
위 코드의 get_aggregated 메서드를 호출하면 다음과 같은 결과를 반환합니다.
>>> # TOne.get_aggregated(session, TOne.Amt1, TOne.calc1, TOne.calc2) 호출 시
(Decimal('10'), Decimal('-15'), Decimal('35'))
여기서 문제는 반환된 sqlalchemy.engine.row.Row 객체의 각 값에 접근할 때 사용되는 컬럼명이 sum_1, sum_2, sum_3과 같이 일반적인 형태로 부여된다는 점입니다. 개발자는 이 컬럼들이 실제 요청한 컬럼명인 Amt1, calc_1, calc_2에 매핑되기를 원합니다.
이 호출이 생성하는 SQL 쿼리는 다음과 같습니다.
SELECT sum([TOne].[Amt1]) AS sum_1, sum([TOne].[Amt1] - [TOne].[Amt2]) AS sum_2, sum([TOne].[Amt1] + [TOne].[Amt2]) AS sum_3 FROM [TOne]
개발자가 원하는 SQL 쿼리는 다음과 같이 명시적인 별칭을 포함해야 합니다.
SELECT sum([TOne].[Amt1]) AS Amt1, sum([TOne].[Amt1] - [TOne].[Amt2]) AS calc_1, sum([TOne].[Amt1] + [TOne].[Amt2]) AS calc_2 FROM [TOne]
이러한 목표를 달성하기 위해 sa.func.sum(c).label(c.name)과 같이 .label() 메서드를 사용하려 시도했지만, 이는 일반 컬럼에는 작동하나 column_property나 hybrid_property와 같은 계산된 컬럼에는 적용되지 않는 문제가 있었습니다.
2. 명확한 발생 원인
SQLAlchemy에서 sa.func.sum(c).label(c.name)이 계산된 컬럼에 제대로 작동하지 않는 이유는 c.name 속성이 해당 컬럼의 내부적인 이름 또는 기본 데이터베이스 컬럼 이름을 참조하기 때문입니다. 특히 column_property나 hybrid_property와 같은 복합적인 표현식의 경우, c.name은 우리가 Python 클래스에 정의한 속성명(예: calc1, calc2)을 직접적으로 반환하지 않을 수 있습니다. 대신 SQLAlchemy 내부적으로 생성된 임시 이름이나 None 값을 가질 수 있으며, 이로 인해 .label()에 전달되는 인자가 의도한 별칭이 되지 않습니다.
집계 함수(sa.func.sum())는 기본적으로 SQL 쿼리에서 결과를 구분하기 위해 sum_1, sum_2와 같은 기본 별칭을 자동으로 할당합니다. 개발자가 명시적으로 .label()을 통해 별칭을 지정해주지 않으면, SQLAlchemy는 쿼리 결과의 모호성을 피하기 위해 이러한 임시 별칭을 사용하게 됩니다. 따라서 우리는 모든 컬럼 유형에 걸쳐 ORM 모델에 정의된 속성 이름을 안정적으로 가져와 .label()에 전달할 방법이 필요합니다.
3. 해결 방법 및 코드 예시
이 문제를 해결하는 핵심은 모든 컬럼 유형에 대해 Python 클래스에 정의된 속성 이름을 안정적으로 가져오는 것입니다. SQLAlchemy의 컬럼 객체에는 .key라는 속성이 있는데, 이 속성은 해당 ORM 속성(즉, Python 클래스에 정의된 변수 이름)을 문자열로 반환합니다. 이는 일반 Mapped 컬럼, column_property, 그리고 hybrid_property 모두에 일관되게 적용됩니다.
따라서, sa.func.sum(c).label(c.name) 대신 sa.func.sum(c).label(c.key)를 사용하면 모든 컬럼 유형에 대해 의도한 별칭을 부여할 수 있습니다. 아래에 수정된 get_aggregated 메서드와 그 결과 예시를 보여드립니다.
3.1. 수정된 코드
get_aggregated 메서드의 columns_to_select 리스트 컴프리헨션 부분만 수정하면 됩니다.
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, column_property
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.declarative import declarative_base
from decimal import Decimal
Base = declarative_base()
class TOne(Base):
__tablename__ = 'TOne'
RecordID: Mapped[int] = mapped_column(primary_key=True)
Amt1: Mapped[Decimal] = mapped_column()
Amt2: Mapped[Decimal] = mapped_column()
calc1 = column_property(Amt1 - Amt2)
@hybrid_property
def calc2(self):
return self.Amt1 + self.Amt2
@calc2.expression
def calc2(cls):
return cls.Amt1 + cls.Amt2
@classmethod
def get_aggregated(cls, session, *columns):
columns_to_select = [
sa.func.sum(c).label(c.key) # 핵심 변경: c.name 대신 c.key 사용
for c in columns
]
return session.execute(
sa.select(*columns_to_select)
).one()
3.2. 해결 후 실행 결과 및 생성된 SQL
수정된 get_aggregated 메서드를 호출하면 이제 Row 객체에서 올바른 컬럼명으로 값에 접근할 수 있게 됩니다.
>>> # TOne.get_aggregated(session, TOne.Amt1, TOne.calc1, TOne.calc2) 호출 시
# (session.execute 결과는 튜플처럼 보이지만, 실제 Row 객체는 key로 접근 가능)
row = TOne.get_aggregated(session, TOne.Amt1, TOne.calc1, TOne.calc2)
print(row.Amt1) # Decimal('10')
print(row.calc1) # Decimal('-15')
print(row.calc2) # Decimal('35')
생성되는 SQL 쿼리 역시 개발자가 의도한 별칭을 포함하게 됩니다.
SELECT sum([TOne].[Amt1]) AS Amt1, sum([TOne].[Amt1] - [TOne].[Amt2]) AS calc1, sum([TOne].[Amt1] + [TOne].[Amt2]) AS calc2 FROM [TOne]
이로써 Row 객체의 데이터에 row.Amt1, row.calc1, row.calc2와 같이 직관적인 방식으로 접근할 수 있게 되었습니다.
4. 향후 예방을 위한 팁
이와 같은 문제를 예방하고 SQLAlchemy를 더욱 효과적으로 사용하기 위한 몇 가지 팁입니다.
-
SQLAlchemy 컬럼 속성 이해
Column또는 ORM 매핑된 속성에는.name,.key,.expression등 여러 속성이 있습니다. 이들의 차이점을 명확히 이해하는 것이 중요합니다..name: 주로 데이터베이스 컬럼의 실제 이름. 복합 표현식에서는 예측하기 어렵거나 유효하지 않을 수 있습니다..key: ORM 매핑에서 사용되는 속성 이름. Python 클래스에 정의된 변수 이름과 일치하며, ORM을 통해 접근할 때 사용됩니다. 집계 쿼리에서 별칭으로 사용하기에 가장 적합합니다..expression:column_property나hybrid_property와 같은 계산된 컬럼의 실제 SQL 표현식.
-
명시적인
.label()사용 습관화복잡한 쿼리, 특히 집계 함수나 서브쿼리를 사용할 때는 항상
.label()을 사용하여 컬럼 별칭을 명시적으로 지정하는 습관을 들이는 것이 좋습니다. 이는 코드의 가독성을 높이고, 예측 불가능한 기본 별칭 할당으로 인한 혼란을 방지합니다. -
테스트 코드 작성
새로운 쿼리나 복잡한 로직을 추가할 때는 반드시 테스트 코드를 작성하여 원하는 결과와 컬럼명을 정확히 반환하는지 확인해야 합니다. SQLAlchemy의 ORM 레이어는 강력하지만, SQL 쿼리의 미묘한 차이로 인해 예상치 못한 동작을 할 수 있습니다.
-
SQLAlchemy 문서 활용
SQLAlchemy 공식 문서는 매우 상세하고 유용합니다. 궁금한 점이 생기거나 특정 기능의 동작 방식을 이해해야 할 때는 항상 문서를 참조하는 것이 가장 정확하고 신뢰할 수 있는 방법입니다.
이 해결책을 통해 SQLAlchemy를 사용하는 개발자들이 집계 쿼리에서 컬럼 별칭 문제로 더 이상 어려움을 겪지 않고, 보다 효율적이고 직관적인 코드를 작성할 수 있기를 바랍니다.
![[문제 해결] [SQLAlchemy] 레거시 읽기 전용 DB Python 데이터 모델링: 최적의 선택과 전략 [문제 해결] [SQLAlchemy] 레거시 읽기 전용 DB Python 데이터 모델링: 최적의 선택과 전략](https://dev-error.com/wp-content/plugins/contextual-related-posts/default.png)