본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : 개념 모델과 ERD

관계와 카디널리티

게시판의 CSV 회원 목록 오류를 통해 1:N·M:N·선택 참여를 구분하고 게시판 회원 관계 속성을 모델링합니다.

관계선 하나만 그리면 ‘한 게시판에 여러 회원’은 보이지만 최소 몇 명인지, 회원에게 게시판이 필수인지, 가입 시각과 역할을 어디에 둘지는 보이지 않습니다.

카디널리티는 최대 수, 참여도는 최소 수를 업무 문장과 예제 데이터로 검증해야 합니다.

회원은 게시판 0개 이상에 가입할 수 있고 게시판은 회원 1명 이상을 가져야 합니다.

가입마다 역할, 상태, joined_at이 다릅니다.

양쪽 모두 여러 건이고 연결 자체가 속성을 가지므로 board_memberships가 독립 관계 엔티티 후보입니다.


쉼표로 저장한 다중 값

CSV는 FK를 걸 수 없고 ID 1 검색이 11을 잘못 매칭합니다.

가입 역할과 시각을 같은 순서의 별 CSV에 저장하면 배열 길이 불일치가 생기며 동시 가입 UPDATE가 서로 덮습니다.

관계를 문자열 한 칸에 압축한 게시판
CREATE TABLE board_members_csv_bad (
  board_id BIGINT UNSIGNED NOT NULL,
  name VARCHAR(100) NOT NULL,
  member_ids VARCHAR(1000) NOT NULL,
  roles VARCHAR(1000) NOT NULL,
  PRIMARY KEY (board_id)
);
INSERT INTO board_members_csv_bad VALUES
  (1, 'DB 읽기 모임', '11,42', 'OWNER,MEMBER');

SELECT board_id, name
FROM board_members_csv_bad
WHERE member_ids LIKE '%1%';

회원 1은 가입하지 않았지만 문자열 11 때문에 게시판이 잘못 반환됩니다.

오류 실행 결과
search member_id = 1
board_id | name
1        | DB 읽기 모임

actual member IDs: 11, 42
false positive rows: 1

LIKE는 토큰 경계를 모르므로 관계 비교가 아닙니다.

FIND_IN_SET도 거짓 양성은 줄이지만 FK·인덱스·관계 속성·동시 쓰기 문제를 해결하지 않습니다.

M:N은 관계형 테이블 두 개 사이 선으로 직접 저장하지 않고 두 FK를 가진 연결 행으로 해소합니다.

역할과 joined_at은 회원이나 게시판이 아니라 특정 가입 사실에 종속됩니다.

문자열 목록이 잃은 네 관계 규칙

  1. 최대 수 — 한 A가 몇 B, 한 B가 몇 A와 연결 가능한지 양방향으로 묻습니다.
  2. 최소 수 — 0이 허용되는 쪽과 정확히 1이 필요한 쪽을 표시합니다.
  3. 관계 속성 — 두 키 조합이 없으면 의미 없는 값을 찾습니다.
  4. 행 검증 — 문자열 검색 대신 FK·UNIQUE·건수로 규칙을 재현합니다.

다대다 관계의 구현

boards 1:N board_memberships, members 1:N board_memberships 두 관계로 바꿉니다.

(board_id, member_id)는 같은 게시판 중복 가입을 막는 후보 키이고 역할·상태·joined_at은 게시판 가입에 둡니다.

회원이 게시판 0개인 선택 참여는 단순히 게시판 가입 행이 없음으로 표현됩니다.

게시판에 최소 1명 규칙은 FK만으로 보장하기 어려워 게시판 생성과 소유자 가입을 한 트랜잭션에 묶습니다.

Peter Chen과 Crow's Foot 표기 읽기

ERD 표기법은 같은 관계를 다른 모양으로 그립니다.

Peter Chen 표기법은 엔티티를 사각형, 속성을 타원, 관계를 마름모로 분리해 개념을 자세히 설명하기 좋습니다.

Crow's Foot 표기법은 엔티티 상자 안에 주요 속성을 함께 적고 관계선 끝의 기호로 최소·최대 수를 표시하므로 논리 모델과 실무 설계 도구에서 널리 사용합니다.

표기법이 달라도 읽어야 할 질문은 같습니다.

관계선의 양 끝에서 각각 “최소 몇 개, 최대 몇 개인가?”를 읽습니다.

