개요
AI 뉴스 기반 주식 추천 서비스(StockAI)에서 조회 속도 저하 문제를 분석하고, BE(Spring Boot) + FE(Next.js) 전체 스택에 걸쳐 13개 최적화 항목을 도출. 순차적 병목 → 병렬화, 2왕복 → 1왕복, 중복 요청 → 캐시 재사용 등의 원칙으로 접근.
1. 국내 주식 배치 가격 조회: 순차 → 병렬
Before
StockPriceBatchService.processChunk()
├─ stockCode[0]: cache miss → KIS API → 125ms
├─ stockCode[1]: cache miss → KIS API → 125ms
├─ stockCode[2]: cache miss → KIS API → 125ms
├─ ... (50개 순차 대기)
└─ stockCode[49]: cache miss → KIS API → 125ms
총 소요: ~6.25초
Semaphore(10), RateLimiter(8.0/sec)로 동시성 제어는 있으나 for문 안에서 순차 acquire() 후 동기 호출
- 해외 주식(
OverseasStockPriceBatchService)은 이미 @Async + CompletableFuture 병렬 패턴인데 국내만 누락
After
StockPriceBatchService.processChunk()
├─ 1차: 캐시 조회 (cache hit 즉시 반환)
└─ 2차: cache miss 항목만 CompletableFuture.allOf() 병렬 dispatch
├─ Future[0]: @Async → KIS API
├─ Future[1]: @Async → KIS API
├─ ... (Semaphore=10, 최대 10개 동시)
└─ Future[49]: @Async → KIS API
총 소요: ~6.25초 (50개 ÷ 8 TPS), 하지만 스레드는 블록하지 않음
핵심 인사이트
- 동시성 제어 도구(Semaphore, RateLimiter)와 호출 방식(동기/비동기)은 독립적 — 제어 도구가 있다고 병렬이 되는 건 아님.
@Async + CompletableFuture로 스레드에 작업을 분산해야 Semaphore가 의미 있음
- RateLimiter 초당 8건 제약이 병목 → 50건 처리에 최소 50÷8 ≈ 6.25초 필요. Semaphore=10으로 10개 스레드가 동시에 acquire()해도 RateLimiter가 8 TPS로 직렬화하므로, 시간 단축은 RateLimiter 한도 완화 전제
- 병렬화의 진짜 이점은 스레드 블록 해제 — 순차 코드에서는 호출 스레드가 6.25초 내내 점유되지만,
@Async에서는 Future를 반환 후 스레드가 즉시 해제됨
효과
| 지표 |
Before |
After |
| 50종목 배치 응답 시간 |
~6.25초 (호출 스레드 점유) |
~6.25초 (RateLimiter 한도) |
| KIS API 호출 방식 |
순차 동기 block |
병렬 비동기 (스레드 논블록) |
| 호출 스레드 점유 시간 |
~6.25초 |
즉시 반환 (Future 위임) |
| 타임아웃 처리 |
없음 |
allOf().get(20s) + cancel |
2. OAuth WebClient: 매번 생성 → 싱글톤 재사용
Before
private WebClient getOAuthClient() {
return WebClient.builder()
.baseUrl(kisConfig.getOauthUrl())
.build(); // 매 호출 시 새 인스턴스
}
WebClient.builder().build()는 내부적으로 새 Reactor Netty ConnectionProvider 생성
- 커넥션 풀 생성 비용 + TCP handshake + SSL 협상이 매 토큰 갱신마다 발생
- KIS 토큰은 하루에도 수십 번 갱신 가능 (TTL 만료, 회수 등)
After
// WebClientConfig.java
@Bean("kisOAuthWebClient")
public WebClient kisOAuthWebClient(KisConfig kisConfig) {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(5));
return WebClient.builder()
.baseUrl(kisConfig.getOauthUrl())
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
핵심 인사이트
- WebClient는 무상태(stateless)이지만 내부 ConnectionProvider는 상태ful —
build()마다 새 커넥션 풀이 생성되고, 기존 풀의 커넥션은 close되지 않은 채 방치됨 (메모리 leak 가능)
- Spring에서 HTTP 클라이언트 Bean은 반드시 싱글톤으로 관리해야 커넥션 재사용이 가능
- 위 코드에서는 명시적
ConnectionProvider 없이 HttpClient.create()를 사용하므로 기본 풀(무제한 idle)이 적용됨 — Section 3의 커넥션 풀 설정을 OAuth용 WebClient에도 동일하게 적용해야 stale connection 문제가 발생하지 않음
효과
| 지표 |
Before |
After |
| 토큰 발급 시 커넥션 생성 |
매번 (TCP+SSL ~100ms) |
풀에서 재사용 (~1ms) |
| 메모리 누수 위험 |
누적 ConnectionProvider |
단일 인스턴스 |
| stale connection 위험 |
높음 (기본 풀 사용 시) |
낮음 (명시적 풀 설정 시) |
| 타임아웃 보장 |
없음 |
connect 5s, response 5s |
3. WebClient 커넥션 풀 설정
Before
Reactor Netty 기본 ConnectionProvider:
- maxConnections: 500 (ConnectionProvider.pool 생성 시)
- maxIdleTime: 무한 (idle 커넥션 정리 없음)
- maxLifeTime: 무한
- evictInBackground: 없음
- 500개 커넥션이 필요 없는 서비스인데 기본값 사용
- idle 커넥션이 영구 생존 → DB/대상 서버에서 timeout이 발생해도 클라이언트는 모름 → stale connection 에러
After
ConnectionProvider provider = ConnectionProvider.builder("kis-pool")
.maxConnections(100)
.pendingAcquireMaxCount(500)
.pendingAcquireTimeout(Duration.ofSeconds(10))
.maxIdleTime(Duration.ofSeconds(30))
.maxLifeTime(Duration.ofSeconds(60))
.evictInBackground(Duration.ofSeconds(30))
.build();
핵심 인사이트
- maxIdleTime과 maxLifeTime의 차이 — idleTime은 마지막 사용 후 경과, lifeTime은 커넥션 생성 후 경과. 둘 다 설정해야 오래된 커넥션이 정리됨
evictInBackground는 별도 스레드에서 주기적으로 정리. 없으면 커넥션이 다음 요청 시점에야 close됨
- KIS API는 1초당 8건 제한이므로 100개 커넥션으로 충분. 과도한 풀 크기는 리소스 낭비
효과
| 지표 |
Before |
After |
| maxConnections |
500 |
100 |
| idle 커넥션 정리 |
없음 |
30초 |
| stale connection 위험 |
높음 |
낮음 (60초 lifeTime + evictInBackground) |
| 메모리 사용 |
불필요한 커넥션 유지 |
적정 수준 유지 |
4. 카탈로그 + 가격 통합: 2왕복 → 1왕복
Before
FE (Server Component)
├─ 1st: GET /api/stocks?page=0 → 종목 목록 [{stockCode, name, sector...}]
│ 응답에 가격 없음 (또는 캐시에 없으면 withoutPrice)
└─ 2nd: POST /api/stocks/prices → {005930: {price:...}, 000660: {price:...}}
↑ 1st 응답의 stockCode 목록이 필요 → 반드시 순차
왕복: 2회 (waterfall)
- BE에 이미
StockCatalogWithPriceResponse DTO와 enrichWithPrice() 메서드가 존재
- But 캐시에 없는 가격은
withoutPrice로 반환 → FE가 별도 batch 호출 필수
After
FE (Server Component)
└─ 1st: GET /api/stocks?page=0 → {stockCode, name, sector, price, changeRate...}
enrichWithPrice()에서 캐시 miss 시 StockPriceService로 실시간 조회
(조회 결과는 @Cacheable에 의해 캐시에도 저장됨)
왕복: 1회
핵심 인사이트
- Backend-Driven Enrichment 원칙 — 클라이언트가 조합해야 할 데이터는 서버에서 미리 합쳐서 반환하는 것이 네트워크 왕복을 줄이는 근본적 해결책
enrichWithPrice()에서 “캐시에 없으면 빈 값 반환”은 안전하지만 비효율적 — 실시간 조회로 채워도 @Cacheable에 의해 후속 요청은 캐시 hit가 됨
- 다수의 cache miss가 한꺼번에 발생할 수 있으므로 배치 병렬화 선행 필수
효과
| 지표 |
Before |
After |
| SSR 시 HTTP 왕복 |
2회 (waterfall) |
1회 |
| FE→BE 요청 수 |
2 |
1 |
| TTFB (캐시 hit 시) |
~200ms |
~100ms |
| TTFB (캐시 miss 시) |
~6.5초+ |
~1.5초 (배치 병렬화 후) |
5. HikariCP 커넥션 풀 튜닝
Before
Spring Boot 기본 HikariCP:
- maximumPoolSize: 10
- minimumIdle: 10
- connectionTimeout: 30000ms (30초)
- leakDetectionThreshold: 0 (비활성)
- 10개 풀로 고부하 시 커넥션 대기 발생 가능
- 30초 타임아웃은 너무 김 → 장애 시 장시간 응답 지연
- 커넥션 누수 감지 없음
After
spring.datasource.hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000 # 5분
max-lifetime: 600000 # 10분
connection-timeout: 5000 # 5초
leak-detection-threshold: 10000 # 10초
핵심 인사이트
- maximumPoolSize는 “동시 연결 수”가 아니라 “풀의 최대 크기” — 10개가 다 사용 중이면 11번째 요청은 대기. 서비스의 동시 DB 쿼리 수(동시 가격 조회 + 카탈로그 + 인증 등)를 기준으로 설정해야 함
connection-timeout=5000은 “5초 내 커넥션 획득 실패 시 예외” → 30초 동안 스레드가 블록되는 것 방지
leak-detection-threshold=10000은 커넥션을 10초 이상 반납하지 않으면 WARN 로그 → 버그 조기 발견
max-lifetime=10분은 MySQL wait_timeout(기본 28800초=8시간)보다 짧게 설정하는 원칙에는 맞으나, 지나치게 공격적 — 풀 전체 커넥션이 10분마다 교체되어 불필요한 TCP 재연결 발생. 보통 5~30분 사이가 적절하며, MySQL의 wait_timeout과 interactive_timeout을 확인 후 그보다 2~3분 짧게 설정하는 것이 권장됨
효과
| 지표 |
Before |
After |
| 최대 동시 DB 커넥션 |
10 |
20 |
| 장애 시 응답 지연 |
최대 30초 블록 |
5초 타임아웃 |
| 커넥션 누수 감지 |
없음 |
10초 경고 |
| idle 최소 유지 |
10 (불필요한 유지) |
5 |
6. Balance/Summary 중복 호출 통합
Before
GET /api/account/balance → kisApiClient.getBalance() → output1
GET /api/account/balance/summary → kisApiClient.getBalance() → output2
↑ 같은 KIS API 2회 호출
- 클라이언트가 두 API를 연속 호출하면 동일한 KIS API 요청이 2번 발생
- KIS API는 초당 호출 제한이 있어 중복 호출이 제한 소모
After
getBalanceFull() → kisApiClient.getBalance() 1회 호출
├─ getBalance(): 캐시된 응답에서 output1 추출
└─ getBalanceSummary(): 캐시된 응답에서 output2 추출
핵심 인사이트
- 같은 외부 API를 여러 서비스 메서드에서 호출할 때 캐시 또는 통합이 필요 — 응답의 다른 부분(output1 vs output2)만 필요하더라도, 요청 자체는 동일하므로 1회만 호출
- KIS API의
getBalance()는 무거운 호출 (잔고 전체 조회). TTL 10초 캐시면 충분 (실시간성 요구 낮음)
효과
| 지표 |
Before |
After |
| KIS API 호출 |
2회/요청 |
1회/요청 |
| 초당 호출 제한 소모 |
2 |
1 |
| 응답 시간 (2번째) |
~300ms |
캐시 hit <5ms |
7. DB 인덱스: sector 단일 컬럼
Before
-- 복합 인덱스만 존재
CREATE INDEX idx_stocks_market_type_sector ON stocks (market_type, sector);
-- findBySector() 쿼리:
SELECT * FROM stocks WHERE sector = '반도체'
-- → 복합 인덱스의 선행 컬럼(market_type)이 조건에 없으므로 인덱스 미사용 → Seq Scan
After
CREATE INDEX idx_stocks_sector ON stocks (sector);
핵심 인사이트
- 복합 인덱스의 최 좌선 원칙(Leftmost Prefix Rule) —
(A, B) 인덱스는 WHERE A=?와 WHERE A=? AND B=?에만 사용 가능. WHERE B=?는 인덱스를 타지 않음
- 단일 컬럼 인덱스 추가는 디스크 공간을 약간 차지하지만, 필터 전용 쿼리의 성능은 극적으로 개선
효과
| 지표 |
Before |
After |
findBySector() 실행 계획 |
Seq Scan |
Index Scan |
| 2000행 기준 조회 시간 |
~15ms |
~1ms |
8. Circuit Breaker Fallback: 장애 전파 → Graceful Degradation
Before
private StockPriceResponse getStockPriceFallback(Exception e) {
throw new RuntimeException("Circuit open"); // 호출부로 예외 전파
}
- CB가 열리면 모든 가격 조회가 500 에러
- 프론트엔드는 에러 표시 또는 빈 값
After
private StockPriceResponse lastGoodResponse; // 마지막 정상 응답 캐시
private StockPriceResponse getStockPriceFallback(Exception e) {
StockPriceResponse response = new StockPriceResponse();
if (lastGoodResponse != null) {
response = lastGoodResponse; // stale data라도 반환
response.setError("CIRCUIT_OPEN_STALE");
} else {
response.setError("CIRCUIT_OPEN");
}
return response;
}
핵심 인사이트
- Circuit Breaker의 목적은 “장애 격리” — fallback에서 예외를 throw하면 격리가 아니라 형태만 바뀐 장애 전파
- 호출부가 이미
error 필드를 체크하는 구조(if (price.getError() == null))라면, fallback이 에러 DTO를 반환하는 것이 자연스러운 흐름
- 사용자는 “일시적 오류” 메시지를 보는 대신 “가격 정보 없음”으로 부드럽게 저하
- Stale Data 전략 — CB가 열리기 직전의 마지막 정상 응답을 반환하면, 사용자는 0이나 빈 값 대신 “마지막으로 확인된 가격”을 볼 수 있어 UX가 크게 개선됨. 단, 데이터가 오래될 수 있으므로 FE에서
error 필드로 표시 필요
효과
| 지표 |
Before |
After |
| CB open 시 FE 응답 |
500 에러 (화면 깨짐) |
정상 응답 + 에러 표시 (또는 stale data) |
| 연쇄 장애 |
가능 |
차단 |
| UX |
전체 페이지 에러 |
개별 종목 가격 숨김 또는 마지막 가격 표시 |
9. FE 카탈로그 Waterfall 제거
Before
Server Component:
const catalog = await getStocks(...) // ~200ms
const prices = await getBatchPrices(codes) // ~6s (전체 cache miss 시)
// 총: ~6.2초 (순차)
After
Server Component:
const catalog = await getStocks(...) // 이미 가격 포함 (~200ms + 실시간 조회)
const prices = catalog에서 추출 // 0ms (추출만)
// 총: ~200ms (캐시 hit) or ~1.5s (miss)
핵심 인사이트
- Server Component에서의 waterfall은 TTFB에 직접 영향 — CSR에서는 병렬 useSWR이 가능하지만, SSR에서는
await의 순차 실행이 불가피
- 해결책은 “FE에서 병렬화”가 아니라 “BE에서 합쳐서 보내기” — 근원적 해결
- 후속 페이지(infinite scroll)의 delta 조회는 유지: SSR은 첫 페이지만 담당
효과
| 지표 |
Before |
After |
| SSR HTTP 왕복 |
2 |
1 |
| TTFB (cache hit) |
~400ms |
~200ms |
| TTFB (cache miss) |
~6.5초 |
~1.5초 |
| FE 복잡도 |
서버에서 batch 호출 필요 |
initialData에서 바로 추출 |
10. TradePanel raw fetch → SWR 전환
Before
useEffect(() => {
getHoldings().then(setUserHolding).catch(...);
}, [isAuthenticated, stockCode]);
// 페이지 이동 시마다 재요청, dedup 없음
After
const { data: holdings } = useSWR(
isAuthenticated ? 'user-holdings' : null,
getHoldings,
{ dedupingInterval: 5000 }
);
// 동일 key면 dedup, Dashboard와 캐시 공유
핵심 인사이트
- raw fetch + useEffect는 SWR의 dedup, 캐시, 재검증 이점을 모두 포기하는 것 — 동일한 API를 다른 컴포넌트에서 다른 전략으로 호출하면 중복 요청 발생
- SWR key를 통일하면 여러 컴포넌트가 같은 캐시를 공유 → 네트워크 요청 1회로 N개 컴포넌트 업데이트
효과
| 지표 |
Before |
After |
| 페이지 이동 시 holdings 요청 |
매번 |
dedup (10s 내 중복 제거) |
| Dashboard ↔ 상세 페이지 캐시 |
공유 안됨 |
동일 key로 공유 |
| 주문 후 갱신 |
수동 fetch |
mutate('user-holdings') |
11. 렌더 중 dispatch → useEffect
Before
if (deltaPrices) {
dispatch({ type: 'merge', prices: deltaPrices });
}
// 렌더 함수 본문 내에서 state 변경 → React가 두 번 렌더링함
- SWR이
deltaPrices를 갱신 → 리렌더 1회 + dispatch로 reducer 상태 변경 → 리렌더 1회 = 동일 데이터에 2회 렌더
After
useEffect(() => {
if (deltaPrices) dispatch({ type: 'merge', prices: deltaPrices });
}, [deltaPrices]);
핵심 인사이트
- React에서 렌더 중 state 변경은 “바로 다시 렌더”를 트리거 —
useEffect는 렌더 완료 후 실행되므로, SWR 갱신 → 리렌더(데이터 반영) → useEffect에서 dispatch → 리렌더(가격 반영)이 되지만, 첫 리렌더에서 이전 가격을 보여주므로 깜빡임이 줄어듦
- 더 근본적으로는
accumulatedPrices를 useMemo로 계산하면 dispatch 없이 합성 가능:
const accumulatedPrices = useMemo(() => {
const merged = { ...initialPrices };
if (deltaPrices) Object.assign(merged, deltaPrices);
return merged;
}, [initialPrices, deltaPrices]);
이렇게 하면 reducer와 dispatch를 완전히 제거할 수 있으며, 리렌더도 SWR 갱신 시 1회만 발생함
효과
| 지표 |
Before |
After |
| 배치 가격 도착 시 리렌더 |
2회 |
1회 |
| 렌더 중 state 변경 경고 |
가능 |
없음 |
12. Price Badge retry 범위 축소
Before
mutate(
(key) => Array.isArray(key) && key[0] === 'batch-prices',
undefined,
{ revalidate: true }
);
// → batch-prices로 시작하는 모든 SWR key 재검증
After
// 해당 종목 단건 API로 대체
const retryPrice = await getStockPrice(stockCode);
핵심 인사이트
- SWR mutate의 필터 매칭은 편리하지만 비용이 큼 — prefix 매칭으로 수십 개 key를 재검증하면, 정상 가격까지 불필요하게 다시 fetch
- 에러 발생 종목만 단건 API로 조회하는 것이 네트워크 비용 최소
효과
| 지표 |
Before |
After |
| retry 시 재요청 범위 |
전체 배치 (50종목) |
실패 1종목 |
| 네트워크 비용 |
POST + 50종목 응답 |
GET + 1종목 응답 |
13. 캔들 데이터 SWR 전환
Before
const [state, dispatch] = useReducer(reducer, initialState);
const fetchCandleData = useCallback(() => {
dispatch({ type: 'FETCH_START' });
getDailyCandles({...}).then(d => dispatch({type:'FETCH_CANDLES_SUCCESS', payload: d}));
}, [...]);
useEffect(() => { fetchCandleData(); }, [fetchCandleData]);
After
const { data: candles, isLoading: candlesLoading } = useSWR(
stockCode ? ['daily-candles', stockCode, period, startDate, endDate] : null,
() => getDailyCandles({stockCode, period, startDate, endDate}),
{ revalidateOnFocus: false }
);
const { data: minuteCandles } = useSWR(
viewMode === 'minute' && stockCode ? ['minute-candles', stockCode] : null,
() => getMinuteCandles(stockCode),
{ revalidateOnFocus: false }
);
핵심 인사이트
- 로딩/에러/데이터 상태 관리는 SWR이 이미 제공 —
useReducer로 직접 관리하면 코드 중복 + 버그 가능성 증가
- SWR key에 파라미터를 포함하면 파라미터 변경 시 자동 재요청 →
useCallback 의존성 관리 불필요
viewMode === 'minute' 조건부 key(null이면 fetch 안 함)로 불필요한 API 호출 방지
효과
| 지표 |
Before |
After |
| 상태 관리 코드 |
~30행 (reducer + action types) |
제거 |
| 캐시/dedup |
없음 |
SWR 자동 |
| period 변경 시 재요청 |
수동 useCallback 관리 |
key 변경으로 자동 |
종합 효과 요약
| 시나리오 |
Before |
After |
개선율 |
| 카탈로그 첫 로드 (cache hit) |
2왕복 ~400ms |
1왕복 ~200ms |
50%↓ |
| 카탈로그 첫 로드 (cache miss) |
2왕복 ~6.5초 |
1왕복 ~6.25초 (RateLimiter 한도) |
4%↓ + 요청 절반 |
| 국내 배치 가격 50종목 |
~6.25초 (순차, 스레드 점유) |
~6.25초 (병렬, 스레드 논블록) |
스레드 가용성↑ |
| KIS 토큰 발급 커넥션 오버헤드 |
~100ms/회 |
~1ms/회 |
99%↓ |
| Balance+Summary 동시 요청 |
KIS API 2회 |
1회+캐시 |
50%↓ |
| CB open 시 사용자 경험 |
500 에러 (전체 장애) |
stale data + 에러 표시 |
가용성↑ |
| 배치 가격 도착 시 리렌더 |
2회 |
1회 |
50%↓ |
| 종목 상세 페이지 이동 |
holdings 중복 요청 |
SWR dedup |
중복 제거 |
| badge retry 네트워크 비용 |
50종목 재요청 |
1종목 단건 |
98%↓ |
핵심 교훈 3가지
- 서버에서 합쳐서 보내면 클라이언트 waterfall은 사라진다 — FE 최적화보다 BE 응답 구조 개선이 근본적
- 동시성 제어 ≠ 병렬 처리 — Semaphore와 RateLimiter는 제한일 뿐,
@Async가 있어야 스레드가 분산됨
- 캐시 전략은 “miss 시 어떻게 채울 것인가”까지 설계해야 한다 — hit만 최적화하면 miss 경로가 새 병목이 됨
추가 고려 항목
14. Cache Stampede 방지
다수의 cache miss가 동시에 발생하면, 여러 스레드가 동일 키에 대해 외부 API를 중복 호출함.
// @Cacheable만 사용 시: 10개 스레드가 동시에 getStockPrice("005930") 호출 → KIS API 10회
// 해결: @Cacheable + CacheLoader 또는 stampede guard
@Cacheable(value = "stockPrice", key = "#stockCode", sync = true)
public StockPriceResponse getStockPrice(String stockCode) { ... }
- Spring의
sync = true는 같은 키에 대해 1개 스레드만 로드하고 나머지는 대기 → 중복 API 호출 방지
- 대규모 트래픽에서는 분산 락(Redis) 또는
Caffeine의 RefreshAfterWrite도 고려
15. FE Error Boundary 적용
CB fallback이 정상 응답을 반환하더라도, FE 렌더링 중 예외(예: stale data의 null 필드 접근)가 발생하면 전체 페이지가 깨질 수 있음.
<ErrorBoundary fallback={<StockCardSkeleton />}>
<StockCard stock={stock} />
</ErrorBoundary>
- 개별 종목 카드 단위로 Error Boundary를 감싸면, 한 종목의 렌더 에러가 전체 목록에 영향을 주지 않음
- SSR에서는 Next.js의
error.tsx로 페이지 레벨 대응