NEON의 이것저것

BookMatchKit 리펙토링 과정-2/3 (Template Method 패턴) 본문

개발지식 정리/Swift

BookMatchKit 리펙토링 과정-2/3 (Template Method 패턴)

Neoself 2025. 2. 17. 23:06

1. 2차 리펙토링

이후 아래와 같은 이유들로 인해 2차 리펙토링의 필요성을 느끼게 되었습니다.

- DeepSeek api처럼 로직 목표를 공유하는 다른 외부 API를 추가하거나 대체하고자 할때, 많은 개발 리소스가 소요됨.

- BookMatchModule 내부 기능상 다른 역할을 하는 메서드 2개가 공존하고 있기에, 유지보수 용이성이 저하됨.

- BookMatchModule 내부 세부로직들에 대한 책임이 명확히 분리되어있지 않음.

 

따라서 크게 4가지 변경점을 가져가고자 했습니다.

1. API 계층 개선

2. BookMatchModule 책임 분리

3. 계층적 디렉토리 구조 도입

4. 서비스 계층 도입

 

2. API 계층 개선

 

API 클라이언트 분리

기존에는 DefaultAPIClient라는 단일 클래스에서 모든 API 호출을 처리하였기에, 책임 분리가 불명확했으며 이는 자연스레 유지보수 소요비용 증가로 이어졌습니다.

이를 해결하기 위해, 호출하는 플랫폼을 기준으로 APIClient를 분리해 단일책임원칙을 더 강화하고자 했습니다.

// 네이버 API 클라이언트
public final class NaverAPI {
    // 중복 코드 1: 설정 관리
    private let configuration: APIConfiguration
    private let disposeBag = DisposeBag()
    
    public init(configuration: APIConfiguration) {
        self.configuration = configuration
    }
    
    public func searchBooks(query: String, limit: Int = 10) -> Single<[BookItem]> {
    	...
    }
}

// OpenAI API 클라이언트
public final class OpenAIAPI {
    // 중복 코드 1: 설정 관리
    private let configuration: APIConfiguration
    private let disposeBag = DisposeBag()
    
    public init(configuration: APIConfiguration) {
        self.configuration = configuration
    }
    
    public func getDescription(question: String, books: [RawBook]) -> Single<String> {
        ...
    }
}

// 이미지 다운로드 API 클라이언트
public final class ImageDownloadAPI {
    // 중복 코드 1: 설정 관리
    private let configuration: APIConfiguration
    private let disposeBag = DisposeBag()
    
    public init(configuration: APIConfiguration) {
        self.configuration = configuration
    }
    
    public func downloadImage(from urlString: String) -> Single<UIImage> {
        ...
    }
}

 

하지만 분리를 하게 되면서, 동일한 초기화 코드들이 모든 APIClient들마다 중복되어 구현되고 있는 것을 파악할 수 있었습니다. 설령, 모든 APIClient들마다, 네트워크 호출로 인한 비동기작업을 처리하기 위해 RxSwift를 사용하게 됨에 따라 disposeBag 속성을 정의해주어야 했습니다.

 

이러한, 중복 코드들을 최소화하기 위해 API 모듈에 대해 Template Method 패턴을 적용하게 되었습니다.

템플릿 메서드 패턴 (Template Method Pattern)

목적
- 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스에서 재정의할 수 있게 함
- 코드 중복을 줄이고 공통 알고리즘 구조를 재사용
- 알고리즘의 변경 없이 특정 단계만 수정 가능하도록 함

작동 방식
- 상위 클래스에서 알고리즘의 뼈대를 템플릿 메서드로 정의
- 알고리즘의 각 단계를 메서드로 분리
- 하위 클래스에서 필요한 단계만 선택적으로 구현/재정의
- 템플릿 메서드 자체는 수정할 수 없도록 보호

장점
- 코드 중복 제거 및 재사용성 향상
- 알고리즘의 구조를 유지하며 특정 단계만 변경 가능
- 하위 클래스에서 알고리즘의 특정 부분만 수정 가능

사용 시점
- 여러 클래스가 비슷한 알고리즘을 포함할 때
- 알고리즘의 구조는 동일하나 세부 구현이 다른 경우 
고리즘의 특정 단계만 확장/변경하고 싶을 때
- 코드 중복을 제거하고 싶을 때

 

