웹 애플리케이션에서 SELECT FOR UPDATE로 레이스 컨디션 방지하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
매우 빠르게 변화하는 웹 애플리케이션 환경에서는 여러 사용자가 종종 동일한 데이터에 동시에 접근합니다. 마치 전자상거래 사이트에서 두 명의 고객이 마지막 남은 상품을 구매하려 하거나, 은행 애플리케이션에서 두 번의 송금이 동일한 계좌에서 출금을 시도하는 상황을 상상해 보세요. 적절한 안전 장치 없이는 이러한 동시 작업이 재고가 잘못 계산되거나, 이중 예약이 발생하거나, 금융 기록이 손상되는 등의 바람직하지 않은 결과를 초래할 수 있습니다. '데이터 경합' 또는 '레이스 컨디션'으로 알려진 이 현상은 여러 스레드 또는 프로세스에 의한 작업의 타이밍이나 상호 작용 방식이 계산의 정확성에 영향을 미칠 때 발생합니다. 이러한 환경에서 데이터 일관성과 무결성을 보장하는 것이 매우 중요합니다. 이 글에서는 강력한 데이터베이스 메커니즘인 SELECT ... FOR UPDATE에 대해 자세히 알아보고, 웹 애플리케이션에서 이러한 동시성 문제를 어떻게 효과적으로 방지하고 안정적인 데이터 트랜잭션을 보장하는지 설명합니다.
동시성 제어 이해하기
SELECT ... FOR UPDATE에 대해 자세히 알아보기 전에 몇 가지 핵심적인 데이터베이스 개념을 이해하는 것이 중요합니다.
- 동시성 제어: 여러 트랜잭션이 서로 방해하지 않고 데이터베이스의 무결성을 손상시키지 않으면서 동시에 실행될 수 있도록 보장하는 메커니즘 모음입니다.
- 트랜잭션: 데이터베이스에 접근하고 잠재적으로 수정하는 단일 논리적 작업 단위입니다. 트랜잭션은 원자성(Atomicity), 일관성(Consistency), 고립성(Isolation), 지속성(Durability)이라는 ACID 속성을 갖습니다.
- 고립 수준(Isolation Levels): 한 작업에서 이루어진 변경 사항이 다른 작업에 어떻게, 언제 보이는지를 정의합니다. 일반적인 수준에는 읽기 미커밋(Read Uncommitted), 읽기 커밋(Read Committed), 반복 가능한 읽기(Repeatable Read), 직렬화 가능(Serializable)이 있습니다. 낮은 고립 수준은 더 높은 동시성을 제공하지만 데이터 무결성 보장은 낮아지고, 그 반대도 마찬가지입니다.
- 잠금(Locking): 데이터베이스에서 공유 리소스(행 또는 테이블 등)에 대한 접근을 제어하는 데 사용되는 메커니즘입니다. 리소스가 잠겨 있으면 다른 트랜잭션은 잠금이 해제될 때까지 해당 리소스에 접근하거나 수정하는 것이 방지됩니다.
- 데이터 경합 / 레이스 컨디션: 계산 결과가 이벤트의 비결정적인 상대적 타이밍에 따라 달라져 종종 부정확한 결과를 초래하는 상황입니다.
- 더티 읽기(Dirty Read): 트랜잭션이 아직 커밋되지 않은 (따라서 롤백될 수 있는) 다른 트랜잭션에 의해 작성된 데이터를 읽는 것입니다.
- 손실 업데이트(Lost Update): 두 트랜잭션이 동일한 데이터를 읽고, 둘 다 수정합니다. 한 트랜잭션의 업데이트가 다른 트랜잭션의 업데이트를 덮어쓰면서 첫 번째 업데이트가 효과적으로 '손실'됩니다.
SELECT ... FOR UPDATE는 주로 손실 업데이트 문제를 다루며, 명시적으로 잠금을 획득함으로써 읽기 커밋(Read Committed) 또는 반복 가능한 읽기(Repeatable Read) 수준에서 더 강력한 고립 보장을 달성하는 데 도움이 됩니다.
SELECT FOR UPDATE 작동 방식
SELECT ... FOR UPDATE는 SQL 절로, SELECT 문에 추가될 때 검색된 행에 대해 배타적(쓰기) 잠금을 획득합니다. 이는 다음을 의미합니다.
- 다른 트랜잭션은 현재 트랜잭션이 커밋하거나 롤백될 때까지 이 잠긴 행을 수정할 수 없습니다.
- 동일한 행에 대한 다른
SELECT ... FOR UPDATE문은 현재 트랜잭션이 잠금을 해제할 때까지 차단됩니다. - 정상적인
SELECT문 (FOR UPDATE제외)은 데이터베이스의 고립 수준에 따라 잠긴 행을 계속 읽을 수 있습니다. 그러나 고립 수준이반복 가능한 읽기또는직렬화 가능인 경우, 일반SELECT도 차단되거나 일관된 스냅샷을 볼 수 있습니다.
이 잠금 메커니즘은 트랜잭션이 수정하려는 의도로 데이터를 읽으면 다른 트랜잭션이 동일한 데이터를 동시에 수정할 수 없도록 보장함으로써 손실 업데이트를 방지합니다.
웹 애플리케이션에서의 실질적 적용
사용자가 상품을 구매하려는 전자상거래 시나리오를 생각해 봅시다. 애플리케이션은 재고를 확인하고, 재고를 줄이며, 주문을 생성해야 합니다.
SELECT FOR UPDATE 없이 (잠재적 레이스 컨디션):
Alice와 Bob 두 사용자가 동시에 마지막 남은 Product A를 구매하려고 시도한다고 가정해 봅시다.
| 시간 | Alice의 트랜잭션 | Bob의 트랜잭션 | Product A 재고 |
|---|---|---|---|
| T1 | SELECT stock FROM products WHERE id = 1; (결과: 1) | 1 | |
| T2 | SELECT stock FROM products WHERE id = 1; (결과: 1) | 1 | |
| T3 | UPDATE products SET stock = 0 WHERE id = 1; | 0 | |
| T4 | COMMIT; | 0 | |
| T5 | UPDATE products SET stock = 0 WHERE id = 1; | 0 | |
| T6 | COMMIT; | 0 |
이 시나리오에서 Alice와 Bob은 모두 상품을 "성공적으로" 구매했지만, 재고는 한 번만 차감되었습니다. 이것은 전형적인 손실 업데이트 문제입니다.
SELECT FOR UPDATE 사용 (레이스 컨디션 방지):
이제 구매 워크플로에 SELECT ... FOR UPDATE를 통합해 보겠습니다.
-- Alice의 트랜잭션 START TRANSACTION; SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- // Alice의 애플리케이션 로직이 stock > 0임을 확인합니다. -- // ... UPDATE products SET stock = stock - 1 WHERE id = 1; INSERT INTO orders (product_id, user_id, quantity) VALUES (1, 'Alice', 1); COMMIT; -- Bob의 트랜잭션 START TRANSACTION; SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 이 SELECT 문은 Alice의 트랜잭션이 커밋되거나 롤백될 때까지 차단됩니다. -- Alice가 커밋하면 Bob의 SELECT가 실행됩니다. -- 만약 stock이 0이면, Bob의 애플리케이션 로직은 재고가 없음을 인지할 것입니다. -- // Bob의 애플리케이션 로직이 stock > 0임을 확인합니다. (Alice가 구매하지 않았다면 가능할 것입니다.) -- // ... -- 만약 stock이 0이라면, Bob의 트랜잭션은 롤백될 수 있거나 상품이 없다는 것을 표시할 것입니다. -- ... -- UPDATE products SET stock = stock - 1 WHERE id = 1; -- INSERT INTO orders (product_id, user_id, quantity) VALUES (1, 'Bob', 1); -- COMMIT;
두 사용자로 다시 추적해 보겠습니다.
| 시간 | Alice의 트랜잭션 | Bob의 트랜잭션 | Product A 재고 | Product A 잠금 |
|---|---|---|---|---|
| T1 | START TRANSACTION; | 1 | ||
| T2 | SELECT stock FROM products WHERE id = 1 FOR UPDATE; (결과: 1) | 1 | Alice (배타적) | |
| T3 | START TRANSACTION; | 1 | Alice (배타적) | |
| T4 | SELECT stock FROM products WHERE id = 1 FOR UPDATE; | 1 | Alice (배타적), Bob 차단 | |
| T5 | UPDATE products SET stock = 0 WHERE id = 1; | 0 | Alice (배타적), Bob 차단 | |
| T6 | INSERT INTO orders ...; | 0 | Alice (배타적), Bob 차단 | |
| T7 | COMMIT; | 0 | 잠금 해제 | |
| T8 | Bob의 SELECT 차단 해제 및 stock = 0 반환 | 0 | Bob (배타적) | |
| T9 | // Bob의 로직이 stock이 0임을 확인하고 구매를 중단합니다 // ROLLBACK; (또는 유사 처리) | 0 | 잠금 해제 |
이 업데이트된 시나리오에서는 Alice가 Product A를 잠그면, Bob이 동일한 행에 대한 잠금을 시도하는 것이 차단됩니다. Alice가 커밋하고 잠금을 해제하면, Bob의 SELECT ... FOR UPDATE가 진행됩니다. 이때 Bob의 쿼리는 업데이트된 재고(0)를 보고 상품이 더 이상 없음을 올바르게 표시합니다. 이는 재고가 음수가 되거나 존재하지 않는 상품에 대한 주문이 생성되는 것을 방지합니다.
구현 시 고려 사항
- 트랜잭션으로 묶기:
SELECT ... FOR UPDATE는 데이터베이스 트랜잭션 내에서 사용될 때만 효과적입니다. 잠금은 트랜잭션이 커밋되거나 롤백될 때까지 유지됩니다. - 성능 영향: 행 잠금은 경쟁 상태와 동시성을 감소시킬 수 있습니다. 많은 트랜잭션이
FOR UPDATE를 사용하여 동일한 행에 자주 접근하면 성능 병목 현상이 발생할 수 있습니다. 중요한 리소스에 대해서는 신중하게 사용해야 합니다. - 교착 상태(Deadlocks): 두 트랜잭션이 다른 순서로 리소스에 대한 잠금을 획득하려고 하면 교착 상태에 빠질 수 있습니다. 최신 데이터베이스 시스템은 일반적으로 교착 상태 감지 및 해결 메커니즘(예: 트랜잭션 중 하나를 롤백)을 가지고 있지만, 교착 상태 발생을 최소화하기 위해 트랜잭션 로직을 신중하게 설계하는 것이 중요합니다.
- 데이터베이스 방언: 정확한 구문과 동작은 데이터베이스 시스템(예: PostgreSQL, MySQL, Oracle)마다 약간씩 다를 수 있습니다. 예를 들어, PostgreSQL은 더 세분화된 제어를 위해
FOR SHARE(공유 잠금),FOR NO KEY UPDATE,FOR SHARE SKIP LOCKED,FOR UPDATE NOWAIT를 제공합니다. MySQL의InnoDB엔진도 유사한 기능을 제공합니다.
# 전자상거래 시나리오를 위한 SQLAlchemy ORM (Python) 예제 from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base # 데이터베이스 설정 engine = create_engine('sqlite:///:memory:', echo=True) Base = declarative_base() class Product(Base): __tablename__ = 'products' id = Column(Integer, primary_key=True) name = Column(String, unique=True, nullable=False) stock = Column(Integer, nullable=False, default=0) def __repr__(self): return f"<Product(id={self.id}, name='{self.name}', stock={self.stock})>" Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) # 데이터베이스 채우기 session = Session() session.add(Product(name='Example Widget', stock=1)) session.commit() session.close() def purchase_product(product_id: int): session = Session() try: with session.begin(): # 트랜잭션 시작 # with_for_update()를 사용하여 FOR UPDATE 추가 product_to_purchase = session.query(Product).filter_by(id=product_id).with_for_update().one() print(f"Purchasing product {product_to_purchase.name} with current stock: {product_to_purchase.stock}") if product_to_purchase.stock > 0: product_to_purchase.stock -= 1 # 실제 앱에서는 여기서 주문 레코드도 생성합니다 print(f"Successfully purchased. New stock: {product_to_purchase.stock}") # session.commit()은 `session.begin()` 컨텍스트 관리자로 자동 수행됩니다. return True else: print(f"Product {product_to_purchase.name} is out of stock.") # session.rollback()은 예외 발생 시 자동 수행됩니다. return False except Exception as e: print(f"An error occurred during purchase: {e}") session.rollback() return False finally: session.close() # 동시 요청 시뮬레이션 import threading results = [] threads = [] for i in range(2): # Alice와 Bob thread = threading.Thread(target=lambda: results.append(purchase_product(1))) threads.append(thread) thread.start() for thread in threads: thread.join() print(f"\nFinal stock of product ID 1: {Session().query(Product).filter_by(id=1).one().stock}") print(f"Purchase results: {results}") # 예상 출력 (스레드 스케줄링으로 인해 출력 순서가 달라질 수 있지만, 정확성은 보장됩니다): # Purchasing product Example Widget with current stock: 1 # Successfully purchased. New stock: 0 # Purchasing product Example Widget with current stock: 0 (이 스레드는 첫 번째 스레드를 기다렸습니다) # Product Example Widget is out of stock. # # Final stock of product ID 1: 0 # Purchase results: [True, False]
Python 예제에서 session.query(Product).filter_by(id=product_id).with_for_update().one()는 SELECT ... FOR UPDATE의 SQLAlchemy 등가물입니다. 첫 번째 스레드가 이를 실행하면 쓰기 잠금을 획득합니다. 동일한 작업을 시도하는 두 번째 스레드는 첫 번째 스레드의 트랜잭션이 커밋되거나 롤백될 때까지 차단됩니다. 이는 하나의 구매만이 재고를 0으로 성공적으로 차감하고 레이스 컨디션을 방지하도록 보장합니다.
결론
SELECT ... FOR UPDATE는 동시 웹 애플리케이션에서 데이터 일관성을 유지하는 데 필수적인 도구입니다. 수정하려는 행에 대한 배타적 잠금을 획득함으로써, 손실 업데이트와 같은 데이터 경합을 효과적으로 방지하여 재고 관리 또는 금융 거래와 같은 중요한 작업이 신뢰성을 유지하도록 보장합니다. 잠재적인 성능 고려 사항과 교착 상태 위험을 도입하지만, 적절하게 설계된 트랜잭션 내에서 신중하게 적용하는 것은 강력하고 신뢰할 수 있는 웹 서비스를 구축하는 데 필수적입니다. SELECT ... FOR UPDATE를 사용하는 것은 데이터 무결성이 협상 불가능한 확장 가능하고 탄력적인 애플리케이션을 구축하기 위한 근본적인 단계입니다.

