ERD 설계서 - seru1027/35cheol GitHub Wiki

ERD 설계서

v0.2 · 2026-10-08 · 멘토 피드백 반영 (이슈 #1, 바뀐 곳은 맨 아래 변경 이력) · MySQL 8 / InnoDB / utf8mb4 · MVP 테이블 4개 기준 문서: API 명세서 v0.2. 테이블마다 반영한 API ID를 적고, 제약과 SQL 조건이 어떤 응답 코드의 근거인지 연결합니다. 원본 SQL: server/src/db/schema.sql


0. 공통 규칙

0-1. 읽는 법

테이블마다 아래 순서로 적습니다.

  1. 반영한 API — 이 테이블을 읽거나 쓰는 API ID
  2. CREATE TABLE — schema.sql과 같은 내용
  3. 제약 → 응답 — UNIQUE·NOT NULL·길이 같은 DB 제약이 어느 API의 어떤 code의 근거인지
  4. SQL 조건 → 응답 — 제약이 아니라 WHERE 조건(조회 결과 0행, 바뀐 행 0개)에서 나오는 응답

0-2. 공통 규칙

항목 규칙
이름 테이블은 복수형 snake_case(users), 칼럼은 snake_case(login_id). API JSON은 camelCase(loginId)
ID INT UNSIGNED AUTO_INCREMENT 기본 키. API의 userId·orgId 등은 이 값
제약 이름 기본 키 PRIMARY KEY, 유일 uq_테이블_칼럼, 인덱스 idx_테이블_칼럼, 외래 키 fk_테이블_대상
시각 모두 DATETIME, UTC로 저장 (D6). 커넥션 풀이 세션 시간대를 +00:00으로 맞추므로 CURRENT_TIMESTAMP도 UTC. API는 ISO 8601 문자열로 응답
글자 수 VARCHAR(n)의 n은 글자 수. 서버 검사도 [...str].length(코드 포인트)로 같은 기준 (D7)
문자셋 DB 기본 utf8mb4 / utf8mb4_0900_ai_ci. 칼럼에 따로 적지 않으면 이 값을 물려받음
조직 데이터 조직에 속한 테이블은 모두 org_id를 가지며, 조회·수정·삭제 SQL에 항상 org_id 조건을 겁니다 (IDOR 방어)
검사 순서 서버 검사가 먼저 400으로 막습니다. DB 제약(NOT NULL·길이·CHECK)은 서버 버그를 잡는 마지막 방어선이고, 예외는 UNIQUE(아래 1062)

0-3. MySQL 에러 → 응답

에러 뜻 응답 나오는 곳
1062 ER_DUP_ENTRY UNIQUE 위반 사용자가 고른 값이면 409 (미리 조회하지 않고 1062를 바꿔 응답, 동시 요청에도 1건만 성공). 서버가 뽑은 값이면 다시 뽑기 AUTH-01(409), JOIN-02(조건부 UPDATE로 이어짐), JOIN-01(재시도)
1406 ER_DATA_TOO_LONG VARCHAR 길이 초과 (strict 모드라 잘라 저장하지 않음) 500 — 서버 길이 검사가 빠진 버그 모든 문자열 칼럼
1451 ER_ROW_IS_REFERENCED_2 다른 행이 참조 중인 행을 삭제 500 — MVP에는 사용자·조직 삭제가 없음 users, organizations
3819 ER_CHECK_CONSTRAINT_VIOLATED CHECK 위반 500 — 상태와 역할을 함께 바꾸지 않은 버그 memberships (D8)

ERD

erDiagram
    users ||--o{ memberships : "소속"
    organizations ||--o{ memberships : "구성원"
    organizations ||--o{ notices : "공지"
    users ||--o{ notices : "작성"

    users {
        INT id PK
        VARCHAR login_id UK
        VARCHAR name
        CHAR password_hash
        DATETIME created_at
    }
    organizations {
        INT id PK
        VARCHAR name
        CHAR invite_code UK "NULL = 미발급"
        DATETIME created_at
    }
    memberships {
        INT id PK
        INT user_id FK
        INT org_id FK
        ENUM role "OWNER·ADMIN·MEMBER, PENDING이면 NULL"
        ENUM status "PENDING·ACTIVE·REJECTED"
        DATETIME requested_at
        DATETIME joined_at "NULL = 아직 가입 전"
    }
    notices {
        INT id PK
        INT org_id FK
        INT author_id FK
        VARCHAR title
        TEXT content
        DATETIME created_at
        DATETIME updated_at "NULL = 수정 안 됨"
    }

핵심은 역할이 사용자가 아니라 memberships(사용자–조직 관계)에 붙는다는 점입니다. 한 사람이 A 동아리에서는 회장, B 동아리에서는 일반 회원일 수 있습니다.


1. users 테이블

API 명세: AUTH-01(회원가입), AUTH-02(로그인), AUTH-03(내 정보 조회) 반영

CREATE TABLE users (
  id            INT UNSIGNED  NOT NULL AUTO_INCREMENT,
  login_id      VARCHAR(20)   NOT NULL,  -- 4~20자 영문·숫자. 서버가 앞뒤 공백 제거·소문자 변환 후 저장
  name          VARCHAR(20)   NOT NULL,  -- 앞뒤 공백 제거 후 1~20자, 중복 허용
  password_hash CHAR(60)      NOT NULL,  -- bcrypt 해시는 항상 60자. 원문 저장 금지
  created_at    DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,

  PRIMARY KEY (id),
  -- AUTH-01 409 LOGIN_ID_ALREADY_EXISTS의 근거 (동시 가입도 1건만). AUTH-02 로그인 조회도 이 인덱스를 쓴다
  -- DB 기본 콜레이션(ai_ci)이 대소문자를 구분하지 않아 소문자 변환이 빠져도 'Kim123'과 'kim123'은 중복
  CONSTRAINT uq_users_login_id UNIQUE (login_id)
) ENGINE = InnoDB;

제약 → 응답

  • login_id: UNIQUE → AUTH-01 409 LOGIN_ID_ALREADY_EXISTS의 근거. INSERT에서 난 1062를 409로 바꿉니다. 같은 아이디로 동시에 가입해도 1건만 성공합니다.
  • login_id VARCHAR(20), name VARCHAR(20): AUTH-01 400 INVALID_LOGIN_ID_LENGTH·INVALID_NAME_LENGTH의 상한(20자)과 같은 값. 서버가 먼저 막고, DB는 마지막 방어선입니다.
  • NOT NULL (login_id, name, password_hash): null 저장만 막습니다. 빈 문자열 ''은 막지 못하므로 REQUIRED_LOGIN_ID·REQUIRED_NAME(공백뿐인 값 포함)은 서버 검사가 맡습니다.
  • password_hash CHAR(60): 비밀번호 길이(8~64자)와 관계없이 해시는 항상 60자라 고정 길이 CHAR. INVALID_PASSWORD_* 400은 해싱 전 원문 검사라 DB와 무관합니다.

SQL 조건 → 응답

  • AUTH-02: SELECT id, login_id, name, password_hash FROM users WHERE login_id = ? → 0행이면 가짜 해시로 비교한 뒤 401 INVALID_LOGIN_ID_OR_PASSWORD. uq_users_login_id가 인덱스 역할도 해서 이 조회가 빠릅니다.
  • AUTH-03: SELECT id, login_id, name FROM users WHERE id = ? (토큰의 사용자 ID) → 0행이면 401 UNAUTHORIZED (토큰은 유효하지만 사용자가 없음).

응답 필드 ↔ 칼럼 (AUTH-01·02·03 data 공통)

응답 필드 칼럼
userId id
loginId login_id (소문자로 바뀐 값)
name name
  • password_hash, created_at은 응답에 담지 않습니다.

대소문자 처리: 서버가 아이디를 소문자로 바꿔 저장하므로 Kim123과 kim123은 같은 값 kim123이 되어 UNIQUE에 걸립니다. login_id는 DB 기본 콜레이션(utf8mb4_0900_ai_ci, 대소문자 비구분)도 물려받아 비교 단계에서도 한 번 더 같은 값으로 봅니다. 화면에 원래 대소문자를 보여줄 필요가 없는 아이디라, 표시용 원본을 남기지 않고 저장값 자체를 하나로 통일했습니다.


2. organizations 테이블

API 명세: ORG-01(조직 생성), ORG-02(내 조직 목록), JOIN-01(초대 코드 발급·조회), JOIN-02(가입 신청) 반영

CREATE TABLE organizations (
  id          INT UNSIGNED  NOT NULL AUTO_INCREMENT,
  name        VARCHAR(30)   NOT NULL,  -- 앞뒤 공백 제거 후 1~30자, 중복 허용
  -- D1: 조직당 1개라 칼럼으로 둔다. 만료·횟수 제한(학습용 확장)이 생기면 별도 테이블로 분리
  -- 8자리, 대문자+숫자에서 0 O 1 I L을 뺀 31자 중 crypto로 뽑는다. NULL = 아직 발급 안 함. 재발급 = 덮어쓰기
  invite_code CHAR(8)       NULL,
  created_at  DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,

  PRIMARY KEY (id),
  -- 코드만으로 조직을 찾으므로(JOIN-02) 서비스 전체에서 유일. NULL끼리는 겹쳐도 된다(미발급 조직 여러 개)
  -- JOIN-01 발급에서 1062가 나면 409가 아니라 새 코드를 뽑아 다시 저장 (5번 연속 실패 → 500)
  CONSTRAINT uq_organizations_invite_code UNIQUE (invite_code)
) ENGINE = InnoDB;

제약 → 응답

  • invite_code: UNIQUE → JOIN-02의 코드 조회가 조직 하나만 찾는 근거. 같은 코드가 두 조직에 있을 수 없으므로 404 INVITE_CODE_NOT_FOUND가 아니면 정확히 한 조직입니다.
  • invite_code: UNIQUE 위반(1062) → JOIN-01 발급에서는 409가 아닙니다. 사용자가 고른 값이 아니라 서버가 뽑은 값이 다른 조직 코드와 겹친 것이므로, 새로 뽑아 다시 저장합니다. 5번 연속 겹치면 500 INTERNAL_SERVER_ERROR.
  • invite_code NULL → JOIN-01 조회 200 { "inviteCode": null } (미발급, 404 아님)의 근거.
  • name VARCHAR(30) → ORG-01 400 INVALID_ORG_NAME_LENGTH의 상한(30자)과 같은 값.
  • name에는 UNIQUE가 없음 → 이름 중복 허용(ORG-01). 그래서 ORG-01에는 409가 없고, 버튼 두 번 클릭은 프론트가 막습니다.

SQL 조건 → 응답

  • ORG-01: 한 트랜잭션으로 INSERT INTO organizations (name) VALUES (?) → 새 id(insertId)로 memberships에 OWNER·ACTIVE 1행. 둘 중 하나라도 실패하면 둘 다 취소(500). 응답 orgId = 새 id.
  • JOIN-01 발급: UPDATE organizations SET invite_code = ? WHERE id = ? → 덮어쓰므로 이전 코드는 이 순간 무효. 이미 들어온 신청(memberships 행)은 코드와 연결되어 있지 않아 그대로 남습니다.
  • JOIN-01 조회: SELECT invite_code FROM organizations WHERE id = ?
  • JOIN-02: SELECT id, name FROM organizations WHERE invite_code = ? (입력은 앞뒤 공백 제거·대문자 변환) → 0행이면 404 INVITE_CODE_NOT_FOUND. 재발급으로 무효가 된 코드도 여기서 0행입니다. uq_organizations_invite_code가 인덱스 역할을 합니다.
  • ORG-02의 name은 memberships와 JOIN해서 읽습니다.
  • ORG-03(조직 조회)은 이 테이블을 읽지 않습니다. 응답 { orgId, myRole }은 URL의 조직 ID와 멤버십 미들웨어가 읽은 memberships.role로 만들고, 조직이 없거나 내가 회원이 아닐 때의 404 ORG_NOT_FOUND도 memberships에서 나옵니다 → 3. memberships

응답 필드 ↔ 칼럼

응답 필드 칼럼 API
orgId id ORG-01·02, JOIN-02 (ORG-03은 URL의 조직 ID)
name name ORG-02, JOIN-02
inviteCode invite_code JOIN-01
  • created_at은 MVP 응답에 담지 않습니다.
  • 회장이 누구인지는 이 테이블에 없습니다(owner_id 칼럼 없음). 역할은 모두 memberships.role에 있습니다.

3. memberships 테이블

API 명세: ORG-01(조직 생성), ORG-02(내 조직 목록), JOIN-02(가입 신청), JOIN-03(가입 신청 목록), JOIN-04(가입 승인), MEMBER-01(회원 목록), 조직 API 공통 에러의 멤버십·역할 검사(ORG-03, JOIN-01, JOIN-03·04, MEMBER-01, NOTICE-01~05) 반영

CREATE TABLE memberships (
  id           INT UNSIGNED  NOT NULL AUTO_INCREMENT,  -- API의 membershipId (JOIN-03 목록 → JOIN-04 승인 URL)
  user_id      INT UNSIGNED  NOT NULL,
  org_id       INT UNSIGNED  NOT NULL,
  -- D2: PENDING·REJECTED는 역할 없음(NULL). 승인(JOIN-04) 때 MEMBER, 조직 생성(ORG-01) 때 OWNER
  --     재신청(JOIN-02)하면 다시 NULL로 → 이전 역할이 남아 승인 즉시 운영진이 되는 일을 막는다
  -- D3: 선언 순서 = 정렬 순서. ORDER BY role 하면 OWNER → ADMIN → MEMBER (MEMBER-01). 순서를 바꾸지 말 것
  role         ENUM('OWNER', 'ADMIN', 'MEMBER')        NULL,
  -- D4: 탈퇴·추방은 행을 지우지 않고 상태값으로 남긴다. 핵심 기능 단계에서 'LEFT', 'REMOVED' 추가 예정
  -- 멤버십 미들웨어: ACTIVE만 통과, PENDING → 403 MEMBERSHIP_PENDING, 나머지·행 없음 → 404 ORG_NOT_FOUND
  status       ENUM('PENDING', 'ACTIVE', 'REJECTED')   NOT NULL,
  -- D5: 행을 재사용하므로 시각 두 개를 따로 둔다
  requested_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,  -- 신청 시각. 재신청 때 갱신. 신청 목록(JOIN-03)·내 조직 목록(ORG-02) 정렬
  joined_at    DATETIME      NULL DEFAULT NULL,                   -- ACTIVE가 된 시각. 재신청 때 NULL로. 회원 목록 정렬·가입일(MEMBER-01)

  PRIMARY KEY (id),
  -- 한 사람·한 조직은 1행 (재신청 시 재사용). JOIN-02 INSERT의 1062 → 기존 행 UPDATE 또는 409
  -- 왼쪽 칼럼(user_id)만으로도 쓰이므로 내 조직 목록(ORG-02: WHERE user_id = ?)의 인덱스를 겸한다
  CONSTRAINT uq_memberships_user_org UNIQUE (user_id, org_id),
  -- 신청 목록(JOIN-03)·회원 목록(MEMBER-01): WHERE org_id = ? AND status = ?
  KEY idx_memberships_org_status (org_id, status),

  CONSTRAINT fk_memberships_user FOREIGN KEY (user_id) REFERENCES users (id),
  CONSTRAINT fk_memberships_org  FOREIGN KEY (org_id)  REFERENCES organizations (id),

  -- D8: 상태와 역할·가입 시각의 짝을 DB가 강제한다 (D2·D5). 어기면 3819 에러 → 500 (서버 버그)
  --     핵심 단계에서 LEFT·REMOVED를 추가할 때 이 식도 함께 고친다
  --   ACTIVE면 역할·가입 시각이 반드시 있음 / PENDING이면 둘 다 NULL / 그 밖의 상태는 제한하지 않음
  --   REJECTED도 역할 NULL(D2)이지만 MVP에는 거절 기능이 없어 아직 강제하지 않는다 → 거절(JOIN-05) 만들 때 추가
  CONSTRAINT chk_memberships_status_role CHECK (
    (status = 'ACTIVE'  AND role IS NOT NULL AND joined_at IS NOT NULL) OR
    (status = 'PENDING' AND role IS NULL     AND joined_at IS NULL) OR
    status NOT IN ('ACTIVE', 'PENDING')
  )
) ENGINE = InnoDB;

제약 → 응답

  • (user_id, org_id): UNIQUE → JOIN-02 409 JOIN_ALREADY_REQUESTED·ALREADY_ORG_MEMBER의 출발점. 새 신청의 INSERT가 1062로 실패하면 기존 행이 있다는 뜻이고, 아래 조건부 UPDATE로 넘어갑니다.
  • (user_id, org_id): UNIQUE → 멤버십 미들웨어의 조회가 최대 1행인 근거. 내 상태가 둘로 갈릴 일이 없습니다.
  • status ENUM ... NOT NULL → 조직 API 공통 에러의 멤버십 검사: ACTIVE만 통과, PENDING → 403 MEMBERSHIP_PENDING, REJECTED·행 없음 → 404 ORG_NOT_FOUND. ENUM이라 이 세 값 말고는 저장될 수 없습니다.
  • role ENUM ... NULL → 조직 API 공통 에러의 역할 검사 403 INSUFFICIENT_ROLE: API의 허용 역할 목록에 내 role이 없으면 403. 멤버십 검사를 통과한 ACTIVE만 역할 검사에 오므로 role은 항상 값이 있습니다.
  • id (기본 키) → JOIN-03 응답 membershipId, JOIN-04 URL의 :membershipId.
  • 외래 키 user_id·org_id → 없는 사용자·조직의 멤버십이 생기지 않습니다. MVP에는 사용자·조직 삭제가 없어 ON DELETE는 기본값(참조 중이면 삭제 거부)입니다.
  • CHECK chk_memberships_status_role (D8) → API 응답은 없습니다. 승인 SQL에서 role을 빠뜨리는 것 같은 서버 버그를 3819 에러(→ 500)로 바로 드러냅니다.

SQL 조건 → 응답

  • 멤버십 미들웨어 (조직 API 공통 에러의 멤버십 검사): SELECT id, role, status FROM memberships WHERE user_id = ? AND org_id = ? → 0행 또는 REJECTED면 404 ORG_NOT_FOUND, PENDING이면 403 MEMBERSHIP_PENDING, ACTIVE면 통과(요청에 역할·상태를 붙임). 없는 조직과 내가 회원이 아닌 조직이 같은 404가 되는 것도 이 한 문장 덕분입니다.
  • JOIN-02 가입 신청 (409 JOIN_ALREADY_REQUESTED·ALREADY_ORG_MEMBER):
    1. INSERT INTO memberships (user_id, org_id, status) VALUES (?, ?, 'PENDING') → 성공하면 201
    2. 1062면 UPDATE memberships SET role = NULL, status = 'PENDING', requested_at = 지금, joined_at = NULL WHERE user_id = ? AND org_id = ? AND status NOT IN ('PENDING', 'ACTIVE') → 바뀐 행 1개면 201 (REJECTED 행 재사용)
    3. 바뀐 행 0개면 SELECT status ...로 현재 상태를 보고 PENDING → 409 JOIN_ALREADY_REQUESTED, ACTIVE → 409 ALREADY_ORG_MEMBER
  • JOIN-04 가입 승인 (데이터 격리 404 + 비즈니스 규칙 409를 한 문장으로): UPDATE memberships SET status = 'ACTIVE', role = 'MEMBER', joined_at = 지금 WHERE id = ? AND org_id = ? AND status = 'PENDING' → 바뀐 행 0개면 WHERE id = ? AND org_id = ?로 다시 조회해 없으면 404 JOIN_REQUEST_NOT_FOUND(없는 ID, 다른 조직의 ID), 있으면 409 JOIN_REQUEST_ALREADY_PROCESSED(다른 운영진이 먼저 승인).
  • JOIN-03 신청 목록: WHERE m.org_id = ? AND m.status = 'PENDING' ORDER BY m.requested_at (users와 JOIN해 loginId·name). idx_memberships_org_status 사용.
  • MEMBER-01 회원 목록: WHERE m.org_id = ? AND m.status = 'ACTIVE' ORDER BY m.role, m.joined_at (D3). idx_memberships_org_status 사용.
  • ORG-02 내 조직 목록: WHERE m.user_id = ? AND m.status IN ('ACTIVE', 'PENDING') ORDER BY m.requested_at (organizations와 JOIN해 name). uq_memberships_user_org의 왼쪽 칼럼(user_id)으로 찾습니다.

API별로 바뀌는 값

API role status requested_at joined_at
ORG-01 조직 생성 (새 행) OWNER ACTIVE 지금 지금
JOIN-02 가입 신청 (새 행) NULL PENDING 지금 NULL
JOIN-02 재신청 (REJECTED 행 재사용) NULL로 PENDING으로 지금으로 NULL로
JOIN-04 가입 승인 MEMBER로 ACTIVE로 그대로 지금으로

응답 필드 ↔ 칼럼

응답 필드 칼럼 API
membershipId id JOIN-03, MEMBER-01(회장·운영진)
myRole / role role ORG-02·03, JOIN-02 / MEMBER-01
myStatus status ORG-02, JOIN-02
requestedAt requested_at JOIN-03
joinedAt joined_at MEMBER-01(회장·운영진)

4. notices 테이블

API 명세: NOTICE-01(공지 목록), NOTICE-02(공지 상세), NOTICE-03(공지 작성), NOTICE-04(공지 수정), NOTICE-05(공지 삭제) 반영

CREATE TABLE notices (
  id         INT UNSIGNED  NOT NULL AUTO_INCREMENT,
  org_id     INT UNSIGNED  NOT NULL,  -- 서버가 URL의 조직 ID로 넣는다. 모든 조회·수정·삭제의 조건 (IDOR 방어)
  author_id  INT UNSIGNED  NOT NULL,  -- 서버가 토큰의 사용자 ID로 넣는다. 응답 authorName은 users와 JOIN한 현재 이름 (D9)
  title      VARCHAR(100)  NOT NULL,  -- 앞뒤 공백 제거 후 1~100자
  -- API 명세서 NOTICE ②: body → content (HTTP 요청 본문 req.body와 헷갈리지 않게)
  content    TEXT          NOT NULL,  -- 앞뒤 공백 제거 후 1~5,000자 일반 텍스트, 그 밖에는 입력 그대로 저장 (TEXT 최대 65,535바이트)
  created_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME      NULL DEFAULT NULL,  -- 수정된 적 없으면 NULL (응답 updatedAt: null). NOTICE-04에서 기록

  PRIMARY KEY (id),
  -- 공지 목록(NOTICE-01): WHERE org_id = ? ORDER BY created_at DESC, id DESC
  -- InnoDB 보조 인덱스는 끝에 기본 키(id)를 품고 있어 (org_id, created_at, id) 순서로 정렬되어 있다
  KEY idx_notices_org_created (org_id, created_at),

  CONSTRAINT fk_notices_org    FOREIGN KEY (org_id)    REFERENCES organizations (id),
  CONSTRAINT fk_notices_author FOREIGN KEY (author_id) REFERENCES users (id)
) ENGINE = InnoDB;

제약 → 응답

  • title VARCHAR(100) → NOTICE-03·04 400 INVALID_TITLE_LENGTH의 상한(100자)과 같은 값.
  • content TEXT → NOTICE-03·04 400 INVALID_CONTENT_LENGTH(5,000자)는 서버만 검사합니다. TEXT는 글자 수가 아니라 65,535바이트까지 받으므로, DB 방어선은 5,000자보다 넓습니다(5,000자 × 최대 4바이트 = 20,000바이트라 언제나 들어감).
  • NOT NULL (title, content) → REQUIRED_TITLE·REQUIRED_CONTENT의 마지막 방어선(null만 막음, 빈 문자열은 서버가 막음 — users와 같음).
  • 외래 키 org_id → 없는 조직의 공지가 생기지 않습니다. 외래 키 author_id → 작성자 users 행이 반드시 있으므로 authorName을 만드는 JOIN이 공지를 빠뜨리지 않습니다. 공지를 가진 사용자는 삭제가 거부됩니다(1451, MVP에 사용자 삭제 없음).
  • updated_at NULL → NOTICE-02 응답 updatedAt: null(수정된 적 없음)의 근거.
  • 공지에는 UNIQUE가 없음 → NOTICE-03에 409가 없는 이유(같은 제목 허용, 두 번 클릭은 프론트가 막음).

SQL 조건 → 응답

  • NOTICE-01: SELECT n.id, n.title, u.name AS author_name, n.created_at FROM notices n JOIN users u ON u.id = n.author_id WHERE n.org_id = ? ORDER BY n.created_at DESC, n.id DESC. 다른 조직 회원은 그 전에 멤버십 미들웨어에서 404 ORG_NOT_FOUND로 막힙니다.
  • NOTICE-02: ... WHERE n.id = ? AND n.org_id = ? → 0행이면 404 NOTICE_NOT_FOUND (없는 공지, 다른 조직의 공지).
  • NOTICE-03: INSERT INTO notices (org_id, author_id, title, content) VALUES (URL의 조직 ID, 토큰의 사용자 ID, ?, ?) → 응답 noticeId = insertId. body의 orgId·authorId는 무시합니다.
  • NOTICE-04: UPDATE notices SET title = ?, content = ?, updated_at = 지금 WHERE id = ? AND org_id = ? → 찾은 행이 0개면 404 NOTICE_NOT_FOUND. 성공하면 NOTICE-02와 같은 SELECT로 다시 읽어 응답합니다.
  • NOTICE-05: DELETE FROM notices WHERE id = ? AND org_id = ? → 지운 행이 0개면 404 NOTICE_NOT_FOUND (다른 운영진이 먼저 삭제한 경우 포함).

응답 필드 ↔ 칼럼

응답 필드 칼럼 API
noticeId id NOTICE-01~04
title title NOTICE-01·02·04
content content NOTICE-02·04
authorName users.name (author_id로 JOIN, 읽는 시점의 이름, D9) NOTICE-01·02·04
createdAt created_at NOTICE-01·02·04
updatedAt updated_at NOTICE-02·04
  • authorName은 칼럼으로 저장하지 않고 (D9) 조회할 때 users와 JOIN해서 만듭니다. 작성자가 이름을 바꾸면 예전 공지도 바뀐 이름으로 보입니다 (MVP에는 이름 변경 API가 없어 아직 일어나지 않음).

설계 결정

ID 결정 이유
D1 초대 코드는 organizations의 칼럼 조직당 1개, 재발급은 덮어쓰기, 미발급은 NULL이라 칼럼 하나로 충분. 만료·횟수 제한(학습용 확장)이 생기면 별도 테이블로 분리
D2 PENDING·REJECTED의 역할은 NULL 기능정의서상 '아직 역할 없음'. 재신청 때 이전 역할(예: 운영진)이 남아 승인 즉시 운영진이 되는 사고를 막음
D3 역할 정렬은 ENUM 선언 순서 (ORDER BY role) 칼럼 추가 없이 OWNER → ADMIN → MEMBER. 선언 순서가 곧 정렬 순서라는 숨은 규칙이라 SQL 주석으로 명시
D4 탈퇴·추방은 행을 지우지 않고 상태값으로 '기록이 남는 운영'이라는 핵심 가치. 핵심 기능 단계에서 status에 LEFT·REMOVED 추가 예정
D5 requested_at + joined_at 두 칼럼 행을 재사용하므로 신청 시각과 가입 시각이 따로 필요. 회원 목록 가입일 = joined_at
D6 DATETIME + 세션 시간대 UTC 고정 TIMESTAMP는 2038년 한계와 자동 시간대 변환으로 헷갈림. DATETIME은 받은 값 그대로 저장하므로 연결 시간대를 UTC로 고정하면 규칙이 하나
D7 글자 수는 코드 포인트 기준 ([...str].length) JS length(UTF-16)와 VARCHAR(글자)의 차이를 없애 '100자'가 화면·서버·DB에서 같은 뜻이 되게
D8 memberships에 상태–역할·가입 시각 CHECK ACTIVE면 역할·가입 시각 필수, PENDING이면 둘 다 NULL을 DB가 강제(D2·D5). 승인 SQL 실수를 3819 에러로 바로 드러냄. REJECTED(D2: 역할 NULL)는 아직 강제하지 않음 — MVP에는 REJECTED를 만드는 기능이 없으므로, 거절 기능(JOIN-05)을 만들 때 CHECK에 추가. LEFT·REMOVED는 핵심 단계에서 정함
D9 authorName은 users와 JOIN한 현재 이름 공지는 '누가 썼나'를 알아보는 용도라 현재 이름이 덜 헷갈림. 이름을 한 곳(users.name)에만 두어 어긋날 일이 없음. 작성 당시 이름이 필요해지면 그때 칼럼 추가

이후 단계 (자리만)

단계 대상
핵심 Refresh Token 저장 (AUTH-05)
핵심 거절·역할 변경·추방·탈퇴·회장 위임 (JOIN-05, MEMBER-02~05) → memberships.status에 LEFT·REMOVED 추가 (D4)
추가 audit_logs 감사 로그 (승인자·삭제자 기록)
추가 dues·due_payments 회비
추가 schedules 일정
추가 새 공지 표시 — 마지막 확인 시각 칼럼 (NOTICE-06)

변경 이력

버전 날짜 내용
v0.2 2026-10-08 멘토 피드백 반영 (이슈 #1). 피드백 4: ORG-03이 organizations를 읽지 않음 (API 명세서 ORG-03에서 name 제거): organizations의 반영 API·SQL 조건·응답 필드에서 ORG-03 제외. 피드백 5: 검사 단계를 번호 대신 이름으로 (memberships, schema.sql 주석). 피드백 8: D2와 D8의 차이 명시 — REJECTED 규칙은 거절 기능(JOIN-05) 때 CHECK에 추가 (D8, memberships CHECK 주석)
v0.1 2026-10-06 DB 설계서 v0.1에서 재정리. 기준 문서를 API 명세서 v0.1로 바꾸고 멘토 형식(반영 API · CREATE TABLE · 제약 → 응답 · SQL 조건 → 응답)으로 MVP 테이블 4개 작성. notices.body → content(API 명세서 NOTICE ②), D8 상태–역할 CHECK, D9 authorName은 현재 이름. 로컬 MySQL 임시 DB에서 실행·제약 위반 확인