백엔드 서비스를 운영하다가 대량 데이터 업데이트나 트랜잭션 요청이 몰릴 때 ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction 에러를 마주친 적이 있을 것이다. 대부분 단순 데이터베이스 과부하로 치부하고 넘어가지만, 정확한 발생 원인과 트랜잭션 잠금 매커니즘을 모르면 동일한 장애가 계속 반복된다. 이번에는 이 에러가 정확히 왜 발생하는지, 실행 중인 락(Lock)을 추적하는 방법, 그리고 실무 PHP 코드에서 이를 근본적으로 해결하는 기법까지 완벽하게 정리해서 소개하겠다.
MySQL의 InnoDB 저장 엔진은 데이터 일관성을 유지하기 위해 행 레벨 잠금(Row-level Lock)을 사용한다. 하나의 트랜잭션이 특정 행(Row)을 변경 중일 때 다른 트랜잭션이 해당 행을 수정하거나 읽기 위해(비동가성 락 요청 시) 대기하게 된다.
이때 대기 시간이 MySQL 설정값인 innodb_lock_wait_timeout(기본값 50초)을 초과하면 데이터베이스는 대기 중인 쿼리를 강제로 취소하고 ERROR 1205를 발생시킨다. 주요 원인은 다음과 같다.
- 긴 트랜잭션 수행: 트랜잭션 내부에서 외부 API 연산이나 복잡한 비즈니스 로직을 처리하느라 락을 오랜 시간 잡고 있는 경우
- 명시적 락 남용:
SELECT ... FOR UPDATE쿼리를 호출한 뒤 트랜잭션을 끝내지 않는 경우 - 인덱스 부재: UPDATE/DELETE 조건절에 인덱스가 없어 테이블 전체 행에 락(Table Scan Lock)이 걸리는 경우
- 트랜잭션 교착 상태(Deadlock) 지연: 두 개 이상의 트랜잭션이 서로 상대방의 락 해제를 기다리는 상황
에러가 발생했을 때 단순히 timeout 시간을 늘리는 것은 임시방편일 뿐이다. 어떤 쿼리가 락을 잡고 블로킹(Blocking)을 일으키고 있는지 추적해야 한다.
MySQL 8.0 이상에서는 information_schema.innodb_trx 테이블을 통해 오랫동안 열려 있는 트랜잭션을 확인할 수 있다.
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_mysql_thread_id AS process_id,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;
가장 오랫동안 락을 점유하고 있으면서 다른 쿼리를 블로킹하는 프로세스 ID(process_id)를 찾아 강제 종료(Kill)할 수 있다.
KILL 12345; -- process_id 입력
개발자들이 실무에서 가장 자주 하는 실수는 데이터베이스 트랜잭션 안에서 외부 HTTP API를 호출하거나 파일 업로드 같은 I/O 작업을 수행하는 것이다.
✗ 외부 API 응답이 지연되면 DB 행 락이 수초간 유지되어 다른 사용자의 요청이 모조리 락 대기 타임아웃에 걸린다.
<?php
try {
$pdo->beginTransaction();
// 1. 주문 데이터 락 조회
$stmt = $pdo->prepare("SELECT * FROM orders WHERE id = :id FOR UPDATE");
$stmt->execute([':id' => $orderId]);
// 2. 외부 PG사 결제 API 호출 (네트워크 지연 발생 가능 구간)
$paymentResult = $pgService->approvePayment($orderId);
if ($paymentResult->isSuccess()) {
$updateStmt = $pdo->prepare("UPDATE orders SET status = 'PAID' WHERE id = :id");
$updateStmt->execute([':id' => $orderId]);
}
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
?>
✓ 외부 API 처리를 트랜잭션 외부로 격리하고, DB 업데이트 시점에만 짧게 트랜잭션을 실행한다.
<?php
try {
// 1. 외부 PG사 결제 API를 먼저 호출 (DB 락 없음)
$paymentResult = $pgService->approvePayment($orderId);
if (!$paymentResult->isSuccess()) {
throw new Exception("결제 승인 실패");
}
// 2. DB 업데이트가 필요한 최소한의 영역에만 트랜잭션 적용
$pdo->beginTransaction();
$updateStmt = $pdo->prepare("UPDATE orders SET status = 'PAID' WHERE id = :id AND status = 'PENDING'");
$updateStmt->execute([':id' => $orderId]);
// 조건에 맞는 행이 업데이트되지 않은 경우 트랜잭션 철회
if ($updateStmt->rowCount() === 0) {
throw new Exception("이미 처리되었거나 존재하지 않는 주문입니다.");
}
$pdo->commit();
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// 필요 시 결제 취소 API 로직 수행
logger()->error("주문 처리 실패: " . $e->getMessage());
}
?>
[Success] 외부 연동과 DB 락 점유 분리로 평균 트랜잭션 점유 시간이 2.5초에서 0.003초로 단축됨.
[Result] 대량 동시 요청 환경에서도 ERROR 1205 (Lock wait timeout) 전면 해결.
트랜잭션 격리 수준 및 락 처리 옵션을 상황에 맞게 적용해야 데이터베이스 안정성을 확보할 수 있다.
| 구분 | 설명 | 적용 권장 상황 |
|---|---|---|
| innodb_lock_wait_timeout 조정 | 락 대기 한계 시간을 초 단위로 설정 | 배치 작업 등 긴 연산이 불가피한 전용 세션 |
| SELECT ... FOR UPDATE | 배타적 잠금(Exclusive Lock) 설정 | 재고 차감, 포인트 차감 등 정확한 동량 제어 |
| SELECT ... FOR SHARE | 공유 잠금(Shared Lock) 설정 | 트랜잭션 종료 전까지 조회 데이터의 수정 방지 |
| 인덱스(Index) 최적화 | UPDATE/DELETE 조건절 인덱스 필수 적용 | 테이블 전체 스캔으로 인한 전체 행 락 방지 |
✗ 타임아웃 에러를 피하려고 MySQL 전역 설정인 innodb_lock_wait_timeout을 300초 이상으로 무작정 늘리지 마라. 전체 시스템의 스레드 풀이 고갈되어 웹서버 전체가 먹통이 되는 부작용을 초래한다.
✓ UPDATE 및 DELETE 쿼리의 WHERE 조건절에는 반드시 인덱스가 걸려 있는지 EXPLAIN 명령어로 실행 계획을 확인해라. 인덱스가 없으면 단 하나의 행을 수정하려 해도 테이블의 수십만 행 전체에 락이 걸린다.
MySQL ERROR 1205는 트랜잭션 설계가 미흡할 때 발생하는 대표적인 병목 현상이다. 짧은 트랜잭션 유지와 적절한 락 관리라는 작은 개발 습관이 모여서 고성능이고 안정적인 DB 환경을 만든다는 점을 잊지 말자. 이 글의 InnoDB 락 추적 쿼리와 코드 분리 기법을 참고해 기존 데이터베이스 로직을 리팩토링하면, 잠금 대기 타임아웃 오류 없는 쾌적한 백엔드 시스템을 얻을 수 있을 것이다.