여는 글

이전 포스팅에서 @Transactional의 동작 원리(프록시 패턴)를 정리했습니다. 이번에는 한 발 더 나아가, @Transactional을 잘못 사용하면 발생할 수 있는 심각한 문제를 다뤄보려 합니다.

실무에서 “갑자기 서버가 느려졌어요”, “API 응답이 안 와요”라는 장애가 발생했을 때, 원인이 DB 커넥션 고갈인 경우가 생각보다 많습니다.


핵심 개념: 트랜잭션 = 커넥션 점유

왜 @Transactional이 시작되면 커넥션을 획득할까?

이전 포스팅에서 @Transactional은 프록시 패턴으로 동작한다고 설명했습니다. 프록시가 실제로 하는 일을 다시 보면:

// Spring이 생성한 프록시의 동작
public class UserService$$Proxy {
    public void createUser(User user) {
        // 1. 트랜잭션 시작 ← 여기서 커넥션 획득!
        TransactionStatus status = transactionManager.getTransaction();

        try {
            target.createUser(user);  // 실제 메소드 실행
            transactionManager.commit(status);  // 커밋
        } catch (RuntimeException e) {
            transactionManager.rollback(status);  // 롤백
            throw e;
        }
        // ← 여기서 커넥션 반환
    }
}

transactionManager.getTransaction()이 호출되는 순간, 내부적으로 다음이 일어납니다:

  1. DataSource(커넥션 풀)에서 커넥션 획득
  2. connection.setAutoCommit(false) 설정
  3. 해당 커넥션을 ThreadLocal에 바인딩

그리고 commit() 또는 rollback()이 호출될 때까지 이 커넥션은 반환되지 않습니다.

프록시 진입 → getTransaction() → 커넥션 획득
                    ↓
              (메소드 실행 - 이 동안 커넥션 점유)
                    ↓
         commit()/rollback() → 커넥션 반환 → 프록시 종료

문제는 커넥션 풀(Connection Pool)의 크기가 제한되어 있다는 것입니다.


커넥션 풀이란?

DB 연결(Connection)은 생성 비용이 비쌉니다. 그래서 미리 일정 개수의 커넥션을 만들어놓고 재사용하는데, 이를 커넥션 풀이라고 합니다.

Spring Boot의 기본 커넥션 풀인 HikariCP의 기본 설정:

spring:
  datasource:
    hikari:
      maximum-pool-size: 10  # 기본값: 10개
      connection-timeout: 30000  # 30초 동안 커넥션 못 얻으면 예외

즉, 동시에 10개의 트랜잭션만 실행 가능합니다.

11번째 요청이 들어오면? 앞선 트랜잭션이 끝날 때까지 대기합니다.


병목 시나리오 1: 외부 API 호출을 트랜잭션 안에서

가장 흔한 실수입니다.

@Transactional
public void processOrder(Order order) {
    // 1. DB 저장 (0.01초)
    orderRepository.save(order);

    // 2. 외부 결제 API 호출 (3초 소요) 😱
    paymentClient.requestPayment(order);

    // 3. DB 업데이트 (0.01초)
    order.setStatus(PAID);
    orderRepository.save(order);
}

문제점

  • 외부 API가 3초 걸리는 동안 커넥션을 점유
  • 10개 요청이 동시에 들어오면 커넥션 풀 고갈
  • 11번째 요청부터 30초 대기 후 타임아웃 예외

해결책

public void processOrder(Order order) {
    // 1. 첫 번째 트랜잭션: 주문 저장
    orderService.saveOrder(order);

    // 2. 외부 API 호출 (트랜잭션 밖에서!)
    PaymentResult result = paymentClient.requestPayment(order);

    // 3. 두 번째 트랜잭션: 상태 업데이트
    orderService.updateOrderStatus(order, result);
}

@Service
public class OrderService {
    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }

    @Transactional
    public void updateOrderStatus(Order order, PaymentResult result) {
        order.setStatus(result.isSuccess() ? PAID : FAILED);
        orderRepository.save(order);
    }
}

핵심: 외부 호출은 트랜잭션 밖으로 빼라!


병목 시나리오 2: 대용량 데이터 처리

@Transactional
public void processAllUsers() {
    List<User> users = userRepository.findAll();  // 100만 건

    for (User user : users) {
        // 각 사용자에 대해 복잡한 처리 (1초씩)
        processUser(user);
    }
}
// 총 소요시간: 100만 초 = 11일... 😱

