Home 검색어 자동완성
Post
Cancel

검색어 자동완성

검색어 자동완성

사용자가 단어를 입력하면 해당 단어로 시작하거나 끝나는 단어를 검색하여 결과를 화면에 출력하는 기능을 정리한 글이다.



1. DB → Cache 저장

사용자가 검색어를 입력할 때마다 DB를 실시간으로 조회하면 성능 저하가 발생할 수 있다.

이를 해결하기 위해 DB 데이터를 Redis Cache에 저장하여 조회 성능을 개선했다.




RedisTemplate을 활용한 데이터 캐싱

ReidsConfig에 일부 로직을 추가하면서 공식문서를 읽다보니 @Resource를 알게되었다.



@Resource

Bean 이름 기반으로 특정 RedisTemplate을 주입받는다.

1
2
@Resource(name = "maxPageRedisTemplate")
private final RedisTemplate<String, Integer> numTemplate;

동일한 타입의 Bean이 여러 개일 경우 @Resource를 사용해야한다.


기존에 @Resource를 작성하지 않아도 동작했던 이유는

생성자 주입을 통한 타입 기반 주입을 수행해 동일한 타입의 Bean이 하나이기 때문에 별도의 지정 없이도 동작한 것이다.



  • @Autowired

    • 기본적으로 타입 기반으로 Bean을 주입한다.

    • 동일 타입의 Bean이 여러 개 있을 경우, 모호성이 발생한다.

  • @Resource

    • 기본적으로 이름 기반으로 Bean을 주입한다.

    • 이름으로 특정 Bean을 선택할 수 있으며, type 옵션을 지정하면 타입 기반으로도 동작할 수 있다.


1
2
@Resource(type = RedisConfig.class)
private final RedisTemplate<String, String> redisTemplate;





실행

모든 Bean 초기화 후 실행되는 CommandLineRunner를 사용해 Redis Cache에 데이터를 저장했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootApplication
public class EngApplication {

    public static void main(String[] args) {
        SpringApplication.run(EngApplication.class, args);
    }

    @Bean
    public CommandLineRunner run(SearchService searchService) {
        return args -> {
            searchService.addWordRedis();
        };
    }
}

모든 의존성 Bean이 초기화된 후 addWordRedis()를 실행할 수 있어

NullPointerException이나 의도치 않은 오류를 방지할 수 있다.





Redis 데이터 저장

db에 저장된 모든 단어와 그 뜻을 조회해서 Redis에 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Resource(name="addWordRedisTemplate")
private final RedisTemplate<String, List<String>> redisTemplate;

public void addWordRedis() {
    List<Word> all = wordRepository.findAll();
    for(Word word : all){
        List<MeaningDto> meaning = meanRepository.findByWordApplicableMean(word.getId());
        List<String> meaningList = meaning.stream()
                        .map(MeaningDto::getMeaning) // .map(meaningDto -> meaningDto.getMeaning());
                .toList();
        redisTemplate.opsForValue().set(word.getWord(), meaningList);
    }
}





2. 단어 검색

사용자가 검색창에 단어를 입력하면 Redis에서 해당 단어로 시작하거나 끝나는 단어를 검색한다.

검색창 하단에 자동완성 결과로 표시된다.


JS

입력 event

1
2
3
4
5
6
searchInput.addEventListener("input", () => {
    const query = searchInput.value;
    if (query.length > 0) {
        
    }
});

사용자가 입력을 하는 순간 부터 서버와 통신한다.




1
2
if (Object.entries(data[0]).length>0 || Object.entries(data[1]).length>0) {
}

배열안에 든 값이 없더라도 data자체의 길이는 2로 출력된다. (startWith, endWith)

따라서 배열안에 든 값이 0일 경우에는 자동완성 검색 html이 띄워지지 않게 작성했다.




*startWith와 endWith는 모두 동일한 로직

1
2
3
4
5
6
let startData = data[0];
Object.entries(startData).forEach(([key, values]) => {
    const li = document.createElement('li');
    li.innerHTML = `${highlightMatching(key, query)} : ${values.join(', ')}`;
    suggestions.appendChild(li);  
});

Object.entries

서버에서 받아오는 데이터 타입은 Object로 Object.entries()[key, value] 쌍의 배열을 반환한다.

[key, value] 형태로 값을 반환하기 때문에 parameter로 [key, value]를 작성했다.




highlight

사용자가 입력한 단어는 파란색으로 표시되게 작성했다. (ex. d를 입력했을 때 deploy의 d만 파란색)

1
2
3
4
function highlightMatching(word, query) {
    const regex = new RegExp(`(${query})`, 'gi'); // 입력된 문자열과 일치하는 부분
    return word.replace(regex, '<span class="highlight">$1</span>'); // 강조 표시
}

1) 정규식 생성

  • new RegExp(pattern, flags)를 사용하여 정규식 생성

    • pattern : 찾고자 하는 단어(query)

    • flags :

      • g: 전역 검색 (모든 일치 항목을 찾음) *g flag를 사용하지 않는다면 처음으로 매칭되는 값만을 가져오게 된다.

      • i: 대소문자 구분 없이 검색


1
const regex = new RegExp(`(${query})`, 'gi'); // 대소문자 구분 없이, 모든 위치에서 찾음

사용자가 a를 입력하면 정규식 /a/gi가 생성되어 모든 a를 검색한다.



2) 문자열 강조

  • replace(): 문자열에서 일치하는 패턴을 대체 (.replace(정규식, 대체문자))

    • 첫 번째 인자: 찾을 패턴 (정규식 사용 가능)

    • 두 번째 인자: 대체할 내용

  • 캡처 그룹 (()$1)

    • (): 정규식에서 패턴의 일부를 캡처한다.

    • $1: 캡처된 첫 번째 그룹을 참조한다.



()로 둘러싸인 부분은 정규식에서 캡처 그룹을 의미한다.

캡처 그룹은 일치하는 문자열을 저장하거나 참조할 수 있게 만든다.

1
2
3
4
5
const regex = /([a-z]+)([0-9]+)/;
const result = 'abc123'.match(regex);

console.log(result[1]); // 'abc' (첫 번째 캡처 그룹)
console.log(result[2]); // '123' (두 번째 캡처 그룹)



1
return word.replace(regex, '<span class="highlight">$1</span>');

정규식과 일치하는 단어를 <span> 태그로 감싸 강조한다.

$1: 캡처 그룹에서 첫 번째 그룹에 해당하는 값을 참조한다.



따라서 사용자가 입력한 a가 단어에 여러 번 등장하면, 모든 a가 개별적으로 $1로 참조되어 강조 표시된다.

image


ex) abstraction 문자열에서 a를 검색하면 모든 a를 찾는다.

만약 g flag를 사용하지 않으면 첫번째 a만 강조된다.





Redis 직렬화/역직렬화 문제

Redis에 데이터를 저장하고 가져오는 과정에서 직렬화/역직렬화 문제로 인해 아래와 같은 오류가 발생했다.

com.fasterxml.jackson.databind.exc.InvalidTypeIdException:
Could not resolve type id ‘설계하다’ as a subtype of java.lang.Object: no such class found



JSON 직렬화 시 타입 정보가 포함되지 않아 Jackson이 역직렬화 시 적절한 타입을 찾지 못해 발생한 문제였다.

이 문제는 기존에 적용하던 캐시 중에 List<Dto>로 받고 있던 로직을 보고 힌트를 얻었다.



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public RedisTemplate<String, List<String>> addWordRedisTemplate() {
    RedisTemplate<String, List<String>> template = new RedisTemplate<>();
    template.setConnectionFactory(redisConnectionFactory());
    template.setKeySerializer(new StringRedisSerializer());

    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.activateDefaultTyping(
            objectMapper.getPolymorphicTypeValidator(),
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
    );
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));// Value를 JSON으로 직렬화
    return template;
}

GenericJackson2JsonRedisSerializer는 타입 정보를 포함한 JSON으로 데이터를 저장하고,

역직렬화 시 이 정보를 기반으로 데이터를 변환한다.

하지만 String은 기본 타입으로 타입 정보(@class 필드)가 포함되지 않아

역직렬화할 때 Jackson이 타입(List<String>)을 추론하지 못해 오류가 발생한 것이다.



따라서 Jackson의 ObjectMapper 설정에서 타입 정보를 강제로 저장하도록 수정했다.

DefaultTyping.EVERYTHING 옵션을 사용해 모든 타입(기본 타입 포함)에 대해 타입 정보를 저장하도록 설정했다.


사용자 정의 객체(List<Dto> 등)는 기본적으로 Jackson이 타입 정보를 포함하므로 DefaultTyping.NON_FINAL로도 동작했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public RedisTemplate<String, List<String>> addWordRedisTemplate() {
    RedisTemplate<String, List<String>> template = new RedisTemplate<>();
    template.setConnectionFactory(redisConnectionFactory());
    template.setKeySerializer(new StringRedisSerializer());

    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.activateDefaultTyping(
            objectMapper.getPolymorphicTypeValidator(),
            ObjectMapper.DefaultTyping.EVERYTHING,
            JsonTypeInfo.As.PROPERTY
    );
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));// Value를 JSON으로 직렬화
    return template;
}

*DefaultTyping.EVERYTHING 옵션은 Deprecated 되었다.



GenericJackson2JsonRedisSerializer vs Jackson2JsonRedisSerializer

GenericJackson2JsonRedisSerializerJackson2JsonRedisSerializer의 차이에 대해서 보고 내가 이해한 바로는


구분GenericJackson2JsonRedisSerializerJackson2JsonRedisSerializer
타입 정보 저장자동으로 @class 필드에 해당 클래스의 패키지 정보까지 포함하여 저장@class 필드 없이 타입 정보 없이 JSON 형태로 저장
유연성별도의 Class Type을 지정할 필요 없이 모든 객체를 JSON으로 직렬화직렬화할 Class Type을 지정해야 하며 지정된 타입만 직렬화 가능
적용 예시여러 객체 타입(User, Board, Comment 등)을 하나의 Redis 캐시에 저장특정 타입(List<String> 등)을 캐시에 저장하는 경우에 사용
타입 추론타입 추론을 자동으로 수행하여 다양한 타입을 저장할 수 있음지정된 타입에 대해서만 직렬화/역직렬화가 가능
단점저장된 데이터에 패키지 정보까지 포함되어 다른 프로젝트에서 사용 시 경로가 일치해야 함지정된 클래스 타입만 직렬화/역직렬화할 수 있어 유연성이 낮음


GenericJackson2JsonRedisSerializer

모든 객체를 JSON 형태로 직렬화할 수 있으며 @class 필드에 클래스의 패키지 정보까지 저장한다.

이로 인해 같은 패키지 경로와 이름을 가진 클래스만 역직렬화가 가능하다.

(다른 프로젝트에서 사용하려면 패키지 경로를 일치시켜야 한다.)

다양한 객체 타입을 유연하게 처리할 수 있기 때문에 여러 객체를 다루는 경우 적합하다.


Jackson2JsonRedisSerializer

직렬화할 클래스 타입을 명시해야 하므로 List<String>와 같이 특정 타입만 직렬화할 수 있다.


나는 여러 객체를 활용해 캐시를 저장하는 것이 아니라 특정 타입(List<String>)만 캐시에 저장한다.

따라서 Jackson2JsonRedisSerializer를 사용하게 되었다.


1
2
3
4
5
6
7
8
@Bean
public RedisTemplate<String, List<String>> addWordRedisTemplate() {
    RedisTemplate<String, List<String>> template = new RedisTemplate<>();
    template.setConnectionFactory(redisConnectionFactory());
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new Jackson2JsonRedisSerializer<>(List.class));
    return template;
}






REFERENCE

Redis 직렬화/역직렬화

정규식

This post is licensed under CC BY 4.0 by the author.