본문 바로가기

시스템/Devops

SQLAlchemy 바인드 이름이 toda로 잘릴 때 CAST로 고치기

PostgreSQL 캐스트를 붙인 :today::text가 today가 아닌 toda로 인식돼, 서버 접속 없이 바인드 목록을 확인하고 CAST(:today AS text)로 수정했다. SQLAlchemy text()에 SELECT to_jsonb(:today::text)를 넣고 바인드 목록을 출력했다. today가 나올 줄 알았는데, 목록에는 toda가 들어 있었다.

전제 환경 — Python 3.12 / uv 임시 환경에 sqlalchemy>=2 설치 / 기록에서 확인된 문제 범위: SQLAlchemy 2.1

한 줄 답 — SQLAlchemy 2.1에서 :today::text는 바인드 이름이 toda로 잘리는 클라이언트 파싱 문제였으며, CAST(:today AS text)로 바꿔 today가 온전히 인식되도록 고쳤다.

먼저 결론

  • DB 실행 전에 text()가 만든 _bindparams를 확인한다.
  • :today::text를 CAST(:today AS text)로 바꾼다.
  • 설치 버전과 수정된 표현의 바인드 이름을 함께 확인한다.

전체 그림

서버에 전달하기 전의 실패 지점

[텍스트 SQL: :today::text]
  → [SQLAlchemy text() 파싱]
  → [바인드 이름 toda: 실패 지점]

[텍스트 SQL: CAST(:today AS text)]
  → [SQLAlchemy text() 파싱]
  → [바인드 이름 today 유지]
  → [이후 DB 실행: 이 기록의 검증 범위 밖]

무엇이 문제였나

PostgreSQL의 축약 캐스트 문법처럼 보이더라도, 텍스트 SQL은 서버에 도달하기 전에 SQLAlchemy의 text() 파서를 거친다. 이 파서는 콜론을 바인드 변수 표식으로 처리한다. 처음에는 PostgreSQL에서 흔히 쓰는 표현이니 그대로 사용할 수 있다고 봤다. 하지만 text() 직후의 _bindparams를 출력해 보니, 서버로 보내기도 전에 이름이 잘려 있었다.

확인한 대상은 SELECT to_jsonb(:today::text)와 SELECT to_jsonb(CAST(:today AS text))다. 서버의 SQL 실행 오류가 아니라 파싱 직후의 바인드 목록을 비교했다. 기록에서 확인한 정확한 결론은 “SQLAlchemy 2.1에서 :today::text는 파라미터 이름이 toda로 잘못 잡힌다”였다. 다른 SQLAlchemy 버전이나 드라이버에서도 동일하다는 검증 결과는 없다.

처음엔 이렇게 봤다

답부터 보면 당연해 보이지만, 처음 몇 시간은 여기에 썼습니다.

처음 의심한 것 그렇게 본 이유 아니라고 판단한 근거
:today::text를 그대로 사용할 수 있다고 판단했다. PostgreSQL에서 흔히 보이는 캐스트 표현이므로 바인드 변수에도 그대로 적용할 수 있어 보였다. text() 직후 _bindparams를 출력한 비교 결과는 다음과 같았다. 'SELECT to_jsonb(:today::text)' -> bind params: ['toda'] 'SELECT to_jsonb(CAST(:today AS text))' -> bind params: ['today'] 축약 캐스트에서는 today가 아니라 toda로 인식돼 서버 실행 전부터 이름이 잘려 있었으므로, 그대로 사용할 수 있다는 판단을 배제했다.

무엇이 달라졌나

바인드 변수 바로 뒤의 축약 캐스트를 명시적 CAST로 교체

이전

-- 기록상 바인드 이름이 toda로 인식됨
SELECT to_jsonb(:today::text);

바꾼 뒤

-- today가 온전한 바인드 이름으로 인식됨
SELECT to_jsonb(CAST(:today AS text));

파라미터

이름 값 무엇을 정하는가
--python 3.12 임시 검증 환경의 Python 버전
--with sqlalchemy>=2 임시 환경에 설치할 의존성 조건. SQLAlchemy 2.1 고정 아님

작업 순서

1. 두 표현의 바인드 목록을 비교한다

이걸 먼저 보는 이유는 데이터 값이나 PostgreSQL 함수를 살피기 전에, SQLAlchemy가 이름을 제대로 읽었는지 확인할 수 있기 때문이다. 실제로 DB 접속 없이 여기서 원인을 좁혔다. 축약 캐스트에서 ['toda'], CAST 표현에서 ['today']가 나오면 다음 단계에서 SQL을 교체한다.

진단 — uv를 사용할 수 있는 환경에서 실행: <작업디렉터리>는 실제 작업 경로로 교체

