From 091bbde5e60e0fd12a94d2457399615d0cd96174 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 15:54:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EA=B3=B5=EA=B0=9C=20=ED=97=AC?= =?UTF-8?q?=EC=8A=A4=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B8=EC=A6=9D=20=EC=97=86=EC=9D=B4=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마지막 점검에서 나왔다. `/api/system/*` 는 permitAll 인데 컴포넌트 상세를 그대로 담고 있었다: "rabbitmq": { "version": "4.3.5" } "s3": { "bucket": "stackup" } "aiServer": { "queue": "ai.generate.questions", "consumers": 1, "pendingMessages": 0 } Actuator 는 기본값이 `show-details: never` 다. 그런데 `SystemHealthService` 가 descriptor 에서 상세를 직접 꺼내 자기 응답에 담으면서 **그 보호를 우회**하고 있었다. RabbitMQ 버전은 알려진 CVE 를 겨냥하는 데 쓰이고, 버킷명·큐 이름·적체량은 내부 토폴로지와 부하를 그대로 드러낸다. `rabbitmq` 상세는 #183(키 오타 수정)으로, `s3`·`aiServer` 상세는 #184(indicator 구현)로 오늘 내가 늘린 것이다 — 늘린 김에 닫는다. `ComponentHealthResponse` 에서 `details` 를 제거해 **이름·상태만** 담는다. 프로브 용도에는 그것으로 충분하고, 상세가 필요하면 호스트에서 Spring 자체 `/actuator/health` 를 본다 (nginx 가 외부로 라우팅하지 않는 것을 확인했다 — 공개 URL 로는 SPA HTML 이 돌아온다). 반사(reflection)로 상세를 꺼내던 `extractDetails` 도 함께 사라진다. 프론트·realtime 어디서도 이 엔드포인트를 호출하지 않아 소비자 영향은 없다. 테스트: `health_doesNotExposeComponentDetails` — 상태는 전달되지만 응답 타입에 상세 필드가 아예 없다는 것을 record 컴포넌트로 못 박는다. ## 함께: frontend/.env.example 의 잘못된 호스트 `VITE_API_BASE_URL`·`VITE_SSE_BASE_URL` 가 `https://www.udangtang.site` 를 가리키고 있었다. 실제 배포 호스트는 `https://stack-up.shop` 다(deploy-app.yml 이 프론트 빌드에 주입하는 값). 새로 온 사람이 그대로 복사하면 존재하지 않는 백엔드를 보게 된다. --- backend/openapi.json | 4 --- .../application/SystemHealthService.java | 13 ++------- .../dto/ComponentHealthResponse.java | 8 +++--- .../application/SystemHealthServiceTest.java | 27 +++++++++++++++++-- docs/observability.md | 14 +++++++--- frontend/.env.example | 4 +-- frontend/src/shared/api/generated.ts | 3 --- 7 files changed, 43 insertions(+), 30 deletions(-) diff --git a/backend/openapi.json b/backend/openapi.json index b89417df..31ba2cc6 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -3966,10 +3966,6 @@ }, "status" : { "type" : "string" - }, - "details" : { - "type" : "object", - "additionalProperties" : { } } } }, diff --git a/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java b/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java index d2ef7a9c..ab2bc8f0 100644 --- a/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java +++ b/backend/src/main/java/com/stackup/stackup/system/application/SystemHealthService.java @@ -57,11 +57,9 @@ private SystemHealthResponse buildResponse(List specs) { private ComponentHealthResponse resolveComponent(ComponentSpec spec) { HealthDescriptor descriptor = resolveDescriptor(spec.actuatorPath()); if (descriptor == null) { - return new ComponentHealthResponse(spec.name(), Status.UNKNOWN.getCode(), Map.of()); + return new ComponentHealthResponse(spec.name(), Status.UNKNOWN.getCode()); } - - Map details = extractDetails(descriptor); - return new ComponentHealthResponse(spec.name(), descriptor.getStatus().getCode(), details); + return new ComponentHealthResponse(spec.name(), descriptor.getStatus().getCode()); } protected HealthDescriptor resolveDescriptor(String path) { @@ -72,13 +70,6 @@ protected HealthDescriptor resolveDescriptor(String path) { } } - private Map extractDetails(HealthDescriptor descriptor) { - try { - return Map.copyOf((Map) descriptor.getClass().getMethod("getDetails").invoke(descriptor)); - } catch (ReflectiveOperationException | ClassCastException ex) { - return Map.of(); - } - } private String aggregateStatus(Iterable components) { boolean hasUnknown = false; diff --git a/backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java b/backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java index 5af952ab..96423b3e 100644 --- a/backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java +++ b/backend/src/main/java/com/stackup/stackup/system/application/dto/ComponentHealthResponse.java @@ -1,10 +1,10 @@ package com.stackup.stackup.system.application.dto; -import java.util.Map; - +// 공개(permitAll) 엔드포인트의 응답이므로 컴포넌트 이름과 상태만 담는다. +// 상세(버전·버킷·큐·적체량)는 인증 없이 흘리면 안 되고, 필요하면 호스트에서 +// Spring 자체 /actuator/health 를 본다(nginx 가 외부로 라우팅하지 않는다). public record ComponentHealthResponse( String name, - String status, - Map details + String status ) { } diff --git a/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java b/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java index 2a52e7ee..5ccba9ca 100644 --- a/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/system/application/SystemHealthServiceTest.java @@ -15,6 +15,7 @@ import org.springframework.boot.health.actuate.endpoint.StatusAggregator; import org.springframework.boot.health.contributor.Health; import org.springframework.boot.health.contributor.HealthIndicator; +import com.stackup.stackup.system.application.dto.ComponentHealthResponse; import org.springframework.boot.health.contributor.Status; import org.springframework.boot.health.registry.DefaultHealthContributorRegistry; import org.springframework.boot.health.registry.DefaultReactiveHealthContributorRegistry; @@ -47,7 +48,6 @@ void ready_usesDatabaseAndRabbitmqIndicators() { assertThat(response.status()).isEqualTo(Status.UP.getCode()); assertThat(response.components()).containsKeys("database", "rabbitmq"); assertThat(response.components()).doesNotContainKeys("s3", "aiServer"); - assertThat(response.components().get("database").details()).containsEntry("connections", 12); } @Test @@ -85,7 +85,6 @@ void health_readsRabbitFromActuatorKeyNotResponseKey() { // 응답 키는 그대로 rabbitmq — 공개 계약은 바뀌지 않는다. assertThat(response.components().get("rabbitmq").status()).isEqualTo(Status.UP.getCode()); - assertThat(response.components().get("rabbitmq").details()).containsEntry("version", "3.13"); assertThat(response.status()).isEqualTo(Status.UP.getCode()); } @@ -104,6 +103,30 @@ void health_reportsUnknownWhenActuatorHasNoSuchComponent() { assertThat(response.status()).isEqualTo(Status.UNKNOWN.getCode()); } + /** + * 공개(permitAll) 엔드포인트라 상세를 담지 않는다. + * + *

