3.1 의존성 수집

애플리케이션의 모든 컴포넌트(Controller, Service, Repository)와 그들 간의 의존 관계를 자동으로 파악하는 기능입니다.


3.2 DependencyCollector 개요

3.2.1 핵심 아이디어

Spring의 Reflection API를 활용하여 생성자 파라미터를 분석합니다.

@Service
public class UserService {
	private final UserRepository userRepository; // ← 의존성!
	private final EmailService emailService; // ← 의존성!
 
	// 생성자를 분석하면 의존성을 알 수 있음
 
	public UserService(UserRepository userRepository, EmailService emailService) {
	this.userRepository = userRepository;
	this.emailService = emailService;
	}
}

3.2.2 실행 시점

@Slf4j
@RequiredArgsConstructor
public class DependencyCollector {
	private final ApplicationContext applicationContext;
	private final ObjectMapper objectMapper;
	private final DependencyLogSender sender;
	private final DatabaseDetector databaseDetector;
 
	@Value("${spring.application.name:unknown-project}")
	private String projectName;
 
	@EventListener(ApplicationReadyEvent.class)
	public void collectDependencies() {
		log.info("🚀 의존성 수집 시작...");
		// ...
	}
}
  • @EventListener(ApplicationReadyEvent.class): 애플리케이션이 완전히 시작된 후 실행
  • 모든 Bean이 생성되고 의존성 주입이 완료된 상태

3.3 수집 프로세스

3.3.1 Step 1: 컴포넌트 수집

public void collectDependencies() {
	log.info("🚀 의존성 수집 시작...");
	Map<String, Component> componentMap = new LinkedHashMap<>();
	List<DependencyRelation> relations = new ArrayList<>();
 
	// 1. Controller 수집 (어노테이션 기반)
	collectBeansWithAnnotation(RestController.class, componentMap, relations);
 
	// 2. Service 수집 (어노테이션 기반)
	collectBeansWithAnnotation(Service.class, componentMap, relations);
 
	// 3-1. Interface-based Repository (JPA, MyBatis 등)
	collectInterfaceBasedRepositories(componentMap, relations);
 
	// 3-2. Class-based Repository (JDBC, Custom 등)
	collectClassBasedRepositories(componentMap, relations);
 
	// 4. 데이터베이스 감지
	List<String> databases = databaseDetector.detectAllDatabases();
	log.info("✅ 수집 완료! (컴포넌트: {}, 관계: {}, DB: {})",
	componentMap.size(), relations.size(), databases.size());
}

3.3.2 Step 2: 어노테이션 기반 Bean 수집

private void collectBeansWithAnnotation(  
        Class<? extends java.lang.annotation.Annotation> annotationClass,  
        Map<String, Component> componentMap,  
        List<DependencyRelation> relations) {  
  
	// Spring에게 해당 어노테이션이 붙은 모든 Bean을 요청
    Map<String, Object> beans = applicationContext.getBeansWithAnnotation(annotationClass);  
    log.debug("🔍 {} Bean 수집: {} 개", annotationClass.getSimpleName(), beans.size());  
  
    for (Map.Entry<String, Object> entry : beans.entrySet()) {  
        Object bean = entry.getValue();  
        Class<?> targetClass = ClassUtils.getUserClass(bean.getClass());  
  
        String componentKey = getComponentKey(targetClass);  
  
		// 컴포넌트 생성
		if (!componentMap.containsKey(componentKey)) {  
            Component component = new Component(  
                    targetClass.getSimpleName(),  
                    targetClass.getSimpleName(),  
                    targetClass.getPackage().getName(),  
                    LayerDetector.detectLayer(targetClass)  
            );  
  
            componentMap.put(componentKey, component);  
            log.debug("📦 수집: {}", component.name());  
        } else {  
            log.debug("⭐️ 이미 수집됨 (의존성은 계속 분석): {}", targetClass.getSimpleName());
        }  
  
		// 의존성 수집 (생성자 분석)
        List<Component> dependencies = collectDependenciesForBean(  
                bean, targetClass, componentMap);  
  
        // 관계 추가  
        Component component = componentMap.get(componentKey);  
        for (Component dep : dependencies) {  
            DependencyRelation relation = new DependencyRelation(  
                    component.name(),  
                    dep.name()  
            );  
  
            if (!relationExists(relations, relation)) {  
                relations.add(relation);  
                log.debug("  ➡️ {} → {}", component.name(), dep.name());  
            }  
        }  
    }  
}

