일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 네이티브
- ios
- 360도 뷰어
- 앱 성능 개선
- SwiftUI
- 3b52.1
- 360도 이미지 뷰어
- react
- 구조적 정체성
- requirenativecomponent
- Android
- 360도 이미지
- panorama view
- 뷰 정체성
- ssot
- launchscreen
- 리액트 네이티브
- React Native
- 리액트
- native
- React-Native
- react-native-fast-image
- privacyinfo.plist
- launch screen
- 스켈레톤 통합
- 뷰 생명주기
- 파노라마 뷰
- data driven construct
- 라이브러리 없이
- 명시적 정체성
- Today
- Total
Neoself의 기술 블로그
텍스트 유사도 기반 도서 검색 매커니즘 구현하기 (2/3) 본문
저번 게시글에서는 책 검출 알고리즘에서 네이버 책 검색 api로부터 1개 이상의 데이터를 가져올 수 있는지 테스트를 진행하였습니다.
결과에 대한 유사도를 연산해 정확도를 높히기에 앞서, 비교분석할 책 데이터가 하나도 없을 경우, 고도화 방향이 부재했기 때문입니다.
1. 책 검색 api의 결과 반환 확률 높히기
진행 결과, 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. 영문 책을 한글 제목으로 검색할 경우, 검색결과가 나오지 않음
1. 엣지케이스: 빈 스트링 값 처리
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 !query.isEmpty else { return [] } // 빈 query값에 대한 예외처리 guard 문 추가!
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)
...
}
처음에 네이버 검색 api를 호출하기 이전, guard 문을 통해 빈 스트링을 입력받는 상황에 대해 예외처리함으로써 이를 해결하였습니다.
2. 엣지케이스: 부제가 잘못된 케이스
처음에는 빙하기의 끝이라는 부제가 존재하는 책이라고 생각하였지만, 네이버 책 검색과 구글 모두 해당 부제를 지닌 설국열차 시리즈가 없음을 확인했습니다. 실질적으로 이러한 케이스는 ChatGPT에서 존재하지 않은 책을 추천한 것이기 때문에 에러로 처리를 넘겨야한다고 판단하였으나, 지극히 일반적인 상황에서의 질문에서도 이러한 잘못된 책을 추천한다는 점을 미루어 보았을 때, 에러로만 처리할 경우 이로인해 책 추천 플로우 성공확률이 큰 폭으로 낮아질 것이라 판단했습니다.
때문에, 검색결과가 없을 경우 에러처리 대신, 부제를 제거한 주제목에 대해서만 검색 api를 재호출해 최대한 많은 유사책들을 가져와 가장 유사도가 높은 책을 택 1하는 것으로 로직 방향을 수정했습니다.
let subTitleDivider = [":","|","-"]
searchedResults = try await fetchSearchResults(sourceBook.title)
// guard !searchedResults.isEmpty else { return nil } /// 검색결과 없을 경우 단순 nil 반환하는 코드 제거
/// 대신 검색결과 없을때, 부제가 다음에 있을 수 있는 특수문자 존재여부 확인 후, 있으면 주제목만 검색결과에 주입
if searchedResults.isEmpty {
/// 제목 내부에 부제 이전에 오는 특수문자 존재할 경우
if !subTitleDivider.filter({ sourceBook.title.contains($0) }).isEmpty {
if let divider = subTitleDivider.first(where: { sourceBook.title.contains($0) }),
let title = sourceBook.title.split(separator: divider).first {
searchedResults = try await fetchSearchResults(String(title))
}
} else {
/// 아니면 그냥 존재하지 않는 값으로 간주하고 nil 반환
return nil
}
}
3. 엣지케이스: 영문 책을 한글 제목으로 검색
브레이브 뉴 월드
이는 생각보다 단순하게 해결이 가능했는데요. ChatGPT의 프롬프트를 수정해 영문 책일 경우, 영문 제목과 작가를 제시해달라고 요청을 추가하면 됩니다.
이로써, 모든 37개의 테스트케이스들로부터 검색 api로부터 책 데이터를 1개 이상 받아옴을 확인했습니다.
2. 기존 유사도 측정 알고리즘의 한계
2.1. ExactMatchStrategy와 ContainsStrategy
우선 CalculationStrategy 프로토콜을 채택한 2개 구현체 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을 반환하는 메서드입니다.
의존성 주입을 통해 각 전략을 바꿔 주입해 정확도를 측정해본 결과 아래와 같이 나오게 되었는데요. 예상과 달리 오히려 ExactMatchStrategy때보다 더 정확도가 낮게 나오는 것을 확인할 수 있었습니다.
func testSearchResultAccurencyBetweenTwo() async throws {
sut = EnhancedBookSearchManager(
titleStrategies: [(ExactMatchStrategy(), 1.0)],
authorStrategies: [(ExactMatchStrategy(), 1.0)],
publisherStrategies: [(ExactMatchStrategy(), 1.0)],
weights: [0.5, 0.4, 0.1],
initialSearchCount: 10
)
var mySet = Set<String>()
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)
if let result = result, result.title != book.title {
mySet.insert(book.title)
}
} catch {
print("\(error) in \(book.title) \(book.author) \(book.publisher)")
}
}
sut = EnhancedBookSearchManager(
titleStrategies: [(ContainsStrategy(), 1.0)],
authorStrategies: [(ContainsStrategy(), 1.0)],
publisherStrategies: [(ContainsStrategy(), 1.0)],
weights: [0.5, 0.4, 0.1],
initialSearchCount: 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)
if let result = result, result.title != book.title, !mySet.contains(book.title) {
print("source: \(book.title) -> target: \(result.title)")
}
} catch {
print("\(error) in \(book.title) \(book.author) \(book.publisher)")
}
}
}
위와 같이 ExactMatchStrategy 적용 상황때에서는 발생하지 않았던 새로운 에러상황들만 추려 출력해본 결과, 아래와 같이 4개의 케이스가 추가로 통과되지 못함을 확인했습니다.
이렇게 정확도가 의도와 다르게 추출되는 상황의 경우는 "심리학 입문" 서적을 대표적인 예시로 설명해볼 수 있습니다.
심리학 입문의 경우 작가가 다르지만, 제목만 정확히 일치하는 책이 5번째에 존재했습니다.
따라서 ExactMatchStrategy 적용 시,제목이 전략에 충족됨에 따라 최고점을 기록, 최종 책으로 선정된 것입니다.
하지만, ContainsStrategy 적용 시, 전략에 충족되는 동등한 조건의 결과가 3개 더 추가됩니다. 따라서 우선순위에 변동없이 초기 1번째에 위치했던 책이 최종 책으로 추출된 것입니다.
이러한 분석을 통해 크게 2가지 결론을 내릴 수 있었습니다.
- 0이나 1만을 반환하는 Strategy는 다수 책 검색결과들 간 변별력 분포도를 좁혀 부정확한 우선순위가 설정됩니다.
- 애초에 책 검증의 정확도를 측정할 수 있는 기준이 정확하지 않습니다.
3. 레벤슈타인 거리 알고리즘 채택
따라서, 가장 먼저 0과 1만을 반환하는 이분법적 로직이 아닌, 텍스트 간의 유사도를 세부적으로 측정할 수 있는 새로운 Strategy를 구현해야 했습니다. 그 과정에서 레벤슈타인 거리 알고리즘을 알게 되었습니다.
레벤슈타인 거리(편집 거리)는 두 문자열 간의 차이를 수치화하는 알고리즘입니다. 한 문자열을 다른 문자열로 변환하는데 필요한 최소 편집 횟수를 계산합니다:
- 문자 삽입
- 문자 삭제
- 문자 대체
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사이의 소수로 점수를 정규화하여 반환합니다. 따라서, 공백, 특수문자, 오타와 같은 부분적인 불일치를 극복할 수 있을 뿐만 아니라, 검색결과 같의 유사도 기준 변별력을 극대화할 수 있다는 이점이 있습니다.
4. 오류 테스트 케이스 분석을 통한 고도화 방향 산정
각 추출된 책마다, 가장 높은 누적 유사도를 지닌 책에 대해 제목, 저자, 출판사에 대한 유사도 결과값들을 배열로 함께 확인해볼 수 있게끔 테스트 환경을 변경해, 입력 -> 출력 값을 한눈에 확인할 수 있게 테스트 환경을 재설계한 후, 아래와 같이 올바른 결과를 추출한 케이스들을 직접 추린 후, 다른 저자를 지닌 책들을 추려 각 책에 대한 현상파악(노가다)를 진행하였습니다.
4.1. 오류 케이스 심층 파악
X는 알고리즘이 추출한 데이터, 즉 올바르지 않은 데이터를 의미하며, O는 보다 정답에 가까운 결과들입니다.
4.1.1. 심리학 입문
가장먼저 심리학 입문입니다.
전혀 다른 저자의 책이 최종 책으로 반환된 배경은 잘못된 제목을 추천한 것에 있었습니다. 영어원서를 ChatGPT가 한글로 번역하는 과정에서 다른 제목을 반환하였고, 이를 토대로 검색하였기 때문에, 10개의 책들 중 저자가 일치하는 책이 검출되지 않은 것이였습니다.
하지만, 반대로 저자를 검색 query에 대입할 경우 ChatGPT가 추천하고자 했던 책인,마이어스의 심리학개론을 확인할 수 있습니다.
4.1.2 파이썬 for Beginner
하지만, 이와는 다르게, 처음부터 잘못된 저자를 반환하는 케이스도 존재했습니다.
이의 경우, 저희는 프로그래밍 초보자가 읽기 좋은 파이썬 책에 대한 질문의 책을 반환하는 것이 목표기 때문에, 제목 검색 기준 최상단에 노출된 파이썬 for Beginner가 타당할 것입니다.
이와는 별개로 레벤슈타인 알고리즘의 구조적 한계도 체감할 수 있었는데요. 부제가 포함된 책의 경우, 제목이 정확하여도 다른 제목을 지닌 책에 비해 유사도가 낮게 측정됨에 따라 유사도가 낮게 측정될 수 있다는 한계를 확인할 수 있었습니다.
4.1.3 철학의 위안
레벤 알고리즘의 구조적 한계는 철학의 위안에서도 확인이 가능했습니다.
4.1.4 자기 앞의 생
자기 앞의 생은 조금 다른 케이스에 속하는데요. 저자의 본명인 로맹 가리가 해당 책을 발표할 당시에는 에밀 아자르라는 이름으로 발표하였기 때문에, 2개의 이름이 혼용되고 있었습니다.
사실 이는 기존 데이터를 최종 데이터로도 반환하여도 문제 없지만, 저자를 검색한 후 나오는 서적과 비교검증할 경우 보다 적합한 책이 나오는 것을 확인했습니다.
4.1.5 그럴 때 있으시죠?, 면접의 정석, 취업 면접 완전정복, 이기는 면접, 당신의 마음을 정리해드립니다.
위 4가지 케이스는 저자와 제목간 아무런 연결점을 확인하지 못한 케이스들입니다. 이는 ChatGPT로부터 존재하지 않는 책에 대해 반환을 하는 케이스이기 때문에, 에러로 처리가 되어야 하는 케이스들이라고 판단했습니다.
이 과정에서 플로우의 성공률을 높이기 위해 fetch된 검색결과들의 상세정보들을 토대로 GPT로 2차 검증을 하는 방안도 고려해보았으나, 일부 책들의 경우 상세정보가 부재하거나 짧게 기입되어있을 뿐만 아니라, GPT가 제시한 책으로 이어진다는 보장이 없기때문에 보류하게 되었습니다.
이를 통해, 면접 준비와 같은 지엽적인 주제에 대한 책을 추천할 경우, 위와 같은 존재하지 않는 책을 추천하는 상황이 더 자주 발생한다는 것을 확인할 수 있었습니다.
5. 책 검출 매커니즘 고도화-1
이를 통해 2개의 구현목표를 세웠습니다.
1. 1.2의 파이썬 for Beginners와 같이 괄호 안의 내용을 유사도 측정 대상에서 제외
2. 1.1의 케이스와 같이 영어원서의 잘못된 제목변경에 대응하기 위해, 저자로 api를 호출해 반환받은 10개의 책도 비교 대상에 추가
*특히 2번째 목표의 경우, 저자만으로 상위 10개 책을 추가로 가져올 경우 오히려 유사도가 전혀 없는 책이 비교대상에 추가될 수 있다고도 생각하였지만, GPT가 추천한 책은 일반적으로 해당 저자가 쓴 책 중 판매량이 높은 책을 의미하기에 상위 10개에 포함될 확률이 높다고 판단해 과감히 저자만으로 추가 검색을 진행키로 했습니다.
5.1. 괄호 안의 내용을 유사도 측정 대상에서 제외
이를 위해 기존 레벤슈타인 Strategy를 복제해, 괄호내부 source 텍스트를 비교대상에서 제외하는 새로운 Strategy를 구현했습니다. 이를 별도의 Strategy로 분리한 이유는, 저자에서는 괄호 내부 텍스트를 무시하면 안되기 때문에, 분리해 사용하고자 했기 때문입니다.
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
}
}
func testSearchResultAccurencyWithLavenStein() async throws {
sut = EnhancedBookSearchManager(
// 제목에 대해서만 새로 구현한 Strategy 주입
titleStrategies: [(LevenshteinStrategyWithNoParenthesis(), 1.0)],
authorStrategies: [(LevenshteinStrategy(), 1.0)],
publisherStrategies: [(LevenshteinStrategy(), 1.0)],
weights: [0.5, 0.4, 0.1],
initialSearchCount: 10
)
for (title,author,pulisher,_) in testCases {
try await Task.sleep(nanoseconds: 1_000_000_000 / 15)
let book = RawBook(title: title, author: author, publisher: pulisher)
do {
let result = try await sut.process(book)
if let result = result {
print("\(result.similarities.map{String(format:"%.2f",$0)}) :\(book.title)-\(book.author) -> \(result.book.title)-\(result.book.author)")
} else {
print("failed for: \(book.title)-\(book.author)")
}
} catch {
print("\(error) in \(book.title) \(book.author) \(book.publisher)")
}
}
}
5.2. 저자로 검색한 10개 책도 비교 대상에 추가
func process(_ sourceBook: RawBook) async throws -> (book: BookItem, similarities: [Double])? {
var searchedResults: [BookItem] = []
/// 제목으로 네이버 책 api에 검색하여 나온 상위 책 10개를 반환받습니다.
async let searchByTitle = fetchSearchResults(sourceBook.title)
/// 저자로 네이버 책 api에 검색하여 나온 상위 책 10개를 반환받습니다.
async let searchByAuthor = fetchSearchResults(sourceBook.author)
let (searchByTitleResult, searchByAuthorResult) = try await (searchByTitle,searchByAuthor)
searchedResults.append(contentsOf: searchByTitleResult)
searchedResults.append(contentsOf: searchByAuthorResult)
...
}
5.3. 고도화 이후 테스트
해결
1. "파이썬 for Beginner": "파이썬 데이터 분석..."에서 파이썬 자체를 다루는 책으로 변경되며 질문에 적합한 책을 최종검출하는 것을 확인했습니다.
*본래 GPT가 올바르지 않은 저자를 제안하고 있었기에 저자는 고려대상에서 제외하였습니다.
3. "철학의 위안" & "자기 앞의 생": 저자와 제목 모두 일치된 책을 최종 반환한 것을 확인했습니다.
미해결
1. "심리학의 모든 것" 외 7권: 모두 GPT로부터 올바른 제목-저자 셋을 제공받지 않아, 올바른 결과를 여전히 추출받고 있음을 확인했습니다.
현재 미해결된 7가지 케이스가 모두 존재하지 않는 제목-저자를 GPT가 반환하고 있음을 알았으며, 정확한 제목-저자가 GPT로부터 반환된 케이스에서는 모두 정확한 결과를 검출하고 있음을 확인했습니다.
5.4. GPT 프롬프트 재설계
따라서, GPT 프롬프트의 재설계를 진행하기로 했습니다.
당신은 전문 북큐레이터입니다. 다음 지침에 따라 질문에 제일 적합한 책들을 추천해주세요:
...
필수 규칙:
- 정확한 도서명-저자명-출판사 형식 준수
- json과 마크다운 구문 제거
...
응답 전 확인사항:
- 도서명/저자명/출판사명 정확성
...
응답 전에 검증단계를 추가로 지시하였으며, 그 과정에서 제목, 저자, 출판사 간의 정확성을 다시 점검하라고 지시하였으며, 필수규칙에도 정확한이라는 키워드를 추가해 보다 정확한 세트가 반환되게끔 하였습니다.
하지만 그럼에도, 사라지지않는 가짜 정보들...
(심리학 입문-홍길동은 뭐..죠...... 그럼 제목이 잘못 번역된 것이 아니였네요...)
초록색: GPT의 잘못된 정보에서 최선의 결과를 도출한 케이스
주황색: GPT의 잘못된 정보에서 질문에 부적합한 케이스를 반환한 케이스
빨간색: GPT가 올바른 정보를 반환했음에도 부적합한 케이스를 반환한 케이스
분명 보다 더 나은 프롬프트 설계방식이 있겠지만, 여러 시도를 한 끝에 ChatGPT로부터 존재하지 않는 책 정보를 아예 막을 수는 없다는 것을 깨달았습니다. 또한 저자 유사도가 0일 경우, 아예 에러를 반환하기로 했었으나 GPT로부터 잘못된 정보를 받는 것을 아예 막을 수는 없기에 이러한 경우에도 차선책을 제시하는 것이 플로우의 성공확률을 높이기 위해 필수적임을 확신했습니다.
GPT가 올바른 제목-저자 쌍을 반환하지 않을 경우에 대해, 크게 2가지의 선택지가 있었습니다.
1. 비교대상에 있는 20개 검색결과들 중 질문에 제일 적합한 차선책을 제시
2. 다른 제목-저자 쌍을 반환하도록 다시 요청
이 2가지 선택지에 대해선 명확한 트레이드 오프가 있었습니다.
기존 비교대상들 중 질문에 적합한 차선책 제시 | 새로운 제목-저자 쌍 요청 | |
단점 | 올바른 책 안나올 가능성 존재 | 추가 네트워크 요청 필요 |
에러 처리 필요한 케이스 증가 | ||
장점 | 추가적인 네트워크 호출 없음 | GPT가 반환한 정확한 책 반환될 수 있음 |
처음에는 1번 방안으로 구현해보고자 했습니다. 그 이유는 심리학 입문-홍길동과 같이 존재하지 않는 쌍을 제시하여도, 유사도 알고리즘에 맞춰 질문에 적합한 최선의 책을 최종 반환하고 있다는 것을 확인했기 때문입니다. 주황색 박스로 감싼 로그들 또한 모두 유사한 케이스들입니다.
하지만, 사용자경험을 고려한 앱이라면 에러 처리 및 네트워크 요청과 같이 개발적, 시스템적 리소스를 더 투입하더라도, 결과적으로 더 정확한 책 결과를 반환하는 것이 더 올바르다고 결론내리게 되었습니다.
GPT가 잘못 반환한 케이스들에 대한 대응 로직 고도화 과정은 다음 게시글에서 다루도록 하겠습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
텍스트 유사도 기반 도서 검색 매커니즘 구현하기 (3/3) (0) | 2025.01.26 |
---|---|
XCTest 가이드 (0) | 2025.01.23 |
텍스트 유사도 기반 도서 검색 매커니즘 구현하기 (1/3) (0) | 2025.01.23 |
클린 아키텍처 도입기 (0) | 2025.01.15 |
RxSwift 라이브러리 딥다이브 (0) | 2025.01.08 |