Firebird의 트랜잭션
Firebird의 트랜잭션: ACID, 격리 수준, 교착 상태 및 업데이트 충돌 해결
Alexey Kovyazin, Vlad Khorsun과 Dmitry Kuzmenko의 도움을 받아 작성, 2019년 4월 8일
목차:
트랜잭션이 어떻게 작동하는지 알아야 할 필요가 있을까?
아마도 트랜잭션의 개념이 단순하기 때문에 많은 개발자들이 Firebird에서 트랜잭션을 올바르게 사용하는 것의 중요성을 과소평가합니다. 그러나 트랜잭션이 어떻게 작동하는지 철저히 이해한 후에야 성능과 관련된 많은 신비한 현상들, 예를 들어 갑작스러운 데이터베이스 속도 저하(잘못된 트랜잭션 관리로 인해 나타나는 과도한 레코드 버전을 정리하는 스윕과 관련된)를 이해할 수 있습니다.
일반적으로 트랜잭션의 개념은 한 상태에서 다른 상태로 이동하는 모든 동적 시스템에 적용됩니다. 예를 들어, 고전적인 트랜잭션 예시는 한 계좌에서 다른 계좌로 돈을 이체하는 것입니다. 일반적으로 다음과 같이 보입니다:
Begin --- 계좌 1에서 계좌 2로 돈 이체
--계좌 1 감소
--계좌 2 증가
End - 트랜잭션 커밋
이 예시의 핵심은 돈이 계좌 1에서 사라지고 계좌 2에 동시에 나타나야 한다는 것입니다. 그렇지 않으면 시스템에 일시적으로 초과 금액이나 설명할 수 없는 부족 금액이 발생할 수 있습니다.
데이터베이스의 관점에서 트랜잭션은 일반적으로 다른 트랜잭션과 독립적인 것으로 간주되는 데이터베이스에서 수행되는 작업 그룹으로 정의됩니다. 제 관점에서 이 정의는 다른 정의보다 낫거나 나쁘지 않지만, 어떤 정의든 DBMS의 실제 내부 작동 방식과 논리를 알지 못하면 의미가 거의 없습니다.
데이터베이스의 트랜잭션은 소위 ACID 요구 사항을 충족해야 한다고 여겨집니다.
A - 원자성(Atomicity)
C - 일관성(Consistency)
I - 격리성(Isolation)
D - 지속성(Durability)
데이터베이스 애플리케이션을 개발하는 많은 개발자들은 이 약어에 너무 매료되어 서로 다른 DBMS를 비교할 때 “ACID에 D가 없다"와 같은 주장을 자주 사용합니다(그리고 보통 “네 생각이 어떻든 상관없어"라는 말이 이어집니다).
실제로 모든 것은 매우 간단합니다 - ACID는 특정 DBMS에서 트랜잭션 구현에 관한 요구 사항 집합이며, 그 중 일부는 매우 엄격하고(예: D - 물론 지속성은 중요합니다!) 일부는 덜 엄격합니다 - 트랜잭션 격리 수준을 살펴보면 격리성이 다양할 수 있음을 알 수 있습니다.
그렇기 때문에 이 약어의 문자 그대로의 의미를 즉시 이해하려고 노력할 가치는 없습니다. 대신 DBMS(특히 트랜잭션)가 어떻게 작동하는지의 논리를 살펴보고 ACID를 “무엇을 의미하는가"가 아닌 “어떻게 만들어졌는가"의 관점에서 바라볼 것입니다.
트랜잭션 측면은 복잡하기 때문에 트랜잭션의 작동과 상호 작용을 보여주기 위해 그래픽 표현, 일종의 차트가 필요합니다. 이 차트를 사용하여 논리적 설명을 구성하고 트랜잭션이 어떻게 작동하는지 자세히 살펴볼 수 있습니다.
우선, 트랜잭션은 시간에 따라 진행되므로 타임라인을 도입하겠습니다. 타임라인은 우리가 필요한 방식으로 표시될 것입니다 - 초나 분이 아니라 트랜잭션 간 상호 작용의 핵심 단계가 필요합니다:

그런 다음 이 타임라인에 트랜잭션을 추가하겠습니다 - 트랜잭션의 시작과 끝에 해당하는 변으로 사각형 형태로 그려보겠습니다. Firebird의 모든 트랜잭션에는 번호가 매겨져 있으므로 트랜잭션 번호도 지정하겠습니다.

따라서 차트는 시간 t3에 시작하여 시간 t10에 끝난 트랜잭션 번호 11을 보여줍니다. 트랜잭션이 끝나는 방법은 두 가지가 있습니다 - COMMIT, 즉 트랜잭션 내에서 이루어진 모든 변경 사항을 적용하는 것, 그리고 ROLLBACK, 즉 트랜잭션 내에서 이루어진 모든 변경 사항을 취소하는 것입니다. 트랜잭션이 끝나는 방식을 다음과 같이 표시하겠습니다:

계속 진행하려면 이 차트에 트랜잭션의 다양한 매개변수를 지정해야 하며, 해당 트랜잭션을 나타내는 사각형의 왼쪽 하단 모서리에 지정하겠습니다 - 이 예시는 트랜잭션 #11이 snapshot 격리 수준을 가지고 있음을 보여줍니다.

“트랜잭션 X가 데이터를 삽입한다” 또는 “트랜잭션 Y가 이런 저런 데이터를 읽는다"라고 말하는 것은 공식적으로 올바르지 않습니다. “트랜잭션 X 내에서 변경이 이루어졌다"라고 말해야 하기 때문입니다. SQL 문만이 데이터를 읽거나 삽입할 수 있으므로, 설명에 중요하다면 이러한 문을 트랜잭션 사각형 안에 표시하겠습니다:

이 예시에서는 테이블 T1, 필드 i1, 값 100에 대한 INSERT 작업이 있으며, 이 작업은 트랜잭션 #11 내에서 수행되고 커밋되었습니다.
또한 때로는 작업의 결과를 표시해야 할 수도 있습니다. 예를 들어 다음 예시에서:

이 예시는 다음을 보여줍니다:
- 격리 수준 매개변수가 snapshot으로 설정된 트랜잭션 #11(격리 수준은 나중에 논의되며, 여기서는 전체 그림을 그리기 위해 표시됨)이 시간 t3에 시작됩니다.
- 테이블 t1의 필드 i1에 값 100을 삽입하는 INSERT INTO T1(i1) values (100) 작업이 시간 t5에 시작되어 시간 t7에 끝납니다.
- i1 값이 100인 것을 반환하는 SELECT i1 from T1 작업이 시간 t8에 시작됩니다.
- 트랜잭션 #11은 COMMIT 문으로 끝나며, 즉 트랜잭션 #11에 의해 이루어진 변경 사항이 데이터베이스에 커밋됩니다.
따라서 트랜잭션 차트를 통해 데이터베이스에서 무슨 일이 일어나고 있는지 자세히 설명하고 트랜잭션이 어떻게 작동하는지 알 수 있습니다.
이제 트랜잭션 차트가 있으니 ACID 약어가 실제로 무엇을 의미하는지 살펴보겠습니다.
원자성(Atomicity)
원자성은 트랜잭션을 구성하는 모든 작업이 수행되거나 그 중 어느 것도 수행되지 않음을 의미합니다: “전부 아니면 전무(All or Nothing)”. 아주 쉬워 보이지만, 그 다음에 세부 사항이 드러납니다.
첫째, DBMS(Firebird뿐만 아니라 거의 모든 DBMS)에는 두 가지 유형의 원자성이 있습니다: 문(statement) 수준의 원자성과 트랜잭션 내 문 그룹 수준의 원자성입니다.
문 수준의 원자성은 UPDATET1 SETX=1 WHEREY=2 문이 항상 성공적으로 실행되거나 실행되지 않음을 의미합니다.
문 그룹 수준의 원자성은 다르게 작동합니다(트랜잭션이 시작되고 커밋되는 시점을 표시하기 위해 여기서는 의사 코드를 사용하겠습니다):
Start transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11
이것은 차트에서 대략 다음과 같이 보일 것입니다:

이는 세 개의 INSERT 문이 모두 성공적으로 실행되고, 트랜잭션 #11이 커밋되는 시점에 그 변경 사항이 커밋됨을 의미합니다.
워크숍에서 트랜잭션에 관해 자주 묻는 질문은 - INSERT INTO..300이 예외를 발생시키는 경우 트랜잭션 11에 대한 COMMIT 문이 성공적으로 실행될까요?

청중의 상당 부분은 항상 COMMIT 문이 성공적으로 실행되지 않을 것이라고 대답합니다! (흥미롭게도, 일부 다른 DBMS에서는 이로 인해 트랜잭션이 롤백될 것입니다!)
그러나 그것은 사실이 아닙니다 - isql을 실행하고 어떤 데이터베이스로든 실험을 수행해 보십시오(isql은 사용자를 위해 “추측"하지 않고 단순하고 직관적인 작업 구현을 가지고 있습니다).
문제는 트랜잭션 커밋으로 보장되는 문 그룹 수준의 원자성은 비즈니스 로직의 문제라는 것입니다. 애플리케이션 개발자는 세 번째 INSERT 문에서 예외가 발생한 경우 트랜잭션을 커밋할지 여부를 결정해야 합니다. 비즈니스 로직이 결과를 커밋하는 것을 허용한다면 COMMIT 문은 쉽게 실행될 수 있습니다.
따라서 ACID에서 원자성 요구사항은 DBMS가 하나의 트랜잭션 내에서 실행된 일련의 명령문들의 결과를 커밋하거나 롤백할 수 있어야 한다는 요구사항입니다. 커밋할지 롤백할지의 결정은 구현해야 하는 비즈니스 로직에 달려 있습니다.
그리고 다시 한번 강조하자면 - 트랜잭션의 원자성이 일련의 명령문에 대해 결과와 무관하게 전체 그룹을 커밋하거나 롤백할 가능성을 의미하지만(그리고 선택은 비즈니스 로직에 달려 있지만), 단일 명령문의 원자성은 DBMS의 구현에 의해 보장됩니다. 즉, 하나의 명령문(예: UPDATE)을 “불완전하게”(비원자적으로) 실행하는 것은 불가능합니다.
일관성
일관성은 데이터베이스 내부의 데이터가 모순을 나타내지 않음을 의미합니다. 물론, 여기서 “모순을 나타내지 않는다는 것이 도대체 무엇을 의미하는가"에 대해 추측할 수 있는 영역이 넓습니다.
일반적으로 두 가지 일관성 수준이 구분됩니다:
- 데이터베이스 수준 - 일관성은 데이터가 Primary, Unique, Foreign 키, Check와 같은 데이터베이스 제약 조건에 부합함을 의미합니다. 이 일관성 수준은 데이터베이스 제약 조건이 제약 조건에 부합하지 않는 데이터의 삽입을 허용하지 않기 때문에 보장됩니다: 예를 들어 CHECK(x>0)는 해당 필드에 음수가 삽입되는 것을 허용하지 않습니다.
- 비즈니스 로직 수준 - 일관성은 애플리케이션 개발자가 트랜잭션과 같은 DBMS가 제공하는 도구를 사용하여 보장합니다.
트랜잭션이 비즈니스 로직 수준에서 일관성을 보장하는 데 어떻게 도움이 될까요? 아주 간단합니다 - 송금 예시를 들면, 개발자는 예외 발생 시 모든 변경 사항이 롤백되도록 해야 하며 트랜잭션을 사용하는 것이 이를 돕습니다.
Starttransaction
계좌 1의 금액 감소…. 성공
계좌 2의 금액 증가… 실패
Rollback ---- 예외 발생 시!
즉, 개발자는 예외 발생 시 데이터가 롤백되도록 코드를 작성해야 하며, 이를 통해 비즈니스 로직의 관점에서 데이터 일관성이 유지됩니다.
이렇게 ACID 약어에서 일관성 요구사항은 DBMS가 트랜잭션 메커니즘을 통해 데이터 일관성을 유지할 수 있는 가능성을 가져야 함을 의미합니다.
격리
트랜잭션 격리 요구사항은 작업이 수행되는 순서와 무관하게 일련의 작업 결과를 보장해야 할 필요성에서 발생합니다.
간단히 말해, 각 트랜잭션은 동시에 활성화된 트랜잭션과 무관하게 동일한 결과로 실행되어야 합니다.
트랜잭션 메커니즘은 비즈니스 로직 수준에서 일관성을 보장하기 위한 것이지만, 동시 트랜잭션 실행 과정에서 나타날 수 있는 임시 미확정 데이터로부터 트랜잭션을 보호하기 위한 것이기도 합니다.
실제로는 다음과 같습니다:

t2 시점에 트랜잭션 #11이 시작되고, 그 안에서 t3-t5 시점에 테이블에 삽입이 이루어집니다. 트랜잭션 #11은 삽입 직후 커밋되지 않고 t8 시점까지 계속 활성 상태로 유지됩니다.
동시에 트랜잭션 #12가 시작되어 트랜잭션 #11이 삽입한 레코드가 있는 테이블에 대해 SELECT 문을 실행합니다. 첫 번째 SELECT 문은 t6 시점에 실행되는데, 이때 삽입 작업은 이미 끝났지만 이 문은 빈 결과를 반환합니다. 트랜잭션 #12는 다른 트랜잭션의 커밋되지 않은 데이터를 볼 수 없기 때문입니다.
트랜잭션 #11은 t8 시점에 커밋되고, 트랜잭션 #12 내의 SELECT 문은 t9 시점에 실행됩니다. 이 문은 100과 같은 결과를 반환합니다. 트랜잭션 #11 내에서 생성된 데이터가 이제 커밋되었기 때문입니다(그리고 트랜잭션 #12의 격리 수준이 read committed이기 때문입니다. 이에 대해서는 나중에 다루겠습니다).
이 예시는 격리 요구사항을 설명하기에 충분합니다 - 원자성과 일관성과 달리 격리는 격리 수준이라고 불리는 엄격한 규칙으로 구현되며, 각 트랜잭션은 자신이 작업하는 격리 수준을 설정하는 매개변수를 가져야 합니다.
지속성
지속성 개념은 개발자가 커밋된 트랜잭션 내에서 생성된 데이터가 즉시 데이터베이스에 나타나고, 이후 어떤 일이 발생하더라도(물론 명시적으로 삭제하거나 변경하는 문이 없다면) 데이터베이스에서 사라지지 않는다는 사실을 전적으로 신뢰할 수 있게 합니다.
보시다시피 지속성 요구사항은 상식에 불과합니다 - 데이터가 갑자기 사라질 수 있는 시스템을 사용하려는 사람은 거의 없을 것입니다.
ACID: 요약
ACID는 트랜잭션이 어떻게 작동해야 하는지에 대한 요구사항을 의미합니다:
- 원자성
- 명령문은 항상 원자적입니다
- 명령문 그룹은 트랜잭션을 통해 원자적으로 만들 수 있습니다
- 일관성
- 두 가지 일관성 수준: 데이터베이스 제약 조건과 비즈니스 로직
- 격리
- 트랜잭션에 설정된 격리 수준을 통해 트랜잭션 메커니즘에 의해 보장됩니다
- 지속성
- 커밋된 모든 데이터는 영구적이 됩니다
보시다시피 모든 것이 상당히 논리적입니다. 실제로 가장 큰 어려움은 격리 수준에서 발생하므로 격리 수준이 어떻게 작동하는지 자세히 살펴보겠습니다.
트랜잭션의 격리 수준은 이 트랜잭션이 볼 수 있는 커밋된 데이터를 정의합니다.
일반적으로 표준이라고 불리는 격리 수준이 있습니다. 이들은 ANSI SQL 표준(여러 개정판)에 설명되어 있습니다. 제가 아는 한, 표준에 설명된 대로 정확히 구현된 DBMS는 하나도 없지만, 특정 DBMS의 실제 트랜잭션 메커니즘에는 비즈니스 로직을 구현하는 데 필요한 모든 옵션이 있으므로 아무도 걱정하지 않습니다.
격리 수준의 고전적인 정의는 “A Critique of ANSI SQL Isolation Levels“에서 찾을 수 있습니다.
이 기사를 읽은 사람들을 위해, 고전적인 격리 수준과 Firebird의 유사한 격리 수준을 비교한 표가 있습니다. 물론 Firebird의 격리 수준은 다른 DBMS와 마찬가지로 ANSI SQL 정의와 100% 일치하지 않지만 매우 유사하므로 대응 관계는 직접적이지 않습니다.
| ANSI 격리 수준 | Firebird의 격리 수준 |
| Read Uncommitted | 해당 없음 |
| Read Committed | Read Committed |
| Repeatable Read | Snapshot |
| Serializable | Snapshot table stability |
다른 DBMS와 마찬가지로 Firebird도 격리 구현에 고유한 특성이 있습니다. 이제 표준과 얼마나 잘 부합하는지가 아니라 Firebird에서 격리 수준이 어떻게 작동하는지에 초점을 맞추겠습니다.
Snapshot 격리 수준
Snapshot 격리 수준은 InterBase의 원래 코드에서 처음 도입되었으며 Firebird 핵심 API 및 유틸리티(예: isql.exe)의 기본값으로 남아 있습니다. 이것이 가장 이해하기 쉬운 이유일 수 있습니다.
Snapshot은 트랜잭션을 시작 시점부터 발생한 모든 변경 사항으로부터 격리합니다.
아래 트랜잭션 차트를 살펴보겠습니다: snapshot 격리 수준으로 시작된 트랜잭션 #10을 보여줍니다. 이 트랜잭션 내에서 이 예시에서는 레코드가 없는 테이블 T1에 대해 여러 SELECT 문이 실행됩니다.

트랜잭션 #10 시작 후 시작된 동시 트랜잭션 #15는 테이블 T1에 데이터를 삽입하고 이 트랜잭션은 t9 시점에 COMMIT 문으로 종료됩니다. 즉, 이 시점에 데이터가 데이터베이스에 커밋되어 다른 트랜잭션의 문에서 사용할 수 있습니다.
그러나 t10 시점(즉, 트랜잭션 #15가 커밋된 후)에 실행된 트랜잭션 #10의 문은 삽입된 데이터를 볼 수 없습니다. snapshot 격리 수준은 트랜잭션 #10 시작 전에 삽입되거나 변경된 커밋된 데이터만 볼 수 있기 때문입니다.
따라서 Snapshot 격리 수준은 트랜잭션이 시작되는 시점에 데이터베이스가 동결된 것처럼 작업할 수 있게 합니다. 빠르게 변화하는 데이터를 기반으로 복잡한 보고서를 작성할 때 일반적으로 필요합니다: 보고서의 첫 부분이 일부 데이터를 기반으로 하고 마지막 부분이 다른 데이터를 기반으로 하는 상황을 피하기 위해 snapshot이 사용됩니다.
하지만 이 훌륭한 기능에는 대가가 따릅니다. 나중에 Firebird에서 격리가 어떻게 구현되는지 살펴볼 때, snapshot 격리 수준으로 매우 긴 트랜잭션을 시작하면 과도한 레코드 버전과 낮은 성능이 발생한다는 것을 알게 될 것입니다.
Read Committed 격리 수준
read committed 격리 수준을 가진 트랜잭션은 활성 상태 동안 커밋된 다른 트랜잭션의 커밋된 데이터를 볼 수 있습니다(snapshot 수준의 경우와 달리, 트랜잭션이 시작되는 순간 이전에 커밋된 데이터만 볼 수 있습니다).
다음 차트를 사용하여 read committed 격리 수준이 어떻게 작동하는지 보여드리겠습니다:

이 예제는 이전 예제와 거의 동일합니다: 두 개의 동시 트랜잭션 중 하나는 T1 테이블에서 데이터를 정기적으로 읽고, 다른 하나는 데이터를 삽입하고 커밋합니다.
snapshot 격리 수준의 경우와 달리, 이 예제의 트랜잭션 #10은 트랜잭션 #15가 삽입하고 커밋한 데이터를 볼 수 있습니다.
이 예제는 read committed 격리 수준의 영향을 보여줍니다: 이 격리 수준을 가진 트랜잭션 내의 문은 해당 문이 실행되는 순간 이전에 커밋된 데이터를 볼 수 있습니다.
다음 차트는 두 개의 동시 트랜잭션 #11과 #18이 데이터를 변경하는 예제를 보여줍니다.
트랜잭션 #11은 데이터를 읽는 트랜잭션 #14가 시작되기 전에 시작되고, 트랜잭션 #18은 그 후에 시작되지만 결과에는 영향을 미치지 않습니다: 데이터가 커밋된 경우 read committed 격리 수준을 가진 동시 트랜잭션에서 볼 수 있습니다.

이러한 가능성 덕분에 read committed 격리 수준은 최신 데이터베이스 상태를 표시하기 위해 정기적으로 실행되는 SQL 문(예: 최신 주문을 표시하기 위해)에 자연스러운 선택이 됩니다.
가비지 컬렉션에 관한 부분에서는 Firebird 버전 4까지의 read-only 수정자를 가진 read committed 트랜잭션이 “무한” 읽기 트랜잭션에 가장 적합한 선택임을 보여줄 것입니다. 왜냐하면 이들은 사전 커밋된 상태로 시작되기 때문입니다.
Snapshot Table Stability 격리 수준
표준 Serializable 격리 모드의 대응물인 snapshot table stability 격리 모드에 대한 이야기는 매우 짧게 또는 상당히 길고 상세하게 만들 수 있습니다.
이야기의 짧은 버전은 다음과 같습니다: 이 수준은 snapshot 수준과 완전히 유사하지만, 추가로 테이블(트랜잭션 매개변수에 명시적으로 지정되어야 함)을 쓰기와 읽기용으로 잠급니다. 즉, 지정된 테이블을 완전히 점유하는 트랜잭션을 시작할 수 있으며 다른 트랜잭션은 액세스 오류를 받게 됩니다.
다시 말해, snapshot table stability 격리 수준을 가진 트랜잭션은 실제로 지정된 테이블에 대한 모든 쿼리를 대기열에 넣습니다. 실제로 일반 트랜잭션의 읽기만 순서에 관계없이 수행되며(물론 상호작용에 따라 다름) 다른 모든 모드는 대기열을 형성합니다.
주의 없이 구현하면 잠금과 데이터베이스 작업 불가능을 유발할 수 있기 때문에 Firebird 데이터베이스 애플리케이션 개발자들은 이 격리 수준을 사용하는 것을 두려워할 수 있습니다.
그러나 올바르게 구현하면 Serializable 격리 수준은 대기열을 쉽게 형성하고 데이터베이스 레코드의 순차적 변경을 가능하게 하며, 이는 카운터, 순차 문서 번호 및 기타 유사한 객체를 구현하는 데 매우 유용할 수 있습니다.
snapshot table stability 격리 수준을 가진 트랜잭션을 사용하여 대기열을 형성하는 방법을 올바르게 설명하려면 하나 더 트랜잭션 매개변수인 wait/nowait를 살펴본 다음 대기열 예제로 돌아가야 합니다.
이전에는 한 트랜잭션 내에서 데이터가 변경되고 다른 트랜잭션 내에서 읽히는 트랜잭션 간의 상호작용 방식을 살펴보았습니다.
그러나 실제로는 서로 다른 트랜잭션이 동일한 데이터를 변경하려고 시도하는 경우가 자주 발생하며, 데이터베이스에는 하나의 결과만 저장되므로 동시 트랜잭션은 충돌 메시지(실제로는 이미 변경된 데이터를 변경하려고 시도하는 해당 문의 실행을 중단(및 취소)하는 예외)를 받게 됩니다.
wait 옵션은 트랜잭션이 업데이트 충돌에 어떻게 반응해야 하는지를 정의합니다. 이 옵션을 구성하는 방법에는 세 가지가 있습니다:
- Wait (매개변수 없음) = 동시 트랜잭션이 끝날 때까지 대기
- Wait Timeout N sec = 동시 트랜잭션이 끝날 때까지 대기하지만 N초를 초과하지 않음
- Nowait - 동시 트랜잭션이 끝날 때까지 대기하지 않음
wait 옵션은 여기서 의사 코드로 지정되었으며 API와 특정 구성 요소에서 이름이 다를 수 있지만 의미는 동일합니다.
wait 옵션의 다양한 변형으로 업데이트 충돌이 발생할 때 어떤 일이 발생하는지 자세히 살펴보겠습니다.
Wait
두 개의 동시 활성 트랜잭션(#11 및 #14)이 있고, 각각 동일한 테이블 T1의 동일한 레코드를 변경해야 하는 UPDATE 문을 실행한다고 가정해 보겠습니다.
트랜잭션 #14는 wait 옵션으로 실행됩니다(isql을 사용하여 예제를 재현하는 경우 wait가 기본적으로 설정됩니다).
트랜잭션 #11의 UPDATE 문은 t3 시점에 시작되어 t5 시점에 끝나지만 트랜잭션은 아직 커밋되지 않았습니다 - 즉, COMMIT 문은 t6 시점까지 없습니다.
아래 차트는 이 상황을 보여줍니다:

UPDATE 문은 트랜잭션 #14에서도 실행되며 동일한 테이블의 동일한 레코드를 업데이트하려고 시도하지만 더 늦게 시작됩니다 - 대략 t4 시점입니다.
트랜잭션 #11의 업데이트와 업데이트 충돌이 있고 트랜잭션 #14에 wait가 지정되어 있으므로 UPDATE 문은 충돌하는 트랜잭션 #11이 끝날 때까지 대기합니다.
트랜잭션 #11이 충분히 오래 지속되면 트랜잭션 #14의 UPDATE 문은 이 문의 실행을 보는 사용자의 관점에서 정지된 것처럼 보일 것입니다.
두 개의 isql.exe를 사용하여 이 상황을 재현하면 다음 그림은 두 번째 트랜잭션(정확히는 동시 UPDATE 문이 더 늦게 시작되는 트랜잭션 - 우리 예제에서는 트랜잭션 #14)이 첫 번째 트랜잭션(우리 예제에서는 트랜잭션 #11)이 끝날 때까지 대기하는 순간을 보여줍니다.

트랜잭션 #11에서 COMMIT 문이 실행된 후, 이를 기다리던 트랜잭션 #14는 즉시 통보되고 충돌하는 업데이트는 예외로 끝납니다.
아래에서 이러한 오류 메시지의 예를 볼 수 있습니다(동시 트랜잭션의 번호는 각 데이터베이스에서 트랜잭션 번호가 처음부터 시작되고 백업/복원 후에만 재설정되므로 우리 예제와 일치하지 않습니다):
SQL> update T1 set i1 = 2 where i1=1;
Statement failed, SQLSTATE = 40001
deadlock
-update conflicts with concurrent update
-concurrent transaction number is 19
SQL>
오류 메시지의 “deadlock"이라는 단어에 주의하십시오 - 실제로는 고전적인 정의에 따른 교착 상태가 아닙니다. 대신 업데이트 충돌이 있지만 Firebird 개발자들은 35년 이상 사용되어 왔기 때문에 오류 메시지를 변경하지 않습니다. 진정한 “고전적인” 교착 상태에 대해서는 나중에 다루겠습니다.
이제 동시 UPDATE 문이 있는 트랜잭션이 COMMIT 문으로 끝나는 상황을 살펴보았습니다. 이제 유사한 상황이지만 롤백되는 경우를 살펴보겠습니다 - 아래 차트에서 볼 수 있습니다:

상황은 이전 상황과 완전히 유사합니다 - 두 개의 UPDATE 문이 동일한 레코드를 업데이트하려고 시도하지만 이번에는 동시 트랜잭션 #20이 롤백되고 결과적으로 트랜잭션 #15 내의 변경 사항이 오류 없이 데이터베이스에 저장됩니다.
따라서 wait 옵션을 사용하면 충돌하는 업데이트가 마지막 순간까지 충돌하는 트랜잭션이 ROLLBACK 문으로 끝나기를 희망하며 대기열에서 무한정 대기하도록 업데이트의 비즈니스 로직을 구성할 수 있습니다.
이 전술이 항상 의미가 있나요? 물론 비즈니스 로직의 구현 방식에 따라 다르지만, Firebird는 wait 옵션을 사용하여 업데이트 충돌을 해결하는 다른 옵션도 제공합니다.
Wait with timeout
우선, 충돌 발생 시 무한정 기다리는 대신 wait 옵션에 타임아웃을 지정하여 대기 시간을 제한하는 것이 좋은 방법일 수 있습니다.
isql.exe에서 이러한 매개변수는 다음 문을 사용하여 지정합니다:
SET TRANSACTION WAIT LOCK TIMEOUT N;
여기서 N은 동시 실행 트랜잭션이 충돌 해결을 기다리는 시간(초)입니다.
트랜잭션 제어 문에 대한 자세한 내용은 Firebird Language Reference에서 확인할 수 있습니다. 특정 드라이버나 액세스 구성 요소에서 타임아웃을 지정하는 방법은 다를 수 있습니다(일반적으로 API 매개변수를 통해).
아래 그림에서 isql의 예를 확인할 수 있습니다:

트랜잭션 차트를 통해 wait 옵션에 타임아웃을 지정할 때 트랜잭션이 어떻게 상호작용하는지 살펴보겠습니다.
상황은 동일합니다 - 두 개의 동시 트랜잭션 #11과 #14가 있으며, 각각 T1 테이블의 동일한 레코드를 업데이트하려는 UPDATE 문을 실행합니다.

그러나 이 경우 트랜잭션 #14 내의 문은 트랜잭션 #11이 종료되거나 지정된 타임아웃(3초)이 만료될 때까지 기다립니다 - 먼저 발생하는 쪽이 우선합니다.
이 예에서는 타임아웃이 먼저 만료되고, 문은 예외로 종료됩니다:
SQL> update T1 set i1=6 where i1=1;
Statement failed, SQLSTATE = 40001
lock time-out on wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 40
오류 메시지에 “deadlock"이 다시 나타나지만, 이것은 여전히 “진짜” 교착 상태가 아닙니다.
따라서 상황은 wait 옵션과 유사하지만, 지정된 타임아웃이 동시 트랜잭션 종료보다 먼저 만료되면 제한됩니다.
타임아웃이 있는 wait 옵션을 지정하는 것은 모든 쓰기 트랜잭션이 매우 짧다는 것을 확실히 알고 있는 경우(예: 1-2초 이하) 비즈니스 로직을 구현하는 좋은 솔루션이 될 수 있습니다.
Nowait
Nowait가 공식적인 관점에서 무엇인지 설명하는 것은 매우 쉽습니다 - 타임아웃이 0인 wait입니다. 트랜잭션에서 nowait를 지정하면 충돌하는 업데이트가 즉시 예외를 발생시킵니다.

이 경우에도 동시 트랜잭션 #11과 #14(nowait)이 있으며, 동시 UPDATE 문이 실행됩니다. nowait 옵션이 있는 트랜잭션 내의 문은 동시 업데이트를 발견하면 기다리지 않고 업데이트 시점에 즉시 다음 예외를 발생시킵니다(트랜잭션 번호만 다릅니다):
SQL> update T1 set i1=5 where i1=1;
Statement failed, SQLSTATE = 40001
lock conflict on no wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 50
SQL>
두 개의 isql 도구를 사용한 예는 다음과 같습니다:

nowait 트랜잭션은 동시 UPDATE 문이 있는 트랜잭션이 언제, 어떻게 종료되는지 신경 쓰지 않습니다 - COMMIT 또는 ROLLBACK 문이든 관계없이 예외는 여전히 발생합니다.
비즈니스 로직의 관점에서 nowait 트랜잭션은 동시 업데이트가 현재 문의 작업을 확실히 취소해야 한다는 것을 알고 있는 경우 편리할 수 있습니다.
많은 Firebird 드라이버는 nowait 옵션을 기본값으로 사용하며, 많은 개발자가 업데이트 충돌 해결을 위한 덜 엄격한 수준을 설정할 수 있다는 것을 모르는 한(예: wait lock timeout 1), 해당 애플리케이션(때로는 사용자도)은 충돌로 인한 불필요한 오류로 고통받습니다.
“deadlock” 키워드가 업데이트 충돌과 관련된 모든 예외에 포함되어 있기 때문에 많은 애플리케이션 개발자는 이것이 실제 교착 상태라고 확신합니다(일부는 이 오류에 Dead이라는 사람이 역할을 했다고 생각하기도 합니다).
동시에 firebird.conf 구성 파일을 살펴보면 DeadlockTimeout 매개변수(기본값 10초)를 볼 수 있고, fb_lock_print 유틸리티의 출력 헤더를 보면 “Deadlock scans” 매개변수도 볼 수 있습니다.
문제는 Firebird에서 “진짜 교착 상태"가 가능하며, 업데이트 충돌과 관련된 모든 예외에 나타나는 “deadlock” 키워드는 이와 직접적인 관련이 없다는 것입니다. 다행히도 진짜 교착 상태는 매우 드물게 발생합니다.
이 “진짜 교착 상태"가 무엇인지 살펴보겠습니다. 이를 위해 다음 트랜잭션 상호작용 차트를 확인하세요:

wait 옵션이 있는 두 개의 동시 트랜잭션이 있으며 UPDATE 문이 실행됩니다. 단순한 업데이트 충돌과 달리 여기서는 상호 의존적인 업데이트 충돌을 볼 수 있습니다:
- 트랜잭션 #11은 key = 20인 레코드를 업데이트하고, 트랜잭션 #12는 key = 10인 레코드를 업데이트합니다;
- 그 후 트랜잭션 #11은 key = 10인 레코드를 업데이트하고, 트랜잭션 #12는 key = 20인 레코드를 업데이트합니다;
결과적으로 각 트랜잭션이 다른 트랜잭션의 종료를 기다려야 하고, 둘 다 wait 옵션이 지정되어 있기 때문에 둘 다 무한정 기다릴 수 있는 상황이 발생합니다. 물론 서버는 이를 허용할 수 없으므로 기본값 10초로 설정된 DeadlockTimeout 매개변수에 지정된 타임아웃 후에 트랜잭션 중 하나가 강제로 롤백됩니다.
두 개의 isql을 사용하여 이 상황을 재현할 수 있습니다:

두 번째 트랜잭션이 시작된 후 진짜 교착 상태가 발생합니다. 이를 확실히 감지하기 위해 서버는 Deadlock scan이라는 프로시저를 시작합니다 - 기본값 10초인 DeadlockTimeout과 같은 간격으로 시작됩니다.
클라이언트(이 경우 isql)는 일반적인 업데이트 충돌 메시지를 받지만, 트랜잭션이 wait 옵션으로 시작된 경우에도 10초 후에 시작됩니다.
서버가 두 트랜잭션의 상호 의존적인 잠금을 감지하면 내부 교착 상태 카운터도 증가시킵니다(fb_lock_print 출력에서 확인할 수 있습니다).
Snapshot Table Stability의 실용적 사용
이제 충돌하는 UPDATE 문과 함께 트랜잭션이 어떻게 작동하는지 알았으므로 Snapshot Table Stability 격리 수준으로 돌아가 실용적인 용도를 찾을 수 있습니다.
이 격리 수준이 지정되면 테이블은 쓰기 및 읽기 모두에 대해 잠깁니다.
트랜잭션 매개변수에서 테이블이 명시적으로 지정되지 않은 경우, 이 트랜잭션 내에서 접근하는 모든 테이블이 잠기며 이는 테이블에 대한 첫 번째 접근 중에 발생합니다. 분명히 이 격리 수준을 주의 없이 사용하면 많은 수의 업데이트 충돌이 쉽게 발생할 수 있습니다.
Reserving TableNN 절을 사용하면 트랜잭션 시작 시 잠글 특정 테이블(또는 여러 테이블)을 지정할 수 있습니다(예약 모드도 지정 가능).
이 훌륭한 기능은 wait 옵션과 함께 특정 테이블 변경을 위한 매우 효과적인 순차 대기열을 구현할 수 있게 해줍니다.
실제로는 다음과 같습니다 - 특정 테이블에 대한 대기열을 만들어야 하는 클라이언트는 이 테이블을 지정하여 SNAPSHOT TABLE STABILITY 트랜잭션을 시작한 다음 이 트랜잭션 내에서 작업을 수행하고 즉시 종료합니다.
예를 들어, CREATE TABLE Table1(i1 integer not null) 유형의 단일 레코드만 있는 테이블에 순차적으로 증가하는 카운터를 만들고 싶지만 어떤 이유로 생성기를 사용할 수 없는 경우입니다.
의사 코드는 대략 다음과 같습니다:
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;
이 코드를 Snapshot Table Stability(Table1) 격리 수준이 아닌 더 낮은 격리 수준으로 실행하면, 트랜잭션 시작과 UPDATE 문 실행 사이에 동시 UPDATE 문이 개입할 가능성이 있습니다. 그 결과, 즉시 업데이트 예외(nowait)가 발생하거나, 문이 동시 트랜잭션 종료까지 대기(wait)하거나, 타임아웃(wait interval)이 발생할 수 있습니다. 즉, 충돌은 문 수준에서 어떻게든 해결됩니다.
Snapshot table stability 격리 수준을 사용하면 트랜잭션 시작 시 테이블이 예약되므로 이러한 문제로부터 안전합니다. 테이블은 완전히 우리 것이거나 완전히 우리 것이 아닙니다. 충돌 해결을 위해 wait 옵션을 지정하면 병렬 연결은 오류를 처리하지 않고 자동으로 대기열을 형성합니다.

물론 이 접근 방식은 짧은 트랜잭션에만 적용할 수 있습니다(우리 예제에서처럼).
실제로 Snapshot table stability 격리 수준은 대기열을 형성하고 복잡한 로직을 독점 모드로 재계산하는 데 사용됩니다(상대적으로 작은 테이블이거나 다른 사용자가 없을 때).
엔진 내부에서 Firebird는 인덱스를 생성할 때 Snapshot Table Stability 격리 수준을 사용합니다. 즉, ALTER INDEX indexname ACTIVE; 문을 실행하면 Firebird는 인덱스를 구축 중인 테이블을 완전히 점유합니다.
다음 단계
이 문서는 Firebird 트랜잭션 개념에 대한 소개만 제공합니다. Firebird에서 트랜잭션이 어떻게 작동하는지 완전히 이해하려면 다중 세대 아키텍처(레코드 버전 및 가비지 컬렉션 개념)를 고려하고, 트랜잭션 마커(Oldest Interesting, Oldest Active, Oldest Snapshot, next 등)를 고려해야 합니다.
이 문서는 2013년 Firebird Tour 세미나에서 처음 소개된 “All About Transactions” 세미나/워크숍 자료와 IBSurgeon 교육 “Firebird Transaction in details“을 기반으로 작성되었습니다.
연락처
[email protected] 질문이나 제안 사항이 있으면 언제든지 문의해 주세요: [email protected]