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());
}
}
}
}동작 원리:
applicationContext.getBeansWithAnnotation(): Spring이 관리하는 Bean 중 해당 어노테이션이 붙은 것들을 모두 가져옴ClassUtils.getUserClass(): Proxy가 아닌 실제 클래스 추출Component객체 생성 및 저장- 생성자 분석으로 의존성 수집
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;
}판단 기준:
- 이름에 “Service” 또는 “Repository” 포함
@Service또는@Repository어노테이션 보유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;
}동작 원리:
- Bean의 모든 인터페이스를 확인
- Spring/Java 내부 인터페이스 제외
- 사용자가 정의한 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";
}
}판단 전략:
- 어노테이션 우선 검사
- 인터페이스 이름으로 추론 (재귀적)
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→ MySQLjdbc:postgresql://localhost:5432/mydb→ PostgreSQLjdbc: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→ MySQLorg.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 # Secondary3.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로
};
}