자동완성 기능이 있는 제품 검색 시스템 설계

1. 개요

검색 상자에 “ao th”를 입력할 때 사용자는 100ms 이내에 “áo thun nam”, “áo thun cotton” 같은 제안을 바로 보고 싶어합니다. 이 글에서는 완전한 사례 연구를 진행합니다: 요구사항, 데이터 모델, API, 쓰기 및 읽기 경로, 그리고 hot key, 일관성, 트래픽 급증 같은 까다로운 부분까지 다룹니다.

이 글을 읽고 나면 Spring Boot, Redis, Kafka를 사용해 자동완성 서비스를 구축할 수 있습니다. 각 설계 결정이 왜 선택되었는지도 이해하게 될 것입니다.

2. 요구사항

2.1. 기능 요구사항

  • 사용자는 최소 2글자를 입력합니다. 시스템은 최대 10개의 제안을 반환하며, 인기도순으로 정렬합니다.
  • 음절 기호가 있든 없든 일치해야 합니다: “ao thun”은 “Áo thun”과 일치해야 합니다.
  • 제안은 두 가지 출처에서 옵니다: 사용자 검색어와 제품명, 브랜드, 카테고리입니다.
  • 관리자는 민감한 키워드를 차단할 수 있습니다. 차단된 키워드는 제안에서 사라져야 합니다.

2.2. 규모 및 제약사항

  • 약 200만 개의 제안 세트와 500만 개의 제품입니다.
  • 일반적인 트래픽은 5,000 request/초입니다. 플래시 세일 중에는 30,000 request/초까지 올라갑니다.
  • 서버 측 p99 지연시간은 50ms 미만이어야 합니다.
  • 허용되는 신선도는 1–2분입니다. 이것은 최종 일관성(eventual consistency) 입니다: 새로 인기 있는 키워드가 바로 나타나지 않을 수 있으며 그것은 괜찮습니다.

마지막 제약이 매우 중요합니다. 쓰기 경로(점수 업데이트)를 읽기 경로(제안 반환)에서 완전히 분리할 수 있게 해줍니다.

3. 전체 아키텍처

Client ──(debounce 150ms)──> API Gateway ──> Autocomplete Service ──> Caffeine (local)
                                                     │                      │ miss
                                                     │                      v
                                                     │               Redis: ac:p:{prefix}
Search Service ──(search-events)──> Kafka ──> Aggregator ──(flush 30s)──> Redis
Catalog Service ──(product-events)──┘

핵심 아이디어는 각 prefix에 대해 top-K를 미리 계산 하는 것입니다. “ao”, “ao t”, “ao th” 같은 각 prefix는 자신의 Redis sorted set을 가집니다. 이 set은 최대 50개의 가장 높은 점수의 항목만 보관합니다. 읽을 때는 ZREVRANGE 하나면 충분하며 시간복잡도는 O(log N + K)입니다. 스캔할 필요도 없고, 계산할 필요도 없습니다.

메모리 추정: 200만 개 항목, 최대 prefix 길이 20글자. 중복 prefix를 합치면 약 1천만 key가 남습니다. 각 key는 약 40바이트짜리 50개 요소를 보관하며, 오버헤드를 포함해 총 약 20GB입니다. 이 크기는 3개 shard와 replica를 가진 Redis Cluster에 적당합니다.

4. 데이터 모델

원본 데이터는 PostgreSQL에 있습니다. Redis는 읽기 제공 목적의 index일 뿐이며 언제든 다시 만들 수 있습니다.

