From 73f74e97060bacdaf672515a0f376bcc591f0134 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 22:08:20 +0900 Subject: [PATCH 01/12] =?UTF-8?q?test:=20=EC=9A=94=EC=B2=AD=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EC=9D=98=20DB=20=EC=9E=91=EC=97=85(=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=C2=B7=EC=BB=A4=EB=B0=8B=C2=B7SQL)=EC=9D=84=20?= =?UTF-8?q?=EC=84=B8=EB=8A=94=20=EA=B3=84=EC=B8=A1=EC=9D=84=20=EB=91=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 데이터소스를 감싸 커넥션 대여·커밋·SQL 문장을 기록하는 시험 전용 SqlRecorder 와, 로그인·리프레시·템플릿 초안· 생성·홈의 DB 작업을 출력하는 RequestDbProfileIT. 부하테스트(2026-10)에서 로그인·생성이 과한지 보려고 만들었다. Co-Authored-By: Claude Opus 5.5 --- .../perf/RequestDbProfileIT.java | 113 ++++++++++++++++++ .../ruleup_backend/perf/SqlRecorder.java | 89 ++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/test/java/com/ruleup/ruleup_backend/perf/RequestDbProfileIT.java create mode 100644 src/test/java/com/ruleup/ruleup_backend/perf/SqlRecorder.java diff --git a/src/test/java/com/ruleup/ruleup_backend/perf/RequestDbProfileIT.java b/src/test/java/com/ruleup/ruleup_backend/perf/RequestDbProfileIT.java new file mode 100644 index 00000000..382b6c4a --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/perf/RequestDbProfileIT.java @@ -0,0 +1,113 @@ +package com.ruleup.ruleup_backend.perf; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.auth.AuthApiSupport; +import com.ruleup.ruleup_backend.moderation.ContentModerationClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * 요청 하나가 DB 에 하는 일을 센다 — 부하테스트(2026-10) 에서 로그인·생성이 과한지 보려고 만들었다. + * 숫자가 늘면 이 시험이 먼저 알려 준다(상한은 개선 후 실측값). + */ +@SpringBootTest +@Import({TestcontainersConfiguration.class, SqlRecorder.Config.class}) +class RequestDbProfileIT extends AuthApiSupport { + + @Autowired WebApplicationContext wac; + @MockitoBean ContentModerationClient moderationClient; + private MockMvc mvc; + + @Override protected MockMvc mvc() { return mvc; } + + @BeforeEach + void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + + record Profile(int connections, int commits, List sql) { + long count(String verb) { return sql.stream().filter(e -> e.sql().regionMatches(true, 0, verb, 0, verb.length())).count(); } + void print(String title) { + System.out.printf("%n==== %s — 커넥션 %d · 커밋 %d · SQL %d (SELECT %d · INSERT %d · UPDATE %d · DELETE %d)%n", title, connections, commits, + sql.size(), count("select"), count("insert"), count("update"), count("delete")); + sql.forEach(e -> System.out.printf(" [%s] %s%n", e.thread(), e.sql().length() > 220 ? e.sql().substring(0, 220) + "…" : e.sql())); + } + } + + private Profile profile(ThrowingRunnable r) throws Exception { + SqlRecorder.start(); + r.run(); + Thread.sleep(1500); // 커밋 뒤 비동기 리스너까지 기다린다 + List sql = SqlRecorder.stop(); + return new Profile(SqlRecorder.connections(), SqlRecorder.commits(), sql); + } + + interface ThrowingRunnable { void run() throws Exception; } + + @Test + @DisplayName("기존 회원 로그인 · 리프레시 · 템플릿 초안 · 생성의 DB 작업") + void loginRefreshCreate() throws Exception { + String tag = uniq("perf"); + signup(tag, "성능" + seq()); + Map login = loginBody(tag, "inst-" + tag, "dev-" + tag); + postJson("/api/v1/auth/oauth/kakao", login); // 첫 로그인은 캐시 워밍(동기화 정책 등) + + MvcResult[] res = new MvcResult[1]; + Profile loginP = profile(() -> res[0] = postJson("/api/v1/auth/oauth/kakao", login)); + assertThat(res[0].getResponse().getStatus()).isEqualTo(200); + loginP.print("기존 회원 로그인(같은 기기)"); + String access = read(res[0], "$.data.accessToken"); + String refresh = read(res[0], "$.data.refreshToken"); + + Profile refreshP = profile(() -> res[0] = postJson("/api/v1/auth/refresh", Map.of("refreshToken", refresh))); + assertThat(res[0].getResponse().getStatus()).isEqualTo(200); + refreshP.print("리프레시"); + + Profile draftP = profile(() -> res[0] = postJsonAuth("/api/v1/challenges/recommendation/by-template", access, Map.of("templateId", 1001))); + assertThat(res[0].getResponse().getStatus()).isEqualTo(200); + draftP.print("템플릿 초안"); + Map body = createBody(res[0]); + + Profile createP = profile(() -> res[0] = mvc.perform(post("/api/v1/challenges").header("Authorization", "Bearer " + access) + .header("Idempotency-Key", UUID.randomUUID().toString()).contentType("application/json") + .content(OM.writeValueAsString(body))).andReturn()); + assertThat(res[0].getResponse().getStatus()).as(res[0].getResponse().getContentAsString()).isIn(200, 201); + createP.print("챌린지 생성(공개 그룹)"); + + Profile homeP = profile(() -> mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/me/home") + .header("Authorization", "Bearer " + access)).andReturn()); + homeP.print("홈(GET /me/home)"); + } + + @SuppressWarnings("unchecked") + private Map createBody(MvcResult draft) throws Exception { + Map d = read(draft, "$.data.draft"); + Map b = new LinkedHashMap<>(); + b.put("draftId", read(draft, "$.data.draftId")); + for (String k : List.of("title", "description", "category", "minTier", "period", "weeklyCount")) b.put(k, d.get(k)); + b.put("mode", "GROUP"); b.put("visibility", "PUBLIC"); b.put("rankingVisible", true); b.put("capacity", 100); + b.put("params", ((List>) d.get("params")).stream() + .map(p -> Map.of("key", p.get("key"), "value", String.valueOf(p.get("value")))).collect(Collectors.toList())); + Map v = (Map) d.get("verification"); + b.put("verification", Map.of("type", v.get("type"), "method", v.get("method"))); + b.put("penalties", Map.of("watcher", false)); + return b; + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/perf/SqlRecorder.java b/src/test/java/com/ruleup/ruleup_backend/perf/SqlRecorder.java new file mode 100644 index 00000000..f730e754 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/perf/SqlRecorder.java @@ -0,0 +1,89 @@ +package com.ruleup.ruleup_backend.perf; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.datasource.DelegatingDataSource; + +import javax.sql.DataSource; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 시험 전용 — 요청 하나가 DB 에 무엇을 몇 번 하는지 센다(커넥션 대여·트랜잭션·SQL 문장). + * 데이터소스를 감싸 Connection·Statement 호출을 기록한다. 운영 코드에는 없다. + */ +public final class SqlRecorder { + + public record Entry(String thread, String sql) {} + + private static final List SQL = new ArrayList<>(); + private static final AtomicInteger CONNECTIONS = new AtomicInteger(); + private static final AtomicInteger COMMITS = new AtomicInteger(); + private static volatile boolean on; + + private SqlRecorder() {} + + public static synchronized void start() { SQL.clear(); CONNECTIONS.set(0); COMMITS.set(0); on = true; } + public static synchronized List stop() { on = false; return List.copyOf(SQL); } + public static int connections() { return CONNECTIONS.get(); } + public static int commits() { return COMMITS.get(); } + + private static synchronized void record(String sql) { + if (on) SQL.add(new Entry(Thread.currentThread().getName(), sql.replaceAll("\\s+", " ").trim())); + } + + @TestConfiguration + public static class Config { + @Bean + static BeanPostProcessor sqlRecordingDataSource() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String name) { + if (bean instanceof DataSource ds && !(bean instanceof Recording)) return new Recording(ds); + return bean; + } + }; + } + } + + static final class Recording extends DelegatingDataSource { + Recording(DataSource target) { super(target); } + + @Override public Connection getConnection() throws SQLException { return wrap(super.getConnection()); } + @Override public Connection getConnection(String u, String p) throws SQLException { return wrap(super.getConnection(u, p)); } + + private static Connection wrap(Connection c) { + if (on) CONNECTIONS.incrementAndGet(); + InvocationHandler h = (proxy, m, args) -> { + String n = m.getName(); + if (("prepareStatement".equals(n) || "prepareCall".equals(n)) && args != null && args[0] instanceof String sql) record(sql); + if ("commit".equals(n) && on) COMMITS.incrementAndGet(); + Object r = invoke(m, c, args); + if (r instanceof Statement st && "createStatement".equals(n)) return statement(st); + return r; + }; + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, h); + } + + private static Statement statement(Statement st) { + Set exec = Set.of("execute", "executeQuery", "executeUpdate", "executeLargeUpdate", "addBatch"); + return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class[]{Statement.class}, (p, m, a) -> { + if (exec.contains(m.getName()) && a != null && a.length > 0 && a[0] instanceof String sql) record(sql); + return invoke(m, st, a); + }); + } + + private static Object invoke(java.lang.reflect.Method m, Object target, Object[] args) throws Throwable { + try { return m.invoke(target, args); } catch (InvocationTargetException e) { throw e.getCause(); } + } + } +} From 385b650a4fc7a238fea20b39ca0c9ab2437c6d06 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 22:08:20 +0900 Subject: [PATCH 02/12] =?UTF-8?q?perf:=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=EC=9D=BC=EC=9D=84=20=ED=95=98=EB=A3=A8=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=EB=A7=8C=20=EC=93=B0=EA=B3=A0=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증된 요청마다 user_activity upsert 가 돌아 GET 하나가 곧 DB 쓰기 하나·커넥션 대여 하나였다. 값이 날짜라 같은 날 반복은 결과가 같으므로 태스크마다 오늘 기록 여부를 기억하고 건너뛴다. 로그인·가입은 touchNow 로 반드시 쓴다(같은 날 앞선 기록이 탈퇴 상태라 무시됐을 수 있다). 로그인은 저장 전후로 두 번 쓰던 것을 저장 뒤 한 번으로. Co-Authored-By: Claude Opus 5.5 --- .../auth/service/AuthService.java | 2 +- .../auth/service/LoginSessionService.java | 4 +-- .../user/UserActivityService.java | 35 +++++++++++++++++-- .../ruleup_backend/auth/OnboardingSpecIT.java | 5 +-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java index a650e787..9d975b71 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java @@ -320,7 +320,7 @@ private SignupResponse register(SignupRequest req, boolean[] tokenConsumed, int user.attachInstallation(req.installationId(), req.deviceId()); applyCountry(user, req.deviceInfo()); // 지오 헤더 → 기기 지역 → Accept-Language → 기기 타임존 → 기본값 userRepository.saveAndFlush(user); - activity.touch(user.getId()); + activity.touchNow(user.getId()); UserScoreSummary summary = scoreService.initialize(user.getId()); // 브론즈 10점 // 친구 초대 기록(선택). 설치 경로로 받은 친구 초대 링크는 코드를 따로 보내지 않아도 연동한다. diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java b/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java index 8c854b20..9fa3e61b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/service/LoginSessionService.java @@ -119,9 +119,9 @@ public OAuthLoginResponse loginExisting(UUID userId, OAuthProvider provider, user.attachInstallation(req.installationId(), req.deviceId()); applyCountry(user, req.deviceInfo()); user.touchLastLogin(); - activity.touch(user.getId()); userRepository.saveAndFlush(user); - activity.touch(user.getId()); + // 저장 뒤에 한 번만 — 복원이면 저장 전엔 아직 WITHDRAWN 이라 기록이 무시된다. + activity.touchNow(user.getId()); socialTokenService.upsert(user.getId(), provider, info.idpTokens()); // unlink 근거 최신화 diff --git a/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java b/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java index bb3611e6..8a228cff 100644 --- a/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java +++ b/src/main/java/com/ruleup/ruleup_backend/user/UserActivityService.java @@ -1,27 +1,56 @@ package com.ruleup.ruleup_backend.user; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.nio.ByteBuffer; +import java.time.Duration; import java.time.LocalDate; import java.time.ZoneId; import java.util.UUID; +/** + * 마지막 활동일 기록 — 휴면 판정의 근거({@code user_activity.last_active_on}). + * + *

값이 날짜라 같은 날 두 번째부터의 기록은 결과가 같다. 그런데 인증된 요청마다 부르므로 + * 그대로 두면 GET 하나가 곧 DB 쓰기 하나였다(부하테스트 2026-10: 읽기 요청마다 upsert 1건 + + * 커넥션 대여 1회). 그래서 태스크마다 「오늘 이미 썼다」를 기억하고 같은 날은 건너뛴다. + * 태스크가 여럿이면 태스크마다 하루 한 번씩 쓴다 — 결과는 같다. + * + *

로그인·가입처럼 계정 상태가 막 바뀐 직후는 {@link #touchNow} 로 반드시 쓴다 — 같은 날 앞선 기록이 + * 탈퇴 상태라 무시됐을 수 있다(INSERT 가 {@code status<>'WITHDRAWN'} 일 때만 들어간다). + */ @Service @RequiredArgsConstructor public class UserActivityService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final JdbcTemplate jdbc; + private final Cache recordedOn = Caffeine.newBuilder() + .maximumSize(200_000).expireAfterWrite(Duration.ofHours(25)).build(); - @Transactional + /** 요청마다 부른다. 오늘 이미 기록했으면 DB 를 건드리지 않는다(트랜잭션·커넥션도 열지 않는다). */ public void touch(UUID userId) { - LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + LocalDate today = LocalDate.now(KST); + if (today.equals(recordedOn.getIfPresent(userId))) return; + write(userId, today); + } + + /** 로그인·가입·복원 직후 — 기억과 상관없이 기록한다. 호출한 쪽 트랜잭션이 있으면 거기에 합류한다. */ + public void touchNow(UUID userId) { + write(userId, LocalDate.now(KST)); + } + + private void write(UUID userId, LocalDate today) { byte[] id = ByteBuffer.allocate(16).putLong(userId.getMostSignificantBits()).putLong(userId.getLeastSignificantBits()).array(); jdbc.update("INSERT INTO user_activity(user_id,last_active_on) SELECT id,? FROM users " + "WHERE id=? AND status<>'WITHDRAWN' ON DUPLICATE KEY UPDATE " + "notified_stage=IF(last_active_on Date: Thu, 15 Oct 2026 22:08:20 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=EA=B8=B0=EB=8F=99=20=EC=A7=81?= =?UTF-8?q?=ED=9B=84=20=ED=83=90=EC=83=89=20Redis=20=EC=B2=AB=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=9D=84=20=EC=9E=AC=EC=8B=9C=EB=8F=84=ED=95=B4=205?= =?UTF-8?q?=EB=B6=84=20=ED=8F=B4=EB=B0=B1=EC=9D=84=20=EB=A7=89=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포·스케일아웃마다 워밍업이 Unable to connect to Redis 로 실패해 회로가 열리고 5분 스윕까지 탐색이 SQL 폴백으로 돌았다(stg·prod 모두). 컨테이너에서 연결+PING 은 7ms 라 네트워크 문제가 아니라 0.5 vCPU 에서 막 뜬 JVM 의 첫 핸드셰이크 지연이다. 워밍업 전에 PING 을 최대 5회(2·4·6·8초 간격) 시도하고, 실패 원인은 감싼 예외가 아니라 맨 안쪽 원인까지 남긴다. Co-Authored-By: Claude Opus 5.5 --- .../explore/store/ExploreIndexJobs.java | 41 ++++++++++++++++++- .../explore/store/ExploreRedisStore.java | 7 ++++ .../explore/store/ExploreWarmupRetryTest.java | 37 +++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreWarmupRetryTest.java diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java index 3d8a3721..564781d3 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreIndexJobs.java @@ -54,9 +54,48 @@ public class ExploreIndexJobs { @EventListener(ApplicationReadyEvent.class) public void warmUpOnStartup() { if (!RuntimeRole.valueOf(runtimeRole.toUpperCase()).runsBackgroundWork()) return; + if (!redisReachable()) return; // 끝내 못 붙으면 5분 스윕이 이어서 시도한다 ensureWarmed("기동"); } + /** + * 기동 직후 첫 Redis 연결은 여러 번 시도한다. + * + *

배포·스케일아웃 때마다 워밍업이 「Unable to connect to Redis」로 실패해 회로가 열리고, 5분 스윕이 + * 다시 데울 때까지 탐색이 SQL 폴백으로 돌았다(stg·prod 모두, 부하테스트 2026-10). 네트워크는 정상이었다 — + * 컨테이너에서 연결+PING 이 7ms. 0.5 vCPU 에서 막 뜬 JVM 이 기동 작업과 겹쳐 첫 핸드셰이크를 제때 못 끝낸 + * 것이라, 시간을 두고 다시 붙으면 된다. 스케일아웃은 부하가 몰릴 때 일어나므로 이 5분이 가장 비싼 5분이다. + */ + boolean redisReachable() { + RuntimeException last = null; + for (int attempt = 1; attempt <= STARTUP_REDIS_ATTEMPTS; attempt++) { + try { + store.ping(); + if (attempt > 1) log.info("탐색 Redis 연결 — {}번째 시도에 성공", attempt); + return true; + } catch (RuntimeException e) { + last = e; + log.info("탐색 Redis 연결 대기 {}/{} — {}", attempt, STARTUP_REDIS_ATTEMPTS, rootCause(e)); + sleepQuietly(STARTUP_REDIS_BACKOFF.multipliedBy(attempt)); + } + } + circuit.openManually("워밍업 실패(기동, 연결 " + STARTUP_REDIS_ATTEMPTS + "회): " + rootCause(last)); + return false; + } + + private static final int STARTUP_REDIS_ATTEMPTS = 5; + private static final java.time.Duration STARTUP_REDIS_BACKOFF = java.time.Duration.ofSeconds(2); + + static String rootCause(Throwable e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) t = t.getCause(); + return t == e ? e.toString() : e.getClass().getSimpleName() + " ← " + t; + } + + private static void sleepQuietly(java.time.Duration d) { + try { Thread.sleep(d.toMillis()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + } + /** * 준비 상태를 확인하고, 아니면 원천으로 만든다. * @@ -123,7 +162,7 @@ private boolean ensureWarmed(String reason) { store.unlockRebuild(token); } } catch (RuntimeException e) { - circuit.openManually("워밍업 실패(" + reason + "): " + e); + circuit.openManually("워밍업 실패(" + reason + "): " + rootCause(e)); return false; } } diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java index 6fbc3256..05149c4e 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreRedisStore.java @@ -31,6 +31,13 @@ public class ExploreRedisStore { private final StringRedisTemplate redis; + // ===== 연결 ===== + + /** 연결을 맺고 왕복 한 번 — 기동 직후 첫 연결을 워밍업 본작업과 떼어 두려고 쓴다. */ + public void ping() { + redis.execute((org.springframework.data.redis.core.RedisCallback) c -> c.ping()); + } + // ===== 워밍업 플래그 ===== public boolean isWarmed() { diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreWarmupRetryTest.java b/src/test/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreWarmupRetryTest.java new file mode 100644 index 00000000..322be66d --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/explore/store/ExploreWarmupRetryTest.java @@ -0,0 +1,37 @@ +package com.ruleup.ruleup_backend.challenge.explore.store; + +import com.ruleup.ruleup_backend.challenge.stats.ChallengeStatsReconciliationService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.RedisConnectionFailureException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +/** 배포 직후 첫 Redis 연결 실패가 곧바로 5분 폴백이 되지 않게 — 부하테스트 2026-10. */ +class ExploreWarmupRetryTest { + + @Test + @DisplayName("첫 연결이 실패해도 다시 붙으면 회로를 열지 않는다") + void retriesFirstConnection() { + ExploreRedisStore store = mock(ExploreRedisStore.class); + doThrow(new RedisConnectionFailureException("Unable to connect to Redis", new IllegalStateException("handshake timed out"))) + .doNothing().when(store).ping(); + ExploreCircuitBreaker circuit = new ExploreCircuitBreaker(new SimpleMeterRegistry()); + org.springframework.test.util.ReflectionTestUtils.setField(circuit, "enabled", true); + org.springframework.test.util.ReflectionTestUtils.setField(circuit, "openDurationMs", 30_000L); + ExploreIndexJobs jobs = new ExploreIndexJobs(mock(ExploreIndexer.class), store, circuit, mock(ChallengeStatsReconciliationService.class)); + + assertThat(jobs.redisReachable()).isTrue(); + verify(store, times(2)).ping(); + assertThat(circuit.isOpen()).isFalse(); + } + + @Test + @DisplayName("실패 원인은 감싼 예외가 아니라 맨 안쪽 원인까지 남긴다") + void logsRootCause() { + var e = new RedisConnectionFailureException("Unable to connect to Redis", new IllegalStateException("handshake timed out")); + assertThat(ExploreIndexJobs.rootCause(e)).contains("RedisConnectionFailureException").contains("handshake timed out"); + } +} From 391f1b9d15d5380bee53ff57141ba0e713a1b881 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 22:08:20 +0900 Subject: [PATCH 04/12] =?UTF-8?q?perf:=20AI=20=EC=B4=88=EC=95=88=EC=9D=98?= =?UTF-8?q?=20LLM=20=ED=98=B8=EC=B6=9C=20=EB=8F=99=EC=95=88=20DB=20?= =?UTF-8?q?=EC=BB=A4=EB=84=A5=EC=85=98=EC=9D=84=20=EC=9E=A1=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draft() 가 @Transactional 이라 시작 시점에 커넥션을 잡고 LLM 응답(수 초, 재시도·폴백 시 수십 초)까지 쥐고 있었다. 태스크 풀 15개 중 동시 AI 초안 수만큼이 묶인다. DB 작업은 등급 조회·초안 저장 둘뿐이라 각자 리포지토리 트랜잭션으로 충분하다. Co-Authored-By: Claude Opus 5.5 --- .../challenge/draft/ChallengeDraftService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java index 01cf13dd..5e43ac2b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/draft/ChallengeDraftService.java @@ -86,7 +86,12 @@ public class ChallengeDraftService { // ===== 경로 B: 설명 입력(LLM 5-Step) ===== - @Transactional + /** + * 트랜잭션으로 감싸지 않는다. LLM 호출이 수 초(재시도·폴백까지 최악 수십 초) 걸리는데, 트랜잭션이 열리면 + * 시작 시점에 DB 커넥션을 잡아 응답이 올 때까지 쥐고 있다 — 태스크 풀 15개 중 동시 AI 초안 수만큼이 묶여 같은 + * 태스크의 다른 API 가 커넥션을 기다렸다(부하테스트 2026-10). DB 작업은 끝의 등급 조회·초안 저장 둘뿐이고 각자 + * 리포지토리 트랜잭션으로 충분하다(초안 저장은 한 행이라 부분 실패가 없다). + */ public DraftResponse draft(UUID userId, String description) { validateDescription(description); From 834466eb84add266ba3c2ba4e209a9a667fa94ff Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 00:28:22 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20sync=20=EC=A0=80=EC=9E=A5=C2=B7?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98?= =?UTF-8?q?=EC=9D=B4=20=EB=8D=B0=EB=93=9C=EB=9D=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=80=EB=A9=B4=20=EB=8B=A4=EC=8B=9C=20=EB=8F=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 사용자의 sync 가 겹치면(재전송·백로그, 빈도 제한기가 태스크마다 메모리에 있어 두 태스크로 나뉘면 못 막는다) 세션 갱신·신호 INSERT IGNORE·판정 갱신을 하는 긴 트랜잭션이 서로의 잠금을 잡아 한쪽이 데드락으로 졌다 — 부하테스트 피크 5xx 11건 전부. 봉투 검증·빈도 제한은 한 번만 하고 트랜잭션 본문만 최대 3회 다시 돈다. Co-Authored-By: Claude Opus 5.5 --- .../service/VerificationSyncService.java | 42 ++++++++- .../VerificationSyncDeadlockRetryIT.java | 88 +++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java index 30fe6b2b..6d9b5af2 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationSyncService.java @@ -36,6 +36,9 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.dao.PessimisticLockingFailureException; import java.math.BigDecimal; import java.math.RoundingMode; @@ -160,7 +163,26 @@ public VerificationSyncService(DeviceSyncPolicyService syncPolicy, ChallengeQuer .collect(Collectors.toMap(MethodEvaluator::method, e -> e, (a, b) -> a)); } - @Transactional + /** 데드락 재시도 — 트랜잭션 본문만 다시 돈다. 생성자가 길어 세터로 받는다. */ + private TransactionTemplate tx; + + @org.springframework.beans.factory.annotation.Autowired + void setTransactionManager(PlatformTransactionManager transactionManager) { + this.tx = new TransactionTemplate(transactionManager); + } + + /** 데드락으로 진 트랜잭션은 이 횟수까지 처음부터 다시 돈다(MySQL 1213 「try restarting transaction」). */ + static final int DEADLOCK_ATTEMPTS = 3; + + /** + * 봉투 검증·빈도 제한은 한 번만 하고, 저장·판정 트랜잭션만 데드락이면 다시 돈다. + * + *

sync 한 건은 세션 갱신·신호 INSERT IGNORE·판정 갱신을 한 트랜잭션에서 한다. 같은 사용자의 sync 가 겹치면 + * (재전송·백로그, 빈도 제한기가 태스크마다 메모리에 있어 두 태스크로 나뉘면 막지 못한다) 서로의 잠금을 잡아 + * 한쪽이 데드락으로 진다 — 부하테스트(2026-10) 피크에서 5xx 11건이 전부 이것이었다. 진 쪽은 아무것도 남기지 않고 + * 롤백되므로 처음부터 다시 하면 된다. 재시도를 트랜잭션 밖에 두는 이유: 안에서 잡으면 이미 롤백 표시가 된 뒤다. + * 빈도 제한을 재시도에 다시 태우면 자기 자신의 첫 시도에 막혀 429 가 된다. + */ public SyncResponse sync(UUID userId, SyncRequest req) { long startedAt = System.nanoTime(); if (req == null) throw rejectEnvelope(userId, null, EnvelopeRejection.MISSING_BODY); @@ -183,6 +205,24 @@ public SyncResponse sync(UUID userId, SyncRequest req) { .distinct().toList(); List gaps = (req.gaps() != null) ? req.gaps() : List.of(); // §8.5 권한 공백 소비 입력 + for (int attempt = 1; ; attempt++) { + try { + return tx.execute(status -> syncInTransaction(userId, req, signals, ignored, gaps, startedAt)); + } catch (PessimisticLockingFailureException e) { + if (attempt >= DEADLOCK_ATTEMPTS) throw e; + log.warn("sync_deadlock_retry userId={} attempt={} cause={}", userId, attempt, e.getMostSpecificCause().getMessage()); + try { + Thread.sleep(java.util.concurrent.ThreadLocalRandom.current().nextLong(20, 80) * attempt); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + } + + private SyncResponse syncInTransaction(UUID userId, SyncRequest req, List signals, List ignored, + List gaps, long startedAt) { Instant now = clock.instant(); LocalDate today = LocalDate.ofInstant(now, KST); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java new file mode 100644 index 00000000..c1f7c2f9 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSyncDeadlockRetryIT.java @@ -0,0 +1,88 @@ +package com.ruleup.ruleup_backend.verification; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.verification.service.VerificationSignalIngestService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * 같은 사용자의 sync 가 겹쳐 데드락으로 진 트랜잭션은 처음부터 다시 돈다 — 부하테스트(2026-10) 피크 5xx 11건의 원인. + * 빈도 제한은 다시 태우지 않으므로 재시도가 자기 첫 시도에 막혀 429 가 되지 않는다. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class VerificationSyncDeadlockRetryIT extends VerificationApiSupport { + + @Autowired WebApplicationContext wac; + @Autowired JdbcTemplate jdbcTemplate; + @MockitoSpyBean VerificationSignalIngestService ingest; + private MockMvc mvc; + + @BeforeEach + void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + + @Override protected MockMvc mvc() { return mvc; } + @Override protected JdbcTemplate jdbc() { return jdbcTemplate; } + + @Test + @DisplayName("신호 적재가 데드락으로 한 번 지면 다시 돌아 200 으로 끝난다") + void retriesDeadlockedTransaction() throws Exception { + Member me = member(uniq("deadlock")); + doThrow(new CannotAcquireLockException("Deadlock found when trying to get lock; try restarting transaction")) + .doCallRealMethod() + .when(ingest).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + + MvcResult res = postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of(steps("dl-1")))); + + assertThat(res.getResponse().getStatus()).as(res.getResponse().getContentAsString()).isEqualTo(200); + verify(ingest, times(2)).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + } + + @Test + @DisplayName("계속 지면 정해진 횟수까지만 돈다") + void givesUpAfterLimit() throws Exception { + Member me = member(uniq("deadlock-limit")); + doThrow(new CannotAcquireLockException("Deadlock found when trying to get lock")) + .when(ingest).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + + MvcResult res = postJsonAuth("/api/v1/verifications/sync", me.token(), syncBody(List.of(steps("dl-2")))); + + assertThat(res.getResponse().getStatus()).isGreaterThanOrEqualTo(500); + verify(ingest, times(3)).ingest(eq(me.id()), anyList(), any(Instant.class), any(VerificationSignalIngestService.Source.class)); + } + + private static Map steps(String recordId) { + Instant to = Instant.now(), from = to.minusSeconds(600); + Map reading = new LinkedHashMap<>(); + reading.put("recordId", recordId); + reading.put("metric", "STEPS"); + reading.put("value", 100); + reading.put("unit", "count"); + reading.put("startTime", from.toString()); + reading.put("endTime", to.toString()); + reading.put("origin", Map.of("dataOrigin", "com.sec.android.app.shealth", "recordingMethod", "AUTO", "device", "WATCH")); + return Map.of("type", "HEALTH", "recordId", recordId, "observedAt", to.toString(), "readings", List.of(reading)); + } +} From cdbb2805ccdac587b67f55a8cd28f13d6a0d613a Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 00:47:49 +0900 Subject: [PATCH 06/12] =?UTF-8?q?perf:=20MySQL=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=84=B1=EB=8A=A5=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=A0=91=EC=86=8D=20=EC=A3=BC=EC=86=8C=EA=B0=80=20?= =?UTF-8?q?=EC=95=84=EB=8B=88=EB=9D=BC=20=ED=92=80=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=97=90=20=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stg·prod 접속 주소(env)가 기본 주소를 통째로 덮어 rewriteBatchedStatements 가 빠져 있었다 — 신호 일괄 적재가 행마다 따로 나갔다. 프로파일에서 드라이버가 CPU 의 16% 였다. 문장 캐시·세션 상태 로컬 관리·autocommit 왕복 생략을 hikari.data-source-properties 로 둬 주소와 상관없이 적용한다. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/application.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2bc79146..15ab7f0f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,6 +36,18 @@ spring: minimum-idle: 5 # 운영 지표 RuleUp/Ops 의 Pool 차원 — POOL-01~03 경보가 이 이름을 가리킨다(기본값 HikariPool-1 은 기동 순서에 따라 바뀔 수 있다). pool-name: ruleup-main + # MySQL 드라이버 성능 옵션 — 접속 주소(env)와 상관없이 늘 적용된다(부하테스트 2026-10). + # 주소에만 두면 env 가 기본 주소를 통째로 덮어 빠진다: stg·prod 주소에 rewriteBatchedStatements 가 없어 + # 신호 일괄 적재가 행마다 따로 나갔다. 프로파일에서 드라이버가 CPU 의 16% 였다. + data-source-properties: + rewriteBatchedStatements: true # batchUpdate 를 다중 행 INSERT 하나로 + cachePrepStmts: true # 같은 SQL 의 파싱 결과를 커넥션마다 재사용 + prepStmtCacheSize: 250 + prepStmtCacheSqlLimit: 2048 + useLocalSessionState: true # autocommit·읽기전용 상태를 드라이버가 기억 — 매 트랜잭션 확인 왕복 생략 + elideSetAutoCommits: true + cacheServerConfiguration: true + maintainTimeStats: false jpa: hibernate: From 7d3a24982f5eeeab2b28c8a7aa22bf0ed59ddadc Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 01:07:26 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=8A=A4=EB=A0=88=EB=93=9C=EB=A5=BC=204=EA=B0=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=8A=98=EB=A0=A4=20=EC=9E=91=EC=97=85=EB=81=BC?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=80=EB=A6=AC=EA=B3=A0=20cron=20=EC=9D=B4=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Scheduled 54개가 기본 스레드 1개를 나눠 써서 긴 작업 뒤에 다른 작업이 밀렸다. 밀린 cron 작업은 다른 태스크의 ShedLock 최소 잠금이 풀린 뒤에 시작해 같은 날 중복 실행됐다(00:40 일별 집계가 태스크 3대에서 각각 00:40·00:41·00:43). SCHEDULER_POOL_SIZE 로 조정한다. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/application.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 15ab7f0f..be9969c2 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -15,6 +15,11 @@ spring: task: scheduling: + # 예약 작업이 50개가 넘는데 기본 스레드는 1개다 — 긴 작업(자정 활성화 등) 뒤에 다른 작업이 줄줄이 밀렸고, + # 밀린 cron 작업은 다른 태스크의 ShedLock 최소 잠금이 풀린 뒤 시작해 같은 날 중복 실행됐다(부하테스트 2026-10: + # 00:40 일별 집계가 태스크 3대에서 00:40·00:41·00:43 에 각각). 같은 작업의 중복은 여전히 ShedLock 이 막는다. + pool: + size: ${SCHEDULER_POOL_SIZE:4} # 확정 배치·정산·파기처럼 @Scheduled 로 도는 작업이 많다. 종료 시 스케줄러를 곧바로 # 끊으면 그 작업이 중간에 죽는데, 대부분 청크 트랜잭션이라 롤백은 깨끗해도 그만큼 # 다음 주기까지 처리가 밀린다. 짧게 기다려 진행 중인 한 사이클만 마치게 한다. From 83c2ac670f7276b6381c9c879e5f879911274907 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 01:07:26 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=EC=8B=9C=EC=9E=91=EC=9D=BC=20?= =?UTF-8?q?=EB=8F=84=EB=8B=AC=20=ED=99=9C=EC=84=B1=ED=99=94=EB=A5=BC=201?= =?UTF-8?q?=EB=B6=84=20200=EA=B1=B4=20=EC=83=81=ED=95=9C=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=81=9D=EA=B9=8C=EC=A7=80=20=EB=8F=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 번 실행에 200건만 전환하고 끝나서, 시작일이 자정에 몰린 방 4,870건이 모두 ACTIVE 가 되기까지 22분 걸렸다. 그동안 오늘 시작인 방이 「시작 전」으로 보이고 인증이 평가되지 않는다. 200건씩 한 트랜잭션으로 끊어 남은 것이 없을 때까지(실행당 최대 4분, 락 10분 안) 돈다. Co-Authored-By: Claude Opus 5.5 --- .../lifecycle/ChallengeActivationService.java | 27 ++++++++++++++++--- .../challenge/ChallengeLifecycleBatchIT.java | 14 ++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java index f6a192fe..5252e3d3 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/lifecycle/ChallengeActivationService.java @@ -18,7 +18,6 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.ZoneId; @@ -42,17 +41,38 @@ public class ChallengeActivationService { private static final Logger log = LoggerFactory.getLogger(ChallengeActivationService.class); private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final int CLAIM_LIMIT = 200; + /** 한 번 실행에서 쓰는 최대 시간 — 락(lockAtMostFor 10분) 안에서 끝나야 다른 태스크가 겹쳐 잡지 않는다. */ + private static final java.time.Duration RUN_BUDGET = java.time.Duration.ofMinutes(4); private final ChallengeRepository challengeRepository; private final ChallengeMemberRepository memberRepository; private final NotificationPublisher notificationPublisher; private final ApplicationEventPublisher eventPublisher; + private final org.springframework.transaction.PlatformTransactionManager transactionManager; - /** 1분마다: 시작일이 도달한 시작 전(UPCOMING)·모더레이션 통과 챌린지를 ACTIVE 로 전환한다. */ + /** + * 1분마다: 시작일이 도달한 시작 전(UPCOMING)·모더레이션 통과 챌린지를 ACTIVE 로 전환한다. + * + *

남은 것이 없을 때까지 200건씩 끊어 돈다(한 덩어리 = 한 트랜잭션). 예전엔 한 번에 200건만 하고 끝나 + * 1분에 200건이 상한이었다 — 생성 기본값이 「내일 시작」이라 시작일이 자정에 몰리는데, 부하테스트(2026-10)에서 + * 4,870건이 다 ACTIVE 가 되기까지 22분 걸렸다. 그동안 그 방들은 오늘 시작인데 「시작 전」이고 인증도 평가되지 않는다. + * 덩어리마다 트랜잭션을 끊어 잠금·커밋을 짧게 두고, 실행 한 번은 {@link #RUN_BUDGET} 안에서 멈춘다. + */ @SchedulerLock(name = "ChallengeActivationService.activateDueChallenges", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S") @Scheduled(fixedDelay = 60_000) - @Transactional public void activateDueChallenges() { + var tx = new org.springframework.transaction.support.TransactionTemplate(transactionManager); + java.time.Instant deadline = java.time.Instant.now().plus(RUN_BUDGET); + int total = 0; + while (true) { + Integer done = tx.execute(status -> activateChunk()); + total += (done == null ? 0 : done); + if (done == null || done < CLAIM_LIMIT || java.time.Instant.now().isAfter(deadline)) break; + } + if (total > CLAIM_LIMIT) log.info("시작일 도달 전환 합계 {}건(한 번 실행)", total); + } + + private int activateChunk() { LocalDate today = LocalDate.now(KST); List due = challengeRepository.findUpcomingDueForActivationForUpdate(today, CLAIM_LIMIT); for (Challenge c : due) { @@ -66,6 +86,7 @@ public void activateDueChallenges() { eventPublisher.publishEvent(ChallengeGridChanged.of("CHALLENGE_ACTIVATED")); log.info("시작일 도달로 ACTIVE 전환한 챌린지 {}건", due.size()); } + return due.size(); } /** diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java index 042cbf4c..3c9f871d 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeLifecycleBatchIT.java @@ -85,6 +85,20 @@ void activateEvenWhenInReview() throws Exception { assertThat(status(id)).isEqualTo("ACTIVE"); } + @Test + @DisplayName("한 번 실행에 200건을 넘는 시작 도래 방도 모두 ACTIVE — 자정에 몰려도 1분에 200건으로 밀리지 않는다") + void activatesBeyondOneChunkInOneRun() throws Exception { + Member owner = member(uniq("lc-bulk")); + java.util.List ids = new java.util.ArrayList<>(); + for (int i = 0; i < 450; i++) ids.add(insertChallenge(owner.id(), "EXERCISE", "UPCOMING", "SOLO")); + jdbcTemplate.update("UPDATE challenges SET start_date = DATE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', '+09:00')) " + + "WHERE owner_id = ? AND status = 'UPCOMING'", bytes(owner.id())); + + activationService.activateDueChallenges(); + + assertThat(ids).allSatisfy(id -> assertThat(status(id)).isEqualTo("ACTIVE")); + } + @Test @DisplayName("종료일 경과 ACTIVE → COMPLETED") void completeEnded() throws Exception { From ebdcfc02edf96407c34fef8c62f1a01bd47010b5 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 01:24:40 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20Redis=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=EC=9D=84=202=EC=B4=88?= =?UTF-8?q?=EB=A1=9C=20=EB=8A=98=EB=A0=A4=20CPU=20=ED=8F=AC=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=A5=EC=95=A0=EB=A1=9C=20=EC=98=A4=ED=8C=90?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 500ms 는 앱 CPU 가 포화되면 Redis 가 한가해도(엔진 CPU 5%) 넘겼고, 3번 연속이면 그 태스크의 탐색 전체가 30초간 503 이었다(가입 몰림 시드에서 145건). 진짜 장애는 2초로도 충분히 빨리 걸린다. Co-Authored-By: Claude Opus 5.5 --- src/main/resources/application.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index be9969c2..b0b8b863 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -73,9 +73,11 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - # 명령 타임아웃은 짧게 — 탐색 조회가 Redis 를 기다리며 늘어지면 그건 장애를 지연으로 - # 바꾸는 것뿐이다. 느리면 빨리 포기하고 회로를 열어 503 으로 드러낸다. - timeout: ${REDIS_TIMEOUT:500ms} + # 명령 타임아웃 — 느리면 포기하고 회로를 열어 503 으로 드러낸다. 다만 500ms 는 너무 짧았다(부하테스트 2026-10): + # 앱 CPU 가 포화되면 Redis 는 한가해도(엔진 CPU 5%) 응답을 받을 이벤트 루프가 늦게 돌아 500ms 를 넘기고, + # 3번 연속이면 그 태스크의 탐색 전체가 30초간 503 이 됐다(시드 가입 몰림에서 145건). 진짜 장애(연결 끊김· + # 무응답)는 2초로도 충분히 빨리 걸린다. + timeout: ${REDIS_TIMEOUT:2000ms} # 연결 타임아웃은 그럴 수 없다. 기동 직후 워밍업은 JVM 이 아직 차가운 상태에서 # DNS 조회·TCP·Lettuce 핸드셰이크를 한꺼번에 하는데, 500ms 로는 그 한 번을 못 넘긴다 — # 실제로 배포할 때마다 워밍업이 여기서 실패해 회로가 열렸고, 5분 스윕이 다시 시도할 From 4a5c4364eebd066bf22bcebcb335fd3b9feb0aa6 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 02:47:52 +0900 Subject: [PATCH 10/12] =?UTF-8?q?perf:=20X-Ray=20API=20=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EB=A5=BC=20=EC=B4=88=EB=8B=B9=2010=EA=B1=B4?= =?UTF-8?q?=20=EB=84=98=EC=9C=BC=EB=A9=B4=2010%=EB=A7=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 피크(350 RPS)에서 100% 기록은 에이전트 스팬 큐(2048)를 넘쳐 스팬을 버리고 기록 요금도 하루 ~$150 꼴이 된다. 초당 10건 예약분은 그대로라 평소 트래픽은 전부 남는다. Co-Authored-By: Claude Opus 5.5 --- infra/monitoring/tracing/enable_tracing.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/infra/monitoring/tracing/enable_tracing.py b/infra/monitoring/tracing/enable_tracing.py index 7f9e147a..0991b98d 100644 --- a/infra/monitoring/tracing/enable_tracing.py +++ b/infra/monitoring/tracing/enable_tracing.py @@ -8,7 +8,9 @@ 1) 태스크 역할에 AWSXRayDaemonWriteAccess 2) X-Ray 샘플링 규칙 — 계정 단위라 stg·prod 공통이다. ruleup-skip-health(100) /actuator/* 는 기록하지 않음(ALB 헬스체크). - ruleup-api(200) */api/* 요청은 전부 기록. + ruleup-api(200) */api/* 요청은 초당 10건까지 전부, 그 위로는 10%만 기록. 평소 트래픽은 사실상 전부 남고, + 피크(2026-10 부하테스트 350 RPS)에선 100% 기록이 스팬 큐(2048)를 넘쳐 버려지고 + X-Ray 기록 요금이 하루 ~$150 꼴이 된다. Default(10000) 그 밖(배치·고아 DB 쿼리·지표 전송·SQS 폴링)은 1%만. 초당 1건+5% 였을 땐 이것들이 트레이스의 98% 였다 (배치 87% — 분당 ~30건이 초당 1건 예약분에 전부 들어갔다). 그룹 ruleup--api API 트레이스만 보는 필터(콘솔 트레이스 목록·서비스 맵에서 그룹을 고른다). @@ -72,7 +74,7 @@ # 에이전트는 경로가 아니라 전체 URL(http://10.1.1.73:8080/actuator/health)로 맞춰 본다 — '/actuator/*' 는 한 번도 안 걸렸다. rule=dict(RuleName='ruleup-skip-health',Priority=100,FixedRate=0.0,ReservoirSize=0,ServiceName='*',ServiceType='*', Host='*',HTTPMethod='*',URLPath='*/actuator/*',ResourceARN='*') - api=dict(RuleName='ruleup-api',Priority=200,FixedRate=1.0,ReservoirSize=10,ServiceName='*',ServiceType='*', + api=dict(RuleName='ruleup-api',Priority=200,FixedRate=0.1,ReservoirSize=10,ServiceName='*',ServiceType='*', Host='*',HTTPMethod='*',URLPath='*/api/*',ResourceARN='*') names={r['SamplingRule']['RuleName'] for r in xray.get_sampling_rules()['SamplingRuleRecords']} for r in (rule,api): From 665da66ccb48e5f99484051a9d559fc19a8aaaf7 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 03:39:20 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=9D=B4=20=EB=B0=A9=20=ED=96=89=EC=9D=84=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=EC=9E=A0=EA=B7=B8=EA=B3=A0=20=EC=84=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 먼저 세고 나서 락을 기다리면 그 첫 SELECT 에 REPEATABLE READ 스냅샷이 고정돼, 기다리는 동안 커밋된 가입·판정을 못 본 채 앞서 끝난 재계산을 옛 값으로 덮었다. 2배 피크 부하테스트에서 5분 보정이 43건을 고친 원인. Co-Authored-By: Claude Opus 5.5 --- .../ChallengeStatsProjectionService.java | 8 ++- .../challenge/ChallengeStatsProjectionIT.java | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java index 21c54c03..dc93a955 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/stats/ChallengeStatsProjectionService.java @@ -60,6 +60,12 @@ public void createRow(UUID challengeId) { public void refresh(UUID challengeId) { byte[] id = toBytes(challengeId); + // 세기 전에 방 행부터 잠근다. InnoDB 는 첫 일반 SELECT 에서 REPEATABLE READ 스냅샷을 + // 고정한다. 먼저 세고 나서 락을 기다리면, 기다리는 동안 커밋된 가입·판정은 못 본 채 앞서 끝난 + // 재계산의 최신 값을 옛 값으로 덮는다(부하테스트 2026-10: 2배 피크에서 5분 보정이 43건을 고쳤다). + // 잠금 읽기는 스냅샷을 만들지 않으므로, 락을 얻은 뒤의 첫 SELECT 가 그 사이 커밋을 모두 본다. + jdbc.queryForList("SELECT id FROM challenges WHERE id = ? FOR UPDATE", (Object) id); + // 참여자 수는 원천에서 다시 센다. 가입·탈퇴 트랜잭션은 더 이상 표시값을 올리고 // 내리지 않는다(탐색 백엔드 5-2·12) — 같은 방의 모든 가입이 그 한 행에서 직렬화되기 // 때문이다. 세어 둔 값을 challenges 에 되써 주는 이유는 다른 모듈(자동 삭제·추천 집계· @@ -69,7 +75,7 @@ public void refresh(UUID challengeId) { Integer.class, id); if (participantCount == null) participantCount = 0; - // challenges 를 먼저, 그리고 배타로 잡는다. 순서를 뒤집으면 락을 승격하게 된다 — + // challenges 를 먼저, 그리고 배타로 잡는다(위 FOR UPDATE). 순서를 뒤집으면 락을 승격하게 된다 — // challenge_stats 에 INSERT 하는 순간 외래키 검사가 부모 행에 공유 락을 걸고, 그 뒤의 // 이 UPDATE 가 같은 행을 배타 락으로 올린다. 그사이 다른 트랜잭션(강퇴·설정 변경처럼 // challenges 를 처음부터 배타로 잡는 경로)이 같은 행의 배타 락을 기다리고 있으면, diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java index 82be0def..5fbc303f 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/ChallengeStatsProjectionIT.java @@ -19,10 +19,15 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.context.event.ApplicationEvents; import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.context.WebApplicationContext; import java.math.BigDecimal; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @@ -46,6 +51,7 @@ class ChallengeStatsProjectionIT extends ChallengeApiSupport { @Autowired VerificationManualService manualVerificationService; @Autowired ChallengeActivationService activationService; @Autowired ApplicationEvents applicationEvents; + @Autowired PlatformTransactionManager txManager; MockMvc mvc; @Override protected MockMvc mvc() { return mvc; } @@ -306,4 +312,54 @@ void reconciliationCountsMissingRow() throws Exception { assertThat(rows).isEqualTo(1); } } + + // ===================================================================== + @Nested + @DisplayName("동시 재계산 — 락을 기다린 쪽이 옛 스냅샷으로 덮어쓰지 않는다") + class ConcurrentRefresh { + + @Test + @DisplayName("방 행을 다른 트랜잭션이 쥔 사이 들어온 가입도 기다렸다 센다") + void refreshWaitingOnLockSeesCommitsBeforeIt() throws Exception { + // 부하테스트(2026-10, 2배 피크)에서 5분 보정이 43건을 고쳤다. 재계산이 락을 잡기 전에 먼저 + // 세면 그 순간 REPEATABLE READ 스냅샷이 고정되고, 락을 기다리는 동안 커밋된 가입은 못 본 채 + // 마지막에 쓰게 된다 — 앞서 끝난 최신 재계산을 옛 값으로 덮는다. + UUID owner = member(uniq("race-owner")).id(); + UUID challengeId = activeChallenge(owner); + insertMemberWithProgress(challengeId, 0, 0, 0); + projectionService.refresh(challengeId); + UUID joiner = member(uniq("race-joiner")).id(); + + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture holder = CompletableFuture.runAsync(() -> + new TransactionTemplate(txManager).executeWithoutResult(tx -> { + // 다른 재계산·가입이 방 행을 쥐고, 그 안에서 가입이 하나 더 들어온다 + jdbcTemplate.queryForList("SELECT id FROM challenges WHERE id = ? FOR UPDATE", + (Object) bytes(challengeId)); + jdbcTemplate.update("INSERT INTO challenge_members " + + "(id, challenge_id, user_id, role, status, success_days, fail_days, target_days) " + + "VALUES (?, ?, ?, 'MEMBER', 'ACTIVE', 0, 0, 0)", + bytes(UUID.randomUUID()), bytes(challengeId), bytes(joiner)); + locked.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + + CompletableFuture refresh = CompletableFuture.runAsync(() -> projectionService.refresh(challengeId)); + Thread.sleep(1_000); // 재계산이 방 행 락에 걸려 기다리게 둔다 + assertThat(refresh).isNotDone(); + release.countDown(); + holder.get(10, TimeUnit.SECONDS); + refresh.get(10, TimeUnit.SECONDS); + + Integer participants = jdbcTemplate.queryForObject( + "SELECT participant_count FROM challenges WHERE id = ?", Integer.class, bytes(challengeId)); + assertThat(participants).isEqualTo(2); + } + } } From 5350337d842e2166575069c9d681a9c8216b5771 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Fri, 16 Oct 2026 04:17:26 +0900 Subject: [PATCH 12/12] =?UTF-8?q?test:=20RuleUp=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EA=B8=B0=EC=A4=80=20k6=20=EB=B6=80?= =?UTF-8?q?=ED=95=98=C2=B7=EC=84=B1=EB=8A=A5=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=EB=A5=BC=20=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실사용 비율의 피크(DAU 1만·동시 500), 2배 스트레스, 한계점, 장시간, AI 초안 시나리오와 API별 SLO 임계값. 준비(stg 로그인 우회·prod 동일 조건)·함정은 README. Co-Authored-By: Claude Opus 5.5 --- infra/loadtest/README.md | 48 ++++++++ infra/loadtest/k6/lib.js | 164 ++++++++++++++++++++++++++ infra/loadtest/k6/ruleup.js | 226 ++++++++++++++++++++++++++++++++++++ infra/loadtest/k6/smoke.js | 51 ++++++++ 4 files changed, 489 insertions(+) create mode 100644 infra/loadtest/README.md create mode 100644 infra/loadtest/k6/lib.js create mode 100644 infra/loadtest/k6/ruleup.js create mode 100644 infra/loadtest/k6/smoke.js diff --git a/infra/loadtest/README.md b/infra/loadtest/README.md new file mode 100644 index 00000000..7fdc13a3 --- /dev/null +++ b/infra/loadtest/README.md @@ -0,0 +1,48 @@ +# 부하·성능 테스트 (k6) + +2026-10 부하테스트에서 쓴 시나리오. 결과·개선 내역은 노션 「부하테스트 및 성능개선」. + +## 준비 (stg 만) + +| 무엇 | 어떻게 | +|---|---| +| 로그인 우회 | 태스크 정의 env `OAUTH_LOAD_TEST_ENABLED=true` — IdP 호출만 빼고 실제 로그인 트랜잭션을 탄다(`LoadTestOAuthGate`, prod 프로파일엔 빈이 없다). `code=loadtest-`, `codeVerifier=` | +| AI 초안 한도 | `APP_CHALLENGE_DRAFT_RATE_LIMIT_PER_MINUTE` (기본 10) | +| prod 와 같은 조건 | 태스크 0.5 vCPU/2GB·2대(오토스케일 2~4, CPU 60%·메모리 75%), `JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=60`, Hikari 15, RDS Multi-AZ, **DB 접속 `sslMode=REQUIRED`**(stg 기본 주소는 `useSSL=false` 라 그대로 재면 prod 보다 좋게 나온다) | +| Cloudflare 건너뛰기 | `-e ALB_IP=` — TLS SNI·Host 는 도메인 그대로. 서버만 잰다 | + +끝나면 반드시 되돌린다 — 우회 스위치 끄기, 스펙·Multi-AZ·오토스케일 원복. + +## 실행 + +```sh +export LT_SECRET=... # stg 시크릿 ruleup-stg/app 의 DEV_TOKEN_SECRET. 저장소에 두지 않는다 +k6 run -e LT_SECRET=$LT_SECRET -e ALB_IP=3.37.221.3 -e RUN_ID=lt2 -e USERS=3000 -e SCENARIO=seed -e VUS=25 infra/loadtest/k6/ruleup.js +k6 run ... -e SCENARIO=seedjoin ... # 가입·방 생성 뒤 남의 방 2곳 가입(사용자당 참여 방 3개 안팎) +k6 run ... -e SCENARIO=peak -e SCALE=1 -e DURATION=5m infra/loadtest/k6/ruleup.js +``` + +| SCENARIO | 내용 | +|---|---| +| `smoke.js` | 주요 API 를 한 번씩 — 계약 오류부터 거른다 | +| `seed` / `seedjoin` | 가짜 사용자 가입·공개 방 생성 / 남의 방 가입 | +| `login` | 아침 몰림 — 로그인 + 홈 + 리프레시, 초당 `RATE` | +| `browse` | 앱 열기·탐색(같은 정렬로 2페이지)·상세·내 방·마이페이지, 동시 `VUS` | +| `create` | 템플릿 초안 → 생성, 초당 `RATE` | +| `aidraft` | AI 초안(실제 LLM), 동시 `VUS` | +| `sync` | 건강정보 동의 → 인증 신호 업로드(backlog=true, 사용자당 10초 간격 — 429 는 정상 거절) | +| `peak` | 위를 실사용 비율로 섞음: 평상시(5분) → 피크(동시 400·로그인 15/s·sync 20/s·생성 3/s·가입 4/s, `DURATION`) → 마무리. `SCALE=2` 면 2배 | +| `breakpoint` | 동시 사용자를 200→2000 까지 2분씩 올리며 오류 5% 에서 멈춘다 | + +임계값은 노션 SLO(로그인 3초·리프레시 1초·탐색/상세 1초·가입 1.5초·sync 3초·오늘/진행률 1초, p95)와 5xx 0건. + +## 함정 + +- 시작일이 「내일」인 방을 대량으로 만들면 **자정에 한꺼번에 ACTIVE** 가 되어 그 뒤 측정의 데이터 모양이 바뀐다. 비교는 같은 데이터 상태에서 연달아 돈다. +- 같은 사용자 묶음으로 피크를 여러 번 돌리면 사용자당 참여 방이 계속 늘어난다(생성·가입). 비교마다 새 `RUN_ID` 로 시드하거나 순서를 붙여 돈다. +- Hikari `connection-timeout` 은 env 로 넣으면 「pool is sealed」로 기동 실패한다(풀이 먼저 시작됨). +- 비교 중에는 오토스케일 최대를 2로 묶어 2대에서 출발시키고, 시험 직전에 4로 푼다(앞 시험이 늘린 태스크가 남으면 출발점이 달라진다). +- 측정 중 로컬에서 gradle·docker 빌드를 돌리지 않는다(부하 발생기와 CPU 를 나눠 쓴다). +- 액세스 토큰은 30분이다. VU 가 오래 쥐는 세션은 `mine()` 이 25분마다 갱신한다 — 안 그러면 30분 넘는 시험 끝물이 전부 401 이다. +- 부하를 한 번 크게 받은 태스크는 힙을 다 커밋한 채 메모리 90% 에 머문다(누수 아님 — 장시간 시험에서 평평). 메모리 75% 오토스케일 정책은 그 뒤 CPU 가 한가해도 태스크를 늘린다. 태스크 수 비교는 새로 뜬 태스크에서 한다. +- 피크에서 X-Ray 전송이 계정 한도(429)에 걸리고 에이전트 스팬 큐가 넘친다 — `ruleup-api` 샘플링 규칙이 100% 면 그렇다. diff --git a/infra/loadtest/k6/lib.js b/infra/loadtest/k6/lib.js new file mode 100644 index 00000000..ddcfba08 --- /dev/null +++ b/infra/loadtest/k6/lib.js @@ -0,0 +1,164 @@ +// RuleUp 부하테스트 공통 — 계정·요청 헬퍼. 비밀값은 환경변수로만 받는다(저장소에 두지 않는다). +// BASE_URL 기본 https://staging-api.ruleup.co.kr +// ALB_IP 있으면 BASE_URL 호스트를 이 IP 로 직접 보낸다(Cloudflare 를 건너뛰고 서버만 잰다. TLS SNI 는 호스트 그대로) +// LT_SECRET stg DEV_TOKEN_SECRET — 로그인 우회(OAUTH_LOAD_TEST_ENABLED)·dev 토큰에 쓴다 +// RUN_ID 계정 식별자 접두어(같은 RUN_ID 면 같은 가짜 사용자를 다시 쓴다) +import http from 'k6/http'; +import { check, fail } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +export const BASE = __ENV.BASE_URL || 'https://staging-api.ruleup.co.kr'; +export const SECRET = __ENV.LT_SECRET || ''; +export const RUN_ID = __ENV.RUN_ID || 'lt1'; +const HOST = BASE.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + +export function hosts() { + return __ENV.ALB_IP ? { [HOST]: __ENV.ALB_IP } : {}; +} + +export const apiErrors = new Counter('ruleup_api_errors'); +export const businessRejects = new Counter('ruleup_business_rejects'); + +const JSON_HEADERS = { 'Content-Type': 'application/json', 'User-Agent': 'ruleup-loadtest/1.0 (k6)' }; + +export function auth(token, extra) { + return { headers: Object.assign({}, JSON_HEADERS, { Authorization: `Bearer ${token}` }, extra || {}) }; +} + +/** 응답 확인 — 2xx 가 아니면 오류로 세고, 코드·상태를 태그로 남긴다(로그 대조용). */ +export function ok(res, name, allowed) { + const okStatus = (allowed || [200, 201]).includes(res.status); + let code = ''; + if (!okStatus) { + try { code = (res.json('error.code') || res.json('code') || '') + ''; } catch (e) { code = 'non_json'; } + if (res.status >= 500 || res.status === 0) apiErrors.add(1, { api: name, status: `${res.status}`, code }); + else businessRejects.add(1, { api: name, status: `${res.status}`, code }); + } + check(res, { [`${name} ${(allowed || [200, 201]).join('/')}`]: () => okStatus }); + return okStatus; +} + +export function deviceInfo() { + return { platform: 'ANDROID', osVersion: '14', sdkInt: 34, deviceModel: 'SM-S921N', manufacturer: 'samsung', + lowRam: false, versionName: '1.0.0', versionCode: 100, country: 'KR', timeZone: 'Asia/Seoul', ramMb: 8192 }; +} + +function ids(n) { + return { code: `loadtest-${RUN_ID}-${n}`, installationId: `lt-inst-${RUN_ID}-${n}`, deviceId: `lt-dev-${RUN_ID}-${n}` }; +} + +/** 실제 로그인 경로(카카오) — 우회 스위치가 IdP 호출만 뺀다. */ +export function oauthLogin(n) { + const i = ids(n); + const res = http.post(`${BASE}/api/v1/auth/oauth/kakao`, JSON.stringify({ + code: i.code, codeVerifier: SECRET, redirectUri: null, installationId: i.installationId, deviceId: i.deviceId, + deviceInfo: deviceInfo(), permissions: { postNotifications: 'GRANTED', location: 'GRANTED', camera: 'GRANTED', screenTime: 'GRANTED' }, + }), { headers: JSON_HEADERS, tags: { api: 'auth.oauth_kakao' } }); + ok(res, 'auth.oauth_kakao'); + return res; +} + +export function signup(n, signupToken) { + const i = ids(n); + const agreed = { agreed: true }; + const res = http.post(`${BASE}/api/v1/auth/signup`, JSON.stringify({ + signupToken, installationId: i.installationId, deviceId: i.deviceId, deviceInfo: deviceInfo(), + nickname: `lt${RUN_ID.slice(-3)}${n}`.slice(0, 12), interestCategories: ['EXERCISE', 'STUDY'], birthDate: '1998-03-21', gender: 'MALE', + agreements: { termsOfService: agreed, privacyPolicy: agreed, locationService: agreed, marketing: agreed, event: agreed }, + }), { headers: JSON_HEADERS, tags: { api: 'auth.signup' } }); + ok(res, 'auth.signup'); + return res; +} + +/** 로그인(없으면 가입 후 다시 로그인)해서 {access, refresh} 를 돌려준다. */ +export function session(n) { + let res = oauthLogin(n); + if (res.status !== 200) fail(`login ${n} ${res.status} ${res.body && res.body.slice(0, 200)}`); + if (res.json('data.isNewUser')) { + const s = signup(n, res.json('data.signupToken')); + if (s.status !== 200) fail(`signup ${n} ${s.status} ${s.body && s.body.slice(0, 200)}`); + res = oauthLogin(n); + } + return { access: res.json('data.accessToken'), refresh: res.json('data.refreshToken'), userId: res.json('data.user.id'), at: Date.now() }; +} + +export function refresh(s) { + const res = http.post(`${BASE}/api/v1/auth/refresh`, JSON.stringify({ refreshToken: s.refresh }), + { headers: JSON_HEADERS, tags: { api: 'auth.refresh' } }); + if (ok(res, 'auth.refresh')) { + s.access = res.json('data.accessToken'); + s.refresh = res.json('data.refreshToken'); + s.at = Date.now(); + } + return res; +} + +/** 앱 첫 화면 — 플랫폼·버전은 헤더로 보낸다(쿼리로 보내면 400). 로그인 전에도 부른다. */ +export function intro() { + const res = http.get(`${BASE}/api/v1/intro`, { headers: Object.assign({}, JSON_HEADERS, { platform: 'ANDROID', appVersionCode: '100' }), + tags: { api: 'intro' } }); + ok(res, 'intro'); + return res; +} + +// 허용한 4xx(예: 409 이미 가입)는 k6 실패율에 넣지 않는다 — 진짜 실패는 ruleup_api_errors(5xx·연결 실패)로 본다. +function expect(allowed) { + return { responseCallback: http.expectedStatuses(...(allowed || [200, 201])) }; +} + +export function get(s, path, name, allowed) { + const res = http.get(`${BASE}${path}`, Object.assign(auth(s.access), { tags: { api: name } }, expect(allowed))); + ok(res, name, allowed); + return res; +} + +export function post(s, path, body, name, extraHeaders, allowed) { + const res = http.post(`${BASE}${path}`, body == null ? null : JSON.stringify(body), + Object.assign(auth(s.access, extraHeaders), { tags: { api: name } }, expect(allowed))); + ok(res, name, allowed); + return res; +} + +export function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +/** 템플릿 초안 → 생성. 초안을 고치지 않고 그대로 보내면 모더레이션 대상이 아니다(실사용의 대부분). */ +export function createFromTemplate(s, templateId, overrides) { + const d = post(s, '/api/v1/challenges/recommendation/by-template', { templateId }, 'challenge.draft_template'); + if (d.status !== 200) return null; + const draft = d.json('data.draft'); + const body = Object.assign({ + draftId: d.json('data.draftId'), title: draft.title, description: draft.description, category: draft.category, + mode: draft.mode, visibility: draft.visibility, rankingVisible: draft.rankingVisible, capacity: draft.capacity, + minTier: draft.minTier, period: { start: draft.period.start, end: draft.period.end }, weeklyCount: draft.weeklyCount, + params: (draft.params || []).map((p) => ({ key: p.key, value: p.value != null ? `${p.value}` : p.defaultValue != null ? `${p.defaultValue}` : null })), + verification: { type: draft.verification.type, method: draft.verification.method }, + penalties: { watcher: false }, imageUrl: null, + }, overrides || {}); + const c = post(s, '/api/v1/challenges', body, 'challenge.create', { 'Idempotency-Key': uuid() }); + return c.status === 200 || c.status === 201 ? c.json('data.challengeId') || c.json('data.id') : null; +} + +/** 탐색·가입 부하에 쓰는 공개 그룹 방 — 제목·설명은 초안 그대로라 모더레이션 대상이 아니다. */ +export const GROUP_PUBLIC = { mode: 'GROUP', visibility: 'PUBLIC', capacity: 100 }; // 정원은 5·30·100·300·무제한만 + +export const SLO_MS = { // 노션 SLO 지연 목표(p95) — alarm-catalog.yaml operations 와 같다 + 'auth.oauth_kakao': 3000, 'auth.refresh': 1000, 'challenge.explore': 1000, 'challenge.detail': 1000, + 'challenge.join': 1500, 'verification.sync': 3000, 'verification.today': 1000, 'verification.progress': 1000, +}; + +// 요약에 API 별 지연이 나오려면 태그별 임계값이 있어야 한다(k6 는 임계값이 걸린 태그만 따로 집계한다). +const ALL_APIS = ['intro', 'me.home', 'users.me', 'challenge.list', 'notification.list', 'notification.settings', 'challenge.categories', + 'challenge.trending', 'challenge.recommendations', 'challenge.draft_template', 'challenge.create', 'challenge.draft_ai', 'room.home', + 'room.ranking', 'challenge.members', 'verification.setup', 'verification.intro', 'profile', 'me.stats', 'me.tier', 'me.calendar', 'auth.signup']; + +export function thresholds(extra) { + const t = { http_req_failed: ['rate<0.005'], ruleup_api_errors: ['count<1'] }; + for (const [api, ms] of Object.entries(SLO_MS)) t[`http_req_duration{api:${api}}`] = [`p(95)<${ms}`]; + for (const api of ALL_APIS) if (!t[`http_req_duration{api:${api}}`]) t[`http_req_duration{api:${api}}`] = ['p(95)<60000']; + return Object.assign(t, extra || {}); +} diff --git a/infra/loadtest/k6/ruleup.js b/infra/loadtest/k6/ruleup.js new file mode 100644 index 00000000..7e8d9713 --- /dev/null +++ b/infra/loadtest/k6/ruleup.js @@ -0,0 +1,226 @@ +// RuleUp 부하·성능 시나리오. SCENARIO 로 고른다. +// seed 가짜 사용자 USERS 명 가입 + 각자 공개 그룹 방 1개 생성 + 남의 방 2곳 가입(이후 시나리오의 데이터) +// login 아침 몰림 — 기존 사용자 로그인 + 리프레시를 초당 RATE 건 +// browse 앱 열기·탐색·상세·마이페이지 읽기 묶음, 동시 사용자 VUS 까지 램프 +// create 템플릿 초안 → 생성을 초당 RATE 건(실사용의 대부분) +// aidraft AI 초안(실제 LLM) → 생성, 동시 VUS 명 +// joinhot 인기 방 하나에 동시 가입(참여자 수·정원 경합) +// sync 자동 인증 신호 업로드(아침 8시 몰림) +// peak 실사용 비율로 섞은 피크(평상시 → 피크 → 2배 스트레스) +// breakpoint 오류·지연이 무너질 때까지 단계적으로 올린다 +// 공통: k6 run -e LT_SECRET=... -e RUN_ID=lt1 -e ALB_IP=... -e SCENARIO=browse infra/loadtest/k6/ruleup.js +import { sleep, check } from 'k6'; +import http from 'k6/http'; +import exec from 'k6/execution'; +import * as L from './lib.js'; + +const SC = __ENV.SCENARIO || 'browse'; +const USERS = Number(__ENV.USERS || 3000); +const RATE = Number(__ENV.RATE || 10); +const VUS = Number(__ENV.VUS || 100); +const DUR = __ENV.DURATION || '5m'; +const TEMPLATES = (__ENV.TEMPLATES || '1001,1002,1209,1501').split(',').map(Number); + +function think(min, max) { sleep(min + Math.random() * (max - min)); } +function pick(a) { return a[Math.floor(Math.random() * a.length)]; } +function userNo() { return Math.floor(Math.random() * USERS); } + +// VU 마다 로그인 상태를 들고 간다(실제 앱처럼 매 요청마다 로그인하지 않는다). +let me = null; +function mine() { + if (!me) me = L.session(userNo()); + // 액세스 토큰은 30분짜리다 — 앱처럼 만료 전에 갱신한다(안 하면 30분 넘는 시험 끝물이 전부 401). + else if (Date.now() - me.at > 25 * 60 * 1000) L.refresh(me); + return me; +} + +const scenarios = { + seed: { executor: 'shared-iterations', vus: VUS, iterations: USERS, maxDuration: '60m', exec: 'seed' }, + seedjoin: { executor: 'shared-iterations', vus: VUS, iterations: USERS, maxDuration: '60m', exec: 'seedjoin' }, + login: { executor: 'ramping-arrival-rate', startRate: 1, timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 400, exec: 'login', + stages: [{ target: RATE, duration: '1m' }, { target: RATE, duration: DUR }, { target: 0, duration: '30s' }] }, + browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, + stages: [{ target: VUS, duration: '2m' }, { target: VUS, duration: DUR }, { target: 0, duration: '30s' }] }, + create: { executor: 'constant-arrival-rate', rate: RATE, timeUnit: '1s', duration: DUR, preAllocatedVUs: 30, maxVUs: 300, exec: 'create' }, + aidraft: { executor: 'constant-vus', vus: VUS, duration: DUR, exec: 'aidraft' }, + joinhot: { executor: 'per-vu-iterations', vus: VUS, iterations: 1, exec: 'joinhot', maxDuration: '5m' }, + sync: { executor: 'ramping-arrival-rate', startRate: 1, timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 400, exec: 'sync', + stages: [{ target: RATE, duration: '1m' }, { target: RATE, duration: DUR }] }, +}; + +function peakMix(scale) { + // DAU 1만, 피크 동시 500 기준. scale 로 배수(2 = 스트레스). + const s = (n) => Math.max(1, Math.round(n * scale)); + return { + browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, + stages: [{ target: s(150), duration: '2m' }, { target: s(150), duration: '3m' }, // 평상시 + { target: s(400), duration: '2m' }, { target: s(400), duration: DUR }, // 피크 + { target: 0, duration: '1m' }] }, + login: { executor: 'ramping-arrival-rate', exec: 'login', timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 500, startRate: 1, + stages: [{ target: s(3), duration: '5m' }, { target: s(15), duration: '2m' }, { target: s(15), duration: DUR }, { target: 0, duration: '1m' }] }, + sync: { executor: 'ramping-arrival-rate', exec: 'sync', timeUnit: '1s', preAllocatedVUs: 50, maxVUs: 500, startRate: 1, + stages: [{ target: s(4), duration: '5m' }, { target: s(20), duration: '2m' }, { target: s(20), duration: DUR }, { target: 0, duration: '1m' }] }, + create: { executor: 'ramping-arrival-rate', exec: 'create', timeUnit: '1s', preAllocatedVUs: 20, maxVUs: 200, startRate: 0, + stages: [{ target: s(1), duration: '5m' }, { target: s(3), duration: '2m' }, { target: s(3), duration: DUR }, { target: 0, duration: '1m' }] }, + join: { executor: 'ramping-arrival-rate', exec: 'join', timeUnit: '1s', preAllocatedVUs: 20, maxVUs: 200, startRate: 0, + stages: [{ target: s(1), duration: '5m' }, { target: s(4), duration: '2m' }, { target: s(4), duration: DUR }, { target: 0, duration: '1m' }] }, + }; +} + +function breakpoint() { + const stages = []; + for (const v of [200, 400, 600, 800, 1000, 1300, 1600, 2000]) stages.push({ target: v, duration: '2m' }, { target: v, duration: '2m' }); + return { browse: { executor: 'ramping-vus', exec: 'browse', startVUs: 0, stages }, + login: { executor: 'constant-arrival-rate', exec: 'login', rate: 5, timeUnit: '1s', duration: '32m', preAllocatedVUs: 50, maxVUs: 300 } }; +} + +const chosen = SC === 'peak' ? peakMix(Number(__ENV.SCALE || 1)) : SC === 'breakpoint' ? breakpoint() : { [SC]: scenarios[SC] }; + +export const options = { + hosts: L.hosts(), scenarios: chosen, discardResponseBodies: false, + thresholds: SC === 'breakpoint' ? { http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '1m' }] } : L.thresholds(), + summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'], +}; + +// ---------------- 시나리오 본문 ---------------- + +export function seed() { + const n = exec.scenario.iterationInTest; + const s = L.session(n); + const tpl = TEMPLATES[n % TEMPLATES.length]; + L.createFromTemplate(s, tpl, L.GROUP_PUBLIC); + const ex = L.get(s, `/api/v1/challenges/explore?sort=${pick(['RECENT', 'POPULAR', 'PARTICIPANTS'])}&size=20`, 'challenge.explore'); + const items = (ex.status === 200 && ex.json('data.items')) || []; + for (const it of items.filter((x) => !x.joined && !x.isFull && x.eligible).slice(0, 2)) + L.post(s, `/api/v1/challenges/${it.challengeId}/members`, null, 'challenge.join', null, [200, 201, 409]); +} + +/** 시드 2단계 — 각자 남의 방 2곳에 가입(시드 1단계가 방을 모두 만든 뒤라야 고르게 퍼진다). */ +export function seedjoin() { + const s = L.session(exec.scenario.iterationInTest); + const ex = L.get(s, `/api/v1/challenges/explore?sort=${pick(['RECENT', 'POPULAR', 'PARTICIPANTS', 'DEADLINE'])}&size=20&eligibleOnly=true`, 'challenge.explore'); + const items = ((ex.status === 200 && ex.json('data.items')) || []).filter((x) => !x.joined && !x.isFull); + for (let k = 0; k < 2 && items.length; k++) { + const c = items.splice(Math.floor(Math.random() * items.length), 1)[0]; + L.post(s, `/api/v1/challenges/${c.challengeId}/members`, null, 'challenge.join', null, [200, 201, 409]); + } +} + +export function login() { + const n = userNo(); + const r = L.oauthLogin(n); + if (r.status === 200 && !r.json('data.isNewUser')) { + const s = { access: r.json('data.accessToken'), refresh: r.json('data.refreshToken') }; + L.get(s, '/api/v1/me/home', 'me.home'); + L.refresh(s); + } +} + +export function browse() { + const s = mine(); + // 앱 열기 + L.intro(); + L.get(s, '/api/v1/me/home', 'me.home'); + L.get(s, '/api/v1/challenges?filter=IN_PROGRESS', 'challenge.list'); + think(1, 3); + // 탐색 — 정렬·카테고리를 바꿔 가며 2~3 페이지 + L.get(s, '/api/v1/challenge-categories', 'challenge.categories'); + L.get(s, '/api/v1/challenges/trending', 'challenge.trending'); + const sort = pick(['POPULAR', 'RECENT', 'DEADLINE', 'PARTICIPANTS']); + let ex = L.get(s, `/api/v1/challenges/explore?sort=${sort}&size=20`, 'challenge.explore'); + think(1, 3); + let items = (ex.status === 200 && ex.json('data.items')) || []; + const cursor = ex.status === 200 ? ex.json('data.nextCursor') : null; + // 다음 페이지는 같은 정렬로 — 정렬이 다르면 커서가 무효(400 CURSOR_INVALID)다. + if (cursor) L.get(s, `/api/v1/challenges/explore?sort=${sort}&size=20&cursor=${encodeURIComponent(cursor)}`, 'challenge.explore'); + if (items.length) { + const c = pick(items); + L.get(s, `/api/v1/challenges/${c.challengeId}`, 'challenge.detail'); + think(2, 5); + } + // 내 방 + const mineList = L.get(s, '/api/v1/challenges?filter=IN_PROGRESS', 'challenge.list'); + const rooms = (mineList.status === 200 && (mineList.json('data.items') || mineList.json('data'))) || []; + if (Array.isArray(rooms) && rooms.length) { + const r = pick(rooms); + const id = r.challengeId || r.id; + if (id) { + L.get(s, `/api/v1/challenges/${id}/room`, 'room.home'); + L.get(s, `/api/v1/challenges/${id}/ranking`, 'room.ranking', [200, 403, 404]); + L.get(s, `/api/v1/challenges/${id}/verifications/today`, 'verification.today', [200, 404, 409]); + } + } + L.get(s, '/api/v1/verifications/progress', 'verification.progress'); + L.get(s, '/api/v1/notifications', 'notification.list'); + think(2, 6); + if (Math.random() < 0.3) { // 마이페이지는 열 명 중 세 명 + L.get(s, '/api/v1/profile', 'profile'); + L.get(s, '/api/v1/me/stats', 'me.stats'); + L.get(s, `/api/v1/me/calendar?month=${new Date().toISOString().slice(0, 7)}`, 'me.calendar'); + } + think(3, 8); +} + +export function create() { + const s = mine(); + L.createFromTemplate(s, pick(TEMPLATES), Math.random() < 0.6 ? L.GROUP_PUBLIC : undefined); +} + +const AI_PROMPTS = ['매일 아침 30분 달리기', '하루 물 2리터 마시기', '주 3회 헬스장 가기', '매일 영어 단어 30개 외우기', + '밤 12시 전에 자기', '하루 만보 걷기', '매일 독서 20쪽', '주 5회 스트레칭 10분']; + +export function aidraft() { + const s = mine(); + const d = L.post(s, '/api/v1/challenges/draft', { description: pick(AI_PROMPTS) }, 'challenge.draft_ai', null, [200, 429]); + check(d, { 'ai draft not fallback': (r) => r.status === 200 && r.json('data.result') !== 'FALLBACK' }); + think(1, 3); +} + +let hotId = null; +export function joinhot() { + // 모두 같은 방 하나에 동시에 들어간다 — 첫 VU 가 방을 만들고 나머지는 explore 최신에서 같은 방을 찾는다. + const s = L.session(USERS + __VU); // 이 시나리오 전용 사용자(이미 가입한 방이 없어야 한다) + if (!hotId) hotId = __ENV.HOT_ID; + const r = L.post(s, `/api/v1/challenges/${hotId}/members`, null, 'challenge.join', null, [200, 201, 409]); + check(r, { 'join ok or full': (x) => [200, 201, 409].includes(x.status) }); +} + +export function join() { + const s = mine(); + const ex = L.get(s, `/api/v1/challenges/explore?sort=${pick(['POPULAR', 'RECENT'])}&size=20&eligibleOnly=true`, 'challenge.explore'); + const items = ((ex.status === 200 && ex.json('data.items')) || []).filter((x) => !x.joined && !x.isFull); + if (!items.length) return; + const c = pick(items); + L.get(s, `/api/v1/challenges/${c.challengeId}`, 'challenge.detail'); + L.post(s, `/api/v1/challenges/${c.challengeId}/members`, null, 'challenge.join', null, [200, 201, 409]); +} + +let healthVersion = null; +export function sync() { + const s = mine(); + if (!s.consented) { // 실사용자는 자동 인증 전에 건강정보 동의를 한다 — 없으면 신호가 저장·판정되지 않는다 + if (!healthVersion) healthVersion = L.intro().json('data.termsVersions.healthInfo'); + L.post(s, '/api/v1/users/me/agreements', { agreements: [{ type: 'HEALTH_INFO', agreed: true, version: healthVersion }] }, + 'agreement.submit', null, [200, 201, 409]); + s.consented = true; + } + if (!s.syncSession) { + const i = L.post(s, '/api/v1/verifications/intro', { deviceId: `lt-dev-${L.RUN_ID}`, appVersion: '1.0.0', + deviceProfile: { sdkInt: 34, model: 'SM-S921N', lowRam: false } }, 'verification.intro'); + s.syncSession = i.status === 200 ? (i.json('data.sessionId') || 'x') : 'x'; + } + const now = Date.now(); + const KST = 9 * 3600 * 1000; // k6 에는 Intl 시간대가 없다 — KST 자정을 직접 계산한다 + const startOfDay = Math.floor((now + KST) / 86400000) * 86400000 - KST; + const from = new Date(now - 10 * 60 * 1000).toISOString(); + const rid = `lt-${L.RUN_ID}-${exec.vu.idInTest}-${now}`; + const body = { + deviceTimeMillis: now, elapsedRealtimeMillis: 1000000 + exec.vu.iterationInScenario, bootSessionId: 'boot-lt', timeZone: 'Asia/Seoul', + coveredFrom: startOfDay, coveredUntil: now, backlog: true, sessionId: s.syncSession, + signals: [{ type: 'HEALTH', recordId: rid, observedAt: new Date(now).toISOString(), readings: [{ recordId: rid, metric: 'STEPS', value: 120, + unit: 'count', startTime: from, endTime: new Date(now).toISOString(), + origin: { dataOrigin: 'com.sec.android.app.shealth', recordingMethod: 'AUTO', device: 'WATCH' } }] }], + }; + // backlog=true 라도 사용자당 10초 간격 — 같은 VU 가 연달아 보내면 429 는 정상 거절이다. + L.post(s, '/api/v1/verifications/sync', body, 'verification.sync', null, [200, 429]); +} diff --git a/infra/loadtest/k6/smoke.js b/infra/loadtest/k6/smoke.js new file mode 100644 index 00000000..f6eaef2e --- /dev/null +++ b/infra/loadtest/k6/smoke.js @@ -0,0 +1,51 @@ +// 스모크 — 사용자 몇 명이 주요 API 를 한 번씩 부른다. 부하 전에 계약 오류(4xx/5xx)를 먼저 걸러 낸다. +// k6 run -e LT_SECRET=... -e RUN_ID=smoke1 infra/loadtest/k6/smoke.js +import { sleep } from 'k6'; +import * as L from './lib.js'; + +export const options = { hosts: L.hosts(), vus: Number(__ENV.VUS || 3), iterations: Number(__ENV.ITER || 3), + thresholds: { ruleup_api_errors: ['count<1'] } }; + +const TEMPLATE = Number(__ENV.TEMPLATE_ID || 1001); + +export default function () { + const s = L.session(__VU * 1000 + __ITER); + // 앱 첫 화면 + L.intro(); + L.get(s, '/api/v1/me/home', 'me.home'); + L.get(s, '/api/v1/users/me', 'users.me'); + L.get(s, '/api/v1/challenges?filter=IN_PROGRESS', 'challenge.list'); + L.get(s, '/api/v1/notifications', 'notification.list'); + L.get(s, '/api/v1/users/me/notification-settings', 'notification.settings'); + L.get(s, '/api/v1/verifications/progress', 'verification.progress'); + // 탐색 + L.get(s, '/api/v1/challenge-categories', 'challenge.categories'); + L.get(s, '/api/v1/challenges/trending', 'challenge.trending'); + const ex = L.get(s, '/api/v1/challenges/explore?sort=POPULAR&size=20', 'challenge.explore'); + L.get(s, '/api/v1/challenges/recommendations', 'challenge.recommendations'); + // 생성 + const id = L.createFromTemplate(s, TEMPLATE, L.GROUP_PUBLIC); + if (id) { + L.get(s, `/api/v1/challenges/${id}`, 'challenge.detail'); + L.get(s, `/api/v1/challenges/${id}/room`, 'room.home'); + L.get(s, `/api/v1/challenges/${id}/members`, 'challenge.members'); + L.get(s, `/api/v1/challenges/${id}/setup`, 'verification.setup'); + L.get(s, `/api/v1/challenges/${id}/verifications/today`, 'verification.today'); + } + // 다른 사람 방 상세·가입 + let items = []; + try { items = ex.json('data.items') || ex.json('data.content') || []; } catch (e) { items = []; } + if (items.length) { + const other = items[Math.floor(Math.random() * items.length)]; + const oid = other.challengeId || other.id; + L.get(s, `/api/v1/challenges/${oid}`, 'challenge.detail'); + } + // 마이페이지 + L.get(s, '/api/v1/profile', 'profile'); + L.get(s, '/api/v1/me/stats', 'me.stats'); + L.get(s, '/api/v1/me/tier', 'me.tier'); + L.get(s, `/api/v1/me/calendar?month=${new Date().toISOString().slice(0, 7)}`, 'me.calendar'); + L.refresh(s); + L.get(s, '/api/v1/users/me', 'users.me'); + sleep(1); +}