싱글톤의 함정? 의존성 주입을 통한 더 나은 아키텍처 설계

Clean ArchitectureSingletonDependency InjectionDependency Inversion Principle
avatar
2025.03.13
·
9 min read

소프트웨어 아키텍처에는 의존성을 명확히 관리하는 것은 유지보수성과 확장성을 위해 필수적이다.

콘솔 기반의 숫자 야구 프로젝트를 진행하면서 클린 아키텍처의 Repository를 싱글톤으로 구현 후 직접 참조했는데, 생각해보니 이 방식은 클린 아키텍처의 의존성 분리 원칙을 위반하고 있었다. 조사를 해보니, 클린 아키텍처에서는 Repository를 싱글톤이 아닌 인터페이스로 정의하고 의존성 주입 방식으로 구현하는 것이 일반적이라고 한다.

그렇다면, 왜 싱글톤보다 인터페이스를 사용한 의존성 주입이 더 나은 선택일까?

이 글에서는 싱글톤으로 구현한 Repository의 한계와 이를 의존성 주입 방식으로 리팩토링하며 겪은 트러블 슈팅 과정을 공유하고자 한다.


문제 상황

싱글톤 Repository 사용으로 유연성이 부족함

처음에는 Repository를 싱글톤으로 구현하여 여러 서비스(또는 ViewModel)에서 동일한 인스턴스를 공유하도록 했다.

class UserRepository {
    static let shared = UserRepository() // 싱글톤 인스턴스
    private init() {} // 외부에서 인스턴스 생성 방지
    
    func getUser() -> User {
        return User(name: "춘장")
    }
}

서비스 또는 ViewModel에서 싱글톤 UserRepository.shared를 직접 참조했다.

class UserService {
    private let repository = UserRepository.shared // 특정 구현체에 강하게 결합됨
    
    func fetchUser() -> User {
        return repository.getUser()
    }
}

발생한 문제

  • 클린 아키텍처 위반: UserServiceUserRepository의 구체적인 구현에 직접 의존.

  • 테스트 어려움: UserRepository.shared를 직접 참조하므로, 테스트 시 Mock Repository를 주입하기 어려움.

  • 확장성 부족: UserRepository를 변경하려면 관련된 모든 서비스나 비즈니스 로직을 수정해야 함.

따라서, 의존성 주입을 적용하여 문제를 해결하기로 결정했다.

원인 추론

싱글톤 직접 참조의 의존성 분리 원칙 위반

클린 아키텍처에서는 의존성 분리 원칙을 강조한다.

상위 모듈(High-level Module)은 하위 모듈(Low-level Module)에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.

그러나 싱글톤 패턴을 직접 참조하면, UserServiceUserRepository의 구체적인 구현에 직접 의존하게 된다. 이는 의존성 분리 원칙을 위반하며, 코드가 강하게 결합되는 원인이 된다.

그렇다면 싱글톤에 의존성 주입을 적용하면 되지 않을까? 위에서 싱글톤으로 구현한 UserRepository를 다시 가져와보자.

class UserRepository {
    static let shared = UserRepository()
    private init() {}
    
    func getUser() -> User {
        return User(name: "춘장")
    }
}

그리고 UserService가 외부에서 repository를 주입받을 수 있도록 변경했다.

class UserService {
    private let repository: UserRepository

    // DI를 적용했지만, 여전히 특정 구현체에 의존함
    init(repository: UserRepository = .shared) {
        self.repository = repository
    }

    func fetchUser() -> User {
        return repository.getUser()
    }
}

이제 MockUserRepository를 생성하여 테스트도 가능하고, UserRepository.shared에 직접 의존하지도 않게 되어 결합도도 감소하였다.

하지만 여전히 몇가지 문제점이 있다.

  1. UserRepository라는 구체적인 클래스에 의존하게 되고,

  2. 앱이 종료될 때까지 인스턴스가 유지되며,

  3. 테스트가 가능하지만, 인터페이스가 없어 다른 구현체로 쉽게 바꿀 수 없다.

인터페이스까지 적용한다면?

protocol UserRepository {
    func getUser() -> User
}
class DefaultUserRepository: UserRepository {
    static let shared = DefaultUserRepository()
    private init() {}

    func getUser() -> User {
        return User(name: "춘장")
    }
}

이제 싱글톤을 유지하면서도 인터페이스로 결합도가 낮아졌고, 다른 구현체로 쉽게 변경도 가능해졌다. 하지만 여전히 DefaultUserRepository.shared라는 싱글톤 객체를 기본으로 사용하며, 객체 생명 주기 관리는 어렵다.

만약 싱글톤을 사용하지 않고, 인터페이스와 DI만 사용한다면 결합도는 더 낮아지고 객체 생명 주기도 자유롭게 컨트롤 할 수 있을 것이다.

정리하면,

비교 항목

싱글톤 + DI

싱글톤 + 인터페이스 + DI

인터페이스 + DI

결합도

높음

낮음

더 낮음

테스트 용이성

제한적

좋음

더 좋음

유지보수성

제한적

좋음

더 좋음

유연성

낮음

보통

높음

객체 생명 주기 관리

앱 종료 시까지 유지

앱 종료 시까지 유지

필요할 때 생성/해제 가능

즉, Repository를 인터페이스로 추상화하고 서비스 계층이 추상화에 의존하도록 변경하는 것이 좋다고 생각한다.

해결 방안

인터페이스 + 의존성 주입 적용

  1. Repository Protocol 정의

    protocol UserRepository {
        func getUser() -> User
    }
  2. Repository 구현체 정의

    class DefaultUserRepository: UserRepository {
        func getUser() -> User {
            return User(name: "춘장")
        }
    }
  3. 서비스에서 의존성 주입 적용

    class UserService {
        private let repository: UserRepository
        
        init(repository: UserRepository) {
            self.repository = repository
        }
        
        func fetchUser() -> User {
            return repository.getUser()
        }
    }
  4. 서비스 생성 시 의존성 주입

    let defaultUserRepository = DefaultUserRepository()
    let userService = UserService(repository: defaultUserRepository)

결과 확인

코드의 유연성과 테스트 용이성 향상

  • 클린 아키텍처 준수: UserServiceUserRepository의 구체적인 구현에 의존하지 않음.

  • 테스트 가능성 증가: MockRepository를 만들어 주입할 수 있음.

  • 유연성 증가: 새로운 Repository를 쉽게 추가할 수 있음(ex. NetworkUserRepository, LocalUserRepository).

  • 객체 생명 주기: 자유롭게 컨트롤 가능







- 컬렉션 아티클