[에러 해결] [SQLAlchemy PostgreSQL JSONB] 원자적(Atomic) 업데이트 효율성 비교 및 최적화

데이터베이스의 JSONB 컬럼을 다룰 때, 특히 여러 프로세스나 사용자가 동시에 접근하는 환경에서는 ‘원자성(Atomicity)’과 ‘효율성’이 매우 중요합니다. 기존 데이터를 조회하지 않고 특정 JSONB 필드를 업데이트해야 하는 상황은 웹 애플리케이션 개발에서 흔히 발생하며, 이때 어떤 방식으로 SQL 쿼리를 구성하는지에 따라 성능과 데이터 무결성이 크게 달라질 수 있습니다.

이 글에서는 Python의 SQLAlchemy를 사용하여 PostgreSQL의 JSONB 컬럼에 새로운 키-값 쌍을 원자적으로 추가하는 두 가지 접근 방식을 분석하고, 각각의 효율성과 원자성 측면에서 어떤 차이가 있는지 심층적으로 다루겠습니다.

1. 에러 발생 상황

사용자의 주요 요구사항은 다음과 같습니다. 특정 데이터베이스 행의 jsonb 컬럼에 저장된 JSON 객체에 새로운 session_id: group_key 쌍을 추가하는 것입니다. 이때 중요한 조건은:

  • 기존 JSONB 컬럼의 현재 상태를 SELECT하지 않고 바로 UPDATE해야 합니다.
  • 업데이트 작업은 반드시 ‘원자적(Atomic)’으로 이루어져야 합니다. 즉, 다른 트랜잭션의 간섭 없이 단일 작업으로 완료되어야 합니다.

이러한 요구사항을 만족하기 위해, 사용자는 두 가지 SQLAlcehmy 쿼리 구현을 제시하고 이들의 효율성 및 원자성에 대한 질문을 던졌습니다.

첫 번째 해결 시도: op("||") 연산자 사용

첫 번째 코드는 PostgreSQL의 JSONB 연결 연산자 ||를 SQLAlchemy의 op() 메서드를 통해 직접 호출하는 방식입니다.

async def add_group_member_session_group_key(
    session: AsyncSession, group_id: int, user_id: int,
    user_session_id: uuid.UUID,
    session_group_key: tuple
):
    query = (
        update(GroupChatMember)
        .where(
            GroupChatMember.group_id==group_id,
            GroupChatMember.user_id==user_id,
        )
        .values(
            group_key=GroupChatMember.group_key.op("||")(
                cast(
                    {str(user_session_id): session_group_key},
                    JSONB,
                )
            )
        )    
    )
    await session.execute(query)
    await session.commit()

두 번째 해결 시도: + literal() 연산자 사용

두 번째 코드는 파이썬의 + 연산자와 SQLAlchemy의 literal() 함수를 활용하는 방식입니다.

async def add_group_member_session_group_key(
    session: AsyncSession, group_id: int, user_id: int,
    user_session_id: uuid.UUID,
    session_group_key: tuple
):
    query = (
        update(GroupChatMember)
        .where(
            GroupChatMember.group_id==group_id,
            GroupChatMember.user_id==user_id,
        )
        .values(    
            group_key=GroupChatMember.group_key + literal(
                {user_session_id: session_group_key}, JSONB
            )
        )
    )

    await session.execute(query)
    await session.commit()

2. 명확한 발생 원인 및 분석

사용자의 질문은 “이 두 가지 방식이 효율성이나 원자성 측면에서 차이가 있는가?”입니다. 결론부터 말하자면, 두 방식 모두 PostgreSQL의 동일한 원자적 JSONB 연산을 수행하며, 효율성 측면에서도 거의 차이가 없습니다.

원자성(Atomicity) 분석

두 코드 예시 모두 UPDATE 쿼리 하나로 JSONB 컬럼을 변경합니다. PostgreSQL 데이터베이스에서 단일 UPDATE 문은 본질적으로 원자적입니다. 이는 데이터베이스 시스템이 내부적으로 락(lock)을 사용하여 동시성 문제를 관리하기 때문입니다. 즉, 하나의 UPDATE 트랜잭션이 완료되기 전에 다른 트랜잭션이 해당 행에 접근하여 중간 상태의 데이터를 보거나 변경하는 것을 방지합니다.

따라서, 현재 JSONB 상태를 SELECT하고 애플리케이션 레이어에서 JSON 객체를 수정한 후 다시 UPDATE하는 방식과 달리, 데이터베이스 자체의 연산자를 사용하는 이 두 방법은 ‘읽기-수정-쓰기’로 인한 경쟁 조건(race condition)을 완벽하게 회피합니다. 두 솔루션 모두 이 측면에서 동일하게 원자적입니다.

효율성(Efficiency) 분석

