ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • NetworkX 대표 알고리즘 3선 — 코드 베이스 분석에서 한 줄로 끝나는 일들
    IT 2026. 5. 27. 23:00
    NetworkX 대표 알고리즘 3선 — 코드 베이스 분석에서 한 줄로 끝나는 일들

    코드 베이스를 노드(함수·모듈)와 화살표(호출·import)의 그래프로 표현하면, 그동안 사람이 손으로 풀던 분석들이 의외로 한 줄 함수 호출로 끝난다. NetworkX가 그 한 줄을 가능하게 만드는 도구다 — Python 그래프 라이브러리로, 약 300개의 표준 그래프 알고리즘을 즉시 쓸 수 있는 형태로 제공한다.

    300개는 많아 보이지만, 코드 베이스 분석에 자주 쓰는 자리는 실제로 셋 정도로 좁혀진다. 순환 import 감지, hub 모듈 식별, 변경 영향 분석. 이 셋만 손에 익으면 정적 분석 도구의 핵심을 직접 짤 수 있다.

    이 글은 그 세 알고리즘이 각각 어떤 문제를 풀고, 코드는 어떻게 생겼고, 결과를 어떻게 활용하는지를 단계별로 풀어 본다.

    대표 3선 한눈에

    diagram

    다이어그램 설명. 셋 모두 "본인이 손으로 짜면 50줄짜리 코드"가 NetworkX 한 줄 함수 호출로 끝난다. BFS·DFS·메모리 관리·중복 방문 추적 같은 보일러플레이트는 모두 라이브러리가 흡수한다. 그래프 객체만 잘 만들어 두면 분석의 80%가 이 세 함수 안에서 끝난다.

    1. simple_cycles — 순환 import의 위치 찾기

    모듈 A가 B를 import하고, B가 C를 import하고, C가 다시 A를 import하면 어떻게 될까? Python에서는 circular import(순환 import)라는 까다로운 문제가 생긴다. 흔히 ImportError: cannot import name 'X' from partially initialized module 'Y' 같은 에러로 나타난다.

    이 문제가 까다로운 이유는 단순하다. 코드를 짤 때는 안 보이고, 어떤 import 순서에서만 터진다. 그래서 "수동으로 찾기 거의 불가능"하다. 모듈 100개짜리 codebase의 모든 import 경로를 손으로 따라가며 사이클을 찾는 건 현실적으로 무리다.

    NetworkX의 simple_cycles는 이 문제를 함수 한 호출로 끝낸다.

    import networkx as nx
    import ast
    from pathlib import Path
    
    def build_import_graph(project_root: Path) -> nx.DiGraph:
        """Python repo를 훑어 모듈 import 그래프를 만든다."""
        g = nx.DiGraph()  # 방향 그래프 (A → B 가 "A가 B를 import")
        for py_file in project_root.rglob("*.py"):
            module_name = py_file.stem
            g.add_node(module_name)
            # AST로 import 문 파싱
            tree = ast.parse(py_file.read_text())
            for node in ast.walk(tree):
                if isinstance(node, (ast.Import, ast.ImportFrom)):
                    for alias in node.names:
                        target = alias.name.split('.')[0]
                        g.add_edge(module_name, target)
        return g
    
    
    # 사이클 찾기 — 한 줄
    g = build_import_graph(Path("./my_project"))
    cycles = list(nx.simple_cycles(g))
    
    # 결과 활용
    if cycles:
        print(f"순환 import {len(cycles)}개 발견:")
        for cycle in cycles:
            print("  " + " → ".join(cycle) + " → " + cycle[0])
    else:
        print("순환 import 없음 ✓")
    

    코드 설명. 두 단계로 끝난다. 첫째, build_import_graph()가 Python 파일을 모두 훑어 AST(Abstract Syntax Tree, 코드 구문 트리)로 파싱하고 import 문을 모두 edge로 추가한다. 둘째, nx.simple_cycles(g)가 그 그래프에서 사이클을 모두 찾아 list로 반환한다. "이 그래프에 사이클이 있나?"라는 질문이 list가 비었는지 한 줄 비교로 답해진다. 사이클이 있으면 ["models", "services", "utils"] 같이 사이클을 구성하는 노드 순서대로 리스트가 나온다 — 그대로 출력하면 "models → services → utils → models"로 어디를 끊어야 하는지 즉시 보인다.

    실전 활용 — CI에서 자동 차단

    이 5줄짜리 검사를 CI 파이프라인의 한 단계로 박으면, 사이클이 추가되는 순간 PR이 막힌다. cycles가 비어 있지 않으면 sys.exit(1)로 끝내면 된다. "순환 import는 commit되기 전에 잡힌다"는 상태가 한 번 만들어지면 두 번 다시 같은 버그를 만나지 않는다.

    2. in_degree_centrality — 가장 자주 import되는 hub 모듈 찾기

    두 번째 질문 — "이 codebase에서 가장 중요한 모듈은 어디인가?"

    여기서 "중요한"의 정의는 여러 가지가 있겠지만, 가장 단순하고 강력한 답은 "가장 많은 모듈이 의존하는 모듈"이다. 100개 모듈 중 50개가 import하는 utils.py가 있다면, "내가 utils.py를 잘못 건드리면 50개 모듈이 모두 영향 받는다"는 뜻이다. 그게 그 모듈의 시스템 내 무게다.

    NetworkX의 in_degree_centrality는 이걸 한 줄로 계산한다. in-degree(들어오는 화살표 개수)를 노드별로 세서 0~1 사이로 정규화한 점수다.

    import networkx as nx
    
    # (앞서 만든 import 그래프 g 재사용)
    g = build_import_graph(Path("./my_project"))
    
    # 각 모듈의 hub 점수 — 한 줄
    centrality = nx.in_degree_centrality(g)
    
    # 가장 import 많이 되는 top 10
    top10 = sorted(centrality.items(), key=lambda x: -x[1])[:10]
    print("hub 모듈 top 10:")
    for name, score in top10:
        inbound_count = g.in_degree(name)
        print(f"  {name:<25} score={score:.3f}  ({inbound_count}개 모듈이 import)")
    

    코드 설명. nx.in_degree_centrality(g){모듈명: 점수} 딕셔너리를 반환한다. 점수 식은 (이 모듈을 import하는 다른 모듈 수) ÷ (전체 노드 수 - 1)이다. 분모가 n-1인 이유는 자기 자신을 뺀 나머지 노드 수가 최대 n-1개라서, 그걸로 나눠야 결과가 0~1 사이에 떨어진다. 예를 들어 모듈이 총 100개인 codebase(n=100)에서 어떤 모듈을 단 1개의 다른 모듈만 import한다면 1 / 99 ≈ 0.01이 되어 거의 0에 가까운 말단으로 잡히고, 99개가 모두 import한다면 99 / 99 = 1.0으로 완벽한 hub로 잡힌다. g.in_degree(name)은 같은 정보를 정수(실제 inbound 화살표 개수)로 돌려준다 — 사람 읽기엔 정수가 더 직관적이라 같이 출력한다.

    실전 활용 — 테스트 커버리지·리뷰 우선순위

    top hub 목록이 뽑히면 다음 같이 활용한다.

    • 테스트 커버리지 우선순위 — 가장 많이 import되는 모듈일수록 깨졌을 때 영향이 크다. 그 모듈의 단위 테스트 커버리지를 가장 먼저 95%로 끌어올린다.
    • 코드 리뷰 우선순위 — hub 모듈을 만지는 PR은 리뷰어 두 명을 강제. 말단 모듈은 한 명으로 충분.
    • 리팩터링 신호 — 한 모듈의 in-degree가 너무 높으면(예: 전체의 40% 이상이 import) "모듈이 너무 크고 모든 책임이 모임"이라는 신호. 분리 고민할 자리.

    참고로 NetworkX는 더 정교한 중심성도 한 줄로 제공한다 — nx.betweenness_centrality(g)"이 노드가 다른 노드들 사이의 최단 경로에 얼마나 자주 끼는가"를 점수로 매긴다. "flow의 통로" 역할을 하는 모듈을 찾는 자리에선 betweenness가 in-degree보다 더 정확한 답을 준다. in-degree는 빠르고 직관적, betweenness는 정확하지만 계산 비용이 큼 — 노드 수 수천 규모까진 둘 다 즉시 끝나니 같이 보면 좋다.

    3. ancestors — "이 모듈을 바꾸면 어디까지 영향이 가나"

    세 번째 질문 — 리팩터링 직전 가장 자주 떠오르는 질문이다. "내가 이 함수의 signature를 바꾸면 깨질 코드가 어디까지 있나?"

    답을 찾는 자연스러운 방법은 역방향 traversal이다. 그래프의 화살표를 거꾸로 따라간다. "A → B"를 "A가 B를 import한다"로 정의했다면, 내가 B를 바꿀 때 영향 받는 건 B를 import하는 A들, A를 import하는 다른 모듈들, 그 모듈을 import하는 또 다른 모듈들... 결국 B에 도달 가능한 모든 ancestor(조상) 노드.

    NetworkX의 ancestors가 이 traversal을 한 줄로 끝낸다.

    import networkx as nx
    
    # (앞서 만든 import 그래프 g 재사용)
    g = build_import_graph(Path("./my_project"))
    
    # 'config' 모듈을 바꿀 때 영향 받는 모든 모듈 — 한 줄
    target = "config"
    affected = nx.ancestors(g, target)
    
    # 결과 활용
    print(f"'{target}' 변경 시 영향 받는 모듈 {len(affected)}개:")
    for module in sorted(affected):
        # 거리(몇 단계 떨어졌나)도 같이
        distance = nx.shortest_path_length(g, module, target)
        print(f"  {module:<25} ({distance}홉 떨어짐)")
    
    # 가까운 영향(1홉) vs 먼 영향(3홉 이상) 구분도 가능
    close = {m for m in affected if nx.shortest_path_length(g, m, target) <= 1}
    far = affected - close
    print(f"\n직접 의존(1홉): {len(close)}개 — 우선 테스트")
    print(f"간접 영향(2홉+): {len(far)}개 — 통합 테스트 범위")
    

    코드 설명. nx.ancestors(g, target)는 target 노드에 도달 가능한 모든 조상 노드의 집합(set)을 반환한다. 한 번에 모든 깊이까지 — 1홉이든 10홉이든 다 포함. nx.shortest_path_length(g, src, dst)를 함께 쓰면 각 ancestor가 target에서 몇 홉 떨어졌는지 알 수 있어, "가까운 영향"과 "먼 영향"을 분리할 수 있다. 1홉 이내(직접 import)는 변경 즉시 깨질 가능성이 높고, 3홉 이상은 보통 간접적이라 통합 테스트 단계에서 잡힌다.

    실전 활용 — 리팩터링 안전 결정

    이 결과가 손에 있으면 리팩터링 의사결정이 다음과 같이 달라진다.

    • ancestors가 적으면 (5개 미만) — 안심하고 변경. 영향 모듈 모두 빠르게 테스트하면 끝.
    • ancestors가 많으면 (20개 이상) — 신중. signature 호환성을 유지(예: 새 인자 추가는 default 값으로, 인자 제거는 deprecate 단계 거치기)하는 식으로 두 단계 리팩터링.
    • 특정 ancestor가 hot path — 다른 큰 시스템의 진입점인 ancestor가 있으면 그쪽 팀과 미리 조율. "이 변경이 X 시스템 빌드를 깰 수 있음"을 사전 공지.

    리팩터링 직전 1초 호출로 "이 변경의 안전성 수준"이 숫자로 잡힌다. 이게 NetworkX 한 줄이 만드는 가장 큰 효과 중 하나다.

    세 알고리즘이 함께 만드는 효과

    diagram

    다이어그램 설명. 세 알고리즘은 코드 베이스 운영의 다른 단계를 각각 책임진다. simple_cycles는 변경 시점(commit 게이트)에, in_degree_centrality는 우선순위 결정 시점(테스트·리뷰 자원 배분)에, ancestors는 리팩터링 직전(안전 평가)에 활용된다. 셋이 합쳐지면 정적 분석 도구가 따로 필요 없을 정도로 일상 운영의 핵심이 커버된다.

    다른 알고리즘들 — 필요해지면 한 줄로

    위 셋 외에도 NetworkX엔 한 줄로 끝나는 유용한 함수들이 많다. 자주 쓰진 않지만 필요한 자리가 생기면 즉시 쓸 수 있는 후보들.

    • nx.strongly_connected_components(g) — 강하게 연결된 컴포넌트. "서로 다 도달 가능한 모듈 묶음"을 찾을 때.
    • nx.weakly_connected_components(g) — 약하게 연결된 컴포넌트. "무관한 모듈 묶음들"(섬처럼 떨어진 영역) 찾기.
    • nx.shortest_path(g, src, dst) — 두 모듈 사이의 가장 짧은 호출 경로. "A가 B를 부르려면 몇 단계 거치나".
    • nx.descendants(g, src) — ancestors의 정방향 버전. "이 모듈에서 시작해 import 따라가면 어디까지 도달하나".
    • nx.topological_sort(g) — 의존 순서대로 정렬. 빌드 순서·초기화 순서 결정에 직결.

    위 다섯도 모두 한 줄 호출. 본인이 직접 BFS·DFS를 구현하지 않고 표준 알고리즘의 검증된 결과를 즉시 쓸 수 있다는 게 NetworkX의 진짜 가치다.

    마무리 — 그래프 알고리즘이 일상 도구가 될 때

    그래프 알고리즘은 보통 "학교에서 배우고 실무에선 쓸 일 없는 것"으로 분류된다. 그런데 코드 베이스를 그래프로 표현하는 한 줄이 있으면 그 분류가 뒤집힌다. 일상 작업의 80%가 그래프 위에서 일어나기 때문이다 — 어느 모듈이 어느 모듈을 부르는가, 무엇이 무엇에 의존하는가, 이걸 바꾸면 어디가 깨지는가.

    NetworkX는 그 그래프를 한 번 만들어 두면 300개의 표준 알고리즘이 즉시 쓸 수 있게 되는 도구다. 그 300개 중 본인이 자주 쓰는 건 셋 — simple_cycles, in_degree_centrality, ancestors. 이 셋만 손에 익히면 정적 분석의 핵심을 직접 짤 수 있고, 코드 베이스가 커질수록 그 가치가 누적된다. 그래프 알고리즘이 학교 과제가 아니라 일상 도구가 되는 순간이 바로 NetworkX가 들어가는 자리다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.