PM의 DB 실습 1편 – 북마크와 권한 관리

PM의 DB 실습 1편-북마크와 권한 관리, 데이터 구조 실습

1:N 관계부터 복합 UNIQUE까지, 실행으로 배운 데이터 설계

들어가며

PM이 SQL을 배워야 할까? 개발자가 아닌데 DB 구조까지 알아야 할까?

이 질문에 대한 답을 찾기 위해 3주간 직접 DB 실습을 계획하여 진행했습니다.

실무에서 데이터 구조를 이해하지 못해 개발자와 소통이 막히고 기획이 자주 뒤집히는 경험을 했기에, 이번 기회에 맥락을 이해하고 업무에 활용할 수 있도록 배워보기로 했습니다.

직접 해보니 PM이 “비즈니스 요구사항이 데이터 구조로 어떻게 전환되는지” 조금이라도 이해하면, 개발자와의 소통이 조금 더 명확해질 수 있겠다는 것을 느꼈습니다.

이번 글에는 “정책 관리 플랫폼”이라는 가상의 서비스를 설계하며 배운 내용을 정리했습니다. 북마크 기능과 권한 관리, 두 가지 실습을 통해 1:N 관계, FK(외래키), 복합 UNIQUE, soft delete, 이력 추적 패턴 등을 체감했습니다.

당연히 아직 배우는 단계라 완벽하지는 않습니다. 하지만 “화면만 그리는 기획자”가 아니라 “데이터 구조를 고민하는 PM”이 되기 위한 과정 중 하나로 기록해둡니다.


실습 1: 북마크 기능

시나리오

“사용자가 관심 있는 정책을 북마크할 수 있다.”

단순해 보이는 요구사항이지만, 실제로 테이블을 설계하려니 여러 질문이 생겼습니다.

  • 북마크를 users 테이블에 TEXT 컬럼으로 넣으면 안 될까?
  • 여러 사용자가 같은 정책을 북마크하면 어떻게 저장하지?
  • 같은 사용자가 실수로 같은 정책을 2번 북마크하면?

잘못된 설계 (처음 시도)

처음에는 users 테이블에 bookmarks TEXT 컬럼을 추가해서 아래와 같이 저장하려고 했습니다:

잘못된 설계 예시:

  • user_id=1 (김철수): bookmarks=”1,3,5″ (정책 1번, 3번, 5번)
  • user_id=2 (이영희): bookmarks=”2,4″ (정책 2번, 4번)

문제점:

  • 특정 정책을 북마크한 사용자 검색 불가 (LIKE 써야 함 = 느림)
  • 정책이 삭제되어도 “1,3,5” 문자열에 그대로 남음
  • 언제 북마크했는지 기록할 수 없음
  • 북마크 개수 집계 어려움 (문자열 파싱 필요)

그래서 별도의 테이블을 추가 생성하여 users 테이블의 user_id를 참조하는 방식으로 구조를 수정했습니다.

올바른 설계 (별도 테이블 분리)

북마크 ERD - users, policies, bookmarks 3개 테이블 관계도
북마크 ERD – users, policies, bookmarks 3개 테이블 관계도

bookmarks 테이블을 별도로 만들면:

CREATE TABLE bookmarks (
  bookmark_id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  policy_id INTEGER NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(user_id),
  FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
  UNIQUE(user_id, policy_id)
);
북마크 테이블 조회 결과 - bookmark_id, user_id, policy_id, created_at 컬럼
북마크 테이블 조회 결과

핵심 개념 설명

1. FOREIGN KEY (FK, 외래키)

FOREIGN KEY (user_id) REFERENCES users(user_id)
  • bookmarks 테이블의 user_id는 users 테이블의 user_id를 “참조”한다는 의미
  • users 테이블에 없는 user_id는 bookmarks에 저장할 수 없음
  • 덕분에 존재하지 않는 사용자 id로 북마크가 생성되는 상황을 DB 단에서 막을 수 있음

2. 복합 UNIQUE 제약

UNIQUE(user_id, policy_id)

무엇을 방지하는가?

  • 같은 사용자(user_id=1)가 같은 정책(policy_id=3)을 2번 북마크하는 실수

어떻게 작동하는가?

✅ 허용되는 경우:

  • (user_id=1, policy_id=1): 김철수 – 정책1
  • (user_id=1, policy_id=3): 김철수 – 정책3 (user_id=1 반복 가능)
  • (user_id=2, policy_id=1): 이영희 – 정책1 (policy_id=1 반복 가능)

❌ 차단되는 경우:

  • (user_id=1, policy_id=1): 김철수 – 정책1을 또 추가하려고 하면?
    • DB 에러 발생: “UNIQUE constraint failed”
    • (1, 1) 조합이 이미 존재하기 때문

