From 6fef801e0a50eaa4d63ed30627f53d5473a60abe Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:01:58 +0900 Subject: [PATCH 01/33] =?UTF-8?q?UPLUS-16=20fix=20:=20git=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/controller/ExampleController.java | 33 ------------------- .../controller/dto/SaveExampleRequest.java | 3 -- .../example/infra/entity/ExampleEntity.java | 33 ------------------- .../repository/ExampleJpaRepository.java | 7 ---- .../infra/repository/ExampleRepository.java | 10 ------ .../repository/ExampleRepositoryImpl.java | 26 --------------- .../example/service/ExampleService.java | 28 ---------------- .../global/config/KafkaTestRunner.java | 18 ---------- .../com/project/global/config/Producer.java | 21 ------------ .../project/global/config/UsageConsumer.java | 19 ----------- 10 files changed, 198 deletions(-) delete mode 100644 src/main/java/com/project/example/controller/ExampleController.java delete mode 100644 src/main/java/com/project/example/controller/dto/SaveExampleRequest.java delete mode 100644 src/main/java/com/project/example/infra/entity/ExampleEntity.java delete mode 100644 src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java delete mode 100644 src/main/java/com/project/example/infra/repository/ExampleRepository.java delete mode 100644 src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java delete mode 100644 src/main/java/com/project/example/service/ExampleService.java delete mode 100644 src/main/java/com/project/global/config/KafkaTestRunner.java delete mode 100644 src/main/java/com/project/global/config/Producer.java delete mode 100644 src/main/java/com/project/global/config/UsageConsumer.java diff --git a/src/main/java/com/project/example/controller/ExampleController.java b/src/main/java/com/project/example/controller/ExampleController.java deleted file mode 100644 index 2966fc0..0000000 --- a/src/main/java/com/project/example/controller/ExampleController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.project.example.controller; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.project.example.controller.dto.SaveExampleRequest; -import com.project.example.infra.entity.ExampleEntity; -import com.project.example.service.ExampleService; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/example") -@RequiredArgsConstructor -public class ExampleController { - - private final ExampleService exampleService; - - @GetMapping("/{exampleId}") - public ResponseEntity find(@PathVariable Long exampleId) { - return ResponseEntity.ok(exampleService.find(exampleId)); - } - - @PostMapping - public void save(@RequestBody SaveExampleRequest request) { - exampleService.save(request); - } -} diff --git a/src/main/java/com/project/example/controller/dto/SaveExampleRequest.java b/src/main/java/com/project/example/controller/dto/SaveExampleRequest.java deleted file mode 100644 index 9dc4e29..0000000 --- a/src/main/java/com/project/example/controller/dto/SaveExampleRequest.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.project.example.controller.dto; - -public record SaveExampleRequest(String exampleName, String exampleContent) {} diff --git a/src/main/java/com/project/example/infra/entity/ExampleEntity.java b/src/main/java/com/project/example/infra/entity/ExampleEntity.java deleted file mode 100644 index 7f6bbb2..0000000 --- a/src/main/java/com/project/example/infra/entity/ExampleEntity.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.project.example.infra.entity; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -import com.project.example.controller.dto.SaveExampleRequest; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "EXAMPLE") -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ExampleEntity { - - @Id @GeneratedValue private Long exampleId; - - private String exampleName; - - private String exampleContent; - - public static ExampleEntity create(SaveExampleRequest request) { - return ExampleEntity.builder() - .exampleName(request.exampleName()) - .exampleContent(request.exampleContent()) - .build(); - } -} diff --git a/src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java b/src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java deleted file mode 100644 index 36cfb05..0000000 --- a/src/main/java/com/project/example/infra/repository/ExampleJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.project.example.infra.repository; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.project.example.infra.entity.ExampleEntity; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/project/example/infra/repository/ExampleRepository.java b/src/main/java/com/project/example/infra/repository/ExampleRepository.java deleted file mode 100644 index 9bc2fcb..0000000 --- a/src/main/java/com/project/example/infra/repository/ExampleRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.project.example.infra.repository; - -import com.project.example.infra.entity.ExampleEntity; - -public interface ExampleRepository { - - ExampleEntity find(Long exampleId); - - void save(ExampleEntity exampleEntity); -} diff --git a/src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java b/src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java deleted file mode 100644 index cad16a1..0000000 --- a/src/main/java/com/project/example/infra/repository/ExampleRepositoryImpl.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.project.example.infra.repository; - -import org.springframework.stereotype.Repository; - -import com.project.example.infra.entity.ExampleEntity; -import com.project.global.exception.ApplicationException; -import com.project.global.exception.code.domain.ExampleErrorCode; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class ExampleRepositoryImpl implements ExampleRepository { - - private final ExampleJpaRepository exampleJpaRepository; - - public ExampleEntity find(Long exampleId) { - return exampleJpaRepository - .findById(exampleId) - .orElseThrow(() -> new ApplicationException(ExampleErrorCode.EXAMPLE_NOT_FOUND)); - } - - public void save(ExampleEntity example) { - exampleJpaRepository.save(example); - } -} diff --git a/src/main/java/com/project/example/service/ExampleService.java b/src/main/java/com/project/example/service/ExampleService.java deleted file mode 100644 index e16a063..0000000 --- a/src/main/java/com/project/example/service/ExampleService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.project.example.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.project.example.controller.dto.SaveExampleRequest; -import com.project.example.infra.entity.ExampleEntity; -import com.project.example.infra.repository.ExampleRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional - public ExampleEntity find(Long exampleId) { - return exampleRepository.find(exampleId); - } - - @Transactional - public void save(SaveExampleRequest request) { - ExampleEntity exampleEntity = ExampleEntity.create(request); - exampleRepository.save(exampleEntity); - } -} diff --git a/src/main/java/com/project/global/config/KafkaTestRunner.java b/src/main/java/com/project/global/config/KafkaTestRunner.java deleted file mode 100644 index 4e84472..0000000 --- a/src/main/java/com/project/global/config/KafkaTestRunner.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.project.global.config; - -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class KafkaTestRunner implements CommandLineRunner { - - private final Producer producer; - - @Override - public void run(String... args) throws Exception { - producer.sendUsageMessage("key", "사용량 알림입니다"); - } -} diff --git a/src/main/java/com/project/global/config/Producer.java b/src/main/java/com/project/global/config/Producer.java deleted file mode 100644 index 0081bef..0000000 --- a/src/main/java/com/project/global/config/Producer.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.project.global.config; - -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class Producer { - - private final KafkaTemplate kafkaTemplate; - - public void sendUsageMessage(String key, String value) { - kafkaTemplate.send("usage_topic", key, value); - } - - public void sendNotificationMessage(String key, String value) { - kafkaTemplate.send("notification_topic", key, value); - } -} diff --git a/src/main/java/com/project/global/config/UsageConsumer.java b/src/main/java/com/project/global/config/UsageConsumer.java deleted file mode 100644 index 76772c2..0000000 --- a/src/main/java/com/project/global/config/UsageConsumer.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.project.global.config; - -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class UsageConsumer { - - private final Producer producer; - - @KafkaListener(id = "usageConsumer", topics = "usage_topic", groupId = "usage-consumer") - public void consume(String message) { - System.out.println("usage consumer received data : " + message); - producer.sendNotificationMessage("key", "데이터 사용량 누적 임계치 경고 알림입니다"); - } -} From d5867f5d41a3f31d6c1a5a8382b832dca99aa295 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:03:01 +0900 Subject: [PATCH 02/33] =?UTF-8?q?UPLUS-16=20feat=20:=20Kafka=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20=ED=95=84=EC=9A=94=ED=95=9C=20Schema=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../producer/schema/CalculatedLimitSchema.java | 10 ++++++++++ .../schema/ExceededNotificationSchema.java | 10 ++++++++++ .../project/producer/schema/PlanChangeSchema.java | 14 ++++++++++++++ .../producer/schema/UsageDlqEventSchema.java | 9 +++++++++ .../project/producer/schema/UsageEventSchema.java | 11 +++++++++++ 5 files changed, 54 insertions(+) create mode 100644 src/main/java/com/project/producer/schema/CalculatedLimitSchema.java create mode 100644 src/main/java/com/project/producer/schema/ExceededNotificationSchema.java create mode 100644 src/main/java/com/project/producer/schema/PlanChangeSchema.java create mode 100644 src/main/java/com/project/producer/schema/UsageDlqEventSchema.java create mode 100644 src/main/java/com/project/producer/schema/UsageEventSchema.java diff --git a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java new file mode 100644 index 0000000..2988a07 --- /dev/null +++ b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java @@ -0,0 +1,10 @@ +package com.project.producer.schema; + +public record CalculatedLimitSchema( + long subscriptionId, + String yyyyMM, + long limit, + long ttlSec, + String unit +) { +} diff --git a/src/main/java/com/project/producer/schema/ExceededNotificationSchema.java b/src/main/java/com/project/producer/schema/ExceededNotificationSchema.java new file mode 100644 index 0000000..9a1b2b6 --- /dev/null +++ b/src/main/java/com/project/producer/schema/ExceededNotificationSchema.java @@ -0,0 +1,10 @@ +package com.project.producer.schema; + +public record ExceededNotificationSchema( + long subscriptionId, + long newTotal, + long limit, + String eventId, + String timeStamp +) { +} diff --git a/src/main/java/com/project/producer/schema/PlanChangeSchema.java b/src/main/java/com/project/producer/schema/PlanChangeSchema.java new file mode 100644 index 0000000..03b7d51 --- /dev/null +++ b/src/main/java/com/project/producer/schema/PlanChangeSchema.java @@ -0,0 +1,14 @@ +package com.project.producer.schema; + +import java.time.OffsetDateTime; + +public record PlanChangeSchema( + String eventId, // 멱등성/추적용 + long subscriptionId, // 회선 ID + String unit, // MONTHLY | DAILY | UNLIMITED + long allowanceAmount, // 월 제공량 or 일 제공량 (bytes 기준) + OffsetDateTime changedAt, // 요금제 변경 시점 + String email, // 사용자 이메일 + String phone +) { +} diff --git a/src/main/java/com/project/producer/schema/UsageDlqEventSchema.java b/src/main/java/com/project/producer/schema/UsageDlqEventSchema.java new file mode 100644 index 0000000..cfb3f24 --- /dev/null +++ b/src/main/java/com/project/producer/schema/UsageDlqEventSchema.java @@ -0,0 +1,9 @@ +package com.project.producer.schema; + +public record UsageDlqEventSchema( + String originKey, + String originalValue, + String error, + String failedAt +) { +} diff --git a/src/main/java/com/project/producer/schema/UsageEventSchema.java b/src/main/java/com/project/producer/schema/UsageEventSchema.java new file mode 100644 index 0000000..00b3f9f --- /dev/null +++ b/src/main/java/com/project/producer/schema/UsageEventSchema.java @@ -0,0 +1,11 @@ +package com.project.producer.schema; + +public record UsageEventSchema( + String eventId, + long subscriptionId, + long usageBytes, + String timeStamp, + String event, + long ttlSec +) { +} From fdeac3b14762136459cb15d0fa249fe9877809fe Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:04:09 +0900 Subject: [PATCH 03/33] =?UTF-8?q?UPLUS-16=20feat=20:=20Kafka=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EB=A1=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/consumer/util/UsageTimeUtil.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/com/project/consumer/util/UsageTimeUtil.java diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java new file mode 100644 index 0000000..72a386f --- /dev/null +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -0,0 +1,35 @@ +package com.project.consumer.util; + +import java.time.*; +import java.time.format.DateTimeFormatter; + +public class UsageTimeUtil { + private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + public static String toYyyyMM(String isoTs) { + OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); + return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); + } + + public static String toYyyyMMdd(String isoTs) { + OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); + return odt.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + public static long ttlToNextMonthWithBufferSec(String isoTs, int bufferDays) { + OffsetDateTime now = OffsetDateTime.parse(isoTs, ISO); + + OffsetDateTime nextMonthStart = now + .withDayOfMonth(1) + .with(LocalTime.MIDNIGHT) + .plusMonths(1); + + Duration base = Duration.between(now, nextMonthStart); + Duration buffer = Duration.ofDays(bufferDays); + + return Math.max( + base.plus(buffer).getSeconds(), + 3600 + ); + } +} From 931a5c1f86f56d9731b0aac0c190f24af13b9c6d Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:05:54 +0900 Subject: [PATCH 04/33] =?UTF-8?q?UPLUS-16=20feat=20:=20Kafka=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=AC=EC=9A=A9=EB=9F=89=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/consumer/UsageConsumer.java | 65 +++++++++++++++ .../com/project/consumer/util/RedisUtil.java | 47 +++++++++++ .../producer/NotificationProducer.java | 15 ++++ .../com/project/producer/UsageProducer.java | 38 +++++++++ .../producer/test/UsageBurstLoadRunner.java | 79 +++++++++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 src/main/java/com/project/consumer/UsageConsumer.java create mode 100644 src/main/java/com/project/consumer/util/RedisUtil.java create mode 100644 src/main/java/com/project/producer/NotificationProducer.java create mode 100644 src/main/java/com/project/producer/UsageProducer.java create mode 100644 src/main/java/com/project/producer/test/UsageBurstLoadRunner.java diff --git a/src/main/java/com/project/consumer/UsageConsumer.java b/src/main/java/com/project/consumer/UsageConsumer.java new file mode 100644 index 0000000..6b50989 --- /dev/null +++ b/src/main/java/com/project/consumer/UsageConsumer.java @@ -0,0 +1,65 @@ +package com.project.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.domain.GlobalErrorCode; +import com.project.producer.NotificationProducer; +import com.project.producer.schema.UsageEventSchema; +import com.project.consumer.util.RedisUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UsageConsumer { + + private final ObjectMapper objectMapper; + private final RedisUtil redisUtil; + private final NotificationProducer notificationProducer; + + @KafkaListener( + id = "usage-batch-consumer", + topics = "usage-data", + groupId = "usage-consumer", + containerFactory = "batchKafkaListenerContainerFactory" + ) + public void consume(List> records, Acknowledgment ack) { + + if (records == null || records.isEmpty()) { + ack.acknowledge(); + return; + } + + List events = new ArrayList<>(records.size()); + + try { + + for (ConsumerRecord rec : records) { + UsageEventSchema schema = objectMapper.readValue(rec.value(), UsageEventSchema.class); + events.add(schema); + } + + List notifications = redisUtil.applyUsageBatch(events); + + // 임계치 넘은 것만 notification_topic으로 발행 + for (String notification : notifications) { + notificationProducer.sendNotification(notification); + } + + // 성공한 배치 ack + ack.acknowledge(); + } catch (Exception e) { + log.error("usage batch failed", e); + // 여기서 ack 안 하면 같은 배치가 재시도됨 (at-least-once) + throw new ApplicationException(GlobalErrorCode.NOTIFICATION_EVENT_PRODUCE_INVALID); + } + } +} diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java new file mode 100644 index 0000000..9b62736 --- /dev/null +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -0,0 +1,47 @@ +package com.project.consumer.util; + +import com.project.consumer.constant.UsageBatchLuaConstant; +import com.project.producer.schema.CalculatedLimitSchema; +import com.project.producer.schema.UsageEventSchema; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RedisUtil { + + private final StringRedisTemplate redisTemplate; + + private final DefaultRedisScript script + = new DefaultRedisScript<>(UsageBatchLuaConstant.LUA, List.class); + + public List applyUsageBatch(List events) { + if (events == null || events.isEmpty()) return List.of(); + + List args = new ArrayList<>(); + args.add(String.valueOf(events.size())); + + for (UsageEventSchema e : events) { + + args.add(String.valueOf(e.subscriptionId())); + args.add(e.eventId()); + args.add(String.valueOf(e.usageBytes())); + args.add(e.timeStamp()); + } + + Object result = redisTemplate.execute( + script, + Collections.emptyList(), + args.toArray() + ); + return result == null ? List.of() : (List) result; + + } +} diff --git a/src/main/java/com/project/producer/NotificationProducer.java b/src/main/java/com/project/producer/NotificationProducer.java new file mode 100644 index 0000000..67342dd --- /dev/null +++ b/src/main/java/com/project/producer/NotificationProducer.java @@ -0,0 +1,15 @@ +package com.project.producer; + +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationProducer { + private final KafkaTemplate kafkaTemplate; + + public void sendNotification(String payload) { + kafkaTemplate.send("notification_topic", payload); + } +} diff --git a/src/main/java/com/project/producer/UsageProducer.java b/src/main/java/com/project/producer/UsageProducer.java new file mode 100644 index 0000000..d894f1a --- /dev/null +++ b/src/main/java/com/project/producer/UsageProducer.java @@ -0,0 +1,38 @@ +package com.project.producer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.producer.schema.UsageEventSchema; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class UsageProducer { + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + public void sendUsageEvent(long subscriptionId, long usageBytes, String yyyyMM, long ttlSec) { + UsageEventSchema schema = new UsageEventSchema( + UUID.randomUUID().toString(), + subscriptionId, + usageBytes, + OffsetDateTime.now().toString(), + yyyyMM, + ttlSec + ); + + try { + String value = objectMapper.writeValueAsString(schema); + String key = String.valueOf(subscriptionId); + + kafkaTemplate.send("usage-data", key, value); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java new file mode 100644 index 0000000..6aa8147 --- /dev/null +++ b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java @@ -0,0 +1,79 @@ +package com.project.producer.test; + +import com.project.producer.UsageProducer; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@RequiredArgsConstructor +public class UsageBurstLoadRunner implements CommandLineRunner { + + private final UsageProducer usageProducer; + + private static final int EVENTS_PER_SECOND = 10_000; + private static final int DURATION_SECONDS = 60; + private static final int THREADS = 8; + + @Override + public void run(String... args) throws Exception { + + ExecutorService executor = Executors.newFixedThreadPool(THREADS); + AtomicInteger sent = new AtomicInteger(); + + int perThreadRate = EVENTS_PER_SECOND / THREADS; + + long start = System.currentTimeMillis(); + + for (int t = 0; t < THREADS; t++) { + executor.submit(() -> { + ThreadLocalRandom random = ThreadLocalRandom.current(); + + long endTime = System.currentTimeMillis() + DURATION_SECONDS * 1000L; + + while (System.currentTimeMillis() < endTime) { + long secondStart = System.nanoTime(); + + for (int i = 0; i < perThreadRate; i++) { + long subId = random.nextLong(1, 1_000_001); + long bytes = random.nextLong(200, 5_000); + + usageProducer.sendUsageEvent( + subId, + bytes, + null, + 0 + ); + + sent.incrementAndGet(); + } + + long elapsedNs = System.nanoTime() - secondStart; + long sleepMs = 1000 - (elapsedNs / 1_000_000); + + if (sleepMs > 0) { + try { + Thread.sleep(sleepMs); + } catch (InterruptedException ignored) {} + } + } + }); + } + + executor.shutdown(); + executor.awaitTermination(DURATION_SECONDS + 10, TimeUnit.SECONDS); + + long elapsed = System.currentTimeMillis() - start; + + System.out.println("✅ DONE"); + System.out.println("Total events sent = " + sent.get()); + System.out.println("Elapsed ms = " + elapsed); + System.out.println("Avg TPS = " + (sent.get() * 1000L / elapsed)); + } +} \ No newline at end of file From 1bf302a06b0a2d3c5e89b6286e7ca01cf8415564 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:06:57 +0900 Subject: [PATCH 05/33] =?UTF-8?q?UPLUS-16=20feat=20:=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=82=AC=EC=9A=A9=EB=9F=89=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=84=EA=B3=84=EC=B9=98=20=EC=B4=88=EA=B3=BC=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20Redis=20Lua=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constant/UsageBatchLuaConstant.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java new file mode 100644 index 0000000..534ff8d --- /dev/null +++ b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java @@ -0,0 +1,86 @@ +package com.project.consumer.constant; + +public final class UsageBatchLuaConstant { + + private UsageBatchLuaConstant() {} + + public static final String LUA = """ + -- ARGV: + -- [1] N 이후 반복 N개: + -- timeKey, subId, eventId, bytes, ts, ttlSec + + local out = {} + local n = tonumber(ARGV[1]) + local idx = 2 + + + for i = 1, n do + local subId = ARGV[idx]; idx = idx + 1 + local eventId = ARGV[idx]; idx = idx + 1 + local bytes = tonumber(ARGV[idx]); idx = idx + 1 + local ts = ARGV[idx]; idx = idx + 1 + + local monthKeyTime = string.sub(ts, 1, 7):gsub('-', '') + local limitKey = 'limit:' .. monthKeyTime .. ':' .. subId + + local unit = redis.call('GET', 'plan:unit:' .. subId) or 'MONTH' + + local usageKeyTime + local thKeyTime + local ttlSec + + if unit == 'DAY' then + usageKeyTime = string.sub(ts, 1, 10):gsub('-', '') + thKeyTime = usageKeyTime + ttlSec = 86400 * 2 + else + usageKeyTime = monthKeyTime + thKeyTime = monthKeyTime + ttlSec = redis.call('TTL', limitKey) + if ttlSec < 0 then ttlSec = 86400 * 35 end + end + + local usageKey = 'usage:' .. usageKeyTime .. ':' .. subId + local processedKey = 'processed:usage:' .. usageKeyTime .. ':' .. subId + local thKey = 'th:' .. thKeyTime .. ':' .. subId + + -- dedup: 이미 처리된 이벤트면 아래 로직을 아예 실행 안 함 + if redis.call('SISMEMBER', processedKey, eventId) == 0 then + redis.call('SADD', processedKey, eventId) + if redis.call('TTL', processedKey) < 0 then + redis.call('EXPIRE', processedKey, ttlSec) + end + + local newTotal = redis.call('INCRBY', usageKey, bytes) + if redis.call('TTL', usageKey) < 0 then + redis.call('EXPIRE', usageKey, ttlSec) + end + + local limit = tonumber(redis.call('GET', limitKey) or '0') + if limit > 0 then + local percent = math.floor((newTotal * 100) / limit) + + local prev = tonumber(redis.call('GET', thKey) or '0') + local next = prev + + if percent >= 100 and prev < 100 then next = 100 + elseif percent >= 80 and prev < 80 then next = 80 + elseif percent >= 50 and prev < 50 then next = 50 + end + + if next ~= prev then + redis.call('SET', thKey, tostring(next)) + if redis.call('TTL', thKey) < 0 then + redis.call('EXPIRE', thKey, ttlSec) + end + + table.insert(out, + thKeyTime .. '|' .. subId .. '|' .. next .. '|' .. percent .. '|' .. + newTotal .. '|' .. limit .. '|' .. eventId .. '|' .. ts) + end + end + end + end + return out + """; +} From 75465366df69364d59f6879e2166d0d15874182d Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:09:25 +0900 Subject: [PATCH 06/33] =?UTF-8?q?UPLUS-16=20feat=20:=20=EC=9A=94=EA=B8=88?= =?UTF-8?q?=EC=A0=9C=20=EB=B3=80=EB=8F=99=EC=8B=9C=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=B0=8F=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/consumer/PlanChangeConsumer.java | 52 ++++++++ .../project/consumer/util/PlanChangeUtil.java | 117 ++++++++++++++++++ .../com/project/consumer/util/RedisUtil.java | 24 ++++ .../project/producer/PlanChangeProducer.java | 49 ++++++++ 4 files changed, 242 insertions(+) create mode 100644 src/main/java/com/project/consumer/PlanChangeConsumer.java create mode 100644 src/main/java/com/project/consumer/util/PlanChangeUtil.java create mode 100644 src/main/java/com/project/producer/PlanChangeProducer.java diff --git a/src/main/java/com/project/consumer/PlanChangeConsumer.java b/src/main/java/com/project/consumer/PlanChangeConsumer.java new file mode 100644 index 0000000..9660438 --- /dev/null +++ b/src/main/java/com/project/consumer/PlanChangeConsumer.java @@ -0,0 +1,52 @@ +package com.project.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.consumer.util.PlanChangeUtil; +import com.project.consumer.util.RedisUtil; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.domain.GlobalErrorCode; +import com.project.producer.schema.CalculatedLimitSchema; +import com.project.producer.schema.PlanChangeSchema; +import lombok.RequiredArgsConstructor; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PlanChangeConsumer { + + private final ObjectMapper objectMapper; + private final PlanChangeUtil planChangeUtil; + private final RedisUtil redisUtil; + + @KafkaListener( + id = "plan-change-consumer", + topics = "change_plan", + groupId = "plan-change-consumer", + containerFactory = "batchKafkaListenerContainerFactory" + ) + public void consume(List> records, Acknowledgment ack) { + if (records == null || records.isEmpty()) { + ack.acknowledge(); + return; + } + List events = new ArrayList<>(records.size()); + try { + for (ConsumerRecord record : records) { + events.add(objectMapper.readValue(record.value(), PlanChangeSchema.class)); + } + + List limits = planChangeUtil.calculate(events); + redisUtil.writePlanChangeBatch(limits); + + ack.acknowledge(); + } catch (Exception e) { + throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); + } + } +} diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java new file mode 100644 index 0000000..15ecf8c --- /dev/null +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -0,0 +1,117 @@ +package com.project.consumer.util; + +import com.project.producer.schema.CalculatedLimitSchema; +import com.project.producer.schema.PlanChangeSchema; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +import static com.project.consumer.util.UsageTimeUtil.toYyyyMM; +import static com.project.consumer.util.UsageTimeUtil.toYyyyMMdd; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PlanChangeUtil { + + private final StringRedisTemplate redisTemplate; + + public List calculate(List events) { + + List results = new ArrayList<>(); + + String yyyyMM; + long finalLimit; + String processedKey; + Long added; + + for (PlanChangeSchema event : events) { + yyyyMM = toYyyyMM(event.changedAt().toString()); + + processedKey = "processed:plan:" + yyyyMM + ":" + event.subscriptionId(); + added = redisTemplate.opsForSet() + .add(processedKey, event.eventId()); + + redisTemplate.expire( + processedKey, + Duration.ofSeconds( + UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2) + ) + ); + + if (added == null || added == 0L) { + // 이미 처리된 요금제 변경 이벤트 → skip + continue; + } + + String unitKey = "plan:unit:" + event.subscriptionId(); + String prevUnit = redisTemplate.opsForValue().get(unitKey); + + + if ("ULTIMATE".equals(event.unit())) { + finalLimit = -1L; + } + else if ("DAY".equals(event.unit())) { + finalLimit = event.allowanceAmount(); + } + else { + finalLimit = getMonthFinalLimit(yyyyMM, event, prevUnit); + } + + long ttlSec = UsageTimeUtil + .ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2); + + results.add(new CalculatedLimitSchema( + event.subscriptionId(), + yyyyMM, + finalLimit, + ttlSec, + event.unit() + )); + + } + + return results; + } + + private long getPreviousLimit(String key) { + String v = redisTemplate.opsForValue().get(key); + return v == null ? 0L : Long.parseLong(v); + } + + // 사용자가 월 기준 중도에 요금제를 변경했을 경우 사용 가능 데이터 집계 로직 + private long getMonthFinalLimit(String yyyyMM, PlanChangeSchema event, String prevUnit) { + OffsetDateTime kstTime = event.changedAt() + .atZoneSameInstant(ZoneId.of("Asia/Seoul")) + .toOffsetDateTime(); + + YearMonth month = YearMonth.from(kstTime); + int totalDays = month.lengthOfMonth(); + int changeDay = kstTime.getDayOfMonth(); + + int daysBefore = changeDay - 1; + int daysAfter = totalDays - daysBefore; + + // 만약 전 요금제가 무제한 or DAY 요금제 였다면 새로 추가된 요금제의 데이터 양만 계산 + if (!"MONTH".equals(prevUnit)) { + return event.allowanceAmount() * daysAfter / totalDays; + } + + String limitKey = "limit:" + yyyyMM + ":" + event.subscriptionId(); + + long prevLimit = getPreviousLimit(limitKey); + + long allowanceBefore = prevLimit * daysBefore / totalDays; + long allowanceAfter = event.allowanceAmount() * daysAfter / totalDays; + + return allowanceBefore + allowanceAfter; + } +} diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index 9b62736..af105c9 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -44,4 +44,28 @@ public List applyUsageBatch(List events) { return result == null ? List.of() : (List) result; } + + public void writePlanChangeBatch(List limits) { + redisTemplate.executePipelined((RedisCallback) connection -> { + for (CalculatedLimitSchema limit : limits) { + String key = "limit:" + limit.yyyyMM() + ":" + limit.subscriptionId(); + + byte[] k = redisTemplate.getStringSerializer().serialize(key); + byte[] v = redisTemplate.getStringSerializer() + .serialize(String.valueOf(limit.limit())); + + connection.set(k, v); + connection.expire(k, limit.ttlSec()); + + String unitKey = "plan:unit:" + limit.subscriptionId(); + byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); + byte[] uv = redisTemplate.getStringSerializer() + .serialize(limit.unit()); + + connection.set(uk, uv); + connection.expire(uk, limit.ttlSec()); + } + return null; + }); + } } diff --git a/src/main/java/com/project/producer/PlanChangeProducer.java b/src/main/java/com/project/producer/PlanChangeProducer.java new file mode 100644 index 0000000..bb8e37a --- /dev/null +++ b/src/main/java/com/project/producer/PlanChangeProducer.java @@ -0,0 +1,49 @@ +package com.project.producer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.domain.GlobalErrorCode; +import com.project.producer.schema.PlanChangeSchema; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class PlanChangeProducer { + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + public void sendPlanChangeEvent( + long subscriptionId, + String unit, // MONTH / DAILY / UNLIMITED + long allowanceAmount, + OffsetDateTime changedAt, + String email, + String phone + ) { + PlanChangeSchema event = new PlanChangeSchema( + UUID.randomUUID().toString(), + subscriptionId, + unit, + allowanceAmount, + changedAt, + email, + phone + ); + + try { + String value = objectMapper.writeValueAsString(event); + String key = String.valueOf(subscriptionId); + + kafkaTemplate.send("change_plan", key, value); + } catch (JsonProcessingException e) { + throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); + } + } +} From e18f1d016d97b19bea2b99ceed21f30b6ed1f36c Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:09:48 +0900 Subject: [PATCH 07/33] =?UTF-8?q?UPLUS-16=20fix=20:=20git=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/global/config/KafkaConfig.java | 35 ++++++++++++++++++- .../code/domain/GlobalErrorCode.java | 7 ++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/project/global/config/KafkaConfig.java b/src/main/java/com/project/global/config/KafkaConfig.java index 1558140..e2e4ffd 100644 --- a/src/main/java/com/project/global/config/KafkaConfig.java +++ b/src/main/java/com/project/global/config/KafkaConfig.java @@ -6,6 +6,9 @@ import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.config.KafkaListenerContainerFactory; import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.util.backoff.FixedBackOff; @Configuration @EnableKafka @@ -16,8 +19,38 @@ public KafkaListenerContainerFactory kafkaListenerContainerFactory( ConsumerFactory consumerFactory) { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory); - factory.setAutoStartup(true); // 기본값이 true + + factory.getContainerProperties() + .setAckMode(ContainerProperties.AckMode.MANUAL); + + return factory; + } + + @Bean + public ConcurrentKafkaListenerContainerFactory + batchKafkaListenerContainerFactory( + ConsumerFactory consumerFactory + ) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + + factory.setConsumerFactory(consumerFactory); + + factory.setBatchListener(true); + + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + + factory.getContainerProperties() + .setAckMode(ContainerProperties.AckMode.MANUAL); + + DefaultErrorHandler errorHandler = new DefaultErrorHandler( + new FixedBackOff(1000L, 9) // 총 10번 시도(초기+9) + ); + + factory.setCommonErrorHandler(errorHandler); + return factory; } } diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index 07945f5..7f51a38 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -1,9 +1,8 @@ package com.project.global.exception.code.domain; -import org.springframework.http.HttpStatus; - import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor @@ -13,7 +12,9 @@ public enum GlobalErrorCode implements BaseErrorCode { METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_003", "지원하지 않은 Http Method 입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_004", "서버 에러가 발생했습니다."), BLOCKED_API(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_005", "운영 환경에서 사용할 수 없는 API 입니다."), - JSON_CONVERT_DISABLE(HttpStatus.BAD_REQUEST, "USER_005", "JSON으로 변환하는 과정에서 오류가 발생하였습니다"); + NOTIFICATION_EVENT_PRODUCE_INVALID(HttpStatus.BAD_REQUEST, "COMMON_006", "Kafka Notification 이벤트 발행 과정에서 에러가 발생했습니다"), + PLAN_CHANGE_EVENT_PRODUCE_INVALID(HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"), + ; private final HttpStatus httpStatus; private final String customCode; From 268b4dd2c1fffaa264ab897d6b7d527766a03fd6 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:10:11 +0900 Subject: [PATCH 08/33] =?UTF-8?q?UPLUS-16=20feat=20:=20Kafka=20=EC=9C=A0?= =?UTF-8?q?=EC=8B=A4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=B3=84=EB=8F=84=EC=9D=98=20?= =?UTF-8?q?Producer=20=EC=9E=84=EC=8B=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/producer/UsageDlqProducer.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/main/java/com/project/producer/UsageDlqProducer.java diff --git a/src/main/java/com/project/producer/UsageDlqProducer.java b/src/main/java/com/project/producer/UsageDlqProducer.java new file mode 100644 index 0000000..1329616 --- /dev/null +++ b/src/main/java/com/project/producer/UsageDlqProducer.java @@ -0,0 +1,16 @@ +package com.project.producer; + +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UsageDlqProducer { + + private final KafkaTemplate kafkaTemplate; + + public void send(String key, String jsonValue) { + kafkaTemplate.send("usage_dlq_topic", key, jsonValue); + } +} From 7ab7cd44eb700f9af0ed701beaf32bbd19cd4f6f Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:10:20 +0900 Subject: [PATCH 09/33] =?UTF-8?q?UPLUS-16=20feat=20:=20Test=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/InitSubscriptionPlanRunner.java | 50 +++++++++++++++++++ .../com/project/producer/test/PlanSeed.java | 19 +++++++ 2 files changed, 69 insertions(+) create mode 100644 src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java create mode 100644 src/main/java/com/project/producer/test/PlanSeed.java diff --git a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java new file mode 100644 index 0000000..54f241b --- /dev/null +++ b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java @@ -0,0 +1,50 @@ +package com.project.producer.test; + +import com.project.producer.PlanChangeProducer; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.concurrent.ThreadLocalRandom; + +@Component +@RequiredArgsConstructor +public class InitSubscriptionPlanRunner implements CommandLineRunner { + + private final PlanChangeProducer planChangeProducer; + + private static final int SUB_START = 1; + private static final int SUB_END = 10_000; + + @Override + public void run(String... args) { + + OffsetDateTime baseTime = OffsetDateTime.now() + .withDayOfMonth(1) + .withHour(0) + .withMinute(0) + .withSecond(0); + + PlanSeed[] plans = PlanSeed.values(); + ThreadLocalRandom random = ThreadLocalRandom.current(); + + for (long subId = SUB_START; subId <= SUB_END; subId++) { + + PlanSeed plan = plans[random.nextInt(plans.length)]; + + planChangeProducer.sendPlanChangeEvent( + subId, + plan.getUnit(), + plan.getAllowance(), + baseTime, + "user" + subId + "@test.com", + "010-" + String.format("%04d-%04d", + random.nextInt(10000), + random.nextInt(10000)) + ); + } + + System.out.println("✅ Initial Plan Seeding Completed (1 ~ 10000)"); + } +} diff --git a/src/main/java/com/project/producer/test/PlanSeed.java b/src/main/java/com/project/producer/test/PlanSeed.java new file mode 100644 index 0000000..43662ab --- /dev/null +++ b/src/main/java/com/project/producer/test/PlanSeed.java @@ -0,0 +1,19 @@ +package com.project.producer.test; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PlanSeed { + + FIVE_G_SIGNATURE("5G 시그니처", -1, "ULTIMATE"), + FIVE_G_STANDARD("5G 스탠다드", 153600, "MONTH"), + FIVE_G_BASIC_PLUS("5G 베이직+", 24576, "MONTH"), + LTE_33("LTE 데이터 33", 1536, "MONTH"), + LTE_DIRECT_45("LTE 다이렉트 45", 5120, "DAY"); + + private final String name; + private final long allowance; + private final String unit; +} From 87b238e4441f3726a2f9f98d063ad1e706c5dd4a Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:11:43 +0900 Subject: [PATCH 10/33] =?UTF-8?q?UPLUS-16=20feat=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../producer/schema/ExceededNotificationSchema.java | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/main/java/com/project/producer/schema/ExceededNotificationSchema.java diff --git a/src/main/java/com/project/producer/schema/ExceededNotificationSchema.java b/src/main/java/com/project/producer/schema/ExceededNotificationSchema.java deleted file mode 100644 index 9a1b2b6..0000000 --- a/src/main/java/com/project/producer/schema/ExceededNotificationSchema.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.project.producer.schema; - -public record ExceededNotificationSchema( - long subscriptionId, - long newTotal, - long limit, - String eventId, - String timeStamp -) { -} From c3364c5cffdf56777d01e5ed600cb88c0836bffe Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:16:26 +0900 Subject: [PATCH 11/33] =?UTF-8?q?UPLUS-16=20feat=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/PlanChangeUtil.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index 15ecf8c..2a3f8e6 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -15,7 +15,6 @@ import java.util.List; import static com.project.consumer.util.UsageTimeUtil.toYyyyMM; -import static com.project.consumer.util.UsageTimeUtil.toYyyyMMdd; @Slf4j @Service From e712b247abb0cce1cca9aada2a4b9caa155f5f19 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:17:00 +0900 Subject: [PATCH 12/33] =?UTF-8?q?UPLUS-16=20feat=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/UsageTimeUtil.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index 72a386f..c32ea0f 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -11,11 +11,6 @@ public static String toYyyyMM(String isoTs) { return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); } - public static String toYyyyMMdd(String isoTs) { - OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); - return odt.format(DateTimeFormatter.ofPattern("yyyyMMdd")); - } - public static long ttlToNextMonthWithBufferSec(String isoTs, int bufferDays) { OffsetDateTime now = OffsetDateTime.parse(isoTs, ISO); From 9bc5c1e3f8f30786e4e6d0f817cbe0539ea35e92 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:17:44 +0900 Subject: [PATCH 13/33] =?UTF-8?q?UPLUS-16=20feat=20:=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=91=EC=84=B1=EB=90=9C=20=EC=9C=A0=EC=8B=A4=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20Producer=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/producer/UsageDlqProducer.java | 16 ---------------- .../producer/schema/UsageDlqEventSchema.java | 9 --------- 2 files changed, 25 deletions(-) delete mode 100644 src/main/java/com/project/producer/UsageDlqProducer.java delete mode 100644 src/main/java/com/project/producer/schema/UsageDlqEventSchema.java diff --git a/src/main/java/com/project/producer/UsageDlqProducer.java b/src/main/java/com/project/producer/UsageDlqProducer.java deleted file mode 100644 index 1329616..0000000 --- a/src/main/java/com/project/producer/UsageDlqProducer.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.project.producer; - -import lombok.RequiredArgsConstructor; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class UsageDlqProducer { - - private final KafkaTemplate kafkaTemplate; - - public void send(String key, String jsonValue) { - kafkaTemplate.send("usage_dlq_topic", key, jsonValue); - } -} diff --git a/src/main/java/com/project/producer/schema/UsageDlqEventSchema.java b/src/main/java/com/project/producer/schema/UsageDlqEventSchema.java deleted file mode 100644 index cfb3f24..0000000 --- a/src/main/java/com/project/producer/schema/UsageDlqEventSchema.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.project.producer.schema; - -public record UsageDlqEventSchema( - String originKey, - String originalValue, - String error, - String failedAt -) { -} From 84c3f1e5697f507ac6baafb6da8d55685a361d2f Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:39:23 +0900 Subject: [PATCH 14/33] =?UTF-8?q?UPLUS-16=20fix=20:=20git=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/consumer/PlanChangeConsumer.java | 54 ++++--- .../com/project/consumer/UsageConsumer.java | 70 +++++---- .../constant/UsageBatchLuaConstant.java | 35 ++--- .../project/consumer/util/PlanChangeUtil.java | 142 ++++++++---------- .../com/project/consumer/util/RedisUtil.java | 67 ++++----- .../project/consumer/util/UsageTimeUtil.java | 30 ++-- .../project/global/config/KafkaConfig.java | 53 +++---- .../code/domain/GlobalErrorCode.java | 24 +-- .../producer/NotificationProducer.java | 8 +- .../project/producer/PlanChangeProducer.java | 56 ++++--- .../com/project/producer/UsageProducer.java | 41 +++-- .../schema/CalculatedLimitSchema.java | 8 +- .../producer/schema/PlanChangeSchema.java | 16 +- .../producer/schema/UsageEventSchema.java | 14 +- .../test/InitSubscriptionPlanRunner.java | 53 +++---- .../com/project/producer/test/PlanSeed.java | 17 +-- .../producer/test/UsageBurstLoadRunner.java | 94 ++++++------ 17 files changed, 364 insertions(+), 418 deletions(-) diff --git a/src/main/java/com/project/consumer/PlanChangeConsumer.java b/src/main/java/com/project/consumer/PlanChangeConsumer.java index 9660438..5c6ef55 100644 --- a/src/main/java/com/project/consumer/PlanChangeConsumer.java +++ b/src/main/java/com/project/consumer/PlanChangeConsumer.java @@ -7,46 +7,44 @@ import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.schema.CalculatedLimitSchema; import com.project.producer.schema.PlanChangeSchema; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; -import java.util.ArrayList; -import java.util.List; - @Component @RequiredArgsConstructor public class PlanChangeConsumer { - private final ObjectMapper objectMapper; - private final PlanChangeUtil planChangeUtil; - private final RedisUtil redisUtil; + private final ObjectMapper objectMapper; + private final PlanChangeUtil planChangeUtil; + private final RedisUtil redisUtil; - @KafkaListener( - id = "plan-change-consumer", - topics = "change_plan", - groupId = "plan-change-consumer", - containerFactory = "batchKafkaListenerContainerFactory" - ) - public void consume(List> records, Acknowledgment ack) { - if (records == null || records.isEmpty()) { - ack.acknowledge(); - return; - } - List events = new ArrayList<>(records.size()); - try { - for (ConsumerRecord record : records) { - events.add(objectMapper.readValue(record.value(), PlanChangeSchema.class)); - } + @KafkaListener( + id = "plan-change-consumer", + topics = "change_plan", + groupId = "plan-change-consumer", + containerFactory = "batchKafkaListenerContainerFactory") + public void consume(List> records, Acknowledgment ack) { + if (records == null || records.isEmpty()) { + ack.acknowledge(); + return; + } + List events = new ArrayList<>(records.size()); + try { + for (ConsumerRecord record : records) { + events.add(objectMapper.readValue(record.value(), PlanChangeSchema.class)); + } - List limits = planChangeUtil.calculate(events); - redisUtil.writePlanChangeBatch(limits); + List limits = planChangeUtil.calculate(events); + redisUtil.writePlanChangeBatch(limits); - ack.acknowledge(); - } catch (Exception e) { - throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); - } + ack.acknowledge(); + } catch (Exception e) { + throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); } + } } diff --git a/src/main/java/com/project/consumer/UsageConsumer.java b/src/main/java/com/project/consumer/UsageConsumer.java index 6b50989..705d8af 100644 --- a/src/main/java/com/project/consumer/UsageConsumer.java +++ b/src/main/java/com/project/consumer/UsageConsumer.java @@ -1,11 +1,13 @@ package com.project.consumer; import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.consumer.util.RedisUtil; import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.NotificationProducer; import com.project.producer.schema.UsageEventSchema; -import com.project.consumer.util.RedisUtil; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -13,53 +15,49 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; -import java.util.ArrayList; -import java.util.List; - @Slf4j @Component @RequiredArgsConstructor public class UsageConsumer { - private final ObjectMapper objectMapper; - private final RedisUtil redisUtil; - private final NotificationProducer notificationProducer; + private final ObjectMapper objectMapper; + private final RedisUtil redisUtil; + private final NotificationProducer notificationProducer; - @KafkaListener( - id = "usage-batch-consumer", - topics = "usage-data", - groupId = "usage-consumer", - containerFactory = "batchKafkaListenerContainerFactory" - ) - public void consume(List> records, Acknowledgment ack) { + @KafkaListener( + id = "usage-batch-consumer", + topics = "usage-data", + groupId = "usage-consumer", + containerFactory = "batchKafkaListenerContainerFactory") + public void consume(List> records, Acknowledgment ack) { - if (records == null || records.isEmpty()) { - ack.acknowledge(); - return; - } + if (records == null || records.isEmpty()) { + ack.acknowledge(); + return; + } - List events = new ArrayList<>(records.size()); + List events = new ArrayList<>(records.size()); - try { + try { - for (ConsumerRecord rec : records) { - UsageEventSchema schema = objectMapper.readValue(rec.value(), UsageEventSchema.class); - events.add(schema); - } + for (ConsumerRecord rec : records) { + UsageEventSchema schema = objectMapper.readValue(rec.value(), UsageEventSchema.class); + events.add(schema); + } - List notifications = redisUtil.applyUsageBatch(events); + List notifications = redisUtil.applyUsageBatch(events); - // 임계치 넘은 것만 notification_topic으로 발행 - for (String notification : notifications) { - notificationProducer.sendNotification(notification); - } + // 임계치 넘은 것만 notification_topic으로 발행 + for (String notification : notifications) { + notificationProducer.sendNotification(notification); + } - // 성공한 배치 ack - ack.acknowledge(); - } catch (Exception e) { - log.error("usage batch failed", e); - // 여기서 ack 안 하면 같은 배치가 재시도됨 (at-least-once) - throw new ApplicationException(GlobalErrorCode.NOTIFICATION_EVENT_PRODUCE_INVALID); - } + // 성공한 배치 ack + ack.acknowledge(); + } catch (Exception e) { + log.error("usage batch failed", e); + // 여기서 ack 안 하면 같은 배치가 재시도됨 (at-least-once) + throw new ApplicationException(GlobalErrorCode.NOTIFICATION_EVENT_PRODUCE_INVALID); } + } } diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java index 534ff8d..23df4a2 100644 --- a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java +++ b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java @@ -2,33 +2,34 @@ public final class UsageBatchLuaConstant { - private UsageBatchLuaConstant() {} + private UsageBatchLuaConstant() {} - public static final String LUA = """ + public static final String LUA = + """ -- ARGV: -- [1] N 이후 반복 N개: -- timeKey, subId, eventId, bytes, ts, ttlSec - + local out = {} local n = tonumber(ARGV[1]) local idx = 2 - - + + for i = 1, n do local subId = ARGV[idx]; idx = idx + 1 local eventId = ARGV[idx]; idx = idx + 1 local bytes = tonumber(ARGV[idx]); idx = idx + 1 local ts = ARGV[idx]; idx = idx + 1 - + local monthKeyTime = string.sub(ts, 1, 7):gsub('-', '') local limitKey = 'limit:' .. monthKeyTime .. ':' .. subId - + local unit = redis.call('GET', 'plan:unit:' .. subId) or 'MONTH' - + local usageKeyTime local thKeyTime local ttlSec - + if unit == 'DAY' then usageKeyTime = string.sub(ts, 1, 10):gsub('-', '') thKeyTime = usageKeyTime @@ -39,41 +40,41 @@ private UsageBatchLuaConstant() {} ttlSec = redis.call('TTL', limitKey) if ttlSec < 0 then ttlSec = 86400 * 35 end end - + local usageKey = 'usage:' .. usageKeyTime .. ':' .. subId local processedKey = 'processed:usage:' .. usageKeyTime .. ':' .. subId local thKey = 'th:' .. thKeyTime .. ':' .. subId - + -- dedup: 이미 처리된 이벤트면 아래 로직을 아예 실행 안 함 if redis.call('SISMEMBER', processedKey, eventId) == 0 then redis.call('SADD', processedKey, eventId) if redis.call('TTL', processedKey) < 0 then redis.call('EXPIRE', processedKey, ttlSec) end - + local newTotal = redis.call('INCRBY', usageKey, bytes) if redis.call('TTL', usageKey) < 0 then redis.call('EXPIRE', usageKey, ttlSec) end - + local limit = tonumber(redis.call('GET', limitKey) or '0') if limit > 0 then local percent = math.floor((newTotal * 100) / limit) - + local prev = tonumber(redis.call('GET', thKey) or '0') local next = prev - + if percent >= 100 and prev < 100 then next = 100 elseif percent >= 80 and prev < 80 then next = 80 elseif percent >= 50 and prev < 50 then next = 50 end - + if next ~= prev then redis.call('SET', thKey, tostring(next)) if redis.call('TTL', thKey) < 0 then redis.call('EXPIRE', thKey, ttlSec) end - + table.insert(out, thKeyTime .. '|' .. subId .. '|' .. next .. '|' .. percent .. '|' .. newTotal .. '|' .. limit .. '|' .. eventId .. '|' .. ts) diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index 2a3f8e6..c221837 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -1,116 +1,102 @@ package com.project.consumer.util; +import static com.project.consumer.util.UsageTimeUtil.toYyyyMM; + import com.project.producer.schema.CalculatedLimitSchema; import com.project.producer.schema.PlanChangeSchema; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; - import java.time.Duration; import java.time.OffsetDateTime; import java.time.YearMonth; import java.time.ZoneId; import java.util.ArrayList; import java.util.List; - -import static com.project.consumer.util.UsageTimeUtil.toYyyyMM; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; @Slf4j @Service @RequiredArgsConstructor public class PlanChangeUtil { - private final StringRedisTemplate redisTemplate; - - public List calculate(List events) { - - List results = new ArrayList<>(); + private final StringRedisTemplate redisTemplate; - String yyyyMM; - long finalLimit; - String processedKey; - Long added; + public List calculate(List events) { - for (PlanChangeSchema event : events) { - yyyyMM = toYyyyMM(event.changedAt().toString()); + List results = new ArrayList<>(); - processedKey = "processed:plan:" + yyyyMM + ":" + event.subscriptionId(); - added = redisTemplate.opsForSet() - .add(processedKey, event.eventId()); + String yyyyMM; + long finalLimit; + String processedKey; + Long added; - redisTemplate.expire( - processedKey, - Duration.ofSeconds( - UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2) - ) - ); + for (PlanChangeSchema event : events) { + yyyyMM = toYyyyMM(event.changedAt().toString()); - if (added == null || added == 0L) { - // 이미 처리된 요금제 변경 이벤트 → skip - continue; - } + processedKey = "processed:plan:" + yyyyMM + ":" + event.subscriptionId(); + added = redisTemplate.opsForSet().add(processedKey, event.eventId()); - String unitKey = "plan:unit:" + event.subscriptionId(); - String prevUnit = redisTemplate.opsForValue().get(unitKey); + redisTemplate.expire( + processedKey, + Duration.ofSeconds( + UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2))); + if (added == null || added == 0L) { + // 이미 처리된 요금제 변경 이벤트 → skip + continue; + } - if ("ULTIMATE".equals(event.unit())) { - finalLimit = -1L; - } - else if ("DAY".equals(event.unit())) { - finalLimit = event.allowanceAmount(); - } - else { - finalLimit = getMonthFinalLimit(yyyyMM, event, prevUnit); - } + String unitKey = "plan:unit:" + event.subscriptionId(); + String prevUnit = redisTemplate.opsForValue().get(unitKey); - long ttlSec = UsageTimeUtil - .ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2); + if ("ULTIMATE".equals(event.unit())) { + finalLimit = -1L; + } else if ("DAY".equals(event.unit())) { + finalLimit = event.allowanceAmount(); + } else { + finalLimit = getMonthFinalLimit(yyyyMM, event, prevUnit); + } - results.add(new CalculatedLimitSchema( - event.subscriptionId(), - yyyyMM, - finalLimit, - ttlSec, - event.unit() - )); + long ttlSec = UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2); - } - - return results; + results.add( + new CalculatedLimitSchema( + event.subscriptionId(), yyyyMM, finalLimit, ttlSec, event.unit())); } - private long getPreviousLimit(String key) { - String v = redisTemplate.opsForValue().get(key); - return v == null ? 0L : Long.parseLong(v); - } + return results; + } - // 사용자가 월 기준 중도에 요금제를 변경했을 경우 사용 가능 데이터 집계 로직 - private long getMonthFinalLimit(String yyyyMM, PlanChangeSchema event, String prevUnit) { - OffsetDateTime kstTime = event.changedAt() - .atZoneSameInstant(ZoneId.of("Asia/Seoul")) - .toOffsetDateTime(); + private long getPreviousLimit(String key) { + String v = redisTemplate.opsForValue().get(key); + return v == null ? 0L : Long.parseLong(v); + } - YearMonth month = YearMonth.from(kstTime); - int totalDays = month.lengthOfMonth(); - int changeDay = kstTime.getDayOfMonth(); + // 사용자가 월 기준 중도에 요금제를 변경했을 경우 사용 가능 데이터 집계 로직 + private long getMonthFinalLimit(String yyyyMM, PlanChangeSchema event, String prevUnit) { + OffsetDateTime kstTime = + event.changedAt().atZoneSameInstant(ZoneId.of("Asia/Seoul")).toOffsetDateTime(); - int daysBefore = changeDay - 1; - int daysAfter = totalDays - daysBefore; + YearMonth month = YearMonth.from(kstTime); + int totalDays = month.lengthOfMonth(); + int changeDay = kstTime.getDayOfMonth(); - // 만약 전 요금제가 무제한 or DAY 요금제 였다면 새로 추가된 요금제의 데이터 양만 계산 - if (!"MONTH".equals(prevUnit)) { - return event.allowanceAmount() * daysAfter / totalDays; - } + int daysBefore = changeDay - 1; + int daysAfter = totalDays - daysBefore; - String limitKey = "limit:" + yyyyMM + ":" + event.subscriptionId(); + // 만약 전 요금제가 무제한 or DAY 요금제 였다면 새로 추가된 요금제의 데이터 양만 계산 + if (!"MONTH".equals(prevUnit)) { + return event.allowanceAmount() * daysAfter / totalDays; + } - long prevLimit = getPreviousLimit(limitKey); + String limitKey = "limit:" + yyyyMM + ":" + event.subscriptionId(); - long allowanceBefore = prevLimit * daysBefore / totalDays; - long allowanceAfter = event.allowanceAmount() * daysAfter / totalDays; + long prevLimit = getPreviousLimit(limitKey); - return allowanceBefore + allowanceAfter; - } + long allowanceBefore = prevLimit * daysBefore / totalDays; + long allowanceAfter = event.allowanceAmount() * daysAfter / totalDays; + + return allowanceBefore + allowanceAfter; + } } diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index af105c9..49c0cd4 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -3,69 +3,64 @@ import com.project.consumer.constant.UsageBatchLuaConstant; import com.project.producer.schema.CalculatedLimitSchema; import com.project.producer.schema.UsageEventSchema; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - @Service @RequiredArgsConstructor public class RedisUtil { - private final StringRedisTemplate redisTemplate; - - private final DefaultRedisScript script - = new DefaultRedisScript<>(UsageBatchLuaConstant.LUA, List.class); - - public List applyUsageBatch(List events) { - if (events == null || events.isEmpty()) return List.of(); + private final StringRedisTemplate redisTemplate; - List args = new ArrayList<>(); - args.add(String.valueOf(events.size())); + private final DefaultRedisScript script = + new DefaultRedisScript<>(UsageBatchLuaConstant.LUA, List.class); - for (UsageEventSchema e : events) { + public List applyUsageBatch(List events) { + if (events == null || events.isEmpty()) return List.of(); - args.add(String.valueOf(e.subscriptionId())); - args.add(e.eventId()); - args.add(String.valueOf(e.usageBytes())); - args.add(e.timeStamp()); - } + List args = new ArrayList<>(); + args.add(String.valueOf(events.size())); - Object result = redisTemplate.execute( - script, - Collections.emptyList(), - args.toArray() - ); - return result == null ? List.of() : (List) result; + for (UsageEventSchema e : events) { + args.add(String.valueOf(e.subscriptionId())); + args.add(e.eventId()); + args.add(String.valueOf(e.usageBytes())); + args.add(e.timeStamp()); } - public void writePlanChangeBatch(List limits) { - redisTemplate.executePipelined((RedisCallback) connection -> { - for (CalculatedLimitSchema limit : limits) { + Object result = redisTemplate.execute(script, Collections.emptyList(), args.toArray()); + return result == null ? List.of() : (List) result; + } + + public void writePlanChangeBatch(List limits) { + redisTemplate.executePipelined( + (RedisCallback) + connection -> { + for (CalculatedLimitSchema limit : limits) { String key = "limit:" + limit.yyyyMM() + ":" + limit.subscriptionId(); byte[] k = redisTemplate.getStringSerializer().serialize(key); - byte[] v = redisTemplate.getStringSerializer() - .serialize(String.valueOf(limit.limit())); + byte[] v = + redisTemplate.getStringSerializer().serialize(String.valueOf(limit.limit())); connection.set(k, v); connection.expire(k, limit.ttlSec()); String unitKey = "plan:unit:" + limit.subscriptionId(); byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); - byte[] uv = redisTemplate.getStringSerializer() - .serialize(limit.unit()); + byte[] uv = redisTemplate.getStringSerializer().serialize(limit.unit()); connection.set(uk, uv); connection.expire(uk, limit.ttlSec()); - } - return null; - }); - } + } + return null; + }); + } } diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index c32ea0f..9b129f5 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -4,27 +4,21 @@ import java.time.format.DateTimeFormatter; public class UsageTimeUtil { - private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - public static String toYyyyMM(String isoTs) { - OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); - return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); - } + public static String toYyyyMM(String isoTs) { + OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); + return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); + } - public static long ttlToNextMonthWithBufferSec(String isoTs, int bufferDays) { - OffsetDateTime now = OffsetDateTime.parse(isoTs, ISO); + public static long ttlToNextMonthWithBufferSec(String isoTs, int bufferDays) { + OffsetDateTime now = OffsetDateTime.parse(isoTs, ISO); - OffsetDateTime nextMonthStart = now - .withDayOfMonth(1) - .with(LocalTime.MIDNIGHT) - .plusMonths(1); + OffsetDateTime nextMonthStart = now.withDayOfMonth(1).with(LocalTime.MIDNIGHT).plusMonths(1); - Duration base = Duration.between(now, nextMonthStart); - Duration buffer = Duration.ofDays(bufferDays); + Duration base = Duration.between(now, nextMonthStart); + Duration buffer = Duration.ofDays(bufferDays); - return Math.max( - base.plus(buffer).getSeconds(), - 3600 - ); - } + return Math.max(base.plus(buffer).getSeconds(), 3600); + } } diff --git a/src/main/java/com/project/global/config/KafkaConfig.java b/src/main/java/com/project/global/config/KafkaConfig.java index e2e4ffd..683ad93 100644 --- a/src/main/java/com/project/global/config/KafkaConfig.java +++ b/src/main/java/com/project/global/config/KafkaConfig.java @@ -14,43 +14,40 @@ @EnableKafka public class KafkaConfig { - @Bean - public KafkaListenerContainerFactory kafkaListenerContainerFactory( - ConsumerFactory consumerFactory) { - ConcurrentKafkaListenerContainerFactory factory = - new ConcurrentKafkaListenerContainerFactory<>(); + @Bean + public KafkaListenerContainerFactory kafkaListenerContainerFactory( + ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); + factory.setConsumerFactory(consumerFactory); - factory.getContainerProperties() - .setAckMode(ContainerProperties.AckMode.MANUAL); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - return factory; - } + return factory; + } - @Bean - public ConcurrentKafkaListenerContainerFactory - batchKafkaListenerContainerFactory( - ConsumerFactory consumerFactory - ) { - ConcurrentKafkaListenerContainerFactory factory = - new ConcurrentKafkaListenerContainerFactory<>(); + @Bean + public ConcurrentKafkaListenerContainerFactory batchKafkaListenerContainerFactory( + ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); + factory.setConsumerFactory(consumerFactory); - factory.setBatchListener(true); + factory.setBatchListener(true); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - factory.getContainerProperties() - .setAckMode(ContainerProperties.AckMode.MANUAL); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - DefaultErrorHandler errorHandler = new DefaultErrorHandler( - new FixedBackOff(1000L, 9) // 총 10번 시도(초기+9) - ); + DefaultErrorHandler errorHandler = + new DefaultErrorHandler( + new FixedBackOff(1000L, 9) // 총 10번 시도(초기+9) + ); - factory.setCommonErrorHandler(errorHandler); + factory.setCommonErrorHandler(errorHandler); - return factory; - } + return factory; + } } diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index 7f51a38..2cd72da 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -7,16 +7,18 @@ @Getter @RequiredArgsConstructor public enum GlobalErrorCode implements BaseErrorCode { - BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 요청입니다."), - METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "COMMON_002", "올바르지 않은 요청입니다."), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_003", "지원하지 않은 Http Method 입니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_004", "서버 에러가 발생했습니다."), - BLOCKED_API(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_005", "운영 환경에서 사용할 수 없는 API 입니다."), - NOTIFICATION_EVENT_PRODUCE_INVALID(HttpStatus.BAD_REQUEST, "COMMON_006", "Kafka Notification 이벤트 발행 과정에서 에러가 발생했습니다"), - PLAN_CHANGE_EVENT_PRODUCE_INVALID(HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"), - ; + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 요청입니다."), + METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "COMMON_002", "올바르지 않은 요청입니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_003", "지원하지 않은 Http Method 입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_004", "서버 에러가 발생했습니다."), + BLOCKED_API(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_005", "운영 환경에서 사용할 수 없는 API 입니다."), + NOTIFICATION_EVENT_PRODUCE_INVALID( + HttpStatus.BAD_REQUEST, "COMMON_006", "Kafka Notification 이벤트 발행 과정에서 에러가 발생했습니다"), + PLAN_CHANGE_EVENT_PRODUCE_INVALID( + HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"), + ; - private final HttpStatus httpStatus; - private final String customCode; - private final String message; + private final HttpStatus httpStatus; + private final String customCode; + private final String message; } diff --git a/src/main/java/com/project/producer/NotificationProducer.java b/src/main/java/com/project/producer/NotificationProducer.java index 67342dd..7af622d 100644 --- a/src/main/java/com/project/producer/NotificationProducer.java +++ b/src/main/java/com/project/producer/NotificationProducer.java @@ -7,9 +7,9 @@ @Component @RequiredArgsConstructor public class NotificationProducer { - private final KafkaTemplate kafkaTemplate; + private final KafkaTemplate kafkaTemplate; - public void sendNotification(String payload) { - kafkaTemplate.send("notification_topic", payload); - } + public void sendNotification(String payload) { + kafkaTemplate.send("notification_topic", payload); + } } diff --git a/src/main/java/com/project/producer/PlanChangeProducer.java b/src/main/java/com/project/producer/PlanChangeProducer.java index bb8e37a..64e2e31 100644 --- a/src/main/java/com/project/producer/PlanChangeProducer.java +++ b/src/main/java/com/project/producer/PlanChangeProducer.java @@ -5,45 +5,43 @@ import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.schema.PlanChangeSchema; +import java.time.OffsetDateTime; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.time.OffsetDateTime; -import java.util.UUID; - @Component @RequiredArgsConstructor public class PlanChangeProducer { - private final KafkaTemplate kafkaTemplate; - private final ObjectMapper objectMapper; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; - public void sendPlanChangeEvent( - long subscriptionId, - String unit, // MONTH / DAILY / UNLIMITED - long allowanceAmount, - OffsetDateTime changedAt, - String email, - String phone - ) { - PlanChangeSchema event = new PlanChangeSchema( - UUID.randomUUID().toString(), - subscriptionId, - unit, - allowanceAmount, - changedAt, - email, - phone - ); + public void sendPlanChangeEvent( + long subscriptionId, + String unit, // MONTH / DAILY / UNLIMITED + long allowanceAmount, + OffsetDateTime changedAt, + String email, + String phone) { + PlanChangeSchema event = + new PlanChangeSchema( + UUID.randomUUID().toString(), + subscriptionId, + unit, + allowanceAmount, + changedAt, + email, + phone); - try { - String value = objectMapper.writeValueAsString(event); - String key = String.valueOf(subscriptionId); + try { + String value = objectMapper.writeValueAsString(event); + String key = String.valueOf(subscriptionId); - kafkaTemplate.send("change_plan", key, value); - } catch (JsonProcessingException e) { - throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); - } + kafkaTemplate.send("change_plan", key, value); + } catch (JsonProcessingException e) { + throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); } + } } diff --git a/src/main/java/com/project/producer/UsageProducer.java b/src/main/java/com/project/producer/UsageProducer.java index d894f1a..d13e712 100644 --- a/src/main/java/com/project/producer/UsageProducer.java +++ b/src/main/java/com/project/producer/UsageProducer.java @@ -3,36 +3,35 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.project.producer.schema.UsageEventSchema; +import java.time.OffsetDateTime; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.time.OffsetDateTime; -import java.util.UUID; - @Component @RequiredArgsConstructor public class UsageProducer { - private final KafkaTemplate kafkaTemplate; - private final ObjectMapper objectMapper; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; - public void sendUsageEvent(long subscriptionId, long usageBytes, String yyyyMM, long ttlSec) { - UsageEventSchema schema = new UsageEventSchema( - UUID.randomUUID().toString(), - subscriptionId, - usageBytes, - OffsetDateTime.now().toString(), - yyyyMM, - ttlSec - ); + public void sendUsageEvent(long subscriptionId, long usageBytes, String yyyyMM, long ttlSec) { + UsageEventSchema schema = + new UsageEventSchema( + UUID.randomUUID().toString(), + subscriptionId, + usageBytes, + OffsetDateTime.now().toString(), + yyyyMM, + ttlSec); - try { - String value = objectMapper.writeValueAsString(schema); - String key = String.valueOf(subscriptionId); + try { + String value = objectMapper.writeValueAsString(schema); + String key = String.valueOf(subscriptionId); - kafkaTemplate.send("usage-data", key, value); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + kafkaTemplate.send("usage-data", key, value); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } + } } diff --git a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java index 2988a07..5c6f652 100644 --- a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java +++ b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java @@ -1,10 +1,4 @@ package com.project.producer.schema; public record CalculatedLimitSchema( - long subscriptionId, - String yyyyMM, - long limit, - long ttlSec, - String unit -) { -} + long subscriptionId, String yyyyMM, long limit, long ttlSec, String unit) {} diff --git a/src/main/java/com/project/producer/schema/PlanChangeSchema.java b/src/main/java/com/project/producer/schema/PlanChangeSchema.java index 03b7d51..559682f 100644 --- a/src/main/java/com/project/producer/schema/PlanChangeSchema.java +++ b/src/main/java/com/project/producer/schema/PlanChangeSchema.java @@ -3,12 +3,10 @@ import java.time.OffsetDateTime; public record PlanChangeSchema( - String eventId, // 멱등성/추적용 - long subscriptionId, // 회선 ID - String unit, // MONTHLY | DAILY | UNLIMITED - long allowanceAmount, // 월 제공량 or 일 제공량 (bytes 기준) - OffsetDateTime changedAt, // 요금제 변경 시점 - String email, // 사용자 이메일 - String phone -) { -} + String eventId, // 멱등성/추적용 + long subscriptionId, // 회선 ID + String unit, // MONTHLY | DAILY | UNLIMITED + long allowanceAmount, // 월 제공량 or 일 제공량 (bytes 기준) + OffsetDateTime changedAt, // 요금제 변경 시점 + String email, // 사용자 이메일 + String phone) {} diff --git a/src/main/java/com/project/producer/schema/UsageEventSchema.java b/src/main/java/com/project/producer/schema/UsageEventSchema.java index 00b3f9f..3f8b15f 100644 --- a/src/main/java/com/project/producer/schema/UsageEventSchema.java +++ b/src/main/java/com/project/producer/schema/UsageEventSchema.java @@ -1,11 +1,9 @@ package com.project.producer.schema; public record UsageEventSchema( - String eventId, - long subscriptionId, - long usageBytes, - String timeStamp, - String event, - long ttlSec -) { -} + String eventId, + long subscriptionId, + long usageBytes, + String timeStamp, + String event, + long ttlSec) {} diff --git a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java index 54f241b..b4c1a0b 100644 --- a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java +++ b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java @@ -1,50 +1,43 @@ package com.project.producer.test; import com.project.producer.PlanChangeProducer; +import java.time.OffsetDateTime; +import java.util.concurrent.ThreadLocalRandom; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; -import java.time.OffsetDateTime; -import java.util.concurrent.ThreadLocalRandom; - @Component @RequiredArgsConstructor public class InitSubscriptionPlanRunner implements CommandLineRunner { - private final PlanChangeProducer planChangeProducer; - - private static final int SUB_START = 1; - private static final int SUB_END = 10_000; + private final PlanChangeProducer planChangeProducer; - @Override - public void run(String... args) { + private static final int SUB_START = 1; + private static final int SUB_END = 10_000; - OffsetDateTime baseTime = OffsetDateTime.now() - .withDayOfMonth(1) - .withHour(0) - .withMinute(0) - .withSecond(0); + @Override + public void run(String... args) { - PlanSeed[] plans = PlanSeed.values(); - ThreadLocalRandom random = ThreadLocalRandom.current(); + OffsetDateTime baseTime = + OffsetDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0); - for (long subId = SUB_START; subId <= SUB_END; subId++) { + PlanSeed[] plans = PlanSeed.values(); + ThreadLocalRandom random = ThreadLocalRandom.current(); - PlanSeed plan = plans[random.nextInt(plans.length)]; + for (long subId = SUB_START; subId <= SUB_END; subId++) { - planChangeProducer.sendPlanChangeEvent( - subId, - plan.getUnit(), - plan.getAllowance(), - baseTime, - "user" + subId + "@test.com", - "010-" + String.format("%04d-%04d", - random.nextInt(10000), - random.nextInt(10000)) - ); - } + PlanSeed plan = plans[random.nextInt(plans.length)]; - System.out.println("✅ Initial Plan Seeding Completed (1 ~ 10000)"); + planChangeProducer.sendPlanChangeEvent( + subId, + plan.getUnit(), + plan.getAllowance(), + baseTime, + "user" + subId + "@test.com", + "010-" + String.format("%04d-%04d", random.nextInt(10000), random.nextInt(10000))); } + + System.out.println("✅ Initial Plan Seeding Completed (1 ~ 10000)"); + } } diff --git a/src/main/java/com/project/producer/test/PlanSeed.java b/src/main/java/com/project/producer/test/PlanSeed.java index 43662ab..43818e9 100644 --- a/src/main/java/com/project/producer/test/PlanSeed.java +++ b/src/main/java/com/project/producer/test/PlanSeed.java @@ -6,14 +6,13 @@ @Getter @AllArgsConstructor public enum PlanSeed { + FIVE_G_SIGNATURE("5G 시그니처", -1, "ULTIMATE"), + FIVE_G_STANDARD("5G 스탠다드", 153600, "MONTH"), + FIVE_G_BASIC_PLUS("5G 베이직+", 24576, "MONTH"), + LTE_33("LTE 데이터 33", 1536, "MONTH"), + LTE_DIRECT_45("LTE 다이렉트 45", 5120, "DAY"); - FIVE_G_SIGNATURE("5G 시그니처", -1, "ULTIMATE"), - FIVE_G_STANDARD("5G 스탠다드", 153600, "MONTH"), - FIVE_G_BASIC_PLUS("5G 베이직+", 24576, "MONTH"), - LTE_33("LTE 데이터 33", 1536, "MONTH"), - LTE_DIRECT_45("LTE 다이렉트 45", 5120, "DAY"); - - private final String name; - private final long allowance; - private final String unit; + private final String name; + private final long allowance; + private final String unit; } diff --git a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java index 6aa8147..1744041 100644 --- a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java +++ b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java @@ -1,79 +1,75 @@ package com.project.producer.test; import com.project.producer.UsageProducer; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; - import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class UsageBurstLoadRunner implements CommandLineRunner { - private final UsageProducer usageProducer; + private final UsageProducer usageProducer; - private static final int EVENTS_PER_SECOND = 10_000; - private static final int DURATION_SECONDS = 60; - private static final int THREADS = 8; + private static final int EVENTS_PER_SECOND = 10_000; + private static final int DURATION_SECONDS = 60; + private static final int THREADS = 8; - @Override - public void run(String... args) throws Exception { + @Override + public void run(String... args) throws Exception { - ExecutorService executor = Executors.newFixedThreadPool(THREADS); - AtomicInteger sent = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(THREADS); + AtomicInteger sent = new AtomicInteger(); - int perThreadRate = EVENTS_PER_SECOND / THREADS; + int perThreadRate = EVENTS_PER_SECOND / THREADS; - long start = System.currentTimeMillis(); + long start = System.currentTimeMillis(); - for (int t = 0; t < THREADS; t++) { - executor.submit(() -> { - ThreadLocalRandom random = ThreadLocalRandom.current(); + for (int t = 0; t < THREADS; t++) { + executor.submit( + () -> { + ThreadLocalRandom random = ThreadLocalRandom.current(); - long endTime = System.currentTimeMillis() + DURATION_SECONDS * 1000L; + long endTime = System.currentTimeMillis() + DURATION_SECONDS * 1000L; - while (System.currentTimeMillis() < endTime) { - long secondStart = System.nanoTime(); + while (System.currentTimeMillis() < endTime) { + long secondStart = System.nanoTime(); - for (int i = 0; i < perThreadRate; i++) { - long subId = random.nextLong(1, 1_000_001); - long bytes = random.nextLong(200, 5_000); + for (int i = 0; i < perThreadRate; i++) { + long subId = random.nextLong(1, 1_000_001); + long bytes = random.nextLong(200, 5_000); - usageProducer.sendUsageEvent( - subId, - bytes, - null, - 0 - ); + usageProducer.sendUsageEvent(subId, bytes, null, 0); - sent.incrementAndGet(); - } + sent.incrementAndGet(); + } - long elapsedNs = System.nanoTime() - secondStart; - long sleepMs = 1000 - (elapsedNs / 1_000_000); + long elapsedNs = System.nanoTime() - secondStart; + long sleepMs = 1000 - (elapsedNs / 1_000_000); - if (sleepMs > 0) { - try { - Thread.sleep(sleepMs); - } catch (InterruptedException ignored) {} - } + if (sleepMs > 0) { + try { + Thread.sleep(sleepMs); + } catch (InterruptedException ignored) { } - }); - } + } + } + }); + } - executor.shutdown(); - executor.awaitTermination(DURATION_SECONDS + 10, TimeUnit.SECONDS); + executor.shutdown(); + executor.awaitTermination(DURATION_SECONDS + 10, TimeUnit.SECONDS); - long elapsed = System.currentTimeMillis() - start; + long elapsed = System.currentTimeMillis() - start; - System.out.println("✅ DONE"); - System.out.println("Total events sent = " + sent.get()); - System.out.println("Elapsed ms = " + elapsed); - System.out.println("Avg TPS = " + (sent.get() * 1000L / elapsed)); - } -} \ No newline at end of file + System.out.println("✅ DONE"); + System.out.println("Total events sent = " + sent.get()); + System.out.println("Elapsed ms = " + elapsed); + System.out.println("Avg TPS = " + (sent.get() * 1000L / elapsed)); + } +} From e417b8203663d231536f628e923284f7f1aea16f Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:49:42 +0900 Subject: [PATCH 15/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20checkStyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/constant/UsageBatchLuaConstant.java | 5 ++--- .../com/project/consumer/util/PlanChangeUtil.java | 14 +++++++------- .../java/com/project/consumer/util/RedisUtil.java | 6 ++++-- .../com/project/consumer/util/UsageTimeUtil.java | 4 +++- .../producer/schema/CalculatedLimitSchema.java | 2 +- .../producer/test/UsageBurstLoadRunner.java | 3 ++- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java index 23df4a2..0b1d849 100644 --- a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java +++ b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java @@ -4,8 +4,7 @@ public final class UsageBatchLuaConstant { private UsageBatchLuaConstant() {} - public static final String LUA = - """ + public static final String LUA = """ -- ARGV: -- [1] N 이후 반복 N개: -- timeKey, subId, eventId, bytes, ts, ttlSec @@ -83,5 +82,5 @@ private UsageBatchLuaConstant() {} end end return out - """; + """; } diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index c221837..542986c 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -26,15 +26,15 @@ public List calculate(List events) { List results = new ArrayList<>(); - String yyyyMM; + String yearMonth; long finalLimit; String processedKey; Long added; for (PlanChangeSchema event : events) { - yyyyMM = toYyyyMM(event.changedAt().toString()); + yearMonth = toYyyyMM(event.changedAt().toString()); - processedKey = "processed:plan:" + yyyyMM + ":" + event.subscriptionId(); + processedKey = "processed:plan:" + yearMonth + ":" + event.subscriptionId(); added = redisTemplate.opsForSet().add(processedKey, event.eventId()); redisTemplate.expire( @@ -55,14 +55,14 @@ public List calculate(List events) { } else if ("DAY".equals(event.unit())) { finalLimit = event.allowanceAmount(); } else { - finalLimit = getMonthFinalLimit(yyyyMM, event, prevUnit); + finalLimit = getMonthFinalLimit(yearMonth, event, prevUnit); } long ttlSec = UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2); results.add( new CalculatedLimitSchema( - event.subscriptionId(), yyyyMM, finalLimit, ttlSec, event.unit())); + event.subscriptionId(), yearMonth, finalLimit, ttlSec, event.unit())); } return results; @@ -74,7 +74,7 @@ private long getPreviousLimit(String key) { } // 사용자가 월 기준 중도에 요금제를 변경했을 경우 사용 가능 데이터 집계 로직 - private long getMonthFinalLimit(String yyyyMM, PlanChangeSchema event, String prevUnit) { + private long getMonthFinalLimit(String yearMonth, PlanChangeSchema event, String prevUnit) { OffsetDateTime kstTime = event.changedAt().atZoneSameInstant(ZoneId.of("Asia/Seoul")).toOffsetDateTime(); @@ -90,7 +90,7 @@ private long getMonthFinalLimit(String yyyyMM, PlanChangeSchema event, String pr return event.allowanceAmount() * daysAfter / totalDays; } - String limitKey = "limit:" + yyyyMM + ":" + event.subscriptionId(); + String limitKey = "limit:" + yearMonth + ":" + event.subscriptionId(); long prevLimit = getPreviousLimit(limitKey); diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index 49c0cd4..fb79e26 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -22,7 +22,9 @@ public class RedisUtil { new DefaultRedisScript<>(UsageBatchLuaConstant.LUA, List.class); public List applyUsageBatch(List events) { - if (events == null || events.isEmpty()) return List.of(); + if (events == null || events.isEmpty()) { + return List.of(); + } List args = new ArrayList<>(); args.add(String.valueOf(events.size())); @@ -44,7 +46,7 @@ public void writePlanChangeBatch(List limits) { (RedisCallback) connection -> { for (CalculatedLimitSchema limit : limits) { - String key = "limit:" + limit.yyyyMM() + ":" + limit.subscriptionId(); + String key = "limit:" + limit.yearMonth() + ":" + limit.subscriptionId(); byte[] k = redisTemplate.getStringSerializer().serialize(key); byte[] v = diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index 9b129f5..ccab757 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -1,6 +1,8 @@ package com.project.consumer.util; -import java.time.*; +import java.time.OffsetDateTime; +import java.time.Duration; +import java.time.LocalTime; import java.time.format.DateTimeFormatter; public class UsageTimeUtil { diff --git a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java index 5c6f652..802601a 100644 --- a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java +++ b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java @@ -1,4 +1,4 @@ package com.project.producer.schema; public record CalculatedLimitSchema( - long subscriptionId, String yyyyMM, long limit, long ttlSec, String unit) {} + long subscriptionId, String yearMonth, long limit, long ttlSec, String unit) {} diff --git a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java index 1744041..c211e1a 100644 --- a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java +++ b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java @@ -28,7 +28,7 @@ public void run(String... args) throws Exception { int perThreadRate = EVENTS_PER_SECOND / THREADS; - long start = System.currentTimeMillis(); + final long start = System.currentTimeMillis(); for (int t = 0; t < THREADS; t++) { executor.submit( @@ -56,6 +56,7 @@ public void run(String... args) throws Exception { try { Thread.sleep(sleepMs); } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); } } } From d287357b19c2d25fd204b7b6039f1646bcd80448 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:53:47 +0900 Subject: [PATCH 16/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20checkStyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/consumer/constant/UsageBatchLuaConstant.java | 3 ++- src/main/java/com/project/consumer/util/UsageTimeUtil.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java index 0b1d849..f15ceec 100644 --- a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java +++ b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java @@ -4,7 +4,8 @@ public final class UsageBatchLuaConstant { private UsageBatchLuaConstant() {} - public static final String LUA = """ + public static final String LUA = + """ -- ARGV: -- [1] N 이후 반복 N개: -- timeKey, subId, eventId, bytes, ts, ttlSec diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index ccab757..d2d58f2 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -1,8 +1,8 @@ package com.project.consumer.util; -import java.time.OffsetDateTime; import java.time.Duration; import java.time.LocalTime; +import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; public class UsageTimeUtil { From 85d5817f7b601488b0cc2e081287125ee69156c9 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 00:56:20 +0900 Subject: [PATCH 17/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20checkStyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/producer/UsageProducer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/project/producer/UsageProducer.java b/src/main/java/com/project/producer/UsageProducer.java index d13e712..b60301f 100644 --- a/src/main/java/com/project/producer/UsageProducer.java +++ b/src/main/java/com/project/producer/UsageProducer.java @@ -15,14 +15,14 @@ public class UsageProducer { private final KafkaTemplate kafkaTemplate; private final ObjectMapper objectMapper; - public void sendUsageEvent(long subscriptionId, long usageBytes, String yyyyMM, long ttlSec) { + public void sendUsageEvent(long subscriptionId, long usageBytes, String yearMonth, long ttlSec) { UsageEventSchema schema = new UsageEventSchema( UUID.randomUUID().toString(), subscriptionId, usageBytes, OffsetDateTime.now().toString(), - yyyyMM, + yearMonth, ttlSec); try { From 71b68c556006df1f0aeeb48100ee2baa11fdb6e4 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 01:00:18 +0900 Subject: [PATCH 18/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20checkStyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/PlanChangeUtil.java | 4 ++-- src/main/java/com/project/consumer/util/UsageTimeUtil.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index 542986c..a42613c 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -1,6 +1,6 @@ package com.project.consumer.util; -import static com.project.consumer.util.UsageTimeUtil.toYyyyMM; +import static com.project.consumer.util.UsageTimeUtil.toYearMonth; import com.project.producer.schema.CalculatedLimitSchema; import com.project.producer.schema.PlanChangeSchema; @@ -32,7 +32,7 @@ public List calculate(List events) { Long added; for (PlanChangeSchema event : events) { - yearMonth = toYyyyMM(event.changedAt().toString()); + yearMonth = toYearMonth(event.changedAt().toString()); processedKey = "processed:plan:" + yearMonth + ":" + event.subscriptionId(); added = redisTemplate.opsForSet().add(processedKey, event.eventId()); diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index d2d58f2..4eebfe1 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -8,7 +8,7 @@ public class UsageTimeUtil { private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - public static String toYyyyMM(String isoTs) { + public static String toYearMonth(String isoTs) { OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); } From 68d5010fc2be66474674949e1b8b5d7113c5fc42 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 01:06:42 +0900 Subject: [PATCH 19/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20checkStyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constant/UsageBatchLuaConstant.java | 156 +++++++++--------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java index f15ceec..b98cb4a 100644 --- a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java +++ b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java @@ -5,83 +5,83 @@ public final class UsageBatchLuaConstant { private UsageBatchLuaConstant() {} public static final String LUA = - """ - -- ARGV: - -- [1] N 이후 반복 N개: - -- timeKey, subId, eventId, bytes, ts, ttlSec - - local out = {} - local n = tonumber(ARGV[1]) - local idx = 2 - - - for i = 1, n do - local subId = ARGV[idx]; idx = idx + 1 - local eventId = ARGV[idx]; idx = idx + 1 - local bytes = tonumber(ARGV[idx]); idx = idx + 1 - local ts = ARGV[idx]; idx = idx + 1 - - local monthKeyTime = string.sub(ts, 1, 7):gsub('-', '') - local limitKey = 'limit:' .. monthKeyTime .. ':' .. subId - - local unit = redis.call('GET', 'plan:unit:' .. subId) or 'MONTH' - - local usageKeyTime - local thKeyTime - local ttlSec - - if unit == 'DAY' then - usageKeyTime = string.sub(ts, 1, 10):gsub('-', '') - thKeyTime = usageKeyTime - ttlSec = 86400 * 2 - else - usageKeyTime = monthKeyTime - thKeyTime = monthKeyTime - ttlSec = redis.call('TTL', limitKey) - if ttlSec < 0 then ttlSec = 86400 * 35 end - end - - local usageKey = 'usage:' .. usageKeyTime .. ':' .. subId - local processedKey = 'processed:usage:' .. usageKeyTime .. ':' .. subId - local thKey = 'th:' .. thKeyTime .. ':' .. subId - - -- dedup: 이미 처리된 이벤트면 아래 로직을 아예 실행 안 함 - if redis.call('SISMEMBER', processedKey, eventId) == 0 then - redis.call('SADD', processedKey, eventId) - if redis.call('TTL', processedKey) < 0 then - redis.call('EXPIRE', processedKey, ttlSec) - end - - local newTotal = redis.call('INCRBY', usageKey, bytes) - if redis.call('TTL', usageKey) < 0 then - redis.call('EXPIRE', usageKey, ttlSec) - end - - local limit = tonumber(redis.call('GET', limitKey) or '0') - if limit > 0 then - local percent = math.floor((newTotal * 100) / limit) - - local prev = tonumber(redis.call('GET', thKey) or '0') - local next = prev - - if percent >= 100 and prev < 100 then next = 100 - elseif percent >= 80 and prev < 80 then next = 80 - elseif percent >= 50 and prev < 50 then next = 50 - end - - if next ~= prev then - redis.call('SET', thKey, tostring(next)) - if redis.call('TTL', thKey) < 0 then - redis.call('EXPIRE', thKey, ttlSec) - end - - table.insert(out, - thKeyTime .. '|' .. subId .. '|' .. next .. '|' .. percent .. '|' .. - newTotal .. '|' .. limit .. '|' .. eventId .. '|' .. ts) - end - end - end - end - return out + """ + -- ARGV: + -- [1] N 이후 반복 N개: + -- timeKey, subId, eventId, bytes, ts, ttlSec + + local out = {} + local n = tonumber(ARGV[1]) + local idx = 2 + + + for i = 1, n do + local subId = ARGV[idx]; idx = idx + 1 + local eventId = ARGV[idx]; idx = idx + 1 + local bytes = tonumber(ARGV[idx]); idx = idx + 1 + local ts = ARGV[idx]; idx = idx + 1 + + local monthKeyTime = string.sub(ts, 1, 7):gsub('-', '') + local limitKey = 'limit:' .. monthKeyTime .. ':' .. subId + + local unit = redis.call('GET', 'plan:unit:' .. subId) or 'MONTH' + + local usageKeyTime + local thKeyTime + local ttlSec + + if unit == 'DAY' then + usageKeyTime = string.sub(ts, 1, 10):gsub('-', '') + thKeyTime = usageKeyTime + ttlSec = 86400 * 2 + else + usageKeyTime = monthKeyTime + thKeyTime = monthKeyTime + ttlSec = redis.call('TTL', limitKey) + if ttlSec < 0 then ttlSec = 86400 * 35 end + end + + local usageKey = 'usage:' .. usageKeyTime .. ':' .. subId + local processedKey = 'processed:usage:' .. usageKeyTime .. ':' .. subId + local thKey = 'th:' .. thKeyTime .. ':' .. subId + + -- dedup: 이미 처리된 이벤트면 아래 로직을 아예 실행 안 함 + if redis.call('SISMEMBER', processedKey, eventId) == 0 then + redis.call('SADD', processedKey, eventId) + if redis.call('TTL', processedKey) < 0 then + redis.call('EXPIRE', processedKey, ttlSec) + end + + local newTotal = redis.call('INCRBY', usageKey, bytes) + if redis.call('TTL', usageKey) < 0 then + redis.call('EXPIRE', usageKey, ttlSec) + end + + local limit = tonumber(redis.call('GET', limitKey) or '0') + if limit > 0 then + local percent = math.floor((newTotal * 100) / limit) + + local prev = tonumber(redis.call('GET', thKey) or '0') + local next = prev + + if percent >= 100 and prev < 100 then next = 100 + elseif percent >= 80 and prev < 80 then next = 80 + elseif percent >= 50 and prev < 50 then next = 50 + end + + if next ~= prev then + redis.call('SET', thKey, tostring(next)) + if redis.call('TTL', thKey) < 0 then + redis.call('EXPIRE', thKey, ttlSec) + end + + table.insert(out, + thKeyTime .. '|' .. subId .. '|' .. next .. '|' .. percent .. '|' .. + newTotal .. '|' .. limit .. '|' .. eventId .. '|' .. ts) + end + end + end + end + return out """; } From a8341d135e9d0340788fd7ddbd625002989c8d6e Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 01:06:56 +0900 Subject: [PATCH 20/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20checkStyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/consumer/constant/UsageBatchLuaConstant.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java index b98cb4a..6851428 100644 --- a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java +++ b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java @@ -83,5 +83,5 @@ private UsageBatchLuaConstant() {} end end return out - """; + """; } From 0e8a253439580d7cad38927fdeb2cfedfebb1514 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 01:16:22 +0900 Subject: [PATCH 21/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20checkStyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/consumer/constant/UsageBatchLuaConstant.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java index 6851428..e87cb19 100644 --- a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java +++ b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java @@ -6,8 +6,7 @@ private UsageBatchLuaConstant() {} public static final String LUA = """ - -- ARGV: - -- [1] N 이후 반복 N개: + -- ARGV:[1] N 이후 반복 N개: -- timeKey, subId, eventId, bytes, ts, ttlSec local out = {} From 66674027385663cb2270092668b6efbdb0fad0e9 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 01:24:14 +0900 Subject: [PATCH 22/33] =?UTF-8?q?UPLUS-16=20feat=20:=20Lua=20Script=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=EC=9D=98=20=EC=A0=95=EC=A0=81=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constant/UsageBatchLuaConstant.java | 86 ------------------- .../consumer/util/LuaScriptLoader.java | 24 ++++++ .../com/project/consumer/util/RedisUtil.java | 5 +- src/main/resources/lua/usage_batch.lua | 75 ++++++++++++++++ 4 files changed, 103 insertions(+), 87 deletions(-) delete mode 100644 src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java create mode 100644 src/main/java/com/project/consumer/util/LuaScriptLoader.java create mode 100644 src/main/resources/lua/usage_batch.lua diff --git a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java b/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java deleted file mode 100644 index e87cb19..0000000 --- a/src/main/java/com/project/consumer/constant/UsageBatchLuaConstant.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.project.consumer.constant; - -public final class UsageBatchLuaConstant { - - private UsageBatchLuaConstant() {} - - public static final String LUA = - """ - -- ARGV:[1] N 이후 반복 N개: - -- timeKey, subId, eventId, bytes, ts, ttlSec - - local out = {} - local n = tonumber(ARGV[1]) - local idx = 2 - - - for i = 1, n do - local subId = ARGV[idx]; idx = idx + 1 - local eventId = ARGV[idx]; idx = idx + 1 - local bytes = tonumber(ARGV[idx]); idx = idx + 1 - local ts = ARGV[idx]; idx = idx + 1 - - local monthKeyTime = string.sub(ts, 1, 7):gsub('-', '') - local limitKey = 'limit:' .. monthKeyTime .. ':' .. subId - - local unit = redis.call('GET', 'plan:unit:' .. subId) or 'MONTH' - - local usageKeyTime - local thKeyTime - local ttlSec - - if unit == 'DAY' then - usageKeyTime = string.sub(ts, 1, 10):gsub('-', '') - thKeyTime = usageKeyTime - ttlSec = 86400 * 2 - else - usageKeyTime = monthKeyTime - thKeyTime = monthKeyTime - ttlSec = redis.call('TTL', limitKey) - if ttlSec < 0 then ttlSec = 86400 * 35 end - end - - local usageKey = 'usage:' .. usageKeyTime .. ':' .. subId - local processedKey = 'processed:usage:' .. usageKeyTime .. ':' .. subId - local thKey = 'th:' .. thKeyTime .. ':' .. subId - - -- dedup: 이미 처리된 이벤트면 아래 로직을 아예 실행 안 함 - if redis.call('SISMEMBER', processedKey, eventId) == 0 then - redis.call('SADD', processedKey, eventId) - if redis.call('TTL', processedKey) < 0 then - redis.call('EXPIRE', processedKey, ttlSec) - end - - local newTotal = redis.call('INCRBY', usageKey, bytes) - if redis.call('TTL', usageKey) < 0 then - redis.call('EXPIRE', usageKey, ttlSec) - end - - local limit = tonumber(redis.call('GET', limitKey) or '0') - if limit > 0 then - local percent = math.floor((newTotal * 100) / limit) - - local prev = tonumber(redis.call('GET', thKey) or '0') - local next = prev - - if percent >= 100 and prev < 100 then next = 100 - elseif percent >= 80 and prev < 80 then next = 80 - elseif percent >= 50 and prev < 50 then next = 50 - end - - if next ~= prev then - redis.call('SET', thKey, tostring(next)) - if redis.call('TTL', thKey) < 0 then - redis.call('EXPIRE', thKey, ttlSec) - end - - table.insert(out, - thKeyTime .. '|' .. subId .. '|' .. next .. '|' .. percent .. '|' .. - newTotal .. '|' .. limit .. '|' .. eventId .. '|' .. ts) - end - end - end - end - return out - """; -} diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java new file mode 100644 index 0000000..0ce5232 --- /dev/null +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -0,0 +1,24 @@ +package com.project.consumer.util; + +import org.springframework.stereotype.Component; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +@Component +public class LuaScriptLoader { + + public static String load(String path) { + try (InputStream is = + LuaScriptLoader.class.getClassLoader().getResourceAsStream(path)) { + + if (is == null) { + throw new IllegalStateException("Lua script not found: " + path); + } + + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("Failed to load lua script", e); + } + } +} diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index fb79e26..aa5517f 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -19,7 +19,10 @@ public class RedisUtil { private final StringRedisTemplate redisTemplate; private final DefaultRedisScript script = - new DefaultRedisScript<>(UsageBatchLuaConstant.LUA, List.class); + new DefaultRedisScript<>( + LuaScriptLoader.load("lua/usage_batch.lua"), + List.class + ); public List applyUsageBatch(List events) { if (events == null || events.isEmpty()) { diff --git a/src/main/resources/lua/usage_batch.lua b/src/main/resources/lua/usage_batch.lua new file mode 100644 index 0000000..569ed5a --- /dev/null +++ b/src/main/resources/lua/usage_batch.lua @@ -0,0 +1,75 @@ +-- ARGV: [1] N 이후 반복 N개: +-- timeKey, subId, eventId, bytes, ts, ttlSec + +local out = {} +local n = tonumber(ARGV[1]) +local idx = 2 + +for i = 1, n do + local subId = ARGV[idx]; idx = idx + 1 + local eventId = ARGV[idx]; idx = idx + 1 + local bytes = tonumber(ARGV[idx]); idx = idx + 1 + local ts = ARGV[idx]; idx = idx + 1 + + local monthKeyTime = string.sub(ts, 1, 7):gsub('-', '') + local limitKey = 'limit:' .. monthKeyTime .. ':' .. subId + + local unit = redis.call('GET', 'plan:unit:' .. subId) or 'MONTH' + + local usageKeyTime + local thKeyTime + local ttlSec + + if unit == 'DAY' then + usageKeyTime = string.sub(ts, 1, 10):gsub('-', '') + thKeyTime = usageKeyTime + ttlSec = 86400 * 2 + else + usageKeyTime = monthKeyTime + thKeyTime = monthKeyTime + ttlSec = redis.call('TTL', limitKey) + if ttlSec < 0 then ttlSec = 86400 * 35 end + end + + local usageKey = 'usage:' .. usageKeyTime .. ':' .. subId + local processedKey = 'processed:usage:' .. usageKeyTime .. ':' .. subId + local thKey = 'th:' .. thKeyTime .. ':' .. subId + + if redis.call('SISMEMBER', processedKey, eventId) == 0 then + redis.call('SADD', processedKey, eventId) + if redis.call('TTL', processedKey) < 0 then + redis.call('EXPIRE', processedKey, ttlSec) + end + + local newTotal = redis.call('INCRBY', usageKey, bytes) + if redis.call('TTL', usageKey) < 0 then + redis.call('EXPIRE', usageKey, ttlSec) + end + + local limit = tonumber(redis.call('GET', limitKey) or '0') + if limit > 0 then + local percent = math.floor((newTotal * 100) / limit) + + local prev = tonumber(redis.call('GET', thKey) or '0') + local next = prev + + if percent >= 100 and prev < 100 then next = 100 + elseif percent >= 80 and prev < 80 then next = 80 + elseif percent >= 50 and prev < 50 then next = 50 + end + + if next ~= prev then + redis.call('SET', thKey, tostring(next)) + if redis.call('TTL', thKey) < 0 then + redis.call('EXPIRE', thKey, ttlSec) + end + + table.insert(out, + thKeyTime .. '|' .. subId .. '|' .. next .. '|' .. percent .. '|' .. + newTotal .. '|' .. limit .. '|' .. eventId .. '|' .. ts) + end + end + end +end + +return out \ No newline at end of file From 38b1e54a106aaa8e9a86a7f135e2349ec976d32e Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 01:28:58 +0900 Subject: [PATCH 23/33] =?UTF-8?q?UPLUS-16=20feat=20:=20spotlessApply=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/util/LuaScriptLoader.java | 22 +++++++++---------- .../com/project/consumer/util/RedisUtil.java | 6 +---- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index 0ce5232..f152e7e 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -1,24 +1,22 @@ package com.project.consumer.util; -import org.springframework.stereotype.Component; - import java.io.InputStream; import java.nio.charset.StandardCharsets; +import org.springframework.stereotype.Component; @Component public class LuaScriptLoader { - public static String load(String path) { - try (InputStream is = - LuaScriptLoader.class.getClassLoader().getResourceAsStream(path)) { + public static String load(String path) { + try (InputStream is = LuaScriptLoader.class.getClassLoader().getResourceAsStream(path)) { - if (is == null) { - throw new IllegalStateException("Lua script not found: " + path); - } + if (is == null) { + throw new IllegalStateException("Lua script not found: " + path); + } - return new String(is.readAllBytes(), StandardCharsets.UTF_8); - } catch (Exception e) { - throw new RuntimeException("Failed to load lua script", e); - } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("Failed to load lua script", e); } + } } diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index aa5517f..47f165e 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -1,6 +1,5 @@ package com.project.consumer.util; -import com.project.consumer.constant.UsageBatchLuaConstant; import com.project.producer.schema.CalculatedLimitSchema; import com.project.producer.schema.UsageEventSchema; import java.util.ArrayList; @@ -19,10 +18,7 @@ public class RedisUtil { private final StringRedisTemplate redisTemplate; private final DefaultRedisScript script = - new DefaultRedisScript<>( - LuaScriptLoader.load("lua/usage_batch.lua"), - List.class - ); + new DefaultRedisScript<>(LuaScriptLoader.load("lua/usage_batch.lua"), List.class); public List applyUsageBatch(List events) { if (events == null || events.isEmpty()) { From e96770b7cfc146f7eef6532e19f3e769e7edb5ff Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 16 Jan 2026 01:33:10 +0900 Subject: [PATCH 24/33] =?UTF-8?q?UPLUS-16=20feat=20:=20CI=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/lua/usage_batch.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/resources/lua/usage_batch.lua b/src/main/resources/lua/usage_batch.lua index 569ed5a..0a5e401 100644 --- a/src/main/resources/lua/usage_batch.lua +++ b/src/main/resources/lua/usage_batch.lua @@ -1,6 +1,3 @@ --- ARGV: [1] N 이후 반복 N개: --- timeKey, subId, eventId, bytes, ts, ttlSec - local out = {} local n = tonumber(ARGV[1]) local idx = 2 From 74055eb8a04a986243855e583985618a32600d56 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:22:36 +0900 Subject: [PATCH 25/33] =?UTF-8?q?UPLUS-16=20fix=20:=20splotless=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/consumer/PlanChangeConsumer.java | 69 +++++----- .../com/project/consumer/UsageConsumer.java | 78 ++++++----- .../consumer/util/LuaScriptLoader.java | 19 +-- .../project/consumer/util/PlanChangeUtil.java | 129 +++++++++--------- .../com/project/consumer/util/RedisUtil.java | 97 +++++++------ .../project/consumer/util/UsageTimeUtil.java | 25 ++-- .../project/global/config/KafkaConfig.java | 50 +++---- .../code/domain/GlobalErrorCode.java | 29 ++-- .../producer/NotificationProducer.java | 11 +- .../project/producer/PlanChangeProducer.java | 67 ++++----- .../com/project/producer/UsageProducer.java | 54 ++++---- .../schema/CalculatedLimitSchema.java | 2 +- .../producer/schema/PlanChangeSchema.java | 14 +- .../producer/schema/UsageEventSchema.java | 12 +- .../test/InitSubscriptionPlanRunner.java | 51 +++---- .../com/project/producer/test/PlanSeed.java | 16 +-- .../producer/test/UsageBurstLoadRunner.java | 93 +++++++------ 17 files changed, 427 insertions(+), 389 deletions(-) diff --git a/src/main/java/com/project/consumer/PlanChangeConsumer.java b/src/main/java/com/project/consumer/PlanChangeConsumer.java index 5c6ef55..0615417 100644 --- a/src/main/java/com/project/consumer/PlanChangeConsumer.java +++ b/src/main/java/com/project/consumer/PlanChangeConsumer.java @@ -1,5 +1,13 @@ package com.project.consumer; +import java.util.ArrayList; +import java.util.List; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + import com.fasterxml.jackson.databind.ObjectMapper; import com.project.consumer.util.PlanChangeUtil; import com.project.consumer.util.RedisUtil; @@ -7,44 +15,39 @@ import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.schema.CalculatedLimitSchema; import com.project.producer.schema.PlanChangeSchema; -import java.util.ArrayList; -import java.util.List; + import lombok.RequiredArgsConstructor; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class PlanChangeConsumer { - private final ObjectMapper objectMapper; - private final PlanChangeUtil planChangeUtil; - private final RedisUtil redisUtil; - - @KafkaListener( - id = "plan-change-consumer", - topics = "change_plan", - groupId = "plan-change-consumer", - containerFactory = "batchKafkaListenerContainerFactory") - public void consume(List> records, Acknowledgment ack) { - if (records == null || records.isEmpty()) { - ack.acknowledge(); - return; - } - List events = new ArrayList<>(records.size()); - try { - for (ConsumerRecord record : records) { - events.add(objectMapper.readValue(record.value(), PlanChangeSchema.class)); - } - - List limits = planChangeUtil.calculate(events); - redisUtil.writePlanChangeBatch(limits); - - ack.acknowledge(); - } catch (Exception e) { - throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); + private final ObjectMapper objectMapper; + private final PlanChangeUtil planChangeUtil; + private final RedisUtil redisUtil; + + @KafkaListener( + id = "plan-change-consumer", + topics = "change_plan", + groupId = "plan-change-consumer", + containerFactory = "batchKafkaListenerContainerFactory") + public void consume(List> records, Acknowledgment ack) { + if (records == null || records.isEmpty()) { + ack.acknowledge(); + return; + } + List events = new ArrayList<>(records.size()); + try { + for (ConsumerRecord record : records) { + events.add(objectMapper.readValue(record.value(), PlanChangeSchema.class)); + } + + List limits = planChangeUtil.calculate(events); + redisUtil.writePlanChangeBatch(limits); + + ack.acknowledge(); + } catch (Exception e) { + throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); + } } - } } diff --git a/src/main/java/com/project/consumer/UsageConsumer.java b/src/main/java/com/project/consumer/UsageConsumer.java index 705d8af..f8291e0 100644 --- a/src/main/java/com/project/consumer/UsageConsumer.java +++ b/src/main/java/com/project/consumer/UsageConsumer.java @@ -1,63 +1,67 @@ package com.project.consumer; +import java.util.ArrayList; +import java.util.List; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + import com.fasterxml.jackson.databind.ObjectMapper; import com.project.consumer.util.RedisUtil; import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.NotificationProducer; import com.project.producer.schema.UsageEventSchema; -import java.util.ArrayList; -import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor public class UsageConsumer { - private final ObjectMapper objectMapper; - private final RedisUtil redisUtil; - private final NotificationProducer notificationProducer; + private final ObjectMapper objectMapper; + private final RedisUtil redisUtil; + private final NotificationProducer notificationProducer; - @KafkaListener( - id = "usage-batch-consumer", - topics = "usage-data", - groupId = "usage-consumer", - containerFactory = "batchKafkaListenerContainerFactory") - public void consume(List> records, Acknowledgment ack) { + @KafkaListener( + id = "usage-batch-consumer", + topics = "usage-data", + groupId = "usage-consumer", + containerFactory = "batchKafkaListenerContainerFactory") + public void consume(List> records, Acknowledgment ack) { - if (records == null || records.isEmpty()) { - ack.acknowledge(); - return; - } + if (records == null || records.isEmpty()) { + ack.acknowledge(); + return; + } - List events = new ArrayList<>(records.size()); + List events = new ArrayList<>(records.size()); - try { + try { - for (ConsumerRecord rec : records) { - UsageEventSchema schema = objectMapper.readValue(rec.value(), UsageEventSchema.class); - events.add(schema); - } + for (ConsumerRecord rec : records) { + UsageEventSchema schema = + objectMapper.readValue(rec.value(), UsageEventSchema.class); + events.add(schema); + } - List notifications = redisUtil.applyUsageBatch(events); + List notifications = redisUtil.applyUsageBatch(events); - // 임계치 넘은 것만 notification_topic으로 발행 - for (String notification : notifications) { - notificationProducer.sendNotification(notification); - } + // 임계치 넘은 것만 notification_topic으로 발행 + for (String notification : notifications) { + notificationProducer.sendNotification(notification); + } - // 성공한 배치 ack - ack.acknowledge(); - } catch (Exception e) { - log.error("usage batch failed", e); - // 여기서 ack 안 하면 같은 배치가 재시도됨 (at-least-once) - throw new ApplicationException(GlobalErrorCode.NOTIFICATION_EVENT_PRODUCE_INVALID); + // 성공한 배치 ack + ack.acknowledge(); + } catch (Exception e) { + log.error("usage batch failed", e); + // 여기서 ack 안 하면 같은 배치가 재시도됨 (at-least-once) + throw new ApplicationException(GlobalErrorCode.NOTIFICATION_EVENT_PRODUCE_INVALID); + } } - } } diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index f152e7e..b09665d 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -2,21 +2,22 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; + import org.springframework.stereotype.Component; @Component public class LuaScriptLoader { - public static String load(String path) { - try (InputStream is = LuaScriptLoader.class.getClassLoader().getResourceAsStream(path)) { + public static String load(String path) { + try (InputStream is = LuaScriptLoader.class.getClassLoader().getResourceAsStream(path)) { - if (is == null) { - throw new IllegalStateException("Lua script not found: " + path); - } + if (is == null) { + throw new IllegalStateException("Lua script not found: " + path); + } - return new String(is.readAllBytes(), StandardCharsets.UTF_8); - } catch (Exception e) { - throw new RuntimeException("Failed to load lua script", e); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException("Failed to load lua script", e); + } } - } } diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index a42613c..1f549f9 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -2,101 +2,106 @@ import static com.project.consumer.util.UsageTimeUtil.toYearMonth; -import com.project.producer.schema.CalculatedLimitSchema; -import com.project.producer.schema.PlanChangeSchema; import java.time.Duration; import java.time.OffsetDateTime; import java.time.YearMonth; import java.time.ZoneId; import java.util.ArrayList; import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; +import com.project.producer.schema.CalculatedLimitSchema; +import com.project.producer.schema.PlanChangeSchema; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j @Service @RequiredArgsConstructor public class PlanChangeUtil { - private final StringRedisTemplate redisTemplate; + private final StringRedisTemplate redisTemplate; - public List calculate(List events) { + public List calculate(List events) { - List results = new ArrayList<>(); + List results = new ArrayList<>(); - String yearMonth; - long finalLimit; - String processedKey; - Long added; + String yearMonth; + long finalLimit; + String processedKey; + Long added; - for (PlanChangeSchema event : events) { - yearMonth = toYearMonth(event.changedAt().toString()); + for (PlanChangeSchema event : events) { + yearMonth = toYearMonth(event.changedAt().toString()); - processedKey = "processed:plan:" + yearMonth + ":" + event.subscriptionId(); - added = redisTemplate.opsForSet().add(processedKey, event.eventId()); + processedKey = "processed:plan:" + yearMonth + ":" + event.subscriptionId(); + added = redisTemplate.opsForSet().add(processedKey, event.eventId()); - redisTemplate.expire( - processedKey, - Duration.ofSeconds( - UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2))); + redisTemplate.expire( + processedKey, + Duration.ofSeconds( + UsageTimeUtil.ttlToNextMonthWithBufferSec( + event.changedAt().toString(), 2))); - if (added == null || added == 0L) { - // 이미 처리된 요금제 변경 이벤트 → skip - continue; - } + if (added == null || added == 0L) { + // 이미 처리된 요금제 변경 이벤트 → skip + continue; + } - String unitKey = "plan:unit:" + event.subscriptionId(); - String prevUnit = redisTemplate.opsForValue().get(unitKey); + String unitKey = "plan:unit:" + event.subscriptionId(); + String prevUnit = redisTemplate.opsForValue().get(unitKey); - if ("ULTIMATE".equals(event.unit())) { - finalLimit = -1L; - } else if ("DAY".equals(event.unit())) { - finalLimit = event.allowanceAmount(); - } else { - finalLimit = getMonthFinalLimit(yearMonth, event, prevUnit); - } + if ("ULTIMATE".equals(event.unit())) { + finalLimit = -1L; + } else if ("DAY".equals(event.unit())) { + finalLimit = event.allowanceAmount(); + } else { + finalLimit = getMonthFinalLimit(yearMonth, event, prevUnit); + } - long ttlSec = UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2); + long ttlSec = + UsageTimeUtil.ttlToNextMonthWithBufferSec(event.changedAt().toString(), 2); - results.add( - new CalculatedLimitSchema( - event.subscriptionId(), yearMonth, finalLimit, ttlSec, event.unit())); - } + results.add( + new CalculatedLimitSchema( + event.subscriptionId(), yearMonth, finalLimit, ttlSec, event.unit())); + } - return results; - } + return results; + } - private long getPreviousLimit(String key) { - String v = redisTemplate.opsForValue().get(key); - return v == null ? 0L : Long.parseLong(v); - } + private long getPreviousLimit(String key) { + String v = redisTemplate.opsForValue().get(key); + return v == null ? 0L : Long.parseLong(v); + } - // 사용자가 월 기준 중도에 요금제를 변경했을 경우 사용 가능 데이터 집계 로직 - private long getMonthFinalLimit(String yearMonth, PlanChangeSchema event, String prevUnit) { - OffsetDateTime kstTime = - event.changedAt().atZoneSameInstant(ZoneId.of("Asia/Seoul")).toOffsetDateTime(); + // 사용자가 월 기준 중도에 요금제를 변경했을 경우 사용 가능 데이터 집계 로직 + private long getMonthFinalLimit(String yearMonth, PlanChangeSchema event, String prevUnit) { + OffsetDateTime kstTime = + event.changedAt().atZoneSameInstant(ZoneId.of("Asia/Seoul")).toOffsetDateTime(); - YearMonth month = YearMonth.from(kstTime); - int totalDays = month.lengthOfMonth(); - int changeDay = kstTime.getDayOfMonth(); + YearMonth month = YearMonth.from(kstTime); + int totalDays = month.lengthOfMonth(); + int changeDay = kstTime.getDayOfMonth(); - int daysBefore = changeDay - 1; - int daysAfter = totalDays - daysBefore; + int daysBefore = changeDay - 1; + int daysAfter = totalDays - daysBefore; - // 만약 전 요금제가 무제한 or DAY 요금제 였다면 새로 추가된 요금제의 데이터 양만 계산 - if (!"MONTH".equals(prevUnit)) { - return event.allowanceAmount() * daysAfter / totalDays; - } + // 만약 전 요금제가 무제한 or DAY 요금제 였다면 새로 추가된 요금제의 데이터 양만 계산 + if (!"MONTH".equals(prevUnit)) { + return event.allowanceAmount() * daysAfter / totalDays; + } - String limitKey = "limit:" + yearMonth + ":" + event.subscriptionId(); + String limitKey = "limit:" + yearMonth + ":" + event.subscriptionId(); - long prevLimit = getPreviousLimit(limitKey); + long prevLimit = getPreviousLimit(limitKey); - long allowanceBefore = prevLimit * daysBefore / totalDays; - long allowanceAfter = event.allowanceAmount() * daysAfter / totalDays; + long allowanceBefore = prevLimit * daysBefore / totalDays; + long allowanceAfter = event.allowanceAmount() * daysAfter / totalDays; - return allowanceBefore + allowanceAfter; - } + return allowanceBefore + allowanceAfter; + } } diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index 47f165e..31d2ffa 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -1,67 +1,74 @@ package com.project.consumer.util; -import com.project.producer.schema.CalculatedLimitSchema; -import com.project.producer.schema.UsageEventSchema; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import lombok.RequiredArgsConstructor; + import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; +import com.project.producer.schema.CalculatedLimitSchema; +import com.project.producer.schema.UsageEventSchema; + +import lombok.RequiredArgsConstructor; + @Service @RequiredArgsConstructor public class RedisUtil { - private final StringRedisTemplate redisTemplate; + private final StringRedisTemplate redisTemplate; - private final DefaultRedisScript script = - new DefaultRedisScript<>(LuaScriptLoader.load("lua/usage_batch.lua"), List.class); + private final DefaultRedisScript script = + new DefaultRedisScript<>(LuaScriptLoader.load("lua/usage_batch.lua"), List.class); - public List applyUsageBatch(List events) { - if (events == null || events.isEmpty()) { - return List.of(); - } + public List applyUsageBatch(List events) { + if (events == null || events.isEmpty()) { + return List.of(); + } - List args = new ArrayList<>(); - args.add(String.valueOf(events.size())); + List args = new ArrayList<>(); + args.add(String.valueOf(events.size())); - for (UsageEventSchema e : events) { + for (UsageEventSchema e : events) { - args.add(String.valueOf(e.subscriptionId())); - args.add(e.eventId()); - args.add(String.valueOf(e.usageBytes())); - args.add(e.timeStamp()); + args.add(String.valueOf(e.subscriptionId())); + args.add(e.eventId()); + args.add(String.valueOf(e.usageBytes())); + args.add(e.timeStamp()); + } + + Object result = redisTemplate.execute(script, Collections.emptyList(), args.toArray()); + return result == null ? List.of() : (List) result; } - Object result = redisTemplate.execute(script, Collections.emptyList(), args.toArray()); - return result == null ? List.of() : (List) result; - } - - public void writePlanChangeBatch(List limits) { - redisTemplate.executePipelined( - (RedisCallback) - connection -> { - for (CalculatedLimitSchema limit : limits) { - String key = "limit:" + limit.yearMonth() + ":" + limit.subscriptionId(); - - byte[] k = redisTemplate.getStringSerializer().serialize(key); - byte[] v = - redisTemplate.getStringSerializer().serialize(String.valueOf(limit.limit())); - - connection.set(k, v); - connection.expire(k, limit.ttlSec()); - - String unitKey = "plan:unit:" + limit.subscriptionId(); - byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); - byte[] uv = redisTemplate.getStringSerializer().serialize(limit.unit()); - - connection.set(uk, uv); - connection.expire(uk, limit.ttlSec()); - } - return null; - }); - } + public void writePlanChangeBatch(List limits) { + redisTemplate.executePipelined( + (RedisCallback) + connection -> { + for (CalculatedLimitSchema limit : limits) { + String key = + "limit:" + limit.yearMonth() + ":" + limit.subscriptionId(); + + byte[] k = redisTemplate.getStringSerializer().serialize(key); + byte[] v = + redisTemplate + .getStringSerializer() + .serialize(String.valueOf(limit.limit())); + + connection.set(k, v); + connection.expire(k, limit.ttlSec()); + + String unitKey = "plan:unit:" + limit.subscriptionId(); + byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); + byte[] uv = + redisTemplate.getStringSerializer().serialize(limit.unit()); + + connection.set(uk, uv); + connection.expire(uk, limit.ttlSec()); + } + return null; + }); + } } diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index 4eebfe1..e70e93c 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -6,21 +6,22 @@ import java.time.format.DateTimeFormatter; public class UsageTimeUtil { - private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - public static String toYearMonth(String isoTs) { - OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); - return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); - } + public static String toYearMonth(String isoTs) { + OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); + return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); + } - public static long ttlToNextMonthWithBufferSec(String isoTs, int bufferDays) { - OffsetDateTime now = OffsetDateTime.parse(isoTs, ISO); + public static long ttlToNextMonthWithBufferSec(String isoTs, int bufferDays) { + OffsetDateTime now = OffsetDateTime.parse(isoTs, ISO); - OffsetDateTime nextMonthStart = now.withDayOfMonth(1).with(LocalTime.MIDNIGHT).plusMonths(1); + OffsetDateTime nextMonthStart = + now.withDayOfMonth(1).with(LocalTime.MIDNIGHT).plusMonths(1); - Duration base = Duration.between(now, nextMonthStart); - Duration buffer = Duration.ofDays(bufferDays); + Duration base = Duration.between(now, nextMonthStart); + Duration buffer = Duration.ofDays(bufferDays); - return Math.max(base.plus(buffer).getSeconds(), 3600); - } + return Math.max(base.plus(buffer).getSeconds(), 3600); + } } diff --git a/src/main/java/com/project/global/config/KafkaConfig.java b/src/main/java/com/project/global/config/KafkaConfig.java index 683ad93..054d946 100644 --- a/src/main/java/com/project/global/config/KafkaConfig.java +++ b/src/main/java/com/project/global/config/KafkaConfig.java @@ -14,40 +14,40 @@ @EnableKafka public class KafkaConfig { - @Bean - public KafkaListenerContainerFactory kafkaListenerContainerFactory( - ConsumerFactory consumerFactory) { - ConcurrentKafkaListenerContainerFactory factory = - new ConcurrentKafkaListenerContainerFactory<>(); + @Bean + public KafkaListenerContainerFactory kafkaListenerContainerFactory( + ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); + factory.setConsumerFactory(consumerFactory); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - return factory; - } + return factory; + } - @Bean - public ConcurrentKafkaListenerContainerFactory batchKafkaListenerContainerFactory( - ConsumerFactory consumerFactory) { - ConcurrentKafkaListenerContainerFactory factory = - new ConcurrentKafkaListenerContainerFactory<>(); + @Bean + public ConcurrentKafkaListenerContainerFactory + batchKafkaListenerContainerFactory(ConsumerFactory consumerFactory) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); - factory.setConsumerFactory(consumerFactory); + factory.setConsumerFactory(consumerFactory); - factory.setBatchListener(true); + factory.setBatchListener(true); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); - DefaultErrorHandler errorHandler = - new DefaultErrorHandler( - new FixedBackOff(1000L, 9) // 총 10번 시도(초기+9) - ); + DefaultErrorHandler errorHandler = + new DefaultErrorHandler( + new FixedBackOff(1000L, 9) // 총 10번 시도(초기+9) + ); - factory.setCommonErrorHandler(errorHandler); + factory.setCommonErrorHandler(errorHandler); - return factory; - } + return factory; + } } diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index 2cd72da..d162140 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -1,24 +1,25 @@ package com.project.global.exception.code.domain; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor public enum GlobalErrorCode implements BaseErrorCode { - BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 요청입니다."), - METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "COMMON_002", "올바르지 않은 요청입니다."), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_003", "지원하지 않은 Http Method 입니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_004", "서버 에러가 발생했습니다."), - BLOCKED_API(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_005", "운영 환경에서 사용할 수 없는 API 입니다."), - NOTIFICATION_EVENT_PRODUCE_INVALID( - HttpStatus.BAD_REQUEST, "COMMON_006", "Kafka Notification 이벤트 발행 과정에서 에러가 발생했습니다"), - PLAN_CHANGE_EVENT_PRODUCE_INVALID( - HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"), - ; + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 요청입니다."), + METHOD_ARGUMENT_NOT_VALID(HttpStatus.BAD_REQUEST, "COMMON_002", "올바르지 않은 요청입니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_003", "지원하지 않은 Http Method 입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_004", "서버 에러가 발생했습니다."), + BLOCKED_API(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_005", "운영 환경에서 사용할 수 없는 API 입니다."), + NOTIFICATION_EVENT_PRODUCE_INVALID( + HttpStatus.BAD_REQUEST, "COMMON_006", "Kafka Notification 이벤트 발행 과정에서 에러가 발생했습니다"), + PLAN_CHANGE_EVENT_PRODUCE_INVALID( + HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"), + ; - private final HttpStatus httpStatus; - private final String customCode; - private final String message; + private final HttpStatus httpStatus; + private final String customCode; + private final String message; } diff --git a/src/main/java/com/project/producer/NotificationProducer.java b/src/main/java/com/project/producer/NotificationProducer.java index 7af622d..8d0985b 100644 --- a/src/main/java/com/project/producer/NotificationProducer.java +++ b/src/main/java/com/project/producer/NotificationProducer.java @@ -1,15 +1,16 @@ package com.project.producer; -import lombok.RequiredArgsConstructor; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class NotificationProducer { - private final KafkaTemplate kafkaTemplate; + private final KafkaTemplate kafkaTemplate; - public void sendNotification(String payload) { - kafkaTemplate.send("notification_topic", payload); - } + public void sendNotification(String payload) { + kafkaTemplate.send("notification_topic", payload); + } } diff --git a/src/main/java/com/project/producer/PlanChangeProducer.java b/src/main/java/com/project/producer/PlanChangeProducer.java index 64e2e31..2b5f45c 100644 --- a/src/main/java/com/project/producer/PlanChangeProducer.java +++ b/src/main/java/com/project/producer/PlanChangeProducer.java @@ -1,47 +1,50 @@ package com.project.producer; +import java.time.OffsetDateTime; +import java.util.UUID; + +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.schema.PlanChangeSchema; -import java.time.OffsetDateTime; -import java.util.UUID; + import lombok.RequiredArgsConstructor; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class PlanChangeProducer { - private final KafkaTemplate kafkaTemplate; - private final ObjectMapper objectMapper; - - public void sendPlanChangeEvent( - long subscriptionId, - String unit, // MONTH / DAILY / UNLIMITED - long allowanceAmount, - OffsetDateTime changedAt, - String email, - String phone) { - PlanChangeSchema event = - new PlanChangeSchema( - UUID.randomUUID().toString(), - subscriptionId, - unit, - allowanceAmount, - changedAt, - email, - phone); - - try { - String value = objectMapper.writeValueAsString(event); - String key = String.valueOf(subscriptionId); - - kafkaTemplate.send("change_plan", key, value); - } catch (JsonProcessingException e) { - throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + public void sendPlanChangeEvent( + long subscriptionId, + String unit, // MONTH / DAILY / UNLIMITED + long allowanceAmount, + OffsetDateTime changedAt, + String email, + String phone) { + PlanChangeSchema event = + new PlanChangeSchema( + UUID.randomUUID().toString(), + subscriptionId, + unit, + allowanceAmount, + changedAt, + email, + phone); + + try { + String value = objectMapper.writeValueAsString(event); + String key = String.valueOf(subscriptionId); + + kafkaTemplate.send("change_plan", key, value); + } catch (JsonProcessingException e) { + throw new ApplicationException(GlobalErrorCode.PLAN_CHANGE_EVENT_PRODUCE_INVALID); + } } - } } diff --git a/src/main/java/com/project/producer/UsageProducer.java b/src/main/java/com/project/producer/UsageProducer.java index b60301f..f4f0017 100644 --- a/src/main/java/com/project/producer/UsageProducer.java +++ b/src/main/java/com/project/producer/UsageProducer.java @@ -1,37 +1,41 @@ package com.project.producer; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.project.producer.schema.UsageEventSchema; import java.time.OffsetDateTime; import java.util.UUID; -import lombok.RequiredArgsConstructor; + import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.producer.schema.UsageEventSchema; + +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class UsageProducer { - private final KafkaTemplate kafkaTemplate; - private final ObjectMapper objectMapper; - - public void sendUsageEvent(long subscriptionId, long usageBytes, String yearMonth, long ttlSec) { - UsageEventSchema schema = - new UsageEventSchema( - UUID.randomUUID().toString(), - subscriptionId, - usageBytes, - OffsetDateTime.now().toString(), - yearMonth, - ttlSec); - - try { - String value = objectMapper.writeValueAsString(schema); - String key = String.valueOf(subscriptionId); - - kafkaTemplate.send("usage-data", key, value); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + public void sendUsageEvent( + long subscriptionId, long usageBytes, String yearMonth, long ttlSec) { + UsageEventSchema schema = + new UsageEventSchema( + UUID.randomUUID().toString(), + subscriptionId, + usageBytes, + OffsetDateTime.now().toString(), + yearMonth, + ttlSec); + + try { + String value = objectMapper.writeValueAsString(schema); + String key = String.valueOf(subscriptionId); + + kafkaTemplate.send("usage-data", key, value); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } - } } diff --git a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java index 802601a..0b36ff7 100644 --- a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java +++ b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java @@ -1,4 +1,4 @@ package com.project.producer.schema; public record CalculatedLimitSchema( - long subscriptionId, String yearMonth, long limit, long ttlSec, String unit) {} + long subscriptionId, String yearMonth, long limit, long ttlSec, String unit) {} diff --git a/src/main/java/com/project/producer/schema/PlanChangeSchema.java b/src/main/java/com/project/producer/schema/PlanChangeSchema.java index 559682f..aa07aa3 100644 --- a/src/main/java/com/project/producer/schema/PlanChangeSchema.java +++ b/src/main/java/com/project/producer/schema/PlanChangeSchema.java @@ -3,10 +3,10 @@ import java.time.OffsetDateTime; public record PlanChangeSchema( - String eventId, // 멱등성/추적용 - long subscriptionId, // 회선 ID - String unit, // MONTHLY | DAILY | UNLIMITED - long allowanceAmount, // 월 제공량 or 일 제공량 (bytes 기준) - OffsetDateTime changedAt, // 요금제 변경 시점 - String email, // 사용자 이메일 - String phone) {} + String eventId, // 멱등성/추적용 + long subscriptionId, // 회선 ID + String unit, // MONTHLY | DAILY | UNLIMITED + long allowanceAmount, // 월 제공량 or 일 제공량 (bytes 기준) + OffsetDateTime changedAt, // 요금제 변경 시점 + String email, // 사용자 이메일 + String phone) {} diff --git a/src/main/java/com/project/producer/schema/UsageEventSchema.java b/src/main/java/com/project/producer/schema/UsageEventSchema.java index 3f8b15f..4ee3ed3 100644 --- a/src/main/java/com/project/producer/schema/UsageEventSchema.java +++ b/src/main/java/com/project/producer/schema/UsageEventSchema.java @@ -1,9 +1,9 @@ package com.project.producer.schema; public record UsageEventSchema( - String eventId, - long subscriptionId, - long usageBytes, - String timeStamp, - String event, - long ttlSec) {} + String eventId, + long subscriptionId, + long usageBytes, + String timeStamp, + String event, + long ttlSec) {} diff --git a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java index b4c1a0b..67d5ee4 100644 --- a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java +++ b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java @@ -1,43 +1,48 @@ package com.project.producer.test; -import com.project.producer.PlanChangeProducer; import java.time.OffsetDateTime; import java.util.concurrent.ThreadLocalRandom; -import lombok.RequiredArgsConstructor; + import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; +import com.project.producer.PlanChangeProducer; + +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class InitSubscriptionPlanRunner implements CommandLineRunner { - private final PlanChangeProducer planChangeProducer; + private final PlanChangeProducer planChangeProducer; - private static final int SUB_START = 1; - private static final int SUB_END = 10_000; + private static final int SUB_START = 1; + private static final int SUB_END = 10_000; - @Override - public void run(String... args) { + @Override + public void run(String... args) { - OffsetDateTime baseTime = - OffsetDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0); + OffsetDateTime baseTime = + OffsetDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0); - PlanSeed[] plans = PlanSeed.values(); - ThreadLocalRandom random = ThreadLocalRandom.current(); + PlanSeed[] plans = PlanSeed.values(); + ThreadLocalRandom random = ThreadLocalRandom.current(); - for (long subId = SUB_START; subId <= SUB_END; subId++) { + for (long subId = SUB_START; subId <= SUB_END; subId++) { - PlanSeed plan = plans[random.nextInt(plans.length)]; + PlanSeed plan = plans[random.nextInt(plans.length)]; - planChangeProducer.sendPlanChangeEvent( - subId, - plan.getUnit(), - plan.getAllowance(), - baseTime, - "user" + subId + "@test.com", - "010-" + String.format("%04d-%04d", random.nextInt(10000), random.nextInt(10000))); - } + planChangeProducer.sendPlanChangeEvent( + subId, + plan.getUnit(), + plan.getAllowance(), + baseTime, + "user" + subId + "@test.com", + "010-" + + String.format( + "%04d-%04d", random.nextInt(10000), random.nextInt(10000))); + } - System.out.println("✅ Initial Plan Seeding Completed (1 ~ 10000)"); - } + System.out.println("✅ Initial Plan Seeding Completed (1 ~ 10000)"); + } } diff --git a/src/main/java/com/project/producer/test/PlanSeed.java b/src/main/java/com/project/producer/test/PlanSeed.java index 43818e9..4374dd5 100644 --- a/src/main/java/com/project/producer/test/PlanSeed.java +++ b/src/main/java/com/project/producer/test/PlanSeed.java @@ -6,13 +6,13 @@ @Getter @AllArgsConstructor public enum PlanSeed { - FIVE_G_SIGNATURE("5G 시그니처", -1, "ULTIMATE"), - FIVE_G_STANDARD("5G 스탠다드", 153600, "MONTH"), - FIVE_G_BASIC_PLUS("5G 베이직+", 24576, "MONTH"), - LTE_33("LTE 데이터 33", 1536, "MONTH"), - LTE_DIRECT_45("LTE 다이렉트 45", 5120, "DAY"); + FIVE_G_SIGNATURE("5G 시그니처", -1, "ULTIMATE"), + FIVE_G_STANDARD("5G 스탠다드", 153600, "MONTH"), + FIVE_G_BASIC_PLUS("5G 베이직+", 24576, "MONTH"), + LTE_33("LTE 데이터 33", 1536, "MONTH"), + LTE_DIRECT_45("LTE 다이렉트 45", 5120, "DAY"); - private final String name; - private final long allowance; - private final String unit; + private final String name; + private final long allowance; + private final String unit; } diff --git a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java index c211e1a..a7a31ee 100644 --- a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java +++ b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java @@ -1,76 +1,79 @@ package com.project.producer.test; -import com.project.producer.UsageProducer; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import lombok.RequiredArgsConstructor; + import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; +import com.project.producer.UsageProducer; + +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class UsageBurstLoadRunner implements CommandLineRunner { - private final UsageProducer usageProducer; + private final UsageProducer usageProducer; - private static final int EVENTS_PER_SECOND = 10_000; - private static final int DURATION_SECONDS = 60; - private static final int THREADS = 8; + private static final int EVENTS_PER_SECOND = 10_000; + private static final int DURATION_SECONDS = 60; + private static final int THREADS = 8; - @Override - public void run(String... args) throws Exception { + @Override + public void run(String... args) throws Exception { - ExecutorService executor = Executors.newFixedThreadPool(THREADS); - AtomicInteger sent = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(THREADS); + AtomicInteger sent = new AtomicInteger(); - int perThreadRate = EVENTS_PER_SECOND / THREADS; + int perThreadRate = EVENTS_PER_SECOND / THREADS; - final long start = System.currentTimeMillis(); + final long start = System.currentTimeMillis(); - for (int t = 0; t < THREADS; t++) { - executor.submit( - () -> { - ThreadLocalRandom random = ThreadLocalRandom.current(); + for (int t = 0; t < THREADS; t++) { + executor.submit( + () -> { + ThreadLocalRandom random = ThreadLocalRandom.current(); - long endTime = System.currentTimeMillis() + DURATION_SECONDS * 1000L; + long endTime = System.currentTimeMillis() + DURATION_SECONDS * 1000L; - while (System.currentTimeMillis() < endTime) { - long secondStart = System.nanoTime(); + while (System.currentTimeMillis() < endTime) { + long secondStart = System.nanoTime(); - for (int i = 0; i < perThreadRate; i++) { - long subId = random.nextLong(1, 1_000_001); - long bytes = random.nextLong(200, 5_000); + for (int i = 0; i < perThreadRate; i++) { + long subId = random.nextLong(1, 1_000_001); + long bytes = random.nextLong(200, 5_000); - usageProducer.sendUsageEvent(subId, bytes, null, 0); + usageProducer.sendUsageEvent(subId, bytes, null, 0); - sent.incrementAndGet(); - } + sent.incrementAndGet(); + } - long elapsedNs = System.nanoTime() - secondStart; - long sleepMs = 1000 - (elapsedNs / 1_000_000); + long elapsedNs = System.nanoTime() - secondStart; + long sleepMs = 1000 - (elapsedNs / 1_000_000); - if (sleepMs > 0) { - try { - Thread.sleep(sleepMs); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - } - }); - } + if (sleepMs > 0) { + try { + Thread.sleep(sleepMs); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } + }); + } - executor.shutdown(); - executor.awaitTermination(DURATION_SECONDS + 10, TimeUnit.SECONDS); + executor.shutdown(); + executor.awaitTermination(DURATION_SECONDS + 10, TimeUnit.SECONDS); - long elapsed = System.currentTimeMillis() - start; + long elapsed = System.currentTimeMillis() - start; - System.out.println("✅ DONE"); - System.out.println("Total events sent = " + sent.get()); - System.out.println("Elapsed ms = " + elapsed); - System.out.println("Avg TPS = " + (sent.get() * 1000L / elapsed)); - } + System.out.println("✅ DONE"); + System.out.println("Total events sent = " + sent.get()); + System.out.println("Elapsed ms = " + elapsed); + System.out.println("Avg TPS = " + (sent.get() * 1000L / elapsed)); + } } From 5b4fed159c2b7bdfd280ae1f42aa92a6ebaa499c Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:26:57 +0900 Subject: [PATCH 26/33] =?UTF-8?q?UPLUS-16=20fix=20:=20checksyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/project/consumer/util/PlanChangeUtil.java | 4 ++-- src/main/java/com/project/consumer/util/RedisUtil.java | 10 +++++----- .../global/exception/code/domain/GlobalErrorCode.java | 3 +-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index 1f549f9..50358f6 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -74,8 +74,8 @@ public List calculate(List events) { } private long getPreviousLimit(String key) { - String v = redisTemplate.opsForValue().get(key); - return v == null ? 0L : Long.parseLong(v); + String value = redisTemplate.opsForValue().get(key); + return value == null ? 0L : Long.parseLong(value); } // 사용자가 월 기준 중도에 요금제를 변경했을 경우 사용 가능 데이터 집계 로직 diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index 31d2ffa..e0c6590 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -48,17 +48,17 @@ public void writePlanChangeBatch(List limits) { (RedisCallback) connection -> { for (CalculatedLimitSchema limit : limits) { - String key = + String ke = "limit:" + limit.yearMonth() + ":" + limit.subscriptionId(); - byte[] k = redisTemplate.getStringSerializer().serialize(key); - byte[] v = + byte[] key = redisTemplate.getStringSerializer().serialize(ke); + byte[] value = redisTemplate .getStringSerializer() .serialize(String.valueOf(limit.limit())); - connection.set(k, v); - connection.expire(k, limit.ttlSec()); + connection.set(key, value); + connection.expire(key, limit.ttlSec()); String unitKey = "plan:unit:" + limit.subscriptionId(); byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index d162140..0ef5d9b 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -16,8 +16,7 @@ public enum GlobalErrorCode implements BaseErrorCode { NOTIFICATION_EVENT_PRODUCE_INVALID( HttpStatus.BAD_REQUEST, "COMMON_006", "Kafka Notification 이벤트 발행 과정에서 에러가 발생했습니다"), PLAN_CHANGE_EVENT_PRODUCE_INVALID( - HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"), - ; + HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"); private final HttpStatus httpStatus; private final String customCode; From ce5ba61bdafc1e704be5efed59a07999e585bad8 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:46:58 +0900 Subject: [PATCH 27/33] =?UTF-8?q?UPLUS-16=20fix=20:=20sonarCube=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradlew | 0 .../project/consumer/PlanChangeConsumer.java | 4 +- .../consumer/util/LuaScriptLoader.java | 10 ++- .../com/project/consumer/util/RedisUtil.java | 25 ++++-- .../project/consumer/util/UsageTimeUtil.java | 3 + .../code/domain/GlobalErrorCode.java | 4 +- .../com/project/producer/UsageProducer.java | 4 +- .../test/InitSubscriptionPlanRunner.java | 4 +- .../com/project/producer/test/PlanSeed.java | 12 +-- .../com/project/producer/test/PlanUnit.java | 7 ++ .../producer/test/UsageBurstLoadRunner.java | 79 ------------------- 11 files changed, 54 insertions(+), 98 deletions(-) mode change 100644 => 100755 gradlew create mode 100644 src/main/java/com/project/producer/test/PlanUnit.java delete mode 100644 src/main/java/com/project/producer/test/UsageBurstLoadRunner.java diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/project/consumer/PlanChangeConsumer.java b/src/main/java/com/project/consumer/PlanChangeConsumer.java index 0615417..8051414 100644 --- a/src/main/java/com/project/consumer/PlanChangeConsumer.java +++ b/src/main/java/com/project/consumer/PlanChangeConsumer.java @@ -38,8 +38,8 @@ public void consume(List> records, Acknowledgment } List events = new ArrayList<>(records.size()); try { - for (ConsumerRecord record : records) { - events.add(objectMapper.readValue(record.value(), PlanChangeSchema.class)); + for (ConsumerRecord rec : records) { + events.add(objectMapper.readValue(rec.value(), PlanChangeSchema.class)); } List limits = planChangeUtil.calculate(events); diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index b09665d..421305c 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -3,9 +3,12 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.domain.GlobalErrorCode; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -@Component +@Slf4j public class LuaScriptLoader { public static String load(String path) { @@ -17,7 +20,10 @@ public static String load(String path) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } catch (Exception e) { - throw new RuntimeException("Failed to load lua script", e); + log.error("Failed to load lua script"); + throw new ApplicationException(GlobalErrorCode.) } } + + private LuaScriptLoader() {} } diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index e0c6590..b797d71 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -4,9 +4,11 @@ import java.util.Collections; import java.util.List; +import org.springframework.data.redis.connection.RedisStringCommands; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.stereotype.Service; import com.project.producer.schema.CalculatedLimitSchema; @@ -20,8 +22,11 @@ public class RedisUtil { private final StringRedisTemplate redisTemplate; - private final DefaultRedisScript script = - new DefaultRedisScript<>(LuaScriptLoader.load("lua/usage_batch.lua"), List.class); + private final DefaultRedisScript> script = + new DefaultRedisScript<>( + LuaScriptLoader.load("lua/usage_batch.lua"), + (Class>) (Class) List.class + ); public List applyUsageBatch(List events) { if (events == null || events.isEmpty()) { @@ -57,16 +62,24 @@ public void writePlanChangeBatch(List limits) { .getStringSerializer() .serialize(String.valueOf(limit.limit())); - connection.set(key, value); - connection.expire(key, limit.ttlSec()); + connection.stringCommands().set( + key, + value, + Expiration.seconds(limit.ttlSec()), + RedisStringCommands.SetOption.UPSERT + ); String unitKey = "plan:unit:" + limit.subscriptionId(); byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); byte[] uv = redisTemplate.getStringSerializer().serialize(limit.unit()); - connection.set(uk, uv); - connection.expire(uk, limit.ttlSec()); + connection.stringCommands().set( + uk, + uv, + Expiration.seconds(limit.ttlSec()), + RedisStringCommands.SetOption.UPSERT + ); } return null; }); diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index e70e93c..27d149c 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -6,8 +6,11 @@ import java.time.format.DateTimeFormatter; public class UsageTimeUtil { + private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private UsageTimeUtil() {} + public static String toYearMonth(String isoTs) { OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); return odt.format(DateTimeFormatter.ofPattern("yyyyMM")); diff --git a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java index 0ef5d9b..05d65b5 100644 --- a/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java +++ b/src/main/java/com/project/global/exception/code/domain/GlobalErrorCode.java @@ -16,7 +16,9 @@ public enum GlobalErrorCode implements BaseErrorCode { NOTIFICATION_EVENT_PRODUCE_INVALID( HttpStatus.BAD_REQUEST, "COMMON_006", "Kafka Notification 이벤트 발행 과정에서 에러가 발생했습니다"), PLAN_CHANGE_EVENT_PRODUCE_INVALID( - HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"); + HttpStatus.BAD_REQUEST, "COMMON_007", "Kafka PlanChange 이벤트 발행 과정에서 에러가 발생했습니다"), + LUA_SCRIPT_LOAD_INVALID(HttpStatus.BAD_REQUEST, "COMMON_008", "LUA 스크립트를 불러오는 과정에서 에러가 발생했습니다"), + JSON_CONVERT_INVALID(HttpStatus.BAD_REQUEST, "COMMON_009", "JSON으로 변환하는 과정에서 에러가 발생했습니다"); private final HttpStatus httpStatus; private final String customCode; diff --git a/src/main/java/com/project/producer/UsageProducer.java b/src/main/java/com/project/producer/UsageProducer.java index f4f0017..59e879e 100644 --- a/src/main/java/com/project/producer/UsageProducer.java +++ b/src/main/java/com/project/producer/UsageProducer.java @@ -3,6 +3,8 @@ import java.time.OffsetDateTime; import java.util.UUID; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.domain.GlobalErrorCode; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @@ -35,7 +37,7 @@ public void sendUsageEvent( kafkaTemplate.send("usage-data", key, value); } catch (JsonProcessingException e) { - throw new RuntimeException(e); + throw new ApplicationException(GlobalErrorCode.JSON_CONVERT_INVALID); } } } diff --git a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java index 67d5ee4..fdc5811 100644 --- a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java +++ b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java @@ -3,6 +3,7 @@ import java.time.OffsetDateTime; import java.util.concurrent.ThreadLocalRandom; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; @@ -10,6 +11,7 @@ import lombok.RequiredArgsConstructor; +@Slf4j @Component @RequiredArgsConstructor public class InitSubscriptionPlanRunner implements CommandLineRunner { @@ -43,6 +45,6 @@ public void run(String... args) { "%04d-%04d", random.nextInt(10000), random.nextInt(10000))); } - System.out.println("✅ Initial Plan Seeding Completed (1 ~ 10000)"); + log.info("Initial Plan Seeding Completed (1 ~ 10000)"); } } diff --git a/src/main/java/com/project/producer/test/PlanSeed.java b/src/main/java/com/project/producer/test/PlanSeed.java index 4374dd5..2c6d45c 100644 --- a/src/main/java/com/project/producer/test/PlanSeed.java +++ b/src/main/java/com/project/producer/test/PlanSeed.java @@ -6,13 +6,13 @@ @Getter @AllArgsConstructor public enum PlanSeed { - FIVE_G_SIGNATURE("5G 시그니처", -1, "ULTIMATE"), - FIVE_G_STANDARD("5G 스탠다드", 153600, "MONTH"), - FIVE_G_BASIC_PLUS("5G 베이직+", 24576, "MONTH"), - LTE_33("LTE 데이터 33", 1536, "MONTH"), - LTE_DIRECT_45("LTE 다이렉트 45", 5120, "DAY"); + FIVE_G_SIGNATURE("5G 시그니처", -1, PlanUnit.ULTIMATE), + FIVE_G_STANDARD("5G 스탠다드", 153600, PlanUnit.MONTH), + FIVE_G_BASIC_PLUS("5G 베이직+", 24576, PlanUnit.MONTH), + LTE_33("LTE 데이터 33", 1536, PlanUnit.MONTH), + LTE_DIRECT_45("LTE 다이렉트 45", 5120, PlanUnit.DAY); private final String name; private final long allowance; - private final String unit; + private final PlanUnit unit; } diff --git a/src/main/java/com/project/producer/test/PlanUnit.java b/src/main/java/com/project/producer/test/PlanUnit.java new file mode 100644 index 0000000..995a419 --- /dev/null +++ b/src/main/java/com/project/producer/test/PlanUnit.java @@ -0,0 +1,7 @@ +package com.project.producer.test; + +public enum PlanUnit { + MONTH, + DAY, + ULTIMATE +} diff --git a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java b/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java deleted file mode 100644 index a7a31ee..0000000 --- a/src/main/java/com/project/producer/test/UsageBurstLoadRunner.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.project.producer.test; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; - -import com.project.producer.UsageProducer; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class UsageBurstLoadRunner implements CommandLineRunner { - - private final UsageProducer usageProducer; - - private static final int EVENTS_PER_SECOND = 10_000; - private static final int DURATION_SECONDS = 60; - private static final int THREADS = 8; - - @Override - public void run(String... args) throws Exception { - - ExecutorService executor = Executors.newFixedThreadPool(THREADS); - AtomicInteger sent = new AtomicInteger(); - - int perThreadRate = EVENTS_PER_SECOND / THREADS; - - final long start = System.currentTimeMillis(); - - for (int t = 0; t < THREADS; t++) { - executor.submit( - () -> { - ThreadLocalRandom random = ThreadLocalRandom.current(); - - long endTime = System.currentTimeMillis() + DURATION_SECONDS * 1000L; - - while (System.currentTimeMillis() < endTime) { - long secondStart = System.nanoTime(); - - for (int i = 0; i < perThreadRate; i++) { - long subId = random.nextLong(1, 1_000_001); - long bytes = random.nextLong(200, 5_000); - - usageProducer.sendUsageEvent(subId, bytes, null, 0); - - sent.incrementAndGet(); - } - - long elapsedNs = System.nanoTime() - secondStart; - long sleepMs = 1000 - (elapsedNs / 1_000_000); - - if (sleepMs > 0) { - try { - Thread.sleep(sleepMs); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - } - }); - } - - executor.shutdown(); - executor.awaitTermination(DURATION_SECONDS + 10, TimeUnit.SECONDS); - - long elapsed = System.currentTimeMillis() - start; - - System.out.println("✅ DONE"); - System.out.println("Total events sent = " + sent.get()); - System.out.println("Elapsed ms = " + elapsed); - System.out.println("Avg TPS = " + (sent.get() * 1000L / elapsed)); - } -} From ec7e45b8b73146b5a14785420e90aeaf6dd433ed Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:47:46 +0900 Subject: [PATCH 28/33] =?UTF-8?q?UPLUS-16=20fix=20:=20sonarCube=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/LuaScriptLoader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index 421305c..035227b 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -21,7 +21,7 @@ public static String load(String path) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } catch (Exception e) { log.error("Failed to load lua script"); - throw new ApplicationException(GlobalErrorCode.) + throw new ApplicationException(GlobalErrorCode.LUA_SCRIPT_LOAD_INVALID); } } From a46971f6c386ae0575c1ce0a034cc7dd48e779a8 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:51:00 +0900 Subject: [PATCH 29/33] =?UTF-8?q?UPLUS-16=20fix=20:=20sonarCube=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/LuaScriptLoader.java | 1 - src/main/java/com/project/consumer/util/RedisUtil.java | 2 +- src/main/java/com/project/producer/PlanChangeProducer.java | 3 ++- .../com/project/producer/schema/CalculatedLimitSchema.java | 4 +++- .../java/com/project/producer/schema/PlanChangeSchema.java | 4 +++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index 035227b..a3df016 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -6,7 +6,6 @@ import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.GlobalErrorCode; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; @Slf4j public class LuaScriptLoader { diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index b797d71..daa51a8 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -72,7 +72,7 @@ public void writePlanChangeBatch(List limits) { String unitKey = "plan:unit:" + limit.subscriptionId(); byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); byte[] uv = - redisTemplate.getStringSerializer().serialize(limit.unit()); + redisTemplate.getStringSerializer().serialize(limit.unit().name()); connection.stringCommands().set( uk, diff --git a/src/main/java/com/project/producer/PlanChangeProducer.java b/src/main/java/com/project/producer/PlanChangeProducer.java index 2b5f45c..2c0cccf 100644 --- a/src/main/java/com/project/producer/PlanChangeProducer.java +++ b/src/main/java/com/project/producer/PlanChangeProducer.java @@ -3,6 +3,7 @@ import java.time.OffsetDateTime; import java.util.UUID; +import com.project.producer.test.PlanUnit; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @@ -23,7 +24,7 @@ public class PlanChangeProducer { public void sendPlanChangeEvent( long subscriptionId, - String unit, // MONTH / DAILY / UNLIMITED + PlanUnit unit, // MONTH / DAILY / UNLIMITED long allowanceAmount, OffsetDateTime changedAt, String email, diff --git a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java index 0b36ff7..d87ca7a 100644 --- a/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java +++ b/src/main/java/com/project/producer/schema/CalculatedLimitSchema.java @@ -1,4 +1,6 @@ package com.project.producer.schema; +import com.project.producer.test.PlanUnit; + public record CalculatedLimitSchema( - long subscriptionId, String yearMonth, long limit, long ttlSec, String unit) {} + long subscriptionId, String yearMonth, long limit, long ttlSec, PlanUnit unit) {} diff --git a/src/main/java/com/project/producer/schema/PlanChangeSchema.java b/src/main/java/com/project/producer/schema/PlanChangeSchema.java index aa07aa3..556a17d 100644 --- a/src/main/java/com/project/producer/schema/PlanChangeSchema.java +++ b/src/main/java/com/project/producer/schema/PlanChangeSchema.java @@ -1,11 +1,13 @@ package com.project.producer.schema; +import com.project.producer.test.PlanUnit; + import java.time.OffsetDateTime; public record PlanChangeSchema( String eventId, // 멱등성/추적용 long subscriptionId, // 회선 ID - String unit, // MONTHLY | DAILY | UNLIMITED + PlanUnit unit, // MONTHLY | DAILY | UNLIMITED long allowanceAmount, // 월 제공량 or 일 제공량 (bytes 기준) OffsetDateTime changedAt, // 요금제 변경 시점 String email, // 사용자 이메일 From 83f9775e713b66d80b7b23bb1b3645a72e08b5cd Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:54:23 +0900 Subject: [PATCH 30/33] =?UTF-8?q?UPLUS-16=20fix=20:=20sonarCube=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/PlanChangeUtil.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index 50358f6..ece222c 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; +import com.project.producer.test.PlanUnit; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -54,9 +55,9 @@ public List calculate(List events) { String unitKey = "plan:unit:" + event.subscriptionId(); String prevUnit = redisTemplate.opsForValue().get(unitKey); - if ("ULTIMATE".equals(event.unit())) { + if (event.unit().equals(PlanUnit.ULTIMATE)) { finalLimit = -1L; - } else if ("DAY".equals(event.unit())) { + } else if (event.unit().equals(PlanUnit.DAY)) { finalLimit = event.allowanceAmount(); } else { finalLimit = getMonthFinalLimit(yearMonth, event, prevUnit); From 3ef610425539021a8fff93ff75968272ad81df7b Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:56:02 +0900 Subject: [PATCH 31/33] =?UTF-8?q?UPLUS-16=20fix=20:=20spotless=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/util/LuaScriptLoader.java | 1 + .../project/consumer/util/PlanChangeUtil.java | 2 +- .../com/project/consumer/util/RedisUtil.java | 35 ++++++++++--------- .../project/producer/PlanChangeProducer.java | 2 +- .../com/project/producer/UsageProducer.java | 4 +-- .../producer/schema/PlanChangeSchema.java | 4 +-- .../test/InitSubscriptionPlanRunner.java | 2 +- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index a3df016..b3fdb1d 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -5,6 +5,7 @@ import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.GlobalErrorCode; + import lombok.extern.slf4j.Slf4j; @Slf4j diff --git a/src/main/java/com/project/consumer/util/PlanChangeUtil.java b/src/main/java/com/project/consumer/util/PlanChangeUtil.java index ece222c..7e37603 100644 --- a/src/main/java/com/project/consumer/util/PlanChangeUtil.java +++ b/src/main/java/com/project/consumer/util/PlanChangeUtil.java @@ -9,12 +9,12 @@ import java.util.ArrayList; import java.util.List; -import com.project.producer.test.PlanUnit; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import com.project.producer.schema.CalculatedLimitSchema; import com.project.producer.schema.PlanChangeSchema; +import com.project.producer.test.PlanUnit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/project/consumer/util/RedisUtil.java b/src/main/java/com/project/consumer/util/RedisUtil.java index daa51a8..5b1289d 100644 --- a/src/main/java/com/project/consumer/util/RedisUtil.java +++ b/src/main/java/com/project/consumer/util/RedisUtil.java @@ -25,8 +25,7 @@ public class RedisUtil { private final DefaultRedisScript> script = new DefaultRedisScript<>( LuaScriptLoader.load("lua/usage_batch.lua"), - (Class>) (Class) List.class - ); + (Class>) (Class) List.class); public List applyUsageBatch(List events) { if (events == null || events.isEmpty()) { @@ -62,24 +61,28 @@ public void writePlanChangeBatch(List limits) { .getStringSerializer() .serialize(String.valueOf(limit.limit())); - connection.stringCommands().set( - key, - value, - Expiration.seconds(limit.ttlSec()), - RedisStringCommands.SetOption.UPSERT - ); + connection + .stringCommands() + .set( + key, + value, + Expiration.seconds(limit.ttlSec()), + RedisStringCommands.SetOption.UPSERT); String unitKey = "plan:unit:" + limit.subscriptionId(); byte[] uk = redisTemplate.getStringSerializer().serialize(unitKey); byte[] uv = - redisTemplate.getStringSerializer().serialize(limit.unit().name()); - - connection.stringCommands().set( - uk, - uv, - Expiration.seconds(limit.ttlSec()), - RedisStringCommands.SetOption.UPSERT - ); + redisTemplate + .getStringSerializer() + .serialize(limit.unit().name()); + + connection + .stringCommands() + .set( + uk, + uv, + Expiration.seconds(limit.ttlSec()), + RedisStringCommands.SetOption.UPSERT); } return null; }); diff --git a/src/main/java/com/project/producer/PlanChangeProducer.java b/src/main/java/com/project/producer/PlanChangeProducer.java index 2c0cccf..f2c589d 100644 --- a/src/main/java/com/project/producer/PlanChangeProducer.java +++ b/src/main/java/com/project/producer/PlanChangeProducer.java @@ -3,7 +3,6 @@ import java.time.OffsetDateTime; import java.util.UUID; -import com.project.producer.test.PlanUnit; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @@ -12,6 +11,7 @@ import com.project.global.exception.ApplicationException; import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.schema.PlanChangeSchema; +import com.project.producer.test.PlanUnit; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/project/producer/UsageProducer.java b/src/main/java/com/project/producer/UsageProducer.java index 59e879e..05ad6a4 100644 --- a/src/main/java/com/project/producer/UsageProducer.java +++ b/src/main/java/com/project/producer/UsageProducer.java @@ -3,13 +3,13 @@ import java.time.OffsetDateTime; import java.util.UUID; -import com.project.global.exception.ApplicationException; -import com.project.global.exception.code.domain.GlobalErrorCode; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.domain.GlobalErrorCode; import com.project.producer.schema.UsageEventSchema; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/project/producer/schema/PlanChangeSchema.java b/src/main/java/com/project/producer/schema/PlanChangeSchema.java index 556a17d..a647165 100644 --- a/src/main/java/com/project/producer/schema/PlanChangeSchema.java +++ b/src/main/java/com/project/producer/schema/PlanChangeSchema.java @@ -1,9 +1,9 @@ package com.project.producer.schema; -import com.project.producer.test.PlanUnit; - import java.time.OffsetDateTime; +import com.project.producer.test.PlanUnit; + public record PlanChangeSchema( String eventId, // 멱등성/추적용 long subscriptionId, // 회선 ID diff --git a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java index fdc5811..b5353d3 100644 --- a/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java +++ b/src/main/java/com/project/producer/test/InitSubscriptionPlanRunner.java @@ -3,13 +3,13 @@ import java.time.OffsetDateTime; import java.util.concurrent.ThreadLocalRandom; -import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import com.project.producer.PlanChangeProducer; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Slf4j @Component From 8930e24445279777c99d35b95900ad18780856ff Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:58:34 +0900 Subject: [PATCH 32/33] =?UTF-8?q?UPLUS-16=20fix=20:=20checkstyle=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/LuaScriptLoader.java | 3 ++- src/main/java/com/project/consumer/util/UsageTimeUtil.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index b3fdb1d..608f65f 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -25,5 +25,6 @@ public static String load(String path) { } } - private LuaScriptLoader() {} + private LuaScriptLoader() { + } } diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index 27d149c..784e0f5 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -9,7 +9,8 @@ public class UsageTimeUtil { private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - private UsageTimeUtil() {} + private UsageTimeUtil() { + } public static String toYearMonth(String isoTs) { OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO); From bd24aa168c324b23f9c61d726fe5e16d1b8c8b22 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 19 Jan 2026 10:59:42 +0900 Subject: [PATCH 33/33] =?UTF-8?q?UPLUS-16=20fix=20:=20spotless=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/project/consumer/util/LuaScriptLoader.java | 3 +-- src/main/java/com/project/consumer/util/UsageTimeUtil.java | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/project/consumer/util/LuaScriptLoader.java b/src/main/java/com/project/consumer/util/LuaScriptLoader.java index 608f65f..b3fdb1d 100644 --- a/src/main/java/com/project/consumer/util/LuaScriptLoader.java +++ b/src/main/java/com/project/consumer/util/LuaScriptLoader.java @@ -25,6 +25,5 @@ public static String load(String path) { } } - private LuaScriptLoader() { - } + private LuaScriptLoader() {} } diff --git a/src/main/java/com/project/consumer/util/UsageTimeUtil.java b/src/main/java/com/project/consumer/util/UsageTimeUtil.java index 784e0f5..27d149c 100644 --- a/src/main/java/com/project/consumer/util/UsageTimeUtil.java +++ b/src/main/java/com/project/consumer/util/UsageTimeUtil.java @@ -9,8 +9,7 @@ public class UsageTimeUtil { private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - private UsageTimeUtil() { - } + private UsageTimeUtil() {} public static String toYearMonth(String isoTs) { OffsetDateTime odt = OffsetDateTime.parse(isoTs, ISO);