Skip to content

[BUG] 외부 API 지연/장애 시 서비스 전체 먹통 #147

@ryuwldnjs

Description

@ryuwldnjs

요약

외부 API(SolvedAc, AWS SES)를 @transactional 안에서 동기 호출하고 있어,
외부 API가 느려지면 두 가지 리소스가 불필요하게 점유된다.

요청 → Tomcat 스레드 점유 → @Transactional → DB 커넥션 점유
         ↓                                      ↓
    외부 API 응답 대기 (수 초~무한)         외부 API 응답 대기 (수 초~무한)
         ↓                                      ↓
    스레드 반환                              커넥션 반환
  • DB 커넥션 풀 (기본 10개) 고갈 → 동시 20건에서 500 에러 + 정상 사용자 401 오류
  • Tomcat 스레드 풀 (기본 200개) 고갈 → 동시 200건에서 서비스 전체 응답 불가
  • timeout 미설정 → 외부 API 무응답 시 무한 대기

영향 범위

외부 API를 호출하는 모든 사용자 기능과 배치 작업에 해당한다.

사용자 API (외부 API 호출 + @transactional)

기능 메서드 외부 호출 트랜잭션
이메일 변경 MemberService.sendEmailVerification() mailSender.send() 클래스 레벨 @Transactional
백준 핸들 등록 MemberService.verifySolvedAcHandle() solvedAcClient.getUserInfo() 클래스 레벨 @Transactional
백준 핸들 인증 BojVerificationFacade.verifyBoj() solvedAcClient.getUserBio() 클래스 레벨 @Transactional
문제 풀이 인증 MemberService.verifySolvedProblem() solvedAcClient.hasUserSolvedProblem() 클래스 레벨 @Transactional
팀 초대 TeamJoinService.inviteMember() mailSender.send() @Transactional
수동 문제추천 RecommendationService.createManualRecommendation() solvedAcClient + mailSender.send() 클래스 레벨 @Transactional

6개 기능 모두 @transactional 안에서 외부 API를 호출하므로, 외부 API 지연 시 DB 커넥션을 장시간 점유한다.

스케줄러 배치

스케줄러 메서드 외부 호출 문제
문제 추천 (06시) prepareDailyRecommendations() solvedAcClient × N팀 전체가 하나의 트랜잭션, 한 팀 실패 시 UnexpectedRollbackException
이메일 발송 (09시) sendPendingRecommendationEmails() mailSender.send() × M명 전체가 하나의 트랜잭션

문제 1: 응답 지연

1-1. timeout 미설정 — 외부 API 지연이 그대로 전파

SolvedAcRestClient에 timeout 설정이 없어서, 외부 API가 느려지면 우리 서비스 응답도 동일하게 느려진다.

// SolvedAcRestClient — timeout 없음
this.rest = RestClient.builder()
        .baseUrl("https://solved.ac/api/v3")
        .defaultHeader("User-Agent", "studyhelper/1.0")
        .build();  // connectTimeout, readTimeout 미설정

외부 API가 10초 걸리면 → 우리 API도 10초.
외부 API가 응답 안 하면 → 우리 API도 무한 대기.

1-2. 동기 호출 — Tomcat 스레드 점유

모든 외부 API 호출이 동기(blocking) 방식이라, 응답이 올 때까지 Tomcat 스레드를 점유한다.

요청 → Tomcat 스레드 할당 → 외부 API 호출 (수십 초 대기) → 스레드 반환
                                ↑ 이 동안 스레드 점유

메일 발송처럼 사용자가 결과를 기다릴 필요 없는 기능도 동기로 처리하고 있어,
불필요하게 스레드를 잡고 있다.

1-3. 순차 처리 — 배치 소요 시간 증가

스케줄러 배치가 for 루프로 순차 처리되어 있다.

6시 배치: 팀 N개 × SolvedAc API 1회 = N번 직렬 호출
9시 배치: 회원 M명 × SES 1회 = M번 직렬 호출

팀 10개면 SolvedAc 10번 직렬, 회원 50명이면 SES 50번 직렬.
병렬 처리나 비동기 처리 없음.


문제 2: 서비스 장애 (커넥션 풀 고갈)

응답 지연이 누적되면 DB 커넥션 풀이 고갈되어 서비스 전체가 먹통이 된다.

원인

외부 I/O(네트워크)가 DB 커넥션을 점유하는 구간 안에서 실행된다.

@Transactional 시작 → 커넥션 획득
  ├─ DB 쿼리 (수 ms)
  ├─ 외부 API 호출 (수 초~수십 초)   ← 이 동안 커넥션 점유
  └─ DB 저장 (수 ms)
@Transactional 종료 → 커넥션 반환

재현

환경

  • FakeMailSender (20초 지연)로 외부 API 지연 시뮬레이션
  • HikariCP 기본 설정 (pool size: 10, connectionTimeout: 30초)

테스트 1: 커넥션 풀 고갈 (32건 동시 요청)

./scripts/load-test.sh without-tx 32

