Language/Python

[SQLAlchemy] 동시성 제어 (Optimistic Lock / Pessimistic Lock)

PRESSO_ 2025. 6. 28. 22:55

개요

웹 개발을 할 때에는 동시성(concurrency)을 신경써야 한다.

 

예를 들면 간단히 게시글에 좋아요를 누를 수 있는 서비스가 있다고 할 때,

사용자 두 명이 "동시에" 좋아요 버튼을 눌렀을 경우 동시성을 무시할 경우 한 사용자의 좋아요만 올라갈 가능성이 발생한다.

 

이를 경쟁 상태(Race Condition)라고 한다.

 

이러한 동시성 문제가 발생하지 않도록 하기 위해 데이터베이스의 "락" 등의 방안이 존재한다.

 

동시성을 무시한 경우, 비관적 락을 사용한 경우, 낙관적 락을 사용한 경우로 나누어 각각 동시에 요청이 들어올 경우 어떤 현상이 발생하는지 정리하고자 한다.

 

우선 데이터베이스 테이블과 Entity를 만들고 SQLAlchemy의 명시적 매핑(imperatively mapping)을 이용해 Entity와 데이터베이스의 객체를 매핑해주자.

from sqlalchemy import Integer, Column, MetaData, Table, text
from sqlalchemy.orm import registry, scoped_session, sessionmaker
from sqlalchemy.engine import create_engine


# Post Entity
class Post:
    like: int


# SQLAlchemy Mapping
metadata = MetaData()
mapper_registry = registry()

post_table = Table(
    't_post',
    metadata,
    Column('id', Integer, primary_key=True, autoincrement=True),
    Column('like', Integer, server_default=text("'0'"))
)

def init_mapper():
    mapper_registry.map_imperatively(
        Post,
        post_table
    )

# SQLAlchemy Etc
def get_engine():
    return create_engine(
        "{your database url}",
        echo=True
    )

def get_session(engine):
    return scoped_session(sessionmaker(
        bind=engine,
        expire_on_commit=False,
        autocommit=False,
        autoflush=False
    ))

def create_tables(engine):
    metadata.create_all(engine)

Default

동시성 문제에 대해 아무런 대응을 하지 않을 경우, 위의 다이어그램처럼 두 개의 "좋아요" 요청이 왔음에도 "좋아요" 개수가 한 개만 증가하게 된다.

 

요청에 대한 각 세션에서 "좋아요"가 0인 상태의 게시글을 불러오고 "좋아요" 개수를 한 개씩 증가시켜 커밋하면 최종적으로 게시글의 "좋아요" 개수는 1이 되기 때문이다.

 

이제 SQLAlchemy를 이용해서 이 현상이 실제로 발생하는지 확인해보자.

 

요청이 동시에 발생한 경우를 만들기 위해 두 개의 쓰레드를 만들고 각 쓰레드에서 "좋아요"를 증가시키도록 코드를 작성했다.

import threading

from sqlalchemy import select, delete
from sqlalchemy.orm.session import Session


def increase_like(session: Session):
    for _ in range(25):
        q = select(Post)
        post = session.execute(q).scalars().first()
        post.like += 1

        session.commit()
        session.close()


if __name__ == "__main__":
    init_mapper()
    session = get_session(get_engine())

    init_post(session)

    t1 = threading.Thread(target=increase_like, args=(session,))
    t2 = threading.Thread(target=increase_like, args=(session,))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    q = select(Post)
    post = session.execute(q).scalars().first()
    print(f"게시글 {post.id}의 좋아요 개수: {post.like}")

 

결과는 아래와 같다.

각 쓰레드에서 게시글의 "좋아요" 수를 25개씩 증가시키도록 했음에도 동시성 문제로 인해 "좋아요" 수가 30개가 되었음을 알 수 있다.

 

이 문제를 해결하려면 어떤 방법을 사용해야 할까?

비관적 락(Pessimistic Lock)

비관적 락(Pessimistic Lock)이란 동시성 문제가 발생할 것을 예상하고 미리 해당 데이터에 대한 접근을 막아놓는 것을 의미한다.

 

사용자 1과 사용자 2가 동시에 "좋아요"를 눌렀다고 가정했을 때, 먼저 온 첫 요청에 대해 "좋아요"를 증가시킬 게시글을 불러오는 시점에서 Lock을 걸고 "좋아요"를 증가시킨 뒤에 커밋 후 Lock을 해제한다.

 

이 때 나중에 온 요청은 Lock이 해체될 때가지 Blocking 된다.

 

이를 다이어그램으로 표현하면 아래와 같다.

첫 요청에서 게시글을 데이터베이스에서 불러올 때 Lock이 걸리고 커밋 시 Lock이 해제된다.

 

나중에 오는 요청은 Lock이 해제될 때까지 대기하며 Lock이 해제되면 게시글을 불러온다.

