핵심 진단 질문
- 새 기능 1개 추가에 수정 파일 수가 과도하게 늘어나는가?
- 하위 모듈 변경이 상위 전역 빌드/테스트를 자주 깨는가?
- 의존 그래프에서 순환(cycle) 또는 거대 허브 모듈이 있는가?
- 테스트 더블(mock/stub) 주입이 어렵고 실서비스 의존이 많은가?
의존성이 많다는 사실 자체보다, 의존성 방향/경계/책임이 흐려졌을 때 문제가 커진다. 이 문서는 "무엇이 실제로 망가지는지"를 기술적으로 설명하고, 진단 포인트와 완화 전략을 정리한다.
기록 없음
의존성 꼬임은 결국 "변경 전파 비용" 문제다. 결합도가 높고 경계가 흐리면, 작은 변경이 다수 모듈에 연쇄 전파되어 개발 속도와 안정성을 동시에 떨어뜨린다.
| 증상 | 기술적 원인 | 실무 피해 |
|---|---|---|
| 빌드 시간이 급격히 증가 | 상위 모듈 fan-in/fan-out 과다, 캐시 무효화 범위 확대 | 개발 피드백 루프 지연, PR 사이클 증가 |
| 테스트 불안정/느림 | 모듈 경계 부재로 통합 의존이 기본값 | 회귀 탐지 늦어짐, 릴리즈 리스크 증가 |
| 런타임 사이드이펙트 증가 | 전역 상태 공유, hidden dependency | 원인 추적 어려움, 핫픽스 반복 |
| 리팩터링 난이도 폭증 | 순환 의존/거대 모듈 | 기술 부채 고착화, 신규 기능 속도 저하 |
의존 구조가 App → FeatureA/FeatureB/FeatureC → SharedCore 라고 가정하자.
이때 SharedCore의 public 타입/프로토콜/함수 시그니처를 1줄만 바꿔도
SharedCore를 참조하는 여러 Feature 타겟이 다시 컴파일 대상이 되고,
최종적으로 App 타겟 재빌드까지 이어질 수 있다.
즉 공용 모듈 변경이 항상 전체 재빌드를 의미하진 않지만, 의존 fan-in이 큰 공용 모듈일수록 작은 수정이 다수 타겟 재컴파일로 확산될 확률이 높다.
아래 before 코드는 서비스 내부에서 직접 네트워크와 스토리지를 생성해 테스트가 실환경 의존으로 흘러간다.
// before: 테스트 대체가 어려운 구조
final class UserProfileService {
private let session = URLSession.shared
func fetchName(userID: String) async throws -> String {
let url = URL(string: "https://api.example.com/users/\(userID)")!
let (data, _) = try await session.data(from: url)
let dto = try JSONDecoder().decode(UserDTO.self, from: data)
UserDefaults.standard.set(dto.name, forKey: "cached_name")
return dto.name
}
}// after: 경계를 protocol로 분리해 mock 주입 가능
protocol UserAPIClient {
func fetchUser(userID: String) async throws -> UserDTO
}
protocol UserNameStore {
func save(name: String)
}
final class UserProfileService {
private let apiClient: UserAPIClient
private let store: UserNameStore
init(apiClient: UserAPIClient, store: UserNameStore) {
self.apiClient = apiClient
self.store = store
}
func fetchName(userID: String) async throws -> String {
let dto = try await apiClient.fetchUser(userID: userID)
store.save(name: dto.name)
return dto.name
}
}// test: 실제 네트워크/스토리지 없이 순수 단위 테스트
final class MockAPIClient: UserAPIClient {
var result: Result<UserDTO, Error> = .success(.init(name: "hank"))
func fetchUser(userID: String) async throws -> UserDTO { try result.get() }
}
final class SpyStore: UserNameStore {
var saved: String?
func save(name: String) { saved = name }
}
관련 문서: 이미지/네트워크 파이프라인 문서, ABC 의존 흐름 문서
아래 2가지가 실무에서 가장 자주 쓰는 방식이다.
핵심은 도메인/정책 레이어가 구현 세부사항(네트워크/스토리지)에 역의존하지 않게 유지하는 것이다.
기술적으로는 가능하다. 다만 같은 패키지 안에서 interface+구현이 강결합되면 테스트 대체성, 빌드 분리, 책임 분리가 빠르게 약해진다. 작은 프로젝트/초기 단계에서만 임시로 허용하고, 규모가 커지면 분리하는 편이 안전하다.
1) Build metric
- 변경 1건당 재빌드 타겟 수
- clean/incremental 빌드 시간 추세
2) Dependency metric
- 모듈별 fan-in / fan-out
- cycle 존재 여부
- 최상위 허브 모듈(의존 집중점)
3) Change metric
- 기능 1건당 수정 모듈 수
- 핫픽스의 재발 영역 일치도
4) Test metric
- mock 가능한 경계 비율
- 통합 테스트에만 의존하는 기능 비율숫자로 측정하지 않으면 "복잡하다"는 감각 논의에서 끝난다. 최소 2~3주 추세를 잡아야 리팩터링 우선순위가 명확해진다.
아래 평가는 다음 두 문서의 예시를 바탕으로 한 기술적 관점 정리다.
"테스트 더블 주입 지점"은 보통 Feature 진입부(생성자/팩토리/조립 코드)에서
실제 구현 대신 Mock/Fake/Spy를 넣을 수 있는 경계를 뜻한다.
이 경계가 분명하면 네트워크/스토리지 없이 모듈 단위 테스트를 독립적으로 실행하기 쉽다.
CORE2는 바로 위 "복잡도 누적 패턴" 다이어그램에 있는 노드(Core Util)다.
규칙상 Feature가 Core를 의존해야 하는데, 반대로 CORE2 -> FEAT1가 생기면
Core가 Feature를 참조하는 역방향이 되어 레이어 경계가 무너진다.
이후 FEAT1 -> CORE2 같은 기존 선과 합쳐지면 순환 의존으로 이어질 수 있다.