Actuator 는 기본값이 {@code show-details: never} 인데 이 서비스가 descriptor 에서 + * 상세를 직접 꺼내 쓰면서 그 보호를 우회하고 있었다 — RabbitMQ 버전·S3 버킷명·큐 이름과 + * 적체량이 인증 없이 나갔다. 상세가 필요하면 호스트에서 /actuator/health 를 본다. + */ + @Test + void health_doesNotExposeComponentDetails() { + HealthEndpoint healthEndpoint = healthEndpoint(Map.of( + "db", indicator(Status.UP, Map.of("database", "PostgreSQL")), + "rabbit", indicator(Status.UP, Map.of("version", "4.3.5")) + )); + SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); + + var response = systemHealthService.health(); + + // 상태는 그대로 전달되지만 상세는 응답 타입에 아예 없다. + assertThat(response.components().get("rabbitmq").status()).isEqualTo(Status.UP.getCode()); + assertThat(ComponentHealthResponse.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .containsExactly("name", "status"); + } + private static HealthEndpoint healthEndpoint(Map indicators) { HealthContributorRegistry registry = new DefaultHealthContributorRegistry(); indicators.forEach(registry::registerContributor); diff --git a/docs/observability.md b/docs/observability.md index a8ce9935..faf2278e 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -158,14 +158,20 @@ GET /api/system/health { "status": "UP", "components": { - "database": { "status": "UP" }, - "rabbitmq": { "status": "UP", "details": { "queues": 6 } }, - "s3": { "status": "UP" }, - "aiServer": { "status": "UP", "details": { "endpoint": "..." } } + "database": { "name": "database", "status": "UP" }, + "rabbitmq": { "name": "rabbitmq", "status": "UP" }, + "s3": { "name": "s3", "status": "UP" }, + "aiServer": { "name": "aiServer", "status": "UP" } } } ``` +> **상태만 담는다.** 이 엔드포인트는 permitAll 이라 인증 없이 열린다. Actuator 는 기본값이 +> `show-details: never` 인데 예전엔 이 서비스가 descriptor 에서 상세를 직접 꺼내 그 보호를 +> 우회했다 — RabbitMQ 버전·S3 버킷명·큐 이름과 적체량이 그대로 나갔다. +> 상세가 필요하면 **호스트에서** Spring 자체 `/actuator/health` 를 본다(nginx 가 외부로 +> 라우팅하지 않는다). + - Spring Boot Actuator 의 컴포넌트를 이름으로 조회해 재구성한다(`SystemHealthService`). - **응답 키와 Actuator 컴포넌트 키는 다르다.** Actuator 키는 Spring 이 등록하는 빈 이름에서 접미사를 뗀 값이라 `database`→`db`, `rabbitmq`→**`rabbit`** 이다. 여기를 틀리면 조회가 diff --git a/frontend/.env.example b/frontend/.env.example index 0c0306ac..eb23fe02 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,10 +2,10 @@ # 백엔드 Core API base URL (Authorization Bearer 호출 + /api/auth/*) # 임시라 상우가 바꾸면 바뀔 수 있습니다. -VITE_API_BASE_URL=https://www.udangtang.site +VITE_API_BASE_URL=https://stack-up.shop # SSE base URL (레거시 — RealTime 전환 후 미사용 예정) -VITE_SSE_BASE_URL=https://www.udangtang.site +VITE_SSE_BASE_URL=https://stack-up.shop # RealTime 서버 base URL (SSE /realtime/stream/* + WS /realtime/sessions/{id}) VITE_REALTIME_BASE_URL=http://localhost:38020 diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index b3aca3be..7d07171c 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -1376,9 +1376,6 @@ export interface components { ComponentHealthResponse: { name?: string; status?: string; - details?: { - [key: string]: unknown; - }; }; SystemHealthResponse: { status?: string;