본문 바로가기
ORA DBDATA ORA DBDATA

BLOCKING_SESSION만 보면 엉뚱한 세션을 정리합니다: Oracle 차단 체인에서 원인 세션 찾는 SQL

읽는 시간 약 24분
세 세션이 한 줄로 막힌 Oracle 차단 체인에서 최종 차단 세션을 찾아가는 과정을 표현한 이미지입니다.

세션 C의 BLOCKING_SESSION은 2424, FINAL_BLOCKING_SESSION은 2181이었습니다. 같은 V$SESSION 한 행에서 두 컬럼이 서로 다른 세션을 가리킨 것입니다. C를 멈추게 한 직접 원인은 2424번 세션이지만, 2424번 역시 다른 세션을 기다리고 있었습니다. 차단의 출발점은 아무 SQL도 실행하지 않고 INACTIVE 상태로 있던 2181번이었습니다.

Oracle 21c XE에서 세 세션이 한 줄로 막히는 차단 체인을 만들고, V$SESSION, V$SQL, V$LOCK, V$LOCKED_OBJECT, V$TRANSACTION을 차례로 조회했습니다. 어느 세션이 원인인지, 어떤 행에서 막혔는지, 원인 세션이 COMMIT하면 체인이 어떻게 바뀌는지를 실측 결과로 정리합니다.

세 세션이 한 줄로 막히도록 만든 실습 구성

검색으로 찾을 수 있는 차단 세션 예제는 대부분 A가 B를 막는 두 세션 구조입니다. 이 구조에서는 BLOCKING_SESSION만 봐도 원인이 바로 나옵니다. 하지만 실제 장애에서는 대기 중인 세션이 다시 다른 세션을 막는 체인이 흔하기 때문에, 이번에는 세 세션이 한 줄로 이어지도록 설계했습니다.

항목내용
DBOracle Database 21c Express Edition, PDB XEPDB1
도구DBeaver, 편집기 4개를 각각 별도 연결과 수동 커밋 모드로 사용
테스트 USERBLK_LAB32 (실습용 가상 시나리오)
테이블PICKING_TASK 2,000건, PK(task_id)
세션 역할A·B·C는 UPDATE, D는 관찰 전용

잠금 진단은 데이터 양과 관계가 없지만, 2,000건 중 막힌 행 하나를 ROWID로 찾아내는 과정을 보여 주기 위해 3행짜리 테이블보다 큰 규모로 정했습니다. 테스트 USER는 SYSTEM으로 XEPDB1에 접속해 만들었습니다. V$ 뷰를 조회하려고 SELECT ANY DICTIONARY를 부여했는데, 범위가 넓은 권한이라 실습 후 USER를 삭제하는 테스트 환경에서만 사용했습니다.

CREATE USER blk_lab32 IDENTIFIED BY "Lab32#Pass"
    DEFAULT TABLESPACE users
    QUOTA 50M ON users;

GRANT CREATE SESSION, CREATE TABLE TO blk_lab32;
GRANT SELECT ANY DICTIONARY TO blk_lab32;
CREATE TABLE picking_task (
    task_id     NUMBER        NOT NULL,
    zone_code   VARCHAR2(10)  NOT NULL,
    status      VARCHAR2(20)  NOT NULL,
    handled_by  VARCHAR2(10),
    pick_qty    NUMBER        NOT NULL,
    created_at  DATE          NOT NULL,
    CONSTRAINT pk_picking_task PRIMARY KEY (task_id)
);

INSERT INTO picking_task (task_id, zone_code, status, handled_by, pick_qty, created_at)
SELECT
    LEVEL,
    'Z' || LPAD(MOD(LEVEL, 8) + 1, 2, '0'),
    'READY',
    NULL,
    MOD(LEVEL, 5) + 1,
    DATE '2026-10-01' + MOD(LEVEL, 10)
FROM dual
CONNECT BY LEVEL <= 2000;

COMMIT;