동작 원리:

  1. applicationContext.getBeansWithAnnotation(): Spring이 관리하는 Bean 중 해당 어노테이션이 붙은 것들을 모두 가져옴
  2. ClassUtils.getUserClass(): Proxy가 아닌 실제 클래스 추출
  3. Component 객체 생성 및 저장
  4. 생성자 분석으로 의존성 수집

3.3.3 Step 3: 생성자 파라미터 분석

private List<Component> collectDependenciesForBean(  
        Object bean,  
        Class<?> targetClass,  
        Map<String, Component> componentMap) {  
  
    List<Component> dependencies = new ArrayList<>();  
  
    // 생성자 파라미터 분석 (final 필드 지원)  
    log.debug("  🔧 생성자 파라미터 분석 시작...");  
    dependencies.addAll(collectFromConstructor(targetClass, componentMap));  
  
    return dependencies;  
}  
 
private List<Component> collectFromConstructor(  
        Class<?> targetClass,  
        Map<String, Component> componentMap) {  
  
    List<Component> dependencies = new ArrayList<>();  
    Constructor<?>[] constructors = targetClass.getDeclaredConstructors();  
  
    for (Constructor<?> constructor : constructors) {  
        Parameter[] parameters = constructor.getParameters();  
  
        log.debug("    🗝️ 생성자 파라미터 {} 개", parameters.length);  
  
        for (Parameter param : parameters) {  
            Class<?> paramType = param.getType();  
            String typeName = paramType.getSimpleName();  
  
            log.debug("      🔍 파라미터 타입: {}", typeName);  
 
			// Service나 Repository인지 확인
            if (!isServiceOrRepositoryType(typeName, paramType)) {  
                log.debug("typeName: " + typeName);  
                continue;  
            }  
  
            try { 
	            // Spring에게 해당 타입의 Bean을 요청 
                Object bean = applicationContext.getBean(paramType);  
                Component dep = createDependencyComponent(bean, paramType, componentMap);  
  
                if (dep != null) {  
                    dependencies.add(dep);  
                    log.info("        ✅ 의존성 추가: {} ({})", dep.name(), dep.layer());  
                }  
  
            } catch (Exception e) {  
                log.debug("        ⚠️ Bean 조회 실패: {} - {}", typeName, e.getMessage());  
            }  
        }  
    }  
  
    return dependencies;  
}

예시 분석:

@Service
public class OrderService {
	private final OrderRepository orderRepository;
	private final UserService userService;
	private final PaymentService paymentService;
 
	public OrderService(OrderRepository orderRepository, UserService userService, PaymentService paymentService) {
		// Reflection으로 이 생성자를 찾아서
		// 3개의 파라미터를 분석
		// → OrderService는 OrderRepository, UserService, PaymentService에 의존
	}
}

3.3.4 Step 4: Service/Repository 판단

private boolean isServiceOrRepositoryType(String typeName, Class<?> type) {  
	// 1. 타입 이름으로 체크
    if (typeName.contains("Service") || typeName.contains("Repository")) {  
        log.debug("        ✅ 타입명으로 Service/Repository 확인: {}", typeName);  
        return true;  
    }  
  
	// 2. 어노테이션으로 체크
    boolean hasServiceAnnotation = AnnotationUtils.findAnnotation(type, Service.class) != null;  
    boolean hasRepoAnnotation = AnnotationUtils.findAnnotation(type, Repository.class) != null;  
  
    if (hasServiceAnnotation || hasRepoAnnotation) {  
        log.debug("        ✅ 어노테이션으로 Service/Repository 확인: {}", typeName);  
        return true;  
    }  
 
	// 3. JpaRepository 상속 체크
    if (isJpaRepository(type)) {  
        log.debug("        ✅ JpaRepository 상속으로 Repository 확인: {}", typeName);  
        return true;  
    }  
  
    return false;  
}
 
