일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 29 | 30 |
- requirenativecomponent
- 360도 이미지
- 구조적 정체성
- Android
- 파노라마 뷰
- ssot
- react
- 네이티브
- 리액트 네이티브
- 뷰 생명주기
- 라이브러리 없이
- 360도 이미지 뷰어
- React-Native
- 명시적 정체성
- @sendable
- launch screen
- 리액트
- ios
- panorama view
- data driven construct
- completion handler
- 뷰 정체성
- 360도 뷰어
- 앱 성능 개선
- 스켈레톤 통합
- native
- react-native-fast-image
- launchscreen
- SwiftUI
- React Native
- Today
- Total
Neoself의 기술 블로그
ChatGPT와 네이버 책검색 api로 도서추천 시스템 구현하기(구현 과정 2/3) 본문
저번 게시글에서는 도서 매칭 시스템에 대한 테스트 환경 구축을 비롯해 설계를 진행하였으며, 해당 게시글에서는 본격적인 구현 과정을 다루고 있습니다.
1. 도서 매칭 시스템 개요
2. 검색 결과 반환 로직 구현
- 메서드 구현
- 테스트 환경 구축
- 트러블 슈팅
3. 엣지케이스 처리를 통한 검색 결과 반환 확률 개선
- 빈 스트링 값 처리
- 부제가 잘못된 케이스 처리
- 영문 책 한글 제목 검색 문제 해결
- 코드 캡슐화
4. GPT 연동 및 유사도 측정 구현
- ChatGPT API 연동
- 검색 결과 유사도 연산
- 최종 매칭 로직 구현
5. 테스트와 성능 개선
- ExactMatchStrategy와 ContainsStrategy 구현
- 레벤슈타인 거리 알고리즘 도입
- 테스트 결과 분석
6. 1차 시스템 고도화
- 심층 분석 진행
- 개선점 도출
- 매커니즘 수정
- 괄호 내부 내용 제외
- 저자 기반 검색 추가
7. 2차 시스템 고도화
- 1차 고도화 결과 분석
- GPT 프롬프트 재설계
- 최종 결과 분석 및 향후 과제
1. 도서 매칭 시스템 개요
앞서, 설명드렸듯 제가 현재까지 계획한 도서 매칭 시스템은 아래와 같습니다.
1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환
2. 각 책 데이터에 대해
1. 제목을 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청
2. 20개의 검색결과 데이터에 대해
1. GPT가 반환한 제목 vs 검색결과 제목 유사도 측정
2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정
3. GPT가 반환한 출판사 vs 검색결과 출판사 유사도 측정
3. 3개 유사도 점수들에 대해 각각 중요도에 따른 가중치 부여
4. 가중치 부여된 3가지 유사도 점수들을 누적합산하여 총 유사도 점수 저장
3. 총 유사도가 가장 높은 검색결과 최종반환 배열에 추가
3. 최종 반환 배열 반환
2. 검색 결과 반환 로직 구현
2.1. GPT api가 반환한 (제목, 저자, 출판사) 데이터 중 제목을 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청
여기서, 2번 과정을 먼저 구현하게 되었습니다. 최종 책 매칭이 되기 위해선, 책 검색 api로부터 최소 1개 이상의 책을 반환받는 것이 필수적인만큼, 매칭 시스템에서의 핵심 절차라고 판단했기 때문입니다.
2.1. 메서드 구현
때문에, 제목, 저자, 출판사로 구성된 RawBook이라는 구조체를 구현한 후, RawBook을 Input으로 전달받으면, 네이버 책 검색 api가 반환하는 BookItem 구조체로 변환하는 process라는 메서드를 따로 구현해 테스트를 통해 매번 1개 이상 책을 반환할 수 있도록 고도화하고자 했습니다.
/// 제목,저자,출판사를 인자로 받으면, 네이버 책 검색 api를 호출해 검색 데이터 중 하나를 반환합니다.
func processBook(_ book: RawBook) -> Single<BookItem?> {
...
return searchedResults[results[0].index]
}
2.2. 테스트 환경 구축
GPT로부터 데이터를 받는 1번 절차는 현재 구현되어있지 않은 상황이었으니, 웹 GPT로 동일한 프롬프트 및 질문을 입력해 반환받은 RawBook 데이터를 직접 배열로 추가하였으며, process 메서드가 BookItem을 반환한다는 것을 평가 기준으로 설정해, 저장했던 모든 RawBook에 대해 테스트를 실행했습니다.
또한, 공백이 전달되는 상황과 같은 엣지케이스는 nil을 반환해야한다는 테스트케이스도 포함해 추후 발생할 수 있는 변수들을 최소화하고자 했습니다.
func testSearchPatterns() async throws {
let testCases = [
/// 심리학 도서
("심리학의 모든 것", "필립 짐바르도", "시그마프레스", true),
("심리학 입문", "데이비드 마이어스", "한울아카데미", true),
/// 경영/리더십 도서
("초격차", "권오현", "쌤앤파커스", true),
("리더의 용기", "브레네 브라운", "갤리온", true),
("원씽","게리 켈러, 제이 파파산", "비즈니스북스",true),
/// SF 소설
("삼체", "류츠신", "자음과모음", true),
("어린 왕자","앙투안 드 생텍쥐페리","문예출판사",true),
("설국열차: 빙하기의 끝", "자크 로브", "알에이치코리아", true),
/// 프로그래밍 도서
("점프 투 파이썬", "박응용", "이지스퍼블리싱", true),
("파이썬 for Beginner","유인동","한빛미디어",true),
("모두의 파이썬", "이승찬", "길벗", true),
/// 철학 도서
("소크라테스 익스프레스", "에릭 와이너", "어크로스", true),
("철학자와 늑대","마크 롤랜즈","추수밭",true),
("철학의 위안","알랭 드 보통","은행나무",true),
/// 우울할 때 읽으면 좋은 책
("죽음의 수용소에서","빅터 프랭클","청아출판사",true),
("자기 앞의 생","에밀 아자르","열린책들",true),
("그럴 때 있으시죠?","정문정","위즈덤하우스",true),
("면접의 정석", "오수향", "리더스북", true),
("취업 면접 완전정복", "송진아", "시대고시기획", true),
("이기는 면접", "임태형", "21세기북스", true),
("아몬드", "손원평", "창비", true),
("하마터면 열심히 살 뻔했다", "하완", "웅진지식하우스", true),
("빨강 머리 앤", "루시 모드 몽고메리", "더모던", true),
("불편한 편의점 2", "김호연", "나무옆의자", true),
("작별인사", "김영하", "복복서가", true),
("역행자", "자청", "웅진지식하우스", true),
("물고기는 존재하지 않는다", "룰루 밀러", "곰출판", true),
("당신의 마음을 정리해드립니다", "정희정", "다산북스", true),
/// 영어 원서
("The Great Gatsby", "F. Scott Fitzgerald", "Scribner", true),
("To Kill a Mockingbird","Harper Lee","Harper Perennial Modern Classics",true),
("1984","George Orwell","Signet Classics",true),
("자기 앞의 생", "에밀 아자르", "열린책들", true),
("브레이브 뉴 월드", "올더스 헉슬리", "펭귄북스", true),
/// Edge cases
("존재하지않는책", "가상의저자", "없는출판사", false),
("", "", "", false),
("삼체 ", "류츠신", " ", true),
("the great gatsby", "f. scott fitzgerald", "Scribner", true)
]
var cnt: Double = 0.0
for (title,author,pulisher,shouldSucceed) in testCases {
let book = RawBook(title: title, author: author, publisher: pulisher)
do {
let result = try await module.processBook(book)
cnt+=1
XCTAssertEqual(result != nil, shouldSucceed, book.title)
} catch {
print("Error for \(book.title): \(error)")
XCTAssertEqual(false, shouldSucceed)
}
print("Accurancy: \(String(format: "%.2f", cnt / Double(testCases.count))) out of \(testCases.count)")
}
2.3. 트러블 슈팅
1차 테스트 결과, 책 검색 결과를 성공적으로 반환하는 비율이 매 시도마다 변경되고 있음을 확인할 수 있었는데요...
책 데이터 반환비율이 낮게 검출되는 것은 알고리즘을 변경하면서 정확도를 향상시킴으로써 해결이 가능하지만, 고정된 인풋에 대해 매번 결과가 다르게 반환되다 보니, 트리거를 파악하기 쉽지 않았습니다
매번 변경되는 정확도의 트리거는 네이버 검색 api에 대한 네트워크 통신 메서드에 있었습니다. 네트워크 통신 간 에러가 반환될 경우, 정상 통신시 반환되어야할 책 데이터를 담는 json이 아닌, errorMessage, errorCode가 포함된 배열이 반환되면서 JSON Decode 에러가 발생하는 것이였는데요..
Decode 에러 발생 시, 반환되는 raw JSON을 확인해보니 아래와 같은 에러를 확인할 수 있었습니다.
네이버 개발자 포럼에 해당 이슈를 검색해보니, 1초에 10번 이상의 요청을 진행할 경우 반환되는 에러라고 합니다...
for (title,author,pulisher,shouldSucceed) in testCases {
...
.delay(.milliseconds(500), scheduler: MainScheduler.instance)
...
}
따라서 매 테스트 케이스를 실행시킬때마다, 0.5초 간 메서드 호출을 지연시켜 api 호출 속도를 늦췄습니다.
3. 엣지케이스 처리를 통한 검색 결과 반환 확률 개선
진행 결과, 37개 테스트 케이스 중 3개의 케이스에서 nil값을 반환, 즉 GPT가 추천한 3개의 책에 대해 검색 결과가 하나도 나오지 않음을 확인했습니다.
XCTAssertEqual failed: ("false") is not equal to ("true") - 설국열차: 빙하기의 끝
XCTAssertEqual failed: ("false") is not equal to ("true") - 브레이브 뉴 월드
keyNotFound(CodingKeys(stringValue: "lastBuildDate", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"lastBuildDate\", intValue: nil) (\"lastBuildDate\").", underlyingError: nil))
nil값이 반환되는 경우는 아래와 같이 정리될 수 있었습니다.
1. 빈 String("")인 제목의 책이 입력될 경우, Decode에러가 발생
2. 부제가 잘못된 제목에 대해 검색결과가 올바르게 리스트업되지 않음
3. 영문 책을 한글 제목으로 검색할 경우, 검색결과가 나오지 않음
3.1. 엣지케이스: 빈 스트링 값 처리
public func searchBooks(query: String, limit: Int = 10) -> Single<[BookItem]> {
guard !query.isEmpty else { // 빈 query값에 대한 예외처리 guard 문 추가!
return .just([])
}
// 네트워크 통신
...
}
처음에 네이버 검색 api를 호출하기 이전, guard 문을 통해 빈 스트링을 입력받는 상황에 대해 예외처리함으로써 이를 해결하였습니다.
3.2. 엣지케이스: 부제가 잘못된 케이스 처리

처음에는 빙하기의 끝이라는 부제가 존재하는 책이라고 생각하였지만, 네이버 책 검색과 구글 모두 해당 부제를 지닌 설국열차 시리즈가 없음을 확인했습니다. 실질적으로 이러한 케이스는 ChatGPT에서 존재하지 않은 책을 추천한 것이기 때문에 에러로 처리를 넘겨야한다고 판단하였으나, 지극히 일반적인 상황에서의 질문에서도 이러한 잘못된 책을 추천한다는 점을 미루어 보았을 때, 에러로만 처리할 경우 이로인해 책 추천 플로우 성공확률이 큰 폭으로 낮아질 것이라 판단했습니다.
때문에, 검색결과가 없을 경우 에러처리 대신, 일단 부제를 제거한 주제목에 대해서만 검색 api를 재호출해 최대한 많은 유사책들을 가져와 가장 유사도가 높은 책을 택 1하는 것으로 로직 방향을 수정했습니다. 부제 존재여부는 부제와 제목 사이에 삽입되는 특수문자 존재여부로 구분하였습니다.
searchBooks(sourceBook.title)
.asObservable()
.flatMap { searchedResults -> Observable<[BookItem]> in
let subTitleDivider = [":", "|", "-"]
/// 1. 검색결과 비어있는데 제목 내부에 특수문자 존재할 경우,
/// 다음단어는 부제라 판단하고 비교대상 최대한 가져오기 위해 주제목만 query로 검색
if searchedResults.isEmpty,
!subTitleDivider.filter({ sourceBook.title.contains($0) }).isEmpty,
let divider = subTitleDivider.first(where: { sourceBook.title.contains($0) }),
let title = sourceBook.title.split(separator: divider).first {
return self.searchBooks(query: String(title), limit: 10)
.asObservable()
}
/// 2. 검색결과 비어있는데 특수문자도 없는 경우 자연스럽게 빈 검색결과 반환
return Observable.just(searchedResults)
}
3.3. 엣지케이스: 영문 책을 한글제목으로 검색하는 케이스 처리
브레이브 뉴 월드

이는 생각보다 단순하게 해결이 가능했는데요. ChatGPT의 프롬프트를 수정해 영문 책일 경우, 영문 제목과 작가를 제시해달라고 요청을 추가해 해결했습니다.
이로써, 모든 37개의 테스트케이스들로부터 검색 api로부터 책 데이터를 1개 이상 받아옴을 확인해, 검색 성공률을 100%로 끌어올릴 수 있었습니다.

특히 입력값에 대한 검증절차를 추가하여 잘못된 API 호출을 방지해 데이터 무결성을 보장하게 되었으며, 부제 포함 검색 실패 시에도 주제목으로 재시도해 최대한의 매칭 기회를 제공해 서비스의 안정성을 개선하고자 했습니다.
*데이터 무결성: 데이터의 정확성, 일관성, 유효성이 유지되는 것
3.4. 코드 캡슐화
이후, 추후 전체 로직에서 사용될 수 있도록 searchOverallBooks 메서드로 검색결과를 반환하는 로직을 캡슐화했습니다.
1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환
2. 각 책 데이터에 대해
1. 제목을 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청 <- getSearchResults, fetchSearchResults
2. 20개의 검색결과 데이터에 대해
1. GPT가 반환한 제목 vs 검색결과 제목 유사도 측정
2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정
3. GPT가 반환한 출판사 vs 검색결과 출판사 유사도 측정
3. 3개 유사도 점수들에 대해 각각 중요도에 따른 가중치 부여
4. 가중치 부여된 3가지 유사도 점수들을 누적합산하여 총 유사도 점수 저장
3. 총 유사도가 가장 높은 검색결과 최종반환 배열에 추가
3. 최종 반환 배열 반환
최종 코드
private func searchOverallBooks(from sourceBook: RawBook) -> Single<[BookItem]> {
Observable<Void>.just(())
.delay(.milliseconds(500), scheduler: MainScheduler.instance)
.flatMap { _ -> Observable<[BookItem]> in
return self.searchBooks(query: sourceBook.title, limit: 10)
.asObservable()
}
.flatMap { searchedResults -> Observable<[BookItem]> in
let subTitleDivider = [":", "|", "-"]
if searchedResults.isEmpty,
!subTitleDivider.filter({ sourceBook.title.contains($0) }).isEmpty,
let divider = subTitleDivider.first(where: { sourceBook.title.contains($0) }),
let title = sourceBook.title.split(separator: divider).first {
return self.apiClient.searchBooks(query: String(title), limit: 10)
.asObservable()
}
return Observable.just(searchedResults)
}
.asSingle()
}
public func searchBooks(query: String, limit: Int = 10) -> Single<[BookItem]> {
guard !query.isEmpty else {
return .just([])
}
let endpoint = NaverBooksEndpoint(
query: query,
limit: limit,
configuration: configuration
)
// 타 로컬 패키지를 활용하여 네트워크 통신을 구현하였습니다.
return NetworkManager.shared.request(endpoint)
.map { response -> [BookItem] in
guard let response else {
throw BookMatchError.invalidResponse
}
return response.items.map { $0.toBookItem() }
}
.catch { error in
if let networkError = error as? NetworkError {
return .error(
BookMatchError.networkError(networkError.localizedDescription)
)
}
return .error(error)
}
}
2번 절차에 대한 안정성을 향상시킨 후, 나머지 로직들에 대한 구현을 시작하였습니다.
4. GPT 연동 및 유사도 측정 구현
1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환 <- 현재 구현 로직
2. 각 책 데이터에 대해
1. 제목을 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청
2. 20개의 검색결과 데이터에 대해
1. GPT가 반환한 제목 vs 검색결과 제목 유사도 측정
2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정
3. GPT가 반환한 출판사 vs 검색결과 출판사 유사도 측정
3. 3개 유사도 점수들에 대해 각각 중요도에 따른 가중치 부여
4. 가중치 부여된 3가지 유사도 점수들을 누적합산하여 총 유사도 점수 저장
3. 총 유사도가 가장 높은 검색결과 최종반환 배열에 추가
3. 최종 반환 배열 반환
4.1 1번째 로직(GPT api로 질문 전달) 구현
private func getBookRecommendation(
question: String,
ownedBooks: [String]
) -> Single<(recommendationFromOwned: [String], recommendationFromUnowned: [RawBook])> {
guard let system = loadEnv()?["PROMPT"],
let openAIApiKey = loadEnv()?["OPENAI_API_KEY"] else {
return .error(GPTError.noKeys)
}
guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else {
return .error(GPTError.noKeys)
}
// 임시 보유 도서 (TODO: 로컬 저장소에서 가져오기)
let tempOwnedBooks = ["이기적 유전자", "클린 아키텍처"]
let prompt = "질문: \(question)\n보유도서: \(tempOwnedBooks)"
let requestBody: [String: Any] = [
"model": "gpt-4o",
"messages": [
["role": "system", "content": system],
["role": "user", "content": prompt]
],
"temperature": 0.01,
"max_tokens": 500
]
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(openAIApiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
return Single<(recommendationFromOwned: [String], recommendationFromUnowned: [RawBook])>.create { single in
do {
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
} catch {
single(.failure(GPTError.invalidResponse))
return Disposables.create()
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
single(.failure(error))
return
}
guard let data = data else {
single(.failure(GPTError.invalidResponse))
return
}
do {
let response = try JSONDecoder().decode(ChatGPTResponse.self, from: data)
guard let jsonString = response.choices.first?.message.content,
let jsonData = jsonString.data(using: .utf8) else {
single(.failure(GPTError.invalidResponse))
return
}
let bookRecommendation = try JSONDecoder().decode(ChatGPTRecommendation.self, from: jsonData)
let ownedBooks = bookRecommendation.ownedBooks.map {
// TODO: 로컬저장소 중에 $0과 일치하는 이름의 id값 찾아서 return
return $0
}
let newBooks = bookRecommendation.newBooks.map {
let arr = $0.split(separator: "-").map { String($0) }
return RawBook(title: arr[0], author: arr[1], publisher: arr[2])
}
single(.success((
recommendationFromOwned: ownedBooks,
recommendationFromUnowned: newBooks
)))
} catch {
print("GPT Error: \(error)")
single(.failure(GPTError.invalidResponse))
}
}
task.resume()
return Disposables.create {
task.cancel()
}
}
}
1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환
2. 각 책 데이터에 대해
1. 제목을 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청
2. 20개의 검색결과 데이터에 대해, <- 현재 구현 로직
1. GPT가 반환한 제목 vs 검색결과 제목 유사도 측정
2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정
3. GPT가 반환한 출판사 vs 검색결과 출판사 유사도 측정
3. 3개 유사도 점수들에 대해 각각 중요도에 따른 가중치 부여
4. 가중치 부여된 3가지 유사도 점수들을 누적합산하여 총 유사도 점수 저장
3. 총 유사도가 가장 높은 검색결과 최종반환 배열에 추가
3. 최종 반환 배열 반환
4.2 검색 결과 유사도 연산
init(
titleStrategy: CalculationStrategy,
authorStrategy: CalculationStrategy,
publisherStrategy: CalculationStrategy,
weights: [Double],
initialSearchCount: Int,
threshold: [Double]
) {
self.titleStrategy = titleStrategy
self.authorStrategy = authorStrategy
self.publisherStrategy = publisherStrategy
self.weights = weights
self.initialSearchCount = initialSearchCount
self.threshold = threshold
}
private func matchToRealBook(from sourceBook: RawBook) -> Single<BookItem?> {
// 검색 결과를 가져오는 스트림
return getSearchResults(from: sourceBook)
.flatMap { searchedResults -> Single<BookItem?> in
guard let searchedResults = searchedResults else {
return .just(nil)
}
// 각 검색 결과에 대한 유사도 계산
return Observable.from(searchedResults.enumerated())
.flatMap { index, searchedBook -> Observable<(Int, [Double])> in
self.calculateOverAllSimilarity(for: searchedBook, from: sourceBook)
.map { similarities in
(index, similarities)
}
.asObservable()
}
.toArray()
.map { results -> BookItem? in
// 가중치를 적용하여 정렬
let sortedResults = results.sorted {
self.calculateWeightedScore($0.1) > self.calculateWeightedScore($1.1)
}
guard let bestMatch = sortedResults.first else {
return nil
}
return searchedResults[bestMatch.0]
}
}
.catch { error in
print("Error in matchToRealBook: \(error)")
return .just(nil)
}
}
private func calculateOverAllSimilarity(
for searchedBook: BookItem,
from targetBook: RawBook
) -> Single<[Double]> {
// 각 전략에 대한 유사도 계산을 병렬로 실행
return Observable.zip(
titleStrategy.calculateSimilarity(searchedBook.title, targetBook.title).asObservable(),
authorStrategy.calculateSimilarity(searchedBook.author, targetBook.author).asObservable(),
publisherStrategy.calculateSimilarity(searchedBook.publisher, targetBook.publisher).asObservable()
)
.map { titleSimilarity, authorSimilarity, publisherSimilarity in
[titleSimilarity, authorSimilarity, publisherSimilarity]
}
.asSingle()
}
private func calculateWeightedScore(_ similarities: [Double]) -> Double {
return zip(similarities, weights)
.map { $0.0 * $0.1 }
.reduce(0, +)
}
4.3 최종 매칭 로직 구현
func recommendBookFor(question: String, ownedBook: [String]) -> Single<[BookItem]> {
getBookRecommendation(question: question, ownedBooks: ownedBook)
.flatMap { [weak self] result -> Single<[BookItem]> in
guard let self = self else {
return .just([])
}
let (_, recommendations) = result
return Observable.from(recommendations)
.concatMap { book -> Observable<BookItem?> in
self.matchToRealBook(from: book)
.asObservable()
.catch { error in
print("Error during book matching: \(error)")
return .just(nil)
}
}
.compactMap { $0 }
.toArray() // [BookItem] 형태로 변환
}
.catch { error in
print("Error in recommendBookFor: \(error)")
return .just([])
}
}
5. 테스트를 통한 성능 개선
5.1. ContainsStrategy, ExactMatchStrategy 구현
하이브리드 도서 매칭 시스템에 대한 구현을 완료한 후, 본격적으로 반복 테스트를 통한 Strategy, 가중치 조율을 위해 아래와 같이 ExactMatchStrategy, ContainsStrategy와 테스트 메서드를 구현해 모든 질문에 대한 테스트를 진행하였습니다.
struct ExactMatchStrategy: CalculationStrategy {
func calculateSimilarity(_ source: String, _ target: String) -> Double {
return source == target ? 1.0 : 0.0
}
}
인풋 타이틀값과 책 검색 결과의 타이틀값이 정확히 일치할 경우 1.0을 반환하는 메서드입니다.
struct ContainsStrategy: CalculationStrategy {
func calculateSimilarity(_ source: String, _ target: String) -> Double {
return target.contains(source) || source.contains(target) ? 1.0 : 0.0
}
}
서로 포함관계에 있는 경우 1.0을 반환하는 메서드입니다.
func testOverAllAccurancy() async throws {
module = EnhancedBookSearchManager(
titleStrategy: ExactMatchStrategy(),
authorStrategy: ExactMatchStrategy(),
publisherStrategy: ExactMatchStrategy(),
weights: [0.5, 0.3, 0.2],
initialSearchCount: 10
)
var cnt = 0
var total = 0
for question in questions {
try await Task.sleep(nanoseconds: 1_000_000_000 / 5) /// 책 검색 api 속도 제한 초과 방지
do {
let validBooks = try! await module.recommendBookFor(question: question,ownedBook:[]).value
total+=validBooks.count
for book in validBooks {
let myBool = await accurancyTester(question: question, title: book.title)
let debugDescription: String = """
질문에 적합한 책이 최종반환되지 않았습니다.
question: \(question)
최종 검출된 책 제목-저자:
\(book.title)-\(book.author)
"""
XCTAssertEqual(myBool, 1, debugDescription)
if myBool == 1 {cnt+=1}
}
} catch {
print(error)
}
}
print("Accurancy: \(Double(cnt)/Double(total)) out of \(total)")
}


두개 Strategy 모두 각각 58%, 66%의 정확도를 보였음을 확인할 수 있었습니다.

ContainsStrategy와 ExactMatchStrategy의 한계를 확인해볼 수 있는 케이스로 "심리학 입문" 서적의 변환 플로우가 있습니다.


GPT가 반환한 책 제목인 심리학 입문을 네이버 책 검색 api에 전달하게될 경우, 검색 api는 생물심리학 입문 책을 1번째로 반환하고 있습니다. 심리학에 대한 책을 추천해달라고 한 상황에서 생물심리학 책은 정상적인 매칭이라고 볼수 없죠.
ExactMatchStrategy를 적용할 경우, 다행히도 5번째 책의 제목이 정확히 Strategy 조건에 충족되기 때문에 1.0을 반환하고 있어, 생물심리학보다 높은 유사도로 최종반환 책에 선정됩니다만, 부제가 포함되거나 공백이 불일치하여 조건에 충족되지 않으면 유사도가 최종적으로 0으로 연산되기 때문에, 아무런 효력을 갖지 않게 됩니다.
ContainsStrategy는 이러한 변수들을 상당수 포용할 수 있다는 의미에서 더 나은 전략이라고 볼 수는 있습니다. 하지만, 의도하지 않았던 생물심리학 입문 서적도 조건에 충족되기 때문에, 우선순위에 대한 변별력없이 테스트 통과에 실패하게 됩니다.
때문에, 0이나 1만을 반환하는 이분법적 Strategy는 다수 책 검색결과들 간 변별력 분포도를 좁혀 부정확한 우선순위 설정으로 이어지며, 이로 인해 테스트 통과비율이 상대적으로 낮게 기록된다 판단했습니다. 따라서, 텍스트 간의 유사도를 세부적으로 측정할 수 있는 새로운 Strategy를 구현해야 했습니다.
5.2. 레벤슈타인 거리 알고리즘 도입
그 과정에서 레벤슈타인 거리 알고리즘을 알게 되었습니다. 레벤슈타인 거리(편집 거리)는 두 문자열 간의 차이를 수치화하는 알고리즘입니다. 한 문자열을 다른 문자열로 변환하는데 필요한 최소 편집 횟수를 계산합니다:
- 문자 삽입
- 문자 삭제
- 문자 대체
struct LevenshteinStrategy: CalculationStrategy {
func calculateSimilarity(_ source: String, _ target: String) -> Double {
// ... 레벤슈타인 거리 계산 ...
// 거리를 유사도 점수로 변환
let distance = Double(matrix[sourceLength][targetLength])
let maxLength = Double(max(sourceLength, targetLength))
return 1 - (distance / maxLength) // 높은 유사도일수록 1에 가까움
}
}
0이나 1을 반환했던 기존 2개 알고리즘과 달리, 레벤슈타인 알고리즘은 0~1사이의 소수로 점수를 정규화하여 반환합니다. 따라서, 공백, 특수문자, 오타와 같은 부분적인 불일치를 극복할 수 있을 뿐만 아니라, 검색결과 같의 유사도 기준 변별력을 극대화할 수 있다는 이점이 있습니다.
5.3. 테스트 결과 분석

위와 같이, 레벤슈타인 알고리즘을 통해 검색 결과 간 변별력을 극대화한 결과, 위와 같이 테스트마다 평균 75%의 테스트 성공률을 보이며, 17% 이상 확률 개선을 할 수 있었습니다.
6. 1차 시스템 고도화
6.1. 테스트 결과에 대한 심층분석
하지만, 책 적합성을 평가하는 테스트 통과 기준만으로는 GPT가 제시한 책과 동일한 책으로 매칭되었는지 여부를 확인해볼 수 없습니다. 유사도 연산에 필요로하는 수치가 잘못 조율되어 책 매칭이 정상적으로 되지 않아도, 네이버 책 검색 api가 반환하는 최상위 검색결과는 일반적으로 질문에 부합할 가능성이 높기 때문입니다.
때문에 매 테스트 케이스마다, 최종반환된 책에 대한 3개 유사도 결과들을 배열로 함께 확인해볼 수 있게끔 테스트 환경을 변경해, 입력 -> 출력 값을 한눈에 확인할 수 있게 테스트 환경을 재설계한 후, 본인 판단 아래 잘못 매칭된 케이스들에 대한 대한 현상파악(노가다)를 진행하였습니다.

*X는 도서 매칭 시스템 최종반환한 데이터, 즉 올바르지 않은 데이터를 의미하며, O는 본래 의도에 부합하는 보다 정답에 가까운 결과입니다.
4.1.1 파이썬 for Beginner, 철학의 위안


GPT가 처음부터 존재하지 않는 책을 반환할 수도 있다는 사실을 일깨워준 첫번째 테스트 케이스입니다...
그럼에도 불구하고 저희는 프로그래밍 초보자가 읽기 좋은 파이썬 책에 대한 질문의 책을 반환하는 것이 목표기 때문에, 제목 검색 기준 최상단에 노출된 파이썬 for Beginner가 타당할 것입니다.
하지만 제목에 포함된 괄호내용으로 인해 요구로하는 편집횟수가 크게 연산되었으며, 결과적으로 질문에 부합하지 않던 데이터 분석 관련 책(3번째 결과)에 유사도점수가 밀려 최종 반환 결과에 선택되지 못했습니다.
이러한 레벤슈타인 거리 알고리즘의 한계는 "철학의 위안" 검색 절차에서도 확인이 가능했습니다.
4.1.2 자기 앞의 생
자기 앞의 생은 조금 다른 케이스에 속하는데요. 저자의 본명인 로맹 가리가 해당 책을 발표할 당시에는 에밀 아자르라는 이름으로 발표하였기 때문에, 2개의 이름이 혼용되고 있었습니다.

사실 이는 기존 데이터를 최종 데이터로도 반환하여도 문제 없지만, 저자를 검색한 후 나오는 서적과 비교검증할 경우 보다 적합한 책이 나오는 것을 확인했습니다.
4.1.3 그럴 때 있으시죠?, 면접의 정석, 취업 면접 완전정복, 이기는 면접, 당신의 마음을 정리해드립니다.


위 4가지 케이스는 저자와 제목간 아무런 연결점을 확인하지 못한 케이스들입니다. 이는 ChatGPT로부터 존재하지 않는 책에 대해 반환을 하는 케이스이기 때문에, 에러로 처리가 되어야 하는 케이스들이라고 판단했습니다.
이 과정에서 플로우의 성공률을 높이기 위해 fetch된 검색결과들의 상세정보들을 토대로 GPT로 2차 검증을 하는 방안도 고려해보았으나, 일부 책들의 경우 상세정보가 부재하거나 짧게 기입되어있을 뿐만 아니라, GPT가 제시한 책으로 이어진다는 보장이 없기때문에 보류하게 되었습니다.
이를 통해, 면접 준비와 같은 지엽적인 주제에 대한 책을 추천할 경우, 위와 같은 존재하지 않는 책을 추천하는 상황이 더 자주 발생한다는 것을 확인할 수 있었습니다.
6.2. 개선점 도출
이를 통해 2가지 인사이트를 수집할 수 있었습니다.
- 괄호 내부 내용을 유사도 측정 대상에서 제외할 경우, 오차범위 내로 GPT가 제시한 책에 부합한 서적 매칭 확률이 높아집니다.
- 저자로 api를 호출해 반환받은 10개의 책도 비교 대상에 추가할 경우, GPT가 제시한 서적과 유의미한 유사도를 보이는 검색결과값들을 수집할 수 있습니다.
*특히 2번째 목표의 경우, 저자만으로 상위 10개 책을 추가로 가져올 경우 오히려 유사도가 전혀 없는 책이 비교대상에 추가될 수 있다고도 생각하였지만, GPT가 추천한 책은 일반적으로 해당 저자가 쓴 책 중 판매량이 높은 책을 의미하기에 상위 10개에 포함될 확률이 높다고 판단해 과감히 저자만으로 추가 검색을 진행키로 했습니다.
6.3. 매커니즘 수정
6.3.1. 괄호 내부 내용을 유사도 측정 대상에서 제외
이를 위해 기존 레벤슈타인 Strategy를 복제해, 괄호내부 source 텍스트를 비교대상에서 제외하는 새로운 Strategy를 구현했습니다. 이를 별도의 Strategy로 분리한 이유는, 저자에서는 괄호 내부 텍스트를 무시하면 안되기 때문에, 분리해 사용하고자 했기 때문입니다.
LavenshteinStrategyWithNoParenthesis() 코드
struct LevenshteinStrategyWithNoParenthesis: CalculationStrategy {
/// - Parameter source: 네이버 책검색 api DB 데이터
/// - Parameter target: GPT가 제시한 텍스트
func calculateSimilarity(_ source: String, _ target: String) -> Double {
let cleanSource = removeParenthesesContent(from: source) // 함수 새로 호출
let sourceChars = Array(cleanSource)
let targetChars = Array(target)
let sourceLength = sourceChars.count
let targetLength = targetChars.count
// 빈 문자열 처리
if sourceLength == 0 { return Double(targetLength) }
if targetLength == 0 { return Double(sourceLength) }
// 거리 계산을 위한 2차원 배열
var matrix = Array(repeating: Array(repeating: 0, count: targetLength + 1), count: sourceLength + 1)
// 첫 행과 열 초기화
for i in 0...sourceLength {
matrix[i][0] = i
}
for j in 0...targetLength {
matrix[0][j] = j
}
// 행렬 채우기
for i in 1...sourceLength {
for j in 1...targetLength {
let substitutionCost = sourceChars[i-1] == targetChars[j-1] ? 0 : 1
matrix[i][j] = min(
matrix[i-1][j] + 1, // 삭제
matrix[i][j-1] + 1, // 삽입
matrix[i-1][j-1] + substitutionCost // 교체
)
}
}
// 거리를 유사도 점수(0~1)로 변환
let distance = Double(matrix[sourceLength][targetLength])
let maxLength = Double(max(sourceLength, targetLength))
return 1 - (distance / maxLength)
}
// 새로 구현한 함수
private func removeParenthesesContent(from text: String) -> String {
var result = ""
var depth = 0
for char in text {
if char == "(" {
depth += 1
} else if char == ")" {
depth -= 1
} else if depth == 0 {
result.append(char)
}
}
return result
}
}
1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환
2. 각 책 데이터에 대해
1. 제목과 저자를 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청 <- 제목뿐만 아니라, 저자에 대한 검색결과도 fetch
2. 20개의 검색결과 데이터에 대해,
1. GPT가 반환한 제목 vs 검색결과 제목 유사도 측정
2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정
3. GPT가 반환한 출판사 vs 검색결과 출판사 유사도 측정
3. 3개 유사도 점수들에 대해 각각 중요도에 따른 가중치 부여
4. 가중치 부여된 3가지 유사도 점수들을 누적합산하여 총 유사도 점수 저장
3. 총 유사도가 가장 높은 검색결과 최종반환 배열에 추가
3. 최종 반환 배열 반환
6.3.2. 저자 기반 검색결과도 비교 대상에 추가
private func searchOverallBooks(from sourceBook: RawBook) -> Single<[BookItem]> {
// title과 author로 병렬 검색을 수행하기 위해 Observable 사용합니다.
Observable<Void>.just(())
.delay(.milliseconds(500), scheduler: MainScheduler.instance)
.flatMap { _ -> Observable<[BookItem]> in
// title 검색과 author 검색을 동시에 수행합니다.
let titleSearch = self.apiClient.searchBooks(query: sourceBook.title, limit: 10)
.asObservable()
let authorSearch = self.apiClient.searchBooks(query: sourceBook.author, limit: 10)
.asObservable()
// 결과를 하나의 배열로 병합합니다.
return Observable.zip(titleSearch, authorSearch)
.map { titleResults, authorResults in
var searchedResults = [BookItem]()
searchedResults.append(contentsOf: titleResults)
searchedResults.append(contentsOf: authorResults)
return searchedResults
}
}
.flatMap { searchedResults -> Observable<[BookItem]> in
let subTitleDivider = [":", "|", "-"]
// If no results and title contains divider, try searching with main title only
if searchedResults.isEmpty,
!subTitleDivider.filter({ sourceBook.title.contains($0) }).isEmpty,
let divider = subTitleDivider.first(where: { sourceBook.title.contains($0) }),
let title = sourceBook.title.split(separator: divider).first {
return self.apiClient.searchBooks(query: String(title), limit: 10)
.asObservable()
}
return Observable.just(searchedResults)
}
.asSingle()
}
7. 2차 시스템 고도화
7.1. 1차 고도화 결과 분석

해결
1. "파이썬 for Beginner": "파이썬 데이터 분석..."에서 파이썬 자체를 다루는 책으로 변경되며 질문에 적합한 책을 최종검출하는 것을 확인했습니다.
*본래 GPT가 올바르지 않은 저자를 제안하고 있었기에 저자는 고려대상에서 제외하였습니다.


3. "철학의 위안" & "자기 앞의 생": 저자와 제목 모두 일치된 책을 최종 반환한 것을 확인했습니다.
미해결
1. "심리학의 모든 것" 외 7권: 모두 GPT로부터 올바른 제목-저자 셋을 제공받지 않아, 올바른 결과를 여전히 추출받고 있음을 확인했습니다.
현재 미해결된 7가지 케이스가 모두 존재하지 않는 제목-저자를 GPT가 반환하고 있음을 알았으며, 정확한 제목-저자가 GPT로부터 반환된 케이스에서는 모두 정확한 결과를 검출하고 있음을 확인했습니다.
7.2. GPT 프롬프트 재설계
따라서, GPT 프롬프트의 재설계를 진행하기로 했습니다.
당신은 전문 북큐레이터입니다. 다음 지침에 따라 질문에 제일 적합한 책들을 추천해주세요:
...
필수 규칙:
- 정확한 도서명-저자명-출판사 형식 준수
- json과 마크다운 구문 제거
...
응답 전 확인사항:
- 도서명/저자명/출판사명 정확성
...
응답 전에 검증단계를 추가로 지시하였으며, 그 과정에서 제목, 저자, 출판사 간의 정확성을 다시 점검하라고 지시하였으며, 필수규칙에도 정확한이라는 키워드를 추가해 보다 정확한 세트가 반환되게끔 하였습니다.
7.3. 최종 결과 분석 및 향후 과제
하지만 그럼에도, 사라지지않는 가짜 정보들...
(심리학 입문-홍길동은 뭐..죠.....)


초록색: GPT의 잘못된 정보에서 최선의 결과를 도출한 케이스
주황색: GPT의 잘못된 정보에서 질문에 부적합한 케이스를 반환한 케이스
빨간색: GPT가 올바른 정보를 반환했음에도 부적합한 케이스를 반환한 케이스
분명 보다 더 나은 프롬프트 설계방식이 있겠지만, 여러 시도를 한 끝에 ChatGPT로부터 존재하지 않는 책 정보를 아예 막을 수는 없다는 것을 깨달았습니다. 또한 저자 유사도가 0일 경우, 아예 에러를 반환하기로 했었으나 GPT로부터 잘못된 정보를 받는 것을 아예 막을 수는 없기에 이러한 경우에도 차선책을 제시하는 것이 플로우의 성공확률을 높이기 위해 필수적임을 확신했습니다.
GPT가 올바른 제목-저자 쌍을 반환하지 않을 경우에 대해, 크게 2가지의 선택지가 있었습니다.
1. 비교대상에 있는 20개 검색결과들 중 질문에 제일 적합한 차선책을 제시
2. 다른 제목-저자 쌍을 반환하도록 다시 요청
이 2가지 선택지에 대해선 명확한 트레이드 오프가 있었습니다.
기존 비교대상들 중 질문에 적합한 차선책 제시 | 새로운 제목-저자 쌍 요청 | |
단점 | 올바른 책 안나올 가능성 존재 | 추가 네트워크 요청 필요 |
에러 처리 필요한 케이스 증가 | ||
장점 | 추가적인 네트워크 호출 없음 | GPT가 반환한 정확한 책 반환될 수 있음 |
처음에는 1번 방안으로 구현해보고자 했습니다. 그 이유는 심리학 입문-홍길동과 같이 존재하지 않는 쌍을 제시하여도, 유사도 알고리즘에 맞춰 질문에 적합한 최선의 책을 최종 반환하고 있다는 것을 확인했기 때문입니다. 주황색 박스로 감싼 로그들 또한 모두 유사한 케이스들입니다.
하지만, 사용자경험을 고려한 앱이라면 에러 처리 및 네트워크 요청과 같이 개발적, 시스템적 리소스를 더 투입하더라도, 결과적으로 더 정확한 책 결과를 반환하는 것이 더 올바르다고 결론내리게 되었습니다.
GPT가 잘못 반환한 케이스에 대한 분류 로직 및 대응 로직 고도화 과정은 다음 게시글에서 다루도록 하겠습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
Vision 프레임워크를 활용한 도서 표지 이미지 매칭 구현하기 (2) | 2025.02.16 |
---|---|
ChatGPT와 네이버 책검색 api로 도서추천 시스템 구현하기(고도화 과정 3/3) (0) | 2025.01.26 |
ChatGPT와 네이버 책검색 api로 도서추천 시스템 구현하기(설계 과정 1/3) (0) | 2025.01.23 |
클린 아키텍처 도입기 (0) | 2025.01.15 |
RxSwift 라이브러리 딥다이브 (0) | 2025.01.08 |