diff --git a/.env.example b/.env.example index f60384f..73334b9 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,15 @@ REDIS_HOST= REDIS_PORT=6379 REDIS_PASSWORD= +ES_HOST= +ES_PORT=9200 +SEARCH_ENABLED=true +SEARCH_SYNC_FIXED_DELAY_MS=300000 +SEARCH_SYNC_BATCH_SIZE=500 + +# 최초 전체 색인을 실행할 배포에서만 true로 설정한 뒤 다시 false로 돌린다. +SEARCH_INITIAL_INDEX_ENABLED=false + SERVER_URL= JWT_SECRET= @@ -26,4 +35,4 @@ DDL_AUTO_SET=update MYSQL_ROOT_PASSWORD= MYSQL_DATABASE= MYSQL_USER= -MYSQL_PASSWORD= \ No newline at end of file +MYSQL_PASSWORD= diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 57ff1f8..1576e9f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -15,6 +15,8 @@ permissions: env: IMAGE_NAME: mingkiboo/amumal-amon + ES_IMAGE_NAME: mingkiboo/amumal-es-nori + ES_VERSION: 9.4.2 ## Docker blue/green #jobs: @@ -131,6 +133,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: docker/elasticsearch 변경 감지 + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + es: + - 'docker/elasticsearch/**' - name: Docker Hub 로그인 uses: docker/login-action@v3 with: @@ -140,6 +151,16 @@ jobs: run: | docker build -t ${{ env.IMAGE_NAME }}:${{ github.sha }} . docker push ${{ env.IMAGE_NAME }}:${{ github.sha }} + # docker/elasticsearch 경로가 바뀔 때만 빌드/push (ES_VERSION 태그 고정이라 매번 돌리면 낭비) + - name: Nori Elasticsearch 이미지 빌드 & push + if: steps.filter.outputs.es == 'true' + run: | + docker build \ + --build-arg ES_VERSION=${{ env.ES_VERSION }} \ + -f docker/elasticsearch/Dockerfile \ + -t ${{ env.ES_IMAGE_NAME }}:${{ env.ES_VERSION }} \ + . + docker push ${{ env.ES_IMAGE_NAME }}:${{ env.ES_VERSION }} update-manifest: needs: build-push @@ -157,4 +178,4 @@ jobs: git config user.email "actions@github.com" git add argocd/amumal-backend/values.yaml git commit -m "chore: bump image ${{ github.sha }} [skip ci]" || echo "변경 없음" - git push \ No newline at end of file + git push diff --git a/.github/workflows/ci-merge.yml b/.github/workflows/ci-merge.yml index 2db02b9..0c48abb 100644 --- a/.github/workflows/ci-merge.yml +++ b/.github/workflows/ci-merge.yml @@ -33,6 +33,8 @@ jobs: SERVER_URL: http://localhost REDIS_HOST: 127.0.0.1 REDIS_PORT: 6379 + ES_HOST: 127.0.0.1 + ES_PORT: 9200 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 diff --git a/.gitignore b/.gitignore index a9bd22f..9cc62e6 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ tmp/ *.seed *.tmp argocd/monitoring-chart/charts/ +argocd/elasticsearch-chart/charts/ +argocd/eck-operator-chart/charts/ # Secrets and certificates *.pem diff --git a/argocd/amumal-backend/templates/deployment.yaml b/argocd/amumal-backend/templates/deployment.yaml index 9cc74df..b1c6857 100644 --- a/argocd/amumal-backend/templates/deployment.yaml +++ b/argocd/amumal-backend/templates/deployment.yaml @@ -44,6 +44,13 @@ spec: envFrom: - secretRef: name: {{ .Values.envSecret }} + env: + # ES가 다른 네임스페이스(es-ns)에 있어 FQDN으로 접근한다. + # X-Pack 보안을 꺼둔 내부 통신이라 인증 정보는 필요 없다 (argocd/elasticsearch-chart/values.yaml 참고). + - name: ES_HOST + value: amumal-search-es-http.es-ns.svc.cluster.local + - name: ES_PORT + value: "9200" lifecycle: preStop: exec: diff --git a/argocd/argocd-apps/app-eck-operator.yaml b/argocd/argocd-apps/app-eck-operator.yaml new file mode 100644 index 0000000..3eed43a --- /dev/null +++ b/argocd/argocd-apps/app-eck-operator.yaml @@ -0,0 +1,24 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: eck-operator + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/kbt-practice/KBT.git + targetRevision: main + path: argocd/eck-operator-chart + helm: + releaseName: eck-operator + destination: + server: https://kubernetes.default.svc + namespace: elastic-system + syncPolicy: + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + automated: + # ECK CRD 삭제는 클러스터의 모든 Elastic 리소스를 제거할 수 있어 prune하지 않는다. + prune: false + selfHeal: true diff --git a/argocd/argocd-apps/app-edge.yaml b/argocd/argocd-apps/app-edge.yaml index 3c924e7..e0b04a0 100644 --- a/argocd/argocd-apps/app-edge.yaml +++ b/argocd/argocd-apps/app-edge.yaml @@ -3,8 +3,6 @@ kind: Application metadata: name: amumal-edge namespace: argocd - annotations: - argocd.argoproj.io/sync-wave: "-1" spec: project: default source: diff --git a/argocd/argocd-apps/app-elasticsearch.yaml b/argocd/argocd-apps/app-elasticsearch.yaml new file mode 100644 index 0000000..78c7b44 --- /dev/null +++ b/argocd/argocd-apps/app-elasticsearch.yaml @@ -0,0 +1,25 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: amumal-elasticsearch + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/kbt-practice/KBT.git + targetRevision: main + path: argocd/elasticsearch-chart + helm: + releaseName: amumal-search + destination: + server: https://kubernetes.default.svc + namespace: es-ns + syncPolicy: + syncOptions: + - CreateNamespace=false + - SkipDryRunOnMissingResource=true + - ServerSideApply=true + automated: + # PVC를 보호하기 위해 Elasticsearch CR은 자동 prune하지 않는다. + prune: false + selfHeal: true diff --git a/argocd/argocd-apps/app-monitoring.yaml b/argocd/argocd-apps/app-monitoring.yaml index d64d5ec..842cea2 100644 --- a/argocd/argocd-apps/app-monitoring.yaml +++ b/argocd/argocd-apps/app-monitoring.yaml @@ -3,8 +3,6 @@ kind: Application metadata: name: amumal-monitoring namespace: argocd - annotations: - argocd.argoproj.io/sync-wave: "1" spec: project: default source: diff --git a/argocd/argocd-apps/app-namespaces.yaml b/argocd/argocd-apps/app-namespaces.yaml index 8e6464d..52f6799 100644 --- a/argocd/argocd-apps/app-namespaces.yaml +++ b/argocd/argocd-apps/app-namespaces.yaml @@ -3,8 +3,6 @@ kind: Application metadata: name: amumal-namespaces namespace: argocd - annotations: - argocd.argoproj.io/sync-wave: "-2" spec: project: default source: diff --git a/argocd/argocd-apps/app-portainer.yaml b/argocd/argocd-apps/app-portainer.yaml index 715501c..edc4fed 100644 --- a/argocd/argocd-apps/app-portainer.yaml +++ b/argocd/argocd-apps/app-portainer.yaml @@ -3,8 +3,6 @@ kind: Application metadata: name: amumal-portainer namespace: argocd - annotations: - argocd.argoproj.io/sync-wave: "0" spec: project: default source: diff --git a/argocd/eck-operator-chart/Chart.lock b/argocd/eck-operator-chart/Chart.lock new file mode 100644 index 0000000..7044c6e --- /dev/null +++ b/argocd/eck-operator-chart/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: eck-operator + repository: https://helm.elastic.co + version: 3.5.0 +digest: sha256:cc2e67cd04e4f5a6be392d16045b4cadae310f73ba3e4d725e2d6d3b717dbb10 +generated: "2026-08-06T14:06:47.329141+09:00" diff --git a/argocd/eck-operator-chart/Chart.yaml b/argocd/eck-operator-chart/Chart.yaml new file mode 100644 index 0000000..942c370 --- /dev/null +++ b/argocd/eck-operator-chart/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: amumal-eck-operator +description: ECK (Elastic Cloud on Kubernetes) operator +type: application +version: 0.1.0 +appVersion: "3.5.0" +dependencies: + - name: eck-operator + version: 3.5.0 + repository: https://helm.elastic.co diff --git a/argocd/eck-operator-chart/values.yaml b/argocd/eck-operator-chart/values.yaml new file mode 100644 index 0000000..d0860f0 --- /dev/null +++ b/argocd/eck-operator-chart/values.yaml @@ -0,0 +1,18 @@ +# ===================================================================== +# eck-operator 3.5.0 +# ===================================================================== +eck-operator: + installCRDs: true + replicaCount: 1 + managedNamespaces: + - es-ns + resources: + requests: + cpu: 100m + memory: 150Mi + limits: + cpu: 500m + memory: 512Mi + config: + metrics: + port: "0" diff --git a/argocd/elasticsearch-chart/Chart.lock b/argocd/elasticsearch-chart/Chart.lock new file mode 100644 index 0000000..8c8f601 --- /dev/null +++ b/argocd/elasticsearch-chart/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: eck-elasticsearch + repository: https://helm.elastic.co + version: 0.20.0 +digest: sha256:f34833df7de4ff85e626d071d02c220c27e2af75be594d4851ae05f4f2d1b63a +generated: "2026-08-06T13:33:45.95534+09:00" diff --git a/argocd/elasticsearch-chart/Chart.yaml b/argocd/elasticsearch-chart/Chart.yaml new file mode 100644 index 0000000..9a586a0 --- /dev/null +++ b/argocd/elasticsearch-chart/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: amumal-elasticsearch +description: Elasticsearch (ECK) for Amumal search +type: application +version: 0.1.0 +appVersion: "9.4.2" +dependencies: + - name: eck-elasticsearch + version: 0.20.0 + repository: https://helm.elastic.co diff --git a/argocd/elasticsearch-chart/values.yaml b/argocd/elasticsearch-chart/values.yaml new file mode 100644 index 0000000..c4f5e7e --- /dev/null +++ b/argocd/elasticsearch-chart/values.yaml @@ -0,0 +1,57 @@ +# ===================================================================== +# eck-elasticsearch 0.20.0 +# - backend(backend-ns)와 다른 네임스페이스(es-ns)에 있어 ECK가 만드는 elastic 유저 Secret을 backend가 못 참조한다. +# 클러스터 내부 통신만 쓰는 전제로 X-Pack 보안(인증/TLS)을 아예 비활성화해 이 문제를 피한다. +# ===================================================================== +eck-elasticsearch: + fullnameOverride: amumal-search + version: 9.4.2 + image: mingkiboo/amumal-es-nori:9.4.2 + + http: + service: + spec: + type: ClusterIP + + volumeClaimDeletePolicy: DeleteOnScaledownOnly + + nodeSets: + - name: default + count: 1 + config: + node.store.allow_mmap: false + xpack.security.enabled: false + xpack.security.http.ssl.enabled: false + xpack.security.transport.ssl.enabled: false + podTemplate: + spec: + # regcred는 backend-ns에 있는 Secret이라 여기서 못 쓴다 (Secret은 네임스페이스 간 참조 불가). + # es-ns에 동일한 이름으로 별도 생성해서 쓴다 (kubectl로 수동 생성, git에는 안 커밋). + imagePullSecrets: + - name: regcred + # 컨테이너 레벨(allowPrivilegeEscalation/capabilities/readOnlyRootFilesystem)은 + # ES 8.8+부터 ECK가 기본으로 넣어줘서 여기선 ECK가 안 채워주는 pod 레벨만 명시한다. + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: elasticsearch + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + cpu: "2" + memory: 2Gi + volumeClaimTemplates: + - metadata: + name: elasticsearch-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi diff --git a/argocd/namespaces-chart/values.yaml b/argocd/namespaces-chart/values.yaml index 0d03d85..bf50b8c 100644 --- a/argocd/namespaces-chart/values.yaml +++ b/argocd/namespaces-chart/values.yaml @@ -6,6 +6,7 @@ namespaces: - name: backend-ns quota: + # Backend 2개 + rolling surge 1개 pods: "3" requests.cpu: "300m" requests.memory: 1536Mi @@ -15,6 +16,19 @@ namespaces: defaultRequest: { cpu: 100m, memory: 512Mi } default: { cpu: 1000m, memory: 1280Mi } + - name: es-ns + quota: + # Elasticsearch 1개 + 운영 여유 1개 + pods: "2" + requests.cpu: "500m" + requests.memory: 2Gi + limits.cpu: "2" + limits.memory: 2Gi + limitRange: + defaultRequest: { cpu: 200m, memory: 512Mi } + default: { cpu: 1000m, memory: 2Gi } + + # Ingress - name: edge-ns quota: pods: "4" diff --git a/build.gradle b/build.gradle index ac4ca3d..4c7c167 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,9 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Elasticsearch + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' + // QueryDSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index c8229ab..99a8086 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -18,15 +18,18 @@ services: SPRING_PROFILES_ACTIVE: dev DB_HOST: mysql REDIS_HOST: redis + ES_HOST: elasticsearch SERVER_URL: http://localhost:8080 LOGGING_FILE_NAME: /tmp/app.log ports: - - "${APP_PORT:-8080}:8080" + - "127.0.0.1:${APP_PORT:-8080}:8080" depends_on: mysql: condition: service_healthy redis: condition: service_healthy + elasticsearch: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "bash -c ' { List findByPostIdAndDeletedAtIsNullOrderByCreatedAtAsc(int postId); + List findByPostIdInAndDeletedAtIsNullOrderByPostIdAscCreatedAtAsc(List postIds); void deleteByPostId(int postId); // N+1 방지: 여러 게시글의 댓글 수를 DTO Projection으로 한 번에 조회 diff --git a/src/main/java/com/kbt/amumal/domain/comment/service/CommentService.java b/src/main/java/com/kbt/amumal/domain/comment/service/CommentService.java index f534811..95a42c8 100644 --- a/src/main/java/com/kbt/amumal/domain/comment/service/CommentService.java +++ b/src/main/java/com/kbt/amumal/domain/comment/service/CommentService.java @@ -5,6 +5,7 @@ import com.kbt.amumal.domain.comment.repository.CommentRepository; import com.kbt.amumal.domain.post.entity.Post; import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.service.SearchDirtyService; import com.kbt.amumal.global.error.CustomException; import com.kbt.amumal.global.error.ErrorCode; import lombok.RequiredArgsConstructor; @@ -19,6 +20,7 @@ public class CommentService { private final CommentRepository commentRepository; private final PostRepository postRepository; + private final SearchDirtyService searchDirtyService; // 댓글 생성 public int create(int id, Integer postId, CommentReqDTO.CreateComment request) { @@ -34,6 +36,7 @@ public int create(int id, Integer postId, CommentReqDTO.CreateComment request) { .postId(postId) .build()); postRepository.incrementCommentCount(postId); + searchDirtyService.markDirty(postId); return newComment.getCommentId(); } @@ -50,6 +53,7 @@ public void update(int id, Integer commentId, CommentReqDTO.UpdateComment reques throw new CustomException(ErrorCode.FORBIDDEN, "유저 정보를 확인해주세요."); comment.updateComment(request.content()); + searchDirtyService.markDirty(comment.getPostId()); } // 댓글 삭제 @@ -65,5 +69,6 @@ public void delete(int id, Integer commentId) { comment.softDelete(); postRepository.decrementCommentCount(comment.getPostId()); + searchDirtyService.markDirty(comment.getPostId()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/kbt/amumal/domain/post/repository/PostRepository.java b/src/main/java/com/kbt/amumal/domain/post/repository/PostRepository.java index ef32f98..07141fa 100644 --- a/src/main/java/com/kbt/amumal/domain/post/repository/PostRepository.java +++ b/src/main/java/com/kbt/amumal/domain/post/repository/PostRepository.java @@ -1,6 +1,7 @@ package com.kbt.amumal.domain.post.repository; import com.kbt.amumal.domain.post.entity.Post; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -34,4 +35,16 @@ public interface PostRepository extends JpaRepository, PostReposi @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Post p SET p.commentCount = p.commentCount - 1 WHERE p.postId = :postId AND p.commentCount > 0") void decrementCommentCount(@Param("postId") Integer postId); + + /** + * 제목·본문에 키워드가 포함된 게시글을 최신순으로 조회한다. + * ES 없이 LIKE만으로 검색할 때의 기준선(baseline) 측정용이며, 앞뒤 와일드카드(%keyword%)라 인덱스를 타지 못하고 풀스캔한다. + */ + @Query(""" + SELECT p FROM Post p + WHERE p.deletedAt IS NULL + AND (p.title LIKE CONCAT('%', :keyword, '%') OR p.content LIKE CONCAT('%', :keyword, '%')) + ORDER BY p.createdAt DESC + """) + List searchByTitleOrContentLike(@Param("keyword") String keyword, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/kbt/amumal/domain/post/service/PostService.java b/src/main/java/com/kbt/amumal/domain/post/service/PostService.java index a71eefa..42623c5 100644 --- a/src/main/java/com/kbt/amumal/domain/post/service/PostService.java +++ b/src/main/java/com/kbt/amumal/domain/post/service/PostService.java @@ -8,6 +8,7 @@ import com.kbt.amumal.domain.post.entity.Post; import com.kbt.amumal.domain.post.repository.LikeRepository; import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.service.SearchDirtyService; import com.kbt.amumal.domain.user.dto.UserProjection; import com.kbt.amumal.domain.user.entity.User; import com.kbt.amumal.domain.user.repository.UserRepository; @@ -36,6 +37,7 @@ public class PostService { private final CommentRepository commentRepository; private final UserRepository userRepository; private final ImageHandler fileService; + private final SearchDirtyService searchDirtyService; public int create(int id, PostReqDTO.CreatePost request, MultipartFile postImage) { String postImageUrl = uploadImageIfPresent(postImage); @@ -53,6 +55,8 @@ public int create(int id, PostReqDTO.CreatePost request, MultipartFile postImage .userId(id) .build()); + searchDirtyService.markDirty(newPost.getPostId()); + return newPost.getPostId(); } @@ -85,6 +89,8 @@ public void afterCompletion(int status) { post.updatePostImage(newImageUrl, postImage.getOriginalFilename()); } + + searchDirtyService.markDirty(postId); } public void delete(int id, Integer postId) { @@ -95,6 +101,7 @@ public void delete(int id, Integer postId) { throw new CustomException(ErrorCode.POST_FORBIDDEN_DELETE); post.softDelete(); + searchDirtyService.markDirty(postId); } public PostResDTO.PostDetailResponse get(Integer postId) { diff --git a/src/main/java/com/kbt/amumal/domain/search/controller/PostSearchController.java b/src/main/java/com/kbt/amumal/domain/search/controller/PostSearchController.java new file mode 100644 index 0000000..27a8d2d --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/controller/PostSearchController.java @@ -0,0 +1,52 @@ +package com.kbt.amumal.domain.search.controller; + +import com.kbt.amumal.domain.search.dto.PostSearchResDTO; +import com.kbt.amumal.domain.search.service.PostLikeSearchService; +import com.kbt.amumal.domain.search.service.PostSearchService; +import com.kbt.amumal.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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; + +@Tag(name = "검색", description = "게시글 제목·본문·댓글 검색 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/search/posts") +@ConditionalOnProperty(name = "search.enabled", havingValue = "true") +public class PostSearchController { + + private final PostSearchService postSearchService; + private final PostLikeSearchService postLikeSearchService; + + // ES 검색 (nori 분석 + fuzzy, 정확도/최신순 정렬, 커서 페이지네이션) + @Operation(summary = "게시글 검색", description = "Nori 형태소 분석과 AUTO fuzzy를 사용해 제목·본문·댓글을 검색합니다.") + @SecurityRequirements + @GetMapping + public ApiResponse searchPosts( + @Parameter(description = "검색어") @RequestParam String keyword, + @Parameter(description = "정렬: accuracy 또는 latest") @RequestParam(defaultValue = "accuracy") String sort, + @Parameter(description = "직전 페이지의 nextCursor") @RequestParam(required = false) String cursor, + @Parameter(description = "페이지 크기(1~100)") @RequestParam(defaultValue = "10") int size + ) { + PostSearchResDTO.response response = postSearchService.search(keyword, sort, cursor, size); + return ApiResponse.success("게시글 검색 성공", response); + } + + // MySQL LIKE 풀스캔 검색 (ES와 결과·성능 비교용 baseline, 페이지네이션 없음) + @Operation(summary = "게시글 검색 (LIKE, 비교용)", description = "ES 검색과 결과·성능을 비교하기 위해 title/content를 LIKE %keyword%로 검색합니다. 커서 페이지네이션은 지원하지 않습니다.") + @SecurityRequirements + @GetMapping("/like") + public ApiResponse searchPostsByLike( + @Parameter(description = "검색어") @RequestParam String keyword, + @Parameter(description = "결과 개수(1~100)") @RequestParam(defaultValue = "10") int size + ) { + return ApiResponse.success("게시글 검색 성공(LIKE)", postLikeSearchService.search(keyword, size)); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/document/PostDocument.java b/src/main/java/com/kbt/amumal/domain/search/document/PostDocument.java new file mode 100644 index 0000000..ffe63be --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/document/PostDocument.java @@ -0,0 +1,39 @@ +package com.kbt.amumal.domain.search.document; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Mapping; +import org.springframework.data.elasticsearch.annotations.Setting; +import org.springframework.data.elasticsearch.annotations.WriteTypeHint; + +import java.time.LocalDateTime; + +// 제목·내용·댓글 내용을 하나의 문서로 색인한다 (유저 이름 등은 검색 범위에서 제외) +// post-mappings.json이 dynamic: strict라서, Spring이 기본으로 넣는 _class 필드를 꺼야 색인이 거부되지 않는다 +@Document(indexName = "posts-v1", createIndex = false, writeTypeHint = WriteTypeHint.FALSE) +@Setting(settingPath = "elasticsearch/post-settings.json") // nori 토크나이저·필터 정의 (korean_nori analyzer) +@Mapping(mappingPath = "elasticsearch/post-mappings.json") // title/content/comments가 korean_nori analyzer를 쓰도록 매핑 +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostDocument { + + @Id + private String id; // postId를 문자열로 변환한 값 (ES 문서 ID) + + private Integer postId; + + private String title; + + private String content; + + // 게시글에 달린 모든 댓글 내용을 공백으로 이어붙인 값 (댓글 CUD 시마다 갱신) + private String comments; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/kbt/amumal/domain/search/dto/PostSearchResDTO.java b/src/main/java/com/kbt/amumal/domain/search/dto/PostSearchResDTO.java new file mode 100644 index 0000000..58943a4 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/dto/PostSearchResDTO.java @@ -0,0 +1,18 @@ +package com.kbt.amumal.domain.search.dto; + +import com.kbt.amumal.domain.post.dto.PostResDTO; + +import java.util.List; + +public class PostSearchResDTO { + + public record response( + List posts, + pagination pagination + ) {} + + public record pagination( + String nextCursor, + boolean hasNext + ) {} +} diff --git a/src/main/java/com/kbt/amumal/domain/search/dto/SearchSortType.java b/src/main/java/com/kbt/amumal/domain/search/dto/SearchSortType.java new file mode 100644 index 0000000..a00f597 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/dto/SearchSortType.java @@ -0,0 +1,6 @@ +package com.kbt.amumal.domain.search.dto; + +public enum SearchSortType { + ACCURACY, + LATEST +} diff --git a/src/main/java/com/kbt/amumal/domain/search/entity/SearchIndexDirty.java b/src/main/java/com/kbt/amumal/domain/search/entity/SearchIndexDirty.java new file mode 100644 index 0000000..02a9c6d --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/entity/SearchIndexDirty.java @@ -0,0 +1,56 @@ +package com.kbt.amumal.domain.search.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * MySQL 원본 데이터보다 검색 문서가 오래된 게시글을 기록 + * postId를 PK로 두어 같은 게시글의 연속 변경을 하나의 재색인 작업 처리 + */ +@Entity +@Table( + name = "search_index_dirty", + indexes = { + @Index(name = "idx_search_dirty_requested", columnList = "requested_at"), + @Index(name = "idx_search_dirty_retry", columnList = "next_retry_at, requested_at") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class SearchIndexDirty { + + @Id + @Column(name = "post_id", nullable = false) + private Integer postId; + + // 배치 중 변경사항이 작동하면 version이 올라가는데, 기존 변경사항이 완료되고 삭제될 때 삭제를 무시하게 함 → 다음 배치에 반영됨 + @Column(nullable = false) + private long version; + + // 오래 기다린것부터 처리하기 위한 생성 시각 + @Column(name = "requested_at", nullable = false, columnDefinition = "TIMESTAMP(6)") + private LocalDateTime requestedAt; + + // 실패할 때마다 +1, 지수 백오프 계산에 사용 + @Column(name = "retry_count", nullable = false) + private int retryCount; + + // ① 실패 후 재시도까지 기다리는 시각 + // ② claim 시 "지금부터 10분간 내가 처리 중"이라는 리스 만료 시각. + @Column(name = "next_retry_at", columnDefinition = "TIMESTAMP(6)") + private LocalDateTime nextRetryAt; + + // 마지막 실패 원인 (운영 디버깅용, 컬럼 길이에 맞춰 1000자로 자름) + @Column(name = "last_error", length = 1000) + private String lastError; +} diff --git a/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepository.java b/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepository.java new file mode 100644 index 0000000..da9266f --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepository.java @@ -0,0 +1,60 @@ +package com.kbt.amumal.domain.search.repository; + +import com.kbt.amumal.domain.search.entity.SearchIndexDirty; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import jakarta.persistence.LockModeType; + +public interface SearchIndexDirtyRepository extends JpaRepository, SearchIndexDirtyRepositoryCustom { + + // postId가 PK라 같은 글이 연달아 바뀌어도 row가 하나로 합쳐진다 (UPSERT). + // 이미 대기 중인 row가 있으면 version만 +1 하고 재시도 상태(retry_count/next_retry_at/last_error)를 + // 전부 초기화한다 — "재시도 대기 중이던 실패한 색인"도 새 변경이 오면 즉시 다시 처리 대상이 되도록. + @Modifying(flushAutomatically = true) + @Query(value = """ + INSERT INTO search_index_dirty + (post_id, version, requested_at, retry_count, next_retry_at, last_error) + VALUES + (:postId, 1, CURRENT_TIMESTAMP(6), 0, NULL, NULL) + ON DUPLICATE KEY UPDATE + version = version + 1, + requested_at = CURRENT_TIMESTAMP(6), + retry_count = 0, + next_retry_at = NULL, + last_error = NULL + """, nativeQuery = true) + void markDirty(@Param("postId") Integer postId); + + // next_retry_at이 비어있거나(=한 번도 안 건드림) 지났으면(=재시도 시간 됐거나 리스 만료됨) 대상. + // 비관적 락으로 잠가서, 같은 순간에 여러 파드가 findProcessable을 돌려도 서로 다른 row를 집어가게 한다. + @Query(""" + SELECT d FROM SearchIndexDirty d + WHERE d.nextRetryAt IS NULL OR d.nextRetryAt <= :now + ORDER BY d.requestedAt ASC + """) + @Lock(LockModeType.PESSIMISTIC_WRITE) + List findProcessable(@Param("now") LocalDateTime now, Pageable pageable); + + // next_retry_at을 "지금부터 N분 뒤"로 밀어서, 그 시간 동안은 findProcessable에 다시 안 걸리게 선점(리스)한다. + // 여기선 version 조건이 필요 없다 - findProcessable의 PESSIMISTIC_WRITE 락을 같은 트랜잭션에서 그대로 들고 + // 있는 상태라, 이 postId들의 row는 이 트랜잭션이 끝나기 전까지 다른 트랜잭션이 절대 못 바꾼다. + // 그래서 postId만으로 안전하게 한 번의 UPDATE로 묶을 수 있다 (건별 왕복 대신). + @Modifying + @Query(""" + UPDATE SearchIndexDirty d + SET d.nextRetryAt = :claimedUntil + WHERE d.postId IN :postIds + """) + int markClaimed( + @Param("postIds") List postIds, + @Param("claimedUntil") LocalDateTime claimedUntil + ); + +} diff --git a/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryCustom.java b/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryCustom.java new file mode 100644 index 0000000..24f9bdb --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryCustom.java @@ -0,0 +1,21 @@ +package com.kbt.amumal.domain.search.repository; + +import com.kbt.amumal.domain.search.entity.SearchIndexDirty; + +import java.time.LocalDateTime; +import java.util.List; + +public interface SearchIndexDirtyRepositoryCustom { + // complete()가 넘긴 대상들을 (postId, version) 쌍으로 한 번에 지운다. + // 이 시점엔 claim 때의 락이 이미 풀려 있어 row마다 version이 다를 수 있어서, 단순 postId IN으로는 못 묶고 + // JDBC batch(addBatch/executeBatch)로 건별 DELETE를 한 번의 왕복에 모아 보낸다. + void deleteAllIfVersionMatches(List targets); + + // recordFailure를 배치로 묶기 위한 캐리어. row마다 nextRetryAt(자기 retryCount 기준 백오프 계산 결과)이 + // 달라서 SearchIndexDirty 엔티티만으로는 못 묶고, 호출부(SearchDirtyBatchService)가 계산해서 넘겨준다. + record FailureRecord(Integer postId, long version, LocalDateTime nextRetryAt, String message) { + } + + // 여러 실패 대상을 (postId, version)별로 한 번에 기록 - JDBC batch로 건별 왕복 대신 한 번에 전송 + void recordFailures(List failures); +} diff --git a/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryImpl.java b/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryImpl.java new file mode 100644 index 0000000..ee087c0 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.kbt.amumal.domain.search.repository; + +import com.kbt.amumal.domain.search.entity.SearchIndexDirty; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class SearchIndexDirtyRepositoryImpl implements SearchIndexDirtyRepositoryCustom { + + private static final String DELETE_SQL = + "DELETE FROM search_index_dirty WHERE post_id = ? AND version = ?"; + + private static final String RECORD_FAILURE_SQL = """ + UPDATE search_index_dirty + SET retry_count = retry_count + 1, next_retry_at = ?, last_error = ? + WHERE post_id = ? AND version = ? + """; + + // JpaTransactionManager가 관리하는 것과 같은 DataSource라서, 이 배치도 호출부의 트랜잭션에 그대로 참여한다. + private final JdbcTemplate jdbcTemplate; + + @Override + public void deleteAllIfVersionMatches(List targets) { + if (targets.isEmpty()) return; + + jdbcTemplate.batchUpdate(DELETE_SQL, targets, targets.size(), (ps, target) -> { + ps.setInt(1, target.getPostId()); + ps.setLong(2, target.getVersion()); + }); + } + + @Override + public void recordFailures(List failures) { + if (failures.isEmpty()) return; + + jdbcTemplate.batchUpdate(RECORD_FAILURE_SQL, failures, failures.size(), (ps, failure) -> { + ps.setObject(1, failure.nextRetryAt()); + ps.setString(2, failure.message()); + ps.setInt(3, failure.postId()); + ps.setLong(4, failure.version()); + }); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/ElasticsearchPostIndexGateway.java b/src/main/java/com/kbt/amumal/domain/search/service/ElasticsearchPostIndexGateway.java new file mode 100644 index 0000000..c9fbcf4 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/ElasticsearchPostIndexGateway.java @@ -0,0 +1,52 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.search.document.PostDocument; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.IndexOperations; +import org.springframework.data.elasticsearch.core.query.DeleteQuery; +import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; + +// ES 실행하는 함수 +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "search.enabled", havingValue = "true") +public class ElasticsearchPostIndexGateway implements PostSearchIndexGateway { + + private final ElasticsearchOperations operations; + + // 인덱스가 없을 때 새로 만듦 + @Override + public void ensureIndex() { + IndexOperations indexOperations = operations.indexOps(PostDocument.class); + if (!indexOperations.exists()) { + indexOperations.createWithMapping(); + } + } + + // 빈 컬렉션이면 ES에 요청을 안 보냄 + @Override + public void saveAll(Collection documents) { + if (!documents.isEmpty()) { + operations.save(documents); + } + } + + // ids 쿼리로 대상 postId들을 한 번의 요청에 묶어서 삭제 + @Override + public void deleteAll(Collection postIds) { + if (postIds.isEmpty()) return; + + List ids = postIds.stream().map(String::valueOf).toList(); + Query query = NativeQuery.builder() + .withQuery(q -> q.ids(idsQuery -> idsQuery.values(ids))) + .build(); + operations.delete(DeleteQuery.builder(query).build(), PostDocument.class); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/InitialPostIndexService.java b/src/main/java/com/kbt/amumal/domain/search/service/InitialPostIndexService.java new file mode 100644 index 0000000..077edd0 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/InitialPostIndexService.java @@ -0,0 +1,64 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.comment.entity.Comment; +import com.kbt.amumal.domain.comment.repository.CommentRepository; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.document.PostDocument; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +// 기존 게시글 색인 +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "search.initial-index.enabled", havingValue = "true") +public class InitialPostIndexService { + + private static final int BATCH_SIZE = 500; + + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostDocumentFactory documentFactory; + private final PostSearchIndexGateway indexGateway; + + // 앱 기동 시(search.initial-index.enabled=true일 때만) MySQL의 게시글 전체를 커서로 훑어 ES에 일괄 색인 + @EventListener(ApplicationReadyEvent.class) + public void indexExistingPosts() { + indexGateway.ensureIndex(); + Integer cursor = 0; + int indexedCount = 0; + + while (true) { + List posts = postRepository.findPostsWithCursor(cursor, BATCH_SIZE); + if (posts.isEmpty()) break; + + List postIds = posts.stream().map(Post::getPostId).toList(); + Map> commentsByPostId = commentRepository + .findByPostIdInAndDeletedAtIsNullOrderByPostIdAscCreatedAtAsc(postIds) + .stream() + .collect(Collectors.groupingBy(Comment::getPostId)); + + List documents = posts.stream() + .map(post -> documentFactory.create( + post, + commentsByPostId.getOrDefault(post.getPostId(), List.of()) + )) + .toList(); + indexGateway.saveAll(documents); + + indexedCount += documents.size(); + cursor = posts.get(posts.size() - 1).getPostId(); + } + + log.info("기존 게시글 전체 색인 완료 - {}개", indexedCount); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/PostDocumentFactory.java b/src/main/java/com/kbt/amumal/domain/search/service/PostDocumentFactory.java new file mode 100644 index 0000000..3180e7e --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/PostDocumentFactory.java @@ -0,0 +1,30 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.comment.entity.Comment; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.search.document.PostDocument; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +// 게시글 당 색인 문서 생성 +@Component +public class PostDocumentFactory { + + // Post 엔티티 + 그 글의 댓글 목록을 받아 ES에 색인할 PostDocument로 변환 (댓글은 내용을 공백으로 이어붙임) + public PostDocument create(Post post, List comments) { + String commentText = comments.stream() + .map(Comment::getContent) + .collect(Collectors.joining(" ")); + + return PostDocument.builder() + .id(post.getPostId().toString()) + .postId(post.getPostId()) + .title(post.getTitle()) + .content(post.getContent()) + .comments(commentText) + .createdAt(post.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/PostLikeSearchService.java b/src/main/java/com/kbt/amumal/domain/search/service/PostLikeSearchService.java new file mode 100644 index 0000000..2cdc2d3 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/PostLikeSearchService.java @@ -0,0 +1,67 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.post.dto.PostResDTO; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.user.dto.UserProjection; +import com.kbt.amumal.domain.user.repository.UserRepository; +import com.kbt.amumal.global.error.CustomException; +import com.kbt.amumal.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +// ES 검색(PostSearchService)과 결과·성능을 비교하기 위한 LIKE 기반 baseline 구현 +@Service +@RequiredArgsConstructor +public class PostLikeSearchService { + + private static final int MIN_SIZE = 1; + private static final int MAX_SIZE = 100; + + private final PostRepository postRepository; + private final UserRepository userRepository; + + // LIKE %keyword% 풀스캔으로 게시글을 찾아 카드 형태로 반환 (ES 없이도 동작하는 baseline 검색) + @Transactional(readOnly = true) + public List search(String keyword, int size) { + if (keyword == null || keyword.isBlank()) + throw new CustomException(ErrorCode.BAD_REQUEST, "검색어를 입력해주세요."); + if (size < MIN_SIZE || size > MAX_SIZE) + throw new CustomException(ErrorCode.BAD_REQUEST, "size는 1에서 100 사이여야 합니다."); + + List posts = postRepository.searchByTitleOrContentLike(keyword.trim(), PageRequest.of(0, size)); + return hydratePostCards(posts); + } + + // 조회된 게시글들의 작성자 정보를 한 번에 붙여 응답용 카드로 조립 + private List hydratePostCards(List posts) { + List authorIds = posts.stream().map(Post::getUserId).distinct().toList(); + Map authorMap = userRepository.findProjectionsByIdIn(authorIds).stream() + .collect(Collectors.toMap(UserProjection::id, Function.identity())); + + return posts.stream() + .map(post -> { + UserProjection user = authorMap.get(post.getUserId()); + return new PostResDTO.PostListItem( + post.getPostId(), + post.getTitle(), + post.getPostImageUrl(), + post.getLikeCount(), + post.getCommentCount(), + post.getViewCount(), + user == null ? null : new PostResDTO.UserInfo( + user.userId(), user.nickname(), user.profileImageUrl() + ), + post.getCreatedAt() + ); + }) + .toList(); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/PostSearchIndexGateway.java b/src/main/java/com/kbt/amumal/domain/search/service/PostSearchIndexGateway.java new file mode 100644 index 0000000..6bd943d --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/PostSearchIndexGateway.java @@ -0,0 +1,15 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.search.document.PostDocument; + +import java.util.Collection; + +// ES 색인 실행 인터페이스 +public interface PostSearchIndexGateway { + // 인덱스가 없으면 매핑/세팅까지 포함해서 새로 만든다 + void ensureIndex(); + // 문서들을 upsert (있으면 갱신, 없으면 생성) + void saveAll(Collection documents); + // 주어진 postId들의 문서를 인덱스에서 삭제 + void deleteAll(Collection postIds); +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/PostSearchService.java b/src/main/java/com/kbt/amumal/domain/search/service/PostSearchService.java new file mode 100644 index 0000000..34b6097 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/PostSearchService.java @@ -0,0 +1,199 @@ +package com.kbt.amumal.domain.search.service; + +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import com.kbt.amumal.domain.post.dto.PostResDTO; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.document.PostDocument; +import com.kbt.amumal.domain.search.dto.PostSearchResDTO; +import com.kbt.amumal.domain.search.dto.SearchSortType; +import com.kbt.amumal.domain.user.dto.UserProjection; +import com.kbt.amumal.domain.user.repository.UserRepository; +import com.kbt.amumal.global.error.CustomException; +import com.kbt.amumal.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.client.elc.NativeQueryBuilder; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "search.enabled", havingValue = "true") +public class PostSearchService { + + private final ElasticsearchOperations operations; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + // 필드별 점수 가중치 (제목 > 본문 > 댓글 순으로 점수를 더 준다) + @Value("${search.boost.title:4.0}") + private float titleBoost; + @Value("${search.boost.content:2.0}") + private float contentBoost; + @Value("${search.boost.comments:1.0}") + private float commentsBoost; + + // 오타 허용(fuzzy) 설정 - nori가 형태소 단위로 쪼갠 토큰을 기준으로 몇 글자까지 다르게 입력해도 매칭시킬지 결정한다 + @Value("${search.fuzziness:AUTO}") // AUTO = 토큰 길이에 따라 자동으로 허용 오차(0~2) 조정 + private String fuzziness; + @Value("${search.fuzzy.max-expansions:25}") // fuzzy 매칭 후보로 몇 개 토큰까지 확장 탐색할지 (많을수록 느려짐) + private int maxExpansions; + @Value("${search.fuzzy.prefix-length:0}") // 앞에서부터 몇 글자는 오타 허용 없이 정확히 일치해야 하는지 + private int prefixLength; + + // 검색 요청의 입구: 입력값 검증 → ES 쿼리 실행 → 다음 페이지 존재 여부·커서 계산 → DB 하이드레이션까지 총괄 + @Transactional(readOnly = true) + public PostSearchResDTO.response search( + String keyword, + String sortValue, + String cursor, + int size + ) { + if (keyword == null || keyword.isBlank()) { + throw new CustomException(ErrorCode.BAD_REQUEST, "검색어를 입력해주세요."); + } + if (size < 1 || size > 100) { + throw new CustomException(ErrorCode.BAD_REQUEST, "size는 1에서 100 사이여야 합니다."); + } + + SearchSortType sortType = parseSortType(sortValue); + NativeQuery query = buildQuery(keyword.trim(), sortType, cursor, size + 1); + SearchHits searchHits = operations.search(query, PostDocument.class); + + List> hits = searchHits.getSearchHits(); + boolean hasNext = hits.size() > size; + List> pageHits = hasNext ? hits.subList(0, size) : hits; + String nextCursor = hasNext && !pageHits.isEmpty() + ? encodeCursor(pageHits.get(pageHits.size() - 1).getSortValues()) + : null; + + return new PostSearchResDTO.response( + hydratePostCards(pageHits), + new PostSearchResDTO.pagination(nextCursor, hasNext) + ); + } + + // nori 분석 + fuzzy가 적용된 multiMatch 쿼리와 정렬·커서 조건을 조립해 ES 네이티브 쿼리를 만든다 + private NativeQuery buildQuery(String keyword, SearchSortType sortType, String cursor, int limit) { + // "^숫자" 문법으로 필드별 가중치를 ES에 전달 (예: "title^4.0") + List fields = List.of( + "title^" + titleBoost, + "content^" + contentBoost, + "comments^" + commentsBoost + ); + + NativeQueryBuilder builder = NativeQuery.builder() + // multiMatch: 입력한 keyword를 nori로 분석한 뒤(색인 때와 동일한 korean_nori analyzer 사용) title/content/comments에서 동시에 검색 + .withQuery(query -> query.multiMatch(multiMatch -> multiMatch + .query(keyword) + .type(TextQueryType.BestFields) // 필드 중 가장 잘 맞은 것의 점수를 기준으로 채택 (합산 아님) + .fields(fields) + .fuzziness(fuzziness) // 여기서 오타 허용(fuzzy)이 실제로 적용됨 + .maxExpansions(maxExpansions) + .prefixLength(prefixLength) + .tieBreaker(0.2) // 채택되지 않은 필드 점수도 20%만큼 더해 동점 상황을 보정 + )) + .withPageable(PageRequest.of(0, limit)); + + if (sortType == SearchSortType.ACCURACY) { + builder.withSort(sort -> sort.score(score -> score.order(SortOrder.Desc))) + .withSort(sort -> sort.field(field -> field.field("createdAt").order(SortOrder.Desc))) + .withSort(sort -> sort.field(field -> field.field("postId").order(SortOrder.Desc))); + } else { + builder.withSort(sort -> sort.field(field -> field.field("createdAt").order(SortOrder.Desc))) + .withSort(sort -> sort.field(field -> field.field("postId").order(SortOrder.Desc))); + } + + if (cursor != null && !cursor.isBlank()) { + builder.withSearchAfter(decodeCursor(cursor)); + } + return builder.build(); + } + + // ES 검색결과(postId 목록)로 MySQL에서 게시글 본문·작성자 정보를 가져와 화면에 필요한 카드 형태로 조립 + private List hydratePostCards(List> hits) { + List orderedIds = hits.stream() + .map(hit -> hit.getContent().getPostId()) + .toList(); + if (orderedIds.isEmpty()) return List.of(); + + Map postMap = postRepository.findAllById(orderedIds).stream() + .filter(post -> post.getDeletedAt() == null) + .collect(Collectors.toMap(Post::getPostId, Function.identity())); + List authorIds = postMap.values().stream() + .map(Post::getUserId) + .distinct() + .toList(); + Map authorMap = userRepository.findProjectionsByIdIn(authorIds).stream() + .collect(Collectors.toMap(UserProjection::id, Function.identity())); + + Map cards = new LinkedHashMap<>(); + orderedIds.forEach(postId -> { + Post post = postMap.get(postId); + if (post == null) return; + UserProjection user = authorMap.get(post.getUserId()); + cards.put(postId, new PostResDTO.PostListItem( + post.getPostId(), + post.getTitle(), + post.getPostImageUrl(), + post.getLikeCount(), + post.getCommentCount(), + post.getViewCount(), + user == null ? null : new PostResDTO.UserInfo( + user.userId(), user.nickname(), user.profileImageUrl() + ), + post.getCreatedAt() + )); + }); + return cards.values().stream().toList(); + } + + // 요청 파라미터 문자열("accuracy"/"latest")을 SearchSortType으로 변환, 잘못된 값이면 400 + private SearchSortType parseSortType(String value) { + try { + return SearchSortType.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new CustomException(ErrorCode.BAD_REQUEST, "sort는 accuracy 또는 latest여야 합니다."); + } + } + + // ES가 준 마지막 히트의 정렬값(sortValues)을 JSON→Base64로 인코딩해 클라이언트에 줄 커서로 만든다 + private String encodeCursor(List sortValues) { + try { + byte[] json = objectMapper.writeValueAsBytes(sortValues); + return Base64.getUrlEncoder().withoutPadding().encodeToString(json); + } catch (Exception exception) { + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "검색 커서 생성에 실패했습니다."); + } + } + + // 클라이언트가 보낸 커서를 Base64→JSON으로 복원해 ES searchAfter에 넘길 정렬값 목록으로 되돌린다 + private List decodeCursor(String cursor) { + try { + byte[] json = Base64.getUrlDecoder().decode(cursor.getBytes(StandardCharsets.UTF_8)); + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception exception) { + throw new CustomException(ErrorCode.BAD_REQUEST, "유효하지 않은 검색 커서입니다."); + } + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/SearchDirtyBatchService.java b/src/main/java/com/kbt/amumal/domain/search/service/SearchDirtyBatchService.java new file mode 100644 index 0000000..333a41f --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/SearchDirtyBatchService.java @@ -0,0 +1,76 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.search.entity.SearchIndexDirty; +import com.kbt.amumal.domain.search.repository.SearchIndexDirtyRepository; +import com.kbt.amumal.domain.search.repository.SearchIndexDirtyRepositoryCustom.FailureRecord; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "search.enabled", havingValue = "true") +public class SearchDirtyBatchService { + + private static final int CLAIM_MINUTES = 10; + + private final SearchIndexDirtyRepository dirtyRepository; + + /** + * 다른 파드가 같은 항목을 처리하지 않도록 짧은 트랜잭션으로 대상을 선점한다. + * claimNextBatch/complete/recordFailure를 각각 REQUIRES_NEW로 분리한 이유: + * 이 셋을 하나의 긴 트랜잭션으로 묶으면, ES 색인(SearchIndexSyncService, DB 트랜잭션 밖의 작업)이 + * 끝날 때까지 select ... for update 락(findProcessable)을 계속 붙들고 있게 되어 다른 파드가 오래 대기한다. + * 선점은 선점 시점에 바로 커밋해서 락을 짧게 끝내고, 완료/실패 기록도 각각 독립적으로 커밋되게 한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List claimNextBatch(int batchSize) { + LocalDateTime now = LocalDateTime.now(); + List targets = dirtyRepository.findProcessable( + now, + PageRequest.of(0, batchSize) + ); + if (!targets.isEmpty()) { + List postIds = targets.stream().map(SearchIndexDirty::getPostId).toList(); + dirtyRepository.markClaimed(postIds, now.plusMinutes(CLAIM_MINUTES)); + } + return targets; + } + + // 색인 반영이 끝난 대상들을 dirty에서 제거 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void complete(List targets) { + dirtyRepository.deleteAllIfVersionMatches(targets); + } + + // 배치 전체가 같은 예외로 실패했을 때, 대상들의 실패를 한 번에 기록하고 다음 시도 시각을 지수 백오프로 예약한다. + // 1회차 60s, 2회차 120s, 3회차 240s ... 6회차부터는 3600s(1시간)로 고정. row마다 자기 retryCount 기준으로 + // 각자 다른 nextRetryAt을 계산해야 해서, 계산은 여기서 하고 실제 UPDATE는 JDBC batch로 한 번에 보낸다. + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordFailure(List targets, RuntimeException exception) { + if (targets.isEmpty()) return; + + String rawMessage = exception.getMessage() == null + ? exception.getClass().getSimpleName() + : exception.getMessage(); + String message = rawMessage.length() > 1000 ? rawMessage.substring(0, 1000) : rawMessage; + + LocalDateTime now = LocalDateTime.now(); + List failures = targets.stream() + .map(target -> { + long delaySeconds = Math.min(3600L, 60L << Math.min(target.getRetryCount(), 5)); + return new FailureRecord( + target.getPostId(), target.getVersion(), now.plusSeconds(delaySeconds), message + ); + }) + .toList(); + + dirtyRepository.recordFailures(failures); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/SearchDirtyService.java b/src/main/java/com/kbt/amumal/domain/search/service/SearchDirtyService.java new file mode 100644 index 0000000..32f41dc --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/SearchDirtyService.java @@ -0,0 +1,17 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.search.repository.SearchIndexDirtyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SearchDirtyService { + + private final SearchIndexDirtyRepository searchIndexDirtyRepository; + + // 게시글·댓글 변경 트랜잭션 안에서 호출해 색인 대상을 유실 없이 기록 + public void markDirty(Integer postId) { + searchIndexDirtyRepository.markDirty(postId); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/SearchIndexInitializer.java b/src/main/java/com/kbt/amumal/domain/search/service/SearchIndexInitializer.java new file mode 100644 index 0000000..b67bfd2 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/SearchIndexInitializer.java @@ -0,0 +1,21 @@ +package com.kbt.amumal.domain.search.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "search.enabled", havingValue = "true") +public class SearchIndexInitializer { + + private final PostSearchIndexGateway indexGateway; + + // 재시작할 때마다 매번 실행되는 안전장치 - 인덱스가 없으면 만든다 + @EventListener(ApplicationReadyEvent.class) + public void ensureIndex() { + indexGateway.ensureIndex(); + } +} diff --git a/src/main/java/com/kbt/amumal/domain/search/service/SearchIndexSyncService.java b/src/main/java/com/kbt/amumal/domain/search/service/SearchIndexSyncService.java new file mode 100644 index 0000000..b61ca58 --- /dev/null +++ b/src/main/java/com/kbt/amumal/domain/search/service/SearchIndexSyncService.java @@ -0,0 +1,104 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.comment.entity.Comment; +import com.kbt.amumal.domain.comment.repository.CommentRepository; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.document.PostDocument; +import com.kbt.amumal.domain.search.entity.SearchIndexDirty; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "search.enabled", havingValue = "true") +public class SearchIndexSyncService { + + private final SearchDirtyBatchService dirtyBatchService; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostDocumentFactory documentFactory; + private final PostSearchIndexGateway indexGateway; + + @Value("${search.sync.batch-size:500}") + private int configuredBatchSize; + + // 설정된 주기마다(기본 5분) 배치 크기만큼 dirty 큐를 처리하는 스케줄 진입점 + @Scheduled(fixedDelayString = "${search.sync.fixed-delay-ms:300000}") + public void synchronizeDirtyPosts() { + synchronizeNextBatch(configuredBatchSize); + } + + // dirty 큐에서 한 배치를 선점해 MySQL 최신 상태로 ES를 갱신/삭제하고, 처리한 건수를 반환 + public int synchronizeNextBatch(int batchSize) { + // 1. dirty 큐에서 배치를 선점 (SearchDirtyBatchService.claimNextBatch 참고 - 리스 방식) + List targets = dirtyBatchService.claimNextBatch(batchSize); + if (targets.isEmpty()) return 0; + + // 2. 대상 postId들의 최신 MySQL 상태를 한 번에 조회 (postId당 쿼리 N번이 아니라 IN 절 1번) + List postIds = targets.stream().map(SearchIndexDirty::getPostId).toList(); + Map postMap = postRepository.findAllById(postIds).stream() + .collect(Collectors.toMap(Post::getPostId, Function.identity())); + + // 삭제된(소프트 딜리트) 글의 댓글까지 굳이 조회할 필요 없으니, 살아있는 글만 골라서 댓글을 불러온다 + List activePostIds = postMap.values().stream() + .filter(post -> post.getDeletedAt() == null) + .map(Post::getPostId) + .toList(); + Map> commentsByPostId = loadComments(activePostIds); + + // 3. dirty였던 이유가 "삭제 처리"일 수도 "삭제됨"일 수도 있어서 분기한다. + // postMap에 없거나(완전 삭제) deletedAt이 찍혀 있으면(소프트 딜리트) → ES 문서를 지워야 할 대상 + // 나머지는 → 최신 내용으로 다시 색인해야 할 대상 + List documents = new ArrayList<>(); + List deletedPostIds = new ArrayList<>(); + for (Integer postId : postIds) { + Post post = postMap.get(postId); + if (post == null || post.getDeletedAt() != null) { + deletedPostIds.add(postId); + } else { + documents.add(documentFactory.create( + post, + commentsByPostId.getOrDefault(postId, List.of()) + )); + } + } + + // 4. ES 반영 성공 시에만 dirty 큐에서 지운다(complete). 하나라도 실패하면 배치 전체를 + // 실패로 간주해 재시도 예약한다 (부분 성공을 가려낼 수 없는 saveAll/deleteAll 특성상, + // 이미 반영된 것도 한 번 더 재시도되는 게 낫다는 판단 - upsert라 중복 반영은 안전함) + try { + indexGateway.saveAll(documents); + indexGateway.deleteAll(deletedPostIds); + dirtyBatchService.complete(targets); + log.info("검색 증분 색인 완료 - 갱신 {}개, 삭제 {}개", documents.size(), deletedPostIds.size()); + } catch (RuntimeException exception) { + dirtyBatchService.recordFailure(targets, exception); + log.error("검색 증분 색인 실패 - 대상 {}개", targets.size(), exception); + } + + return targets.size(); + } + + // 주어진 postId들의 살아있는 댓글을 postId별로 묶어서 반환 (빈 입력이면 조회 스킵) + private Map> loadComments(Collection postIds) { + if (postIds.isEmpty()) return Map.of(); + return commentRepository + .findByPostIdInAndDeletedAtIsNullOrderByPostIdAscCreatedAtAsc(List.copyOf(postIds)) + .stream() + .collect(Collectors.groupingBy(Comment::getPostId)); + } + +} diff --git a/src/main/java/com/kbt/amumal/global/common/RetentionScheduler.java b/src/main/java/com/kbt/amumal/global/common/RetentionScheduler.java index 6da027f..36f5b29 100644 --- a/src/main/java/com/kbt/amumal/global/common/RetentionScheduler.java +++ b/src/main/java/com/kbt/amumal/global/common/RetentionScheduler.java @@ -4,6 +4,7 @@ import com.kbt.amumal.domain.post.entity.Post; import com.kbt.amumal.domain.post.repository.LikeRepository; import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.service.SearchDirtyService; import com.kbt.amumal.domain.user.entity.User; import com.kbt.amumal.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -34,6 +35,7 @@ public class RetentionScheduler { private final CommentRepository commentRepository; private final LikeRepository likeRepository; private final ImageHandler imageHandler; + private final SearchDirtyService searchDirtyService; /** * 소프트 딜리트된 지 30일 이상 된 게시글의 이미지 파일을 디스크에서 삭제한다. @@ -100,6 +102,7 @@ private void hardDeleteUser(User user) { // 유저 게시글 정리: 댓글·좋아요 먼저 삭제 후 게시글 삭제 List userPosts = postRepository.findByUserId(user.getId()); for (Post post : userPosts) { + searchDirtyService.markDirty(post.getPostId()); imageHandler.deleteSafely(post.getPostImageUrl()); commentRepository.deleteByPostId(post.getPostId()); likeRepository.deleteByPostId(post.getPostId()); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 43c6950..bf1eab0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -35,6 +35,25 @@ spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} spring.data.redis.password=${REDIS_PASSWORD:} +# Elasticsearch (nori 커스텀 이미지, 클러스터 내부 통신이라 보안 기능은 비활성화 상태로 운용) +spring.elasticsearch.uris=http://${ES_HOST:localhost}:${ES_PORT:9200} +spring.elasticsearch.username=${ES_USERNAME:} +spring.elasticsearch.password=${ES_PASSWORD:} +search.enabled=${SEARCH_ENABLED:true} +search.sync.fixed-delay-ms=${SEARCH_SYNC_FIXED_DELAY_MS:300000} +search.sync.batch-size=${SEARCH_SYNC_BATCH_SIZE:500} +search.initial-index.enabled=${SEARCH_INITIAL_INDEX_ENABLED:false} + +# 검색 가중치: 주제를 압축하는 제목을 가장 높게 두고 본문·댓글을 보조 신호로 사용한다 +search.boost.title=4.0 +search.boost.content=2.0 +search.boost.comments=1.0 + +# 오타를 허용하는 정도 (nori 토큰 단위 Levenshtein 거리, AUTO = 토큰 길이에 따라 자동 조정) +search.fuzziness=AUTO +search.fuzzy.max-expansions=25 +search.fuzzy.prefix-length=0 + # JSON 직렬화 타임존 spring.jackson.time-zone=Asia/Seoul diff --git a/src/main/resources/elasticsearch/post-mappings.json b/src/main/resources/elasticsearch/post-mappings.json new file mode 100644 index 0000000..dae2299 --- /dev/null +++ b/src/main/resources/elasticsearch/post-mappings.json @@ -0,0 +1,11 @@ +{ + "dynamic": "strict", + "properties": { + "id": { "type": "keyword" }, + "postId": { "type": "integer" }, + "title": { "type": "text", "analyzer": "korean_nori", "search_analyzer": "korean_nori" }, + "content": { "type": "text", "analyzer": "korean_nori", "search_analyzer": "korean_nori" }, + "comments": { "type": "text", "analyzer": "korean_nori", "search_analyzer": "korean_nori" }, + "createdAt": { "type": "date" } + } +} diff --git a/src/main/resources/elasticsearch/post-settings.json b/src/main/resources/elasticsearch/post-settings.json new file mode 100644 index 0000000..61ac059 --- /dev/null +++ b/src/main/resources/elasticsearch/post-settings.json @@ -0,0 +1,36 @@ +{ + "number_of_shards": "1", + "number_of_replicas": "0", + "analysis": { + "tokenizer": { + "nori_mixed_tokenizer": { + "type": "nori_tokenizer", + "decompound_mode": "mixed", + "discard_punctuation": "true" + } + }, + "filter": { + "nori_pos_filter": { + "type": "nori_part_of_speech", + "stoptags": [ + "EP", "EF", "EC", "ETN", "ETM", + "IC", + "JKS", "JKC", "JKG", "JKO", "JKB", "JKV", "JKQ", "JX", "JC", + "MAG", "MAJ", "MM", + "SP", "SSC", "SSO", "SC", "SE", + "XPN", "XSA", "XSN", "XSV", "UNA", "NA", "VSV" + ] + }, + "nori_reading_filter": { + "type": "nori_readingform" + } + }, + "analyzer": { + "korean_nori": { + "type": "custom", + "tokenizer": "nori_mixed_tokenizer", + "filter": ["nori_pos_filter", "nori_reading_filter", "lowercase"] + } + } + } +} diff --git a/src/test/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryTest.java b/src/test/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryTest.java new file mode 100644 index 0000000..84bcaa7 --- /dev/null +++ b/src/test/java/com/kbt/amumal/domain/search/repository/SearchIndexDirtyRepositoryTest.java @@ -0,0 +1,33 @@ +package com.kbt.amumal.domain.search.repository; + +import com.kbt.amumal.domain.search.entity.SearchIndexDirty; +import com.kbt.amumal.global.config.QueryDSLConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QueryDSLConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class SearchIndexDirtyRepositoryTest { + + @Autowired + SearchIndexDirtyRepository repository; + + @Test + void 같은_게시글의_변경은_하나의_Dirty행에_병합한다() { + repository.markDirty(42); + repository.markDirty(42); + + SearchIndexDirty dirty = repository.findById(42).orElseThrow(); + + assertThat(dirty.getVersion()).isEqualTo(2); + assertThat(repository.count()).isEqualTo(1); + } +} diff --git a/src/test/java/com/kbt/amumal/domain/search/service/PostDocumentFactoryTest.java b/src/test/java/com/kbt/amumal/domain/search/service/PostDocumentFactoryTest.java new file mode 100644 index 0000000..a662777 --- /dev/null +++ b/src/test/java/com/kbt/amumal/domain/search/service/PostDocumentFactoryTest.java @@ -0,0 +1,35 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.comment.entity.Comment; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.search.document.PostDocument; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostDocumentFactoryTest { + + private final PostDocumentFactory factory = new PostDocumentFactory(); + + @Test + void 활성_댓글을_공백으로_합쳐_게시글_문서를_만든다() { + Post post = Post.builder() + .postId(42) + .title("오사카 숙소 추천") + .content("난바 근처 숙소를 찾아요") + .userId(1) + .build(); + List comments = List.of( + Comment.builder().commentId(1).postId(42).content("난바역 근처가 좋아요").build(), + Comment.builder().commentId(2).postId(42).content("우메다도 괜찮아요").build() + ); + + PostDocument document = factory.create(post, comments); + + assertThat(document.getId()).isEqualTo("42"); + assertThat(document.getPostId()).isEqualTo(42); + assertThat(document.getComments()).isEqualTo("난바역 근처가 좋아요 우메다도 괜찮아요"); + } +} diff --git a/src/test/java/com/kbt/amumal/domain/search/service/PostSearchPerformanceBenchmarkTest.java b/src/test/java/com/kbt/amumal/domain/search/service/PostSearchPerformanceBenchmarkTest.java new file mode 100644 index 0000000..ff04140 --- /dev/null +++ b/src/test/java/com/kbt/amumal/domain/search/service/PostSearchPerformanceBenchmarkTest.java @@ -0,0 +1,188 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.post.dto.PostResDTO; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.document.PostDocument; +import com.kbt.amumal.domain.search.dto.PostSearchResDTO; +import jakarta.persistence.EntityManagerFactory; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +// LIKE 풀스캔 검색 vs 실제 구현된 ES 검색(PostSearchService)의 쿼리 개수·소요시간을, +// 데이터 규모별로 반복 측정해 ES가 유리해지는 손익분기점을 찾기 위한 벤치마크 +// search.enabled은 integration 프로필 기본값이 false라 이 테스트에서만 true로 켠다 +@Slf4j +@Tag("integration") +@ActiveProfiles("integration") +@SpringBootTest(properties = "search.enabled=true") +class PostSearchPerformanceBenchmarkTest { + + private static final int KEYWORD_HIT_COUNT = 15; + private static final int PAGE_SIZE = 20; + // 형태소 분석/오타 허용 이슈와 무관하게 순수 색인 방식 성능만 비교하기 위한, 다른 글자와 안 섞이는 전용 키워드 + private static final String KEYWORD = "BENCHMARKTOKEN"; + + private static final List summary = new ArrayList<>(); + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostSearchService postSearchService; + + @Autowired + private PostSearchIndexGateway indexGateway; + + @Autowired + private PostDocumentFactory documentFactory; + + @Autowired + private ElasticsearchOperations elasticsearchOperations; + + private List keywordPostIds; + private Statistics statistics; + + @BeforeEach + void setUp() { + statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.setStatisticsEnabled(true); + } + + @AfterEach + void tearDown() { + postRepository.deleteAll(); + // 건별로 지우는 대신 인덱스를 통째로 지운다 (다음 테스트의 ensureIndex가 재생성) + elasticsearchOperations.indexOps(PostDocument.class).delete(); + } + + @AfterAll + static void printSummary() { + log.info("=== LIKE vs ES 규모별 비교 요약 (post 건수 기준) ==="); + log.info(String.format("%10s | %10s(쿼리) | %10s(쿼리) | %s", "postCount", "LIKE ms", "ES ms", "더 빠른 쪽")); + summary.stream() + .sorted(Comparator.comparingInt(Row::postCount)) + .forEach(r -> log.info(String.format("%10d | %6dms(%2d) | %6dms(%2d) | %s", + r.postCount(), r.likeMs(), r.likeQueries(), r.esMs(), r.esQueries(), + r.esMs() < r.likeMs() ? "ES" : "LIKE"))); + } + + // 맨 앞의 1은 ES 클라이언트 콜드스타트(커넥션 풀 초기화 등) 비용을 흡수시키는 웜업용이라 요약 표에서는 제외한다 + private static final int WARMUP_POST_COUNT = 1; + + @ParameterizedTest(name = "post {0}건일 때 LIKE vs ES 비교") + @ValueSource(ints = {WARMUP_POST_COUNT, 100, 500, 1_000, 3_000, 10_000, 30_000, 100_000}) + @DisplayName("키워드 검색 시 데이터 규모별로 LIKE 풀스캔과 ES 검색의 쿼리 개수·소요시간을 비교한다") + void compareLikeAndElasticsearch(int postCount) { + seedData(postCount); + + Result likeResult = measure("1. MySQL LIKE (%keyword%) 풀스캔", this::searchByLike); + Result esResult = measure("2. Elasticsearch 검색 (PostSearchService)", this::searchByElasticsearch); + + // 두 방식 모두 키워드가 포함된 게시글을 빠짐없이 찾아야 한다 (정확성은 동일해야 비교 의미가 있음) + assertThat(likeResult.postIds()).containsExactlyInAnyOrderElementsOf(keywordPostIds); + assertThat(esResult.postIds()).containsExactlyInAnyOrderElementsOf(keywordPostIds); + + if (postCount == WARMUP_POST_COUNT) { + log.info("=== post {}건(웜업, 요약 제외): LIKE {}ms(쿼리 {}개) vs ES {}ms(쿼리 {}개) ===", + postCount, likeResult.elapsedMs(), likeResult.queryCount(), + esResult.elapsedMs(), esResult.queryCount()); + return; + } + + log.info("=== post {}건 비교 결과: LIKE {}ms(쿼리 {}개) vs ES {}ms(쿼리 {}개) ===", + postCount, likeResult.elapsedMs(), likeResult.queryCount(), + esResult.elapsedMs(), esResult.queryCount()); + + summary.add(new Row(postCount, likeResult.elapsedMs(), likeResult.queryCount(), + esResult.elapsedMs(), esResult.queryCount())); + } + + private void seedData(int postCount) { + log.info("더미데이터 세팅 시작: post {}개 (키워드 포함 {}개)", postCount, KEYWORD_HIT_COUNT); + long seedStart = System.nanoTime(); + + List newPosts = IntStream.range(0, postCount) + .mapToObj(i -> Post.builder() + .title("일본 여행 후기 " + i) + .content(i < KEYWORD_HIT_COUNT + ? "이번 여행 정말 좋았어요 " + KEYWORD + " 다음에 또 가고 싶습니다 " + i + : "이번 여행 정말 좋았어요 별다른 특이사항은 없었습니다 " + i) + .userId(1) + .build()) + .toList(); + List posts = postRepository.saveAll(newPosts); + keywordPostIds = posts.stream() + .filter(post -> post.getContent().contains(KEYWORD)) + .map(Post::getPostId) + .toList(); + + // 색인은 실제 증분 동기화(SearchIndexSyncService)를 거치지 않고 직접 색인해 세팅 시간을 아낀다 (읽기 성능만 측정) + indexGateway.ensureIndex(); + List documents = posts.stream() + .map(post -> documentFactory.create(post, List.of())) + .toList(); + indexGateway.saveAll(documents); + elasticsearchOperations.indexOps(PostDocument.class).refresh(); + + long seedMs = (System.nanoTime() - seedStart) / 1_000_000; + log.info("더미데이터 세팅 완료: post {}건, {}ms", posts.size(), seedMs); + } + + private Set searchByLike() { + return postRepository.searchByTitleOrContentLike(KEYWORD, PageRequest.of(0, PAGE_SIZE)).stream() + .map(Post::getPostId) + .collect(Collectors.toSet()); + } + + private Set searchByElasticsearch() { + PostSearchResDTO.response response = postSearchService.search(KEYWORD, "latest", null, PAGE_SIZE); + return response.posts().stream() + .map(PostResDTO.PostListItem::postId) + .collect(Collectors.toSet()); + } + + private Result measure(String label, Supplier> action) { + statistics.clear(); + long start = System.nanoTime(); + Set postIds = action.get(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + long queryCount = statistics.getQueryExecutionCount(); + log.info("[{}] queries={}, time={}ms, hits={}", label, queryCount, elapsedMs, postIds.size()); + + return new Result(postIds, queryCount, elapsedMs); + } + + private record Result(Set postIds, long queryCount, long elapsedMs) { + } + + private record Row(int postCount, long likeMs, long likeQueries, long esMs, long esQueries) { + } +} diff --git a/src/test/java/com/kbt/amumal/domain/search/service/PostSearchServiceTest.java b/src/test/java/com/kbt/amumal/domain/search/service/PostSearchServiceTest.java new file mode 100644 index 0000000..c5ead7a --- /dev/null +++ b/src/test/java/com/kbt/amumal/domain/search/service/PostSearchServiceTest.java @@ -0,0 +1,75 @@ +package com.kbt.amumal.domain.search.service; + +import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType; +import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.document.PostDocument; +import com.kbt.amumal.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.test.util.ReflectionTestUtils; +import tools.jackson.databind.ObjectMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PostSearchServiceTest { + + @Mock ElasticsearchOperations operations; + @Mock PostRepository postRepository; + @Mock UserRepository userRepository; + @Mock SearchHits searchHits; + + private PostSearchService service; + + @BeforeEach + void setUp() { + service = new PostSearchService(operations, postRepository, userRepository, new ObjectMapper()); + ReflectionTestUtils.setField(service, "titleBoost", 4.0f); + ReflectionTestUtils.setField(service, "contentBoost", 2.0f); + ReflectionTestUtils.setField(service, "commentsBoost", 1.0f); + ReflectionTestUtils.setField(service, "fuzziness", "AUTO"); + ReflectionTestUtils.setField(service, "maxExpansions", 25); + ReflectionTestUtils.setField(service, "prefixLength", 0); + when(searchHits.getSearchHits()).thenReturn(java.util.List.of()); + } + + @Test + void 정확도순은_AUTO_fuzzy와_세_개의_정렬_기준을_사용한다() { + when(operations.search(org.mockito.ArgumentMatchers.any(NativeQuery.class), eq(PostDocument.class))) + .thenReturn(searchHits); + + service.search("오사카 숙소", "accuracy", null, 10); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(NativeQuery.class); + verify(operations).search(queryCaptor.capture(), eq(PostDocument.class)); + NativeQuery query = queryCaptor.getValue(); + + assertThat(query.getQuery().multiMatch().fuzziness()).isEqualTo("AUTO"); + assertThat(query.getQuery().multiMatch().type()).isEqualTo(TextQueryType.BestFields); + assertThat(query.getQuery().multiMatch().fields()) + .containsExactly("title^4.0", "content^2.0", "comments^1.0"); + assertThat(query.getSortOptions()).hasSize(3); + } + + @Test + void 최신순은_생성일과_게시글_ID로_정렬한다() { + when(operations.search(org.mockito.ArgumentMatchers.any(NativeQuery.class), eq(PostDocument.class))) + .thenReturn(searchHits); + + service.search("오사카", "latest", null, 10); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(NativeQuery.class); + verify(operations).search(queryCaptor.capture(), eq(PostDocument.class)); + assertThat(queryCaptor.getValue().getSortOptions()).hasSize(2); + } +} diff --git a/src/test/java/com/kbt/amumal/domain/search/service/SearchIndexSyncServiceTest.java b/src/test/java/com/kbt/amumal/domain/search/service/SearchIndexSyncServiceTest.java new file mode 100644 index 0000000..e252d51 --- /dev/null +++ b/src/test/java/com/kbt/amumal/domain/search/service/SearchIndexSyncServiceTest.java @@ -0,0 +1,70 @@ +package com.kbt.amumal.domain.search.service; + +import com.kbt.amumal.domain.comment.entity.Comment; +import com.kbt.amumal.domain.comment.repository.CommentRepository; +import com.kbt.amumal.domain.post.entity.Post; +import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.search.document.PostDocument; +import com.kbt.amumal.domain.search.entity.SearchIndexDirty; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SearchIndexSyncServiceTest { + + @Mock SearchDirtyBatchService dirtyBatchService; + @Mock PostRepository postRepository; + @Mock CommentRepository commentRepository; + @Mock PostSearchIndexGateway indexGateway; + + @Test + void Dirty에_있는_게시글만_문서로_재구성하고_버전으로_제거한다() { + PostDocumentFactory factory = new PostDocumentFactory(); + SearchIndexSyncService service = new SearchIndexSyncService( + dirtyBatchService, postRepository, commentRepository, factory, indexGateway + ); + SearchIndexDirty target = new SearchIndexDirty( + 42, 7, LocalDateTime.now(), 0, null, null + ); + Post post = Post.builder() + .postId(42) + .title("오사카 숙소") + .content("내용") + .userId(1) + .build(); + Comment comment = Comment.builder() + .commentId(1) + .postId(42) + .content("난바역 근처 추천") + .build(); + + when(dirtyBatchService.claimNextBatch(500)).thenReturn(List.of(target)); + when(postRepository.findAllById(List.of(42))).thenReturn(List.of(post)); + when(commentRepository.findByPostIdInAndDeletedAtIsNullOrderByPostIdAscCreatedAtAsc(List.of(42))) + .thenReturn(List.of(comment)); + + int processed = service.synchronizeNextBatch(500); + + assertThat(processed).isEqualTo(1); + @SuppressWarnings("unchecked") + ArgumentCaptor> documents = ArgumentCaptor.forClass(Collection.class); + verify(indexGateway).saveAll(documents.capture()); + assertThat(documents.getValue()).singleElement() + .extracting(PostDocument::getComments) + .isEqualTo("난바역 근처 추천"); + verify(dirtyBatchService).complete(List.of(target)); + verify(indexGateway).deleteAll(eq(List.of())); + } +} diff --git a/src/test/resources/application-integration.properties b/src/test/resources/application-integration.properties index 10ec6c6..d965053 100644 --- a/src/test/resources/application-integration.properties +++ b/src/test/resources/application-integration.properties @@ -1,4 +1,4 @@ -spring.datasource.url=jdbc:mysql://${DB_HOST:127.0.0.1}:3306/${DB_NAME:amumal_test}?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 +spring.datasource.url=jdbc:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:3306}/${DB_NAME:amumal_test}?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 spring.datasource.username=${DB_USERNAME:testuser} spring.datasource.password=${DB_PASSWORD:testpass} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver @@ -26,6 +26,7 @@ spring.data.redis.password=${REDIS_PASSWORD:} server.url=${SERVER_URL:http://localhost} logging.file.name=build/integration-test.log +search.enabled=false # 테스트를 위한 설정 # 쿼리 개수/소요시간 측정용 (Hibernate Statistics) diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 862db38..a7d380a 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -22,3 +22,4 @@ jwt.secret=c2VjcmV0a2V5c2VjcmV0a2V5c2VjcmV0a2V5c2VjcmV0a2V5 server.url=http://localhost logging.file.name=build/test.log +search.enabled=false