DBeaver에서 같은 USER로 편집기를 여러 개 열면, V$SESSION에서 어느 행이 어느 편집기인지 구분하기 어렵습니다. 그래서 각 편집기에서 DBMS_SESSION.SET_IDENTIFIER로 이름표를 붙이고, 진단 쿼리는 모두 CLIENT_IDENTIFIER로 걸렀습니다. 운영에서 애플리케이션이 이 값을 설정해 두면 같은 방식으로 세션을 추적할 수 있습니다.

ALTER SESSION SET CURRENT_SCHEMA = BLK_LAB32;

BEGIN
    DBMS_SESSION.SET_IDENTIFIER('LAB32_A');
END;
21c XE의 XEPDB1에서 BLK_LAB32로 접속한 LAB32_A·B·C·D 네 세션이 SID 2181, 2424, 2542, 247로 각각 조회됩니다.
네 편집기가 서로 다른 세션으로 접속했고, 관찰 세션 D만 ACTIVE 상태입니다.

세션별 UPDATE 순서는 다음과 같습니다. B는 202번을 먼저 잡은 상태에서 101번을 기다리므로, 대기 세션이면서 동시에 차단 세션이 됩니다.

순서세션실행 내용결과
1A (2181)101번 UPDATE, COMMIT 안 함즉시 완료
2B (2424)202번 UPDATE즉시 완료
3B (2424)101번 UPDATEA를 기다리며 멈춤
4C (2542)202번 UPDATEB를 기다리며 멈춤
-- 세션 A
UPDATE picking_task
SET status = 'HOLD_A', handled_by = 'A', pick_qty = pick_qty + 1
WHERE task_id = 101;

-- 세션 B (두 문장을 순서대로)
UPDATE picking_task
SET status = 'B_FIRST', handled_by = 'B', pick_qty = pick_qty + 100
WHERE task_id = 202;

UPDATE picking_task
SET status = 'B_SECOND', handled_by = 'B', pick_qty = pick_qty + 10
WHERE task_id = 101;

-- 세션 C
UPDATE picking_task
SET status = 'C_LAST', handled_by = 'C', pick_qty = pick_qty + 1000
WHERE task_id = 202;

V$SESSION 한 번으로 보이는 직접 차단자와 최종 차단자

B와 C가 멈추고 잠시 뒤, 관찰 세션 D에서 세 세션의 대기 상태를 조회했습니다.

SELECT
    s.client_identifier AS who,
    s.sid,
    s.status,
    s.state,
    s.event,
    ROUND(s.wait_time_micro / 1000000, 1) AS wait_sec,
    s.last_call_et,
    s.blocking_session_status AS blk_status,
    s.blocking_session AS blk_sid,
    s.final_blocking_session_status AS final_status,
    s.final_blocking_session AS final_sid
FROM v$session s
WHERE s.client_identifier IN ('LAB32_A', 'LAB32_B', 'LAB32_C')
ORDER BY s.client_identifier;
C의 BLK_SID는 2424, FINAL_SID는 2181로 서로 다르게 조회되고, A는 INACTIVE 상태로 SQL*Net message from client 이벤트에 머물러 있습니다.
C의 직접 차단자는 B(2424)이고, 최종 차단자는 A(2181)입니다.
세션STATUSEVENTWAIT_SECBLK_SIDFINAL_SID
A (2181)INACTIVESQL*Net message from client114.5NULL (NO HOLDER)NULL (UNKNOWN)
B (2424)ACTIVEenq: TX – row lock contention34.821812181
C (2542)ACTIVEenq: TX – row lock contention25.224242181

C 행만 보고 BLOCKING_SESSION을 따라가면 B를 원인으로 판단하게 됩니다. B를 정리하면 202번 행은 풀리지만, 101번을 쥐고 있는 A는 그대로 남습니다. FINAL_BLOCKING_SESSION은 체인을 끝까지 따라간 결과를 바로 보여 주므로, 원인 세션을 찾을 때는 이 컬럼을 먼저 봐야 합니다.

STATE가 WAITING인 A를 대기 세션으로 읽지 않습니다

