제약조건과 DEFAULT: 데이터의 의미는 어디에서 결정되는가
제약조건과 기본값이 데이터 의미를 규정하는 방식과 이관 시 검증 포인트를 정리합니다.
앞 글에서 객체가 어디에 어떻게 놓이는지 — 저장구조를 다뤘습니다. 이제 그 객체에 데이터가 들어오고 변할 때 따라야 할 규칙으로 넘어옵니다. 제약조건과 DEFAULT입니다.
DDL 변환은 대부분 성공합니다. 제약조건과 DEFAULT도 문제없이 생성됩니다. 그런데 운영에 들어가면 데이터가 달라집니다.
제약조건은 단순히 데이터를 막는 장치가 아니라, 데이터의 의미를 강제하는 규칙입니다. 제약조건 이관이 어려운 이유는 구문의 복잡함이 아닙니다. 정의는 복사되지만, 그 정의가 데이터를 통제하던 방식은 복사되지 않는다는 점입니다. Oracle에서 당연하게 작동하던 규칙이 오픈소스에서는 다르게 작동하거나, 아예 작동하지 않습니다. 그리고 그 차이는 DDL이 아니라 데이터 안에서 드러납니다.
이 글은 양방향, 즉 Oracle의 제약조건·DEFAULT 패턴을 오픈소스로 옮기는 방향과, 반대로 오픈소스에서 운영되던 규칙을 Oracle로 옮기는 방향을 모두 다룹니다.
세 DB의 제약조건·DEFAULT 모델
| 항목 | Oracle | PostgreSQL | MySQL |
|---|---|---|---|
| DEFAULT (컬럼 생략 시) | 지원 | 지원 | 지원 |
| 명시 NULL 가로채기 | DEFAULT ON NULL (12c+) | 미지원 (일반 DEFAULT는 명시 NULL을 가로채지 않음; trigger로만 재현) | 미지원 |
| 표현식 DEFAULT (함수/식) | 함수 호출 자유 | 함수 호출 자유 | 8.0.13+ 표현식 DEFAULT 지원 |
| 갱신 시각 자동 갱신 | trigger | trigger | ON UPDATE CURRENT_TIMESTAMP 컬럼 속성 |
| 시간 함수 평가 시점 | SYSDATE/SYSTIMESTAMP 호출 시점의 현재 시각 | now() 트랜잭션 시작 시점 / clock_timestamp() 호출 순간 | NOW()/CURRENT_TIMESTAMP statement 시작 시점 |
| CHECK 상태 | ENABLE/DISABLE × VALIDATE/NOVALIDATE 4조합 | 상시 활성, 적재 시 NOT VALID로 검증 유예 가능 | 8.0.16+ 정식 지원(이전엔 파싱만) |
| UNIQUE에서의 NULL | NULL 중복 허용 (NULL은 중복으로 보지 않음) | NULL 중복 허용 (기본 NULLS DISTINCT); 15+ UNIQUE NULLS NOT DISTINCT 옵션 | NULL 중복 허용 |
| FK 동작 옵션 | CASCADE/SET NULL/NO ACTION (SET DEFAULT 미지원) | CASCADE/SET NULL/SET DEFAULT/RESTRICT/NO ACTION | CASCADE/SET NULL/RESTRICT/NO ACTION (SET DEFAULT 파싱은 되나 무시) |
| FK 검증 시점 | IMMEDIATE / DEFERRABLE INITIALLY DEFERRED | 지원하지만 DDL에 DEFERRABLE 절 명시 필요(아래 참조) | DEFERRABLE 미지원 (항상 즉시 검증) |
| EXCLUDE 제약 | (없음) | 지원 (GiST 인덱스 기반) | (없음) |
표가 보여주는 핵심은 같은 이름의 제약조건이 같은 의미로 작동하지 않는다는 점입니다. Oracle의 DEFAULT ON NULL, PostgreSQL의 EXCLUDE, MySQL의 ON UPDATE CURRENT_TIMESTAMP는 각 DB가 가진 고유 기능이고, 이름이 비슷한 옵션도 검증 시점·기본값·지원 범위가 다릅니다. DDL이 옮겨졌다고 동작이 따라오지는 않습니다.
DDL은 복사됐지만 의미가 달라지는 것들
정의는 그대로인데 데이터가 담는 의미가 달라진다면, 복사는 성공했지만 이관은 실패한 것입니다.
갱신 시각 컬럼. Oracle에서 갱신일시를 관리하는 일반적인 패턴은 trigger입니다. 행이 UPDATE될 때마다 trigger가 발동해 해당 컬럼에 현재 시각을 기록합니다. 이 패턴은 DDL에 명시되지 않습니다. 이관 도구는 테이블 정의와 DEFAULT 구문을 옮기지만, trigger는 별도 객체이므로 누락되거나 타겟 DB에 맞게 재작성되지 않으면 이관 후 갱신일시 컬럼은 INSERT 시점 이후로 더 이상 갱신되지 않습니다. MySQL은 ON UPDATE CURRENT_TIMESTAMP라는 컬럼 속성으로 trigger 없이 이 동작을 처리하지만, 그 동작은 explicit_defaults_for_timestamp 설정과 TIMESTAMP/DATETIME 타입 선택에 따라 미묘하게 갈립니다(특히 테이블의 첫 TIMESTAMP 컬럼에 대한 자동 초기화 동작은 이 시스템 변수에 따라 달라지며, 구버전 호환 모드에서는 명시하지 않아도 자동 적용되는 케이스가 있습니다). 방향이 바뀌어 MySQL → Oracle/PostgreSQL이라면 컬럼 속성에 담겨 있던 동작을 BEFORE UPDATE trigger로 풀어내야 하고(AFTER 시점에서는 컬럼 값을 수정할 수 없습니다), 이 변환이 빠지면 데이터는 들어가 있고 오류도 없지만 이력 추적의 의미가 사라진 상태가 됩니다.
시간 함수의 평가 시점. PostgreSQL의 now()/CURRENT_TIMESTAMP는 트랜잭션 시작 시점으로 고정됩니다. 하나의 트랜잭션 안에서 수십만 건을 INSERT해도 timestamp는 동일합니다. Oracle의 SYSDATE/SYSTIMESTAMP는 호출 시점의 현재 시각을 반환하므로 같은 SQL 안에서는 같은 값처럼 보일 수 있지만, PostgreSQL now()처럼 트랜잭션 시작 시점으로 고정되는 모델과는 다릅니다. MySQL의 NOW()/CURRENT_TIMESTAMP는 statement 시작 시점에 평가되어 같은 statement 안에서는 동일한 값을 반환합니다. 행 단위로 다른 시각이 찍혀야 하는 이력 테이블이라면, PostgreSQL에서는 clock_timestamp()를 명시적으로 써야 합니다. DEFAULT에 어떤 함수를 쓰느냐가 아니라, 그 함수가 어떤 시점 기준으로 평가되는지를 확인하지 않으면 이 차이는 데이터 안에 그대로 반영됩니다.
명시 NULL 가로채기. Oracle 12c+의 DEFAULT ... ON NULL은 애플리케이션이 명시적으로 NULL을 보내더라도 DB가 기본값으로 가로채는 동작입니다. 오픈소스의 일반 DEFAULT는 컬럼이 생략됐을 때만 작동하고, 표현식 DEFAULT 역시 마찬가지여서 명시 NULL을 가로채지 못합니다. 명시적으로 NULL을 보내면 그대로 저장되거나, NOT NULL 제약이 걸려 있다면 에러로 돌아옵니다. 같은 INSERT 문이 Oracle에서는 성공하고 오픈소스에서는 실패하는 상황이 여기서 발생합니다. 오픈소스에서 이 동작을 재현하려면 BEFORE INSERT trigger로 NULL을 가로채는 방법밖에 없습니다. Oracle → 오픈소스 방향에서는 애플리케이션의 NULL 전송 로직을 함께 점검하거나, trigger 설계를 추가해야 합니다.
표현식 DEFAULT. MySQL은 5.x까지 CURRENT_TIMESTAMP를 제외한 표현식 DEFAULT를 지원하지 않았습니다. 8.0.13부터 정식 지원됩니다. PostgreSQL/Oracle에서 자유롭게 쓰던 함수 DEFAULT(예: DEFAULT lpad(...))를 5.x MySQL로 옮기려면 trigger로 재구성해야 했지만, 8.x로 가는 경우라면 그대로 옮길 수 있습니다. 다만 함수 가용성과 결정성(deterministic) 요건은 별도로 확인해야 합니다.
더미값 관행. NOT NULL을 통과시키기 위해 넣어두었던 빈 문자열, 0, 9999-12-31 같은 값들은 결측치를 통과시키기 위한 장치였지만, 이 값들이 실제 데이터처럼 저장되면 집계와 통계에서 왜곡이 발생합니다. 이관은 이 관행을 정리할 수 있는 시점이기도 합니다. 하지만 더미값을 그대로 넘기면서 DEFAULT만 바꾸면, 기존 데이터의 왜곡은 그대로 남습니다.
DEFAULT는 이관을 편하게 만드는 도구가 아니라, 데이터의 의미를 결정하는 통제 수단입니다.
CHECK — 존재한다고 작동하는 것은 아니다
CHECK 제약조건은 제약조건 이관에서 가장 단순해 보이지만, 먼저 확인해야 할 대상입니다.
Oracle에서 CHECK는 ENABLE/DISABLE과 VALIDATE/NOVALIDATE의 네 가지 조합 상태를 가집니다. 정의만 해두고 DISABLE 상태로 관리하던 경우, 이관 후 PostgreSQL에서 해당 CHECK가 활성화되면서 기존 데이터나 배치 SQL이 갑자기 제약 위반으로 실패합니다. 기존 환경에서 사실상 죽어있던 규칙이 이관 후 살아나는 것입니다. 반대로, Oracle에서 작동하던 CHECK가 이관 과정에서 누락되면 무결성 통제 없이 데이터가 들어가는 상태가 됩니다. 어느 쪽이든 이관 전에 각 CHECK 제약조건이 실제로 작동하고 있었는지를 확인하지 않으면, 타겟 환경에서 의도하지 않은 동작이 나옵니다. CHECK는 정의가 아니라 실행 상태가 기준입니다.
MySQL → Oracle/PostgreSQL 방향에서는 정반대 함정이 기다립니다. MySQL은 8.0.16에 와서야 CHECK 제약조건을 정식 지원했습니다. 그 이전 버전은 CHECK를 파싱만 하고 실제로는 무시했습니다. 따라서 5.x나 8.0 초기 버전에서 운영되던 시스템에는 CHECK 정의는 존재하지만 실제로는 한 번도 검증된 적 없는 데이터가 광범위하게 쌓여 있을 수 있습니다. 이 데이터를 Oracle이나 PostgreSQL로 옮기면 정의된 CHECK가 정상 작동하면서 적재가 실패하거나, NOT VALID 상태로 통과시킨 뒤 운영 중 신규 데이터에서만 거절이 발생합니다. 이관 전 CHECK 조건에 위배되는 기존 데이터를 전수 점검하고, 정제하거나 제약을 재정의하는 결정이 필요합니다.
PostgreSQL은 NOT VALID로 제약을 정의하면 기존 데이터는 검증하지 않고 신규 데이터에만 적용할 수 있습니다. 이후 VALIDATE CONSTRAINT로 기존 데이터까지 확장 검증합니다. 이관 적재 단계에서 유용하지만, NOT VALID 상태를 정리하지 않은 채 운영에 들어가면 옵티마이저가 이 제약을 신뢰하지 않아 일부 최적화 기회를 잃습니다.
UNIQUE — NULL의 처리는 DB마다 다르다
UNIQUE 제약은 단순해 보이지만, NULL 값을 어떻게 취급하는지에서 세 DB의 동작이 갈립니다.
기본 동작은 세 DB 모두 같습니다 — NULL 값은 서로 중복으로 보지 않습니다. UNIQUE 컬럼에 NULL을 갖는 행은 여러 개 들어갈 수 있고, 복합 UNIQUE에서도 마찬가지로 부분 NULL인 행이 여러 개 공존할 수 있습니다. 이는 SQL 표준의 NULLS DISTINCT 모델을 세 DB가 모두 따르기 때문입니다.
차이는 두 곳에서 생깁니다.
첫째, PostgreSQL 15부터 UNIQUE NULLS NOT DISTINCT 옵션이 추가되었습니다. NULL도 같은 값으로 취급해 중복으로 거절하도록 제약 정의 시점에 선택할 수 있습니다. Oracle/MySQL에는 이 옵션이 없습니다. PostgreSQL 15+ 환경이라면 "NULL이 곧 미입력이므로 중복 검사 대상이 아니다"라는 기본 의도가 맞는지 다시 묻는 기회이고, 다른 DB에서 옮겨 올 때는 의도와 무관하게 기본값 NULLS DISTINCT를 따르게 됩니다.
둘째, Oracle은 현재 VARCHAR2의 빈 문자열을 NULL처럼 처리하지만, Oracle 문서는 이 동작에 장기 호환성을 기대하지 말라고 권고합니다.. 이 차이가 양방향 이관에서 가장 큰 함정을 만듭니다. 오픈소스에서 (A, B) UNIQUE 인덱스가 있고 B가 Nullable일 때, A=1, B='' 행과 A=1, B=NULL 행이 별개의 값으로 공존할 수 있습니다. 이 데이터를 Oracle로 이관하면 ''가 NULL로 변환되면서 두 행 모두 A=1, B=NULL이 되고, Oracle UNIQUE는 NULL 조합의 중복을 허용하므로 적재는 성공하지만 업무적으로는 의도하지 않은 중복이 생긴 상태가 됩니다. 에러는 없고, 건수도 맞고, 로그에도 흔적이 없습니다. 이 함정의 더 자세한 분석은 인덱스 편(11)에서 다룹니다.
방향별 함의가 다릅니다.
- Oracle → PostgreSQL/MySQL: 기본 동작이 같아 큰 변경은 없습니다. PostgreSQL 15+ 환경이라면
NULLS NOT DISTINCT도입을 검토할 수 있습니다. - PostgreSQL/MySQL → Oracle: UNIQUE 컬럼 중 Nullable + 빈 문자열이 들어 있는 것을 이관 전에 전수 점검하고, 정제 또는 제약 재설계를 결정해야 합니다. 이관 후로 미루면 에러 없이 들어간 중복 데이터를 운영 중에 추적해야 합니다.
FK — 존재보다 동작이 중요하다
Oracle 환경에서도 대규모 시스템에서는 FK를 DISABLE 상태로 관리하는 경우가 적지 않습니다. 대량 배치 작업에서의 락 경합, CASCADE 동작의 예측 범위 문제가 이유입니다. 오픈소스에서는 이 부담이 더 직접적입니다. PostgreSQL은 FK 검증 과정에서 참조 무결성 확인을 위한 잠금과 추가 읽기 비용이 발생하고, MySQL InnoDB는 FK 컬럼에 적절한 인덱스가 필요하며, 없을 경우 인덱스가 자동 생성되거나 생성 조건에 맞지 않으면 제약 생성 자체가 실패합니다. Oracle에서 죽어있던 FK를 살려서 가져갈 이유는 없고, 살아있던 FK도 오픈소스에서 같은 방식으로 작동하지 않습니다.
ON DELETE / ON UPDATE 액션도 DDL이 복사됐다고 안심할 수 없는 영역입니다. 이름이 같아 보여도 지원 여부와 동작이 다릅니다.
SET DEFAULT: PostgreSQL은 정식 지원, Oracle은 미지원, MySQL은 InnoDB에서 파싱만 되고 실제로는 무시(NDB만 동작). PostgreSQL → Oracle 방향에서ON DELETE SET DEFAULT가 정의되어 있다면 Oracle에서는 trigger 또는SET NULL+ DEFAULT 컬럼 정의 조합으로 재현해야 합니다.CASCADE/SET NULL/RESTRICT/NO ACTION: 세 DB 모두 지원하지만 검증 시점 차이가 결과를 가릅니다(다음 절).ON UPDATE액션: Oracle은ON UPDATE액션을 지원하지 않습니다. 부모 키 변경 자체를 trigger로 차단하는 패턴이 일반적입니다. PostgreSQL/MySQL → Oracle 방향에서ON UPDATE CASCADE가 있다면 trigger 또는 애플리케이션 로직으로 재설계해야 합니다.
이관 도구가 적재 편의를 위해 FK를 끄고 데이터를 넣은 뒤, Oracle의 NOVALIDATE나 PostgreSQL의 NOT VALID 상태로 재활성화하는 경우도 있습니다. 이 상태에서 새로 들어오는 데이터는 통제를 받지만, 기존 데이터는 무결성이 보장되지 않습니다. 옵티마이저도 이 상태를 신뢰하지 않습니다. FK가 완전히 검증된 상태라면 Join Elimination 같은 최적화를 적용할 수 있지만, NOVALIDATE/NOT VALID에서는 그 가능성이 사라집니다. 껍데기만 켜진 제약조건은 데이터 무결성도, 성능 이점도 주지 못합니다.
FK는 관계를 표현하는 정의가 아니라, 관계를 강제하는 실행 규칙입니다.
FK는 언제 검사되는가 — DEFERRABLE의 비대칭
FK를 유지한다고 판단했더라도, 그 FK가 언제 발동하는지는 다시 확인해야 합니다.
Oracle에서 FK를 DEFERRABLE INITIALLY DEFERRED로 설정하고 배치를 돌리는 패턴이 있습니다. 부모-자식 관계의 데이터를 순서 상관없이 넣고, COMMIT 시점에 한 번만 검증하는 방식입니다. 이 패턴에 의존하는 비즈니스 로직이 있다면, 이관 후 동작이 달라집니다.
PostgreSQL은 DEFERRABLE을 지원하지만, 검증 시점을 미루려면 두 단계가 모두 필요합니다.
첫째, 제약 정의 시점에 DEFERRABLE 절을 DDL에 명시해야 합니다. DEFERRABLE 절 없이 만든 제약은 이후 SET CONSTRAINTS로도 미룰 수 없습니다. 즉 이관 시점에 DDL을 어떻게 생성하느냐가 첫 결정입니다.
둘째, 검증 시점을 정합니다. DEFERRABLE INITIALLY DEFERRED로 만들면 트랜잭션 끝까지 자동으로 미뤄집니다. DEFERRABLE INITIALLY IMMEDIATE(DEFERRABLE만 명시하면 이쪽이 기본)로 만들면 평소에는 즉시 검증되고, 필요한 트랜잭션에서 런타임에 SET CONSTRAINTS ... DEFERRED를 호출해야 미뤄집니다. Oracle의 DEFERRABLE INITIALLY DEFERRED 동작을 그대로 재현하려면 DDL에 INITIALLY DEFERRED까지 포함시키거나, 애플리케이션 코드가 매 배치 시작 시 SET CONSTRAINTS를 호출하도록 보장해야 합니다.
MySQL은 DEFERRABLE 자체를 지원하지 않습니다. 검증은 항상 즉시 일어납니다. 이관 후 같은 배치 SQL이 중간에 FK 위반으로 실패하는 케이스가 여기서 나옵니다. 비즈니스 로직이 "트랜잭션 끝에 정합성을 맞추면 된다"는 전제로 작성되어 있었는데, 타겟 DB가 "매 문장마다 즉시 검증한다"로 바뀐 것입니다. MySQL로 가는 경우는 우회 자체가 어렵고, 적재 순서를 보장하는 배치 재설계 또는 FK 일시 해제·재활성화 절차로 풀어야 합니다.
NO ACTION과 RESTRICT도 이 맥락에서 갈립니다. 이름이 비슷하지만 DEFERRABLE 여부에 따라 동작이 다릅니다. NO ACTION은 DEFERRED 상태에서 COMMIT 시점까지 검증을 미룰 수 있지만, RESTRICT는 항상 즉시 검증합니다. 이 차이를 인식하지 못한 채 이관하면, 검증 시점이 바뀌면서 같은 트랜잭션이 성공하거나 실패하는 결과가 달라집니다.
오픈소스 고유 제약 — EXCLUDE의 부재
PostgreSQL의 EXCLUDE 제약은 Oracle/MySQL에 직접 대응이 없는 고유 기능입니다. UNIQUE가 동등 비교만 처리한다면, EXCLUDE는 임의의 연산자(&& 범위 겹침, <> 비동일 등)로 행 간 충돌을 정의합니다. 예약 시스템에서 시간 범위가 겹치지 않도록 보장하는 패턴이 대표적입니다.
PostgreSQL → Oracle/MySQL 방향에서 EXCLUDE 제약이 정의되어 있다면, 타겟 DB에서는 trigger로 재구현하거나 애플리케이션 레벨에서 직렬화하는 방식으로 우회해야 합니다. 둘 다 동시성과 정확성 측면에서 EXCLUDE의 native 성능에는 미치지 못하므로, 이관 시 EXCLUDE에 의존하던 비즈니스 규칙은 별도 설계 항목으로 다뤄야 합니다.
결론
제약조건 이관은 DDL을 옮기는 작업이 아닙니다. 정의 뒤에 숨어 있는 상태(ENABLE/DISABLE), 검증 시점(IMMEDIATE/DEFERRED), 평가 시점(statement/transaction), 그리고 NULL과 명시값의 경계가 모두 함께 옮겨져야 의미가 보존됩니다.
결국 다섯 가지 질문으로 수렴됩니다.
첫째, 이 DEFAULT는 타겟 DB에서도 원래 의도한 시점 기준으로 작동하는가. 그리고 DEFAULT ON NULL 같은 명시 NULL 가로채기 동작이 사라지면 애플리케이션은 영향을 받지 않는가.
둘째, NOT NULL을 통과시키기 위해 넣은 기본값이나 더미값이 데이터의 원래 의미를 위장하고 있지는 않은가. 이관이 정리의 기회가 될 수 있는가.
셋째, 이관 전 소스에서 각 제약조건이 실제로 작동하던 상태(Oracle의 ENABLE/VALIDATE, MySQL 5.x의 묵음 무시, PostgreSQL의 NOT VALID)와 이관 후 상태가 의도적으로 일치하는가.
넷째, UNIQUE 컬럼에서 NULL과 빈 문자열의 경계가 이관 후에도 유지되는가. 특히 오픈소스 → Oracle 방향에서 ''가 NULL로 합쳐지면서 의도하지 않은 중복이 생기는 경우는 점검되었는가. PostgreSQL 15+ 환경이라면 NULLS NOT DISTINCT 도입 여부는 결정되었는가.
다섯째, 검증 시점에 의존하는 비즈니스 로직(Oracle DEFERRABLE 배치, PostgreSQL EXCLUDE)이 타겟 DB의 검증 모델에서도 같은 결과를 만드는가. PostgreSQL로 가는 경우 DDL에 DEFERRABLE 절이 포함되어 있는가. MySQL처럼 DEFERRABLE이 없는 환경이라면 배치 순서나 FK 일시 해제 절차로 대체되었는가.
이 질문에 답하지 못한 상태에서의 이관은, 제약조건의 복사가 아니라 추정입니다. 그리고 그 추정이 틀렸다는 사실은 데이터를 직접 확인한 사람만 알 수 있습니다.
규칙이 정해졌다면, 그 규칙 안에서 가장 자주 사고가 나는 영역이 다음 글의 주제입니다. 시퀀스와 식별자 채번 — 같은 번호가 두 번 찍히는 사고가 가장 자주 나는 곳을 다음 글에서 살펴봅니다.
