일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
- react-native-fast-image
- 명시적 정체성
- React-Native
- ios
- SwiftUI
- 3b52.1
- 360도 뷰어
- 360도 이미지
- 라이브러리 없이
- launch screen
- panorama view
- React Native
- privacyinfo.plist
- 앱 성능 개선
- 360도 이미지 뷰어
- react
- 네이티브
- data driven construct
- native
- 구조적 정체성
- requirenativecomponent
- launchscreen
- 뷰 정체성
- 파노라마 뷰
- Android
- 리액트
- 스켈레톤 통합
- 리액트 네이티브
- 뷰 생명주기
- ssot
- Today
- Total
Neoself의 기술 블로그
텍스트 유사도 기반 도서 검색 매커니즘 구현하기 (1/3) 본문
ChatGPT api를 활용해 사용자가 제시한 질문에 적합한 책을 추천하는 로직을 개발하면서 발생한 기술 도전과제의 해결과정을 정리한 글입니다.
ChatGPT 반환값의 후가공 과정에 대한 이해도를 높히고자 하는 개발자분들께 도움이 되었으면 좋겠습니다.
0. 배경
구현이 필요한 핵심로직은 아래 2개로 정리할 수 있습니다.
- 책 추천: AI를 활용해 사용자의 질문에 답이 될 수 있는 책을 추천하고, 앱 내 서재 탭에 추가할 수 있도록 하기
- 책 추가: 사용자가 책표지를 촬영할 경우, OCR촬영된 책 앱 내 서재 탭에 추가하기
위 기능을 구현하기 위해선 결국 아래와 같은 기술적 고민으로 이어졌습니다.
1. 사진촬영을 통해 책을 인식하는 로직
2. ChatGPT로 책을 추천받는 로직
1번과 2번 로직의 경우, ChatGPT만을 사용하는 방향을 처음에 고안했었습니다.
ChatGPT의 이미지 모달 인식률이 최근들어 매우 높아져 큰 개발비용없이 사진을 통핸 책인식 로직 구현이 가능했으며, 프롬프트 설계를 통해 인식된 책에 대한 상세정보 요청 또한 가능했기 때문입니다. 이러한 이점은 개발기간이 촉박했던 상황에도 무시하지 못할 메리트였습니다.
하지만, 이의 경우, gpt api가 부정확한 정보를 제공할때, 대응이 어렵다는 문제점이 있었습니다. 아무리 프롬프트를 체계적으로 설계하여도, 요구한 JSON 형식에 맞춰 데이터를 반환하지 않는 경우가 발생할 수 있으며, 설령 형식을 맞춰 반환하더라도 존재하지 않는 책에 대한 결과값을 반환할 수 있기 때문입니다.
특히, 1번과 2번 로직을 통해 추가되는 책들을 모두 앱 내부 서재 탭에서 확인할 수 있게 하기 위해선 두 플로우의 최종 반환값 통일이 필요했기에, 이러한 변수는 치명적이였습니다.
*ChatGPT 이미지 모달 유지보수 비용이 비싸다는 점도 무시못했습니다...
이를 해결하기 위해 gpt api를 통해 반환받는 데이터를 바로 사용하는 대신, 책 검색 api의 query에 대입해 검증하고, api로부터 최종 책 데이터를 반환받는 로직을 구상하게 되었습니다.
위 방식은 아래와 같은 장점이 있습니다.
- AI가 존재하지 않는 책을 추천할 경우 검색 api로부터 책 데이터를 받지 않게 됨에 따라 자체 필터링 가능
- 검색 api가 제공하는 공통 책 데이터모델을 최종사용하기 때문에 반환값 타입 불일치 문제 해결
따라서, 위 방향에 맞춰 책 추천 및 책 추가 플로우를 아래와 같이 구체화해볼 수 있었습니다.
책 추가와 책 추천의 플로우를 비교해보았을때, 데이터를 추가하는 방식은 상이하지만, 결국 책 검색 api의 검색값에 대입할 수 있는 텍스트를 추출한 후, 책 검색 api로 검증해 api가 반환하는 데이터모델을 최종모델로 사용한다는 점에서 일부 로직들이 공유될 수 있다는 것을 확인할 수 있었습니다.
따라서 저희는, 두 로직을 하나의 서비스 레이어로 통합해, 검색 api를 활용해 검증 및 최종 데이터 추출 로직을 재사용 가능한 모듈로 설계하기로 초기방향을 수립하게 되었습니다.
1. 책 검색 api 선정
본격적으로 구현하기에 앞서, 책 검색 api를 비교분석해보았습니다.
크게 네이버 책 검색 api와 카카오 책 검색 api를 두고 분석해본 결과, 아래와 같은 차이점을 파악했습니다.
1. 작가가 여러명일 경우 카카오는 배열로 작가를 나누어 반환하는 반면, 네이버는 단일 string으로 합쳐 반환
2. 네이버 api가 반환하는 이미지 크기가 카카오 api보다 일반적으로 더 큼
최종적으로 네이버 책 검색 api를 선정하였는데요. 이유는 내 서재 탭에서 각 책의 이미지를 통해 리스트를 표시하게 되는만큼 이미지 크기가 UI 구성에 핵심이였으며, 여러명의 작가가 하나의 String으로 묶여 반환될 경우, String.split(separator:",")을 통해 배열형태로 쉽게 변환이 가능할 것이라 판단했기 때문입니다.
2. 구현 방향 산정
구현이 번복되는 일을 최소화하기 위해, 알고리즘 방향을 설계하기에 앞서 고려해야하는 엣지케이스들을 리스트업해봤습니다.
제목 관련 엣지케이스
1. 일부 글자 누락 (ex. Swft -> Swift)
2. 띄어쓰기 불일치 (ex. 클린 코드 vs 클린코드)
3. 영문/한글 혼용 (ex. Real 마케팅 vs 리얼 마케팅)
4. 부제 포함여부 불일치 (ex. 클린 코드 vs 클린코드: 애자일 소프트웨어 장인 정신)
5. 개정판 표기 (ex. "리팩터링 2판" vs "리팩터링")
6. 시리즈물 표기 방식 (ex. "해리포터 1" vs "해리포터와 마법사의 돌")
저자 관련 엣지케이스
1. 이름 순서(First name, Last name) (ex. 마틴 파울러 vs 파울러 마틴)
2. 한글/ 영문 표기 혼용 (ex 로버트 마틴 vs Robert C. Martin)
이후, 이러한 엣지케이스들에 대해 네이버 책 검색 api는 어느정도 대응이 가능한지도 파악해보았습니다.
가장 먼저 일부글자가 누락되는 케이스를 그대로 검색 query에 기입해보았습니다.
위 사진은 Naver 책검색 api에 query를 입력해 첫번째 결과값을 확인할 수 있는 데모 앱의 캡처본인데요. 흥미롭게도 Swift에 i가 누락된 상태로 기입해도 의도했던 제목의 책이 반환되는 것을 볼 수 있습니다.
3. 구현 방향 산정
따라서, 이러한 엣지케이스들을 고려해 아래와 같은 로직을 구상하게 되었습니다.
- 제목, 저자, 출판사를 Input으로 가져옴
- 제목을 query에 입력한 후 20개의 데이터 요청
- 20개의 데이터에 대해
- 데이터의 제목과 chatGPT가 초기에 추천한 제목 간 유사도 측정
- 데이터의 저자과 chatGPT가 초기에 추천한 저자 간 유사도 측정
- 데이터의 출판사와 chatGPT가 초기에 추천한 출판사 간 유사도 측정
- 제목 유사도, 저자 유사도, 출판사 유사도에 각각 중요도에 따라 가중치 부여
- 가중치 부여된 3개 유사도를 누적해 총 유사도 점수 저장
- 총 유사도 점수가 가장 높은 책을 선정
- 최고 유사도 점수가 일정 기준 못 미칠 시, 존재하지 않는 서적으로 분류 후 에러처리, 기준 상회할 경우 반환
편의를 위해 위에 서술한 로직을 텍스트 유사도 기반 도서 검색 알고리즘으로 칭하겠습니다.
4. 테스트를 위한 클래스 구조 설계
검색된 도서와 타겟 도서 간의 정확도를 위해, 다양한 유사도 계산 알고리즘을 구현하고 테스트를 통해 알고리즘들을 동적으로 주입해 정확도를 비교검증해보는 것이 필수라고 판단했습니다. 때문에 최적의 알고리즘 조합을 위해 Strategy 패턴을 도입했습니다.
protocol CalculationStrategy {
func calculateSimilarity(_ source: String, _ target: String) -> Double
}
클래스 타입으로 알고리즘을 구현하게 된 이유는 아래와 같이 정리해볼 수 있습니다.
- async/await 기반 메서드들의 일관된 실행 컨텍스트 제공
- 초기화 시점에 Strategy들과 가중치를 주입받아 불변성 보장
class EnhancedBookSearchManager {
...
}
최대한 유사한 케이스에서의 Reference 자료를 조사해보았으나, 참고할 수 있는 자료가 없었습니다. 때문에, 아무런 데이터도 검증되지 못한 상황에서 최대한 모든 변수들을 테스트 결과를 통해 비교해보고 검증해볼 필요가 있었습니다.
때문에, 당장은 시간이 오래 걸리더라도 연산에 필요로하는 매개변수들을 최대한 외부에서 주입받도록 설계해 테스트를 선행한 후, 방향이 확정되면 의존성 주입을 줄이는 방향으로 클래스를 설계하기로 했습니다.
init(
titleStrategies: [(CalculationStrategy, Double)], // 제목 유사도 계산에 사용될 알고리즘, 가중치
authorStrategies: [(CalculationStrategy, Double)], // 저자 유사도 계산...
publisherStrategies: [(CalculationStrategy, Double)], // 출판사 유사도 계산...
weights: [Double], // 3개 유사도 값들에 대한 가중치값들
initialSearchCount: Int // 최초 책 검색 시 가져올 상위 책 데이터 개수
)
최종 코드 EnhancedBookSearchManager
import Foundation
enum CalculationError: Error {
case invalid
case noBook
}
/// 테스트 간에 전략 구현 내용 및 스위칭이 유연하게 적용될 수 있어야 함. 미리 프로토콜로 분리
protocol CalculationStrategy {
func calculateSimilarity(_ source: String, _ target: String) -> Double
}
class EnhancedBookSearchManager {
private let clientId = ""
private let clientSecret = ""
private let titleStrategies: [(CalculationStrategy, Double)]
private let authorStrategies: [(CalculationStrategy, Double)]
private let publisherStrategies: [(CalculationStrategy, Double)]
private let weights: [Double]
private let initialSearchCount: Int
init (
titleStrategies: [(CalculationStrategy, Double)],
authorStrategies: [(CalculationStrategy, Double)],
publisherStrategies: [(CalculationStrategy, Double)],
weights: [Double],
initialSearchCount: Int
) {
self.titleStrategies = titleStrategies
self.authorStrategies = authorStrategies
self.publisherStrategies = publisherStrategies
self.weights = weights
self.initialSearchCount = initialSearchCount
}
func process(_ sourceBook: RawBook) async throws -> BookItem? {
var searchedResults: [BookItem] = []
/// 제목으로 네이버 책 api에 검색하여 나온 상위 책 10개를 반환받습니다.
searchedResults = try await fetchSearchResults(sourceBook.title, count: initialSearchCount)
/// 하나도 없을 경우 올바른 제목이 아니라 간주하고, 1차 필터링합니다.
guard !searchedResults.isEmpty else { return nil }
/// 각 책을 유사도와 매핑시켜 저장하기 위한 튜플 배열을 생성합니다.
var results = [(index:Int,value:Double)]()
/// 각 책마다 누적 유사도 값을 계산하고 배열에 id값과 함께 저장합니다.
for (index, searchedBook) in searchedResults.enumerated() {
let num = calculateOverAllSimilarity(for: searchedBook, from: sourceBook)
results.append((index:index,value:num))
}
/// 가장 유사도가 높은 책 검출 위해 sort 실행
// TODO: 우선순위 큐로 변경하기
results.sort(by: {$0.1 > $1.1})
// 가장 높은 유사도 보유한 책 반환
return searchedResults[results[0].index]
}
func calculateOverAllSimilarity(for searchedBook: BookItem, from targetBook: RawBook) -> Double {
let values = [
titleStrategies.reduce(0.0) { result, strategy in
result + (strategy.0.calculateSimilarity(searchedBook.title, targetBook.title) * strategy.1)
}, authorStrategies.reduce(0.0) { result, strategy in
result + (strategy.0.calculateSimilarity(searchedBook.author, targetBook.author) * strategy.1)
}, publisherStrategies.reduce(0.0) { result, strategy in
result + (strategy.0.calculateSimilarity(searchedBook.publisher, targetBook.publisher) * strategy.1)
}
]
var sum = 0.0
for id in 0..<3 {
sum+=values[id]*weights[id]
}
return sum
}
private func fetchSearchResults(_ query: String,count: Int = 20) async throws -> [BookItem] {
let queryString = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
let urlString = "https://openapi.naver.com/v1/search/book.json?query=\(queryString)&display=10&start=1"
guard let url = URL(string: urlString) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.addValue(clientId, forHTTPHeaderField: "X-Naver-Client-Id")
request.addValue(clientSecret, forHTTPHeaderField: "X-Naver-Client-Secret")
let (data,_) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(BookResponse.self, from: data)
return response.items
}
}
5. 테스트환경 구축
테스트에 사용될 Input값 산정
먼저 책 추가 플로우의 시작점인 사용자가 질문하는 시점부터 생각해보았습니다.
사용자가 책 추천 시 생각할 수 있는 질문들을 나열해보았습니다.
장르/주제 관련:
"심리학 입문서 추천해주세요""
경영/리더십 도서 중 베스트셀러는?"
"SF 소설 중 우주를 배경으로 한 작품 있나요?"
독자 수준:
"프로그래밍 초보자가 읽기 좋은 파이썬 책은?"
"고등학생이 이해하기 쉬운 철학책 추천해주세요"
"영어 중급자에게 적합한 원서 추천해주세요"
상황/목적:
"우울할 때 읽으면 좋은 책 알려주세요"
"면접 준비하는데 도움될 만한 책은?"
"주말에 하루 만에 읽을 수 있는 가벼운 책 추천해주세요"
기타:
"최근 한 달간 가장 많이 팔린 책은?"
"신간 중에서 추천할 만한 책이 있나요?"
"이 분야의 고전/필독서는 뭐가 있나요?"
이후 프롬프트 설계가 마친 ChatGPT 4.0 mini API에 위 질문을 입력하였고, api로부터 반환받은 답변을 매핑해 아래와 같이 테스트 케이스들을 구성할 수 있었습니다. 이와는 별개로, 존재하지 않는 책에 대한 값, 빈 스트링 값과 같은 엣지케이스들도 추가했습니다.
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), // MARK: 작가가 괄호로 있음
("브레이브 뉴 월드", "올더스 헉슬리", "펭귄북스", true), // MARK: 영어로 존재
/// 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 sut.process(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)")
}
우선 최종 책 자체가 추출되는지 여부를 테스트하기 위해 위와 같이 엣지케이스들을 포함하여 제목, 저자, 출판사 3개 값을 알고리즘에 주입시킨후, 최종 반환값으로 nil을 반환하지 않는 케이스 비율을 파악하고자 했습니다.
6. 기초 로직에 대한 트러블 슈팅
책 검출 정확도를 높히기에 앞서, 존재하는 책일 경우 텍스트값에서 책 검색 api값을 도출하는 과정 간에 책 검색 결과가 1개라도 추출되는 것을 보장하는 것을 목표로 1차 테스트환경을 설계했습니다.
현재 테스트에서는 존재여부 로직 자체를 테스트하는 것이 아니기 때문에 아래와 같이 유사도 수치의 하한선을 제거해 api로부터 책 검색결과가 나오면 첫번째 요소를 바로 반환하도록 임시 변경하였습니다.
func process(_ sourceBook: RawBook) async throws -> BookItem? {
...
// 기존에는 가장 높은 유사도 점수가 0.4 이하일 경우 nil 반환
// -> 현재는 하한선없이 가장 높은 유사도 보유한 책 반환
return searchedResults[results[0].index]
}
1차 테스트 결과, nil을 반환하는 비율이 매 시도마다 변경되고 있음을 확인할 수 있었는데요...
정확도 자체가 낮게 검출되는 것은 알고리즘을 변경하면서 정확도를 향상시킴으로써 해결이 가능하지만, 고정된 3개 텍스트값에 대해 매번 결과가 다르게 반환되다 보니, 결과값을 변화시키는 트리거를 파악하기 쉽지 않았습니다.
매번 변경되는 정확도의 트리거는 네이버 검색 api에 대한 네트워크 통신 메서드에 있었습니다. 네트워크 통신 간 에러가 반환될 경우, 정상 통신시 반환되어야할 책 데이터를 담는 json이 아닌, errorMessage, errorCode가 포함된 배열이 반환되면서 JSON Decode 에러가 발생하는 것이였는데요.
Decode 에러 발생 시, 반환되는 raw JSON을 확인해보니 아래와 같은 에러를 확인할 수 있었습니다.
네이버 개발자 포럼에 해당 이슈를 검색해보니, 1초에 10번 이상의 요청을 진행할 경우 반환되는 에러라고 합니다...
for (title,author,pulisher,shouldSucceed) in testCases {
try await Task.sleep(nanoseconds: 1_000_000_000 / 15) /// 책 검색 api 속도 제한 초과 방지
let book = RawBook(title: title, author: author, publisher: pulisher)
do {
let result = try await sut.process(book)
cnt+=1
XCTAssertEqual(result != nil, shouldSucceed, book.title)
} catch {
print("\(error) in \(book.title)")
}
}
따라서 매 테스트 케이스를 실행시킬때마다, 1/15초 간 Task를 중지하도록 해 api 호출 속도를 늦췄습니다...
책 검출 정확도를 높이는 과정은 다음 글에 이어 작성하도록 하겠습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
텍스트 유사도 기반 도서 검색 매커니즘 구현하기 (2/3) (0) | 2025.01.25 |
---|---|
XCTest 가이드 (0) | 2025.01.23 |
클린 아키텍처 도입기 (0) | 2025.01.15 |
RxSwift 라이브러리 딥다이브 (0) | 2025.01.08 |
Understanding Swift Performance 정리 (0) | 2025.01.07 |