TPS와 성능 최적화 - 서버 늘리기 전에 코드부터 보자

목차

TPS란?

TPS(Transaction Per Second)는 초당 처리할 수 있는 트랜잭션 수다. 쉽게 말해 “1초에 몇 건의 작업(비즈니스 처리)을 완료할 수 있는가”를 나타내는 지표다. 요청 단위를 세는 RPS(Request Per Second)와는 다른 개념으로, 트랜잭션 하나가 여러 요청을 포함할 수 있다.

서비스 규모별로 대략적인 TPS 수준을 보면 감이 온다.

서비스 규모TPS
소규모 사내 시스템10~50
일반 웹서비스100~500
대형 커머스1,000~10,000
카카오/네이버급수십만 이상

TPS와 함께 자주 등장하는 용어도 정리해두면 좋다.

용어의미
TPS초당 트랜잭션 수
RPS초당 요청 수 (Request Per Second)
Latency응답 시간
Throughput처리량

스케일아웃과 캐싱만으로는 부족하다

스케일아웃의 한계

WAS를 아무리 늘려도 결국 DB가 1대면 거기서 병목이 생긴다.

[손님들] → [계산대(WAS) x 10] → [창고(DB) 1개] ← 여기가 병목!

캐싱의 한계

읽기(조회)는 캐싱으로 해결할 수 있지만, 쓰기(주문, 결제, 재고 차감)는 무조건 DB를 거쳐야 한다. 트래픽이 급증하면 캐시 히트율이 높아도 미스 절대량이 많아져서 DB가 버티지 못한다.


TPS를 올리는 실제 기법들

1. 벌크 처리 (비용: 0원)

가장 먼저 해볼 수 있는 방법이다. 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원)

핵심 로직만 동기로 처리하고, 나머지는 이벤트로 넘기면 응답 시간이 확 줄어든다.

// 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%를 차지한다. 읽기를 분리하는 것만으로도 DB 부하가 크게 줄어든다.

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위샤딩높음최후의 수단

서버 사기 전에 코드부터 보자. 비용 0원짜리 최적화가 생각보다 효과가 크다.


DB 커넥션 풀

Spring Boot의 HikariCP 기본 커넥션 수는 10개다. 실무에서는 서비스 규모에 따라 조정이 필요하다.

서비스 규모커넥션 수
소규모10~20개
중규모20~50개
대규모50~100개

적정 커넥션 수를 계산하는 공식도 있다.

커넥션 수 = (CPU 코어 수 × 2) + 스토리지 디스크 수
※ HikariCP 문서(brettwooldridge)에서 소개된 공식. PostgreSQL 환경 기준이며 어디까지나 출발점이다. 실제 값은 부하 테스트로 조정이 필요하다.

그런데 커넥션 풀이 부족하다고 느껴지면, 진짜 원인은 대부분 느린 쿼리다. 커넥션이 부족한 게 아니라 커넥션을 너무 오래 잡고 있는 게 문제다. 커넥션 풀을 늘리는 건 임시방편이고, 쿼리 최적화가 먼저다.


샤딩을 하면 생기는 문제들

샤딩이 최후의 수단인 이유를 구체적으로 보자.

샤드 간 조인 불가

-- 철수(DB1)의 친구 민수(DB2) 정보 조회?
-- DB가 다르니까 조인 불가능!

애플리케이션에서 각 DB를 따로 조회한 뒤 직접 합쳐야 한다.

샤드 간 트랜잭션

철수(DB1) → 민수(DB2) 송금
1. DB1에서 철수 잔액 -10만원
2. DB2에서 민수 잔액 +10만원

2번에서 실패하면? → 철수 돈만 사라짐

이런 문제를 해결하려면 분산 트랜잭션(2PC)이나 SAGA 패턴이 필요해진다.

글로벌 연산 (랭킹, 통계 등)

랭킹 TOP 10을 조회하려면 모든 샤드를 전부 조회해서 애플리케이션에서 합쳐야 한다. 결국 Redis 같은 별도 저장소로 분리 관리하게 된다.

기능샤딩 전샤딩 후
랭킹ORDER BY LIMITRedis 별도 관리
검색WHERE LIKEElasticsearch 별도
통계COUNT, SUM각 샤드 조회 후 합산

현실적인 성능 최적화 단계

1단계: 쿼리 최적화, 인덱스 정리       → 대부분 여기서 해결됨
2단계: 읽기 분리 (Read Replica)       → 중규모까지 커버
3단계: 캐싱 (Redis)                  → 여기까지 하면 웬만하면 버팀
4단계: 수직 확장 (DB 스펙 업그레이드)  → 돈으로 해결
5단계: 샤딩                          → 진짜 답이 없을 때만

정리

  • TPS 향상은 코드 최적화부터 - 서버 늘리기 전에 코드 먼저
  • 비용 0원인 최적화가 효과 크다 - 벌크 처리, 비동기, 락 최적화
  • 커넥션 풀 부족은 증상이다 - 진짜 원인은 느린 쿼리
  • 샤딩은 최후의 수단 - 복잡도가 급격히 증가하고, 대기업이나 하는 이유가 있다
  • “샤딩 해야 할까요?”라고 물어보는 단계면 안 해도 된다