|
1 | 1 | --- |
2 | | -title: "RDB 정규화" |
3 | | -description: "RDB 정규화 개념에 대해 설명합니다." |
4 | | -date: 2025-08-07 +00:00:00 |
5 | | -permalink: /posts/2025-08-07-db/ |
| 2 | +title: "비관적 락 vs 낙관적 락" |
| 3 | +description: "비관적 락과 낙관적 락이 뭔지와 어떻게 락을 걸 수 있는지 어떤 차이점이 있는지 알아보도록 하겠습니다." |
| 4 | +date: 2026-02-28 +00:00:00 |
| 5 | +permalink: /posts/2026-02-28-db/ |
6 | 6 | categories: [Blogging,db] |
7 | 7 | --- |
8 | 8 |
|
9 | | -관계형데이터베이스의 테이블을 효율적으로 설계하기위한 일련의 규칙, 즉 정규화를 말합니다. 데이터 중복을 최소화하고, 데이터 무결성을 향상시키며, 불필요한 데이터 변경을 방지하는것을 목표로 합니다. |
| 9 | +동시성 문제를 해결하기 위해서 DB 레벨에서 해결하는 경우가 있습니다. 비관적 락과 낙관적 락으로 해결할 수 있는 방법을 설명합니다. |
10 | 10 |
|
11 | | - |
| 11 | +> 재고가 100개인 상품을 동시에 사용자 1000명이 요청이 왔을때의 시나리오로 진행하겠습니다. |
12 | 12 |
|
13 | | -일반적으로 3NF까지 도달하면 정규화가 됐다고 하고 있습니다. 이후 단계를 하는것은 복잡성이 증가하고 비효율적일 가능성이 높습니다. |
| 13 | +## 비관적 락 |
14 | 14 |
|
15 | | -### 예제테이블 설명 |
| 15 | +비관적 락을 알려면 배타적 락과 공유 락을 먼저 이해해야합니다. |
16 | 16 |
|
17 | | -수강신청한 학생, 강의, 교수의 정보를 담은 정규화 되지 않은 테이블을 만들어 생성합니다. |
| 17 | +### 배타적 락(Exclusive Lock / X-Lock) |
| 18 | + |
| 19 | +락을 획득한 트랜잭션만 해당 데이터에 읽기/쓰기 모두 독점합니다. 다른 트랜잭션은 락이 해제될 때 까지 |
| 20 | + |
| 21 | +X-Lock, S-Lock 모두 대기합니다. |
18 | 22 |
|
19 | | -```sql |
20 | | -CREATE TABLE student_courses_0nf ( |
21 | | - student_id INT, |
22 | | - student_name VARCHAR(50), |
23 | | - student_phone VARCHAR(20), |
24 | | - student_major VARCHAR(50), |
25 | | - course_id TEXT, |
26 | | - course_name TEXT, |
27 | | - professor TEXT, |
28 | | - department TEXT, |
29 | | - professor_phone TEXT, |
30 | | - grade TEXT |
31 | | -); |
32 | 23 | ``` |
| 24 | +TX 1 ──────────────────────────────────────────────────────────▶ |
| 25 | + 🔒 X-Lock 획득 📖 재고 확인 & 사용 ✅ COMMIT (락 해제) |
| 26 | + (FOR UPDATE) |
33 | 27 |
|
34 | | - |
35 | | - |
36 | | -### 1NF: 원자값 보장 |
37 | | - |
38 | | -수강신청한 `course_id` ,`course_name` , `professor` 들이 여러개의 값을 가지고 있습니다. 1NF는 이를 원자값으로 가지게하는 작업을 말합니다. |
39 | | - |
40 | | -```sql |
41 | | -CREATE TABLE student_courses_1nf ( |
42 | | - student_id INT, |
43 | | - student_name VARCHAR(50), |
44 | | - student_phone VARCHAR(20), |
45 | | - student_major VARCHAR(50), |
46 | | - course_id VARCHAR(10), |
47 | | - course_name VARCHAR(50), |
48 | | - professor VARCHAR(50), |
49 | | - department VARCHAR(50), |
50 | | - professor_phone VARCHAR(20), |
51 | | - grade CHAR(1) |
52 | | -); |
| 28 | +TX 2 ──────────────────────────────────────────────────────────▶ |
| 29 | + ⏳ 대기중... ⏳ 대기중... 🔒 X-Lock 획득 |
| 30 | + (읽기도 차단됨) (이미 사용됨 감지) |
53 | 31 | ``` |
54 | 32 |
|
55 | | - |
| 33 | +### 공유 락(Shared Lock / S-Lock) |
| 34 | + |
| 35 | +여러 트랜잭션이 동시에 읽기는 허용하되, 누군가 읽는 동안 쓰기는 차단합니다. “읽는 동안 데이터가 바뀌지 않음”을 보장합니다. X-Lock 대기, S-Lock 허용합니다. |
| 36 | + |
| 37 | +``` |
| 38 | +TX 1 ──────────────────────────────────────────▶ |
| 39 | + 🔑 S-Lock 획득 📖 재고 읽기... |
| 40 | + (FOR SHARE) |
| 41 | +
|
| 42 | +TX 2 ──────────────────────────────────────────▶ |
| 43 | + 🔑 S-Lock 획득 ✅ 📖 재고 읽기... |
| 44 | + (동시 허용!) (TX1과 동시에 실행됨!) |
| 45 | +
|
| 46 | +TX 3 (쓰기 시도) ──────────────────────────────▶ |
| 47 | + ⏳ X-Lock 대기중... |
| 48 | + (S-Lock이 모두 해제될 때까지 쓰기 불가) |
| 49 | +``` |
| 50 | + |
| 51 | +동시성 문제를 해결하기 위해 비관적 락을 사용하면 배타적 락을 걸어 동시성 문제를 해결 할 수 있습니다. |
| 52 | + |
| 53 | +```java |
| 54 | +@Lock(LockModeType.PESSIMISTIC_WRITE) |
| 55 | +@Query("SELECT i FROM Inventory i WHERE i.product.id = :productId") |
| 56 | +Optional<Inventory> findByProductIdWithPessimisticLock(Long productId); |
| 57 | +``` |
| 58 | + |
| 59 | +### 문제점 |
| 60 | + |
| 61 | +1. 처리량 병목 |
56 | 62 |
|
57 | | -**1NF 문제점: 삽입/삭제/수정 이상** |
| 63 | + ``` |
| 64 | + 사용자 10,000명 동시 요청 |
| 65 | + ↓ |
| 66 | + [ X-Lock 대기열 ] |
| 67 | + TX1 처리중... (50ms) |
| 68 | + TX2 ⏳ 대기 |
| 69 | + TX3 ⏳ 대기 |
| 70 | + TX4 ⏳ 대기 |
| 71 | + ... |
| 72 | + TX10000 ⏳ 대기 (최대 50ms × 10,000 = 500초 대기) |
| 73 | + ``` |
58 | 74 |
|
59 | | -미적분학 김교수가 갑작스럽게 퇴직하여 다른 교수로 업데이트한다고 했을 때 강의를 듣는 김철수, 이영희 학생에 대한 ROW 2개를 업데이트를 하게됩니다. 이러한 과정에서 업데이트가 원활하게 이루어지지 않는다면 데이터 일관성이 깨지게 됩니다. |
| 75 | + 한번에 딱 1개의 트랜잭션만 재고 row에 접근 가능하기 때문에, 동시에 요청이 많을수록 마지막 트랜잭션은 대기 시간이 증가할 수 밖에 없습니다. |
60 | 76 |
|
61 | | -### 2NF: 부분 함수 종속성 제거 |
| 77 | + ``` |
| 78 | + 재고 차감 (X-Lock) 재고 조회 (S-Lock) |
| 79 | + ↓ ↓ |
| 80 | + 같은 row 접근 시도 |
| 81 | + |
| 82 | + TX_구매1: X-Lock 보유 중... |
| 83 | + TX_조회1: ⏳ S-Lock 대기 (X-Lock 때문에 읽기도 못함) |
| 84 | + TX_조회2: ⏳ S-Lock 대기 |
| 85 | + TX_조회3: ⏳ S-Lock 대기 |
| 86 | + TX_구매2: ⏳ X-Lock 대기 |
| 87 | + ``` |
62 | 88 |
|
63 | | -현재 프라임키 `student_id` , `course_id` 에는 non-attribute key들이 부분 종속되어 있는걸 확인 할 수 있습니다. |
| 89 | + 재고를 차감하는 row와 조회하는 row가 겹칠 경우에도 처리량에 문제가 될 수 있습니다. |
64 | 90 |
|
65 | | -**부분 함수 종속 관계들:** |
| 91 | +2. 데드락 |
66 | 92 |
|
67 | | -- `student_name, student_phone` → `student_id` (student_id에만 종속) |
68 | | -- `course_name, professor, department` → `course_id` (course_id에만 종속) |
| 93 | + ``` |
| 94 | + TX1: 상품A 락 획득 → 상품B 락 대기 |
| 95 | + TX2: 상품B 락 획득 → 상품A 락 대기 |
| 96 | + ``` |
69 | 97 |
|
70 | | -**완전 함수 종속:** |
| 98 | + 두 트랜잭션이 묶음으로 서로 다른 상품을 위에 시나리오 처럼 구매하는 경우 데드락이 발생할 수 있습니다. |
71 | 99 |
|
72 | | -- `grade` → `(student_id, course_id)` (복합키 전체에 종속) |
73 | 100 |
|
74 | | - |
| 101 | +## 낙관적 락 |
75 | 102 |
|
76 | | -```sql |
77 | | --- 학생 테이블 (student_id에만 종속되는 속성들) |
78 | | -CREATE TABLE students_2nf ( |
79 | | - student_id INT PRIMARY KEY, |
80 | | - student_name VARCHAR(50), |
81 | | - student_phone VARCHAR(20), |
82 | | - student_major VARCHAR(50) |
83 | | -); |
| 103 | +동시성문제를 어플리케이션에서 처리하여 데드락과 성능문제를 풀어볼 수 있습니다. |
84 | 104 |
|
85 | | --- 과목 테이블 (course_id에만 종속되는 속성들) |
86 | | -CREATE TABLE courses_2nf ( |
87 | | - course_id VARCHAR(10) PRIMARY KEY, |
88 | | - course_name VARCHAR(50), |
89 | | - professor VARCHAR(50), |
90 | | - department VARCHAR(50), |
91 | | - professor_phone VARCHAR(20) |
92 | | -); |
| 105 | +row별로 버전 컬럼을 사용하여 동시성 문제를 해결합니다. |
93 | 106 |
|
94 | | --- 수강 성적 테이블 (프라임키 전체에 종속되는 속성들) |
95 | | -CREATE TABLE enrollments_2nf ( |
96 | | - student_id INT, |
97 | | - course_id VARCHAR(10), |
98 | | - grade CHAR(1), |
99 | | - PRIMARY KEY (student_id, course_id), |
100 | | - FOREIGN KEY (student_id) REFERENCES students_2nf(student_id), |
101 | | - FOREIGN KEY (course_id) REFERENCES courses_2nf(course_id) |
102 | | -); |
| 107 | +{% tabs log %} |
| 108 | +
|
| 109 | +{% tab log 낙관적 락 흐름 %} |
| 110 | +``` |
| 111 | +🔓 DB 락 없이 조회 → ✏️ @Version으로 체크 → 💥 충돌 시 예외 → 🔄 앱에서 재시도 |
| 112 | +``` |
| 113 | +{% endtab %} |
| 114 | +
|
| 115 | +{% tab log Entity %} |
| 116 | +```java |
| 117 | +public class Inventory { |
| 118 | + ... |
| 119 | +
|
| 120 | + @Version |
| 121 | + @Column(nullable = false) |
| 122 | + @Builder.Default |
| 123 | + private Long version = 0L; |
| 124 | +
|
| 125 | +} |
103 | 126 | ``` |
| 127 | +{% endtab %} |
104 | 128 |
|
105 | | -**2NF문제점**: **이행적 함수 종속성** |
106 | | - |
107 | | -과목테이블의 기본키 `course_id` 가 `professor_phone` , `department` 결정되는게 아니라 교수의 이름 `professor` 이 `professor_phone` , `department` 결정하게 됩니다. |
108 | | - |
109 | | -> 🤔 이행적 함수 종속성이 뭔가요?\ |
110 | | -> A → B, B → C 관계가 성립할 때, A → C가 성립하는걸 의미합니다.데이터 베이스에서는 기본키(A)가 아닌 일반 컬럼(B)이 다른 일반 컬럼(C)을 결저하는 것을 말합니다. |
111 | | -
|
112 | | -### 3NF: 이행적 함수 종속성 제거 |
113 | | - |
114 | | - |
115 | | - |
116 | | -```sql |
117 | | --- 교수 테이블 |
118 | | -CREATE TABLE professors_3nf ( |
119 | | - professor_name VARCHAR(50) PRIMARY KEY, |
120 | | - professor_phone VARCHAR(20), |
121 | | - department_name VARCHAR(50) |
122 | | -); |
123 | | - |
124 | | --- 과목 테이블 (3NF) |
125 | | -CREATE TABLE courses_3nf ( |
126 | | - course_id VARCHAR(10) PRIMARY KEY, |
127 | | - course_name VARCHAR(50), |
128 | | - professor_name VARCHAR(50), |
129 | | - FOREIGN KEY (professor_name) REFERENCES professors_3nf(professor_name) |
130 | | -); |
131 | | - |
132 | | --- 학생 테이블 (변경 없음) |
133 | | -CREATE TABLE students_3nf ( |
134 | | - student_id INT PRIMARY KEY, |
135 | | - student_name VARCHAR(50), |
136 | | - student_phone VARCHAR(20), |
137 | | - student_major VARCHAR(50) |
138 | | -); |
139 | | - |
140 | | --- 수강 성적 테이블 (변경 없음) |
141 | | -CREATE TABLE enrollments_3nf ( |
142 | | - student_id INT, |
143 | | - course_id VARCHAR(10), |
144 | | - grade CHAR(1), |
145 | | - PRIMARY KEY (student_id, course_id), |
146 | | - FOREIGN KEY (student_id) REFERENCES students_3nf(student_id), |
147 | | - FOREIGN KEY (course_id) REFERENCES courses_3nf(course_id) |
148 | | -); |
| 129 | +{% tab log JPA Repository %} |
| 130 | +```java |
| 131 | +@Lock(LockModeType.OPTIMISTIC) |
| 132 | +@Query("SELECT i FROM Inventory i WHERE i.product.id = :productId") |
| 133 | +Optional<Inventory> findByProductIdWithOptimisticLock(Long productId); |
149 | 134 | ``` |
| 135 | +{% endtab %} |
| 136 | + |
| 137 | +{% tab log Service Code %} |
| 138 | +```java |
| 139 | +public class CreateOrderWithOptimisticLockUseCase { |
| 140 | + private final OrderRepository orderRepository; |
| 141 | + private final UserRepository userRepository; |
| 142 | + private final InventoryRepository inventoryRepository; |
| 143 | + |
| 144 | + public record Input(@NotNull Long userId, @NotNull Long productId) { } |
| 145 | + |
| 146 | + @Retryable( |
| 147 | + retryFor = ObjectOptimisticLockingFailureException.class, |
| 148 | + maxAttempts = 3, |
| 149 | + backoff = @Backoff(delay = 100) |
| 150 | + ) |
| 151 | + public void execute(Input input) { |
| 152 | + User user = userRepository.findById(input.userId()) |
| 153 | + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); |
| 154 | + |
| 155 | + Inventory inventory = inventoryRepository.findByProductIdWithOptimisticLock(input.productId()) |
| 156 | + .orElseThrow(() -> new IllegalArgumentException("재고를 찾을 수 없습니다")); |
| 157 | + |
| 158 | + inventory.getStockQuantity().decreaseStockQuantity(); |
| 159 | + |
| 160 | + Order order = Order.builder() |
| 161 | + .product(inventory.getProduct()) |
| 162 | + .user(user) |
| 163 | + .build(); |
| 164 | + |
| 165 | + orderRepository.save(order); |
| 166 | + |
| 167 | + } |
| 168 | + |
| 169 | + @Recover |
| 170 | + public void recover(ObjectOptimisticLockingFailureException e, Input input) { |
| 171 | + log.warn("낙관적 락 재시도 모두 실패: productId={}",input.productId()); |
| 172 | + throw new RuntimeException("잠시 후 다시 시도해주세요."); |
| 173 | + } |
| 174 | +} |
| 175 | +``` |
| 176 | +{% endtab %} |
| 177 | + |
| 178 | +### 문제점 |
| 179 | + |
| 180 | +1. 충돌이 많은 경우 재시도 증가 |
| 181 | + |
| 182 | + 낙관적 락은 충돌이 드문 경우 사용이 효율적인데, 충돌이 많은 경우 재시도 횟수와 SELECT + UPDATE가 쿼리가 증가하면서 비관적 락보다 DB 부하가 더 심해질 수 있습니다. |
| 183 | + |
| 184 | +2. 복잡한 로직 |
| 185 | + |
| 186 | + 어플리케이션내에서 직접 처리해줘야 해서 복잡한 재시도 로직들을 구현해야합니다. |
0 commit comments