이 글은 2026-07-13 하루의 기록이다. 같은
artifact-transformer를 두 채널에서 밀었다. 데스크톱 Agent Mode 세션에서는 인프라(Keycloak 도커 운영, Bitbucket 연동)를 가이드로 먼저 그렸고, 터미널 CLI 세션에서는 그 위에 실제 코드 — 산출물 피드백을 Jira 이슈로 다루는 CLI, Keycloak 기반 인증 서비스 계층, SDLC 단계 게이트, 웹 SSO 로그인 — 를 붙였다. 이날 만든 것은 전부 “누가 무엇을 할 권한이 있는가”를 코드로 박는 일이었는데, 정작 결과물을 올리는 마지막 한 걸음에서 나 자신에게 그 권한이 없었다.
배경 — 도구를 서비스로 바꾸는 연동의 날
artifact-transformer는 31종 소프트웨어 산출물을 생성·검증하는 CLI 파이프라인에서, 인증과 이슈 추적을 갖춘 다중 사용자 협업 서비스로 넘어가는 중이다. 문서(내용)와 코드는 이미 Git 위에 있다. 이날의 과제는 그 위에 작업(Task)의 추적과 사용자 인증이라는 두 레이어를 얹는 것이었다.
두 채널이 역할을 나눴다. 데스크톱 Agent Mode 세션은 운영 인프라를 설계·문서화했다 — Keycloak을 도커로 세우는 단계별 구성과, Bitbucket Cloud 계정을 연동하는 절차. 터미널 CLI 세션은 그 설계를 코드로 옮겼다 — common/jira_client.py, tools/jira.py feedback·gate, service/ 인증 패키지, revise.py --notify-issue, 그리고 웹(server/+frontend/) SSO 로그인까지.
문제
- 산출물 검토 피드백을, 저장소와 Jira 양쪽에 복제하지 않고 어떻게 이슈로 관리하나.
- 결정적으로 강제해야 할 규약과 사람이 판단할 문안의 경계는 어디인가.
- 인증을 누가 책임지나 — 서비스가 비밀번호를 직접 들 것인가.
- 커밋 후 자동 알림에서 산출물 식별자(
AN22-1)와 Jira 이슈 키(FDSP-241)를 어떻게 구분하나. - 로그인한 사용자의 신원을 커밋에 어떻게 위조 불가능하게 박나.
- 이 모든 걸 만들고도, 왜 PR 하나를 하루 종일 못 올렸나.
결정 1 — Jira는 작업의 정본, 저장소는 내용의 정본
“산출물에 대한 피드백을 Jira 이슈로 처리하고 싶다”는 요청의 핵심은 복제하지 않는 것이었다. Jira에 문서 내용을 옮겨 담거나, 저장소에 이슈 상태를 미러링하면 둘은 곧 어긋난다. 그래서 Jira는 “작업”의 정본, 저장소는 “내용”의 정본으로 두고, 둘을 키로만 상호 참조하게 했다 — 이슈 키 ↔ 문서 코드·버전·커밋 해시.
구현은 feedback 서브커맨드 5종(create/list/show/comment/done)으로 나왔다. 이슈 규약은 CLI가 단일 출처로 강제한다: 라벨 artifact-feedback + 문서 코드(예: AN22-1), 요약 [AN22-1 요구사항 정의서] {제목}, 설명 말미에 대상 버전(입력 JSON의 최신 revision)을 자동 기재. 산출물별 부모 이슈를 강제하지 않고, 라벨 JQL 자체가 곧 “산출물별 피드백함” 역할을 한다.
바닥에는 Jira Cloud REST v3 공용 클라이언트(common/jira_client.py)를 새로 깔았다. v3에서 필수인 ADF(Atlassian Document Format) ↔ 일반 텍스트 변환도 여기서 처리한다.
결정 2 — 결정적 단계는 CLI, 사람은 문안만 — 그리고 SDLC 전체로
이 프로젝트의 오래된 원칙은 “LLM은 최소로 개입한다”다. 규약 조립·JQL 구성·상태 전이 선택 같은 결정적 단계는 CLI에 두고, 안내 스킬은 사이클 흐름만 설명한다. 피드백 규약은 common/jira_feedback.py 한 곳에 모아 CLI와 서비스 API가 같은 출처를 공유하게 했다.
이 골격을 피드백 사이클에서 SDLC 전 주기로 넓혔다. 분석→설계→구현→시험→인도, 각 단계가 같은 리듬을 탄다 — 진입 게이트 → 작업 → 피드백/결함 사이클 → 완료 게이트. 설계를 두 축으로 갈랐다:
| 축 | 만드는 것 | Jira 개체 |
|---|---|---|
| 요구사항 축 | 기능 | Story → 구현 Task/Sub-task → Bug |
| 산출물 축 | 문서 | 피드백 Task, 관리 Task (CLI가 규약 강제) |
게이트의 기계화가 이 설계의 실전 결론이다. 커스텀 워크플로우 없이, 라벨 phase-AN/DE/… + JQL(labels = phase-AN AND statusCategory != Done → 0건)과 저장소 게이트(check ERROR 0)를 AND로 묶어 jira.py gate --phase AN이 종료 코드(0 통과 / 1 미달)로 판정한다. 게이트가 돌려면 이슈에 단계 라벨이 있어야 하므로, feedback create가 문서 코드에서 단계 라벨을 자동으로 도출해 붙이게 했다(AN22-1 → phase-AN). 상태는 Jira 기본 statusCategory로 해석하니 팀마다 다른 커스텀 워크플로우가 필요 없다.
결정 3 — 인증은 서비스가 만들지 않는다
“Keycloak 기반 사용자 인증을 추가하고 사용자별 Jira 계정을 연동해달라”는 요청에서 첫 결정은 서비스가 비밀번호를 들지 않는다는 것이었다. service/ FastAPI 앱은 Keycloak이 발급한 JWT를 JWKS 공개키로 서명 검증하고 issuer·만료·audience만 확인한다. 사용자는 조직 Keycloak(SSO)에서 토큰을 받아오고, 서비스는 비밀번호를 저장하지도 취급하지도 않는다.
사용자별 외부 계정 연동은 다른 층의 문제다. Jira·Bitbucket 계정은 본인이 PUT /me/accounts/*로 연동하되, 저장 전에 실제 호출(/myself, /2.0/user)로 실검증해서 잘못된 토큰은 422로 거부한다. 토큰은 Fernet로 암호화 저장하고, 평문이 디스크에 남지 않음을 테스트로 고정했다. 이후 피드백 이슈 생성·댓글이 요청자 본인 계정으로 실행되어, Jira 이력에 실제 작성자가 남는다.
데스크톱 채널은 이 코드가 설 자리를 먼저 그렸다. Keycloak을 도커로 세우는 구성 — PostgreSQL 16(볼륨 영속) + Keycloak production 모드(start, start-dev는 운영 금지), TLS는 앞단 리버스 프록시가 종단, 첫 로그인 후 부트스트랩 관리자 계정 비활성화, at-web 공개 클라이언트는 Authorization Code + PKCE(S256) 필수. 인증 시스템이므로 HTTPS 뒤에서만 노출한다는 전제를 문서에 박아 뒀다.
그다음 웹(server/+frontend/)에 이 SSO를 연동했다. 여기서 가장 중요한 결정은 커밋 신원의 위조 방지였다. 이 도구는 X-Author 헤더를 git 커밋 author로 쓰는 구조인데, 로그인 상태에선 미들웨어가 이 헤더를 토큰의 검증된 이름으로 교체한다 — 클라이언트가 “spoofed-name”을 보내도 커밋 author는 실제 로그인 사용자가 되게 하고, 테스트로 고정했다. 프런트엔드는 외부 라이브러리 없이 Authorization Code + PKCE(비보안 http 오리진은 plain 폴백)를 직접 구현했다.
인증은 명시적 opt-in으로 뒀다. SERVER_AUTH=required + KEYCLOAK_ISSUER가 둘 다 있어야 켜지고, 기본은 기존 사내망 모드 그대로다. required인데 issuer가 없으면 기동 시 즉시 실패하게 해서, 설정 실수를 조기에 드러냈다.
결정 4 — 오탐 방지가 자동화의 핵심
로드맵 1순위였던 revise.py --notify-issue는 개정 커밋 뒤 개정 설명문에서 Jira 이슈 키를 감지해 각 이슈에 “버전·커밋” 댓글을 자동으로 단다. 문제는 산출물 개정 설명문에 REQ-COM-101, AN22-1, FDSP-UTC-CTL-001 같은 산출물 식별자가 이슈 키와 똑같이 생겼다는 것이다.
그래서 설계의 무게중심이 기능이 아니라 오탐 방지로 옮겨 갔다. 이슈 키 정규식은 ① 프로젝트 키를 영문 대문자만으로 제한해 문서 코드 AN22-1류를 배제하고 ② 앞뒤가 하이픈이나 영숫자면 배제해 복합 식별자의 COM-101 세그먼트를 차단한다. "FDSP-241 검토 반영 — AN22-1 표 3 의 REQ-COM-101 표기 정정"에서 FDSP-241만 뽑히는 것을 테스트로 못 박았다. 트레이드오프도 명시했다 — 숫자가 섞인 Jira 프로젝트 키(드묾)는 미감지, 코드 주석에 남김.
외부 쓰기라는 점도 안전장치를 요구했다. 알림은 커밋 이후의 best-effort이고, Jira 실패(토큰 만료·이슈 없음) 시 커밋은 그대로 유지한 채 종료 코드 3(“커밋 성공, 알림 실패”)으로 구분해 알린다. --dry-run이면 클라이언트 생성 없이 “알림 예정: FDSP-241” 미리보기만 낸다.
자동화를 만들 때 정작 시간을 잡아먹은 건 “무엇을 할까”가 아니라 “무엇을 하지 말까”였다. 이슈 키 하나 잘못 집어서 엉뚱한 이슈에 댓글을 다는 것이, 알림을 아예 안 다는 것보다 나쁘다.
결정 5 — 앱 비밀번호가 폐기되는 중이라 API 토큰으로
Bitbucket Cloud 연동은 외부 사실 하나가 설계를 정했다. 데스크톱 채널에서 확인한 바, Bitbucket Cloud 앱 비밀번호는 폐기 단계다 — 2025-09-09부터 신규 생성 불가, 2026-06-09부로 동작 중지. 그래서 모든 절차를 Atlassian 스코프 지정 API 토큰 기준으로 짰다.
CLI 채널에서 만든 common/bitbucket_client.py는 지난 세션에서 실제로 겪은 "API Token provided has no Bitbucket scopes" 401 응답을 특별 처리한다 — 이 문구를 감지하면 오류 메시지에 “스코프 토큰 재발급” 안내를 함께 넣는다. 서비스 계층의 계정 연동은 Jira와 동일한 패턴(사용자별 링크 + 실검증 + Fernet 암호화)으로 재사용했고, accounts.py를 서비스 일반형으로 리팩터링하면서 기존 Jira 래퍼는 호환용으로 남겼다.
의도치 않은 함정 — 하루 종일 PR 하나를 못 만들었다
구현과 커밋 정리(기능 단위 3건)까지 끝내고 PR을 만들려는 순간부터, 이날의 나머지가 인증의 벽에 부딪혔다.
GitHub 인증 수단이 하나도 없었다 — gh CLI 미설치(PATH·표준 경로 모두), API 토큰 부재(GITHUB_TOKEN/GH_TOKEN, gh hosts 파일 모두), Claude in Chrome 확장 미연결. 세 경로가 전부 막혔다.
거기에 원격이 Bitbucket이라는 오해까지 겹쳤다. 확인해 보니 SSH 인증(git@bitbucket.org)은 되는데 대상 저장소를 못 찾았고, 기존 Atlassian 토큰은 Bitbucket 스코프가 없어 401이었다 — 앞의 결정 5에서 코드로 처리해 둔 바로 그 응답이다. 결국 “이건 GitHub만 있는 게 맞다”로 정리됐고, push는 이미 끝나 있으니 막힌 지점은 오직 GitHub 인증 하나였다.
에이전트가 통제할 수 없는 경계를 인정하고, 대안 세 가지만 정리해 넘겼다 — 직접 클릭할 PR 링크(제목·본문 완성본 포함), PAT를 넣으면 API로 생성, Chrome 확장을 연결하면 브라우저로 생성. 사용자가 직접 풀·머지한 뒤, 워크트리를 정리하고 이후 작업은 main에서 직접 하기로 전환했다(이 저장소는 워크트리 없이 main-direct라는 지침을 메모리에 남겼다).
이날 만든 코드는 전부 “누가 무엇을 할 권한이 있는가”를 기계로 강제하는 일이었다. Keycloak 토큰 검증, 사용자별 계정 실검증, 커밋 author 위조 방지. 그런데 정작 나 자신은 GitHub에 PR 하나 올릴 권한이 없었다. 인증은 편의로 우회할 수 있는 게 아니라 사람이 한 번 세워 줘야 하는 경계라는 걸, 만들던 쪽과 막히던 쪽 양쪽에서 같은 날 확인했다.
작은 자국 하나도 남겨 둔다. 웹 SSO 연동 중 전체 스위트 344/346 통과였는데, 실패 2건(test_assist.py)은 병렬로 진행 중인 서버 WIP의 환경 의존 테스트로, 내 변경과 무관했다 — 별도 세션에서 고치도록 작업 칩만 남겼다.
마무리
이날은 도구를 서비스로 바꾸는 연동을, 설계 채널과 구현 채널 양쪽에서 하루 만에 밀어붙인 날이었다.
- 복제 대신 참조 — Jira는 작업의 정본, 저장소는 내용의 정본. 키로만 이어 두 정본이 어긋나지 않게 했다.
- 결정적 CLI + 기계화된 게이트 — 규약·JQL·전이는 CLI 단일 출처가 강제하고, SDLC 단계 전환은 라벨 JQL과 check 결과의 AND로 판정한다. 커스텀 워크플로우 없이.
- 인증은 서비스가 만들지 않는다 — Keycloak이 토큰을 발급하고 서비스는 검증만. 커밋 author는 토큰의 검증된 이름으로 교체해 위조를 막았다.
- 오탐 방지가 자동화의 본체 — 이슈 키와 산출물 식별자를 가르는 정규식이
revise --notify-issue의 절반이었다. 잘못 알리는 것이 안 알리는 것보다 나쁘다. - 폐기되는 인증 방식에 올라타지 않기 — Bitbucket 앱 비밀번호 대신 스코프 API 토큰으로 처음부터 설계했다.
- 인증의 벽은 우회 대상이 아니다 — PR 하나를 못 만든 게 이날의 결론이었다. 만들 수 없는 것을 만들어 낸 척하지 않고, 경계를 인정하고 대안만 넘겼다.
다음에 남는 것:
- Jira 연동 로드맵 나머지 —
jira.py defect create --testcase(시험 항목 → Story 자동 링크 Bug),feedback done사전 게이트,upload.py --comment-issue. - Keycloak을 실제로 띄운 뒤 웹 SSO와 사용자별 Jira·Bitbucket 계정 연동을 실계정으로 확인.
test_assist.py환경 의존 실패 2건을 병렬 WIP 쪽에서 정리.- main-direct로 전환한 뒤, 병렬 서버 WIP와의 파일 충돌 관리(이번에도
.gitignore하나가 겹쳤다).