본문으로 건너뛰기

워크스페이스 루트 하나의 조건, PostgreSQL과 Oracle의 유니크 인덱스

· 약 5분
김성연
AI Research Engineer, Braincrew

워크스페이스마다 루트 폴더가 하나 있어야 한다. 제품 요구사항으로는 짧지만, 데이터베이스에 옮기면 “둘 이상 생기지 않는다”와 “없는 상태를 채운다”를 따로 구현해야 한다.

Deep Connector는 루트 중복을 PostgreSQL의 부분 유니크 인덱스와 Oracle의 함수 기반 유니크 인덱스로 막는다. 인덱스가 보장하는 것은 최대 하나다. 루트를 생성하는 작업과 인덱스를 설치하는 작업까지 있어야 제품이 요구하는 상태를 만들 수 있다.

이 글은 구현 코드와 공식 문서를 대조한 설계 설명이다. SQL은 식별자와 구조를 단순화한 개념 예시이며, 글 작성 과정에서 PostgreSQL이나 Oracle에 실행한 실험 결과가 아니다.

루트 식별과 중복 방지의 구분​

루트는 depth = 0인 폴더다. 엔티티의 교차 컬럼 CHECK는 루트의 parent_id가 NULL이어야 하고, 사용자 폴더는 깊이가 1부터 3까지이며 부모 값이 있어야 한다고 정한다. workspace_id와 depth에는 NOT NULL이 선언돼 있다.

이 조건만으로 루트 중복을 막지는 못한다. 같은 워크스페이스에 depth = 0, parent_id = NULL인 행을 추가해도 각 행은 CHECK를 만족하기 때문이다. 반대로 workspace_id 전체에 유니크를 걸면 사용자 폴더까지 워크스페이스당 하나로 제한된다.

필요한 조건은 루트 행만 골라서 workspace_id의 중복을 금지하는 것이다.

PostgreSQL의 부분 유니크 인덱스​

PostgreSQL의 부분 인덱스는 조건에 맞는 행만 인덱스에 포함한다. 여기에 UNIQUE를 붙이면 그 행들 사이에서만 중복을 막는다.

PostgreSQL 개념 예시
CREATE UNIQUE INDEX uq_folder_root
ON folder (workspace_id)
WHERE depth = 0;

folder는 설명용 테이블 이름이며, workspace_id가 NOT NULL이라는 전제다. 같은 워크스페이스의 루트 행은 동일한 키를 사용하므로 중복될 수 없고, depth > 0인 폴더는 이 인덱스의 제한을 받지 않는다. 다른 워크스페이스의 루트는 키가 다르므로 함께 존재할 수 있다.

실제 PostgreSQL 마이그레이션은 기존 루트 인덱스를 삭제하고, workspace_id에 WHERE depth = 0 조건을 붙인 부분 유니크 인덱스를 만든 뒤, 루트 표시용 중복 컬럼을 제거한다. 이 마이그레이션 자체가 없는 루트를 생성하는 것은 아니다.

Oracle의 함수 기반 유니크 인덱스​

Oracle 구현은 같은 중복 방지 조건을 표현식으로 옮긴다.

Oracle 개념 예시
CREATE UNIQUE INDEX uq_folder_root
ON folder (
CASE WHEN depth = 0 THEN workspace_id END
);

루트 행에서는 CASE가 workspace_id를 반환하고, 나머지 행에서는 NULL을 반환한다. Oracle의 일반 B-tree 인덱스는 키 전체가 NULL인 행을 저장하지 않는다. 이 예시는 표현식 하나만 인덱스 키로 쓰므로 루트가 아닌 행은 인덱스에서 빠진다.

결과적으로 루트 행끼리만 워크스페이스 키를 비교한다. 여기서도 workspace_id의 NOT NULL이 중요하다. 루트의 워크스페이스 값까지 NULL을 허용하면 그 행도 키가 없는 행으로 빠져 중복 방지 대상에서 제외된다.

두 SQL은 같은 업무 규칙을 표현하지만, 설치 과정까지 같은 것은 아니다.

최초 테이블 생성 뒤의 인덱스 설치​

Oracle 구현은 기존 스키마를 정리하는 시작 업그레이드와 루트 인덱스 생성을 나눈다. 시작 업그레이드는 예전 루트 표시 컬럼을 정리하고, 없는 루트를 시드하며, 깊이와 부모의 CHECK를 맞춘다. 루트의 함수 기반 유니크 인덱스 생성은 별도 부트스트랩 서비스가 맡는다.

이 구분은 빈 스키마를 처음 만드는 경로에서 필요하다. 명시적으로 동기화를 요청한 빈 Oracle 스키마에서는 TypeORM의 synchronize를 허용하고 시작 업그레이드는 건너뛴다. 시작 업그레이드가 먼저 테이블을 만들면 빈 스키마라는 전제를 바꿔 버리기 때문이다.

그래서 인덱스를 시작 업그레이드에만 넣으면 최초 동기화 경로에서 빠질 수 있다. 구현은 OnApplicationBootstrap에서 테이블 존재 여부를 확인한 뒤 인덱스를 생성하도록 배치한다. NestJS의 이 훅은 모든 모듈이 초기화된 뒤, 연결을 수신하기 전에 실행된다.

다만 테이블 존재 확인은 스키마 전체 검증이 아니다. 테이블이 없으면 이 서비스는 인덱스 생성도 건너뛴다.

재기동 허용과 인덱스 검증의 차이​

부트스트랩 서비스는 생성 중 ORA-00955 또는 ORA-01408을 받으면 오류를 다시 던지지 않는다. 공식 오류 설명에서 ORA-00955는 이미 사용 중인 객체 이름, ORA-01408은 이미 인덱스가 있는 컬럼 목록을 뜻한다.

이 처리는 재기동 때 이미 있는 인덱스를 다시 만들려는 시도를 허용하지만, 원하는 인덱스가 있다는 증거는 아니다. 서비스는 이후 카탈로그를 조회해 인덱스의 표현식이나 유니크 속성을 다시 검사하지 않는다. 특히 같은 이름이 남아 있다는 사실만으로 새 루트 조건과 일치한다고 판단할 수 없다.

기존 중복 데이터를 자동으로 정리하는 코드도 이 서비스에는 없다. 따라서 “재실행해도 일부 중복 생성 오류를 무시한다”와 “어떤 기존 스키마에서도 올바른 인덱스로 복구한다”는 구분해야 한다.

외부 관리 스키마의 책임​

DBA가 스키마를 관리하고 애플리케이션은 데이터만 읽고 쓰는 배포라면 인덱스 설치도 스키마 관리 절차에 포함해야 한다. Oracle 루트 인덱스 서비스는 외부 관리로 판정되면 카탈로그 조회와 인덱스 생성을 건너뛴다. 이때 애플리케이션이 만들지 않는다는 이유로 인덱스가 불필요해지는 것은 아니다.

서비스가 사용하는 USER_TABLES는 현재 사용자가 소유한 테이블을 보여준다. 다른 계정의 테이블을 읽을 권한과 그 테이블을 소유한다는 사실은 다르므로, 조회 결과가 없다는 이유만으로 생성에 나서면 안 된다.

외부 관리 설정을 모든 시작 작업의 차단 스위치로 해석하는 것도 위험하다. 확인한 코드에서는 TYPEORM_SYNCHRONIZE=true를 명시하고 테이블 수 조회가 성공하면, 테이블이 있는 경우 시작 업그레이드 플래그를 다시 켜는 분기가 있다. 루트 인덱스 서비스의 외부 관리 가드는 그대로지만, 별도 시작 업그레이드의 DDL과 DML까지 무조건 제외된다고 말할 수는 없다.

외부 관리 배포에서는 동기화 설정과 시작 업그레이드 실행 여부를 함께 확인하고, 담당 관리 절차에서 루트 인덱스의 정의를 검증해야 한다.

최대 하나와 최소 하나의 별도 검증​

유니크 인덱스는 루트가 없는 상태를 위반으로 보지 않는다. 없는 루트를 채우는 시드나 생성 로직이 따로 필요하고, 생성 시점에 존재했다는 사실만으로 이후에도 루트가 항상 남는다고 보장할 수는 없다.

배포 검증에서는 올바른 인덱스가 설치됐는지 확인한 뒤, 루트가 없는 워크스페이스도 별도로 조회해야 한다. 중복 루트 입력과 일반 폴더 입력, 루트 부재 상태를 나눠 확인하는 것은 이 규칙을 검증하기 위한 제안이며, 여기서 실행한 테스트 목록은 아니다.

워크스페이스 루트 하나라는 제품 규칙에는 중복 방지, 루트 생성, 스키마 설치 책임이 함께 들어 있다. SQL 문법을 데이터베이스마다 바꾸는 것만으로는 부족하고, 각 작업의 실행 조건까지 맞아야 한다.

References​