diff --git a/.gitignore b/.gitignore index a496d1b..48dc164 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ src/main/resources/application.yml -src/main/resources/application-test.yml +src/test/resources/application-test.yml ### STS ### .apt_generated diff --git a/src/main/java/org/example/siljeun/domain/concert/entity/Concert.java b/src/main/java/org/example/siljeun/domain/concert/entity/Concert.java index 8d43c54..d63e457 100644 --- a/src/main/java/org/example/siljeun/domain/concert/entity/Concert.java +++ b/src/main/java/org/example/siljeun/domain/concert/entity/Concert.java @@ -1,5 +1,6 @@ package org.example.siljeun.domain.concert.entity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -42,6 +43,10 @@ public class Concert extends BaseEntity { private int cancelCharge; + @Column(nullable = false) + private Long viewCount = 0L; + + @Builder public Concert(String title, String description, ConcertCategory category, Venue venue, int cancelCharge) { @@ -60,4 +65,8 @@ public void update(String title, String description, ConcertCategory category, V this.venue = venue; this.cancelCharge = cancelCharge; } + + public void addViewCount(Long count) { + this.viewCount += count; + } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/concert/scheduler/ConcertViewSyncScheduler.java b/src/main/java/org/example/siljeun/domain/concert/scheduler/ConcertViewSyncScheduler.java new file mode 100644 index 0000000..7877651 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/concert/scheduler/ConcertViewSyncScheduler.java @@ -0,0 +1,55 @@ +package org.example.siljeun.domain.concert.scheduler; + +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.concert.repository.ConcertRepository; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ConcertViewSyncScheduler { + + private final RedisTemplate redisTemplate; + private final ConcertRepository concertRepository; + + public ConcertViewSyncScheduler( + @Qualifier("redisStringTemplate") RedisTemplate redisTemplate, + ConcertRepository concertRepository + ) { + this.redisTemplate = redisTemplate; + this.concertRepository = concertRepository; + } + + @Scheduled(fixedRate = 600_000) // 10분 마다 실행 + public void syncViewCountsToDatabase() { + Set keys = redisTemplate.keys("concert:viewCount.*"); + + if (keys == null || keys.isEmpty()) { + return; + } + + for (String key : keys) { + try { + Long concertId = Long.valueOf(key.replace("concert:viewCount.", "")); + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + continue; + } + + Long viewCount = Long.parseLong(value); + + concertRepository.findById(concertId).ifPresent(concert -> { + concert.addViewCount(viewCount); + concertRepository.save(concert); + }); + + redisTemplate.delete(key); + } catch (Exception e) { + log.warn("조회수 반영 중 오류 발생: key = {}", key, e); + } + } + } +} diff --git a/src/main/java/org/example/siljeun/domain/concert/service/ConcertCacheService.java b/src/main/java/org/example/siljeun/domain/concert/service/ConcertCacheService.java index 5c867a3..f6c0b55 100644 --- a/src/main/java/org/example/siljeun/domain/concert/service/ConcertCacheService.java +++ b/src/main/java/org/example/siljeun/domain/concert/service/ConcertCacheService.java @@ -1,9 +1,13 @@ package org.example.siljeun.domain.concert.service; +import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Duration; import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.concert.dto.response.ConcertDetailResponse; +import org.example.siljeun.domain.concert.entity.Concert; +import org.example.siljeun.domain.concert.repository.ConcertRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -15,9 +19,22 @@ public class ConcertCacheService { @Qualifier("redisJsonTemplate") private final RedisTemplate redisTemplate; + private final ConcertRepository concertRepository; + private static final String RANK_KEY = "concert:ranking"; public void increaseViewCount(Long concertId) { + String viewCountKey = "concert:viewCount:" + concertId; + if (Boolean.FALSE.equals(redisTemplate.hasKey(viewCountKey))) { + // 캐시에 없으면 DB에서 조회 후 Redis에 캐싱 + Long viewCount = concertRepository.findById(concertId) + .map(Concert::getViewCount) + .orElse(0L); + redisTemplate.opsForValue().set(viewCountKey, viewCount); + } + + redisTemplate.opsForValue().increment(viewCountKey); + redisTemplate.opsForZSet().incrementScore(RANK_KEY, concertId, 1); redisTemplate.opsForZSet().incrementScore("ranking:daily", concertId, 1); @@ -49,4 +66,29 @@ private void ensureTtl(String key, Duration ttl) { } } + private static final String CONCERT_KEY_CACHE_PREFIX = "concert:info"; + private static final Duration TTL = Duration.ofMinutes(30); + private final ObjectMapper objectMapper; + + public ConcertDetailResponse getConcertDetailCache(Long concertId) { + String json = (String) redisTemplate.opsForValue().get(CONCERT_KEY_CACHE_PREFIX + concertId); + if (json == null) { + return null; + } + try { + return objectMapper.readValue(json, ConcertDetailResponse.class); + } catch (Exception e) { + return null; + } + } + + public void saveConcertDetailCache(Long concertId, ConcertDetailResponse response) { + try { + String json = objectMapper.writeValueAsString(response); + redisTemplate.opsForValue().set(CONCERT_KEY_CACHE_PREFIX + concertId, json, TTL); + } catch (Exception e) { + // 로그만 남기고 캐싱 실패는 무시 + } + } + } diff --git a/src/main/java/org/example/siljeun/domain/concert/service/ConcertServiceImpl.java b/src/main/java/org/example/siljeun/domain/concert/service/ConcertServiceImpl.java index 784948d..3c07cbd 100644 --- a/src/main/java/org/example/siljeun/domain/concert/service/ConcertServiceImpl.java +++ b/src/main/java/org/example/siljeun/domain/concert/service/ConcertServiceImpl.java @@ -18,6 +18,7 @@ import org.example.siljeun.domain.venue.dto.response.VenueSimpleResponse; import org.example.siljeun.domain.venue.entity.Venue; import org.example.siljeun.domain.venue.repository.VenueRepository; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +32,7 @@ public class ConcertServiceImpl implements ConcertService { private final UserRepository userRepository; private final ScheduleRepository scheduleRepository; private final ConcertCacheService concertCacheService; + private final RedisTemplate redisTemplate; @Override @@ -89,6 +91,12 @@ public List getConcertList() { @Override public ConcertDetailResponse getConcertDetail(Long concertId) { + ConcertDetailResponse cached = concertCacheService.getConcertDetailCache(concertId); + if (cached != null) { + concertCacheService.increaseViewCount(concertId); + return cached; + } + concertCacheService.increaseViewCount(concertId); Concert concert = concertRepository.findById(concertId) @@ -114,7 +122,7 @@ public ConcertDetailResponse getConcertDetail(Long concertId) { )) .toList(); - return new ConcertDetailResponse( + ConcertDetailResponse response = new ConcertDetailResponse( concert.getId(), concert.getTitle(), concert.getDescription(), @@ -122,6 +130,10 @@ public ConcertDetailResponse getConcertDetail(Long concertId) { venueResponse, schedules ); + + concertCacheService.saveConcertDetailCache(concertId, response); + + return response; } @Override @@ -148,4 +160,6 @@ private List mapConcertsByIdOrder(List ids) { )) .toList(); } + + } diff --git a/src/main/java/org/example/siljeun/global/config/RedisConfig.java b/src/main/java/org/example/siljeun/global/config/RedisConfig.java index e280366..4e97908 100644 --- a/src/main/java/org/example/siljeun/global/config/RedisConfig.java +++ b/src/main/java/org/example/siljeun/global/config/RedisConfig.java @@ -15,46 +15,55 @@ @Configuration public class RedisConfig { - @Value("${spring.data.redis.host}") - private String host; - - @Value("${spring.data.redis.port}") - private int port; - - private static final String REDISSON_PREFIX = "redis://"; - - /** - * Redisson 클라이언트 설정 - */ - @Bean - public RedissonClient redissonClient() { - Config config = new Config(); - config.useSingleServer() - .setAddress(REDISSON_PREFIX + host + ":" + port); - return Redisson.create(config); - } - - /** - * Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용) - */ - @Bean - public RedisTemplate redisLongTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(connectionFactory); - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); - return redisTemplate; - } - - /** - * JSON 직렬화 RedisTemplate (객체 캐싱용) - */ - @Bean - public RedisTemplate redisJsonTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화 - return template; - } + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + private static final String REDISSON_PREFIX = "redis://"; + + /** + * Redisson 클라이언트 설정 + */ + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSON_PREFIX + host + ":" + port); + return Redisson.create(config); + } + + /** + * Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용) + */ + @Bean + public RedisTemplate redisLongTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); + return redisTemplate; + } + + /** + * JSON 직렬화 RedisTemplate (객체 캐싱용) + */ + @Bean + public RedisTemplate redisJsonTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화 + return template; + } + + @Bean + RedisTemplate redisStringTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + return template; + } } diff --git a/src/test/java/org/example/siljeun/domain/concert/service/ConcertCacheServiceTest.java b/src/test/java/org/example/siljeun/domain/concert/service/ConcertCacheServiceTest.java new file mode 100644 index 0000000..e50c3da --- /dev/null +++ b/src/test/java/org/example/siljeun/domain/concert/service/ConcertCacheServiceTest.java @@ -0,0 +1,101 @@ +package org.example.siljeun.domain.concert.service; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +import org.example.siljeun.domain.concert.dto.response.ConcertDetailResponse; +import org.example.siljeun.domain.concert.entity.Concert; +import org.example.siljeun.domain.concert.entity.ConcertCategory; +import org.example.siljeun.domain.concert.repository.ConcertRepository; +import org.example.siljeun.domain.venue.entity.Venue; +import org.example.siljeun.domain.venue.repository.VenueRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class ConcertCacheServiceTest { + + @Autowired + private ConcertRepository concertRepository; + + @Autowired + private ConcertCacheService concertCacheService; + + @Autowired + private VenueRepository venueRepository; + + @Autowired + private RedisTemplate redisTemplate; + + private List testConcertIds; + + @BeforeEach + void setUp() { + Venue venue = venueRepository.save(new Venue("테스트 공연장", "서울", 500)); + + testConcertIds = IntStream.rangeClosed(1, 10) + .mapToObj(i -> { + Concert concert = Concert.builder() + .title("공연" + i) + .description("설명" + i) + .category(ConcertCategory.CONCERT) + .venue(venue) + .cancelCharge(0) + .build(); + concert = concertRepository.save(concert); + + // 캐시용 DTO 미리 저장 + ConcertDetailResponse response = new ConcertDetailResponse( + concert.getId(), concert.getTitle(), concert.getDescription(), + concert.getCategory(), null, List.of() + ); + concertCacheService.saveConcertDetailCache(concert.getId(), response); + + return concert.getId(); + }) + .toList(); + } + + @Test + void compareDbVsRedisCachePerformance() { + int iterations = 10000; + + // 워밍업 + for (Long id : testConcertIds) { + concertRepository.findById(id); + concertCacheService.getConcertDetailCache(id); + } + + long dbStart = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + for (Long id : testConcertIds) { + concertRepository.findById(id); + } + } + long dbEnd = System.nanoTime(); + + long redisStart = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + for (Long id : testConcertIds) { + concertCacheService.getConcertDetailCache(id); + } + } + long redisEnd = System.nanoTime(); + + long dbDuration = TimeUnit.NANOSECONDS.toMillis(dbEnd - dbStart); + long redisDuration = TimeUnit.NANOSECONDS.toMillis(redisEnd - redisStart); + + System.out.println("🔵 DB 직접 조회 총 소요 시간: " + dbDuration + " ms"); + System.out.println("🟢 Redis 캐시 기반 조회 총 소요 시간: " + redisDuration + " ms"); + + // Redis 캐싱 성능이 DB보다 확실히 좋아야 한다 + assertTrue(redisDuration < dbDuration, "Redis 캐싱 조회가 DB 조회보다 빨라야 합니다."); + } +}