사전 순환 의존
문제

순환 의존

gabury1고친 사람 github-actions[bot]

순환 의존은 코드 묶음들이 서로를 필요로 해서 어느 하나도 따로 떼어 쓸 수 없게 되는 고장입니다. 주문 코드와 결제 코드가 서로를 부르는 모습이 흔한 예입니다. 누가 일부러 고르는 구조가 아닙니다. 기능을 하나씩 덧붙이다 보면 저절로 생깁니다.

쉽고 빠른 이해

순환 의존은 두 코드 묶음(모듈)이 서로를 불러서 한 덩어리로 묶여 버린 고장입니다. 주문 모듈이 결제를 부릅니다. 결제 모듈도 주문 상태를 바꾸려고 주문을 부르면 둘은 늘 함께 다녀야 합니다.

이것이 문제인 것은 코드를 나눈 보람이 사라지기 때문입니다. 코드를 여러 묶음으로 나누는 것은 하나씩 고치고 시험하려는 것입니다. 부르는 방향을 따라가면 제자리로 돌아오는 고리가 생기면, 하나를 건드릴 때 고리 전체를 같이 건드려야 합니다.

어떻게 생기나:

  1. 주문이 결제를 부릅니다. 여기까지는 한 방향입니다
  2. 결제가 끝나면 주문 상태를 바꿔야 합니다. 그래서 결제 코드가 주문을 바로 부릅니다
  3. 부르는 방향을 따라가면 한 바퀴 돌아 주문으로 돌아옵니다

무엇이 나빠지나 — 고리에 걸린 모듈은 따로 시험하거나 떼어 낼 수 없습니다. 언어에 따라서는 서로를 불러오는 순서가 꼬여 프로그램이 뜨다가 멈춥니다.

언제 문제가 되나 — 한 모듈 안에서 함수끼리 서로 부르는 것은 괜찮습니다. 따로 고치고 시험하려고 나눈 경계를 넘을 때만 문제가 됩니다.

상세

순환 의존은 의존 관계를 따라가다 출발한 곳으로 되돌아오는 고리가 생긴 고장입니다. 고리에 걸린 코드는 모두 한 덩어리로 움직입니다. 이 편은 쇼핑몰 백엔드의 주문·결제·알림 세 모듈을 예로 씁니다.

의존과 고리

모듈은 관련된 코드를 한데 묶어 이름을 붙인 단위입니다. 파일 하나나 패키지 하나가 모듈이 됩니다. 코드를 모듈로 나누는 것은 한 번에 한 묶음만 들여다보고 고치기 위해서입니다.

한 모듈의 소스 코드가 다른 모듈의 함수나 클래스를 이름으로 가져다 쓰면, 앞쪽이 뒤쪽에 의존한다고 합니다. 주문 모듈이 결제 모듈의 pay() 함수를 불러 쓰면 주문 코드에 결제의 이름이 적힙니다. 이때 주문은 결제에 의존합니다. 결제 모듈이 없으면 주문 모듈은 빌드부터 안 됩니다.

모듈을 점으로, 의존을 화살표로 그린 그림을 의존성 그래프라고 합니다. 화살표는 이름을 가져다 쓰는 쪽에서 쓰이는 쪽으로 향합니다. 이 그림에서 화살표를 따라가다 출발한 점으로 돌아오는 길이 하나라도 있으면 순환 의존입니다.

flowchart TD
    주문 -->|pay 를 부른다| 결제
    결제 -->|결과를 알린다| 알림
    알림 -->|주문 내역을 읽는다| 주문

어느 점에서 출발하든 화살표를 셋 지나면 제자리로 돌아옵니다. 고리가 두 모듈 사이에만 생기는 것은 아닙니다. 이렇게 여럿을 돌아 생긴 고리는 모듈 하나만 들여다봐서는 안 보입니다.

화살표를 따라가도 고리가 하나도 없는 그래프를 DAG(Directed Acyclic Graph, 방향 비순환 그래프)라고 합니다. 순환 의존을 없앤다는 것은 의존성 그래프를 DAG 로 만든다는 말과 같습니다.

고리가 생기는 과정

순환 의존은 대개 한 번에 생기지 않습니다. 저마다 이치에 맞는 수정이 몇 번 쌓여 생깁니다. 주문과 결제 사이에서는 흔히 이런 순서를 밟습니다.

  1. 주문 모듈이 결제 모듈의 pay() 를 부릅니다. 의존은 주문에서 결제로 한 방향입니다
  2. 결제가 끝나면 주문 상태를 「결제 완료」로 바꿔 달라는 요구가 들어옵니다
  3. 결제 코드를 고치던 사람이 주문 모듈의 markPaid() 를 바로 부릅니다
  4. 이제 결제도 주문에 의존합니다. 둘 사이에 고리가 생겼습니다

셋째 단계의 수정은 파일 하나만 고치면 끝납니다. 고친 사람은 결제 파일만 보고 있어서 반대 방향 화살표가 이미 있다는 것을 모릅니다. 그래서 순환 의존은 누가 고른 설계가 아닙니다. 수정이 쌓이며 저절로 생깁니다.

재현되는 조건

두 조건이 함께 맞으면 순환 의존이 생깁니다.

  1. 모듈 A 의 코드가 모듈 B 에 있는 함수·클래스·타입 가운데 하나를 이름으로 가져다 씁니다
  2. 모듈 B 의 코드가 모듈 A 의 이름을 가져다 씁니다. 직접 쓰든 다른 모듈을 거쳐 쓰든 같습니다

둘째 조건에서 거쳐 가는 모듈의 수는 상관없습니다. 앞 그림처럼 알림을 하나 거쳐도 고리입니다. 어느 한 조건만 빠져도 화살표가 한 방향으로 끝나서 고리가 없습니다.

「이름으로 가져다 쓴다」는 것은 소스 코드에 상대 모듈의 이름이 적힌다는 뜻입니다. import 문이나 타입 선언이 그렇습니다. 실행 중에 넘겨받은 값을 부르기만 하는 것은 여기에 들지 않습니다. 결제 모듈이 인자로 받은 객체의 메서드를 부를 뿐 소스에는 주문 모듈의 이름을 한 번도 적지 않는 경우가 그렇습니다.

불러오는 순서가 꼬이는 모습

고리가 가장 먼저 눈에 띄는 때는 프로그램이 모듈을 불러오는 순간입니다. 파이썬은 모듈을 불러올 때 그 파일을 위에서부터 실행합니다. 이런 언어에서 고리가 잘 드러납니다. 아래 두 파일이 서로를 불러옵니다.

Python
# order.py
from payment import pay  # payment 로 간다
def mark_paid(): ...

# payment.py
from order import mark_paid  # 오류
def pay(): ...

다른 파일에서 import order 를 하면 아래 순서로 흘러갑니다. 참여자 셋은 order 를 불러오는 쪽과 두 파일입니다.

sequenceDiagram
    participant 부르는쪽 as 부르는 쪽
    participant order as order.py
    participant payment as payment.py
    부르는쪽->>order: 불러온다
    order->>payment: 첫 줄에서 불러온다
    payment->>order: 첫 줄에서 mark_paid 를 찾는다
    order-->>payment: 아직 첫 줄이라 없다
    Note over payment: 이름을 못 찾는 오류로 멈춘다

셋째 화살표가 고장의 핵심입니다. order.py 는 아직 첫 줄을 실행하는 중입니다. 그래서 payment.py 에게는 반쯤 만들어진 모듈이 보입니다. 둘째 줄의 mark_paid 는 그 안에 아직 없습니다.

이 오류는 코드를 조금만 옮겨도 사라집니다. payment.py 를 아래처럼 바꾸면 됩니다.

Python
# payment.py
import order  # 모듈만 받는다
def pay():
    order.mark_paid()  # 이때 찾는다

첫 줄은 mark_paid 라는 이름을 꺼내지 않고 order 모듈만 받아 둡니다. 반쯤 만들어진 모듈이어도 받아 두는 것까지는 됩니다.

mark_paid 는 pay() 가 불릴 때 비로소 찾습니다. 그때는 order 가 다 만들어져 있어서 멀쩡히 돕니다. 오류는 사라지지만 고리는 남습니다.

빌드 순서가 안 나오는 모습

컴파일러를 쓰는 언어에서는 빌드 단계에서 고리가 드러납니다. 빌드 도구는 의존 대상이 먼저 만들어지도록 모듈의 순서를 매깁니다. 의존성 그래프에서 이렇게 순서를 매기는 일을 위상 정렬이라고 합니다.

고리가 있으면 위상 정렬이 답을 못 냅니다. 주문을 만들려면 결제가 먼저 있어야 합니다. 결제를 만들려면 주문이 먼저 있어야 합니다.

한 프로젝트 안의 모듈이라면 컴파일러가 고리에 걸린 모듈을 한 번에 묶어 컴파일할 수 있습니다. 이때는 순서를 매길 필요가 없어 고리가 있어도 빌드가 지나가기도 합니다. 고리는 남은 채 빌드만 통과한 것입니다.

라이브러리는 여러 프로그램이 가져다 쓰도록 미리 따로 빌드해 두는 코드 묶음입니다. 따로 빌드하는 라이브러리끼리는 그렇게 한 번에 묶을 수 없습니다. 두 라이브러리가 서로를 요구하면 빌드 도구는 빌드를 거부합니다.

객체를 만들 때 생기는 고리

같은 고리가 객체를 만드는 순간에도 생깁니다. 의존성 주입(DI, Dependency Injection)은 객체가 쓸 다른 객체를 스스로 만들지 않고 밖에서 넘겨받는 방식입니다. 넘겨받는 통로로 생성자를 흔히 씁니다.

Java
class OrderService {
    OrderService(PaymentService p) { }
}
class PaymentService {
    PaymentService(OrderService o) { }
}

OrderService 를 만들려면 PaymentService 가 먼저 있어야 합니다. PaymentService 를 만들려면 OrderService 가 먼저 있어야 합니다. 어느 쪽도 먼저 만들 수 없습니다. 객체를 대신 만들어 주는 DI 컨테이너는 대개 애플리케이션이 뜰 때 이것을 오류로 알립니다.

단위마다 나타나는 꼴

고리는 코드를 묶는 단위가 무엇이든 생깁니다. 단위가 커질수록 고리를 알아채는 때가 늦어집니다.

단위 고리의 예 드러나는 때
클래스 두 클래스가 생성자로 서로를 받는다 객체를 만들 때
모듈 · 패키지 주문과 결제가 서로의 함수를 부른다 불러올 때 · 빌드할 때
라이브러리 두 라이브러리가 서로를 의존 목록에 올린다 빌드할 때
서비스 두 서비스가 네트워크 너머로 서로를 부른다 배포 순서를 정할 때 · 장애가 번질 때

서비스 사이의 고리는 빌드도 불러오기도 안 막습니다. 그래서 운영 중에 한쪽이 느려져 다른 쪽까지 끌려 내려갈 때 처음 보이기도 합니다.

고리가 막는 일

순환 의존은 모듈을 나눈 목적을 하나씩 무너뜨립니다. 모듈을 나누는 목적은 셋입니다. 따로 시험하기, 따로 고치기, 따로 떼어 내기입니다.

하려는 일 고리가 막는 방식
한 모듈만 시험하기 결제만 단위 테스트 하려 해도 주문을 같이 불러와야 합니다
한 모듈만 고치기 주문의 함수 모양이 바뀌면 고리를 돌아 결제와 알림까지 다시 봐야 합니다
한 모듈만 떼어 내기 결제를 별도 서비스로 옮기려 해도 주문 코드가 따라옵니다

모놀리스는 기능 전체를 한 프로그램으로 빌드하고 배포하는 구조입니다. 표의 셋째 줄은 모놀리스를 쪼갤 때 가장 먼저 부딪히는 벽입니다.

고리를 둔 채로 모듈을 서비스로 옮기면 두 서비스가 네트워크 너머로 서로를 부릅니다. 따로 배포할 수 없는 서비스 묶음이 됩니다. 이것을 분산 모놀리스라고 부릅니다.

모듈러 모놀리스는 한 프로그램 안에서 모듈 경계를 엄격히 지키는 구조입니다. 이 구조가 모듈끼리 부르는 방향을 한쪽으로 정해 두는 것도 이 고리를 막기 위해서입니다.

문제가 안 되는 고리

모든 고리가 고장은 아닙니다. 한 모듈 안에서 함수 둘이 서로를 부르는 상호 재귀는 흔한 기법입니다. 따로 떼어 낼 생각이 없는 단위 안의 고리는 치를 값이 없습니다.

순환 의존이 문제가 되는 것은 고리가 경계를 넘을 때입니다. 여기서 경계는 따로 고치고 시험하고 배포하려고 그어 둔 선입니다. 같은 두 클래스의 고리도 한 모듈 안에 있으면 괜찮습니다. 두 모듈에 걸쳐 있으면 순환 의존입니다.

이름이 비슷한 고리가 둘 더 있습니다. 셋은 무엇이 고리를 이루는지에서 갈립니다.

이름 고리를 이루는 것
순환 의존 소스 코드가 서로의 이름을 가져다 쓰는 관계
순환 참조 실행 중인 객체들이 서로를 가리키는 관계. 순환 의존과 같은 뜻으로 부르는 사람도 많습니다
순환 대기 실행 흐름들이 상대가 쥔 자원을 기다리는 관계. 데드락이 서는 조건 가운데 하나입니다

고리를 찾는 법

작은 고리는 코드를 읽다가 보입니다. 여럿을 도는 고리는 그림을 그려야 보입니다. 그래서 의존성 그래프를 기계로 뽑아 고리를 찾습니다.

고리는 흔히 깊이 우선 탐색으로 찾습니다. 한 점에서 화살표를 따라 끝까지 내려갑니다. 내려가는 동안 지금 걷고 있는 길 위의 점들을 기억해 둡니다. 내려가다 그 길 위의 점을 다시 만나면 고리를 찾은 것입니다.

한 번 없앤 고리는 다시 생기기 쉽습니다. 고리가 생기던 과정이 저마다 이치에 맞는 수정이었기 때문입니다. 그래서 빌드에 검사를 넣어 새 고리가 생기면 빌드가 실패하게 두기도 합니다. 소스 코드를 실행하지 않고 읽어서 이런 규칙을 검사하는 일을 정적 분석이라고 합니다.

고리를 끊는 방법

고리를 끊는 길은 여럿입니다. 공통점은 화살표 하나의 방향을 바꾸거나 화살표 하나를 없애는 것입니다.

첫째 방법에는 인터페이스가 나옵니다. 인터페이스는 부를 함수의 이름과 모양만 정해 두고 몸통은 비워 둔 틀입니다. 부르는 쪽은 이 틀만 알면 됩니다. 몸통은 다른 모듈이 채웁니다.

방법 무엇을 바꾸나
[[의존성 역전 원칙 의존 역전]]
이벤트 발행 결제는 「결제가 끝났다」는 소식만 냅니다. 주문이 그 소식을 받아 상태를 바꿉니다
공통 부분 떼어 내기 둘이 함께 쓰는 코드를 셋째 모듈로 옮깁니다. 둘 다 그쪽만 봅니다
합치기 늘 함께 바뀌는 두 모듈이면 한 모듈로 합칩니다

의존 역전을 쓰고 나면 화살표는 이렇게 놓입니다.

flowchart TD
    주문["주문 모듈"] -->|pay 를 부른다| 결제코드
    주문 -->|창구를 구현한다| 창구
    subgraph 결제모듈["결제 모듈"]
        결제코드["결제 코드"] -->|결제가 끝나면 부른다| 창구["결제 완료 창구"]
    end

화살표가 전부 결제 모듈 쪽을 향합니다. 결제 모듈에서 주문으로 가는 화살표는 없으므로 고리도 없습니다.

실행 순서는 앞과 같습니다. 결제가 끝나면 여전히 주문의 코드가 돕니다. 바뀐 것은 누가 누구의 이름을 적었는가 하나뿐입니다. 그래서 순환 의존을 가르는 기준은 실행 순서가 아니라 소스 코드에 적힌 이름입니다.

관련 항목

순환 의존이 그려지는 그래프 구조

의존성 그래프 · DAG · 방향 그래프 · 그래프

순환 의존을 찾아내는 그래프 알고리즘

깊이 우선 탐색 · 위상 정렬 · 사이클 검출 · 강연결 요소

순환 의존이 생기는 코드 단위

모듈 · 패키지 · 클래스 · 라이브러리 · 마이크로서비스

순환 의존이 드러나는 빌드와 로딩 단계

빌드 · 컴파일러 · 모듈 시스템 · ES 모듈 · CommonJS · 번들러 · 지연 로딩

순환 의존을 끊는 설계 기법

의존성 역전 원칙 · 인터페이스 · 이벤트 · 옵저버 패턴 · 의존성 주입 · 콜백 · 도메인 이벤트

순환 의존이 무너뜨리는 설계 원칙

결합도 · 응집도 · 캡슐화 · 정보 은닉 · 관심사 분리 · 모듈성

순환 의존이 쌓여 커지는 아키텍처 문제

모놀리스 · 모듈러 모놀리스 · 분산 모놀리스 · 빅 볼 오브 머드 · 기술 부채 · 신 객체 · 계층 위반

순환 의존과 이름이 헷갈리는 고리

순환 참조 · 순환 대기 · 데드락 · 상호 재귀 · 무한 루프

순환 의존을 막는 검사 수단

정적 분석 · 아키텍처 테스트 · 린터 · 지속적 통합 · 코드 리뷰

다른 이름: 순환 의존성 · 의존성 순환 · circular dependency · dependency cycle