Architecture Discussion

의존성 꼬임과 복잡도 증가가 만드는 기술적 문제

의존성이 많다는 사실 자체보다, 의존성 방향/경계/책임이 흐려졌을 때 문제가 커진다. 이 문서는 "무엇이 실제로 망가지는지"를 기술적으로 설명하고, 진단 포인트와 완화 전략을 정리한다.

Build, test, runtime impact iOS modular project context Architecture refactor discussion base
학습 날짜

기록 없음

핵심 진단 질문

  • 새 기능 1개 추가에 수정 파일 수가 과도하게 늘어나는가?
  • 하위 모듈 변경이 상위 전역 빌드/테스트를 자주 깨는가?
  • 의존 그래프에서 순환(cycle) 또는 거대 허브 모듈이 있는가?
  • 테스트 더블(mock/stub) 주입이 어렵고 실서비스 의존이 많은가?

문제의 본질

의존성 꼬임은 결국 "변경 전파 비용" 문제다. 결합도가 높고 경계가 흐리면, 작은 변경이 다수 모듈에 연쇄 전파되어 개발 속도와 안정성을 동시에 떨어뜨린다.

기술적 증상과 실제 피해

증상 기술적 원인 실무 피해
빌드 시간이 급격히 증가 상위 모듈 fan-in/fan-out 과다, 캐시 무효화 범위 확대 개발 피드백 루프 지연, PR 사이클 증가
테스트 불안정/느림 모듈 경계 부재로 통합 의존이 기본값 회귀 탐지 늦어짐, 릴리즈 리스크 증가
런타임 사이드이펙트 증가 전역 상태 공유, hidden dependency 원인 추적 어려움, 핫픽스 반복
리팩터링 난이도 폭증 순환 의존/거대 모듈 기술 부채 고착화, 신규 기능 속도 저하

왜 빌드/배포가 흔들리는가

  • 의존 그래프가 넓고 깊으면 incremental build 이점이 줄어든다.
  • 공용 모듈이 비대하면 작은 수정도 다수 타겟을 재컴파일시킨다.
  • 서드파티 패키지 버전/옵션 충돌이 상위 타겟으로 전파된다.
예시: 공용 모듈 변경의 연쇄 영향

의존 구조가 App → FeatureA/FeatureB/FeatureC → SharedCore 라고 가정하자. 이때 SharedCorepublic 타입/프로토콜/함수 시그니처를 1줄만 바꿔도 SharedCore를 참조하는 여러 Feature 타겟이 다시 컴파일 대상이 되고, 최종적으로 App 타겟 재빌드까지 이어질 수 있다.

즉 공용 모듈 변경이 항상 전체 재빌드를 의미하진 않지만, 의존 fan-in이 큰 공용 모듈일수록 작은 수정이 다수 타겟 재컴파일로 확산될 확률이 높다.

왜 테스트가 깨지기 쉬운가

  • DI 경계가 약하면 mock 대체가 어려워 실제 네트워크/스토리지에 붙는다.
  • 공통 유틸에 비즈니스 로직이 누적되면 테스트 범위가 비정상적으로 커진다.
  • 모듈 책임이 겹치면 assertion 포인트가 모호해진다.
구체 예시: DI 경계 부재로 테스트가 외부 의존에 붙는 경우

아래 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 의존 흐름 문서

DI 경계 설계 예시 (SharedCore 기준)

아래 2가지가 실무에서 가장 자주 쓰는 방식이다. 핵심은 도메인/정책 레이어가 구현 세부사항(네트워크/스토리지)에 역의존하지 않게 유지하는 것이다.

옵션 A: SharedCore가 인터페이스 소유, 구현은 하위 모듈

flowchart LR APP["App Composition Root"] --> FEAT["Feature"] FEAT --> CORE["SharedCore (protocol only)"] FEAT --> DATA["Data/Infra Implementations"] DATA --> CORE subgraph CORE_EX["SharedCore"] C1["UserRepository protocol"] C2["Clock protocol"] end subgraph DATA_EX["Data/Infra"] D1["RemoteUserRepository"] D2["SystemClock"] end

옵션 B: SharedCoreInterfaces 별도 모듈 분리

flowchart LR APP["App Composition Root"] --> FEAT["Feature"] FEAT --> CORE["SharedCore Domain"] FEAT --> IFACE["SharedCoreInterfaces"] DATA["Data/Infra"] --> IFACE CORE --> IFACE APP --> DATA

1. SharedCore 패키지에 interface와 구현체를 같이 넣어도 되나?

2. 정리

기술적으로는 가능하다. 다만 같은 패키지 안에서 interface+구현이 강결합되면 테스트 대체성, 빌드 분리, 책임 분리가 빠르게 약해진다. 작은 프로젝트/초기 단계에서만 임시로 허용하고, 규모가 커지면 분리하는 편이 안전하다.

flowchart LR FEAT["Feature Test"] --> CORE_BAD["SharedCore (interface + concrete)"] CORE_BAD --> NET["Real Network"] CORE_BAD --> STORE["Real Storage"] FEAT -. "mock 주입 어려움" .-> CORE_BAD

정량 지표(Discussion Metrics)

1) Build metric
- 변경 1건당 재빌드 타겟 수
- clean/incremental 빌드 시간 추세

2) Dependency metric
- 모듈별 fan-in / fan-out
- cycle 존재 여부
- 최상위 허브 모듈(의존 집중점)

3) Change metric
- 기능 1건당 수정 모듈 수
- 핫픽스의 재발 영역 일치도

4) Test metric
- mock 가능한 경계 비율
- 통합 테스트에만 의존하는 기능 비율

숫자로 측정하지 않으면 "복잡하다"는 감각 논의에서 끝난다. 최소 2~3주 추세를 잡아야 리팩터링 우선순위가 명확해진다.

완화 전략(단기)

  • 순환 의존 1개씩 끊기: protocol interface 모듈 분리
  • 거대 공용 모듈 분할: 도메인/인프라 경계 재정의
  • 이미지/네트워크/스토리지처럼 변동 큰 영역부터 DI 강화
  • 테스트 더블 주입 포인트를 feature entry에서 강제

완화 전략(중기)

  • 의존성 가이드라인 문서화(허용 방향/금지 방향)
  • CI에서 cycle/fan-out 임계치 검사 자동화
  • 모듈 오너십 명확화(책임 모호 영역 제거)
  • 변경 전파 비용이 높은 모듈부터 단계적 분해

ABC 사례 기반 분석

아래 평가는 다음 두 문서의 예시를 바탕으로 한 기술적 관점 정리다.

목적은 “좋고 나쁨 평가”보다 변경 전파 비용을 줄일 구조 개선 포인트를 찾는 것이다.

다이어그램: 권장 의존 방향

flowchart LR APP["App Layer"] FEAT["Feature Layer"] CORE["Core Layer"] EXT["External Packages"] APP --> FEAT FEAT --> CORE CORE --> EXT
  • 방향이 단방향이면 영향 전파가 아래쪽으로 제한된다.
  • Feature 수정 시 App/Core 전체 재조정 가능성이 낮아진다.
  • 테스트 더블 주입 지점이 명확해 모듈 테스트 분리가 쉬워진다.
보충 해설

"테스트 더블 주입 지점"은 보통 Feature 진입부(생성자/팩토리/조립 코드)에서 실제 구현 대신 Mock/Fake/Spy를 넣을 수 있는 경계를 뜻한다. 이 경계가 분명하면 네트워크/스토리지 없이 모듈 단위 테스트를 독립적으로 실행하기 쉽다.

다이어그램: 복잡도 누적 패턴

flowchart LR APP["App"] FEAT1["Feature A"] FEAT2["Feature B"] CORE1["Core Service"] CORE2["Core Util"] HUB["Shared Manager"] EXT1["SDK 1"] EXT2["SDK 2"] APP --> FEAT1 APP --> FEAT2 FEAT1 --> CORE1 FEAT2 --> CORE1 FEAT1 --> CORE2 FEAT2 --> CORE2 FEAT1 --> HUB FEAT2 --> HUB HUB --> CORE1 HUB --> CORE2 HUB --> EXT1 CORE1 --> EXT2 CORE2 --> EXT2 CORE2 --> FEAT1
  • 허브(`HUB`) 집중 의존은 fan-in/fan-out을 동시에 키워 재빌드 범위를 확장한다.
  • `CORE2 -> FEAT1` 같은 역방향 의존은 레이어 규칙을 깨고 순환 위험을 만든다.
  • 작은 기능 변경도 다중 모듈에 전파되어 배포 안정성을 떨어뜨린다.
보충 해설

CORE2는 바로 위 "복잡도 누적 패턴" 다이어그램에 있는 노드(Core Util)다. 규칙상 Feature가 Core를 의존해야 하는데, 반대로 CORE2 -> FEAT1가 생기면 Core가 Feature를 참조하는 역방향이 되어 레이어 경계가 무너진다. 이후 FEAT1 -> CORE2 같은 기존 선과 합쳐지면 순환 의존으로 이어질 수 있다.

잘한 점 (Why It Works)

  • App / Core / Feature 구분으로 레이어 인식이 명확하다. 이 분류는 의존성 논의 시 책임 경계를 설명하기 쉽다.
  • 읽기 순서(기반 → 앱 공통 → feature)를 제시해 온보딩 비용을 줄인다. 구조 복잡도가 높아도 학습 경로가 있으면 진입 장벽이 낮아진다.
  • 외부 패키지 매핑을 공개해 “어떤 모듈이 어떤 서드파티에 묶였는지”를 빠르게 파악할 수 있다. 의존성 영향도 분석(업데이트/라이선스/보안)에 유리하다.
  • Target / Scheme / Build Configuration 흐름을 문서화해 빌드 실패 원인 추적 시간을 줄인다. 구조 문서가 없는 프로젝트 대비 운영 안정성이 높다.

개선 포인트 (Why It Hurts)

  • 일부 상위 모듈에 의존성이 집중되는 허브 구조가 보인다. 허브 모듈 변경 시 재빌드/회귀 범위가 과도하게 커질 위험이 있다.
  • Feature가 Core + App 공통 + 다수 외부 패키지를 동시에 참조하는 패턴은 변경 전파를 키운다. 기능 수정이 인프라/공통 레이어까지 파고드는 현상이 발생하기 쉽다.
  • 의존 방향 규칙(허용/금지)이 문서 차원 설명에 머무르면 시간이 지나며 위반이 누적된다. CI에서 cycle/fan-out 임계치 검사를 자동화할 필요가 있다.
  • 공통 유틸/매니저 모듈이 비대해지면 테스트 더블 주입이 어려워진다. 결과적으로 통합 테스트 의존도가 올라가고 피드백 속도가 느려질 수 있다.

토론 템플릿(회의용)

논의를 "좋은 구조 vs 나쁜 구조"가 아니라 "현재 비용을 줄이는 실험 계획"으로 전환하면 실행 가능성이 올라간다.