LabHub

블로그

SCIM 2.0 딥다이브 — 사용자 프로비저닝 자동화의 표준

한국어English日本語

들어가며 — SSO는 절반의 해답입니다

엔터프라이즈 IAM을 이야기할 때 대부분의 논의는 SSO(Single Sign-On)에 집중됩니다. SAML이냐 OIDC냐, IdP는 무엇을 쓰느냐 같은 주제입니다. 하지만 실무에서 SSO를 도입해 본 분이라면 곧바로 다음 질문에 부딪히게 됩니다.

"로그인은 되는데, 계정은 누가 만들어 주나요? 퇴사자는 누가 지우나요?"

이것이 바로 프로비저닝(provisioning) 의 문제이고, 2026년 현재 이 문제의 사실상 표준 해답이 SCIM 2.0(System for Cross-domain Identity Management) 입니다. Zero Trust가 "identity-first"를 외치는 시대에, 계정 수명주기 자동화는 더 이상 선택이 아니라 보안의 기본 전제입니다. AI 에이전트 같은 non-human identity까지 관리 대상에 들어오면서, 수작업 계정 관리는 운영적으로도 보안적으로도 유지가 불가능해졌습니다.

이 글에서는 SCIM 2.0의 스펙 구조부터 실제 HTTP 예제, 주요 IdP(Okta, Microsoft Entra ID, Keycloak)의 지원 현황, 그리고 SCIM 서버를 직접 구현할 때 빠지기 쉬운 함정까지 실무 관점에서 정리합니다.

왜 프로비저닝이 SSO만큼 중요한가 — JML 수명주기

계정 수명주기는 흔히 JML(Joiner / Mover / Leaver) 모델로 설명합니다.

단계이벤트필요한 작업실패 시 리스크
Joiner입사, 신규 채용계정 생성, 그룹/권한 부여온보딩 지연, 생산성 손실
Mover부서 이동, 직무 변경권한 재조정, 그룹 변경권한 누적(privilege creep)
Leaver퇴사, 계약 종료계정 비활성화, 세션/토큰 회수퇴사자 접근, 정보 유출

SSO는 "인증의 순간"만 다룹니다. 반면 JML은 "계정의 일생"을 다룹니다. SSO만 있고 프로비저닝이 없으면 다음과 같은 일이 벌어집니다.

특히 Leaver 처리는 보안 사고와 직결됩니다. SSO 차단은 "새 로그인"만 막을 뿐, 이미 발급된 세션·리프레시 토큰·앱 로컬 계정은 별도로 회수해야 합니다. 이 회수를 자동화하는 표준 통로가 SCIM의 deprovisioning입니다.

SCIM 2.0 스펙 구조 — RFC 세 개로 이루어진 표준

SCIM 2.0은 2015년 IETF에서 세 개의 RFC로 표준화되었습니다.

RFC제목역할
RFC 7642Definitions, Overview, Concepts, and Requirements용어 정의, 유스케이스, 요구사항
RFC 7643Core SchemaUser/Group 리소스 스키마, 확장 모델
RFC 7644ProtocolREST API, 필터, PATCH, 벌크, 페이지네이션

구조를 그림으로 보면 다음과 같습니다.

+----------------------------+         +----------------------------+
|  IdP / HR 시스템            |  SCIM   |  SP (SaaS 앱)              |
|  (SCIM 클라이언트)          | ------> |  (SCIM 서버)               |
|                            |  HTTPS  |                            |
|  - Okta                    |         |  - POST   /Users           |
|  - Entra ID                |         |  - GET    /Users?filter=   |
|  - Keycloak + 커스텀       |         |  - PATCH  /Users/id        |
|  - Workday 등 HR           |         |  - DELETE /Users/id        |
+----------------------------+         +----------------------------+
        RFC 7642: 개념          RFC 7643: 스키마    RFC 7644: 프로토콜

핵심 포인트는 역할 분담입니다. 일반적으로 IdP가 SCIM 클라이언트, SaaS 앱(SP)이 SCIM 서버입니다. "우리 서비스가 SCIM을 지원한다"는 말은 곧 "우리가 SCIM 서버를 구현했다"는 뜻입니다.