private boolean isJpaRepository(Class<?> type) {  
    try {  
        for (Class<?> interfaceClass : type.getInterfaces()) {  
            String interfaceName = interfaceClass.getName();  
            if (interfaceName.contains("JpaRepository") ||  
                    interfaceName.contains("CrudRepository") ||  
                    interfaceName.contains("Repository")) {  
                return true;  
            }
            // 재귀적으로 상위 인터페이스 체크
            if (isJpaRepository(interfaceClass)) {  
                return true;  
            }  
        }  
    } catch (Exception e) {  
        log.debug("        JpaRepository 체크 실패: {}", e.getMessage());  
    }  
    return false;  
}

판단 기준:

  1. 이름에 “Service” 또는 “Repository” 포함
  2. @Service 또는 @Repository 어노테이션 보유
  3. JpaRepository, CrudRepository 등을 상속

3.3.5 Step 5: Interface-based Repository 처리

JPA Repository는 인터페이스만 정의하고 Spring Data JPA가 런타임에 구현체를 생성합니다.

// 사용자 코드
public interface UserRepository extends JpaRepository<User, Long> {
	// 인터페이스만 정의
}
 
// 런타임에 Spring Data JPA가 프록시 생성
// com.sun.proxy.$Proxy123 같은 클래스가 생성됨

수집 로직:

private void collectInterfaceBasedRepositories(  
        Map<String, Component> componentMap,  
        List<DependencyRelation> relations) {  
  
    try {  
        // Spring Data Repository 마커 인터페이스로 Bean 찾기  
        Class<?> repositoryClass = Class.forName("org.springframework.data.repository.Repository");  
        Map<String, ?> beans = applicationContext.getBeansOfType(repositoryClass);  
  
        log.debug("🔍 Interface-based Repository 수집: {} 개", beans.size());  
  
        for (Map.Entry<String, ?> entry : beans.entrySet()) {  
            String beanName = entry.getKey();  
            Object bean = entry.getValue();  
  
            // 프록시에서 실제 인터페이스 추출  
            Class<?> repositoryInterface = extractRepositoryInterface(bean);  
  
            if (repositoryInterface == null) {  
                log.warn("⚠️ Repository 인터페이스를 찾지 못함: {}", beanName);  
                continue;  
            }  
  
            log.debug("📦 Repository 발견: {}", repositoryInterface.getSimpleName());  
  
            String componentKey = getComponentKey(repositoryInterface);  
  
            // 중복 체크 (Class-based Repository와 겹칠 수 있음)  
            if (componentMap.containsKey(componentKey)) {  
                log.debug("⭐️ 이미 수집된 Repository: {}", repositoryInterface.getSimpleName());  
                continue;  
            }  
  
            // 컴포넌트 생성  
            Component component = new Component(  
                    repositoryInterface.getSimpleName(),  
                    repositoryInterface.getSimpleName(),  
                    repositoryInterface.getPackage().getName(),  
                    LayerDetector.detectLayer(repositoryInterface)  
            );  
  
            componentMap.put(componentKey, component);  
            log.debug("  ✅ Interface-based Repository 수집: {}", component.name());  
  
            // ⚠️ 의존성 수집 스킵 - 인터페이스는 생성자 없음  
            log.debug("  ⭐️ 의존성 수집 스킵 (인터페이스)");  
        }  
  
    } catch (ClassNotFoundException e) {  
        // Spring Data가 없는 경우 (순수 JDBC만 사용)  
        log.debug("ℹ️ Spring Data Repository를 찾을 수 없음. JDBC 전용 프로젝트인 것으로 판단.");  
    }  
}

프록시에서 실제 인터페이스 추출:

private Class<?> extractRepositoryInterface(Object bean) {  
    Class<?> beanClass = bean.getClass();  
    Class<?>[] interfaces = beanClass.getInterfaces();  
  
    log.debug("  🔍 인터페이스 탐색 중...");  
  
    for (Class<?> intf : interfaces) {  
        String interfaceName = intf.getName();  
  
        log.debug("    - {}", interfaceName);  
  
        // Spring/Java 내부 인터페이스 제외  
        if (interfaceName.startsWith("org.springframework.data.repository")) {  
            // 이건 Spring Data 마커 인터페이스 (CrudRepository, JpaRepository 등)  
            continue;  
        }  
  
        if (interfaceName.startsWith("org.springframework") ||  
                interfaceName.startsWith("java.") ||  
                interfaceName.startsWith("jdk.")) {  
            continue;  
        }  
  
        // 우리가 선언한 Repository 인터페이스!  
        log.debug("    ✅ 발견: {}", intf.getSimpleName());  
        return intf;  
    }  
  
    // 못 찾으면 상위 인터페이스까지 재귀 탐색  
    for (Class<?> intf : interfaces) {  
        if (intf.getName().startsWith("org.springframework.data.repository")) {  
            // 혹시 이 인터페이스가 우리 인터페이스를 확장했나?  
            Class<?>[] superInterfaces = intf.getInterfaces();  
            for (Class<?> superIntf : superInterfaces) {  
                if (!superIntf.getName().startsWith("org.springframework") &&  
                        !superIntf.getName().startsWith("java.")) {  
                    log.debug("    ✅ 상위에서 발견: {}", superIntf.getSimpleName());  
                    return superIntf;  
                }  
            }  
        }  
    }  
  
    return null;  
}

동작 원리:

  1. Bean의 모든 인터페이스를 확인
  2. Spring/Java 내부 인터페이스 제외
  3. 사용자가 정의한 Repository 인터페이스 반환

3.4 LayerDetector: 계층 감지

파일: LayerDetector.java

public class LayerDetector {  
  
    /**  
     * Layer 감지  
     *  
     * @param clazz 판단할 클래스  
     * @return CONTROLLER, SERVICE, REPOSITORY, COMPONENT, UNKNOWN  
     */    public static String detectLayer(Class<?> clazz) {  
        // 1순위: Annotation 체크  
        if (clazz.isAnnotationPresent(RestController.class)) {  
            return "CONTROLLER";  
        }  
        if (clazz.isAnnotationPresent(Service.class)) {  
            return "SERVICE";  
        }  
        if (clazz.isAnnotationPresent(Repository.class)) {  
            return "REPOSITORY";  
        }  
        if (clazz.isAnnotationPresent(Component.class)) {  
            return "COMPONENT";  
        }  
  
        // 2순위: 인터페이스 체크 (JpaRepository 등)  
        String fromInterface = inferFromInterfaces(clazz);  
        if (!fromInterface.equals("UNKNOWN")) {  
            return fromInterface;  
        }  
  
        return "UNKNOWN";  
    }  
  
    /**  
     * 인터페이스로 Layer 추론 (JpaRepository, CrudRepository 등)  
     */    private static String inferFromInterfaces(Class<?> clazz) {  
        try {  
            for (Class<?> interfaceClass : clazz.getInterfaces()) {  
                String interfaceName = interfaceClass.getName();  
  
                // JpaRepository, CrudRepository 등  
                if (interfaceName.contains("Repository")) {  
                    return "REPOSITORY";  
                }  
  
                // 재귀적으로 상위 인터페이스 체크  
                String result = inferFromInterfaces(interfaceClass);  
                if (!result.equals("UNKNOWN")) {  
                    return result;  
                }  
            }  
        } catch (Exception e) {  
            // 무시  
        }  
        return "UNKNOWN";  
    }  
}

판단 전략:

  1. 어노테이션 우선 검사
  2. 인터페이스 이름으로 추론 (재귀적)

3.5 DatabaseDetector: 데이터베이스 감지

파일: DatabaseDetector.java

