๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
โœ๏ธ Today I Learned(TIL)/์ŠคํŒŒ๋ฅดํƒ€ ๋‚ด์ผ๋ฐฐ์›€์บ ํ”„

[ TIL ] ์ตœ์ข… ํ”„๋กœ์ ํŠธ_Day 22

by carrot0911 2025. 3. 5.

์˜ค๋Š˜ ์ง„ํ–‰ํ•œ ๋‚ด์šฉ๋“ค ๐Ÿง 

์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋งํ•˜๊ธฐ

Redis์™€ MySQL ๋™๊ธฐํ™” ๋กœ์ง ๋ฆฌํŒฉํ† ๋ง

๋”๋ณด๊ธฐ

์ˆ˜์ •ํ•˜๊ธฐ ์ „ ๋กœ์ง

@Component
@RequiredArgsConstructor
public class HistoryScheduler {

    private final UserFindByService userFindByService;
    private final HistoryRepository historyRepository;
    private final RedisTemplate<String, String> redisTemplate;

    /**
     * Redis์— ์ €์žฅ๋œ ๊ฒ€์ƒ‰๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ DB์— ์ €์žฅํ•˜๋Š” ์Šค์ผ€์ค„๋Ÿฌ ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค.
     *
     * 30๋ถ„๋งˆ๋‹ค ์‹คํ–‰์ด ๋˜๋ฉฐ, ๊ฐ ์‚ฌ์šฉ์ž์˜ ๊ฒ€์ƒ‰์–ด๋ฅผ Redis์—์„œ ๊ฐ€์ ธ์™€์„œ DB์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
     * ์ด๋ฏธ ์ €์žฅ๋œ ๊ฒ€์ƒ‰์–ด๋Š” ์ค‘๋ณต ์ €์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
     */
    @Scheduled(fixedRate = 1800000) // 30๋ถ„๋งˆ๋‹ค ์‹คํ–‰
    public void saveSearchHistoryToDb() {
        Set<String> keys = redisTemplate.keys("user:*:search_history");

        if (keys != null) {
            for (String key : keys) {
                Long userId = Long.parseLong(key.split(":")[1]);
                User user = userFindByService.findById(userId);

                Set<String> searchTerms = redisTemplate.opsForZSet().range(key, 0, -1);

                if (searchTerms != null) {
                    searchTerms.forEach(term -> {
                        if (!historyRepository.existsByUserIdAndName(userId, term)) {
                            historyRepository.save(History.toEntity(user, term));
                        }
                    });
                }
            }
        }
    }
}

์ˆ˜์ •ํ•œ ํ›„ ๋กœ์ง

@Component
@RequiredArgsConstructor
public class HistoryScheduler {

    private final UserFindByService userFindByService;
    private final HistoryRepository historyRepository;
    private final RedisTemplate<String, String> redisTemplate;

    /**
     * Redis์— ์ €์žฅ๋œ ๊ฒ€์ƒ‰๋ฅผ ์ฃผ๊ธฐ์ ์œผ๋กœ DB์— ์ €์žฅํ•˜๋Š” ์Šค์ผ€์ค„๋Ÿฌ ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค.
     *
     * 30๋ถ„๋งˆ๋‹ค ์‹คํ–‰์ด ๋˜๋ฉฐ, ๊ฐ ์‚ฌ์šฉ์ž์˜ ๊ฒ€์ƒ‰์–ด๋ฅผ Redis์—์„œ ๊ฐ€์ ธ์™€์„œ DB์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
     * ์ด๋ฏธ ์ €์žฅ๋œ ๊ฒ€์ƒ‰์–ด๋Š” ์ค‘๋ณต ์ €์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
     */
    @Scheduled(fixedRate = 1800000) // 30๋ถ„๋งˆ๋‹ค ์‹คํ–‰
    public void saveSearchHistoryToDb() {
        Set<String> keys = redisTemplate.keys("user:*:search_history");

        if (keys != null) {
            for (String key : keys) {
                Long userId = Long.parseLong(key.split(":")[1]);
                User user = userFindByService.findById(userId);

                Set<String> searchTerms = redisTemplate.opsForZSet().range(key, 0, -1);

                if (searchTerms != null && !searchTerms.isEmpty()) {
                    // ํ•ด๋‹น ์œ ์ €์˜ ๊ธฐ์กด ๊ฒ€์ƒ‰์–ด ์กฐํšŒ
                    Set<String> existingSearchTermSet = historyRepository.findNamesByUserId(userId);

                    // ์ค‘๋ณต๋˜์ง€ ์•Š์€ ๊ฒ€์ƒ‰์–ด ํ•„ํ„ฐ๋ง
                    List<History> historyList = searchTerms.stream()
                        .filter(term -> !existingSearchTermSet.contains(term))
                        .map(term -> History.toEntity(user, term))
                        .toList();

                    // ์ƒˆ๋กœ์šด ๊ฒ€์ƒ‰์–ด๊ฐ€ ์žˆ๋‹ค๋ฉด ์ €์žฅ
                    if (!historyList.isEmpty()) {
                        historyRepository.saveAll(historyList);
                    }
                }
            }
        }
    }
}

