Skip to content

Commit a65e59a

Browse files
authored
Feat: Redis 캐싱 개선 (#29)
* feat: 레디스를 활용한 조회 성능 캐싱 * gitignore 추가 * Remove application.yml from tracking * refactor: redisconfig
1 parent 6ad9912 commit a65e59a

7 files changed

Lines changed: 274 additions & 44 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build/
55
!**/src/main/**/build/
66
!**/src/test/**/build/
77
src/main/resources/application.yml
8-
src/main/resources/application-test.yml
8+
src/test/resources/application-test.yml
99

1010
### STS ###
1111
.apt_generated

src/main/java/org/example/siljeun/domain/concert/entity/Concert.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.example.siljeun.domain.concert.entity;
22

3+
import jakarta.persistence.Column;
34
import jakarta.persistence.Entity;
45
import jakarta.persistence.EnumType;
56
import jakarta.persistence.Enumerated;
@@ -42,6 +43,10 @@ public class Concert extends BaseEntity {
4243

4344
private int cancelCharge;
4445

46+
@Column(nullable = false)
47+
private Long viewCount = 0L;
48+
49+
4550
@Builder
4651
public Concert(String title, String description, ConcertCategory category, Venue venue,
4752
int cancelCharge) {
@@ -60,4 +65,8 @@ public void update(String title, String description, ConcertCategory category, V
6065
this.venue = venue;
6166
this.cancelCharge = cancelCharge;
6267
}
68+
69+
public void addViewCount(Long count) {
70+
this.viewCount += count;
71+
}
6372
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package org.example.siljeun.domain.concert.scheduler;
2+
3+
import java.util.Set;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.example.siljeun.domain.concert.repository.ConcertRepository;
6+
import org.springframework.beans.factory.annotation.Qualifier;
7+
import org.springframework.data.redis.core.RedisTemplate;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
11+
@Slf4j
12+
@Component
13+
public class ConcertViewSyncScheduler {
14+
15+
private final RedisTemplate<String, String> redisTemplate;
16+
private final ConcertRepository concertRepository;
17+
18+
public ConcertViewSyncScheduler(
19+
@Qualifier("redisStringTemplate") RedisTemplate<String, String> redisTemplate,
20+
ConcertRepository concertRepository
21+
) {
22+
this.redisTemplate = redisTemplate;
23+
this.concertRepository = concertRepository;
24+
}
25+
26+
@Scheduled(fixedRate = 600_000) // 10분 마다 실행
27+
public void syncViewCountsToDatabase() {
28+
Set<String> keys = redisTemplate.keys("concert:viewCount.*");
29+
30+
if (keys == null || keys.isEmpty()) {
31+
return;
32+
}
33+
34+
for (String key : keys) {
35+
try {
36+
Long concertId = Long.valueOf(key.replace("concert:viewCount.", ""));
37+
String value = redisTemplate.opsForValue().get(key);
38+
if (value == null) {
39+
continue;
40+
}
41+
42+
Long viewCount = Long.parseLong(value);
43+
44+
concertRepository.findById(concertId).ifPresent(concert -> {
45+
concert.addViewCount(viewCount);
46+
concertRepository.save(concert);
47+
});
48+
49+
redisTemplate.delete(key);
50+
} catch (Exception e) {
51+
log.warn("조회수 반영 중 오류 발생: key = {}", key, e);
52+
}
53+
}
54+
}
55+
}

src/main/java/org/example/siljeun/domain/concert/service/ConcertCacheService.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package org.example.siljeun.domain.concert.service;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
34
import java.time.Duration;
45
import java.util.List;
56
import java.util.Set;
67
import lombok.RequiredArgsConstructor;
8+
import org.example.siljeun.domain.concert.dto.response.ConcertDetailResponse;
9+
import org.example.siljeun.domain.concert.entity.Concert;
10+
import org.example.siljeun.domain.concert.repository.ConcertRepository;
711
import org.springframework.beans.factory.annotation.Qualifier;
812
import org.springframework.data.redis.core.RedisTemplate;
913
import org.springframework.stereotype.Service;
@@ -15,9 +19,22 @@ public class ConcertCacheService {
1519
@Qualifier("redisJsonTemplate")
1620
private final RedisTemplate<String, Object> redisTemplate;
1721

22+
private final ConcertRepository concertRepository;
23+
1824
private static final String RANK_KEY = "concert:ranking";
1925

2026
public void increaseViewCount(Long concertId) {
27+
String viewCountKey = "concert:viewCount:" + concertId;
28+
if (Boolean.FALSE.equals(redisTemplate.hasKey(viewCountKey))) {
29+
// 캐시에 없으면 DB에서 조회 후 Redis에 캐싱
30+
Long viewCount = concertRepository.findById(concertId)
31+
.map(Concert::getViewCount)
32+
.orElse(0L);
33+
redisTemplate.opsForValue().set(viewCountKey, viewCount);
34+
}
35+
36+
redisTemplate.opsForValue().increment(viewCountKey);
37+
2138
redisTemplate.opsForZSet().incrementScore(RANK_KEY, concertId, 1);
2239

2340
redisTemplate.opsForZSet().incrementScore("ranking:daily", concertId, 1);
@@ -49,4 +66,29 @@ private void ensureTtl(String key, Duration ttl) {
4966
}
5067
}
5168

69+
private static final String CONCERT_KEY_CACHE_PREFIX = "concert:info";
70+
private static final Duration TTL = Duration.ofMinutes(30);
71+
private final ObjectMapper objectMapper;
72+
73+
public ConcertDetailResponse getConcertDetailCache(Long concertId) {
74+
String json = (String) redisTemplate.opsForValue().get(CONCERT_KEY_CACHE_PREFIX + concertId);
75+
if (json == null) {
76+
return null;
77+
}
78+
try {
79+
return objectMapper.readValue(json, ConcertDetailResponse.class);
80+
} catch (Exception e) {
81+
return null;
82+
}
83+
}
84+
85+
public void saveConcertDetailCache(Long concertId, ConcertDetailResponse response) {
86+
try {
87+
String json = objectMapper.writeValueAsString(response);
88+
redisTemplate.opsForValue().set(CONCERT_KEY_CACHE_PREFIX + concertId, json, TTL);
89+
} catch (Exception e) {
90+
// 로그만 남기고 캐싱 실패는 무시
91+
}
92+
}
93+
5294
}

src/main/java/org/example/siljeun/domain/concert/service/ConcertServiceImpl.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.example.siljeun.domain.venue.dto.response.VenueSimpleResponse;
1919
import org.example.siljeun.domain.venue.entity.Venue;
2020
import org.example.siljeun.domain.venue.repository.VenueRepository;
21+
import org.springframework.data.redis.core.RedisTemplate;
2122
import org.springframework.stereotype.Service;
2223
import org.springframework.transaction.annotation.Transactional;
2324

@@ -31,6 +32,7 @@ public class ConcertServiceImpl implements ConcertService {
3132
private final UserRepository userRepository;
3233
private final ScheduleRepository scheduleRepository;
3334
private final ConcertCacheService concertCacheService;
35+
private final RedisTemplate redisTemplate;
3436

3537

3638
@Override
@@ -89,6 +91,12 @@ public List<ConcertSimpleResponse> getConcertList() {
8991
@Override
9092
public ConcertDetailResponse getConcertDetail(Long concertId) {
9193

94+
ConcertDetailResponse cached = concertCacheService.getConcertDetailCache(concertId);
95+
if (cached != null) {
96+
concertCacheService.increaseViewCount(concertId);
97+
return cached;
98+
}
99+
92100
concertCacheService.increaseViewCount(concertId);
93101

94102
Concert concert = concertRepository.findById(concertId)
@@ -114,14 +122,18 @@ public ConcertDetailResponse getConcertDetail(Long concertId) {
114122
))
115123
.toList();
116124

117-
return new ConcertDetailResponse(
125+
ConcertDetailResponse response = new ConcertDetailResponse(
118126
concert.getId(),
119127
concert.getTitle(),
120128
concert.getDescription(),
121129
concert.getCategory(),
122130
venueResponse,
123131
schedules
124132
);
133+
134+
concertCacheService.saveConcertDetailCache(concertId, response);
135+
136+
return response;
125137
}
126138

127139
@Override
@@ -148,4 +160,6 @@ private List<ConcertSimpleResponse> mapConcertsByIdOrder(List<Long> ids) {
148160
))
149161
.toList();
150162
}
163+
164+
151165
}

src/main/java/org/example/siljeun/global/config/RedisConfig.java

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,46 +15,55 @@
1515
@Configuration
1616
public class RedisConfig {
1717

18-
@Value("${spring.data.redis.host}")
19-
private String host;
20-
21-
@Value("${spring.data.redis.port}")
22-
private int port;
23-
24-
private static final String REDISSON_PREFIX = "redis://";
25-
26-
/**
27-
* Redisson 클라이언트 설정
28-
*/
29-
@Bean
30-
public RedissonClient redissonClient() {
31-
Config config = new Config();
32-
config.useSingleServer()
33-
.setAddress(REDISSON_PREFIX + host + ":" + port);
34-
return Redisson.create(config);
35-
}
36-
37-
/**
38-
* Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용)
39-
*/
40-
@Bean
41-
public RedisTemplate<String, Long> redisLongTemplate(RedisConnectionFactory connectionFactory) {
42-
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
43-
redisTemplate.setConnectionFactory(connectionFactory);
44-
redisTemplate.setKeySerializer(new StringRedisSerializer());
45-
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));
46-
return redisTemplate;
47-
}
48-
49-
/**
50-
* JSON 직렬화 RedisTemplate (객체 캐싱용)
51-
*/
52-
@Bean
53-
public RedisTemplate<String, Object> redisJsonTemplate(RedisConnectionFactory connectionFactory) {
54-
RedisTemplate<String, Object> template = new RedisTemplate<>();
55-
template.setConnectionFactory(connectionFactory);
56-
template.setKeySerializer(new StringRedisSerializer());
57-
template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화
58-
return template;
59-
}
18+
@Value("${spring.data.redis.host}")
19+
private String host;
20+
21+
@Value("${spring.data.redis.port}")
22+
private int port;
23+
24+
private static final String REDISSON_PREFIX = "redis://";
25+
26+
/**
27+
* Redisson 클라이언트 설정
28+
*/
29+
@Bean
30+
public RedissonClient redissonClient() {
31+
Config config = new Config();
32+
config.useSingleServer()
33+
.setAddress(REDISSON_PREFIX + host + ":" + port);
34+
return Redisson.create(config);
35+
}
36+
37+
/**
38+
* Long 타입 RedisTemplate (조회수 등 숫자 기반 저장용)
39+
*/
40+
@Bean
41+
public RedisTemplate<String, Long> redisLongTemplate(RedisConnectionFactory connectionFactory) {
42+
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
43+
redisTemplate.setConnectionFactory(connectionFactory);
44+
redisTemplate.setKeySerializer(new StringRedisSerializer());
45+
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));
46+
return redisTemplate;
47+
}
48+
49+
/**
50+
* JSON 직렬화 RedisTemplate (객체 캐싱용)
51+
*/
52+
@Bean
53+
public RedisTemplate<String, Object> redisJsonTemplate(RedisConnectionFactory connectionFactory) {
54+
RedisTemplate<String, Object> template = new RedisTemplate<>();
55+
template.setConnectionFactory(connectionFactory);
56+
template.setKeySerializer(new StringRedisSerializer());
57+
template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화
58+
return template;
59+
}
60+
61+
@Bean
62+
RedisTemplate<String, String> redisStringTemplate(RedisConnectionFactory connectionFactory) {
63+
RedisTemplate<String, String> template = new RedisTemplate<>();
64+
template.setConnectionFactory(connectionFactory);
65+
template.setKeySerializer(new StringRedisSerializer());
66+
template.setValueSerializer(new StringRedisSerializer());
67+
return template;
68+
}
6069
}

0 commit comments

Comments
 (0)