Architecture Deep Dive

모듈 아키텍처 심층 분석

레포를 특정하지 않고도 모듈 아키텍처를 평가할 수 있도록, 의존 방향, 테스트 경계, 변경 전파, 빌드 영향도를 정량·정성 기준으로 정리한다. 이 문서는 개념 설명을 넘어서 실제 리팩터링 순서와 검증 체크리스트까지 포함한다.

Confirmed: Layered + Interface-first pattern Assumption: Swift SPM 기반 모듈화 Decision Needed: 경계 분리 우선순위
학습 날짜

2026-04-29

Why This Work Exists

모듈 수가 늘어나면 빌드 시간, 테스트 비용, 변경 파급이 급격히 커진다. 특히 "공통 모듈이 기능 모듈을 참조"하거나 "구현체 직접 의존"이 누적되면 작은 수정도 다수 모듈 재빌드와 회귀 검증으로 이어진다.

Scope / Non-scope

  • 범위: 모듈 경계/의존 방향/테스트 대체성/변경 전파 분석
  • 범위: DI 경계 설계와 인터페이스 분리 전략
  • 제외: 특정 앱의 실제 그래프 추출 결과(별도 문서에서 수행)
  • 제외: CI 파이프라인/캐시 최적화의 도구별 세부 튜닝

As-is

  • 기능 모듈에서 공통 모듈 구현체를 직접 참조
  • 공통 모듈에서 기능 모듈 타입을 참조(역방향 의존)
  • 테스트 시 mock 주입 경계가 약해 실제 I/O 연결 발생
  • 변경 영향 범위를 예측하지 못해 릴리즈 리스크 확대

To-be

  • 상위 레이어는 하위 레이어만 참조
  • 모듈 간 계약은 interface(프로토콜)로만 노출
  • 테스트 더블 주입 지점을 강제해 모듈 테스트 분리
  • 변경 전파를 계약 변경 시점으로 제한

What The Developer Must Do Next

  1. 모듈 의존 그래프를 추출하고 레이어 위반/순환 여부를 표시한다.
  2. 외부 I/O 경계(네트워크/스토리지/이벤트/로그)를 interface로 먼저 분리한다.
  3. 구현체 직접 생성 지점을 주입 방식으로 교체한다.
  4. 테스트 더블 기반 단위 테스트를 추가해 실제 I/O 연결을 차단한다.
  5. 변경 전파(재빌드 대상) 지표를 전/후 비교한다.
우선순위 원칙

모듈 전체를 한 번에 뜯지 말고, "파급이 큰 경계"부터 순차적으로 분리한다.

API / Data Contract

계약 타입노출 규칙예시
UseCase InterfaceFeature 외부에 동작 계약만 노출LoginUseCaseInterface
Gateway InterfaceCore 구현체 대신 protocol 노출TokenStoreInterface
DTO/Entity경계 간 전달 모델을 불변에 가깝게 유지AuthTokenDTO

Risks And Decisions Needed

  • 리스크: 과도한 추상화로 코드량/복잡도만 증가
  • 리스크: 계약 설계 미숙으로 interface churn 발생
  • 결정: interface 모듈 위치(공통 패키지 내부 vs 별도 패키지)
  • 결정: 모듈 분리 granularity(화면 단위 vs 도메인 단위)

Class Diagram

classDiagram
            class AppComposer
            class FeatureInteractor
            class FeaturePresenter
            class WorkerInterface {
              <>
              +execute()
            }
            class WorkerImpl
            class CoreGatewayInterface {
              <>
              +request()
            }
            class CoreGatewayImpl

            AppComposer --> FeatureInteractor : injects
            FeatureInteractor --> WorkerInterface : depends on
            WorkerImpl ..|> WorkerInterface
            WorkerImpl --> CoreGatewayInterface
            CoreGatewayImpl ..|> CoreGatewayInterface
            FeatureInteractor --> FeaturePresenter
          

Sequence Diagram

