Java 트랜잭션 장애가 발생한 실제 문제 상황
Java 기반의 주문 처리 서비스에서 발생한 문제였습니다. 사용자가 결제를 완료하면 서버는 주문 상태를 변경하고, 결제 승인 결과를 저장한 뒤, 외부 정산 API를 호출하는 흐름을 가지고 있었습니다.
처음 코드는 보기에는 단순했습니다. 하나의 서비스 메서드에 @Transactional을 붙이고, 그 안에서 주문 조회, 상태 변경, 결제 결과 저장, 외부 API 호출, 후속 이력 저장까지 모두 처리했습니다. 흐름만 보면 한 번에 처리되니 안전해 보입니다.
하지만 이 구조의 문제는 DB 트랜잭션이 너무 오래 유지된다는 점이었습니다. 특히 외부 API 응답이 늦어지는 순간, DB 커넥션과 row lock이 함께 오래 붙잡히면서 다른 요청까지 영향을 받기 시작했습니다.
@Service
public class OrderService {
@Transactional
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.complete();
paymentHistoryRepository.save(
PaymentHistory.success(orderId)
);
externalSettlementClient.send(orderId);
orderEventRepository.save(
OrderEvent.completed(orderId)
);
}
}
이 코드는 문법적으로 틀린 코드는 아닙니다. 문제는 트랜잭션 안에서 반드시 함께 묶여야 할 작업과 그렇지 않은 작업이 구분되지 않았다는 데 있습니다.
Java 트랜잭션 범위가 넓을 때 나타난 증상
Java 트랜잭션 문제가 무서운 이유는 처음부터 명확한 에러로 드러나지 않는 경우가 많기 때문입니다. 단순히 API가 조금 느려진 것처럼 보이다가, 어느 순간 DB 커넥션 부족이나 lock wait으로 이어집니다.
당시 확인된 증상은 대략 이런 흐름이었습니다. 주문 완료 API의 응답 시간이 길어졌고, 같은 주문 테이블을 수정하는 다른 API에서도 대기 시간이 늘어났습니다. 이후에는 커넥션 풀 사용량이 빠르게 올라가면서 일부 요청이 타임아웃으로 실패했습니다.
SQLTransientConnectionException:
HikariPool-1 - Connection is not available, request timed out after 30000ms.
Lock wait timeout exceeded; try restarting transaction
처음에는 DB 부하나 인덱스 문제를 의심하기 쉽습니다. 물론 그런 가능성도 확인해야 합니다. 하지만 이 경우에는 느린 쿼리보다 트랜잭션 유지 시간이 더 큰 문제였습니다.
겉으로는 외부 API 장애처럼 보였습니다
외부 정산 API의 응답이 느려진 것이 시작점이었습니다. 그래서 처음에는 외부 연동 장애로만 판단할 수도 있었습니다. 하지만 내부 시스템에서 더 심각했던 부분은, 외부 API를 기다리는 동안 DB 트랜잭션이 계속 열린 상태였다는 점입니다.
외부 API 호출 자체는 언제든 느려질 수 있습니다. 네트워크 지연, 상대 서버의 일시적인 응답 지연, 재시도 로직 등 여러 이유가 있습니다. 그렇기 때문에 외부 호출을 DB 트랜잭션 안에 넣을 때는 신중해야 합니다.
트랜잭션 분리가 안 된 코드의 근본 원인
트랜잭션은 관련 데이터를 일관성 있게 변경하기 위해 사용합니다. Java Spring 환경에서는 @Transactional 하나로 쉽게 적용할 수 있어 편하지만, 그만큼 범위를 넓게 잡는 실수도 자주 나옵니다.
이 사례의 원인은 단순히 @Transactional을 사용했다는 점이 아닙니다. 문제는 트랜잭션 안에 DB 작업과 외부 시스템 호출이 함께 들어가 있었다는 점입니다.
@Transactional
public void process() {
updateDatabase();
callExternalApi();
insertHistory();
}
위 구조에서 callExternalApi()가 3초, 5초, 10초씩 지연되면 그 시간 동안 DB 트랜잭션도 같이 유지됩니다. DB 커넥션도 반환되지 않고, 수정한 데이터에 대한 lock도 오래 유지될 수 있습니다.
트랜잭션은 짧을수록 좋다는 말을 많이 합니다. 여기서 말하는 짧다는 것은 단순히 코드 줄 수가 적다는 뜻이 아니라, DB 리소스를 점유하는 시간이 짧아야 한다는 의미입니다.
비즈니스 흐름과 트랜잭션 범위는 같지 않습니다
주문 완료라는 비즈니스 흐름은 하나일 수 있습니다. 하지만 그 안의 모든 작업이 반드시 하나의 DB 트랜잭션으로 묶여야 하는 것은 아닙니다.
주문 상태 변경과 결제 이력 저장은 같은 트랜잭션으로 묶을 수 있습니다. 둘 중 하나만 성공하면 데이터가 어색해질 수 있기 때문입니다. 반면 외부 정산 API 호출이나 알림 발송은 별도의 후속 처리로 분리하는 편이 안전한 경우가 많습니다.
이 차이를 구분하지 않으면 코드상으로는 깔끔해 보이지만, 장애가 발생했을 때 영향 범위가 커집니다. 협업할 때도 “이 메서드는 주문 완료 전체를 처리한다”는 설명만으로는 부족하고, 어디까지가 원자적으로 묶여야 하는지 명확히 합의해야 합니다.
Java 트랜잭션 분리를 위한 대안 비교
Java 트랜잭션 문제를 해결할 때는 단순히 @Transactional을 제거하는 방식으로 접근하면 안 됩니다. 어떤 작업을 같은 트랜잭션으로 묶고, 어떤 작업을 분리할지 기준을 세우는 것이 먼저입니다.
대안 1. 외부 API 호출을 트랜잭션 밖으로 이동
가장 먼저 검토할 수 있는 방식은 DB 변경을 먼저 커밋하고, 그 이후 외부 API를 호출하는 구조입니다. 구현이 비교적 단순하고, 트랜잭션 유지 시간을 줄일 수 있습니다.
@Service
public class OrderFacade {
private final OrderTransactionService orderTransactionService;
private final ExternalSettlementClient externalSettlementClient;
public void completeOrder(Long orderId) {
orderTransactionService.complete(orderId);
externalSettlementClient.send(orderId);
}
}
@Service
public class OrderTransactionService {
@Transactional
public void complete(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.complete();
paymentHistoryRepository.save(
PaymentHistory.success(orderId)
);
}
}
이 방식은 주문 완료 데이터 저장과 외부 API 호출을 분리합니다. 외부 API가 느려져도 DB 트랜잭션은 이미 종료된 상태이기 때문에 커넥션과 lock을 오래 붙잡지 않습니다.
다만 외부 API 호출이 실패했을 때 어떻게 보상 처리할지 별도로 설계해야 합니다. 단순히 트랜잭션 밖으로 빼는 것만으로 모든 문제가 해결되는 것은 아닙니다.
대안 2. 이벤트 테이블에 저장하고 비동기로 처리
조금 더 안정적으로 가져가려면 외부 연동 요청 자체를 이벤트로 저장하고, 별도 워커나 스케줄러가 처리하는 구조를 사용할 수 있습니다. 주문 트랜잭션 안에서는 “정산 요청이 필요하다”는 사실만 기록합니다.
@Transactional
public void complete(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.complete();
paymentHistoryRepository.save(
PaymentHistory.success(orderId)
);
settlementEventRepository.save(
SettlementEvent.ready(orderId)
);
}
이후 별도 처리기가 READY 상태의 이벤트를 읽고 외부 정산 API를 호출합니다. 성공하면 SUCCESS, 실패하면 FAILED 또는 RETRY 상태로 변경할 수 있습니다.
public void sendSettlement() {
List<SettlementEvent> events =
settlementEventRepository.findReadyEvents();
for (SettlementEvent event : events) {
try {
externalSettlementClient.send(event.getOrderId());
event.markSuccess();
} catch (Exception e) {
event.markRetry();
}
}
}
이 방식은 구현할 내용이 조금 늘어납니다. 대신 실패한 외부 연동을 추적하기 쉽고, 재처리 기준도 명확해집니다. 결제나 주문처럼 후속 처리 이력이 중요한 도메인에서는 이런 구조가 유지보수에 더 유리한 경우가 많습니다.
대안 3. REQUIRES_NEW로 트랜잭션을 나누는 방식
Spring에서는 Propagation.REQUIRES_NEW를 사용해 별도 트랜잭션을 만들 수 있습니다. 예를 들어 메인 트랜잭션과 이력 저장 트랜잭션을 분리하고 싶을 때 사용할 수 있습니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(Long orderId, String message) {
orderLogRepository.save(
OrderLog.of(orderId, message)
);
}
다만 이 방식은 트랜잭션 분리의 목적을 정확히 알고 써야 합니다. 외부 API 호출 문제를 해결하려고 무조건 REQUIRES_NEW를 붙이는 것은 좋은 접근이 아닙니다.
특히 자기 자신 내부 메서드 호출에서는 Spring AOP 프록시가 적용되지 않아 기대한 대로 트랜잭션이 분리되지 않을 수 있습니다. 이 부분은 Java Spring 트랜잭션에서 자주 놓치는 지점입니다.
@Service
public class OrderService {
@Transactional
public void completeOrder(Long orderId) {
saveLog(orderId); // 같은 클래스 내부 호출
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(Long orderId) {
orderLogRepository.save(OrderLog.of(orderId));
}
}
위 코드는 의도와 다르게 동작할 수 있습니다. 같은 클래스 내부에서 직접 메서드를 호출하면 프록시를 거치지 않기 때문입니다. 트랜잭션 분리를 의도했다면 별도 Bean으로 분리하는 편이 더 명확합니다.
트랜잭션 장애를 줄이기 위해 적용한 해결 방법
Java 트랜잭션 장애를 줄이기 위해 최종적으로 선택한 방향은 외부 API 호출을 DB 트랜잭션에서 분리하고, 후속 처리는 이벤트 기반으로 남기는 방식이었습니다. 주문 완료 자체와 외부 정산 요청을 같은 트랜잭션에 묶지 않았습니다.
구조는 크게 세 단계로 나눴습니다. 먼저 주문 상태와 결제 이력을 저장합니다. 다음으로 정산 요청 이벤트를 같은 트랜잭션 안에 기록합니다. 마지막으로 별도 처리기가 이벤트를 읽어 외부 정산 API를 호출합니다.
@Transactional
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.complete();
paymentHistoryRepository.save(
PaymentHistory.success(orderId)
);
settlementEventRepository.save(
SettlementEvent.ready(orderId)
);
}
이렇게 하면 주문 완료와 정산 요청 이벤트 저장은 원자적으로 처리됩니다. 주문은 완료됐는데 정산 요청 이벤트가 없는 상태를 줄일 수 있습니다.
외부 API 호출은 트랜잭션 밖에서 처리합니다. 실패 시에는 이벤트 상태를 바꾸고, 재시도 대상에 포함시킵니다. 이때 중요한 점은 외부 API가 멱등성을 지원하는지 확인하는 것입니다.
멱등성 키를 함께 설계했습니다
외부 API 재시도를 허용하려면 같은 요청이 여러 번 나가도 중복 처리되지 않도록 설계해야 합니다. 결제, 주문, 정산 같은 영역에서는 이 부분이 특히 중요합니다.
예를 들어 orderId나 별도의 settlementRequestId를 멱등성 키로 사용할 수 있습니다. 외부 시스템이 같은 키를 받은 경우 이미 처리된 요청으로 판단하게 만들면, 재시도 로직을 더 안전하게 가져갈 수 있습니다.
public void send(SettlementEvent event) {
SettlementRequest request = new SettlementRequest(
event.getSettlementRequestId(),
event.getOrderId(),
event.getAmount()
);
externalSettlementClient.send(request);
}
트랜잭션을 분리하면 실패 처리가 밖으로 밀려납니다. 그래서 분리 자체보다 중요한 것은 실패를 추적하고 재처리할 수 있는 구조입니다. 이 부분이 빠지면 단순히 장애 형태만 바뀔 수 있습니다.
적용 후 달라진 점
Java 트랜잭션 범위를 줄인 뒤 가장 먼저 달라진 부분은 DB 커넥션 점유 시간이었습니다. 외부 API 응답이 늦어져도 주문 저장 트랜잭션은 빠르게 종료됐고, 커넥션 풀이 불필요하게 고갈되는 상황을 줄일 수 있었습니다.
또 하나의 변화는 장애 대응 방식입니다. 이전에는 주문 처리 중간에서 외부 API가 실패하면 전체 흐름이 애매하게 멈췄습니다. 개선 후에는 주문 완료와 정산 요청 상태를 분리해서 볼 수 있었기 때문에, 실패한 후속 작업만 골라 재처리할 수 있었습니다.
운영 관점에서도 확인 지점이 명확해졌습니다. 주문 데이터가 정상인지, 정산 이벤트가 생성됐는지, 외부 전송이 성공했는지를 각각 확인할 수 있게 됐습니다. 하나의 큰 메서드 안에 모든 처리가 들어 있을 때보다 문제를 나눠서 보기 쉬워졌습니다.
Java 트랜잭션 설계 시 자주 하는 실수
Java 트랜잭션을 사용할 때 자주 하는 실수는 @Transactional을 비즈니스 메서드 전체에 습관적으로 붙이는 것입니다. 처음에는 편하지만, 메서드가 커질수록 트랜잭션 범위도 함께 커집니다.
외부 API 호출을 트랜잭션 안에 넣는 실수
외부 API 호출은 응답 시간을 예측하기 어렵습니다. 상대 시스템의 상태, 네트워크 상황, 타임아웃 설정에 따라 지연될 수 있습니다. 이런 작업을 DB 트랜잭션 안에 넣으면 내부 리소스까지 같이 묶입니다.
트랜잭션 안에는 가능하면 DB 일관성에 직접 필요한 작업만 두는 편이 좋습니다. 외부 호출, 알림 발송, 파일 업로드, 메시지 전송 같은 작업은 분리 가능성을 먼저 검토해야 합니다.
조회 로직까지 불필요하게 트랜잭션으로 묶는 실수
단순 조회 메서드에 무조건 @Transactional을 붙이는 경우도 있습니다. 조회 일관성이 필요하거나 지연 로딩을 다뤄야 하는 상황에서는 의미가 있을 수 있습니다. 하지만 모든 조회에 같은 기준을 적용할 필요는 없습니다.
조회 전용이라면 @Transactional(readOnly = true)를 고려할 수 있습니다. 다만 이것도 습관적으로 붙이기보다, 해당 메서드가 어떤 영속성 컨텍스트 범위를 필요로 하는지 보고 판단하는 편이 좋습니다.
@Transactional(readOnly = true)
public OrderDetailResponse getOrderDetail(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
return OrderDetailResponse.from(order);
}
트랜잭션 전파 옵션으로 모든 문제를 해결하려는 실수
REQUIRES_NEW, NESTED 같은 전파 옵션은 분명 유용합니다. 하지만 트랜잭션 범위 설계가 정리되지 않은 상태에서 전파 옵션만 추가하면 흐름이 더 복잡해질 수 있습니다.
특히 팀 단위로 유지보수하는 코드에서는 트랜잭션 전파 옵션이 많아질수록 실패 시 데이터 상태를 예측하기 어려워집니다. 옵션을 쓰기 전에 먼저 메서드 책임을 나누고, 어떤 데이터가 함께 커밋되어야 하는지 정리하는 것이 우선입니다.
Java 트랜잭션 분리 기준 정리
Java 트랜잭션을 분리할 때는 “같이 성공하거나 같이 실패해야 하는가”를 먼저 봐야 합니다. 이 질문에 명확히 그렇다고 답할 수 있는 작업만 같은 트랜잭션에 두는 편이 안전합니다.
반대로 외부 시스템 호출, 알림, 통계 적재, 검색 색인 반영처럼 나중에 재처리해도 되는 작업은 분리 대상이 될 수 있습니다. 물론 도메인에 따라 기준은 달라질 수 있습니다. 중요한 것은 편의상 한 메서드에 넣는 것이 아니라, 실패했을 때 어떤 상태까지 허용할지 먼저 정하는 것입니다.
| 작업 유형 | 같은 트랜잭션 권장 여부 | 판단 기준 |
|---|---|---|
| 주문 상태 변경 | 권장 | 핵심 도메인 상태 변경 |
| 결제 성공 이력 저장 | 권장 | 주문 상태와 함께 일관성 필요 |
| 외부 정산 API 호출 | 분리 권장 | 응답 지연과 실패 가능성 존재 |
| 알림 발송 | 분리 권장 | 실패해도 재처리 가능 |
| 이벤트 발행 이력 저장 | 상황에 따라 권장 | 후속 처리 보장을 위해 필요 |
이 기준을 정리해두면 코드 리뷰에서도 이야기가 쉬워집니다. “왜 여기서 트랜잭션을 끊었는지”, “왜 이 작업은 같은 트랜잭션이어야 하는지”를 설명할 수 있기 때문입니다.
마무리: 트랜잭션은 넓게 묶는 것이 안전한 게 아닙니다
Java에서 트랜잭션은 데이터 정합성을 지키기 위한 중요한 장치입니다. 하지만 모든 작업을 하나의 트랜잭션에 넣는다고 더 안전해지는 것은 아닙니다.
오히려 트랜잭션 범위가 넓어질수록 DB 커넥션 점유 시간, lock 유지 시간, 실패 영향 범위가 함께 커질 수 있습니다. 특히 외부 API 호출처럼 예측하기 어려운 작업이 들어가면 장애가 내부 시스템 전체로 번지기 쉽습니다.
실무에서는 트랜잭션을 적용할지 말지보다, 어디서 시작해서 어디서 끝낼지를 더 중요하게 봐야 합니다. 같은 트랜잭션에 들어갈 작업은 데이터 일관성 기준으로 묶고, 재처리 가능한 후속 작업은 분리하는 편이 유지보수와 장애 대응에 유리합니다.
정리하면 Java 트랜잭션 설계의 기준은 단순합니다. 함께 커밋되어야 하는 작업만 묶고, 느리거나 실패할 수 있는 외부 작업은 가능한 한 분리합니다. 이 기준만 지켜도 트랜잭션 때문에 발생하는 많은 문제를 줄일 수 있습니다.
'개발 > JAVA' 카테고리의 다른 글
| [JAVA] Java SSLHandshakeException 해결 경험: HTTP 네트워크 통신에서 인증서 문제를 추적하는 방법 (0) | 2026.06.03 |
|---|---|
| [JAVA] Java Connection reset 에러 발생 원인과 해결 과정 (0) | 2026.05.30 |
| [JAVA] Hibernate flush 타이밍 때문에 발생한 문제와 실무에서 확인하는 방법 (0) | 2026.05.23 |
| [JAVA] Lock wait timeout exceeded 에러가 발생하는 원인과 해결 방법 (0) | 2026.05.22 |
| [JAVA] Java DB Connection Pool 부족으로 장애가 났을 때 원인 분석과 해결 방법 (0) | 2026.05.21 |