새로 생성한 BaseAPIClient 클래스를 통해 모든 API 클라이언트가 갖춰야할 핵심 속성들에 대한 관리를 중앙화하고자 했으며, NaverAPI, OpenAIAPI와 같은 하위 클래스들이 이를 상속받아 각 API의 특성에 맞는 구체적인 구현을 제공하게 설계하여 코드 중복을 줄이고자 했습니다.

public class BaseAPIClient {
    let configuration: APIConfiguration
    let disposeBag = DisposeBag()
    
    public init(configuration: APIConfiguration) {
        self.configuration = configuration
    }
}

public final class NaverAPI: BaseAPIClient {
    // 네이버 책 검색 전용 클라이언트
}

public final class OpenAIAPI: BaseAPIClient {
    // GPT 관련 기능 전용 클라이언트
}

public final class ImageDownloadAPI: BaseAPIClient {
    // 이미지 다운로드 전용 클라이언트
}

 

이를 통해 코드의 유지보수 용이성을 향상시킬 수 있었으며, DeepSeek api와 같은 새로운 API Client를 추가하더라도, BaseAPIClient를 상속해 중복 작업을 감소시킬 수 있게 되었습니다.

 

API 클라이언트에 대한 인터페이스 분리

테스트 상황에서 deepseek api와 chatGPT api 사이의 정확도 차이를 비교분석하는 등, 동일한 동작을 기대할 수 있는 API Client를 바꿔 주입시키기 위해, 상위 모듈에서 APIClient 구현체 대신 의존할 수 있는 프로토콜이 필요했습니다.

 

때문에, 분리된 APIClient 클래스들에 맞춰, 프로토콜 또한 분리하였습니다.

public protocol BookSearchable {
    func searchBooks(query: String, limit: Int) -> Single<[BookItem]>
}

public protocol AIRecommendable {
    func getBookRecommendation(question: String, ownedBooks: [OwnedBook]) 
        -> Single<AiRecommendationForQuestion>
    // ... 기타 AI 관련 메서드
}

public protocol ImageDownloadable {
    func downloadImage(from urlString: String) -> Single<UIImage>
}

 

BaseAPIClient 클래스와 각 APIClient 세부 프로토콜을 상속 및 채택함으로써, 새로운 APIClient를 효율적으로 구현할 수 있게 되었으며, 동일한 목적에 대한 기능을 수행하는 APIClient를 런타임에 외부에서 주입할 수 있게 되었습니다.

public class BaseAPIClient {
    let configuration: APIConfiguration
    let disposeBag = DisposeBag()
    
    public init(configuration: APIConfiguration) {
        self.configuration = configuration
    }
}

// 세부 APIClient 프로토콜 추가 채택
public final class NaverAPI: BaseAPIClient, BookSearchable {
 	func searchBooks(query: String, limit: Int) -> Single<[BookItem]> {
        // ...
    }
}

// 세부 APIClient 프로토콜 추가 채택
public final class OpenAIAPI: BaseAPIClient, AIRecommendable {
    func getBookRecommendation(question: String, ownedBooks: [OwnedBook]) 
        -> Single<AiRecommendationForQuestion> {
        // ... 기타 AI 관련 메서드
    }
}

// 세부 APIClient 프로토콜 추가 채택
public final class ImageDownloadAPI: BaseAPIClient, ImageDownloadable {
     func downloadImage(from urlString: String) -> Single<UIImage> {
         // ...
     }
}

 

3. BookMatchModule 분리 및 책임 명확화

기존 코드의 경우 도서 이미지 매칭 및 도서 추천 2개 기능을 모두 BookMatchModule이라는 단일 클래스에서 모두 담당하고 있었습니다. 때문에, 도서 이미지 매칭 관련 로직은 BookMatchKit에 유지하되, OpenAI api를 호출하여 도서를 추천받는 메서드 2개는 새로 생성한 BookRecommendationKit 모듈로 이동하여 모듈을 분리하였으며, 기능별로 프로토콜 또한 분리하였습니다.

public protocol BookMatchable {
    func matchBook(_ rawData: [[String]], image: UIImage) -> Single<BookItem?>
}

public protocol BookRecommendable {
    func recommendBooks(from ownedBooks: [OwnedBook]) async -> [BookItem]
    func recommendBooks(for question: String, from ownedBooks: [OwnedBook]) async 
        -> BookMatchModuleOutput
}

 

리펙토링 후

 

기본적인 계층 분리와 책임 분리를 진행한 이후, 더 세밀한 모듈화와 함께 기존 BookMatchModule을 구성했던 로직들에 대해 명확한 경계를 설정하고자 했습니다.

