본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 무결성과 트랜잭션

무결성 제약 조건

NOT NULL·UNIQUE·PK·FK·검사의 책임을 구분하고 애플리케이션 검사를 우회한 잘못된 행을 DB에서 거절합니다.

애플리케이션 검증은 친절한 오류 메시지를 만들지만 데이터가 들어오는 모든 경로를 통제하지는 못합니다.

배치, 관리자 SQL, 다른 서비스가 같은 테이블을 쓸 수 있습니다.

영원히 참이어야 하는 규칙은 마지막 관문인 데이터베이스에도 선언해야 합니다.

회원 게시판에서 존재하지 않는 작성자의 게시글, 비정상 조회수, 같은 회원이 같은 요청 키로 등록한 중복 게시글은 저장해서는 안 됩니다.

세 규칙을 각각 FK, 검사, UNIQUE로 표현하고 어떤 위반이 어떤 오류를 내는지 확인합니다.

실습은 post_integrity_demo를 별도로 만들어 앞 장의 테이블을 변경하지 않습니다.

부모 회원 한 명을 넣고 정상 행과 세 가지 위반 행을 순서대로 실행합니다.

제약 이름을 명시해 운영 오류 로그만 보고도 어떤 규칙이 깨졌는지 알 수 있게 합니다.


SELECT 후 INSERT의 경쟁 조건

INSERT 전에 회원을 조회했으니 안전하다고 가정하고 FK를 생략합니다.

확인 직후 부모가 삭제되거나 다른 경로가 INSERT를 수행하면 고아 행이 생깁니다.

검증과 쓰기 사이의 시간 차이는 애플리케이션 코드만으로 닫히지 않습니다.

FK 없는 테이블에 존재하지 않는 부모 저장
DROP TABLE IF EXISTS post_integrity_bad;
CREATE TABLE post_integrity_bad (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  author_id BIGINT UNSIGNED NOT NULL,
  request_key VARCHAR(80) NOT NULL,
  created_at DATETIME(6) NOT NULL,
  view_count BIGINT NOT NULL,
  status VARCHAR(16) NOT NULL,
  PRIMARY KEY (id)
) ENGINE = InnoDB;

INSERT INTO post_integrity_bad
  (author_id, request_key, created_at, view_count, status)
VALUES
  (999999, 'req-bad-1', '2026-07-14 09:00:00.000000', -30, 'DONE');

SELECT * FROM post_integrity_bad;

문법과 타입만 맞으므로 존재하지 않는 회원, 음수 조회수, 허용하지 않은 상태가 한 행에 함께 저장됩니다.

오류 실행 결과
id | author_id | request_key | created_at                 | view_count | status
1  | 999999    | req-bad-1  | 2026-07-14 09:00:00.000000 | -30        | DONE

SQL error: none
business rules violated: 3

오류가 없다는 사실이 바로 오류입니다.

BIGINT와 VARCHAR는 업무 의미를 모르므로 타입만으로 고아 참조와 잘못된 상태를 막을 수 없습니다.

member_id의 이름이 같아도 관계는 자동으로 생기지 않습니다.

애플리케이션 SELECT는 사용자 피드백에는 유용하지만 제약의 대체물이 아닙니다.

DB 제약 위반은 최종 방어선이고 서비스는 그 오류를 업무 오류로 변환합니다.

오류가 없다는 오류를 네 규칙으로 해부하기

  1. 행 존재 — 부모 키 999999가 실제 회원에 있는지 안티 조인으로 확인합니다.
  2. 도메인 — 조회수와 상태가 허용 집합·범위를 벗어났는지 표시합니다.
  3. 중복 — 같은 회원과 request_key 조합이 여러 번 저장될 수 있는지 검사합니다.
  4. 경로 — 웹 요청 외 배치·SQL 가져오기도 같은 검증을 거치는지 확인합니다.

제약 조건별 불변식

기본 키는 행의 대표 식별과 NOT NULL 유일성을, UNIQUE는 다른 후보 키를, 외래 키는 참조 대상 존재와 삭제·변경 정책을 지킵니다.

