diff --git a/.env.example b/.env.example index a406ca37..efcc7766 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,9 @@ JWT_SECRET=c3RhY2t1cC1zcHJpbnQxLWxvY2FsLWp3dC1zZWNyZXQtMzItYnl0ZXMtbG9uZw== ENCRYPTION_KEY=c3RhY2t1cC1zcHJpbnQxLWVuY3J5cHRpb24ta2V5QUI= # 액세스 토큰 수명(초). 기본 3600(1시간) — 면접 진행 중 토큰 만료로 튕기는 문제 방지. JWT_ACCESS_TTL_SECONDS=3600 +# @Scheduled 스레드 수. Spring 기본값 1이면 모든 스위퍼가 한 줄로 서고, 느린 작업 하나가 +# 멈춘 면접을 푸는 스위퍼까지 멈춘다. +# SCHEDULING_POOL_SIZE=4 # AI 서버와 공유. 운영은 GitHub Secrets(CORE_INTERNAL_API_KEY)로 덮어쓸 것. CORE_INTERNAL_API_KEY=local-development-internal-api-key # GitHub OAuth (운영은 GitHub Secrets(GH_OAUTH_CLIENT_ID/SECRET)로 덮어쓸 것) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 251f5baa..1db7f882 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -586,6 +586,22 @@ docker compose up -d 같은 지점에 **늦은 콜백 가드**도 넣었다: `StaleTranscriptionSweeper` 가 FAILED 로 확정해 턴을 푼 뒤 늦은 콜백이 도착하면 옛 답변이 COMPLETED 로 되살아나 같은 턴에 답변이 둘이 된다(꼬리질문 `applyFollowup` 이 같은 이유로 막아 둔 것과 동일). `status != CREATED` 면 드롭. +- **스케줄러 스레드 풀 + 생존 신호 본 구현**: `@Scheduled` 가 7개인데 Spring Boot 기본 + `spring.task.scheduling.pool.size` 는 **1** 이라 전부 한 줄로 서 있었다. 그중 + `SessionTimeoutSweeper`·`StaleTranscriptionSweeper`·`StaleFollowupSweeper` 는 + **멈춘 면접을 푸는 유일한 장치**다 — 느린 작업 하나가(S3/MinIO 를 도는 + `OrphanedObjectSweeper` 가 가장 유력) 면접 복구를 통째로 멈춘다. + - `pool.size` 를 `${SCHEDULING_POOL_SIZE:4}` 로. 설정 한 줄이 지워져도 컴파일·테스트가 + 통과하고 **운영에서도 장애 때까지 아무 증상이 없어** `SchedulingPoolSizeTest` 로 지킨다. + - **S3 클라이언트에 호출 상한**(`apiCallTimeout` 30초 / `apiCallAttemptTimeout` 10초). + AWS SDK v2 기본값에는 호출 전체 상한이 없다(소켓 타임아웃 + 재시도뿐). 같은 클라이언트를 + 요청 스레드(업로드·프록시)와 스케줄러가 공유하므로 "느림"이 "멈춤"으로 번진다. + - **`SchedulerHeartbeat` + `SchedulerHealthIndicator`**: 스위퍼들은 할 일이 없으면 로그를 + 남기지 않아 "돌았는데 대상이 없었다"와 "아예 안 돌았다"를 구분할 수 없었다(운영 백엔드 + 전체 로그에 sweeper 관련 줄 **0건**). 1분마다 타임스탬프만 갱신하고, 5분 넘게 멈추면 + `/api/system/health` 의 `scheduler` 가 DOWN. DB·네트워크를 건드리지 않는다 — 심장박동이 + 자기가 감시하는 문제(느린 I/O)로 막히면 안 된다. `BACKUP` 과 같이 **informational** 이라 + aggregate 는 끌어내리지 않는다(스케줄러가 멈춰도 면접·로그인·조회는 된다). - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/src/main/java/com/stackup/stackup/common/health/SchedulerHealthIndicator.java b/backend/src/main/java/com/stackup/stackup/common/health/SchedulerHealthIndicator.java new file mode 100644 index 00000000..0a7b47be --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/health/SchedulerHealthIndicator.java @@ -0,0 +1,56 @@ +package com.stackup.stackup.common.health; + +import java.time.Duration; +import java.time.Instant; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.stereotype.Component; + +/** + * 스케줄러 생존 여부. + * + *
{@link SchedulerHeartbeat} 의 마지막 박동이 한도를 넘으면 DOWN. 스케줄러가 멈췄다는 + * 것은 멈춘 면접을 푸는 스위퍼들도 같이 멈췄다는 뜻이다 — 지금까지는 이 상태를 + * 밖에서 알 방법이 아예 없었다. + * + *
{@code BackupHealthIndicator} 와 같은 이유로 aggregate 에는 넣지 않는다 + * (informational). 스케줄러가 멈춰도 면접 진행·로그인·조회는 정상 동작한다. 여기서 DOWN 은 + * "서비스가 죽었다"가 아니라 "고장 복구가 안 되고 있다"는 뜻이고, 그 둘을 섞으면 + * 업타임 감시가 헛울린다. + * + *
빈 이름이 곧 Actuator 컴포넌트 키가 된다 — {@code schedulerHealthIndicator} → {@code "scheduler"}. + */ +@Component +public class SchedulerHealthIndicator implements HealthIndicator { + + private final SchedulerHeartbeat heartbeat; + private final Duration maxAge; + + public SchedulerHealthIndicator( + SchedulerHeartbeat heartbeat, + // 박동 주기(기본 60초)의 5배. 한두 번 밀리는 것으로 울리면 아무도 안 본다. + @Value("${scheduling.heartbeat-max-age-seconds:300}") long maxAgeSeconds + ) { + this.heartbeat = heartbeat; + this.maxAge = Duration.ofSeconds(maxAgeSeconds); + } + + @Override + public Health health() { + Instant last = heartbeat.lastBeat(); + if (last == null) { + // 기동 직후(initialDelay 이내)에도 여기로 온다. 그래서 aggregate 에서 빼 둔다 — + // 재기동마다 전체 상태가 잠깐 DOWN 으로 보이면 안 된다. + return Health.down() + .withDetail("reason", "스케줄러가 아직 한 번도 실행되지 않았습니다") + .build(); + } + Duration age = Duration.between(last, Instant.now()); + Health.Builder builder = age.compareTo(maxAge) > 0 ? Health.down() : Health.up(); + return builder + .withDetail("lastBeat", last.toString()) + .withDetail("ageSeconds", age.toSeconds()) + .build(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/health/SchedulerHeartbeat.java b/backend/src/main/java/com/stackup/stackup/common/health/SchedulerHeartbeat.java new file mode 100644 index 00000000..f393548e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/health/SchedulerHeartbeat.java @@ -0,0 +1,47 @@ +package com.stackup.stackup.common.health; + +import java.time.Instant; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 스케줄러가 살아 있는지 알려 주는 최소 신호. + * + *
왜 필요한가 — 이 서비스의 스위퍼들은 할 일이 없으면 아무 로그도 남기지 않는다. + * 그래서 운영 로그만 봐서는 "돌았는데 대상이 없었다"와 "아예 안 돌았다"를 구분할 수 없다. + * 실제로 운영 백엔드 전체 로그에 sweeper 관련 줄이 0건이다. 그런데 그중 셋은 + * 멈춘 면접을 푸는 유일한 장치라, 스케줄러가 조용히 죽으면 사용자만 알게 된다. + * + *
하는 일은 타임스탬프 하나를 갱신하는 것뿐이다. 이 작업이 멈췄다면 스케줄러 자체가 + * 멈췄다는 뜻이고({@link SchedulerHealthIndicator} 가 DOWN 으로 보고한다), 다른 스위퍼들도 + * 같이 멈춰 있다고 봐야 한다. + * + *
DB·네트워크를 건드리지 않는다 — 심장박동이 자기가 감시하는 문제(느린 I/O)로
+ * 막히면 안 된다.
+ */
+@Component
+public class SchedulerHeartbeat {
+
+ private static final Logger log = LoggerFactory.getLogger(SchedulerHeartbeat.class);
+
+ private final AtomicReference Spring Boot 기본값은 1이고, 그러면 모든 {@code @Scheduled} 가 한 줄로 선다. 이 서비스의
+ * 스케줄 작업 중 셋은 멈춘 면접을 푸는 유일한 장치라서, 느린 작업 하나가
+ * (S3/MinIO 를 도는 OrphanedObjectSweeper 가 가장 유력하다) 면접 복구를 통째로 멈춘다.
+ *
+ * 설정 한 줄이 지워져도 컴파일·테스트가 통과하고 운영에서도 아무 증상이 없다가
+ * 장애 때만 드러난다 — 그래서 CI 가 대신 본다.
+ */
+class SchedulingPoolSizeTest {
+
+ @Test
+ void applicationYml_declaresSchedulingPoolSizeGreaterThanOne() throws IOException {
+ String yml = read("application.yml");
+
+ Matcher m = Pattern.compile("size:\\s*\\$\\{SCHEDULING_POOL_SIZE:(\\d+)}").matcher(yml);
+ assertThat(m.find())
+ .as("spring.task.scheduling.pool.size 설정이 사라졌다 — 기본값 1로 되돌아간다")
+ .isTrue();
+ assertThat(Integer.parseInt(m.group(1)))
+ .as("스케줄 작업이 직렬화되지 않을 만큼은 돼야 한다")
+ .isGreaterThan(1);
+ }
+
+ private String read(String name) throws IOException {
+ try (InputStream in = getClass().getClassLoader().getResourceAsStream(name)) {
+ assertThat(in).as(name + " 을 찾을 수 없다").isNotNull();
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/backend/src/test/java/com/stackup/stackup/common/health/SchedulerHealthIndicatorTest.java b/backend/src/test/java/com/stackup/stackup/common/health/SchedulerHealthIndicatorTest.java
new file mode 100644
index 00000000..102e7fb8
--- /dev/null
+++ b/backend/src/test/java/com/stackup/stackup/common/health/SchedulerHealthIndicatorTest.java
@@ -0,0 +1,70 @@
+package com.stackup.stackup.common.health;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.health.contributor.Health;
+import org.springframework.boot.health.contributor.Status;
+import org.springframework.test.util.ReflectionTestUtils;
+
+/**
+ * 스위퍼들은 할 일이 없으면 로그를 남기지 않는다 — 그래서 "돌았는데 대상이 없었다"와
+ * "아예 안 돌았다"를 밖에서 구분할 수 없었다. 그 구분을 만드는 게 심장박동이다.
+ */
+class SchedulerHealthIndicatorTest {
+
+ @SuppressWarnings("unchecked")
+ private SchedulerHeartbeat heartbeatAt(Instant at) {
+ SchedulerHeartbeat hb = new SchedulerHeartbeat();
+ ((AtomicReference