4. 계층적 디렉토리 구조 도입

기존 APIModule의 경우, APIClient를 플랫폼별로 하위 클래스로 분리는 하였으나, 여전히 각 하위 클래스에 필요로 하는 DTO, 프로토콜 및 상수 데이터들은 BookMatchAPI 모듈 기준 최상위 디렉토리에 집약해 관리하고 있었습니다. 하지만, 각 하위 APIClient들의 구현체 규모가 점차 커짐에 따라, 각 APIClient마다 필요로 하는 구성요소를 집약해 완결된 구조를 가지도록 디렉토리 구조를 변경했습니다.

// 변경 전
BookMatchAPI/
├── Configuration/
├── Constants/
├── Implementation/
├── Models/DTOs/
└── Protocols/

// 변경 후
BookMatchAPI /
├──Clients/
│   ├── ImageDownload/
│   │   ├── Endpoint/
│   │   │   └── ImageDownloadEndpoint.swift
│   │   └── ImageDownloadAPI.swift
│   ├── Naver/
│   │   ├── Endpoint/
│   │   ├── Models/
│   │   └── NaverAPI.swift
│   └── OpenAI/
│       ├── Constants/
│       ├── Endpoint/
│       ├── Models/
│       └── OpenAIAPI.swift

이로써, 클라이언트 관련 코드의 응집도가 향상되면서 유지보수성이 개선되는 것을 체감할 수 있었습니다.

 

5. 서비스 계층 도입

이후에는, 도서 매칭 및 추천 모듈에서 사용되는 세부 로직들에 대한 역할 분리를 진행하고자 했습니다. 

 

1. BookSearchService

public final class BookRecommendationKit {
    // ... 핵심 도서 도서 관련 로직들

   // 위 로직들에 사용되는 책검색 api 활용 메서드
    private func searchProgressively(from textData: [String]) -> Single<[BookItem]>
}

public final class BookMatchKit {
    // ... 핵심 도서 매칭 관련 로직들
    
    // 위 로직들에 사용되는 책검색 api 활용 메서드
    private func searchOverallBooks(from sourceBook: RawBook) -> Single<[BookItem]>
}

가장 먼저 도서 매칭 모듈과 도서 추천 모듈 초반부에서 비교대상을 가져오기 위해 책 검색 api를 호출하는 메서드 2개를 BookSearchService로 분리해 검색 관련 책임을 명확하게 분리하고자 했습니다.

public protocol BookSearchable {
    func searchByTitleAndAuthor(from sourceBook: RawBook) -> Single<[BookItem]>
    func searchProgressively(from textData: [String]) -> Single<[BookItem]>
}

public final class BookSearchService: BookSearchable {
    private let naverAPI: NaverAPI
    
    // 검색 로직 구현
}

 

2. BookValidationService

protocol BookValidatable {
    func findMatchingBookWithRetry(
        book: RawBook,
        question: String,
        previousBooks: [RawBook],
        openAiAPI: OpenAIAPI
    ) -> Single<BookItem?>

    func validateRecommendedBook(_ input: RawBook)
        -> Single<(isMatching: Bool, book: BookItem?, similarity: Double)>
}

public final class BookValidationService: BookValidatable {
    private let similiarityThreshold: [Double]
    private let maxRetries: Int
    private let titleWeight: Double
    private let searchService: BookSearchService
    
    // 검증 로직 구현
}

이후 두 최상위 모듈에서 GPT 혹은 OCR이 검출한 텍스트값이 실제 존재하는 책으로 이어지는지 검증하는 메서드 2개를 BookValidationService로 분리해 도서 검증 로직을 캡슐화하고자 했습니다.

 

 

3. TextExtractionService(OCR 관련 기능)
이후 기존에는 BookMatchKit 모듈 내부에 구현되어있던 OCR 관련 로직을 TextExtractionService, 그리고 ImageProcessService로 분리하여, OCR 및 이미지 전처리 기능을 독립적으로 모듈화하고자 했습니다.

public protocol TextExtractable {
    func extractText(from image: UIImage) -> Single<[String]>
}

final class TextExtractionService: TextExtractable {
    private let imageProcessService: ImageProcessable
    
    // OCR 로직 구현
}

 

위 4가지 변경사항을 반영한 후, 아래 도식과 같이 패키지 구조를 완성시킬 수 있었습니다.

리펙토링 전
최종 리팩토링 후



3차 리팩토링 과정은 다음 글에서 이어 작성하도록 하겠습니다.

감사합니다.