검사는 한 행의 값 범위와 컬럼 간 조건을, NOT NULL은 값 부재 금지를 표현합니다.

제약을 많이 붙이는 것이 목표가 아니라 요구사항을 DB가 판단 가능한 불변식으로 번역하는 것이 목표입니다.

‘한 회원이 같은 요청 키로 만든 게시글은 최대 한 개’처럼 여러 행을 보는 규칙은 UNIQUE나 별도 모델·트랜잭션으로 표현합니다.

불변식을 제약 종류에 배치하기

  1. 행 정체성id를 안정적인 PK로 두고 업무 중복은 별도 UNIQUE로 보존합니다.
  2. 참조 생명주기 — 게시글이 있는 회원 삭제를 RESTRICT할지 소프트 삭제할지 결정합니다.
  3. 값 범위 — 조회수는 0 이상이어야 하고 제목·본문은 허용 길이를 지켜야 합니다.
  4. 상태 집합 — 문자열 오타를 막되 상태 추가가 DDL 변경인지 코드 테이블인지 판단합니다.

이름 있는 제약 선언

부모 FK, 글 등록 요청 키, 조회수·상태 검사를 선언합니다.

MySQL 8.4의 InnoDB는 검사를 실제로 강제하며 위반한 구문 전체를 거절합니다.

회원 게시판 무결성 데모 스키마
DROP TABLE IF EXISTS post_integrity_demo;
CREATE TABLE post_integrity_demo (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  author_id BIGINT UNSIGNED NOT NULL,
  request_key VARCHAR(80) NOT NULL,
  title VARCHAR(80) NOT NULL,
  content VARCHAR(5000) NOT NULL,
  created_at DATETIME(6) NOT NULL,
  view_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
  status VARCHAR(16) NOT NULL,
  PRIMARY KEY (id),
  CONSTRAINT uq_integrity_author_request
    UNIQUE (author_id, request_key),
  CONSTRAINT fk_integrity_author
    FOREIGN KEY (author_id) REFERENCES members (id),
  CONSTRAINT chk_integrity_view_count
    CHECK (view_count <= 1000000000),
  CONSTRAINT chk_integrity_content
    CHECK (CHAR_LENGTH(content) BETWEEN 1 AND 5000),
  CONSTRAINT chk_integrity_status
    CHECK (status IN ('DRAFT', 'HIDDEN', 'PUBLISHED', 'DELETED'))
) ENGINE = InnoDB;

INSERT INTO post_integrity_demo
  (author_id, request_key, title, content, created_at, view_count, status)
SELECT id, 'req-1', '첫 게시글', '제약이 지키는 게시글 본문',
       '2026-07-14 09:00:00.000000', 30, 'PUBLISHED'
FROM members ORDER BY id LIMIT 1;

정상 행은 저장되고, 같은 스키마로 고아 FK를 시도하면 제약 이름과 함께 1452 오류가 발생합니다.

개선 실행 결과
normal insert: Query OK, 1 row affected

invalid parent insert:
ERROR 1452 (23000): Cannot add or update a child row:
a foreign key constraint fails (... CONSTRAINT 'fk_integrity_author' ...)

stored invalid rows: 0

UNSIGNED 타입이 음수를 타입 단계에서 막고 검사가 업무 상한을 추가하면 저장 범위가 드러납니다.

상태 검사는 오타를 막지만 상태 전이 순서까지 보장하지는 않습니다.

DRAFT에서 PUBLISHED로 바로 갈 수 있는지는 서비스 트랜잭션 규칙입니다.

FK 생성 시 자식 키 인덱스가 필요합니다.

기존 성능용 복합 인덱스의 왼쪽 접두어가 author_id를 제공하는지 확인해 중복 단일 인덱스를 피합니다.

위반을 하나씩 격리해 확인하기

  1. 정상 예제 데이터 — 부모 한 명과 허용 범위 행을 먼저 넣어 스키마 자체가 사용 가능한지 확인합니다.
  2. 위반 하나씩 — FK, 검사, UNIQUE를 각각 별 구문으로 실행해 오류 원인을 분리합니다.
  3. 잔존 행 — 오류 발생 뒤 건수와 안티 조인으로 잘못된 행이 0인지 확인합니다.
  4. 오류 매핑 — 제약 조건 이름을 사용자 메시지와 운영 로그의 안정적 키로 사용합니다.

무결성 위반 재현

한 구문에 여러 위반을 섞으면 첫 오류만 보여 어느 제약이 작동했는지 알기 어렵습니다.

트랜잭션 안에서 각 오류를 실행하고 테스트 데이터를 롤백합니다.

FK·CHECK·UNIQUE 위반 확인
-- 1) 존재하지 않는 부모
INSERT INTO post_integrity_demo
  (author_id, request_key, title, content, created_at, view_count, status)
VALUES
  (999999, 'req-orphan', '고아 글', '부모 회원 없음',
   '2026-07-14 10:00:00', 30, 'PUBLISHED');

-- 2) 잘못된 조회수: 기존 회원 id를 사용한다.
INSERT INTO post_integrity_demo
  (author_id, request_key, title, content, created_at, view_count, status)
SELECT id, 'req-overflow', '조회수 오류', '상한 검사',
       '2026-07-14 11:00:00', 1000000001, 'PUBLISHED'
FROM members ORDER BY id LIMIT 1;

-- 3) 이미 저장한 author_id + request_key 중복
INSERT INTO post_integrity_demo
  (author_id, request_key, title, content, created_at, view_count, status)
SELECT id, 'req-1', '중복 요청', '멱등성 키 재사용',
       '2026-07-14 12:00:00', 45, 'PUBLISHED'
FROM members ORDER BY id LIMIT 1;

첫 구문은 오류 1452, 두 번째는 오류 3819와 chk_integrity_view_count, 세 번째는 오류 1062와 uq_integrity_author_request를 반환합니다.

각 오류 구문은 행을 추가하지 않습니다.

제약별 반례를 직접 만들기

  1. NULL 실험 — NULL 허용 UNIQUE에 NULL 두 행을 넣고 후보 키에 NOT NULL이 필요한 이유를 적습니다.
  2. 삭제 정책 — RESTRICT와 CASCADE를 별 데모에서 비교하고 게시글 보존 요구를 선택합니다.
  3. 제약 이름 — 자동 이름과 업무 이름이 로그 분석에서 어떤 차이를 만드는지 확인합니다.
  4. 기존 데이터 — ADD 제약 조건 전에 위반 행을 찾는 사전 진단 쿼리를 작성합니다.

NULL 후보 키와 삭제 연쇄의 의미

MySQL UNIQUE 인덱스는 NULL 허용 컬럼의 여러 NULL을 허용할 수 있습니다.

반드시 존재해야 하는 후보 키라면 NOT NULL을 함께 선언합니다.

FK의 NULL은 관계 미참여를 뜻하므로 선택 참여 요구와 맞는지 별도로 판단합니다.

CASCADE는 편리한 정리 기능이 아니라 수명 정책입니다.

회원 삭제가 수년치 게시글을 연쇄 삭제해야 하는지, RESTRICT 후 익명화·보존해야 하는지 감사 요구와 함께 결정합니다.

검사·정렬 규칙·가져오기 우회 경로

  • 검사 표현식에는 비결정 함수와 다른 행 조회를 넣을 수 없으므로 여러 행 규칙은 다른 구조가 필요합니다.
  • 문자열 FK는 두 컬럼의 타입·길이·정렬 규칙이 호환되어야 합니다.
  • 부모 키 변경을 ON UPDATE CASCADE로 허용하기 전에 대표 식별자가 정말 변경 가능한 자연 키인지 재검토합니다.
  • 대량 가져오기에서 FOREIGN_KEY_CHECKS를 끄는 방식은 검증 책임을 별도 일치 검사 단계로 옮긴다는 뜻입니다.

