Java Lock wait timeout exceeded 에러는 어떤 상황에서 발생할까?
Java Lock wait timeout exceeded 에러는 보통 Java 애플리케이션에서 MySQL InnoDB 테이블을 수정하는 도중 발생합니다. 대표적으로 Spring Boot, JPA, MyBatis 환경에서 INSERT, UPDATE, DELETE를 수행할 때 많이 보입니다.
에러 메시지는 보통 다음과 비슷합니다.
java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
Lock wait timeout exceeded; try restarting transaction
이 메시지만 보면 데이터베이스가 멈춘 것처럼 느껴질 수 있지만, 실제 의미는 조금 다릅니다. 내 트랜잭션이 어떤 row를 수정하려고 했는데, 다른 트랜잭션이 이미 그 row나 관련 인덱스 범위에 락을 잡고 있어서 기다리다가 제한 시간을 넘긴 것입니다.
MySQL InnoDB에서는 하나의 트랜잭션이 데이터를 변경하면, 그 변경이 커밋되거나 롤백되기 전까지 다른 트랜잭션이 같은 데이터를 마음대로 변경할 수 없습니다. 이 동작 자체는 정상입니다. 문제는 락을 잡은 트랜잭션이 너무 오래 끝나지 않을 때 발생합니다.
Lock wait timeout exceeded 원인 이해하기
Lock wait timeout exceeded 원인은 대부분 “누가 락을 잡고 오래 버티고 있는가”로 정리할 수 있습니다. 그래서 이 문제를 볼 때는 실패한 쿼리만 보면 부족합니다. 실패한 쿼리는 피해자일 수 있고, 실제 원인은 먼저 실행된 다른 트랜잭션일 수 있습니다.
1. 트랜잭션이 너무 오래 유지되는 경우
가장 흔한 원인은 긴 트랜잭션입니다. Java 코드에서 @Transactional 범위 안에 DB 수정뿐 아니라 외부 API 호출, 파일 처리, 복잡한 계산, 메시지 발행 같은 작업이 함께 들어가면 락을 오래 잡을 수 있습니다.
@Transactional
public void updateOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.changeStatus(OrderStatus.PAID);
externalPaymentClient.confirm(orderId); // 외부 API 호출
orderRepository.save(order);
}
위 코드는 보기에는 자연스럽지만, 외부 API 호출이 느려지면 트랜잭션이 길어집니다. 그동안 해당 주문 row에 대한 락이 유지될 수 있습니다. 실무에서는 이런 코드가 Lock wait timeout exceeded의 출발점이 되는 경우가 많습니다.
이 경우에는 트랜잭션 안에서 꼭 필요한 DB 작업만 수행하도록 범위를 줄이는 편이 낫습니다.
public void confirmPayment(Long orderId) {
externalPaymentClient.confirm(orderId);
updateOrderStatus(orderId);
}
@Transactional
public void updateOrderStatus(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.changeStatus(OrderStatus.PAID);
}
물론 결제 같은 도메인에서는 외부 API와 DB 상태의 정합성을 함께 고민해야 합니다. 단순히 트랜잭션 밖으로 빼는 것만으로 끝나는 문제는 아닙니다. 다만 락 관점에서는 트랜잭션 범위를 짧게 유지하는 것이 기본 방향입니다.
2. 같은 데이터를 여러 요청이 동시에 수정하는 경우
동일한 회원, 주문, 쿠폰, 재고 데이터를 여러 요청이 동시에 수정하면 락 경합이 발생할 수 있습니다. 특히 상태 변경 API가 중복 호출되거나, 배치와 실시간 API가 같은 테이블을 함께 수정하는 구조에서 자주 보입니다.
UPDATE order
SET status = 'PAID'
WHERE order_id = 1001;
이 쿼리가 동시에 여러 번 실행되면 하나의 트랜잭션이 먼저 락을 잡고, 나머지 트랜잭션은 기다립니다. 먼저 잡은 트랜잭션이 빨리 끝나면 문제가 없지만, 커밋이 늦어지면 대기하던 쿼리 중 일부가 타임아웃됩니다.
이런 경우에는 API 멱등성, 중복 요청 방지, 상태 전이 조건을 함께 봐야 합니다. 단순히 DB 설정값만 늘리면 겉으로는 에러가 줄어든 것처럼 보여도, 내부적으로는 대기 시간이 길어지는 문제가 남을 수 있습니다.
3. 인덱스가 없어 불필요하게 많은 row를 잠그는 경우
UPDATE나 DELETE 조건에 적절한 인덱스가 없으면 MySQL이 많은 row를 스캔하면서 락 범위가 커질 수 있습니다. 이 부분은 단순 성능 문제가 아니라 락 범위 문제로 이어질 수 있어 주의해야 합니다.
UPDATE payment
SET status = 'EXPIRED'
WHERE payment_status = 'READY'
AND expired_at < NOW();
위 쿼리에서 payment_status, expired_at 조건을 자주 사용한다면 인덱스가 필요할 수 있습니다. 인덱스가 없으면 대상 row를 찾기 위해 더 넓은 범위를 훑게 되고, 그 과정에서 다른 트랜잭션과 충돌할 가능성도 커집니다.
EXPLAIN
UPDATE payment
SET status = 'EXPIRED'
WHERE payment_status = 'READY'
AND expired_at < NOW();
실제로는 UPDATE 문에 대해 실행 계획을 바로 확인하기 어려운 환경도 있으므로, 같은 WHERE 조건을 가진 SELECT로 먼저 실행 계획을 보는 방식도 많이 사용합니다.
EXPLAIN
SELECT id
FROM payment
WHERE payment_status = 'READY'
AND expired_at < NOW();
4. 여러 테이블을 서로 다른 순서로 수정하는 경우
서비스 코드가 여러 테이블을 수정할 때 순서가 일정하지 않으면 락 대기가 길어질 수 있습니다. 데드락처럼 바로 감지되는 경우도 있지만, 상황에 따라 Lock wait timeout 형태로 보이기도 합니다.
// A 서비스
@Transactional
public void processA() {
updateOrder();
updatePayment();
}
// B 서비스
@Transactional
public void processB() {
updatePayment();
updateOrder();
}
두 코드가 같은 주문과 결제 데이터를 대상으로 동시에 실행되면 서로가 필요한 락을 기다리는 상황이 생길 수 있습니다. 이런 코드는 기능 단위로 보면 문제가 없어 보이지만, 동시에 실행될 때 취약해집니다.
팀에서 정리할 때는 “주문 관련 처리에서는 order를 먼저 수정하고 payment를 나중에 수정한다”처럼 테이블 접근 순서를 맞춰두는 것이 도움이 됩니다. 규칙이 단순해야 리뷰 단계에서도 발견하기 쉽습니다.
Lock wait timeout exceeded 확인 방법
Lock wait timeout exceeded 확인 방법은 애플리케이션 로그와 DB 상태를 함께 보는 것이 좋습니다. Java 로그에는 실패한 쿼리와 예외만 남는 경우가 많아서, 실제로 어떤 트랜잭션이 락을 잡고 있었는지는 DB에서 확인해야 합니다.
MySQL InnoDB 상태 확인
가장 먼저 확인할 수 있는 명령은 다음입니다.
SHOW ENGINE INNODB STATUS\G
여기서는 최근 데드락 정보, 트랜잭션 상태, 락 대기 정보를 확인할 수 있습니다. 출력이 길기 때문에 처음에는 부담스럽지만, TRANSACTIONS 영역을 중심으로 보면 됩니다.
현재 실행 중인 트랜잭션 확인
MySQL 환경에 따라 다음 테이블에서 현재 트랜잭션을 확인할 수 있습니다.
SELECT *
FROM information_schema.INNODB_TRX;
여기서 오래 실행 중인 트랜잭션이 있는지 확인합니다. 특히 trx_started, trx_query, trx_state 값을 보면 어떤 트랜잭션이 오래 살아 있는지 파악하는 데 도움이 됩니다.
MySQL 8에서 락 대기 관계 확인
MySQL 8 환경이라면 performance_schema를 통해 락 대기 관계를 더 구체적으로 볼 수 있습니다.
SELECT *
FROM performance_schema.data_lock_waits;
추가로 어떤 락이 걸려 있는지 보려면 다음 테이블도 함께 확인합니다.
SELECT *
FROM performance_schema.data_locks;
운영 환경에서는 이 결과를 그대로 보기보다, 기다리는 트랜잭션과 막고 있는 트랜잭션을 조인해서 보는 쿼리를 만들어두는 편이 좋습니다. 매번 장애 상황에서 긴 쿼리를 새로 작성하면 실수하기 쉽습니다.
Java에서 Lock wait timeout exceeded 해결 방법
Java에서 Lock wait timeout exceeded 해결 방법은 크게 트랜잭션 범위 축소, 쿼리 개선, 락 순서 정리, 재시도 처리로 나눌 수 있습니다. 이 중에서 가장 먼저 볼 것은 트랜잭션 범위입니다.
1. @Transactional 범위를 줄입니다
Spring에서 @Transactional은 편리하지만, 범위를 크게 잡으면 그만큼 락 유지 시간도 길어질 수 있습니다. 특히 서비스 메서드 전체에 습관적으로 붙여둔 경우에는 내부에서 어떤 작업이 함께 묶이는지 다시 봐야 합니다.
@Transactional
public void process(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
sendEmail(user); // 트랜잭션 안에서 할 필요가 낮음
callExternalApi(user); // 대기 시간이 길어질 수 있음
user.updateLastProcessedAt();
}
이런 코드는 다음처럼 DB 변경 구간만 분리하는 방식으로 개선할 수 있습니다.
public void process(Long userId) {
callExternalApi(userId);
updateProcessedAt(userId);
sendEmail(userId);
}
@Transactional
public void updateProcessedAt(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
user.updateLastProcessedAt();
}
단, 트랜잭션을 나누면 실패 처리 기준도 함께 바뀝니다. 외부 API는 성공했는데 DB 업데이트가 실패하는 상황, DB는 성공했는데 메일 발송이 실패하는 상황을 어떻게 처리할지 정해야 합니다.
2. UPDATE 조건에 맞는 인덱스를 확인합니다
락 문제를 볼 때 실행 계획 확인은 거의 필수에 가깝습니다. WHERE 조건이 인덱스를 타지 못하면 예상보다 많은 row를 건드릴 수 있고, 그만큼 락 충돌 가능성도 높아집니다.
SELECT id
FROM coupon
WHERE user_id = 10
AND coupon_status = 'AVAILABLE'
AND expired_at > NOW();
위 조건으로 쿠폰을 자주 조회하거나 수정한다면, 인덱스 후보를 검토할 수 있습니다.
CREATE INDEX idx_coupon_user_status_expired
ON coupon (user_id, coupon_status, expired_at);
다만 인덱스는 많을수록 무조건 좋은 것이 아닙니다. INSERT, UPDATE 비용이 늘고, 인덱스 관리 부담도 생깁니다. 실제 쿼리 빈도와 조건 조합을 보고 필요한 인덱스만 추가하는 편이 안전합니다.
3. 한 번에 너무 많은 데이터를 수정하지 않습니다
배치나 관리자 기능에서 많은 데이터를 한 번에 UPDATE하면 락 유지 시간이 길어질 수 있습니다. 이때는 작업을 작은 단위로 나누는 방식이 도움이 됩니다.
UPDATE point
SET expired = true
WHERE expired = false
AND expired_at < NOW();
위 쿼리가 많은 row를 한 번에 수정한다면, ID 범위나 LIMIT 기반으로 나누는 방식을 고려할 수 있습니다.
UPDATE point
SET expired = true
WHERE id BETWEEN 100000 AND 110000
AND expired = false
AND expired_at < NOW();
작게 나누면 전체 작업 시간은 조금 길어질 수 있지만, 하나의 트랜잭션이 락을 오래 잡는 상황을 줄일 수 있습니다. 운영 중 실행되는 배치라면 이 차이가 더 중요해집니다.
4. 테이블 접근 순서를 맞춥니다
여러 서비스 로직이 같은 테이블들을 수정한다면 접근 순서를 통일하는 것이 좋습니다. 예를 들어 주문과 결제를 함께 수정하는 경우, 팀 내부에서 순서를 명확히 정해두는 방식입니다.
// 권장: 같은 도메인에서는 접근 순서를 통일
@Transactional
public void processPayment(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
Payment payment = paymentRepository.findByOrderId(orderId).orElseThrow();
order.markPaid();
payment.complete();
}
순서 통일은 코드만 봐서는 사소해 보일 수 있습니다. 하지만 동시에 여러 요청이 들어오는 서비스에서는 이런 규칙이 락 경합을 줄이는 데 도움이 됩니다. 특히 결제, 재고, 쿠폰처럼 같은 row를 여러 흐름에서 만지는 도메인에서는 더 신경 써야 합니다.
5. 필요한 경우 재시도 로직을 넣습니다
Lock wait timeout exceeded는 일시적인 락 경합으로 발생할 수 있습니다. 그래서 일부 작업은 재시도로 복구할 수 있습니다. 다만 모든 작업에 무조건 재시도를 넣으면 중복 처리 문제가 생길 수 있습니다.
재시도는 멱등성이 보장되는 작업에 제한적으로 적용하는 편이 좋습니다.
for (int i = 0; i < 3; i++) {
try {
orderService.updateStatus(orderId);
break;
} catch (CannotAcquireLockException e) {
if (i == 2) {
throw e;
}
Thread.sleep(200L);
}
}
위 코드는 개념을 보여주기 위한 단순 예시입니다. 실제 서비스에서는 Spring Retry를 사용하거나, 예외 타입을 더 명확히 분리하고, 재시도 간격도 고정값이 아니라 점진적으로 늘리는 방식을 검토할 수 있습니다.
중요한 점은 재시도가 원인 해결은 아니라는 것입니다. 락을 오래 잡는 코드가 그대로 남아 있다면 재시도는 증상을 늦게 드러나게 만들 뿐입니다.
innodb_lock_wait_timeout 값을 늘리면 해결될까?
innodb_lock_wait_timeout 값을 늘리면 Lock wait timeout exceeded 에러가 줄어드는 것처럼 보일 수 있습니다. 하지만 이것은 대기 시간을 늘리는 설정이지, 락 경합 자체를 없애는 설정은 아닙니다.
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
필요하다면 세션 단위로 값을 조정할 수 있습니다.
SET innodb_lock_wait_timeout = 10;
운영 설정으로 값을 변경할 수도 있지만, 신중하게 봐야 합니다. 값을 너무 크게 잡으면 실패가 빨리 드러나지 않고, 애플리케이션 요청이 오래 대기하면서 커넥션을 점유할 수 있습니다.
그래서 이 설정은 근본 해결책이라기보다 보조 수단으로 보는 편이 맞습니다. 먼저 오래 열린 트랜잭션, 인덱스 누락, 대량 UPDATE, 접근 순서 문제를 확인한 뒤에 조정 여부를 판단하는 것이 좋습니다.
Spring Boot와 JPA에서 자주 놓치는 부분
Spring Boot JPA Lock wait timeout exceeded 문제는 코드상으로 락을 직접 사용하지 않았는데도 발생할 수 있습니다. JPA는 변경 감지와 flush 시점이 있기 때문에, 실제 UPDATE가 언제 DB로 나가는지 헷갈릴 수 있습니다.
flush 시점이 예상과 다를 수 있습니다
JPA에서는 엔티티 값을 변경했다고 해서 즉시 UPDATE가 실행되는 것은 아닙니다. 트랜잭션 커밋 시점이나 flush가 필요한 시점에 SQL이 나갑니다.
@Transactional
public void changeUserName(Long userId, String name) {
User user = userRepository.findById(userId).orElseThrow();
user.changeName(name);
// 여기서 다른 조회가 실행되면서 flush가 먼저 발생할 수 있음
orderRepository.findByUserId(userId);
}
이런 흐름에서는 개발자가 생각한 것보다 이른 시점에 UPDATE가 실행될 수 있습니다. 락 문제가 발생했을 때는 SQL 로그와 트랜잭션 범위를 함께 확인해야 합니다.
비관적 락은 필요한 곳에만 사용합니다
JPA에서 PESSIMISTIC_WRITE를 사용하면 명시적으로 쓰기 락을 잡을 수 있습니다. 재고 차감이나 중복 처리 방지처럼 필요한 경우에는 유용하지만, 무분별하게 사용하면 락 경합이 늘어납니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from Order o where o.id = :id")
Optional<Order> findByIdForUpdate(@Param("id") Long id);
이 방식은 “동시에 수정되면 안 되는 데이터”에 제한적으로 사용하는 것이 좋습니다. 단순 조회나 충돌 가능성이 낮은 데이터까지 비관적 락으로 묶으면 오히려 문제가 커질 수 있습니다.
Lock wait timeout exceeded 대응 순서 정리
Lock wait timeout exceeded 대응은 아래 순서로 진행하면 원인을 좁히기 쉽습니다. 중요한 것은 실패한 쿼리만 보지 말고, 그 쿼리를 막고 있던 트랜잭션을 찾는 것입니다.
확인 순서
1. Java 로그에서 실패한 SQL과 요청 흐름을 확인한다.
2. MySQL에서 현재 트랜잭션과 락 대기 상태를 확인한다.
3. 오래 열린 트랜잭션이 있는지 확인한다.
4. UPDATE, DELETE 조건의 인덱스를 확인한다.
5. @Transactional 범위가 불필요하게 넓은지 확인한다.
6. 여러 로직이 같은 테이블을 다른 순서로 수정하는지 확인한다.
7. 필요한 경우 멱등성이 보장된 작업에만 재시도를 적용한다.
8. 마지막으로 innodb_lock_wait_timeout 조정을 검토한다.
이 순서대로 보면 설정값 변경부터 하는 실수를 줄일 수 있습니다. Lock wait timeout exceeded는 DB 설정 하나로 해결되는 문제가 아니라, 애플리케이션 트랜잭션 설계와 쿼리 조건이 함께 얽힌 문제인 경우가 많습니다.
마무리
Java에서 Lock wait timeout exceeded 에러가 발생했다면 먼저 “어떤 쿼리가 실패했는가”보다 “어떤 트랜잭션이 락을 오래 잡고 있었는가”를 확인해야 합니다. 실패한 쿼리는 결과일 수 있고, 원인은 그보다 앞선 트랜잭션일 수 있습니다.
실무 기준으로는 @Transactional 범위를 줄이고, UPDATE 조건의 인덱스를 확인하고, 대량 수정 작업을 작게 나누고, 테이블 접근 순서를 맞추는 방식이 우선입니다. 재시도와 DB 타임아웃 설정 변경은 그 다음에 검토하는 편이 안전합니다.
이 에러는 한 번 해결하고 끝나는 문제가 아니라, 코드 구조와 데이터 접근 방식이 바뀔 때 다시 나타날 수 있습니다. 그래서 단순히 예외를 잡는 것보다 트랜잭션을 짧고 명확하게 유지하는 습관이 더 중요합니다.
'개발 > JAVA' 카테고리의 다른 글
| [JAVA] 트랜잭션 분리 안 해서 장애 난 사례: 외부 API 호출과 DB 저장을 같은 트랜잭션에 묶으면 생기는 일 (0) | 2026.05.24 |
|---|---|
| [JAVA] Hibernate flush 타이밍 때문에 발생한 문제와 실무에서 확인하는 방법 (0) | 2026.05.23 |
| [JAVA] Java DB Connection Pool 부족으로 장애가 났을 때 원인 분석과 해결 방법 (0) | 2026.05.21 |
| [JAVA] JPA N+1 문제 발견하고 해결한 과정: 원인 분석부터 Fetch Join 적용까지 (0) | 2026.05.20 |
| [JAVA] Java Deadlock 발생했을 때 처리 전략: 원인 분석부터 재발 방지까지 (0) | 2026.05.19 |
