데이터베이스 학습 노트 목차

제약·트리거·뷰 — DB가 직접 지키는 규칙과 가상 테이블

왜 규칙을 DB에 두는가 — 마지막 방어선

"음수 재고는 없다", "없는 회원의 주문은 없다" 같은 규칙은 애플리케이션 코드에서 검사하면 되지 않나? 싶다. 문제는 — 애플리케이션은 깜빡하고, 여러 앱·배치·관리자 콘솔이 같은 DB를 건드리며, 동시 요청이 검사를 비집고 들어온다. 앱에서 "재고 > 0인지 확인 후 차감"해도, 두 요청이 동시에 확인을 통과하면 둘 다 차감해 음수가 된다([04 Lost Update]). 그래서 진짜 중요한 규칙은 DB 자신이 못 박아야 한다 — **제약(constraint)**으로 값의 규칙을, **트리거(trigger)**로 변경에 반응하는 동작을, **뷰(view)**로 복잡한 쿼리·보안을 캡슐화한다. 이들은 애플리케이션이 무엇을 하든 DB가 지키는 마지막 방어선이다. (출처: PostgreSQL — Constraints/Triggers/Views / MySQL 8.4.)

제약 — 값의 규칙을 못 박는다

CREATE TABLE orders (
  id         bigint PRIMARY KEY,                                        -- 개체 무결성(유일+NOT NULL)
  member_id  bigint NOT NULL REFERENCES member(id) ON DELETE RESTRICT,  -- 참조 무결성(FK)
  status     text NOT NULL DEFAULT 'pending',                           -- 필수값 + 기본값
  amount     numeric(12,2) NOT NULL CHECK (amount >= 0),                 -- 도메인 무결성(CHECK)
  coupon     text UNIQUE,                                                -- 중복 금지
  CONSTRAINT valid_status CHECK (status IN ('pending','paid','shipped')) -- 명명 제약
);

각 제약이 무엇을 보장하는지 정확히 알아야 한다.

  • PRIMARY KEY유일 + NOT NULL. "식별할 수 없는 행"을 금지한다([01 개체 무결성]).
  • FOREIGN KEY참조 무결성. member_id반드시 존재하는 member를 가리키거나 NULL이어야 한다 — 없는 회원의 주문을 원천 차단한다. 그리고 ON DELETE부모가 사라질 때를 정한다(아래).
  • UNIQUE중복 금지(여러 칼럼 복합도 가능). 주의 — NULL은 보통 서로 다르다고 보아 여럿 허용된다(쿠폰을 안 쓴 주문이 여럿 NULL이어도 OK).
  • CHECK임의 조건(범위·열거·칼럼 간 관계). PostgreSQL은 오래전부터 강력하고, MySQL도 8.0.16+부터 실제로 강제한다(그 전엔 무시됐다).
  • NOT NULL / DEFAULT — 필수값 / 미지정 시 기본값.

ON DELETE의 선택이 연쇄 사고를 가른다. RESTRICT(자식이 있으면 부모 삭제 거부, 기본), CASCADE(부모 삭제 시 자식도 함께 삭제), SET NULL(자식의 FK를 NULL로). CASCADE는 편하지만 — 회원 하나 지웠는데 주문·결제·로그가 줄줄이 사라질 수 있어 신중해야 한다. *명명 제약(CONSTRAINT name)*은 에러 메시지를 알아보기 쉽게 하고 나중에 ALTER로 떼기도 쉽다. PostgreSQL의 *지연 제약(DEFERRABLE)*은 검사를 트랜잭션 끝으로 미뤄 순환 FK 삽입 같은 경우를 푼다.

뷰 — 복잡한 쿼리를 가상 테이블로

**뷰(view)**는 저장된 SELECT물리 데이터를 복제하는 게 아니라, 쿼리에 이름을 붙인 가상 테이블이다. 쓰임이 둘이다. 하나는 복잡한 조인·집계를 감춰 사용 측을 단순하게(매번 긴 쿼리를 안 써도 됨), 다른 하나는 보안 — 특정 칼럼·행만 노출하는 것이다.

-- 보안: 민감 칼럼(email·주민번호)을 빼고 활성 회원만 노출
CREATE VIEW active_member AS
  SELECT id, name FROM member WHERE status = 'active';
SELECT * FROM active_member;   -- 테이블처럼 쓰되, 노출된 것만 보임

