API 설계
본 문서는 서버가 바깥에 제공하는 HTTP 계약의 규약을 정의합니다.
개별 엔드포인트의 요청과 응답 스키마는 본 문서가 소유하지 않습니다. 그것은 코드에서 생성되는 OpenAPI 명세의 몫이며, 무엇을 만들 것인가는 요구사항 문서의 인수조건이 담당합니다. 본 문서가 담는 것은 그 위에 걸리는 규약과, 클라이언트가 알아야 하는 공개 계약입니다.
1. REST 준수 범위
REST 규칙 중 일부만 선택적으로 적용합니다.
| 항목 | 적용 | 규격 |
|---|---|---|
| HTTP 메서드 | 적용 | GET · POST · PUT · PATCH · DELETE 를 의미에 맞게 구분합니다. 조회는 서버 상태를 바꾸지 않습니다 |
| 리소스 구조 | 적용 | /리소스 또는 /리소스/{id} 계층을 지킵니다 |
| 리소스명 복수형 | 적용 | 영문 전체 낱말의 복수형을 씁니다 |
| HATEOAS | 미적용 | 응답에 링크를 담지 않습니다 |
HATEOAS를 쓰지 않는 이유는 프론트엔드가 생성된 명세로 이미 서버와 연결되어 있어 응답 안의 링크로 탐색할 실익이 없기 때문입니다.
2. 경로와 버저닝
- 모든 API를
/api/v1아래에 둡니다. 버전 세그먼트를 나중에 붙이면 기존 경로를 전부 옮겨야 하므로 처음부터 둡니다. - 리소스명은 영문 전체 낱말의 복수형을 씁니다.
/api/v1/sessions·/api/v1/users/{id}·/api/v1/attachments가 그 예입니다. - 하위 리소스는 상위 식별자 아래에 중첩합니다.
/api/v1/users/{id}/social-accounts가 그 예입니다. - 인증 주체 하나에만 존재하는 리소스는 단수형을 쓰고 식별자를 두지 않습니다.
/api/v1/profile이 그 예이며, 하위 리소스는 그 아래에 복수형으로 붙습니다(/api/v1/profile/auth-methods). 복수형으로 만들면 목록이 있다는 뜻이 되고, 식별자를 두면 남의 것을 요청하는 형태가 계약에 생깁니다 — 대상은 인증된 신원에서만 얻어야 합니다.
3. 에러 응답 — RFC 7807 단일 형식
에러는 전 구간 application/problem+json(Spring 의 ProblemDetail) 하나로 응답합니다. 성공 응답을 감싸는 공통 봉투는 쓰지 않습니다. HTTP 상태 코드가 이미 그 역할을 합니다.
{
"type": "about:blank",
"title": "인증 정보가 올바르지 않습니다",
"status": 401,
"detail": "이메일 또는 비밀번호를 확인해 주세요",
"instance": "/api/v1/sessions",
"code": "AUTH_INVALID_CREDENTIALS",
"traceId": "..."
}| 필드 | 성격 |
|---|---|
code |
클라이언트가 분기 판단에 쓰는 안정적인 식별자입니다. 계약입니다 |
title · detail |
사람이 읽는 문구입니다. 바뀔 수 있습니다 |
traceId |
서버 로그와 대조하기 위한 키입니다. 응답 헤더 X-Trace-Id 로도 함께 나갑니다 |
컨트롤러와 서비스가 응답 객체를 직접 조립하지 않습니다. 형식이 분기되면 프론트엔드가 오류 유형마다 다른 처리를 구현해야 합니다. 전역 핸들러가 예외를 단일 형식으로 변환합니다.
응답 본문은 평탄한 DTO를 씁니다. 중첩이 깊어지면 로그 마스킹이 성립하지 않아 민감 필드가 그대로 노출됩니다.
4. 에러 코드의 명명
에러 코드는 <컨텍스트>_<사유> 형태의 대문자 스네이크 케이스로 씁니다. AUTH_INVALID_CREDENTIALS 와 FILE_UPLOAD_EXPIRED 가 그 예입니다.
요구사항 식별자와 형태가 겹치면 추적성 검색이 오염됩니다. 하이픈은 요구사항 식별자(AUTH-01), 언더스코어는 에러 코드로 구분을 강제합니다.
사유를 구분하되 열거 가능성을 만들지 않습니다. 존재하지 않는 대상과 값이 틀린 경우는 같은 코드입니다.
5. 인증 계약
클라이언트가 인증을 어떻게 수행하는지는 공개 계약의 일부입니다. 웹과 모바일이 서로 다른 경로를 쓰기 때문입니다. 근거는 결정-0014에 있습니다.
| 클라이언트 | 세션 토큰 전달 | 함께 필요한 것 |
|---|---|---|
| 웹 | HttpOnly Secure SameSite 쿠키 |
CSRF 방어 |
| 모바일 · 외부 | Authorization: Bearer <token> |
없음 |
- 토큰은 불투명한 난수입니다. 자체 검증되는 JWT가 아니며
session테이블 조회로 검증합니다. - 세션 테이블은 하나입니다. 두 전달 경로가 같은 세션을 가리킵니다.
- 무효화는 즉시 반영됩니다. 로그아웃과 비밀번호 변경, 비밀번호 재설정, 기기 정리가 그 대상입니다.
- 소셜 제공자가 발급한 액세스 토큰과 리프레시 토큰은 받지도 보관하지도 않습니다(결정-0022).
6. 이메일 소유 증명 계약
이메일 소유 증명(OTP)도 공개 계약의 일부입니다. 클라이언트가 대기 레코드 식별자를 들고 다녀야 하기 때문입니다. 근거는 결정-0015에 있습니다.
증명 흐름은 두 번의 호출입니다. 발급이 이메일을 받아 대기 레코드 식별자를 내주고, 검증이 식별자와 코드를 받습니다.
| 규칙 | 내용 |
|---|---|
| 조회 키 | 검증은 (이메일, 코드) 가 아니라 대기 레코드 식별자로 조회합니다 |
| 식별자의 성격 | 대기 레코드는 아직 user 가 아닙니다. 식별자는 인증 자격이 아니며 세션으로 승격되지 않습니다 |
| 소셜 최초 로그인 | 2단계이며 제공자 인증 결과는 대기 레코드에 담깁니다. user 없이 account 를 만들지 않습니다 |
| 발급 응답 | 계정 존재 여부를 노출하지 않습니다. 기존 계정이 발견되면 코드 대신 안내 메일을 보내되 응답 형태는 동일합니다 |
| 오류 코드 | AUTH_OTP_EXPIRED · AUTH_OTP_ATTEMPTS_EXCEEDED · AUTH_OTP_INVALID 로 사유를 구분하되 열거 가능성을 만들지 않습니다 |
이메일과 코드만으로 대기 레코드를 찾으면 타인이 시작한 대기 레코드를 피해자가 자기 화면에서 완성시키는 공격이 성립합니다. 조회 키를 식별자로 고정하는 것이 그 경로를 막습니다.
제공자 인증과 이메일 입력 사이를 잇는 중간 표는 HttpOnly 쿠키로만 전달합니다. URL 쿼리에 실으면 액세스 로그와 Location 헤더, 브라우저 히스토리, Referer 에 남습니다. 표를 주운 사람이 자기 이메일로 소유 증명을 통과해 남의 제공자 신원을 영구 선점할 수 있습니다(보안 검토 F1). 표는 한 번 쓰면 쿠키에서 지웁니다.
주의
기기 목록의 표시 값은 공격자가 통제합니다
GET /api/v1/sessions 는 userAgent 와 ipAddress 를 원문 그대로 내려줍니다. 사용자가 자기 로그인 요청에 임의의 User-Agent 를 넣을 수 있기 때문입니다. 서버는 이 값을 인증 판단에 쓰지 않고 길이도 자르므로 서버 결함은 아니지만, 프런트가 이스케이프 없이 렌더하면 자기 화면 한정 XSS 가 됩니다(검토 #32-5). Angular 는 보간과 속성 바인딩을 기본으로 이스케이프하므로 그대로 쓰면 안전하며, 이 값에 innerHTML 과 bypassSecurityTrustHtml 을 쓰지 않습니다.
7. 하위 호환
응답 필드는 추가만 허용합니다. 필드 제거와 의미 변경은 프론트엔드가 그것을 쓰지 않는다는 확인을 거친 뒤에만 수행합니다. 백엔드 선행 배포가 항상 안전해야 합니다. 이 전제가 깨지면 두 배포 단위의 순서를 매번 협의해야 하고, 순서를 지키지 못한 배포가 곧 장애가 됩니다.
양쪽을 동시에 고치는 기능은 브랜치와 커밋 메시지에 같은 요구사항 식별자를 부여해 추적합니다.
8. 명세는 손으로 쓰지 않습니다
서버 코드에서 springdoc-openapi 가 생성한 OpenAPI 명세가 API 문서의 정본입니다. 엔드포인트 목록과 스키마를 마크다운으로 옮겨 적지 않습니다. 코드와 어긋나는 순간 없느니만 못한 문서가 됩니다.
springdoc-openapi 는 아직 도입하지 않았습니다. 도입 전까지 개별 엔드포인트의 계약을 기록하는 곳은 요구사항의 인수조건과 컨트롤러 코드뿐이며, 그 사이를 메우려고 수기 명세를 만들지 않습니다. 본 절이 금지하는 바로 그것이기 때문입니다.