Compare commits

...

7 Commits

Author SHA1 Message Date
joungmin
7b2753b9fd docs(design): #329 admin/page.tsx 분리 + 유틸 통일 설계서 (Architect)
5개 패널을 _panels/<Panel>.tsx로 추출 + localStorage→getAdminToken + SSE→consumeSseStream.
내부 로직 변경 없음(점진적 접근).

설계서: docs/design/329-admin-split/README.md (Approved)
Refs: #329 (Architect)
2026-06-15 15:48:49 +09:00
joungmin
7411c8956f docs(changelog): v0.1.34 #331 VectorService batchUpdate 기록 2026-06-15 15:42:43 +09:00
joungmin
be302612f5 perf(vector): #331 VectorService.saveRestaurantVectors batchUpdate
- N+1 단건 jdbc.update → 단일 jdbc.batchUpdate(SqlParameterSource[])
- UUID 인라인 변환 → IdGenerator.newId() 공통 유틸
- 현재 N=1로 영향 작으나, chunk 분할 도입 시를 위한 사전 정비
- 회귀 없음: 동일 INSERT SQL, 동일 파라미터 매핑

설계서: docs/design/331-vector-batch-insert/README.md

Refs: #331 (Developer 단계)
2026-06-15 15:40:45 +09:00
joungmin
91d9813253 docs(design): #331 VectorService batchUpdate 설계서 (Architect)
jdbc.batchUpdate(SqlParameterSource[]) 단일 호출 + IdGenerator.newId() 공통화.
테스트는 본 범위 밖 (#343 후속 테스트 인프라).

설계서: docs/design/331-vector-batch-insert/README.md (Approved)
Refs: #331 (Architect)
2026-06-15 15:39:50 +09:00
joungmin
11e1cf7877 docs(changelog): v0.1.33 #326 parseJson 단일 패스 기록 2026-06-15 15:37:48 +09:00
joungmin
648ccde4d7 perf(parse): #326 parseJson truncated-array O(N²) → 단일 패스
OciGenAiService.parseJson:
- 잘린 배열 복구 시 각 idx에서 end를 1씩 늘려 readValue 시도하던 O(N²) 로직 제거
- findObjectEnd: brace depth counter (문자열/escape 처리) 단일 패스 O(N)
- 8192 token 응답 처리 시간 수백 ms → 10ms 이하 예상
- 매 try마다 Jackson 예외 객체/스택트레이스 생성하던 부담 제거

설계서: docs/design/326-parsejson-optimization/README.md (Approved)

Refs: #326
2026-06-15 15:35:48 +09:00
joungmin
ed61d29632 docs(changelog): v0.1.32 #332 화이트리스트 기록 2026-06-15 15:33:52 +09:00
6 changed files with 339 additions and 21 deletions

View File

@@ -6,6 +6,27 @@
## 2026-06-15 ## 2026-06-15
### ⚡ #331 VectorService batchUpdate (v0.1.34)
- saveRestaurantVectors: N+1 단건 INSERT → 단일 jdbc.batchUpdate(SqlParameterSource[])
- UUID 인라인 변환 제거 → IdGenerator.newId() 공통화
- 현재 N=1이지만 chunk 분할 도입 시 효과 본격화
- 설계서: docs/design/331-vector-batch-insert/README.md
- Refs: #331 (close)
### ⚡ #326 parseJson 단일 패스 (v0.1.33)
- OciGenAiService.parseJson 잘린 배열 복구를 brace depth counter 단일 패스로 교체
- 이전 O(N²) + Jackson 예외 양산 → O(N) + 명시적 에러 경로
- 문자열/escape 처리 정확
- 설계서: docs/design/326-parsejson-optimization/README.md
- Refs: #326 (close)
### 🛡️ #332 Restaurant PUT 화이트리스트 명시 (v0.1.32)
- ALLOWED_UPDATE_FIELDS set으로 PUT /api/restaurants/{id} body 필터
- 허용 외 키 silent drop + DEBUG 로그
- sanitized.isEmpty()면 200 + no-op
- 후속 분리: #348 (DDG → 정식 API, isNameSimilar 한국어, DTO 표준화)
- Refs: #332 (close)
### 🛡️ #337 통계 봇 필터 + 레이트리밋 (v0.1.31) ### 🛡️ #337 통계 봇 필터 + 레이트리밋 (v0.1.31)
- BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse) - BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
- RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화) - RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화)

