목록으로

Spring Boot OSIV, 왜 끄기가 무서울까

11

Spring Boot OSIV, 왜 끄기가 무서울까

→ 월요일 아침마다 터지는 커넥션 풀 고갈. 로그를 추적하다 보니 OSIV가 범인이었다.

2026-03-24


월요일 아침 8시 23분, 텔레그램에 알람이 쏟아졌다.

ConfHikariPool - Connection is not available, request timed out after 30000ms
(total=20, active=20, idle=0, waiting=180)

DB 커넥션 풀이 꽉 찼다. 20개 전부 사용 중이고, 180개 요청이 대기 중. PROFILE, PIN_CHECK, 알림 조회 같은 평범한 API들까지 전부 30초 타임아웃으로 실패하고 있었다.

범인 찾기

처음엔 거래소 API(빗썸, 코인원, 코빗)가 월요일 아침에 느려지는 거 아닌가 의심했다. 우리 서비스의 /v3/account API가 세 거래소의 잔액을 순차적으로 조회하기 때문

그래서 로그를 뒤졌다.

grep "(BithumbServerException|CoinoneServerException|KorbitServerException)" service.log
# → 결과 없음

거래소 타임아웃 0건. 거래소는 무죄였다.

그런데 같은 시간대 /v3/account의 응답시간을 보니:

시간대msTime
정상 (13:08)207-481ms
장애 (08:23)80,313-108,498ms

80초-108초. 정상의 200배.

진짜 원인: OSIV

spring.jpa.open-in-view를 검색해보니 설정이 없었다. Spring Boot 기본값은 true.

OSIV(Open Session In View)가 켜져 있으면 EntityManager가 HTTP 요청의 시작부터 끝까지 살아있다. 문제는 첫 DB 쿼리 시점에 획득한 JDBC 커넥션도 요청이 끝날 때까지 반환되지 않는다는 것.

우리 /v3/account API의 처리 흐름을 그려보면:

[DB] 지갑 조회         10ms
[DB] 약관 동의 조회      5ms
[DB] 코인원 토큰 조회     3ms
[HTTP] 코인원 API     200ms  ← 커넥션 점유 중
[DB] 코빗 토큰 조회      3ms
[HTTP] 코빗 API       300ms  ← 커넥션 점유 중
[HTTP] 빗썸 API       200ms  ← 커넥션 점유 중
[HTTP] 블록체인 조회    200ms  ← 커넥션 점유 중
[DB] 기타 조회들        30ms

DB 쿼리 자체는 60ms면 끝나는데, 외부 HTTP 호출 동안에도 커넥션을 물고 있어서 한 요청당 약 960ms를 점유한다. maximum-pool-size=20이니까, 동시에 20개 요청만 들어와도 풀이 찬다.

월요일 아침 출근 시간대에 사용자들이 앱을 열면 /v3/account가 동시에 밀려들고, 20개 커넥션이 전부 차면 나머지 요청은 30초 대기 후 타임아웃. 그게 연쇄적으로 180건까지 쌓인 거다.

해결: DB 조회 먼저, 외부 호출은 병렬로

OSIV를 끄면 가장 깔끔하지만 전역 설정이라 다른 API에 영향을 줄 수 있다. 그래서 두 가지를 먼저 적용했다.

1. DB 조회를 앞으로 모으기

// Phase 1: DB 조회 전부 완료 (약 60ms)
val walletConnections = walletConnectUserRepository.findAll(mycode)
val coinoneToken = coinoneTokenRepository.findById(mycode)
val korbitToken = korbitTokenRepository.findById(mycode)
val symbolManage = manageService.getSymbolManage()
val connectManage = manageService.getConnectManage()
val paycoinWallet = ppWalletRepository.find(mycode, PCI)

// Phase 2: 외부 API 병렬 호출 (약 300ms)
val walletBalance = walletConnectInfoService.getMarketBalanceInfo(...)
val onchainBalance = paycoinOnchainService?.getAccount(address)

2. 거래소 API를 CompletableFuture로 병렬 호출

// 토큰은 Phase 1에서 이미 조회해둔 상태
val bithumbFuture = CompletableFuture.supplyAsync {
    bithumbClient.userWalletBalance(buid, symbol)
}
val coinoneFuture = CompletableFuture.supplyAsync {
    coinoneClient.getBalanceList(coinoneToken.accessToken, symbolList)
}
val korbitFuture = CompletableFuture.supplyAsync {
    korbitClient.getAssets(korbitToken.accessToken)
}
// 전부 기다림 - 가장 느린 코빗 기준 약 300ms

변경 전에는 빗썸(200ms) → 코인원(200ms) → 코빗(300ms)을 순차로 돌려서 700ms였는데, 병렬로 바꾸니 300ms(가장 느린 코빗 기준)로 줄었다.

결과

항목변경 전변경 후
응답시간약 960ms약 365ms
커넥션 점유약 960ms약 365ms

OSIV가 여전히 켜져 있어서 커넥션 점유는 365ms이지만, 순차 960ms보다 2.6배 좋아졌다. OSIV를 끄면 커넥션 점유가 60ms(DB 쿼리만)로 줄어들어 16배 개선이 가능한데, 이건 모니터링 후 다음 스텝으로.

교훈

OSIV는 편하다. @Transactional 없이도 Lazy Loading이 되니까. 하지만 그 편함의 대가로 DB 커넥션이 HTTP 요청 전체 수명 동안 잡혀있다는 걸 잊으면, 외부 API 호출이 끼어드는 순간 커넥션 풀이 터진다.

특히 이런 패턴이 위험하다:

@Service에서 DB 조회 → 외부 HTTP 호출 → 다시 DB 조회

이 사이에 OSIV가 커넥션을 잡고 있다는 걸 인지하지 못하면, 평소엔 문제없다가 트래픽이 몰리는 순간 연쇄 장애로 번진다.