솔루션 한 덩어리가 우리 포탈의 엔진 역할을 하고 있다. 솔루션 쪽은 이미 Gitea OIDC를 단일 신원 출처로 잡고 표준화돼 있었고, 포탈은 별도로 Keycloak 기반 회원관리를 하고 있다. 두 인증 체계가 따로 굴러가는 상태에서 “포탈에 로그인하면 엔진의 권한 트리에도 자연스럽게 살아 있어야 한다”는 요구를 풀어야 했다. 그 결과 IdP Broker 방식을 채택했고, 알파 한 곳에서나 겨우 굴러가던 초기 부트스트랩 스크립트는 베타·사업장 prod 다환경 대응을 위해 한 번 더 다시 깎였다. 그 과정의 기록이다.
- 솔루션은 자체적으로 Gitea OIDC를 통합 인증 기준으로 쓰고 있었고, 포탈은 Keycloak 회원관리가 이미 박혀 있었다. 둘 중 하나를 버리는 선택은 불가능했다.
- 결합 방식으로 Keycloak을 IdP Broker, Gitea를 External OIDC IdP로 두는 구조를 채택했다. SP는 OIDC만 보고 들어오면 되고, 신원의 단일 출처는 여전히 Gitea다.
- 초기 Keycloak 초기 설정 스크립트는 멱등 처리가 불가했고, alpha 한 환경 기준으로만 짜여 있어서 베타/사업장 prod로 옮길 수 없었다.
- 베타·사업장 prod 다환경 준비를 위해
apply-client.sh,apply-auth-flow.sh+ 환경별 JSON 템플릿으로 다시 깎았다. 재실행해도 폭발하지 않고, 납품팀이 사내 자산 없이 단독 실행할 수 있는 게 1순위 요건이었다. - 결합 과정에서 한 번 발등을 찍은 함정. “IDP 최초 로그인은 됐는데 엔진 쪽 조직/팀 프로비저닝은 안 들어가는” 케이스도 함께 정리한다. 진입점이 둘 이상이면 결과 상태를 함수가 아니라 한 단계 위에서 묶어야 한다.
1. 사건의 발단 - 두 인증 체계가 동시에 살아 있다
엔진(솔루션)과 포탈은 결합 관계가 강하다. 엔진이 가진 자원(저장소, 조직, 팀, 권한)을 포탈이 그대로 끌어다 쓰고, 포탈에서 발생한 사용자 행동의 상당수는 결국 엔진 측 자원에 반영돼야 한다. 그런데 인증 체계가 둘이었다.
- 엔진 쪽 통합 OIDC 기준은 Gitea다. 엔진의 모든 사용자/조직/팀 모델이 Gitea OAuth2를 단일 신원 출처로 쓰는 전제 위에 깔려 있다.
- 포탈은 Keycloak 기반으로 회원관리를 한다. 회원 DB, 역할 분리, 토큰 정책, FE/BE 분리 인증이 전부 Keycloak realm 안에서 굴러간다.
두 체계 중 하나를 버리는 선택은 가능하지 않았다.
- 엔진 측의 Gitea OIDC를 끄면 솔루션이 가진 자체 권한 모델/사용자 라이프사이클이 무너진다. 솔루션은 우리 포탈 외에도 다른 곳에 들어가니 “포탈 편의를 위해 솔루션 인증 표준을 흔드는” 결정은 합리적이지 않다.
- 포탈의 Keycloak도 같은 이유로 못 뺀다. 포탈에 붙는 SP가 한둘이 아니고, FE/BE 외에도 사이드 컴포넌트들이 OIDC만 보고 들어오게 설계돼 있다. SP들에게 “이제부터 Gitea OAuth2 보세요”라고 하는 비용이 너무 크다.
그래서 “양쪽을 동시에 살리되 사용자 경험은 한 번의 로그인으로” 만드는 게 출발점이었다.
2. 결합 전략을 고른 이유 - 왜 IdP Broker였나
선택지는 사실 셋이었다.
| 안 | 요지 | 단점 |
|---|---|---|
| A. SP가 Gitea OIDC를 직접 본다 | 포탈 BE/FE를 비롯한 모든 SP가 Gitea를 OIDC Provider로 직접 본다. | 포탈 회원관리(Keycloak realm)를 사실상 폐기해야 한다. SP가 늘어나면 Gitea OIDC의 한계(클레임 매핑, 세션 정책, MFA 미지원 등)를 SP 전부가 떠안는다. |
| B. 이중 로그인 / 외부 동기화 | 포탈은 Keycloak만, 엔진은 Gitea만 본다. 별도 동기화 잡이 두 시스템 사이를 메운다. | 신원 동기화 지연, 두 번 로그인, 권한 일관성이 운영 단계에서 끝없는 사고 트리거가 된다. |
| C. Keycloak을 IdP Broker로 두고 Gitea를 External OIDC IdP로 페더레이션 | SP는 Keycloak만 보고, Keycloak이 Gitea OIDC를 위임 호출. 신원 단일 출처는 그대로 Gitea, 회원관리/realm 정책은 Keycloak. | Authentication Flow 설계, External IdP 콜백 토폴로지, federated identity 보관 정책을 잘 잡아야 한다. 잘못 잡으면 “인증은 되는데 권한 동기화가 안 되는” 유령 사용자가 만들어진다. |
C안을 골랐다. 이유는 단순하다. SP가 늘어나도 OIDC만 보고 붙으면 되고, 신원의 출처는 여전히 Gitea라 엔진 측 권한 모델을 흔들 일이 없다. 운영 비용이 가장 낮은 그림이었다.

