제약 조건 오류 진단
NOT NULL과 UNIQUE, 외래 키, CHECK가 낸 오류를 번호와 SQLSTATE, 제약의 이름으로 구별하고, 다시 시도하면 풀리는 오류인지 입력이나 쓰는 순서를 고쳐야 하는 오류인지 가립니다. 제약을 끄지 않고 올바른 값과 순서로 다시 넣는 방법과 현재 데이터 전체를 진단하는 쿼리를 다룹니다.
제약 오류는 데이터베이스가 귀찮게 막는 예외가 아니라, 정해 둔 데이터 규칙이 제대로 작동했다는 신호입니다. 모든 오류를 "저장 실패" 하나로 묶으면 사용자의 입력과 중복 요청, 부모를 넣는 순서, 상태의 조합 가운데 무엇을 고쳐야 하는지 알 수 없습니다. 오류를 읽을 때는 오류 번호와 제약의 이름을 보고, 그것을 업무의 원인과 이어야 합니다.
이 글에서는 회원과 게시글의 NOT NULL, 이메일의 UNIQUE, 작성자의 외래 키, 본문의 CHECK를 하나씩 일부러 어겨 봅니다. InnoDB에서 IGNORE 없이 실행한 문장은 제약에 걸리면 행을 남기지 않지만, AUTO_INCREMENT의 번호는 빌 수 있습니다. 마지막으로 원인을 고친 정상 게시글을 넣고 현재 데이터 전체를 진단합니다.
같은 오류처럼 다루면 생기는 일
필수인 이메일이 없는 요청, 이미 있는 이메일, 없는 회원을 가리키는 게시글, 공백뿐인 본문을 모두 일시적인 장애처럼 다시 실행한다고 해 봅시다. 입력과 참조하는 데이터가 그대로라면 재시도는 같은 위반을 되풀이할 뿐입니다. 반대로 UNIQUE 충돌을 무조건 성공으로 처리하면, 다른 사용자가 만든 행을 자기 요청의 결과로 잘못 알 수 있습니다.
다음 네 문장은 각각 다른 제약을 어깁니다. 한 문장씩 실행하며 오류를 확인합니다.
-- NOT NULL: 필수 값이 없다
INSERT INTO members (email, password_hash, name)
VALUES (NULL, '{demo}hash', '필수값 누락');
-- UNIQUE: 업무상의 키가 겹친다
INSERT INTO members (email, password_hash, name)
VALUES ('[email protected]', '{demo}other-hash', '중복 사용자');
-- 외래 키: 부모가 없다
INSERT INTO posts (
author_id, title, content, status, created_at, updated_at, request_key
) VALUES (
999999, '고아 게시글', '존재하지 않는 작성자를 참조합니다.', 'DRAFT',
'2026-07-07 00:00:00', '2026-07-07 00:00:00', 'orphan-001'
);
-- CHECK: 허용하는 범위 밖의 값이다
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, request_key
) VALUES (
1, '본문 오류', ' ', 'DRAFT',
0, '2026-07-07 01:00:00', '2026-07-07 01:00:00', 'invalid-content-001'
);앞 장까지의 실습으로 회원 1인 [email protected]이 있고, id가 999999인 회원과 이 예제의 요청 키는 없다고 전제합니다. 네 문장의 오류는 다음과 같습니다. 1452의 괄호 안에는 실패한 테이블과 외래 키의 정의가 길게 이어지므로, 아래에서는 앞부분만 남기고 줄였습니다.
ERROR 1048 (23000): Column 'email' cannot be null
ERROR 1062 (23000): Duplicate entry '[email protected]' for key 'members.uq_members_email'
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`board_lab`.`posts`, CONSTRAINT `fk_posts_author` ...)
ERROR 3819 (HY000): Check constraint 'ck_posts_content' is violated.예제를 반복했거나 다른 데이터를 더했다면 먼저 걸리는 규칙이 달라질 수 있습니다. 대화형 mysql 클라이언트는 오류가 난 뒤에도 다음 문장을 받지만, 파일을 입력으로 넘겨 실행하면 첫 오류에서 멈추는 것이 기본입니다. 자동 커밋이 켜져 있으므로 각 문장은 따로 확정되고, 실패한 문장은 행을 남기지 않으며 앞에서 성공한 문장에도 영향을 주지 않습니다.
| 오류 | SQLSTATE | 어긴 규칙 | 살펴볼 곳 |
|---|---|---|---|
| 1048 | 23000 | email의 NOT NULL | 필수 값을 확인하는 입력 검증 |
| 1062 | 23000 | uq_members_email | 이미 있는 회원인지, 같은 요청의 재시도인지 |
| 1452 | 23000 | fk_posts_author | 부모를 먼저 만드는 순서, 잘못된 참조 |
| 3819 | HY000 | ck_posts_content | 본문의 값 |
오류 번호만 기록하지 말고, 제약의 이름과 요청 키, 개인정보를 뺀 입력의 요약을 함께 로그에 남깁니다. 1062의 메시지에는 겹친 값인 이메일이 그대로 들어 있으므로, 메시지를 그대로 로그에 남기면 개인정보도 함께 남는다는 점에 주의합니다.
CONSTRAINT · NEXT ACTION
오류 번호 다음에 위반한 규칙과 입력을 찾는다
네 오류는 수정할 대상이 다릅니다. 동일한 입력을 반복하기 전에 제약 이름, 입력값, 참조 대상을 대조합니다.
| 오류와 의미 | 먼저 확인할 입력 | 다음 행동 |
|---|---|---|
| 1048 · 필수값 | NULL인 필수 열 | 필수 값을 제공하거나 누락 원인을 수정합니다. |
| 1062 · 중복 키 | 충돌한 UNIQUE와 기존 행 | 같은 업무 요청인지 비교한 뒤 충돌 정책을 적용합니다. |
| 1452 · 부모 없음 | 자식 FK 값과 참조 대상 | 부모의 존재·저장 순서·잘못된 키를 확인합니다. |
| 3819 · 검사 위반 | 실패한 CHECK 이름과 행의 값 | 해당 범위나 열 조합을 수정합니다. |
- 1048 · 필수값
- 먼저 확인할 입력:
NULL인 필수 열다음 행동: 필수 값을 제공하거나 누락 원인을 수정합니다. - 1062 · 중복 키
- 먼저 확인할 입력: 충돌한 UNIQUE와 기존 행다음 행동: 같은 업무 요청인지 비교한 뒤 충돌 정책을 적용합니다.
- 1452 · 부모 없음
- 먼저 확인할 입력: 자식 FK 값과 참조 대상다음 행동: 부모의 존재·저장 순서·잘못된 키를 확인합니다.
- 3819 · 검사 위반
- 먼저 확인할 입력: 실패한 CHECK 이름과 행의 값다음 행동: 해당 범위나 열 조합을 수정합니다.
입력과 참조 데이터가 그대로라면 맹목적인 재시도로 해결되지 않습니다. 여러 규칙을 동시에 어긴 행에서는 먼저 보고된 제약부터 확인합니다.
오류를 도메인의 결과로 바꾼다
오류 하나를 없애려고 제약을 끄지 않습니다. 고쳐야 하는 것은 데이터를 만드는 순서와 입력입니다. 부모를 먼저 만든 뒤 자식을 넣고, 중복 요청이라면 이미 있는 행이 같은 업무 요청인지 요청 키로 확인합니다.
제약이라는 개념은 표준 SQL에 있지만, 오류 번호와 메시지의 형식은 MySQL의 규칙입니다. 메시지는 버전에 따라 바뀌기도 해서, MySQL 8.0.19부터는 중복 키 오류의 키 이름 앞에 테이블 이름이 붙었습니다. 그러니 애플리케이션은 메시지를 문자열로 해석하기보다 오류 번호와 SQLSTATE, 제약의 이름을 쓰고, 버전을 올릴 때 이 대응이 그대로인지 확인합니다.
데이터베이스의 오류는 애플리케이션에서 안정적인 도메인 오류로 바꿉니다. CHECK 위반은 HTTP 400, UNIQUE 충돌은 409처럼 바꿀 수 있지만, 모든 중복이 같은 뜻은 아닙니다. 같은 요청의 재시도라면 앞서 만든 결과를 돌려주면 되고, 다른 사람이 이미 쓰는 이메일이라면 가입을 거절해야 합니다. INSERT IGNORE나 ON DUPLICATE KEY UPDATE도 오류를 없애는 만능 도구가 아니며, 충돌했을 때 무엇을 할지 정한 정책이 있을 때만 씁니다.
부모를 확인하고 자식을 넣는다
정상 게시글은 기존 회원을 이메일로 찾아 그 회원의 글로 넣습니다. 같은 회원을 새로 만들지 않고 이미 있는 행을 참조하며, 오류를 숨기는 IGNORE는 쓰지 않습니다.
SELECT id, email, status
FROM members
WHERE email = '[email protected]'
AND status = 'ACTIVE';
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, published_at, request_key
) VALUES (
(SELECT id FROM members WHERE email = '[email protected]'),
'제약 조건 복습', '제약 조건이 지키는 범위를 게시글로 정리합니다.',
'PUBLISHED', 50,
'2026-07-07 02:00:00', '2026-07-07 02:50:00',
'2026-07-07 02:50:00', 'post-min-constraint-001'
);
SELECT id, author_id, title, view_count, status, version
FROM posts
WHERE title = '제약 조건 복습';사전 조회는 활성 회원 한 명을 보여 주고, INSERT 뒤의 조회는 새 게시글 한 행을 보여 줍니다. 게시글의 id는 앞에서 실패한 INSERT가 번호를 배정받았는지에 따라서도 달라질 수 있으므로 결과에서 뺐습니다.
id | email | status
1 | [email protected] | ACTIVE
author_id | title | view_count | status | version
1 | 제약 조건 복습 | 50 | PUBLISHED | 0사전 조회의 ACTIVE 조건은 INSERT 안의 이메일 서브쿼리에 들어 있지 않습니다. 그래서 사전 조회가 0행이어도 뒤의 INSERT가 저절로 멈추지는 않으며, 0행을 보고 멈추는 것은 실행하는 사람이나 애플리케이션의 몫입니다.
SELECT와 INSERT 사이에 부모의 상태가 바뀔 수도 있으므로, 애플리케이션에서는 트랜잭션과 권한 검사를 함께 설계합니다. 외래 키는 부모 행이 있다는 것만 보장하고, 그 회원이 활성 상태인지까지 강제하지는 않습니다. "활성 회원만 글을 쓸 수 있다"는 규칙은 서비스의 트랜잭션이나 더 정교한 모델이 지켜야 하며, 오류를 처리할 때도 데이터베이스의 제약이 보장하는 것과 보장하지 않는 것을 구별합니다.
현재 데이터 전체를 진단한다
정상으로 넣은 뒤에는 고아 행과 범위, 상태의 조합, 이메일의 중복이 없는지 현재 데이터를 진단합니다. 제약이 이미 막고 있더라도, 마이그레이션이나 제약을 우회한 가져오기, 복구한 데이터를 확인할 때 쓸 기준 쿼리로 남겨 둡니다.
SELECT
(SELECT COUNT(*)
FROM members
GROUP BY email
HAVING COUNT(*) > 1
LIMIT 1) AS duplicate_email_group,
(SELECT COUNT(*)
FROM posts AS p
LEFT JOIN members AS m ON m.id = p.author_id
WHERE m.id IS NULL) AS orphan_posts,
(SELECT COUNT(*) FROM posts
WHERE CHAR_LENGTH(TRIM(title)) NOT BETWEEN 1 AND 80
OR CHAR_LENGTH(TRIM(content)) NOT BETWEEN 1 AND 5000) AS range_violations,
(SELECT COUNT(*) FROM posts
WHERE (status = 'PUBLISHED' AND published_at IS NULL)
OR (status = 'DRAFT' AND published_at IS NOT NULL))
AS state_pair_violations;duplicate_email_group | orphan_posts | range_violations | state_pair_violations
NULL | 0 | 0 | 0duplicate_email_group은 중복된 이메일 그룹이 없으면 스칼라 서브쿼리가 행을 돌려주지 않으므로 NULL입니다. 중복이 있을 때의 값도 LIMIT 1로 고른 한 그룹의 행 수일 뿐, 중복된 그룹이 모두 몇 개인지는 아닙니다. 나머지 세 값은 0이어야 하며, 이런 진단 쿼리에는 NULL이 무엇을 뜻하는지도 함께 적어 둡니다.
애플리케이션의 검증과 제약이 맡는 일
애플리케이션의 검증과 데이터베이스의 제약은 같은 일을 두 번 하는 것이 아니라, 맡는 일이 다릅니다. 애플리케이션은 빠르고 친절한 오류를 보여 줄 수 있지만, 동시에 들어온 두 요청 사이에서 이메일이 겹치는지는 데이터베이스의 UNIQUE만 최종적으로 판단할 수 있습니다. 내부의 버그로 생긴 오류와 사용자가 고칠 수 있는 입력의 오류도 나누어 응답합니다.
제약마다 알아 둘 동작도 있습니다. NULL을 허용하는 열의 UNIQUE는 NULL을 여러 개 받아들이고, 정렬 규칙(collation)에 따라 대소문자나 악센트만 다른 값을 같은 값으로 비교합니다. 이 교재가 쓰는 utf8mb4_0900_ai_ci에서는 [email protected]도 [email protected]과 겹치는 값으로 거부됩니다. CHECK가 NULL 때문에 알 수 없음이 된 식을 통과시킨다는 것은 1장 4편에서 보았습니다.
외래 키는 같은 트랜잭션 안에서 부모를 먼저 넣으면, 커밋하기 전에도 그 부모를 참조할 수 있습니다. 그러나 InnoDB는 외래 키를 행을 바꿀 때 바로 검사하고, 커밋할 때까지 검사를 미루는 지연 제약(deferred constraint)을 지원하지 않으므로 넣는 순서가 중요합니다. 여러 행을 넣는 문장에서 한 행이 실패한 경우나 트리거가 낸 오류도 따로 구별합니다.
운영에서 오류를 읽는 법
오류율은 제약의 이름처럼 종류가 정해진 값으로 집계하고, 이메일이나 id처럼 값마다 다른 것을 지표의 이름표로 쓰지 않습니다. 갑자기 1452가 늘면 배포의 순서나 이벤트의 지연, 잘못된 스키마에 연결한 것은 아닌지 살피고, 1062가 늘면 재시도와 멱등성의 문제를 봅니다.
급하다고 제약을 끄기 전에 쓰기를 멈추고 원인을 고칠 수 있는지, 거부된 데이터를 따로 모아 둘 테이블과 복구 계획이 있는지부터 검토합니다. 대량 가져오기라면 거부된 행을 배치 번호와 함께 따로 모아 고친 뒤 다시 처리합니다. 제약의 이름을 안정적인 스키마 규칙으로 관리하면, 애플리케이션과 운영 대시보드가 같은 원인을 같은 이름으로 말할 수 있습니다.
| 따져 볼 것 | 질문 |
|---|---|
| 종류 | NOT NULL, UNIQUE, 외래 키, CHECK 가운데 어느 규칙인가 |
| 지속성 | 같은 입력으로 다시 시도하면 풀리는 일시적인 오류인가 |
| 원인 | 사용자의 입력, 쓰는 순서, 애플리케이션의 버그 가운데 무엇이고, 누가 고치는가 |
| 노출 | 클라이언트에는 안정적인 도메인 오류를, 로그에는 제약의 이름을 남기는가 |
| 복구 | 제약을 끄지 않고 올바른 데이터와 순서로 다시 처리하는가 |
오류 메시지를 지우고 재시도하지 않습니다. 제약의 이름과 어긴 키를 찾아, 사용자가 고칠 수 있는 입력인지 시스템의 결함인지 나눈 뒤 각각에 맞는 다음 행동을 고릅니다. 이 분류는 8장 1편의 무결성 제약과 8장 2편의 재시도와 멱등성으로 이어지고, 운영 중에 필수 열을 더하는 절차는 2장 1편에서 다시 볼 수 있습니다.
정리
- NOT NULL과 UNIQUE, 외래 키, CHECK는 서로 다른 규칙을 지키며, 오류 번호와 제약의 이름으로 구별합니다.
- 입력이 그대로인 재시도는 같은 위반을 되풀이하므로, 영구적인 입력 오류를 재시도하거나 제약을 꺼서 해결하지 않습니다.
- 애플리케이션은 메시지 문자열 대신 오류 번호와 SQLSTATE, 제약의 이름을 써서 안정적인 도메인 오류로 바꿉니다.
- 외래 키는 부모가 있다는 것만 보장하므로, 활성 회원만 쓸 수 있다는 같은 업무 규칙은 서비스가 따로 지킵니다.
- 애플리케이션의 검증은 빠른 안내를, 데이터베이스의 제약은 동시에 들어온 요청 사이의 최종 판단을 맡습니다.
연습 문제
1. 게시 시각이 빠진 공개 글
2장 2편에서 넣은 [email protected]의 글로, 조회수가 50회인 PUBLISHED 게시글을 고유한 request_key로 넣어 보세요. 먼저 published_at을 NULL로 두어 오류를 확인한 뒤 올바르게 고칩니다. 뒤의 장에서 본문과 같은 결과를 보려면 트랜잭션 안에서 실행하고 ROLLBACK합니다.
해설 보기
부모는 UNIQUE인 이메일로 찾는 서브쿼리로 정하고, PUBLISHED 상태에는 NULL이 아닌 published_at을 함께 넣습니다.
START TRANSACTION;
-- 실패: ck_posts_publication_pair
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, published_at, request_key
) VALUES (
(SELECT id FROM members WHERE email = '[email protected]'),
'제약 진단 연습', '게시 시각 조합을 확인합니다.', 'PUBLISHED', 50,
'2026-07-08 01:00:00', '2026-07-08 01:00:00', NULL, 'post-sora-check-001'
);
-- 고친 문장
INSERT INTO posts (
author_id, title, content, status, view_count,
created_at, updated_at, published_at, request_key
) VALUES (
(SELECT id FROM members WHERE email = '[email protected]'),
'제약 진단 연습', '게시 시각 조합을 확인합니다.', 'PUBLISHED', 50,
'2026-07-08 01:00:00', '2026-07-08 01:50:00',
'2026-07-08 01:50:00', 'post-sora-check-001'
);
ROLLBACK;첫 문장은 오류 3819로 거부되고, 메시지에 적힌 제약은 ck_posts_publication_pair입니다. PUBLISHED인데 게시 시각이 NULL이어서 이 제약의 세 조건이 모두 거짓이 되기 때문입니다. 첫 문장이 실패해 행을 남기지 않았으므로 둘째 문장은 같은 요청 키로도 저장됩니다. 제목은 UNIQUE가 아니어서 다른 게시글과 겹쳐도 저장된다는 점도 함께 확인합니다. 마지막의 ROLLBACK은 둘째 문장이 저장한 행도 되돌리므로, 뒤의 장에서 쓰는 posts의 행은 그대로입니다.
2. 데이터베이스의 성공과 업무의 허용
[email protected] 회원은 있지만 상태가 SUSPENDED라고 가정해 보세요. 새 게시글의 요청 키는 겹치지 않고 나머지 값과 제약도 모두 정상입니다. 본문의 ACTIVE 사전 조회가 0행을 돌려준 뒤에도 INSERT를 계속 실행하면, 외래 키가 비활성 회원이라는 이유로 거부할까요?
해설 보기
거부하지 않습니다. 외래 키는 회원 행이 있는지만 확인하므로 SUSPENDED라는 이유로는 막지 않고, INSERT 안의 이메일 서브쿼리에도 ACTIVE 조건이 없어서 그 회원의 id를 그대로 얻습니다. 따라서 데이터베이스에 저장되었다는 것이 업무상 글쓰기가 허용되었다는 뜻은 아닙니다. 활성 회원만 쓸 수 있다는 규칙이 있다면, 사전 조회의 0행을 확인한 쪽이 쓰기를 멈추고, 조회와 쓰기 사이에 상태가 바뀌는 경쟁도 처리해야 합니다.
3. 오류를 하나씩 없애기
본문의 네 문장을 하나씩 고쳐 다시 실행해 보세요. 작성자를 실제 회원으로 바꾸면 1452가 사라지는지, 공백뿐인 본문을 실제 문장으로 바꾸면 3819가 사라지는지, 중복된 이메일은 이름을 바꿔도 계속 거부되는지 확인하고 이유를 설명합니다. 고친 문장도 트랜잭션 안에서 실행하고 ROLLBACK으로 되돌립니다.
해설 보기
1048은 이메일에 값을 넣으면 사라지지만, 그 값이 이미 있는 이메일이면 이번에는 1062가 납니다. 1062는 UNIQUE가 이메일 열에 걸려 있으므로 이름이나 비밀번호 해시를 바꿔도 사라지지 않고, 이메일이 달라야 합니다. 1452는 author_id를 (SELECT id FROM members WHERE email = '[email protected]')처럼 실제 회원으로 바꾸면 사라집니다. id를 999999로 정해 회원을 직접 만들어도 오류는 사라지지만, 그러면 members의 AUTO_INCREMENT가 그 다음 값으로 뛰므로 이 실습에서는 쓰지 않습니다.
3819는 본문에 공백이 아닌 글자가 들어가면 사라집니다. 한 문장이 여러 규칙을 함께 어기면 하나를 고친 뒤 다른 오류가 이어서 나타날 수 있으므로, 오류가 바뀔 때마다 제약의 이름을 다시 읽습니다.
다음 글 SELECT 기본 조회에서는 결과를 누구의 환경에서나 같게 하려고 따로 만든 실습 테이블 query_post_demo로, 필요한 열만 골라 조회하는 방법부터 다룹니다.