3.5.1 감지 전략

@Slf4j  
public class DatabaseDetector {  
  
    private final Environment environment;  
  
    public DatabaseDetector(Environment environment) {  
        this.environment = environment;  
    }  
  
 
     public List<String> detectAllDatabases() {  
        Set<String> databases = new HashSet<>();  
  
        // 1. Primary datasource 감지  
        detectPrimaryDatasource(databases);  
  
        // 2. Secondary datasources 감지 (spring.datasource.*.url 패턴)  
        detectSecondaryDatasources(databases);  
  
        // 3. NoSQL 감지  
        detectNoSqlDatabases(databases);  
  
        List<String> result = new ArrayList<>(databases);  
  
        if (result.isEmpty()) {  
            log.warn("⚠️ 데이터베이스를 감지할 수 없습니다.");  
            result.add("UNKNOWN");  
        } else {  
            log.info("📊 감지된 데이터베이스: {}", result);  
        }  
  
        return result;  
    }
}

3.5.2 Primary Datasource 감지

private void detectPrimaryDatasource(Set<String> databases) {  
    String url = environment.getProperty("spring.datasource.url");  
    if (url != null) {  
        String dbType = detectFromUrl(url);  
        if (!"UNKNOWN".equals(dbType)) {  
            databases.add(dbType);  
            log.debug("✅ Primary DB 감지: {} ({})", dbType, url);  
        }  
        return;  
    }  
  
    // URL이 없으면 driver로 시도  
    String driver = environment.getProperty("spring.datasource.driver-class-name");  
    if (driver != null) {  
        String dbType = detectFromDriver(driver);  
        if (!"UNKNOWN".equals(dbType)) {  
            databases.add(dbType);  
            log.debug("✅ Primary DB 감지: {} ({})", dbType, driver);  
        }  
    }  
}

3.5.3 JDBC URL 파싱

private String detectFromUrl(String url) {
	String lowerUrl = url.toLowerCase();
 
	if (lowerUrl.contains(":h2:")) return "H2";
	if (lowerUrl.contains(":mysql:")) return "MySQL";
	if (lowerUrl.contains(":mariadb:")) return "MariaDB";
	if (lowerUrl.contains(":postgresql:")) return "PostgreSQL";
	if (lowerUrl.contains(":oracle:")) return "Oracle";
	if (lowerUrl.contains(":sqlserver:")) return "SQLServer";
 
	return "UNKNOWN";
}

예시 URL:

  • jdbc:mysql://localhost:3306/mydb → MySQL
  • jdbc:postgresql://localhost:5432/mydb → PostgreSQL
  • jdbc:h2:mem:testdb → H2

3.5.4 Driver 파싱

private String detectFromDriver(String driver) {
	String lowerDriver = driver.toLowerCase();
 
	if (lowerDriver.contains("h2")) return "H2";
	if (lowerDriver.contains("mysql")) return "MySQL";
	if (lowerDriver.contains("mariadb")) return "MariaDB";
	if (lowerDriver.contains("postgresql")) return "PostgreSQL";
	if (lowerDriver.contains("oracle")) return "Oracle";
	if (lowerDriver.contains("sqlserver")) return "SQLServer";
 
	return "UNKNOWN";
}

예시 Driver:

  • com.mysql.cj.jdbc.Driver → MySQL
  • org.postgresql.Driver → PostgreSQL

3.5.5 Secondary Datasource 감지

private void detectSecondaryDatasources(Set<String> databases) {
	// 일반적인 secondary datasource 패턴들
	String[] prefixes = {
		"spring.datasource.secondary",
		"spring.datasource.readonly",
		"spring.datasource.slave",
		"spring.datasource.replica"
	};
 
	for (String prefix : prefixes) {
		String url = environment.getProperty(prefix + ".url");
		if (url != null) {
			String dbType = detectFromUrl(url);
			if (!"UNKNOWN".equals(dbType)) {
				databases.add(dbType);
				log.debug("✅ Secondary DB 감지: {} ({})", dbType, url);
			}
		}
	}
}

