검색어 자동완성
사용자가 단어를 입력하면 해당 단어로 시작하거나 끝나는 단어를 검색하여 결과를 화면에 출력하는 기능을 정리한 글이다.
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로 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
로 참조되어 강조 표시된다.
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
GenericJackson2JsonRedisSerializer
와 Jackson2JsonRedisSerializer
의 차이에 대해서 보고 내가 이해한 바로는
구분 | GenericJackson2JsonRedisSerializer | Jackson2JsonRedisSerializer |
---|---|---|
타입 정보 저장 | 자동으로 @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 직렬화/역직렬화
정규식