SPI 확장
Q-Framework는 SPI(Service Provider Interface) 패턴으로 확장 포인트를 제공합니다. 프레임워크 내부 코드를 수정하지 않고 인터페이스 구현만으로 동작을 커스터마이징할 수 있습니다.
SPI 설계 원칙
Q-Framework (인터페이스 정의)
↑ 의존
애플리케이션 (구현체 제공)- 고수준 프레임워크가 저수준 어댑터에 의존하지 않음 (DIP 원칙)
- 구현체는 런타임에 자동 탐색
- 하나의 인터페이스에 여러 구현체 등록 가능
QfUserProvider
현재 요청의 사용자 정보를 제공합니다. 반드시 구현해야 합니다.
@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
조직 계층 정보를 제공합니다. 조직 모델을 사용하는 경우 구현이 필요합니다.
@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
런타임 초기화 시점에 추가 작업을 수행합니다.
@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 엔드포인트에 초기화 데이터를 제공합니다.
@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 파라미터는 조건부 데이터 로딩을 위한 클라이언트 측 리비전 값을 포함합니다.
| 필드 | 타입 | 설명 |
|---|---|---|
localeRevision | String | 클라이언트의 현재 로케일 목록 리비전 |
messageRevision | String | 활성 로케일의 현재 메시지 리비전 |
setupRevision | String | 클라이언트의 현재 설정 데이터 리비전 |
rsaPublicKey | String | 전송 키 교환용 클라이언트 임시 RSA 공개 키 (SPKI, Base64) |
리비전이 서버 값과 일치하면 해당 데이터 섹션은 생략 가능합니다 (해당 키에 null 반환).
QfDiagnosticListener
Q-Framework로부터 진단 이벤트를 수신합니다.
@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 등)에서 동적으로 제공합니다.
@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
애플리케이션이 지원하는 로케일 목록을 제공합니다. 다국어 지원 시 구현이 필요합니다.
@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 사용 시 구현이 필요합니다.
@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 사용 시 구현이 필요합니다.
@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) 사용 시 구현이 필요합니다.
@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 작업 수행 전 권한을 검증합니다. 선택 사항이며, 미구현 시 모든 작업이 허용됩니다.
@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.mode가 permissive(기본값) 또는 strict인 경우 이 어노테이션 없이는 애플리케이션이 시작되지 않습니다.
SPI 등록 방법
Spring Boot 환경에서는 @Component만 추가하면 자동으로 등록됩니다.
@Component // 이것만으로 Q-Framework가 자동 탐색
public class MyUserProvider implements QfUserProvider {
// ...
}Spring이 아닌 환경에서는 ServiceLoader 방식을 사용합니다:
META-INF/services/net.softminds.qframework.spi.QfUserProvider
→ com.example.myapp.MyUserProviderSPI 목록 요약
| SPI 인터페이스 | 필수 여부 | 설명 |
|---|---|---|
QfUserProvider | ✅ 필수 | 현재 사용자 정보 제공 |
QfOrganizationProvider | 조건부 | 조직 모델 사용 시 필수 |
QfLocaleProvider | 조건부 | 다국어 지원 시 필수 |
QfMessageProvider | 조건부 | 메시지 키 기반 i18n 사용 시 필수 |
QfFileStorageProvider | 조건부 | @QfFile 사용 시 필수 |
QfEntityHistoryProvider | 조건부 | @QfEntity(history = true) 사용 시 필수 |
QfOperationPrivilegeChecker | 선택 | 작업 수준 권한 검증 |
QfRuntimeInitializationHook | 선택 | 초기화 시점 훅 |
QfInitDataContributor | 선택 | 프런트엔드 초기화 데이터 제공 |
QfDiagnosticListener | 선택 | 진단 이벤트 수신 |
QfRuntimeConfigProvider | 선택 | 동적 설정 제공 |
다음 단계
- 아키텍처 개요 — Q-Framework 내부 구조
- 어노테이션 레퍼런스 — 전체 어노테이션 목록