용어 API
{idOrSlug}는 UUID와 slug 둘 다 받는다. UUID 형식이 아니면 slug로 조회한다. 주소가 바뀐 용어는 옛 slug로도 조회된다.
생성 시 slug는 약어가 아니라 fullNameEn → nameEn → nameKo 순서로 만든다. SLA는 service-level-agreement가 된다. 약어는 분야마다 뜻이 겹치므로 어떤 용어의 slug도 되지 않는다. 화면 주소 /g/sla는 표기로 조회해, 뜻이 하나면 그 용어로 이동하고 여럿이면 고르는 화면을 보여준다. 만든 slug가 이미 쓰이면 분야를 붙이고(pod-kubernetes), 분야가 없거나 분야까지 겹치면 번호를 붙인다(pod-2).
목록 조회
GET /api/v1/terms?q=exposure&domain=ISP&category=design&tag=노출%20제어&status=active&page=1&pageSize=20| 파라미터 | 기본값 | 설명 |
|---|---|---|
q | — | 검색어. Term이 아니라 Surface를 향한다 |
domain | — | 도메인 태그 하나 |
category | — | 관리자가 구성한 업무 분류의 안정적인 key 하나 |
tag | — | 해당 태그가 달린 용어. 기존 topic 쿼리도 지원 |
status | — | draft | active |
page | 1 | |
pageSize | 20 | 1~100으로 클램프된다 |
{ "items": [ /* TermSummary[] */ ], "total": 137, "page": 1, "pageSize": 20 }TermSummary는 id, slug, qualityProfile, nameEn, nameKo, domain, categories, category, categoryLabels, categoryLabel, tags, topic, ownerId, ownerName, status를 담는다. 호환용 topic에는 첫 태그가 담긴다.
category는 URL·API용 key이고 categoryLabel은 현재 표시 이름이다. 관리자가 표시 이름을 바꿔도 key와 기존 링크는 유지된다.
type 필터는 지원하지 않는다. status는 용어의 정리 상태(draft/active)이며 비권장·금지 표기는 용어 상태가 아니라 표기의 discouraged·forbidden kind로 나타낸다.
전체 카탈로그 내려받기
GET /api/v1/terms/catalog
Authorization: Bearer glk_...read 권한 API 키로 현재 용어 전체를 JSON으로 읽는다. items의 각 항목은 대표명, 풀네임, 정의, 본문, 도메인, 분류, 태그, 상태, revision, 모든 surfaces를 담는다. 관리자 복원용 스냅샷과 달리 내부 감사·정규화 컬럼은 포함하지 않는다.
응답의 ETag와 catalogVersion은 같은 내용 해시다. 다음 요청에 If-None-Match: "<catalogVersion>"을 보내면 변경이 없을 때 304가 반환된다. 이 API는 전체 카탈로그를 한 번에 직렬화하므로 큰 카탈로그에서는 응답 크기에 유의한다.
JSON 일괄 검토·반영
POST /api/v1/terms/batch
Content-Type: application/json
Authorization: Bearer glk_...
Idempotency-Key: import-2026-09-26-a
{
"dryRun": false,
"items": [
{ "key": "row-1", "operation": "create", "term": {
"nameEn": "Auto Exposure", "nameKo": "자동 노출", "domain": ["ISP"]
} },
{ "key": "row-2", "operation": "update", "idOrSlug": "existing-slug",
"expectedRevision": 6, "term": { "definitionMd": "수정된 정의" } }
]
}dryRun 기본값은 true다. 최대 100행, JSON 본문 최대 2MB이며 반영 요청에는 Idempotency-Key가 필수다. write 권한이 필요하다. 각 행의 key는 요청 안에서 고유해야 한다. 생성 입력은 단건 POST /terms, 수정 입력은 단건 PATCH /terms/{idOrSlug}의 용어 필드를 사용한다. 수정에는 양수 expectedRevision이 필수다.
results에는 행별 outcome이 담긴다. 검토 시 would_create/would_update, 반영 시 created/updated, 문제 행은 invalid/duplicate/not_found/ revision_conflict/slug_conflict다. 각 행을 순서대로 처리하며 문제 행은 저장하지 않고 다음 행으로 진행한다. 생성 중복은 대표명과 기존 표기 충돌을 판정한다. 다른 표기의 충돌은 동음이의어를 허용하는 기존 규칙대로 warnings에 싣는다.
성공 행의 결과와 재시도 기록은 같은 트랜잭션에 저장된다. 응답을 받지 못한 경우 동일한 Idempotency-Key, 행 key, 내용을 재전송하면 저장 없이 기록된 성공 결과와 replayed: true를 받는다. 같은 키에 다른 행 내용을 보내면 409 operation_conflict다. 성공 기록이 없는 실패 행은 재시도 때 다시 검사한다. 검토와 반영 사이에 다른 편집이 일어나면 판정이 달라질 수 있으므로 반영 시 재검사한다.
검색이 Surface를 향하는 이유
"오토익스포저"나 auto-exposure로 검색해도 AE 개념 페이지에 도착해야 한다. 사람들은 표준 표기를 모르기 때문에 용어집을 찾는다. 표준 표기로만 검색되면 도구가 무용지물이다. pg_trgm 유사도로 오타도 흡수한다.
잘못된 파라미터의 처리
?status=foo처럼 알 수 없는 enum 값 → 400validation_failed.details에field와allowed가 실린다.?status=처럼 빈 값 → "지정 안 함"으로 조용히 무시한다.<select>를 아무것도 고르지 않고 제출한 폼이 이런 쿼리스트링을 만든다.?page=abc,?page=1e999→ 400validation_failed. 재시도해도 성공하지 않는 입력이므로 500이 아니다.
같은 규칙이 화면(/sheet)에는 적용되지 않는다. 화면은 알 수 없는 값을 조용히 무시하고 기본값을 쓴다 — 사람이 주소창을 손으로 고치다 낸 오타 하나로 에러 페이지를 띄우면 안 되기 때문이다.
등록
POST /api/v1/terms
Content-Type: application/json
{
"nameEn": "AE",
"nameKo": "자동노출",
"fullNameEn": "Auto Exposure",
"domain": ["ISP"],
"category": "design",
"tags": ["노출 제어", "카메라"],
"ownerId": "11111111-1111-1111-1111-111111111111",
"status": "active",
"definitionMd": "장면 밝기에 따라 노출을 자동으로 맞추는 기능.",
"surfaces": [
{ "text": "AE", "lang": "en", "kind": "abbreviation" },
{ "text": "오토익스포저", "lang": "ko", "kind": "alias" },
{ "text": "auto exposure control", "lang": "en", "kind": "discouraged" }
]
}nameEn 또는 nameKo 중 최소 하나는 있어야 한다. status 입력은 이전 클라이언트 호환용으로 받지만, 실제 저장 상태는 서버가 내용의 완성도를 기준으로 다시 계산한다.
tags는 용어에 붙일 태그 배열이다. 앞의 #와 공백은 정리되고 중복은 제거된다. 기존 클라이언트의 topic 단일 값은 첫 태그로 받아들인다.
표기는 자동으로 파생된다
surfaces에 직접 넣지 않아도 표준 이름에서 표기가 만들어진다.
| 필드 | 파생되는 kind |
|---|---|
nameEn | 기본은 canonical. 같은 표기를 abbreviation으로 명시하면 약어 속성을 우선 보존 |
nameKo | canonical |
fullNameEn, fullNameKo | full_name |
caseSensitive를 주지 않으면 ^[A-Z0-9]{2,6}$에 맞는 짧은 전대문자 표기만 true가 된다. AE는 대소문자를 구분하고 Auto Exposure는 구분하지 않는다.
정규화 키와 kind가 같으면 먼저 온 쪽이 남는다. 파생 표기가 명시 표기보다 앞이다. 약어는 surfaces에 kind: "abbreviation"으로 명시한다. 대표 영문명과 약어 텍스트가 같으면 두 표기를 중복 생성하지 않고 약어 표기 하나로 저장한다.
201, 409가 아니다
{
"term": { "id": "…", "slug": "ae", "updatedAt": "2026-08-28T01:02:03.000Z", "…": "…" },
"surfaces": [ { "id": "…", "text": "AE", "lang": "en", "kind": "abbreviation", "caseSensitive": true } ],
"warnings": [ { "surfaceText": "AE", "conflictingSlug": "ae-audio-engine" } ]
}정규화 키가 기존 용어와 충돌해도 409를 던지지 않는다. 동음이의어를 허용하기로 한 설계이므로 저장은 그대로 진행하고 warnings로만 알린다. 등록을 막으면 안 되지만 "이미 이런 용어가 있다"는 반드시 보여줘야 한다.
warnings는 표기 텍스트와 충돌 대상 slug만 담는다. 화면이 "표기 → 기존 용어로 이동" 링크를 그리는 데 그 둘이면 충분하다.
400이 나는 경우
nameEn/nameKo가 둘 다 없다.- 표기가 trim 후에도 기호뿐이라(
"---") 정규화하면 빈 문자열이 된다..trim().min(1)으로는 잡히지 않는다 — 정규화의 구분자 집합이 JStrim()보다 넓다. - 같은 정규화 키에 서로 모순되는 kind가 붙어 있다. 승인군(
canonical/abbreviation/full_name/alias)과 비승인군(discouraged/forbidden)이 같은 키에 함께 올 수 없고,discouraged와forbidden이 동시에 붙을 수도 없다.
상세
GET /api/v1/terms/ae{
"term": {
"id": "…", "slug": "ae",
"nameEn": "AE", "nameKo": "자동노출",
"fullNameEn": "Auto Exposure", "fullNameKo": null,
"domain": ["ISP"], "status": "active",
"definitionMd": "…", "bodyMd": null,
"updatedAt": "2026-08-28T01:02:03.000Z",
"surfaces": [ /* SurfaceRow[] */ ],
"homonyms": [ /* TermSummary[] — 같은 표기의 다른 용어 */ ]
}
}updatedAt은 항상 ISO 문자열이다. homonyms가 비어 있지 않으면 화면이 상단에 "같은 표기의 다른 용어" 목록을 띄운다.
없으면 404 term_not_found.
수정
PATCH /api/v1/terms/ae
Content-Type: application/json
{ "definitionMd": "…", "expectedRevision": 6 }부분 갱신이라 표준 표기 필수 조건이 걸리지 않는다. 응답 형태는 등록과 같다 ({ term, surfaces, warnings }).
surfaces를 아예 보내지 않으면 기존 명시 표기를 유지한다. 보내면 그 배열이 명시 표기 전체를 대체한다.
URL 주소 변경
편집 화면의 URL 변경 버튼은 slug만 별도로 PATCH하고, 성공하면 새 편집 주소로 이동한다.
PATCH /api/v1/terms/ae
Content-Type: application/json
{ "slug": "auto exposure", "expectedRevision": 6 }서버는 입력을 auto-exposure처럼 소문자·하이픈 형식으로 정리한다. 이미 사용 중인 주소면 저장하지 않고 409 slug_conflict를 반환한다. 다른 용어가 예전에 쓰던 주소도 그 용어의 옛 링크라 409다. 예약 주소와 UUID 형식은 400으로 거부한다.
바꾸기 전 주소는 옛 slug로 남는다. 옛 주소로 들어온 화면 요청은 새 주소로 넘어가고 (옛 주소가 sla처럼 이 용어의 표기면 ?from=SLA를 붙여 넘어옴을 표시한다), API는 옛 slug로도 같은 용어를 돌려준다.
낙관적 잠금
편집 화면은 자기가 읽은 리비전 번호를 들고 있다가 저장 시점에 expectedRevision으로 되돌려 보낸다. 그 사이 남이 고쳤으면 서버가 덮어쓰지 않고 409로 거절한다.
{
"error": {
"code": "revision_conflict",
"message": "다른 사람이 먼저 수정했습니다.",
"details": { "currentRevision": 8 }
}
}클라이언트는 details.currentRevision으로 상대 변경을 다시 읽어 diff를 보여준 뒤 병합을 유도한다. 비관적 잠금은 "잠가놓고 퇴근한 사람" 문제를 만들어 쓰지 않는다.
expectedRevision을 생략하면 잠금 검사를 건너뛴다 — 도구가 무조건 덮어써야 하는 경우를 위한 것이므로 사람이 쓰는 편집 경로에서는 항상 실어 보낸다.
삭제
DELETE /api/v1/terms/aeadmin 역할이 필요하다. API 키에는 역할 개념이 없으므로 키로는 호출할 수 없다 (403 forbidden). 성공하면 204다. 표기와 리비전은 ON DELETE CASCADE로 함께 사라진다.
수정 이력
GET /api/v1/terms/ae/revisions최신순으로 돌려준다.
{
"revisions": [
{
"id": "…", "revisionNumber": 8, "message": "정의 보강",
"authorId": "…", "authorKeyId": null, "authorName": "홍길동",
"createdAt": "2026-08-28T01:02:03.000Z"
}
]
}authorId와 authorKeyId는 서로 배타적이다 — 세션 요청은 앞쪽, API 키 요청은 뒤쪽에 찍힌다. authorName은 조인해서 실어주므로 화면이 id를 다시 풀 필요가 없다. 사용자가 삭제됐으면 authorId는 남아도 authorName이 null일 수 있다.
되돌리기
POST /api/v1/terms/ae/revisions/6/revert
Content-Type: application/json
{ "expectedRevision": 8 }리비전 6의 스냅샷을 지금 상태에 덮어쓴다. 응답 형태는 수정과 같다 ({ term, surfaces, warnings }).
되돌려도 이력은 지워지지 않는다. 리비전 6~8이 사라지는 게 아니라, 6의 내용을 담은 새 리비전 9가 쌓이고 메시지는 #6으로 되돌림으로 남는다. 되돌리기를 되돌리는 것도 그냥 또 한 번의 되돌리기다. 승인 워크플로우가 없는 개방 편집에서 안전판은 라벨이 아니라 이 이력이므로, 이력을 깎는 방식은 쓰지 않는다.
- 로그인한 사용자면 누구나 호출할 수 있다(
writescope API 키도 가능). 삭제와 달리admin을 요구하지 않는다 — 되돌리기는 파괴적이지 않기 때문이다. expectedRevision은 수정과 같은 낙관적 잠금이다. 이력 화면을 열어 둔 사이 남이 먼저 고쳤으면 409revision_conflict. 화면의 되돌리기 버튼은 항상 실어 보낸다 — 되돌리기가 남의 편집을 조용히 지우는 도구가 되면 안 된다.- 본문은 없어도 된다. 그러면 잠금 검사 없이 되돌린다.
- 대상 리비전 이후에 추가된 표기는 사라지고, 그때 있던 표기가 복원된다. 그 리비전에 정의가 없었다면 정의도 비워진다.
- 없는 리비전 번호는 404
not_found, 없는 용어는 404term_not_found다.
왜 GET이 아닌가
되돌리기는 쓰기다. 링크(GET)로 만들면 "이 링크 눌러봐" 한 줄로 남의 용어를 되돌릴 수 있게 되어, 출처 검증과 SameSite=Lax 쿠키를 사용하는 이 사이트의 CSRF 방어가 그대로 뚫린다.
배치 조회 lookup
AI-Lint 통합 지점이다. 실제 호출 패턴이 "이 목록이 다 등록돼 있나?"라서 배치를 기본으로 둔다.
POST /api/v1/terms/lookup
Content-Type: application/json
{ "texts": ["AE", "이미지센서", "AutoExposure", "Foobar"] }texts는 1~500개다. 빈 문자열과 공백뿐인 문자열은 400이다.
{
"results": [
{
"text": "AE",
"found": true,
"matchKind": "abbreviation",
"terms": [
{ "id": "t_ae", "slug": "ae",
"nameEn": "AE", "nameKo": "자동노출", "domain": ["ISP"], "status": "active" }
],
"similar": []
},
{
"text": "Foobar",
"found": false,
"matchKind": null,
"terms": [],
"similar": [{ "slug": "foobar-mode", "score": 0.72 }]
}
]
}동작상 알아둘 것.
text는 요청 원문 그대로 되돌아온다." ZDK "를 보내면" ZDK "가 온다. 정규화는 내부에서만 일어난다.results는 요청한texts와 같은 길이, 같은 순서다. 중복 표기를 보내도 그대로 각각 응답한다(내부 조회는 한 번만 한다).matchKind는 매칭된 표기가 여럿일 때 우선순위로 하나를 고른 것이다.forbidden > discouraged > canonical > abbreviation > full_name > alias. 행 순서가 아니라 코드에 명시 고정된 표다 — 같은 표기가 alias이자 forbidden으로 등록돼 있을 때 alias가 먼저 나오면 린터가 금지 표기를 놓친다.similar는 못 찾았을 때만 채워진다.pg_trgm유사도 상위 3개이고, 같은 용어가 여러 표기로 걸려도 slug 기준으로 이미 중복 제거되어 있다. 정렬은 점수 내림차순, 동점이면 slug 오름차순으로 완전히 고정된다.- 정규화하면 빈 문자열이 되는 입력(
"---"같은 것)은 매칭도 유사도 조회도 대상이 되지 않는다.
상태를 바꾸지 않는 읽기 동작인데 POST인 이유는, 문서 전체를 훑는 배치 요청이라 본문이 GET 쿼리스트링에 담기지 않기 때문이다. "상태를 바꾸는 GET을 만들지 않는다"는 불변식과는 무관하다.
자동완성 suggest
홈 검색창이 한 글자마다 부르는 자리다. 응답을 작게 유지한다 — 정의문·도메인·리비전은 싣지 않는다.
GET /api/v1/terms/suggest?q=syq는 필수다. 없거나 공백뿐이면 400 validation_failed(details.field가 "q").
{
"items": [
{ "id": "t_soc", "slug": "system-on-chip",
"nameEn": "System on Chip", "nameKo": "시스템 온 칩", "status": "active",
"matchedText": "SoC", "matchedKind": "abbreviation", "exact": false, "prefix": true }
]
}동작상 알아둘 것.
- 후보는 최대 8개다. 개수를 조절하는 파라미터는 없다 — 더 넓게 보려는 요청은
GET /terms가 받는다. prefix가 자동완성과 오타 교정을 가른다. 입력이 그 표기의 앞부분이면true,pg_trgm유사도로만 걸렸으면false다. 화면은 이 값으로 목록을 두 묶음으로 나눈다 (섞어서 보여주면 사용자가 자기 오타를 끝까지 모른다).- 앞부분 판정은 정규화 키(
norm_loose) 기준이다."sysonchip"으로"System on Chip"이 걸릴 수 있으므로, 눈에 보이는 문자열이 입력으로 시작한다는 보장은 없다. - 한 용어에 걸린 표기가 여럿이어도 후보는 용어당 하나다. 정렬은 정확 매치 → 앞부분 매치 → 유사도 → 짧은 표기 순.
- 정규화하면 빈 문자열이 되는 입력은 빈
items를 돌려준다.