๋ฉด์ ๊ด: "Discord์ ๊ฐ์ ์ค์๊ฐ ์ฑํ ์์คํ ์์ ๋๋์ ๋ฉ์์ง ์ค์์ ํน์ ๋จ์ด๋ ๋ฌธ๊ตฌ๋ฅผ ๊ฒ์ํ์ฌ ํด๋น ์ฑํ ์ค๋ ๋๋ฅผ ์ฐพ๋ ์์คํ ์ ์ค๊ณํด์ฃผ์ธ์."
์ง์์: ๋ค, ๋ช ๊ฐ์ง ์๊ตฌ์ฌํญ์ ํ์ธํ๊ณ ์ถ์ต๋๋ค.
- ํ๋ฃจ ํ๊ท ๋ฉ์์ง ์๊ณผ ๊ฒ์ ์ฟผ๋ฆฌ ์๋ ์ผ๋ง๋ ๋๋์?
- ๊ฒ์ ๊ฒฐ๊ณผ์ ์ค์๊ฐ์ฑ์ด ์ผ๋ง๋ ์ค์ํ๊ฐ์?
- ๊ฒ์ ๋ฒ์(๊ธฐ๊ฐ, ์ฑ๋ ๋ฑ)์ ๋ํ ์ ํ์ด ์๋์?
- ๊ฒ์ ๊ฒฐ๊ณผ์ ์ ํ๋์ ์๋ ์ค ๋ฌด์์ด ๋ ์ค์ํ๊ฐ์?
๋ฉด์ ๊ด:
- ์ผ์ผ 1์ต ๊ฐ์ ์๋ก์ด ๋ฉ์์ง, ์ด๋น 1000๊ฐ์ ๊ฒ์ ์ฟผ๋ฆฌ
- 1๋ถ ์ด๋ด์ ์ง์ฐ ์๊ฐ ํ์ฉ
- ์ต๋ 1๋ ์น ๋ฉ์์ง ๊ฒ์ ์ง์, ์ฑ๋/์๋ฒ๋ณ ๊ฒ์ ํ์
- ์ ํ๋์ ์๋ ๋ชจ๋ ์ค์, ๊ฒ์ ๊ฒฐ๊ณผ๋ 1์ด ์ด๋ด ๋ฐํ ํ์
@Service
public class MessageIndexingService {
private final ElasticsearchClient esClient;
private final KafkaTemplate<String, Message> kafkaTemplate;
// 1. ์ค์๊ฐ ๋ฉ์์ง ์ธ๋ฑ์ฑ
@KafkaListener(topics = "chat-messages")
public void indexMessage(Message message) {
// ๋ฉ์์ง ์ ์ฒ๋ฆฌ
IndexableMessage indexableMsg = preprocessMessage(message);
// ๋น๋๊ธฐ ์ธ๋ฑ์ฑ
CompletableFuture.runAsync(() -> {
IndexRequest<IndexableMessage> request = IndexRequest.of(r -> r
.index("messages")
.id(message.getId())
.document(indexableMsg)
.routing(message.getServerId()) // ์๋ฒ๋ณ ์ค๋ฉ
.pipeline("message-enrichment")); // ์ธ๋ฑ์ฑ ํ์ดํ๋ผ์ธ
try {
esClient.index(request);
} catch (Exception e) {
// ์คํจํ ์ธ๋ฑ์ฑ์ ์ฌ์๋ ํ๋ก
kafkaTemplate.send("failed-indexing", message);
}
});
}
// 2. ์ธ๋ฑ์ค ์ต์ ํ
@Data
@Document(indexName = "messages")
public class IndexableMessage {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "custom_analyzer")
private String content;
@Field(type = FieldType.Keyword)
private String serverId;
@Field(type = FieldType.Keyword)
private String channelId;
@Field(type = FieldType.Date)
private Instant timestamp;
@Field(type = FieldType.Nested)
private List<Attachment> attachments;
}
// 3. ์ปค์คํ
๋ถ์๊ธฐ ์ค์
@Bean
public Analysis createCustomAnalyzer() {
return Analysis.of(a -> a
.analyzer("custom_analyzer",
CustomAnalyzer.of(ca -> ca
.tokenizer("standard")
.filter("lowercase",
"stop",
"snowball",
"edge_ngram"))));
}
}
@Service
public class SearchService {
private final ElasticsearchClient esClient;
private final SearchCache searchCache;
private final SearchOptimizer searchOptimizer;
// 1. ๊ฒ์ ์ฟผ๋ฆฌ ์ฒ๋ฆฌ
public SearchResult search(SearchRequest request) {
// ์บ์ ํ์ธ
String cacheKey = generateCacheKey(request);
SearchResult cachedResult = searchCache.get(cacheKey);
if (cachedResult != null) {
return cachedResult;
}
// ๊ฒ์ ์ฟผ๋ฆฌ ๊ตฌ์ฑ
Query query = buildSearchQuery(request);
// ๊ฒ์ ์คํ
SearchResponse<IndexableMessage> response = esClient.search(s -> s
.index("messages")
.routing(request.getServerId()) // ์๋ฒ๋ณ ์ค๋ฉ ํ์ฉ
.query(query)
.highlight(h -> h
.fields("content", f -> f
.preTags("<mark>")
.postTags("</mark>")
.numberOfFragments(3)))
.sort(so -> so
.field(f -> f.field("timestamp").order(SortOrder.Desc)))
.from(request.getOffset())
.size(request.getLimit())
, IndexableMessage.class
);
// ๊ฒฐ๊ณผ ๋ณํ ๋ฐ ์บ์ ์ ์ฅ
SearchResult result = convertToSearchResult(response);
searchCache.put(cacheKey, result);
return result;
}
// 2. ๊ณ ๊ธ ๊ฒ์ ์ฟผ๋ฆฌ ๊ตฌ์ฑ
private Query buildSearchQuery(SearchRequest request) {
BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
// ํค์๋ ๊ฒ์
queryBuilder.must(m -> m
.matchPhrasePrefix(mp -> mp
.field("content")
.query(request.getKeyword())
.maxExpansions(50)));
// ํํฐ ์ ์ฉ
if (request.getChannelId() != null) {
queryBuilder.filter(f -> f
.term(t -> t
.field("channelId")
.value(request.getChannelId())));
}
// ์๊ฐ ๋ฒ์ ํํฐ
if (request.getTimeRange() != null) {
queryBuilder.filter(f -> f
.range(r -> r
.field("timestamp")
.gte(JsonData.of(request.getTimeRange().getStart()))
.lte(JsonData.of(request.getTimeRange().getEnd()))));
}
return queryBuilder.build()._toQuery();
}
// 3. ๊ฒ์ ์ต์ ํ
@Component
public class SearchOptimizer {
// ๊ฒ์์ด ์๋ ์์ฑ
public List<String> getSuggestions(String prefix) {
return esClient.search(s -> s
.index("message_suggestions")
.suggest(sug -> sug
.suggestor("content_suggest", cs -> cs
.prefix(prefix)
.completion(c -> c
.field("suggest")
.size(5)
.skipDuplicates(true)))),
IndexableMessage.class)
.suggest()
.get("content_suggest")
.stream()
.map(suggestion -> suggestion.text())
.collect(Collectors.toList());
}
// ๊ฒ์ ๊ฒฐ๊ณผ ์ค์ฝ์ด๋ง
private double calculateRelevanceScore(SearchHit<IndexableMessage> hit) {
double score = hit.score();
// ์๊ฐ ๊ฐ์ค์น
long ageInHours = Duration.between(
hit.source().getTimestamp(),
Instant.now()).toHours();
double timeBoost = 1.0 / Math.log(ageInHours + 2);
// ์ฑ๋ ํ์ฑ๋ ๊ฐ์ค์น
double channelActivityBoost =
getChannelActivityScore(hit.source().getChannelId());
return score * timeBoost * channelActivityBoost;
}
}
// 4. ๊ฒ์ ๊ฒฐ๊ณผ ์บ์ฑ
@Component
public class SearchCache {
private final LoadingCache<String, SearchResult> cache;
public SearchCache() {
this.cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build(this::loadSearchResult);
}
private SearchResult loadSearchResult(String cacheKey) {
SearchRequest request = deserializeRequest(cacheKey);
return performSearch(request);
}
// ์บ์ ๋ฌดํจํ (์ ๋ฉ์์ง ๋์ฐฉ ์)
public void invalidateRelatedCache(Message message) {
// ๊ด๋ จ๋ ๊ฒ์ ๊ฒฐ๊ณผ ์บ์ ๋ฌดํจํ
Set<String> relatedCacheKeys = findRelatedCacheKeys(message);
relatedCacheKeys.forEach(cache::invalidate);
}
}
// 5. ๋ถ์ฐ ๊ฒ์ ์ฒ๋ฆฌ
@Component
public class DistributedSearchHandler {
public SearchResult executeDistributedSearch(SearchRequest request) {
// ์๋ฒ/์ฑ๋๋ณ ์ค๋ ๊ฒฐ์
List<SearchShard> targetShards = determineTargetShards(request);
// ๋ณ๋ ฌ ๊ฒ์ ์คํ
List<CompletableFuture<PartialSearchResult>> futures =
targetShards.stream()
.map(shard -> CompletableFuture.supplyAsync(() ->
executeSearchOnShard(request, shard)))
.collect(Collectors.toList());
// ๊ฒฐ๊ณผ ๋ณํฉ
return CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0]))
.thenApply(v -> mergeResults(futures))
.join();
}
private SearchResult mergeResults(
List<CompletableFuture<PartialSearchResult>> futures) {
return futures.stream()
.map(CompletableFuture::join)
.reduce(new SearchResult(), this::mergePartialResults);
}
}
}
์ด๋ฌํ ๊ฒ์ ์์คํ ์ ํตํด:
-
ํจ์จ์ ์ธ ์ธ๋ฑ์ฑ
- ์ค์๊ฐ ๋ฉ์์ง ์ธ๋ฑ์ฑ
- ์ปค์คํ ๋ถ์๊ธฐ๋ก ๊ฒ์ ํ์ง ํฅ์
- ์ค๋ฉ์ ํตํ ์ฑ๋ฅ ์ต์ ํ
-
๊ณ ์ฑ๋ฅ ๊ฒ์
- ์บ์ฑ์ ํตํ ์๋ต ์๊ฐ ๊ฐ์
- ๋ถ์ฐ ๊ฒ์์ผ๋ก ๋๊ท๋ชจ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
- ๊ฒฐ๊ณผ ์ค์ฝ์ด๋ง์ผ๋ก ์ ํ๋ ํฅ์
-
์ฌ์ฉ์ ๊ฒฝํ ์ต์ ํ
- ๊ฒ์์ด ์๋ ์์ฑ
- ํ์ด๋ผ์ดํ
- ๊ด๋ จ์ฑ ๊ธฐ๋ฐ ์ ๋ ฌ
-
ํ์ฅ์ฑ
- ์ค๋ฉ์ ํตํ ์ํ์ ํ์ฅ
- ์บ์๋ฅผ ํตํ ๋ถํ ๋ถ์ฐ
- ๋น๋๊ธฐ ์ฒ๋ฆฌ๋ก ์ฑ๋ฅ ํฅ์
์ ๊ตฌํํ ์ ์์ต๋๋ค.
๋ฉด์ ๊ด: ๋๊ท๋ชจ ํ์คํ ๋ฆฌ ๊ฒ์ ์ ๋ฐ์ํ ์ ์๋ ์ฑ๋ฅ ์ด์๋ ์ด๋ป๊ฒ ํด๊ฒฐํ์๊ฒ ์ต๋๊น?
@Service
public class HistoricalSearchOptimizationService {
// 1. ์๊ฐ ๊ธฐ๋ฐ ์ธ๋ฑ์ค ์ค๋ฉ
@Component
public class TimeBasedIndexManager {
private static final String INDEX_PATTERN = "messages-%s";
public List<String> determineTargetIndices(TimeRange searchRange) {
List<String> targetIndices = new ArrayList<>();
// ์๋ณ ์ธ๋ฑ์ค ๊ตฌ์ฑ
YearMonth current = YearMonth.from(searchRange.getStart());
YearMonth end = YearMonth.from(searchRange.getEnd());
while (!current.isAfter(end)) {
targetIndices.add(String.format(
INDEX_PATTERN,
current.format(DateTimeFormatter.ofPattern("yyyy-MM"))
));
current = current.plusMonths(1);
}
return targetIndices;
}
// ์ธ๋ฑ์ค ์๋ช
์ฃผ๊ธฐ ๊ด๋ฆฌ
@Scheduled(cron = "0 0 0 * * *")
public void manageIndices() {
// ์ค๋๋ ์ธ๋ฑ์ค ์์ถ
compressOldIndices();
// ์์นด์ด๋ธ ์ ์ฑ
์ ์ฉ
applyArchivePolicy();
}
}
// 2. ๊ฒ์ ๊ฒฐ๊ณผ ํ์ด์ง ์ต์ ํ
@Component
public class SearchPaginationOptimizer {
private final RedisTemplate<String, String> redisTemplate;
public SearchResult getNextPage(String searchId, int page) {
// ๊ฒ์ ์ปจํ
์คํธ ๋ณต์
SearchContext context = getSearchContext(searchId);
// search_after ์ฌ์ฉํ์ฌ ํ์ด์ง
SearchResponse<IndexableMessage> response = esClient.search(s -> s
.index(context.getTargetIndices())
.query(context.getQuery())
.searchAfter(context.getLastSort())
.sort(so -> so
.field(f -> f.field("timestamp").order(SortOrder.Desc))
.field(f -> f.field("_id").order(SortOrder.Desc)))
.size(context.getPageSize())
);
// ๋ค์ ํ์ด์ง ์ปจํ
์คํธ ์ ์ฅ
updateSearchContext(searchId, response);
return convertToSearchResult(response);
}
// ๊ฒ์ ์ปจํ
์คํธ ๊ด๋ฆฌ
private class SearchContext {
private final List<String> targetIndices;
private final Query query;
private final List<Sort> sorts;
private List<Object> lastSort;
private final int pageSize;
// TTL ์ค์
@PostConstruct
public void setTTL() {
redisTemplate.expire(
"search:context:" + searchId,
Duration.ofHours(1)
);
}
}
}
// 3. ๊ฒฐ๊ณผ ์บ์ฑ ๊ณ์ธตํ
@Component
public class LayeredSearchCache {
private final LoadingCache<String, SearchResult> localCache;
private final RedisTemplate<String, SearchResult> redisCache;
public SearchResult getCachedResult(SearchRequest request) {
String cacheKey = generateCacheKey(request);
// L1 ์บ์ (๋ก์ปฌ ๋ฉ๋ชจ๋ฆฌ)
SearchResult localResult = localCache.getIfPresent(cacheKey);
if (localResult != null) {
return localResult;
}
// L2 ์บ์ (Redis)
SearchResult redisResult = redisCache.opsForValue()
.get("search:" + cacheKey);
if (redisResult != null) {
localCache.put(cacheKey, redisResult);
return redisResult;
}
// ์บ์ ๋ฏธ์ค
return null;
}
}
// 4. ์ฟผ๋ฆฌ ์ต์ ํ
@Component
public class QueryOptimizer {
public Query optimizeQuery(Query originalQuery) {
BoolQuery.Builder optimizedQuery = new BoolQuery.Builder();
// ํ๋ ๊ฐ์ค์น ์ ์ฉ
optimizedQuery.should(s -> s
.matchPhrase(mp -> mp
.field("content^2") // ์ปจํ
์ธ ํ๋ ๊ฐ์ค์น
.query(originalQuery)));
// ํํฐ ์ต์ ํ
optimizedQuery.filter(f -> f
.bool(b -> b
.minimumShouldMatch(1)
.cache(true))); // ํํฐ ์บ์ฑ
return optimizedQuery.build()._toQuery();
}
// ์ฟผ๋ฆฌ ์คํ ๊ณํ ๋ถ์
public QueryPlan analyzeQuery(Query query) {
return esClient.indices()
.validateQuery(v -> v
.query(query)
.explain(true))
.explain();
}
}
// 5. ๋น๋๊ธฐ ๊ฒ์ ์ฒ๋ฆฌ
@Service
public class AsyncSearchService {
public CompletableFuture<SearchResult> asyncSearch(
SearchRequest request) {
// ๊ฒ์ ์์
์์ฑ
String searchTaskId = UUID.randomUUID().toString();
// ๋น๋๊ธฐ ๊ฒ์ ์คํ
return CompletableFuture.supplyAsync(() -> {
try {
return executeSearch(request);
} catch (Exception e) {
// ์คํจ ์ฒ๋ฆฌ
handleSearchFailure(searchTaskId, e);
throw e;
}
}).whenComplete((result, error) -> {
if (error == null) {
// ์ฑ๊ณต ์ ๊ฒฐ๊ณผ ์บ์ฑ
cacheSearchResult(searchTaskId, result);
}
});
}
// ๊ฒ์ ์งํ ์ํฉ ์กฐํ
public SearchProgress getSearchProgress(String searchTaskId) {
return new SearchProgress(
searchTaskId,
getCompletedShards(),
getTotalShards(),
getEstimatedTimeRemaining()
);
}
}
}
์ด๋ฌํ ์ต์ ํ ์ ๋ต์ ํตํด:
-
์๊ฐ ๊ธฐ๋ฐ ์ธ๋ฑ์ค ๋ถํ
- ์๋ณ/์ฐ๋๋ณ ์ธ๋ฑ์ค ์์ฑ
- ์ค๋๋ ๋ฐ์ดํฐ ์์ถ/์์นด์ด๋น
- ๊ฒ์ ๋ฒ์ ์ต์ ํ
-
ํจ์จ์ ์ธ ํ์ด์ง
- search_after ์ฌ์ฉ
- ๊ฒ์ ์ปจํ ์คํธ ๊ด๋ฆฌ
- ์ํ ์ ์ง ํ์ด์ง
-
๋ค์ธต ์บ์ฑ
- ๋ก์ปฌ ์บ์
- ๋ถ์ฐ ์บ์
- ๊ฒฐ๊ณผ ์ฌ์ฌ์ฉ
-
์ฟผ๋ฆฌ ์ต์ ํ
- ํ๋ ๊ฐ์ค์น
- ํํฐ ์บ์ฑ
- ์คํ ๊ณํ ๋ถ์
-
๋น๋๊ธฐ ์ฒ๋ฆฌ
- ๋ฐฑ๊ทธ๋ผ์ด๋ ๊ฒ์
- ์งํ ์ํฉ ๋ชจ๋ํฐ๋ง
- ๋ถ๋ถ ๊ฒฐ๊ณผ ๋ฐํ
์ ๊ตฌํํ์ฌ ๋๊ท๋ชจ ํ์คํ ๋ฆฌ ๊ฒ์์ ์ฑ๋ฅ์ ๊ฐ์ ํ ์ ์์ต๋๋ค.