Rails는 다양한 상황에 맞는 편리한 도구들이 기본으로 내장되어 있는 대형 프레임워크입니다. 이 시리즈에서는 Rails의 방대한 코드베이스 속에 숨어 있는, 잘 알려지지 않은 도구들을 하나씩 살펴보고 있습니다.
이번 글에서는 ActiveRecord의 update_counters 메서드를 집중적으로 다뤄보겠습니다. 이 과정에서 멀티스레드 프로그램의 고질적인 함정인 경쟁 상태(race condition)가 무엇인지, 그리고 이 메서드가 어떻게 이러한 문제를 예방할 수 있는지 함께 알아보겠습니다.
스레드(Thread)
프로그래밍에서 코드를 병렬로 실행하는 방법은 여러 가지가 있습니다. 프로세스(process), 스레드(thread)가 대표적이고, 최근의 Ruby에서는 파이버(fiber)나 리액터(reactor) 같은 방식도 등장했습니다. 이 글에서는 Rails 개발자가 가장 자주 접하게 되는 스레드에 초점을 맞추겠습니다. 실제로 Puma는 멀티스레드 웹 서버이고, Sidekiq 역시 멀티스레드 백그라운드 잡 프로세서입니다.
여기서 스레드와 스레드 안전성(thread safety)을 깊이 파고들지는 않겠습니다. 꼭 기억해야 할 핵심은 단 하나입니다. 두 개의 스레드가 동시에 같은 데이터를 다루면 데이터가 쉽게 어긋날 수 있다는 것입니다. 이것이 바로 '경쟁 상태'입니다.
경쟁 상태(Race Condition)
경쟁 상태는 두 개 이상의 스레드가 동시에 같은 데이터를 조작할 때 발생합니다. 이때 특정 스레드는 이미 오래된(stale) 데이터를 사용하게 될 수 있습니다. 마치 여러 스레드가 서로 경주하는 것처럼 보인다고 해서 'race condition'이라는 이름이 붙었으며, 어떤 스레드가 '경주에서 이겼느냐'에 따라 데이터의 최종 상태가 달라질 수 있습니다.
더 골치 아픈 점은, 경쟁 상태는 스레드들이 특정한 순서로, 코드의 특정 지점에서 실행을 '교대'할 때만 발생하는 경우가 많아 재현 자체가 매우 어렵다는 사실입니다.
예제로 살펴보기
경쟁 상태를 설명할 때 가장 널리 쓰이는 시나리오는 바로 은행 잔액 업데이트입니다. 기본적인 Rails 애플리케이션에 간단한 테스트 클래스를 만들어 직접 확인해 보겠습니다.
class UnsafeTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
threads = []
4.times do
threads << Thread.new do
balance = account.reload.balance
account.update!(balance: balance + 100)
balance = account.reload.balance
account.update!(balance: balance - 100)
end
end
threads.map(&:join)
account.reload.balance
end
end
UnsafeTransaction 클래스는 매우 단순합니다. BigDecimal 타입의 balance 속성을 가진 표준적인 Rails 모델인 Account를 조회하고, 테스트를 반복 실행하기 쉽도록 잔액을 0으로 초기화합니다.
흥미로운 부분은 내부 루프입니다. 여기서는 4개의 스레드를 생성하고, 각 스레드는 계좌의 현재 잔액을 읽어온 뒤 100을 더하고(예: 10만 원 입금), 이어서 바로 100을 뺍니다(예: 10만 원 출금). 심지어 두 번 모두 reload를 호출해 최신 잔액을 확실히 가져오도록 했습니다.
나머지 코드는 정리 작업에 불과합니다. Thread.join은 모든 스레드가 종료될 때까지 기다린다는 뜻이며, 메서드 마지막에는 최종 잔액을 반환합니다.
이 코드를 단일 스레드로 실행한다면(루프를 1.times do로 바꾸면) 백만 번을 돌려도 최종 잔액이 항상 0임을 보장할 수 있습니다. 하지만 스레드를 두 개 이상으로 늘리는 순간 이야기가 달라집니다.
콘솔에서 한 번만 실행하면 아마 올바른 결과가 나올 겁니다.
UnsafeTransaction.run
=> 0.0
그런데 이 코드를 반복해서 실행하면 어떻게 될까요? 열 번 실행해 보겠습니다.
(1..10).map { UnsafeTransaction.run }.map(&:to_f)
=> [0.0, 300.0, 300.0, 100.0, 100.0, 100.0, 300.0, 300.0, 100.0, 300.0]
문법이 낯설다면 간단히 설명하겠습니다. (1..10).map {}은 블록 안의 코드를 10번 실행하고, 각 실행 결과를 배열에 담습니다. 마지막의 .map(&:to_f)는 BigDecimal 값이 보통 0.1e3처럼 지수 표기법으로 출력되기 때문에, 숫자를 사람이 읽기 쉽게 만들어 줄 뿐입니다.
다시 강조하지만, 우리 코드는 현재 잔액을 읽어 100을 더한 후 즉시 100을 뺍니다. 따라서 최종 결과는 반드시 0.0이어야 합니다. 그럼에도 100.0과 300.0이 등장했다는 것은 경쟁 상태가 실제로 발생했다는 명백한 증거입니다.
문제 지점 상세 분석
이제 문제가 되는 코드를 확대해서 무슨 일이 일어나는지 살펴보겠습니다. 잔액 변경 로직을 분리하면 더욱 명확해집니다.
threads << Thread.new do
# 이 시점에 스레드가 전환될 수 있음
balance = account.reload.balance
# 또는 여기서...
balance += 100
# 또는 여기서...
account.update!(balance: balance)
# 또는 여기서...
balance = account.reload.balance
# 또는 여기서...
balance -= 100
# 또는 여기서...
account.update!(balance: balance)
# 또는 여기서...
end
주석에서 보듯이, 스레드는 이 코드의 거의 모든 지점에서 서로 교체될 수 있습니다. 스레드 1이 잔액을 읽은 직후 컴퓨터가 스레드 2의 실행으로 전환하면, 스레드 1이 update!를 호출하는 시점에는 데이터가 이미 낡아 있을 가능성이 큽니다. 다르게 표현하면, 스레드 1, 스레드 2, 그리고 데이터베이스가 각각 데이터를 가지고 있지만 서로 동기화가 깨져 버리는 것입니다.
여기서 든 예제는 분석하기 쉽도록 일부러 단순하게 만든 것입니다. 실제 환경에서는 경쟁 상태를 진단하기가 훨씬 어렵습니다. 무엇보다 신뢰성 있게 재현할 수 없는 경우가 대부분이기 때문입니다.
해결 방법
경쟁 상태를 막는 방법은 여러 가지가 있지만, 거의 모든 방법이 하나의 아이디어를 중심으로 돌아갑니다. 특정 시점에는 오직 하나의 개체만 데이터를 변경하도록 보장하는 것입니다.
방법 1: 뮤텍스(Mutex)
가장 단순한 방법은 '상호 배제 잠금(mutual exclusion lock)', 흔히 말하는 뮤텍스(mutex)입니다. 뮤텍스는 키가 하나뿐인 자물쇠라고 생각하면 됩니다. 한 스레드가 키를 쥐고 있는 동안에는 해당 스레드만 뮤텍스 내부의 코드를 실행할 수 있고, 나머지 스레드는 키를 받을 때까지 기다려야 합니다.
앞선 예제 코드에 뮤텍스를 적용하면 다음과 같습니다.
class MutexTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
mutex = Mutex.new
threads = []
4.times do
threads << Thread.new do
mutex.lock
balance = account.reload.balance
account.update!(balance: balance + 100)
mutex.unlock
mutex.lock
balance = account.reload.balance
account.update!(balance: balance - 100)
mutex.unlock
end
end
threads.map(&:join)
account.reload.balance
end
end
account를 읽고 쓰는 매 순간 먼저 mutex.lock을 호출하고, 작업이 끝나면 mutex.unlock을 호출해 다른 스레드에게 차례를 넘겨줍니다. 물론 블록 시작 부분에서 mutex.lock, 끝 부분에서 mutex.unlock을 호출하는 방식도 가능합니다. 하지만 그렇게 하면 스레드가 더 이상 병렬로 실행되지 않아, 애초에 스레드를 사용하는 이유가 퇴색됩니다. 성능 관점에서는 뮤텍스 내부의 코드를 최소한으로 유지해, 스레드가 최대한 많은 코드를 병렬로 실행할 수 있도록 하는 것이 좋습니다.
여기서는 이해를 돕기 위해 .lock과 .unlock을 사용했지만, Ruby의 Mutex 클래스는 블록을 인자로 받아 이 과정을 자동으로 처리해 주는 synchronize 메서드를 제공합니다. 따라서 아래처럼 작성할 수도 있습니다.
mutex.synchronize do
balance = ...
...
end
Ruby의 뮤텍스만으로도 우리 목적은 충분히 달성됩니다. 하지만 짐작하시겠지만, Rails 애플리케이션에서는 특정 데이터베이스 행(row)을 잠가야 하는 경우가 훨씬 흔하며, ActiveRecord는 바로 이런 상황을 위한 기능을 갖추고 있습니다.
방법 2: ActiveRecord 잠금(Lock)
ActiveRecord는 몇 가지 잠금 메커니즘을 제공합니다. 여기서는 모든 것을 깊이 다루지 않고, 업데이트하려는 행을 잠글 때 사용하는 lock!만 살펴보겠습니다.
class LockedTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
threads = []
4.times do
threads << Thread.new do
Account.transaction do
account = account.reload
account.lock!
account.update!(balance: account.balance + 100)
end
Account.transaction do
account = account.reload
account.lock!
account.update!(balance: account.balance - 100)
end
end
end
threads.map(&:join)
account.reload.balance
end
end
뮤텍스가 특정 스레드를 위해 코드 구간을 잠근다면, lock!은 데이터베이스의 특정 행을 잠급니다. 덕분에 동일한 코드를 여러 계정에 대해(예: 여러 백그라운드 잡에서) 동시에 병렬 실행할 수 있고, 같은 레코드에 접근해야 하는 스레드만 대기하게 됩니다.
ActiveRecord는 트랜잭션과 잠금을 한 번에 처리할 수 있는 편리한 #with_lock 메서드도 제공합니다. 위의 업데이트 코드를 이를 활용해 더 간결하게 작성하면 다음과 같습니다.
account = account.reload
account.with_lock do
account.update!(account.balance + 100)
end
...
방법 3: 원자적(Atomic) 메서드
'원자적(atomic)' 메서드란 실행 도중에 중단될 수 없는 메서드를 의미합니다. 예를 들어 Ruby에서 흔히 쓰이는 += 연산은 한 번의 연산처럼 보이지만 사실 원자적이지 않습니다.
value += 10
# 실제로는 다음과 동일:
value = value + 10
# 더 자세히 풀면:
temp_value = value + 10
value = temp_value
스레드가 value + 10을 계산한 뒤 그 결과를 value에 다시 쓰기 전에 갑자기 '잠들면(sleep)' 경쟁 상태가 발생할 여지가 생깁니다. 반대로 만약 이 연산 도중 스레드가 절대 잠들지 않는다면, 즉 컴퓨터가 절대 다른 스레드로 실행을 전환하지 않는다고 확신할 수 있다면, 이 연산을 '원자적'이라고 부를 수 있을 것입니다.
일부 언어는 바로 이런 종류의 스레드 안전성을 위해 원시 값의 원자적 버전을 제공합니다(예: AtomicInteger, AtomicFloat). 하지만 Rails 개발자에게도 몇 가지 '원자적' 연산이 존재합니다. 그 대표적인 예가 바로 ActiveRecord의 update_counters 메서드입니다.
이 메서드는 원래 카운터 캐시(counter cache)를 최신 상태로 유지하기 위한 용도이지만, 애플리케이션에서 자유롭게 활용하는 데 아무런 제약이 없습니다. 카운터 캐시에 대한 자세한 내용은 이전에 작성한 캐싱 관련 글을 참고하시기 바랍니다.
사용법은 놀랄 만큼 간단합니다.
class CounterTransaction
def self.run
account = Account.find(1)
account.update!(balance: 0)
threads = []
4.times do
threads << Thread.new do
Account.update_counters(account.id, balance: 100)
Account.update_counters(account.id, balance: -100)
end
end
threads.map(&:join)
account.reload.balance
end
end
뮤텍스도 없고, 잠금도 없습니다. 단 두 줄의 Ruby 코드뿐입니다. update_counters는 첫 번째 인자로 레코드 ID를 받고, 이어서 어떤 컬럼을(balance:) 얼마나 변경할지(100 또는 -100) 지정합니다.
이 방법이 동작하는 이유는, 읽기-업데이트-쓰기(read-update-write) 사이클이 이제 데이터베이스 내부에서 단일 SQL 호출로 처리되기 때문입니다. 따라서 Ruby 스레드가 이 연산을 중간에 끊을 수 없습니다. 스레드가 잠들더라도 전혀 문제가 되지 않는데, 실제 계산은 데이터베이스가 수행하기 때문입니다.
실제로 생성되는 SQL은 다음과 같습니다(필자의 머신 기준 PostgreSQL).
Account Update All (1.7ms) UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2 [["balance", "100.0"], ["id", 1]]
이 방식은 성능 면에서도 훨씬 뛰어납니다. 계산이 전부 데이터베이스에서 이뤄지므로 최신 값을 얻기 위해 레코드를 reload할 필요가 없기 때문입니다. 다만 이 속도에는 대가가 따릅니다. Raw SQL로 처리하는 만큼 Rails 모델을 우회하게 되어, 유효성 검증(validation)이나 콜백(callback)이 실행되지 않습니다. 그 결과 updated_at 타임스탬프조차 갱신되지 않습니다.
마치며
경쟁 상태는 하이젠버그(Heisenbug)의 대표 주제라고 해도 과언이 아닙니다. 쉽게 코드에 스며들고, 재현이 거의 불가능하며, 미리 예측하기도 어렵습니다. 다행히 Ruby와 Rails는 이런 문제를 발견한 뒤 해결하는 데 도움이 되는 유용한 도구들을 제공합니다.
일반적인 Ruby 코드에서는 Mutex가 좋은 선택지입니다. '스레드 안전성'이라는 말을 들으면 대부분의 개발자가 가장 먼저 떠올리는 도구이기도 합니다.
Rails에서는 데이터가 대부분 ActiveRecord를 통해 다뤄집니다. 이런 경우 lock!(또는 with_lock)이 사용하기 간편할 뿐 아니라, 데이터베이스의 관련 행만 잠그기 때문에 뮤텍스보다 더 높은 처리량(throughput)을 제공합니다.
솔직히 말하면, 필자도 실무에서 update_counters를 자주 찾을지는 확신하지 못합니다. 다른 개발자가 이 메서드의 동작 방식에 익숙하지 않을 수 있고, 코드의 의도가 특별히 명확하게 드러나지도 않기 때문입니다. 스레드 안전성 문제에 직면했다면 ActiveRecord의 잠금(lock! 또는 with_lock)이 더 널리 쓰이면서도 개발자의 의도를 더 분명하게 전달합니다.
하지만 단순한 '증가 또는 감소' 작업이 대량으로 밀려 있고, 극한의 처리 속도가 필요한 상황이라면 update_counters는 툴박스 속에 꼭 넣어둘 만한 강력한 무기가 될 것입니다.