User / Group 스키마와 확장 모델

Core User 스키마

RFC 7643이 정의하는 User 리소스의 대표 속성은 다음과 같습니다.

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "2819c223-7f76-453a-919d-413861904646",
  "externalId": "emp-10042",
  "userName": "yjkim@example.com",
  "name": {
    "familyName": "Kim",
    "givenName": "Youngju"
  },
  "displayName": "Youngju Kim",
  "emails": [
    {
      "value": "yjkim@example.com",
      "type": "work",
      "primary": true
    }
  ],
  "active": true,
  "groups": [
    {
      "value": "e9e30dba-f08f-4109-8486-d5c6a331660a",
      "display": "platform-team"
    }
  ],
  "meta": {
    "resourceType": "User",
    "created": "2026-06-12T09:00:00Z",
    "lastModified": "2026-06-12T09:00:00Z",
    "version": "W/\"3694e05e9dff590\"",
    "location": "https://api.example.com/scim/v2/Users/2819c223-7f76-453a-919d-413861904646"
  }
}

주의 깊게 봐야 할 속성들입니다.

Group 스키마

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
  "id": "e9e30dba-f08f-4109-8486-d5c6a331660a",
  "displayName": "platform-team",
  "members": [
    {
      "value": "2819c223-7f76-453a-919d-413861904646",
      "display": "yjkim@example.com",
      "type": "User"
    }
  ]
}

Group은 단순해 보이지만 실무에서 가장 골치 아픈 리소스입니다. 수천 명이 속한 그룹의 members를 통째로 PUT으로 교체하는 IdP가 있는가 하면, PATCH로 한 명씩 add/remove하는 IdP도 있어서 서버 구현이 양쪽을 모두 견뎌야 합니다.

Enterprise User 확장과 커스텀 확장

HR 속성(부서, 사번, 매니저 등)은 Enterprise User 확장 스키마로 표현합니다.

{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User",
    "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
  ],
  "userName": "yjkim@example.com",
  "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
    "employeeNumber": "10042",
    "department": "Platform Engineering",
    "costCenter": "CC-3120",
    "manager": {
      "value": "26118915-6090-4610-87e4-49d8ca9f808d",
      "displayName": "Jane Doe"
    }
  }
}

자체 속성이 필요하면 자신의 URN 네임스페이스로 커스텀 확장 스키마를 정의할 수 있고, 서버는 /Schemas 엔드포인트로 이를 광고합니다. 다만 커스텀 확장은 IdP 쪽 속성 매핑 UI가 지원해야 실제로 쓸 수 있으므로, 가능하면 Enterprise 확장 범위 안에서 해결하는 것이 호환성에 유리합니다.

프로토콜 — REST 엔드포인트와 HTTP 예제

RFC 7644가 정의하는 엔드포인트 전체 목록입니다.

메서드경로용도
POST/Users사용자 생성
GET/Users목록 조회 + filter 검색
GET/Users/id단건 조회
PUT/Users/id전체 교체
PATCH/Users/id부분 수정
DELETE/Users/id삭제
GET/ServiceProviderConfig서버 기능 광고
GET/Schemas스키마 광고
GET/ResourceTypes리소스 타입 광고
POST/Bulk벌크 연산(선택)

사용자 생성 — POST

POST /scim/v2/Users HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/scim+json

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "externalId": "emp-10042",
  "userName": "yjkim@example.com",
  "name": { "givenName": "Youngju", "familyName": "Kim" },
  "emails": [{ "value": "yjkim@example.com", "type": "work", "primary": true }],
  "active": true
}

성공 시 서버는 201 Created와 함께 id, meta가 채워진 전체 리소스를 반환합니다. userName이 이미 존재하면 409 Conflict에 SCIM 에러 바디를 담아 응답해야 합니다.

HTTP/1.1 409 Conflict
Content-Type: application/scim+json

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "scimType": "uniqueness",
  "detail": "userName yjkim@example.com already exists",
  "status": "409"
}

필터 검색 — GET과 filter 쿼리

IdP는 푸시 전에 "이 사용자가 이미 있는가"를 filter로 확인합니다. 가장 빈번하게 호출되는 패턴입니다.

