Skip to content

Commit 98f374c

Browse files
committed
Refactor: WebSocket 공통 생명주기를 추상 클래스로 분리
1 parent 1b71bc5 commit 98f374c

3 files changed

Lines changed: 432 additions & 433 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package com.rabbittick.streamer.connector;
2+
3+
import java.time.Duration;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
7+
import org.springframework.beans.factory.DisposableBean;
8+
import org.springframework.web.reactive.socket.WebSocketMessage;
9+
import org.springframework.web.reactive.socket.WebSocketSession;
10+
import org.springframework.web.reactive.socket.client.WebSocketClient;
11+
12+
import com.rabbittick.streamer.service.MarketDataService;
13+
14+
import lombok.extern.slf4j.Slf4j;
15+
import reactor.core.Disposable;
16+
import reactor.core.publisher.Flux;
17+
import reactor.core.publisher.Mono;
18+
import reactor.core.scheduler.Schedulers;
19+
import reactor.util.retry.Retry;
20+
21+
/**
22+
* 거래소 WebSocket 커넥터의 공통 생명주기를 구현하는 추상 클래스.
23+
*
24+
* <p>주요 책임:
25+
* <ul>
26+
* <li>마켓코드 청크 분할 및 병렬 WebSocket 연결 관리</li>
27+
* <li>연결 실패 시 지수 백오프 기반 자동 재연결</li>
28+
* <li>주기적 Ping 전송으로 연결 유지</li>
29+
* <li>메시지 수신 루프 및 {@code boundedElastic} 스케줄러 기반 비동기 처리</li>
30+
* <li>애플리케이션 종료 시 연결 정리</li>
31+
* </ul>
32+
*
33+
* <p>거래소별 프로토콜 차이(구독 메시지 포맷, 메시지 파싱, 마켓코드 로드 방식)는
34+
* {@link ExchangeConnector} 인터페이스의 추상 메서드로 위임한다.
35+
*
36+
* <p>새로운 거래소를 추가하려면 이 클래스를 상속하고
37+
* {@link ExchangeConnector}의 추상 메서드와 {@link #getMaxMarketsPerConnection()},
38+
* {@link #getProcessingConcurrency()}를 구현한다.
39+
*/
40+
@Slf4j
41+
public abstract class AbstractExchangeConnector implements ExchangeConnector, DisposableBean {
42+
43+
protected final WebSocketClient webSocketClient;
44+
protected final MarketDataService marketDataService;
45+
46+
/** WebSocket 연결 상태를 관리하는 Disposable. 종료 시 연결 정리에 사용된다. */
47+
private Disposable connectionDisposable;
48+
49+
/**
50+
* 공통 의존성을 주입받는 생성자.
51+
*
52+
* @param webSocketClient WebSocket 연결 클라이언트
53+
* @param marketDataService 시장 데이터 처리 서비스
54+
*/
55+
protected AbstractExchangeConnector(WebSocketClient webSocketClient,
56+
MarketDataService marketDataService) {
57+
this.webSocketClient = webSocketClient;
58+
this.marketDataService = marketDataService;
59+
}
60+
61+
/**
62+
* 단일 WebSocket 연결당 최대 구독 마켓코드 수를 반환한다.
63+
*
64+
* <p>이 값을 초과하면 마켓코드를 청크로 나눠 복수의 연결을 유지한다.
65+
* 거래소 API 제약에 맞게 구현체가 직접 정의한다.
66+
*
67+
* @return 연결당 최대 마켓코드 수
68+
*/
69+
protected abstract int getMaxMarketsPerConnection();
70+
71+
/**
72+
* 메시지 처리 시 동시에 실행할 내부 Mono의 최대 개수를 반환한다.
73+
*
74+
* <p>파싱·변환·발행 작업은 {@code boundedElastic} 스케줄러에서
75+
* 이 값을 상한으로 병렬 처리된다.
76+
*
77+
* @return 처리 동시성 상한
78+
*/
79+
protected abstract int getProcessingConcurrency();
80+
81+
/**
82+
* 마켓코드를 로드하고 WebSocket 연결을 시작한다.
83+
*
84+
* <p>{@link #loadMarketCodes()}로 구독 대상을 확정한 후
85+
* {@link #getMaxMarketsPerConnection()} 단위로 청크를 나눠
86+
* 병렬 연결을 시작한다.
87+
*/
88+
@Override
89+
public void start() {
90+
List<String> marketCodes = loadMarketCodes();
91+
int chunkCount = (int) Math.ceil((double) marketCodes.size() / getMaxMarketsPerConnection());
92+
log.info("[{}] {}개 마켓코드 → {}개 WebSocket 연결을 시작합니다.",
93+
getExchangeName(), marketCodes.size(), chunkCount);
94+
connectAndSubscribe(marketCodes);
95+
}
96+
97+
/**
98+
* 마켓코드를 청크로 분할하여 병렬 WebSocket 연결을 시작한다.
99+
*
100+
* <p>연결 실패 시 초기 5초, 최대 1분의 지수 백오프로 무제한 재연결을 시도한다.
101+
*
102+
* @param marketCodes 구독할 전체 마켓코드 목록
103+
*/
104+
private void connectAndSubscribe(List<String> marketCodes) {
105+
List<List<String>> chunks = partition(marketCodes, getMaxMarketsPerConnection());
106+
log.info("[{}] 총 {}개 청크로 병렬 연결 시작", getExchangeName(), chunks.size());
107+
108+
this.connectionDisposable = Flux.fromIterable(chunks)
109+
.flatMap(chunk -> webSocketClient
110+
.execute(getWebSocketUri(), session -> handleWebSocketSession(session, chunk))
111+
.retryWhen(Retry.backoff(Long.MAX_VALUE, Duration.ofSeconds(5))
112+
.maxBackoff(Duration.ofMinutes(1))
113+
.doBeforeRetry(retrySignal ->
114+
log.warn("[{}] WebSocket 연결 재시도 중 (마켓코드 {}개, 시도: {})",
115+
getExchangeName(), chunk.size(), retrySignal.totalRetries() + 1)))
116+
.doOnError(error ->
117+
log.error("[{}] WebSocket 연결 복구 불가능한 오류 (마켓코드 {}개)",
118+
getExchangeName(), chunk.size(), error)))
119+
.subscribe(
120+
null,
121+
error -> log.error("[{}] 전체 WebSocket 연결에 복구 불가능한 오류 발생", getExchangeName(), error),
122+
() -> log.info("[{}] 전체 WebSocket 연결이 정상적으로 종료되었습니다.", getExchangeName())
123+
);
124+
}
125+
126+
/**
127+
* 리스트를 {@code chunkSize} 단위로 분할하여 반환한다.
128+
*
129+
* @param list 분할할 원본 리스트
130+
* @param chunkSize 청크 크기
131+
* @return 분할된 청크 리스트
132+
*/
133+
private <T> List<List<T>> partition(List<T> list, int chunkSize) {
134+
List<List<T>> chunks = new ArrayList<>();
135+
for (int i = 0; i < list.size(); i += chunkSize) {
136+
chunks.add(list.subList(i, Math.min(i + chunkSize, list.size())));
137+
}
138+
return chunks;
139+
}
140+
141+
/**
142+
* WebSocket 세션을 처리한다.
143+
* 구독 메시지 전송, 주기적 Ping, 메시지 수신을 병렬로 실행한다.
144+
*
145+
* @param session WebSocket 세션
146+
* @param marketCodes 구독할 마켓코드 목록
147+
* @return 세션 처리 완료를 나타내는 Mono
148+
*/
149+
private Mono<Void> handleWebSocketSession(WebSocketSession session, List<String> marketCodes) {
150+
log.info("[{}] WebSocket 연결 성공 (마켓코드 {}개), 데이터 구독을 시작합니다.",
151+
getExchangeName(), marketCodes.size());
152+
153+
Mono<Void> subscription = sendSubscription(session, marketCodes);
154+
Mono<Void> keepAlive = sendPeriodicPing(session);
155+
Mono<Void> receiveMessages = receiveAndProcessMessages(session);
156+
157+
return Mono.when(subscription, keepAlive, receiveMessages);
158+
}
159+
160+
/**
161+
* {@link #getPingInterval()} 주기로 Ping 메시지를 전송하여 연결을 유지한다.
162+
*
163+
* @param session WebSocket 세션
164+
* @return Ping 전송 완료를 나타내는 Mono
165+
*/
166+
private Mono<Void> sendPeriodicPing(WebSocketSession session) {
167+
return Flux.interval(getPingInterval())
168+
.doOnNext(tick -> log.trace("[{}] Ping 메시지 전송 (tick: {})", getExchangeName(), tick))
169+
.flatMap(tick -> {
170+
WebSocketMessage pingMessage = session.pingMessage(factory ->
171+
factory.wrap("ping".getBytes()));
172+
return session.send(Mono.just(pingMessage));
173+
})
174+
.onErrorContinue((error, obj) ->
175+
log.warn("[{}] Ping 전송 실패, 연결 상태를 확인하세요: {}", getExchangeName(), error.getMessage()))
176+
.then();
177+
}
178+
179+
/**
180+
* WebSocket으로부터 메시지를 수신하고 {@link #handleMessage(String)}에 위임한다.
181+
*
182+
* <p>블로킹 작업(파싱·변환·발행)은 {@code boundedElastic} 스케줄러에서 실행되어
183+
* 수신 스레드가 다음 메시지를 계속 받을 수 있도록 한다.
184+
*
185+
* @param session WebSocket 세션
186+
* @return 메시지 수신 완료를 나타내는 Mono
187+
*/
188+
private Mono<Void> receiveAndProcessMessages(WebSocketSession session) {
189+
return session.receive()
190+
.map(WebSocketMessage::getPayloadAsText)
191+
.flatMap(rawJson ->
192+
handleMessage(rawJson)
193+
.subscribeOn(Schedulers.boundedElastic())
194+
.onErrorResume(error -> {
195+
log.warn("[{}] 메시지 처리 실패, 다음 메시지 계속 처리: {}",
196+
getExchangeName(), error.getMessage());
197+
return Mono.empty();
198+
}),
199+
getProcessingConcurrency())
200+
.then();
201+
}
202+
203+
/**
204+
* 애플리케이션 종료 시 WebSocket 연결을 정리한다.
205+
*
206+
* <p>{@link DisposableBean} 구현을 통해 Spring 컨테이너 종료 시 자동으로 호출된다.
207+
*/
208+
@Override
209+
public void destroy() {
210+
if (connectionDisposable != null && !connectionDisposable.isDisposed()) {
211+
log.info("[{}] WebSocket 연결을 종료합니다.", getExchangeName());
212+
connectionDisposable.dispose();
213+
}
214+
}
215+
}

