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: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> redisTemplate;
private final ConcertRepository concertRepository;

public ConcertViewSyncScheduler(
@Qualifier("redisStringTemplate") RedisTemplate<String, String> redisTemplate,
ConcertRepository concertRepository
) {
this.redisTemplate = redisTemplate;
this.concertRepository = concertRepository;
}

@Scheduled(fixedRate = 600_000) // 10분 마다 실행
public void syncViewCountsToDatabase() {
Set<String> 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,9 +19,22 @@ public class ConcertCacheService {
@Qualifier("redisJsonTemplate")
private final RedisTemplate<String, Object> 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);
Expand Down Expand Up @@ -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) {
// 로그만 남기고 캐싱 실패는 무시
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -89,6 +91,12 @@ public List<ConcertSimpleResponse> 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)
Expand All @@ -114,14 +122,18 @@ public ConcertDetailResponse getConcertDetail(Long concertId) {
))
.toList();

return new ConcertDetailResponse(
ConcertDetailResponse response = new ConcertDetailResponse(
concert.getId(),
concert.getTitle(),
concert.getDescription(),
concert.getCategory(),
venueResponse,
schedules
);

concertCacheService.saveConcertDetailCache(concertId, response);

return response;
}

@Override
Expand All @@ -148,4 +160,6 @@ private List<ConcertSimpleResponse> mapConcertsByIdOrder(List<Long> ids) {
))
.toList();
}


}
93 changes: 51 additions & 42 deletions src/main/java/org/example/siljeun/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Long> redisLongTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));
return redisTemplate;
}

/**
* JSON 직렬화 RedisTemplate (객체 캐싱용)
*/
@Bean
public RedisTemplate<String, Object> redisJsonTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> 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<String, Long> redisLongTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));
return redisTemplate;
}

/**
* JSON 직렬화 RedisTemplate (객체 캐싱용)
*/
@Bean
public RedisTemplate<String, Object> redisJsonTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화
return template;
}

@Bean
RedisTemplate<String, String> redisStringTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
Loading