application.yml 예시:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/primary # Primary
    readonly:
      url: jdbc:mysql://replica:3306/primary # Secondary

3.5.6 NoSQL 감지

private void detectNoSqlDatabases(Set<String> databases) {
	// MongoDB
	String mongoUri = environment.getProperty("spring.data.mongodb.uri");
	
	if (mongoUri != null) {
		databases.add("MongoDB");
		log.debug("✅ NoSQL 감지: MongoDB");
	}
 
	// Redis
	String redisHost = environment.getProperty("spring.data.redis.host");
	String redisUrl = environment.getProperty("spring.data.redis.url");
	
	if (redisHost != null || redisUrl != null) {
		databases.add("Redis");
		log.debug("✅ NoSQL 감지: Redis");
	}
  
	// Elasticsearch
	String elasticsearchUris =
		environment.getProperty("spring.data.elasticsearch.uris");
		if (elasticsearchUris != null) {
			databases.add("Elasticsearch");
			log.debug("✅ NoSQL 감지: Elasticsearch");
		}
	}

지원하는 NoSQL:

  • MongoDB
  • Redis
  • Elasticsearch

3.6 데이터 전송

3.6.1 2단계 전송 전략

public void collectDependencies() {
	// ... 수집 로직 ...
 
	// ✅ 1단계: 컴포넌트만 먼저 전송
	log.info("📤 [1단계] 컴포넌트 정보 전송...");
	List<ComponentRequest> componentRequests = componentMap.values().stream()
				.map(this::convertToComponentRequest)
				.toList();
 
	ComponentBatchRequest batchRequest = new ComponentBatchRequest(componentRequests);
	sender.sendComponents(batchRequest);
 
	// ✅ 2단계: 의존성 관계 나중에 전송
	log.info("📤 [2단계] 의존성 관계 정보 전송...");
	sender.sendDependencies(projectName, relations, databases);
 
	log.info("🎉 전송 완료!");
}

왜 2단계로 나눴을까? 데이터베이스의 외래키 제약조건 때문입니다.

-- 컴포넌트 테이블
CREATE TABLE component (
	id BIGINT PRIMARY KEY,
	name VARCHAR(255) NOT NULL
);
 
-- 의존성 관계 테이블
CREATE TABLE dependency_relation (
	id BIGINT PRIMARY KEY,
	from_component_id BIGINT NOT NULL,
	to_component_id BIGINT NOT NULL,
	FOREIGN KEY (from_component_id) REFERENCES component(id),
	FOREIGN KEY (to_component_id) REFERENCES component(id)
);

관계를 저장하려면 먼저 컴포넌트가 존재해야 합니다.

3.6.2 Component → ComponentRequest 변환

private ComponentRequest convertToComponentRequest(Component component) {
	log.info("🔍 변환 중: name={}, type={}, layer={}", component.name(), component.type(), component.layer());
 
return new ComponentRequest(
		component.name(),
		component.type(), // classType
		determineComponentType(component), // "BE" or "INFRA"
		component.packageName(),
		determineComponentLayer(component), // "CONTROLLER", "SERVICE", "REPOSITORY"
		"Spring Boot"
	);
} 
 
private String determineComponentType(Component component) {
	String packageName = component.packageName();
 
	// 인프라 계층 판단
	if (packageName != null && (packageName.contains(".config") || packageName.contains(".filter") || packageName.contains(".security") ||
packageName.contains(".aspect"))) {
		return "INFRA";
	}
 
	// 기본값: 백엔드
	return "BE";
}
 
private String determineComponentLayer(Component component) {
	String layer = component.layer();
 
	if (layer == null) {
		return null;
	}
  
	// 서버의 ComponentLayer Enum 값만 허용
	return switch (layer) {
		case "CONTROLLER" -> "CONTROLLER";
		case "SERVICE" -> "SERVICE";
		case "REPOSITORY" -> "REPOSITORY";
		default -> null; // COMPONENT, UNKNOWN 등은 null로
	};
}