결과:

  요청 #6 → HTTP 200 (20.064588초)    ┐
  요청 #7 → HTTP 200 (20.063363초)    │
  요청 #4 → HTTP 200 (20.064879초)    │
  요청 #1 → HTTP 200 (20.063786초)    │ 1차 배치: 10건 성공
  요청 #2 → HTTP 200 (20.064500초)    │ (커넥션 10개 점유)
  요청 #3 → HTTP 200 (20.069324초)    │
  요청 #10 → HTTP 200 (20.071395초)   │
  요청 #12 → HTTP 200 (20.050326초)   │
  요청 #11 → HTTP 200 (20.050036초)   │
  요청 #8 → HTTP 200 (20.054311초)    ┘
  요청 #13 → HTTP 500 (30.053549초)   ┐
  요청 #28 → HTTP 500 (30.023065초)   │
  요청 #25 → HTTP 500 (30.028111초)   │
  요청 #31 → HTTP 500 (30.022123초)   │
  요청 #24 → HTTP 500 (30.028155초)   │ 타임아웃: 12건 실패
  요청 #26 → HTTP 500 (30.029122초)   │ (30초 대기 후 커넥션 못 얻음)
  요청 #27 → HTTP 500 (30.031413초)   │
  요청 #23 → HTTP 500 (30.031350초)   │
  요청 #29 → HTTP 500 (30.023957초)   │
  요청 #32 → HTTP 500 (30.023920초)   │
  요청 #30 → HTTP 500 (30.024327초)   │
  요청 #22 → HTTP 500 (30.036143초)   ┘
  요청 #19 → HTTP 200 (40.056461초)   ┐
  요청 #5 → HTTP 200 (40.063345초)    │
  요청 #14 → HTTP 200 (40.058174초)   │
  요청 #17 → HTTP 200 (40.054969초)   │ 2차 배치: 10건 성공
  요청 #16 → HTTP 200 (40.071116초)   │ (1차 배치 완료 후 커넥션 획득)
  요청 #9 → HTTP 200 (40.071535초)    │
  요청 #21 → HTTP 200 (40.052459초)   │
  요청 #18 → HTTP 200 (40.071162초)   │
  요청 #15 → HTTP 200 (40.066877초)   │
  요청 #20 → HTTP 200 (40.052873초)   ┘
시간 상태 건수 결과
0~20초 1차 배치 처리 10건 HTTP 200
30초 커넥션 대기 30초 초과 12건 HTTP 500
20~40초 2차 배치 처리 10건 HTTP 200

32건 중 12건(37.5%) 실패. 동시 요청 20건만 넘어도 실패 시작.

테스트 2: 로그인 사용자 401 오류

부하 테스트 중 웹사이트에서 정상 JWT 토큰으로 API 호출 시:

{
    "httpStatusCode": 401,
    "code": "2001",
    "message": "잘못된 토큰입니다.",
    "data": null
}

토큰은 정상이지만, JwtAuthenticationFilter에서 PrincipalDetailsService.loadUserByUsername()
DB 조회를 시도할 때 커넥션을 못 얻어 예외가 발생하고, 이것이 인증 실패(401)로 처리된다.

JwtAuthenticationFilter.doFilterInternal()
  → jwtUtil.validateTokenOrThrow(token)             ✅ 토큰 유효
  → principalDetailsService.loadUserByUsername(id)
    → memberRepository.findById(id)                  ← 커넥션 필요
    → SQLTransientConnectionException               💥 커넥션 풀 고갈
  → catch(Exception e)                              ← "UNKNOWN_ERROR"로 처리
  → 401 "잘못된 토큰입니다"

사용자 입장: 갑자기 로그아웃됨. 실제 원인(커넥션 부족)을 알 수 없음.

장애 단계 정리

단계 병목 한계 현상
1단계 DB 커넥션 풀 (10개) 동시 20건 500 에러 + 401 토큰 오류
2단계 Tomcat 스레드 (200개) 동시 200건 서비스 전체 응답 불가

해결 방향

1단계: 응답 지연 완화

  • RestClient timeout 설정 (connectTimeout 5초, readTimeout 10초)
  • 메일 발송을 비동기로 전환 (@Async 또는 이벤트 기반) — 사용자에게 즉시 응답
    • 이메일 변경, 팀 초대, 수동 추천, 스케줄러 이메일 발송 모두 해당
    • 수동 추천의 경우 SolvedAc 호출 + DB 저장은 동기 유지 (결과를 즉시 보여줘야 함), 메일만 비동기
  • 스케줄러 배치 병렬 처리 검토

2단계: DB 커넥션 풀 보호

  • 외부 API 호출을 트랜잭션 밖으로 분리
  • 스케줄러 배치 트랜잭션을 팀별로 분리 (REQUIRES_NEW)
  • JwtAuthenticationFilter에서 커넥션 실패와 토큰 오류 구분

3단계: 장애 전파 차단

  • 서킷브레이커 도입 (외부 API 장애 시 빠른 실패)
  • SolvedAc 호출 전용 스레드풀 분리

참고

OSIV (Open Session In View)

Spring Boot에서 기본 활성화(spring.jpa.open-in-view: true)되어 있다.
OSIV는 요청 전체에 Hibernate Session을 열어두지만, 커넥션은 lazy acquisition이라
쿼리가 없으면 커넥션을 잡지 않는다.
현재 문제는 @transactional이 직접적 원인이므로, 트랜잭션 분리가 우선이다.
OSIV 비활성화는 별도로 검토.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions