From a40a5f4c5427ca72465cbda02262ea263637d222 Mon Sep 17 00:00:00 2001 From: kimjb Date: Mon, 18 May 2026 23:37:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Dockerfile | 13 +- build.gradle | 1 + docker-compose.prod.yml | 198 +++++++++++++++++- grafana/.gitignore | 4 + .../provisioning/dashboards/dashboards.yml | 13 ++ .../provisioning/datasources/datasources.yml | 31 +++ monitoring/loki-config.yml | 48 +++++ monitoring/prometheus.yml | 20 ++ monitoring/promtail-config.yml | 27 +++ nginx/nginx.conf | 8 + .../global/config/SecurityConfig.java | 1 + 12 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 grafana/.gitignore create mode 100644 grafana/provisioning/dashboards/dashboards.yml create mode 100644 grafana/provisioning/datasources/datasources.yml create mode 100644 monitoring/loki-config.yml create mode 100644 monitoring/prometheus.yml create mode 100644 monitoring/promtail-config.yml diff --git a/.gitignore b/.gitignore index 7475426..fad1995 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ PRD.md ### Data ### *.csv +store_ratings.sql ### Uploads ### uploads/ diff --git a/Dockerfile b/Dockerfile index 74a9ff8..a3dd81f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,15 +23,22 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends wget \ && rm -rf /var/lib/apt/lists/* +# OpenTelemetry Java Agent — 코드 수정 없이 트레이스 자동 수집 +# Spring MVC, JDBC, HikariCP, HTTP Client 등 자동 계측 +ARG OTEL_AGENT_VERSION=2.10.0 +RUN wget -qO /opentelemetry-javaagent.jar \ + https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_AGENT_VERSION}/opentelemetry-javaagent.jar \ + && chmod 644 /opentelemetry-javaagent.jar + # 비루트 유저로 실행 RUN useradd -r -u 1001 spring USER spring COPY --from=builder /app/build/libs/*.jar app.jar -# t3.micro(1GB RAM) 환경 고려한 JVM 메모리 튜닝 -# 컨테이너에 할당된 메모리의 70%를 힙으로 사용 (나머지 30%는 metaspace, 스택, 네이티브) -ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=50.0" +# t3.small 환경 고려한 JVM 메모리 튜닝 + OTel Agent 자동 활성화 +# OTEL_EXPORTER_OTLP_ENDPOINT가 설정 안 되면 트레이스 전송만 실패할 뿐 앱은 정상 동작 +ENV JAVA_TOOL_OPTIONS="-javaagent:/opentelemetry-javaagent.jar -XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=50.0 -XX:+UseG1GC" EXPOSE 8080 diff --git a/build.gradle b/build.gradle index 8a43163..ccf2fd3 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-model-google-genai' implementation 'org.jetbrains.kotlin:kotlin-reflect' implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' implementation 'io.github.cdimascio:dotenv-java:3.0.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4173235..8e747c9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,13 +7,27 @@ services: env_file: .env expose: - "8080" - # t3.micro(1GB) 메모리 한계 고려: app은 최대 700MB로 제한 + # t3.small(2GB) 환경. 모니터링 스택과 공존하기 위해 app은 600M로 제한. + # OTel Agent가 약 80MB 추가 사용하므로 500M → 600M로 상향. + environment: + # JVM 옵션은 Dockerfile의 ENV에 기본값으로 정의됨 (javaagent + 메모리 튜닝). + # 여기선 OpenTelemetry Agent의 송신 대상만 지정. + OTEL_SERVICE_NAME: catchtable-backend + OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 + OTEL_EXPORTER_OTLP_PROTOCOL: grpc + OTEL_TRACES_EXPORTER: otlp + OTEL_METRICS_EXPORTER: none # 메트릭은 Micrometer/Prometheus로 처리 + OTEL_LOGS_EXPORTER: none # 로그는 Promtail/Loki로 처리 + OTEL_RESOURCE_ATTRIBUTES: deployment.environment=production + # 샘플링: 전체 요청 대비 트레이스 수집 비율 (1.0=100%, 부하 시 0.1로 낮추기 권장) + OTEL_TRACES_SAMPLER: parentbased_traceidratio + OTEL_TRACES_SAMPLER_ARG: "1.0" deploy: resources: limits: - memory: 700M + memory: 600M reservations: - memory: 500M + memory: 400M healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 30s @@ -23,6 +37,11 @@ services: restart: unless-stopped networks: - catchtable-net + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" nginx: image: nginx:1.26.3-alpine @@ -50,6 +69,179 @@ services: entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done;'" restart: unless-stopped + # ============================================================ + # 모니터링 스택 (Prometheus + Loki + Promtail + Jaeger + Grafana) + # ============================================================ + + prometheus: + image: prom/prometheus:v2.55.0 + container_name: catchtable-prometheus + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.retention.time=7d' + - '--storage.tsdb.retention.size=1GB' + - '--web.enable-lifecycle' + expose: + - "9090" + deploy: + resources: + limits: + memory: 350M + restart: unless-stopped + networks: + - catchtable-net + + loki: + image: grafana/loki:3.2.0 + container_name: catchtable-loki + volumes: + - ./monitoring/loki-config.yml:/etc/loki/local-config.yaml:ro + - loki-data:/loki + command: -config.file=/etc/loki/local-config.yaml + expose: + - "3100" + deploy: + resources: + limits: + memory: 250M + restart: unless-stopped + networks: + - catchtable-net + + promtail: + image: grafana/promtail:3.2.0 + container_name: catchtable-promtail + volumes: + - ./monitoring/promtail-config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail-positions:/tmp + command: -config.file=/etc/promtail/config.yml + depends_on: + - loki + deploy: + resources: + limits: + memory: 100M + restart: unless-stopped + networks: + - catchtable-net + + jaeger: + image: jaegertracing/all-in-one:1.62.0 + container_name: catchtable-jaeger + user: "0" # named volume mkdir 퍼미션 회피 (내부 도구라 보안 영향 없음) + environment: + COLLECTOR_OTLP_ENABLED: "true" + SPAN_STORAGE_TYPE: badger + BADGER_EPHEMERAL: "false" + BADGER_DIRECTORY_VALUE: /badger/data + BADGER_DIRECTORY_KEY: /badger/key + BADGER_SPAN_STORE_TTL: 168h0m0s + volumes: + - jaeger-data:/badger + ports: + - "16686:16686" # Jaeger UI (외부 접근) + expose: + - "4317" # OTLP gRPC (앱에서 전송) + - "4318" # OTLP HTTP + deploy: + resources: + limits: + memory: 350M + restart: unless-stopped + networks: + - catchtable-net + + grafana: + image: grafana/grafana:11.3.0 + container_name: catchtable-grafana + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - grafana-data:/var/lib/grafana + ports: + - "3001:3000" # Grafana UI (외부 접근, 3000은 프론트 dev와 충돌 회피) + depends_on: + - prometheus + - loki + - jaeger + deploy: + resources: + limits: + memory: 200M + restart: unless-stopped + networks: + - catchtable-net + + # ============================================================ + # 고도화 작업 시작 시 주석 해제 (5대 고도화 항목 1, 3번 진행 시) + # ============================================================ + + # redis: + # image: redis:7-alpine + # container_name: catchtable-redis + # command: + # - redis-server + # - --maxmemory + # - 80mb + # - --maxmemory-policy + # - allkeys-lru + # - --appendonly + # - "yes" + # expose: + # - "6379" + # volumes: + # - redis-data:/data + # deploy: + # resources: + # limits: + # memory: 100M + # restart: unless-stopped + # networks: + # - catchtable-net + + # kafka: + # image: bitnami/kafka:3.8 + # container_name: catchtable-kafka + # environment: + # # KRaft 모드 (Zookeeper 불필요, 메모리 절약) + # KAFKA_CFG_NODE_ID: 0 + # KAFKA_CFG_PROCESS_ROLES: controller,broker + # KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 + # KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + # KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + # KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER + # KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + # # 메모리 다이어트 + # KAFKA_HEAP_OPTS: "-Xms256m -Xmx256m" + # expose: + # - "9092" + # volumes: + # - kafka-data:/bitnami/kafka + # deploy: + # resources: + # limits: + # memory: 400M + # restart: unless-stopped + # networks: + # - catchtable-net + networks: catchtable-net: driver: bridge + +volumes: + prometheus-data: + loki-data: + jaeger-data: + grafana-data: + promtail-positions: + # redis-data: + # kafka-data: diff --git a/grafana/.gitignore b/grafana/.gitignore new file mode 100644 index 0000000..0f90f2f --- /dev/null +++ b/grafana/.gitignore @@ -0,0 +1,4 @@ +# Grafana 런타임 데이터 (커밋 금지) +data/ +plugins/ +*.db diff --git a/grafana/provisioning/dashboards/dashboards.yml b/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..0be33ce --- /dev/null +++ b/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: true diff --git a/grafana/provisioning/datasources/datasources.yml b/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..f073717 --- /dev/null +++ b/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,31 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + jsonData: + timeInterval: "15s" + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + editable: true + jsonData: + derivedFields: + # 로그 안의 traceId(OTel) 를 클릭하면 Jaeger 트레이스로 점프 + - datasourceUid: jaeger + matcherRegex: "traceId=(\\w+)" + name: TraceID + url: "$${__value.raw}" + + - name: Jaeger + type: jaeger + access: proxy + url: http://jaeger:16686 + uid: jaeger + editable: true diff --git a/monitoring/loki-config.yml b/monitoring/loki-config.yml new file mode 100644 index 0000000..91393a4 --- /dev/null +++ b/monitoring/loki-config.yml @@ -0,0 +1,48 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h # 7일 + reject_old_samples: true + reject_old_samples_max_age: 168h + allow_structured_metadata: true + volume_enabled: true + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 150 + delete_request_store: filesystem + +ruler: + alertmanager_url: http://localhost:9093 + +analytics: + reporting_enabled: false diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 0000000..ff73fee --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,20 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + cluster: catchtable + env: prod + +scrape_configs: + # Spring Boot Actuator (Micrometer) + - job_name: 'catchtable-app' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['app:8080'] + labels: + service: catchtable-backend + + # Prometheus 자체 메트릭 + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] diff --git a/monitoring/promtail-config.yml b/monitoring/promtail-config.yml new file mode 100644 index 0000000..5942c9a --- /dev/null +++ b/monitoring/promtail-config.yml @@ -0,0 +1,27 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + # Docker 컨테이너 stdout 자동 수집 + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: ["com.docker.compose.project=catchtable-clone"] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'stream' + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: 'service' diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 45436fc..64e56fe 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -50,6 +50,14 @@ http { # HSTS (HTTPS 강제, 6개월) add_header Strict-Transport-Security "max-age=15768000; includeSubDomains" always; + # /actuator 경로는 외부 접근 차단 + # Prometheus는 Docker 내부 네트워크에서 app:8080을 직접 호출하므로 영향 없음 + # Docker healthcheck도 컨테이너 내부 호출이라 영향 없음 + location /actuator/ { + deny all; + return 403; + } + location / { proxy_pass http://app:8080; proxy_http_version 1.1; diff --git a/src/main/java/com/catchtable/global/config/SecurityConfig.java b/src/main/java/com/catchtable/global/config/SecurityConfig.java index 2692d10..9e08137 100644 --- a/src/main/java/com/catchtable/global/config/SecurityConfig.java +++ b/src/main/java/com/catchtable/global/config/SecurityConfig.java @@ -48,6 +48,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-resources/**", "/actuator/health", "/actuator/info", + "/actuator/prometheus", "/images/**" ).permitAll() .requestMatchers(HttpMethod.GET, From a367a1ee16601370ef8355ad95e462a16871d07d Mon Sep 17 00:00:00 2001 From: kimjb Date: Tue, 19 May 2026 01:14:46 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Refactor:=20=EC=98=88=EC=95=BD=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20null=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remain/service/StoreRemainService.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index 6cf280a..aeae498 100644 --- a/src/main/java/com/catchtable/remain/service/StoreRemainService.java +++ b/src/main/java/com/catchtable/remain/service/StoreRemainService.java @@ -150,9 +150,16 @@ public int generateDailySlotsForAllStores(LocalDate targetDate) { public List buildSlotPlans(List stores) { List plans = new ArrayList<>(stores.size()); for (Store store : stores) { + String openTimeStr = store.getOpenTime(); + String closeTimeStr = store.getCloseTime(); + if (openTimeStr == null || closeTimeStr == null) { + log.warn("[영업시간 누락] storeId={}, openTime={}, closeTime={}", + store.getId(), openTimeStr, closeTimeStr); + continue; + } try { - LocalTime openTime = LocalTime.parse(store.getOpenTime(), TIME_FORMATTER); - LocalTime closeTime = LocalTime.parse(store.getCloseTime(), TIME_FORMATTER); + LocalTime openTime = LocalTime.parse(openTimeStr, TIME_FORMATTER); + LocalTime closeTime = LocalTime.parse(closeTimeStr, TIME_FORMATTER); List expectedTimes = buildSlotTimes(openTime, closeTime); if (expectedTimes.isEmpty()) { continue; @@ -160,7 +167,7 @@ public List buildSlotPlans(List stores) { plans.add(new StoreSlotPlan(store, expectedTimes)); } catch (Exception e) { log.error("[영업시간 파싱 실패] storeId={}, openTime={}, closeTime={}", - store.getId(), store.getOpenTime(), store.getCloseTime()); + store.getId(), openTimeStr, closeTimeStr); } } return plans;