Elasticsearch ๊ด€๋ จ ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง

๋”๋ณด๊ธฐ

์ˆ˜์ •ํ•˜๊ธฐ ์ „ ๋กœ์ง

/**
 * ํ•„ํ„ฐ๋ง๋œ ์ฑ„์šฉ๊ณต๊ณ  ์กฐํšŒ
 */
@Transactional
public Page<ReadJobOpeningElasticResponseDto> readJobOpeningUsingElasticSearchFilter(
        ReadJobOpeningElasticRequestDto requestDto,
        Long userId,
        Pageable pageable
) {
    if (requestDto.getSearchTerm() != null) {
        historyService.saveSearchTerm(userId, requestDto.getSearchTerm());
    }

    JobOpeningDocumentFilter filter = new JobOpeningDocumentFilter(requestDto);
    var boolQueryBuilder = filter.build();
    int pageSize = pageable.getPageSize();
    int pageNumber = pageable.getPageNumber();
    int from = calculateFrom(pageNumber, pageSize, IndexName.MAX_JOP_OPENING_SIZE);
    SearchRequest searchRequest = new SearchRequest.Builder()
            .index(IndexName.JOB_OPENING_DOCUMENT)
            .query(q -> q.bool(boolQueryBuilder.build()))
            .sort(s -> s.field(f -> f.field(IndexName.CREATED_AT).order(SortOrder.Desc)))
            .from(from)
            .size(pageSize)
            .build();
    List<JobOpeningDocument> jobOpeningDocuments = elasticsearchClientService.fetchJobOpeningDocumentList(searchRequest);
    List<ReadJobOpeningElasticResponseDto> dtoList = ReadJobOpeningElasticResponseDto.toDto(jobOpeningDocuments);
    return new PageImpl<>(dtoList, pageable, IndexName.MAX_JOP_OPENING_SIZE);
}

์ˆ˜์ •ํ•œ ํ›„ ๋กœ์ง

/**
 * ํ•„ํ„ฐ๋ง๋œ ์ฑ„์šฉ๊ณต๊ณ  ์กฐํšŒ
 */
@Transactional
public Page<ReadJobOpeningElasticResponseDto> readJobOpeningUsingElasticSearchFilter(
        ReadJobOpeningElasticRequestDto requestDto,
        Long userId,
        Pageable pageable
) {
    if (requestDto.getSearchTerm() != null) {
        historyService.saveSearchTerm(userId, requestDto.getSearchTerm());
    }

    int pageSize = pageable.getPageSize();
    int pageNumber = pageable.getPageNumber();
    int from = calculateFrom(pageNumber, pageSize, IndexName.MAX_JOP_OPENING_SIZE);

    List<JobOpeningDocument> jobOpeningDocumentList = jobOpeningDocumentRepository.searchJobOpeningWithFilter(requestDto, pageSize, from);
    List<ReadJobOpeningElasticResponseDto> dtoList = ReadJobOpeningElasticResponseDto.toDto(jobOpeningDocumentList);

    return new PageImpl<>(dtoList, pageable, IndexName.MAX_JOP_OPENING_SIZE);
}
public interface JobOpeningDocumentCustomRepository {
    List<JobOpeningDocument> searchJobOpeningWithFilter(ReadJobOpeningElasticRequestDto requestDto, int pageSize, int from);
}
@Repository
@RequiredArgsConstructor
public class JobOpeningDocumentRepositoryImpl implements JobOpeningDocumentCustomRepository{

    private final ElasticsearchClientService elasticsearchClientService;

    @Override
    public List<JobOpeningDocument> searchJobOpeningWithFilter(ReadJobOpeningElasticRequestDto requestDto, int pageSize, int from) {
        JobOpeningDocumentFilter jobOpeningDocumentFilter = new JobOpeningDocumentFilter(requestDto);
        BoolQuery.Builder builder = jobOpeningDocumentFilter.build();

        SearchRequest searchRequest = new SearchRequest.Builder()
            .index(IndexName.JOB_OPENING_DOCUMENT)
            .query(q -> q.bool(builder.build()))
            .sort(s -> s
                .field(f -> f.
                    field(IndexName.CREATED_AT).
                    order(SortOrder.Desc)))
            .from(from)
            .size(pageSize)
            .build();

        return elasticsearchClientService.fetchJobOpeningDocumentList(searchRequest);
    }
}
public interface JobOpeningDocumentRepository extends ElasticsearchRepository<JobOpeningDocument, String> {
}

