Skip to content
This repository was archived by the owner on Jan 2, 2026. It is now read-only.

Commit 30e935d

Browse files
authored
✨ Feature: ee-doc 데이터 인덱스 저장
* ✨ Feat: ee_doc 파일 인덱싱 기능 구현 * ✨ Feat: 자동완성 검색 기능 구현 * ♻️ Refactor: 핵사고날 구조에 적용 * ♻️ Refactor: 자동완성 기능 아키텍쳐 적용 및 예외처리 추가 * ♻️ Refactor: 인덱싱 저장 로직 청크 단위로 저장하도록 변경 * ♻️ Refactor: List값 응답 객체에 담아서 보내도록 변경 * ♻️ Refactor: Mapper 클래스 분리 * 💄 Style: JavaDoc 주석문 추가 * ✨ Feat: 증상기반 약품 검색 기능 개발 * 💄 Style: Mapper 클래스 주석문 추가
1 parent e74fc3a commit 30e935d

22 files changed

Lines changed: 951 additions & 19 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.likelion.backendplus4.yakplus.drug.application.service;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.PageRequest;
7+
import org.springframework.stereotype.Service;
8+
9+
import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug;
10+
import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.DrugSymptomEsAdapter;
11+
import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.GovDrugJpaAdapter;
12+
import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument;
13+
import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.SymptomMapper;
14+
import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomList;
15+
import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomResponse;
16+
import com.likelion.backendplus4.yakplus.drug.presentation.controller.dto.DrugSymptomSearchListResponse;
17+
18+
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
20+
21+
/**
22+
* 약품 증상 데이터를 처리하는 서비스입니다.
23+
* @sice 2025-04-24
24+
* @modified 2025-04-25
25+
*/
26+
@Service
27+
@RequiredArgsConstructor
28+
@Slf4j
29+
public class DrugSymptomService {
30+
31+
private static final int CHUNK_SIZE = 1_000;
32+
33+
private final GovDrugJpaAdapter drugJpaAdapter;
34+
private final DrugSymptomEsAdapter symptomAdapter;
35+
36+
/**
37+
* DB에서 약품 데이터를 페이징으로 가져와 Elasticsearch에 일괄 색인합니다.
38+
* 각 페이지는 CHUNK_SIZE만큼 처리되며, 모든 데이터를 순차적으로 색인합니다.
39+
*
40+
* @author 박찬병
41+
* @since 2025-04-24
42+
* @modified 2025-04-25
43+
*/
44+
public void indexAll() {
45+
int page = 0;
46+
Page<GovDrug> drugPage;
47+
48+
do {
49+
// 1. 페이징으로 DB에서 한 청크 가져오기
50+
drugPage = drugJpaAdapter.findAllDrugs(PageRequest.of(page, CHUNK_SIZE));
51+
52+
// 2. 도메인 → ES Document 변환
53+
List<DrugSymptomDocument> docs = drugPage.stream()
54+
.map(SymptomMapper::toDocument) // 내부에서 예외 처리 됨
55+
.toList();
56+
57+
// 3. 청크별 ES에 색인
58+
symptomAdapter.saveAll(docs);
59+
60+
// 4. 다음 1000개 값 루프
61+
page++;
62+
} while (drugPage.hasNext());
63+
}
64+
65+
/**
66+
* 주어진 사용자 입력 문자열을 바탕으로 증상 자동완성 키워드를 가져옵니다.
67+
* Elasticsearch에서 Suggest API 등을 활용하여 추천 결과를 반환합니다.
68+
*
69+
* @param q 사용자 입력 문자열
70+
* @return 자동완성 추천 결과 리스트 DTO
71+
* @author 박찬병
72+
* @since 2025-04-24
73+
* @modified 2025-04-25
74+
*/
75+
public DrugSymptomSearchListResponse getSymptomAutoComplete(String q) {
76+
return new DrugSymptomSearchListResponse(symptomAdapter.getSearchAutoCompleteResponse(q));
77+
}
78+
79+
/**
80+
* 주어진 증상 키워드로 검색하여 약품명 리스트를 반환합니다.
81+
*
82+
* @param q 검색어 프리픽스
83+
* @param page 조회할 페이지 번호
84+
* @param size 페이지 당 문서 수
85+
* @return 중복 제거된 약품명 리스트
86+
* @since 2025-04-25
87+
* @modified 2025-04-25
88+
*/
89+
public DrugSymptomList searchDrugNamesBySymptom(String q, int page, int size) {
90+
List<DrugSymptomResponse> drugSymptomResponses = symptomAdapter.searchDocsBySymptom(q, page, size)
91+
.stream()
92+
.map(SymptomMapper::toResponse)
93+
.toList();
94+
95+
return new DrugSymptomList(drugSymptomResponses);
96+
}
97+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.likelion.backendplus4.yakplus.drug.domain.model;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Builder
12+
public class DrugSymptom {
13+
private Long drugId;
14+
private String drugName;
15+
private String symptom;
16+
}

src/main/java/com/likelion/backendplus4/yakplus/drug/domain/model/GovDrug.java

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,24 +53,24 @@ public List<Material> getMaterialInfo() {
5353
return matrerials;
5454
}
5555

56-
public List<String> getEfficacy() {
57-
List<String> efficacys = new ArrayList<>();
58-
try {
59-
ObjectMapper objectMapper = new ObjectMapper();
60-
JsonNode json = objectMapper.readTree(this.efficacy);
61-
for (JsonNode section : json.get("sections")) {
62-
for (JsonNode article : section.get("articles")) {
63-
for (JsonNode paragraph : article.get("paragraphs")) {
64-
efficacys.add(paragraph.get("text").asText());
65-
}
66-
}
67-
}
68-
} catch (JsonProcessingException e) {
69-
//TODO: 예외처리
70-
throw new RuntimeException(e);
71-
}
72-
return efficacys;
73-
}
56+
// public List<String> getEfficacy() {
57+
// List<String> efficacys = new ArrayList<>();
58+
// try {
59+
// ObjectMapper objectMapper = new ObjectMapper();
60+
// JsonNode json = objectMapper.readTree(this.efficacy);
61+
// for (JsonNode section : json.get("sections")) {
62+
// for (JsonNode article : section.get("articles")) {
63+
// for (JsonNode paragraph : article.get("paragraphs")) {
64+
// efficacys.add(paragraph.get("text").asText());
65+
// }
66+
// }
67+
// }
68+
// } catch (JsonProcessingException e) {
69+
// //TODO: 예외처리
70+
// throw new RuntimeException(e);
71+
// }
72+
// return efficacys;
73+
// }
7474

7575
public Map<WarningType, List<String>> getPrecaution() {
7676
ObjectMapper objectMapper = new ObjectMapper();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.likelion.backendplus4.yakplus.drug.exception;
2+
3+
import com.likelion.backendplus4.yakplus.common.exception.CustomException;import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode;
4+
5+
public class EsSuggestException extends CustomException {
6+
private final ErrorCode errorCode;
7+
8+
public EsSuggestException(ErrorCode errorCode) {
9+
super(errorCode);
10+
this.errorCode = errorCode;
11+
}
12+
13+
@Override
14+
public ErrorCode getErrorCode() {
15+
return errorCode;
16+
}
17+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.likelion.backendplus4.yakplus.drug.exception.error;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import com.likelion.backendplus4.yakplus.common.exception.error.ErrorCode;
6+
7+
import lombok.RequiredArgsConstructor;
8+
9+
@RequiredArgsConstructor
10+
public enum EsErrorCode implements ErrorCode {
11+
ES_SUGGEST_SEARCH_FAIL(440001, HttpStatus.INTERNAL_SERVER_ERROR, "검색어 자동완성에 실패했습니다."),
12+
ES_SEARCH_FAIL(440002, HttpStatus.INTERNAL_SERVER_ERROR, "증상 검색에 실패했습니다.");
13+
14+
private final int codeNumber;
15+
private final HttpStatus httpStatus;
16+
private final String message;
17+
18+
@Override
19+
public HttpStatus httpStatus() {
20+
return httpStatus;
21+
}
22+
23+
@Override
24+
public int codeNumber() {
25+
return codeNumber;
26+
}
27+
28+
@Override
29+
public String message() {
30+
return message;
31+
}
32+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter;
2+
3+
4+
import static org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName.*;
5+
6+
import java.io.IOException;
7+
import java.util.List;
8+
import java.util.Objects;
9+
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.annotation.Propagation;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import com.likelion.backendplus4.yakplus.drug.domain.model.DrugSymptom;
15+
import com.likelion.backendplus4.yakplus.drug.exception.EsSuggestException;
16+
import com.likelion.backendplus4.yakplus.drug.exception.error.EsErrorCode;
17+
import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.document.DrugSymptomDocument;
18+
import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.elasticsearch.DrugSymptomRepository;
19+
import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.SymptomMapper;
20+
21+
import co.elastic.clients.elasticsearch.ElasticsearchClient;
22+
import co.elastic.clients.elasticsearch.core.SearchResponse;
23+
import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption;
24+
import co.elastic.clients.elasticsearch.core.search.Hit;
25+
import lombok.RequiredArgsConstructor;
26+
import lombok.extern.slf4j.Slf4j;
27+
28+
/**
29+
* Elasticsearch를 통해 약품 증상 문서를 색인하고,
30+
* 자동완성 제안 결과를 제공하는 어댑터입니다.
31+
*
32+
* @author 박찬병
33+
* @since 2025-04-24
34+
* @modified 2025-04-25
35+
*/
36+
@Component
37+
@Slf4j
38+
@RequiredArgsConstructor
39+
public class DrugSymptomEsAdapter {
40+
41+
private final DrugSymptomRepository symptomRepository;
42+
private final ElasticsearchClient esClient;
43+
44+
/**
45+
* 주어진 증상 문서 리스트를 Elasticsearch에 색인합니다.
46+
*
47+
* @param docs 색인할 DrugSymptomDocument 객체 리스트
48+
* @author 박찬병
49+
* @modified 2025-04-25
50+
* @since 2025-04-24
51+
*/
52+
@Transactional(propagation = Propagation.REQUIRES_NEW)
53+
public void saveAll(List<DrugSymptomDocument> docs) {
54+
symptomRepository.saveAll(docs);
55+
}
56+
57+
/**
58+
* 사용자 입력 키워드를 바탕으로 Elasticsearch Suggest API를 호출해
59+
* 자동완성 추천 단어 리스트를 반환합니다.
60+
*
61+
* @param q 사용자 입력 문자열
62+
* @return 추천 키워드 리스트
63+
* @throws EsSuggestException 자동완성 API 호출 실패 시 발생
64+
* @author 박찬병
65+
* @modified 2025-04-25
66+
* @since 2025-04-24
67+
*/
68+
public List<String> getSearchAutoCompleteResponse(String q) {
69+
SearchResponse<Void> resp;
70+
try {
71+
resp = esClient.search(s -> s
72+
.index("eedoc")
73+
.suggest(su -> su
74+
.suggesters("symp_sugg", sg -> sg
75+
.prefix(q)
76+
.completion(c -> c
77+
.field("symptomSuggester")
78+
.analyzer("symptom_search_autocomplete") // ← 이 줄만 추가
79+
.size(20)
80+
)
81+
)
82+
)
83+
, Void.class);
84+
} catch (IOException e) {
85+
throw new EsSuggestException(EsErrorCode.ES_SUGGEST_SEARCH_FAIL);
86+
}
87+
88+
// Suggest 파싱
89+
return resp.suggest().get("symp_sugg")
90+
.get(0).completion().options().stream()
91+
.map(CompletionSuggestOption::text)
92+
.distinct()
93+
.toList();
94+
}
95+
96+
/**
97+
* 검색어에 매칭되는 증상 문서 리스트를 Elasticsearch에서 조회합니다.
98+
*
99+
* @param q 검색어 프리픽스
100+
* @param page 조회할 페이지 번호 (0부터 시작)
101+
* @param size 페이지 당 문서 수
102+
* @return 증상 문서 리스트
103+
* @throws EsSuggestException 검색 중 오류 발생 시
104+
*/
105+
public List<DrugSymptom> searchDocsBySymptom(String q, int page, int size) {
106+
try {
107+
SearchResponse<DrugSymptomDocument> resp = esClient.search(s -> s
108+
.index(INDEX)
109+
.from(page * size)
110+
.size(size)
111+
.query(qb -> qb
112+
.multiMatch(mm -> mm
113+
.fields("symptom")
114+
.query(q)
115+
.fuzziness("AUTO")
116+
)
117+
), DrugSymptomDocument.class);
118+
return resp.hits().hits().stream()
119+
.map(Hit::source)
120+
.filter(Objects::nonNull)
121+
.map(SymptomMapper::toDomain)
122+
.toList();
123+
} catch (IOException e) {
124+
log.error("ES 증상 문서 검색 실패: q={}", q, e);
125+
throw new EsSuggestException(EsErrorCode.ES_SEARCH_FAIL);
126+
}
127+
}
128+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.likelion.backendplus4.yakplus.drug.infrastructure.adapter;
2+
3+
4+
import org.springframework.data.domain.Page;
5+
import org.springframework.data.domain.Pageable;
6+
import org.springframework.stereotype.Component;
7+
8+
import com.likelion.backendplus4.yakplus.drug.domain.model.GovDrug;
9+
import com.likelion.backendplus4.yakplus.drug.infrastructure.adapter.persistence.repository.jpa.GovDrugJpaRepository;
10+
import com.likelion.backendplus4.yakplus.drug.infrastructure.support.mapper.DrugDataMapper;
11+
12+
import lombok.RequiredArgsConstructor;
13+
14+
/**
15+
* DB에서 약품 데이터를 다루는 어댑터입니다.
16+
*
17+
* @author 박찬병
18+
* @since 2025-04-24
19+
* @modified 2025-04-25
20+
*/
21+
@Component
22+
@RequiredArgsConstructor
23+
public class GovDrugJpaAdapter {
24+
25+
private final GovDrugJpaRepository drugJpaRepository;
26+
27+
/**
28+
* 주어진 Pageable 정보에 따라 DB에서 한 페이지 분량의 GovDrugEntity를 조회하고,
29+
* 각 엔티티를 도메인 모델(GovDrug)로 변환하여 Page 형태로 반환합니다.
30+
*
31+
* @param pageable 조회할 페이지 번호와 크기를 포함하는 Pageable 객체
32+
* @return 페이지 단위로 변환된 GovDrug 도메인 객체의 Page
33+
* @author 박찬병
34+
* @since 2025-04-24
35+
* @modified 2025-04-25
36+
*
37+
*/
38+
public Page<GovDrug> findAllDrugs(Pageable pageable) {
39+
return drugJpaRepository.findAll(pageable)
40+
.map(DrugDataMapper::toDomainFromEntity);
41+
}
42+
}

0 commit comments

Comments
 (0)