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. 읽는 법
테이블마다 아래 순서로 적습니다.
- 반영한 API — 이 테이블을 읽거나 쓰는 API ID
CREATE TABLE—schema.sql과 같은 내용- 제약 → 응답 —
UNIQUE·NOT NULL·길이 같은 DB 제약이 어느 API의 어떤code의 근거인지 - 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 409LOGIN_ID_ALREADY_EXISTS의 근거. INSERT에서 난 1062를 409로 바꿉니다. 같은 아이디로 동시에 가입해도 1건만 성공합니다.login_id VARCHAR(20),name VARCHAR(20): AUTH-01 400INVALID_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행이면 가짜 해시로 비교한 뒤 401INVALID_LOGIN_ID_OR_PASSWORD.uq_users_login_id가 인덱스 역할도 해서 이 조회가 빠릅니다. - AUTH-03:
SELECT id, login_id, name FROM users WHERE id = ?(토큰의 사용자 ID) → 0행이면 401UNAUTHORIZED(토큰은 유효하지만 사용자가 없음).
응답 필드 ↔ 칼럼 (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의 코드 조회가 조직 하나만 찾는 근거. 같은 코드가 두 조직에 있을 수 없으므로 404INVITE_CODE_NOT_FOUND가 아니면 정확히 한 조직입니다.invite_code:UNIQUE위반(1062) → JOIN-01 발급에서는 409가 아닙니다. 사용자가 고른 값이 아니라 서버가 뽑은 값이 다른 조직 코드와 겹친 것이므로, 새로 뽑아 다시 저장합니다. 5번 연속 겹치면 500INTERNAL_SERVER_ERROR.invite_code NULL→ JOIN-01 조회 200{ "inviteCode": null }(미발급, 404 아님)의 근거.name VARCHAR(30)→ ORG-01 400INVALID_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행이면 404INVITE_CODE_NOT_FOUND. 재발급으로 무효가 된 코드도 여기서 0행입니다.uq_organizations_invite_code가 인덱스 역할을 합니다. - ORG-02의
name은memberships와 JOIN해서 읽습니다. - ORG-03(조직 조회)은 이 테이블을 읽지 않습니다. 응답
{ orgId, myRole }은 URL의 조직 ID와 멤버십 미들웨어가 읽은memberships.role로 만들고, 조직이 없거나 내가 회원이 아닐 때의 404ORG_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 409JOIN_ALREADY_REQUESTED·ALREADY_ORG_MEMBER의 출발점. 새 신청의 INSERT가 1062로 실패하면 기존 행이 있다는 뜻이고, 아래 조건부 UPDATE로 넘어갑니다.(user_id, org_id):UNIQUE→ 멤버십 미들웨어의 조회가 최대 1행인 근거. 내 상태가 둘로 갈릴 일이 없습니다.status ENUM ... NOT NULL→ 조직 API 공통 에러의 멤버십 검사:ACTIVE만 통과,PENDING→ 403MEMBERSHIP_PENDING,REJECTED·행 없음 → 404ORG_NOT_FOUND. ENUM이라 이 세 값 말고는 저장될 수 없습니다.role ENUM ... NULL→ 조직 API 공통 에러의 역할 검사 403INSUFFICIENT_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면 404ORG_NOT_FOUND,PENDING이면 403MEMBERSHIP_PENDING,ACTIVE면 통과(요청에 역할·상태를 붙임). 없는 조직과 내가 회원이 아닌 조직이 같은 404가 되는 것도 이 한 문장 덕분입니다. - JOIN-02 가입 신청 (409
JOIN_ALREADY_REQUESTED·ALREADY_ORG_MEMBER):INSERT INTO memberships (user_id, org_id, status) VALUES (?, ?, 'PENDING')→ 성공하면 201- 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 행 재사용) - 바뀐 행 0개면
SELECT status ...로 현재 상태를 보고PENDING→ 409JOIN_ALREADY_REQUESTED,ACTIVE→ 409ALREADY_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 = ?로 다시 조회해 없으면 404JOIN_REQUEST_NOT_FOUND(없는 ID, 다른 조직의 ID), 있으면 409JOIN_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 400INVALID_TITLE_LENGTH의 상한(100자)과 같은 값.content TEXT→ NOTICE-03·04 400INVALID_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. 다른 조직 회원은 그 전에 멤버십 미들웨어에서 404ORG_NOT_FOUND로 막힙니다. - NOTICE-02:
... WHERE n.id = ? AND n.org_id = ?→ 0행이면 404NOTICE_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개면 404NOTICE_NOT_FOUND. 성공하면 NOTICE-02와 같은 SELECT로 다시 읽어 응답합니다. - NOTICE-05:
DELETE FROM notices WHERE id = ? AND org_id = ?→ 지운 행이 0개면 404NOTICE_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에서 실행·제약 위반 확인 |