포스트

(멋쟁이사자처럼_백엔드스쿨플러스) Day16 엘라스틱 서치

스프링으로 ElasticSearch 연동하기

kibana 설치 및 시각화 도구 사용하기

  • Docker compose를 이용하여 kibana를 설치한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    servies:
      kibana:
        image: docker.elastic.co/kibana/kibana:8.3.3
        container_name: kibana
        environment:
          SERVER_NAME: kibana
          ELASTICSEARCH_HOSTS: http://elasticsearch:9200
        ports:
          - 5601:5601
        depends_on:
          - elasticsearch
        networks:
          - elastic
    networks:
      elastic:
        driver: bridge
    
  • localhost:5601로 접속하여 kibana를 확인한다. Image

dev-tools를 이용하여 데이터 확인하기

  • post-man을 통해 확인 한 api 요청을 바로 확인할 수 있다. Image

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    
    # title 검색
    GET /app1_posts/_search
    {
        "query": {
            "match": {
                "title": "검색어"
            }
        }
    }
    # content 검색
    GET /app1_posts/_search
    {
        "query": {
            "match": {
                "content": "검색어"
            }
        }
    }
    # title, content 모두 검색
    GET /app1_posts/_search
    {
      "query": {
        "multi_match": {
          "query": "여행",
          "fields": ["title", "content"]
        }
      }
    }
      
    # 부분일치
    GET /app1_posts/_search
    {
      "query": {
        "wildcard": {
          "title": "*출근*"
        }
      }
    }
      
    # 여러조건을 조합
    GET /app1_posts/_search
    {
      "query": {
        "bool": {
          "must": [
            { "match": { "title": "코딩" } },
            { "match": { "content": "오류" } }
          ],
          "filter": [
            { "term": { "_index": "app1_posts" } }
          ]
        }
      }
    }
      
    # 정렬
    GET /app1_posts/_search
    {
      "query": {
        "match_all": {}
      },
      "sort": [
        { "id": "asc" }
      ]
    }
      
    #제한
    GET /app1_posts/_search
    {
      "query": {
        "match_all": {}
      },
      "size": 5
    }
    
  • sql문을 사용하여 데이터를 조회할 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    
    # 모든 문서 조회
    POST /_sql?format=txt
    {
      "query": "SELECT * FROM app1_posts"
    }
    # 특정 컬럼만 조회
    POST /_sql?format=txt
    {
      "query": "SELECT title, content FROM app1_posts"
    }
      
    # 조건 조회
    # title에 특정 단어가 포함된 문서 조회:
    POST /_sql?format=txt
    {
      "query": "SELECT * FROM app1_posts WHERE title LIKE '%검색어%'"
    }
    # 정렬
    # views 필드 기준 내림차순 정렬:
      
    POST /_sql?format=txt
    {
      "query": "SELECT * FROM app1_posts ORDER BY views DESC"
    }
    # 결과 제한
    # 최대 5개의 결과만 조회:
    POST /_sql?format=txt
    {
      "query": "SELECT * FROM app1_posts LIMIT 5"
    }
    # 집계
    # views 필드의 평균값 계산:
    POST /_sql?format=txt
    {
      "query": "SELECT AVG(views) AS avg_views FROM app1_posts"
    }
      
    # 날짜 조건 추가
    # published_date가 2025 1 1 이후인 문서 검색:
    POST /_sql?format=txt
    {
      "query": "SELECT * FROM app1_posts WHERE published_date >= '2025-01-01'"
    }
    # SQL 쿼리를 JSON 형식으로 반환
    # 결과를 JSON 형식으로 얻고 싶다면 format=txt 대신 format=json을 사용합니다.
    POST /_sql?format=json
    {
      "query": "SELECT * FROM app1_posts"
    }
    

스프링으로 ElasticSearch 연동하기

PostDocs 수정

  • @Field(type = FieldType.Text)필드 매핑을 통해 text로 매핑한다.

    필드 매핑 정의

    • 자바 클래스의 필드를 Elasticsearch의 필드 타입으로 매핑
    • FieldType.Text는 이 필드가 Elasticsearch에서 text 타입으로 저장

Text 타입의 특징

  • 전문 검색(Full-text search)이 가능
  • 필드의 내용이 분석(analyze)되어 검색을 위한 토큰으로 분리
  • ex) “Hello World”라는 내용은 “hello”, “world” 두 개의 토큰으로 분리되어 저장
1
2
3
4
5
6
7
8
  public class PostDoc {
    @Id
    private String id;
    @Field(type = FieldType.Text)
    private String title;
    @Field(type = FieldType.Text)
    private String content;
  }