표면적으로는 “SP는 Keycloak만 본다”인데, 실제 신원의 단일 출처는 점선 안쪽의 Gitea다. 인증 표면(Keycloak) 과 신원 출처(Gitea) 를 분리한 게 핵심이다.
3. 흐름을 한 번에 따라가기
전체 시퀀스는 이렇게 흐른다.

5번 이후가 진짜 일하는 구간이다. id_token을 받아드는 것만으로 끝나는 게 아니라, 포탈 회원 동기화 + 엔진 측 조직/팀 프로비저닝까지 가야 “결합”이 완성된다. 둘 중 하나라도 빠지면 “포탈에는 보이는데 엔진에는 안 보이는 사용자”가 만들어진다.
4. Authentication Flow 설계 - 어디서 자주 망하는가
Keycloak의 First Broker Login Flow는 외부 IdP를 거쳐 들어온 사용자에게 적용되는 후처리다. 토폴로지는 이렇게 잡았다.
first broker login for gitea (basic-flow, topLevel)
├── Review Profile [DISABLED]
├── User creation or linking [REQUIRED]
│ ├── Create User If Unique [ALTERNATIVE]
│ └── Automatically set existing user [ALTERNATIVE]
└── First Broker Login - Conditional Organization [CONDITIONAL]
├── Condition - user configured [REQUIRED]
└── Organization Member Onboard [REQUIRED]
각 결정의 이유를 짧게 적는다.
- Review Profile [DISABLED] : 외부 IdP 프로필을 다시 보여주는 페이지를 없앤다. 우리 시나리오에서는 Gitea가 신원의 단일 출처라 사용자에게 “이미 본인 정보인데 한 번 더 확인하라”는 단계가 필요 없다. UX가 한 단계 줄어든다. 다만 외부 IdP를 신뢰 가능한 단일 출처로 볼 수 있을 때만 정당화되는 결정이라는 걸 기록으로 남겼다.
- User creation or linking [REQUIRED] : Keycloak 내부 user 레코드를 만들거나, 이미 있으면 federation link만 건다.
Create User If Unique와Automatically set existing user를 ALTERNATIVE로 둬서 “있으면 연결, 없으면 생성”을 한 줄로 처리한다. - First Broker Login - Conditional Organization [CONDITIONAL] : Keycloak Organizations 기능을 끼워서 멤버 온보딩 후처리를 한다. realm 단위 멤버십을 한 번에 정리하는 포인트인데, Conditional을 Required로 잘못 걸면 외부 IdP 콜백만 받고 곧장 튕긴다. 한 번 발등을 찍힌 부분이다.
한 줄로: Review Profile은 끄고, User creation은 ALTERNATIVE로 분기, Organization 처리는 CONDITIONAL로. 이 셋의 조합이 IdP Broker + Federation의 디폴트 답이라는 결론이다.
5. 초기 부트스트랩 스크립트의 한계 - 멱등이 안 됐다
여기까지 설계만 보면 깔끔해 보이지만, 운영 관점에서 부트스트랩 스크립트 자체가 한 번 더 부서졌다. 초기 버전은 alpha 환경 한 곳에서만 굴러가도록 짜여 있었고, 핵심 결함이 두 가지였다.
- 멱등 처리가 불가능했다. 같은 환경에 두 번 돌리면 realm/client/identityProvider 자원이 중복 등록되거나, 그 자체로 5xx를 뱉으며 멈췄다. 운영 중 작은 변경(redirect URI 한 줄 추가)을 위해 스크립트를 다시 돌릴 수 없는 상태였다.
- 환경 분기가 코드에 박혀 있었다. alpha의 Keycloak 도메인, 사내 Gitea 주소, 사내 헬름 차트 의존성이 스크립트 본문에 그대로 들어가 있어서 베타/사업장 prod로 옮길 수 없었다.
운영 과정에서 “Keycloak 인스턴스를 매번 콘솔로 손으로 셋업”하는 우회가 반복됐다. 한두 번이면 모르는데, 베타/사업장 prod 시나리오가 다가오면서 더 이상 손으로 못 받는 단계에 들어왔다.
6. 다환경 대응 - 무엇을 다시 깎았나
다시 깎으면서 정한 원칙은 단순하다.
- 재실행해도 폭발하지 않는다 (멱등성).
- 사업장 prod에서는 납품팀이 사내 자산 없이 단독 실행할 수 있다 (사내 헬름/사내 NPM/사내 PyPI 의존 0).
- 환경 분기는 환경 변수와 JSON 템플릿으로만 받는다 (스크립트 본문에 도메인 박지 않는다).
6.1 실행 순서
[apply-client.sh]
1) ccp realm 보장 (없으면 create, 있으면 update)
2) admin / client 초기 설정
- admin user 시드
- 포탈 BE / FE OIDC client 등록 (redirectUris, webOrigins)
3) Gitea OAuth2 Application 발급
- gitea_admin 토큰으로 호출
- 네이밍: keycloak-idp-broker (이름 기준 유일성)
- 발급된 client_id / client_secret 보존
[apply-auth-flow.sh]
4) Keycloak External IdP (gitea-oidc-idp) 등록
- storeToken=true (federated identity에 외부 토큰 보관)
- 위 3)의 client_id / client_secret 주입
5) Authentication Flow 구성 (4장 토폴로지 그대로 적용)
6) Gitea OAuth2 Application patch
- redirectURI를 Keycloak External IdP 콜백 주소로 동기화
6.2 환경 분리
| 항목 | alpha-net | beta | 사업장 prod |
|---|---|---|---|
| Keycloak Base URL | 사내 도메인 | 별도 도메인 | 사업장 폐쇄망 도메인 |
| Gitea Base URL | 사내 Gitea | 사내 Gitea (별도 인스턴스 가능) | 사업장 단독 Gitea |
| realm 이름 | ccp (공통) |
ccp |
ccp |
| client redirect | https://alpha-… |
https://beta-… |
사업장 도메인 |
| 시드 admin | 운영자 계정 | 운영자 계정 | 사업장 PM 계정 |
| 사내 자산 의존 | 있음 | 있음 | 없음 (스크립트 + JSON만) |
표 마지막 행이 핵심이다. 사업장 prod는 사내 헬름 차트, 사내 NPM, 사내 PyPI 어느 것에도 못 붙는다. 스크립트가 “bash + curl + JSON” 조합으로 자급 가능해야 한다는 제약이 사실상 1순위 설계 입력이었다.
6.3 멱등성을 어떻게 잡았나
원칙은 두 줄로 줄었다.
- 모든 자원에 대해 “있으면 update, 없으면 create”. Keycloak Admin REST는 PUT/POST가 분리돼 있으니 사전 GET으로 존재 여부 확인 후 분기. realm, client, identityProvider, authentication flow 전부 같은 패턴.
- 이름 기준 유일성으로 외부 자원 매칭. 특히 Gitea OAuth2 Application은 동일 이름이 여러 개 만들어질 수 있어서,
keycloak-idp-broker이름 기준으로 매칭하고 중복이면 patch.
추가로 신경 쓴 두 가지
- Keycloak 부트 직후의 race. Keycloak 컨테이너가 살아 있어도 master realm 초기화가 끝나기 전에 스크립트가 들어오면 5xx가 난다. 진입부에
wait-for-keycloak헬스체크 폴링을 두고, 초과 시 명시적 실패. 무한 루프는 두지 않는다. - 부분 실패 후 재실행. 6단계 중 4단계까지 성공하고 5단계에서 죽었을 때, 다시 1번부터 돌려도 괜찮아야 한다. 위의 “있으면 update” 규칙을 모든 단계에 일관되게 적용해서, 재실행이 곧 복구가 되는 구조로 만들었다.
7. 한 번 발등을 찍은 함정 - 진입점이 둘이면 결과 상태를 묶어라
결합 자체가 깔끔해 보여도, 운영을 시작하면 “두 시스템의 일관성”이 무너지는 지점이 항상 있다. 우리 경우는 회원 등록 진입점이 둘이었다는 데서 터졌다.
증상은 단순했다. Gitea IdP Broker로 최초 로그인했는데 포탈 회원은 정상 등록됐고, 정작 엔진 쪽 조직/팀에는 사용자가 안 들어가 있었다.
원인을 찾아 들어가니, 회원 등록 진입점이 두 개였고 각자 외부 시스템 프로비저닝 호출 여부가 달랐다.
| 회원 등록 경로 | 구현체 | 엔진 프로비저닝 |
|---|---|---|
| 자체 회원가입 (이벤트 기반) | 회원가입 컨슈머 → 외부 시스템 프로비저닝 호출 | O |
IDP 로그인 → /members/sync |
동기화 유즈케이스 → DB 저장만 | X (누락) |
IDP 로그인 경로의 동기화 유즈케이스는 Keycloak 사용자 조회 → 포탈 DB upsert에서 끝났다. 그래서 Keycloak 입장에서는 정상 로그인, 포탈 입장에서는 정상 회원, 엔진 입장에서는 “모르는 사용자”라는 모순 상태가 됐다.
해결은 단순했다.
- 동기화 유즈케이스에서 신규 회원 생성 분기일 때만 엔진 프로비저닝을 호출한다.
- 단, IDP 경로의 사용자는 이미 Gitea 계정이 있는 게 전제다. 그래서 사용자 생성 단계는 건너뛰고, 팀 resolve → 팀 add만 수행한다.
- 사용자명이 필요할 때는 Keycloak Federated Identity의
storeToken=true설정을 활용해 Gitea username을 직접 조회한다. 이메일 기반 추정은 위험해서 안 쓴다.