sequenceDiagram
            participant VC as ViewController
            participant I as Interactor
            participant W as WorkerInterface
            participant C as CoreGatewayInterface
            participant P as Presenter

            VC->>I: doAction(request)
            I->>W: execute(request)
            W->>C: request(dto)
            C-->>W: response
            W-->>I: result
            I->>P: present(result)
            P-->>VC: viewModel
          

Flowchart

flowchart LR
          A[Start: Analyze module graph] --> B{Reverse dependency exists?}
          B -->|Yes| C[Split contract module]
          B -->|No| D{Direct concrete dependency exists?}
          C --> D
          D -->|Yes| E[Introduce interface + DI]
          D -->|No| F{Test doubles can replace I/O?}
          E --> F
          F -->|No| G[Refactor injection boundary]
          F -->|Yes| H[Measure build impact]
          G --> H
          H --> I[Finalize checklist + rollout]
        

Before / After 코드 예시

Before (구현체 직접 의존)

final class LoginInteractor {
    private let worker = RealLoginWorker()

    func login(id: String, pw: String) async throws -> Token {
        try await worker.login(id: id, pw: pw)
    }
}

After (계약 의존 + 주입)

protocol LoginWorkerInterface {
    func login(id: String, pw: String) async throws -> Token
}

final class LoginInteractor {
    private let worker: LoginWorkerInterface

    init(worker: LoginWorkerInterface) {
        self.worker = worker
    }

    func login(id: String, pw: String) async throws -> Token {
        try await worker.login(id: id, pw: pw)
    }
}

테스트 예시 (mock/stub)

final class LoginWorkerStub: LoginWorkerInterface {
    var result: Result!

    func login(id: String, pw: String) async throws -> Token {
        switch result {
        case .success(let token): return token
        case .failure(let error): throw error
        case .none: fatalError("result not set")
        }
    }
}

func test_login_success() async throws {
    let stub = LoginWorkerStub()
    stub.result = .success(Token(value: "t1"))
    let sut = LoginInteractor(worker: stub)

    let token = try await sut.login(id: "a", pw: "b")
    assert(token.value == "t1")
}

핵심: 실제 네트워크 없이 use case 테스트가 독립적으로 수행된다.

정량 지표 (Architecture Health Metrics)

지표의미권장 방향
역방향 의존 수하위 레이어가 상위 레이어를 참조하는 건수0
순환 컴포넌트 수강결합 SCC(Strongly Connected Component) 개수0
직접 구현체 의존 비율구현체 타입 import / 전체 의존감소 추세
테스트 더블 대체율테스트에서 외부 I/O를 더블로 대체 가능한 비율상승 추세
변경 전파 크기모듈 1개 변경 시 재빌드/재테스트 대상 수감소 추세

Migration Playbook (단계별 적용)

  1. Step 1: 그래프 추출 후 역방향/순환 구간 식별
  2. Step 2: 경계 모듈에 interface target 추가
  3. Step 3: 구현체 직접 의존 코드 주입 방식으로 교체
  4. Step 4: 테스트 더블 기반 단위 테스트 확보
  5. Step 5: 빌드/테스트 영향도 지표 비교 및 롤아웃
대규모 프로젝트에서는 "고립된 1개 기능 모듈"을 파일럿으로 먼저 적용한 뒤, 규칙을 템플릿화해서 점진 확장하는 방식이 가장 안전하다.

QA Checklist

  • 성공: 인터페이스 교체 후 기능 동작 동일
  • 성공: 테스트가 실제 네트워크 없이 통과
  • 실패: 역방향 의존이 재발하지 않는지 정적 점검
  • 회귀: 기존 기능 모듈의 import 경로 변화 점검

Operations / Rollout Checklist

  • 릴리즈 전: 핵심 사용자 플로우 smoke test
  • 릴리즈 후: 빌드 시간/실패율 추이 모니터링
  • 문제 발생 시: interface 변경과 구현 변경 분리 rollback
  • 지속 관리: 아키텍처 규칙(역방향/순환 금지) CI 검사