멋쟁이 사자처럼

[멋사 백엔드 부트캠프] 게시글 자주 나오는 단어 추출 기능 구현

sunm2n 2025. 7. 18. 11:40
{
  "settings": {
    "index": {
      "max_ngram_diff": 3,
      "analysis": {
        "tokenizer": {
          "nori_no_decompound": {
            "type": "nori_tokenizer",
            "decompound_mode": "none"
          }
        },
        "filter": {
          "autocomplete_filter": {
            "type": "edge_ngram",
            "min_gram": 1,
            "max_gram": 20
          },
          "ngram_filter": {
            "type": "ngram",
            "min_gram": 2,
            "max_gram": 5
          },
          "chosung_filter": {
            "type": "hanhinsam_chosung"
          },
          "korean_stop": {
            "type": "stop",
            "stopwords": "_korean_"
          },
          "korean_pos_filter": {
            "type": "nori_part_of_speech",
            "stoptags": [
              "E",
              "IC",
              "J",
              "MAG",
              "MAJ",
              "MM",
              "SP",
              "SSC",
              "SSO",
              "SC",
              "SE",
              "XPN",
              "XSA",
              "XSN",
              "XSV",
              "UNA",
              "NA",
              "VSV"
            ]
          }
        },
        "analyzer": {
          "autocomplete_analyzer": {
            "type": "custom",
            "tokenizer": "standard",
            "filter": [
              "lowercase",
              "autocomplete_filter"
            ]
          },
          "ngram_analyzer": {
            "type": "custom",
            "tokenizer": "standard",
            "filter": [
              "lowercase",
              "ngram_filter"
            ]
          },
          "chosung_analyzer": {
            "type": "custom",
            "tokenizer": "standard",
            "filter": [
              "chosung_filter"
            ]
          },
          "korean_clean_analyzer": {
            "type": "custom",
            "tokenizer": "nori_no_decompound",
            "filter": [
              "korean_pos_filter",
              "lowercase",
              "korean_stop"
            ]
          }
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "_class": {
        "type": "keyword",
        "index": false,
        "doc_values": false
      },
      "content": {
        "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"
          },
          "morph": {
            "type": "text",
            "analyzer": "korean_clean_analyzer",
            "fielddata": true
          }
        }
      },
      "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",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "userId": {
        "type": "long"
      },
      "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"
      },
      "viewCount": {
        "type": "long"
      },
      "autonomousDistrict": {
        "type": "keyword"
      }
    }
  }
}

 

다음과 같이 board-index.json 파일을 만든다.

 

hanhinsam 과 nori 라는 외부 플러그인은 설치 해야한다.

 

 

GET /board-index/_search
{
  "size": 0,
  "aggs": {
    "frequent_words": {
      "terms": {
        "field": "content.morph",
        "size": 10
      }
    }
  }
}

 

다음 쿼리를 보내게 되면 결과가 나온다.

 

 

 

이제 단어가 잘 나오는 것을 확인하고 날짜 필터를 추가하여 최근 한달 게시글만 불러오는 것을 추가할 것이다.

 

package com.dosion.noisense.web.board.elasticsearch.dto;

import com.dosion.noisense.web.board.dto.BoardDto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.Id;
import lombok.*;
import org.springframework.data.elasticsearch.annotations.Document;

import java.time.format.DateTimeFormatter;

@JsonIgnoreProperties(ignoreUnknown = true)
@Document(indexName = "board-index", createIndex = false)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BoardEsDocument {

  @Id
  private String id;
  private String title;
  private String content;
  private String username;
  private Long userId;
  private String autonomousDistrict;
  private String created_date;
  private String updated_date;

  @Builder.Default
  private Long view_count = 0L;

  /** BoardDto → BoardEsDocument 변환 메서드 **/
  public static BoardEsDocument from(BoardDto dto) {
    return BoardEsDocument.builder()
      .id(dto.getBoardId() != null ? String.valueOf(dto.getBoardId()) : null)
      .title(dto.getTitle())
      .content(dto.getContent())
      .username(dto.getNickname())
      .userId(dto.getUserId())
      .autonomousDistrict(dto.getAutonomousDistrict())
      .created_date(dto.getCreatedDate() != null ? dto.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) : null)
      .updated_date(dto.getModifiedDate() != null ? dto.getModifiedDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) : null)
      .view_count(dto.getViewCount())
      .build();
  }
}

 