A의 STATE도 WAITING으로 나왔습니다. 처음 보면 A 역시 무언가를 기다린다고 오해하기 쉽습니다. 하지만 A의 EVENT는 SQL*Net message from client로, 클라이언트의 다음 요청을 기다리는 유휴 대기입니다. A의 WAIT_SEC 114.5와 LAST_CALL_ET 114가 거의 같다는 점도, A가 마지막 호출 이후 아무 일도 하지 않고 트랜잭션만 열어 둔 상태임을 보여 줍니다.

같은 관계를 계층 쿼리로 그리면 체인이 한눈에 보입니다.

SELECT
    LPAD(' ', 2 * (LEVEL - 1)) || who || ' (SID ' || sid || ')' AS chain,
    status,
    event
FROM (
    SELECT sid, blocking_session, client_identifier AS who, status, event
    FROM v$session
    WHERE client_identifier IN ('LAB32_A', 'LAB32_B', 'LAB32_C')
)
START WITH blocking_session IS NULL
CONNECT BY PRIOR sid = blocking_session;
CHAINSTATUSEVENT
LAB32_A (SID 2181)INACTIVESQL*Net message from client
  LAB32_B (SID 2424)ACTIVEenq: TX – row lock contention
    LAB32_C (SID 2542)ACTIVEenq: TX – row lock contention

막는 세션의 SQL은 PREV_SQL_ID로도 찾을 수 없었습니다

원인 세션을 찾았다면 다음 질문은 “A가 무엇을 실행했는가”입니다. 실행 중인 SQL이 없는 세션은 PREV_SQL_ID로 직전 SQL을 보라는 조언이 많아서, V$SQL과 함께 조회했습니다.

SELECT
    s.client_identifier AS who,
    s.sid,
    s.status,
    s.sql_id,
    SUBSTR(cur.sql_text, 1, 90) AS current_sql,
    s.prev_sql_id,
    SUBSTR(prv.sql_text, 1, 90) AS prev_sql
FROM v$session s
LEFT JOIN v$sql cur
    ON cur.sql_id = s.sql_id
   AND cur.child_number = s.sql_child_number
LEFT JOIN v$sql prv
    ON prv.sql_id = s.prev_sql_id
   AND prv.child_number = s.prev_child_number
WHERE s.client_identifier IN ('LAB32_A', 'LAB32_B', 'LAB32_C')
ORDER BY s.client_identifier;
A는 SQL_ID가 NULL이고, A·B·C의 PREV_SQL_ID가 모두 4p0j7x26vq744로 같으며 직전 SQL은 DBMS_OUTPUT.GET_LINE 호출입니다.
대기 중인 B·C의 UPDATE는 보이지만, A가 실행한 UPDATE는 어디에도 남아 있지 않습니다.

B와 C는 실행 중인 UPDATE가 SQL_ID와 함께 그대로 보입니다. 반면 A는 SQL_ID가 NULL이고, PREV_SQL_ID에도 101번을 바꾼 UPDATE가 아니라 BEGIN DBMS_OUTPUT.GET_LINE(:1, :2); END;가 남아 있었습니다. B와 C의 PREV_SQL_ID도 같은 4p0j7x26vq744였습니다.

이 호출은 실습에서 직접 실행한 문장이 아닙니다. DBeaver가 SQL을 실행한 뒤 서버 출력을 읽으려고 같은 세션에서 보낸 것으로 보입니다. 같은 실습을 처음 시도했을 때는 A의 직전 SQL이 DBeaver의 객체 목록 조회(SELECT DISTINCT OWNER,OBJECT_NAME,OBJECT_TYPE ...)로 나오기도 했습니다. 클라이언트 도구나 커넥션 풀이 사용자 SQL 뒤에 자체 호출을 보내면, PREV_SQL_ID는 그 호출을 가리킵니다.

운영에서도 애플리케이션 프레임워크가 검증 쿼리나 세션 초기화 호출을 보낼 수 있으므로, PREV_SQL_ID만으로 차단 세션의 작업을 판단해서는 안 됩니다. 이번 실습에서 A의 작업을 특정할 수 있었던 근거는 SQL 텍스트가 아니라, 다음에 볼 막힌 행과 트랜잭션 정보였습니다.

