
현재 구현된 초성 검색 시스템은 기본적인 기능을 제공하고 있지만, 실제 프로덕션 환경에서는 더 많은 요구사항과 도전 과제들이 존재합니다.
지나번에 작성한 초성검색 유틸리티 클래스에대해 좀더 보완하고자 포스팅 에서는 다음과 같은 실무 적용 사례와 최적화 방안을 다루겠습니다.
현재 구현은 모든 대상 문자열을 순차적으로 검사하므로, 데이터가 많아질수록 검색 성능이 저하됩니다.
public class ChosungIndex {
private final Map<String, Set<String>> index = new ConcurrentHashMap<>();
public void addToIndex(String text) {
String chosung = extractChosung(text);
index.computeIfAbsent(chosung, k -> ConcurrentHashMap.newKeySet()).add(text);
}
private String extractChosung(String text) {
StringBuilder chosung = new StringBuilder();
for (char c : text.toCharArray()) {
if (isHangul(c)) {
char cho = getChosung(c);
chosung.append(cho);
}
}
return chosung.toString();
}
private boolean isHangul(char c) {
return c >= 0xAC00 && c <= 0xD7A3;
}
private char getChosung(char c) {
int choIndex = (c - 0xAC00) / (21 * 28);
return (char) (0x1100 + choIndex);
}
}
@Service
public class IndexManager {
private final ChosungIndex chosungIndex;
private final DataSource dataSource;
@PostConstruct
public void initialize() {
// 초기 데이터 로딩 및 인덱싱
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT text FROM search_targets");
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String text = rs.getString("text");
chosungIndex.addToIndex(text);
}
} catch (SQLException e) {
throw new RuntimeException("인덱스 초기화 실패", e);
}
}
}
public class FuzzyChosungMatcher {
private static final int MAX_DISTANCE = 2;
public boolean matches(String pattern, String target, int maxDistance) {
int[][] dp = new int[pattern.length() + 1][target.length() + 1];
// DP 배열 초기화
for (int i = 0; i <= pattern.length(); i++) {
dp[i][0] = i;
}
for (int j = 0; j <= target.length(); j++) {
dp[0][j] = j;
}
// 편집 거리 계산
for (int i = 1; i <= pattern.length(); i++) {
for (int j = 1; j <= target.length(); j++) {
if (isSimilarChosung(pattern.charAt(i-1), target.charAt(j-1))) {
dp[i][j] = dp[i-1][j-1];
} else {
dp[i][j] = Math.min(dp[i-1][j-1] + 1, // 대체
Math.min(dp[i-1][j] + 1, // 삭제
dp[i][j-1] + 1)); // 삽입
}
}
}
return dp[pattern.length()][target.length()] <= maxDistance;
}
private boolean isSimilarChosung(char c1, char c2) {
// 비슷한 초성 판단 (예: ㄱ과 ㅋ, ㄷ과 ㅌ)
return getSimilarityScore(c1, c2) >= 0.7;
}
}
public class ChosungSearchCache {
private final LoadingCache<String, List<String>> searchCache;
public ChosungSearchCache() {
searchCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(1, TimeUnit.HOURS)
.recordStats()
.build(this::executeSearch);
}
private List<String> executeSearch(String pattern) {
// 실제 검색 로직
return searchService.search(pattern, getTargets());
}
public CacheStats getStats() {
return searchCache.stats();
}
}
@Service
public class AsyncChosungSearchService {
private final ExecutorService executorService;
private final ChosungSearchCache searchCache;
public CompletableFuture<List<String>> searchAsync(String pattern) {
return CompletableFuture.supplyAsync(() -> {
try {
return searchCache.get(pattern);
} catch (Exception e) {
throw new CompletionException(e);
}
}, executorService);
}
@PreDestroy
public void shutdown() {
executorService.shutdown();
try {
if (!executorService.awaitTermination(800, TimeUnit.MILLISECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
}
}
}
@Component
public class SearchMetricsCollector {
private final MeterRegistry registry;
private final Timer searchTimer;
private final Counter searchCounter;
public SearchMetricsCollector(MeterRegistry registry) {
this.registry = registry;
this.searchTimer = Timer.builder("chosung.search.duration")
.description("초성 검색 소요 시간")
.register(registry);
this.searchCounter = Counter.builder("chosung.search.total")
.description("총 검색 요청 수")
.register(registry);
}
public void recordSearchTime(long duration) {
searchTimer.record(duration, TimeUnit.MILLISECONDS);
searchCounter.increment();
}
public void recordCacheHit() {
registry.counter("chosung.cache.hits").increment();
}
public void recordCacheMiss() {
registry.counter("chosung.cache.misses").increment();
}
}
실제 운영 환경을 가정한 성능 테스트 결과:
@SpringBootTest
public class ChosungSearchLoadTest {
@Autowired
private AsyncChosungSearchService searchService;
@Test
public void loadTest() throws Exception {
int numberOfUsers = 1000;
int numberOfSearches = 100;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfUsers);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < numberOfUsers; i++) {
futures.add(executorService.submit(() -> {
for (int j = 0; j < numberOfSearches; j++) {
String pattern = generateRandomChosung();
searchService.searchAsync(pattern)
.thenAccept(results ->
log.debug("Search completed with {} results", results.size()));
}
}));
}
// 모든 작업 완료 대기
for (Future<?> future : futures) {
future.get(1, TimeUnit.MINUTES);
}
}
}
이러한 고도화를 통해 다음과 같은 이점을 얻을 수 있었습니다