PROJECT

iOS-TheModularArchitecture-Sample
같은 앱을 두 번

GitHub 검색 앱 · Clean Architecture에서 Micro Feature Architecture(TMA)로

2025.03 ~ 2026

GitHub 검색 앱을 Clean Architecture로 한 번, Micro Feature Architecture로 한 번 만들었습니다. 같은 요구사항을 두 구조로 구현해 무엇이 달라지는지 직접 비교했습니다.

1장

모듈을 다시 나눈다

Case 01 · TMA / Micro Feature

레이어 대신 피처로 나누기

문제

전편은 Domain / Data / Presentation 레이어로 모듈을 나눴습니다. 그래서 검색 기능 하나를 고치려면 세 모듈을 동시에 열어야 했습니다. 전편 README에 적어 둔 한계(모듈이 계속 커지고, 테스트를 잘게 나누기 어려움)가 이 프로젝트의 출발점입니다.

접근

레이어 대신 피처 단위로 모듈을 나눴습니다. 검색과 관련된 코드는 FeatureSearch 하나에 모두 들어갑니다. 레이어 구분은 피처 안에 그대로 두고, 피처끼리는 Interface로만 주고받습니다.

구현

피처끼리는 Interface로만 의존하고, 구현체는 App에서만 import할 수 있습니다.

결과

기능 하나를 고칠 때 모듈 하나만 열면 됩니다.

FeatureProfile/Project.swift
let project = Project.module(
    name: ModulePaths.Feature.FeatureProfile.rawValue,
    targets: [
        .interface(
            module: .feature(.FeatureProfile),
            dependencies: [.shared(target: .SharedKit)]
        ),
        .implements(
            module: .feature(.FeatureProfile),
            dependencies: [
                .feature(target: .FeatureProfile, type: .interface),
                .shared(target: .SharedKit),
                .core(target: .CoreNetwork),
                .core(target: .CoreDesignSystem),
                // 유일한 피처 간 의존 - Interface에만
                .feature(target: .FeatureAuth, type: .interface)
            ]
        ),
        .testing(/* Stub 공개 */),
        .tests(/* Testing 재사용 */),
        .demo(/* 단독 실행 앱 */)
    ]
)

2장

Task 관리를 타입에 맡긴다

Case 02 · Swift Concurrency / Task

Task 수명을 타입에 맡긴다

문제

UIKit 진입점(viewDidLoad, 버튼 핸들러)은 동기 함수라 async 작업을 시작하려면 unstructured task(Task {})를 만들어야 합니다. 이 task를 들고 있지 않으면 화면을 닫아도 요청이 계속 돌고, 취소한 줄 알았던 이전 검색 결과가 뒤늦게 도착해 최신 결과를 덮어씁니다.

접근

핸들 보관, 재진입 시 취소, deinit 취소, 취소 후 결과 무시, CancellationError 무시. ViewModel마다 매번 지켜야 하는데 컴파일러는 하나도 잡아 주지 않습니다. task를 만들 때마다 이걸 다시 챙기는 대신, ManagedTask 타입이 대신 지키도록 했습니다.

구현

replace는 이전 작업을 취소하고 새 작업으로 바꿉니다(검색). runIfIdle은 이미 돌고 있으면 무시합니다(페이징). 슬롯을 비울 때 generation을 비교해 늦게 끝난 예전 작업이 새 작업의 핸들을 지우지 않게 했습니다. 취소는 await 체인을 타고 URLSession까지 내려가 HTTP 요청이 실제로 끊깁니다.

결과

화면을 닫으면 요청이 실제로 끊기고, 늦게 도착한 이전 응답이 최신 결과를 덮어쓰지 않습니다. 각 동작을 테스트로 고정해 두어 나중에 고쳐도 깨지지 않습니다.

ManagedTask.swift
@MainActor
public final class ManagedTask {
    private var task: Task<Void, Never>?
    private var generation = 0