GET /scim/v2/Users?filter=userName%20eq%20%22yjkim%40example.com%22 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

디코딩하면 다음 필터입니다.

filter=userName eq "yjkim@example.com"

RFC 7644의 필터 문법은 eq, ne, co(contains), sw(starts with), gt, ge, lt, le, pr(present) 연산자와 and/or/not 결합, 괄호, 복합 속성 경로까지 지원합니다.

filter=emails[type eq "work" and value co "@example.com"]
filter=meta.lastModified gt "2026-06-01T00:00:00Z"
filter=userName sw "yj" and active eq true

다만 실제 IdP들이 쓰는 필터는 거의 "userName eq ..." 와 "externalId eq ..." 두 가지입니다. 서버 구현 시 전체 필터 문법을 완벽 지원하기보다, 최소 이 두 패턴을 정확히 지원하고 나머지는 점진 확장하는 전략이 현실적입니다.

부분 수정 — PATCH

PATCH는 SCIM에서 가장 복잡한 부분입니다. add / remove / replace 세 가지 op를 가진 연산 목록을 전달합니다.

PATCH /scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/scim+json

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    { "op": "replace", "path": "active", "value": false },
    { "op": "replace", "path": "name.familyName", "value": "Lee" },
    {
      "op": "add",
      "path": "emails",
      "value": [{ "value": "yj.lee@example.com", "type": "work" }]
    }
  ]
}

그룹 멤버십 조작은 value filter가 들어간 path를 사용합니다.

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    {
      "op": "remove",
      "path": "members[value eq \"2819c223-7f76-453a-919d-413861904646\"]"
    },
    {
      "op": "add",
      "path": "members",
      "value": [{ "value": "08b0fe34-0ec4-4857-b8e2-58dbccca2f48" }]
    }
  ]
}

전체 교체 — PUT

PUT은 리소스 전체를 교체합니다. 단순하지만 위험합니다. 클라이언트가 모르는 속성(서버가 자체 관리하는 속성)까지 날려버릴 수 있기 때문에, 서버는 readOnly/immutable 속성을 PUT 바디와 무관하게 보존해야 합니다.

푸시 모델 vs 풀 모델

구분푸시(IdP → SP)풀(SP → IdP/HR)
방향IdP가 변경 발생 시 SP의 SCIM API 호출SP가 주기적으로 원천을 폴링
지연거의 실시간(이벤트 기반)폴링 주기에 의존(수십 분~수 시간)
대표 사례Okta, Entra ID의 SaaS 프로비저닝Entra ID의 온프레미스 HR 연동 일부
SP 구현 부담SCIM 서버 구현 필요SCIM 클라이언트 구현 필요
장애 처리IdP의 재시도 큐에 의존다음 폴링에서 자연 복구

업계 주류는 푸시 모델입니다. Okta와 Entra ID 모두 자사 디렉터리의 변경 이벤트를 감지해 SP의 SCIM 엔드포인트로 푸시합니다. 흥미로운 점은 Entra ID의 동작 방식인데, 순수 이벤트 푸시가 아니라 약 40분 주기의 동기화 사이클 안에서 변경분을 모아 푸시합니다. "Entra 프로비저닝이 즉시 반영되지 않아요"라는 문의의 대부분이 이 주기 때문입니다.

푸시 모델에서 SP가 반드시 고려해야 할 것은 멱등성입니다. IdP는 네트워크 오류 시 같은 요청을 재시도하므로, 같은 externalId의 POST가 두 번 와도 중복 계정이 생기면 안 됩니다.

주요 IdP의 SCIM 지원 현황 (2026)

Okta

Microsoft Entra ID (구 Azure AD)

Keycloak

이벤트 리스너 SPI로 푸시형 연동을 구현하는 골격은 다음과 같습니다.

public class ScimPushEventListener implements EventListenerProvider {

    private final ScimClient scimClient;

    @Override
    public void onEvent(AdminEvent event, boolean includeRepresentation) {
        if (event.getResourceType() != ResourceType.USER) {
            return;
        }
        switch (event.getOperationType()) {
            case CREATE -> scimClient.createUser(toScimUser(event));
            case UPDATE -> scimClient.patchUser(toScimPatch(event));
            case DELETE -> scimClient.deactivateUser(extractUserId(event));
        }
    }

