동시성을 공부하면 원자성, 가시성, 격리성, 일관성, 무결성, race condition이 한꺼번에 등장한다. 비슷해 보이는 이유는 같은 문제가 아니라 서로 다른 계층에서 같은 단어를 다시 사용하기 때문이다.
특히 원자성은 CPU·언어의 연산과 데이터베이스 transaction에서 모두 쓰이고, 일관성은 memory model·ACID·CAP에서 서로 다른 뜻을 갖는다. 먼저 어느 계층을 말하는지 붙여야 오해가 줄어든다.
한 표로 먼저 구분하면
| 용어 | 주로 묻는 질문 | 계층·문맥 |
|---|---|---|
| 연산의 원자성 | 다른 실행이 중간 상태를 관측하거나 끼어들 수 있는가 | CPU 명령, 언어·동시성 primitive |
| transaction atomicity | 여러 변경이 전부 반영되거나 전부 취소되는가 | 데이터베이스 |
| 가시성 | 한 thread의 write를 다른 thread가 언제 볼 수 있는가 | language memory model |
| ordering·memory consistency | 여러 read/write가 어떤 순서로 관측될 수 있는가 | compiler, CPU, language memory model |
| transaction isolation | concurrent transaction 결과가 어느 정도 분리되는가 | 데이터베이스 |
| 무결성·ACID consistency | 유효한 상태와 제약을 transaction 전후에 지키는가 | schema·domain rule |
| race condition | 실행 timing·순서에 따라 결과가 달라지는가 | 동시 시스템 전반 |
“원자성은 CPU, 격리성은 DB”처럼 칸을 완전히 나누기도 어렵다. 데이터베이스의 ACID에도 atomicity가 있고, Java의 lock도 여러 연산을 하나의 critical section으로 묶는다.
lost update는 한 줄이 여러 동작이어서 생긴다
잔액이 1,000이고 두 thread가 동시에 100씩 차감한다고 하자.
balance = 1000
def withdraw():
global balance
balance = balance - 100
소스 코드 한 줄은 논리적으로 read, calculate, write로 나뉜다.
Thread A: read 1000
Thread B: read 1000
Thread A: write 900
Thread B: write 900
두 번 성공했다면 기대값은 800인데 결과는 900이다. 이를 lost update라고 부른다. 문제는 “CPU가 반드시 정확히 세 개의 명령어를 실행한다”는 데 있지 않다. 언어와 runtime이 이 read-modify-write 전체를 원자적으로 보장하지 않는다는 데 있다. 실제 기계어와 최적화는 환경마다 달라질 수 있다.
lock이나 compare-and-set loop, 언어가 제공하는 atomic primitive로 필요한 범위를 보호할 수 있다. 다만 개별 연산 하나를 atomic으로 바꿨다고 전체 business operation이 원자적이 되는 것은 아니다.
check와 update를 함께 묶어야 하는 이유
if balance >= amount:
balance -= amount
balance -= amount만 atomic이어도 두 thread가 먼저 같은 잔액을 읽고 조건을 통과할 수 있다. 잔액이 1,000일 때 두 요청이 각각 700을 출금하면 atomic decrement 두 번으로 -400이 될 수 있다.
보호해야 하는 단위는 “감산” 하나가 아니라 다음 불변조건을 확인하고 상태를 바꾸는 전체 과정이다.
check(balance >= amount) + update(balance) + record(audit)
한 프로세스 안에서는 lock이나 CAS loop를, 여러 프로세스가 같은 데이터베이스를 갱신한다면 조건부 UPDATE, row lock, optimistic locking, 적절한 isolation level 등을 검토한다. 해결책은 공유 상태의 위치와 충돌 범위에 따라 달라진다.
Java의 가시성은 물리 캐시 그림만으로 설명하지 않는다
한 thread가 값을 썼는데 다른 thread가 이전 값을 계속 읽을 수 있는 문제를 흔히 “코어별 cache가 달라서”라고 설명한다. 직관에는 도움이 되지만 Java 프로그램의 보장은 물리 장치가 아니라 Java Memory Model로 판단해야 한다.
Java Language Specification 17장은 두 conflicting access가 happens-before 관계로 정렬되지 않으면 data race가 있다고 정의한다. 대표적인 happens-before edge는 다음과 같다.
- monitor unlock은 같은 monitor의 이후 lock보다 먼저 일어난다.
volatilefield write는 같은 field의 이후 read보다 먼저 일어난다.Thread.start()는 시작된 thread의 action보다 먼저 일어난다.- thread의 action은 다른 thread가
join()에서 돌아오기보다 먼저 일어난다.
final class Status {
private volatile boolean ready;
private int value;
void publish(int next) {
value = next;
ready = true;
}
int readWhenReady() {
if (!ready) {
throw new IllegalStateException("not ready");
}
return value;
}
}
같은 객체가 적절히 공유된다는 전제에서 ready의 volatile write와 이후 read가 앞선 value write의 가시성을 연결한다. 그러나 volatile int count에 대한 count++ 전체가 atomic이 되는 것은 아니다. read-modify-write에는 AtomicInteger, lock 등 별도 수단이 필요하다.
CPU·메모리·thread의 물리적 관계는 하드웨어 직관을 얻는 데 도움이 되지만, Java 코드의 정확성은 JMM의 보장으로 다시 확인해야 한다.
memory ordering과 consistency는 “모두 같은 순서”보다 넓다
compiler와 CPU는 single-thread 결과를 보존하는 범위에서 명령을 재배치할 수 있고, memory model은 여러 thread가 허용된 실행을 어떻게 관측하는지 정한다. 따라서 다음 코드에서 동기화 없이 ready == true를 봤다고 value의 새 값까지 반드시 보인다고 가정할 수 없다.
int value = 0;
boolean ready = false;
// writer
value = 42;
ready = true;
// reader
if (ready) {
System.out.println(value);
}
여기서 문제를 “CPU가 두 줄을 바꿔 실행했다” 하나로 단정하지 않는다. compiler 최적화, register 사용, cache coherence, architecture memory ordering을 language memory model이 추상화한다. synchronized, volatile, lock, concurrent collection 같은 primitive가 만드는 happens-before를 기준으로 reasoning한다.
데이터베이스 atomicity와 isolation은 서로 다른 축이다
데이터베이스 transaction의 atomicity는 여러 statement가 모두 반영되거나 모두 취소되는 성질이다.
BEGIN;
UPDATE account
SET balance = balance - 100
WHERE id = 1;
UPDATE account
SET balance = balance + 100
WHERE id = 2;
COMMIT;
두 번째 update 전에 실패하면 첫 번째도 남지 않아야 한다. PostgreSQL의 transaction tutorial은 이를 all-or-nothing operation으로 설명한다.
Isolation은 여러 transaction이 동시에 실행될 때 허용할 관측과 anomaly의 범위다. “다른 transaction의 중간 상태를 절대 못 본다”만으로 모든 level을 설명할 수 없다. PostgreSQL transaction isolation 문서처럼 Read Committed, Repeatable Read, Serializable은 dirty read, nonrepeatable read, phantom read, serialization anomaly에 대해 서로 다른 보장을 제공한다.
Serializable도 공짜가 아니다. 충돌하면 transaction이 serialization failure로 중단될 수 있으므로 애플리케이션에 전체 transaction retry가 필요하다.
무결성과 ACID consistency는 규칙을 누가 정의하는지가 중요하다
잔액이 음수가 아니어야 한다거나 이메일이 유일해야 한다는 것은 데이터의 유효한 상태를 정의하는 규칙이다. 데이터베이스는 CHECK, UNIQUE, FOREIGN KEY, NOT NULL 같은 제약으로 일부를 강제할 수 있다.
CREATE TABLE account (
id bigint PRIMARY KEY,
balance numeric NOT NULL CHECK (balance >= 0)
);
그러나 business rule을 애플리케이션의 if에만 두고 concurrent write를 막지 않으면 check와 update 사이에 상태가 바뀔 수 있다. 가능한 규칙은 데이터베이스 제약으로 가까이 옮기고, 여러 row·외부 시스템에 걸친 규칙은 transaction과 idempotency, 보상 절차까지 설계한다.
여기서 ACID의 consistency와 CAP의 consistency는 같은 단어지만 다른 개념이다. CAP에서는 partition 중 linearizability와 availability의 상충을 다룬다. 이 차이는 CAP 정리 정확히 읽기에서 별도로 설명했다.
race condition과 data race도 같지 않다
race condition은 실행 timing이나 순서에 따라 프로그램의 결과가 달라지는 넓은 표현이다. data race는 언어 memory model에서 정의한 더 좁은 조건일 수 있다. Java에서는 같은 variable에 대한 conflicting access 중 적어도 하나가 write이고, happens-before로 정렬되지 않을 때 data race다.
모든 race condition이 단순 공유 변수 data race인 것은 아니다. 예를 들어 “파일이 없음을 확인한 뒤 생성”하는 두 프로세스의 TOCTOU 문제는 운영체제 자원에 대한 race다. 반대로 의도적으로 경쟁해 먼저 끝난 결과를 채택하는 코드는 timing에 의존해도 버그가 아닐 수 있다.
문제를 만났을 때의 확인 순서
- 공유되는 상태와 소유자를 적는다.
- 지켜야 할 invariant를 한 문장으로 쓴다.
- read-check-write 중 어디가 나뉘는지 찾는다.
- 언어 memory model의 happens-before가 있는지 확인한다.
- 데이터베이스 transaction과 isolation level의 실제 보장을 확인한다.
- timeout·deadlock·serialization failure의 retry가 안전한지 본다.
- lock 범위를 넓혔을 때 contention과 latency를 측정한다.
동시성에서 정확성과 성능은 따로 떼기 어렵다. lock을 줄이기 전에 correctness condition을 세우고, 그 조건을 보존하는 여러 구현을 측정해야 한다. 용어를 계층별로 나누는 일은 그 출발점이다.
참고 자료
'배움과 성장 > 시스템·성능' 카테고리의 다른 글
| 리눅스 메모리 용어: RSS·PSS·malloc·mmap·fork를 계층별로 구분하기 (0) | 2026.03.30 |
|---|---|
| 사용자 공간과 커널 공간: 가상 주소가 분리되는 실제 방식 (0) | 2026.03.29 |
| CPU 레지스터·Linux task·goroutine 구분하기: 성능 용어의 층위 (0) | 2026.02.19 |
| epoll·sendfile·False Sharing·TCP BBR: 병목이 다른 성능 최적화 (0) | 2026.01.18 |
| 시스템 성능 엔지니어링 1장: 관측·실험·USE 방법론으로 읽기 (0) | 2025.12.28 |
댓글