기존 controller를 이용하여 검색기능 추가

  • 데이터 추가
    • 데이터 추가는 POST요청으로 데이터를 추가한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    @PostMapping("/write")
    public RsData<PostDoc> write(
        @RequestBody @Valid PostDocWriteRequest writeRequest) {
          PostDoc postDoc = postDocService.write(writeRequest.title, writeRequest.content);
          return new RsData<>(
              "200",
              "게시글이 생성됨",
              postDoc
          );
    }
        
    record PostDocWriteRequest(
      @NotBlank String title,
      @NotBlank String content
    ) {}
    
  • 데이터 조회
    • 데이터 조회는 GET요청으로 데이터를 조회한다.
    1
    2
    3
    4
    5
    
    @GetMapping("/search")
      public List<PostDoc> search(
          @RequestParam("keyworad") String keyword) {
        return postDocService.findByTitle(keyword);
      }
    

Service 수정

  • 기존 write메서드는 동일하게 repository에 ElasticSearchRepository를 상속받아 데이터를 save한다.
  • 조회 코드는 findByTitle메서드를 추가하여 title로 검색하는 메서드를 추가한다.

    1
    2
    3
    
    public List<PostDoc> findByTitle(String keyword) {
            return postDocRepository.findByTitleContainingOrContentContaining(keyword, keyword);
        }
    

PostDocRepository 수정

  • itleContainingOrContentContaining매서드는 기본 메서드가 아니기 때문에 추상매서드로 작성한다.

    1
    2
    3
    
    public interface PostDocRepository extends ElasticsearchRepository<PostDoc, String> {
        List<PostDoc> findByTitleContainingOrContentContaining(String keyword, String keyword2);
    }
    

    메서드 이름 분석

    • findBy: 검색을 수행하라는 명령
    • TitleContaining: title 필드에서 포함된 내용을 찾음
    • Or: 또는
    • ContentContaining: content 필드에서 포함된 내용을 찾음

postman을 이용하여 검색기능 확인

  • postman을 이용하여 검색기능을 확인한다.
  • localhost:8090/api/v1/postsDocs/write로 데이터를 추가한다. Image

  • localhost:8090/api/v1/postsDocs/search?keyword=영화로 데이터를 조회한다. Image

MySQL과 ElasticSearch 동시 사용하기

  • 새로은 Post 도메인을 만들어 sql 방식의 controller, service, entity, repository를 만든다.
  • Jpa를 이용하여 SQL을 관리한다.
  • 레포

참고. AOP를 이용한 Response 수정

  • AOP(Aspect-Oriented Programming)는 공통 관심사(Cross-cutting concerns)를 분리해서 관리하는 프로그래밍 패러다임이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 기존 컨트롤러 코드 
// 모든 컨트롤러에서 이런 코드를 반복해서 작성해야 합니다
@GetMapping("/posts")
public RsData<List<Post>> getPosts() {
    List<Post> posts = postService.getPosts();
    RsData<List<Post>> rsData = RsData.of("S-1", "성공", posts);
    response.setStatus(rsData.getStatusCode()); // 이 부분이 모든 컨트롤러에서 반복됨
    return rsData;
}

// AOP를 이용한 코드
@GetMapping("/posts")
public RsData<List<Post>> getPosts() {
  List<Post> posts = postService.getPosts();
  return RsData.of("S-1", "성공", posts);  // 상태 코드 설정 걱정 없이 RsData만 반환
}
  • AOP를 사용하면
    • 모든 컨트롤러에서 상태 코드 설정하는 중복 코드를 제거
    • 컨트롤러는 비즈니스 로직에만 집중
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Aspect   // AOP 클래스임을 나타냄
@Component
@RequiredArgsConstructor
public class ResponseAspect {
  private final HttpServletResponse response;
  // @Around: 메서드 실행 전후에 처리
  @Around("""
            (
                within
                (
                    @org.springframework.web.bind.annotation.RestController *
                )
                &&
                (
                    @annotation(org.springframework.web.bind.annotation.GetMapping)
                    ||
                    @annotation(org.springframework.web.bind.annotation.PostMapping)
                    ||
                    @annotation(org.springframework.web.bind.annotation.PutMapping)
                    ||
                    @annotation(org.springframework.web.bind.annotation.DeleteMapping)
                    ||
                    @annotation(org.springframework.web.bind.annotation.RequestMapping)
                )
            )
            ||
            @annotation(org.springframework.web.bind.annotation.ResponseBody)
            """)
  public Object handleResponse(ProceedingJoinPoint joinPoint) throws Throwable {
    // 1. 컨트롤러 메서드 실행
    Object proceed = joinPoint.proceed();

    // 2. 컨트롤러에서 반환된 값으로 후처리
    if (proceed instanceof RsData<?>) {
      RsData<?> rsData = (RsData<?>) proceed;
      response.setStatus(rsData.getStatusCode());
    }

    // 3. MessageConverter로 전달
    return proceed;
  }
}
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.