Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)로 덮어쓸 것)
Expand Down
16 changes: 16 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 레코드 또는 인메모리로.

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 스케줄러 생존 여부.
*
* <p>{@link SchedulerHeartbeat} 의 마지막 박동이 한도를 넘으면 DOWN. 스케줄러가 멈췄다는
* 것은 <b>멈춘 면접을 푸는 스위퍼들도 같이 멈췄다</b>는 뜻이다 — 지금까지는 이 상태를
* 밖에서 알 방법이 아예 없었다.
*
* <p>{@code BackupHealthIndicator} 와 같은 이유로 <b>aggregate 에는 넣지 않는다</b>
* (informational). 스케줄러가 멈춰도 면접 진행·로그인·조회는 정상 동작한다. 여기서 DOWN 은
* "서비스가 죽었다"가 아니라 "고장 복구가 안 되고 있다"는 뜻이고, 그 둘을 섞으면
* 업타임 감시가 헛울린다.
*
* <p>빈 이름이 곧 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 스케줄러가 살아 있는지 알려 주는 최소 신호.
*
* <p>왜 필요한가 — 이 서비스의 스위퍼들은 <b>할 일이 없으면 아무 로그도 남기지 않는다.</b>
* 그래서 운영 로그만 봐서는 "돌았는데 대상이 없었다"와 "아예 안 돌았다"를 구분할 수 없다.
* 실제로 운영 백엔드 전체 로그에 sweeper 관련 줄이 <b>0건</b>이다. 그런데 그중 셋은
* 멈춘 면접을 푸는 유일한 장치라, 스케줄러가 조용히 죽으면 사용자만 알게 된다.
*
* <p>하는 일은 타임스탬프 하나를 갱신하는 것뿐이다. 이 작업이 멈췄다면 스케줄러 자체가
* 멈췄다는 뜻이고({@link SchedulerHealthIndicator} 가 DOWN 으로 보고한다), 다른 스위퍼들도
* 같이 멈춰 있다고 봐야 한다.
*
* <p>DB·네트워크를 건드리지 않는다 — 심장박동이 자기가 감시하는 문제(느린 I/O)로
* 막히면 안 된다.
*/
@Component
public class SchedulerHeartbeat {

private static final Logger log = LoggerFactory.getLogger(SchedulerHeartbeat.class);

private final AtomicReference<Instant> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<ComponentSpec> specs) {
Expand Down
8 changes: 8 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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로 돌아가지 않도록 지킨다.
*
* <p>Spring Boot 기본값은 1이고, 그러면 모든 {@code @Scheduled} 가 한 줄로 선다. 이 서비스의
* 스케줄 작업 중 셋은 <b>멈춘 면접을 푸는 유일한 장치</b>라서, 느린 작업 하나가
* (S3/MinIO 를 도는 OrphanedObjectSweeper 가 가장 유력하다) 면접 복구를 통째로 멈춘다.
*
* <p>설정 한 줄이 지워져도 컴파일·테스트가 통과하고 <b>운영에서도 아무 증상이 없다가</b>
* 장애 때만 드러난다 — 그래서 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Instant>) 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
// 준비 상태(트래픽 수용 가능)와 백업은 무관하다.
Expand Down
2 changes: 2 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 없음)
Expand Down
Loading