무결성 책임 위치 비교

선택얻는 효과감수할 비용적합한 조건
DB 제약모든 쓰기 경로의 최종 일관성DDL·오류 처리 결합항상 참인 불변식일 때
서비스 검증친절한 문맥 메시지경쟁 조건과 우회 경로사전 안내와 복잡한 정책일 때
검사 상태간단한 폐쇄 집합상태 추가 DDL상태가 작고 안정적일 때
코드 테이블 FK상태 메타데이터 확장조인·운영 복잡도상태가 동적으로 관리될 때

기존 테이블에 제약을 추가하는 순서

기존 대형 테이블에 제약을 추가하면 전체 검증과 잠금 비용이 생길 수 있습니다.

배포 전 위반 건수를 0으로 만들고 DDL 알고리즘·복제 지연을 확인합니다.

제약 오류율 급증은 버그나 규칙 불일치 신호입니다.

오류를 무조건 500으로 처리하지 말고 409·422 등 업무 응답과 제약 조건 이름 지표로 분류합니다.

제약은 한 구문의 불변식을 지키지만 여러 구문을 하나의 업무로 묶지는 못합니다.

다음 문서에서 포인트 이동의 두 UPDATE와 원장 INSERT를 트랜잭션으로 묶습니다.


무결성 규칙의 책임 위치

판단 축확인할 질문
불변식항상 참이어야 하는 규칙을 제약 문장으로 썼는가?
제약 종류PK·UNIQUE·FK·검사의 책임이 겹치지 않는가?
NULL필수 후보 키와 선택 관계의 NULL 정책이 분명한가?
생명주기부모 삭제·변경 동작을 업무 보존 정책으로 골랐는가?
오류제약 조건 이름과 SQLState를 서비스 오류로 매핑했는가?

DB가 현재 행·참조 키로 판단할 수 있고 항상 참이어야 하는 규칙은 제약으로 선언합니다.

사용자 안내를 위한 사전 검증은 중복해서 두되 최종 위반 가능성을 인정하고 DB 오류를 처리합니다.


연습 문제

한 회원은 같은 날짜에 같은 게시판에서 일일 게시 한도를 한 번만 설정할 수 있고 한도는 1~20건이어야 합니다.

member_daily_post_quota 테이블의 PK, UNIQUE, FK, 검사를 설계하고 각 위반 SQL을 하나씩 작성하세요.

해설과 예시 답안

대리 PK와 (member_id, quota_date, board_id) 후보 키를 분리합니다.

회원 FK와 게시 한도 검사를 이름 있게 선언합니다.

CREATE TABLE member_daily_post_quota (
  quota_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  member_id BIGINT UNSIGNED NOT NULL,
  board_id BIGINT UNSIGNED NOT NULL,
  quota_date DATE NOT NULL,
  max_posts TINYINT UNSIGNED NOT NULL,
  PRIMARY KEY (quota_id),
  CONSTRAINT uq_quota_member_date_board
    UNIQUE (member_id, quota_date, board_id),
  CONSTRAINT fk_quota_member
    FOREIGN KEY (member_id) REFERENCES members (id),
  CONSTRAINT chk_quota_posts CHECK (max_posts BETWEEN 1 AND 20)
) ENGINE = InnoDB;

NULL 허용 컬럼이 후보 키에 없는지, 중복·잘못된 부모·0건 한도가 각각 1062·1452·3819로 거절되는지 확인합니다.


핵심 정리

  • 타입과 애플리케이션 검증만으로 업무 무결성이 완성되지는 않습니다.
  • PK·UNIQUE·FK·검사·NOT NULL은 서로 다른 불변식을 지킵니다.
  • 제약 위반은 하나씩 재현하고 오류 발생 뒤 잘못된 행이 0인지 확인합니다.
  • 제약 이름과 삭제 정책은 운영 규칙의 일부입니다.

다음 문서에서는 여러 정상 구문이 중간 오류로 잘못된 업무 결과를 만드는 문제를 트랜잭션으로 해결합니다.