[리팩토링] 07. SwiftDataClient 리팩토링 - 중복 코드 제거

2026. 2. 12. 17:02·탭탭 - TapTap/리팩토링
728x90

어.. 이거 리팩토링 해야겠는데?

탭탭의 전반적인 리팩토링을 진행하면서 프로젝트를 전반적으로 되돌아 보게 되었는데요. 그 과정에서 가장 눈에 띈 부분은 SwiftDataClient 부분이었습니다. (누가봐도 개선이 필요한 코드)

 

물론 기능은 잘 동작하고 있었지만, 몇 가지 문제가 있었습니다.

 

1. 중복 코드가 많다.

fetchLinks: {
    let descriptor = FetchDescriptor<ArticleItem>()
    return try modelContext.fetch(descriptor)
},

fetchLink: { id in
    let descriptor = FetchDescriptor<ArticleItem>(predicate: #Predicate { $0.id == id })
    return try modelContext.fetch(descriptor).first
},

searchLinks: { query in
    let predicate = #Predicate<ArticleItem> { $0.title.contains(query) }
    let descriptor = FetchDescriptor<ArticleItem>(predicate: predicate)
    return try modelContext.fetch(descriptor)
},

fetchRecentLinks: {
    var descriptor = FetchDescriptor<ArticleItem>(
        sortBy: [SortDescriptor(\.lastViewedDate, order: .reverse)]
    )
    descriptor.fetchLimit = 6
    return try modelContext.fetch(descriptor)
},

fetchCategories: {
    let descriptor = FetchDescriptor<CategoryItem>()
    return try modelContext.fetch(descriptor)
}

fetch 관련 메소드들을 확인해보면, 패턴이 거의 동일한 중복 코드임을 확인할 수 있습니다.

 

2. 정렬 기준이 일관되지 않았다.

여러 개의 fetch 메소드가 존재하지만, 일부는 정렬이 적용되어 있고, 일부는 정렬이 적용되어 있지 않습니다. 

 

3. 에러 처리의 일관성이 부족하다.

어떤 메소드는 NSError를 반환하는 방식으로 에러를 전달했고, 어떤 메소드는 별도의 에러 전달 없이 return으로 흐름을 종료하고 있습니다.

 

4.  SwiftData 관련 Dependency 메소드가 Dependency가 아닌 Feature에 직접 구현된 코드들이 있다.

일부 로직은 Dependency 레이어에 위치해야 함에도 불구하고, Feature 내부에 직접 구현되어 있습니다. 

 

5. SwiftClient에 모든 메소드들이 구현되어 있어 크기가 비대하다.

 

위와 같은 이유로 SwiftDataClient 리팩토링을 진행하였습니다.


오.. 생각보다 볼륨이 큰데?

네...! 실제로 리팩토링을 시작해보니, 단순히 구조만 정리하면 되는 문제가 아니였습니다. SwiftDataClient는 여러 Feature에서 공통으로 사용되고 있었기 때문에, 변경이 가져올 영향 범위를 개발 팀과 함께 고민해야 했습니다. 또한 일부 코드는 이해가 부족하여 구현 의도와 맥락을 충분히 이해하는 과정이 필요했습니다.

 

한번에 리팩토링을 진행하기보다는, 점진적으로 개선해 나가는 방향이 더 안전하다고 판단했습니다.

그래서 이번 리팩토링은 한 번에 큰 변화를 주기보다는, 작은 단위로 나누어 단계적으로 진행하기로 결정했습니다.

 

이번 글에서는 SwiftDataClient 내부에 존재하던 중복 코드 제거에 대해 이야기해보려고 합니다.


중복 코드 제거

SwiftDataClient를 확인해 보면, fetch 관련 코드가 중복되는 패턴이 많았습니다. 구현은 조금씩 달랐지만, 내부 흐름은 거의 동일했습니다.

 

1. FetchDescriptor<T> 생성

2. predicate, sortDescriptors, fetchLimit 설정

3. try modelContext.fetch(descriptor) 호출

4. 첫 번째 아이템만 반환하거나, 배열 그대로 반환

 

즉, 모델 타입만 다를 뿐 동일한 로직이 계속 복사되어 사용되고 있는 구조였습니다.

 

이런 형태의 중복은 단순히 코드가 길어지는 문제를 넘어, 수정이 필요할 때 여러 메소드를 동시에 변경해야 하는 리스크가 있습니다. 이러한 반복되는 코드를 제거하는 가장 적절한 방법은 제네릭을 활용해 공통 로직을 추상화하는 것이라고 판단했습니다.

extension ModelContext {
    func fetchAll<T: PersistentModel>(
        _ type: T.Type,
        predicate: Predicate<T>? = nil,
        sortDescriptors: [SortDescriptor<T>]? = nil,
        fetchLimit: Int? = nil
    ) throws -> [T] {
        var descriptor = FetchDescriptor<T>()
        if let predicate = predicate { descriptor.predicate = predicate }
        if let sortDescriptors = sortDescriptors { descriptor.sortBy = sortDescriptors }
        if let fetchLimit = fetchLimit { descriptor.fetchLimit = fetchLimit }
        return try fetch(descriptor)
    }

    func fetchOne<T: PersistentModel>(
        _ type: T.Type,
        predicate: Predicate<T>
    ) throws -> T? {
        try fetchAll(type, predicate: predicate).first
    }
}

 위 코드와 같이 헬퍼 메소드를 구현하여, FetchDescriptor 생성과 설정, 그리고 fetch 호출까지의 과정을 하나의 공통 로직으로 묶었습니다.

// 수정 전
fetchLinks: {
  let descriptor = FetchDescriptor<ArticleItem>()
  return try modelContext.fetch(descriptor)
},

fetchCategories: {
  let descriptor = FetchDescriptor<CategoryItem>()
  return try modelContext.fetch(descriptor)
}

// 수정 후
fetchLinks: {
  return try context.fetchAll(ArticleItem.self)
},

fetchCategories: {
  try context.fetchAll(CategoryItem.self)
}

 이제 각 도메인별 fetch 메소드는 무엇을 가져올지에만 집중하면 됩니다. FetchDescriptor를 어떻게 구성하는지에 대한 세부 구현은 더 이상 반복해서 작성할 필요가 없어졌습니다. but! 위 코드를 보면 메소드 내부 흐름 구현에 대한 중복은 해결했지만, 한 가지 흥미로운 지점이 있습니다. (무엇이냐면 ~) 두 메소드는 반환 타입만 다를 뿐, 결국 내부 동작은 완전히 동일한 메소드라는 점입니다. 이 부분은 일단 넘어가고 아래에서 더 자세히 이야기 해볼게요. 


헬퍼 메소드를 ModelContext 내부에 구현한 이유 (feat. extension)

중복 로직을 정리하면서 정말...정말 많이 고민했던 부분은 "헬퍼를 어디에 위치시킬 것인가?"였습니다. 제가 생각한 선택지는 두 가지였습니다.

 

1. 별도의 SwiftDataHelper 구조체를 만든다.

2. ModelContext를 extension으로 확장한다.

 

겉보기에는 단순한 선택처럼 보이지만, 사용성, 책임 분리, 추상화 수준, 테스팅 방식 등 여러 요소가 얽혀 있는 (나름 중요한)결정이었습니다.


별도 Helper 구조체 

처음 리팩토링을 시작했을 때는 헬퍼를 별도의 구조체(SwiftDataHelper)로 분리하는 방향으로 설계를 진행했습니다. 헬퍼를 독립된 타입으로 두면 역할이 더 명확해 보였고, ModelContext를 직접 확장하는 것보다 구조적으로 더 깔끔해 보인다는 느낌도 있었습니다. 

예를 들면 아래와 같은 형태였죠.

let context = ModelContext(...)
let helper = SwiftDataHelper(context: context)
let links = try helper.fetchAll(
  ArticleItem.self,
  sortDescriptors: [SortDescriptor(\.createAt, order: .reverse)]
)

구조적으로는 나쁘지 않다고 생각합니다. 오히려 "SwifftData와 관련된 공통 로직을 한 곳에 모은다"는 점에서는 합리적인 선택처럼 보였습니다.

 

이 방식의 장점은 역할이 명확하다는 점과 SwiftData 관련 공통 로직을 하나의 타입에 모을 수 있고, 테스트 시 Mocking 구조를 설계하기 수월하다는 장점이 있습니다.  하지만 실제 사용 코드를 계속 작성해보면서 생각이 조금 바뀌기 시작했습니다.

 

1. 매번 SwiftDataHelper를 생성해야 하는 한 단계가 추가됨

2. SwiftData의 기본 사용 흐름과 다소 분리된 느낌

3. 결국 모든 동작이 ModelContext 기반인데, 중간 레이어가 하나 더 생기는 구조


ModelContext 확장 

let context = ModelContext(...)
let links = try context.fetchAll(
  ArticleItem.self,
  sortDescriptors: [SortDescriptor(\.createAt, order: .reverse)]
)

 

이 방식은 훨씬 자연스럽습니다. 

1. SwiftData의 기본 API 흐름을 그대로 확장하는 형태이기 때문에, SwiftData의 기본 사용 흐름과 자연스럽게 이어집니다.

2. 불필요한 객체 생성이 없습니다. 

3. 사용 코드가 더 간결하고 직관적이다.

 

무엇보다 SwiftData의 중심은 결국 ModelContext 입니다. SwiftData의 모든 작업은 ModelContext를 기반으로 이루어지고, 데이터 생성, 수정, 삭제, 읽기까지 모든 동작은 ModelContext로부터 시작합니다. 그렇다면 헬퍼 메소드 역시 ModelContext의 확장 기능으로 보는 것이 더 일관된 설계라고 판단했습니다.


테스팅 방식 관점

별도 Helper 구조체를 구현할 때 효율적인 테스팅을 위해 프로토콜 기반으로 구현했습니다. 이렇게 프로토콜 기반으로 구현 된 Helper 구조체는 ModelContext 확장 방식보다 테스트 측면에서 유리합니다.

 

1. 테스트용 Helper를 따로 구현할 수 있다.

2. 실제 SwiftData를 사용하지 않고도 테스트가 가능하다.

3. 레포지토리 단위 테스트에서 Mock으로 쉽게 교체 가능하다.

즉, 테스팅 관점에서는 매우 이상적인 구조입니다.

 

하지만, 우리 프로젝트 맥락에서는..? 

탭탭은 TCA 기반으로 구성되어 있습니다. 이 구조에서 테스트의 핵심은 Repository 단위 테스트 보다는 Action -> Effect -> State 흐름 검증이 더 중요합니다. TCA에서는 의존성을 Dependency로 추상화하고, Feature 테스트는 TestStore를 통해 이 의존성을 교체하여 진행합니다. 즉, 이미 한 단계의 추상화가 존재합니다. 여기에 또 하나의 Helper 레이어를 추가하는 것이 과연 실제 테스트 가치에 비해 필요한 복잡도인지 고민하게 되었습니다.

 

SwiftData의 In-Memory 지원

또 하나의 중요한 요소는 SwiftData가 inMemoryContainer를 지원한다는 점이었습니다. 이를 활용하면 실제 DB 동작을 그대로 테스트할 수 있고, Mock 없어도 충분히 빠르고 안정적인 테스트 환경을 구성할 수 있습니다. 굳이 SwiftData를 완전히 추상화하지 않아도 테스트 가능성이 이미 확보되어있는 셈이죠.


그래서 결국 ModelContext 확장 방식을 선택한 이유는?

이야기가 조금 길어졌네요 하하하... 그래서 왜 ModelContext 확장 방식을 선택했냐면요 ~

 

1. 중복 코드는 제네릭 헬퍼로 충분히 해결 가능하다.

2. SwiftData 작업의 중심은 ModelContext이며, 모든 동작이 여기서 시작된다.

3. ModelContext 확장은 SwiftData 기본 사용 흐름을 자연스럽게 확장하는 방식

4. 별도 Helper 구조체가 주는 테스트 유연성은 있지만, TCA Dependency 추상화 및 SwiftData in-memory로 충분한 테스팅이 가능하다.

5. 별도 Helper 레이어는 우리 프로젝트 맥락에서는 과도한 추상화라고 판단


메소드 오버로딩 VS 옵셔널 매개변수

이 부분도 정말 많이 고민했던 부분이었습니다. (고민이 참 많네요 하하)  전체 데이터를 fetch할 때 고려해야 할 경우의 수가 생각보다 많았습니다.

 

1. fetchAll
2. [sort]fetchAll

3. [predicate] fetchAll

4. [predicate + sort] fetchAll

5. [limit] fetchAll

6. [limit + sort] fetchAll

7. [limit + predicate] fetchAll

8. [limit + sort + predicate] fetchAll

이처럼 무려 8가지의 메소드를 각각 구현해야 하는 상황이 생깁니다.


메소드 오버로딩

처음에는 각 경우에 대해 메소드를 구현하여 오버로딩 하는 방법으로 구현하였습니다.

fetchAll 메소드탑 아름답다

물론 메소드 오버로딩은 각 경우가 명확하게 구분된다는 장점이 있습니다. 하지만 새로운 경우의 수 추가될 때마다 메소드를 하나씩 더 만들어야 하므로, 유지보수가 점점 어려워진다는 매우매우매우 치명적인 단점이 존재합니다.

 

+ 개인적으로 리팩토링과 유지보수를 진행하면서, 이러한 '유지보수의 어려움'이라는 단점은 매우매우매우매우 치명적인 단점이라고 생각해요. 이 단점 하나가 다른 장점을 완전히 상쇄한다고 생각해요...


옵셔널 매개변수

사실 정말 간단한 방법이 있어요. 

func fetchAll<T: PersistentModel>(
  _ type: T.Type,
  predicate: Predicate<T>? = nil,
  sortDescriptors: [SortDescriptor<T>]? = nil,
  fetchLimit: Int? = nil
) throws -> [T]

이렇게 매개변수를 모두 옵셔널로 처리하면 한 가지 메소드로 모든 경우를 커버할 수 있어 코드가 훨씬 간결해지고 유지보수도 쉬워집니다. 결과적으로, 옵셔널 매개변수 방식이 다양한 경우의 수를 효과적으로 관리하는 데 더 적합하다고 판단했고, 실제 리팩토링도 옵셔널 매개변수로 진행하였습니다.


메소드 이름 통일

기존 코드에는 add와 create, edit와 update 등 같은 의미임에도 메소드 이름이 다르게 사용되거나 혼용되는 경우가 많았습니다.

 

예를 들어, createComment와 addLink, editComment, updateLink 같은 경우였습니다. 이러한 이름 불일치는 일관성이 떨어져 코드 가독성과 유지보수에 문제가 생깁니다.

 

그래서? 메소드 이름을 통일 시켰습니다.

- create -> add

- edit -> update

로 통일하여 일관성과 유지보수성을 향상시켰습니다.


아까 위에서 못 다했던 이야기

// 수정 전
fetchLinks: {
  let descriptor = FetchDescriptor<ArticleItem>()
  return try modelContext.fetch(descriptor)
},

fetchCategories: {
  let descriptor = FetchDescriptor<CategoryItem>()
  return try modelContext.fetch(descriptor)
}

// 수정 후
fetchLinks: {
  return try context.fetchAll(ArticleItem.self)
},

fetchCategories: {
  try context.fetchAll(CategoryItem.self)
}

네.. 아주 흥미롭죠... 위에서 잠깐 언급했지만, 다시 한번 정리해볼까요? 위 코드를 보면 메소드는 반환 타입만 다를 뿐, 결국 내부 동작은 완전히 동일한 메소드라는 점입니다. 즉, 로직의 차이는 없고 타입만 다른 동일한 구조의 래퍼 메소드라고 볼 수 있습니다.

 

논리적으로 보면, 이 패턴 마찬가지로 완전히 제네릭화할 수 있고, 아래처럼 하나의 메소드로 통합할 수 있습니다.

func fetchAll<T: PersistentModel>(
  _ type: T.Type,
  predicate: Predicate<T>? = nil,
  sortDescriptors: [SortDescriptor<T>]? = nil,
  limit: Int? = nil
) throws -> [T] {
  try context.fetchAll(type, predicate: predicate, sortDescriptors: sortDescriptors, fetchLimit: limit)
}

그렇다면 왜 굳이 구현부만 제네릭 헬퍼 메소드로 중복을 제거하고, 도메인별 메소드를 의도적으로 남겨두었을까요?

 

음.. 사실 당장의 문제와 기능상의 문제는 전~혀 없어요. 제 개인적으로는 관점의 차이, 프로젝트 규모의 차이라고 생각해요. 먼저 "이 메소드들이 과연 각각 독립적으로 존재해야 할 이유가 충분한가?"에 대해 생각해볼까요?

 

이유1. API 명확성

-각 도메인별 메소드를 그대로 두면, 호출하는 쪽에서 어떤 데이터를 가져오는지 메소드 이름만 보고 직관적으로 파악이 가능합니다. 제네릭 메소드 하나로 통합하면 코드가 간결해지지만, 호출부에서는 타입을 확인해야만 동작을 이해할 수 있습니다. 따라서 도메인별 메소드는 API 자체가 문서화된 역할을 수행한다고 볼 수 있습니다.

 

이유2. 유지보수 

- 도메인별 메소드는 중복 코드가 아니라, 명시적 API 역할을 한다고 생각합니다. 다른 팀원이 쉽게 이해하고 사용할 수 있는 명확한 도메인 단위 API를 제공하는 것도 가치가 있다고 생각합니다.

 

결국 이 판단은 코드 중복 제거 관점과 API 명확성 관점 사이에서 내려진 판단입니다만.. 저는 API 명확성 관점 쪽을 더 생각한 것 같아요.

- 내부적으로는 제네릭으로 모든 동작을 처리할 수 있지만, 외부에서 사용하는 API로서는 도메인별 메소드가 호출 의도를 더 명확히 보여준다고 생각합니다.

 

+ 일단.. 이 부분은 개발 팀과의 추가적인 회의를 통해 결정해야할 것 같습니다! 

'탭탭 - TapTap > 리팩토링' 카테고리의 다른 글

[리팩토링] 08. SwiftDataClient 리팩토링 - SwiftDataClient의 비대함 완화 및 명확한 구분  (0) 2026.02.12
[리팩토링] 06. 모듈 개선  (0) 2026.02.03
[리팩토링] 05. Swift Concurrency로 메모리 누수와 Callback 지옥을 해결해보자 (온보딩 리팩토링).  (0) 2026.01.19
[리팩토링] 03. 구조개선 - App진입점을 TCA로 컨버팅  (1) 2026.01.15
[리팩토링] 02. 탭탭의 구조 개선  (0) 2026.01.15
'탭탭 - TapTap/리팩토링' 카테고리의 다른 글
  • [리팩토링] 08. SwiftDataClient 리팩토링 - SwiftDataClient의 비대함 완화 및 명확한 구분
  • [리팩토링] 06. 모듈 개선
  • [리팩토링] 05. Swift Concurrency로 메모리 누수와 Callback 지옥을 해결해보자 (온보딩 리팩토링).
  • [리팩토링] 03. 구조개선 - App진입점을 TCA로 컨버팅
여성일
여성일
  • 여성일
    성일노트
    여성일
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 탭탭 - TapTap
        • 리팩토링
        • 트러블슈팅
        • 개발일지
      • 애플 디벨로퍼 아카데미
        • 챌린지 회고
        • 하루의 날씨
      • Swift Student Challenge 202..
      • AI를 잘쓰는 개발자가 될래요
      • 우리 같이 협업하자
      • ToyProject - 사카마카 (살까말까 고민 ..
      • ToyProject - Book2OnNon (모바..
      • ToyProject - 바꿔조 (환율 계산기)
      • iOS
        • iOS
        • Vapor
        • Design Pattern
        • CoreData
        • Tuist
        • RxSwift
        • ReactorKit
        • TCA
      • Swift
        • Swift 기본기
        • UIkit
        • SwiftUI
      • UX, 사용성 N
      • 원티드 프리온보딩 챌린지 iOS 과정
  • 블로그 메뉴

    • 링크

    • 공지사항

    • 인기 글

    • 태그

      F
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.6
    여성일
    [리팩토링] 07. SwiftDataClient 리팩토링 - 중복 코드 제거
    상단으로

    티스토리툴바