ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Circular Dependency (순환 참조) 문제와 해결 방법
    kotlin 2025. 4. 27. 17:29

    개발을 하다 보면 클래스 간의 의존성이 서로 얽히는 "써큘러 디펜던시(Circular Dependency)" 문제를 종종 만나게 됩니다. 이번 글에서는 코틀린(Kotlin) 코드로 순환 의존성의 문제를 살펴보고, 이를 lazy 초기화, 의존성 역전 원칙(DIP) 등을 통해 해결하는 방법을 예제와 다이어그램을 곁들여 정리해보겠습니다.


    1. 써큘러 디펜던시란?

    써큘러 디펜던시(Circular Dependency)는 두 개 이상의 클래스가 서로를 직접 또는 간접적으로 참조해서 순환 참조가 발생하는 상황을 말합니다. 이는 프로그램의 컴파일 또는 실행 과정에서 오류를 유발할 수 있습니다.

    문제 예제

    class A(private val b: B)
    
    class B(private val a: A)
    • A B를 필요로 하고, B A를 필요로 합니다.
    • 이 상태로는 인스턴스를 생성할 수 없습니다.
    • 초기화할때 A, B 두 인스턴스가 모두 초기화 되어 있어야하는 딜레마가 생긴다.

    다이어그램

    +---+          +---+
    | A | -------> | B |
    |   | <------- |   |
    +---+          +---+

    2. 해결 방법 1: Lazy를 이용한 회피

    lazy 초기화를 사용하면, 생성 시점에 바로 의존성을 요구하지 않고, 실제 사용 시점에 초기화를 미루어 초기화 실패를 회피할 수 있습니다.

    Lazy 사용 예제

    class A(private val lazyB: Lazy<B>) {
        fun doSomething() {
            val b = lazyB.value
            b.doSomethingElse()
        }
    }
    
    class B(private val a: A) {
        fun doSomethingElse() {
            println("B is working with A")
        }
    }
    
    // 생성 코드
    val a: A by lazy { A(lazy { b }) }
    val b = B(a)
    • A B를 바로 요구하지 않고 필요할 때만 사용합니다.
    • 순환 생성 문제를 "회피"할 수 있습니다.

    Lazy 사용 순서도

    Start
      |
    [A 생성]
      |
    [필요 시점?] - No -> 계속 대기
           |
          Yes
           |
       [B 생성]
      |
     End

    그러나, lazy 사용은 순환 의존을 완전히 해결하는 것은 아닙니다. 구조적으로는 여전히 두 클래스가 서로를 알고 있습니다.

    Lazy를 사용할 때 발생할 수 있는 문제 및 예제

    1) 런타임 오류 가능성

    class A(private val lazyB: Lazy<B>) {
        fun doSomething() {
            val b = lazyB.value  // 여기서 초기화된 b가 없으면 예외 발생 가능
            b.doSomethingElse()
        }
    }
    
    class B(private val a: A)
    
    val a: A by lazy { A(lazy { error("B가 아직 초기화되지 않음") }) }
    val b = B(a)
    
    fun main() {
        a.doSomething() // 런타임 에러 발생
    }

    2) 순환 참조로 인한 메모리 누수

    class A(private val lazyB: Lazy<B>)
    class B(private val a: A)
    
    lateinit var a: A
    lateinit var b: B
    
    fun create() {
        a = A(lazy { b })
        b = B(a)
    }
    
    fun main() {
        create()
        // a와 b는 서로를 참조하고 있어 GC가 수거하지 못함 -> 메모리 누수 가능
    }

    3) 코드 복잡성 증가

    class Manager(private val lazyWorker: Lazy<Worker>) {
        fun manage() {
            if (shouldStartWork()) {
                lazyWorker.value.work()
            }
        }
    }
    
    class Worker(private val manager: Manager) {
        fun work() {
            println("Working...")
        }
    }
    
    fun shouldStartWork(): Boolean = (Math.random() > 0.5)
    
    // 복잡한 로직 안에서 lazy.value를 사용하면 흐름 추적이 어렵다.

    4) 테스트 난이도 증가

    class A(private val lazyB: Lazy<B>) {
        fun action() {
            lazyB.value.doAction()
        }
    }
    
    class B {
        fun doAction() {
            println("Action!")
        }
    }
    
    fun main() {
        val mockB = B()
        val a = A(lazy { mockB })
    
        // 테스트 중 언제 lazyB.value가 호출될지 예측하기 어려움
        a.action()
    }

    3. 해결 방법 2: 의존성 역전 원칙(DIP) 적용

    **Dependency Inversion Principle (DIP)**를 적용하면, 고수준 모듈(A)과 저수준 모듈(B) 둘 다 공통 인터페이스에 의존하도록 만들어서 순환 참조를 아예 없앨 수 있습니다.

    DIP 적용 예제

    // 인터페이스 정의
    interface BContract {
        fun doSomethingElse()
    }
    
    class A(private val b: BContract) {
        fun doSomething() {
            b.doSomethingElse()
        }
    }
    
    class B(private val a: A) : BContract {
        override fun doSomethingElse() {
            println("B is working with A")
        }
    }
    
    // 생성 코드
    val a: A by lazy { A(b) }
    val b = B(a)
    • A BContract만 알기 때문에, B에 직접 의존하지 않습니다.
    • 구조적으로 순환 의존이 끊어졌습니다.

    DIP 구조 다이어그램

    +--------+        +------------+
    |   A    | -----> | BContract  |
    +--------+        +------------+
                                    ^
                                    |
                                  +---+
                                  | B |
                                  +---+

    DIP를 적용하면 구조적 문제도 해결하고, 시스템이 훨씬 견고하고 유지보수하기 쉬워집니다.


    마무리

    구분 Lazy 사용 DIP 적용
    초기화 실패 방지 가능 가능
    순환 의존성 제거 불가 가능
    설계 품질 그대로 개선
    • lazy 응급처치 (초기화 문제만 회피)
    • DIP 근본 치료 (구조적으로 문제 제거)

    상황에 따라 필요한 방법을 잘 선택해서 사용하는 것이 중요합니다. 가능하면 DIP를 적용해 구조를 깔끔하게 만드는 것이 가장 좋은 방향입니다.

Designed by Tistory.