Compare commits

..

8 Commits

Author SHA1 Message Date
joungmin
e5dc0534c4 feat(daemon): #335 분산 락 (ShedLock + Redis)
build.gradle:
- shedlock-spring 5.16.0
- shedlock-provider-redis-spring 5.16.0

TastebyApplication: @EnableSchedulerLock(defaultLockAtMostFor=PT15M)

ShedLockConfig 신규: RedisLockProvider Bean (in-cluster Redis 재사용)

DaemonScheduler.run:
- @SchedulerLock(name="daemon-runner", lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
- 멀티 파드 환경(RollingUpdate 등)에서 한 인스턴스만 실행
- Redis 키: lock:daemon-runner

설계서: docs/design/335-daemon-distributed-lock/README.md (commit c88cb6a)

Refs: #335 (Developer 단계)
2026-06-15 15:18:14 +09:00
joungmin
c88cb6ad54 docs(design): #335 데몬 분산 락 설계서 (Architect)
ShedLock + Redis lock provider 선택. DaemonScheduler.run을
@SchedulerLock(name='daemon-runner', lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
로 보호. RollingUpdate 시 두 파드 공존 중 YouTube/OCI 중복 호출 차단.

설계서: docs/design/335-daemon-distributed-lock/README.md (Approved, 12개 섹션)

Refs: #335 (Architect 단계)
2026-06-15 15:16:06 +09:00
joungmin
079384b645 docs(changelog): v0.1.29 #336 SCAN/UNLINK/복구/메트릭 기록 2026-06-15 15:09:57 +09:00
joungmin
c7bd3c4c09 feat(cache): #336 SCAN/UNLINK + disabled 자동 복구 + 에러 메트릭
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹.
  UNLINK 미지원 환경은 DEL로 폴백. 500 batch 단위.
- 30초 주기 @Scheduled checkHealth: Redis ping → disabled 자동 토글.
  startup 시 disabled=true여도 Redis 재기동되면 자동 복구.
- recordError 헬퍼: AtomicLong errorCount + volatile lastError.
  로그 throttle (n==1 || n%100==0만 WARN, 나머지 DEBUG).
- CacheStats record + GET /api/admin/cache/stats (admin only).
- 설계서: docs/design/336-cache-scan-recovery/README.md (Approved).

Refs: #336
2026-06-15 15:07:22 +09:00
joungmin
1a5db34e15 fix(review): #334 ReviewService update/delete @Transactional 명시 (단일 SQL이지만 일관성) 2026-06-15 14:55:51 +09:00
joungmin
f126664117 docs(changelog): P5-2 작은 후속 기록 2026-06-15 14:51:22 +09:00
joungmin
a0e8878d9a feat: P5-2 작은 후속 (#338+#320+#340+#333)
#338: /api/version 신규
- HealthController에 @Value 빌드 정보 + GET /api/version 추가
- SecurityConfig.permitAll에 /api/version 추가
- application.yml app.build.version/commit (env APP_VERSION/APP_COMMIT)
- 부수: SecurityConfig에서 /api/daemon/config permitAll 제거 (이미 admin-only)

#320: findRegionFromCoords 거리 보정
- 유클리드 거리 → cos(lat) 가중치(equirectangular approx)로 위경도 실거리 보정
- 위도가 큰 지역(부산↔서울)에서 city 추정 정확도 향상

#340: MapView 마커/범례 ARIA
- 클러스터 마커: role=button + aria-label
- 개별 식당 마커: role=button + aria-label (name + 폐업 여부)
- 채널 범례: role=region + aria-label, 색상 점은 aria-hidden

#333: ChannelController 캐시 세분화
- cache.flush() 전체 무효화 → cache.del(makeKey("channels"))로 채널 키만 evict
- 다른 모듈(restaurants/search) 캐시 hit율 보존

후속: deploy.sh에 APP_VERSION/APP_COMMIT env 주입은 별도 (현재 dev/unknown 응답)

Refs: #338 #320 #340 #333
2026-06-15 14:48:32 +09:00
joungmin
3304b9c54f docs(changelog): v0.1.24 P5-1 작은 후속 기록 2026-06-15 14:44:08 +09:00
16 changed files with 485 additions and 33 deletions

View File

@@ -6,6 +6,27 @@
## 2026-06-15 ## 2026-06-15
### 💾 #336 캐시 SCAN/UNLINK + 자동 복구 + 에러 메트릭 (v0.1.29)
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch)
- @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구)
- AtomicLong errorCount + volatile lastError + 로그 throttle (n==1 또는 n%100==0)
- GET /api/admin/cache/stats: disabled/errorCount/lastError 노출 (admin only)
- 설계서: docs/design/336-cache-scan-recovery/README.md
- Refs: #336 (close)
### 🔧 P5-2 작은 후속 (v0.1.26)
- #338: /api/version 신규 (HealthController + permitAll), application.yml app.build.{version,commit} env 주입 준비
- #320: findRegionFromCoords 거리 보정 (유클리드 → cos(lat) 가중치)
- #340: MapView 클러스터/마커/범례에 role/aria-label
- #333: ChannelController cache.flush() → cache.del("channels") (다른 모듈 캐시 보존)
- Refs: #338 #320 #340 #333 (close)
### 🧹 P5-1 작은 후속 묶음 (v0.1.24)
- #325: ThreadLocalRandom 통일, rebuildVectors not_implemented 이벤트, getTranscript JavaDoc 명세
- #319: buildSearchQuery 헬퍼 + fn-doc(BottomSheet snap 정책)
- #344: --z-bottom-sheet/--z-filter-sheet/--z-modal CSS 변수 + LoginMenu zIndex 99999 → var(--z-modal)
- Refs: #319 #325 #344 (close)
### ⭐ P4-4 별점 공통화 + 로그인 모달 접근성 (v0.1.23) ### ⭐ P4-4 별점 공통화 + 로그인 모달 접근성 (v0.1.23)
- #281: 공통 Stars 컴포넌트 (0.5단위 절반 채우기), StarSelector role=radiogroup + 44px + 반쪽 별 ⯨, try/catch + alert - #281: 공통 Stars 컴포넌트 (0.5단위 절반 채우기), StarSelector role=radiogroup + 44px + 반쪽 별 ⯨, try/catch + alert
- #283: LoginMenu에 useEscapeKey/useFocusTrap/useBodyScrollLock 훅 적용, role=dialog/aria-modal/aria-labelledby, onError 인라인 alert - #283: LoginMenu에 useEscapeKey/useFocusTrap/useBodyScrollLock 훅 적용, role=dialog/aria-modal/aria-labelledby, onError 인라인 alert

View File

@@ -28,6 +28,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'
// #335 — 분산 락 (RollingUpdate 시 멀티 파드 공존 중 데몬 중복 실행 차단)
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
// 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

@@ -1,5 +1,6 @@
package com.tasteby; package com.tasteby;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
@@ -8,6 +9,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableAsync @EnableAsync
@EnableScheduling @EnableScheduling
// #335 — defaultLockAtMostFor: 어떤 작업이 lockAtMostFor 명시 안 해도 보호 (안전 마진)
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
public class TastebyApplication { public class TastebyApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(TastebyApplication.class, args); SpringApplication.run(TastebyApplication.class, args);

View File

@@ -30,13 +30,14 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Public endpoints // Public endpoints
.requestMatchers("/api/health").permitAll() .requestMatchers("/api/health").permitAll()
.requestMatchers("/api/version").permitAll() // #338 — 빌드 정보 공개
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/search").permitAll() .requestMatchers(HttpMethod.GET, "/api/search").permitAll()
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll() .requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll()
.requestMatchers("/api/stats/**").permitAll() .requestMatchers("/api/stats/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/daemon/config").permitAll() // #275 — /api/daemon/config는 admin-only로 변경 (이전 permitAll 제거)
// Everything else requires authentication (controller-level admin checks) // Everything else requires authentication (controller-level admin checks)
.anyRequest().authenticated() .anyRequest().authenticated()
) )

View File

@@ -0,0 +1,22 @@
package com.tasteby.config;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
/**
* #335 — ShedLock LockProvider (Redis 기반).
*
* 데몬 스케줄러가 다중 파드 환경에서 한 번에 하나만 실행되도록 보장.
* key prefix는 ShedLock 기본 ("lock:")을 사용 → Redis 키는 `lock:daemon-runner`.
*/
@Configuration
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory);
}
}

View File

@@ -22,4 +22,14 @@ public class AdminCacheController {
cacheService.flush(); cacheService.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }
/**
* #336 — 캐시 상태 가시화: disabled / errorCount / lastError.
* 외부 모니터링 도구 도입 전 운영자가 어드민에서 확인 가능.
*/
@GetMapping("/cache/stats")
public CacheService.CacheStats cacheStats() {
AuthUtil.requireAdmin();
return cacheService.getStats();
}
} }

View File

@@ -62,7 +62,8 @@ public class ChannelController {
} }
try { try {
String id = channelService.create(channelId, channelName, titleFilter); String id = channelService.create(channelId, channelName, titleFilter);
cache.flush(); // #333 — 전체 flush 대신 channels 키만 evict (다른 모듈 캐시 보존)
cache.del(cache.makeKey("channels"));
return Map.of("id", id, "channel_id", channelId); return Map.of("id", id, "channel_id", channelId);
} catch (DataIntegrityViolationException e) { } catch (DataIntegrityViolationException e) {
// #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고). // #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).

View File

@@ -1,5 +1,6 @@
package com.tasteby.controller; package com.tasteby.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -8,8 +9,20 @@ import java.util.Map;
@RestController @RestController
public class HealthController { public class HealthController {
// #338 — 배포 시 set되는 빌드 정보. 미설정 시 "dev"로 표시.
@Value("${app.build.version:dev}")
private String version;
@Value("${app.build.commit:unknown}")
private String commit;
@GetMapping("/api/health") @GetMapping("/api/health")
public Map<String, String> health() { public Map<String, String> health() {
return Map.of("status", "ok"); return Map.of("status", "ok");
} }
@GetMapping("/api/version")
public Map<String, String> version() {
return Map.of("version", version, "commit", commit);
}
} }

View File

@@ -5,46 +5,46 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Set; import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
@Service @Service
public class CacheService { public class CacheService {
private static final Logger log = LoggerFactory.getLogger(CacheService.class); private static final Logger log = LoggerFactory.getLogger(CacheService.class);
private static final String PREFIX = "tasteby:"; private static final String PREFIX = "tasteby:";
private static final String SCAN_PATTERN = PREFIX + "*";
private static final int SCAN_BATCH = 500;
private final StringRedisTemplate redis; private final StringRedisTemplate redis;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Duration ttl; private final Duration ttl;
private boolean disabled = false;
// #336 — disabled/errorCount/lastError는 헬스체크와 다른 호출 스레드 사이에서 안전하게 공유.
private volatile boolean disabled = false;
private final AtomicLong errorCount = new AtomicLong(0);
private volatile String lastError = null;
public CacheService(StringRedisTemplate redis, ObjectMapper mapper, public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) { @Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
this.redis = redis; this.redis = redis;
this.mapper = mapper; this.mapper = mapper;
this.ttl = Duration.ofSeconds(ttlSeconds); this.ttl = Duration.ofSeconds(ttlSeconds);
// #276 — ping 연결 자원 누수 방지: try-with-resources this.disabled = !pingOk();
var factory = redis.getConnectionFactory(); if (!disabled) log.info("Redis connected");
if (factory == null) {
log.warn("Redis ConnectionFactory is null, caching disabled");
disabled = true;
return;
}
try (var conn = factory.getConnection()) {
conn.ping();
log.info("Redis connected");
} catch (Exception e) {
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
disabled = true;
}
} }
public String makeKey(String... parts) { public String makeKey(String... parts) {
// #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지
if (parts == null || parts.length == 0) { if (parts == null || parts.length == 0) {
throw new IllegalArgumentException("makeKey requires at least one part"); throw new IllegalArgumentException("makeKey requires at least one part");
} }
@@ -62,7 +62,7 @@ public class CacheService {
return mapper.readValue(val, type); return mapper.readValue(val, type);
} }
} catch (Exception e) { } catch (Exception e) {
log.debug("Cache get error: {}", e.getMessage()); recordError("get", e);
} }
return null; return null;
} }
@@ -72,7 +72,7 @@ public class CacheService {
try { try {
return redis.opsForValue().get(key); return redis.opsForValue().get(key);
} catch (Exception e) { } catch (Exception e) {
log.debug("Cache get error: {}", e.getMessage()); recordError("getRaw", e);
return null; return null;
} }
} }
@@ -83,30 +83,114 @@ public class CacheService {
String json = mapper.writeValueAsString(value); String json = mapper.writeValueAsString(value);
redis.opsForValue().set(key, json, ttl); redis.opsForValue().set(key, json, ttl);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
log.debug("Cache set error: {}", e.getMessage()); recordError("set:serialize", e);
} catch (Exception e) {
recordError("set", e);
} }
} }
/**
* #336 — KEYS 블로킹 명령 대체.
* SCAN으로 cursor 순회 후 UNLINK(논블로킹 삭제)로 일괄 삭제.
*/
public void flush() { public void flush() {
if (disabled) return; if (disabled) return;
try { Integer count = redis.execute((org.springframework.data.redis.core.RedisCallback<Integer>) conn -> {
Set<String> keys = redis.keys(PREFIX + "*"); List<byte[]> batch = new ArrayList<>(SCAN_BATCH);
if (keys != null && !keys.isEmpty()) { int deleted = 0;
redis.delete(keys); try (Cursor<byte[]> cursor = conn.keyCommands().scan(
ScanOptions.scanOptions().match(SCAN_PATTERN).count(SCAN_BATCH).build())) {
while (cursor.hasNext()) {
batch.add(cursor.next());
if (batch.size() >= SCAN_BATCH) {
deleted += unlinkBatch(conn, batch);
batch.clear();
}
}
if (!batch.isEmpty()) {
deleted += unlinkBatch(conn, batch);
} }
log.info("Cache flushed");
} catch (Exception e) { } catch (Exception e) {
log.debug("Cache flush error: {}", e.getMessage()); recordError("flush:scan", e);
}
return deleted;
});
log.info("Cache flushed ({} keys via SCAN+UNLINK)", count == null ? 0 : count);
}
private int unlinkBatch(org.springframework.data.redis.connection.RedisConnection conn, List<byte[]> keys) {
try {
Long n = conn.keyCommands().unlink(keys.toArray(new byte[0][]));
return n == null ? 0 : n.intValue();
} catch (Exception e) {
// UNLINK 미지원 환경 대비 DEL 폴백
recordError("flush:unlink", e);
try {
Long n = conn.keyCommands().del(keys.toArray(new byte[0][]));
return n == null ? 0 : n.intValue();
} catch (Exception delErr) {
recordError("flush:del", delErr);
return 0;
}
} }
} }
// #290 — 단일 키 삭제 (캐시 역직렬화 실패 시 자동 evict 등에 사용)
public void del(String key) { public void del(String key) {
if (disabled) return; if (disabled) return;
try { try {
redis.delete(key); redis.delete(key);
} catch (Exception e) { } catch (Exception e) {
log.debug("Cache del error: {}", e.getMessage()); recordError("del", e);
} }
} }
/**
* #336 — Redis 다운 → disabled=true, 재기동되면 자동으로 disabled=false.
* 30초마다 ping 한 번(<1ms)이라 부하 미미.
*/
@Scheduled(fixedDelay = 30_000L)
public void checkHealth() {
boolean ok = pingOk();
if (ok && disabled) {
disabled = false;
log.info("Redis recovered, caching re-enabled");
} else if (!ok && !disabled) {
disabled = true;
log.warn("Redis lost, caching disabled");
}
}
private boolean pingOk() {
RedisConnectionFactory factory = redis.getConnectionFactory();
if (factory == null) return false;
try (var conn = factory.getConnection()) {
conn.ping();
return true;
} catch (Exception e) {
lastError = "ping: " + e.getMessage();
return false;
}
}
private void recordError(String op, Exception e) {
long n = errorCount.incrementAndGet();
String msg = e.getMessage();
lastError = op + ": " + (msg == null ? e.getClass().getSimpleName() : msg);
// 한 번씩만 WARN, 나머지는 DEBUG로 (운영 로그 폭주 방지 — 단순한 throttle)
if (n == 1 || n % 100 == 0) {
log.warn("Cache {} error #{}: {}", op, n, lastError);
} else {
log.debug("Cache {} error #{}: {}", op, n, lastError);
}
}
public boolean isDisabled() {
return disabled;
}
public CacheStats getStats() {
return new CacheStats(disabled, errorCount.get(), lastError);
}
public record CacheStats(boolean disabled, long errorCount, String lastError) {}
} }

