테크
2026. 09. 09
15년 묵은 에브리타임 iOS, Tuist로 허물고 다시 쌓다
프리뷰 하나 띄우자고 13년 치 코드와 씨름한 이야기
에브리타임 iOS 앱은 2011년에 출시됐지만, 지금 저장소에 남아있는 가장 오래된 커밋은 2013년입니다. 그로부터 13년간 커밋은 9,000개 가까이 쌓였고, 코드베이스에는 시대의 지층이 그대로 남아 있습니다. Objective-C 파일도 살아 있고, Storyboard와 xib이 50개가 넘으며, UIViewController 서브클래스는 100개가 넘습니다. 가장 큰 Storyboard 파일 하나에는 뷰, 라벨 같은 UI 컴포넌트가 230여 개 넘게 담겨 있습니다. 이 모든 것이 최근까지 하나의 앱 타겟 안에 있었습니다.
타겟은 Xcode가 컴파일하는 최소 단위입니다. 하나의 타겟 안에 있다는 것은, 코드 몇 줄만 바꿔도 이 모든 코드가 매번 다시 컴파일 대상이 된다는 뜻이었습니다. 파일 하나만 추가해도 프로젝트 설정 파일 전체가 바뀌어, 누구와도 동시에 작업하기 어려웠습니다.
이 문제를 해결하기 위해 저희는 단일 타겟 앱에 Tuist로 모듈 아키텍처를 도입하기로 결정했습니다. 한 번의 빅뱅 마이그레이션은 아니었습니다. 서비스를 멈추지 않고 2년째 진행 중인 이야기를 지금부터 시작해 보겠습니다.
프리뷰 하나 쓰고 싶다는 마음에서 시작된 프로젝트
모듈화의 동기는 거창하지 않았습니다. 단지 SwiftUI 프리뷰를 보면서 화면을 개발하고 싶었습니다.

#Preview {
ArticleListView()
}하지만 단일 타겟 위에서는 그조차 쉽지 않았습니다. 프리뷰 한 번 띄우는 데도 13년 치 코드를 전부 컴파일해야 했습니다. 여기에 Objective-C 브릿징 헤더와 CocoaPods 의존성까지 얽혀 있다 보니, 프리뷰는 돌아가다 말다 하다가 결국 아무도 쓰지 않게 되었습니다. 수정하고, 전체 빌드하고, 탭을 눌러 해당하는 화면까지 찾아가 확인하는 루프가 일상이었습니다.

마침 이미 쥐고 있는 도구가 하나 있었습니다. 여러 명이 동시에 작업하면서 project.pbxproj 충돌에 시달리던 끝에, 2024년 5월부터 Tuist를 도입해 둔 상태였습니다. 처음엔 그저 프로젝트 파일 충돌을 없애려는 목적이었지만, 이 선택이 훗날 모듈화의 발판이 되어 주었습니다.

Tuist는 Xcode 프로젝트를 Swift 코드로 선언하고 생성하는 도구입니다. 프로젝트의 타겟·의존성·빌드 설정을 Project.swift 매니페스트에 코드로 적어 두면 tuist generate 명령어가 그로부터 .xcodeproj를 생성합니다. 프로젝트 파일은 이제 저장소에 커밋되지 않고, 매니페스트로부터 매번 생성되는 결과물이 됩니다.
프로젝트 구조가 이미 Swift 코드(Project.swift)로 선언되어 있으니, 모듈을 하나 늘릴 때 Xcode GUI를 헤집는 대신 코드 몇 줄만 추가하면 됩니다.
2025년 1월, 전체 프로젝트를 Projects/ 폴더 아래로 옮기고 Tuist 워크스페이스로 재편했습니다. 2,600여 개 파일이 움직인 커밋이었지만 내용 변경은 거의 없는, 순수한 이사였습니다. 여기서부터 본격적인 코드 분리가 시작됩니다.
// Workspace.swift
let workspace = Workspace(
name: "everytime",
projects: [
.relativeToRoot("./Projects/**")
]
)모듈화 1️⃣ 의존성 최말단부터 — Shared
가장 먼저 떼어낼 곳은 의존 그래프의 최말단이었습니다. 아무것도 의존하지 않는 코드부터 분리해야 컴파일이 깨지지 않기 때문입니다.

그해 3월부터 Shared 프로젝트를 만들고, 유틸리티·리소스·외부 의존성 래퍼처럼 다른 모듈을 필요로 하지 않는 코드부터 옮기기 시작했습니다. 이 단계의 실질적인 작업은 코드 이동보다 접근 제어자 정리였습니다. 단일 타겟 안에서는 모든 것이 internal로 통했지만, 모듈 경계를 넘는 순간 public을 명시해야 합니다. 파일을 옮기고, 빌드를 깨뜨리고, 컴파일러가 알려주는 지점마다 public을 붙이는 반복이었습니다. 지루한 과정이지만 이 과정 자체가 "이 타입의 진짜 공개 표면(public surface)이 무엇인가"를 처음으로 고민하게 만들었습니다.
현재 Shared에는 리소스 모듈(Tuist의 ResourceSynthesizer를 통한 문자열·에셋 자동 생성), 유틸리티 모듈, 디버그 도구 모듈 같은 타겟들이 있습니다. 특히 리소스 모듈은 앱의 모든 문자열·이미지·컬러의 단일 소스가 되었습니다. 리소스가 App 타겟에 갇혀 있으면 나중에 만들 Feature 모듈이 전부 App에 역의존하게 되기 때문에, 리소스를 먼저 최말단으로 내려보낸 것이 이후 단계의 관건이었습니다.
모듈화 2️⃣ 인프라 계층 — Core
Shared가 자리를 잡자, 그 위 계층을 세울 차례였습니다. 외부 SDK 의존성이 있거나 Shared에 의존하는 전역 인프라 코드가 대상이었습니다. 올해 3월 Core 프로젝트를 신설하고, 네트워크 동작을 담당하는 코드부터 옮겼습니다.

Core에는 아래 타겟들이 있습니다.
네트워크 모듈
로컬 저장소 모듈
디자인 시스템 모듈
광고 모듈
로깅·원격 설정·웹뷰 모듈 등
계층을 설계한 규칙은 단순합니다. Shared는 본인과 외부 의존성 외에는 모르고, Core는 Shared와 하위 의존성만 압니다. Feature는 Core와 Shared를 알고, App은 전부 압니다. 역방향 의존이 생기려는 순간, 해당 코드의 설계를 다시 생각해 봐야 했습니다.
의존성 선언이 코드라는 점이 여기서 빛을 발했습니다. 자주 쓰는 타겟 의존성을 헬퍼에 상수로 선언해 두어 Feature 쪽 매니페스트에서 조립하여 사용할 수 있게 되었습니다.
// Tuist/ProjectDescriptionHelpers
extension ProjectDescription.TargetDependency {
public static let Network: TargetDependency =
.project(target: "Network", path: .relativeToRoot("Projects/Core"))
public static let Utility: TargetDependency =
.project(target: "Utility", path: .relativeToRoot("Projects/Shared"))
// ...
}모듈화 3️⃣ Feature 모듈과 TMA — 연합게시판을 첫 사례로
인프라가 갖춰졌으니 이제 화면을 모듈로 만들 차례입니다. 올해 6월 연합게시판 기능을 처음으로, Tuist가 제안하는 The Modular Architecture(TMA)를 적용했습니다.

