1. Redis의 Sorted Set(ZSet) 을 활용하기
Redisson의 RScoredSortedSet을 활용해 주간 랭킹 키(weeklyKey)로 점수 기반 정렬 데이터를 관리
entryRangeReversed로 상위 N개를 내림차순 조회하며, Redis가 비어있을 경우 DB fallback으로 안정성을 확보함
2. Cache Stampede
ZSet 내부는 Skip List + Hash Table 조합으로 구현되어 있으며, 삽입/삭제/조회 모두 O(log N)
entryRangeReversed는 내부적으로 ZREVRANGE + WITHSCORES에 해당DB fallback 시 캐시 워밍업(aggregateWeekly 재호출) 로직이 포함되어 있어 Cache Stampede 가 발생할 수 있음 — 동시에 여러 요청이 weeklySet.isEmpty()를 통과하면 aggregateWeekly가 중복 실행될 가능성 존재
-> Lock 추가
public void aggregateWeekly() {
RLock lock = redissonClient.getLock("lock:weekly-ranking-aggregate");
if (!lock.tryLock()) {
// 이미 다른 인스턴스가 실행 중이면 스킵
return;
}
try {
// 기존 로직 그대로
String weeklyKey = weeklyRankingKey();
String tempKey = weeklyKey + ":temp";
LocalDate today = LocalDate.now();
for (int i = 0; i < WEEKLY_DAYS; i++) {
rebuildDailyRanking(today.minusDays(i));
}
String[] dailyKeys = IntStream.range(0, WEEKLY_DAYS)
.mapToObj(i -> dailyRankingKey(today.minusDays(i)))
.toArray(String[]::new);
RScoredSortedSet<String> tempSet =
redissonClient.getScoredSortedSet(tempKey);
tempSet.delete();
tempSet.union(dailyKeys);
if (tempSet.isEmpty()) {
tempSet.delete();
return;
}
tempSet.expire(Duration.ofSeconds(WEEKLY_TTL_SECONDS));
tempSet.rename(weeklyKey);
} finally {
lock.unlock();
}
}
Lock 추가 이후 aggregateWeekly method 동작 흐름
- aggregation lock 획득 시도 후 2) ~ 7) 실행
- tempKey 를 생성 (weeklyKey + :temp)
- 최근 7일의 daily key 가 없으면 dedup data 에서 복구 (rebuildDailyRanking)
- 7일 daily key 배열 생성
- tempSet 에 daily key 점수를 합산하여 저장, 비었다면 return
- TTL 설정
- temp 를 실제 weekly Key 로 교체 (rename)
rename 이유 : weeklyKey 에 직접 union을 하면 해당 로직을 수행하는 과정에서 비게 되는데, 그 때 요청이 들어오면 빈 리스트가 조회됨 → rename method 를 사용하면 weeklyKey 가 비는 상황이 오지 않습니다.
주간 랭킹 집계(aggregateWeekly)는 스케줄러에 의해 주기적으로 실행
다중 인스턴스 환경에서 중복 실행을 방지하기 위해 Redisson 분산 락을 적용
// weekly key update : 정각 20초마다 재집계
@Scheduled(cron = "20 0 * * * *")
public void refreshWeeklyRanking() {
aggregateWeekly();
}
또한, 집계 작업은 실시간성을 요구하지 않으므로,
tryLock()을 사용하여 락 획득에 실패할 경우 즉시 스킵
(스킵이 되면 이전 집계 결과가 그대로 출력되므로 UX 도 괜찮음)
3. 카테고리 필터링을 Redis 가 아닌 DB 에서 하는 이유
| Redis | DB |
| 카테고리별 조회가 초당 수천 건 이상으로 빈번 할 때 카테고리 수가 고정적이고 적을 때 좋음 |
key 관리가 쉬움 |
category 별 키를 저장하면 category 개수 * 일별 조회수 로 key 의 개수가 늘어남
→ 랭킹 점수 계산은 Redis에서, 카테고리 필터링은 DB에서 처리
4. 집계 기간 설계 - 고정 윈도우 vs 슬라이딩 윈도우
집계 기간 설계 (실시간 vs 일별 vs 주간)
일별 키(`dailyRankingKey`)에 매일 데이터를 쌓고, 조회 시점에 `aggregateWeekly`를 호출해 7일치 dailyKey를 `ZUNIONSTORE`(union)로 합산하여 주간 랭킹을 생성
weeklyKey에는 별도 TTL(`WEEKLY_TTL_SECONDS`)을 설정해 자동 만료
| 고정 윈도우 (Fixed Window) | 슬라이딩 윈도우 (Sliding Window) |
| 오늘 기준 7일 전까지의 데이터를 단순 합산 | 현재 시각 기준으로 정확히 7일 전 데이터를 실시간으로 반영 |
| 간단 | 정밀 |
특정 이벤트의 조회수 자체가 일 단위로 집계되므로, 시간 단위의 정밀도를 제공하는 슬라이딩 윈도우 방식은 실질적인 이점이 없다고 판단했기 때문에 고정 윈도우 방식 선택
5. 동일 사용자의 중복 검색 카운팅
dedupKey(eventId) 기반의 RSet에 userId를 저장하여, 동일 사용자가 같은 공연을 중복 조회해도 점수가 한 번만 증가하도록 구현
현재 사용자가 많아지는 것을 크게 고려하지 않은 이유는 키가 dedup:{eventId}:{date} 의 형식이고 안에 value 로 userId 가 저장되며, TTL 이 적용되어 자동 만료로 인해 계속 쌓이지 않음
공연이 10만개 이상 늘어날 시 Bloom Filter 고려
Bloom Filter
고정된 메모리로 "이 사용자가 이미 조회했는가"를 확률적으로 판별 실제로 조회 안 한 사용자가 간혹 조회한 것으로 판정되지만 소수의 오차가 전체 순위에 큰 영향을 주지 않으니 허용가능
100만 명을 1% 오차율로 체크→ 소요 메모리: 1.2MB
트러블 슈팅
| [countView] Redis 장애 - eventId: 3, error: WRONGTYPE Operation against a key holding the wrong kind of value. channel: [id: 0x92be9434, L:/172.18.0.4:37544 - R:redis-compose/172.18.0.2:6379] command: (SADD), params: [event:view:dedup:3:2026-04-22, PooledUnsafeDirectByteBuf(ridx: 0, widx: 1, cap: 256)], promise: java.util.concurrent.CompletableFuture@186817d9[Not completed, 1 dependents] |
-> 기존에 있던 RSetCache 와 충돌이기 때문에 삭제해주기
6. Count Query 분리
해결 포인트
1. Count query 가 id 개수만 읽어올 수 있게 함 (다른 요소들까지 같이 읽어와서 개수를 세는 문제 해결)
2. 모든 JOIN 이 필수로 이루어져야 count 를 할 수 있다는 문제를 해결하기위해 EXIST 로 변환
private int countEventsWithExists(DSLContext dsl, GetEventsRequest request) {
// --- 조건을 테이블별로 분리 ---
var eventCond = DSL.noCondition();
var venueCond = DSL.noCondition();
var sessionCond = DSL.noCondition();
var ticketCond = DSL.noCondition();
boolean needVenue = false;
boolean needSessionTicket = false;
// events 조건
if (request.category() != null && !request.category().isEmpty()) {
eventCond = eventCond.and(EVENTS.CATEGORY.in(request.category()));
}
if (request.startDate() != null) {
eventCond = eventCond.and(EVENTS.OPEN_DATE.ge(request.startDate()));
}
if (request.endDate() != null) {
eventCond = eventCond.and(EVENTS.END_DATE.le(request.endDate()));
}
if (request.keyword() != null && !request.keyword().isBlank()) {
eventCond = eventCond.and(
EVENTS.TITLE.containsIgnoreCase(request.keyword())
.or(EVENTS.DESCRIPTION.containsIgnoreCase(request.keyword()))
);
}
// venues 조건
if (request.area() != null && !request.area().isEmpty()) {
venueCond = venueCond.and(VENUES.LOCATION.in(request.area()));
needVenue = true;
}
// ticket_types 조건
if (request.startPrice() != null) {
ticketCond = ticketCond.and(TICKET_TYPES.PRICE.ge(request.startPrice()));
needSessionTicket = true;
}
if (request.endPrice() != null) {
ticketCond = ticketCond.and(TICKET_TYPES.PRICE.le(request.endPrice()));
needSessionTicket = true;
}
// 예약 가능 필터
if (Boolean.TRUE.equals(request.reservePossible())) {
ticketCond = ticketCond.and(
TICKET_TYPES.TICKET_TYPE_STATUS.in("PENDING", "ON_SALE")
);
sessionCond = sessionCond.and(
EVENT_SESSIONS.STATUS.eq(String.valueOf(EventSessionStatus.SCHEDULED))
);
eventCond = eventCond.and(
EVENTS.EVENT_STATUS.ne(String.valueOf(EventStatus.CLOSED))
);
needSessionTicket = true;
}
// --- EXISTS 서브쿼리 조립 ---
var query = dsl.selectCount()
.from(EVENTS)
.where(eventCond);
// session + ticket EXISTS (필요할 때만)
if (needSessionTicket) {
var ticketExists = DSL.exists(
DSL.selectOne()
.from(TICKET_TYPES)
.where(TICKET_TYPES.EVENT_SESSION_ID.eq(EVENT_SESSIONS.ID))
.and(ticketCond)
);
query = query.and(DSL.exists(
DSL.selectOne()
.from(EVENT_SESSIONS)
.where(EVENT_SESSIONS.EVENT_ID.eq(EVENTS.ID))
.and(sessionCond)
.and(ticketExists)
));
}
// venue EXISTS (지역 조건이 있을 때만)
if (needVenue) {
query = query.and(DSL.exists(
DSL.selectOne()
.from(VENUES)
.where(VENUES.ID.eq(EVENTS.VENUE_ID))
.and(venueCond)
));
}
Integer count = query.fetchOne(0, Integer.class);
return (count != null) ? count : 0;
}
| 최적화 전 | 최적화 후 | 개선율 |
| 28.2ms | 24.3ms | -13.8% |
7. 캐시 전략
| Cache-aside (Lazy Loading) | Write-through | Write-back (Write-behind) | |
| 읽기 | Cache 확인 → 없으면 DB에서 조회 (miss) → Cache 에 저장 |
캐시에서 바로 반환 (항상 최신) |
캐시에서 바로 반환 |
| 쓰기 | DB만 Update, Cache는 삭제 | DB uptate , 바로 캐시에도 같은 값 저장 | 캐시에만 저장 → 나중에 일괄로 DB에 반영 |
| 장점 | 실제 조회 데이터만 저장 → 메모리 낭비 낮음 |
캐시와 DB 간 데이터 일관성 | 쓰기 성능이 빠 |
| 단점 | 첫 조회 시 항상 cache miss | 조회X 데이터도 캐시 추가됨 | DB에 반영 전 서버 다운 시 데이터 유실 |
이벤트 목록 조회는 쓰기보다 읽기 요청이 압도적으로 많아 Cache-aside 전략을 선택했습니다. 실제로 조회되는 데이터만 캐시에 올리므로 메모리 효율적이며, 데이터 변경 시 캐시를 무효화하여 일관성을 유지합니다.
검색 조건의 조합이 다양하기 때문에 Hit Rate 가 낮아질 가능성이 존재. 특히 본 이벤트 목록 조회는 지역, 가격, 카테고리 등 다양한 필터 조합이 가능 -> 비효율적일 수 있음
그러나 실제 사용자 패턴을 고려했을 때, 이벤트가 집중되는 특정 지역이나 가격 범위 등 자주 조회되는 조건은 일정 범위로 수렴하는 경향이 있다. 이에 따라 주요 조회 패턴에 대해서는 캐시 적중률을 확보할 수 있을 것으로 판단
8. 캐시 키 설계
| key | 사용처 | TTL | TTL 선정 이유, 관리 방식 |
| event:ranking:daily:{date} | 일간 랭킹 | 8D | 7일치 주간 집계 → 하루 여유 스케줄러(1시간마다) + TTL |
| event:ranking:weekly | 주간 랭킹 | 1H | 주기적 갱신→ 최신 조회수 반영 TTL 만 |
| event:view:dedup:{eventId}:{date} | 중복 조회 방지 | 8D | Daily key 가 삭제 되었을 때 재집계 하기위해 8일간 유지 TTL 만 |
| eventSearchRedis::{hashCode}_{page} | 목록 조회 | 5m | 5분 (여러 조합 값이 사용됨) @Cacheable + TTL |
| 캐시 종류 | 특성 | 조회 빈도 |
| 주간 인기글 (Weekly) | 조회수 기반, 반복 조회 많음 | 높음 |
| 일간 인기글 (Daily, TTL 8일) | 조회수 기반, 더 자주 접근 | 높음 |
| 이벤트 목록 조회 | 조건 다양하지만 일부 패턴 집중 | 조건에 따라 편차 큼 |
| 구분 | 목록 조회 | 랭킹/조회수 |
| 방식 | Spring Cache 추상화 (@Cacheable) | RedisTemplate 직접 조작 |
| 저장소 | Redis | Redis |
| 왜 이 방식? | 단순 조회 캐싱은 어노테이션 | ZSet 같은 Redis 자료구조를 직접 다뤄야 하니까 |
| 캐시 이름 | eventSearchRedis | 없음 (직접 관리) |
자주 조회되는 데이터와 일회성 조회 데이터가 섞여있음
LFU 정책을 적용하면 다음과 같이 동작
- 인기글 캐시 (daily / weekly) → 조회수 높음 → 계속 살아남음
- 이벤트 목록 (마이너 조건) → 몇 번 조회되고 끝 → 자동으로 밀려남
-> 진짜 필요한 캐시만 남기고 나머지가 자연스럽게 정리
| 구분 | 즉시 무효화 (@CacheEvict) | TTL 자연 만료 |
| 데이터 일관성 | 높음 — 수정 즉시 캐시 제거 | 낮음 — TTL 만료 전까지 옛날 데이터 반환 |
| 구현 복잡도 | 높음 — 수정/삭제 로직마다 evict 처리 필요 | 낮음 — TTL만 설정하면 끝 |
| 성능 부담 | 있음 — evict 후 다음 조회 시 cache miss 발생 | 없음 — 만료 전까지 캐시 히트 유지 |
| 적합한 경우 | 정확한 데이터가 중요할 때 (가격, 재고, 좌석) | 약간의 지연이 허용될 때 (인기글 랭킹, 목록) |
9. 로컬 캐시의 한계
| 저장 위치 | 장점 | 단점 | |
| 로컬캐시 | 각 서버의 JVM 메모리 | ns 수준으로 빠름 | 서버가 여러 대로 Scale-out되면 서버 A의 캐시와 서버 B의 캐시가 달라지는 문제가 발생 |
| Redis | 서버 외부 | 모든 서버가 같은 캐시를 바라봄 -> 동일한 캐시 참조 |
네트워크를 타야 함 → ms 수준 |
로컬 캐시에서 값이 달라지는 문제 예시: 서버 A 에서 이벤트를 수정하면 A 에서는 Cache 가 삭제되고, 이후 새로 조회하면 다시 최신 상태의 데이터를 가져올 수 있는 반면, 서버 B 에는 옛날 데이터가 여전히 남음
Redis 조회 시 네트워크 지연(약 1~2ms)이 발생하지만, 이벤트 목록 조회의 경우 캐시 미스 시 다중 JOIN + 동적 조건의 DB 쿼리를 실행해야 하므로 그 비용(수십~수백ms)에 비하면 Redis 네트워크 비용은 무시할 수 있는 수준
또한 이벤트 데이터는 결제로 이어질 수 있는 서비스에서 작동하기 때문에 여러 서버에서 동일한 결과를 보장해야 하므로, 약간의 지연을 감수하더라도 데이터 일관성이 더 중요하다고 판단
10. Redis 에 대하여
Remote 캐시 전략이 다양함: 왜 Redis 를 사용했는지?
-> Hash, Zset 등 다양한 자료구조 지원
+ 싱글 스레드 기반으로 명령이 처리되어 경쟁조건 없이 원자적으로 데이터를 저장가능
| 캐시 | 자료구조 | 선택 이유 |
| 일간/주간 랭킹 | ZSet | score 기반 자동 정렬, union으로 일간 합산 가능 |
| 중복 조회 방지 | String | 존재 여부만 확인, 가장 가볍고 빠름 |
| 이벤트 목록 조회 | String (JSON) | Spring Cache 기본, 결과 통째로 캐싱 |
SQL과 NoSQL (Redis 가 대표적인 NoSQL) 차이
관계에 대한 정의
| SQL | 정합성 우선 -> 고정된 스키마 위에서 수직적인 확장, 구조를 강제 |
| NoSQL | 정의된 관계X 관계를 논리적으로만 설정 정합성 낮음 하지만 수평확장 가능 , 유연 |
11. 대용량 데이터 삽입
코드
@SpringBootTest
@ActiveProfiles("test")
//@Disabled // 실행할 때는 주석 처리!
class TestDataGenerator {
@Autowired
private JdbcTemplate jdbcTemplate;
private static final String[] VENUE_LOCATIONS = buildVenueLocations();
private static String[] buildVenueLocations() {
String[] locations = new String[100];
int idx = 0;
// 100개 중 35개 서울 집중
for (int i = 0; i < 35; i++) locations[idx++] = "SEOUL";
for (int i = 0; i < 20; i++) locations[idx++] = "GYEONGGI";
// . . .생략
for (int i = 0; i < 1; i++) locations[idx++] = "JEJU";
for (int i = 0; i < 1; i++) locations[idx++] = "GYEONGBUK";
return locations;
}
@Test
void generateTestData() {
LocalDateTime now = LocalDateTime.now();
// 1. Venue 100개 (서울 집중 분배) - 생략
// 2. SeatSection (Venue당 3개씩 = 300개) -생략
// 3. Event 5만개 (1만개씩 batch)
Boolean eventsExist = jdbcTemplate.queryForObject(
"SELECT EXISTS(SELECT 1 FROM events LIMIT 1)", Boolean.class);
if (eventsExist == null || !eventsExist) {
System.out.println("=== Step 3: Event 생성 (5만개) ===");
String eventSql = "INSERT INTO events (venue_id, title, description, category, event_status, open_date, end_date, deleted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
String[] categories = {"MUSICAL", "CONCERT", "PLAY", "EXHIBITION", "SPORT"};
for (int batch = 0; batch < 5; batch++) {
final int batchNum = batch;
jdbcTemplate.batchUpdate(eventSql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
int idx = batchNum * 10000 + i;
// venue_id 1~100 순환 → 서울 venue가 35개라 서울 이벤트가 자연스럽게 35% 차지
int venueId = (idx % 100) + 1;
// 날짜: 과거 70% / 미래 30%
int offsetDays;
if (idx % 10 < 7) {
offsetDays = -(ThreadLocalRandom.current().nextInt(1, 1501));
} else {
offsetDays = ThreadLocalRandom.current().nextInt(1, 731);
}
LocalDateTime openDate = now.plusDays(offsetDays);
LocalDateTime endDate = openDate.plusDays(ThreadLocalRandom.current().nextInt(1, 101));
// 상태 결정
String status;
if (now.isBefore(openDate)) {
status = "SCHEDULED";
} else if (now.isAfter(endDate)) {
status = "CLOSED";
} else {
status = "OPEN";
}
ps.setLong(1, venueId);
ps.setString(2, "이벤트_" + idx);
ps.setString(3, "설명_" + idx);
ps.setString(4, categories[idx % 5]);
ps.setString(5, status);
ps.setTimestamp(6, Timestamp.valueOf(openDate));
ps.setTimestamp(7, Timestamp.valueOf(endDate));
ps.setBoolean(8, false);
ps.setTimestamp(9, Timestamp.valueOf(now));
ps.setTimestamp(10, Timestamp.valueOf(now));
}
@Override
public int getBatchSize() { return 10000; }
});
System.out.println("Event batch " + (batch + 1) + "/5 완료");
}
System.out.println(" Event 5만개 완료");
} else {
System.out.println(" Event 이미 존재 - 건너뜀");
}
// 4. EventSession (Event당 1개씩 = 5만개) - 생략
// 5. TicketType (Session당 3개씩 = 15만개) - 생략
System.out.println("\n=== 전체 완료! ===");
}
@Test
void cleanUpData() {
// 데이터 삭제
checkData();
}
}
| 방식 | 장점 | 단점 |
| SQL Stored Procedure | 구현 간단 | WHILE 반복문으로 1건씩 INSERT, 대량 데이터 시 매우 느림 |
| Datafaker 라이브러리 | 랜덤 데이터 자동 생성 | 현실적인 분포를 반영하기 어려움 (서울 집중 등) |
| JDBC Batch Insert | JPA 대비 훨씬 빠른 대량 삽입 | 직접 SQL 작성 필요 |
JDBC Batch Insert :
는 여러 건을 하나의 네트워크 요청으로 묶어서 DB에 전송 → network 왕복 횟수 줄어듦
( 배치 단위를 1만 건 단위로 분할, 각 배치가 완료될 때마다 메모리가 해제)
| Batch Insert 동작 X | 54228 ms |
| Batch Insert 동작 O | 6989 ms |
88%의 개선
12. 검색 API 성능 테스트
- 캐시 별 평균 응답 속도
- 감당 가능한 VU (Virtual User)
- 포화지점 찾기
| 지표 | v1 (캐시 없음) | v2 (로컬 캐시) | v3 (Redis 캐시) |
| 총 요청 수 | 2,075 | 10,993 | 11,259 |
| 평균 응답 | 5,231ms | 514ms | 494ms |
| 중간값 | 5,035ms | 1.5ms | 3.0ms |
| 실패율 | 95.33% | 8.84% | 8.24% |
| 처리량 | 16.9 req/s | 91.0 req/s | 93.1 req/s |
로컬 캐시(v2)가 중간값 기준 Redis 캐시(v3)보다 약 1.5ms 빠르지만, 전체 처리량과 실패율에서는 유의미한 차이 X
Scale-out 환경에서의 데이터 일관성을 고려하면 약간의 지연을 감수하고 Redis를 선택하는 것이 합리적
| VUs | TPS | 평균 | 응답실패율 |
| 10 | 27.3 | 66.2ms | 1.22% |
| 50 | 164.1 | 3.8ms | 0.00% |
| 60 | 195.2 | 5.3ms | 0.00% |
| 65 | 213.5 | 3.8ms | 0.00% |
| 70 | 177.9 | 49.7ms | 0.73% |
| 100 | 101.4 | 526.4ms | 8.57% |
| 200 | 186.3 | 670.4ms | 12.09% |
v3(Redis 캐시) 기준 포화지점은 동시 사용자 약 65명이며, 이 시점에서 TPS 213.5, 실패율 0%, 평균 응답 3.8ms로 최적의 성능
100명 이상에서는 DB 커넥션 풀 고갈로 인해 성능이 급격히 저하
Ramp up 사용해보기
| VUs | TPS | 평균 | 응답실패율 | 상태 |
| 10 | 21.9 | 156.2ms | 3.03% | 워밍업 구간 |
| 30 | 98.2 | 4.5ms | 0.00% | 안정 |
| 50 | 164.1 | 3.9ms | 0.00% | 안정 |
| 65 | 212.6 | 4.9ms | 0.00% | 안정 |
| 80 | 262.6 | 3.9ms | 0.00% | 안정 |
| 100 | 271.3 | 65.4ms | 1.14% | 성능 저하 시작 |
| 150 | 132.4 | 719.2ms | 12.80% | 불안정 |
| 180 | 168.5 | 666.3ms | 12.03% | 불안정 |
| 200 | 206.2 | 517.8ms | 9.18% | 불안정 |
포화지점은 동시 사용자 약 80~100명 구간
| 동시 투입: 65명 → 캐시 비어있음 → 65개 전부 DB 조회 → 커넥션 풀 고갈 점진적 투입: 80명 → 캐시 이미 채워짐 → 일부만 DB 조회 → 여유 있음 |
'SPARTA 과제 > SPRING' 카테고리의 다른 글
| Grafana, Prometheus 도입 (1) | 2026.05.12 |
|---|---|
| Lock Service 분리 트러블슈팅 (0) | 2026.05.11 |
| Distributed Lock 트러블슈팅 (0) | 2026.05.07 |
| RedisTemplate StackOverFlow Error (1) | 2026.05.03 |
| K6 테스트 (0) | 2026.04.29 |