InnoDB hidden columns: DB_ROW_ID, DB_TRX_ID, DB_ROLL_PTR의 의미
InnoDB 레코드에 포함되는 hidden column이 clustered index, MVCC, undo log, Primary Key 설계에 어떤 의미를 갖는지 운영 관점에서 정리한다.
1. 왜 hidden column을 알아야 하는가
InnoDB를 운영하다 보면 사용자가 정의하지 않은 정보가 레코드 내부에 존재한다는 설명을 자주 만난다. 대표적으로 DB_ROW_ID, DB_TRX_ID, DB_ROLL_PTR가 있다. 이들은 SHOW COLUMNS나 일반 SELECT로 조회되는 컬럼은 아니지만, InnoDB가 행을 식별하고, 트랜잭션 격리를 구현하고, rollback과 consistent read를 수행하는 데 필요한 핵심 메타데이터다.
hidden column을 이해하면 다음 질문에 더 정확히 답할 수 있다.
- Primary Key가 없는 InnoDB 테이블은 내부적으로 어떻게 clustered index를 구성하는가
- MVCC에서 “이 행 버전이 어느 트랜잭션에 의해 만들어졌는가”를 InnoDB가 어떻게 판단하는가
- undo log는 현재 레코드와 어떤 연결 고리로 이어지는가
- Primary Key를 명시하지 않는 설계가 왜 장기적으로 성능과 복구 가능성에 불리한가
- Aurora MySQL에서도 왜 InnoDB 레코드 구조와 MVCC 메타데이터 이해가 여전히 중요한가
hidden column은 사용자가 직접 조작하는 대상이 아니다. 하지만 운영자는 이 구조를 알아야 history list length, long transaction, purge 지연, Primary Key 없는 테이블의 복제·변경 추적 문제를 올바르게 해석할 수 있다.
2. InnoDB 레코드에 붙는 세 가지 내부 메타데이터
InnoDB는 테이블의 행을 단순히 사용자가 정의한 컬럼 값만으로 저장하지 않는다. clustered index leaf page의 각 record에는 행 데이터와 함께 내부 관리 정보가 붙는다. 개념적으로는 다음과 같이 볼 수 있다.
flowchart LR
subgraph R[InnoDB clustered index record]
U[사용자 정의 컬럼]
RID[DB_ROW_ID\n명시 PK가 없을 때 내부 행 식별자]
TRX[DB_TRX_ID\n마지막 변경 트랜잭션 ID]
ROLL[DB_ROLL_PTR\nundo log record 포인터]
end
ROLL --> ULOG[undo log record]
ULOG --> PREV[이전 행 버전]
TRX --> MVCC[MVCC 가시성 판단]
RID --> CLUST[clustered index key 역할]
세 컬럼의 의미는 서로 다르다.
| 내부 컬럼 | 대략적 역할 | 사용자가 직접 볼 수 있는가 | 운영상 의미 |
|---|---|---|---|
DB_ROW_ID |
명시적 Primary Key와 적절한 unique key가 없을 때 InnoDB가 생성하는 내부 행 ID | 아니오 | PK 없는 테이블의 clustered index 키가 되며, 복제·변경 추적·장기 운영에서 불리하다 |
DB_TRX_ID |
해당 record version을 마지막으로 변경한 transaction ID | 아니오 | consistent read와 purge 판단에 사용된다 |
DB_ROLL_PTR |
이전 version을 찾기 위한 undo log record 포인터 | 아니오 | rollback, consistent read, purge 작업의 연결 고리다 |
정확한 byte layout은 row format, record format, InnoDB 버전에 따라 세부 차이가 있을 수 있다. 다만 운영 관점에서는 “InnoDB record는 사용자 컬럼만 저장하지 않고, MVCC와 row identity를 위한 내부 메타데이터를 함께 저장한다”는 점이 중요하다.
3. DB_ROW_ID: Primary Key가 없을 때의 내부 행 식별자
InnoDB 테이블은 clustered index를 중심으로 저장된다. 사용자가 Primary Key를 정의하면 그 Primary Key가 clustered index key가 된다. Primary Key가 없고 모든 컬럼이 NOT NULL인 unique index가 있으면 InnoDB는 그 unique index를 clustered index로 사용할 수 있다. 이마저 적절하지 않으면 InnoDB는 내부적으로 DB_ROW_ID라는 hidden column을 만들어 clustered index key처럼 사용한다.
운영 관점에서 중요한 점은 DB_ROW_ID가 편리한 대체 수단이 아니라는 것이다.
- 사용자는
DB_ROW_ID를 조건절에 사용할 수 없다. - secondary index leaf에 저장되는 clustered key가 명시적으로 설계된 업무 키나 surrogate key가 아니라 내부 값이 된다.
- 테이블을 dump/reload하거나 재구성할 때 내부 행 ID의 안정성을 업무적으로 기대할 수 없다.
- row 기반 복제, CDC, 온라인 스키마 변경, 장애 분석에서 행을 명확히 지목하기 어렵다.
다음 예제는 Primary Key가 없는 테이블과 있는 테이블이 SQL 계층에서 어떻게 보이는지 비교한다. DB_ROW_ID는 내부 식별자이므로 SHOW COLUMNS 결과에 나타나지 않는다.
DROP TABLE IF EXISTS hidden_no_pk_demo;
DROP TABLE IF EXISTS hidden_with_pk_demo;
CREATE TABLE hidden_no_pk_demo (
email VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
KEY ix_status_created (status, created_at)
) ENGINE=InnoDB;
CREATE TABLE hidden_with_pk_demo (
user_id BIGINT NOT NULL,
email VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (user_id),
KEY ix_status_created (status, created_at)
) ENGINE=InnoDB;
SHOW COLUMNS FROM hidden_no_pk_demo;
SHOW INDEX FROM hidden_no_pk_demo;
SHOW COLUMNS FROM hidden_with_pk_demo;
SHOW INDEX FROM hidden_with_pk_demo;
DROP TABLE hidden_no_pk_demo;
DROP TABLE hidden_with_pk_demo;
위 결과에서 hidden_no_pk_demo에는 Primary Key가 보이지 않는다. 그러나 InnoDB 내부에서는 clustered index 없이 heap처럼 무질서하게 저장되는 것이 아니라, 내부 row ID를 이용해 clustered 구조를 유지한다. 이 구조가 보이지 않는다는 점이 오히려 운영상 문제다. 운영자는 내부 row ID에 의존해 특정 행을 안정적으로 지정하거나 애플리케이션의 idempotency 기준으로 삼을 수 없다.
4. DB_TRX_ID: 마지막 변경 트랜잭션을 기록하는 정보
DB_TRX_ID는 해당 record version을 마지막으로 삽입하거나 변경한 InnoDB transaction ID를 담는다. InnoDB의 consistent read는 단순히 “현재 페이지의 값을 읽는다”가 아니라, 읽기 시점의 read view와 record의 transaction metadata를 비교해 해당 version이 보여도 되는지 판단한다.
예를 들어 어떤 트랜잭션이 REPEATABLE READ에서 첫 번째 consistent read를 수행하면 InnoDB는 read view를 만든다. 이후 다른 트랜잭션이 같은 행을 수정하고 commit하더라도, 기존 트랜잭션은 자기 read view에서 보이는 과거 version을 읽어야 한다. 이때 현재 record의 DB_TRX_ID가 read view 기준으로 너무 최신이면, InnoDB는 DB_ROLL_PTR를 따라 undo log를 읽어 이전 version을 재구성한다.
개념 흐름은 다음과 같다.
sequenceDiagram
participant R as Read transaction
participant I as InnoDB record
participant U as Undo log
R->>I: record read
I-->>R: DB_TRX_ID 확인
alt read view에서 현재 version이 보임
R->>R: 현재 record 반환
else 현재 version이 너무 최신
R->>U: DB_ROLL_PTR를 따라 이전 version 조회
U-->>R: read view에 맞는 과거 version 재구성
end
이 구조 때문에 긴 트랜잭션은 단순히 lock만 오래 잡는 문제가 아니다. 오래된 read view가 유지되면 purge가 더 이상 필요 없는 undo record를 제거하지 못하고, undo history가 길어질 수 있다. 운영자는 DB_TRX_ID를 직접 보지는 못하지만, 그 결과로 나타나는 long transaction, undo tablespace 증가, purge 지연, consistent read 비용 증가를 관찰한다.
5. DB_ROLL_PTR: undo log와 현재 레코드를 잇는 포인터
DB_ROLL_PTR는 현재 record에서 이전 version을 찾기 위한 undo log record 포인터다. UPDATE가 발생하면 InnoDB는 변경 전 값을 undo log에 기록하고, 현재 record는 새 값과 새 DB_TRX_ID, 그리고 이전 version을 가리키는 DB_ROLL_PTR를 갖는다. DELETE도 내부적으로는 delete-mark와 purge 과정을 거치므로 undo와 밀접하게 연결된다.
운영 관점에서 DB_ROLL_PTR는 다음 의미를 갖는다.
- rollback 시 변경 전 상태로 되돌릴 수 있는 근거다.
- consistent read가 과거 version을 재구성하는 출발점이다.
- purge thread가 더 이상 필요 없는 과거 version을 정리할 때 따라가는 연결 구조의 일부다.
- undo log가 계속 쌓이는 현상을 “디스크가 이상하게 늘어난다”가 아니라 “오래된 read view 때문에 이전 version이 아직 필요하다”로 해석하게 해준다.
다음 예제는 직접 hidden column을 조회하지는 않지만, InnoDB의 MVCC가 사용자에게 어떤 가시성 결과를 제공하는지 단일 세션에서 확인한다. 같은 트랜잭션 안에서 update 후 rollback하면 undo 정보를 이용해 변경 전 값으로 돌아간다.
DROP TABLE IF EXISTS hidden_mvcc_demo;
CREATE TABLE hidden_mvcc_demo (
id BIGINT NOT NULL,
account_name VARCHAR(50) NOT NULL,
balance INT NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
INSERT INTO hidden_mvcc_demo VALUES (1, 'operating', 1000);
START TRANSACTION;
UPDATE hidden_mvcc_demo
SET balance = balance - 300
WHERE id = 1;
SELECT id, account_name, balance
FROM hidden_mvcc_demo
WHERE id = 1;
ROLLBACK;
SELECT id, account_name, balance
FROM hidden_mvcc_demo
WHERE id = 1;
DROP TABLE hidden_mvcc_demo;
실행 결과(MySQL 8.0.46):
mysql> DROP TABLE IF EXISTS hidden_mvcc_demo;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE hidden_mvcc_demo (
-> id BIGINT NOT NULL,
-> account_name VARCHAR(50) NOT NULL,
-> balance INT NOT NULL,
-> PRIMARY KEY (id)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO hidden_mvcc_demo VALUES (1, 'operating', 1000);
Query OK, 1 row affected (0.01 sec)
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> UPDATE hidden_mvcc_demo
-> SET balance = balance - 300
-> WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> SELECT id, account_name, balance
-> FROM hidden_mvcc_demo
-> WHERE id = 1;
+----+--------------+---------+
| id | account_name | balance |
+----+--------------+---------+
| 1 | operating | 700 |
+----+--------------+---------+
1 row in set (0.00 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT id, account_name, balance
-> FROM hidden_mvcc_demo
-> WHERE id = 1;
+----+--------------+---------+
| id | account_name | balance |
+----+--------------+---------+
| 1 | operating | 1000 |
+----+--------------+---------+
1 row in set (0.00 sec)
mysql> DROP TABLE hidden_mvcc_demo;
Query OK, 0 rows affected (0.00 sec)
이 예제에서 rollback 이후 값이 원래대로 돌아오는 것은 단순히 client가 UPDATE를 취소했기 때문이 아니다. InnoDB가 undo log와 record metadata를 이용해 transaction atomicity를 보장하기 때문이다. 실제 운영 장애 분석에서는 이 원리를 바탕으로 “왜 rollback이 오래 걸리는가”, “왜 undo tablespace가 줄지 않는가”, “왜 오래된 SELECT가 purge를 막는가”를 판단한다.
6. hidden column은 왜 일반 SQL에서 보이지 않는가
DB_ROW_ID, DB_TRX_ID, DB_ROLL_PTR는 SQL 표준의 사용자 컬럼이 아니라 InnoDB record 관리 메타데이터다. 따라서 다음과 같은 방식으로 다루지 않는다.
SELECT DB_TRX_ID FROM t처럼 조회할 수 없다.ALTER TABLE로 타입을 변경하거나 인덱스를 직접 만들 수 없다.- 애플리케이션 로직에서 내부 컬럼 값을 업무 식별자로 사용할 수 없다.
그 대신 운영자는 SQL 계층에서 보이는 현상과 InnoDB 메타데이터·상태 지표를 함께 해석한다. 예를 들어 Primary Key가 없는 테이블은 information_schema.tables, SHOW INDEX, SHOW CREATE TABLE로 식별할 수 있고, long transaction과 purge 지연은 information_schema.innodb_trx나 SHOW ENGINE INNODB STATUS로 확인한다.
다음 쿼리는 현재 스키마에서 Primary Key가 없는 InnoDB 테이블을 찾는 기본 형태다. 테스트 컨테이너에서도 동작하도록 작은 재현 테이블을 만든 뒤 조회한다.
DROP TABLE IF EXISTS pk_check_no_pk;
DROP TABLE IF EXISTS pk_check_with_pk;
CREATE TABLE pk_check_no_pk (
email VARCHAR(100) NOT NULL,
created_at DATETIME NOT NULL,
KEY ix_created_at (created_at)
) ENGINE=InnoDB;
CREATE TABLE pk_check_with_pk (
id BIGINT NOT NULL,
email VARCHAR(100) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY ix_created_at (created_at)
) ENGINE=InnoDB;
SELECT t.table_name
FROM information_schema.tables AS t
LEFT JOIN information_schema.table_constraints AS c
ON c.table_schema = t.table_schema
AND c.table_name = t.table_name
AND c.constraint_type = 'PRIMARY KEY'
WHERE t.table_schema = DATABASE()
AND t.engine = 'InnoDB'
AND c.constraint_name IS NULL
ORDER BY t.table_name;
DROP TABLE pk_check_no_pk;
DROP TABLE pk_check_with_pk;
실행 결과(MySQL 8.0.46):
mysql> DROP TABLE IF EXISTS pk_check_no_pk;
Query OK, 0 rows affected (0.00 sec)
mysql> DROP TABLE IF EXISTS pk_check_with_pk;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE pk_check_no_pk (
-> email VARCHAR(100) NOT NULL,
-> created_at DATETIME NOT NULL,
-> KEY ix_created_at (created_at)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE pk_check_with_pk (
-> id BIGINT NOT NULL,
-> email VARCHAR(100) NOT NULL,
-> created_at DATETIME NOT NULL,
-> PRIMARY KEY (id),
-> KEY ix_created_at (created_at)
-> ) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT t.table_name
-> FROM information_schema.tables AS t
-> LEFT JOIN information_schema.table_constraints AS c
-> ON c.table_schema = t.table_schema
-> AND c.table_name = t.table_name
-> AND c.constraint_type = 'PRIMARY KEY'
-> WHERE t.table_schema = DATABASE()
-> AND t.engine = 'InnoDB'
-> AND c.constraint_name IS NULL
-> ORDER BY t.table_name;
+----------------+
| TABLE_NAME |
+----------------+
| pk_check_no_pk |
+----------------+
1 row in set (0.01 sec)
mysql> DROP TABLE pk_check_no_pk;
Query OK, 0 rows affected (0.00 sec)
mysql> DROP TABLE pk_check_with_pk;
Query OK, 0 rows affected (0.00 sec)
이 쿼리는 “DB_ROW_ID를 직접 찾는 쿼리”가 아니다. 운영상 더 중요한 질문, 즉 “명시적 Primary Key 없이 InnoDB가 내부 row ID에 의존하게 만든 테이블이 있는가”를 찾는 쿼리다.
7. Primary Key 없는 테이블의 비용과 오해
Primary Key가 없는 테이블도 작은 개발 환경에서는 정상적으로 동작한다. 이 때문에 “InnoDB가 알아서 hidden row ID를 만들므로 굳이 PK가 없어도 된다”는 오해가 생긴다. 하지만 운영 규모에서는 다음 비용이 누적된다.
7.1 행 식별성이 약해진다
장애 대응 중에는 특정 행을 정확히 지목해야 한다. 중복 가능성이 있는 컬럼 조합만 존재하면, 데이터 보정·재처리·CDC 이벤트 추적에서 실수가 발생하기 쉽다. 내부 DB_ROW_ID는 사용자에게 노출되지 않으므로 이런 문제를 해결해 주지 않는다.
7.2 secondary index와 clustered lookup 해석이 불명확해진다
InnoDB secondary index leaf는 clustered key를 참조한다. 명시 Primary Key가 있으면 그 값이 logical row locator 역할을 한다. Primary Key가 없으면 내부 row ID가 이 역할을 하지만, 사용자는 그 값을 기준으로 실행 계획이나 데이터 접근 패턴을 업무적으로 해석하기 어렵다.
7.3 온라인 스키마 변경과 복제 도구가 불리해진다
많은 운영 도구는 변경 대상 행을 안정적으로 식별하기 위해 Primary Key 또는 unique key를 요구하거나 강하게 권장한다. Primary Key가 없으면 chunking, resume, conflict detection, row matching이 어려워진다. MySQL 자체 기능뿐 아니라 백업 검증, 마이그레이션, CDC 파이프라인에서도 동일한 문제가 나타난다.
7.4 Aurora MySQL에서도 문제가 사라지지 않는다
Aurora MySQL은 스토리지 계층 구조와 redo 처리 방식에서 Community MySQL과 차이가 있지만, SQL 계층과 InnoDB 호환 동작을 제공한다. Primary Key 없는 테이블의 행 식별성 문제, MVCC로 인한 undo/old version 해석, long transaction이 만드는 운영 부담은 Aurora에서도 중요한 주제다. Aurora에서는 Performance Insights, CloudWatch 지표, 엔진 상태 정보를 함께 봐야 하지만, 그 밑의 행 버전 관리 개념은 여전히 InnoDB 이해에서 출발한다.
8. MVCC 문제를 hidden column 관점으로 해석하기
운영자가 직접 DB_TRX_ID와 DB_ROLL_PTR를 조회하지 못하더라도, 다음 증상을 만나면 hidden column과 undo chain을 떠올려야 한다.
history list length가 계속 증가한다.- 오래 실행 중인
SELECT또는 열린 트랜잭션이 존재한다. - 대량 UPDATE/DELETE 후 undo tablespace 사용량이 예상보다 오래 유지된다.
- purge가 밀리면서 buffer pool과 I/O에 부담이 증가한다.
- replica 또는 CDC consumer가 오래된 version을 따라잡지 못하는 것처럼 보인다.
이때의 해석 순서는 다음과 같다.
- 현재 record에는 최신
DB_TRX_ID가 붙어 있다. - 어떤 read view에서는 그 최신 version이 보이면 안 될 수 있다.
- InnoDB는
DB_ROLL_PTR를 따라 undo log에서 이전 version을 찾는다. - 오래된 read view가 살아 있으면 purge는 그 undo record를 제거할 수 없다.
- 결과적으로 undo history가 길어지고, consistent read 비용과 저장 공간 부담이 증가한다.
이 흐름을 이해하면 “undo tablespace가 크다”를 단순 용량 문제로만 보지 않게 된다. 먼저 오래 열린 트랜잭션, batch job의 transaction boundary, connection pool의 autocommit 설정, 읽기 전용 세션의 방치 여부를 확인하게 된다.
9. 실무 진단: Primary Key와 긴 트랜잭션 확인
Primary Key 없는 테이블 확인은 예방 점검에 가깝고, long transaction 확인은 장애 대응에 가깝다. 다음 쿼리는 현재 세션에서 information_schema.innodb_trx 객체가 존재하고 조회 가능한지 확인하는 최소 진단 예제다. 실제 운영 환경에서는 결과가 비어 있을 수도 있으며, 권한과 실행 중인 트랜잭션 상태에 따라 보이는 내용이 달라진다.
SHOW TABLES FROM information_schema LIKE 'INNODB_TRX';
SELECT trx_id,
trx_state,
trx_started,
trx_mysql_thread_id,
LEFT(trx_query, 120) AS trx_query_sample
FROM information_schema.innodb_trx
ORDER BY trx_started
LIMIT 5;
실행 결과(MySQL 8.0.46):
mysql> SHOW TABLES FROM information_schema LIKE 'INNODB_TRX';
+-------------------------------------------+
| Tables_in_information_schema (INNODB_TRX) |
+-------------------------------------------+
| INNODB_TRX |
+-------------------------------------------+
1 row in set (0.00 sec)
mysql> SELECT trx_id,
-> trx_state,
-> trx_started,
-> trx_mysql_thread_id,
-> LEFT(trx_query, 120) AS trx_query_sample
-> FROM information_schema.innodb_trx
-> ORDER BY trx_started
-> LIMIT 5;
Empty set (0.00 sec)
information_schema.innodb_trx는 hidden column 값을 보여주는 테이블이 아니다. 하지만 DB_TRX_ID와 read view, undo chain이 만들어 내는 운영상 결과를 관찰하는 출발점이다. 장애 상황에서는 이 결과를 SHOW PROCESSLIST, performance_schema.threads, 애플리케이션 배포 시각, 배치 작업 로그와 함께 대조해야 한다.
10. 운영 체크리스트
11. 결론
DB_ROW_ID, DB_TRX_ID, DB_ROLL_PTR는 일반 SQL로 다루는 컬럼이 아니지만, InnoDB의 행 저장 구조와 트랜잭션 동작을 이해하는 데 반드시 필요한 개념이다. DB_ROW_ID는 Primary Key 없는 테이블이 내부적으로 행을 식별하는 방식이며, DB_TRX_ID와 DB_ROLL_PTR는 MVCC와 undo log를 연결하는 핵심 메타데이터다.
운영자는 hidden column을 직접 조회하려 하기보다, 이들이 만들어 내는 결과를 읽어야 한다. Primary Key 없는 테이블을 예방적으로 제거하고, long transaction과 purge 지연을 조기에 발견하며, undo log와 consistent read 비용을 올바르게 해석하는 것이 실무적으로 더 중요하다. 다음 글에서는 이러한 내부 메타데이터가 undo log, purge, read view와 결합해 InnoDB MVCC를 어떻게 완성하는지 더 구체적으로 다룰 수 있다.