intro
앱 기능 중 음식명으로 레시피를 조회하는 기능이 있습니다. 이 검색 기능에는 ElasticSearch를 사용해서 구현했습니다. ElasticSearch를 사용하면 성능이 매우 좋다는 정보를 많이 접했기 때문에 이를 기반으로 구현해 보았습니다.
elasticsearch를 사용한 이유
엘라스틱서치를 검색 엔진으로 사용할 때 독보적인 성능을 가지고 있습니다. 그 이유는 데이터를 저장하는 방식이 rdb와는 다른 방식을 사용해서 저장을 하고 있습니다.
엘라스틱 서치에서는 색인이라고 표현하는데 rdb에서 인덱스라고 표현하는 것을 역인덱스 구조로 저장하는 방식입니다.
키워드에 인덱스를 주어지게 되어서 검색할 때 키워드를 이용해서 찾기 때문에 빠른 검색을 할 수 있습니다.
Tool
- Spring 2.7.7
- ElasticSearch 7.17.3
- Google Cloud Service
- GCP에서 무료 크레딧을 제공해주어서 GCP를 이용해서 서버를 구축해 보았습니다.*
원격 접속해서 docker를 이용해서 ElasticSearch 서버를 구축했습니다
ssh 키 만들어주기
$ ssh-keygen -t rsa -f ~/.ssh/[키이름] -C [gmail계정] -b 4096
생성된 pub 파일 내용을 메타 데이터에 ssh키 등록해주기
ssh -i [private 키파일경로] [계정]@[외부IP]
레시피 db에 저장하기
만개의 레시피 데이터를 json 파일로 db에 저장하기 쉽게 만들어 두었습니다.
- 일단 local에서 테스트 하기 위해 데이터를 db에 저장해주었습니다.
GCP에 elasticsearch 서버 구축하기
위에서 생성한 GCP 인스턴스에 원격접속해서 docker를 이용해서 elasticsearch 컨테이너를 실행해주었습니다.
도커 설치
//설치
sudo apt update
sudo apt install apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"
sudo apt-get update && sudo apt-get install docker-ce docker-ce-cli containerd.io
//실행
sudo systemctl enable docker
sudo service docker start
//확인
sudo service docker status
elasticsearch, kibana 설치
//자신에게 맞는 버전 설치
//elasticsearch
sudo docker pull docker.elastic.co/elasticsearch/elasticsearch:7.17.3
sudo docker volume create elasticsearch-volume
sudo docker run -d -p 9200:9200 -e "discovery.type=single-node" --name elasticsearch docker.elastic.co/elasticsearch/elasticsearch:7.17.3
sudo docker ps
//kibana
sudo docker pull docker.elastic.co/kibana/kibana:7.17.3
sudo docker run -d --link elasticsearch:elasticsearch -p 5601:5601 docker.elastic.co/kibana/kibana:7.17.3
sudo docker ps
잘 작동하는지 확인하기 위해 gcp 9200포트와 5601포트를 열어보았습니다
Nori Tokenizer 설치
한글을 형태소 단위로 분석하여 검색을 할 수 있게 지원해주는 Nori tokenizer를 설치해 주었습니다.
sudo docker exec -it {elasticsearch 컨테이너 이름} /bin/bash
./bin/elasticsearch-plugin install analysis-nori
보안설정
//elasticsearch 컨테이너 접속
sudo docker exec -it elasticsearch /bin/bash
vi /usr/share/elasticsearch/config/elasticsearch.yml
//xpack.security.enabled: true
//xpack.security.transport.ssl.enabled: true 추가 후
// /usr/share/elasticsearch/bin 폴더에서 아래 코드 실행후 비밀번호 설정
elasticsearch-setup-passwords interactive
//kibana 컨테이너 접속
sudo docker exec -it {kibana 커넽이너 이름} /bin/bash
vi config/kibana.yml
//elasticsearch.username: "elastic"
//elasticsearch.password: "비밀번호" 추가하기
암호가 생긴 것을 확인할 수 있습니다
spring batch로 수집한 데이터 ELK로 전송하기
주기적으로 db에 있는 레시피 데이터를 elasticsearch에 저장하기 위해서 spring batch를 사용했습니다.
@Slf4j
@Configuration
@EnableBatchProcessing
@AllArgsConstructor
public class ElasticBatchConfiguration {
private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
private final RecipeRepository recipeRepository;
private final RecipeElasticRepository recipeDocumentRepository;
@Bean
public ItemReader<Recipe> reader() {
LocalDateTime lastProcessedDate = getLastProcessedDate(); // 마지막 처리 시점 가져오기
return new RepositoryItemReaderBuilder<Recipe>()
.repository(recipeRepository)
.methodName("findUpdatedAfter")
.arguments(lastProcessedDate)
.pageSize(200)
.sorts(Collections.singletonMap("id", Sort.Direction.DESC))
.name("recipeItemReader")
.build();
}
@Bean
public ItemProcessor<Recipe, RecipeDocument> processor() {
return recipe -> RecipeDocument.builder()
.id(recipe.getId().toString())
.name(recipe.getName())
.info(recipe.getInfo())
.description(recipe.getDescription())
.ingredient(recipe.getIngredient())
.recommendCount(recipe.getRecommendCount())
.bookmark(recipe.getBookmark())
.build();
}
@Bean
public ItemWriter<RecipeDocument> writer() {
return items -> {
log.info("Writing {} items to ElasticSearch", items.size());
recipeDocumentRepository.saveAll(items);
};
}
@Bean
public TaskExecutor taskExecutor() {
return new SimpleAsyncTaskExecutor("batch_task_executor");
}
@Bean
public Step step1() {
return stepBuilderFactory.get("step1")
.<Recipe, RecipeDocument>chunk(200)
.reader(reader())
.processor(processor())
.writer(writer())
.taskExecutor(taskExecutor()) // 병렬 처리
.throttleLimit(4)
.build();
}
@Bean
public Job importRecipeJob(JobRepository jobRepository) {
return jobBuilderFactory.get("importRecipeJob")
.incrementer(new RunIdIncrementer())
.preventRestart()
.flow(step1())
.end()
.build();
}
}
elasticSearch에 데이터가 잘 저장된 것을 확인할 수 있습니다.
elasticSearch에 저장된 데이터 조회 api 만들기
elasticSearch 연결을 위한 config 추가
@Configuration
@EnableElasticsearchRepositories(basePackages = "com.sm.project.elasticsearch.repository")
public class ElasticConfig {
@Value("${spring.elastic.url}")
private String elasticUrl;
@Value("${spring.elasticsearch.username}")
private String username;
@Value("${spring.elasticsearch.password}")
private String password;
@Bean
public RestHighLevelClient elasticsearchClient() {
final ClientConfiguration clientConfiguration = ClientConfiguration.builder()
.connectedTo(elasticUrl)
.withBasicAuth(username,password)
.build();
return RestClients.create(clientConfiguration).rest();
}
}
RecipeDocument를 조회할 수 있는 Elasticsearch Repository를 작성합니다.
@Repository("elasticSearchRepository")
public interface RecipeElasticRepository extends ElasticsearchRepository<RecipeDocument, Long> {
List<RecipeDocument> findByName(String name);
}
재료를 이용해서 검색했을때 추천수 기준으로 정렬해서 5개의 레시피를 보여주도록 로직을 작성합니다.
@Service
@AllArgsConstructor
@Transactional
public class RecipeService {
private final RecipeElasticRepository recipeDocumentRepository;
// 추천수 높은 순으로 페이징된 레시피를 조회하는 메소드
public Page<RecipeDocument> findTopRecipes(int lastIndex, int limit) {
Pageable pageable = PageRequest.of(lastIndex / limit, limit, Sort.by(Sort.Direction.DESC, "recommendCount"));
return recipeDocumentRepository.findAllByOrderByRecommendCountDesc(pageable);
}
//검색한 재료로 레시피를 조회하는 메소드
public Page<RecipeDocument> searchByIngredient(String ingredient, int lastIndex, int limit) {
Pageable pageable = PageRequest.of(lastIndex / limit, limit, Sort.by(Sort.Direction.DESC, "recommendCount"));
return recipeDocumentRepository.findByIngredientContainingOrderByRecommendCountDesc(ingredient, pageable);
}
}
떡으로 검색한 결과가 잘 나온 것을 확인할 수 있습니다.
'Dev' 카테고리의 다른 글
상품 주문하기 동시성 문제 해결하기 (0) | 2024.12.04 |
---|---|
과도한 트래픽에 대한 방어하기 (1) | 2024.12.04 |
위치 기반으로 글 조회 기능 구현 (1) | 2024.12.04 |
fcm 이용해서 앱 푸쉬 구현 (0) | 2024.12.04 |
소켓 통신(채팅방 구현) (0) | 2024.12.04 |