Compare commits

..

6 Commits

Author SHA1 Message Date
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
joungmin
51f7b5c7d3 feat(restaurant): #332 PUT body 화이트리스트 명시화
ALLOWED_UPDATE_FIELDS set으로 PUT /api/restaurants/{id} body를 SQL updateFields
컬럼 가드와 1:1로 매핑. 허용 외 키는 silent drop + DEBUG 로그.

기존 SQL <if containsKey>로 이미 임의 컬럼 갱신이 차단되어 있으나, Controller에
명시 화이트리스트가 없어 의도 모호. 본 변경으로 두 레이어 모두 화이트리스트 확보.

sanitized가 비면 200 no-op로 응답 (사용자 경험 우선).

DDG/isNameSimilar/DTO는 별도 후속 (예: #346) 분리.

설계서: docs/design/332-restaurant-update-whitelist/README.md

Refs: #332
2026-06-15 15:31:56 +09:00
joungmin
f4cb95e88c docs(design): #332 Restaurant 화이트리스트 설계서 (Architect)
ALLOWED_UPDATE_FIELDS set으로 PUT body 허용 키 명시. DDG/isNameSimilar/DTO는 후속.

설계서: docs/design/332-restaurant-update-whitelist/README.md (Approved)
Refs: #332 (Architect)
2026-06-15 15:30:38 +09:00
joungmin
109ad106ac docs(changelog): v0.1.31 #337 봇/레이트리밋 기록 2026-06-15 15:28:52 +09:00
joungmin
319fd18258 feat(stats): #337 봇 UA 필터 + IP 레이트리밋
- BotDetector 유틸 (Pattern.CASE_INSENSITIVE: bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
- RateLimitService: Redis SET NX EX(60s) 패턴으로 같은 IP 윈도우 차단
  - Bucket4j 대신 spring-data-redis 기존 의존성 재사용 (간결)
  - Redis 다운 시 fail-open (사용자 경험 우선)
- StatsController.recordVisit: HttpServletRequest 받아 UA + X-Forwarded-For 우선 IP
  - 봇/리밋 초과 → 200 + counted:false (사용자 페이지 로드 지장 X)
  - 통과 → 200 + counted:true → statsService.recordVisit()
- application.yml: app.rate-limit.visit-window-seconds (env VISIT_WINDOW_SECONDS) 기본 60
- dev 검증: 봇 UA → counted=false, Mozilla → true, 즉시 재호출 → false

설계서: docs/design/337-stats-bot-ratelimit/README.md

Refs: #337 (Developer 단계)
2026-06-15 15:26:27 +09:00
10 changed files with 384 additions and 29 deletions

View File

@@ -6,6 +6,23 @@
## 2026-06-15 ## 2026-06-15
### 🛡️ #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)
- BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
- RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화)
- StatsController.recordVisit: X-Forwarded-For 우선 IP + 봇/IP 가드
- 응답: {ok, counted:bool} — 차단도 200 (사용자 페이지 지장 X)
- application.yml: app.rate-limit.visit-window-seconds (기본 60)
- 운영 검증: Googlebot/Mozilla/즉시 재호출 인수조건 모두 충족
- 설계서: docs/design/337-stats-bot-ratelimit/README.md
- Refs: #337 (close)
### 🔒 #335 데몬 분산 락 ShedLock+Redis (v0.1.30) ### 🔒 #335 데몬 분산 락 ShedLock+Redis (v0.1.30)
- shedlock-spring 5.16.0 + shedlock-provider-redis-spring - shedlock-spring 5.16.0 + shedlock-provider-redis-spring
- @EnableSchedulerLock(defaultLockAtMostFor=PT15M) - @EnableSchedulerLock(defaultLockAtMostFor=PT15M)

View File

@@ -32,6 +32,8 @@ dependencies {
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0' implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0' implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
// #337 — IP 레이트리밋은 Redis SET NX EX 패턴으로 자체 구현 (기존 spring-data-redis 활용)
// Oracle JDBC + Security (Wallet support for Oracle ADB) // Oracle JDBC + Security (Wallet support for Oracle ADB)
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01' implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01' implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'

View File

@@ -90,15 +90,34 @@ public class RestaurantController {
return r; return r;
} }
// #332 — Restaurant 업데이트 화이트리스트 (SQL updateFields의 컬럼 가드와 1:1).
// 허용되지 않은 키는 무시(silent drop). DTO 도입은 후속 작업.
private static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
"name", "address", "region", "cuisine_type", "price_range",
"phone", "website", "tabling_url", "catchtable_url",
"latitude", "longitude", "google_place_id",
"business_status", "rating", "rating_count"
);
@PutMapping("/{id}") @PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) { public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
// #332 — 입력 body를 허용 키만 통과시킨 가변 Map으로 정규화
Map<String, Object> sanitized = new java.util.LinkedHashMap<>();
for (var e : body.entrySet()) {
if (ALLOWED_UPDATE_FIELDS.contains(e.getKey())) {
sanitized.put(e.getKey(), e.getValue());
} else {
log.debug("Ignoring non-whitelisted update field: {}", e.getKey());
}
}
// Re-geocode if name or address changed // Re-geocode if name or address changed
String newName = (String) body.get("name"); String newName = (String) sanitized.get("name");
String newAddress = (String) body.get("address"); String newAddress = (String) sanitized.get("address");
boolean nameChanged = newName != null && !newName.equals(r.getName()); boolean nameChanged = newName != null && !newName.equals(r.getName());
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress()); boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
if (nameChanged || addressChanged) { if (nameChanged || addressChanged) {
@@ -106,26 +125,30 @@ public class RestaurantController {
String geoAddr = newAddress != null ? newAddress : r.getAddress(); String geoAddr = newAddress != null ? newAddress : r.getAddress();
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr); var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
if (geo != null) { if (geo != null) {
body.put("latitude", geo.get("latitude")); sanitized.put("latitude", geo.get("latitude"));
body.put("longitude", geo.get("longitude")); sanitized.put("longitude", geo.get("longitude"));
body.put("google_place_id", geo.get("google_place_id")); sanitized.put("google_place_id", geo.get("google_place_id"));
if (geo.containsKey("formatted_address")) { if (geo.containsKey("formatted_address")) {
body.put("address", geo.get("formatted_address")); sanitized.put("address", geo.get("formatted_address"));
} }
if (geo.containsKey("rating")) body.put("rating", geo.get("rating")); if (geo.containsKey("rating")) sanitized.put("rating", geo.get("rating"));
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count")); if (geo.containsKey("rating_count")) sanitized.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) body.put("phone", geo.get("phone")); if (geo.containsKey("phone")) sanitized.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status")); if (geo.containsKey("business_status")) sanitized.put("business_status", geo.get("business_status"));
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
String addr = (String) geo.get("formatted_address"); String addr = (String) geo.get("formatted_address");
if (addr != null) { if (addr != null) {
body.put("region", GeocodingService.parseRegionFromAddress(addr)); sanitized.put("region", GeocodingService.parseRegionFromAddress(addr));
} }
} }
} }
restaurantService.update(id, body); if (sanitized.isEmpty()) {
// 허용 키가 하나도 없으면 no-op
return Map.of("ok", true, "restaurant", r);
}
restaurantService.update(id, sanitized);
cache.flush(); cache.flush();
var updated = restaurantService.findById(id); var updated = restaurantService.findById(id);
return Map.of("ok", true, "restaurant", updated); return Map.of("ok", true, "restaurant", updated);