SQLAlchemy는 Python 객체와 SQL 구문 사이의 추상화 계층을 제공합니다. 두 가지 접근 방식이 최종적으로 PostgreSQL에서 어떤 SQL 쿼리로 변환되는지 살펴보겠습니다.

  • GroupChatMember.group_key.op("||")(cast(..., JSONB)): 이 구문은 PostgreSQL의 jsonb_column || '{"new_key": "new_value"}'::jsonb와 같은 형태로 직접 매핑됩니다. op("||")는 개발자가 명시적으로 PostgreSQL의 || 연산자를 사용하겠다고 알리는 것입니다. cast(..., JSONB)는 파이썬 딕셔너리를 PostgreSQL의 JSONB 타입으로 명시적으로 캐스팅합니다.
  • GroupChatMember.group_key + literal({user_session_id: session_group_key}, JSONB): SQLAlchemy는 + 연산자가 `JSONB` 타입과 함께 사용될 때, 이를 PostgreSQL의 || (JSONB 연결) 연산자로 변환하도록 오버로드되어 있습니다. literal(..., JSONB)는 주어진 파이썬 객체를 특정 타입(여기서는 JSONB)의 SQL 리터럴로 해석하도록 지시합니다. 결과적으로 이 역시 jsonb_column || '{"new_key": "new_value"}'::jsonb와 유사한 SQL 구문으로 변환됩니다.

두 방식 모두 최종적으로 동일한 PostgreSQL의 jsonb || jsonb 연산을 사용하기 때문에, 데이터베이스 엔진 수준에서의 성능 차이는 거의 없다고 볼 수 있습니다. SQLAlchemy 내부에서 SQL 쿼리를 생성하는 과정에서 미세한 오버헤드가 있을 수 있으나, 이는 일반적으로 무시할 수 있는 수준입니다.

따라서, 두 솔루션 모두 효율성 측면에서도 실질적인 차이를 보이지 않습니다.

3. 해결 방법 및 코드 예시

위의 분석에 따라, 두 가지 해결책 모두 원자적이고 효율적입니다. 개발자는 자신의 코드 스타일이나 가독성 선호도에 따라 선택할 수 있습니다. 그러나 SQLAlchemy의 관용적인 표현을 선호한다면 + literal(...) 방식이 조금 더 직관적일 수 있습니다.

권장되는 접근 방식 (두 솔루션 모두 유효)

두 솔루션 모두 올바른 접근 방식이지만, 일반적으로 SQLAlchemy와 같은 ORM을 사용할 때는 ORM이 제공하는 연산자를 최대한 활용하는 것이 좋습니다. + literal(...) 방식은 SQLAlchemy가 Python의 연산자를 SQL 연산자로 변환하는 방식을 활용하므로 더 ‘SQLAlchemy스러운’ 코드로 볼 수 있습니다.

만약 명시적인 PostgreSQL 연산자 사용을 선호한다면 op("||")도 좋은 선택입니다. 중요한 것은 두 방식 모두 PostgreSQL의 || 연산자로 변환된다는 점입니다. 이 연산자는 두 JSONB 객체를 병합하며, 동일한 키가 존재할 경우 오른쪽 JSONB 객체의 값이 우선합니다.

다음은 두 번째 솔루션을 기반으로 한 코드 예시입니다. 이미 완벽하게 작동하는 코드이므로, 이대로 사용하시면 됩니다.

from sqlalchemy import update, cast, literal
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
import asyncio

# 가상의 GroupChatMember 모델 (실제 환경에 맞게 조정 필요)
class GroupChatMember:
    # 예시를 위한 스켈레톤, 실제 ORM 모델은 Column, Integer 등을 사용
    group_id: int
    user_id: int
    group_key: dict # JSONB 컬럼을 파이썬 딕셔너리로 매핑 가정

    def __init__(self, group_id, user_id, group_key):
        self.group_id = group_id
        self.user_id = user_id
        self.group_key = group_key

    # SQLAlchemy 모델이라면 다음과 같이 접근해야 함 (예시)
    # class GroupChatMember(Base):
    #     __tablename__ = 'group_chat_members'
    #     group_id = Column(Integer, primary_key=True)
    #     user_id = Column(Integer, primary_key=True)
    #     group_key = Column(JSONB)

# 스택오버플로우 질문자의 두 번째 솔루션 (권장)
async def add_group_member_session_group_key_optimized(
    session: AsyncSession, group_id: int, user_id: int,
    user_session_id: uuid.UUID,
    session_group_key: tuple
):
    """
    SQLAlchemy의 literal 함수를 사용하여 JSONB 컬럼에 새로운 키-값 쌍을 원자적으로 추가합니다.
    """
    # GroupChatMember.group_key는 실제 모델의 JSONB 컬럼 객체여야 합니다.
    # 예시를 위해 임의의 Column 객체를 생성 (실제 사용 시 모델에서 가져옵니다)
    from sqlalchemy import Column
    group_key_column = Column('group_key', JSONB) # 실제 모델 컬럼 사용

    query = (
        update(GroupChatMember) # 실제 SQLAlchemy 모델 객체
        .where(
            GroupChatMember.group_id == group_id, # 실제 모델 컬럼 사용
            GroupChatMember.user_id == user_id,   # 실제 모델 컬럼 사용
        )
        .values(    
            group_key=group_key_column + literal( # group_key_column 대신 GroupChatMember.group_key 사용
                {str(user_session_id): session_group_key}, JSONB
            )
        )
    )

    result = await session.execute(query)
    # 변경된 행의 수를 확인할 수 있습니다.
    if result.rowcount == 0:
        print(f"Warning: No row found for group_id={group_id}, user_id={user_id}")
    await session.commit()
    print(f"JSONB updated for group_id={group_id}, user_id={user_id} with session_id={user_session_id}")