View File

@@ -1,6 +1,7 @@
package com.tasteby.service; package com.tasteby.service;
import com.tasteby.domain.DaemonConfig; import com.tasteby.domain.DaemonConfig;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -37,6 +38,10 @@ public class DaemonScheduler {
} }
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds @Scheduled(fixedDelay = 30_000) // Check every 30 seconds
// #335 — 분산 락: 멀티 파드 환경에서 한 인스턴스만 실행. Redis 키 `lock:daemon-runner`.
// lockAtMostFor: 작업이 비정상 종료돼도 15분 후 강제 해제 (다음 cron이 잡을 수 있게)
// lockAtLeastFor: 빨리 끝나도 30초 동안 유지 (즉시 다른 cron이 같은 작업 잡는 것 방지)
@SchedulerLock(name = "daemon-runner", lockAtMostFor = "PT15M", lockAtLeastFor = "PT30S")
public void run() { public void run() {
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지). // 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함. // dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.

View File

@@ -38,11 +38,13 @@ public class ReviewService {
return mapper.findById(id); return mapper.findById(id);
} }
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) { public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null; String visitedStr = visitedAt != null ? visitedAt.toString() : null;
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0; return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
} }
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
public boolean delete(String reviewId, String userId) { public boolean delete(String reviewId, String userId) {
return mapper.deleteReview(reviewId, userId) > 0; return mapper.deleteReview(reviewId, userId) > 0;
} }

