diff --git a/docker/mysql/init/01-init.sql b/docker/mysql/init/01-init.sql
index dd8e6051..1326a6de 100644
--- a/docker/mysql/init/01-init.sql
+++ b/docker/mysql/init/01-init.sql
@@ -36,4 +36,72 @@ ON DUPLICATE KEY UPDATE
-- 참고: MySQL은 역순 정렬 최적화 시 DESC 인덱스가 도움될 수 있음
-- CREATE INDEX idx_audit_logs_rt_rid_et_ts_desc ON audit_logs(resource_type, resource_id, event_type, event_timestamp DESC);
-- 대안(버전 호환):
--- CREATE INDEX idx_audit_logs_rt_rid_et_ts ON audit_logs(resource_type, resource_id, event_type, event_timestamp);
\ No newline at end of file
+-- CREATE INDEX idx_audit_logs_rt_rid_et_ts ON audit_logs(resource_type, resource_id, event_type, event_timestamp);
+
+-- 테넌트 설정 관련 테이블 생성
+CREATE TABLE IF NOT EXISTS tenant_configs (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ tenant_id BIGINT NOT NULL,
+ config_key VARCHAR(255) NOT NULL,
+ config_value TEXT,
+ config_type ENUM('STRING', 'NUMBER', 'BOOLEAN', 'JSON', 'ENCRYPTED') NOT NULL,
+ description TEXT,
+ is_encrypted BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ created_by VARCHAR(100),
+ updated_by VARCHAR(100),
+ is_deleted BOOLEAN DEFAULT FALSE,
+ UNIQUE KEY uk_tenant_config (tenant_id, config_key),
+ INDEX idx_tenant_config_tenant_id (tenant_id),
+ INDEX idx_tenant_config_key (config_key),
+ INDEX idx_tenant_config_deleted (is_deleted)
+);
+
+CREATE TABLE IF NOT EXISTS tenant_type_configs (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ tenant_type ENUM('INDIVIDUAL', 'SMALL_BUSINESS', 'ENTERPRISE', 'GOVERNMENT') NOT NULL,
+ config_key VARCHAR(255) NOT NULL,
+ config_value TEXT,
+ config_type ENUM('STRING', 'NUMBER', 'BOOLEAN', 'JSON', 'ENCRYPTED') NOT NULL,
+ description TEXT,
+ is_encrypted BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ created_by VARCHAR(100),
+ updated_by VARCHAR(100),
+ is_deleted BOOLEAN DEFAULT FALSE,
+ UNIQUE KEY uk_tenant_type_config (tenant_type, config_key),
+ INDEX idx_tenant_type_config_type (tenant_type),
+ INDEX idx_tenant_type_config_key (config_key),
+ INDEX idx_tenant_type_config_deleted (is_deleted)
+);
+
+-- 테넌트 타입별 기본 설정 데이터 삽입
+INSERT INTO tenant_type_configs (tenant_type, config_key, config_value, config_type, description, is_encrypted) VALUES
+-- ENTERPRISE 기본 설정
+('ENTERPRISE', 'max_users', '1000', 'NUMBER', 'Maximum number of users for Enterprise tenants', FALSE),
+('ENTERPRISE', 'max_storage_gb', '10000', 'NUMBER', 'Maximum storage quota in GB for Enterprise tenants', FALSE),
+('ENTERPRISE', 'support_level', 'premium', 'STRING', 'Support level for Enterprise tenants', FALSE),
+('ENTERPRISE', 'max_file_size', '1024', 'NUMBER', 'Maximum file size in MB for Enterprise tenants', FALSE),
+
+-- SMALL_BUSINESS 기본 설정
+('SMALL_BUSINESS', 'max_users', '100', 'NUMBER', 'Maximum number of users for Small Business tenants', FALSE),
+('SMALL_BUSINESS', 'max_storage_gb', '1000', 'NUMBER', 'Maximum storage quota in GB for Small Business tenants', FALSE),
+('SMALL_BUSINESS', 'support_level', 'standard', 'STRING', 'Support level for Small Business tenants', FALSE),
+('SMALL_BUSINESS', 'max_file_size', '100', 'NUMBER', 'Maximum file size in MB for Small Business tenants', FALSE),
+
+-- INDIVIDUAL 기본 설정
+('INDIVIDUAL', 'max_users', '10', 'NUMBER', 'Maximum number of users for Individual tenants', FALSE),
+('INDIVIDUAL', 'max_storage_gb', '100', 'NUMBER', 'Maximum storage quota in GB for Individual tenants', FALSE),
+('INDIVIDUAL', 'support_level', 'basic', 'STRING', 'Support level for Individual tenants', FALSE),
+('INDIVIDUAL', 'max_file_size', '50', 'NUMBER', 'Maximum file size in MB for Individual tenants', FALSE),
+
+-- GOVERNMENT 기본 설정
+('GOVERNMENT', 'max_users', '5000', 'NUMBER', 'Maximum number of users for Government tenants', FALSE),
+('GOVERNMENT', 'max_storage_gb', '50000', 'NUMBER', 'Maximum storage quota in GB for Government tenants', FALSE),
+('GOVERNMENT', 'support_level', 'government', 'STRING', 'Support level for Government tenants', FALSE),
+('GOVERNMENT', 'max_file_size', '2048', 'NUMBER', 'Maximum file size in MB for Government tenants', FALSE)
+ON DUPLICATE KEY UPDATE
+ config_value = VALUES(config_value),
+ description = VALUES(description);
\ No newline at end of file
diff --git a/docs/TENANT_CONFIG_INHERITANCE_GUIDE.md b/docs/TENANT_CONFIG_INHERITANCE_GUIDE.md
new file mode 100644
index 00000000..eeb7b57c
--- /dev/null
+++ b/docs/TENANT_CONFIG_INHERITANCE_GUIDE.md
@@ -0,0 +1,194 @@
+# 테넌트별 설정 상속 시스템 가이드
+
+## 개요
+
+테넌트별 설정 상속 시스템은 플랫폼 전역 설정, 테넌트 타입별 기본 설정, 개별 테넌트 설정 간의 상속 구조를 지원하여 모든 테넌트의 유효 설정을 일관성 있게 계산하고 캐시하는 시스템입니다.
+
+## 상속 순서
+
+설정값은 다음 순서로 상속됩니다:
+
+1. **플랫폼 전역 설정** (최하위 우선순위)
+2. **테넌트 타입별 기본 설정** (중간 우선순위)
+3. **개별 테넌트 설정** (최상위 우선순위)
+
+## 주요 컴포넌트
+
+### 1. 엔티티
+
+- `TenantConfig`: 개별 테넌트의 설정
+- `TenantTypeConfig`: 테넌트 타입별 기본 설정
+- `PlatformConfig`: 플랫폼 전역 설정 (기존)
+
+### 2. 서비스
+
+- `TenantConfigInheritanceService`: 설정 상속 로직 처리
+- `TenantConfigService`: 개별 테넌트 설정 관리
+- `TenantConfigCacheService`: 캐시 관리
+
+### 3. 컨트롤러
+
+- `TenantConfigController`: 테넌트 설정 API
+- `TenantTypeConfigController`: 테넌트 타입별 설정 API
+
+## API 사용 예시
+
+### 1. 테넌트의 유효 설정 조회
+
+```http
+GET /api/v1/tenants/{tenantKey}/configs/effective
+```
+
+**응답 예시:**
+
+```json
+{
+ "success": true,
+ "data": {
+ "tenantKey": "enterprise-tenant-1",
+ "configurations": {
+ "max_users": {
+ "value": 1000,
+ "source": "TENANT_TYPE",
+ "description": "Maximum number of users for Enterprise tenants",
+ "configType": "NUMBER"
+ },
+ "max_file_size": {
+ "value": 2048,
+ "source": "TENANT",
+ "description": "Custom file size limit",
+ "configType": "NUMBER"
+ },
+ "support_level": {
+ "value": "premium",
+ "source": "TENANT_TYPE",
+ "description": "Support level for Enterprise tenants",
+ "configType": "STRING"
+ }
+ }
+ }
+}
+```
+
+### 2. 특정 설정 조회
+
+```http
+GET /api/v1/tenants/{tenantKey}/configs/effective/{configKey}
+```
+
+### 3. 테넌트 개별 설정 관리
+
+```http
+# 설정 생성/수정
+POST /api/v1/tenants/{tenantKey}/configs
+Content-Type: application/json
+
+{
+ "configKey": "custom_feature_enabled",
+ "configValue": "true",
+ "configType": "BOOLEAN",
+ "description": "Enable custom feature for this tenant"
+}
+
+# 설정 삭제
+DELETE /api/v1/tenants/{tenantKey}/configs/{configKey}
+```
+
+### 4. 테넌트 타입별 기본 설정 관리
+
+```http
+# 타입별 설정 생성/수정
+POST /api/v1/tenant-types/{tenantType}/configs
+Content-Type: application/json
+
+{
+ "configKey": "max_users",
+ "configValue": "1000",
+ "configType": "NUMBER",
+ "description": "Maximum number of users for Enterprise tenants"
+}
+```
+
+## 시나리오 예시
+
+### 시나리오 1: 플랫폼 설정 상속
+
+**Given**: 플랫폼 전역 설정에 `max_file_size: 100MB`가 설정됨
+**When**: 테넌트가 특별한 설정을 하지 않음
+**Then**: 테넌트는 자동으로 100MB 제한을 상속받음
+
+### 시나리오 2: 테넌트별 설정 오버라이드
+
+**Given**: 플랫폼 전역 설정이 `max_file_size: 100MB`
+**When**: 특정 테넌트가 `max_file_size: 500MB`로 설정
+**Then**: 해당 테넌트는 500MB 제한을 사용하고, 다른 테넌트는 100MB 제한 유지
+
+### 시나리오 3: 타입 기본값 적용
+
+**Given**: 전역에 `support_level` 없음
+**And**: 타입(ENTERPRISE) 기본 `support_level=premium`
+**When**: 테넌트가 미설정
+**Then**: `support_level=premium` 적용
+
+### 시나리오 4: 타입 변경 반영
+
+**Given**: 테넌트 타입 INDIVIDUAL → ENTERPRISE로 변경
+**When**: 타입 변경 이벤트 발생
+**Then**: 캐시 무효화 및 유효 설정에 ENTERPRISE 기본값 반영
+
+## 캐시 관리
+
+시스템은 자동으로 캐시를 관리합니다:
+
+- **설정 변경 시**: 해당 테넌트의 캐시 자동 무효화
+- **테넌트 타입 변경 시**: 해당 타입의 모든 테넌트 캐시 무효화
+- **플랫폼 설정 변경 시**: 모든 테넌트 캐시 무효화
+
+수동 캐시 무효화:
+
+```http
+POST /api/v1/tenants/{tenantKey}/configs/cache/evict
+```
+
+## 설정 타입
+
+지원하는 설정 타입:
+
+- `STRING`: 문자열
+- `NUMBER`: 숫자 (정수/실수)
+- `BOOLEAN`: 불린값
+- `JSON`: JSON 객체
+- `ENCRYPTED`: 암호화된 값
+
+## 에러 처리
+
+표준화된 에러 코드를 사용합니다:
+
+- `TENANT_CONFIG_NOT_FOUND`: 설정을 찾을 수 없음
+- `INVALID_CONFIG_VALUE`: 유효하지 않은 설정값
+- `CONFIG_KEY_ALREADY_EXISTS`: 중복된 설정 키
+- `CONFIG_INHERITANCE_FAILED`: 상속 처리 실패
+
+## 성능 고려사항
+
+- 설정 조회는 캐시를 통해 최적화됨
+- 설정 변경 시에만 캐시 무효화
+- 대량 설정 조회 시 배치 처리 권장
+- 설정 상속 계산은 비동기로 처리 가능
+
+## 보안 고려사항
+
+- 민감한 설정은 암호화 저장
+- 테넌트별 설정 접근 권한 검증
+- 감사 로깅으로 설정 변경 이력 추적
+- 설정값 유효성 검증
+
+## 모니터링
+
+- 설정 조회 성능 메트릭
+- 캐시 히트율 모니터링
+- 설정 변경 빈도 추적
+- 에러율 및 예외 상황 모니터링
+
+
+
diff --git a/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java b/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java
index 0b913d23..9b52f95a 100644
--- a/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java
+++ b/src/main/java/com/agenticcp/core/common/audit/AuditEntityListener.java
@@ -1,116 +1,80 @@
package com.agenticcp.core.common.audit;
-import com.agenticcp.core.common.context.AuditChangeContext;
-import com.agenticcp.core.common.entity.AuditLog;
-import com.agenticcp.core.common.util.ChangeTracker;
-import jakarta.persistence.PreRemove;
+import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
-import java.lang.reflect.Field;
-import java.util.Map;
+import java.time.LocalDateTime;
/**
- * JPA 엔티티 생명주기 리스너
+ * 엔티티 감사 리스너
+ *
+ *
엔티티의 생성/수정 시 자동으로 감사 정보를 설정합니다.
*
- * 엔티티가 UPDATE/DELETE 되기 직전에 자동으로 변경 전 값을 캡처합니다.
- *
* @author AgenticCP Team
* @version 1.0.0
+ * @since 2024-01-01
*/
-@Slf4j
@Component
public class AuditEntityListener {
- private static ChangeTracker changeTracker;
-
- @Autowired
- public void setChangeTracker(ChangeTracker tracker) {
- AuditEntityListener.changeTracker = tracker;
- }
-
- @PreUpdate
- public void preUpdate(Object entity) {
- if (entity instanceof AuditLog) {
- return; // 감지된 엔티티가 AuditLog 자신이면, 아무것도 하지 않고 즉시 종료
- }
- try {
- if (changeTracker == null) {
- log.warn("ChangeTracker가 주입되지 않았습니다. 변경 추적을 건너뜁니다.");
- return;
- }
-
- Map oldValue = changeTracker.extractOldValue(entity);
- String entityId = extractEntityId(entity);
+ /**
+ * 엔티티 생성 전 감사 정보 설정
+ */
+ @PrePersist
+ public void prePersist(Object entity) {
+ if (entity instanceof com.agenticcp.core.common.entity.BaseEntity) {
+ com.agenticcp.core.common.entity.BaseEntity baseEntity = (com.agenticcp.core.common.entity.BaseEntity) entity;
- if (oldValue != null && entityId != null) {
- AuditChangeContext.setChangeData(oldValue, entityId);
- log.debug("엔티티 변경 전 값 자동 캡처 [Entity: {}, ID: {}]",
- entity.getClass().getSimpleName(), entityId);
- }
+ LocalDateTime now = LocalDateTime.now();
+ String currentUser = getCurrentUser();
- } catch (Exception e) {
- log.warn("엔티티 변경 추적 중 오류 발생 [Entity: {}]: {}",
- entity.getClass().getSimpleName(), e.getMessage());
+ if (baseEntity.getCreatedAt() == null) {
+ baseEntity.setCreatedAt(now);
+ }
+ if (baseEntity.getUpdatedAt() == null) {
+ baseEntity.setUpdatedAt(now);
+ }
+ if (baseEntity.getCreatedBy() == null) {
+ baseEntity.setCreatedBy(currentUser);
+ }
+ if (baseEntity.getUpdatedBy() == null) {
+ baseEntity.setUpdatedBy(currentUser);
+ }
+ if (baseEntity.getIsDeleted() == null) {
+ baseEntity.setIsDeleted(false);
+ }
}
}
- @PreRemove
- public void preRemove(Object entity) {
- try {
- if (changeTracker == null) {
- return;
- }
-
- Map oldValue = changeTracker.extractOldValue(entity);
- String entityId = extractEntityId(entity);
-
- if (oldValue != null && entityId != null) {
- AuditChangeContext.setChangeData(oldValue, entityId);
- log.debug("엔티티 삭제 전 값 자동 캡처 [Entity: {}, ID: {}]",
- entity.getClass().getSimpleName(), entityId);
- }
+ /**
+ * 엔티티 수정 전 감사 정보 설정
+ */
+ @PreUpdate
+ public void preUpdate(Object entity) {
+ if (entity instanceof com.agenticcp.core.common.entity.BaseEntity) {
+ com.agenticcp.core.common.entity.BaseEntity baseEntity = (com.agenticcp.core.common.entity.BaseEntity) entity;
- } catch (Exception e) {
- log.warn("엔티티 삭제 추적 중 오류 발생 [Entity: {}]: {}",
- entity.getClass().getSimpleName(), e.getMessage());
+ baseEntity.setUpdatedAt(LocalDateTime.now());
+ baseEntity.setUpdatedBy(getCurrentUser());
}
}
- private String extractEntityId(Object entity) {
+ /**
+ * 현재 사용자 정보 조회
+ */
+ private String getCurrentUser() {
try {
- try {
- Object id = entity.getClass().getMethod("getId").invoke(entity);
- return id != null ? id.toString() : null;
- } catch (NoSuchMethodException e) {
- }
-
- for (Field field : entity.getClass().getDeclaredFields()) {
- if (field.isAnnotationPresent(jakarta.persistence.Id.class)) {
- field.setAccessible(true);
- Object id = field.get(entity);
- return id != null ? id.toString() : null;
- }
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication != null && authentication.isAuthenticated() &&
+ !"anonymousUser".equals(authentication.getPrincipal())) {
+ return authentication.getName();
}
-
- Class> superClass = entity.getClass().getSuperclass();
- if (superClass != null) {
- for (Field field : superClass.getDeclaredFields()) {
- if (field.isAnnotationPresent(jakarta.persistence.Id.class)) {
- field.setAccessible(true);
- Object id = field.get(entity);
- return id != null ? id.toString() : null;
- }
- }
- }
-
} catch (Exception e) {
- log.debug("엔티티 ID 추출 실패: {}", e.getMessage());
+ // SecurityContext가 없는 경우 무시
}
-
- return null;
+ return "system";
}
-}
-
+}
\ No newline at end of file
diff --git a/src/main/java/com/agenticcp/core/common/config/RedisConfig.java b/src/main/java/com/agenticcp/core/common/config/RedisConfig.java
index 3b0821a3..170ca6f3 100644
--- a/src/main/java/com/agenticcp/core/common/config/RedisConfig.java
+++ b/src/main/java/com/agenticcp/core/common/config/RedisConfig.java
@@ -18,6 +18,7 @@
*/
@Configuration
@ConditionalOnClass(RedisConnectionFactory.class)
+@ConditionalOnProperty(name = "app.redis.enabled", havingValue = "true", matchIfMissing = false)
public class RedisConfig {
/**
diff --git a/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java b/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java
index 1a2ea06c..5ca4b705 100644
--- a/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java
+++ b/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java
@@ -16,6 +16,8 @@ public enum AuditResourceType {
USER("사용자"),
TENANT("테넌트"),
+ TENANT_CONFIG("테넌트설정"),
+ TENANT_TYPE_CONFIG("테넌트타입설정"),
POLICY("보안정책"),
ROLE("역할"),
PERMISSION("권한"),
@@ -33,4 +35,4 @@ public enum AuditResourceType {
this.description = description;
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java b/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java
index a10ff1a8..2930c222 100644
--- a/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java
+++ b/src/main/java/com/agenticcp/core/common/security/JwtAuthenticationFilter.java
@@ -72,10 +72,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
* 요청에서 JWT 토큰 추출
*/
private String extractTokenFromRequest(HttpServletRequest request) {
- String authHeader = request.getHeader(AUTHORIZATION_HEADER);
+ String authHeader = request.getHeader(JwtConstants.AUTHORIZATION_HEADER);
- if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_PREFIX)) {
- return authHeader.substring(BEARER_PREFIX.length());
+ if (StringUtils.hasText(authHeader) && authHeader.startsWith(JwtConstants.BEARER_PREFIX)) {
+ return authHeader.substring(JwtConstants.BEARER_PREFIX.length());
}
return null;
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantConfigController.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantConfigController.java
new file mode 100644
index 00000000..06a2ee52
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantConfigController.java
@@ -0,0 +1,271 @@
+package com.agenticcp.core.domain.tenant.controller;
+
+import com.agenticcp.core.common.audit.AuditController;
+import com.agenticcp.core.common.audit.AuditRequired;
+import com.agenticcp.core.common.dto.exception.ApiResponse;
+import com.agenticcp.core.common.enums.AuditResourceType;
+import com.agenticcp.core.common.enums.AuditSeverity;
+import com.agenticcp.core.common.util.LogMaskingUtils;
+import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse;
+import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest;
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import com.agenticcp.core.domain.tenant.service.TenantConfigCacheService;
+import com.agenticcp.core.domain.tenant.service.TenantConfigService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.validation.Valid;
+import java.util.List;
+
+/**
+ * 테넌트 설정 관리 컨트롤러
+ *
+ * 테넌트별 설정 상속 시스템의 API를 제공합니다.
+ * 개별 테넌트 설정(TenantConfig)의 생성, 조회, 수정, 삭제 및
+ * 계층적 상속을 통한 유효 설정 조회 기능을 제공합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/tenants/{tenantKey}/configs")
+@RequiredArgsConstructor
+@Tag(name = "Tenant Configuration", description = "테넌트 설정 관리 API")
+@AuditController(
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ defaultSeverity = AuditSeverity.MEDIUM,
+ defaultIncludeRequestData = true,
+ targetHttpMethods = {"POST", "PUT", "DELETE"}
+)
+public class TenantConfigController {
+
+ private final TenantConfigService tenantConfigService;
+ private final TenantConfigCacheService tenantConfigCacheService;
+
+ /**
+ * 테넌트의 유효 설정 전체 조회 (상속 포함)
+ *
+ * 플랫폼, 테넌트 타입, 개별 테넌트 설정을 계층적으로 상속하여
+ * 최종 유효 설정을 모두 조회합니다. 캐시를 활용하여 성능을 최적화합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @return 유효 설정 응답 (설정값과 출처 정보 포함)
+ */
+ @GetMapping("/effective")
+ @Operation(summary = "테넌트 유효 설정 조회",
+ description = "플랫폼 전역 설정, 테넌트 타입 기본 설정, 개별 테넌트 설정을 상속하여 최종 유효 설정을 조회합니다.")
+ @AuditRequired(
+ action = "getEffectiveConfigurations",
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ severity = AuditSeverity.LOW,
+ includeRequestData = false
+ )
+ public ResponseEntity> getEffectiveConfigurations(
+ @Parameter(description = "테넌트 키") @PathVariable String tenantKey) {
+
+ log.info("[TenantConfigController] getEffectiveConfigurations - tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+
+ EffectiveConfigResponse response = tenantConfigCacheService.getCachedConfigurations(tenantKey);
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+
+ /**
+ * 테넌트의 특정 유효 설정 단건 조회
+ *
+ * 특정 설정 키에 대한 테넌트의 유효 설정값을 조회합니다.
+ * 상속 계층(개별 → 타입 → 플랫폼)을 따라 우선순위가 높은 값을 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @param configKey 조회할 설정 키
+ * @return 유효 설정값 (파싱된 객체), 없으면 null
+ */
+ @GetMapping("/effective/{configKey}")
+ @Operation(summary = "테넌트 특정 유효 설정 조회",
+ description = "특정 설정 키에 대한 테넌트의 유효 설정값을 조회합니다.")
+ @AuditRequired(
+ action = "getEffectiveConfiguration",
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ severity = AuditSeverity.LOW,
+ includeRequestData = false
+ )
+ public ResponseEntity> getEffectiveConfiguration(
+ @Parameter(description = "테넌트 키") @PathVariable String tenantKey,
+ @Parameter(description = "설정 키") @PathVariable String configKey) {
+
+ log.info("[TenantConfigController] getEffectiveConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ Object value = tenantConfigCacheService.getCachedConfiguration(tenantKey, configKey);
+
+ return ResponseEntity.ok(ApiResponse.success(value));
+ }
+
+ /**
+ * 테넌트의 개별 설정 목록 조회
+ *
+ * 테넌트가 직접 설정한 모든 개별 설정을 조회합니다.
+ * 상속된 설정이 아닌 TenantConfig 레벨의 설정만 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @return 테넌트의 개별 설정 목록
+ */
+ @GetMapping
+ @Operation(summary = "테넌트 개별 설정 목록 조회",
+ description = "테넌트가 직접 설정한 개별 설정 목록을 조회합니다.")
+ @AuditRequired(
+ action = "getTenantConfigurations",
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ severity = AuditSeverity.LOW,
+ includeRequestData = false
+ )
+ public ResponseEntity>> getTenantConfigurations(
+ @Parameter(description = "테넌트 키") @PathVariable String tenantKey) {
+
+ log.info("[TenantConfigController] getTenantConfigurations - tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+
+ List configs = tenantConfigService.getTenantConfigurations(tenantKey);
+
+ return ResponseEntity.ok(ApiResponse.success(configs));
+ }
+
+ /**
+ * 테넌트의 특정 개별 설정 단건 조회
+ *
+ * 테넌트가 직접 설정한 특정 개별 설정을 조회합니다.
+ * 상속된 값이 아닌 TenantConfig 레벨의 설정만 조회합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @param configKey 조회할 설정 키
+ * @return 테넌트의 개별 설정 (없으면 null)
+ */
+ @GetMapping("/{configKey}")
+ @Operation(summary = "테넌트 특정 개별 설정 조회",
+ description = "테넌트가 직접 설정한 특정 설정을 조회합니다.")
+ @AuditRequired(
+ action = "getTenantConfiguration",
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ severity = AuditSeverity.LOW,
+ includeRequestData = false
+ )
+ public ResponseEntity> getTenantConfiguration(
+ @Parameter(description = "테넌트 키") @PathVariable String tenantKey,
+ @Parameter(description = "설정 키") @PathVariable String configKey) {
+
+ log.info("[TenantConfigController] getTenantConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ return tenantConfigService.getTenantConfiguration(tenantKey, configKey)
+ .map(config -> ResponseEntity.ok(ApiResponse.success(config)))
+ .orElse(ResponseEntity.ok(ApiResponse.success(null)));
+ }
+
+ /**
+ * 테넌트 설정 생성/수정 (Upsert)
+ *
+ * 테넌트의 개별 설정을 생성하거나 수정합니다.
+ * 같은 configKey가 이미 존재하면 수정, 없으면 신규 생성합니다.
+ * 저장 후 자동으로 캐시를 무효화합니다.
+ *
+ * @param tenantKey 설정을 저장할 테넌트 키
+ * @param request 설정 요청 정보 (키, 값, 타입, 설명, 암호화 여부)
+ * @return 저장된 테넌트 설정
+ */
+ @PostMapping
+ @Operation(summary = "테넌트 설정 생성/수정",
+ description = "테넌트의 개별 설정을 생성하거나 수정합니다.")
+ @AuditRequired(
+ action = "saveTenantConfiguration",
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ severity = AuditSeverity.MEDIUM,
+ includeRequestData = true
+ )
+ public ResponseEntity> saveTenantConfiguration(
+ @Parameter(description = "테넌트 키") @PathVariable String tenantKey,
+ @Valid @RequestBody TenantConfigRequest request) {
+
+ log.info("[TenantConfigController] saveTenantConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), request.getConfigKey());
+
+ TenantConfig config = tenantConfigService.saveTenantConfiguration(tenantKey, request);
+
+ // 캐시 무효화: 해당 테넌트의 설정 캐시를 갱신
+ tenantConfigCacheService.evictTenantConfigCache(tenantKey);
+
+ return ResponseEntity.ok(ApiResponse.success(config));
+ }
+
+ /**
+ * 테넌트 설정 삭제 (소프트 삭제)
+ *
+ * 테넌트의 특정 개별 설정을 삭제합니다.
+ * 물리적 삭제가 아닌 isDeleted 플래그를 설정하는 소프트 삭제입니다.
+ * 삭제 후 자동으로 캐시를 무효화합니다.
+ *
+ * @param tenantKey 삭제할 설정이 속한 테넌트 키
+ * @param configKey 삭제할 설정 키
+ * @return 성공 응답
+ */
+ @DeleteMapping("/{configKey}")
+ @Operation(summary = "테넌트 설정 삭제",
+ description = "테넌트의 개별 설정을 삭제합니다.")
+ @AuditRequired(
+ action = "deleteTenantConfiguration",
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ severity = AuditSeverity.MEDIUM,
+ includeRequestData = true
+ )
+ public ResponseEntity> deleteTenantConfiguration(
+ @Parameter(description = "테넌트 키") @PathVariable String tenantKey,
+ @Parameter(description = "설정 키") @PathVariable String configKey) {
+
+ log.info("[TenantConfigController] deleteTenantConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ tenantConfigService.deleteTenantConfiguration(tenantKey, configKey);
+
+ // 캐시 무효화: 해당 테넌트의 특정 설정 캐시를 제거
+ tenantConfigCacheService.evictTenantConfigCache(tenantKey, configKey);
+
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+
+ /**
+ * 테넌트 설정 캐시 강제 무효화
+ *
+ * 테넌트의 설정 캐시를 수동으로 무효화합니다.
+ * 캐시 동기화 문제가 발생하거나 강제로 최신 상태로 갱신해야 할 때 사용합니다.
+ * 주의: 이 API는 관리자 또는 디버깅 용도로만 사용해야 합니다.
+ *
+ * @param tenantKey 캐시를 무효화할 테넌트 키
+ * @return 성공 응답
+ */
+ @PostMapping("/cache/evict")
+ @Operation(summary = "테넌트 설정 캐시 무효화",
+ description = "테넌트의 설정 캐시를 강제로 무효화합니다.")
+ @AuditRequired(
+ action = "evictTenantConfigCache",
+ resourceType = AuditResourceType.TENANT_CONFIG,
+ severity = AuditSeverity.HIGH,
+ includeRequestData = false
+ )
+ public ResponseEntity> evictTenantConfigCache(
+ @Parameter(description = "테넌트 키") @PathVariable String tenantKey) {
+
+ log.info("[TenantConfigController] evictTenantConfigCache - tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+
+ tenantConfigCacheService.evictTenantConfigCache(tenantKey);
+
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantTypeConfigController.java b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantTypeConfigController.java
new file mode 100644
index 00000000..3a280a30
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/controller/TenantTypeConfigController.java
@@ -0,0 +1,256 @@
+package com.agenticcp.core.domain.tenant.controller;
+
+import com.agenticcp.core.common.audit.AuditController;
+import com.agenticcp.core.common.audit.AuditRequired;
+import com.agenticcp.core.common.dto.exception.ApiResponse;
+import com.agenticcp.core.common.enums.AuditResourceType;
+import com.agenticcp.core.common.enums.AuditSeverity;
+import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig;
+import com.agenticcp.core.domain.tenant.repository.TenantTypeConfigRepository;
+import com.agenticcp.core.domain.tenant.service.TenantConfigCacheService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.validation.Valid;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 테넌트 타입별 기본 설정 관리 컨트롤러
+ *
+ * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)별 기본 설정을 관리하는 API를 제공합니다.
+ * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값으로 사용되며,
+ * 개별 테넌트는 이 값을 오버라이드할 수 있습니다.
+ *
+ * 설정 우선순위: TenantConfig > TenantTypeConfig > PlatformConfig
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/tenant-types/{tenantType}/configs")
+@RequiredArgsConstructor
+@Tag(name = "Tenant Type Configuration", description = "테넌트 타입별 설정 관리 API")
+@AuditController(
+ resourceType = AuditResourceType.TENANT_TYPE_CONFIG,
+ defaultSeverity = AuditSeverity.HIGH,
+ defaultIncludeRequestData = true,
+ targetHttpMethods = {"POST", "PUT", "DELETE"}
+)
+public class TenantTypeConfigController {
+
+ private final TenantTypeConfigRepository tenantTypeConfigRepository;
+ private final TenantConfigCacheService tenantConfigCacheService;
+
+ /**
+ * 테넌트 타입의 모든 기본 설정 목록 조회
+ *
+ * 특정 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)의 모든 기본 설정을 조회합니다.
+ * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값입니다.
+ *
+ * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL)
+ * @return 테넌트 타입의 기본 설정 목록
+ */
+ @GetMapping
+ @Operation(summary = "테넌트 타입 설정 목록 조회",
+ description = "특정 테넌트 타입의 모든 기본 설정을 조회합니다.")
+ @AuditRequired(
+ action = "getTenantTypeConfigurations",
+ resourceType = AuditResourceType.TENANT_TYPE_CONFIG,
+ severity = AuditSeverity.LOW,
+ includeRequestData = false
+ )
+ public ResponseEntity>> getTenantTypeConfigurations(
+ @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType) {
+
+ log.info("[TenantTypeConfigController] getTenantTypeConfigurations - tenantType={}", tenantType);
+
+ List configs = tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(tenantType);
+
+ return ResponseEntity.ok(ApiResponse.success(configs));
+ }
+
+ /**
+ * 테넌트 타입의 특정 기본 설정 단건 조회
+ *
+ * 테넌트 타입의 특정 기본 설정을 조회합니다.
+ * 해당 타입의 모든 테넌트에 적용되는 기본값을 확인할 때 사용합니다.
+ *
+ * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL)
+ * @param configKey 조회할 설정 키
+ * @return 테넌트 타입의 기본 설정 (없으면 null)
+ */
+ @GetMapping("/{configKey}")
+ @Operation(summary = "테넌트 타입 특정 설정 조회",
+ description = "테넌트 타입의 특정 기본 설정을 조회합니다.")
+ @AuditRequired(
+ action = "getTenantTypeConfiguration",
+ resourceType = AuditResourceType.TENANT_TYPE_CONFIG,
+ severity = AuditSeverity.LOW,
+ includeRequestData = false
+ )
+ public ResponseEntity> getTenantTypeConfiguration(
+ @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType,
+ @Parameter(description = "설정 키") @PathVariable String configKey) {
+
+ log.info("[TenantTypeConfigController] getTenantTypeConfiguration - tenantType={}, configKey={}",
+ tenantType, configKey);
+
+ Optional config = tenantTypeConfigRepository
+ .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenantType, configKey);
+
+ return config.map(c -> ResponseEntity.ok(ApiResponse.success(c)))
+ .orElse(ResponseEntity.ok(ApiResponse.success(null)));
+ }
+
+ /**
+ * 테넌트 타입 기본 설정 생성/수정 (Upsert)
+ *
+ * 테넌트 타입의 기본 설정을 생성하거나 수정합니다.
+ * 같은 configKey가 이미 존재하면 수정, 없으면 신규 생성합니다.
+ *
+ * 주의: 이 설정은 해당 타입의 모든 테넌트에 영향을 줍니다.
+ * 저장 후 해당 타입의 모든 테넌트 캐시를 자동으로 무효화합니다.
+ *
+ * @param tenantType 설정을 저장할 테넌트 타입
+ * @param request 설정 요청 정보 (키, 값, 타입, 설명, 암호화 여부)
+ * @return 저장된 테넌트 타입 기본 설정
+ */
+ @PostMapping
+ @Operation(summary = "테넌트 타입 설정 생성/수정",
+ description = "테넌트 타입의 기본 설정을 생성하거나 수정합니다.")
+ @AuditRequired(
+ action = "saveTenantTypeConfiguration",
+ resourceType = AuditResourceType.TENANT_TYPE_CONFIG,
+ severity = AuditSeverity.HIGH,
+ includeRequestData = true
+ )
+ public ResponseEntity> saveTenantTypeConfiguration(
+ @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType,
+ @Valid @RequestBody TenantConfigRequest request) {
+
+ log.info("[TenantTypeConfigController] saveTenantTypeConfiguration - tenantType={}, configKey={}",
+ tenantType, request.getConfigKey());
+
+ // Upsert 패턴: 기존 설정이 있는지 확인
+ Optional existingConfig = tenantTypeConfigRepository
+ .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenantType, request.getConfigKey());
+
+ TenantTypeConfig config;
+ if (existingConfig.isPresent()) {
+ // 기존 설정 수정 (UPDATE)
+ config = existingConfig.get();
+ config.setConfigValue(request.getConfigValue());
+ config.setConfigType(mapConfigType(request.getConfigType()));
+ config.setDescription(request.getDescription());
+ config.setIsEncrypted(request.getIsEncrypted());
+ log.info("[TenantTypeConfigController] saveTenantTypeConfiguration - updated existing config");
+ } else {
+ // 신규 설정 생성 (INSERT)
+ config = TenantTypeConfig.builder()
+ .tenantType(tenantType)
+ .configKey(request.getConfigKey())
+ .configValue(request.getConfigValue())
+ .configType(mapConfigType(request.getConfigType()))
+ .description(request.getDescription())
+ .isEncrypted(request.getIsEncrypted())
+ .build();
+ log.info("[TenantTypeConfigController] saveTenantTypeConfiguration - created new config");
+ }
+
+ TenantTypeConfig savedConfig = tenantTypeConfigRepository.save(config);
+
+ // 캐시 무효화: 해당 타입을 사용하는 모든 테넌트의 설정 캐시 갱신
+ tenantConfigCacheService.evictCacheByTenantType(tenantType.name());
+
+ return ResponseEntity.ok(ApiResponse.success(savedConfig));
+ }
+
+ /**
+ * 테넌트 타입 기본 설정 삭제 (소프트 삭제)
+ *
+ * 테넌트 타입의 특정 기본 설정을 삭제합니다.
+ * 물리적 삭제가 아닌 isDeleted 플래그를 설정하는 소프트 삭제입니다.
+ *
+ * 주의: 이 설정은 해당 타입의 모든 테넌트에 영향을 줍니다.
+ * 삭제 후 해당 타입의 모든 테넌트 캐시를 자동으로 무효화합니다.
+ *
+ * @param tenantType 삭제할 설정이 속한 테넌트 타입
+ * @param configKey 삭제할 설정 키
+ * @return 성공 응답
+ */
+ @DeleteMapping("/{configKey}")
+ @Operation(summary = "테넌트 타입 설정 삭제",
+ description = "테넌트 타입의 기본 설정을 삭제합니다.")
+ @AuditRequired(
+ action = "deleteTenantTypeConfiguration",
+ resourceType = AuditResourceType.TENANT_TYPE_CONFIG,
+ severity = AuditSeverity.HIGH,
+ includeRequestData = true
+ )
+ public ResponseEntity> deleteTenantTypeConfiguration(
+ @Parameter(description = "테넌트 타입") @PathVariable Tenant.TenantType tenantType,
+ @Parameter(description = "설정 키") @PathVariable String configKey) {
+
+ log.info("[TenantTypeConfigController] deleteTenantTypeConfiguration - tenantType={}, configKey={}",
+ tenantType, configKey);
+
+ Optional configOpt = tenantTypeConfigRepository
+ .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenantType, configKey);
+
+ if (configOpt.isPresent()) {
+ // 소프트 삭제: isDeleted 플래그만 설정
+ TenantTypeConfig config = configOpt.get();
+ config.setIsDeleted(true);
+ tenantTypeConfigRepository.save(config);
+
+ log.info("[TenantTypeConfigController] deleteTenantTypeConfiguration - soft deleted successfully");
+
+ // 캐시 무효화: 해당 타입을 사용하는 모든 테넌트의 설정 캐시 갱신
+ tenantConfigCacheService.evictCacheByTenantType(tenantType.name());
+ } else {
+ log.warn("[TenantTypeConfigController] deleteTenantTypeConfiguration - config not found");
+ }
+
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+
+ /**
+ * ConfigType 매핑 헬퍼 메서드
+ *
+ * TenantConfig.ConfigType을 TenantTypeConfig.ConfigType으로 변환합니다.
+ * TenantConfig와 TenantTypeConfig는 동일한 ConfigType enum을 가지지만
+ * 패키지가 다르기 때문에 매핑이 필요합니다.
+ *
+ * @param tenantConfigType 변환할 TenantConfig의 ConfigType
+ * @return 변환된 TenantTypeConfig의 ConfigType (기본값: STRING)
+ */
+ private TenantTypeConfig.ConfigType mapConfigType(com.agenticcp.core.domain.tenant.entity.TenantConfig.ConfigType tenantConfigType) {
+ // ConfigType 1:1 매핑
+ switch (tenantConfigType) {
+ case STRING:
+ return TenantTypeConfig.ConfigType.STRING;
+ case NUMBER:
+ return TenantTypeConfig.ConfigType.NUMBER;
+ case BOOLEAN:
+ return TenantTypeConfig.ConfigType.BOOLEAN;
+ case JSON:
+ return TenantTypeConfig.ConfigType.JSON;
+ case ENCRYPTED:
+ return TenantTypeConfig.ConfigType.ENCRYPTED;
+ default:
+ // 알 수 없는 타입의 경우 STRING으로 폴백
+ log.warn("[TenantTypeConfigController] mapConfigType - unknown type: {}, fallback to STRING", tenantConfigType);
+ return TenantTypeConfig.ConfigType.STRING;
+ }
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/dto/EffectiveConfigResponse.java b/src/main/java/com/agenticcp/core/domain/tenant/dto/EffectiveConfigResponse.java
new file mode 100644
index 00000000..ed8554c8
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/dto/EffectiveConfigResponse.java
@@ -0,0 +1,41 @@
+package com.agenticcp.core.domain.tenant.dto;
+
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * 테넌트의 유효 설정 응답 DTO
+ * 설정값과 그 출처 정보를 포함합니다.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EffectiveConfigResponse {
+
+ private String tenantKey;
+ private Map configurations;
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class ConfigValueWithSource {
+ private Object value;
+ private ConfigSource source;
+ private String description;
+ private TenantConfig.ConfigType configType;
+ }
+
+ public enum ConfigSource {
+ PLATFORM, // 플랫폼 전역 설정
+ TENANT_TYPE, // 테넌트 타입 기본 설정
+ TENANT // 개별 테넌트 설정
+ }
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/dto/TenantConfigRequest.java b/src/main/java/com/agenticcp/core/domain/tenant/dto/TenantConfigRequest.java
new file mode 100644
index 00000000..a7105d00
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/dto/TenantConfigRequest.java
@@ -0,0 +1,34 @@
+package com.agenticcp.core.domain.tenant.dto;
+
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * 테넌트 설정 생성/수정 요청 DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TenantConfigRequest {
+
+ @NotBlank(message = "설정 키는 필수입니다")
+ private String configKey;
+
+ @NotBlank(message = "설정 값은 필수입니다")
+ private String configValue;
+
+ @NotNull(message = "설정 타입은 필수입니다")
+ private TenantConfig.ConfigType configType;
+
+ private String description;
+
+ @Builder.Default
+ private Boolean isEncrypted = false;
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java
index c0c664a0..0eb91bc6 100644
--- a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java
+++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantConfig.java
@@ -2,52 +2,154 @@
import com.agenticcp.core.common.entity.BaseEntity;
import jakarta.persistence.*;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
+import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
+/**
+ * 테넌트별 설정 엔티티
+ *
+ * 개별 테넌트의 설정값을 저장하고 관리합니다.
+ * 플랫폼 설정과 테넌트 타입 설정을 상속받아 오버라이드할 수 있습니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
@Entity
-@Table(name = "tenant_configs")
+@Table(name = "tenant_configs",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"tenant_id", "config_key"}))
@Data
+@EqualsAndHashCode(callSuper = false)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TenantConfig extends BaseEntity {
+ /**
+ * 설정이 속한 테넌트
+ */
+ @NotNull(message = "테넌트는 필수입니다")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id", nullable = false)
private Tenant tenant;
- @Column(name = "config_key", nullable = false)
+ /**
+ * 설정 키 (예: max_users, storage_limit)
+ */
+ @NotBlank(message = "설정 키는 필수입니다")
+ @Size(min = 1, max = 100, message = "설정 키는 1-100자 사이여야 합니다")
+ @Column(name = "config_key", nullable = false, length = 100)
private String configKey;
+ /**
+ * 설정 값 (JSON, 문자열, 숫자 등 다양한 형식 지원)
+ */
@Column(name = "config_value", columnDefinition = "TEXT")
private String configValue;
- @Column(name = "config_type")
+ /**
+ * 설정 값의 타입
+ */
+ @NotNull(message = "설정 타입은 필수입니다")
+ @Column(name = "config_type", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private ConfigType configType;
- @Column(name = "description")
+ /**
+ * 설정에 대한 설명
+ */
+ @Size(max = 500, message = "설명은 500자를 초과할 수 없습니다")
+ @Column(name = "description", length = 500)
private String description;
- @Column(name = "is_encrypted")
+ /**
+ * 값이 암호화되어 저장되었는지 여부
+ */
+ @Builder.Default
+ @Column(name = "is_encrypted", nullable = false)
private Boolean isEncrypted = false;
- @Column(name = "is_required")
- private Boolean isRequired = false;
-
- @Column(name = "default_value", columnDefinition = "TEXT")
- private String defaultValue;
-
+ /**
+ * 설정 값의 데이터 타입
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
public enum ConfigType {
+ /**
+ * 문자열 타입
+ */
STRING,
+
+ /**
+ * 숫자 타입 (정수, 실수)
+ */
NUMBER,
+
+ /**
+ * 불리언 타입 (true/false)
+ */
BOOLEAN,
+
+ /**
+ * JSON 객체 또는 배열
+ */
JSON,
- ENCRYPTED,
- URL,
- EMAIL
+
+ /**
+ * 암호화된 값
+ */
+ ENCRYPTED
+ }
+
+ // === 비즈니스 메서드 ===
+
+ /**
+ * 설정 값이 암호화되어 있는지 확인
+ *
+ * @return 암호화 여부
+ */
+ public boolean isEncrypted() {
+ return Boolean.TRUE.equals(this.isEncrypted);
+ }
+
+ /**
+ * 설정을 암호화 상태로 변경
+ */
+ public void markAsEncrypted() {
+ this.isEncrypted = true;
+ }
+
+ /**
+ * 설정을 복호화 상태로 변경
+ */
+ public void markAsDecrypted() {
+ this.isEncrypted = false;
+ }
+
+ /**
+ * 설정 값이 비어있는지 확인
+ *
+ * @return 값이 null이거나 빈 문자열인 경우 true
+ */
+ public boolean hasValue() {
+ return configValue != null && !configValue.trim().isEmpty();
+ }
+
+ /**
+ * 특정 타입인지 확인
+ *
+ * @param type 확인할 타입
+ * @return 동일한 타입이면 true
+ */
+ public boolean isType(ConfigType type) {
+ return this.configType == type;
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantTypeConfig.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantTypeConfig.java
new file mode 100644
index 00000000..7a81d7fd
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantTypeConfig.java
@@ -0,0 +1,153 @@
+package com.agenticcp.core.domain.tenant.entity;
+
+import com.agenticcp.core.common.entity.BaseEntity;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+/**
+ * 테넌트 타입별 기본 설정 엔티티
+ *
+ * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)에 따른 기본 설정값을 저장합니다.
+ * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값으로 사용됩니다.
+ * 개별 테넌트는 이 값을 오버라이드할 수 있습니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Entity
+@Table(name = "tenant_type_configs",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"tenant_type", "config_key"}),
+ indexes = {
+ @Index(name = "idx_tenant_type_configs_type", columnList = "tenant_type"),
+ @Index(name = "idx_tenant_type_configs_key", columnList = "config_key"),
+ @Index(name = "idx_tenant_type_configs_config_type", columnList = "config_type")
+ })
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TenantTypeConfig extends BaseEntity {
+
+ @NotNull(message = "테넌트 타입은 필수입니다")
+ @Column(name = "tenant_type", nullable = false, length = 20)
+ @Enumerated(EnumType.STRING)
+ private Tenant.TenantType tenantType;
+
+ @NotBlank(message = "설정 키는 필수입니다")
+ @Size(min = 1, max = 100, message = "설정 키는 1-100자 사이여야 합니다")
+ @Column(name = "config_key", nullable = false, length = 100)
+ private String configKey;
+
+ @Column(name = "config_value", columnDefinition = "TEXT")
+ private String configValue;
+
+ @NotNull(message = "설정 타입은 필수입니다")
+ @Column(name = "config_type", nullable = false, length = 20)
+ @Enumerated(EnumType.STRING)
+ private ConfigType configType;
+
+ @Size(max = 500, message = "설명은 500자를 초과할 수 없습니다")
+ @Column(name = "description", length = 500)
+ private String description;
+
+ @Builder.Default
+ @Column(name = "is_encrypted", nullable = false)
+ private Boolean isEncrypted = false;
+
+ /**
+ * 설정 값의 데이터 타입
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+ public enum ConfigType {
+ /**
+ * 문자열 타입
+ */
+ STRING,
+
+ /**
+ * 숫자 타입 (정수, 실수)
+ */
+ NUMBER,
+
+ /**
+ * 불리언 타입 (true/false)
+ */
+ BOOLEAN,
+
+ /**
+ * JSON 객체 또는 배열
+ */
+ JSON,
+
+ /**
+ * 암호화된 값
+ */
+ ENCRYPTED
+ }
+
+ // === 비즈니스 메서드 ===
+
+ /**
+ * 설정 값이 암호화되어 있는지 확인
+ *
+ * @return 암호화 여부
+ */
+ public boolean isEncrypted() {
+ return Boolean.TRUE.equals(this.isEncrypted);
+ }
+
+ /**
+ * 설정을 암호화 상태로 변경
+ */
+ public void markAsEncrypted() {
+ this.isEncrypted = true;
+ }
+
+ /**
+ * 설정을 복호화 상태로 변경
+ */
+ public void markAsDecrypted() {
+ this.isEncrypted = false;
+ }
+
+ /**
+ * 설정 값이 비어있는지 확인
+ *
+ * @return 값이 null이거나 빈 문자열인 경우 true
+ */
+ public boolean hasValue() {
+ return configValue != null && !configValue.trim().isEmpty();
+ }
+
+ /**
+ * 특정 타입인지 확인
+ *
+ * @param type 확인할 타입
+ * @return 동일한 타입이면 true
+ */
+ public boolean isType(ConfigType type) {
+ return this.configType == type;
+ }
+
+ /**
+ * 특정 테넌트 타입에 대한 설정인지 확인
+ *
+ * @param type 확인할 테넌트 타입
+ * @return 동일한 테넌트 타입이면 true
+ */
+ public boolean isForTenantType(Tenant.TenantType type) {
+ return this.tenantType == type;
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/enums/TenantConfigErrorCode.java b/src/main/java/com/agenticcp/core/domain/tenant/enums/TenantConfigErrorCode.java
new file mode 100644
index 00000000..537f9df5
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/enums/TenantConfigErrorCode.java
@@ -0,0 +1,36 @@
+package com.agenticcp.core.domain.tenant.enums;
+
+import com.agenticcp.core.common.dto.exception.BaseErrorCode;
+import com.agenticcp.core.common.enums.ErrorCategory;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+/**
+ * 테넌트 설정 관련 에러 코드
+ */
+@Getter
+@RequiredArgsConstructor
+public enum TenantConfigErrorCode implements BaseErrorCode {
+
+ // 3000-3999: 테넌트 도메인
+ TENANT_CONFIG_NOT_FOUND(HttpStatus.NOT_FOUND, 3001, "테넌트 설정을 찾을 수 없습니다."),
+ TENANT_TYPE_CONFIG_NOT_FOUND(HttpStatus.NOT_FOUND, 3002, "테넌트 타입별 설정을 찾을 수 없습니다."),
+ INVALID_CONFIG_VALUE(HttpStatus.BAD_REQUEST, 3003, "유효하지 않은 설정값입니다."),
+ CONFIG_KEY_ALREADY_EXISTS(HttpStatus.CONFLICT, 3004, "이미 존재하는 설정 키입니다."),
+ INVALID_CONFIG_TYPE(HttpStatus.BAD_REQUEST, 3005, "유효하지 않은 설정 타입입니다."),
+ CONFIG_ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3006, "설정 암호화에 실패했습니다."),
+ CONFIG_DECRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3007, "설정 복호화에 실패했습니다."),
+ TENANT_CONFIG_QUOTA_EXCEEDED(HttpStatus.BAD_REQUEST, 3008, "테넌트 설정 할당량을 초과했습니다."),
+ CONFIG_INHERITANCE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3009, "설정 상속 처리에 실패했습니다."),
+ CACHE_EVICTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 3010, "캐시 무효화에 실패했습니다.");
+
+ private final HttpStatus httpStatus;
+ private final int codeNumber;
+ private final String message;
+
+ @Override
+ public String getCode() {
+ return ErrorCategory.TENANT.generate(codeNumber);
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/PlatformConfigChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/PlatformConfigChangeEvent.java
new file mode 100644
index 00000000..5ed8ea53
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/event/PlatformConfigChangeEvent.java
@@ -0,0 +1,23 @@
+package com.agenticcp.core.domain.tenant.event;
+
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * 플랫폼 설정 변경 이벤트
+ */
+@Getter
+public class PlatformConfigChangeEvent extends ApplicationEvent {
+
+ private final String configKey;
+ private final String action; // CREATE, UPDATE, DELETE
+
+ public PlatformConfigChangeEvent(Object source, String configKey, String action) {
+ super(source);
+ this.configKey = configKey;
+ this.action = action;
+ }
+}
+
+
+
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigChangeEvent.java
new file mode 100644
index 00000000..729007fd
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigChangeEvent.java
@@ -0,0 +1,25 @@
+package com.agenticcp.core.domain.tenant.event;
+
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * 테넌트 설정 변경 이벤트
+ */
+@Getter
+public class TenantConfigChangeEvent extends ApplicationEvent {
+
+ private final String tenantKey;
+ private final String configKey;
+ private final String action; // CREATE, UPDATE, DELETE
+
+ public TenantConfigChangeEvent(Object source, String tenantKey, String configKey, String action) {
+ super(source);
+ this.tenantKey = tenantKey;
+ this.configKey = configKey;
+ this.action = action;
+ }
+}
+
+
+
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigEventListener.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigEventListener.java
new file mode 100644
index 00000000..f341da8d
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantConfigEventListener.java
@@ -0,0 +1,80 @@
+package com.agenticcp.core.domain.tenant.event;
+
+import com.agenticcp.core.domain.tenant.service.TenantConfigCacheService;
+import com.agenticcp.core.common.util.LogMaskingUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 테넌트 설정 관련 이벤트 리스너
+ *
+ * 테넌트 설정 변경, 테넌트 타입 변경 등의 이벤트를 감지하여
+ * 관련 캐시를 자동으로 무효화합니다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class TenantConfigEventListener {
+
+ private final TenantConfigCacheService tenantConfigCacheService;
+
+ /**
+ * 테넌트 타입 변경 이벤트 처리
+ */
+ @EventListener
+ public void handleTenantTypeChangeEvent(TenantTypeChangeEvent event) {
+ log.info("[TenantConfigEventListener] handleTenantTypeChangeEvent - tenantKey={}, oldType={}, newType={}",
+ LogMaskingUtils.maskTenantKey(event.getTenantKey()), event.getOldType(), event.getNewType());
+
+ // 해당 테넌트의 캐시 무효화
+ tenantConfigCacheService.evictTenantConfigCache(event.getTenantKey());
+
+ log.info("[TenantConfigEventListener] handleTenantTypeChangeEvent - cache evicted for tenantKey={}",
+ LogMaskingUtils.maskTenantKey(event.getTenantKey()));
+ }
+
+ /**
+ * 테넌트 설정 변경 이벤트 처리
+ */
+ @EventListener
+ public void handleTenantConfigChangeEvent(TenantConfigChangeEvent event) {
+ log.info("[TenantConfigEventListener] handleTenantConfigChangeEvent - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(event.getTenantKey()), event.getConfigKey());
+
+ // 해당 테넌트의 캐시 무효화
+ tenantConfigCacheService.evictTenantConfigCache(event.getTenantKey(), event.getConfigKey());
+
+ log.info("[TenantConfigEventListener] handleTenantConfigChangeEvent - cache evicted for tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(event.getTenantKey()), event.getConfigKey());
+ }
+
+ /**
+ * 플랫폼 설정 변경 이벤트 처리
+ */
+ @EventListener
+ public void handlePlatformConfigChangeEvent(PlatformConfigChangeEvent event) {
+ log.info("[TenantConfigEventListener] handlePlatformConfigChangeEvent - configKey={}", event.getConfigKey());
+
+ // 플랫폼 설정 변경으로 인한 모든 테넌트 캐시 무효화
+ tenantConfigCacheService.evictCacheByPlatformConfigChange();
+
+ log.info("[TenantConfigEventListener] handlePlatformConfigChangeEvent - all tenant caches evicted");
+ }
+
+ /**
+ * 테넌트 타입별 설정 변경 이벤트 처리
+ */
+ @EventListener
+ public void handleTenantTypeConfigChangeEvent(TenantTypeConfigChangeEvent event) {
+ log.info("[TenantConfigEventListener] handleTenantTypeConfigChangeEvent - tenantType={}, configKey={}",
+ event.getTenantType(), event.getConfigKey());
+
+ // 해당 타입을 사용하는 모든 테넌트의 캐시 무효화
+ tenantConfigCacheService.evictCacheByTenantType(event.getTenantType());
+
+ log.info("[TenantConfigEventListener] handleTenantTypeConfigChangeEvent - cache evicted for tenantType={}",
+ event.getTenantType());
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeChangeEvent.java
new file mode 100644
index 00000000..3a2fb439
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeChangeEvent.java
@@ -0,0 +1,26 @@
+package com.agenticcp.core.domain.tenant.event;
+
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * 테넌트 타입 변경 이벤트
+ */
+@Getter
+public class TenantTypeChangeEvent extends ApplicationEvent {
+
+ private final String tenantKey;
+ private final Tenant.TenantType oldType;
+ private final Tenant.TenantType newType;
+
+ public TenantTypeChangeEvent(Object source, String tenantKey, Tenant.TenantType oldType, Tenant.TenantType newType) {
+ super(source);
+ this.tenantKey = tenantKey;
+ this.oldType = oldType;
+ this.newType = newType;
+ }
+}
+
+
+
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeConfigChangeEvent.java b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeConfigChangeEvent.java
new file mode 100644
index 00000000..2fef00bb
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/event/TenantTypeConfigChangeEvent.java
@@ -0,0 +1,33 @@
+package com.agenticcp.core.domain.tenant.event;
+
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * 테넌트 타입별 설정 변경 이벤트
+ */
+@Getter
+public class TenantTypeConfigChangeEvent extends ApplicationEvent {
+
+ private final String tenantType;
+ private final String configKey;
+ private final String action; // CREATE, UPDATE, DELETE
+
+ public TenantTypeConfigChangeEvent(Object source, String tenantType, String configKey, String action) {
+ super(source);
+ this.tenantType = tenantType;
+ this.configKey = configKey;
+ this.action = action;
+ }
+
+ public TenantTypeConfigChangeEvent(Object source, Tenant.TenantType tenantType, String configKey, String action) {
+ super(source);
+ this.tenantType = tenantType.name();
+ this.configKey = configKey;
+ this.action = action;
+ }
+}
+
+
+
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantConfigRepository.java b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantConfigRepository.java
new file mode 100644
index 00000000..305dd407
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantConfigRepository.java
@@ -0,0 +1,90 @@
+package com.agenticcp.core.domain.tenant.repository;
+
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 테넌트 개별 설정 Repository
+ *
+ * 개별 테넌트의 설정값을 조회하고 관리하는 데이터 액세스 레이어입니다.
+ * TenantConfig 엔티티에 대한 CRUD 및 커스텀 쿼리를 제공합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Repository
+public interface TenantConfigRepository extends JpaRepository {
+
+ /**
+ * 테넌트 엔티티로 모든 개별 설정 조회
+ *
+ * 특정 테넌트의 모든 설정을 조회합니다.
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param tenant 조회할 테넌트 엔티티
+ * @return 테넌트의 개별 설정 목록 (삭제되지 않은 것만)
+ */
+ List findByTenantAndIsDeletedFalse(Tenant tenant);
+
+ /**
+ * 테넌트 엔티티와 설정 키로 특정 개별 설정 조회
+ *
+ * 특정 테넌트의 특정 설정 키에 해당하는 설정을 조회합니다.
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param tenant 조회할 테넌트 엔티티
+ * @param configKey 조회할 설정 키
+ * @return 테넌트의 특정 설정 (없으면 empty)
+ */
+ Optional findByTenantAndConfigKeyAndIsDeletedFalse(Tenant tenant, String configKey);
+
+ /**
+ * 테넌트 키로 모든 개별 설정 조회
+ *
+ * 테넌트 엔티티 없이 tenantKey 문자열만으로 설정을 조회합니다.
+ * Tenant 엔티티와 조인하여 tenantKey 기반으로 조회합니다.
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키 (예: "tenant-001")
+ * @return 테넌트의 개별 설정 목록 (삭제되지 않은 것만)
+ */
+ @Query("SELECT tc FROM TenantConfig tc JOIN tc.tenant t WHERE t.tenantKey = :tenantKey AND tc.isDeleted = false")
+ List findByTenantKey(@Param("tenantKey") String tenantKey);
+
+ /**
+ * 테넌트 키와 설정 키로 특정 개별 설정 조회
+ *
+ * 테넌트 엔티티 없이 tenantKey와 configKey 문자열만으로 설정을 조회합니다.
+ * Tenant 엔티티와 조인하여 tenantKey 기반으로 조회합니다.
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키 (예: "tenant-001")
+ * @param configKey 조회할 설정 키 (예: "max_users")
+ * @return 테넌트의 특정 설정 (없으면 empty)
+ */
+ @Query("SELECT tc FROM TenantConfig tc JOIN tc.tenant t WHERE t.tenantKey = :tenantKey AND tc.configKey = :configKey AND tc.isDeleted = false")
+ Optional findByTenantKeyAndConfigKey(@Param("tenantKey") String tenantKey, @Param("configKey") String configKey);
+
+ /**
+ * 설정 키로 모든 테넌트의 설정 조회
+ *
+ * 특정 설정 키를 가진 모든 테넌트의 설정을 조회합니다.
+ * 동일한 설정 키를 사용하는 여러 테넌트의 설정을 비교하거나
+ * 일괄 조회할 때 사용합니다. (예: 모든 테넌트의 "max_users" 설정 조회)
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param configKey 조회할 설정 키 (예: "max_users")
+ * @return 해당 설정 키를 가진 모든 테넌트의 설정 목록
+ */
+ @Query("SELECT tc FROM TenantConfig tc JOIN tc.tenant t WHERE tc.configKey = :configKey AND tc.isDeleted = false")
+ List findByConfigKey(@Param("configKey") String configKey);
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantTypeConfigRepository.java b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantTypeConfigRepository.java
new file mode 100644
index 00000000..4f03e0d5
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantTypeConfigRepository.java
@@ -0,0 +1,63 @@
+package com.agenticcp.core.domain.tenant.repository;
+
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 테넌트 타입별 기본 설정 Repository
+ *
+ * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)별 기본 설정값을 조회하고 관리하는
+ * 데이터 액세스 레이어입니다. TenantTypeConfig 엔티티에 대한 CRUD 및
+ * 커스텀 쿼리를 제공합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Repository
+public interface TenantTypeConfigRepository extends JpaRepository {
+
+ /**
+ * 테넌트 타입으로 모든 기본 설정 조회
+ *
+ * 특정 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)의 모든 기본 설정을 조회합니다.
+ * 이 설정은 해당 타입의 모든 테넌트에 적용되는 기본값입니다.
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL)
+ * @return 테넌트 타입의 기본 설정 목록 (삭제되지 않은 것만)
+ */
+ List findByTenantTypeAndIsDeletedFalse(Tenant.TenantType tenantType);
+
+ /**
+ * 테넌트 타입과 설정 키로 특정 기본 설정 조회
+ *
+ * 특정 테넌트 타입의 특정 설정 키에 해당하는 기본 설정을 조회합니다.
+ * 해당 타입의 모든 테넌트에 적용되는 기본값을 확인할 때 사용합니다.
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param tenantType 조회할 테넌트 타입 (ENTERPRISE, STANDARD, TRIAL)
+ * @param configKey 조회할 설정 키 (예: "max_users")
+ * @return 테넌트 타입의 특정 기본 설정 (없으면 empty)
+ */
+ Optional findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType tenantType, String configKey);
+
+ /**
+ * 모든 테넌트 타입의 기본 설정 일괄 조회
+ *
+ * 모든 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)의 기본 설정을
+ * 한 번에 조회합니다. 전체 설정 비교, 초기 설정 로드, 관리자 페이지 등에서
+ * 사용합니다. 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @return 모든 테넌트 타입의 기본 설정 목록 (삭제되지 않은 것만)
+ */
+ List findByIsDeletedFalse();
+}
+
+
+
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigCacheService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigCacheService.java
new file mode 100644
index 00000000..a1a5a12c
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigCacheService.java
@@ -0,0 +1,159 @@
+package com.agenticcp.core.domain.tenant.service;
+
+import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse;
+import com.agenticcp.core.common.util.LogMaskingUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+
+/**
+ * 테넌트 설정 캐시 서비스
+ *
+ * 테넌트별 유효 설정을 캐시하여 성능을 최적화하고,
+ * 설정 변경 시 캐시 무효화를 관리합니다.
+ *
+ * 캐시 전략:
+ * - tenantConfigs: 테넌트의 전체 설정 캐시
+ * - tenantConfig: 테넌트의 개별 설정 캐시
+ *
+ * 무효화 시나리오:
+ * 1. TenantConfig 변경 시 → 해당 테넌트 캐시만 무효화
+ * 2. TenantTypeConfig 변경 시 → 해당 타입의 모든 테넌트 캐시 무효화
+ * 3. PlatformConfig 변경 시 → 전체 캐시 무효화
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TenantConfigCacheService {
+
+ private final TenantConfigInheritanceService tenantConfigInheritanceService;
+
+ /**
+ * 캐시된 테넌트 전체 설정 조회
+ *
+ * 테넌트의 모든 유효 설정을 캐시에서 조회합니다.
+ * 캐시 미스 시 TenantConfigInheritanceService를 통해 설정을 계산하고 캐시합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @return 유효 설정 응답 (설정값과 출처 정보 포함)
+ */
+ @Cacheable(value = "tenantConfigs", key = "#tenantKey")
+ public EffectiveConfigResponse getCachedConfigurations(String tenantKey) {
+ log.info("[TenantConfigCacheService] getCachedConfigurations - cache miss, loading tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+
+ return tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey);
+ }
+
+ /**
+ * 캐시된 특정 설정 조회
+ *
+ * 테넌트의 특정 설정 키에 대한 값을 캐시에서 조회합니다.
+ * 캐시 미스 시 상속 계층을 통해 설정을 계산하고 캐시합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @param configKey 조회할 설정 키
+ * @return 유효 설정값 (파싱된 객체), 없으면 null
+ */
+ @Cacheable(value = "tenantConfig", key = "#tenantKey + ':' + #configKey")
+ public Object getCachedConfiguration(String tenantKey, String configKey) {
+ log.info("[TenantConfigCacheService] getCachedConfiguration - cache miss, loading tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ return tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, configKey);
+ }
+
+ /**
+ * 특정 테넌트의 설정 캐시 무효화
+ *
+ * 테넌트의 개별 설정(TenantConfig)이 변경되었을 때 호출됩니다.
+ * 해당 테넌트의 전체 설정 캐시와 개별 설정 캐시를 모두 무효화합니다.
+ *
+ * @param tenantKey 캐시를 무효화할 테넌트 키
+ */
+ @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, key = "#tenantKey")
+ public void evictTenantConfigCache(String tenantKey) {
+ log.info("[TenantConfigCacheService] evictTenantConfigCache - tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+ }
+
+ /**
+ * 특정 테넌트의 특정 설정 캐시 무효화
+ *
+ * 테넌트의 개별 설정 중 특정 키만 변경되었을 때 호출됩니다.
+ * 해당 테넌트의 전체 설정 캐시와 해당 키의 개별 캐시를 무효화합니다.
+ *
+ * @param tenantKey 캐시를 무효화할 테넌트 키
+ * @param configKey 캐시를 무효화할 설정 키
+ */
+ @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, key = "#tenantKey + ':' + #configKey")
+ public void evictTenantConfigCache(String tenantKey, String configKey) {
+ log.info("[TenantConfigCacheService] evictTenantConfigCache - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+ }
+
+ /**
+ * 모든 테넌트 설정 캐시 일괄 무효화
+ *
+ * 모든 테넌트의 설정 캐시를 일괄 무효화합니다.
+ * 시스템 전반에 영향을 주는 변경 시 사용됩니다.
+ * (예: 대규모 설정 마이그레이션, 캐시 초기화)
+ */
+ @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true)
+ public void evictAllTenantConfigCache() {
+ log.info("[TenantConfigCacheService] evictAllTenantConfigCache");
+ }
+
+ /**
+ * 테넌트 타입별 캐시 무효화
+ *
+ * TenantTypeConfig(타입별 기본 설정)가 변경되었을 때 호출됩니다.
+ * 해당 타입을 사용하는 모든 테넌트의 캐시를 무효화합니다.
+ * (예: ENTERPRISE 타입의 기본 설정 변경 시)
+ *
+ * 참고: 현재는 allEntries=true로 전체 무효화하지만,
+ * 향후 성능 최적화를 위해 특정 타입만 무효화하도록 개선 가능
+ *
+ * @param tenantType 캐시를 무효화할 테넌트 타입
+ */
+ @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true)
+ public void evictCacheByTenantType(String tenantType) {
+ log.info("[TenantConfigCacheService] evictCacheByTenantType - tenantType={}", tenantType);
+ }
+
+ /**
+ * 플랫폼 설정 변경으로 인한 캐시 무효화
+ *
+ * PlatformConfig(플랫폼 전역 설정)가 변경되었을 때 호출됩니다.
+ * 플랫폼 설정은 모든 테넌트에 영향을 주므로 전체 캐시를 무효화합니다.
+ * (예: 전역 기본값, 시스템 레벨 제한 변경 시)
+ */
+ @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true)
+ public void evictCacheByPlatformConfigChange() {
+ log.info("[TenantConfigCacheService] evictCacheByPlatformConfigChange");
+ }
+
+ /**
+ * 특정 설정 키 변경으로 인한 캐시 무효화
+ *
+ * 특정 설정 키가 여러 레벨(Platform, TenantType, Tenant)에서 변경되었을 때 호출됩니다.
+ * 해당 설정 키를 참조하는 모든 테넌트의 캐시를 무효화합니다.
+ * (예: max_users 설정이 여러 레벨에서 변경될 때)
+ *
+ * 참고: 현재는 allEntries=true로 전체 무효화하지만,
+ * 향후 성능 최적화를 위해 특정 키만 무효화하도록 개선 가능
+ *
+ * @param configKey 변경된 설정 키
+ */
+ @CacheEvict(value = {"tenantConfigs", "tenantConfig"}, allEntries = true)
+ public void evictCacheByConfigKey(String configKey) {
+ log.info("[TenantConfigCacheService] evictCacheByConfigKey - configKey={}", configKey);
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceService.java
new file mode 100644
index 00000000..6c8bcad2
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceService.java
@@ -0,0 +1,351 @@
+package com.agenticcp.core.domain.tenant.service;
+
+import com.agenticcp.core.domain.platform.entity.PlatformConfig;
+import com.agenticcp.core.domain.platform.service.PlatformConfigService;
+import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig;
+import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository;
+import com.agenticcp.core.domain.tenant.repository.TenantTypeConfigRepository;
+import com.agenticcp.core.common.util.LogMaskingUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 테넌트 설정 상속 서비스
+ *
+ * 플랫폼 전역 설정 → 테넌트 타입 기본 설정 → 개별 테넌트 설정 순서로 상속하여
+ * 테넌트별 유효 설정을 계산합니다.
+ *
+ * 설정 우선순위:
+ * 1. TenantConfig (개별 테넌트 설정) - 최우선
+ * 2. TenantTypeConfig (테넌트 타입별 기본 설정) - 중간
+ * 3. PlatformConfig (플랫폼 전역 설정) - 최하위
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class TenantConfigInheritanceService {
+
+ private final PlatformConfigService platformConfigService;
+ private final TenantService tenantService;
+ private final TenantConfigRepository tenantConfigRepository;
+ private final TenantTypeConfigRepository tenantTypeConfigRepository;
+
+ /**
+ * 테넌트의 모든 유효 설정 조회
+ *
+ * 플랫폼, 테넌트 타입, 개별 테넌트의 설정을 계층적으로 병합하여
+ * 최종 유효 설정값과 출처 정보를 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @return 유효 설정 응답 (설정값과 출처 정보 포함)
+ */
+ public EffectiveConfigResponse getEffectiveConfigurations(String tenantKey) {
+ log.info("[TenantConfigInheritanceService] getEffectiveConfigurations - tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+
+ Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey);
+ Map configurations = new HashMap<>();
+
+ // 설정 상속 프로세스 (하위 레벨이 상위 레벨을 오버라이드)
+ // 1. 플랫폼 전역 설정 로드 (기본값)
+ loadPlatformConfigurations(configurations);
+
+ // 2. 테넌트 타입별 기본 설정 적용 (플랫폼 설정 오버라이드)
+ // 예: ENTERPRISE 플랜의 기본 스펙 적용
+ loadTenantTypeConfigurations(tenant.getTenantType(), configurations);
+
+ // 3. 개별 테넌트 설정 적용 (최종 오버라이드)
+ // 예: 특별 계약 고객의 맞춤 설정
+ loadTenantConfigurations(tenant, configurations);
+
+ EffectiveConfigResponse response = EffectiveConfigResponse.builder()
+ .tenantKey(tenantKey)
+ .configurations(configurations)
+ .build();
+
+ log.info("[TenantConfigInheritanceService] getEffectiveConfigurations - success tenantKey={}, configCount={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configurations.size());
+
+ return response;
+ }
+
+ /**
+ * 테넌트의 특정 설정값 조회
+ *
+ * 상속 순서(개별 테넌트 → 테넌트 타입 → 플랫폼)에 따라
+ * 가장 우선순위가 높은 설정값을 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @param configKey 조회할 설정 키
+ * @return 유효 설정값 (파싱된 객체), 없으면 null
+ */
+ public Object getEffectiveConfiguration(String tenantKey, String configKey) {
+ log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey);
+
+ // 1. 개별 테넌트 설정 확인 (최우선)
+ Optional tenantConfig = tenantConfigRepository
+ .findByTenantAndConfigKeyAndIsDeletedFalse(tenant, configKey);
+ if (tenantConfig.isPresent()) {
+ log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - found in tenant config");
+ return parseConfigValue(tenantConfig.get().getConfigValue(), tenantConfig.get().getConfigType());
+ }
+
+ // 2. 테넌트 타입별 설정 확인
+ Optional typeConfig = tenantTypeConfigRepository
+ .findByTenantTypeAndConfigKeyAndIsDeletedFalse(tenant.getTenantType(), configKey);
+ if (typeConfig.isPresent()) {
+ log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - found in tenant type config");
+ return parseConfigValue(typeConfig.get().getConfigValue(), typeConfig.get().getConfigType());
+ }
+
+ // 3. 플랫폼 전역 설정 확인
+ Optional platformConfig = platformConfigService.getConfigByKey(configKey);
+ if (platformConfig.isPresent()) {
+ log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - found in platform config");
+ return parseConfigValue(platformConfig.get().getConfigValue(), platformConfig.get().getConfigType());
+ }
+
+ log.info("[TenantConfigInheritanceService] getEffectiveConfiguration - not found tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+ return null;
+ }
+
+ /**
+ * 플랫폼 전역 설정 로드
+ *
+ * 플랫폼 전체에 적용되는 기본 설정을 맵에 추가합니다.
+ *
+ * @param configurations 설정을 저장할 맵
+ */
+ private void loadPlatformConfigurations(Map configurations) {
+ List platformConfigs = platformConfigService.getAllConfigs();
+ for (PlatformConfig config : platformConfigs) {
+ configurations.put(config.getConfigKey(),
+ EffectiveConfigResponse.ConfigValueWithSource.builder()
+ .value(parseConfigValue(config.getConfigValue(), config.getConfigType()))
+ .source(EffectiveConfigResponse.ConfigSource.PLATFORM)
+ .description(config.getDescription())
+ .configType(mapConfigType(config.getConfigType()))
+ .build());
+ }
+ }
+
+ /**
+ * 테넌트 타입별 설정 로드 (오버라이드)
+ *
+ * 테넌트 타입(ENTERPRISE, STANDARD, TRIAL)에 해당하는 기본 설정을 로드하여
+ * 플랫폼 설정을 오버라이드합니다.
+ *
+ * @param tenantType 테넌트 타입
+ * @param configurations 설정을 저장할 맵 (기존 값 오버라이드)
+ */
+ private void loadTenantTypeConfigurations(Tenant.TenantType tenantType,
+ Map configurations) {
+ List typeConfigs = tenantTypeConfigRepository
+ .findByTenantTypeAndIsDeletedFalse(tenantType);
+
+ for (TenantTypeConfig config : typeConfigs) {
+ configurations.put(config.getConfigKey(),
+ EffectiveConfigResponse.ConfigValueWithSource.builder()
+ .value(parseConfigValue(config.getConfigValue(), config.getConfigType()))
+ .source(EffectiveConfigResponse.ConfigSource.TENANT_TYPE)
+ .description(config.getDescription())
+ .configType(mapConfigType(config.getConfigType()))
+ .build());
+ }
+ }
+
+ /**
+ * 개별 테넌트 설정 로드 (최종 오버라이드)
+ *
+ * 특정 테넌트만의 고유 설정을 로드하여
+ * 플랫폼 및 타입 설정을 최종 오버라이드합니다.
+ *
+ * @param tenant 테넌트 엔티티
+ * @param configurations 설정을 저장할 맵 (최종 오버라이드)
+ */
+ private void loadTenantConfigurations(Tenant tenant,
+ Map configurations) {
+ List tenantConfigs = tenantConfigRepository
+ .findByTenantAndIsDeletedFalse(tenant);
+
+ for (TenantConfig config : tenantConfigs) {
+ configurations.put(config.getConfigKey(),
+ EffectiveConfigResponse.ConfigValueWithSource.builder()
+ .value(parseConfigValue(config.getConfigValue(), config.getConfigType()))
+ .source(EffectiveConfigResponse.ConfigSource.TENANT)
+ .description(config.getDescription())
+ .configType(config.getConfigType())
+ .build());
+ }
+ }
+
+ /**
+ * 설정값 파싱 (타입 다형성 처리)
+ *
+ * 다양한 ConfigType(TenantConfig, TenantTypeConfig, PlatformConfig)을
+ * 처리하기 위한 오버로딩 메서드 디스패처입니다.
+ *
+ * @param configValue 파싱할 설정값 문자열
+ * @param configType 설정 타입 (다형성)
+ * @return 파싱된 객체 (String, Long, Double, Boolean 등)
+ */
+ private Object parseConfigValue(String configValue, Object configType) {
+ if (configValue == null) {
+ return null;
+ }
+
+ if (configType instanceof TenantConfig.ConfigType) {
+ return parseConfigValue(configValue, (TenantConfig.ConfigType) configType);
+ } else if (configType instanceof TenantTypeConfig.ConfigType) {
+ return parseConfigValue(configValue, (TenantTypeConfig.ConfigType) configType);
+ } else if (configType instanceof PlatformConfig.ConfigType) {
+ return parseConfigValue(configValue, (PlatformConfig.ConfigType) configType);
+ }
+
+ return configValue;
+ }
+
+ /**
+ * TenantConfig.ConfigType 기반 설정값 파싱
+ *
+ * @param configValue 파싱할 설정값
+ * @param configType 테넌트 설정 타입
+ * @return 파싱된 객체
+ */
+ private Object parseConfigValue(String configValue, TenantConfig.ConfigType configType) {
+ return parseConfigValueByType(configValue, configType.name());
+ }
+
+ /**
+ * TenantTypeConfig.ConfigType 기반 설정값 파싱
+ *
+ * @param configValue 파싱할 설정값
+ * @param configType 테넌트 타입 설정 타입
+ * @return 파싱된 객체
+ */
+ private Object parseConfigValue(String configValue, TenantTypeConfig.ConfigType configType) {
+ return parseConfigValueByType(configValue, configType.name());
+ }
+
+ /**
+ * PlatformConfig.ConfigType 기반 설정값 파싱
+ *
+ * @param configValue 파싱할 설정값
+ * @param configType 플랫폼 설정 타입
+ * @return 파싱된 객체
+ */
+ private Object parseConfigValue(String configValue, PlatformConfig.ConfigType configType) {
+ return parseConfigValueByType(configValue, configType.name());
+ }
+
+ /**
+ * 타입 이름 기반 설정값 파싱
+ *
+ * 타입에 따라 문자열을 적절한 객체로 변환합니다:
+ * - NUMBER: 소수점 있으면 Double, 없으면 Long
+ * - BOOLEAN: Boolean
+ * - JSON: String (그대로 반환, 필요 시 클라이언트에서 파싱)
+ * - 기타: String
+ *
+ * @param configValue 파싱할 설정값
+ * @param typeName 타입 이름 (STRING, NUMBER, BOOLEAN, JSON, ENCRYPTED)
+ * @return 파싱된 객체, 파싱 실패 시 원본 문자열 반환
+ */
+ private Object parseConfigValueByType(String configValue, String typeName) {
+ try {
+ switch (typeName) {
+ case "NUMBER":
+ // 숫자 타입: 소수점 포함 여부로 Double/Long 구분
+ if (configValue.contains(".")) {
+ return Double.parseDouble(configValue); // 실수
+ } else {
+ return Long.parseLong(configValue); // 정수
+ }
+ case "BOOLEAN":
+ // 불리언 타입: "true", "false" 문자열을 Boolean으로 변환
+ return Boolean.parseBoolean(configValue);
+ case "JSON":
+ // JSON 타입: 문자열로 반환 (클라이언트에서 필요 시 파싱)
+ // 향후 ObjectMapper를 사용한 검증 추가 고려
+ return configValue;
+ default:
+ // STRING, ENCRYPTED 등: 문자열 그대로 반환
+ return configValue;
+ }
+ } catch (NumberFormatException e) {
+ // 파싱 실패 시 경고 로그 후 원본 문자열 반환 (시스템 중단 방지)
+ log.warn("[TenantConfigInheritanceService] 설정값 파싱 실패 - value={}, type={}, error={}",
+ configValue, typeName, e.getMessage());
+ return configValue;
+ }
+ }
+
+ /**
+ * TenantTypeConfig.ConfigType을 TenantConfig.ConfigType으로 매핑
+ *
+ * 응답 DTO에서 일관된 타입을 사용하기 위한 매핑 메서드입니다.
+ *
+ * @param typeConfigType 테넌트 타입 설정 타입
+ * @return 테넌트 설정 타입
+ */
+ private TenantConfig.ConfigType mapConfigType(TenantTypeConfig.ConfigType typeConfigType) {
+ switch (typeConfigType) {
+ case STRING:
+ return TenantConfig.ConfigType.STRING;
+ case NUMBER:
+ return TenantConfig.ConfigType.NUMBER;
+ case BOOLEAN:
+ return TenantConfig.ConfigType.BOOLEAN;
+ case JSON:
+ return TenantConfig.ConfigType.JSON;
+ case ENCRYPTED:
+ return TenantConfig.ConfigType.ENCRYPTED;
+ default:
+ return TenantConfig.ConfigType.STRING;
+ }
+ }
+
+ /**
+ * PlatformConfig.ConfigType을 TenantConfig.ConfigType으로 매핑
+ *
+ * 응답 DTO에서 일관된 타입을 사용하기 위한 매핑 메서드입니다.
+ *
+ * @param platformType 플랫폼 설정 타입
+ * @return 테넌트 설정 타입
+ */
+ private TenantConfig.ConfigType mapConfigType(PlatformConfig.ConfigType platformType) {
+ switch (platformType) {
+ case STRING:
+ return TenantConfig.ConfigType.STRING;
+ case NUMBER:
+ return TenantConfig.ConfigType.NUMBER;
+ case BOOLEAN:
+ return TenantConfig.ConfigType.BOOLEAN;
+ case JSON:
+ return TenantConfig.ConfigType.JSON;
+ case ENCRYPTED:
+ return TenantConfig.ConfigType.ENCRYPTED;
+ default:
+ return TenantConfig.ConfigType.STRING;
+ }
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigService.java b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigService.java
new file mode 100644
index 00000000..bfad2b04
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/service/TenantConfigService.java
@@ -0,0 +1,200 @@
+package com.agenticcp.core.domain.tenant.service;
+
+import com.agenticcp.core.common.exception.ResourceNotFoundException;
+import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository;
+import com.agenticcp.core.common.util.LogMaskingUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 테넌트 설정 관리 서비스
+ *
+ * 개별 테넌트의 설정을 생성/수정/삭제/조회하는 기능을 제공합니다.
+ * TenantConfig는 특정 테넌트만의 고유 설정으로,
+ * TenantTypeConfig와 PlatformConfig보다 높은 우선순위를 가집니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class TenantConfigService {
+
+ private final TenantConfigRepository tenantConfigRepository;
+ private final TenantService tenantService;
+
+ /**
+ * 테넌트의 모든 설정 조회
+ *
+ * 특정 테넌트에 설정된 모든 개별 설정을 조회합니다.
+ * 삭제되지 않은(isDeleted=false) 설정만 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @return 테넌트의 설정 목록
+ */
+ public List getTenantConfigurations(String tenantKey) {
+ log.info("[TenantConfigService] getTenantConfigurations - tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+
+ List configs = tenantConfigRepository.findByTenantKey(tenantKey);
+
+ log.info("[TenantConfigService] getTenantConfigurations - success tenantKey={}, count={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configs.size());
+
+ return configs;
+ }
+
+ /**
+ * 테넌트의 특정 설정 조회
+ *
+ * 테넌트의 특정 설정 키에 해당하는 설정을 조회합니다.
+ * 설정이 없거나 삭제된 경우 Empty Optional을 반환합니다.
+ *
+ * @param tenantKey 조회할 테넌트 키
+ * @param configKey 조회할 설정 키
+ * @return Optional로 감싼 테넌트 설정 (없으면 Empty)
+ */
+ public Optional getTenantConfiguration(String tenantKey, String configKey) {
+ log.info("[TenantConfigService] getTenantConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ Optional config = tenantConfigRepository.findByTenantKeyAndConfigKey(tenantKey, configKey);
+
+ log.info("[TenantConfigService] getTenantConfiguration - found={}, tenantKey={}, configKey={}",
+ config.isPresent(), LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ return config;
+ }
+
+ /**
+ * 테넌트 설정 생성/수정
+ *
+ * 테넌트의 특정 설정을 생성하거나 수정합니다.
+ * 같은 configKey가 이미 존재하면 수정, 없으면 신규 생성합니다.
+ * 저장 후 해당 테넌트의 설정 캐시를 무효화합니다.
+ *
+ * @param tenantKey 설정을 저장할 테넌트 키
+ * @param request 설정 요청 정보 (키, 값, 타입, 설명, 암호화 여부)
+ * @return 저장된 테넌트 설정
+ */
+ @Transactional
+ @CacheEvict(value = "tenantConfigs", key = "#tenantKey")
+ public TenantConfig saveTenantConfiguration(String tenantKey, TenantConfigRequest request) {
+ log.info("[TenantConfigService] saveTenantConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), request.getConfigKey());
+
+ Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey);
+
+ // Upsert 패턴: 기존 설정 존재 여부 확인
+ Optional existingConfig = tenantConfigRepository
+ .findByTenantAndConfigKeyAndIsDeletedFalse(tenant, request.getConfigKey());
+
+ TenantConfig config;
+ if (existingConfig.isPresent()) {
+ // 기존 설정 수정 (모든 필드 업데이트)
+ config = existingConfig.get();
+ config.setConfigValue(request.getConfigValue());
+ config.setConfigType(request.getConfigType());
+ config.setDescription(request.getDescription());
+ config.setIsEncrypted(request.getIsEncrypted());
+ log.info("[TenantConfigService] saveTenantConfiguration - updated existing config");
+ } else {
+ // 신규 설정 생성
+ config = TenantConfig.builder()
+ .tenant(tenant)
+ .configKey(request.getConfigKey())
+ .configValue(request.getConfigValue())
+ .configType(request.getConfigType())
+ .description(request.getDescription())
+ .isEncrypted(request.getIsEncrypted())
+ .build();
+ log.info("[TenantConfigService] saveTenantConfiguration - created new config");
+ }
+
+ TenantConfig savedConfig = tenantConfigRepository.save(config);
+
+ log.info("[TenantConfigService] saveTenantConfiguration - success tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), request.getConfigKey());
+
+ return savedConfig;
+ }
+
+ /**
+ * 테넌트 설정 삭제 (소프트 삭제)
+ *
+ * 특정 테넌트의 설정을 삭제합니다.
+ * 물리적 삭제가 아닌 isDeleted 플래그를 true로 변경하는 소프트 삭제입니다.
+ * 삭제 후 해당 테넌트의 설정 캐시를 무효화합니다.
+ *
+ * @param tenantKey 삭제할 설정이 속한 테넌트 키
+ * @param configKey 삭제할 설정 키
+ * @throws ResourceNotFoundException 설정을 찾을 수 없는 경우
+ */
+ @Transactional
+ @CacheEvict(value = "tenantConfigs", key = "#tenantKey")
+ public void deleteTenantConfiguration(String tenantKey, String configKey) {
+ log.info("[TenantConfigService] deleteTenantConfiguration - tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+
+ Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey);
+
+ // 설정 존재 확인 후 소프트 삭제
+ TenantConfig config = tenantConfigRepository
+ .findByTenantAndConfigKeyAndIsDeletedFalse(tenant, configKey)
+ .orElseThrow(() -> new ResourceNotFoundException("TenantConfig", "configKey", configKey));
+
+ // 소프트 삭제: isDeleted 플래그만 변경 (데이터 보존)
+ config.setIsDeleted(true);
+ tenantConfigRepository.save(config);
+
+ log.info("[TenantConfigService] deleteTenantConfiguration - success tenantKey={}, configKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configKey);
+ }
+
+ /**
+ * 테넌트의 모든 설정 일괄 삭제 (소프트 삭제)
+ *
+ * 특정 테넌트의 모든 개별 설정을 일괄 삭제합니다.
+ * 소프트 삭제 방식으로 데이터는 보존되며, 삭제 후 캐시를 무효화합니다.
+ * 테넌트 탈퇴나 초기화 시 사용됩니다.
+ *
+ * @param tenantKey 모든 설정을 삭제할 테넌트 키
+ */
+ @Transactional
+ @CacheEvict(value = "tenantConfigs", key = "#tenantKey")
+ public void deleteAllTenantConfigurations(String tenantKey) {
+ log.info("[TenantConfigService] deleteAllTenantConfigurations - tenantKey={}",
+ LogMaskingUtils.maskTenantKey(tenantKey));
+
+ Tenant tenant = tenantService.getTenantByKeyOrThrow(tenantKey);
+
+ // 삭제되지 않은 모든 설정 조회
+ List configs = tenantConfigRepository.findByTenantAndIsDeletedFalse(tenant);
+
+ // 일괄 소프트 삭제 처리
+ for (TenantConfig config : configs) {
+ config.setIsDeleted(true);
+ }
+
+ // 배치 업데이트로 성능 최적화
+ tenantConfigRepository.saveAll(configs);
+
+ log.info("[TenantConfigService] deleteAllTenantConfigurations - success tenantKey={}, count={}",
+ LogMaskingUtils.maskTenantKey(tenantKey), configs.size());
+ }
+}
+
+
+
diff --git a/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java b/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java
index 944cfd8d..d82d128f 100644
--- a/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java
+++ b/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java
@@ -10,7 +10,16 @@
@SpringBootTest(properties = {
"app.redis.enabled=false",
- "spring.cache.type=simple"
+ "spring.cache.type=simple",
+ "spring.datasource.url=jdbc:h2:mem:testdb",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "logging.level.org.springframework.web=WARN",
+ "logging.level.org.hibernate=WARN",
+ "logging.level.org.springframework.boot.autoconfigure=WARN",
+ "logging.level.org.springframework.context=WARN",
+ "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.redis.RedisAutoConfiguration"
})
@ActiveProfiles("test")
@Import(MockAdaptersConfig.class)
@@ -28,4 +37,4 @@ void contextLoads() {
// 애플리케이션 컨텍스트가 정상적으로 로드되는지 테스트
// Redis가 비활성화된 상태에서도 정상 동작하는지 확인
}
-}
+}
\ No newline at end of file
diff --git a/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java b/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java
index 59e9ba26..ab816b3c 100644
--- a/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java
+++ b/src/test/java/com/agenticcp/core/domain/security/controller/SecurityScenarioIntegrationTest.java
@@ -110,6 +110,4 @@ void permissions_Called_Twice_ShouldOk() throws Exception {
.andExpect(status().isOk());
}
}
-}
-
-
+}
\ No newline at end of file
diff --git a/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceServiceTest.java b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceServiceTest.java
new file mode 100644
index 00000000..8c19677b
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigInheritanceServiceTest.java
@@ -0,0 +1,249 @@
+package com.agenticcp.core.domain.tenant.service;
+
+import com.agenticcp.core.domain.platform.entity.PlatformConfig;
+import com.agenticcp.core.domain.platform.service.PlatformConfigService;
+import com.agenticcp.core.domain.tenant.dto.EffectiveConfigResponse;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import com.agenticcp.core.domain.tenant.entity.TenantTypeConfig;
+import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository;
+import com.agenticcp.core.domain.tenant.repository.TenantTypeConfigRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+/**
+ * 테넌트 설정 상속 서비스 테스트
+ */
+@ExtendWith(MockitoExtension.class)
+class TenantConfigInheritanceServiceTest {
+
+ @Mock
+ private PlatformConfigService platformConfigService;
+
+ @Mock
+ private TenantService tenantService;
+
+ @Mock
+ private TenantConfigRepository tenantConfigRepository;
+
+ @Mock
+ private TenantTypeConfigRepository tenantTypeConfigRepository;
+
+ @InjectMocks
+ private TenantConfigInheritanceService tenantConfigInheritanceService;
+
+ private Tenant testTenant;
+ private String tenantKey = "test-tenant";
+
+ @BeforeEach
+ void setUp() {
+ testTenant = Tenant.builder()
+ .tenantKey(tenantKey)
+ .tenantName("Test Tenant")
+ .tenantType(Tenant.TenantType.ENTERPRISE)
+ .build();
+ }
+
+ @Test
+ void getEffectiveConfigurations_플랫폼설정만있는경우() {
+ // Given
+ PlatformConfig platformConfig = PlatformConfig.builder()
+ .configKey("max_file_size")
+ .configValue("100")
+ .configType(PlatformConfig.ConfigType.NUMBER)
+ .description("Maximum file size in MB")
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(platformConfigService.getAllConfigs()).thenReturn(List.of(platformConfig));
+ when(tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE))
+ .thenReturn(List.of());
+ when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant))
+ .thenReturn(List.of());
+
+ // When
+ EffectiveConfigResponse response = tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey);
+
+ // Then
+ assertThat(response.getTenantKey()).isEqualTo(tenantKey);
+ assertThat(response.getConfigurations()).hasSize(1);
+ assertThat(response.getConfigurations().get("max_file_size")).isNotNull();
+ assertThat(response.getConfigurations().get("max_file_size").getValue()).isEqualTo(100L);
+ assertThat(response.getConfigurations().get("max_file_size").getSource())
+ .isEqualTo(EffectiveConfigResponse.ConfigSource.PLATFORM);
+ }
+
+ @Test
+ void getEffectiveConfigurations_테넌트타입설정오버라이드() {
+ // Given
+ PlatformConfig platformConfig = PlatformConfig.builder()
+ .configKey("max_users")
+ .configValue("50")
+ .configType(PlatformConfig.ConfigType.NUMBER)
+ .description("Default max users")
+ .build();
+
+ TenantTypeConfig typeConfig = TenantTypeConfig.builder()
+ .tenantType(Tenant.TenantType.ENTERPRISE)
+ .configKey("max_users")
+ .configValue("1000")
+ .configType(TenantTypeConfig.ConfigType.NUMBER)
+ .description("Enterprise max users")
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(platformConfigService.getAllConfigs()).thenReturn(List.of(platformConfig));
+ when(tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE))
+ .thenReturn(List.of(typeConfig));
+ when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant))
+ .thenReturn(List.of());
+
+ // When
+ EffectiveConfigResponse response = tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey);
+
+ // Then
+ assertThat(response.getConfigurations().get("max_users").getValue()).isEqualTo(1000L);
+ assertThat(response.getConfigurations().get("max_users").getSource())
+ .isEqualTo(EffectiveConfigResponse.ConfigSource.TENANT_TYPE);
+ }
+
+ @Test
+ void getEffectiveConfigurations_테넌트설정최종오버라이드() {
+ // Given
+ PlatformConfig platformConfig = PlatformConfig.builder()
+ .configKey("max_users")
+ .configValue("50")
+ .configType(PlatformConfig.ConfigType.NUMBER)
+ .build();
+
+ TenantTypeConfig typeConfig = TenantTypeConfig.builder()
+ .tenantType(Tenant.TenantType.ENTERPRISE)
+ .configKey("max_users")
+ .configValue("1000")
+ .configType(TenantTypeConfig.ConfigType.NUMBER)
+ .build();
+
+ TenantConfig tenantConfig = TenantConfig.builder()
+ .tenant(testTenant)
+ .configKey("max_users")
+ .configValue("2000")
+ .configType(TenantConfig.ConfigType.NUMBER)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(platformConfigService.getAllConfigs()).thenReturn(List.of(platformConfig));
+ when(tenantTypeConfigRepository.findByTenantTypeAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE))
+ .thenReturn(List.of(typeConfig));
+ when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant))
+ .thenReturn(List.of(tenantConfig));
+
+ // When
+ EffectiveConfigResponse response = tenantConfigInheritanceService.getEffectiveConfigurations(tenantKey);
+
+ // Then
+ assertThat(response.getConfigurations().get("max_users").getValue()).isEqualTo(2000L);
+ assertThat(response.getConfigurations().get("max_users").getSource())
+ .isEqualTo(EffectiveConfigResponse.ConfigSource.TENANT);
+ }
+
+ @Test
+ void getEffectiveConfiguration_개별설정조회() {
+ // Given
+ TenantConfig tenantConfig = TenantConfig.builder()
+ .tenant(testTenant)
+ .configKey("custom_setting")
+ .configValue("custom_value")
+ .configType(TenantConfig.ConfigType.STRING)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "custom_setting"))
+ .thenReturn(Optional.of(tenantConfig));
+
+ // When
+ Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "custom_setting");
+
+ // Then
+ assertThat(value).isEqualTo("custom_value");
+ }
+
+ @Test
+ void getEffectiveConfiguration_타입설정조회() {
+ // Given
+ TenantTypeConfig typeConfig = TenantTypeConfig.builder()
+ .tenantType(Tenant.TenantType.ENTERPRISE)
+ .configKey("type_setting")
+ .configValue("type_value")
+ .configType(TenantTypeConfig.ConfigType.STRING)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "type_setting"))
+ .thenReturn(Optional.empty());
+ when(tenantTypeConfigRepository.findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE, "type_setting"))
+ .thenReturn(Optional.of(typeConfig));
+
+ // When
+ Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "type_setting");
+
+ // Then
+ assertThat(value).isEqualTo("type_value");
+ }
+
+ @Test
+ void getEffectiveConfiguration_플랫폼설정조회() {
+ // Given
+ PlatformConfig platformConfig = PlatformConfig.builder()
+ .configKey("platform_setting")
+ .configValue("platform_value")
+ .configType(PlatformConfig.ConfigType.STRING)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "platform_setting"))
+ .thenReturn(Optional.empty());
+ when(tenantTypeConfigRepository.findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE, "platform_setting"))
+ .thenReturn(Optional.empty());
+ when(platformConfigService.getConfigByKey("platform_setting"))
+ .thenReturn(Optional.of(platformConfig));
+
+ // When
+ Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "platform_setting");
+
+ // Then
+ assertThat(value).isEqualTo("platform_value");
+ }
+
+ @Test
+ void getEffectiveConfiguration_설정이없는경우() {
+ // Given
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "non_existent"))
+ .thenReturn(Optional.empty());
+ when(tenantTypeConfigRepository.findByTenantTypeAndConfigKeyAndIsDeletedFalse(Tenant.TenantType.ENTERPRISE, "non_existent"))
+ .thenReturn(Optional.empty());
+ when(platformConfigService.getConfigByKey("non_existent"))
+ .thenReturn(Optional.empty());
+
+ // When
+ Object value = tenantConfigInheritanceService.getEffectiveConfiguration(tenantKey, "non_existent");
+
+ // Then
+ assertThat(value).isNull();
+ }
+}
+
+
+
diff --git a/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigServiceTest.java b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigServiceTest.java
new file mode 100644
index 00000000..ea07745f
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/tenant/service/TenantConfigServiceTest.java
@@ -0,0 +1,243 @@
+package com.agenticcp.core.domain.tenant.service;
+
+import com.agenticcp.core.common.exception.ResourceNotFoundException;
+import com.agenticcp.core.domain.tenant.dto.TenantConfigRequest;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.tenant.entity.TenantConfig;
+import com.agenticcp.core.domain.tenant.repository.TenantConfigRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+/**
+ * 테넌트 설정 서비스 테스트
+ */
+@ExtendWith(MockitoExtension.class)
+class TenantConfigServiceTest {
+
+ @Mock
+ private TenantConfigRepository tenantConfigRepository;
+
+ @Mock
+ private TenantService tenantService;
+
+ @InjectMocks
+ private TenantConfigService tenantConfigService;
+
+ private Tenant testTenant;
+ private String tenantKey = "test-tenant";
+
+ @BeforeEach
+ void setUp() {
+ testTenant = Tenant.builder()
+ .tenantKey(tenantKey)
+ .tenantName("Test Tenant")
+ .tenantType(Tenant.TenantType.ENTERPRISE)
+ .build();
+ }
+
+ @Test
+ void getTenantConfigurations_성공() {
+ // Given
+ TenantConfig config1 = TenantConfig.builder()
+ .configKey("setting1")
+ .configValue("value1")
+ .configType(TenantConfig.ConfigType.STRING)
+ .build();
+
+ TenantConfig config2 = TenantConfig.builder()
+ .configKey("setting2")
+ .configValue("value2")
+ .configType(TenantConfig.ConfigType.NUMBER)
+ .build();
+
+ when(tenantConfigRepository.findByTenantKey(tenantKey))
+ .thenReturn(List.of(config1, config2));
+
+ // When
+ List result = tenantConfigService.getTenantConfigurations(tenantKey);
+
+ // Then
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).getConfigKey()).isEqualTo("setting1");
+ assertThat(result.get(1).getConfigKey()).isEqualTo("setting2");
+ }
+
+ @Test
+ void getTenantConfiguration_존재하는설정() {
+ // Given
+ TenantConfig config = TenantConfig.builder()
+ .configKey("setting1")
+ .configValue("value1")
+ .configType(TenantConfig.ConfigType.STRING)
+ .build();
+
+ when(tenantConfigRepository.findByTenantKeyAndConfigKey(tenantKey, "setting1"))
+ .thenReturn(Optional.of(config));
+
+ // When
+ Optional result = tenantConfigService.getTenantConfiguration(tenantKey, "setting1");
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().getConfigKey()).isEqualTo("setting1");
+ }
+
+ @Test
+ void getTenantConfiguration_존재하지않는설정() {
+ // Given
+ when(tenantConfigRepository.findByTenantKeyAndConfigKey(tenantKey, "non_existent"))
+ .thenReturn(Optional.empty());
+
+ // When
+ Optional result = tenantConfigService.getTenantConfiguration(tenantKey, "non_existent");
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void saveTenantConfiguration_새설정생성() {
+ // Given
+ TenantConfigRequest request = TenantConfigRequest.builder()
+ .configKey("new_setting")
+ .configValue("new_value")
+ .configType(TenantConfig.ConfigType.STRING)
+ .description("New setting")
+ .isEncrypted(false)
+ .build();
+
+ TenantConfig savedConfig = TenantConfig.builder()
+ .tenant(testTenant)
+ .configKey("new_setting")
+ .configValue("new_value")
+ .configType(TenantConfig.ConfigType.STRING)
+ .description("New setting")
+ .isEncrypted(false)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "new_setting"))
+ .thenReturn(Optional.empty());
+ when(tenantConfigRepository.save(any(TenantConfig.class))).thenReturn(savedConfig);
+
+ // When
+ TenantConfig result = tenantConfigService.saveTenantConfiguration(tenantKey, request);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getConfigKey()).isEqualTo("new_setting");
+ assertThat(result.getConfigValue()).isEqualTo("new_value");
+ }
+
+ @Test
+ void saveTenantConfiguration_기존설정수정() {
+ // Given
+ TenantConfigRequest request = TenantConfigRequest.builder()
+ .configKey("existing_setting")
+ .configValue("updated_value")
+ .configType(TenantConfig.ConfigType.STRING)
+ .description("Updated setting")
+ .isEncrypted(false)
+ .build();
+
+ TenantConfig existingConfig = TenantConfig.builder()
+ .tenant(testTenant)
+ .configKey("existing_setting")
+ .configValue("old_value")
+ .configType(TenantConfig.ConfigType.STRING)
+ .description("Old setting")
+ .isEncrypted(false)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "existing_setting"))
+ .thenReturn(Optional.of(existingConfig));
+ when(tenantConfigRepository.save(existingConfig)).thenReturn(existingConfig);
+
+ // When
+ TenantConfig result = tenantConfigService.saveTenantConfiguration(tenantKey, request);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getConfigValue()).isEqualTo("updated_value");
+ assertThat(result.getDescription()).isEqualTo("Updated setting");
+ }
+
+ @Test
+ void deleteTenantConfiguration_성공() {
+ // Given
+ TenantConfig config = TenantConfig.builder()
+ .tenant(testTenant)
+ .configKey("setting_to_delete")
+ .configValue("value")
+ .configType(TenantConfig.ConfigType.STRING)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "setting_to_delete"))
+ .thenReturn(Optional.of(config));
+ when(tenantConfigRepository.save(config)).thenReturn(config);
+
+ // When
+ tenantConfigService.deleteTenantConfiguration(tenantKey, "setting_to_delete");
+
+ // Then
+ assertThat(config.getIsDeleted()).isTrue();
+ }
+
+ @Test
+ void deleteTenantConfiguration_존재하지않는설정() {
+ // Given
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndConfigKeyAndIsDeletedFalse(testTenant, "non_existent"))
+ .thenReturn(Optional.empty());
+
+ // When & Then
+ assertThatThrownBy(() -> tenantConfigService.deleteTenantConfiguration(tenantKey, "non_existent"))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ @Test
+ void deleteAllTenantConfigurations_성공() {
+ // Given
+ TenantConfig config1 = TenantConfig.builder()
+ .tenant(testTenant)
+ .configKey("setting1")
+ .configValue("value1")
+ .configType(TenantConfig.ConfigType.STRING)
+ .build();
+
+ TenantConfig config2 = TenantConfig.builder()
+ .tenant(testTenant)
+ .configKey("setting2")
+ .configValue("value2")
+ .configType(TenantConfig.ConfigType.NUMBER)
+ .build();
+
+ when(tenantService.getTenantByKeyOrThrow(tenantKey)).thenReturn(testTenant);
+ when(tenantConfigRepository.findByTenantAndIsDeletedFalse(testTenant))
+ .thenReturn(List.of(config1, config2));
+ when(tenantConfigRepository.saveAll(List.of(config1, config2)))
+ .thenReturn(List.of(config1, config2));
+
+ // When
+ tenantConfigService.deleteAllTenantConfigurations(tenantKey);
+
+ // Then
+ assertThat(config1.getIsDeleted()).isTrue();
+ assertThat(config2.getIsDeleted()).isTrue();
+ }
+}
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 68b07f87..742d4003 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -2,13 +2,13 @@ spring:
autoconfigure:
exclude:
- org.redisson.spring.starter.RedissonAutoConfiguration
-
+
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL
driver-class-name: org.h2.Driver
username: sa
- password:
-
+ password:
+
jpa:
hibernate:
ddl-auto: create-drop
@@ -16,14 +16,14 @@ spring:
properties:
hibernate:
format_sql: false
-
+
cache:
type: simple
-
+
redis:
host: ${SPRING_REDIS_HOST:localhost}
port: ${SPRING_REDIS_PORT:6379}
- password:
+ password:
timeout: 2000ms
lettuce:
pool: