MongoDB에서 UPDATE...RETURNING: findOneAndUpdate()를 활용한 ACID 및 멱등성

발행: (2026년 1월 5일 오전 03:52 GMT+9)
17 min read
원문: Dev.to

Source: Dev.to

ACID와 멱등성 업데이트 반환 (MongoDB findOneAndUpdate 사용)

MongoDB는 ACID 트랜잭션을 지원하지만, 단일 문서에 대한 원자적 업데이트를 수행할 때는 findOneAndUpdate와 같은 메서드를 활용하는 것이 일반적입니다. 이 글에서는

  • 단일 문서 업데이트를 원자적으로 수행하는 방법
  • 업데이트를 멱등성(idempotent) 으로 만들기 위한 패턴
  • 업데이트 후 변경된 문서를 반환받는 방법

을 단계별로 살펴보겠습니다.


1️⃣ 왜 findOneAndUpdate인가?

  • 원자성: 하나의 명령어 안에서 검색수정반환이 모두 이루어집니다.
  • 트랜잭션 오버헤드가 없음: 별도의 세션을 열 필요가 없으므로 성능이 좋습니다.
  • 옵션을 통해 업서트(upsert), 새 문서 반환, 필드 제한 등을 손쉽게 지정할 수 있습니다.

2️⃣ 멱등성(idempotent) 업데이트란?

멱등성 업데이트는 같은 요청을 여러 번 수행해도 결과가 동일하게 유지되는 업데이트를 의미합니다.
예를 들어, counter = counter + 1 같은 연산은 멱등성이 아니지만, counter = max(counter, X) 혹은 버전 번호(optimistic lock)를 활용하면 멱등성을 확보할 수 있습니다.

일반적인 멱등성 패턴

// 버전 번호를 이용한 멱등성 업데이트
db.collection.findOneAndUpdate(
  { _id: id, version: expectedVersion },   // 조건에 version 포함
  {
    $set: { field: newValue },
    $inc: { version: 1 }                  // 버전 증가
  },
  { returnDocument: "after" }
);
  • 조건에 현재 version 값을 포함시켜, 이미 업데이트된 경우에는 매치되지 않게 합니다.
  • 업데이트가 성공하면 version이 증가하고, 실패하면 클라이언트가 재시도하거나 오류를 처리합니다.

3️⃣ 업데이트 후 새 문서 반환하기

findOneAndUpdate는 기본적으로 업데이트 전 문서를 반환합니다. 최신 버전(4.2 이상)에서는 returnDocument 옵션을 사용해 업데이트 후 문서를 받을 수 있습니다.

db.collection.findOneAndUpdate(
  { _id: id },
  { $set: { status: "processed" } },
  {
    returnDocument: "after",   // "before"가 기본값
    upsert: false
  }
);

Tip: returnNewDocument: true는 오래된 드라이버에서 사용되던 옵션이며, 최신 드라이버에서는 returnDocument: "after" 로 대체됩니다.


4️⃣ 실전 예제: 주문 상태 전환

아래 예제는 주문(Order) 컬렉션에서 상태(state)pending → paid → shipped 로 순차적으로 전환하면서, 동시에 버전을 관리해 멱등성을 보장합니다.

// 1️⃣ 현재 상태와 버전을 확인하고, 상태를 "paid" 로 바꾸기
const result = db.orders.findOneAndUpdate(
  {
    _id: orderId,
    state: "pending",
    version: expectedVersion   // 클라이언트가 마지막으로 본 버전
  },
  {
    $set: { state: "paid", paidAt: new Date() },
    $inc: { version: 1 }
  },
  {
    returnDocument: "after"
  }
);

if (!result.value) {
  // 매치되지 않음 → 이미 처리됐거나 버전 불일치
  throw new Error("Concurrent update detected or invalid state");
}

// 2️⃣ 이후 단계(예: "shipped")도 동일한 패턴으로 진행

주요 포인트

포인트설명
조건에 version 포함동시성 충돌을 방지하고 멱등성을 확보
$inc 로 version 증가다음 요청이 올바른 버전을 기대하도록 함
returnDocument: "after"최신 상태를 바로 클라이언트에 반환
에러 처리result.valuenull이면 충돌 또는 잘못된 상태 → 재시도 또는 오류 반환

5️⃣ 트랜잭션과의 차이점

항목findOneAndUpdate (단일 문서)다문서 트랜잭션
범위하나의 문서에 한정여러 컬렉션·문서
성능매우 빠름 (단일 네트워크 라운드‑트립)라운드‑트립·락·커밋 오버헤드
복구자동 롤백 (업데이트가 실패하면 적용 안 됨)커밋 전까지 모든 변경이 보류
복잡도간단한 옵션만 지정세션·트랜잭션 시작·커밋 필요

단일 문서 업데이트라면 findOneAndUpdate 로 충분히 ACID 보장을 받을 수 있습니다. 복수 문서에 걸친 복잡한 비즈니스 로직이 필요할 때만 트랜잭션을 고려하세요.


6️⃣ 마무리

  • 원자적 업데이트 → findOneAndUpdate 로 한 번에 처리
  • 멱등성 확보 → 버전(optimistic lock) 혹은 max, setOnInsert 등 활용
  • 업데이트 후 최신 문서 반환 → returnDocument: "after" 옵션 사용

이 패턴을 적용하면 동시성 문제를 최소화하면서 읽기‑쓰기 일관성을 보장할 수 있습니다. 여러분의 서비스에 맞는 버전 관리 전략을 선택하고, 필요에 따라 트랜잭션과 조합해 보세요. Happy coding!

단일 호출에서 원자적 읽기‑쓰기 작업

명시적인 트랜잭션 대신 단일 호출로 원자적 읽기‑쓰기 작업을 구현하면 라운드 트립 수와 동시 충돌 가능성을 모두 줄일 수 있습니다.

  • PostgreSQLSELECT FOR UPDATEUPDATE 대신 UPDATE … RETURNING을 사용합니다.
  • MongoDBupdateOne()find() 대신 findOneAndUpdate() 를 사용합니다.

이렇게 하면 MongoDB에서 멱등성을 갖기 때문에 실패에 강인하고 안전하게 재시도할 수 있는 단일 ACID 읽기‑쓰기 작업을 수행할 수 있습니다.

데모: 두 번의 인출과 영수증

시나리오 – Bob은 직불카드(음수 잔액 허용 안 함)를 사용해 인출합니다. 애플리케이션은 먼저 updateOne을 호출해 잔액이 충분할 경우에만 금액을 차감하고, 그 다음 별도의 find()를 호출해 영수증에 잔액을 출력합니다. 이 두 호출 사이에 Alice가 신용카드(음수 잔액 허용)를 사용해 같은 계좌에서 돈을 인출하면, Bob이 출력한 잔액이 인출 시 확인한 잔액과 일치하지 않게 됩니다.

해결 방법findOneAndUpdate()를 사용해 인출과 동시에 업데이트된 잔액을 원자적으로 반환합니다.

연결 및 컬렉션

from pymongo import MongoClient, ReturnDocument
import threading
import time

# Connect to MongoDB
client = MongoClient("mongodb://127.0.0.1:27017/?directConnection=true")
db = client.bank
accounts = db.accounts

# Prepare test account: Bob & Alice share this account
def reset_account():
    accounts.drop()
    accounts.insert_one({
        "_id": "b0b4l3ce",
        "owner": ["Bob", "Alice"],
        "balance": 100
    })

reset_account()

문서 보기

bank> db.accounts.find()
[ { _id: 'b0b4l3ce', owner: [ 'Bob', 'Alice' ], balance: 100 } ]

잔액은 100으로 설정됩니다.

updateOne()find() 시나리오

Bob의 인출 (직불)

def bob_withdraw_debit(amount):
    print("[Bob] Attempting debit withdrawal", amount)

    # Application logic checks balance then updates
    result = accounts.update_one(
        {"_id": "b0b4l3ce", "balance": {"$gte": amount}},   # must have enough money
        {"$inc": {"balance": -amount}}
    )

    # If no document was updated, the filter didn't find enough funds
    if result.modified_count == 0:
        print("[Bob] Withdrawal denied - insufficient funds")
        return

    # Simulate processing delay before printing the ticket
    time.sleep(1)

    # Query the balance for the receipt
    balance = accounts.find_one({"_id": "b0b4l3ce"})["balance"]
    print(f"[Bob] Debit card ticket: withdrew {amount}, balance after withdrawal: {balance}")

