diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechKeyword.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechKeyword.java new file mode 100644 index 00000000..81b2af22 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/TechKeyword.java @@ -0,0 +1,36 @@ +package com.dreamypatisiel.devdevdev.domain.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(indexes = { + @Index(name = "idx_tech_keyword_01", columnList = "chosung_key"), + @Index(name = "idx_tech_keyword_02", columnList = "jamo_key") +}) +public class TechKeyword extends BasicTime { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100, columnDefinition = "varchar(100) COLLATE utf8mb4_bin") + private String keyword; + + @Column(nullable = false, length = 300, columnDefinition = "varchar(300) COLLATE utf8mb4_bin") + private String jamoKey; + + @Column(nullable = false, length = 150, columnDefinition = "varchar(150) COLLATE utf8mb4_bin") + private String chosungKey; + + @Builder + private TechKeyword(String keyword, String jamoKey, String chosungKey) { + this.keyword = keyword; + this.jamoKey = jamoKey; + this.chosungKey = chosungKey; + } +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/TechKeywordRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/TechKeywordRepository.java new file mode 100644 index 00000000..52d7bc15 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/TechKeywordRepository.java @@ -0,0 +1,8 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle; + +import com.dreamypatisiel.devdevdev.domain.entity.TechKeyword; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom.TechKeywordRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TechKeywordRepository extends JpaRepository, TechKeywordRepositoryCustom { +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/TechKeywordRepositoryCustom.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/TechKeywordRepositoryCustom.java new file mode 100644 index 00000000..6e85b03a --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/TechKeywordRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom; + +import com.dreamypatisiel.devdevdev.domain.entity.TechKeyword; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface TechKeywordRepositoryCustom { + List searchKeyword(String inputJamo, String inputChosung, Pageable pageable); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/TechKeywordRepositoryImpl.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/TechKeywordRepositoryImpl.java new file mode 100644 index 00000000..46d782a5 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/techArticle/custom/TechKeywordRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.dreamypatisiel.devdevdev.domain.repository.techArticle.custom; + +import com.dreamypatisiel.devdevdev.domain.entity.TechKeyword; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberTemplate; +import com.querydsl.jpa.JPQLQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static com.dreamypatisiel.devdevdev.domain.entity.QTechKeyword.techKeyword; + +@RequiredArgsConstructor +public class TechKeywordRepositoryImpl implements TechKeywordRepositoryCustom { + + public static final String MATCH_AGAINST_FUNCTION = "match_against"; + private final JPQLQueryFactory query; + + @Override + public List searchKeyword(String inputJamo, String inputChosung, Pageable pageable) { + BooleanExpression jamoMatch = Expressions.booleanTemplate( + "function('" + MATCH_AGAINST_FUNCTION + "', {0}, {1}) > 0.0", + techKeyword.jamoKey, inputJamo + ); + + BooleanExpression chosungMatch = Expressions.booleanTemplate( + "function('" + MATCH_AGAINST_FUNCTION + "', {0}, {1}) > 0.0", + techKeyword.chosungKey, inputChosung + ); + + // 스코어 계산을 위한 expression + NumberTemplate jamoScore = Expressions.numberTemplate(Double.class, + "function('" + MATCH_AGAINST_FUNCTION + "', {0}, {1})", + techKeyword.jamoKey, inputJamo + ); + NumberTemplate chosungScore = Expressions.numberTemplate(Double.class, + "function('" + MATCH_AGAINST_FUNCTION + "', {0}, {1})", + techKeyword.chosungKey, inputChosung + ); + + return query + .selectFrom(techKeyword) + .where(jamoMatch.or(chosungMatch)) + .orderBy( + // 더 높은 스코어를 우선으로 정렬 + Expressions.numberTemplate(Double.class, + "GREATEST({0}, {1})", jamoScore, chosungScore).desc(), + // 동일한 스코어라면 키워드 길이가 짧은 것을 우선으로 정렬 + techKeyword.keyword.length().asc() + ) + .limit(pageable.getPageSize()) + .fetch(); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/keyword/TechKeywordService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/keyword/TechKeywordService.java new file mode 100644 index 00000000..0911485c --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/keyword/TechKeywordService.java @@ -0,0 +1,61 @@ +package com.dreamypatisiel.devdevdev.domain.service.techArticle.keyword; + +import com.dreamypatisiel.devdevdev.domain.entity.TechKeyword; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechKeywordRepository; +import com.dreamypatisiel.devdevdev.global.utils.HangulUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class TechKeywordService { + private final TechKeywordRepository techKeywordRepository; + + /** + * @Note: + * @Author: 유소영 + * @Since: 2025.08.13 + * @param prefix + * @return 검색어(최대 20개) + */ + public List autocompleteKeyword(String prefix) { + String processedInput = prefix; + + // 한글이 포함되어 있다면 자/모음 분리 + if (HangulUtils.hasHangul(prefix)) { + processedInput = HangulUtils.convertToJamo(prefix); + } + + // 불리언 검색을 위해 토큰 사이에 '+' 연산자 추가 + String booleanPrefix = convertToBooleanSearch(processedInput); + Pageable pageable = PageRequest.of(0, 20); + List techKeywords = techKeywordRepository.searchKeyword(booleanPrefix, booleanPrefix, pageable); + + // 응답 데이터 가공 + return techKeywords.stream() + .map(TechKeyword::getKeyword) + .toList(); + } + + /** + * 불리언 검색을 위해 각 토큰 사이에 '+' 연산자를 추가하는 메서드 + */ + private String convertToBooleanSearch(String searchTerm) { + if (searchTerm == null || searchTerm.trim().isEmpty()) { + return searchTerm; + } + + // 공백을 기준으로 토큰을 분리하고 각 토큰 앞에 '+' 추가 + String[] tokens = searchTerm.trim().split("\\s+"); + for (int i = 0; i < tokens.length; i++) { + tokens[i] = "+" + tokens[i]; + } + return String.join(" ", tokens); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/config/CustomMySQLFunctionContributor.java b/src/main/java/com/dreamypatisiel/devdevdev/global/config/CustomMySQLFunctionContributor.java new file mode 100644 index 00000000..1328d9a2 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/config/CustomMySQLFunctionContributor.java @@ -0,0 +1,18 @@ +package com.dreamypatisiel.devdevdev.global.config; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; + +import static org.hibernate.type.StandardBasicTypes.DOUBLE; + +public class CustomMySQLFunctionContributor implements FunctionContributor { + private static final String MATCH_AGAINST_FUNCTION = "match_against"; + private static final String MATCH_AGAINST_PATTERN = "match (?1) against (?2 in boolean mode)"; + + @Override + public void contributeFunctions(FunctionContributions functionContributions) { + functionContributions.getFunctionRegistry() + .registerPattern(MATCH_AGAINST_FUNCTION, MATCH_AGAINST_PATTERN, + functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(DOUBLE)); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/HangulUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/HangulUtils.java new file mode 100644 index 00000000..c431548d --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/HangulUtils.java @@ -0,0 +1,162 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +/** + * 한글 처리를 위한 유틸리티 클래스 + */ +public abstract class HangulUtils { + + // 한글 유니코드 범위 + private static final int HANGUL_START = 0xAC00; // '가' + private static final int HANGUL_END = 0xD7A3; // '힣' + + // 자모 유니코드 범위 + private static final int JAMO_START = 0x1100; // 'ㄱ' + private static final int JAMO_END = 0x11FF; // 'ㅿ' + + // 호환 자모 유니코드 범위 + private static final int COMPAT_JAMO_START = 0x3130; // 'ㄱ' + private static final int COMPAT_JAMO_END = 0x318F; // 'ㆎ' + + // 한글 분해를 위한 상수 + private static final int CHOSUNG_COUNT = 19; + private static final int JUNGSUNG_COUNT = 21; + private static final int JONGSUNG_COUNT = 28; + + // 초성 배열 + private static final char[] CHOSUNG = { + 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', + 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ' + }; + + // 중성 배열 + private static final char[] JUNGSUNG = { + 'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', + 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ' + }; + + // 종성 배열 (첫 번째는 받침 없음) + private static final char[] JONGSUNG = { + '\0', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', + 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', + 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ' + }; + + /** + * 문자열에 한글이 포함되어 있는지 확인 + */ + public static boolean hasHangul(String text) { + if (text == null || text.isEmpty()) { + return false; + } + + for (char ch : text.toCharArray()) { + if (isHangul(ch)) { + return true; + } + } + return false; + } + + /** + * 한글 문자열을 자모로 분해 + */ + public static String convertToJamo(String text) { + if (text == null || text.isEmpty()) { + return text; + } + + StringBuilder result = new StringBuilder(); + + for (char ch : text.toCharArray()) { + if (isCompleteHangul(ch)) { + // 완성된 한글 문자를 자모로 분해 + int unicode = ch - HANGUL_START; + + int chosungIndex = unicode / (JUNGSUNG_COUNT * JONGSUNG_COUNT); + int jungsungIndex = (unicode % (JUNGSUNG_COUNT * JONGSUNG_COUNT)) / JONGSUNG_COUNT; + int jongsungIndex = unicode % JONGSUNG_COUNT; + + result.append(CHOSUNG[chosungIndex]); + result.append(JUNGSUNG[jungsungIndex]); + + if (jongsungIndex > 0) { + result.append(JONGSUNG[jongsungIndex]); + } + } else { + // 한글이 아니거나 이미 자모인 경우 그대로 추가 + result.append(ch); + } + } + + return result.toString(); + } + + /** + * 한글 문자열에서 초성만 추출 + */ + public static String extractChosung(String text) { + if (text == null || text.isEmpty()) { + return text; + } + + StringBuilder result = new StringBuilder(); + + for (char ch : text.toCharArray()) { + if (isCompleteHangul(ch)) { + // 완성된 한글 문자에서 초성 추출 + int unicode = ch - HANGUL_START; + int chosungIndex = unicode / (JUNGSUNG_COUNT * JONGSUNG_COUNT); + result.append(CHOSUNG[chosungIndex]); + } else if (isChosung(ch)) { + // 이미 초성인 경우 그대로 추가 + result.append(ch); + } else if (!isHangul(ch)) { + // 한글이 아닌 문자는 그대로 추가 + result.append(ch); + } + // 중성, 종성은 무시 + } + + return result.toString(); + } + + /** + * 문자가 한글인지 확인 (완성형 한글 + 자모) + */ + private static boolean isHangul(char ch) { + return isCompleteHangul(ch) || isJamo(ch) || isCompatJamo(ch); + } + + /** + * 문자가 완성된 한글인지 확인 + */ + private static boolean isCompleteHangul(char ch) { + return ch >= HANGUL_START && ch <= HANGUL_END; + } + + /** + * 문자가 자모인지 확인 + */ + private static boolean isJamo(char ch) { + return ch >= JAMO_START && ch <= JAMO_END; + } + + /** + * 문자가 호환 자모인지 확인 + */ + private static boolean isCompatJamo(char ch) { + return ch >= COMPAT_JAMO_START && ch <= COMPAT_JAMO_END; + } + + /** + * 문자가 초성인지 확인 + */ + private static boolean isChosung(char ch) { + for (char chosung : CHOSUNG) { + if (ch == chosung) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordController.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordController.java index bac7cebb..c7f3463d 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordController.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordController.java @@ -1,35 +1,36 @@ package com.dreamypatisiel.devdevdev.web.controller.techArticle; -import com.dreamypatisiel.devdevdev.elastic.domain.service.ElasticKeywordService; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.keyword.TechKeywordService; import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import java.io.IOException; -import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "검색어 자동완성 API", description = "검색어 자동완성, 검색어 추가 API") @Slf4j +@Profile({"test", "dev", "prod"}) // local 에서는 검색어 자동완성 불가 @RestController @RequestMapping("/devdevdev/api/v1/keywords") @RequiredArgsConstructor public class KeywordController { - private final ElasticKeywordService elasticKeywordService; + private final TechKeywordService techKeywordService; @Operation(summary = "기술블로그 검색어 자동완성") @GetMapping("/auto-complete") - public ResponseEntity> autocompleteKeyword(@RequestParam String prefix) - throws IOException { - - List response = elasticKeywordService.autocompleteKeyword(prefix); - + public ResponseEntity> autocompleteKeyword( + @RequestParam String prefix + ) { + List response = techKeywordService.autocompleteKeyword(prefix); return ResponseEntity.ok(BasicResponse.success(response)); } } diff --git a/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 00000000..74b9d6ff --- /dev/null +++ b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1 @@ +com.dreamypatisiel.devdevdev.global.config.CustomMySQLFunctionContributor diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/TechKeywordServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/TechKeywordServiceTest.java new file mode 100644 index 00000000..45f05d0d --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/TechKeywordServiceTest.java @@ -0,0 +1,182 @@ +package com.dreamypatisiel.devdevdev.domain.service.techArticle; + +import com.dreamypatisiel.devdevdev.domain.entity.TechKeyword; +import com.dreamypatisiel.devdevdev.domain.repository.techArticle.TechKeywordRepository; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.keyword.TechKeywordService; +import com.dreamypatisiel.devdevdev.global.utils.HangulUtils; +import com.dreamypatisiel.devdevdev.test.MySQLTestContainer; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.transaction.BeforeTransaction; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +class TechKeywordServiceTest extends MySQLTestContainer { + + @Autowired + EntityManager em; + + @Autowired + TechKeywordService techKeywordService; + + @Autowired + TechKeywordRepository techKeywordRepository; + + @Autowired + DataSource dataSource; + + private static boolean indexesCreated = false; + + @BeforeTransaction + public void initIndexes() throws SQLException { + if (!indexesCreated) { + // 인덱스 생성 + createFulltextIndexesWithJDBC(); + indexesCreated = true; + + // 데이터 추가 + TechKeyword keyword1 = createTechKeyword("자바"); + TechKeyword keyword2 = createTechKeyword("자바스크립트"); + TechKeyword keyword3 = createTechKeyword("스프링"); + TechKeyword keyword4 = createTechKeyword("스프링부트"); + TechKeyword keyword5 = createTechKeyword("꿈빛"); + TechKeyword keyword6 = createTechKeyword("꿈빛 나라"); + TechKeyword keyword7 = createTechKeyword("행복한 꿈빛 파티시엘"); + List techKeywords = List.of(keyword1, keyword2, keyword3, keyword4, keyword5, keyword6, keyword7); + techKeywordRepository.saveAll(techKeywords); + } + } + + /** + * JDBC를 사용하여 MySQL fulltext 인덱스를 생성 + */ + private void createFulltextIndexesWithJDBC() throws SQLException { + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + + connection.setAutoCommit(false); // 트랜잭션 시작 + + try { + // 기존 인덱스가 있다면 삭제 + statement.executeUpdate("DROP INDEX idx__ft__jamo_key ON tech_keyword"); + statement.executeUpdate("DROP INDEX idx__ft__chosung_key ON tech_keyword"); + } catch (Exception e) { + System.out.println("인덱스 없음 (정상): " + e.getMessage()); + } + + // fulltext 인덱스 생성 + statement.executeUpdate("CREATE FULLTEXT INDEX idx__ft__jamo_key ON tech_keyword (jamo_key) WITH PARSER ngram"); + statement.executeUpdate("CREATE FULLTEXT INDEX idx__ft__chosung_key ON tech_keyword (chosung_key) WITH PARSER ngram"); + + connection.commit(); // 트랜잭션 커밋 + } + + @Test + @DisplayName("검색어와 prefix가 일치하는 키워드를 조회한다.") + void autocompleteKeyword() { + // given + String prefix = "자바"; + + // when + List keywords = techKeywordService.autocompleteKeyword(prefix); + + // then + assertThat(keywords) + .hasSize(2) + .contains("자바", "자바스크립트"); + } + + @ParameterizedTest + @ValueSource(strings = {"ㅈ", "자", "잡", "ㅈㅏ", "ㅈㅏㅂ", "ㅈㅏㅂㅏ"}) + @DisplayName("한글 검색어의 경우 자음, 모음을 분리하여 검색할 수 있다.") + void autocompleteKoreanKeywordBySeparatingConsonantsAndVowels(String prefix) { + // given // when + List keywords = techKeywordService.autocompleteKeyword(prefix); + + // then + assertThat(keywords) + .hasSize(2) + .contains("자바", "자바스크립트"); + } + + @Test + @DisplayName("한글 검색어의 경우 초성검색을 할 수 있다.") + void autocompleteKoreanKeywordByChosung() { + // given + String prefix = "ㅅㅍㄹ"; + + // when + List keywords = techKeywordService.autocompleteKeyword(prefix); + + // then + assertThat(keywords) + .hasSize(2) + .contains("스프링", "스프링부트"); + } + + @Test + @DisplayName("일치하는 키워드가 없을 경우 빈 리스트를 반환한다.") + void autocompleteKeywordNotFound() { + // given + String prefix = "엘라스틱서치"; + + // when + List keywords = techKeywordService.autocompleteKeyword(prefix); + + // then + assertThat(keywords).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {19, 20, 21, 22}) + @DisplayName("검색 결과는 최대 20개로 제한된다.") + void autocompleteKeywordLimitTo20Results(int n) { + // given + List techKeywords = new ArrayList<>(); + for (int i = 0; i < n; i++) { + techKeywords.add(createTechKeyword("키워드" + i)); + } + techKeywordRepository.saveAll(techKeywords); + + // when + List result = techKeywordService.autocompleteKeyword("키워드"); + + // then + assertThat(result).hasSizeLessThanOrEqualTo(20); + } + + @Test + @DisplayName("검색 결과가 관련도 순으로 정렬된다.") + void autocompleteKeywordSortedByRelevance() { + // given // when + List result = techKeywordService.autocompleteKeyword("꿈빛"); + + // then + assertThat(result).isNotEmpty(); + // 더 정확히 매치되는 "꿈빛"이 상위에 나와야 한다 + assertThat(result.get(0)).isEqualTo("꿈빛"); + } + + private TechKeyword createTechKeyword(String keyword) { + return TechKeyword.builder() + .keyword(keyword) + .jamoKey(HangulUtils.convertToJamo(keyword)) + .chosungKey(HangulUtils.extractChosung(keyword)) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticKeywordServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticKeywordServiceTest.java index 048b8da7..03b9e4f0 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticKeywordServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/elastic/domain/service/ElasticKeywordServiceTest.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -15,6 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +@Disabled @SpringBootTest class ElasticKeywordServiceTest { diff --git a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/HangulUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/HangulUtilsTest.java new file mode 100644 index 00000000..0226a38e --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/HangulUtilsTest.java @@ -0,0 +1,89 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class HangulUtilsTest { + + @ParameterizedTest + @ValueSource(strings = {"꿈빛 파티시엘", "Hello꿈빛", "ㄱㄴㄷ", "댑댑댑", "123꿈빛파티시엘", "!@#꿈빛$%^"}) + @DisplayName("한글이 포함된 문자열이면 true를 리턴한다.") + void hasHangulWithKorean(String input) { + // when // then + assertThat(HangulUtils.hasHangul(input)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"Hello World", "spring", "!@#$%", "", " ", "123456789"}) + @DisplayName("한글이 포함되지 않은 문자열은 false를 리턴한다.") + void hasHangulWithoutKorean(String input) { + // when // then + assertThat(HangulUtils.hasHangul(input)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ + "꿈빛, ㄲㅜㅁㅂㅣㅊ", + "꿈빛 파티시엘, ㄲㅜㅁㅂㅣㅊ ㅍㅏㅌㅣㅅㅣㅇㅔㄹ", + "개발자, ㄱㅐㅂㅏㄹㅈㅏ", + "Hello꿈빛, Helloㄲㅜㅁㅂㅣㅊ" + }) + @DisplayName("한글 문자열을 자모음으로 분해한다.") + void convertToJamo(String input, String expected) { + // when + String result = HangulUtils.convertToJamo(input); + + // then + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "안녕!@#하세요$%^, ㅇㅏㄴㄴㅕㅇ!@#ㅎㅏㅅㅔㅇㅛ$%^", + "Spring Boot 3.0, Spring Boot 3.0", + "한글123영어, ㅎㅏㄴㄱㅡㄹ123ㅇㅕㅇㅇㅓ" + }) + @DisplayName("특수문자와 혼합된 문자열을 자모음으로 분해한다.") + void convertToJamoWithSpecialCharacters(String input, String expected) { + // when + String result = HangulUtils.convertToJamo(input); + + // then + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "꿈빛 파티시엘, ㄲㅂ ㅍㅌㅅㅇ", + "댑댑댑, ㄷㄷㄷ", + "댑구리 99, ㄷㄱㄹ 99" + }) + @DisplayName("한글 문자열에서 초성을 추출한다.") + void extractChosung(String input, String expected) { + // when + String result = HangulUtils.extractChosung(input); + + // then + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "꿈빛!@#파티시엘$%^, ㄲㅂ!@#ㅍㅌㅅㅇ$%^", + "React.js개발자, React.jsㄱㅂㅈ", + "Spring Boot 3.0, Spring Boot 3.0", + "꿈빛123개발자, ㄲㅂ123ㄱㅂㅈ" + }) + @DisplayName("특수문자와 혼합된 문자열에서 초성을 추출한다.") + void extractChosungWithSpecialCharacters(String input, String expected) { + // when + String result = HangulUtils.extractChosung(input); + + // then + assertThat(result).isEqualTo(expected); + } +} \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/test/MySQLTestContainer.java b/src/test/java/com/dreamypatisiel/devdevdev/test/MySQLTestContainer.java new file mode 100644 index 00000000..6884b394 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/test/MySQLTestContainer.java @@ -0,0 +1,39 @@ +package com.dreamypatisiel.devdevdev.test; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * MySQL 테스트컨테이너를 제공하는 공통 클래스 + * 1. 테스트 클래스에서 이 클래스를 상속받거나 + * 2. @ExtendWith(MySQLTestContainer.class) 어노테이션을 사용 + */ +@Testcontainers +public abstract class MySQLTestContainer { + + @Container + @ServiceConnection + protected static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("devdevdev_test") + .withUsername("test") + .withPassword("test") + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci", + "--ngram_token_size=1" + ); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mysql::getJdbcUrl); + registry.add("spring.datasource.username", mysql::getUsername); + registry.add("spring.datasource.password", mysql::getPassword); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.show-sql", () -> "true"); + } +} diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordControllerTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordControllerTest.java index f78981e5..7193528c 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordControllerTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/techArticle/KeywordControllerTest.java @@ -1,52 +1,35 @@ package com.dreamypatisiel.devdevdev.web.controller.techArticle; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; -import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticKeyword; -import com.dreamypatisiel.devdevdev.elastic.domain.repository.ElasticKeywordRepository; -import com.dreamypatisiel.devdevdev.elastic.domain.service.ElasticKeywordService; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.keyword.TechKeywordService; import com.dreamypatisiel.devdevdev.web.controller.SupportControllerTest; import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; -import java.nio.charset.StandardCharsets; -import java.util.List; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; -class KeywordControllerTest extends SupportControllerTest { +import java.nio.charset.StandardCharsets; +import java.util.List; - @Autowired - ElasticKeywordService elasticKeywordService; - @Autowired - ElasticKeywordRepository elasticKeywordRepository; - @Autowired - MemberRepository memberRepository; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @AfterEach - void afterEach() { - elasticKeywordRepository.deleteAll(); - } +class KeywordControllerTest extends SupportControllerTest { + + @MockBean + TechKeywordService techKeywordService; @Test @DisplayName("기술블로그 키워드를 검색하면 자동완성 키워드 후보 리스트를 최대 20개 반환한다.") void autocompleteKeyword() throws Exception { // given - ElasticKeyword keyword1 = ElasticKeyword.create("자바"); - ElasticKeyword keyword2 = ElasticKeyword.create("자바스크립트"); - ElasticKeyword keyword3 = ElasticKeyword.create("자바가 최고야"); - ElasticKeyword keyword4 = ElasticKeyword.create("스프링"); - ElasticKeyword keyword5 = ElasticKeyword.create("스프링부트"); - List elasticKeywords = List.of(keyword1, keyword2, keyword3, keyword4, keyword5); - elasticKeywordRepository.saveAll(elasticKeywords); - String prefix = "자"; + List result = List.of("자바", "자바 스크립트", "자바가 최고야"); + given(techKeywordService.autocompleteKeyword(prefix)).willReturn(result); // when // then ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/keywords/auto-complete") diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/KeywordControllerDocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/KeywordControllerDocsTest.java index 5b5b088f..560dd455 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/KeywordControllerDocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/KeywordControllerDocsTest.java @@ -1,9 +1,19 @@ package com.dreamypatisiel.devdevdev.web.docs; +import com.dreamypatisiel.devdevdev.domain.service.techArticle.keyword.TechKeywordService; +import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; import static org.springframework.restdocs.payload.JsonFieldType.STRING; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -15,47 +25,18 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; -import com.dreamypatisiel.devdevdev.elastic.domain.document.ElasticKeyword; -import com.dreamypatisiel.devdevdev.elastic.domain.repository.ElasticKeywordRepository; -import com.dreamypatisiel.devdevdev.elastic.domain.service.ElasticKeywordService; -import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; -import java.nio.charset.StandardCharsets; -import java.util.List; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.ResultActions; - class KeywordControllerDocsTest extends SupportControllerDocsTest { - @Autowired - ElasticKeywordService elasticKeywordService; - @Autowired - ElasticKeywordRepository elasticKeywordRepository; - @Autowired - MemberRepository memberRepository; - - @AfterEach - void afterEach() { - elasticKeywordRepository.deleteAll(); - } + @MockBean + TechKeywordService techKeywordService; @Test @DisplayName("기술블로그 키워드를 검색하면 자동완성 키워드 후보 리스트를 최대 20개 반환한다.") void autocompleteKeyword() throws Exception { // given - ElasticKeyword keyword1 = ElasticKeyword.create("자바"); - ElasticKeyword keyword2 = ElasticKeyword.create("자바스크립트"); - ElasticKeyword keyword3 = ElasticKeyword.create("자바가 최고야"); - ElasticKeyword keyword4 = ElasticKeyword.create("스프링"); - ElasticKeyword keyword5 = ElasticKeyword.create("스프링부트"); - List elasticKeywords = List.of(keyword1, keyword2, keyword3, keyword4, keyword5); - elasticKeywordRepository.saveAll(elasticKeywords); - String prefix = "자"; + List result = List.of("자바", "자바 스크립트", "자바가 최고야"); + given(techKeywordService.autocompleteKeyword(prefix)).willReturn(result); // when // then ResultActions actions = mockMvc.perform(get(DEFAULT_PATH_V1 + "/keywords/auto-complete") @@ -81,5 +62,4 @@ void autocompleteKeyword() throws Exception { ) )); } - } \ No newline at end of file