TMA에서 하나의 Feature는 다음 레이어들로 구성됩니다.
Feature/
├── FeatureInterface # 도메인 모델, Codable 모델, 의존성 프로토콜 — 계약만
├── Feature # Reducer, View, ServiceProvider — 실제 구현
├── FeatureTesting # Interface 기반 목·샘플 데이터
├── FeatureTests # 이 Feature만 검증하는 유닛 테스트
└── FeatureExample # 이 Feature만 실행하는 데모 앱각 레이어가 무엇을 담고 어디에 의존하는지 차례로 살펴보겠습니다.
① Interface: 계약
이 Feature가 바깥 세상과 주고받는 공개 API와 데이터 모델만 담습니다. 구현은 한 줄도 없습니다. TMA 문서는 이 레이어의 존재 이유를 "한 모듈의 구현이 다른 모듈의 구현에 결합(coupling)되는 것을 막기 위해"라고 설명합니다. 다른 모듈은 이 Feature를 쓸 때 Interface에만 의존하면 되므로, 구현이 아무리 바뀌어도 계약이 유지되는 한 영향을 받지 않습니다.
② Feature(구현): 본체
Reducer(TCA의 핵심 개념), View, ServiceProvider 등 실제 구현 코드와 리소스가 들어갑니다. 자신의 Interface에 의존하며, Interface에 선언된 계약을 채웁니다.
③ Testing: 목과 샘플 데이터
Interface에 정의된 타입들의 목(Mock)·샘플 구현을 제공합니다. 중요한 것은 의존 방향입니다: Testing은 Interface에만 의존하고 구현은 모릅니다. 덕분에 다른 Feature가 이 모듈을 목킹해서 테스트할 때도, 프리뷰에 샘플 상태를 주입할 때도, 무거운 실제 구현을 끌어올 필요가 없습니다.
④ Tests: 검증
이 Feature만 검증하는 유닛·통합 테스트입니다. Feature(구현)와 Testing에 의존해서 Testing이 제공하는 목 데이터로 구현을 검증합니다. 앱 전체가 아니라 모듈 하나만 빌드하면 테스트가 돌아갑니다.
⑤ Example: 데모 앱
Feature(구현)와 Testing에 의존하는 실행 가능한 샘플 앱입니다. 이 Feature를 다양한 조건(언어, 화면 크기, 상태)에서 띄워 보는 용도로, 개발 중의 작업대이자 살아 있는 사용 예시 문서가 됩니다.
타겟 사이의 의존 방향을 그래프로 정리하면 다음과 같습니다. 모든 화살표가 결국 Interface로 모입니다.

넉 달쯤 이 구조로 개발해 보니, 레이어 중 가장 중요한 것은 역시 Interface였습니다.
저희가 설계한 Interface에는 도메인 모델과 서버 응답을 디코딩하는 Codable 모델, 그리고 의존성의 프로토콜(TCA 진영의 swift-dependencies를 사용합니다)이 들어갑니다. 이 얇은 계약 계층이 존재하는 덕분에 다음이 가능해집니다.
Feature끼리 서로의 구현을 모른 채 협업합니다. A Feature가 B Feature의 화면을 띄워야 할 때, B의 Reducer를 public으로 노출하는 대신 Interface에 정의된 빌더로
UIViewController를 만들어 임베드합니다. 구현 교체가 자유롭습니다.
Testing 레이어가 가볍습니다. Testing은 Interface에만 의존합니다. 목 데이터를 만드는 데 실제 네트워크 코드가 필요 없습니다.
빌드 그래프가 끊어집니다. Interface는 변할 일이 적어서 구현을 고쳐도 의존하는 쪽의 재컴파일이 최소화됩니다.
이 구조는 AI와의 궁합도 좋습니다. Testing과 Example은 프로덕션에 포함되지 않는 타겟이라 배포 코드의 품질 기준이나 컨벤션에서 상대적으로 자유롭습니다. 그래서 케이스 셀렉터 화면이나 목 상태 주입 같은 데모용 코드는 AI에게 맡겨 손쉽게 작성합니다. Feature 본체는 여전히 사람이 꼼꼼히 리뷰하지만 이쪽만큼은 "빠르게 만들어 빠르게 확인하는 도구"로 취급합니다.
TMA가 바꾼 개발 방식: Example 앱과 scaffold
① Example 앱 — 본 앱을 켜지 않고 개발하기

Example 앱이 생기고 나서는 개발하는 방식이 달라졌습니다. Feature 작업을 할 때 이제 본 앱을 켜지 않습니다. 클린 빌드 기준 전체 앱은 6분이 걸리지만, Example 앱은 1분이면 끝납니다. 해당 Feature만 곧장 뜨고, 진입 화면에서 케이스를 골라 바로 원하는 상태로 들어갑니다. 로그인도, 탭 네비게이션도 없습니다. 첫 모듈에서 이 경험을 하고 나서는 이후의 Feature들도 모두 같은 구조로 만들어졌습니다.
협업의 결도 달라졌습니다. 예전에는 특정 화면 상태를 확인하려면 서버 개발자분께 목 데이터를 내려 달라고 요청하거나 테스트 계정을 그 상태로 만들어 두어야 했습니다. 지금은 Testing 레이어에 넣을 샘플 데이터를 AI로 만들어 주입하면 끝입니다. 빈 목록, 수백 개짜리 목록, 극단적으로 긴 제목, 로드 실패와 같이 서버에 부탁하기 애매한 엣지케이스도 쉽게 재현할 수 있습니다. 서버 API가 완전히 준비되기 전이라도 API 문서를 통해 Interface 계약만 합의되어 있으면 화면 개발을 먼저 시작할 수 있습니다.
② scaffold — 누가 만들어도 같은 모듈이 나오도록
모듈이 서너 개 쌓이자 다른 문제가 보였습니다. 이 TMA 구조를 손으로 만들면 매번 미묘하게 달라집니다. Testing 타겟의 의존성을 빼먹거나, Example 앱 설정이 모듈마다 다르거나 하는 식입니다.
구조의 일관성이 사람의 꼼꼼함에 달려 있으면 오래가지 못하니, 검증된 구조를 Tuist scaffold 기능을 이용해 템플릿으로 만들었습니다.
tuist scaffold framework --name NewFeature이 한 줄이면 Project.swift와 Interface / Testing / Feature / Tests / Example 타겟 구성이 한 번에 만들어집니다. 각 레이어에는 뼈대 소스(도메인 모델·Codable 모델·프로토콜 스텁, Mock, 구현, 케이스 셀렉터가 있는 데모 앱 등)까지 미리 채워집니다. 템플릿은 Stencil 파일로 저장소에 함께 커밋되어 있어서 구조 개선이 생기면 템플릿만 고쳐도 그다음 모듈부터 반영됩니다.
효과는 생성 속도보다 일관성에 있습니다. 신규 입사자든 다른 도메인 담당자든, 새 Feature를 시작하는 사람이 아키텍처 결정을 다시 내릴 필요가 없습니다. tuist scaffold 한 번이면 팀의 합의된 구조가 그대로 나오고 리뷰어도 "구조가 맞는가"를 따질 필요 없이 내용만 보면 됩니다.
TMA가 지켜낸 것: 프로덕션 바이너리에서 목 코드 격리하기
프리뷰와 Example 앱을 적극적으로 쓰다 보면 한 가지 문제가 따라옵니다. 프리뷰 코드와 목 데이터 코드는 유저들에게 배포되는 프로덕션 앱에 실려 나가면 안 됩니다.
저희는 두 겹으로 막았습니다. 첫 번째 겹은 익숙한 전처리문입니다. 프리뷰 블록을 #if DEBUG에 더해 Testing 모듈이 import 가능한 상태인지 점검하는 canImport(FeatureTesting)으로 감쌉니다.
#if DEBUG && canImport(FeatureTesting)
import FeatureTesting
#Preview("빈 목록 케이스") {
withDependencies {
$0.featureNetworkServiceProvider.findItemList = { _ in
ItemListPagination(items: [], nextCursor: nil)
}
} operation: {
FeatureListPage(store: ...)
}
}
#endifwithDependencies로 네트워크 의존성을 그 자리에서 오버라이드하니, "빈 목록", "미인증", "로드 실패" 같은 상태를 프리뷰 캔버스에 나란히 띄워 놓고 개발할 수 있습니다. 예전에는 서버 개발자분께 요청해서 응답을 받거나, 코드를 임시로 고쳐 확인하던 케이스들입니다.
두 번째 겹이 핵심인데, Tuist의 generation-time configuration입니다. canImport가 성립하려면 Feature 타겟이 Testing 타겟에 의존해야 합니다. 저희는 이 의존성 자체를 프로젝트 생성 시점의 환경 변수로 분기했습니다.
// Tuist/ProjectDescriptionHelpers
/// `TUIST_FOR_PREVIEW=true`로 `tuist generate`된 generation인지 여부
public let isPreviewGeneration: Bool = Environment.forPreview.getBoolean(default: false)
extension ProjectDescription.Product {
/// 프리뷰 generation이면 dynamic framework, 그 외 static framework.
/// SwiftUI Preview는 dynamic이어야 symbol resolve가 됩니다.
public static var staticFrameworkUnlessPreview: Self {
isPreviewGeneration ? .framework : .staticFramework
}
}// Projects/Feature/Project.swift
.target(
name: "Feature",
product: .staticFrameworkUnlessPreview,
dependencies: [
.target(name: "FeatureInterface"),
.Network,
// ...
] + (isPreviewGeneration ? [.target(name: "FeatureTesting")] : []),
)평소 개발과 배포 빌드에서는 TUIST_FOR_PREVIEW가 없으므로 Feature → Testing 의존성이 아예 존재하지 않습니다. canImport(FeatureTesting)이 false가 되어 프리뷰 블록 전체가 컴파일에서 빠지고 목 코드가 실수로라도 프로덕션에 링크될 경로가 원천 차단됩니다. 프리뷰를 쓰고 싶을 때만 TUIST_FOR_PREVIEW=true tuist generate로 워크스페이스를 다시 생성하면 의존성이 붙고 product type도 프리뷰가 요구하는 dynamic framework로 바뀝니다.
여기에서 product type이 함께 바뀌는 데에는 이유가 있습니다. 저희는 내부 모듈을 기본적으로 static framework로 링크합니다. dynamic framework가 많아지면 앱 실행 시 dyld가 각 프레임워크를 로드·바인딩하는 비용이 늘어나 사용자의 앱 런치 시간이 길어지기 때문입니다. 하지만 개발하는 데 사용하는 SwiftUI 프리뷰는 dynamic framework여야 작동합니다.
배포는 static, 개발 중 프리뷰는 dynamic. 서로 다른 두 요구를 하나의 코드베이스에서 만족시켜야 했지만, 프로젝트가 생성물인 Tuist에서는 어렵지 않았습니다. 같은 매니페스트에서 환경 변수 하나로 Mach-O 타입이 전혀 다른 워크스페이스를 뽑아낼 수 있어, 평소에는 static으로 개발·배포하다가 프리뷰가 필요한 순간에만 dynamic 구성으로 갈아끼우면 됩니다.

