Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 데이터베이스 연동
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/fastcampus/book_bot/BookBotApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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;
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 BookCacheService bookCacheService;
private final RedisTemplate<String, Object> redisTemplate;

/**
* 애플리케이션 시작 시 캐시 초기화
* - 주간/월간 베스트셀러 캐시 확인 및 생성
* - 주문량 상위 20% 도서 Redis 캐싱
*/
@Override
public void run(ApplicationArguments args) throws Exception {
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("주간 베스트셀러 캐시 없음 - 생성 시작");
bestSellerService.updateWeeklyBestSeller();
} else {
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,11 +19,19 @@ public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connec
RedisTemplate<String, Object> 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));
Expand All @@ -32,4 +41,4 @@ public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connec
template.afterPropertiesSet();
return template;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 {

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@Builder
public class OrdersDTO {

private Long bookId;
private Integer bookId;
private String bookName;
private String bookAuthor;
private String bookPublisher;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Repository
Expand All @@ -24,4 +25,16 @@ public interface BookRepository extends JpaRepository<Book, Integer> {
@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<Book> findByTop20ByOrderCount(@Param("limit") int limit);

}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<Book> 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);
}
}

}
Loading