View File

@@ -150,26 +150,25 @@ public class OciGenAiService {
return mapper.readValue(raw, Object.class); return mapper.readValue(raw, Object.class);
} catch (Exception ignored) {} } catch (Exception ignored) {}
// Try to recover truncated array // #326 — Recover truncated array. Brace depth counter로 단일 패스 O(N).
// 이전: 각 idx에서 end를 1씩 늘려가며 매번 readValue → O(N²) + 예외 스택트레이스 양산.
if (raw.trim().startsWith("[")) { if (raw.trim().startsWith("[")) {
List<Object> items = new ArrayList<>(); List<Object> items = new ArrayList<>();
int idx = raw.indexOf('[') + 1; int idx = raw.indexOf('[') + 1;
while (idx < raw.length()) { while (idx < raw.length()) {
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++; while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
if (idx >= raw.length() || raw.charAt(idx) == ']') break; if (idx >= raw.length() || raw.charAt(idx) == ']') break;
if (raw.charAt(idx) != '{') break; // 객체 시작이 아니면 복구 중단
// Try to parse next object int end = findObjectEnd(raw, idx);
boolean found = false; if (end < 0) break; // 잘린 객체 — 거기서 멈춤
for (int end = idx + 1; end <= raw.length(); end++) { try {
try { Object obj = mapper.readValue(raw.substring(idx, end + 1), Object.class);
Object obj = mapper.readValue(raw.substring(idx, end), Object.class); items.add(obj);
items.add(obj); } catch (Exception ignored2) {
idx = end; break; // 불가해 객체 — 멈춤
found = true;
break;
} catch (Exception ignored2) {}
} }
if (!found) break; idx = end + 1;
} }
if (!items.isEmpty()) { if (!items.isEmpty()) {
log.info("Recovered {} items from truncated JSON", items.size()); log.info("Recovered {} items from truncated JSON", items.size());
@@ -179,4 +178,27 @@ public class OciGenAiService {
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length()))); throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
} }
/**
* #326 — JSON 객체 시작 위치(`{`)에서 매칭되는 닫는 `}` 인덱스를 반환.
* 문자열 안의 `{` `}`와 escape는 무시. 매칭 못 찾으면 -1.
*/
private static int findObjectEnd(String raw, int start) {
int depth = 0;
boolean inString = false;
boolean escaped = false;
for (int i = start; i < raw.length(); i++) {
char c = raw.charAt(i);
if (escaped) { escaped = false; continue; }
if (c == '\\') { escaped = true; continue; }
if (c == '"') { inString = !inString; continue; }
if (inString) continue;
if (c == '{') depth++;
else if (c == '}') {
depth--;
if (depth == 0) return i;
}
}
return -1;
}
} }

View File

