DAG와 의존성 그래프 — 빌드 시스템부터 스프링 빈까지
"A를 하려면 먼저 B를 해야 하고, B를 하려면 먼저 C를 해야 한다" — 이런 의존 관계를 어떻게 체계적으로 관리할 수 있을까요?
DAG란
DAG(Directed Acyclic Graph, 방향 비순환 그래프)는 방향이 있고 사이클이 없는 그래프입니다.
DAG: DAG가 아닌 그래프 (사이클):
A → B → D A → B → D
↓ ↓ ↓ ↓ ↑
C → E C → E → F
"사이클이 없다"는 것은 어떤 정점에서 출발해도 ** 자기 자신으로 돌아올 수 없다 **는 뜻입니다.
왜 DAG가 중요한가
DAG는 ** 의존 관계 **를 표현하는 가장 자연스러운 구조입니다.
- ** 빌드 시스템 **: 소스 파일 간 컴파일 의존성
- ** 패키지 관리자 **: npm, Maven의 패키지 의존성
- **Spring 컨테이너 **: 빈 간 의존성
- ** 데이터 파이프라인 **: Airflow의 태스크 순서
- ** 스프레드시트 **: 셀 간 수식 의존성
이 모든 것이 "A가 B에 의존하면, B를 먼저 처리해야 한다"는 구조이며, 사이클이 있으면 처리할 수 없습니다.
위상 정렬 (Topological Sort)
DAG의 정점을 ** 의존성 순서대로 정렬 **하는 알고리즘입니다. 모든 간선 (u, v)에 대해 u가 v보다 앞에 옵니다.
Kahn's Algorithm (BFS 기반)
public List<Integer> topologicalSort(int vertices, List<List<Integer>> adjList) {
// 1. 진입 차수 계산
int[] inDegree = new int[vertices];
for (int u = 0; u < vertices; u++) {
for (int v : adjList.get(u)) {
inDegree[v]++;
}
}
// 2. 진입 차수가 0인 정점을 큐에 추가
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < vertices; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
// 3. BFS
List<Integer> order = new ArrayList<>();
while (!queue.isEmpty()) {
int u = queue.poll();
order.add(u);
for (int v : adjList.get(u)) {
inDegree[v]--;
if (inDegree[v] == 0) {
queue.offer(v);
}
}
}
// 4. 사이클 감지
if (order.size() != vertices) {
throw new RuntimeException("사이클이 존재합니다!");
}
return order;
}
DFS 기반 위상 정렬
public List<Integer> topologicalSortDFS(int vertices, List<List<Integer>> adjList) {
boolean[] visited = new boolean[vertices];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < vertices; i++) {
if (!visited[i]) {
dfs(i, adjList, visited, stack);
}
}
return new ArrayList<>(stack);
}
private void dfs(int u, List<List<Integer>> adjList,
boolean[] visited, Deque<Integer> stack) {
visited[u] = true;
for (int v : adjList.get(u)) {
if (!visited[v]) {
dfs(v, adjList, visited, stack);
}
}
stack.push(u); // 후위 순서로 스택에 추가
}
위상 정렬 예시
A → B → D
↓ ↓
C → E
진입 차수: A=0, B=1, C=1, D=1, E=2
순서: A → B → C → D → E (또는 A → C → B → E → D 등)
유효한 위상 정렬은 여러 개일 수 있습니다. 의존 관계가 없는 정점들은 순서가 자유롭습니다.
Gradle 빌드 시스템
Gradle은 태스크 간 의존성을 DAG로 관리합니다.
// build.gradle
task compileJava {
// 소스 컴파일
}
task processResources {
// 리소스 복사
}
task classes {
dependsOn compileJava, processResources
}
task test {
dependsOn classes
}
task jar {
dependsOn classes
}
task build {
dependsOn test, jar
}
이 의존성 구조는 DAG입니다:
compileJava ─────┐
├→ classes → test ──┐
processResources ─┘ │ ├→ build
jar ───┘
Gradle은 이 DAG에 위상 정렬을 적용하여 실행 순서를 결정합니다. 의존 관계가 없는 태스크(compileJava와 processResources)는 ** 병렬 실행 **이 가능합니다.
Spring Bean 의존성
Spring 컨테이너는 빈 간의 의존성을 DAG로 관리합니다.
@Service
public class OrderService {
private final UserService userService;
private final PaymentService paymentService;
// OrderService → UserService, PaymentService
public OrderService(UserService userService, PaymentService paymentService) {
this.userService = userService;
this.paymentService = paymentService;
}
}
@Service
public class PaymentService {
private final PaymentGateway gateway;
// PaymentService → PaymentGateway
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
}
빈 생성 순서 (위상 정렬):
PaymentGateway → PaymentService
UserService ─────────────────────→ OrderService
순환 의존성 감지
// 순환 의존성 — 이것은 DAG가 아닙니다!
@Service
public class ServiceA {
@Autowired private ServiceB serviceB; // A → B
}
@Service
public class ServiceB {
@Autowired private ServiceA serviceA; // B → A → 사이클!
}
Spring Boot 2.6+에서는 이런 순환 의존성을 ** 기본적으로 금지 **합니다.
***************************
APPLICATION FAILED TO START
***************************
The dependencies of some of the beans in the application context form a cycle:
serviceA → serviceB → serviceA
해결 방법:
- ** 구조 리팩토링 **: 공통 로직을 별도 서비스로 분리
- ** 이벤트 기반 **: 직접 호출 대신 이벤트로 통신
- @Lazy: 지연 주입 (권장하지 않음)
// 해결: 공통 로직을 별도 서비스로 추출
@Service
public class ServiceA {
private final CommonService commonService;
}
@Service
public class ServiceB {
private final CommonService commonService;
}
Apache Airflow — 데이터 파이프라인
Airflow는 이름 자체에 DAG가 들어가 있을 정도로, DAG를 핵심 개념으로 사용합니다.
# Airflow DAG 예시 (Python)
from airflow import DAG
from airflow.operators.python import PythonOperator
dag = DAG('daily_etl', schedule_interval='@daily')
extract = PythonOperator(task_id='extract', python_callable=extract_data, dag=dag)
transform = PythonOperator(task_id='transform', python_callable=transform_data, dag=dag)
load = PythonOperator(task_id='load', python_callable=load_data, dag=dag)
notify = PythonOperator(task_id='notify', python_callable=send_notification, dag=dag)
extract >> transform >> load >> notify
# extract → transform → load → notify
extract → transform → load → notify
Airflow 스케줄러는 위상 정렬에 따라 태스크를 실행하며, 의존 관계가 없는 태스크는 병렬로 처리합니다.
크리티컬 패스 분석
DAG에서 가장 긴 경로가 전체 소요 시간을 결정합니다.
// 크리티컬 패스: DAG에서 최장 경로 (위상 정렬 + DP)
public int criticalPath(int vertices, List<List<int[]>> adjList) {
List<Integer> order = topologicalSort(vertices, adjList);
int[] longest = new int[vertices]; // 시작점부터의 최장 거리
for (int u : order) {
for (int[] edge : adjList.get(u)) {
int v = edge[0], weight = edge[1];
longest[v] = Math.max(longest[v], longest[u] + weight);
}
}
return Arrays.stream(longest).max().orElse(0);
}
예시: CI/CD 파이프라인
빌드(30s) → 단위 테스트(60s) → 통합 테스트(120s) → 배포(30s)
빌드(30s) → 코드 분석(45s)
빌드(30s) → Docker 이미지(20s)
크리티컬 패스: 빌드 → 단위 테스트 → 통합 테스트 → 배포 = 240초
전체 파이프라인 시간은 크리티컬 패스인 240초이며, 코드 분석(45s)과 Docker 이미지(20s)는 이 시간 안에 병렬로 처리됩니다.
사이클 감지
DAG의 핵심 조건은 "사이클 없음"입니다. 사이클을 감지하는 방법:
DFS 기반 사이클 감지
public boolean hasCycle(int vertices, List<List<Integer>> adjList) {
int[] state = new int[vertices]; // 0: 미방문, 1: 진행 중, 2: 완료
for (int i = 0; i < vertices; i++) {
if (state[i] == 0 && dfsCycle(i, adjList, state)) {
return true;
}
}
return false;
}
private boolean dfsCycle(int u, List<List<Integer>> adjList, int[] state) {
state[u] = 1; // 진행 중
for (int v : adjList.get(u)) {
if (state[v] == 1) return true; // 진행 중인 노드를 다시 방문 → 사이클!
if (state[v] == 0 && dfsCycle(v, adjList, state)) return true;
}
state[u] = 2; // 완료
return false;
}
위상 정렬로 사이클 감지
Kahn's Algorithm에서 모든 정점이 결과에 포함되지 않으면 사이클이 존재합니다.
DAG 활용 패턴 요약
| 도메인 | 정점 | 간선 | 활용 |
|---|---|---|---|
| Gradle | 태스크 | 의존성 | 빌드 순서, 병렬 실행 |
| Spring | 빈 | 의존 주입 | 생성 순서, 순환 감지 |
| Airflow | 태스크 | 데이터 흐름 | ETL 파이프라인 |
| npm/Maven | 패키지 | 의존성 | 설치 순서 |
| CI/CD | 단계 | 선후 관계 | 파이프라인 스케줄링 |
| 스프레드시트 | 셀 | 수식 참조 | 계산 순서 |
정리
- DAG 는 방향이 있고 사이클이 없는 그래프로, 의존 관계 를 표현하는 핵심 구조입니다.
- 위상 정렬 로 의존성 순서를 결정하며, 사이클이 있으면 정렬이 불가능합니다.
- Gradle, Spring Bean, Airflow 등 실무의 많은 시스템이 DAG 기반 으로 동작합니다.
- Spring에서 순환 의존성은 DAG 조건 위반이며, 구조 리팩토링으로 해결해야 합니다.
- 크리티컬 패스 분석으로 전체 소요 시간과 병목을 파악할 수 있습니다.