diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index a5311e2b..4490a1c6 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -5,14 +5,94 @@ on: push: branches: [ "dev" ] -permissions: - contents: read - jobs: deploy: - runs-on: self-hosted + runs-on: ubuntu-latest + steps: + - name: Github Actions 호스트 IP 가져오기 + id: ip + uses: haythem/public-ip@bdddd92c198b0955f0b494a8ebeac529754262ff + + - name: AWS 로그인 + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_DEFAULT_REGION }} + + - name: IP 허용 + run: | + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECURITY_GROUP_ID }} --protocol "tcp" --port "${{ secrets.EC2_PORT }}" --cidr "${{ steps.ip.outputs.ipv4 }}/32" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} + + - name: 저장소 Checkout + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 + + - name: 자바 17 셋업 + uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 + with: + java-version: '17' + cache: 'gradle' + distribution: 'corretto' + + - name: 설정 파일 추가 + run: | + cd ./src/main/resources/ + + cat < application-dev.yml + ${{ secrets.APPLICATION_DEV_YML }} + EOF + + cat < application-oauth.yml + ${{ secrets.APPLICATION_OAUTH_YML }} + EOF + + cat < application-storage.yml + ${{ secrets.APPLICATION_STORAGE_YML }} + EOF + + - name: 디렉터리 이동 + run: cd /home/runner/work/ListyWave-back/ListyWave-back/ + + - name: Gradle 셋업, 빌드, 캐싱 + uses: burrunan/gradle-cache-action@3bf23b8dd95e7d2bacf2470132454fe893a178a1 + with: + arguments: bootJar + + - name: 도커 이미지 빌드 + run: docker build -t ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.DEV_TAG }} ./ + + - name: 도커 허브에 로그인 + uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: 도커 허브에 Push + run: docker push ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.DEV_TAG }} + + - name: 인스턴스 접속 및 배포 스크립트 실행 + uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.DEV_EC2_PRIVATE_KEY }} + script: | + docker stop "${{ secrets.CONTAINER_NAME }}" + docker rm -f "${{ secrets.CONTAINER_NAME }}" + docker rmi "${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.DEV_TAG }}" + docker pull "${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.DEV_TAG }}" + docker run -d -p 8080:8080 -p ${{ secrets.METRIC_PORT }}:${{ secrets.METRIC_PORT }} --name "${{ secrets.CONTAINER_NAME }}" -e "SPRING_PROFILES_ACTIVE=dev,oauth,storage" --network=monitoring_default "${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.DEV_TAG }}" + + - name: IP 제거 + if: ${{ always() }} + run: | + aws ec2 revoke-security-group-ingress --group-id "${{ secrets.AWS_SECURITY_GROUP_ID }}" --protocol "tcp" --port "${{ secrets.EC2_PORT }}" --cidr "${{ steps.ip.outputs.ipv4 }}/32" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} - steps: - - name: Run Deploy script - working-directory: /home/ubuntu - run: sudo ./listywave.sh deploy diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 7d0bd816..c1566662 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -22,7 +22,7 @@ jobs: - name: IP 허용 run: | - aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECURITY_GROUP_ID }} --protocol "tcp" --port "${{ secrets.PROD_EC2_PORT }}" --cidr "${{ steps.ip.outputs.ipv4 }}/32" + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECURITY_GROUP_ID }} --protocol "tcp" --port "${{ secrets.EC2_PORT }}" --cidr "${{ steps.ip.outputs.ipv4 }}/32" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -54,20 +54,13 @@ jobs: ${{ secrets.APPLICATION_STORAGE_YML }} EOF - - name: Gradle 캐싱 - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + - name: 디렉터리 이동 + run: cd /home/runner/work/ListyWave-back/ListyWave-back/ - - name: 애플리케이션 빌드 - run: | - cd /home/runner/work/ListyWave-back/ListyWave-back/ - ./gradlew bootJar + - name: Gradle 셋업, 빌드, 캐싱 + uses: burrunan/gradle-cache-action@3bf23b8dd95e7d2bacf2470132454fe893a178a1 + with: + arguments: bootJar - name: 도커 이미지 빌드 run: docker build -t ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.PROD_TAG }} ./ @@ -85,19 +78,19 @@ jobs: uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 with: host: ${{ secrets.PROD_EC2_HOST }} - username: ${{ secrets.PROD_EC2_USERNAME }} + username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.PROD_EC2_PRIVATE_KEY }} script: | - docker stop "${{ secrets.PROD_CONTAINER_NAME }}" - docker rm -f "${{ secrets.PROD_CONTAINER_NAME }}" + docker stop "${{ secrets.CONTAINER_NAME }}" + docker rm -f "${{ secrets.CONTAINER_NAME }}" docker rmi "${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.PROD_TAG }}" docker pull "${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.PROD_TAG }}" - docker run -d -p 8080:8080 --name "${{ secrets.PROD_CONTAINER_NAME }}" "${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.PROD_TAG }}" + docker run -d -p 8080:8080 -p ${{ secrets.METRIC_PORT }}:${{ secrets.METRIC_PORT }} --name "${{ secrets.PROD_CONTAINER_NAME }}" -e "SPRING_PROFILES_ACTIVE=prod,oauth,storage" --network=monitoring_default "${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.IMAGE_NAME }}:${{ secrets.PROD_TAG }}" - name: IP 제거 if: ${{ always() }} run: | - aws ec2 revoke-security-group-ingress --group-id "${{ secrets.AWS_SECURITY_GROUP_ID }}" --protocol "tcp" --port "${{ secrets.PROD_EC2_PORT }}" --cidr "${{ steps.ip.outputs.ipv4 }}/32" + aws ec2 revoke-security-group-ingress --group-id "${{ secrets.AWS_SECURITY_GROUP_ID }}" --protocol "tcp" --port "${{ secrets.EC2_PORT }}" --cidr "${{ steps.ip.outputs.ipv4 }}/32" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/Dockerfile b/Dockerfile index 3537c1dd..2f106556 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,4 +4,4 @@ COPY ./build/libs/listywave.jar listywave.jar ENV TZ=Asia/Seoul -ENTRYPOINT ["java", "-Dspring.profiles.active=prod,oauth,storage", "-jar", "listywave.jar"] +ENTRYPOINT ["java", "-jar", "listywave.jar"] diff --git a/build.gradle b/build.gradle index b946a5cc..4df9bf86 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-actuator' // jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.4' @@ -50,11 +51,16 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // elasticsearch + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' + // RestAssured testImplementation 'io.rest-assured:rest-assured' // for Remove "warning: unknown enum constant When.MAYBE" implementation 'com.google.code.findbugs:jsr305:3.0.2' + + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/docker-es/.dockerignore b/docker-es/.dockerignore new file mode 100644 index 00000000..e873dbbd --- /dev/null +++ b/docker-es/.dockerignore @@ -0,0 +1,2 @@ +# Ignore OS artifacts +**/.DS_Store diff --git a/docker-es/.env b/docker-es/.env new file mode 100644 index 00000000..7c3b4833 --- /dev/null +++ b/docker-es/.env @@ -0,0 +1 @@ +ELK_VERSION=7.16.2 diff --git a/docker-es/.gitattributes b/docker-es/.gitattributes new file mode 100644 index 00000000..a3002918 --- /dev/null +++ b/docker-es/.gitattributes @@ -0,0 +1,2 @@ +# Declare files that will always have LF line endings on checkout. +*.sh text eol=lf diff --git a/docker-es/LICENSE b/docker-es/LICENSE new file mode 100644 index 00000000..0dbd69f8 --- /dev/null +++ b/docker-es/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Anthony Lapenna + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker-es/docker-compose.yml b/docker-es/docker-compose.yml new file mode 100644 index 00000000..c05f5248 --- /dev/null +++ b/docker-es/docker-compose.yml @@ -0,0 +1,89 @@ +version: '3.2' + +services: + elasticsearch: + build: + context: elasticsearch/ # elasticsearch 전용 Dockerfile을 가져와 빌드 + args: + ELK_VERSION: $ELK_VERSION + volumes: + - type: bind + source: ./elasticsearch/config/elasticsearch.yml + target: /usr/share/elasticsearch/config/elasticsearch.yml + read_only: true + - type: volume + source: elasticsearch + target: /usr/share/elasticsearch/data + ports: + - "9200:9200" + - "9300:9300" + environment: + ES_JAVA_OPTS: "-Xmx256m -Xms256m" + # ELASTIC_PASSWORD: elastic + # Use single node discovery in order to disable production mode and avoid bootstrap checks. + # see: https://www.elastic.co/guide/en/elasticsearch/reference/current/bootstrap-checks.html + discovery.type: single-node + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - elk + + logstash: + build: + context: logstash/ + args: + ELK_VERSION: $ELK_VERSION + volumes: + - type: bind + source: ./logstash/config/logstash.yml + target: /usr/share/logstash/config/logstash.yml + read_only: true + - type: bind + source: ./logstash/config/pipelines.yml # 커스텀 pipeline을 사용할 수 있게 함 + target: /usr/share/logstash/config/pipelines.yml + read_only: true + - type: bind + source: ./logstash/pipeline # 커스텀 pipeline을 바인드 + target: /usr/share/logstash/pipeline + read_only: true + - type: bind + source: ./logstash/mysql-connector-j-8.0.33.jar # mysql을 연결할 수 있도록 커넥터 바인드 + target: /usr/share/logstash/logstash-core/lib/jars/mysql-connector-j-8.0.33.jar + ports: + - "5044:5044" + - "9600:9600" + - "9900:9900" + environment: + LS_JAVA_OPTS: "-Xmx256m -Xms256m" + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - elk + depends_on: + - elasticsearch + + kibana: + build: + context: kibana/ + args: + ELK_VERSION: $ELK_VERSION + volumes: + - type: bind + source: ./kibana/config/kibana.yml + target: /usr/share/kibana/config/kibana.yml + read_only: true + ports: + - "5601:5601" + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - elk + depends_on: + - elasticsearch + +networks: + elk: + driver: bridge + +volumes: + elasticsearch: diff --git a/docker-es/elasticsearch/Dockerfile b/docker-es/elasticsearch/Dockerfile new file mode 100644 index 00000000..b763f0a3 --- /dev/null +++ b/docker-es/elasticsearch/Dockerfile @@ -0,0 +1,8 @@ +ARG ELK_VERSION + +# https://www.docker.elastic.co/ +FROM docker.elastic.co/elasticsearch/elasticsearch:${ELK_VERSION} +RUN elasticsearch-plugin install analysis-nori +RUN bin/elasticsearch-plugin install https://github.com/netcrazy/elasticsearch-jaso-analyzer/releases/download/v7.16.2/jaso-analyzer-plugin-7.16.2-plugin.zip +# Add your elasticsearch plugins setup here +# Example: RUN elasticsearch-plugin install analysis-icu diff --git a/docker-es/elasticsearch/config/elasticsearch.yml b/docker-es/elasticsearch/config/elasticsearch.yml new file mode 100644 index 00000000..f7afa37c --- /dev/null +++ b/docker-es/elasticsearch/config/elasticsearch.yml @@ -0,0 +1,13 @@ +--- +## Default Elasticsearch configuration from Elasticsearch base image. +## https://github.com/elastic/elasticsearch/blob/master/distribution/docker/src/docker/config/elasticsearch.yml +# +cluster.name: "docker-cluster" +network.host: 0.0.0.0 + +## X-Pack settings +## see https://www.elastic.co/guide/en/elasticsearch/reference/current/setup-xpack.html +# +#xpack.license.self_generated.type: basic +#xpack.security.enabled: true +#xpack.monitoring.collection.enabled: true diff --git a/docker-es/kibana/Dockerfile b/docker-es/kibana/Dockerfile new file mode 100644 index 00000000..2fb3659b --- /dev/null +++ b/docker-es/kibana/Dockerfile @@ -0,0 +1,7 @@ +ARG ELK_VERSION + +# https://www.docker.elastic.co/ +FROM docker.elastic.co/kibana/kibana:${ELK_VERSION} + +# Add your kibana plugins setup here +# Example: RUN kibana-plugin install diff --git a/docker-es/kibana/config/kibana.yml b/docker-es/kibana/config/kibana.yml new file mode 100644 index 00000000..9d325acf --- /dev/null +++ b/docker-es/kibana/config/kibana.yml @@ -0,0 +1,13 @@ +--- +## Default Kibana configuration from Kibana base image. +## https://github.com/elastic/kibana/blob/master/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts +# +server.name: kibana +server.host: 0.0.0.0 +elasticsearch.hosts: [ "http://host.docker.internal:9200" ] +monitoring.ui.container.elasticsearch.enabled: true + +## X-Pack security credentials +# +#elasticsearch.username: elastic +#elasticsearch.password: elastic diff --git a/docker-es/logstash/Dockerfile b/docker-es/logstash/Dockerfile new file mode 100644 index 00000000..6a444e7b --- /dev/null +++ b/docker-es/logstash/Dockerfile @@ -0,0 +1,7 @@ +ARG ELK_VERSION + +# https://www.docker.elastic.co/ +FROM docker.elastic.co/logstash/logstash:${ELK_VERSION} + +# Add your logstash plugins setup here +# Example: RUN logstash-plugin install logstash-filter-json diff --git a/docker-es/logstash/config/logstash.yml b/docker-es/logstash/config/logstash.yml new file mode 100644 index 00000000..67e66e6a --- /dev/null +++ b/docker-es/logstash/config/logstash.yml @@ -0,0 +1,12 @@ +--- +## Default Logstash configuration from Logstash base image. +## https://github.com/elastic/logstash/blob/master/docker/data/logstash/config/logstash-full.yml +# +http.host: "0.0.0.0" +#xpack.monitoring.elasticsearch.hosts: [ "http://elasticsearch:9200" ] + +## X-Pack security credentials +# +#xpack.monitoring.enabled: true +#xpack.monitoring.elasticsearch.username: elastic +#xpack.monitoring.elasticsearch.password: elastic diff --git a/docker-es/logstash/config/pipelines.yml b/docker-es/logstash/config/pipelines.yml new file mode 100644 index 00000000..e50ef729 --- /dev/null +++ b/docker-es/logstash/config/pipelines.yml @@ -0,0 +1,2 @@ +- pipeline.id: pjs_logstash + path.config: "/usr/share/logstash/pipeline/logstash.conf" diff --git a/docker-es/logstash/mysql-connector-j-8.0.33.jar b/docker-es/logstash/mysql-connector-j-8.0.33.jar new file mode 100644 index 00000000..3f741f59 Binary files /dev/null and b/docker-es/logstash/mysql-connector-j-8.0.33.jar differ diff --git a/docker-es/logstash/pipeline/logstash.conf b/docker-es/logstash/pipeline/logstash.conf new file mode 100644 index 00000000..3932fd17 --- /dev/null +++ b/docker-es/logstash/pipeline/logstash.conf @@ -0,0 +1,43 @@ +input { + http { + port => 9900 + tags => [ "web" ] + } + jdbc { + jdbc_validate_connection => true + jdbc_driver_library => "/usr/share/logstash/logstash-core/lib/jars/mysql-connector-j-8.0.33.jar" + jdbc_driver_class => "com.mysql.cj.jdbc.Driver" + jdbc_connection_string => "${JDBC_CONNECTION_STRING}" + jdbc_user => "${JDBC_USER}" + jdbc_password => "${JDBC_PASSWORD}" + jdbc_paging_enabled => true + tracking_column => "unix_ts_in_secs" + use_column_value => true + tracking_column_type => "numeric" + schedule => "*/5 * * * * *" + statement => "SELECT *, UNIX_TIMESTAMP(updated_date) AS unix_ts_in_secs FROM users WHERE (UNIX_TIMESTAMP(updated_date) > :sql_last_value AND updated_date < NOW()) ORDER BY updated_date ASC" + tags => [ "mysql" ] + } +} + +filter { + if "mysql" in [tags]{ + mutate { + copy => { "id" => "[@metadata][_id]"} + remove_field => ["id", "@version", "unix_ts_in_secs"] + } + } +} + +output { + if "mysql" in [tags]{ + elasticsearch { + index => "users_sync_idx_v1" + document_id => "%{[@metadata][_id]}" + hosts => "host.docker.internal:9200" +# user => "elastic" +# password => "elastic" + ecs_compatibility => disabled # Logstash가 Elasticsearch에 데이터를 전송할 때 ECS 호환성을 끄는 것 + } + } +} diff --git a/src/main/java/com/listywave/common/config/ElasticSearchConfig.java b/src/main/java/com/listywave/common/config/ElasticSearchConfig.java new file mode 100644 index 00000000..7670fefa --- /dev/null +++ b/src/main/java/com/listywave/common/config/ElasticSearchConfig.java @@ -0,0 +1,34 @@ +package com.listywave.common.config; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import org.apache.http.HttpHost; +import org.elasticsearch.client.RestClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; + +@Configuration +@EnableElasticsearchRepositories +public class ElasticSearchConfig { + + @Value("${spring.elasticsearch.rest.uris}") + private String server; + + @Bean + public RestClient restClient() { + return RestClient.builder(HttpHost.create(server)).build(); + } + + @Bean + public RestClientTransport elasticsearchTransport(RestClient restClient) { + return new RestClientTransport(restClient, new JacksonJsonpMapper()); + } + + @Bean + public ElasticsearchClient elasticsearchClient(RestClientTransport transport) { + return new ElasticsearchClient(transport); + } +} diff --git a/src/main/java/com/listywave/common/exception/ErrorCode.java b/src/main/java/com/listywave/common/exception/ErrorCode.java index 3bc56f60..999ab79f 100644 --- a/src/main/java/com/listywave/common/exception/ErrorCode.java +++ b/src/main/java/com/listywave/common/exception/ErrorCode.java @@ -27,6 +27,7 @@ public enum ErrorCode { METHOD_ARGUMENT_TYPE_MISMATCH(BAD_REQUEST, "요청 한 값 타입이 잘못되어 binding에 실패하였습니다."), RESOURCE_NOT_FOUND(NOT_FOUND, "대상이 존재하지 않습니다."), RESOURCES_EMPTY(NOT_FOUND, "해당 대상들이 존재하지 않습니다."), + ELASTICSEARCH_REQUEST_FAILED(BAD_REQUEST, "Elasticsearch 검색 요청에 실패했습니다."), // Validation NICKNAME_CONTAINS_WHITESPACE_EXCEPTION(BAD_REQUEST, "닉네임의 처음과 마지막에 공백이 존재할 수 없습니다."), diff --git a/src/main/java/com/listywave/user/application/domain/UserDocument.java b/src/main/java/com/listywave/user/application/domain/UserDocument.java new file mode 100644 index 00000000..d956c710 --- /dev/null +++ b/src/main/java/com/listywave/user/application/domain/UserDocument.java @@ -0,0 +1,77 @@ +package com.listywave.user.application.domain; + +import static lombok.AccessLevel.PROTECTED; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.DateFormat; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; +import org.springframework.data.elasticsearch.annotations.Mapping; +import org.springframework.data.elasticsearch.annotations.Setting; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +@Document(indexName = "users_sync_idx") +@Setting(settingPath = "elastic/user/es-setting.json") +@Mapping(mappingPath = "elastic/user/es-mapping.json") +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserDocument { + + @Id + @Field(type = FieldType.Keyword) + private Long id; + + @Field(name = "nickname", type = FieldType.Text) + private String nickname; + + @Field(name = "is_delete", type = FieldType.Boolean) + private boolean isDelete; + + @Field(name = "background_image_url", type = FieldType.Text) + private String backgroundImageUrl; + + @JsonProperty("profile_image_url") + @Field(name = "profile_image_url", type = FieldType.Text) + private String profileImageUrl; + + @Field(name = "description", type = FieldType.Text) + private String description; + + @Field(name = "follower_count", type = FieldType.Long) + private Long followerCount; + + @Field(name = "following_count", type = FieldType.Long) + private Long followingCount; + + @Field(name = "all_private", type = FieldType.Boolean) + private boolean allPrivate; + + @Field(name = "oauth_id", type = FieldType.Long) + private Long oauthId; + + @Field(name = "oauth_email", type = FieldType.Text) + private String oauthEmail; + + @Field(name = "kakao_access_token", type = FieldType.Text) + private String kakaoAccessToken; + + @Field(name = "updated_date", type = FieldType.Date, format = DateFormat.date_time) + private LocalDateTime updatedDate; + + @Field(name = "created_date", type = FieldType.Date, format = DateFormat.date_time) + private LocalDateTime createdDate; + + public void setId(Long id) { + this.id = id; + } +} diff --git a/src/main/java/com/listywave/user/application/dto/search/UserElasticSearchResponse.java b/src/main/java/com/listywave/user/application/dto/search/UserElasticSearchResponse.java new file mode 100644 index 00000000..c90f5e82 --- /dev/null +++ b/src/main/java/com/listywave/user/application/dto/search/UserElasticSearchResponse.java @@ -0,0 +1,24 @@ +package com.listywave.user.application.dto.search; + +import java.util.List; +import lombok.Builder; + +@Builder +public record UserElasticSearchResponse( + List users, + Long totalCount, + Boolean hasNext +) { + + public static UserElasticSearchResponse of( + List users, + Long totalCount, + Boolean hasNext + ) { + return UserElasticSearchResponse.builder() + .users(users) + .totalCount(totalCount) + .hasNext(hasNext) + .build(); + } +} diff --git a/src/main/java/com/listywave/user/application/dto/search/UserElasticSearchResult.java b/src/main/java/com/listywave/user/application/dto/search/UserElasticSearchResult.java new file mode 100644 index 00000000..1f22fcdd --- /dev/null +++ b/src/main/java/com/listywave/user/application/dto/search/UserElasticSearchResult.java @@ -0,0 +1,20 @@ +package com.listywave.user.application.dto.search; + +import com.listywave.user.application.domain.UserDocument; +import lombok.Builder; + +@Builder +public record UserElasticSearchResult( + Long id, + String nickname, + String profileImageUrl +) { + + public static UserElasticSearchResult of(UserDocument user) { + return UserElasticSearchResult.builder() + .id(user.getId()) + .nickname(user.getNickname()) + .profileImageUrl(user.getProfileImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/listywave/user/application/service/UserService.java b/src/main/java/com/listywave/user/application/service/UserService.java index 35506173..0f3f5171 100644 --- a/src/main/java/com/listywave/user/application/service/UserService.java +++ b/src/main/java/com/listywave/user/application/service/UserService.java @@ -16,15 +16,18 @@ import com.listywave.user.application.dto.RecommendUsersResponse; import com.listywave.user.application.dto.UserInfoResponse; import com.listywave.user.application.dto.UserProflieUpdateCommand; +import com.listywave.user.application.dto.search.UserElasticSearchResponse; import com.listywave.user.application.dto.search.UserSearchResponse; import com.listywave.user.application.dto.search.UserSearchResult; import com.listywave.user.repository.follow.FollowRepository; import com.listywave.user.repository.user.UserRepository; +import com.listywave.user.repository.user.elastic.UserElasticRepository; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +39,7 @@ public class UserService { private final ListRepository listRepository; private final UserRepository userRepository; private final FollowRepository followRepository; + private final UserElasticRepository userElasticRepository; private final ApplicationEventPublisher applicationEventPublisher; @Transactional(readOnly = true) @@ -178,4 +182,13 @@ public void updateListVisibility(Long loginUserId, Long listId, Boolean beforeIs list.updateVisibility(!beforeIsPublic); } + + @Transactional(readOnly = true) + public UserElasticSearchResponse searchUserByElastic(@Nullable Long loginUserId, String keyword, Pageable pageable) { + if (loginUserId == null) { + return userElasticRepository.findAll(-1L, keyword, pageable); + } + User user = userRepository.getById(loginUserId); + return userElasticRepository.findAll(user.getId(), keyword, pageable); + } } diff --git a/src/main/java/com/listywave/user/presentation/UserController.java b/src/main/java/com/listywave/user/presentation/UserController.java index 711801da..49abbe21 100644 --- a/src/main/java/com/listywave/user/presentation/UserController.java +++ b/src/main/java/com/listywave/user/presentation/UserController.java @@ -6,6 +6,7 @@ import com.listywave.user.application.dto.FollowingsResponse; import com.listywave.user.application.dto.RecommendUsersResponse; import com.listywave.user.application.dto.UserInfoResponse; +import com.listywave.user.application.dto.search.UserElasticSearchResponse; import com.listywave.user.application.dto.search.UserSearchResponse; import com.listywave.user.application.service.UserService; import com.listywave.user.presentation.dto.ListVisibilityUpdateRequest; @@ -129,4 +130,14 @@ ResponseEntity updateListVisibility( userService.updateListVisibility(loginUserId, request.listId(), request.isPublic()); return ResponseEntity.ok().build(); } + + @GetMapping("/v2/users") + ResponseEntity searchUserByElastic( + @OptionalAuth Long loginUserId, + @RequestParam(name = "keyword", defaultValue = "") String keyword, + @PageableDefault(size = 10) Pageable pageable + ) { + UserElasticSearchResponse response = userService.searchUserByElastic(loginUserId, keyword, pageable); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/listywave/user/repository/user/elastic/UserElasticRepository.java b/src/main/java/com/listywave/user/repository/user/elastic/UserElasticRepository.java new file mode 100644 index 00000000..26c77917 --- /dev/null +++ b/src/main/java/com/listywave/user/repository/user/elastic/UserElasticRepository.java @@ -0,0 +1,101 @@ +package com.listywave.user.repository.user.elastic; + +import static com.listywave.common.exception.ErrorCode.ELASTICSEARCH_REQUEST_FAILED; +import static com.listywave.common.util.PaginationUtils.checkEndPage; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.ElasticsearchException; +import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import com.listywave.common.exception.CustomException; +import com.listywave.user.application.domain.UserDocument; +import com.listywave.user.application.dto.search.UserElasticSearchResponse; +import com.listywave.user.application.dto.search.UserElasticSearchResult; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserElasticRepository { + + private static final float NGRAM_BOOST = 2.0f; + private static final float JASO_BOOST = 1.5f; + + private final ElasticsearchClient esClient; + + public UserElasticSearchResponse findAll(Long loginUserId, String keyword, Pageable pageable) { + Query excludeUserIdQuery = buildTermQuery("_id", loginUserId); + Query isNotDeletedQuery = buildTermQuery("is_delete", false); + Query nicknameNgramQuery = buildMatchQuery("nickname.ngram", keyword, NGRAM_BOOST); + Query nicknameJasoQuery = buildMatchQuery("nickname.jaso", keyword, JASO_BOOST); + + SearchRequest request = + buildSearchRequest(pageable, excludeUserIdQuery, isNotDeletedQuery, nicknameNgramQuery, nicknameJasoQuery); + SearchResponse searchResponse = requestSearch(request); + List users = convertSearchResults(searchResponse); + + Long totalCount = searchResponse.hits().total().value(); + Slice result = checkEndPage(pageable, users); + + return UserElasticSearchResponse.of(result.getContent(), totalCount, result.hasNext()); + } + + private Query buildTermQuery(String field, Object value) { + if (value instanceof Boolean) { + return TermQuery.of(term -> term.field(field).value((Boolean) value))._toQuery(); + } + return TermQuery.of(term -> term.field(field).value((Long) value))._toQuery(); + } + + private Query buildMatchQuery(String field, String keyword, float boost) { + return MatchQuery.of(match -> match.field(field).query(keyword).boost(boost))._toQuery(); + } + + private SearchRequest buildSearchRequest( + Pageable pageable, + Query excludeUserIdQuery, + Query isNotDeletedQuery, + Query nicknameNgramQuery, + Query nicknameJasoQuery + ) { + int offset = pageable.getPageNumber() * pageable.getPageSize(); + + return SearchRequest.of(sr -> sr + .index("users_sync_idx") + .query(q -> q.bool( + b -> b.mustNot(excludeUserIdQuery) + .should(s -> s.bool(bb -> bb.must(nicknameNgramQuery, isNotDeletedQuery))) + .should(s -> s.bool(bb -> bb.must(nicknameJasoQuery, isNotDeletedQuery))) + ) + ) + .from(offset) + .size(pageable.getPageSize() + 1) + ); + } + + private SearchResponse requestSearch(SearchRequest request) { + try { + return esClient.search(request, UserDocument.class); + } catch (ElasticsearchException | IOException e) { + throw new CustomException(ELASTICSEARCH_REQUEST_FAILED); + } + } + + private List convertSearchResults(SearchResponse searchResponse) { + return searchResponse.hits().hits().stream() + .map(hit -> { + UserDocument user = hit.source(); + user.setId(Long.valueOf(hit.id())); + return UserElasticSearchResult.of(user); + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e4968f13..134983ff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,6 +4,7 @@ spring: username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver + jpa: hibernate: ddl-auto: create @@ -13,13 +14,19 @@ spring: highlight_sql: true default_batch_fetch_size: 100 defer-datasource-initialization: true + h2: console: enabled: true + sql: init: mode: always + elasticsearch: + rest: + uris: http://localhost + cors: allowedOrigins: @@ -49,3 +56,15 @@ jwt: refresh-token-valid-time-duration: 2 refresh-token-valid-time-unit: HOURS +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always +server: + tomcat: + mbeanregistry: + enabled: true diff --git a/src/main/resources/elastic/user/es-mapping.json b/src/main/resources/elastic/user/es-mapping.json new file mode 100644 index 00000000..88cd6c0c --- /dev/null +++ b/src/main/resources/elastic/user/es-mapping.json @@ -0,0 +1,59 @@ +{ + "properties": { + "id": { + "type": "keyword" + }, + "nickname": { + "type": "text", + "fields": { + "ngram": { + "type": "text", + "analyzer": "my_ngram_analyzer" + }, + "jaso": { + "type": "text", + "analyzer": "suggest_index_analyzer", + "search_analyzer": "suggest_search_analyzer" + } + } + }, + "is_delete": { + "type": "boolean" + }, + "background_image_url": { + "type": "text" + }, + "profile_image_url": { + "type": "text" + }, + "description": { + "type": "text" + }, + "follower_count": { + "type": "long" + }, + "following_count": { + "type": "long" + }, + "all_private": { + "type": "boolean" + }, + "oauth_id": { + "type": "long" + }, + "oauth_email": { + "type": "text" + }, + "kakao_access_token": { + "type": "text" + }, + "updated_date": { + "type": "date", + "format": "uuuu-MM-dd'T'HH:mm:ss.SSSXXX" + }, + "created_date": { + "type": "date", + "format": "uuuu-MM-dd'T'HH:mm:ss.SSSXXX" + } + } +} diff --git a/src/main/resources/elastic/user/es-setting.json b/src/main/resources/elastic/user/es-setting.json new file mode 100644 index 00000000..951c31a4 --- /dev/null +++ b/src/main/resources/elastic/user/es-setting.json @@ -0,0 +1,48 @@ +{ + "index": { + "number_of_replicas": "0", + "max_ngram_diff": 50, + "analysis": { + "filter": { + "suggest_filter": { + "type": "ngram", + "min_gram": 1, + "max_gram": 50 + } + }, + "analyzer": { + "my_ngram_analyzer": { + "tokenizer": "my_ngram_tokenizer" + }, + "suggest_search_analyzer": { + "type": "custom", + "tokenizer": "jaso_search_tokenizer" + }, + "suggest_index_analyzer": { + "type": "custom", + "tokenizer": "jaso_index_tokenizer", + "filter": [ + "suggest_filter" + ] + } + }, + "tokenizer": { + "jaso_search_tokenizer": { + "type": "jaso_tokenizer", + "mistype": true, + "chosung": false + }, + "jaso_index_tokenizer": { + "type": "jaso_tokenizer", + "mistype": true, + "chosung": true + }, + "my_ngram_tokenizer": { + "type": "ngram", + "min_gram": "1", + "max_gram": "10" + } + } + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 6431544b..046358a5 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -17,6 +17,9 @@ spring: sql: init: mode: always + elasticsearch: + rest: + uris: "http://localhost:9200" logging: level: