-
Notifications
You must be signed in to change notification settings - Fork 2
Description
요약
외부 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 비활성화는 별도로 검토.