3 minute read

두 트랜잭션이 동일한 데이터를 건드리지 않는다면, 어느 트랜잭션도 다른 트랜잭션에 의존하지 않기 때문에 안전하게 병렬로 실행할 수 있다.

동시성 문제는 한 트랜잭션이 다른 트랜잭션이 동시에 수정한 데이터를 읽거나 두 트랜잭션이 동일한 데이터를 동시에 수정하려고 할 때만 발생한다.

동시성 문제가 어려운 이유

동시성 버그는 타이밍을 잘못 맞출 때만 발생하므로 테스트를 통해 찾기가 어렵다. 이러한 타이밍 문제는 매우 드물게 발생할 수 있으며 일반적으로 재현하기 어렵다. 특히, 데이터베이스에 액세스하는 다른 코드가 무엇인지 알 수 없는 대규모 애플리케이션에서는 동시성을 추론하기는 더욱 어렵다. 한 번에 한 명의 사용자만 있어도 애플리케이션 개발은 충분히 어렵지만, 동시 사용자가 많으면 데이터의 일부가 언제든 예기치 않게 변경될 수 있기 때문이다.

동시성 문제를 숨기려는 시도들

데이터베이스는 트랜잭션 격리 기능을 제공하여 애플리케이션 개발자로부터 동시성 문제를 숨기려고 오랫동안 노력해 왔다. 이론적으로 격리 기능은 동시성이 발생하지 않는 것처럼 보이게 함으로써 사용자 경험을 향상한다. 직렬화 가능(Serializabilty) 격리란 데이터베이스가 트랜잭션이 직렬로 실행되는 것처럼 한 번에 하나의 연산만 수행하도록 한다는 것이다.

실제로 격리를 구현하는 것은 간단하지 않다. 직렬화 가능한 격리에는 성능당 비용이 발생하며, 많은 데이터베이스가 그 비용을 지불하고 싶어하지 않다. 따라서 시스템에서 더 약한 수준의 격리를 사용하는 것이 일반적이며, 이는 일부 동시성 문제를 방지하지만 모든 문제를 방지하지는 못한다. 이러한 격리 수준은 견디기가 훨씬 어렵고 미묘한 버그를 유발할 수 있지만, 그럼에도 불구하고 실제로 사용된다.

도구에 맹목적으로 의존하기보다는 존재하는 동시성 문제의 종류와 이를 방지하는 방법을 잘 이해해야 한다. 그래야만 원하는 도구를 사용하여 안정적이고 올바른 애플리케이션을 구축할 수 있다.

커밋 읽기 Read commited

가장 기본적인 트랜잭션 격리 수준은 읽기 커밋이다.

커밋 읽기는 두가지를 보장한다.

  1. 데이터베이스에서 읽을 때 커밋된 데이터만 볼 수 있다. Dirty Read가 없다.
  2. 데이터베이스에 쓸 때는 커밋된 데이터만 덮어쓴다. Dirty Write가 없다.

더티 읽기(Dirty Read)

트랜잭션이 데이터베이스에 일부 데이터를 썼지만 트랜잭션이 아직 커밋되거나 중단되지 않았다고 가정해 보자. 다른 트랜잭션이 커밋되지 않은 데이터를 볼 수 있는가? 그렇다면 이를 더티 읽기라고 한다.

더티 읽기를 방지해야 하는 이유

커밋되지 않은 상태의 데이터베이스를 보는 것은 사용자에게 혼란을 주고 다른 트랜잭션이 잘못된 결정을 내릴 수 있다. 트랜잭션이 중단되면 해당 트랜잭션이 수행한 모든 쓰기를 롤백해야 한다. 데이터베이스에서 더티 읽기를 허용하는 경우, 트랜잭션이 나중에 롤백된 데이터, 즉 실제로 데이터베이스에 커밋되지 않은 데이터를 볼 수 있다는 의미다.

더티 쓰기(Dirty Write)

데이터베이스에서 동일한 개체를 두 개의 트랜잭션이 동시에 업데이트하려고 하면 어떻게 될까? 어떤 순서로 쓰기가 진행될지는 알 수 없지만, 일반적으로 나중에 쓰면 이전 쓰기를 덮어쓴다고 가정한다.

하지만 이전 쓰기가 아직 커밋되지 않은 트랜잭션의 일부여서 나중에 쓰기가 커밋되지 않은 값을 덮어쓴다면 어떻게 될까? 이를 더티 쓰기라고 한다. 읽기 커밋 격리 수준에서 실행되는 트랜잭션은 일반적으로 첫 번째 쓰기 트랜잭션이 커밋되거나 중단될 때까지 두 번째 쓰기를 지연시킴으로써 더티 쓰기를 방지해야 한다.

읽기 커밋 구현하기

읽기 커밋은 매우 널리 사용되는 격리 수준이다. Oracle 11g, PostgreSQL, SQL Server 2012, MemSQL 및 기타 여러 데이터베이스의 기본 설정이다.

가장 일반적으로 데이터베이스는 행 수준 잠금을 사용하여 더티 쓰기를 방지한다. 트랜잭션이 특정 객체(행 또는 문서)를 수정하려면 먼저 해당 객체에 대한 잠금을 획득해야 한다. 그런 다음 트랜잭션이 커밋되거나 중단될 때까지 해당 잠금을 유지해야 한다. 하나의 트랜잭션만 특정 개체에 대한 잠금을 보유할 수 있으며, 다른 트랜잭션이 동일한 개체에 쓰기를 원할 경우 첫 번째 트랜잭션이 커밋되거나 중단될 때까지 기다려야 잠금을 획득하고 계속할 수 있다.

더티 읽기를 막으려면 어떻게 구현해야 할까?

더티 읽기를 방지하는 한 가지 방법은 동일한 잠금을 사용하고, 객체를 읽으려는 모든 트랜잭션이 잠금을 잠시 획득했다가 읽기 직후 다시 해제하도록 하는 것이다. 이렇게 하면 오브젝트에 커밋되지 않은 더티 값이 있는 동안에는 읽기가 발생할 수 없다.

그러나 읽기 잠금을 요구하는 접근 방식은 실제로는 잘 작동하지 않는다. 하나의 쓰기 트랜잭션으로 인해 많은 읽기 전용 트랜잭션이 긴 트랜잭션이 완료될 때까지 대기해야 할 수 있기 때문이다.

이러한 이유로 대부분의 데이터베이스는 다음의 접근 방식을 사용하여 더티 읽기를 방지한다.

  • 데이터베이스는 쓰여지는 모든 객체에 대해 이전에 커밋된 값과 현재 쓰기 잠금을 보유한 트랜잭션에 의해 설정된 새로운 값을 모두 기억한다.
  • 트랜잭션이 진행되는 동안 해당 오브젝트를 읽는 다른 트랜잭션은 이전 값을 그대로 사용한다. 새 값이 커밋될 때만 트랜잭션이 새 값을 읽도록 전환된다.