- 프로젝트명: Picke
- 스택: Swift 6, SwiftUI, TCA 1.25, Tuist 4
- 아키텍처: TCA + Clean Architecture 멀티모듈
- 배포 타겟: iOS 17.0+, iPhone 전용
- 네비게이션: TCAFlow
- 의존성 주입: WeaveDI 3.4.1
Projects/
├── App/ # 앱 타겟 (진입점, DI 조립)
│ ├── Sources/ # AppReducer, App Entry Point
│ ├── Resources/ # Assets, Info.plist
│ └── Tests/ # 앱 단 통합 테스트
├── Presentation/ # 화면 + ViewModel (TCA Feature)
│ └── Presentation/ # Feature Reducer + SwiftUI View
├── Domain/
│ ├── Entity/ # 도메인 엔티티 + Entity Protocol
│ ├── UseCase/ # 비즈니스 로직 구현
│ ├── DomainInterface/ # Repository / UseCase 인터페이스
│ └── DataInterface/ # Data 계층 인터페이스
├── Data/
│ ├── Model/ # DTO, API Response → Entity 변환
│ ├── Repository/ # Repository 구현체
│ ├── API/ # REST API Endpoint
│ └── Service/ # 데이터 처리 서비스
├── Network/
│ ├── Networking/ # HTTP 클라이언트 설정
│ ├── Foundations/ # 네트워크 기반 유틸리티 (Token, Header)
│ └── ThirdPartys/ # AsyncMoya, WeaveDI 등
└── Shared/
├── DesignSystem/ # 공통 UI 컴포넌트, 폰트, 색상
├── Shared/ # 공통 공유 모듈
└── Utill/ # 날짜, 문자열, 로깅 유틸리티
의존성 방향: Presentation → Domain ← Data, Network는 Data에서만 참조
// Core Architecture
ComposableArchitecture: 1.25.5 // TCA
Dependencies: 1.10.0 // TCA 의존성 관리
TCAFlow: 1.1.2 // @FlowCoordinator 기반 네비게이션
WeaveDI: 3.4.1 // 의존성 주입
IdentifiedCollections: 1.1.0+
// Networking
AsyncMoya: 1.1.8 // 비동기 네트워크 (Moya 래퍼)
Moya: 15.0.3
Alamofire: 5.11.1
ReactiveSwift: 6.7.0
RxSwift: 6.10.2
// Authentication
AppAuth-iOS: 2.0.0 // OAuth 2.0
GoogleSignIn-iOS: 9.1.0 // Google 소셜 로그인
// Firebase
firebase-ios-sdk: 12.12.0 // Crashlytics / Messaging
// Analytics / Ads
GoogleMobileAds: 13.3.0 // AdMob 광고
Mixpanel: 5.2.0 // 제품 분석
MixpanelSessionReplay: 1.4.0 // 세션 리플레이
// UI / Utility
SDWebImageSwiftUI: 3.1.4 // 이미지 비동기 로딩
Kingfisher: 8.2.0 // 이미지 로딩
LogMacro: 1.1.1 // 로깅 매크로프로젝트의 상세 가이드는 docs/agent/ 폴더에서 관리합니다.
- TCA 기본 구조 및 규칙
- Extension 패턴 활용법
- Action 처리 메서드 분리
- State Computed Properties
- Coordinator Extension 패턴
- SwiftUI 코드 구조화
- View Extension 패턴
- Computed Properties + @ViewBuilder 조합
- 조건부 렌더링 및 Skeleton 패턴
View body 는 최상위 레이아웃 (ZStack / VStack) 만 두고, 모든 하위 영역은 같은 파일의 extension View {} 안에 private func sectionName() -> some View (또는 private var sectionName: some View) 로 분리합니다.
LoginView / OnBoardingView 가 정착된 레퍼런스입니다.
// ✅ 올바른 패턴 — LoginView 와 동일하게 extension 분리
public struct OnBoardingView: View {
@Bindable var store: StoreOf<OnBoardingFeature>
public var body: some View {
ZStack {
Color.bgSubtle.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) {
topSection()
.frame(maxHeight: .infinity)
bottomSection()
.padding(.horizontal, 16)
.padding(.bottom, 40)
}
}
}
}
extension OnBoardingView {
private func topSection() -> some View {
TabView(selection: $store.currentIndex) { /* ... */ }
}
private func bottomSection() -> some View {
VStack(spacing: 24) {
OnBoardingPageIndicator(/* ... */)
CustomButton(/* ... */)
}
}
}
// ❌ 금지 — body 안에 모든 레이아웃을 inline 으로 작성하지 말 것
public var body: some View {
VStack {
TabView { /* ... */ }
VStack { /* indicator + button */ }
}
}규칙:
body는 호출자만, 실제 레이아웃은extension안으로- 메서드 이름은 의도가 드러나는 명사형 (
topSection,bottomSection,loginSNSButtonText,logoView) - 한 메서드 안에서 다시 큰 블록이 생기면 더 작게 쪼개기 (재귀 적용)
- 공통 컴포넌트는 별도 파일 (
Components/*.swift) 로 추출
분리한 sub-view 의 선언 형태는 자식 개수와 분기 유무 로만 정한다.
// ✅ 다중 자식을 감싸거나 if/else · switch 분기가 있으면 `@ViewBuilder` + 함수
@ViewBuilder
private func hotBattlesSection() -> some View {
VStack(alignment: .leading, spacing: 12) {
HomeSectionHeader(title: "지금 뜨는 배틀") { send(.seeMoreTapped(.hotBattles)) }
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(store.hotBattles) { HotBattleCardView(battle: $0) }
}
}
}
}
@ViewBuilder
private func thumbnail(url: URL?) -> some View {
if let url {
KFImage(url).resizable().scaledToFill()
} else {
Color.neutral200
}
}
// ✅ 단일 뷰만 반환하면 `private var` 형태
private var primaryButton: some View {
CustomButton(
action: { send(.primaryButtonTapped) },
title: "사전 투표하기",
config: CustomButtonConfig.primary(.large, height: 52),
isEnable: store.isPrimaryButtonEnabled
)
}
// ❌ 금지 — VStack 으로 자식 여러 개 감싸는데 var 만 쓰는 경우 (분기 / 동적 children 추가 시 깨짐)
private var section: some View {
VStack { ... } // → @ViewBuilder + func 으로 가야 안전
}규칙:
@ViewBuilder+private func: VStack/HStack/ZStack 등으로 자식 ≥ 2개 를 감싸거나if/switch/ForEach같은 분기·반복이 있을 때private var ...: some View: 단일 뷰 1개만 반환할 때 (단순 wrapping · CTA 버튼 · 단일 Image 등)- body 안에서 호출하는 sub-view 가 인자가 필요하면 함수, 없으면 var 가 우선 — 기준은 "자식 수 / 분기 유무" 가 먼저
- 레퍼런스:
HomeView.hotBattlesSection,PreVoteView.primaryButton,HeroCardView.thumbnail
// ✅ 디자인 시스템 토큰이 있는 경우 (16/14/12 등)
Text("시작하기")
.pretendardCustomFont(textStyle: .headingMedium)
// ✅ 토큰에 없는 임의 크기 (24, 15 등 Figma 스펙 그대로)
Text(page.title)
.pretendardFont(family: .SemiBold, size: 24)
Text(page.subtitle)
.pretendardFont(family: .Medium, size: 15)
// ❌ 금지 — 시스템 폰트 직접 사용
.font(.system(size: 24, weight: .semibold))// ✅ 컨텍스트 추론 가능한 위치는 점 단축형
Text(...)
.foregroundStyle(.neutral900)
Color.bgSubtle.edgesIgnoringSafeArea(.all)
// ❌ 금지 — 매번 Color 타입 명시
.foregroundStyle(Color.neutral900)SwiftUI.Color 의 정적 멤버로 디자인 토큰 (neutral50 … neutral900, primary50 … primary900, secondary50 …, beige50 … beige900, bgSubtle) 이 등록되어 있어 ShapeStyle 을 받는 모든 modifier 에서 점 단축형 사용:
// ✅ 점 단축형 — ShapeStyle 컨텍스트 모두 적용
.foregroundStyle(.neutral900)
.fill(.beige200)
.stroke(.beige700, lineWidth: 1)
.background(.beige50, in: RoundedRectangle(cornerRadius: 2))
.tint(.primary500)
// ❌ 금지 — Color 타입 명시
.foregroundStyle(Color.neutral900)
.fill(Color.beige200)
.stroke(Color.beige700, lineWidth: 1)
.background(Color.beige50, in: ...)예외:
Color가 View 자체로 쓰여 메서드 체이닝을 받는 경우는 그대로 둔다.Color.beige50.ignoresSafeArea() // ✅ View 로 쓰임 — Color 명시 필요 Color.neutral500.opacity(0.4) // ✅ View 로 쓰임 — Color 명시 필요
// ✅ 데이터 모델이 String 이 아닌 ImageAsset 을 보유
public struct Page: Equatable, Identifiable {
public let imageAsset: ImageAsset
}
// View 에서는 단축 init 만 사용
Image(asset: page.imageAsset)
.resizable()
.scaledToFit()
// ❌ 금지 — rawValue 문자열 / bundle 명시
Image(page.imageName, bundle: .module)
Image(ImageAsset.onboarding1.rawValue)이미지 케이스가 추가되면 반드시:
Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/<카테고리>/<name>.imageset/폴더 +Contents.json추가ImageAssetenum 에case <name>추가 (raw value = imageset 폴더명과 동일)tuist generate로 리소스 재인덱싱
TCA View 가 store 를 들고 있을 때는 항상 @Bindable 로 선언한다. 단순 표시뿐이라도 future-proof 하기 위해 동일.
// ✅ 올바른 패턴
public struct OnBoardingView: View {
@Bindable var store: StoreOf<OnBoardingFeature>
}
public struct MainTabView: View {
@Bindable private var store: StoreOf<MainTabCoordinator>
}
// ❌ 금지 — 그냥 let / var
public struct TabFeatureView: View {
let store: StoreOf<TabFeature> // ← @Bindable 누락
var store: StoreOf<HomeFeature> // ← 동일하게 누락
}근거:
$store.binding형태가 필요한 시점이 거의 반드시 옴 (TabView selection, TextField, NavigationDestination 등)@Bindable은 read-only 사용 시에도 비용이 없고, 후에 binding 이 추가될 때 시그니처 변경 없이 받음- LoginView / OnBoardingView / MainTabView / AuthCoordinatorView 모두 이 규칙 따름
가시성:
- 외부에서 store 를 주입받는 표면은
public또는 그대로 두고, - 그 외 내부에서만 쓸 store 는
private으로 가린다 (@Bindable private var store)
표시용 파생값은 View 가 아니라 State 의 computed property 로 정의해서 View 에서는 그대로 꺼내기만 한다.
// ✅ State 가 자기 자신을 설명
@ObservableState
public struct State: Equatable {
public var currentIndex: Int
public var isLastPage: Bool { currentIndex >= pageCount - 1 }
public var primaryButtonTitle: String { isLastPage ? "시작하기" : "다음" }
}
// View
CustomButton(
title: store.primaryButtonTitle,
...
)
// ❌ 금지 — View 안에서 store 상태를 다시 가공
private var primaryButtonTitle: String {
store.isLastPage ? "시작하기" : "다음"
}@ObservableState 의 State 는 프로퍼티마다 inline default 값을 박고, public init() {} 만 외부에 노출한다. 긴 파라미터 리스트의 public init(x:, y:, ...) 는 쓰지 않는다.
// ✅ 올바른 패턴 — 외부는 .init() 만 호출, 변경은 reducer 내부에서
@ObservableState
public struct State: Equatable {
public var isLoading: Bool = false
public var newNotice: Bool = false
public var heroes: [HeroBattle] = []
public var heroIndex: Int = 0
public var hotBattles: [HotBattle] = []
public var currentHero: HeroBattle? { heroes[safe: heroIndex] }
public init() {}
}
// ❌ 금지 — 모든 필드를 init 파라미터로 펼침
public init(
isLoading: Bool = false,
newNotice: Bool = false,
heroes: [HeroBattle] = [],
heroIndex: Int = 0,
hotBattles: [HotBattle] = []
) {
self.isLoading = isLoading
// …
}근거:
- 호출처는
Feature.State()한 줄이면 충분 — 사용 시점에 노이즈 없음 - 초기값은 한 곳에서만 정의 — 프로퍼티 추가/제거 시 init 도 따라 고칠 일 없음
- 테스트/프리뷰에서 다른 값을 넣고 싶으면
var state = Feature.State(); state.heroes = ...로 직접 mutate - Shared / Presents / AppStorage 도 동일하게 inline 으로 선언 (
@Shared(...) var foo: Foo = .empty)
레퍼런스: HomeFeature.State, attendance ProfileFeature.State
do/catch + 별도 Loaded / Failed 액션 분리하지 말고, Result 로 감싸서 단일 xxxResponse(Result<Success, AuthError>) Inner 액션으로 보낸다. State 캡쳐는 [키 = state.xxx] 형태.
// ✅ 올바른 패턴
public enum InnerAction: Equatable {
case homeResponse(Result<HomeBundle, AuthError>)
}
case .fetchHome:
state.isLoading = true
return .run { [repository = homeRepository] send in
let result = await Result {
try await repository.fetchHome()
}
.mapError(AuthError.from)
return await send(.inner(.homeResponse(result)))
}
.cancellable(id: CancelID.fetchHome, cancelInFlight: true)
// 핸들러에서 한 자리에서 success/failure 분기
case let .homeResponse(result):
state.isLoading = false
switch result {
case let .success(bundle): /* state 갱신 */
case let .failure(error): Log.error("\(error.localizedDescription)")
}
return .none
// ❌ 금지 — do/catch 로 두 액션을 발사
return .run { send in
do {
let bundle = try await repository.fetchHome()
await send(.inner(.homeLoaded(bundle))) // ← 분리됨
} catch {
await send(.inner(.homeFailed(error.localizedDescription)))
}
}규칙:
- 성공/실패 상태 머지 → 하나의
xxxResponse(Result<Success, AuthError>)케이스 - 에러 타입은
AuthError로 통일하고AuthError.from(_:)로 변환 (이미 Entity 에 정의됨) - 캡쳐는
[repository = self.repository, userSession = state.userSession]처럼 명시 .cancellable(id: CancelID.xxx, cancelInFlight: true)로 중복 호출 방지- 레퍼런스:
AuthUseCaseImpl.withDraw/HomeFeature.fetchHome
Repository 구현체의 MoyaProvider 는 let 으로 직접 선언하고, init 기본값으로 .default / .authorized 팩토리를 그대로 사용한다. Optional + nil 합치기나 MoyaProviderPool 인다이렉션 금지.
// ✅ 올바른 패턴 — 단일 provider (인증 필요)
public final class HomeRepositoryImpl: HomeInterface, @unchecked Sendable {
private let provider: MoyaProvider<HomeService>
public init(
provider: MoyaProvider<HomeService> = MoyaProvider<HomeService>.authorized
) {
self.provider = provider
}
}
// ✅ 올바른 패턴 — default + authorized 두 개 필요 (로그인/로그아웃 분리)
public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable {
private let provider: MoyaProvider<AuthService>
private let authProvider: MoyaProvider<AuthService>
public init(
provider: MoyaProvider<AuthService> = MoyaProvider<AuthService>.default,
authProvider: MoyaProvider<AuthService> = MoyaProvider<AuthService>.authorized
) {
self.provider = provider
self.authProvider = authProvider
}
}
// ❌ 금지 — Optional + nil 합치기 + Pool 인다이렉션
public init(
provider: MoyaProvider<AuthService>? = nil,
authProvider: MoyaProvider<AuthService>? = nil
) {
self.provider = provider ?? MoyaProviderPool.shared.defaultProvider(for: AuthService.self)
self.authProvider = authProvider ?? MoyaProviderPool.shared.authorizedProvider(for: AuthService.self)
}규칙:
- 토큰 인증이 필요한 API →
.authorized(OptimizedSessionManager 인터셉터 부착) - 로그인/회원가입 같이 헤더 없는 API →
.default(로그 플러그인만) - 테스트/프리뷰는 Mock provider 를 init 으로 그대로 주입 —
MockProvider변수 따로 둘 필요 없음 MoyaProviderPool은 더 이상 RepositoryImpl 에서 직접 호출하지 않는다 (필요 시 풀 자체에서 내부적으로 캐시 처리)- 레퍼런스:
HomeRepositoryImpl,AuthRepositoryImpl, AsyncMoyaMoyaProvider+Factory.default,Extension+MoyaProvider+Auth.authorized
TCAFlow 기반 Coordinator 의 XScreen 정의는 반드시 별도 extension 의 @Reducer public enum 형태를 유지한다. 마이그레이션 / 리팩터링 / 자동 포맷터 어떤 이유로도 이 구조를 본체 안으로 끌어들이거나 enum을 struct/Reducer 로 바꾸지 말 것.
// ✅ 올바른 패턴 — extension + @Reducer + public enum + State: Equatable 보조 extension
@FlowCoordinator(screen: "HomeScreen", navigation: true)
public struct HomeCoordinator {
// … State / Action / handleRoute …
}
// swiftformat:disable extensionAccessControl
extension HomeCoordinator {
@Reducer
public enum HomeScreen {
case home(HomeFeature)
case preVote(PreVoteFeature)
case chatRoom(ChatRoomFeature)
}
}
// swiftformat:enable extensionAccessControl
extension HomeCoordinator.HomeScreen.State: Equatable {}
// ❌ 금지 — Coordinator 본체 안에 enum 을 인라인 선언
public struct HomeCoordinator {
@Reducer
public enum HomeScreen { ... } // 안 됨 (매크로 인식 / Route 추론 깨짐)
}
// ❌ 금지 — 자동 포맷터가 `public extension { enum }` 으로 바꾸도록 방치
public extension HomeCoordinator {
@Reducer
enum HomeScreen { ... } // @Reducer 매크로 확장이 internal 로 생성 → "must be declared public" 에러
}규칙:
XScreen은@Reducer public enum. struct 로 바꾸지 말 것- 본체와 분리된 별도 extension 안에 선언
- swiftformat 자동 변환 차단: 해당 블록 앞뒤로
// swiftformat:disable extensionAccessControl/// swiftformat:enable extensionAccessControl주석 페어 필수.- 포맷터가
public extension X { enum XScreen }으로 끌어올리면@Reducer매크로가 만드는State/Action/body가 internal 로 생성되어enum 'State' must be declared public because it matches a requirement in public protocol 'CaseReducer'빌드 에러가 난다.
- 포맷터가
extension Coordinator.XScreen.State: Equatable {}보조 conformance 도 같이 유지 (Route diff 비교 필요)- 라우터 핸들러 (
routerAction) 안에서state.routes.push/pop/goBack직접 호출은 OK, 단dismiss/submit같이 반복되는 종료 액션은.send(.view(.backAction))으로 일원화 - 레퍼런스:
HomeCoordinator,AuthCoordinator,MainTabCoordinator
🌐 switch 기반 computed property — 모든 case 에 return 명시 유지 (DomainType / BaseTargetType / DependencyKey 공통)
switch self { ... } 만으로 값을 돌려주는 computed property 는 모든 case 에 return 키워드를 명시한다. 적용 범위:
DomainType.url—PieckeDomain같은 도메인 prefix 매핑BaseTargetType구현체의urlPath/method/parameters/task/sampleData— 모든 Moya TargetType switchDependencyKey.liveValue/testValue—UnifiedDI.resolve(...) ?? Default...()패턴- 그 외 단일 표현식 switch 를 본문으로 갖는 computed property 전부
자동 포맷터의 redundantReturn 룰이 single-expression switch 에서 return 을 떼어내려 하지만, 새 case 추가 시 일부만 implicit / 일부는 explicit 으로 혼합되는 상태가 가장 깨지기 쉽다. 새 case 추가 후엔 항상 기존 case 까지 같이 return 으로 정렬할 것.
// ✅ DomainType — 모든 case return
extension PieckeDomain: DomainType {
public var url: String {
switch self {
case .auth: return "api/v1/auth/"
case .profile: return "api/v1/me/"
case .home: return "api/v1/home"
case .poll: return "api/v1/poll"
case .battle: return "api/v1/battles/"
}
}
}
// ✅ BaseTargetType — urlPath / method / parameters 모두 return 명시
extension BattleService: BaseTargetType {
public var urlPath: String {
switch self {
case let .preVote(battleId, _):
return BattleAPI.preVote(battleId: battleId).description
case let .scenario(battleId):
return BattleAPI.scenario(battleId: battleId).description
}
}
public var method: Moya.Method {
switch self {
case .preVote: return .post
case .scenario: return .get
}
}
public var parameters: [String: Any]? {
switch self {
case let .preVote(_, body): return body.toDictionary
case .scenario: return nil
}
}
}
// ✅ DependencyKey — liveValue / testValue 도 return 명시
public struct BattleRepositoryDependency: DependencyKey {
public static var liveValue: BattleInterface {
return UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl()
}
public static var testValue: BattleInterface {
return UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl()
}
}
// ❌ 금지 — 포맷터가 떼어낸 implicit return 혼합
public var method: Moya.Method {
switch self {
case .preVote: .post // ← 안 됨
case .scenario: return .get // ← 혼합 상태
}
}규칙:
- 새 case 추가 후 포맷터가 기존 case 의
return을 떼어냈다면 PR 전에 직접 되돌려서 일관성 유지 - 새 도메인 case (
.battle등) / 새 서비스 case (.scenario등) / 새 DI 키 추가 시 모두 동일하게return ...형태로 작성 - 포맷터가 반복적으로 깨면 해당 파일 또는 함수 블록에
// swiftformat:disable redundantReturn디렉티브 페어 추가 검토 - 레퍼런스:
PieckeDomain,BattleService,AuthService,BattleRepositoryDependency,HomeRepositoryDependency
- Swift 스타일 가이드
- 에러 처리 패턴
- TCA 에러 처리 규칙
- 테스트 패턴
- CustomAlert (TCA 기반 커스텀 알림)
- Toast 시스템 (전역 메시지)
- CustomModal (드래그 지원 모달)
- TCA Presentation 패턴 규칙
- WeaveDI 3.4.1 패턴
- AppDIManager 구조
- TCA Dependencies 통합
- Interface 기반 등록 규칙
- 성능 최적화 통합 시스템
- 서브에이전트 호출 규칙
- TCA/SwiftUI 성능 문제 해결
- 빌드 오류 해결 프로세스
- 브랜치 전략
- 커밋 메시지 컨벤션
- Pull Request 규칙
- 코드 리뷰 가이드라인
- 에이전트가 작성하는 모든 git commit 메시지는 한국어로 작성
- 커밋 제목/본문 모두 한글 기준으로 작성
- 영문 타입 prefix(
feat,fix,refactor,test,chore)는 사용 가능하지만, 설명 문구는 반드시 한국어 - 커밋 제목에는 관련 GitHub 이슈 번호를 포함해
chore: #1 SPM 의존성 확장처럼 이슈와 바로 매칭되게 작성 - 커밋 본문은 장황한 문장보다
- 변경 요약bullet 형식을 우선 사용하고, 변경 파일/모듈별 핵심 의도와 후속 주의사항을 짧게 정리 - 별도 요청이 없는 한 영어 커밋 메시지는 사용하지 않음
- 커밋 메시지에
Co-Authored-By: Claude ...등의 자동 서명 라인을 절대 추가하지 않음 - OMX 훅 검증을 위해 커밋 명령에는
Co-authored-by: OmX <[email protected]>trailer를 포함하되, 최종 커밋 메시지에서는 제거
- @FlowCoordinator 패턴
- 기본 네비게이션 동작 (Push, Present, Dismiss)
- 화면 간 통신 패턴
- 딥 링크 처리
- TuistTool / Make 명령어
- Xcode 빌드 설정 (Dev, Stage, Prod, Release)
- Tuist 사용 규칙
- 테스트 패턴
- 메인:
Picke.xcworkspace - Tuist 생성 결과:
Projects/App/Picke.xcodeproj
# 의존성 설치 + 프로젝트 생성 (clean → install → generate)
./tuisttool build
# 의존성만 재설치
./tuisttool install
# 캐시 전체 클린 후 재생성
./tuisttool reset
# 테스트 실행
tuist test
# 의존성 그래프 확인
tuist graph --format pdf --path ./graph.pdfDev.xcconfig— 개발 환경Stage.xcconfig— 스테이징Prod.xcconfig— 프로덕션Release.xcconfig— 릴리즈 빌드 공통Shared.xcconfig— 모든 환경 공통 설정
Tokens Studio for Figma 가 export 한 Mode 1.tokens.json을 Swift 토큰으로 변환합니다.
단일 소스
- 토큰 JSON 은
SWYP-Find/design-tokens레포(public)가 단일 소스 - Picke-iOS 의
Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json은 워크플로우 실행 시에만 다운로드되는 임시 파일이며 git 에 추적되지 않음 (.gitignore처리)
자동 생성 출력 (
Sources/Color/ShapeStyle+.swift— 색 토큰 (.primary500,.bgDefault,.borderError등)Sources/Extension/CGFloat/CGFloat+Radius+.swift— radius (.none/.default/.full)Sources/Extension/CGFloat/CGFloat+Spacing+.swift— spacing (.s0~.s96)Sources/UI/Token/ComponentToken.swift— 컴포넌트 토큰 (ComponentToken.Button.Primary.Background.default등)
디자이너 핸드오프 흐름 (자동)
- 디자이너가 Tokens Studio →
Mode 1.tokens.jsonexport SWYP-Find/design-tokens의main브랜치에 push- (자동)
notify-ios.yml→repository_dispatch(design-tokens-updated)발사 - (자동) Picke-iOS
sync-design-tokens.yml실행 → raw URL 로 JSON 다운로드 →swift Tools/TokenGenerator.swift→ 4개 출력 파일을develop에 직접 commit + push
수동 트리거가 필요할 때:
gh workflow run sync-design-tokens.yml --repo SWYP-Find/Picke-iOSComponent 토큰 해석 우선순위 (TokenGenerator 내부)
"{Colors.brand.primary.500}"같은 string alias →.primary500- inline hex +
$extensions.com.figma.aliasData.targetVariableName→ 해당 변수명이 우리 토큰셋에 있으면 그쪽으로 - inline hex가 brand/semantic 변수의 hex와 일치하면 그 변수로
- 위 셋 다 실패 시
.init(hex: "...")inline
Projects/Shared/DesignSystem/Sources/
├── Color/ # 색 토큰 (auto)
├── CustomFont/ # Pretendard 폰트 정의
├── Image/ # ImageAsset
├── Extension/
│ ├── CGFloat/ # radius / spacing (auto)
│ ├── Color/ # Color/UIColor hex 초기화 등
│ ├── Image/
│ └── ScreenSize/
└── UI/
├── Button/ # CTA 버튼 컴포넌트
├── Navigaion/ # UINavigationController gesture 확장
└── Token/ # 컴포넌트 토큰 (auto)
- 색·radius는
ComponentToken.*또는 brand/semantic 토큰 참조. hex 리터럴(.init(hex: "...")) 직접 사용 금지 - CTA 버튼은 두 API 제공 (병행 유지):
CustomButton(action:title:config:isEnable:trailingIcon:)— Config 기반, 기존 호출처 호환Button { } .ctaButtonStyle(.primary, size: .large, icon: nil)—ButtonStyle기반
- variant × size 확장 시
CTAButtonStyle.swift의 enum에 케이스 추가 →ComponentToken.Button.*을 통해 색 분기 - pressed 상태는
configuration.isPressed로 토큰의.Background.pressed색을 사용 (opacity 변경 X)
Project.swift의 sources: ["Sources/**"] glob이 새 파일을 자동 픽업하지만, xcodeproj 동기화는 별도:
tuist generate --no-open --path Projects/Shared/DesignSystem재생성 전 SourceKit 에러가 떠도 실제 빌드는 정상일 수 있으니, 항상 xcodebuild로 실 빌드 확인할 것.
@test-auto-pr-agent— Swift Testing 기반 완전 자동 테스트 생성- 도메인별 테스트 코드 자동 생성 (Entity / UseCase / Repository)
- Swift Testing 프레임워크 사용 (XCTest 대신)
- TCA TestStore, WeaveDI Mock 자동 설정
- 테스트 실행 → 실패 시 자동 수정 → 성공까지 반복
@ios-performance-optimizer— PFW 철학 통합 자동화 시스템 (v4.0)@ios-performance-pfw— Point-Free Workshop 전문@swiftui-uikit-interop— SwiftUI ↔ UIKit 상호 운용성 전문@swift-concurrency— Swift 6 Concurrency 및 async/await 전문
- 출처: AvdLee/SwiftUI-Agent-Skill (Agent Skills 오픈 포맷)
- 로컬 설치 위치
- Claude Code:
~/.claude/plugins/SwiftUI-Agent-Skill/ - Codex:
~/.codex/skills/swiftui-expert-skill/ - Cursor 도 동일 폴더를
Plugins가이드대로 등록하면 됨
- Claude Code:
- 재설치 / 업데이트
rm -rf ~/.claude/plugins/SwiftUI-Agent-Skill ~/.codex/skills/swiftui-expert-skill git clone https://github.com/AvdLee/SwiftUI-Agent-Skill.git ~/.claude/plugins/SwiftUI-Agent-Skill cp -R ~/.claude/plugins/SwiftUI-Agent-Skill/swiftui-expert-skill ~/.codex/skills/swiftui-expert-skill
- 언제 호출: SwiftUI 상태관리(
@Observable/ 프로퍼티 래퍼 선택), 뷰 컴포지션, 리스트·내비게이션·시트, Swift Charts, 애니메이션, macOS multi-window, iOS 26+ Liquid Glass, 접근성, Instruments 트레이스 분석. - 호출 방법: 프롬프트에 "swiftui-expert skill 을 사용해 ..." 형태로 지시하거나,
.trace경로/녹화 요청처럼 트리거 키워드가 들어오면 자동 활성화.
다음 키워드 언급 시 자동으로 성능 최적화 스킬 호출:
ifCaseLet,TCA,Effect,메모리 누수,성능,최적화SwiftUI,렌더링,빌드 시간,TCAFlow,WeaveDICannot infer,Extensions must not,Type annotation missing빌드 오류,컴파일 에러,SourceKit error
이 문서는 Picke iOS 프로젝트의 아키텍처 가이드라인입니다. 새로운 기능 개발이나 코드 리뷰 시 이 가이드와 세부 문서들을 참고하여 일관성 있는 코드를 작성해주세요.