전처리문만 있었다면 목 코드 격리는 결국 사람이 지켜야 하는 규칙으로 남았을 것입니다. 의존성 자체를 끊어 두니 규칙이 아니라 구조가 되었고 어기는 것이 불가능해졌습니다.
모듈화가 만들어낸 바이너리 캐시
모듈 경계가 생기니 로컬에서 Tuist의 바이너리 캐시도 쓸 수 있게 되었습니다. 바이너리 캐시는 타겟을 소스에서 매번 컴파일하는 대신, 미리 빌드해 둔 .xcframework 바이너리로 통째로 갈아끼우는 기능입니다. 캐시가 적용된 타겟은 빌드 대상에서 아예 빠지므로, 클린 빌드를 하든 브랜치를 갈아타든 그 타겟의 컴파일 시간은 0이 됩니다. Xcode의 증분 빌드가 DerivedData가 날아가면 무력해지는 것과 달리, 바이너리 캐시는 소스가 그대로인 한 계속 유효합니다.
저희는 자주 변할 일 없는 하부 계층(Core·Shared 등)에 태그를 달아 두고 개발 시에는 이들을 바이너리로 대체합니다.

// Tuist.swift — dev 캐시 프로파일
"dev": .profile(.onlyExternal, and: [.tagged("cache-dev")])날마다 고치는 것은 Feature 코드인데, 단일 타겟 시절에는 그때마다 13년 치 코드 전체를 컴파일하며 개발했습니다. 지금은 인프라 계층이 통째로 캐시 히트로 끝나고 실제로 컴파일되는 것은 작업 중인 Feature와 App 조립부뿐입니다. 빌드가 빨라지는 것 외에 부수 효과도 있습니다. 캐시된 모듈은 Xcode 워크스페이스에 바이너리로 들어오므로 인덱싱 대상에서도 빠져, 저희와 같은 대형 프로젝트에서 체감되는 Xcode 자체의 반응 속도까지 빨라집니다. 모듈화가 없었다면 캐시할 단위 자체가 없었을 것입니다.
지금, 그리고 앞으로
현재 Feature 계층에는 연합게시판을 필두로 게시판, 포인트 시스템 등이 TMA 구조로 자리 잡았고 각자의 Example 앱, 프리뷰와 함께 개발되고 있습니다.
물론 아직 갈 길이 멉니다. App 타겟에는 이러한 모듈이나 TMA 구조로 옮기지 못한 코드가 꽤나 남아 있고 Objective-C 파일과 Storyboard도 여전히 잘 돌아가고 있습니다. 사실 이걸 한 번에 걷어내려 하지는 않습니다. 서비스는 지속해서 배포되어야 하고, 리팩터링을 이유로 기능 개발을 멈출 수는 없으니까요. 그래서 저희는 새 기능은 TMA 구조로 만들고, 레거시는 손대는 김에 조금씩 옮기는 보이스카우트 룰(Boy Scout Rule)을 모듈화에 적용하기로 했습니다. 레거시 화면을 고칠 일이 생기면 그 김에 리소스 호출을 모듈 API로 바꾸고 손대는 파일만큼만 경계 안으로 들여옵니다. 비록 지금 당장은 느릴지언정, 새 코드가 모듈에 쌓이고 레거시가 한 뼘씩 줄어드는 걸 지켜보다 보면, 언젠가 App 타겟이 Feature들의 조립만 남은 얇은 층이 되는 날도 올 것이라 믿습니다.
이 여정에서 얻은 건 세 가지입니다.
분리는 의존성 최말단부터: 아무것도 의존하지 않는 코드부터 떼어내야 컴파일을 유지한 채 전진할 수 있습니다. 리소스처럼 모두가 참조하는 것일수록 먼저 내려보내야 합니다.
Interface가 모듈화의 절반: 구현 없는 계약 계층이 있어야 Feature 간 결합 없이 협업하고 Testing과 Example이 가벼워집니다.
규율보다 구조: 목 코드 격리를 전처리문(사람의 규율)에만 맡기지 않고 generation-time 의존성 분기(구조)로 이중화하면, 실수의 여지가 원천적으로 사라집니다. scaffold 템플릿도 같은 철학입니다.
프리뷰 하나 쓰고 싶다는 욕망에서 시작한 일이 결과적으로는 접근 제어 정리, 계층 규칙, 계약 기반 협업, 목 코드 격리, 바이너리 캐시까지 이어졌습니다. 이제 새 기능을 시작할 때의 기본값은 정해져 있습니다. tuist scaffold로 TMA 구조를 만들고, Interface에 계약을 먼저 쓰고, 프리뷰를 띄워 놓고 개발합니다. 필요하면 Example앱을 빌드시켜 동작을 체크합니다. 에브리타임은 비록 15살이나 먹었지만, 새로 코드를 짤 때만큼은 갓 태어난 앱 다루듯 하고 있습니다.
오래된 코드베이스의 모듈화를 고민하는 분들이 있다면, 이 글이 부디 좋은 참고 자료가 되길 바랍니다.
-
Written by 김인환 | 플랫폼프로덕트팀 소프트웨어 엔지니어
탄탄한 구조가 유연한 변화를 만든다고 믿는 iOS 개발자입니다.