모듈 A가 B를, B가 C를, C가 다시 A를 참조하면 어떻게 될까요? 이런 순환 의존성을 어떻게 찾아낼 수 있을까요?

사이클 탐지란

그래프에서 사이클(cycle) 은 어떤 노드에서 출발해 간선을 따라가다 다시 자기 자신으로 돌아오는 경로입니다. 사이클 탐지는 그래프에 이런 순환이 있는지 확인하는 문제입니다.

방향 그래프와 무방향 그래프에서 사이클을 탐지하는 방법이 다르기 때문에, 둘을 구분해서 알아야 합니다.

왜 필요한가

사이클 탐지는 다양한 곳에서 중요합니다.

  • **빌드 시스템 **: Maven/Gradle에서 모듈 간 순환 의존성이 있으면 빌드 순서를 정할 수 없습니다
  • Spring IoC: 빈 간 순환 참조가 있으면 컨테이너가 시작할 수 없습니다
  • ** 데드락 탐지 **: 트랜잭션 대기 그래프에 사이클이 있으면 데드락입니다
  • ** 패키지 관리 **: npm, pip 등에서 순환 의존성 검사

방향 그래프 — DFS 색칠법

방향 그래프에서 가장 직관적인 방법은 DFS를 하면서 노드에 색을 칠하는 것입니다.

세 가지 색

  • WHITE (미방문): 아직 방문하지 않은 노드
  • GRAY (탐색 중): DFS 스택에 있는 노드 (현재 탐색 경로에 포함)
  • BLACK (완료): DFS가 끝난 노드 (모든 후손 탐색 완료)

GRAY 노드에서 GRAY 노드로 가는 간선이 있으면 → 현재 탐색 경로에 사이클이 존재합니다.

JAVA
public class DirectedCycleDetection {
    private static final int WHITE = 0, GRAY = 1, BLACK = 2;

    public boolean hasCycle(List<List<Integer>> graph, int n) {
        int[] color = new int[n]; // 모두 WHITE로 시작

        for (int i = 0; i < n; i++) {
            if (color[i] == WHITE && dfs(graph, i, color)) {
                return true;
            }
        }
        return false;
    }

    private boolean dfs(List<List<Integer>> graph, int u, int[] color) {
        color[u] = GRAY; // 탐색 시작

        for (int v : graph.get(u)) {
            if (color[v] == GRAY) {
                return true; // 사이클 발견!
            }
            if (color[v] == WHITE && dfs(graph, v, color)) {
                return true;
            }
            // BLACK은 이미 탐색 완료 → 무시
        }

        color[u] = BLACK; // 탐색 완료
        return false;
    }
}

왜 GRAY만 확인하는가?

BLACK 노드를 만나는 것은 "이미 탐색이 끝난 노드에 도달한 것"이므로 사이클이 아닙니다. ** 현재 탐색 경로에 있는(GRAY)** 노드를 다시 만나야 사이클입니다.

PLAINTEXT
정상:  A(GRAY) → B(GRAY) → C(GRAY) → D(BLACK)  ← BLACK이므로 사이클 아님
사이클: A(GRAY) → B(GRAY) → C(GRAY) → A(GRAY)   ← GRAY이므로 사이클!

사이클 경로 복원

사이클을 탐지하는 것에서 한 발 더 나아가, 어떤 노드들이 사이클을 형성하는지 출력하고 싶을 때가 있습니다.

JAVA
// 사이클 경로 복원
private List<Integer> cyclePath;
private int[] parent;

private boolean dfsWithPath(List<List<Integer>> graph, int u, int[] color) {
    color[u] = GRAY;

    for (int v : graph.get(u)) {
        if (color[v] == GRAY) {
            // 사이클 경로 복원
            cyclePath = new ArrayList<>();
            cyclePath.add(v);
            int curr = u;
            while (curr != v) {
                cyclePath.add(curr);
                curr = parent[curr];
            }
            cyclePath.add(v);
            Collections.reverse(cyclePath);
            return true;
        }
        if (color[v] == WHITE) {
            parent[v] = u;
            if (dfsWithPath(graph, v, color)) return true;
        }
    }

    color[u] = BLACK;
    return false;
}

방향 그래프 — 위상 정렬(Kahn) 방식

위상 정렬에서 진입 차수가 0인 노드를 하나씩 제거합니다. 모든 노드를 제거하지 못하면 사이클이 있습니다.

JAVA
public boolean hasCycleKahn(List<List<Integer>> graph, int n) {
    int[] inDegree = new int[n];

    // 진입 차수 계산
    for (int u = 0; u < n; u++) {
        for (int v : graph.get(u)) {
            inDegree[v]++;
        }
    }

    // 진입 차수 0인 노드 큐에 추가
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < n; i++) {
        if (inDegree[i] == 0) queue.offer(i);
    }

    int processed = 0;
    while (!queue.isEmpty()) {
        int u = queue.poll();
        processed++;
        for (int v : graph.get(u)) {
            inDegree[v]--;
            if (inDegree[v] == 0) queue.offer(v);
        }
    }

    // 처리된 노드 수 < 전체 → 사이클 존재
    return processed < n;
}

이 방식은 사이클에 포함된 노드들의 진입 차수가 절대 0이 되지 않는 원리를 이용합니다.

무방향 그래프 — DFS 방식