@@ -1,10 +1,12 @@
package com.tasteby.service; package com.tasteby.service;
import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil; import com.tasteby.util.JsonUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@@ -64,6 +66,9 @@ public class VectorService {
/** /**
* Save vector embeddings for a restaurant. * Save vector embeddings for a restaurant.
*
* #331 — N개 청크를 단일 batchUpdate 호출로 처리 (이전: N+1 INSERT round-trip).
* UUID 생성은 IdGenerator.newId() 공통 유틸 사용 (인라인 변환 코드 제거).
*/ */
public void saveRestaurantVectors(String restaurantId, List<String> chunks) { public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
if (chunks.isEmpty()) return; if (chunks.isEmpty()) return;
@@ -75,19 +80,20 @@ public class VectorService {
VALUES (:id, :rid, :chunk, :emb) VALUES (:id, :rid, :chunk, :emb)
"""; """;
SqlParameterSource[] batch = new SqlParameterSource[chunks.size()];
for (int i = 0; i < chunks.size(); i++) { for (int i = 0; i < chunks.size(); i++) {
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); List<Double> emb = embeddings.get(i);
float[] vec = new float[embeddings.get(i).size()]; float[] vec = new float[emb.size()];
for (int j = 0; j < vec.length; j++) { for (int j = 0; j < vec.length; j++) {
vec[j] = embeddings.get(i).get(j).floatValue(); vec[j] = emb.get(j).floatValue();
} }
var params = new MapSqlParameterSource(); batch[i] = new MapSqlParameterSource()
params.addValue("id", id); .addValue("id", IdGenerator.newId())
params.addValue("rid", restaurantId); .addValue("rid", restaurantId)
params.addValue("chunk", chunks.get(i)); .addValue("chunk", chunks.get(i))
params.addValue("emb", vec); .addValue("emb", vec);
jdbc.update(sql, params);
} }
jdbc.batchUpdate(sql, batch);
} }
/** /**

View File

@@ -0,0 +1,81 @@
# 설계서: OciGenAiService.parseJson 단일 패스 최적화 (#326)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #326 · 부모: #292 (추출 파이프라인 Reviewer 후속, 09-Done)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/OciGenAiService.java`
## 1. 목적 (Why)
LLM 응답이 잘린(truncated) 배열일 때 `parseJson`의 복구 로직이 O(N²): 각 객체 시작점에서 `end`를 1씩 늘려가며 매번 `mapper.readValue(substring)`을 try. 8192 토큰 응답(약 30KB)에서 매우 비효율 + 매 try마다 Jackson 예외 객체 생성(스택트레이스 양산). brace depth counter로 단일 패스 O(N)으로 교체.
## 2. 범위
- **포함**: `parseJson`의 truncated-array 복구 로직을 brace depth counter로 변경.
- **제외**: `parseJson`의 마크다운/콤마 정규식 전처리는 그대로. Jackson streaming API 도입은 추가 최적화이지만 본 범위 밖.
## 3. 인수조건
- [ ] 정상 JSON 배열 → 동일 결과 반환.
- [ ] 잘린 배열(끝 `}` 누락) → 가능한 만큼 객체 추출 + 로그.
- [ ] 문자열 안의 `{` `}` `"` (escape 포함) 잘못 카운트 안 됨.
- [ ] 8192 token 응답 처리 시간 < 10ms (이전: 수백 ms 가능).
- [ ] 회귀 없음 (기존 추출 파이프라인 시나리오 통과).
## 4. 컨텍스트 & 제약
- LLM 응답은 마크다운 + JSON 혼합 가능.
- 응답 크기 최대 약 30KB (8192 token × 4 char/token).
- mapper는 Jackson ObjectMapper.
## 5. 아키텍처 개요
```
parseJson(raw)
├ strip markdown/trailing commas (기존)
├ try readValue(raw) → 성공 시 반환
└ truncated array 복구:
idx = '['의 다음
while idx < len:
skip whitespace, ','
if raw[idx] != '{': break // 객체 아님
depth=0, inString=false, escaped=false
단일 패스로 객체 끝 (depth==0 && } 만남) 찾음
items.add(readValue(substring))
idx = 객체 끝 다음
return items
```
## 6. 함수 명세
| 함수 | 책임 | 비고 |
|------|------|------|
| `parseJson(raw)` (수정) | brace depth + 단일 readValue | private 헬퍼 `findObjectEnd(raw, start)` 추출 |
## 7. 흐름
1. 기존 정규식 전처리.
2. 전체 파싱 시도.
3. 실패 + 배열 시작이면 위 알고리즘으로 객체 단위 복구.
## 8. 엣지케이스
- **빈 배열 `[]`**: 일반 readValue가 처리.
- **문자열 안 `{` `}`**: inString 토글로 무시.
- **escape `\"` `\\`**: escaped 토글로 무시.
- **객체가 아닌 원시값 배열 `[1, 2, 3]`**: 첫 char가 `{`가 아니므로 break. 전체 파싱이 성공할 경우 도달 안 함.
- **매우 짧은 응답**: 전체 파싱이 성공 → 복구 경로 미진입.
## 9. 테스트
- 정상 배열, 잘린 끝, 마크다운 wrap, escape 포함 5케이스 unit test (후속).
## 10. 리스크 & 대안
- **선택**: brace depth counter (단일 패스).
- **대안 A**: Jackson `JsonParser` streaming API — 더 빠르지만 코드 복잡.
- **대안 B**: 응답을 모두 받지 않고 streaming 파싱 — 본 범위 밖.
## 11. 미해결 질문
- LLM 응답에 객체 외 다른 타입 섞일 수 있는가? 현재 추출 결과는 `[{...}, {...}]` 형태로 가정.

View File

@@ -0,0 +1,107 @@
# 설계서: admin/page.tsx 분리 + 토큰/SSE 유틸 통일 (#329)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #329 · 부모: #304 (현행화 frontend-admin, 09-Done)
> · 구현 파일: `frontend/src/app/admin/page.tsx`, `frontend/src/app/admin/_panels/*.tsx` (신규), `frontend/src/lib/admin-utils.ts`
> · 테스트: 수동 (각 탭 진입 + 기존 시나리오 회귀)
## 1. 목적 (Why)
`admin/page.tsx`가 2817 LOC 단일 파일. 5개 패널이 같은 파일 안에 함께 있어 (a) 빌드 변경 시 무관한 패널까지 재빌드/재배포, (b) 코드 리뷰 시 충돌 가능성↑, (c) 로직 격리 어려움. 또한 `localStorage` 직접 호출 + SSE 파싱 코드 중복이 남아있어 #304 후속에서 한 번 더 정리.
## 2. 범위 (Scope)
- **포함**
- 5개 패널을 `app/admin/_panels/<Panel>.tsx`로 추출 (Next.js underscore prefix로 라우팅 제외).
- `page.tsx`는 탭 라우팅 + 헤더 + 패널 import만.
- 남은 `localStorage.getItem("tasteby_token")` 호출 → `getAdminToken()`/`authHeaders()` 교체.
- SSE 파싱 중복(약 5~6곳) → `consumeSseStream()` 활용.
- 공유 타입(`AdminUser`/`UserFavorite`/`UserReview`/`UserMemo`/`VideoSortKey`)을 _panels 파일 내부 또는 `api.ts`에 옮김(짧은 타입만).
- **제외**
- 패널 내부 로직 변경 (state/effect 그대로).
- catch{/*ignore*/} 일괄 로깅 (별도 후속).
- ad-hoc 타입 캐스팅 정리 (별도 후속).
- 디자인 시스템 색상 통일.
## 3. 인수조건
- [ ] 5개 파일 신규: `_panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx`.
- [ ] `page.tsx` < 200 LOC (이전 2817).
- [ ] localStorage 직접 호출이 admin 페이지 내에 0건.
- [ ] SSE reader 직접 호출(`response.body?.getReader()`)이 0건.
- [ ] 모든 탭 진입 + 기존 시나리오 회귀 없음(빌드 통과 + 수동 smoke).
- [ ] dev 빌드 + 운영 배포 성공.
## 4. 컨텍스트 & 제약
- Next.js 16 (App Router). `_` prefix 디렉토리는 route 제외.
- `"use client"` 지시문 각 패널 파일 상단에 필요.
- React Server Components 미사용 (모두 클라이언트 컴포넌트).
- `useAuth()` 훅이 `auth-context`에 있음. 부모 `AdminPage`가 isAdmin 판정 후 prop으로 패널에 전달.
- 패널 간 상태 공유 없음 (각각 독립).
## 5. 아키텍처 개요
```
app/admin/
page.tsx ← 탭 라우팅 + 헤더 + CacheFlushButton + 패널 import
_panels/
ChannelsPanel.tsx ← 214 LOC
VideosPanel.tsx ← 1272 LOC (가장 큼)
RestaurantsPanel.tsx ← 667 LOC
UsersPanel.tsx ← 332 LOC
DaemonPanel.tsx ← 223 LOC
lib/admin-utils.ts (이미 존재)
├─ getAdminToken()
├─ authHeaders()
└─ consumeSseStream()
```
## 6. 함수 명세
| 단위 | 책임 | 비고 |
|------|------|------|
| `page.tsx` (재작성) | 탭 라우팅 + 패널 import | < 200 LOC |
| `_panels/*.tsx` (신규 5개) | 각 패널 로직 그대로 옮김 | "use client" |
| `localStorage` 호출 (~10곳) | `getAdminToken()`/`authHeaders()`로 통일 | 의미 동일 |
| SSE `getReader` (~5곳) | `consumeSseStream(resp, onEvent)`로 통일 | 의미 동일 |
## 7. 흐름
1. `_panels/` 디렉토리 생성.
2. 각 패널 함수 + 그에 종속된 타입/상수를 `<Panel>Panel.tsx`로 잘라 옮김.
3. 각 파일에 `"use client"` + import 추가.
4. `localStorage.getItem("tasteby_token")``getAdminToken()` 일괄.
5. SSE `getReader/decoder/buf.split/match` 패턴 → `consumeSseStream(resp, onEvent)` 일괄.
6. `page.tsx` 재작성 — 탭 라우팅 + 패널 import.
7. `npm run build`.
8. `pm2 restart` 또는 `deploy.sh --frontend-only`.
## 8. 엣지케이스
- **순환 import**: 패널 간 의존 없음 → 안전.
- **Type 중복**: `AdminUser` 등 패널 내부 타입은 그대로 옮김. 공유 타입은 `api.ts`에 있음.
- **default export vs named**: 각 패널은 named export. `page.tsx`에서 `import { ChannelsPanel } from "./_panels/ChannelsPanel"`.
- **빌드 크기**: 동일(코드 splitting은 별도 작업).
## 9. 테스트
- 빌드: `npm run build` 통과.
- 수동:
- `/admin` 접근 → 5탭 모두 진입 가능.
- 채널 추가, 영상 강제 추출(SSE), 식당 검색/수정, 유저 권한 토글, 데몬 설정 변경 — 모두 정상.
- 자동: 별도 후속(테스트 인프라 #343).
## 10. 리스크 & 대안
- **선택**: 5개 파일 추출 + 내부 로직 그대로.
- **대안 A**: 추출 + 내부 리팩터링 동시 — 회귀 위험↑, 별도 후속이 안전.
- **대안 B**: Atomic Design (atoms/molecules/organisms) — 큰 재구조화. 미루기.
- **트레이드오프**: 외형은 동일, 유지보수성/충돌 가능성만 개선. 점진적 접근.
## 11. 미해결 질문
- 공통 panel layout(헤더/리스트/페이징) 추상화 — 후속.
- VideosPanel 1272 LOC 내부 분할(상태 머신 + SSE 흐름 분리) — 별도 후속.

View File

@@ -0,0 +1,81 @@
# 설계서: VectorService batch insert + IdGenerator 공통화 (#331)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #331 · 부모: #293 (검색/벡터 Reviewer 후속, 09-Done)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VectorService.java`
> · 테스트: 본 이슈 범위 밖 (단위 테스트 인프라 도입은 #343 후속 묶음에 해당)
## 1. 목적 (Why)
`VectorService.saveRestaurantVectors`가 chunk N개를 N번의 단건 `jdbc.update`로 처리한다. 현재 `buildChunks`가 1개 청크만 반환해 N=1이지만, 향후 chunk 분할 도입 시 N+1 INSERT 비효율. 또한 UUID 생성 코드가 인라인 변환(`UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase()`)으로 다른 곳의 `IdGenerator.newId()`와 중복.
## 2. 범위
- **포함**
- `jdbc.batchUpdate(sql, SqlParameterSource[])`로 단일 호출 전환.
- UUID 생성을 `IdGenerator.newId()` 공통 유틸로 교체.
- **제외**
- 단위/통합 테스트 도입 (테스트 인프라 미도입 — 별도 후속 #343 묶음).
- `buildChunks`의 chunk 분할 로직 자체 변경 (현재 단일 청크 정책 유지).
- `restaurant_vectors` 스키마 변경.
## 3. 인수조건
- [ ] `saveRestaurantVectors`가 한 번의 `jdbc.batchUpdate` 호출로 N개 청크 삽입.
- [ ] UUID 인라인 변환 제거 → `IdGenerator.newId()` 호출.
- [ ] 회귀 없음 — 신규 식당 등록 시 `restaurant_vectors`에 정상 row 추가.
- [ ] N=0 가드(`chunks.isEmpty()`)는 유지.
## 4. 컨텍스트 & 제약
- Spring `NamedParameterJdbcTemplate.batchUpdate(String, SqlParameterSource[])` 사용.
- Oracle VECTOR 타입 파라미터는 `float[]`로 그대로 바인딩 가능 (`MapSqlParameterSource.addValue`).
- 한 batch 안 `int[]` 반환 → batch 결과 카운트는 사용하지 않음(throw if 어쩌고 미적용).
- `IdGenerator.newId()` 시그니처: `public static String newId()` → 32-char uppercase hex (현재 인라인과 동일).
## 5. 아키텍처 개요
```
saveRestaurantVectors(restaurantId, chunks)
├ if chunks.isEmpty() → return
├ embeddings = genAi.embedTexts(chunks)
├ params[] = build N개 MapSqlParameterSource
│ .addValue("id", IdGenerator.newId())
│ .addValue("rid", restaurantId)
│ .addValue("chunk", chunks.get(i))
│ .addValue("emb", float[] embeddings[i])
└ jdbc.batchUpdate(sql, params)
```
## 6. 함수 명세
| 함수 | 책임 | 비고 |
|------|------|------|
| `VectorService.saveRestaurantVectors(id, chunks)` (수정) | batchUpdate 1회 | IdGenerator 사용 |
## 7. 흐름
1. embed 호출 (기존).
2. `SqlParameterSource[]` 생성.
3. `jdbc.batchUpdate(sql, params)` 단일 호출.
## 8. 엣지케이스
- **chunks 빈 배열**: 조기 return (기존 유지).
- **embed 결과와 chunks 크기 불일치**: 현재 OCI GenAI는 입력 N → 출력 N 보장. 안전 가드 추가는 본 범위 밖 (필요 시 후속).
## 9. 테스트 (수동만)
- dev에서 신규 식당 등록(데몬 또는 수동 trigger) → `SELECT count(*) FROM restaurant_vectors WHERE restaurant_id = '...'` 정상 row 확인.
## 10. 리스크 & 대안
- **선택**: `NamedParameterJdbcTemplate.batchUpdate`. 단일 트랜잭션 + 단일 round-trip.
- **대안 A**: `JdbcTemplate.batchUpdate(BatchPreparedStatementSetter)` — 더 저수준이지만 named param 손실.
- **대안 B**: MERGE로 upsert — 동일 restaurant_id 재처리 시 중복 제거 가능. 다만 본 이슈 범위 밖.
## 11. 미해결 질문
- chunk 분할 정책(현재 1개 단일 청크) — 후속 (검색 정확도 vs 토큰 비용 트레이드오프 결정).
- batchUpdate 결과 row 수 검증 — 운영 모니터링 도구 도입 후 결정.