BoardEsDocument.java 의 코드는 지금 다음과 같다. 

 

날짜 포멧을 보면

yyyy-MM-dd'T'HH:mm:ss

 

다음과 같이 설정해놨다. 따라서 위에 boar-index.json에서도 날짜 포멧을 맞춰줘야 한다.

 

"created_date": {
  "type": "date",
  "format": "yyyy-MM-dd'T'HH:mm:ss||strict_date_optional_time||epoch_millis"
},
"updated_date": {
  "type": "date",
  "format": "yyyy-MM-dd'T'HH:mm:ss||strict_date_optional_time||epoch_millis"
}

 

다음과 같이 날짜 부분을 수정했다.

 

여기서 중요한 점이 엘라스틱 서치에서는 시간 기준이 UTC 이고 LocalDateTime은 KST이다. 따라서 시간을 통일해야 한다.

 

그리고 우리 프로젝트에서는 KST로 통일하기로 했다.

 

추가적으로 지역구 필터까지 적용하면 다음과 같은 쿼리가 생성된다.

 

GET /board-index/_search
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "match": { "autonomousDistrict": "노원구" }},
        {
          "range": {
            "created_date": {
              "gte": "now-1M/M+9h",
              "lte": "now+9h"
            }
          }
        }
      ]
    }
  },
  "aggs": {
    "frequent_words": {
      "terms": {
        "field": "content.morph",
        "size": 10
      }
    }
  }
}

 

 

now-1M/M+9h이렇게 시간에 +9를 해줘서 시간을 맞추어 주었다.

 

 

필터가 다음과 같이 정상적으로 작동해서 결과를 내준다.

 

그리고 추가적으로 저 쿼리는 각 게시글에 나오는 단어와 수를 체크할 뿐 구와 시간 필터를 가지고 집계하는 별도의 함수가 필요하다.

 

package com.dosion.noisense.module.board.elasticsearch.service;

import com.dosion.noisense.module.board.elasticsearch.repository.BoardEsRepository;
import com.dosion.noisense.web.board.dto.BoardDto;
import com.dosion.noisense.web.board.elasticsearch.dto.BoardEsDocument;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

@RequiredArgsConstructor
@Service
public class BoardEsService {

  private final BoardEsRepository repository;

  public void save(BoardEsDocument document) {
    repository.save(document);
  }

  public void saveBoardToElasticsearch(BoardDto dto) {
    BoardEsDocument document = BoardEsDocument.from(dto);
    repository.save(document);
  }


  /** 통합 검색 (title or content에 keyword 포함) **/
  public Page<BoardEsDocument> search(String keyword, int page, int size) {
    List<BoardEsDocument> results =
      repository.findByTitleContainingOrContentContaining(keyword, keyword);

    int start = Math.min(page * size, results.size());
    int end = Math.min(start + size, results.size());

    List<BoardEsDocument> pageContent = results.subList(start, end);
    return new PageImpl<>(pageContent, PageRequest.of(page, size), results.size());
  }

  /** 자주 등장하는 단어 직접 집계 **/
  public Map<String, Long> getFrequentWords(String autonomousDistrict,
                                            LocalDateTime startDate,
                                            LocalDateTime endDate,
                                            int size) {

    List<BoardEsDocument> all = StreamSupport
      .stream(repository.findAll().spliterator(), false)
      .collect(Collectors.toList());

    // 1. 필터링
    List<BoardEsDocument> filtered = all.stream()
      .filter(doc -> {
        if (autonomousDistrict != null && !autonomousDistrict.isBlank()) {
          if (!autonomousDistrict.equals(doc.getAutonomousDistrict())) return false;
        }
        if (startDate != null && endDate != null && doc.getCreatedDate() != null) {
          // Instant → LocalDateTime (KST 기준)
          LocalDateTime created = LocalDateTime.ofInstant(doc.getCreatedDate(), ZoneId.of("Asia/Seoul"));
          if (created.isBefore(startDate) || created.isAfter(endDate)) return false;
        }
        return true;
      })
      .collect(Collectors.toList());

    // 2. content에서 단어 추출 및 카운팅
    Map<String, Long> wordCount = new HashMap<>();
    for (BoardEsDocument doc : filtered) {
      String content = doc.getContent();
      if (content == null) continue;

      String[] words = content.split("\\s+");
      for (String word : words) {
        String clean = word.replaceAll("[^가-힣a-zA-Z0-9]", "");
        if (clean.length() <= 1) continue; // 한 글자 제외

        wordCount.put(clean, wordCount.getOrDefault(clean, 0L) + 1);
      }
    }

    // 3. 상위 size개 정렬 후 반환
    return wordCount.entrySet().stream()
      .sorted((e1, e2) -> Long.compare(e2.getValue(), e1.getValue()))
      .limit(size)
      .collect(Collectors.toMap(
        Map.Entry::getKey,
        Map.Entry::getValue,
        (e1, e2) -> e1,
        LinkedHashMap::new
      ));
  }
}

 