정리:

  • user_id=1은 여러 번 나올 수 있음 (여러 정책 북마크 가능)
  • policy_id=1도 여러 번 나올 수 있음 (여러 사람이 북마크 가능)
  • 하지만 (user_id=1, policy_id=1) 조합은 단 한 번만 허용

3. created_at 컬럼의 역할

  • 언제 북마크했는지 시점 기록
  • “최근 북마크한 정책 순”으로 정렬 가능
  • 통계: “이번 주 북마크 수” 집계 가능

개선 효과

  • 부서 검색이 WHERE department = '디자인팀'으로 간단해짐
  • 부서 추가/삭제가 INSERT/DELETE로 끝남
  • UNIQUE 제약으로 같은 권한 중복 추가 방지

실습 2: 댓글 관리와 이력 추적

시나리오

“사용자가 정책에 댓글을 달 수 있다. 댓글은 수정/삭제할 수 있고, 모든 변경 이력을 추적한다.”

테이블 설계

comments 테이블 - comment_id, policy_id, user_id, content, created_at, updated_at, deleted_at
댓글 테이블 구조
comment_history 테이블 - history_id, comment_id, content, modified_at, modified_by, action
댓글 이력 테이블 구조

왜 테이블을 2개로 나눴나?

comments 테이블: 현재 상태만 저장 (최신 버전)

  • 화면에 보여줄 댓글 내용
  • 빠른 조회 (최신 상태만 읽으면 됨)

comment_history 테이블: 모든 변경 기록 저장 (전체 이력)

  • 누가 언제 무엇을 수정했는지
  • 삭제된 댓글도 복구 가능
  • 감사(audit) 목적으로 활용 가능
CREATE TABLE comments (
  comment_id INTEGER PRIMARY KEY AUTOINCREMENT,
  policy_id INTEGER NOT NULL,
  user_id INTEGER NOT NULL,
  content TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP,
  deleted_at TIMESTAMP,
  FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
  FOREIGN KEY (user_id) REFERENCES users(user_id)
);

CREATE TABLE comment_history (
  history_id INTEGER PRIMARY KEY AUTOINCREMENT,
  comment_id INTEGER NOT NULL,
  content TEXT,
  modified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  modified_by INTEGER NOT NULL,
  action TEXT CHECK(action IN ('created', 'updated', 'deleted')),
  FOREIGN KEY (comment_id) REFERENCES comments(comment_id),
  FOREIGN KEY (modified_by) REFERENCES users(user_id)
);

핵심 개념 설명

1. soft delete (소프트 삭제)

deleted_at TIMESTAMP 컬럼의 역할:

활성 댓글 (deleted_at IS NULL):

  • comment_id=1: “좋은 정책입니다” (deleted_at: NULL)
  • comment_id=2: “도움됐어요” (deleted_at: NULL)

삭제된 댓글 (deleted_at IS NOT NULL):

  • comment_id=3: “애매한 정책이네요” (deleted_at: 2026-02-13 17:00)
    • 화면에는 comment_id=1, 2만 표시
    • comment_id=3은 DB에 유지되지만 숨김

왜 실제로 삭제(DELETE)하지 않나?

  • 복구 가능성: 실수로 삭제해도 되돌릴 수 있음
  • 이력 추적: “누가 언제 삭제했는지” 기록 남음
  • 법적 요구사항: 일부 서비스는 삭제 기록 보관 의무

처음 시도했던 방식:

deleted BOOLEAN  -- true/false만 저장

문제점: “언제 삭제했는지” 알 수 없음

개선한 방식:

deleted_at TIMESTAMP  -- 삭제 시점까지 기록

2. CHECK 제약으로 허용값 제한

action TEXT CHECK(action IN ('created', 'updated', 'deleted'))

왜 필요한가?

제약 없이 두면 개발자가 임의로 이렇게 입력할 수 있음:

  • ‘CREATED’ (대문자)
  • ‘Update’ (첫 글자만 대문자)
  • ‘modify’ (다른 단어)

CHECK 제약을 걸면:

  • ‘created’, ‘updated’, ‘deleted’ 딱 3개 값만 허용
  • 다른 값 입력 시 DB가 에러 발생: “CHECK constraint failed”
  • 기획에서 정한 규칙을 DB 레벨에서도 지킬 수 있다는 게 신기했던 부분

3. modified_by로 수정자 추적

modified_by INTEGER,
FOREIGN KEY (modified_by) REFERENCES users(user_id)

왜 필요한가?