View File

@@ -1,7 +1,12 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.domain.SiteVisitStats; import com.tasteby.domain.SiteVisitStats;
import com.tasteby.service.RateLimitService;
import com.tasteby.service.StatsService; import com.tasteby.service.StatsService;
import com.tasteby.util.BotDetector;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
@@ -10,20 +15,51 @@ import java.util.Map;
@RequestMapping("/api/stats") @RequestMapping("/api/stats")
public class StatsController { public class StatsController {
private final StatsService statsService; private static final Logger log = LoggerFactory.getLogger(StatsController.class);
public StatsController(StatsService statsService) { private final StatsService statsService;
private final RateLimitService rateLimitService;
public StatsController(StatsService statsService, RateLimitService rateLimitService) {
this.statsService = statsService; this.statsService = statsService;
this.rateLimitService = rateLimitService;
} }
@PostMapping("/visit") @PostMapping("/visit")
public Map<String, Object> recordVisit() { public Map<String, Object> recordVisit(HttpServletRequest req) {
// #337 — 봇 UA + IP 레이트리밋. 모두 통과해야 카운트 진행.
String ua = req.getHeader("User-Agent");
if (BotDetector.isBot(ua)) {
log.debug("visit skipped (bot): {}", ua);
return Map.of("ok", true, "counted", false);
}
String clientIp = resolveClientIp(req);
if (!rateLimitService.tryConsume(clientIp)) {
log.debug("visit skipped (rate-limit): {}", clientIp);
return Map.of("ok", true, "counted", false);
}
statsService.recordVisit(); statsService.recordVisit();
return Map.of("ok", true); return Map.of("ok", true, "counted", true);
} }
@GetMapping("/visits") @GetMapping("/visits")
public SiteVisitStats getVisits() { public SiteVisitStats getVisits() {
return statsService.getVisits(); return statsService.getVisits();
} }
/**
* #337 — X-Forwarded-For 우선 (Nginx Ingress 뒤). chain이면 첫 번째(원본).
* 없으면 RemoteAddr 폴백.
*/
private static String resolveClientIp(HttpServletRequest req) {
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
int comma = fwd.indexOf(',');
return (comma > 0 ? fwd.substring(0, comma) : fwd).trim();
}
String addr = req.getRemoteAddr();
return addr != null ? addr : "unknown";
}
} }

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), Object.class); Object obj = mapper.readValue(raw.substring(idx, end + 1), Object.class);
items.add(obj); items.add(obj);
idx = end; } catch (Exception ignored2) {
found = true; break; // 불가해 객체 — 멈춤
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

@@ -0,0 +1,51 @@
package com.tasteby.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* #337 — IP 기반 레이트리밋 (방문 카운트 어뷰즈 차단).
*
* 단순 Redis SETIFABSENT(SET NX EX) 패턴:
* - 첫 호출 시 키 등록 + TTL → 허용
* - TTL 동안 다음 호출은 키 존재로 차단
*
* Redis 다운 시 fail-open (true 반환) — 사용자 페이지 로드 우선.
* 멀티 파드 + Redis 단일 인스턴스 환경에서 자연스럽게 동작.
*/
@Service
public class RateLimitService {
private static final Logger log = LoggerFactory.getLogger(RateLimitService.class);
private static final String PREFIX = "ratelimit:visit:";
private final StringRedisTemplate redis;
@Value("${app.rate-limit.visit-window-seconds:60}")
private long visitWindowSeconds;
public RateLimitService(StringRedisTemplate redis) {
this.redis = redis;
}
/**
* 단일 IP의 visit 호출 허용 여부.
* @return true = 허용 (첫 호출 또는 윈도우 만료), false = 차단 (윈도우 안 재호출)
*/
public boolean tryConsume(String ipKey) {
try {
String key = PREFIX + ipKey;
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(visitWindowSeconds));
return Boolean.TRUE.equals(ok);
} catch (Exception e) {
// fail-open: Redis 문제로 통계가 약간 부풀어도 사용자 영향 X
log.debug("RateLimit error (fail-open): {}", e.getMessage());
return true;
}
}
}

