Skip to content

SPI 확장

Q-Framework는 SPI(Service Provider Interface) 패턴으로 확장 포인트를 제공합니다. 프레임워크 내부 코드를 수정하지 않고 인터페이스 구현만으로 동작을 커스터마이징할 수 있습니다.

SPI 설계 원칙

Q-Framework (인터페이스 정의)
        ↑ 의존
애플리케이션 (구현체 제공)
  • 고수준 프레임워크가 저수준 어댑터에 의존하지 않음 (DIP 원칙)
  • 구현체는 런타임에 자동 탐색
  • 하나의 인터페이스에 여러 구현체 등록 가능

QfUserProvider

현재 요청의 사용자 정보를 제공합니다. 반드시 구현해야 합니다.

java
@Component
public class MyUserProvider implements QfUserProvider {

    @QfAllowedDirectAccess(reason = "SPI 구현체 — 사용자 저장소에 직접 접근 필요")
    private final UserRepository userRepository;

    @Override
    public QfUser getUserById(Object userId) {
        return userRepository.findById(userId)
            .map(this::toQfUser)
            .orElse(null);
    }

    @Override
    public QfUser getCurrentUser(QfRequestContext context) {
        // Spring Security, JWT, Session 등 어떤 방식도 사용 가능
        String userId = extractUserIdFromContext(context);

        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UnauthorizedException());

        return QfUser.builder()
            .id(user.getId())
            .name(user.getName())
            .organizationId(user.getOrganizationId())
            .privileges(roleService.getPrivileges(user.getRoles()))
            .locale(user.getPreferredLocale())
            .build();
    }

    @Override
    public int priority() {
        return 100;  // 높은 값이 우선; 기본값은 0
    }
}

QfUserProvider 메서드

메서드필수 여부설명
QfUser getUserById(Object userId)식별자로 사용자 조회
QfUser getCurrentUser(QfRequestContext context)기본값: null 반환요청 컨텍스트에서 현재 인증된 사용자 조회
int priority()기본값: 0복수 구현체 등록 시 선택 우선순위; 높은 값이 우선

QfOrganizationProvider

조직 계층 정보를 제공합니다. 조직 모델을 사용하는 경우 구현이 필요합니다.

java
@Component
public class MyOrganizationProvider implements QfOrganizationProvider {

    @QfAllowedDirectAccess(reason = "SPI 구현체 — 조직 저장소에 직접 접근 필요")
    private final OrganizationRepository organizationRepository;

    @Override
    public List<QfOrganization> getAllOrganizations() {
        return organizationRepository.findAll()
            .stream()
            .map(this::toQfOrganization)
            .collect(Collectors.toList());
    }

    @Override
    public List<QfOrganization> getOrganizations(Object userId) {
        return organizationRepository.findByUserId(userId)
            .stream()
            .map(this::toQfOrganization)
            .collect(Collectors.toList());
    }

    // 필요 시 default 메서드를 오버라이드하여 구현 가능

    private QfOrganization toQfOrganization(OrganizationEntity org) {
        return QfOrganization.builder()
            .id(org.getId())
            .parentId(org.getParentId())
            .name(org.getName())
            .depth(org.getDepth())
            .build();
    }

    @Override
    public int priority() {
        return 100;  // 높은 값이 우선; 기본값은 0
    }
}

QfOrganizationProvider 메서드

메서드필수 여부설명
List<QfOrganization> getAllOrganizations()애플리케이션에 등록된 모든 조직 반환
List<QfOrganization> getOrganizations(Object userId)해당 사용자가 속한 조직 목록 반환
List<QfOrganization> getDelegatedOrganizations(Object userId)기본값: 빈 목록사용자가 위임 관리하는 조직 반환 (교차 조직 접근)
List<QfOrganization> getAncestors(Object orgId, int maxDepth)기본값: 인메모리 탐색orgId 기준 maxDepth 단계 상위 조직 반환
List<QfOrganization> getAllUnder(Collection<Object> orgIds)기본값: 인메모리 BFS주어진 루트에서 도달 가능한 모든 하위 조직 반환 (루트 포함)
int priority()기본값: 0복수 구현체 등록 시 선택 우선순위; 높은 값이 우선

QfRuntimeInitializationHook

런타임 초기화 시점에 추가 작업을 수행합니다.

java
@Component
public class MyInitializationHook implements QfRuntimeInitializationHook {

    @Override
    public void onInitializationStarted(QfRuntimeContext runtimeContext) {
        // 초기화 시작: 검증, 전처리 등
        log.info("Q-Framework 초기화 시작");
    }

    @Override
    public void onInitializationSucceeded(QfRuntimeContext runtimeContext) {
        // 초기화 성공: 캐시 프리로딩, 기본 데이터 설정 등
        log.info("Q-Framework 초기화 완료");
    }

    @Override
    public void onInitializationFailed(QfRuntimeContext runtimeContext) {
        // 초기화 실패: 정리 작업, 알림 발송 등
        log.error("Q-Framework 초기화 실패");
    }
}

QfInitDataContributor

프런트엔드 /qapi/init 엔드포인트에 초기화 데이터를 제공합니다.