    @Override
    public void close() {
        // no-op
    }
}

실제로는 이벤트 유실에 대비한 아웃박스(outbox) 테이블과 재시도 큐를 함께 두는 것이 안전합니다.

SCIM 서버 구현 시 함정들

함정 1 — PATCH 연산의 복잡성

PATCH는 SCIM 구현 난이도의 8할을 차지합니다.

직접 파서를 만들기보다 검증된 라이브러리(Java라면 UnboundID SCIM 2 SDK 등)를 쓰는 것을 권합니다.

함정 2 — ETag와 동시성

HR 시스템과 IdP 관리자가 동시에 같은 사용자를 수정하면 마지막 쓰기가 이깁니다(lost update). SCIM은 이를 위해 ETag 기반 낙관적 잠금을 정의합니다.

PUT /scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1
If-Match: W/"3694e05e9dff590"
Content-Type: application/scim+json

버전이 다르면 서버는 412 Precondition Failed를 반환합니다. 다만 현실에서는 대부분의 IdP 클라이언트가 If-Match를 보내지 않으므로, 서버는 ETag를 "지원하되 강제하지 않는" 수준으로 구현하고 ServiceProviderConfig에 정직하게 광고하는 것이 맞습니다.

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
  "patch": { "supported": true },
  "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
  "filter": { "supported": true, "maxResults": 200 },
  "etag": { "supported": true },
  "changePassword": { "supported": false },
  "sort": { "supported": false },
  "authenticationSchemes": [
    {
      "type": "oauthbearertoken",
      "name": "OAuth Bearer Token",
      "description": "Authorization header with Bearer token"
    }
  ]
}

함정 3 — 페이지네이션

SCIM의 기본 페이지네이션은 1부터 시작하는 startIndex 기반입니다.

GET /scim/v2/Users?startIndex=101&count=100 HTTP/1.1
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 5042,
  "startIndex": 101,
  "itemsPerPage": 100,
  "Resources": []
}

함정은 다음과 같습니다.

함정 4 — 에러 응답 형식

SCIM 클라이언트는 에러 바디의 scimType을 보고 동작을 분기합니다. 401/403/404/409/412를 정확한 SCIM 에러 형식으로 반환하지 않으면, IdP가 "엔드포인트 비정상"으로 판단해 프로비저닝을 격리(quarantine)시키는 경우가 있습니다. Entra ID의 quarantine 상태가 대표적입니다.

Deprovisioning과 보안

Leaver 처리는 보안 관점에서 가장 중요한 흐름입니다. 권장 설계는 다음과 같습니다.

[HR: 퇴사 처리]
      |
      v
[IdP: 계정 비활성화] ----> SCIM PATCH active=false ----> [SP: soft-delete]
      |                                                      |
      v                                                      v
[IdP 세션 전부 폐기]                              [SP 세션/리프레시 토큰 폐기]
                                                  [API 키, PAT 무효화]
                                                  [보존 기간 후 hard-delete]

핵심 원칙입니다.

  1. DELETE보다 active=false 우선 — 감사 추적과 데이터 보존(법적 요구) 때문에 즉시 hard-delete는 피합니다. 다수 IdP도 기본 동작이 "비활성화"입니다.
  2. SCIM은 세션을 죽이지 않습니다 — active=false는 "새 인증"을 막을 뿐입니다. SP는 이 신호를 받으면 해당 사용자의 활성 세션과 리프레시 토큰을 능동적으로 폐기해야 합니다. 이 간극을 메우는 보완 표준이 OpenID의 Shared Signals Framework / CAEP이며, 2026년 현재 주요 벤더들이 속속 채택 중입니다.
  3. non-human identity도 잊지 말 것 — 퇴사자가 만든 서비스 계정, 봇 토큰, AI 에이전트 위임 권한은 SCIM 범위 밖인 경우가 많습니다. 소유권 이전 프로세스를 별도로 설계해야 합니다.

HR-driven Provisioning 아키텍처

성숙한 조직의 최종 형태는 HR 시스템을 단일 진실 원천(source of truth)으로 삼는 구조입니다.