댓글을 쓴 사람(user_id)과 수정한 사람(modified_by)이 다를 수 있음:

  • 일반적: 본인이 작성한 글을 본인이 수정 (user_id = modified_by)
  • 특수한 경우: 다른 관리자가 삭제 (user_id ≠ modified_by)

이력 추적 예시:

  • history_id=1: 댓글1, 김철수→김철수, created
  • history_id=2: 댓글1, 김철수→김철수, updated (본인 수정)
  • history_id=3: 댓글1, 김철수→관리자, deleted (관리자 삭제)

→ 원작성자와 수정자가 다를 수 있음을 기록

JOIN으로 의미 있는 정보 조회

comment_history만 조회하면:

  • history_id=1: comment_id=1, modified_by=1, action=created
  • history_id=2: comment_id=1, modified_by=1, action=updated
    • modified_by=1이 누군지 모름 (숫자만 보임)

users 테이블과 JOIN하면:

SELECT 
  h.history_id,
  h.comment_id AS 댓글번호,
  u_author.name AS 원작성자,
  u_modifier.name AS 수정자,
  h.action AS 행동,
  h.content AS 내용,
  h.modified_at AS 시간
FROM comment_history h
JOIN comments c ON h.comment_id = c.comment_id
JOIN users u_author ON c.user_id = u_author.user_id
JOIN users u_modifier ON h.modified_by = u_modifier.user_id
ORDER BY h.comment_id, h.modified_at;

결과:

  • 댓글1: 원작성자=김철수, 수정자=김철수, created, “좋은 정책입니다”
  • 댓글1: 원작성자=김철수, 수정자=김철수, updated, “개선 필요해요”
    • “김철수”라는 의미 있는 정보 획득

이 과정을 통해 FK와 JOIN의 필요성을 체감했습니다. FK로 연결하고 JOIN으로 조회하면, 단순한 숫자(user_id=1)가 의미 있는 정보(김철수)로 바뀔 수 있습니다.

숫자만 보이는 데이터를 의미 있는 정보로 바꾸는 것이 관계형 DB의 핵심입니다.


실습 3: 권한 관리

권한 관리는 3단계로 나눠 점진적으로 확장했습니다. 각 시나리오는 이전 시나리오의 한계를 해결합니다.

시나리오 1: 부서별 권한

요구사항:

  • 인사팀 정책 → 전 부서 열람 가능
  • 디자인팀 정책 → 디자인팀만 열람
  • 마케팅팀 정책 → 마케팅팀 + 경영지원팀 열람
권한관리 ERD v1 - users, policies, policy_permissions 3개 테이블
권한관리ERD_v1부서별

테이블 설계:

CREATE TABLE policy_permissions (
  permission_id INTEGER PRIMARY KEY AUTOINCREMENT,
  policy_id INTEGER NOT NULL,
  department TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
  UNIQUE(policy_id, department)
);

데이터 예시:

  • 정책1 – 인사팀만 열람 가능
  • 정책1 – 디자인팀만 열람 가능
  • 정책1 – 마케팅팀만 열람 가능
  • 정책2 – 디자인팀만 열람 가능
  • 정책3 – 마케팅팀만 열람 가능
  • 정책3 – 경영지원팀만 열람 가능

복합 UNIQUE(policy_id, department)의 역할:

  • 정책1에 “디자인팀” 권한을 실수로 2번 추가하는 것 방지
  • (1, 디자인팀) 조합은 단 한 번만 허용
정책별 열람 가능 부서 현황 조회 결과
정책별 열람 가능 부서 현황 조회 결과

왜 별도 테이블로 분리했나?

잘못된 방식:

CREATE TABLE policies (
  policy_id INTEGER PRIMARY KEY,
  title TEXT,
  accessible_departments TEXT  -- "디자인팀,마케팅팀,개발팀"
);

문제점:

  1. 검색 느림: WHERE accessible_departments LIKE '%디자인팀%' (전체 스캔)
  2. 부서 추가/삭제: 문자열 파싱 후 수정 필요
  3. 부서별 권한 부여 시점 추적 불가
  4. 통계 어려움: “디자인팀에게 권한 있는 정책 수” 집계 복잡

올바른 방식:

  • 부서마다 1개 row
  • 검색 빠름: WHERE department = '디자인팀' (인덱스 사용)
  • 부서 추가: INSERT INTO policy_permissions...
  • 부서 삭제: DELETE FROM policy_permissions...

시나리오 2: 직급별 권한 추가

요구사항:

  • 경영 전략 정책 → 과장 이상만 열람
  • 복리후생 정책 → 전 직원 열람
  • 임원 회의록 → 임원만 열람

