상황
사내 도구를 만들다 보면 설정 계층은 자연스럽게 세 단으로 갈라진다. 조직이 관리하는 시스템 설정, 사용자마다 다른 개인 설정, 작업 단위로 오버라이드되는 프로젝트 설정. 여기까지는 흔한 구조다.
문제는 자격증명이었다. 이 시스템은 Jira 인스턴스에 이슈를 발행하고, LLM API를 호출한다. Jira PAT(Personal Access Token)와 LLM API 키를 어느 계층에 두고, 누구 명의로 쓸 것인가 — 이 질문에 "자격증명은 개인 설정이니까 사용자당 하나"라는 직관적인 답을 그대로 적용하면 설계가 어긋나기 시작한다.
문제
세 가지 지점에서 직관이 깨졌다.
첫째, 자격증명은 개인 글로벌 단일값이 아닌 경우가 많다. Jira PAT는 사용자가 발급받는 것이 맞지만, 인스턴스별로 발급받는다. 회사가 Jira 인스턴스를 두 개 운영하면 같은 사용자가 PAT를 두 개 가진다. "사용자 테이블에 jira_token 컬럼 하나"로는 모델링이 안 된다.
둘째, 세션 없는 실행 경로가 있다. 이슈 발행은 웹 요청 안에서만 일어나지 않는다. 비동기 워커가 처리하기도 하고, MCP 클라이언트가 머신 키로 호출하기도 한다. 이 경로들에는 "현재 로그인한 사용자"가 없다. 그러면 누구의 PAT를 쓸 것인가? 흔한 임시방편은 "아무 유저의 키나 집어 쓰기" 또는 "admin 계정으로 폴백"인데, 둘 다 시한폭탄이다.
셋째, 폴백을 일괄 허용할 수도, 일괄 금지할 수도 없다. 개인 키가 없을 때 시스템 공용 키로 넘어가는 폴백을 Jira에 허용하면 남의 계정 활동으로 기록되고, LLM에 금지하면 회사 공용 게이트웨이를 쓰는 실존 케이스가 막힌다.
접근과 판단
원칙 1 — 자격증명은 개인×리소스 교차 테이블이다
리소스 정보와 인증정보를 계층으로 분리했다. Jira 인스턴스 자체(endpoint, 인증 타입, 필드 매핑 기본값)는 조직이 관리하는 시스템 계층의 리소스다. 그 인스턴스에 접근하는 PAT는 개인 계층의 인증정보다. 그리고 PAT가 인스턴스별 발급이므로, 개인 인증정보는 사용자 하위의 단일값이 아니라 두 축의 교차점에 놓인다.
JiraCredential(user_id, jira_config_id, token)
"자격증명 = 개인 설정"이 아니라 "자격증명 = 개인 × 리소스"다. 이 교차 테이블 구조를 처음부터 잡으면, 인스턴스가 하나뿐인 초기에도 스키마 변경 없이 인스턴스 추가를 흡수한다.
flowchart TB
subgraph system["시스템 계층 (조직 관리)"]
JC1[JiraConfig A<br/>endpoint, auth type, 매핑 기본값]
JC2[JiraConfig B]
SK[시스템 공용 LLM 키<br/>LiteLLM / Bedrock 게이트웨이]
end
subgraph personal["개인 계층 (사용자 관리)"]
U1[User 1]
U2[User 2]
CR1["JiraCredential<br/>(user 1 × config A)"]
CR2["JiraCredential<br/>(user 1 × config B)"]
CR3["JiraCredential<br/>(user 2 × config A)"]
PK[개인 LLM 키]
end
U1 --- CR1 & CR2
U2 --- CR3
CR1 --- JC1
CR2 --- JC2
CR3 --- JC1
U1 --- PK
PK -. 없으면 폴백 .-> SK
원칙 2 — 모든 동작은 실행자에게 귀속되고, 귀속 대상이 없으면 동작하지 않는다
"세션 없는 경로"를 하나씩 뜯어보면, 진짜 주인 없는 동작은 없다.
- 비동기 작업 — 누군가 트리거했다. 트리거한 사용자 ID를 작업 시작 시점에 캡처해서 작업 레코드에 저장하면, 워커는 세션 없이도 귀속 대상을 안다.
- MCP 머신 키 — 누군가 발급받았다. 키 자체에 발급자를 귀속시키면(
McpAuthKey.UserID) 머신 호출도 사람에게 닿는다.
이 두 경로를 정리하고 나면 "아무 유저 키 집어 쓰기"와 "admin 자동 폴백"은 존재 이유가 사라지므로 제거했다. 귀속 대상을 못 찾으면 조용히 우회하는 대신 동작을 거부한다.
원칙 3 — 폴백 정책은 크리덴셜의 성격에 따라 차등한다
폴백 허용 여부를 가르는 기준은 "이 크리덴셜로 한 행동이 누구의 활동으로 기록되는가"와 "공용 크리덴셜이 실존하는가"다.
| Jira PAT | LLM API 키 | |
|---|---|---|
| 행동의 기록 | PAT 소유자의 계정 활동으로 남음 | 과금 주체만 다를 뿐 활동 귀속 문제 없음 |
| 공용 크리덴셜 실존 여부 | 없음 — PAT는 본질적으로 개인 발급 | 있음 — 회사 공용 게이트웨이(LiteLLM, Bedrock) |
| 폴백 정책 | 금지. 개인 크리덴셜 없으면 명시적 인가 에러 | 2단 허용. 개인 → 시스템 공용 → 에러 |
Jira에서 폴백을 허용하면 A가 트리거한 발행이 B의 계정 활동으로 기록된다 — 감사 추적이 오염되고, B는 자기가 하지 않은 활동을 해명해야 한다. 그래서 폴백 대신 명시적 인가 에러를 던지고, 에러 메시지가 개인 설정 등록 화면으로 유도하게 했다. 반면 LLM 키는 회사가 공용 게이트웨이를 운영하는 케이스가 실존하므로, 개인 키가 없으면 시스템 공용 키로 내려가는 것이 오히려 올바른 기본값이다.
구현
귀속을 정확히 잡으면 파생 효과가 따라온다.
담당자 자동 지정. Jira 발행 시 담당자를 수동으로 사번 입력받던 UI가 있었는데, 귀속 설계 이후 불필요해졌다. 발행에 사용된 PAT의 소유자가 곧 실행자이므로, Jira의 myself API로 PAT 소유자를 조회해 담당자로 자동 지정하면 된다. 수동 입력 UI는 폐기했다. 역으로 이 파생은 귀속이 틀리면 담당자 지정까지 연쇄 오염된다는 뜻이기도 하다 — 폴백 금지가 여기서 한 번 더 정당화된다.
리소스 바인딩 변경 시 원 리소스 추적 유지. 프로젝트가 바라보는 Jira 인스턴스를 교체하면 기존 산출물은 어떻게 되는가. 이미 발행된 이슈를 새 인스턴스로 재발행하거나 이관하지 않는다. 산출물은 발행 당시의 리소스를 추적 필드(Task.JiraConfigID)로 유지하고, 화면에는 "이 산출물은 이전 인스턴스에 발행됨" 경고만 띄운다. 바인딩 변경은 미래 동작의 대상을 바꾸는 것이지 과거 산출물의 소속을 바꾸는 것이 아니다.
결과
- 자격증명 모델이
(user_id, resource_id)교차 테이블로 정착되어, 리소스(Jira 인스턴스) 추가가 스키마 변경 없이 흡수된다. - 비동기 작업·MCP 경로를 포함한 모든 실행 경로에서 행위자가 추적된다. "아무 유저 키"·admin 자동 폴백 제거로 감사 추적 오염 경로가 사라졌다.
- Jira 담당자 수동 입력 UI 폐기 — 귀속 데이터에서 자동 파생.
- 폴백 정책이 크리덴셜별 표로 명문화되어, 새 외부 연동을 추가할 때 "이 크리덴셜의 행동은 누구 활동으로 기록되는가 / 공용 크리덴셜이 실존하는가" 두 질문으로 정책을 결정할 수 있게 됐다.
배운 것
- "자격증명은 개인 설정"이라는 직관은 반만 맞다. 크리덴셜이 리소스별로 발급되는지 먼저 물어야 하고, 그렇다면 모델은 단일 컬럼이 아니라 개인×리소스 교차 테이블이다. 리소스 정보(시스템 계층)와 인증정보(개인 계층)의 분리는 그 전제 위에서 성립한다.
- "세션이 없어서 누구 키를 쓸지 모르겠다"는 경로는 대부분 귀속 정보를 캡처하지 않았을 뿐, 주인이 없는 게 아니다. 트리거 시점 캡처와 발급자 귀속으로 전부 사람에게 닿는다. 닿지 않는 동작은 거부하는 것이 임시 폴백보다 싸다.
- 폴백은 일괄 정책이 아니다. 크리덴셜로 한 행동이 타인의 활동으로 기록되는 종류(개인 발급형)는 폴백 금지 + 명시적 에러로 등록을 유도하고, 공용 크리덴셜이 실존하는 종류(게이트웨이형)는 개인 → 공용 폴백이 올바른 기본값이다.
- 귀속을 정확히 잡으면 UI가 줄어든다(담당자 자동 지정). 반대로 귀속이 틀리면 오염이 파생 지점까지 연쇄된다. 귀속은 보안 속성이면서 동시에 UX 자산이다.