View File

@@ -64,6 +64,11 @@ 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}
build:
# #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown.
version: ${APP_VERSION:dev}
commit: ${APP_COMMIT:unknown}
daemon: daemon:
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로 # 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다. # dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.

View File

@@ -0,0 +1,104 @@
# 설계서: 데몬 스케줄러 분산 락 (#335)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #335 · 부모: #275 (현행화 backend-daemon, 09-Done)
> · 구현 파일: `backend-java/build.gradle`, `backend-java/src/main/java/com/tasteby/TastebyApplication.java`, `backend-java/src/main/java/com/tasteby/config/ShedLockConfig.java` (신규), `backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java`
> · 테스트: 수동 (롤링 업데이트 시 두 파드 공존 시뮬레이션)
## 1. 목적 (Why)
OKE 운영에서 backend Pod 1개로 동작하지만 RollingUpdate(maxSurge>0) 시 신·구 Pod이 잠시 공존. 또한 dev(PM2)와 운영이 같은 Oracle ATP를 공유 — 이미 `DAEMON_ENABLED` 플래그로 dev 폴링은 차단했지만, 운영 자체에서 두 Pod이 같은 30초 주기로 `scanAllChannels`를 호출하면 YouTube/OCI GenAI 중복 호출 + 동일 영상 두 번 처리 + 봇 감지 위험. ShedLock으로 한 인스턴스만 실행하도록 보장.
## 2. 범위 (Scope)
- **포함**
- `DaemonScheduler.run()`을 분산 락으로 보호 (lockAtMostFor + lockAtLeastFor).
- Lock provider: Redis (이미 운영 중인 in-cluster Redis 재사용).
- 의존성: `net.javacrumbs.shedlock:shedlock-spring`, `shedlock-provider-redis-spring`.
- **제외 (out of scope)**
- 다른 @Scheduled 메서드(CacheService.checkHealth, 향후 추가될 cron). 필요 시 같은 패턴으로 확장.
- 락 획득 실패 시 알람 — Spring Actuator/Micrometer 도입 후 후속.
- DB 기반 lock provider (JDBC) — Redis가 충분.
## 3. 인수조건
- [ ] build.gradle에 shedlock-spring + shedlock-provider-redis-spring 추가.
- [ ] `@EnableSchedulerLock` 활성화.
- [ ] `DaemonScheduler.run``@SchedulerLock(name="daemon-runner", ...)` 적용.
- [ ] 락 키는 `lock:daemon-runner` 형태로 Redis에 저장 (prefix 기본).
- [ ] 운영 배포 후 로그에 lock acquire/release 메시지 또는 정상 동작 확인.
- [ ] 회귀 없음 — 자동 cron 정상 동작.
## 4. 컨텍스트 & 제약
- Redis는 in-cluster 단일 인스턴스. ShedLock의 Redis provider는 단일 인스턴스에서 SET NX EX로 동작.
- Pod 1개 운영이라 평소엔 락 경합 없음 → ShedLock 부하 미미 (Redis 1회 SET NX EX, <1ms).
- `lockAtMostFor`: 락이 강제로 해제되기까지 시간. `scanAllChannels`는 channel 6 × 영상 fetch 시간 ≈ 최대 10분 예상. `PT15M`로 안전 마진.
- `lockAtLeastFor`: 작업이 빨리 끝나도 락 유지하는 최소 시간 (다음 cron이 즉시 잡지 못하게). 30초 cycle이라 PT30S로 충분.
## 5. 아키텍처 개요
```
[Pod A] [Pod B]
│ │
│ @Scheduled(fixedDelay=30s)
▼ ▼
DaemonScheduler.run DaemonScheduler.run
│ │
│ @SchedulerLock │ @SchedulerLock
▼ ▼
LockProvider (Redis)
├─ SET lock:daemon-runner EX 900 NX ✓ → Pod A 진행
└─ SET lock:daemon-runner EX 900 NX ✗ → Pod B 즉시 종료(no-op)
scanAllChannels / processPending 실행 (A만)
▼ 종료 시 락 키 lockUntil 시각으로 갱신 (lockAtLeastFor 보장)
```
## 6. 데이터 모델
Redis 키 1개:
- key: `lock:daemon-runner`
- value: lockedBy(host:pid) + lockedAt
- expiry: lockAtMostFor
## 7. 함수 명세
| 함수 | 책임 | 시그니처 | 비고 |
|------|------|----------|------|
| `DaemonScheduler.run()` (수정) | @SchedulerLock 추가 | 기존 | name="daemon-runner" |
| `ShedLockConfig.lockProvider(...)` (신규) | Bean 등록 | `LockProvider lockProvider(RedisConnectionFactory)` | Redis provider |
## 8. 흐름
1. 30초마다 fixedDelay로 run() 호출.
2. ShedLock AOP가 SET NX EX 시도.
3. 성공: 본문 실행. 실패: 즉시 반환(no-op).
4. 본문 종료 시 lockUntil 갱신.
## 9. 엣지케이스
- **lockAtMostFor 초과 작업**: 락 자동 해제 후 다른 Pod이 잡을 수 있음. scanAllChannels가 15분 넘기지 않게 channel별 timeout 적용 권고(설계서 #275 §11 참고).
- **Pod 죽음**: lockAtMostFor 만료 후 자동 해제.
- **Redis 다운**: SET 실패 → Spring AOP가 RuntimeException → 다음 30초에 재시도. 캐시 disabled와 별개.
- **clock skew**: ShedLock은 Redis 서버 시간 기준이라 클러스터 노드 간 시간 차이 무관.
## 10. 테스트 계획
- 수동: Pod 2개 동시 실행 (kubectl scale deploy backend --replicas=2) 후 로그에서 한 쪽만 `Running scheduled channel scan...` 찍히는지 확인.
- 자동: 후속 (ShedLock 자체는 lib 차원에서 테스트됨).
## 11. 리스크 & 대안
- **선택**: ShedLock + Redis.
- **대안 A**: Redis SET NX EX 수동 구현 — 가능하나 ShedLock이 lockAtMostFor/lockAtLeastFor 자동 처리해서 더 안전.
- **대안 B**: DB(Oracle) 기반 ShedLock — 추가 테이블 필요 + DB 부하. Redis가 더 단순.
- **대안 C**: 단일 leader pod (k8s Lease object) — Spring Cloud Kubernetes 도입 부담 크다.
## 12. 미해결 질문
- ShedLock 의존성이 standard library가 아닌 4th-party에 가까움 — 검증된 라이브러리(8년+ 사용, 4k+ stars)지만 향후 Spring 마이크로 버전 호환성은 별도 모니터링.
- CacheService.checkHealth는 락 안 걸어도 됨(idempotent). 추가 cron 도입 시 same name 충돌 주의.

View File

@@ -0,0 +1,162 @@
# 설계서: 캐시 SCAN/UNLINK + disabled 자동 복구 + 에러 메트릭 (#336)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #336 · 부모: #276 (현행화 backend-cache, 09-Done)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/CacheService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminCacheController.java`
> · 테스트: 후속 (Testcontainers Redis 인프라는 별도)
## 1. 목적 (Why)
`CacheService.flush()``redis.keys("tasteby:*")` 블로킹 명령을 사용해 키가 많아지면 Redis 인스턴스 전체가 정지(Redis는 single-threaded). 또한 생성자에서 한 번 ping 실패하면 `disabled=true`로 영구 no-op 상태 — Redis가 재기동되어도 자동 복구 안 됨. 그리고 set/get/flush 실패가 DEBUG 로그로만 묻혀 운영 monitoring 사각지대.
## 2. 범위 (Scope)
- **포함**
- `flush()`/추후 `flushByPrefix()``SCAN` + `UNLINK`(논블로킹 삭제)로 교체.
- 30초 주기 헬스체크로 `disabled` 플래그 자동 토글 (Redis 재기동 시 자동 복구).
- 캐시 에러 카운터(in-memory `AtomicLong`)와 마지막 에러 메시지를 노출하는 admin 엔드포인트.
- **제외 (out of scope)**
- Micrometer/Prometheus 메트릭 stack 도입(별도 이슈, Spring Boot Actuator + 별도 인프라).
- Testcontainers Redis 기반 단위 테스트(별도 후속, 인프라 도입 비용 큼).
- 캐시 key 네임스페이스 다중화.
## 3. 인수조건 (Acceptance Criteria)
- [ ] `flush()``KEYS` 대신 SCAN 커서 기반으로 동작한다 (블로킹 없음).
- [ ] 삭제는 `UNLINK`(Redis 4.0+ 논블로킹) 사용. 미지원 환경에서는 `DEL`로 폴백.
- [ ] Redis가 다운된 상태에서 startup → `disabled=true`. 이후 Redis 재기동되면 60초 이내 `disabled=false`로 자동 복구되어 set/get 정상 동작한다.
- [ ] set/get/flush/del의 예외는 `cacheErrorCount` 카운터가 증가하고 `lastError`에 메시지를 기록한다.
- [ ] `GET /api/admin/cache/stats``{ disabled, errorCount, lastError }` 응답.
- [ ] 기존 캐시 동작(hit/miss/TTL) 회귀 없음.
- [ ] 운영 배포 후 외부 `/api/restaurants` 응답이 캐시 hit 경로에서 변함없이 동작.
## 4. 컨텍스트 & 제약
- Spring Data Redis 3.x + Lettuce 클라이언트.
- 운영 Redis: OKE in-cluster (단일 인스턴스, persistence X). UNLINK 지원.
- 키 prefix: `tasteby:`. 현재 키 개수는 수십~수백 (식당/검색/채널 캐시), 향후 수만으로 증가 가능성.
- 30초 헬스체크 추가 부하 미미(ping 한 번 = 0.5ms 이하).
- 가정: `StringRedisTemplate.execute(RedisCallback)`에서 native `ScanOptions` + `RedisServerCommands.scan()` 사용 가능.
## 5. 아키텍처 개요
```
[ Spring Context ]
CacheService
├─ disabled : volatile boolean (자동 토글)
├─ errorCount : AtomicLong
├─ lastError : volatile String
├─ get/set/del/flush
│ └─ try {} catch { errorCount.incrementAndGet(); lastError = ...; }
├─ flush() ⟶ redis.execute(connection -> {
│ ScanOptions opt = match("tasteby:*").count(500);
│ while (cursor.hasNext()) keys.add(cursor.next());
│ if (!keys.isEmpty()) connection.keyCommands().unlink(keys);
│ })
└─ @Scheduled(fixedDelay=30_000)
checkHealth()
├ try { ping } → disabled = false (회복)
└ catch → disabled = true (또는 유지)
AdminCacheController
└─ GET /api/admin/cache/stats → { disabled, errorCount, lastError }
```
I/O ↔ 순수 로직 경계: SCAN 루프는 Redis 통신이지만 결과 처리는 단순 `for` 루프. 헬스체크는 단일 ping. 에러 기록은 atomic.
## 6. 데이터 모델
| 필드 | 타입 | 의미 |
|------|------|------|
| `disabled` | `volatile boolean` | 캐시 일시 비활성 (Redis 다운 시 true) |
| `errorCount` | `AtomicLong` | 누적 에러 횟수 (set/get/flush/del 통합) |
| `lastError` | `volatile String` | 최근 에러 메시지 (운영 디버깅용) |
응답 DTO:
```json
{ "disabled": false, "errorCount": 0, "lastError": null }
```
## 7. 함수 명세
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러 | 복잡? |
|------|-----------|----------|------|------|------|-------|
| `CacheService.flush()` (수정) | SCAN+UNLINK 기반 prefix 삭제 | `void flush()` | - | side-effect | recordError() | **복잡** |
| `CacheService.checkHealth()` (신규) | 30초마다 ping → disabled 토글 | `void checkHealth()` (@Scheduled) | - | side-effect | disabled=true 유지 | 단순 |
| `CacheService.recordError(op, e)` (신규) | 카운터 증가 + lastError 기록 | `void recordError(String, Exception)` | op, e | side-effect | - | 단순 |
| `CacheService.getStats()` (신규) | 외부 노출용 stats | `CacheStats getStats()` | - | DTO | - | 단순 |
| `AdminCacheController.stats()` (신규) | GET endpoint | `Map stats()` | - | DTO | requireAdmin | 단순 |
## 8. 흐름 / 알고리즘
### flush (SCAN + UNLINK)
```
batch = 500 (한 번에 받는 키 수)
keys = []
redis.execute(conn ->
try (Cursor<byte[]> cursor = conn.keyCommands().scan(ScanOptions.scanOptions().match("tasteby:*").count(batch).build())) {
while (cursor.hasNext()) keys.add(new String(cursor.next()));
}
if (!keys.isEmpty()) conn.keyCommands().unlink(keys.toArray(byte[][]));
return null;
)
```
### 헬스체크
```
@Scheduled(fixedDelay = 30_000)
void checkHealth() {
if (DAEMON_ENABLED env가 false면 dev에서 노이즈 피해 skip 가능 — 단, 캐시 헬스체크는 데몬 플래그와 무관하니 항상 실행)
try (conn = factory.getConnection()) {
conn.ping();
if (disabled) { log.info("Redis recovered"); disabled = false; }
} catch (Exception e) {
if (!disabled) { log.warn("Redis lost: {}", e); disabled = true; }
}
}
```
### 에러 기록
```
catch (Exception e) {
errorCount.incrementAndGet();
lastError = op + ": " + e.getMessage();
log.warn("Cache {} error (count={}): {}", op, errorCount.get(), e.getMessage());
}
```
## 9. 엣지케이스 & 에러 처리
- **SCAN 중 다른 스레드가 키 추가/삭제**: SCAN의 best-effort 보장상 일부 키 누락 가능. flush의 자연 무효화(TTL)와 함께 작동하면 영향 미미.
- **UNLINK 미지원 Redis(2.x)**: Spring Data Redis가 fallback하지 않으므로 `DEL`로 명시 폴백. 운영 Redis는 6.x라 미지원 가능성 거의 없음.
- **헬스체크와 set/get 동시 호출**: volatile + atomic 사용. race 가능하지만 영향 작음 (잠시 후 보정).
- **로그 폭주**: 같은 에러가 매번 발생하면 WARN 로그가 폭주 — 운영에서 모니터링 후 throttle 검토 (후속).
- **fixedDelay=30s 가 너무 잦은가**: ping은 0.5ms 미만이라 무해.
## 10. 테스트 계획
- 수동:
- dev에서 Redis 임시 중단(`pm2 stop redis` 등) → 60초 후 `/api/admin/cache/stats` 의 disabled=true 확인.
- Redis 재기동 → 60초 이내 disabled=false 자동 복구 확인.
- `/api/restaurants` 호출로 캐시 set/get 작동 확인.
- 자동: Testcontainers Redis 기반 단위 테스트는 별도 후속.
## 11. 리스크 & 대안 검토
- **선택**: SCAN + UNLINK + @Scheduled 헬스체크.
- **대안 A**: TTL만 의존(flush 폐기) — 단순하지만 즉시 무효화 불가, 어드민 강제 무효화 시나리오 손상.
- **대안 B**: Redis 6.0+의 `FLUSHDB ASYNC` — 더 단순하지만 prefix 격리 안 됨(다른 앱이 같은 Redis 공유 시 위험). tasteby Redis는 전용이라 가능하지만 일반화 위해 SCAN/UNLINK 채택.
- **대안 C**: Lettuce native `RedisAsyncCommands.scan` 직접 사용 — 더 빠르지만 추상화 레벨 낮춤.
- **트레이드오프**: SCAN은 N개 키마다 cursor 왕복 발생 → flush 1회 latency 증가(키 1만 개 기준 ~50ms). 비동기 UNLINK로 삭제는 빠름.
## 12. 미해결 질문
- Micrometer 메트릭(JVM/캐시) 도입 시 errorCount를 prom으로 export — 별도 후속.
- Redis sentinel/cluster 도입 시 헬스체크 의미 재정의 — 현재 단일 인스턴스라 무관.
- `lastError` 노출이 운영자에게 충분한가, 또는 sliding window가 필요한가 — 운영 24h 관찰 후 결정.

View File

@@ -168,10 +168,15 @@ function findRegionFromCoords(
} }
let best: { country: string; city: string } | null = null; let best: { country: string; city: string } | null = null;
let bestDist = Infinity; let bestDist = Infinity;
// #320 — 유클리드 거리는 적도/극지에서 경도·위도의 실거리 차이가 커서 왜곡됨.
// cos(lat) 가중치(equirectangular approximation)로 위도 의존 보정.
const cosLat = Math.cos((lat * Math.PI) / 180);
for (const g of groups.values()) { for (const g of groups.values()) {
const cLat = g.lats.reduce((a, b) => a + b, 0) / g.lats.length; const cLat = g.lats.reduce((a, b) => a + b, 0) / g.lats.length;
const cLng = g.lngs.reduce((a, b) => a + b, 0) / g.lngs.length; const cLng = g.lngs.reduce((a, b) => a + b, 0) / g.lngs.length;
const dist = (cLat - lat) ** 2 + (cLng - lng) ** 2; const dLat = cLat - lat;
const dLng = (cLng - lng) * cosLat;
const dist = dLat * dLat + dLng * dLng;
if (dist < bestDist) { if (dist < bestDist) {
bestDist = dist; bestDist = dist;
best = { country: g.country, city: g.city }; best = { country: g.country, city: g.city };

View File

@@ -209,6 +209,8 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
zIndex={100} zIndex={100}
> >
<div <div
role="button"
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
style={{ style={{
width: size, width: size,
height: size, height: size,
@@ -246,7 +248,10 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
onClick={() => handleMarkerClick(r)} onClick={() => handleMarkerClick(r)}
zIndex={isSelected ? 1000 : 1} zIndex={isSelected ? 1000 : 1}
> >
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}> <div
role="button"
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
<div <div
style={{ style={{
padding: "4px 8px", padding: "4px 8px",
@@ -389,10 +394,15 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
</button> </button>
)} )}
{channelNames.length > 0 && ( {channelNames.length > 0 && (
<div className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"> <div
role="region"
aria-label="채널 범례"
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
>
{channelNames.map((ch) => ( {channelNames.map((ch) => (
<div key={ch} className="flex items-center gap-1"> <div key={ch} className="flex items-center gap-1">
<span <span
aria-hidden="true"
className="inline-block w-2.5 h-2.5 rounded-full border" className="inline-block w-2.5 h-2.5 rounded-full border"
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }} style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
/> />