문제점

  • 11일 동안 커넥션 1개 점유
  • 메모리에 100만 건 로딩 → OOM 가능성

해결책: 배치 처리 + 페이징

public void processAllUsers() {
    int page = 0;
    int size = 1000;

    while (true) {
        List<User> users = userService.findUsersPage(page, size);
        if (users.isEmpty()) break;

        for (User user : users) {
            userService.processUser(user);  // 각각 별도 트랜잭션
        }
        page++;
    }
}

@Service
public class UserService {
    @Transactional(readOnly = true)
    public List<User> findUsersPage(int page, int size) {
        return userRepository.findAll(PageRequest.of(page, size)).getContent();
    }

    @Transactional
    public void processUser(User user) {
        // 개별 처리
    }
}

병목 시나리오 3: N+1 쿼리 문제

@Transactional
public void printAllOrders() {
    List<Order> orders = orderRepository.findAll();  // 쿼리 1번

    for (Order order : orders) {
        System.out.println(order.getUser().getName());  // 쿼리 N번 (Lazy Loading)
    }
}

주문이 1000건이면 1001번의 쿼리가 실행됩니다.

해결책: Fetch Join

@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();

병목 시나리오 4: readOnly를 안 쓰는 조회

@Transactional  // readOnly = false (기본값)
public List<User> getUsers() {
    return userRepository.findAll();
}

문제점

  • 읽기 전용인데도 쓰기용 트랜잭션 설정
  • Dirty Checking, 스냅샷 저장 등 불필요한 오버헤드

해결책

@Transactional(readOnly = true)  // ✅ 읽기 전용 명시
public List<User> getUsers() {
    return userRepository.findAll();
}

readOnly = true의 장점:

  • Dirty Checking 생략 → 성능 향상
  • DB Replication 환경에서 Read Replica로 라우팅 가능

모니터링: 커넥션 풀 상태 확인

HikariCP는 메트릭을 제공합니다:

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,metrics

logging:
  level:
    com.zaxxer.hikari: DEBUG

주요 메트릭:

  • hikaricp.connections.active: 사용 중인 커넥션
  • hikaricp.connections.pending: 대기 중인 요청
  • hikaricp.connections.timeout: 타임아웃 발생 횟수

pending이 지속적으로 높으면 커넥션 부족!


커넥션 풀 사이즈, 얼마가 적당할까?

HikariCP 공식 문서에서 제안하는 공식:

connections = (CPU 코어 수 * 2) + 유효 스핀들 수

하지만 실무에서는:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20  # 적당히 시작
      minimum-idle: 5
      connection-timeout: 3000  # 3초 (빨리 실패하는 게 낫다)

주의: 커넥션 풀을 무작정 늘리면 안 됩니다!

  • DB도 동시 커넥션 한계가 있음
  • 컨텍스트 스위칭 오버헤드 증가

정리: @Transactional 사용 체크리스트

체크 항목 확인
트랜잭션 범위가 최소한인가?
외부 API 호출이 트랜잭션 안에 있지 않은가?
대용량 처리는 배치/페이징으로 분리했는가?
조회 메소드에 readOnly = true를 적용했는가?
N+1 쿼리 문제를 확인했는가?
커넥션 풀 모니터링을 설정했는가?

면접에서 이렇게 답하자

“@Transactional은 시작부터 종료까지 DB 커넥션을 점유합니다. 커넥션 풀 크기가 제한되어 있기 때문에, 트랜잭션이 길어지면 커넥션 고갈로 인한 병목이 발생할 수 있습니다. 특히 외부 API 호출이나 대용량 처리를 트랜잭션 안에서 하면 위험합니다. 트랜잭션 범위를 최소화하고, 조회에는 readOnly 옵션을 사용하며, HikariCP 메트릭으로 커넥션 상태를 모니터링하는 것이 중요합니다.”


마치며

@Transactional은 편리하지만, 아무 생각 없이 붙이면 운영 환경에서 큰 장애로 이어질 수 있습니다.

“트랜잭션 = 커넥션 점유”라는 사실을 항상 기억하고, 트랜잭션 범위를 최소화하는 습관을 들이는 게 중요합니다.

다음 포스팅에서는 분산 트랜잭션이나 SAGA 패턴 같은 MSA 환경에서의 트랜잭션 관리를 다뤄볼까 합니다.