Language/Python

[SQLAlchemy] Pessimistic/Optimistic Lock

j4ko 2024. 3. 17. 21:41
728x90
반응형

목차

     

    개요 

    웹 서버는 여러 Client의 요청을 동시에 수행할 수 있어야 한다.

     

    한 자원을 놓고 동시에 요청이 실행한다면 어떤 문제가 발생할 수 있을까?

     

    위와 같은 주제는 동시성(Concurrency)이라는 개념에 대해 생각하게 만드는 주제이다. 비관적(Pessimistic)/낙관적(Optimistic) 잠금에 대한 이해를 필요로 한다. 그러한 맥락에서 이 포스팅은 SQLAlChemy의 Session ORM을 통해 각각을 어떻게 구현할 수 있을지에 대해 정리한 내용이다.

     

    1. Concurrency 문제

    한 자원을 놓고 동시에 요청을 실행하는 문제는 익히 알려져 있다. 이는 다음과 같은 도식화로 설명이 가능하다.

    위와 같은 시나리오에서는 사용자 A, B가 Resouce를 읽어(Read), Count를 올리고(Modify), 이를 저장(Save)하는 것을 표현한다. 사용자 A, B가 공유 Resouce에 Count를 올리는 것이기 때문에 Count는 결과적으로 2가 되어야 하지만 1이 돼버리는 것이 문제인 상황이다.

     

    이는 두 사용자가 공유 Resource를 사용하는데 Lock이 설정되지 않은 상황에서 Resource에 동시 접근하여 사용하다보니 발생하는 문제이다.

     

    2. Concurrency Implements

    2.1 Schema Setup

    앞서 설명한 문제 상황을 테스트해보자. “개요”에서 언급했듯 SQLAlchemy를 사용해 이와 같은 문제가 일어나는지 검증해 볼 것이다. 먼저 문제 구현에 검증할 Schema 부분이다.

    CREATE TABLE `posts` (
      `pk` int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
      `like` int(10) unsigned DEFAULT 0,
      `modified_at` int(1) unsigned DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    

    위의 “posts” Schema에서 “like” 열에 대해 Concurrnecy 이슈가 발생되는지 확인해볼 것이다.

    2.2 SQLAlchemy Setup

    필자는 Imperpartive Mapping 방식에 익숙하기 때문에 위 Schema를 통해 다음과 같이 환경을 설정했다.

    class Post(Base):
        __tablename__ = 'posts'
    
        pk = Column(INTEGER(10), primary_key=True)
        like = Column(INTEGER(10), server_default=text("'0'"))
        modified_at = Column(INTEGER(1), server_default=text("'0'"))
    
    class PostEntity:
        like: int
        modified_at: int
        
    
    def start_mapper():
        orm_mapper = registry(orm.metadata)
        orm_mapper.map_imperatively(
            PostEntity,
            orm.Post
        )
        return orm_mapper
    

    2.3 테스트

    이제 Post Table의 like를 증가시키는 코드를 SQLAlchemy(1.4)의 Session ORM으로 작성해 보자.

    def increase_like(session):
        for _ in range(25):
            post = session.query(PostEntity).filter(PostEntity.pk == 1).one()
            post.like += 1
            session.commit()
            session.close()
    

    사용자 A, B가 이 코드를 동시에 사용하는 상황을 thread를 통해 검증해 보자.

    import threading
    from sqlalchemy.orm.session import Session
    
    session: Session = get_session(get_engine())
    
    t1 = threading.Thread(target=increase_like, args=(session,))
    t2 = threading.Thread(target=increase_like, args=(session,))
    
    t1.start()
    t2.start()
    
    t1.join()
    t2.join()
    
    post = session.query(PostEntity).filter(PostEntity.pk == 1).one()
    print("LIKE RESULT: ", post.like)
    

    사용자 A, B가 increase_like() 함수를 사용한다고 가정하면 각각 like를 25번씩 증가시키므로 이에 대한 결과로써 like의 수는 50이 되어야 한다. 그러나 이를 실행하면 결과는 50이 아닌 30이 되어버린다.

    사실 매번 실행할 때마다 like의 결과는 다르게 나오니 참고하자.

     

    3. Pessimistic / Optimistic Lock

    앞서 살펴본 문제를 해결하기 위해 Pessimistic / Optimistic Lock을 살펴보자.

    3.1 Pessimistic Lock, 비관적 락

    Pessimistic Lock은 Data에 Lock을 거는 방법이다. 이는 실제로 Lock을 걸기 때문에 다른 쪽에서는 Lock이 해제되기 전에 데이터를 가져갈 수 없게 된다. 다른 트랜잭션이 해제된다는 것을 실제로 COMMIT을 찍는다는 것을 의미한다.

     

    그러나 Pessmistic Lock은 성능의 손해와 함께 Dead Lock이 발생을 야기할 수 있다. SQLALchemy에서는 with_for_update()로 구현이 가능하다.

    def increase_like_with_pessimistic_lock(session):
        for _ in range(25):
            post = session.query(PostEntity).filter(PostEntity.pk == 1).with_for_update().one()
            post.like += 1
            session.commit()
            session.close()
    

    SQLAlchemy의 Query 로그를 살펴보면 실행된 SQL에 “FOR UPDATE”가 붙어있는 것을 확인할 수 있으며 like column의 결과도 각각 50인 것을 확인할 수 있다.

    한 마디로 정리해 보자면 비관적 락의 콘셉트는 “요청에 따른 동시성 문제가 발생할 것”을 예상하고 Lock을 걸어버리는 방법이라 할 수 있다.

    3.2 Optimistic Lock, 낙관적 락

    Optimistic Lock은 version을 명시함으로써 구현이 가능하다. Pessimistic Lock과는 콘셉트이 다른데 “동시성 문제가 발생하면 그때 처리하자”라는 컨셉이다. 이 컨셉 덕분에 Optimstic Lock은 한쪽에서 Update가 실패한 경우 이를 어떻게 처리해야 할지에 대한 방법도 같이 생각해봐야 한다.

     

    이 글에서는 retry를 통한 해결 방법을 기록해 볼 것이다. 또한 Optimistic Lock을 구현하기 위한 2가지 방법을 설명할 것인데 첫 번째 방법은 일반적인 방법인 version 번호를 제어하는 방법이고 두 번째 방법은 SQLAlchemy에 내재된 기능을 기반으로 처리하는 방법이다.

     

    간략히 언급했듯 Optimistic Lock은 version을 명시해야 한다. Schema를 다음과 같이 수정하자.

    CREATE TABLE `posts` (
      `pk` int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
      `like` int(10) unsigned DEFAULT 0,
      `version` int(1) unsigned DEFAULT 0 NOT NULL,
      `modified_at` int(1) unsigned DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    

     

    3.2.1 version을 명시하는 Optimistic Lock

    Schema가 수정되었으니 imperative Mapping Class도 수정되어야 함에 주의하자. Optimistic Lock을 처리하기 위한 첫 번째 방법부터 살펴보자. version을 명시적으로 업데이트하는 방법이다.

    def increase_like_by_optimistic_lock(session):
        for _ in range(25):
            post = session.query(PostEntity).filter(PostEntity.pk == 1).one()
    
            result = session.query(PostEntity).filter(
                PostEntity.pk == post.pk,
                PostEntity.version == post.version
            ).update({
                PostEntity.like: PostEntity.like + 1,
                PostEntity.version: PostEntity.version + 1
            })
    
            if not bool(result):
                session.rollback()
            else:
                session.commit()
            session.close()
    

    위를 실행하면 낙관적 Lock의 콘셉트 때문에 어느 한쪽이 Update에 실패하게 되면 결과에 반영되지 않으므로 결과적으로 50이 되지 않는다. version을 명시하는 ORM을 사용했으므로 SQL Log는 다음과 같다.

     

    3.2.2 SQLAlchemy의 기능으로 구현하는 Optimistic Lock

    SQLAlchemy에는 optimistic lock을 처리하는 방법에 관해 지원되는 기능이 존재한다. 이 문서에서 참고할 수 있다. 해당 문서의 내용은 Declarative Base를 기준으로 설명하는데 필자가 기록하는 이 글은 Imperative Mapping 방식이기에 다음과 같이 설정해야 한다.

    orm_mapper.map_imperatively(
          PostEntity,
          orm.Post,
          version_id_col=orm.Post.__table__.c.version,
      )
    

    앞서 version을 명시하는 ORM을 사용하는 방식과 다르게 이 방식은 간단히 session.add를 사용함으로써 가능하다.

    def increase_like_by_optimistic_lock_with_sqlalchemy(session):
        for _ in range(25):
            post = session.query(PostEntity).filter(PostEntity.pk == 1).one()
            post.like = post.like + 1
    
            session.add(post)
            session.commit()
            session.close()
    

    위 결과는 다음과 같은 SQL 로그를 발생시킨다.

    session orm에서는 version을 명시하지 않았지만 SQL을 날리는 단계에서 version 번호를 제어하고 있음을 알 수 있다.

     

    3.2.3 Optimistic Lock - retry

    Update가 실패하면 재시도(retry)를 통해 해결하도록 처리해 보자. 직접 구현하는 방법도 있지만 backoff를 사용하면 구현이 가능하다.

    class OptimisticUpdateFailure(Exception):
        """ Transaction Faile """
    
    @backoff.on_exception(backoff.expo, OptimisticUpdateFailure, max_time=1)
    def increase_like_by_optimistic_lock(session):
        for _ in range(25):
            post = session.query(PostEntity).filter(PostEntity.pk == 1).one()
    
            result = session.query(PostEntity).filter(
                PostEntity.pk == post.pk,
                PostEntity.version == post.version
            ).update({
                PostEntity.like: PostEntity.like + 1,
                PostEntity.version: PostEntity.version + 1
            })
    
            if not bool(result):
                session.rollback()
                raise OptimisticUpdateFailure()
            else:
                session.commit()
                session.close()
    

    그러나 이는 개념상의 증명이다. 실제로 위 코드는 like의 결과가 50이 넘는 상황도 발생한다.

     

     

    마치며

    이로써 Pessmistic Lock과 Optimistic Lock에 대한 구현 방법을 알아봤다. Pessmistic Lock은 데이터의 무결성이 중요하지만 Optimistic Lock은 그렇지 못한 것임을 알 수 있다. 실제로 어떤 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://gist.github.com/jak010/e214c2968489d69d8a4b086f73b62bc0

     

    SQLALCHEMY pessimisic/optimistic lock

    SQLALCHEMY pessimisic/optimistic lock. GitHub Gist: instantly share code, notes, and snippets.

    gist.github.com

    https://m.yes24.com/Goods/Detail/101818336

     

    파이썬으로 살펴보는 아키텍처 패턴 - 예스24

    아키텍처 패턴의 기초부터 활용까지 다루는 실전 가이드파이썬을 활용한 프로젝트의 규모가 커지면서 육각형/클린 아키텍처, 이벤트 기반 아키텍처, 도메인 주도 설계 등 고수준 디자인 패턴에

    m.yes24.com

     

     

     


     

    728x90
    반응형