From a18adf8bdaca81c0f37206e83299680b3c70e1a9 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Mon, 10 Nov 2025 18:49:49 +0900 Subject: [PATCH 01/38] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=88=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(websocket=20=EB=AA=A8=EB=93=88=EC=9A=A9=20application?= =?UTF-8?q?.yml,=20build.gradle,=20dockerfile,=20deploy.yml=20settings.gra?= =?UTF-8?q?dle=20=EC=88=98=EC=A0=95=20=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-websocket.yml | 98 +++++++++++++++++++ .github/workflows/deploy.yml | 2 +- app-child/build.gradle | 1 + .../com/fintory/child/ChildApplication.java | 3 +- settings.gradle | 3 +- websocket/Dockerfile-websocket | 13 +++ .../websocket/WebsocketApplication.java | 20 ++++ .../application-websocket-deploy.yml | 50 ++++++++++ .../resources/application-websocket-local.yml | 50 ++++++++++ .../main/resources/application-websocket.yml | 21 ++++ 10 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy-websocket.yml create mode 100644 websocket/Dockerfile-websocket create mode 100644 websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java create mode 100644 websocket/src/main/resources/application-websocket-deploy.yml create mode 100644 websocket/src/main/resources/application-websocket-local.yml create mode 100644 websocket/src/main/resources/application-websocket.yml diff --git a/.github/workflows/deploy-websocket.yml b/.github/workflows/deploy-websocket.yml new file mode 100644 index 00000000..5b870ae3 --- /dev/null +++ b/.github/workflows/deploy-websocket.yml @@ -0,0 +1,98 @@ +name: Fintory Websocket Module CI/CD +on: + push: + branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + +env: + AWS_REGION: ap-northeast-2 + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Grant permission to gradlew + run: chmod +x ./gradlew + + - name: Cache Gradle + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Build and push websocket + run: | + ./gradlew :websocket:clean build + docker build -t websocket:latest ./websocket + docker tag websocket:latest ${{ steps.login-ecr.outputs.registry }}/fintory-websocket:latest + docker push ${{ steps.login-ecr.outputs.registry }}/fintory-websocket:latest + + deploy-websocket: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Deploy fintory-websocket to EC2 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.FINTORY_WEBSOCKET_HOST }} + username: ubuntu + key: ${{ secrets.EC2_WEBSOCKET_SSH_KEY }} + script: | + export ECR_REGISTRY=${{ steps.login-ecr.outputs.registry }} + export RDS_URL=${{ secrets.RDS_URL }} + export RDS_USERNAME=${{ secrets.RDS_USERNAME }} + export RDS_PASSWORD=${{ secrets.RDS_PASSWORD }} + export AWS_REDIS_PASSWORD=${{ secrets.AWS_REDIS_PASSWORD }} + export HANTU_APPKEY=${{ secrets.HANTU_APPKEY}} + export HANTU_APPSECRET=${{ secrets.HANTU_APPSECRET}} + export DB_APPKEY=${{ secrets.DB_APPKEY}} + export DB_APPSECRET=${{ secrets.DB_APPSECRET}} + export EOS_API_KEY=${{ secrets.EOS_API_KEY}} + + + aws ecr get-login-password --region ap-northeast-2 | \ + docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }} + + docker pull ${{ steps.login-ecr.outputs.registry }}/fintory-websocket:latest + + docker compose down + docker compose up -d + + docker image prune -f + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 34cca234..ac8d324a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -106,4 +106,4 @@ jobs: docker compose up -d docker image prune -f - + diff --git a/app-child/build.gradle b/app-child/build.gradle index 747a3959..c8004794 100644 --- a/app-child/build.gradle +++ b/app-child/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation project(':domain') implementation project(':infra') implementation project(':auth') + implementation project(":websocket") /* .env 자동 로딩 */ diff --git a/app-child/src/main/java/com/fintory/child/ChildApplication.java b/app-child/src/main/java/com/fintory/child/ChildApplication.java index 48274f26..efee9b39 100644 --- a/app-child/src/main/java/com/fintory/child/ChildApplication.java +++ b/app-child/src/main/java/com/fintory/child/ChildApplication.java @@ -15,7 +15,8 @@ @SpringBootApplication(scanBasePackages = { "com.fintory.infra", "com.fintory.auth", - "com.fintory.child" + "com.fintory.child", + "com.fintory.websocket" }) @ConfigurationPropertiesScan(basePackages = { "com.fintory.auth" diff --git a/settings.gradle b/settings.gradle index 8bc68d65..bf9b00ba 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,4 +4,5 @@ include 'app-child' include 'domain' include 'infra' include 'common' -include 'auth' \ No newline at end of file +include 'auth' +include 'websocket' diff --git a/websocket/Dockerfile-websocket b/websocket/Dockerfile-websocket new file mode 100644 index 00000000..4549d057 --- /dev/null +++ b/websocket/Dockerfile-websocket @@ -0,0 +1,13 @@ +FROM eclipse-temurin:17-jdk + +RUN apt-get update && apt-get install -y tzdata \ + && rm -rf /var/lib/apt/lists/* + +ENV TZ=Asia/Seoul + +WORKDIR /app + +COPY build/libs/*.jar app.jar +COPY src/main/resources/application-websocket-deploy.yml /app/application-websocket-deploy.yml + +ENTRYPOINT ["java", "-jar","app.jar", "--spring.profiles.active=deploy"] diff --git a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java new file mode 100644 index 00000000..1c879b69 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java @@ -0,0 +1,20 @@ +package com.fintory.websocket; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + + + +@EnableScheduling +@SpringBootApplication(scanBasePackages = { + "com.fintory.websocket", + "com.fintory.domain", + "com.fintory.common", + "com.fintory.infra" +}) +public class WebsocketApplication { + public static void main(String[] args) { + SpringApplication.run(WebsocketApplication.class, args); + } +} diff --git a/websocket/src/main/resources/application-websocket-deploy.yml b/websocket/src/main/resources/application-websocket-deploy.yml new file mode 100644 index 00000000..0b70129a --- /dev/null +++ b/websocket/src/main/resources/application-websocket-deploy.yml @@ -0,0 +1,50 @@ +server: + port: 8080 + +spring: + config: + activate: + on-profile: deploy + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${RDS_URL} + username: ${RDS_USERNAME} + password: ${RDS_PASSWORD} + + jpa: + show-sql: false + hibernate: + ddl-auto: update + + main: + allow-bean-definition-overriding: true + + data: + redis: + host: ${AWS_REDIS_HOST} + port: 6379 + password: ${AWS_REDIS_PASSWORD} + +management: + endpoints: + web: + exposure: + include: health, prometheus,metrics + endpoint: + health: + show-details: always + +hantu-openapi: + appkey: ${HANTU_APPKEY} + appsecret: ${HANTU_APPSECRET} + base-url: https://openapi.koreainvestment.com:9443 + +db-openapi: + db-appkey: ${DB_APPKEY} + db-appsecret: ${DB_APPSECRET} + base-url: https://openapi.dbsec.co.kr:8443 + +eos: + api-key: ${EOS_API_KEY} + diff --git a/websocket/src/main/resources/application-websocket-local.yml b/websocket/src/main/resources/application-websocket-local.yml new file mode 100644 index 00000000..c3390794 --- /dev/null +++ b/websocket/src/main/resources/application-websocket-local.yml @@ -0,0 +1,50 @@ +server: + port: 8080 + +spring: + config: + activate: + on-profile: local + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${RDS_URL} + username: ${RDS_USERNAME} + password: ${RDS_PASSWORD} + + jpa: + show-sql: false + hibernate: + ddl-auto: update + + main: + allow-bean-definition-overriding: true + + data: + redis: + host: ${AWS_REDIS_HOST} + port: 6379 + password: ${AWS_REDIS_PASSWORD} + +management: + endpoints: + web: + exposure: + include: health, prometheus,metrics + endpoint: + health: + show-details: always + +hantu-openapi: + appkey: ${HANTU_APPKEY} + appsecret: ${HANTU_APPSECRET} + base-url: https://openapi.koreainvestment.com:9443 + +db-openapi: + db-appkey: ${DB_APPKEY} + db-appsecret: ${DB_APPSECRET} + base-url: https://openapi.dbsec.co.kr:8443 + +eos: + api-key: ${EOS_API_KEY} + diff --git a/websocket/src/main/resources/application-websocket.yml b/websocket/src/main/resources/application-websocket.yml new file mode 100644 index 00000000..5558aa3a --- /dev/null +++ b/websocket/src/main/resources/application-websocket.yml @@ -0,0 +1,21 @@ +spring: + config: + import: + - optional:file:.env + profiles: + active: local + jpa: + show-sql: true + properties: + hibernate.jdbc.time_zone: Asia/Seoul + + messages: + basename: messages + encoding: UTF-8 + + + + + + + From 29f58bfbde1f6d404412ef2887376b83ce03a37f Mon Sep 17 00:00:00 2001 From: mhee167 Date: Mon, 10 Nov 2025 18:51:50 +0900 Subject: [PATCH 02/38] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=EC=9D=98?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20infra=20=EB=AA=A8=EB=93=88=EC=97=90?= =?UTF-8?q?=EC=84=9C=20websocket=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java # websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java --- .../fintory/websocket}/config/WebSocketBrokerConfig.java | 3 +-- .../fintory/websocket}/config/WebSocketClientConfig.java | 5 +++-- .../fintory/websocket}/config/WebSocketInterceptor.java | 2 +- .../handler/KoreanLiveStockPriceWebSocketHandler.java | 5 +++-- .../handler/OverseasLiveStockPriceWebSocketHandler.java | 2 +- .../config/StompStatsMetricsConfiguration.java | 2 +- .../websocket}/monitoring/config/WebSocketMetrics.java | 9 +++++---- .../monitoring/config/WebSocketStatsConfig.java | 2 +- .../monitoring/listener/WebSocketEventListener.java | 5 +++-- .../service/LiveStockPriceWebSocketSaverService.java | 6 ++---- 10 files changed, 21 insertions(+), 20 deletions(-) rename {infra/src/main/java/com/fintory/infra/domain/stock => websocket/src/main/java/com/fintory/websocket}/config/WebSocketBrokerConfig.java (95%) rename {infra/src/main/java/com/fintory/infra/domain/stock => websocket/src/main/java/com/fintory/websocket}/config/WebSocketClientConfig.java (92%) rename {infra/src/main/java/com/fintory/infra/domain/stock => websocket/src/main/java/com/fintory/websocket}/config/WebSocketInterceptor.java (96%) rename {infra/src/main/java/com/fintory/infra/domain/stock => websocket/src/main/java/com/fintory/websocket}/handler/KoreanLiveStockPriceWebSocketHandler.java (98%) rename {infra/src/main/java/com/fintory/infra/domain/stock => websocket/src/main/java/com/fintory/websocket}/handler/OverseasLiveStockPriceWebSocketHandler.java (99%) rename {infra/src/main/java/com/fintory/infra => websocket/src/main/java/com/fintory/websocket}/monitoring/config/StompStatsMetricsConfiguration.java (99%) rename {infra/src/main/java/com/fintory/infra => websocket/src/main/java/com/fintory/websocket}/monitoring/config/WebSocketMetrics.java (90%) rename {infra/src/main/java/com/fintory/infra => websocket/src/main/java/com/fintory/websocket}/monitoring/config/WebSocketStatsConfig.java (94%) rename {infra/src/main/java/com/fintory/infra => websocket/src/main/java/com/fintory/websocket}/monitoring/listener/WebSocketEventListener.java (81%) rename infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebSocketSaverServiceImpl.java => websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketSaverService.java (95%) diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketBrokerConfig.java b/websocket/src/main/java/com/fintory/websocket/config/WebSocketBrokerConfig.java similarity index 95% rename from infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketBrokerConfig.java rename to websocket/src/main/java/com/fintory/websocket/config/WebSocketBrokerConfig.java index 7639fdb8..813ecbec 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketBrokerConfig.java +++ b/websocket/src/main/java/com/fintory/websocket/config/WebSocketBrokerConfig.java @@ -1,6 +1,5 @@ -package com.fintory.infra.domain.stock.config; +package com.fintory.websocket.config; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketClientConfig.java b/websocket/src/main/java/com/fintory/websocket/config/WebSocketClientConfig.java similarity index 92% rename from infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketClientConfig.java rename to websocket/src/main/java/com/fintory/websocket/config/WebSocketClientConfig.java index d42b7be2..85bae0d6 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketClientConfig.java +++ b/websocket/src/main/java/com/fintory/websocket/config/WebSocketClientConfig.java @@ -1,6 +1,7 @@ -package com.fintory.infra.domain.stock.config; +package com.fintory.websocket.config; -import com.fintory.infra.domain.stock.handler.*; +import com.fintory.websocket.handler.KoreanLiveStockPriceWebSocketHandler; +import com.fintory.websocket.handler.OverseasLiveStockPriceWebSocketHandler; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketInterceptor.java b/websocket/src/main/java/com/fintory/websocket/config/WebSocketInterceptor.java similarity index 96% rename from infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketInterceptor.java rename to websocket/src/main/java/com/fintory/websocket/config/WebSocketInterceptor.java index 5d08f786..d50a4af1 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/config/WebSocketInterceptor.java +++ b/websocket/src/main/java/com/fintory/websocket/config/WebSocketInterceptor.java @@ -1,4 +1,4 @@ -package com.fintory.infra.domain.stock.config; +package com.fintory.websocket.config; import lombok.extern.slf4j.Slf4j; import org.springframework.http.server.ServerHttpRequest; diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/handler/KoreanLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/handler/KoreanLiveStockPriceWebSocketHandler.java similarity index 98% rename from infra/src/main/java/com/fintory/infra/domain/stock/handler/KoreanLiveStockPriceWebSocketHandler.java rename to websocket/src/main/java/com/fintory/websocket/handler/KoreanLiveStockPriceWebSocketHandler.java index 7701ac03..8bec9a0c 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/handler/KoreanLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/handler/KoreanLiveStockPriceWebSocketHandler.java @@ -1,4 +1,4 @@ -package com.fintory.infra.domain.stock.handler; +package com.fintory.websocket.handler; import com.fasterxml.jackson.databind.ObjectMapper; import com.fintory.common.exception.DomainErrorCode; @@ -83,6 +83,7 @@ public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ this.session = null; isConnected.set(false); this.connectionLatch = new CountDownLatch(1); + log.warn("웹소켓 연결 종료 - Code: {}, Reason: {}", status.getCode(), status.getReason()); log.info("웹소켓 연결 종료"); } @@ -91,7 +92,7 @@ public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ public void subscribe(String code){ try { sendSubscribeMessage(code); - Thread.sleep(100); + Thread.sleep(200); }catch (InterruptedException e){ Thread.currentThread().interrupt(); diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/handler/OverseasLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/handler/OverseasLiveStockPriceWebSocketHandler.java similarity index 99% rename from infra/src/main/java/com/fintory/infra/domain/stock/handler/OverseasLiveStockPriceWebSocketHandler.java rename to websocket/src/main/java/com/fintory/websocket/handler/OverseasLiveStockPriceWebSocketHandler.java index cb849484..b2f201cd 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/handler/OverseasLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/handler/OverseasLiveStockPriceWebSocketHandler.java @@ -1,4 +1,4 @@ -package com.fintory.infra.domain.stock.handler; +package com.fintory.websocket.handler; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/infra/src/main/java/com/fintory/infra/monitoring/config/StompStatsMetricsConfiguration.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java similarity index 99% rename from infra/src/main/java/com/fintory/infra/monitoring/config/StompStatsMetricsConfiguration.java rename to websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java index 33137a78..074ea517 100644 --- a/infra/src/main/java/com/fintory/infra/monitoring/config/StompStatsMetricsConfiguration.java +++ b/websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java @@ -1,4 +1,4 @@ -package com.fintory.infra.monitoring.config; +package com.fintory.websocket.monitoring.config; import io.micrometer.core.instrument.FunctionCounter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/infra/src/main/java/com/fintory/infra/monitoring/config/WebSocketMetrics.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java similarity index 90% rename from infra/src/main/java/com/fintory/infra/monitoring/config/WebSocketMetrics.java rename to websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java index 78c4acfa..d4c373a7 100644 --- a/infra/src/main/java/com/fintory/infra/monitoring/config/WebSocketMetrics.java +++ b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java @@ -1,6 +1,6 @@ -package com.fintory.infra.monitoring.config; +package com.fintory.websocket.monitoring.config; -import com.fintory.domain.stock.service.websocket.LiveStockPriceWebsocketService; +import com.fintory.websocket.service.LiveStockPriceWebSocketService; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; @@ -14,14 +14,14 @@ @Component public class WebSocketMetrics { - private final LiveStockPriceWebsocketService websocketService; + private final LiveStockPriceWebSocketService websocketService; private final MeterRegistry meterRegistry; private final AtomicInteger activeConnections = new AtomicInteger(0); private Counter messageSent; //REVIEW @Lazy를 쓰기 위해 명시적 생성자 사용 -> @Lazy는 생성자 파라미터에 직접 붙어 있어야 동작함 // @RequiredConstructor는 생성자 파라미터별 어노테이션을 직접 지원하지 않는 것으로 알고 있음. - public WebSocketMetrics(@Lazy LiveStockPriceWebsocketService websocketService, MeterRegistry meterRegistry) { + public WebSocketMetrics(@Lazy LiveStockPriceWebSocketService websocketService, MeterRegistry meterRegistry) { this.websocketService = websocketService; this.meterRegistry = meterRegistry; } @@ -35,6 +35,7 @@ public void registerMetrics(){ .description("Active STOMP connections (클라이언트 수)") .register(meterRegistry); + // TODO 활성 구독 종목 수 -> 그라파나로 확인한 후 없애기 // 국내 주식 활성 구독 종목 수 Gauge.builder("websocket.korean.subscriptions.active", websocketService, service -> service.getKoreanSubscribedStocks().size()) diff --git a/infra/src/main/java/com/fintory/infra/monitoring/config/WebSocketStatsConfig.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java similarity index 94% rename from infra/src/main/java/com/fintory/infra/monitoring/config/WebSocketStatsConfig.java rename to websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java index 27c706ec..d5a1f947 100644 --- a/infra/src/main/java/com/fintory/infra/monitoring/config/WebSocketStatsConfig.java +++ b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java @@ -1,4 +1,4 @@ -package com.fintory.infra.monitoring.config; +package com.fintory.websocket.monitoring.config; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; diff --git a/infra/src/main/java/com/fintory/infra/monitoring/listener/WebSocketEventListener.java b/websocket/src/main/java/com/fintory/websocket/monitoring/listener/WebSocketEventListener.java similarity index 81% rename from infra/src/main/java/com/fintory/infra/monitoring/listener/WebSocketEventListener.java rename to websocket/src/main/java/com/fintory/websocket/monitoring/listener/WebSocketEventListener.java index f714796f..0d4899f3 100644 --- a/infra/src/main/java/com/fintory/infra/monitoring/listener/WebSocketEventListener.java +++ b/websocket/src/main/java/com/fintory/websocket/monitoring/listener/WebSocketEventListener.java @@ -1,6 +1,6 @@ -package com.fintory.infra.monitoring.listener; +package com.fintory.websocket.monitoring.listener; -import com.fintory.infra.monitoring.config.WebSocketMetrics; +import com.fintory.websocket.monitoring.config.WebSocketMetrics; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; @@ -17,6 +17,7 @@ public class WebSocketEventListener { private final WebSocketMetrics webSocketMetrics; + // 클라이언트 stomp 연결(STMOP CONNECTED로 응답 완료 후 ) -> SessionConntecdEvent 발행 @EventListener public void handleSessionConnect(SessionConnectedEvent event) { webSocketMetrics.incrementConnection(); diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebSocketSaverServiceImpl.java b/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketSaverService.java similarity index 95% rename from infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebSocketSaverServiceImpl.java rename to websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketSaverService.java index f292417f..492f339b 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebSocketSaverServiceImpl.java +++ b/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketSaverService.java @@ -1,4 +1,4 @@ -package com.fintory.infra.domain.stock.service.websocket; +package com.fintory.websocket.service; import com.fintory.common.exception.DomainErrorCode; import com.fintory.common.exception.DomainException; @@ -7,7 +7,6 @@ import com.fintory.domain.stock.model.LiveStockPrice; import com.fintory.domain.stock.model.Stock; import com.fintory.domain.stock.model.StockPriceHistory; -import com.fintory.domain.stock.service.websocket.LiveStockPriceWebSocketSaverService; import com.fintory.infra.domain.stock.repository.LiveStockPriceRepository; import com.fintory.infra.domain.stock.repository.StockPriceHistoryRepository; import com.fintory.infra.domain.stock.repository.StockRepository; @@ -26,7 +25,7 @@ @Service @RequiredArgsConstructor @Slf4j -public class LiveStockPriceWebSocketSaverServiceImpl implements LiveStockPriceWebSocketSaverService { +public class LiveStockPriceWebSocketSaverService { private final StockPriceHistoryRepository stockPriceHistoryRepository; private final StockRepository stockRepository; @@ -35,7 +34,6 @@ public class LiveStockPriceWebSocketSaverServiceImpl implements LiveStockPriceWe private static final Map todayOpenPrices = new ConcurrentHashMap<>(); //데이터 DB에 저장 메소드 - @Override @Transactional public void saveStockData(LiveStockPriceStream dto) { Stock stock = stockRepository.findByCode(dto.code()) From 10cb82128fac5f913602efc851b1a9a32682b457 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Mon, 10 Nov 2025 18:52:54 +0900 Subject: [PATCH 03/38] =?UTF-8?q?feat=20:=20LiveStockPriceWebSocketService?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveStockPriceWebsocketServiceImpl.java | 649 ------------------ .../LiveStockPriceWebSocketService.java | 153 +++++ .../websocket/service/MarketTimeService.java | 51 ++ .../service/StockDataBatchSaveService.java | 54 ++ .../service/StockDataProcessService.java | 103 +++ .../service/StockSubscriptionService.java | 90 +++ .../service/WebSocketConnectionService.java | 207 ++++++ .../websocket/state/StockDataHolder.java | 34 + 8 files changed, 692 insertions(+), 649 deletions(-) delete mode 100644 infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebsocketServiceImpl.java create mode 100644 websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketService.java create mode 100644 websocket/src/main/java/com/fintory/websocket/service/MarketTimeService.java create mode 100644 websocket/src/main/java/com/fintory/websocket/service/StockDataBatchSaveService.java create mode 100644 websocket/src/main/java/com/fintory/websocket/service/StockDataProcessService.java create mode 100644 websocket/src/main/java/com/fintory/websocket/service/StockSubscriptionService.java create mode 100644 websocket/src/main/java/com/fintory/websocket/service/WebSocketConnectionService.java create mode 100644 websocket/src/main/java/com/fintory/websocket/state/StockDataHolder.java diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebsocketServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebsocketServiceImpl.java deleted file mode 100644 index 4cecbef5..00000000 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/websocket/LiveStockPriceWebsocketServiceImpl.java +++ /dev/null @@ -1,649 +0,0 @@ -package com.fintory.infra.domain.stock.service.websocket; - -import com.fintory.common.exception.DomainErrorCode; -import com.fintory.common.exception.DomainException; -import com.fintory.infra.monitoring.config.WebSocketMetrics; -import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; -import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; -import com.fintory.domain.stock.model.Stock; -import com.fintory.domain.stock.service.websocket.LiveStockPriceWebSocketSaverService; -import com.fintory.domain.stock.service.websocket.LiveStockPriceWebsocketService; -import com.fintory.infra.domain.alarm.event.PriceAlertEvent; -import com.fintory.infra.domain.stock.handler.KoreanLiveStockPriceWebSocketHandler; -import com.fintory.infra.domain.stock.handler.OverseasLiveStockPriceWebSocketHandler; -import com.fintory.infra.domain.stock.repository.StockRepository; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.messaging.simp.SimpMessageHeaderAccessor; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.socket.client.WebSocketConnectionManager; - -import java.math.BigDecimal; -import java.time.*; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; - -//NOTE 구독 시도시 -> 에러 코드를 보고 프론트에서 DB API 호출 -//NOTE 구독 성공 후 일정시간 동안 데이터가 오지 않으면 -> 프론트에서 연결 끊김 판단 -@Service -@Slf4j -public class LiveStockPriceWebsocketServiceImpl implements LiveStockPriceWebsocketService { - - @Value("${db-openapi.base-url}") - private String baseUrl; - - private final RedisTemplate redisTemplate; - private final WebSocketConnectionManager koreanConnectionManager; - private final WebSocketConnectionManager overseasConnectionManager; - private final KoreanLiveStockPriceWebSocketHandler koreanHandler; - private final OverseasLiveStockPriceWebSocketHandler overseasHandler; - private final StockRepository stockRepository; - private final SimpMessagingTemplate messageTemplate; - - // 공통 데이터 구조들 - //구독 중인 종목 코드 저장 - private final Set koreanSubscribedStocks = ConcurrentHashMap.newKeySet(); - private final Set overseasSubscribedStocks = ConcurrentHashMap.newKeySet(); - - //db에 저장되지 않은 주식 데이터 임시 저장용 - private final Map koreanPendingData = new ConcurrentHashMap<>(); - private final Map overseasPendingData = new ConcurrentHashMap<>(); - - // 이전에 받은 주식 데이터 저장 -> 중복 데이터 필터링용 - private final Map previousKoreanData = new ConcurrentHashMap<>(); - private final Map previousOverseasData = new ConcurrentHashMap<>(); - private final RestTemplate restTemplate; - - private volatile AtomicBoolean isKoreanConnected = new AtomicBoolean(false); - private volatile AtomicBoolean isOverseasConnected = new AtomicBoolean(false); - private String cachedAccessToken; - - private final LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService; - - //이벤트 - private final ApplicationEventPublisher applicationEventPublisher; - - //그라파나용 매트릭 -> 레이턴시, 효율성 - private final Timer dataProcessingTime; - - private final WebSocketMetrics webSocketMetrics; - - - public LiveStockPriceWebsocketServiceImpl( - @Qualifier("koreanLiveStockPriceWebSocketConnectionManager") WebSocketConnectionManager koreanConnectionManager, - @Qualifier("overseasLiveStockPriceWebSocketConnectionManager") WebSocketConnectionManager overseasConnectionManager, - KoreanLiveStockPriceWebSocketHandler koreanHandler, - OverseasLiveStockPriceWebSocketHandler overseasHandler, - StockRepository stockRepository, - SimpMessagingTemplate messageTemplate, - RestTemplate restTemplate, - RedisTemplate redisTemplate, - LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService, - ApplicationEventPublisher applicationEventPublisher, - MeterRegistry meterRegistry, - @Lazy WebSocketMetrics webSocketMetrics) { - - this.koreanConnectionManager = koreanConnectionManager; - this.overseasConnectionManager = overseasConnectionManager; - this.koreanHandler = koreanHandler; - this.overseasHandler = overseasHandler; - this.stockRepository = stockRepository; - this.messageTemplate = messageTemplate; - this.restTemplate = restTemplate; - this.redisTemplate = redisTemplate; - this.liveStockPriceWebSocketSaverService = liveStockPriceWebSocketSaverService; - this.applicationEventPublisher = applicationEventPublisher; - - this.dataProcessingTime = Timer.builder("websocket.data.processing.time") - .description("Time to process and send stock data") - .publishPercentiles(0.5,0.95,0.99) - .register(meterRegistry); - this.webSocketMetrics = webSocketMetrics; - } - - /* 구독 관리 메서드 */ - @Override - public void koreanStockSubscribe(String code) { - subscribeStock(code, "국내", isKoreanConnected, koreanSubscribedStocks, - this::connectKoreanWebSocket, koreanHandler::subscribe); - } - - @Override - public void koreanStockUnsubscribe(String code) { - unsubscribeStock(code, "국내", koreanSubscribedStocks, previousKoreanData, - koreanPendingData, koreanHandler::unsubscribe); - } - - @Override - public void overseasStockSubscribe(String code) { - subscribeStock(code, "해외", isOverseasConnected, overseasSubscribedStocks, - this::connectOverseasWebSocket, overseasHandler::subscribe); - } - - @Override - public void overseasStockUnsubscribe(String code) { - unsubscribeStock(code, "해외", overseasSubscribedStocks, previousOverseasData, - overseasPendingData, overseasHandler::unsubscribe); - } - - @Override - public void sendStockData(String stockCode, Object stockData) { - if (stockData instanceof LiveStockPriceStream stream) { - if (stream.priceChange() == null || stream.priceChange().compareTo(BigDecimal.ZERO) == 0) { - log.debug("변동 없음 - 전송 스킵: {}", stockCode); - return; - } - - // 지연 시간을 측정하기 위해 STOMP 헤더에 타임스탬프 추가 - // REVIEW 헤더에 데이터를 추가한 것일 뿐 바디는 바뀌지 않으므로 프론트 코드에는 문제가 없는 것으로 알고 있는데 아니라면 수정 필수 - SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(); - headerAccessor.setNativeHeader("sentTimestamp", String.valueOf(System.currentTimeMillis())); - - webSocketMetrics.incrementMessageSent(); - messageTemplate.convertAndSend("/topic/stock/live-Price/" + stockCode, stockData, headerAccessor.getMessageHeaders()); - } - } - - /* 구독 자동 실행 메소드 */ - @Scheduled(cron="0 30 09 * * MON-FRI", zone="America/New_York") - public void scheduledOverseasMarketSubscription(){ - startOverseasMarketSubscription(); - } - - @Scheduled(cron="0 0 9 * * MON-FRI", zone="Asia/Seoul") - public void scheduledKoreanMarketSubscription(){ - startKoreanMarketSubscription(); - } - - @PostConstruct - public void initMarketSubscriptions() { - // 국내 장 체크 및 구독 - if (isKoreanMarketOpen()) { - log.info("애플리케이션 시작 - 국내 장 열림, 자동 구독 시작"); - startKoreanMarketSubscription(); - } else { - log.info("국내 장이 열려있지 않아 자동 구독 스킵"); - } - - // 해외 장 체크 및 구독 - if (isOverseasMarketOpen()) { - log.info("애플리케이션 시작 - 해외 장 열림, 자동 구독 시작"); - startOverseasMarketSubscription(); - } else { - log.info("해외 장이 열려있지 않아 자동 구독 스킵"); - } - } - - /* 통합 구독/구독해제 로직 */ - private void subscribeStock(String code, String marketName, AtomicBoolean isConnected, - Set subscribedStocks, Runnable connectAction, - Consumer subscribeAction) { - try { - - //해외, 국내 주식 토큰 분리 - if("해외".equals(marketName)){ - cachedAccessToken = (String) redisTemplate.opsForValue().get("db-access-token"); - }else{ - cachedAccessToken = (String) redisTemplate.opsForValue().get("kis-websocket-access-token"); - } - - boolean isMarketClosed = ("해외".equals(marketName) && !isOverseasMarketOpen()) || - ("국내".equals(marketName) && !isKoreanMarketOpen()); - - if (isMarketClosed) { - throw new DomainException(DomainErrorCode.MARKET_CLOSED); - } - - if (!isConnected.get()) { - log.info("{} 주식 WebSocket이 연결되어 있지 않아 자동 연결을 시작합니다.", marketName); - connectAction.run(); - } - - if (!subscribedStocks.contains(code)) { - subscribedStocks.add(code); - } - log.info("{} 종목 {} 구독", marketName, code); - - subscribeAction.accept(code); - } catch (Exception e) { - subscribedStocks.remove(code); - log.error("{} 종목 구독 실패: {}", marketName, e.getMessage()); - throw new DomainException(DomainErrorCode.STOCK_SUBSCRIBE_FAILED); - } - } - - private void unsubscribeStock(String code, String marketName, Set subscribedStocks, - Map previousData, - Map pendingData, - Consumer unsubscribeAction) { - try { - if (subscribedStocks.contains(code)) { - unsubscribeAction.accept(code); - subscribedStocks.remove(code); - - // 메모리 정리 - previousData.remove(code); - pendingData.remove(code); - log.info("{} 종목 {} 구독 해제", marketName, code); - } - } catch (Exception e) { - log.error("{} 종목 구독 해제 실패: {}", marketName, e.getMessage()); - throw new DomainException(DomainErrorCode.STOCK_UNSUBSCRIBE_FAILED); - } - } - - /* WebSocket 연결 관리 */ - private void connectKoreanWebSocket() { - if (isKoreanConnected.get()) { - log.info("국내 주식 WebSocket이 이미 연결되어 있습니다."); - return; - } - - Consumer callback = dto -> - processStreamData(dto, previousKoreanData, koreanPendingData, "국내"); - - koreanHandler.setDataCallBack(callback); - koreanConnectionManager.start(); - - boolean connected = koreanHandler.waitForConnection(30); - if (!connected) { - log.info("국내 장시간임에도 WebSocket 연결 실패 - 공휴일이거나 기술적 문제일 수 있음"); - throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); - } - - isKoreanConnected.set(true); - log.info("국내 주식 WebSocket 연결 완료"); - } - - private void connectOverseasWebSocket() { - if (isOverseasConnected.get()) { - log.info("해외 주식 WebSocket이 이미 연결되어 있습니다."); - return; - } - - Consumer callback = dto -> - processStreamData(dto, previousOverseasData, overseasPendingData, "해외"); - - overseasHandler.setDataCallBack(callback); - overseasConnectionManager.start(); - - boolean connected = overseasHandler.waitForConnection(30); - if (!connected) { - log.info("해외 장시간임에도 WebSocket 연결 실패 - 공휴일이거나 기술적 문제일 수 있음"); - throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); - } - - isOverseasConnected.set(true); - log.info("해외 주식 WebSocket 연결 완료"); - } - - //웹소켓으로 받은 데이터를 처리하는 메서드 - private void processStreamData(LiveStockPriceStream dto, - Map previousData, - Map pendingData, - String marketName) { - LiveStockPriceStream previous = previousData.get(dto.code()); - - Timer.Sample sample = Timer.start(); - try { - - //이전 데이터와 비교하여 중복 체크 - if (previous != null && previous.equals(dto)) { - log.debug("{} 주식 중복 데이터 스킵: {}", marketName, dto.code()); - return; //똑같은 데이터면 무시 - } - - //새로운 데이터를 받으면 -> 감시가 이벤트 발행 - applicationEventPublisher.publishEvent( - new PriceAlertEvent(this, dto) - ); - - //스케쥴러 + 웹소켓 연결 시작하자마자 받은 데이터 값(첫 데이터) 저장 - if (previous == null) { - try { - liveStockPriceWebSocketSaverService.saveStockData(dto); //DB에 바로 저장 - log.debug("{} 종목 {} 실시간 저장 완료", marketName, dto.code()); - } catch (Exception e) { - // 실패 시 배치 저장을 위해 pendingData에 보관 - pendingData.put(dto.code(), dto); - log.error("{} 종목 {} 실시간 저장 실패, 배치 저장 대기: {}", marketName, dto.code(), e.getMessage()); - } - } - - //새로운 데이터면 다음 중복 체크용으로 저장 - previousData.put(dto.code(), dto); - pendingData.put(dto.code(), dto); //배치 저장 대기 - sendStockData(dto.code(), dto); //클라이언트에게 전송 - }finally { - sample.stop(dataProcessingTime); - - } - } - - /* 스케줄링 - 배치 저장 */ - @Scheduled(cron = "0 * 9-15 * * MON-FRI", zone = "Asia/Seoul") - public void saveKoreanStockDataBatch() { - if (!isKoreanMarketOpen()) { - log.debug("국내 장 마감으로 인한 배치 저장 중단"); - return; - } - saveBatchData("국내", koreanPendingData); - } - - @Scheduled(cron = "0 * 9-15 * * MON-FRI", zone = "America/New_York") - public void saveOverseasStockDataBatch() { - if (!isOverseasMarketOpen()) { - log.debug("해외 장 마감으로 인한 배치 저장 중단"); - return; - } - saveBatchData("해외", overseasPendingData); - } - - private void saveBatchData(String marketName, Map pendingData) { - if (pendingData.isEmpty()) return; - - Map dataToSave = new HashMap<>(pendingData); - pendingData.clear(); - - dataToSave.values().forEach(dto -> { - try { - liveStockPriceWebSocketSaverService.saveStockData(dto); - } catch (Exception e) { - log.error("{} 종목 {} 저장 실패: {}", marketName, dto.code(), e.getMessage()); - } - }); - - log.info("{} 주식 배치 저장 완료 - 저장된 종목 수: {}", marketName, dataToSave.size()); - } - - /* 장 시작 시 자동으로 필요한 종목 전부 구독*/ - public void startKoreanMarketSubscription(){ - if (!isKoreanMarketOpen()) { - log.info("국내 장이 열려있지 않아 자동 구독 스킵"); - return; - } - - connectKoreanWebSocket(); - - List targetStocks = stockRepository.findByCurrencyName("KRW"); - int beforeSize = koreanSubscribedStocks.size(); - - targetStocks.forEach(dto -> { - if(!koreanSubscribedStocks.contains(dto.getCode())) { - try { - koreanHandler.subscribe(dto.getCode()); - koreanSubscribedStocks.add(dto.getCode()); - }catch (Exception e){ - log.error("종목 {} 구독 실패: {}", dto.getCode(), e.getMessage()); - } - } - }); - int successCount = koreanSubscribedStocks.size() - beforeSize; - log.info("장 시작 - 총 {} 종목 중 {} 종목 구독 완료", - targetStocks.size(), successCount); - } - - public void startOverseasMarketSubscription(){ - - if (!isOverseasMarketOpen()) { - log.info("해외 장이 열려있지 않아 자동 구독 스킵"); - return; - } - - connectOverseasWebSocket(); - - - List targetStocks = stockRepository.findByCurrencyName("USD"); - int beforeSize = overseasSubscribedStocks.size(); - - targetStocks.forEach(stock -> { - if (!overseasSubscribedStocks.contains(stock.getCode())) { - try { - overseasHandler.subscribe(stock.getCode()); - overseasSubscribedStocks.add(stock.getCode()); - }catch(Exception e){ - log.error("종목 {} 구독 실패: {}", stock.getCode(), e.getMessage()); - } - } - }); - - int successCount = overseasSubscribedStocks.size() - beforeSize; - log.info("장 시작 - 총 {} 종목 중 {} 종목 구독 완료", - targetStocks.size(), successCount); - } - - @Override - public MarketStatusResponse getMarketStatus() { - // 국내 장 시간이면 "korean" - if (isKoreanConnected.get() && isKoreanMarketOpen()) { - return new MarketStatusResponse("korean"); - } - - // 해외 장 시간이면 "overseas" - if (isOverseasConnected.get() && isOverseasMarketOpen()) { - return new MarketStatusResponse("overseas"); - } - - // 둘 다 아니면 "no" - return new MarketStatusResponse("no"); - } - - /* 스케줄링 - 장 마감 정리 */ - @Scheduled(cron = "0 20 15 * * MON-FRI", zone = "Asia/Seoul") - public void cleanUpAfterKoreanMarketClose() { - log.debug("국내 장 마감 - 마지막 데이터 저장 및 정리 시작"); - saveRemainingData("국내", koreanPendingData); - disconnectKoreanWebSocket(); - log.info("국내 장 마감 정리 완료"); - } - - @Scheduled(cron = "0 0 16 * * MON-FRI", zone = "America/New_York") - public void cleanUpAfterOverseasMarketClose() { - log.debug("해외 장 마감 - 마지막 데이터 저장 및 정리 시작"); - saveRemainingData("해외", overseasPendingData); - disconnectOverseasWebSocket(); - log.info("해외 장 마감 정리 완료"); - } - - private void saveRemainingData(String marketName, Map pendingData) { - if (!pendingData.isEmpty()) { - - Map dataToSave = new HashMap<>(pendingData); - pendingData.clear(); - - dataToSave.values().forEach(dto -> { - try { - liveStockPriceWebSocketSaverService.saveStockData(dto); - } catch (Exception e) { - log.error("{} 종목 {} 마지막 저장 실패: {}", marketName, dto.code(), e.getMessage()); - } - }); - - log.info("{} 주식 마지막 배치 저장 완료 - 저장된 종목 수: {}", marketName, dataToSave.size()); - } - } - - private void disconnectKoreanWebSocket() { - if (!isKoreanConnected.get()) return; - - log.info("국내 WebSocket 연결 해제 시작"); - try { - new ArrayList<>(koreanSubscribedStocks).forEach(code -> { - try { - koreanStockUnsubscribe(code); - } catch (Exception e) { - log.warn("국내 종목 {} 구독 해제 중 에러 발생: {}", code, e.getMessage()); - } - }); - Thread.sleep(1000); //서버 처리 대기 - } catch (Exception e) { - log.error("구독 해제 중 에러: {}", e.getMessage()); - } finally { - // 반드시 실행 - koreanConnectionManager.stop(); - koreanSubscribedStocks.clear(); - previousKoreanData.clear(); - koreanPendingData.clear(); - } - - log.info("국내 WebSocket 연결 해제 완료"); - } - - - private void disconnectOverseasWebSocket() { - if (!isOverseasConnected.get()) return; - - log.info("해외 WebSocket 연결 해제 시작"); - try { - new ArrayList<>(overseasSubscribedStocks).forEach(code -> { - try { - overseasStockUnsubscribe(code); - } catch (Exception e) { - log.warn("해외 종목 {} 구독 해제 중 에러 발생: {}", code, e.getMessage()); - } - }); - - Thread.sleep(1000); //서버 처리 대기 - try { - disconnectDBSession(); //db증권은 세션 정리를 하지 않을 경우 에러 발생함 - Thread.sleep(500); - } catch (Exception e) { - log.warn("세션 종료 실패 (무시): {}", e.getMessage()); // 에러 무시 - } - - } catch (Exception e) { - log.error("구독 해제 중 에러: {}", e.getMessage()); - } finally { - overseasConnectionManager.stop(); - isOverseasConnected.set(false); - overseasSubscribedStocks.clear(); - previousOverseasData.clear(); - overseasPendingData.clear(); - } - - log.info("해외 WebSocket 연결 해제 완료"); - } - - - public void disconnectDBSession(){ - try { - //ERROR LettuceConnectionFactory has been STOPPED. Use start() to initialize it - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("authorization", "Bearer " + cachedAccessToken); - - HttpEntity> entity = new HttpEntity<>(new HashMap<>(), headers); - - ResponseEntity response = restTemplate.postForEntity( - baseUrl + "/api/v1/websocket/disconnectSession", - entity, - Map.class - ); - log.info(response.getBody().toString()); - if (response.getStatusCode().is2xxSuccessful()) { - Map body = response.getBody(); - log.info("웹소켓 세션 초기화 성공: {}", body.get("result")); - } else { - log.warn("웹소켓 세션 초기화 응답 이상: {}", response.getStatusCode()); - } - - }catch (Exception e) { - log.error("웹소켓 세션 초기화 중 오류 발생", e); - throw new RuntimeException("웹소켓 세션 초기화 실패: " + e.getMessage()); - } - } - - /* 유틸리티 메서드 */ - private boolean isKoreanMarketOpen() { - ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); - boolean weekday = now.getDayOfWeek() != DayOfWeek.SATURDAY && now.getDayOfWeek() != DayOfWeek.SUNDAY; - return weekday - && !now.toLocalTime().isBefore(LocalTime.of(9, 0)) - && now.toLocalTime().isBefore(LocalTime.of(15, 30)); - } - - private boolean isOverseasMarketOpen() { - ZonedDateTime now = ZonedDateTime.now(ZoneId.of("America/New_York")); - boolean weekday = now.getDayOfWeek() != DayOfWeek.SATURDAY && now.getDayOfWeek() != DayOfWeek.SUNDAY; - return weekday - && !now.toLocalTime().isBefore(LocalTime.of(9, 0)) - && now.toLocalTime().isBefore(LocalTime.of(16, 0)); - } - - @PreDestroy - public void cleanUp() { - log.info("애플리케이션 종료로 인한 WebSocket 연결 해제 시작"); - - try { - // 남은 데이터 저장 - saveRemainingData("국내", koreanPendingData); - saveRemainingData("해외", overseasPendingData); - - // 연결 해제 - if (isKoreanConnected.get()) { - disconnectKoreanWebSocket(); - } - if (isOverseasConnected.get()) { - disconnectOverseasWebSocket(); - } - - // 최종 리소스 정리 -> (안전장치) - koreanSubscribedStocks.clear(); - overseasSubscribedStocks.clear(); - previousKoreanData.clear(); - previousOverseasData.clear(); - koreanPendingData.clear(); - overseasPendingData.clear(); - - } catch (Exception e) { - log.error("WebSocket cleanup 중 에러 발생", e); - - // 에러 발생해도 리소스는 강제 정리 - koreanSubscribedStocks.clear(); - overseasSubscribedStocks.clear(); - previousKoreanData.clear(); - previousOverseasData.clear(); - koreanPendingData.clear(); - overseasPendingData.clear(); - } - - log.info("WebSocket 연결 해제 완료"); - } - - - - /* 메트릭용 Getter 추가 */ - public Set getKoreanSubscribedStocks() { - return koreanSubscribedStocks; - } - - public Set getOverseasSubscribedStocks() { - return overseasSubscribedStocks; - } - - public boolean isKoreanConnected() { - return isKoreanConnected.get(); - } - - public boolean isOverseasConnected() { - return isOverseasConnected.get(); - } -} \ No newline at end of file diff --git a/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketService.java new file mode 100644 index 00000000..157fcb78 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketService.java @@ -0,0 +1,153 @@ +package com.fintory.websocket.service; + + +import com.fintory.websocket.state.StockDataHolder; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import java.util.*; + + + +//NOTE 구독 시도시 -> 에러 코드를 보고 프론트에서 DB API 호출 +//NOTE 구독 성공 후 일정시간 동안 데이터가 오지 않으면 -> 프론트에서 연결 끊김 판단 +@Service +@Slf4j +@RequiredArgsConstructor +public class LiveStockPriceWebSocketService { + + + private final StockDataHolder stockDataHolder; + private final StockSubscriptionService stockSubscriptionService; + private final StockDataBatchSaveService stockDataBatchSaveService; + private final WebSocketConnectionService webSocketConnectionService; + private final MarketTimeService marketTimeService; + + /* 구독 자동 실행 메소드 */ + @Scheduled(cron="0 30 09 * * MON-FRI", zone="America/New_York") + public void scheduledOverseasMarketSubscription(){ + stockSubscriptionService.startOverseasMarketSubscription(); + } + + @Scheduled(cron="0 0 9 * * MON-FRI", zone="Asia/Seoul") + public void scheduledKoreanMarketSubscription(){ + stockSubscriptionService.startKoreanMarketSubscription(); + } + + + @PostConstruct + public void initMarketSubscriptions() { + // 국내 장 체크 및 구독 + if (marketTimeService.isKoreanMarketOpen()) { + log.info("애플리케이션 시작 - 국내 장 열림, 자동 구독 시작"); + stockSubscriptionService.startKoreanMarketSubscription(); + } else { + log.info("국내 장이 열려있지 않아 자동 구독 스킵"); + } + + // 해외 장 체크 및 구독 + if (marketTimeService.isOverseasMarketOpen()) { + log.info("애플리케이션 시작 - 해외 장 열림, 자동 구독 시작"); + stockSubscriptionService.startOverseasMarketSubscription(); + } else { + log.info("해외 장이 열려있지 않아 자동 구독 스킵"); + } + } + + /* 스케줄링 - 배치 저장 */ + @Scheduled(cron = "0 * 9-15 * * MON-FRI", zone = "Asia/Seoul") + public void saveKoreanStockDataBatch() { + if (!marketTimeService.isKoreanMarketOpen()) { + log.debug("국내 장 마감으로 인한 배치 저장 중단"); + return; + } + stockDataBatchSaveService.saveBatchData("국내", stockDataHolder.getKoreanPendingData()); + } + + @Scheduled(cron = "0 * 9-15 * * MON-FRI", zone = "America/New_York") + public void saveOverseasStockDataBatch() { + if (!marketTimeService.isOverseasMarketOpen()) { + log.debug("해외 장 마감으로 인한 배치 저장 중단"); + return; + } + stockDataBatchSaveService.saveBatchData("해외", stockDataHolder.getOverseasPendingData()); + } + + /* 스케줄링 - 장 마감 정리 */ + @Scheduled(cron = "0 20 15 * * MON-FRI", zone = "Asia/Seoul") + public void cleanUpAfterKoreanMarketClose() { + log.debug("국내 장 마감 - 마지막 데이터 저장 및 정리 시작"); + stockDataBatchSaveService.saveRemainingData("국내", stockDataHolder.getKoreanPendingData()); + webSocketConnectionService.disconnectKoreanWebSocket(); + log.info("국내 장 마감 정리 완료"); + } + + @Scheduled(cron = "0 0 16 * * MON-FRI", zone = "America/New_York") + public void cleanUpAfterOverseasMarketClose() { + log.debug("해외 장 마감 - 마지막 데이터 저장 및 정리 시작"); + stockDataBatchSaveService.saveRemainingData("해외", stockDataHolder.getOverseasPendingData()); + webSocketConnectionService.disconnectOverseasWebSocket(); + log.info("해외 장 마감 정리 완료"); + } + + + @PreDestroy + public void cleanUp() { + log.info("애플리케이션 종료로 인한 WebSocket 연결 해제 시작"); + try { + // 남은 데이터 저장 + stockDataBatchSaveService.saveRemainingData("국내", stockDataHolder.getKoreanPendingData()); + stockDataBatchSaveService.saveRemainingData("해외", stockDataHolder.getOverseasPendingData()); + + // 웹소켓 연결 해제 + if (stockDataHolder.getIsKoreanConnected().get()) { + webSocketConnectionService.disconnectKoreanWebSocket(); + } + if (stockDataHolder.getIsOverseasConnected().get()) { + webSocketConnectionService.disconnectOverseasWebSocket(); + } + + // 최종 리소스 정리 -> (안전장치) + stockDataHolder.getKoreanSubscribedStocks().clear(); + stockDataHolder.getOverseasSubscribedStocks().clear(); + stockDataHolder.getPreviousKoreanData().clear(); + stockDataHolder.getPreviousOverseasData().clear(); + stockDataHolder.getKoreanPendingData().clear(); + stockDataHolder.getOverseasPendingData().clear(); + + } catch (Exception e) { + log.error("WebSocket cleanup 중 에러 발생", e); + + // 에러 발생해도 리소스는 강제 정리 + stockDataHolder.getKoreanSubscribedStocks().clear(); + stockDataHolder.getOverseasSubscribedStocks().clear(); + stockDataHolder.getPreviousKoreanData().clear(); + stockDataHolder.getPreviousOverseasData().clear(); + stockDataHolder.getKoreanPendingData().clear(); + stockDataHolder.getOverseasPendingData().clear(); + } + + log.info("WebSocket 연결 해제 완료"); + } + + /* 메트릭용 Getter 추가 */ + public Set getKoreanSubscribedStocks() { + return stockDataHolder.getKoreanSubscribedStocks(); + } + + public Set getOverseasSubscribedStocks() { + return stockDataHolder.getOverseasSubscribedStocks(); + } + + public boolean isKoreanConnected() { + return stockDataHolder.getIsKoreanConnected().get(); + } + + public boolean isOverseasConnected() { + return stockDataHolder.getIsOverseasConnected().get(); + } +} \ No newline at end of file diff --git a/websocket/src/main/java/com/fintory/websocket/service/MarketTimeService.java b/websocket/src/main/java/com/fintory/websocket/service/MarketTimeService.java new file mode 100644 index 00000000..7d371e40 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/service/MarketTimeService.java @@ -0,0 +1,51 @@ +package com.fintory.websocket.service; + +import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; +import com.fintory.websocket.state.StockDataHolder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +@Service +@RequiredArgsConstructor +public class MarketTimeService { + + private final StockDataHolder stockDataHolder; + + public boolean isKoreanMarketOpen() { + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + boolean weekday = now.getDayOfWeek() != DayOfWeek.SATURDAY && now.getDayOfWeek() != DayOfWeek.SUNDAY; + return weekday + && !now.toLocalTime().isBefore(LocalTime.of(9, 0)) + && now.toLocalTime().isBefore(LocalTime.of(15, 30)); + } + + public boolean isOverseasMarketOpen() { + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("America/New_York")); + boolean weekday = now.getDayOfWeek() != DayOfWeek.SATURDAY && now.getDayOfWeek() != DayOfWeek.SUNDAY; + return weekday + && !now.toLocalTime().isBefore(LocalTime.of(9, 0)) + && now.toLocalTime().isBefore(LocalTime.of(16, 0)); + } + + + public MarketStatusResponse getMarketStatus() { + // 국내 장 시간이면 "korean" + if (stockDataHolder.getIsKoreanConnected().get() && isKoreanMarketOpen()) { + return new MarketStatusResponse("korean"); + } + + // 해외 장 시간이면 "overseas" + if (stockDataHolder.getIsOverseasConnected().get() && isOverseasMarketOpen()) { + return new MarketStatusResponse("overseas"); + } + + // 둘 다 아니면 "no" + return new MarketStatusResponse("no"); + } + +} diff --git a/websocket/src/main/java/com/fintory/websocket/service/StockDataBatchSaveService.java b/websocket/src/main/java/com/fintory/websocket/service/StockDataBatchSaveService.java new file mode 100644 index 00000000..755ca04e --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/service/StockDataBatchSaveService.java @@ -0,0 +1,54 @@ +package com.fintory.websocket.service; + +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + + +@Service +@Slf4j +@RequiredArgsConstructor +public class StockDataBatchSaveService { + + private final LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService; + + public void saveBatchData(String marketName, Map pendingData) { + if (pendingData.isEmpty()) return; + + Map dataToSave = new HashMap<>(pendingData); + pendingData.clear(); + + dataToSave.values().forEach(dto -> { + try { + liveStockPriceWebSocketSaverService.saveStockData(dto); + } catch (Exception e) { + log.error("{} 종목 {} 저장 실패: {}", marketName, dto.code(), e.getMessage()); + } + }); + + log.info("{} 주식 배치 저장 완료 - 저장된 종목 수: {}", marketName, dataToSave.size()); + } + + public void saveRemainingData(String marketName, Map pendingData) { + if (!pendingData.isEmpty()) { + + Map dataToSave = new HashMap<>(pendingData); + pendingData.clear(); + + dataToSave.values().forEach(dto -> { + try { + liveStockPriceWebSocketSaverService.saveStockData(dto); + } catch (Exception e) { + log.error("{} 종목 {} 마지막 저장 실패: {}", marketName, dto.code(), e.getMessage()); + } + }); + + log.info("{} 주식 마지막 배치 저장 완료 - 저장된 종목 수: {}", marketName, dataToSave.size()); + } + } + +} diff --git a/websocket/src/main/java/com/fintory/websocket/service/StockDataProcessService.java b/websocket/src/main/java/com/fintory/websocket/service/StockDataProcessService.java new file mode 100644 index 00000000..30eaad49 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/service/StockDataProcessService.java @@ -0,0 +1,103 @@ +package com.fintory.websocket.service; + +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; +import com.fintory.infra.domain.alarm.event.PriceAlertEvent; +import com.fintory.websocket.monitoring.config.WebSocketMetrics; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import io.micrometer.core.instrument.Timer; + +import java.math.BigDecimal; +import java.util.Map; + +@Service +@Slf4j +public class StockDataProcessService { + private final WebSocketMetrics webSocketMetrics; + private final SimpMessagingTemplate messageTemplate; + private final Timer dataProcessingTime; + private final ApplicationEventPublisher applicationEventPublisher; + private final LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService; + + public StockDataProcessService(@Lazy WebSocketMetrics webSocketMetrics, + SimpMessagingTemplate messageTemplate, + MeterRegistry meterRegistry, + ApplicationEventPublisher applicationEventPublisher, + LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService) { + this.webSocketMetrics = webSocketMetrics; + this.messageTemplate = messageTemplate; + this.dataProcessingTime = Timer.builder("websocket.data.processing.time") + .description("Time to process and send stock data") + .publishPercentiles(0.5,0.95,0.99) + .register(meterRegistry); + this.applicationEventPublisher = applicationEventPublisher; + this.liveStockPriceWebSocketSaverService = liveStockPriceWebSocketSaverService; + } + + //웹소켓으로 받은 데이터를 처리하는 메서드 + public void processStreamData(LiveStockPriceStream dto, + Map previousData, + Map pendingData, + String marketName) { + LiveStockPriceStream previous = previousData.get(dto.code()); + + Timer.Sample sample = Timer.start(); + try { + + //이전 데이터와 비교하여 중복 체크 + if (previous != null && previous.equals(dto)) { + log.debug("{} 주식 중복 데이터 스킵: {}", marketName, dto.code()); + return; //똑같은 데이터면 무시 + } + + //새로운 데이터를 받으면 -> 감시가 이벤트 발행 + applicationEventPublisher.publishEvent( + new PriceAlertEvent(this, dto) + ); + + //스케쥴러 + 웹소켓 연결 시작하자마자 받은 데이터 값(첫 데이터) 저장 + if (previous == null) { + try { + liveStockPriceWebSocketSaverService.saveStockData(dto); //DB에 바로 저장 + log.debug("{} 종목 {} 실시간 저장 완료", marketName, dto.code()); + } catch (Exception e) { + // 실패 시 배치 저장을 위해 pendingData에 보관 + pendingData.put(dto.code(), dto); + log.error("{} 종목 {} 실시간 저장 실패, 배치 저장 대기: {}", marketName, dto.code(), e.getMessage()); + } + } + + //새로운 데이터면 다음 중복 체크용으로 저장 + previousData.put(dto.code(), dto); + pendingData.put(dto.code(), dto); //배치 저장 대기 + sendStockData(dto.code(), dto); //클라이언트에게 전송 + }finally { + sample.stop(dataProcessingTime); + + } + } + + public void sendStockData(String stockCode, Object stockData) { + if (stockData instanceof LiveStockPriceStream stream) { + if (stream.priceChange() == null || stream.priceChange().compareTo(BigDecimal.ZERO) == 0) { + log.debug("변동 없음 - 전송 스킵: {}", stockCode); + return; + } + + // 지연 시간을 측정하기 위해 STOMP 헤더에 타임스탬프 추가 + // REVIEW 헤더에 데이터를 추가한 것일 뿐 바디는 바뀌지 않으므로 프론트 코드에는 문제가 없는 것으로 알고 있는데 아니라면 수정 필수 + SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(); + headerAccessor.setNativeHeader("sentTimestamp", String.valueOf(System.currentTimeMillis())); + + webSocketMetrics.incrementMessageSent(); + messageTemplate.convertAndSend("/topic/stock/live-Price/" + stockCode, stockData, headerAccessor.getMessageHeaders()); + } + } + + +} diff --git a/websocket/src/main/java/com/fintory/websocket/service/StockSubscriptionService.java b/websocket/src/main/java/com/fintory/websocket/service/StockSubscriptionService.java new file mode 100644 index 00000000..bcec794c --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/service/StockSubscriptionService.java @@ -0,0 +1,90 @@ +package com.fintory.websocket.service; + +import com.fintory.common.exception.DomainErrorCode; +import com.fintory.common.exception.DomainException; +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; +import com.fintory.domain.stock.model.Stock; +import com.fintory.infra.domain.stock.repository.StockRepository; +import com.fintory.websocket.handler.KoreanLiveStockPriceWebSocketHandler; +import com.fintory.websocket.handler.OverseasLiveStockPriceWebSocketHandler; +import com.fintory.websocket.state.StockDataHolder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +@Service +@Slf4j +@RequiredArgsConstructor +public class StockSubscriptionService { + private final StockDataHolder stockDataHolder; + private final KoreanLiveStockPriceWebSocketHandler koreanHandler; + private final OverseasLiveStockPriceWebSocketHandler overseasHandler; + + private final WebSocketConnectionService connectionService; + private final StockRepository stockRepository; + private final MarketTimeService marketTimeService; + + /* 장 시작 시 자동으로 필요한 종목 전부 구독*/ + public void startKoreanMarketSubscription(){ + List targetStocks = stockRepository.findByCurrencyName("KRW"); + + if (!marketTimeService.isKoreanMarketOpen()) { + log.info("국내 장이 열려있지 않아 자동 구독 스킵"); + return; + } + + connectionService.connectKoreanWebSocket(); + + int beforeSize = stockDataHolder.getKoreanSubscribedStocks().size(); + + targetStocks.forEach(dto -> { + if(!stockDataHolder.getKoreanSubscribedStocks().contains(dto.getCode())) { + try { + koreanHandler.subscribe(dto.getCode()); + stockDataHolder.getKoreanSubscribedStocks().add(dto.getCode()); + }catch (Exception e){ + log.error("종목 {} 구독 실패: {}", dto.getCode(), e.getMessage()); + } + } + }); + int successCount = stockDataHolder.getKoreanSubscribedStocks().size() - beforeSize; + log.info("장 시작 - 총 {} 종목 중 {} 종목 구독 완료", + targetStocks.size(), successCount); + } + + + public void startOverseasMarketSubscription(){ + + List targetStocks = stockRepository.findByCurrencyName("USD"); + + if (!marketTimeService.isOverseasMarketOpen()) { + log.info("해외 장이 열려있지 않아 자동 구독 스킵"); + return; + } + + connectionService.connectOverseasWebSocket(); + + int beforeSize = stockDataHolder.getOverseasSubscribedStocks().size(); + + targetStocks.forEach(stock -> { + if (!stockDataHolder.getOverseasSubscribedStocks().contains(stock.getCode())) { + try { + overseasHandler.subscribe(stock.getCode()); + stockDataHolder.getOverseasSubscribedStocks().add(stock.getCode()); + }catch(Exception e){ + log.error("종목 {} 구독 실패: {}", stock.getCode(), e.getMessage()); + } + } + }); + + int successCount = stockDataHolder.getOverseasSubscribedStocks().size() - beforeSize; + log.info("장 시작 - 총 {} 종목 중 {} 종목 구독 완료", + targetStocks.size(), successCount); + } + + +} diff --git a/websocket/src/main/java/com/fintory/websocket/service/WebSocketConnectionService.java b/websocket/src/main/java/com/fintory/websocket/service/WebSocketConnectionService.java new file mode 100644 index 00000000..8bec1c5a --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/service/WebSocketConnectionService.java @@ -0,0 +1,207 @@ +package com.fintory.websocket.service; + +import com.fintory.common.exception.DomainErrorCode; +import com.fintory.common.exception.DomainException; +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; +import com.fintory.websocket.handler.KoreanLiveStockPriceWebSocketHandler; +import com.fintory.websocket.handler.OverseasLiveStockPriceWebSocketHandler; +import com.fintory.websocket.state.StockDataHolder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.socket.client.WebSocketConnectionManager; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +@Service +@Slf4j +public class WebSocketConnectionService { + + private final RedisTemplate redisTemplate; + @Value("${db-openapi.base-url}") + private String baseUrl; + + private final StockDataHolder stockDataHolder; + private final WebSocketConnectionManager koreanConnectionManager; + private final WebSocketConnectionManager overseasConnectionManager; + private final StockDataProcessService stockDataProcessService; + private final RestTemplate restTemplate; + private final KoreanLiveStockPriceWebSocketHandler koreanHandler; + private final OverseasLiveStockPriceWebSocketHandler overseasHandler; + + public WebSocketConnectionService(StockDataHolder stockDataHolder, + @Qualifier("koreanLiveStockPriceWebSocketConnectionManager")WebSocketConnectionManager koreanConnectionManager, + @Qualifier("overseasLiveStockPriceWebSocketConnectionManager")WebSocketConnectionManager overseasConnectionManager, + StockDataProcessService stockDataProcessService, RestTemplate restTemplate, + KoreanLiveStockPriceWebSocketHandler koreanHandler, + OverseasLiveStockPriceWebSocketHandler overseasHandler, + RedisTemplate redisTemplate) { + this.stockDataHolder = stockDataHolder; + this.koreanConnectionManager = koreanConnectionManager; + this.overseasConnectionManager = overseasConnectionManager; + this.stockDataProcessService = stockDataProcessService; + this.restTemplate = restTemplate; + this.koreanHandler = koreanHandler; + this.overseasHandler = overseasHandler; + this.redisTemplate = redisTemplate; + } + + + /* WebSocket 연결 관리 */ + public void connectKoreanWebSocket() { + log.info("=== connectKoreanWebSocket 시작 ==="); + log.info("현재 연결 상태: {}", stockDataHolder.getIsKoreanConnected().get()); + log.info("Handler 연결 상태: {}", koreanHandler.isConnected()); + + if (stockDataHolder.getIsKoreanConnected().get()) { + log.info("국내 주식 WebSocket이 이미 연결되어 있습니다."); + return; + } + + Consumer callback = dto -> + stockDataProcessService.processStreamData(dto, + stockDataHolder.getPreviousKoreanData(), + stockDataHolder.getKoreanPendingData(), "국내"); + + koreanHandler.setDataCallBack(callback); + koreanConnectionManager.start(); + + boolean connected = koreanHandler.waitForConnection(30); + if (!connected) { + log.error("연결 실패!"); + throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); + } + + stockDataHolder.getIsKoreanConnected().set(true); + log.info("국내 주식 WebSocket 연결 완료"); + } + + public void connectOverseasWebSocket() { + if (stockDataHolder.getIsOverseasConnected().get()) { + log.info("해외 주식 WebSocket이 이미 연결되어 있습니다."); + return; + } + + Consumer callback = dto -> + stockDataProcessService.processStreamData(dto, stockDataHolder.getPreviousOverseasData(), stockDataHolder.getOverseasPendingData(), "해외"); + + overseasHandler.setDataCallBack(callback); + overseasConnectionManager.start(); + + boolean connected = overseasHandler.waitForConnection(30); + if (!connected) { + log.info("해외 장시간임에도 WebSocket 연결 실패 - 공휴일이거나 기술적 문제일 수 있음"); + throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); + } + + stockDataHolder.getIsOverseasConnected().set(true); + log.info("해외 주식 WebSocket 연결 완료"); + } + + + public void disconnectKoreanWebSocket() { + if (!stockDataHolder.getIsKoreanConnected().get()) return; + + log.info("국내 WebSocket 연결 해제 시작"); + try { + new ArrayList<>(stockDataHolder.getKoreanSubscribedStocks()).forEach(code -> { + try { + koreanHandler.unsubscribe(code); + stockDataHolder.getKoreanSubscribedStocks().remove(code); + } catch (Exception e) { + log.warn("국내 종목 {} 구독 해제 중 에러 발생: {}", code, e.getMessage()); + } + }); + Thread.sleep(1000); //서버 처리 대기 + } catch (Exception e) { + log.error("구독 해제 중 에러: {}", e.getMessage()); + } finally { + // 반드시 실행 + koreanConnectionManager.stop(); + stockDataHolder.getKoreanSubscribedStocks().clear(); + stockDataHolder.getPreviousKoreanData().clear(); + stockDataHolder.getKoreanPendingData().clear(); + } + + log.info("국내 WebSocket 연결 해제 완료"); + } + + + public void disconnectOverseasWebSocket() { + if (!stockDataHolder.getIsOverseasConnected().get()) return; + + log.info("해외 WebSocket 연결 해제 시작"); + try { + new ArrayList<>(stockDataHolder.getOverseasSubscribedStocks()).forEach(code -> { + try { + overseasHandler.unsubscribe(code); + stockDataHolder.getOverseasSubscribedStocks().remove(code); + } catch (Exception e) { + log.warn("해외 종목 {} 구독 해제 중 에러 발생: {}", code, e.getMessage()); + } + }); + + Thread.sleep(1000); //서버 처리 대기 + try { + disconnectDBSession(); //db증권은 세션 정리를 하지 않을 경우 에러 발생함 + Thread.sleep(500); + } catch (Exception e) { + log.warn("세션 종료 실패 (무시): {}", e.getMessage()); // 에러 무시 + } + + } catch (Exception e) { + log.error("구독 해제 중 에러: {}", e.getMessage()); + } finally { + overseasConnectionManager.stop(); + stockDataHolder.getIsOverseasConnected().set(false); + stockDataHolder.getOverseasSubscribedStocks().clear(); + stockDataHolder.getPreviousOverseasData().clear(); + stockDataHolder.getOverseasPendingData().clear(); + + } + + log.info("해외 WebSocket 연결 해제 완료"); + } + + + public void disconnectDBSession(){ + try { + //ERROR LettuceConnectionFactory has been STOPPED. Use start() to initialize it + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("authorization", "Bearer " + (String) redisTemplate.opsForValue().get("db-access-token")); + + HttpEntity> entity = new HttpEntity<>(new HashMap<>(), headers); + + ResponseEntity response = restTemplate.postForEntity( + baseUrl + "/api/v1/websocket/disconnectSession", + entity, + Map.class + ); + log.info(response.getBody().toString()); + if (response.getStatusCode().is2xxSuccessful()) { + Map body = response.getBody(); + log.info("웹소켓 세션 초기화 성공: {}", body.get("result")); + } else { + log.warn("웹소켓 세션 초기화 응답 이상: {}", response.getStatusCode()); + } + + }catch (Exception e) { + log.error("웹소켓 세션 초기화 중 오류 발생", e); + throw new RuntimeException("웹소켓 세션 초기화 실패: " + e.getMessage()); + } + } + + +} diff --git a/websocket/src/main/java/com/fintory/websocket/state/StockDataHolder.java b/websocket/src/main/java/com/fintory/websocket/state/StockDataHolder.java new file mode 100644 index 00000000..873900d7 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/state/StockDataHolder.java @@ -0,0 +1,34 @@ +package com.fintory.websocket.state; + +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; + + +import lombok.Data; +import lombok.Getter; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +@Component +@Data +public class StockDataHolder { + + private final Set koreanSubscribedStocks = ConcurrentHashMap.newKeySet(); + private final Set overseasSubscribedStocks = ConcurrentHashMap.newKeySet(); + + // 이전에 받은 주식 데이터 저장 -> 중복 데이터 필터링용 + private final Map previousKoreanData = new ConcurrentHashMap<>(); + private final Map previousOverseasData = new ConcurrentHashMap<>(); + + // db에 저장되지 않은 주식 데이터 임시 저장용 + private final Map koreanPendingData = new ConcurrentHashMap<>(); + private final Map overseasPendingData = new ConcurrentHashMap<>(); + + private final AtomicBoolean isKoreanConnected = new AtomicBoolean(false); + private final AtomicBoolean isOverseasConnected = new AtomicBoolean(false); + + +} From 2f469b5db094cdbf3fc2cf0c3bb2af8de744d69f Mon Sep 17 00:00:00 2001 From: mhee167 Date: Mon, 10 Nov 2025 18:53:19 +0900 Subject: [PATCH 04/38] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/CommonStockControllerImpl.java | 10 +- .../StockWebSocketControllerImpl.java | 118 ------------- .../websocket/StockWebsocketController.java | 50 ------ .../LiveStockPriceWebSocketSaverService.java | 7 - .../LiveStockPriceWebsocketService.java | 62 ------- .../korean/KoreanStockRankServiceImpl.java | 164 ------------------ .../OverseasStockRankServiceImpl.java | 150 ---------------- 7 files changed, 5 insertions(+), 556 deletions(-) delete mode 100644 app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebSocketControllerImpl.java delete mode 100644 app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebsocketController.java delete mode 100644 domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebSocketSaverService.java delete mode 100644 domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebsocketService.java delete mode 100644 infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockRankServiceImpl.java delete mode 100644 infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockRankServiceImpl.java diff --git a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java index da22cbbe..9d4ef808 100644 --- a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java +++ b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java @@ -4,7 +4,7 @@ import com.fintory.domain.stock.dto.korean.response.StockSearchResponse; import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; import com.fintory.domain.stock.service.common.CommonStockService; -import com.fintory.domain.stock.service.websocket.LiveStockPriceWebsocketService; +import com.fintory.websocket.service.MarketTimeService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -20,22 +20,22 @@ public class CommonStockControllerImpl implements CommonStockController { private final CommonStockService commonStockService; - private final LiveStockPriceWebsocketService websocketService; + private final MarketTimeService marketTimeService; //주식 종목 검색 @Override @GetMapping("/search") public ResponseEntity>> searchStock(@RequestParam String keyword) { - List stockSearchRespons = commonStockService.searchStock(keyword); - return ResponseEntity.ok(ApiResponse.ok(stockSearchRespons)); + List stockSearchResponse = commonStockService.searchStock(keyword); + return ResponseEntity.ok(ApiResponse.ok(stockSearchResponse)); } //장시간 리턴 @Override @GetMapping("/opened-market") public ResponseEntity> getMarketStatus(){ - return ResponseEntity.ok(ApiResponse.ok(websocketService.getMarketStatus())); + return ResponseEntity.ok(ApiResponse.ok(marketTimeService.getMarketStatus())); } } diff --git a/app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebSocketControllerImpl.java b/app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebSocketControllerImpl.java deleted file mode 100644 index ae07653e..00000000 --- a/app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebSocketControllerImpl.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.fintory.child.domain.stock.controller.websocket; -import com.fintory.domain.stock.dto.websocket.StockMessageRequest; -import com.fintory.domain.stock.model.Stock; -import com.fintory.domain.stock.service.websocket.LiveStockPriceWebsocketService; -import com.fintory.infra.domain.stock.repository.StockRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Controller; - -import java.util.List; - - -@Controller //Http ResponseBody가 아닌 WebSocket 메시지 브로드 캐스트용 -@RequiredArgsConstructor -@Slf4j -public class StockWebSocketControllerImpl { - - private final LiveStockPriceWebsocketService liveStockWebSocketService; - private final StockRepository stockRepository; - - /** - * 프론트엔드에서 STOMP로 한국 주식 구독 요청 - * - * @param request 종목 코드가 포함된 요청 - */ - @MessageMapping("/stock/subscribe/korean") - public void subscribeKoreanStock(StockMessageRequest request) { - log.info("STOMP를 통한 한국 종목 {} 구독 요청", request.code()); - // 실제 WebSocket 구독 시작 (자동 연결 포함) - liveStockWebSocketService.koreanStockSubscribe(request.code()); - } - - /** - * 프론트엔드에서 STOMP로 한국 주식 구독 해제 요청 - */ - @MessageMapping("/stock/unsubscribe/korean") - public void unsubscribeKoreanStock(StockMessageRequest request) { - log.info("STOMP를 통한 한국 종목 {} 구독 해제 요청", request.code()); - liveStockWebSocketService.koreanStockUnsubscribe(request.code()); - } - - /** - * 프론트엔드에서 STOMP로 해외 주식 구독 요청 - */ - @MessageMapping("/stock/subscribe/overseas") - public void subscribeOverseasStock(StockMessageRequest request) { - log.info("STOMP를 통한 해외 종목 {} 구독 요청", request.code()); - liveStockWebSocketService.overseasStockSubscribe(request.code()); - } - - /** - * 프론트엔드에서 STOMP로 해외 주식 구독 해제 요청 - */ - @MessageMapping("/stock/unsubscribe/overseas") - public void unsubscribeOverseasStock(StockMessageRequest request) { - log.info("STOMP를 통한 해외 종목 {} 구독 해제 요청", request.code()); - liveStockWebSocketService.overseasStockUnsubscribe(request.code()); - } - - //NOTE 프론트에서 어떻게 구현할지 정해지지 않아서 현재처럼 전체 구독 + 개별 구독을 같이 구현 - /** - * 전체 구독 - * - */ - @MessageMapping("/stock/subscribe-all/korean") - @Async - public void subscribeKoreanStockAll() { - List stockList = stockRepository.findByCurrencyName("KRW"); - for (Stock stock : stockList) { - log.info("STOMP를 통한 국내 종목 {} 구독 요청", stock.getCode()); - // 실제 WebSocket 구독 시작 (자동 연결 포함) - liveStockWebSocketService.koreanStockSubscribe(stock.getCode()); - } - log.info("국내 종목 일괄 구독 완료"); - - } - - @MessageMapping("/stock/unsubscribe-all/korean") - @Async - public void unsubscribeKoreanStockAll() { - List stockList = stockRepository.findByCurrencyName("KRW"); - for (Stock stock : stockList) { - log.info("STOMP를 통한 국내 종목 {} 구독 취소 요청", stock.getCode()); - // 실제 WebSocket 구독 시작 (자동 연결 포함) - liveStockWebSocketService.koreanStockUnsubscribe(stock.getCode()); - } - log.info("국내 종목 일괄 구독 취소 완료"); - - } - - @MessageMapping("/stock/subscribe-all/overseas") - @Async - public void subscribeOverseasStockAll() { - List stockList = stockRepository.findByCurrencyName("USD"); - for (Stock stock : stockList) { - log.info("STOMP를 통한 해외 종목 {} 구독 요청", stock.getCode()); - // 실제 WebSocket 구독 시작 (자동 연결 포함) - liveStockWebSocketService.overseasStockSubscribe(stock.getCode()); - } - log.info("해외 종목 일괄 구독 완료"); - - } - - @MessageMapping("/stock/unsubscribe-all/overseas") - @Async - public void unsubscribeOverseasStockAll() { - List stockList = stockRepository.findByCurrencyName("USD"); - for (Stock stock : stockList) { - log.info("STOMP를 통한 해외 종목 {} 구독 취소 요청", stock.getCode()); - // 실제 WebSocket 구독 시작 (자동 연결 포함) - liveStockWebSocketService.overseasStockUnsubscribe(stock.getCode()); - } - log.info("해외 종목 일괄 구독 취소 완료"); - } - -} \ No newline at end of file diff --git a/app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebsocketController.java b/app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebsocketController.java deleted file mode 100644 index bc7d57aa..00000000 --- a/app-child/src/main/java/com/fintory/child/domain/stock/controller/websocket/StockWebsocketController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fintory.child.domain.stock.controller.websocket; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; - -public interface StockWebsocketController { - - /* 개별 구독 */ - @Operation(summary = "한국 주식 실시간 구독", description = "한국 주식 종목의 실시간 가격 정보를 구독합니다.") - @ApiResponse(responseCode = "200", description = "한국 주식 구독 성공") - ResponseEntity> subscribeKoreanStock( - @Parameter(description = "종목 코드", example = "005930") @PathVariable String code); - - @Operation(summary = "한국 주식 실시간 구독 해제", description = "한국 주식 종목의 실시간 가격 정보 구독을 해제합니다") - @ApiResponse(responseCode = "200", description = "한국 주식 구독 해제 성공") - ResponseEntity> unsubscribeKoreanStock( - @Parameter(description = "종목 코드", example = "005930") @PathVariable String code); - - @Operation(summary = "해외 주식 실시간 구독", description = "해외 주식 종목의 실시간 가격 정보를 구독합니다.") - @ApiResponse(responseCode = "200", description = "해외 주식 구독 성공") - ResponseEntity> subscribeOverseasStock( - @Parameter(description = "종목 코드", example = "AAPL") @PathVariable String code); - - @Operation(summary = "해외 주식 실시간 구독 해제", description = "해외 주식 종목의 실시간 가격 정보 구독을 해제합니다") - @ApiResponse(responseCode = "200", description = "해외 주식 구독 해제 성공") - ResponseEntity> unsubscribeOverseasStock( - @Parameter(description = "종목 코드", example = "AAPL") @PathVariable String code); - - - /* 전체 구독 */ - @Operation(summary = "한국 주식 전체 구독", description = "모든 한국 주식을 일괄 구독합니다") - @ApiResponse(responseCode = "200", description = "구독 시작") - ResponseEntity> subscribeAllKoreanStocks(); - - @Operation(summary = "한국 주식 전체 구독 해제", description = "모든 한국 주식 구독을 해제합니다") - @ApiResponse(responseCode = "200", description = "구독 해제 시작") - ResponseEntity> unsubscribeAllKoreanStocks(); - - @Operation(summary = "해외 주식 전체 구독", description = "모든 해외 주식을 일괄 구독합니다") - @ApiResponse(responseCode = "200", description = "구독 시작") - ResponseEntity> subscribeAllOverseasStocks(); - - @Operation(summary = "해외 주식 전체 구독 해제", description = "모든 해외 주식 구독을 해제합니다") - @ApiResponse(responseCode = "200", description = "구독 해제 시작") - ResponseEntity> unsubscribeAllOverseasStocks(); - -} diff --git a/domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebSocketSaverService.java b/domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebSocketSaverService.java deleted file mode 100644 index 34b668c8..00000000 --- a/domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebSocketSaverService.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.fintory.domain.stock.service.websocket; - -import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; - -public interface LiveStockPriceWebSocketSaverService { - void saveStockData(LiveStockPriceStream dto); -} diff --git a/domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebsocketService.java b/domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebsocketService.java deleted file mode 100644 index 825fa2bc..00000000 --- a/domain/src/main/java/com/fintory/domain/stock/service/websocket/LiveStockPriceWebsocketService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.fintory.domain.stock.service.websocket; - -import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; - -import java.util.Map; -import java.util.Set; - -/** - * 실시간 주식 가격 WebSocket 서비스 인터페이스 - * 한국 및 해외 주식의 실시간 가격 구독/해제 및 데이터 전송을 담당 - */ -public interface LiveStockPriceWebsocketService { - - /** - * 한국 주식 종목 구독 - * @param code 주식 종목 코드 - * @throws com.fintory.common.exception.DomainException 구독 실패 시 - */ - void koreanStockSubscribe(String code); - - /** - * 한국 주식 종목 구독 해제 - * @param code 주식 종목 코드 - * @throws com.fintory.common.exception.DomainException 구독 해제 실패 시 - */ - void koreanStockUnsubscribe(String code); - - /** - * 해외 주식 종목 구독 - * @param code 주식 종목 코드 - * @throws com.fintory.common.exception.DomainException 구독 실패 시 - */ - void overseasStockSubscribe(String code); - - /** - * 해외 주식 종목 구독 해제 - * @param code 주식 종목 코드 - * @throws com.fintory.common.exception.DomainException 구독 해제 실패 시 - */ - void overseasStockUnsubscribe(String code); - - /** - * 해당 토픽을 구독한 사용자(프론트)들에게 주식 데이터 브로드캐스트 - * @param stockCode 주식 종목 코드 - * @param stockData 전송할 주식 데이터 (KoreanLiveStockPriceStream 또는 OverseasLiveStockPriceStream) - */ - void sendStockData(String stockCode, Object stockData); - - - /** - * stomp 구독 시 어떤 장이 열린건지 확인 - * @return korean, overseas, no 중 하나 - */ - MarketStatusResponse getMarketStatus(); - - - /* 매트릭용 Getter 함수 추가 */ - Set getKoreanSubscribedStocks(); - Set getOverseasSubscribedStocks(); - boolean isKoreanConnected(); - boolean isOverseasConnected(); -} \ No newline at end of file diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockRankServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockRankServiceImpl.java deleted file mode 100644 index fefec73a..00000000 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockRankServiceImpl.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.fintory.infra.domain.stock.service.korean; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fintory.common.exception.DomainErrorCode; -import com.fintory.common.exception.DomainException; -import com.fintory.domain.stock.dto.korean.core.KoreanStockRankData; -import com.fintory.domain.stock.dto.korean.wrapper.KoreanStockRankDataWrapper; -import com.fintory.domain.stock.model.Stock; -import com.fintory.domain.stock.model.StockRank; -import com.fintory.domain.stock.service.korean.KoreanStockRankService; -import com.fintory.infra.domain.stock.repository.StockRankRepository; -import com.fintory.infra.domain.stock.repository.StockRepository; -import com.fintory.infra.domain.stock.service.korean.saver.KoreanStockRankSaverService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.*; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Retryable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.ResourceAccessException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import java.util.Comparator; -import java.util.List; - -@Service -@Slf4j -@RequiredArgsConstructor -public class KoreanStockRankServiceImpl implements KoreanStockRankService { - - - private final StockRepository stockRepository; - private final StockRankRepository stockRankRepository; - private final KoreanStockRankSaverService koreanStockRankSaverService; - private final RedisTemplate redisTemplate; - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; - - @Value("${hantu-openapi.appkey}") - private String appkey; - - @Value("${hantu-openapi.appsecret}") - private String appsecret; - - @Value("${hantu-openapi.base-url}") - private String baseUrl; - - //REVIEW API호출 실패 문제는 대부분 시스템 레벨 문제 -> 개별 종목만 실패할 확률은 낮고 대부분 전체적으로 실패하므로 처음부터 다시 시작하도록 설정 - @Retryable(maxAttempts=5, backoff = @Backoff(delay = 1000)) - @Override - public void initiateKoreanStockRank(){ - - List stockList = stockRepository.findByCurrencyName("KRW"); - String token = (String) redisTemplate.opsForValue().get("kis-access-token"); - - int failCount=0; - int successCount=0; - - // 토큰 null 체크 추가 - if (token == null || token.trim().isEmpty()) { - log.error("KIS 액세스 토큰을 찾을 수 없습니다."); - throw new DomainException(DomainErrorCode.TOKEN_EMPTY); - } - - for(Stock stock: stockList){ - try { - processStockRankData(stock.getCode(), token); - successCount++; - }catch(Exception e){ - failCount++; - log.warn("주식 {} 처리 실패: {}", stock.getCode(), e.getMessage()); //로그 기록 남기기 - } - } - - //하나라도 성공을 못 시킬때만 재시작 - if(successCount == 0){ - log.error("순위 데이터 초기화 작업 중 모든 종목 처리 실패"); - throw new DomainException(DomainErrorCode.COMPLETE_INITIALIZATION_FAILURE); - } - - //순위 데이터 생성 및 저장 - processStockRank(); - } - - //NOTE LiveStockPrice에서도 동일한 호출을 진행하지만 클래스의 책임 분리가 모호해서 따로따로 호출하기로 함 -> 대신 상위 클래스에서 초기화 메소드 실행 시 순서만 조정 - //순위를 얻는데 필요한 데이터 조회 - private void processStockRankData(String code, String token) { - try { - //URL 생성 - String url = UriComponentsBuilder.fromHttpUrl(baseUrl) - .path("/uapi/domestic-stock/v1/quotations/inquire-price") - .queryParam("FID_COND_MRKT_DIV_CODE", "UN") - .queryParam("FID_INPUT_ISCD", code) - .build() - .toUriString(); - - // 헤더 설정 - HttpHeaders headers = new HttpHeaders(); - headers.set("authorization", "Bearer " + token); - headers.set("appkey", appkey); - headers.set("appsecret", appsecret); - headers.set("tr_id", "FHKST01010100"); - headers.set("custtype", "P"); - headers.setContentType(MediaType.APPLICATION_JSON); - - - HttpEntity entity = new HttpEntity<>(headers); - - // API 호출 - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); - - - if (response.getStatusCode().is2xxSuccessful()) { - KoreanStockRankDataWrapper wrapper = objectMapper.readValue(response.getBody(), KoreanStockRankDataWrapper.class); - - koreanStockRankSaverService.saveStockRankData(code, wrapper); - } else { - log.error("순위 관련 데이터 조회 실패: {} - 응답이 비어있음", code); - throw new DomainException(DomainErrorCode.API_RESPONSE_EMPTY); - } - - } catch (DomainException e) { - throw e; - } catch (JsonProcessingException e) { - log.error("JSON 파싱 실패: {} - {}", code, e.getMessage()); - throw new DomainException(DomainErrorCode.JSON_PARSING_ERROR); - } catch (ResourceAccessException e) { - log.error("API 연결 실패: {} - {}", code, e.getMessage()); - throw new DomainException(DomainErrorCode.API_CONNECTION_ERROR); - } catch (Exception e) { - log.error("예상치 못한 오류: {} - {}", code, e.getMessage()); - throw new DomainException(DomainErrorCode.INTERNAL_SERVER_ERROR); - } - } - - //순위 데이터 생성 및 저장 - private void processStockRank(){ - List stockRankList = stockRankRepository.findByCurrencyName("KRW"); - - stockRankList.sort(Comparator.comparing(StockRank::getMarketCap).reversed()); - for (int i = 0; i < stockRankList.size(); i++) { - stockRankList.get(i).updateMarketCapRank(i + 1); - } - - stockRankList.sort(Comparator.comparing((StockRank sr) -> sr.getRocRate().abs()).thenComparing(StockRank::getRocRate).reversed()); //같은 절댓값이면 실제값으로 재정렬 - for (int i = 0; i < stockRankList.size(); i++) { - stockRankList.get(i).updateRocRank(i + 1); - } - - stockRankList.sort(Comparator.comparing(StockRank::getTradingVolume).reversed()); - for (int i = 0; i < stockRankList.size(); i++) { - stockRankList.get(i).updateTradingVolumeRank(i + 1); - } - - stockRankRepository.saveAll(stockRankList); - } - - -} diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockRankServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockRankServiceImpl.java deleted file mode 100644 index 85c56cb5..00000000 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockRankServiceImpl.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.fintory.infra.domain.stock.service.overseas; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fintory.common.exception.DomainErrorCode; -import com.fintory.common.exception.DomainException; -import com.fintory.domain.stock.dto.overseas.wrapper.OverseasStockRankDataWrapper; -import com.fintory.domain.stock.model.Stock; -import com.fintory.domain.stock.model.StockRank; -import com.fintory.domain.stock.service.overseas.OverseasStockRankService; -import com.fintory.infra.domain.stock.repository.StockRankRepository; -import com.fintory.infra.domain.stock.repository.StockRepository; -import com.fintory.infra.domain.stock.service.overseas.saver.OverseasStockRankSaverService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.*; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Retryable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.ResourceAccessException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import java.util.Comparator; -import java.util.List; - -@Service -@Slf4j -@RequiredArgsConstructor -public class OverseasStockRankServiceImpl implements OverseasStockRankService { - - private final StockRepository stockRepository; - private final StockRankRepository stockRankRepository; - private final OverseasStockRankSaverService overseasStockRankSaverService; - private final RedisTemplate redisTemplate; - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; - - @Value("${hantu-openapi.appkey}") - private String appkey; - - @Value("${hantu-openapi.appsecret}") - private String appsecret; - - @Value("${hantu-openapi.base-url}") - private String baseUrl; - - //NOTE 랭킹 데이터는 상대적 비교가 필요해서 전체적인 일관성이 필요함 -> 랭킹이 동일하더라도 프론트에서 받은 데이터를 정렬해서 표시 - @Override - @Retryable(maxAttempts=3, backoff = @Backoff(delay = 1000)) - public void initiateOverseasStockRank(){ - List stockList = stockRepository.findByCurrencyName("USD"); - String token = (String) redisTemplate.opsForValue().get("kis-access-token"); - int successCount = 0; - - if (token == null || token.trim().isEmpty()) { - log.error("KIS 액세스 토큰을 찾을 수 없습니다."); - throw new DomainException(DomainErrorCode.TOKEN_EMPTY); - } - - for(Stock stock : stockList){ - try { - processStockRankData(stock.getCode(), token); - successCount++; - }catch(Exception e){ - log.warn("주식 {} 처리 실패: {}", stock.getCode(), e.getMessage()); //로그 기록 남기기 - } - } - - //하나라도 성공을 못 시킬때만 재시작 - if(successCount == 0){ - log.error("순위 데이터 초기화 작업 중 모든 종목 처리 실패"); - throw new DomainException(DomainErrorCode.COMPLETE_INITIALIZATION_FAILURE); - } - - // 순위 데이터 생성 및 저장 - processStockRank(); - } - - //순위를 얻는데 필요한 데이터 조회 - @Transactional - public void processStockRankData(String code, String token) { - try { - String url = UriComponentsBuilder.fromHttpUrl(baseUrl) - .path("/uapi/overseas-price/v1/quotations/price-detail") - .queryParam("AUTH", "") - .queryParam("EXCD", "NAS") - .queryParam("SYMB", code) - .build() - .toUriString(); - - HttpHeaders headers = new HttpHeaders(); - headers.set("authorization", "Bearer " + token); - headers.set("appkey", appkey); - headers.set("appsecret", appsecret); - headers.set("tr_id", "HHDFS76200200"); - headers.set("custtype", "P"); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); - - if (response.getStatusCode().is2xxSuccessful()) { - OverseasStockRankDataWrapper wrapper = objectMapper.readValue(response.getBody(), OverseasStockRankDataWrapper.class); - overseasStockRankSaverService.saveStockRankData(code, wrapper); //db에 데이터 저장 - } else { - log.error("순위 관련 데이터 조회 실패: {} - 응답이 비어있음", code); - throw new DomainException(DomainErrorCode.API_RESPONSE_EMPTY); - } - - } catch (DomainException e) { - throw e; - } catch (JsonProcessingException e) { - log.error("JSON 파싱 실패: {} - {}", code, e.getMessage()); - throw new DomainException(DomainErrorCode.JSON_PARSING_ERROR); - } catch (ResourceAccessException e) { - log.error("API 연결 실패: {} - {}", code, e.getMessage()); - throw new DomainException(DomainErrorCode.API_CONNECTION_ERROR); - } catch (Exception e) { - log.error("예상치 못한 오류: {} - {}", code, e.getMessage()); - throw new DomainException(DomainErrorCode.INTERNAL_SERVER_ERROR); - } - } - - //순위 데이터 생성 및 저장 - private void processStockRank(){ - List stockRankList = stockRankRepository.findByCurrencyName("USD"); - - stockRankList.sort(Comparator.comparing(StockRank::getMarketCap).reversed()); - for (int i = 0; i < stockRankList.size(); i++) { - stockRankList.get(i).updateMarketCapRank(i + 1); - } - - stockRankList.sort(Comparator.comparing((StockRank sr) -> sr.getRocRate().abs()).thenComparing(StockRank::getRocRate).reversed()); - for (int i = 0; i < stockRankList.size(); i++) { - stockRankList.get(i).updateRocRank(i + 1); - } - - stockRankList.sort(Comparator.comparing(StockRank::getTradingVolume).reversed()); - for (int i = 0; i < stockRankList.size(); i++) { - stockRankList.get(i).updateTradingVolumeRank(i + 1); - } - - stockRankRepository.saveAll(stockRankList); - } -} \ No newline at end of file From cf61267d4cbd145dac9687becfb31a048671c25e Mon Sep 17 00:00:00 2001 From: mhee167 Date: Mon, 10 Nov 2025 22:31:56 +0900 Subject: [PATCH 05/38] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=EC=97=90?= =?UTF-8?q?=EB=8A=94=20=EA=B0=99=EC=9D=80=20=EC=84=9C=EB=B2=84=20=EB=82=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=A7=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9C=ED=96=89/=EA=B5=AC=EB=8F=85=20->=20Redis?= =?UTF-8?q?=20Pub/Sub=EC=9D=84=20=ED=86=B5=ED=95=B4=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EA=B0=84=20=ED=86=B5=EC=8B=A0=EC=9D=B4=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-websocket.yml | 3 +- .../alarm/config/RedisSubscribeConfig.java | 36 +++++++++++++++++++ .../domain/alarm/event/PriceAlertEvent.java | 17 --------- .../serviceImpl/PriceAlertEventListener.java | 29 +++++++++------ 4 files changed, 57 insertions(+), 28 deletions(-) create mode 100644 infra/src/main/java/com/fintory/infra/domain/alarm/config/RedisSubscribeConfig.java delete mode 100644 infra/src/main/java/com/fintory/infra/domain/alarm/event/PriceAlertEvent.java diff --git a/.github/workflows/deploy-websocket.yml b/.github/workflows/deploy-websocket.yml index 5b870ae3..39c612eb 100644 --- a/.github/workflows/deploy-websocket.yml +++ b/.github/workflows/deploy-websocket.yml @@ -77,7 +77,8 @@ jobs: export ECR_REGISTRY=${{ steps.login-ecr.outputs.registry }} export RDS_URL=${{ secrets.RDS_URL }} export RDS_USERNAME=${{ secrets.RDS_USERNAME }} - export RDS_PASSWORD=${{ secrets.RDS_PASSWORD }} + export RDS_PASSWORD=${{ secrets.RDS_PASSWORD }} + export AWS_REDIS_HOST=${{ secrets.FINTORY_CHILD_PRIVATE_HOST }} export AWS_REDIS_PASSWORD=${{ secrets.AWS_REDIS_PASSWORD }} export HANTU_APPKEY=${{ secrets.HANTU_APPKEY}} export HANTU_APPSECRET=${{ secrets.HANTU_APPSECRET}} diff --git a/infra/src/main/java/com/fintory/infra/domain/alarm/config/RedisSubscribeConfig.java b/infra/src/main/java/com/fintory/infra/domain/alarm/config/RedisSubscribeConfig.java new file mode 100644 index 00000000..b7898a8a --- /dev/null +++ b/infra/src/main/java/com/fintory/infra/domain/alarm/config/RedisSubscribeConfig.java @@ -0,0 +1,36 @@ +package com.fintory.infra.domain.alarm.config; + +import com.fintory.infra.domain.alarm.serviceImpl.PriceAlertEventListener; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; + +/** + * Spring이 Redis Pub/Sub을 자동으로 구독하도록 설정 => 누가 어떤 채널을 듣고 어떤 메소드로 처리하는지 정의 + */ +@Configuration +@RequiredArgsConstructor +public class RedisSubscribeConfig { + + private static final String PRICE_ALERT_CHANNEL = "price:alert:channel"; + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( //Redis로부터 메시지를 받아서 지정된 리스너에게 전달하는 도우미 백그라운드 쓰레드 풀 + RedisConnectionFactory connectionFactory, + MessageListenerAdapter listenerAdapter) { + + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(listenerAdapter, new ChannelTopic(PRICE_ALERT_CHANNEL)); //Redis 메시지가 들어올 때 자동으로 PriceAlertEventListener 호출 + return container; + } + + @Bean + public MessageListenerAdapter listenerAdapter(PriceAlertEventListener subscriber) { + return new MessageListenerAdapter(subscriber, "onMessage"); + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/fintory/infra/domain/alarm/event/PriceAlertEvent.java b/infra/src/main/java/com/fintory/infra/domain/alarm/event/PriceAlertEvent.java deleted file mode 100644 index 7a11f80f..00000000 --- a/infra/src/main/java/com/fintory/infra/domain/alarm/event/PriceAlertEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.fintory.infra.domain.alarm.event; - -import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; -import lombok.Getter; -import org.springframework.context.ApplicationEvent; - -@Getter -public class PriceAlertEvent extends ApplicationEvent { - - private final LiveStockPriceStream stockPriceStream; - - - public PriceAlertEvent(Object source, LiveStockPriceStream stockPriceStream) { - super(source); - this.stockPriceStream = stockPriceStream; - } -} diff --git a/infra/src/main/java/com/fintory/infra/domain/alarm/serviceImpl/PriceAlertEventListener.java b/infra/src/main/java/com/fintory/infra/domain/alarm/serviceImpl/PriceAlertEventListener.java index cbe09fe7..7b28c3e9 100644 --- a/infra/src/main/java/com/fintory/infra/domain/alarm/serviceImpl/PriceAlertEventListener.java +++ b/infra/src/main/java/com/fintory/infra/domain/alarm/serviceImpl/PriceAlertEventListener.java @@ -4,16 +4,15 @@ import com.fintory.domain.alarm.model.NotificationType; import com.fintory.domain.alarm.model.PriceAlert; import com.fintory.domain.alarm.service.AlarmService; -import com.fintory.infra.domain.alarm.event.PriceAlertEvent; +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; import com.fintory.infra.domain.alarm.repository.PriceAlertRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; +import org.springframework.data.redis.connection.MessageListener; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; - +import org.springframework.data.redis.connection.Message; import java.math.BigDecimal; import java.time.Duration; import java.util.List; @@ -21,20 +20,30 @@ @Component @RequiredArgsConstructor @Slf4j -public class PriceAlertEventListener { +public class PriceAlertEventListener implements MessageListener { private final RedisTemplate redisTemplate; private final PriceAlertRepository priceAlertRepository; private final AlarmService alarmService; - @Async("alertExecutor") - @EventListener + @Override + public void onMessage(Message message, byte[] pattern){ + try{ + LiveStockPriceStream stockPriceStream = (LiveStockPriceStream) redisTemplate.getValueSerializer() + .deserialize(message.getBody()); + log.debug("Redis로부터 감시가 체크 요청 수신: {}", stockPriceStream.code()); + handlePriceAlert(stockPriceStream); + } catch (Exception e) { + log.error("Redis 메시지 처리 실패", e); + } + } + @Transactional - public void handlePriceAlert(PriceAlertEvent event){ + public void handlePriceAlert(LiveStockPriceStream stockData){ - String stockCode = event.getStockPriceStream().code(); - BigDecimal currentPrice = event.getStockPriceStream().currentPrice(); + String stockCode = stockData.code(); + BigDecimal currentPrice = stockData.currentPrice(); String cachedKey = "priceAlert:"+stockCode; List priceAlertList = getPriceAlertFromCache(cachedKey,stockCode); From 86e9d37883b966beab2f03b1ba8ea5b8bffea166 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 00:45:57 +0900 Subject: [PATCH 06/38] =?UTF-8?q?refactor:=20=EC=84=9C=EB=B2=84-=ED=81=B4?= =?UTF-8?q?=EB=9D=BC,=20=EC=A6=9D=EA=B6=8C=EC=82=AC-=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/WebSocketClientConfig.java | 6 ++-- .../config/WebSocketInterceptor.java | 2 +- .../KoreanLiveStockPriceWebSocketHandler.java | 15 ++------- ...verseasLiveStockPriceWebSocketHandler.java | 32 +++++-------------- .../service/StockSubscriptionService.java | 20 ++++-------- .../service/WebSocketConnectionService.java | 21 ++++++------ .../config/WebSocketBrokerConfig.java | 11 +------ .../LiveStockPriceWebSocketSaverService.java | 2 +- .../LiveStockPriceWebSocketService.java | 13 +++----- .../service/MarketTimeService.java | 4 +-- .../service/StockDataBatchSaveService.java | 2 +- .../service/StockDataProcessService.java | 30 ++++++++--------- .../state/StockDataHolder.java | 3 +- 13 files changed, 56 insertions(+), 105 deletions(-) rename websocket/src/main/java/com/fintory/websocket/{ => provider}/config/WebSocketClientConfig.java (91%) rename websocket/src/main/java/com/fintory/websocket/{ => provider}/config/WebSocketInterceptor.java (96%) rename websocket/src/main/java/com/fintory/websocket/{ => provider}/handler/KoreanLiveStockPriceWebSocketHandler.java (92%) rename websocket/src/main/java/com/fintory/websocket/{ => provider}/handler/OverseasLiveStockPriceWebSocketHandler.java (91%) rename websocket/src/main/java/com/fintory/websocket/{ => provider}/service/StockSubscriptionService.java (81%) rename websocket/src/main/java/com/fintory/websocket/{ => provider}/service/WebSocketConnectionService.java (92%) rename websocket/src/main/java/com/fintory/websocket/{ => publisher}/config/WebSocketBrokerConfig.java (85%) rename websocket/src/main/java/com/fintory/websocket/{ => publisher}/service/LiveStockPriceWebSocketSaverService.java (99%) rename websocket/src/main/java/com/fintory/websocket/{ => publisher}/service/LiveStockPriceWebSocketService.java (90%) rename websocket/src/main/java/com/fintory/websocket/{ => publisher}/service/MarketTimeService.java (93%) rename websocket/src/main/java/com/fintory/websocket/{ => publisher}/service/StockDataBatchSaveService.java (97%) rename websocket/src/main/java/com/fintory/websocket/{ => publisher}/service/StockDataProcessService.java (83%) rename websocket/src/main/java/com/fintory/websocket/{ => publisher}/state/StockDataHolder.java (95%) diff --git a/websocket/src/main/java/com/fintory/websocket/config/WebSocketClientConfig.java b/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketClientConfig.java similarity index 91% rename from websocket/src/main/java/com/fintory/websocket/config/WebSocketClientConfig.java rename to websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketClientConfig.java index 85bae0d6..04af4ab0 100644 --- a/websocket/src/main/java/com/fintory/websocket/config/WebSocketClientConfig.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketClientConfig.java @@ -1,7 +1,7 @@ -package com.fintory.websocket.config; +package com.fintory.websocket.provider.config; -import com.fintory.websocket.handler.KoreanLiveStockPriceWebSocketHandler; -import com.fintory.websocket.handler.OverseasLiveStockPriceWebSocketHandler; +import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; +import com.fintory.websocket.provider.handler.OverseasLiveStockPriceWebSocketHandler; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/websocket/src/main/java/com/fintory/websocket/config/WebSocketInterceptor.java b/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketInterceptor.java similarity index 96% rename from websocket/src/main/java/com/fintory/websocket/config/WebSocketInterceptor.java rename to websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketInterceptor.java index d50a4af1..57db5ae3 100644 --- a/websocket/src/main/java/com/fintory/websocket/config/WebSocketInterceptor.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketInterceptor.java @@ -1,4 +1,4 @@ -package com.fintory.websocket.config; +package com.fintory.websocket.provider.config; import lombok.extern.slf4j.Slf4j; import org.springframework.http.server.ServerHttpRequest; diff --git a/websocket/src/main/java/com/fintory/websocket/handler/KoreanLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java similarity index 92% rename from websocket/src/main/java/com/fintory/websocket/handler/KoreanLiveStockPriceWebSocketHandler.java rename to websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java index 8bec9a0c..3ebbad5e 100644 --- a/websocket/src/main/java/com/fintory/websocket/handler/KoreanLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java @@ -1,4 +1,4 @@ -package com.fintory.websocket.handler; +package com.fintory.websocket.provider.handler; import com.fasterxml.jackson.databind.ObjectMapper; import com.fintory.common.exception.DomainErrorCode; @@ -68,14 +68,8 @@ public boolean waitForConnection(long timeoutSeconds){ @Override public void afterConnectionEstablished(WebSocketSession session) { this.session = session; - isConnected.set(true); - connectionLatch.countDown(); - log.info("국내 주식 웹소켓 연결 성공"); - log.info("세션 ID: {}", session.getId()); - log.info("웹소켓 handshake 헤더: {}", session.getHandshakeHeaders()); - log.info("웹소켓 URI: {}", session.getUri()); - log.info("웹소켓 로컬 주소: {}", session.getLocalAddress()); - log.info("웹소켓 리모트 주소: {}", session.getRemoteAddress()); + this.isConnected.set(true); + this.connectionLatch.countDown(); } @Override @@ -83,7 +77,6 @@ public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ this.session = null; isConnected.set(false); this.connectionLatch = new CountDownLatch(1); - log.warn("웹소켓 연결 종료 - Code: {}, Reason: {}", status.getCode(), status.getReason()); log.info("웹소켓 연결 종료"); } @@ -187,7 +180,6 @@ public void sendUnsubscribeMessage(String code) { //메시지를 받으면 실행되는 메소드 public void handleTextMessage(WebSocketSession session, TextMessage message){ String payload = message.getPayload(); - //log.info(payload); try{ String[] fields = payload.split("\\^"); if (fields.length < 40) return; @@ -207,7 +199,6 @@ public void handleTextMessage(WebSocketSession session, TextMessage message){ executeCallbacks(stockData); - log.debug("주식 데이터 처리 완료: {}", stockData); }catch(Exception e){ log.error("KIS Developer 실시간 현재가 조회 시 응답 받는 과정에서 에러 발생:{}",e.getMessage()); throw new DomainException(DomainErrorCode.WEBSOCKET_MESSAGE_PARSE_FAILED); diff --git a/websocket/src/main/java/com/fintory/websocket/handler/OverseasLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java similarity index 91% rename from websocket/src/main/java/com/fintory/websocket/handler/OverseasLiveStockPriceWebSocketHandler.java rename to websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java index b2f201cd..1dfb4265 100644 --- a/websocket/src/main/java/com/fintory/websocket/handler/OverseasLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java @@ -1,4 +1,4 @@ -package com.fintory.websocket.handler; +package com.fintory.websocket.provider.handler; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -52,6 +52,13 @@ public boolean isConnected() { return isConnected.get() && session != null && session.isOpen(); } + @Override + public void afterConnectionEstablished(WebSocketSession session) { + this.session = session; + this.isConnected.set(true); + this.connectionLatch.countDown(); + } + // 연결 대기 public boolean waitForConnection(long timeoutSeconds) { try { @@ -63,19 +70,6 @@ public boolean waitForConnection(long timeoutSeconds) { } } - @Override - public void afterConnectionEstablished(WebSocketSession session) { - this.session = session; - isConnected.set(true); - connectionLatch.countDown(); - log.info("해외 주식 웹소켓 연결 성공"); - log.info("세션 ID: {}", session.getId()); - log.info("웹소켓 handshake 헤더: {}", session.getHandshakeHeaders()); - log.info("웹소켓 URI: {}", session.getUri()); - log.info("웹소켓 로컬 주소: {}", session.getLocalAddress()); - log.info("웹소켓 리모트 주소: {}", session.getRemoteAddress()); - } - @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { this.session = null; @@ -129,7 +123,6 @@ public void sendSubscribeMessage(String code) { String message = objectMapper.writeValueAsString(request); session.sendMessage(new TextMessage(message)); - log.info("해외 주식 구독 메시지 전송 완료 - 종목: {}", code); } catch (Exception e) { log.error("DB API 실시간 현재가 데이터 조회 메시지 요청 중 에러 발생 - 종목: {}, 에러: {}", code, e.getMessage()); @@ -176,7 +169,6 @@ public void sendUnsubscribeMessage(String code) { String jsonMessage = objectMapper.writeValueAsString(request); session.sendMessage(new TextMessage(jsonMessage)); - log.info("해외 주식 구독 해제 메시지 전송 완료 - 종목: {}", code); } catch (Exception e) { log.error("DB API 실시간 현재가 데이터 구독 해제 요청 중 에러 발생 - 종목: {}, 에러: {}", code, e.getMessage()); @@ -189,9 +181,6 @@ public void sendUnsubscribeMessage(String code) { @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { String payload = message.getPayload(); - //log.info("수신된 payload: {}", payload); - - synchronized (sendLock) { try { JsonNode root = objectMapper.readTree(payload); @@ -216,9 +205,6 @@ private void parseAndProcessMessage(String payload) { try { JsonNode root = objectMapper.readTree(payload); - JsonNode header = root.get("header"); - - JsonNode body = root.get("body"); // 안전한 필드 추출 @@ -238,8 +224,6 @@ private void parseAndProcessMessage(String payload) { // 콜백 실행 executeCallbacks(stockData); - //log.debug("해외 주식 데이터 처리 완료: {}", stockData); - } catch (Exception e) { log.error("메시지 파싱 중 에러 발생: {}", e.getMessage()); throw new DomainException(DomainErrorCode.WEBSOCKET_MESSAGE_PARSE_FAILED); diff --git a/websocket/src/main/java/com/fintory/websocket/service/StockSubscriptionService.java b/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java similarity index 81% rename from websocket/src/main/java/com/fintory/websocket/service/StockSubscriptionService.java rename to websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java index bcec794c..6d682b8c 100644 --- a/websocket/src/main/java/com/fintory/websocket/service/StockSubscriptionService.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java @@ -1,20 +1,17 @@ -package com.fintory.websocket.service; +package com.fintory.websocket.provider.service; + -import com.fintory.common.exception.DomainErrorCode; -import com.fintory.common.exception.DomainException; -import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; import com.fintory.domain.stock.model.Stock; import com.fintory.infra.domain.stock.repository.StockRepository; -import com.fintory.websocket.handler.KoreanLiveStockPriceWebSocketHandler; -import com.fintory.websocket.handler.OverseasLiveStockPriceWebSocketHandler; -import com.fintory.websocket.state.StockDataHolder; +import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; +import com.fintory.websocket.provider.handler.OverseasLiveStockPriceWebSocketHandler; +import com.fintory.websocket.publisher.service.MarketTimeService; +import com.fintory.websocket.publisher.state.StockDataHolder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Consumer; + @Service @Slf4j @@ -33,7 +30,6 @@ public void startKoreanMarketSubscription(){ List targetStocks = stockRepository.findByCurrencyName("KRW"); if (!marketTimeService.isKoreanMarketOpen()) { - log.info("국내 장이 열려있지 않아 자동 구독 스킵"); return; } @@ -62,10 +58,8 @@ public void startOverseasMarketSubscription(){ List targetStocks = stockRepository.findByCurrencyName("USD"); if (!marketTimeService.isOverseasMarketOpen()) { - log.info("해외 장이 열려있지 않아 자동 구독 스킵"); return; } - connectionService.connectOverseasWebSocket(); int beforeSize = stockDataHolder.getOverseasSubscribedStocks().size(); diff --git a/websocket/src/main/java/com/fintory/websocket/service/WebSocketConnectionService.java b/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java similarity index 92% rename from websocket/src/main/java/com/fintory/websocket/service/WebSocketConnectionService.java rename to websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java index 8bec1c5a..97a9311b 100644 --- a/websocket/src/main/java/com/fintory/websocket/service/WebSocketConnectionService.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java @@ -1,12 +1,12 @@ -package com.fintory.websocket.service; +package com.fintory.websocket.provider.service; import com.fintory.common.exception.DomainErrorCode; import com.fintory.common.exception.DomainException; import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; -import com.fintory.websocket.handler.KoreanLiveStockPriceWebSocketHandler; -import com.fintory.websocket.handler.OverseasLiveStockPriceWebSocketHandler; -import com.fintory.websocket.state.StockDataHolder; -import lombok.RequiredArgsConstructor; +import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; +import com.fintory.websocket.provider.handler.OverseasLiveStockPriceWebSocketHandler; +import com.fintory.websocket.publisher.service.StockDataProcessService; +import com.fintory.websocket.publisher.state.StockDataHolder; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -19,6 +19,7 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.socket.client.WebSocketConnectionManager; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; @@ -60,12 +61,7 @@ public WebSocketConnectionService(StockDataHolder stockDataHolder, /* WebSocket 연결 관리 */ public void connectKoreanWebSocket() { - log.info("=== connectKoreanWebSocket 시작 ==="); - log.info("현재 연결 상태: {}", stockDataHolder.getIsKoreanConnected().get()); - log.info("Handler 연결 상태: {}", koreanHandler.isConnected()); - if (stockDataHolder.getIsKoreanConnected().get()) { - log.info("국내 주식 WebSocket이 이미 연결되어 있습니다."); return; } @@ -87,12 +83,11 @@ public void connectKoreanWebSocket() { log.info("국내 주식 WebSocket 연결 완료"); } + public void connectOverseasWebSocket() { if (stockDataHolder.getIsOverseasConnected().get()) { - log.info("해외 주식 WebSocket이 이미 연결되어 있습니다."); return; } - Consumer callback = dto -> stockDataProcessService.processStreamData(dto, stockDataHolder.getPreviousOverseasData(), stockDataHolder.getOverseasPendingData(), "해외"); @@ -110,6 +105,8 @@ public void connectOverseasWebSocket() { } + + public void disconnectKoreanWebSocket() { if (!stockDataHolder.getIsKoreanConnected().get()) return; diff --git a/websocket/src/main/java/com/fintory/websocket/config/WebSocketBrokerConfig.java b/websocket/src/main/java/com/fintory/websocket/publisher/config/WebSocketBrokerConfig.java similarity index 85% rename from websocket/src/main/java/com/fintory/websocket/config/WebSocketBrokerConfig.java rename to websocket/src/main/java/com/fintory/websocket/publisher/config/WebSocketBrokerConfig.java index 813ecbec..731a926b 100644 --- a/websocket/src/main/java/com/fintory/websocket/config/WebSocketBrokerConfig.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/config/WebSocketBrokerConfig.java @@ -1,4 +1,4 @@ -package com.fintory.websocket.config; +package com.fintory.websocket.publisher.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,15 +15,6 @@ @EnableWebSocketMessageBroker public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer { - // REVIEW 현재 dev를 pull 받으니까 순환 참조 문제 발생 - /* - private final TaskScheduler messageBrokerTaskScheduler; - - @Autowired - public WebSocketBrokerConfig(TaskScheduler webSocketTaskScheduler) { - this.messageBrokerTaskScheduler = webSocketTaskScheduler; - } -*/ @Bean(name = "webSocketTaskScheduler") public TaskScheduler messageBrokerTaskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); diff --git a/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketSaverService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketSaverService.java similarity index 99% rename from websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketSaverService.java rename to websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketSaverService.java index 492f339b..1664ebd3 100644 --- a/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketSaverService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketSaverService.java @@ -1,4 +1,4 @@ -package com.fintory.websocket.service; +package com.fintory.websocket.publisher.service; import com.fintory.common.exception.DomainErrorCode; import com.fintory.common.exception.DomainException; diff --git a/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java similarity index 90% rename from websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketService.java rename to websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java index 157fcb78..741198f0 100644 --- a/websocket/src/main/java/com/fintory/websocket/service/LiveStockPriceWebSocketService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java @@ -1,7 +1,9 @@ -package com.fintory.websocket.service; +package com.fintory.websocket.publisher.service; -import com.fintory.websocket.state.StockDataHolder; +import com.fintory.websocket.provider.service.StockSubscriptionService; +import com.fintory.websocket.provider.service.WebSocketConnectionService; +import com.fintory.websocket.publisher.state.StockDataHolder; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; @@ -24,7 +26,7 @@ public class LiveStockPriceWebSocketService { private final StockDataHolder stockDataHolder; private final StockSubscriptionService stockSubscriptionService; private final StockDataBatchSaveService stockDataBatchSaveService; - private final WebSocketConnectionService webSocketConnectionService; + private final WebSocketConnectionService webSocketConnectionService; private final MarketTimeService marketTimeService; /* 구독 자동 실행 메소드 */ @@ -62,7 +64,6 @@ public void initMarketSubscriptions() { @Scheduled(cron = "0 * 9-15 * * MON-FRI", zone = "Asia/Seoul") public void saveKoreanStockDataBatch() { if (!marketTimeService.isKoreanMarketOpen()) { - log.debug("국내 장 마감으로 인한 배치 저장 중단"); return; } stockDataBatchSaveService.saveBatchData("국내", stockDataHolder.getKoreanPendingData()); @@ -71,7 +72,6 @@ public void saveKoreanStockDataBatch() { @Scheduled(cron = "0 * 9-15 * * MON-FRI", zone = "America/New_York") public void saveOverseasStockDataBatch() { if (!marketTimeService.isOverseasMarketOpen()) { - log.debug("해외 장 마감으로 인한 배치 저장 중단"); return; } stockDataBatchSaveService.saveBatchData("해외", stockDataHolder.getOverseasPendingData()); @@ -80,7 +80,6 @@ public void saveOverseasStockDataBatch() { /* 스케줄링 - 장 마감 정리 */ @Scheduled(cron = "0 20 15 * * MON-FRI", zone = "Asia/Seoul") public void cleanUpAfterKoreanMarketClose() { - log.debug("국내 장 마감 - 마지막 데이터 저장 및 정리 시작"); stockDataBatchSaveService.saveRemainingData("국내", stockDataHolder.getKoreanPendingData()); webSocketConnectionService.disconnectKoreanWebSocket(); log.info("국내 장 마감 정리 완료"); @@ -88,7 +87,6 @@ public void cleanUpAfterKoreanMarketClose() { @Scheduled(cron = "0 0 16 * * MON-FRI", zone = "America/New_York") public void cleanUpAfterOverseasMarketClose() { - log.debug("해외 장 마감 - 마지막 데이터 저장 및 정리 시작"); stockDataBatchSaveService.saveRemainingData("해외", stockDataHolder.getOverseasPendingData()); webSocketConnectionService.disconnectOverseasWebSocket(); log.info("해외 장 마감 정리 완료"); @@ -97,7 +95,6 @@ public void cleanUpAfterOverseasMarketClose() { @PreDestroy public void cleanUp() { - log.info("애플리케이션 종료로 인한 WebSocket 연결 해제 시작"); try { // 남은 데이터 저장 stockDataBatchSaveService.saveRemainingData("국내", stockDataHolder.getKoreanPendingData()); diff --git a/websocket/src/main/java/com/fintory/websocket/service/MarketTimeService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/MarketTimeService.java similarity index 93% rename from websocket/src/main/java/com/fintory/websocket/service/MarketTimeService.java rename to websocket/src/main/java/com/fintory/websocket/publisher/service/MarketTimeService.java index 7d371e40..753943ef 100644 --- a/websocket/src/main/java/com/fintory/websocket/service/MarketTimeService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/MarketTimeService.java @@ -1,7 +1,7 @@ -package com.fintory.websocket.service; +package com.fintory.websocket.publisher.service; import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; -import com.fintory.websocket.state.StockDataHolder; +import com.fintory.websocket.publisher.state.StockDataHolder; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/websocket/src/main/java/com/fintory/websocket/service/StockDataBatchSaveService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataBatchSaveService.java similarity index 97% rename from websocket/src/main/java/com/fintory/websocket/service/StockDataBatchSaveService.java rename to websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataBatchSaveService.java index 755ca04e..c2d58ac6 100644 --- a/websocket/src/main/java/com/fintory/websocket/service/StockDataBatchSaveService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataBatchSaveService.java @@ -1,4 +1,4 @@ -package com.fintory.websocket.service; +package com.fintory.websocket.publisher.service; import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; import lombok.RequiredArgsConstructor; diff --git a/websocket/src/main/java/com/fintory/websocket/service/StockDataProcessService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java similarity index 83% rename from websocket/src/main/java/com/fintory/websocket/service/StockDataProcessService.java rename to websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java index 30eaad49..7d0d3c9e 100644 --- a/websocket/src/main/java/com/fintory/websocket/service/StockDataProcessService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java @@ -1,12 +1,11 @@ -package com.fintory.websocket.service; +package com.fintory.websocket.publisher.service; import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; -import com.fintory.infra.domain.alarm.event.PriceAlertEvent; import com.fintory.websocket.monitoring.config.WebSocketMetrics; import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; @@ -21,22 +20,22 @@ public class StockDataProcessService { private final WebSocketMetrics webSocketMetrics; private final SimpMessagingTemplate messageTemplate; private final Timer dataProcessingTime; - private final ApplicationEventPublisher applicationEventPublisher; private final LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService; + private final RedisTemplate redisTemplate; + private static final String PRICE_ALERT_CHANNEL = "price:alert:channel"; public StockDataProcessService(@Lazy WebSocketMetrics webSocketMetrics, SimpMessagingTemplate messageTemplate, MeterRegistry meterRegistry, - ApplicationEventPublisher applicationEventPublisher, - LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService) { + LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService, RedisTemplate redisTemplate) { this.webSocketMetrics = webSocketMetrics; this.messageTemplate = messageTemplate; this.dataProcessingTime = Timer.builder("websocket.data.processing.time") .description("Time to process and send stock data") .publishPercentiles(0.5,0.95,0.99) .register(meterRegistry); - this.applicationEventPublisher = applicationEventPublisher; this.liveStockPriceWebSocketSaverService = liveStockPriceWebSocketSaverService; + this.redisTemplate = redisTemplate; } //웹소켓으로 받은 데이터를 처리하는 메서드 @@ -48,23 +47,24 @@ public void processStreamData(LiveStockPriceStream dto, Timer.Sample sample = Timer.start(); try { - //이전 데이터와 비교하여 중복 체크 if (previous != null && previous.equals(dto)) { - log.debug("{} 주식 중복 데이터 스킵: {}", marketName, dto.code()); return; //똑같은 데이터면 무시 } - //새로운 데이터를 받으면 -> 감시가 이벤트 발행 - applicationEventPublisher.publishEvent( - new PriceAlertEvent(this, dto) - ); + // @EventListener는 같은 JVM 내에서만 동작함 -> 다른 통신 방법 필요 -> redis pub/sub 활용 + /* 알림 기능 -> 잠깐 미룬 상태 + try { + + redisTemplate.convertAndSend(PRICE_ALERT_CHANNEL, dto); + } catch (Exception e) { + log.error("Redis Pub/Sub 전송 실패: {}", dto.code(), e); + }*/ //스케쥴러 + 웹소켓 연결 시작하자마자 받은 데이터 값(첫 데이터) 저장 if (previous == null) { try { liveStockPriceWebSocketSaverService.saveStockData(dto); //DB에 바로 저장 - log.debug("{} 종목 {} 실시간 저장 완료", marketName, dto.code()); } catch (Exception e) { // 실패 시 배치 저장을 위해 pendingData에 보관 pendingData.put(dto.code(), dto); @@ -85,10 +85,8 @@ public void processStreamData(LiveStockPriceStream dto, public void sendStockData(String stockCode, Object stockData) { if (stockData instanceof LiveStockPriceStream stream) { if (stream.priceChange() == null || stream.priceChange().compareTo(BigDecimal.ZERO) == 0) { - log.debug("변동 없음 - 전송 스킵: {}", stockCode); return; } - // 지연 시간을 측정하기 위해 STOMP 헤더에 타임스탬프 추가 // REVIEW 헤더에 데이터를 추가한 것일 뿐 바디는 바뀌지 않으므로 프론트 코드에는 문제가 없는 것으로 알고 있는데 아니라면 수정 필수 SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(); diff --git a/websocket/src/main/java/com/fintory/websocket/state/StockDataHolder.java b/websocket/src/main/java/com/fintory/websocket/publisher/state/StockDataHolder.java similarity index 95% rename from websocket/src/main/java/com/fintory/websocket/state/StockDataHolder.java rename to websocket/src/main/java/com/fintory/websocket/publisher/state/StockDataHolder.java index 873900d7..7116dbcc 100644 --- a/websocket/src/main/java/com/fintory/websocket/state/StockDataHolder.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/state/StockDataHolder.java @@ -1,10 +1,9 @@ -package com.fintory.websocket.state; +package com.fintory.websocket.publisher.state; import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; import lombok.Data; -import lombok.Getter; import org.springframework.stereotype.Component; import java.util.Map; From f874696729da7dff5ff1ca0badec57f6ee513173 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 00:47:44 +0900 Subject: [PATCH 07/38] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=84=9C=EB=B2=84-=ED=81=B4=EB=9D=BC,?= =?UTF-8?q?=20=EC=A6=9D=EA=B6=8C=EC=82=AC-=EC=84=9C=EB=B2=84=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20=EC=9D=B4=ED=9B=84=20?= =?UTF-8?q?->=20=EC=9E=90=EB=8F=99=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=EB=90=9C=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/common/CommonStockControllerImpl.java | 2 +- .../domain/stock/dto/websocket/LiveStockPriceStream.java | 5 ++++- .../stock/service/korean/KoreanStockServiceImpl.java | 9 --------- .../stock/service/overseas/OverseasStockServiceImpl.java | 9 --------- .../websocket/monitoring/config/WebSocketMetrics.java | 2 +- 5 files changed, 6 insertions(+), 21 deletions(-) diff --git a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java index 9d4ef808..59f5384b 100644 --- a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java +++ b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java @@ -4,7 +4,7 @@ import com.fintory.domain.stock.dto.korean.response.StockSearchResponse; import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; import com.fintory.domain.stock.service.common.CommonStockService; -import com.fintory.websocket.service.MarketTimeService; +import com.fintory.websocket.publisher.service.MarketTimeService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java index c69197d6..f761858d 100644 --- a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java +++ b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.io.Serializable; import java.math.BigDecimal; @JsonIgnoreProperties(ignoreUnknown = true) @@ -10,4 +11,6 @@ public record LiveStockPriceStream( BigDecimal currentPrice, BigDecimal priceChange, BigDecimal priceChangeRate -){ } +) implements Serializable { + private static final long serialVersionUID = 1L; +} diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockServiceImpl.java index 580bad35..29b6da61 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockServiceImpl.java +++ b/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/KoreanStockServiceImpl.java @@ -6,9 +6,6 @@ import com.fintory.domain.stock.dto.korean.response.*; import com.fintory.domain.stock.model.Stock; import com.fintory.domain.stock.service.korean.*; -import com.fintory.infra.domain.stock.repository.LiveStockPriceRepository; -import com.fintory.infra.domain.stock.repository.StockPriceHistoryRepository; -import com.fintory.infra.domain.stock.repository.StockRankRepository; import com.fintory.infra.domain.stock.repository.StockRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,13 +23,8 @@ @RequiredArgsConstructor public class KoreanStockServiceImpl implements KoreanStockService { - private final KoreanStockRankService koreanStockRankService; private final KoreanLiveStockPriceService koreanLiveStockPriceService; private final KoreanStockPriceHistoryService koreanStockPriceHistoryService; - private final LiveStockPriceRepository liveStockPriceRepository; - private final StockPriceHistoryRepository stockPriceHistoryRepository; - - private final StockRankRepository stockRankRepository; private final StockRepository stockRepository; // 어플리케이션이 완전히 준비된 후 한번만 실행됨 @@ -109,7 +101,6 @@ public KoreanLiveStockPriceResponse getLiveStockPrice(String code) { private boolean executeWithErrorHandling(String taskName,Runnable task){ try{ task.run(); - log.info("{} 초기화 성공",taskName); return true; }catch(Exception e){ log.error("{} 초기화 실패 {}",taskName,e.getMessage()); diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockServiceImpl.java index d3a96a93..7ba899db 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockServiceImpl.java +++ b/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/OverseasStockServiceImpl.java @@ -5,8 +5,6 @@ import com.fintory.domain.stock.dto.overseas.response.*; import com.fintory.domain.stock.model.Stock; import com.fintory.domain.stock.service.overseas.*; -import com.fintory.infra.domain.stock.repository.StockPriceHistoryRepository; -import com.fintory.infra.domain.stock.repository.StockRankRepository; import com.fintory.infra.domain.stock.repository.StockRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -15,10 +13,8 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import java.math.BigDecimal; import java.util.Comparator; import java.util.List; -import java.util.function.Function; import java.util.stream.Collectors; @@ -27,13 +23,9 @@ @RequiredArgsConstructor public class OverseasStockServiceImpl implements OverseasStockService { - private final OverseasStockRankService overseasStockRankService; private final OverseasLiveStockPriceService overseasLiveStockPriceService; private final OverseasStockPriceHistoryService overseasStockPriceHistoryService; - - private final StockRankRepository stockRankRepository; private final StockRepository stockRepository; - private final StockPriceHistoryRepository stockPriceHistoryRepository; @EventListener(ApplicationReadyEvent.class) @@ -129,7 +121,6 @@ public OverseasLiveStockPriceResponse getLiveStockPrice(String code) { private boolean executeWithErrorHandling(String taskName, Runnable task){ try{ task.run(); - log.info("{} 초기화 성공",taskName); return true; }catch(Exception e){ log.error("{} 초기화 실패 {}",taskName,e.getMessage()); diff --git a/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java index d4c373a7..0f5aeb64 100644 --- a/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java +++ b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java @@ -1,6 +1,6 @@ package com.fintory.websocket.monitoring.config; -import com.fintory.websocket.service.LiveStockPriceWebSocketService; +import com.fintory.websocket.publisher.service.LiveStockPriceWebSocketService; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; From 3da425ef3ced88d8c19c2df3ce5d3fb1e81b773b Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 01:00:51 +0900 Subject: [PATCH 08/38] =?UTF-8?q?fix:=20predestroy=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=A0=84=EC=97=90=20redis=EA=B0=80=20=EB=8B=AB=ED=98=80?= =?UTF-8?q?=EB=B2=84=EB=A0=A4=EC=84=9C=20redisTemplate=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A4=91=20=EC=97=90=EB=9F=AC=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=20->=20Redis=20=EA=B4=80=EB=A0=A8=20cleanup=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=8A=A4=ED=82=B5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveStockPriceWebSocketService.java | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java index 741198f0..99e1f0f5 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.util.*; @@ -28,6 +29,7 @@ public class LiveStockPriceWebSocketService { private final StockDataBatchSaveService stockDataBatchSaveService; private final WebSocketConnectionService webSocketConnectionService; private final MarketTimeService marketTimeService; + private final RedisTemplate redisTemplate; /* 구독 자동 실행 메소드 */ @Scheduled(cron="0 30 09 * * MON-FRI", zone="America/New_York") @@ -100,12 +102,20 @@ public void cleanUp() { stockDataBatchSaveService.saveRemainingData("국내", stockDataHolder.getKoreanPendingData()); stockDataBatchSaveService.saveRemainingData("해외", stockDataHolder.getOverseasPendingData()); - // 웹소켓 연결 해제 - if (stockDataHolder.getIsKoreanConnected().get()) { - webSocketConnectionService.disconnectKoreanWebSocket(); - } - if (stockDataHolder.getIsOverseasConnected().get()) { - webSocketConnectionService.disconnectOverseasWebSocket(); + try { + if (redisTemplate.getConnectionFactory().getConnection().isClosed()) { + log.warn("Redis connection 이미 정리됨 - cleanup 작업 스킵하기"); + } else { + // 웹소켓 연결 해제 + if (stockDataHolder.getIsKoreanConnected().get()) { + webSocketConnectionService.disconnectKoreanWebSocket(); + } + if (stockDataHolder.getIsOverseasConnected().get()) { + webSocketConnectionService.disconnectOverseasWebSocket(); + } + } + } catch (Exception e) { + log.warn("레디스 이미 종료 완료. disconnect 작업 스킵하기", e); } // 최종 리소스 정리 -> (안전장치) From 1598640cdc5c442e563d35bfade9496b51fe4332 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 02:12:05 +0900 Subject: [PATCH 09/38] =?UTF-8?q?feature:=20websocket=20=EC=9A=A9=20build.?= =?UTF-8?q?gradle=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- websocket/build.gradle | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 websocket/build.gradle diff --git a/websocket/build.gradle b/websocket/build.gradle new file mode 100644 index 00000000..886ab1c8 --- /dev/null +++ b/websocket/build.gradle @@ -0,0 +1,74 @@ +plugins { + id 'java' + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} + +group = 'com.fintory' +version = '0.0.1-SNAPSHOT' + + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(":domain") + implementation project(":common") + implementation project(':infra') + + /* db */ + runtimeOnly 'com.mysql:mysql-connector-j' + + /* .env 자동 로딩 */ + implementation 'me.paulschwarz:spring-dotenv:4.0.0' + + /* jpa */ + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + /* lombok */ + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + /* webFlux */ + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + /* Jackson */ + implementation 'com.fasterxml.jackson.core:jackson-databind' + + /* Redis */ + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'io.lettuce:lettuce-core' + + /* WebSocket */ + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework:spring-messaging' + + /* Micrometer(Prometheus) */ + implementation("io.micrometer:micrometer-registry-prometheus:1.15.2") + +} + +test { + useJUnitPlatform() +} + +bootJar { + enabled = true +} + +jar { + enabled = true //다른 모듈이 참조할 수 있는 일반 jar로 변경함 +} \ No newline at end of file From 987fc74203b883e62ca96e7d7bb65aff438d3bbb Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 02:17:20 +0900 Subject: [PATCH 10/38] =?UTF-8?q?refactor:=20DockerFile=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- websocket/{Dockerfile-websocket => Dockerfile} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename websocket/{Dockerfile-websocket => Dockerfile} (100%) diff --git a/websocket/Dockerfile-websocket b/websocket/Dockerfile similarity index 100% rename from websocket/Dockerfile-websocket rename to websocket/Dockerfile From 29e6ade6ea8d74f56103c617167df260091c64a8 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 02:24:49 +0900 Subject: [PATCH 11/38] =?UTF-8?q?fix:=20ecr=20repository=EB=A5=BC=20fintor?= =?UTF-8?q?y=EB=A1=9C=20=ED=86=B5=EC=9D=BC,=20=ED=83=9C=EA=B7=B8=EB=A1=9C?= =?UTF-8?q?=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-websocket.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-websocket.yml b/.github/workflows/deploy-websocket.yml index 39c612eb..50090620 100644 --- a/.github/workflows/deploy-websocket.yml +++ b/.github/workflows/deploy-websocket.yml @@ -49,8 +49,8 @@ jobs: run: | ./gradlew :websocket:clean build docker build -t websocket:latest ./websocket - docker tag websocket:latest ${{ steps.login-ecr.outputs.registry }}/fintory-websocket:latest - docker push ${{ steps.login-ecr.outputs.registry }}/fintory-websocket:latest + docker tag websocket:latest ${{ steps.login-ecr.outputs.registry }}/fintory:websocket-latest + docker push ${{ steps.login-ecr.outputs.registry }}/fintory:websocket-latest deploy-websocket: needs: build-and-push @@ -90,7 +90,7 @@ jobs: aws ecr get-login-password --region ap-northeast-2 | \ docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }} - docker pull ${{ steps.login-ecr.outputs.registry }}/fintory-websocket:latest + docker pull ${{ steps.login-ecr.outputs.registry }}/fintory:websocket-latest docker compose down docker compose up -d From 2947c9706a66cbd0f81039332cfb5c4c78324722 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 02:26:46 +0900 Subject: [PATCH 12/38] =?UTF-8?q?fix:=20ecr=20repository=EB=A5=BC=20fintor?= =?UTF-8?q?y-child=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-websocket.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-websocket.yml b/.github/workflows/deploy-websocket.yml index 50090620..5e9774c5 100644 --- a/.github/workflows/deploy-websocket.yml +++ b/.github/workflows/deploy-websocket.yml @@ -49,8 +49,8 @@ jobs: run: | ./gradlew :websocket:clean build docker build -t websocket:latest ./websocket - docker tag websocket:latest ${{ steps.login-ecr.outputs.registry }}/fintory:websocket-latest - docker push ${{ steps.login-ecr.outputs.registry }}/fintory:websocket-latest + docker tag websocket:latest ${{ steps.login-ecr.outputs.registry }}/fintory-child:websocket-latest + docker push ${{ steps.login-ecr.outputs.registry }}/fintory-child:websocket-latest deploy-websocket: needs: build-and-push @@ -90,7 +90,7 @@ jobs: aws ecr get-login-password --region ap-northeast-2 | \ docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }} - docker pull ${{ steps.login-ecr.outputs.registry }}/fintory:websocket-latest + docker pull ${{ steps.login-ecr.outputs.registry }}/fintory-child:websocket-latest docker compose down docker compose up -d From 13705a06c88b78f066db7d8bade52bb93a457a88 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 02:54:37 +0900 Subject: [PATCH 13/38] =?UTF-8?q?fix:=20application.yml=20->=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=EB=AA=85=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- websocket/Dockerfile | 2 +- ...{application-websocket-deploy.yml => application-deploy.yml} | 0 .../{application-websocket-local.yml => application-local.yml} | 0 .../resources/{application-websocket.yml => application.yml} | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename websocket/src/main/resources/{application-websocket-deploy.yml => application-deploy.yml} (100%) rename websocket/src/main/resources/{application-websocket-local.yml => application-local.yml} (100%) rename websocket/src/main/resources/{application-websocket.yml => application.yml} (100%) diff --git a/websocket/Dockerfile b/websocket/Dockerfile index 4549d057..241a224c 100644 --- a/websocket/Dockerfile +++ b/websocket/Dockerfile @@ -8,6 +8,6 @@ ENV TZ=Asia/Seoul WORKDIR /app COPY build/libs/*.jar app.jar -COPY src/main/resources/application-websocket-deploy.yml /app/application-websocket-deploy.yml +COPY src/main/resources/application-deploy.yml /app/application-deploy.yml ENTRYPOINT ["java", "-jar","app.jar", "--spring.profiles.active=deploy"] diff --git a/websocket/src/main/resources/application-websocket-deploy.yml b/websocket/src/main/resources/application-deploy.yml similarity index 100% rename from websocket/src/main/resources/application-websocket-deploy.yml rename to websocket/src/main/resources/application-deploy.yml diff --git a/websocket/src/main/resources/application-websocket-local.yml b/websocket/src/main/resources/application-local.yml similarity index 100% rename from websocket/src/main/resources/application-websocket-local.yml rename to websocket/src/main/resources/application-local.yml diff --git a/websocket/src/main/resources/application-websocket.yml b/websocket/src/main/resources/application.yml similarity index 100% rename from websocket/src/main/resources/application-websocket.yml rename to websocket/src/main/resources/application.yml From 307d8dde6b25f10a4841864b6e904087062868c7 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 03:19:24 +0900 Subject: [PATCH 14/38] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stock/repository/StockRankRepository.java | 26 --------- .../saver/KoreanStockRankSaverService.java | 58 ------------------- .../saver/OverseasStockRankSaverService.java | 56 ------------------ 3 files changed, 140 deletions(-) delete mode 100644 infra/src/main/java/com/fintory/infra/domain/stock/repository/StockRankRepository.java delete mode 100644 infra/src/main/java/com/fintory/infra/domain/stock/service/korean/saver/KoreanStockRankSaverService.java delete mode 100644 infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/saver/OverseasStockRankSaverService.java diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/repository/StockRankRepository.java b/infra/src/main/java/com/fintory/infra/domain/stock/repository/StockRankRepository.java deleted file mode 100644 index 22b563a4..00000000 --- a/infra/src/main/java/com/fintory/infra/domain/stock/repository/StockRankRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.fintory.infra.domain.stock.repository; - -import com.fintory.domain.stock.model.StockRank; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface StockRankRepository extends JpaRepository { - Optional findByStockCode(String code); - - @Query("SELECT sr, lsp FROM StockRank sr JOIN FETCH sr.stock s JOIN LiveStockPrice lsp ON lsp.stock =s WHERE sr.stock.currencyName=:currencyName ORDER BY sr.marketCapRank ASC") - List findMarketCapTop20(String currencyName); - - @Query("SELECT sr, lsp FROM StockRank sr JOIN FETCH sr.stock s JOIN LiveStockPrice lsp ON lsp.stock = s WHERE sr.stock.currencyName=:currencyName ORDER BY sr.rocRank ASC") - List findROCTop20(String currencyName); - - @Query("SELECT sr, lsp FROM StockRank sr JOIN FETCH sr.stock s JOIN LiveStockPrice lsp ON lsp.stock = s WHERE sr.stock.currencyName=:currencyName ORDER BY sr.tradingVolumeRank ASC") - List findTradingVolumeTop20(String currencyName); - - @Query("SELECT sr FROM StockRank sr WHERE sr.stock.currencyName=:currencyName") - List findByCurrencyName(String currencyName); -} diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/saver/KoreanStockRankSaverService.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/saver/KoreanStockRankSaverService.java deleted file mode 100644 index 801d6ee7..00000000 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/korean/saver/KoreanStockRankSaverService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fintory.infra.domain.stock.service.korean.saver; - -import com.fintory.common.exception.DomainErrorCode; -import com.fintory.common.exception.DomainException; -import com.fintory.domain.stock.dto.korean.core.KoreanStockRankData; -import com.fintory.domain.stock.dto.korean.wrapper.KoreanStockRankDataWrapper; -import com.fintory.domain.stock.model.Stock; -import com.fintory.domain.stock.model.StockRank; -import com.fintory.infra.domain.stock.repository.StockRankRepository; -import com.fintory.infra.domain.stock.repository.StockRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Slf4j -@RequiredArgsConstructor -public class KoreanStockRankSaverService { - - - private final StockRepository stockRepository; - private final StockRankRepository stockRankRepository; - - //순위를 얻는데 필요한 데이터 저장 메서드 - @Transactional - public void saveStockRankData(String code, KoreanStockRankDataWrapper response) { - - if (response == null || response.output() == null) { - log.warn("순위 관련 데이터 응답이 비어있음: {}", code); - throw new DomainException(DomainErrorCode.API_RESPONSE_EMPTY); - } - - KoreanStockRankData item = response.output(); - - if (item == null) { - log.warn("순위 관련 응답에서 데이터를 찾을 수 없음"); - throw new DomainException(DomainErrorCode.STOCK_DATA_NOT_FOUND); - } - - StockRank stockRank = stockRankRepository.findByStockCode(code).orElse(null); - Stock stock = stockRepository.findByCode(code).orElseThrow(()->new DomainException(DomainErrorCode.STOCK_NOT_FOUND)); - - if (stockRank == null) { - stockRank = StockRank.builder() - .tradingVolume(item.tradingVolume()) - .rocRate(item.roc()) - .marketCap(item.marketCap()) - .stock(stock) - .build(); - } else { - stockRank.updateStockRankData(item.marketCap(), item.roc(), item.tradingVolume()); - } - - stockRankRepository.save(stockRank); - } - -} diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/saver/OverseasStockRankSaverService.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/saver/OverseasStockRankSaverService.java deleted file mode 100644 index 0f46b806..00000000 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/overseas/saver/OverseasStockRankSaverService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.fintory.infra.domain.stock.service.overseas.saver; - -import com.fintory.common.exception.DomainErrorCode; -import com.fintory.common.exception.DomainException; -import com.fintory.domain.stock.dto.overseas.core.OverseasStockRankData; -import com.fintory.domain.stock.dto.overseas.wrapper.OverseasStockRankDataWrapper; -import com.fintory.domain.stock.model.Stock; -import com.fintory.domain.stock.model.StockRank; -import com.fintory.infra.domain.stock.repository.StockRankRepository; -import com.fintory.infra.domain.stock.repository.StockRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Slf4j -public class OverseasStockRankSaverService { - - private final StockRankRepository stockRankRepository; - private final StockRepository stockRepository; - - //순위를 얻는데 필요한 데이터 저장 메서드 - @Transactional - public void saveStockRankData(String code, OverseasStockRankDataWrapper response) { - if (response == null || response.output() == null) { - log.warn("순위 관련 데이터 응답이 비어있음: {}", code); - throw new DomainException(DomainErrorCode.API_RESPONSE_EMPTY); - } - - OverseasStockRankData item = response.output(); - - if (item == null) { - log.warn("순위 관련 응답에서 데이터를 찾을 수 없음"); - throw new DomainException(DomainErrorCode.STOCK_DATA_NOT_FOUND); - } - - StockRank stockRank = stockRankRepository.findByStockCode(code).orElse(null); - Stock stock = stockRepository.findByCode(code).orElseThrow(() -> new DomainException(DomainErrorCode.STOCK_NOT_FOUND)); - - if (stockRank == null) { - stockRank = StockRank.builder() - .tradingVolume(item.tradingVolume()) - .rocRate(item.roc()) - .marketCap(item.marketCap()) - .stock(stock) - .build(); - } else { - stockRank.updateStockRankData(item.marketCap(), item.roc(), item.tradingVolume()); - } - - stockRankRepository.save(stockRank); - } - -} From c9be4ba253b6ce1c2e7dcf23175546090abc7d42 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 03:22:08 +0900 Subject: [PATCH 15/38] =?UTF-8?q?fix=20:=20=20EC2=20=EA=B0=84=20Bean=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B6=84=EB=A6=AC=20(Service/Rep?= =?UTF-8?q?ository=20=EC=A7=81=EC=A0=91=20=EC=B0=B8=EC=A1=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/WebsocketApplication.java | 4 ++++ .../service/StockSubscriptionService.java | 2 +- .../repository/LiveStockPriceRepository.java | 11 +++++++++ .../StockPriceHistoryRepository.java | 24 +++++++++++++++++++ .../publisher/repository/StockRepository.java | 15 ++++++++++++ .../LiveStockPriceWebSocketSaverService.java | 6 ++--- 6 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 websocket/src/main/java/com/fintory/websocket/publisher/repository/LiveStockPriceRepository.java create mode 100644 websocket/src/main/java/com/fintory/websocket/publisher/repository/StockPriceHistoryRepository.java create mode 100644 websocket/src/main/java/com/fintory/websocket/publisher/repository/StockRepository.java diff --git a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java index 1c879b69..d2da0416 100644 --- a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java +++ b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java @@ -2,6 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @@ -13,6 +14,9 @@ "com.fintory.common", "com.fintory.infra" }) +@EnableJpaRepositories(basePackages = { + "com.fintory.websocket.publisher.repository" +}) public class WebsocketApplication { public static void main(String[] args) { SpringApplication.run(WebsocketApplication.class, args); diff --git a/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java b/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java index 6d682b8c..e8bc6a3d 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java @@ -2,9 +2,9 @@ import com.fintory.domain.stock.model.Stock; -import com.fintory.infra.domain.stock.repository.StockRepository; import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; import com.fintory.websocket.provider.handler.OverseasLiveStockPriceWebSocketHandler; +import com.fintory.websocket.publisher.repository.StockRepository; import com.fintory.websocket.publisher.service.MarketTimeService; import com.fintory.websocket.publisher.state.StockDataHolder; import lombok.RequiredArgsConstructor; diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/repository/LiveStockPriceRepository.java b/websocket/src/main/java/com/fintory/websocket/publisher/repository/LiveStockPriceRepository.java new file mode 100644 index 00000000..3ee7a125 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/publisher/repository/LiveStockPriceRepository.java @@ -0,0 +1,11 @@ +package com.fintory.websocket.publisher.repository; + +import com.fintory.domain.stock.model.LiveStockPrice; +import com.fintory.domain.stock.model.Stock; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LiveStockPriceRepository extends JpaRepository { + Optional findByStock(Stock stock); +} diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/repository/StockPriceHistoryRepository.java b/websocket/src/main/java/com/fintory/websocket/publisher/repository/StockPriceHistoryRepository.java new file mode 100644 index 00000000..918080ec --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/publisher/repository/StockPriceHistoryRepository.java @@ -0,0 +1,24 @@ +package com.fintory.websocket.publisher.repository; + +import com.fintory.domain.stock.model.IntervalType; +import com.fintory.domain.stock.model.Stock; +import com.fintory.domain.stock.model.StockPriceHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface StockPriceHistoryRepository extends JpaRepository { + + + @Query("SELECT sph FROM StockPriceHistory sph WHERE sph.stock=:stock AND sph.intervalType=:intervalType AND sph.date=:now ORDER BY sph.updatedAt ASC LIMIT 1") + Optional findOldestByStockAndIntervalTypeAndDate(Stock stock, IntervalType intervalType, LocalDate now); + + void deleteByStockAndIntervalTypeAndDateBefore(Stock stock, IntervalType intervalType, LocalDate now); + + List findByStockAndIntervalTypeAndDate(Stock stock, IntervalType intervalType, LocalDate localDate); +} diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/repository/StockRepository.java b/websocket/src/main/java/com/fintory/websocket/publisher/repository/StockRepository.java new file mode 100644 index 00000000..f24cf28d --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/publisher/repository/StockRepository.java @@ -0,0 +1,15 @@ +package com.fintory.websocket.publisher.repository; + +import com.fintory.domain.stock.model.Stock; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface StockRepository extends JpaRepository { + Optional findByCode(String code); + + List findByCurrencyName(String krw); +} diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketSaverService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketSaverService.java index 1664ebd3..68745d6f 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketSaverService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketSaverService.java @@ -7,9 +7,9 @@ import com.fintory.domain.stock.model.LiveStockPrice; import com.fintory.domain.stock.model.Stock; import com.fintory.domain.stock.model.StockPriceHistory; -import com.fintory.infra.domain.stock.repository.LiveStockPriceRepository; -import com.fintory.infra.domain.stock.repository.StockPriceHistoryRepository; -import com.fintory.infra.domain.stock.repository.StockRepository; +import com.fintory.websocket.publisher.repository.LiveStockPriceRepository; +import com.fintory.websocket.publisher.repository.StockPriceHistoryRepository; +import com.fintory.websocket.publisher.repository.StockRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; From 7ce27f3f33c263491cfaa312de7dcda38b66fd86 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 11:10:07 +0900 Subject: [PATCH 16/38] =?UTF-8?q?fix=20:=20child=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=EC=84=9C=20websocket=20=EB=AA=A8=EB=93=88=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 + .../com/fintory/child/ChildApplication.java | 1 - .../common/CommonStockControllerImpl.java | 12 ++++++--- .../src/main/resources/application-deploy.yml | 6 ++++- .../src/main/resources/application-local.yml | 3 +++ .../websocket/WebsocketApplication.java | 25 ++++++++++++++++--- .../controller/MarketApiController.java | 20 +++++++++++++++ .../src/main/resources/application-local.yml | 4 +-- 8 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 websocket/src/main/java/com/fintory/websocket/publisher/controller/MarketApiController.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ac8d324a..90a0846e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -95,6 +95,7 @@ jobs: export OPENAI_MODEL=${{ secrets.OPENAI_MODEL}} export OPENAI_TEMPERATURE=${{secrets.OPENAI_TEMPERATURE}} export OPENAI_MAX_TOKENS=${{secrets.OPENAI_MAX_TOKENS}} + export WEBSOCKET_PRIVATE_HOST=${{secrets.WEBSOCKET_PRIVATE_HOST}} export FIREBASE_CONFIG='${{secrets.FIREBASE_CONFIG}}' aws ecr get-login-password --region ap-northeast-2 | \ diff --git a/app-child/src/main/java/com/fintory/child/ChildApplication.java b/app-child/src/main/java/com/fintory/child/ChildApplication.java index efee9b39..83c90930 100644 --- a/app-child/src/main/java/com/fintory/child/ChildApplication.java +++ b/app-child/src/main/java/com/fintory/child/ChildApplication.java @@ -16,7 +16,6 @@ "com.fintory.infra", "com.fintory.auth", "com.fintory.child", - "com.fintory.websocket" }) @ConfigurationPropertiesScan(basePackages = { "com.fintory.auth" diff --git a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java index 59f5384b..8ee96a95 100644 --- a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java +++ b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java @@ -4,13 +4,14 @@ import com.fintory.domain.stock.dto.korean.response.StockSearchResponse; import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; import com.fintory.domain.stock.service.common.CommonStockService; -import com.fintory.websocket.publisher.service.MarketTimeService; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; 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; +import org.springframework.web.client.RestTemplate; import java.util.List; @@ -20,7 +21,10 @@ public class CommonStockControllerImpl implements CommonStockController { private final CommonStockService commonStockService; - private final MarketTimeService marketTimeService; + private final RestTemplate restTemplate; + + @Value("${websocket.server.url}") + private String websocketServerUrl; //주식 종목 검색 @@ -35,7 +39,9 @@ public ResponseEntity>> searchStock(@Reque @Override @GetMapping("/opened-market") public ResponseEntity> getMarketStatus(){ - return ResponseEntity.ok(ApiResponse.ok(marketTimeService.getMarketStatus())); + String url = websocketServerUrl + "/api/websocket/market/status"; + MarketStatusResponse response = restTemplate.getForObject(url, MarketStatusResponse.class); + return ResponseEntity.ok(ApiResponse.ok(response)); } } diff --git a/app-child/src/main/resources/application-deploy.yml b/app-child/src/main/resources/application-deploy.yml index 1dc2c0f4..c7358024 100644 --- a/app-child/src/main/resources/application-deploy.yml +++ b/app-child/src/main/resources/application-deploy.yml @@ -96,4 +96,8 @@ eos: api-key: ${EOS_API_KEY} firebase: - config: ${FIREBASE_CONFIG} \ No newline at end of file + config: ${FIREBASE_CONFIG} + +websocket: + server: + url: http://${WEBSOCKET_PRIVATE_HOST}:8080 \ No newline at end of file diff --git a/app-child/src/main/resources/application-local.yml b/app-child/src/main/resources/application-local.yml index 707f6b63..f4ab3474 100644 --- a/app-child/src/main/resources/application-local.yml +++ b/app-child/src/main/resources/application-local.yml @@ -100,6 +100,9 @@ db-openapi: firebase: config: ${FIREBASE_CONFIG} +websocket: + server: + url: http://localhost:8081 diff --git a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java index d2da0416..32388546 100644 --- a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java +++ b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java @@ -2,6 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @@ -12,11 +15,25 @@ "com.fintory.websocket", "com.fintory.domain", "com.fintory.common", - "com.fintory.infra" -}) -@EnableJpaRepositories(basePackages = { - "com.fintory.websocket.publisher.repository" + "com.fintory.infra.config", + "com.fintory.infra.domain.stock.service.token" }) +@EntityScan(basePackages = "com.fintory.domain") +@EnableJpaRepositories( + basePackages = { + "com.fintory.websocket.publisher.repository", + "com.fintory.infra" + }, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + com.fintory.infra.domain.stock.repository.StockRepository.class, + com.fintory.infra.domain.stock.repository.StockPriceHistoryRepository.class, + com.fintory.infra.domain.stock.repository.LiveStockPriceRepository.class + } + ) +) + public class WebsocketApplication { public static void main(String[] args) { SpringApplication.run(WebsocketApplication.class, args); diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/controller/MarketApiController.java b/websocket/src/main/java/com/fintory/websocket/publisher/controller/MarketApiController.java new file mode 100644 index 00000000..f2f26d00 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/publisher/controller/MarketApiController.java @@ -0,0 +1,20 @@ +package com.fintory.websocket.publisher.controller; + +import com.fintory.domain.stock.dto.websocket.MarketStatusResponse; +import com.fintory.websocket.publisher.service.MarketTimeService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/websocket/market") +@RequiredArgsConstructor +public class MarketApiController { + private final MarketTimeService marketTimeService; + + @GetMapping("/status") + public MarketStatusResponse getMarketStatus() { + return marketTimeService.getMarketStatus(); + } +} diff --git a/websocket/src/main/resources/application-local.yml b/websocket/src/main/resources/application-local.yml index c3390794..1778e769 100644 --- a/websocket/src/main/resources/application-local.yml +++ b/websocket/src/main/resources/application-local.yml @@ -1,5 +1,5 @@ server: - port: 8080 + port: 8081 spring: config: @@ -24,7 +24,7 @@ spring: redis: host: ${AWS_REDIS_HOST} port: 6379 - password: ${AWS_REDIS_PASSWORD} + management: endpoints: From 9abb802feef7af12095a096f269dd53a53cc80de Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 14:00:35 +0900 Subject: [PATCH 17/38] =?UTF-8?q?fix=20:=20appcliation.yml=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=ED=99=9C=EC=9A=A9=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stock/controller/common/CommonStockControllerImpl.java | 2 +- app-child/src/main/resources/application-deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java index 8ee96a95..19a1a0a9 100644 --- a/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java +++ b/app-child/src/main/java/com/fintory/child/domain/stock/controller/common/CommonStockControllerImpl.java @@ -39,7 +39,7 @@ public ResponseEntity>> searchStock(@Reque @Override @GetMapping("/opened-market") public ResponseEntity> getMarketStatus(){ - String url = websocketServerUrl + "/api/websocket/market/status"; + String url = "http://"+websocketServerUrl+":8080" + "/api/websocket/market/status"; MarketStatusResponse response = restTemplate.getForObject(url, MarketStatusResponse.class); return ResponseEntity.ok(ApiResponse.ok(response)); } diff --git a/app-child/src/main/resources/application-deploy.yml b/app-child/src/main/resources/application-deploy.yml index c7358024..2c217dbc 100644 --- a/app-child/src/main/resources/application-deploy.yml +++ b/app-child/src/main/resources/application-deploy.yml @@ -100,4 +100,4 @@ firebase: websocket: server: - url: http://${WEBSOCKET_PRIVATE_HOST}:8080 \ No newline at end of file + url: ${WEBSOCKET_PRIVATE_HOST} \ No newline at end of file From 7e989e2f44bdafd30c31b5834aee35c4f188c65b Mon Sep 17 00:00:00 2001 From: mhee167 Date: Tue, 11 Nov 2025 14:29:23 +0900 Subject: [PATCH 18/38] =?UTF-8?q?fix=20:=20=EB=93=A4=EC=97=AC=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 90a0846e..80db38df 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -99,7 +99,7 @@ jobs: export FIREBASE_CONFIG='${{secrets.FIREBASE_CONFIG}}' aws ecr get-login-password --region ap-northeast-2 | \ - docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }} + docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }} docker pull ${{ steps.login-ecr.outputs.registry }}/fintory-child:latest From 9c091df72725424dd1b9b47330816c614dc79cdb Mon Sep 17 00:00:00 2001 From: mhee167 Date: Wed, 12 Nov 2025 00:49:12 +0900 Subject: [PATCH 19/38] =?UTF-8?q?fix=20:=20=EB=B9=88=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fintory/websocket/WebsocketApplication.java | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java index 32388546..6a318638 100644 --- a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java +++ b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java @@ -21,19 +21,9 @@ @EntityScan(basePackages = "com.fintory.domain") @EnableJpaRepositories( basePackages = { - "com.fintory.websocket.publisher.repository", - "com.fintory.infra" - }, - excludeFilters = @ComponentScan.Filter( - type = FilterType.ASSIGNABLE_TYPE, - classes = { - com.fintory.infra.domain.stock.repository.StockRepository.class, - com.fintory.infra.domain.stock.repository.StockPriceHistoryRepository.class, - com.fintory.infra.domain.stock.repository.LiveStockPriceRepository.class - } - ) + "com.fintory.websocket.publisher.repository" + } ) - public class WebsocketApplication { public static void main(String[] args) { SpringApplication.run(WebsocketApplication.class, args); From 3743bc709ca17156607b47e86057589b2fef66d0 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Wed, 12 Nov 2025 00:50:58 +0900 Subject: [PATCH 20/38] =?UTF-8?q?fix=20:=20WebSocket=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20=EC=8B=9C=20Redis=20=EC=97=B0=EA=B2=B0=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/WebSocketConnectionService.java | 7 +++---- .../service/LiveStockPriceWebSocketService.java | 13 ++++--------- .../websocket/publisher/state/StockDataHolder.java | 2 ++ 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java b/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java index 97a9311b..013eff46 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java @@ -142,13 +142,12 @@ public void disconnectOverseasWebSocket() { try { new ArrayList<>(stockDataHolder.getOverseasSubscribedStocks()).forEach(code -> { try { - overseasHandler.unsubscribe(code); + //overseasHandler.unsubscribe(code); stockDataHolder.getOverseasSubscribedStocks().remove(code); } catch (Exception e) { log.warn("해외 종목 {} 구독 해제 중 에러 발생: {}", code, e.getMessage()); } }); - Thread.sleep(1000); //서버 처리 대기 try { disconnectDBSession(); //db증권은 세션 정리를 하지 않을 경우 에러 발생함 @@ -156,7 +155,6 @@ public void disconnectOverseasWebSocket() { } catch (Exception e) { log.warn("세션 종료 실패 (무시): {}", e.getMessage()); // 에러 무시 } - } catch (Exception e) { log.error("구독 해제 중 에러: {}", e.getMessage()); } finally { @@ -177,7 +175,8 @@ public void disconnectDBSession(){ //ERROR LettuceConnectionFactory has been STOPPED. Use start() to initialize it HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("authorization", "Bearer " + (String) redisTemplate.opsForValue().get("db-access-token")); + String token = stockDataHolder.getCachedAccessToken(); + headers.set("authorization", "Bearer " +token); HttpEntity> entity = new HttpEntity<>(new HashMap<>(), headers); diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java index 99e1f0f5..8e22bf64 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.DependsOn; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -21,6 +22,7 @@ @Service @Slf4j @RequiredArgsConstructor +@DependsOn({"DBTokenIssueServiceImpl", "kisTokenIssueServiceImpl"}) public class LiveStockPriceWebSocketService { @@ -48,6 +50,7 @@ public void initMarketSubscriptions() { // 국내 장 체크 및 구독 if (marketTimeService.isKoreanMarketOpen()) { log.info("애플리케이션 시작 - 국내 장 열림, 자동 구독 시작"); + stockDataHolder.setCachedAccessToken((String) redisTemplate.opsForValue().get("kis-access-token")); stockSubscriptionService.startKoreanMarketSubscription(); } else { log.info("국내 장이 열려있지 않아 자동 구독 스킵"); @@ -56,6 +59,7 @@ public void initMarketSubscriptions() { // 해외 장 체크 및 구독 if (marketTimeService.isOverseasMarketOpen()) { log.info("애플리케이션 시작 - 해외 장 열림, 자동 구독 시작"); + stockDataHolder.setCachedAccessToken((String) redisTemplate.opsForValue().get("db-access-token")); stockSubscriptionService.startOverseasMarketSubscription(); } else { log.info("해외 장이 열려있지 않아 자동 구독 스킵"); @@ -101,11 +105,6 @@ public void cleanUp() { // 남은 데이터 저장 stockDataBatchSaveService.saveRemainingData("국내", stockDataHolder.getKoreanPendingData()); stockDataBatchSaveService.saveRemainingData("해외", stockDataHolder.getOverseasPendingData()); - - try { - if (redisTemplate.getConnectionFactory().getConnection().isClosed()) { - log.warn("Redis connection 이미 정리됨 - cleanup 작업 스킵하기"); - } else { // 웹소켓 연결 해제 if (stockDataHolder.getIsKoreanConnected().get()) { webSocketConnectionService.disconnectKoreanWebSocket(); @@ -113,10 +112,6 @@ public void cleanUp() { if (stockDataHolder.getIsOverseasConnected().get()) { webSocketConnectionService.disconnectOverseasWebSocket(); } - } - } catch (Exception e) { - log.warn("레디스 이미 종료 완료. disconnect 작업 스킵하기", e); - } // 최종 리소스 정리 -> (안전장치) stockDataHolder.getKoreanSubscribedStocks().clear(); diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/state/StockDataHolder.java b/websocket/src/main/java/com/fintory/websocket/publisher/state/StockDataHolder.java index 7116dbcc..c4544cb2 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/state/StockDataHolder.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/state/StockDataHolder.java @@ -29,5 +29,7 @@ public class StockDataHolder { private final AtomicBoolean isKoreanConnected = new AtomicBoolean(false); private final AtomicBoolean isOverseasConnected = new AtomicBoolean(false); + private String cachedAccessToken; + } From 58fb44c9aa03496fd65c00d0c9973993d6f68535 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Wed, 12 Nov 2025 01:12:05 +0900 Subject: [PATCH 21/38] =?UTF-8?q?fix=20:=20@EnableJpaAuditing=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/fintory/websocket/WebsocketApplication.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java index 6a318638..66e68560 100644 --- a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java +++ b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java @@ -3,8 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @@ -24,6 +23,7 @@ "com.fintory.websocket.publisher.repository" } ) +@EnableJpaAuditing public class WebsocketApplication { public static void main(String[] args) { SpringApplication.run(WebsocketApplication.class, args); From eb7fa262457fc4cd44e92c441893981404bc820b Mon Sep 17 00:00:00 2001 From: mhee167 Date: Wed, 12 Nov 2025 14:30:05 +0900 Subject: [PATCH 22/38] =?UTF-8?q?fix=20:=20actuator=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20+=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=A3=BC=EA=B8=B0=2023=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20->=208=EC=8B=9C=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/stock/service/token/KisTokenIssueServiceImpl.java | 3 ++- websocket/build.gradle | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java index 1181d7d8..e4ecd986 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java +++ b/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java @@ -39,6 +39,7 @@ public class KisTokenIssueServiceImpl implements KisTokenIssueService { @PostConstruct public void refreshKisToken() { try { + if(redisTemplate.opsForValue().get("kis-access-token")== null || redisTemplate.opsForValue().get("kis-websocket-access-token") == null || redisTemplate.getExpire("kis-access-token")<=0 || redisTemplate.getExpire("kis-websocket-access-token")<=0) { // REST API 토큰 발급 @@ -62,7 +63,7 @@ public void refreshKisToken() { } // 23시간마다 토큰 갱신 - @Scheduled(fixedRate = 82800000, initialDelay = 82800000) + @Scheduled(fixedRate = 28800000, initialDelay = 28800000) public void changeRefreshToken() { try { // REST API 토큰 갱신 diff --git a/websocket/build.gradle b/websocket/build.gradle index 886ab1c8..85f35116 100644 --- a/websocket/build.gradle +++ b/websocket/build.gradle @@ -59,6 +59,9 @@ dependencies { /* Micrometer(Prometheus) */ implementation("io.micrometer:micrometer-registry-prometheus:1.15.2") + /* Actuator */ + implementation 'org.springframework.boot:spring-boot-starter-actuator' + } test { From 393e3ab801f586855043d48baf1b1d826c848944 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Wed, 12 Nov 2025 15:34:38 +0900 Subject: [PATCH 23/38] =?UTF-8?q?fix=20:=20SecurityConfig=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20->=20=EA=B7=B8=EB=9D=BC=ED=8C=8C=EB=82=98=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=EC=84=9C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../token/KisTokenIssueServiceImpl.java | 1 + websocket/build.gradle | 3 + .../websocket/config/SecurityConfig.java | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 websocket/src/main/java/com/fintory/websocket/config/SecurityConfig.java diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java index e4ecd986..e9154174 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java +++ b/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java @@ -42,6 +42,7 @@ public void refreshKisToken() { if(redisTemplate.opsForValue().get("kis-access-token")== null || redisTemplate.opsForValue().get("kis-websocket-access-token") == null || redisTemplate.getExpire("kis-access-token")<=0 || redisTemplate.getExpire("kis-websocket-access-token")<=0) { + Thread.sleep(2000); //TODO 추후 수정 예정 -> 임시방편용 // REST API 토큰 발급 KisTokenResponse restToken = getNewKisToken(); log.info("REST API 토큰 발급 성공"); diff --git a/websocket/build.gradle b/websocket/build.gradle index 85f35116..d686f860 100644 --- a/websocket/build.gradle +++ b/websocket/build.gradle @@ -62,6 +62,9 @@ dependencies { /* Actuator */ implementation 'org.springframework.boot:spring-boot-starter-actuator' + /* Security */ + implementation 'org.springframework.boot:spring-boot-starter-security' + } test { diff --git a/websocket/src/main/java/com/fintory/websocket/config/SecurityConfig.java b/websocket/src/main/java/com/fintory/websocket/config/SecurityConfig.java new file mode 100644 index 00000000..985a8bd8 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/config/SecurityConfig.java @@ -0,0 +1,67 @@ +package com.fintory.websocket.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@DependsOn("liveStockPriceWebSocketService") +public class SecurityConfig { + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + + /** + * CORS 설정을 위한 CorsConfigurationSource 빈을 정의 + * JWT 인증을 위해 특정 헤더를 허용하도록 설정 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + + CorsConfiguration configuration = new CorsConfiguration(); + + // 허용할 프론트 도메인 설정 + configuration.setAllowedOriginPatterns(List.of( + "http://localhost:8081", + "https://localhost:8081", + "http://fintory.xyz", + "https://fintory.xyz" + )); + // 허용할 HTTP 메서드 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + // 자격 증명(쿠키, HTTP 인증 등) 허용 + configuration.setAllowCredentials(true); + // 모든 헤더 허용 + configuration.setAllowedHeaders(List.of("*")); + // 클라이언트가 접근할 수 있도록 노출할 응답 헤더 + configuration.setExposedHeaders(Arrays.asList("Authorization", "AccessToken", "RefreshToken")); + + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } + + +} From 0d613a579a8441e7a0e68520c6a6fe07d33516ff Mon Sep 17 00:00:00 2001 From: mhee167 Date: Thu, 13 Nov 2025 23:37:05 +0900 Subject: [PATCH 24/38] =?UTF-8?q?fix=20:=20dependson=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/stock/service/token/KisTokenIssueServiceImpl.java | 3 +-- .../publisher/service/LiveStockPriceWebSocketService.java | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java b/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java index e9154174..2a19f518 100644 --- a/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java +++ b/infra/src/main/java/com/fintory/infra/domain/stock/service/token/KisTokenIssueServiceImpl.java @@ -42,7 +42,6 @@ public void refreshKisToken() { if(redisTemplate.opsForValue().get("kis-access-token")== null || redisTemplate.opsForValue().get("kis-websocket-access-token") == null || redisTemplate.getExpire("kis-access-token")<=0 || redisTemplate.getExpire("kis-websocket-access-token")<=0) { - Thread.sleep(2000); //TODO 추후 수정 예정 -> 임시방편용 // REST API 토큰 발급 KisTokenResponse restToken = getNewKisToken(); log.info("REST API 토큰 발급 성공"); @@ -63,7 +62,7 @@ public void refreshKisToken() { } } - // 23시간마다 토큰 갱신 + // 8시간마다 토큰 갱신 @Scheduled(fixedRate = 28800000, initialDelay = 28800000) public void changeRefreshToken() { try { diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java index 8e22bf64..693caa6c 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java @@ -9,7 +9,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.DependsOn; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -22,7 +21,6 @@ @Service @Slf4j @RequiredArgsConstructor -@DependsOn({"DBTokenIssueServiceImpl", "kisTokenIssueServiceImpl"}) public class LiveStockPriceWebSocketService { From f3b7092a2f3fdfef1e2ff489aaa7ee5ae3c27034 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Fri, 14 Nov 2025 11:39:52 +0900 Subject: [PATCH 25/38] =?UTF-8?q?fix=20:=20redis=20replica=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../publisher/service/LiveStockPriceWebSocketService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java index 693caa6c..f6d7f881 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java @@ -1,6 +1,7 @@ package com.fintory.websocket.publisher.service; + import com.fintory.websocket.provider.service.StockSubscriptionService; import com.fintory.websocket.provider.service.WebSocketConnectionService; import com.fintory.websocket.publisher.state.StockDataHolder; @@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.DependsOn; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -21,6 +23,7 @@ @Service @Slf4j @RequiredArgsConstructor +@DependsOn({"DBTokenIssueServiceImpl", "kisTokenIssueServiceImpl"}) public class LiveStockPriceWebSocketService { From 3b381d5e33bca122b964b5e9ad045410fe0dfad7 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Fri, 14 Nov 2025 12:16:49 +0900 Subject: [PATCH 26/38] =?UTF-8?q?fix=20:=20redis=20replica=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80(=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../publisher/service/LiveStockPriceWebSocketService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java index f6d7f881..47a47e1a 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java @@ -23,7 +23,6 @@ @Service @Slf4j @RequiredArgsConstructor -@DependsOn({"DBTokenIssueServiceImpl", "kisTokenIssueServiceImpl"}) public class LiveStockPriceWebSocketService { From 978d9e20a773eb9615e25ba11c8a592da95c027e Mon Sep 17 00:00:00 2001 From: mhee167 Date: Sun, 16 Nov 2025 21:24:42 +0900 Subject: [PATCH 27/38] =?UTF-8?q?fix=20:=20websocket=20stomp=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-child/src/main/resources/application-deploy.yml | 3 --- websocket/src/main/resources/application-deploy.yml | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app-child/src/main/resources/application-deploy.yml b/app-child/src/main/resources/application-deploy.yml index 2c217dbc..c7d09f66 100644 --- a/app-child/src/main/resources/application-deploy.yml +++ b/app-child/src/main/resources/application-deploy.yml @@ -6,9 +6,6 @@ spring: activate: on-profile: deploy - websocket: - stomp: - stats-log-period: 30000 datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/websocket/src/main/resources/application-deploy.yml b/websocket/src/main/resources/application-deploy.yml index 0b70129a..6a17f530 100644 --- a/websocket/src/main/resources/application-deploy.yml +++ b/websocket/src/main/resources/application-deploy.yml @@ -26,6 +26,10 @@ spring: port: 6379 password: ${AWS_REDIS_PASSWORD} + websocket: + stomp: + stats-log-period: 30000 + management: endpoints: web: From 2c63ba5841927ef4064a2cba4eea32e7c51e9b3d Mon Sep 17 00:00:00 2001 From: mhee167 Date: Sun, 16 Nov 2025 21:25:38 +0900 Subject: [PATCH 28/38] =?UTF-8?q?fix=20:=20app-child=EC=97=90=EC=84=9C=20w?= =?UTF-8?q?ebsocket=20=EB=AA=A8=EB=93=88=20=EC=B0=B8=EC=A1=B0=20x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-child/build.gradle | 1 - websocket/build.gradle | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app-child/build.gradle b/app-child/build.gradle index c8004794..747a3959 100644 --- a/app-child/build.gradle +++ b/app-child/build.gradle @@ -26,7 +26,6 @@ dependencies { implementation project(':domain') implementation project(':infra') implementation project(':auth') - implementation project(":websocket") /* .env 자동 로딩 */ diff --git a/websocket/build.gradle b/websocket/build.gradle index d686f860..3fcc4ea5 100644 --- a/websocket/build.gradle +++ b/websocket/build.gradle @@ -76,5 +76,5 @@ bootJar { } jar { - enabled = true //다른 모듈이 참조할 수 있는 일반 jar로 변경함 + enabled = false } \ No newline at end of file From 98ce9ddb06091cb23ea8b8ecb0f8a3a8329803f7 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Sun, 16 Nov 2025 21:26:09 +0900 Subject: [PATCH 29/38] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../child/exceptionhandler/GlobalExceptionHandler.java | 2 +- .../handler/KoreanLiveStockPriceWebSocketHandler.java | 4 ---- .../handler/OverseasLiveStockPriceWebSocketHandler.java | 4 ---- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app-child/src/main/java/com/fintory/child/exceptionhandler/GlobalExceptionHandler.java b/app-child/src/main/java/com/fintory/child/exceptionhandler/GlobalExceptionHandler.java index 0860f253..436c5e77 100644 --- a/app-child/src/main/java/com/fintory/child/exceptionhandler/GlobalExceptionHandler.java +++ b/app-child/src/main/java/com/fintory/child/exceptionhandler/GlobalExceptionHandler.java @@ -67,7 +67,7 @@ public ResponseEntity handleHandlerMethodValidation(HandlerMe } @ExceptionHandler(Exception.class) - public ResponseEntity handleUnhandledException(Exception e, HttpServletRequest request) { + public ResponseEntity handleUnhandledException(Exception e) { log.error("Unknown server error", e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java index 3ebbad5e..bd8e7be5 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java @@ -44,10 +44,6 @@ public void setDataCallBack(Consumer dataCallBack) { this.dataCallBack = dataCallBack; } - public void setSaveCallBack(Consumer saveCallBack) { - this.saveCallBack = saveCallBack; - } - //연결 상태 확인 public boolean isConnected(){ diff --git a/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java index 1dfb4265..a2b9bd34 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java @@ -43,10 +43,6 @@ public void setDataCallBack(Consumer dataCallBack) { this.dataCallBack = dataCallBack; } - public void setSaveCallBack(Consumer saveCallBack) { - this.saveCallBack = saveCallBack; - } - // 연결 상태 확인 public boolean isConnected() { return isConnected.get() && session != null && session.isOpen(); From 5aaddeccf144953ef3a611c0004e983f884700b8 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Thu, 20 Nov 2025 02:31:21 +0900 Subject: [PATCH 30/38] =?UTF-8?q?refactor:=20webflux=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- websocket/build.gradle | 1 - .../main/java/com/fintory/websocket/WebsocketApplication.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/websocket/build.gradle b/websocket/build.gradle index 3fcc4ea5..c3d7575c 100644 --- a/websocket/build.gradle +++ b/websocket/build.gradle @@ -53,7 +53,6 @@ dependencies { implementation 'io.lettuce:lettuce-core' /* WebSocket */ - implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework:spring-messaging' /* Micrometer(Prometheus) */ diff --git a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java index 66e68560..af94834a 100644 --- a/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java +++ b/websocket/src/main/java/com/fintory/websocket/WebsocketApplication.java @@ -14,7 +14,7 @@ "com.fintory.websocket", "com.fintory.domain", "com.fintory.common", - "com.fintory.infra.config", + "com.fintory.infra.config", //TODO infra 모듈의 경우 로컬 실행을 위해서 남겨둠 -> 최종 때 삭제 예정 "com.fintory.infra.domain.stock.service.token" }) @EntityScan(basePackages = "com.fintory.domain") From 525fdbefab0ff4eb6ddcbc994158503462b63797 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Thu, 20 Nov 2025 02:42:25 +0900 Subject: [PATCH 31/38] =?UTF-8?q?refactor:=20=EC=95=84=EC=9B=83=EB=B0=94?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EC=A6=9D=EA=B6=8C=EC=82=AC=20WebSocket=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=EB=A5=BC=20WebFlu?= =?UTF-8?q?x=20Reactor=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/KoreanWebSocketConnection.java | 41 +++ .../config/OverseasWebSocketConnection.java | 53 ++++ .../config/WebSocketClientConfig.java | 50 ++-- .../KoreanLiveStockPriceWebSocketHandler.java | 268 +++++++---------- ...verseasLiveStockPriceWebSocketHandler.java | 282 ++++++++---------- .../service/WebSocketConnectionService.java | 91 +++--- 6 files changed, 388 insertions(+), 397 deletions(-) create mode 100644 websocket/src/main/java/com/fintory/websocket/provider/config/KoreanWebSocketConnection.java create mode 100644 websocket/src/main/java/com/fintory/websocket/provider/config/OverseasWebSocketConnection.java diff --git a/websocket/src/main/java/com/fintory/websocket/provider/config/KoreanWebSocketConnection.java b/websocket/src/main/java/com/fintory/websocket/provider/config/KoreanWebSocketConnection.java new file mode 100644 index 00000000..fe0e7ce9 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/provider/config/KoreanWebSocketConnection.java @@ -0,0 +1,41 @@ +package com.fintory.websocket.provider.config; + +import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.socket.client.WebSocketClient; +import reactor.core.Disposable; + +import java.net.URI; + +@Component +@RequiredArgsConstructor +@Slf4j +public class KoreanWebSocketConnection { + private final WebSocketClient client; + private final KoreanLiveStockPriceWebSocketHandler koreanLiveStockPriceWebSocketHandler; + private Disposable connection; + + private static final String WS_URL = "ws://ops.koreainvestment.com:21000/tryitout/H0STCNT0"; + + public void connect(){ + if (connection != null && !connection.isDisposed()) { + log.warn("한국 투자 증권 WebSocket이 이미 연결되어 있습니다."); + return; + } + + connection = client.execute(URI.create(WS_URL), koreanLiveStockPriceWebSocketHandler) + .subscribe(null, error->{ + log.error("한국 투자 증권 WebSocket 연결 에러: {}", error.getMessage()); //TODO 재연결 로직 추가 + }, ()-> log.info("한국 투자 증권 WebSocket 연결 종료")); + } + + public void disconnect(){ + if (connection != null && !connection.isDisposed()) { + connection.dispose(); + log.info("한국투자증권 WebSocket 연결 해제"); + } + } + +} diff --git a/websocket/src/main/java/com/fintory/websocket/provider/config/OverseasWebSocketConnection.java b/websocket/src/main/java/com/fintory/websocket/provider/config/OverseasWebSocketConnection.java new file mode 100644 index 00000000..3ac5e69f --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/provider/config/OverseasWebSocketConnection.java @@ -0,0 +1,53 @@ +package com.fintory.websocket.provider.config; + +import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; +import com.fintory.websocket.provider.handler.OverseasLiveStockPriceWebSocketHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.socket.client.WebSocketClient; +import reactor.core.Disposable; + +import java.net.URI; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OverseasWebSocketConnection { + + private final WebSocketClient client; + private final OverseasLiveStockPriceWebSocketHandler overseasLiveStockPriceWebSocketHandler; + private Disposable connection; + + private static final String WS_URL = "wss://openapi.dbsec.co.kr:7070/websocket"; + + public void connect() { + if (connection != null && !connection.isDisposed()) { + log.warn("DB증권 WebSocket이 이미 연결되어 있습니다."); + return; + } + + connection = client.execute( + URI.create(WS_URL), + overseasLiveStockPriceWebSocketHandler + ).subscribe( + null, + error -> { + log.error("DB증권 WebSocket 연결 에러: {}", error.getMessage()); + // 재연결 로직 추가 가능 + }, + () -> log.info("DB증권 WebSocket 연결 종료") + ); + } + + public void disconnect() { + if (connection != null && !connection.isDisposed()) { + connection.dispose(); + log.info("DB증권 WebSocket 연결 해제"); + } + } + + public boolean isConnected() { + return connection != null && !connection.isDisposed(); + } +} \ No newline at end of file diff --git a/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketClientConfig.java b/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketClientConfig.java index 04af4ab0..111e65c7 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketClientConfig.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketClientConfig.java @@ -2,50 +2,40 @@ import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; import com.fintory.websocket.provider.handler.OverseasLiveStockPriceWebSocketHandler; -import org.springframework.beans.factory.annotation.Qualifier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.web.socket.client.WebSocketClient; -import org.springframework.web.socket.client.WebSocketConnectionManager; -import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient; +import org.springframework.web.reactive.socket.client.WebSocketClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; // 서버에서 외부 금융 API 서버로 WebSocket 연결을 위한 클라이언트 설정 코드 @Configuration +@RequiredArgsConstructor +@Slf4j public class WebSocketClientConfig { - @Bean - @Qualifier("overseasLiveStockPriceWebSocketConnectionManager") - public WebSocketConnectionManager overseasLiveStockPriceWebSocketConnectionManager( @Qualifier("overseasWebSocketClient") WebSocketClient overseasClient, OverseasLiveStockPriceWebSocketHandler overseasLiveStockPriceWebSocketHandler){ - WebSocketConnectionManager webSocketConnectionManager = new WebSocketConnectionManager( - overseasClient, - overseasLiveStockPriceWebSocketHandler, - "wss://openapi.dbsec.co.kr:7070/websocket" - ); - webSocketConnectionManager.setAutoStartup(false); - return webSocketConnectionManager; - } + private final KoreanLiveStockPriceWebSocketHandler koreanLiveStockPriceWebSocketHandler; + private final OverseasLiveStockPriceWebSocketHandler overseasLiveStockPriceWebSocketHandler; @Bean - @Primary - @Qualifier("koreanLiveStockPriceWebSocketConnectionManager") - public WebSocketConnectionManager koreanLiveStockPriceWebSocketConnectionManager(@Qualifier("koreanWebSocketClient") WebSocketClient koreanClient, KoreanLiveStockPriceWebSocketHandler koreanLiveStockPriceWebSocketHandler){ - WebSocketConnectionManager webSocketCConnectionManager = new WebSocketConnectionManager( - koreanClient, - koreanLiveStockPriceWebSocketHandler, - "ws://ops.koreainvestment.com:21000/tryitout/H0STCNT0" - ); - webSocketCConnectionManager.setAutoStartup(false); - return webSocketCConnectionManager; + public WebSocketClient reactorNettyWebSocketClient() { + HttpClient httpClient = HttpClient.create() + .responseTimeout(Duration.ofSeconds(10)); + + return new ReactorNettyWebSocketClient(httpClient); } @Bean - public WebSocketClient overseasWebSocketClient() { - return new StandardWebSocketClient(); + public KoreanWebSocketConnection koreanWebSocketConnection(WebSocketClient client) { + return new KoreanWebSocketConnection(client, koreanLiveStockPriceWebSocketHandler); } @Bean - public WebSocketClient koreanWebSocketClient() { - return new StandardWebSocketClient(); + public OverseasWebSocketConnection overseasWebSocketConnection(WebSocketClient client) { + return new OverseasWebSocketConnection(client, overseasLiveStockPriceWebSocketHandler); } } diff --git a/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java index bd8e7be5..d1b10e89 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java @@ -8,36 +8,27 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; +import org.springframework.web.reactive.socket.WebSocketHandler; +import org.springframework.web.reactive.socket.WebSocketMessage; +import org.springframework.web.reactive.socket.WebSocketSession; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import java.math.BigDecimal; +import java.time.Duration; import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @Service @Slf4j @RequiredArgsConstructor -public class KoreanLiveStockPriceWebSocketHandler extends TextWebSocketHandler { - - private volatile WebSocketSession session; - private final AtomicBoolean isConnected = new AtomicBoolean(false); +public class KoreanLiveStockPriceWebSocketHandler implements WebSocketHandler { + private final RedisTemplate redisTemplate; private Consumer dataCallBack; - private Consumer saveCallBack; - private final ObjectMapper objectMapper; - private final RedisTemplate redisTemplate; - - private volatile CountDownLatch connectionLatch = new CountDownLatch(1); - - private final Object sendLock = new Object(); - + private WebSocketSession session; // 데이터를 수신받을 때마다 호출되는 콜백 함수 public void setDataCallBack(Consumer dataCallBack) { @@ -45,140 +36,105 @@ public void setDataCallBack(Consumer dataCallBack) { } - //연결 상태 확인 - public boolean isConnected(){ - return isConnected.get() && session != null && session.isOpen(); + @Override + public Mono handle(WebSocketSession session) { + this.session = session; + return session.receive() + .doOnNext(msg -> log.info("Raw WebSocket 메시지 수신")) + .map(WebSocketMessage::getPayloadAsText) + .doOnNext(message->log.info("메시지: {}",message)) + .flatMap(this::parseAndProcessMessage) + .doOnNext(message->log.info("국내 주식 메시지: {}",message)) + .doOnError(e -> log.error("국내 주식 구독 메시지 처리 중 에러:{}", e.getMessage())) + .onErrorResume(e -> Mono.empty()) + .doOnTerminate(()->{ + this.session=null; + log.info("한국투자증권 WebSocket 연결 종료"); + }) + .then(); } - //timeoutSeconds만큼 wait하도록 하는 동기화 함수 - public boolean waitForConnection(long timeoutSeconds){ - try{ - return connectionLatch.await(timeoutSeconds, TimeUnit.SECONDS); - }catch(InterruptedException e){ - Thread.currentThread().interrupt(); - log.warn("웹소켓 연결 대기 중 인터럽트 발생"); - return false; + public void subscribe(String code) { + if (session == null || !session.isOpen()) { + log.warn("WebSocket이 연결되지 않음 - 종목: {}", code); + return; } - } - @Override - public void afterConnectionEstablished(WebSocketSession session) { - this.session = session; - this.isConnected.set(true); - this.connectionLatch.countDown(); + createSubscribeMessage(code, "1") + .flatMap(message -> session.send(Mono.just(session.textMessage(message)))) + .delayElement(Duration.ofSeconds(5)) + .onErrorResume(e -> { + log.error("국내 주식 구독 실패 :{}", code); + return Mono.empty(); + }) + .subscribe(); } + public void unsubscribe(String code) { + if (session == null || !session.isOpen()) { + log.warn("WebSocket이 연결되지 않음 - 종목: {}", code); + return; + } - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status){ - this.session = null; - isConnected.set(false); - this.connectionLatch = new CountDownLatch(1); - log.info("웹소켓 연결 종료"); + createSubscribeMessage(code, "2") + .flatMap(message -> session.send(Mono.just(session.textMessage(message)))) + .onErrorResume(e -> { + log.warn("구독 해제 실패: {}", code); + return Mono.empty(); + }) + .subscribe(); } - //구독 메시지 전달 - public void subscribe(String code){ - try { - sendSubscribeMessage(code); - Thread.sleep(200); - }catch (InterruptedException e){ - Thread.currentThread().interrupt(); + private Mono createSubscribeMessage(String code, String trType) { + return Mono.fromCallable(()->{ + String approvalKey = (String) redisTemplate.opsForValue().get("kis-websocket-access-token"); - } - } + if (approvalKey == null || approvalKey.isEmpty()) { + log.error("Redis에서 KIS 토큰을 찾을 수 없습니다."); + throw new DomainException(DomainErrorCode.TOKEN_NOT_FOUND); + } - public void sendSubscribeMessage(String code) { - if (!isConnected()) { - log.warn("웹소켓이 연결되지 않아 구독 메시지를 보낼 수 없습니다. 종목: {}", code); - throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); - } + Map message = Map.of( + "header", Map.of( + "approval_key", approvalKey, + "custtype", "P", + "tr_type", trType, + "content-type", "utf-8" + ), + "body", Map.of( + "input", Map.of( + "tr_id", "H0STCNT0", + "tr_key", code + ) + ) - synchronized (sendLock) { - try { - - String approvalKey = (String) redisTemplate.opsForValue().get("kis-websocket-access-token"); - - if (approvalKey == null || approvalKey.isEmpty()) { - log.error("Redis에서 KIS 토큰을 찾을 수 없습니다."); - throw new DomainException(DomainErrorCode.TOKEN_NOT_FOUND); - } - - Map message = Map.of( - "header", Map.of( - "approval_key", approvalKey, - "custtype", "P", - "tr_type", "1", - "content-type", "utf-8" - ), - "body", Map.of( - "input", Map.of( - "tr_id", "H0STCNT0", - "tr_key", code - ) - ) - - ); - - String jsonMessage = objectMapper.writeValueAsString(message); - session.sendMessage(new TextMessage(jsonMessage)); - } catch (Exception e) { - log.error("KIS Developer 실시간 현재가 조회 시 요청 보내는 과정에서 에러 발생:{}", e.getMessage()); - throw new DomainException(DomainErrorCode.WEBSOCKET_SEND_FAILED); - } - } - } + ); + return objectMapper.writeValueAsString(message); + }) + .subscribeOn(Schedulers.boundedElastic()); - // 구독 취소 요청 - public void unsubscribe(String code){ - sendUnsubscribeMessage(code); } - public void sendUnsubscribeMessage(String code) { - synchronized (sendLock) { - try { - if (session == null || !session.isOpen()) { - log.warn("WebSocket 세션이 닫혀있어 구독 해제 메시지를 보낼 수 없습니다. 종목: {}", code); - return; // 예외를 던지지 않고 그냥 리턴 - } - - String approvalKey = (String) redisTemplate.opsForValue().get("kis-websocket-access-token"); - - if (approvalKey == null || approvalKey.isEmpty()) { - log.error("Redis에서 KIS 토큰을 찾을 수 없습니다."); - throw new DomainException(DomainErrorCode.TOKEN_NOT_FOUND); - } - - Map message = Map.of( - "header", Map.of( - "approval_key", approvalKey, - "custtype", "P", - "tr_type", "2", - "content-type", "utf-8" - ), - "body", Map.of( - "input", Map.of( - "tr_id", "H0STCNT0", - "tr_key", code - ) - ) - - ); - String jsonMessage = objectMapper.writeValueAsString(message); - session.sendMessage(new TextMessage(jsonMessage)); - } catch (Exception e) { - log.error("KIS Developer 실시간 현재가 조회 세션 close시 요청 보내는 과정에서 에러 발생:{}", e.getMessage()); - throw new DomainException(DomainErrorCode.WEBSOCKET_SEND_FAILED); - } - } + private Mono parseAndProcessMessage(String payload) { + return Mono.fromCallable(() -> { + return parseStockData(payload); + }) + .flatMap(stockData -> { + if (stockData == null) return Mono.empty(); + // 콜백 실행 + return executeCallbacks(stockData); + }) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorResume(e -> { + log.error("구독 메시지 파싱 실패: {} ", e.getMessage()); + return Mono.empty(); + }); } - //메시지를 받으면 실행되는 메소드 - public void handleTextMessage(WebSocketSession session, TextMessage message){ - String payload = message.getPayload(); - try{ - String[] fields = payload.split("\\^"); - if (fields.length < 40) return; + private LiveStockPriceStream parseStockData(String body) { + try { + String[] fields = body.split("\\^"); + if (fields.length < 40) return null; String codeField = fields[0].trim(); String code = codeField.contains("|") @@ -193,14 +149,15 @@ public void handleTextMessage(WebSocketSession session, TextMessage message){ code, price, change, changePercent ); - executeCallbacks(stockData); + return stockData; - }catch(Exception e){ - log.error("KIS Developer 실시간 현재가 조회 시 응답 받는 과정에서 에러 발생:{}",e.getMessage()); + } catch (Exception e) { + log.error("KIS Developer 실시간 현재가 조회 시 응답 받는 과정에서 에러 발생:{}", e.getMessage()); throw new DomainException(DomainErrorCode.WEBSOCKET_MESSAGE_PARSE_FAILED); } } + private BigDecimal parseBigDecimal(String value){ try{ return new BigDecimal(value); @@ -211,28 +168,17 @@ private BigDecimal parseBigDecimal(String value){ } - private void executeCallbacks(LiveStockPriceStream stockData) { - try { - if (dataCallBack != null) { - dataCallBack.accept(stockData); - } - } catch (Exception e) { - log.error("데이터 콜백 실행 중 에러: {}", e.getMessage()); - } - - try { - if (saveCallBack != null) { - saveCallBack.accept(stockData); - } - } catch (Exception e) { - log.error("저장 콜백 실행 중 에러: {}", e.getMessage()); - } + private Mono executeCallbacks(LiveStockPriceStream stockData) { + return Mono.fromRunnable(() -> { + if (dataCallBack != null) { + dataCallBack.accept(stockData); + } + }) + .doOnError(e -> log.error("국내 주식 데이터 콜백 실행 중 에러 :{}", e.getMessage())) + .onErrorResume(e -> Mono.empty()) + .then(); } - - @Override - public void handleTransportError(WebSocketSession session, Throwable exception) { - log.error("국내 주식 웹소켓 전송 에러 발생: {}", exception.getMessage()); - isConnected.set(false); + public boolean isConnected(){ + return session != null && session.isOpen(); } - } \ No newline at end of file diff --git a/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java index a2b9bd34..c4ba296d 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java @@ -9,196 +9,160 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; +import org.springframework.web.reactive.socket.WebSocketHandler; +import org.springframework.web.reactive.socket.WebSocketMessage; +import org.springframework.web.reactive.socket.WebSocketSession; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import java.math.BigDecimal; +import java.time.Duration; import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @Service @Slf4j @RequiredArgsConstructor -public class OverseasLiveStockPriceWebSocketHandler extends TextWebSocketHandler { - - private volatile WebSocketSession session; - private final AtomicBoolean isConnected = new AtomicBoolean(false); +public class OverseasLiveStockPriceWebSocketHandler implements WebSocketHandler { private Consumer dataCallBack; - private Consumer saveCallBack; private final ObjectMapper objectMapper; private final RedisTemplate redisTemplate; - private volatile CountDownLatch connectionLatch = new CountDownLatch(1); - private final Object sendLock = new Object(); // 동기화용 락 + private WebSocketSession session; // 콜백 함수 설정 public void setDataCallBack(Consumer dataCallBack) { this.dataCallBack = dataCallBack; } - // 연결 상태 확인 - public boolean isConnected() { - return isConnected.get() && session != null && session.isOpen(); - } @Override - public void afterConnectionEstablished(WebSocketSession session) { + public Mono handle(WebSocketSession session) { this.session = session; - this.isConnected.set(true); - this.connectionLatch.countDown(); - } - // 연결 대기 - public boolean waitForConnection(long timeoutSeconds) { - try { - return connectionLatch.await(timeoutSeconds, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("웹소켓 연결 대기 중 인터럽트 발생"); - return false; - } - } + return session.receive() + .map(WebSocketMessage::getPayloadAsText) + .flatMap(this:: processMessage) + .doOnError(e->{ + log.error("해외 주식 메시지 처리 중 에러 발생 - 에러: {}", e.getMessage()); + }) + .onErrorResume(e-> Mono.empty()) + .doOnTerminate(()->{ + this.session= null; + log.info("DB증권 WebSocket 연결 종료"); + }) + .then(); - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - this.session = null; - isConnected.set(false); - this.connectionLatch = new CountDownLatch(1); - log.info("해외 주식 웹소켓 연결 종료 - 상태: {}, 코드: {}", status.getReason(), status.getCode()); } - // 구독 메시지 전달 - public void subscribe(String code) { - try { - sendSubscribeMessage(code); - TimeUnit.SECONDS.sleep(5); // 5초 대기 - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("구독 요청 중 인터럽트 발생 - 종목: {}", code); - } - } - public void sendSubscribeMessage(String code) { - // 연결 상태 확인 - if (!isConnected()) { - log.warn("웹소켓이 연결되지 않아 구독 메시지를 보낼 수 없습니다. 종목: {}", code); - throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); + public void subscribe(String code){ + if (session == null || !session.isOpen()) { + log.warn("WebSocket이 연결되지 않음 - 종목: {}", code); + return; } - // 동시 전송 방지 - synchronized (sendLock) { - try { - String token = (String) redisTemplate.opsForValue().get("db-access-token"); - - if (token == null || token.trim().isEmpty()) { - log.error("Redis에서 DB 토큰을 찾을 수 없습니다."); - throw new DomainException(DomainErrorCode.TOKEN_NOT_FOUND); - } - - Map header = Map.of( - "token", token, - "tr_type", "1" - ); - - Map body = Map.of( - "tr_cd", "V60", - "tr_key", "FN" + code - ); - - Map request = Map.of( - "header", header, - "body", body - ); + sendMessage(code,"1") + .flatMap(message-> session.send(Mono.just(session.textMessage(message)))) + .delayElement(Duration.ofSeconds(5)) //TODO 필요없으면 지우기 + .doOnError(e->{ + log.error("DB API 실시간 현재가 데이터 조회 메시지 요청 중 에러 발생 - 종목: {}, 에러: {}", code, e.getMessage()); + }) + .onErrorResume(e-> Mono.empty()) + .subscribe(); - String message = objectMapper.writeValueAsString(request); - session.sendMessage(new TextMessage(message)); - - } catch (Exception e) { - log.error("DB API 실시간 현재가 데이터 조회 메시지 요청 중 에러 발생 - 종목: {}, 에러: {}", code, e.getMessage()); - throw new DomainException(DomainErrorCode.WEBSOCKET_SEND_FAILED); - } - } } - // 구독 취소 요청 - public void unsubscribe(String code) { - sendUnsubscribeMessage(code); - } - - public void sendUnsubscribeMessage(String code) { - if (!isConnected()) { - log.warn("웹소켓 세션이 닫혀있어 구독 해제 메시지를 보낼 수 없습니다. 종목: {}", code); + public void unsubscribe(String code){ + if (session == null || !session.isOpen()) { + log.warn("WebSocket이 연결되지 않음 - 종목: {}", code); return; } - synchronized (sendLock) { - try { - String token = (String) redisTemplate.opsForValue().get("db-access-token"); - - if (token == null || token.trim().isEmpty()) { - log.error("Redis에서 DB 토큰을 찾을 수 없습니다."); - throw new DomainException(DomainErrorCode.TOKEN_NOT_FOUND); - } - - Map header = Map.of( - "token", token, - "tr_type", "2" - ); - - Map body = Map.of( - "tr_cd", "V60", - "tr_key", "FN" + code - ); - - Map request = Map.of( - "header", header, - "body", body - ); - - String jsonMessage = objectMapper.writeValueAsString(request); + sendMessage(code,"2") + .flatMap(message-> session.send(Mono.just(session.textMessage(message)))) + .delayElement(Duration.ofSeconds(5)) + .doOnError(e->{ + log.error("DB API 실시간 현재가 데이터 구독 해제 메시지 요청 중 에러 발생 - 종목: {}, 에러: {}", code, e.getMessage()); + }) + .onErrorResume(e-> Mono.empty()) + .subscribe(); + } - session.sendMessage(new TextMessage(jsonMessage)); + private Mono sendMessage(String code, String trType) { + // TODO 연결 상태 확인 + /* + * log.warn("웹소켓이 연결되지 않아 구독 메시지를 보낼 수 없습니다. 종목: {}", code); + throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); + * */ + return Mono.fromCallable(() -> { + String token = (String) redisTemplate.opsForValue().get("db-access-token"); + + if (token == null || token.trim().isEmpty()) { + log.error("Redis에서 DB 토큰을 찾을 수 없습니다."); + throw new DomainException(DomainErrorCode.TOKEN_NOT_FOUND); + } + + Map header = Map.of( + "token", token, + "tr_type", trType + ); + + Map body = Map.of( + "tr_cd", "V60", + "tr_key", "FN" + code + ); + + Map request = Map.of( + "header", header, + "body", body + ); + return objectMapper.writeValueAsString(request); + }) + .subscribeOn(Schedulers.boundedElastic()); - } catch (Exception e) { - log.error("DB API 실시간 현재가 데이터 구독 해제 요청 중 에러 발생 - 종목: {}, 에러: {}", code, e.getMessage()); - // 구독 해제는 실패해도 예외를 던지지 않음 - } - } } - // 메시지 수신 처리 - @Override - public void handleTextMessage(WebSocketSession session, TextMessage message) { - String payload = message.getPayload(); - synchronized (sendLock) { - try { + private Mono processMessage(String payload) { + return Mono.fromCallable(() -> { JsonNode root = objectMapper.readTree(payload); - JsonNode header = root.get("header"); + JsonNode header = root.get("header"); JsonNode body = root.get("body"); - //+ 구독 확인 요청도 자연스럽게 해결 - if (body != null && body.has("symbol")) { - parseAndProcessMessage(payload); - } else if (header != null && header.has("tr_type") && "2".equals(header.get("tr_type").asText())) { + if (header != null && header.has("tr_type") + && "2".equals(header.get("tr_type").asText())) { log.info("구독 해제 요청 완료"); + return null; } - } catch (Exception e) { - log.error("메시지 처리 중 에러 발생 - payload: {}, 에러: {}", payload, e.getMessage()); - // 메시지 파싱 실패는 전체 연결을 끊지 않음 - } - } + if(body == null || !body.has("symbol")){ + return null; + } + return payload; + }) + .flatMap(p->{ + if(p==null) return Mono.empty(); + return parseAndProcessMessage(p); + }).subscribeOn(Schedulers.boundedElastic()); + } - private void parseAndProcessMessage(String payload) { + private Mono parseAndProcessMessage(String payload) { + return Mono.fromCallable(() -> parseStockData(payload)) + .flatMap(stockData -> { + if (stockData == null) return Mono.empty(); + return executeCallbacks(stockData); + }) + .onErrorResume(e -> { + log.error("해외 주식 메시지 파싱 중 에러 발생: {}", e.getMessage()); + return Mono.empty(); + }); + } + private LiveStockPriceStream parseStockData(String payload) { try { JsonNode root = objectMapper.readTree(payload); JsonNode body = root.get("body"); @@ -210,16 +174,13 @@ private void parseAndProcessMessage(String payload) { BigDecimal diff = parseBigDecimal(getTextValue(body, "diff")); BigDecimal rate = parseBigDecimal(getTextValue(body, "rate")); - LiveStockPriceStream stockData = new LiveStockPriceStream( + return new LiveStockPriceStream( symbol.substring(2), // FN 접두사 제거 last, diff, rate ); - // 콜백 실행 - executeCallbacks(stockData); - } catch (Exception e) { log.error("메시지 파싱 중 에러 발생: {}", e.getMessage()); throw new DomainException(DomainErrorCode.WEBSOCKET_MESSAGE_PARSE_FAILED); @@ -244,27 +205,20 @@ private BigDecimal parseBigDecimal(String value) { } } - private void executeCallbacks(LiveStockPriceStream stockData) { - try { - if (dataCallBack != null) { - dataCallBack.accept(stockData); - } - } catch (Exception e) { - log.error("데이터 콜백 실행 중 에러: {}", e.getMessage()); - } + private Mono executeCallbacks(LiveStockPriceStream stockData) { + return Mono.fromRunnable(()->{ + if(dataCallBack!= null){ + dataCallBack.accept(stockData); + } + }) + .doOnError(e -> log.error("데이터 콜백 실행 중 에러: {}", e.getMessage())) + .onErrorResume(e->Mono.empty()) + .then(); - try { - if (saveCallBack != null) { - saveCallBack.accept(stockData); - } - } catch (Exception e) { - log.error("저장 콜백 실행 중 에러: {}", e.getMessage()); - } } - @Override - public void handleTransportError(WebSocketSession session, Throwable exception) { - log.error("해외 주식 웹소켓 전송 에러 발생: {}", exception.getMessage()); - isConnected.set(false); + public boolean isConnected(){ + return session != null && session.isOpen(); } + } \ No newline at end of file diff --git a/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java b/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java index 013eff46..591045b2 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/service/WebSocketConnectionService.java @@ -3,12 +3,13 @@ import com.fintory.common.exception.DomainErrorCode; import com.fintory.common.exception.DomainException; import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; +import com.fintory.websocket.provider.config.KoreanWebSocketConnection; +import com.fintory.websocket.provider.config.OverseasWebSocketConnection; import com.fintory.websocket.provider.handler.KoreanLiveStockPriceWebSocketHandler; import com.fintory.websocket.provider.handler.OverseasLiveStockPriceWebSocketHandler; import com.fintory.websocket.publisher.service.StockDataProcessService; import com.fintory.websocket.publisher.state.StockDataHolder; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; @@ -17,9 +18,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import org.springframework.web.socket.client.WebSocketConnectionManager; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; @@ -34,74 +33,82 @@ public class WebSocketConnectionService { private String baseUrl; private final StockDataHolder stockDataHolder; - private final WebSocketConnectionManager koreanConnectionManager; - private final WebSocketConnectionManager overseasConnectionManager; private final StockDataProcessService stockDataProcessService; private final RestTemplate restTemplate; private final KoreanLiveStockPriceWebSocketHandler koreanHandler; private final OverseasLiveStockPriceWebSocketHandler overseasHandler; + private final KoreanWebSocketConnection koreanWebSocketConnection; + private final OverseasWebSocketConnection overseasWebSocketConnection; public WebSocketConnectionService(StockDataHolder stockDataHolder, - @Qualifier("koreanLiveStockPriceWebSocketConnectionManager")WebSocketConnectionManager koreanConnectionManager, - @Qualifier("overseasLiveStockPriceWebSocketConnectionManager")WebSocketConnectionManager overseasConnectionManager, StockDataProcessService stockDataProcessService, RestTemplate restTemplate, KoreanLiveStockPriceWebSocketHandler koreanHandler, OverseasLiveStockPriceWebSocketHandler overseasHandler, - RedisTemplate redisTemplate) { + RedisTemplate redisTemplate, KoreanWebSocketConnection koreanWebSocketConnection, OverseasWebSocketConnection overseasWebSocketConnection) { this.stockDataHolder = stockDataHolder; - this.koreanConnectionManager = koreanConnectionManager; - this.overseasConnectionManager = overseasConnectionManager; this.stockDataProcessService = stockDataProcessService; this.restTemplate = restTemplate; this.koreanHandler = koreanHandler; this.overseasHandler = overseasHandler; this.redisTemplate = redisTemplate; + this.koreanWebSocketConnection = koreanWebSocketConnection; + this.overseasWebSocketConnection = overseasWebSocketConnection; } /* WebSocket 연결 관리 */ public void connectKoreanWebSocket() { - if (stockDataHolder.getIsKoreanConnected().get()) { - return; - } + try { + if (stockDataHolder.getIsKoreanConnected().get()) { + return; + } - Consumer callback = dto -> - stockDataProcessService.processStreamData(dto, - stockDataHolder.getPreviousKoreanData(), - stockDataHolder.getKoreanPendingData(), "국내"); + Consumer callback = dto -> + stockDataProcessService.processStreamData(dto, + stockDataHolder.getPreviousKoreanData(), + stockDataHolder.getKoreanPendingData(), "국내"); - koreanHandler.setDataCallBack(callback); - koreanConnectionManager.start(); + koreanHandler.setDataCallBack(callback); + koreanWebSocketConnection.connect(); - boolean connected = koreanHandler.waitForConnection(30); - if (!connected) { - log.error("연결 실패!"); - throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); - } + Thread.sleep(5000); + boolean connected = koreanHandler.isConnected(); + if (!connected) { + log.error("연결 실패!"); + throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); + } - stockDataHolder.getIsKoreanConnected().set(true); - log.info("국내 주식 WebSocket 연결 완료"); + stockDataHolder.getIsKoreanConnected().set(true); + log.info("국내 주식 WebSocket 연결 완료"); + }catch (InterruptedException e){ + + } } public void connectOverseasWebSocket() { - if (stockDataHolder.getIsOverseasConnected().get()) { - return; - } - Consumer callback = dto -> - stockDataProcessService.processStreamData(dto, stockDataHolder.getPreviousOverseasData(), stockDataHolder.getOverseasPendingData(), "해외"); + try { + if (stockDataHolder.getIsOverseasConnected().get()) { + return; + } + Consumer callback = dto -> + stockDataProcessService.processStreamData(dto, stockDataHolder.getPreviousOverseasData(), stockDataHolder.getOverseasPendingData(), "해외"); - overseasHandler.setDataCallBack(callback); - overseasConnectionManager.start(); + overseasHandler.setDataCallBack(callback); + overseasWebSocketConnection.connect(); - boolean connected = overseasHandler.waitForConnection(30); - if (!connected) { - log.info("해외 장시간임에도 WebSocket 연결 실패 - 공휴일이거나 기술적 문제일 수 있음"); - throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); - } + Thread.sleep(5000); + boolean connected = overseasHandler.isConnected(); + if (!connected) { + log.info("해외 장시간임에도 WebSocket 연결 실패 - 공휴일이거나 기술적 문제일 수 있음"); + throw new DomainException(DomainErrorCode.WEBSOCKET_CONNECTION_FAILED); + } + + stockDataHolder.getIsOverseasConnected().set(true); + log.info("해외 주식 WebSocket 연결 완료"); + }catch (InterruptedException e){ - stockDataHolder.getIsOverseasConnected().set(true); - log.info("해외 주식 WebSocket 연결 완료"); + } } @@ -125,7 +132,7 @@ public void disconnectKoreanWebSocket() { log.error("구독 해제 중 에러: {}", e.getMessage()); } finally { // 반드시 실행 - koreanConnectionManager.stop(); + koreanWebSocketConnection.disconnect(); stockDataHolder.getKoreanSubscribedStocks().clear(); stockDataHolder.getPreviousKoreanData().clear(); stockDataHolder.getKoreanPendingData().clear(); @@ -158,7 +165,7 @@ public void disconnectOverseasWebSocket() { } catch (Exception e) { log.error("구독 해제 중 에러: {}", e.getMessage()); } finally { - overseasConnectionManager.stop(); + overseasWebSocketConnection.disconnect(); stockDataHolder.getIsOverseasConnected().set(false); stockDataHolder.getOverseasSubscribedStocks().clear(); stockDataHolder.getPreviousOverseasData().clear(); From 107ede5a708d3291ccc767bd05199c3c856cfd5b Mon Sep 17 00:00:00 2001 From: mhee167 Date: Thu, 20 Nov 2025 02:43:14 +0900 Subject: [PATCH 32/38] =?UTF-8?q?refactor:=20WebSocket=EC=9D=84=20SSE=20+?= =?UTF-8?q?=20WebFlux=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EB=8B=A8=EB=B0=A9=ED=96=A5=20=EC=8A=A4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=8D=20+=20=EA=B8=B0=EC=A1=B4=EC=9D=98=20metric=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monitoring/config/SSEMetrics.java | 74 ++++++++++ .../StompStatsMetricsConfiguration.java | 138 ------------------ .../monitoring/config/WebSocketMetrics.java | 82 ----------- .../config/WebSocketStatsConfig.java | 27 ---- .../listener/WebSocketEventListener.java | 30 ---- .../provider/config/WebSocketInterceptor.java | 40 ----- .../config/WebSocketBrokerConfig.java | 45 ------ .../controller/StockStreamController.java | 55 +++++++ .../publisher/handler/StockStreamBridge.java | 28 ++++ .../LiveStockPriceWebSocketService.java | 18 +-- .../service/StockDataProcessService.java | 25 ++-- 11 files changed, 167 insertions(+), 395 deletions(-) create mode 100644 websocket/src/main/java/com/fintory/websocket/monitoring/config/SSEMetrics.java delete mode 100644 websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java delete mode 100644 websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java delete mode 100644 websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java delete mode 100644 websocket/src/main/java/com/fintory/websocket/monitoring/listener/WebSocketEventListener.java delete mode 100644 websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketInterceptor.java delete mode 100644 websocket/src/main/java/com/fintory/websocket/publisher/config/WebSocketBrokerConfig.java create mode 100644 websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java create mode 100644 websocket/src/main/java/com/fintory/websocket/publisher/handler/StockStreamBridge.java diff --git a/websocket/src/main/java/com/fintory/websocket/monitoring/config/SSEMetrics.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/SSEMetrics.java new file mode 100644 index 00000000..47a53592 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/monitoring/config/SSEMetrics.java @@ -0,0 +1,74 @@ +package com.fintory.websocket.monitoring.config; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@RequiredArgsConstructor +public class SSEMetrics { + + private final MeterRegistry meterRegistry; + private final AtomicInteger activeConnections = new AtomicInteger(0); + private final AtomicInteger activeSubscribers = new AtomicInteger(0); + private Counter messagesSent; + private Counter messagesDropped; + + @PostConstruct + public void registerMetrics() { + + // 1. 활성 SSE 연결 수 + Gauge.builder("sse.connections.active", + activeConnections, AtomicInteger::get) + .description("Active SSE connections") + .register(meterRegistry); + + // 2. 활성 구독자 수 + Gauge.builder("sse.subscribers.active", + activeSubscribers, AtomicInteger::get) + .description("Active Flux subscribers") + .register(meterRegistry); + + // 3. 전송된 메시지 수 + this.messagesSent = Counter.builder("sse.messages.sent") + .description("Total messages sent to clients") + .register(meterRegistry); + + // 4. 드롭된 메시지 수 (백프레셔) + this.messagesDropped = Counter.builder("sse.messages.dropped") + .description("Messages dropped due to backpressure") + .register(meterRegistry); + } + + // 연결 관리 + public void incrementConnection() { + activeConnections.incrementAndGet(); + } + + public void decrementConnection() { + activeConnections.decrementAndGet(); + } + + // 구독자 관리 + public void incrementSubscriber() { + activeSubscribers.incrementAndGet(); + } + + public void decrementSubscriber() { + activeSubscribers.decrementAndGet(); + } + + // 메시지 카운팅 + public void incrementMessageSent() { + messagesSent.increment(); + } + + public void incrementMessageDropped() { + messagesDropped.increment(); + } +} \ No newline at end of file diff --git a/websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java deleted file mode 100644 index 074ea517..00000000 --- a/websocket/src/main/java/com/fintory/websocket/monitoring/config/StompStatsMetricsConfiguration.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.fintory.websocket.monitoring.config; - -import io.micrometer.core.instrument.FunctionCounter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tag; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.WebSocketMessageBrokerStats; -import org.springframework.web.socket.messaging.StompSubProtocolHandler; -import org.springframework.web.socket.messaging.SubProtocolWebSocketHandler; - -import java.util.Arrays; -import java.util.Collections; - -@Configuration -public class StompStatsMetricsConfiguration { - - private final WebSocketMessageBrokerStats stats; - private final MeterRegistry registry; - - public StompStatsMetricsConfiguration(WebSocketMessageBrokerStats stats, MeterRegistry registry) { - this.stats = stats; - this.registry = registry; - registerMetrics(); - } - - private void registerMetrics() { - // 1. WebSocket 세션 통계 (Gauge) - registerSessionMetrics(); - - // 2. STOMP 프로토콜 통계 (FunctionCounter) - registerStompProtocolMetrics(); - - // 3. 채널 Executor 큐 통계 (Gauge) - registerChannelExecutorMetrics(); - } - - // --- 1. WebSocket 세션 통계 등록 --- - private void registerSessionMetrics() { - final String sessionsGaugeName = "websocket.sessions"; - final String sessionsGaugeNameTotal = "websocket.total.sessions"; - - // 현재 활성 세션 수 (Gauge) - registry.gauge(sessionsGaugeName, Arrays.asList(Tag.of("status", "current"), Tag.of("type", "total")), - this.stats, s -> { - SubProtocolWebSocketHandler.Stats sessionStats = s.getWebSocketSessionStats(); - return (sessionStats != null) ? sessionStats.getWebSocketSessions() : 0; - }); - - // 누적 총 세션 수 (FunctionCounter) - FunctionCounter.builder(sessionsGaugeNameTotal, this.stats, s -> { - SubProtocolWebSocketHandler.Stats sessionStats = s.getWebSocketSessionStats(); - return (sessionStats != null) ? sessionStats.getTotalSessions() : 0; - }) - .description("Total number of WebSocket sessions (accumulated)") - .tag("type", "total_accumulated") - .register(registry); - - // 전송 오류로 종료된 세션 수 (FunctionCounter) - FunctionCounter.builder(sessionsGaugeName + ".closed.abnormally", this.stats, s -> { - SubProtocolWebSocketHandler.Stats sessionStats = s.getWebSocketSessionStats(); - return (sessionStats != null) ? sessionStats.getTransportErrorSessions() : 0; - }) - .description("Number of sessions closed due to transport error") - .tag("reason", "transport_error") - .register(registry); - } - - // --- 2. STOMP 프로토콜 통계 등록 --- - private void registerStompProtocolMetrics() { - final String metricName = "stomp.messages.processed"; - - // CONNECT 프레임 처리 수 (FunctionCounter) - FunctionCounter.builder(metricName, this.stats, s -> { - StompSubProtocolHandler.Stats stompStats = s.getStompSubProtocolStats(); - return (stompStats != null) ? stompStats.getTotalConnect() : 0; - }) - .description("Total number of STOMP CONNECT frames processed") - .tag("action", "CONNECT") - .register(registry); - - // CONNECTED 프레임 처리 수 (FunctionCounter) - FunctionCounter.builder(metricName, this.stats, s -> { - StompSubProtocolHandler.Stats stompStats = s.getStompSubProtocolStats(); - return (stompStats != null) ? stompStats.getTotalConnected() : 0; - }) - .description("Total number of STOMP CONNECTED frames sent") - .tag("action", "CONNECTED") - .register(registry); - - // DISCONNECT 프레임 처리 수 (FunctionCounter) - FunctionCounter.builder(metricName, this.stats, s -> { - StompSubProtocolHandler.Stats stompStats = s.getStompSubProtocolStats(); - return (stompStats != null) ? stompStats.getTotalDisconnect() : 0; - }) - .description("Total number of STOMP DISCONNECT frames processed") - .tag("action", "DISCONNECT") - .register(registry); - } - - // --- 3. 채널 Executor 큐 통계 등록 --- - private void registerChannelExecutorMetrics() { - // 이미 Micrometer Actuator가 executor_queued_tasks 등으로 등록할 가능성이 높지만, - // 명시적인 이름으로 재등록하여 보장성을 높입니다. - - // 인바운드 채널 대기 큐 크기 (Gauge) - registry.gauge("websocket.channel.queue.size", Collections.singletonList(Tag.of("channel", "inbound")), - this.stats, s -> { - String info = s.getClientInboundExecutorStatsInfo(); - return extractQueuedTasks(info); - }); - - // 아웃바운드 채널 대기 큐 크기 (Gauge) - registry.gauge("websocket.channel.queue.size", Collections.singletonList(Tag.of("channel", "outbound")), - this.stats, s -> { - String info = s.getClientOutboundExecutorStatsInfo(); - return extractQueuedTasks(info); - }); - } - - // ThreadPoolExecutor 문자열에서 queued tasks 값을 추출하는 헬퍼 메서드 - private double extractQueuedTasks(String statsInfo) { - if (statsInfo.contains("queued tasks = ")) { - try { - int start = statsInfo.indexOf("queued tasks = ") + "queued tasks = ".length(); - int end = statsInfo.indexOf(",", start); - if (end == -1) { - end = statsInfo.indexOf("]", start); - } - String value = statsInfo.substring(start, end).trim(); - return Double.parseDouble(value); - } catch (Exception e) { - // 파싱 오류 시 0 반환 - return 0; - } - } - return 0; - } -} diff --git a/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java deleted file mode 100644 index 0f5aeb64..00000000 --- a/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketMetrics.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.fintory.websocket.monitoring.config; - -import com.fintory.websocket.publisher.service.LiveStockPriceWebSocketService; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.MeterRegistry; -import jakarta.annotation.PostConstruct; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Component; - -import java.util.concurrent.atomic.AtomicInteger; - -//REVIEW 혹시 해당 파일의 위치를 바꾸길 원하시면 리뷰 주세요! ->WebSocketMetrics는 Micrometer와 Prometheus 같은 외부 기술에 의존하기 때문에 infra 모듈에 위치시켰습니다 -@Component -public class WebSocketMetrics { - - private final LiveStockPriceWebSocketService websocketService; - private final MeterRegistry meterRegistry; - private final AtomicInteger activeConnections = new AtomicInteger(0); - private Counter messageSent; - - //REVIEW @Lazy를 쓰기 위해 명시적 생성자 사용 -> @Lazy는 생성자 파라미터에 직접 붙어 있어야 동작함 - // @RequiredConstructor는 생성자 파라미터별 어노테이션을 직접 지원하지 않는 것으로 알고 있음. - public WebSocketMetrics(@Lazy LiveStockPriceWebSocketService websocketService, MeterRegistry meterRegistry) { - this.websocketService = websocketService; - this.meterRegistry = meterRegistry; - } - - @PostConstruct - public void registerMetrics(){ - - // STOMP 활성 연결 수 - Gauge.builder("stomp.connections.active", - activeConnections, AtomicInteger::get) - .description("Active STOMP connections (클라이언트 수)") - .register(meterRegistry); - - // TODO 활성 구독 종목 수 -> 그라파나로 확인한 후 없애기 - // 국내 주식 활성 구독 종목 수 - Gauge.builder("websocket.korean.subscriptions.active", - websocketService, service -> service.getKoreanSubscribedStocks().size()) - .description("Active Korean Stock subscriptions count") - .register(meterRegistry); - - // 해외 주식 활성 구독 종목 수 - Gauge.builder("websocket.overseas.subscriptions.active", - websocketService, service -> service.getOverseasSubscribedStocks().size()) - .description("Active Overseas Stock subscriptions count") - .register(meterRegistry); - - // 국내 Websocket 연결 상태 - Gauge.builder("websocket.korean.connected", - websocketService, service -> service.isKoreanConnected() ? 1.0 : 0.0) - .description("Korean WebSocket connection status") - .register(meterRegistry); - - // 해외 Websocket 연결 상태 - Gauge.builder("websocket.overseas.connected", - websocketService, service -> service.isOverseasConnected() ? 1.0 : 0.0) - .description("Overseas WebSocket connection status") - .register(meterRegistry); - - this.messageSent = Counter.builder("websocket.messages.sent") - .description("Messages sent to Front") - .register(meterRegistry); - } - - // 연결 관리 - public void incrementConnection() { - activeConnections.incrementAndGet(); - } - - public void decrementConnection() { - activeConnections.decrementAndGet(); - } - - public void incrementMessageSent(){ - messageSent.increment(); - } -} - - diff --git a/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java b/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java deleted file mode 100644 index d5a1f947..00000000 --- a/websocket/src/main/java/com/fintory/websocket/monitoring/config/WebSocketStatsConfig.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.fintory.websocket.monitoring.config; - -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.WebSocketMessageBrokerStats; - -@Configuration -public class WebSocketStatsConfig { - - // 설정 파일의 속성 값을 읽어와 주입 - @Value("${spring.websocket.stomp.stats-log-period:30000}") - private long loggingPeriodMillis; - - private final WebSocketMessageBrokerStats stats; - - public WebSocketStatsConfig(WebSocketMessageBrokerStats stats) { - this.stats = stats; - } - - @PostConstruct - public void setCustomLoggingPeriod() { - if (this.loggingPeriodMillis > 0) { - this.stats.setLoggingPeriod(this.loggingPeriodMillis); - } - } -} diff --git a/websocket/src/main/java/com/fintory/websocket/monitoring/listener/WebSocketEventListener.java b/websocket/src/main/java/com/fintory/websocket/monitoring/listener/WebSocketEventListener.java deleted file mode 100644 index 0d4899f3..00000000 --- a/websocket/src/main/java/com/fintory/websocket/monitoring/listener/WebSocketEventListener.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.fintory.websocket.monitoring.listener; - -import com.fintory.websocket.monitoring.config.WebSocketMetrics; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.messaging.SessionConnectedEvent; -import org.springframework.web.socket.messaging.SessionDisconnectEvent; - - -//REVIEW 서비스 코드는 아니고, 리스너는 인터페이스가 필요없어서 따로 패키지를 만들었는데, 논리상 파일 위치에 문제 있을 시 리뷰 주시면 반영하겠습니다! -@Component -@RequiredArgsConstructor -@Slf4j -public class WebSocketEventListener { - - private final WebSocketMetrics webSocketMetrics; - - // 클라이언트 stomp 연결(STMOP CONNECTED로 응답 완료 후 ) -> SessionConntecdEvent 발행 - @EventListener - public void handleSessionConnect(SessionConnectedEvent event) { - webSocketMetrics.incrementConnection(); - } - - @EventListener - public void handleSessionDisconnect(SessionDisconnectEvent event) { - webSocketMetrics.decrementConnection(); - } -} diff --git a/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketInterceptor.java b/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketInterceptor.java deleted file mode 100644 index 57db5ae3..00000000 --- a/websocket/src/main/java/com/fintory/websocket/provider/config/WebSocketInterceptor.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.fintory.websocket.provider.config; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.server.ServerHttpRequest; -import org.springframework.http.server.ServerHttpResponse; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.WebSocketHandler; -import org.springframework.web.socket.server.HandshakeInterceptor; - -import java.util.Map; - -@Component -@Slf4j -public class WebSocketInterceptor implements HandshakeInterceptor { - @Override - public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) throws Exception { - log.info("WebSocket 핸드셰이크 헤더 정보 조회"); - log.info("요청 URI:{}", request.getURI()); - log.info("요청 메소드:{}",request.getMethod()); - - - request.getHeaders().forEach((headerName, headerValues) -> { - log.info("헤더 [{}]: {}", headerName, headerValues); - }); - - log.info("Origin: {}", request.getHeaders().getOrigin()); - - return true; //핸드셰이크 계속 진행 - - } - - @Override - public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { - if (exception != null) { - log.error("WebSocket 핸드셰이크 실패: ", exception); - } else { - log.info("WebSocket 핸드셰이크 성공!"); - } - } -} diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/config/WebSocketBrokerConfig.java b/websocket/src/main/java/com/fintory/websocket/publisher/config/WebSocketBrokerConfig.java deleted file mode 100644 index 731a926b..00000000 --- a/websocket/src/main/java/com/fintory/websocket/publisher/config/WebSocketBrokerConfig.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.fintory.websocket.publisher.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.messaging.simp.config.MessageBrokerRegistry; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; -import org.springframework.web.socket.config.annotation.StompEndpointRegistry; -import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; - -//클라이언트들이 내 서버에 연결하도록 설정하는 코드 -//REVIEW 일반적인 어플에서도 시세 데이터는 별도의 로그인 과정 없이도 조회가 가능해서 핸드셰이크 인터셉터 설정x(jwt 토큰 인증x) -@Configuration -@EnableWebSocketMessageBroker -public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer { - - @Bean(name = "webSocketTaskScheduler") - public TaskScheduler messageBrokerTaskScheduler() { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(1); - scheduler.setThreadNamePrefix("webSocket-heartbeat-thread-"); - return scheduler; - } - @Override - public void configureMessageBroker(MessageBrokerRegistry config) { - config.enableSimpleBroker("/topic") // 서버 -> 클라이언트 - .setHeartbeatValue(new long[]{10000, 10000}) - .setTaskScheduler(messageBrokerTaskScheduler()); - - config.setApplicationDestinationPrefixes("/app"); //클라이언트 -> 서버 - } - - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*"); - - registry.addEndpoint("/ws-sockjs") // 초기 웹소켓 연결을 위한 경로 - .setAllowedOriginPatterns("*") //cors 설정 - .withSockJS(); //구형 브라우저를 위한 폴백 - - - } -} diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java b/websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java new file mode 100644 index 00000000..ad398400 --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java @@ -0,0 +1,55 @@ +package com.fintory.websocket.publisher.controller; + +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; +import com.fintory.websocket.monitoring.config.SSEMetrics; +import com.fintory.websocket.publisher.handler.StockStreamBridge; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + + +@RestController +@RequestMapping("/api/stock") +@RequiredArgsConstructor +@Slf4j +public class StockStreamController { + + private final StockStreamBridge stockStreamBridge; + private final SSEMetrics sseMetrics; + + @GetMapping(value="/live-price",produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux> streamAll(){ + return stockStreamBridge.getStream() + .map(data-> ServerSentEvent.builder() + .data(data) + .build()) + .onBackpressureLatest() + .doOnSubscribe(sub->{ + sseMetrics.incrementConnection(); + sseMetrics.incrementSubscriber(); + }) + .doOnNext(data->{ + sseMetrics.incrementMessageSent(); + }) + .doOnCancel(()->{ + sseMetrics.decrementConnection(); + sseMetrics.decrementSubscriber(); + }) + .doOnComplete(()->{ + sseMetrics.decrementSubscriber(); + sseMetrics.decrementConnection(); + }) + .doOnError(error->{ + sseMetrics.decrementConnection(); + sseMetrics.decrementSubscriber(); + }) + .doOnDiscard(LiveStockPriceStream.class, discarded->{ + sseMetrics.incrementMessageDropped(); + }); + } +} diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/handler/StockStreamBridge.java b/websocket/src/main/java/com/fintory/websocket/publisher/handler/StockStreamBridge.java new file mode 100644 index 00000000..f4907f6e --- /dev/null +++ b/websocket/src/main/java/com/fintory/websocket/publisher/handler/StockStreamBridge.java @@ -0,0 +1,28 @@ +package com.fintory.websocket.publisher.handler; + +import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; + +@Component +@Slf4j +public class StockStreamBridge { + + private final Sinks.Many sink; + + public StockStreamBridge() { + this.sink = Sinks.many() + .multicast() + .directBestEffort(); + } + + public Flux getStream(){ + return sink.asFlux(); + } + + public void publish(LiveStockPriceStream data){ + sink.tryEmitNext(data); + } +} diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java index 47a47e1a..c04a64a9 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/LiveStockPriceWebSocketService.java @@ -23,6 +23,7 @@ @Service @Slf4j @RequiredArgsConstructor +@DependsOn({"kisTokenIssueServiceImpl","DBTokenIssueServiceImpl"}) public class LiveStockPriceWebSocketService { @@ -135,21 +136,4 @@ public void cleanUp() { log.info("WebSocket 연결 해제 완료"); } - - /* 메트릭용 Getter 추가 */ - public Set getKoreanSubscribedStocks() { - return stockDataHolder.getKoreanSubscribedStocks(); - } - - public Set getOverseasSubscribedStocks() { - return stockDataHolder.getOverseasSubscribedStocks(); - } - - public boolean isKoreanConnected() { - return stockDataHolder.getIsKoreanConnected().get(); - } - - public boolean isOverseasConnected() { - return stockDataHolder.getIsOverseasConnected().get(); - } } \ No newline at end of file diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java index 7d0d3c9e..f073608e 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java @@ -1,13 +1,12 @@ package com.fintory.websocket.publisher.service; import com.fintory.domain.stock.dto.websocket.LiveStockPriceStream; -import com.fintory.websocket.monitoring.config.WebSocketMetrics; +import com.fintory.websocket.monitoring.config.SSEMetrics; +import com.fintory.websocket.publisher.handler.StockStreamBridge; import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.messaging.simp.SimpMessageHeaderAccessor; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import io.micrometer.core.instrument.Timer; @@ -17,19 +16,18 @@ @Service @Slf4j public class StockDataProcessService { - private final WebSocketMetrics webSocketMetrics; - private final SimpMessagingTemplate messageTemplate; + private final SSEMetrics SSEMetrics; + private final StockStreamBridge stockStreamBridge; private final Timer dataProcessingTime; private final LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService; private final RedisTemplate redisTemplate; private static final String PRICE_ALERT_CHANNEL = "price:alert:channel"; - public StockDataProcessService(@Lazy WebSocketMetrics webSocketMetrics, - SimpMessagingTemplate messageTemplate, + public StockDataProcessService(@Lazy SSEMetrics SSEMetrics, StockStreamBridge stockStreamBridge, MeterRegistry meterRegistry, LiveStockPriceWebSocketSaverService liveStockPriceWebSocketSaverService, RedisTemplate redisTemplate) { - this.webSocketMetrics = webSocketMetrics; - this.messageTemplate = messageTemplate; + this.SSEMetrics = SSEMetrics; + this.stockStreamBridge = stockStreamBridge; this.dataProcessingTime = Timer.builder("websocket.data.processing.time") .description("Time to process and send stock data") .publishPercentiles(0.5,0.95,0.99) @@ -87,13 +85,8 @@ public void sendStockData(String stockCode, Object stockData) { if (stream.priceChange() == null || stream.priceChange().compareTo(BigDecimal.ZERO) == 0) { return; } - // 지연 시간을 측정하기 위해 STOMP 헤더에 타임스탬프 추가 - // REVIEW 헤더에 데이터를 추가한 것일 뿐 바디는 바뀌지 않으므로 프론트 코드에는 문제가 없는 것으로 알고 있는데 아니라면 수정 필수 - SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(); - headerAccessor.setNativeHeader("sentTimestamp", String.valueOf(System.currentTimeMillis())); - - webSocketMetrics.incrementMessageSent(); - messageTemplate.convertAndSend("/topic/stock/live-Price/" + stockCode, stockData, headerAccessor.getMessageHeaders()); + SSEMetrics.incrementMessageSent(); + stockStreamBridge.publish((LiveStockPriceStream) stockData); } } From c8b9e5172b09de44c57ba6268227cc3cd43c840c Mon Sep 17 00:00:00 2001 From: mhee167 Date: Thu, 20 Nov 2025 03:10:24 +0900 Subject: [PATCH 33/38] =?UTF-8?q?refactor:=20DB=20=EC=A6=9D=EA=B6=8C=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=20=EB=B6=84=EB=8B=B9=20=ED=9A=9F=EC=88=98=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20delayElements=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KoreanLiveStockPriceWebSocketHandler.java | 3 -- ...verseasLiveStockPriceWebSocketHandler.java | 1 - .../service/StockSubscriptionService.java | 35 ++++++++++--------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java index d1b10e89..98697e66 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/KoreanLiveStockPriceWebSocketHandler.java @@ -40,11 +40,8 @@ public void setDataCallBack(Consumer dataCallBack) { public Mono handle(WebSocketSession session) { this.session = session; return session.receive() - .doOnNext(msg -> log.info("Raw WebSocket 메시지 수신")) .map(WebSocketMessage::getPayloadAsText) - .doOnNext(message->log.info("메시지: {}",message)) .flatMap(this::parseAndProcessMessage) - .doOnNext(message->log.info("국내 주식 메시지: {}",message)) .doOnError(e -> log.error("국내 주식 구독 메시지 처리 중 에러:{}", e.getMessage())) .onErrorResume(e -> Mono.empty()) .doOnTerminate(()->{ diff --git a/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java index c4ba296d..3d2d94ea 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/handler/OverseasLiveStockPriceWebSocketHandler.java @@ -67,7 +67,6 @@ public void subscribe(String code){ sendMessage(code,"1") .flatMap(message-> session.send(Mono.just(session.textMessage(message)))) - .delayElement(Duration.ofSeconds(5)) //TODO 필요없으면 지우기 .doOnError(e->{ log.error("DB API 실시간 현재가 데이터 조회 메시지 요청 중 에러 발생 - 종목: {}, 에러: {}", code, e.getMessage()); }) diff --git a/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java b/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java index e8bc6a3d..fd9543dd 100644 --- a/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java +++ b/websocket/src/main/java/com/fintory/websocket/provider/service/StockSubscriptionService.java @@ -10,6 +10,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; import java.util.List; @@ -64,21 +68,20 @@ public void startOverseasMarketSubscription(){ int beforeSize = stockDataHolder.getOverseasSubscribedStocks().size(); - targetStocks.forEach(stock -> { - if (!stockDataHolder.getOverseasSubscribedStocks().contains(stock.getCode())) { - try { - overseasHandler.subscribe(stock.getCode()); - stockDataHolder.getOverseasSubscribedStocks().add(stock.getCode()); - }catch(Exception e){ - log.error("종목 {} 구독 실패: {}", stock.getCode(), e.getMessage()); - } - } - }); - - int successCount = stockDataHolder.getOverseasSubscribedStocks().size() - beforeSize; - log.info("장 시작 - 총 {} 종목 중 {} 종목 구독 완료", - targetStocks.size(), successCount); + Flux.fromIterable(targetStocks) + .filter(stock -> !stockDataHolder.getOverseasSubscribedStocks().contains(stock.getCode())) + .delayElements(Duration.ofSeconds(1)) //최대 호출 횟수(분당 6회) 제한 때문에 추가 + .doOnNext(stock->{ + overseasHandler.subscribe(stock.getCode()); + stockDataHolder.getOverseasSubscribedStocks().add(stock.getCode()); + }) + .doOnError(e->log.error("해외 주식 구독 실패: {}", e.getMessage())) + .onErrorResume(e-> Mono.empty()) + .doOnComplete(()->{ + int successCount = stockDataHolder.getOverseasSubscribedStocks().size() - beforeSize; + log.info("장 시작 - 총 {} 종목 중 {} 종목 구독 완료", + targetStocks.size(), successCount); + }) + .subscribe(); } - - } From dc36c5954a4086df981e6a0c5c3d3948e19e96f7 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Fri, 21 Nov 2025 12:04:55 +0900 Subject: [PATCH 34/38] =?UTF-8?q?refactor:=20sentTimestamp=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/stock/dto/websocket/LiveStockPriceStream.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java index f761858d..03e66fda 100644 --- a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java +++ b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java @@ -9,7 +9,7 @@ public record LiveStockPriceStream( String code, BigDecimal currentPrice, - BigDecimal priceChange, + long sentTimestamp, BigDecimal priceChangeRate ) implements Serializable { private static final long serialVersionUID = 1L; From 6229bae1cb0bc0edb0fbff383483b00ce315de87 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Fri, 21 Nov 2025 12:15:28 +0900 Subject: [PATCH 35/38] =?UTF-8?q?refactor:=20=EC=82=AD=EC=A0=9C=ED=96=88?= =?UTF-8?q?=EB=8D=98=20priceChange=20=EC=B6=94=EA=B0=80(=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=B2=B4=ED=81=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fintory/domain/stock/dto/websocket/LiveStockPriceStream.java | 1 + 1 file changed, 1 insertion(+) diff --git a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java index 03e66fda..9ea481dc 100644 --- a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java +++ b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java @@ -9,6 +9,7 @@ public record LiveStockPriceStream( String code, BigDecimal currentPrice, + BigDecimal priceChange, long sentTimestamp, BigDecimal priceChangeRate ) implements Serializable { From 1309e7a8150d123d455aee0d95c41e23820c2fca Mon Sep 17 00:00:00 2001 From: mhee167 Date: Fri, 21 Nov 2025 12:28:16 +0900 Subject: [PATCH 36/38] =?UTF-8?q?refactor:=20sentTimestamp=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fintory/domain/stock/dto/websocket/LiveStockPriceStream.java | 1 - 1 file changed, 1 deletion(-) diff --git a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java index 9ea481dc..f761858d 100644 --- a/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java +++ b/domain/src/main/java/com/fintory/domain/stock/dto/websocket/LiveStockPriceStream.java @@ -10,7 +10,6 @@ public record LiveStockPriceStream( String code, BigDecimal currentPrice, BigDecimal priceChange, - long sentTimestamp, BigDecimal priceChangeRate ) implements Serializable { private static final long serialVersionUID = 1L; From 8dc8ffb094376b147ecb4a2221637febaa234b78 Mon Sep 17 00:00:00 2001 From: mhee167 Date: Sat, 22 Nov 2025 03:33:07 +0900 Subject: [PATCH 37/38] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EC=A4=91=EB=B3=B5=20stock=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/publisher/service/StockDataProcessService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java index f073608e..7cd3d006 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/service/StockDataProcessService.java @@ -82,9 +82,10 @@ public void processStreamData(LiveStockPriceStream dto, public void sendStockData(String stockCode, Object stockData) { if (stockData instanceof LiveStockPriceStream stream) { + /* if (stream.priceChange() == null || stream.priceChange().compareTo(BigDecimal.ZERO) == 0) { return; - } + }*/ SSEMetrics.incrementMessageSent(); stockStreamBridge.publish((LiveStockPriceStream) stockData); } From 2e52ca149d4baa120392916314408566c148bc8b Mon Sep 17 00:00:00 2001 From: mhee167 Date: Sat, 22 Nov 2025 03:42:40 +0900 Subject: [PATCH 38/38] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20messageS?= =?UTF-8?q?ent=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/publisher/controller/StockStreamController.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java b/websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java index ad398400..65f711d1 100644 --- a/websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java +++ b/websocket/src/main/java/com/fintory/websocket/publisher/controller/StockStreamController.java @@ -33,9 +33,6 @@ public Flux> streamAll(){ sseMetrics.incrementConnection(); sseMetrics.incrementSubscriber(); }) - .doOnNext(data->{ - sseMetrics.incrementMessageSent(); - }) .doOnCancel(()->{ sseMetrics.decrementConnection(); sseMetrics.decrementSubscriber();