Crow's Foot 끝 기호수량읽는 방법
||정확히 1반드시 하나
o|0 또는 1없어도 되고 최대 하나
|{1개 이상반드시 하나 이상, 최대 여러 개
o{0개 이상없어도 되고 여러 개 가능

회원 게시판의 관계를 간단한 문자 표기로 적으면 다음과 같습니다.

관계선 양 끝의 최소·최대 수
members ||--o{ posts             : writes
boards  ||--|{ board_memberships : contains
members ||--o{ board_memberships : joins

첫 줄은 “각 게시글은 정확히 한 회원에게 속하고, 한 회원은 게시글이 없거나 여러 개일 수 있다”는 뜻입니다.

둘째 줄은 “각 가입은 정확히 한 게시판에 속하고, 활성 게시판은 가입을 하나 이상 가져야 한다”는 업무 규칙입니다.

셋째 줄은 가입하지 않은 회원도 존재할 수 있음을 나타냅니다.

Chen 표기에서는 같은 내용을 회원 — 기록한다 — 게시글이라는 관계와 각 끝의 1, N, 선택·필수 참여 표시로 나타냅니다.

어느 표기법을 선택하든 용어 사전의 관계 문장, ERD의 기호, DDL의 FK·NULL·UNIQUE가 서로 같은 규칙을 말해야 합니다.

다만 ERD의 모든 최소 수가 FK 하나로 강제되는 것은 아닙니다.

posts.author_id NOT NULL과 FK는 게시글 쪽의 “회원 정확히 1”을 막아 주지만, “게시판은 가입을 최소 1개 가진다”처럼 부모가 자식을 반드시 가져야 하는 규칙은 여러 행을 함께 봐야 합니다.

이런 규칙은 생성 트랜잭션, 제한된 권한, 사후 점검 쿼리로 보완합니다.

최소·최대 수를 양방향으로 읽기

  1. 방향 없음 — FK는 저장 위치일 뿐 양쪽 방향 JOIN을 막지 않습니다.
  2. 다 쪽 FK — 연결 행이 두 부모 FK를 가져 유효한 조합만 저장합니다.
  3. 최소 1 규칙 — 행 삭제·생성 경계를 트랜잭션과 권한 정책으로 지킵니다.
  4. 용어 사전 — 구성원, 소유자, 활성 의미를 한 정의로 고정합니다.

회원 관계 엔티티

게시판과 게시판 가입을 분리하고 조합 UNIQUE와 상태 검사를 둡니다.

소유자 가입을 게시판 생성 트랜잭션 안에서 함께 삽입합니다.

아래 예제는 ch9-1에서 만든 concept_members를 사용하며, 관계 수를 고정하기 위해 회원 11과 42를 명시적으로 추가합니다.

게시판과 board_memberships 관계 엔티티
DROP TABLE IF EXISTS concept_board_invitations;
DROP TABLE IF EXISTS concept_board_memberships;
DROP TABLE IF EXISTS concept_boards;

INSERT INTO concept_members (member_id, email, display_name)
VALUES
  (11, 'owner@example.com', '게시판 소유자'),
  (42, 'member@example.com', '게시판 회원');

CREATE TABLE concept_boards (
  board_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  name VARCHAR(100) NOT NULL,
  created_by_member_id BIGINT UNSIGNED NOT NULL,
  PRIMARY KEY (board_id),
  FOREIGN KEY (created_by_member_id) REFERENCES concept_members (member_id)
);
CREATE TABLE concept_board_memberships (
  membership_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  board_id BIGINT UNSIGNED NOT NULL,
  member_id BIGINT UNSIGNED NOT NULL,
  role VARCHAR(16) NOT NULL,
  status VARCHAR(16) NOT NULL,
  joined_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (membership_id),
  UNIQUE (board_id, member_id),
  FOREIGN KEY (board_id) REFERENCES concept_boards (board_id),
  FOREIGN KEY (member_id) REFERENCES concept_members (member_id),
  CHECK (role IN ('OWNER', 'MEMBER')),
  CHECK (status IN ('ACTIVE', 'LEFT'))
);

INSERT INTO concept_boards (name, created_by_member_id)
VALUES ('데이터베이스 게시판', 11);

SET @concept_board_id := LAST_INSERT_ID();

INSERT INTO concept_board_memberships
  (board_id, member_id, role, status, joined_at)
VALUES
  (@concept_board_id, 11, 'OWNER', 'ACTIVE', '2026-07-14 12:00:00'),
  (@concept_board_id, 42, 'MEMBER', 'ACTIVE', '2026-07-14 12:10:00');

회원 1 검색은 member_id 동등 비교로 정확하고 가입별 역할·상태가 같은 행에 정렬됩니다.

중복 가입과 존재하지 않는 회원 실패
INSERT INTO concept_board_memberships
  (board_id, member_id, role, status)
VALUES (@concept_board_id, 11, 'MEMBER', 'ACTIVE');
-- ERROR 1062: 같은 게시판-회원 조합 중복
INSERT INTO concept_board_memberships
  (board_id, member_id, role, status)
VALUES (@concept_board_id, 999999, 'MEMBER', 'ACTIVE');
-- ERROR 1452: 존재하지 않는 회원 FK
개선 실행 결과
member_id = 1 memberships: 0 rows
member_id = 11 memberships: 1 row (OWNER, ACTIVE)
member_id = 42 memberships: 1 row (MEMBER, ACTIVE)
duplicate (board_id, member_id): ERROR 1062
unknown member: ERROR 1452

created_by와 소유자가 같은 사람이어야 한다는 중복 규칙은 불일치 위험이 있습니다.

생성 프로시저는 감사 사건이고 현재 소유자는 게시판 가입 역할이라면 둘을 구분하고 소유자 이전 정책을 트랜잭션으로 관리합니다.

게시판에 소유자 정확히 한 명은 검사로 여러 행을 볼 수 없어 단순 제약이 아닙니다.

별도의 boards.owner_membership_id 참조, 잠금 트랜잭션, 권한 서비스 규칙을 비교합니다.

소유자 생성과 중복 가입을 검증하기

  1. 게시판 생성 — 게시판과 소유자 게시판 가입을 한 트랜잭션에 넣습니다.
  2. 선택 참여 — 가입 없는 회원이 정상적으로 존재하는지 확인합니다.
  3. 중복 가입 — 같은 조합 재삽입이 1062인지 확인합니다.
  4. 탈퇴 — 행 삭제 대신 상태 LEFT와 이력 보존이 요구인지 결정합니다.

카디널리티와 참여도 확인

게시판별 활성 회원 수와 회원별 게시판 수를 각각 집계합니다.

최소 1 규칙을 위반한 활성 게시판을 운영 진단 쿼리로 찾습니다.

관계 수와 owner 대사
SELECT g.board_id, g.name,
       COUNT(CASE WHEN e.status = 'ACTIVE' THEN 1 END) AS active_members,
       COUNT(CASE WHEN e.status = 'ACTIVE' AND e.role = 'OWNER' THEN 1 END) AS owners
FROM concept_boards AS g
LEFT JOIN concept_board_memberships AS e ON e.board_id = g.board_id
GROUP BY g.board_id, g.name;

SELECT member_id, COUNT(*) AS active_boards
FROM concept_board_memberships
WHERE status = 'ACTIVE'
GROUP BY member_id;

정상 게시판은 active_members >= 1, 소유자 = 1입니다.

회원은 결과 행이 없으면 게시판 0개라는 선택 참여를 만족합니다.

현재 건수와 허용 카디널리티 구분

카디널리티는 현재 데이터 최대값이 아니라 허용 규칙입니다.

지금 회원이 한 명뿐이어도 요구가 여러 명을 허용하면 1:N입니다.

반대로 현재 중복 데이터가 있다고 M:N이 정답인 것도 아닙니다.

용어 사전은 게시판 구성원, 회원, 소유자, 탈퇴 상태의 정의와 동의어를 정해 컬럼과 API가 서로 다른 말을 쓰지 않게 합니다.

가입 행의 키·이력 선택표

선택얻는 효과감수할 비용적합한 조건
복합 PK조합 정체성 간결하위 FK 넓음가입을 외부 참조하지 않을 때
membership_id관계 이력 참조 용이조합 UNIQUE 추가가입 자체가 수명 가질 때
행 삭제현재 관계 단순가입 이력 소실감사 요구 없을 때
상태 보존탈퇴·재가입 추적현재 조건 관리이력이 중요할 때

0·1·N 예제 데이터로 Crow's Foot 확인

  1. 수량 문장 — 각 관계를 0..N, 1..N, 정확히 1로 양방향 작성합니다.
  2. 예제 데이터 — 0가입 회원, 1명 게시판, 여러 명 게시판을 만듭니다.
  3. 소유자 이동 — 두 트랜잭션이 동시에 소유자를 바꿀 때 규칙을 설계합니다.
  4. 용어 — 회원·회원·멤버가 같은지 다른지 용어집으로 합의합니다.

게시판 삭제와 회원 수 캐시 관리

게시판 삭제 연쇄는 게시판 가입 이력을 없앱니다.

보존 정책과 내보내기·감사 요구를 결정한 뒤 RESTRICT·소프트 삭제를 선택합니다.

활성 회원 수 캐시를 게시판에 두면 게시판 가입 변경과 트랜잭션으로 동기화하고 원본 건수 일치 검사 작업을 둡니다.

재가입·초대·소유자 이전·테넌트 경계

  • 탈퇴 후 재가입을 같은 조합 행 UPDATE로 볼지 새 가입 사건으로 볼지 이력 요구에 따라 키가 달라집니다.
  • 초대 상태가 있으면 가입 전 관계와 실제 포함 여부를 같은 상태 머신에 둘지 분리합니다.
  • 소유자 탈퇴 전 새 소유자 선출이 필수이며 두 변경을 한 트랜잭션으로 묶습니다.
  • 테넌트 경계가 있다면 게시판과 회원이 같은 테넌트인지 단일 FK 두 개만으로 충분한지 검토합니다.

다음 실습은 지금까지의 엔티티·속성·카디널리티·관계 엔티티 판단을 게시글 요구사항 전체에 적용하고 용어집과 검증 DDL을 함께 만듭니다.


관계 모델 완료 기준

판단 축확인할 질문
최대양쪽에서 최대 1인지 N인지 문장으로 썼는가?
최소0 허용과 필수 참여를 구분했는가?
FK 위치다 쪽 또는 관계 엔티티가 참조 키를 갖는가?
관계 속성두 키 조합에 종속된 값을 연결 행에 두었는가?
다행 규칙최소 1·소유자 1명의 트랜잭션 대책이 있는가?

Crow's Foot 기호를 그린 뒤 끝내지 않고 허용 예제 데이터와 위반 예제 데이터를 만듭니다.

DB 제약이 직접 강제하지 못하는 최소 참여는 서비스 트랜잭션과 진단 쿼리로 운영합니다.


연습 문제

회원은 여러 게시판에 초대될 수 있고 초대는 초대한 사람, 만료 시각, ACCEPTED·DECLINED 상태를 가집니다.

포함 여부와 초대를 하나의 테이블로 둘지 분리할지 판단하세요.

해설과 예시 답안

초대는 수락 전에도 존재하고 만료·거절 수명이 포함 여부와 다릅니다.

초대를 별 엔티티로 두고 수락 트랜잭션이 게시판 가입을 만들며 초대를 ACCEPTED로 바꿉니다.

CREATE TABLE concept_board_invitations (
  invitation_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  board_id BIGINT UNSIGNED NOT NULL,
  invitee_member_id BIGINT UNSIGNED NOT NULL,
  invited_by_member_id BIGINT UNSIGNED NOT NULL,
  status VARCHAR(16) NOT NULL,
  expires_at DATETIME(6) NOT NULL,
  PRIMARY KEY (invitation_id),
  UNIQUE (board_id, invitee_member_id, status),
  FOREIGN KEY (board_id) REFERENCES concept_boards (board_id),
  CHECK (status IN ('PENDING', 'ACCEPTED', 'DECLINED', 'EXPIRED'))
);

대기 중복을 어떻게 막을지, 과거 ACCEPTED 이력을 같은 UNIQUE가 방해하지 않는지 검토합니다.

상태 포함 UNIQUE는 여러 과거 상태 중복을 제한하므로 별 활성 초대 모델이 필요할 수 있습니다.


핵심 정리

  • 카디널리티는 양방향 최대 수, 참여도는 최소 수를 표현합니다.
  • M:N은 두 FK를 가진 관계 엔티티로 해소합니다.
  • 관계에 종속된 역할·상태·시각은 연결 행의 속성입니다.
  • 여러 행을 보는 최소 참여 규칙은 트랜잭션과 일치 검사 쿼리가 필요합니다.

다음 문서에서는 하나의 실제 요구사항을 처음부터 ERD·용어집·DDL·검증 쿼리까지 완성합니다.