From 9ff069ebc16fe3db90a650b5c87e99d9452360a9 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 1 Oct 2025 21:21:43 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor=20:=20=EC=98=B5=EC=A0=80=EB=B2=84?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/noti/BookStockManager.java | 56 +++++-------------- .../service/noti/SubscriptionObserver.java | 54 +++++++----------- 2 files changed, 32 insertions(+), 78 deletions(-) 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 3ff1127..2e11eb9 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 @@ -12,10 +12,7 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; -// 구체적인 관찰 대상 (Spring Component에서 제외하고 직접 인스턴스 생성) -// BookId가 런타임(주문)때 결정되므로 @RequiredArgsConstructor는 사용불가 @Slf4j public class BookStockManager extends StockSubject { @@ -25,7 +22,6 @@ public class BookStockManager extends StockSubject { private final MailService mailService; private final ThreadPoolTaskExecutor taskExecutor; - // 단일 생성자 public BookStockManager(Integer bookId, BookRepository bookRepository, NotificationSubRepository notificationSubRepository, MailService mailService, ThreadPoolTaskExecutor taskExecutor) { @@ -36,7 +32,6 @@ public BookStockManager(Integer bookId, BookRepository bookRepository, this.taskExecutor = taskExecutor; } - // 재고 업데이트 (구매, 입고) 등 @Transactional public void updateStock(Integer newQuantity) { bookRepository.updateBookQuantity(bookId, newQuantity); @@ -62,12 +57,22 @@ public void notifyObservers() { List> futures = subList.stream() .map(notificationSub -> { + // 트랜잭션 내에서 미리 필요한 데이터를 추출 (Lazy Loading 방지) Integer subscriptionId = notificationSub.getId(); String userEmail = notificationSub.getUser().getUserEmail(); Integer thresholdQuantity = notificationSub.getThresholdQuantity(); + Boolean isActive = notificationSub.getIsActive(); + + SubscriptionObserver observer = new SubscriptionObserver( + subscriptionId, + userEmail, + thresholdQuantity, + isActive, + mailService + ); return CompletableFuture.runAsync(() -> { - sendNotification(subscriptionId, userEmail, bookTitle, currentStock, thresholdQuantity); + observer.update(this); }, taskExecutor); }) .toList(); @@ -78,7 +83,8 @@ public void notifyObservers() { .exceptionally(ex -> { log.error("재고 알림 처리 중 오류 발생 - 도서ID: {}", bookId, ex); return null; - }); } + }); + } @Override public int getCurrentStock() { @@ -96,40 +102,4 @@ 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/SubscriptionObserver.java b/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java index 9968f16..9b5d9d0 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 @@ -9,59 +9,43 @@ import java.util.Optional; -// 구체적인 관찰자 @RequiredArgsConstructor @Slf4j public class SubscriptionObserver implements StockObserver { private final Integer subscriptionId; - private final NotificationSubRepository notificationSubRepository; - private final BookRepository bookRepository; + private final String userEmail; + private final Integer thresholdQuantity; + private final Boolean isActive; private final MailService mailService; @Override public void update(StockSubject subject) { + try { + if (!isActive) { + log.info("비활성화된 구독 - 알림 생략. 구독ID: {}", subscriptionId); + return; + } - Optional subOptional = notificationSubRepository.findById(subscriptionId); - - if (subOptional.isEmpty()) { - log.warn("구독 정보를 찾을 수 없습니다. ID: {}", subscriptionId); - return; - } - - NotificationSub notificationSub = subOptional.get(); - - // 구독이 비활성화되어 있으면 알림하지 않음 - if (!notificationSub.getIsActive()) { - log.info("비활성화된 구독 - 알림 생략. 구독ID: {}", subscriptionId); - return; - } - - int currentStock = subject.getCurrentStock(); - String bookTitle = subject.getBookTitle(); - - // 임계값 조건 확인 - 현재 재고가 설정한 임계값 이하일 때만 알림 - if (currentStock <= notificationSub.getThresholdQuantity()) { + int currentStock = subject.getCurrentStock(); + String bookTitle = subject.getBookTitle(); - String message = createNotificationMessage(bookTitle, currentStock); + if (currentStock <= thresholdQuantity) { + String message = createNotificationMessage(bookTitle, currentStock); - try { - // 이메일 발송 if (mailService != null) { - mailService.sendStockNotification(notificationSub.getUser().getUserEmail(), bookTitle, currentStock, message); + mailService.sendStockNotification(userEmail, bookTitle, currentStock, message); log.info("재고 알림 이메일 발송 완료 - 사용자: {}, 도서: {}, 재고: {}권", - notificationSub.getUser().getUserEmail(), bookTitle, currentStock); + userEmail, bookTitle, currentStock); } else { log.warn("MailService가 null입니다. 이메일을 발송할 수 없습니다."); } - } catch (Exception e) { - log.error("재고 알림 이메일 발송 실패 - 사용자: {}, 도서: {}", - notificationSub.getUser().getUserEmail(), bookTitle, e); + } else { + log.debug("임계값 조건 미충족 - 현재재고: {}, 임계값: {}", + currentStock, thresholdQuantity); } - - } else { - log.debug("임계값 조건 미충족 - 현재재고: {}, 임계값: {}", - currentStock, notificationSub.getThresholdQuantity()); + } catch (Exception e) { + log.error("재고 알림 이메일 발송 실패 - 구독ID: {}, 사용자: {}", subscriptionId, userEmail, e); } } From 60253ce1dcd894fb5122a72688bdf84bc08223e6 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 4 Oct 2025 13:58:49 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix=20:=20=EA=B2=BD=ED=95=A9=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/common/config/RedisConfig.java | 9 +- .../service/book/BookCacheService.java | 110 ++++++++++++++---- .../book_bot/service/order/OrderService.java | 62 ++++++---- 3 files changed, 135 insertions(+), 46 deletions(-) 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 ac8a8f4..8d4ff20 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 @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -19,12 +20,10 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); - // 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) @@ -32,7 +31,6 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec ObjectMapper.DefaultTyping.NON_FINAL ); - // Key는 String으로, Value는 JSON으로 직렬화 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); template.setHashKeySerializer(new StringRedisSerializer()); @@ -41,4 +39,9 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec template.afterPropertiesSet(); return template; } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } } \ 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 index 88ec66f..29fb109 100644 --- a/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java @@ -2,15 +2,17 @@ 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.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; +import java.util.Collections; import java.util.List; @Service @@ -19,9 +21,11 @@ public class BookCacheService { private final RedisTemplate redisTemplate; + private final StringRedisTemplate stringRedisTemplate; private final BookRepository bookRepository; private static final String BOOK_CACHE = "book:"; + private static final String BOOK_QUANTITY = "book:quantity:"; /** * Redis에서 도서 조회 @@ -40,39 +44,107 @@ public Book getBook(Integer bookId) { } /** - * 도서를 Redis에 저장 + * 도서를 Redis에 저장 (Book 객체 + 재고 수량) */ public void setBookRedis(Integer bookId, Book book) { - String cacheKey = BOOK_CACHE + bookId; - redisTemplate.opsForValue().set(cacheKey, book, Duration.ofDays(7)); + String bookCacheKey = BOOK_CACHE + bookId; + String quantityKey = BOOK_QUANTITY + bookId; + + redisTemplate.opsForValue().set(bookCacheKey, book, Duration.ofDays(7)); + + stringRedisTemplate.opsForValue().set( + quantityKey, + String.valueOf(book.getBookQuantity()), + Duration.ofDays(7) + ); + + log.info("Redis 저장 완료 - BookId: {}, 재고: {}", bookId, book.getBookQuantity()); } /** * Redis에서 재고 조회 */ public Integer getBookQuantity(Integer bookId) { - String cacheKey = BOOK_CACHE + bookId; - Book cachedBook = (Book) redisTemplate.opsForValue().get(cacheKey); + String quantityKey = BOOK_QUANTITY + bookId; - if (cachedBook != null) { - return cachedBook.getBookQuantity(); + String quantity = stringRedisTemplate.opsForValue().get(quantityKey); + + if (quantity != null) { + return Integer.valueOf(quantity); } return null; } /** - * Redis에서 도서 재고 차감 + * Lua Script를 사용한 원자적 재고 차감 */ - public void decrementBookQuantity(Integer bookId, Integer quantity) { - String cacheKey = BOOK_CACHE + bookId; - Book cachedBook = (Book) redisTemplate.opsForValue().get(cacheKey); + public Long decrementBookQuantity(Integer bookId, Integer quantity) { + String quantityKey = BOOK_QUANTITY + bookId; + + String luaScript = + "local current = redis.call('get', KEYS[1]) " + + "if current == false then " + + " return -1 " + + "end " + + "current = tonumber(current) " + + "if current == nil then " + + " return -3 " + + "end " + + "if current < tonumber(ARGV[1]) then " + + " return -2 " + + "end " + + "return redis.call('decrby', KEYS[1], ARGV[1])"; + + DefaultRedisScript redisScript = new DefaultRedisScript<>(luaScript, Long.class); + + Long result = stringRedisTemplate.execute( + redisScript, + Collections.singletonList(quantityKey), + quantity.toString() + ); + + if (result == null) { + log.error("Lua Script 실행 실패 - BookId: {}", bookId); + throw new RuntimeException("재고 차감 실패"); + } - if (cachedBook != null) { - cachedBook.setBookQuantity(cachedBook.getBookQuantity() - quantity); - redisTemplate.opsForValue().set(cacheKey, cachedBook, Duration.ofDays(7)); + if (result == -1) { + log.error("재고 정보 없음 - BookId: {}", bookId); + throw new IllegalArgumentException("재고 정보가 존재하지 않습니다: " + bookId); + } - log.info("Redis 도서 재고 차감 완료 - BookId: {}", bookId); + if (result == -3) { + log.error("재고 데이터 타입 오류 - BookId: {}", bookId); + throw new IllegalStateException("재고 데이터 타입 오류. Redis 데이터를 확인하세요."); + } + + if (result == -2) { + Integer currentStock = getBookQuantity(bookId); + log.error("재고 부족 - BookId: {}, 요청수량: {}, 현재재고: {}", + bookId, quantity, currentStock); + throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + currentStock); + } + + log.info("원자적 재고 차감 성공 - BookId: {}, 차감수량: {}, 남은재고: {}", + bookId, quantity, result); + + updateBookObjectQuantity(bookId, result.intValue()); + + return result; + } + + /** + * Book 객체의 재고 수량 업데이트 + */ + private void updateBookObjectQuantity(Integer bookId, Integer newQuantity) { + String bookCacheKey = BOOK_CACHE + bookId; + Book cachedBook = (Book) redisTemplate.opsForValue().get(bookCacheKey); + + if (cachedBook != null) { + cachedBook.setBookQuantity(newQuantity); + redisTemplate.opsForValue().set(bookCacheKey, cachedBook, Duration.ofDays(7)); + log.debug("Book 객체 재고 업데이트 완료 - BookId: {}, 새 재고: {}", bookId, newQuantity); } } @@ -86,9 +158,8 @@ public void BookToRedis() { int cachedCount = 0; try { - long totalCount = bookRepository.count(); - int top20Count = (int) Math.ceil(totalCount* 0.2); + int top20Count = (int) Math.ceil(totalCount * 0.2); List books = bookRepository.findByTop20ByOrderCount(top20Count); @@ -102,5 +173,4 @@ public void BookToRedis() { log.error("Redis 저장 실패. 완료된 책 개수: {}", cachedCount, e); } } - -} +} \ No newline at end of file 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 b32a293..d509c4a 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 @@ -9,16 +9,12 @@ import com.fastcampus.book_bot.repository.BookRepository; import com.fastcampus.book_bot.repository.OrderBookRepository; 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 com.fastcampus.book_bot.service.noti.OrderStockService; 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; @@ -106,29 +102,36 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { Integer currentStock = bookCacheService.getBookQuantity(ordersDTO.getBookId()); if (currentStock != null) { + // ✅ Redis 캐시 사용 - Lua Script로 원자적 재고 차감 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); + log.info("Redis 캐시 히트 - BookId: {}, 현재재고: {}", + ordersDTO.getBookId(), currentStock); + + try { + // ⭐ 핵심: 원자적 재고 차감 (Lua Script 실행) + Long remainingStock = bookCacheService.decrementBookQuantity( + ordersDTO.getBookId(), + ordersDTO.getQuantity() + ); + + log.info("✅ 원자적 재고 차감 성공 - 남은재고: {}", remainingStock); + + // Book 객체 조회 (재고는 이미 차감됨) + book = bookCacheService.getBook(ordersDTO.getBookId()); + + } catch (IllegalStateException e) { + log.error("재고 부족으로 주문 실패 - BookId: {}, 요청수량: {}, 현재재고: {}", + ordersDTO.getBookId(), ordersDTO.getQuantity(), currentStock); + throw e; + } catch (IllegalArgumentException e) { + log.error("Redis 재고 정보 없음 - DB로 폴백 - BookId: {}", ordersDTO.getBookId()); + isRedis = false; + book = getBookFromDB(ordersDTO); } - 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()); - } + book = getBookFromDB(ordersDTO); } Orders order = Orders.builder() @@ -157,8 +160,11 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { orderStockService.updateStockAndNotify(book.getBookId(), ordersDTO.getQuantity()); - log.info("=== 주문 저장 프로세스 완료 (Redis 캐시 히트: {}) ===", isRedis); + log.info("=== ✅ 주문 저장 프로세스 완료 (Redis 사용: {}) ===", isRedis); + } catch (IllegalStateException e) { + log.error("주문 실패 - {}", e.getMessage()); + throw e; } catch (Exception e) { log.error("주문 저장 중 오류 발생", e); log.error("오류 상세 정보 - 사용자ID: {}, 상품ID: {}, 오류메시지: {}", @@ -167,4 +173,14 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { } } + private Book getBookFromDB(OrdersDTO ordersDTO) { + Book book = bookRepository.findById(ordersDTO.getBookId()) + .orElseThrow(() -> { + log.error("도서 조회 실패 - 존재하지 않는 ID"); + return new IllegalArgumentException("존재하지 않는 도서입니다."); + }); + + return book; + } + } \ No newline at end of file From cfd3f7486f34e6c1071cd08215570ec6d4ea4df1 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 4 Oct 2025 14:28:55 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor=20:=20Redis=EC=9D=98=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20value=EB=A5=BC=20String=20->=20Hash=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일부 필드를 조회하려면 String으로는 읽지 못함. 따라서 Hash로 일부 필드조회를 가능하도록 변경 --- .../service/book/BookCacheService.java | 85 +++++++++++-------- .../book_bot/service/order/OrderService.java | 7 +- 2 files changed, 53 insertions(+), 39 deletions(-) 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 index 29fb109..f1e65c0 100644 --- a/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java @@ -13,7 +13,10 @@ import java.time.Duration; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @@ -25,38 +28,43 @@ public class BookCacheService { private final BookRepository bookRepository; private static final String BOOK_CACHE = "book:"; - private static final String BOOK_QUANTITY = "book:quantity:"; /** * 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; + Map bookData = stringRedisTemplate.opsForHash().entries(cacheKey); + + if (!bookData.isEmpty()) { + log.info("Redis Hash에서 도서 조회 성공 - BookId: {}", bookId); + return convertHashToBook(bookData); } + + log.info("Redis 조회 실패 -> DB 조회 - BookId: {}", bookId); return bookRepository.findById(bookId) .orElseThrow(() -> new IllegalArgumentException("Book not found: " + bookId)); } /** - * 도서를 Redis에 저장 (Book 객체 + 재고 수량) + * 도서를 Redis에 저장 */ public void setBookRedis(Integer bookId, Book book) { - String bookCacheKey = BOOK_CACHE + bookId; - String quantityKey = BOOK_QUANTITY + bookId; + String cacheKey = BOOK_CACHE + bookId; - redisTemplate.opsForValue().set(bookCacheKey, book, Duration.ofDays(7)); + Map bookData = new HashMap<>(); + bookData.put("bookId", String.valueOf(book.getBookId())); + bookData.put("title", book.getBookName()); + bookData.put("author", book.getBookAuthor() != null ? book.getBookAuthor() : ""); + bookData.put("publisher", book.getBookPublisher() != null ? book.getBookPublisher() : ""); + bookData.put("price", String.valueOf(book.getBookDiscount())); + bookData.put("quantity", String.valueOf(book.getBookQuantity())); + bookData.put("isbn", book.getBookIsbn() != null ? book.getBookIsbn() : ""); - stringRedisTemplate.opsForValue().set( - quantityKey, - String.valueOf(book.getBookQuantity()), - Duration.ofDays(7) - ); + stringRedisTemplate.opsForHash().putAll(cacheKey, bookData); + stringRedisTemplate.expire(cacheKey, Duration.ofDays(7)); log.info("Redis 저장 완료 - BookId: {}, 재고: {}", bookId, book.getBookQuantity()); } @@ -65,12 +73,12 @@ public void setBookRedis(Integer bookId, Book book) { * Redis에서 재고 조회 */ public Integer getBookQuantity(Integer bookId) { - String quantityKey = BOOK_QUANTITY + bookId; + String cacheKey = BOOK_CACHE + bookId; - String quantity = stringRedisTemplate.opsForValue().get(quantityKey); + Object quantity = stringRedisTemplate.opsForHash().get(cacheKey, "quantity"); if (quantity != null) { - return Integer.valueOf(quantity); + return Integer.valueOf((String) quantity); } return null; @@ -80,27 +88,36 @@ public Integer getBookQuantity(Integer bookId) { * Lua Script를 사용한 원자적 재고 차감 */ public Long decrementBookQuantity(Integer bookId, Integer quantity) { - String quantityKey = BOOK_QUANTITY + bookId; + String cacheKey = BOOK_CACHE + bookId; String luaScript = - "local current = redis.call('get', KEYS[1]) " + + // 1. Hash에서 quantity 필드 조회 + "local current = redis.call('HGET', KEYS[1], 'quantity') " + + + // 2. 키가 없는 경우 "if current == false then " + " return -1 " + "end " + + + // 3. 숫자로 변환 "current = tonumber(current) " + "if current == nil then " + " return -3 " + "end " + + + // 4. 재고 부족 체크 "if current < tonumber(ARGV[1]) then " + " return -2 " + "end " + - "return redis.call('decrby', KEYS[1], ARGV[1])"; + + // 5. 재고 차감 (원자적 연산!) + "return redis.call('HINCRBY', KEYS[1], 'quantity', -ARGV[1])"; DefaultRedisScript redisScript = new DefaultRedisScript<>(luaScript, Long.class); Long result = stringRedisTemplate.execute( redisScript, - Collections.singletonList(quantityKey), + Collections.singletonList(cacheKey), quantity.toString() ); @@ -123,29 +140,29 @@ public Long decrementBookQuantity(Integer bookId, Integer quantity) { Integer currentStock = getBookQuantity(bookId); log.error("재고 부족 - BookId: {}, 요청수량: {}, 현재재고: {}", bookId, quantity, currentStock); - throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + currentStock); + throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + currentStock + " BookId: " + bookId); } log.info("원자적 재고 차감 성공 - BookId: {}, 차감수량: {}, 남은재고: {}", bookId, quantity, result); - updateBookObjectQuantity(bookId, result.intValue()); - return result; } /** - * Book 객체의 재고 수량 업데이트 + * Hash 데이터를 Book 객체로 변환 */ - private void updateBookObjectQuantity(Integer bookId, Integer newQuantity) { - String bookCacheKey = BOOK_CACHE + bookId; - Book cachedBook = (Book) redisTemplate.opsForValue().get(bookCacheKey); - - if (cachedBook != null) { - cachedBook.setBookQuantity(newQuantity); - redisTemplate.opsForValue().set(bookCacheKey, cachedBook, Duration.ofDays(7)); - log.debug("Book 객체 재고 업데이트 완료 - BookId: {}, 새 재고: {}", bookId, newQuantity); - } + private Book convertHashToBook(Map bookData) { + Book book = new Book(); + book.setBookId(Integer.valueOf((String) bookData.get("bookId"))); + book.setBookName((String) bookData.get("title")); + book.setBookAuthor((String) bookData.get("author")); + book.setBookPublisher((String) bookData.get("publisher")); + book.setBookDiscount(Integer.valueOf((String) bookData.get("price"))); + book.setBookQuantity(Integer.valueOf((String) bookData.get("quantity"))); + book.setBookIsbn((String) bookData.get("isbn")); + + return book; } /** 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 d509c4a..ed048d0 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 @@ -102,21 +102,18 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { Integer currentStock = bookCacheService.getBookQuantity(ordersDTO.getBookId()); if (currentStock != null) { - // ✅ Redis 캐시 사용 - Lua Script로 원자적 재고 차감 isRedis = true; log.info("Redis 캐시 히트 - BookId: {}, 현재재고: {}", ordersDTO.getBookId(), currentStock); try { - // ⭐ 핵심: 원자적 재고 차감 (Lua Script 실행) Long remainingStock = bookCacheService.decrementBookQuantity( ordersDTO.getBookId(), ordersDTO.getQuantity() ); - log.info("✅ 원자적 재고 차감 성공 - 남은재고: {}", remainingStock); + log.info("원자적 재고 차감 성공 - 남은재고: {}", remainingStock); - // Book 객체 조회 (재고는 이미 차감됨) book = bookCacheService.getBook(ordersDTO.getBookId()); } catch (IllegalStateException e) { @@ -160,7 +157,7 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { orderStockService.updateStockAndNotify(book.getBookId(), ordersDTO.getQuantity()); - log.info("=== ✅ 주문 저장 프로세스 완료 (Redis 사용: {}) ===", isRedis); + log.info("=== 주문 저장 프로세스 완료 (Redis 사용: {}) ===", isRedis); } catch (IllegalStateException e) { log.error("주문 실패 - {}", e.getMessage()); From 1bd9a6b7ba948add4876a385faa9e89aef006833 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 4 Oct 2025 15:08:16 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat=20:=20=EB=B6=80=ED=95=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/controller/MainController.java | 19 ++++++++++++ .../book/BestSellerApiController.java | 26 +++++++++++++++++ .../service/order/BestSellerService.java | 29 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 src/main/java/com/fastcampus/book_bot/controller/book/BestSellerApiController.java diff --git a/src/main/java/com/fastcampus/book_bot/controller/MainController.java b/src/main/java/com/fastcampus/book_bot/controller/MainController.java index 8a8ac65..0deb592 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/MainController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/MainController.java @@ -44,4 +44,23 @@ public String main(Model model) { return "index"; } + + @GetMapping("/test/db") + public String mainWithDB(Model model) { + try { + List monthlyBestSellers = bestSellerService.getMonthBestSellerFromDB(); + List weeklyBestSellers = bestSellerService.getWeekBestSellerFromDB(); + + model.addAttribute("monthlyBestSellers", monthlyBestSellers); + model.addAttribute("weeklyBestSellers", weeklyBestSellers); + + log.info("DB BestSellers loaded - Monthly: {}, Weekly: {}", + monthlyBestSellers.size(), weeklyBestSellers.size()); + } catch (Exception e) { + log.error("Error loading bestsellers from DB", e); + model.addAttribute("monthlyBestSellers", List.of()); + model.addAttribute("weeklyBestSellers", List.of()); + } + return "index"; + } } diff --git a/src/main/java/com/fastcampus/book_bot/controller/book/BestSellerApiController.java b/src/main/java/com/fastcampus/book_bot/controller/book/BestSellerApiController.java new file mode 100644 index 0000000..09a5816 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/book/BestSellerApiController.java @@ -0,0 +1,26 @@ +package com.fastcampus.book_bot.controller.book; + +import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.service.order.BestSellerService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class BestSellerApiController { + + private final BestSellerService bestSellerService; + + @GetMapping("/api/bestseller/redis") + public List getBestSellerRedis() { + return bestSellerService.getMonthBestSeller(); + } + + @GetMapping("/api/bestseller/db") + public List getBestSellerDB() { + return bestSellerService.getMonthBestSellerFromDB(); + } +} 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 d7e6d40..58b8792 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 @@ -109,4 +109,33 @@ public void updateMonthBestSeller() { log.error("월간 베스트셀러 캐시 갱신 실패", e); } } + + // Redis 대신 DB에서 직접 조회하는 메서드 + @Transactional(readOnly = true) + public List getWeekBestSellerFromDB() { + log.info("주간 베스트셀러 DB 직접 조회"); + LocalDateTime weekAgo = LocalDateTime.now().minusDays(7); + List weeklyBestSellers = orderBookRepository.findWeeklyBestSellers(weekAgo); + + List bookList = new ArrayList<>(); + for (BookSalesDTO bookSalesDTO : weeklyBestSellers) { + Optional book = bookRepository.findById(bookSalesDTO.getBookId()); + book.ifPresent(bookList::add); + } + return bookList; + } + + @Transactional(readOnly = true) + public List getMonthBestSellerFromDB() { + log.info("월간 베스트셀러 DB 직접 조회"); + 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); + } + return bookList; + } } From 1174b69a46167999c8b13c705b3874819ebd358a Mon Sep 17 00:00:00 2001 From: JiHoon Date: Mon, 6 Oct 2025 14:06:31 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat=20:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=EA=B2=80=EC=83=89=EC=96=B4=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EC=86=8C=EB=B6=84=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../book_bot/common/config/KomoranConfig.java | 15 + .../book_bot/controller/MainController.java | 8 +- .../controller/book/BookController.java | 9 +- .../domain/navigation/PopularKeyword.java | 42 ++ .../book_bot/dto/keyword/KeywordDTO.java | 42 ++ .../navigation/PopularKeywordService.java | 97 ++++ .../migration/V18__Create_table_popular.sql | 7 + .../templates/common/popularKeywords.html | 187 ++++++- src/main/resources/templates/index.html | 458 ++++++++++++++---- 10 files changed, 764 insertions(+), 105 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/config/KomoranConfig.java create mode 100644 src/main/java/com/fastcampus/book_bot/domain/navigation/PopularKeyword.java create mode 100644 src/main/java/com/fastcampus/book_bot/dto/keyword/KeywordDTO.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/navigation/PopularKeywordService.java create mode 100644 src/main/resources/db/migration/V18__Create_table_popular.sql diff --git a/build.gradle b/build.gradle index 5e9ada5..20e7ed6 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ configurations { repositories { mavenCentral() + maven { url 'https://jitpack.io' } } dependencies { @@ -65,6 +66,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' // JWT JSON 처리(Jackson 연동) implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // KOMORAN 형태소 분석 + implementation 'com.github.shin285:KOMORAN:3.3.4' } tasks.named('test') { diff --git a/src/main/java/com/fastcampus/book_bot/common/config/KomoranConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/KomoranConfig.java new file mode 100644 index 0000000..40f6dd7 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/config/KomoranConfig.java @@ -0,0 +1,15 @@ +package com.fastcampus.book_bot.common.config; + +import kr.co.shineware.nlp.komoran.constant.DEFAULT_MODEL; +import kr.co.shineware.nlp.komoran.core.Komoran; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class KomoranConfig { + + @Bean + public Komoran komoran() { + return new Komoran(DEFAULT_MODEL.FULL); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/MainController.java b/src/main/java/com/fastcampus/book_bot/controller/MainController.java index 0deb592..d39e990 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/MainController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/MainController.java @@ -1,6 +1,8 @@ package com.fastcampus.book_bot.controller; import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.dto.keyword.KeywordDTO; +import com.fastcampus.book_bot.service.navigation.PopularKeywordService; import com.fastcampus.book_bot.service.order.BestSellerService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,6 +18,7 @@ public class MainController { private final BestSellerService bestSellerService; + private final PopularKeywordService popularKeywordService; @GetMapping public String main(Model model) { @@ -28,16 +31,17 @@ public String main(Model model) { bestSellerService.getWeekBestSeller(); List weeklyBestSellers = bestSellerService.getWeekBestSeller(); - // 모델에 데이터 추가 + List popularKeywords = popularKeywordService.getPopularKeywords(5); + model.addAttribute("monthlyBestSellers", monthlyBestSellers); model.addAttribute("weeklyBestSellers", weeklyBestSellers); + model.addAttribute("popularKeywords", popularKeywords); log.info("BestSellers loaded - Monthly: {}, Weekly: {}", monthlyBestSellers.size(), weeklyBestSellers.size()); } catch (Exception e) { log.error("Error loading bestsellers", e); - // 에러 발생시 빈 리스트로 처리 model.addAttribute("monthlyBestSellers", List.of()); model.addAttribute("weeklyBestSellers", List.of()); } diff --git a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java index c24ab04..ef80031 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java @@ -3,6 +3,8 @@ import com.fastcampus.book_bot.domain.book.Book; import com.fastcampus.book_bot.dto.book.SearchDTO; import com.fastcampus.book_bot.service.book.BookSearchService; +import com.fastcampus.book_bot.service.navigation.PopularKeywordService; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -17,14 +19,13 @@ import java.util.Optional; @Controller +@RequiredArgsConstructor @Slf4j public class BookController { private final BookSearchService bookSearchService; + private final PopularKeywordService popularKeywordService; - public BookController(BookSearchService bookSearchService) { - this.bookSearchService = bookSearchService; - } @GetMapping("/search") public String searchBooks(@ModelAttribute SearchDTO searchDTO, @@ -37,6 +38,8 @@ public String searchBooks(@ModelAttribute SearchDTO searchDTO, pageable ); + popularKeywordService.recordKeyword(searchDTO); + searchDTO.setSearchResult(searchResult); searchDTO.setPageInfo(pageable); diff --git a/src/main/java/com/fastcampus/book_bot/domain/navigation/PopularKeyword.java b/src/main/java/com/fastcampus/book_bot/domain/navigation/PopularKeyword.java new file mode 100644 index 0000000..ea75be5 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/navigation/PopularKeyword.java @@ -0,0 +1,42 @@ +package com.fastcampus.book_bot.domain.navigation; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "popular_keyword") +@EntityListeners(AuditingEntityListener.class) +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class PopularKeyword { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Integer id; + + @Column(name = "KEYWORD", nullable = false, length = 100) + private String keyword; + + @Column(name = "COUNT", nullable = false) + @Builder.Default + private Integer count = 0; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/fastcampus/book_bot/dto/keyword/KeywordDTO.java b/src/main/java/com/fastcampus/book_bot/dto/keyword/KeywordDTO.java new file mode 100644 index 0000000..01f823b --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/keyword/KeywordDTO.java @@ -0,0 +1,42 @@ +package com.fastcampus.book_bot.dto.keyword; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KeywordDTO { + + /** + * 검색어 + */ + private String keyword; + + /** + * 검색 횟수 + */ + private Integer count; + + + /** + * 검색 날짜 + */ + private LocalDate searchDate; + + /** + * 생성 시간 + */ + private LocalDateTime createdAt; + + public KeywordDTO(String keyword, Integer count) { + this.keyword = keyword; + this.count = count; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/service/navigation/PopularKeywordService.java b/src/main/java/com/fastcampus/book_bot/service/navigation/PopularKeywordService.java new file mode 100644 index 0000000..db992d0 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/navigation/PopularKeywordService.java @@ -0,0 +1,97 @@ +package com.fastcampus.book_bot.service.navigation; + +import com.fastcampus.book_bot.dto.book.SearchDTO; +import com.fastcampus.book_bot.dto.keyword.KeywordDTO; +import kr.co.shineware.nlp.komoran.core.Komoran; +import kr.co.shineware.nlp.komoran.model.KomoranResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PopularKeywordService { + + private final RedisTemplate redisTemplate; + private final Komoran komoran; + + private static final String POPULAR_KEYWORD = "popular:keywords:"; + + /** + * 검색시 호출 + * 키워드 추출 후 Redis에 저장 + */ + public void recordKeyword(SearchDTO searchDTO) { + List keywords = extractKeywords(searchDTO.getKeyword()); + + String redisKey = POPULAR_KEYWORD + LocalDate.now(); + + keywords.forEach(keyword -> { + redisTemplate.opsForZSet().incrementScore(redisKey, keyword, 1); + }); + } + + /** + * 인기 검색어 조회 + */ + public List getPopularKeywords(int limit) { + try { + String redisKey = POPULAR_KEYWORD + LocalDate.now(); + Set> results = + redisTemplate.opsForZSet().reverseRangeWithScores(redisKey, 0, limit - 1); + + if (results == null || results.isEmpty()) { + log.debug("오늘의 인기 검색어가 없습니다."); + return List.of(); + } + + return results.stream() + .map(tuple -> new KeywordDTO( + tuple.getValue(), + tuple.getScore().intValue() + )) + .collect(Collectors.toList()); + + } catch (Exception e) { + log.error("인기 검색어 조회 중 오류 발생", e); + return List.of(); + } + } + + /** + * 키워드 추출 + */ + private List extractKeywords(String query) { + + if (query == null || query.trim().isEmpty()) { + return List.of(); + } + + try { + + KomoranResult result = komoran.analyze(query); + List keywords = result.getNouns(); + + return keywords.stream() + .filter(keyword -> keyword.length() >= 2) + .distinct() + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("키워드 추출 중 오류 발생: {}", query, e); + + // 공백으로 분리 + return List.of(query.trim().split("\\s+")) + .stream() + .filter(word -> word.length() >= 2) + .collect(Collectors.toList()); + } + } +} diff --git a/src/main/resources/db/migration/V18__Create_table_popular.sql b/src/main/resources/db/migration/V18__Create_table_popular.sql new file mode 100644 index 0000000..2184dda --- /dev/null +++ b/src/main/resources/db/migration/V18__Create_table_popular.sql @@ -0,0 +1,7 @@ +CREATE TABLE popular_keyword ( + ID INT AUTO_INCREMENT PRIMARY KEY, + KEYWORD VARCHAR(100) NOT NULL, + COUNT INT NOT NULL DEFAULT 0, + CREATED_AT DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UPDATED_AT DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) +); \ No newline at end of file diff --git a/src/main/resources/templates/common/popularKeywords.html b/src/main/resources/templates/common/popularKeywords.html index ea82b3e..c4ff89b 100644 --- a/src/main/resources/templates/common/popularKeywords.html +++ b/src/main/resources/templates/common/popularKeywords.html @@ -1,15 +1,180 @@