diff --git a/[Help b/[Help
new file mode 100644
index 000000000..e69de29bb
diff --git a/pom.xml b/pom.xml
index 50a19e655..05a4c20e2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -311,6 +311,22 @@
${project.version}
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ --add-opens java.base/java.lang=ALL-UNNAMED
+ --add-opens java.base/java.util=ALL-UNNAMED
+ --add-opens java.base/java.lang.reflect=ALL-UNNAMED
+ --add-opens java.base/java.text=ALL-UNNAMED
+ --add-opens java.desktop/java.awt.font=ALL-UNNAMED
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java b/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java
index 5259464f7..a27eeb5e2 100644
--- a/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java
+++ b/src/main/java/com/agenticcp/core/AgenticCpCoreApplication.java
@@ -2,12 +2,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
-@EnableJpaRepositories
@EnableAsync
@EnableScheduling
public class AgenticCpCoreApplication {
diff --git a/src/main/java/com/agenticcp/core/common/config/JpaConfig.java b/src/main/java/com/agenticcp/core/common/config/JpaConfig.java
index 13324c430..196511a46 100644
--- a/src/main/java/com/agenticcp/core/common/config/JpaConfig.java
+++ b/src/main/java/com/agenticcp/core/common/config/JpaConfig.java
@@ -1,9 +1,52 @@
package com.agenticcp.core.common.config;
+import com.agenticcp.core.common.interceptor.TenantAwareInterceptor;
+import com.agenticcp.core.common.repository.TenantAwareRepositoryImpl;
+import org.hibernate.cfg.AvailableSettings;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
+import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+/**
+ * JPA 설정 클래스
+ * Hibernate Interceptor를 통한 테넌트 데이터 격리 설정
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-01
+ */
@Configuration
@EnableJpaAuditing
+@EnableJpaRepositories(
+ basePackages = {"com.agenticcp.core.domain", "com.agenticcp.core.common.repository"},
+ repositoryBaseClass = TenantAwareRepositoryImpl.class
+)
public class JpaConfig {
+
+ @Autowired
+ private TenantAwareInterceptor tenantAwareInterceptor;
+
+ /**
+ * Hibernate 속성 커스터마이저
+ * StatementInspector를 통해 SQL 쿼리 인터셉션 설정
+ *
+ * @return HibernatePropertiesCustomizer
+ */
+ @Bean
+ public HibernatePropertiesCustomizer hibernatePropertiesCustomizer() {
+ return hibernateProperties -> {
+ // StatementInspector 설정 (Hibernate 5.4+)
+ hibernateProperties.put(AvailableSettings.STATEMENT_INSPECTOR, tenantAwareInterceptor);
+
+ // Interceptor 설정 (추가적인 엔티티 레벨 처리용)
+ hibernateProperties.put(AvailableSettings.INTERCEPTOR, tenantAwareInterceptor);
+
+ // SQL 로깅 활성화 (개발 환경에서 테넌트 필터링 확인용)
+ hibernateProperties.put("hibernate.show_sql", false);
+ hibernateProperties.put("hibernate.format_sql", true);
+ };
+ }
}
diff --git a/src/main/java/com/agenticcp/core/common/entity/BaseEntity.java b/src/main/java/com/agenticcp/core/common/entity/BaseEntity.java
index 41fddd320..b34649a0c 100644
--- a/src/main/java/com/agenticcp/core/common/entity/BaseEntity.java
+++ b/src/main/java/com/agenticcp/core/common/entity/BaseEntity.java
@@ -1,86 +1,71 @@
package com.agenticcp.core.common.entity;
import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
+/**
+ * 기본 엔티티 - 테넌트 정보 없음
+ *
+ *
+ * 전역 기능(플랫폼 설정, 클라우드 제공자 등)에서 사용하는 베이스 엔티티입니다.
+ * 모든 엔티티의 공통 필드(ID, 생성/수정 정보, 삭제 플래그)를 제공합니다.
+ *
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-24
+ */
+@Getter
+@Setter
@MappedSuperclass
-@EntityListeners({
- AuditingEntityListener.class,
- com.agenticcp.core.common.audit.AuditEntityListener.class
-})
+@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
+ /**
+ * 엔티티 고유 식별자
+ */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * 엔티티 생성 시각
+ * Spring Data JPA Auditing에 의해 자동으로 설정됩니다.
+ */
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
+ /**
+ * 엔티티 최종 수정 시각
+ * Spring Data JPA Auditing에 의해 자동으로 설정됩니다.
+ */
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
+ /**
+ * 엔티티 생성자 식별자 (사용자명 또는 시스템 ID)
+ */
@Column(name = "created_by")
private String createdBy;
+ /**
+ * 엔티티 최종 수정자 식별자 (사용자명 또는 시스템 ID)
+ */
@Column(name = "updated_by")
private String updatedBy;
+ /**
+ * 논리 삭제 플래그
+ * true인 경우 삭제된 것으로 간주합니다.
+ */
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
-
- // Getters and Setters
- public Long getId() {
- return id;
- }
-
- public void setId(Long id) {
- this.id = id;
- }
-
- public LocalDateTime getCreatedAt() {
- return createdAt;
- }
-
- public void setCreatedAt(LocalDateTime createdAt) {
- this.createdAt = createdAt;
- }
-
- public LocalDateTime getUpdatedAt() {
- return updatedAt;
- }
-
- public void setUpdatedAt(LocalDateTime updatedAt) {
- this.updatedAt = updatedAt;
- }
-
- public String getCreatedBy() {
- return createdBy;
- }
-
- public void setCreatedBy(String createdBy) {
- this.createdBy = createdBy;
- }
-
- public String getUpdatedBy() {
- return updatedBy;
- }
-
- public void setUpdatedBy(String updatedBy) {
- this.updatedBy = updatedBy;
- }
-
- public Boolean getIsDeleted() {
- return isDeleted;
- }
-
- public void setIsDeleted(Boolean isDeleted) {
- this.isDeleted = isDeleted;
- }
}
diff --git a/src/main/java/com/agenticcp/core/common/entity/TenantAwareEntity.java b/src/main/java/com/agenticcp/core/common/entity/TenantAwareEntity.java
new file mode 100644
index 000000000..c7e3ddb2f
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/common/entity/TenantAwareEntity.java
@@ -0,0 +1,59 @@
+package com.agenticcp.core.common.entity;
+
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+/**
+ * 테넌트 인식 엔티티 - 테넌트 정보 포함
+ *
+ *
+ * 테넌트별 격리가 필요한 기능(사용자, 조직, 리소스 등)에서 사용하는 베이스 엔티티입니다.
+ * BaseEntity를 상속받아 공통 필드를 포함하며, 추가로 테넌트 정보를 관리합니다.
+ *
+ *
+ *
+ * TenantAwareEntityListener를 통해 테넌트 컨텍스트 기반의 자동 설정 및
+ * 멀티 테넌시 데이터 격리를 지원합니다.
+ *
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-24
+ */
+@Getter
+@Setter
+@MappedSuperclass
+@EntityListeners({AuditingEntityListener.class, TenantAwareEntityListener.class})
+public abstract class TenantAwareEntity extends BaseEntity {
+
+ /**
+ * 엔티티가 속한 테넌트
+ * 멀티 테넌시 환경에서 데이터 격리를 위해 사용됩니다.
+ */
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tenant_id", nullable = false)
+ private Tenant tenant;
+
+ /**
+ * 엔티티가 특정 테넌트에 속하는지 확인합니다.
+ *
+ * @param tenant 확인할 테넌트 객체
+ * @return 엔티티가 해당 테넌트에 속하면 true, 아니면 false
+ */
+ public boolean belongsToTenant(Tenant tenant) {
+ return this.tenant != null && this.tenant.equals(tenant);
+ }
+
+ /**
+ * 엔티티가 특정 테넌트 ID에 속하는지 확인합니다.
+ *
+ * @param tenantId 확인할 테넌트 ID
+ * @return 엔티티가 해당 테넌트 ID에 속하면 true, 아니면 false
+ */
+ public boolean belongsToTenant(Long tenantId) {
+ return this.tenant != null && this.tenant.getId().equals(tenantId);
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/common/entity/TenantAwareEntityListener.java b/src/main/java/com/agenticcp/core/common/entity/TenantAwareEntityListener.java
new file mode 100644
index 000000000..957b6f2e6
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/common/entity/TenantAwareEntityListener.java
@@ -0,0 +1,112 @@
+package com.agenticcp.core.common.entity;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.enums.CommonErrorCode;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.PreUpdate;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 테넌트 인식 엔티티 리스너
+ *
+ *
+ * JPA 엔티티 생명주기 콜백을 통해 TenantAwareEntity를 상속받은 엔티티에 대해
+ * 자동으로 현재 테넌트 정보를 주입합니다.
+ *
+ *
+ *
+ * 멀티 테넌시 환경에서 데이터 격리를 보장하기 위해 엔티티 저장/수정 시
+ * 반드시 테넌트 컨텍스트가 설정되어 있어야 합니다.
+ *
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-24
+ */
+@Slf4j
+public class TenantAwareEntityListener {
+
+ /**
+ * 엔티티 저장 전에 테넌트 정보를 자동으로 설정합니다.
+ *
+ *
+ * 엔티티에 테넌트 정보가 설정되지 않은 경우,
+ * TenantContextHolder에서 현재 테넌트 정보를 가져와 자동으로 설정합니다.
+ *
+ *
+ * @param entity 저장할 엔티티 (TenantAwareEntity를 상속받은 객체)
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @PrePersist
+ public void prePersist(Object entity) {
+ if (entity instanceof TenantAwareEntity tenantAwareEntity) {
+ setTenantIfNotSet(tenantAwareEntity, "prePersist");
+ }
+ }
+
+ /**
+ * 엔티티 수정 전에 테넌트 정보를 검증합니다.
+ *
+ *
+ * 엔티티에 테넌트 정보가 설정되지 않은 경우,
+ * TenantContextHolder에서 현재 테넌트 정보를 가져와 자동으로 설정합니다.
+ * (일반적으로 수정 시에는 이미 테넌트가 설정되어 있어야 합니다)
+ *
+ *
+ * @param entity 수정할 엔티티 (TenantAwareEntity를 상속받은 객체)
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @PreUpdate
+ public void preUpdate(Object entity) {
+ if (entity instanceof TenantAwareEntity tenantAwareEntity) {
+ setTenantIfNotSet(tenantAwareEntity, "preUpdate");
+ }
+ }
+
+ /**
+ * 엔티티에 테넌트 정보가 설정되지 않은 경우 현재 컨텍스트의 테넌트 정보를 설정합니다.
+ *
+ *
+ * 이미 테넌트가 설정된 경우 건너뛰며, 테넌트 컨텍스트가 설정되지 않은 경우
+ * BusinessException을 발생시킵니다.
+ *
+ *
+ * @param tenantAwareEntity 테넌트 정보를 설정할 엔티티
+ * @param operation 수행 중인 작업 (prePersist 또는 preUpdate, 로깅용)
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우 (TENANT_CONTEXT_NOT_SET)
+ */
+ private void setTenantIfNotSet(TenantAwareEntity tenantAwareEntity, String operation) {
+ // 이미 테넌트가 설정되어 있으면 건너뛰기
+ if (tenantAwareEntity.getTenant() != null) {
+ log.debug("테넌트가 이미 설정됨: entity={}, operation={}",
+ tenantAwareEntity.getClass().getSimpleName(), operation);
+ return;
+ }
+
+ try {
+ // 현재 테넌트 컨텍스트에서 테넌트 정보 조회
+ Tenant currentTenant = TenantContextHolder.getCurrentTenantOrThrow();
+
+ // 테넌트 정보 설정
+ tenantAwareEntity.setTenant(currentTenant);
+
+ log.debug("테넌트 자동 설정 완료: tenantKey={}, entity={}, operation={}",
+ currentTenant.getTenantKey(),
+ tenantAwareEntity.getClass().getSimpleName(),
+ operation);
+
+ } catch (BusinessException e) {
+ // 테넌트 컨텍스트가 설정되지 않은 경우
+ log.error("테넌트 설정 실패: entity={}, operation={}, error={}",
+ tenantAwareEntity.getClass().getSimpleName(),
+ operation,
+ e.getMessage());
+
+ // 테넌트 컨텍스트가 필수인 경우 예외 발생
+ throw new BusinessException(CommonErrorCode.TENANT_CONTEXT_NOT_SET,
+ "Tenant context is required for entity operations");
+ }
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/common/interceptor/TenantAwareInterceptor.java b/src/main/java/com/agenticcp/core/common/interceptor/TenantAwareInterceptor.java
new file mode 100644
index 000000000..799b6c4e0
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/common/interceptor/TenantAwareInterceptor.java
@@ -0,0 +1,476 @@
+package com.agenticcp.core.common.interceptor;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.enums.CommonErrorCode;
+import com.agenticcp.core.common.exception.BusinessException;
+import lombok.extern.slf4j.Slf4j;
+import org.hibernate.Interceptor;
+import org.hibernate.resource.jdbc.spi.StatementInspector;
+import org.springframework.stereotype.Component;
+
+import java.io.Serializable;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 테넌트 인식 Hibernate Interceptor
+ *
+ *
+ * Hibernate의 Interceptor와 StatementInspector를 구현하여 모든 SQL 쿼리를 가로채고,
+ * 자동으로 tenant_id 조건을 추가하여 멀티 테넌시 환경에서 데이터 격리를 보장합니다.
+ *
+ *
+ *
+ * 주요 기능:
+ * - SELECT 쿼리: WHERE 절에 tenant_id 필터 자동 추가
+ * - UPDATE 쿼리: WHERE 절에 tenant_id 필터 자동 추가
+ * - DELETE 쿼리: WHERE 절에 tenant_id 필터 자동 추가
+ * - INSERT 쿼리: tenant_id 컬럼과 값 자동 주입
+ *
+ *
+ *
+ * 이 Interceptor는 Repository 계층의 테넌트 필터링을 보완하여
+ * 네이티브 쿼리나 직접 SQL 실행 시에도 데이터 격리를 보장합니다.
+ *
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-24
+ * @see org.hibernate.Interceptor
+ * @see org.hibernate.resource.jdbc.spi.StatementInspector
+ * @see com.agenticcp.core.common.context.TenantContextHolder
+ */
+@Slf4j
+@Component
+public class TenantAwareInterceptor implements Interceptor, StatementInspector {
+
+ /**
+ * SELECT 쿼리 패턴 매칭을 위한 정규식
+ * 예: SELECT * FROM users
+ */
+ private static final Pattern SELECT_PATTERN = Pattern.compile(
+ "(?i)\\bSELECT\\b.*?\\bFROM\\b\\s+(\\w+)",
+ Pattern.CASE_INSENSITIVE | Pattern.DOTALL
+ );
+
+ /**
+ * UPDATE 쿼리 패턴 매칭을 위한 정규식
+ * 예: UPDATE users SET name = 'John'
+ */
+ private static final Pattern UPDATE_PATTERN = Pattern.compile(
+ "(?i)\\bUPDATE\\b\\s+(\\w+)\\s+\\bSET\\b",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ /**
+ * DELETE 쿼리 패턴 매칭을 위한 정규식
+ * 예: DELETE FROM users
+ */
+ private static final Pattern DELETE_PATTERN = Pattern.compile(
+ "(?i)\\bDELETE\\b\\s+\\bFROM\\b\\s+(\\w+)",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ /**
+ * INSERT 쿼리 패턴 매칭을 위한 정규식
+ * 예: INSERT INTO users (name, email)
+ */
+ private static final Pattern INSERT_PATTERN = Pattern.compile(
+ "(?i)\\bINSERT\\b\\s+\\bINTO\\b\\s+(\\w+)\\s*\\(",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ /**
+ * SQL 쿼리를 검사하고 테넌트 필터링을 적용합니다.
+ *
+ *
+ * Hibernate가 SQL을 실행하기 직전에 호출되어 모든 쿼리에 테넌트 컨텍스트를 적용합니다.
+ * TenantContextHolder에서 현재 테넌트 정보를 가져와 SQL 쿼리에 자동으로 주입합니다.
+ *
+ *
+ *
+ * 처리 흐름:
+ * 1. SQL이 null이거나 비어있으면 그대로 반환
+ * 2. 테넌트 컨텍스트 존재 여부 확인
+ * 3. 현재 테넌트 키 조회
+ * 4. SQL 타입(SELECT/UPDATE/DELETE/INSERT)에 따라 처리
+ * 5. 수정된 SQL 반환
+ *
+ *
+ * @param sql 원본 SQL 쿼리
+ * @return 테넌트 필터링이 적용된 SQL 쿼리
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않았거나 SQL 처리 중 오류 발생 시
+ */
+ @Override
+ public String inspect(String sql) {
+ // null 또는 빈 SQL은 그대로 반환
+ if (sql == null || sql.trim().isEmpty()) {
+ return sql;
+ }
+
+ try {
+ // 현재 테넌트 컨텍스트 확인
+ if (!TenantContextHolder.hasTenantContext()) {
+ log.warn("테넌트 컨텍스트 없음 - SQL 실행 허용: sql={}", sql.substring(0, Math.min(50, sql.length())));
+ return sql;
+ }
+
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+ log.debug("SQL 인터셉트 시작: tenantKey={}, sqlType={}",
+ tenantKey, getSqlType(sql));
+
+ // SQL 타입에 따라 테넌트 필터링 적용
+ String modifiedSql = modifySqlForTenant(sql, tenantKey);
+
+ // SQL이 수정되었으면 로그 기록
+ if (!sql.equals(modifiedSql)) {
+ log.debug("SQL 수정 완료: tenantKey={}, original={}, modified={}",
+ tenantKey,
+ sql.substring(0, Math.min(50, sql.length())),
+ modifiedSql.substring(0, Math.min(50, modifiedSql.length())));
+ }
+
+ return modifiedSql;
+
+ } catch (BusinessException e) {
+ // BusinessException은 그대로 전파
+ throw e;
+ } catch (Exception e) {
+ log.error("SQL 인터셉트 중 오류 발생: sql={}, error={}",
+ sql.substring(0, Math.min(50, sql.length())), e.getMessage(), e);
+ throw new BusinessException(CommonErrorCode.TENANT_CONTEXT_NOT_SET,
+ "데이터베이스 작업을 위해 테넌트 컨텍스트가 필요합니다");
+ }
+ }
+
+ /**
+ * SQL 쿼리를 테넌트에 맞게 수정합니다.
+ *
+ *
+ * SQL 타입(SELECT/UPDATE/DELETE/INSERT)을 판단하여
+ * 각각에 맞는 테넌트 필터링 로직을 적용합니다.
+ *
+ *
+ * @param sql 원본 SQL 쿼리
+ * @param tenantKey 현재 테넌트 키
+ * @return 테넌트 필터링이 적용된 수정된 SQL 쿼리
+ */
+ private String modifySqlForTenant(String sql, String tenantKey) {
+ String trimmedSql = sql.trim();
+
+ // SELECT 쿼리 처리 - WHERE 절에 tenant_id 필터 추가
+ if (trimmedSql.toUpperCase().startsWith("SELECT")) {
+ return addTenantFilterToSelect(trimmedSql, tenantKey);
+ }
+
+ // UPDATE 쿼리 처리 - WHERE 절에 tenant_id 필터 추가
+ if (trimmedSql.toUpperCase().startsWith("UPDATE")) {
+ return addTenantFilterToUpdate(trimmedSql, tenantKey);
+ }
+
+ // DELETE 쿼리 처리 - WHERE 절에 tenant_id 필터 추가
+ if (trimmedSql.toUpperCase().startsWith("DELETE")) {
+ return addTenantFilterToDelete(trimmedSql, tenantKey);
+ }
+
+ // INSERT 쿼리 처리 - tenant_id 컬럼과 값 주입
+ if (trimmedSql.toUpperCase().startsWith("INSERT")) {
+ return addTenantToInsert(trimmedSql, tenantKey);
+ }
+
+ // 알 수 없는 SQL 타입은 그대로 반환
+ return sql;
+ }
+
+ /**
+ * SELECT 쿼리에 tenant_id 필터를 추가합니다.
+ *
+ *
+ * 정규식을 사용하여 테이블명을 추출하고, WHERE 절에 tenant_id 조건을 추가합니다.
+ * 기존 WHERE 절이 있으면 AND로 연결하고, 없으면 새로 생성합니다.
+ *
+ *
+ *
+ * 변환 예시:
+ * - 원본: SELECT * FROM users WHERE age > 18
+ * - 결과: SELECT * FROM users WHERE users.tenant_id = 'tenant1' AND age > 18
+ *
+ *
+ * @param sql 원본 SELECT 쿼리
+ * @param tenantKey 현재 테넌트 키
+ * @return tenant_id 필터가 추가된 SELECT 쿼리
+ */
+ private String addTenantFilterToSelect(String sql, String tenantKey) {
+ Matcher matcher = SELECT_PATTERN.matcher(sql);
+ if (matcher.find()) {
+ String tableName = matcher.group(1);
+
+ // WHERE 절이 이미 있는지 확인
+ if (sql.toUpperCase().contains("WHERE")) {
+ // 기존 WHERE 절에 tenant_id 조건을 맨 앞에 추가 (AND로 연결)
+ return sql.replaceFirst("(?i)\\bWHERE\\b",
+ "WHERE " + tableName + ".tenant_id = '" + tenantKey + "' AND ");
+ } else {
+ // WHERE 절이 없으면 새로 추가
+ return sql + " WHERE " + tableName + ".tenant_id = '" + tenantKey + "'";
+ }
+ }
+ return sql;
+ }
+
+ /**
+ * UPDATE 쿼리에 tenant_id 필터를 추가합니다.
+ *
+ *
+ * 정규식을 사용하여 테이블명을 추출하고, WHERE 절에 tenant_id 조건을 추가합니다.
+ * 이를 통해 다른 테넌트의 데이터가 실수로 수정되는 것을 방지합니다.
+ *
+ *
+ *
+ * 변환 예시:
+ * - 원본: UPDATE users SET name = 'John' WHERE id = 1
+ * - 결과: UPDATE users SET name = 'John' WHERE users.tenant_id = 'tenant1' AND id = 1
+ *
+ *
+ * @param sql 원본 UPDATE 쿼리
+ * @param tenantKey 현재 테넌트 키
+ * @return tenant_id 필터가 추가된 UPDATE 쿼리
+ */
+ private String addTenantFilterToUpdate(String sql, String tenantKey) {
+ Matcher matcher = UPDATE_PATTERN.matcher(sql);
+ if (matcher.find()) {
+ String tableName = matcher.group(1);
+
+ // WHERE 절이 이미 있는지 확인
+ if (sql.toUpperCase().contains("WHERE")) {
+ // 기존 WHERE 절에 tenant_id 조건을 맨 앞에 추가 (AND로 연결)
+ return sql.replaceFirst("(?i)\\bWHERE\\b",
+ "WHERE " + tableName + ".tenant_id = '" + tenantKey + "' AND ");
+ } else {
+ // WHERE 절이 없으면 새로 추가
+ return sql + " WHERE " + tableName + ".tenant_id = '" + tenantKey + "'";
+ }
+ }
+ return sql;
+ }
+
+ /**
+ * DELETE 쿼리에 tenant_id 필터를 추가합니다.
+ *
+ *
+ * 정규식을 사용하여 테이블명을 추출하고, WHERE 절에 tenant_id 조건을 추가합니다.
+ * 이를 통해 다른 테넌트의 데이터가 실수로 삭제되는 것을 방지합니다.
+ *
+ *
+ *
+ * 변환 예시:
+ * - 원본: DELETE FROM users WHERE id = 1
+ * - 결과: DELETE FROM users WHERE users.tenant_id = 'tenant1' AND id = 1
+ *
+ *
+ * @param sql 원본 DELETE 쿼리
+ * @param tenantKey 현재 테넌트 키
+ * @return tenant_id 필터가 추가된 DELETE 쿼리
+ */
+ private String addTenantFilterToDelete(String sql, String tenantKey) {
+ Matcher matcher = DELETE_PATTERN.matcher(sql);
+ if (matcher.find()) {
+ String tableName = matcher.group(1);
+
+ // WHERE 절이 이미 있는지 확인
+ if (sql.toUpperCase().contains("WHERE")) {
+ // 기존 WHERE 절에 tenant_id 조건을 맨 앞에 추가 (AND로 연결)
+ return sql.replaceFirst("(?i)\\bWHERE\\b",
+ "WHERE " + tableName + ".tenant_id = '" + tenantKey + "' AND ");
+ } else {
+ // WHERE 절이 없으면 새로 추가
+ return sql + " WHERE " + tableName + ".tenant_id = '" + tenantKey + "'";
+ }
+ }
+ return sql;
+ }
+
+ /**
+ * INSERT 쿼리에 tenant_id 컬럼과 값을 자동으로 주입합니다.
+ *
+ *
+ * 정규식을 사용하여 테이블명과 컬럼 리스트를 추출하고,
+ * tenant_id 컬럼을 맨 앞에 추가하며, VALUES 절에도 테넌트 키를 주입합니다.
+ *
+ *
+ *
+ * 변환 예시:
+ * - 원본: INSERT INTO users (name, email) VALUES ('John', 'john@example.com')
+ * - 결과: INSERT INTO users (tenant_id, name, email) VALUES ('tenant1', 'John', 'john@example.com')
+ *
+ *
+ * @param sql 원본 INSERT 쿼리
+ * @param tenantKey 현재 테넌트 키
+ * @return tenant_id가 주입된 INSERT 쿼리
+ */
+ private String addTenantToInsert(String sql, String tenantKey) {
+ Matcher matcher = INSERT_PATTERN.matcher(sql);
+ if (matcher.find()) {
+ String tableName = matcher.group(1);
+
+ // INSERT INTO table (columns) VALUES (values) 형태에서
+ // 1. columns 리스트 맨 앞에 tenant_id 추가
+ // 2. VALUES 절의 값 리스트 맨 앞에 tenant_key 추가
+ return sql.replaceFirst("(?i)\\bINSERT\\b\\s+\\bINTO\\b\\s+" + tableName + "\\s*\\(",
+ "INSERT INTO " + tableName + " (tenant_id, ")
+ .replaceFirst("(?i)\\bVALUES\\b\\s*\\(",
+ "VALUES ('" + tenantKey + "', ");
+ }
+ return sql;
+ }
+
+ /**
+ * SQL 타입을 문자열로 반환합니다 (로깅용 헬퍼 메서드).
+ *
+ * @param sql SQL 쿼리
+ * @return SQL 타입 (SELECT, INSERT, UPDATE, DELETE, UNKNOWN)
+ */
+ private String getSqlType(String sql) {
+ if (sql == null || sql.trim().isEmpty()) {
+ return "EMPTY";
+ }
+
+ String trimmedSql = sql.trim().toUpperCase();
+ if (trimmedSql.startsWith("SELECT")) {
+ return "SELECT";
+ } else if (trimmedSql.startsWith("INSERT")) {
+ return "INSERT";
+ } else if (trimmedSql.startsWith("UPDATE")) {
+ return "UPDATE";
+ } else if (trimmedSql.startsWith("DELETE")) {
+ return "DELETE";
+ } else {
+ return "UNKNOWN";
+ }
+ }
+
+ // ========== Hibernate Interceptor 인터페이스 기본 구현 ==========
+
+ /**
+ * 엔티티가 데이터베이스에서 로드될 때 호출됩니다.
+ *
+ *
+ * 현재 구현에서는 추가 처리가 필요 없으므로 false를 반환합니다.
+ *
+ *
+ * @param entity 로드된 엔티티
+ * @param id 엔티티 ID
+ * @param state 엔티티 상태 배열
+ * @param propertyNames 속성 이름 배열
+ * @param types 속성 타입 배열
+ * @return 상태가 변경되었으면 true, 아니면 false
+ */
+ @Override
+ public boolean onLoad(Object entity, Serializable id, Object[] state, String[] propertyNames, org.hibernate.type.Type[] types) {
+ return false;
+ }
+
+ /**
+ * 엔티티가 더티(dirty) 상태로 감지되어 업데이트될 때 호출됩니다.
+ *
+ *
+ * 현재 구현에서는 추가 처리가 필요 없으므로 false를 반환합니다.
+ *
+ *
+ * @param entity 업데이트될 엔티티
+ * @param id 엔티티 ID
+ * @param currentState 현재 상태 배열
+ * @param previousState 이전 상태 배열
+ * @param propertyNames 속성 이름 배열
+ * @param types 속성 타입 배열
+ * @return 상태가 변경되었으면 true, 아니면 false
+ */
+ @Override
+ public boolean onFlushDirty(Object entity, Serializable id, Object[] currentState, Object[] previousState, String[] propertyNames, org.hibernate.type.Type[] types) {
+ return false;
+ }
+
+ /**
+ * 엔티티가 저장될 때 호출됩니다.
+ *
+ *
+ * 현재 구현에서는 추가 처리가 필요 없으므로 false를 반환합니다.
+ * tenant_id는 SQL 레벨에서 자동으로 주입됩니다.
+ *
+ *
+ * @param entity 저장될 엔티티
+ * @param id 엔티티 ID
+ * @param state 엔티티 상태 배열
+ * @param propertyNames 속성 이름 배열
+ * @param types 속성 타입 배열
+ * @return 상태가 변경되었으면 true, 아니면 false
+ */
+ @Override
+ public boolean onSave(Object entity, Serializable id, Object[] state, String[] propertyNames, org.hibernate.type.Type[] types) {
+ return false;
+ }
+
+ /**
+ * 엔티티가 삭제될 때 호출됩니다.
+ *
+ *
+ * 현재 구현에서는 추가 처리가 필요 없습니다.
+ * tenant_id 필터링은 SQL 레벨에서 자동으로 적용됩니다.
+ *
+ *
+ * @param entity 삭제될 엔티티
+ * @param id 엔티티 ID
+ * @param state 엔티티 상태 배열
+ * @param propertyNames 속성 이름 배열
+ * @param types 속성 타입 배열
+ */
+ @Override
+ public void onDelete(Object entity, Serializable id, Object[] state, String[] propertyNames, org.hibernate.type.Type[] types) {
+ // tenant_id 필터링은 SQL 레벨에서 처리됨
+ }
+
+ /**
+ * 컬렉션이 삭제될 때 호출됩니다.
+ *
+ *
+ * 현재 구현에서는 추가 처리가 필요 없습니다.
+ *
+ *
+ * @param collection 삭제될 컬렉션
+ * @param key 컬렉션 키
+ */
+ @Override
+ public void onCollectionRemove(Object collection, Serializable key) {
+ // 현재 추가 처리 없음
+ }
+
+ /**
+ * 컬렉션이 재생성될 때 호출됩니다.
+ *
+ *
+ * 현재 구현에서는 추가 처리가 필요 없습니다.
+ *
+ *
+ * @param collection 재생성될 컬렉션
+ * @param key 컬렉션 키
+ */
+ @Override
+ public void onCollectionRecreate(Object collection, Serializable key) {
+ // 현재 추가 처리 없음
+ }
+
+ /**
+ * 컬렉션이 업데이트될 때 호출됩니다.
+ *
+ *
+ * 현재 구현에서는 추가 처리가 필요 없습니다.
+ *
+ *
+ * @param collection 업데이트될 컬렉션
+ * @param key 컬렉션 키
+ */
+ @Override
+ public void onCollectionUpdate(Object collection, Serializable key) {
+ // 현재 추가 처리 없음
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/common/repository/BaseRepository.java b/src/main/java/com/agenticcp/core/common/repository/BaseRepository.java
new file mode 100644
index 000000000..7d6cd9763
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/common/repository/BaseRepository.java
@@ -0,0 +1,42 @@
+package com.agenticcp.core.common.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.repository.NoRepositoryBean;
+
+/**
+ * 기본 Repository 인터페이스 - 테넌트 격리 없음
+ *
+ *
+ * 전역 기능(플랫폼 설정, 클라우드 제공자, 시스템 설정 등)에서 사용하는 베이스 리포지토리입니다.
+ * BaseEntity를 상속받은 엔티티에 대한 데이터 액세스 계층을 제공합니다.
+ *
+ *
+ *
+ * 이 인터페이스는 테넌트 필터링 없이 모든 데이터에 접근하므로,
+ * 멀티 테넌시가 필요한 경우 TenantAwareRepository를 사용해야 합니다.
+ *
+ *
+ *
+ * {@code @NoRepositoryBean} 어노테이션을 통해 Spring Data JPA가
+ * 이 인터페이스의 구현체를 생성하지 않도록 합니다.
+ *
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-24
+ * @see org.springframework.data.jpa.repository.JpaRepository
+ * @see com.agenticcp.core.common.entity.BaseEntity
+ */
+@NoRepositoryBean
+public interface BaseRepository extends JpaRepository {
+ // 기본 JPA 메서드만 제공 (상속된 메서드)
+ // - save(): 엔티티 저장
+ // - findById(): ID로 엔티티 조회
+ // - findAll(): 모든 엔티티 조회
+ // - deleteById(): ID로 엔티티 삭제
+ // - count(): 엔티티 개수 조회
+ // 등 JpaRepository의 모든 CRUD 메서드 사용 가능
+
+ // 테넌트 필터링 없이 모든 데이터에 접근
+ // 멀티 테넌시 데이터 격리가 필요한 경우 TenantAwareRepository 사용 필요
+}
diff --git a/src/main/java/com/agenticcp/core/common/repository/TenantAwareRepository.java b/src/main/java/com/agenticcp/core/common/repository/TenantAwareRepository.java
index c7da0d80a..b8bfaa27c 100644
--- a/src/main/java/com/agenticcp/core/common/repository/TenantAwareRepository.java
+++ b/src/main/java/com/agenticcp/core/common/repository/TenantAwareRepository.java
@@ -1,28 +1,53 @@
package com.agenticcp.core.common.repository;
import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
+import com.agenticcp.core.common.exception.BusinessException;
import com.agenticcp.core.domain.tenant.entity.Tenant;
-import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.NoRepositoryBean;
import java.util.List;
import java.util.Optional;
/**
- * 테넌트 인식 Repository 인터페이스
+ * 테넌트 인식 Repository 인터페이스 - 테넌트별 데이터 격리
+ *
+ *
+ * 멀티 테넌시 환경에서 테넌트별 데이터 격리를 지원하는 베이스 리포지토리입니다.
+ * TenantAwareEntity를 상속받은 엔티티에 대한 데이터 액세스 계층을 제공하며,
* 자동으로 현재 테넌트 컨텍스트를 적용하여 데이터를 필터링합니다.
+ *
+ *
+ *
+ * 이 인터페이스는 현재 테넌트 컨텍스트 기반의 메서드들을 제공하며,
+ * TenantContextHolder를 통해 자동으로 테넌트 정보를 가져와 적용합니다.
+ *
+ *
+ *
+ * {@code @NoRepositoryBean} 어노테이션을 통해 Spring Data JPA가
+ * 이 인터페이스의 구현체를 생성하지 않도록 합니다.
+ *
*
* @author AgenticCP Team
* @version 1.0.0
- * @since 2024-01-01
+ * @since 2025-10-24
+ * @see com.agenticcp.core.common.entity.TenantAwareEntity
+ * @see com.agenticcp.core.common.context.TenantContextHolder
+ * @see com.agenticcp.core.common.repository.BaseRepository
*/
@NoRepositoryBean
-public interface TenantAwareRepository extends JpaRepository {
+public interface TenantAwareRepository extends BaseRepository {
/**
- * 현재 테넌트의 모든 엔티티 조회
+ * 현재 테넌트의 모든 엔티티를 조회합니다.
+ *
+ *
+ * TenantContextHolder에서 현재 테넌트를 가져와 해당 테넌트의 모든 엔티티를 반환합니다.
+ * 테넌트 컨텍스트가 설정되지 않은 경우 예외가 발생합니다.
+ *
*
* @return 현재 테넌트의 엔티티 목록
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
*/
default List findAllForCurrentTenant() {
Tenant currentTenant = TenantContextHolder.getCurrentTenantOrThrow();
@@ -30,18 +55,29 @@ default List findAllForCurrentTenant() {
}
/**
- * 테넌트별 엔티티 조회 (구현 필요)
+ * 특정 테넌트의 모든 엔티티를 조회합니다.
+ *
+ *
+ * 구현 클래스에서 반드시 구현해야 하는 메서드입니다.
+ * Spring Data JPA의 메서드 이름 규칙을 사용하여 자동으로 쿼리가 생성됩니다.
+ *
*
- * @param tenant 테넌트
- * @return 엔티티 목록
+ * @param tenant 조회할 테넌트
+ * @return 해당 테넌트의 엔티티 목록
*/
List findByTenant(Tenant tenant);
/**
- * 현재 테넌트에서 ID로 엔티티 조회
+ * 현재 테넌트에서 ID로 엔티티를 조회합니다.
+ *
+ *
+ * TenantContextHolder에서 현재 테넌트를 가져와 해당 테넌트의 엔티티만 조회합니다.
+ * 다른 테넌트의 데이터는 접근할 수 없으므로 데이터 격리를 보장합니다.
+ *
*
- * @param id 엔티티 ID
- * @return 엔티티 (현재 테넌트에 속한 경우만)
+ * @param id 조회할 엔티티의 ID
+ * @return 현재 테넌트에 속한 엔티티 (존재하지 않으면 Empty)
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
*/
default Optional findByIdForCurrentTenant(ID id) {
Tenant currentTenant = TenantContextHolder.getCurrentTenantOrThrow();
@@ -49,28 +85,44 @@ default Optional findByIdForCurrentTenant(ID id) {
}
/**
- * 테넌트와 ID로 엔티티 조회 (구현 필요)
+ * 특정 테넌트에서 ID로 엔티티를 조회합니다.
*
- * @param id 엔티티 ID
- * @param tenant 테넌트
- * @return 엔티티
+ *
+ * 구현 클래스에서 반드시 구현해야 하는 메서드입니다.
+ * Spring Data JPA의 메서드 이름 규칙을 사용하여 자동으로 쿼리가 생성됩니다.
+ *
+ *
+ * @param id 조회할 엔티티의 ID
+ * @param tenant 조회할 테넌트
+ * @return 해당 테넌트에 속한 엔티티 (존재하지 않으면 Empty)
*/
Optional findByIdAndTenant(ID id, Tenant tenant);
/**
- * 현재 테넌트에서 엔티티 존재 여부 확인
+ * 현재 테넌트에서 엔티티의 존재 여부를 확인합니다.
+ *
+ *
+ * 내부적으로 findByIdForCurrentTenant()를 호출하여 엔티티 존재 여부를 확인합니다.
+ *
*
- * @param id 엔티티 ID
- * @return 존재 여부
+ * @param id 확인할 엔티티의 ID
+ * @return 존재하면 true, 존재하지 않으면 false
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
*/
default boolean existsByIdForCurrentTenant(ID id) {
return findByIdForCurrentTenant(id).isPresent();
}
/**
- * 현재 테넌트에서 엔티티 삭제
+ * 현재 테넌트에서 엔티티를 삭제합니다.
+ *
+ *
+ * TenantContextHolder에서 현재 테넌트를 가져와 해당 테넌트의 엔티티만 삭제합니다.
+ * 다른 테넌트의 데이터는 삭제할 수 없으므로 데이터 격리를 보장합니다.
+ *
*
- * @param id 엔티티 ID
+ * @param id 삭제할 엔티티의 ID
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
*/
default void deleteByIdForCurrentTenant(ID id) {
Tenant currentTenant = TenantContextHolder.getCurrentTenantOrThrow();
@@ -78,17 +130,27 @@ default void deleteByIdForCurrentTenant(ID id) {
}
/**
- * 테넌트와 ID로 엔티티 삭제 (구현 필요)
+ * 특정 테넌트에서 엔티티를 삭제합니다.
*
- * @param id 엔티티 ID
- * @param tenant 테넌트
+ *
+ * 구현 클래스에서 반드시 구현해야 하는 메서드입니다.
+ * Spring Data JPA의 메서드 이름 규칙을 사용하여 자동으로 쿼리가 생성됩니다.
+ *
+ *
+ * @param id 삭제할 엔티티의 ID
+ * @param tenant 삭제할 테넌트
*/
void deleteByIdAndTenant(ID id, Tenant tenant);
/**
- * 현재 테넌트의 엔티티 수 조회
+ * 현재 테넌트의 엔티티 개수를 조회합니다.
+ *
+ *
+ * TenantContextHolder에서 현재 테넌트를 가져와 해당 테넌트의 엔티티 개수를 반환합니다.
+ *
*
- * @return 엔티티 수
+ * @return 현재 테넌트의 엔티티 개수
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
*/
default long countForCurrentTenant() {
Tenant currentTenant = TenantContextHolder.getCurrentTenantOrThrow();
@@ -96,10 +158,15 @@ default long countForCurrentTenant() {
}
/**
- * 테넌트별 엔티티 수 조회 (구현 필요)
+ * 특정 테넌트의 엔티티 개수를 조회합니다.
+ *
+ *
+ * 구현 클래스에서 반드시 구현해야 하는 메서드입니다.
+ * Spring Data JPA의 메서드 이름 규칙을 사용하여 자동으로 쿼리가 생성됩니다.
+ *
*
- * @param tenant 테넌트
- * @return 엔티티 수
+ * @param tenant 조회할 테넌트
+ * @return 해당 테넌트의 엔티티 개수
*/
long countByTenant(Tenant tenant);
}
diff --git a/src/main/java/com/agenticcp/core/common/repository/TenantAwareRepositoryImpl.java b/src/main/java/com/agenticcp/core/common/repository/TenantAwareRepositoryImpl.java
new file mode 100644
index 000000000..1f0abae92
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/common/repository/TenantAwareRepositoryImpl.java
@@ -0,0 +1,659 @@
+package com.agenticcp.core.common.repository;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
+import com.agenticcp.core.common.enums.CommonErrorCode;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.TypedQuery;
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.data.jpa.repository.support.JpaEntityInformation;
+import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 테넌트 인식 Repository 구현체
+ *
+ *
+ * SimpleJpaRepository를 상속받아 모든 기본 JPA 메서드를 테넌트 필터링 버전으로 오버라이드합니다.
+ * 멀티 테넌시 환경에서 데이터 격리를 자동으로 보장하며, Criteria API를 사용하여
+ * 동적 쿼리를 생성합니다.
+ *
+ *
+ *
+ * 이 구현체는 Spring Data JPA의 RepositoryFactoryBean을 통해 자동으로 생성되며,
+ * 모든 CRUD 작업에 현재 테넌트 필터링을 자동으로 적용합니다.
+ *
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-10-24
+ * @see com.agenticcp.core.common.repository.TenantAwareRepository
+ * @see org.springframework.data.jpa.repository.support.SimpleJpaRepository
+ */
+@Slf4j
+public class TenantAwareRepositoryImpl
+ extends SimpleJpaRepository implements TenantAwareRepository {
+
+ private final EntityManager entityManager;
+ private final Class domainClass;
+
+ /**
+ * TenantAwareRepositoryImpl 생성자
+ *
+ *
+ * Spring Data JPA의 RepositoryFactoryBean에 의해 자동으로 호출됩니다.
+ * EntityManager와 엔티티 정보를 주입받아 초기화합니다.
+ *
+ *
+ * @param entityInformation JPA 엔티티 메타정보
+ * @param entityManager JPA EntityManager
+ */
+ public TenantAwareRepositoryImpl(JpaEntityInformation entityInformation, EntityManager entityManager) {
+ super(entityInformation, entityManager);
+ this.entityManager = entityManager;
+ this.domainClass = entityInformation.getJavaType();
+
+ log.debug("TenantAwareRepositoryImpl 초기화: domainClass={}", domainClass.getSimpleName());
+ }
+
+ /**
+ * 현재 테넌트의 모든 엔티티를 조회합니다 (기본 findAll 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 findAll()을 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ * TenantAwareRepository의 findAllForCurrentTenant()를 내부적으로 호출합니다.
+ *
+ *
+ * @return 현재 테넌트의 모든 엔티티 목록
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ @NonNull
+ public List findAll() {
+ log.debug("현재 테넌트의 모든 엔티티 조회: domainClass={}", domainClass.getSimpleName());
+ return findAllForCurrentTenant();
+ }
+
+ /**
+ * 현재 테넌트의 모든 엔티티를 정렬하여 조회합니다 (기본 findAll 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 findAll(Sort)을 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ *
+ *
+ * @param sort 정렬 조건
+ * @return 현재 테넌트의 정렬된 엔티티 목록
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ @NonNull
+ public List findAll(@NonNull Sort sort) {
+ log.debug("현재 테넌트의 엔티티 조회 (정렬): domainClass={}, sort={}",
+ domainClass.getSimpleName(), sort);
+ Tenant currentTenant = getCurrentTenantOrThrow();
+ return findByTenantWithSort(currentTenant, sort);
+ }
+
+ /**
+ * 현재 테넌트의 엔티티를 페이징하여 조회합니다 (기본 findAll 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 findAll(Pageable)을 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ *
+ *
+ * @param pageable 페이징 정보 (페이지 번호, 크기, 정렬)
+ * @return 현재 테넌트의 페이징된 엔티티
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ @NonNull
+ public Page findAll(@NonNull Pageable pageable) {
+ log.debug("현재 테넌트의 엔티티 조회 (페이징): domainClass={}, page={}, size={}",
+ domainClass.getSimpleName(), pageable.getPageNumber(), pageable.getPageSize());
+ Tenant currentTenant = getCurrentTenantOrThrow();
+ return findByTenantWithPageable(currentTenant, pageable);
+ }
+
+ /**
+ * 현재 테넌트에서 ID로 엔티티를 조회합니다 (기본 findById 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 findById()를 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ * 다른 테넌트의 데이터는 조회할 수 없으므로 데이터 격리를 보장합니다.
+ *
+ *
+ * @param id 조회할 엔티티의 ID
+ * @return 현재 테넌트에 속한 엔티티 (존재하지 않으면 Empty)
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ @NonNull
+ public Optional findById(@NonNull ID id) {
+ log.debug("현재 테넌트에서 엔티티 조회: domainClass={}, id={}",
+ domainClass.getSimpleName(), id);
+ return findByIdForCurrentTenant(id);
+ }
+
+ /**
+ * 현재 테넌트에서 엔티티의 존재 여부를 확인합니다 (기본 existsById 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 existsById()를 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ *
+ *
+ * @param id 확인할 엔티티의 ID
+ * @return 존재하면 true, 존재하지 않으면 false
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ public boolean existsById(@NonNull ID id) {
+ log.debug("현재 테넌트에서 엔티티 존재 확인: domainClass={}, id={}",
+ domainClass.getSimpleName(), id);
+ return existsByIdForCurrentTenant(id);
+ }
+
+ /**
+ * 현재 테넌트의 엔티티 개수를 조회합니다 (기본 count 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 count()를 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ *
+ *
+ * @return 현재 테넌트의 엔티티 개수
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ public long count() {
+ log.debug("현재 테넌트의 엔티티 개수 조회: domainClass={}", domainClass.getSimpleName());
+ return countForCurrentTenant();
+ }
+
+ /**
+ * 현재 테넌트에서 엔티티를 삭제합니다 (기본 deleteById 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 deleteById()를 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ * 다른 테넌트의 데이터는 삭제할 수 없으므로 데이터 격리를 보장합니다.
+ *
+ *
+ * @param id 삭제할 엔티티의 ID
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ public void deleteById(@NonNull ID id) {
+ log.debug("현재 테넌트에서 엔티티 삭제: domainClass={}, id={}",
+ domainClass.getSimpleName(), id);
+ deleteByIdForCurrentTenant(id);
+ }
+
+ /**
+ * 현재 테넌트에서 엔티티를 삭제합니다 (기본 delete 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 delete()를 오버라이드하여 현재 테넌트 접근 권한을 검증합니다.
+ * 엔티티가 현재 테넌트에 속하지 않으면 예외가 발생합니다.
+ *
+ *
+ * @param entity 삭제할 엔티티
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않았거나 엔티티가 현재 테넌트에 속하지 않은 경우
+ */
+ @Override
+ public void delete(@NonNull T entity) {
+ log.debug("엔티티 삭제: domainClass={}", domainClass.getSimpleName());
+ validateTenantAccess(entity);
+ super.delete(entity);
+ }
+
+ /**
+ * 현재 테넌트에서 여러 엔티티를 삭제합니다 (기본 deleteAll 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 deleteAll(Iterable)을 오버라이드하여
+ * 각 엔티티에 대해 현재 테넌트 접근 권한을 검증합니다.
+ *
+ *
+ * @param entities 삭제할 엔티티들
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않았거나 엔티티가 현재 테넌트에 속하지 않은 경우
+ */
+ @Override
+ public void deleteAll(@NonNull Iterable extends T> entities) {
+ log.debug("여러 엔티티 삭제: domainClass={}", domainClass.getSimpleName());
+ // 모든 엔티티에 대해 테넌트 접근 권한 검증
+ for (T entity : entities) {
+ validateTenantAccess(entity);
+ }
+ super.deleteAll(entities);
+ }
+
+ /**
+ * 현재 테넌트의 모든 엔티티를 삭제합니다 (기본 deleteAll 오버라이드).
+ *
+ *
+ * SimpleJpaRepository의 deleteAll()을 오버라이드하여 현재 테넌트 필터링을 적용합니다.
+ * 다른 테넌트의 데이터는 삭제되지 않으므로 데이터 격리를 보장합니다.
+ *
+ *
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @Override
+ public void deleteAll() {
+ log.debug("현재 테넌트의 모든 엔티티 삭제: domainClass={}", domainClass.getSimpleName());
+ Tenant currentTenant = getCurrentTenantOrThrow();
+ deleteAllByTenant(currentTenant);
+ }
+
+ // ========== TenantAwareRepository 인터페이스 구현 ==========
+
+ /**
+ * 특정 테넌트의 모든 엔티티를 조회합니다.
+ *
+ *
+ * Criteria API를 사용하여 동적으로 쿼리를 생성하고 테넌트 필터링을 적용합니다.
+ *
+ *
+ * @param tenant 조회할 테넌트
+ * @return 해당 테넌트의 모든 엔티티 목록
+ */
+ @Override
+ public List findByTenant(Tenant tenant) {
+ log.debug("테넌트별 엔티티 조회: domainClass={}, tenantId={}",
+ domainClass.getSimpleName(), tenant.getId());
+
+ // Criteria API를 사용한 동적 쿼리 생성
+ CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+ CriteriaQuery query = cb.createQuery(domainClass);
+ Root root = query.from(domainClass);
+
+ // WHERE tenant = :tenant 조건 추가
+ query.select(root).where(cb.equal(root.get("tenant"), tenant));
+
+ return entityManager.createQuery(query).getResultList();
+ }
+
+ /**
+ * 특정 테넌트에서 ID로 엔티티를 조회합니다.
+ *
+ *
+ * Criteria API를 사용하여 ID와 테넌트 조건을 모두 만족하는 엔티티를 조회합니다.
+ * 데이터 격리를 보장하기 위해 반드시 두 조건을 AND로 결합합니다.
+ *
+ *
+ * @param id 조회할 엔티티의 ID
+ * @param tenant 조회할 테넌트
+ * @return 해당 테넌트에 속한 엔티티 (존재하지 않으면 Empty)
+ */
+ @Override
+ public Optional findByIdAndTenant(ID id, Tenant tenant) {
+ log.debug("테넌트와 ID로 엔티티 조회: domainClass={}, id={}, tenantId={}",
+ domainClass.getSimpleName(), id, tenant.getId());
+
+ // Criteria API를 사용한 동적 쿼리 생성
+ CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+ CriteriaQuery query = cb.createQuery(domainClass);
+ Root root = query.from(domainClass);
+
+ // WHERE id = :id AND tenant = :tenant 조건 생성
+ Predicate idPredicate = cb.equal(root.get("id"), id);
+ Predicate tenantPredicate = cb.equal(root.get("tenant"), tenant);
+
+ query.select(root).where(cb.and(idPredicate, tenantPredicate));
+
+ TypedQuery typedQuery = entityManager.createQuery(query);
+ List results = typedQuery.getResultList();
+
+ return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
+ }
+
+ /**
+ * 특정 테넌트에서 엔티티를 삭제합니다.
+ *
+ *
+ * 먼저 엔티티를 조회한 후 존재하는 경우에만 삭제합니다.
+ * 다른 테넌트의 데이터는 삭제되지 않으므로 데이터 격리를 보장합니다.
+ *
+ *
+ * @param id 삭제할 엔티티의 ID
+ * @param tenant 삭제할 테넌트
+ */
+ @Override
+ public void deleteByIdAndTenant(ID id, Tenant tenant) {
+ log.debug("테넌트와 ID로 엔티티 삭제: domainClass={}, id={}, tenantId={}",
+ domainClass.getSimpleName(), id, tenant.getId());
+
+ // 엔티티 존재 여부 확인 후 삭제
+ Optional entity = findByIdAndTenant(id, tenant);
+ if (entity.isPresent()) {
+ entityManager.remove(entity.get());
+ log.debug("엔티티 삭제 완료: domainClass={}, id={}", domainClass.getSimpleName(), id);
+ } else {
+ log.debug("삭제할 엔티티가 존재하지 않음: domainClass={}, id={}",
+ domainClass.getSimpleName(), id);
+ }
+ }
+
+ /**
+ * 특정 테넌트의 엔티티 개수를 조회합니다.
+ *
+ *
+ * Criteria API를 사용하여 COUNT 쿼리를 생성하고 테넌트 필터링을 적용합니다.
+ *
+ *
+ * @param tenant 조회할 테넌트
+ * @return 해당 테넌트의 엔티티 개수
+ */
+ @Override
+ public long countByTenant(Tenant tenant) {
+ log.debug("테넌트별 엔티티 개수 조회: domainClass={}, tenantId={}",
+ domainClass.getSimpleName(), tenant.getId());
+
+ // Criteria API를 사용한 COUNT 쿼리 생성
+ CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+ CriteriaQuery query = cb.createQuery(Long.class);
+ Root root = query.from(domainClass);
+
+ // SELECT COUNT(*) WHERE tenant = :tenant
+ query.select(cb.count(root)).where(cb.equal(root.get("tenant"), tenant));
+
+ return entityManager.createQuery(query).getSingleResult();
+ }
+
+ // ========== 추가 헬퍼 메서드들 ==========
+
+ /**
+ * 테넌트별 엔티티를 정렬하여 조회합니다 (private 헬퍼).
+ *
+ *
+ * Criteria API를 사용하여 테넌트 필터링과 정렬을 동시에 적용합니다.
+ * 정렬 조건이 없는 경우 기본 순서로 조회됩니다.
+ *
+ *
+ * @param tenant 조회할 테넌트
+ * @param sort 정렬 조건
+ * @return 정렬된 엔티티 목록
+ */
+ private List findByTenantWithSort(Tenant tenant, Sort sort) {
+ // Criteria API를 사용한 동적 쿼리 생성
+ CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+ CriteriaQuery query = cb.createQuery(domainClass);
+ Root root = query.from(domainClass);
+
+ // WHERE tenant = :tenant 조건 추가
+ query.select(root).where(cb.equal(root.get("tenant"), tenant));
+
+ // 정렬 조건 적용
+ if (sort != null && sort.isSorted()) {
+ List orders = new ArrayList<>();
+ for (Sort.Order order : sort) {
+ // ASC 또는 DESC 방향에 따라 정렬 추가
+ if (order.getDirection().isAscending()) {
+ orders.add(cb.asc(root.get(order.getProperty())));
+ } else {
+ orders.add(cb.desc(root.get(order.getProperty())));
+ }
+ }
+ query.orderBy(orders);
+ }
+
+ return entityManager.createQuery(query).getResultList();
+ }
+
+ /**
+ * 테넌트별 엔티티를 페이징하여 조회합니다 (private 헬퍼).
+ *
+ *
+ * 먼저 전체 개수를 조회한 후 페이징된 데이터를 조회합니다.
+ * Pageable에 포함된 정렬 조건도 함께 적용됩니다.
+ *
+ *
+ * @param tenant 조회할 테넌트
+ * @param pageable 페이징 정보 (페이지 번호, 크기, 정렬)
+ * @return 페이징된 엔티티
+ */
+ private Page findByTenantWithPageable(Tenant tenant, Pageable pageable) {
+ // 1단계: 전체 개수 조회 (페이지 정보 계산을 위해 필요)
+ long total = countByTenant(tenant);
+
+ // 2단계: 페이징된 데이터 조회
+ CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+ CriteriaQuery query = cb.createQuery(domainClass);
+ Root root = query.from(domainClass);
+
+ // WHERE tenant = :tenant 조건 추가
+ query.select(root).where(cb.equal(root.get("tenant"), tenant));
+
+ // 정렬 조건 적용
+ if (pageable.getSort().isSorted()) {
+ List orders = new ArrayList<>();
+ for (Sort.Order order : pageable.getSort()) {
+ if (order.getDirection().isAscending()) {
+ orders.add(cb.asc(root.get(order.getProperty())));
+ } else {
+ orders.add(cb.desc(root.get(order.getProperty())));
+ }
+ }
+ query.orderBy(orders);
+ }
+
+ // 페이징 파라미터 설정
+ TypedQuery typedQuery = entityManager.createQuery(query);
+ typedQuery.setFirstResult((int) pageable.getOffset()); // 시작 위치
+ typedQuery.setMaxResults(pageable.getPageSize()); // 페이지 크기
+
+ List content = typedQuery.getResultList();
+
+ // Page 객체 생성 (content, pageable, total)
+ return new PageImpl<>(content, pageable, total);
+ }
+
+ /**
+ * 테넌트의 모든 엔티티를 삭제합니다 (private 헬퍼).
+ *
+ *
+ * 먼저 해당 테넌트의 모든 엔티티를 조회한 후 하나씩 삭제합니다.
+ * 다른 테넌트의 데이터는 삭제되지 않으므로 데이터 격리를 보장합니다.
+ *
+ *
+ * @param tenant 삭제할 테넌트
+ */
+ private void deleteAllByTenant(Tenant tenant) {
+ log.debug("테넌트의 모든 엔티티 삭제: domainClass={}, tenantId={}",
+ domainClass.getSimpleName(), tenant.getId());
+
+ // Criteria API를 사용한 동적 쿼리 생성
+ CriteriaBuilder cb = entityManager.getCriteriaBuilder();
+ CriteriaQuery query = cb.createQuery(domainClass);
+ Root root = query.from(domainClass);
+
+ // WHERE tenant = :tenant 조건으로 조회
+ query.select(root).where(cb.equal(root.get("tenant"), tenant));
+
+ List entities = entityManager.createQuery(query).getResultList();
+
+ // 조회된 모든 엔티티 삭제
+ for (T entity : entities) {
+ entityManager.remove(entity);
+ }
+
+ log.debug("테넌트의 엔티티 삭제 완료: domainClass={}, count={}",
+ domainClass.getSimpleName(), entities.size());
+ }
+
+ /**
+ * 엔티티의 테넌트 접근 권한을 검증합니다 (private 헬퍼).
+ *
+ *
+ * 엔티티가 현재 테넌트에 속하는지 확인합니다.
+ * 다른 테넌트의 데이터에 접근하려고 하면 BusinessException이 발생합니다.
+ *
+ *
+ * @param entity 검증할 엔티티
+ * @throws BusinessException 엔티티가 현재 테넌트에 속하지 않은 경우
+ */
+ private void validateTenantAccess(T entity) {
+ if (entity == null) {
+ return;
+ }
+
+ Tenant currentTenant = getCurrentTenantOrThrow();
+ Tenant entityTenant = entity.getTenant();
+
+ // 엔티티의 테넌트가 없거나 현재 테넌트와 다른 경우 예외 발생
+ if (entityTenant == null || !entityTenant.getId().equals(currentTenant.getId())) {
+ log.error("테넌트 접근 권한 없음: domainClass={}, currentTenantId={}, entityTenantId={}",
+ domainClass.getSimpleName(), currentTenant.getId(),
+ entityTenant != null ? entityTenant.getId() : "null");
+
+ throw new BusinessException(CommonErrorCode.TENANT_CONTEXT_NOT_SET,
+ "접근 거부: 엔티티가 현재 테넌트에 속하지 않습니다");
+ }
+ }
+
+ /**
+ * 현재 테넌트를 조회합니다 (private 헬퍼).
+ *
+ *
+ * TenantContextHolder에서 현재 테넌트를 가져옵니다.
+ * 테넌트 컨텍스트가 설정되지 않은 경우 예외가 발생합니다.
+ *
+ *
+ * @return 현재 테넌트
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ private Tenant getCurrentTenantOrThrow() {
+ try {
+ return TenantContextHolder.getCurrentTenantOrThrow();
+ } catch (Exception e) {
+ log.error("테넌트 컨텍스트 조회 실패: {}", e.getMessage());
+ throw new BusinessException(CommonErrorCode.TENANT_CONTEXT_NOT_SET,
+ "리포지토리 작업을 위해 테넌트 컨텍스트가 필요합니다");
+ }
+ }
+
+ /**
+ * Specification을 사용하여 현재 테넌트의 엔티티를 조회합니다.
+ *
+ *
+ * 사용자가 제공한 Specification과 테넌트 필터링 Specification을 AND로 결합합니다.
+ * 이를 통해 동적 쿼리 구성과 테넌트 격리를 동시에 보장합니다.
+ *
+ *
+ *
+ * 사용 예시:
+ *
+ * Specification spec = (root, query, cb) -> cb.equal(root.get("status"), "ACTIVE");
+ * List users = repository.findAll(spec); // 현재 테넌트의 ACTIVE 사용자만 조회
+ *
+ *
+ *
+ * @param spec 사용자 정의 Specification (null 가능)
+ * @return 조건을 만족하는 현재 테넌트의 엔티티 목록
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @NonNull
+ public List findAll(@Nullable Specification spec) {
+ log.debug("Specification을 사용한 엔티티 조회: domainClass={}", domainClass.getSimpleName());
+
+ Tenant currentTenant = getCurrentTenantOrThrow();
+
+ // 테넌트 필터링 Specification 생성
+ Specification tenantSpec = (root, query, cb) -> cb.equal(root.get("tenant"), currentTenant);
+
+ // 사용자 Specification과 AND로 결합
+ Specification combinedSpec = spec != null ? spec.and(tenantSpec) : tenantSpec;
+
+ return super.findAll(combinedSpec);
+ }
+
+ /**
+ * Specification을 사용하여 현재 테넌트의 엔티티를 페이징 조회합니다.
+ *
+ *
+ * 사용자가 제공한 Specification과 테넌트 필터링 Specification을 AND로 결합합니다.
+ * 페이징과 정렬 정보도 함께 적용됩니다.
+ *
+ *
+ *
+ * 사용 예시:
+ *
+ * Specification spec = (root, query, cb) -> cb.like(root.get("name"), "%John%");
+ * PageRequest pageRequest = PageRequest.of(0, 10, Sort.by("createdAt").descending());
+ * Page users = repository.findAll(spec, pageRequest);
+ *
+ *
+ *
+ * @param spec 사용자 정의 Specification (null 가능)
+ * @param pageable 페이징 정보 (페이지 번호, 크기, 정렬)
+ * @return 조건을 만족하는 현재 테넌트의 페이징된 엔티티
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @NonNull
+ public Page findAll(@Nullable Specification spec, @NonNull Pageable pageable) {
+ log.debug("Specification을 사용한 엔티티 조회 (페이징): domainClass={}, page={}, size={}",
+ domainClass.getSimpleName(), pageable.getPageNumber(), pageable.getPageSize());
+
+ Tenant currentTenant = getCurrentTenantOrThrow();
+
+ // 테넌트 필터링 Specification 생성
+ Specification tenantSpec = (root, query, cb) -> cb.equal(root.get("tenant"), currentTenant);
+
+ // 사용자 Specification과 AND로 결합
+ Specification combinedSpec = spec != null ? spec.and(tenantSpec) : tenantSpec;
+
+ return super.findAll(combinedSpec, pageable);
+ }
+
+ /**
+ * Specification을 사용하여 현재 테넌트의 엔티티를 정렬 조회합니다.
+ *
+ *
+ * 사용자가 제공한 Specification과 테넌트 필터링 Specification을 AND로 결합합니다.
+ * 정렬 조건도 함께 적용됩니다.
+ *
+ *
+ *
+ * 사용 예시:
+ *
+ * Specification spec = (root, query, cb) -> cb.greaterThan(root.get("age"), 18);
+ * Sort sort = Sort.by("name").ascending();
+ * List users = repository.findAll(spec, sort);
+ *
+ *
+ *
+ * @param spec 사용자 정의 Specification (null 가능)
+ * @param sort 정렬 조건
+ * @return 조건을 만족하는 현재 테넌트의 정렬된 엔티티 목록
+ * @throws BusinessException 테넌트 컨텍스트가 설정되지 않은 경우
+ */
+ @NonNull
+ public List findAll(@Nullable Specification spec, @NonNull Sort sort) {
+ log.debug("Specification을 사용한 엔티티 조회 (정렬): domainClass={}, sort={}",
+ domainClass.getSimpleName(), sort);
+
+ Tenant currentTenant = getCurrentTenantOrThrow();
+
+ // 테넌트 필터링 Specification 생성
+ Specification tenantSpec = (root, query, cb) -> cb.equal(root.get("tenant"), currentTenant);
+
+ // 사용자 Specification과 AND로 결합
+ Specification combinedSpec = spec != null ? spec.and(tenantSpec) : tenantSpec;
+
+ return super.findAll(combinedSpec, sort);
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
index f6f9754fe..07ece7420 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
@@ -1,6 +1,6 @@
package com.agenticcp.core.domain.cloud.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import com.agenticcp.core.common.enums.Status;
import com.agenticcp.core.domain.tenant.entity.Tenant;
import jakarta.persistence.*;
@@ -18,7 +18,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class CloudResource extends BaseEntity {
+public class CloudResource extends TenantAwareEntity {
@Column(name = "resource_id", nullable = false, unique = true)
private String resourceId;
@@ -41,9 +41,6 @@ public class CloudResource extends BaseEntity {
@JoinColumn(name = "service_id", nullable = false)
private CloudService service;
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id")
- private Tenant tenant;
@Enumerated(EnumType.STRING)
@Column(name = "status")
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudProviderRepository.java b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudProviderRepository.java
index e1c6c8294..d5468b361 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudProviderRepository.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudProviderRepository.java
@@ -1,5 +1,5 @@
package com.agenticcp.core.domain.cloud.repository;
-
+import com.agenticcp.core.common.repository.BaseRepository;
import com.agenticcp.core.domain.cloud.entity.CloudProvider;
import com.agenticcp.core.common.enums.Status;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -12,7 +12,7 @@
import java.util.Optional;
@Repository
-public interface CloudProviderRepository extends JpaRepository {
+public interface CloudProviderRepository extends BaseRepository {
Optional findByProviderKey(String providerKey);
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java
index 0f379e1a2..75fcdfbac 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java
@@ -1,7 +1,7 @@
package com.agenticcp.core.domain.cloud.repository;
+import com.agenticcp.core.common.repository.TenantAwareRepository;
import com.agenticcp.core.domain.cloud.entity.CloudResource;
-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;
@@ -16,19 +16,19 @@
* @since 2025-10-06
*/
@Repository
-public interface CloudResourceRepository extends JpaRepository {
+public interface CloudResourceRepository extends TenantAwareRepository {
/**
- * 테넌트 키로 클라우드 리소스 목록 조회
+ * 프로바이더별 클라우드 리소스 목록 조회
*
- * @param tenantKey 테넌트 키 (tenantKey)
+ * @param providerId 프로바이더 ID
* @return 클라우드 리소스 목록
*/
@Query("SELECT cr FROM CloudResource cr " +
"JOIN FETCH cr.provider " +
- "WHERE cr.tenant.tenantKey = :tenantKey " +
+ "WHERE cr.provider.id = :providerId " +
"AND cr.isDeleted = false")
- List findByTenantId(@Param("tenantKey") String tenantKey);
+ List findByProviderId(@Param("providerId") Long providerId);
/**
* 리소스 ID로 조회
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java
index 52dbb6241..1e01f988b 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/CloudResourceService.java
@@ -35,7 +35,7 @@ public List getResourcesByTenant(String tenantKey) {
log.info("[CloudResourceService] getResourcesByTenant - tenantKey={}",
LogMaskingUtils.mask(tenantKey, 2, 2));
- List resources = cloudResourceRepository.findByTenantId(tenantKey);
+ List resources = cloudResourceRepository.findAllForCurrentTenant();
log.info("[CloudResourceService] getResourcesByTenant - success count={} tenantId={}",
resources.size(), LogMaskingUtils.mask(tenantKey, 2, 2));
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java
index 085a5db36..06aeeabc3 100644
--- a/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java
+++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/Tenant.java
@@ -1,6 +1,5 @@
package com.agenticcp.core.domain.tenant.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
import com.agenticcp.core.common.enums.Status;
import com.agenticcp.core.domain.organization.entity.Organization;
import jakarta.persistence.*;
@@ -8,6 +7,9 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@@ -17,7 +19,30 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class Tenant extends BaseEntity {
+@EntityListeners(AuditingEntityListener.class)
+public class Tenant {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @CreatedDate
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ @Column(name = "created_by")
+ private String createdBy;
+
+ @Column(name = "updated_by")
+ private String updatedBy;
+
+ @Column(name = "is_deleted", nullable = false)
+ @Builder.Default
+ private Boolean isDeleted = false;
@Column(name = "tenant_key", nullable = false, unique = true)
private String tenantKey;
@@ -35,6 +60,7 @@ public class Tenant extends BaseEntity {
@Column(name = "status")
@Enumerated(EnumType.STRING)
+ @Builder.Default
private Status status = Status.ACTIVE;
@Column(name = "tenant_type")
@@ -72,6 +98,7 @@ public class Tenant extends BaseEntity {
private LocalDateTime subscriptionEndDate;
@Column(name = "is_trial")
+ @Builder.Default
private Boolean isTrial = false;
@Column(name = "trial_end_date")
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantBilling.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantBilling.java
index bef7206b0..c966878c9 100644
--- a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantBilling.java
+++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantBilling.java
@@ -1,6 +1,6 @@
package com.agenticcp.core.domain.tenant.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -16,11 +16,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class TenantBilling extends BaseEntity {
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id", nullable = false)
- private Tenant tenant;
+public class TenantBilling extends TenantAwareEntity {
@Column(name = "billing_cycle")
@Enumerated(EnumType.STRING)
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 c0c664a0f..4e29eb122 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
@@ -1,6 +1,6 @@
package com.agenticcp.core.domain.tenant.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -13,11 +13,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class TenantConfig extends BaseEntity {
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id", nullable = false)
- private Tenant tenant;
+public class TenantConfig extends TenantAwareEntity {
@Column(name = "config_key", nullable = false)
private String configKey;
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantIsolation.java b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantIsolation.java
index 1df464f00..6fc8963f2 100644
--- a/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantIsolation.java
+++ b/src/main/java/com/agenticcp/core/domain/tenant/entity/TenantIsolation.java
@@ -1,6 +1,6 @@
package com.agenticcp.core.domain.tenant.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -13,11 +13,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class TenantIsolation extends BaseEntity {
-
- @OneToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id", nullable = false)
- private Tenant tenant;
+public class TenantIsolation extends TenantAwareEntity {
@Column(name = "isolation_level")
@Enumerated(EnumType.STRING)
diff --git a/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantBillingRepository.java b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantBillingRepository.java
new file mode 100644
index 000000000..3cb2a4548
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/tenant/repository/TenantBillingRepository.java
@@ -0,0 +1,113 @@
+package com.agenticcp.core.domain.tenant.repository;
+
+import com.agenticcp.core.common.repository.TenantAwareRepository;
+import com.agenticcp.core.domain.tenant.entity.TenantBilling;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 테넌트 과금 Repository
+ * 테넌트별 과금 정보를 관리합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2024-01-01
+ */
+@Repository
+public interface TenantBillingRepository extends TenantAwareRepository {
+
+ /**
+ * 결제 상태별 과금 정보 조회
+ *
+ * @param paymentStatus 결제 상태
+ * @return 과금 정보 목록
+ */
+ List findByPaymentStatus(TenantBilling.PaymentStatus paymentStatus);
+
+ /**
+ * 과금 주기별 과금 정보 조회
+ *
+ * @param billingCycle 과금 주기
+ * @return 과금 정보 목록
+ */
+ List findByBillingCycle(TenantBilling.BillingCycle billingCycle);
+
+ /**
+ * 특정 기간의 과금 정보 조회
+ *
+ * @param startDate 시작일
+ * @param endDate 종료일
+ * @return 과금 정보 목록
+ */
+ @Query("SELECT tb FROM TenantBilling tb WHERE tb.billingPeriodStart >= :startDate AND tb.billingPeriodEnd <= :endDate")
+ List findByBillingPeriod(@Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ /**
+ * 연체된 과금 정보 조회
+ *
+ * @param currentDate 현재 날짜
+ * @return 연체된 과금 정보 목록
+ */
+ @Query("SELECT tb FROM TenantBilling tb WHERE tb.dueDate < :currentDate AND tb.paymentStatus = 'PENDING'")
+ List findOverdueBilling(@Param("currentDate") LocalDateTime currentDate);
+
+ /**
+ * 특정 금액 이상의 과금 정보 조회
+ *
+ * @param minAmount 최소 금액
+ * @return 과금 정보 목록
+ */
+ @Query("SELECT tb FROM TenantBilling tb WHERE tb.totalAmount >= :minAmount")
+ List findByTotalAmountGreaterThanEqual(@Param("minAmount") BigDecimal minAmount);
+
+ /**
+ * 인보이스 번호로 과금 정보 조회
+ *
+ * @param invoiceNumber 인보이스 번호
+ * @return 과금 정보
+ */
+ Optional findByInvoiceNumber(String invoiceNumber);
+
+ /**
+ * 결제 방법별 과금 정보 조회
+ *
+ * @param paymentMethod 결제 방법
+ * @return 과금 정보 목록
+ */
+ List findByPaymentMethod(String paymentMethod);
+
+ /**
+ * 통화별 과금 정보 조회
+ *
+ * @param currency 통화
+ * @return 과금 정보 목록
+ */
+ List findByCurrency(String currency);
+
+ /**
+ * 특정 기간의 총 과금 금액 조회
+ *
+ * @param startDate 시작일
+ * @param endDate 종료일
+ * @return 총 과금 금액
+ */
+ @Query("SELECT SUM(tb.totalAmount) FROM TenantBilling tb WHERE tb.billingPeriodStart >= :startDate AND tb.billingPeriodEnd <= :endDate")
+ BigDecimal getTotalBillingAmount(@Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ /**
+ * 결제 상태별 과금 금액 합계 조회
+ *
+ * @param paymentStatus 결제 상태
+ * @return 과금 금액 합계
+ */
+ @Query("SELECT SUM(tb.totalAmount) FROM TenantBilling tb WHERE tb.paymentStatus = :paymentStatus")
+ BigDecimal getTotalAmountByPaymentStatus(@Param("paymentStatus") TenantBilling.PaymentStatus paymentStatus);
+}
diff --git a/src/main/java/com/agenticcp/core/domain/ui/entity/Menu.java b/src/main/java/com/agenticcp/core/domain/ui/entity/Menu.java
index a0a94be14..305cb9e19 100644
--- a/src/main/java/com/agenticcp/core/domain/ui/entity/Menu.java
+++ b/src/main/java/com/agenticcp/core/domain/ui/entity/Menu.java
@@ -2,7 +2,7 @@
import com.agenticcp.core.common.logging.masking.Masked;
import com.agenticcp.core.common.logging.masking.MaskingType;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import com.agenticcp.core.domain.tenant.entity.Tenant;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
@@ -29,7 +29,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class Menu extends BaseEntity {
+public class Menu extends TenantAwareEntity {
/**
* 메뉴 키 (테넌트 내에서 유일)
diff --git a/src/main/java/com/agenticcp/core/domain/user/entity/Permission.java b/src/main/java/com/agenticcp/core/domain/user/entity/Permission.java
index dbacd3901..562142745 100644
--- a/src/main/java/com/agenticcp/core/domain/user/entity/Permission.java
+++ b/src/main/java/com/agenticcp/core/domain/user/entity/Permission.java
@@ -1,8 +1,7 @@
package com.agenticcp.core.domain.user.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import com.agenticcp.core.common.enums.Status;
-import com.agenticcp.core.domain.tenant.entity.Tenant;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -17,7 +16,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class Permission extends BaseEntity {
+public class Permission extends TenantAwareEntity {
@Column(name = "permission_key", nullable = false)
private String permissionKey;
@@ -28,9 +27,6 @@ public class Permission extends BaseEntity {
@Column(name = "description")
private String description;
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id")
- private Tenant tenant;
@Enumerated(EnumType.STRING)
@Column(name = "status")
diff --git a/src/main/java/com/agenticcp/core/domain/user/entity/Role.java b/src/main/java/com/agenticcp/core/domain/user/entity/Role.java
index 2c48e97a6..48f51468f 100644
--- a/src/main/java/com/agenticcp/core/domain/user/entity/Role.java
+++ b/src/main/java/com/agenticcp/core/domain/user/entity/Role.java
@@ -1,8 +1,7 @@
package com.agenticcp.core.domain.user.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import com.agenticcp.core.common.enums.Status;
-import com.agenticcp.core.domain.tenant.entity.Tenant;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -17,7 +16,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class Role extends BaseEntity {
+public class Role extends TenantAwareEntity {
@Column(name = "role_key", nullable = false)
private String roleKey;
@@ -28,9 +27,6 @@ public class Role extends BaseEntity {
@Column(name = "description")
private String description;
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id")
- private Tenant tenant;
@Enumerated(EnumType.STRING)
@Column(name = "status")
diff --git a/src/main/java/com/agenticcp/core/domain/user/entity/User.java b/src/main/java/com/agenticcp/core/domain/user/entity/User.java
index 62ee0a4d4..b387883c8 100644
--- a/src/main/java/com/agenticcp/core/domain/user/entity/User.java
+++ b/src/main/java/com/agenticcp/core/domain/user/entity/User.java
@@ -1,6 +1,6 @@
package com.agenticcp.core.domain.user.entity;
-import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.entity.TenantAwareEntity;
import com.agenticcp.core.common.enums.Status;
import com.agenticcp.core.common.enums.UserRole;
import com.agenticcp.core.domain.tenant.entity.Tenant;
@@ -35,7 +35,7 @@
@Builder
@NoArgsConstructor
@AllArgsConstructor
-public class User extends BaseEntity {
+public class User extends TenantAwareEntity {
@NotBlank(message = "사용자명은 필수입니다")
@Size(min = 2, max = 50, message = "사용자명은 2-50자 사이여야 합니다")
@@ -55,14 +55,15 @@ public class User extends BaseEntity {
@Column(name = "password_hash")
private String passwordHash;
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id")
- private Tenant tenant;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organization_id")
private Organization organization;
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tenant_id", nullable = false)
+ private Tenant tenant;
+
@Enumerated(EnumType.STRING)
@Column(name = "role")
private UserRole role = UserRole.VIEWER;
diff --git a/src/main/java/com/agenticcp/core/domain/user/repository/UserRepository.java b/src/main/java/com/agenticcp/core/domain/user/repository/UserRepository.java
index e7d0310e0..b38c31604 100644
--- a/src/main/java/com/agenticcp/core/domain/user/repository/UserRepository.java
+++ b/src/main/java/com/agenticcp/core/domain/user/repository/UserRepository.java
@@ -3,8 +3,8 @@
import com.agenticcp.core.domain.user.entity.User;
import com.agenticcp.core.common.enums.Status;
import com.agenticcp.core.common.enums.UserRole;
+import com.agenticcp.core.common.repository.TenantAwareRepository;
import com.agenticcp.core.domain.tenant.entity.Tenant;
-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;
@@ -14,7 +14,7 @@
import java.util.Optional;
@Repository
-public interface UserRepository extends JpaRepository {
+public interface UserRepository extends TenantAwareRepository {
Optional findByUsername(String username);
diff --git a/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java b/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java
index a4206bc01..78271d661 100644
--- a/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java
+++ b/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java
@@ -229,7 +229,6 @@ public Permission createPermission(CreatePermissionRequest request) {
.permissionKey(request.getPermissionKey())
.permissionName(request.getPermissionName())
.description(request.getDescription())
- .tenant(currentTenant)
.resource(request.getResource())
.action(request.getAction())
.isSystem(false)
diff --git a/src/main/java/com/agenticcp/core/domain/user/service/RoleService.java b/src/main/java/com/agenticcp/core/domain/user/service/RoleService.java
index b9e544697..ead4e0f89 100644
--- a/src/main/java/com/agenticcp/core/domain/user/service/RoleService.java
+++ b/src/main/java/com/agenticcp/core/domain/user/service/RoleService.java
@@ -1,6 +1,7 @@
package com.agenticcp.core.domain.user.service;
import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.dto.exception.ApiResponse;
import com.agenticcp.core.common.util.LogMaskingUtils;
import com.agenticcp.core.common.enums.Status;
import com.agenticcp.core.common.exception.BusinessException;
@@ -176,7 +177,6 @@ public Role createRole(CreateRoleRequest request) {
.roleKey(request.getRoleKey())
.roleName(request.getRoleName())
.description(request.getDescription())
- .tenant(currentTenant)
.isSystem(Boolean.TRUE.equals(request.getIsSystem()))
.isDefault(false)
.priority(request.getPriority())
diff --git a/src/main/java/com/agenticcp/core/domain/user/service/SystemRolePermissionInitializer.java b/src/main/java/com/agenticcp/core/domain/user/service/SystemRolePermissionInitializer.java
index e140c3aea..70f9e2fe2 100644
--- a/src/main/java/com/agenticcp/core/domain/user/service/SystemRolePermissionInitializer.java
+++ b/src/main/java/com/agenticcp/core/domain/user/service/SystemRolePermissionInitializer.java
@@ -179,7 +179,6 @@ private Permission createPermission(Tenant tenant, String key, String name, Stri
.permissionKey(key)
.permissionName(name)
.description(description)
- .tenant(tenant)
.resource(resource)
.action(action)
.isSystem(isSystem)
@@ -198,7 +197,6 @@ private Role createRole(Tenant tenant, String key, String name, String descripti
.roleKey(key)
.roleName(name)
.description(description)
- .tenant(tenant)
.permissions(permissions)
.isSystem(isSystem)
.isDefault(isDefault)
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 608705f2f..b62e9858b 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -4,13 +4,13 @@ spring:
profiles:
active: local,notification
-
+
datasource:
url: ${DATABASE_URL:jdbc:mysql://localhost:3306/agenticcp?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC}
username: ${DATABASE_USERNAME:agenticcp}
password: ${DATABASE_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
-
+
jpa:
hibernate:
ddl-auto: update
@@ -20,7 +20,7 @@ spring:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true
open-in-view: false
-
+
devtools:
restart:
enabled: true
@@ -37,7 +37,6 @@ aws:
region: ${AWS_REGION:us-east-1}
endpoint: ${AWS_S3_ENDPOINT:}
-
security:
jwt:
# base64 인코딩된 256비트 이상 키를 사용하세요. 예시는 개발용입니다.
@@ -97,12 +96,12 @@ spring:
on-profile: docker
datasource:
url: jdbc:mysql://mysql:3306/agenticcp?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
- username: agenticcp
- password: agenticcppassword
+ username: ${DATABASE_USERNAME:agenticcp}
+ password: ${DATABASE_PASSWORD:agenticcppassword}
cloud:
stub:
- enabled: true # Docker 개발 환경용 Stub 활성화
+ enabled: true # Docker 개발 환경용 Stub 활성화
---
spring:
@@ -110,10 +109,10 @@ spring:
activate:
on-profile: prod
datasource:
- url: ${DATABASE_URL:jdbc:mysql://localhost:3306/agenticcp}
- username: ${DATABASE_USERNAME:agenticcp}
- password: ${DATABASE_PASSWORD:agenticcppassword}
+ url: ${DATABASE_URL}
+ username: ${DATABASE_USERNAME}
+ password: ${DATABASE_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
- show-sql: false
\ No newline at end of file
+ show-sql: false
diff --git a/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java b/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java
index 944cfd8d4..5587f722e 100644
--- a/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java
+++ b/src/test/java/com/agenticcp/core/AgenticCpCoreApplicationTests.java
@@ -10,7 +10,18 @@
@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",
+ "security.jwt.secret=ZmFrZV9zZWNyZXRfZm9yX2Rldl9vbmx5X3VzZV9jaGFuZ2VfbWU=",
+ "config.cipher.key=MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
})
@ActiveProfiles("test")
@Import(MockAdaptersConfig.class)
@@ -29,3 +40,4 @@ void contextLoads() {
// Redis가 비활성화된 상태에서도 정상 동작하는지 확인
}
}
+
\ No newline at end of file
diff --git a/src/test/java/com/agenticcp/core/common/audit/AuditControllerTest.java b/src/test/java/com/agenticcp/core/common/audit/AuditControllerTest.java
index 4e21b4c4f..c902c45a5 100644
--- a/src/test/java/com/agenticcp/core/common/audit/AuditControllerTest.java
+++ b/src/test/java/com/agenticcp/core/common/audit/AuditControllerTest.java
@@ -1,12 +1,15 @@
package com.agenticcp.core.common.audit;
+import com.agenticcp.core.common.enums.AuditResourceType;
+import com.agenticcp.core.common.enums.AuditSeverity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.junit.jupiter.api.Disabled;
+import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -15,11 +18,13 @@
* AuditController 애노테이션 테스트
*/
@ExtendWith(MockitoExtension.class)
-@Disabled("Controller test disabled")
class AuditControllerTest {
private MockMvc mockMvc;
+ @Mock
+ private WebApplicationContext webApplicationContext;
+
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(new TestAuditController()).build();
diff --git a/src/test/java/com/agenticcp/core/common/audit/AuditLoggingIntegrationTest.java b/src/test/java/com/agenticcp/core/common/audit/AuditLoggingIntegrationTest.java
index d3d210dfc..2f6a35ab3 100644
--- a/src/test/java/com/agenticcp/core/common/audit/AuditLoggingIntegrationTest.java
+++ b/src/test/java/com/agenticcp/core/common/audit/AuditLoggingIntegrationTest.java
@@ -1,9 +1,11 @@
package com.agenticcp.core.common.audit;
+import com.agenticcp.core.common.enums.AuditResourceType;
+import com.agenticcp.core.common.enums.AuditSeverity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.junit.jupiter.api.Disabled;
+import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@@ -17,7 +19,6 @@
* AuditController와 AuditRequired 애노테이션이 함께 작동하는지 검증
*/
@ExtendWith(MockitoExtension.class)
-@Disabled("Integration test disabled")
class AuditLoggingIntegrationTest {
private MockMvc mockMvc;
diff --git a/src/test/java/com/agenticcp/core/common/audit/AuditRequiredTest.java b/src/test/java/com/agenticcp/core/common/audit/AuditRequiredTest.java
index 19513270c..256b6ebd4 100644
--- a/src/test/java/com/agenticcp/core/common/audit/AuditRequiredTest.java
+++ b/src/test/java/com/agenticcp/core/common/audit/AuditRequiredTest.java
@@ -1,8 +1,11 @@
package com.agenticcp.core.common.audit;
+import com.agenticcp.core.common.enums.AuditResourceType;
+import com.agenticcp.core.common.enums.AuditSeverity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
diff --git a/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java b/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java
new file mode 100644
index 000000000..ccfbf8d38
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java
@@ -0,0 +1,221 @@
+package com.agenticcp.core.common.context;
+
+import com.agenticcp.core.common.enums.CommonErrorCode;
+import com.agenticcp.core.common.enums.Status;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * TenantContextHolder 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-01
+ */
+@DisplayName("TenantContextHolder 단위 테스트")
+class TenantContextHolderTest {
+
+ private Tenant testTenant1;
+ private Tenant testTenant2;
+
+ @BeforeEach
+ void setUp() {
+ // 테스트용 테넌트 생성
+ testTenant1 = Tenant.builder()
+ .tenantKey("tenant1")
+ .tenantName("Test Tenant 1")
+ .status(Status.ACTIVE)
+ .build();
+ testTenant1.setId(1L);
+
+ testTenant2 = Tenant.builder()
+ .tenantKey("tenant2")
+ .tenantName("Test Tenant 2")
+ .status(Status.ACTIVE)
+ .build();
+ testTenant2.setId(2L);
+ }
+
+ @AfterEach
+ void tearDown() {
+ // 각 테스트 후 컨텍스트 정리
+ TenantContextHolder.clear();
+ }
+
+ @Test
+ @DisplayName("테넌트 설정 및 조회 - 성공")
+ void testSetAndGetTenant_Success() {
+ // Given
+ TenantContextHolder.setTenant(testTenant1);
+
+ // When
+ Tenant retrievedTenant = TenantContextHolder.getCurrentTenant();
+ String retrievedTenantKey = TenantContextHolder.getCurrentTenantKey();
+
+ // Then
+ assertThat(retrievedTenant).isNotNull();
+ assertThat(retrievedTenant.getId()).isEqualTo(1L);
+ assertThat(retrievedTenant.getTenantKey()).isEqualTo("tenant1");
+ assertThat(retrievedTenantKey).isEqualTo("tenant1");
+ }
+
+ @Test
+ @DisplayName("테넌트 키로 설정 및 조회 - 성공")
+ void testSetAndGetTenantKey_Success() {
+ // Given
+ TenantContextHolder.setTenantKey("tenant1");
+
+ // When
+ String retrievedTenantKey = TenantContextHolder.getCurrentTenantKey();
+ boolean hasContext = TenantContextHolder.hasTenantContext();
+
+ // Then
+ assertThat(retrievedTenantKey).isEqualTo("tenant1");
+ assertThat(hasContext).isTrue();
+ }
+
+ @Test
+ @DisplayName("테넌트 컨텍스트 존재 여부 확인 - 성공")
+ void testHasTenantContext_Success() {
+ // Given
+ assertThat(TenantContextHolder.hasTenantContext()).isFalse();
+
+ // When
+ TenantContextHolder.setTenant(testTenant1);
+
+ // Then
+ assertThat(TenantContextHolder.hasTenantContext()).isTrue();
+ }
+
+ @Test
+ @DisplayName("테넌트 컨텍스트 존재 여부 확인 - 실패")
+ void testHasTenantContext_Failure() {
+ // When & Then
+ assertThat(TenantContextHolder.hasTenantContext()).isFalse();
+ }
+
+ @Test
+ @DisplayName("테넌트 조회 - 컨텍스트 없음")
+ void testGetCurrentTenant_NoContext() {
+ // When
+ Tenant retrievedTenant = TenantContextHolder.getCurrentTenant();
+
+ // Then
+ assertThat(retrievedTenant).isNull();
+ }
+
+ @Test
+ @DisplayName("테넌트 키 조회 - 컨텍스트 없음")
+ void testGetCurrentTenantKey_NoContext() {
+ // When
+ String retrievedTenantKey = TenantContextHolder.getCurrentTenantKey();
+
+ // Then
+ assertThat(retrievedTenantKey).isNull();
+ }
+
+ @Test
+ @DisplayName("테넌트 조회 - 예외 발생 (컨텍스트 없음)")
+ void testGetCurrentTenantOrThrow_NoContext() {
+ // When & Then
+ assertThatThrownBy(() -> TenantContextHolder.getCurrentTenantOrThrow())
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", CommonErrorCode.TENANT_CONTEXT_NOT_SET)
+ .hasMessageContaining("테넌트 컨텍스트가 설정되지 않았습니다.");
+ }
+
+ @Test
+ @DisplayName("테넌트 키 조회 - 예외 발생 (컨텍스트 없음)")
+ void testGetCurrentTenantKeyOrThrow_NoContext() {
+ // When & Then
+ assertThatThrownBy(() -> TenantContextHolder.getCurrentTenantKeyOrThrow())
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", CommonErrorCode.TENANT_CONTEXT_NOT_SET)
+ .hasMessageContaining("테넌트 컨텍스트가 설정되지 않았습니다.");
+ }
+
+ @Test
+ @DisplayName("테넌트 조회 - 예외 발생 (성공)")
+ void testGetCurrentTenantOrThrow_Success() {
+ // Given
+ TenantContextHolder.setTenant(testTenant1);
+
+ // When
+ Tenant retrievedTenant = TenantContextHolder.getCurrentTenantOrThrow();
+
+ // Then
+ assertThat(retrievedTenant).isNotNull();
+ assertThat(retrievedTenant.getId()).isEqualTo(1L);
+ assertThat(retrievedTenant.getTenantKey()).isEqualTo("tenant1");
+ }
+
+ @Test
+ @DisplayName("테넌트 키 조회 - 예외 발생 (성공)")
+ void testGetCurrentTenantKeyOrThrow_Success() {
+ // Given
+ TenantContextHolder.setTenantKey("tenant1");
+
+ // When
+ String retrievedTenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+
+ // Then
+ assertThat(retrievedTenantKey).isEqualTo("tenant1");
+ }
+
+ @Test
+ @DisplayName("테넌트 컨텍스트 변경 - 성공")
+ void testChangeTenantContext_Success() {
+ // Given
+ TenantContextHolder.setTenant(testTenant1);
+ assertThat(TenantContextHolder.getCurrentTenantKey()).isEqualTo("tenant1");
+
+ // When
+ TenantContextHolder.setTenant(testTenant2);
+
+ // Then
+ assertThat(TenantContextHolder.getCurrentTenantKey()).isEqualTo("tenant2");
+ assertThat(TenantContextHolder.getCurrentTenant().getId()).isEqualTo(2L);
+ }
+
+ @Test
+ @DisplayName("테넌트 컨텍스트 정리 - 성공")
+ void testClearTenantContext_Success() {
+ // Given
+ TenantContextHolder.setTenant(testTenant1);
+ assertThat(TenantContextHolder.hasTenantContext()).isTrue();
+
+ // When
+ TenantContextHolder.clear();
+
+ // Then
+ assertThat(TenantContextHolder.hasTenantContext()).isFalse();
+ assertThat(TenantContextHolder.getCurrentTenant()).isNull();
+ assertThat(TenantContextHolder.getCurrentTenantKey()).isNull();
+ }
+
+ @Test
+ @DisplayName("ThreadLocal 격리 테스트 - 성공")
+ void testThreadLocalIsolation_Success() throws InterruptedException {
+ // Given
+ TenantContextHolder.setTenant(testTenant1);
+ assertThat(TenantContextHolder.getCurrentTenantKey()).isEqualTo("tenant1");
+
+ // When - 다른 스레드에서 다른 테넌트 설정
+ Thread otherThread = new Thread(() -> {
+ TenantContextHolder.setTenant(testTenant2);
+ assertThat(TenantContextHolder.getCurrentTenantKey()).isEqualTo("tenant2");
+ });
+
+ otherThread.start();
+ otherThread.join();
+
+ // Then - 원래 스레드의 컨텍스트는 변경되지 않음
+ assertThat(TenantContextHolder.getCurrentTenantKey()).isEqualTo("tenant1");
+ }
+}
diff --git a/src/test/java/com/agenticcp/core/common/entity/BaseEntityTest.java b/src/test/java/com/agenticcp/core/common/entity/BaseEntityTest.java
new file mode 100644
index 000000000..5b62ebc65
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/common/entity/BaseEntityTest.java
@@ -0,0 +1,176 @@
+package com.agenticcp.core.common.entity;
+
+import com.agenticcp.core.common.enums.Status;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.user.entity.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * BaseEntity 기본 필드 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-01
+ */
+@DisplayName("BaseEntity 기본 필드 단위 테스트")
+class BaseEntityTest {
+
+ private Tenant testTenant1;
+ private Tenant testTenant2;
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ // 테스트용 테넌트 생성
+ testTenant1 = Tenant.builder()
+ .tenantKey("tenant1")
+ .tenantName("Test Tenant 1")
+ .status(Status.ACTIVE)
+ .build();
+ testTenant1.setId(1L);
+
+ testTenant2 = Tenant.builder()
+ .tenantKey("tenant2")
+ .tenantName("Test Tenant 2")
+ .status(Status.ACTIVE)
+ .build();
+ testTenant2.setId(2L);
+
+ // 테스트용 사용자 생성
+ testUser = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+ }
+
+ @Test
+ @DisplayName("BaseEntity 기본 필드 설정 및 조회")
+ void testSetAndGetBasicFields() {
+ // Given
+ LocalDateTime now = LocalDateTime.now();
+ testUser.setId(1L);
+ testUser.setCreatedAt(now);
+ testUser.setUpdatedAt(now);
+ testUser.setCreatedBy("admin");
+ testUser.setUpdatedBy("admin");
+ testUser.setIsDeleted(false);
+
+ // When & Then
+ assertThat(testUser.getId()).isEqualTo(1L);
+ assertThat(testUser.getCreatedAt()).isEqualTo(now);
+ assertThat(testUser.getUpdatedAt()).isEqualTo(now);
+ assertThat(testUser.getCreatedBy()).isEqualTo("admin");
+ assertThat(testUser.getUpdatedBy()).isEqualTo("admin");
+ assertThat(testUser.getIsDeleted()).isFalse();
+ }
+
+ @Test
+ @DisplayName("BaseEntity 상속 객체의 기본 필드들 확인")
+ void testBaseEntityFields() {
+ // Given
+ LocalDateTime now = LocalDateTime.now();
+ testUser.setId(1L);
+ testUser.setCreatedAt(now);
+ testUser.setUpdatedAt(now);
+ testUser.setCreatedBy("admin");
+ testUser.setUpdatedBy("admin");
+ testUser.setIsDeleted(false);
+
+ // When & Then
+ assertThat(testUser.getId()).isEqualTo(1L);
+ assertThat(testUser.getCreatedAt()).isEqualTo(now);
+ assertThat(testUser.getUpdatedAt()).isEqualTo(now);
+ assertThat(testUser.getCreatedBy()).isEqualTo("admin");
+ assertThat(testUser.getUpdatedBy()).isEqualTo("admin");
+ assertThat(testUser.getIsDeleted()).isFalse();
+ }
+
+ @Test
+ @DisplayName("BaseEntity 상속 객체의 toString 메서드 확인")
+ void testToString() {
+ // Given
+ testUser.setId(1L);
+ testUser.setUsername("testuser");
+ testUser.setEmail("test@example.com");
+
+ // When
+ String toString = testUser.toString();
+
+ // Then
+ assertThat(toString).contains("username=testuser");
+ assertThat(toString).contains("email=test@example.com");
+ // id는 Lombok @Data에서 toString에 포함되지 않을 수 있으므로 제거
+ }
+
+ @Test
+ @DisplayName("BaseEntity 상속 객체의 equals와 hashCode 확인")
+ void testEqualsAndHashCode() {
+ // Given
+ User user1 = User.builder()
+ .username("user1")
+ .email("user1@example.com")
+ .build();
+ user1.setId(1L);
+
+ User user2 = User.builder()
+ .username("user1")
+ .email("user1@example.com")
+ .build();
+ user2.setId(1L);
+
+ User user3 = User.builder()
+ .username("user2")
+ .email("user2@example.com")
+ .build();
+ user3.setId(2L);
+
+ // When & Then
+ assertThat(user1).isEqualTo(user2);
+ assertThat(user1).isNotEqualTo(user3);
+ assertThat(user1.hashCode()).isEqualTo(user2.hashCode());
+ assertThat(user1.hashCode()).isNotEqualTo(user3.hashCode());
+ }
+
+ @Test
+ @DisplayName("BaseEntity 상속 객체의 JPA 매핑 어노테이션 확인")
+ void testJpaMappingAnnotations() {
+ // Given
+ testUser.setId(1L);
+ testUser.setUsername("testuser");
+ testUser.setEmail("test@example.com");
+
+ // When & Then
+ // @Entity, @Table 어노테이션이 올바르게 설정되었는지 확인
+ assertThat(testUser.getId()).isNotNull();
+ assertThat(testUser.getUsername()).isNotNull();
+ assertThat(testUser.getEmail()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("BaseEntity 상속 객체의 복합 필드 설정")
+ void testComplexFieldSetting() {
+ // Given
+ LocalDateTime now = LocalDateTime.now();
+ testUser.setId(1L);
+ testUser.setCreatedAt(now);
+ testUser.setUpdatedAt(now);
+ testUser.setCreatedBy("admin");
+ testUser.setUpdatedBy("admin");
+ testUser.setIsDeleted(false);
+
+ // When & Then
+ // 모든 필드가 올바르게 설정되었는지 확인
+ assertThat(testUser.getId()).isEqualTo(1L);
+ assertThat(testUser.getCreatedAt()).isEqualTo(now);
+ assertThat(testUser.getUpdatedAt()).isEqualTo(now);
+ assertThat(testUser.getCreatedBy()).isEqualTo("admin");
+ assertThat(testUser.getUpdatedBy()).isEqualTo("admin");
+ assertThat(testUser.getIsDeleted()).isFalse();
+ }
+}
diff --git a/src/test/java/com/agenticcp/core/common/entity/TenantAwareEntityListenerTest.java b/src/test/java/com/agenticcp/core/common/entity/TenantAwareEntityListenerTest.java
new file mode 100644
index 000000000..adf57225f
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/common/entity/TenantAwareEntityListenerTest.java
@@ -0,0 +1,249 @@
+package com.agenticcp.core.common.entity;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.enums.CommonErrorCode;
+import com.agenticcp.core.common.enums.Status;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.user.entity.User;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * TenantAwareEntityListener 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-01
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("TenantAwareEntityListener 단위 테스트")
+class TenantAwareEntityListenerTest {
+
+ private TenantAwareEntityListener listener;
+ private Tenant testTenant;
+ private Tenant existingTenant;
+
+ @BeforeEach
+ void setUp() {
+ listener = new TenantAwareEntityListener();
+
+ testTenant = Tenant.builder()
+ .tenantKey("test-tenant")
+ .tenantName("Test Tenant")
+ .status(Status.ACTIVE)
+ .build();
+ testTenant.setId(1L);
+
+ existingTenant = Tenant.builder()
+ .tenantKey("existing-tenant")
+ .tenantName("Existing Tenant")
+ .status(Status.ACTIVE)
+ .build();
+ existingTenant.setId(2L);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ @Test
+ @DisplayName("엔티티 생성 시 테넌트 자동 설정 - 성공")
+ void testPrePersist_SetTenant_Success() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+
+ // When
+ listener.prePersist(user);
+
+ // Then
+ assertThat(user.getTenant()).isNotNull();
+ assertThat(user.getTenant().getId()).isEqualTo(1L);
+ assertThat(user.getTenant().getTenantKey()).isEqualTo("test-tenant");
+ }
+
+ @Test
+ @DisplayName("엔티티 생성 시 테넌트 컨텍스트 없음 - 예외 발생")
+ void testPrePersist_NoTenantContext_ThrowsException() {
+ // Given
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> listener.prePersist(user))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", CommonErrorCode.TENANT_CONTEXT_NOT_SET)
+ .hasMessageContaining("Tenant context is required for entity operations");
+ }
+
+ @Test
+ @DisplayName("엔티티 생성 시 이미 테넌트 설정됨 - 건너뛰기")
+ void testPrePersist_TenantAlreadySet_Skip() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+ user.setTenant(existingTenant); // 이미 다른 테넌트 설정
+
+ // When
+ listener.prePersist(user);
+
+ // Then - 기존 테넌트 유지
+ assertThat(user.getTenant()).isNotNull();
+ assertThat(user.getTenant().getId()).isEqualTo(2L);
+ assertThat(user.getTenant().getTenantKey()).isEqualTo("existing-tenant");
+ }
+
+ @Test
+ @DisplayName("엔티티 수정 시 테넌트 자동 설정 - 성공")
+ void testPreUpdate_SetTenant_Success() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+
+ // When
+ listener.preUpdate(user);
+
+ // Then
+ assertThat(user.getTenant()).isNotNull();
+ assertThat(user.getTenant().getId()).isEqualTo(1L);
+ assertThat(user.getTenant().getTenantKey()).isEqualTo("test-tenant");
+ }
+
+ @Test
+ @DisplayName("엔티티 수정 시 테넌트 컨텍스트 없음 - 예외 발생")
+ void testPreUpdate_NoTenantContext_ThrowsException() {
+ // Given
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> listener.preUpdate(user))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", CommonErrorCode.TENANT_CONTEXT_NOT_SET)
+ .hasMessageContaining("Tenant context is required for entity operations");
+ }
+
+ @Test
+ @DisplayName("엔티티 수정 시 이미 테넌트 설정됨 - 건너뛰기")
+ void testPreUpdate_TenantAlreadySet_Skip() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+ user.setTenant(existingTenant); // 이미 다른 테넌트 설정
+
+ // When
+ listener.preUpdate(user);
+
+ // Then - 기존 테넌트 유지
+ assertThat(user.getTenant()).isNotNull();
+ assertThat(user.getTenant().getId()).isEqualTo(2L);
+ assertThat(user.getTenant().getTenantKey()).isEqualTo("existing-tenant");
+ }
+
+ @Test
+ @DisplayName("BaseEntity가 아닌 객체 처리 - 건너뛰기")
+ void testPrePersist_NonBaseEntity_Skip() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String nonEntity = "not an entity";
+
+ // When & Then - 예외 없이 실행되어야 함
+ assertThatCode(() -> listener.prePersist(nonEntity))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("BaseEntity가 아닌 객체 처리 (preUpdate) - 건너뛰기")
+ void testPreUpdate_NonBaseEntity_Skip() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String nonEntity = "not an entity";
+
+ // When & Then - 예외 없이 실행되어야 함
+ assertThatCode(() -> listener.preUpdate(nonEntity))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("null 객체 처리 - 건너뛰기")
+ void testPrePersist_NullObject_Skip() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+
+ // When & Then - 예외 없이 실행되어야 함
+ assertThatCode(() -> listener.prePersist(null))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("null 객체 처리 (preUpdate) - 건너뛰기")
+ void testPreUpdate_NullObject_Skip() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+
+ // When & Then - 예외 없이 실행되어야 함
+ assertThatCode(() -> listener.preUpdate(null))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("테넌트 키만 설정된 경우 - 예외 발생")
+ void testPrePersist_TenantKeyOnly_ThrowsException() {
+ // Given
+ TenantContextHolder.setTenantKey("test-tenant");
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+
+ // When & Then
+ assertThatThrownBy(() -> listener.prePersist(user))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", CommonErrorCode.TENANT_CONTEXT_NOT_SET)
+ .hasMessageContaining("Tenant context is required for entity operations");
+ }
+
+ @Test
+ @DisplayName("다양한 BaseEntity 상속 객체 처리 - 성공")
+ void testPrePersist_DifferentBaseEntityTypes_Success() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+
+ // User 엔티티
+ User user = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .build();
+
+ // When
+ listener.prePersist(user);
+
+ // Then
+ assertThat(user.getTenant()).isNotNull();
+ assertThat(user.getTenant().getId()).isEqualTo(1L);
+ }
+}
diff --git a/src/test/java/com/agenticcp/core/common/interceptor/TenantAwareInterceptorTest.java b/src/test/java/com/agenticcp/core/common/interceptor/TenantAwareInterceptorTest.java
new file mode 100644
index 000000000..29a3ab39a
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/common/interceptor/TenantAwareInterceptorTest.java
@@ -0,0 +1,322 @@
+package com.agenticcp.core.common.interceptor;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.enums.Status;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * TenantAwareInterceptor 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-01
+ */
+@DisplayName("TenantAwareInterceptor 단위 테스트")
+class TenantAwareInterceptorTest {
+
+ private TenantAwareInterceptor interceptor;
+ private Tenant testTenant;
+
+ @BeforeEach
+ void setUp() {
+ interceptor = new TenantAwareInterceptor();
+
+ testTenant = Tenant.builder()
+ .tenantKey("test-tenant")
+ .tenantName("Test Tenant")
+ .status(Status.ACTIVE)
+ .build();
+ testTenant.setId(1L);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ @Test
+ @DisplayName("SELECT 쿼리 필터링 - 테넌트 컨텍스트 있음")
+ void testInspect_SelectQuery_WithTenantContext() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "SELECT * FROM users";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ assertThat(result).contains("SELECT * FROM users");
+ }
+
+ @Test
+ @DisplayName("SELECT 쿼리 필터링 - 테넌트 컨텍스트 없음")
+ void testInspect_SelectQuery_NoTenantContext() {
+ // Given
+ String originalSql = "SELECT * FROM users";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isEqualTo(originalSql);
+ }
+
+ @Test
+ @DisplayName("SELECT 쿼리 필터링 - 테이블 별칭 있음")
+ void testInspect_SelectQuery_WithTableAlias() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "SELECT u.* FROM users u";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ }
+
+ @Test
+ @DisplayName("UPDATE 쿼리 필터링 - 테넌트 컨텍스트 있음")
+ void testInspect_UpdateQuery_WithTenantContext() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "UPDATE users SET username = 'newuser'";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ assertThat(result).contains("UPDATE users SET username = 'newuser'");
+ }
+
+ @Test
+ @DisplayName("UPDATE 쿼리 필터링 - 테넌트 컨텍스트 없음")
+ void testInspect_UpdateQuery_NoTenantContext() {
+ // Given
+ String originalSql = "UPDATE users SET username = 'newuser'";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isEqualTo(originalSql);
+ }
+
+ @Test
+ @DisplayName("DELETE 쿼리 필터링 - 테넌트 컨텍스트 있음")
+ void testInspect_DeleteQuery_WithTenantContext() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "DELETE FROM users";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ assertThat(result).contains("DELETE FROM users");
+ }
+
+ @Test
+ @DisplayName("DELETE 쿼리 필터링 - 테넌트 컨텍스트 없음")
+ void testInspect_DeleteQuery_NoTenantContext() {
+ // Given
+ String originalSql = "DELETE FROM users";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isEqualTo(originalSql);
+ }
+
+ @Test
+ @DisplayName("INSERT 쿼리 - tenant_id 자동 주입")
+ void testInspect_InsertQuery_WithTenantContext() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "INSERT INTO users (username, email) VALUES ('testuser', 'test@example.com')";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("tenant_id");
+ assertThat(result).contains("'test-tenant'");
+ assertThat(result).contains("INSERT INTO users");
+ }
+
+ @Test
+ @DisplayName("INSERT 쿼리 - 테넌트 컨텍스트 없음")
+ void testInspect_InsertQuery_NoTenantContext() {
+ // Given
+ String originalSql = "INSERT INTO users (username, email) VALUES ('testuser', 'test@example.com')";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isEqualTo(originalSql);
+ }
+
+ @Test
+ @DisplayName("INSERT 쿼리 - 이미 tenant_id 포함")
+ void testInspect_InsertQuery_AlreadyHasTenantId() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "INSERT INTO users (username, email, tenant_id) VALUES ('testuser', 'test@example.com', 'other-tenant')";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ // 기존 tenant_id 값이 유지되어야 함
+ assertThat(result).contains("tenant_id");
+ assertThat(result).contains("'other-tenant'");
+ }
+
+ @Test
+ @DisplayName("대소문자 혼합 SQL 쿼리 처리")
+ void testInspect_MixedCaseSQL() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "SeLeCt * FrOm UsErS";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ }
+
+ @Test
+ @DisplayName("복잡한 SELECT 쿼리 처리")
+ void testInspect_ComplexSelectQuery() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "SELECT u.id, u.username, o.name FROM users u JOIN organizations o ON u.org_id = o.id WHERE u.status = 'ACTIVE'";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ assertThat(result).contains("u.status = 'ACTIVE'");
+ }
+
+ @Test
+ @DisplayName("JOIN이 포함된 SELECT 쿼리 처리")
+ void testInspect_SelectWithJoin() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "SELECT u.*, o.name FROM users u LEFT JOIN organizations o ON u.org_id = o.id";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ }
+
+ @Test
+ @DisplayName("서브쿼리가 포함된 SELECT 쿼리 처리")
+ void testInspect_SelectWithSubquery() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "SELECT * FROM users WHERE id IN (SELECT user_id FROM user_roles)";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result).contains("WHERE");
+ assertThat(result).contains("tenant_id = 'test-tenant'");
+ }
+
+ @Test
+ @DisplayName("null SQL 처리")
+ void testInspect_NullSQL() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+
+ // When & Then
+ assertThatCode(() -> interceptor.inspect(null))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("빈 문자열 SQL 처리")
+ void testInspect_EmptySQL() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isEqualTo(originalSql);
+ }
+
+ @Test
+ @DisplayName("인식되지 않는 SQL 타입 처리")
+ void testInspect_UnknownSQLType() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "CREATE TABLE test (id INT)";
+
+ // When
+ String result = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result).isEqualTo(originalSql);
+ }
+
+ @Test
+ @DisplayName("테넌트 컨텍스트 변경 시 다른 tenant_id 적용")
+ void testInspect_DifferentTenantContext() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+ String originalSql = "SELECT * FROM users";
+ String result1 = interceptor.inspect(originalSql);
+
+ // When - 다른 테넌트로 변경
+ Tenant otherTenant = Tenant.builder()
+ .tenantKey("other-tenant")
+ .tenantName("Other Tenant")
+ .status(Status.ACTIVE)
+ .build();
+ otherTenant.setId(2L);
+ TenantContextHolder.setTenant(otherTenant);
+ String result2 = interceptor.inspect(originalSql);
+
+ // Then
+ assertThat(result1).contains("tenant_id = 'test-tenant'");
+ assertThat(result2).contains("tenant_id = 'other-tenant'");
+ }
+}
diff --git a/src/test/java/com/agenticcp/core/controller/AdvancedHealthControllerTest.java b/src/test/java/com/agenticcp/core/controller/AdvancedHealthControllerTest.java
index 215a19bcc..67c770ff2 100644
--- a/src/test/java/com/agenticcp/core/controller/AdvancedHealthControllerTest.java
+++ b/src/test/java/com/agenticcp/core/controller/AdvancedHealthControllerTest.java
@@ -198,4 +198,4 @@ void getAvailableComponents_ShouldReturnComponentsList() {
assertThat(response.getBody().getData()).containsKey("application");
}
-}
+}
\ No newline at end of file
diff --git a/src/test/java/com/agenticcp/core/domain/monitoring/service/HealthCheckServiceTest.java b/src/test/java/com/agenticcp/core/domain/monitoring/service/HealthCheckServiceTest.java
index b6bb8f2ad..7170f303a 100644
--- a/src/test/java/com/agenticcp/core/domain/monitoring/service/HealthCheckServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/monitoring/service/HealthCheckServiceTest.java
@@ -65,10 +65,10 @@ void setUp() throws Exception {
.username("superadmin")
.email("admin@agenticcp.com")
.name("플랫폼 운영자")
- .tenant(testAdminTenant)
.role(UserRole.SUPER_ADMIN)
.status(Status.ACTIVE)
.build();
+ testSuperAdmin.setTenant(testAdminTenant);
setId(testSuperAdmin, 1L);
// Mock 기본 동작 설정
@@ -80,9 +80,23 @@ void setUp() throws Exception {
* Reflection을 사용하여 BaseEntity의 id 필드 설정
*/
private void setId(Object entity, Long id) throws Exception {
- java.lang.reflect.Field idField = entity.getClass().getSuperclass().getDeclaredField("id");
- idField.setAccessible(true);
- idField.set(entity, id);
+ Class> currentClass = entity.getClass();
+ java.lang.reflect.Field idField = null;
+
+ // BaseEntity 또는 TenantAwareEntity에서 id 필드 찾기
+ while (currentClass != null && !currentClass.equals(Object.class)) {
+ try {
+ idField = currentClass.getDeclaredField("id");
+ break;
+ } catch (NoSuchFieldException e) {
+ currentClass = currentClass.getSuperclass();
+ }
+ }
+
+ if (idField != null) {
+ idField.setAccessible(true);
+ idField.set(entity, id);
+ }
}
/**
diff --git a/src/test/java/com/agenticcp/core/domain/monitoring/service/MonitoringAlertServiceTest.java b/src/test/java/com/agenticcp/core/domain/monitoring/service/MonitoringAlertServiceTest.java
index a8453993f..f300d8736 100644
--- a/src/test/java/com/agenticcp/core/domain/monitoring/service/MonitoringAlertServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/monitoring/service/MonitoringAlertServiceTest.java
@@ -273,9 +273,23 @@ void handleThresholdExceeded_DifferentTenants_ShouldSendSeparateNotifications()
*/
private void setId(Object entity, Long id) {
try {
- var field = entity.getClass().getSuperclass().getDeclaredField("id");
- field.setAccessible(true);
- field.set(entity, id);
+ Class> currentClass = entity.getClass();
+ java.lang.reflect.Field idField = null;
+
+ // BaseEntity 또는 TenantAwareEntity에서 id 필드 찾기
+ while (currentClass != null && !currentClass.equals(Object.class)) {
+ try {
+ idField = currentClass.getDeclaredField("id");
+ break;
+ } catch (NoSuchFieldException e) {
+ currentClass = currentClass.getSuperclass();
+ }
+ }
+
+ if (idField != null) {
+ idField.setAccessible(true);
+ idField.set(entity, id);
+ }
} catch (Exception e) {
throw new RuntimeException("Failed to set id", e);
}
diff --git a/src/test/java/com/agenticcp/core/domain/notification/service/MonitoringNotificationServiceTest.java b/src/test/java/com/agenticcp/core/domain/notification/service/MonitoringNotificationServiceTest.java
index d9e5dfb1e..c1e397b11 100644
--- a/src/test/java/com/agenticcp/core/domain/notification/service/MonitoringNotificationServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/notification/service/MonitoringNotificationServiceTest.java
@@ -85,10 +85,10 @@ void setUp() throws Exception {
.username("admin")
.email("admin@test.com")
.name("테스트 관리자")
- .tenant(testTenant)
.role(UserRole.TENANT_ADMIN)
.status(Status.ACTIVE)
.build();
+ testAdminUser.setTenant(testTenant);
// Reflection으로 ID 설정
setId(testAdminUser, 10L);
@@ -128,9 +128,23 @@ void setUp() throws Exception {
* Reflection을 사용하여 BaseEntity의 id 필드 설정
*/
private void setId(Object entity, Long id) throws Exception {
- java.lang.reflect.Field idField = entity.getClass().getSuperclass().getDeclaredField("id");
- idField.setAccessible(true);
- idField.set(entity, id);
+ Class> currentClass = entity.getClass();
+ java.lang.reflect.Field idField = null;
+
+ // BaseEntity 또는 TenantAwareEntity에서 id 필드 찾기
+ while (currentClass != null && !currentClass.equals(Object.class)) {
+ try {
+ idField = currentClass.getDeclaredField("id");
+ break;
+ } catch (NoSuchFieldException e) {
+ currentClass = currentClass.getSuperclass();
+ }
+ }
+
+ if (idField != null) {
+ idField.setAccessible(true);
+ idField.set(entity, id);
+ }
}
@AfterEach
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 59e9ba26c..ab816b3cd 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/security/controller/TenantPolicyControllerIntegrationTest.java b/src/test/java/com/agenticcp/core/domain/security/controller/TenantPolicyControllerIntegrationTest.java
index b2f5445c4..f7c51d977 100644
--- a/src/test/java/com/agenticcp/core/domain/security/controller/TenantPolicyControllerIntegrationTest.java
+++ b/src/test/java/com/agenticcp/core/domain/security/controller/TenantPolicyControllerIntegrationTest.java
@@ -94,12 +94,12 @@ void setUp() {
.policyName("테넌트 정책 1")
.policyType(SecurityPolicy.PolicyType.DATA_PROTECTION)
.priority(150)
- .tenant(testTenant)
.status(Status.ACTIVE)
.isGlobal(false)
.isEnabled(true)
.rules("{\"encryptAtRest\": true}")
.build();
+ tenantPolicy.setTenant(testTenant);
policyRepository.save(tenantPolicy);
}
diff --git a/src/test/java/com/agenticcp/core/domain/security/service/TenantPolicyServiceTest.java b/src/test/java/com/agenticcp/core/domain/security/service/TenantPolicyServiceTest.java
index c6f3e0cba..ecb0eb313 100644
--- a/src/test/java/com/agenticcp/core/domain/security/service/TenantPolicyServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/security/service/TenantPolicyServiceTest.java
@@ -89,11 +89,11 @@ void setUp() {
.policyName("테넌트 정책 1")
.policyType(SecurityPolicy.PolicyType.DATA_PROTECTION)
.priority(150)
- .tenant(testTenant)
.isGlobal(false)
.isEnabled(true)
.build();
tenantPolicy1.setId(3L);
+ tenantPolicy1.setTenant(testTenant);
tenantPolicies.add(tenantPolicy1);
}
diff --git a/src/test/java/com/agenticcp/core/domain/ui/service/MenuAuthorizationServiceTest.java b/src/test/java/com/agenticcp/core/domain/ui/service/MenuAuthorizationServiceTest.java
index 998cd1d46..b5be3834d 100644
--- a/src/test/java/com/agenticcp/core/domain/ui/service/MenuAuthorizationServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/ui/service/MenuAuthorizationServiceTest.java
@@ -71,14 +71,15 @@ void setUp() {
testPermission = Permission.builder()
.permissionKey("MENU_READ")
.permissionName("메뉴 조회")
- .tenant(testTenant)
.build();
+ testPermission.setTenant(testTenant);
testRole = Role.builder()
.roleKey("ADMIN")
.roleName("관리자")
.permissions(List.of(testPermission))
.build();
+ testRole.setTenant(testTenant);
testUser = User.builder()
.username("testuser")
@@ -86,6 +87,7 @@ void setUp() {
.roles(List.of(testRole))
.permissions(List.of())
.build();
+ testUser.setTenant(testTenant);
testMenu = Menu.builder()
.menuKey("TEST_MENU")
diff --git a/src/test/java/com/agenticcp/core/domain/ui/service/MenuServiceTest.java b/src/test/java/com/agenticcp/core/domain/ui/service/MenuServiceTest.java
index 6bdf6c762..ef5ada9d0 100644
--- a/src/test/java/com/agenticcp/core/domain/ui/service/MenuServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/ui/service/MenuServiceTest.java
@@ -69,8 +69,8 @@ void setUp() {
.sortOrder(1)
.isActive(true)
.isSystem(false)
- .tenant(testTenant)
.build();
+ testMenu.setTenant(testTenant);
}
@Test
@@ -232,8 +232,8 @@ void deleteMenu_SystemMenu_ThrowsException() {
.menuKey("SYSTEM_MENU")
.menuName("시스템 메뉴")
.isSystem(true)
- .tenant(testTenant)
.build();
+ systemMenu.setTenant(testTenant);
when(menuRepository.findById(1L)).thenReturn(Optional.of(systemMenu));
@@ -254,8 +254,8 @@ void deleteMenu_WithChildren_ThrowsException() {
.menuKey("CHILD_MENU")
.menuName("하위 메뉴")
.parentId(1L)
- .tenant(testTenant)
.build();
+ childMenu.setTenant(testTenant);
when(menuRepository.findById(1L)).thenReturn(Optional.of(testMenu));
when(menuRepository.findByParentIdAndTenant(1L, testTenant))
diff --git a/src/test/java/com/agenticcp/core/domain/user/service/RoleServiceTest.java b/src/test/java/com/agenticcp/core/domain/user/service/RoleServiceTest.java
index cdb6998ce..7f69f362d 100644
--- a/src/test/java/com/agenticcp/core/domain/user/service/RoleServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/user/service/RoleServiceTest.java
@@ -85,24 +85,28 @@ void createRole_assignsPermissions() {
return arg;
}).given(roleRepository).save(any(Role.class));
- Permission p1 = Permission.builder().permissionKey("perm.read").tenant(tenant).build();
- Permission p2 = Permission.builder().permissionKey("perm.write").tenant(tenant).build();
+ Permission p1 = Permission.builder().permissionKey("perm.read").build();
+ p1.setTenant(tenant);
+ Permission p2 = Permission.builder().permissionKey("perm.write").build();
+ p2.setTenant(tenant);
given(permissionRepository.findByPermissionKeyInAndTenant(permissionKeys, tenant))
.willReturn(Arrays.asList(p1, p2));
// assignPermissionsToRole 에서 호출되는 findById(1L)
- given(roleRepository.findById(1L)).willReturn(Optional.of(Role.builder()
- .roleKey("ROLE_DEV").tenant(tenant).build()));
+ Role role = Role.builder().roleKey("ROLE_DEV").build();
+ role.setTenant(tenant);
+ given(roleRepository.findById(1L)).willReturn(Optional.of(role));
// createRole 마지막 반환에서 호출되는 findByIdAndTenantWithPermissions(1L, tenant)
+ Role roleWithPermissions = Role.builder()
+ .roleKey("ROLE_DEV")
+ .roleName("개발자")
+ .description("개발자 권한")
+ .status(Status.ACTIVE)
+ .build();
+ roleWithPermissions.setTenant(tenant);
given(roleRepository.findByIdAndTenantWithPermissions(1L, tenant))
- .willReturn(Optional.of(Role.builder()
- .roleKey("ROLE_DEV")
- .roleName("개발자")
- .description("개발자 권한")
- .tenant(tenant)
- .status(Status.ACTIVE)
- .build()));
+ .willReturn(Optional.of(roleWithPermissions));
// When
Role result = roleService.createRole(request);
@@ -127,14 +131,16 @@ void assignPermissionsToRole_updatesRolePermissions() {
// Given
Role role = Role.builder()
.roleKey("ROLE_USER")
- .tenant(tenant)
.permissions(new ArrayList<>())
.build();
+ role.setTenant(tenant);
role.setId(1L);
List newKeys = Arrays.asList("perm.export", "perm.audit");
- Permission px = Permission.builder().permissionKey("perm.export").tenant(tenant).build();
- Permission py = Permission.builder().permissionKey("perm.audit").tenant(tenant).build();
+ Permission px = Permission.builder().permissionKey("perm.export").build();
+ px.setTenant(tenant);
+ Permission py = Permission.builder().permissionKey("perm.audit").build();
+ py.setTenant(tenant);
given(roleRepository.findById(1L)).willReturn(Optional.of(role));
given(permissionRepository.findByPermissionKeyInAndTenant(newKeys, tenant))
@@ -162,10 +168,10 @@ void deleteRole_systemRole_throws() {
// Given
Role systemRole = Role.builder()
.roleKey("SUPER_ADMIN")
- .tenant(tenant)
.isSystem(true)
.isDefault(false)
.build();
+ systemRole.setTenant(tenant);
given(roleRepository.findByRoleKeyAndTenantWithPermissions("SUPER_ADMIN", tenant))
.willReturn(Optional.of(systemRole));
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 68b07f873..838b1ad9f 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -7,8 +7,8 @@ spring:
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:
@@ -40,9 +40,10 @@ config:
cipher:
key: ${CONFIG_CIPHER_KEY:MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=}
+# 테스트용 JWT 설정
security:
jwt:
- secret: ZmFrZV9zZWNyZXRfZm9yX2Rldl9vbmx5X3VzZV9jaGFuZ2VfbWU=
+ secret: ${JWT_SECRET:ZmFrZV9zZWNyZXRfZm9yX2Rldl9vbmx5X3VzZV9jaGFuZ2VfbWU=}
access-token-expiration-ms: 3600000
refresh-token-expiration-ms: 604800000
diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 000000000..1f0955d45
--- /dev/null
+++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline