ThreadLocal
여러 스레드가 동일한 객체를 쓰더라도, 각 스레드가 서로 독립적인 자신만의 데이터를 가지도록 해준다.
주요 사용처
1.
로그 추적
2.
인증/인가(로그인 사용자 정보) 임시 저장
3.
트랜잭션/커넥션 관리
ThreadLocal + 로그 추적기
ElasticSearch
ElasticSearch란?
•
ElasticSearch는 대용량 데이터를 실시간으로 저장, 검색, 분석할 수 있는 분산형 검색 엔진
인덱싱
•
문서를 특정 인덱스에 저장하는 과정
•
이 과정에서 역색인(Inverted Index) 구조를 만들어서 검색이 빠르게 가능
•
ElasticSearch에서는 인덱스가 MySQL의 테이블과 동일한 의미를 가짐
역색인이란?
•
원본 데이터
ElasticSearch 인덱싱의 실제 흐름
ElasticSearch 노드(node), 클러스터(cluster), 샤드(shard), 복제(replica)
•
노드란 서버 한 대를 의미
◦
모든 노드들은 Cluster로 하나의 논리적 묶음으로 동작
•
클러스터란 여러 대의 노드를 하나의 논리적인 그룹으로 묶은 것
•
샤드란 인덱스를 여러 개의 작은 조각으로 나눈 것
◦
샤드 개수는 한 번 생성하면 변경 불가, 복제 개수는 변경 가능
•
복제란 각 샤드의 사본
운영환경에서 ElasticSearch 검색엔진의 최소 권장 노드(서버) 수
•
ES(ElasticSearch)에서 만든 인덱스 매핑과 Spring에서 @Document 자바 클래스 구조는 반드시 데이터 타입과 필드명이 일치해야 함
•
ElasticSearch에서 기본적으로 사용하는 standard analyzer는
◦
문장을 단어 단위로 쪼개고,
◦
모든 단어를 소문자로 바꾸는,
◦
가장 기본적인 분석기(analyzer) 이다.
•
단어 단위로 쪼개서 검색을 하면 중간에 있는 단어도 검색 가능
•
prefix 쿼리는 “색인된 토큰(단어)”의 “앞부분(접두사)”만 찾는 쿼리
•
match: “포함” (부분일치/중간문자/끝글자도 잡을 수 있음, analyzer 영향 큼)
쿼리 종류
실습 - 검색은 ElasticSearch로 진행, CRUD는 MySQL에서
•
build.gradle 에 ElasticSearch 의존 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
Shell
복사
•
application-properties
•
ElasticSearch 서비스 위한 controller, dto, repository, service 생성
◦
dto
package org.example.backendproject.board.elasticsearch.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.example.backendproject.board.dto.BoardDTO;
import org.springframework.data.elasticsearch.annotations.Document;
@JsonIgnoreProperties(ignoreUnknown = true)
@Document(indexName = "board-index")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BoardEsDocument {
// 엘라스틱서치에 적용될 문서를 자바 객체로 정의한 클래스
// 엘라스틱 전용 DTO
@Id
private String id;
private String title;
private String content;
private String username;
private Long userId;
private String created_date;
private String updated_date;
// BoardDTO를 ElasticSearch 전용 DTO로 변환하는 정적 메서드
public static BoardEsDocument from(BoardDTO dto) {
// BoardDTO를 받아서 ElasticSearch DTO로 변환한다.
return BoardEsDocument.builder()
.id(String.valueOf(dto.getId()))
.title(dto.getTitle())
.content(dto.getContent())
.username(dto.getUsername())
.userId(dto.getUser_id())
.created_date(dto.getCreated_date() != null ? dto.getCreated_date().toString() : null)
.updated_date(dto.getUpdated_date() != null ? dto.getUpdated_date().toString() : null)
.build();
}
}
Java
복사
◦
repository
package org.example.backendproject.board.elasticsearch.repository;
import org.example.backendproject.board.elasticsearch.dto.BoardEsDocument;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BoardEsRepository extends ElasticsearchRepository<BoardEsDocument, String> {
// 문서 Id로 데이터 삭제하는 쿼리메서드
void deleteById(String id);
}
Java
복사
◦
service
package org.example.backendproject.board.elasticsearch.service;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
import co.elastic.clients.elasticsearch._types.query_dsl.MatchAllQuery;
import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
import co.elastic.clients.elasticsearch._types.query_dsl.PrefixQuery;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.BulkRequest;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import co.elastic.clients.elasticsearch.core.search.Hit;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.backendproject.board.elasticsearch.dto.BoardEsDocument;
import org.example.backendproject.board.elasticsearch.repository.BoardEsRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class BoardEsService {
// 엘라스틱서치에 명령을 전달하는 자바 API
private final ElasticsearchClient client;
private final BoardEsRepository repository;
// 데이터 저장 메서드
public void save(BoardEsDocument document) {
repository.save(document);
}
// 데이터 삭제 메서드
public void deleteById(String id) {
repository.deleteById(id);
}
// 검색 키워드와 페이지 번호와 페이지 크기를 받아서 엘라스틱서치에서 검색하는 메서드
// 검색된 정보와 페이징 정보도 함께 반환하도록 하기 위해 page 객체를 사용하여 반환
public Page<BoardEsDocument> search(String keyword, int page, int size) {
try {
// 엘라스틱서치에서 페이징을 위한 시작 위치를 계산하는 변수
int from = page * size;
// 엘라스틱서치에서 사용할 검색조건을 담는 객체
Query query;
// 검색어가 없으면 모든 문서를 검색하는 matchAll 쿼리
if (keyword == null || keyword.isBlank()) {
query = MatchAllQuery.of(m -> m)._toQuery(); // 전체 문서를 가져오는 쿼리를 생성하는 람다 함수
// MatchAllQuery 는 엘라스틱서치에서 조건 없이 모든 문서를 검색할 때 사용하는 쿼리
} else { // 검색어가 있을 때
// boolquery는 복수 조건을 조합할 때 사용하는 쿼리
// 이 쿼리 안에서 여러 개의 조건을 나열
// 예를 들어 "백엔드" 라는 키워드가 들어왔을 때 이 "백엔드" 키워드를 어떻게 분석해서 데이터를 보여줄 것인가를 작성
query = BoolQuery.of(b -> {
// PrefixQuery는 해당 필드가 특정 단어로 시작하는지 검사하는 쿼리
// MatchQuery는 해당 단어가 포함되어 있는지 검사하는 쿼리
/**
* must: 모두 일치해야 함(AND)
* should: 하나라도 일치해야 함(OR)
* must_not: 해당 조건을 만족하면 제외
* filter: must와 같지만 점수 계산 안함 (속도 빠름)
*/
// 접두어 글자 검색
b.should(PrefixQuery.of(p -> p.field("title").value(keyword))._toQuery());
// 접두어 글자 검색, PrefixQuery는 해당 필드가 특정 단어로 시작하는지 검사하는 쿼리
b.should(PrefixQuery.of(p -> p.field("content").value(keyword))._toQuery());
// 초성 (ㅈㅂ 입력 시 자바 관련 글 검색 가능)
b.should(PrefixQuery.of(p -> p.field("title.chosung").value(keyword))._toQuery());
b.should(PrefixQuery.of(p -> p.field("content.chosung").value(keyword))._toQuery());
// 중간 글자 검색 (match만 가능), 대소문자 상관없이 검색 가능
b.should(MatchQuery.of(m -> m.field("title.ngram").query(keyword))._toQuery());
b.should(MatchQuery.of(m -> m.field("content.ngram").query(keyword))._toQuery());
// fuzziness: "AUTO"는 오타 허용 검색 기능을 자동으로 켜주는 설정 -> 유사도 계산을 매번 수행하기 때문에 느림
// 짧은 키워드에는 사용 xxx
// 오타 허용 (오타허용은 match만 가능)
if (keyword.length() >= 3){
b.should(MatchQuery.of(m -> m.field("title").query(keyword).fuzziness("AUTO"))._toQuery());
b.should(MatchQuery.of(m -> m.field("content").query(keyword).fuzziness("AUTO"))._toQuery());
}
return b;
})._toQuery();
}
// SearchRequest는 엘라스틱서치에서 검색을 하기 위한 검색요청 객체
// 인덱스명, 페이징 정보, 쿼리를 포함한 검색 요청
SearchRequest request = SearchRequest.of(s -> s
.index("board-index")
.from(from)
.size(size)
.query(query)
);
// SearchResponse는 엘라스틱서치의 검색 결과를 담고 있는 응답 객체
SearchResponse<BoardEsDocument> response =
// 엘라스틱서치에 명령을 전달하는 자바 API 검색요청을 담아서 응답객체로 반환
client.search(request, BoardEsDocument.class);
// 위 응답객체에서 받은 검색 결과 중 문서만 추출해서 리스트로 만듦
// Hit는 엘라스틱서치에서 검색된 문서 1개를 감싸고 있는 객체
List<BoardEsDocument> content = response.hits() // 엘라스틱 서치 응답에서 hits(문서 검색결과) 전체를 꺼냄
.hits() // 검색 결과 안에 개별 리스트를 가져옴
.stream() // 자바 stream api를 사용
.map(Hit::source) // 각 Hit 객체에서 실제 문서를 꺼내는 작업
.collect(Collectors.toList()); // 위에서 꺼낸 객체를 자바 LIST에 넣음
// 전체 검색 결과 수 (총 문서의 갯수)
long total = response.hits().total().value();
// PageImpl 객체를 사용하여 Spring 에서 사용할 수 있는 page 객체로 변환
return new PageImpl<>(content, PageRequest.of(page, size), total);
} catch (IOException e) {
log.error("검색 오류 ", e.getMessage());
throw new RuntimeException("검색 중 오류 발생", e);
}
}
// 문서 리스트를 받아서 엘라스틱서치에 bulk 색인하는 메서드
public void bulkIndexInsert(List<BoardEsDocument> documents) throws IOException {
// 한 번에 처리할 묶음(batch) 크기를 설정
int batchSize = 1000;
for (int i = 0; i < documents.size(); i++) {
// 현재 batch 의 끝 인덱스를 구함
int end = Math.min(i + batchSize, documents.size());
// 현재 batch 단위의 문서 리스트를 잘라냄
List<BoardEsDocument> batch = documents.subList(i, end);
// 엘라스틱서치의 bulk 요청을 담을 빌더 생성
BulkRequest.Builder br = new BulkRequest.Builder();
// 각 문서를 bulk 요청 안에 하나씩 담음
for (BoardEsDocument document : batch) {
br.operations(op -> op // operations() 로 하나하나 문서를 담음
.index(idx -> idx // 인덱스에 문서를 저장하는 작업
.index("board-index") // 인덱스명
.id(String.valueOf(document.getId())) // 수동으로 Id 지정
.document(document) // 실제 저장할 문서 객체
)
);
}
// bulk 요청 실행 : batch 단위로 엘라스틱서치에 색인 수행
BulkResponse response = client.bulk(br.build());
// 벌크 작업 중 에러가 있는 경우 로그 출력
if (response.errors()) {
for (BulkResponseItem item : response.items()) {
if (item.error() != null) {
// 실패한 문서의 ID와 에러 내용을 출력
log.error("엘라스틱서치 벌크 색인 작업 중 오류 발생 ID: {}, 오류 : {}", item.id(), item.error());
}
}
}
}
}
}
Java
복사
◦
BoardService 쪽에 ElasticSearch 관련 기능 적용
/** 글 등록 **/
@Transactional
public BoardDTO createBoard(BoardDTO boardDTO) {
// userId(PK)를 이용해서 User 조회
if (boardDTO.getUser_id() == null) {
throw new IllegalArgumentException("userId(PK)가 필요합니다!");
}
// 연관관계 매핑!
// 작성자 User 엔티티 조회 (userId 필요)
User user = userRepository.findById(boardDTO.getUser_id())
.orElseThrow(() -> new IllegalArgumentException("작성자 정보가 올바르지 않습니다."));
// mysql 저장
Board board = new Board();
board.setTitle(boardDTO.getTitle());
board.setContent(boardDTO.getContent());
// 연관관계 매핑
board.setUser(user);
Board saved = boardRepository.save(board);
// mysql 저장 완료
// 엘라스틱서치에 저장 시작 - 추가
BoardEsDocument doc = BoardEsDocument.builder()
.id(String.valueOf(board.getId()))
.title(board.getTitle())
.content(board.getContent())
.userId(board.getUser().getId())
.username(board.getUser().getUserProfile().getUsername())
.created_date(String.valueOf(board.getCreated_date()))
.updated_date(String.valueOf(board.getUpdated_date()))
.build();
boardEsService.save(doc);
return toDTO(saved);
}
/** 게시글 수정 **/
@Transactional
public BoardDTO updateBoard(Long boardId, BoardDTO dto) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("게시글 없음: " + boardId));
board.setTitle(dto.getTitle());
board.setContent(dto.getContent());
boardRepository.save(board);
// 엘라스틱서치에 데이터 수정 - 추가
BoardEsDocument doc = BoardEsDocument.builder()
.id(String.valueOf(board.getId()))
.title(board.getTitle())
.content(board.getContent())
.userId(board.getUser().getId())
.username(board.getUser().getUserProfile().getUsername())
.created_date(String.valueOf(board.getCreated_date()))
.updated_date(String.valueOf(board.getUpdated_date()))
.build();
boardEsService.save(doc);
return toDTO(board);
}
/** 게시글 삭제 **/
@Transactional
public void deleteBoard(Long userId, Long boardId) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("사용자 정보가 없습니다"));
if (!board.getUser().getId().equals(userId)) {
throw new IllegalArgumentException("삭제 권한이 없습니다.");
}
if (!boardRepository.existsById(boardId)) {
throw new IllegalArgumentException("게시글 없음: " + boardId);
}
// mysql 삭제
boardRepository.deleteById(boardId);
// 엘라스틱서치 삭제 - 추가
boardEsService.deleteById(String.valueOf(boardId));
}
/** 배치 작업 (JdbcTemplate) **/
@Transactional
public void batchSaveBoard(List<BoardDTO> boardDTOList) {
Long start = System.currentTimeMillis();
int batchsize = 1000; // 한번에 처리할 배치 크기
for (int i = 0; i < boardDTOList.size(); i += batchsize) { // i는 1000씩 증가
// 전체 데이터를 1000개씩 잘라서 배치리스트에 담는다.
int end = Math.min(boardDTOList.size(), i + batchsize); // 두 개의 숫자 중 작은 수를 반환
List<BoardDTO> batchList = boardDTOList.subList(i, end);
// 전체 데이터에서 1000씩 작업을 하는데 마지막 데이터가 1000개가 안 될수도 있으니
// Math.min()으로 전체 크기를 넘지 않게 마지막 인덱스를 계산해서 작업한다.
// 내가 넣은 데이터만 엘라스틱서치에 동기화하기 위해 uuid 생성
String batchKey = UUID.randomUUID().toString();
for (BoardDTO dto : batchList) {
dto.setBatchKey(batchKey);
}
// 1. MySQL로 INSERT - 추가
batchRepository.batchInsert(batchList);
// 2. MySQL에 Insert한 데이터를 다시 조회 - 추가
List<BoardDTO> saveBoards = batchRepository.findByBatchKey(batchKey);
// 3. 엘라스틱서치용으로 변환 - 추가
List<BoardEsDocument> documents = saveBoards.stream()
.map(BoardEsDocument::from) // DTO -> 엘라스틱서치용 dto로 변환
.toList();
try {
// 4. 엘라스틱서치 bulk 인덱싱 - 추가
boardEsService.bulkIndexInsert(documents);
} catch (IOException e) {
log.error("[BOARD][BATCH] ElasticSearch 벌크 인덱싱 실패: {}", e.getMessage(), e);
}
}
Long end = System.currentTimeMillis();
log.info("[BOARD][BATCH] 전체 저장 소요 시간(ms): {}", (end - start));
}
Java
복사
◦
controller
package org.example.backendproject.board.elasticsearch.controller;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.board.elasticsearch.dto.BoardEsDocument;
import org.example.backendproject.board.elasticsearch.service.BoardEsService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
@RequestMapping("/boards")
public class BoardEsController {
private final BoardEsService boardEsService;
@GetMapping("/elasticsearch")
// 엘라스틱서치 검색 결과를 page 형태로 감싼 다음 HTTP 응답을 json으로 반환
public ResponseEntity<Page<BoardEsDocument>> elasticSearch(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return ResponseEntity.ok(boardEsService.search(keyword, page, size));
}
}
Java
복사
◦
BatchRepository 쪽에 ElasticSearch 관련 코드 추가
public List<BoardDTO> findByBatchKey(String batchKey) {
String sql = "SELECT b.id, b.title, b.content, b.user_id, b.created_date, b.updated_date, b.batch_key, up.username " +
"FROM board b " +
"JOIN user u ON b.user_id = u.id " +
"JOIN user_profile up ON up.user_id = u.id " +
"WHERE b.batch_key = ?";
return jdbcTemplate.query(sql, new Object[]{batchKey}, (rs, rowNum) -> {
BoardDTO dto = new BoardDTO();
dto.setId(rs.getLong("id"));
dto.setTitle(rs.getString("title"));
dto.setContent(rs.getString("content"));
dto.setUser_id(rs.getLong("user_id"));
dto.setCreated_date(rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null);
dto.setUpdated_date(rs.getTimestamp("updated_date") != null ? rs.getTimestamp("updated_date").toLocalDateTime() : null);
dto.setBatchKey(rs.getString("batch_key"));
dto.setUsername(rs.getString("username"));
return dto;
});
}
Java
복사
•
Dev Tools 메뉴에 접속하여 board-index 복사 후 붙여넣기
PUT /board-index
{
"settings": {
//"number_of_shards": 3, //샤드 3개
//"number_of_replicas": 1, //복제본 1개
"index.max_ngram_diff": 3, //중간 문자 검색을 위해 단어를 쪼갤때 최대치와 최소치의 간극 정의
"analysis": { // 문장 분석기 설정 영역
"filter": { // 🔹 [1]"filter 정의 영역" (단어 단위로 잘라낸 후 그 단어들을 어떻게 변형할지 정의하는 영역)
"autocomplete_filter": { // 내가 정의한 filter 이름
"type": "edge_ngram", // 앞에서부터 잘게 자름 예) 예: "spring" → "s", "sp", "spr", "spri", "sprin", "spring"
"min_gram": 1, // 최소 몇 글자부터 자를지
"max_gram": 20 // 최대 몇 글자까지 자를지
},
//중간 문자 검색
"ngram_filter": {
"type": "ngram", // 단어를 2~5글자씩 잘라서 부분 문자열을 만듬 (아래 정한 수치의 글자수 만큼)
"min_gram": 2, // 예) "spring" -> 2글자 : sp, pr, ri, in, ng 3글자 : spr, pri, rin, ing
"max_gram": 5
},
//초성
"chosung_filter": {
"type": "hanhinsam_chosung"
}
},
"analyzer": { // 실제로 문장을 분석할 때 사용할 분석기를 정의하는 곳 (위에서 정의할 필터를 사용하는 곳)
//접두어 검색
"autocomplete_analyzer": { // 내가 정의한 커스텀 분석기 이름
"type": "custom", // 내가 만든 커스텀 분석기 사용
"tokenizer": "standard", // 단어 단위로 분리 (공백, 특수문자 등 기준으로 단어 분리) -> ppt에 종류 있음
"filter": [ // 🔸 [2] "어떤 필터들을 사용할지 나열"
"lowercase", // 소문자로 통일
"autocomplete_filter" // 위에서 정의한 필터를 사용 (접두사 조각 생성) 예) 예: "spring" → "s", "sp", "spr", "spri", "sprin", "spring"
]
},
//중간 문자열 검색
"ngram_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"ngram_filter"
]
},
//초성 검색
"chosung_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"chosung_filter"
]
}
}
}
},
"mappings": {
"properties": {
"_class": { //spring data elasticsearch에서 객체를 저장할 때 자동으로 추가하는 메타데이터
"type": "keyword",
"index": false, // 🔒 저장은 되지만 검색/정렬에서 제외
"doc_values": false // 🔒 집계에도 사용 안 함
},
"content": {
"type": "text", // ✅ 자연어 문장을 검색할 수 있는 필드라는 의미 -> ppt에 종류 있음
"analyzer": "autocomplete_analyzer", // ✅ (인덱싱용) 텍스트를 어떻게 쪼개서 색인(저장)할지 (위 settings -> analysis -> analyzer 에서 만든 분석기 사용)
"search_analyzer": "standard", // ✅ (검색용) 검색 시에는 어떻게 분석해서 찾을지 -> ppt에 종류 있음
//서브 필드 (서브 필드는 쿼리에서 명시적으로 필드명을 지정할 때만 적용이 됨)
"fields": { //여러 방식으로 색인/검색하고 싶을 때 추가로 사용하는 서브필드
"keyword": { //정확히 일치하는 값으로 검색
"type": "keyword",
"ignore_above": 256 // ✅ 256자 이상인 문자열은 인덱싱하지 않겠다는 설정 (일반적으로 Elasticsearch의 성능 보호를 위한 제한)
},
"chosung": { // 초성 검색 전용 필드
"type": "text",
"analyzer": "chosung_analyzer" // ✅ 초성검색
},
"ngram": { //중간 문자열 검색
"type": "text",
"analyzer": "ngram_analyzer"
}
}
},
"title": {
"type": "text",
"analyzer": "autocomplete_analyzer",
"search_analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
},
"chosung": { // 초성 검색 전용 필드
"type": "text",
"analyzer": "chosung_analyzer"
},
"ngram": {
"type": "text",
"analyzer": "ngram_analyzer"
}
}
},
"id": {
"type": "text", // ⚠️ 원래는 keyword로 쓰는 게 더 좋음 (식별자니까)
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"userId": {
"type": "long" // 사용자 ID (정수형 필터/정렬 가능)
},
"username": {
"type": "text", // 사용자 이름 (검색용)
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256 // 정렬/집계용
}
}
},
"created_date": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss.SSSSSS||yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis" //여러 개의 포맷 중 하나라도 맞으면 날짜로 인식
},
"updated_date": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss.SSSSSS||yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"
}
}
}
}
Shell
복사
•
초성 검색 관련 플러그인 다운 받기
◦
명령어 실행
docker cp hanhinsam-0.1.zip elasticsearch:/tmp/
docker exec -it elasticsearch /bin/bash
cd /usr/share/elasticsearch
./bin/elasticsearch-plugin install file:///tmp/hanhinsam-0.1.zip
~/Desktop/project/backend5 │ main +5 !260 ?37 cd ElasticSearch ✔
~/Desktop/project/backend5/ElasticSearch │ main +5 !260 ?37 docker cp hanhinsam-0.1.zip elasticsearch:/tmp/ ✔
Successfully copied 15.9kB to elasticsearch:/tmp/
~/Desktop/project/backend5/ElasticSearch │ main +5 !260 ?37 docker exec -it elasticsearch /bin/bash ✔
elasticsearch@de15fe939cdb:~$ cd /usr/share/elasticsearch
elasticsearch@de15fe939cdb:~$ ./bin/elasticsearch-plugin install file:///tmp/hanhinsam-0.1.zip
-> Installing file:///tmp/hanhinsam-0.1.zip
-> Downloading file:///tmp/hanhinsam-0.1.zip
[=================================================] 100%??
-> Installed hanhinsam
-> Please restart Elasticsearch to activate any plugins installed
elasticsearch@de15fe939cdb:~$
Shell
복사
•
Elasticsearch, board-index 생성
# Click the Variables button, above, to create your own variables.
GET ${exampleVariable1} // _search
{
"query": {
"${exampleVariable2}": {} // match_all
}
}
PUT /board-index
{
"settings": {
//"number_of_shards": 3, //샤드 3개
//"number_of_replicas": 1, //복제본 1개
"index.max_ngram_diff": 3, //중간 문자 검색을 위해 단어를 쪼갤때 최대치와 최소치의 간극 정의
"analysis": { // 문장 분석기 설정 영역
"filter": { // 🔹 [1]"filter 정의 영역" (단어 단위로 잘라낸 후 그 단어들을 어떻게 변형할지 정의하는 영역)
"autocomplete_filter": { // 내가 정의한 filter 이름
"type": "edge_ngram", // 앞에서부터 잘게 자름 예) 예: "spring" → "s", "sp", "spr", "spri", "sprin", "spring"
"min_gram": 1, // 최소 몇 글자부터 자를지
"max_gram": 20 // 최대 몇 글자까지 자를지
},
//중간 문자 검색
"ngram_filter": {
"type": "ngram", // 단어를 2~5글자씩 잘라서 부분 문자열을 만듬 (아래 정한 수치의 글자수 만큼)
"min_gram": 2, // 예) "spring" -> 2글자 : sp, pr, ri, in, ng 3글자 : spr, pri, rin, ing
"max_gram": 5
},
//초성
"chosung_filter": {
"type": "hanhinsam_chosung"
}
},
"analyzer": { // 실제로 문장을 분석할 때 사용할 분석기를 정의하는 곳 (위에서 정의할 필터를 사용하는 곳)
//접두어 검색
"autocomplete_analyzer": { // 내가 정의한 커스텀 분석기 이름
"type": "custom", // 내가 만든 커스텀 분석기 사용
"tokenizer": "standard", // 단어 단위로 분리 (공백, 특수문자 등 기준으로 단어 분리) -> ppt에 종류 있음
"filter": [ // 🔸 [2] "어떤 필터들을 사용할지 나열"
"lowercase", // 소문자로 통일
"autocomplete_filter" // 위에서 정의한 필터를 사용 (접두사 조각 생성) 예) 예: "spring" → "s", "sp", "spr", "spri", "sprin", "spring"
]
},
//중간 문자열 검색
"ngram_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"ngram_filter"
]
},
//초성 검색
"chosung_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"chosung_filter"
]
}
}
}
},
"mappings": {
"properties": {
"_class": { //spring data elasticsearch에서 객체를 저장할 때 자동으로 추가하는 메타데이터
"type": "keyword",
"index": false, // 🔒 저장은 되지만 검색/정렬에서 제외
"doc_values": false // 🔒 집계에도 사용 안 함
},
"content": {
"type": "text", // ✅ 자연어 문장을 검색할 수 있는 필드라는 의미 -> ppt에 종류 있음
"analyzer": "autocomplete_analyzer", // ✅ (인덱싱용) 텍스트를 어떻게 쪼개서 색인(저장)할지 (위 settings -> analysis -> analyzer 에서 만든 분석기 사용)
"search_analyzer": "standard", // ✅ (검색용) 검색 시에는 어떻게 분석해서 찾을지 -> ppt에 종류 있음
//서브 필드 (서브 필드는 쿼리에서 명시적으로 필드명을 지정할 때만 적용이 됨)
"fields": { //여러 방식으로 색인/검색하고 싶을 때 추가로 사용하는 서브필드
"keyword": { //정확히 일치하는 값으로 검색
"type": "keyword",
"ignore_above": 256 // ✅ 256자 이상인 문자열은 인덱싱하지 않겠다는 설정 (일반적으로 Elasticsearch의 성능 보호를 위한 제한)
},
"chosung": { // 초성 검색 전용 필드
"type": "text",
"analyzer": "chosung_analyzer" // ✅ 초성검색
},
"ngram": { //중간 문자열 검색
"type": "text",
"analyzer": "ngram_analyzer"
}
}
},
"title": {
"type": "text",
"analyzer": "autocomplete_analyzer",
"search_analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
},
"chosung": { // 초성 검색 전용 필드
"type": "text",
"analyzer": "chosung_analyzer"
},
"ngram": {
"type": "text",
"analyzer": "ngram_analyzer"
}
}
},
"id": {
"type": "text", // ⚠️ 원래는 keyword로 쓰는 게 더 좋음 (식별자니까)
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"userId": {
"type": "long" // 사용자 ID (정수형 필터/정렬 가능)
},
"username": {
"type": "text", // 사용자 이름 (검색용)
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256 // 정렬/집계용
}
}
},
"created_date": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss.SSSSSS||yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis" //여러 개의 포맷 중 하나라도 맞으면 날짜로 인식
},
"updated_date": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss.SSSSSS||yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"
}
}
}
}
Shell
복사
•
플러그인 설치 내역 확인
•
플러그인 설치 후 PUT 쪽에서 재생 버튼 누르면 다음과 같이 출력됨
•
1000개 이상의 데이터, 배치 등록
•
ElasticSearch 인덱스 조회 결과 확인
{
"took": 3,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 7004,
"relation": "eq"
},
"max_score": 1,
"hits": [
{
"_index": "board-index",
"_id": "16228",
"_score": 1,
"_source": {
"id": "16228",
"title": "몽고디비와 자바의 예제 204",
"content": "JWT를 활용한 웹소켓 개발 및 쿠버네티스 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:24",
"updated_date": "2023-03-18T13:24"
}
},
{
"_index": "board-index",
"_id": "16229",
"_score": 1,
"_source": {
"id": "16229",
"title": "머신러닝와 데이터베이스의 예제 205",
"content": "뷰를 활용한 MySQL 개발 및 NoSQL 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:25",
"updated_date": "2023-03-18T13:25"
}
},
{
"_index": "board-index",
"_id": "16230",
"_score": 1,
"_source": {
"id": "16230",
"title": "API와 JPA의 예제 206",
"content": "뷰를 활용한 보안 개발 및 리눅스 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:26",
"updated_date": "2023-03-18T13:26"
}
},
{
"_index": "board-index",
"_id": "16231",
"_score": 1,
"_source": {
"id": "16231",
"title": "REST와 웹소켓의 예제 207",
"content": "JWT를 활용한 몽고디비 개발 및 백엔드 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:27",
"updated_date": "2023-03-18T13:27"
}
},
{
"_index": "board-index",
"_id": "16232",
"_score": 1,
"_source": {
"id": "16232",
"title": "JPA와 API의 예제 208",
"content": "API를 활용한 데이터베이스 개발 및 보안 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:28",
"updated_date": "2023-03-18T13:28"
}
},
{
"_index": "board-index",
"_id": "16233",
"_score": 1,
"_source": {
"id": "16233",
"title": "리액트와 딥러닝의 예제 209",
"content": "데이터베이스를 활용한 JPA 개발 및 보안 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:29",
"updated_date": "2023-03-18T13:29"
}
},
{
"_index": "board-index",
"_id": "16234",
"_score": 1,
"_source": {
"id": "16234",
"title": "JPA와 NoSQL의 예제 210",
"content": "AI를 활용한 뷰 개발 및 엘라스틱서치 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:30",
"updated_date": "2023-03-18T13:30"
}
},
{
"_index": "board-index",
"_id": "16235",
"_score": 1,
"_source": {
"id": "16235",
"title": "프론트엔드와 도커의 예제 211",
"content": "쿠버네티스를 활용한 웹소켓 개발 및 리눅스 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:31",
"updated_date": "2023-03-18T13:31"
}
},
{
"_index": "board-index",
"_id": "16236",
"_score": 1,
"_source": {
"id": "16236",
"title": "리액트와 웹소켓의 예제 212",
"content": "REST를 활용한 마이크로서비스 개발 및 MySQL 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:32",
"updated_date": "2023-03-18T13:32"
}
},
{
"_index": "board-index",
"_id": "16237",
"_score": 1,
"_source": {
"id": "16237",
"title": "리눅스와 쿠버네티스의 예제 213",
"content": "파이썬를 활용한 리눅스 개발 및 쿠버네티스 연동 방법에 대해 설명합니다.",
"username": "hms",
"userId": 1,
"created_date": "2023-03-16T13:33",
"updated_date": "2023-03-18T13:33"
}
}
]
}
}
JSON
복사
•
초성 입력
•
대소문자 구분없이 조회 가능
•
오타를 입력해도 조회 가능
오늘 푸시한 커밋리스트
날짜 | 커밋 메시지 |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 | |
2025-06-30 |