cd <작업디렉터리>
uv run --quiet --no-project --python 3.12 --with 'sqlalchemy>=2' python -c "
from sqlalchemy import text

for sql in [
    'SELECT to_jsonb(:today::text)',
    'SELECT to_jsonb(CAST(:today AS text))',
]:
    statement = text(sql)
    print(repr(sql), '-> bind params:', list(statement._bindparams))
"

출력 예

'SELECT to_jsonb(:today::text)' -> bind params: ['toda']
'SELECT to_jsonb(CAST(:today AS text))' -> bind params: ['today']

실패하면 — 출력이 다르면 다음 단계에서 출력하는 SQLAlchemy 버전을 확인한다. sqlalchemy>=2는 2.1을 고정하는 조건이 아니므로, 다른 버전의 결과를 이 기록과 같다고 단정하지 않는다.

2. 축약 캐스트를 CAST로 바꾸고 버전과 바인드 이름을 확인한다

SQL만 보면 둘 다 캐스트 표현이라, 고친 뒤에도 바인드 목록으로 확인했다. CAST 표현에서 ['today']가 나오고 설치 버전이 남으면 이 기록의 파싱 검증은 끝이다. 아래 비교 명령은 애플리케이션의 SQL 변경 여부나 DB 실행 성공까지 확인하는 명령은 아니다.

조치 — 애플리케이션에서 text()에 전달하는 SQL 교체

-- :today::text 대신 명시적 CAST 사용
SELECT to_jsonb(CAST(:today AS text));

검증 — 같은 작업 디렉터리에서 실행: <작업디렉터리>는 실제 작업 경로로 교체

cd <작업디렉터리>
uv run --quiet --no-project --python 3.12 --with 'sqlalchemy>=2' python -c "
import sqlalchemy
from sqlalchemy import text

print('sqlalchemy', sqlalchemy.__version__)
for sql in [
    'SELECT to_jsonb(:today::text)',
    'SELECT to_jsonb(CAST(:today AS text))',
]:
    statement = text(sql)
    print(repr(sql), '-> bind params:', list(statement._bindparams))
"

출력 예

# 첫 줄에 sqlalchemy와 설치 버전이 출력되는지 확인
# 아래는 SQLAlchemy 2.1에서 확인한 바인드 목록
'SELECT to_jsonb(:today::text)' -> bind params: ['toda']
'SELECT to_jsonb(CAST(:today AS text))' -> bind params: ['today']

실패하면 — 수정한 SQL에 :today::text가 남아 있는지 확인한다. 비교 결과가 기록과 다르면 설치 버전을 먼저 대조한다.

끝내고 확인할 것

  • 애플리케이션의 해당 SQL이 CAST(:today AS text)를 사용한다.
  • 수정된 표현의 bind params 출력이 ['today']다.
  • 확인 명령 첫 줄에 설치된 SQLAlchemy 버전이 출력된다.

증상과 대처

현상 원인 대응
:today::text의 바인드 파라미터 이름이 today가 아니라 toda로 인식됨 SQLAlchemy 텍스트 SQL 파서와 바인드 바로 뒤의 PostgreSQL 축약 캐스트 문법 충돌 CAST(:today AS text)로 바꾸고 _bindparams를 확인한다.

롤백

되돌리기 명령은 기록에 없다. 원래의 :today::text로 복원하면 확인된 바인드 이름 문제가 다시 생기므로 복원을 해결책으로 사용하지 않는다.

요점

  • DB 고유 문법과 바인드 표식을 함께 쓴 텍스트 SQL은 서버 오류를 추측하기 전에 SQLAlchemy가 인식한 바인드 목록부터 확인한다.

자주 묻는 것

SQLAlchemy에서 :today::text의 바인드 이름이 toda로 나오는 이유는?

SQLAlchemy 2.1의 text() 파서가 해당 표현에서 바인드 이름을 toda로 인식했기 때문이다. 이 기록에서는 DB에 접속하지 않고 _bindparams 출력으로 확인했다.

SQLAlchemy 바인드 변수에 PostgreSQL text 캐스트를 어떻게 붙이나요?

이 사례에서는 :today::text를 CAST(:today AS text)로 바꿨다. SELECT to_jsonb(CAST(:today AS text))의 바인드 목록은 ['today']로 확인됐다.

이 문제가 SQLAlchemy 2 버전 전체에서 발생하나요?

이 기록만으로는 SQLAlchemy 2 계열 전체에 일반화할 수 없다. 확인된 범위는 SQLAlchemy 2.1의 해당 표현이며, 다른 버전이나 드라이버의 동일 동작은 검증하지 않았다.