테이블과 제약
85분 안팎
학습 목표
PK·FK·UNIQUE·NOT NULL·CHECK로 데이터 규칙을 표현합니다.
개념
왜 애플리케이션 검사 외에 제약을 두나요
앞 모듈 POST는 빈 제목을 거부했습니다. 하지만 파일 이관 코드나 나중의 관리자 스크립트도 데이터를 저장합니다. 컨트롤러 한 곳의 검사만 믿으면 다른 입력 경로에서 빈 제목과 존재하지 않는 회원 대여가 들어갈 수 있습니다. 이번에는 DB가 거부해야 하는 규칙을 DDL로 적고 유효한 데이터가 실제로 저장되는지 확인합니다. 사용자 친화적인 오류 응답은 다음 모듈의 책임입니다.
브라우저는 SQLite에서 실행하고 미션은 H2 2.1.214에서 실행합니다. 공통 SQL 의미를 배우되 엔진 옵션을 섞지 않습니다. SQLite의 FK 검사는 연결마다 PRAGMA foreign_keys=ON으로 켜고 1인지 확인합니다. H2에 그 PRAGMA를 복사하지 않습니다. FK 선언과 검사 활성화가 같다고 가정하면 잘못된 자식 행이 저장되는 실험을 정상으로 오해할 수 있습니다.
어떤 값이 어떤 규칙을 만족해야 하나요
PK는 행을 식별하고 중복 키를 거부합니다. books.id는 파일의 양수 키를 그대로 사용하며 CHECK(id>0)를 추가합니다. UNIQUE는 email처럼 기본키 외에도 중복을 허용하지 않을 값에 둡니다. 같은 제목 실물이 여러 권이므로 title에 UNIQUE를 두지 않습니다. 모든 문자열에 유일성을 붙이면 정상 데이터를 오류로 바꾸게 됩니다.
NOT NULL은 값의 부재를 금지하지만 공백 문자열까지 금지하지 않습니다. title에는 NOT NULL과 length(trim(title))>0 CHECK를 함께 둡니다. trim은 이번 SQL에서 일반 공백을 다룹니다. 탭·개행·모든 유니코드 공백까지 Java isBlank와 완전히 같다고 주장하지 않습니다. H2에서는 CHAR_LENGTH(TRIM(title))를 사용하며 길이 200 제한도 있습니다. 경로별 검증 차이는 문서에 남깁니다.
참조와 시각 규칙을 표현합니다
loans.book_id는 books.id를, loans.member_id는 members.id를 참조합니다. FK만 두고 NULL을 허용하면 부모가 없는 선택 관계가 될 수 있으므로 이번 필수 관계는 NOT NULL도 둡니다. 데이터 입력 순서는 부모 도서·회원 다음 대여입니다. 삭제할 때는 이력 보존 정책에 따라 부모 삭제를 제한합니다. FK 오류를 없애려고 검사 옵션을 끄면 정합성 보장은 사라집니다.
loaned_at은 NOT NULL, returned_at은 NULL 허용입니다. CHECK(returned_at IS NULL OR returned_at>=loaned_at)로 미반납과 정상 반납을 허용합니다. NULL은 모르는 값이므로 CHECK 비교 하나만으로 필수 여부를 표현하려 하지 않습니다. NOT NULL과 CHECK의 역할을 분리합니다. 이번 브라우저 시각은 ISO 문자열로 같은 형식·시간대를 유지하고 H2에서는 TIMESTAMP 비교로 검증합니다.
DDL과 DML을 나눠 따라갑니다
CREATE TABLE은 구조를 만드는 DDL입니다. INSERT는 행을 추가하고 UPDATE는 기존 행 값을 바꾸며 SELECT는 저장 결과를 읽습니다. 스키마를 바꿨다고 이미 있던 데이터가 원하는 값으로 바뀌는 것은 아닙니다. 따라하기는 각 단계가 독립 DB에서 시작하도록 초기 SQL을 함께 제공합니다. 하나의 콘솔에서 반복하려면 새 메모리 DB로 초기화한 뒤 실행합니다.
명시적인 컬럼 목록을 INSERT에 쓰면 값 순서를 읽기 쉽습니다. 숫자 ID와 문자열 제목을 구별하고 SQL 문자열 안의 작은따옴표는 두 번 써서 표현합니다. 실제 Java 이관 코드는 PreparedStatement의 바인딩을 사용하므로 제목을 SQL 문장에 문자열로 이어 붙이지 않습니다. 이 모듈에서는 SQL 문법과 저장 규칙을 익히고 바인딩 구조는 미션 코드에서 확인합니다.
실패를 감추지 않고 메시지를 읽습니다
SQLite에서 UNIQUE constraint failed: books.id는 같은 ID가 이미 있다는 단서입니다. CHECK constraint failed는 빈 제목이나 양수 ID 같은 식이 거짓이라는 뜻입니다. FOREIGN KEY constraint failed는 참조 부모의 존재와 FK 옵션을 조사할 위치를 알려 줍니다. NOT NULL constraint failed는 필수 컬럼의 값이 없다는 뜻입니다. 메시지 하나로 요청자가 악의적이라고 판단하지 않습니다.
실패 명령은 정상 단계와 따로 실행하고 저장 행 수가 바뀌지 않았는지도 확인합니다. 미션 JUnit은 assertThrows로 실패를 기대하므로 오류 사례가 통과 기준이 됩니다. H2에서는 SQLState와 제약 이름을 함께 확인합니다. 23505는 유일성, 23506은 부모 참조, 23513은 CHECK, 23502는 NOT NULL 문제를 조사할 단서입니다. 정확한 문구는 엔진 버전마다 다를 수 있습니다.
브라우저 실습의 OR IGNORE를 제한해서 씁니다
실습은 raw_books(seq,id,title)에 정상·불량 후보를 제공합니다. books 테이블을 올바른 PK·NOT NULL·CHECK로 만들고 seq 순서로 INSERT OR IGNORE SELECT를 실행한 뒤 id 순으로 결과를 출력합니다. 동일 ID 후보에서는 앞 후보를 남기고 같은 제목의 다른 ID는 둘 다 남깁니다. 0·음수 ID, 빈 제목·일반 공백·NULL 제목은 제약으로 제외되어야 합니다.
OR IGNORE는 이 작은 정제 실험에서 PK·NOT NULL·CHECK 거부를 관찰하기 위한 SQLite 옵션입니다. 모든 오류를 무시하는 일반 이관 정책으로 쓰지 않습니다. 특히 FK 위반은 같은 방법으로 무시되지 않습니다. 실제 미션 이관은 오류가 나면 전체 이관을 롤백하고 원인을 확인합니다. 실습 결과에서 제외 행을 발견해도 운영 데이터를 조용히 버려도 된다는 결론을 내리지 않습니다.
정상과 경계를 함께 검증합니다
네 가지 이상의 독립 입력은 정상 두 권, 동일 제목 두 실물, 중복 ID, 공백과 NULL, 양수 범위 오류, 빈 입력을 포함합니다. 기대 행을 하드코딩하지 않고 입력 테이블의 후보를 처리합니다. starter에는 제목 CHECK와 NOT NULL이 빠져 일부 불량 행이 살아남습니다. 제약을 고치고 정상 데이터 보존과 불량 데이터 제외를 함께 확인합니다.
미션에서는 추가로 FK·email UNIQUE·대여 필수 시각·역전 반납 시각·참조 부모 삭제 실패를 검증합니다. constraints.sql을 별도로 만들기보다 제공된 schema.sql에 구조를 완성합니다. 이미 생성한 DB를 재사용하면 CREATE TABLE 중복 오류가 나므로 테스트마다 새 연결을 씁니다. 더 읽기에서는 자료형별 제약과 엔진 차이를 확장하고 여기서는 동아리 데이터 규칙의 증거를 제출합니다.
엔진 옵션 확인: SQLite 외래키 공식 문서에서 연결별 활성화와 필수 FK의 NOT NULL 의미를 확인할 수 있습니다.
따라하기
참조 검사를 켜고 구조를 확인합니다
새 SQLite 콘솔 또는 브라우저 SQL 실습을 초기화하고 아래 초기화 SQL부터 실행합니다.
PRAGMA foreign_keys=ON;
CREATE TABLE books(id INTEGER PRIMARY KEY CHECK(id>0),title TEXT NOT NULL CHECK(length(trim(title))>0));
CREATE TABLE members(id INTEGER PRIMARY KEY,name TEXT NOT NULL,email TEXT NOT NULL UNIQUE);
CREATE TABLE loans(id INTEGER PRIMARY KEY,book_id INTEGER NOT NULL REFERENCES books(id),member_id INTEGER NOT NULL REFERENCES members(id),loaned_at TEXT NOT NULL,returned_at TEXT CHECK(returned_at IS NULL OR returned_at>=loaned_at));
각 연결에서 FK를 켭니다. title의 notnull 값 1과 id의 pk 값 1을 읽습니다.
PRAGMA foreign_keys;
PRAGMA table_info(books);실행 결과
1 0 id INTEGER 0 NULL 1 1 title TEXT 1 NULL 0
중복 제목을 허용하고 제목을 수정합니다
새 SQLite 콘솔 또는 브라우저 SQL 실습을 초기화하고 아래 초기화 SQL부터 실행합니다.
PRAGMA foreign_keys=ON;
CREATE TABLE books(id INTEGER PRIMARY KEY CHECK(id>0),title TEXT NOT NULL CHECK(length(trim(title))>0));
CREATE TABLE members(id INTEGER PRIMARY KEY,name TEXT NOT NULL,email TEXT NOT NULL UNIQUE);
CREATE TABLE loans(id INTEGER PRIMARY KEY,book_id INTEGER NOT NULL REFERENCES books(id),member_id INTEGER NOT NULL REFERENCES members(id),loaned_at TEXT NOT NULL,returned_at TEXT CHECK(returned_at IS NULL OR returned_at>=loaned_at));
같은 제목의 다른 실물은 정상입니다. UPDATE는 지정 ID 한 권만 바꿉니다.
INSERT INTO books(id,title) VALUES(10,'Java'),(11,'Java');
UPDATE books SET title='Java-17' WHERE id=11;
SELECT id,title FROM books ORDER BY id;실행 결과
10 Java 11 Java-17
불량 후보를 제약으로 제외합니다
새 SQLite 콘솔 또는 브라우저 SQL 실습을 초기화하고 아래 초기화 SQL부터 실행합니다.
PRAGMA foreign_keys=ON;
CREATE TABLE books(id INTEGER PRIMARY KEY CHECK(id>0),title TEXT NOT NULL CHECK(length(trim(title))>0));
CREATE TABLE members(id INTEGER PRIMARY KEY,name TEXT NOT NULL,email TEXT NOT NULL UNIQUE);
CREATE TABLE loans(id INTEGER PRIMARY KEY,book_id INTEGER NOT NULL REFERENCES books(id),member_id INTEGER NOT NULL REFERENCES members(id),loaned_at TEXT NOT NULL,returned_at TEXT CHECK(returned_at IS NULL OR returned_at>=loaned_at));
이 관찰에만 OR IGNORE를 사용합니다. 정상 첫 행만 남는지 봅니다.
INSERT OR IGNORE INTO books VALUES(1,'SQL'),(1,'Other'),(2,' '),(3,NULL),(0,'Invalid');
SELECT id,title FROM books ORDER BY id;실행 결과
1 SQL
반납의 NULL과 정상 시각을 구별합니다
새 SQLite 콘솔 또는 브라우저 SQL 실습을 초기화하고 아래 초기화 SQL부터 실행합니다.
PRAGMA foreign_keys=ON;
CREATE TABLE books(id INTEGER PRIMARY KEY CHECK(id>0),title TEXT NOT NULL CHECK(length(trim(title))>0));
CREATE TABLE members(id INTEGER PRIMARY KEY,name TEXT NOT NULL,email TEXT NOT NULL UNIQUE);
CREATE TABLE loans(id INTEGER PRIMARY KEY,book_id INTEGER NOT NULL REFERENCES books(id),member_id INTEGER NOT NULL REFERENCES members(id),loaned_at TEXT NOT NULL,returned_at TEXT CHECK(returned_at IS NULL OR returned_at>=loaned_at));
INSERT INTO books VALUES(7,'SQL'),(10,'Java'),(11,'Java');
INSERT INTO members VALUES(1,'민지','m@example.test'),(2,'준','j@example.test'),(3,'민지','n@example.test');
INSERT INTO loans VALUES(1,7,1,'2026-10-01T10:00:00',NULL),(2,10,2,'2026-09-01T10:00:00','2026-09-02T10:00:00');
NULL은 이 모델의 미반납 상태이며 없는 회원을 뜻하지 않습니다.
SELECT id,CASE WHEN returned_at IS NULL THEN 'ACTIVE' ELSE 'RETURNED' END FROM loans ORDER BY id;실행 결과
1 ACTIVE 2 RETURNED
실패 메시지와 저장 불변을 확인합니다
Python 3 표준 sqlite3로 거부 사례를 한 번에 재현합니다. 아래 코드를 probe.py로 저장해 python3 probe.py로 실행합니다. 각 메시지를 제약 종류와 연결하고 실패 후 도서 1행·대여 0행을 확인합니다. 예상한 IntegrityError만 잡으며 다른 오류는 숨기지 않습니다.
import sqlite3
with sqlite3.connect(":memory:") as db:
db.executescript('PRAGMA foreign_keys=ON;\nCREATE TABLE books(id INTEGER PRIMARY KEY CHECK(id>0),title TEXT NOT NULL CHECK(length(trim(title))>0));\nCREATE TABLE members(id INTEGER PRIMARY KEY,name TEXT NOT NULL,email TEXT NOT NULL UNIQUE);\nCREATE TABLE loans(id INTEGER PRIMARY KEY,book_id INTEGER NOT NULL REFERENCES books(id),member_id INTEGER NOT NULL REFERENCES members(id),loaned_at TEXT NOT NULL,returned_at TEXT CHECK(returned_at IS NULL OR returned_at>=loaned_at));\n')
db.execute("INSERT INTO books VALUES(7,'SQL')")
db.execute("INSERT INTO members VALUES(1,'민지','m@example.test')")
cases = [
"INSERT INTO books VALUES(7,'Other')",
"INSERT INTO books VALUES(10,' ')",
"INSERT INTO books VALUES(11,NULL)",
"INSERT INTO loans VALUES(1,7,99,'2026-10-01T10:00:00',NULL)",
"INSERT INTO loans VALUES(2,7,1,'2026-10-02T10:00:00','2026-10-01T10:00:00')",
]
for sql in cases:
try:
db.execute(sql)
except sqlite3.IntegrityError as error:
print(str(error))
print("books", db.execute("SELECT COUNT(*) FROM books").fetchone()[0])
print("loans", db.execute("SELECT COUNT(*) FROM loans").fetchone()[0])
실행 결과
UNIQUE constraint failed: books.id CHECK constraint failed: length(trim(title))>0 NOT NULL constraint failed: books.title FOREIGN KEY constraint failed CHECK constraint failed: returned_at IS NULL OR returned_at>=loaned_at books 1 loans 0
확인 문제
실습
raw_books(seq,id,title)의 후보를 seq 순으로 처리합니다. books를 양수 ID PK, title NOT NULL 및 일반 공백 제거 후 길이 CHECK로 생성합니다. INSERT OR IGNORE로 후보를 넣고 id,title을 ID 오름차순으로 출력합니다. 동일 ID는 먼저 온 행을 유지하고 같은 제목 다른 ID는 보존합니다. 불량 후보 제외는 학습 관찰이며 실제 이관은 실패 원인을 기록하고 롤백합니다.
모범 답안
CREATE TABLE books(id INTEGER PRIMARY KEY CHECK(id>0), title TEXT NOT NULL CHECK(length(trim(title))>0)); INSERT OR IGNORE INTO books(id,title) SELECT id,title FROM raw_books ORDER BY seq; SELECT id,title FROM books ORDER BY id;
더 읽기
면접 질문
- 도서와 대여 기록의 테이블 관계를 설명합니다.