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/mysql-docker/docker-compose.yml b/mysql-docker/docker-compose.yml index 0401ecd..48b3e9f 100644 --- a/mysql-docker/docker-compose.yml +++ b/mysql-docker/docker-compose.yml @@ -35,7 +35,12 @@ services: - redis-data:/data networks: - bookshop-network - command: redis-server --appendonly yes + command: > + redis-server + --appendonly yes + --maxmemory 512mb + --maxmemory-policy volatile-lru + --maxmemory-samples 5 flyway: image: flyway/flyway:9.22.3 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/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/common/config/WebConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/WebConfig.java index 9802ac8..8c726bb 100644 --- a/src/main/java/com/fastcampus/book_bot/common/config/WebConfig.java +++ b/src/main/java/com/fastcampus/book_bot/common/config/WebConfig.java @@ -15,7 +15,7 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) - .addPathPatterns("/order/**", "/api/order/**") + .addPathPatterns("/order/**", "/api/order/**", "/book/**", "/api/recent-books") .excludePathPatterns("/login", "/register"); } } diff --git a/src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java b/src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java index 4c86647..63c58b7 100644 --- a/src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java +++ b/src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java @@ -5,9 +5,12 @@ import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; import lombok.Data; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import java.security.Key; import java.util.Date; @@ -16,6 +19,7 @@ @Component @Data @ConfigurationProperties(prefix = "jwt") +@Slf4j public class JwtUtil { private String secretKey; @@ -107,4 +111,19 @@ public boolean validateToken(String token, Integer userId) { return (tokenUserId.equals(userId) && !isTokenExpired(token)); } + public Integer extractUserIdFromRequest(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + try { + String accessToken = authorization.substring(7); + if (!isTokenExpired(accessToken)) { + return extractUserId(accessToken); + } + } catch (Exception e) { + log.debug("Authorization Header JWT 파싱 실패", e); + } + } + return null; + } + } 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..3d23c98 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/MainController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/MainController.java @@ -1,13 +1,19 @@ package com.fastcampus.book_bot.controller; +import com.fastcampus.book_bot.common.utils.JwtUtil; 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.navigation.RecentlyViewService; import com.fastcampus.book_bot.service.order.BestSellerService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import java.util.Collections; import java.util.List; @Controller @@ -16,9 +22,13 @@ public class MainController { private final BestSellerService bestSellerService; + private final PopularKeywordService popularKeywordService; + private final RecentlyViewService recentlyViewService; + private final JwtUtil jwtUtil; @GetMapping - public String main(Model model) { + public String main(HttpServletRequest request, + Model model) { try { // 월간 베스트셀러 데이터 조회 및 캐시 업데이트 bestSellerService.getMonthBestSeller(); @@ -28,20 +38,40 @@ 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()); } 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/controller/book/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java index c24ab04..283d9c6 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 @@ -1,30 +1,41 @@ package com.fastcampus.book_bot.controller.book; +import com.fastcampus.book_bot.common.utils.JwtUtil; import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.domain.user.User; 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 com.fastcampus.book_bot.service.navigation.RecentlyViewService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ResponseBody; +import java.util.Collections; +import java.util.List; import java.util.Optional; @Controller +@RequiredArgsConstructor @Slf4j public class BookController { private final BookSearchService bookSearchService; + private final PopularKeywordService popularKeywordService; + private final RecentlyViewService recentlyViewService; + private final JwtUtil jwtUtil; - public BookController(BookSearchService bookSearchService) { - this.bookSearchService = bookSearchService; - } @GetMapping("/search") public String searchBooks(@ModelAttribute SearchDTO searchDTO, @@ -37,6 +48,8 @@ public String searchBooks(@ModelAttribute SearchDTO searchDTO, pageable ); + popularKeywordService.recordKeyword(searchDTO); + searchDTO.setSearchResult(searchResult); searchDTO.setPageInfo(pageable); @@ -46,14 +59,45 @@ public String searchBooks(@ModelAttribute SearchDTO searchDTO, } @GetMapping("/book/{bookId}") - public String bookDetail(@PathVariable Integer bookId, Model model) { + public String bookDetail(@PathVariable Integer bookId, + HttpServletRequest request, + Model model) { Optional book = bookSearchService.getBookById(bookId); - if (book.isPresent()) { - model.addAttribute("book", book.get()); - return "book/detail"; - } else { + if (book.isEmpty()) { return "error/404"; } + + model.addAttribute("book", book.get()); + + Integer userId = jwtUtil.extractUserIdFromRequest(request); + if (userId != null) { + try { + recentlyViewService.addRecentBook(userId, bookId); + log.info("최근 본 상품 추가 - UserId: {}, BookId: {}", userId, bookId); + } catch (Exception e) { + log.error("최근 본 상품 추가 실패 - UserId: {}, BookId: {}", userId, bookId, e); + } + } + + return "book/detail"; + } + + @GetMapping("/api/recent-books") + @ResponseBody + public ResponseEntity> getRecentBooks(HttpServletRequest request) { + Integer userId = jwtUtil.extractUserIdFromRequest(request); + + if (userId == null) { + return ResponseEntity.ok(Collections.emptyList()); + } + + try { + List recentBooks = recentlyViewService.getRecentBookIds(userId); + return ResponseEntity.ok(recentBooks); + } catch (Exception e) { + log.error("최근 본 상품 조회 실패 - UserId: {}", userId, e); + return ResponseEntity.ok(Collections.emptyList()); + } } } 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/book/BookCacheService.java b/src/main/java/com/fastcampus/book_bot/service/book/BookCacheService.java index 88ec66f..18b244f 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,16 +2,21 @@ 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.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @@ -19,6 +24,7 @@ public class BookCacheService { private final RedisTemplate redisTemplate; + private final StringRedisTemplate stringRedisTemplate; private final BookRepository bookRepository; private static final String BOOK_CACHE = "book:"; @@ -28,13 +34,16 @@ public class BookCacheService { */ 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)); } @@ -44,7 +53,21 @@ public Book getBook(Integer bookId) { */ public void setBookRedis(Integer bookId, Book book) { String cacheKey = BOOK_CACHE + bookId; - redisTemplate.opsForValue().set(cacheKey, book, Duration.ofDays(7)); + + Map bookData = new HashMap<>(); + bookData.put("bookId", String.valueOf(book.getBookId())); + bookData.put("title", book.getBookName()); + bookData.put("imagePath", book.getBookImagePath()); + 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.opsForHash().putAll(cacheKey, bookData); + stringRedisTemplate.expire(cacheKey, Duration.ofDays(7)); + + log.info("Redis 저장 완료 - BookId: {}, 재고: {}", bookId, book.getBookQuantity()); } /** @@ -52,28 +75,96 @@ public void setBookRedis(Integer bookId, Book book) { */ public Integer getBookQuantity(Integer bookId) { String cacheKey = BOOK_CACHE + bookId; - Book cachedBook = (Book) redisTemplate.opsForValue().get(cacheKey); - if (cachedBook != null) { - return cachedBook.getBookQuantity(); + Object quantity = stringRedisTemplate.opsForHash().get(cacheKey, "quantity"); + + if (quantity != null) { + return Integer.valueOf((String) quantity); } return null; } /** - * Redis에서 도서 재고 차감 + * Lua Script를 사용한 원자적 재고 차감 */ - public void decrementBookQuantity(Integer bookId, Integer quantity) { + public Long 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)); + String luaScript = + // 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 " + + + // 5. 재고 차감 (원자적 연산!) + "return redis.call('HINCRBY', KEYS[1], 'quantity', -ARGV[1])"; + + DefaultRedisScript redisScript = new DefaultRedisScript<>(luaScript, Long.class); + + Long result = stringRedisTemplate.execute( + redisScript, + Collections.singletonList(cacheKey), + quantity.toString() + ); + + if (result == null) { + log.error("Lua Script 실행 실패 - BookId: {}", bookId); + throw new RuntimeException("재고 차감 실패"); + } + + 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 + " BookId: " + bookId); + } + + log.info("원자적 재고 차감 성공 - BookId: {}, 차감수량: {}, 남은재고: {}", + bookId, quantity, result); + + return result; + } + + /** + * Hash 데이터를 Book 객체로 변환 + */ + private Book convertHashToBook(Map bookData) { + Book book = new Book(); + book.setBookId(Integer.valueOf((String) bookData.get("bookId"))); + book.setBookName((String) bookData.get("title")); + book.setBookImagePath((String) bookData.get("imagePath")); + 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; } /** @@ -86,9 +177,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 +192,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/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/java/com/fastcampus/book_bot/service/navigation/RecentlyViewService.java b/src/main/java/com/fastcampus/book_bot/service/navigation/RecentlyViewService.java new file mode 100644 index 0000000..6b71cf0 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/navigation/RecentlyViewService.java @@ -0,0 +1,109 @@ +package com.fastcampus.book_bot.service.navigation; + +import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.dto.api.BookDTO; +import com.fastcampus.book_bot.service.book.BookCacheService; +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.stereotype.Service; + +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RecentlyViewService { + + private final StringRedisTemplate redisTemplate; + private final BookCacheService bookCacheService; + + private static final String RECENTLY_KEY = "recent:view:"; + private static final String BOOK_CACHE_KEY = "book:"; + private static final int MAX_RECENT_ITEMS = 5; + + /** + * 최근 본 상품 추가 + * - 이미 존재 시, timestamp만 업데이트 + * - MAX_RECENT_ITEMS 초과 시 오래된 항목 삭제 + */ + public void addRecentBook(Integer userId, Integer bookId) { + String key = RECENTLY_KEY + userId; + double score = System.currentTimeMillis(); + + redisTemplate.opsForZSet().add(key, bookId.toString(), score); + + String bookCacheKey = BOOK_CACHE_KEY + bookId; + Map existingData = redisTemplate.opsForHash().entries(bookCacheKey); + + if (existingData == null || existingData.isEmpty()) { + log.info("북 캐시 없음 - 새로 저장: {}", bookCacheKey); + Book book = bookCacheService.getBook(bookId); + bookCacheService.setBookRedis(bookId, book); + } else { + log.debug("북 캐시 존재 - bookId: {}", bookId); + } + + Long size = redisTemplate.opsForZSet().size(key); + if (size != null && size > MAX_RECENT_ITEMS) { + redisTemplate.opsForZSet().removeRange(key, 0, size - MAX_RECENT_ITEMS - 1); + } + + redisTemplate.expire(key, Duration.ofDays(30)); + } + + /** + * 최근 본 상품 조회 + */ + public List getRecentBookIds(Integer userId) { + String key = RECENTLY_KEY + userId; + log.info("최근 본 상품 조회 시작 - UserId: {}, Key: {}", userId, key); + + Set bookIds = redisTemplate.opsForZSet() + .reverseRange(key, 0, MAX_RECENT_ITEMS - 1); + + log.info("Redis ZSet 조회 결과 - bookIds: {}", bookIds); + + if (bookIds == null || bookIds.isEmpty()) { + log.warn("Redis에서 최근 본 상품이 없음 - UserId: {}", userId); + return Collections.emptyList(); + } + + List books = new ArrayList<>(); + for (String bookId : bookIds) { + String bookCacheKey = BOOK_CACHE_KEY + bookId; + log.debug("북 캐시 조회 - Key: {}", bookCacheKey); + + Map bookData = redisTemplate.opsForHash().entries(bookCacheKey); + log.debug("북 데이터 조회 결과 - bookId: {}, dataSize: {}", bookId, bookData.size()); + + if (!bookData.isEmpty()) { + Book book = convertHashToBook(bookData); + books.add(book); + log.debug("북 추가 완료 - bookId: {}, bookName: {}", book.getBookId(), book.getBookName()); + } else { + log.warn("북 캐시 데이터 없음 - bookCacheKey: {}", bookCacheKey); + } + } + + log.info("최근 본 상품 조회 완료 - UserId: {}, 조회된 책 수: {}", userId, books.size()); + return books; + } + + private Book convertHashToBook(Map bookData) { + Book book = new Book(); + book.setBookId(Integer.valueOf((String) bookData.get("bookId"))); + book.setBookName((String) bookData.get("title")); + book.setBookImagePath((String) bookData.get("imagePath")); + 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/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); } } 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; + } } 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..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 @@ -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; @@ -107,28 +103,32 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { 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); + log.info("Redis 캐시 히트 - BookId: {}, 현재재고: {}", + ordersDTO.getBookId(), currentStock); + + try { + Long remainingStock = bookCacheService.decrementBookQuantity( + ordersDTO.getBookId(), + ordersDTO.getQuantity() + ); + + log.info("원자적 재고 차감 성공 - 남은재고: {}", remainingStock); + + 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 +157,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 +170,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 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/navigation.html b/src/main/resources/templates/common/navigation.html index 7e63706..3e1fd0e 100644 --- a/src/main/resources/templates/common/navigation.html +++ b/src/main/resources/templates/common/navigation.html @@ -5,7 +5,7 @@