개요

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는 상태fulbuild()마다 새 커넥션 풀이 생성되고, 기존 풀의 커넥션은 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_timeoutinteractive_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 → 리렌더(가격 반영)이 되지만, 첫 리렌더에서 이전 가격을 보여주므로 깜빡임이 줄어듦
  • 더 근본적으로는 accumulatedPricesuseMemo로 계산하면 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 재검증
  • 1개 종목 에러 → 전체 배치 재요청

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가지

  1. 서버에서 합쳐서 보내면 클라이언트 waterfall은 사라진다 — FE 최적화보다 BE 응답 구조 개선이 근본적
  2. 동시성 제어 ≠ 병렬 처리 — Semaphore와 RateLimiter는 제한일 뿐, @Async가 있어야 스레드가 분산됨
  3. 캐시 전략은 “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) 또는 CaffeineRefreshAfterWrite도 고려

15. FE Error Boundary 적용

CB fallback이 정상 응답을 반환하더라도, FE 렌더링 중 예외(예: stale data의 null 필드 접근)가 발생하면 전체 페이지가 깨질 수 있음.

<ErrorBoundary fallback={<StockCardSkeleton />}>
  <StockCard stock={stock} />
</ErrorBoundary>
  • 개별 종목 카드 단위로 Error Boundary를 감싸면, 한 종목의 렌더 에러가 전체 목록에 영향을 주지 않음
  • SSR에서는 Next.js의 error.tsx로 페이지 레벨 대응