| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- BEST of the BEST
- Eclipse EDC
- EU 데이터법
- C++11
- 데이터 스페이스 커넥터
- Eclipse Dataspace Components
- Catena-X
- 데이터 스페이스
- Network
- 오버워치
- EDC
- IDS-RAM 2026-1
- ftz
- KASAN
- IDSA Rulebook 2026-1
- 데이터 주권
- libpcap
- 데이터 스페이스 프로토콜
- #Best of the Best #OS #MINT64 #Sqix
- Sqix
- EDC 아키텍처
- IDSA
- Overwatch League SaberMetrics
- DSP 2025-1
- 오버워치 세이버메트릭스
- dsp
- Gaia-X
- libtins
- 데이터스페이스 프로토콜
- Dataspace Protocol
- Today
- Total
Sqix
데이터 스페이스 - 3. Eclipse 데이터 스페이스 컴포넌트(Eclipse EDC) 본문
지난 글에서 데이터 스페이스 프로토콜(DSP) 2025-1이 무엇을 어떻게 표준화하는지를 다뤘다. 다만 DSP 표준은 어떤 메시지가 오가고 그것이 어떤 상태 변화를 일으키며 어떻게 HTTP에 실리는지까지를 규정할 뿐, 그 메시지를 실제로 신뢰성 있게 주고받고 상태를 영속적으로 관리하는 운영 코드는 구현체의 몫으로 남겨 둔다. 이 운영 코드를 가장 충실하게 보여주는 참조 구현이 바로 Eclipse EDC이기에, 이번 글에서는 EDC 코드를 분석하여 DSP 구현을 위해 어떠한 설계를 해야 하며, 어떤 원리로 구현해야 하는지를 알아본다.
EDC는 Java로 작성되고 Gradle로 빌드되며 Apache License 2.0으로 공개된 오픈소스로, 코드는 eclipse-edc/Connector github repo[1]에 있다.
EDC(Eclipse Dataspace Components)란?
EDC는 프레임워크로서, Gaia-X Trust Framework와 IDSA의 DSP를 토대로 삼아 그 위에서 DSP와 DCP(Decentralized Claims Protocol)를 구현한다. 그 구성요소를 하나씩 들여다보면 정책과 계약, 합의를 다루는 Control Plane과 실제 데이터를 전송하는 Data Plane을 양축으로 두고, 여기에 신원을 다루는 Identity Hub와 여러 제공자의 카탈로그를 모으는 Federated Catalog, 자격증명을 발급하는 Issuer Service가 더해진다.
ServiceExtension과 SPI
EDC에서 기능 하나를 런타임에 등록하려면 세 가지가 필요한데, ServiceExtension인터페이스를 구현하는 클래스 하나와 Java ServiceLoader가 읽는 provider-configuration 파일 하나, 그리고 Gradle 빌드 파일에 그모듈을 더하는 코드(runtimeOnly(project(":module:path")))이다[3].
Extension은 비즈니스 로직을 담지 않는다는 규칙이 있어, 설정을 읽고 서비스를 만들어 등록하고 자원을 할당하고 해제하는 일만 맡는다. ServiceExtension 인터페이스는 org.eclipse.edc.spi.system 패키지의 인터페이스로, Lifecycle Hook을 담당하는 initialize(ServiceExtensionContext), prepare(), start(), shutdown()메서드와 Extension Name을 돌려주는 name() 메서드를 가지는데, 대부분 기본 구현이 비어 있으므로 각 확장은 필요한 훅만 재정의한다.
name()은 상위 인터페이스 SystemExtension의 기본 메서드로 기본값이 클래스 전체 이름이고 종료 쪽에는 shutdown() 다음에 cleanup() 훅을 가진다. 이니셜라이즈는 다음 순서를 거친다.
(1) @Inject 필드 주입
(2) initialize(context)메서드 호출
(3) Extension의 @Provider 메서드들을 실행해 서비스를 등록
모든 확장이 여기까지 끝나면 컨텍스트를 freeze(읽기 전용으로 동결)한 뒤 다시 이전 extension의 prepare()메서드를, 마지막으로 start() 메서드를 부른다. 즉 '의존성 주입 → 초기화 → 서비스 제공'이 한 extension 단위로 끝난 뒤에야 다음 단계로 넘어간다.
Extension끼리는 서로를 직접 호출하지 않고 Annotation으로 의존을 선언하는데, 여기 쓰이는 네 개의 Annotation의 역할은 다음과 같다.
- @Inject는 필드에 붙여 필요한 서비스를 주입받는다
- @Provides는 클래스에 붙여 extension이 initialize()에서 직접 등록하는 서비스를 메타모델에 선언한다
- @Provider는 메서드 레벨 팩토리로서 반환 타입이 곧 등록되는 서비스가 되고 인자는 없거나 ServiceExtensionContext 하나만 받을 수 있어 @Provider public PolicyEngine policyEngine() { return new PolicyEngineImpl(...); }처럼 쓴다
- @Requires는 type 레벨에서 필요한 feature를 선언하는데, 주로 필드 @Inject 없이 자동 등록되는 클래스의 의존을 의존성
그래프에 알리는 데 쓴다.
이 Annotation들은 문서 자동화(Autodoc)가 의존성 그래프를 그리는 데 쓰이는 동시에 런타임이 실제로 서비스 의존을 해소하는 데에도 쓰이는데, 이들의 정의는 Runtime-Metamodel(runtime-metamodel 아티팩트)에 들어 있다.(메타모델과 autodoc을 코어에서 떼어 별도 repo로 옮긴 것은 순환 의존을 끊으려는 2022-09-23 결정 때문이었고, 그래서 EDC는 이를 컴파일타임 의존으로만 참조한다.)
같은 Annotation 을 두 소비자가 나눠 읽는데, 빌드 타임에는 Autodoc Gradle 플러그인의 Annotation 프로세서가 읽어 뒤에 나올 build/edc.json 매니페스트를 만들고, 런타임에는 부트 모듈의 InjectionPointScanner가 @Retention(RUNTIME)인 같은 Annotation을 리플렉션으로 읽어 의존성 그래프를 세우고 실제 주입을 수행하므로, 결국 한 Annotation이 '문서'와 '주입' 양쪽에 모두 효력을 갖는다. Transformer를 등록하는 실제 확장은 다음과 같다.
@Inject
private TypeTransformerRegistry typeTransformerRegistry;
@Override
public void initialize(ServiceExtensionContext context) {
typeTransformerRegistry.register(new JsonObjectToYourEntityTransformer());
}
이때 Extension에게 Config, Logger, Common Service 등에 접근할 수 있도록 런타임의 공용 도구를 건네는 것이 ServiceExtensionContext다. 설정은 context.getConfig()로 읽는데, 값의 출처가 세 곳이고 그 순서가 정해져 있어 먼저 파일 기반 ConfigurationExtension을, 그다음 환경 변수(edc.someconfig.someval은 EDC_SOMECONFIG_SOMEVAL로 매핑된다)를, 마지막으로 자바 시스템 프로퍼티(-Dedc.someconfig.someval=...)를 차례로 보며, 필수 설정이 없으면 EdcException을 던진다.
혹은 선언형 설정을 통한 의존성 주입도 가능하다.(이 방법이 2024년 이후 권장되고 있음) 필드에 @Setting(key = "edc.iam.publickey.alias", defaultValue = "foobar")처럼 붙여 두면 서비스 주입과 같은 의존성 주입 단계에서 런타임이 설정값을 찾아 필드에 꽂아 주는데(없고 기본값도 없으면 주입 오류), 이때는 기본값을 클래스 상수가 아니라 @Setting(defaultValue=...) 문자열로 주고 required=false로 선택적 설정을, min/max로 범위를 표현한다. 여러 설정을 한 객체로 묶고 싶으면 @Settings를 단 POJO를 만들어 확장 필드에 @Configuration으로 주입하면 된다.
이 메타데이터는 빌드 시 ./gradlew autodoc으로 모아져 build/edc.json에 구조 정보로 남는데, 예컨대 한 확장의 항목은 무엇을 제공하고(provides) 무엇을 참조하며(references) 어떤 설정(configuration)을 쓰는지를 클래스명과 함께 담는다.
{
"provides": [ { "service": "org.eclipse.edc.web.spi.WebService" } ],
"references": [ { "service": "org.eclipse.edc.spi.types.TypeManager", "required": true } ],
"configuration": [ { "key": "edc.web.rest.cors.methods", "required": false } ],
"className": "org.eclipse.edc.web.jersey.JerseyExtension"
}
비동기 State Machine: StateMachineManager
EDC에서 가장 자주 마주치는 골격이 State Machine인데, 이는 EDC가 처음부터 비동기를 전제로 설계되었기 때문이다. 계약 협상 하나만 해도 여러 상태를 거치는 장기 실행 과정이고, 상태 전이는 상대 커넥터에 메시지를 보내야 비로소 일어나며 그 응답이 몇 시간이나 며칠 뒤에 올 수도 있다[5]. 그래서 EDC 인스턴스는 메모리에 상태를 들고 있지 않는 일회적(ephemeral) 존재로 다뤄지며, 상태는 모두 영속성에 두어 런타임을 아무 때나 끄고 켜도 다른 복제본이 멈춘 자리에서 이어받게 한다.
상태를 담는 모든 엔티티의 기반이 추상 클래스 org.eclipse.edc.spi.entity.StatefulEntity다. 현재 상태(state)와 같은 상태에 머문 횟수(stateCount), 마지막 전이 시각(stateTimestamp), 오류 상세(errorDetail), 갱신 시각(updatedAt)을 들고 있고, 상태 전이는 transitionTo 한 곳에서 처리한다. ContractNegotiation과 TransferProcess가 이 클래스를 상속한다.
// org.eclipse.edc.spi.entity.StatefulEntity
public abstract class StatefulEntity<T extends StatefulEntity<T>> extends Entity implements TraceCarrier {
protected int state;
protected int stateCount; // 같은 상태에 머문 횟수
protected long stateTimestamp; // 마지막 전이 시각
protected String errorDetail;
protected boolean pending = false;
protected long updatedAt;
protected void transitionTo(int targetState) {
stateCount = state == targetState ? stateCount + 1 : 1;
state = targetState;
updateStateTimestamp();
setModified();
}
// getState(), getStateCount(), copy(), stateAsString() ...
}
상태를 정수 state로 보관하고 transitionTo에서 stateCount를 올려 두는 단순한 구조이지만, 뒤에 이어질 배치 처리와 잠금, 재시도가 모두 이 두 값에 기대어 돌아간다.
이 일을 맡는 것이 StateMachineManager다. 매 주기(tick)마다 여러 개의 Processor를 순서대로 부르고, 각 Processor는 특정 상태에 있는 StatefulEntity들을 골라 처리한다. 계약 협상의 제공자 쪽 관리자 ProviderContractNegotiationManagerImpl은 시작할 때 상태마다 처리 함수를 등록한다.
// core/control-plane/control-plane-contract-manager … ProviderContractNegotiationManagerImpl
@Override
protected StateMachineManager.Builder configureStateMachineManager(StateMachineManager.Builder builder) {
return builder
.processor(processNegotiationsInState(OFFERING, negotiationProcessors::processOffering))
.processor(processNegotiationsInState(REQUESTED, negotiationProcessors::processRequested))
.processor(processNegotiationsInState(ACCEPTED, negotiationProcessors::processAccepted))
.processor(processNegotiationsInState(AGREEING, negotiationProcessors::processAgreeing))
.processor(processNegotiationsInState(VERIFIED, negotiationProcessors::processVerified))
.processor(processNegotiationsInState(FINALIZING, negotiationProcessors::processFinalizing))
.processor(processNegotiationsInState(TERMINATING, negotiationProcessors::processTerminating));
}
DSP의 협상 상태는 REQUESTED, OFFERED, ACCEPTED, AGREED, VERIFIED, FINALIZED, TERMINATED였으나, EDC 코드에는 여기에 더해 OFFERING, AGREEING, FINALIZING, TERMINATING 같은 진행형 상태가 끼어 있는데, 이는 표준에 없는 EDC 내부의 상태로 "메시지를 보내는 중"에 런타임이 죽는 경우까지 안전하게 다루려고 둔 장치다. 그래서 밖으로 내보내는 표준 상태와, 안에서 신뢰성을 확보하려고 거치는 처리 상태가 서로 갈린다.
이 안팎 구분은 API 응답에서 구체적인 규칙으로 드러나는데, 협상과 전송의 조회(GET) 엔드포인트는 내부 진행형 상태(ING)를 대응되는 표준 상태(ED)로 바꿔서 돌려주므로 엔티티가 TERMINATING이어도 API는 TERMINATED로 응답한다. 들어온 메시지를 처리하는 프로토콜 서비스 쪽도 비동기 계산이 필요하면 엔티티를 *ING로 둔 채 매니저에게 일을 넘기고, 그 상태 변경과 한 트랜잭션으로 묶어 ack를 즉시 돌려주는데, 상대는 ack를 받는 순간 그 작업이 끝난 것으로 간주하지만 실제 후속 처리는 이쪽 State Machine이 비동기로 이어 간다.
ContractNegotiationStates는 표준 상태 사이사이에 진행형 상태(REQUESTING, OFFERING, ACCEPTING, AGREEING, VERIFYING, FINALIZING, TERMINATING)를 끼워 두고, 정렬과 비교에 쓰는 정수 코드를 각 상태에 매긴다.
// org.eclipse.edc.connector.controlplane.contract.spi.types.negotiation.ContractNegotiationStates
public enum ContractNegotiationStates {
INITIAL(50),
REQUESTING(100), REQUESTED(200),
OFFERING(300), OFFERED(400),
ACCEPTING(700), ACCEPTED(800),
AGREEING(825), AGREED(850),
VERIFYING(1050), VERIFIED(1100),
FINALIZING(1150), FINALIZED(1200),
TERMINATING(1300), TERMINATED(1400);
private static final List<Integer> FINAL_STATES = List.of(FINALIZED.code(), TERMINATED.code());
public static boolean isFinal(int state) { return FINAL_STATES.contains(state); }
// code(), from(int) ...
}
전송 프로세스도 같은 방식을 따르지만 진행형 상태가 더 촘촘하고 그 위에 한 겹이 더 얹혀 있다. TransferProcessStates에는 STARTING, SUSPENDING, RESUMING, COMPLETING, TERMINATING 같은 진행형 상태 외에
PREPARATION_REQUESTED·STARTUP_REQUESTED·SUSPENDING_REQUESTED·RESUMING_REQUESTED·COMPLETING_REQUESTED·TERMINATING_REQUESTED 같은 *_REQUESTED 대기 상태가 함께 있는데, 이 대기 상태들은 provisioning/deprovisioning을 Control Plane에서 Data Plane으로 옮기면서 생겨났다. Control Plane이 Data Plane에 prepare·start·suspend·terminate를 신호로 보냈을 때 그쪽에서 비동기 프로비저닝이 걸리면, 전송 프로세스는 곧장 다음 상태로 가지 않고 대응되는 *_REQUESTED 중간 상태에 머물러 Data Plane의 콜백을 기다리며, 콜백이 오면 정상 흐름을 잇고 오지 않으면 State Machine이 오래 멈춘(stale) *_REQUESTED 엔티티를 감시해 다시 시도한다.
결국 Control Plane이 Data Plane 프로토콜의 세부를 몰라도 되도록 갈라 둔 것이기에, 이 *_REQUESTED 상태들은 테스트용이 아니라 TransferProcess.transitionStartupRequested() 같은 실제 전이 메서드로 구현되어 있다. 초기 EDC가 Control Plane에서 직접 다루던 provisioning 계열 상태(PROVISIONING, PROVISIONED, DEPROVISIONING 등)는 그래서 0.16.0부터 @Deprecated로 빠지는 중이고, 종착(FINAL) 상태는 COMPLETED와 TERMINATED, 그리고 아직 남아 있는 DEPROVISIONED다.
// org.eclipse.edc.connector.controlplane.transfer.spi.types.TransferProcessStates
public enum TransferProcessStates {
INITIAL(100),
PREPARATION_REQUESTED(250),
REQUESTING(400), REQUESTED(500),
STARTING(550), STARTUP_REQUESTED(570), STARTED(600),
SUSPENDING(650), SUSPENDING_REQUESTED(675), SUSPENDED(700),
RESUMING(720), RESUMING_REQUESTED(722), RESUMED(725),
COMPLETING(750), COMPLETING_REQUESTED(775), COMPLETED(800),
TERMINATING(825), TERMINATING_REQUESTED(840), TERMINATED(850);
// @Deprecated(since = "0.16.0"): PROVISIONING(200), PROVISIONED, DEPROVISIONING(900), DEPROVISIONED
// FINAL_STATES = COMPLETED, TERMINATED, DEPROVISIONED
}
지난 글에서 본 전송의 표준 상태는 REQUESTED·STARTED·SUSPENDED·COMPLETED·TERMINATED 다섯뿐이었고, EDC 코드의 진행형 상태(STARTING·SUSPENDING·COMPLETING·TERMINATING)는 협상의 OFFERING·AGREEING과 같은 이유로 표준 상태 사이에 끼운 처리용 단계다. 그런데 전송의 RESUMING·RESUMED는 결이 다른데, DSP에 대응하는 안정 상태가 아예 없는 EDC 고유 상태이기 때문이다.
DSP 2025-1의 전송 단계 State Machine은 일시 중단된 전송을 다시 켜는 일조차 별도 RESUMED 상태로 두지 않고, 소비자가 다시 보내는 Transfer Start Message로 STARTED로 되돌리는 Transfer로 규정한다. EDC는 재개 과정을 내부적으로 RESUMING·RESUMED로 쪼개 추적하지만 와이어로 나가는 순간에는 표준의 start 전이 하나로 수렴하기에, 직접 구현할 때 RESUMED를 외부에 노출하는 표준 상태로 착각하지 않으려면 EDC enum의 상태와 DSP가 정한 상태를 구분해 두어야 한다.
두 enum 모두 isFinal(int)로 종착 상태를 가린다. 협상은 FINALIZED와 TERMINATED, 전송은 COMPLETED와 TERMINATED, 그리고 아직 남아 있는 DEPROVISIONED에 도달해서 멈춘다.
다만 enum이 담는 것은 상태 목록과 종착 여부까지이고, 상태들 사이에 "어디서 어디로 갈 수 있는가"는 enum이 아니라 엔티티 클래스 ContractNegotiation에 코드로 박혀 있다. 이 클래스는 StatefulEntity<ContractNegotiation>를 상속하고 상태마다 transitionXxx() 메서드를 두는데, 그 메서드들이 결국 하나의 private transition()을 거치면서 허용된 직전 상태가 아니면 IllegalStateException을 던지고 통과하면 앞서 본 StatefulEntity.transitionTo를 부른다.
// org.eclipse.edc.connector.controlplane.contract.spi.types.negotiation.ContractNegotiation
public class ContractNegotiation extends StatefulEntity<ContractNegotiation> {
private Type type = Type.CONSUMER; // CONSUMER 또는 PROVIDER
public void transitionOffering() { // 제공자 전용 상태
if (Type.CONSUMER == type) {
throw new IllegalStateException("Consumer processes have no OFFERING state");
}
transition(OFFERING, OFFERING, OFFERED, REQUESTED); // 허용된 직전 상태에서만
}
// 모든 transitionXxx() 가 거치는 가드
private void transition(ContractNegotiationStates end, Predicate<ContractNegotiationStates> canTransitTo) {
var targetState = end.code();
if (!canTransitTo.test(ContractNegotiationStates.from(state))) {
throw new IllegalStateException(format("Cannot transition from state %s to %s",
ContractNegotiationStates.from(state), ContractNegotiationStates.from(targetState)));
}
if (state != targetState) {
protocolMessages.setLastSent(null); // 상태가 바뀌면 마지막 전송 메시지 초기화
}
transitionTo(targetState); // 앞서 본 StatefulEntity 의 메서드
}
}
이 가드레일에서 두 가지 설계 의도가 드러난다. 하나는 협상의 방향이 코드로 강제된다는 점인데, type이 CONSUMER인지 PROVIDER인지에 따라 제공자에게 없는 상태(REQUESTING·ACCEPTING·VERIFYING)나 소비자에게 없는 상태(OFFERING·AGREEING·FINALIZING)로 가려 하면 곧장 Exception을 발생시킨다.
다른 하나는 멱등성으로, ContractNegotiation은 protocolMessages로 이미 받은 메시지 id를 기억해 shouldIgnoreIncomingMessage로 중복을 거르고 마지막으로 보낸 메시지까지 추적한다. 네트워크 재시도와 중복 수신을 비정상이 아니라 정상 조건으로 본다는 원칙이 이렇게 엔티티 안에 새겨져 있다.
매니저 자체는 얇아서, ProviderContractNegotiationManagerImpl은 AbstractContractNegotiationManager를 상속하고 type()으로 PROVIDER를 돌려줄 뿐 상태별 처리는 negotiationProcessors에 위임한다. 즉, 무엇을 할지는 매니저와 프로세서가, 어떤 전이를 할 수 있는지에 대한 여부는 엔티티가 나눠 가진다.
실제 루프는 단일 스레드에서 이렇게 돈다. 매 주기마다 등록된 processor들을 차례로 부르고, 처리한 엔티티가 하나도 없으면 wait 전략만큼 쉬었다가 다음 주기를 잡는다.
// org.eclipse.edc.statemachine.StateMachineManager (발췌)
private void performLogic() {
var processed = processors.stream()
.mapToLong(processor -> processor.process())
.sum();
waitStrategy.success();
var delay = processed == 0 ? waitStrategy.waitForMillis() : 0;
scheduleNextIterationIn(delay);
}
각 Processor가 한 엔티티를 어떻게 다루는지는 ProcessorImpl에 담겨 있는데, 해당 상태의 엔티티들을 공급자로 불러와 하나씩 처리하고 처리한 개수를 돌려준다. 재시도 역시 별도 장치를 두지 않고 StatefulEntity의 stateCount로 판단하므로, 같은 상태로 한 번 넘게 들어왔다면 재시도로 보고 백오프 시간이 아직 차지 않았으면 이번 주기에는 건너뛴다.
// org.eclipse.edc.statemachine.ProcessorImpl<E extends StatefulEntity<E>> (발췌)
public Long process() {
return entities.get().stream() // 해당 상태의 엔티티를 불러와
.map(this::process) // 하나씩 처리하고
.filter(isEqual(true))
.count(); // 처리한 개수를 반환
}
private Boolean process(E entity) {
if (isRetry(entity) && delayMillis(entity) > 0) { // 백오프 시간이 안 됐으면
onNotProcessed.accept(entity);
return false; // 이번엔 건너뛴다
}
var actualProcess = guard.predicate().test(entity) ? guard.process() : process;
actualProcess.apply(entity);
return true;
}
// isRetry(entity) == entity.getStateCount() - 1 > 0
재시도 여부 자체는 별도 장치 없이 stateCount로 가리지만, 재시도가 일어났을 때 무엇을 할지까지 stateCount가 정하지는 않는다. stateCount와 백오프가 이번 주기에 이 엔티티를 건드릴지를 결정한다면, 한 상태 처리 안에서 실제로 일어나는 작업, 예컨대 Data Plane을 start한 뒤 상대에게 DSP 메시지를 보내는 두 단계를 묶어 그 결과에 따라 다음 상태로 보내는 일은 RetryProcessor가 맡는다.
매니저는 .doProcess(...)로 멱등한 작업들을 체인으로 이은 뒤 그 결과에 따라 세 갈래로 처리하는데, 성공하면 onSuccess로 다음 상태로 전진하고 이번 시도가 실패하면 onFailure로 처리해 다음 주기에 다시 시도하며 retryLimit를 넘었거나 복구할 수 없는 오류면 onFinalFailure로 종료 상태로 보낸다. 몇 번째 시도인가는 stateCount가, 그 시도의 결과로 어디로 갈 것인가는 RetryProcessor가 나눠 맡는다.
guard와 pending은 한 쌍을 이룬다. State Machine은 본래 전부 자동으로 도는 엔진이지만 '카운터 오퍼'처럼 사용자가 끼어들어야 하는 상태가 있어 각 매니저는 PendingGuard를 등록하고 guard의 조건에 걸린 엔티티는 자동 처리 대신 pending=true로 표시한다.
State Machine이 엔티티를 꺼내올 때 쓰는 질의에는 isNotPending()(pending = false) 조건이 항상 붙어 있으므로, pending으로 표시된 엔티티는 다음 주기부터 아예 적재되지 않고 DB에 가만히 멈춰 Management API 명령을 기다린다. 앞서 본 StatefulEntity의 pending 필드와 ProcessorImpl의 guard.predicate().test(entity) 분기가 바로 이 장치이며, 자동 State Machine과 외부 개입이 서로 충돌하지 않도록 갈라 두는 경계다.
State Machine이 매 주기 저장소에서 엔티티를 꺼낼 때는 두 가지 안전장치가 함께 작동하는데, 하나는 한 번에 가져오는 개수를 제한하는 배치 크기이고 다른 하나는 상태가 가장 오래 갱신되지 않은 것부터 처리하는 정렬이며, 처리할 것이 없으면 정해진 시간만큼 쉬었다 깬다.
운영에서 Control Plane은 보통 여러 대로 복제되는데, 그러면 두 복제본이 같은 엔티티를 동시에 집어 같은 DSP 메시지를 두 번 보내는 사고가 날 수 있다. EDC는 이를 데이터베이스 수준의 비관적 잠금으로 막으며 그 잠금을 Lease라 부르는데, 엔티티가 잠겼는지, 잠금이 만료됐는지, 어느 복제본이 잠갔는지를 DB에 기록한다. 잠금 보유자는 edc.runtime.id 값으로 식별하고, 클러스터 환경에서는 이 값을 일부러 설정하지 않고 무작위 기본값을 쓰도록 권장한다. 각 복제본은 처리할 엔티티에 잠금을 잡은 뒤, 처리하면 상태를 전진시키고 잠금을 풀며 처리하지 못하면 그냥 잠금을 푼다.
이 배치 로딩과 잠금은 State Machine 저장소 인터페이스에 그대로 드러나는데, nextNotLeased가 오래된 것부터 최대 max개를 빌려오면서 잠그고, findByIdAndLease가 단건을 잠그며(이미 잠겨 있으면 실패를 돌려준다), breakLease가 잠금을 푼다. 상태로 거르는 hasState도 여기 있다.
// org.eclipse.edc.spi.persistence.StateEntityStore<T> (발췌)
public interface StateEntityStore<T> {
static Criterion hasState(int stateCode) {
return new Criterion("state", "=", stateCode);
}
List<T> nextNotLeased(int max, Criterion... criteria); // 오래된 것부터 max개를 빌려옴(lease)
StoreResult<T> findByIdAndLease(String id); // 단건 잠금(이미 잠겼으면 실패)
StoreResult<Void> save(T entity);
StoreResult<Void> breakLease(T entity); // 잠금 해제
}
인터페이스 곳곳에 보이는 StoreResult 반환에는 또 하나의 설계 의도가 깔려 있다. EDC는 엔티티 스토어를 정리하면서 모든 변경 연산이 성공인지 실패인지를 분명한 결과 객체로 돌려주게 하고 "있으면 갱신 없으면 생성" 같은 애매한 동작을 없앴다. 그래서 만들기는 이미 있으면 실패, 갱신은 없으면 실패로 갈렸다[6]. 저장 결과가 빈 값인지 객체인지 모호하면 그 모호함이 State Machine 코드의 오류로 번질 수 있기 때문이다.
모든 변경 연산이 결과 객체(StoreResult)를 돌려주게 한 것은 State Machine 스토어에도 그대로 적용되지만, 'create는 있으면 실패, update는 없으면 실패'라는 분리는 Asset 같은 일반 엔티티 스토어에 해당하는 이야기다(2023-03-02). 정작 State Machine이 쓰는 StateEntityStore.save()는 의도적으로 UPSERT를 유지하고, 같은 메서드 Javadoc도 '이전에 없던 객체면 생성된다'고 못박는데, 매 주기 엔티티를 꺼내 갱신한 뒤 다시 저장하는 State Machine의 특성상 존재 여부를 따로 분기하지 않고 한 번의 save()로 끝내는 편이 자연스럽기 때문이다.
Transformer와 JSON-LD
DSP 메시지는 JSON-LD로 오가는데, 확장 가능한 속성과 네임스페이스 때문에 평범한 Jackson 직렬화로는 이를 감당하기 어려워 EDC는 별도의 직렬화 계층을 두고 그것을 트랜스포머(transformer)라 부른다. 모든 트랜스포머의 공통 부모는 AbstractJsonLdTransformer<I, O>이며, 이름 규약은 JsonObjectTo<Entity>Transformer와 JsonObjectFrom<Entity>Transformer처럼 변환의 방향을 그대로 드러내어, 예컨대 JsonObjectToAssetTransformer가 그렇다. 이때 엔티티 클래스는 확장된(expanded) 속성 이름을 상수로 들고 있어야 하고, 역직렬화 트랜스포머는 switch로 속성을 분기해 가며 빌더를 채워 나간다.
복잡한 객체는 트랜스포머가 혼자 다 풀지 않는데, 1차 속성만 처리하고 그 안의 중첩 객체는 TransformerContext를 통해 다시 위임하며, 전역 트랜스포머 목록은 TypeTransformerRegistry가 들고 있다. 게다가 같은 타입을 맥락에 따라 다르게 직렬화해야 하는 경우, 예를 들어 DataAddress를 DSP API와 Data Plane 시그널링 API에서 서로 다르게 내보내야 하는 경우를 위해 레지스트리 자체를 맥락별로 나눠 둔다.
var dspRegistry = typeTransformerRegistry.forContext("dsp-api");
dspRegistry.register(new JsonObjectToDataAddressTransformer());
var signalingApiRegistry = typeTransformerRegistry.forContext("signaling-api");
signalingApiRegistry.register(new JsonObjectFromDataAddressDspaceTransformer());
트랜스포머는 변환에 실패해도 예외를 던지지 않고 TransformerContext에 문제를 보고한다. 지난 글에서 "JSON Schema가 1차 검증을, 내부 모델이 2차를 맡는다"고 한 그 경계를, EDC는 트랜스포머 계층과 별도의 검증기(validator) 계층으로 나눠 둔다.
모든 검증기는 메시지의 JSON-LD @type을 키로 삼아 JsonObjectValidatorRegistry에 등록되고, 들어온 JsonObject는 그 타입에 맞는 검증기로 넘겨진다. 트랜스포머가 형식을 객체로 바꾸는 일을 맡는다면 검증기는 그 입력이 규칙에 맞는지를 가리므로, 예컨대 DSP 협상 모듈은 ContractRequestMessageValidator, ContractAgreementMessageValidator처럼 메시지 타입마다 검증기를 따로 등록한다.
// DspNegotiationApi2025Extension (발췌)
@Inject
private JsonObjectValidatorRegistry validatorRegistry;
validatorRegistry.register(
DSP_NAMESPACE_V_2025_1.toIri(DSPACE_TYPE_CONTRACT_REQUEST_MESSAGE_TERM),
ContractRequestMessageValidator.instance(DSP_NAMESPACE_V_2025_1, false));
한 타입에 검증기를 여러 개 등록하면 순서대로 실행되어 결과가 합쳐진다(compose). 그래서 코어 검증 위에 데이터 스페이스별 규칙을 덧붙이기 쉽다. 검증 결과도 예외가 아니라 ValidationResult로 돌아온다는 점은 앞서 본 트랜스포머·저장소의 "실패를 결과 객체로" 원칙과 같다. (참고: 여기서 말하는 검증기는 DSP 와이어 메시지의 JSON-LD @type별 검증이고, 별도로 EDC Management API는 v4부터 JSON-Schema 기반 Validator<JsonObject>를 같은 레지스트리에 v4:Type 키로 등록한다
신원과 토큰
DSP는 토큰의 의미를 정해 두지 않으므로 그 토큰을 어떻게 만들고 검증할지는 구현체가 채워야 하는데, EDC는 생성과 검증을 각각 별도 서비스로 갈라 둔다. 생성은 TokenGenerationService가 맡아 토큰의 클레임과 헤더를 더하는 TokenDecorator 함수들을 받아 조립하고, 검증은 TokenValidationService가 들어온 JWT의 kid 헤더로 검증에 쓸 공개키를 식별한 뒤 PublicKeyResolver가 그 공개키를 JWKS나 DID 문서의 검증 메서드에서 받아 온다.
EDC의 설계가 가장 분명히 드러나는 지점은 검증 규칙을 맥락별 레지스트리로 분리했다는 데 있는데, 서명을 확인한 뒤 클레임에 적용하는 규칙들이 TokenValidationRulesRegistry에 맥락 단위로 등록되기 때문이다. 현재 맥락에는 DCP의 자체 발급 ID 토큰을 검증하는 dcp-si, 외부 증명이 붙은 검증가능 자격증명과 표현을 위한 dcp-vc와 dcp-vp, OAuth2를 위한 oauth2, 관리 API 진입을 위한 management-api가 있다. 같은 분리의 연장선에서 2026-06-06 결정(scope-based-api-authorization)으로 관리 API 인가가 역할 기반에서 스코프 기반으로 옮겨졌는데, 예전에는 토큰의 role 클레임과 @RolesAllowed로 admin·provisioner·participant 역할을 따졌으나 이제는 표준 scope 클레임과 @RequiredScope 하나로 판단한다.
스코프 문법은 management-api[:리소스]:동작형태이고 admin이 테넌트 경계를 넘는 상승 권한 역할을 하며, 참여자 컨텍스트 id도 커스텀 participant_context_id 클레임 대신 표준 sub 클레임으로 들어온다. 그 덕분에 커스텀 Keycloak 확장 없이 어떤 표준 IdP로도 유효한 토큰을 발급할 수 있게 됐으나, 하위 호환 창을 두지 않은 깨는 변경이기에 IdentityHub를 비롯한 다운스트림이 같은 시점에 맞춰 옮겨가야 한다. 데이터 스페이스마다 규칙을 더 얹어야 할 때는 이렇게 확장에서 규칙을 추가한다.
@Inject
private TokenValidationRulesRegistry rulesRegistry;
@Override
public void initialize(ServiceExtensionContext context) {
rulesRegistry.addRule(DCP_SELF_ISSUED_TOKEN_CONTEXT, (claimtoken, additional) -> {
// 규칙 검사 후 결과 반환
return checkResult;
});
}
토큰 자체를 코어 라우트에 박지 않고 이렇게 맥락별 규칙으로 빼 두면 같은 커넥터가 서로 다른 데이터 스페이스의 인증 규칙을 동시에 수용할 수 있다. 프로토콜 버전만으로는 부족하기에 와이어 프로토콜과 인증, 정책 함수, 어휘, 식별자 해석을 한데 묶은 "Dataspace Profile Context"가 필요하다는 결정이 바로 그 출발점이다[7]. 이 사고방식은 거기서 한 걸음 더 나아가 하나의 가상 EDC 런타임이 참여자별로 여러 데이터 스페이스 프로파일을 동시에 제공하도록 했는데, 각 프로파일은 자기 JSON-LD 네임스페이스·컨텍스트와 자기 DSP URL 세그먼트를 갖고 요청 시점에 참여자 컨텍스트로 골라진다. 그래서 가상 모드의 DSP 경로는 /{participantContextId}/{profileId}/catalog/request처럼 참여자와 프로파일을 함께 담으며, protocol 문자열도 dataspace-protocol-http:{버전} 대신 프로파일 id 그 자체가 된다. 어느 참여자가 어떤 프로파일을 쓸 수 있는지는 가상 모드 SPI(ParticipantProfileService, 결정 기록상의 이름은 ParticipantProfileResolver)가 참여자별 설정(edc.dataspace.profiles.<id>...)을 보고 resolve(participantContextId, profileId)로 가리는데, 이는 같은 프로세스 안에서 겹치는 데이터 스페이스들을 충돌 없이 호스팅하기 위한 장치이고 관련 모듈은 data-protocols/dsp/dsp-virtual/ 아래에 모여 있다. 다만 기존 단일 참여자 런타임은 부팅 시 기본 프로파일 하나를 그대로 등록하므로 동작이 바뀌지 않는다.
그러므로 참여자 식별자를 고정된 하나의 Web DID라고 단정하면 최신 설계와 어긋나는데, EDC는 한 참여자가 데이터 스페이스 프로파일 컨텍스트마다 다른 식별자를 쓸 수 있도록 자기 식별자 해석을 ParticipantIdentityResolver(participantContextId·protocol을 입력으로 받음)에, 상대 식별자 추출을ParticipantIdExtractionFunction에 갈라 두었기 때문이다. 예컨대 DCP 기본 추출 함수(DefaultDcpParticipantIdExtractionFunction)는 받은 클레임 토큰의 검증가능 자격증명에서 credentialSubject.id를 꺼내 상대를 식별한다. 그 과정에서 예전의 고정 edc.participant.id 한 값에 의존하던 코드와 ServiceExtensionContext#getParticipantId()가 제거되었고, 한때 도입됐던 DataspaceProfileContextRegistry#getParticipantId(protocol)와 DataspaceProfileContext#participantId도 함께 사라졌다.
Data Plane 시그널링: Control Plane Data Transport의 분리
DSP가 표준화하지 않는 영역, 곧 실제 데이터 전송은 EDC에서 Data Plane이 맡는데, Control Plane과 Data Plane이 별도 런타임으로 갈려 있는 만큼 둘 사이의 대화 역시 Data Plane시그널링(Data Plane Signaling)이라는 자체 프로토콜로 표준화되어 있다[8]. 코드 수준에서 보면 Control Plane쪽에 DataFlowController가 있어, 전송 프로세스가 STARTING 단계에 들어가면 이 컨트롤러가 적절한 Data Plane을 골라 DataFlowStartMessage를 보낸다[9]. 인터페이스 자체가 전송의 수명주기를 그대로 메서드로 드러내며, 예외를 던지는 대신 StatusResult로 성공·실패와 재시도 여부를 돌려준다.
Data Plane 시그널링 구현인 DataPlaneSignalingFlowController의 canHandle은 늘 true를 돌려주며, 이전 동작을 남겨 둔 LegacyDataPlaneSignalingFlowController만 transferType으로 가린다. 즉 '어느 컨트롤러가 맡을지'를 고르던 단계가 사라지고, '어느 Data Plane 인스턴스로 보낼지'만 DataPlaneSelectorService.selectFor(transferProcess)가 정한다.
// org.eclipse.edc.connector.controlplane.transfer.spi.flow.DataFlowController
public interface DataFlowController {
boolean canHandle(TransferProcess transferProcess);
StatusResult<DataFlowResponse> prepare(TransferProcess transferProcess, Policy policy);
StatusResult<DataFlowResponse> start(TransferProcess transferProcess, Policy policy);
StatusResult<Void> suspend(TransferProcess transferProcess);
StatusResult<DataFlowResponse> resume(TransferProcess transferProcess);
StatusResult<Void> terminate(TransferProcess transferProcess);
StatusResult<Void> started(TransferProcess transferProcess);
StatusResult<Void> completed(TransferProcess transferProcess);
Set<String> transferTypesFor(Asset asset);
}
여기서 prepare는 나머지 메서드와 결이 조금 다른데, provisioning/deprovisioning 책임을 Control Plane에서 Data Plane으로 옮기면서 추가된 메서드이기 때문이다. Control Plane은 Data Plane에 prepare를 보내 자원 준비(provisioning)를 위임하고, 그 작업이 비동기로 걸리면 전송 프로세스를 앞서 본 PREPARATION_REQUESTED 같은 대기 상태에 세워 둔 채 콜백을 기다린다. 이는 Control Plane을 데이터 흐름 프로토콜의 세부에서 떼어 놓으려는 것이다. 그래서 Control Plane의 PROVISIONING 상태가 deprecated된 것은 이 단계가 사라져서가 아니라 자리를 옮겼기 때문이다. provisioning은 이제 Data Plane측 DataFlowStates enum에 살아 있어, 거기에는 PROVISIONING, PROVISION_REQUESTED, PROVISIONED, DEPROVISIONING, DEPROVISIONED가 그대로 들어 있다. 결국 Control Plane은 전송 흐름 초입인 소비자 측 INITIAL 단계에서 prepare로 준비를 위임하고, 비동기 준비가 걸리면 PREPARATION_REQUESTED에서 콜백을 기다리되 걸리지 않으면 곧장 REQUESTING으로 진행한다.
canHandle로 이 컨트롤러가 해당 전송을 맡을 수 있는지 먼저 가린 뒤 start가 실제 흐름을 여는데, 이렇게 Data Plane으로 나가는 신호는 그 아래에서 HTTP로 표현된다.
POST https://dataplane-host:port/api/signaling/v1/dataflows/start
Content-Type: application/json
{
"@type": "DataFlowStartMessage",
"processId": "process-id",
"agreementId": "agreement-id",
"transferType": "HttpData-PULL",
"sourceDataAddress": { "type": "HttpData", "baseUrl": "https://example.com/data" },
"callbackAddress": "http://control-plane"
}
신호의 HTTP 표현도 정확히는 단일 엔드포인트가 아니라 동작별 경로로 나뉜다. 시그널링 컨트롤러는 /api/signaling 컨텍스트(web.http.signaling.path 기본값) 아래 /v1/dataflows에 올라가고, 그 밑으로 POST .../v1/dataflows/prepare(준비·프로비저닝), POST .../v1/dataflows/start(시작), POST .../v1/dataflows/{id}/suspend(중단), POST .../v1/dataflows/{id}/terminate(종료), GET .../v1/dataflows/{id}/state(상태 조회)처럼 동작이 경로에 그대로 드러난다. 그중 prepare는 provisioning이 Control Plane에서 Data Plane으로 옮겨오면서 생긴 것이라 start와 분리된 별도 엔드포인트이며 본문으로 prepare는 DataFlowProvisionMessage를, start는 DataFlowStartMessage를 받는다.
같은 'HttpData-PULL' 값이라도, EDC가 Control Plane과 Data Plane 사이에서 주고받는 이 내부 신호에서는 transferType이라는 필드에 담기지만, DSP 표준 메시지인 카탈로그 Distribution과 소비자가 보내는 Transfer Request Message에서는 같은 값을 format이라는 속성에 담는다. 즉 transferType은 EDC 시그널링 API의 명칭이고 표준 와이어 속성은 format이며, 값의 표기법인 'DESTTYPE-{PUSH|PULL}(-RESPONSETYPE)' 역시 EDC가 정한 관례이지 DSP가 규정한 형식은 아니다. 따라서 내부 신호 필드명 transferType과 표준 메시지 필드명 format을 구분해야 와이어 호환성 문제를 피할 수 있다.
Data Plane은 DataFlowResponseMessage로 답하는데, 소비자가 끌어가는(pull) 전송이라면 공개 엔드포인트와 접근 토큰을 담은 DataAddress를 돌려준다. 이 DataAddress는 Control Plane이 소비자에게 보내는 DSP의 TransferProcessStarted 메시지에 실려 전달되고, 소비자 쪽에서는 자산 ID나 계약 ID 같은 정보를 더한 EndpointDataReference(EDR)로 보관된다.
Data Plane 전송이 push/pull 단방향만 있는 것은 아니어서, EDC는 양방향(bidirectional) 전송을 도입했다. 전송이 진행되는 동안 소비자가 오류나 흐름 제어 같은 피드백을 되돌려 보낼 수 있는 응답 채널(response channel)을 둘 수 있는데, 이때도 별도 offer가 아니라 하나의 offer와 합의로 forward·response 양쪽을 표현한다. Control Plane에서는 TransferType에 선택적 responseChannelType이 붙어 HttpData-PULL-gRpc처럼 인코딩되고, 카탈로그에서는 같은 DataSet이 응답 채널 유무에 따라 두 개의 Distribution으로 광고되며, Data Plane이 돌려주는 DataAddress에는 responseChannel-endpoint·responseChannel-authorization 같은 속성이 평탄화되어 함께 담긴다.
중단과 종료도 같은 시그널링 API로 DataFlowSuspendMessage와 DataFlowTerminateMessage를 보내 처리하는데, 모든 요청이 멱등하게 설계되어 있어 Data Plane이 중복 요청을 걸러내고 처리에 성공하면 ack를 돌려주며, Control Plane은 그 ack를 받아야 비로소 전송 프로세스를 다음 상태로 옮기고 ack가 오지 않으면 다음 주기에 다시 보낸다.
접근 토큰을 만드는 일은 DataPlaneManager가 DataFlowStartMessage를 받을 때 DataPlaneAuthorizationService를 통해 처리하며, 이때 토큰과 원본 주소, 그리고 누구의 토큰인지를 담은 AccessTokenData를 저장해 두는데 거기에는 계약 ID와 자산 ID, 전송 프로세스 ID, 흐름 유형(push 또는 pull), 소비자 참여자 ID가 들어간다. EDC 기본 Data Plane은 토큰을 갱신하지 않는 방식이라 전송이 STARTED 상태인 동안만 토큰이 유효하고 SUSPENDED나 TERMINATED, COMPLETED로 넘어가면 무효가 되며, 토큰을 즉시 폐기하려면 저장된 AccessTokenData를 지우면 된다.
Data Plane은 자기 수명이 Control Plane과 따로 돌기 때문에, 온라인이 되면 DataPlaneSelectorControlApi로 Control Plane에 스스로 등록하며, 이때 보내는 DataPlaneInstance에는 자기가 지원하는 전송 유형과 원본 유형, URL, 컴포넌트 ID가 담긴다.
Control Plane은 등록 정보로 카탈로그를 만들 때 어떤 자산을 어떤 전송 방식으로 줄 수 있는지를 판단하므로, 같은 자산이 HTTP pull과 S3 push로 모두 가능하면 카탈로그에 두 개의 Distribution으로 나타난다. 이때 두 Distribution을 표준에서 가르는 것이 format 속성인데, DSP 2025-1에서 각 Distribution은 format(JSON-LD로 펼치면 dct:format) 값으로 전송 형식을 식별하고, EDC는 이 값에 HttpData-PULL, AmazonS3-PUSH 같은 전송 유형 문자열을 그대로 싣는다(DefaultDistributionResolver가 transferTypesFor의 결과를 format으로 매핑한다). 그리고 이 format 값은 뒤에서 소비자가 전송을 개시할 때 Transfer Request Message의 format 속성에 그대로 다시 들어가므로, 카탈로그의 Distribution.format이 곧 전송 요청의 format으로 이어지는 이 연결고리가 '하나의 자산을 여러 방식으로 줄 수 있다'는 말이 표준 위에서 실제로 구현되는 방식이다.
프로토콜 계층: RemoteMessage와 양방향 디스패치
State Machine이 상태를 어떻게 옮기는지는 앞서 살폈으나, 그 전이를 일으키는 메시지가 실제로 상대 커넥터로 어떻게 나가고 또 들어오는지는 아직 다루지 않았는데, EDC는 이 부분을 특정 프로토콜과 무관한 한 층으로 추상화한다[10]. 중심에 놓인 추상 클래스 RemoteMessage가 본체에 담는 것은 전송 프로토콜(protocol)과 상대의 콜백 주소(counterPartyAddress) 둘뿐이고, 상대 식별자(counterPartyId)는 한 단계 아래로 내려가 DSP로 오가는 메시지의 봉투에 해당하는 추상 클래스 ProtocolRemoteMessage extends RemoteMessage에서 비로소 더해진다. 그렇기에 DSP 메시지를 보내는 디스패처가 다루는 타입 상한도 RemoteMessage가 아니라 ProtocolRemoteMessage인데, 코어가 아는 것은 "어디로, 어떤 프로토콜로"까지이고 "누구에게"는 프로토콜 메시지 계층에서 붙기 때문이다. 한편 구체적인 RemoteMessage 하위 타입들은 한곳에 모여 있지 않고 다루는 영역에 따라 카탈로그·계약 협상·전송 세 묶음(catalog-spi·contract-spi·transfer-spi)으로 나뉘어 정의된다.
나가는 메시지를 보내는 SPI는 현재 ProtocolRemoteMessageDispatcher(control-plane-spi)이고, 시그니처는 다음과 같다.
public interface ProtocolRemoteMessageDispatcher {
<T, M extends ProtocolRemoteMessage> CompletableFuture<StatusResult<T>> dispatch(
String participantContextId, Class<T> responseType, M message);
<M extends ProtocolRemoteMessage, RSP, REQ, RB> void registerMessage(
Class<M> clazz, RequestFactory<M, REQ> requestFactory, ProtocolResponseBodyExtractor<RB, RSP> bodyExtractor);
<M extends ProtocolRemoteMessage> void registerPolicyScope(
Class<M> messageClass, Function<M, Policy> policyProvider, RequestPolicyContext.Provider contextProvider);
}
여러 DSP 버전 사이의 선택은 이제 DSP 디스패치 경로가 쓰는 직렬화·transformer 계층이 맡는데, DspProtocolTypeTransformerRegistry가 나가는 메시지의 protocol 문자열에 맞는 트랜스포머 묶음을 고르고, 직렬화기가 참조하는 DataspaceProfileContextRegistry가 그 protocol에 맞는 JSON-LD compaction 컨텍스트를 고른다. 다만 클라이언트 콜백(웹훅)은 이제 이 디스패처 경로와 분리되어 전용 CallbackClient(control-plane-spi, 기본 구현 CallbackHttpClient)를 탄다.
들어오는 메시지는 반대 방향으로 흐르는데, API 컨트롤러가 네트워크에서 메시지를 받아 JSON-LD를 RemoteMessage로 역직렬화한 뒤 셋 중 알맞은 프로토콜 서비스(CatalogProtocolService, ContractNegotiationProtocolService, TransferProcessProtocolService)로 넘긴다. 요청 처리 한 줄로 정리하면 API 컨트롤러 → 트랜스포머(JSON-LD를 객체로) → 검증기 → 프로토콜 서비스 → 저장소 순이다.
DSP 구현 자체는 data-protocols/dsp 아래에 있고, 지금 라우팅과 직렬화에 쓰이는 protocol 키는 버전이 붙은 dataspace-protocol-http:2025-1이다. protocol 속성에 버전을 실어 <프로토콜명>:<버전> 규약을 만들었고, 코드에서도 DATASPACE_PROTOCOL_HTTP에 구분자 ":"와 "2025-1"을 붙여 키를 조립하는데(Dsp2025Constants.DATASPACE_PROTOCOL_HTTP_V_2025_1), 한 런타임이 여러 DSP 버전을 동시에 지원할 수 있는 비결이 바로 여기에 있다. 디스패처 레지스트리가 사라진 지금 버전을 가르는 일은 앞서 본 DspProtocolTypeTransformerRegistry와 DataspaceProfileContextRegistry가 맡으므로, 새 DSP 버전을 더하는 일은 새 프로파일과 트랜스포머 세트를 등록하는 것으로 끝나고 디스패처나 라우팅 코드는 손대지 않아도 된다.
앞서 본 ProtocolRemoteMessageDispatcher를 직접 구현한 DspHttpRemoteMessageDispatcherImpl(dsp-core/dsp-http-core)이 메시지 핸들러 등록 기능을 더하고, 카탈로그·협상·전송 명세는 각각 별도 모듈로 구현되는데, 현재 트리는 DSP 버전을 기준으로 갈려 있다. 특정 버전의 명세별 모듈은 버전 접미사를 달고 묶이는데, 예컨대 dsp-2025/dsp-catalog-2025/ 아래에 HTTP 엔드포인트를 올리는 dsp-catalog-http-api-2025와 JSON-LD↔메시지 트랜스포머를 등록하는 dsp-catalog-transform-2025가 함께 있고, 협상·전송도 같은 꼴이다.
버전 사이에 공유하는 로직은 dsp-lib/(dsp-catalog-lib 등)로, HTTP 디스패처 구현은 dsp-core/(dsp-http-core와 dsp-*-http-dispatcher)로 빠져 있으며, 여러 데이터 스페이스 프로파일을 한 런타임에서 동시에 받는 가상 커넥터용 모듈은 따로 dsp-virtual/ 아래에 있다. 이렇게 명세별·버전별로 모듈이 갈려 있기에, 카탈로그 요청만 제공하는 런타임이나 특정 버전만 지원하는 런타임을 골라 조립할 수 있다.
카탈로그 생성: Asset, 정책, ContractDefinition
지난 글에서 카탈로그가 단순한 목록이 아니라고 했는데, EDC는 그 카탈로그를 미리 만들어 두는 정적 문서로 보관하지 않고 세 entity의 조합으로부터 요청이 들어올 때마다 새로 생성한다[11].
세 entity 가운데 먼저 Asset을 보면, 자산은 실제 데이터 그 자체가 아니라 데이터를 가리키는 서술자(descriptor)이기에 전역 고유 id와 공개 properties, 클라이언트에 노출하지 않는 privateProperties, 그리고 실제 데이터 위치를 가리키는 dataAddress(HttpData·S3 등)를 담는다. 자산은 Management API로 등록되며, 이 과정에서 JSON-LD 확장을 거치므로 속성 이름이 전체 URI로 펼쳐진 형태로 저장된다.
{
"@context": { "edc": "https://w3id.org/edc/v0.0.1/ns/" },
"@id": "899d1ad0-532a-47e8-2245-1aa3b2a4eac6",
"properties": { "somePublicProp": "a very interesting value" },
"privateProperties": { "secretKey": "..." },
"dataAddress": { "type": "HttpData", "baseUrl": "http://localhost:8080/test" }
}
(다만 데이터 위치를 표현하는 방식은 지금도 옮겨 가는 중인데, Asset의 dataAddress 필드가 0.18.0부터 @Deprecated로 표시되었고 카탈로그 생성 코드 역시 DataAddress는 앞으로 Asset에서 제거될 예정이라는 경고를 찍으며, 그 자리는 새 dataplaneMetadata 필드가 대신하는 방향으로 가고 있기 때문이다. 그래서 지금 EDC를 읽거나 직접 흉내 낼 때 dataAddress는 여전히 동작하되 이행 중인 필드로 다루는 편이 안전하다.)
정책은 ODRL로 표현되어 PolicyDefinition으로 등록된 뒤 @id로 재사용되며, 이 자산과 정책을 잇는 자리에 ContractDefinition이 놓이는데 여기에는 성격이 다른 두 정책이 함께 붙는다. 하나는 소비자에게 카탈로그로 노출되는 contract policy(contractPolicyId)이고, 다른 하나는 노출되지 않은 채 제공자 런타임 안에서만 평가되는 access policy(accessPolicyId)다. 그리고 이 계약 정의를 어떤 자산에 적용할지는 assetsSelector가 질의(Criterion 목록)의 형태로 정한다.
{
"@type": "https://w3id.org/edc/v0.0.1/ns/ContractDefinition",
"edc:accessPolicyId": "access-policy-1234",
"edc:contractPolicyId": "contract-policy-5678",
"edc:assetsSelector": [
{ "@type": "edc:Criterion", "edc:operandLeft": "id", "edc:operator": "in", "edc:operandRight": ["id1", "id2"] }
]
}
assetsSelector는 런타임에 평가되므로 자산이 아직 등록되지 않았더라도 계약 정의를 먼저 만들어 둘 수 있고, 그 위에서 카탈로그 생성은 다음과 같이 흐른다. 소비자가 카탈로그를 요청하면 제공자는 모든 ContractDefinition을 가져와 access policy를 소비자의 클레임(검증가능 자격증명에서 채운 것)으로 평가하고, 통과한 정의의 assetsSelector를 돌려 자산 목록을 얻은 뒤, 각 자산과 contract policy를 묶어 Dataset을 만들고 이를 모아 DCAT 카탈로그로 돌려준다. 그래서 카탈로그는 고정된 정적 문서가 아니라 요청자의 신원과 자격에 따라 매번 다르게 생성되는 결과물이 된다.
카탈로그를 생성하는 단계에서 소비자 클레임으로 실제 평가되는 정책은 access policy뿐인데, ContractDefinitionResolverImpl이 accessPolicyId의 정책을 catalog 스코프로 평가해 통과한 ContractDefinition만 남기는 반면, 그다음에 도는 DatasetResolverImpl은 contractPolicyId의 정책을 평가하지 않고 그저 가져와 PolicyType.OFFER로 Dataset의 오퍼에 붙여 소비자에게 광고하기 때문이다. contract policy는 이렇게 먼저 노출된 다음 협상과 전송 단계에 이르러서야 비로소 평가되므로, access policy는 노출되지 않는 입장 조건으로, contract policy는 광고되는 사용 조건으로 갈리는 비대칭이 생긴다.
이렇게 만들어진 DCAT 카탈로그를 DSP 와이어로 내보낼 때 표준은 루트 Catalog 객체에 제공자 식별자인 participantId를 반드시 싣도록 요구하며(2025-1 카탈로그 스키마의 required 필드), EDC는 이 값을 참여자 컨텍스트에서 끌어와 채운다. 그러므로 카탈로그 응답을 직접 조립하는 구현이라면 데이터셋이나 디스트리뷰션과는 별개로 participantId를 빠뜨리지 않고 포함해야 한다.
정책 엔진과 정책 모니터
EDC 정책 엔진은 선언형 언어를 두지 않고 코드 우선(code-first) 방식을 택했는데[12], ODRL 정책을 POJO 트리(일종의 AST)로 역직렬화한 뒤 제약의 좌변(leftOperand) 키에 등록된 함수로 디스패치해 평가하며, 그 함수가 구현하는 인터페이스가 AtomicConstraintRuleFunction이다.
@FunctionalInterface
public interface AtomicConstraintRuleFunction<R extends Rule, C extends PolicyContext> {
boolean evaluate(Operator operator, Object rightValue, R rule, C context);
}
다만 코드 우선이 유일한 길로 남지는 않았는데, CEL(Common Expression Language)을 정책 평가에 도입하면서 커스텀 Java 함수를 작성하지 않고도 표현식으로 제약을 정의·평가할 수 있게 되었고, 이는 이미 core/common/cel-core 모듈로 들어와 CelExpressionFunction이 동적 제약 함수로 등록되어 CEL 엔진에 평가를 위임한다. 아직은 experimental 단계라 기본 BOM에 포함되지 않고 CEL 확장을 명시적으로 끼워 넣어야 쓸 수 있으나, 그렇기에 EDC 정책 엔진은 Java 코드로 짠 함수와 CEL 표현식을 나란히 둘 수 있는 쪽으로 가는 중이라고 볼 수 있다.
디스패치에는 갈래가 하나 더 있다. leftOperand 키를 컴파일 타임에 알 수 없는 경우를 위해, DynamicAtomicConstraintRuleFunction과 동적 바인딩(RuleBindingRegistry.dynamicBind)을 도입했다. 엔진은 먼저 키에 정확히 매칭되는 함수를 찾고, 없으면 canHandle(leftOperand)로 자신이 그 좌변을 평가할 수 있는지 스스로 판단하는 동적 함수들을 부른다. 실제로 PolicyEngineImpl은 키로 등록된 정적 함수와 별개로 동적 함수 목록을 두고, 각 동적 함수의 canHandle을 duty·permission·prohibition 평가에 꽂는다. 이 위에 CEL 표현식 평가가 추가되었는데, CelExpressionFunction이 바로 이 DynamicAtomicConstraintRuleFunction으로 등록되어 어떤 leftOperand가 CEL 표현식인지 런타임에 가려 평가를 가져간다.
정책 엔진은 스코프(scope)와 바인딩으로 규칙을 디스패치하는데, 규칙은 저마다 특정 스코프에 바인딩된다. 현재 코드에 실제로 정의된 정책 스코프는 카탈로그 생성용 catalog, 계약 협상용 contract.negotiation, 전송용 transfer.process, 정책 모니터용 policy.monitor, 그리고 나가는 요청에서 자격증명 스코프를 산출하는 데 쓰는 request.catalog·request.contract.negotiation·request.transfer.process다. 바인딩되지 않은 규칙은 그 스코프 평가에서 걸러진다. 같은 규칙이라도 스코프마다 다른 함수를 붙일 수 있고, 스코프와 컨텍스트는 DOT 표기로 계층을 이루어 부모에 바인딩된 규칙은 자식 스코프에서도 평가된다.
스코프는 타입화된 PolicyContext 서브클래스와 묶인다. registerScope(scope, ContextType.class)로 스코프와 컨텍스트 타입을 등록하고, 평가 시 PolicyEngineImpl은 entry.contextType().isAssignableFrom(context.getClass())로 함수를 고른다. 즉 자식 컨텍스트가 부모 컨텍스트를 상속하면 부모에 등록된 함수도 함께 선택되는 식으로, 스코프 상속이 Java 클래스 계층으로 이뤄진다. DOT 표기 계층은 RuleBindingRegistry가 어떤 규칙 타입을 어느 스코프에서 보이게 할지 거를 때 여전히 쓰인다.
제공자가 카탈로그를 생성하며 access policy를 평가할 때 쓰는 스코프는 catalog(CatalogPolicyContext)이고, CatalogCoreExtension이 이를 등록한다. 비슷하게 생긴 request.catalog(RequestCatalogPolicyContext)는 소비자가 '나가는 카탈로그 요청'을 만들며 자기 자격증명 스코프를 산출할 때 평가되는 스코프다. 동기 요청에서 한 번에 많은 정책을 평가해 함수 안에서 원격 호출을 피하고 필요하면 캐시를 두라는 성능 지침이 붙는 쪽은 바로 이 catalog 스코프다.
정책은 한 번 통과시키는 것으로 끝나지 않을 수 있는데, 스트림처럼 오래 가는 비유한(non-finite) 전송에서는 소비자가 여전히 자격을 유지하는지 주기적으로 확인해야 하기 때문이다. 그래서 EDC는 PolicyMonitor를 두어, Control Plane에 내장하거나 별도 런타임으로 돌리면서 전송이 살아 있는 동안 소비자 자격증명을 반복 점검한다.
PolicyMonitor가 어떻게 도는지를 보면 앞서 본 State Machine 골격을 그대로 재사용하는데, 전송이 STARTED가 되면 TransferProcessStarted 이벤트 리스너가 모니터 항목을 PolicyMonitorStore에 적재하고, watchdog가 STARTED 상태 항목을 배치로 빌려와(nextNotLeased로 lease를 잡는다) 정책을 검사한다. 정책이 더는 유효하지 않으면 해당 전송을 종료하고 항목을 지우며, 통과하면 lease를 풀어 다음 주기에 다시 보는데, 이 평가는 policy.monitor 스코프에서 일어나고 컨텍스트(PolicyMonitorContext)는 합의(ContractAgreement)와 현재 시각을 들고 들어간다. 그러므로 PolicyMonitor는 Control Plane에 내장하든 별도 런타임으로 분리하든 실행할 수 있으며, State Machine과 리스, 배치라는 동일한 구성 요소로 구현된 또 하나의 watchdog다.
분산 신원 : Identity Hub와 DCP
DSP가 신원을 비워 두었다고 했는데, 그 빈자리를 채우는 표준이 DCP(Decentralized Claims Protocol)이고 EDC에서 이를 구현한 것이 Identity Hub다[13]. Identity Hub는 W3C DID(특히 did:web)와 검증가능 자격증명(VC)을 토대로 자격증명과 키쌍, DID 문서를 관리하는 기계 대 기계용 컴포넌트로, 사람 개입이 필요한 OID4VC는 다루지 않는다.
중심이 되는 흐름은 제시(presentation)인데, DCP는 중앙 신원 제공자에게 토큰을 받는 대신 자기 발급(self-issued) 토큰을 쓰기 때문이다. 소비자 Control Plane은 DSP 메시지의 Authorization 헤더에 자기서명 JWT를 싣되, sub에 자신의 Web DID를 담고 DID 문서의 검증 메서드에 있는 공개키로 서명하며, 제공자는 그 DID 문서를 풀어 서명을 검증한다. 다만 이 단계에서 확인하는 것은 이 Control Plane이 그 참여자를 대신한다는 사실뿐이고, 그 참여자를 믿을 수 있는지에 대한 신뢰 판단은 그다음으로 미뤄 둔다. JWT의 token 클레임에 든 액세스 토큰을 근거로, 제공자는 DID 문서의 CredentialService 항목에서 소비자 Identity Hub 주소를 찾아 검증가능 표현(VP)을 요청하기 때문이다.
EDC는 이 검증 흐름 또한 한 메서드에 뭉쳐 두지 않고 또 하나의 경계로 쪼개 두는데, 상대 SI 토큰을 검증하는 일과 그 안의 액세스 토큰을 다시 자기 SI 토큰으로 포장해 CredentialService에 VP를 요청하는 일을 DcpIdentityService가 직접 맡지 않고 PresentationRequestService(decentralized-claims-spi)에 위임하기 때문이다. 기본 구현인 DefaultPresentationRequestService는 PRESENTATION_TOKEN_CLAIM에 상대 액세스 토큰을 담아 5분짜리 SI 토큰을 만들고, CredentialServiceUrlResolver로 상대 Identity Hub 주소를 푼 뒤 CredentialServiceClient.requestPresentation을 부른다. 이 부분을 별도 SPI로 빼 둔 덕분에 데이터 스페이스마다 VP 요청 로직, 예컨대 토큰 클레임을 가공하거나 해석하는 방식을 코어를 건드리지 않고 갈아 끼울 수 있다.
{
"@type": "PresentationQueryMessage",
"scope": ["AuditCertificationCredential"]
}
이 질의가 스코프 문자열만 쓰는 것은 아닌데, PresentationQueryMessage는 scope 배열이나 presentationDefinition(DIF Presentation Exchange의 표현 정의) 중 하나를 담을 수 있고, 검증기인 PresentationQueryValidator는 둘 다 비었거나 둘 다 채워져 있으면 거부한다. 스코프가 '어떤 자격증명 타입을 원한다'는 간단한 지정이라면 presentationDefinition은 더 정교한 제약을 건 질의이며, 어느 쪽으로 질의하든 검증기는 둘 중 정확히 하나만 채워져 있을 때에 한해 통과시킨다.
요청은 스코프 문자열로 어떤 자격증명을 원하는지 지정하고(기본 설정은 스코프를 VC 타입으로 매핑하며 ScopeToCriterionTransformer로 바꿀 수 있다), Identity Hub는 액세스 토큰이 허용하는 범위에서 VP를 만들어 돌려준다. VP 형식은 JWT 기반이 기본이고(테스트에서 LDP보다 한 자릿수 빨랐다) VerifiablePresentationService로 교체할 수 있다. VP를 헤더에 그대로 싣지 않고 Identity Hub를 따로 부르는 이유는, VP가 HTTP 헤더 크기 한계를 넘기 쉬운 데다, 무엇보다 앞서 본 정책 모니터처럼 제공자가 소비자 자격을 능동적으로 다시 조회해야 하는 경우가 있기 때문이다.
DCP에는 제시(presentation)만이 아니라 발급(issuance) 흐름도 있는데, EDC에서는 Issuer Service가 이를 구현한다(dcp-issuer-core, dcp-issuer-api). 이 발급 절차 역시 앞서 본 State Machine 패턴을 그대로 따른다. 보유자(Identity Hub) 쪽에서 자격증명을 요청하면 그 요청은 StatefulEntity를 상속한 HolderCredentialRequest 엔티티로 추적되며, 상태가 CREATED → REQUESTING(Issuer로 전송) → REQUESTED(Issuer가 응답해 issuerPid 확보) → ISSUED(자격증명 저장)로 흐르다가 실패하면 ERROR로 간다. 또한 발급 흐름은 제시와 분리된 자체발급 토큰 컨텍스트(dcp-issuance-si)를 따로 두므로(제시 흐름은 dcp-si), '신원'이라는 한 덩어리 안에서도 발급과 제시가 각각 독립된 경계와 State Machine으로 갈려 있다.
스코프는 두 경계에서 다뤄진다. 하나는 방금 본 Identity Hub 쪽으로, 받은 스코프 문자열을 자격증명 질의(Criterion)로 바꾸는 ScopeToCriterionTransformer(기본 구현은 EdcScopeToCriterionTransformer)다. 다른 하나는 소비자 커넥터가 DSP 요청(카탈로그·협상·전송)에 '어떤 스코프를 실어 보낼지' 정하는 쪽인데, 최신 EDC는 이를 설정 기반 동적 메커니즘으로도 처리한다(2026-01-30). DcpScope는 DEFAULT(요청마다 항상 붙는 정적 스코프)와 POLICY(정책 제약과 매칭될 때만 붙는 스코프) 두 종류를 두며, DefaultScopeMappingFunction이 정책 엔진의 post-validator로, DynamicScopeExtractor가 스코프 추출기로 등록되어 작동한다. 그래서 PolicyValidatorRule이나 ScopeExtractor를 직접 코딩하던 기존 방식 대신 edc.iam.dcp.scopes.* 설정만으로 스코프 규칙을 선언할 수도 있다.
Identity Hub 안에서 모든 자원은 참여자 컨텍스트(Participant Context) 아래 놓이며, 이 컨텍스트는 CREATED·ACTIVATED·DEACTIVATED 상태를 가지면서 보안과 범위의 경계 역할을 한다. 다만 참여자 컨텍스트는 Identity Hub만의 개념이 아닌데, EDC는 한 런타임이 여러 참여자의 워크로드를 동시에 돌릴 수 있도록(EDC Virtual) 이 개념을 커넥터 코어의 공용 SPI(connector-participant-context-spi)로 끌어올렸고, Identity Hub의 IdentityHubParticipantContext는 그 ParticipantContext를 상속하기 때문이다.
그 결과 Asset, PolicyDefinition, ContractDefinition, ContractNegotiation, ContractAgreement, TransferProcess, DataPlaneInstance, EDR 엔티티에 participantContextId가 추가되어, 같은 런타임 안에서 어떤 자원이 어느 참여자 소유인지가 코드로 구분되고, 단일 참여자 배포에서는 edc.participant.context.id 설정에서 컨텍스트 ID를 추론하는 shim 계층이 끼워진다. 그래서 CREATED·ACTIVATED·DEACTIVATED라는 컨텍스트 상태는 해당 참여자 경계의 보안·수명 상태를 나타낸다. 키쌍 자원은 CREATED·ACTIVATED·ROTATED·REVOKED 수명주기로 관리되어, 활성화 시 공개키가 DID 문서의 검증 메서드로 게시되고, 회전 시 새 키로 서명하되 기존 공개키는 한동안 남겨 이전 서명 검증을 유지하며, 폐기 시 검증 메서드를 제거한다. 개인키는 안전 저장소에 보관하는데 Hashicorp Vault를 지원하고, 나아가 Vault의 Transit 엔진으로 키를 메모리에 올리지 않고 서명만 위임할 수도 있으며, DID 문서 게시는 DidDocumentPublisher로 확장한다.
신뢰성 있는 메시징과 이벤트
지금까지의 조각들을 묶는 운영 원칙이 신뢰성 있는 메시징인데, EDC의 모든 상호작용은 고유 id를 가진 멱등 메시지로 다뤄지기에 확인(ack)이 오지 않으면 다시 보내고 받는 쪽은 같은 메시지를 중복 제거한다. 이 신뢰성은 재시작을 넘어 유지되어, 런타임이 응답을 보내기 전에 죽더라도 클러스터의 다른 인스턴스나 다시 살아난 런타임이 멈춘 자리에서 이어 보낸다. 이것이 가능한 까닭은 모든 상호작용 상태를 트랜잭션 저장소(예: PostgreSQL)에 State Machine으로 기록하고, 상태 전이를 상대에게 메시지를 보내는 트랜잭션 안에서 일으키되 확인을 받은 뒤에만 커밋하기 때문이며, 앞서 본 ContractNegotiation의 protocolMessages와 State Machine의 Lease가 바로 이 그림을 떠받치는 부품이었다.
상태 전이는 메시지로만 그치지 않고 이벤트로도 흘러나가는데, 확장 코드는 EventRouter로 이벤트를 구독하며 여기에는 비동기 알림과 동기 트랜잭션 알림 두 방식이 있다. 후자는 계약 협상 확정 같은 이벤트를 메시지 큐나 외부 시스템으로 신뢰성 있게 전달하기에, 그 이벤트를 받아 데이터 전송을 자동으로 개시하는 식의 통합에 쓰인다.
확장이 EventRouter에 구독자를 등록할 때 register는 비동기이고 registerSync는 동기인데, 동기 구독자는 일반 구독자보다 먼저 호출되고 그중 하나라도 예외를 던지면 그 publish 전체가 멈춘다. 앞서 말한 '트랜잭션 보장 알림'이 실제로 동작하는 방식이 바로 이것이다.
// org.eclipse.edc.spi.event.EventRouter (발췌)
public interface EventRouter {
<E extends Event> void registerSync(Class<E> eventKind, EventSubscriber subscriber); // 동기
<E extends Event> void register(Class<E> eventKind, EventSubscriber subscriber); // 비동기
<E extends Event> void publish(E event);
}
흘러나가는 이벤트는 EventEnvelope에 실린다. 봉투에는 고유 id(기본값은 랜덤 UUID), 생성 시각 at, 실제 페이로드, 그리고 직렬화 때 붙는 payload 클래스명 type이 담긴다. 모든 이벤트가 고유 id를 갖는다는 점은 앞서 본 멱등 메시징 원칙과 그대로 이어진다.
정리
EDC를 코드 수준에서 읽으며 거듭 마주친 것은 결국 여러 겹의 경계였는데, 코어와 구현체를 떼어 놓는 ServiceExtension과 의존성 주입에서 시작해, 장기 실행을 영속 상태와 잠금으로 다루는 StateMachineManager와 StatefulEntity, 전이의 합법성을 엔티티에 새긴 ContractNegotiation, 와이어 형식을 떼어 내는 트랜스포머와 맥락별 레지스트리, 메시지를 양방향으로 옮기는 ProtocolRemoteMessageDispatcher와 프로토콜 서비스, 자산·정책·ContractDefinition에서 요청자별로 생성하는 동적 카탈로그, ODRL을 코드로 평가하는 정책 엔진(AtomicConstraintRuleFunction)과 비유한 전송을 지키는 정책 모니터, 토큰 검증을 맥락별 규칙으로 분리한 TokenValidationRulesRegistry와 그 위의 DCP·Identity Hub, 전송을 제어에서 떼어 내는 Data Plane 시그널링, 그리고 이 모두를 떠받치는 멱등·트랜잭션 기반 신뢰성 메시징으로 이어진다. 표준이 무엇을 정해 두고 구현이 무엇을 책임지는지는 바로 이 경계들에서 갈린다.
위에서는 ODRL을 Java 함수로 평가하는 길을 그렸으나, EDC가 코드 우선 하나로만 굳어 있는 것은 아니다. 최근 결정으로 CEL(Common Expression Language) 식으로 정책을 평가하는 길이 코어 기능으로 채택되어, Java 함수를 새로 짜지 않고도 식으로 정책을 정의·평가할 수 있게 됐다. 아직 실험 단계라 기본 BOM에는 들어 있지 않고 별도 확장으로 켜야 하지만, 선언형 평가의 자리가 조금씩 넓어지고 있으며, 기존 AtomicConstraintRuleFunction 기반 평가와 CEL 기반 평가가 나란히 공존한다.
다음 글(Day-4)부터는 여기서 배운 교훈들을 기반으로 DSP를 Python으로 구현하기 시작한다. EDC의 Java SPI와 Annotation 주입을 Python의 어떤 장치로 대신할지, State Machine과 잠금을 어떻게 구현할지 등의 사항들을 정하고 구현에 착수할 예정이다.
참고
- https://github.com/eclipse-edc/Connector
- https://eclipse-edc.github.io/documentation/
- https://eclipse-edc.github.io/documentation/for-contributors/runtime/extension-model/
- https://github.com/eclipse-edc/Connector/blob/main/docs/developer/decision-records/2023-11-27-refactor-protocol-services/README.md
- https://eclipse-edc.github.io/documentation/for-contributors/runtime/programming-primitives/
- https://github.com/eclipse-edc/Connector/blob/main/docs/developer/decision-records/2023-03-02_entity_store_refactoring/README.md
- https://github.com/eclipse-edc/Connector/blob/main/docs/developer/decision-records/2025-05-28-dataspace-profile-context/README.md
- https://github.com/eclipse-edc/Connector/blob/main/docs/developer/decision-records/2023-12-12-dataplane-signaling/README.md
- https://eclipse-edc.github.io/documentation/for-contributors/data-plane/data-plane-signaling/
- https://eclipse-edc.github.io/documentation/for-contributors/control-plane/protocol-extensions/
- https://eclipse-edc.github.io/documentation/for-adopters/control-plane/
- https://eclipse-edc.github.io/documentation/for-adopters/control-plane/policy-engine/
- https://eclipse-edc.github.io/documentation/for-adopters/identity-hub/
- https://github.com/eclipse-edc/Connector/blob/main/docs/developer/decision-records/2023-02-28-processing-callbacks/README.md
- https://github.com/eclipse-edc/Connector/blob/main/docs/developer/decision-records/2023-03-09-event-framework-refactoring/README.md
- https://github.com/eclipse-tractusx/tractusx-edc
- https://github.com/eclipse-edc/Connector/blob/main/docs/developer/decision-records/2023-05-17-delete-helm-charts/README.md
Connector/docs/developer/decision-records/2023-05-17-delete-helm-charts/README.md at main · eclipse-edc/Connector
EDC core services including data plane and control plane - eclipse-edc/Connector
github.com
'Data Space' 카테고리의 다른 글
| 데이터 스페이스 - 2. 데이터 스페이스 프로토콜(DSP) (0) | 2026.06.16 |
|---|---|
| 데이터 스페이스 - 1. 데이터 스페이스란 무엇인가? (1) | 2026.06.15 |