# 실제 사용을 위한 가이드라인 (테스트용)
# async def main():
#     # AsyncSession 설정 (데이터베이스 연결 정보 필요)
#     # from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
#     # from sqlalchemy.orm import sessionmaker
#     # engine = create_async_engine("postgresql+asyncpg://user:password@host/dbname")
#     # async_session = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
#     # async with async_session() as session:
#     #     # 예시 데이터 (실제 DB에 존재해야 함)
#     #     group_id_val = 1
#     #     user_id_val = 101
#     #     new_session_id = uuid.uuid4()
#     #     new_session_group_key = ("some_key", "some_value")
#     #     
#     #     # 가상의 GroupChatMember 객체 생성 (실제로는 DB에서 가져옴)
#     #     # GroupChatMember.group_id, GroupChatMember.user_id는 실제 Column 객체여야 함.
#     #     # 여기서는 예시를 위해 스켈레톤 클래스 사용.
#     #     # 실제 SQLAlchemy 모델 사용 시 GroupChatMember.group_id, GroupChatMember.user_id, GroupChatMember.group_key 로 직접 접근.
#     #     
#     #     await add_group_member_session_group_key_optimized(
#     #         session, group_id_val, user_id_val, new_session_id, new_session_group_key
#     #     )
# 
# if __name__ == "__main__":
#     # asyncio.run(main())
#     print("The code demonstrates the atomic update logic.")
#     print("Please replace GroupChatMember and its columns with your actual SQLAlchemy ORM model.")
# 

참고: 위 예시 코드의 GroupChatMember 클래스는 SQLAlchemy 모델의 스켈레톤입니다. 실제 애플리케이션에서는 ORM 선언 모델(예: Base.metadata.create_all(engine)으로 생성된 모델)을 사용해야 합니다. group_key_column = Column('group_key', JSONB) 부분은 설명 목적으로 포함되었으며, 실제로는 GroupChatMember.group_key와 같이 모델에 정의된 컬럼 객체를 직접 사용해야 합니다.

4. 향후 예방을 위한 팁

  • SQLAlchemy 버전 확인: SQLAlchemy의 JSONB 관련 기능은 버전이 업데이트되면서 개선되거나 추가될 수 있습니다. 항상 최신 문서를 참고하고, 사용하는 SQLAlchemy 버전이 필요한 기능을 지원하는지 확인하세요.
  • PostgreSQL jsonb 연산자 숙지: PostgreSQL은 jsonb 타입에 대해 강력하고 다양한 연산자를 제공합니다. || 연산자 외에도 특정 키를 삭제하거나, 배열 요소를 추가하는 등 다양한 요구사항에 맞는 연산자가 있으므로, PostgreSQL 공식 문서를 통해 이들을 숙지하는 것이 좋습니다. 이를 통해 불필요한 애플리케이션 로직을 줄이고 데이터베이스 레벨에서 직접 효율적인 연산을 수행할 수 있습니다.
  • EXPLAIN ANALYZE 활용: 복잡한 JSONB 연산이나 대량의 데이터에 대한 업데이트 쿼리의 성능이 우려된다면, PostgreSQL의 EXPLAIN ANALYZE 명령어를 사용하여 쿼리 실행 계획과 실제 실행 시간을 분석하세요. 이를 통해 잠재적인 병목 현상을 식별하고 최적화 방안을 모색할 수 있습니다.
  • JSONB 인덱싱 고려: JSONB 컬럼 내부의 특정 필드를 기준으로 조회가 잦다면, GIN(Generalized Inverted Index) 인덱스를 생성하는 것을 고려해야 합니다. 예를 들어 CREATE INDEX idx_group_key_gin ON GroupChatMember USING GIN (group_key);와 같이 인덱스를 생성하면 jsonb ? 'some_key'와 같은 연산의 성능을 크게 향상시킬 수 있습니다.
  • 오류 처리 및 트랜잭션 관리: 비동기 세션을 사용하는 경우, try...except 블록을 통해 예외를 적절히 처리하고, 문제가 발생했을 때 session.rollback()을 호출하여 데이터 일관성을 유지하는 것이 중요합니다.

이 가이드라인을 통해 SQLAlchemy와 PostgreSQL의 JSONB 컬럼을 원자적이고 효율적으로 업데이트하는 데 도움이 되기를 바랍니다. 데이터베이스의 강력한 기능을 활용하여 견고하고 성능 좋은 애플리케이션을 구축하세요!

댓글 남기기