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 lastBeat = new AtomicReference<>(); + + @Scheduled( + fixedDelayString = "${scheduling.heartbeat-interval-ms:60000}", + initialDelayString = "${scheduling.heartbeat-initial-delay-ms:10000}") + public void beat() { + Instant now = Instant.now(); + Instant previous = lastBeat.getAndSet(now); + if (previous != null && log.isDebugEnabled()) { + log.debug("scheduler heartbeat. gapMs={}", now.toEpochMilli() - previous.toEpochMilli()); + } + } + + /** 마지막 박동. 한 번도 뛰지 않았으면 null(기동 직후 또는 스케줄러 미동작). */ + public Instant lastBeat() { + return lastBeat.get(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java index 47770cff..9a01a739 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java @@ -41,6 +41,14 @@ public S3ObjectStorageClient(S3Properties properties) { .endpointOverride(properties.endpoint()) .region(Region.of(properties.region())) .credentialsProvider(credentialsProvider) + // SDK 기본값에는 **호출 전체 상한이 없다**(소켓 타임아웃 + 재시도 3회뿐). + // 이 클라이언트는 요청 스레드(업로드·프록시)와 스케줄러 스레드 + // (OrphanedObjectSweeper)가 같이 쓴다 — MinIO 가 느려지면 전자는 톰캣 + // 스레드를, 후자는 스케줄러를 붙잡는다. 스케줄러가 붙잡히면 면접 복구 + // 스위퍼가 같이 멈춘다. 상한을 걸어 "느림"이 "멈춤"으로 번지지 않게 한다. + .overrideConfiguration(o -> o + .apiCallAttemptTimeout(Duration.ofSeconds(10)) + .apiCallTimeout(Duration.ofSeconds(30))) .serviceConfiguration(S3Configuration.builder() .pathStyleAccessEnabled(properties.pathStyle()) .build()) 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 bb5dc6d5..e57802e7 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 @@ -28,6 +28,9 @@ public class SystemHealthService { // "복구 수단이 없다"가 구분되지 않고, /health 를 보는 업타임 감시가 헛울린다. // 값은 보여주되 aggregate 에서는 뺀다. private static final ComponentSpec BACKUP = new ComponentSpec("backup", "backup", true); + // 스케줄러가 멈춰도 면접 진행·로그인·조회는 된다 — 멈춘 것은 '고장 복구'다. + // BACKUP 과 같은 이유로 값만 보여주고 aggregate 에서는 뺀다. + private static final ComponentSpec SCHEDULER = new ComponentSpec("scheduler", "scheduler", true); private final HealthEndpoint healthEndpoint; @@ -44,7 +47,7 @@ public SystemHealthResponse ready() { } public SystemHealthResponse health() { - return buildResponse(List.of(DATABASE, RABBITMQ, S3, AI_SERVER, BACKUP)); + return buildResponse(List.of(DATABASE, RABBITMQ, S3, AI_SERVER, BACKUP, SCHEDULER)); } private SystemHealthResponse buildResponse(List specs) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 590ffb36..138e2709 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -14,6 +14,14 @@ spring: multipart: max-file-size: 20MB max-request-size: 20MB + task: + scheduling: + # 기본값은 1이다. @Scheduled 가 6개(+#256 으로 7개)인데 그중 + # SessionTimeoutSweeper·StaleTranscriptionSweeper 는 **멈춘 면접을 푸는 유일한 장치**다. + # 한 스레드를 공유하면 느린 작업 하나가(가장 유력한 건 S3/MinIO 를 도는 + # OrphanedObjectSweeper) 면접 복구를 통째로 멈춘다 — 아무 신호도 없이. + pool: + size: ${SCHEDULING_POOL_SIZE:4} server: port: ${SERVER_PORT:38010} diff --git a/backend/src/test/java/com/stackup/stackup/common/config/SchedulingPoolSizeTest.java b/backend/src/test/java/com/stackup/stackup/common/config/SchedulingPoolSizeTest.java new file mode 100644 index 00000000..c38b255e --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/config/SchedulingPoolSizeTest.java @@ -0,0 +1,43 @@ +package com.stackup.stackup.common.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * 스케줄러 스레드 풀이 1로 돌아가지 않도록 지킨다. + * + *

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) ReflectionTestUtils.getField(hb, "lastBeat")).set(at); + return hb; + } + + @Test + void 최근_박동이_있으면_UP() { + Health health = new SchedulerHealthIndicator( + heartbeatAt(Instant.now().minus(30, ChronoUnit.SECONDS)), 300).health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("ageSeconds", 30L); + } + + @Test + void 한도를_넘긴_박동은_DOWN() { + // 스케줄러가 붙잡혀 있다 — 멈춘 면접을 푸는 스위퍼들도 같이 멈춰 있다는 뜻. + Health health = new SchedulerHealthIndicator( + heartbeatAt(Instant.now().minus(10, ChronoUnit.MINUTES)), 300).health(); + + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + } + + @Test + void 경계값_직전은_UP() { + Health health = new SchedulerHealthIndicator( + heartbeatAt(Instant.now().minus(299, ChronoUnit.SECONDS)), 300).health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + } + + @Test + void 한_번도_안_뛰었으면_DOWN() { + Health health = new SchedulerHealthIndicator(new SchedulerHeartbeat(), 300).health(); + + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails().get("reason").toString()) + .contains("아직 한 번도 실행되지 않았습니다"); + } + + @Test + void beat_는_시각을_갱신한다() { + SchedulerHeartbeat hb = new SchedulerHeartbeat(); + assertThat(hb.lastBeat()).isNull(); + + hb.beat(); + + assertThat(hb.lastBeat()).isNotNull(); + } +} 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 88742c88..a34a4c51 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 @@ -210,6 +210,26 @@ void health_realOutageStillDragsOverallStatusDown() { assertThat(systemHealthService.health().status()).isEqualTo(Status.DOWN.getCode()); } + // 스케줄러가 멈춰도 면접 진행·로그인·조회는 된다. 멈춘 것은 '고장 복구' 쪽이라 + // 백업과 같은 취급을 한다 — 값은 보여주되 집계에서는 뺀다. + @Test + void health_schedulerDownDoesNotDragOverallStatusDown() { + HealthEndpoint healthEndpoint = healthEndpoint(Map.of( + "db", indicator(Status.UP, Map.of()), + "rabbit", indicator(Status.UP, Map.of()), + "s3", indicator(Status.UP, Map.of()), + "aiServer", indicator(Status.UP, Map.of()), + "backup", indicator(Status.UP, Map.of()), + "scheduler", indicator(Status.DOWN, Map.of()) + )); + SystemHealthService systemHealthService = new SystemHealthService(healthEndpoint); + + var response = systemHealthService.health(); + + assertThat(response.components().get("scheduler").status()).isEqualTo(Status.DOWN.getCode()); + assertThat(response.status()).isEqualTo(Status.UP.getCode()); + } + @Test void ready_doesNotIncludeBackup() { // 준비 상태(트래픽 수용 가능)와 백업은 무관하다. diff --git a/docs/environment.md b/docs/environment.md index 0ce88620..bba2e4e6 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -97,6 +97,8 @@ MINIO_BUCKET=stackup # ===== App ===== SPRING_PROFILES_ACTIVE=local SERVER_PORT=8080 +SCHEDULING_POOL_SIZE=4 # @Scheduled 스레드 수. Spring 기본값 1이면 스위퍼들이 직렬화되고, + # 느린 작업 하나가 면접 복구 스위퍼까지 멈춘다 # ===== Auth ===== JWT_SECRET= # base64-encoded 32+ bytes (필수, default 없음)