시나리오 1의 한계:

  • 부서는 체크하는데 직급은 체크 못 함
  • “디자인팀 과장 이상” 같은 조건 불가능
권한관리 ERD v2 - min_position_level 컬럼 추가
권한관리 ERD v2 직급별

테이블 확장:

CREATE TABLE users (
  user_id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT,
  department TEXT,
  position TEXT,           -- "사원", "대리", "과장" (화면 표시용)
  position_level INTEGER,  -- 1, 2, 3 (권한 체크용)
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE policy_permissions (
  permission_id INTEGER PRIMARY KEY AUTOINCREMENT,
  policy_id INTEGER NOT NULL,
  department TEXT,              -- NULL 허용
  min_position_level INTEGER,   -- NULL 허용
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
  UNIQUE(policy_id, department, min_position_level)
);

핵심 개념 설명

1. position vs position_level 분리 이유

position (TEXT): “과장”, “차장”, “부장”

  • 역할: 화면에 표시
  • 사용처: 사용자 프로필, 댓글 작성자 표시

position_level (INTEGER): 3, 4, 5

  • 역할: 권한 비교
  • 사용처: “과장 이상” 조건 체크

문자열만 쓰면 안 되나?

문자열로 “과장 이상” 체크하려면:

if (user.position == "과장" || 
    user.position == "차장" || 
    user.position == "부장" || 
    user.position == "임원") {
  allow();
}

→ 직급 추가될 때마다 코드 수정

숫자로 체크하면:

if (user.position_level >= 3) {
  allow();
}

→ 직급 추가되어도 level만 부여하면 끝

이 부분에서 “왜 데이터 타입을 잘 설계해야 하는지” 이해가 됐습니다.

2. min_position_level의 의미

왜 “min” (최소)인가?

  • “이상” 조건이기 때문

예시: min_position_level = 3 (과장)

  • 허용: 과장(3), 차장(4), 부장(5), 임원(6) ✅
  • 차단: 사원(1), 대리(2) ❌
  • 체크 조건: user.position_level >= 3

실제 권한 체크 SQL:

WHERE user.position_level >= permissions.min_position_level

3. NULL 허용 컬럼의 의미

department NULL: 부서 제한 없음 = 전 부서 대상

권한 규칙 예시:

  • 정책1: department=NULL, min_position_level=NULL → 전 부서, 전 직급
  • 정책2: department=NULL, min_position_level=5 → 전 부서, 부장 이상
  • 정책3: department=디자인팀, min_position_level=3 → 디자인팀, 과장 이상

예시 1: 임원 회의록

  • 요구사항: “부서 상관없이 임원만”
  • 데이터: department=NULL, min_position_level=6
  • 의미: 모든 부서의 임원 허용

예시 2: 복리후생 정책

  • 요구사항: “전 직원”
  • 데이터: department=NULL, min_position_level=NULL
  • 의미: 제한 없음

NULL 없이 하려면?

  • 10개 부서면 10개 row 생성 필요
  • 새 부서 추가될 때마다 row 추가
  • 비효율적
부서별, 직급별 권한 현황 조회 결과
부서별, 직급별 권한 현황 조회 결과

시나리오 3: 개인 예외 처리

요구사항:

  • 재무 보고서 → 재무팀 전체 + CEO (경영팀 소속이지만 예외 허용)
  • 보안 정책 → 개발팀 전체 + 디자인팀 과장 이상 + 디자인팀 사원 이영희 (예외 허용)
  • 보안 정책 → 개발팀 과장 최지원 (보안 위반으로 차단)

시나리오 2의 한계:

  • 부서+직급 조건은 가능하지만 개인 예외는 불가능
  • “디자인팀 사원은 허용 안 되지만 이영희만 허용” 불가능
권한관리 ERD v3 - permission_type, user_id, is_allowed 컬럼 추가
권한관리 ERD v3 예외처리

최종 테이블:

CREATE TABLE policy_permissions (
  permission_id INTEGER PRIMARY KEY AUTOINCREMENT,
  policy_id INTEGER NOT NULL,
  permission_type TEXT CHECK(permission_type IN ('department', 'position', 'user_exception')),
  department TEXT,
  min_position_level INTEGER,
  user_id INTEGER,
  is_allowed INTEGER DEFAULT 1,  -- 1=허용, 0=차단
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
  FOREIGN KEY (user_id) REFERENCES users(user_id),
  UNIQUE(policy_id, permission_type, department, min_position_level, user_id)
);

핵심 개념 설명

1. permission_type으로 조건 구분

왜 필요한가?

한 테이블에 3가지 종류의 규칙을 저장하기 위해:

permission_type = 'department'      → 부서 조건 (재무팀 전체)
permission_type = 'position'            → 직급 조건 (과장 이상)
permission_type = 'user_exception' → 개인 예외 (CEO만 특별히)

데이터 예시 (정책3: 보안 정책):

  • permission_id=1: type=department, 개발팀 전체, is_allowed=1
  • permission_id=2: type=department, 디자인팀 과장(3) 이상, is_allowed=1
  • permission_id=3: type=user_exception, 이영희(user_id=2), is_allowed=1
  • permission_id=4: type=user_exception, 최지원(user_id=4), is_allowed=0

테이블을 분리하지 않은 이유:

이렇게 하면 관리가 복잡해짐:

Table department_permissions { ... }  -- 부서용
Table position_permissions { ... }       -- 직급용
Table user_exceptions { ... }              -- 예외용

문제점:

  • 테이블 3개 관리
  • 권한 체크 시 JOIN 3번
  • 새 조건 타입 추가 시 테이블 추가

한 테이블에 type으로 구분:

WHERE permission_type IN ('department', 'user_exception')

→ 한 번에 조회, 확장 쉬움

2. user_id로 개인 지정

user_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(user_id)

언제 사용하나?

  • permission_type = 'user_exception'일 때만
  • 나머지 경우는 NULL

예시:

  • CEO(user_id=5)에게 재무 보고서 특별 허용
  • 보안 위반자(user_id=4)를 보안 정책에서 차단

3. is_allowed로 허용/차단 구분

is_allowed INTEGER DEFAULT 1  -- 1=허용, 0=차단

왜 필요한가?

대부분은 “허용” 규칙이지만, 때로는 “차단” 규칙도 필요합니다.

예시: 정책3 (보안 정책)

  • 개발팀 전체 허용 (is_allowed=1)
  • 디자인팀 과장 이상 허용 (is_allowed=1)
  • 이영희 개인 허용 (is_allowed=1)
  • 최지원 개인 차단 (is_allowed=0) ← 차단 규칙

DEFAULT 1의 의미:

  • 값을 생략하면 자동으로 1
  • 대부분 허용 규칙이므로 편의성 향상

4. 5개 컬럼 복합 UNIQUE

UNIQUE(policy_id, permission_type, department, min_position_level, user_id)

왜 5개나 포함되는가?

같은 규칙 중복 방지:

✅ 허용 (다른 조합):

  • (정책3, department, 개발팀, NULL, NULL) – 개발팀 전체
  • (정책3, department, 디자인팀, 3, NULL) – 디자인팀 과장 이상
  • (정책3, user_exception, NULL, NULL, 2) – 이영희 개인

❌ 차단 (중복):

  • (정책3, department, 개발팀, NULL, NULL) – 첫 번째 등록
  • (정책3, department, 개발팀, NULL, NULL) – 중복! DB 에러 발생

SQLite의 NULL 특성:

  • NULL은 UNIQUE 비교에서 특수하게 처리됨
  • (1, department, 개발팀, NULL, NULL)과 (1, department, 개발팀, NULL, NULL)은 중복으로 간주
  • 하지만 (1, user_exception, NULL, NULL, 2)와 (1, user_exception, NULL, NULL, 3)은 다름 (user_id가 다름)
부서/직급/개인 예외 권한 현황 조회 결과 - permission_type 구분 표시
부서/직급/개인 예외 권한 현황 조회 결과

특정 사용자 권한 체크

디자인팀 사원 이영희(user_id=2)가 볼 수 있는 정책:

SELECT DISTINCT
  p.policy_id,
  p.title AS 정책명
FROM policies p
JOIN policy_permissions pp ON p.policy_id = pp.policy_id
WHERE 
  -- 일반 규칙 (부서 + 직급)
  (pp.permission_type IN ('department', 'position')
   AND pp.is_allowed = 1
   AND (pp.department IS NULL OR pp.department = '디자인팀')
   AND (pp.min_position_level IS NULL OR 1 >= pp.min_position_level))
  OR
  -- 개인 예외 (허용)
  (pp.permission_type = 'user_exception' 
   AND pp.user_id = 2 
   AND pp.is_allowed = 1)
ORDER BY p.policy_id;

쿼리 해석:

1. 일반 규칙 체크:

pp.permission_type IN ('department', 'position')  -- 부서나 직급 규칙
AND pp.is_allowed = 1                             -- 허용 규칙만
AND (pp.department IS NULL OR pp.department = '디자인팀')  -- 전체 또는 디자인팀
AND (pp.min_position_level IS NULL OR 1 >= pp.min_position_level)  -- 전체 또는 사원(1) 이상

2. 개인 예외 체크:

pp.permission_type = 'user_exception'  -- 개인 예외 규칙
AND pp.user_id = 2                     -- 이영희
AND pp.is_allowed = 1                  -- 허용

3. OR 연결:

  • 일반 규칙 또는 개인 예외 중 하나만 맞아도 열람 가능
디자인팀 사원 이영희가 볼 수 있는 정책 목록 조회 결과
디자인팀 사원 이영희가 볼 수 있는 정책 목록 조회 결과

권한 로직의 핵심:

  • 여러 조건을 OR로 연결
  • 부서 OR 직급 OR 개인예외 중 하나라도 맞으면 허용
  • 이 부분을 만약 if문으로 표현한다면 코드 100줄 필요, 테이블로 하면 데이터 몇 줄로 해결 가능

배운 점

1. 문자열 저장의 함정

“1,3,5” 형태로 저장하는 방식이 편해 보이지만, 검색/수정/통계 부분에서 모두 어려워질 수 있습니다.

잘못된 예:

bookmarks TEXT  -- "1,3,5"

문제:

  • 검색: LIKE '%3%' (느림)
    • 13, 30, 123, … “3” 전부 매칭, 부정확한 결과
  • 삭제: 문자열 파싱 후 재조합
    • “1, 3, 5″에서 3번 삭제 시 문자열을 쪼개서 “1, 5″로 다시 합쳐야 함
  • 통계: 쉼표 개수 세야 함
    • 북마크 개수를 세러면 쉼표를 세고 +1 해야 함(“1,3,5” = 쉼표 2개 = 북마크 3개)

올바른 예:

CREATE TABLE bookmarks (user_id, policy_id, ...)

장점:

  • 검색: WHERE policy_id = 3 (빠름)
    • 인덱스를 사용한 정확한 일치 검색, 문자열 스캔 불필요
  • 삭제: DELETE WHERE policy_id = 3
    • 해당 row만 삭제, 문자열 파싱이나 재조합 불필요
  • 통계: COUNT(*)
    • row 개수만 세면 북마크 수 집계 완료

원칙: 관계가 있으면 별도 테이블로 분리

  • 관계가 있는 경우 (별도 테이블 필요):
    • 사용자 1명이 정책 여러 개 북마크 → bookmarks 테이블 분리
    • 정책 1개에 댓글 여러 개 → comments 테이블 분리 → 1:N 관계 = 별도 테이블
  • 관계가 없는 경우 (같은 테이블에 컬럼):
    • 사용자 1명의 이름, 이메일, 부서 → users 테이블 안에 컬럼으로 포함
    • 정책 1개의 제목, 내용, 작성일 → policies 테이블 안에 컬럼으로 포 → 1:1 속성 = 같은 테이블 컬럼

2. NULL의 두 가지 의미

의미 1: “값이 없음”

updated_at TIMESTAMP  -- 아직 수정 안 함 → NULL

의미 2: “제한 없음”

department TEXT  -- 전 부서 → NULL
min_position_level INTEGER  -- 전 직급 → NULL

이 차이를 이해하는 데 시간이 걸렸습니다. 컬럼 정의에 따라서 같은 NULL이지만 의미가 달라질 수 있습니다.

3. 복합 UNIQUE의 활용

(policy_id, department) 조합이 유일하다는 제약은 실수를 방지합니다.

실수로 중복 등록 시:

INSERT INTO policy_permissions (policy_id, department) VALUES (1, '디자인팀');
INSERT INTO policy_permissions (policy_id, department) VALUES (1, '디자인팀');  -- 중복!

DB가 에러 발생:

Error: UNIQUE constraint failed: policy_permissions.policy_id, policy_permissions.department

기획에서 “같은 조건을 두 번 등록하면 안 된다”는 규칙을 DB 제약 조건으로도 걸 수 있다는 것을 이번에 처음 알았습니다.

4. 테이블 분리 vs 통합

언제 분리하는 것이 좋은가?

  • 엔티티가 명확히 다를 경우 (users, policies)
  • 1:N, N:M 관계

언제 통합해야 하는가?

  • 조건 타입이 여러 개일 경우 (department, position, user_exception)
  • 자주 확장될 것 같은 상황인 경우
    • “팀별 권한”, “프로젝트별 권한”, “기간별 권한” 같은 새로운 조건 타입이 계속 추가될 가능성이 있을 때
  • permission_type 컬럼으로 구분

실습하면서 분리할지 통합할지 기준이 처음에는 감이 안 잡혔는데 “나중에 확장될 가능성이 있느냐”를 기준으로 판단하니 조금 더 명확해졌습니다.

5. soft delete의 필요성

처음 시도:

deleted BOOLEAN  -- true/false

문제: “언제 삭제했는지” 모름

개선:

deleted_at TIMESTAMP  -- 2026-02-13 17:00:00

장점:

  • 삭제 시점 기록
  • 복구 가능
  • 통계: “이번 주 삭제된 댓글 수”

단순히 true/false 하나 차이인 것 같지만, 실제로는 이 컬럼 하나가 “복구 가능 여부”“삭제 시점 통계”를 결정한다는 걸 직접 설계해보고 나서야 체감이 됐습니다.


PM 관점에서의 시사점

개발자와 소통이 명확해진다

“정책별로 열람 권한을 설정할 수 있어야 해요”라고 요구사항을 전달하면, 개발자는 “어떤 조건으로요? 부서만요? 직급도 체크해야 하나요? 예외 처리는요?”라고 되묻게 됩니다. 그러면 PM은 다시 생각해보고 답하고, 개발자는 또 질문하고… 이 과정이 너무 많이 반복된다면 소통 비용이 커지게 됩니다.

하지만 “policy_permissions 테이블에 policy_id, permission_type, department, min_position_level, user_id를 넣고, 복합 UNIQUE로 중복 방지하면 어떨까요?”라고 구체적으로 제안하면 이야기가 달라집니다. 테이블 구조와 제약 조건까지 언급하면 개발자는 바로 구현에 들어갈 수 있게 됩니다.

완벽한 설계가 아니어도 괜찮습니다. 최소한 같은 그림을 보며 대화할 수 있다는 점이 가장 중요하다고 생각합니다.

불가능한 기획을 미리 걸러낸다

“사용자마다 다른 권한을 실시간으로 변경할 수 있어야 해요”라는 요구사항이 들어왔을 때, DB 구조를 모르면 “네, 가능할 것 같아요”라고 쉽게 답하게 됩니다. 그러다 개발 막바지 일정에서 “생각보다 복잡하네요”, “일정 늘려야 할 것 같아요”라는 말을 듣게 되는 경우가 많습니다.

하지만 데이터 구조를 이해하면 “is_allowed 값만 UPDATE하면 되는 건가요, 아니면 권한 변경 이력을 추적하는 별도 테이블이 필요한가요?”라고 물어볼 수 있습니다.

이력 추적이 필요하다면 테이블이 하나 더 생기고, 그만큼 개발 시간이 늘어난다는 걸 알 수 있게 됩니다. 초기에 범위를 명확히 하면 일정 조정도 미리 할 수 있습니다.

데이터 구조가 비즈니스 로직을 결정한다

“부서 조건 OR 직급 조건 OR 개인 예외” 같은 권한 로직은 복잡해 보입니다. 실제로 이걸 코드로 짜면 if문이 여러 줄 필요하고, 새로운 조건이 추가될 때마다 코드를 수정하고 배포해야 한다는 것을 배웠습니다. 엔지니어링팀, 디자인팀, 과장 이상, 특정 사용자… 조건이 늘어날수록 코드는 점점 복잡해질 것입니다.

그런데 테이블 구조를 잘 설계해두면 이야기가 달라집니다. permission_type 컬럼으로 조건을 구분하고, 필요한 값만 채워서 row를 추가하면 됩니다.

새로운 조건이 생겨도 INSERT 한 줄이면 끝입니다. 코드는 단순한 SELECT 쿼리 하나로 모든 경우를 처리할 수 있습니다.

이번 실습을 통해 “테이블 구조를 잘 설계하면 비즈니스 로직이 간단해진다”는 말의 의미를 이해할 수 있었습니다. 반대로 구조가 잘못되면 if문 100줄로도 부족할 수 있습니다.

아직 DB 설계를 처음부터 혼자 할 수 있는 수준은 아니지만, “이 기능에 어떤 테이블이 필요할 것 같다”는 감은 조금씩 생기기 시작한 것 같습니다.


마치며

PM이 DB를 완벽하게 인지하고 설계할 필요는 없다고 생각합니다.

하지만 “1:N 관계가 뭔지”, “FK가 왜 필요한지”, “복합 UNIQUE가 어떤 역할을 하는지” 이해하면, 기획 단계에서 놓치던 부분이 보이기 시작하는 것 같습니다.

“화면에는 이렇게 보이면 돼요”가 아니라 “이 데이터는 이런 구조로 저장되고, 이런 조건으로 조회되면 돼요”라고 기획 내용에 구조 설명을 덧붙여 할 수 있게 되는 것만으로도 충분하지 않을까 싶습니다.

저도 아직 배우는 중이지만 앞으로 발생할 비즈니스 로직을 이해하기 위한 첫걸음은 시작했다고 생각합니다.

다음 글 예고:
PM의 DB 실습 2편에서는 버전 관리와 태그 기능을 다룹니다. 불변성 패턴, 이력 추적, N:M 관계를 실습하며 데이터 설계의 핵심 원칙을 배울 예정입니다.


참고: 전체 SQL 코드

실습 방법:

  1. 아래 CREATE TABLE 구문을 순서대로 실행
  2. INSERT 구문으로 샘플 데이터 입력 (원하는 데이터로 수정 가능)
  3. SELECT 쿼리로 결과 확인

주의사항:

  • 테이블 생성 순서 중요: 참조되는 테이블(users, policies)을 먼저 생성
  • FK 제약조건이 있어 참조 테이블이 없으면 에러 발생
  • 샘플 데이터는 개념 이해용이므로 자유롭게 수정 가능

— 테이블 생성
CREATE TABLE users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
email TEXT,
department TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE policies (
policy_id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT,
department TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE bookmarks (
bookmark_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
policy_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id),
FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
UNIQUE(user_id, policy_id)
);

CREATE TABLE comments (
comment_id INTEGER PRIMARY KEY AUTOINCREMENT,
policy_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP,
FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
FOREIGN KEY (user_id) REFERENCES users(user_id)
);

CREATE TABLE comment_history (
history_id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL,
content TEXT,
modified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified_by INTEGER NOT NULL,
action TEXT CHECK(action IN (‘created’, ‘updated’, ‘deleted’)),
FOREIGN KEY (comment_id) REFERENCES comments(comment_id),
FOREIGN KEY (modified_by) REFERENCES users(user_id)
);

— 테이블 생성
CREATE TABLE users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
email TEXT UNIQUE,
department TEXT,
position TEXT, — “사원”, “대리”, “과장” (화면 표시용)
position_level INTEGER, — 1, 2, 3 (권한 체크용)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE policies (
policy_id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT,
created_by INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(user_id)
);

CREATE TABLE policy_permissions (
permission_id INTEGER PRIMARY KEY AUTOINCREMENT,
policy_id INTEGER NOT NULL,
permission_type TEXT CHECK(permission_type IN (‘department’, ‘position’, ‘user_exception’)),
department TEXT,
min_position_level INTEGER,
user_id INTEGER,
is_allowed INTEGER DEFAULT 1, — 1=허용, 0=차단
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (policy_id) REFERENCES policies(policy_id),
FOREIGN KEY (user_id) REFERENCES users(user_id),
UNIQUE(policy_id, permission_type, department, min_position_level, user_id)
);

다음 단계:

  • 샘플 데이터를 INSERT하여 실제 동작 확인
  • 글에서 설명한 SELECT 쿼리를 실행하여 결과 비교
  • 자신만의 시나리오로 데이터를 변경하며 실험

참고:

  • dbdiagram.io에서 DBML 코드로 ERD 시각화 가능
  • 실습 중 에러 발생 시 FK 제약조건과 테이블 생성 순서 확인

— Lane

Lane
Lanehttps://protolane.kr
프로덕트 기획·UX/UI 역량과 디자인 기반 사고를 바탕으로, 실무 인사이트와 커리어 성장 경험을 공유합니다.

LEAVE A REPLY

Please enter your comment!
Please enter your name here

인기 글 보기

ProtoLane, 디자인 기반 문제 해결형 서비스 기획자

👋 About Me 안녕하세요. 프로토레인(ProtoLane)입니다. 저는 제약 속에서 문제를 정의하고, 실행 가능한 해결책을 설계하는 서비스 기획자입니다. 7년간 웹 디자이너로 쌓은 시각적 사고와 구조...

실제 기획 프로젝트 경험 이후 검증된 Practical UX/Product 사고 프레임

이 글은 웹 디자이너에서 기획자로 전환하던 시점으로 작성된 뉴스레터 내용을, 이후 실제 기획 업무를 경험한 뒤 실전 UX·Product 사고...

대기업 IT 계열사 오피스 플랫폼 구축 및 고도화 프로젝트

Enterprise Digital Workspace Platform: 대기업 IT 계열사 오피스 플랫폼 구축 및 고도화 프로젝트 *비공개 프로젝트로, 비밀유지계약...

IT 스타트업 ‘AI 인증 보안 솔루션’ MVP 서비스

*본 프로젝트는 공개된 전시 시연용 프로토타입을 기반으로 작성되었으며, 기업명과 상세 기술 사양은 일부 대체 처리되었습니다. 📌 프로젝트...