java
@Component
public class AppInitDataContributor implements QfInitDataContributor {

    @Override
    public Map<String, Object> getInitData(QfInitRequest request) {
        // 클라이언트 리비전이 서버와 같으면 메시지 생략
        if (request.messageRevision() != null &&
            request.messageRevision().equals(messageService.currentRevision())) {
            return Map.of("clientApps", clientAppService.getAll());
        }
        return Map.of(
            "clientApps", clientAppService.getAll(),
            "menus", menuService.getMenusForCurrentUser()
        );
    }

    @Override
    public int priority() {
        return 100;  // 동일 키 충돌 시 높은 우선순위가 이김
    }
}

QfInitRequest 필드

request 파라미터는 조건부 데이터 로딩을 위한 클라이언트 측 리비전 값을 포함합니다.

필드타입설명
localeRevisionString클라이언트의 현재 로케일 목록 리비전
messageRevisionString활성 로케일의 현재 메시지 리비전
setupRevisionString클라이언트의 현재 설정 데이터 리비전
rsaPublicKeyString전송 키 교환용 클라이언트 임시 RSA 공개 키 (SPKI, Base64)

리비전이 서버 값과 일치하면 해당 데이터 섹션은 생략 가능합니다 (해당 키에 null 반환).


QfDiagnosticListener

Q-Framework로부터 진단 이벤트를 수신합니다.

java
@Component
public class MyDiagnosticListener implements QfDiagnosticListener {

    @Override
    public void onError(DiagnosticEvent event) {
        log.error("[{}] {}", event.code(), event.message());
        monitoring.track("qf_error", Map.of("code", event.code()));
    }

    @Override
    public void onWarning(DiagnosticEvent event) {
        log.warn("[{}] {}", event.code(), event.message());
    }

    @Override
    public void onInfo(DiagnosticEvent event) {
        log.info("[{}] {}", event.code(), event.message());
    }
}

QfRuntimeConfigProvider

런타임 설정을 외부 소스(DB, Config Server 등)에서 동적으로 제공합니다.

java
@Component
public class DatabaseConfigProvider implements QfRuntimeConfigProvider {

    @QfAllowedDirectAccess(reason = "SPI 구현체 — 설정 저장소에 직접 접근 필요")
    private final ConfigRepository configRepository;

    @Override
    public List<ApplicationConfiguration> load() {
        // DB에서 설정 로딩 후 구조화된 설정 소스로 반환
        Map<String, Object> props = configRepository.findAll()
            .stream()
            .collect(Collectors.toMap(Config::getKey, Config::getValue));
        return List.of(new ApplicationConfiguration(props, "database"));
    }
}

QfLocaleProvider

애플리케이션이 지원하는 로케일 목록을 제공합니다. 다국어 지원 시 구현이 필요합니다.

java
@Component
public class MyLocaleProvider implements QfLocaleProvider {

    @Override
    public List<QfLocale> getLocales() {
        return List.of(
            QfLocale.of("ko", "한국어", 10),
            QfLocale.of("en", "English", 0)
        );
    }

    @Override
    public int priority() {
        return 100;  // 높은 값이 우선; 기본값은 0
    }
}

QfLocaleProvider 메서드

메서드필수 여부설명
List<QfLocale> getLocales()애플리케이션이 지원하는 로케일 목록 반환
Optional<QfLocale> guessLocale(QfAbstractExecutionContext context)기본값: 우선순위 기반 선택실행 컨텍스트에서 최적 로케일 결정
int priority()기본값: 0복수 구현체 등록 시 선택 우선순위; 높은 값이 우선

QfMessageProvider

메시지 키를 다국어 텍스트로 해석합니다. 메시지 키 기반 i18n 사용 시 구현이 필요합니다.

java
@Component
public class MyMessageProvider implements QfMessageProvider {

    @Override
    public String getMessage(String messageKey, String localeCode) {
        return messageSource.getMessage(messageKey, null, Locale.forLanguageTag(localeCode));
    }

    @Override
    public String getMessage(String messageKey, Map<String, String> params, String localeCode) {
        return messageSource.getMessage(messageKey, params.values().toArray(), Locale.forLanguageTag(localeCode));
    }

    @Override
    public int priority() {
        return 100;  // 높은 값이 우선; 기본값은 0
    }
}

QfMessageProvider 메서드

메서드필수 여부설명
String getMessage(String messageKey, String localeCode)메시지 키를 해당 로케일 텍스트로 해석
String getMessage(String messageKey, Map<String, String> params, String localeCode)메시지 키 해석 후 파라미터 보간
int priority()기본값: 0복수 구현체 등록 시 선택 우선순위; 높은 값이 우선

QfFileStorageProvider

파일 연결·정리·조회 작업을 처리합니다. @QfFile 사용 시 구현이 필요합니다.

java
@Component
public class MyFileStorageProvider implements QfFileStorageProvider {

    @Override
    public void linkFiles(List<Map<String, Object>> files, Object entityId,
                          String tableName, String purpose) {
        // 임시 업로드 파일을 영속화된 엔티티에 연결
    }

