Oracle Lock·Deadlock·격리 수준과 Spring 트랜잭션 완벽 정리
개인 학습용 Spring·Oracle 기술 노트 3회차
학습 방식: 핵심 개념 → 세션별 동작 → Spring 연계 → 실전 문제 → 정답 해설 → 운영 점검
들어가며
앞선 학습에서는 Spring의 @Transactional이 AOP 프록시를 통해 동작한다는 점과 REQUIRED, REQUIRES_NEW, rollback-only, UnexpectedRollbackException을 공부했다.
이번에는 Spring 트랜잭션이 실제 Oracle 데이터베이스에서 어떤 동시성 현상을 만드는지 연결한다.
실무에서는 다음과 같은 문제가 자주 발생한다.
- UPDATE 문이 끝나지 않고 계속 대기한다.
- 특정 업무만 느려지고 WAS의 사용 가능 커넥션이 줄어든다.
ORA-00060이 간헐적으로 발생한다.SERIALIZABLE을 설정했더니ORA-08177이 발생한다.- 같은 데이터를 두 번 조회했는데 값이 달라진다.
- 두 요청이 동시에 처리되면서 먼저 저장한 값이 사라진다.
REQUIRES_NEW를 적용했는데 내부 메서드가 멈춘 것처럼 보인다.- DB 예외를
catch했더니 일부 데이터만 커밋된다.
이 문제를 이해하려면 다음 세 계층을 함께 봐야 한다.
Java 예외 처리
↓
Spring 트랜잭션 경계와 전파 속성
↓
Oracle MVCC, Undo, Lock, 격리 수준
이번 글의 핵심 문장은 다음과 같다.
트랜잭션은 여러 데이터 변경을 하나의 업무 단위로 묶고, Lock은 동시에 실행되는 트랜잭션이 같은 데이터를 파괴적으로 변경하지 못하도록 순서를 통제한다.
1. 동시성 문제가 발생하는 이유
사용자가 한 명이라면 데이터 처리는 비교적 단순하다.
조회 → 계산 → 수정 → 커밋
하지만 실제 업무 시스템에서는 여러 사용자의 요청, 배치, 연계 시스템이 동시에 데이터를 변경한다.
재고가 10개인 상품을 두 사용자가 동시에 주문한다고 가정하자.
트랜잭션 A: 재고 10 조회
트랜잭션 B: 재고 10 조회
트랜잭션 A: 3개 차감 → 7 저장
트랜잭션 B: 4개 차감 → 6 저장
정상적인 최종 재고는 3이어야 한다. 그러나 애플리케이션이 조회값을 기준으로 계산한 결과를 그대로 덮어쓰면 최종 값은 6이나 7이 될 수 있다.
동시 실행을 통제하지 못하면 다음 문제가 생길 수 있다.
- Lost Update
- 중복 접수
- 재고 음수
- 순번 중복
- 부모·자식 상태 불일치
- 같은 대상의 중복 이관
- 교착상태
Oracle은 이런 문제를 통제하기 위해 MVCC, Undo, 일관된 읽기, 행 잠금, 제약조건과 격리 수준을 제공한다.
2. Lock의 의미
Lock은 여러 트랜잭션이 같은 자원을 동시에 변경해 데이터를 손상시키는 것을 막는 장치다.
다음 데이터가 있다고 가정한다.
| RCPT_NO | STATUS |
|---|---|
| 123 | READY |
세션 A가 다음 UPDATE를 실행한다.
UPDATE migration_master
SET status = 'PROCESSING'
WHERE rcpt_no = '123';
아직 COMMIT이나 ROLLBACK은 실행하지 않았다. 세션 A는 변경된 행의 Lock을 보유한다.
이때 세션 B가 같은 행을 수정한다.
UPDATE migration_master
SET status = 'COMPLETED'
WHERE rcpt_no = '123';
세션 B는 바로 완료되지 않고 세션 A가 Lock을 해제할 때까지 기다린다.
세션 A
123번 행 UPDATE
↓
123번 행 Lock 보유
세션 B
123번 행 UPDATE 요청
↓
세션 A의 트랜잭션 종료 대기
Lock은 Repository 메서드가 반환될 때가 아니라 트랜잭션이 커밋되거나 롤백될 때 해제되는 것이 핵심이다.
3. Oracle의 일반적인 읽기와 쓰기 관계
Oracle은 MVCC와 Undo를 이용한 일관된 읽기를 제공한다.
일반적인 동작은 다음과 같다.
| 작업 조합 | 일반적인 동작 |
|---|---|
| SELECT ↔ SELECT | 서로 차단하지 않음 |
| 일반 SELECT ↔ UPDATE | 일반적으로 서로 차단하지 않음 |
| UPDATE ↔ 일반 SELECT | SELECT는 과거 커밋 버전을 읽을 수 있음 |
| 같은 행 UPDATE ↔ UPDATE | 나중 UPDATE가 대기 |
| 서로 다른 행 UPDATE ↔ UPDATE | 일반적으로 동시에 진행 가능 |
3.1 미커밋 데이터는 보이지 않는다
세션 A가 상태를 변경했지만 커밋하지 않았다고 가정한다.
UPDATE migration_master
SET status = 'PROCESSING'
WHERE rcpt_no = '123';
세션 A에서는 자신의 변경값을 볼 수 있다.
PROCESSING
세션 B가 일반 SELECT를 실행하면 세션 A의 미커밋 값이 아니라 이전 커밋값을 본다.
SELECT status
FROM migration_master
WHERE rcpt_no = '123';
READY
Oracle은 다른 트랜잭션의 미커밋 변경을 읽는 Dirty Read를 허용하지 않는다.
3.2 일반 SELECT와 동일 행 UPDATE의 차이
세션 B의 일반 SELECT는 Undo를 이용해 이전 커밋 버전을 읽고 진행할 수 있다. 하지만 세션 B가 같은 행을 UPDATE하면 세션 A가 보유한 행 Lock 때문에 대기한다.
일반 SELECT
→ 과거 커밋 버전 조회
→ 대기하지 않고 진행 가능
같은 행 UPDATE
→ 기존 Lock 해제 필요
→ 앞선 트랜잭션 종료까지 대기
4. MVCC와 Undo
MVCC는 여러 시점의 데이터 버전을 통해 동시성과 일관성을 함께 제공하는 방식이다.
Oracle은 데이터 변경 전에 이전 값을 Undo 영역에 기록한다.
변경 전: STATUS = READY
변경 후: STATUS = PROCESSING
현재 데이터 블록: PROCESSING
Undo 이전 버전: READY
다른 세션이 현재 트랜잭션에서 볼 수 없는 변경을 만나면 Oracle은 Undo를 이용해 필요한 시점의 과거 버전을 재구성할 수 있다.
변경한 세션 A
→ 자신의 미커밋 값 PROCESSING 확인
다른 세션 B
→ Undo를 이용한 커밋 버전 READY 확인
따라서 Oracle에서는 Writer가 행을 변경 중이어도 일반 Reader가 항상 기다리는 것은 아니다.
5. SCN과 문장 단위 일관성
Oracle은 SCN(System Change Number)을 이용해 데이터베이스 변경의 논리적인 순서를 관리한다.
일반 SELECT가 실행되면 Oracle은 조회 시작 시점에 맞는 커밋 데이터를 반환한다.
SELECT 시작
↓
기준 SCN 결정
↓
기준 시점 이후의 변경이 있으면 Undo로 이전 상태 재구성
↓
한 시점에 일관된 결과 반환
한 SELECT가 대량의 행을 오래 읽는 도중 다른 세션이 데이터를 변경하고 커밋해도 해당 SELECT는 자신의 시작 시점을 기준으로 일관된 결과를 반환한다.
6. Oracle 기본 격리 수준: READ COMMITTED
Oracle의 기본 트랜잭션 격리 수준은 READ COMMITTED다.
Spring에서 다음 선언은 DB 기본 격리 수준을 사용한다.
@Transactional
public void process() {
}
이는 다음과 같다.
@Transactional(isolation = Isolation.DEFAULT)
public void process() {
}
Oracle 기본 설정을 사용한다면 실질적으로 READ COMMITTED로 동작한다.
명시적으로 선언할 수도 있다.
@Transactional(isolation = Isolation.READ_COMMITTED)
public void process() {
}
6.1 문장마다 새로운 조회 시점을 사용한다
READ COMMITTED에서는 트랜잭션 시작 시점이 아니라 각 SQL 문장 시작 시점에 커밋된 데이터를 본다.
세션 A의 첫 조회
SELECT status
FROM migration_master
WHERE rcpt_no = '123';
결과:
READY
세션 B의 변경
UPDATE migration_master
SET status = 'COMPLETED'
WHERE rcpt_no = '123';
COMMIT;
세션 A의 두 번째 조회
SELECT status
FROM migration_master
WHERE rcpt_no = '123';
결과:
COMPLETED
세션 A의 트랜잭션이 종료되지 않았어도 두 번째 SELECT는 세션 B가 커밋한 값을 볼 수 있다.
6.2 Non-repeatable Read
같은 트랜잭션에서 같은 행을 다시 조회했을 때 다른 트랜잭션이 커밋한 변경 때문에 값이 달라지는 현상을 반복 불가능한 읽기라고 한다.
첫 번째 SELECT → READY
다른 트랜잭션 UPDATE + COMMIT
두 번째 SELECT → COMPLETED
Oracle READ COMMITTED는 Dirty Read는 차단하지만 Non-repeatable Read는 발생할 수 있다.
6.3 Phantom Read
같은 조건으로 여러 번 조회했는데 다른 트랜잭션이 행을 추가하여 결과 집합이 달라지는 현상이다.
SELECT COUNT(*)
FROM migration_master
WHERE status = 'READY';
첫 번째 결과가 10건이었다고 가정한다. 다른 세션이 READY 데이터를 하나 추가해 커밋한다.
INSERT INTO migration_master(rcpt_no, status)
VALUES ('999', 'READY');
COMMIT;
첫 번째 세션이 같은 SELECT를 다시 실행하면 11건을 볼 수 있다.
7. SERIALIZABLE 격리 수준
Oracle의 SERIALIZABLE에서는 여러 SELECT가 트랜잭션 시작 시점에 맞는 데이터를 일관되게 본다.
Spring에서는 다음과 같이 선언할 수 있다.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void process() {
}
트랜잭션 시작 시점의 스냅샷
↓
첫 번째 SELECT → READY
↓
다른 세션이 COMPLETED로 변경 후 커밋
↓
두 번째 SELECT → 여전히 READY
SERIALIZABLE은 트랜잭션 단위의 읽기 일관성을 강화하지만, 동시 변경을 모두 기다렸다가 성공시키는 설정은 아니다.
직렬화 트랜잭션이 자신의 시작 이후 다른 트랜잭션에서 변경·커밋한 데이터를 수정하려고 하면 다음 오류가 발생할 수 있다.
ORA-08177: can't serialize access for this transaction
애플리케이션은 이 오류에 대해 전체 롤백과 제한적인 재시도를 검토해야 한다.
8. 표준 격리 수준과 Oracle
SQL 표준에서는 다음 현상을 기준으로 격리 수준을 설명한다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 |
| READ COMMITTED | 방지 | 가능 | 가능 |
| REPEATABLE READ | 방지 | 방지 | 가능 |
| SERIALIZABLE | 방지 | 방지 | 방지 |
Oracle에서는 주로 다음 모드를 제공한다.
READ COMMITTEDSERIALIZABLEREAD ONLY
Spring의 Isolation 상수가 모든 DB에서 동일하게 지원된다고 생각해서는 안 된다. 실제 적용 가능성과 동작은 DB와 JDBC 드라이버를 확인해야 한다.
Oracle에서는 다음을 중심으로 이해하면 된다.
Isolation.DEFAULT
→ Oracle 기본 READ COMMITTED
Isolation.READ_COMMITTED
→ Oracle READ COMMITTED
Isolation.SERIALIZABLE
→ Oracle SERIALIZABLE
격리 수준을 무조건 높이면 동시성 충돌과 재시도 부담이 커질 수 있으므로 업무 요구에 따라 선택해야 한다.
9. 행 Lock을 획득하는 대표 작업
대표적으로 다음 작업이 행 Lock과 관련된다.
UPDATEDELETE- 특정
INSERT및 제약조건 충돌 SELECT ... FOR UPDATE
다음 UPDATE가 행을 변경하면 행 Lock은 트랜잭션이 종료될 때까지 유지된다.
UPDATE migration_master
SET status = 'PROCESSING'
WHERE rcpt_no = '123';
UPDATE 및 Lock 획득
↓
서비스의 다음 로직 수행
↓
COMMIT 또는 ROLLBACK
↓
Lock 해제
Repository 메서드가 끝났다고 Lock이 해제되는 것이 아니다. 해당 Repository 호출을 포함하는 실제 Spring 트랜잭션 경계를 확인해야 한다.
10. 긴 Spring 트랜잭션의 문제
@Transactional
public void migrate(String rcptNo) {
migrationRepository.updateStatus(rcptNo, "PROCESSING");
externalApi.call();
sourceFileDownloader.download();
blobConverter.convert();
validator.validate();
migrationRepository.insertData(rcptNo);
migrationRepository.updateStatus(rcptNo, "COMPLETED");
}
첫 UPDATE에서 Lock을 획득한 뒤 외부 API, 다운로드와 변환 작업이 오래 걸리면 해당 행의 Lock과 DB 커넥션을 장시간 보유한다.
가능하면 트랜잭션 밖에서 준비 작업을 수행하고 원자성이 필요한 DB 변경만 짧은 트랜잭션으로 묶는다.
public void migrate(String rcptNo) {
SourceFile file = sourceFileDownloader.download(rcptNo);
ConvertedData converted = blobConverter.convert(file);
ValidatedData validated = validator.validate(converted);
migrationTransactionService.save(rcptNo, validated);
}
@Service
@RequiredArgsConstructor
public class MigrationTransactionService {
private final MigrationRepository migrationRepository;
@Transactional
public void save(
String rcptNo,
ValidatedData data
) {
migrationRepository.updateStatus(rcptNo, "PROCESSING");
migrationRepository.insertData(rcptNo, data);
migrationRepository.updateStatus(rcptNo, "COMPLETED");
}
}
준비 작업 이후 원본이 변경될 수 있다면 버전 확인이나 트랜잭션 안의 최종 재검증을 추가해야 한다.
11. SELECT FOR UPDATE
일반 SELECT는 조회 행에 변경용 Lock을 걸지 않는다.
SELECT balance
FROM account
WHERE account_id = :accountId;
읽은 값을 기반으로 반드시 해당 행을 변경해야 하며 다른 트랜잭션이 먼저 수정하지 못하게 하려면 FOR UPDATE를 사용할 수 있다.
SELECT balance
FROM account
WHERE account_id = :accountId
FOR UPDATE;
행 조회 및 Lock 획득
↓
잔액 검증
↓
금액 변경
↓
COMMIT 또는 ROLLBACK
11.1 NOWAIT
이미 잠긴 행을 기다리지 않고 즉시 실패하려면 다음과 같이 사용할 수 있다.
SELECT status
FROM migration_master
WHERE rcpt_no = :rcptNo
FOR UPDATE NOWAIT;
사용자에게 “다른 사용자가 처리 중입니다”라고 즉시 응답해야 할 때 검토할 수 있다.
11.2 SKIP LOCKED
이미 잠긴 행을 건너뛰고 잠기지 않은 행을 처리하려면 다음 방식을 사용할 수 있다.
SELECT rcpt_no
FROM migration_master
WHERE status = 'READY'
ORDER BY rcpt_no
FOR UPDATE SKIP LOCKED;
여러 배치 작업자가 큐의 서로 다른 건을 병렬 처리할 때 활용할 수 있다. 다만 장시간 잠긴 건의 누락, 재처리와 정렬 정책을 함께 설계해야 한다.
12. Lock Wait와 Deadlock
두 현상은 다르다.
12.1 Lock Wait
한 트랜잭션이 다른 트랜잭션의 Lock 해제를 기다리는 상태다.
트랜잭션 A: 1번 행 Lock 보유
트랜잭션 B: 1번 행 Lock 요청 → 대기
트랜잭션 A가 커밋하거나 롤백하면 B가 진행할 수 있다. 이는 정상적인 동시성 제어일 수도 있다.
12.2 Deadlock
두 트랜잭션이 서로 상대방이 보유한 자원을 기다리는 순환 대기다.
트랜잭션 A: 1번 행 보유, 2번 행 대기
트랜잭션 B: 2번 행 보유, 1번 행 대기
Oracle이 순환 관계를 탐지하면 다음 오류를 발생시킨다.
ORA-00060: deadlock detected while waiting for resource
13. Deadlock 재현 예제
계좌 A와 B가 있다고 가정한다.
세션 1
UPDATE account
SET balance = balance - 1000
WHERE account_id = 'A';
세션 1이 A 행 Lock을 보유한다.
세션 2
UPDATE account
SET balance = balance - 2000
WHERE account_id = 'B';
세션 2가 B 행 Lock을 보유한다.
세션 1이 B 수정
UPDATE account
SET balance = balance + 1000
WHERE account_id = 'B';
세션 1은 세션 2가 보유한 B 행을 기다린다.
세션 2가 A 수정
UPDATE account
SET balance = balance + 2000
WHERE account_id = 'A';
세션 2도 세션 1이 보유한 A 행을 기다린다.
트랜잭션 T1
├─ A Lock 보유
└─ B Lock 대기
↑
│
↓
트랜잭션 T2
├─ B Lock 보유
└─ A Lock 대기
14. Deadlock 예방: 자원 접근 순서 통일
교착 예제의 핵심 원인은 접근 순서가 반대라는 점이다.
T1: A → B
T2: B → A
모든 요청이 동일한 기준으로 Lock을 획득하면 순환 가능성을 크게 줄일 수 있다.
T1: A → B
T2: A → B
계좌 ID를 정렬하는 예제다.
String firstId;
String secondId;
if (fromId.compareTo(toId) < 0) {
firstId = fromId;
secondId = toId;
} else {
firstId = toId;
secondId = fromId;
}
accountRepository.findByIdForUpdate(firstId);
accountRepository.findByIdForUpdate(secondId);
다른 요청은 첫 번째 자원에서 대기하고, 선행 요청이 두 자원 처리를 끝낸 후 진행한다. 대기는 남을 수 있지만 순환 대기를 방지할 수 있다.
15. ORA-00060과 Spring 롤백
Oracle DB 관점과 Spring 애플리케이션 관점을 나누어 이해해야 한다.
Oracle은 교착상태를 탐지해 관련 SQL 중 하나를 오류로 종료시킨다. 이것을 Oracle이 항상 전체 업무 트랜잭션을 자동 종료한다고 단순화하면 안 된다.
하지만 Oracle 오류는 JDBC SQLException으로 애플리케이션에 전달되고, Spring의 데이터 접근 예외 변환을 거치면 보통 DataAccessException 계열의 런타임 예외로 전달된다.
Oracle Deadlock 탐지
↓
ORA-00060
↓
JDBC SQLException
↓
Spring DataAccessException 계열
↓
@Transactional 프록시까지 전달
↓
기본 롤백 규칙에 따라 전체 Spring 트랜잭션 롤백
구체적인 예외 클래스는 Spring 버전, JDBC 드라이버와 데이터 접근 기술에 따라 달라질 수 있으므로 상위 계열과 오류 코드를 함께 확인한다.
16. Lost Update
두 트랜잭션이 같은 값을 읽고 각각 계산한 뒤 저장하면서 먼저 반영된 변경이 사라지는 현상이다.
초기 재고: 10
T1: 10 조회
T2: 10 조회
T1: 7 저장
T2: 6 저장
최종값: 6
정상 기대값: 3
16.1 위험한 조회 후 덮어쓰기
Stock stock = stockRepository.findById(productId);
int newQuantity = stock.getQuantity() - orderQuantity;
stockRepository.updateQuantity(productId, newQuantity);
조회와 UPDATE 사이에 다른 트랜잭션이 값을 바꿀 수 있다.
16.2 원자적 UPDATE
가능하면 계산을 SQL 한 문장으로 수행한다.
UPDATE product_stock
SET quantity = quantity - :orderQuantity
WHERE product_id = :productId
AND quantity >= :orderQuantity;
영향받은 행 수가 0이면 재고 부족 또는 경쟁 조건을 판단한다.
int updatedRows = stockRepository.decrease(
productId,
orderQuantity
);
if (updatedRows == 0) {
throw new OutOfStockException("재고가 부족합니다.");
}
16.3 비관적 잠금
SELECT quantity
FROM product_stock
WHERE product_id = :productId
FOR UPDATE;
충돌을 미리 Lock으로 직렬화한다. 충돌이 잦고 대기가 허용되는 업무에 적합할 수 있지만 처리량과 Deadlock을 고려해야 한다.
16.4 낙관적 잠금
UPDATE product_stock
SET quantity = :newQuantity,
version = version + 1
WHERE product_id = :productId
AND version = :oldVersion;
영향 행이 0건이면 다른 트랜잭션이 먼저 변경한 것이다.
JPA에서는 @Version을 사용할 수 있다.
@Entity
public class ProductStock {
@Id
private Long productId;
private int quantity;
@Version
private Long version;
}
충돌이 드문 업무에는 낙관적 잠금이 유리할 수 있으며 충돌 시 재조회와 제한적인 재시도를 설계해야 한다.
17. REQUIRES_NEW와 외부 Lock
REQUIRES_NEW는 외부 트랜잭션을 커밋하는 것이 아니라 일시 중단하고 새로운 물리적 트랜잭션을 시작한다.
따라서 외부 트랜잭션이 보유한 Lock은 그대로 유지된다.
@Service
@RequiredArgsConstructor
public class MigrationService {
private final ResultService resultService;
private final MigrationRepository migrationRepository;
@Transactional
public void migrate(String rcptNo) {
migrationRepository.updateStatus(rcptNo, "PROCESSING");
resultService.saveResult(rcptNo);
}
}
@Service
@RequiredArgsConstructor
public class ResultService {
private final MigrationRepository migrationRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveResult(String rcptNo) {
migrationRepository.updateStatus(rcptNo, "COMPLETED");
}
}
외부 트랜잭션 T1이 같은 행을 먼저 수정하고 Lock을 보유한다. 내부 T2가 새 커넥션으로 같은 행을 수정하면 T1의 Lock을 기다린다.
외부 T1
├─ 123번 행 Lock 보유
└─ 내부 메서드 T2 종료 대기
내부 T2
└─ T1의 123번 행 Lock 해제 대기
외부 T1이 애플리케이션 호출 스택에서 내부 메서드 반환을 기다리는 상태라면 Oracle이 DB 내부의 전형적인 순환 Lock 관계로 즉시 감지하지 못할 수 있다.
그 결과는 ORA-00060보다 장시간 Lock Wait, 쿼리 타임아웃 또는 트랜잭션 타임아웃으로 나타날 수 있다.
독립 실패 로그는 외부 트랜잭션이 잠근 업무 테이블과 다른 테이블에 저장하는 것이 상대적으로 안전하다.
18. 인덱스와 Lock 시간
인덱스가 없다고 무조건 테이블 전체 행에 배타적 행 Lock이 걸린다고 단순화하면 안 된다. 행 Lock과 대상 행을 탐색하는 비용은 구분해야 한다.
다만 적절한 인덱스가 없으면 다음 문제가 생길 수 있다.
- 대상 행 탐색 시간 증가
- 더 많은 블록과 행 검사
- SQL 실행 시간 증가
- 트랜잭션 유지 시간 증가
- Lock 보유 시간 증가
- 동시 요청 충돌 가능성 증가
다음 조건이 PK나 인덱스로 관리되는지 확인한다.
UPDATE migration_master
SET status = 'PROCESSING'
WHERE rcpt_no = :rcptNo;
부모·자식 데이터에서는 외래키 인덱스도 점검해야 한다. 부모 키 변경이나 삭제가 포함되면 동시성 문제와 성능에 영향을 줄 수 있다.
19. 트랜잭션 Timeout과 Lock Timeout
다음 Spring 설정이 모든 종류의 대기를 정확히 10초에 종료한다고 단정할 수 없다.
@Transactional(timeout = 10)
public void process() {
}
실제 중단 시점은 다음에 따라 달라질 수 있다.
- Spring 트랜잭션 매니저
- JDBC 드라이버
- Query Timeout 전달 여부
- Oracle의 Lock 대기 동작
- 커넥션 풀 설정
- 소켓 Read Timeout
- HTTP 요청 Timeout
다음은 서로 다른 개념이다.
HTTP 요청 Timeout
JDBC Query Timeout
Spring Transaction Timeout
Connection Acquisition Timeout
Oracle Lock Wait
Socket Read Timeout
장애 분석에서는 어떤 계층의 타임아웃이 발생했는지 확인해야 한다.
20. Deadlock 재시도 전략
Deadlock은 동시 실행 타이밍에 따라 간헐적으로 발생할 수 있다. 근본 원인을 먼저 줄이고, 재시도 가능한 업무에만 제한적으로 재시도를 적용한다.
20.1 근본 원인 개선
- 테이블과 행 접근 순서 통일
- 트랜잭션 범위 축소
- 불필요한
FOR UPDATE제거 - 적절한 인덱스 구성
- 외부 호출을 트랜잭션 밖으로 이동
REQUIRES_NEW의 동일 행 재접근 제거- 한 트랜잭션의 처리 건수 축소
20.2 전체 업무 트랜잭션 재시도
public void migrateWithRetry(String rcptNo) {
int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
migrationTransactionService.migrate(rcptNo);
return;
} catch (TransientDataAccessException e) {
if (attempt == maxAttempts) {
throw e;
}
log.warn(
"일시적 DB 충돌로 재시도합니다. rcptNo={}, attempt={}",
rcptNo,
attempt
);
}
}
}
@Service
@RequiredArgsConstructor
public class MigrationTransactionService {
@Transactional
public void migrate(String rcptNo) {
// 하나의 완전한 재시도 단위
}
}
재시도 메서드와 트랜잭션 메서드를 별도 Spring Bean으로 나눠 매 시도마다 새 트랜잭션이 시작되게 해야 한다.
20.3 멱등성 확인
같은 요청을 다시 실행해도 중복 또는 왜곡이 생기지 않아야 한다.
- 업무 키 UNIQUE 제약
- 요청 ID 저장
- 이미 처리된 상태 확인
- UPSERT 또는 MERGE
- 외부 API 중복 호출 방지 키
- 최대 재시도 횟수
- 백오프와 무작위 지연
무제한 즉시 재시도는 DB 부하를 더 높일 수 있다.
21. 실전 문제
다음 서비스를 기준으로 Lock과 트랜잭션 흐름을 분석한다.
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountRepository accountRepository;
private final AuditService auditService;
@Transactional
public void transfer(
String fromAccountId,
String toAccountId,
long amount
) {
accountRepository.decrease(fromAccountId, amount);
auditService.saveTransferLog(
fromAccountId,
toAccountId,
amount
);
accountRepository.increase(toAccountId, amount);
}
}
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditRepository auditRepository;
private final AccountRepository accountRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveTransferLog(
String fromAccountId,
String toAccountId,
long amount
) {
auditRepository.insert(
fromAccountId,
toAccountId,
amount
);
accountRepository.updateLastEvent(fromAccountId);
}
}
동시에 다음 두 요청이 실행된다고 가정한다.
요청 1: A 계좌 → B 계좌 이체
요청 2: B 계좌 → A 계좌 이체
문제 1
요청 1의 외부 트랜잭션이 A 계좌를 먼저 수정하면 어떤 Lock을 보유하는가?
문제 2
AuditService.saveTransferLog()의 내부 REQUIRES_NEW 트랜잭션이 다시 A 계좌의 last_event를 수정하면 바로 실행되는가, 기다리는가?
문제 3
요청 1은 A를 먼저 수정하고 B를 나중에 수정한다. 요청 2는 B를 먼저 수정하고 A를 나중에 수정한다. 어떤 문제가 발생할 수 있는가?
문제 4
Deadlock과 장시간 Lock Wait 가능성을 낮추려면 코드를 어떻게 수정해야 하는가?
문제 5
Oracle 기본 READ COMMITTED에서 세션 A가 같은 행을 두 번 SELECT한다. 두 SELECT 사이에 세션 B가 행을 변경하고 커밋하면 두 번째 SELECT에서 변경값이 보이는가?
문제 6
Deadlock 관련 DataAccessException을 트랜잭션 메서드 안에서 다음과 같이 잡고 끝내면 어떤 위험이 있는가?
@Transactional
public void transfer(...) {
try {
executeTransfer();
} catch (DataAccessException e) {
log.error("이체 실패", e);
}
}
22. 문제 1 정답: 외부 트랜잭션의 행 Lock
요청 1은 A → B 이체이므로 다음 코드가 A 계좌를 먼저 수정한다.
accountRepository.decrease("A", amount);
개념적인 SQL은 다음과 같다.
UPDATE account
SET balance = balance - :amount
WHERE account_id = 'A';
외부 트랜잭션 T1은 A 계좌 행에 대한 배타적 행 Lock을 보유한다.
외부 트랜잭션 T1
└─ A 계좌 행 Lock 보유
이 Lock은 decrease() Repository 메서드가 끝날 때 해제되지 않는다. 외부 transfer() 트랜잭션이 최종 커밋하거나 롤백할 때까지 유지된다.
정답: 요청 1의 외부 트랜잭션은 A 계좌 행의 배타적 변경 Lock을 획득하고 트랜잭션 종료까지 보유한다.
23. 문제 2 정답: REQUIRES_NEW의 동일 행 대기
외부 트랜잭션 T1이 A 행의 Lock을 보유한 상태에서 AuditService가 호출된다.
외부 T1 일시 중단
내부 T2 시작
중단은 커밋이나 롤백이 아니다. T1은 커넥션, 미커밋 변경과 A 행 Lock을 계속 보유한다.
내부 T2가 다음 코드를 실행한다.
accountRepository.updateLastEvent("A");
T2는 T1과 다른 물리적 트랜잭션이므로 T1의 미커밋 행을 자유롭게 수정할 수 없다. T1의 Lock 해제를 기다린다.
외부 T1
├─ A 행 Lock 보유
└─ 내부 T2의 메서드 종료 대기
내부 T2
└─ 외부 T1의 A 행 Lock 해제 대기
이것이 항상 Oracle의 ORA-00060으로 즉시 감지된다고 단정하면 안 된다. DB 입장에서는 T2가 T1의 Lock을 기다리는 관계만 보일 수 있고, T1은 DB 자원이 아니라 Java 호출 반환을 기다리는 상태일 수 있다.
따라서 다음 현상으로 나타날 수 있다.
- 장시간 Lock Wait
- JDBC Query Timeout
- Spring Transaction Timeout
- HTTP 요청 Timeout
- 커넥션 풀 고갈
정답: 내부
REQUIRES_NEW는 외부 트랜잭션이 보유한 A 행 Lock을 기다린다. 외부 트랜잭션은 중단되었을 뿐 종료된 것이 아니므로 Lock은 해제되지 않는다.
24. 문제 3 정답: 반대 순서 접근에 의한 Deadlock
내부 감사 호출을 잠시 제외하고 외부 이체 흐름을 보면 다음과 같다.
요청 1, T1: A 감소 → B 증가
요청 2, T2: B 감소 → A 증가
특정 타이밍에서 다음 상태가 된다.
T1: A Lock 보유
T2: B Lock 보유
T1: B Lock 요청 → T2 대기
T2: A Lock 요청 → T1 대기
순환 대기가 만들어져 Deadlock이 발생하며 Oracle은 ORA-00060을 발생시킬 수 있다.
정답: 두 트랜잭션이 A와 B를 반대 순서로 잠그므로 서로 상대방의 자원을 기다리는 Deadlock이 발생할 수 있다.
25. 문제 4 정답: 설계 개선
25.1 Lock 획득 순서 통일
계좌 ID를 정렬하여 모든 요청이 같은 순서로 계좌를 잠근다.
@Transactional
public void transfer(
String fromId,
String toId,
long amount
) {
String firstId;
String secondId;
if (fromId.compareTo(toId) < 0) {
firstId = fromId;
secondId = toId;
} else {
firstId = toId;
secondId = fromId;
}
accountRepository.findByIdForUpdate(firstId);
accountRepository.findByIdForUpdate(secondId);
accountRepository.decrease(fromId, amount);
accountRepository.increase(toId, amount);
}
SELECT account_id, balance
FROM account
WHERE account_id = :accountId
FOR UPDATE;
두 요청 모두 A부터 잠그면 두 번째 요청은 처음부터 A에서 기다린다. 순환 대기가 만들어지지 않는다.
25.2 내부 감사 트랜잭션에서 계좌 행을 수정하지 않는다
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditRepository auditRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveTransferLog(
String fromId,
String toId,
long amount
) {
auditRepository.insert(fromId, toId, amount);
}
}
외부는 ACCOUNT를 변경하고 내부는 별도 AUDIT_LOG를 저장하여 잠금 대상을 분리한다.
25.3 감사 로그 상태를 정확하게 설계한다
감사 로그가 먼저 커밋된 뒤 실제 이체가 실패할 수 있으므로 STARTED, COMPLETED, FAILED 등을 구분하거나 아웃박스 패턴을 검토한다.
25.4 트랜잭션을 짧게 유지한다
외부 API, 느린 파일 처리와 불필요한 조회를 이체 트랜잭션 밖으로 이동한다.
25.5 인덱스와 재시도 점검
계좌 ID가 PK 또는 적절한 인덱스로 관리되는지 확인하고, Deadlock에 대한 제한적 재시도와 멱등성을 설계한다.
모범답안: 모든 트랜잭션이 계좌를 동일 순서로 잠그게 하고, 내부
REQUIRES_NEW감사 트랜잭션에서 외부가 이미 잠근 계좌 행을 다시 수정하지 않도록 한다. 트랜잭션 범위, 인덱스, 멱등성과 제한적 재시도도 함께 점검한다.
26. 문제 5 정답: READ COMMITTED의 두 번째 SELECT
두 번째 SELECT에서 세션 B가 커밋한 변경값을 볼 수 있다.
Oracle READ COMMITTED는 각 SELECT 문장 시작 시점에 커밋된 데이터를 보기 때문이다.
세션 A 첫 SELECT → 100000
세션 B UPDATE → 90000, COMMIT
세션 A 두 번째 SELECT → 90000
하나의 SELECT가 실행 중인 동안에는 문장 단위 일관성이 유지되지만 서로 다른 두 SELECT 사이에는 새 커밋 데이터가 보일 수 있다.
정답: 보일 수 있다.
READ COMMITTED는 트랜잭션 전체가 아니라 SQL 문장 단위의 읽기 일관성을 제공한다.
27. 문제 6 정답: 예외를 삼킬 때의 부분 커밋 위험
@Transactional
public void transfer(...) {
try {
executeTransfer();
} catch (DataAccessException e) {
log.error("이체 실패", e);
}
}
결과는 executeTransfer()의 트랜잭션 경계에 따라 달라진다.
27.1 같은 클래스의 일반 메서드인 경우
executeTransfer()가 같은 객체의 내부 메서드라면 별도 프록시가 예외를 먼저 확인하지 않는다.
DB 오류를 transfer() 내부에서 잡고 정상 반환하면 외부 트랜잭션 프록시는 성공으로 판단해 커밋을 시도할 수 있다.
출금 성공
↓
입금 SQL에서 DB 오류
↓
catch가 예외를 삼킴
↓
프록시에는 정상 반환
↓
출금만 부분 커밋될 위험
Oracle이 Deadlock 해소 과정에서 오류 SQL을 실패시킨 것과 Spring 업무 트랜잭션 전체 롤백은 별개의 판단이라는 점이 중요하다.
27.2 별도 Bean의 REQUIRED 메서드인 경우
내부 Bean의 트랜잭션 프록시가 DataAccessException을 확인하면 공유 트랜잭션을 rollback-only로 표시할 수 있다.
외부에서 예외를 잡더라도 마지막 커밋 시 전체가 롤백되고 UnexpectedRollbackException이 발생할 수 있다.
내부 DataAccessException
↓
공유 트랜잭션 rollback-only
↓
외부 catch
↓
외부 커밋 시도
↓
전체 롤백 및 UnexpectedRollbackException 가능
27.3 안전한 기본 처리
원자성이 필요한 업무에서는 예외를 프록시 밖으로 다시 전달한다.
@Transactional
public void transfer(...) {
try {
executeTransfer();
} catch (DataAccessException e) {
log.error("이체 실패", e);
throw e;
}
}
업무 예외로 변환할 수도 있다.
@Transactional
public void transfer(...) {
try {
executeTransfer();
} catch (DataAccessException e) {
log.error("이체 실패", e);
throw new TransferException(
"계좌 이체에 실패했습니다.",
e
);
}
}
public class TransferException extends RuntimeException {
public TransferException(
String message,
Throwable cause
) {
super(message, cause);
}
}
모범답안: DB 예외를 트랜잭션 메서드 안에서 삼키면 프록시가 정상 반환으로 판단해 오류 전 성공 SQL이 부분 커밋될 수 있다. 별도
REQUIREDBean이 rollback-only로 표시했다면 전체 롤백되지만UnexpectedRollbackException이 발생할 수 있다. 원자적 업무에서는 롤백 대상 예외를 프록시까지 전달해야 한다.
28. 전체 문제 정답 요약
| 문제 | 핵심 정답 |
|---|---|
| 1 | 외부 트랜잭션이 A 계좌 행의 배타적 변경 Lock을 보유한다 |
| 2 | 내부 REQUIRES_NEW는 외부 트랜잭션이 보유한 A 행 Lock을 기다린다 |
| 3 | A→B와 B→A의 반대 접근 순서 때문에 Deadlock이 발생할 수 있다 |
| 4 | 접근 순서를 통일하고 내부 독립 트랜잭션에서 동일 계좌 행을 재수정하지 않는다 |
| 5 | READ COMMITTED는 문장 단위이므로 두 번째 SELECT에서 새 커밋값이 보일 수 있다 |
| 6 | 예외를 삼키면 부분 커밋 또는 UnexpectedRollbackException 위험이 있다 |
29. 운영 장애 점검 SQL
UPDATE가 장시간 대기하면 어떤 세션이 누구에게 차단됐는지 확인해야 한다.
대표적으로 다음 Oracle 동적 성능 뷰를 사용한다.
V$SESSIONV$LOCKV$SQLV$TRANSACTIONDBA_BLOCKERSDBA_WAITERS
권한과 DB 버전에 따라 사용 가능한 뷰가 다를 수 있다.
29.1 차단 세션 확인 예시
SELECT
sid,
serial#,
username,
status,
event,
wait_class,
seconds_in_wait,
blocking_session,
sql_id
FROM v$session
WHERE blocking_session IS NOT NULL;
29.2 세션의 SQL 확인 예시
SELECT
s.sid,
s.serial#,
s.username,
s.status,
s.event,
s.sql_id,
q.sql_text
FROM v$session s
LEFT JOIN v$sql q
ON q.sql_id = s.sql_id
WHERE s.sid = :sid;
V$SQL에는 동일 SQL ID에 여러 자식 커서가 있을 수 있으므로 실제 운영 조회에서는 중복 결과와 권한을 고려한다.
29.3 DBA에게 전달할 정보
- 장애 발생 시각
- 업무 요청 ID 또는 접수번호
- 애플리케이션 서버와 인스턴스
- 실행 스레드명
- 대상 테이블과 키
- SQL 또는 SQL ID
- 트랜잭션 시작 시각
- 타임아웃 발생 시각
- 관련 Java 예외 스택
운영 세션 강제 종료는 미커밋 데이터를 롤백시키므로 원인을 확인하지 않고 수행해서는 안 된다.
30. 실무 점검 체크리스트
트랜잭션 경계
- DB 변경 전에 불필요하게 트랜잭션을 시작하지 않았는가?
- 외부 API와 파일 처리가 트랜잭션 안에 있는가?
@Transactional메서드가 프록시를 통과하는가?- 여러 데이터소스와 트랜잭션 매니저를 올바르게 선택했는가?
Lock 순서
- 여러 행과 테이블을 모든 코드 경로에서 동일 순서로 접근하는가?
- 양방향 이체처럼 입력 순서가 Lock 순서가 되지는 않는가?
REQUIRES_NEW가 외부 트랜잭션의 동일 행을 다시 수정하는가?
SQL과 인덱스
- UPDATE와 DELETE 조건에 적절한 인덱스가 있는가?
- 부모·자식 관계의 외래키 인덱스를 점검했는가?
- 실행계획상 불필요한 대량 스캔이 발생하는가?
- 영향받은 행 수를 확인하는가?
예외와 롤백
DataAccessException을 트랜잭션 내부에서 삼키는가?- 예외가 프록시까지 전달되는가?
- checked exception이라면 rollback 규칙이 맞는가?
- 내부
REQUIRED가 rollback-only로 표시했는가?
재시도와 멱등성
- Deadlock과 직렬화 충돌만 선별해 재시도하는가?
- 전체 업무 트랜잭션을 새로 시작하는가?
- 중복 호출 방지 키와 UNIQUE 제약이 있는가?
- 재시도 횟수와 백오프가 제한돼 있는가?
관찰 가능성
- 요청 ID와 업무 키가 로그에 남는가?
- SQL ID와 대기 이벤트를 추적할 수 있는가?
- 트랜잭션 처리 시간을 관찰하는가?
- 커넥션 풀 사용량과 대기 시간을 모니터링하는가?
31. 경력직 면접 1분 답변
Oracle은 MVCC와 Undo를 이용해 일관된 읽기를 제공하므로 일반 SELECT는 다른 트랜잭션의 미커밋 변경을 읽지 않고 조회 시점에 맞는 커밋 버전을 재구성합니다. 그래서 일반적으로 Reader와 Writer는 서로 차단하지 않지만 같은 행에 대한 동시 UPDATE는 먼저 Lock을 가진 트랜잭션이 끝날 때까지 대기합니다. 두 트랜잭션이 서로 다른 자원을 보유한 채 상대방 자원을 기다리면 Deadlock이 발생하며 Oracle은
ORA-00060을 발생시킵니다. Oracle의 기본 격리 수준인 READ COMMITTED는 SQL 문장 단위 일관성을 제공하므로 같은 트랜잭션의 두 SELECT가 다른 값을 볼 수 있습니다. SERIALIZABLE은 트랜잭션 단위 일관성을 제공하지만 동시 변경 시ORA-08177이 발생할 수 있습니다. 애플리케이션에서는 트랜잭션 범위를 짧게 유지하고, 자원 접근 순서를 통일하며, 원자적 UPDATE나 낙관적·비관적 잠금을 업무에 맞게 선택해야 합니다. 또한REQUIRES_NEW는 외부 트랜잭션의 Lock을 해제하지 않으므로 동일 행 재접근과 커넥션 풀 사용을 주의해야 합니다.
32. 면접 예상 질문
Q1. Oracle에서 SELECT와 UPDATE는 서로 차단되나요?
일반 SELECT는 MVCC와 Undo를 이용해 적절한 커밋 버전을 읽으므로 일반적으로 UPDATE와 서로 차단되지 않는다. 다만 SELECT FOR UPDATE는 변경용 Lock을 획득하므로 다르다.
Q2. UPDATE 메서드가 끝나면 Lock도 해제되나요?
Repository 메서드 종료가 아니라 실제 트랜잭션의 커밋 또는 롤백 시점에 해제된다.
Q3. Lock Wait와 Deadlock의 차이는 무엇인가요?
Lock Wait는 한쪽이 다른 쪽을 기다리는 상태로 선행 트랜잭션이 종료되면 진행할 수 있다. Deadlock은 서로 상대방을 기다리는 순환 관계로 DB의 개입이 필요하다.
Q4. Deadlock을 어떻게 예방하나요?
자원 접근 순서를 통일하고 트랜잭션을 짧게 유지하며, 불필요한 잠금과 동일 행 재접근을 제거한다. SQL과 인덱스를 점검하고 재시도가 필요하다면 멱등성을 확보한다.
Q5. READ COMMITTED에서 같은 값을 두 번 읽는 것이 보장되나요?
보장되지 않는다. Oracle READ COMMITTED는 문장 단위 스냅샷을 사용하므로 두 SELECT 사이에 다른 트랜잭션이 커밋하면 두 번째 조회에서 새 값이 보일 수 있다.
Q6. SERIALIZABLE이면 모든 트랜잭션이 성공하나요?
아니다. 읽기 일관성은 강화되지만 트랜잭션 시작 이후 다른 트랜잭션이 변경한 데이터와 충돌하면 ORA-08177로 실패할 수 있다.
Q7. REQUIRES_NEW를 호출하면 외부 Lock이 풀리나요?
아니다. 외부 트랜잭션은 일시 중단될 뿐 종료되지 않으므로 기존 Lock을 계속 보유한다.
Q8. DB 예외를 catch하고 로그만 남기면 안전한가요?
아니다. 프록시가 예외를 보지 못해 일부 SQL이 커밋될 수 있다. 별도 REQUIRED 범위가 rollback-only로 표시한 경우에는 마지막에 UnexpectedRollbackException이 발생할 수 있다.
33. 최종 요약
- Oracle은 MVCC와 Undo로 일관된 읽기를 제공한다.
- 다른 트랜잭션의 미커밋 변경은 일반 SELECT에 보이지 않는다.
- 일반 SELECT와 UPDATE는 일반적으로 서로 차단하지 않는다.
- 같은 행의 동시 UPDATE는 나중 트랜잭션이 대기한다.
- 행 Lock은 메서드가 아니라 트랜잭션 종료 시 해제된다.
- Oracle 기본
READ COMMITTED는 문장 단위 읽기 일관성을 제공한다. - 같은 트랜잭션의 두 SELECT가 서로 다른 커밋값을 볼 수 있다.
SERIALIZABLE은 트랜잭션 단위 일관성을 제공하지만ORA-08177이 발생할 수 있다.- 서로 반대 순서로 행을 잠그면 Deadlock 가능성이 커진다.
- Deadlock은
ORA-00060으로 나타날 수 있다. - 자원 접근 순서를 통일하면 순환 대기를 줄일 수 있다.
REQUIRES_NEW는 외부 트랜잭션의 Lock을 해제하지 않는다.- 외부와 내부 독립 트랜잭션이 같은 행을 수정하면 장시간 대기가 발생할 수 있다.
- DB 예외를 트랜잭션 내부에서 삼키면 부분 커밋 위험이 있다.
- 재시도는 멱등성을 확보한 뒤 제한적으로 적용해야 한다.
한 문장으로 정리하면 다음과 같다.
Oracle 동시성 장애를 해결하려면 SQL 한 줄만 보는 것이 아니라 Spring 트랜잭션 경계, 커넥션, Lock 획득 순서와 예외 전달 경로를 하나의 흐름으로 분석해야 한다.
34. 다음 학습을 위한 복습 문제
문제 1
100개의 이관 대상 행을 SELECT FOR UPDATE SKIP LOCKED로 여러 배치 인스턴스가 나눠 처리할 때, 장시간 잠긴 건과 실패 건이 영구적으로 누락되지 않도록 어떤 상태 및 재처리 구조가 필요한가?
문제 2
다음 재고 차감 방식에서 Lost Update 가능성을 줄일 수 있는 이유와, 영향 행 수가 0인 경우의 의미를 설명해 보자.
UPDATE product_stock
SET quantity = quantity - :amount
WHERE product_id = :productId
AND quantity >= :amount;
문제 3
외부 트랜잭션이 MIGRATION_MASTER를 UPDATE한 후 내부 REQUIRES_NEW가 같은 행을 UPDATE한다. 왜 일반적인 DB Deadlock이 아니라 장시간 Lock Wait나 애플리케이션 타임아웃으로 나타날 수 있는가?
문제 4
ORA-08177과 ORA-00060의 발생 원인을 각각 비교하고, 재시도 전에 확인해야 할 멱등성 조건을 설명해 보자.
다음 학습에서는 이 내용을 바탕으로 Oracle 실행계획, 인덱스, Full Table Scan과 느린 SQL 진단을 공부한다.
참고 자료
'개발 > java,spring' 카테고리의 다른 글
| Spring 트랜잭션 전파 완벽 정리: `REQUIRED`, `REQUIRES_NEW`, rollback-only와 `UnexpectedRollbackException` (0) | 2026.09.11 |
|---|---|
| Spring `@Transactional` 동작 원리와 롤백되지 않는 이유 (0) | 2026.09.10 |
| Checked Exception vs Unchecked Exception 차이: 실무에서 어떻게 선택해야 할까? (0) | 2026.03.31 |
| Java Exception 구조 이해하기: try-catch만 알면 부족한 이유 (0) | 2026.03.24 |
| REST API 설계 기본 원칙 정리 (백엔드 개발자를 위한 가이드) (0) | 2026.03.19 |
| springframework.web.filter.CharacterEncodingFiler cannot be cast to class jakarta.servlet.Filter (0) | 2025.07.20 |
| dynamic web module facet version 5.0 was not found (1) | 2025.07.17 |