CREATE TABLE suggestion (
    id            BIGSERIAL PRIMARY KEY,
    display_text  VARCHAR(200) NOT NULL,          -- "Áo thun nam cotton"
    normalized    VARCHAR(200) NOT NULL UNIQUE,   -- "ao thun nam cotton"
    source        VARCHAR(20)  NOT NULL,          -- QUERY | PRODUCT | BRAND
    score         BIGINT       NOT NULL DEFAULT 0,
    blocked       BOOLEAN      NOT NULL DEFAULT FALSE,
    updated_at    TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_suggestion_score ON suggestion (score DESC) WHERE blocked = FALSE;

Redis 쪽에는 세 가지 key 유형이 있습니다:

ac:score            ZSET  member=display_text, score=전역 총점
ac:p:{prefix}       ZSET  member=display_text, score=총점 (최대 50개 항목)
ac:blocked          SET   차단된 display_text들

5. 베트남어 정규화 및 prefix 생성

이것이 가장 실수하기 쉬운 부분입니다. 기호를 제거하고, 소문자로 변환하고, 공백을 병합하고, “đ” 문자를 따로 처리해야 합니다. Normalizer가 이를 분리하지 못하기 때문입니다.

public final class TextNormalizer {

    private static final Pattern MARKS = Pattern.compile("\\p{M}+");
    private static final Pattern SPACES = Pattern.compile("\\s+");

    private TextNormalizer() {}

    public static String normalize(String input) {
        if (input == null) return "";
        String s = Normalizer.normalize(input, Normalizer.Form.NFD);
        s = MARKS.matcher(s).replaceAll("")
                 .replace('đ', 'd').replace('Đ', 'D')
                 .toLowerCase(Locale.ROOT);
        return SPACES.matcher(s).replaceAll(" ").trim();
    }

    public static List<String> prefixes(String normalized, int minLen, int maxLen) {
        List<String> result = new ArrayList<>();
        int end = Math.min(normalized.length(), maxLen);
        for (int i = minLen; i <= end; i++) {
            String p = normalized.substring(0, i);
            if (!p.endsWith(" ")) result.add(p); // "ao "는 trim 후 "ao"와 동일함
        }
        return result;
    }
}

Form.NFD는 “á”를 “a”와 결합 문자로 분리합니다. 그 다음 regex \p{M}이 문자 부분을 제거합니다. prefixes 함수는 공백으로 끝나는 prefix를 건너뜁니다. 이유: 사용자의 쿼리는 이미 trim() 되었으므로, ac:p:ao 키는 읽혀본 적이 없을 것입니다.

주요 동작에 대한 테스트:

class TextNormalizerTest {

    @Test
    void shouldRemoveVietnameseDiacritics() {
        assertThat(TextNormalizer.normalize("  Áo Thun ĐỎ   nam "))
            .isEqualTo("ao thun do nam");
    }

    @Test
    void shouldGeneratePrefixesWithoutTrailingSpace() {
        assertThat(TextNormalizer.prefixes("ao thun", 2, 5))
            .containsExactly("ao", "ao t", "ao th");
    }
}

입력 "ao thun", minLen = 2, maxLen = 5일 때, 길이 2부터 5까지의 부분 문자열은 "ao", "ao ", "ao t", "ao th"입니다. "ao " 요소는 공백으로 끝나므로 제외됩니다.

주의: 이 방법은 phrase의 시작부터만 일치합니다. “thun”도 “áo thun”을 제안하게 하려면, 각 위치부터 시작하는 prefix를 추가로 생성합니다: “thun nam”, “nam”… 대신 메모리는 약 3–4배 증가합니다. 활성화하기 전에 측정하세요.

6. API

@RestController
@RequestMapping("/api/v1/autocomplete")
public class AutocompleteController {

    private final AutocompleteService service;

    public AutocompleteController(AutocompleteService service) {
        this.service = service;
    }

    public record SuggestResponse(String query, List<String> suggestions) {}

    @GetMapping
    public SuggestResponse suggest(@RequestParam("q") String q,
                                   @RequestParam(defaultValue = "10") int limit) {
        int safeLimit = Math.clamp(limit, 1, 10);
        return new SuggestResponse(q, service.suggest(q, safeLimit));
    }
}

Math.clamp (Java 21)는 limit을 1–10 범위로 제한합니다. 클라이언트는 10,000개 항목을 요청할 수 없습니다. 샘플 요청:

GET /api/v1/autocomplete?q=ao%20th&limit=3

{"query":"ao th","suggestions":["Áo thun nam","Áo thun cotton","Áo thun oversize"]}

클라이언트는 약 150ms debounce를 하고 사용자가 더 입력할 때 이전 요청을 취소해야 합니다. 이것만 해도 요청량을 60–70% 줄입니다.

7. 읽기 경로

필요한 의존성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

읽기 서비스는 두 개의 cache 계층을 사용합니다: JVM 내 Caffeine과 Redis입니다.

@Service
public class AutocompleteService {

    static final int MAX_PREFIX = 20;
    private final StringRedisTemplate redis;
    private final Cache<String, List<String>> local = Caffeine.newBuilder()
            .maximumSize(200_000)
            .expireAfterWrite(Duration.ofSeconds(30))
            .build();

    public AutocompleteService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public List<String> suggest(String rawQuery, int limit) {
        String query = TextNormalizer.normalize(rawQuery);
        if (query.length() < 2) return List.of();
        String key = query.length() > MAX_PREFIX ? query.substring(0, MAX_PREFIX) : query;

        return local.get(key, this::loadFromRedis).stream()
                .filter(s -> TextNormalizer.normalize(s).startsWith(query))
                .limit(limit)
                .toList();
    }

    private List<String> loadFromRedis(String prefix) {
        Set<String> top = redis.opsForZSet().reverseRange("ac:p:" + prefix, 0, 49);
        return top == null ? List.of() : List.copyOf(top);
    }
}

주목할 점들:

  • Caffeine의 Cache.get(key, loader)는 key당 atomic 작업입니다. 1,000개 thread가 cache가 비어있을 때 동일한 prefix “ao”를 묻더라도, 오직 하나의 thread만 Redis를 호출합니다. 나머지 thread는 결과를 기다립니다. 이것이 request coalescing 기법이며, cache stampede를 방지합니다.
  • 20글자보다 긴 쿼리는 처음 20글자의 key를 사용합니다. 이후 filter(startsWith) 단계에서 정확하게 필터링합니다.
  • 필터링은 정규화된 query를 사용하고 key를 사용하지 않습니다. 따라서 결과는 항상 사용자가 입력한 정확한 문자열과 일치합니다.

8. 쓰기 경로: 점수 집계 및 index 업데이트

사용자가 검색할 때마다 Search Service는 search-events topic으로 event를 보냅니다. 각 event가 수십 개의 ZADD 명령을 일으킨다면 Redis는 즉시 과부하가 됩니다. 따라서 먼저 메모리에 점수를 집계합니다.

@Component
public class SearchEventAggregator {

    private final ConcurrentHashMap<String, LongAdder> pending = new ConcurrentHashMap<>();
    private final AutocompleteIndexer indexer;

    public SearchEventAggregator(AutocompleteIndexer indexer) {
        this.indexer = indexer;
    }

    @KafkaListener(topics = "search-events", groupId = "autocomplete-aggregator")
    public void onSearch(String displayQuery) {
        pending.computeIfAbsent(displayQuery.trim(), k -> new LongAdder()).increment();
    }

    @Scheduled(fixedDelay = 30_000)
    public void flush() {
        for (String phrase : List.copyOf(pending.keySet())) {
            LongAdder adder = pending.remove(phrase);
            if (adder != null) indexer.addScore(phrase, adder.sum());
        }
    }
}

LongAdder는 많은 thread가 동시에 증가시켜도 AtomicLong보다 경합이 적습니다. 30초마다 각 phrase는 한 번만 쓰기를 일으킵니다. 그 기간에 10,000번 검색되었더라도요. 설정 클래스에 @EnableScheduling을 추가하는 것을 잊지 마세요.

주의: 작은 틈이 있습니다. remove와 sum() 사이에 event가 도착하면, 새 LongAdder에 들어가 다음 flush에서 처리됩니다. instance가 flush 전에 죽으면 최대 30초의 점수를 잃습니다. 제안 순위 문제에서는 이 오차가 허용됩니다. 금전 거래에서는 아닙니다.

Indexer는 전역 점수를 업데이트한 후 각 prefix에 총점을 밀어넣습니다:

@Component
public class AutocompleteIndexer {

    static final int KEEP_PER_PREFIX = 50;
    private final StringRedisTemplate redis;

    public AutocompleteIndexer(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void addScore(String phrase, long delta) {
        Double total = redis.opsForZSet().incrementScore("ac:score", phrase, delta);
        if (total == null) return;
        byte[] member = phrase.getBytes(StandardCharsets.UTF_8);
        List<String> prefixes = TextNormalizer.prefixes(
                TextNormalizer.normalize(phrase), 2, AutocompleteService.MAX_PREFIX);

        redis.executePipelined((RedisCallback<Object>) conn -> {
            for (String p : prefixes) {
                byte[] key = ("ac:p:" + p).getBytes(StandardCharsets.UTF_8);
                conn.zSetCommands().zAdd(key, total, member, RedisZSetCommands.ZAddArgs.empty().gt());
                conn.zSetCommands().zRemRange(key, 0, -(KEEP_PER_PREFIX + 1));
            }
            return null;
        });
    }
}

각 줄이 하는 역할:

  • ZINCRBY ac:score atomic하게 점수를 더하고 새로운 합계를 반환합니다. 응용 프로그램 쪽에서 read-modify-write 단계가 없습니다.
  • ZADD ... GT는 새 점수가 기존 점수보다 높을 때만 씁니다. 두 instance가 동시에 동일한 phrase를 flush 한다고 가정합시다. 느린 instance가 낡은 합계를 들고 도착합니다. 그 낡은 합계는 새 합계를 덮어쓸 수 없습니다. 점수만 증가하므로 GT만으로 충분히 경합을 해결합니다.
  • ZREMRANGEBYRANK key 0 -51는 낮은 rank의 요소를 제거해 50개 요소를 유지합니다. 10개가 아닌 50개를 유지하는 이유는 차단된 항목 필터링과 긴 쿼리 필터링을 위한 여유 때문입니다.
  • Pipeline은 약 40개 명령을 한 왕복에 보냅니다. Pipeline은 각 명령이 하나의 key만 건드리므로 Redis Cluster에서 잘 작동합니다.

주의: 이 명령들을 Redis Cluster의 다중 key Lua script로 묶지 마세요. 각 ac:p:* key는 다른 slot에 있을 수 있으며 script가 CROSSSLOT 오류를 냅니다. hash tag를 사용해 같은 slot으로 강제할 수 있지만, 모든 부하가 하나의 shard로 몰립니다.

Catalog Service도 product-events topic을 통해 유사하게 처리합니다. 새 제품이 생성되면 제품명이 작은 초기 점수로 추가됩니다. Kafka key로 productId를 사용해 동일 제품의 event들이 항상 순서대로 도착하도록 하세요.

9. 어려운 부분과 해결 방법

9.1. Hot key와 트래픽 급증

flash sale에서 “iPhone”이 시작되면 수만 명이 동시에 “ip”를 입력합니다. ac:p:ip key가 hot key가 되고 하나의 Redis shard가 모든 부하를 감당합니다. 시스템은 각 계층에서 대응합니다:

  1. 클라이언트: debounce, 최소 2글자 길이 제약, 이전 요청 취소.
  2. Caffeine: 각 instance에서 30초 동안 cache합니다. 짧은 prefix가 트래픽의 대부분을 차지하므로 hit 비율은 보통 90% 이상입니다. 20개 instance가 있으면 Redis는 30초 동안 한 prefix당 최대 약 20개 요청만 받습니다.
  3. Coalescing: 7번 섹션에서 언급한 대로, 동일 key의 많은 요청은 Redis에 한 번의 호출만 일으킵니다.
  4. Redis replica: replica에서 읽도록 설정합니다 (Lettuce에서 ReadFrom.REPLICA_PREFERRED). 읽기 부하를 분산합니다.
  5. Degrade: Redis timeout이 30ms를 초과하면 빈 목록 또는 미리 cache된 전역 hot phrase 목록을 반환합니다. 검색 상자가 페이지 전체를 멈추게 하지 마세요.

9.2. 키워드 차단 시 일관성

차단된 phrase는 30초 TTL을 기다리지 않고 몇 초 내에 사라져야 합니다. 절차는 두 단계입니다:

  • 관리자가 phrase를 차단합니다. 시스템은 DB에 blocked = true를 기록하고, ac:blocked에 추가하고, keyword-blocked event를 발행합니다.
  • 각 instance가 이 event를 감청합니다. Instance는 메모리의 Set<String> blocklist를 업데이트한 후 local.invalidateAll()을 호출합니다. 읽을 때 filter 단계가 blocklist에 속한 항목을 제거합니다.
  • background job이 그 phrase의 prefix들에 ZREM을 실행해 메모리를 정리하고 ac:score에서도 제거합니다.

읽기 시 필터는 즉시 정확함을 보장합니다. 정리 job은 메모리만 절약합니다.

9.3. Index 재구축

Redis가 데이터를 잃었거나 정규화 규칙을 바꿨나요? 그러면 suggestion 테이블에서 재구축하고 새 namespace에 씁니다. 예를 들어 ac:v2:p:*에 씁니다. 그 다음 읽기 설정을 v2로 전환하고 v1을 삭제합니다. 이것이 index에 대한 blue-green 방식입니다: 사용자가 반쪽 완성된 index를 보는 시간이 없습니다.

주기적으로 ac:score도 suggestion.score 컬럼과 동기화해야 합니다. 이 컬럼이 필요할 때 재구축하기 위한 원본입니다.

10. 이 방법을 사용하면 안 될 때

  • Fuzzy 검색이 필요한 경우: 사용자가 “ao thn”으로 잘못 입력했는데 여전히 “áo thun”을 원합니다. Prefix 기반 top-K는 할 수 없습니다. Elasticsearch/OpenSearch를 completion suggester나 search_as_you_type과 함께 사용하세요.
  • 개인화된 제안: 각 사용자마다 다른 목록입니다. Prefix별 key는 전역이므로 뒤에 사용자별 re-rank 단계를 추가해야 합니다.
  • 작은 catalog (수만 개 제품, 초당 수백 request): LIKE 'ao th%' 쿼리를 정규화된 컬럼에 text_pattern_ops index와 함께 PostgreSQL에서 실행하는 것으로 충분합니다. Kafka와 Redis Cluster를 이 규모의 문제에 구축하지 마세요.

11. 결론

방금 초당 수만 request를 견디는 자동완성 시스템 설계를 살펴봤습니다. 핵심 포인트는 네 가지 결정에 있습니다:

  • 각 prefix에 대해 top-K를 미리 계산해 읽기 경로가 단 하나의 ZREVRANGE 명령만 실행하도록 합니다.
  • 쓰기 경로를 Kafka를 통해 분리하고 점수를 배치로 집계해 Redis가 쓰기 명령으로 폭주하지 않도록 합니다.
  • ZINCRBY와 ZADD GT를 사용해 경합을 처리하며 lock이 필요 없습니다.
  • Caffeine과 request coalescing으로 hot key를 막습니다.

순위 점수의 최종 일관성을 수용하되 blocklist처럼 즉시 정확해야 할 부분은 엄격히 제어하는 것이 대부분의 전자상거래 플랫폼에 합리적인 균형입니다. 이 기반에서 필요할 때 fuzzy 검색이나 개인화를 추가할 수 있습니다.

system-designrediskafkaspring-bootautocomplete