From 7eb393649f5ae9eb23e324dde6008f03b6fc0a61 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 1 Oct 2025 13:17:05 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat=20:=20=EA=B0=84=EB=8B=A8=ED=95=9C=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=95=EC=8B=9C=EA=B0=84=20logging=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../book_bot/common/aop/LoggingAspect.java | 61 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/main/java/com/fastcampus/book_bot/common/aop/LoggingAspect.java diff --git a/build.gradle b/build.gradle index 07a29b7..5e9ada5 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' // Bean Validation (입력값 검증) implementation 'org.springframework.boot:spring-boot-starter-validation' + // 횡단 관심사 + implementation 'org.springframework.boot:spring-boot-starter-aop' // 이메일 발송 기능 implementation 'org.springframework.boot:spring-boot-starter-mail' // Redis 데이터베이스 연동 diff --git a/src/main/java/com/fastcampus/book_bot/common/aop/LoggingAspect.java b/src/main/java/com/fastcampus/book_bot/common/aop/LoggingAspect.java new file mode 100644 index 0000000..21f4402 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/aop/LoggingAspect.java @@ -0,0 +1,61 @@ +package com.fastcampus.book_bot.common.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +public class LoggingAspect { + + @Around("execution(* com.fastcampus.book_bot.controller..*(..))") + public Object logControllerExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + long startTime = System.currentTimeMillis(); + + String className = joinPoint.getSignature().getDeclaringTypeName(); + String methodName = joinPoint.getSignature().getName(); + + try { + Object result = joinPoint.proceed(); + + long executionTime = System.currentTimeMillis() - startTime; + log.info("[CONTROLLER] {}.{}() 실행 시간: {}ms", + className, methodName, executionTime); + + return result; + + } catch (Exception e) { + long executionTime = System.currentTimeMillis() - startTime; + log.error("[CONTROLLER-FAIL] {}.{}() 실행 시간: {}ms - 에러: {}", + className, methodName, executionTime, e.getMessage()); + throw e; + } + } + + @Around("execution(* com.fastcampus.book_bot.service..*(..))") + public Object logServiceExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + long startTime = System.currentTimeMillis(); + + String className = joinPoint.getSignature().getDeclaringTypeName(); + String methodName = joinPoint.getSignature().getName(); + + try { + Object result = joinPoint.proceed(); + + long executionTime = System.currentTimeMillis() - startTime; + log.info("[SERVICE] {}.{}() 실행 시간: {}ms", + className, methodName, executionTime); + + return result; + + } catch (Exception e) { + long executionTime = System.currentTimeMillis() - startTime; + log.error("[SERVICE-FAIL] {}.{}() 실행 시간: {}ms - 에러: {}", + className, methodName, executionTime, e.getMessage()); + throw e; + } + } +} \ No newline at end of file From 6d3af2b3ee80a4193b418fa552cdbe7f82f66cae Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 1 Oct 2025 14:45:00 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=B2=A0=EC=8A=A4=ED=8A=B8=EC=85=80=EB=9F=AC=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/cache/CacheInitializer.java | 43 ++++++++++ .../common/config/SchedulerConfig.java | 10 +++ .../service/order/BestSellerService.java | 86 ++++++++++++++----- 3 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java create mode 100644 src/main/java/com/fastcampus/book_bot/common/config/SchedulerConfig.java diff --git a/src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java b/src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java new file mode 100644 index 0000000..b028fc6 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java @@ -0,0 +1,43 @@ +package com.fastcampus.book_bot.common.cache; + +import com.fastcampus.book_bot.service.order.BestSellerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CacheInitializer implements ApplicationRunner { + + private final BestSellerService bestSellerService; + private final RedisTemplate redisTemplate; + + /** + * 주간/월간 베스트셀러 존재하지 않을 시, 수동 실행 (분리 필요) + * */ + @Override + public void run(ApplicationArguments args) throws Exception { + log.info("캐시 워밍 시작"); + + boolean weeklyExists = redisTemplate.hasKey("bestseller:weekly"); + boolean monthlyExists = redisTemplate.hasKey("bestseller:monthly"); + + if(!weeklyExists) { + log.info("주간 베스트셀러 캐시 없음"); + bestSellerService.updateWeeklyBestSeller(); + } else { + log.info("주간 베스트셀러 캐시 존재"); + } + + if(!monthlyExists) { + log.info("월간 베스트셀러 캐시 없음"); + bestSellerService.updateMonthBestSeller(); + } else { + log.info("월간 베스트셀러 캐시 존재"); + } + } +} diff --git a/src/main/java/com/fastcampus/book_bot/common/config/SchedulerConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/SchedulerConfig.java new file mode 100644 index 0000000..80b4f64 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/config/SchedulerConfig.java @@ -0,0 +1,10 @@ +package com.fastcampus.book_bot.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + +} diff --git a/src/main/java/com/fastcampus/book_bot/service/order/BestSellerService.java b/src/main/java/com/fastcampus/book_bot/service/order/BestSellerService.java index 777d683..d7e6d40 100644 --- a/src/main/java/com/fastcampus/book_bot/service/order/BestSellerService.java +++ b/src/main/java/com/fastcampus/book_bot/service/order/BestSellerService.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,38 +32,81 @@ public class BestSellerService { @Transactional public List getWeekBestSeller() { - LocalDateTime weekAgo = LocalDateTime.now().minusDays(7); - - List weekSalesList = orderBookRepository.findWeeklyBestSellers(weekAgo); - - List bookList = new ArrayList<>(); - - for (BookSalesDTO bookSalesDTO : weekSalesList) { - Optional book = bookRepository.findById(bookSalesDTO.getBookId()); - book.ifPresent(bookList::add); + try { + Object cached = redisTemplate.opsForValue().get(WEEKLY_BESTSELLER_KEY); + + if (cached instanceof List) { + log.info("주간 베스트셀러 캐시 조회 성공"); + return (List) cached; + } + log.warn("주간 베스트셀러 캐시가 없습니다."); + return new ArrayList<>(); + } catch (Exception e) { + log.error("주간 베스트셀러 캐시 조회 실패",e); + return new ArrayList<>(); } - - redisTemplate.opsForValue().set(WEEKLY_BESTSELLER_KEY, bookList, Duration.ofDays(7)); - - return bookList; } @Transactional public List getMonthBestSeller() { - LocalDateTime monthAgo = LocalDateTime.now().minusDays(30); + try { + Object cached = redisTemplate.opsForValue().get(MONTHLY_BESTSELLER_KEY); + if (cached instanceof List) { + log.info("월간 베스트셀러 캐시 조회 성공"); + return (List) cached; + } + log.warn("월간 베스트셀러 캐시가 없습니다."); + return new ArrayList<>(); + } catch (Exception e) { + log.error("월간 베스트셀러 캐시 조회 실패", e); + return new ArrayList<>(); + } + } + + @Scheduled(cron = "0 0 4 * * MON") + @Transactional(readOnly = true) + public void updateWeeklyBestSeller() { + + try { + log.info("주간 베스트셀러 캐시 갱신 시작"); - List monthlySalesList = orderBookRepository.findMonthlyBestSellers(monthAgo); + LocalDateTime weekAgo = LocalDateTime.now().minusDays(7); + List weeklyBestSellers = orderBookRepository.findWeeklyBestSellers(weekAgo); - List bookList = new ArrayList<>(); + List bookList = new ArrayList<>(); + for (BookSalesDTO bookSalesDTO : weeklyBestSellers) { + Optional book = bookRepository.findById(bookSalesDTO.getBookId()); + book.ifPresent(bookList::add); + } - for (BookSalesDTO bookSalesDTO : monthlySalesList) { - Optional book = bookRepository.findById(bookSalesDTO.getBookId()); - book.ifPresent(bookList::add); + redisTemplate.opsForValue().set(WEEKLY_BESTSELLER_KEY, bookList, Duration.ofDays(7)); + log.info("주간 베스트셀러 캐시 갱신 완료"); + } catch (Exception e) { + log.error("주간 베스트셀러 캐시 갱신 실패", e); } + } + + @Scheduled(cron = "0 0 4 1 * *") + @Transactional(readOnly = true) + public void updateMonthBestSeller() { - redisTemplate.opsForValue().set(MONTHLY_BESTSELLER_KEY, bookList, Duration.ofDays(30)); + try { + log.info("월간 베스트셀러 캐시 갱신 시작"); - return bookList; + LocalDateTime monthAgo = LocalDateTime.now().minusDays(30); + List monthlySalesList = orderBookRepository.findMonthlyBestSellers(monthAgo); + + List bookList = new ArrayList<>(); + for (BookSalesDTO bookSalesDTO : monthlySalesList) { + Optional book = bookRepository.findById(bookSalesDTO.getBookId()); + book.ifPresent(bookList::add); + } + + redisTemplate.opsForValue().set(MONTHLY_BESTSELLER_KEY, bookList, Duration.ofDays(30)); + log.info("월간 베스트셀러 캐시 갱신 완료: {} 건", bookList.size()); + } catch (Exception e) { + log.error("월간 베스트셀러 캐시 갱신 실패", e); + } } } From 638888adff2c78dd685aac4d4e9fc0690d1b8757 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 1 Oct 2025 15:22:38 +0900 Subject: [PATCH 3/6] =?UTF-8?q?chore=20:=20=EC=A3=BC=EC=84=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/service/grade/GradeCacheService.java | 8 +------- .../fastcampus/book_bot/service/order/OrderService.java | 9 +-------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java b/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java index ac9e887..690388e 100644 --- a/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java +++ b/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java @@ -23,23 +23,19 @@ public class GradeCacheService { private final UserGradeRepository userGradeRepository; private static final String GRADE_CACHE_KEY = "grade:"; - private static final Duration CACHE_TTL = Duration.ofHours(24); @Transactional(readOnly = true) public GradeInfo getGradeInfo(String gradeName) { String cacheKey = GRADE_CACHE_KEY + gradeName; try { - // Redis 조회 Object cachedValue = redisTemplate.opsForValue().get(cacheKey); if (cachedValue != null) { - // LinkedHashMap인 경우 GradeInfo 객체로 변환 if (cachedValue instanceof LinkedHashMap) { LinkedHashMap map = (LinkedHashMap) cachedValue; return mapToGradeInfo(map); } - // 이미 GradeInfo 객체인 경우 else if (cachedValue instanceof GradeInfo) { return (GradeInfo) cachedValue; } @@ -48,12 +44,11 @@ else if (cachedValue instanceof GradeInfo) { log.warn("Redis에서 등급 정보 조회 실패: {}, DB에서 조회합니다.", gradeName, e); } - // 캐시에 없거나 오류 발생 시 DB에서 조회 return userGradeRepository.findByGradeName(gradeName) .map(this::convertToGradeInfo) .map(gradeInfo -> { try { - redisTemplate.opsForValue().set(cacheKey, gradeInfo, CACHE_TTL); + redisTemplate.opsForValue().set(cacheKey, gradeInfo, Duration.ofDays(30)); } catch (Exception e) { log.warn("Redis 캐시 저장 실패: {}", gradeName, e); } @@ -81,7 +76,6 @@ public void evictAllGradeCache() { } } - // LinkedHashMap을 GradeInfo로 변환하는 헬퍼 메서드 private GradeInfo mapToGradeInfo(LinkedHashMap map) { try { return GradeInfo.builder() diff --git a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java index 5506bee..428317f 100644 --- a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java +++ b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java @@ -109,7 +109,6 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + book.getBookQuantity()); } - // 주문 정보 저장 Orders order = Orders.builder() .user(user) .orderStatus("ORDER_READY") @@ -123,7 +122,6 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { log.info("주문 저장 성공 - 주문ID: {}, 상태: {}, 총금액: {}", savedOrder.getOrderId(), savedOrder.getOrderStatus(), savedOrder.getTotalPrice()); - // 주문 상품 정보 저장 OrderBook orderBook = OrderBook.builder() .order(savedOrder) .book(book) @@ -135,7 +133,6 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { log.info("주문상품 저장 성공 - 주문상품ID: {}, 수량: {}, 가격: {}", savedOrderBook.getOrderBookId(), savedOrderBook.getQuantity(), savedOrderBook.getPrice()); - // ===== 재고 업데이트 및 알림 처리 ===== updateStockAndNotify(book.getBookId(), ordersDTO.getQuantity()); log.info("=== 주문 저장 프로세스 완료 ==="); @@ -144,7 +141,7 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { log.error("주문 저장 중 오류 발생", e); log.error("오류 상세 정보 - 사용자ID: {}, 상품ID: {}, 오류메시지: {}", user.getUserId(), ordersDTO.getBookId(), e.getMessage()); - throw e; // 트랜잭션 롤백을 위해 예외 재발생 + throw e; } } @@ -154,16 +151,13 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { @Transactional public void updateStockAndNotify(Integer bookId, Integer orderQuantity) { try { - // 현재 재고 조회 Book book = bookRepository.findById(bookId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다: " + bookId)); - // 새로운 재고 수량 계산 Integer newQuantity = book.getBookQuantity() - orderQuantity; log.info("재고 업데이트 시작 - 도서: {}, 기존재고: {}, 주문수량: {}, 새재고: {}", book.getBookName(), book.getBookQuantity(), orderQuantity, newQuantity); - // BookStockManager를 통해 재고 업데이트 및 알림 발송 BookStockManager stockManager = new BookStockManager( bookId, bookRepository, @@ -171,7 +165,6 @@ public void updateStockAndNotify(Integer bookId, Integer orderQuantity) { mailService ); - // 재고 업데이트 (이 메서드 내부에서 알림도 자동으로 발송됨) stockManager.updateStock(newQuantity); log.info("재고 업데이트 및 알림 처리 완료 - 도서ID: {}", bookId); From 2b115311935822887e2504b42624b469ffef1bc8 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 1 Oct 2025 19:08:11 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix=20:=20=EC=A3=BC=EB=AC=B8=EB=9F=89=20?= =?UTF-8?q?=EC=83=81=EC=9C=84=2020%=20=EC=B1=85=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20redis=EC=97=90=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/BookBotApplication.java | 2 + .../common/cache/CacheInitializer.java | 45 ++++++-- .../book_bot/common/config/RedisConfig.java | 13 ++- .../book_bot/dto/order/OrdersDTO.java | 2 +- .../book_bot/repository/BookRepository.java | 13 +++ .../service/book/BookCacheService.java | 106 ++++++++++++++++++ .../book_bot/service/order/OrderService.java | 72 +++++++++--- 7 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java diff --git a/src/main/java/com/fastcampus/book_bot/BookBotApplication.java b/src/main/java/com/fastcampus/book_bot/BookBotApplication.java index 1c00d83..0a872b7 100644 --- a/src/main/java/com/fastcampus/book_bot/BookBotApplication.java +++ b/src/main/java/com/fastcampus/book_bot/BookBotApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; @EnableJpaAuditing @SpringBootApplication +@EnableAsync public class BookBotApplication { public static void main(String[] args) { diff --git a/src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java b/src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java index b028fc6..1eab883 100644 --- a/src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java +++ b/src/main/java/com/fastcampus/book_bot/common/cache/CacheInitializer.java @@ -1,5 +1,6 @@ package com.fastcampus.book_bot.common.cache; +import com.fastcampus.book_bot.service.book.BookCacheService; import com.fastcampus.book_bot.service.order.BestSellerService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -14,30 +15,58 @@ public class CacheInitializer implements ApplicationRunner { private final BestSellerService bestSellerService; + private final BookCacheService bookCacheService; private final RedisTemplate redisTemplate; /** - * 주간/월간 베스트셀러 존재하지 않을 시, 수동 실행 (분리 필요) - * */ + * 애플리케이션 시작 시 캐시 초기화 + * - 주간/월간 베스트셀러 캐시 확인 및 생성 + * - 주문량 상위 20% 도서 Redis 캐싱 + */ @Override public void run(ApplicationArguments args) throws Exception { - log.info("캐시 워밍 시작"); + log.info("=== 캐시 워밍 시작 ==="); + + initTopBooksCache(); + + initBestSellerCache(); + + log.info("=== 캐시 워밍 완료 ==="); + } + + /** + * 베스트셀러 캐시 초기화 + */ + private void initBestSellerCache() { boolean weeklyExists = redisTemplate.hasKey("bestseller:weekly"); boolean monthlyExists = redisTemplate.hasKey("bestseller:monthly"); - if(!weeklyExists) { - log.info("주간 베스트셀러 캐시 없음"); + if (!weeklyExists) { + log.info("주간 베스트셀러 캐시 없음 - 생성 시작"); bestSellerService.updateWeeklyBestSeller(); } else { log.info("주간 베스트셀러 캐시 존재"); } - if(!monthlyExists) { - log.info("월간 베스트셀러 캐시 없음"); + if (!monthlyExists) { + log.info("월간 베스트셀러 캐시 없음 - 생성 시작"); bestSellerService.updateMonthBestSeller(); } else { log.info("월간 베스트셀러 캐시 존재"); } } -} + + /** + * 주문량 상위 20% 도서 캐시 초기화 + */ + private void initTopBooksCache() { + try { + log.info("주문량 상위 20% 도서 Redis 캐싱 시작"); + bookCacheService.BookToRedis(); + log.info("주문량 상위 20% 도서 Redis 캐싱 완료"); + } catch (Exception e) { + log.error("주문량 상위 20% 도서 Redis 캐싱 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/common/config/RedisConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/RedisConfig.java index 3b503c6..ac8a8f4 100644 --- a/src/main/java/com/fastcampus/book_bot/common/config/RedisConfig.java +++ b/src/main/java/com/fastcampus/book_bot/common/config/RedisConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -18,11 +19,19 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); - // ObjectMapper에 JSR310 모듈 추가 + // ObjectMapper에 JSR310 모듈 추가 및 타입 정보 포함 설정 ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + // 타입 정보를 포함하여 직렬화 (LinkedHashMap 문제 해결) + objectMapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + // Key는 String으로, Value는 JSON으로 직렬화 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); @@ -32,4 +41,4 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec template.afterPropertiesSet(); return template; } -} +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/dto/order/OrdersDTO.java b/src/main/java/com/fastcampus/book_bot/dto/order/OrdersDTO.java index 26c81ca..8c22790 100644 --- a/src/main/java/com/fastcampus/book_bot/dto/order/OrdersDTO.java +++ b/src/main/java/com/fastcampus/book_bot/dto/order/OrdersDTO.java @@ -7,7 +7,7 @@ @Builder public class OrdersDTO { - private Long bookId; + private Integer bookId; private String bookName; private String bookAuthor; private String bookPublisher; diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java index e1f27ad..789cd72 100644 --- a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; @Repository @@ -24,4 +25,16 @@ public interface BookRepository extends JpaRepository { @Modifying @Query("UPDATE Book b SET b.bookQuantity = :newQuantity WHERE b.bookId = :bookId") void updateBookQuantity(@Param("bookId") Integer bookId, @Param("newQuantity") Integer newQuantity); + + + @Query(""" + SELECT b + FROM Book b + JOIN OrderBook ob ON b.bookId = ob.book.bookId + GROUP BY b.bookId + order by SUM(ob.quantity) DESC + LIMIT :limit + """) + List findByTop20ByOrderCount(@Param("limit") int limit); + } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java b/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java new file mode 100644 index 0000000..88ec66f --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java @@ -0,0 +1,106 @@ +package com.fastcampus.book_bot.service.book; + +import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.repository.BookRepository; +import com.fastcampus.book_bot.repository.OrderBookRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookCacheService { + + private final RedisTemplate redisTemplate; + private final BookRepository bookRepository; + + private static final String BOOK_CACHE = "book:"; + + /** + * Redis에서 도서 조회 + */ + public Book getBook(Integer bookId) { + String cacheKey = BOOK_CACHE + bookId; + Book cachedBook = (Book) redisTemplate.opsForValue().get(cacheKey); + + if (cachedBook != null) { + log.info("Redis에 도서 존재 - BookId: {}", bookId); + return cachedBook; + } + + return bookRepository.findById(bookId) + .orElseThrow(() -> new IllegalArgumentException("Book not found: " + bookId)); + } + + /** + * 도서를 Redis에 저장 + */ + public void setBookRedis(Integer bookId, Book book) { + String cacheKey = BOOK_CACHE + bookId; + redisTemplate.opsForValue().set(cacheKey, book, Duration.ofDays(7)); + } + + /** + * Redis에서 재고 조회 + */ + public Integer getBookQuantity(Integer bookId) { + String cacheKey = BOOK_CACHE + bookId; + Book cachedBook = (Book) redisTemplate.opsForValue().get(cacheKey); + + if (cachedBook != null) { + return cachedBook.getBookQuantity(); + } + + return null; + } + + /** + * Redis에서 도서 재고 차감 + */ + public void decrementBookQuantity(Integer bookId, Integer quantity) { + String cacheKey = BOOK_CACHE + bookId; + Book cachedBook = (Book) redisTemplate.opsForValue().get(cacheKey); + + if (cachedBook != null) { + cachedBook.setBookQuantity(cachedBook.getBookQuantity() - quantity); + redisTemplate.opsForValue().set(cacheKey, cachedBook, Duration.ofDays(7)); + + log.info("Redis 도서 재고 차감 완료 - BookId: {}", bookId); + } + } + + /** + * 주문량 상위 20% 도서를 Redis에 저장 + */ + @Scheduled(cron = "0 0 4 * * 1") + @Transactional(readOnly = true) + public void BookToRedis() { + + int cachedCount = 0; + + try { + + long totalCount = bookRepository.count(); + int top20Count = (int) Math.ceil(totalCount* 0.2); + + List books = bookRepository.findByTop20ByOrderCount(top20Count); + + for (Book book : books) { + setBookRedis(book.getBookId(), book); + cachedCount++; + } + + log.info("Redis 저장 완료. 완료된 책 개수: {}", cachedCount); + } catch (Exception e) { + log.error("Redis 저장 실패. 완료된 책 개수: {}", cachedCount, e); + } + } + +} diff --git a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java index 428317f..42959ec 100644 --- a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java +++ b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java @@ -11,11 +11,13 @@ import com.fastcampus.book_bot.repository.OrderRepository; import com.fastcampus.book_bot.repository.NotificationSubRepository; import com.fastcampus.book_bot.service.auth.MailService; +import com.fastcampus.book_bot.service.book.BookCacheService; import com.fastcampus.book_bot.service.grade.GradeStrategy; import com.fastcampus.book_bot.service.grade.GradeStrategyFactory; import com.fastcampus.book_bot.service.noti.BookStockManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +32,7 @@ public class OrderService { private final GradeStrategyFactory gradeStrategyFactory; private final OrderBookRepository orderBookRepository; private final OrderRepository orderRepository; + private final BookCacheService bookCacheService; private final BookRepository bookRepository; private final NotificationSubRepository notificationSubRepository; private final MailService mailService; @@ -41,12 +44,12 @@ public OrderCalculationResult calculateOrder(OrdersDTO ordersDTO, User user) { return calculateOrder( user.getUserGrade().getGradeName(), ordersDTO.getPrice() * ordersDTO.getQuantity(), - 0 // 포인트 사용은 기본값 0 (나중에 추가 가능) + user.getPoint() ); } /** - * 주문 금액 계산 (포인트 사용 포함) + * 주문 금액 계산 */ public OrderCalculationResult calculateOrder(OrdersDTO ordersDTO, User user, Integer usedPoints) { return calculateOrder( @@ -97,16 +100,35 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { OrderCalculationResult calculationResult = calculateOrder(ordersDTO, user); log.info("주문 금액 계산 완료 - 최종 결제금액: {}", calculationResult.getFinalAmount()); - Book book = bookRepository.findById(ordersDTO.getBookId().intValue()) - .orElseThrow(() -> { - log.error("도서 조회 실패 - 존재하지 않는 도서ID: {}", ordersDTO.getBookId()); - return new IllegalArgumentException("존재하지 않는 도서입니다: " + ordersDTO.getBookId()); - }); - log.info("도서 조회 성공 - 도서명: {}, 재고: {}", book.getBookName(), book.getBookQuantity()); - - if (book.getBookQuantity() < ordersDTO.getQuantity()) { - log.error("재고 부족 - 요청수량: {}, 현재재고: {}", ordersDTO.getQuantity(), book.getBookQuantity()); - throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + book.getBookQuantity()); + Book book; + boolean isRedis = false; + + Integer currentStock = bookCacheService.getBookQuantity(ordersDTO.getBookId()); + + if (currentStock != null) { + isRedis = true; + book = bookCacheService.getBook(ordersDTO.getBookId()); + log.info("Redis 사용 - BookId: {}", ordersDTO.getBookId()); + + if (currentStock < ordersDTO.getQuantity()) { + log.error("재고 부족 - 요청수량: {}, 현재재고: {}", ordersDTO.getQuantity(), currentStock); + throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + currentStock); + } + + bookCacheService.decrementBookQuantity(ordersDTO.getBookId(), ordersDTO.getQuantity()); + } else { + log.info("Redis 캐시 미스 - DB 조회 시작 - 도서ID: {}", ordersDTO.getBookId()); + book = bookRepository.findById(ordersDTO.getBookId()) + .orElseThrow(() -> { + log.error("도서 조회 실패 - 존재하지 않는 도서ID: {}", ordersDTO.getBookId()); + return new IllegalArgumentException("존재하지 않는 도서입니다: " + ordersDTO.getBookId()); + }); + log.info("DB 조회 완료 - 도서명: {}, 현재재고: {}", book.getBookName(), book.getBookQuantity()); + + if (book.getBookQuantity() < ordersDTO.getQuantity()) { + log.error("재고 부족 - 요청수량: {}, 현재재고: {}", ordersDTO.getQuantity(), book.getBookQuantity()); + throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + book.getBookQuantity()); + } } Orders order = Orders.builder() @@ -133,9 +155,10 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { log.info("주문상품 저장 성공 - 주문상품ID: {}, 수량: {}, 가격: {}", savedOrderBook.getOrderBookId(), savedOrderBook.getQuantity(), savedOrderBook.getPrice()); + updateStockAsync(book.getBookId(), ordersDTO.getQuantity()); updateStockAndNotify(book.getBookId(), ordersDTO.getQuantity()); - log.info("=== 주문 저장 프로세스 완료 ==="); + log.info("=== 주문 저장 프로세스 완료 (Redis 캐시 히트: {}) ===", isRedis); } catch (Exception e) { log.error("주문 저장 중 오류 발생", e); @@ -145,6 +168,29 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { } } + /** + * DB 재고 차감 (비동기) + */ + @Async + public void updateStockAsync(Integer bookId, Integer orderQuantity) { + + try { + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다: " + bookId)); + + Integer newQuantity = book.getBookQuantity() - orderQuantity; + log.info("재고 업데이트 시작 (비동기) - 도서: {}, 기존재고: {}, 주문수량: {}, 새재고: {}", + book.getBookName(), book.getBookQuantity(), orderQuantity, newQuantity); + + bookRepository.updateBookQuantity(bookId, newQuantity); + + log.info("DB 재고 업데이트 완료 (비동기) - 도서ID: {}", bookId); + + } catch (Exception e) { + log.error("DB 재고 업데이트 중 오류 발생 (비동기) - 도서ID: {}", bookId, e); + } + } + /** * 재고 업데이트 및 알림 처리 */ From f56da4da51140847d93b739012186cd962c8e8eb Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 1 Oct 2025 19:31:24 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat=20:=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=EC=95=8C=EB=A6=BC=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/noti/OrderStockService.java | 52 ++++++++++++++++ .../book_bot/service/order/OrderService.java | 59 +------------------ 2 files changed, 55 insertions(+), 56 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java b/src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java new file mode 100644 index 0000000..f180b77 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java @@ -0,0 +1,52 @@ +package com.fastcampus.book_bot.service.noti; + +import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.repository.BookRepository; +import com.fastcampus.book_bot.repository.NotificationSubRepository; +import com.fastcampus.book_bot.service.auth.MailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OrderStockService { + + private final BookRepository bookRepository; + private final NotificationSubRepository notificationSubRepository; + private final MailService mailService; + + /** + * DB 재고 차감 및 알림 처리 (비동기) + */ + @Transactional + @Async + public void updateStockAndNotify(Integer bookId, Integer orderQuantity) { + try { + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다: " + bookId)); + + Integer newQuantity = book.getBookQuantity() - orderQuantity; + log.info("재고 업데이트 시작 (비동기) - 도서: {}, 기존재고: {}, 주문수량: {}, 새재고: {}", + book.getBookName(), book.getBookQuantity(), orderQuantity, newQuantity); + + // BookStockManager를 사용하여 재고 업데이트 및 Observer 패턴으로 알림 발송 + BookStockManager stockManager = new BookStockManager( + bookId, + bookRepository, + notificationSubRepository, + mailService + ); + + stockManager.updateStock(newQuantity); + + log.info("재고 업데이트 및 알림 처리 완료 (비동기) - 도서ID: {}", bookId); + + } catch (Exception e) { + log.error("재고 업데이트 및 알림 처리 중 오류 발생 (비동기) - 도서ID: {}", bookId, e); + } + } +} diff --git a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java index 42959ec..b32a293 100644 --- a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java +++ b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java @@ -15,6 +15,7 @@ import com.fastcampus.book_bot.service.grade.GradeStrategy; import com.fastcampus.book_bot.service.grade.GradeStrategyFactory; import com.fastcampus.book_bot.service.noti.BookStockManager; +import com.fastcampus.book_bot.service.noti.OrderStockService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -33,9 +34,8 @@ public class OrderService { private final OrderBookRepository orderBookRepository; private final OrderRepository orderRepository; private final BookCacheService bookCacheService; + private final OrderStockService orderStockService; private final BookRepository bookRepository; - private final NotificationSubRepository notificationSubRepository; - private final MailService mailService; /** * 주문 금액 계산 (User 객체 기반) @@ -155,8 +155,7 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { log.info("주문상품 저장 성공 - 주문상품ID: {}, 수량: {}, 가격: {}", savedOrderBook.getOrderBookId(), savedOrderBook.getQuantity(), savedOrderBook.getPrice()); - updateStockAsync(book.getBookId(), ordersDTO.getQuantity()); - updateStockAndNotify(book.getBookId(), ordersDTO.getQuantity()); + orderStockService.updateStockAndNotify(book.getBookId(), ordersDTO.getQuantity()); log.info("=== 주문 저장 프로세스 완료 (Redis 캐시 히트: {}) ===", isRedis); @@ -168,56 +167,4 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { } } - /** - * DB 재고 차감 (비동기) - */ - @Async - public void updateStockAsync(Integer bookId, Integer orderQuantity) { - - try { - Book book = bookRepository.findById(bookId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다: " + bookId)); - - Integer newQuantity = book.getBookQuantity() - orderQuantity; - log.info("재고 업데이트 시작 (비동기) - 도서: {}, 기존재고: {}, 주문수량: {}, 새재고: {}", - book.getBookName(), book.getBookQuantity(), orderQuantity, newQuantity); - - bookRepository.updateBookQuantity(bookId, newQuantity); - - log.info("DB 재고 업데이트 완료 (비동기) - 도서ID: {}", bookId); - - } catch (Exception e) { - log.error("DB 재고 업데이트 중 오류 발생 (비동기) - 도서ID: {}", bookId, e); - } - } - - /** - * 재고 업데이트 및 알림 처리 - */ - @Transactional - public void updateStockAndNotify(Integer bookId, Integer orderQuantity) { - try { - Book book = bookRepository.findById(bookId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다: " + bookId)); - - Integer newQuantity = book.getBookQuantity() - orderQuantity; - log.info("재고 업데이트 시작 - 도서: {}, 기존재고: {}, 주문수량: {}, 새재고: {}", - book.getBookName(), book.getBookQuantity(), orderQuantity, newQuantity); - - BookStockManager stockManager = new BookStockManager( - bookId, - bookRepository, - notificationSubRepository, - mailService - ); - - stockManager.updateStock(newQuantity); - - log.info("재고 업데이트 및 알림 처리 완료 - 도서ID: {}", bookId); - - } catch (Exception e) { - log.error("재고 업데이트 및 알림 처리 중 오류 발생 - 도서ID: {}", bookId, e); - throw e; - } - } } \ No newline at end of file From 996c8fbc4b517caa0dbdc41c873375b60ecd6784 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 1 Oct 2025 20:57:49 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat=20:=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=EC=95=8C=EB=A6=BC=20=EB=B3=91=EB=A0=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/common/cache/AsyncConfig.java | 25 +++++++ .../service/noti/BookStockManager.java | 75 +++++++++++++++---- .../service/noti/OrderStockService.java | 5 +- .../service/noti/SubscriptionObserver.java | 7 +- 4 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/cache/AsyncConfig.java diff --git a/src/main/java/com/fastcampus/book_bot/common/cache/AsyncConfig.java b/src/main/java/com/fastcampus/book_bot/common/cache/AsyncConfig.java new file mode 100644 index 0000000..0c97075 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/cache/AsyncConfig.java @@ -0,0 +1,25 @@ +package com.fastcampus.book_bot.common.cache; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "taskExecutor") + public ThreadPoolTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(10); + executor.setMaxPoolSize(50); + executor.setThreadNamePrefix("Stock-Notification-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/BookStockManager.java b/src/main/java/com/fastcampus/book_bot/service/noti/BookStockManager.java index 607639a..3ff1127 100644 --- a/src/main/java/com/fastcampus/book_bot/service/noti/BookStockManager.java +++ b/src/main/java/com/fastcampus/book_bot/service/noti/BookStockManager.java @@ -6,10 +6,13 @@ import com.fastcampus.book_bot.repository.NotificationSubRepository; import com.fastcampus.book_bot.service.auth.MailService; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; // 구체적인 관찰 대상 (Spring Component에서 제외하고 직접 인스턴스 생성) // BookId가 런타임(주문)때 결정되므로 @RequiredArgsConstructor는 사용불가 @@ -20,15 +23,17 @@ public class BookStockManager extends StockSubject { private final BookRepository bookRepository; private final NotificationSubRepository notificationSubRepository; private final MailService mailService; + private final ThreadPoolTaskExecutor taskExecutor; // 단일 생성자 public BookStockManager(Integer bookId, BookRepository bookRepository, NotificationSubRepository notificationSubRepository, - MailService mailService) { + MailService mailService, ThreadPoolTaskExecutor taskExecutor) { this.bookId = bookId; this.bookRepository = bookRepository; this.notificationSubRepository = notificationSubRepository; this.mailService = mailService; + this.taskExecutor = taskExecutor; } // 재고 업데이트 (구매, 입고) 등 @@ -47,25 +52,33 @@ public void updateStock(Integer newQuantity) { public void notifyObservers() { Integer currentStock = getCurrentStock(); - // DB에서 해당 책의 활성 구독자들 중 임계값 조건에 맞는 사용자들 조회 List subList = notificationSubRepository.findActiveByBookIdAndThresholdQuantity(bookId, currentStock); log.info("재고 알림 대상자 조회 - 도서ID: {}, 현재재고: {}, 알림대상: {}명", bookId, currentStock, subList.size()); - // 각 구독자들에게 알림 - for (NotificationSub notificationSub : subList) { - SubscriptionObserver observer = new SubscriptionObserver( - notificationSub.getId(), - notificationSubRepository, - bookRepository, - mailService - ); + String bookTitle = getBookTitle(); - observer.update(this); - } - } + List> futures = subList.stream() + .map(notificationSub -> { + Integer subscriptionId = notificationSub.getId(); + String userEmail = notificationSub.getUser().getUserEmail(); + Integer thresholdQuantity = notificationSub.getThresholdQuantity(); + + return CompletableFuture.runAsync(() -> { + sendNotification(subscriptionId, userEmail, bookTitle, currentStock, thresholdQuantity); + }, taskExecutor); + }) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenRun(() -> log.info("모든 재고 알림 처리 완료 - 도서ID: {}, 처리건수: {}건", + bookId, futures.size())) + .exceptionally(ex -> { + log.error("재고 알림 처리 중 오류 발생 - 도서ID: {}", bookId, ex); + return null; + }); } @Override public int getCurrentStock() { @@ -83,4 +96,40 @@ public String getBookTitle() { Optional bookOpt = bookRepository.findById(bookId); return bookOpt.map(Book::getBookName).orElse("Unknown"); } + + private void sendNotification(Integer subscriptionId, String userEmail, + String bookTitle, int currentStock, int thresholdQuantity) { + try { + if (currentStock <= thresholdQuantity) { + String message = createNotificationMessage(bookTitle, currentStock); + + if (mailService != null) { + mailService.sendStockNotification(userEmail, bookTitle, currentStock, message); + log.info("재고 알림 이메일 발송 완료 - 사용자: {}, 도서: {}, 재고: {}권", + userEmail, bookTitle, currentStock); + } else { + log.warn("MailService가 null입니다. 이메일을 발송할 수 없습니다."); + } + } else { + log.debug("임계값 조건 미충족 - 현재재고: {}, 임계값: {}", + currentStock, thresholdQuantity); + } + } catch (Exception e) { + log.error("재고 알림 이메일 발송 실패 - 사용자: {}, 도서: {}", userEmail, bookTitle, e); + } + } + + /** + * 알림 메시지 생성 + * ← 이 메서드도 새로 추가했습니다! + */ + private String createNotificationMessage(String bookTitle, int currentStock) { + if (currentStock == 0) { + return bookTitle + "' 품절되었습니다!"; + } else if (currentStock <= 3) { + return bookTitle + "' 재고 부족! 남은 수량: " + currentStock + "권"; + } else { + return bookTitle + "' 재고 알림: " + currentStock + "권 남음"; + } + } } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java b/src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java index f180b77..1acf80b 100644 --- a/src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java +++ b/src/main/java/com/fastcampus/book_bot/service/noti/OrderStockService.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +19,7 @@ public class OrderStockService { private final BookRepository bookRepository; private final NotificationSubRepository notificationSubRepository; private final MailService mailService; + private final ThreadPoolTaskExecutor taskExecutor; /** * DB 재고 차감 및 알림 처리 (비동기) @@ -38,7 +40,8 @@ public void updateStockAndNotify(Integer bookId, Integer orderQuantity) { bookId, bookRepository, notificationSubRepository, - mailService + mailService, + taskExecutor ); stockManager.updateStock(newQuantity); diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java b/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java index 2c6b9bf..9968f16 100644 --- a/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java +++ b/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java @@ -38,7 +38,6 @@ public void update(StockSubject subject) { } int currentStock = subject.getCurrentStock(); - int bookId = subject.getBookId(); String bookTitle = subject.getBookTitle(); // 임계값 조건 확인 - 현재 재고가 설정한 임계값 이하일 때만 알림 @@ -68,11 +67,11 @@ public void update(StockSubject subject) { private String createNotificationMessage(String bookTitle, int currentStock) { if (currentStock == 0) { - return "⚠️ '" + bookTitle + "' 품절되었습니다!"; + return bookTitle + "' 품절되었습니다!"; } else if (currentStock <= 3) { - return "🔥 '" + bookTitle + "' 재고 부족! 남은 수량: " + currentStock + "권"; + return bookTitle + "' 재고 부족! 남은 수량: " + currentStock + "권"; } else { - return "📢 '" + bookTitle + "' 재고 알림: " + currentStock + "권 남음"; + return bookTitle + "' 재고 알림: " + currentStock + "권 남음"; } } } \ No newline at end of file