src/main/java/com/rabbittick/streamer/connector/ExchangeConnector.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
* </ul>
2121
*
2222
* <p>각 거래소 구현체는 이 인터페이스를 통해 거래소별 프로토콜 차이를
23-
* 캡슐화하고, {@code AbstractExchangeConnector}가 제공하는 공통 연결
23+
* 캡슐화하고, {@link AbstractExchangeConnector}가 제공하는 공통 연결
2424
* 생명주기(연결 시작, 청크 분할, Ping, 재연결) 위에서 동작한다.
2525
*
2626
* <p>새로운 거래소를 추가하려면 이 인터페이스와
27-
* {@code AbstractExchangeConnector}를 함께 구현한다.
27+
* {@link AbstractExchangeConnector}를 함께 구현한다.
2828
*/
2929
public interface ExchangeConnector {
3030

@@ -69,7 +69,7 @@ public interface ExchangeConnector {
6969
*
7070
* <p>{@code ConnectorManager}가 {@code ApplicationReadyEvent} 수신 후
7171
* 활성화된 커넥터에 대해 호출한다. 연결 실패 시 지수 백오프 방식으로
72-
* 재연결을 시도하는 것은 {@code AbstractExchangeConnector}의 공통 구현에서 처리한다.
72+
* 재연결을 시도하는 것은 {@link AbstractExchangeConnector}의 공통 구현에서 처리한다.
7373
*/
7474
void start();
7575

@@ -102,7 +102,7 @@ public interface ExchangeConnector {
102102
* 구독할 마켓코드 목록을 로드한다.
103103
*
104104
* <p>설정 파일 또는 외부 API에서 마켓코드를 조회하는 방식은
105-
* 구현체가 직접 정의한다. 로드된 목록은 {@code AbstractExchangeConnector}의
105+
* 구현체가 직접 정의한다. 로드된 목록은 {@link AbstractExchangeConnector}의
106106
* 청크 분할 로직에 의해 연결당 최대 구독 수 이하로 나뉘어 처리된다.
107107
*
108108
* @return 구독할 마켓코드 목록

0 commit comments

Comments
 (0)