|
| 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 모드까지 함께 맞아야 한다. |
0 commit comments