어느 행에서 막혔는지 ROWID로 좁히기

대기 세션의 V$SESSION에는 기다리는 행의 위치가 ROW_WAIT_OBJ#, ROW_WAIT_FILE#, ROW_WAIT_BLOCK#, ROW_WAIT_ROW#로 남아 있습니다. 이 값으로 ROWID를 만들어 테이블과 조인하면, 막힌 행을 정확히 꺼낼 수 있습니다.

WITH w AS (
    SELECT
        s.client_identifier AS who,
        s.sid,
        o.object_name,
        s.row_wait_file#,
        s.row_wait_block#,
        s.row_wait_row#,
        DBMS_ROWID.ROWID_CREATE(
            1, o.data_object_id,
            s.row_wait_file#, s.row_wait_block#, s.row_wait_row#
        ) AS waiting_rowid
    FROM v$session s
    JOIN dba_objects o
        ON o.object_id = s.row_wait_obj#
    WHERE s.client_identifier IN ('LAB32_B', 'LAB32_C')
      AND s.event LIKE 'enq: TX%'
)
SELECT
    w.who, w.sid, w.object_name,
    w.row_wait_file#, w.row_wait_block#, w.row_wait_row#,
    w.waiting_rowid,
    p.task_id, p.status, p.handled_by, p.pick_qty
FROM w
LEFT JOIN picking_task p
    ON p.ROWID = w.waiting_rowid
ORDER BY w.who;
B는 파일 12, 블록 12438, 행 100에서 task_id 101을, C는 같은 블록의 행 201에서 task_id 202를 기다리는 것으로 조회됩니다.
2,000건 중 B는 101번, C는 202번 행에서 막혀 있으며 관찰 세션에는 변경 전 값이 보입니다.

B는 101번, C는 202번 행에서 기다리는 것으로 확인되었습니다. 두 행은 같은 블록(파일 12, 블록 12438)의 100번째와 201번째 슬롯에 있었습니다.

조회된 행 값도 눈여겨볼 만합니다. 관찰 세션 D에는 두 행 모두 READY와 원래 pick_qty(2, 3)로 보였습니다. A와 B가 이미 UPDATE했지만 COMMIT 전이라, D는 마지막으로 커밋된 값을 읽은 것입니다. 이 방법으로 찾은 행 값은 “잠금을 쥔 세션이 바꾸려는 값”이 아니라 “현재 커밋된 값”입니다.

DBMS_ROWID.ROWID_CREATE는 상대 파일 번호를 기대하지만, ROW_WAIT_FILE#은 절대 파일 번호입니다. 이번 XE 환경에서는 두 번호가 같아서 변환 없이 행이 조회되었습니다. 데이터 파일이 많은 운영 DB에서 결과가 비어 있다면, DBA_DATA_FILES의 RELATIVE_FNO로 변환해야 하는지 확인해야 합니다.

V$LOCK에서 B는 보유자이면서 대기자입니다

SELECT
    s.client_identifier AS who,
    l.sid, l.type, l.id1, l.id2,
    l.lmode, l.request, l.ctime, l.block
FROM v$lock l
JOIN v$session s
    ON s.sid = l.sid
WHERE s.client_identifier IN ('LAB32_A', 'LAB32_B', 'LAB32_C')
  AND l.type IN ('TX', 'TM')
ORDER BY s.client_identifier, l.type, l.request;
A와 B는 각자 TX를 LMODE 6, BLOCK 1로 보유하고, B는 A의 TX 655389를, C는 B의 TX 589834를 REQUEST 6으로 요청합니다.
B는 자기 TX를 보유한 채 A의 TX를 요청하고 있어, 한 세션이 보유와 대기를 동시에 갖습니다.
세션TYPEID1ID2LMODEREQUESTBLOCK
ATM760810300
ATX6553892235601
BTM760810300
BTX589834635601
BTX6553892235060
CTM760810300
CTX589834635060

