<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>phonebee 님의 블로그</title>
    <link>https://phonebee.tistory.com/</link>
    <description>phonebee 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Wed, 15 Apr 2026 06:51:06 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>phonebee</managingEditor>
    <image>
      <title>phonebee 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/7468419/attach/15ff187c04504c70b3b41c5389e46a9a</url>
      <link>https://phonebee.tistory.com</link>
    </image>
    <item>
      <title>경매 검색 기능 강화를 위해 AWS OpenSearch를 EC2와 연동하여 실행시키기</title>
      <link>https://phonebee.tistory.com/95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저번에는 퍼블릭 도메인을 생성하여 AWS OpenSearch를 진행했다면 이번에는 EC2와 연동하여 진행하는 OpenSearch를 해보기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. EC2 제작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 연동하기 위한 EC2 인스턴스를 제작하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▶ 주의 사항&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenSearch 도메인과 연결하기 위한 EC2 인스턴스를 제작할 때에는 주의해야할 것이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;① 사용할 VPC가 연동할 OpenSearch 도메인과 같아야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 기본적인 외부 접속용(SSH, 8080)인 보안 그룹을 추가하여 인스턴스 제작을 완료했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. OpenSearch 도메인 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현 OpenSearch에는 EC2에서만 접근을 해야하기 때문에 보안 그룹을 수정하여 EC2의 보안 그룹 ID만 허용하도록 제작해야한다고 한다,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 OpenSearch용 보안그룹에 EC2의 보안 그룹 ID를 소스로 넣어서 보안그룹을 재설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. EC2에서 Spring Boot 실행하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 EC2에 접속하여 실행시켜보도록 하자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ssh -i &quot;your-key.pem&quot; ec2-ubuntu@&amp;lt;EC2 Public IP&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 EC2에 접속하기 위해서 EC2를 제작하면서 만든 pem키가 저장된 파일 경로와 EC2의 퍼블륵 IP를 위의 코드에 적용시켜서 실행한다.&lt;br /&gt;&lt;br /&gt;그러면 EC2에 접속하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후에는&lt;br /&gt;● 시스템 업데이트&lt;br /&gt;sudo&amp;nbsp;apt&amp;nbsp;update&amp;nbsp;&amp;amp;&amp;amp;&amp;nbsp;sudo&amp;nbsp;apt&amp;nbsp;upgrade&amp;nbsp;-y &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● Java 설치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sudo&amp;nbsp;apt&amp;nbsp;install&amp;nbsp;openjdk-17-jdk&amp;nbsp;-y &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 하여 환경을 구성한다.&lt;br /&gt;&lt;br /&gt;4. .jar파일 빌드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Spring Boot 애플리케이션 실행 준비를 위해 .jar파일을 EC2에 복사해야한다.&lt;br /&gt;&lt;br /&gt;그래서 해당 프로젝트의 jar파일을 찾고 있었는데&lt;br /&gt;막상 찾다 보니 jar파일이 만들어지지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 인텔리제이에 있는 build시스템에서 boojar를 사용하여 jar파일을 생성했다.&lt;br /&gt;&lt;br /&gt;그 후&lt;br /&gt;scp&amp;nbsp;-i&amp;nbsp;&quot;C:\Users\user\Downloads\auctionSearchTest.pem&quot;&amp;nbsp;path\to\your\file.jar&amp;nbsp;ubuntu@3.35.25.159:~ &lt;br /&gt;&lt;br /&gt;를 통해서 jar파일을 EC2에 복사해서 넣는다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. EC2에서 Spring boot 앱 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 마지막으로 EC2에서 Spring boot 앱을 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB_URL=&quot;jdbc:mysql://auctions.c9ak2ykyg8pv.ap-northeast-2.rds.amazo &lt;br /&gt;naws.com:3306/auctionMarket&quot; DB_USERNAME=&quot;EC2의 마스터 이름&quot; DB_PASSWORD=&quot;마스터 비밀번호&quot; JWT_SECRET_KEY=&quot;7 &lt;br /&gt;Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==&quot;&amp;nbsp;java&amp;nbsp;-jar&amp;nbsp;AuctionMarket-0.0.1-SNAPSHOT.jar&amp;nbsp;--spring.profiles.active=prod&lt;br /&gt;&lt;br /&gt;해당 코드를 EC2에 작성하여 실행하게 되었다.&lt;br /&gt;위의 실행 코드는 DB_URL, USERNAME, PASSWORD, JWT_SECRET_KEY를 직접 입력해주었는데 전의 로컬에서는 환경 변수로 해당 값들을 전부 주었지만 EC2에서는 로컬에 준 환경변수는 사용이 불가하므로 따로 줘야했다.&lt;br /&gt;그리고 EC2에서 Spring Boot를 실행하는 것이기 때문에 USERNAME, PASSWORD는 해당 EC2의 마스터 이름과 패스워드를 줘야 실행을 시킬 수 있다.&lt;br /&gt;&lt;br /&gt;그래서 해당 코드를 작성 후 실행하게 되면&lt;br /&gt;EC2에서 Spring Boot를 실행하게 되었다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 캡처 2025-04-27 172656.png&quot; data-origin-width=&quot;769&quot; data-origin-height=&quot;1357&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oB80C/btsNA1RFbyE/TVaxuElHQJJ2nH3F8RD5U0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oB80C/btsNA1RFbyE/TVaxuElHQJJ2nH3F8RD5U0/img.png&quot; data-alt=&quot;EC2에서 실행한 Spring Boot에서 경매 검색 기능 실행&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oB80C/btsNA1RFbyE/TVaxuElHQJJ2nH3F8RD5U0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoB80C%2FbtsNA1RFbyE%2FTVaxuElHQJJ2nH3F8RD5U0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;769&quot; height=&quot;1357&quot; data-filename=&quot;화면 캡처 2025-04-27 172656.png&quot; data-origin-width=&quot;769&quot; data-origin-height=&quot;1357&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;EC2에서 실행한 Spring Boot에서 경매 검색 기능 실행&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▶ 진행하면서 발생한 문제 트러블 슈팅&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 로컬로 진행한 elastic search와의 충돌&lt;br /&gt;● 배경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2에 jar파일을 저장한 후에 실행을 진행했더니 에러가 발생하면서 실패하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 원인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 OpenSearch는 VPC 전용으로 만들어졌지만 Spring Data Elasticsearch 기본 설정은 Public IP로 통신하려고 하기 때문에 실패를 하게 된다.&lt;br /&gt;&lt;br /&gt;● 해결 과정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾은 해결 과정으로는 RestHighLevelClient를 직접 사용하여 OpenSearch 쿼리에 던지는 것이였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 기존에 있었던 AuctionSearchService를 수정하고&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;import com.example.auctionmarket.domain.auction.document.AuctionDocument;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.action.index.IndexRequest;
