Skip to content

Commit 78a2ed2

Browse files
authored
Merge pull request #14 from DocGrid/test/12
[Test] 기본 임베딩 모델 단일성·동시성·트랜잭션 검증
2 parents 1bf956c + e1bc4f1 commit 78a2ed2

4 files changed

Lines changed: 1309 additions & 1 deletion

File tree

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
# 기본 임베딩 모델 단일성 및 동시성 검증
2+
3+
## 1. 문제 배경
4+
5+
신규 `embedding_job`이 사용할 기본 임베딩 모델은 `is_active = true`이면서
6+
`is_searchable = true`인 행이다. 여러 서버가 동시에 "현재 기본 모델이 없다"고 조회한 뒤
7+
각자 저장하면 애플리케이션 사전 조회만으로는 두 행이 함께 Commit되는 경쟁 조건이 생긴다.
8+
9+
검증 기준 Git 커밋은 `1bf956c`이며, PR 1 머지 커밋 `eecf255`가 포함된 최신
10+
`develop`에서 측정했다.
11+
12+
## 2. is_active와 is_searchable을 분리한 이유
13+
14+
- `is_active`: 신규 embedding job이 선택할 모델인지 나타낸다.
15+
- `is_searchable`: 기존에 생성된 Vector를 현재 검색에 사용할 수 있는지 나타낸다.
16+
17+
모델 교체 후에도 기존 모델을 `false + true`로 유지하면 신규 Job에는 사용하지 않으면서
18+
기존 Vector 검색 가능성은 유지할 수 있다.
19+
20+
## 3. 애플리케이션 사전 조회만 사용할 때의 경쟁 조건
21+
22+
Partial Index가 없는 테스트 전용 비교 테이블에서 두 독립 트랜잭션이 각각 기본 모델 수를
23+
조회하고 Barrier에서 만난 뒤 저장했다.
24+
25+
```text
26+
시도: 2
27+
두 트랜잭션의 사전 조회 결과: 각각 0
28+
성공: 2
29+
실패: 0
30+
최종 true + true: 2
31+
전체 시도 처리시간 median: 4.472063 ms
32+
```
33+
34+
따라서 조회 후 저장 사이의 경쟁 조건 때문에 애플리케이션 사전 조회만으로 단일성을
35+
완전히 보장할 수 없다.
36+
37+
## 4. 일반 UNIQUE(is_active, is_searchable)가 부적합한 이유
38+
39+
일반 Boolean 복합 Unique는 네 조합을 각각 한 건으로 제한한다. 실제 정책은 다음과 같다.
40+
41+
| is_active | is_searchable | 실제 저장 결과 |
42+
|---|---|---:|
43+
| false | false | 2건 성공 |
44+
| true | false | 2건 성공 |
45+
| false | true | 2건 성공 |
46+
| true | true | 1건 성공, 두 번째 저장 차단 |
47+
48+
두 번째 true+true 저장이 실패한 뒤에도 총 7건과 각 상태별 개수가 유지됐다.
49+
50+
## 5. Partial Unique Index 선택 이유
51+
52+
`true + true` 행에만 같은 상수 표현식 `(1)`을 인덱싱하면 해당 Predicate를 만족하는 모든
53+
행이 동일한 Unique Key를 갖는다. 그 외 Boolean 조합은 인덱스 대상이 아니므로 여러 행을
54+
허용한다.
55+
56+
DB는 최대 한 개를 보장하고, 애플리케이션 Service는 0개 및 비정상 다중 결과를 명시적인
57+
설정 오류로 처리한다.
58+
59+
## 6. 현재 Migration
60+
61+
`src/main/resources/db/migration/V27__add_embedding_model_constraints.sql`
62+
63+
```sql
64+
CREATE UNIQUE INDEX uk_embedding_models_one_active_searchable
65+
ON embedding_models ((1))
66+
WHERE is_active = TRUE
67+
AND is_searchable = TRUE;
68+
```
69+
70+
이번 검증에서는 V27을 수정하거나 인덱스를 재생성하지 않았다.
71+
72+
## 7. 실제 OpenSQL 환경
73+
74+
```text
75+
Container image: tmaxopensql/postgres:14.6
76+
Image digest: sha256:6d4cc9a80921acc315a97c8cad92aea631ae40983173e80f0e8c5a373967f713
77+
DB response: PostgreSQL 14.6 on x86_64-pc-linux-gnu
78+
Profile: test
79+
정확성 테스트 스키마: docgrid_embedding_constraint_test
80+
Benchmark 스키마: docgrid_embedding_benchmark_test
81+
Flyway locations: classpath:db/migration, classpath:db/seed
82+
Testcontainers/H2: 사용하지 않음
83+
```
84+
85+
현재 로컬 `.env``docgrid` 계정 및 SSL 비활성 설정과 기존 OpenSQL 볼륨의 `app` 계정 및
86+
SSL 요구사항이 일치하지 않았다. 볼륨을 삭제하지 않고 테스트 명령에 현재 볼륨과 맞는
87+
DB 이름·사용자·`sslmode=require`를 주입했다. 비밀번호와 JWT Secret은 문서에 기록하지 않는다.
88+
89+
## 8. Index 카탈로그 조회 결과
90+
91+
`pg_index`, `pg_class`, `pg_namespace`, `pg_get_expr()`, `pg_get_indexdef()`로 확인했다.
92+
93+
```text
94+
index: uk_embedding_models_one_active_searchable
95+
table: docgrid_embedding_constraint_test.embedding_models
96+
indisunique: true
97+
predicate: ((is_active = true) AND (is_searchable = true))
98+
definition: CREATE UNIQUE INDEX uk_embedding_models_one_active_searchable
99+
ON docgrid_embedding_constraint_test.embedding_models USING btree ((1))
100+
WHERE ((is_active = true) AND (is_searchable = true))
101+
```
102+
103+
## 9. Boolean 조합별 저장 결과
104+
105+
```text
106+
false + false: 2
107+
true + false: 2
108+
false + true : 2
109+
true + true : 1
110+
total : 7
111+
```
112+
113+
두 번째 true+true 저장의 실제 결과:
114+
115+
```text
116+
Spring exception: DataIntegrityViolationException
117+
SQLSTATE: 23505
118+
Index: uk_embedding_models_one_active_searchable
119+
실패 후 true + true: 1
120+
```
121+
122+
## 10. 동시 저장 비교 결과
123+
124+
| 비교 항목 | 애플리케이션 사전 조회만 | Partial Unique Index 적용 |
125+
|---|---:|---:|
126+
| 반복 | 1 | 20 |
127+
| 저장 시도 | 2 | 40 |
128+
| 성공 | 2 | 20 |
129+
| 실패 | 0 | 20 |
130+
| Unique 위반 | 0 | 20 |
131+
| 예상 외 실패 | 0 | 0 |
132+
| 최종 true+true | 2 | 매 반복 1 |
133+
| 불변식 위반 | 있음 | 0회 |
134+
| 실패 SQLSTATE | 해당 없음 | 23505 |
135+
| 실패 Index | 해당 없음 | uk_embedding_models_one_active_searchable |
136+
| 전체 시도 처리시간 median | 4.472063 ms | 1.776959 ms |
137+
138+
DB 제약 적용 후 한 요청이 실패하는 것은 시스템 장애가 아니라 잘못된 중복 상태를 차단한
139+
결과다. 처리시간은 서로 다른 반복 조건의 로컬 관찰값이므로 성능 우열 근거로 사용하지 않는다.
140+
141+
## 11. 모델 교체 트랜잭션 결과
142+
143+
정상 순서:
144+
145+
```text
146+
1. old-model: true + true -> false + true
147+
2. new-model: false + false -> true + true
148+
3. Commit 성공
149+
4. 최종 true + true: 1
150+
```
151+
152+
잘못된 순서:
153+
154+
```text
155+
1. new-model을 먼저 true + true로 변경
156+
2. SQLSTATE 23505 / uk_embedding_models_one_active_searchable
157+
3. 트랜잭션 Rollback
158+
4. old-model: true + true 유지
159+
5. new-model: false + false 유지
160+
```
161+
162+
## 12. Rollback 결과
163+
164+
기존 모델의 `is_active`를 false로 변경한 직후 테스트 예외를 발생시켰다.
165+
166+
```text
167+
exception: IntentionalTestException
168+
old-model: true + true 유지
169+
new-model: false + false 유지
170+
최종 true + true: 1
171+
```
172+
173+
## 13. 조회 실행 계획 비교
174+
175+
합성 모델 100,000건 중 true+true는 한 건이었다.
176+
177+
적용 전:
178+
179+
```text
180+
Node: Index Scan
181+
Index: idx_benchmark_is_searchable
182+
Filter: is_active
183+
Rows removed by filter: 13,334
184+
Actual rows: 1
185+
Planning time: 0.015 ms
186+
Execution time: 0.899 ms
187+
Shared hit blocks: 748
188+
Shared read blocks: 0
189+
```
190+
191+
적용 후:
192+
193+
```text
194+
Nodes: Bitmap Heap Scan -> Bitmap Index Scan
195+
Index: uk_benchmark_one_active_searchable
196+
Actual rows: 1
197+
Planning time: 0.014 ms
198+
Execution time: 0.009 ms
199+
Shared hit blocks: 2
200+
Shared read blocks: 0
201+
```
202+
203+
Planner 문자열 전체를 테스트에서 고정하지 않고, 결과 한 건과 적용 후 partial index 사용만
204+
자동 검증했다.
205+
206+
## 14. 성능 측정 결과
207+
208+
```text
209+
Synthetic rows: 100,000
210+
Warm-up: 5회
211+
Measured runs: 20회
212+
213+
적용 전: median 1.084917 ms / min 1.037958 ms / max 1.297708 ms
214+
적용 후: median 0.276500 ms / min 0.224708 ms / max 0.467959 ms
215+
관찰된 median 변화율: 74.514% 감소
216+
217+
Table size: 6,029,312 bytes
218+
Indexes before: 3,596,288 bytes
219+
Indexes after: 3,612,672 bytes
220+
Partial Index: 16,384 bytes
221+
```
222+
223+
이 수치는 로컬 OpenSQL 컨테이너, 합성 데이터, 현재 캐시 상태에서 얻은 결과다. 실제 운영
224+
트래픽의 성능 향상으로 일반화할 수 없다. 이 작업의 핵심 효과는 조회 속도가 아니라 경쟁
225+
조건 제거와 데이터 불변식 보장이다.
226+
227+
## 15. 해결 전후 비교
228+
229+
| 항목 | PR 1 기준선 | PR 1-A |
230+
|---|---:|---:|
231+
| 전체 기본 테스트 | 17 | 26 |
232+
| Repository 테스트 | 5 | 5 |
233+
| Service 테스트 | 4 | 4 |
234+
| Controller 테스트 | 3 | 3 |
235+
| 동시성·트랜잭션 통합 테스트 | 0 | 9 |
236+
| 실패 | 0 | 0 |
237+
| 오류 | 0 | 0 |
238+
| 스킵 | 0 | 0 |
239+
| XML 테스트 시간 합계 | 0.445 s | 0.700 s |
240+
| 별도 Benchmark | 없음 | 1개, 0.802 s |
241+
242+
테스트 수 증가는 성능 개선이 아니라 검증 및 회귀 방지 범위가 넓어진 것이다.
243+
244+
## 16. 한계와 해석 주의점
245+
246+
- 로컬 개발 장비의 단일 OpenSQL 컨테이너에서 실행했다.
247+
- 동시성 테스트는 2개 Thread와 20회 반복으로 제한했다.
248+
- Benchmark는 합성 데이터 100,000건이며 실제 운영 분포와 다르다.
249+
- 절대 시간은 장비 부하와 캐시 상태에 따라 변한다.
250+
- 인덱스 유지에 따른 쓰기 비용이나 운영 부하 테스트는 측정하지 않았다.
251+
- DB는 최대 한 개만 보장한다. 기본 모델 0개 처리는 Service 책임이다.
252+
253+
## 17. 재현 명령어
254+
255+
먼저 저장소의 OpenSQL 컨테이너가 현재 볼륨의 계정과 일치하는 설정으로 healthy인지 확인한다.
256+
아래 `<...>` 값은 로컬 테스트 환경변수로 주입하며 문서나 Git에 저장하지 않는다.
257+
258+
```bash
259+
export JWT_SECRET='<test-only-jwt-secret>'
260+
export DB_PASSWORD='<local-test-db-password>'
261+
export DB_PORT=5432
262+
export DB_NAME=app
263+
export DB_USER=app
264+
export DB_SSLMODE=require
265+
```
266+
267+
정확성·동시성·트랜잭션 테스트:
268+
269+
```bash
270+
./gradlew cleanTest test --tests '*EmbeddingModelConstraintIntegrationTest'
271+
```
272+
273+
전체 테스트(Benchmark 제외):
274+
275+
```bash
276+
./gradlew cleanTest test
277+
```
278+
279+
Benchmark:
280+
281+
```bash
282+
./gradlew benchmarkTest --rerun-tasks
283+
```
284+
285+
결과 수치는 다음 XML에서 확인한다.
286+
287+
```text
288+
build/test-results/test/TEST-*.xml
289+
build/test-results/benchmarkTest/TEST-*.xml
290+
```
291+
292+
두 테스트 전용 스키마는 각 테스트 클래스 종료 시 `DROP SCHEMA ... CASCADE`로 제거된다.
293+
294+
## 18. 배운 점
295+
296+
- 사전 조회는 사용자 친화적 오류나 빠른 실패에는 유용하지만 DB 불변식을 대체하지 못한다.
297+
- 상수 표현식 기반 Partial Unique Index는 조건을 만족하는 행만 단일화할 수 있다.
298+
- 모델 교체는 기존 기본 모델 해제와 신규 모델 활성화를 한 트랜잭션에서 순서대로 수행해야 한다.
299+
- 정확성 테스트와 Benchmark를 분리해야 시간 변동이 회귀 테스트의 성공 여부를 왜곡하지 않는다.
300+
- OpenSQL 검증에서는 이미지뿐 아니라 vars 파일, 볼륨에 초기화된 사용자, SSL 모드까지 함께 맞아야 한다.

build.gradle

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,25 @@ dependencies {
3535
annotationProcessor 'org.projectlombok:lombok'
3636
testImplementation 'org.springframework.boot:spring-boot-starter-test'
3737
testImplementation 'org.springframework.security:spring-security-test'
38+
testImplementation 'org.postgresql:postgresql'
3839
testCompileOnly 'org.projectlombok:lombok'
3940
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
4041
testAnnotationProcessor 'org.projectlombok:lombok'
4142
}
4243

4344
tasks.named('test') {
44-
useJUnitPlatform()
45+
useJUnitPlatform {
46+
excludeTags 'benchmark'
47+
}
48+
}
49+
50+
tasks.register('benchmarkTest', Test) {
51+
group = 'verification'
52+
description = 'OpenSQL 기반 benchmark 태그 테스트를 실행합니다.'
53+
testClassesDirs = sourceSets.test.output.classesDirs
54+
classpath = sourceSets.test.runtimeClasspath
55+
useJUnitPlatform {
56+
includeTags 'benchmark'
57+
}
58+
outputs.upToDateWhen { false }
4559
}

0 commit comments

Comments
 (0)