P95 2877ms → 10ms, 실패율 99.96% → 0.008%
어떤 것들을 바꿨는지 순서대로 정리합니다.
1. HikariCP + Tomcat Thread Pool 튜닝
요청 하나의 생명주기를 보면 DB 커넥션이 필요한 구간은 일부입니다.
[JWT 검증] → [Redis 조회] → [DB 쿼리] → [JSON 직렬화] → 응답
↑
여기서만 커넥션 필요
그래서 기본 원칙은 Tomcat Thread ≥ HikariCP 입니다.
리틀의 법칙으로 필요한 값을 계산했습니다.
TPS = 동시 처리량 / 평균 처리 시간
목표: 1000 TPS, P95 300ms 이내
→ 필요 스레드 = 1000 × 0.3 = 300
하지만 2코어 서버에서 300은 과함
컨텍스트 스위칭 비용 증가
→ Tomcat 200, HikariCP 50으로 설정
server:
tomcat:
threads:
max: 200
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
2. DB 인덱스 설계
실제 쿼리의 WHERE, ORDER BY, JOIN 패턴을 먼저 파악하고, 그 패턴에 맞는 인덱스만 설계했습니다. 불필요한 인덱스는 INSERT/UPDATE 비용을 높이기 때문입니다.
| 테이블 | 인덱스 | 이유 |
| announce | reqst_start_date | 기간 필터 범위 조건 |
| announce | pubDate DESC | 최신순 정렬, filesort 제거 |
| announce | viewNum DESC | 인기 공고 배치 쿼리 |
| festival / article | 날짜 단독 | 최신순 정렬 패턴 |
| notice | (status, created_date) | 필터 + 정렬 동시 처리 커버링 |
| document | announce_id | FK 컬럼 풀스캔 방지 |
| memberFavorite | (announce_id, member_id) | 커버링 인덱스, 즐겨찾기 집계 |
| memberDocumentCheck | member_id | 회원 기준 단독 조회 패턴 |
notice 테이블은 WHERE status = 'ACTIVE' ORDER BY created_date DESC 패턴이라, (status, created_date) 복합 인덱스로 필터와 정렬을 동시에 처리했습니다. status 단독 인덱스였다면 필터 후 filesort가 추가로 발생했을 것입니다.
3. @Transactional 위치
문제: 클래스 레벨에 선언
@Transactional(readOnly = true)
public class AnnounceService { ... }
클래스 레벨에 @Transactional이 있으면 Redis만 쓰는 메서드도, 캐시 히트로 DB 조회가 없는 메서드도 전부 커넥션을 점유합니다. Redis가 느려지면 커넥션을 그 시간만큼 붙잡고 있는 구조입니다.
개선: DB가 필요한 메서드에만
public class AnnounceService {
@Transactional(readOnly = true) // DB 조회 있는 메서드에만
public AnnounceDetailResponse getAnnounceDetail(...) { ... }
public void redisOnlyMethod() { } // 트랜잭션 없음 → 커넥션 안 잡음
}
4. N+1 해결 — EXISTS → LEFT JOIN
즐겨찾기 여부를 행마다 서브쿼리로 확인하고 있었습니다.
기존: 공고 20개면 서브쿼리 20번 실행
JPAExpressions.selectOne().from(memberFavorite)
.where(memberFavorite.member.id.eq(memberId),
memberFavorite.announce.id.eq(announce.id))
.exists()
개선: LEFT JOIN으로 한 번에
.from(announce)
.leftJoin(memberFavorite)
.on(memberFavorite.announce.id.eq(announce.id)
.and(memberFavorite.member.id.eq(memberId)))
공고 20개 기준 서브쿼리 20번 → JOIN 1번.
5. N+1 해결 — documents.size() → LEFT JOIN COUNT
서류 개수를 행마다 COUNT 서브쿼리로 가져오고 있었습니다.
기존: 즐겨찾기 20개면 COUNT 쿼리 20번
announce.documents.size()
개선: LEFT JOIN + GROUP BY로 한 번에
document.count().intValue()
6. N+1 해결 — saveAll() → Bulk INSERT
체크리스트 저장 시 JPA saveAll()은 N번 INSERT를 발생시킵니다.
기존: N번 INSERT
memberDocumentCheckRepository.saveAll(checkList);
개선: 단일 Batch INSERT
memberDocumentCheckService.bulkInsert(memberId, documentIds);
// → INSERT INTO ... VALUES (1), (2), ..., (N)
SOS 이미지 삭제도 동일하게 개선했습니다.
기존: N번 DELETE
sosImages.forEach(image -> sosImageRepository.delete(image));
개선: 1번 벌크 DELETE
queryFactory.delete(sosImage)
.where(sosImage.sos.id.eq(sosId))
.execute();
7. viewCount — Hot Spot 해결 (Redis Write Back)
인기 공고에 동시에 접속하면 같은 Row에 UPDATE가 폭발적으로 몰립니다. Row Lock 경합이 생기고 응답 시간이 급격히 증가하는 Hot Spot 문제입니다.
기존: 조회마다 DB UPDATE → Lock 경합
announceRepository.updateViewNum(announceId);
개선: Redis에 모아두고 배치에서 한 번에 DB 반영
INCR view:1 // 카운트 +1
SADD view:keys "view:1" // 처리 대상 목록 등록
// 10분마다 배치에서 처리
Set<String> keys = redisTemplate.opsForSet().members("view:keys");
for (String key : keys) {
Long count = Long.parseLong(redisTemplate.opsForValue().get(key));
announceRepository.bulkIncreaseViewNum(announceId, count);
redisTemplate.delete(key);
redisTemplate.opsForSet().remove("view:keys", key);
}
키가 두 개인 이유가 있습니다. KEYS view:*로 전체 Redis를 스캔하면 Redis 싱글 스레드 특성상 스캔하는 동안 다른 요청이 전부 대기합니다. view:keys라는 Set을 따로 두면 SMEMBERS 한 번으로 처리 대상 목록을 O(1)로 가져올 수 있습니다.
8. 인기 공고 — 데드코드 제거 + @EnableScheduling 누락
RedisAsyncService가 매 요청마다 ZINCRBY로 home:popular:announce에 쓰고 있었습니다. 그런데 HomeService가 읽는 키는 home:popular:announce:cache였습니다.
RedisAsyncService → ZINCRBY "home:popular:announce" ← 아무도 읽지 않음
PopularAnnounceBatch → DB → ZADD "home:popular:announce:cache" ← HomeService가 읽음
완전한 데드코드였습니다. ZINCRBY를 제거했습니다.
그런데 여기서 더 충격적인 사실을 발견했습니다. 배치가 단 한 번도 실행되지 않고 있었습니다.
// 원인: @EnableScheduling 누락
@SpringBootApplication
// @EnableScheduling ← 이게 없었음
public class Application { ... }
어노테이션 하나를 추가하고 나서 Redis 메모리가 안정적으로 유지되기 시작했고, home API 병목도 함께 해소됐습니다.
인기 공고 배치는 del 대신 unlink를 씁니다. del은 Redis 싱글 스레드가 삭제하는 동안 블로킹이 발생하고, unlink는 즉시 분리 후 백그라운드에서 메모리를 해제합니다.
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.unlink(KEY_BYTES); // 비동기 삭제
connection.rPush(KEY_BYTES, ...); // 새 데이터 저장
connection.expire(KEY_BYTES, TTL); // TTL 설정
return null;
});
unlink + rPush + expire를 파이프라인으로 묶어서 삭제와 저장 사이에 빈 순간이 없도록 합니다.
9. 최근 본 공고 — lRem 제거
최근 본 공고를 저장할 때 중복 제거를 위해 lRem을 쓰고 있었습니다.
기존: lRem이 리스트 전체 스캔 O(N)
redis.lRem(key, 0, value);
redis.lPush(key, value);
redis.lTrim(key, 0, 4);
개선: lRem 제거
redis.lPush(key, value);
redis.lTrim(key, 0, 4);
최근 5개만 유지하는 구조에서 약간의 중복이 생겨도 UX에 영향이 거의 없습니다. 중복 허용 대신 성능을 택했습니다.
10. JSON 직렬화 → ID 저장
최근 본 공고를 Redis에 DTO 전체를 JSON으로 직렬화해서 저장하고 있었습니다.
기존: DTO 전체 JSON 저장 (수백 바이트~수 KB)
String json = objectMapper.writeValueAsString(announceDetailResponse);
redis.lPush(key, json);
두 가지 문제가 있었습니다. 첫째, DTO가 업데이트돼도 캐시에는 구버전이 남습니다. 둘째, JSON이 커서 Redis 메모리를 불필요하게 차지합니다.
개선: ID만 저장
redis.lPush(key, String.valueOf(announceId));
// 조회 시 ID로 다시 가져옴
List<Long> ids = redis.lRange(key, 0, 4).stream()
.map(Long::parseLong)
.collect(toList());
메모리가 대폭 줄고, 데이터 정합성도 자연스럽게 보장됩니다.
11. Thread.sleep(50) 제거
홈 API Cache-Aside 구조에서 분산 락을 얻지 못한 요청의 처리가 문제였습니다.
기존: 락 못 얻으면 50ms 대기 후 재조회
Thread.sleep(50);
List<String> retried = stringRedisTemplate.opsForList().range(key, 0, -1);
동시 요청 500개라면 락을 못 얻은 499개 스레드가 전부 50ms 잠들어버립니다. Tomcat 스레드 풀이 순식간에 고갈됩니다.
개선: 기다리지 말고 즉시 DB 폴백
return dbFallback.get();
락을 얻은 1개 요청이 캐시를 채우는 동안, 나머지는 DB에서 직접 조회합니다. 스레드 블로킹이 없으니 풀이 고갈되지 않습니다.
12. Redis 파이프라인
Redis를 여러 번 호출할 때 명령마다 네트워크 왕복이 발생합니다.
INCR view:1 → 요청 → 응답 (1 RTT)
SADD view:keys → 요청 → 응답 (1 RTT)
LPUSH recent → 요청 → 응답 (1 RTT)
개선: 파이프라인으로 1번 왕복에 묶기
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.incr(keyBytes);
connection.sAdd(setKeyBytes, keyBytes);
return null;
});
파이프라인을 적용한 곳 네 군데입니다.
| 위치 | 적용 내용 |
| ViewCountRedisService | INCR + SADD를 1번 왕복으로 |
| AnnounceService | LPUSH + LTRIM + EXPIRE를 1번으로 |
| CacheService.listLookAside() | 캐시 미스 시 List push + expire를 1번으로 |
| HomeService.getHomeData() | 홈 API lRange 6번 호출을 1번 왕복으로 통합 |
13. @Async 비동기 분리 + 스레드풀 분리
공고 상세 API에서 Redis 작업이 동기로 실행되고 있었습니다.
기존: DB 쿼리 끝나도 Redis 작업 끝날 때까지 커넥션 점유
@Transactional(readOnly = true)
public AnnounceDetailResponse getAnnounceDetail(...) {
Announce announce = announceRepository.findAnnounceById(announceId);
saveRecentAndPopularToRedis(...); // 동기 실행
return response;
}
개선: @Async로 분리
@Transactional(readOnly = true)
public AnnounceDetailResponse getAnnounceDetail(...) {
Announce announce = announceRepository.findAnnounceById(announceId);
redisAsyncService.saveRecentAndPopular(...); // 비동기 → 즉시 커넥션 반납
return response;
}
@Async("redisAsyncExecutor")
public void saveRecentAndPopular(...) { ... }
스레드풀을 용도별로 4개로 분리했습니다. 하나의 풀에 몰아넣으면 이메일 발송이 느릴 때 Redis 비동기 처리까지 대기하는 상황이 생깁니다.
emailAsyncExecutor 이메일 발송 전용
redisAsyncExecutor Redis 비동기 저장 전용
viewNumExecutor 조회수 처리 전용
cacheWarmingExecutor 캐시 워밍 전용
14. Cache-Aside + TTL Jitter + 분산 락
홈 화면 조회마다 공고, 축제, 기사, 공지를 DB에서 가져오고 있었습니다.
기존: 매번 DB 조회
List<NewAnnounceRedisDto> newAnnounces = announceRepository.findLatest(3);
개선 :Cache-Aside 패턴을 도입
캐시 히트 → Redis에서 바로 반환 (DB 조회 없음)
캐시 미스 → DB 조회 → Redis 저장 → 반환
여기서 두 가지를 추가로 신경 썼습니다.
TTL Jitter: TTL을 동일하게 설정하면 동시에 만료됩니다. Math.random() * 60초를 더해서 만료 시점을 분산시켰습니다.
Duration ttlWithJitter = baseTtl.plusSeconds((long)(Math.random() * 60));
분산 락: TTL 만료 직후 동시 요청이 몰려도 단 1개 요청만 DB를 조회하도록 setIfAbsent로 5초 락을 걸었습니다. Dog-pile effect를 차단합니다.
15. 캐시 워밍
서버가 처음 뜨면 캐시가 비어있어서 초기 트래픽이 전부 DB로 직행합니다. 서버 시작 시 미리 캐시를 채우는 캐시 워밍을 추가했습니다.
@Async("cacheWarmingExecutor")
@EventListener(ApplicationReadyEvent.class)
public void warmUp() {
try {
cacheService.listLookAside("home:new:announce", ...);
} catch (Exception e) {
log.warn("[CacheWarming] 신규 공고 실패: {}", e.getMessage());
}
// 축제, 기사, 공지도 동일하게
}
세 가지를 신경 썼습니다.
- @Async로 비동기 처리
캐시 워밍이 오래 걸려도 서버 시작을 블로킹하지 않습니다. - ApplicationReadyEvent 사용
@PostConstruct는 Bean 초기화 단계에서 실행되어 다른 Bean이 아직 준비되지 않았을 수 있습니다.
ApplicationReadyEvent는 서버가 완전히 뜬 뒤에 발생합니다. - 항목별 try-catch 격리
신규 공고 워밍이 실패해도 나머지 항목은 계속 진행됩니다. 캐시 워밍 실패가 서버 시작 실패로 이어지면 안 됩니다.
16. Redis 메모리 설정
# RDB, AOF 비활성화 (성능 우선)
save ""
appendonly no
# 메모리 상한 + LRU eviction
maxmemory 1gb
maxmemory-policy allkeys-lru
# 위험한 명령어 비활성화 (KEYS 전체 스캔 차단)
rename-command KEYS ""
# 압축 설정 (메모리 절약)
hash-max-ziplist-entries 512
list-max-ziplist-size -2
zset-max-ziplist-entries 128
KEYS 명령어를 비활성화한 이유는, 전체 키 스캔이 Redis를 완전히 블로킹하기 때문입니다. 실수로라도 KEYS *가 날아가면 서비스 전체가 순간 멈춥니다. view:keys Set으로 처리 대상 목록을 관리하는 이유도 여기에 있습니다.
allkeys-lru는 메모리가 꽉 찼을 때 가장 오래된 것부터 제거합니다. TTL이 없는 키도 포함해서 제거하기 때문에 캐시 용도로 적합합니다.
17. 이메일 발송 비동기 + Semaphore
이메일 발송이 동기로 처리되어 메일 서버 응답 대기 중 스레드가 블로킹되고 있었습니다.
기존: 수백 ms 블로킹
mailSender.send(message);
개선: @Async + Semaphore
@Async("emailAsyncExecutor")
public void sendVerificationEmail(String email) {
semaphore.acquire();
try {
mailSender.send(message);
} finally {
semaphore.release();
}
}
Semaphore로 동시 발송 수를 제한한 이유는 메일 서버에 한 번에 너무 많은 요청이 몰리면 발송 차단이 걸릴 수 있기 때문입니다.
스레드풀 거절 정책은 CallerRunsPolicy로 설정했습니다. 큐가 꽉 차도 요청을 버리지 않고 호출 스레드가 직접 처리합니다. 이메일 인증은 드롭되면 안 됩니다.
18. H2 → MySQL + JdbcTemplate Batch 삽입
성능 테스트는 실제 운영과 비슷한 데이터 규모에서 해야 의미가 있습니다. DB가 비어있으면 당연히 빠릅니다.
H2 인메모리 → MySQL 교체
더미 데이터: 약 200만 건 삽입
JPA saveAll()은 ORM 오버헤드가 커서 대용량 삽입에 적합하지 않습니다.
기존: JPA saveAll() → 엔티티 변환 + 쿼리 N번
repository.saveAll(entities);
개선: JdbcTemplate batchUpdate() → JDBC 직접 배치
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
public void setValues(PreparedStatement ps, int i) throws SQLException {
// 파라미터 세팅
}
public int getBatchSize() { return 5000; }
});
BATCH_SIZE 5000으로 묶어서 한 번에 INSERT합니다. 200만 건도 빠르게 삽입할 수 있었습니다.
전체 개선 요약
| 항목 | 이전 | 이후 |
| HikariCP / Tomcat | 기본값 | 리틀의 법칙 기반 튜닝 |
| DB 인덱스 | 없음 | 쿼리 패턴 기반 8개 설계 |
| @Transactional 위치 | 클래스 레벨 | 메서드 레벨로 좁힘 |
| 즐겨찾기 여부 조회 | N+1 EXISTS | LEFT JOIN 1번 |
| 서류 개수 조회 | N+1 size() | LEFT JOIN COUNT 1번 |
| 체크리스트 저장 | N번 INSERT | Bulk INSERT 1번 |
| viewCount | 매번 DB UPDATE → Lock 경합 | Redis INCR + 배치 Write Back |
| 인기 공고 | 매 요청 풀스캔 + 데드코드 | 10분 배치 캐싱 |
| 최근 본 공고 저장 | lRem O(N) | lPush + lTrim O(1) |
| 캐시 저장 | DTO JSON 직렬화 | ID만 저장 |
| Thundering Herd | Thread.sleep(50) | 즉시 DB 폴백 |
| Redis 호출 | N번 왕복 | 파이프라인 1번 왕복 |
| Redis 작업 | 동기 실행, 커넥션 점유 | @Async 비동기, 스레드풀 4개 분리 |
| 홈 API | 매번 DB | Cache-Aside + TTL Jitter + 분산 락 |
| 콜드 스타트 | 캐시 없이 시작 | ApplicationReadyEvent 캐시 워밍 |
| Redis 키 삭제 | del 블로킹 | unlink 비동기 |
| Redis 메모리 | 기본 설정 | maxmemory + LRU + KEYS 비활성화 |
| 이메일 발송 | 동기 블로킹 | @Async + Semaphore |
| 더미 데이터 | 없음 / JPA saveAll() | JdbcTemplate batchUpdate() 200만 건 |
결과
| 지표 | 최초 | 최종 | 개선율 |
| home P95 | 2865ms | 3ms | 99.9% |
| announce P95 | 2877ms | 10ms | 99.6% |
| favorite P95 | 2748ms | 7ms | 99.7% |
| checklist P95 | 2773ms | 26ms | 99.1% |
| 실패 건수 | 17,094건 | 19건 | 99.9% |
| 에러율 | 4.5% | 0.008% | - |
회고
하나씩 파고들면서 깨달은 것들입니다.
- Thread.sleep()은 절대 쓰면 안 됩니다. 스레드를 블로킹하는 순간 그 비용이 전체 시스템으로 퍼집니다.
- @Transactional은 붙이는 위치가 중요합니다. 클래스 레벨은 생각보다 넓은 범위를 커버합니다.
- 데드코드는 생각보다 쉽게 생깁니다. 읽는 키와 쓰는 키가 달랐는데 아무도 몰랐습니다.
- @EnableScheduling 어노테이션 하나가 전체 배치를 멈출 수 있습니다.
- KEYS view:* vs SMEMBERS view:keys처럼 사소해 보이는 설계 결정이 스케일이 커지면 큰 차이가 됩니다.
- 쿼리 수를 줄이는 것이 항상 정답은 아닙니다. 동시성 환경에서는 얼마나 좁은 범위에 락을 거느냐가 훨씬 중요할 수 있습니다.

'🖥️ 컴퓨터 공부 > 부하테스트 & 성능최적화' 카테고리의 다른 글
| 해커톤 성능 최적화 -4 (1) | 2026.04.24 |
|---|---|
| 해커톤 성능 최적화 -3 (0) | 2026.04.24 |
| 해커톤 성능 최적화 -2 (0) | 2026.04.24 |
| 해커톤 성능 최적화 - 1 (0) | 2026.04.24 |
| k6 와 그라파나로 성능최적화 도전 (0) | 2026.04.24 |