import org.opensearch.action.search.SearchRequest;
import org.opensearch.action.search.SearchResponse;
import org.opensearch.client.RequestOptions;
import org.opensearch.client.RestHighLevelClient;

import org.opensearch.common.unit.Fuzziness;
import org.opensearch.common.xcontent.XContentType;
import org.opensearch.index.query.BoolQueryBuilder;
import org.opensearch.index.query.QueryBuilders;
import org.opensearch.search.SearchHit;
import org.opensearch.search.builder.SearchSourceBuilder;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuctionOpenSearchService {
    private final RestHighLevelClient restHighLevelClient;
    private final ObjectMapper objectMapper;

    private static final String INDEX_NAME = &quot;auctions&quot;;

    public void save(AuctionDocument auction) throws IOException {
        IndexRequest request = new IndexRequest(INDEX_NAME)
                .id(String.valueOf(auction.getId()))
                .source(objectMapper.writeValueAsString(auction), XContentType.JSON);

        restHighLevelClient.index(request, RequestOptions.DEFAULT);
    }

    public List&amp;lt;AuctionDocument&amp;gt; search(String keyword, String category, int page, int size) throws IOException {
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        if(keyword != null &amp;amp;&amp;amp; !keyword.isBlank()){
            boolQuery.should(QueryBuilders.matchPhrasePrefixQuery(&quot;productName&quot;, keyword))
                    .should(QueryBuilders.fuzzyQuery(&quot;productName&quot;, keyword).fuzziness(Fuzziness.AUTO))
                    .minimumShouldMatch(1);
        }

        if(category != null &amp;amp;&amp;amp; !category.isBlank()){
            boolQuery.filter(QueryBuilders.termQuery(&quot;category&quot;, category));
        }

        int from = (page - 1) * size;

        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
                .query(boolQuery)
                .from(from)
                .size(size);

        SearchRequest searchRequest = new SearchRequest(INDEX_NAME).source(sourceBuilder);
        SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);

        List&amp;lt;AuctionDocument&amp;gt; result = new ArrayList&amp;lt;&amp;gt;();
        for(SearchHit hit : searchResponse.getHits().getHits()) {
            AuctionDocument auction = objectMapper.readValue(hit.getSourceAsString(), AuctionDocument.class);
            result.add(auction);
        }

        return result;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로는 RestHighLevelClient를 쓰고 있기에 auctionSearchRepository를 주석처리를 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하고도 같은 에러가 나타나기에 좀 더 찾아 봤더니&lt;br /&gt;@EnableElasticsearchRepositories 때문에 주석처리를 하지 않더라도 파일은 계속해서 남아 있기 때문에 Spring은 컴파일 타임에 그걸 신경 안 쓰고 무조건 탐색한다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 해당 어노테이션과 auctionSearchRepository를 삭제한 후에 진행하게 되었는데...&lt;br /&gt;&lt;br /&gt;다시 똑같은 에러가 반복되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계속 같은 에러가 반복되기에 더 이상 코드의 문제가 아닌 것 같아서 다른 문제점을 찾아 봤더니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드를 다시 해야 지금까지 코드 수정한 내용이 적용이 된다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 EC2에 저장된 기존의 jar 파일을 삭제하고 새로 build를 한 후에 수정한 jar 파일을 다시 EC2서버에 복제한 후에 해당 jar파일을 실행했더니 성공적으로 프로그램이 가동되었다.&lt;br /&gt;&lt;br /&gt;그리고 postman으로 진행했더니 정상적으로 경매 검색까지 진행할 수 있었다.&lt;/p&gt;</description>
      <category>최종 프로젝트</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/95</guid>
      <comments>https://phonebee.tistory.com/95#entry95comment</comments>
      <pubDate>Sun, 27 Apr 2025 20:29:13 +0900</pubDate>
    </item>
    <item>
      <title>AWS OpenSearch를 활용한 검색 기능 구축기</title>
      <link>https://phonebee.tistory.com/94</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1. OpenSearch란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenSearch는 AWS에서 제공하는 Elasticsearch 기반의 오픈소스 검색 엔진으로, 대규모 텍스트 기반 검색과 로그 분석에 적합한 도구&lt;br /&gt;&lt;br /&gt;이번 프로젝트에서 OpenSearch를 활용하여 경매 검색 기능을 구축&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. AWS OpenSearch 도메인 생성하기&lt;br /&gt;우선, OpenSearch를 사용하기 위해서는 AWS OpenSearch 도메인을 생성해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 도메인이란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenSearch에서 domain은 하나의 클러스터(서버 집합)를 의미하며, 여기에 인덱스를 저장하고 쿼리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인을 생성하기 위해서 OpenSearch 서비스에 있는 도메인 생성을 통해서 생성하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;○ 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비용 절감을 위해서 많은 부분을 줄이게 되었다.&lt;br /&gt;▷ 버전 : OpenSearch 2.17&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▷ 데이터 노드 수&lt;br /&gt;- 가용 영역 : 1-대기 상태가 아닌 AZ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 인스턴스 유형 : t3.small.search&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 데이터 노드 수 : 1&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 스토리지 유형 : EBS&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 볼륨 크기 : 10&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 퍼블릭 엑세스를 허용해서 제작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 인텔리제이로 넘어와서 코드를 작성했다.&lt;br /&gt;&lt;br /&gt;3. 인덱스 구조 정의(AuctionDocument)&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Document(indexName = &quot;auctions&quot;)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuctionDocument {

    @Id
    private Long id;

    private String productName;
    private String category;
    private Long minPrice;
    private String startTime;
    private String endTime;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● @Document(indexName = &quot;auctions&quot;)로 인덱스를 지정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 검색 대상 필드를 문자열로 관리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. Spring Boot에서 OpenSearch 연동&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'org.opensearch.client:opensearch-rest-high-level-client:2.17.0'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle에 OpenSearch를 연동하기 위해서 의존성을 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● OpenSearchConfig 설정&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
public class OpenSearchConfig {

    @Value(&quot;${opensearch.url}&quot;)
    private String openSearchUrl;

    @Value(&quot;${opensearch.username}&quot;)
    private String username;

    @Value(&quot;${opensearch.password}&quot;)
    private String password;

    @Bean
    public RestHighLevelClient restHighLevelClient() {
        final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password));

        RestClientBuilder builder = RestClient.builder(HttpHost.create(openSearchUrl))
                .setHttpClientConfigCallback(httpClientBuilder -&amp;gt;
                        httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));

        return new RestHighLevelClient(builder);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● OpenSearch에 연결할 RestHighLevelClient를 설정하는 Spring @Configuration&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● application.properties에서 opensearch.url, username, password를 읽어와서 연결에 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● Basic 인증을 적용하여 AWS OpenSearch 퍼블릭 도메인에 접속할 수 있도록 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 검색 기능 구현(keyword + category 기반)&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class AuctionOpenSearchService {
    private final RestHighLevelClient restHighLevelClient;
    private final ObjectMapper objectMapper;

    private static final String INDEX_NAME = &quot;auctions&quot;;

    public void save(AuctionDocument auction) throws IOException {
        IndexRequest request = new IndexRequest(INDEX_NAME)
                .id(String.valueOf(auction.getId()))
                .source(objectMapper.writeValueAsString(auction), XContentType.JSON);

        restHighLevelClient.index(request, RequestOptions.DEFAULT);
    }

    public List&amp;lt;AuctionDocument&amp;gt; search(String keyword, String category, int page, int size) throws IOException {
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        if(keyword != null &amp;amp;&amp;amp; !keyword.isBlank()){
            boolQuery.should(QueryBuilders.matchPhrasePrefixQuery(&quot;productName&quot;, keyword))
                    .should(QueryBuilders.fuzzyQuery(&quot;productName&quot;, keyword).fuzziness(Fuzziness.AUTO))
                    .minimumShouldMatch(1);
        }

        if(category != null &amp;amp;&amp;amp; !category.isBlank()){
            boolQuery.filter(QueryBuilders.termQuery(&quot;category&quot;, category));
        }

        int from = (page - 1) * size;

        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
                .query(boolQuery)
                .from(from)
                .size(size);

        SearchRequest searchRequest = new SearchRequest(INDEX_NAME).source(sourceBuilder);
        SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);

        List&amp;lt;AuctionDocument&amp;gt; result = new ArrayList&amp;lt;&amp;gt;();
        for(SearchHit hit : searchResponse.getHits().getHits()) {
            AuctionDocument auction = objectMapper.readValue(hit.getSourceAsString(), AuctionDocument.class);
            result.add(auction);
        }

        return result;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● matchPhrasePrefixQuery를 통해 keyword 접두어 일치 검색을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● fuzzyQuery를 추가하여 오타가 있는 검색어도 허용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● category의 경우 termQuery를 사용해 category를 정확히 필터링한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 검색 결과는 AuctionDocument 리스트로 변환되어 반환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 AWS OpenSearch를 퍼블릭 도메인으로 제작을 했지만, EC2를 사용해서 다시 구현할 생각이다.&lt;/p&gt;</description>
      <category>최종 프로젝트</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/94</guid>
      <comments>https://phonebee.tistory.com/94#entry94comment</comments>
      <pubDate>Fri, 25 Apr 2025 20:55:24 +0900</pubDate>
    </item>
    <item>
      <title>QueryDSL vs 로컬 Elastic Search vs Open Search(AWS 퍼블릭)</title>
      <link>https://phonebee.tistory.com/93</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;▶ 테스트 방식&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10000건의 더미 데이터 생성 후 각 검색 기능 마다 검색 속도 비교&lt;br /&gt;&lt;br /&gt;&amp;lt;테스트 코드&amp;gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@SpringBootTest
@ActiveProfiles(&quot;test&quot;)
public class AuctionSearchBenchmarkTest {

    @Autowired
    private AuctionRepository auctionRepository;

    @Autowired
    private AuctionSearchRepository auctionSearchRepository;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private AuctionSearchService auctionSearchService;

    @Autowired
    private AuctionOpenSearchService auctionOpenSearchService;

    @BeforeEach
    void cleanUp(){
        auctionRepository.deleteAll();
        auctionSearchRepository.deleteAll();
        productRepository.deleteAll();
    }

    @Test
    @Transactional
    public void generateDummyAuctions(){
        User user = userRepository.findAll().stream().findFirst()
                .orElseThrow(()-&amp;gt; new RuntimeException(&quot;No user found&quot;));

        for(int i=0; i&amp;lt;10000; i++){

            Product product = new Product(
                    user,
                    &quot;Test Product &quot;+i,
                    &quot;content&quot;,
                    ProductCategory.SHOES
            );
            productRepository.save(product);

            Auction auction = new Auction(
                    product,
                    1000L + i,
                    LocalDateTime.now().plusHours(i),
                    60L
            );
            auctionRepository.save(auction);
            auctionSearchRepository.save(AuctionMapper.toDucument(auction));

            //OpenSearch 인덱싱 추가
            AuctionDocument document = AuctionDocument.builder()
                    .id(auction.getId())
                    .productName(auction.getProduct().getProductName())
                    .category(auction.getProduct().getCategory().name())
                    .minPrice(auction.getMinPrice())
                    .startTime(auction.getStartTime().toString())
                    .endTime(auction.getEndTime().toString())
                    .build();

            try{
                auctionOpenSearchService.save(document);
            }catch (IOException e){
                System.out.println(&quot;OpenSearch 인덱싱 실패: {&quot;+e.getMessage()+&quot;}&quot;);
            }
        }

        System.out.println(&quot;10000건의 더미 경매가 생성되었습니다.&quot;);
    }

    @Test
    public void benchmarkSearch() throws IOException {
        int size=10;
        int page=1;

        Pageable pageable = PageRequest.of(page-1, size);
        String keyword = &quot;testproduct&quot;;
        String category = &quot;SHOES&quot;;

        //QueryDSL을 사용한 검색 기능
        long startTime1 = System.currentTimeMillis();
        auctionRepository.findBySearch(keyword, category, pageable);
        long endTime1 = System.currentTimeMillis();
        System.out.println(&quot;QueryDSL 검색 소요 시간: &quot;+(endTime1-startTime1)+&quot;ms&quot;);

        //Elastic Search를 사용한 검색 기능
        long startTime2 = System.currentTimeMillis();
        auctionSearchService.searchAuctions(keyword, category, pageable);
        long endTime2 = System.currentTimeMillis();
        System.out.println(&quot;Elastic Search 검색 소요 시간: &quot;+(endTime2-startTime2)+&quot;ms&quot;);

        //OpenSearch를 사용한 검색 기능
        long startTime3 = System.currentTimeMillis();
        List&amp;lt;AuctionDocument&amp;gt; result = auctionOpenSearchService.search(keyword, category, page, size);
        long endTime3 = System.currentTimeMillis();
        System.out.println(&quot;OpenSearch 검색 소요 시간: &quot;+(endTime3-startTime3)+&quot;ms&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▶ 테스트 결과&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QueryDSL&amp;nbsp;검색&amp;nbsp;소요&amp;nbsp;시간:&amp;nbsp;388ms &lt;br /&gt;Elastic&amp;nbsp;Search&amp;nbsp;검색&amp;nbsp;소요&amp;nbsp;시간:&amp;nbsp;245ms &lt;br /&gt;OpenSearch&amp;nbsp;검색&amp;nbsp;소요&amp;nbsp;시간:&amp;nbsp;528ms&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 드는 의문점은 어째서 OpenSearch의 소요 시간이 남들보다 훨씬 더 걸리는가 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▶ 원인 분석&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 AWS 외부와 통신이 필요한 구조이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 Elastic Search은 로컬이기 때문에 네트워크 지연이 없지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS 외부와 통신을 하기 때문에 외부 호출로 인해서 시간 소요가 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ 단, 퍼블릭이 아닌 EC2 내부에서 호출하게 되면 속도가 훨씬 개선된다고 한다.&lt;/p&gt;</description>
      <category>최종 프로젝트</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/93</guid>
      <comments>https://phonebee.tistory.com/93#entry93comment</comments>
      <pubDate>Fri, 25 Apr 2025 20:09:23 +0900</pubDate>
    </item>
    <item>
      <title>로컬 elastic search와 AWS에서 제공하는 Amazon OpenSearch Service를 사용한 elastic search의 차이점</title>
      <link>https://phonebee.tistory.com/92</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;▶ 현재 사용 중인 로컬 Elasticsearch의 특징&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 145px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 20px;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 20px;&quot;&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 20px;&quot;&gt;설치 방식&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 20px;&quot;&gt;직접 설치(Docker or 바이너리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 20px;&quot;&gt;운영 책임&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 20px;&quot;&gt;본인이 직접 관리(리소스, 보안, 백업 등)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;가용성&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;단일 노드(서버 중지 시 검색도 중지)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;확장성&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;직접 수동 설정 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;보안&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;기본 인증 or 설정 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;모니터링/로그&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;로그 직접 수집하거나 kibana 연동 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;백업&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;수동 스냅샷 또는 외부 도구 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▷ 분석 결과 개발/테스트 환경에는 충분하지만, 운영환경으로 쓰기엔 한계가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▶ AWS OpenSearch의 장점&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;이점 설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;완전관리형 서비스&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;EC2에 직접 설치 X, AWS에서 자동으로 관리함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;고가용성 지원&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;다중 AZ 구성 가능, 자동 장애 복구&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;자동 백업 지원&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;자동 스냅샷 기능 제공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;모니터링 제공&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;CloudWatch, OpenSearch Dashboards 연동 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;보안 강화&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;IAM 인증, VPC 접근 제어, HTTPS 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;확장성 우수&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;클릭 몇 번으로 노드 수, 스토리지, 메모리 확장 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;Kibana 내장&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;시각화 도구 포함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;기본 SLA 제공&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;AWS SLA에 따라 안정적인 운영 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이를 보았을 때&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 로컬 ES &amp;rarr; 빠른 개발, 테스트 용도로 적합&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● AWS OpenSearch &amp;rarr; 운영 환경에 적합한 고가용성, 보안, 자동화 지원&lt;/p&gt;</description>
      <category>최종 프로젝트</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/92</guid>
      <comments>https://phonebee.tistory.com/92#entry92comment</comments>
      <pubDate>Wed, 23 Apr 2025 19:53:53 +0900</pubDate>
    </item>
    <item>
      <title>QueryDSL vs Elastic Search</title>
      <link>https://phonebee.tistory.com/91</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;MVP 개발에 제작한 QueryDSL을 사용한 검색 기능과 Elastic Search를 활용한 검색 기능의 성능 비교를 해보았다.&lt;br /&gt;&lt;br /&gt;▶ 조건&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10000건의 경매 검색&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;성능 테스트 코드&amp;gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@SpringBootTest
@ActiveProfiles(&quot;test&quot;)
public class AuctionSearchBenchmarkTest {

    @Autowired
    private AuctionRepository auctionRepository;

    @Autowired
    private AuctionSearchRepository auctionSearchRepository;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private AuctionSearchService auctionSearchService;

    @BeforeEach
    void cleanUp(){
        auctionRepository.deleteAll();
        auctionSearchRepository.deleteAll();
        productRepository.deleteAll();
    }

    @Test
    @Transactional
    public void generateDummyAuctions(){
        User user = userRepository.findAll().stream().findFirst()
                .orElseThrow(()-&amp;gt; new RuntimeException(&quot;No user found&quot;));

        for(int i=0; i&amp;lt;10000; i++){

            Product product = new Product(
                    user,
                    &quot;Test Product &quot;+i,
                    &quot;content&quot;,
                    ProductCategory.SHOES
            );
            productRepository.save(product);

            Auction auction = new Auction(
                    product,
                    1000L + i,
                    LocalDateTime.now().plusHours(i),
                    60L
            );
            auctionRepository.save(auction);
            auctionSearchRepository.save(AuctionMapper.toDucument(auction));
        }

        System.out.println(&quot;10000건의 더미 경매가 생성되었습니다.&quot;);
    }

    @Test
    public void benchmarkSearch() {
        Pageable pageable = PageRequest.of(0, 10);
        String keyword = &quot;testproduct&quot;;
        String category = &quot;SHOES&quot;;

        //QueryDSL을 사용한 검색 기능
        long startTime1 = System.currentTimeMillis();
        auctionRepository.findBySearch(keyword, category, pageable);
        long endTime1 = System.currentTimeMillis();
        System.out.println(&quot;QueryDSL 검색 소요 시간: &quot;+(endTime1-startTime1)+&quot;ms&quot;);

        //Elastic Search를 사용한 검색 기능
        long startTime2 = System.currentTimeMillis();
        auctionSearchService.searchAuctions(keyword, category, pageable);
        long endTime2 = System.currentTimeMillis();
        System.out.println(&quot;Elastic Search 검색 소요 시간: &quot;+(endTime2-startTime2)+&quot;ms&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;우선 10000건의 더미 데이터를 생성한 후 benchmarkSearch()메서드를 실행하였다.&lt;br /&gt;&lt;br /&gt;▶ 결과&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QueryDSL&amp;nbsp;검색&amp;nbsp;소요&amp;nbsp;시간:&amp;nbsp;380ms &lt;br /&gt;Elastic&amp;nbsp;Search&amp;nbsp;검색&amp;nbsp;소요&amp;nbsp;시간:&amp;nbsp;228ms&lt;br /&gt;&lt;br /&gt;Elastic Search를 활용한 검색 기능의 속도가 더 빠른 것을 알 수 있었다.&lt;br /&gt;&lt;br /&gt;▶ 원인 분석&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어째서 QueryDSL이 Elastic Search 보다 느렸는가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;● QueryDSL&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- DB에서 전체 데이터를 조회 + 정렬 + 필터링하는 방식&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 데이터량이 많아질수록 &amp;rarr; 정렬/조건/조인 처리 비용이 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● Elastic Search&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 검색에 최적화된 인덱스 구조 덕분에 문자열 검색 속도가 매우 빠름&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 내부적으로 역색인(inverted index) 구조를 쓰기 때문에 텍스트 기반 검색에 강함&lt;br /&gt;&lt;br /&gt;즉, 검색어가 많고 데이터량이 많을수록 검색 소요 시간의 차이가 점점 더 커지게 된다.&lt;br /&gt;&lt;br /&gt;특히 이 차이를 쉽게 알 수 있던게 바로 더미 데이터를 적용하기 전의 성능 비교이다.&lt;br /&gt;QueryDSL 검색 소요 시간: 506ms&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Elastic Search 검색 소요 시간: 514ms&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 더미 데이터를 적용하기 전 소량의 검색에는 오히려 QueryDSL이 앞선 것을 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 대량의 데이터 처리를 진행할 경우에는 Elastic Search를 이용하는 것이 좀 더 효과적인 것을 알 수 있다.&lt;/p&gt;</description>
      <category>최종 프로젝트</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/91</guid>
      <comments>https://phonebee.tistory.com/91#entry91comment</comments>
      <pubDate>Wed, 23 Apr 2025 16:00:56 +0900</pubDate>
    </item>
    <item>
      <title>벤치 마크를 사용하여 성능 비교</title>
      <link>https://phonebee.tistory.com/90</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;● 테스트 주제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;조회를 100번 시도 했을 때의 평균 응답 속도&amp;gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class AuctionServiceBenchmarkTest {

    @Autowired
    private AuctionService auctionService;

    @Test
    public void benchmarkGetAuctionsRedis(){
        int repeat = 100;
        long start = System.currentTimeMillis();

        for(int i=0;i&amp;lt;repeat;i++){
            auctionService.getAuctionsRedis(1,10);
        }

        long end = System.currentTimeMillis();
        System.out.println(&quot;[Redis] 평균 응답 시간: &quot; + (end-start)/(double) repeat + &quot;ms&quot;);
    }

    @Test
    public void benchmarkGetAuctionsCaffeine(){
        int repeat = 100;
        long start = System.currentTimeMillis();

        for(int i=0;i&amp;lt;repeat;i++){
            auctionService.getAuctionsCaffeine(1,10);
        }

        long end = System.currentTimeMillis();
        System.out.println(&quot;[Caffeine] 평균 응답 시간: &quot; + (end-start)/(double) repeat + &quot;ms&quot;);
    }

    @Test
    public void benchmarkGetAuctionsWithoutCache(){
        int repeat = 100;
        long start = System.currentTimeMillis();

        for(int i=0;i&amp;lt;repeat;i++){
            auctionService.getAuctions(1,10);
        }

        long end = System.currentTimeMillis();
        System.out.println(&quot;[No Cache] 평균 응답 시간: &quot; + (end-start)/(double) repeat + &quot;ms&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 테스트 결과&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[No Cache] 평균 응답 시간: 5.26ms&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[Redis] 평균 응답 시간: 10.05ms&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[Caffeine] 평균 응답 시간: 0.12ms&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Caffeine &amp;rarr; No Cache &amp;rarr; Redis 순으로 응답 속도가 빠르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 의문점&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 왜 Redis가 느린가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis는 기본적으로 외부서버와 TCP통신을 하기 때문에&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Java 객체 &amp;rarr; JSON 직렬화&lt;/li&gt;
&lt;li&gt;Redis로 네트워크 전송&lt;/li&gt;
&lt;li&gt;다시 역직렬화해서 Java 객체로 복구&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Caffeine은 직접 JVM 메모리에서 객체를 바로 꺼내기 때문에 Redis보다 훨씬 빠르다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 그럼 No Cache보다 느린가?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터가 작고 DB가 로컬에 있으면 단군 쿼리 실행이 Redis 통신보다 빠를 수도 있다.&lt;/li&gt;
&lt;li&gt;Redis는 캐시할 때 Jackson 직렬화/역직렬화가 항상 개입하기 때문에 그만큼 시간이 더 걸리게 된다.&lt;/li&gt;
&lt;li&gt;캐시 미스가 발생해서 Redis가 무의미하게 조회되고 있을 수도 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 속도만을 비교하면 Redis보다 Caffeine이 좀 더 우위에 있음을 알 수 있다.&lt;/p&gt;</description>
      <category>최종 프로젝트</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/90</guid>
      <comments>https://phonebee.tistory.com/90#entry90comment</comments>
      <pubDate>Fri, 18 Apr 2025 14:12:25 +0900</pubDate>
    </item>
    <item>
      <title>경매 서비스 성능 개선을 위한 Redis 활용</title>
      <link>https://phonebee.tistory.com/89</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1. Redis란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 인메모리 키-값 저장소로, 초고속 데이터 처리에 특화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 자료구조 지원 : String, List, Set, Sorted Set, Hash 등&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ 경매 서비스 적용 포인트&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 경매 목록 캐싱 &amp;rarr; String 또는 Hash&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 실시간 입찰 알림 &amp;rarr; Pub/Sub&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 최고가 갱신 Sorted Set&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Redis vs RDBMS(MySQL 비교)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;특징&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;Redis&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;MySQL(RDBMS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;저장 위치&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;메모리(RAM)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;디스크(SSD/HDD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;응답 속도&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;마이크로초 단위&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;밀리초 단위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;데이터 영속성&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;설정에 따라 가능(AOF/RDB)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;기본 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;트랜잭션&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;간단한 트랜잭션 지원&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;ACID 완벽 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;쿼리 언어&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;명령어 기반(GET/SET 등)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description>
      <category>최종 프로젝트</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/89</guid>
      <comments>https://phonebee.tistory.com/89#entry89comment</comments>
      <pubDate>Mon, 14 Apr 2025 10:29:11 +0900</pubDate>
    </item>
    <item>
      <title>플러스 주차 개인 과제 트러블 슈팅 TIL</title>
      <link>https://phonebee.tistory.com/88</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1. QueryDSL 빈 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 개요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 테스트를 하기 위해서 실행을 시켰을 때 오류가 발생하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 오류문&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Parameter 0 of constructor in org.example.expert.domain.todo.repository.TodoRepositoryImpl required a bean of type 'com.querydsl.jpa.impl.JPAQueryFactory' that could not be found. &lt;br /&gt;&lt;br /&gt;● 원인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 오류문을 검색해보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPAQueryFactory가 빈으로 등록되지 않아 발생한 문제라고 한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class TodoRepositoryImpl implements TodoRepositoryQuery{

    private final JPAQueryFactory queryFactory;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 불러서 사용했지만 정작 빈으로 등록을 하지 않아서 생긴 문제라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 해결 과정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이를 해결하기 위해서 빈을 등록할 Config 클래스를 제작했다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Configuration
public class QueryDSLConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 @PersistenceContext를 사용하여 EntityManager를 주입받고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPAQueryFactory를 빈으로 등록하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 다시 코드를 실행했을 때 정상적으로 작동되었음을 알 수 있었다.&lt;/p&gt;</description>
      <category>TIL</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/88</guid>
      <comments>https://phonebee.tistory.com/88#entry88comment</comments>
      <pubDate>Fri, 21 Mar 2025 10:21:57 +0900</pubDate>
    </item>
    <item>
      <title>플러스 주차 개인 과제 TIL 7.</title>
      <link>https://phonebee.tistory.com/87</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;▶ LEVEL 3&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▷ 11. Transaction 심화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 문제의 요구사항&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 매니저 등록 요청을 기록하는 로그 테이블을 제작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- DB테이블명: log&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 매니저 등록과는 별개로 로그 테이블에는 항상 요청 로그가 남아야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 매니저 등록은 실패할 수 있지만, 로그는 반드시 저장되어야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 로그 생성 기간은 반드시 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 그 외 로그에 들어가는 내용은 원하는 정보를 자유롭게 넣어야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 로그 기록을 받기 위한 로그 엔티티를 제작하도록 하자&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;@Entity
@Getter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
@Table(name = &quot;log&quot;)
public class Log extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long managerId;

    private String actions;
    private String status;
    private String message;

    public Log(Long managerId, String actions, String status, String message) {
        this.managerId = managerId;
        this.actions = actions;
        this.status = status;
        this.message = message;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;log엔티티에서는 &quot;log&quot;인 이름인 테이블을 생성하며 해당 엔티티에서는 managerId, actions, status, message값을 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 로그 저장을 위해 리포지토리를 제작한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface LogRepository extends JpaRepository&amp;lt;Log, Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 매니저 등록과 로그 저장을 독립적인 트랙잭션으로 처리한 서비스 로직을 제작한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class LogService {

    private final LogRepository logRepository;

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void saveLog(Long managerId, String actions, String status, String message){
        Log log = new Log(managerId, actions, status, message);
        logRepository.save(log);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하여 별도의 트랙잭션으로 저장되도록 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 로그 서비스를 이용하여 manager가 생성되거나 삭제될 때 로그를 출력할 수 있도록 매니저 서비스에 구현한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ManagerService {

    private final ManagerRepository managerRepository;
    private final UserRepository userRepository;
    private final TodoRepository todoRepository;
    private final LogService logService;

    @Transactional
    public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSaveRequest managerSaveRequest) {
        // 일정을 만든 유저
        User user = User.fromAuthUser(authUser);
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;Todo not found&quot;));

        if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
            throw new InvalidRequestException(&quot;담당자를 등록하려고 하는 유저가 유효하지 않거나, 일정을 만든 유저가 아닙니다.&quot;);
        }

        User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
                .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;등록하려고 하는 담당자 유저가 존재하지 않습니다.&quot;));

        if (ObjectUtils.nullSafeEquals(user.getId(), managerUser.getId())) {
            throw new InvalidRequestException(&quot;일정 작성자는 본인을 담당자로 등록할 수 없습니다.&quot;);
        }

        Long managerId = null;
        try{
            Manager newManagerUser = new Manager(managerUser, todo);
            Manager savedManagerUser = managerRepository.save(newManagerUser);
            managerId = savedManagerUser.getId();

            logService.saveLog(managerId, &quot;MANAGER_REGISTER&quot;, &quot;SUCCESS&quot;, &quot;매니저 등록 성공&quot;);

            return new ManagerSaveResponse(
                    savedManagerUser.getId(),
                    new UserResponse(managerUser.getId(), managerUser.getEmail())
            );
        }catch (Exception e){
            logService.saveLog(managerId, &quot;MANAGER_REGISTER&quot;, &quot;FAILURE&quot;, e.getMessage());
            throw e;
        }
    }

    public List&amp;lt;ManagerResponse&amp;gt; getManagers(long todoId) {
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;Todo not found&quot;));

        List&amp;lt;Manager&amp;gt; managerList = managerRepository.findByTodoIdWithUser(todo.getId());

        List&amp;lt;ManagerResponse&amp;gt; dtoList = new ArrayList&amp;lt;&amp;gt;();
        for (Manager manager : managerList) {
            User user = manager.getUser();
            dtoList.add(new ManagerResponse(
                    manager.getId(),
                    new UserResponse(user.getId(), user.getEmail())
            ));
        }
        return dtoList;
    }

    @Transactional
    public void deleteManager(AuthUser authUser, long todoId, long managerId) {
        User user = User.fromAuthUser(authUser);

        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;Todo not found&quot;));

        if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
            throw new InvalidRequestException(&quot;해당 일정을 만든 유저가 유효하지 않습니다.&quot;);
        }

        Manager manager = managerRepository.findById(managerId)
                .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;Manager not found&quot;));

        if (!ObjectUtils.nullSafeEquals(todo.getId(), manager.getTodo().getId())) {
            throw new InvalidRequestException(&quot;해당 일정에 등록된 담당자가 아닙니다.&quot;);
        }

        try{
            managerRepository.delete(manager);

            logService.saveLog(managerId, &quot;MANAGER_DELETE&quot;, &quot;SUCCESS&quot;, &quot;매니저 삭제 성공&quot;);
        }catch (Exception e){
            logService.saveLog(managerId, &quot;MANAGER_DELETE&quot;, &quot;FAILURE&quot;, e.getMessage());
            throw e;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 제작했는데 여기서 로그 서비스는 매니저를 등록하거나 삭제할 때 로그를 출력하며 실패했을 때에도 오류 메세지와 함께 출력되게 된다.&lt;/p&gt;</description>
      <category>TIL</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/87</guid>
      <comments>https://phonebee.tistory.com/87#entry87comment</comments>
      <pubDate>Tue, 18 Mar 2025 19:47:51 +0900</pubDate>
    </item>
    <item>
      <title>플러스 주차 개인 과제 TIL 6.</title>
      <link>https://phonebee.tistory.com/86</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;▶ LEVEL 3&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▷ 10. QueryDSL을 사용하여 검색 기능 만들기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;● 문제의 요구사항&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 새로운 API로 제작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 검색 조건 구성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 검색 키워드로 일정의 제목을 검색할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(제목은 부분적으로 일치해도 검색이 가능)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 일정의 생성일 범위로 검색&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(일정을 생성일 최신순으로 정렬)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 담당자의 닉네임으로도 검색 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(닉네임은 부분적으로 일치해도 검색 가능)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 검색 결과에 해당하는 내용이 들어있어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 일정에 대한 모든 정보가 아닌, 제목만 넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 해당 일정의 담당자 수를 넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 해당 일정의 총 댓글 개수를 넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 검색 결과는 페이징 처리되어 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 글로 요구사항을 해결해보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 새로운 API로 제작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능을 작동할 새로운 API를 Conrollter클래스에서 제작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 검색 조건 구성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 제목과, 닉네임이 부분적으로 일치했을 때도 검색하는 쿼리 조건을 추가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 일정의 생성일을 받아오고, 해당 범위 내에 있는 일정을 생성일을 기준으로 내림차순으로 정렬한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 검색 결과 출력&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요한 내용만 출력하도록 제작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 검색 결과는 페이징 처리되어 반환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이징 처리 기능을 추가하여 제작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 제작을 해보도록 하자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 응답을 새로 만들어야하기 때문에 해당하는 DTO를 제작하도록 한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class TodoSearchResponse {
    private String title;
    private Long managerCount;
    private Long commentCount;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 전에 생성자를 작성하는 것이 아니라 @AllArgsConstructor를 사용했는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@AllArgsConstructor는 모든 필드를 포함한 생성자가 자동 생성되므로 따로 생성자를 만들 필요가 없기 때문에 사용하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 DTO의 경우에는 제목과 담당자 수, 댓글 수만 출력하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답용 DTO를 제작했으니 QueryDSL을 사용한 리포지토리를 작성해보도록 하자&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface TodoRepositoryQuery {
    Optional&amp;lt;Todo&amp;gt; findByIdWithUser(Long todoId);

    Page&amp;lt;TodoSearchResponse&amp;gt; searchTodos(String keyword, LocalDateTime startDate, LocalDateTime endDate, String managerNickname, Pageable pageable);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QueryDSL을 사용할 것이기 때문에 별도로 생성한 TodoRepositoryQuery에 searchTodos 메서드를 작성한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Override
public Page&amp;lt;TodoSearchResponse&amp;gt; searchTodos(String keyword, LocalDateTime startDate, LocalDateTime endDate, String managerNickname, Pageable pageable) {
    QTodo todo = QTodo.todo;
    QManager manager = QManager.manager;
    QUser user = QUser.user;
    QComment comment = QComment.comment;

    JPQLQuery&amp;lt;TodoSearchResponse&amp;gt; query = queryFactory
            .select(Projections.constructor(
                    TodoSearchResponse.class,
                    todo.title,
                    manager.countDistinct(),
                    comment.countDistinct()
            ))
            .from(todo)
            .leftJoin(todo.managers, manager)
            .leftJoin(manager.user, user)
            .leftJoin(todo.comments, comment)
            .where(
                    titleContains(keyword),
                    createdAtBetween(startDate, endDate),
                    managerNicknameContains(managerNickname)
            )
            .groupBy(todo.id)
            .orderBy(todo.createdAt.desc());

    List&amp;lt;TodoSearchResponse&amp;gt; results = query
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    long total = query.fetchCount();

    return new PageImpl&amp;lt;&amp;gt;(results, pageable, total);
}

private BooleanExpression titleContains(String keyword) {
    return (keyword == null || keyword.isEmpty()) ? null : QTodo.todo.title.containsIgnoreCase(keyword);
}

private BooleanExpression createdAtBetween(LocalDateTime start, LocalDateTime end) {
    return (start == null || end == null) ? null : QTodo.todo.createdAt.between(start, end);
}

private BooleanExpression managerNicknameContains(String managerNickname) {
    return (managerNickname == null || managerNickname.isEmpty()) ? null : QUser.user.nickname.containsIgnoreCase(managerNickname);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 QueryDSL로 작성한 쿼리문과 조건문들을 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후에 Service와 Controller에 추가하여 마무리하였다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public Page&amp;lt;TodoSearchResponse&amp;gt; searchTodos(String keyword, LocalDateTime startDate, LocalDateTime endDate, String managerNickname, Pageable pageable){
    return todoRepository.searchTodos(keyword, startDate, endDate, managerNickname, pageable);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@GetMapping(&quot;/todos/search&quot;)
public ResponseEntity&amp;lt;Page&amp;lt;TodoSearchResponse&amp;gt;&amp;gt; searchTodos(
        @RequestParam(required = false) String keyword,
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
        @RequestParam(required = false) String managerNickname,
        @PageableDefault(size = 10, sort = &quot;createdAt&quot;, direction = Sort.Direction.DESC) Pageable pageable
){
    Page&amp;lt;TodoSearchResponse&amp;gt; result = todoService.searchTodos(keyword, startDate, endDate, managerNickname, pageable);
    return ResponseEntity.ok(result);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러의 경우 @RequestParam(required = false)를 통해서 해당 값을 받지 않아도 검색이 진행될 수 있게끔 작성하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>phonebee</author>
      <guid isPermaLink="true">https://phonebee.tistory.com/86</guid>
      <comments>https://phonebee.tistory.com/86#entry86comment</comments>
      <pubDate>Mon, 17 Mar 2025 13:26:46 +0900</pubDate>
    </item>
  </channel>
</rss>