여는 글

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


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

왜 @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() → 커넥션 반환 → 프록시 종료

여러 DB 작업도 하나의 커넥션

트랜잭션 내에서 여러 Repository를 호출해도 같은 커넥션을 사용합니다.

@Transactional
public void complexOperation() {
    userRepository.save(user);       // 커넥션 A 사용
    orderRepository.save(order);     // 커넥션 A 사용 (동일)
    paymentRepository.save(payment); // 커넥션 A 사용 (동일)
}
// 트랜잭션 종료 → 커넥션 A 반환

왜 가능할까요? ThreadLocal 덕분입니다.

Thread-1: @Transactional 시작
    ↓
ThreadLocal에 Connection 저장
    ↓
userRepository.save()    → ThreadLocal에서 Connection 꺼냄
orderRepository.save()   → ThreadLocal에서 같은 Connection 꺼냄
paymentRepository.save() → ThreadLocal에서 같은 Connection 꺼냄
    ↓
@Transactional 종료 → commit → Connection 반환

이게 트랜잭션 원자성이 보장되는 이유입니다. 같은 커넥션이어야 하나의 트랜잭션으로 묶이고, 중간에 실패하면 전체 롤백이 가능합니다.

문제는 커넥션 풀(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초 대기 후 타임아웃 예외

해결책: 상태 기반 처리

트랜잭션을 분리하면 데이터 일관성 문제가 생길 수 있습니다.

  • 주문 저장 성공 → 외부 API 성공 → 상태 업데이트 실패
  • 주문은 있는데 상태가 안 바뀜 → 불일치!

이를 해결하려면 상태 기반 처리가 필요합니다.

public void processOrder(Order order) {
    // 1. PENDING 상태로 먼저 저장 (트랜잭션 1)
    Long orderId = orderService.createPendingOrder(order);

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

        // 3. 결과에 따라 상태 업데이트 (트랜잭션 2)
        if (result.isSuccess()) {
            orderService.confirmOrder(orderId);
        } else {
            orderService.failOrder(orderId, result.getErrorMessage());
        }

    } catch (Exception e) {
        // 4. 외부 API 실패 시에도 상태 업데이트
        orderService.failOrder(orderId, e.getMessage());
        throw e;
    }
}

@Service
public class OrderService {

    @Transactional
    public Long createPendingOrder(Order order) {
        order.setStatus(PENDING);  // 대기 상태로 저장
        orderRepository.save(order);
        return order.getId();
    }

    @Transactional
    public void confirmOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.setStatus(PAID);
        orderRepository.save(order);
    }

    @Transactional
    public void failOrder(Long orderId, String reason) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.setStatus(FAILED);
        order.setFailureReason(reason);
        orderRepository.save(order);
    }
}

그래도 실패하면?

confirmOrder()failOrder() 자체가 실패할 수 있습니다. 이 경우 PENDING 상태 주문이 남게 됩니다.

복구 스케줄러로 처리합니다:

@Scheduled(fixedDelay = 60000)  // 1분마다
public void recoverPendingOrders() {
    // 10분 이상 PENDING 상태인 주문 조회
    List<Order> stuckOrders = orderRepository.findByStatusAndCreatedAtBefore(
        PENDING, LocalDateTime.now().minusMinutes(10)
    );

    for (Order order : stuckOrders) {
        // 결제 상태 확인 후 재처리
        PaymentStatus status = paymentClient.checkPaymentStatus(order.getId());
        if (status == SUCCESS) {
            orderService.confirmOrder(order.getId());
        } else {
            orderService.failOrder(order.getId(), "복구 처리: 결제 확인 실패");
        }
    }
}

핵심:

  • 외부 호출은 트랜잭션 밖으로 빼되
  • 상태 기반으로 처리하고
  • 복구 스케줄러로 누락 건을 잡아야 합니다

병목 시나리오 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 환경에서의 트랜잭션 관리를 다뤄볼까 합니다.