TM 잠금의 ID1 76081은 USER_OBJECTS에서 조회한 PICKING_TASK의 OBJECT_ID와 같습니다. 세 세션 모두 LMODE 3(Row Exclusive)의 TM을 보유하는데, 이 모드끼리는 호환되므로 여기서 충돌이 일어난 것은 아닙니다.

실제 대기는 TX에서 일어났습니다. 대기 세션의 REQUEST 행과 보유 세션의 LMODE 행은 ID1과 ID2가 같은 값으로 짝지어집니다. B가 요청한 655389/2235는 A가 보유한 TX이고, C가 요청한 589834/635는 B가 보유한 TX입니다. B는 BLOCK 1로 C를 막으면서 동시에 REQUEST 6으로 A를 기다리고 있습니다.

TX의 ID1은 Undo 세그먼트 번호와 슬롯을 합친 값이라, TRUNC(ID1/65536)로 Undo 세그먼트 번호를 꺼낼 수 있습니다. 655389는 10, 589834는 9가 나오며, 아래 V$LOCKED_OBJECT의 XIDUSN과 일치합니다. 캡처마다 CTIME이 다른 것은 조회 시점이 조금씩 달랐기 때문입니다.

V$LOCKED_OBJECT에 나왔다고 잠금 보유자는 아닙니다

잠긴 객체를 찾을 때 자주 쓰는 V$LOCKED_OBJECT도 함께 조회했습니다.

SELECT
    s.client_identifier AS who,
    lo.session_id, lo.xidusn, lo.locked_mode,
    o.object_name
FROM v$locked_object lo
JOIN dba_objects o
    ON o.object_id = lo.object_id
JOIN v$session s
    ON s.sid = lo.session_id
WHERE s.client_identifier IN ('LAB32_A', 'LAB32_B', 'LAB32_C')
ORDER BY s.client_identifier;
세션SESSION_IDXIDUSNLOCKED_MODEOBJECT_NAME
A2181103PICKING_TASK
B242493PICKING_TASK
C254203PICKING_TASK

아무 행도 바꾸지 못하고 기다리기만 하는 C도 LOCKED_MODE 3으로 나왔습니다. 세 세션의 차이는 XIDUSN뿐이었습니다. C는 0인데, 아직 트랜잭션이 시작되지 않아 Undo 세그먼트를 할당받지 않았다는 뜻입니다. 실제로 V$TRANSACTION에는 A와 B만 조회되었습니다.

SELECT
    s.client_identifier AS who,
    s.sid, t.start_time, t.status, t.used_ublk, t.used_urec
FROM v$transaction t
JOIN v$session s
    ON s.taddr = t.addr
WHERE s.client_identifier IN ('LAB32_A', 'LAB32_B', 'LAB32_C')
ORDER BY s.client_identifier;
세션START_TIMESTATUSUSED_UBLKUSED_UREC
A (2181)10/10/26 20:55:55ACTIVE11
B (2424)10/10/26 20:57:00ACTIVE11

V$LOCKED_OBJECT는 “이 테이블을 쓰고 있는 세션 목록”이지 “다른 세션을 막는 세션 목록”이 아닙니다. 차단 여부는 V$SESSION의 차단 컬럼이나 V$LOCK의 BLOCK 값으로 판단해야 합니다.

A가 COMMIT해도 C의 대기는 끝나지 않았습니다

원인 세션 A에서 COMMIT한 직후 체인을 다시 조회했습니다.

SELECT
    s.client_identifier AS who,
    s.sid, s.status, s.event,
    s.blocking_session AS blk_sid,
    s.final_blocking_session AS final_sid,
    s.final_blocking_session_status AS final_status
FROM v$session s
WHERE s.client_identifier IN ('LAB32_A', 'LAB32_B', 'LAB32_C')
ORDER BY s.client_identifier;
A가 COMMIT한 뒤 A와 B는 INACTIVE이고, C는 계속 enq: TX - row lock contention 상태이며 BLK_SID와 FINAL_SID가 모두 2424입니다.
원인 세션이 끝나도 체인은 사라지지 않고, B가 새로운 최종 차단자가 되었습니다.