특정 역할에게 원본 테이블 대신 뷰에만 권한을 주면 — 그 역할은 email을 아예 볼 수 없다([17 보안]의 컬럼 통제). 두 가지 더 — 단순 뷰(단일 테이블·집계 없음)는 INSERT/UPDATE도 가능하고(WITH CHECK OPTION으로 뷰 조건을 어기는 변경을 막는다), PostgreSQL의 **구체화 뷰(materialized view)**는 결과를 실제로 저장해 무거운 집계를 캐싱한다(REFRESH로 갱신 — 최신성↔성능 트레이드오프). MySQL엔 구체화 뷰가 없어 요약 테이블 + 트리거/배치로 흉내 낸다.

트리거 — 변경에 반응하는 자동 동작

트리거INSERT/UPDATE/DELETE가 일어날 때 DB가 자동으로 실행하는 로직이다 — 감사 로그·파생 값 계산·복잡한 무결성에 쓴다.

다이어그램 로딩 중…
-- PostgreSQL: 트리거 '함수'를 만들고 트리거로 연결 (수정 시각 자동 기록)
CREATE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;          -- BEFORE라 NEW를 바꿔 저장
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_updated BEFORE UPDATE ON orders
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

-- MySQL: 트리거 본문을 직접 작성 (상태 변경을 감사 테이블에 기록)
CREATE TRIGGER trg_audit AFTER UPDATE ON orders FOR EACH ROW
  INSERT INTO order_audit(order_id, old_status, new_status, changed_at)
  VALUES (OLD.id, OLD.status, NEW.status, NOW());

세 가지 축을 안다. 타이밍BEFORE(변경 , NEW를 수정·검증)·AFTER(변경 , 로그·파생)·PostgreSQL의 INSTEAD OF(뷰에 대한 변경을 가로챔). 레벨FOR EACH ROW(영향받는 행마다)·PostgreSQL의 FOR EACH STATEMENT(문장당 1회). NEW/OLD — 새/옛 행 값을 참조한다.

트리거의 양날 — 강력하지만 **"숨은 로직"**이다. 애플리케이션 코드에는 안 보이는데 DB가 몰래 동작하니, 왜 이 값이 바뀌었는지 추적이 어렵고, 트리거가 또 다른 트리거를 부르는 연쇄·성능 문제가 생긴다. 그래서 updated_at 자동 기록·감사 로그처럼 명확하고 단순한 용도에만 쓰고, *복잡한 비즈니스 로직은 애플리케이션(또는 명시적 코드)*에 두는 게 정석이다.

저장 프로시저·함수

DB 안에 로직을 함수로 둘 수도 있다 — PostgreSQL의 PL/pgSQL, MySQL의 stored procedure/function. DB 가까이서 대량 데이터를 처리할 땐 네트워크 왕복을 줄여 유리하지만 — 버전 관리(Git)·테스트·이식성이 약하고 디버깅이 어려워 과용은 피한다. 단순한 파생 값은 트리거 대신 *생성 칼럼(GENERATED ALWAYS AS)*으로 더 깔끔하게 처리한다([06·08]).

정리 — 규칙은 DB에, 로직은 신중히

  • 제약값의 규칙(PK·FK·UNIQUE·CHECK·NOT NULL)을 DB가 강제하는 애플리케이션 버그·동시성의 마지막 방어선. ON DELETE로 부모 삭제 시 동작(RESTRICT/CASCADE/SET NULL)을 정한다.
  • 복잡 쿼리·보안(컬럼/행 제한)을 가상 테이블로 캡슐화. 구체화 뷰는 집계 캐싱(최신성↔성능).
  • 트리거 — *변경에 자동 반응(감사·파생·updated_at)*하되, "숨은 로직"이라 단순·명확한 용도에만 쓰고 복잡 로직은 앱에.

한 줄 요약 — 제약: PK(유일+NOT NULL)·FK(참조 무결성·ON DELETE RESTRICT/CASCADE/SET NULL)·UNIQUE(NULL은 여럿)·CHECK(MySQL 8.0.16+ 강제)·NOT NULL/DEFAULT·명명/DEFERRABLE — 동시성에도 뚫리지 않는 마지막 방어선. 뷰=저장된 SELECT(보안·캡슐화, 단순 뷰는 갱신, materialized view는 집계 캐싱). 트리거=DML에 자동 반응(BEFORE 검증/AFTER 로그·NEW/OLD)·숨은 로직이라 감사/updated_at만. 복잡 로직·대량 처리는 신중히(앱 또는 생성 칼럼).

(출처: PostgreSQL — Constraints / Views / Triggers / PL/pgSQL / MySQL 8.4 — CHECK / CREATE VIEW / CREATE TRIGGER.)

SQL 쿼리문 — SELECT·조인·서브쿼리·집계·윈도우·CTE정규화 — 이상현상·함수종속·1NF~BCNF·반정규화