Skip to content

feat(#31): 도서 검색 MySQL FULLTEXT 옵션 추가 - #33

Merged
fnzl54 merged 2 commits into
mainfrom
feat/#31
Aug 20, 2026
Merged

feat(#31): 도서 검색 MySQL FULLTEXT 옵션 추가#33
fnzl54 merged 2 commits into
mainfrom
feat/#31

Conversation

@fnzl54

@fnzl54 fnzl54 commented Aug 20, 2026

Copy link
Copy Markdown
Member

연관 이슈

작업 사항

  • feat(#31): 도서 검색 MySQL FULLTEXT 옵션 추가
    • BookRepository.searchFullText(): MATCH AGAINST 기반 네이티브 쿼리 추가
    • search.engine=mysql_fulltext로 활성화
  • feat(#31): 검색 성능 비교 (FULLTEXT) devtools
    • ExplainAnalyzer(LIKE vs FULLTEXT EXPLAIN ANALYZE 비교)
    • 테스트 용 MysqlDataGenerator대량 더미데이터 생성 추가

테스트

  • 로컬 서버 테스트 완료
  • devtools ExplainAnalyzer 실행해 LIKE vs FULLTEXT 실행계획 비교 확인 (관련 노션 - 3장)

주의 사항 및 참고사항

  • WITH PARSER ngram FULLTEXT 인덱스는 ddl-auto로 자동 생성 X
    • 테스트 데이터의 경우 MysqlDataGenerator 실행 시 함께 생성
    • 실데이터가 있는 경우 ALTER TABLE book ADD FULLTEXT INDEX idx_book_title_author_ft (title, author) WITH PARSER ngram; 실행
  • 해당 PR에서 작업한 FULLTEXT로 실행하려면 yml 파일 search.engine=mysql_fulltext 수정

@fnzl54 fnzl54 self-assigned this Aug 20, 2026
init {
require("localhost" in jdbcUrl) {
"devtools 도구는 localhost DB만 대상으로 실행할 수 있습니다. jdbcUrl=$jdbcUrl"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] localhost 가드가 substring 매칭이라 프로덕션/공유 DB를 실수로 TRUNCATE할 수 있음

Problem: require("localhost" in jdbcUrl)는 문자열 어딘가에 "localhost"가 포함되기만 하면 통과합니다. SSH 터널로 원격(스테이징/공유) MySQL을 localhost:xxxx로 포워딩해 접속하는 경우, 실제로는 원격 DB인데도 이 가드를 그대로 통과해 MysqlDataGeneratorTRUNCATE TABLE loan/book_item/book을 실행해 버립니다.

Evidence: MysqlDataGenerator.ktmain()은 가드 통과 후 곧바로 세 테이블을 TRUNCATE합니다. 이 검사가 유일한 안전장치인데, 로컬 개발자가 흔히 쓰는 SSH 포트포워딩 시나리오에서 정확히 실패합니다.

Fix direction: URL을 파싱해 호스트가 정확히 localhost/127.0.0.1인지 검사하거나, 최소한 실행 전 명시적 확인 플래그(DEVTOOLS_CONFIRM_TRUNCATE=true)를 요구하세요.

val host = java.net.URI(jdbcUrl.removePrefix("jdbc:")).host
require(host in setOf("localhost", "127.0.0.1")) {
    "devtools 도구는 localhost DB만 대상으로 실행할 수 있습니다. host=$host"
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 완료

URL 파싱 후 host 정확히 비교(host == "localhost" || "127.0.0.1")로 변경해 substring 오탐 가능성 제거

?: "jdbc:mysql://localhost:3306/${System.getenv("DB_NAME") ?: "library"}" +
"?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useUnicode=true&rewriteBatchedStatements=true"
val username: String = System.getenv("DB_USERNAME") ?: "library_mysql"
val password: String = System.getenv("DB_PASSWORD") ?: "library_mysql"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] devtools 소스에 DB 자격증명 기본값을 하드코딩

Problem: DB_USERNAME/DB_PASSWORD가 미설정일 때 "library_mysql"이라는 실제 자격증명 문자열이 커밋된 소스 코드에 기본값으로 박혀 있습니다. 저장소 전체를 확인해도(docker-compose.yml, 애플리케이션 설정 등) 다른 곳에는 이런 리터럴 기본값이 커밋되어 있지 않고 전부 환경변수로만 주입되는데, 이 파일만 예외적으로 자격증명을 코드에 남깁니다.

Evidence: src/devtools/kotlin/org/library/devtools/db/MysqlConfig.kt:11-12. docker-compose.yml${DB_USERNAME}, ${DB_PASSWORD}처럼 기본값 없이 필수 환경변수로만 구성되어 있어, 이 PR이 도입한 패턴이 기존 컨벤션과 다릅니다.

Fix direction: 기본값을 제거하고 미설정 시 명확히 실패시키세요(이미 jdbcUrl 가드에서 쓰는 require 패턴과 동일하게).

val username: String = requireNotNull(System.getenv("DB_USERNAME")) { "DB_USERNAME 환경변수가 필요합니다." }
val password: String = requireNotNull(System.getenv("DB_PASSWORD")) { "DB_PASSWORD 환경변수가 필요합니다." }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 완료

docker-compose.yml과 동일한 방식으로 프로젝트 루트의 .env를 파싱 DB_USERNAME/DB_PASSWORD/DB_NAME을 읽어오도록 수정

publisher = publisher,
isbn = isbn,
bookItemCount = bookItemCount,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Like/FullText 어댑터 간 소장본 카운트·매핑 로직 중복

Problem: bookItemCounts 계산과 Book.toDocument() 매핑(약 15~18줄)이 MysqlLikeBookSearchAdapter와 완전히 동일하게 복제되어 있습니다. PR 설명에 언급된 OpenSearch 비교까지 실 구현이 들어가면 동일 로직이 세 번째로 복제될 가능성이 있어, 한쪽만 수정하고 다른 쪽을 놓치는 실수가 생기기 쉽습니다.

Evidence: src/main/kotlin/org/library/adapter/book/search/mysql/MysqlFullTextBookSearchAdapter.kt:31-48MysqlLikeBookSearchAdapter.kt의 동일 블록을 비교하면 검색 쿼리 호출부(1줄)만 다르고 나머지는 동일합니다.

Fix direction: 공통 로직(카운트 조회 + toDocument 매핑)을 BookSearchPort 구현체들이 공유하는 추상 클래스나 최상위 확장 함수로 추출하세요.

internal fun Page<Book>.enrichWithItemCounts(
    bookItemRepository: BookItemRepository,
): Page<BookDocument> {
    val bookIds = content.map { it.id }
    val counts = if (bookIds.isEmpty()) emptyMap()
        else bookItemRepository.countActiveByBookIdIn(bookIds).associate { it.bookId to it.itemCount }
    return map { it.toDocument(counts[it.id] ?: 0L) }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

중복은 확인하였으나 OpenSearch 작업 시 동일 로직이 반복될 경우 공통 함수로 추출 예정

where b.deleted_at is null
and match(b.title, b.author) against(:q in natural language mode)
) t
where t.score > 0.0001

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] 스코어 임계값 0.0001이 매직 넘버로 3곳에 중복

Problem: score > 0.0001 임계값이 이유 설명 없이 메인 쿼리와 countQuery에 중복되고, src/devtools/kotlin/org/library/devtools/benchmark/ExplainAnalyzer.ktFULLTEXT_SQL에도 동일 값이 하드코딩되어 있습니다. 나중에 튜닝이 필요해지면 여러 곳을 빠짐없이 동시에 고쳐야 하고, 왜 이 값인지 근거가 없어 유지보수 시 임의로 바뀔 위험이 있습니다.

Evidence: BookRepository.kt:47, BookRepository.kt:57, ExplainAnalyzer.ktFULLTEXT_SQL 상수.

Fix direction: 이름 있는 상수로 추출하고, 왜 이 임계값을 골랐는지 주석으로 남기세요 (예: 관련성 낮은 노이즈 매치를 걸러내기 위함이라는 의도).

// MATCH ... AGAINST(... IN NATURAL LANGUAGE MODE) 스코어가 0에 매우 가까운 저관련성 매치를 제외하기 위한 임계값
private const val FULLTEXT_MIN_SCORE = 0.0001

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 완료

0.001은 노이즈성 매치를 걸러내기 위해 임의로 설정한 임계값
FULLTEXT_MIN_SCORE는 상수로 처리하여 전역으로 사용하도록 수정

@Transactional(readOnly = true)
@ConditionalOnProperty(name = ["search.engine"], havingValue = "rdb", matchIfMissing = true)
class RdbBookSearchAdapter(
@ConditionalOnProperty(name = ["search.engine"], havingValue = "mysql", matchIfMissing = true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] search.engine 값 rdb -> mysql 변경 후 OpenSearch 스텁의 안내 메시지가 갱신되지 않음

Problem: 이 PR에서 @ConditionalOnPropertyhavingValue"rdb"에서 "mysql"로 바꿨는데, OpenSearchBookSearchAdapter.ktNotImplementedError 메시지는 여전히 "search.engine=rdb 를 사용하세요"를 안내합니다. 이제 rdb는 어떤 어댑터도 활성화하지 못하는 값이라, 이 메시지를 보고 따라한 개발자는 빈 컨텍스트(활성 BookSearchPort 빈 없음) 오류를 겪게 됩니다.

Evidence: 이 줄에서 값이 mysql로 바뀌었고, src/main/kotlin/org/library/external/search/opensearch/OpenSearchBookSearchAdapter.kt:16은 이 PR에서 손대지 않아 옛 값(rdb)을 그대로 참조합니다.

Fix direction: OpenSearch 스텁의 안내 메시지도 search.engine=mysql로 함께 갱신하세요.

throw NotImplementedError(
    "OpenSearch 검색 어댑터는 아직 스켈레톤입니다. search.engine=mysql 를 사용하세요.",
)

@fnzl54 fnzl54 Aug 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다음 이슈(#32)에서 OpenSearchBookSearchAdapter 구현 예정으로 해당 PR에서 따로 수정 X

#34 에서 작업 완료 (커밋 - 7aa9fc6)

val books = if (keyword == null) {
bookRepository.findAllByDeletedAtIsNull(page.toPageRequest(Sort.by(Sort.Direction.DESC, "createdAt")))
} else {
bookRepository.searchFullText(keyword, page.toPageRequest())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] FULLTEXT 검색 시 API 문서에 명시된 "최신 등록순" 정렬 계약이 깨짐

Problem: BookController/books/search Swagger 설명은 검색어가 있든 없든 결과를 "최신 등록순"(createdAt DESC)으로 반환한다고 명시합니다. 그런데 MysqlFullTextBookSearchAdapter의 키워드 검색 분기는 정렬 없이 Pageable을 그대로 넘기고, BookRepository.searchFullText의 네이티브 쿼리는 order by t.score desc로 하드코딩돼 있어 관련도순으로 반환됩니다.

Evidence: 문서화된 계약은 BookController.kt#L66-L69 ("최신 등록순으로 반환한다"), 실제 정렬 기준은 BookRepository.kt#L46-L49order by t.score desc입니다. 기존 MysqlLikeBookSearchAdapter(구 RdbBookSearchAdapter)는 키워드 유무와 관계없이 항상 Sort.by(DESC, "createdAt")를 적용해 이 계약을 지켜왔습니다. 즉 같은 엔드포인트가 search.engine 설정값에 따라 서로 다른 정렬 의미를 갖게 되며, 문서는 갱신되지 않았습니다.

Fix direction: search.engine=mysql_fulltext로 전환해도 API 계약을 유지하려면 relevance 정렬을 쓰지 않거나(예: createdAt으로 통일), 관련도순으로 정렬 기준을 바꾸기로 한 것이라면 @Operation(description = ...)을 갱신해 클라이언트가 동작 변화를 알 수 있게 해야 합니다.

// 옵션 A: 기존 계약(최신 등록순) 유지
bookRepository.searchFullText(keyword, page.toPageRequest(Sort.by(Sort.Direction.DESC, "createdAt")))
// 이 경우 native query의 order by t.score desc 도 제거하거나 Pageable Sort를 우선하도록 변경 필요

// 옵션 B: 관련도순으로 전환하는 것이 의도라면 문서 갱신
@Operation(
    summary = "도서 검색",
    description = "검색어가 없으면 전체 목록을 최신 등록순으로, 있으면 제목·저자 관련도순으로 반환한다.",
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 완료

searchFullText의 정렬기준을 created_at desc 수정

Comment thread mysql/index.sql

# 사용처 : BookRepository.searchFullText (search.engine=mysql_fulltext)
# 한글은 공백 기준 형태소 분리가 무의미해 ngram 파서(기본 ngram_token_size=2) 기반 역색인을 쓴다.
ALTER TABLE book ADD FULLTEXT INDEX idx_book_title_author_ft (title, author) WITH PARSER ngram;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] ngram(기본 token_size=2) FULLTEXT 인덱스는 1글자 검색어를 무결과로 침묵 처리함

Problem: WITH PARSER ngram 인덱스는 기본 ngram_token_size=2로 색인 텍스트와 검색어 모두를 2글자 단위로 토큰화합니다. 1글자 검색어(예: 흔한 한글 성씨 "김", "이", "박")는 색인 가능한 토큰을 만들지 못해 MATCH ... AGAINST가 매칭되는 것이 없어 결과가 조용히 0건이 됩니다. 기존 LIKE 검색은 길이 제한 없이 부분 일치하므로 동일 검색어로 결과가 있었습니다.

Evidence: 이 코멘트(mysql/index.sql#L11-L13) 자체가 "기본 ngram_token_size=2"임을 인지하고 있고, BookRepository.searchFullText(BookRepository.kt#L37-L49)의 native query와 MysqlFullTextBookSearchAdapter.search(MysqlFullTextBookSearchAdapter.kt#L24)의 query?.trim()?.takeIf { it.isNotBlank() } 검증에는 최소 길이 가드가 없습니다. search.engine=mysql_fulltext로 전환 시 1글자 검색어가 LIKE 대비 침묵 회귀(silent regression)를 일으킵니다.

Fix direction: 최소 길이 미만이면 LIKE 방식으로 폴백하거나, 클라이언트에 최소 글자 수 제약을 안내하세요.

val keyword = query?.trim()?.takeIf { it.isNotBlank() }
val books = when {
    keyword == null -> bookRepository.findAllByDeletedAtIsNull(page.toPageRequest(Sort.by(Sort.Direction.DESC, "createdAt")))
    keyword.length < 2 -> bookRepository.findAllByTitleContainingOrAuthorContaining(keyword) // ngram 토큰화 불가 구간 폴백
    else -> bookRepository.searchFullText(keyword, page.toPageRequest())
}

@fnzl54 fnzl54 Aug 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 완료

ngram 파서에서는 1글자 검색어는 토큰이 만들어지지 않아 FULLTEXT 검색이 조용히 0건을 반환하는 문제 확인
글자 수가 1글자인 경우 MysqlLikeBookSearchAdapter가 사용 중인 searchActive(q, pageable): Page 사용하도록 수정
해당 수정은 1글자 검색이라는 좁은 구간에서만 발생하고 ngram 인덱스로 검색이 불가능한 영역이라 성능보다 예외 케이스를 막는 것으로 결정

SELECT id FROM book
WHERE deleted_at IS NULL
AND (LOWER(title) LIKE LOWER(CONCAT('%', ?, '%')) OR LOWER(author) LIKE LOWER(CONCAT('%', ?, '%')))
ORDER BY created_at DESC LIMIT 20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] 벤치마크 쿼리가 "운영 쿼리와 동일"하다고 명시하지만 LIMIT 값이 실제 기본값과 다름

Problem: LIKE_SQL/FULLTEXT_SQL 위 주석은 각각 BookRepository.searchActive/searchFullText와 "의미적으로 동일"/"동일한" 쿼리라고 명시하지만, 두 쿼리 모두 LIMIT 20으로 고정되어 있습니다. 실제 운영 기본 페이지 크기는 PageRequestParams.DEFAULT_PAGE_SIZE = 10이고 BookController.searchpageSize 미지정 시 이 값을 사용하므로, 벤치마크는 운영에서 흔히 발생하는 LIMIT 10 실행 계획과 다른 조건(LIMIT 20)을 측정합니다.

Evidence: ExplainAnalyzer.kt#L5-L11, PageRequestParams.DEFAULT_PAGE_SIZE = 10.

Fix direction: LIMIT 값을 PageRequestParams.DEFAULT_PAGE_SIZE와 동일하게 맞추거나, 여러 pageSize 값을 파라미터화해 실측하세요.

private const val DEFAULT_PAGE_SIZE = 10 // PageRequestParams.DEFAULT_PAGE_SIZE 와 동기화

private const val LIKE_SQL = """
    SELECT id FROM book
    WHERE deleted_at IS NULL
      AND (LOWER(title) LIKE LOWER(CONCAT(0x25, ?, 0x25)) OR LOWER(author) LIKE LOWER(CONCAT(0x25, ?, 0x25)))
    ORDER BY created_at DESC LIMIT $DEFAULT_PAGE_SIZE
"""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

실제 운영에서는 프론트가 페이지 크기를 20으로 고정해서 요청하여서 변경 X

@fnzl54
fnzl54 merged commit b4f3b7e into main Aug 20, 2026
@fnzl54
fnzl54 deleted the feat/#31 branch August 20, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant