API 명세서 - seru1027/35cheol GitHub Wiki

API 명세서

v0.3 · 2026-10-13 · MVP API 18개 · 멘토 피드백 반영 (이슈 #1, 바뀐 곳은 맨 아래 변경 이력) 기준 문서: Figma 02 기능정의서 (MVP 17개). 이 명세서의 API ID는 기능정의서의 기능 ID와 같습니다. 기능 17개 = API 18개: JOIN-01(초대 코드) 기능 하나가 발급(POST)·조회(GET) API 2개라 하나 많습니다. 새 ID는 이미 예약한 ID와 겹치지 않게 정합니다 (예: JOIN-05 = 가입 거절, ERD 설계서 이후 단계). 이 명세서를 기준으로 만든 DB 설계: ERD 설계서


0. 공통 규칙

0-1. 기본

항목 규칙
기본 주소 모든 API는 /api로 시작 (프론트 화면 URL과 분리)
요청·응답 형식 JSON (Content-Type: application/json)
인증 로그인하면 Access Token(30분)을 access_token 쿠키(HttpOnly, SameSite=Lax)로 받음. 이후 요청은 브라우저가 쿠키를 자동으로 보냄
배포 조건 프론트와 백엔드는 같은 사이트로 배포: 같은 도메인, 또는 프론트 서버가 /api를 백엔드로 넘겨주는 프록시. SameSite=Lax 쿠키는 다른 사이트로 가는 요청에 실리지 않으므로, 주소가 완전히 다르면(예: xxx.vercel.app → yyy.onrender.com) 로그인은 200인데 다음 요청부터 모든 API가 401
개발 환경 프론트 localhost:5173 → 백엔드 localhost:4000. 포트만 달라 같은 사이트라 쿠키는 실리지만, 출처(origin)가 달라 CORS 설정이 필요: 서버는 http://localhost:5173 출처와 credentials를 허용하고, 프론트 요청은 credentials: 'include'. cors 미들웨어는 모든 라우터와 NOT_FOUND 함수보다 앞에 둡니다: 브라우저는 JSON POST·PUT·DELETE 전에 OPTIONS 사전 요청(preflight)을 먼저 보내는데, CORS가 뒤에 있으면 이 요청이 NOT_FOUND로 끝나 실제 요청이 나가지 않고, CORS 헤더가 없는 에러 응답은 브라우저가 읽지 못합니다
이름 표기 요청·응답 JSON은 camelCase (loginId), DB 칼럼은 snake_case (login_id)
요청 body 서버는 명세에 적힌 값만 골라 받고, 나머지는 무시
시각 UTC, ISO 8601 문자열 ("2026-10-05T09:30:00.000Z"). 화면이 브라우저 시간대로 바꿔 표시
글자 수 앞뒤 공백 제거 후 [...str].length(코드 포인트)로 셈. DB VARCHAR와 같은 기준. 비밀번호는 예외: 앞뒤 공백을 지우지 않고 입력 그대로 셈 (AUTH-01·02)

0-2. 응답 형식

성공·실패 모두 같은 모양으로 감쌉니다.

{ "status": 201, "code": "SIGNUP_SUCCESS", "message": null, "data": { } }
필드 뜻
status HTTP 상태 코드와 같은 값
code 결과를 구분하는 영문 코드. 프론트는 이 값으로 분기하고 화면 문구를 정함
message 항상 null. 사용자에게 보일 문구는 프론트가 code로 결정
data 성공 시 결과 데이터. 결과가 없거나 실패면 null
  • 본문이 없는 성공(로그아웃, 가입 승인, 공지 삭제)도 이 형식을 지키기 위해 **204를 쓰지 않고 200 + data: null**로 응답합니다.
  • 목록은 data에 배열을 바로 담지 않고 { 복수형 이름: [...] }으로 감쌉니다 (예: { "orgs": [] }). 페이지네이션 정보(totalCount 등)를 나중에 필드만 추가해 붙일 수 있게 하기 위해서입니다.

0-3. 코드 이름 규칙

상황 이름 예
성공 {기능}_SUCCESS (조회는 GET_{대상}_SUCCESS, 생성은 CREATE_{대상}_SUCCESS) SIGNUP_SUCCESS, GET_ME_SUCCESS, CREATE_ORG_SUCCESS
값이 없음 (없음·null·공백뿐. 비밀번호는 없음·null·빈 문자열 ""만) REQUIRED_{필드} REQUIRED_LOGIN_ID
문자열이 아님 INVALID_{필드}_TYPE INVALID_LOGIN_ID_TYPE
길이 범위 밖 INVALID_{필드}_LENGTH INVALID_LOGIN_ID_LENGTH
허용하지 않는 문자 INVALID_{필드}_FORMAT INVALID_LOGIN_ID_FORMAT
중복 {필드}_ALREADY_EXISTS LOGIN_ID_ALREADY_EXISTS
  • 입력 오류가 여러 개여도 첫 번째 하나만 응답합니다. 검사 순서는 명세의 body 순서이고, 필드 안에서는 위 표의 순서(REQUIRED → TYPE → LENGTH → FORMAT)입니다.
  • 화면이 칸별로 문구를 띄울 수 있게 코드에 필드 이름을 넣습니다.
  • 코드 하나의 뜻은 서비스 전체에서 하나입니다. 다른 API의 같은 이름 필드와 겹치면 대상을 붙입니다 (조직 name → REQUIRED_ORG_NAME, 사용자 name → REQUIRED_NAME). 프론트는 code → 문구 사전 하나로 문구를 고릅니다.

0-4. 공통 에러 코드

status code 언제
400 INVALID_JSON 요청 body가 JSON 문법에 맞지 않음 (예: 중괄호 하나 누락, 끝에 쉼표)
400 BAD_REQUEST 그 밖의 요청 형식 오류 (예: Content-Length와 실제 body 길이가 다름). 드묾
413 PAYLOAD_TOO_LARGE 요청 body가 100KB(express.json() 기본 한도)를 넘음
415 UNSUPPORTED_MEDIA_TYPE body의 문자 인코딩(charset)이나 압축 방식(Content-Encoding)을 서버가 지원하지 않음
401 UNAUTHORIZED 로그인이 필요한 API에 쿠키가 없거나, 토큰이 위조·만료됐거나, 토큰의 사용자가 없음
404 NOT_FOUND 없는 주소이거나, 주소는 있지만 그 Method가 없음 (예: GET /api/orgz, DELETE /api/auth/me)
500 INTERNAL_SERVER_ERROR 서버·DB 오류 (예상하지 못한 모든 실패)

어떤 실패든 위 응답 형식(0-2)의 JSON으로 답합니다. Express 기본 동작은 HTML 에러 페이지라, 프론트의 res.json()이 실패해 code를 읽지 못합니다. 구현: 모든 라우터 뒤에 어느 라우터에도 맞지 않은 요청을 NOT_FOUND로 답하는 함수를, 맨 끝에 에러 처리 함수 하나를 둡니다. 에러 처리 함수는 어디서 난 에러든 받아서 이렇게 바꿔 응답합니다: err.type === 'entity.parse.failed'(JSON 문법 오류)는 INVALID_JSON. 그 밖의 요청 오류(err.status가 4xx)는 원래 상태 코드를 유지합니다(413 entity.too.large → PAYLOAD_TOO_LARGE, 415 charset.unsupported·encoding.unsupported → UNSUPPORTED_MEDIA_TYPE, 나머지 400 → BAD_REQUEST). 상태 코드가 없거나 5xx인 에러만 INTERNAL_SERVER_ERROR입니다. 요청한 쪽 잘못을 500(서버 잘못)으로 바꾸지 않기 위해서입니다. INVALID_JSON·BAD_REQUEST·PAYLOAD_TOO_LARGE·UNSUPPORTED_MEDIA_TYPE·NOT_FOUND는 API에 도착하기 전에 나는 에러라 API별 응답 표에는 적지 않습니다. NOT_FOUND는 주소 자체가 없음이고, ORG_NOT_FOUND·NOTICE_NOT_FOUND는 주소는 맞는데 대상이 없음입니다. body가 아예 없는 요청(빈 body, Content-Type 없음)은 JSON 오류가 아니라 빈 객체 {}로 보고 검사합니다 → 첫 필수 필드의 REQUIRED_* 400.

조직 API 공통 에러 — URL에 조직 ID가 있는 모든 API(/api/orgs/:orgId/...)가 아래 순서로 검사합니다. 문서마다 번호가 달라 헷갈리지 않도록 검사는 번호 대신 이름으로 부릅니다 (기능정의서·ERD 설계서·schema.sql 공통).

로그인 → ID 형식 → 멤버십 → 역할 → 입력값 → 데이터 격리 → 비즈니스 규칙

검사 status code 언제 어디서
로그인 401 UNAUTHORIZED 위와 같음 인증 미들웨어
ID 형식 400 INVALID_ORG_ID URL의 조직 ID가 1 이상의 정수가 아님: 문자열 그대로 정규식 ^[1-9][0-9]*$로 검사 (abc, 0, -1, 1.5, 01, 1e3, +1, 1, 1.0 모두 400). Number()로 바꿔 검사하면 01·1e3·+1· 1·1.0이 통과함 모든 조직 API
ID 형식 400 INVALID_MEMBERSHIP_ID, INVALID_NOTICE_ID URL의 멤버십 ID(JOIN-04)·공지 ID(NOTICE-02·04·05)가 1 이상의 정수가 아님 (같은 정규식). 조직 ID 다음에 검사 그 ID가 URL에 있는 API
멤버십 404 ORG_NOT_FOUND 없는 조직, 또는 내가 ACTIVE·PENDING이 아닌 조직 (멤버십 없음·REJECTED 등). 존재를 드러내지 않도록 같은 응답 멤버십 미들웨어
멤버십 403 MEMBERSHIP_PENDING 가입 승인 대기 중 (PENDING). 프론트는 ‘승인 대기중’ 안내 멤버십 미들웨어
역할 403 INSUFFICIENT_ROLE 회장·운영진 전용 API에 일반 회원(MEMBER)이 요청 역할 미들웨어 (허용 역할이 정해진 API만)
입력값 400 REQUIRED_*, INVALID_* 요청 body 검사 (0-3) body가 있는 API (NOTICE-03·04)
데이터 격리 404 JOIN_REQUEST_NOT_FOUND, NOTICE_NOT_FOUND 대상(신청·공지)이 없거나 URL의 조직 것이 아님 각 API의 SQL AND org_id = ?
비즈니스 규칙 409 JOIN_REQUEST_ALREADY_PROCESSED 대상은 맞지만 상태가 요청과 맞지 않음 각 API

ID 형식은 DB를 읽기 전에 URL의 ID를 모두 한 번에 검사합니다. 그래서 회원이 아닌 조직의 /notices/abc도 404가 아니라 400입니다. 400은 조직이 있는지 알려주지 않으므로 존재를 숨기는 규칙과 부딪히지 않습니다. 멤버십 미들웨어 규칙: ACTIVE만 통과, PENDING은 403, 나머지는 모두 404 (REJECTED, 핵심 단계의 탈퇴·추방 포함). 역할 미들웨어 규칙: API마다 허용 역할 목록을 받아, 내 역할이 목록에 없으면 403. 각 API 설명에 ‘허용 역할’로 적습니다. 데이터 격리는 미들웨어가 아니라 각 API의 SQL WHERE ... AND org_id = ?에서 합니다. JOIN-04는 데이터 격리와 비즈니스 규칙을 UPDATE 한 문장으로 함께 처리합니다. 입력값은 역할 다음, 데이터 격리 앞입니다. 권한이 없는 사람에게는 입력 규칙과 관계없이 403을 주고, 입력이 맞아야 SQL 한 문장(예: NOTICE-04의 UPDATE)으로 데이터 격리를 처리할 수 있기 때문입니다.


API 목록

ID 기능 Method URL 상태
AUTH-01 회원가입 POST /api/auth/signup ✅ 작성
AUTH-02 로그인 POST /api/auth/login ✅ 작성
AUTH-03 내 정보 조회 GET /api/auth/me ✅ 작성
AUTH-04 로그아웃 POST /api/auth/logout ✅ 작성
ORG-01 조직 생성 POST /api/orgs ✅ 작성
ORG-02 내 조직 목록 GET /api/orgs ✅ 작성
ORG-03 조직 조회 GET /api/orgs/:orgId ✅ 작성
JOIN-01 초대 코드 발급 POST /api/orgs/:orgId/invite-code ✅ 작성
JOIN-01 초대 코드 조회 GET /api/orgs/:orgId/invite-code ✅ 작성
JOIN-02 가입 신청 POST /api/join-requests ✅ 작성
JOIN-03 가입 신청 목록 GET /api/orgs/:orgId/join-requests ✅ 작성
JOIN-04 가입 승인 POST /api/orgs/:orgId/join-requests/:membershipId/approve ✅ 작성
MEMBER-01 회원 목록 GET /api/orgs/:orgId/members ✅ 작성
NOTICE-01 공지 목록 GET /api/orgs/:orgId/notices ✅ 작성
NOTICE-02 공지 상세 GET /api/orgs/:orgId/notices/:noticeId ✅ 작성
NOTICE-03 공지 작성 POST /api/orgs/:orgId/notices ✅ 작성
NOTICE-04 공지 수정 PUT /api/orgs/:orgId/notices/:noticeId ✅ 작성
NOTICE-05 공지 삭제 DELETE /api/orgs/:orgId/notices/:noticeId ✅ 작성

AUTH-01. 회원가입

Request method url body 설명
POST /api/auth/signup loginId: String,
name: String,
password: String
필수: loginId, name, password. 아이디는 앞뒤 공백 제거·소문자 변환 후 검사·저장. 이름은 앞뒤 공백 제거 후 검사·저장. 비밀번호는 변환 없이(앞뒤 공백도 지우지 않음) 검사 후 bcrypt 해시로만 저장. 비밀번호 확인은 화면에서만 쓰고 보내지 않음. 로그인 필요 없음

검사 규칙 — 아이디: 420자, 영문·숫자만. 이름: 120자, 문자 제한 없음, 중복 허용. 비밀번호: 864자, 아스키 33126번 문자만(공백·한글 불가), 필수 조합 없음. 비밀번호는 앞뒤 공백을 지우지 않습니다 (공통 규칙 0-1의 예외). 없음·null·빈 문자열 ""이면 REQUIRED_PASSWORD, 그 밖에는 입력 그대로 TYPE → LENGTH → FORMAT 순서로 검사합니다. 공백은 아스키 32번이라 길이를 통과하면 INVALID_PASSWORD_FORMAT입니다.

입력 응답 code 이유
"" REQUIRED_PASSWORD 빈 문자열
" " (공백 3개) INVALID_PASSWORD_LENGTH 공백뿐이어도 REQUIRED가 아님. 3자 < 8자
" abcdefg" (공백 + 7자) INVALID_PASSWORD_FORMAT 공백을 지우지 않아 8자 → 길이 통과, 공백 불가

아이디 중복은 미리 조회하지 않고 DB의 UNIQUE 제약 위반(1062)을 409로 바꿔 응답합니다. 같은 아이디로 동시에 가입해도 1건만 성공합니다. 가입해도 자동 로그인하지 않습니다(쿠키를 주지 않음). 화면은 로그인 탭으로 바꾸고 응답의 loginId(소문자로 바뀐 값)를 아이디 칸에 채웁니다.

Response status data
201 { "status":201, "code":"SIGNUP_SUCCESS", "message":null, "data":{ "userId":1, "loginId":"kim123", "name":"김철수" } }
400 { "status":400, "code":"REQUIRED_LOGIN_ID", "message":null, "data":null }
400 { "status":400, "code":"INVALID_LOGIN_ID_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_LOGIN_ID_LENGTH", "message":null, "data":null }
400 { "status":400, "code":"INVALID_LOGIN_ID_FORMAT", "message":null, "data":null }
400 { "status":400, "code":"REQUIRED_NAME", "message":null, "data":null }
400 { "status":400, "code":"INVALID_NAME_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_NAME_LENGTH", "message":null, "data":null }
400 { "status":400, "code":"REQUIRED_PASSWORD", "message":null, "data":null }
400 { "status":400, "code":"INVALID_PASSWORD_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_PASSWORD_LENGTH", "message":null, "data":null }
400 { "status":400, "code":"INVALID_PASSWORD_FORMAT", "message":null, "data":null }
409 { "status":409, "code":"LOGIN_ID_ALREADY_EXISTS", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

AUTH-02. 로그인

Request method url body 설명
POST /api/auth/login loginId: String,
password: String
필수: loginId, password. 아이디는 회원가입과 같게 앞뒤 공백 제거·소문자 변환 후 조회하고, 비밀번호는 필수값·문자열 타입만 확인한 뒤 원문을 앞뒤 공백도 지우지 않고 그대로 bcrypt.compare()로 비교 (길이·형식 검사는 하지 않음). 로그인 필요 없음

아이디·비밀번호 모두 필수값과 문자열 타입만 검사합니다. 형식이 틀린 아이디는 어차피 가입될 수 없으므로 조회에 실패해 401이 됩니다. 비밀번호의 필수값은 없음·null·빈 문자열 ""만입니다(AUTH-01과 같은 예외). 공백뿐인 값(" ")도 그대로 비교해 401이 됩니다. 아이디 미존재와 비밀번호 불일치는 모두 INVALID_LOGIN_ID_OR_PASSWORD로 통일합니다. 목적은 비밀번호를 맞춰보려는 시도에 "아이디는 맞았다"는 힌트를 주지 않는 것입니다. 아이디 존재 자체를 숨기지는 못합니다: 회원가입은 사용자 편의상 이미 있는 아이디에 409 LOGIN_ID_ALREADY_EXISTS를 돌려주므로, 가입 화면에서 아이디가 있는지 확인할 수 있습니다. 없는 아이디여도 가짜 해시로 bcrypt.compare()를 수행해(B2), 두 경우의 응답 시간도 같게 맞춥니다. 가짜 해시는 실제 저장 해시와 같은 cost로 서버 시작 때 한 번 만들어 둔 유효한 bcrypt 해시여야 합니다. cost가 다르거나 bcrypt 형식이 아닌 문자열이면 비교가 바로 끝나(멘토 실측: 같은 cost 약 56~57ms, 다른 cost 약 0.9ms, 아무 문자열 약 0.1ms) 시간 차이가 그대로 드러납니다. 위 이유로 보안 효과는 제한적이지만, 응답 시간으로 정보가 새는 경로를 다뤄 보는 학습용으로 유지합니다. 응답 data는 AUTH-03 내 정보 조회와 같은 모양입니다. 토큰은 프론트가 읽을 수 없으므로 로그인한 사람이 누구인지는 body로 알려줍니다. 성공하면 Access Token(JWT, 내용은 사용자 ID만, 30분)을 쿠키로 보냅니다. 응답 body에는 토큰을 담지 않습니다. Set-Cookie: access_token=<JWT>; HttpOnly; SameSite=Lax; Path=/; Max-Age=1800 (배포 환경 HTTPS에서는 Secure 추가) 이 API의 401은 공통 UNAUTHORIZED와 코드가 다르므로, 프론트의 공통 401 처리(로그인 화면으로 이동)에서 빠지고 칸 아래 문구로 보여줍니다.

Response status data
200 { "status":200, "code":"LOGIN_SUCCESS", "message":null, "data":{ "userId":1, "loginId":"kim123", "name":"김철수" } }
400 { "status":400, "code":"REQUIRED_LOGIN_ID", "message":null, "data":null }
400 { "status":400, "code":"INVALID_LOGIN_ID_TYPE", "message":null, "data":null }
400 { "status":400, "code":"REQUIRED_PASSWORD", "message":null, "data":null }
400 { "status":400, "code":"INVALID_PASSWORD_TYPE", "message":null, "data":null }
401 { "status":401, "code":"INVALID_LOGIN_ID_OR_PASSWORD", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

AUTH-03. 내 정보 조회

Request method url body 설명
GET /api/auth/me 없음 access_token 쿠키로 지금 로그인한 사용자를 확인. 로그인 필요. 프론트는 앱 시작·새로고침 때 이 API로 로그인 상태를 판단

인증 미들웨어를 처음 만드는 API입니다. 미들웨어는 쿠키의 토큰 서명·만료만 검사하고(DB 조회 없음), 통과하면 요청에 사용자 ID를 붙입니다. 이후 로그인이 필요한 모든 API가 이 미들웨어를 재사용합니다. 이 API는 그 사용자 ID로 users를 조회합니다. 토큰은 유효하지만 사용자가 없으면 401입니다. 응답에 비밀번호·해시와 소속 조직은 담지 않습니다 (조직은 ORG-02 내 조직 목록). 401은 네 경우(쿠키 없음, 서명 불일치, 만료, 사용자 없음) 모두 공통 UNAUTHORIZED 하나입니다. 프론트는 어느 경우든 로그인 화면으로 이동합니다. 401이어도 서버는 쿠키를 지우지 않습니다. 만료된 쿠키는 Max-Age로 브라우저가 지우고, 쿠키 삭제는 AUTH-04 로그아웃이 맡습니다.

Response status data
200 { "status":200, "code":"GET_ME_SUCCESS", "message":null, "data":{ "userId":1, "loginId":"kim123", "name":"김철수" } }
401 { "status":401, "code":"UNAUTHORIZED", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

AUTH-04. 로그아웃

Request method url body 설명
POST /api/auth/logout 없음 토큰 쿠키를 만료시켜 로그아웃. 로그인 필요 없음 (쿠키가 없거나 만료돼도 처리). 상태를 바꾸는 요청이라 GET 불가

서버는 로그인 때와 같은 이름·경로·옵션으로 쿠키를 만료시킵니다. Set-Cookie: access_token=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0 또는 과거 날짜의 Expires (배포 환경에서는 Secure 추가) 구현은 res.clearCookie('access_token', { httpOnly: true, sameSite: 'lax', path: '/' })입니다. Express는 이것을 Max-Age=0 대신 Expires=Thu, 01 Jan 1970 00:00:00 GMT로 보냅니다. 둘 다 브라우저에서 쿠키를 지우므로 어느 쪽이든 맞는 응답이고, 확인은 헤더 문자열이 아니라 로그아웃 뒤 GET /api/auth/me가 401인지로 합니다. 서버에 저장된 토큰이 없으므로 서버에서 지울 것은 없습니다. 한계: 이미 복사된 토큰은 30분 만료까지 유효합니다(차단 목록은 범위 밖). 이미 로그아웃된 상태여도 결과가 같으므로 성공입니다(멱등성). 그래서 인증 미들웨어를 거치지 않고, 401이 없습니다.

Response status data
200 { "status":200, "code":"LOGOUT_SUCCESS", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

ORG-01. 조직 생성

Request method url body 설명
POST /api/orgs name: String 필수: name. 동아리 이름은 앞뒤 공백 제거 후 1~30자, 문자 제한 없음, 중복 허용. 로그인 필요. 만든 사람이 회장(OWNER)이 됨

조직(organizations 1행)과 만든 사람의 멤버십(memberships 1행: OWNER, ACTIVE)을 한 트랜잭션으로 만듭니다. 하나라도 실패하면 둘 다 취소되어 아무것도 남지 않습니다(500). 한 사람이 만들 수 있는 동아리 수 제한은 없고, 초대 코드는 만들지 않습니다(JOIN-01 발급 때 생성). 이름 중복을 허용하므로 막아줄 UNIQUE가 없습니다. 버튼 두 번 클릭은 프론트가 요청 중 버튼을 잠가 막습니다. 성공하면 프론트는 응답의 orgId로 그 동아리 홈으로 이동합니다. body 필드는 name이지만, 사용자 이름(AUTH-01)과 뜻이 다르므로 에러 코드에는 ORG_를 붙입니다.

Response status data
201 { "status":201, "code":"CREATE_ORG_SUCCESS", "message":null, "data":{ "orgId":3 } }
400 { "status":400, "code":"REQUIRED_ORG_NAME", "message":null, "data":null }
400 { "status":400, "code":"INVALID_ORG_NAME_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_ORG_NAME_LENGTH", "message":null, "data":null }
401 { "status":401, "code":"UNAUTHORIZED", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

ORG-02. 내 조직 목록

Request method url body 설명
GET /api/orgs 없음 내가 속한(가입 신청 중 포함) 동아리 목록. 로그인 필요. 사용자는 토큰에서만 꺼냄 (요청으로 사용자 ID를 받지 않음)

내 멤버십이 ACTIVE·PENDING인 조직만 담습니다 (REJECTED 표시는 가입 거절 기능 때, 탈퇴·추방은 핵심 기능 단계). memberships와 organizations를 JOIN해서 조회합니다. 정렬은 requested_at 오름차순 (먼저 들어간 동아리가 앞). PENDING 조직도 같은 항목을 담지만 조직 데이터(공지·회원 등)는 담지 않습니다. PENDING이면 myRole은 null입니다 (DB 설계 D2). 0개면 빈 배열입니다 (404 아님). 메인 화면과 햄버거바가 같은 API를 씁니다.

Response status data
200 { "status":200, "code":"GET_MY_ORGS_SUCCESS", "message":null, "data":{ "orgs":[ { "orgId":3, "name":"코딩 동아리", "myRole":"OWNER", "myStatus":"ACTIVE" }, { "orgId":7, "name":"등산 모임", "myRole":null, "myStatus":"PENDING" } ] } }
200 { "status":200, "code":"GET_MY_ORGS_SUCCESS", "message":null, "data":{ "orgs":[] } } (0개)
401 { "status":401, "code":"UNAUTHORIZED", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

ORG-03. 조직 조회

Request method url body 설명
GET /api/orgs/:orgId 없음 동아리 홈 진입 확인: 내가 이 동아리의 ACTIVE 회원인지와 내 역할. 로그인 필요, 그 동아리의 ACTIVE 회원만

멤버십 미들웨어를 처음 만드는 API입니다. 위의 조직 API 공통 에러 로그인 → ID 형식 → 멤버십을 그대로 따릅니다. 미들웨어는 통과하면 요청에 내 멤버십(역할·상태)을 붙이고, 이후 모든 조직 API가 재사용합니다. 응답의 myRole은 화면 표시용입니다(버튼 보이기·숨기기). 실제 권한 검사는 서버가 각 기능에서 합니다. 동아리 이름은 담지 않습니다. 화면은 동아리명을 ORG-02 내 조직 목록에서 찾습니다(화면정의서 결정 P). 응답은 이미 가진 값(형식 검사를 통과해 숫자로 바꾼 URL의 조직 ID, 멤버십 미들웨어가 읽은 내 역할)만으로 만들어 추가 SQL이 없습니다. 동아리 홈의 공지·멤버·가입 관리 박스는 이 응답에 담지 않고, 기능별 API(NOTICE-01, MEMBER-01, JOIN-03)를 따로 호출합니다.

Response status data
200 { "status":200, "code":"GET_ORG_SUCCESS", "message":null, "data":{ "orgId":3, "myRole":"OWNER" } }
400 { "status":400, "code":"INVALID_ORG_ID", "message":null, "data":null }
401 { "status":401, "code":"UNAUTHORIZED", "message":null, "data":null }
403 { "status":403, "code":"MEMBERSHIP_PENDING", "message":null, "data":null }
404 { "status":404, "code":"ORG_NOT_FOUND", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

JOIN-01. 초대 코드 발급 · 조회

조직당 유효한 코드는 하나라서, 조직 아래의 단수 리소스 /invite-code 하나에 발급(POST)과 조회(GET)를 둡니다.

발급 (재발급 포함)

Request method url body 설명
POST /api/orgs/:orgId/invite-code 없음 새 초대 코드를 만들어 저장하고 돌려줌. 이미 있으면 덮어써서 이전 코드는 즉시 무효 (이미 들어온 신청은 유지). 허용 역할: OWNER, ADMIN

역할 미들웨어를 처음 만드는 API입니다 (조직 API 공통 에러의 역할 검사). 코드: 8자리, 영문 대문자+숫자에서 헷갈리는 문자(0 O 1 I L)를 뺀 31자 중에서 crypto로 뽑습니다 (Math.random 금지). 코드는 서비스 전체에서 유일(UNIQUE)합니다. 다른 조직의 코드와 겹치면(1062) 새로 뽑아 다시 저장하고, 5번 연속 겹치면 500입니다. 경우의 수가 31⁸ ≈ 8,500억이라 실제로 겹칠 일은 거의 없습니다. 저장은 UPDATE organizations SET invite_code = ? WHERE id = ?입니다. 매번 새 코드가 나오므로 같은 요청을 두 번 보내면 결과가 달라집니다(멱등이 아님) → PUT이 아니라 POST.

Response status data
201 { "status":201, "code":"CREATE_INVITE_CODE_SUCCESS", "message":null, "data":{ "inviteCode":"K7MX4QPA" } }
400 { "status":400, "code":"INVALID_ORG_ID", "message":null, "data":null }
401 { "status":401, "code":"UNAUTHORIZED", "message":null, "data":null }
403 { "status":403, "code":"MEMBERSHIP_PENDING", "message":null, "data":null }
403 { "status":403, "code":"INSUFFICIENT_ROLE", "message":null, "data":null }
404 { "status":404, "code":"ORG_NOT_FOUND", "message":null, "data":null }
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

조회

Request method url body 설명
GET /api/orgs/:orgId/invite-code 없음 현재 유효한 초대 코드. 허용 역할: OWNER, ADMIN (발급과 같음)

아직 발급하지 않았으면 inviteCode가 null입니다 (404 아님). 프론트는 가입 관리 박스에 ‘발급’ 버튼만 보여줍니다.

Response status data
200 { "status":200, "code":"GET_INVITE_CODE_SUCCESS", "message":null, "data":{ "inviteCode":"K7MX4QPA" } }
200 { "status":200, "code":"GET_INVITE_CODE_SUCCESS", "message":null, "data":{ "inviteCode":null } } (미발급)
400 · 401 · 403 · 404 · 500 발급과 같음

JOIN-02. 가입 신청

Request method url body 설명
POST /api/join-requests inviteCode: String 필수: inviteCode. 앞뒤 공백 제거·대문자 변환 후 조회 (필수값·문자열 타입만 검사, 형식 검사 없음). 로그인 필요. 아직 그 동아리 회원이 아닌 사람

가입 신청 생성(JOIN-02) → 신청 목록(JOIN-03) → 신청 승인(JOIN-04)이 모두 join-requests 한 이름으로 이어집니다. DB에서는 memberships의 PENDING 행이지만, URL은 테이블 이름이 아니라 API를 쓰는 사람이 보는 개념을 따릅니다. 신청자는 조직 ID를 모르고 코드만 알기 때문에, URL에 조직 ID가 없습니다. 그래서 조직 API 공통 에러(멤버십 미들웨어)를 거치지 않고, 코드로 조직을 찾습니다. 재발급으로 무효가 된 코드는 없는 코드(404)입니다. 신청할 수 있는 경우: 멤버십 없음, REJECTED (핵심 단계의 탈퇴·추방 포함). PENDING이면 409, ACTIVE면 409. 한 사람·한 동아리의 멤버십은 1행(UNIQUE)이므로, 기존 행이 있으면 그 행을 재사용합니다: role = NULL, status = PENDING, requested_at = 지금, joined_at = NULL (DB 설계 D2·D5). 동시 요청 처리: (1) INSERT를 먼저 시도 → (2) 1062(이미 행 있음)면 UPDATE ... WHERE user_id = ? AND org_id = ? AND status NOT IN ('PENDING','ACTIVE') → (3) 바뀐 행이 0개면 현재 상태를 보고 409. 버튼을 두 번 눌러도 첫 요청만 201, 두 번째는 409입니다. 새 행이든 재사용이든 클라이언트 입장에서는 ‘신청이 새로 생김’이므로 모두 201입니다. 응답 data는 ORG-02 내 조직 목록의 항목과 같은 모양이라, 프론트가 그대로 메인 카드 목록에 붙입니다(ORG-02 재호출 없음).

Response status data
201 { "status":201, "code":"JOIN_REQUEST_SUCCESS", "message":null, "data":{ "orgId":7, "name":"등산 모임", "myRole":null, "myStatus":"PENDING" } }
400 { "status":400, "code":"REQUIRED_INVITE_CODE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_INVITE_CODE_TYPE", "message":null, "data":null }
401 { "status":401, "code":"UNAUTHORIZED", "message":null, "data":null }
404 { "status":404, "code":"INVITE_CODE_NOT_FOUND", "message":null, "data":null }
409 { "status":409, "code":"JOIN_ALREADY_REQUESTED", "message":null, "data":null } (이미 대기중)
409 { "status":409, "code":"ALREADY_ORG_MEMBER", "message":null, "data":null } (이미 가입함)
500 { "status":500, "code":"INTERNAL_SERVER_ERROR", "message":null, "data":null }

JOIN-03. 가입 신청 목록

Request method url body 설명
GET /api/orgs/:orgId/join-requests 없음 이 동아리에 들어온 대기 중(PENDING) 가입 신청 목록. 허용 역할: OWNER, ADMIN

조직 API 공통 에러 로그인 → ID 형식 → 멤버십 → 역할을 따릅니다. 신청(PENDING)과 회원(ACTIVE)은 같은 memberships 테이블이지만 보는 사람·권한·항목·정렬이 달라서, ?status= 필터 대신 기능마다 URL을 따로 둡니다 (회원 목록은 MEMBER-01 /members). SQL에 조직 ID 조건을 반드시 겁니다: WHERE m.org_id = ? AND m.status = 'PENDING' → 다른 조직의 신청은 섞이지 않습니다. REJECTED·ACTIVE는 담지 않습니다. 정렬은 requested_at 오름차순(오래된 신청 먼저). 0건이면 빈 목록입니다. 가입 관리 박스의 대기 건수는 이 목록의 개수를 씁니다(건수 전용 API 없음). 각 항목의 membershipId는 JOIN-04 승인에 씁니다.

Response status data
200 { "status":200, "code":"GET_JOIN_REQUESTS_SUCCESS", "message":null, "data":{ "joinRequests":[ { "membershipId":12, "loginId":"jung01", "name":"정신입", "requestedAt":"2026-10-05T09:30:00.000Z" } ] } }
200 { "status":200, "code":"GET_JOIN_REQUESTS_SUCCESS", "message":null, "data":{ "joinRequests":[] } } (0건)
400 · 401 · 403 · 404 · 500 조직 API 공통 에러 (INVALID_ORG_ID, UNAUTHORIZED, MEMBERSHIP_PENDING·INSUFFICIENT_ROLE, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

JOIN-04. 가입 승인

Request method url body 설명
POST /api/orgs/:orgId/join-requests/:membershipId/approve 없음 대기 중인 가입 신청 하나를 승인해 회원(ACTIVE, MEMBER)으로 만듦. 허용 역할: OWNER, ADMIN. 한 번에 한 명씩 (일괄 승인 없음)

조직 API 공통 에러 로그인 → ID 형식 → 멤버십 → 역할을 따릅니다. ID 형식에서 조직 ID 다음에 URL의 멤버십 ID도 검사합니다: 1 이상의 정수가 아니면 400 INVALID_MEMBERSHIP_ID. 한 문장으로 데이터 격리(IDOR 방어)와 동시 승인을 함께 처리합니다: UPDATE memberships SET status = 'ACTIVE', role = 'MEMBER', joined_at = 지금 WHERE id = ? AND org_id = ? AND status = 'PENDING' 바뀐 행이 0개면 WHERE id = ? AND org_id = ?로 다시 조회해 → 없으면(없는 ID, 다른 조직의 ID) 404 JOIN_REQUEST_NOT_FOUND, 있으면(다른 운영진이 먼저 처리) 409 JOIN_REQUEST_ALREADY_PROCESSED. 승인은 칸 하나를 고치는 수정(PATCH)이 아니라 PENDING → ACTIVE만 허용되는 업무 행동이라, 리소스 뒤에 동사를 붙인 행동 URL로 둡니다. 상태 값을 body로 받지 않으므로 다른 상태로 바꾸는 요청이 끼어들 여지가 없습니다. 핵심 단계의 거절은 .../reject입니다. 운영진 지정은 핵심 기능의 역할 변경에서 합니다. 승인자는 MVP에서 기록하지 않습니다(감사 로그에서).

Response status data
200 { "status":200, "code":"APPROVE_JOIN_REQUEST_SUCCESS", "message":null, "data":null }
400 { "status":400, "code":"INVALID_MEMBERSHIP_ID", "message":null, "data":null } (조직 ID 형식은 공통 INVALID_ORG_ID)
404 { "status":404, "code":"JOIN_REQUEST_NOT_FOUND", "message":null, "data":null }
409 { "status":409, "code":"JOIN_REQUEST_ALREADY_PROCESSED", "message":null, "data":null }
401 · 403 · 404 · 500 조직 API 공통 에러 (UNAUTHORIZED, MEMBERSHIP_PENDING·INSUFFICIENT_ROLE, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

MEMBER-01. 회원 목록

Request method url body 설명
GET /api/orgs/:orgId/members 없음 동아리의 회원(ACTIVE) 목록. 허용 역할: 모두 (OWNER, ADMIN, MEMBER). 단 역할에 따라 응답 항목이 다름

조직 API 공통 에러 로그인 → ID 형식 → 멤버십을 따릅니다 (역할 검사 없음). SQL: WHERE m.org_id = ? AND m.status = 'ACTIVE'. PENDING은 JOIN-03 신청 목록에서 봅니다. 정렬: 역할 순(ORDER BY m.role → 회장·운영진·회원, DB 설계 D3), 같은 역할 안에서는 joined_at 오름차순(가입 순). 같은 API에서 멤버십 미들웨어가 붙인 내 역할을 보고 항목을 고릅니다. 일반 회원 응답에서는 항목을 서버가 아예 빼고 보냅니다(프론트에서 숨기기 아님). 새 역할이 생겨도 기본은 일반 회원 항목(넣는 목록).

내 역할 항목
OWNER, ADMIN membershipId, loginId, name, role, joinedAt
MEMBER name, role

가입일(joinedAt, DB의 joined_at)은 회비 납부 대상 판단 같은 관리용 정보라 회장·운영진에게만 보냅니다. 목록에는 최소 1명(나)이 있습니다. ‘나’ 표시(isMe)는 화면정의서 결정대로 담지 않습니다.

Response status data
200 (회장·운영진) { "status":200, "code":"GET_MEMBERS_SUCCESS", "message":null, "data":{ "members":[ { "membershipId":1, "loginId":"kim123", "name":"김철수", "role":"OWNER", "joinedAt":"2026-10-01T03:00:00.000Z" }, { "membershipId":12, "loginId":"jung01", "name":"정신입", "role":"MEMBER", "joinedAt":"2026-10-05T10:00:00.000Z" } ] } }
200 (일반 회원) { "status":200, "code":"GET_MEMBERS_SUCCESS", "message":null, "data":{ "members":[ { "name":"김철수", "role":"OWNER" }, { "name":"정신입", "role":"MEMBER" } ] } }
400 · 401 · 403 · 404 · 500 조직 API 공통 에러 (INVALID_ORG_ID, UNAUTHORIZED, MEMBERSHIP_PENDING, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

NOTICE-01. 공지 목록

Request method url body 설명
GET /api/orgs/:orgId/notices 없음 동아리의 공지 목록. 허용 역할: 모두. 본문은 담지 않음 (상세에서)

조직 API 공통 에러 로그인 → ID 형식 → 멤버십을 따릅니다. B 조직 회원이 A 조직 공지 목록을 요청하면 여기서(멤버십 검사) 404로 차단됩니다 — MVP 완료 기준. SQL: WHERE n.org_id = ?, 작성자 이름은 users와 JOIN. 정렬은 최신순(created_at DESC, id DESC — 같은 초에 쓴 공지도 순서가 고정되게). 페이지네이션은 학습용 확장 단계라 MVP는 전체를 돌려줍니다(목록을 객체로 감싸 두었으므로 나중에 필드만 추가). 동아리 홈 공지 박스는 같은 API 결과에서 프론트가 앞의 5개만 표시합니다.

Response status data
200 { "status":200, "code":"GET_NOTICES_SUCCESS", "message":null, "data":{ "notices":[ { "noticeId":5, "title":"10월 정기 모임 안내", "authorName":"김철수", "createdAt":"2026-10-05T09:30:00.000Z" } ] } }
200 { "status":200, "code":"GET_NOTICES_SUCCESS", "message":null, "data":{ "notices":[] } } (0건)
400 · 401 · 403 · 404 · 500 조직 API 공통 에러 (INVALID_ORG_ID, UNAUTHORIZED, MEMBERSHIP_PENDING, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

NOTICE-02. 공지 상세

Request method url body 설명
GET /api/orgs/:orgId/notices/:noticeId 없음 공지 하나의 전체 내용. 허용 역할: 모두

조직 API 공통 에러 로그인 → ID 형식 → 멤버십을 따릅니다. ID 형식에서 조직 ID 다음에 공지 ID도 검사합니다: 1 이상의 정수가 아니면 400 INVALID_NOTICE_ID. 공지가 URL의 조직 것인지 SQL로 확인합니다(IDOR 방어): SELECT ... WHERE n.id = ? AND n.org_id = ? → 없는 공지, 다른 조직의 공지는 모두 404 NOTICE_NOT_FOUND. A 회원이 /orgs/A/notices/(B 공지 ID)를 요청하면 여기서 차단됩니다 — MVP 완료 기준. updatedAt은 수정된 적 없으면 null입니다. 본문은 일반 텍스트이며 화면은 React 기본 출력 + 줄바꿈 유지(CSS pre-wrap)로 표시합니다(dangerouslySetInnerHTML 금지, XSS 방어). 수정·삭제 버튼 표시는 ORG-03의 myRole로 판단하고, 실제 권한 검사는 NOTICE-04·05에서 합니다.

Response status data
200 { "status":200, "code":"GET_NOTICE_SUCCESS", "message":null, "data":{ "noticeId":5, "title":"10월 정기 모임 안내", "content":"10월 12일 토요일 오후 2시\n동아리방에서 모입니다.", "authorName":"김철수", "createdAt":"2026-10-05T09:30:00.000Z", "updatedAt":null } }
400 { "status":400, "code":"INVALID_NOTICE_ID", "message":null, "data":null } (조직 ID 형식은 공통 INVALID_ORG_ID)
404 { "status":404, "code":"NOTICE_NOT_FOUND", "message":null, "data":null }
401 · 403 · 404 · 500 조직 API 공통 에러 (UNAUTHORIZED, MEMBERSHIP_PENDING, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

NOTICE-03. 공지 작성

Request method url body 설명
POST /api/orgs/:orgId/notices title: String,
content: String
필수: title, content. 허용 역할: OWNER, ADMIN. 서버는 제목·본문만 골라 받음

조직 API 공통 에러 로그인 → ID 형식 → 멤버십 → 역할을 따르고, 그다음 입력값을 검사합니다. 새 공지의 조직은 서버가 URL의 조직 ID로, 작성자는 토큰의 사용자 ID로 넣습니다. 요청 body에 orgId·authorId가 와도 무시합니다(Mass Assignment 방어). 제목: 앞뒤 공백 제거 후 1100자, 문자 제한 없음. 본문: 앞뒤 공백 제거 후 15,000자, 일반 텍스트. 줄바꿈 등 가운데 공백은 그대로 둡니다. 본문은 입력한 글자 그대로 저장합니다(<script> 같은 글자도 지우지 않음). 안전하게 출력하는 것은 화면의 일입니다(NOTICE-02). ‘그대로’는 글자를 지우지 않는다는 뜻이라, 앞뒤 공백 제거는 다른 입력과 똑같이 적용합니다. 본문 필드 이름은 HTTP 요청 본문(req.body)과 헷갈리지 않도록 content입니다. DB 칼럼도 ERD 설계서에서 content로 맞춥니다. 버튼 두 번 클릭은 프론트가 요청 중 버튼을 잠가 막습니다(막아줄 UNIQUE 없음, ORG-01과 같음).

Response status data
201 { "status":201, "code":"CREATE_NOTICE_SUCCESS", "message":null, "data":{ "noticeId":6 } }
400 { "status":400, "code":"REQUIRED_TITLE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_TITLE_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_TITLE_LENGTH", "message":null, "data":null }
400 { "status":400, "code":"REQUIRED_CONTENT", "message":null, "data":null }
400 { "status":400, "code":"INVALID_CONTENT_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_CONTENT_LENGTH", "message":null, "data":null }
400 · 401 · 403 · 404 · 500 조직 API 공통 에러 (INVALID_ORG_ID, UNAUTHORIZED, MEMBERSHIP_PENDING·INSUFFICIENT_ROLE, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

NOTICE-04. 공지 수정

Request method url body 설명
PUT /api/orgs/:orgId/notices/:noticeId title: String,
content: String
제목·본문을 새 값으로 교체. 둘 다 필수(규칙은 NOTICE-03과 같음). 허용 역할: OWNER, ADMIN (작성자 본인 제한 없음)

조직 API 공통 에러 로그인 → ID 형식(조직 ID, 공지 ID 400 INVALID_NOTICE_ID) → 멤버십 → 역할 → 입력값 순서로 검사합니다. 수정할 수 있는 값(제목·본문)을 둘 다 보내서 통째로 교체하므로 PATCH가 아니라 PUT입니다. 조직·작성자·작성 시각은 바꿀 수 없습니다. UPDATE notices SET title = ?, content = ?, updated_at = 지금 WHERE id = ? AND org_id = ? → 해당 행이 없으면(없는 공지, 다른 조직의 공지) 404 NOTICE_NOT_FOUND. A 회원이 B 공지 수정 시도 → 여기서 차단 — MVP 완료 기준. 동시 수정은 나중에 저장한 쪽이 덮어씁니다(충돌 감지 없음). 성공하면 수정된 공지를 NOTICE-02와 같은 모양으로 돌려줘, 상세 화면이 다시 조회하지 않고 바로 갱신합니다.

Response status data
200 { "status":200, "code":"UPDATE_NOTICE_SUCCESS", "message":null, "data":{ "noticeId":5, "title":"10월 정기 모임 안내 (장소 변경)", "content":"10월 12일 토요일 오후 2시\n학생회관 301호에서 모입니다.", "authorName":"김철수", "createdAt":"2026-10-05T09:30:00.000Z", "updatedAt":"2026-10-06T02:10:00.000Z" } }
400 { "status":400, "code":"INVALID_NOTICE_ID", "message":null, "data":null }
400 { "status":400, "code":"REQUIRED_TITLE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_TITLE_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_TITLE_LENGTH", "message":null, "data":null }
400 { "status":400, "code":"REQUIRED_CONTENT", "message":null, "data":null }
400 { "status":400, "code":"INVALID_CONTENT_TYPE", "message":null, "data":null }
400 { "status":400, "code":"INVALID_CONTENT_LENGTH", "message":null, "data":null }
404 { "status":404, "code":"NOTICE_NOT_FOUND", "message":null, "data":null }
400 · 401 · 403 · 404 · 500 조직 API 공통 에러 (INVALID_ORG_ID, UNAUTHORIZED, MEMBERSHIP_PENDING·INSUFFICIENT_ROLE, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

NOTICE-05. 공지 삭제

Request method url body 설명
DELETE /api/orgs/:orgId/notices/:noticeId 없음 공지를 완전 삭제(행을 DB에서 지움). 허용 역할: OWNER, ADMIN (작성자 본인 제한 없음)

조직 API 공통 에러 로그인 → ID 형식(조직 ID, 공지 ID 400 INVALID_NOTICE_ID) → 멤버십 → 역할 순서로 검사합니다. DELETE FROM notices WHERE id = ? AND org_id = ? → 지운 행이 0개면(없는 공지, 다른 조직의 공지, 다른 운영진이 먼저 삭제) 404 NOTICE_NOT_FOUND. A 회원이 B 공지 삭제 시도 → 여기서 차단 — MVP 완료 기준. 프론트는 확인창을 띄운 뒤 요청합니다. Soft Delete는 학습용 확장(조직 삭제)에서, 누가 지웠는지는 감사 로그(추가 기능)에서 다룹니다.

Response status data
200 { "status":200, "code":"DELETE_NOTICE_SUCCESS", "message":null, "data":null }
400 { "status":400, "code":"INVALID_NOTICE_ID", "message":null, "data":null }
404 { "status":404, "code":"NOTICE_NOT_FOUND", "message":null, "data":null }
400 · 401 · 403 · 404 · 500 조직 API 공통 에러 (INVALID_ORG_ID, UNAUTHORIZED, MEMBERSHIP_PENDING·INSUFFICIENT_ROLE, ORG_NOT_FOUND, INTERNAL_SERVER_ERROR)

변경 이력

버전 날짜 내용
v0.3 2026-10-13 멘토 2차 피드백 반영 (이슈 #1 승인 코멘트의 "사소한 것"). 5-2: 에러 처리 함수는 요청 오류(4xx)의 원래 상태 코드를 유지, 공통 에러에 400 BAD_REQUEST·413 PAYLOAD_TOO_LARGE·415 UNSUPPORTED_MEDIA_TYPE 추가, JSON 오타는 err.type === 'entity.parse.failed'로 구분 (0-4). 5-3: cors 미들웨어는 모든 라우터와 NOT_FOUND보다 앞 (0-1). 5-4: 가짜 해시는 같은 cost의 유효한 bcrypt 해시 (AUTH-02). 5-5: ID 형식은 정규식 ^[1-9][0-9]*$ (0-4). 5-6: 로그아웃 쿠키는 Max-Age=0 또는 과거 날짜의 Expires, 구현은 res.clearCookie (AUTH-04)
v0.2 2026-10-08 멘토 피드백 반영 (이슈 #1). 피드백 1: 비밀번호는 앞뒤 공백 제거의 예외: 지우지 않고 그대로 검사·비교, 필수값은 없음·null·""만 (0-1, 0-3, AUTH-01, AUTH-02). 피드백 2: 공통 에러에 400 INVALID_JSON·404 NOT_FOUND 추가, 모든 실패를 JSON으로 응답, body 없는 요청은 {}로 보고 REQUIRED_* (0-4). 피드백 3: 쿠키 인증의 배포 조건(같은 사이트)과 개발 환경 CORS·credentials (0-1). 피드백 7: 로그인 에러 통일의 목적을 "아이디 존재 숨김"에서 "비밀번호 추측에 힌트 주지 않기"로 정정, 가짜 해시 비교는 학습용으로 유지 (AUTH-02). 피드백 4: ORG-03 응답에서 name 제거 → { orgId, myRole }, 동아리명은 ORG-02에서 (ORG-03). 피드백 5: 검사 단계를 번호 대신 이름으로(로그인 → ID 형식 → 멤버십 → 역할 → 입력값 → 데이터 격리 → 비즈니스 규칙, ‘입력값’은 멘토 목록에 더해 기존 순서를 이름으로 적음), 멤버십·공지 ID 형식 검사를 멤버십 검사 앞으로 이동 (0-4, ORG-03, JOIN-01·03·04, MEMBER-01, NOTICE-01~05). 피드백 8: 기능 17개 = API 18개 설명 (머리말)
v0.1 2026-10-05 공통 규칙, API 목록, AUTH-01 회원가입(A1 공백 제거, A2 응답 data 확정), AUTH-02 로그인(B1 응답 data, B2 가짜 해시 비교), AUTH-03 내 정보 조회(C1 쿠키 유지), AUTH-04 로그아웃(① 204 대신 200), ORG-01 조직 생성(① 코드에 대상), ORG-02 내 조직 목록(① 목록 감싸기), ORG-03 조직 조회 + 조직 API 공통 에러(① REJECTED 404), JOIN-01 초대 코드 발급·조회 + 역할 검사, JOIN-02 가입 신청(① URL, ② 응답 data), JOIN-03 가입 신청 목록(① /join-requests, ② JOIN-02 URL을 /api/join-requests로 변경), JOIN-04 가입 승인(① 행동 URL), MEMBER-01 회원 목록(① 가입일은 회장·운영진만), NOTICE-01 공지 목록, NOTICE-02 공지 상세, NOTICE-03~05 공지 작성·수정·삭제 — MVP 18개 모두 작성 (NOTICE ① 본문 앞뒤 공백 제거, ② 필드 이름 content)
⚠️ **GitHub.com Fallback** ⚠️