From f662d577153708620b25fb757fc3c388d7e125e0 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 15:30:53 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EC=97=90=EC=84=9C=20RabbitMQ=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B0=80=20=ED=95=AD=EC=83=81=20UNKNOWN=20=EC=9D=B4=EB=8D=98?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포된 dev 의 `/api/system/health` 를 실제로 찔러 보다 발견했다. database 만 UP 이고 rabbitmq 는 details 까지 빈 채 UNKNOWN 이었다. `SystemHealthService` 는 Actuator 컴포넌트를 이름으로 조회하는데, RabbitMQ 를 `"rabbitmq"` 로 찾고 있었다. Spring 은 `rabbitHealthContributor` 빈을 등록하므로 실제 키는 **`rabbit`** 이다(빈 이름에서 접미사를 뗀 값). 없는 이름이라 `healthForPath` 가 null 을 돌려주고 그대로 UNKNOWN 이 된다 — 예외가 아니라 조용한 무응답이라 눈에 띄지 않았다. 바로 옆의 `database` 는 `"db"` 로 올바르게 매핑돼 있었다. 응답 키와 Actuator 키를 분리한 `ComponentSpec(name, actuatorPath)` 구조는 이미 맞았고, rabbit 값만 틀렸다. 영향: `/api/system/ready`(database·rabbitmq 종합)가 **항상 UNKNOWN** 이라 readiness 판단에 쓸 수 없었다. RabbitMQ 가 죽으면 질문 생성·피드백·음성 분석이 전부 멎는데 이 엔드포인트는 아무것도 알려주지 않는다. (컨테이너 healthcheck 는 Spring 자체 `/actuator/health` 를 쓰므로 배포 게이트 자체는 영향 없었다.) ## 테스트가 왜 못 잡았나 픽스처를 `"rabbitmq"` 키로 등록해서 **같은 실수를 그대로 재현**하고 있었다. 실제 Actuator 키로 바꾸고, 매핑 자체를 못 박는 테스트 2개를 추가했다 — 응답 키는 rabbitmq 로 유지하면서 Actuator 는 rabbit 에서 읽는지, 그리고 없는 이름일 때 UNKNOWN 이 되는지. `docs/observability.md` 에 키가 다르다는 점과 s3·aiServer 미구현으로 `/health` 종합 status 가 UNKNOWN 으로 고정된다는 점을 적었다. --- .../application/SystemHealthService.java | 6 ++- .../application/SystemHealthServiceTest.java | 42 ++++++++++++++++++- docs/observability.md | 9 +++- 3 files changed, 53 insertions(+), 4 deletions(-) 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 b006c3d0..d2ef7a9c 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 @@ -15,8 +15,12 @@ @Service public class SystemHealthService { + // 응답 키(name)와 Actuator 컴포넌트 키(actuatorPath)는 다르다. + // Actuator 키는 Spring 이 등록하는 빈 이름에서 접미사를 뗀 값이다 — + // DataSourceHealthContributor→"db", rabbitHealthContributor→"rabbit". + // "rabbitmq" 로 조회하면 항상 null 이라 rabbitmq 컴포넌트가 영구 UNKNOWN 이 된다. private static final ComponentSpec DATABASE = new ComponentSpec("database", "db"); - private static final ComponentSpec RABBITMQ = new ComponentSpec("rabbitmq", "rabbitmq"); + private static final ComponentSpec RABBITMQ = new ComponentSpec("rabbitmq", "rabbit"); private static final ComponentSpec S3 = new ComponentSpec("s3", "s3"); private static final ComponentSpec AI_SERVER = new ComponentSpec("aiServer", "aiServer"); 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 4d86803d..2a52e7ee 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 @@ -38,7 +38,7 @@ void live_returnsUpStatus() { void ready_usesDatabaseAndRabbitmqIndicators() { HealthEndpoint healthEndpoint = healthEndpoint(Map.of( "db", indicator(Status.UP, Map.of("connections", 12)), - "rabbitmq", indicator(Status.UP, Map.of("host", "localhost")) + "rabbit", indicator(Status.UP, Map.of("host", "localhost")) // Actuator 실제 키 )); SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); @@ -54,7 +54,7 @@ void ready_usesDatabaseAndRabbitmqIndicators() { void health_includesDatabaseRabbitmqS3AndAiServer() { HealthEndpoint healthEndpoint = healthEndpoint(Map.of( "db", indicator(Status.UP, Map.of()), - "rabbitmq", indicator(Status.DOWN, Map.of("reachable", false)) + "rabbit", indicator(Status.DOWN, Map.of("reachable", false)) // Actuator 실제 키 )); SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); @@ -66,6 +66,44 @@ void health_includesDatabaseRabbitmqS3AndAiServer() { assertThat(response.components().get("aiServer").status()).isEqualTo(Status.UNKNOWN.getCode()); } + /** + * 응답 키와 Actuator 컴포넌트 키가 다르다는 사실 자체를 못 박는다. + * + *

