From 60d81a8306acd6c64a2b6daadefaf584811b6421 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 24 Dec 2025 14:47:52 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=84=9C=20=EA=B8=B0=EC=A1=B4=20intern=20?= =?UTF-8?q?=EB=9D=BD=20=EB=8C=80=EC=8B=A0=20Guava=20Striped=20=EB=9D=BD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OOM 에러 위험 감소 --- build.gradle | 3 +++ .../application/NotificationBatchService.java | 17 ++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index b6fe6761..83107669 100644 --- a/build.gradle +++ b/build.gradle @@ -81,6 +81,9 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Guava (동시성 제어용) + implementation 'com.google.guava:guava:33.4.0-jre' } tasks.named('test') { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java index 355eda93..103d120a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java @@ -1,5 +1,6 @@ package org.devkor.apu.saerok_server.domain.notification.application; +import com.google.common.util.concurrent.Striped; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.notification.application.model.batch.*; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; @@ -9,6 +10,7 @@ import java.util.List; import java.util.Optional; +import java.util.concurrent.locks.Lock; /** * 알림 배치 관리 서비스. @@ -20,6 +22,7 @@ public class NotificationBatchService { private final NotificationBatchStore batchStore; private final NotificationBatchConfig batchConfig; + private final Striped stripedLocks = Striped.lock(256); /** * 배치에 알림 추가. @@ -40,7 +43,9 @@ public BatchResult addToBatch(ActionNotificationPayload payload) { BatchActor actor = BatchActor.of(payload.actorId(), payload.actorName()); - synchronized (this.getLockKey(key)) { + Lock lock = stripedLocks.get(key.toRedisKey()); + lock.lock(); + try { Optional existingBatch = batchStore.findBatch(key); if (existingBatch.isPresent()) { @@ -65,6 +70,8 @@ public BatchResult addToBatch(ActionNotificationPayload payload) { return BatchResult.created(newBatch); } + } finally { + lock.unlock(); } } @@ -75,12 +82,4 @@ public List findExpiredBatches() { public void deleteBatch(BatchKey key) { batchStore.deleteBatch(key); } - - /** - * 동시성 제어를 위한 락 키 생성. - * 같은 배치 키에 대한 동시 접근을 막기 위해 String 인터닝 활용. - */ - private String getLockKey(BatchKey key) { - return key.toRedisKey().intern(); - } } From 15b530277a79c62319990d9db68af150ba5e9f6f Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 24 Dec 2025 15:25:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20Redis=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20SCAN=20=EB=B0=A9=EC=8B=9D=EC=97=90=EC=84=9C=20ZSET?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명시적으로 만료된 키들만 조회하여 성능 개선 --- .../redis/RedisNotificationBatchStore.java | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java index 70fec9bb..000b9087 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java @@ -8,15 +8,15 @@ import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; import org.devkor.apu.saerok_server.domain.notification.application.store.NotificationBatchStore; import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.time.Duration; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; /** * Redis 기반 알림 배치 저장소 구현체. @@ -26,8 +26,12 @@ @RequiredArgsConstructor public class RedisNotificationBatchStore implements NotificationBatchStore { - private static final String KEY_PREFIX = "notification:batch:"; - private static final String KEY_PATTERN = KEY_PREFIX + "*"; + /** + * 만료 시간 인덱스용 Sorted Set. + * score: 만료 시간 타임스탬프 (밀리초) + * member: 배치 데이터 Redis 키 + */ + private static final String EXPIRY_INDEX = "notification:batch:expiry_index"; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -62,6 +66,13 @@ public void saveBatch(NotificationBatch batch) { redisTemplate.opsForValue().set(redisKey, json, Duration.ofSeconds(batchConfig.getTtlSeconds())); + long expiryTimestamp = batch.getExpiresAt() + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + + redisTemplate.opsForZSet().add(EXPIRY_INDEX, redisKey, expiryTimestamp); + } catch (JsonProcessingException e) { log.error("Redis에서 배치 데이터 직렬화에 실패했습니다: {}", batch.getKey(), e); throw new IllegalStateException("Redis에 배치 저장하는 것에 실패했습니다", e); @@ -72,24 +83,30 @@ public void saveBatch(NotificationBatch batch) { public void deleteBatch(BatchKey key) { String redisKey = key.toRedisKey(); redisTemplate.delete(redisKey); + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); } @Override public List findExpiredBatches() { List expiredBatches = new ArrayList<>(); - ScanOptions scanOptions = ScanOptions.scanOptions() - .match(KEY_PATTERN) - .count(100) - .build(); + try { + long now = System.currentTimeMillis(); + + // Sorted Set에서 score가 현재 시간 이하인 키들만 조회 + Set expiredKeys = redisTemplate.opsForZSet() + .rangeByScore(EXPIRY_INDEX, 0, now); + + if (expiredKeys == null || expiredKeys.isEmpty()) { + return expiredBatches; + } - try (Cursor cursor = redisTemplate.scan(scanOptions)) { - while (cursor.hasNext()) { - String redisKey = cursor.next(); + // 만료된 키들의 데이터 조회 + for (String redisKey : expiredKeys) { String json = redisTemplate.opsForValue().get(redisKey); if (json == null) { - // 키가 스캔 후 만료되었을 수 있음 + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); continue; } @@ -99,14 +116,16 @@ public List findExpiredBatches() { if (batch.isExpired()) { expiredBatches.add(batch); + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); } } catch (JsonProcessingException e) { log.error("Redis 키 역직렬화에 실패했습니다: {}", redisKey, e); + redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); } } } catch (Exception e) { - log.error("만료된 배치 스캔에 실패했습니다.", e); + log.error("만료된 배치 조회에 실패했습니다.", e); } return expiredBatches; From eea8f80420f5747edbf2c0743154ddf8c85db426 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 24 Dec 2025 15:37:24 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EC=A1=B4=20A?= =?UTF-8?q?sia/Seoul=EB=A1=9C=20=EB=B3=80=EA=B2=BD=EA=B3=BC=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B5=9C=EB=8C=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=B0=EC=B9=98=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/redis/RedisNotificationBatchStore.java | 8 ++++++-- .../core/config/feature/NotificationBatchConfig.java | 4 ++++ src/main/resources/application.yml | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java index 000b9087..2f2c6450 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java @@ -32,6 +32,7 @@ public class RedisNotificationBatchStore implements NotificationBatchStore { * member: 배치 데이터 Redis 키 */ private static final String EXPIRY_INDEX = "notification:batch:expiry_index"; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -67,7 +68,7 @@ public void saveBatch(NotificationBatch batch) { redisTemplate.opsForValue().set(redisKey, json, Duration.ofSeconds(batchConfig.getTtlSeconds())); long expiryTimestamp = batch.getExpiresAt() - .atZone(ZoneId.systemDefault()) + .atZone(KST) .toInstant() .toEpochMilli(); @@ -86,6 +87,9 @@ public void deleteBatch(BatchKey key) { redisTemplate.opsForZSet().remove(EXPIRY_INDEX, redisKey); } + /** + * Sorted Set을 사용한 만료 배치 조회. + */ @Override public List findExpiredBatches() { List expiredBatches = new ArrayList<>(); @@ -95,7 +99,7 @@ public List findExpiredBatches() { // Sorted Set에서 score가 현재 시간 이하인 키들만 조회 Set expiredKeys = redisTemplate.opsForZSet() - .rangeByScore(EXPIRY_INDEX, 0, now); + .rangeByScore(EXPIRY_INDEX, 0, now, 0, batchConfig.getMaxBatchesPerTick()); if (expiredKeys == null || expiredKeys.isEmpty()) { return expiredBatches; diff --git a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java index ea27a96a..4fdc012c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java @@ -19,6 +19,7 @@ public class NotificationBatchConfig { private int initialWindowSeconds = 30; private int maxWindowSeconds = 60; private int ttlSeconds = 90; + private int maxBatchesPerTick = 300; // 한 번의 스케줄러 틱에서 처리할 최대 배치 수 @PostConstruct void validateConfig() { @@ -40,5 +41,8 @@ void validateConfig() { ttlSeconds, maxWindowSeconds) ); } + if (maxBatchesPerTick <= 0) { + throw new IllegalStateException("notification-batch.max-batches-per-tick은 양수여야합니다"); + } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 278d68f6..1af059ae 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,4 +44,5 @@ notification-batch: enabled: true initial-window-seconds: 30 max-window-seconds: 60 - ttl-seconds: 90 \ No newline at end of file + ttl-seconds: 90 + max-batches-per-tick: 300 \ No newline at end of file