    deinit { task?.cancel() }  // 소유자 해제 = 요청 취소

    public func replace(...) {   // 검색: 취소 후 교체
        task?.cancel()
        start(...)
    }
    public func runIfIdle(...) { // 페이징: 진행 중이면 무시
        guard task == nil else { return }
        start(...)
    }

    private func start(...) {
        generation &+= 1
        let startedGeneration = generation
        task = Task { [weak self] in
            do { try await operation() }
            catch is CancellationError { }  // 취소는 침묵
            catch { if !Task.isCancelled { onError?(error) } }
            // 최신 작업일 때만 슬롯 반납 - 늦은 완료가
            // 새 핸들을 지우는 레이스 방지
            if let self, self.generation == startedGeneration {
                self.task = nil
            }
        }
    }
}

3장

이미지 캐시를 직접 만든다

Case 03 · ImageIO / Prefetch

아바타 이미지 파이프라인 · 2계층 캐시와 다운샘플

문제

아바타 원본은 목록에 필요한 크기보다 훨씬 큽니다. UIImage(data:)는 원본 해상도 그대로 메모리에 올려 두고, 화면에만 55pt로 줄여 보여줍니다. 실제로 쓰는 크기의 몇십 배를 계속 들고 있습니다. 셀을 재사용할 때 이전 사람 아바타가 잠깐 보이는 문제도 있었습니다.

접근

캐시를 두 개로 나눴습니다. 내려받은 Data는 디스크에 저장해 같은 이미지를 다시 받지 않게 하고(Data 계층), 셀 크기로 줄인 UIImage는 메모리에 저장해 다시 만들지 않게 합니다(Presentation 계층).

구현

다운샘플러가 ImageIO로 셀 크기만큼만 이미지를 만들어 메모리 캐시에 넣습니다. 이 작업은 메인 스레드 밖에서 합니다. 셀에는 어떤 사용자의 이미지를 기다리는지 representedID로 적어 두고, 이미지가 도착하면 지금 그 셀이 맞는지 확인한 뒤 붙입니다. 화면 밖으로 나간 행은 다운로드를 취소합니다.

결과

스크롤할 때 이미지 처리 때문에 화면이 멈추지 않습니다. 셀을 빠르게 재사용해도 이전 사람의 아바타가 나타나지 않습니다.

ImageDownsampler.swift
enum ImageDownsampler {
    static func makeImage(
        from data: Data,
        pointSize: CGSize,
        scale: CGFloat
    ) async -> sending UIImage? {
        let maxPixel = max(pointSize.width, pointSize.height) * scale

        let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        guard let source = CGImageSourceCreateWithData(
            data as CFData, sourceOptions) else { return nil }

        let thumbnailOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceCreateThumbnailWithTransform: true,  // EXIF 회전 반영
            kCGImageSourceShouldCacheImmediately: true,  // 디코딩을 지금, off-main에서
            kCGImageSourceThumbnailMaxPixelSize: maxPixel  // 셀 크기만큼만
        ] as CFDictionary

        guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
            source, 0, thumbnailOptions) else { return nil }

        // scale 명시 - 165px / 3 = 55pt (스케일 1 가정 버그 방지)
        return UIImage(cgImage: cgImage, scale: scale, orientation: .up)
    }
}

기록

과정 전체를 문서로 남겼습니다

블로그 시리즈

왜 이 구조인지, 무엇을 포기했는지까지 velog에 3편으로 나눠 게시했습니다. 아키텍처 결정의 이유부터 트레이드오프까지 글로 정리했습니다.

1편 읽기 →

레포 docs/ 구현 노트

인터셉터 파이프라인, 멱등과 재시도, ManagedTask, owner 패턴, Keychain, 이미지 파이프라인, weak let 생성자 주입, Swift 6 Sendable. 왜 그렇게 결정했는지와 실제 소스 파일 링크를 노트마다 함께 적었습니다.