여기서 얻은 한 줄 교훈이라면 아래와 같다.
회원 등록 진입점이 둘 이상이면, 결과 상태를 함수 안이 아니라 한 단계 위에서 묶어야 한다. 각자 “회원이 등록되었음”의 정의를 다르게 들고 있으면 결합 자체가 의미를 잃는다.
8. 회고
이번 작업의 본질은 “Keycloak이냐 Gitea OIDC냐”의 선택이 아니었다. 둘 다 살아 있어야 하는 결합 관계라는 사실을 받아들이고, 인증 표면과 신원 출처를 분리하는 그림을 그린 게 출발점이었다. 그 다음에야 IdP Broker라는 구현 수단이 자연스러워졌다.
그리고 한 가지 더, 부트스트랩 스크립트가 멱등하지 않으면 결국 “누군가의 머릿속 절차”에 시스템이 묶인다. alpha 한 곳에서 굴러가던 초기 스크립트는 시간이 지날수록 운영 비용이 기하급수적으로 늘었고, 베타/사업장 prod 시나리오가 그 한계를 한 번에 끌어당겼다. 재실행해도 폭발하지 않는다는 단순한 원칙이, 실제로 다환경 대응 비용을 가장 많이 낮춰준 결정이었다.
다음 단계는 사용자 식별을 한 칸 더 깊게 가져가는 일이다. 지금까지는 Keycloak 사용자 단위로 모든 게 끝났지만, 엔진 권한 모델은 결국 Gitea username과 조직/팀 단위로 작동한다. 포탈에서 K8s 같은 하부 자원에 접근할 때 엔드 유저 식별을 Gitea username 기반으로 끌어내려 Impersonation 헤더에 싣는 작업이 다음 차례다. 이건 다음 글에서 따로 적는다.
부록. 자주 받는 질문 메모
- 왜 Review Profile을 끄나? Gitea가 신원의 단일 출처일 때만 정당화된다. 여러 IdP를 동시에 받는 시나리오면 ENABLED로 되돌린다.
storeToken=true는 왜 켰나? 백엔드에서 Gitea username을 federated identity로 직접 조회하기 위해서다. 이메일 추정 회피가 핵심 목적.- Terraform Keycloak Provider는 왜 안 쓰나? 사업장 폐쇄망에 Terraform 환경 의존을 새로 깔 수 없어서다. 현 시점에는 bash + curl + JSON 조합이 납품 기준 최저 의존이다. 사내 prod 일원화 시점에 재검토.
- Authentication Flow 변경을 콘솔에서 하면 안 되나? 하면 다음 부트스트랩 재실행 때 덮어써져 사고가 난다. 변경은 PR 단위로만.
References
- Keycloak Server Administration - Authentication Flows: https://www.keycloak.org/docs/latest/server_admin/#_authentication-flows
- Keycloak Server Administration - Identity Brokering: https://www.keycloak.org/docs/latest/server_admin/#_identity_broker