Elasticsearch ์ž๋™ ์™„์„ฑ ๊ธฐ๋Šฅ ๊ณต๋ถ€ํ•˜๊ธฐ

๋ฐฉ๋ฒ• 1: @Setting์„ ์ด์šฉํ•˜์—ฌ Nori/Ngram ์ ์šฉํ•˜๊ธฐ

Spring Data Elasticsearch์—์„œ @Setting์„ ์‚ฌ์šฉํ•˜๋ฉด ๋งคํ•‘์„ JSON ํŒŒ์ผ๋กœ ๋ถ„๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.

@Setting์„ ์ ์šฉํ•œ JobOpeningDocument

import org.springframework.data.elasticsearch.annotations.Setting;

@Document(indexName = IndexName.JOB_OPENING_DOCUMENT)
@Setting(settingPath = "/elasticsearch/job-opening-settings.json")  // JSON ํŒŒ์ผ์„ ์„ค์ •
public class JobOpeningDocument {
    @Id
    @Field(type = FieldType.Keyword)
    private String id;

    @Field(type = FieldType.Text, analyzer = "nori_analyzer", searchAnalyzer = "nori_analyzer")
    private String title; // ํ•œ๊ธ€ ํ˜•ํƒœ์†Œ ๋ถ„์„ ์ ์šฉ

    @Field(type = FieldType.Text, analyzer = "ngram_analyzer", searchAnalyzer = "standard")
    private String company; // N-gram ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰

    @Field(type = FieldType.Text, analyzer = "nori_analyzer")
    private String position; // ์ง๋ฌด ๊ฒ€์ƒ‰์—๋„ ํ˜•ํƒœ์†Œ ๋ถ„์„ ์ ์šฉ

    @Field(type = FieldType.Keyword)
    private String location;

    @Field(type = FieldType.Integer)
    private int salary;
}

ํ•ต์‹ฌ ํฌ์ธํŠธ

  • @Setting(settingPath = "/elasticsearch/job-opening-settings.json")๋ฅผ ์‚ฌ์šฉํ•ด ๋งคํ•‘์„ JSON์œผ๋กœ ๋ถ„๋ฆฌ.
  • analyzer = "nori_analyzer" → ํ•œ๊ธ€ ํ˜•ํƒœ์†Œ ๋ถ„์„๊ธฐ ์ ์šฉ
  • analyzer = "ngram_analyzer" → n-gram ๊ฒ€์ƒ‰ ์ ์šฉ (ํšŒ์‚ฌ๋ช… ๊ฒ€์ƒ‰์šฉ)

job-opening-settings.json (Elasticsearch ์ธ๋ฑ์Šค ์„ค์ •)

src/main/resources/elasticsearch/job-opening-settings.json ํŒŒ์ผ ์ƒ์„ฑ ํ›„ ์•„๋ž˜ ๋‚ด์šฉ์„ ์ถ”๊ฐ€.

{
  "analysis": {
    "tokenizer": {
      "nori_tokenizer": {
        "type": "nori_tokenizer"
      },
      "ngram_tokenizer": {
        "type": "ngram",
        "min_gram": 2,
        "max_gram": 5,
        "token_chars": ["letter", "digit"]
      }
    },
    "analyzer": {
      "nori_analyzer": {
        "type": "custom",
        "tokenizer": "nori_tokenizer"
      },
      "ngram_analyzer": {
        "type": "custom",
        "tokenizer": "ngram_tokenizer",
        "filter": ["lowercase"]
      }
    }
  }
}

์„ค์ • ์„ค๋ช…

  • nori_analyzer → ํ•œ๊ธ€ ํ˜•ํƒœ์†Œ ๋ถ„์„ (nori_tokenizer ์‚ฌ์šฉ)
  • ngram_analyzer → n-gram ๋ถ„์„ (2~5๊ธ€์ž ๋‹จ์œ„๋กœ ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅ)

 

๋‚ด์ผ ๊ณ„ํš โฐ

  • Elasticsearch ์ž๋™ ์™„์„ฑ ๊ธฐ๋Šฅ ๊ณต๋ถ€ํ•˜๊ธฐ
  • Elasticsearch ์ž๋™ ์™„์„ฑ ๊ธฐ๋Šฅ ๊ตฌํ˜„ํ•˜๊ธฐ

+ ์ถ”๊ฐ€ ๊ณ„ํš์ด ์ƒ๊ธธ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค ~_~