PROJECT

GitSearch
같은 앱을 두 번

개인 프로젝트 · GitHub 검색 앱 · Clean Architecture에서 Micro Feature Architecture(TMA)로

2025.03 ~ 2026

GitHub 검색 앱을 Clean Architecture로 만든 뒤, 실무에서 배운 것을 반영해 Micro Feature Architecture로 다시 만들었습니다. 같은 앱을 두 번 만들며 구조가 실제로 무엇을 바꾸는지 확인했습니다.

1장

구조를 다시 자른다

Case 01 · TMA / Micro Feature

레이어 절단에서 피처 절단으로 · 피처 하나 = 5타겟

문제

전편은 Domain / Data / Presentation 레이어로 코드를 갈랐습니다. 검색 하나를 고치는 데 세 레이어 모듈을 동시에 수정해야 했습니다. 전편 README에 스스로 적어 둔 한계(모듈 비대화, 테스트 세분화 제한)가 이 프로젝트의 출발점입니다.

접근

절단 방향을 수평에서 수직으로 바꿨습니다. 피처 하나를 Interface·구현·Testing·Tests·Demo 다섯 타겟으로 자르면 검색 코드는 FeatureSearch 한 모듈 안에 전부 모입니다. Micro Feature는 Clean을 버리는 것이 아니라 감쌉니다. 피처 내부는 여전히 Composition / Domain / Data / Presentation이고 피처 사이만 Interface로 통신합니다.

구현

의존 규칙을 빌드 설정으로 강제했습니다. 피처끼리는 Interface로만 의존하고 구현체 import는 App만 할 수 있습니다. FeatureProfileFeatureAuth의 Interface에만 의존하므로 구현 타입을 참조하는 코드는 컴파일되지 않습니다.

결과

기능 하나를 고칠 때 건드리는 모듈이 하나가 됐습니다. Testing 타겟이 공개한 Mock을 Tests와 Demo가 재사용해 피처별 단독 테스트와 단독 실행이 가능해졌고 전편의 "테스트 세분화 제한"이 풀렸습니다.

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(/* 단독 실행 앱 */)
    ]
)

Case 02 · Tuist / Local Plugin

반복 선언은 플러그인으로, 새 피처는 scaffold 한 줄로

문제

수직 절단의 대가는 타겟 수입니다. 모듈 8개에 타겟 31개, 피처마다 같은 모양의 타겟 선언 5개가 반복됩니다. 스킴 구성까지 손으로 맞추면 언젠가 어긋납니다.

접근

반복을 Tuist 로컬 플러그인 4개(Environment·Dependency·Configuration·Templates)와 DSL로 줄였습니다. 모듈 목록을 enum 레지스트리 하나에 모으면 워크스페이스가 allCases를 순회하므로 새 피처를 추가해도 기존 모듈은 수정할 것이 없습니다.

구현

각 피처의 Project.swift는 .interface / .implements / .testing / .tests / .demo 팩토리 호출만으로 구성됩니다. 스킴은 DSL이 생성합니다: 모듈 스킴 8개에 실행 스킴 12개(데모 5개와 본 앱 각각 DEV / PROD). 새 피처는 tuist scaffold 한 줄과 레지스트리 케이스 한 줄이면 됩니다.

결과

DEV 데모는 진짜 화면 로직에 Testing 타겟의 목 세션만 갈아 끼운 것입니다. 서버·토큰 없이 페이징과 화면 전환까지 실제 코드가 그대로 돕니다.

DependencyPlugin · ModulePaths.swift
public enum ModulePaths {
    case shared(Shared)
    case core(Core)
    case feature(Feature)
}

// 피처 5타겟의 네이밍 규칙
public enum MicroTargetType: String {
    case interface = "Interface"
    case sources = ""
    case testing = "Testing"
    case unitTest = "Tests"
    case demo = "Demo"
}

public extension ModulePaths {
    enum Feature: String, MicroTargetPathConvertable, CaseIterable {
        case FeatureSearch
        case FeatureAuth
        case FeatureProfile
        // 새 피처 = 케이스 한 줄, 워크스페이스는 allCases 순회
    }
}

2장

네트워크 계층을 처음부터 만든다

Case 03 · CoreNetwork / Interceptor

인터셉터 파이프라인 · 시점과 정책의 분리

문제

토큰 부착 같은 공통 정책을 엔드포인트마다 각자 선언하면 중복과 누락이 생깁니다. 전편은 UseCase가 토큰을 꺼내 파라미터로 전달해 사용처마다 토큰의 존재를 알아야 했습니다.

접근

요청의 일생에서 정해진 시점 두 곳에 훅을 뒀습니다. adapt는 전송 직전 요청을 손보고 retry는 실패 직후 재시도를 결정합니다. 시점은 프로토콜이 정의하고 무엇을 끼울지는 Composition Root의 배열이 정합니다. 파이프라인을 실행하는 NetworkSession은 호출 순서만 알 뿐 정책의 내용을 모릅니다.

구현

기본 구현은 안전한 무행동입니다. adapt는 항등, retry는 .doNotRetry라서 인터셉터는 필요한 훅만 구현합니다. 요청을 바꾸는 인터셉터와 지켜보기만 하는 모니터(NetworkEventMonitor)는 프로토콜 자체를 분리했습니다.

결과

새 정책은 새 타입 하나에 조립부 배열 한 줄입니다. 프로토콜도 NetworkSession도 수정하지 않습니다. 토큰은 AuthTokenInterceptor가 자동 부착하므로 사용처는 토큰의 존재를 모릅니다.

RequestInterceptor.swift · 요지
public protocol RequestInterceptor: Sendable {
    func adapt(_ request: URLRequest,
               endpoint: some NetworkEndpoint) throws -> URLRequest
    func retry(_ endpoint: some NetworkEndpoint,
               dueTo error: Error,
               attempt: Int) async -> RetryDecision
}

// 기본 구현 = 안전한 무행동
extension RequestInterceptor {
    public func adapt(...) throws -> URLRequest {
        request        // 항등 - 체인에 끼어도 영향 0
    }
    public func retry(...) async -> RetryDecision {
        .doNotRetry    // 잊어도 사고가 안 나는 방향
    }
}

// NetworkSession.retryDelay - 전원에게 묻되 첫 .retry 채택
for interceptor in interceptors {
    if case .retry(let after) = await interceptor.retry(
        endpoint, dueTo: error, attempt: attempt
    ) { return after }
}
return nil  // 아무도 원하지 않으면 에러 그대로

Case 04 · HTTP / Idempotency

멱등과 재시도 · 게이트가 뚫리지 않는 자리

문제

일시적 네트워크 에러는 다시 보내면 성공할 수 있습니다. 그런데 POST 재전송은 부수효과(토큰 이중 발급류)가 중복 실행될 수 있어 아무 요청이나 재시도하면 사고가 됩니다.

접근

사실과 행동을 나눴습니다. "두 번 보내도 안전한가"는 엔드포인트가 isIdempotent로 선언하는 사실이고 그 사실을 보고 재시도를 결정하는 것은 인터셉터의 행동입니다. 기본값은 RFC 9110 정의대로 HTTP 메서드에서 유도합니다.

구현

재시도는 인터셉터 전원에게 물어 하나라도 .retry면 실행되는 OR 결합입니다. 멱등 게이트를 별도 인터셉터로 빼면 다른 인터셉터의 .retry에 뚫립니다. 그래서 게이트를 재시도 정책 안쪽의 첫 guard로 뒀습니다. 지연은 인터셉터가 지수 백오프로 계산합니다.

결과

비멱등 요청은 에러 종류를 보기도 전에 재시도에서 빠집니다. OAuth 토큰 교환(POST)은 아무 선언 없이도 자동 재시도에서 제외됩니다.

TransientErrorRetryInterceptor.swift · 요지
public struct TransientErrorRetryInterceptor: RequestInterceptor {
    public func retry(
        _ endpoint: some NetworkEndpoint,
        dueTo error: any Error,
        attempt: Int
    ) async -> RetryDecision {
        // 1. 비멱등은 재시도 금지 - 게이트는 정책 안의 첫 guard
        guard endpoint.isIdempotent else { return .doNotRetry }
        // 2. 자체 한도
        guard attempt <= maxRetries else { return .doNotRetry }
        // 3. 일시적 에러만
        guard Self.isTransient(error) else { return .doNotRetry }

        // 지수 백오프: 0.5 -> 1.0 -> 2.0
        return .retry(after: baseDelay * pow(2, Double(attempt - 1)))
    }
}

// NetworkEndpoint.swift - '사실'은 엔드포인트가 선언
public var isIdempotent: Bool {
    switch method {
    case .get, .put, .delete: true
    case .post, .patch: false  // 부수효과 중복 위험
    }
}

3장

수명 규칙을 타입으로 옮긴다

Case 05 · Swift Concurrency / Task

ManagedTask · 방치된 Task의 규칙 5가지를 타입으로

문제

UIKit 진입점(viewDidLoad, 버튼 핸들러)은 동기 함수라 async 작업을 시작하려면 Task {}로 만드는 unstructured task를 쓸 수밖에 없습니다. 이 태스크를 잡아 두지 않으면 화면이 닫혀도 요청이 계속 돌고, 늦게 도착한 옛 응답이 새 화면을 덮는 stale 버그도 생깁니다.

접근

핸들 보관, 재진입 취소, deinit 취소, 취소 후 결과 무시, CancellationError 침묵. ViewModel마다 직접 지켜야 하는 이 규칙 5가지를 컴파일러는 하나도 잡아 주지 않습니다. 규칙을 복붙하는 대신 ManagedTask라는 타입 하나에 가뒀습니다.

구현

replace는 이전 작업을 취소하고 교체합니다(검색). runIfIdle은 진행 중이면 무시합니다(페이징). 슬롯 반납은 generation 비교로 최신 작업만 하게 해 늦게 끝난 이전 작업이 새 작업의 핸들을 지우는 레이스를 막았습니다. 취소는 await 체인을 타고 URLSession까지 내려가 진행 중인 HTTP 요청이 실제로 끊깁니다.

결과

동작 5가지를 계약 테스트로 고정했습니다(테스트 24개 중 ManagedTask 5개). 호출부에 반복되던 [weak self]는 owner 오버로드(replace(with:))와 sink(with:)로 걷어내 20곳을 전환했습니다.

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
            }
        }
    }
}

4장

밑단까지 직접 만든다

Case 06 · Security / Keychain

Keychain 토큰 저장소 · 정책 선언 한 줄의 결정

문제

전편은 토큰을 UserDefaults에 저장했습니다. UserDefaults는 평문 plist 파일이라 자격증명이 들어갈 자리가 아닙니다.

접근

Keychain으로 옮기며 저장 코드보다 정책 선언에 무게를 뒀습니다. 접근성 상수 AfterFirstUnlockThisDeviceOnly 한 줄이 백그라운드 네트워킹은 허용하고 기기 이전 시에는 재로그인을 강제한다는 토큰의 표준 프로파일을 선언합니다. 갱신은 로그인 순간뿐인 저빈도 작업이라 SecItemUpdate 분기 대신 delete 후 add로 단순화했습니다.

구현

kSecClassGenericPassword에 service와 account 조합을 키로 씁니다. 데모 앱은 account를 다른 값으로 바꿔 본 앱과 저장 슬롯을 분리합니다. TokenStorage 계약 테스트는 In-memory Mock과 실제 Keychain 구현이 같은 테스트로 통과합니다.

결과

호스트 앱 없는 테스트 타겟에서 store()가 조용히 실패하는 원인을 OSStatus로 추적해 -34018(errSecMissingEntitlement)을 실측했습니다. Keychain 테스트에 호스트 앱이 필수라는 것이 확인돼 Tests와 HostedTests를 분리했습니다.

KeychainTokenStorage.swift · 요지
public final class KeychainTokenStorage: TokenStorage, Sendable {
    public func store(_ token: String) {
        SecItemDelete(baseQuery as CFDictionary)  // 저빈도라 delete 후 add

        var query = baseQuery
        query[kSecValueData as String] = Data(token.utf8)
        // 첫 잠금해제 후 접근 가능 + 백업/기기 이전 제외
        query[kSecAttrAccessible as String] =
            kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

        let status = SecItemAdd(query as CFDictionary, nil)
        // DEBUG에서 OSStatus 확인 - 침묵 실패 방지
    }

    public func retrieve() -> String? { ... }  // 인터셉터가 동기로 읽는다
    public func clear() { ... }

    // 아이템의 기본키 = 클래스 + service + account
    private var baseQuery: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
    }
}

Case 07 · ImageIO / Prefetch

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

문제

목록에 비동기 이미지를 붙이면 비용이 겹칩니다. UIImage(data:)는 원본 해상도 그대로 풀 디코딩해 55pt 슬롯에 약 826KB 비트맵을 올립니다. 셀 재사용 시 남의 아바타가 잠깐 뜨는 잔상도 생깁니다.

접근

캐시를 "무엇을 담는가"로 나눴습니다. 원본 Data를 담는 바이트 캐시(디스크 영속)는 재다운로드를 줄이고 디코딩된 UIImage를 담는 메모리 캐시는 재디코딩을 줄입니다. 디코딩은 ImageIO 썸네일로 셀 크기(165px)만큼만 해 약 826KB를 약 106KB로 줄였습니다.

구현

다운샘플은 nonisolated async 함수라 협조적 스레드 풀에서 돌고 non-Sendable인 UIImage는 sending 반환(SE-0430)으로 메인 액터에 넘깁니다. 셀에는 representedID를 기록해 완료 시 대조합니다. 화면을 벗어난 행은 URLSession까지 취소를 전파하고 prefetch와 load는 userID별 태스크를 공유해 다운로드를 1회로 합칩니다.

결과

스크롤 중 디코딩이 메인 스레드를 막지 않고 재사용 잔상은 이중 방어(취소 + representedID 대조)로 차단됩니다. 이 경계 전체가 Swift 6 strict concurrency(전 타겟 complete)에서 컴파일로 검증됩니다.

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)
    }
}

기록

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

3

블로그 시리즈

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

1편 읽기 →
8

레포 docs/ 구현 노트

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