일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 3b52.1
- privacyinfo.plist
- native
- requirenativecomponent
- 앱 성능 개선
- launch screen
- 파노라마 뷰
- 리액트
- ssot
- SwiftUI
- 명시적 정체성
- data driven construct
- 360도 뷰어
- launchscreen
- React-Native
- react-native-fast-image
- 리액트 네이티브
- React Native
- ios
- 구조적 정체성
- Android
- react
- panorama view
- 뷰 정체성
- 라이브러리 없이
- 네이티브
- 360도 이미지
- 뷰 생명주기
- 스켈레톤 통합
- 360도 이미지 뷰어
- Today
- Total
Neoself의 기술 블로그
ChatGPT와 네이버 책검색 api로 도서매칭 시스템 구현하기(모듈화 과정 4/4) 본문
이번 포스트에서는 도서 추천 시스템을 모듈화하고 재사용 가능한 패키지로 개선한 과정을 공유하고자 합니다. 기존 단일 프로젝트로 구성되어 있던 코드를 여러 하위 라이브러리로 분리하고, 각 기능의 역할과 책임을 명확히 하여 더 유지보수하기 좋은 구조로 개선했습니다.
0. Package화의 필요성
기존 도서 매칭 시스템의 경우 모듈 간 결합도가 높았습니다. 때문에 특정 기능만 분리해 단위 테스트를 작성하기 어려웠습니다.
class BookRecommendationSystem {
let searchManager = BookSearchManager()
let similarityCalculator = SimilarityCalculator()
func recommendBooks(query: String) async throws -> [Book] {
let searchResults = try await searchManager.searchBooks(query: query)
// 테스트하기 어려운 복잡한 로직
return searchResults
}
}
// 테스트가 어려움
class BookRecommendationTests: XCTestCase {
func testRecommendation() async throws {
let system = BookRecommendationSystem()
// 실제 API를 호출해야 함
// 결과가 환경에 따라 달라질 수 있음
}
}
이외에도, 프로젝트 확장 시 다음과 같은 문제점도 예상해볼 수 있었습니다.
1. 코드 재사용의 어려움
// ProjectA/BookRecommendation/BookSearchManager.swift
class BookSearchManager {
func searchBooks(query: String) async throws -> [Book] {
// Naver 책 검색 API 호출 구현
}
func calculateSimilarity(_ str1: String, _ str2: String) -> Double {
// 문자열 유사도 계산 로직
}
}
// ProjectB/Utils/BookSearch.swift
class BookSearch { // 동일한 기능을 복사하여 구현
func searchBooks(query: String) async throws -> [Book] {
// 동일 로직
}
}
다른 프로젝트에서 도서 추천 기능을 사용하고 싶을 때 구현체 자체를 복사 붙여넣기해야할 수 있다는 것을 예상할 수 있었습니다. 이는 불필요한 코드 증가와 함께 유지보수 비용의 증가로 이어지리라 예상했습니다.
2. 의존성 관리의 복잡성
// ProjectA/Podfile
pod 'Alamofire', '~> 5.0' // 네트워킹용
// ProjectB/Podfile
pod 'Alamofire', '5.2.0' // 다른 버전 사용
현재는 외부 라이브러리들을 사용하지 않고 있지만, 추후 시스템이 고도화됨에 따라 외부 라이브러리들을 사용하게 될 경우 버전 관리가 체계적이지 않게 될 것이라 예상했습니다.
이러한 문제점들을 해결하고자 Swift Package로의 전환을 결정했으며, 다음과 같은 이점을 기대했습니다:
명확한 모듈 분리로 코드 재사용성 향상
// BookMatchAPI 패키지
public protocol APIClientProtocol {
func searchBooks(query: String, limit: Int) async throws -> [BookItem]
}
// 여러 프로젝트에서 재사용
import BookMatchAPI
class MyBookService {
let apiClient: APIClientProtocol
init(apiClient: APIClientProtocol) {
self.apiClient = apiClient
}
}
체계적인 버전 관리와 의존성 관리
// Package.swift
let package = Package(
name: "BookMatch",
products: [
.library(name: "BookMatchAPI", targets: ["BookMatchAPI"]),
.library(name: "BookMatchCore", targets: ["BookMatchCore"])
],
dependencies: [
.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.0.0"))
]
)
테스트 용이성 향상
// 테스트가 용이한 구조
class MockAPIClient: APIClientProtocol {
var mockResults: [BookItem] = []
func searchBooks(query: String, limit: Int) async throws -> [BookItem] {
return mockResults
}
}
class BookMatchTests: XCTestCase {
func testRecommendation() async throws {
let mockClient = MockAPIClient()
mockClient.mockResults = [
BookItem(id: "1", title: "Test Book", author: "Test Author")
]
let module = BookMatchModule(
apiClient: mockClient,
titleStrategy: LevenshteinStrategy()
)
let result = try await module.processBookRecommendation(input)
XCTAssertEqual(result.newBooks.count, 1)
}
}
1. Swift Packages로 패키징 기초환경 구축하기
먼저, XCode에서 File > New > Package를 선택해, 패키지를 생성하였습니다.
이후, Package.swift 파일을 통해 타겟과 라이브러리들을 명시하여 의존성 관계를 정의하였습니다.
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "BookMatchKit",
platforms: [
.iOS(.v16)
],
products: [
.library(
name: "BookMatchKit",
targets: ["BookMatchKit"]
),
.library(
name: "BookMatchCore",
targets: ["BookMatchCore"]
),
...
],
targets: [
.target(
name: "BookMatchCore",
dependencies: []
),
...
]
)
주요 구성 요소
1. swift-tools-version: 패키지를 빌드하는 데 필요한 Swift 도구 버전을 명시합니다.
2. platforms: 지원하는 플랫폼과 최소 버전을 정의합니다.
3. products: 외부에 노출할 모듈, 즉 다른 패키지나 앱에서 사용할 수 있는 라이브러리를 정의합니다.
4. targets: 실제 코드가 있는 모듈 정의하며, 각 타겟 간의 의존성 관계를 명시합니다.
targets: [
// 1. Core Layer - 가장 하위 계층
.target(
name: "BookMatchCore",
dependencies: [] // 의존성 없음
),
// 2. API Layer - Core에 의존
.target(
name: "BookMatchAPI",
dependencies: ["BookMatchCore"]
),
// 3. Strategy Layer - Core에 의존
.target(
name: "BookMatchStrategy",
dependencies: ["BookMatchCore"]
),
// 4. Tests
.testTarget(
name: "BookMatchCoreTests",
dependencies: ["BookMatchCore"]
)
]
특히, Targets 정의를 통해 각 모듈의 의존성 방향을 명시함으로써, 상위 레이어만 하위 레이어에 의존하는 단방향 의존성을 명확히 정의할 수 있는데요. 이는 컴파일 타임에 순환참조를 방지시켜주며, 아키텍처 의도를 명확히 표현할수 있게 해줍니다.
2. 라이브러리와 별도 파일로 시스템에 대한 역할 분리하기
패키징에 대한 기초 토대를 구축한 후에는, 하나로 구현되어있던 도서 매칭 시스템을 다음과 같이 3개의 하위 라이브러리로 분리했습니다.
각 라이브러리 별로 해당 프로젝트를 설명드리도록 하겠습니다.
BookMatch/
├── Sources/
│ ├── BookMatchCore/ # 핵심 도메인 모델과 프로토콜
│ ├── BookMatchAPI/ # API 통신 관련 기능
│ └── BookMatchStrategy/ # 문자열 유사도 계산 알고리즘
└── Tests/
├── BookMatchCoreTests/
├── BookMatchAPITests/
└── BookMatchStrategyTests/
2.1. BookMatchCore
BookMatchCore는 프로젝트의 핵심 도메인 모델과 프로토콜을 포함합니다. 다른 모듈들이 의존하는 기본 계층입니다.
주요 인터페이스
public protocol BookMatchable {
func processBookRecommendation(_ input: BookMatchModuleInput) async throws -> BookMatchModuleOutput
func processBookMatch(_ input: RawBook) async throws -> (isMatching: Bool, book: BookItem?, similarity: Double)
}
public protocol SimilarityCalculatable {
func calculateSimilarity(_ str1: String, _ str2: String) -> Double
}
도메인 모델
public struct BookItem: Codable, Identifiable, Hashable {
public let id: String
public let title: String
public let link: String
public let image: String
public let author: String
public let discount: String?
public let publisher: String
public let isbn: String
public let description: String
public let pubdate: String?
}
public struct RawBook: Codable, Hashable {
public let title: String
public let author: String
}
public struct OwnedBook: Codable, Identifiable, Hashable {
public let id: String
public let title: String
public let author: String
}
2.2. BookMatchAPI
그 다음, API 통신과 관련된 기능을 담당하는 BookMatchAPI 라이브러리를 구현했습니다. 이는 Naver 책 검색 API와 OpenAI GPT API를 사용한 통신을 처리합니다.
주요 인터페이스
public protocol APIClientProtocol {
func searchBooks(query: String, limit: Int) async throws -> [BookItem]
func getBookRecommendation(question: String, ownedBooks: [OwnedBook]) async throws -> GPTRecommendation
func getAdditionalBook(question: String, previousBooks: [RawBook]) async throws -> RawBook
func getDescription(question: String, books: [RawBook]) async throws -> String
}
구현 클래스
public final class DefaultAPIClient: APIClientProtocol {
private let configuration: APIConfiguration
private let session: URLSession
public init(
configuration: APIConfiguration,
session: URLSession = .shared
) {
self.configuration = configuration
self.session = session
}
// API 메서드 구현...
}
2.3. BookMatchStrategy
문자열 유사도 계산 알고리즘을 구현한 모듈입니다. 다양한 전략을 쉽게 교체할 수 있도록 설계했습니다.
public struct LevenshteinStrategy: SimilarityCalculatable {
public func calculateSimilarity(_ source: String, _ target: String) -> Double {
// Levenshtein 거리 계산 알고리즘 구현
}
}
public struct LevenshteinStrategyWithNoParenthesis: SimilarityCalculatable {
public func calculateSimilarity(_ source: String, _ target: String) -> Double {
let cleanSource = removeParenthesesContent(from: source)
// 괄호 내용을 제거한 문자열에 대해 유사도 계산
}
}
위와 같이 모든 하위 라이브러리들에 프로토콜을 별도로 정의한 이유는, BookMatchModule의 인스턴스 생성 시, 같은 프로토콜을 준수하는 다른 구현체로 변경해 주입하는 경우와 같이 구현체 교체를 용이하기 위해서입니다.
let module = BookMatchModule(
apiClient: DefaultAPIClient(configuration: .default),
titleStrategy: LevenshteinStrategyWithNoParenthesis(), // 같은 CalculationStrategy 프로토콜 준수
authorStrategy: LevenshteinStrategy(), // 같은 CalculationStrategy 프로토콜 준수
configuration: .default
)
2.4. 프롬프트 관리
이후 기존에는 .env 파일로 관리하고 있던 프롬프트 내용들을 별도의 파일로 분리해 관리를 중앙화하였습니다.
enum Prompts {
static let bookRecommendation = """
당신은 전문 북큐레이터입니다. 다음 지침에 따라 질문에 제일 적합한 책들을 추천해주세요:
1. 입/출력 형식
입력:
- 사용자 질문 (문자열)
- 보유도서 제목-저자 목록 (배열)
출력: 다음 구조의 JSON
{
"ownedBooks": ["도서명-저자명"], // 보유도서 중 0-3권
"newBooks": ["도서명-저자명"], // 신규추천 1-3권
}
...
"""
static let additionalBook = """
당신은 전문 북큐레이터입니다. 아래 지침에 따라 새로운 도서를 추천해주세요:
...
"""
static let description = """
당신은 전문 북큐레이터입니다. 아래 정보를 받아 각 도서 선정의 이유를 상세히 설명해주세요:
...
"""
}
이로써 아래와 같이 패키지화한 도서 매칭 시스템을 생성해 사용할 수 있게 되었습니다.
// 설정 객체 생성
let apiConfiguration = APIConfiguration(
naverClientId: "YOUR_NAVER_CLIENT_ID",
naverClientSecret: "YOUR_NAVER_CLIENT_SECRET",
openAIApiKey: "YOUR_OPENAI_API_KEY"
)
// API 클라이언트 생성
let apiClient = DefaultAPIClient(configuration: apiConfiguration)
// 도서 매칭 모듈 생성
let bookMatchModule = BookMatchModule(
apiClient: apiClient,
titleStrategy: LevenshteinStrategyWithNoParenthesis(),
authorStrategy: LevenshteinStrategy(),
configuration: .default
)
// 도서 추천 처리
let input = BookMatchModuleInput(
question: "프로그래밍 입문자를 위한 책을 추천해주세요",
ownedBooks: [] // 사용자가 보유한 도서 목록
)
do {
let result = try await bookMatchModule.processBookRecommendation(input)
print("추천된 보유 도서:", result.ownedISBNs)
print("추천된 새로운 도서:", result.newBooks)
print("추천 이유:", result.description)
} catch {
print("Error:", error)
}
3. 도서 매칭 시스템 정확도 검증 및 개선 결과
패키지화를 완료한 이후, 시스템의 안정성과 일관성을 검증하고자, 테스트환경을 아래와 같이 구현했습니다.
let questions = [
"요즘 스트레스가 많은데, 마음의 안정을 찾을 수 있는 책 추천해주세요.",
"SF와 판타지를 좋아하는데, 현실과 가상세계를 넘나드는 소설 없을까요?",
"창업 준비 중인데 스타트업 성공사례를 다룬 책을 찾고 있어요.",
"철학책을 처음 읽어보려고 하는데, 입문자가 읽기 좋은 책이 있을까요?",
"퇴사 후 새로운 삶을 준비하는 중인데, 인생의 방향을 찾는데 도움이 될 만한 책 있나요?",
"육아로 지친 마음을 위로받을 수 있는 책을 찾고 있어요.",
"무라카미 하루키 스타일의 미스터리 소설 없을까요?",
"'사피엔스'를 재미있게 읽었는데, 비슷한 책 추천해주세요.",
"우울할 때 읽으면 좋은 따뜻한 책 추천해주세요.",
"의욕이 없을 때 동기부여가 될 만한 책 없을까요?"
]
func test_Accurancy() async throws {
let client = DefaultAPIClient(configuration: config)
let module = BookMatchModule(apiClient:client)
var cnt = 0
var total = 0
var results = [Double]()
for id in 0 ..< 5 {
for question in questions {
do {
let input = BookMatchModuleInput(
question: question,
ownedBooks: []
)
let result = try await module.processBookRecommendation(input)
total += result.newBooks.count
XCTAssertTrue(!result.description.isEmpty)
XCTAssertTrue(!result.newBooks.isEmpty)
for book in result.newBooks {
let myBool = await accurancyTester(question: question, title: book.title)
if myBool == 1 { cnt += 1 }
}
} catch {
print("Error during test: \(error)")
}
}
let acc = Double(cnt)/Double(total)
print("accurancy in \(id+1)th try: \(acc)")
results.append(acc)
}
print("total accurancy: \(results.reduce(0.0,+) / 5.0)")
}
개선 결과
패키지화와 구조 개선 후 최종 정확도 테스트를 한 결과, 아래 수치들을 검출할 수 있었습니다.
초기 정확도: 58.3%
개선 후 정확도: 92.8%
향상률: 34.5%
특히 GPT가 제시한 도서가 실제 검색 가능한 도서가 아닐 수 있다는 제약 조건을 고려할 때, 92.8%의 정확도는 매우 유의미한 결과라고 판단됩니다.
도서 매칭 시스템의 정확도 개선 요인들은 아래와 같이 정리해볼 수 있습니다.
1. 유사도 계산 알고리즘 개선
- 대상 간 텍스트들이 포함관계인지를 통해 참,거짓만을 반환하는 ContainsStrategy 대신 0에서 1 사이 소수로 정규화해 유사도를 반환할 수 있는 레벤슈타인 알고리즘으로 검색결과 간 변별력 극대화하였습니다.
- 상세내용이 포함된 괄호 내부 내용을 유사도 비교대상에서 제외하였습니다.
- 정상/비정상 매칭 사례들의 유사도 패턴을 통해 출판사의 변별력 부제를 확인하였으며, 이를 토대로 출판사의 비중을 과감히 배제했습니다.
2. 검색 범위 확장
- 기존 제목 기반 검색에 저자 기반 검색을 추가해 유사도 비교대상을 늘렸습니다.
- 특수문자를 기준으로 부제목을 구분하고 네이버 책검색결과가 나오지 않을 경우, 부제목을 제외한 주제목으로 다시 검색해, 책 검출확률을 높히고자 했습니다.
3. 재시도 매커니즘
- GPT api 와 네이버 책 검색 api 호출 간 발생할 수 있는(특히 GPT...) 변수들에 대한 복원력을 확보하기 위해 3회 재시도 로직을 추가하였으며, 최종 3회 실패 시에도 가장 높은 유사도를 보유했던 도서를 반환함으로써 단계적 대응 전략을 수립하였습니다.
4. GPT 프롬프트 최적화
- 정확한 형식을 명시하여, GPT api 호출 간 변수를 최소화했습니다.
이러한 요소들이 복합적으로 작용하여 34.5%의 정확도 향상을 달성할 수 있었습니다.
이상으로 GPT와 네이버 책 검색 api를 결합한 도서 매칭 시스템 구현하기 글을 마치겠습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
ChatGPT와 네이버 책검색 api로 도서매칭 시스템 구현하기(고도화 과정 3/4) (0) | 2025.01.26 |
---|---|
ChatGPT와 네이버 책검색 api로 도서매칭 시스템 구현하기(구현 과정 2/4) (1) | 2025.01.25 |
ChatGPT와 네이버 책검색 api로 도서매칭 시스템 구현하기(설계 과정 1/4) (0) | 2025.01.23 |
클린 아키텍처 도입기 (0) | 2025.01.15 |
RxSwift 라이브러리 딥다이브 (0) | 2025.01.08 |