운영에서 rabbitmq 가 영구 UNKNOWN 이었던 원인이 여기였다 — Actuator 는 + * {@code rabbitHealthContributor} 빈을 "rabbit" 으로 등록하는데 "rabbitmq" 로 조회했다. + * 기존 테스트는 픽스처를 "rabbitmq" 로 등록해서 같은 실수를 그대로 재현하고 있었다. + */ + @Test + void health_readsRabbitFromActuatorKeyNotResponseKey() { + HealthEndpoint healthEndpoint = healthEndpoint(Map.of( + "db", indicator(Status.UP, Map.of()), + "rabbit", indicator(Status.UP, Map.of("version", "3.13")) + )); + SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); + + var response = systemHealthService.ready(); + + // 응답 키는 그대로 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()); + } + + // Actuator 에 없는 이름으로 조회하면 UNKNOWN 이 된다 — 회귀 시 이 테스트가 아니라 + // 위 테스트가 깨지도록, 여기서는 '없을 때의 동작'만 확인한다. + @Test + void health_reportsUnknownWhenActuatorHasNoSuchComponent() { + HealthEndpoint healthEndpoint = healthEndpoint(Map.of( + "db", indicator(Status.UP, Map.of()) + )); + SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); + + var response = systemHealthService.ready(); + + assertThat(response.components().get("rabbitmq").status()).isEqualTo(Status.UNKNOWN.getCode()); + assertThat(response.status()).isEqualTo(Status.UNKNOWN.getCode()); + } + 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 a0687838..65e64102 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -166,9 +166,16 @@ GET /api/system/health } ``` -- Spring Boot Actuator + 커스텀 indicator +- Spring Boot Actuator 의 컴포넌트를 이름으로 조회해 재구성한다(`SystemHealthService`). +- **응답 키와 Actuator 컴포넌트 키는 다르다.** Actuator 키는 Spring 이 등록하는 빈 이름에서 + 접미사를 뗀 값이라 `database`→`db`, `rabbitmq`→**`rabbit`** 이다. 여기를 틀리면 조회가 + null 을 돌려줘 그 컴포넌트가 **영구 UNKNOWN** 이 된다(에러가 아니라 조용한 무응답). +- `s3`·`aiServer` 는 아직 커스텀 indicator 가 없어 UNKNOWN 이다 → `/health` 의 종합 status 도 + UNKNOWN 으로 고정된다. 종합 판단이 필요하면 현재는 `/ready`(database·rabbitmq)를 쓴다. - K8s liveness: 단순 200 응답 (`/api/system/live`) - K8s readiness: 의존성 포함 (`/api/system/ready`) +- 컨테이너 healthcheck 는 Spring 자체 `/actuator/health` 를 쓴다(docker-compose) — 이쪽은 + Actuator 종합이라 RabbitMQ 장애를 정상적으로 잡는다. ---