구현

SQLAlchemy에서 Lock을 사용하려면 select 쿼리에 with_for_update를 추가해주면 된다.

import threading

from sqlalchemy import select, delete
from sqlalchemy.orm.session import Session


def increase_like(session: Session):
    for _ in range(25):
        q = select(Post).with_for_update()
        post = session.execute(q).scalars().first()
        post.like += 1

        session.commit()
        session.close()


if __name__ == "__main__":
    init_mapper()
    session = get_session(get_engine())

    init_post(session)

    t1 = threading.Thread(target=increase_like, args=(session,))
    t2 = threading.Thread(target=increase_like, args=(session,))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    q = select(Post)
    post = session.execute(q).scalars().first()
    print(f"게시글 {post.id}의 좋아요 개수: {post.like}")

결과는 아래와 같다.

SQLAlchemy에서 생성된 쿼리를 확인해 보면 SELECT 쿼리에 FOR UPDATE가 추가된 것을 볼 수 있다.

 

FOR UPDATE가 붙어있으면 SELECT한 데이터에 대한 접근을 다른 세션에서 접근할 수 없도록 막고(Lock), Lock이 해제될 때까지 다른 세션에서의 접근은 Blocking된다.

 

UPDATE 쿼리가 실행되면 Lock이 해제되며 다른 세션에서의 요청도 UnBlocking 되어 해당 데이터에 접근할 수 있게 된다.

장점

비관적 락을 사용하면 별 다른 처리 없이도 쉽게 동시성 문제를 해결할 수 있다.

그냥 쿼리에 with_for_update만 추가해주면 된다.

단점

하나의 세션에서 데이터에 대해 Lock을 걸고 해제하기 전까지 다른 요청들은 그냥 대기하고 있기 때문에 코드의 실행 시간이 증가한다.

 

확실한 차이를 알아보기 위해 게시글의 "좋아요"를 100,000개 증가시키는 코드를 각각 비관적 락(Pessimistic Lock)을 사용하지 않았을 때와 사용했을 때 소요되는 시간을 알아보자.

 

Pessimistic Lock을 사용하지 않은 경우 게시글의 "좋아요"를 100,000개 증가시키는 데에 약 40초 가량이 걸렸다.

(동시성 문제가 발생해 좋아요 개수도 맞지 않는 것을 확인할 수 있다)

Pessimistic Lock을 사용한 경우 게시글의 "좋아요"를 100,000개 증가시키는 데에 약 60초 가량이 걸렸다.

(좋아요 개수는 정상이다)

 

낙관적 락(Optimistic Lock)

낙관적 락(Optimistic Lock)이란 동시성 문제가 발생할 것을 예상하는 것이 아니라 실제로 동시성 문제가 발생했을 때 이에 대응하는 것을 의미한다.

 

각 데이터의 version을 저장해 두고 커밋하는 시점에서 데이터베이스 내의 데이터의 version을 확인한다.

이 때, 데이터의 version이 세션 내의 version과 다르다면 해당 데이터가 최신 데이터가 아니라고 판단하고 에러를 발생시킨다.

 

이를 다이어그램으로 표현하면 아래와 같다.

각 요청에서 동시에 데이터를 가져온 후 "좋아요"를 증가시키고 커밋하는 것은 동일하지만,

이미 다른 세션에서 커밋되어 데이터베이스 내 데이터의 version과 세션 내 데이터의 version이 다르다면 에러를 발생시킨다.

 

SQLAlchemy에서 Optimisitic Lock 사용 시 동시성 문제가 발생하면 StaleDataError가 발생하고, 해당 에러 발생 시 재시도하도록 구현하면 된다.

구현

우선, 데이터베이스 테이블에 데이터의 버전을 저장하기 위한 version 컬럼을 만들어준다.

 

version을 담당하기 위한 컬럼은 null 값을 허용하지 말라고 SQLAlchemy 공식 문서에 나와있다.

