본문 바로가기
Java & Spring

성능 최적화: @Transactional(readOnly)와 JDBC Fetch 튜닝으로 DB 부하 60% 줄이는 실전 전략

by yamoojin83 2025. 12. 7.

성능 최적화: @Transactional(readOnly)와 JDBC Fetch 튜닝으로 DB 부하 60% 줄이는 실전 전략


대부분의 Spring Boot 애플리케이션에서 DB 성능 병목은 조회 쿼리(read query)에서 발생합니다. 특히 대량 데이터 조회, 반복적인 조회 API, 보고서/리스트 화면 API는 DB와 애플리케이션 사이에서 발생하는 네트워크 왕복 비용커넥션 관리 비용 때문에 전체 성능을 크게 떨어뜨립니다.

이 글에서는 다음 두 가지 기술을 결합하여 DB 부하를 30~60%까지 줄일 수 있는 전략을 설명합니다.

@Transactional(readOnly = true)JDBC fetch size / Statement 옵션 튜닝

JPA를 쓰든, MyBatis를 쓰든, 순수 JDBC를 쓰든 모두 적용할 수 있는 실전 가이드입니다.

 

readOnly 트랜잭션 구조


1) readOnly 트랜잭션이 왜 중요한가?


많은 개발자가 readOnly 옵션을 “그냥 넣는 것” 정도로 생각하지만, 실제 내부에서는 다음과 같은 결정적인 최적화가 발생합니다.

■ 플러시(Flush) 생략 → 쓰기 비용 감소
■ 변경 감지(DIRTY CHECK) 비활성화 → 엔티티 스냅샷 메모리 절감
■ Hibernate가 읽기 트랜잭션으로 최적화된 코드 경로 사용

즉, readOnly = true로 설정한 메서드는 DB에서 읽기만 수행할 것임을 엔진에게 명확히 알리는 역할을 합니다.

Spring Boot JPA의 기본 트랜잭션 설정은 다음과 같습니다.


@Transactional(readOnly = true)
public List getUsers() {
    return userRepository.findAll();
}


여기서 중요한 점은:

✔ 서비스 계층의 조회 메서드는 100% readOnly 필요 ✔ 변경 작업(create/update/delete)은 별도의 메서드로 분리

이 원칙만 지켜도 JPA와 Hibernate의 내부 동작이 가벼워지면서 API 성능은 자연스럽게 향상됩니다.


2) JDBC Fetch Size 튜닝이 필요한 이유


Fetch Size는 “한 번의 네트워크 왕복에서 읽어오는 레코드 수”를 의미합니다.

기본적으로 JDBC 드라이버는 10~50개 정도의 row만 미리 가져오고, 나머지는 필요할 때마다 추가 요청을 보냅니다.

즉, 다음과 같은 비효율이 발생합니다.

■ 대량 데이터 조회 시 수백~수천 번의 네트워크 왕복 발생 ■ 매 호출마다 Context Switching 증가 ■ 응답 지연 증가 및 CPU 낭비

하지만 Fetch Size를 적절히 조정하면 이 문제를 크게 개선할 수 있습니다.


// 직접 JDBC Template 사용 시
jdbcTemplate.setFetchSize(1000);


PostgreSQL, MySQL, Oracle 등 대부분의 DB는 fetchSize = 500~2000 사이로 설정했을 때 좋은 성능을 보입니다.

특히 PostgreSQL은 서버 사이드 커서(server-side cursor)를 사용할 수 있어 대량 데이터 처리에 매우 유리합니다.

 

JDBC Fetch Size 개념 이미지


3) Spring Boot + JPA 환경에서 적용하기


Spring Boot에서는 JPA 또는 Hibernate 설정을 통해 fetch size 옵션을 간접적으로 조정할 수 있습니다.

📌 Hibernate에서 fetch_size 설정


spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          fetch_size: 500
          batch_size: 50


fetch_size는 SELECT 튜닝, batch_size는 INSERT/UPDATE 튜닝에서 효과를 발휘합니다.

📌 JPA Query Hint로 개별 쿼리 fetch size 조정


@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "1000"))
@Query("select u from User u")
List findAllUsers();


운영 환경에서는 “모든 쿼리에 fetch size 1000” 같은 방식보다 쿼리 성격에 맞춰 개별 조정하는 것을 권장합니다.


4) MyBatis 환경에서의 Fetch Size


MyBatis는 JDBC layer 위에서 동작하므로 fetch size 설정이 매우 직접적입니다.


    SELECT * FROM users


MyBatis의 fetchSize는 JDBC 드라이버에 전달되며 대량 SELECT에서 큰 효과를 보여줍니다.


5) readOnly + fetch size를 함께 적용하면 생기는 효과


이 두 옵션을 함께 사용했을 때의 효과는 단순 합이 아닙니다.

■ DB의 네트워크 왕복 횟수 감소
■ 애플리케이션의 메모리 사용량 감소
■ 트랜잭션 Lock 시간이 줄어듦
■ Connection Pool 점유 시간이 짧아짐
■ 전체 TPS(초당 처리량) 증가

실제로 많은 기업에서 다음과 같은 성능 개선을 확인했습니다.

✔ 대량 조회 API: 평균 응답시간 40~60% 감소 ✔ JPA 기반 리스트 API: CPU 사용량 20~35% 감소 ✔ MyBatis 기반 보고서 API: 1/3 수준의 커넥션 점유량

즉, readOnly + fetchSize는 “투자 대비 효과가 가장 큰 DB 튜닝”입니다.

 

readOnly + FetchSize 튜닝 조합 흐름도


6) 언제 readOnly 트랜잭션을 사용하면 안 될까?


다음 상황에서는 readOnly = true를 사용하면 안 됩니다.

❌ save(), update(), delete() 작업이 있는 메서드 ❌ 메서드 내부에서 다른 save 메서드를 호출하는 경우 ❌ Lazy 로딩된 엔티티를 변경하는 경우 ❌ Dirty checking을 의도적으로 사용해야 하는 경우

readOnly로 선언하면 flush가 발생하지 않기 때문에 데이터가 DB에 반영되지 않는 위험이 있습니다.


7) 운영 환경에서의 테스트 체크리스트


다음 7가지는 운영 환경에서 반드시 확인해야 하는 체크 포인트입니다.

1) 평균 응답 시간 변화 2) DB Connection Pool 사용량 3) DB CPU 및 I/O 변화 4) 네트워크 패킷 감소 여부 5) fetch size가 드라이버에 적용되었는지 6) readOnly에서 flush가 비활성화되었는지 7) Lazy 로딩으로 N+1이 발생하지는 않는지

이 중 세 번째와 네 번째가 가장 효과가 크게 나타납니다.


8) 결론: 읽기 API는 반드시 readOnly + fetch size 조합을 사용하라


대부분의 서비스에서 “조회 API 비중”은 전체 API 호출 중 80% 이상을 차지합니다.

따라서 단순한 비즈니스 로직보다 조회 트랜잭션 최적화가 전체 시스템 성능에 절대적인 영향을 줍니다.

✔ @Transactional(readOnly = true)
✔ fetch size 튜닝

이 두 가지는 비용 대비 효과가 가장 높은 튜닝이며 Spring Boot, MyBatis, JPA 모두에 적용할 수 있는 범용 전략입니다.

클린 코드, 확장성, 성능 중 무엇을 추구하든 이 조합은 반드시 고려해야 하는 “필수 옵션”입니다.