원자성
고친 사람 github-actions[bot]
원자성은 여러 단계로 이루어진 일을 한 덩어리처럼 다루게 해 주는 성질입니다. 그 일은 끝까지 다 되거나, 아예 안 된 것으로 남습니다. 데이터베이스와 동시성 프로그래밍이 같은 이름으로 이 성질을 씁니다. 다만 두 곳에서 막는 것이 조금 다릅니다.
쉽고 빠른 이해
원자성은 「하다 만 상태」를 없애 주는 성질입니다. 계좌이체에서 내 돈은 빠졌는데 상대 계좌에는 안 들어간 상태가 그 예입니다.
이게 없으면 중간에 멈춘 일이 데이터를 망가뜨립니다. 서버가 도중에 죽으면 반쯤 바뀐 값이 남습니다. 다른 스레드가 끼어들면 반쯤 바뀐 값을 남이 읽습니다. 그걸 막는 코드를 매번 손으로 짜야 합니다.
데이터베이스의 트랜잭션에서는 이렇게 돕니다:
- 여러 단계를 한 묶음으로 선언합니다
- 끝까지 가면 한꺼번에 반영합니다
- 도중에 실패하면 한 일을 전부 되돌립니다
동시성 프로그래밍에서는 되돌리지 않습니다. 그 대신 일이 끝날 때까지 다른 스레드가 못 끼어들게 막습니다.
여러 곳을 함께 바꿔야 뜻이 맞는 일에 챙깁니다. 하나만 바뀌는 쓰기는 묶을 필요가 없습니다. 챙기면 그만큼 느려집니다. 되돌릴 준비를 해 두거나 남이 못 끼어들게 막아야 하기 때문입니다.
상세
마트에서 카트에 물건을 하나씩 담다가 계산대 앞에서 지갑을 두고 온 걸 알았다고 해 봅시다. 담은 것을 하나하나 도로 제자리에 갖다 놓고 빈손으로 나옵니다. 카트는 담는 내내 반쯤 찬 채였고, 그걸 비우는 발품까지 들여서야 다 사 왔거나 아무것도 안 사 왔거나 둘 중 하나가 됩니다.
원자성(atomicity)은 여러 단계로 된 연산이 전부 일어나거나 전혀 일어나지 않은 것으로 보이게 하는 성질입니다. 이름은 더 쪼갤 수 없다는 뜻의 그리스어 「원자」에서 왔습니다. 계좌 A 에서 100을 빼고 계좌 B 에 100을 더하는 이체가 대표 예입니다. 원자성이 지켜지면 이체가 끝난 뒤에 「A 에서만 빠진 상태」는 남지 않습니다.
이 절은 먼저 이 낱말이 쓰이는 두 맥락을 가릅니다. 이어서 원자성이 깨지면 무슨 일이 나는지 두 장면으로 봅니다. 그다음 그것을 지키는 방법과 이 성질이 약속하지 않는 것을 봅니다.
두 맥락에서 쓰는 같은 낱말
첫째 맥락은 트랜잭션입니다. 트랜잭션은 데이터베이스에서 여러 읽기와 쓰기를 한 묶음으로 처리하는 단위입니다. 여기서 원자성은 실패해도 반쯤 된 결과가 남지 않는다는 약속입니다. 도중에 오류가 나거나 서버가 꺼지면 그 묶음이 한 일은 전부 없던 일이 됩니다.
트랜잭션이 끝나는 길은 둘입니다. 모든 단계를 한꺼번에 반영하는 일이 커밋입니다. 한 일을 전부 취소하고 원래 값으로 돌려놓는 일이 롤백입니다.
둘째 맥락은 동시성 프로그래밍입니다. 동시성은 여러 실행 흐름이 같은 데이터를 번갈아 또는 동시에 다루는 상황을 말합니다. 한 프로그램 안에서 따로 도는 실행 흐름 하나를 스레드라고 합니다.
이 맥락에서 원자성은 실행 도중에 다른 스레드가 끼어들어 중간 값을 보지 못한다는 약속입니다. 이 약속을 지키는 연산을 원자적 연산이라고 합니다.
같은 낱말이 두 곳에서 조금 다른 뜻으로 쓰이는 셈입니다. 트랜잭션에서는 「실패하면 반쪽이 남지 않는다」입니다. 동시성에서는 「끼어들어 중간 값을 못 본다」입니다. 트랜잭션끼리 서로의 중간 값을 못 보게 하는 일은 원자성이 아니라 격리성이라는 다른 성질이 맡습니다.
| 맥락 | 막는 것 | 지키는 대표 수단 |
|---|---|---|
| 트랜잭션 | 도중의 실패 · 장애 | 커밋과 롤백 |
| 동시성 | 도중의 끼어들기 | 락 · 원자적 CPU(Central Processing Unit, 중앙 처리 장치) 명령 |
실패가 남기는 반쪽 결과
계좌이체를 SQL(Structured Query Language, 구조화 질의 언어) 두 문장으로 짠다고 해 봅시다. SQL 은 관계형 데이터베이스에 데이터를 묻고 고치는 언어입니다. 첫 문장은 A 에서 빼고 둘째 문장은 B 에 더합니다.
첫 문장이 끝난 직후 서버 전원이 나갔다고 합시다. 원자성이 없으면 A 에서 100이 빠진 채로 남습니다. B 는 받지 못했습니다. 100이 어디에도 없는 상태가 디스크에 굳어 버립니다.
트랜잭션으로 두 문장을 묶으면 이 상태가 안 남습니다. 묶음이 거치는 상태는 아래 그림과 같습니다.
stateDiagram-v2
[*] --> 진행중
진행중 --> 커밋됨: 모든 단계 성공
진행중 --> 중단됨: 도중에 실패
중단됨 --> 되돌림: 한 일을 전부 취소
커밋됨 --> [*]
되돌림 --> [*]
그림에서 볼 것은 끝이 둘뿐이라는 점입니다. 다 반영된 「커밋됨」이거나, 아무것도 안 한 것과 같은 「되돌림」입니다. 「중단됨」은 실패를 알아챈 때이고, 「되돌림」은 취소를 다 마친 때입니다. 전원이 나가서 멈춘 묶음도 다시 켜질 때 되돌림으로 정리됩니다.
끼어들기가 잃게 하는 갱신
이번에는 스레드 둘이 같은 카운터를 1씩 올리는 장면입니다. count++ 는 코드에서 한 줄이지만, CPU 안에서는 세 단계로 나뉩니다. 값을 읽고, 1을 더하고, 다시 씁니다.
단계가 셋이면 그 사이로 다른 스레드가 끼어들 틈이 생깁니다. 아래 그림은 두 스레드가 모두 0을 읽은 뒤에 각자 1을 쓰는 경우입니다.
sequenceDiagram
participant A as 스레드 A
participant C as count
participant B as 스레드 B
A->>C: 읽기
C-->>A: 0
B->>C: 읽기
C-->>B: 0
A->>C: 0+1 인 1을 쓴다
B->>C: 0+1 인 1을 쓴다
Note over C: 두 번 올렸는데 1
두 번 올렸으니 2가 되어야 합니다. 결과는 1입니다. 한 스레드가 한 일이 다른 스레드의 쓰기에 덮여 사라졌습니다. 이렇게 실행 순서에 따라 결과가 바뀌는 상황을 경쟁 상태라고 부릅니다. 읽기부터 쓰기까지가 원자적이었다면 B 는 A 의 쓰기가 끝난 뒤에야 값을 읽었을 겁니다.
트랜잭션이 원자성을 지키는 방법
데이터베이스는 바꾸기 전에 기록부터 남깁니다. 무엇을 어떻게 바꿀지를 로그 파일에 먼저 적습니다. 데이터는 그다음에 고칩니다. 이 방식을 WAL(Write-Ahead Logging, 선행 기록)이라고 합니다.
로그가 있으면 장애 뒤에 되짚을 수 있습니다. 커밋 기록까지 남은 묶음은 끝까지 다시 반영합니다. 커밋 기록이 없는 묶음은 로그를 거꾸로 따라가 원래 값으로 돌려놓습니다. 앞에서 말한 롤백이 이 일입니다.
애플리케이션 코드가 할 일은 경계를 긋는 것입니다. 어디서 묶음이 시작하고 어디서 끝나는지만 알려 주면 됩니다.
BEGIN;
UPDATE acct SET bal = bal - 100 WHERE id = 1;
UPDATE acct SET bal = bal + 100 WHERE id = 2;
COMMIT;
BEGIN 과 COMMIT 사이의 두 문장이 한 묶음입니다. 둘째 문장이 실패하면 애플리케이션이 COMMIT 대신 ROLLBACK 을 보냅니다. 그러면 첫 문장까지 취소됩니다. 연결이 끊기거나 서버가 죽어 COMMIT 이 오지 않은 묶음은 데이터베이스가 스스로 되돌립니다.
동시성에서 원자성을 지키는 방법
첫째 방법은 락입니다. 락은 한 번에 한 스레드만 들어가게 문을 잠그는 장치입니다. 읽기부터 쓰기까지를 락 안에 넣으면, 그 구간은 다른 스레드가 보기에 한 번에 일어난 일이 됩니다.
이렇게 한 번에 한 스레드만 들어가야 하는 코드 구간을 임계 구역이라고 합니다.
둘째 방법은 CPU 가 원자적으로 해 주는 명령을 쓰는 것입니다. 대표가 CAS(Compare-And-Swap, 비교 후 교체)입니다. 「지금 값이 내가 읽은 값과 같을 때만 새 값으로 바꿔라」를 한 명령으로 처리합니다. 그 사이 누가 값을 바꿨으면 교체는 실패합니다. 그러면 코드가 값을 다시 읽고 다시 시도합니다.
언어의 표준 라이브러리는 이 명령을 감싼 도구를 줍니다. 아래는 자바의 AtomicInteger 로 앞 장면의 카운터를 다시 짠 것입니다. 스레드 둘이 각자 1000번씩 올립니다.
AtomicInteger c = new AtomicInteger();
Runnable job = () -> {
for (int i = 0; i < 1000; i++)
c.incrementAndGet();
};
Thread a = new Thread(job);
Thread b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
c.get(); // 2000
평범한 int 에 count++ 를 썼다면 2000보다 작은 값이 나올 수 있습니다. incrementAndGet 은 읽기와 쓰기를 CAS 로 묶었기 때문에 갱신을 잃지 않습니다.
원자성이 약속하지 않는 것
원자성은 「되거나 안 되거나」만 책임집니다. 트랜잭션에는 이것 말고도 지켜야 할 성질이 몇 있습니다. 아래 표는 원자성과 자주 헷갈리는 성질이 각각 어떤 질문에 답하는지 모은 것입니다.
| 성질 | 답하는 질문 |
|---|---|
| 원자성 | 도중에 멈추면 반쯤 된 결과가 남나 |
| 격리성 | 동시에 도는 트랜잭션끼리 중간 결과가 보이나 |
| 일관성 | 끝난 결과가 규칙을 지키나 |
| 지속성 | 끝난 결과가 장애 뒤에도 남나 |
| 멱등성 | 같은 요청을 되풀이하면 결과가 바뀌나 |
표의 위 네 줄은 트랜잭션이 함께 지키는 성질입니다. 이 넷을 줄여 ACID(Atomicity · Consistency · Isolation · Durability, 원자성 · 일관성 · 격리성 · 지속성)라고 부릅니다. 원자성은 그 첫 글자입니다.
격리성은 진행 중인 트랜잭션끼리 서로의 중간 결과를 얼마나 볼 수 있는지를 정합니다. 원자성은 끝났을 때의 모습만 약속합니다. 진행 중에 무엇이 보이는지는 격리성이 맡습니다.
일관성은 값이 규칙에 맞는지를 다룹니다. 잔액이 음수가 되면 안 된다는 규칙이 그 예입니다. 이 규칙을 지키는 일은 원자성의 몫이 아닙니다.
지속성은 커밋한 결과가 전원이 나가도 남는지를 다룹니다. 원자성은 커밋 전에 멈춘 묶음을 다룹니다. 지속성은 커밋 뒤의 결과를 다룹니다.
멱등성은 같은 요청을 여러 번 해도 결과가 한 번 한 것과 같다는 성질입니다. 원자적인 이체도 두 번 보내면 두 번 빠집니다. 재시도를 안전하게 하려면 원자성과 별도로 멱등성을 챙겨야 합니다.
여러 저장소에 걸친 원자성
한 데이터베이스 안에서는 원자성을 데이터베이스가 지켜 줍니다. 일이 데이터베이스 두 개나 서비스 여러 개에 걸치면 사정이 다릅니다. 한쪽은 커밋하고 다른 쪽은 실패하는 반쪽 결과가 다시 생길 수 있습니다.
이 문제를 푸는 고전적인 방법이 2단계 커밋입니다. 조정자가 참여자 모두에게 「커밋할 준비가 됐나」를 먼저 묻고, 전원이 예라고 답할 때만 커밋을 지시합니다. 대가는 둘입니다. 왕복이 한 번 더 늡니다. 참여자가 막힐 수도 있습니다. 조정자가 도중에 멈추면 참여자들이 결론을 기다리며 묶여 있게 됩니다.
그래서 분산 시스템에서는 원자성을 포기하고 다른 길을 고르기도 합니다. 단계마다 따로 커밋하고, 뒤 단계가 실패하면 앞 단계를 취소하는 작업을 따로 실행하는 식입니다. 그 취소 작업을 보상 트랜잭션이라고 합니다.
파일 하나를 원자적으로 바꾸기
데이터베이스가 없는 코드에서도 같은 문제가 생깁니다. 설정 파일을 덮어쓰다가 프로세스가 죽으면, 앞쪽 절반만 새 내용인 파일이 남습니다. 다음에 그 파일을 읽는 프로그램은 깨진 내용을 받습니다.
흔히 쓰는 방법은 새 내용을 임시 파일에 다 쓴 뒤 원래 이름으로 바꾸는 것입니다. 리눅스와 맥은 POSIX(Portable Operating System Interface, 이식 가능 운영체제 인터페이스)라는 공통 규격을 따릅니다. 이런 운영체제에서는 같은 파일 시스템 안에서 rename 으로 이름을 바꾸는 일이 원자적입니다. 읽는 쪽은 옛 파일 전체나 새 파일 전체 중 하나만 보게 됩니다.
언제 신경 쓰나
여러 곳을 함께 바꿔야 의미가 맞는 일이면 원자성을 챙깁니다. 주문을 만들면서 재고를 줄이는 일, 이체, 게시글과 첨부 목록을 같이 저장하는 일이 그렇습니다. 하나만 바뀌어도 되는 쓰기라면 묶을 필요가 없습니다.
묶음은 짧게 둡니다. 묶음이 길수록 되돌릴 준비와 잠금이 오래 이어집니다. 다른 요청은 그만큼 기다립니다. 메일 발송이나 다른 서비스 호출처럼 되돌릴 수 없는 일은 묶음 안에 넣어도 롤백되지 않는다는 점도 기억해야 합니다.
관련 항목
원자성을 한 글자로 품는 성질 묶음
원자성과 헷갈리는 이웃 성질
멱등성 · 격리 수준 · 선형화 가능성 · 직렬화 가능성 · 최종 일관성
트랜잭션에서 원자성을 지키는 장치
커밋 · 롤백 · WAL · 언두 로그 · 리두 로그 · 체크포인트 · 세이브포인트 · 장애 복구
동시성에서 원자성을 지키는 장치
락 · 뮤텍스 · 임계 구역 · CAS · AtomicInteger · 락 프리 · 스핀락
원자성이 깨질 때 나는 오류
경쟁 상태 · 갱신 손실 · 부분 실패 · 더티 리드 · 찢어진 쓰기
여러 저장소에 걸쳐 원자성을 다루는 방법
2단계 커밋 · 3단계 커밋 · 사가 · 보상 트랜잭션 · 트랜잭셔널 아웃박스 · 분산 트랜잭션 · 분산 시스템
원자성이 쓰이는 실행 환경
다른 이름: atomicity · 원자적 · 원자적 연산