
이 글은 ThirdTool 서비스를 운영하면서 UX 체감 성능을 개선하기 위해
팀 내부에서 정리한 성능 감시 체크리스트입니다.
처음부터 완벽하게 갖춘 팀은 없어요. 우리도 장애를 겪고, 느린 쿼리를 발견하고, 그때그때 고쳐가며 여기까지 왔어요.
ThirdTool은 간격 반복(Spaced Repetition) 기반의 학습 앱이에요.
사용자가 복습 세션에 들어가는 순간 — 오늘의 카드를 불러오고, 복습 결과를 저장하고, 다음 복습 일정을 업데이트하는 흐름이 연속으로 발생해요.
문제는 이 흐름이 느려지면 학습 집중이 끊긴다는 거예요. 성능 = UX 인 서비스예요.
초반엔 장애가 나면 로그 뒤지고 핫픽스 배포하는 방식으로 버텼는데, 어느 순간부터 "이게 언제 터질지 모른다"는 불안감 이 팀 전체에 깔리기 시작했어요.
그때부터 팀이 공유할 수 있는 성능 감시 기준을 만들기 시작했습니다.진행하면서
크게 두 레이어로 나눠서 관리해요.
📦 Application 레벨 → 코드와 쿼리에서 잡는 성능(관측 가능성)
🏗️ Infrastructure 레벨 → 서버와 네트워크에서 잡는 성능(가시성)
실무에서 슬로우 쿼리의 절반 이상은 N+1이에요. ThirdTool처럼 카드-키워드 관계가 있는 도메인은 특히 조심해야 해요.
// ❌ Before: 카드 100개 → 쿼리 101번 발생
cardRepository.findAll()
.forEach(card -> card.getKeywords().size());
// ✅ After: fetch join으로 쿼리 1번
@Query("SELECT c FROM Card c JOIN FETCH c.keywords WHERE c.userId = :userId")
List<Card> findAllWithKeywords(@Param("userId") Long userId);
// 또는 컬렉션 N+1엔 @BatchSize
@BatchSize(size = 100)
@OneToMany(mappedBy = "card")
private List<Keyword> keywords;
확인 방법: spring.jpa.show-sql=true 로 쿼리 수 직접 세기, 또는 p6spy 라이브러리로 쿼리 로그 분석.
카드가 10,000개인 사용자가 복습 목록을 열면 어떻게 될까요? 전부 메모리에 올라가요.
// ❌ Before
List<Card> all = cardRepository.findByUserId(userId);
// ✅ After: 무한스크롤엔 Slice (count 쿼리 없어서 더 빠름)
Slice<Card> cards = cardRepository.findByUserId(
userId, PageRequest.of(page, 20)
);
Slice vs Page 선택 기준
- 무한스크롤 →
Slice(전체 개수 불필요)- 페이지 번호 UI →
Page(count 쿼리 필요)
복습 목록 화면에서 30개 컬럼짜리 Card 엔티티를 전부 꺼낼 필요는 없어요.
// ✅ 화면에 필요한 필드만 선언
public interface CardSummary {
Long getId();
String getMainNote();
LocalDate getNextReviewDate();
}
List<CardSummary> cards = cardRepository.findProjectedByUserId(userId);
엔티티 전체 로딩 대비 DB I/O, 메모리, 직렬화 비용 모두 줄어요.
조회 메서드에 @Transactional(readOnly = true) 하나만 붙여도 JPA dirty checking이 스킵돼요.
// ✅ 조회
@Transactional(readOnly = true)
public List<CardSummary> getTodayCards(Long userId) { ... }
// ✅ 쓰기 (기본값 readOnly=false)
@Transactional
public ReviewResult submitReview(Long cardId, int quality) { ... }
나중에 Read Replica 도입 시 라우팅 기반으로 자연스럽게 연결되는 설계이기도 해요.
복습 결과를 루프로 건별 저장하고 있다면 즉시 바꿔야 해요.
// ❌ Before: INSERT 100번
reviews.forEach(r -> reviewRepository.save(r));
// ✅ After: batch로 한 번에
reviewRepository.saveAll(reviews);
# application.yml — hibernate batch 설정 필수
spring:
jpa:
properties:
hibernate:
jdbc.batch_size: 50
order_inserts: true
order_updates: true
Caffeine (로컬 캐시) → Redis (분산 캐시) 순서로 도입해요.
처음부터 Redis 없어도 Caffeine만으로 조회성 API는 극적으로 개선돼요.
// 오늘의 복습 카드는 자주 바뀌지 않음 → 캐시 적합
@Cacheable(value = "todayCards", key = "#userId")
public List<Card> getTodayCards(Long userId) { ... }
캐시 도입 전 체크 포인트:
복습 저장 API가 느리다면, 동기로 묶인 부가 작업이 원인일 수 있어요.
// ❌ Before: 복습 저장 + 통계 갱신 + 알림이 한 트랜잭션
public ReviewResult submitReview(Long cardId, int quality) {
saveReview(cardId, quality); // 핵심
updateUserStatistics(userId); // 부가 → 비동기로 분리
sendPushNotification(userId); // 부가 → 비동기로 분리
}
// ✅ After
@Async
public void updateUserStatistics(Long userId) { ... }
@Async
public void sendPushNotification(Long userId) { ... }
응답 시간 = 핵심 로직만의 시간으로 줄어들어요.
Prometheus에 슬로우 쿼리가 잡히기 시작하면 인덱스가 원인인 경우가 70% 이상 이에요.
부하 테스트 전에 EXPLAIN으로 실행 계획을 반드시 확인하는 습관을 들이세요.
-- next_review_date 기반 조회가 잦은데 인덱스 없으면 full scan
EXPLAIN SELECT * FROM card
WHERE user_id = ? AND next_review_date <= NOW();
-- 복합 인덱스 추가
CREATE INDEX idx_card_user_review
ON card(user_id, next_review_date);
ThirdTool에서 인덱스 우선순위가 높은 쿼리들:
user_id + next_review_date 조회 (복습 스케줄)user_id + created_at 조회 (카드 목록)HikariCP 기본값 그대로 쓰면 동시 요청이 몰릴 때 Pool 대기가 병목이 돼요.
spring:
datasource:
hikari:
maximum-pool-size: 10 # 공식: CPU 코어 수 × 2 + 디스크 수
minimum-idle: 5
connection-timeout: 3000 # 3초 후 예외 → 무한 대기 방지
idle-timeout: 600000
모니터링 신호: Prometheus에서 hikaricp_connections_pending > 0 이 뜨기 시작하면 pool-size를 올려야 한다는 신호예요.
설정 3줄로 JSON 응답 크기를 60~80% 줄일 수 있어요.
server:
compression:
enabled: true
mime-types: application/json
min-response-size: 1024 # 1KB 이상만 압축
모바일 사용자가 많은 ThirdTool에서 체감 차이가 커요.
// null 필드를 응답에서 제외
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CardResponse {
private Long id;
private String mainNote;
private String summary; // null이면 응답에서 제외
private String keywordCue; // null이면 응답에서 제외
}
ThirdTool의 읽기:쓰기 비율은 대략 8:2 예요 (복습 조회가 압도적으로 많음).
이 비율이 7:3을 넘으면 Read Replica 분리 효과가 극적으로 나타나요.
Write → Primary DB
Read → Replica DB (복습 조회, 스케줄 조회, 카드 목록)
// AbstractRoutingDataSource로 readOnly 여부에 따라 자동 분기
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "replica" : "primary";
}
}
4번 체크리스트(@Transactional(readOnly = true))와 자연스럽게 연결되는 이유가 여기 있어요.
❌ Before: React 빌드 결과물 → Spring 서버 직접 서빙
✅ After: React 빌드 결과물 → S3 + CloudFront → CDN 엣지 캐싱
Spring 서버는 API 처리만 담당
정적 파일 처리 비용이 서버에서 완전히 빠지고, 사용자는 가장 가까운 엣지 서버에서 받아요.
우리 팀이 실제로 밟아온 순서예요. 처음부터 다 갖출 필요는 없어요.
1단계 (지금 당장)
└─ EXPLAIN으로 슬로우 쿼리 인덱스 확인
└─ @Transactional(readOnly = true) 적용
└─ N+1 체크 (show-sql 로그로)
2단계 (사용자 증가 전)
└─ Caffeine 로컬 캐시 도입
└─ Pagination 강제 (전체 조회 제거)
└─ Gzip 압축 활성화
3단계 (부하 테스트 단계)
└─ Prometheus + Grafana 모니터링 구축
└─ HikariCP 튜닝 (pending 지표 기반)
└─ @Async로 비동기 분리
4단계 (스케일업)
└─ Redis 캐시 도입
└─ Read Replica 분리
└─ CDN (S3 + CloudFront)
성능 개선은 "한 번에 다 하는 것"이 아니에요.
인덱스와 캐싱을 먼저 잡고 → 그다음 부하 테스트.
그래야 "인프라 문제 vs 코드 문제"를 깔끔하게 구분할 수 있어요.
ThirdTool 팀은 이 체크리스트를 PR 리뷰 기준 중 하나로 사용하고 있어요.
완벽한 시스템을 처음부터 만드는 것보다, 지금 단계에 맞는 기준을 팀이 공유하는 것 이 더 중요하다고 생각해요.
다음 편에서는 Prometheus + Grafana 실제 구축 과정을 다뤄볼 예정이에요.
Tags: #Spring #성능최적화 #JPA #ThirdTool #백엔드