다음과 같이 구현했다.

 

먼저 각 게시글 별로 자주 나온 단어를 추출하고 집계 함수로 걸린 필터 별로 집계를 한다.

 

결국 데이터가 필요한 대쉬보드 쪽에서 집계함수만 호출하면 데이터를 가져갈 수 있도록 설계 했다.

 

그리고 확장성을 위해 지역구나 날짜를 선택하지 않으면 전체를 다 가져오는 방향으로 설계를 했다.

 

그리고 엘라스틱 서치가 Localdatetime으로 DTO를 설정할 경우 엘라스틱 서치에 저장이 될 때 Long 타입으로 저장되는 문제가 발생했다.

 

package com.dosion.noisense.web.board.elasticsearch.dto;

import com.dosion.noisense.web.board.dto.BoardDto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.Id;
import lombok.*;
import org.springframework.data.elasticsearch.annotations.DateFormat;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;

@JsonIgnoreProperties(ignoreUnknown = true)
@Document(indexName = "board-index", createIndex = false)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BoardEsDocument {

  @Id
  private String id;
  private String title;
  private String content;
  private String username;
  private Long userId;
  private String autonomousDistrict;

  // Instant 기반으로 변경
  @Field(type = FieldType.Date, format = DateFormat.date_time)
  @JsonFormat(shape = JsonFormat.Shape.STRING)
  private Instant createdDate;
  @Field(type = FieldType.Date, format = DateFormat.date_time)
  @JsonFormat(shape = JsonFormat.Shape.STRING)
  private Instant modifiedDate;

  @Builder.Default
  private Long view_count = 0L;

  /** BoardDto → BoardEsDocument 변환 메서드 **/
  public static BoardEsDocument from(BoardDto dto) {
    return BoardEsDocument.builder()
      .id(dto.getBoardId() != null ? String.valueOf(dto.getBoardId()) : null)
      .title(dto.getTitle())
      .content(dto.getContent())
      .username(dto.getNickname())
      .userId(dto.getUserId())
      .autonomousDistrict(dto.getAutonomousDistrict())
      .createdDate(dto.getCreatedDate() != null ? dto.getCreatedDate().atZone(ZoneId.of("Asia/Seoul")).toInstant() : null)
      .modifiedDate(dto.getModifiedDate() != null ? dto.getModifiedDate().atZone(ZoneId.of("Asia/Seoul")).toInstant() : null)
      .view_count(dto.getViewCount())
      .build();
  }

  /** Instant → LocalDateTime (KST) 변환 도우미 메서드 **/
  public LocalDateTime getCreatedDateAsLocalDateTime() {
    return createdDate != null ? createdDate.atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime() : null;
  }

  public LocalDateTime getModifiedDateAsLocalDateTime() {
    return modifiedDate != null ? modifiedDate.atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime() : null;
  }
}

 

그래서 다음과 같이 Instant 타입으로 먼저 선언을 하고 변환 메서드를 만들어서 설정하는 것으로 수정했다.

 

"createdDate": {
  "type": "date",
  "format": "strict_date_time"
},
"modifiedDate": {
  "type": "date",
  "format": "strict_date_time"

 

board-index.json 에서도 fomat을 다음과 같이 수정했다.