Elasticsearch 한글 태그 자동완성 구현

WAS·2026년 4월 21일

사이드프로젝트

목록 보기
1/2

상품을 등록할 때 상품 태그를 선택해서 등록하는 기능이 있다.
만약 키보드 를 입력했을 때, #기계식키보드 #무선키보드 #키보드 모두 나오게 하려면 어떻게 해야할까


✅ 전체 구조

사용자 입력 → 서버 API → Elasticsearch 검색 → 결과 반환 → 자동완성 UI

조금 더 구체적으로 보면

[브라우저] "키보" 입력
     ↓  AJAX GET /product/tags?keyword=키보
[TagController]
     ↓
[TagService]
     ↓
[TagEsDao] → Elasticsearch "tags" 인덱스 검색
     ↓
["#키보드", "#기계식키보드", "#무선키보드"] 반환
     ↓  JSON
[브라우저] 드롭다운 표시 ✅

✅ 디비를 사용하지 않고 왜 Elasticsearch 로 처리할까?

만약 디비에서 LIKE '%키보드%' 로 검색하면
대용량 데이터일 경우 너무 느리며, 부분 검색이 한계가 있고, 인덱스를 만들어도 소용이 없다

하지만 Elasticsearch 로 하면 텍스트를 잘게 쪼개 토큰으로 저장하여 초고속으로 검색할 수 있다


✅ 인덱스 설계

Elasticsearch 에서 텍스트를 잘게 쪼개서 토큰으로 저장할 때 Analyzer (분석기) 가 담당한다.

이 분석기는 총 3단계의 방법으로 처리하는데

char_filter (글자 전처리)
tokenizer (단어 분리)
filter (토큰 후처리)
-> 엘라스틱 서치 인덱스에 저장

로 동작한다.

우선 전체 코드를 보면

## tag-settings.json
## 이 json 파일로 분석기(Analyzer) 을 만듬
{
  "index": {
    "max_ngram_diff": 20
  },
  "analysis": {
    "char_filter": {
      "hash_remover": {
        "type": "pattern_replace",
        "pattern": "#",
        "replacement": ""
      },
      "script_boundary": {
        "type": "pattern_replace",
        "pattern": "(?<=[가-힣])(?=[a-zA-Z0-9])|(?<=[a-zA-Z0-9])(?=[가-힣])",
        "replacement": " "
      }
    },
    "filter": {
      "ngram_filter": {
        "type": "ngram",
        "min_gram": 1,
        "max_gram": 20
      }
    },
    "analyzer": {
      "tag_index_analyzer": {  ## 분석기 종류 1
        "type": "custom",
        "char_filter": ["hash_remover", "script_boundary"],
        "tokenizer": "whitespace",
        "filter": ["lowercase", "ngram_filter"]
      },
      "tag_search_analyzer": { ## 분석기 종류 2
        "type": "custom",
        "char_filter": ["hash_remover", "script_boundary"],
        "tokenizer": "whitespace",
        "filter": ["lowercase"]
      }
    }
  }
}
## tag-mapping.json
## 이 json 파일에 위에서 만든 json 파일을 연결해야 동작함
## ES에서 검색 OR 저장할 때 알아서 맞는 분석기랑 매칭해줌
{
  "properties": {
    "tagName": {
      "type": "text",
      "analyzer": "tag_index_analyzer", // 저장할때 해당 분석기 사용
      "search_analyzer": "tag_search_analyzer", // 검색할때 해당 분석기 사용
      "fields": {
        "keyword": {
          "type": "keyword"
        }
      }
    },
    "useCount": {
      "type": "integer"
    }
  }
}

우선 char_filter (전처리) 부터 확인하자.

모든 키워드들은 앞에 # 이 붙어있는 데이터로 # 을 제거해주는 작업이 필요하다
추가로 한글과 영문을 분리하는 작업이 필요하다

이름역할예시
hash_remover# 기호 제거#맥북 -> 맥북
script_boundary한글과 영문 경계에 공백 삽입M1맥북 -> M1 맥북

왜냐하면 M1맥북 이 데이터는 하나로 보기 때문에 "맥북" 이라 검색해도 데이터가 찾아지지 않는다.


두번째로 tokenizer (단어분리)

이름역할예시
Whitespace공백기준으로 분리M1 맥북 -> ["M1", "맥북"]

마지막으로 filter (토큰 후 처리)

이름역할예시
lowercase대소문자 통일RTX -> rtx
ngram_filter모든 위치에서 부분 문자열 생성["맥", "맥북", "맥북프", ... "북", "북프", ... "프로"]-

위에서 사용자정의 필터를 만들때 ngram_filter 타입을 ngram 으로 만들었는데
edge_ngramngram 두가지 선택지가 있었다.
둘의 차이점은 edge_ngram 은 앞에서만 잘라서 문자열을 만들고, ngram 은 모든 위치에서 자른다.

## edge_ngram  -> "맥", "맥북", "맥북프", "맥북프로"
## ngram -> "맥", "맥북", "맥북프", "맥북프로","북", "북프", "북프로","프", "프로","로" ...

개발할 때 키보드 를 입력하면 기계식 키보드 도 자동완성이 나오게 구현하고싶어서 ngram 으로 지정


✅ 주의할 점

DB에서 TAG 테이블에 있는 데이터를 추가하려고 하면
Elasticsearch 에서도 데이터를 추가해줘야 한다

데이터 동기화 문제를 해결해야 한다.

대규모 서비스에서는 주로 Kafka 를 사용하여 이벤트를 발행해 Elasticsearch 에 데이터를 넣어준다

하지만 소규모 프로젝트에서는 Kafka 를 도입하는 것이 비용 및 시간적으로 부담이 되기때문에
애플리케이션 레벨에서 직접 소스코드로 동기화를 시켜준다.

  • 서버 시작시 자동 동기화 구현
@Component
@Slf4j
public class TagDataInitializer implements ApplicationRunner {

    private final TagService tagService;
    private final TagEsDao   tagEsDao;

    @Override
    public void run(ApplicationArguments args) {
        tagEsDao.setupIndex();     // ① 인덱스 재생성
        tagService.syncAllToEs();  // ② DB → ES 동기화
    }
}

💡 ApplicationRunner : 애플리케이션이 완전히 실행된 직후 자동으로 실행되는 코드를 만들 때 사용
위에서 인덱스 재생성과 DB와 ES 데이터 동기화 부분은 운영환경에서는 함부로 하면 안된다.
배포할 경우 특정한 조건일 때만 행하도록 바꿔야 한다.

 public void setupIndex() {
     try {
          boolean exists = client.indices().exists(e -> e.index("tags")).value();
           if (exists) {
              client.indices().delete(d -> d.index("tags"));
           }
	
    		// 위에서 만든 json 파일들
           InputStream productTagSettings = getClass().getResourceAsStream("/elasticsearch/tag-settings.json");
           InputStream productTagMappings = getClass().getResourceAsStream("/elasticsearch/tag-mappings.json");

           client.indices().create(c -> c
                    .index("tags")
                    .settings(s -> s.withJson(productTagSettings))
                    .mappings(m -> m.withJson(productTagMappings))
           );
            log.info("tags 인덱스 재생성 완료");
        } catch (Exception e) {
            log.error("tags 인덱스 생성 실패: {}", e.getMessage());
        }
    }
	
    // syncAllToEs에서 태그들 갖고 온 후 bulkIndex 메소드 호출
	// 디비에서 가져온 태그들을 bulk 인덱싱 처리
	public void bulkIndex(List<TagDocument> tags) {
        try {
            List<BulkOperation> operations = tags.stream()
                    .map(tag -> BulkOperation.of(op -> op
                            .index(i -> i
                                    .index("tags")
                                    .id(String.valueOf(tag.getId()))
                                    .document(tag)
                            )
                    ))
                    .toList();

            BulkResponse response = client.bulk(BulkRequest.of(b -> b
                    .operations(operations)
            ));

            if (response.errors()) {
                response.items().stream()
                        .filter(item -> item.error() != null)
                        .forEach(item -> log.error("bulk 인덱싱 실패 id={} reason={}",
                                item.id(), item.error().reason()));
            } else {
                log.info("ES 태그 bulk 동기화 완료: {}건", tags.size());
            }
        } catch (Exception e) {
            log.error("ES bulk 인덱싱 실패: {}", e.getMessage());
        }
    }

💡 Bulk 인덱싱 이란 데이터를 하나씩이 아닌 한번에 여러개 묶어서 저장하는 것
이 방식을 사용하지 않으면 만약 250개의 태그를 하나씩 저장하면 250번의 HTTP 요청으로 인해 느리다.
Bulk 방식을 사용하면 1번의 요청으로 처리할 수 있어어 빠르다는 장점이 있음


✅ 태그 검색 API

ajax 방식으로 키 입력시 호출 -> match query 실행 (사용자 사용 기준 정렬 + 상위 10개 반환)

// 키워드로 태그 검색
 public List<String> searchByKeyword(String keyword) {
        try {
            List<String> result = client.search(s -> s
                    .index("tags")
                    .query(q -> q  // 쿼리 종류 설정 (match? term? range?)
                            .match(m -> m
                                    .field("tagName")
                                    .query(keyword) // 입력한 키워드로 검색
                            )
                    )
                    // useCount 필드 기준으로 많이 사용된 순으로 정렬
                    .sort(sort -> sort
                            .field(f -> f.field("useCount").order(SortOrder.Desc)) 
                    )
                    .size(10), // 최대 10개만 반환
                    TagDocument.class
            ).hits().hits().stream()
                    .map(Hit::source)
                    .map(TagDocument::getTagName)
                    .collect(Collectors.toList());
            log.info("태그 검색 keyword={} 결과={}건", keyword, result.size());
            return result;
        } catch (Exception e) {
            log.error("태그 ES 검색 실패 keyword={}", keyword, e);
            return List.of();
        }
    }


✅ 받은 데이터를 화면에서 자동완성으로 처리

💡 영어와 한글의 키보드 이벤트 흐름은 달라서 주의가 필요하다

영어 "a" 입력 : keydown -> input -> keyup 으로 단순하다

하지만 를 입력한다고 가정하면

"ㄱ" 입력 : keydown -> input -> keyup
"ㅏ" 입력 : keydown -> compositionupdate -> input -> keyup
compositionupdate : 현재 텍스트 조합세션 값이 변경된 경우 트리거

"아래화살표 ↓ 누르면" : keydown -> compositionend -> input -> keyup
compositionend : 현재 텍스트 조합 세션이 완료되거나 취소된 경우 트리거 됨

keyup 하나로만 처리하면 화살표키 눌렀을 때, 의도치 않게 재검색이 트리거돼서
드롭다운 하이라이트가 사라지는 버그가 생긴다.

// ① 방향키 / Enter / ESC 네비게이션 → keyup으로 처리
// keyup : 키보드에서 손 뗐을때 발생
$searchTag.addEventListener("keyup", (event) => {
    switch (event.keyCode) {
        case 38:  // ↑
            nowIndex = Math.max(nowIndex - 1, -1);
            showList(matchDataList, $searchTag.value.trim(), nowIndex);
            break;
        case 40:  // ↓
            nowIndex = Math.min(nowIndex + 1, matchDataList.length - 1);
            showList(matchDataList, $searchTag.value.trim(), nowIndex);
            break;
        case 13:  // Enter
            if (matchDataList.length === 0 || nowIndex < 0) return;
            addTag(matchDataList[nowIndex]);
            resetAutoComplete();
            return;
        case 27:  // ESC
            resetAutoComplete();
            return;
    }
});


// ② 실제 검색 → input 이벤트로 처리
// 화살표키가 한글 IME에서 input을 발생시킬 때 → lastSearchedValue로 중복 방지
$searchTag.addEventListener("input", () => {
    const value = $searchTag.value.trim();

    if (!value) {
        matchDataList = [];
        $autoComplete.innerHTML = "";
        lastSearchedValue = "";
        return;
    }

    if (value === lastSearchedValue) return;  // 같은 값이면 재검색 안 함
    lastSearchedValue = value;

    clearTimeout(searchDebounceTimer);
    searchDebounceTimer = setTimeout(() => {  // 200ms debounce
        $.ajax({
            url: "/product/tags",
            data: { keyword: value },
            success: function(result) {
                matchDataList = result;
                nowIndex = 0;
                showList(matchDataList, value, nowIndex);
            },
            error: function() {
                matchDataList = [];
                $autoComplete.innerHTML = "";
            }
        });
    }, 200);
});

profile
우측 상단 햇님모양 클릭하셔서 무조건 야간모드로 봐주세요!!

0개의 댓글