데이터베이스는 상태가 사는 곳이다 — 트랜잭션, 잠금, 마이그레이션
백엔드 지식 지도 2편. 동시에 두 요청이 같은 데이터를 만질 때 데이터베이스가 무엇을 보장하고, 무엇은 애플리케이션이 책임져야 하는지 정리한다.
Series
백엔드 지식 지도백엔드에서 가장 실무 효과가 큰 지식을 하나만 고르라면 나는 데이터베이스를 고르겠다.
이유는 단순하다.
상태가 거기에 살기 때문이다.
요청은 지나가고, 프로세스는 재시작되고, 큐의 잡은 성공하거나 실패한다. 하지만 서비스가 기억해야 하는 것은 결국 데이터베이스에 남는다. 그래서 백엔드 장애의 상당수는 마지막에 데이터베이스 문제처럼 보인다.
- 동시에 두 요청이 같은 행을 바꾼다.
- 트랜잭션이 오래 열려 있다.
- 락이 풀리지 않는다.
- 마이그레이션이 운영 쿼리를 막는다.
- 인덱스를 추가했는데 배포 중 장애가 난다.
- 외부 API 호출을 트랜잭션 안에서 기다린다.
데이터베이스를 공부할 때 핵심 질문은 이것이다.
동시에 두 요청이 같은 데이터를 만지면 어떻게 되는가?
이 질문에 답하려면 트랜잭션, 격리 수준, MVCC, 행 잠금, 교착, 인덱스, 실행 계획, 스키마 마이그레이션을 하나의 흐름으로 봐야 한다.
ACID에서 매일 부딪히는 것은 격리성이다
트랜잭션을 설명할 때 보통 ACID부터 나온다.
- Atomicity: 전부 성공하거나 전부 실패한다.
- Consistency: 데이터베이스의 제약을 깨지 않는다.
- Isolation: 동시에 실행되는 트랜잭션이 서로 어떻게 보이는지 정한다.
- Durability: 커밋된 데이터는 남는다.
네 가지 모두 중요하지만, 실무에서 매일 부딪히는 것은 대체로 Isolation, 즉 격리성이다.
원자성과 지속성은 보통 데이터베이스가 잘 처리해 준다. 물론 디스크, WAL, replication 같은 깊은 주제로 들어가면 복잡하지만 일반적인 애플리케이션 개발자가 매일 직접 만지는 영역은 아니다.
반면 격리성은 매일 만진다.
두 사용자가 동시에 같은 쿠폰을 쓰면 어떻게 되는가. 재고가 하나 남았는데 주문 두 개가 동시에 들어오면 어떻게 되는가. 같은 계좌에서 동시에 출금하면 어떻게 되는가. 같은 row를 읽고 계산한 뒤 업데이트하는 요청이 동시에 오면 어떻게 되는가.
이런 문제는 프레임워크가 대신 해결해 주지 않는다. 데이터베이스가 제공하는 격리 수준과 잠금을 이해하고, 애플리케이션이 그 위에서 안전한 순서를 만들어야 한다.
READ COMMITTED는 생각보다 많은 것을 허용한다
많은 PostgreSQL 서비스의 기본 격리 수준은 READ COMMITTED다.
이 이름은 꽤 안심되는 느낌을 준다. 커밋된 데이터만 읽는다는 뜻이니까 안전해 보인다. 하지만 READ COMMITTED는 “내 트랜잭션 안에서 같은 SELECT를 두 번 하면 같은 결과가 나온다”를 보장하지 않는다.
다른 트랜잭션이 중간에 커밋하면, 다음 쿼리에서는 다른 결과가 보일 수 있다.
T1: SELECT balance FROM accounts WHERE id = 1; -- 100
T2: UPDATE accounts SET balance = 50 WHERE id = 1;
T2: COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1; -- 50READ COMMITTED에서는 자연스러운 동작이다.
문제는 애플리케이션 코드가 은근히 “한 요청 안에서 내가 본 세상은 고정되어 있다”고 가정한다는 점이다. 실제로는 그렇지 않다. 트랜잭션 안에서도 쿼리마다 새로 커밋된 세계를 볼 수 있다.
격리 수준을 올리면 더 많은 이상 현상을 막을 수 있다. REPEATABLE READ는 한 트랜잭션 안의 스냅샷을 더 강하게 유지하고, SERIALIZABLE은 동시 실행 결과가 어떤 직렬 실행 순서와 같도록 보장하려고 한다.
하지만 격리 수준을 올리면 비용도 온다.
성능 비용도 있지만 더 중요한 것은 재시도 책임이다. SERIALIZABLE에서 데이터베이스가 “이 실행은 직렬화할 수 없다”고 판단하면 한쪽 트랜잭션을 실패시킬 수 있다. 그러면 애플리케이션은 그 트랜잭션을 다시 실행해야 한다.
즉, 격리 수준은 마법의 안전 버튼이 아니다.
더 강한 보장을 사면, 충돌이 났을 때 다시 시도하는 책임도 같이 산다.
MVCC: 읽기가 쓰기를 막지 않는 대신 청소가 필요하다
PostgreSQL을 이해할 때 MVCC는 꼭 지나가야 한다.
MVCC는 Multi-Version Concurrency Control의 약자다. 말 그대로 같은 데이터의 여러 버전을 두고 동시성을 처리한다.
핵심은 이렇다.
쓰기가 옛 버전을 남기기 때문에, 읽기가 쓰기를 막지 않는다.
어떤 row가 업데이트되면 기존 row를 제자리에서 덮어쓰는 것이 아니라 새 버전을 만든다. 오래된 트랜잭션은 자신이 볼 수 있는 옛 버전을 보고, 새 트랜잭션은 새 버전을 본다.
이 구조 덕분에 읽기와 쓰기가 서로 덜 막힌다.
하지만 대가가 있다.
옛 버전이 남는다. 이제 필요 없어진 row 버전을 청소해야 한다. PostgreSQL에서는 이 일을 VACUUM이 한다.
여기서 중요한 문제가 하나 나온다.
오래 열린 트랜잭션이 있으면 VACUUM이 죽은 튜플을 치우지 못한다. 그 트랜잭션 입장에서는 아직 옛 버전이 필요할 수 있기 때문이다.
그래서 idle in transaction이 위험하다.
트랜잭션을 열어 놓고 아무것도 하지 않는 상태는 단순히 커넥션 하나를 낭비하는 문제가 아니다. 잠금을 쥐고 있을 수 있고, VACUUM을 막을 수 있고, 테이블과 인덱스 bloat를 키울 수 있다.
데이터베이스에서 “가만히 있음”은 가만히 있는 것이 아닐 때가 많다.
행 잠금은 강도와 호환 관계로 봐야 한다
동시성 문제를 다룰 때 가장 흔한 도구는 행 잠금이다.
PostgreSQL에는 대표적으로 네 가지 행 잠금 강도가 있다.
FOR KEY SHARE
FOR SHARE
FOR NO KEY UPDATE
FOR UPDATE오른쪽으로 갈수록 강하다. 강한 잠금은 더 많은 작업을 막는다. 그래서 무조건 강하게 잡는 것이 능사는 아니다. 필요한 최소 강도를 고르는 것이 실력이다.
하지만 최소 강도만 생각하면 함정에 빠질 수 있다.
예를 들어 같은 행에 두 트랜잭션이 동시에 FOR KEY SHARE를 얻었다고 해 보자. FOR KEY SHARE끼리는 서로 호환되기 때문에 둘 다 성공한다.
그런데 둘 다 나중에 그 행을 UPDATE하려고 하면 어떻게 될까?
UPDATE에는 더 강한 잠금이 필요하다. 즉, 약한 잠금을 강한 잠금으로 올려야 한다. 이때 각 트랜잭션은 상대가 쥔 FOR KEY SHARE가 풀리기를 기다린다.
서로가 서로를 기다린다.
교착이다.
이 사례가 보여 주는 교훈은 간단하다.
나중에 쓸 거라면 처음부터 최종 강도로 잡아야 한다.
처음에는 약하게 잡아 두고, 나중에 필요해지면 올리면 되겠지라고 생각하기 쉽다. 하지만 동시성이 있는 시스템에서는 그 “나중에 올리는 순간”이 교착의 입구가 된다.
FK가 있는 INSERT도 잠금을 잡는다
많이 놓치는 지점이 있다.
INSERT는 그냥 새 row를 넣는 작업이니까 기존 row를 막지 않을 것처럼 보인다. 하지만 외래키가 있으면 이야기가 달라진다.
자식 테이블에 row를 INSERT할 때, 데이터베이스는 부모 row가 실제로 존재하고 사라지지 않아야 한다는 것을 보장해야 한다. 그래서 PostgreSQL은 부모 행에 FOR KEY SHARE 잠금을 건다.
즉, FK가 있는 스키마에서는 INSERT도 잠금을 잡는다.
이걸 모르면 이런 상황을 이해하기 어렵다.
T1: 부모 row를 SELECT ... FOR UPDATE로 잡음
T2: 그 부모를 참조하는 자식 row INSERT
T2: 대기T2는 새 row를 넣을 뿐인데 왜 기다릴까?
부모 row의 key가 바뀌거나 삭제되면 안 되기 때문이다. T1이 FOR UPDATE로 부모 row를 강하게 잡고 있으면, 자식 INSERT가 필요한 FOR KEY SHARE와 충돌한다.
이런 문제는 “우리는 UPDATE가 아니라 INSERT만 하는데요”라는 말 뒤에 숨어 있다.
데이터베이스에서 쓰기 작업의 영향 범위는 내가 직접 바꾸는 테이블 하나로 끝나지 않는다. 제약 조건, FK, 인덱스, 트리거가 함께 움직인다.
외부 호출을 트랜잭션 안에 넣지 말자
실무에서 가장 자주 보이는 안티패턴 중 하나는 외부 I/O를 트랜잭션 안에서 기다리는 것이다.
트랜잭션 시작
→ row 조회 및 잠금
→ 외부 API 호출
→ 응답 대기
→ row 업데이트
→ 커밋처음 보면 자연스럽다. 어떤 데이터를 읽고, 외부 서비스를 호출하고, 결과를 저장한다. 하나의 비즈니스 흐름이니까 하나의 트랜잭션으로 묶고 싶어진다.
하지만 이 구조는 위험하다.
첫째, 외부 API가 느린 동안 잠금을 계속 쥔다. 같은 데이터를 만지는 다른 요청이 전부 기다린다.
둘째, DB 커넥션을 외부 응답 시간만큼 점유한다. 외부 서비스 하나가 느려졌을 뿐인데 애플리케이션의 DB 커넥션 풀이 고갈될 수 있다.
셋째, 장애의 원인과 증상이 멀어진다. 원인은 외부 API 지연인데, 사용자는 DB 저장 실패나 커넥션 부족을 본다.
더 나은 흐름은 대체로 이렇다.
필요한 입력을 읽는다
→ 커밋한다
→ 외부 API를 호출한다
→ 같은 순서로 다시 잠근다
→ 세상이 바뀌었는지 확인한다
→ 저장하고 커밋한다물론 이 방식은 더 번거롭다. 외부 호출을 하는 동안 데이터가 바뀌었을 수 있기 때문에 다시 잠근 뒤 검증해야 한다.
하지만 그 번거로움이 낫다. 트랜잭션은 짧게 유지하는 편이 대부분의 운영 문제를 줄인다.
트랜잭션은 비즈니스 플로우 전체를 감싸는 도구가 아니다. 데이터베이스 안에서 원자적으로 보장해야 하는 최소 구간을 감싸는 도구에 가깝다.
DDL은 짧아 보여도 운영 쿼리를 막을 수 있다
운영 DB에서 스키마를 바꾸는 일은 항상 조심해야 한다.
특히 DDL은 잠금을 잡는다.
ALTER TABLE, CREATE INDEX, 제약 추가 같은 작업은 테이블에 잠금을 요구한다. 더 무서운 것은 DDL이 실제로 실행되는 순간만이 아니다. 잠금을 기다리는 동안도 문제가 생길 수 있다.
예를 들어 어떤 긴 트랜잭션이 테이블을 사용 중이다. 그 뒤에 ALTER TABLE이 들어와 잠금을 기다린다. 그리고 그 뒤로 평범한 SELECT나 UPDATE들이 줄을 선다.
DDL은 아직 실행되지도 않았다. 그런데 뒤에 들어온 쿼리들이 함께 막힌다.
이게 짧은 마이그레이션 하나가 전면 장애가 되는 고전적인 경로다.
그래서 운영 DDL에는 짧은 lock_timeout을 거는 편이 좋다. 잠금을 바로 못 얻으면 실패시키고, 안전한 타이밍에 다시 시도하는 것이다.
인덱스도 마찬가지다. PostgreSQL에서는 CREATE INDEX CONCURRENTLY를 쓰면 쓰기 차단을 줄일 수 있다. 하지만 공짜는 아니다. 더 오래 걸리고, 스캔을 두 번 하며, 실패하면 invalid 인덱스가 남아 수동 정리가 필요할 수 있다.
운영 마이그레이션의 핵심은 “내 SQL이 맞는가”가 아니다.
이 변경이 지금 돌고 있는 코드와 동시에 존재해도 안전한가?
이 질문이다.
롤링 배포에서는 옛 코드와 새 코드가 같은 DB를 본다
스키마 마이그레이션이 어려운 이유는 배포가 한순간에 끝나지 않기 때문이다.
롤링 배포 중에는 옛 코드와 새 코드가 동시에 돈다. 그리고 둘은 같은 데이터베이스를 본다.
그래서 이런 변경은 위험하다.
- 컬럼 삭제
- 컬럼 rename
- 타입 축소
- 갑작스러운
NOT NULL추가 - 기존 데이터와 맞지 않는 제약 추가
새 코드 기준으로는 맞는 변경일 수 있다. 하지만 아직 살아 있는 옛 코드가 그 컬럼을 읽거나 쓰고 있다면 바로 깨진다.
그래서 무중단 스키마 변경은 보통 확장-수축 패턴으로 한다.
1. 새 컬럼을 nullable로 추가한다
2. 옛 코드와 새 코드가 모두 깨지지 않게 양쪽 쓰기를 한다
3. 기존 데이터를 백필한다
4. 읽기 경로를 새 컬럼으로 전환한다
5. 더 이상 옛 코드가 없음을 확인한다
6. 옛 컬럼을 제거한다한 번에 rename하지 않고, 추가하고, 같이 쓰고, 옮기고, 나중에 지운다.
느리고 귀찮아 보이지만 운영 서비스에서는 이 방식이 훨씬 싸다. 장애가 난 뒤 롤백하는 비용보다 단계적으로 옮기는 비용이 작다.
인덱스와 실행 계획은 “왜 느린가”를 말해 준다
데이터베이스 공부를 하면 인덱스 이야기를 피할 수 없다.
B-tree 인덱스, 복합 인덱스, 선택도, 함수 인덱스, covering index 같은 용어가 나온다. 하지만 처음부터 모든 인덱스 종류를 외우는 것보다 중요한 습관이 있다.
EXPLAIN ANALYZE를 보고 추정 행 수와 실제 행 수의 차이를 보는 것이다.
쿼리 플래너는 통계를 바탕으로 “이 조건이면 몇 행쯤 나오겠지”라고 추정한다. 그 추정이 크게 틀리면 잘못된 계획을 고를 수 있다.
예를 들어 실제로는 대부분의 행이 조건에 맞는데 플래너가 아주 적은 행만 나온다고 생각하면, 인덱스를 타고 많은 row를 랜덤 접근하는 비싼 계획을 고를 수 있다. 반대로 실제로는 적은 행만 나오는데 플래너가 많다고 생각하면 seq scan을 고를 수 있다.
여기서 중요한 점이 하나 있다.
Seq Scan은 항상 나쁜 것이 아니다.
작은 테이블이거나, 어차피 대부분의 행을 읽어야 한다면 순차 스캔이 더 빠를 수 있다. “인덱스를 안 타서 문제”라고 단정하기 전에, 왜 플래너가 그 계획을 골랐는지를 봐야 한다.
ORM은 쓰기 시점을 숨긴다
ORM을 쓰면 생산성이 올라간다. 하지만 동시에 데이터베이스의 실제 동작이 가려진다.
대표적인 함정은 N+1 쿼리다. 목록을 한 번 조회했다고 생각했는데, 각 row의 연관 객체를 접근할 때마다 추가 쿼리가 나간다.
또 하나는 lazy loading이 세션 밖에서 터지는 문제다. 객체는 들고 있지만, 필요한 관계 데이터는 아직 로딩되지 않았고, 세션은 이미 닫힌 상태다.
더 미묘한 문제는 autoflush다.
일부 ORM은 객체 속성을 바꾼 뒤 다음 조회를 실행할 때, 조회 전에 변경분을 먼저 flush한다. 개발자는 단순히 파이썬 속성을 대입했다고 생각하지만, 뒤이은 SELECT 전에 UPDATE가 나가고 row 잠금을 잡을 수 있다.
즉, 코드에서 “대입한 시점”과 데이터베이스에 “쓰기 나간 시점”이 다를 수 있다.
ORM을 잘 쓰려면 ORM을 믿지 말아야 한다는 뜻이 아니다. ORM이 생성하는 SQL과 트랜잭션 경계를 볼 수 있어야 한다는 뜻이다.
자가진단 질문
데이터베이스를 어느 정도 알고 있는지 확인하려면 다음 질문에 답해 보면 된다.
- 같은 row에 두 세션이
FOR KEY SHARE를 동시에 얻은 뒤 각자 UPDATE하면 어떻게 되는가? - 외부 API 호출을 트랜잭션 안에 두면 무엇이 나빠지는가?
- 운영 테이블에 인덱스를 추가할 때 왜
lock_timeout을 걸어야 하는가? - 롤링 배포 중 안전한 스키마 변경과 위험한 변경을 가르는 기준은 무엇인가?
idle in transaction이 단순히 커넥션 하나를 잡아먹는 것보다 더 위험한 이유는 무엇인가?
이 질문들에 말로 답할 수 있으면 데이터베이스를 “사용하는 단계”에서 “운영 중 문제를 해석하는 단계”로 넘어가기 시작한 것이다.
무엇부터 깊게 파야 하나
데이터베이스 영역에서 우선순위를 세 개만 고르면 이렇다.
첫째, 잠금 강도와 호환 관계다.
동시성 버그의 상당수는 여기서 나온다. 중요한 것은 표를 외우는 것이 아니라 “왜 이 작업이 이 강도의 잠금을 요구하는가”를 이해하는 것이다.
둘째, 트랜잭션 경계 설계다.
어디서 트랜잭션을 열고 어디서 닫는지에 따라 장애의 모양이 달라진다. 외부 I/O를 트랜잭션 밖으로 빼는 규율 하나만으로도 많은 장애를 줄일 수 있다.
셋째, 확장-수축 마이그레이션이다.
무중단 배포를 하는 순간 필수가 된다. 운영 서비스의 스키마 변경은 SQL 한 줄의 문제가 아니라, 옛 코드와 새 코드가 같은 DB를 보는 시간 구간을 설계하는 문제다.
더 읽을 것
이 주제는 공식 문서를 직접 보는 편이 좋다. 특히 PostgreSQL을 쓴다면 13장 Concurrency Control은 짧고 밀도가 높다.
- PostgreSQL 문서 13장: Concurrency Control
- PostgreSQL 문서 13.2: Transaction Isolation
- PostgreSQL 문서 13.3: Explicit Locking
- Egor Rogov, PostgreSQL 14 Internals
- Hermitage: 격리 수준별 이상 현상 재현
- Use The Index, Luke!
마무리
데이터베이스는 단순한 저장소가 아니다.
데이터베이스는 상태가 사는 곳이고, 동시에 여러 요청이 그 상태를 만질 때 질서를 만들어 주는 장치다. 하지만 모든 질서를 자동으로 만들어 주지는 않는다.
READ COMMITTED는 생각보다 많은 동시성 현상을 허용한다. MVCC는 읽기와 쓰기를 덜 막아 주지만 오래 열린 트랜잭션과 bloat라는 대가를 만든다. 행 잠금은 강도와 호환 관계를 이해해야 하고, FK가 있는 INSERT도 잠금을 잡을 수 있다. 운영 DDL은 실행 시간보다 잠금 대기가 더 위험할 수 있고, 롤링 배포에서는 옛 코드와 새 코드가 같은 DB를 동시에 본다.
그래서 데이터베이스를 공부한다는 것은 SQL 문법을 더 많이 아는 일이 아니다.
동시에 일어나는 일을 어떤 순서로 보이게 만들 것인가.
이 질문에 답할 수 있게 되는 것이다.
다음 편에서는 이 관점을 네트워크 너머로 확장한다. 주제는 분산 시스템이다.
핵심 질문은 이것이다.
응답이 없다는 사실만으로는 왜 아무것도 추론할 수 없는가.