Theme:

"A를 하려면 먼저 B를 해야 하고, B를 하려면 먼저 C를 해야 한다" — 이런 의존 관계를 어떻게 체계적으로 관리할 수 있을까요?

DAG란

DAG(Directed Acyclic Graph, 방향 비순환 그래프)는 방향이 있고 사이클이 없는 그래프입니다.

PLAINTEXT
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 기반)

JAVA
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 기반 위상 정렬

JAVA
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); // 후위 순서로 스택에 추가
}

위상 정렬 예시

PLAINTEXT
  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로 관리합니다.

GROOVY
// 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입니다:

PLAINTEXT
compileJava ─────┐
                  ├→ classes → test ──┐
processResources ─┘            │      ├→ build
                               jar ───┘

Gradle은 이 DAG에 위상 정렬을 적용하여 실행 순서를 결정합니다. 의존 관계가 없는 태스크(compileJava와 processResources)는 ** 병렬 실행 **이 가능합니다.

Spring Bean 의존성

Spring 컨테이너는 빈 간의 의존성을 DAG로 관리합니다.

JAVA
@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;
    }
}

빈 생성 순서 (위상 정렬):

PLAINTEXT
PaymentGateway → PaymentService
UserService ─────────────────────→ OrderService

순환 의존성 감지

JAVA
// 순환 의존성 — 이것은 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+에서는 이런 순환 의존성을 ** 기본적으로 금지 **합니다.

PLAINTEXT
***************************
APPLICATION FAILED TO START
***************************
The dependencies of some of the beans in the application context form a cycle:
  serviceA → serviceB → serviceA

해결 방법:

  1. ** 구조 리팩토링 **: 공통 로직을 별도 서비스로 분리
  2. ** 이벤트 기반 **: 직접 호출 대신 이벤트로 통신
  3. @Lazy: 지연 주입 (권장하지 않음)
JAVA
// 해결: 공통 로직을 별도 서비스로 추출
@Service
public class ServiceA {
    private final CommonService commonService;
}

@Service
public class ServiceB {
    private final CommonService commonService;
}

Apache Airflow — 데이터 파이프라인

Airflow는 이름 자체에 DAG가 들어가 있을 정도로, DAG를 핵심 개념으로 사용합니다.

PYTHON
# 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
PLAINTEXT
extract → transform → load → notify

Airflow 스케줄러는 위상 정렬에 따라 태스크를 실행하며, 의존 관계가 없는 태스크는 병렬로 처리합니다.

크리티컬 패스 분석

DAG에서 가장 긴 경로가 전체 소요 시간을 결정합니다.

JAVA
// 크리티컬 패스: 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 파이프라인

PLAINTEXT
빌드(30s) → 단위 테스트(60s) → 통합 테스트(120s) → 배포(30s)
빌드(30s) → 코드 분석(45s)
빌드(30s) → Docker 이미지(20s)

크리티컬 패스: 빌드 → 단위 테스트 → 통합 테스트 → 배포 = 240초

전체 파이프라인 시간은 크리티컬 패스인 240초이며, 코드 분석(45s)과 Docker 이미지(20s)는 이 시간 안에 병렬로 처리됩니다.

사이클 감지

DAG의 핵심 조건은 "사이클 없음"입니다. 사이클을 감지하는 방법:

DFS 기반 사이클 감지

JAVA
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 조건 위반이며, 구조 리팩토링으로 해결해야 합니다.
  • 크리티컬 패스 분석으로 전체 소요 시간과 병목을 파악할 수 있습니다.
댓글 로딩 중...