    @Override
    public void cleanupOrphanFiles(List<Map<String, Object>> files, Object entityId,
                                   String tableName, String purpose) {
        // 더 이상 참조되지 않는 파일 삭제
    }

    @Override
    public List<Map<String, Object>> getFiles(Object entityId, String tableName,
                                              String purpose, int maxFiles) {
        return fileRepository.findByEntityIdAndPurpose(entityId, tableName, purpose, maxFiles);
    }
}

QfFileStorageProvider 메서드

메서드필수 여부설명
void linkFiles(files, entityId, tableName, purpose)create/update 후 임시 업로드 파일을 엔티티에 연결
void cleanupOrphanFiles(files, entityId, tableName, purpose)엔티티에서 참조 해제된 파일 정리
List<Map<String, Object>> getFiles(entityId, tableName, purpose, maxFiles)엔티티에 연결된 파일 목록 반환

QfEntityHistoryProvider

엔티티 변경 이력을 페이징 조회합니다. @QfEntity(history = true) 사용 시 구현이 필요합니다.

java
@Component
public class MyEntityHistoryProvider implements QfEntityHistoryProvider {

    @Override
    public QfPageResultDto<Map<String, Object>> getHistory(
            QfEntityMetadataDoc.Entity entityMetadata,
            QfEntityHistoryRequest request) {
        // 최신순으로 정렬된 변경 이력 반환
        return historyRepository.findByEntityId(
            entityMetadata.fqcn(), request.entityId(), request.toPageable()
        );
    }

    @Override
    public int priority() {
        return 100;  // 높은 값이 우선; 기본값은 0
    }
}

QfEntityHistoryProvider 메서드

메서드필수 여부설명
QfPageResultDto<Map<String, Object>> getHistory(entityMetadata, request)엔티티 변경 이력을 페이징 조회 (최신순)
int priority()기본값: 0복수 구현체 등록 시 선택 우선순위; 높은 값이 우선

QfOperationPrivilegeChecker

CRUD 작업 수행 전 권한을 검증합니다. 선택 사항이며, 미구현 시 모든 작업이 허용됩니다.

java
@Component
public class MyPrivilegeChecker implements QfOperationPrivilegeChecker {

    @Override
    public void checkOrThrow(QfEntityMetadataDoc.Entity entityMetadata,
                             Operation operation,
                             QfAbstractExecutionContext context) {
        String privilegeKey = entityMetadata.capabilityKey() + "__" + operation.name().toLowerCase();
        if (!context.getCurrentUser().hasPrivilege(privilegeKey)) {
            throw new QfManagedException("ACCESS_DENIED", "권한 부족: " + privilegeKey);
        }
    }

    @Override
    public int priority() {
        return 100;  // 높은 값이 우선; 기본값은 0
    }
}

QfOperationPrivilegeChecker 메서드

메서드필수 여부설명
void checkOrThrow(entityMetadata, operation, context)권한 검증 후 거부 시 QfManagedException 발생; 허용 시 정상 반환
int priority()기본값: 0복수 구현체 등록 시 선택 우선순위; 높은 값이 우선

Operation 열거형 값

설명
CREATE생성 작업
LIST목록 조회
DETAIL단건 조회
UPDATE수정 작업
DELETE삭제 작업
TREE트리 조회
CHECK_UNIQUE중복 확인
HISTORY이력 조회
KEY_VALUE키-값 조회
UPDATE_KEY_VALUE키-값 업데이트

SPI 구현체에서의 직접 데이터 접근

Spring Data Repository, EntityManager, JdbcTemplate 등 직접 데이터 접근 타입을 주입하는 SPI 구현체는 해당 필드에 @QfAllowedDirectAccess(reason = "...") 어노테이션을 선언해야 합니다.

qf.persistence.access.modepermissive(기본값) 또는 strict인 경우 이 어노테이션 없이는 애플리케이션이 시작되지 않습니다.


SPI 등록 방법

Spring Boot 환경에서는 @Component만 추가하면 자동으로 등록됩니다.

java
@Component  // 이것만으로 Q-Framework가 자동 탐색
public class MyUserProvider implements QfUserProvider {
    // ...
}

Spring이 아닌 환경에서는 ServiceLoader 방식을 사용합니다:

META-INF/services/net.softminds.qframework.spi.QfUserProvider
→ com.example.myapp.MyUserProvider

SPI 목록 요약

SPI 인터페이스필수 여부설명
QfUserProvider✅ 필수현재 사용자 정보 제공
QfOrganizationProvider조건부조직 모델 사용 시 필수
QfLocaleProvider조건부다국어 지원 시 필수
QfMessageProvider조건부메시지 키 기반 i18n 사용 시 필수
QfFileStorageProvider조건부@QfFile 사용 시 필수
QfEntityHistoryProvider조건부@QfEntity(history = true) 사용 시 필수
QfOperationPrivilegeChecker선택작업 수준 권한 검증
QfRuntimeInitializationHook선택초기화 시점 훅
QfInitDataContributor선택프런트엔드 초기화 데이터 제공
QfDiagnosticListener선택진단 이벤트 수신
QfRuntimeConfigProvider선택동적 설정 제공

다음 단계

Released under the Apache 2.0 License.