TPS와 성능 최적화 - 서버 늘리기 전에 코드부터 보자
목차
TPS란?
TPS(Transaction Per Second)는 초당 처리할 수 있는 트랜잭션 수입니다. 쉽게 말해 “1초에 몇 건의 작업(비즈니스 처리)을 완료할 수 있는가”를 나타내는 지표입니다. 요청 단위를 세는 RPS(Request Per Second)와는 다른 개념으로, 트랜잭션 하나가 여러 요청을 포함할 수 있습니다.
서비스 규모별로 대략적인 TPS 수준을 보면 감이 옵니다.
| 서비스 규모 | TPS |
|---|---|
| 소규모 사내 시스템 | 10~50 |
| 일반 웹서비스 | 100~500 |
| 대형 커머스 | 1,000~10,000 |
| 카카오/네이버급 | 수십만 이상 |
스케일아웃과 캐싱만으로는 부족하다
스케일아웃의 한계
WAS를 아무리 늘려도 DB가 1대면 거기서 병목이 생깁니다.
[손님들] → [계산대(WAS) x 10] → [창고(DB) 1개] ← 여기가 병목!
캐싱의 한계
읽기(조회)는 캐싱으로 해결할 수 있지만, 쓰기(주문, 결제, 재고 차감)는 무조건 DB를 거쳐야 합니다. 트래픽이 급증하면 캐시 히트율이 높아도 캐시 미스 건수 자체가 늘어나 DB가 버티지 못합니다.
TPS를 올리는 실제 기법들
1. 벌크 처리 (비용: 0원)
가장 먼저 해볼 수 있는 방법입니다. 배치가 정상 적용되면 INSERT 100건이 50건씩 묶여 DB 왕복 두 번으로 끝납니다.
// Before: INSERT 쿼리 100번 실행
for (User user : users) {
userRepository.save(user);
}
// After: 배치 INSERT로 DB 왕복 횟수 감소
// 필수: spring.jpa.properties.hibernate.jdbc.batch_size=50 (기본값 0 → 배치 꺼짐)
// 권장: spring.jpa.properties.hibernate.order_inserts=true (여러 엔티티 타입 혼합 시 효율 향상)
// 주의: @GeneratedValue(strategy = IDENTITY) 사용 시 배치 비활성화됨 → SEQUENCE 전략 필요
userRepository.saveAll(users);
2. 비동기 처리 (비용: 0원)
핵심 로직만 동기로 처리하고, 나머지는 이벤트로 넘깁니다. 아래 예시에서는 응답까지 4초 걸리던 것이 1초로 줄어듭니다.
// Before: 전부 동기 처리 → 응답까지 총 4초
public void order() {
saveOrder(); // 1초
sendKakaoAlert(); // 2초 (외부 API)
updateStats(); // 1초
}
// After: 핵심만 동기 처리 → 응답까지 총 1초
// (sendKakaoAlert, updateStats는 이벤트 핸들러에서 @Async로 비동기 처리)
// 주의: @Async 동작을 위해 @EnableAsync 설정 클래스 필요
public void order() {
saveOrder(); // 1초 (동기 - 반드시 완료되어야 함)
eventPublisher.publishEvent(new OrderCreatedEvent());
}
@EventListener
@Async // 별도 스레드에서 비동기 처리 (핸들러 내부는 순차 실행)
public void handleOrderCreated(OrderCreatedEvent event) {
sendKakaoAlert(); // 2초 (비동기)
updateStats(); // 1초 (비동기)
}
3. 락 최적화 (비용: 0원)
비관적 락은 안전한 대신 동시성이 낮습니다. 충돌이 자주 발생하지 않는 상황이라면 낙관적 락으로 전환하는 것만으로 동시 처리량이 올라갑니다.
4. 읽기/쓰기 DB 분리 - Read Replica (비용: 중간)
쓰기 → Master DB 1대
읽기 → Read Replica 여러 대
대부분의 서비스는 읽기가 80~90%를 차지합니다. 읽기를 전부 Replica로 보내면 Master에는 쓰기 10~20%만 남습니다.
CQRS(Command Query Responsibility Segregation)는 코드 레벨에서 쓰기 모델과 읽기 모델 자체를 분리하는 아키텍처 패턴으로, Read Replica 구성보다 넓은 개념입니다. 이 글에서는 인프라 수준의 읽기/쓰기 분리를 다룹니다.
5. 캐싱 (비용: 중간)
DB 조회를 Redis 조회로 바꾸면 응답 속도가 50ms에서 1ms로 떨어집니다. 다만 캐시 갱신 시점이나 데이터 불일치 같은 관리 포인트가 생깁니다.
6. 샤딩 (비용: 높음)
[범위 기반(Range Sharding) 예시]
사용자 ID 1~100만 → DB 서버 1
사용자 ID 100만~200만 → DB 서버 2
사용자 ID 200만~300만 → DB 서버 3
DB 자체를 쪼개서 부하를 분산하는 방법입니다. 효과는 확실하지만 복잡도가 급격히 올라가기 때문에 최후의 수단으로 남겨둬야 합니다.
비용 대비 효율 순위
| 순위 | 기법 | 비용 | 효과 |
|---|---|---|---|
| 1위 | 벌크/비동기 처리 | 0원 | 수십 배 향상 가능 |
| 2위 | 락 최적화 | 0원 | 동시 처리량 향상 |
| 3위 | Read Replica | 중간 | 읽기 성능 대폭 향상 |
| 4위 | 캐싱 | 중간 | 효과 좋지만 관리 복잡 |
| 5위 | 샤딩 | 높음 | 최후의 수단 |
서버를 사기 전에 코드부터 봐야 합니다. 1위와 2위가 둘 다 0원입니다.
DB 커넥션 풀
Spring Boot의 HikariCP 기본 커넥션 수는 10개입니다. 실무에서는 서비스 규모에 따라 조정이 필요합니다.
| 서비스 규모 | 커넥션 수 |
|---|---|
| 소규모 | 10~20개 |
| 중규모 | 20~50개 |
| 대규모 | 50~100개 |
적정 커넥션 수를 계산하는 공식도 있습니다.
커넥션 수 = (CPU 코어 수 × 2) + effective_spindle_count
HikariCP 문서가 소개한 PostgreSQL 프로젝트의 공식입니다. effective_spindle_count는 장착된 디스크 개수가 아니라, 데이터가 전부 캐시에 있으면 0이 되는 값입니다. 실제 값은 부하 테스트로 조정해야 합니다.
그런데 커넥션 풀이 부족하다고 느껴지면, 진짜 원인은 대부분 느린 쿼리입니다. 커넥션이 부족한 게 아니라 커넥션을 너무 오래 잡고 있는 게 문제입니다. 커넥션 풀을 늘리는 건 임시방편이고, 쿼리 최적화가 먼저입니다.
샤딩을 하면 생기는 문제들
샤드 간 조인 불가
-- 철수(DB1)의 친구 민수(DB2) 정보 조회?
-- DB가 다르니까 조인 불가능!
애플리케이션에서 각 DB를 따로 조회한 뒤 직접 합쳐야 합니다.
샤드 간 트랜잭션
철수(DB1) → 민수(DB2) 송금
1. DB1에서 철수 잔액 -10만원
2. DB2에서 민수 잔액 +10만원
2번에서 실패하면? → 철수 돈만 사라짐
이런 문제를 해결하려면 분산 트랜잭션(2PC)이나 SAGA 패턴이 필요해집니다.
글로벌 연산 (랭킹, 통계 등)
랭킹 TOP 10을 조회하려면 모든 샤드를 전부 조회해서 애플리케이션에서 합쳐야 합니다. 결국 Redis 같은 별도 저장소로 분리 관리하게 됩니다.
| 기능 | 샤딩 전 | 샤딩 후 |
|---|---|---|
| 랭킹 | ORDER BY LIMIT | Redis 별도 관리 |
| 검색 | WHERE LIKE | Elasticsearch 별도 |
| 통계 | COUNT, SUM | 각 샤드 조회 후 합산 |
현실적인 성능 최적화 단계
1단계: 쿼리 최적화, 인덱스 정리 → 대부분 여기서 해결됨
2단계: 읽기 분리 (Read Replica) → 중규모까지 커버
3단계: 캐싱 (Redis) → 여기까지 하면 웬만하면 버팀
4단계: 수직 확장 (DB 스펙 업그레이드) → 돈으로 해결
5단계: 샤딩 → 진짜 답이 없을 때만
“샤딩을 해야 할까요?”라고 묻는 단계라면 아직 5단계로 갈 때는 아닙니다.