View File

@@ -0,0 +1,25 @@
package com.tasteby.util;
import java.util.regex.Pattern;
/**
* #337 — User-Agent 기반 봇 패턴 매칭.
*
* Googlebot / bingbot / facebookexternalhit / 일반 crawler/spider 등을 일괄 차단.
* 빈 UA는 봇으로 간주하지 않음(모바일 앱 등 정상 케이스 보호).
*/
public final class BotDetector {
private BotDetector() {}
// 일반적인 봇/크롤러 패턴. 케이스 무시.
private static final Pattern BOT_PATTERN = Pattern.compile(
"bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse",
Pattern.CASE_INSENSITIVE
);
public static boolean isBot(String userAgent) {
if (userAgent == null || userAgent.isBlank()) return false;
return BOT_PATTERN.matcher(userAgent).find();
}
}

View File

@@ -64,6 +64,10 @@ app:
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값. # 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
max-distance: ${SEARCH_MAX_DISTANCE:0.57} max-distance: ${SEARCH_MAX_DISTANCE:0.57}
rate-limit:
# #337 — 같은 IP에서 visit 카운트 허용 간격(초). 기본 60.
visit-window-seconds: ${VISIT_WINDOW_SECONDS:60}
build: build:
# #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown. # #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown.
version: ${APP_VERSION:dev} version: ${APP_VERSION:dev}

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,94 @@
# 설계서: Restaurant 임의 필드 업데이트 화이트리스트 (#332)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #332 · 부모: #290 (식당 CRUD Reviewer 후속, 09-Done)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`
> · 테스트: 수동 (curl로 화이트리스트 외 필드 시도)
## 1. 목적 (Why)
`PUT /api/restaurants/{id}` body가 `Map<String, Object>`로 임의 키를 받는다. SQL 측 `updateFields`는 컬럼별 `<if test="containsKey('...')">` 가드로 화이트리스트가 이미 적용되어 있어 임의 컬럼 갱신은 차단된다. 다만 Controller 레벨에서 화이트리스트가 명시되지 않아 — (a) 의도 모호, (b) 향후 SQL 측 가드가 무력화되거나 다른 매퍼로 확장되면 위험. 명시적 화이트리스트로 의도 강화.
## 2. 범위 (Scope)
- **포함**
- `RestaurantController.update(id, body)`에서 허용된 키만 통과시키는 `ALLOWED_UPDATE_FIELDS` set.
- 허용되지 않은 키는 무시(silently drop) + 디버그 로그. 400 응답은 hard policy라 사용자 경험 영향 큼.
- **제외 (out of scope)**
- DTO 클래스 도입 (RestaurantUpdateDTO + Bean Validation) — 더 강한 표준화지만 코드 영향 큼. 후속.
- DDG HTML 검색 → 정식 API 전환 (별도 후속, 비용/계약 결정 필요).
- isNameSimilar 한국어 자모 알고리즘 (별도 후속).
- UNIQUE(google_place_id) 제약 강화 (DB 마이그레이션 + 데이터 정리 필요, 별도).
## 3. 인수조건
- [ ] `ALLOWED_UPDATE_FIELDS` 상수 정의 (SQL `updateFields` 가드와 일치).
- [ ] PUT 호출 시 body에서 허용 외 키는 자동 제거 + 디버그 로그.
- [ ] 허용 키만 있는 정상 호출 → 200 정상 동작 회귀 없음.
- [ ] 허용 외 키만 있는 호출 → 200 + 변경 없음 (또는 200 + 빈 업데이트).
## 4. 컨텍스트 & 제약
- Spring MVC. 변경 최소화. DTO 도입 없이도 가드 가능.
- 운영 영향 없음 (어드민이 화면에서 호출하는 키는 모두 허용 set 안에).
- 가정: 화이트리스트 set은 SQL `updateFields``<if>` 키들과 1:1.
## 5. 아키텍처 개요
```
PUT /api/restaurants/{id} body
RestaurantController.update
├ requireAdmin
├ allowed = ALLOWED_UPDATE_FIELDS
├ filtered = body where key ∈ allowed
├ if (filtered.isEmpty()) → 200 + no-op
└ restaurantService.update(id, filtered) → mapper.updateFields
```
## 6. 데이터 모델
`Set<String> ALLOWED_UPDATE_FIELDS = Set.of(name, address, region, cuisine_type, price_range, phone, website, tabling_url, catchtable_url, latitude, longitude, google_place_id, business_status, rating, rating_count)` — SQL `updateFields`의 키들과 일치.
## 7. 함수 명세
| 함수 | 책임 | 비고 |
|------|------|------|
| `RestaurantController.update(id, body)` (수정) | 화이트리스트 필터 후 위임 | filtered.isEmpty()면 no-op |
## 8. 흐름
1. body Map 수신.
2. `body.entrySet().stream().filter(e -> ALLOWED_UPDATE_FIELDS.contains(e.getKey())).collect(toMap)`.
3. 비어있으면 200 응답하고 끝.
4. 아니면 `restaurantService.update(id, filtered)` 호출.
## 9. 엣지케이스
- **빈 body**: 200 + no-op.
- **허용 외 키만**: 200 + no-op + 디버그 로그.
- **null 값을 포함한 허용 키**: SQL `updateFields`가 그대로 NULL 저장 — 의도된 동작 (좌표/주소 해제 등).
## 10. 테스트
- 수동:
```
curl -X PUT -H "Authorization: Bearer <admin>" -H "Content-Type: application/json" \
-d '{"name":"테스트", "is_admin":true}' /api/restaurants/<id>
→ name만 갱신, is_admin은 무시
```
- 자동: 별도 후속(통합 테스트 인프라 도입 시).
## 11. 리스크 & 대안
- **선택**: Controller 화이트리스트 set + silent drop.
- **대안 A**: DTO + Bean Validation — 표준화 깔끔하지만 변경 범위 큼.
- **대안 B**: 허용 외 키 발견 시 400 — 사용자 경험 부담, 클라이언트가 잘못된 키 보내면 즉시 실패. 본 변경은 보수적으로 silent drop.
## 12. 미해결 질문
- DDG → 정식 검색 API 전환 — 별도 후속 (비용/계약 결정).
- isNameSimilar 한국어 알고리즘 — 별도 후속.
- DTO 도입 표준화 — 별도 후속.