무방향 그래프에서는 부모 노드로의 역방향 간선을 사이클로 오인하지 않도록 주의해야 합니다.

JAVA
public boolean hasCycleUndirected(List<List<Integer>> graph, int n) {
    boolean[] visited = new boolean[n];

    for (int i = 0; i < n; i++) {
        if (!visited[i] && dfsUndirected(graph, i, -1, visited)) {
            return true;
        }
    }
    return false;
}

private boolean dfsUndirected(List<List<Integer>> graph, int u, int parent, boolean[] visited) {
    visited[u] = true;

    for (int v : graph.get(u)) {
        if (!visited[v]) {
            if (dfsUndirected(graph, v, u, visited)) return true;
        } else if (v != parent) {
            return true; // 부모가 아닌 방문된 노드 → 사이클
        }
    }
    return false;
}

무방향 그래프 — Union-Find 방식

Union-Find(Disjoint Set)로도 사이클을 탐지할 수 있습니다. 간선을 하나씩 추가하면서 두 노드가 이미 같은 집합이면 사이클입니다.

JAVA
public class UnionFindCycle {
    private int[] parent, rank;

    public boolean hasCycle(int n, int[][] edges) {
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;

        for (int[] edge : edges) {
            int rootU = find(edge[0]);
            int rootV = find(edge[1]);

            if (rootU == rootV) return true; // 사이클!
            union(rootU, rootV);
        }
        return false;
    }

    private int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]); // 경로 압축
        return parent[x];
    }

    private void union(int a, int b) {
        if (rank[a] < rank[b]) { int t = a; a = b; b = t; }
        parent[b] = a;
        if (rank[a] == rank[b]) rank[a]++;
    }
}

Union-Find는 크루스칼 알고리즘에서 MST를 구할 때 사이클 방지용으로도 사용합니다.

방식 비교

방식그래프 유형시간 복잡도특징
DFS 색칠법방향O(V + E)사이클 경로 복원 가능
위상 정렬 (Kahn)방향O(V + E)사이클 포함 노드 식별 가능
DFS (부모 추적)무방향O(V + E)가장 일반적
Union-Find무방향O(E α(V))간선 리스트에 적합

백엔드/실무 연결

Spring 순환 참조 탐지

Spring은 빈 생성 시 순환 참조를 탐지합니다. 생성자 주입에서 순환 참조가 발생하면 BeanCurrentlyInCreationException이 던져집니다.

JAVA
// 순환 참조 예시
@Component
public class ServiceA {
    private final ServiceB serviceB;
    public ServiceA(ServiceB serviceB) { this.serviceB = serviceB; }
}

@Component
public class ServiceB {
    private final ServiceA serviceA;
    public ServiceB(ServiceA serviceA) { this.serviceA = serviceA; }
}
// → BeanCurrentlyInCreationException 발생!

Spring 내부적으로는 현재 생성 중인 빈을 Set에 기록해두고, 다시 같은 빈을 만나면 순환으로 판단합니다. DFS 색칠법의 GRAY 노드 확인과 같은 원리입니다.

데드락 탐지

데이터베이스에서 트랜잭션 A가 자원 1을 잡고 자원 2를 기다리고, 트랜잭션 B가 자원 2를 잡고 자원 1을 기다리면 데드락입니다. 대기 그래프(wait-for graph)에서 사이클을 탐지하면 데드락을 발견할 수 있습니다.

MySQL의 InnoDB는 주기적으로 대기 그래프의 사이클을 확인하고, 데드락이 발견되면 비용이 적은 트랜잭션을 롤백합니다.

패키지/모듈 의존성

Node.js의 npm, Java의 Maven, Python의 pip 모두 패키지 간 순환 의존성을 검사합니다. 순환이 있으면 설치 순서를 결정할 수 없기 때문입니다.

PLAINTEXT
module-a → module-b → module-c → module-a  (순환!)

주의할 점

무방향 그래프와 방향 그래프의 사이클 탐지 방법을 혼동하는 실수

무방향 그래프에서는 Union-Find나 DFS의 부모 추적으로 사이클을 탐지합니다. 방향 그래프에서는 DFS의 3가지 상태(미방문, 진행 중, 완료)로 back edge를 탐지해야 합니다. 방법을 혼동하면 오탐이 발생합니다.

Spring Bean 순환 의존성을 런타임에서야 발견하는 실수

Spring은 순환 의존성이 있으면 BeanCurrentlyInCreationException을 던집니다. 설계 단계에서 의존성 그래프를 그려보면 사전에 예방할 수 있습니다.


정리

  • ** 방향 그래프 **에서는 DFS 색칠법(WHITE/GRAY/BLACK)으로 사이클을 탐지합니다. GRAY → GRAY 간선이 사이클의 증거입니다
  • ** 위상 정렬 **으로도 사이클을 탐지할 수 있습니다. 처리된 노드 수가 전체보다 적으면 사이클이 있습니다
  • ** 무방향 그래프 **에서는 DFS 시 부모 노드를 구분하거나, Union-Find를 사용합니다
  • Spring 순환 참조, 데드락 탐지, 패키지 의존성 검사 등 백엔드 전반에서 활용되는 핵심 알고리즘입니다
  • 사이클이 있으면 위상 정렬이 불가능하고, 이는 곧 "순서를 정할 수 없다"는 의미입니다
댓글 로딩 중...