Alice의 인출 (신용)

def alice_withdraw_credit(amount, delay=0):
    time.sleep(delay)          # let Bob start first
    print("[Alice] Attempting credit withdrawal", amount)

    # No balance check for credit cards
    accounts.update_one(
        {"_id": "b0b4l3ce"},
        {"$inc": {"balance": -amount}}
    )
    print("[Alice] Credit withdrawal complete")

데모 스크립트 (경쟁 조건)

def demo():
    reset_account()
    t_bob = threading.Thread(target=bob_withdraw_debit, args=(80,))
    t_alice = threading.Thread(target=alice_withdraw_credit, args=(30, 0.5))  # starts just after Bob update
    t_bob.start()
    t_alice.start()
    t_bob.join()
    t_alice.join()

출력

>>> demo()
[Bob] Attempting debit withdrawal 80
[Alice] Attempting credit withdrawal 30
[Alice] Credit withdrawal complete
[Bob] Debit card ticket: withdrew 80, balance after withdrawal: -10

Bob은 직불카드에서 음수 잔액을 보여주는 티켓을 받게 됩니다 – 버그 ❌.

findOneAndUpdate() (returnDocument = AFTER) 시나리오

Bob의 인출 (직불)

def bob_withdraw_debit(amount):
    print("[Bob] Attempting debit withdrawal", amount)

    doc = accounts.find_one_and_update(
        {"_id": "b0b4l3ce", "balance": {"$gte": amount}},
        {"$inc": {"balance": -amount}},
        return_document=ReturnDocument.AFTER   # get post‑update document atomically
    )

    # No need to check the update count; we have the document if it was updated
    if not doc:
        print("[Bob] Withdrawal denied - insufficient funds")
        return

    # Ticket immediately shows consistent balance
    print(f"[Bob] Ticket: withdrew {amount}, balance after withdrawal: {doc['balance']}")

다시 실행한 데모

>>> demo()
[Bob] Attempting debit withdrawal 80
[Bob] Ticket: withdrew 80, balance after withdrawal: 20

awal: 20
[Alice] 신용 인출 시도 30
[Alice] 신용 인출 완료

Bob now receives a ticket showing the balance at the exact time of withdrawal ✅.  
The update write and post‑update read occurred as a single atomic operation on the document, leaving no opportunity for another write between the update and the displayed read result.

복원력

MongoDB에서는 읽기와 쓰기가 관계형 데이터베이스처럼 트랜잭션 잠금을 획득하지 않지만, 명시적인 트랜잭션을 시작하지 않아도 문서 업데이트는 문서 수준에서 원자적입니다. MongoDB는 단일 문서에 대한 ACID 보장을 위해 내부적으로 가벼운 문서‑레벨 잠금을 사용합니다. 이는 하나의 업데이트가 여러 내부 읽기·쓰기(예: 고유 제약 조건 적용 및 인덱스 업데이트)를 포함할 수 있기 때문입니다.

  • updateOne()는 메타데이터(예: 업데이트된 문서 수)만 반환합니다.
  • findOneAndUpdate()는 업데이트된 문서 자체를 반환하며, 읽기와 쓰기가 단일 문서 수준에서 같은 원자적 연산으로 수행됩니다. 이 원자성은 실패 상황에서도 유지됩니다.

네트워크가 끊기거나 기본(primary) 서버가 다운되고 보조(secondary) 서버가 승격되면, MongoDB 드라이버는 재시도 가능한 쓰기(retryable writes)의 일환으로 작업을 다시 시도합니다. 재시도는 멱등성을 요구하므로 findOneAndUpdate()는 재시도 시 동일한 문서 이미지를 반환합니다.

이를 지원하기 위해 MongoDB는 문서 이미지(예: after 이미지—이 예시에서는 returnDocument: "after" 사용—또는 before 이미지)를 내부 시스템 컬렉션(config.image_collection)에 저장합니다. 이 컬렉션은 oplog와 별도로 복제되며, 동일한 트랜잭션의 일부로 관리됩니다:

// Switch to the config database
use config

// View the image collection
db.image_collection.find()
[
  {
    _id: {
      id: UUID('d04e10d6-c61d-42ad-9a44-5bb226a898d8'),
      uid: Binary.createFromBase64('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 0)
    },
    txnNum: Long('15'),
    ts: Timestamp({ t: 1767478784, i: 5 }),
    imageKind: 'postImage',
    image: { _id: 'b0b4l3ce', owner: [ 'Bob', 'Alice' ], balance: 20 },
    invalidated: false
  }
]

쓰기 재시도를 활성화하면, 이 이미지 컬렉션이 내부적으로 사용되어 쓰기 작업을 실패에 강인하게 만듭니다. 이 처리는 애플리케이션에 투명하게 이루어지며 가장 강력한 일관성 보장을 제공합니다.

PostgreSQL과의 비교

PostgreSQL에서 동등한 작업은 다음과 같이 작성됩니다:

CREATE TABLE accounts (
    id TEXT PRIMARY KEY,
    balance NUMERIC,
    owners TEXT[]
);

INSERT INTO accounts VALUES ('b0b4l3ce', ARRAY['Bob','Alice'], 100);

-- Alice의 트랜잭션
UPDATE accounts
SET balance = balance - 30
WHERE id = 'shared_account';

-- Bob의 트랜잭션
UPDATE accounts
SET balance = balance - 80
WHERE id = 'b0b4l3ce' AND balance >= 80
RETURNING balance AS new_balance;

PostgreSQL 드라이버는 실패를 자동으로 재시도하지 않으며, MVCC와 잠금을 이용해 ACID 특성을 보장합니다.

  • Repeatable Read 격리 수준( SET balance = balance - 80 쓰기가 WHERE balance >= 80 읽기에 의존하므로 적절함):

    • Bob의 트랜잭션은 시작 시 스냅샷을 잡습니다. Alice와 동시에 실행되더라도 balance = 100을 보게 됩니다.

    • Alice가 먼저 커밋하여 잔액을 70으로 줄이면, Bob의 트랜잭션은 다음과 같은 오류와 함께 실패합니다:

      ERROR: could not serialize access due to concurrent update
    • 애플리케이션은 전체 트랜잭션을 재시도해야 하며, 드라이버가 이를 자동으로 수행하지는 않습니다.

  • Read Committed(기본) 격리 수준:

    • Bob의 트랜잭션은 Alice의 업데이트가 행을 잠그면 대기합니다.
    • Alice가 커밋한 뒤 PostgreSQL은 Bob의 WHERE 절을 다시 평가합니다. 잔액이 70이 되어 balance >= 80 조건을 만족하지 않으므로 UPDATE는 0행에 영향을 주고 인출이 거부됩니다. 이는 음수 잔액이 발생하지 않도록 방지합니다.
    • 하나의 행만 영향을 받는 경우에는 잘 동작하지만, 다중 행 문에서는 서로 다른 트랜잭션 상태의 행을 동시에 처리하게 되어 일관성이 깨질 수 있습니다.

결론

MongoDB는 다중‑문서 트랜잭션과 단일‑문서 원자적 작업을 모두 지원하지만, 가능한 경우 단일‑문서 작업을 강력히 권장합니다. 비즈니스 로직이 하나의 문서에 들어가도록 스키마를 설계하면 findOneAndUpdate()는:

  • 조건 검사를 수행하고,
  • 업데이트를 적용하며,
  • 업데이트된 문서를 원자적으로 반환할 수 있어,

경합 조건 이상을 방지하고 멱등 재시도를 가능하게 합니다.

일부 경우—예를 들어 앞서의 블로그 게시물 FOR UPDATE SKIP LOCKED in MongoDB—에서 설명한 것처럼—updateOne() 뒤에 적절한 조건을 가진 find()를 사용하는 것이 충분하며, 사전·사후 이미지 저장을 피할 수 있습니다.

Back to Blog

관련 글

더 보기 »