+-----------+     +---------------------+     +----------------------+
| HR 시스템  | --> | IdP                 | --> | SaaS 앱 1 (SCIM 서버) |
| (Workday  |     | (Okta / Entra /     | --> | SaaS 앱 2 (SCIM 서버) |
|  등)      |     |  Keycloak)          | --> | 사내 앱   (SCIM 서버) |
+-----------+     +---------------------+     +----------------------+
  입사/이동/퇴사      그룹/권한 매핑 규칙           계정/권한 반영
  이벤트 발행        (부서 -> 그룹 -> 앱 롤)        세션/토큰 회수

설계 포인트입니다.

테스트 전략

SCIM 서버를 출시하기 전 점검할 테스트 매트릭스입니다.

카테고리테스트 항목
생성POST 정상, 중복 userName 409, 필수 속성 누락 400
조회userName/externalId filter, 대소문자 비구분, URL 인코딩
수정PATCH add/remove/replace, path 없는 PATCH, value filter path
교체PUT 시 readOnly 속성 보존, 미포함 속성 처리
비활성화active=false 시 세션/토큰 회수 연동
페이지네이션startIndex=1 기준, 경계값, 빈 결과
동시성ETag 불일치 412, 동시 PATCH
인증만료 토큰 401, 권한 부족 403
멱등성동일 POST 재시도, 동일 PATCH 재적용

도구 측면에서는 다음을 활용할 수 있습니다.

#!/usr/bin/env bash
set -euo pipefail

BASE="https://api.example.com/scim/v2"
TOKEN="$SCIM_TEST_TOKEN"

# 1. 생성
USER_ID=$(curl -sf -X POST "$BASE/Users" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d @fixtures/user-create.json | jq -r '.id')

# 2. filter 조회
curl -sf "$BASE/Users?filter=userName%20eq%20%22scim-test%40example.com%22" \
  -H "Authorization: Bearer $TOKEN" | jq -e '.totalResults == 1'

# 3. 비활성화
curl -sf -X PATCH "$BASE/Users/$USER_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
       "Operations":[{"op":"replace","path":"active","value":false}]}' \
  | jq -e '.active == false'

# 4. 정리
curl -sf -X DELETE "$BASE/Users/$USER_ID" -H "Authorization: Bearer $TOKEN"
echo "SCIM smoke test passed"

스테이징 환경에 실제 IdP 테넌트(Okta developer org, Entra 테스트 테넌트)를 연결해 두고, 릴리스마다 실 IdP 발 트래픽으로 회귀 테스트를 돌리는 것이 가장 확실합니다. IdP별 구현 편차는 문서만으로는 절대 다 잡히지 않습니다.

운영 베스트 프랙티스

마치며

SCIM 2.0은 화려한 기술이 아닙니다. 그러나 SSO가 "들어오는 문"이라면 SCIM은 "명부 관리"이고, 보안 사고의 다수는 문이 아니라 명부에서 터집니다. 2026년의 엔터프라이즈 B2B 시장에서 SCIM 지원은 SSO와 함께 엔터프라이즈 딜의 체크리스트 항목이 된 지 오래입니다.

정리하면 다음과 같습니다.

  1. SSO와 프로비저닝은 별개의 문제이며, JML 수명주기 전체를 자동화해야 비로소 identity-first 보안이 성립합니다.
  2. SCIM 2.0은 RFC 7642(개념), 7643(스키마), 7644(프로토콜) 세 축으로 이해하면 됩니다.
  3. 서버 구현의 난소는 PATCH 의미론, 페이지네이션, 에러 형식, 멱등성입니다. 검증된 SDK와 IdP 실연동 테스트로 잡으십시오.
  4. deprovisioning은 active=false로 끝나지 않습니다. 세션·토큰 회수까지 설계해야 하며, 장기적으로는 Shared Signals/CAEP 같은 이벤트 표준과의 결합을 바라봐야 합니다.

다음 글에서는 멀티테넌트 SaaS에서 고객사별 SSO를 어떻게 설계하는지, 그리고 그 위에서 SCIM 온보딩을 어떻게 셀프서비스화하는지 다루겠습니다.

참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다