A가 101번을 놓자 B의 두 번째 UPDATE가 끝났고, B는 INACTIVE가 되었습니다. 그런데 B는 아직 COMMIT하지 않아 202번을 계속 쥐고 있으므로, C는 여전히 대기 중입니다. C의 BLOCKING_SESSION과 FINAL_BLOCKING_SESSION은 모두 2424로 바뀌었습니다. 처음 상태에서 A만 정리하면 문제가 끝났다고 판단하기 쉽지만, 체인이 한 단계 짧아졌을 뿐입니다.

B가 COMMIT한 뒤에는 C의 UPDATE가 진행되었습니다. 이때 C가 COMMIT하기 전에 조회하면, 202번은 B_FIRST와 103으로 보이고 남은 TX 잠금은 1개였습니다. C가 COMMIT한 뒤의 최종 값은 다음과 같습니다.

task_idSTATUSHANDLED_BYPICK_QTY남은 TX 잠금
101B_SECONDB13 (2 + 1 + 10)0
202C_LASTC1,103 (3 + 100 + 1,000)0

세 세션의 변경이 빠짐없이 순서대로 반영되었습니다. 대기 세션은 앞선 트랜잭션이 확정한 값을 기준으로 계산을 이어 갔습니다.

정리할 세션을 고르는 기준과 이 실습의 한계

이번 결과를 운영 판단으로 옮기면 기준은 세 가지입니다. 첫째, 원인 세션은 FINAL_BLOCKING_SESSION으로 찾고, 중간 세션(B)을 정리 대상으로 착각하지 않습니다. 둘째, 원인 세션이 무엇을 했는지는 PREV_SQL_ID가 아니라 막힌 행(ROW_WAIT)과 트랜잭션 시작 시각(V$TRANSACTION)으로 추적하고, 애플리케이션 로그와 대조합니다. 셋째, 세션을 강제로 정리하면 트랜잭션이 롤백되므로 USED_UREC로 롤백할 양을 먼저 확인합니다. 이번에는 1건이라 부담이 없었지만, 대량 변경을 쥔 세션은 롤백에도 시간이 걸립니다.

Oracle의 행 잠금 대기에는 MySQL의 innodb_lock_wait_timeout 같은 기본 제한 시간이 없습니다. 원인 세션이 끝나지 않으면 대기 세션은 계속 기다리고, 체인 뒤에 새 요청이 쌓입니다. 대기 시간을 제한하려면 애플리케이션에서 SELECT ... FOR UPDATE WAIT n 같은 방식을 따로 써야 합니다.

이 실습은 단일 인스턴스 21c XE에서 세 세션, 한 블록 안의 두 행으로 진행했습니다. RAC에서는 차단 세션이 다른 인스턴스에 있을 수 있어 GV$SESSION과 BLOCKING_INSTANCE, FINAL_BLOCKING_INSTANCE를 함께 봐야 합니다. 체인이 갈라지거나 여러 세션이 같은 행을 기다리는 경우도 이번에는 재현하지 않았습니다. PREV_SQL_ID에 나온 호출 역시 DBeaver 기준의 결과이며, 다른 도구나 애플리케이션에서는 다른 SQL이 남을 수 있습니다.

INACTIVE 세션 하나가 체인 전체를 붙잡고 있었습니다

이번 체인의 출발점은 SQL을 실행하지 않고 유휴 대기 중이던 INACTIVE 세션이었습니다. 실행 중인 SQL만 보면 ACTIVE인 B와 C가 문제처럼 보이고, BLOCKING_SESSION만 따라가면 B에서 멈추게 됩니다. FINAL_BLOCKING_SESSION으로 원인 세션을 찾고, ROW_WAIT로 막힌 행을 특정하고, V$LOCK의 ID1·ID2로 보유와 요청을 짝지은 뒤 V$TRANSACTION으로 정리 영향을 확인하는 순서를 따르면, 세션이 멈춘 상황을 “A가 101번 행을 쥔 채 클라이언트 응답을 기다리고 있다”는 구체적인 사실로 바꿀 수 있습니다.

admin
함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.