(https://docs.sqlalchemy.org/en/20/orm/versioning.html#simple-version-counting)

 

post_table = Table(
    't_post',
    metadata,
    Column('id', Integer, primary_key=True, autoincrement=True),
    Column('like', Integer, server_default=text("'0'")),
    Column('version', Integer, server_default=text("'1'"), nullable=False),
)

(예제 코드에서는 Integer을 사용했지만 실무에서는 혹시 모르기 때문에 BigInteger를 사용하는게 좋을 것 같다.)

 

그리고 mapping 시 버전을 판단하기 위한 컬럼으로 어떤 컬럼을 사용할지 지정해주어야 한다.

def init_mapper():
    mapper_registry.map_imperatively(
        Post,
        post_table,
        version_id_col=post_table.c.version,
    )

 

그리고 테스트 코드를 작성한다.

여기서 주의해야 할 점은, StaleDataError가 발생했을 때 트랜젝션을 롤백하고 재시도하는 로직을 작성해줘야 한다는 점이다.

 

필자는 tenacity를 이용해서 재시도 로직을 구현했다.

import threading
import time

from sqlalchemy.orm.exc import StaleDataError
from tenacity import retry, wait_random_exponential, retry_if_exception_type

from sqlalchemy import select, delete
from sqlalchemy.orm.session import Session


def increase_like(session: Session):
    @retry(
        retry=retry_if_exception_type(StaleDataError),
        wait=wait_random_exponential(multiplier=0.05, max=1),
    )
    def inc():
        q = select(Post)
        post = session.execute(q).scalars().first()
        post.like += 1

        session.add(post)

        try:
            session.commit()
        except StaleDataError:
            print("StaleDataError 발생, 트랜잭션 롤백")
            session.rollback()
            raise
        finally:
            session.close()

    for _ in range(25):
        inc()


if __name__ == "__main__":
    engine = get_engine()
    metadata.drop_all(engine)
    metadata.create_all(engine)

    init_mapper()
    session = get_session(engine)

    init_post(session)

    t1 = threading.Thread(target=increase_like, args=(session,))
    t2 = threading.Thread(target=increase_like, args=(session,))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    q = select(Post)
    post = session.execute(q).scalars().first()
    print(f"게시글 {post.id}의 좋아요 개수: {post.like}")

 

결과는 아래와 같다.

 

SQLAlchemy가 생성한 쿼리를 확인해보면 SELECT 시 version을 함께 불러오고 UPDATE 시 WHERE 절을 이용해 PK와 version으로 데이터를 찾아서 업데이트하는 것을 알 수 있다.

이 때, 동일한 데이터가 없다면(= version이 일치하는 데이터가 없다면) StaleDataError Exception이 발생한다.

 

StaleDataError이 발생했을 때 트랜젝션을 롤백하고 재시도하는 로직을 작성했기 때문에 다시 데이터를 가져오고 업데이트하게 된다.

장점

테스트 코드에서는 동시성 문제가 자주 발생하도록 구현했기 때문에 동시성 문제가 자주 발생하는 것처럼 보일 수 있으나,

현실 세계에서 동시성 문제가 그렇게 자주 발생하지는 않는다.

 

그렇기 때문에 모든 요청에 대해 Lock을 거는 것보다 실제로 동시성 문제가 발생했을 때 발생했다는 것을 인지하고 대처하도록 하는 게 더 좋을 수 있다.

단점

우선, 데이터의 정합성이 확실하게 보장되지 않는다는 단점이 있다.

비관적 락 사용 시 데이터베이스 단에서 Lock을 보장하지만 낙관적 락은 어플리케이션 단에서 구현된다.

 

그리고 동시성 문제 발생 시 해당 트랜젝션을 모두 롤백하고 재시도하기 때문에 동시성 문제가 자주 발생할 경우, 시간이 많이 걸리는 작업 등에서  비관적 락보다 더 많은 시간이 걸리게 된다.

 

다른 소소한 단점으로 version을 저장할 컬럼을 추가적으로 만들어줘야 하기 때문에 용량을 더 많이 사용한다는 단점이 있다.

그래서 어떤 방법을 사용해야 할까?

비관적 락(Pessimistic Lock)

  • 정합성 문제가 자주 발생할 거라고 예상되는 경우
  • 시간이 많이 걸리는 작업이 포함되어 있는 경우
  • 시간을 희생하더라도 데이터베이스 용량을 최대한 줄여야 하는 경우
  • 정합성이 매우 중요한(ex. 은행 등) 시스템

낙관적 락(Optimistic Lock)

  • 정합성 문제가 자주 발생하지 않을 경우

마치며

우선, 낙관적 락을 사용하도록 코드를 작성하고 운영 환경에서 동시성 문제가 자주 발생하는 부분에 비관적 락을 사용하도록 하는 것이 좋을 것으로 보인다.

Reference

https://docs.sqlalchemy.org/en/20/orm/versioning.html#simple-version-counting

 

Configuring a Version Counter — SQLAlchemy 2.0 Documentation

Configuring a Version Counter The Mapper supports management of a version id column, which is a single table column that increments or otherwise updates its value each time an UPDATE to the mapped table occurs. This value is checked each time the ORM emits

docs.sqlalchemy.org

https://jakpentest.tistory.com/entry/SQLAlchemy-PessimisticOptimistic-Lock

 

[SQLAlchemy] Pessimistic/Optimistic Lock

HTML 삽입 미리보기할 수 없는 소스 개요 웹 서버는 여러 Client의 요청을 동시에 수행할 수 있어야 한다. 한 자원을 놓고 동시에 요청이 실행한다면 어떤 문제가 발생할 수 있을까? 위와 같은 주제

jakpentest.tistory.com